@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,169 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { exists, isDir, versionOf } from "../../fsutil.ts"
|
|
3
|
+
import { resolveBin } from "../opencode/writer.ts"
|
|
4
|
+
|
|
5
|
+
// Crush (charmbracelet/crush): where its stores are and what its rows look like, from the v0.100.0 source
|
|
6
|
+
// (internal/db/migrations, internal/db/sql, internal/message/{message,content}.go, internal/config/load.go,
|
|
7
|
+
// internal/projects/projects.go) and the store the v0.100.0 binary creates.
|
|
8
|
+
//
|
|
9
|
+
// Each project has its own store, <project>/.crush/crush.db: tables sessions and messages, a message's
|
|
10
|
+
// content a JSON array of {type, data} parts (text, reasoning, tool_call, tool_result, finish, …). A tool
|
|
11
|
+
// call sits in its assistant message; each result is a separate message with role "tool". Times are Unix
|
|
12
|
+
// seconds and messages are read back ordered by created_at alone. The projects Crush has run in are listed in
|
|
13
|
+
// ~/.local/share/crush/projects.json, which is how a reader finds the stores.
|
|
14
|
+
|
|
15
|
+
export const TOOL = "crush"
|
|
16
|
+
export const LABEL = "Crush"
|
|
17
|
+
export const TESTED_VERSIONS = ["0.100.0"]
|
|
18
|
+
|
|
19
|
+
/** The Crush binary: `go install`'s copy under home first (~/go/bin), else the one on PATH (brew, npm). */
|
|
20
|
+
export const crushBin = (home: string) => resolveBin({ bin: "crush", installDir: "go" }, home)
|
|
21
|
+
|
|
22
|
+
/** The installed Crush version ("crush version v0.100.0" → "0.100.0"). */
|
|
23
|
+
export const crushVersion = (home: string) => versionOf(crushBin(home).path)?.match(/(\d+\.\d+\.\d+)/)?.[1]
|
|
24
|
+
|
|
25
|
+
export const projectsFile = (home: string) => join(home, ".local", "share", "crush", "projects.json")
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The data folder Crush uses for a project folder, as config.setDefaults finds it: the closest `.crush` from
|
|
29
|
+
* the folder up to its repository root (never the one in the home folder), else `<folder>/.crush`. A
|
|
30
|
+
* data_directory set in the person's crush.json is not followed.
|
|
31
|
+
*/
|
|
32
|
+
export function dataDirFor(cwd: string, home: string): string {
|
|
33
|
+
let root = cwd
|
|
34
|
+
for (let dir = cwd; dir && dir !== "/"; dir = dir.slice(0, dir.lastIndexOf("/")) || "/")
|
|
35
|
+
if (exists(join(dir, ".git"))) {
|
|
36
|
+
root = dir
|
|
37
|
+
break
|
|
38
|
+
}
|
|
39
|
+
for (let dir = cwd; ; dir = dir.slice(0, dir.lastIndexOf("/")) || "/") {
|
|
40
|
+
if (isDir(join(dir, ".crush"))) {
|
|
41
|
+
if (dir === home) break
|
|
42
|
+
return join(dir, ".crush")
|
|
43
|
+
}
|
|
44
|
+
if (dir === root || dir === "/") break
|
|
45
|
+
}
|
|
46
|
+
return join(cwd, ".crush")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The goose migrations a v0.100.0 store has applied (version 0 is goose's own first row). */
|
|
50
|
+
export const MIGRATIONS = [
|
|
51
|
+
0, 20250424200609, 20250515105448, 20250624000000, 20250627000000, 20250810000000, 20250812000000, 20260127000000, 20260902000000, 20260912000000,
|
|
52
|
+
20260921000000, 20260928000000, 20260928000001,
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
/** The schema Crush v0.100.0 leaves after its migrations, for a project that has no store yet. */
|
|
56
|
+
export const SCHEMA = `
|
|
57
|
+
CREATE TABLE goose_db_version (
|
|
58
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
59
|
+
version_id INTEGER NOT NULL,
|
|
60
|
+
is_applied INTEGER NOT NULL,
|
|
61
|
+
tstamp TIMESTAMP DEFAULT (datetime('now'))
|
|
62
|
+
);
|
|
63
|
+
CREATE TABLE sessions (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
parent_session_id TEXT,
|
|
66
|
+
title TEXT NOT NULL,
|
|
67
|
+
message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
|
|
68
|
+
prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0),
|
|
69
|
+
completion_tokens INTEGER NOT NULL DEFAULT 0 CHECK (completion_tokens>= 0),
|
|
70
|
+
cost REAL NOT NULL DEFAULT 0.0 CHECK (cost >= 0.0),
|
|
71
|
+
updated_at INTEGER NOT NULL,
|
|
72
|
+
created_at INTEGER NOT NULL
|
|
73
|
+
, summary_message_id TEXT, todos TEXT, channel TEXT);
|
|
74
|
+
CREATE TRIGGER update_sessions_updated_at
|
|
75
|
+
AFTER UPDATE ON sessions
|
|
76
|
+
BEGIN
|
|
77
|
+
UPDATE sessions SET updated_at = strftime('%s', 'now')
|
|
78
|
+
WHERE id = new.id;
|
|
79
|
+
END;
|
|
80
|
+
CREATE TABLE files (
|
|
81
|
+
id TEXT PRIMARY KEY,
|
|
82
|
+
session_id TEXT NOT NULL,
|
|
83
|
+
path TEXT NOT NULL,
|
|
84
|
+
content TEXT NOT NULL,
|
|
85
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
86
|
+
created_at INTEGER NOT NULL,
|
|
87
|
+
updated_at INTEGER NOT NULL,
|
|
88
|
+
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
|
|
89
|
+
UNIQUE(path, session_id, version)
|
|
90
|
+
);
|
|
91
|
+
CREATE INDEX idx_files_session_id ON files (session_id);
|
|
92
|
+
CREATE INDEX idx_files_path ON files (path);
|
|
93
|
+
CREATE TRIGGER update_files_updated_at
|
|
94
|
+
AFTER UPDATE ON files
|
|
95
|
+
BEGIN
|
|
96
|
+
UPDATE files SET updated_at = strftime('%s', 'now')
|
|
97
|
+
WHERE id = new.id;
|
|
98
|
+
END;
|
|
99
|
+
CREATE TABLE messages (
|
|
100
|
+
id TEXT PRIMARY KEY,
|
|
101
|
+
session_id TEXT NOT NULL,
|
|
102
|
+
role TEXT NOT NULL,
|
|
103
|
+
parts TEXT NOT NULL default '[]',
|
|
104
|
+
model TEXT,
|
|
105
|
+
created_at INTEGER NOT NULL,
|
|
106
|
+
updated_at INTEGER NOT NULL,
|
|
107
|
+
finished_at INTEGER, provider TEXT, is_summary_message INTEGER DEFAULT 0 NOT NULL, prism_model_id TEXT, prism_model_name TEXT, prism_hypercredit_savings REAL, prism_dollar_savings REAL,
|
|
108
|
+
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
|
|
109
|
+
);
|
|
110
|
+
CREATE INDEX idx_messages_session_id ON messages (session_id);
|
|
111
|
+
CREATE TRIGGER update_messages_updated_at
|
|
112
|
+
AFTER UPDATE ON messages
|
|
113
|
+
BEGIN
|
|
114
|
+
UPDATE messages SET updated_at = strftime('%s', 'now')
|
|
115
|
+
WHERE id = new.id;
|
|
116
|
+
END;
|
|
117
|
+
CREATE TRIGGER update_session_message_count_on_insert
|
|
118
|
+
AFTER INSERT ON messages
|
|
119
|
+
BEGIN
|
|
120
|
+
UPDATE sessions SET
|
|
121
|
+
message_count = message_count + 1
|
|
122
|
+
WHERE id = new.session_id;
|
|
123
|
+
END;
|
|
124
|
+
CREATE TRIGGER update_session_message_count_on_delete
|
|
125
|
+
AFTER DELETE ON messages
|
|
126
|
+
BEGIN
|
|
127
|
+
UPDATE sessions SET
|
|
128
|
+
message_count = message_count - 1
|
|
129
|
+
WHERE id = old.session_id;
|
|
130
|
+
END;
|
|
131
|
+
CREATE INDEX idx_sessions_created_at ON sessions (created_at);
|
|
132
|
+
CREATE INDEX idx_messages_created_at ON messages (created_at);
|
|
133
|
+
CREATE INDEX idx_files_created_at ON files (created_at);
|
|
134
|
+
CREATE TABLE read_files (
|
|
135
|
+
session_id TEXT NOT NULL CHECK (session_id != ''),
|
|
136
|
+
path TEXT NOT NULL CHECK (path != ''),
|
|
137
|
+
read_at INTEGER NOT NULL,
|
|
138
|
+
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
|
|
139
|
+
PRIMARY KEY (path, session_id)
|
|
140
|
+
);
|
|
141
|
+
CREATE INDEX idx_read_files_session_id ON read_files (session_id);
|
|
142
|
+
CREATE INDEX idx_read_files_path ON read_files (path);
|
|
143
|
+
CREATE INDEX idx_messages_role_created_at ON messages (role, created_at);
|
|
144
|
+
CREATE TABLE mcp_disabled_servers (
|
|
145
|
+
name TEXT PRIMARY KEY
|
|
146
|
+
);
|
|
147
|
+
CREATE TABLE mcp_enabled_servers (
|
|
148
|
+
name TEXT PRIMARY KEY
|
|
149
|
+
);
|
|
150
|
+
`
|
|
151
|
+
|
|
152
|
+
/** One stored part, as marshalParts writes it. */
|
|
153
|
+
export interface Part {
|
|
154
|
+
type: "text" | "reasoning" | "tool_call" | "tool_result" | "finish" | "image_url" | "binary" | "shell_command" | "sub_agent_report"
|
|
155
|
+
data: Record<string, any>
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A messages row before it is inserted. */
|
|
159
|
+
export interface MessageRow {
|
|
160
|
+
id: string
|
|
161
|
+
role: "user" | "assistant" | "tool"
|
|
162
|
+
parts: Part[]
|
|
163
|
+
/** Crush stores "" for user and tool messages, the model id for assistant ones. */
|
|
164
|
+
model: string
|
|
165
|
+
provider: string | null
|
|
166
|
+
created_at: number
|
|
167
|
+
finished_at: number | null
|
|
168
|
+
is_summary_message: 0 | 1
|
|
169
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"
|
|
4
|
+
import { basename, join, resolve } from "node:path"
|
|
5
|
+
import { imageDataUrl, parseDataUrl } from "../../session/attachments.ts"
|
|
6
|
+
import { toolLabel } from "../../brief.ts"
|
|
7
|
+
import { exists, isDir, isRunning, readJson } from "../../fsutil.ts"
|
|
8
|
+
import { withSnapshot } from "../../sqlite.ts"
|
|
9
|
+
import type { Session, Turn } from "../../session/types.ts"
|
|
10
|
+
import { countTurns, unpaired } from "../../session/util.ts"
|
|
11
|
+
import { continuedNote } from "../opencode/note.ts"
|
|
12
|
+
import { random62 } from "../opencode/ids.ts"
|
|
13
|
+
import { CRUSH, mapCall } from "../opencode/tools.ts"
|
|
14
|
+
import { continuedTitle, modelRef, sh } from "../opencode/writer.ts"
|
|
15
|
+
import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
|
|
16
|
+
import { readCrush } from "./reader.ts"
|
|
17
|
+
import { LABEL, MIGRATIONS, SCHEMA, TESTED_VERSIONS, TOOL, crushBin, crushVersion, dataDirFor, projectsFile, type MessageRow, type Part } from "./records.ts"
|
|
18
|
+
|
|
19
|
+
// Continues a portable session in Crush as a new session of the project's own store, written as Crush's
|
|
20
|
+
// message service writes one (message.Service.Create and Update, agent.go): the note as a hidden user text,
|
|
21
|
+
// a user message per user turn, an assistant message per model response (reasoning, text and tool calls, then
|
|
22
|
+
// a finish part), one "tool" message per result, and a compaction as Crush's summary message, an assistant
|
|
23
|
+
// message flagged is_summary_message that sessions.summary_message_id points at; Crush sends the model only
|
|
24
|
+
// the messages from it on. The project is registered in projects.json, as Crush does when it starts.
|
|
25
|
+
//
|
|
26
|
+
// The store is SQLite that Crush holds open while it runs, so this writes only with Crush closed and backs
|
|
27
|
+
// the store up first. A project with no store yet gets the schema Crush v0.100.0 creates. Verified live with
|
|
28
|
+
// the v0.100.0 release binary (temp home, no network): `crush session list --json` and `session show --json`.
|
|
29
|
+
|
|
30
|
+
/** Crush's provider ids (catwalk) where they differ from the model family's. */
|
|
31
|
+
const providerOf = (model: string | undefined) => {
|
|
32
|
+
const p = modelRef(model).providerID
|
|
33
|
+
return p === "google" ? "gemini" : p === "unknown" ? null : p
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CrushPlan {
|
|
37
|
+
id: string
|
|
38
|
+
title: string
|
|
39
|
+
messages: MessageRow[]
|
|
40
|
+
summaryId: string | null
|
|
41
|
+
lost: string[]
|
|
42
|
+
/** What a read-back must find. */
|
|
43
|
+
expect: { users: number; assistants: number; calls: number }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The rows of a new Crush session continuing `session`. Pure: writes nothing. */
|
|
47
|
+
export function planCrush(session: Session, opts: { id: string; now: Date }): CrushPlan {
|
|
48
|
+
const drops = new Map<string, number>()
|
|
49
|
+
const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
|
|
50
|
+
const mapped = { n: 0 }
|
|
51
|
+
const messages: MessageRow[] = []
|
|
52
|
+
const expect = { users: 0, assistants: 0, calls: 0 }
|
|
53
|
+
const nowSec = Math.floor(opts.now.getTime() / 1000)
|
|
54
|
+
|
|
55
|
+
// Crush orders a session's messages by created_at (whole seconds) alone, so every message gets its own second.
|
|
56
|
+
let clock = Math.floor((Date.parse(session.header.startedAt ?? "") || opts.now.getTime() - session.turns.length * 1000) / 1000) - 1
|
|
57
|
+
const tick = (at?: string) => {
|
|
58
|
+
const t = at ? Math.floor(Date.parse(at) / 1000) : NaN
|
|
59
|
+
clock = Number.isFinite(t) && t > clock ? t : clock + 1
|
|
60
|
+
return clock
|
|
61
|
+
}
|
|
62
|
+
const finish = (reason: string, time: number): Part => ({ type: "finish", data: { reason, time } })
|
|
63
|
+
const row = (role: MessageRow["role"], parts: Part[], at: number, extra: Partial<MessageRow> = {}): MessageRow => {
|
|
64
|
+
const m: MessageRow = { id: randomUUID(), role, parts, model: "", provider: null, created_at: at, finished_at: null, is_summary_message: 0, ...extra }
|
|
65
|
+
messages.push(m)
|
|
66
|
+
return m
|
|
67
|
+
}
|
|
68
|
+
// User and tool messages get the finish part Create gives every message that is not the assistant's.
|
|
69
|
+
const plain = (role: "user" | "tool", parts: Part[], at?: string) => row(role, [...parts, finish("stop", 0)], tick(at))
|
|
70
|
+
|
|
71
|
+
let model = session.header.models.at(-1)
|
|
72
|
+
let step: { m: MessageRow; text: string[]; thinking: string[]; calls: Part[]; results: boolean } | undefined
|
|
73
|
+
const pending: Part[] = []
|
|
74
|
+
const callIds = new Map<string, { id: string; name: string }>()
|
|
75
|
+
const answered = new Set<string>()
|
|
76
|
+
let summaryId: string | null = null
|
|
77
|
+
|
|
78
|
+
const openStep = (at?: string) => {
|
|
79
|
+
if (step && !step.results) return step
|
|
80
|
+
closeStep()
|
|
81
|
+
const m = row("assistant", [], tick(at), { model: model ?? "unknown", provider: providerOf(model) })
|
|
82
|
+
step = { m, text: [], thinking: [], calls: [], results: false }
|
|
83
|
+
return step
|
|
84
|
+
}
|
|
85
|
+
function closeStep() {
|
|
86
|
+
if (!step) return
|
|
87
|
+
const { m, text, thinking, calls } = step
|
|
88
|
+
const end = Math.max(m.created_at, clock)
|
|
89
|
+
m.parts = [
|
|
90
|
+
...(thinking.length ? [{ type: "reasoning" as const, data: { thinking: thinking.join("\n\n"), signature: "", thought_signature: "", tool_id: "", responses_data: null, started_at: m.created_at, finished_at: m.created_at } }] : []),
|
|
91
|
+
...(text.length ? [{ type: "text" as const, data: { text: text.join("\n\n") } }] : []),
|
|
92
|
+
...calls,
|
|
93
|
+
finish(calls.length ? "tool_use" : "end_turn", end),
|
|
94
|
+
]
|
|
95
|
+
m.finished_at = end
|
|
96
|
+
if (text.length) expect.assistants++
|
|
97
|
+
step = undefined
|
|
98
|
+
flushResults()
|
|
99
|
+
}
|
|
100
|
+
// Each result is its own message, after the response that asked for it.
|
|
101
|
+
const flushResults = () => {
|
|
102
|
+
for (const r of pending.splice(0)) plain("tool", [r])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
plain("user", [{ type: "text", data: { text: continuedNote(toolLabel(session.header.tool), session.header.id, opts.now.toISOString().slice(0, 10)), hidden: true } }])
|
|
106
|
+
|
|
107
|
+
for (const t of session.turns as Turn[]) {
|
|
108
|
+
switch (t.kind) {
|
|
109
|
+
case "user": {
|
|
110
|
+
// Crush keeps an attachment as a "binary" part (Go BinaryContent: Path, MIMEType, Data as base64) after the text.
|
|
111
|
+
const bins: Part[] = []
|
|
112
|
+
let n = 0
|
|
113
|
+
for (const a of t.attachments ?? []) {
|
|
114
|
+
const d = a.kind === "image" ? parseDataUrl(imageDataUrl(a)) : undefined
|
|
115
|
+
if (d) bins.push({ type: "binary", data: { Path: a.name ?? (a.path ? basename(a.path) : "image"), MIMEType: d.mime, Data: d.base64 } })
|
|
116
|
+
else {
|
|
117
|
+
drop("attachments")
|
|
118
|
+
n++
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const text = t.text.trim() ? t.text : n ? `(${n} attachment${n > 1 ? "s" : ""} not carried over)` : bins.length ? "(image)" : ""
|
|
122
|
+
if (!text) break
|
|
123
|
+
closeStep()
|
|
124
|
+
plain("user", [{ type: "text", data: { text } }, ...bins], t.at)
|
|
125
|
+
expect.users++
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
case "assistant": {
|
|
129
|
+
if (!t.text.trim()) break
|
|
130
|
+
if (t.model && t.model !== model) {
|
|
131
|
+
// A response belongs to one model: text from another starts a new one.
|
|
132
|
+
if (step) closeStep()
|
|
133
|
+
model = t.model
|
|
134
|
+
}
|
|
135
|
+
openStep(t.at).text.push(t.text)
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
case "reasoning":
|
|
139
|
+
openStep(t.at).thinking.push(t.text)
|
|
140
|
+
drop("reasoning: kept as text, without provider signatures")
|
|
141
|
+
break
|
|
142
|
+
case "tool_call": {
|
|
143
|
+
if (callIds.has(t.id)) {
|
|
144
|
+
drop("repeated tool call ids")
|
|
145
|
+
break
|
|
146
|
+
}
|
|
147
|
+
const st = openStep(t.at)
|
|
148
|
+
tick(t.at)
|
|
149
|
+
const call = mapCall(CRUSH, t.name, t.toolKind, t.input, mapped)
|
|
150
|
+
const id = `call_${random62(24)}`
|
|
151
|
+
callIds.set(t.id, { id, name: call.tool })
|
|
152
|
+
st.calls.push({ type: "tool_call", data: { id, name: call.tool, input: JSON.stringify(call.input), provider_executed: false, finished: true } })
|
|
153
|
+
expect.calls++
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
case "tool_result": {
|
|
157
|
+
const call = callIds.get(t.callId)
|
|
158
|
+
if (!call) {
|
|
159
|
+
drop("tool results without a call")
|
|
160
|
+
break
|
|
161
|
+
}
|
|
162
|
+
if (answered.has(t.callId)) {
|
|
163
|
+
drop("repeated tool results")
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
answered.add(t.callId)
|
|
167
|
+
pending.push({ type: "tool_result", data: { tool_call_id: call.id, name: call.name, content: t.output, data: "", mime_type: "", metadata: "", is_error: !t.ok } })
|
|
168
|
+
if (step) step.results = true
|
|
169
|
+
else flushResults()
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
case "compaction": {
|
|
173
|
+
closeStep()
|
|
174
|
+
const at = tick(t.at)
|
|
175
|
+
const m = row("assistant", [{ type: "text", data: { text: t.summary } }, finish("end_turn", at)], at, {
|
|
176
|
+
model: model ?? "unknown",
|
|
177
|
+
provider: providerOf(model),
|
|
178
|
+
finished_at: at,
|
|
179
|
+
is_summary_message: 1,
|
|
180
|
+
})
|
|
181
|
+
summaryId = m.id
|
|
182
|
+
break
|
|
183
|
+
}
|
|
184
|
+
case "file_edit":
|
|
185
|
+
if (!t.callId) drop("file edits not made by a tool call")
|
|
186
|
+
break
|
|
187
|
+
case "subagent":
|
|
188
|
+
drop("subagent sessions (their call and result are kept)")
|
|
189
|
+
break
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Calls that never got a result are closed as failed, as Crush closes an interrupted one.
|
|
193
|
+
for (const [src, call] of callIds)
|
|
194
|
+
if (!answered.has(src)) {
|
|
195
|
+
pending.push({ type: "tool_result", data: { tool_call_id: call.id, name: call.name, content: "No result was recorded for this call.", data: "", mime_type: "", metadata: "", is_error: true } })
|
|
196
|
+
drop("calls with no result, marked as failed")
|
|
197
|
+
}
|
|
198
|
+
closeStep()
|
|
199
|
+
flushResults()
|
|
200
|
+
|
|
201
|
+
// Nothing may sort after the next message Crush writes, which is stamped now.
|
|
202
|
+
const late = (messages.at(-1)?.created_at ?? 0) - nowSec
|
|
203
|
+
if (late > 0)
|
|
204
|
+
for (const m of messages) {
|
|
205
|
+
m.created_at -= late
|
|
206
|
+
if (m.finished_at !== null) m.finished_at -= late
|
|
207
|
+
for (const p of m.parts) if (p.type === "finish" && p.data.time) p.data.time -= late
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const lost = [...drops].map(([what, n]) => `${what} (${n})`)
|
|
211
|
+
if (callIds.size) lost.push(`tool-call ids replaced (${callIds.size})`)
|
|
212
|
+
if (mapped.n) lost.push(`tool calls renamed to Crush's own tools (${mapped.n})`)
|
|
213
|
+
return { id: opts.id, title: continuedTitle(session), messages, summaryId, lost, expect }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The newest migration a store has applied, read from a copy. */
|
|
217
|
+
function storeVersion(db: string): number {
|
|
218
|
+
return withSnapshot(db, (d) => (d.query("SELECT max(version_id) AS v FROM goose_db_version WHERE is_applied = 1").get() as { v: number | null }).v ?? 0)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const NEEDED = {
|
|
222
|
+
sessions: ["id", "parent_session_id", "title", "message_count", "prompt_tokens", "completion_tokens", "cost", "updated_at", "created_at", "summary_message_id"],
|
|
223
|
+
messages: ["id", "session_id", "role", "parts", "model", "created_at", "updated_at", "finished_at", "provider", "is_summary_message"],
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export const crushWriter: Writer = {
|
|
227
|
+
tool: TOOL,
|
|
228
|
+
label: LABEL,
|
|
229
|
+
way: "native",
|
|
230
|
+
|
|
231
|
+
async ready(opts: WriteOptions): Promise<Readiness> {
|
|
232
|
+
const v = crushVersion(opts.home)
|
|
233
|
+
if (!v) return { ok: false, why: "Crush is not installed (no crush on PATH or in ~/go/bin)" }
|
|
234
|
+
if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `Crush ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
|
|
235
|
+
if (!isDir(opts.cwd)) return { ok: false, why: `the folder to continue in does not exist: ${opts.cwd}` }
|
|
236
|
+
if (isRunning("crush")) return { ok: false, why: "Crush is running; close it first, since it holds the session store open" }
|
|
237
|
+
const db = join(dataDirFor(resolve(opts.cwd), opts.home), "crush.db")
|
|
238
|
+
if (exists(db)) {
|
|
239
|
+
const applied = storeVersion(db)
|
|
240
|
+
if (applied > MIGRATIONS.at(-1)!) return { ok: false, why: `the project's Crush store was migrated by a newer Crush (migration ${applied})` }
|
|
241
|
+
}
|
|
242
|
+
return { ok: true }
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
async write(session: Session, opts: WriteOptions): Promise<WriteResult> {
|
|
246
|
+
const cwd = resolve(opts.cwd)
|
|
247
|
+
const dataDir = dataDirFor(cwd, opts.home)
|
|
248
|
+
const dbPath = join(dataDir, "crush.db")
|
|
249
|
+
const now = new Date()
|
|
250
|
+
const plan = planCrush(session, { id: randomUUID(), now })
|
|
251
|
+
const backupDir = join(opts.home, ".brain", "backups", "crush", now.toISOString().replace(/[:.]/g, "-"))
|
|
252
|
+
const written = [dbPath, projectsFile(opts.home), backupDir]
|
|
253
|
+
if (!opts.dryRun) {
|
|
254
|
+
backup(dbPath, projectsFile(opts.home), backupDir)
|
|
255
|
+
insert(dbPath, dataDir, plan, cwd)
|
|
256
|
+
register(opts.home, cwd, dataDir, now)
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
tool: TOOL,
|
|
260
|
+
way: "native",
|
|
261
|
+
sessionId: plan.id,
|
|
262
|
+
written,
|
|
263
|
+
open: `cd ${sh(cwd)} && ${sh(crushBin(opts.home).command)} --session ${plan.id}`,
|
|
264
|
+
lost: plan.lost,
|
|
265
|
+
dryRun: !!opts.dryRun,
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Copies the store (with its write-ahead log) and the projects list before anything is changed. */
|
|
271
|
+
function backup(dbPath: string, projects: string, dir: string) {
|
|
272
|
+
mkdirSync(dir, { recursive: true })
|
|
273
|
+
for (const f of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`, projects]) if (exists(f)) copyFileSync(f, join(dir, f.split("/").pop()!))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Writes the session in one transaction and reads it back before committing; a mismatch leaves the store as it was. */
|
|
277
|
+
function insert(dbPath: string, dataDir: string, plan: CrushPlan, cwd: string) {
|
|
278
|
+
const fresh = !exists(dbPath)
|
|
279
|
+
if (fresh) {
|
|
280
|
+
mkdirSync(dataDir, { recursive: true, mode: 0o700 })
|
|
281
|
+
// Crush keeps its data folder out of git the same way.
|
|
282
|
+
if (!exists(join(dataDir, ".gitignore"))) writeFileSync(join(dataDir, ".gitignore"), "*\n")
|
|
283
|
+
}
|
|
284
|
+
const db = new Database(dbPath)
|
|
285
|
+
try {
|
|
286
|
+
db.exec("PRAGMA busy_timeout = 5000")
|
|
287
|
+
db.exec("PRAGMA foreign_keys = ON")
|
|
288
|
+
if (fresh) {
|
|
289
|
+
db.exec("PRAGMA journal_mode = WAL")
|
|
290
|
+
db.exec(SCHEMA)
|
|
291
|
+
const add = db.prepare("INSERT INTO goose_db_version (version_id, is_applied) VALUES (?, 1)")
|
|
292
|
+
for (const v of MIGRATIONS) add.run(v)
|
|
293
|
+
}
|
|
294
|
+
for (const [table, cols] of Object.entries(NEEDED)) {
|
|
295
|
+
const have = new Set((db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map((c) => c.name))
|
|
296
|
+
const missing = cols.filter((c) => !have.has(c))
|
|
297
|
+
if (missing.length) throw new Error(`the Crush store at ${dbPath} has no ${table}.${missing.join(", ")}; it is not a store this writer knows`)
|
|
298
|
+
}
|
|
299
|
+
const first = plan.messages[0]?.created_at ?? Math.floor(Date.now() / 1000)
|
|
300
|
+
db.transaction(() => {
|
|
301
|
+
db.prepare(
|
|
302
|
+
"INSERT INTO sessions (id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, summary_message_id, updated_at, created_at) VALUES (?, NULL, ?, 0, 0, 0, 0, ?, ?, ?)",
|
|
303
|
+
).run(plan.id, plan.title, plan.summaryId, first, first)
|
|
304
|
+
const add = db.prepare(
|
|
305
|
+
"INSERT INTO messages (id, session_id, role, parts, model, provider, is_summary_message, created_at, updated_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
306
|
+
)
|
|
307
|
+
for (const m of plan.messages)
|
|
308
|
+
add.run(m.id, plan.id, m.role, JSON.stringify(m.parts), m.model, m.provider, m.is_summary_message, m.created_at, m.finished_at ?? m.created_at, m.finished_at)
|
|
309
|
+
// Read back as Crush's rows are read; throwing here rolls the transaction back.
|
|
310
|
+
const back = readCrush(db, plan.id, { path: dbPath, cwd })
|
|
311
|
+
const n = countTurns(back)
|
|
312
|
+
const pairs = unpaired(back)
|
|
313
|
+
if (n.user !== plan.expect.users || n.assistant !== plan.expect.assistants || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length)
|
|
314
|
+
throw new Error(
|
|
315
|
+
`the Crush session did not read back as written (users ${n.user}/${plan.expect.users}, replies ${n.assistant}/${plan.expect.assistants}, tool calls ${n.tool_call}/${plan.expect.calls}, unpaired ${pairs.calls.length + pairs.results.length}); nothing was kept`,
|
|
316
|
+
)
|
|
317
|
+
})()
|
|
318
|
+
// Leave the store as Crush leaves it when it exits: everything in the main file.
|
|
319
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
320
|
+
} finally {
|
|
321
|
+
db.close()
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Puts the project first in projects.json, as projects.Register does whenever Crush starts in a folder. */
|
|
326
|
+
function register(home: string, cwd: string, dataDir: string, now: Date) {
|
|
327
|
+
const file = projectsFile(home)
|
|
328
|
+
const list = readJson<{ projects?: Array<Record<string, unknown>> }>(file) ?? {}
|
|
329
|
+
const projects = (Array.isArray(list.projects) ? list.projects : []).filter((p) => p.path !== cwd)
|
|
330
|
+
projects.unshift({ path: cwd, data_dir: dataDir, last_accessed: now.toISOString() })
|
|
331
|
+
mkdirSync(join(file, ".."), { recursive: true, mode: 0o700 })
|
|
332
|
+
writeFileSync(file, JSON.stringify({ ...list, projects }, null, 2), { mode: 0o600 })
|
|
333
|
+
}
|