@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,521 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import type { Attachment, SessionHeader, Turn, Usage } from "../../session/types.ts"
|
|
3
|
+
import { asText, clip, iso, noteModel, toolKindOf, type lossCounter } from "../../session/util.ts"
|
|
4
|
+
|
|
5
|
+
// Facts about Gemini CLI's session records, shared by its reader. Checked against the installed 0.33.1
|
|
6
|
+
// (whole-file chats/session-*.json and checkpoints/*.json on this Mac) and against upstream main
|
|
7
|
+
// (packages/core/src/services/chatRecordingService.ts and chatRecordingTypes.ts), which writes the
|
|
8
|
+
// append-only .jsonl. See docs/sessions/2026-11-08-session-formats.md, entry 4.
|
|
9
|
+
|
|
10
|
+
export const TOOL = "gemini-cli"
|
|
11
|
+
export const LABEL = "Gemini CLI"
|
|
12
|
+
|
|
13
|
+
type Lost = ReturnType<typeof lossCounter>
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The note brain puts first in a continued session's first user message, as its own text part: the model
|
|
17
|
+
* reads it, the screen shows the message's displayContent without it. Readers drop it and record the source.
|
|
18
|
+
*/
|
|
19
|
+
export const continuedNote = (label: string, id: string, when: string) =>
|
|
20
|
+
`[This session continues ${label} session ${id}, carried over by brain session on ${when}. The conversation below is its history; what could not carry over is left out. Check files before relying on details, since they may have changed.]`
|
|
21
|
+
export const CONTINUED_NOTE = /^\[This session continues .+? session (\S+), carried over by brain session/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Gemini CLI's compression replaces the history with its summary as a user message (the model writes it
|
|
25
|
+
* inside <state_snapshot>) and a fixed model acknowledgement; current versions persist that in the session.
|
|
26
|
+
*/
|
|
27
|
+
export const SNAPSHOT_OPEN = "<state_snapshot>"
|
|
28
|
+
export const SNAPSHOT_ACK = "Got it. Thanks for the additional context!"
|
|
29
|
+
/** A portable compaction's summary as the user message Gemini CLI's compression writes. */
|
|
30
|
+
export const snapshotOf = (label: string, summary: string) =>
|
|
31
|
+
`${SNAPSHOT_OPEN}\n[Summary of the earlier conversation in ${label}, which replaced the turns before it]\n\n${summary}\n</state_snapshot>`
|
|
32
|
+
/** The summary inside a <state_snapshot> message, or undefined when the text is not one. */
|
|
33
|
+
export const snapshotText = (text: string) => {
|
|
34
|
+
const t = text.trim()
|
|
35
|
+
if (!t.startsWith(SNAPSHOT_OPEN)) return undefined
|
|
36
|
+
// The header snapshotOf adds is brain's, not part of the summary.
|
|
37
|
+
return t.slice(SNAPSHOT_OPEN.length).replace(/<\/state_snapshot>\s*$/, "").trim().replace(/^\[Summary of the earlier conversation in .+?, which replaced the turns before it\]\n\n/, "")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A Part from @google/genai, as Gemini CLI stores it in messages, tool results and checkpoints. */
|
|
41
|
+
export interface Part {
|
|
42
|
+
text?: string
|
|
43
|
+
/** A thought summary part (readable). */
|
|
44
|
+
thought?: boolean
|
|
45
|
+
/** Gemini 3 signs function calls and thoughts; signatures never cross to another model. */
|
|
46
|
+
thoughtSignature?: string
|
|
47
|
+
functionCall?: { id?: string; name?: string; args?: unknown }
|
|
48
|
+
functionResponse?: { id?: string; name?: string; response?: unknown }
|
|
49
|
+
inlineData?: { mimeType?: string; data?: string }
|
|
50
|
+
fileData?: { mimeType?: string; fileUri?: string }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** PartListUnion: a string, one Part, or a list of either. */
|
|
54
|
+
export type PartList = string | Part | Array<string | Part>
|
|
55
|
+
|
|
56
|
+
export interface ToolCallRecord {
|
|
57
|
+
id: string
|
|
58
|
+
name: string
|
|
59
|
+
args?: unknown
|
|
60
|
+
result?: PartList | null
|
|
61
|
+
status?: string
|
|
62
|
+
timestamp?: string
|
|
63
|
+
resultDisplay?: unknown
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface MessageRecord {
|
|
67
|
+
id: string
|
|
68
|
+
timestamp?: string
|
|
69
|
+
/** user | gemini | info | error | warning */
|
|
70
|
+
type: string
|
|
71
|
+
content?: PartList
|
|
72
|
+
toolCalls?: ToolCallRecord[]
|
|
73
|
+
thoughts?: Array<{ subject?: string; description?: string; timestamp?: string }>
|
|
74
|
+
tokens?: { input?: number; output?: number; cached?: number; thoughts?: number } | null
|
|
75
|
+
model?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface Conversation {
|
|
79
|
+
sessionId: string
|
|
80
|
+
projectHash?: string
|
|
81
|
+
startTime?: string
|
|
82
|
+
lastUpdated?: string
|
|
83
|
+
/** A model-written summary of the session (newer versions), used as its title. */
|
|
84
|
+
summary?: string
|
|
85
|
+
kind?: string
|
|
86
|
+
messages: MessageRecord[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface Loaded {
|
|
90
|
+
conv: Conversation
|
|
91
|
+
/** Record kinds the replay did not know, by name. */
|
|
92
|
+
unknown: Map<string, number>
|
|
93
|
+
/** Lines that were not JSON (a half-written last line, say). */
|
|
94
|
+
bad: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const isObj = (v: unknown): v is Record<string, any> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
98
|
+
|
|
99
|
+
function asConversation(v: unknown): Conversation | undefined {
|
|
100
|
+
if (!isObj(v) || typeof v.sessionId !== "string" || !Array.isArray(v.messages)) return undefined
|
|
101
|
+
return { ...(v as Conversation), messages: v.messages.filter((m: unknown) => isObj(m) && typeof m.id === "string") }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A session file, whichever way it was written: the older whole-file JSON (one ConversationRecord,
|
|
106
|
+
* rewritten every turn) or the current JSONL log, replayed. Undefined when it is neither.
|
|
107
|
+
*/
|
|
108
|
+
export function loadConversation(path: string): Loaded | undefined {
|
|
109
|
+
let text: string
|
|
110
|
+
try {
|
|
111
|
+
text = readFileSync(path, "utf8")
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
const whole = () => {
|
|
116
|
+
try {
|
|
117
|
+
const conv = asConversation(JSON.parse(text))
|
|
118
|
+
return conv ? { conv, unknown: new Map<string, number>(), bad: 0 } : undefined
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Gemini falls back the same way: a .jsonl whose replay finds no metadata is tried as legacy JSON.
|
|
124
|
+
return path.endsWith(".json") ? whole() ?? replay(text) : replay(text) ?? whole()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Replays a JSONL session the way chatRecordingService's loader does: the first line is metadata; a
|
|
129
|
+
* message line adds a message, or replaces an earlier one with the same id in place (that is how tool
|
|
130
|
+
* results and token counts are added after the fact); {$set} merges metadata (a `messages` array there is
|
|
131
|
+
* a legacy full-history checkpoint that replaces everything); {$patch} edits messages by id, removes
|
|
132
|
+
* some, or reorders them; {$rewindTo: id} drops that message and everything after it.
|
|
133
|
+
*/
|
|
134
|
+
export function replay(text: string): Loaded | undefined {
|
|
135
|
+
let meta: Record<string, any> = {}
|
|
136
|
+
const msgs = new Map<string, MessageRecord>()
|
|
137
|
+
const unknown = new Map<string, number>()
|
|
138
|
+
let bad = 0
|
|
139
|
+
const addAll = (list: unknown) => {
|
|
140
|
+
if (Array.isArray(list)) for (const m of list) if (isObj(m) && typeof m.id === "string") msgs.set(m.id, m as MessageRecord)
|
|
141
|
+
}
|
|
142
|
+
const patchOne = (p: Record<string, any>) => {
|
|
143
|
+
const m = msgs.get(p.id)
|
|
144
|
+
if (!m) return
|
|
145
|
+
if (p.content !== undefined) m.content = p.content
|
|
146
|
+
if (Array.isArray(p.toolCalls) && m.toolCalls) {
|
|
147
|
+
for (const tp of p.toolCalls) {
|
|
148
|
+
const tc = m.toolCalls.find((t) => t.id === tp?.id)
|
|
149
|
+
if (tc && "result" in tp) tc.result = tp.result
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const line of text.split("\n")) {
|
|
154
|
+
if (!line.trim()) continue
|
|
155
|
+
let r: unknown
|
|
156
|
+
try {
|
|
157
|
+
r = JSON.parse(line)
|
|
158
|
+
} catch {
|
|
159
|
+
bad++
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
if (!isObj(r)) {
|
|
163
|
+
bad++
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
if (typeof r.$rewindTo === "string") {
|
|
167
|
+
let found = false
|
|
168
|
+
for (const id of [...msgs.keys()]) {
|
|
169
|
+
if (id === r.$rewindTo) found = true
|
|
170
|
+
if (found) msgs.delete(id)
|
|
171
|
+
}
|
|
172
|
+
if (!found) msgs.clear()
|
|
173
|
+
} else if (isObj(r.$patch)) {
|
|
174
|
+
const p = r.$patch
|
|
175
|
+
if (typeof p.id === "string") patchOne(p)
|
|
176
|
+
if (Array.isArray(p.updates)) for (const u of p.updates) if (isObj(u) && typeof u.id === "string") patchOne(u)
|
|
177
|
+
if (Array.isArray(p.removeIds)) for (const id of p.removeIds) if (typeof id === "string") msgs.delete(id)
|
|
178
|
+
if (Array.isArray(p.orderIds)) {
|
|
179
|
+
// Messages named in orderIds move to the end in that order; the rest keep their places before them.
|
|
180
|
+
const ordered = p.orderIds.filter((id: unknown) => typeof id === "string" && msgs.has(id)) as string[]
|
|
181
|
+
const keep = new Set(ordered)
|
|
182
|
+
const entries = [...[...msgs].filter(([id]) => !keep.has(id)), ...ordered.map((id) => [id, msgs.get(id)!] as const)]
|
|
183
|
+
msgs.clear()
|
|
184
|
+
for (const [id, m] of entries) msgs.set(id, m)
|
|
185
|
+
}
|
|
186
|
+
} else if (typeof r.id === "string") {
|
|
187
|
+
msgs.set(r.id, r as MessageRecord)
|
|
188
|
+
} else if (isObj(r.$set)) {
|
|
189
|
+
const { messages, ...rest } = r.$set
|
|
190
|
+
if (Array.isArray(messages)) {
|
|
191
|
+
msgs.clear()
|
|
192
|
+
addAll(messages)
|
|
193
|
+
}
|
|
194
|
+
meta = { ...meta, ...rest }
|
|
195
|
+
} else if (typeof r.sessionId === "string" && typeof r.projectHash === "string") {
|
|
196
|
+
const { messages, ...rest } = r
|
|
197
|
+
meta = { ...meta, ...rest }
|
|
198
|
+
addAll(messages)
|
|
199
|
+
} else {
|
|
200
|
+
const name = Object.keys(r)[0] ?? "empty"
|
|
201
|
+
unknown.set(name, (unknown.get(name) ?? 0) + 1)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (typeof meta.sessionId !== "string") return undefined
|
|
205
|
+
return { conv: { ...(meta as Omit<Conversation, "messages">), sessionId: meta.sessionId, messages: [...msgs.values()] }, unknown, bad }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The parts of a PartListUnion as a list. */
|
|
209
|
+
export function toParts(c: PartList | null | undefined): Part[] {
|
|
210
|
+
if (c === undefined || c === null) return []
|
|
211
|
+
if (typeof c === "string") return [{ text: c }]
|
|
212
|
+
if (Array.isArray(c)) return c.map((p) => (typeof p === "string" ? { text: p } : p)).filter(isObj)
|
|
213
|
+
return isObj(c) ? [c] : []
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Readable text of a functionResponse.response: tools answer {output}, {error}, or both. */
|
|
217
|
+
function responseText(r: unknown): { text: string; ok: boolean } {
|
|
218
|
+
if (isObj(r) && ("output" in r || "error" in r)) {
|
|
219
|
+
const text = [asText(r.output), asText(r.error)].filter(Boolean).join("\n")
|
|
220
|
+
return { text, ok: !r.error }
|
|
221
|
+
}
|
|
222
|
+
return { text: asText(r), ok: true }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Text of a tool result (its functionResponse parts plus any text parts), whether it failed, and binary parts. */
|
|
226
|
+
export function resultText(result: PartList | null | undefined): { text: string; ok: boolean; binary: number } {
|
|
227
|
+
const out: string[] = []
|
|
228
|
+
let ok = true
|
|
229
|
+
let binary = 0
|
|
230
|
+
for (const p of toParts(result)) {
|
|
231
|
+
if (p.functionResponse) {
|
|
232
|
+
const r = responseText(p.functionResponse.response)
|
|
233
|
+
out.push(r.text)
|
|
234
|
+
ok &&= r.ok
|
|
235
|
+
} else if (typeof p.text === "string") out.push(p.text)
|
|
236
|
+
else if (p.inlineData || p.fileData) binary++
|
|
237
|
+
}
|
|
238
|
+
return { text: out.filter(Boolean).join("\n"), ok, binary }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Gemini's per-message token counts. Its `input` includes the cached part and its `output` leaves out
|
|
243
|
+
* thinking; the portable record keeps cached input apart and counts thinking as output, as Claude's and
|
|
244
|
+
* OpenAI's counts (and so the Claude Code and Codex readers) do.
|
|
245
|
+
*/
|
|
246
|
+
function usageOf(t: MessageRecord["tokens"]): Usage | undefined {
|
|
247
|
+
if (!t) return undefined
|
|
248
|
+
const cached = typeof t.cached === "number" && t.cached > 0 ? t.cached : 0
|
|
249
|
+
const u: Usage = {}
|
|
250
|
+
if (typeof t.input === "number") u.input = Math.max(0, t.input - cached)
|
|
251
|
+
if (typeof t.output === "number") u.output = t.output + (typeof t.thoughts === "number" ? t.thoughts : 0)
|
|
252
|
+
if (cached) u.cacheRead = cached
|
|
253
|
+
return Object.keys(u).length ? u : undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Keeps tool call ids unique within a session (a rewound turn can reuse one) and names calls that have none. */
|
|
257
|
+
export function idMaker() {
|
|
258
|
+
const seen = new Set<string>()
|
|
259
|
+
let n = 0
|
|
260
|
+
return (id: string | undefined): string => {
|
|
261
|
+
const base = id || `gemini_call_${++n}`
|
|
262
|
+
let out = base
|
|
263
|
+
for (let i = 2; seen.has(out); i++) out = `${base}#${i}`
|
|
264
|
+
seen.add(out)
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
type CallTurn = Extract<Turn, { kind: "tool_call" }>
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Pairs function calls with their responses across the API history kept in checkpoints, where a
|
|
273
|
+
* functionCall part often has no id and only the functionResponse carries one: by id when both have it,
|
|
274
|
+
* otherwise the oldest pending call with the same name, otherwise the oldest pending call.
|
|
275
|
+
*/
|
|
276
|
+
export function pairer(newId: ReturnType<typeof idMaker>) {
|
|
277
|
+
// `src` is the call's own id as recorded; the turn's id can differ when idMaker had to make it unique.
|
|
278
|
+
const pending: Array<{ turn: CallTurn; src?: string; name: string }> = []
|
|
279
|
+
const answered = new Set<string>()
|
|
280
|
+
return {
|
|
281
|
+
call(fc: NonNullable<Part["functionCall"]>, at: string | undefined): CallTurn {
|
|
282
|
+
const name = fc.name ?? "unknown"
|
|
283
|
+
const turn: CallTurn = { kind: "tool_call", ...(at ? { at } : {}), id: newId(fc.id), name, toolKind: toolKindOf(name), input: fc.args ?? {} }
|
|
284
|
+
pending.push({ turn, ...(fc.id ? { src: fc.id } : {}), name })
|
|
285
|
+
return turn
|
|
286
|
+
},
|
|
287
|
+
/** The id of the call this response answers, or undefined when no call is waiting for it. */
|
|
288
|
+
respond(fr: NonNullable<Part["functionResponse"]>): string | undefined {
|
|
289
|
+
let i = fr.id ? pending.findIndex((p) => p.src === fr.id) : -1
|
|
290
|
+
if (i < 0) i = pending.findIndex((p) => !p.src && p.name === fr.name)
|
|
291
|
+
if (i < 0) i = pending.findIndex((p) => !p.src)
|
|
292
|
+
if (i < 0) return undefined
|
|
293
|
+
const [p] = pending.splice(i, 1)
|
|
294
|
+
// A call without an id takes its response's id, so the pair reads naturally.
|
|
295
|
+
if (!p!.src && fr.id) p!.turn.id = newId(fr.id)
|
|
296
|
+
if (fr.id) answered.add(fr.id)
|
|
297
|
+
return p!.turn.id
|
|
298
|
+
},
|
|
299
|
+
/** The call recorded with this id, while it still waits for its result. */
|
|
300
|
+
waiting: (id: string | undefined) => (id ? pending.find((p) => p.src === id)?.turn : undefined),
|
|
301
|
+
/** Marks a call answered by a result found elsewhere (the tool call record itself). */
|
|
302
|
+
settle(turn: CallTurn, id: string | undefined) {
|
|
303
|
+
const i = pending.findIndex((p) => p.turn === turn)
|
|
304
|
+
if (i >= 0) pending.splice(i, 1)
|
|
305
|
+
if (id) answered.add(id)
|
|
306
|
+
},
|
|
307
|
+
/** Whether a response with this id was paired already and no call with it waits: a second copy of it. */
|
|
308
|
+
repeated: (id: string | undefined) => Boolean(id && answered.has(id) && !pending.some((p) => p.src === id)),
|
|
309
|
+
/** Calls still waiting, in order. */
|
|
310
|
+
open: () => pending.map((p) => p.turn),
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export interface Ctx {
|
|
315
|
+
h: SessionHeader
|
|
316
|
+
lost: Lost
|
|
317
|
+
newId: ReturnType<typeof idMaker>
|
|
318
|
+
pair: ReturnType<typeof pairer>
|
|
319
|
+
/** File edits waiting for their call's result, which a later user message carries, by call id. */
|
|
320
|
+
edits: Map<string, Turn>
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function newCtx(h: SessionHeader, lost: Lost): Ctx {
|
|
324
|
+
const newId = idMaker()
|
|
325
|
+
return { h, lost, newId, pair: pairer(newId), edits: new Map() }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The turns in one message's content. Text becomes a user or assistant turn; thought parts become
|
|
330
|
+
* reasoning; functionCall and functionResponse parts become tool calls and results paired by `ctx.pair`.
|
|
331
|
+
*/
|
|
332
|
+
export function partsToTurns(content: PartList | undefined, role: "user" | "model", at: string | undefined, ctx: Ctx, model?: string, usage?: Usage): Turn[] {
|
|
333
|
+
const out: Turn[] = []
|
|
334
|
+
let text = ""
|
|
335
|
+
let attachments: Attachment[] = []
|
|
336
|
+
const flush = () => {
|
|
337
|
+
if (role === "user" && (text.trim() || attachments.length)) out.push({ kind: "user", ...(at ? { at } : {}), text, ...(attachments.length ? { attachments } : {}) })
|
|
338
|
+
if (role === "model" && text.trim()) out.push({ kind: "assistant", ...(at ? { at } : {}), text, ...(model ? { model } : {}) })
|
|
339
|
+
text = ""
|
|
340
|
+
attachments = []
|
|
341
|
+
}
|
|
342
|
+
for (const p of toParts(content)) {
|
|
343
|
+
if (p.thoughtSignature) ctx.lost.add("thought signatures")
|
|
344
|
+
if (typeof p.text === "string") {
|
|
345
|
+
if (p.thought) {
|
|
346
|
+
flush()
|
|
347
|
+
if (p.text.trim()) out.push({ kind: "reasoning", ...(at ? { at } : {}), text: p.text })
|
|
348
|
+
} else text += p.text
|
|
349
|
+
} else if (p.functionCall) {
|
|
350
|
+
flush()
|
|
351
|
+
out.push(ctx.pair.call(p.functionCall, at))
|
|
352
|
+
} else if (p.functionResponse) {
|
|
353
|
+
flush()
|
|
354
|
+
// Newer versions can record a response twice; Gemini CLI keeps the first one per round, and so do we.
|
|
355
|
+
if (ctx.pair.repeated(p.functionResponse.id)) continue
|
|
356
|
+
const callId = ctx.pair.respond(p.functionResponse)
|
|
357
|
+
if (!callId) {
|
|
358
|
+
ctx.lost.add("tool results without a call")
|
|
359
|
+
continue
|
|
360
|
+
}
|
|
361
|
+
const r = responseText(p.functionResponse.response)
|
|
362
|
+
const c = clip(r.text)
|
|
363
|
+
out.push({ kind: "tool_result", ...(at ? { at } : {}), callId, ok: r.ok, output: c.text, ...(c.truncated ? { truncated: true } : {}) })
|
|
364
|
+
const edit = ctx.edits.get(callId)
|
|
365
|
+
if (edit) {
|
|
366
|
+
out.push(edit)
|
|
367
|
+
ctx.edits.delete(callId)
|
|
368
|
+
}
|
|
369
|
+
} else if (p.fileData?.fileUri) {
|
|
370
|
+
attachments.push({ kind: "file", path: p.fileData.fileUri, ...(p.fileData.mimeType ? { mime: p.fileData.mimeType } : {}) })
|
|
371
|
+
} else if (p.inlineData) {
|
|
372
|
+
ctx.lost.add("inline images and files")
|
|
373
|
+
} else if (!p.thoughtSignature) {
|
|
374
|
+
ctx.lost.add(`unknown part types: ${Object.keys(p)[0] ?? "empty"}`)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
flush()
|
|
378
|
+
if (usage) {
|
|
379
|
+
const last = [...out].reverse().find((t) => t.kind === "assistant") as Extract<Turn, { kind: "assistant" }> | undefined
|
|
380
|
+
if (last) last.usage = usage
|
|
381
|
+
}
|
|
382
|
+
return out
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** A file edit's unified diff, which edits keep in their display (FileDiff). */
|
|
386
|
+
function editOf(tc: ToolCallRecord, at: string | undefined, callId: string): Turn | undefined {
|
|
387
|
+
const d = tc.resultDisplay
|
|
388
|
+
if (!isObj(d) || typeof d.fileDiff !== "string") return undefined
|
|
389
|
+
const path = typeof d.filePath === "string" ? d.filePath : typeof d.fileName === "string" ? d.fileName : undefined
|
|
390
|
+
return path ? { kind: "file_edit", ...(at ? { at } : {}), path, patch: d.fileDiff, callId } : undefined
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A tool call recorded in a gemini message, with its result (stored inside the call) and the edit it made.
|
|
395
|
+
* Newer versions also keep the call in the message's content, where it has become a tool_call already, and
|
|
396
|
+
* record its response in the user message after it (`answeredLater`); then this adds neither again, the
|
|
397
|
+
* way Gemini CLI's own resume (convertSessionToClientHistory) does.
|
|
398
|
+
*/
|
|
399
|
+
function toolCallTurns(tc: ToolCallRecord, fallbackAt: string | undefined, ctx: Ctx, answeredLater: boolean): Turn[] {
|
|
400
|
+
const at = iso(tc.timestamp) ?? fallbackAt
|
|
401
|
+
const name = typeof tc.name === "string" ? tc.name : "unknown"
|
|
402
|
+
const out: Turn[] = []
|
|
403
|
+
let call = ctx.pair.waiting(tc.id)
|
|
404
|
+
if (!call) {
|
|
405
|
+
call = ctx.pair.call({ ...(tc.id ? { id: tc.id } : {}), name, args: tc.args ?? {} }, at)
|
|
406
|
+
out.push(call)
|
|
407
|
+
}
|
|
408
|
+
const edit = editOf(tc, at, call.id)
|
|
409
|
+
if (answeredLater) {
|
|
410
|
+
if (edit) ctx.edits.set(call.id, edit)
|
|
411
|
+
return out
|
|
412
|
+
}
|
|
413
|
+
ctx.pair.settle(call, tc.id)
|
|
414
|
+
const hasResult = tc.result !== undefined && tc.result !== null
|
|
415
|
+
const r = resultText(tc.result)
|
|
416
|
+
if (r.binary) ctx.lost.add("binary tool output")
|
|
417
|
+
if (!hasResult) ctx.lost.add("tool results not recorded")
|
|
418
|
+
const display = typeof tc.resultDisplay === "string" ? tc.resultDisplay : ""
|
|
419
|
+
const text = hasResult ? r.text : display || `(no result recorded; the call's status was ${tc.status ?? "unknown"})`
|
|
420
|
+
const c = clip(text)
|
|
421
|
+
// A {$patch} adds the result without touching the status, which can still say "executing".
|
|
422
|
+
const ok = hasResult && r.ok && tc.status !== "error" && tc.status !== "cancelled"
|
|
423
|
+
out.push({ kind: "tool_result", ...(at ? { at } : {}), callId: call.id, ok, output: c.text, ...(c.truncated ? { truncated: true } : {}) })
|
|
424
|
+
if (edit) out.push(edit)
|
|
425
|
+
return out
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** A thought summary as one readable text. */
|
|
429
|
+
const thoughtText = (t: { subject?: string; description?: string }) => [t.subject, t.description].filter((x) => typeof x === "string" && x.trim()).join("\n")
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* User messages Gemini CLI itself leaves out when it resumes a session (isIgnoredUserContent): commands,
|
|
433
|
+
* and the session and hook context it injects again on its own.
|
|
434
|
+
*/
|
|
435
|
+
export function ignoredUserContent(content: PartList | undefined): boolean {
|
|
436
|
+
const parts = toParts(content)
|
|
437
|
+
if (parts.some((p) => p.functionResponse)) return false
|
|
438
|
+
const text = parts.map((p) => (typeof p.text === "string" && !p.thought ? p.text : "")).join("").trim()
|
|
439
|
+
return /^(\/|\?|<session_context>|<hook_context>)/.test(text)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The ids of the tool responses in a user message; "" for a response without one. */
|
|
443
|
+
const responseIds = (m: MessageRecord) => (m.type === "user" ? toParts(m.content).flatMap((p) => (p.functionResponse ? [p.functionResponse.id ?? ""] : [])) : [])
|
|
444
|
+
|
|
445
|
+
/** The portable turns for a recorded conversation's messages. */
|
|
446
|
+
export function messagesToTurns(messages: MessageRecord[], ctx: Ctx): Turn[] {
|
|
447
|
+
const turns: Turn[] = []
|
|
448
|
+
let afterSnapshot = false
|
|
449
|
+
messages.forEach((m, i) => {
|
|
450
|
+
const at = iso(m.timestamp)
|
|
451
|
+
const snapshot = afterSnapshot
|
|
452
|
+
afterSnapshot = false
|
|
453
|
+
switch (m.type) {
|
|
454
|
+
case "user": {
|
|
455
|
+
if (ignoredUserContent(m.content)) {
|
|
456
|
+
ctx.lost.add("Gemini CLI commands and injected context")
|
|
457
|
+
break
|
|
458
|
+
}
|
|
459
|
+
let parts = toParts(m.content)
|
|
460
|
+
const note = parts[0]?.text?.match(CONTINUED_NOTE)
|
|
461
|
+
if (note) {
|
|
462
|
+
ctx.h.parent = { id: note[1]!, kind: "continued" }
|
|
463
|
+
parts = parts.slice(1)
|
|
464
|
+
}
|
|
465
|
+
const summary = !parts.some((p) => p.functionResponse) ? snapshotText(parts.map((p) => (typeof p.text === "string" && !p.thought ? p.text : "")).join("")) : undefined
|
|
466
|
+
if (summary !== undefined) {
|
|
467
|
+
turns.push({ kind: "compaction", ...(at ? { at } : {}), summary })
|
|
468
|
+
afterSnapshot = true
|
|
469
|
+
} else turns.push(...partsToTurns(parts, "user", at, ctx))
|
|
470
|
+
break
|
|
471
|
+
}
|
|
472
|
+
case "gemini": {
|
|
473
|
+
// The acknowledgement Gemini CLI's compression adds after its summary is part of the compaction.
|
|
474
|
+
if (snapshot && !m.toolCalls?.length && toParts(m.content).map((p) => p.text ?? "").join("").trim() === SNAPSHOT_ACK) break
|
|
475
|
+
// Newer versions keep the model's own parts in content (thoughts, calls); thoughts[] repeats them.
|
|
476
|
+
if (!toParts(m.content).some((p) => p.thought)) {
|
|
477
|
+
for (const t of m.thoughts ?? []) {
|
|
478
|
+
const text = thoughtText(t)
|
|
479
|
+
if (text) turns.push({ kind: "reasoning", ...((iso(t.timestamp) ?? at) ? { at: iso(t.timestamp) ?? at } : {}), text })
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
noteModel(ctx.h, m.model)
|
|
483
|
+
turns.push(...partsToTurns(m.content, "model", at, ctx, m.model, usageOf(m.tokens)))
|
|
484
|
+
// Responses the user messages up to the next gemini message carry, as Gemini CLI's resume finds them.
|
|
485
|
+
const later = new Set<string>()
|
|
486
|
+
for (let j = i + 1; j < messages.length && messages[j]!.type !== "gemini"; j++) for (const id of responseIds(messages[j]!)) later.add(id)
|
|
487
|
+
for (const tc of m.toolCalls ?? []) if (isObj(tc)) turns.push(...toolCallTurns(tc, at, ctx, tc.id ? later.has(tc.id) : later.has("")))
|
|
488
|
+
break
|
|
489
|
+
}
|
|
490
|
+
// UI notices (cancelled requests, quota messages, API errors): what the person saw, not the conversation.
|
|
491
|
+
case "info":
|
|
492
|
+
case "error":
|
|
493
|
+
case "warning":
|
|
494
|
+
ctx.lost.add(`${m.type} notices`)
|
|
495
|
+
break
|
|
496
|
+
default:
|
|
497
|
+
ctx.lost.add(`unknown record types: ${String(m.type)}`)
|
|
498
|
+
}
|
|
499
|
+
})
|
|
500
|
+
return turns
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Results for calls that never got one (a cancelled call, or a checkpoint taken just before its tool ran),
|
|
505
|
+
* placed right after their calls, so every call stays paired. The output says that nothing was recorded.
|
|
506
|
+
*/
|
|
507
|
+
export function closeOpenCalls(turns: Turn[], open: CallTurn[], ctx: Ctx, why: (call: CallTurn) => string): Turn[] {
|
|
508
|
+
if (!open.length) return turns
|
|
509
|
+
ctx.lost.add("tool results not recorded", open.length)
|
|
510
|
+
const waiting = new Set(open)
|
|
511
|
+
const out: Turn[] = []
|
|
512
|
+
for (const t of turns) {
|
|
513
|
+
out.push(t)
|
|
514
|
+
if (t.kind !== "tool_call" || !waiting.has(t)) continue
|
|
515
|
+
out.push({ kind: "tool_result", ...(t.at ? { at: t.at } : {}), callId: t.id, ok: false, output: `(no result recorded: ${why(t)})` })
|
|
516
|
+
const edit = ctx.edits.get(t.id)
|
|
517
|
+
if (edit) out.push(edit)
|
|
518
|
+
ctx.edits.delete(t.id)
|
|
519
|
+
}
|
|
520
|
+
return out
|
|
521
|
+
}
|