@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,452 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { mkdirSync } from "node:fs"
3
+ import { basename, join, resolve } from "node:path"
4
+ import { toolLabel } from "../../brief.ts"
5
+ import { exists, gitInfo, isDir } from "../../fsutil.ts"
6
+ import type { Session, Turn } from "../../session/types.ts"
7
+ import { countTurns, firstUserText, unpaired } from "../../session/util.ts"
8
+ import { withSnapshot } from "../../sqlite.ts"
9
+ import { continuedNote } from "../opencode/note.ts"
10
+ import { random62 } from "../opencode/ids.ts"
11
+ import { sh } from "../opencode/writer.ts"
12
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
13
+ import { readHermes } from "./reader.ts"
14
+ import {
15
+ LABEL,
16
+ MAX_SCHEMA,
17
+ MIN_SCHEMA,
18
+ SESSION_ID,
19
+ TESTED_VERSIONS,
20
+ TOOL,
21
+ columnsOf,
22
+ hermesBin,
23
+ hermesCall,
24
+ hermesVersion,
25
+ newSessionId,
26
+ pyJson,
27
+ responseItemId,
28
+ schemaVersion,
29
+ storePath,
30
+ summaryText,
31
+ toolContent,
32
+ } from "./records.ts"
33
+
34
+ // Continues a portable session in Hermes Agent as a new session of its own store, ~/.hermes/state.db, with the
35
+ // rows Hermes itself writes (SessionDB.append_message / _insert_message_rows in hermes_state.py): a user row per
36
+ // prompt, an assistant row per model response (its text, readable reasoning in `reasoning`, its calls as an
37
+ // OpenAI `tool_calls` list), and a `tool` row per result right after the call that asked for it. The note saying
38
+ // where the session came from opens the first prompt the model sees, as Hermes opens a prompt with its own
39
+ // image-fallback notes. A compaction is written as Hermes compacts (archive_and_compact): the turns before the
40
+ // last summary are kept but out of the replay (active = 0, compacted = 1), then the summary as a user message
41
+ // with Hermes's legacy summary prefix (flagged _compressed_summary where the store has that column).
42
+ //
43
+ // session-migrate installs through Hermes's own SessionDB.import_sessions; this writer inserts the same rows
44
+ // itself, in one immediate transaction, setting only columns the target store has, so it writes the schema-19
45
+ // store of Hermes 0.18.2 and the schema-26 store of 0.20.6 alike; the store's own triggers keep its FTS5 indexes
46
+ // in sync. The session is read back inside the transaction and rolled back unless it reads as written. Hermes
47
+ // shares the store between its CLI, gateway and cron processes (WAL, short write transactions with retries), so
48
+ // a new session can go in while Hermes runs, as its own processes do; when the store stays locked the write is
49
+ // refused. The store is backed up first with SQLite's VACUUM INTO, a consistent copy even with Hermes running.
50
+ // A missing store is not created: Hermes creates and migrates its own schema the first time it runs.
51
+
52
+ const NO_RESULT = "No result was recorded for this call; the session moved before it finished."
53
+
54
+ /** The columns the writer cannot do without; anything else is written only where the store has it. */
55
+ const NEEDED = {
56
+ sessions: ["id", "source", "model", "parent_session_id", "started_at", "ended_at", "end_reason", "message_count", "tool_call_count", "cwd", "title"],
57
+ messages: ["id", "session_id", "role", "content", "tool_call_id", "tool_calls", "tool_name", "timestamp", "finish_reason", "reasoning", "active", "compacted"],
58
+ }
59
+
60
+ /** One messages row before it is inserted (columns the store lacks are left out then). */
61
+ export interface MessageRow {
62
+ role: "user" | "assistant" | "tool"
63
+ content: string
64
+ tool_call_id: string | null
65
+ tool_calls: string | null
66
+ tool_name: string | null
67
+ timestamp: number
68
+ finish_reason: string | null
69
+ reasoning: string | null
70
+ observed: 0
71
+ active: 0 | 1
72
+ compacted: 0 | 1
73
+ _compressed_summary: 0 | 1
74
+ }
75
+
76
+ export interface HermesPlan {
77
+ id: string
78
+ /** The title before it is made unique in the store (Hermes keeps titles unique: "title #2"). */
79
+ title: string
80
+ model: string | null
81
+ /** When the session is created (seconds): its start, as its id says, though its messages keep their own times. */
82
+ createdAt: number
83
+ messages: MessageRow[]
84
+ lost: string[]
85
+ /** What a read-back must find among the replayed (active) rows. */
86
+ expect: { users: number; assistants: number; calls: number; reasoning: number; compactions: number }
87
+ }
88
+
89
+ const clean = (s: string) =>
90
+ s
91
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff\ufffc\ufff9-\ufffb]/g, "")
92
+ .replace(/\s+/g, " ")
93
+ .trim()
94
+
95
+ /** The session's title marked with where it came from, within Hermes's 100 characters (with room for " #N"). */
96
+ export function hermesTitle(s: Session, label: string): string {
97
+ const suffix = ` (from ${label})`
98
+ const room = 100 - suffix.length - 5
99
+ const base = clean(s.header.title ?? firstUserText(s) ?? "") || "Continued session"
100
+ return `${base.length > room ? `${base.slice(0, room - 1).trimEnd()}…` : base}${suffix}`
101
+ }
102
+
103
+ /** The rows of a new Hermes session continuing `session`. Pure: reads and writes nothing. */
104
+ export function planHermes(session: Session, opts: { id: string; now: Date }): HermesPlan {
105
+ const drops = new Map<string, number>()
106
+ const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
107
+ const turns = session.turns as Turn[]
108
+ const label = session.header.tool === TOOL ? LABEL : toolLabel(session.header.tool)
109
+ const lastCompaction = turns.map((t) => t.kind).lastIndexOf("compaction")
110
+ const expect = { users: 0, assistants: 0, calls: 0, reasoning: 0, compactions: 0 }
111
+ const messages: MessageRow[] = []
112
+
113
+ // Times are seconds and never go backwards (Hermes replays in id order, but lists and searches by time).
114
+ const known = turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
115
+ let clock = (Date.parse(session.header.startedAt ?? "") || known || opts.now.getTime() - turns.length * 1000) / 1000 - 0.001
116
+ const tick = (at?: string) => {
117
+ const t = at ? Date.parse(at) / 1000 : Number.NaN
118
+ clock = Number.isFinite(t) && t > clock ? t : clock + 0.001
119
+ return clock
120
+ }
121
+ const row = (r: Pick<MessageRow, "role" | "content"> & Partial<MessageRow>, at: number, archived: boolean): MessageRow => ({
122
+ tool_call_id: null,
123
+ tool_calls: null,
124
+ tool_name: null,
125
+ finish_reason: null,
126
+ reasoning: null,
127
+ observed: 0,
128
+ _compressed_summary: 0,
129
+ ...r,
130
+ timestamp: at,
131
+ active: archived ? 0 : 1,
132
+ compacted: archived ? 1 : 0,
133
+ })
134
+
135
+ // The first result of every call, written right after the call (OpenAI order), wherever it sat in the source.
136
+ const callIds = new Set(turns.filter((t) => t.kind === "tool_call").map((t) => (t as Extract<Turn, { kind: "tool_call" }>).id))
137
+ const results = new Map<string, Extract<Turn, { kind: "tool_result" }>>()
138
+ for (const t of turns) if (t.kind === "tool_result" && callIds.has(t.callId) && !results.has(t.callId)) results.set(t.callId, t)
139
+
140
+ // Tool-call ids go to the provider on every replay: ids providers reject are replaced (results follow their call).
141
+ const used = new Set<string>()
142
+ let replaced = 0
143
+ const idFor = (src: string) => {
144
+ let id = /^[A-Za-z0-9_-]{1,64}$/.test(src) && !used.has(src) ? src : ""
145
+ if (!id) {
146
+ replaced++
147
+ do id = `call_${random62(24)}`
148
+ while (used.has(id))
149
+ }
150
+ used.add(id)
151
+ return id
152
+ }
153
+
154
+ const note = continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10))
155
+ let noted = false
156
+ const noteRow = () => {
157
+ messages.push(row({ role: "user", content: note }, tick(), false))
158
+ noted = true
159
+ }
160
+
161
+ type Pending = { src: string; id: string; name: string; terminal: boolean }
162
+ let step: { row: MessageRow; text: string[]; reasoning: string[]; calls: Array<Record<string, unknown>>; pending: Pending[]; archived: boolean } | undefined
163
+ const written = new Set<string>()
164
+ let renamed = 0
165
+
166
+ const open = (archived: boolean, at?: string) => {
167
+ if (step && step.archived !== archived) close()
168
+ if (step) return step
169
+ if (!archived && !noted) noteRow()
170
+ step = { row: row({ role: "assistant", content: "" }, tick(at), archived), text: [], reasoning: [], calls: [], pending: [], archived }
171
+ return step
172
+ }
173
+ function close() {
174
+ if (!step) return
175
+ const { row: r, text, reasoning, calls, pending, archived } = step
176
+ step = undefined
177
+ if (!text.length && !calls.length) {
178
+ drop("reasoning with no reply to attach it to")
179
+ return
180
+ }
181
+ r.content = text.join("\n\n")
182
+ r.reasoning = reasoning.length ? reasoning.join("\n\n") : null
183
+ r.tool_calls = calls.length ? pyJson(calls) : null
184
+ r.finish_reason = calls.length ? "tool_calls" : "stop"
185
+ messages.push(r)
186
+ if (!archived) {
187
+ if (text.length) expect.assistants++
188
+ if (reasoning.length) expect.reasoning++
189
+ expect.calls += calls.length
190
+ }
191
+ for (const p of pending) {
192
+ const res = results.get(p.src)
193
+ if (!res) drop("tool calls with no result, closed as failed")
194
+ const output = res ? (res.fullPath ? `${res.output}\n\n[Full output: ${res.fullPath}]` : res.output) : NO_RESULT
195
+ messages.push(
196
+ row({ role: "tool", content: toolContent(output, res?.ok ?? false, p.terminal), tool_call_id: p.id, tool_name: p.name }, tick(res?.at), archived),
197
+ )
198
+ }
199
+ }
200
+
201
+ for (const [i, t] of turns.entries()) {
202
+ const archived = i < lastCompaction
203
+ switch (t.kind) {
204
+ case "user": {
205
+ // Images travel as their path, which Hermes's vision_analyze tool can open (as its own fallback note does).
206
+ const extra: string[] = []
207
+ for (const a of t.attachments ?? []) {
208
+ if (a.path) {
209
+ extra.push(a.kind === "image" ? `[The user attached an image: ${a.path}]` : `[The user attached a file: ${a.path}]`)
210
+ if (a.kind === "image") drop("images (passed as a file path)")
211
+ } else drop("attachments without their bytes")
212
+ }
213
+ let text = [t.text.trim() ? t.text : "", ...extra].filter(Boolean).join("\n\n")
214
+ if (!text) break
215
+ close()
216
+ if (!archived && !noted) {
217
+ text = `${note}\n\n${text}`
218
+ noted = true
219
+ }
220
+ messages.push(row({ role: "user", content: text }, tick(t.at), archived))
221
+ if (!archived) expect.users++
222
+ break
223
+ }
224
+ case "assistant": {
225
+ if (!t.text.trim()) break
226
+ if (step?.calls.length) close()
227
+ open(archived, t.at).text.push(t.text)
228
+ break
229
+ }
230
+ case "reasoning": {
231
+ if (!t.text.trim()) break
232
+ if (step?.calls.length) close()
233
+ open(archived, t.at).reasoning.push(t.text)
234
+ break
235
+ }
236
+ case "tool_call": {
237
+ if (written.has(t.id)) {
238
+ drop("repeated tool call ids")
239
+ break
240
+ }
241
+ written.add(t.id)
242
+ const st = open(archived, t.at)
243
+ const call = hermesCall(t)
244
+ if (call.mapped) renamed++
245
+ const id = idFor(t.id)
246
+ st.calls.push({ id, call_id: id, response_item_id: responseItemId(id), type: "function", function: { name: call.name, arguments: pyJson(call.args) } })
247
+ st.pending.push({ src: t.id, id, name: call.name, terminal: call.name === "terminal" })
248
+ break
249
+ }
250
+ case "tool_result": {
251
+ if (!callIds.has(t.callId)) drop("tool results without a call")
252
+ else if (results.get(t.callId) !== t) drop("repeated tool results")
253
+ // Its call's response ends here; the result itself is written with the call.
254
+ else if (step?.pending.some((p) => p.src === t.callId)) close()
255
+ break
256
+ }
257
+ case "compaction": {
258
+ close()
259
+ messages.push(row({ role: "user", content: summaryText(t.summary), _compressed_summary: 1 }, tick(t.at), archived))
260
+ if (!archived) expect.compactions++
261
+ break
262
+ }
263
+ case "file_edit":
264
+ if (!t.callId) drop("file edits not made by a tool call")
265
+ break
266
+ case "subagent":
267
+ drop("subagent sessions (their call and result are kept)")
268
+ break
269
+ }
270
+ }
271
+ close()
272
+ if (!noted && messages.length) noteRow()
273
+ if (lastCompaction > 0) drop("turns before the last compaction, kept as Hermes's compacted history (not replayed)", lastCompaction)
274
+
275
+ // Nothing may sort after the next message Hermes writes, which is stamped now.
276
+ const late = (messages.at(-1)?.timestamp ?? 0) - opts.now.getTime() / 1000
277
+ if (late > 0) for (const m of messages) m.timestamp -= late + 0.001
278
+
279
+ const lost = [...drops].map(([what, n]) => `${what} (${n})`)
280
+ if (replaced) lost.push(`tool-call ids replaced with ids providers accept (${replaced})`)
281
+ if (renamed) lost.push(`tool calls renamed to Hermes's own tools (${renamed})`)
282
+ if (session.header.models.length > 1) lost.push(`which model wrote each reply: Hermes keeps one model per session (${session.header.models.length} models)`)
283
+ const usage = turns.filter((t) => t.kind === "assistant" && t.usage).length
284
+ if (usage) lost.push(`token usage (${usage} replies)`)
285
+ return { id: opts.id, title: hermesTitle(session, label), model: session.header.models.at(-1) ?? null, createdAt: opts.now.getTime() / 1000, messages, lost, expect }
286
+ }
287
+
288
+ /** The folder of the git repository `cwd` is in, below the home folder, as Hermes records git_repo_root. */
289
+ function repoRoot(cwd: string, home: string): string | undefined {
290
+ for (let dir = cwd; dir && dir !== "/" && dir !== home; dir = dir.slice(0, dir.lastIndexOf("/"))) if (exists(join(dir, ".git"))) return dir
291
+ return undefined
292
+ }
293
+
294
+ /** Hermes's title rule (get_next_title_in_lineage): a title in use becomes "title #N", one past the highest N. */
295
+ function uniqueTitle(db: Database, title: string): string {
296
+ const esc = title.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")
297
+ const taken = (db.query("SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'").all(title, `${esc} #%`) as Array<{ title: string }>).map((r) => r.title)
298
+ if (!taken.length) return title
299
+ const n = Math.max(1, ...taken.map((t) => Number(t.match(/ #(\d+)$/)?.[1] ?? 1)))
300
+ return `${title} #${n + 1}`
301
+ }
302
+
303
+ /** Why a store cannot take a new session, or nothing when it can. */
304
+ function storeProblem(d: Database, path: string): string | undefined {
305
+ const v = schemaVersion(d)
306
+ if (v === undefined) return `the Hermes store at ${path} has no schema version; it is not a store this writer knows`
307
+ if (v > MAX_SCHEMA) return `the Hermes store at ${path} was migrated by a newer Hermes (schema ${v}; this writer knows ${MIN_SCHEMA} to ${MAX_SCHEMA})`
308
+ if (v < MIN_SCHEMA) return `the Hermes store at ${path} has an older schema (${v}); run hermes once so it migrates its store`
309
+ for (const [table, cols] of Object.entries(NEEDED)) {
310
+ const have = columnsOf(d, table)
311
+ const missing = cols.filter((c) => !have.has(c))
312
+ if (missing.length) return `the Hermes store at ${path} has no ${table}.${missing.join(", ")}; it is not a store this writer knows`
313
+ }
314
+ return undefined
315
+ }
316
+
317
+ const noStore = (path: string) => `Hermes has no session store at ${path} yet; run hermes once so it creates and migrates its own, then try again`
318
+
319
+ const stamp = (d: Date) => d.toISOString().replace(/[:.]/g, "-")
320
+
321
+ /** The Hermes writer; `newId` and `busyMs` are for tests. */
322
+ export function hermesWriter(opts: { newId?: (now: Date) => string; busyMs?: number } = {}): Writer {
323
+ return {
324
+ tool: TOOL,
325
+ label: LABEL,
326
+ way: "native",
327
+
328
+ async ready(o: WriteOptions): Promise<Readiness> {
329
+ const bin = hermesBin(o.home)
330
+ if (!bin) return { ok: false, why: "Hermes Agent is not installed (no hermes on PATH or in ~/.local/bin)" }
331
+ const v = hermesVersion(bin.path)
332
+ if (!v) return { ok: false, why: `\`${bin.path} --version\` did not say which Hermes Agent it is` }
333
+ if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `Hermes Agent ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
334
+ if (!isDir(o.cwd)) return { ok: false, why: `the folder to continue in does not exist: ${o.cwd}` }
335
+ const db = storePath(o.home)
336
+ if (!exists(db)) return { ok: false, why: noStore(db) }
337
+ try {
338
+ const problem = withSnapshot(db, (d) => storeProblem(d, db))
339
+ if (problem) return { ok: false, why: problem }
340
+ } catch (e) {
341
+ return { ok: false, why: `the Hermes store at ${db} cannot be read: ${(e as Error).message}` }
342
+ }
343
+ return { ok: true }
344
+ },
345
+
346
+ async write(session: Session, o: WriteOptions): Promise<WriteResult> {
347
+ const cwd = resolve(o.cwd)
348
+ const now = new Date()
349
+ const db = storePath(o.home)
350
+ const plan = planHermes(session, { id: (opts.newId ?? newSessionId)(now), now })
351
+ if (!SESSION_ID.test(plan.id)) throw new Error(`not a Hermes session id: ${plan.id}`)
352
+ if (!plan.expect.users && !plan.expect.assistants && !plan.expect.calls && !plan.expect.compactions)
353
+ throw new Error("the session has no messages to carry over to Hermes")
354
+ if (!exists(db)) throw new Error(noStore(db))
355
+ const backupDir = join(o.home, ".brain", "backups", TOOL, stamp(now))
356
+ const command = hermesBin(o.home)?.command ?? "hermes"
357
+ const result = (title: string): WriteResult => ({
358
+ tool: TOOL,
359
+ way: "native",
360
+ sessionId: plan.id,
361
+ written: [db, backupDir],
362
+ open: `cd ${sh(cwd)} && ${sh(command)} chat --resume ${plan.id}`,
363
+ note: `Hermes lists it as "${title}".`,
364
+ lost: plan.lost,
365
+ dryRun: !!o.dryRun,
366
+ })
367
+ if (o.dryRun) {
368
+ const problem = withSnapshot(db, (d) => storeProblem(d, db))
369
+ if (problem) throw new Error(problem)
370
+ return result(plan.title)
371
+ }
372
+ return result(insert(db, plan, { cwd, home: o.home, backupDir, busyMs: opts.busyMs ?? 5000, source: session.header.id }))
373
+ },
374
+ }
375
+ }
376
+
377
+ export const hermesNativeWriter = hermesWriter()
378
+
379
+ const isBusy = (e: unknown) => /SQLITE_BUSY|database is locked/i.test(`${(e as { code?: string }).code ?? ""} ${(e as Error).message ?? ""}`)
380
+
381
+ /** Backs the store up, writes the session in one transaction and reads it back before committing. Returns the title. */
382
+ function insert(dbPath: string, plan: HermesPlan, o: { cwd: string; home: string; backupDir: string; busyMs: number; source: string }): string {
383
+ const db = new Database(dbPath)
384
+ try {
385
+ db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(o.busyMs))}`)
386
+ db.exec("PRAGMA foreign_keys = ON")
387
+ const problem = storeProblem(db, dbPath)
388
+ if (problem) throw new Error(problem)
389
+ mkdirSync(o.backupDir, { recursive: true, mode: 0o700 })
390
+ db.exec(`VACUUM INTO '${join(o.backupDir, basename(dbPath)).replace(/'/g, "''")}'`)
391
+
392
+ const sCols = columnsOf(db, "sessions")
393
+ const mCols = columnsOf(db, "messages")
394
+ const git = gitInfo(o.cwd, o.home)
395
+ const root = git.gitBranch ? repoRoot(o.cwd, o.home) : undefined
396
+ const live = plan.messages.filter((m) => m.active)
397
+ let title = plan.title
398
+
399
+ db.transaction(() => {
400
+ if (db.query("SELECT 1 FROM sessions WHERE id = ?").get(plan.id)) throw new Error(`Hermes already has a session ${plan.id}; nothing was written`)
401
+ title = uniqueTitle(db, plan.title)
402
+ const session: Record<string, unknown> = {
403
+ id: plan.id,
404
+ source: "cli",
405
+ model: plan.model,
406
+ started_at: plan.createdAt,
407
+ ended_at: plan.createdAt,
408
+ end_reason: "brain_import",
409
+ message_count: live.length,
410
+ tool_call_count: live.reduce((n, m) => n + (m.tool_calls ? (JSON.parse(m.tool_calls) as unknown[]).length : 0), 0),
411
+ cwd: o.cwd,
412
+ title,
413
+ ...(git.gitBranch ? { git_branch: git.gitBranch } : {}),
414
+ ...(root ? { git_repo_root: root } : {}),
415
+ }
416
+ const keys = Object.keys(session).filter((k) => sCols.has(k))
417
+ db.prepare(`INSERT INTO sessions (${keys.map((k) => `"${k}"`).join(", ")}) VALUES (${keys.map(() => "?").join(", ")})`).run(...(keys.map((k) => session[k] ?? null) as any[]))
418
+ const mKeys = ["session_id", ...Object.keys(plan.messages[0]!)].filter((k) => mCols.has(k))
419
+ const add = db.prepare(`INSERT INTO messages (${mKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${mKeys.map(() => "?").join(", ")})`)
420
+ for (const m of plan.messages) {
421
+ const values: Record<string, unknown> = { ...m, session_id: plan.id }
422
+ add.run(...(mKeys.map((k) => values[k] ?? null) as any[]))
423
+ }
424
+ // Read back as the reader reads Hermes; throwing here rolls everything back.
425
+ const back = readHermes(db, plan.id, { path: dbPath, home: o.home })
426
+ const n = countTurns(back)
427
+ const pairs = unpaired(back)
428
+ const e = plan.expect
429
+ if (
430
+ n.user !== e.users ||
431
+ n.assistant !== e.assistants ||
432
+ n.tool_call !== e.calls ||
433
+ n.tool_result !== e.calls ||
434
+ n.reasoning !== e.reasoning ||
435
+ n.compaction !== e.compactions ||
436
+ pairs.calls.length ||
437
+ pairs.results.length ||
438
+ back.header.parent?.id !== o.source
439
+ )
440
+ throw new Error(
441
+ `the Hermes session did not read back as written (users ${n.user}/${e.users}, replies ${n.assistant}/${e.assistants}, tool calls ${n.tool_call}/${e.calls}, results ${n.tool_result}/${e.calls}, reasoning ${n.reasoning}/${e.reasoning}, summaries ${n.compaction}/${e.compactions}, unpaired ${pairs.calls.length + pairs.results.length}); nothing was kept`,
442
+ )
443
+ }).immediate()
444
+ db.exec("PRAGMA wal_checkpoint(PASSIVE)")
445
+ return title
446
+ } catch (e) {
447
+ if (isBusy(e)) throw new Error(`Hermes is holding its session store (${dbPath} is locked); try again when it is idle. Nothing was written.`)
448
+ throw e
449
+ } finally {
450
+ db.close()
451
+ }
452
+ }
@@ -0,0 +1,72 @@
1
+ import { join } from "node:path"
2
+ import { isDir, ls, mtime } from "../../fsutil.ts"
3
+ import type { Session } from "../../session/types.ts"
4
+ import { header, iso, lossCounter } from "../../session/util.ts"
5
+ import type { Reader, SessionRef, Writer } from "../types.ts"
6
+ import { type IflowRecord, LABEL, readRecords, recordsToTurns, stripNote, TOOL } from "./records.ts"
7
+ import { iflowNativeWriter } from "./writer.ts"
8
+
9
+ // iFlow CLI keeps each project's sessions in ~/.iflow/projects/<project name>/<session id>.jsonl, where the
10
+ // session id is session-<uuid> (records.ts has the format). Its list is every .jsonl in the project's folder,
11
+ // newest activity first; a session has no title, so the list shows its first request.
12
+
13
+ const oneLine = (s: string) => s.replace(/\s+/g, " ").trim()
14
+
15
+ function firstRequest(records: IflowRecord[]): string | undefined {
16
+ for (const r of records) {
17
+ if (r.type !== "user" || r.isMeta || r.toolUseResult || r.isCompactSummary) continue
18
+ const c = r.message.content
19
+ const text = typeof c === "string" ? c : Array.isArray(c) ? c.map((b) => (b.type === "text" ? b.text : "")).join("") : ""
20
+ const t = oneLine(stripNote(text).text)
21
+ if (t) return t.slice(0, 120)
22
+ }
23
+ return undefined
24
+ }
25
+
26
+ export const reader: Reader = {
27
+ tool: TOOL,
28
+ label: LABEL,
29
+
30
+ async list(home) {
31
+ const projects = join(home, ".iflow", "projects")
32
+ if (!isDir(projects)) return []
33
+ const refs: SessionRef[] = []
34
+ for (const project of ls(projects)) {
35
+ for (const name of ls(join(projects, project))) {
36
+ if (!name.endsWith(".jsonl")) continue
37
+ const path = join(projects, project, name)
38
+ const { records } = readRecords(path)
39
+ if (!records.length) continue
40
+ const cwd = records.find((r) => r.cwd)?.cwd
41
+ const title = firstRequest(records)
42
+ const updatedAt = iso(records[records.length - 1]!.timestamp) ?? mtime(path)
43
+ refs.push({ tool: TOOL, sourceId: name.replace(/\.jsonl$/, ""), path, ...(title ? { title } : {}), ...(cwd ? { cwd } : {}), ...(updatedAt ? { updatedAt } : {}), extra: { project } })
44
+ }
45
+ }
46
+ return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
47
+ },
48
+
49
+ async read(ref) {
50
+ const { records, bad } = readRecords(ref.path)
51
+ if (!records.length) throw new Error(`${ref.path} holds no iFlow CLI records`)
52
+ const lost = lossCounter()
53
+ if (bad) lost.add("unreadable lines", bad)
54
+ const first = records[0]!
55
+ const cwd = records.find((r) => r.cwd)?.cwd
56
+ const branch = records.find((r) => r.gitBranch)?.gitBranch
57
+ const title = firstRequest(records)
58
+ const h = header(TOOL, ref.sourceId, {
59
+ sourcePath: ref.path,
60
+ project: { ...(cwd ? { cwd } : {}), ...(branch ? { gitBranch: branch } : {}) },
61
+ ...(title ? { title } : {}),
62
+ ...(iso(first.timestamp) ? { startedAt: iso(first.timestamp) } : {}),
63
+ ...(iso(records[records.length - 1]!.timestamp) ? { updatedAt: iso(records[records.length - 1]!.timestamp) } : {}),
64
+ })
65
+ const turns = recordsToTurns(records, h, lost)
66
+ lost.flush(h)
67
+ return { header: h, turns } satisfies Session
68
+ },
69
+ }
70
+
71
+ export const readers: Reader[] = [reader]
72
+ export const writers: Writer[] = [iflowNativeWriter]