Skip to content
Merged
4 changes: 4 additions & 0 deletions changelog.d/letta-native-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Added
- Chat shows a Letta Code pane's conversation, read from Letta's own transcript, when herdr's Letta
integration reports it and Letta's own record names the same conversation; otherwise the
terminal's text stays.
2 changes: 1 addition & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c
| **Hermes** | Native SQLite session store | — use Terminal |
| **Muse Code** | **Linux untested with real Muse.** Native session log, bound to the pane by the session lock Muse writes for its process; panes in one folder each show their own session. macOS and Linux; Windows uses Terminal. See [Muse chat](muse-chat.md). | — use Terminal |
| **Antigravity (agy)** | **Experimental; macOS untested.** Native ledger, selected by the running process’s held conversation lock. Linux native-tested; Windows uses terminal text. See [Agy behavior and limits](agy-chat.md). | — use Terminal |
| **Letta Code** | The terminal's text. Choose **Letta Code** in **New workspace**, **New tab with agent…** or **New worktree** where `letta` is installed, or run `letta` in a herdr pane's **Terminal**. | — use Terminal |
| **Letta Code** | **Experimental; Linux and macOS.** Read-only native transcript from where the pane's own `letta` process keeps it (`~/.letta/transcripts`, or its `LETTA_CONFIG_DIR` / `LETTA_TRANSCRIPT_ROOT`), when herdr's Letta integration is installed (`herdr integration install letta`, experimental), it names a conversation for the pane, and Letta's own record in the project folder (`.letta/settings.local.json`) names the same one, is older than that `letta` process and is writable by it, and `letta` is still in the folder it started in (for a local-backend conversation, Letta's local store must also name that agent for it). That holds when `letta` resumes the conversation it last had in that folder (`letta`, or `letta --conversation <id>` on that one). After `/new` or `/resume`, a start on another conversation (`--new` included), once `letta` has moved to another folder (its SetWorkingDirectory or EnterWorktree tools), another `letta` writing that record, while another pane runs `letta` in the same folder, with two panes on one conversation, in the home folder, or before the conversation's first turn on this PC ends, Chat uses the terminal's text until `letta` starts again on that conversation. Letta writes a turn when it ends: a turn appears once it is done, and a cancelled one never does. Only turns Letta Code wrote on this PC show. Choose **Letta Code** in **New workspace**, **New tab with agent…** or **New worktree** where `letta` is installed, or run `letta` in a herdr pane's **Terminal**. Windows uses Terminal. | — use Terminal |
| **GitHub Copilot CLI** | Linux native transcript when the foreground session is verified. See [Copilot setup and limits](copilot-chat.md). | — recorded choices are read-only; answer questions and approvals in Terminal |
| **Anything else** | The terminal's text | — use Terminal |

Expand Down
2 changes: 2 additions & 0 deletions server/conversation-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ export function parseConversationMetadata(text: string, source: ConversationResp
const metadata: ConversationMetadata = before ? { ...before } : { model: null, reasoning_effort: null };
// Agy's exported rows do not attest the selected model or reasoning effort.
if (source === "agy-transcript") return metadata;
// Letta's transcript names no model or effort
if (source === "letta-transcript") return metadata;
if (source === "copilot-transcript") {
for (const { type, data } of copilotRecords(text)) {
const model = type === "session.start" ? label(data.selectedModel)
Expand Down
2 changes: 1 addition & 1 deletion server/conversation-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class HistoryChanged extends Error {
/** What paneConversation resolved: which store the turns came from, and where they start. */
export type RecognizedConversation = {
activity?: "thinking" | "working" | "idle";
source: "copilot-transcript" | "grok-transcript" | "agy-transcript" | "muse-transcript" | "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "cmd-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "hermes-transcript";
source: "copilot-transcript" | "grok-transcript" | "agy-transcript" | "muse-transcript" | "letta-transcript" | "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "cmd-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "hermes-transcript";
turns: ConversationTurn[];
metadata: ConversationMetadata;
/** `metadata` was read off the agent's own screen (ConversationResponse.screen) */
Expand Down
27 changes: 23 additions & 4 deletions server/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ import { AgyUnavailable, agyCatalog, agyToolOutput, forgetAgyState, parseAgyTran
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 { withinRetentionBudget } from "./retention-budget.ts";
import { forgetAllPiIndexes, forgetPiIndex, piAbandonedTurns, piBranchRecords, piBranchSegments } from "./pi-tree.ts";
import { copilotOffsetOutput, copilotOutput, copilotRootRecord, parseCopilotTranscript } from "./copilot.ts";
Expand Down Expand Up @@ -302,7 +304,7 @@ function applyHistoryBoundary(path: string, stream: TranscriptStream, source: Re
* what is kept and puts it back when it ends, so a page read between its steps never sees half of it.
*/
function* applyHistoryBoundarySteps(path: string, stream: TranscriptStream, source: RecognizedConversation["source"], apart: boolean): Steps<void> {
if (source === "codex-transcript" || source === "grok-transcript" || source === "agy-transcript" || source === "muse-transcript") return;
if (source === "codex-transcript" || source === "grok-transcript" || source === "agy-transcript" || source === "muse-transcript" || source === "letta-transcript") return;
let scan = clearScans.get(path);
if (!scan || scan.id !== stream.id || scan.scanned > stream.length || bytesBefore(stream, scan.scanned) !== scan.tail) {
scan = { id: stream.id, scanned: 0, floor: 0, tail: "", settings: {}, ...(source === "claude-transcript" ? { branch: { last: null, ends: new Map(), answers: [], forks: [] } } : {}) };
Expand Down Expand Up @@ -555,6 +557,12 @@ function parseTurns(source: RecognizedConversation["source"], path: string, text
throw error;
}
}
if (source === "letta-transcript") {
try { return parseLettaTranscript(text); } catch (error) {
if (error instanceof LettaUnavailable) throw new ConversationUnavailable("transcript_missing");
throw error;
}
}
return source === "codex-transcript" ? parseCodexTranscript(text, Infinity)
: source === "claude-transcript" ? parseClaudeTranscript(text, Infinity, { subagents: subagentsOnPage(path, text), noticed })
: parseOmpTranscript(text, Infinity, ompReading(source, path, taskTitles));
Expand Down Expand Up @@ -1274,8 +1282,8 @@ async function resolveTranscript(pane: HerdrPane, cwd: string, codexHome?: strin
);
if (running || await gjcPidUnderShell(info?.process_info?.shell_pid)) agent = "gjc";
}
// a Muse pane's processes are asked for once, for omo and for its session lock
if (agent === "muse" && known === undefined) known = (await paneProcessInfo(paneId).catch(() => undefined)) ?? null;
// a Muse or Letta pane's processes are asked for once, for omo and for its own binding
if ((agent === "muse" || agent === "letta") && known === undefined) known = (await paneProcessInfo(paneId).catch(() => undefined)) ?? null;
if (await paneRunsOmo(paneId, known)) {
return { source: "omo-transcript", path: await omoTranscriptPath(paneId, cwd, panes) };
}
Expand All @@ -1294,6 +1302,13 @@ async function resolveTranscript(pane: HerdrPane, cwd: string, codexHome?: strin
if (path === null) throw new ConversationUnavailable("no_session_path");
return { source: "muse-transcript", path };
}
// herdr's label, not a report an earlier Letta left behind: the binding needs a running one
if (agent === "letta" && pane.agent === "letta") {
// 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 };
}
if (agent === "agy") {
const found = await resolveAgyStore(pane);
if (!found) throw new ConversationUnavailable("no_session_path");
Expand Down Expand Up @@ -1523,7 +1538,7 @@ export async function paneConversation(paneId: string, codexHome?: string, page:
throw error;
}
}
const identity = resolved.source === "agy-transcript" || resolved.source === "copilot-transcript" ? resolved.path
const identity = resolved.source === "agy-transcript" || resolved.source === "copilot-transcript" || resolved.source === "letta-transcript" ? resolved.path
: resolved.source === "claude-transcript" ? nodePath.basename(resolved.path, ".jsonl")
: resolved.source === "pi-transcript" || resolved.source === "cmd-transcript" || resolved.source === "omp-transcript" ? realpathSync(resolved.path) : null;
rememberPaneRead(paneId, resolved.path);
Expand Down Expand Up @@ -2272,6 +2287,10 @@ export function transcriptToolOutput(source: StreamSource, path: string, ref: st
const output = museToolOutput(path, ref);
return output !== null && output.length > TOOL_OUTPUT_MAX ? `${output.slice(0, TOOL_OUTPUT_MAX)}\n… trimmed` : output;
}
if (source === "letta-transcript") {
const output = lettaToolOutput(path, ref);
return output !== null && output.length > TOOL_OUTPUT_MAX ? `${output.slice(0, TOOL_OUTPUT_MAX)}\n… trimmed` : output;
}
if (source === "pi-transcript" || source === "cmd-transcript") return piToolOutput(path, ref);
if (source === "copilot-transcript") {
let fd: number;
Expand Down
18 changes: 18 additions & 0 deletions server/fixtures/letta/local-conversation-0.34.27.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"id": "local-conv-1",
"agent_id": "agent-local-97f4bceb-2f0d-4a16-8b9f-abb2f3d4a72c",
"archived": false,
"archived_at": null,
"created_at": "2026-10-29T01:55:23.137Z",
"updated_at": "2026-10-29T01:59:01.109Z",
"last_message_at": "2026-10-29T01:59:01.109Z",
"summary": "Reply with exactly: second conversation ok",
"in_context_message_ids": [
"ui-msg-10",
"ui-msg-11",
"ui-msg-12",
"ui-msg-13",
"ui-msg-14",
"ui-msg-15"
]
}
13 changes: 13 additions & 0 deletions server/fixtures/letta/settings.local-0.34.27.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"lastAgent": "agent-local-97f4bceb-2f0d-4a16-8b9f-abb2f3d4a72c",
"sessionsByServer": {
"local:/home/user/.letta/lc-local-backend": {
"agentId": "agent-local-97f4bceb-2f0d-4a16-8b9f-abb2f3d4a72c",
"conversationId": "local-conv-1"
}
},
"lastSession": {
"agentId": "agent-local-97f4bceb-2f0d-4a16-8b9f-abb2f3d4a72c",
"conversationId": "local-conv-1"
}
}
5 changes: 5 additions & 0 deletions server/fixtures/letta/transcript-conversation-0.34.27.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{"kind":"user","text":"Reply with exactly: second conversation ok","captured_at":"2026-10-29T01:55:46.561Z","source_line_id":"user-mvsw60vy-gtmdpv"}
{"kind":"assistant","text":"second conversation ok","captured_at":"2026-10-29T01:55:46.561Z","source_line_id":"ui-msg-11","source_message_id":"ui-msg-11"}
{"kind":"user","text":"Use the shell tool to run: cat missing-file.txt. Then tell me in one sentence what happened.","captured_at":"2026-10-29T01:59:01.253Z","source_line_id":"user-mvswa5bi-bfsv6p"}
{"kind":"tool_call","name":"Bash","argsText":"{\"command\":\"cat missing-file.txt\",\"description\":\"Read missing file\"}","resultText":"Exit code: 1\ncat: missing-file.txt: No such file or directory\n","resultOk":false,"captured_at":"2026-10-29T01:59:01.253Z","source_line_id":"Bash-1793239139187262013-3"}
{"kind":"assistant","text":"When I ran the command to read the missing file, it failed because the file does not exist.","captured_at":"2026-10-29T01:59:01.253Z","source_line_id":"ui-msg-15","source_message_id":"ui-msg-15"}
6 changes: 6 additions & 0 deletions server/fixtures/letta/transcript-default-0.34.27.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{"kind":"user","text":"Use your shell tool to run: echo fixture-ok && ls. Then reply in one short sentence.","captured_at":"2026-10-29T01:53:05.293Z","source_line_id":"user-mvsw2ivj-7t557v"}
{"kind":"tool_call","name":"Bash","argsText":"{\"command\":\"echo fixture-ok && ls\",\"description\":\"Run test command to check fixture and list files\"}","resultText":"fixture-ok\n","resultOk":true,"captured_at":"2026-10-29T01:53:05.293Z","source_line_id":"Bash-1793238783145231402-1"}
{"kind":"assistant","text":"I have successfully run the command and verified the environment.","captured_at":"2026-10-29T01:53:05.293Z","source_line_id":"ui-msg-5","source_message_id":"ui-msg-5"}
{"kind":"user","text":"Read notes.txt and tell me its second line.","captured_at":"2026-10-29T01:55:05.021Z","source_line_id":"user-mvsw53jx-6tdo87"}
{"kind":"tool_call","name":"Read","argsText":"{\"file_path\":\"/home/user/proj/notes.txt\"}","resultText":"1\talpha\n2\tbeta\n3\t","resultOk":true,"captured_at":"2026-10-29T01:55:05.021Z","source_line_id":"Read-1793238903065214259-2"}
{"kind":"assistant","text":"The second line of notes.txt is \"beta\".","captured_at":"2026-10-29T01:55:05.021Z","source_line_id":"ui-msg-9","source_message_id":"ui-msg-9"}
Loading
Loading