@100xprompt/brain 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +48 -0
- package/README.md +172 -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 +326 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/connect.ts +845 -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,228 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from "node:fs"
|
|
2
|
+
import { extname, resolve } from "node:path"
|
|
3
|
+
import type { Attachment, Session, Turn, Usage } from "../../session/types.ts"
|
|
4
|
+
|
|
5
|
+
// What the Gemini family's writers (Gemini CLI, and its forks Qwen Code and iFlow CLI) share: the portable
|
|
6
|
+
// session's flat turns regrouped into the exchanges these tools keep (a person's message; one model
|
|
7
|
+
// response with its readable thoughts, its text and its tool calls, each with the result and edits that
|
|
8
|
+
// answered it; a compaction summary), and portable tool calls mapped onto a target's own tools.
|
|
9
|
+
|
|
10
|
+
export type Call = Extract<Turn, { kind: "tool_call" }>
|
|
11
|
+
export type Result = Extract<Turn, { kind: "tool_result" }>
|
|
12
|
+
export type Edit = Extract<Turn, { kind: "file_edit" }>
|
|
13
|
+
|
|
14
|
+
export interface Answered {
|
|
15
|
+
call: Call
|
|
16
|
+
/** Undefined when the session never recorded one; writers close the call as failed. */
|
|
17
|
+
result?: Result
|
|
18
|
+
edits: Edit[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type Exchange =
|
|
22
|
+
| { kind: "user"; at?: string; text: string; attachments: Attachment[] }
|
|
23
|
+
| { kind: "model"; at?: string; model?: string; usage?: Usage; thoughts: Array<{ at?: string; text: string }>; text: string; calls: Answered[] }
|
|
24
|
+
| { kind: "compaction"; at?: string; summary: string }
|
|
25
|
+
|
|
26
|
+
export type Drop = (what: string, n?: number) => void
|
|
27
|
+
|
|
28
|
+
/** Counts what a writer drops, by phrase, and lists it the way `WriteResult.lost` wants. */
|
|
29
|
+
export function drops() {
|
|
30
|
+
const counts = new Map<string, number>()
|
|
31
|
+
const drop: Drop = (what, n = 1) => counts.set(what, (counts.get(what) ?? 0) + n)
|
|
32
|
+
return { drop, list: () => [...counts].map(([what, n]) => `${what} (${n})`) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The session's exchanges, in order. A tool result closes the response that made its call, so a call made
|
|
37
|
+
* after it starts a new response, as it does in these tools (the model saw the result before calling
|
|
38
|
+
* again). With `fromLastCompaction`, a compacted session starts from its last summary, the way a tool that
|
|
39
|
+
* replaces the history with its summary would load it; the turns before it are dropped and counted.
|
|
40
|
+
*/
|
|
41
|
+
export function regroup(session: Session, opts: { fromLastCompaction: boolean; keepReasoning: boolean; drop: Drop }): Exchange[] {
|
|
42
|
+
const { drop } = opts
|
|
43
|
+
let turns = session.turns
|
|
44
|
+
if (opts.fromLastCompaction) {
|
|
45
|
+
let last = -1
|
|
46
|
+
turns.forEach((t, i) => {
|
|
47
|
+
if (t.kind === "compaction") last = i
|
|
48
|
+
})
|
|
49
|
+
if (last > 0) {
|
|
50
|
+
drop("turns before the last compaction, which its summary replaced", last)
|
|
51
|
+
turns = turns.slice(last)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const out: Exchange[] = []
|
|
55
|
+
type Model = Extract<Exchange, { kind: "model" }>
|
|
56
|
+
let cur: Model | undefined
|
|
57
|
+
const answered = new Map<string, Answered>()
|
|
58
|
+
const flush = () => {
|
|
59
|
+
if (cur && (cur.text.trim() || cur.calls.length || cur.thoughts.length)) out.push(cur)
|
|
60
|
+
cur = undefined
|
|
61
|
+
}
|
|
62
|
+
const model = (at: string | undefined) => (cur ??= { kind: "model", ...(at ? { at } : {}), thoughts: [], text: "", calls: [] })
|
|
63
|
+
for (const t of turns) {
|
|
64
|
+
switch (t.kind) {
|
|
65
|
+
case "user":
|
|
66
|
+
flush()
|
|
67
|
+
if (t.text.trim() || t.attachments?.length) out.push({ kind: "user", ...(t.at ? { at: t.at } : {}), text: t.text, attachments: t.attachments ?? [] })
|
|
68
|
+
break
|
|
69
|
+
case "compaction":
|
|
70
|
+
flush()
|
|
71
|
+
out.push({ kind: "compaction", ...(t.at ? { at: t.at } : {}), summary: t.summary })
|
|
72
|
+
break
|
|
73
|
+
case "reasoning":
|
|
74
|
+
if (!opts.keepReasoning) {
|
|
75
|
+
drop("reasoning (no place for it)")
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
// Thinking comes before a response's text and calls; thinking after them starts the next response.
|
|
79
|
+
if (cur && (cur.text || cur.calls.length)) flush()
|
|
80
|
+
if (t.text.trim()) model(t.at).thoughts.push({ ...(t.at ? { at: t.at } : {}), text: t.text })
|
|
81
|
+
break
|
|
82
|
+
case "assistant": {
|
|
83
|
+
// Text after a tool call was written once the call's result came back: a new response.
|
|
84
|
+
if (cur?.calls.length) flush()
|
|
85
|
+
if (!t.text.trim()) break
|
|
86
|
+
const m = model(t.at)
|
|
87
|
+
m.text = m.text ? `${m.text}\n\n${t.text}` : t.text
|
|
88
|
+
if (t.model && !m.model) m.model = t.model
|
|
89
|
+
if (t.usage && !m.usage) m.usage = t.usage
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
case "tool_call": {
|
|
93
|
+
if (answered.has(t.id)) {
|
|
94
|
+
drop("repeated tool call ids")
|
|
95
|
+
break
|
|
96
|
+
}
|
|
97
|
+
const a: Answered = { call: t, edits: [] }
|
|
98
|
+
answered.set(t.id, a)
|
|
99
|
+
model(t.at).calls.push(a)
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
case "tool_result": {
|
|
103
|
+
const a = answered.get(t.callId)
|
|
104
|
+
if (!a) drop("tool results without a call")
|
|
105
|
+
else if (a.result) drop("repeated tool results")
|
|
106
|
+
else {
|
|
107
|
+
a.result = t
|
|
108
|
+
if (cur?.calls.includes(a)) flush()
|
|
109
|
+
}
|
|
110
|
+
break
|
|
111
|
+
}
|
|
112
|
+
case "file_edit": {
|
|
113
|
+
const a = t.callId ? answered.get(t.callId) : undefined
|
|
114
|
+
if (a) a.edits.push(t)
|
|
115
|
+
else drop("file edits not made by a tool call")
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
case "subagent":
|
|
119
|
+
drop("subagent sessions (their results stay in the parent's tool results)")
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
flush()
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A target tool: its name, and for each argument it takes, the names other tools give that argument. */
|
|
128
|
+
export interface ToolSpec {
|
|
129
|
+
name: string
|
|
130
|
+
/** The first argument is the one the call must have for the mapping to apply. */
|
|
131
|
+
args: Record<string, readonly string[]>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const ALIASES = {
|
|
135
|
+
file: ["file_path", "path", "filePath", "absolute_path", "target_file", "file", "filename", "notebook_path"],
|
|
136
|
+
dir: ["dir_path", "path", "directory", "relative_workspace_path", "target_directory", "dir"],
|
|
137
|
+
oldText: ["old_string", "oldString", "old_str", "old_text"],
|
|
138
|
+
newText: ["new_string", "newString", "new_str", "new_text"],
|
|
139
|
+
content: ["content", "contents", "file_text", "text"],
|
|
140
|
+
command: ["command", "cmd", "commandLine"],
|
|
141
|
+
pattern: ["pattern", "query", "regex", "search", "glob_pattern"],
|
|
142
|
+
query: ["query", "search_term", "q"],
|
|
143
|
+
url: ["url", "uri"],
|
|
144
|
+
} as const
|
|
145
|
+
|
|
146
|
+
/** Function names Gemini, OpenAI-style APIs and these tools all accept. */
|
|
147
|
+
export const functionName = (name: string) => {
|
|
148
|
+
const v = name.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 64)
|
|
149
|
+
return /^[A-Za-z_]/.test(v) ? v : `_${v}`.slice(0, 64)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A portable call as the target's own tool: the target's name and argument names, with every argument the
|
|
154
|
+
* target has no name for kept as it was. A call whose main argument cannot be found keeps its own name and
|
|
155
|
+
* arguments, so nothing is guessed. Arguments must be an object; anything else is wrapped as {input}.
|
|
156
|
+
*/
|
|
157
|
+
export function mapCall(call: Call, spec: ToolSpec | undefined): { name: string; args: Record<string, unknown> } {
|
|
158
|
+
const input = call.input && typeof call.input === "object" && !Array.isArray(call.input) ? (call.input as Record<string, unknown>) : undefined
|
|
159
|
+
const own = { name: functionName(call.name), args: input ?? { input: call.input ?? null } }
|
|
160
|
+
if (!spec || !input) return own
|
|
161
|
+
const keys = Object.entries(spec.args)
|
|
162
|
+
const found = (aliases: readonly string[]) => aliases.find((k) => input[k] !== undefined && input[k] !== null)
|
|
163
|
+
if (!keys.length || !found(keys[0]![1])) return own
|
|
164
|
+
const args: Record<string, unknown> = {}
|
|
165
|
+
const used = new Set<string>()
|
|
166
|
+
for (const [key, aliases] of keys) {
|
|
167
|
+
const from = aliases.filter((k) => !used.has(k)).find((k) => input[k] !== undefined && input[k] !== null)
|
|
168
|
+
if (!from) continue
|
|
169
|
+
used.add(from)
|
|
170
|
+
const v = input[from]
|
|
171
|
+
// A command given as argv (Codex's shell) is one command line to these tools.
|
|
172
|
+
args[key] = key === "command" && Array.isArray(v) && v.every((x) => typeof x === "string") ? v.join(" ") : v
|
|
173
|
+
}
|
|
174
|
+
for (const [k, v] of Object.entries(input)) if (!used.has(k) && !(k in args)) args[k] = v
|
|
175
|
+
return { name: spec.name, args }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
|
|
179
|
+
const IMAGE_LIMIT = 5 * 1024 * 1024
|
|
180
|
+
|
|
181
|
+
/** An attached image's bytes, when it is still on disk, a known image type, and small enough to inline. */
|
|
182
|
+
export function imageData(a: Attachment): { mimeType: string; data: string } | undefined {
|
|
183
|
+
if (a.kind !== "image" || !a.path) return undefined
|
|
184
|
+
const mimeType = a.mime ?? IMAGE_TYPES[extname(a.path).toLowerCase()]
|
|
185
|
+
if (!mimeType || !Object.values(IMAGE_TYPES).includes(mimeType)) return undefined
|
|
186
|
+
try {
|
|
187
|
+
if (statSync(a.path).size > IMAGE_LIMIT) return undefined
|
|
188
|
+
return { mimeType, data: readFileSync(a.path).toString("base64") }
|
|
189
|
+
} catch {
|
|
190
|
+
return undefined
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The text a tool result carries into the target, with where its full output was kept when it was cut. */
|
|
195
|
+
export function resultOutput(r: Result | undefined): string {
|
|
196
|
+
if (!r) return "(no result was recorded for this call)"
|
|
197
|
+
const out = r.fullPath ? `${r.output}\n\n[Full output: ${r.fullPath}]` : r.output
|
|
198
|
+
return out.trim() ? out : "(no output)"
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Timestamps that never go backwards, from the turns' own times where they have them. */
|
|
202
|
+
export function clock(session: Session, now: Date) {
|
|
203
|
+
const known = session.turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
|
|
204
|
+
let t = (known ?? now.getTime()) - 1
|
|
205
|
+
return (at?: string) => {
|
|
206
|
+
const v = at ? Date.parse(at) : Number.NaN
|
|
207
|
+
t = Number.isFinite(v) && v > t ? v : t + 1
|
|
208
|
+
return new Date(t).toISOString()
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A one-line title for the new session: the source's title, or its first request. */
|
|
213
|
+
export function titleOf(session: Session): string {
|
|
214
|
+
const first = session.turns.find((t) => t.kind === "user" && t.text.trim()) as Extract<Turn, { kind: "user" }> | undefined
|
|
215
|
+
return (session.header.title ?? first?.text ?? "Session").replace(/\s+/g, " ").trim().slice(0, 80) || "Session"
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A folder as these tools record it: the resolved path the process sees (/tmp is /private/tmp on macOS). */
|
|
219
|
+
export function realCwd(cwd: string): string {
|
|
220
|
+
try {
|
|
221
|
+
return realpathSync(cwd)
|
|
222
|
+
} catch {
|
|
223
|
+
return resolve(cwd)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A word for a shell command line, quoted only when it needs it. */
|
|
228
|
+
export const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { readFileSync } from "node:fs"
|
|
3
|
+
import { basename, dirname, join } from "node:path"
|
|
4
|
+
import { gitInfo, isDir, ls, mtime, readJson } from "../../fsutil.ts"
|
|
5
|
+
import type { Session, Turn } from "../../session/types.ts"
|
|
6
|
+
import { header, iso, lossCounter } from "../../session/util.ts"
|
|
7
|
+
import type { Reader, SessionRef, Writer } from "../types.ts"
|
|
8
|
+
import { closeOpenCalls, CONTINUED_NOTE, type Conversation, ignoredUserContent, LABEL, loadConversation, messagesToTurns, newCtx, type Part, partsToTurns, TOOL, toParts } from "./records.ts"
|
|
9
|
+
import { geminiNativeWriter } from "./writer.ts"
|
|
10
|
+
|
|
11
|
+
// Gemini CLI keeps each project's sessions under ~/.gemini/tmp/<project>/: chats/session-*.json (0.33 and
|
|
12
|
+
// older: one JSON document rewritten every turn) or chats/session-*.jsonl (current: an append-only log),
|
|
13
|
+
// subagent sessions in chats/<parent session id>/<id>.jsonl, and, when checkpointing is on,
|
|
14
|
+
// checkpoints/*.json snapshots of the API history taken before each file edit. Versions before chat
|
|
15
|
+
// recording left only those checkpoints, so a conversation they cover and no chat file does is read
|
|
16
|
+
// from them. <project> is a slug from ~/.gemini/projects.json now, the sha256 of the project folder
|
|
17
|
+
// before. The writer (writer.ts) continues a session as a new Gemini CLI session in the installed format.
|
|
18
|
+
|
|
19
|
+
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex")
|
|
20
|
+
const isHash = (s: string) => /^[0-9a-f]{64}$/.test(s)
|
|
21
|
+
const oneLine = (s: string) => s.replace(/\s+/g, " ").trim()
|
|
22
|
+
const titleFrom = (s: string | undefined) => (s && oneLine(s) ? oneLine(s).slice(0, 120) : undefined)
|
|
23
|
+
|
|
24
|
+
function readText(p: string): string | undefined {
|
|
25
|
+
try {
|
|
26
|
+
return readFileSync(p, "utf8")
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Project folder by tmp folder name: registry slugs, .project_root files, and sha256 names of every known folder. */
|
|
33
|
+
function projectFolders(home: string): Map<string, string> {
|
|
34
|
+
const gemini = join(home, ".gemini")
|
|
35
|
+
const map = new Map<string, string>()
|
|
36
|
+
const reg = readJson<{ projects?: Record<string, unknown> }>(join(gemini, "projects.json"))?.projects
|
|
37
|
+
for (const [path, slug] of Object.entries(reg ?? {})) if (typeof slug === "string") map.set(slug, path)
|
|
38
|
+
for (const base of ["tmp", "history"]) {
|
|
39
|
+
for (const d of ls(join(gemini, base))) {
|
|
40
|
+
const root = readText(join(gemini, base, d, ".project_root"))?.trim()
|
|
41
|
+
if (root && !map.has(d)) map.set(d, root)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const root of [...map.values()]) map.set(sha256(root), root)
|
|
45
|
+
// Folders the person trusted, and the folders above them, may name a legacy sha256 folder too. A sha256
|
|
46
|
+
// match is exact, so extra candidates cannot give a wrong folder.
|
|
47
|
+
const trusted = readJson<Record<string, unknown>>(join(gemini, "trustedFolders.json"))
|
|
48
|
+
for (const path of trusted && typeof trusted === "object" && !Array.isArray(trusted) ? Object.keys(trusted) : []) {
|
|
49
|
+
for (let d = path; d.startsWith("/") && d !== "/"; d = dirname(d)) if (!map.has(sha256(d))) map.set(sha256(d), d)
|
|
50
|
+
}
|
|
51
|
+
return map
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Absolute paths mentioned in tool arguments, to recover a legacy sha256-named project's folder. */
|
|
55
|
+
function absPaths(v: unknown, out: Set<string>, depth = 0): Set<string> {
|
|
56
|
+
if (out.size > 200 || depth > 10) return out
|
|
57
|
+
if (typeof v === "string") {
|
|
58
|
+
if (v.startsWith("/") && !v.includes("\n")) out.add(v)
|
|
59
|
+
} else if (Array.isArray(v)) for (const x of v) absPaths(x, out, depth + 1)
|
|
60
|
+
else if (v && typeof v === "object") for (const x of Object.values(v)) absPaths(x, out, depth + 1)
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The folder whose sha256 is `hash`, found among the ancestors of `paths`. */
|
|
65
|
+
function rootFromPaths(hash: string, paths: Iterable<string>): string | undefined {
|
|
66
|
+
for (const p of paths) {
|
|
67
|
+
for (let d = p; d && d !== "/" && d !== "."; d = dirname(d)) if (sha256(d) === hash) return d
|
|
68
|
+
}
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function projectCwd(folders: Map<string, string>, folder: string, projectHash: string | undefined, args: () => unknown): string | undefined {
|
|
73
|
+
return folders.get(folder) ?? (projectHash ? folders.get(projectHash) : undefined) ?? (isHash(folder) ? rootFromPaths(folder, absPaths(args(), new Set())) : undefined)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A user message's text, without the note brain puts first in a session it continued. */
|
|
77
|
+
const userText = (m: { content?: unknown }) =>
|
|
78
|
+
toParts(m.content as Part[])
|
|
79
|
+
.filter((p, i) => !(i === 0 && typeof p.text === "string" && CONTINUED_NOTE.test(p.text)))
|
|
80
|
+
.map((p) => (typeof p.text === "string" && !p.thought ? p.text : ""))
|
|
81
|
+
.join("")
|
|
82
|
+
/** A user message's text, unless Gemini CLI leaves the message out of the conversation. */
|
|
83
|
+
const askText = (m: { content?: unknown }) => (ignoredUserContent(m.content as Part[]) ? "" : userText(m))
|
|
84
|
+
|
|
85
|
+
interface Summary {
|
|
86
|
+
sessionId: string
|
|
87
|
+
kind?: string
|
|
88
|
+
projectHash?: string
|
|
89
|
+
startTime?: string
|
|
90
|
+
lastUpdated?: string
|
|
91
|
+
title?: string
|
|
92
|
+
/** Tool arguments, kept only for legacy files whose project folder must be recovered. */
|
|
93
|
+
args?: unknown
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Enough of a session for the list. A legacy .json is one document, so it is parsed whole; a .jsonl is
|
|
98
|
+
* scanned for its metadata line, its {$set} records and its first user message only.
|
|
99
|
+
*/
|
|
100
|
+
function summarize(path: string): Summary | undefined {
|
|
101
|
+
if (path.endsWith(".json")) {
|
|
102
|
+
const conv = loadConversation(path)?.conv
|
|
103
|
+
if (!conv || !conv.messages.some((m) => m.type === "user")) return undefined
|
|
104
|
+
const first = conv.messages.find((m) => m.type === "user" && askText(m).trim())
|
|
105
|
+
return {
|
|
106
|
+
sessionId: conv.sessionId,
|
|
107
|
+
kind: conv.kind,
|
|
108
|
+
projectHash: conv.projectHash,
|
|
109
|
+
startTime: conv.startTime,
|
|
110
|
+
lastUpdated: conv.lastUpdated,
|
|
111
|
+
title: titleFrom(conv.summary) ?? titleFrom(first && userText(first)),
|
|
112
|
+
args: conv.messages.map((m) => m.toolCalls?.map((t) => t.args)),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const text = readText(path)
|
|
116
|
+
if (!text) return undefined
|
|
117
|
+
let meta: Record<string, any> = {}
|
|
118
|
+
let first: string | undefined
|
|
119
|
+
let line0 = true
|
|
120
|
+
for (const line of text.split("\n")) {
|
|
121
|
+
if (!line.trim()) continue
|
|
122
|
+
const isFirst = line0
|
|
123
|
+
line0 = false
|
|
124
|
+
if (!isFirst && !line.startsWith('{"$set"') && (first !== undefined || !line.includes('"type":"user"'))) continue
|
|
125
|
+
try {
|
|
126
|
+
const r = JSON.parse(line)
|
|
127
|
+
if (isFirst && typeof r.sessionId === "string") meta = { ...meta, ...r }
|
|
128
|
+
else if (r.$set && typeof r.$set === "object") {
|
|
129
|
+
meta = { ...meta, ...r.$set }
|
|
130
|
+
if (first === undefined && Array.isArray(r.$set.messages)) first = r.$set.messages.map((m: any) => (m?.type === "user" ? askText(m) : "")).find((t: string) => t.trim())
|
|
131
|
+
} else if (r.type === "user" && askText(r).trim()) first = askText(r)
|
|
132
|
+
} catch {
|
|
133
|
+
// a half-written line; the full read will count it
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (typeof meta.sessionId !== "string" || first === undefined) return undefined
|
|
137
|
+
return { sessionId: meta.sessionId, kind: meta.kind, projectHash: meta.projectHash, startTime: meta.startTime, lastUpdated: meta.lastUpdated, title: titleFrom(meta.summary) ?? titleFrom(first) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- checkpoints ------------------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
interface Checkpoint {
|
|
143
|
+
path: string
|
|
144
|
+
at?: string
|
|
145
|
+
history: Array<{ role?: string; parts?: Part[] }>
|
|
146
|
+
/** The UI's history items; their ids are the epoch milliseconds they were shown. */
|
|
147
|
+
ui: Array<{ id?: unknown }>
|
|
148
|
+
args: unknown
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** "2025-07-28T15-52-02_821Z-file.ts-replace.json" was written at 2025-07-28T15:52:02.821Z. */
|
|
152
|
+
function checkpointTime(name: string): string | undefined {
|
|
153
|
+
const m = name.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})_(\d{3})Z/)
|
|
154
|
+
return m ? `${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z` : undefined
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readCheckpoint(path: string): Checkpoint | undefined {
|
|
158
|
+
const j = readJson<Record<string, any>>(path)
|
|
159
|
+
if (!j || !Array.isArray(j.clientHistory)) return undefined
|
|
160
|
+
return { path, at: checkpointTime(basename(path)), history: j.clientHistory, ui: Array.isArray(j.history) ? j.history : [], args: [j.toolCall?.args, j.filePath, j.clientHistory] }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Checkpoints grouped by conversation: each one holds the whole API history up to its edit, so a later
|
|
165
|
+
* checkpoint of the same conversation starts with an earlier one's history.
|
|
166
|
+
*/
|
|
167
|
+
function checkpointGroups(dir: string): Checkpoint[][] {
|
|
168
|
+
const groups: Checkpoint[][] = []
|
|
169
|
+
const key = (c: unknown) => JSON.stringify(c)
|
|
170
|
+
for (const name of ls(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
171
|
+
const cp = readCheckpoint(join(dir, name))
|
|
172
|
+
if (!cp || !cp.history.length) continue
|
|
173
|
+
const g = groups.find((g) => {
|
|
174
|
+
const last = g[g.length - 1]!
|
|
175
|
+
return last.history.length <= cp.history.length && last.history.every((c, i) => key(c) === key(cp.history[i]))
|
|
176
|
+
})
|
|
177
|
+
if (g) g.push(cp)
|
|
178
|
+
else groups.push([cp])
|
|
179
|
+
}
|
|
180
|
+
return groups
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Before chat recording, Gemini CLI opened every conversation with its own environment context and an acknowledgement. */
|
|
184
|
+
const isSetupContext = (c: { role?: string; parts?: Part[] } | undefined) =>
|
|
185
|
+
c?.role === "user" && (c.parts ?? []).some((p) => typeof p.text === "string" && p.text.startsWith("This is the Gemini CLI. We are setting up the context"))
|
|
186
|
+
|
|
187
|
+
function checkpointRefs(dir: string, folder: string, spans: Array<[number, number]>, folders: Map<string, string>): SessionRef[] {
|
|
188
|
+
const refs: SessionRef[] = []
|
|
189
|
+
for (const g of checkpointGroups(join(dir, "checkpoints"))) {
|
|
190
|
+
const first = Date.parse(g[0]!.at ?? "")
|
|
191
|
+
const last = Date.parse(g[g.length - 1]!.at ?? "")
|
|
192
|
+
// Newer versions record the same conversation in chats/ too; read it from there.
|
|
193
|
+
if (Number.isFinite(first) && spans.some(([s, e]) => first <= e && last >= s)) continue
|
|
194
|
+
const latest = g[g.length - 1]!
|
|
195
|
+
const title = titleFrom(firstAsk(latest.history))
|
|
196
|
+
const stamp = basename(g[0]!.path).match(/^[^Z]*Z/)?.[0] ?? basename(g[0]!.path, ".json")
|
|
197
|
+
const cwd = projectCwd(folders, folder, undefined, () => g.map((c) => c.args))
|
|
198
|
+
refs.push({
|
|
199
|
+
tool: TOOL,
|
|
200
|
+
sourceId: `checkpoint-${stamp}-${folder.slice(0, 8)}`,
|
|
201
|
+
path: latest.path,
|
|
202
|
+
...(title ? { title } : {}),
|
|
203
|
+
...(cwd ? { cwd } : {}),
|
|
204
|
+
...(latest.at ? { updatedAt: latest.at } : {}),
|
|
205
|
+
extra: { folder, checkpoints: g.map((c) => c.path) },
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
return refs
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The first thing the person asked in a checkpoint's history, after Gemini CLI's own setup exchange. */
|
|
212
|
+
const firstAsk = (history: Checkpoint["history"]) => {
|
|
213
|
+
const c = history.find((c, i) => c.role === "user" && !(i === 0 && isSetupContext(c)) && userText({ content: c.parts }).trim())
|
|
214
|
+
return c ? userText({ content: c.parts }) : undefined
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readCheckpointSession(ref: SessionRef, home: string): Session {
|
|
218
|
+
const files = (ref.extra?.checkpoints as string[] | undefined) ?? [ref.path]
|
|
219
|
+
const cps = files.map(readCheckpoint).filter((c): c is Checkpoint => Boolean(c))
|
|
220
|
+
const latest = cps[cps.length - 1]
|
|
221
|
+
if (!latest) throw new Error(`${ref.path} is not a Gemini CLI checkpoint`)
|
|
222
|
+
const shown = latest.ui.map((u) => (typeof u.id === "number" ? u.id : NaN)).filter(Number.isFinite)
|
|
223
|
+
const startedAt = shown.length ? iso(Math.min(...shown)) : cps[0]!.at
|
|
224
|
+
const folder = (ref.extra?.folder as string | undefined) ?? basename(dirname(dirname(latest.path)))
|
|
225
|
+
const cwd = ref.cwd ?? projectCwd(projectFolders(home), folder, undefined, () => cps.map((c) => c.args))
|
|
226
|
+
const title = ref.title ?? titleFrom(firstAsk(latest.history))
|
|
227
|
+
const h = header(TOOL, ref.sourceId, {
|
|
228
|
+
sourcePath: latest.path,
|
|
229
|
+
...(title ? { title } : {}),
|
|
230
|
+
project: { ...(cwd ? { cwd } : {}), ...gitInfo(cwd) },
|
|
231
|
+
...(startedAt ? { startedAt } : {}),
|
|
232
|
+
...(latest.at ? { updatedAt: latest.at } : {}),
|
|
233
|
+
})
|
|
234
|
+
const lost = lossCounter()
|
|
235
|
+
const ctx = newCtx(h, lost)
|
|
236
|
+
let history = latest.history
|
|
237
|
+
if (isSetupContext(history[0])) {
|
|
238
|
+
lost.add("Gemini CLI setup context")
|
|
239
|
+
history = history.slice(history[1]?.role === "model" ? 2 : 1)
|
|
240
|
+
}
|
|
241
|
+
let turns: Turn[] = []
|
|
242
|
+
for (const c of history) {
|
|
243
|
+
if (c.role !== "user" && c.role !== "model") lost.add(`unknown record types: ${String(c.role)}`)
|
|
244
|
+
else turns.push(...partsToTurns(c.parts, c.role, undefined, ctx))
|
|
245
|
+
}
|
|
246
|
+
// The newest checkpoint is taken just before its own edit runs, so that call, the last turn, has no
|
|
247
|
+
// result yet; an open call before it was cancelled or interrupted.
|
|
248
|
+
const final = turns[turns.length - 1]
|
|
249
|
+
turns = closeOpenCalls(turns, ctx.pair.open(), ctx, (c) => (c === final ? "the checkpoint was saved before this call ran" : "the call was cancelled or interrupted"))
|
|
250
|
+
lost.add("timestamps and model names (checkpoints keep neither)")
|
|
251
|
+
lost.flush(h)
|
|
252
|
+
return { header: h, turns }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---- chat sessions ----------------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
function toSession(conv: Conversation, path: string, cwd: string | undefined, extraLost: Array<[string, number]>, parent?: { id: string; sourceId: string }): Session {
|
|
258
|
+
const first = conv.messages.find((m) => m.type === "user" && askText(m).trim())
|
|
259
|
+
const title = titleFrom(conv.summary) ?? titleFrom(first && askText(first))
|
|
260
|
+
const startedAt = iso(conv.startTime)
|
|
261
|
+
const updatedAt = iso(conv.lastUpdated) ?? mtime(path)
|
|
262
|
+
const h = header(TOOL, parent?.sourceId ?? conv.sessionId, {
|
|
263
|
+
sourcePath: path,
|
|
264
|
+
...(title ? { title } : {}),
|
|
265
|
+
project: { ...(cwd ? { cwd } : {}), ...gitInfo(cwd) },
|
|
266
|
+
...(startedAt ? { startedAt } : {}),
|
|
267
|
+
...(updatedAt ? { updatedAt } : {}),
|
|
268
|
+
...(parent ? { parent: { id: parent.id, kind: "subagent" as const } } : {}),
|
|
269
|
+
})
|
|
270
|
+
const lost = lossCounter()
|
|
271
|
+
for (const [what, n] of extraLost) lost.add(what, n)
|
|
272
|
+
const ctx = newCtx(h, lost)
|
|
273
|
+
const turns = closeOpenCalls(messagesToTurns(conv.messages, ctx), ctx.pair.open(), ctx, () => "the call never got a response")
|
|
274
|
+
lost.flush(h)
|
|
275
|
+
return { header: h, turns }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function load(path: string): { conv: Conversation; lost: Array<[string, number]> } {
|
|
279
|
+
const l = loadConversation(path)
|
|
280
|
+
if (!l) throw new Error(`${path} is not a Gemini CLI session file`)
|
|
281
|
+
const lost: Array<[string, number]> = [...l.unknown].map(([k, n]) => [`unknown record types: ${k}`, n])
|
|
282
|
+
if (l.bad) lost.push(["unreadable lines", l.bad])
|
|
283
|
+
return { conv: l.conv, lost }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* A session's subagents, each placed where it started among the parent's turns. Current versions keep
|
|
288
|
+
* them in chats/<parent session id>/<id>.jsonl; 0.33 wrote them beside the parent as session-*.json with
|
|
289
|
+
* the parent's session id and kind "subagent".
|
|
290
|
+
*/
|
|
291
|
+
function withChildren(s: Session, path: string, cwd: string | undefined): Session {
|
|
292
|
+
const chats = dirname(path)
|
|
293
|
+
const id = s.header.sourceId
|
|
294
|
+
const files = [
|
|
295
|
+
...ls(join(chats, id)).filter((n) => /\.jsonl?$/.test(n)).map((n) => join(chats, id, n)),
|
|
296
|
+
...ls(chats).filter((n) => /^session-.*\.json$/.test(n) && join(chats, n) !== path).map((n) => join(chats, n)),
|
|
297
|
+
].sort()
|
|
298
|
+
const children: Session[] = []
|
|
299
|
+
for (const file of files) {
|
|
300
|
+
const nested = dirname(file) !== chats
|
|
301
|
+
try {
|
|
302
|
+
const { conv, lost } = load(file)
|
|
303
|
+
if (!nested && (conv.sessionId !== id || conv.kind !== "subagent")) continue
|
|
304
|
+
const sourceId = nested ? conv.sessionId : `${id}/${basename(file).replace(/\.json$/, "")}`
|
|
305
|
+
children.push(toSession(conv, file, cwd, lost, { id: s.header.id, sourceId }))
|
|
306
|
+
} catch {
|
|
307
|
+
// A file beside the session that cannot be read may be any session of the project, not this one's.
|
|
308
|
+
if (nested) s.header.lost.push(`unreadable subagent session (${basename(file)})`)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (!children.length) return s
|
|
312
|
+
const turns = [...s.turns]
|
|
313
|
+
for (const c of children) {
|
|
314
|
+
const at = Date.parse(c.header.startedAt ?? "")
|
|
315
|
+
const end = Date.parse(c.header.updatedAt ?? "")
|
|
316
|
+
let i = Number.isFinite(at) ? turns.findIndex((x) => x.at !== undefined && Date.parse(x.at) > at) : -1
|
|
317
|
+
// A tool call's time is when it finished, so the call that ran the subagent is timed after the
|
|
318
|
+
// subagent's last message: among the tool turns found by time, the first call that ended after the
|
|
319
|
+
// subagent did is its call, and the subagent goes after that call's result and edits.
|
|
320
|
+
let callId: string | undefined
|
|
321
|
+
for (let j = i; j >= 0 && j < turns.length && Number.isFinite(end) && /^(tool_call|tool_result|file_edit|subagent)$/.test(turns[j]!.kind); j++) {
|
|
322
|
+
const x = turns[j]!
|
|
323
|
+
if (x.kind === "tool_call" && x.at && Date.parse(x.at) >= end) {
|
|
324
|
+
callId = x.id
|
|
325
|
+
i = j + 1
|
|
326
|
+
while (i < turns.length && (turns[i]!.kind === "tool_result" || turns[i]!.kind === "file_edit") && (turns[i] as { callId?: string }).callId === callId) i++
|
|
327
|
+
break
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// Never between a call and its result.
|
|
331
|
+
while (i >= 0 && i < turns.length && turns[i]!.kind === "tool_result") i++
|
|
332
|
+
const t: Turn = { kind: "subagent", ...(c.header.startedAt ? { at: c.header.startedAt } : {}), sessionId: c.header.id, ...(callId ? { callId } : {}), ...(c.header.title ? { description: c.header.title } : {}) }
|
|
333
|
+
if (i < 0 || i >= turns.length) turns.push(t)
|
|
334
|
+
else turns.splice(i, 0, t)
|
|
335
|
+
}
|
|
336
|
+
return { ...s, turns, children }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Which file stands for a session id: a main session over a subagent, the live .jsonl over its legacy .json, then the newest. */
|
|
340
|
+
function rank(r: SessionRef): string {
|
|
341
|
+
return `${r.extra?.subagent ? 0 : 1}${r.path.endsWith(".jsonl") ? 1 : 0}${r.updatedAt ?? ""}`
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export const reader: Reader = {
|
|
345
|
+
tool: TOOL,
|
|
346
|
+
label: LABEL,
|
|
347
|
+
|
|
348
|
+
async list(home) {
|
|
349
|
+
const tmp = join(home, ".gemini", "tmp")
|
|
350
|
+
if (!isDir(tmp)) return []
|
|
351
|
+
const folders = projectFolders(home)
|
|
352
|
+
const refs: SessionRef[] = []
|
|
353
|
+
for (const folder of ls(tmp)) {
|
|
354
|
+
const dir = join(tmp, folder)
|
|
355
|
+
if (!isDir(dir)) continue
|
|
356
|
+
const chats = join(dir, "chats")
|
|
357
|
+
const byId = new Map<string, SessionRef>()
|
|
358
|
+
const spans: Array<[number, number]> = []
|
|
359
|
+
for (const name of ls(chats)) {
|
|
360
|
+
if (!/^session-.*\.jsonl?$/.test(name)) continue
|
|
361
|
+
const path = join(chats, name)
|
|
362
|
+
const s = summarize(path)
|
|
363
|
+
if (!s) continue
|
|
364
|
+
const updatedAt = iso(s.lastUpdated) ?? mtime(path)
|
|
365
|
+
spans.push([Date.parse(iso(s.startTime) ?? updatedAt ?? ""), Date.parse(updatedAt ?? "")])
|
|
366
|
+
const cwd = projectCwd(folders, folder, s.projectHash, () => s.args)
|
|
367
|
+
const ref: SessionRef = {
|
|
368
|
+
tool: TOOL,
|
|
369
|
+
sourceId: s.sessionId,
|
|
370
|
+
path,
|
|
371
|
+
...(s.title ? { title: s.title } : {}),
|
|
372
|
+
...(cwd ? { cwd } : {}),
|
|
373
|
+
...(updatedAt ? { updatedAt } : {}),
|
|
374
|
+
extra: { folder, ...(s.kind === "subagent" ? { subagent: true } : {}) },
|
|
375
|
+
}
|
|
376
|
+
// One id can have several files: 0.33's subagents share their parent's id, and resuming a legacy
|
|
377
|
+
// .json writes a .jsonl copy beside it. The main, live file stands for the session.
|
|
378
|
+
const prev = byId.get(s.sessionId)
|
|
379
|
+
if (!prev || rank(ref) > rank(prev)) byId.set(s.sessionId, ref)
|
|
380
|
+
}
|
|
381
|
+
refs.push(...byId.values(), ...checkpointRefs(dir, folder, spans, folders))
|
|
382
|
+
}
|
|
383
|
+
return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
async read(ref, home) {
|
|
387
|
+
if (ref.extra?.checkpoints) return readCheckpointSession(ref, home)
|
|
388
|
+
const { conv, lost } = load(ref.path)
|
|
389
|
+
const folder = (ref.extra?.folder as string | undefined) ?? basename(dirname(dirname(ref.path)))
|
|
390
|
+
const cwd = ref.cwd ?? projectCwd(projectFolders(home), folder, conv.projectHash, () => conv.messages.map((m) => m.toolCalls?.map((t) => t.args)))
|
|
391
|
+
return withChildren(toSession(conv, ref.path, cwd, lost), ref.path, cwd)
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export const readers: Reader[] = [reader]
|
|
396
|
+
export const writers: Writer[] = [geminiNativeWriter]
|