@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.
- package/LICENSE +48 -0
- package/README.md +235 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +351 -0
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +261 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +847 -0
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- package/src/gateway/connectors/zed.ts +29 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- 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
|
+
}
|