@100xprompt/brain 0.0.0-stage → 0.1.1

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 (260) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +235 -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 +351 -0
  170. package/src/dashboard/cli.ts +148 -0
  171. package/src/dashboard/conversions.ts +170 -0
  172. package/src/dashboard/report.ts +261 -0
  173. package/src/dashboard/send.ts +213 -0
  174. package/src/dashboard/state.ts +95 -0
  175. package/src/fsutil.ts +133 -0
  176. package/src/gateway/common.ts +232 -0
  177. package/src/gateway/connect.ts +847 -0
  178. package/src/gateway/connectors/aider.ts +356 -0
  179. package/src/gateway/connectors/amazon-q-cli.ts +6 -0
  180. package/src/gateway/connectors/amazon-q-ide.ts +6 -0
  181. package/src/gateway/connectors/antigravity.ts +6 -0
  182. package/src/gateway/connectors/auggie.ts +6 -0
  183. package/src/gateway/connectors/cline-sdk.ts +85 -0
  184. package/src/gateway/connectors/cline.ts +34 -0
  185. package/src/gateway/connectors/codebuff.ts +6 -0
  186. package/src/gateway/connectors/continue.ts +579 -0
  187. package/src/gateway/connectors/copilot-cli.ts +138 -0
  188. package/src/gateway/connectors/crush.ts +313 -0
  189. package/src/gateway/connectors/cursor-cli.ts +6 -0
  190. package/src/gateway/connectors/cursor.ts +6 -0
  191. package/src/gateway/connectors/devin.ts +6 -0
  192. package/src/gateway/connectors/factory-droid.ts +308 -0
  193. package/src/gateway/connectors/goose.ts +79 -0
  194. package/src/gateway/connectors/grok-cli.ts +82 -0
  195. package/src/gateway/connectors/hermes.ts +230 -0
  196. package/src/gateway/connectors/iflow-cli.ts +50 -0
  197. package/src/gateway/connectors/index.ts +86 -0
  198. package/src/gateway/connectors/kilo-cli.ts +434 -0
  199. package/src/gateway/connectors/kilo-code.ts +32 -0
  200. package/src/gateway/connectors/kimi-code.ts +241 -0
  201. package/src/gateway/connectors/kiro-cli.ts +6 -0
  202. package/src/gateway/connectors/kiro-ide.ts +6 -0
  203. package/src/gateway/connectors/letta-code.ts +170 -0
  204. package/src/gateway/connectors/mastracode.ts +91 -0
  205. package/src/gateway/connectors/mistral-vibe.ts +495 -0
  206. package/src/gateway/connectors/muse.ts +27 -0
  207. package/src/gateway/connectors/omp.ts +279 -0
  208. package/src/gateway/connectors/openhands.ts +296 -0
  209. package/src/gateway/connectors/pearai.ts +52 -0
  210. package/src/gateway/connectors/pi.ts +357 -0
  211. package/src/gateway/connectors/qoder-cli.ts +6 -0
  212. package/src/gateway/connectors/qwen-code.ts +411 -0
  213. package/src/gateway/connectors/roo.ts +43 -0
  214. package/src/gateway/connectors/tabnine-cli.ts +6 -0
  215. package/src/gateway/connectors/void.ts +6 -0
  216. package/src/gateway/connectors/vscode-copilot.ts +286 -0
  217. package/src/gateway/connectors/windsurf.ts +6 -0
  218. package/src/gateway/connectors/zed.ts +29 -0
  219. package/src/gateway/signin.ts +323 -0
  220. package/src/handoff.ts +104 -0
  221. package/src/hooks.ts +455 -0
  222. package/src/memory/audit.ts +514 -0
  223. package/src/memory/blobs.ts +94 -0
  224. package/src/memory/cli.ts +117 -0
  225. package/src/memory/distill.ts +381 -0
  226. package/src/memory/externalize.ts +435 -0
  227. package/src/memory/lookups.ts +70 -0
  228. package/src/memory/mcp-install.ts +401 -0
  229. package/src/memory/mcp-server.ts +296 -0
  230. package/src/memory/prepare.ts +179 -0
  231. package/src/memory/preview.ts +154 -0
  232. package/src/memory/project.ts +35 -0
  233. package/src/memory/redact.ts +72 -0
  234. package/src/memory/relevance.ts +187 -0
  235. package/src/memory/repomap.ts +516 -0
  236. package/src/memory/store.ts +270 -0
  237. package/src/memory/sync.ts +166 -0
  238. package/src/memory/tidy.ts +193 -0
  239. package/src/memory/types.ts +118 -0
  240. package/src/org/admin-ops.ts +96 -0
  241. package/src/org/auth.ts +112 -0
  242. package/src/org/backup.ts +115 -0
  243. package/src/org/cli.ts +438 -0
  244. package/src/org/client.ts +404 -0
  245. package/src/org/config.ts +44 -0
  246. package/src/org/crypto.ts +537 -0
  247. package/src/org/db.ts +435 -0
  248. package/src/org/graph.ts +318 -0
  249. package/src/org/oidc.ts +435 -0
  250. package/src/org/policy.ts +398 -0
  251. package/src/org/server.ts +920 -0
  252. package/src/org/sso.ts +316 -0
  253. package/src/org/tls.ts +66 -0
  254. package/src/org/types.ts +193 -0
  255. package/src/self.ts +68 -0
  256. package/src/session/attachments.ts +53 -0
  257. package/src/session/peekcache.ts +50 -0
  258. package/src/session/types.ts +104 -0
  259. package/src/session/util.ts +130 -0
  260. package/src/sqlite.ts +48 -0
@@ -0,0 +1,639 @@
1
+ import { readFileSync, statSync } from "node:fs"
2
+ import { basename, dirname, join } from "node:path"
3
+ import { exists, findFiles, gitInfo, ls, mtime, readJson, readJsonl } from "../../fsutil.ts"
4
+ import type { Attachment, Session, SessionHeader, Turn, Usage } from "../../session/types.ts"
5
+ import { keepImage } from "../../session/attachments.ts"
6
+ import { peekCache } from "../../session/peekcache.ts"
7
+ import { clip, header, iso, lossCounter, noteModel, toolKindOf } from "../../session/util.ts"
8
+ import type { Reader, SessionRef } from "../types.ts"
9
+ import { CHAIN_TYPES, CONTINUED_NOTE, FILE_ATTACHMENTS, HOUSEKEEPING, LABEL, SESSION_FILE, TOOL, projectSlug, projectsDir } from "./records.ts"
10
+
11
+ // Reads Claude Code transcripts: <home>/.claude/projects/<project>/<session>.jsonl, with subagents in
12
+ // <session>/subagents/ and spilled tool outputs in <session>/tool-results/.
13
+ //
14
+ // A transcript is a tree, not a list: every conversation record has a uuid and a parentUuid, and a file
15
+ // keeps abandoned branches (rewinds, retries) beside the live one. The session is the line from the
16
+ // newest leaf back to the root. Quirks of real files shape the walk: parallel tool results each hang off
17
+ // the record of their own tool_use, so all but the last sit on side branches (and a few carry no parent
18
+ // link at all), so results are re-attached by tool_use id; a few files have a parent that was never
19
+ // written or a loop, where the walk bridges to the record before in the file instead of stopping; and a
20
+ // result can be written before its call, so it waits for the call.
21
+
22
+ type Row = Record<string, any>
23
+ type Lost = ReturnType<typeof lossCounter>
24
+
25
+ const HEAD = 64 * 1024
26
+ const TAIL = 256 * 1024
27
+
28
+ const lastMatch = (text: string, re: RegExp): string | undefined => {
29
+ let out: string | undefined
30
+ for (const m of text.matchAll(re)) out = m[1]
31
+ return out
32
+ }
33
+ const jsonString = (raw: string | undefined): string | undefined => {
34
+ if (raw === undefined) return undefined
35
+ try {
36
+ return JSON.parse(`"${raw}"`) as string
37
+ } catch {
38
+ return undefined
39
+ }
40
+ }
41
+ // Claude Code writes compact JSON, but transcripts written by other programs (which Claude Code resumes
42
+ // just the same) may put spaces around the colon.
43
+ const CWD = /"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/
44
+ const RELOCATED = /"relocatedCwd"\s*:\s*"((?:[^"\\]|\\.)*)"/g
45
+ const TIMESTAMP = /"timestamp"\s*:\s*"((?:[^"\\]|\\.)*)"/g
46
+
47
+ /** The title records in a stretch of the file: the newest custom title, else the newest AI title or summary. */
48
+ function titlesIn(text: string): { custom?: string; ai?: string } {
49
+ const out: { custom?: string; ai?: string } = {}
50
+ for (const line of text.split("\n")) {
51
+ if (!line.includes('"customTitle"') && !line.includes('"aiTitle"') && !line.includes('"summary"')) continue
52
+ let r: Row
53
+ try {
54
+ r = JSON.parse(line)
55
+ } catch {
56
+ continue
57
+ }
58
+ if (r.type === "custom-title" && typeof r.customTitle === "string" && r.customTitle.trim()) out.custom = r.customTitle
59
+ if (r.type === "ai-title" && typeof r.aiTitle === "string" && r.aiTitle.trim()) out.ai = r.aiTitle
60
+ if (r.type === "summary" && typeof r.summary === "string" && r.summary.trim()) out.ai = r.summary
61
+ }
62
+ return out
63
+ }
64
+
65
+ /** A file's modified time in ms, or 0 when it is missing. */
66
+ const mtimeMs = (p: string) => {
67
+ try {
68
+ return statSync(p).mtimeMs
69
+ } catch {
70
+ return 0
71
+ }
72
+ }
73
+
74
+ /** Title, cwd and last activity from the start and end of a transcript, without reading the whole file. */
75
+ async function peek(path: string, sessionDir: string): Promise<{ title?: string; cwd?: string; updatedAt?: string } | undefined> {
76
+ const file = Bun.file(path)
77
+ const size = file.size
78
+ const head = await file.slice(0, Math.min(size, HEAD)).text()
79
+ const tail = size > HEAD ? await file.slice(Math.max(HEAD, size - TAIL)).text() : ""
80
+ let whole: string | undefined
81
+ // The session's folder is the one its first record names (the project folder is named after it); later
82
+ // records name wherever the shell had moved to, so the tail is no substitute when the head has none.
83
+ // First records can be huge (a pasted file), so a growing prefix is read until one names its cwd.
84
+ let cwd = jsonString(head.match(CWD)?.[1])
85
+ for (let n = HEAD * 16, read = Math.min(size, HEAD); !cwd && read < size; n *= 4) {
86
+ read = Math.min(size, n)
87
+ const prefix = await file.slice(0, read).text()
88
+ cwd = jsonString(prefix.match(CWD)?.[1])
89
+ if (read === size) whole = prefix
90
+ }
91
+ // A file with no cwd anywhere has no conversation records (only metadata): nothing to continue.
92
+ if (!cwd) return undefined
93
+ // After /cd the session moves to another project folder and says so in `relocated` records. They are
94
+ // stamped again and again, so the tail nearly always has one; the whole file is searched only when the
95
+ // folder the session sits in does not match its first cwd.
96
+ let moved = whole === undefined ? lastMatch(tail, RELOCATED) ?? lastMatch(head, RELOCATED) : lastMatch(whole, RELOCATED)
97
+ if (!moved && whole === undefined && size > HEAD + TAIL && projectSlug(cwd) !== basename(dirname(path))) moved = lastMatch(await file.text(), RELOCATED)
98
+ cwd = jsonString(moved) ?? cwd
99
+ const fromTail = titlesIn(tail)
100
+ const fromHead = titlesIn(head)
101
+ const titleFile = readJson<{ customTitle?: string }>(join(sessionDir, "custom-title.json"))?.customTitle
102
+ const title = fromTail.custom ?? fromHead.custom ?? (titleFile?.trim() ? titleFile : undefined) ?? fromTail.ai ?? fromHead.ai
103
+ const updatedAt = lastTimestamp(tail || head) ?? mtime(path)
104
+ return { ...(title ? { title } : {}), cwd, ...(updatedAt ? { updatedAt } : {}) }
105
+ }
106
+
107
+ /**
108
+ * The newest record's own timestamp in a stretch of the file. Records written last (file-history snapshots,
109
+ * cost state) carry timestamps nested inside them that are not activity, so lines are parsed from the end;
110
+ * a stretch that is one huge partial line falls back to the last timestamp anywhere in it.
111
+ */
112
+ function lastTimestamp(text: string): string | undefined {
113
+ const lines = text.split("\n")
114
+ for (let i = lines.length - 1; i >= 0; i--) {
115
+ if (!lines[i]!.includes('"timestamp"')) continue
116
+ try {
117
+ const when = iso(JSON.parse(lines[i]!).timestamp)
118
+ if (when) return when
119
+ } catch {
120
+ // the stretch's first line is usually cut
121
+ }
122
+ }
123
+ return iso(lastMatch(text, TIMESTAMP))
124
+ }
125
+
126
+ /**
127
+ * The live conversation: the records from the newest leaf back to the root, plus the results of its tool
128
+ * calls wherever they hang, in file order. File order is append order, so a rewound branch written
129
+ * later wins, and turns before a compaction (reached through logicalParentUuid) come first.
130
+ */
131
+ export function mainLine(rows: Row[]): Row[] {
132
+ // Tree records always carry parentUuid (null at a root); a record without the key is not on the tree.
133
+ const nodes: number[] = []
134
+ for (let i = 0; i < rows.length; i++) if (typeof rows[i]!.uuid === "string" && "parentUuid" in rows[i]! && CHAIN_TYPES.has(rows[i]!.type)) nodes.push(i)
135
+ if (!nodes.length) return []
136
+ const at = new Map<string, number>()
137
+ for (const i of nodes) at.set(rows[i]!.uuid, i)
138
+ // A compaction boundary hangs off the conversation it summarised through logicalParentUuid, which is
139
+ // sometimes a record that was never written. The newest message the compaction kept verbatim (the tail of
140
+ // its preserved segment) is that same point, when it was written before the boundary.
141
+ const parentOf = (r: Row, i: number): string | undefined => {
142
+ if (r.parentUuid) return r.parentUuid
143
+ if (r.logicalParentUuid === undefined || at.has(r.logicalParentUuid)) return r.logicalParentUuid
144
+ const cm: Row = r.compactMetadata ?? {}
145
+ const tail = cm.preservedSegment?.tailUuid ?? (Array.isArray(cm.preservedMessages?.uuids) ? cm.preservedMessages.uuids.at(-1) : undefined)
146
+ return typeof tail === "string" && (at.get(tail) ?? i) < i ? tail : r.logicalParentUuid
147
+ }
148
+ const keep = new Set<number>()
149
+ let earliest = Number.POSITIVE_INFINITY
150
+ let cur: number | undefined = nodes[nodes.length - 1]
151
+ for (;;) {
152
+ let root = false
153
+ while (cur !== undefined && !keep.has(cur)) {
154
+ keep.add(cur)
155
+ earliest = Math.min(earliest, cur)
156
+ const r: Row = rows[cur]!
157
+ const parent = parentOf(r, cur)
158
+ if (!parent) {
159
+ // A prompt with no parent is where the conversation (or a rewind to its start) begins; a system or
160
+ // attachment record with no parent mid-file is not.
161
+ root = r.type === "user"
162
+ break
163
+ }
164
+ cur = at.get(parent)
165
+ }
166
+ if (root) break
167
+ // Broken link, loop or a stray root: carry on from the nearest earlier record not yet on the line.
168
+ const before = nodes.filter((i) => i < earliest && !keep.has(i))
169
+ if (!before.length) break
170
+ cur = before[before.length - 1]
171
+ }
172
+ // The other records of each response on the line: a response is split one block per record, and with
173
+ // parallel calls the conversation can carry on from an earlier block's result, leaving the later blocks
174
+ // (and their calls) on a side branch. Claude Code's own loader gathers them by message.id the same way.
175
+ const responses = new Set<string>()
176
+ for (const i of keep) if (rows[i]!.type === "assistant" && typeof rows[i]!.message?.id === "string") responses.add(rows[i]!.message.id)
177
+ for (const i of nodes) if (!keep.has(i) && rows[i]!.type === "assistant" && responses.has(rows[i]!.message?.id)) keep.add(i)
178
+ // Results for calls on the line, wherever they hang: parallel results sit on side branches, and a few
179
+ // result records carry no parent link at all. Matched by tool_use id, not by parent.
180
+ const blocks = (r: Row, type: string): Row[] => (Array.isArray(r.message?.content) ? r.message.content.filter((b: Row) => b?.type === type) : [])
181
+ const called = new Set<string>()
182
+ const answered = new Set<string>()
183
+ for (const i of keep) {
184
+ for (const b of blocks(rows[i]!, "tool_use")) called.add(b.id)
185
+ for (const b of blocks(rows[i]!, "tool_result")) answered.add(b.tool_use_id)
186
+ }
187
+ for (let i = 0; i < rows.length; i++) {
188
+ if (keep.has(i) || rows[i]!.type !== "user") continue
189
+ const ids = blocks(rows[i]!, "tool_result").map((b) => b.tool_use_id).filter((id) => called.has(id) && !answered.has(id))
190
+ if (!ids.length) continue
191
+ keep.add(i)
192
+ for (const id of ids) answered.add(id)
193
+ }
194
+ return [...keep].sort((a, b) => a - b).map((i) => rows[i]!)
195
+ }
196
+
197
+ function usageOf(u: Row | undefined): Usage | undefined {
198
+ if (!u || typeof u !== "object") return undefined
199
+ const out: Usage = {}
200
+ if (typeof u.input_tokens === "number") out.input = u.input_tokens
201
+ if (typeof u.output_tokens === "number") out.output = u.output_tokens
202
+ if (typeof u.cache_read_input_tokens === "number") out.cacheRead = u.cache_read_input_tokens
203
+ if (typeof u.cache_creation_input_tokens === "number") out.cacheWrite = u.cache_creation_input_tokens
204
+ return Object.keys(out).length ? out : undefined
205
+ }
206
+
207
+ /** A tool result's content as text; images and documents inside it are counted, not carried. */
208
+ function resultText(content: unknown, lost: Lost): string {
209
+ if (typeof content === "string") return content
210
+ if (!Array.isArray(content)) return content === undefined || content === null ? "" : JSON.stringify(content)
211
+ const parts: string[] = []
212
+ for (const b of content as Row[]) {
213
+ if (b?.type === "text" && typeof b.text === "string") parts.push(b.text)
214
+ else if (b?.type === "image") lost.add("images in tool results")
215
+ else if (b?.type === "document") lost.add("documents in tool results")
216
+ else if (b?.type === "tool_reference") parts.push(`[tool loaded: ${b.tool_name}]`)
217
+ else parts.push(JSON.stringify(b))
218
+ }
219
+ return parts.join("\n")
220
+ }
221
+
222
+ const PERSISTED = /<persisted-output>[\s\S]*?Full output saved to: (\S+)/
223
+
224
+ /** Where Claude Code spilled a large output: Bash says so in toolUseResult, other tools in a <persisted-output> preview. */
225
+ function spilled(tur: unknown, text: string): { spilled: boolean; path?: string } {
226
+ const fromResult = tur && typeof tur === "object" ? (tur as Row).persistedOutputPath : undefined
227
+ const path = typeof fromResult === "string" ? fromResult : text.match(PERSISTED)?.[1]
228
+ if (!path) return { spilled: false }
229
+ return exists(path) ? { spilled: true, path } : { spilled: true }
230
+ }
231
+
232
+ /** Edit and Write results carry the change as hunks; turned into a unified diff. */
233
+ function patchOf(tur: Row): string | undefined {
234
+ const hunks = Array.isArray(tur.structuredPatch) ? (tur.structuredPatch as Row[]) : []
235
+ const body = hunks
236
+ .filter((h) => Array.isArray(h?.lines))
237
+ .map((h) => `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@\n${(h.lines as string[]).join("\n")}`)
238
+ if (!body.length && tur.type === "create" && typeof tur.content === "string") {
239
+ const lines = tur.content.split("\n")
240
+ body.push(`@@ -0,0 +1,${lines.length} @@\n${lines.map((l: string) => `+${l}`).join("\n")}`)
241
+ }
242
+ if (!body.length) return undefined
243
+ return clip(`--- a/${tur.filePath}\n+++ b/${tur.filePath}\n${body.join("\n")}`).text
244
+ }
245
+
246
+ /** Plain text of a user message's content (string or blocks). */
247
+ const textOf = (c: unknown): string =>
248
+ typeof c === "string" ? c : Array.isArray(c) ? (c as Row[]).filter((b) => b?.type === "text").map((b) => String(b.text ?? "")).join("\n\n") : ""
249
+
250
+ /**
251
+ * Turns for one transcript's records. Fills `h` (project, times, models, version) and counts what is
252
+ * dropped into `lost`; also returns the sessionId on the first live record, which differs from the file's
253
+ * own id when the session was forked from another.
254
+ */
255
+ function convert(rows: Row[], h: SessionHeader, lost: Lost): { turns: Turn[]; firstSessionId?: string } {
256
+ for (const r of rows) {
257
+ const t = r.type
258
+ if (typeof t === "string" && !CHAIN_TYPES.has(t) && !HOUSEKEEPING.has(t)) lost.add(`unknown record type: ${t}`)
259
+ }
260
+ const turns: Turn[] = []
261
+ const calls = new Set<string>()
262
+ const results = new Set<string>()
263
+ const textTurn = new Map<string, Extract<Turn, { kind: "assistant" }>>()
264
+ const usage = new Map<string, Usage>()
265
+ let lastMessageId: string | undefined
266
+ let compacting: Row | undefined
267
+ let lastUser: Extract<Turn, { kind: "user" }> | undefined
268
+ // Where each record's turns begin, so a compaction can be placed before the messages it kept verbatim.
269
+ const startOf = new Map<string, number>()
270
+
271
+ const user = (text: string, at: string | undefined, attachments: Attachment[] = []) => {
272
+ const t: Extract<Turn, { kind: "user" }> = { kind: "user", ...(at ? { at } : {}), text, ...(attachments.length ? { attachments } : {}) }
273
+ turns.push(t)
274
+ lastUser = t
275
+ }
276
+
277
+ // A result can sit in the file before its call (a response record rewritten later); it waits for the call.
278
+ const early = new Map<string, { b: Row; tur: unknown; at: string | undefined }>()
279
+ const result = (b: Row, tur: unknown, at: string | undefined) => {
280
+ const id = b.tool_use_id
281
+ if (typeof id !== "string" || results.has(id)) return
282
+ if (!calls.has(id)) {
283
+ if (!early.has(id)) early.set(id, { b, tur, at })
284
+ return
285
+ }
286
+ results.add(id)
287
+ const text = resultText(b.content, lost)
288
+ const spill = spilled(tur, text)
289
+ const c = clip(text)
290
+ turns.push({
291
+ kind: "tool_result",
292
+ ...(at ? { at } : {}),
293
+ callId: id,
294
+ ok: !b.is_error,
295
+ output: c.text,
296
+ ...(c.truncated || spill.spilled ? { truncated: true } : {}),
297
+ ...(spill.path ? { fullPath: spill.path } : {}),
298
+ })
299
+ if (!b.is_error && tur && typeof tur === "object" && typeof (tur as Row).filePath === "string" && "structuredPatch" in (tur as Row)) {
300
+ const patch = patchOf(tur as Row)
301
+ turns.push({ kind: "file_edit", ...(at ? { at } : {}), path: (tur as Row).filePath, ...(patch ? { patch } : {}), callId: id })
302
+ }
303
+ }
304
+
305
+ /** Image and document blocks of a prompt, as attachments; the bytes stay behind and are counted. */
306
+ const inlineFiles = (c: unknown): Attachment[] => {
307
+ const out: Attachment[] = []
308
+ for (const b of (Array.isArray(c) ? c : []) as Row[]) {
309
+ if (b?.type === "image") {
310
+ const kept = b.source?.type === "base64" && typeof b.source.data === "string" ? keepImage(b.source.data, b.source.media_type) : undefined
311
+ if (kept) out.push(kept)
312
+ else {
313
+ out.push({ kind: "image", ...(b.source?.media_type ? { mime: b.source.media_type } : {}) })
314
+ lost.add("inline images")
315
+ }
316
+ } else if (b?.type === "document") {
317
+ out.push({ kind: "file", ...(b.source?.media_type ? { mime: b.source.media_type } : {}) })
318
+ lost.add("inline documents")
319
+ }
320
+ }
321
+ return out
322
+ }
323
+
324
+ const line = mainLine(rows)
325
+ for (const r of line) {
326
+ if (typeof r.uuid === "string") startOf.set(r.uuid, turns.length)
327
+ const at = iso(r.timestamp)
328
+ if (at) {
329
+ h.startedAt ??= at
330
+ h.updatedAt = at
331
+ }
332
+ if (typeof r.version === "string") h.toolVersion = r.version
333
+ if (!h.project.cwd && typeof r.cwd === "string") h.project.cwd = r.cwd
334
+ if (typeof r.gitBranch === "string" && r.gitBranch) h.project.gitBranch = r.gitBranch
335
+ const messageId = r.type === "assistant" && typeof r.message?.id === "string" ? (r.message.id as string) : undefined
336
+ const sameMessage = messageId !== undefined && messageId === lastMessageId
337
+ lastMessageId = messageId
338
+
339
+ if (r.type === "system") {
340
+ if (r.subtype === "compact_boundary") compacting = r
341
+ continue
342
+ }
343
+
344
+ if (r.type === "attachment") {
345
+ const a: Row = r.attachment ?? {}
346
+ // A prompt typed while the model was working reaches it as a queued_command attachment, not a user record.
347
+ if (a.type === "queued_command" && !a.isMeta) {
348
+ const text = typeof a.prompt === "string" ? a.prompt : textOf(a.prompt)
349
+ const files = inlineFiles(a.prompt)
350
+ if (text.trim() || files.length) user(text, at, files)
351
+ } else if (FILE_ATTACHMENTS.has(a.type) && typeof a.filename === "string" && lastUser) {
352
+ const list = (lastUser.attachments ??= [])
353
+ if (!list.some((x) => x.path === a.filename)) {
354
+ list.push({ kind: "file", path: a.filename, ...(typeof a.displayPath === "string" ? { name: a.displayPath } : {}), ...(a.type === "pdf_reference" ? { mime: "application/pdf" } : {}) })
355
+ }
356
+ }
357
+ continue
358
+ }
359
+
360
+ if (r.type === "assistant") {
361
+ const m: Row = r.message ?? {}
362
+ // API errors and local placeholders ("No response requested.") are not the model's words.
363
+ if (r.isApiErrorMessage || m.model === "<synthetic>") continue
364
+ noteModel(h, m.model)
365
+ // One response is split over several records sharing message.id; each repeats usage, the last is final.
366
+ const u = usageOf(m.usage)
367
+ if (messageId && u) usage.set(messageId, u)
368
+ for (const b of (Array.isArray(m.content) ? m.content : []) as Row[]) {
369
+ switch (b?.type) {
370
+ case "text": {
371
+ if (typeof b.text !== "string" || !b.text.trim()) break
372
+ const own = messageId ? textTurn.get(messageId) : undefined
373
+ if (sameMessage && own && turns[turns.length - 1] === own) own.text += `\n\n${b.text}`
374
+ else {
375
+ const t: Extract<Turn, { kind: "assistant" }> = { kind: "assistant", ...(at ? { at } : {}), text: b.text, ...(m.model ? { model: m.model } : {}) }
376
+ turns.push(t)
377
+ if (messageId && !textTurn.has(messageId)) textTurn.set(messageId, t)
378
+ }
379
+ break
380
+ }
381
+ case "thinking":
382
+ if (typeof b.thinking === "string" && b.thinking.trim()) turns.push({ kind: "reasoning", ...(at ? { at } : {}), text: b.thinking })
383
+ else lost.add("signed reasoning")
384
+ break
385
+ case "redacted_thinking":
386
+ lost.add("redacted reasoning")
387
+ break
388
+ case "tool_use":
389
+ case "server_tool_use":
390
+ case "mcp_tool_use":
391
+ if (typeof b.id !== "string" || calls.has(b.id)) break
392
+ calls.add(b.id)
393
+ turns.push({ kind: "tool_call", ...(at ? { at } : {}), id: b.id, name: String(b.name ?? "unknown"), toolKind: toolKindOf(String(b.name ?? "")), input: b.input ?? {} })
394
+ if (early.has(b.id)) {
395
+ const e = early.get(b.id)!
396
+ early.delete(b.id)
397
+ result(e.b, e.tur, e.at)
398
+ }
399
+ break
400
+ default:
401
+ // Server-side tools (web search, fetch) answer inside the assistant message itself.
402
+ if (typeof b?.type === "string" && b.type.endsWith("_tool_result")) result(b, undefined, at)
403
+ else lost.add(`unknown content: ${String(b?.type)}`)
404
+ }
405
+ }
406
+ continue
407
+ }
408
+
409
+ if (r.type === "user") {
410
+ if (r.isMeta) continue
411
+ const c = r.message?.content
412
+ if (r.isCompactSummary || compacting) {
413
+ const boundary = compacting
414
+ compacting = undefined
415
+ if (r.isCompactSummary) {
416
+ // A compaction keeps its newest messages verbatim. Claude Code shows them after the summary, but
417
+ // they are often written before the boundary, so the summary goes in front of the first of them:
418
+ // a compaction turn replaces only the turns before it.
419
+ const cm: Row = boundary?.compactMetadata ?? {}
420
+ const kept: unknown[] = Array.isArray(cm.preservedMessages?.uuids) ? cm.preservedMessages.uuids : [cm.preservedSegment?.headUuid]
421
+ const starts = kept.map((u) => startOf.get(u as string)).filter((n): n is number => n !== undefined)
422
+ const t: Turn = { kind: "compaction", ...(at ? { at } : {}), summary: textOf(c) }
423
+ if (starts.length) turns.splice(Math.min(...starts), 0, t)
424
+ else turns.push(t)
425
+ continue
426
+ }
427
+ }
428
+ if (typeof c === "string") {
429
+ if (c.trim()) user(c, at)
430
+ continue
431
+ }
432
+ if (!Array.isArray(c)) continue
433
+ const texts: string[] = []
434
+ const attachments = inlineFiles(c)
435
+ const nResults = c.filter((b: Row) => b?.type === "tool_result").length
436
+ for (const b of c as Row[]) {
437
+ if (b?.type === "text") {
438
+ if (typeof b.text === "string" && b.text.trim()) texts.push(b.text)
439
+ } else if (b?.type === "tool_result") result(b, nResults === 1 ? r.toolUseResult : undefined, at)
440
+ else if (b?.type !== "image" && b?.type !== "document") lost.add(`unknown content: ${String(b?.type)}`)
441
+ }
442
+ if (texts.length || attachments.length) user(texts.join("\n\n"), at, attachments)
443
+ }
444
+ }
445
+ if (early.size) lost.add("tool results without a call", early.size)
446
+ for (const [id, t] of textTurn) {
447
+ const u = usage.get(id)
448
+ if (u) t.usage = u
449
+ }
450
+ const sid = line.find((r) => typeof r.sessionId === "string")?.sessionId
451
+ return { turns, ...(sid ? { firstSessionId: sid as string } : {}) }
452
+ }
453
+
454
+ function parseLines(text: string): { rows: Row[]; bad: number } {
455
+ const rows: Row[] = []
456
+ let bad = 0
457
+ for (const line of text.split("\n")) {
458
+ if (!line.trim()) continue
459
+ try {
460
+ rows.push(JSON.parse(line))
461
+ } catch {
462
+ bad++
463
+ }
464
+ }
465
+ return { rows, bad }
466
+ }
467
+
468
+ /** A transcript's records; subagent transcripts are sometimes gzipped. */
469
+ function records(path: string): { rows: Row[]; bad: number } {
470
+ if (!path.endsWith(".gz")) return readJsonl(path)
471
+ return parseLines(new TextDecoder().decode(Bun.gunzipSync(readFileSync(path))))
472
+ }
473
+
474
+ /** One transcript as a session (children not attached). */
475
+ function sessionOf(rows: Row[], bad: number, sourceId: string, path: string, extra: Partial<SessionHeader> = {}): Session & { firstSessionId?: string } {
476
+ const h = header(TOOL, sourceId, { sourcePath: path, ...extra })
477
+ const lost = lossCounter()
478
+ if (bad) lost.add("unreadable lines", bad)
479
+ const { turns, firstSessionId } = convert(rows.filter((r) => r && typeof r === "object" && !Array.isArray(r)), h, lost)
480
+ lost.flush(h)
481
+ // Titles are written over and over (Claude Desktop rewrites them); the newest wins.
482
+ let custom: string | undefined
483
+ let ai: string | undefined
484
+ let moved: string | undefined
485
+ for (const r of rows) {
486
+ if (r?.type === "custom-title" && typeof r.customTitle === "string" && r.customTitle.trim()) custom = r.customTitle
487
+ if (r?.type === "ai-title" && typeof r.aiTitle === "string" && r.aiTitle.trim()) ai = r.aiTitle
488
+ if (r?.type === "summary" && typeof r.summary === "string" && r.summary.trim()) ai = r.summary
489
+ if (r?.type === "relocated" && typeof r.relocatedCwd === "string" && r.relocatedCwd) moved = r.relocatedCwd
490
+ }
491
+ // After /cd the session belongs to the folder it moved to (its transcript moves to that project folder).
492
+ if (moved) h.project.cwd = moved
493
+ const titleFile = readJson<{ customTitle?: string }>(join(path.replace(/\.jsonl(\.gz)?$/, ""), "custom-title.json"))?.customTitle
494
+ h.title ??= custom ?? (titleFile?.trim() ? titleFile : undefined) ?? ai
495
+ // The last activity is the newest timestamp on any record, not only the conversation's.
496
+ for (let i = rows.length - 1; i >= 0; i--) {
497
+ const when = iso(rows[i]?.timestamp)
498
+ if (when) {
499
+ if (!h.updatedAt || when > h.updatedAt) h.updatedAt = when
500
+ break
501
+ }
502
+ }
503
+ return { header: h, turns, ...(firstSessionId ? { firstSessionId } : {}) }
504
+ }
505
+
506
+ interface Child {
507
+ agentId: string
508
+ session: Session
509
+ meta: Row
510
+ }
511
+
512
+ /** Subagent transcripts under <session>/subagents (workflow agents included), nested by parentAgentId. */
513
+ function children(sessionDir: string, parent: Session, sid: string): Session[] {
514
+ const dir = join(sessionDir, "subagents")
515
+ if (!exists(dir)) return []
516
+ const byAgent = new Map<string, Child>()
517
+ const files = findFiles(dir, (n) => /^agent-.+\.jsonl(\.gz)?$/.test(n), 6).sort((a, b) => Number(a.endsWith(".gz")) - Number(b.endsWith(".gz")))
518
+ const lost = lossCounter()
519
+ for (const file of files) {
520
+ const agentId = basename(file).replace(/^agent-/, "").replace(/\.jsonl(\.gz)?$/, "")
521
+ if (byAgent.has(agentId)) continue
522
+ const meta = readJson<Row>(file.replace(/\.jsonl(\.gz)?$/, ".meta.json")) ?? {}
523
+ let read: { rows: Row[]; bad: number }
524
+ try {
525
+ read = records(file)
526
+ } catch {
527
+ lost.add("unreadable subagent transcripts")
528
+ continue
529
+ }
530
+ const session = sessionOf(read.rows, read.bad, `${sid}/agent-${agentId}`, file, {
531
+ ...(typeof meta.description === "string" ? { title: meta.description } : {}),
532
+ parent: { id: parent.header.id, kind: "subagent" },
533
+ })
534
+ delete session.firstSessionId
535
+ byAgent.set(agentId, { agentId, session, meta })
536
+ }
537
+ lost.flush(parent.header)
538
+ // Agents spawned by a subagent belong to it; the rest to the session.
539
+ const top: Child[] = []
540
+ const under = new Map<string, Child[]>()
541
+ for (const c of byAgent.values()) {
542
+ const p = typeof c.meta.parentAgentId === "string" ? byAgent.get(c.meta.parentAgentId) : undefined
543
+ if (p && p !== c) {
544
+ c.session.header.parent = { id: p.session.header.id, kind: "subagent" }
545
+ under.set(p.agentId, [...(under.get(p.agentId) ?? []), c])
546
+ } else top.push(c)
547
+ }
548
+ for (const c of byAgent.values()) attach(c.session, under.get(c.agentId) ?? [])
549
+ attach(parent, top)
550
+ return parent.children ?? []
551
+ }
552
+
553
+ /**
554
+ * Adds subagent turns: right after the Agent call that spawned each child (meta.toolUseId), or, for
555
+ * workflow agents that record no call, at the point in time the child started.
556
+ */
557
+ function attach(s: Session, kids: Child[]) {
558
+ if (!kids.length) return
559
+ kids.sort((a, b) => (a.session.header.startedAt ?? "").localeCompare(b.session.header.startedAt ?? ""))
560
+ const turnFor = (c: Child, callId?: string): Turn => ({
561
+ kind: "subagent",
562
+ ...(c.session.header.startedAt ? { at: c.session.header.startedAt } : {}),
563
+ sessionId: c.session.header.id,
564
+ ...(callId ? { callId } : {}),
565
+ ...(typeof c.meta.description === "string" ? { description: c.meta.description } : {}),
566
+ })
567
+ const callIds = new Set(s.turns.filter((t) => t.kind === "tool_call").map((t) => (t as Extract<Turn, { kind: "tool_call" }>).id))
568
+ const afterCall = new Map<string, Turn[]>()
569
+ const byTime: Turn[] = []
570
+ for (const c of kids) {
571
+ const id = c.meta.toolUseId
572
+ if (typeof id === "string" && callIds.has(id)) afterCall.set(id, [...(afterCall.get(id) ?? []), turnFor(c, id)])
573
+ else byTime.push(turnFor(c))
574
+ }
575
+ const out: Turn[] = []
576
+ for (const t of s.turns) {
577
+ while (byTime.length && t.at && byTime[0]!.at && byTime[0]!.at < t.at) out.push(byTime.shift()!)
578
+ out.push(t)
579
+ if (t.kind === "tool_call") out.push(...(afterCall.get(t.id) ?? []))
580
+ }
581
+ out.push(...byTime)
582
+ s.turns = out
583
+ s.children = kids.map((c) => c.session)
584
+ }
585
+
586
+ export const claudeCodeReader: Reader = {
587
+ tool: TOOL,
588
+ label: LABEL,
589
+
590
+ async list(home: string): Promise<SessionRef[]> {
591
+ const root = projectsDir(home)
592
+ const refs: SessionRef[] = []
593
+ // Peeking opens every transcript; the result is reused while a file's size and time (and its title file's) are unchanged.
594
+ const cache = peekCache(home, "claude-peek", 1)
595
+ for (const project of ls(root)) {
596
+ const dir = join(root, project)
597
+ for (const name of ls(dir)) {
598
+ if (!SESSION_FILE.test(name)) continue
599
+ const path = join(dir, name)
600
+ try {
601
+ const st = statSync(path)
602
+ if (!st.isFile()) continue
603
+ const sessionDir = path.slice(0, -".jsonl".length)
604
+ const titleTime = mtimeMs(join(sessionDir, "custom-title.json"))
605
+ const p = await cache.get(path, st, titleTime, () => peek(path, sessionDir))
606
+ if (p) refs.push({ tool: TOOL, sourceId: name.slice(0, -".jsonl".length), path, ...p })
607
+ } catch {
608
+ // Deleted or unreadable between listing and peeking: not a session to offer.
609
+ }
610
+ }
611
+ }
612
+ cache.flush()
613
+ return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
614
+ },
615
+
616
+ async read(ref: SessionRef, _home: string): Promise<Session> {
617
+ if (!exists(ref.path)) throw new Error(`no Claude Code transcript at ${ref.path}`)
618
+ const { rows, bad } = readJsonl(ref.path)
619
+ if (!rows.length) throw new Error(`${ref.path} has no readable records`)
620
+ const sid = basename(ref.path, ".jsonl")
621
+ const { firstSessionId, ...s } = sessionOf(rows, bad, sid, ref.path)
622
+ const h = s.header
623
+ // brain's note on a continued session is an isMeta record (skipped as a turn); read where it came from from the raw
624
+ // record. Sessions written before the note was meta carry it as the first user turn: it is ours, not the person's words.
625
+ const noteRow = rows.find((r) => r.type === "user" && typeof r.message?.content === "string" && CONTINUED_NOTE.test(r.message.content))
626
+ const continued = (noteRow?.message.content as string | undefined)?.match(CONTINUED_NOTE)?.[1]
627
+ if (continued) {
628
+ h.parent = { id: continued, kind: "continued" }
629
+ const i = s.turns.findIndex((t) => t.kind === "user" && CONTINUED_NOTE.test(t.text))
630
+ if (i >= 0) s.turns.splice(i, 1)
631
+ } else if (firstSessionId && firstSessionId !== sid) h.parent = { id: `${TOOL}:${firstSessionId}`, kind: "fork" }
632
+ if (h.project.cwd) {
633
+ const remote = gitInfo(h.project.cwd).gitRemote
634
+ if (remote) h.project.gitRemote = remote
635
+ }
636
+ children(ref.path.slice(0, -".jsonl".length), s, sid)
637
+ return s
638
+ },
639
+ }