@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,9 @@
|
|
|
1
|
+
import type { Reader, Writer } from "../types.ts"
|
|
2
|
+
import { reader } from "./reader.ts"
|
|
3
|
+
import { vscodeCopilotNativeWriter } from "./writer.ts"
|
|
4
|
+
|
|
5
|
+
// Copilot Chat in VS Code: reads every local chat (.json and the 1.109+ .jsonl log), and continues any
|
|
6
|
+
// session as a new local chat in the Chat view's history (VS Code must be closed while it is written).
|
|
7
|
+
export { reader }
|
|
8
|
+
export const readers: Reader[] = [reader]
|
|
9
|
+
export const writers: Writer[] = [vscodeCopilotNativeWriter]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs"
|
|
2
|
+
import { dirname, join, resolve } from "node:path"
|
|
3
|
+
import { appSupport, gitInfo, isDir, ls, mtime, readJson } from "../../fsutil.ts"
|
|
4
|
+
import type { Turn } from "../../session/types.ts"
|
|
5
|
+
import { header, iso, lossCounter } from "../../session/util.ts"
|
|
6
|
+
import type { Reader, SessionRef } from "../types.ts"
|
|
7
|
+
import { CONTINUED_NOTE } from "../claude-code/records.ts"
|
|
8
|
+
import { type Ctx, firstMessage, LABEL, loadSession, requestTurns, TESTED_VERSION, TOOL, uriPath } from "./records.ts"
|
|
9
|
+
|
|
10
|
+
// Copilot Chat in VS Code: chats live in the workbench's own storage, not the extension's. Per workspace,
|
|
11
|
+
// User/workspaceStorage/<hash>/chatSessions/<sessionId>.json (before 1.109) or .jsonl (a mutation log,
|
|
12
|
+
// 1.109 and later), with workspace.json mapping the hash to its folder; chats from windows with no folder
|
|
13
|
+
// open are in User/globalStorage/emptyWindowChatSessions/. VS Code keeps an index of them in its
|
|
14
|
+
// state.vscdb, but that database also holds other extensions' secrets and can be large, so the list comes
|
|
15
|
+
// from the files themselves. Older chats in the state.vscdb key 'interactive.sessions' are not read.
|
|
16
|
+
|
|
17
|
+
/** The VS Code builds whose chats are read; forks such as Cursor have their own adapters. */
|
|
18
|
+
const APPS = ["Code", "Code - Insiders"]
|
|
19
|
+
|
|
20
|
+
/** Session files up to this size are parsed whole for the list; bigger ones (tens of MB happen) are peeked at. */
|
|
21
|
+
const FULL_READ_LIMIT = 4 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
const oneLine = (s: string) => s.replace(/\s+/g, " ").trim()
|
|
24
|
+
const titleFrom = (s: string | undefined) => (s && oneLine(s) ? oneLine(s).slice(0, 120) : undefined)
|
|
25
|
+
|
|
26
|
+
/** Bytes [start, start+length) of a file as text. */
|
|
27
|
+
function slice(path: string, start: number, length: number): string {
|
|
28
|
+
const fd = openSync(path, "r")
|
|
29
|
+
try {
|
|
30
|
+
const buf = Buffer.alloc(length)
|
|
31
|
+
const n = readSync(fd, buf, 0, length, Math.max(0, start))
|
|
32
|
+
return buf.subarray(0, n).toString("utf8")
|
|
33
|
+
} finally {
|
|
34
|
+
closeSync(fd)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The last JSON string literal captured by `re` in `text`, decoded. */
|
|
39
|
+
function lastString(text: string, re: RegExp): string | undefined {
|
|
40
|
+
let out: string | undefined
|
|
41
|
+
for (const m of text.matchAll(re)) {
|
|
42
|
+
try {
|
|
43
|
+
out = JSON.parse(m[1]!)
|
|
44
|
+
} catch {
|
|
45
|
+
// cut off at the edge of the slice
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const STRING = String.raw`("(?:[^"\\]|\\.)*")`
|
|
52
|
+
|
|
53
|
+
/** JSON with comments and trailing commas, as .code-workspace files are written. */
|
|
54
|
+
function parseJsonc(text: string): unknown {
|
|
55
|
+
let out = ""
|
|
56
|
+
for (let i = 0; i < text.length; i++) {
|
|
57
|
+
const c = text[i]!
|
|
58
|
+
if (c === '"') {
|
|
59
|
+
const end = text.slice(i).match(/^"(?:[^"\\]|\\.)*"/)?.[0] ?? c
|
|
60
|
+
out += end
|
|
61
|
+
i += end.length - 1
|
|
62
|
+
} else if (c === "/" && text[i + 1] === "/") {
|
|
63
|
+
while (i < text.length && text[i] !== "\n") i++
|
|
64
|
+
out += "\n"
|
|
65
|
+
} else if (c === "/" && text[i + 1] === "*") {
|
|
66
|
+
const end = text.indexOf("*/", i + 2)
|
|
67
|
+
i = end < 0 ? text.length : end + 1
|
|
68
|
+
} else out += c
|
|
69
|
+
}
|
|
70
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The folder a workspace storage folder belongs to: workspace.json's folder, or a multi-root workspace's first folder. */
|
|
74
|
+
function workspaceFolder(dir: string): string | undefined {
|
|
75
|
+
const w = readJson<{ folder?: unknown; workspace?: unknown }>(join(dir, "workspace.json"))
|
|
76
|
+
if (!w) return undefined
|
|
77
|
+
if (w.folder !== undefined) return uriPath(w.folder)
|
|
78
|
+
const file = uriPath(w.workspace)
|
|
79
|
+
if (!file) return undefined
|
|
80
|
+
try {
|
|
81
|
+
const first = (parseJsonc(readFileSync(file, "utf8")) as { folders?: Array<{ path?: string; uri?: string }> }).folders?.[0]
|
|
82
|
+
if (typeof first?.path === "string") return resolve(dirname(file), first.path)
|
|
83
|
+
if (typeof first?.uri === "string") return uriPath(first.uri)
|
|
84
|
+
} catch {
|
|
85
|
+
// an unreadable workspace file: the folder it sits in is the best guess
|
|
86
|
+
}
|
|
87
|
+
return dirname(file)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface Summary {
|
|
91
|
+
sessionId: string
|
|
92
|
+
title?: string
|
|
93
|
+
updatedAt?: string
|
|
94
|
+
cwd?: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Enough of a session for the list; undefined for an empty chat (VS Code hides those too). */
|
|
98
|
+
function summarize(path: string): Summary | undefined {
|
|
99
|
+
const stem = path.replace(/^.*\//, "").replace(/\.jsonl?$/, "")
|
|
100
|
+
let size = 0
|
|
101
|
+
try {
|
|
102
|
+
size = statSync(path).size
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
if (size <= FULL_READ_LIMIT) {
|
|
107
|
+
const data = loadSession(path)?.data
|
|
108
|
+
const requests = Array.isArray(data?.requests) ? data!.requests : []
|
|
109
|
+
if (!data || !requests.length) return undefined
|
|
110
|
+
const title = titleFrom(typeof data.customTitle === "string" ? data.customTitle : undefined) ?? titleFrom(firstMessage(data))
|
|
111
|
+
const updatedAt = iso(data.lastMessageDate) ?? iso(requests[requests.length - 1]?.timestamp) ?? mtime(path)
|
|
112
|
+
const cwd = uriPath(data.workingDirectory)
|
|
113
|
+
return { sessionId: typeof data.sessionId === "string" ? data.sessionId : stem, ...(title ? { title } : {}), ...(updatedAt ? { updatedAt } : {}), ...(cwd ? { cwd } : {}) }
|
|
114
|
+
}
|
|
115
|
+
// A big file: the first request's text is near the start, the title and last date near the end (the
|
|
116
|
+
// .json writes them after the requests; the .jsonl appends title changes as Set entries).
|
|
117
|
+
const head = slice(path, 0, 256 * 1024)
|
|
118
|
+
const tail = slice(path, size - 64 * 1024, 64 * 1024)
|
|
119
|
+
const title =
|
|
120
|
+
lastString(tail, new RegExp(String.raw`"k"\s*:\s*\[\s*"customTitle"\s*\]\s*,\s*"v"\s*:\s*${STRING}`, "g")) ??
|
|
121
|
+
lastString(tail, new RegExp(String.raw`"customTitle"\s*:\s*${STRING}`, "g")) ??
|
|
122
|
+
lastString(head.slice(0, 4096), new RegExp(String.raw`"customTitle"\s*:\s*${STRING}`, "g")) ??
|
|
123
|
+
lastString(head.match(new RegExp(String.raw`"message"\s*:\s*\{[^]*?"text"\s*:\s*${STRING}`))?.[0] ?? "", new RegExp(String.raw`"text"\s*:\s*${STRING}`, "g"))
|
|
124
|
+
const last = [...tail.matchAll(/"lastMessageDate"\s*:\s*(\d+)/g)].pop()?.[1]
|
|
125
|
+
const updatedAt = iso(last ? Number(last) : undefined) ?? mtime(path)
|
|
126
|
+
return { sessionId: stem, ...(titleFrom(title) ? { title: titleFrom(title) } : {}), ...(updatedAt ? { updatedAt } : {}) }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The sessions in one chatSessions folder; when a chat has both a .json and a .jsonl, VS Code reads the .jsonl. */
|
|
130
|
+
function sessionRefs(dir: string, cwd: string | undefined, extra: Record<string, unknown>): SessionRef[] {
|
|
131
|
+
const names = ls(dir).filter((n) => /\.jsonl?$/.test(n))
|
|
132
|
+
const refs: SessionRef[] = []
|
|
133
|
+
for (const name of names) {
|
|
134
|
+
if (name.endsWith(".json") && names.includes(`${name}l`)) continue
|
|
135
|
+
const path = join(dir, name)
|
|
136
|
+
const s = summarize(path)
|
|
137
|
+
if (!s) continue
|
|
138
|
+
const where = s.cwd ?? cwd
|
|
139
|
+
refs.push({ tool: TOOL, sourceId: s.sessionId, path, ...(s.title ? { title: s.title } : {}), ...(where ? { cwd: where } : {}), ...(s.updatedAt ? { updatedAt: s.updatedAt } : {}), extra })
|
|
140
|
+
}
|
|
141
|
+
return refs
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const reader: Reader = {
|
|
145
|
+
tool: TOOL,
|
|
146
|
+
label: LABEL,
|
|
147
|
+
|
|
148
|
+
async list(home) {
|
|
149
|
+
const refs: SessionRef[] = []
|
|
150
|
+
for (const app of APPS) {
|
|
151
|
+
const user = join(appSupport(home), app, "User")
|
|
152
|
+
if (!isDir(user)) continue
|
|
153
|
+
const storage = join(user, "workspaceStorage")
|
|
154
|
+
for (const hash of ls(storage)) {
|
|
155
|
+
const dir = join(storage, hash, "chatSessions")
|
|
156
|
+
if (!ls(dir).length) continue
|
|
157
|
+
refs.push(...sessionRefs(dir, workspaceFolder(join(storage, hash)), { app, workspace: hash }))
|
|
158
|
+
}
|
|
159
|
+
refs.push(...sessionRefs(join(user, "globalStorage", "emptyWindowChatSessions"), undefined, { app, emptyWindow: true }))
|
|
160
|
+
}
|
|
161
|
+
return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async read(ref) {
|
|
165
|
+
const loaded = loadSession(ref.path)
|
|
166
|
+
if (!loaded || !Array.isArray(loaded.data.requests)) throw new Error(`${ref.path} is not a VS Code chat session`)
|
|
167
|
+
const { data, bad } = loaded
|
|
168
|
+
const lost = lossCounter()
|
|
169
|
+
// Readers feed the brief, so an unexpected version is read and flagged rather than refused.
|
|
170
|
+
if (data.version !== TESTED_VERSION) lost.add(`unverified session format version ${String(data.version)}`)
|
|
171
|
+
if (bad) lost.add("unreadable log lines", bad)
|
|
172
|
+
const cwd = uriPath(data.workingDirectory) ?? ref.cwd
|
|
173
|
+
const requests: Array<Record<string, any>> = data.requests.filter((r: unknown) => typeof r === "object" && r !== null)
|
|
174
|
+
// The extension version is recorded on requests by newer builds; the latest one wrote the session last.
|
|
175
|
+
const version = requests.map((r) => r.agent?.extensionVersion).filter((v) => typeof v === "string").pop()
|
|
176
|
+
const title = titleFrom(typeof data.customTitle === "string" ? data.customTitle : undefined) ?? titleFrom(firstMessage(data))
|
|
177
|
+
const startedAt = iso(data.creationDate) ?? iso(requests[0]?.timestamp)
|
|
178
|
+
const updatedAt = iso(data.lastMessageDate) ?? iso(requests[requests.length - 1]?.timestamp) ?? mtime(ref.path)
|
|
179
|
+
const h = header(TOOL, typeof data.sessionId === "string" ? data.sessionId : ref.sourceId, {
|
|
180
|
+
...(version ? { toolVersion: version } : {}),
|
|
181
|
+
sourcePath: ref.path,
|
|
182
|
+
...(title ? { title } : {}),
|
|
183
|
+
project: { ...(cwd ? { cwd } : {}), ...gitInfo(cwd) },
|
|
184
|
+
...(startedAt ? { startedAt } : {}),
|
|
185
|
+
...(updatedAt ? { updatedAt } : {}),
|
|
186
|
+
})
|
|
187
|
+
// A chat this tool's writer made says where it came from in its first request's rendered message.
|
|
188
|
+
for (const req of requests) {
|
|
189
|
+
const rendered = req.result?.metadata?.renderedUserMessage
|
|
190
|
+
const text = req.isSystemInitiated ? req.message?.text : Array.isArray(rendered) ? rendered.map((p: Record<string, unknown>) => (typeof p?.text === "string" ? p.text : "")).join("") : undefined
|
|
191
|
+
const from = typeof text === "string" ? text.match(CONTINUED_NOTE)?.[1] : undefined
|
|
192
|
+
if (from) {
|
|
193
|
+
h.parent = { id: from, kind: "continued" }
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const ctx: Ctx = { h, lost, ids: new Set() }
|
|
198
|
+
const turns: Turn[] = []
|
|
199
|
+
for (const req of requests) {
|
|
200
|
+
// Hidden requests were undone (restored to an earlier checkpoint) and are no longer part of the chat.
|
|
201
|
+
if (req.isHidden || req.hiddenFromTranscript || req.shouldBeRemovedOnSend) {
|
|
202
|
+
lost.add("undone requests")
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
turns.push(...requestTurns(req, data, ctx))
|
|
206
|
+
}
|
|
207
|
+
if (Array.isArray(data.pendingRequests) && data.pendingRequests.length) lost.add("queued requests not yet sent", data.pendingRequests.length)
|
|
208
|
+
lost.flush(h)
|
|
209
|
+
return { header: h, turns }
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { keepImage } from "../../session/attachments.ts"
|
|
3
|
+
import type { Attachment, SessionHeader, Turn, Usage } from "../../session/types.ts"
|
|
4
|
+
import { asText, clip, iso, noteModel, toolKindOf, type lossCounter } from "../../session/util.ts"
|
|
5
|
+
|
|
6
|
+
// Facts about VS Code's chat session files (ISerializableChatData), shared by the reader. Checked against
|
|
7
|
+
// the 82 version-3 .json sessions on this Mac (VS Code up to 1.144, Copilot Chat 0.35) and against
|
|
8
|
+
// microsoft/vscode main: chatModel.ts, chatSessionOperationLog.ts and objectMutationLog.ts, which define
|
|
9
|
+
// the .jsonl mutation log that 1.109 and later write. See docs/sessions/2026-11-08-session-formats.md,
|
|
10
|
+
// entry 33.
|
|
11
|
+
|
|
12
|
+
export const TOOL = "vscode-copilot"
|
|
13
|
+
export const LABEL = "Copilot Chat in VS Code"
|
|
14
|
+
|
|
15
|
+
/** The session format version this reader was checked against. */
|
|
16
|
+
export const TESTED_VERSION = 3
|
|
17
|
+
|
|
18
|
+
type Lost = ReturnType<typeof lossCounter>
|
|
19
|
+
type Json = Record<string, any>
|
|
20
|
+
|
|
21
|
+
const isObj = (v: unknown): v is Json => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
22
|
+
// Keys a log entry must never walk into: they would reach Object.prototype.
|
|
23
|
+
const UNSAFE = new Set(["__proto__", "constructor", "prototype"])
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Rebuilds a session from VS Code's append-only mutation log (ObjectMutationLog): {kind: 0, v} sets the
|
|
27
|
+
* whole state, {kind: 1, k: path, v} sets a value at a path, {kind: 2, k, v?, i?} truncates the array at
|
|
28
|
+
* `k` to `i` items and pushes `v`, {kind: 3, k} deletes. The log compacts itself back to one kind-0 line
|
|
29
|
+
* from time to time. Lines that are not JSON, or that point nowhere, are counted as `bad` and skipped.
|
|
30
|
+
*/
|
|
31
|
+
export function replayLog(text: string): { state: Json | undefined; bad: number } {
|
|
32
|
+
let state: unknown
|
|
33
|
+
let bad = 0
|
|
34
|
+
const parentOf = (path: unknown[]): Json | unknown[] | undefined => {
|
|
35
|
+
let cur: unknown = state
|
|
36
|
+
for (const seg of path.slice(0, -1)) {
|
|
37
|
+
if (UNSAFE.has(String(seg)) || (typeof cur !== "object" || cur === null)) return undefined
|
|
38
|
+
cur = (cur as Json)[seg as string]
|
|
39
|
+
}
|
|
40
|
+
return typeof cur === "object" && cur !== null ? (cur as Json) : undefined
|
|
41
|
+
}
|
|
42
|
+
for (const line of text.split("\n")) {
|
|
43
|
+
if (!line.trim()) continue
|
|
44
|
+
let e: Json
|
|
45
|
+
try {
|
|
46
|
+
e = JSON.parse(line)
|
|
47
|
+
} catch {
|
|
48
|
+
bad++
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
if (e?.kind === 0) {
|
|
52
|
+
state = e.v
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
const k = e?.k
|
|
56
|
+
if (state === undefined || !Array.isArray(k) || ![1, 2, 3].includes(e.kind)) {
|
|
57
|
+
bad++
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (!k.length) {
|
|
61
|
+
// A changed key field replaces the whole object at its path; at the root that is the session.
|
|
62
|
+
if (e.kind === 1) state = e.v
|
|
63
|
+
else bad++
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
const parent = parentOf(k)
|
|
67
|
+
const last = String(k[k.length - 1])
|
|
68
|
+
if (!parent || UNSAFE.has(last)) {
|
|
69
|
+
bad++
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
const p = parent as Json
|
|
73
|
+
if (e.kind === 1) p[last] = e.v
|
|
74
|
+
else if (e.kind === 3) delete p[last]
|
|
75
|
+
else {
|
|
76
|
+
const arr: unknown[] = Array.isArray(p[last]) ? p[last] : []
|
|
77
|
+
if (typeof e.i === "number") arr.length = Math.min(arr.length, e.i)
|
|
78
|
+
if (Array.isArray(e.v)) for (const x of e.v) arr.push(x)
|
|
79
|
+
p[last] = arr
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { state: isObj(state) ? state : undefined, bad }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A session file read whole: a .json document, or a .jsonl log replayed. */
|
|
86
|
+
export function loadSession(path: string): { data: Json; bad: number } | undefined {
|
|
87
|
+
let text: string
|
|
88
|
+
try {
|
|
89
|
+
text = readFileSync(path, "utf8")
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
if (path.endsWith(".jsonl")) {
|
|
94
|
+
const r = replayLog(text)
|
|
95
|
+
return r.state ? { data: r.state, bad: r.bad } : undefined
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const data = JSON.parse(text)
|
|
99
|
+
return isObj(data) ? { data, bad: 0 } : undefined
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A local path from a file:// URI string or a serialised URI ({scheme, path, fsPath}); undefined for remote ones. */
|
|
106
|
+
export function uriPath(u: unknown): string | undefined {
|
|
107
|
+
if (typeof u === "string") {
|
|
108
|
+
if (!u.startsWith("file://")) return undefined
|
|
109
|
+
try {
|
|
110
|
+
return decodeURIComponent(new URL(u).pathname)
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (isObj(u)) {
|
|
116
|
+
if (u.scheme !== undefined && u.scheme !== "file") return undefined
|
|
117
|
+
if (typeof u.fsPath === "string") return u.fsPath
|
|
118
|
+
if (typeof u.path === "string") return u.path
|
|
119
|
+
if (isObj(u.uri)) return uriPath(u.uri)
|
|
120
|
+
}
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Text of a string or a MarkdownString ({value}). */
|
|
125
|
+
const mdText = (v: unknown): string => (typeof v === "string" ? v : isObj(v) && typeof v.value === "string" ? v.value : "")
|
|
126
|
+
|
|
127
|
+
/** The text in a prompt-tsx element tree (LanguageModelPromptTsxPart), which tools such as read_file return. */
|
|
128
|
+
function tsxText(node: unknown, depth = 0): string {
|
|
129
|
+
if (!isObj(node) || depth > 64) return ""
|
|
130
|
+
let s = typeof node.text === "string" ? `${node.lineBreakBefore ? "\n" : ""}${node.text}` : ""
|
|
131
|
+
if (Array.isArray(node.children)) for (const c of node.children) s += tsxText(c, depth + 1)
|
|
132
|
+
return s
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A tool result as the model saw it (toolCallResults[id].content: text parts, prompt-tsx parts, data parts). */
|
|
136
|
+
function resultContent(r: unknown): { text: string; binary: number } {
|
|
137
|
+
const parts = isObj(r) && Array.isArray(r.content) ? r.content : []
|
|
138
|
+
const out: string[] = []
|
|
139
|
+
let binary = 0
|
|
140
|
+
for (const p of parts) {
|
|
141
|
+
if (!isObj(p)) continue
|
|
142
|
+
if (typeof p.value === "string") out.push(p.value)
|
|
143
|
+
else if (isObj(p.value) && "node" in p.value) out.push(tsxText(p.value.node))
|
|
144
|
+
else if (p.data !== undefined || p.mimeType !== undefined) binary++
|
|
145
|
+
else out.push(asText(p.value))
|
|
146
|
+
}
|
|
147
|
+
return { text: out.join("\n"), binary }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Tool arguments are a JSON string in toolCallRounds; keep them as an object when they parse. */
|
|
151
|
+
function args(raw: unknown): unknown {
|
|
152
|
+
if (typeof raw !== "string") return raw ?? {}
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(raw)
|
|
155
|
+
} catch {
|
|
156
|
+
return { arguments: raw }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The model a request used: its modelId when recorded, else the name in result.details ("GPT-4.1 • 1x"). */
|
|
161
|
+
function requestModel(req: Json, session: Json): string | undefined {
|
|
162
|
+
if (typeof req.modelId === "string" && req.modelId) return req.modelId
|
|
163
|
+
const d = req.result?.details
|
|
164
|
+
if (typeof d === "string" && d.trim()) return d.split(" • ")[0]!.trim()
|
|
165
|
+
const sel = session.selectedModel?.identifier ?? session.inputState?.selectedModel?.identifier
|
|
166
|
+
return typeof sel === "string" ? sel : undefined
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function attachmentsOf(req: Json, lost: Lost): Attachment[] {
|
|
170
|
+
const out: Attachment[] = []
|
|
171
|
+
const vars = req.variableData?.variables
|
|
172
|
+
for (const v of Array.isArray(vars) ? vars : []) {
|
|
173
|
+
if (!isObj(v)) continue
|
|
174
|
+
if (v.isImage || v.kind === "image") {
|
|
175
|
+
// Pasted images are stored inline as {$base64} bytes with a mimeType; any other form stays behind.
|
|
176
|
+
const b64 = isObj(v.value) && typeof v.value.$base64 === "string" ? v.value.$base64 : undefined
|
|
177
|
+
const kept = b64 ? keepImage(b64, typeof v.mimeType === "string" ? v.mimeType : undefined) : undefined
|
|
178
|
+
if (kept) out.push({ ...kept, ...(typeof v.name === "string" ? { name: v.name } : {}) })
|
|
179
|
+
else {
|
|
180
|
+
out.push({ kind: "image", ...(typeof v.name === "string" ? { name: v.name } : {}) })
|
|
181
|
+
lost.add("image bytes")
|
|
182
|
+
}
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
const path = uriPath(v.value)
|
|
186
|
+
if (path) out.push({ kind: "file", path, ...(typeof v.name === "string" ? { name: v.name } : {}) })
|
|
187
|
+
}
|
|
188
|
+
return out
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Response parts that only drive the chat UI (progress, links, buttons, edit bookkeeping): nothing a model
|
|
192
|
+
// needs to carry on. Parts not listed here and not handled below are counted as unknown.
|
|
193
|
+
const UI_PARTS = new Set([
|
|
194
|
+
"codeblockUri", "undoStop", "progressMessage", "progressTask", "progressTaskSerialized", "warning", "info", "confirmation",
|
|
195
|
+
"command", "mcpServersStarting", "prepareToolInvocation", "extensions", "pullRequest", "treeData", "markdownVuln", "hook",
|
|
196
|
+
"systemNotification", "clearToPreviousToolInvocation", "elicitationSerialized", "questionCarousel", "autoModeResolution",
|
|
197
|
+
"planReview", "multiDiffData", "workspaceEdit", "externalEdit", "disabledClaudeHooks", "notebookEditGroup", "codeCitation",
|
|
198
|
+
])
|
|
199
|
+
|
|
200
|
+
export interface Ctx {
|
|
201
|
+
h: SessionHeader
|
|
202
|
+
lost: Lost
|
|
203
|
+
/** Tool call ids already used in this session. */
|
|
204
|
+
ids: Set<string>
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function uniqueId(ctx: Ctx, id: unknown): string {
|
|
208
|
+
const base = typeof id === "string" && id ? id : `vscode_call_${ctx.ids.size + 1}`
|
|
209
|
+
let out = base
|
|
210
|
+
for (let i = 2; ctx.ids.has(out); i++) out = `${base}#${i}`
|
|
211
|
+
ctx.ids.add(out)
|
|
212
|
+
return out
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function resultTurn(callId: string, text: string, ok: boolean, at?: string): Turn {
|
|
216
|
+
const c = clip(text)
|
|
217
|
+
return { kind: "tool_result", ...(at ? { at } : {}), callId, ok, output: c.text, ...(c.truncated ? { truncated: true } : {}) }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Readable thinking, or a count in lost when it is encrypted or redacted. */
|
|
221
|
+
function thinkingTurn(t: unknown, ctx: Ctx, at?: string): Turn | undefined {
|
|
222
|
+
if (!isObj(t)) return undefined
|
|
223
|
+
if (t.encrypted || t.signature) ctx.lost.add("signed or encrypted reasoning")
|
|
224
|
+
if (t.redacted) return undefined
|
|
225
|
+
const text = Array.isArray(t.text) ? t.text.join("") : typeof t.text === "string" ? t.text : Array.isArray(t.value) ? t.value.join("") : typeof t.value === "string" ? t.value : ""
|
|
226
|
+
return text.trim() ? { kind: "reasoning", ...(at ? { at } : {}), text } : undefined
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The agent's own record of a request, when it kept one: result.metadata.toolCallRounds, one per model
|
|
231
|
+
* call, each with the text the model wrote and the tools it called, and toolCallResults by call id. This
|
|
232
|
+
* is the conversation the model saw; the response parts are its rendering (edits shown as code blocks,
|
|
233
|
+
* links in place of file names), and their tool ids are the UI's own, not the model's.
|
|
234
|
+
*/
|
|
235
|
+
function roundTurns(req: Json, rounds: Json[], model: string | undefined, ctx: Ctx): Turn[] {
|
|
236
|
+
const out: Turn[] = []
|
|
237
|
+
const meta = isObj(req.result?.metadata) ? req.result.metadata : {}
|
|
238
|
+
const results: Json = isObj(meta.toolCallResults) ? meta.toolCallResults : {}
|
|
239
|
+
// "Summarized conversation history": a summary that replaced the history up to a round, perhaps one
|
|
240
|
+
// in an earlier request; it goes after that round, or first when the round is not in this request.
|
|
241
|
+
const summaries = (Array.isArray(meta.summaries) ? meta.summaries : isObj(meta.summary) ? [meta.summary] : []).filter((x: unknown) => isObj(x) && typeof x.text === "string" && x.text.trim())
|
|
242
|
+
const roundIds = new Set(rounds.map((r) => r.id))
|
|
243
|
+
for (const s of summaries) if (s.toolCallRoundId === undefined || !roundIds.has(s.toolCallRoundId)) out.push({ kind: "compaction", summary: s.text })
|
|
244
|
+
for (const round of rounds) {
|
|
245
|
+
const m = typeof round.modelId === "string" ? round.modelId : model
|
|
246
|
+
noteModel(ctx.h, m)
|
|
247
|
+
const r = thinkingTurn(round.thinking, ctx)
|
|
248
|
+
if (r) out.push(r)
|
|
249
|
+
// Provider-side compaction state (OpenAI Responses) is opaque and stays with that provider.
|
|
250
|
+
if (round.compaction !== undefined && round.compaction !== null) ctx.lost.add("opaque compaction state")
|
|
251
|
+
if (typeof round.response === "string" && round.response.trim()) out.push({ kind: "assistant", text: round.response, ...(m ? { model: m } : {}) })
|
|
252
|
+
const calls = (Array.isArray(round.toolCalls) ? round.toolCalls : []).filter(isObj)
|
|
253
|
+
const ids = calls.map((c) => uniqueId(ctx, c.id))
|
|
254
|
+
calls.forEach((c, i) => {
|
|
255
|
+
const name = typeof c.name === "string" ? c.name : "unknown"
|
|
256
|
+
out.push({ kind: "tool_call", id: ids[i]!, name, toolKind: toolKindOf(name), input: args(c.arguments) })
|
|
257
|
+
})
|
|
258
|
+
calls.forEach((c, i) => {
|
|
259
|
+
const res = typeof c.id === "string" && Object.hasOwn(results, c.id) ? results[c.id] : undefined
|
|
260
|
+
if (res === undefined) {
|
|
261
|
+
ctx.lost.add("tool results not recorded")
|
|
262
|
+
out.push(resultTurn(ids[i]!, "(no result recorded)", false))
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
const { text, binary } = resultContent(res)
|
|
266
|
+
if (binary) ctx.lost.add("binary tool output")
|
|
267
|
+
// Copilot's tools report failure in their text ("ERROR: …", "Error: …"), not in a status field.
|
|
268
|
+
out.push(resultTurn(ids[i]!, text, !/^\s*error\b/i.test(text)))
|
|
269
|
+
})
|
|
270
|
+
for (const s of summaries) if (s.toolCallRoundId !== undefined && s.toolCallRoundId === round.id) out.push({ kind: "compaction", summary: s.text })
|
|
271
|
+
}
|
|
272
|
+
return out
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A tool shown in the response without the agent's own record: name, input and output as the UI kept them. */
|
|
276
|
+
function invocationTurns(p: Json, ctx: Ctx): Turn[] {
|
|
277
|
+
const data = isObj(p.toolSpecificData) ? p.toolSpecificData : {}
|
|
278
|
+
// Before toolId was kept, the UI's message is all that names a tool: 'Using "Fetch Web Page"', or a
|
|
279
|
+
// verb ("Reading …") that names none.
|
|
280
|
+
const shown = mdText(p.invocationMessage).match(/^Using "([^"]+)"/)?.[1]
|
|
281
|
+
const name = typeof p.toolId === "string" && p.toolId ? p.toolId : data.kind === "terminal" ? "run_in_terminal" : (shown ?? "copilot_tool")
|
|
282
|
+
const details = p.resultDetails
|
|
283
|
+
let input: unknown
|
|
284
|
+
if (data.kind === "terminal") input = { command: data.commandLine?.original ?? data.commandLine?.userEdited ?? data.command }
|
|
285
|
+
else if (data.kind === "input") input = data.rawInput
|
|
286
|
+
else if (isObj(details) && typeof details.input === "string") input = args(details.input)
|
|
287
|
+
else input = { description: mdText(p.invocationMessage) }
|
|
288
|
+
const id = uniqueId(ctx, p.toolCallId)
|
|
289
|
+
const started = iso(p.startedAt)
|
|
290
|
+
let output: string
|
|
291
|
+
if (isObj(details) && details.output !== undefined) output = asText(details.output)
|
|
292
|
+
else if (Array.isArray(details)) output = details.map((d) => uriPath(d) ?? uriPath(isObj(d) ? d.uri : undefined) ?? asText(d)).join("\n")
|
|
293
|
+
else output = mdText(p.pastTenseMessage) || mdText(p.invocationMessage)
|
|
294
|
+
// isConfirmed was a boolean before 1.104 and is now a reason; types 0 and 5 are Denied and Skipped.
|
|
295
|
+
const denied = p.isConfirmed === false || (isObj(p.isConfirmed) && (p.isConfirmed.type === 0 || p.isConfirmed.type === 5))
|
|
296
|
+
const ok = p.isComplete !== false && !denied && !p.resultError
|
|
297
|
+
return [
|
|
298
|
+
{ kind: "tool_call", ...(started ? { at: started } : {}), id, name, toolKind: toolKindOf(name), input: input ?? {} },
|
|
299
|
+
resultTurn(id, p.resultError ? `${asText(p.resultError)}\n${output}`.trim() : output, ok, iso(p.completedAt)),
|
|
300
|
+
]
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The response parts in order: markdown becomes assistant text, tools become calls and results, thinking becomes reasoning. */
|
|
304
|
+
function partTurns(parts: unknown[], model: string | undefined, ctx: Ctx): Turn[] {
|
|
305
|
+
const out: Turn[] = []
|
|
306
|
+
let text = ""
|
|
307
|
+
const flush = () => {
|
|
308
|
+
if (text.trim()) out.push({ kind: "assistant", text, ...(model ? { model } : {}) })
|
|
309
|
+
text = ""
|
|
310
|
+
}
|
|
311
|
+
for (const p of parts) {
|
|
312
|
+
if (!isObj(p)) continue
|
|
313
|
+
const kind = p.kind
|
|
314
|
+
if (kind === undefined || kind === null || kind === "markdownContent") text += mdText(kind === "markdownContent" ? p.content : p)
|
|
315
|
+
else if (kind === "inlineReference") {
|
|
316
|
+
// A file or symbol link inside the text; keep its name so the sentence still reads.
|
|
317
|
+
const ref = p.inlineReference
|
|
318
|
+
const name = typeof p.name === "string" ? p.name : isObj(ref) && typeof ref.name === "string" ? ref.name : (uriPath(ref)?.split("/").pop() ?? "")
|
|
319
|
+
text += name ? `\`${name}\`` : ""
|
|
320
|
+
} else if (kind === "thinking") {
|
|
321
|
+
flush()
|
|
322
|
+
const r = thinkingTurn(p, ctx)
|
|
323
|
+
if (r) out.push(r)
|
|
324
|
+
} else if (kind === "toolInvocationSerialized") {
|
|
325
|
+
flush()
|
|
326
|
+
out.push(...invocationTurns(p, ctx))
|
|
327
|
+
} else if (kind === "textEditGroup") {
|
|
328
|
+
flush()
|
|
329
|
+
const path = uriPath(p.uri)
|
|
330
|
+
if (path) out.push({ kind: "file_edit", path })
|
|
331
|
+
} else if (!UI_PARTS.has(kind)) ctx.lost.add(`unknown record types: ${String(kind)}`)
|
|
332
|
+
}
|
|
333
|
+
flush()
|
|
334
|
+
return out
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** One request: the person's message, then the reply (from the agent's rounds when it kept them). */
|
|
338
|
+
export function requestTurns(req: Json, session: Json, ctx: Ctx): Turn[] {
|
|
339
|
+
const out: Turn[] = []
|
|
340
|
+
const at = iso(req.timestamp)
|
|
341
|
+
const text = typeof req.message?.text === "string" ? req.message.text : ""
|
|
342
|
+
const attachments = attachmentsOf(req, ctx.lost)
|
|
343
|
+
// A system-initiated request is a notification the chat shows (a terminal finishing, a summary), not the person's words.
|
|
344
|
+
if ((text.trim() || attachments.length) && !req.isSystemInitiated) out.push({ kind: "user", ...(at ? { at } : {}), text, ...(attachments.length ? { attachments } : {}) })
|
|
345
|
+
const model = requestModel(req, session)
|
|
346
|
+
// Very old sessions kept the response as one markdown string; VS Code reads it the same way.
|
|
347
|
+
const parts: unknown[] = Array.isArray(req.response) ? req.response : typeof req.response === "string" ? [{ value: req.response }] : []
|
|
348
|
+
const rounds = req.result?.metadata?.toolCallRounds
|
|
349
|
+
let reply: Turn[]
|
|
350
|
+
if (Array.isArray(rounds) && rounds.length) {
|
|
351
|
+
reply = roundTurns(req, rounds.filter(isObj), model, ctx)
|
|
352
|
+
// Edits the UI recorded, so the files a request changed are known even though its tool ids differ.
|
|
353
|
+
const seen = new Set<string>()
|
|
354
|
+
for (const p of parts) {
|
|
355
|
+
const path = isObj(p) && p.kind === "textEditGroup" ? uriPath(p.uri) : undefined
|
|
356
|
+
if (path && !seen.has(path)) {
|
|
357
|
+
seen.add(path)
|
|
358
|
+
reply.push({ kind: "file_edit", path })
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} else {
|
|
362
|
+
noteModel(ctx.h, model)
|
|
363
|
+
reply = partTurns(parts, model, ctx)
|
|
364
|
+
}
|
|
365
|
+
// Newer versions keep token counts per request; they belong to the reply's last model turn.
|
|
366
|
+
const usage: Usage = {}
|
|
367
|
+
if (typeof req.promptTokens === "number") usage.input = req.promptTokens
|
|
368
|
+
if (typeof req.completionTokens === "number") usage.output = req.completionTokens
|
|
369
|
+
const last = [...reply].reverse().find((t) => t.kind === "assistant") as Extract<Turn, { kind: "assistant" }> | undefined
|
|
370
|
+
if (last) {
|
|
371
|
+
if (Object.keys(usage).length) last.usage = usage
|
|
372
|
+
const done = iso(req.modelState?.completedAt ?? req.responseTimestamp)
|
|
373
|
+
if (done) last.at = done
|
|
374
|
+
}
|
|
375
|
+
// A cancelled request (modelState 2) is one the person stopped or moved on from, not a failure.
|
|
376
|
+
if (req.result?.errorDetails && req.modelState?.value !== 2) ctx.lost.add("failed responses")
|
|
377
|
+
out.push(...reply)
|
|
378
|
+
return out
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Text of the first request, the usual title of a chat. */
|
|
382
|
+
export function firstMessage(data: Json): string | undefined {
|
|
383
|
+
for (const r of Array.isArray(data.requests) ? data.requests : []) {
|
|
384
|
+
const t = r?.message?.text
|
|
385
|
+
if (typeof t === "string" && t.trim()) return t
|
|
386
|
+
}
|
|
387
|
+
return undefined
|
|
388
|
+
}
|