@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.
Files changed (213) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +172 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +326 -0
  170. package/src/fsutil.ts +133 -0
  171. package/src/gateway/connect.ts +845 -0
  172. package/src/gateway/signin.ts +323 -0
  173. package/src/handoff.ts +104 -0
  174. package/src/hooks.ts +455 -0
  175. package/src/memory/audit.ts +514 -0
  176. package/src/memory/blobs.ts +94 -0
  177. package/src/memory/cli.ts +117 -0
  178. package/src/memory/distill.ts +381 -0
  179. package/src/memory/externalize.ts +435 -0
  180. package/src/memory/lookups.ts +70 -0
  181. package/src/memory/mcp-install.ts +401 -0
  182. package/src/memory/mcp-server.ts +296 -0
  183. package/src/memory/prepare.ts +179 -0
  184. package/src/memory/preview.ts +154 -0
  185. package/src/memory/project.ts +35 -0
  186. package/src/memory/redact.ts +72 -0
  187. package/src/memory/relevance.ts +187 -0
  188. package/src/memory/repomap.ts +516 -0
  189. package/src/memory/store.ts +270 -0
  190. package/src/memory/sync.ts +166 -0
  191. package/src/memory/tidy.ts +193 -0
  192. package/src/memory/types.ts +118 -0
  193. package/src/org/admin-ops.ts +96 -0
  194. package/src/org/auth.ts +112 -0
  195. package/src/org/backup.ts +115 -0
  196. package/src/org/cli.ts +438 -0
  197. package/src/org/client.ts +404 -0
  198. package/src/org/config.ts +44 -0
  199. package/src/org/crypto.ts +537 -0
  200. package/src/org/db.ts +435 -0
  201. package/src/org/graph.ts +318 -0
  202. package/src/org/oidc.ts +435 -0
  203. package/src/org/policy.ts +398 -0
  204. package/src/org/server.ts +920 -0
  205. package/src/org/sso.ts +316 -0
  206. package/src/org/tls.ts +66 -0
  207. package/src/org/types.ts +193 -0
  208. package/src/self.ts +68 -0
  209. package/src/session/attachments.ts +53 -0
  210. package/src/session/peekcache.ts +50 -0
  211. package/src/session/types.ts +104 -0
  212. package/src/session/util.ts +130 -0
  213. 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
+ }