@100xprompt/brain 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +172 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +326 -0
  170. package/src/fsutil.ts +133 -0
  171. package/src/gateway/connect.ts +845 -0
  172. package/src/gateway/signin.ts +323 -0
  173. package/src/handoff.ts +104 -0
  174. package/src/hooks.ts +455 -0
  175. package/src/memory/audit.ts +514 -0
  176. package/src/memory/blobs.ts +94 -0
  177. package/src/memory/cli.ts +117 -0
  178. package/src/memory/distill.ts +381 -0
  179. package/src/memory/externalize.ts +435 -0
  180. package/src/memory/lookups.ts +70 -0
  181. package/src/memory/mcp-install.ts +401 -0
  182. package/src/memory/mcp-server.ts +296 -0
  183. package/src/memory/prepare.ts +179 -0
  184. package/src/memory/preview.ts +154 -0
  185. package/src/memory/project.ts +35 -0
  186. package/src/memory/redact.ts +72 -0
  187. package/src/memory/relevance.ts +187 -0
  188. package/src/memory/repomap.ts +516 -0
  189. package/src/memory/store.ts +270 -0
  190. package/src/memory/sync.ts +166 -0
  191. package/src/memory/tidy.ts +193 -0
  192. package/src/memory/types.ts +118 -0
  193. package/src/org/admin-ops.ts +96 -0
  194. package/src/org/auth.ts +112 -0
  195. package/src/org/backup.ts +115 -0
  196. package/src/org/cli.ts +438 -0
  197. package/src/org/client.ts +404 -0
  198. package/src/org/config.ts +44 -0
  199. package/src/org/crypto.ts +537 -0
  200. package/src/org/db.ts +435 -0
  201. package/src/org/graph.ts +318 -0
  202. package/src/org/oidc.ts +435 -0
  203. package/src/org/policy.ts +398 -0
  204. package/src/org/server.ts +920 -0
  205. package/src/org/sso.ts +316 -0
  206. package/src/org/tls.ts +66 -0
  207. package/src/org/types.ts +193 -0
  208. package/src/self.ts +68 -0
  209. package/src/session/attachments.ts +53 -0
  210. package/src/session/peekcache.ts +50 -0
  211. package/src/session/types.ts +104 -0
  212. package/src/session/util.ts +130 -0
  213. package/src/sqlite.ts +48 -0
@@ -0,0 +1,413 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"
3
+ import { extname, join } from "node:path"
4
+ import { toolLabel } from "../../brief.ts"
5
+ import { exists, isDir, readJson, versionOf } from "../../fsutil.ts"
6
+ import type { Session, Turn } from "../../session/types.ts"
7
+ import { countTurns, firstUserText, unpaired } from "../../session/util.ts"
8
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
9
+ import { lettaReader } from "./reader.ts"
10
+ import {
11
+ LABEL,
12
+ MANIFEST,
13
+ SESSION_HEADER_VERSION,
14
+ TESTED_VERSIONS,
15
+ TOOL,
16
+ continuedNote,
17
+ conversationDirs,
18
+ conversationKey,
19
+ conversationsDir,
20
+ encodeSegment,
21
+ isLocalAgentId,
22
+ localAgents,
23
+ numericSuffix,
24
+ storageDir,
25
+ } from "./records.ts"
26
+
27
+ // Continues a portable session as a new conversation of one of the person's local Letta agents, written
28
+ // the way LocalStore writes a forked conversation: a folder under lc-local-backend/conversations with
29
+ // conversation.json (the title as its summary, every message in context), manifest.json and messages.jsonl
30
+ // (the session header, then one entry per message on a parentId chain). `letta --agent <id> --conversation
31
+ // <id>` opens it with the earlier turns on screen, and /resume lists it under that agent. Letta's server
32
+ // ("cloud") agents keep their conversations on the server, which only takes new messages by running the
33
+ // agent, so they are not a target here.
34
+ //
35
+ // Messages are pi-ai messages, as Letta stores them: one assistant message per model response (its readable
36
+ // reasoning as thinking without a signature, its text, its toolCall blocks), then one toolResult message per
37
+ // call, right after it, since Letta drops a result that does not follow its call. A call without a result is
38
+ // closed as an error. Assistant messages name the source tool as their provider and "brain-session" as their
39
+ // api, so pi-ai treats them as another model's turns (their reasoning goes to the model as text, their call
40
+ // ids are normalised). A compaction is Letta's compaction entry: the summary becomes a user message packed
41
+ // as a system alert, and only it and what follows stay in context. The note saying where the conversation
42
+ // came from is a <system-reminder> in the first user message in context, which Letta keeps off screen.
43
+ //
44
+ // The agent is the one Letta last used for the local store (the project's settings, then the global ones),
45
+ // else the newest visible local agent. The agent's memory stays its own; only the conversation is new.
46
+
47
+ const API = "brain-session"
48
+ const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
49
+ const IMAGE_LIMIT = 5 * 1024 * 1024
50
+ const ZERO_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }
51
+
52
+ type Json = Record<string, any>
53
+ type Call = Extract<Turn, { kind: "tool_call" }>
54
+ type Result = Extract<Turn, { kind: "tool_result" }>
55
+
56
+ const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
57
+
58
+ function realCwd(cwd: string): string {
59
+ try {
60
+ return realpathSync(cwd)
61
+ } catch {
62
+ return cwd
63
+ }
64
+ }
65
+
66
+ /** Tool names and call ids the providers Letta talks to accept (Anthropic's ^[a-zA-Z0-9_-]{1,64}$). */
67
+ const toolName = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "tool"
68
+ const safeId = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "call"
69
+
70
+ /** Tool input as an object, as pi-ai's toolCall.arguments must be; a raw string or array is wrapped. */
71
+ const objectInput = (input: unknown): Json => (input && typeof input === "object" && !Array.isArray(input) ? (input as Json) : { input: input ?? null })
72
+
73
+ /** Letta's packageLocalSummaryMessage for an "all" compaction. */
74
+ const packedSummary = (summary: string, time: string) =>
75
+ JSON.stringify({
76
+ type: "system_alert",
77
+ message: `Note: prior messages have been hidden from view due to conversation memory constraints.\nThe following is a summary of the previous messages:\n ${summary}`,
78
+ time,
79
+ })
80
+
81
+ /** The local agent to file the conversation under: the last one Letta used here, else the newest visible one. */
82
+ export function pickAgent(home: string, cwd: string): string | undefined {
83
+ const agents = localAgents(home)
84
+ const known = new Set(agents.map((a) => a.id))
85
+ const last = (settings: Json | undefined): string | undefined => {
86
+ if (!settings) return undefined
87
+ for (const [server, s] of Object.entries((settings.sessionsByServer ?? {}) as Record<string, Json>))
88
+ if (server.startsWith("local:") && known.has(s?.agentId)) return s.agentId
89
+ for (const id of [settings.lastSession?.agentId, settings.lastAgent]) if (isLocalAgentId(id) && known.has(id)) return id
90
+ return undefined
91
+ }
92
+ return (
93
+ last(readJson<Json>(join(cwd, ".letta", "settings.local.json"))) ??
94
+ last(readJson<Json>(join(home, ".letta", "settings.json"))) ??
95
+ agents.find((a) => !a.hidden)?.id
96
+ )
97
+ }
98
+
99
+ export interface ConversationPlan {
100
+ /** conversation.json */
101
+ record: Json
102
+ /** messages.jsonl rows: the session header, then message and compaction entries. */
103
+ rows: Json[]
104
+ title: string
105
+ lost: string[]
106
+ /** What a read-back must find. */
107
+ expect: { users: number; assistants: number; calls: number }
108
+ }
109
+
110
+ /** The files of new conversation `conversationId` of `agentId` continuing `session`. Pure apart from reading attached images. */
111
+ export function planConversation(
112
+ session: Session,
113
+ opts: { agentId: string; conversationId: string; firstMessageSeq: number; now: Date; cwd: string },
114
+ ): ConversationPlan {
115
+ const drops = new Map<string, number>()
116
+ const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
117
+ const label = toolLabel(session.header.tool)
118
+ const expect = { users: 0, assistants: 0, calls: 0 }
119
+ const nowIso = opts.now.toISOString()
120
+
121
+ let seq = opts.firstMessageSeq
122
+ const messageId = () => `ui-msg-${seq++}`
123
+ // Timestamps never go backwards, and fall back to just before now for turns that carry none.
124
+ const known = session.turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
125
+ let clock = (known ?? opts.now.getTime()) - 1
126
+ const stamp = (at?: string) => {
127
+ const t = at ? Date.parse(at) : Number.NaN
128
+ clock = Number.isFinite(t) && t > clock ? t : clock + 1
129
+ return clock
130
+ }
131
+ const meta = (ms: number) => ({ created_at: new Date(ms).toISOString(), updated_at: new Date(ms).toISOString(), agent_id: opts.agentId, conversation_id: opts.conversationId })
132
+
133
+ // Calls are answered right after the message holding them; results are looked up by call id.
134
+ const results = new Map<string, Result>()
135
+ for (const t of session.turns) if (t.kind === "tool_result" && !results.has(t.callId)) results.set(t.callId, t)
136
+ const ids = new Map<string, string>()
137
+ const used = new Set<string>()
138
+ let remapped = 0
139
+ const idFor = (id: string) => {
140
+ let v = ids.get(id)
141
+ if (v) return v
142
+ v = safeId(id)
143
+ for (let n = 2; used.has(v); n++) v = `${safeId(id)}_${n}`
144
+ ids.set(id, v)
145
+ used.add(v)
146
+ if (v !== id) remapped++
147
+ return v
148
+ }
149
+
150
+ // Items in order: messages, and compaction markers (the summary message they carry).
151
+ type Item = { kind: "message"; message: Json } | { kind: "compaction"; summary: string; message: Json }
152
+ const items: Item[] = []
153
+ let cur: { msg: Json; calls: Call[] } | undefined
154
+ const flush = () => {
155
+ if (!cur) return
156
+ const { msg, calls } = cur
157
+ cur = undefined
158
+ if (!msg.content.length) return
159
+ msg.stopReason = calls.length ? "toolUse" : "stop"
160
+ items.push({ kind: "message", message: msg })
161
+ for (const call of calls) {
162
+ const r = results.get(call.id)
163
+ const ts = stamp(r?.at)
164
+ const output = r ? (r.fullPath ? `${r.output}\n\n[Full output: ${r.fullPath}]` : r.output) : "No result was recorded for this call; the session moved on before it finished."
165
+ if (!r) drop("tool calls without a result (closed as errors)")
166
+ items.push({
167
+ kind: "message",
168
+ message: {
169
+ id: messageId(),
170
+ role: "toolResult",
171
+ toolCallId: idFor(call.id),
172
+ toolName: toolName(call.name),
173
+ content: [{ type: "text", text: output.trim() ? output : "(no output)" }],
174
+ isError: r ? !r.ok : true,
175
+ timestamp: ts,
176
+ metadata: meta(ts),
177
+ },
178
+ })
179
+ }
180
+ }
181
+ const assistant = (at: string | undefined, model: string | undefined) => {
182
+ if (!cur) {
183
+ const ts = stamp(at)
184
+ cur = {
185
+ msg: { id: messageId(), role: "assistant", content: [], api: API, provider: session.header.tool, model: model ?? session.header.models.at(-1) ?? "unknown", usage: ZERO_USAGE, stopReason: "stop", timestamp: ts, metadata: meta(ts) },
186
+ calls: [],
187
+ }
188
+ }
189
+ return cur
190
+ }
191
+ const hasCalls = () => !!cur?.calls.length
192
+ const seen = new Set<string>()
193
+
194
+ for (const t of session.turns) {
195
+ switch (t.kind) {
196
+ case "user": {
197
+ flush()
198
+ const content: Json[] = []
199
+ let text = t.text
200
+ for (const a of t.attachments ?? []) {
201
+ if (!a.path) continue // bytes the reader could not keep; it already counted them
202
+ if (a.kind === "file") {
203
+ text += `\n\n[Attached file: ${a.path}]`
204
+ continue
205
+ }
206
+ const mime = a.mime ?? IMAGE_TYPES[extname(a.path).toLowerCase()]
207
+ let size = Number.POSITIVE_INFINITY
208
+ try {
209
+ size = statSync(a.path).size
210
+ } catch {}
211
+ if (mime && Object.values(IMAGE_TYPES).includes(mime) && size <= IMAGE_LIMIT) content.push({ type: "image", data: readFileSync(a.path).toString("base64"), mimeType: mime })
212
+ else drop("images Letta cannot hold")
213
+ }
214
+ if (!text.trim() && !content.length) break
215
+ if (text.trim()) content.unshift({ type: "text", text })
216
+ const ts = stamp(t.at)
217
+ items.push({ kind: "message", message: { id: messageId(), role: "user", content, timestamp: ts, metadata: meta(ts) } })
218
+ if (text.trim()) expect.users++
219
+ break
220
+ }
221
+ case "assistant": {
222
+ // Text after a call is a new response: it was written after the call's result came back.
223
+ if (hasCalls()) flush()
224
+ if (!t.text.trim()) break
225
+ assistant(t.at, t.model).msg.content.push({ type: "text", text: t.text })
226
+ expect.assistants++
227
+ break
228
+ }
229
+ case "reasoning":
230
+ if (hasCalls()) flush()
231
+ if (t.text.trim()) assistant(t.at, undefined).msg.content.push({ type: "thinking", thinking: t.text })
232
+ break
233
+ case "tool_call": {
234
+ if (seen.has(t.id)) {
235
+ drop("repeated tool call ids")
236
+ break
237
+ }
238
+ seen.add(t.id)
239
+ const name = toolName(t.name)
240
+ if (name !== t.name) drop("tool names changed to fit Letta")
241
+ const a = assistant(t.at, undefined)
242
+ a.msg.content.push({ type: "toolCall", id: idFor(t.id), name, arguments: objectInput(t.input) })
243
+ a.calls.push(t)
244
+ expect.calls++
245
+ break
246
+ }
247
+ case "tool_result":
248
+ if (!seen.has(t.callId)) drop("tool results without a call")
249
+ break
250
+ case "compaction": {
251
+ flush()
252
+ const ts = stamp(t.at)
253
+ const summary = `[Summary of the earlier conversation in ${label}, which replaced the turns before it]\n\n${t.summary}`
254
+ items.push({
255
+ kind: "compaction",
256
+ summary,
257
+ message: { id: messageId(), role: "user", content: [{ type: "text", text: packedSummary(summary, new Date(ts).toISOString()) }], timestamp: ts, metadata: { ...meta(ts), compaction: { summary } } },
258
+ })
259
+ break
260
+ }
261
+ case "file_edit":
262
+ if (!t.callId || !seen.has(t.callId)) drop("file edits outside a tool call")
263
+ break
264
+ case "subagent":
265
+ drop("subagent sessions (their results stay in the parent's tool results)")
266
+ break
267
+ }
268
+ }
269
+ flush()
270
+ for (const t of session.turns) if (t.kind === "tool_result" && results.get(t.callId) !== t) drop("repeated tool results")
271
+ if (!items.some((i) => i.kind === "message")) throw new Error("nothing Letta can show: the session has no messages or tool calls")
272
+
273
+ // Context starts at the last compaction; the note goes in the first user message there (or opens it).
274
+ const last = items.map((i) => i.kind).lastIndexOf("compaction")
275
+ const note = { type: "text", text: continuedNote(label, session.header.id, nowIso.slice(0, 10)) }
276
+ const firstUser = items.findIndex((i, k) => k > last && i.kind === "message" && i.message.role === "user")
277
+ if (firstUser >= 0) (items[firstUser] as { message: Json }).message.content.push(note)
278
+ else {
279
+ const ts = stamp(undefined)
280
+ items.splice(last + 1, 0, { kind: "message", message: { id: messageId(), role: "user", content: [note], timestamp: ts, metadata: meta(ts) } })
281
+ }
282
+
283
+ // Letta shows each part of a message 1 ms after the one before it and sorts by time, so every message
284
+ // starts after the last part of the one before it.
285
+ let next = 0
286
+ for (const { message: m } of items) {
287
+ m.timestamp = Math.max(m.timestamp, next)
288
+ m.metadata.created_at = m.metadata.updated_at = new Date(m.timestamp).toISOString()
289
+ next = m.timestamp + Math.max(1, m.role === "assistant" ? m.content.length : 1)
290
+ }
291
+
292
+ // The transcript: header, then entries on one parentId chain with 8-hex ids, as LocalStore appends them.
293
+ const rows: Json[] = [{ type: "session", version: SESSION_HEADER_VERSION, id: opts.conversationId, timestamp: nowIso, cwd: opts.cwd }]
294
+ const usedEntries = new Set<string>()
295
+ const entryId = () => {
296
+ let v = randomUUID().slice(0, 8)
297
+ while (usedEntries.has(v)) v = randomUUID().slice(0, 8)
298
+ usedEntries.add(v)
299
+ return v
300
+ }
301
+ let parentId: string | null = null
302
+ for (const item of items) {
303
+ const id = entryId()
304
+ const timestamp = new Date(item.message.timestamp).toISOString()
305
+ // A compaction keeps nothing from before it, so its firstKeptEntryId is null.
306
+ rows.push(
307
+ item.kind === "message"
308
+ ? { type: "message", id, parentId, timestamp, message: item.message }
309
+ : { type: "compaction", id, parentId, timestamp, summary: item.summary, firstKeptEntryId: null, tokensBefore: 0, message: item.message },
310
+ )
311
+ parentId = id
312
+ }
313
+
314
+ const inContext = items.slice(last >= 0 ? last : 0).map((i) => i.message.id as string)
315
+ const lastAt = new Date(items.at(-1)!.message.timestamp).toISOString()
316
+ const firstText = firstUserText(session)?.replace(/\s+/g, " ").trim().slice(0, 60)
317
+ const title = `${session.header.title ?? firstText ?? "Session"} (from ${label})`
318
+ const record: Json = {
319
+ id: opts.conversationId,
320
+ agent_id: opts.agentId,
321
+ archived: false,
322
+ archived_at: null,
323
+ created_at: nowIso,
324
+ updated_at: nowIso,
325
+ last_message_at: lastAt,
326
+ summary: title,
327
+ in_context_message_ids: inContext,
328
+ }
329
+ const lost = [...drops].map(([what, n]) => `${what} (${n})`)
330
+ if (remapped) lost.push(`tool call ids, changed to fit Letta (${remapped})`)
331
+ return { record, rows, title, lost, expect }
332
+ }
333
+
334
+ /** The next free local-conv-<n> and ui-msg-<n>, past every conversation already on disk. */
335
+ export function nextIds(home: string): { conversationId: string; firstMessageSeq: number } {
336
+ let conv = 0
337
+ let msg = 0
338
+ for (const { key, record } of conversationDirs(home)) {
339
+ if (key.startsWith("conversation:")) conv = Math.max(conv, numericSuffix(key.slice("conversation:".length), "local-conv-"))
340
+ for (const id of Array.isArray(record?.in_context_message_ids) ? record.in_context_message_ids : []) msg = Math.max(msg, numericSuffix(id, "ui-msg-"))
341
+ }
342
+ for (let n = conv + 1; ; n++) {
343
+ const conversationId = `local-conv-${n}`
344
+ if (!exists(join(conversationsDir(home), encodeSegment(conversationKey(conversationId, ""))))) return { conversationId, firstMessageSeq: msg + 1 }
345
+ }
346
+ }
347
+
348
+ const semver = (v: string | undefined) => v?.match(/(\d+\.\d+\.\d+)/)?.[1]
349
+
350
+ /** The Letta writer; `version` is for tests, which must not depend on what is installed. */
351
+ export function lettaWriter(opts: { version?: string } = {}): Writer {
352
+ const installed = () => opts.version ?? semver(versionOf("letta"))
353
+ return {
354
+ tool: TOOL,
355
+ label: LABEL,
356
+ way: "native",
357
+
358
+ async ready(w: WriteOptions): Promise<Readiness> {
359
+ const v = installed()
360
+ if (!v) return { ok: false, why: "Letta Code is not installed (no `letta` on the PATH)" }
361
+ if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `Letta Code ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
362
+ if (!isDir(storageDir(w.home)))
363
+ return { ok: false, why: "Letta has no local store here: its agents live on the Letta server, which only adds messages by running the agent. Create a local agent with `letta --backend local` first" }
364
+ if (!pickAgent(w.home, w.cwd)) return { ok: false, why: "Letta's local store has no agent to file the conversation under; create one with `letta --backend local` first" }
365
+ if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder` }
366
+ return { ok: true }
367
+ },
368
+
369
+ async write(session: Session, w: WriteOptions): Promise<WriteResult> {
370
+ const now = new Date()
371
+ const cwd = realCwd(w.cwd)
372
+ const agentId = pickAgent(w.home, cwd)
373
+ if (!agentId) throw new Error("Letta's local store has no agent to file the conversation under; create one with `letta --backend local` first")
374
+ const { conversationId, firstMessageSeq } = nextIds(w.home)
375
+ const plan = planConversation(session, { agentId, conversationId, firstMessageSeq, now, cwd })
376
+ const dir = join(conversationsDir(w.home), encodeSegment(conversationKey(conversationId, agentId)))
377
+ const files = [join(dir, "conversation.json"), join(dir, "manifest.json"), join(dir, "messages.jsonl")]
378
+ if (!w.dryRun) {
379
+ mkdirSync(dir, { recursive: true })
380
+ writeFileSync(files[1]!, `${JSON.stringify({ ...MANIFEST, created_at: now.toISOString() }, null, 2)}\n`)
381
+ writeFileSync(files[2]!, `${plan.rows.map((r) => JSON.stringify(r)).join("\n")}\n`)
382
+ // The record last: Letta lists a conversation by its record, so it never sees a half-written one.
383
+ writeFileSync(files[0]!, `${JSON.stringify(plan.record, null, 2)}\n`)
384
+ let problem: string | undefined
385
+ try {
386
+ const back = await lettaReader.read({ tool: TOOL, sourceId: conversationKey(conversationId, agentId), path: dir }, w.home)
387
+ const n = countTurns(back)
388
+ const pairs = unpaired(back)
389
+ if (n.user !== plan.expect.users || n.assistant !== plan.expect.assistants || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length)
390
+ problem = `users ${n.user}/${plan.expect.users}, replies ${n.assistant}/${plan.expect.assistants}, tool calls ${n.tool_call}/${plan.expect.calls}, unpaired ${pairs.calls.length + pairs.results.length}`
391
+ } catch (e) {
392
+ problem = e instanceof Error ? e.message : String(e)
393
+ }
394
+ if (problem) {
395
+ rmSync(dir, { recursive: true, force: true })
396
+ throw new Error(`the new Letta conversation did not read back as written (${problem}); it was removed`)
397
+ }
398
+ }
399
+ return {
400
+ tool: TOOL,
401
+ way: "native",
402
+ sessionId: conversationId,
403
+ written: files,
404
+ open: `cd ${shellQuote(cwd)} && letta --agent ${agentId} --conversation ${conversationId}`,
405
+ note: `Filed under the local agent ${agentId}; /resume lists it as "${plan.title}".`,
406
+ lost: plan.lost,
407
+ dryRun: !!w.dryRun,
408
+ }
409
+ },
410
+ }
411
+ }
412
+
413
+ export const lettaNativeWriter = lettaWriter()
@@ -0,0 +1,8 @@
1
+ import type { Reader, Writer } from "../types.ts"
2
+ import { mastracodeReader } from "./reader.ts"
3
+ import { mastracodeWriter } from "./writer.ts"
4
+
5
+ // Mastra Code (the mastracode CLI): reads every thread of its local mastra.db, and continues any session as a
6
+ // new native thread in that store (with MastraCode closed, after a backup). Ported from session-migrate (MIT).
7
+ export const readers: Reader[] = [mastracodeReader]
8
+ export const writers: Writer[] = [mastracodeWriter]