@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,156 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { mkdirSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"
3
+ import { basename, dirname, join, resolve } from "node:path"
4
+ import { exists, isDir, ls, readJson } from "../../fsutil.ts"
5
+ import type { Session } from "../../session/types.ts"
6
+ import { countTurns, unpaired } from "../../session/util.ts"
7
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
8
+ import { realCwd, shellQuote } from "../gemini-cli/exchanges.ts"
9
+ import { planConversation, serialize } from "../gemini-cli/writer.ts"
10
+ import { tabnineReader } from "./reader.ts"
11
+ import { GEMINI_FORMAT_VERSION, LABEL, TESTED_VERSIONS, TOOL, agentDir, tabnineVersion } from "./records.ts"
12
+
13
+ // Continues a portable session in the Tabnine CLI as a new session file in the project's chats folder under
14
+ // ~/.tabnine/agent: the records the Gemini CLI planner makes (Tabnine's format is Gemini CLI's JSONL), with
15
+ // the project folder claimed the way Tabnine's ProjectRegistry claims one. Tabnine lists it
16
+ // (--list-sessions, /resume) and its loader gives the model the whole conversation.
17
+
18
+ const sha256 = (s: string) => createHash("sha256").update(s).digest("hex")
19
+ const readText = (p: string) => {
20
+ try {
21
+ return readFileSync(p, "utf8")
22
+ } catch {
23
+ return undefined
24
+ }
25
+ }
26
+ const slugify = (text: string) =>
27
+ text
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]/g, "-")
30
+ .replace(/-+/g, "-")
31
+ .replace(/^-|-$/g, "") || "project"
32
+
33
+ /**
34
+ * The project's folder under ~/.tabnine/agent/tmp, as ProjectRegistry.getShortId finds or claims it (the
35
+ * same rules as Gemini CLI's, under Tabnine's root): the registry's name when its markers agree, else a
36
+ * folder whose .project_root names the project, else a new name from the folder's base name.
37
+ */
38
+ export function projectFolder(home: string, cwd: string): { dir: string; markers: string[]; register?: { path: string; slug: string } } {
39
+ const root = agentDir(home)
40
+ const project = resolve(cwd)
41
+ const tmp = join(root, "tmp")
42
+ const bases = [tmp, join(root, "history")]
43
+ const owner = (base: string, slug: string) => readText(join(base, slug, ".project_root"))?.trim()
44
+ const legacy = join(tmp, sha256(project))
45
+ const registry = readJson<{ projects?: Record<string, string> }>(join(root, "projects.json"))?.projects ?? {}
46
+ let slug: string | undefined = registry[project]
47
+ if (slug && bases.some((b) => owner(b, slug!) !== undefined && resolve(owner(b, slug!)!) !== project)) slug = undefined
48
+ for (const base of bases) for (const d of slug ? [] : ls(base)) if (!slug && owner(base, d) !== undefined && resolve(owner(base, d)!) === project) slug = d
49
+ if (!slug) {
50
+ const taken = new Set(Object.values(registry))
51
+ const base = slugify(basename(project) || "project")
52
+ for (let i = 0; !slug; i++) {
53
+ const c = i === 0 ? base : `${base}-${i}`
54
+ if (!taken.has(c) && bases.every((b) => owner(b, c) === undefined || resolve(owner(b, c)!) === project)) slug = c
55
+ }
56
+ }
57
+ const fresh = ls(join(tmp, slug)).every((f) => f === ".project_root")
58
+ if (isDir(legacy) && fresh) return { dir: legacy, markers: [] }
59
+ return {
60
+ dir: join(tmp, slug),
61
+ markers: bases.map((b) => join(b, slug!, ".project_root")).filter((p) => !exists(p)),
62
+ ...(registry[project] === slug ? {} : { register: { path: project, slug } }),
63
+ }
64
+ }
65
+
66
+ /** Adds the registry entry under Tabnine's lock (proper-lockfile's `<file>.lock` folder), atomically. */
67
+ function register(home: string, entry: { path: string; slug: string }) {
68
+ const file = join(agentDir(home), "projects.json")
69
+ const lock = `${file}.lock`
70
+ mkdirSync(agentDir(home), { recursive: true })
71
+ for (let tries = 0; ; tries++) {
72
+ try {
73
+ mkdirSync(lock)
74
+ break
75
+ } catch {
76
+ let age = 0
77
+ try {
78
+ age = Date.now() - statSync(lock).mtimeMs
79
+ } catch {}
80
+ if (age > 10_000) rmdirSync(lock)
81
+ else if (tries > 50) throw new Error(`Tabnine is updating ${file}; try again`)
82
+ else Bun.sleepSync(100)
83
+ }
84
+ }
85
+ try {
86
+ const data = readJson<{ projects?: Record<string, string> }>(file) ?? {}
87
+ const tmp = `${file}.${randomUUID()}.tmp`
88
+ writeFileSync(tmp, JSON.stringify({ ...data, projects: { ...(data.projects ?? {}), [entry.path]: entry.slug } }, null, 2))
89
+ renameSync(tmp, file)
90
+ } finally {
91
+ rmdirSync(lock)
92
+ }
93
+ }
94
+
95
+ /** The Tabnine writer; `version` is for tests, which must not depend on what is installed. */
96
+ export function tabnineWriter(opts: { version?: string } = {}): Writer {
97
+ const installed = (home: string) => opts.version ?? tabnineVersion(home)
98
+ return {
99
+ tool: TOOL,
100
+ label: LABEL,
101
+ way: "native",
102
+
103
+ async ready(w: WriteOptions): Promise<Readiness> {
104
+ const v = installed(w.home)
105
+ if (!v) return { ok: false, why: "the Tabnine CLI is not installed (no active bundle in ~/.tabnine/agent/.bundles)" }
106
+ if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `Tabnine CLI ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
107
+ if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; Tabnine lists and resumes sessions per project folder` }
108
+ return { ok: true }
109
+ },
110
+
111
+ async write(session: Session, w: WriteOptions): Promise<WriteResult> {
112
+ const cwd = realCwd(w.cwd)
113
+ const now = new Date()
114
+ const folder = projectFolder(w.home, cwd)
115
+ const chats = join(folder.dir, "chats")
116
+ let sessionId = randomUUID()
117
+ const name = () => `session-${now.toISOString().slice(0, 16).replace(/:/g, "-")}-${sessionId.slice(0, 8)}.jsonl`
118
+ while (exists(join(chats, name()))) sessionId = randomUUID()
119
+ const path = join(chats, name())
120
+ const plan = planConversation(session, { sessionId, projectHash: sha256(cwd), now, version: GEMINI_FORMAT_VERSION })
121
+ const written = [path, ...folder.markers, ...(folder.register ? [join(agentDir(w.home), "projects.json")] : [])]
122
+ if (!w.dryRun) {
123
+ for (const m of folder.markers) {
124
+ mkdirSync(dirname(m), { recursive: true })
125
+ writeFileSync(m, cwd, { flag: "wx" })
126
+ }
127
+ if (folder.register) register(w.home, folder.register)
128
+ mkdirSync(chats, { recursive: true })
129
+ // "wx": a new file only; an existing session is never touched.
130
+ writeFileSync(path, serialize(plan, "jsonl"), { flag: "wx" })
131
+ try {
132
+ const back = await tabnineReader.read({ tool: TOOL, sourceId: sessionId, path, cwd }, w.home)
133
+ const got = countTurns(back)
134
+ const pairs = unpaired(back)
135
+ if (got.user !== plan.expect.users || got.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length)
136
+ throw new Error(`the new Tabnine session did not read back as written (users ${got.user}/${plan.expect.users}, tool calls ${got.tool_call}/${plan.expect.calls})`)
137
+ } catch (e) {
138
+ rmSync(path, { force: true })
139
+ throw new Error(`${(e as Error).message}; the session file was removed`)
140
+ }
141
+ }
142
+ return {
143
+ tool: TOOL,
144
+ way: "native",
145
+ sessionId,
146
+ written,
147
+ open: `cd ${shellQuote(cwd)} && tabnine --resume ${sessionId}`,
148
+ note: "If Tabnine's OpenCode-based CLI is installed, this CLI is `tabnine-legacy`.",
149
+ lost: plan.lost.map((l) => l.replace(/Gemini CLI/g, LABEL)),
150
+ dryRun: !!w.dryRun,
151
+ }
152
+ },
153
+ }
154
+ }
155
+
156
+ export const tabnineNativeWriter = tabnineWriter()
@@ -0,0 +1,73 @@
1
+ import type { Session } from "../session/types.ts"
2
+
3
+ // The contract every tool adapter implements. A reader finds a tool's sessions under a home folder and
4
+ // turns one into the portable session; a writer continues a portable session in its tool. Readers and
5
+ // writers take `home` explicitly so tests run against a fake home and never touch the real one.
6
+
7
+ export interface SessionRef {
8
+ /** Reader id, e.g. "claude-code". */
9
+ tool: string
10
+ /** The session's id in its own tool. */
11
+ sourceId: string
12
+ /** The file, folder or database the session lives in. */
13
+ path: string
14
+ title?: string
15
+ cwd?: string
16
+ updatedAt?: string
17
+ /** Whatever else the reader needs to find this session again (a workspace hash, a row key). */
18
+ extra?: Record<string, unknown>
19
+ }
20
+
21
+ export interface Reader {
22
+ tool: string
23
+ /** Name shown to people, e.g. "Claude Code". */
24
+ label: string
25
+ /**
26
+ * Every session this tool keeps under `home`, newest first. Reads only; returns [] when the tool has
27
+ * never stored a session here. Must be cheap enough to run for every tool on `brain session list`.
28
+ */
29
+ list(home: string): Promise<SessionRef[]>
30
+ /** One session in full. Throws a plain Error with a reason when it cannot be read. */
31
+ read(ref: SessionRef, home: string): Promise<Session>
32
+ }
33
+
34
+ /** How a writer continues a session: as the tool's own session, through the tool's importer, or by a brief. */
35
+ export type Way = "native" | "import" | "brief"
36
+
37
+ export interface WriteOptions {
38
+ home: string
39
+ /** The folder to continue in: the session's own project folder unless the person chose another. */
40
+ cwd: string
41
+ dryRun?: boolean
42
+ /** For brief writers: a lean brief and what the project remembers (see BriefOptions); other writers ignore it. */
43
+ brief?: { lean?: boolean; memory?: Array<{ kind?: string; text: string; source?: string }>; map?: string }
44
+ }
45
+
46
+ export interface WriteResult {
47
+ tool: string
48
+ way: Way
49
+ /** The new session's id in the target tool, when there is one. */
50
+ sessionId?: string
51
+ /** Files written (or that would be written, in a dry run). */
52
+ written: string[]
53
+ /** What the person runs or does next, e.g. `claude --resume <id>`. */
54
+ open: string
55
+ /** What the person does after `open`, when it is more than a command (a click in the app). */
56
+ note?: string
57
+ /** What the target cannot keep, on top of what reading already lost. */
58
+ lost: string[]
59
+ dryRun: boolean
60
+ }
61
+
62
+ export type Readiness = { ok: true } | { ok: false; why: string }
63
+
64
+ export interface Writer {
65
+ /** Target id, e.g. "claude-code" or "cursor". */
66
+ tool: string
67
+ label: string
68
+ way: Way
69
+ /** Whether it can write now on this machine: the tool installed, a tested version, the app closed if that matters. */
70
+ ready(opts: WriteOptions): Promise<Readiness>
71
+ /** Continues the session as a new session in the target; never modifies the source. */
72
+ write(session: Session, opts: WriteOptions): Promise<WriteResult>
73
+ }
@@ -0,0 +1,9 @@
1
+ import type { Reader, Writer } from "../types.ts"
2
+ import { reader } from "./reader.ts"
3
+ import { voidNativeWriter } from "./writer.ts"
4
+
5
+ // Void (a VS Code fork): reads its chat threads from its global state.vscdb, and continues any session as a
6
+ // new thread in its chat history (Void must be closed while it is written).
7
+ export { reader }
8
+ export const readers: Reader[] = [reader]
9
+ export const writers: Writer[] = [voidNativeWriter]
@@ -0,0 +1,48 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import { exists } from "../../fsutil.ts"
3
+ import type { Session } from "../../session/types.ts"
4
+ import { withSnapshot } from "../../sqlite.ts"
5
+ import type { Reader, SessionRef } from "../types.ts"
6
+ import { KEY, LABEL, TOOL, dbPath, parseThreads, threadTitle, toPortable } from "./records.ts"
7
+
8
+ type Json = Record<string, any>
9
+
10
+ /** The value of an ItemTable key, as text. */
11
+ export function itemValue(db: Database, key: string): string | undefined {
12
+ const row = db.query("SELECT value FROM ItemTable WHERE key = ?").get(key) as { value: string | Uint8Array } | null
13
+ return row ? (typeof row.value === "string" ? row.value : new TextDecoder().decode(row.value)) : undefined
14
+ }
15
+
16
+ /** Every stored thread, read from a copy of Void's global storage. */
17
+ export function readThreads(home: string): { threads: Json; path: string } {
18
+ const path = dbPath(home)
19
+ if (!exists(path)) return { threads: {}, path }
20
+ const text = withSnapshot(path, (db) => itemValue(db, KEY))
21
+ return { threads: text ? parseThreads(text) : {}, path }
22
+ }
23
+
24
+ export const reader: Reader = {
25
+ tool: TOOL,
26
+ label: LABEL,
27
+
28
+ async list(home: string): Promise<SessionRef[]> {
29
+ const { threads, path } = readThreads(home)
30
+ return Object.values(threads)
31
+ .filter((t: Json) => typeof t?.id === "string" && Array.isArray(t.messages) && t.messages.length)
32
+ .map((t: Json) => ({
33
+ tool: TOOL,
34
+ sourceId: t.id,
35
+ path,
36
+ ...(threadTitle(t) ? { title: threadTitle(t)!.replace(/\s+/g, " ").slice(0, 120) } : {}),
37
+ ...(typeof t.lastModified === "string" ? { updatedAt: t.lastModified } : {}),
38
+ }))
39
+ .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
40
+ },
41
+
42
+ async read(ref: SessionRef, home: string): Promise<Session> {
43
+ const { threads, path } = readThreads(home)
44
+ const t = threads[ref.sourceId]
45
+ if (!t) throw new Error(`Void has no thread ${ref.sourceId} in ${path}`)
46
+ return toPortable(t, path)
47
+ },
48
+ }
@@ -0,0 +1,285 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { join } from "node:path"
3
+ import { toolLabel } from "../../brief.ts"
4
+ import { appSupport } from "../../fsutil.ts"
5
+ import type { Session, ToolKind, Turn } from "../../session/types.ts"
6
+ import { asText, clip, header, lossCounter, toolKindOf } from "../../session/util.ts"
7
+ import { CONTINUED_NOTE, continuedNote } from "../claude-code/records.ts"
8
+ import { ARG, type ToolMap, asArgs, mapTool, pick, safeToolName } from "../vscode-copilot/shared.ts"
9
+
10
+ // Void's chat threads, checked against voideditor/void main at 1.4.9 (b3166e7e): chatThreadService.ts,
11
+ // chatThreadServiceTypes.ts, convertToLLMMessageService.ts, SidebarThreadSelector.tsx and SidebarChat.tsx.
12
+ //
13
+ // Every thread lives in one JSON value in Void's global storage, User/globalStorage/state.vscdb, ItemTable key
14
+ // 'void.chatThreadStorageII': {[threadId]: {id, createdAt, lastModified, messages, state, filesWithUserChanges}}.
15
+ // Void reads it once when it starts and rewrites the whole value from memory on every change, so it must
16
+ // be closed while the value is written. URIs inside are marshalled VS Code URIs ($mid 1).
17
+ //
18
+ // A user message has `content` (sent to the model) and `displayContent` (shown), which is where the note
19
+ // that the session was carried over goes. Tool calls exist only as tool messages ({role: 'tool', id, name,
20
+ // rawParams, params, result, content}); when Void builds the model's messages it attaches each tool
21
+ // message to the assistant message right before it as that message's single call, so every tool message
22
+ // gets its own assistant message before it. The thread list names a thread by its first user message.
23
+
24
+ export const TOOL = "void"
25
+ export const LABEL = "Void"
26
+ export const KEY = "void.chatThreadStorageII"
27
+
28
+ export const dbPath = (home: string) => join(appSupport(home), "Void", "User", "globalStorage", "state.vscdb")
29
+
30
+ type Json = Record<string, any>
31
+ const isObj = (v: unknown): v is Json => typeof v === "object" && v !== null && !Array.isArray(v)
32
+
33
+ const KIND: Record<string, ToolKind> = {
34
+ read_file: "read",
35
+ ls_dir: "list",
36
+ get_dir_tree: "list",
37
+ search_pathnames_only: "search",
38
+ search_for_files: "search",
39
+ search_in_file: "search",
40
+ read_lint_errors: "read",
41
+ rewrite_file: "write",
42
+ edit_file: "edit",
43
+ create_file_or_folder: "write",
44
+ delete_file_or_folder: "edit",
45
+ run_command: "shell",
46
+ run_persistent_command: "shell",
47
+ open_persistent_terminal: "shell",
48
+ kill_persistent_terminal: "shell",
49
+ }
50
+
51
+ /** A marshalled file URI, as Void stores one. */
52
+ const fileUri = (path: string) => ({ $mid: 1, fsPath: path, external: `file://${encodeURI(path)}`, path, scheme: "file" })
53
+
54
+ /** The JSON Void stores, with URIs (objects with $mid 1) turned into their paths. */
55
+ export function parseThreads(text: string): Json {
56
+ const v = JSON.parse(text, (_k, value) => (isObj(value) && value.$mid === 1 && typeof value.path === "string" ? value.path : value))
57
+ return isObj(v) ? v : {}
58
+ }
59
+
60
+ /** The first user message as the thread list shows it. */
61
+ export const threadTitle = (t: Json): string | undefined => {
62
+ const m = Array.isArray(t.messages) ? t.messages.find((x: Json) => x?.role === "user") : undefined
63
+ return typeof m?.displayContent === "string" && m.displayContent ? m.displayContent : undefined
64
+ }
65
+
66
+ // --- Reading ---------------------------------------------------------------------------------------------
67
+
68
+ export function toPortable(thread: Json, path: string): Session {
69
+ const lost = lossCounter()
70
+ const h = header(TOOL, String(thread.id), {
71
+ ...(threadTitle(thread) ? { title: threadTitle(thread)!.replace(/\s+/g, " ").slice(0, 120) } : {}),
72
+ sourcePath: path,
73
+ project: {},
74
+ ...(typeof thread.createdAt === "string" ? { startedAt: thread.createdAt } : {}),
75
+ ...(typeof thread.lastModified === "string" ? { updatedAt: thread.lastModified } : {}),
76
+ })
77
+ const turns: Turn[] = []
78
+ for (const m of Array.isArray(thread.messages) ? thread.messages.filter(isObj) : []) {
79
+ if (m.role === "user") {
80
+ // What the person typed is displayContent; content adds their selections (and brain's note).
81
+ const shown = typeof m.displayContent === "string" ? m.displayContent : ""
82
+ const sent = typeof m.content === "string" ? m.content : ""
83
+ const from = sent.match(CONTINUED_NOTE)?.[1]
84
+ if (from && !h.parent) h.parent = { id: from, kind: "continued" }
85
+ // A request that is only brain's note (its shown label is not in what was sent) is not the person's.
86
+ if (from && !sent.includes(shown)) continue
87
+ const text = shown || sent
88
+ if (Array.isArray(m.selections) && m.selections.length) lost.add("code selections", m.selections.length)
89
+ if (text.trim()) turns.push({ kind: "user", text })
90
+ } else if (m.role === "assistant") {
91
+ if (Array.isArray(m.anthropicReasoning) && m.anthropicReasoning.length) lost.add("signed reasoning", m.anthropicReasoning.length)
92
+ else if (typeof m.reasoning === "string" && m.reasoning.trim()) turns.push({ kind: "reasoning", text: m.reasoning })
93
+ if (typeof m.displayContent === "string" && m.displayContent.trim()) turns.push({ kind: "assistant", text: m.displayContent })
94
+ } else if (m.role === "tool") {
95
+ const id = String(m.id ?? "")
96
+ const name = String(m.name ?? "")
97
+ if (!id || !name) {
98
+ lost.add("tool calls without an id or name")
99
+ continue
100
+ }
101
+ turns.push({ kind: "tool_call", id, name, toolKind: KIND[name] ?? toolKindOf(name), input: isObj(m.params) ? m.params : isObj(m.rawParams) ? m.rawParams : {} })
102
+ const output = typeof m.content === "string" && m.content ? m.content : typeof m.result === "string" ? m.result : asText(m.result ?? "")
103
+ const failed = m.type !== "success"
104
+ const why = m.type === "rejected" ? "The user rejected this tool call." : m.type === "invalid_params" ? "The tool call had invalid parameters." : m.type === "tool_request" || m.type === "running_now" ? "The tool call did not finish." : ""
105
+ const c = clip(why && !output ? why : output)
106
+ turns.push({ kind: "tool_result", callId: id, ok: !failed, output: c.text, ...(c.truncated ? { truncated: true } : {}) })
107
+ } else if (m.role === "interrupted_streaming_tool") lost.add("tool calls interrupted while streaming")
108
+ }
109
+ lost.flush(h)
110
+ return { header: h, turns }
111
+ }
112
+
113
+ // --- Writing ---------------------------------------------------------------------------------------------
114
+
115
+ /** A tool message Void can show, for a call it would have made itself where the arguments map. */
116
+ interface VoidTool {
117
+ name: string
118
+ params: Json
119
+ rawParams: Json
120
+ result: (output: string) => unknown
121
+ }
122
+
123
+ const TOOLS: ToolMap = {
124
+ read: (i) => {
125
+ const path = pick(i, ARG.path)
126
+ return path ? { name: "read_file", args: { path } } : undefined
127
+ },
128
+ shell: (i) => {
129
+ const command = pick(i, ARG.command)
130
+ return command ? { name: "run_command", args: { command } } : undefined
131
+ },
132
+ }
133
+
134
+ function voidTool(t: Extract<Turn, { kind: "tool_call" }>): VoidTool {
135
+ const mapped = mapTool(TOOLS, t.toolKind, t.input)
136
+ if (mapped?.name === "read_file") {
137
+ const path = String(mapped.args.path)
138
+ return {
139
+ name: "read_file",
140
+ params: { uri: fileUri(path), startLine: null, endLine: null, pageNumber: 1 },
141
+ rawParams: { uri: path },
142
+ result: (out) => ({ fileContents: out, totalFileLen: out.length, totalNumLines: out.split("\n").length, hasNextPage: false }),
143
+ }
144
+ }
145
+ if (mapped?.name === "run_command") {
146
+ const command = String(mapped.args.command)
147
+ // The terminal is gone; the result view shows the stored output without it.
148
+ return { name: "run_command", params: { command, cwd: null, terminalId: "brain" }, rawParams: { command }, result: (out) => ({ result: out, resolveReason: { type: "done", exitCode: 0 } }) }
149
+ }
150
+ // Anything else is shown the way Void shows an MCP tool's call: its inputs and a text result.
151
+ const name = safeToolName(t.name)
152
+ const args = asArgs(t.input)
153
+ return { name, params: args, rawParams: args, result: (out) => ({ event: "text", text: out, toolName: name }) }
154
+ }
155
+
156
+ export interface VoidPlan {
157
+ thread: Json
158
+ lost: string[]
159
+ expect: { users: number; calls: number }
160
+ }
161
+
162
+ /** The thread Void would store for `session` continued as thread `threadId`. Pure. */
163
+ export function planThread(session: Session, opts: { threadId: string; now: Date }): VoidPlan {
164
+ const label = toolLabel(session.header.tool)
165
+ const drops = new Map<string, number>()
166
+ const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
167
+ const note = continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10))
168
+ const results = new Map<string, Extract<Turn, { kind: "tool_result" }>>()
169
+ const callIds = new Set<string>()
170
+ for (const t of session.turns) {
171
+ if (t.kind === "tool_result" && !results.has(t.callId)) results.set(t.callId, t)
172
+ if (t.kind === "tool_call") callIds.add(t.id)
173
+ }
174
+ // Void has no summary record: the thread starts from the last compaction, its summary in the note.
175
+ let turns = session.turns
176
+ let carried: string | undefined
177
+ const last = turns.map((t) => t.kind).lastIndexOf("compaction")
178
+ if (last >= 0) {
179
+ const before = turns.slice(0, last).filter((t) => t.kind === "user" || t.kind === "assistant" || t.kind === "tool_call").length
180
+ if (before) drop("messages and tool calls before the last compaction (its summary is kept)", before)
181
+ carried = (turns[last] as Extract<Turn, { kind: "compaction" }>).summary
182
+ turns = turns.slice(last + 1)
183
+ }
184
+ const sent = carried ? `${note}\n\nSummary of the earlier conversation:\n${carried}` : note
185
+
186
+ const messages: Json[] = []
187
+ let noteGiven = false
188
+ let users = 0
189
+ let calls = 0
190
+ let reasoning: string[] = []
191
+ const seen = new Set<string>()
192
+ const user = (shown: string, extra?: string) => {
193
+ const content = noteGiven ? shown : `${sent}\n\n${shown}`
194
+ noteGiven = true
195
+ messages.push({ role: "user", content: extra ? `${content}\n\n${extra}` : content, displayContent: shown, selections: null, state: { stagingSelections: [], isBeingEdited: false } })
196
+ }
197
+ const assistant = (text: string) => {
198
+ if (!messages.some((m) => m.role === "user")) {
199
+ // Replies before any request: a request that is only the note, labelled for the thread list.
200
+ messages.push({ role: "user", content: sent, displayContent: `Continued from ${label}: ${session.header.title ?? session.header.sourceId}`, selections: null, state: { stagingSelections: [], isBeingEdited: false } })
201
+ noteGiven = true
202
+ }
203
+ messages.push({ role: "assistant", displayContent: text, reasoning: reasoning.join("\n\n"), anthropicReasoning: null })
204
+ reasoning = []
205
+ }
206
+
207
+ for (const t of turns) {
208
+ switch (t.kind) {
209
+ case "user": {
210
+ const files = (t.attachments ?? []).filter((a) => a.kind === "file" && a.path).map((a) => `[Attached file: ${a.path}]`)
211
+ if ((t.attachments?.length ?? 0) > files.length) drop("attachments that are not files", (t.attachments?.length ?? 0) - files.length)
212
+ if (!t.text.trim() && !files.length) break
213
+ if (reasoning.length) drop("reasoning with no reply after it", reasoning.length)
214
+ reasoning = []
215
+ user(t.text, files.join("\n") || undefined)
216
+ users++
217
+ break
218
+ }
219
+ case "assistant":
220
+ if (t.text.trim()) assistant(t.text)
221
+ break
222
+ case "reasoning":
223
+ if (t.text.trim()) {
224
+ reasoning.push(t.text)
225
+ drop("reasoning (shown in the chat, not sent back to the model)")
226
+ }
227
+ break
228
+ case "tool_call": {
229
+ if (seen.has(t.id)) {
230
+ drop("repeated tool call ids")
231
+ break
232
+ }
233
+ seen.add(t.id)
234
+ // Void sends a tool message as the call of the assistant message right before it.
235
+ if (messages.at(-1)?.role !== "assistant") assistant("")
236
+ const res = results.get(t.id)
237
+ const tool = voidTool(t)
238
+ let out = res?.output ?? "No result was recorded for this call; the session moved on before it finished."
239
+ if (res?.fullPath) out += `\n\n[Full output: ${res.fullPath}]`
240
+ if (!res) drop("tool calls without a result (kept as errors)")
241
+ const ok = !!res?.ok
242
+ messages.push({
243
+ role: "tool",
244
+ type: ok ? "success" : "tool_error",
245
+ name: tool.name,
246
+ params: tool.params,
247
+ result: ok ? tool.result(out) : out,
248
+ content: out,
249
+ id: t.id,
250
+ rawParams: tool.rawParams,
251
+ mcpServerName: undefined,
252
+ })
253
+ calls++
254
+ break
255
+ }
256
+ case "tool_result":
257
+ if (!callIds.has(t.callId)) drop("tool results without a call")
258
+ break
259
+ case "file_edit":
260
+ if (!t.callId || !callIds.has(t.callId)) drop("file edits outside a tool call")
261
+ break
262
+ case "subagent":
263
+ drop("subagent sessions (their call and result are kept)")
264
+ break
265
+ case "compaction":
266
+ break
267
+ }
268
+ }
269
+ if (reasoning.length) drop("reasoning with no reply after it", reasoning.length)
270
+ if (!messages.length) throw new Error("nothing to continue: the session has no messages")
271
+
272
+ const created = opts.now.toISOString()
273
+ const thread = {
274
+ id: opts.threadId,
275
+ createdAt: created,
276
+ lastModified: created,
277
+ messages,
278
+ state: { currCheckpointIdx: null, stagingSelections: [], linksOfMessageIdx: {} },
279
+ // A Set in Void's memory, which JSON.stringify writes as {}.
280
+ filesWithUserChanges: {},
281
+ }
282
+ return { thread, lost: [...drops].map(([what, n]) => `${what} (${n})`), expect: { users, calls } }
283
+ }
284
+
285
+ export const newThreadId = () => randomUUID()
@@ -0,0 +1,93 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { mkdirSync } from "node:fs"
3
+ import { dirname, join } from "node:path"
4
+ import { exists, isRunning, readJson } from "../../fsutil.ts"
5
+ import type { Session } from "../../session/types.ts"
6
+ import { countTurns, unpaired } from "../../session/util.ts"
7
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
8
+ import { backup, shellQuote } from "../vscode-copilot/shared.ts"
9
+ import { itemValue } from "./reader.ts"
10
+ import { KEY, LABEL, TOOL, dbPath, newThreadId, parseThreads, planThread, toPortable } from "./records.ts"
11
+
12
+ // Continues a portable session as a new Void chat thread: the thread is added to the JSON value Void keeps
13
+ // all threads in, in its global state.vscdb. Void holds that value in memory and writes all of it back on
14
+ // every change, so this runs only with Void closed, and backs the database up first.
15
+
16
+ /** Void releases this writer was checked against (the source at 1.4.9). */
17
+ export const TESTED_VOID = ["1.4"]
18
+
19
+ /** The installed Void's version (product.json voidVersion). */
20
+ export function installedVoid(home: string): string | undefined {
21
+ for (const root of ["/Applications", join(home, "Applications")]) {
22
+ const p = readJson<{ voidVersion?: string; version?: string }>(join(root, "Void.app", "Contents", "Resources", "app", "product.json"))
23
+ if (p) return p.voidVersion ?? p.version ?? "unknown"
24
+ }
25
+ return undefined
26
+ }
27
+
28
+ const line = (v: string | undefined) => v?.match(/\d+\.\d+/)?.[0]
29
+
30
+ /** The Void writer; `version` and `running` are for tests, which must not depend on the installed app. */
31
+ export function voidWriter(opts: { version?: string | null; running?: () => boolean } = {}): Writer {
32
+ const version = (home: string) => (opts.version === undefined ? installedVoid(home) : (opts.version ?? undefined))
33
+ const running = opts.running ?? (() => isRunning("Void"))
34
+ return {
35
+ tool: TOOL,
36
+ label: LABEL,
37
+ way: "native",
38
+
39
+ async ready(w: WriteOptions): Promise<Readiness> {
40
+ const v = version(w.home)
41
+ if (!v) return { ok: false, why: "Void is not installed (no Void.app in /Applications or ~/Applications)" }
42
+ if (!TESTED_VOID.includes(line(v) ?? "")) return { ok: false, why: `Void ${v} is not a version this writer was checked against (${TESTED_VOID.join(", ")})` }
43
+ if (running()) return { ok: false, why: "Void is running; quit it first (it rewrites all chat threads from memory)" }
44
+ return { ok: true }
45
+ },
46
+
47
+ async write(session: Session, w: WriteOptions): Promise<WriteResult> {
48
+ if (!w.dryRun && running()) throw new Error("Void is running; quit it first")
49
+ const db = dbPath(w.home)
50
+ const threadId = newThreadId()
51
+ const plan = planThread(session, { threadId, now: new Date() })
52
+ // What Void will read back, checked before anything is written.
53
+ const back = toPortable(parseThreads(JSON.stringify({ [threadId]: plan.thread }))[threadId], db)
54
+ const n = countTurns(back)
55
+ const pairs = unpaired(back)
56
+ if (n.user !== plan.expect.users || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length)
57
+ throw new Error(`the new thread did not read back intact (${n.user} of ${plan.expect.users} messages, ${n.tool_call} of ${plan.expect.calls} tool calls)`)
58
+ let backedUp: string | undefined
59
+ if (!w.dryRun) {
60
+ if (exists(db)) backedUp = backup(w.home, TOOL, [db])
61
+ else mkdirSync(dirname(db), { recursive: true })
62
+ const conn = new Database(db, { create: true })
63
+ try {
64
+ // The schema VS Code (and so Void) creates; a no-op on an existing store.
65
+ conn.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)")
66
+ conn.transaction(() => {
67
+ const text = itemValue(conn, KEY)
68
+ // Parsed without reviving URIs, so every other thread is written back exactly as Void stored it.
69
+ const threads = text ? JSON.parse(text) : {}
70
+ if (typeof threads !== "object" || threads === null || Array.isArray(threads)) throw new Error(`Void's thread store in ${db} is not the format this writer knows`)
71
+ threads[threadId] = plan.thread
72
+ conn.query("INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)").run(KEY, JSON.stringify(threads))
73
+ })()
74
+ } finally {
75
+ conn.close()
76
+ }
77
+ }
78
+ const first = (plan.thread.messages as Array<{ role: string; displayContent?: string }>).find((m) => m.role === "user")?.displayContent ?? ""
79
+ return {
80
+ tool: TOOL,
81
+ way: "native",
82
+ sessionId: threadId,
83
+ written: [db],
84
+ open: `open -a Void ${shellQuote(w.cwd)}`,
85
+ note: `In Void's chat, open the thread history and pick the thread that starts "${first.replace(/\s+/g, " ").slice(0, 60)}".${backedUp ? ` Void's state.vscdb was backed up to ${backedUp}.` : ""}`,
86
+ lost: plan.lost,
87
+ dryRun: !!w.dryRun,
88
+ }
89
+ },
90
+ }
91
+ }
92
+
93
+ export const voidNativeWriter = voidWriter()