@taicho-ai/framework 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 (75) hide show
  1. package/README.md +4 -0
  2. package/package.json +48 -0
  3. package/src/coaching/patterns.ts +103 -0
  4. package/src/coaching/proposal.ts +29 -0
  5. package/src/coaching/retrieval.ts +27 -0
  6. package/src/coaching/supersede.ts +14 -0
  7. package/src/coaching/teach.ts +74 -0
  8. package/src/core/agent-status.ts +79 -0
  9. package/src/core/auth/constants.ts +53 -0
  10. package/src/core/auth/login.ts +110 -0
  11. package/src/core/auth/pkce.ts +18 -0
  12. package/src/core/auth/profile.ts +38 -0
  13. package/src/core/auth/refresh.ts +57 -0
  14. package/src/core/auth/status.ts +26 -0
  15. package/src/core/command-guard.ts +126 -0
  16. package/src/core/conversation-artifacts.ts +40 -0
  17. package/src/core/conversation-replay.ts +160 -0
  18. package/src/core/costs.ts +97 -0
  19. package/src/core/discovery.ts +22 -0
  20. package/src/core/draft.ts +6 -0
  21. package/src/core/e2e-model.ts +315 -0
  22. package/src/core/embed.ts +221 -0
  23. package/src/core/events.ts +133 -0
  24. package/src/core/firecrawl.ts +25 -0
  25. package/src/core/headless.ts +260 -0
  26. package/src/core/instrument.ts +88 -0
  27. package/src/core/logger.ts +143 -0
  28. package/src/core/mcp/adapter.ts +34 -0
  29. package/src/core/mcp/manager.ts +145 -0
  30. package/src/core/mcp/oauth.ts +119 -0
  31. package/src/core/memory.ts +13 -0
  32. package/src/core/mock-model.ts +70 -0
  33. package/src/core/model.ts +91 -0
  34. package/src/core/pricing.ts +27 -0
  35. package/src/core/providers/openai-codex.ts +67 -0
  36. package/src/core/registry.ts +67 -0
  37. package/src/core/run.ts +909 -0
  38. package/src/core/schedule-cli.ts +55 -0
  39. package/src/core/scheduler.ts +356 -0
  40. package/src/core/tasks.ts +108 -0
  41. package/src/core/team-cli.ts +118 -0
  42. package/src/core/team-routing.ts +58 -0
  43. package/src/core/tools.ts +1104 -0
  44. package/src/core/turn-audit.ts +100 -0
  45. package/src/core/verification.ts +102 -0
  46. package/src/core/workflow-run.ts +119 -0
  47. package/src/index.ts +8 -0
  48. package/src/knowledge/retrieval.ts +73 -0
  49. package/src/knowledge/sync.ts +28 -0
  50. package/src/skills/retrieval.ts +22 -0
  51. package/src/store/annotations.ts +107 -0
  52. package/src/store/artifacts.ts +338 -0
  53. package/src/store/config.ts +187 -0
  54. package/src/store/conversation.ts +107 -0
  55. package/src/store/db.ts +34 -0
  56. package/src/store/files.ts +51 -0
  57. package/src/store/knowledge.ts +201 -0
  58. package/src/store/mcp-store.ts +65 -0
  59. package/src/store/migrate.ts +278 -0
  60. package/src/store/plans.ts +260 -0
  61. package/src/store/policy.ts +66 -0
  62. package/src/store/prefs.ts +64 -0
  63. package/src/store/roster.ts +308 -0
  64. package/src/store/run-transcript.ts +103 -0
  65. package/src/store/schedules.ts +94 -0
  66. package/src/store/seed-skills.ts +75 -0
  67. package/src/store/skills.ts +89 -0
  68. package/src/store/sources.ts +58 -0
  69. package/src/store/spend-ledger.ts +114 -0
  70. package/src/store/task-state.ts +267 -0
  71. package/src/store/teams.ts +227 -0
  72. package/src/store/thread.ts +72 -0
  73. package/src/store/trace.ts +98 -0
  74. package/src/store/vectors.ts +26 -0
  75. package/src/store/workflows.ts +144 -0
@@ -0,0 +1,51 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ /** Workspace file canon. Files are the source of truth; the DB is a cache. */
5
+ export const paths = {
6
+ agentDir: (ws: string, id: string) => join(ws, "agents", id),
7
+ agentFile: (ws: string, id: string) => join(ws, "agents", id, "agent.md"),
8
+ teamsDir: (ws: string) => join(ws, "teams"),
9
+ teamDir: (ws: string, id: string) => join(ws, "teams", id),
10
+ teamFile: (ws: string, id: string) => join(ws, "teams", id, "team.md"),
11
+ teamWorkflowFile: (ws: string, id: string) => join(ws, "teams", id, "workflow.md"),
12
+ policyDir: (ws: string, id: string) => join(ws, "agents", id, "policies"),
13
+ exemplarDir: (ws: string, id: string) => join(ws, "agents", id, "exemplars"),
14
+ artifactDir: (ws: string) => join(ws, "artifacts"),
15
+ plansDir: (ws: string) => join(ws, "plans"),
16
+ runDir: (ws: string, id: string) => join(ws, "runs", id),
17
+ runRecordDir: (ws: string, runId: string) => {
18
+ const i = runId.indexOf("/");
19
+ if (i < 0) throw new Error("bad run id: " + runId);
20
+ return join(ws, "runs", runId.slice(0, i), runId.slice(i + 1));
21
+ },
22
+ conversationDir: (ws: string, id: string) => join(ws, "conversations", id),
23
+ taskDir: (ws: string) => join(ws, "tasks"),
24
+ scheduleDir: (ws: string) => join(ws, "schedules"),
25
+ // Plan 25: a workflow's DEFINITION lives in teams/<team>/workflow.md; per-RUN state is here.
26
+ workflowsDir: (ws: string) => join(ws, "workflows"),
27
+ workflowRunsDir: (ws: string, id: string) => join(ws, "workflows", id, "runs"),
28
+ workflowRunDir: (ws: string, id: string, runId: string) => join(ws, "workflows", id, "runs", runId),
29
+ kbNodeDir: (ws: string) => join(ws, "kb", "nodes"),
30
+ kbNodeFile: (ws: string, id: string) => join(ws, "kb", "nodes", `${id}.md`),
31
+ kbSourceDir: (ws: string) => join(ws, "kb", "sources"),
32
+ kbSourceFile: (ws: string, name: string) => join(ws, "kb", "sources", name),
33
+ skillsDir: (ws: string) => join(ws, "skills"),
34
+ skillFile: (ws: string, id: string) => join(ws, "skills", `${id}.md`),
35
+ inputHistoryFile: (ws: string) => join(ws, ".taicho-input-history"), // Plan 24: REPL message history
36
+ };
37
+
38
+ export async function ensureWorkspace(ws: string) {
39
+ await mkdir(join(ws, "agents"), { recursive: true });
40
+ await mkdir(join(ws, "teams"), { recursive: true });
41
+ await mkdir(join(ws, "artifacts"), { recursive: true });
42
+ await mkdir(join(ws, "plans"), { recursive: true });
43
+ await mkdir(join(ws, "runs"), { recursive: true });
44
+ await mkdir(join(ws, "conversations"), { recursive: true });
45
+ await mkdir(join(ws, "tasks"), { recursive: true });
46
+ await mkdir(join(ws, "schedules"), { recursive: true });
47
+ await mkdir(join(ws, "workflows"), { recursive: true });
48
+ await mkdir(join(ws, "kb", "nodes"), { recursive: true });
49
+ await mkdir(join(ws, "kb", "sources"), { recursive: true });
50
+ await mkdir(join(ws, "skills"), { recursive: true });
51
+ }
@@ -0,0 +1,201 @@
1
+ /** Squad knowledgebase store: one file per node at kb/nodes/<kb_id>.md (YAML frontmatter = the node
2
+ * minus `content`, body = content). Files are canon; kb_nodes/kb_edges in SQLite are a rebuildable
3
+ * index. Mirrors store/policy.ts + store/roster.ts. */
4
+ import { YAML } from "bun";
5
+ import type { Database } from "bun:sqlite";
6
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { KbNode } from "@taicho-ai/contracts/knowledge";
9
+ import { paths } from "./files";
10
+ import { putVector } from "./vectors";
11
+ import { log } from "../core/logger";
12
+
13
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
14
+
15
+ export function mkKbId(): string {
16
+ return "kb_" + Math.random().toString(36).slice(2, 10);
17
+ }
18
+
19
+ export function serializeNode(n: KbNode): string {
20
+ const { content, ...meta } = n;
21
+ return `---\n${YAML.stringify(meta, null, 2)}\n---\n${content}\n`;
22
+ }
23
+
24
+ export function parseNode(text: string): KbNode {
25
+ const m = FRONTMATTER.exec(text);
26
+ if (!m) throw new Error("knowledge node missing YAML frontmatter");
27
+ const meta = YAML.parse(m[1]) as Record<string, unknown>;
28
+ return KbNode.parse({ ...meta, content: m[2].trim() });
29
+ }
30
+
31
+ /** Write a node's canonical file, then (re)index it into kb_nodes/kb_edges. */
32
+ export function writeNode(ws: string, db: Database, node: KbNode): void {
33
+ mkdirSync(paths.kbNodeDir(ws), { recursive: true });
34
+ writeFileSync(paths.kbNodeFile(ws, node.id), serializeNode(node));
35
+ indexNode(db, node);
36
+ }
37
+
38
+ export function readNode(ws: string, id: string): KbNode | null {
39
+ const f = paths.kbNodeFile(ws, id);
40
+ if (!existsSync(f)) return null;
41
+ try { return parseNode(readFileSync(f, "utf8")); } catch { return null; }
42
+ }
43
+
44
+ export function listNodes(ws: string): KbNode[] {
45
+ const dir = paths.kbNodeDir(ws);
46
+ if (!existsSync(dir)) return [];
47
+ const out: KbNode[] = [];
48
+ for (const f of readdirSync(dir)) {
49
+ if (!f.endsWith(".md")) continue;
50
+ try { out.push(parseNode(readFileSync(join(dir, f), "utf8"))); }
51
+ catch (e) { log.warn(`skipping kb node ${f}`, e); }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ export function nodeExists(db: Database, id: string): boolean {
57
+ return !!db.query("SELECT 1 FROM kb_nodes WHERE id = ?").get(id);
58
+ }
59
+
60
+ /** Upsert a node row and replace its outgoing edges. */
61
+ export function indexNode(db: Database, n: KbNode): void {
62
+ db.query(
63
+ `INSERT INTO kb_nodes (id, kind, title, summary, content, source, scope, metadata, updated)
64
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, unixepoch())
65
+ ON CONFLICT(id) DO UPDATE SET
66
+ kind=excluded.kind, title=excluded.title, summary=excluded.summary, content=excluded.content,
67
+ source=excluded.source, scope=excluded.scope, metadata=excluded.metadata, updated=unixepoch()`,
68
+ ).run(n.id, n.kind, n.title, n.summary ?? null, n.content, n.source ?? null, n.scope, n.metadata ? JSON.stringify(n.metadata) : null);
69
+ db.query("DELETE FROM kb_edges WHERE from_id = ?").run(n.id);
70
+ const ins = db.query("INSERT OR IGNORE INTO kb_edges (from_id, to_id, rel, weight, metadata) VALUES (?, ?, ?, ?, ?)");
71
+ for (const e of n.edges) ins.run(n.id, e.to, e.rel, e.weight, e.metadata ? JSON.stringify(e.metadata) : null);
72
+ }
73
+
74
+ export interface KbRow { id: string; kind: string; title: string; summary: string | null; content: string; }
75
+
76
+ export function getNodes(db: Database, ids: string[]): Map<string, KbRow> {
77
+ const out = new Map<string, KbRow>();
78
+ if (!ids.length) return out;
79
+ const placeholders = ids.map(() => "?").join(",");
80
+ for (const r of db.query(`SELECT id, kind, title, summary, content FROM kb_nodes WHERE id IN (${placeholders})`).all(...ids) as KbRow[]) {
81
+ out.set(r.id, r);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** N-hop neighbors of a seed set, walking edges in BOTH directions, keeping each node's min depth.
87
+ * Optionally restricted to specific edge relations. Excludes the seeds themselves. */
88
+ export function neighbors(db: Database, seedIds: string[], hops: number, rels?: string[]): { id: string; depth: number }[] {
89
+ if (seedIds.length === 0 || hops <= 0) return [];
90
+ const seedJson = JSON.stringify(seedIds);
91
+ const relFilter = rels && rels.length ? `AND e.rel IN (${rels.map(() => "?").join(",")})` : "";
92
+ const sql = `
93
+ WITH RECURSIVE reach(id, depth) AS (
94
+ SELECT value, 0 FROM json_each(?)
95
+ UNION
96
+ SELECT CASE WHEN e.from_id = r.id THEN e.to_id ELSE e.from_id END, r.depth + 1
97
+ FROM reach r JOIN kb_edges e ON (e.from_id = r.id OR e.to_id = r.id)
98
+ WHERE r.depth < ? ${relFilter}
99
+ )
100
+ SELECT id, MIN(depth) AS depth FROM reach
101
+ WHERE id NOT IN (SELECT value FROM json_each(?))
102
+ GROUP BY id`;
103
+ const params = [seedJson, hops, ...(rels && rels.length ? rels : []), seedJson];
104
+ return db.query(sql).all(...params) as { id: string; depth: number }[];
105
+ }
106
+
107
+ /** Plan 19 Ph1b: rewrite any node file still carrying the legacy `scope: deck` frontmatter. `parseNode`
108
+ * already normalizes the value on read (schemas/knowledge.ts KbScope), so this just re-serializes what
109
+ * it parsed — the files are canon and must eventually say what they mean. Idempotent, and a no-op on a
110
+ * workspace that has never seen a pre-Plan-19 taicho. Runs at boot, before reindexKnowledge. */
111
+ export function reconcileKbScope(ws: string, db: Database): number {
112
+ const dir = paths.kbNodeDir(ws);
113
+ if (!existsSync(dir)) return 0;
114
+ let migrated = 0;
115
+ for (const f of readdirSync(dir)) {
116
+ if (!f.endsWith(".md")) continue;
117
+ const file = join(dir, f);
118
+ let raw: string;
119
+ try { raw = readFileSync(file, "utf8"); } catch { continue; }
120
+ if (!/^scope:\s*["']?deck["']?\s*$/m.test(raw)) continue;
121
+ try { writeNode(ws, db, parseNode(raw)); migrated++; }
122
+ catch (e) { log.warn(`kb scope backfill skipped ${f}`, e); }
123
+ }
124
+ if (migrated) log.info(`kb: rewrote ${migrated} node file(s) from scope 'deck' to 'squad'`);
125
+ return migrated;
126
+ }
127
+
128
+ /** Rebuild the kb_nodes/kb_edges index from the canonical files (proves files-are-canon). */
129
+ export function reindexKnowledge(ws: string, db: Database): void {
130
+ db.exec("DELETE FROM kb_edges; DELETE FROM kb_nodes;");
131
+ for (const n of listNodes(ws)) indexNode(db, n);
132
+ }
133
+
134
+ export interface NodeFilter { ids?: string[]; kind?: string; sourcePrefix?: string }
135
+
136
+ /** Build the WHERE clause + params for a NodeFilter, shared by the destructive (resolveNodeIds) and
137
+ * list (listNodeRows) paths. Combine clauses with AND. */
138
+ function filterClause(filter: NodeFilter): { where: string; params: (string | number)[] } {
139
+ const parts: string[] = [];
140
+ const params: (string | number)[] = [];
141
+ if (filter.ids?.length) { parts.push(`id IN (${filter.ids.map(() => "?").join(",")})`); params.push(...filter.ids); }
142
+ if (filter.kind) { parts.push("kind = ?"); params.push(filter.kind); }
143
+ if (filter.sourcePrefix) { parts.push("source LIKE ? ESCAPE '\\'"); params.push(likePrefix(filter.sourcePrefix)); }
144
+ return { where: parts.length ? `WHERE ${parts.join(" AND ")}` : "", params };
145
+ }
146
+
147
+ /** Node ids matching a filter. An EMPTY filter matches nothing (never "everything") — a safety
148
+ * guard so a mis-built prune can't wipe the whole graph. Combine clauses with AND. */
149
+ export function resolveNodeIds(db: Database, filter: NodeFilter): string[] {
150
+ const c = filterClause(filter);
151
+ if (!c.where) return []; // empty filter matches NOTHING — the destructive-path safety guard
152
+ return (db.query(`SELECT id FROM kb_nodes ${c.where}`).all(...c.params) as { id: string }[]).map((r) => r.id);
153
+ }
154
+
155
+ export interface NodeRow { id: string; kind: string; title: string; source: string | null }
156
+
157
+ /** List semantics: an EMPTY filter lists ALL nodes — deliberately different from resolveNodeIds,
158
+ * which is the destructive path and matches nothing on empty. */
159
+ export function listNodeRows(db: Database, filter: NodeFilter): NodeRow[] {
160
+ const c = filterClause(filter);
161
+ return db.query(`SELECT id, kind, title, source FROM kb_nodes ${c.where} ORDER BY updated DESC, id`).all(...c.params) as NodeRow[];
162
+ }
163
+
164
+ /** Escape LIKE wildcards in a literal prefix, then append `%`. */
165
+ function likePrefix(prefix: string): string {
166
+ return prefix.replace(/[\\%_]/g, (c) => "\\" + c) + "%";
167
+ }
168
+
169
+ /** Cascade delete: for the matched nodes, remove their edges (both directions), vectors, node rows,
170
+ * and canonical files — atomically. The single prune path for /kb forget and source re-sync. */
171
+ export function forgetNodes(ws: string, db: Database, filter: NodeFilter): { removedNodes: number; removedEdges: number } {
172
+ const ids = resolveNodeIds(db, filter);
173
+ if (!ids.length) return { removedNodes: 0, removedEdges: 0 };
174
+ const ph = ids.map(() => "?").join(",");
175
+ // Files are canon: delete them FIRST so a crash can't leave a file that boot-reindex resurrects.
176
+ for (const id of ids) {
177
+ try { rmSync(paths.kbNodeFile(ws, id)); }
178
+ catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; }
179
+ }
180
+ let removedEdges = 0;
181
+ db.transaction(() => {
182
+ removedEdges = (db.query(`SELECT COUNT(*) c FROM kb_edges WHERE from_id IN (${ph}) OR to_id IN (${ph})`).get(...ids, ...ids) as { c: number }).c;
183
+ db.query(`DELETE FROM kb_edges WHERE from_id IN (${ph}) OR to_id IN (${ph})`).run(...ids, ...ids);
184
+ db.query(`DELETE FROM embeddings WHERE kind = 'kb' AND ref IN (${ph})`).run(...ids);
185
+ db.query(`DELETE FROM kb_nodes WHERE id IN (${ph})`).run(...ids);
186
+ })();
187
+ return { removedNodes: ids.length, removedEdges };
188
+ }
189
+
190
+ /** (Re)compute a vector for every kb_node from its title/summary/content. Used by /kb reindex to
191
+ * refresh semantic vectors after hand-edits or a blown-away embeddings table. Best-effort per node:
192
+ * one failure doesn't abort the pass. */
193
+ export async function reembedAll(db: Database, embed: (t: string) => Promise<Float32Array>): Promise<number> {
194
+ const rows = db.query("SELECT id, title, summary, content FROM kb_nodes").all() as KbRow[];
195
+ let n = 0;
196
+ for (const r of rows) {
197
+ try { putVector(db, r.id, "kb", await embed(`${r.title}\n${r.summary ?? ""}\n${r.content}`)); n++; }
198
+ catch (e) { log.error(`reembed ${r.id} failed`, e); }
199
+ }
200
+ return n;
201
+ }
@@ -0,0 +1,65 @@
1
+ /** Writable store of MCP servers added at runtime via `/mcp add`, per workspace at
2
+ * <ws>/agents/.mcp/servers.json (under the gitignored agents/ dir). taicho.yaml `mcp.servers`
3
+ * is read-only canon; this store layers on top — the effective set is yaml ∪ store. */
4
+ import { z } from "zod";
5
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { McpServerConfig } from "./config";
8
+ import { log } from "../core/logger";
9
+
10
+ const StoreSchema = z.record(z.string(), McpServerConfig);
11
+ export type McpStore = z.infer<typeof StoreSchema>;
12
+
13
+ function storeDir(ws: string): string { return join(ws, "agents", ".mcp"); }
14
+ function storePath(ws: string): string { return join(storeDir(ws), "servers.json"); }
15
+
16
+ export function readMcpStore(ws: string): McpStore {
17
+ const f = storePath(ws);
18
+ if (!existsSync(f)) return {};
19
+ let raw: unknown;
20
+ try { raw = JSON.parse(readFileSync(f, "utf8")); } catch { return {}; }
21
+ if (typeof raw !== "object" || raw === null) return {};
22
+ // Validate per entry so one malformed server doesn't silently wipe the whole store.
23
+ const out: McpStore = {};
24
+ for (const [name, spec] of Object.entries(raw as Record<string, unknown>)) {
25
+ const parsed = McpServerConfig.safeParse(spec);
26
+ if (parsed.success) out[name] = parsed.data;
27
+ else log.warn(`skipping invalid MCP server "${name}" in mcp-store`);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ function write(ws: string, all: McpStore): void {
33
+ mkdirSync(storeDir(ws), { recursive: true });
34
+ writeFileSync(storePath(ws), JSON.stringify(all, null, 2));
35
+ }
36
+
37
+ export function addMcpServer(ws: string, name: string, spec: McpServerConfig): void {
38
+ const all = readMcpStore(ws);
39
+ all[name] = spec;
40
+ write(ws, all);
41
+ }
42
+
43
+ export function removeMcpServer(ws: string, name: string): boolean {
44
+ const all = readMcpStore(ws);
45
+ if (!(name in all)) return false;
46
+ delete all[name];
47
+ write(ws, all);
48
+ return true;
49
+ }
50
+
51
+ /** Load every stored server's `env` into `process.env`, so a `${VAR}` ref in a url/header/arg resolves
52
+ * (via `interpolateEnv`). Called on boot BEFORE the manager connects, and again right after a server is
53
+ * added so it connects in the SAME session — the env travels WITH the server entry, no separate secrets
54
+ * file. Idempotent (re-applying the same values is a no-op). Returns the variable NAMES applied; the
55
+ * VALUES are secrets and must never be logged. */
56
+ export function applyMcpEnv(ws: string): string[] {
57
+ const applied: string[] = [];
58
+ for (const spec of Object.values(readMcpStore(ws))) {
59
+ for (const [name, value] of Object.entries(spec.env ?? {})) {
60
+ process.env[name] = value;
61
+ applied.push(name);
62
+ }
63
+ }
64
+ return applied;
65
+ }
@@ -0,0 +1,278 @@
1
+ /** Minimal versioned schema migrator for the derived SQLite cache. The DB holds derived state only
2
+ * (files are canon — deleting the DB and re-indexing must always work), so migrations just create
3
+ * tables/indexes; a `meta` row tracks the applied version. Run once in openDb after the baseline. */
4
+ import type { Database } from "bun:sqlite";
5
+
6
+ export function getMeta(db: Database, key: string): string | null {
7
+ const r = db.query("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | null;
8
+ return r?.value ?? null;
9
+ }
10
+
11
+ export function setMeta(db: Database, key: string, value: string): void {
12
+ db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
13
+ }
14
+
15
+ interface Migration { version: number; up: (db: Database) => void; }
16
+
17
+ const MIGRATIONS: Migration[] = [
18
+ // v1: baseline — registry/embeddings/task_ledger already created by openDb's `CREATE TABLE IF NOT EXISTS`.
19
+ { version: 1, up: () => {} },
20
+ // v2: knowledgebase graph — typed nodes + typed edges. Embeddings reuse the existing table (kind='kb').
21
+ {
22
+ version: 2,
23
+ up: (db) =>
24
+ db.exec(`
25
+ CREATE TABLE IF NOT EXISTS kb_nodes (
26
+ id TEXT PRIMARY KEY,
27
+ kind TEXT NOT NULL,
28
+ title TEXT NOT NULL,
29
+ summary TEXT,
30
+ content TEXT NOT NULL,
31
+ source TEXT,
32
+ scope TEXT NOT NULL DEFAULT 'deck',
33
+ metadata TEXT,
34
+ created INTEGER DEFAULT (unixepoch()),
35
+ updated INTEGER DEFAULT (unixepoch())
36
+ );
37
+ CREATE TABLE IF NOT EXISTS kb_edges (
38
+ from_id TEXT NOT NULL,
39
+ to_id TEXT NOT NULL,
40
+ rel TEXT NOT NULL,
41
+ weight REAL DEFAULT 1.0,
42
+ metadata TEXT,
43
+ created INTEGER DEFAULT (unixepoch()),
44
+ PRIMARY KEY (from_id, to_id, rel)
45
+ );
46
+ CREATE INDEX IF NOT EXISTS kb_edges_from ON kb_edges(from_id);
47
+ CREATE INDEX IF NOT EXISTS kb_edges_to ON kb_edges(to_id);
48
+ CREATE INDEX IF NOT EXISTS kb_nodes_kind ON kb_nodes(kind);
49
+ `),
50
+ },
51
+ // v3: source-document tracking — one row per file in kb/sources/, holds its last-synced hash.
52
+ {
53
+ version: 3,
54
+ up: (db) =>
55
+ db.exec(`
56
+ CREATE TABLE IF NOT EXISTS kb_sources (
57
+ path TEXT PRIMARY KEY, -- relative, e.g. "sources/architecture.md"
58
+ hash TEXT NOT NULL, -- content hash last synced
59
+ updated INTEGER DEFAULT (unixepoch())
60
+ );
61
+ `),
62
+ },
63
+ // v4: agent skills — reusable procedure documents. Files (skills/*.md) are canon; this is the index.
64
+ {
65
+ version: 4,
66
+ up: (db) =>
67
+ db.exec(`
68
+ CREATE TABLE IF NOT EXISTS skills (
69
+ id TEXT PRIMARY KEY,
70
+ name TEXT NOT NULL,
71
+ description TEXT NOT NULL,
72
+ tags TEXT,
73
+ status TEXT NOT NULL DEFAULT 'active',
74
+ body TEXT NOT NULL,
75
+ updated INTEGER DEFAULT (unixepoch())
76
+ );
77
+ CREATE INDEX IF NOT EXISTS skills_status ON skills(status);
78
+ `),
79
+ },
80
+ // v5: task queue index (Plan 04). Files (tasks/*.json) are canon; this is a rebuildable index so
81
+ // `/tasks` can list/query fast and background tasks survive restarts. reindexTasks() rebuilds it.
82
+ {
83
+ version: 5,
84
+ up: (db) =>
85
+ db.exec(`
86
+ CREATE TABLE IF NOT EXISTS tasks (
87
+ id TEXT PRIMARY KEY,
88
+ agent TEXT,
89
+ goal TEXT,
90
+ status TEXT NOT NULL,
91
+ kind TEXT NOT NULL DEFAULT 'chat', -- 'chat' (a watched turn) | 'background' (dispatched)
92
+ root_run_id TEXT,
93
+ result_ref TEXT,
94
+ summary TEXT,
95
+ created TEXT,
96
+ updated TEXT
97
+ );
98
+ CREATE INDEX IF NOT EXISTS tasks_status ON tasks(status);
99
+ CREATE INDEX IF NOT EXISTS tasks_kind ON tasks(kind);
100
+ `),
101
+ },
102
+ // v6: deck spend counters (Plan 09). Rolling per-period totals keyed by UTC day / ISO week so
103
+ // deck-wide budget ceilings span sessions. An enforcement counter, not a ledger of record — traces
104
+ // stay canon for /costs reporting; this just answers "how much has the deck spent this day/week".
105
+ // (v7 renames this table to squad_spend. Migrations are history — this DDL stays as it shipped.)
106
+ {
107
+ version: 6,
108
+ up: (db) =>
109
+ db.exec(`
110
+ CREATE TABLE IF NOT EXISTS deck_spend (
111
+ period_kind TEXT NOT NULL, -- 'day' | 'week'
112
+ period_key TEXT NOT NULL, -- 'YYYY-MM-DD' | 'YYYY-Www'
113
+ tokens INTEGER NOT NULL DEFAULT 0,
114
+ cost_usd REAL NOT NULL DEFAULT 0, -- priced runs only; subscription/unpriced commit 0
115
+ updated INTEGER DEFAULT (unixepoch()),
116
+ PRIMARY KEY (period_kind, period_key)
117
+ );
118
+ `),
119
+ },
120
+ // v7: Plan 19 — "deck" retires in favour of "squad" (every agent in the workspace; taicho is 隊長,
121
+ // squad leader). Unlike the DeckLedger→SpendLedger rename this touches PERSISTED values, so it is a
122
+ // real migration, not a find-and-replace:
123
+ // · kb_nodes.scope rows 'deck' → 'squad'. The column DEFAULT stays 'deck' because SQLite cannot
124
+ // alter a default without rebuilding the table — and it is inert, since store/knowledge.ts always
125
+ // supplies scope explicitly. Node FILES are canon; reconcileKbScope() rewrites their frontmatter
126
+ // at boot and reindexKnowledge() then refreshes these rows anyway. This UPDATE covers the window
127
+ // before that runs, and any DB whose canonical files were removed.
128
+ // · deck_spend → squad_spend. Rows carry forward: silently resetting a user's running weekly total
129
+ // would be rude, and the counter is cheap to move.
130
+ {
131
+ version: 7,
132
+ up: (db) => {
133
+ db.exec("UPDATE kb_nodes SET scope = 'squad' WHERE scope = 'deck'");
134
+ const legacy = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'deck_spend'").get();
135
+ if (legacy) db.exec("ALTER TABLE deck_spend RENAME TO squad_spend");
136
+ else
137
+ db.exec(`
138
+ CREATE TABLE IF NOT EXISTS squad_spend (
139
+ period_kind TEXT NOT NULL,
140
+ period_key TEXT NOT NULL,
141
+ tokens INTEGER NOT NULL DEFAULT 0,
142
+ cost_usd REAL NOT NULL DEFAULT 0,
143
+ updated INTEGER DEFAULT (unixepoch()),
144
+ PRIMARY KEY (period_kind, period_key)
145
+ );
146
+ `);
147
+ },
148
+ },
149
+ // v8: Plan 19 — team membership on the registry index. `registry` is created by openDb's BASELINE
150
+ // `CREATE TABLE IF NOT EXISTS` (db.ts), not by a migration, and IF NOT EXISTS cannot add a column to a
151
+ // table that already exists. So the column is declared in BOTH places and both are idempotent:
152
+ // · baseline db.ts — gives a FRESH workspace the column at creation
153
+ // · this ALTER — gives every EXISTING workspace the column on upgrade
154
+ // Either one alone is a bug (baseline alone silently skips existing DBs; ALTER alone throws
155
+ // "duplicate column" on fresh ones), which is why the guard below is a check and not a try/catch.
156
+ // Membership is canon in agents/<id>/agent.md (`team:`); this column is the derived index that makes
157
+ // "who is on team news" one query instead of a full roster scan.
158
+ {
159
+ version: 8,
160
+ up: (db) => {
161
+ // migrate() also runs standalone over a bare Database in unit tests, where the baseline never ran.
162
+ if (!tableExists(db, "registry")) return;
163
+ if (!columnExists(db, "registry", "team")) db.exec("ALTER TABLE registry ADD COLUMN team TEXT");
164
+ db.exec("CREATE INDEX IF NOT EXISTS registry_team ON registry(team)");
165
+ },
166
+ },
167
+ // v9: Plan 19 — per-team spend ceilings. The counter gains a `scope` dimension ('squad' | 'team:<id>')
168
+ // and it belongs in the primary key: a team's daily total and the squad's must accumulate separately.
169
+ // SQLite cannot alter a primary key, so the table is recreated and its rows copied forward as 'squad'
170
+ // spend. It is only a counter, not a ledger of record (traces stay canon for /costs) — but silently
171
+ // resetting someone's running weekly total would be rude, so we carry it.
172
+ {
173
+ version: 9,
174
+ up: (db) => {
175
+ if (!tableExists(db, "squad_spend")) return; // bare migrate() over a Database with no baseline
176
+ if (columnExists(db, "squad_spend", "scope")) return; // already reshaped
177
+ db.exec(`
178
+ CREATE TABLE squad_spend_v9 (
179
+ scope TEXT NOT NULL, -- 'squad' | 'team:<id>'
180
+ period_kind TEXT NOT NULL, -- 'day' | 'week'
181
+ period_key TEXT NOT NULL, -- 'YYYY-MM-DD' | 'YYYY-Www'
182
+ tokens INTEGER NOT NULL DEFAULT 0,
183
+ cost_usd REAL NOT NULL DEFAULT 0, -- priced runs only; subscription/unpriced commit 0
184
+ updated INTEGER DEFAULT (unixepoch()),
185
+ PRIMARY KEY (scope, period_kind, period_key)
186
+ );
187
+ INSERT INTO squad_spend_v9 (scope, period_kind, period_key, tokens, cost_usd, updated)
188
+ SELECT 'squad', period_kind, period_key, tokens, cost_usd, updated FROM squad_spend;
189
+ DROP TABLE squad_spend;
190
+ ALTER TABLE squad_spend_v9 RENAME TO squad_spend;
191
+ `);
192
+ },
193
+ },
194
+ // v10: Plan 18 — the plan index. Files are canon (plans/<id>/v<N>.json + events.jsonl); this table
195
+ // stores only the FOLDED counters, so the panel and /plan never walk the event log to answer "how many
196
+ // are open". reindexPlans() rebuilds it from the files, which is what makes the DB throwaway.
197
+ {
198
+ version: 10,
199
+ up: (db) =>
200
+ db.exec(`
201
+ CREATE TABLE IF NOT EXISTS plans (
202
+ id TEXT PRIMARY KEY,
203
+ version INTEGER NOT NULL,
204
+ owner TEXT NOT NULL,
205
+ goal TEXT,
206
+ total INTEGER NOT NULL DEFAULT 0,
207
+ done INTEGER NOT NULL DEFAULT 0,
208
+ open INTEGER NOT NULL DEFAULT 0,
209
+ failed INTEGER NOT NULL DEFAULT 0,
210
+ root_run_id TEXT,
211
+ created TEXT,
212
+ updated TEXT
213
+ );
214
+ CREATE INDEX IF NOT EXISTS plans_owner ON plans(owner);
215
+ CREATE INDEX IF NOT EXISTS plans_open ON plans(open);
216
+ `),
217
+ },
218
+ // v11: Plan 22 — many-to-many team membership. An agent may sit on several teams, so the single
219
+ // `registry.team` column can no longer hold membership. `agent_teams` is the join (agent × team), and
220
+ // — unlike v8's `team` COLUMN, which needed a baseline + a guarded ALTER because IF NOT EXISTS can't
221
+ // add a column — a NEW TABLE is created idempotently by the migration alone, for fresh and existing
222
+ // DBs both. Membership is canon in agents/<id>/agent.md (`teams:`); this is the derived index, and
223
+ // reindex() rebuilds it every boot (adding the implicit `default` membership). The one-time carry
224
+ // below only bridges the window before that first reindex (and unit tests that migrate but never
225
+ // reindex): it lifts any pre-Plan-22 single-team rows into the join so nothing is lost.
226
+ {
227
+ version: 11,
228
+ up: (db) => {
229
+ db.exec(`
230
+ CREATE TABLE IF NOT EXISTS agent_teams (
231
+ agent_id TEXT NOT NULL,
232
+ team_id TEXT NOT NULL,
233
+ ord INTEGER NOT NULL DEFAULT 0, -- declaration order, for deterministic model resolution
234
+ PRIMARY KEY (agent_id, team_id)
235
+ );
236
+ CREATE INDEX IF NOT EXISTS agent_teams_team ON agent_teams(team_id);
237
+ `);
238
+ if (!tableExists(db, "registry") || !columnExists(db, "registry", "team")) return;
239
+ const rows = db.query("SELECT id, team FROM registry WHERE team IS NOT NULL AND team <> ''").all() as { id: string; team: string }[];
240
+ const ins = db.query("INSERT OR IGNORE INTO agent_teams (agent_id, team_id, ord) VALUES (?, ?, 0)");
241
+ for (const r of rows) ins.run(r.id, r.team);
242
+ },
243
+ },
244
+ ];
245
+
246
+ function tableExists(db: Database, table: string): boolean {
247
+ return !!db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
248
+ }
249
+
250
+ function columnExists(db: Database, table: string, column: string): boolean {
251
+ return (db.query(`PRAGMA table_info(${table})`).all() as { name: string }[]).some((c) => c.name === column);
252
+ }
253
+
254
+ export const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]!.version;
255
+
256
+ export function migrate(db: Database): void {
257
+ db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)");
258
+ const cur = Number(getMeta(db, "schema_version") ?? "0");
259
+ for (const m of MIGRATIONS) {
260
+ if (m.version > cur) {
261
+ db.transaction(() => m.up(db))();
262
+ setMeta(db, "schema_version", String(m.version));
263
+ }
264
+ }
265
+ }
266
+
267
+ /** Guard against mixing embedding models/dimensions: if the active embedder changed, wipe stale kb
268
+ * vectors so cosine never compares incompatible spaces. Safe — embeddings are a derived cache that
269
+ * re-fills lazily on the next remember/recall. */
270
+ export function ensureEmbedSpace(db: Database, model: string, dim: number): void {
271
+ const pm = getMeta(db, "embed_model");
272
+ const pd = Number(getMeta(db, "embed_dim") ?? "0");
273
+ if (pm !== model || pd !== dim) {
274
+ db.query("DELETE FROM embeddings WHERE kind = 'kb'").run();
275
+ setMeta(db, "embed_model", model);
276
+ setMeta(db, "embed_dim", String(dim));
277
+ }
278
+ }