Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/letta-model-pill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added
- A Letta Code pane on Letta's local backend shows its model and reasoning effort in the composer,
as Letta runs the turn: the conversation's own `/model` choice, else its agent's.
5 changes: 5 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,11 @@ until `letta` starts again on that conversation. In the home folder it always do
while another pane runs `letta` in the same folder or reports the same conversation, and until the conversation's
first turn on this PC has ended; Chat comes back by itself once that is no longer so.

With Letta's local backend, the composer shows the model and reasoning effort the conversation
runs with, read from Letta's local store (`lc-local-backend`): the conversation's own `/model`
choice, else its agent's. A cloud conversation keeps those on Letta's server, so its composer shows
none.

Letta writes a turn only when it ends, so a turn appears once it is done, and a cancelled one
never does. While it runs, Chat shows the turns written so far and **Working…**, and a message you
sent stays a faded bubble for a minute at most; the Terminal shows the turn as it happens. Only
Expand Down
18 changes: 15 additions & 3 deletions server/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import { resolveAgyStore, sameAgyStore, type AgyStore } from "./agy-store.ts";
import { museSession, museToolOutput, MuseUnavailable, parseMuseTranscript } from "./muse.ts";
import { resolveMuseSession } from "./muse-store.ts";
import { LettaUnavailable, lettaToolOutput, parseLettaTranscript } from "./letta.ts";
import { resolveLettaTranscript } from "./letta-store.ts";
import { lettaLocalSettings, resolveLettaTranscript } from "./letta-store.ts";
import { withinRetentionBudget } from "./retention-budget.ts";
import { forgetAllPiIndexes, forgetPiIndex, piAbandonedTurns, piBranchRecords, piBranchSegments } from "./pi-tree.ts";
import { COPILOT_IMAGE_REF, copilotImage, copilotOffsetOutput, copilotOutput, copilotRootRecord, parseCopilotTranscript } from "./copilot.ts";
Expand Down Expand Up @@ -1406,7 +1406,7 @@ async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[

/** Where a pane's conversation is: a transcript file, or a session in OpenCode's database. */
type ResolvedTranscript =
| ({ path: string; codexHome?: string; agy?: AgyStore; piAgentDir?: string | null; ompBlobs?: string | null } & (
| ({ path: string; codexHome?: string; agy?: AgyStore; piAgentDir?: string | null; ompBlobs?: string | null; letta?: { store: string; agent: string; conversation: string } } & (
| { source: Exclude<StreamSource, "copilot-transcript"> }
| { source: "copilot-transcript"; binding: string; peerClaims: ReadonlySet<string> }
))
Expand Down Expand Up @@ -1479,7 +1479,7 @@ async function resolveTranscript(pane: HerdrPane, cwd: string, codexHome?: strin
// no file yet is no proof the conversation has no turns (one begun elsewhere): Terminal stands in
const found = await resolveLettaTranscript(pane, panes ?? [pane], known);
if (found === null) throw new ConversationUnavailable("no_session_path");
return { source: "letta-transcript", path: found.path };
return { source: "letta-transcript", path: found.path, letta: { store: found.store, agent: found.agent, conversation: found.conversation } };
}
if (agent === "agy") {
const found = await resolveAgyStore(pane);
Expand Down Expand Up @@ -1782,6 +1782,18 @@ export async function paneConversation(paneId: string, codexHome?: string, page:
version: answerVersion(answer.version, model),
};
}
if (resolved.source === "letta-transcript" && resolved.letta) {
// Letta's transcript names no model, and /model writes no turn: its local store holds the
// setting (lettaLocalSettings). Enrich a response, never the shared transcript page, and include
// the setting in its ETag.
const { store, agent, conversation } = resolved.letta;
const settings = lettaLocalSettings(store, agent, conversation);
if (settings !== null && (settings.model !== null || settings.reasoning_effort !== null)) return {
...answer,
metadata: { ...answer.metadata, ...settings },
version: answerVersion(answer.version, `${settings.model}\0${settings.reasoning_effort}`),
};
}
if (resolved.source === "grok-transcript") {
try { return { ...answer, activity: grokProjection(resolved.path, statSync(resolved.path)).activity }; }
catch { throw new ConversationUnavailable("transcript_missing"); }
Expand Down
54 changes: 47 additions & 7 deletions server/letta-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*/
import { accessSync, closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync, realpathSync } from "node:fs";
import { basename, isAbsolute, join, normalize } from "node:path";
import type { HerdrPane } from "../shared/protocol.ts";
import type { ConversationMetadata, HerdrPane } from "../shared/protocol.ts";
import { darwinProcess } from "./claude-store.ts";
import { paneProcessInfo, type ProcessInfo } from "./herdr/client.ts";
import { processStartLowerBound } from "./process-start.ts";
Expand Down Expand Up @@ -78,12 +78,17 @@ export function parseEnviron(text: string): Env {
return env;
}

/** O_NONBLOCK, so a FIFO put where a record was is not waited on; O_NOFOLLOW, so a link there is refused. */
const RECORD_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0);

/** The text of a regular file of at most `bytes`, opened through no final link; null otherwise. */
function readBounded(path: string, bytes: number): string | null {
let fd: number;
try { fd = openSync(path, "r"); } catch { return null; }
try { fd = openSync(path, RECORD_FLAGS); } catch { return null; }
try {
const size = fstatSync(fd).size;
if (size > bytes) return null;
const stat = fstatSync(fd);
if (!stat.isFile() || stat.size > bytes) return null;
const size = stat.size;
const buffer = Buffer.alloc(size);
return buffer.subarray(0, readSync(fd, buffer, 0, size, 0)).toString("utf8");
} catch { return null; } finally { closeSync(fd); }
Expand Down Expand Up @@ -282,13 +287,48 @@ export function lettaLocalOwner(store: string, agent: string, conversation: stri
} catch { return false; }
}

/** The transcript a Letta pane shows, written and held as lettaTranscriptHeld says, or null. */
export async function resolveLettaTranscript(pane: HerdrPane, panes: readonly HerdrPane[], known?: ProcessInfo | null): Promise<LettaTranscript | null> {
const recordAt = (path: string): Record<string, unknown> | null => {
const text = readBounded(path, SETTINGS_BYTES);
if (text === null) return null;
try {
const parsed: unknown = JSON.parse(text);
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
} catch { return null; }
};
const fields = (value: unknown): Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const word = (value: unknown): string | null => typeof value === "string" && value.trim() !== "" ? value.trim() : null;

/**
* The model and reasoning effort Letta runs a local conversation with, as its local backend resolves
* them for a turn: the conversation's own `model` (what `/model` sets in it), else its agent's, and the
* conversation's `model_settings` over the agent's, whose effort Letta reads (reasoningForSettings) as
* `reasoning.reasoning_effort`, else `effort` (what it writes for Anthropic and Bedrock), else
* `reasoning_effort`. Letta's transcript names neither, and `/model` writes no turn. Null for a cloud
* conversation, whose settings live on the server, and for records that do not read as Letta writes
* them, name another agent, or (a local conversation's) another conversation.
*/
export function lettaLocalSettings(store: string, agent: string, conversation: string): ConversationMetadata | null {
const key = conversation === "default" ? `default:${agent}` : conversation.startsWith("local-conv-") ? `conversation:${conversation}` : null;
if (key === null) return null;
const own = recordAt(join(store, "conversations", Buffer.from(key).toString("base64url"), "conversation.json"));
const base = recordAt(join(store, "agents", `${Buffer.from(agent).toString("base64url")}.json`));
if (own === null || base === null || own["agent_id"] !== agent || base["id"] !== agent) return null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (conversation !== "default" && own["id"] !== conversation) return null;
const settings = { ...fields(base["model_settings"]), ...fields(own["model_settings"]) };
return {
model: word(own["model"]) ?? word(base["model"]),
reasoning_effort: word(fields(settings["reasoning"])["reasoning_effort"] ?? settings["effort"] ?? settings["reasoning_effort"]),
};
}

/** The transcript a Letta pane shows, written and held as lettaTranscriptHeld says, and its process's local store; or null. */
export async function resolveLettaTranscript(pane: HerdrPane, panes: readonly HerdrPane[], known?: ProcessInfo | null): Promise<(LettaTranscript & { store: string }) | null> {
const owner = await paneLettaProcess(pane.pane_id, known);
if (owner === null || !lettaInStartFolder(owner) || !lettaRecordOwned(pane, panes, owner)) return null;
const found = lettaTranscriptFor(pane, panes, owner);
const store = lettaLocalStore(owner.env);
const root = lettaTranscriptRoot(owner.env);
if (found === null || store === null || root === null || !lettaLocalOwner(store, found.agent, found.conversation)) return null;
return lettaTranscriptHeld(found.path, root) ? found : null;
return lettaTranscriptHeld(found.path, root) ? { ...found, store } : null;
}
Loading
Loading