@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,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()
|