@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.
- package/README.md +4 -0
- package/package.json +48 -0
- package/src/coaching/patterns.ts +103 -0
- package/src/coaching/proposal.ts +29 -0
- package/src/coaching/retrieval.ts +27 -0
- package/src/coaching/supersede.ts +14 -0
- package/src/coaching/teach.ts +74 -0
- package/src/core/agent-status.ts +79 -0
- package/src/core/auth/constants.ts +53 -0
- package/src/core/auth/login.ts +110 -0
- package/src/core/auth/pkce.ts +18 -0
- package/src/core/auth/profile.ts +38 -0
- package/src/core/auth/refresh.ts +57 -0
- package/src/core/auth/status.ts +26 -0
- package/src/core/command-guard.ts +126 -0
- package/src/core/conversation-artifacts.ts +40 -0
- package/src/core/conversation-replay.ts +160 -0
- package/src/core/costs.ts +97 -0
- package/src/core/discovery.ts +22 -0
- package/src/core/draft.ts +6 -0
- package/src/core/e2e-model.ts +315 -0
- package/src/core/embed.ts +221 -0
- package/src/core/events.ts +133 -0
- package/src/core/firecrawl.ts +25 -0
- package/src/core/headless.ts +260 -0
- package/src/core/instrument.ts +88 -0
- package/src/core/logger.ts +143 -0
- package/src/core/mcp/adapter.ts +34 -0
- package/src/core/mcp/manager.ts +145 -0
- package/src/core/mcp/oauth.ts +119 -0
- package/src/core/memory.ts +13 -0
- package/src/core/mock-model.ts +70 -0
- package/src/core/model.ts +91 -0
- package/src/core/pricing.ts +27 -0
- package/src/core/providers/openai-codex.ts +67 -0
- package/src/core/registry.ts +67 -0
- package/src/core/run.ts +909 -0
- package/src/core/schedule-cli.ts +55 -0
- package/src/core/scheduler.ts +356 -0
- package/src/core/tasks.ts +108 -0
- package/src/core/team-cli.ts +118 -0
- package/src/core/team-routing.ts +58 -0
- package/src/core/tools.ts +1104 -0
- package/src/core/turn-audit.ts +100 -0
- package/src/core/verification.ts +102 -0
- package/src/core/workflow-run.ts +119 -0
- package/src/index.ts +8 -0
- package/src/knowledge/retrieval.ts +73 -0
- package/src/knowledge/sync.ts +28 -0
- package/src/skills/retrieval.ts +22 -0
- package/src/store/annotations.ts +107 -0
- package/src/store/artifacts.ts +338 -0
- package/src/store/config.ts +187 -0
- package/src/store/conversation.ts +107 -0
- package/src/store/db.ts +34 -0
- package/src/store/files.ts +51 -0
- package/src/store/knowledge.ts +201 -0
- package/src/store/mcp-store.ts +65 -0
- package/src/store/migrate.ts +278 -0
- package/src/store/plans.ts +260 -0
- package/src/store/policy.ts +66 -0
- package/src/store/prefs.ts +64 -0
- package/src/store/roster.ts +308 -0
- package/src/store/run-transcript.ts +103 -0
- package/src/store/schedules.ts +94 -0
- package/src/store/seed-skills.ts +75 -0
- package/src/store/skills.ts +89 -0
- package/src/store/sources.ts +58 -0
- package/src/store/spend-ledger.ts +114 -0
- package/src/store/task-state.ts +267 -0
- package/src/store/teams.ts +227 -0
- package/src/store/thread.ts +72 -0
- package/src/store/trace.ts +98 -0
- package/src/store/vectors.ts +26 -0
- package/src/store/workflows.ts +144 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/** Addressable, versioned, IMMUTABLE-PER-VERSION artifact store over artifacts/. Files are canon;
|
|
2
|
+
* artifacts/_index.json is a rebuildable manifest for enumeration. A revision is a NEW version,
|
|
3
|
+
* never an overwrite — v<N>.json is written exclusively (flag "wx"), like reserveRunId. The store
|
|
4
|
+
* is PAYLOAD-AGNOSTIC: the body is opaque bytes (or an external ref) and it never interprets it.
|
|
5
|
+
* Mirrors store/skills.ts (files canon + a rebuildable index).
|
|
6
|
+
*
|
|
7
|
+
* Layout: artifacts/<id>/v<N>.json — the envelope (metadata + summary + location)
|
|
8
|
+
* artifacts/<id>/v<N>.<ext> — the body bytes (local-file artifacts only)
|
|
9
|
+
* artifacts/_index.json — manifest: the latest version of every id (rebuildable) */
|
|
10
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, renameSync } from "node:fs";
|
|
11
|
+
import { join, basename } from "node:path";
|
|
12
|
+
import { Artifact, type ArtifactLocation, parseHandle } from "@taicho-ai/contracts/artifact";
|
|
13
|
+
import { paths } from "./files";
|
|
14
|
+
|
|
15
|
+
// underscore prefix ⇒ can never collide with a valid artifact id dir (ids start with [a-z0-9]).
|
|
16
|
+
const MANIFEST = "_index.json";
|
|
17
|
+
|
|
18
|
+
function idDir(ws: string, id: string): string { return join(paths.artifactDir(ws), id); }
|
|
19
|
+
function envelopeFile(ws: string, id: string, version: number): string { return join(idDir(ws, id), `v${version}.json`); }
|
|
20
|
+
function bodyFile(ws: string, id: string, version: number, ext: string): string { return join(idDir(ws, id), `v${version}.${ext}`); }
|
|
21
|
+
function manifestPath(ws: string): string { return join(paths.artifactDir(ws), MANIFEST); }
|
|
22
|
+
|
|
23
|
+
function slugify(s: string): string {
|
|
24
|
+
const slug = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
25
|
+
return slug || "artifact";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A caller-supplied id, normalized to the store's id shape; null if it reduces to nothing. */
|
|
29
|
+
function normalizeId(raw?: string): string | null {
|
|
30
|
+
if (!raw) return null;
|
|
31
|
+
const id = raw.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
32
|
+
return id || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Versions present for an id, ascending. */
|
|
36
|
+
export function artifactVersions(ws: string, id: string): number[] {
|
|
37
|
+
const dir = idDir(ws, id);
|
|
38
|
+
if (!existsSync(dir)) return [];
|
|
39
|
+
const vs: number[] = [];
|
|
40
|
+
for (const f of readdirSync(dir)) {
|
|
41
|
+
const m = f.match(/^v(\d+)\.json$/);
|
|
42
|
+
if (m) vs.push(Number(m[1]));
|
|
43
|
+
}
|
|
44
|
+
return vs.sort((a, b) => a - b);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SaveArtifactInput {
|
|
48
|
+
id?: string; // stable logical id/slug; omit to derive from the title
|
|
49
|
+
title: string;
|
|
50
|
+
type?: string; // free-form tag
|
|
51
|
+
role?: "output" | "input" | "resource";
|
|
52
|
+
producer: string; // agentId (provenance — from ctx in the tool)
|
|
53
|
+
runId: string; // run id (provenance — from ctx in the tool)
|
|
54
|
+
parents?: string[]; // parent artifact handles (lineage)
|
|
55
|
+
summary?: string;
|
|
56
|
+
body?: string | Uint8Array; // local bytes to store; omit when using `external`
|
|
57
|
+
external?: string; // external ref URI (instead of local body)
|
|
58
|
+
ext?: string; // body file extension (default md)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Save a NEW immutable version. Reusing an existing id bumps to the next version (never overwrites);
|
|
62
|
+
* a fresh id starts at v1. Provenance/lineage come from the caller. */
|
|
63
|
+
export function saveArtifact(ws: string, input: SaveArtifactInput): Artifact {
|
|
64
|
+
if (input.body === undefined && !input.external) throw new Error("save_artifact needs a body or an external location");
|
|
65
|
+
const id = normalizeId(input.id) ?? slugify(input.title);
|
|
66
|
+
mkdirSync(idDir(ws, id), { recursive: true });
|
|
67
|
+
const ext = (input.ext ?? "md").replace(/[^a-z0-9]/gi, "").slice(0, 12) || "md";
|
|
68
|
+
|
|
69
|
+
// exclusive-create the envelope so a concurrent save can never clobber a version — retry the next.
|
|
70
|
+
for (let version = (artifactVersions(ws, id).at(-1) ?? 0) + 1; ; version++) {
|
|
71
|
+
const location: ArtifactLocation = input.external
|
|
72
|
+
? { kind: "external", uri: input.external }
|
|
73
|
+
: { kind: "file", path: bodyFile(ws, id, version, ext) };
|
|
74
|
+
const artifact = Artifact.parse({
|
|
75
|
+
id, version, title: input.title, type: input.type ?? "document",
|
|
76
|
+
role: input.role ?? "output", producer: input.producer, runId: input.runId,
|
|
77
|
+
parents: input.parents ?? [], summary: input.summary, location,
|
|
78
|
+
created: new Date().toISOString(),
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
writeFileSync(envelopeFile(ws, id, version), JSON.stringify(artifact, null, 2), { flag: "wx" });
|
|
82
|
+
if (location.kind === "file") writeFileSync(location.path, input.body ?? "");
|
|
83
|
+
upsertManifest(ws, artifact);
|
|
84
|
+
return artifact;
|
|
85
|
+
} catch (e: unknown) {
|
|
86
|
+
if ((e as NodeJS.ErrnoException)?.code === "EEXIST") continue; // lost a race for this version
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The envelope for a handle ("id" ⇒ latest, "id@vN" ⇒ that version); null if absent. */
|
|
93
|
+
export function readArtifact(ws: string, handle: string): Artifact | null {
|
|
94
|
+
const { id, version } = parseHandle(handle);
|
|
95
|
+
const v = version ?? artifactVersions(ws, id).at(-1);
|
|
96
|
+
if (!v) return null;
|
|
97
|
+
const file = envelopeFile(ws, id, v);
|
|
98
|
+
if (!existsSync(file)) return null;
|
|
99
|
+
try { return Artifact.parse(JSON.parse(readFileSync(file, "utf8"))); } catch { return null; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Raw body bytes for a local-file artifact; null if external or missing. Payload-agnostic (Buffer).
|
|
103
|
+
* RELOCATABLE addressing: the body path is recomputed from the CURRENT ws + the artifact's id + the
|
|
104
|
+
* stored filename (v<N>.<ext>), exactly as envelopeFile recomputes the envelope path. The absolute
|
|
105
|
+
* path baked into the envelope goes stale the moment the workspace dir is renamed/moved, but the
|
|
106
|
+
* bytes still live at ws/artifacts/<id>/v<N>.<ext> — so we address them by ws, not by the baked path. */
|
|
107
|
+
export function readArtifactBody(ws: string, handle: string): Buffer | null {
|
|
108
|
+
const file = artifactBodyPath(ws, handle);
|
|
109
|
+
if (!file || !existsSync(file)) return null;
|
|
110
|
+
return readFileSync(file);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Plan 21: the body file's CURRENT path (recomputed from ws, per the relocatable-addressing note
|
|
114
|
+
* above — never read `location.path` directly); null for external artifacts or a missing handle.
|
|
115
|
+
* The browser's `o` verb hands this to $EDITOR. */
|
|
116
|
+
export function artifactBodyPath(ws: string, handle: string): string | null {
|
|
117
|
+
const a = readArtifact(ws, handle);
|
|
118
|
+
if (!a || a.location.kind !== "file") return null;
|
|
119
|
+
return join(idDir(ws, a.id), basename(a.location.path));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ArtifactFilter {
|
|
123
|
+
producer?: string;
|
|
124
|
+
type?: string;
|
|
125
|
+
role?: "output" | "input" | "resource";
|
|
126
|
+
q?: string; // substring over id/title/summary
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The latest version of each artifact, filtered, newest first. Reads the manifest (rebuilds it from
|
|
130
|
+
* the canonical envelopes if it's missing). */
|
|
131
|
+
export function listArtifacts(ws: string, filter: ArtifactFilter = {}): Artifact[] {
|
|
132
|
+
const ql = filter.q?.toLowerCase();
|
|
133
|
+
return readManifest(ws).filter((a) =>
|
|
134
|
+
(!filter.producer || a.producer === filter.producer) &&
|
|
135
|
+
(!filter.type || a.type === filter.type) &&
|
|
136
|
+
(!filter.role || a.role === filter.role) &&
|
|
137
|
+
(!ql || `${a.id} ${a.title} ${a.summary ?? ""}`.toLowerCase().includes(ql)),
|
|
138
|
+
).sort((x, y) => y.created.localeCompare(x.created));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── manifest (rebuildable index) ─────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
function readManifestRaw(ws: string): Artifact[] {
|
|
144
|
+
const f = manifestPath(ws);
|
|
145
|
+
if (!existsSync(f)) return [];
|
|
146
|
+
try {
|
|
147
|
+
const arr = JSON.parse(readFileSync(f, "utf8"));
|
|
148
|
+
return Array.isArray(arr) ? arr.map((x) => Artifact.parse(x)) : [];
|
|
149
|
+
} catch { return []; }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The manifest (latest version per id). _index.json is a REBUILDABLE cache, never authoritative:
|
|
153
|
+
* rebuilt from the canonical envelopes when it's missing/empty, and RECONCILED against a cheap dir
|
|
154
|
+
* scan so a valid-but-stale manifest (an id dropped by a cross-process last-writer-wins upsert)
|
|
155
|
+
* self-heals — we union in any on-disk id the manifest is missing. The common case is a single
|
|
156
|
+
* readdir + set-membership check (no file reads); recovery only touches ids the manifest lacks. */
|
|
157
|
+
export function readManifest(ws: string): Artifact[] {
|
|
158
|
+
const raw = readManifestRaw(ws);
|
|
159
|
+
if (!raw.length) return rebuildArtifactIndex(ws);
|
|
160
|
+
const dir = paths.artifactDir(ws);
|
|
161
|
+
if (!existsSync(dir)) return raw;
|
|
162
|
+
const known = new Set(raw.map((a) => a.id));
|
|
163
|
+
const recovered: Artifact[] = [];
|
|
164
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
165
|
+
if (!entry.isDirectory() || known.has(entry.name)) continue;
|
|
166
|
+
const top = artifactVersions(ws, entry.name).at(-1);
|
|
167
|
+
if (!top) continue;
|
|
168
|
+
const a = readArtifact(ws, `${entry.name}@v${top}`);
|
|
169
|
+
if (a) recovered.push(a);
|
|
170
|
+
}
|
|
171
|
+
if (!recovered.length) return raw;
|
|
172
|
+
const healed = [...raw, ...recovered];
|
|
173
|
+
writeManifest(ws, healed); // make the heal durable
|
|
174
|
+
return healed;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function writeManifest(ws: string, arr: Artifact[]): void {
|
|
178
|
+
mkdirSync(paths.artifactDir(ws), { recursive: true });
|
|
179
|
+
// atomic publish (temp + rename) so a concurrent reader never observes a half-written manifest.
|
|
180
|
+
const dest = manifestPath(ws);
|
|
181
|
+
const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`;
|
|
182
|
+
writeFileSync(tmp, JSON.stringify(arr, null, 2));
|
|
183
|
+
renameSync(tmp, dest);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `a` is always the newest version of its id (saveArtifact only ever appends), so it replaces any
|
|
187
|
+
* prior manifest entry for that id. Seeds from the canonical scan so a deleted manifest self-heals. */
|
|
188
|
+
function upsertManifest(ws: string, a: Artifact): void {
|
|
189
|
+
const cur = readManifest(ws).filter((x) => x.id !== a.id);
|
|
190
|
+
cur.push(a);
|
|
191
|
+
writeManifest(ws, cur);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Rebuild the manifest by scanning every id dir's latest envelope (files are canon). */
|
|
195
|
+
export function rebuildArtifactIndex(ws: string): Artifact[] {
|
|
196
|
+
const dir = paths.artifactDir(ws);
|
|
197
|
+
if (!existsSync(dir)) return [];
|
|
198
|
+
const latest: Artifact[] = [];
|
|
199
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
200
|
+
if (!entry.isDirectory()) continue;
|
|
201
|
+
const top = artifactVersions(ws, entry.name).at(-1);
|
|
202
|
+
if (!top) continue;
|
|
203
|
+
const a = readArtifact(ws, `${entry.name}@v${top}`);
|
|
204
|
+
if (a) latest.push(a);
|
|
205
|
+
}
|
|
206
|
+
writeManifest(ws, latest);
|
|
207
|
+
return latest;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── retention & GC (Plan 01 Phase 4b) ────────────────────────────────────────
|
|
211
|
+
// Immutable-per-version + heavy media = unbounded disk. Policy (Phase 0): keep-latest-N per id +
|
|
212
|
+
// archive UNREFERENCED old versions; NEVER break an id CONSUMED by a hand-off/policy/task. "Archive"
|
|
213
|
+
// relocates a version's files into artifacts/<id>/_archive/ — out of the live scan (artifactVersions
|
|
214
|
+
// only reads the id dir's top level), so the addressable store shrinks WITHOUT losing history and no
|
|
215
|
+
// referenced version ever disappears from under a live reference.
|
|
216
|
+
//
|
|
217
|
+
// A version is "referenced" iff something CONSUMES or PINS it — a hand-off edge (inputArtifacts /
|
|
218
|
+
// outputArtifacts), a task's resultRef, an approved-output exemplar (policy.artifact), an annotation,
|
|
219
|
+
// or the parent-closure of a kept version. It is NOT referenced merely because its own producing run
|
|
220
|
+
// recorded emitting it: EVERY version an agent saves lands in that run's `trace.artifacts`, so
|
|
221
|
+
// treating the producing record as a reference would pin every version ever produced and shadow
|
|
222
|
+
// keep-latest-N entirely (the retention feature would archive nothing — the PR #17 GC no-op bug).
|
|
223
|
+
// `collectReferencedArtifacts` below deliberately draws ONLY from the consumption/hand-off graph.
|
|
224
|
+
|
|
225
|
+
// underscore prefix ⇒ can never collide with a valid artifact id dir; also where a version's files go.
|
|
226
|
+
const ARCHIVE = "_archive";
|
|
227
|
+
|
|
228
|
+
/** The live-reference sources GC honors: everything that CONSUMES or PINS an artifact handle, as
|
|
229
|
+
* opposed to a producing run's own record of what it emitted (`trace.artifacts`, deliberately absent
|
|
230
|
+
* here — see the GC-section note above). Annotation targets + parent-closure are protected INSIDE
|
|
231
|
+
* gcArtifacts (off the on-disk annotations log + lineage fixpoint), not gathered here. */
|
|
232
|
+
export interface GcReferenceSources {
|
|
233
|
+
// Hand-off graph only — the handles that flowed ACROSS delegation edges (down to children /
|
|
234
|
+
// up from them). NEVER a trace's own `artifacts` (its production record).
|
|
235
|
+
traces?: { inputArtifacts?: string[]; outputArtifacts?: string[] }[];
|
|
236
|
+
taskResultRefs?: (string | null | undefined)[]; // task-state hand-off refs (an id@vN, or a run id → harmlessly ignored)
|
|
237
|
+
exemplarArtifacts?: (string | null | undefined)[]; // approved-output exemplars (policy.artifact handles)
|
|
238
|
+
extra?: (string | null | undefined)[]; // any other live handles the caller knows about
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** PURE: collect the deduped set of artifact handles GC must protect because something CONSUMES,
|
|
242
|
+
* HANDS OFF, or PINS them — the union of every source in `src`. Deliberately draws from the
|
|
243
|
+
* consumption/hand-off graph, NOT a producing run's own `trace.artifacts` (which lists every version
|
|
244
|
+
* it ever saved and would shadow keep-latest-N). A superseded intermediate that nothing here points
|
|
245
|
+
* at, and that falls outside keep-latest-N, is therefore archivable. */
|
|
246
|
+
export function collectReferencedArtifacts(src: GcReferenceSources): string[] {
|
|
247
|
+
const out = new Set<string>();
|
|
248
|
+
const add = (h?: string | null) => { const t = h?.trim(); if (t) out.add(t); };
|
|
249
|
+
for (const t of src.traces ?? []) {
|
|
250
|
+
for (const h of t.inputArtifacts ?? []) add(h);
|
|
251
|
+
for (const h of t.outputArtifacts ?? []) add(h);
|
|
252
|
+
}
|
|
253
|
+
for (const h of src.taskResultRefs ?? []) add(h);
|
|
254
|
+
for (const h of src.exemplarArtifacts ?? []) add(h);
|
|
255
|
+
for (const h of src.extra ?? []) add(h);
|
|
256
|
+
return [...out];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface GcOptions {
|
|
260
|
+
keepLatest?: number; // per id: always keep the N newest versions (default 3)
|
|
261
|
+
referenced?: string[]; // protected handles the CALLER knows are live (hand-off/policy/task refs — see collectReferencedArtifacts)
|
|
262
|
+
/** Plan 21: compute the protected set and the would-archive list on the SAME code path, but touch
|
|
263
|
+
* nothing — the browser's `g` verb previews with this, then runs for real; preview and action
|
|
264
|
+
* cannot disagree because they are one function. */
|
|
265
|
+
dryRun?: boolean;
|
|
266
|
+
}
|
|
267
|
+
export interface GcReport {
|
|
268
|
+
archived: string[]; // version handles ("id@vN") relocated into _archive
|
|
269
|
+
kept: number; // count of live versions remaining after GC
|
|
270
|
+
protectedRefs: number; // size of the protected set honored (keep-latest + referenced + annotated + lineage)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Normalize a handle to a concrete "id@vN" (bare id ⇒ its latest present version); null if absent. */
|
|
274
|
+
function pin(ws: string, handle: string): string | null {
|
|
275
|
+
const { id, version } = parseHandle(handle);
|
|
276
|
+
const v = version ?? artifactVersions(ws, id).at(-1);
|
|
277
|
+
return v ? `${id}@v${v}` : null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Collect the handles GC must protect for one id: keep-latest-N + every version that carries an
|
|
281
|
+
* annotation (its target/resolvedBy). Read straight off the annotations log to avoid an import cycle
|
|
282
|
+
* with store/annotations.ts (which reads THIS module). */
|
|
283
|
+
function protectForId(ws: string, id: string, keepLatest: number, into: Set<string>): void {
|
|
284
|
+
const vs = artifactVersions(ws, id);
|
|
285
|
+
for (const v of vs.slice(-keepLatest)) into.add(`${id}@v${v}`);
|
|
286
|
+
const af = join(idDir(ws, id), "annotations.jsonl");
|
|
287
|
+
if (!existsSync(af)) return;
|
|
288
|
+
for (const line of readFileSync(af, "utf8").split("\n")) {
|
|
289
|
+
if (!line.trim()) continue;
|
|
290
|
+
try {
|
|
291
|
+
const a = JSON.parse(line) as { target?: string; resolvedBy?: string };
|
|
292
|
+
for (const h of [a.target, a.resolvedBy]) { if (h) { const n = pin(ws, h); if (n) into.add(n); } }
|
|
293
|
+
} catch { /* skip corrupt annotation line */ }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function gcArtifacts(ws: string, opts: GcOptions = {}): GcReport {
|
|
298
|
+
const keepLatest = Math.max(1, opts.keepLatest ?? 3);
|
|
299
|
+
const dir = paths.artifactDir(ws);
|
|
300
|
+
if (!existsSync(dir)) return { archived: [], kept: 0, protectedRefs: 0 };
|
|
301
|
+
|
|
302
|
+
const ids = readdirSync(dir, { withFileTypes: true })
|
|
303
|
+
.filter((e) => e.isDirectory() && e.name !== ARCHIVE)
|
|
304
|
+
.map((e) => e.name);
|
|
305
|
+
|
|
306
|
+
const protectedSet = new Set<string>();
|
|
307
|
+
for (const h of opts.referenced ?? []) { const n = pin(ws, h); if (n) protectedSet.add(n); }
|
|
308
|
+
for (const id of ids) protectForId(ws, id, keepLatest, protectedSet);
|
|
309
|
+
|
|
310
|
+
// Lineage integrity: a protected version's ancestors must survive too. Iterate to a fixed point.
|
|
311
|
+
let grew = true;
|
|
312
|
+
while (grew) {
|
|
313
|
+
grew = false;
|
|
314
|
+
for (const h of [...protectedSet]) {
|
|
315
|
+
const a = readArtifact(ws, h);
|
|
316
|
+
for (const p of a?.parents ?? []) { const n = pin(ws, p); if (n && !protectedSet.has(n)) { protectedSet.add(n); grew = true; } }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const archived: string[] = [];
|
|
321
|
+
let kept = 0;
|
|
322
|
+
for (const id of ids) {
|
|
323
|
+
for (const v of artifactVersions(ws, id)) {
|
|
324
|
+
const handle = `${id}@v${v}`;
|
|
325
|
+
if (protectedSet.has(handle)) { kept++; continue; }
|
|
326
|
+
if (!opts.dryRun) {
|
|
327
|
+
const archiveDir = join(idDir(ws, id), ARCHIVE);
|
|
328
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
329
|
+
// relocate the envelope (v<N>.json) + any body (v<N>.<ext>) — anchored so v1 never matches v11.
|
|
330
|
+
const re = new RegExp(`^v${v}\\.`);
|
|
331
|
+
for (const f of readdirSync(idDir(ws, id))) if (re.test(f)) renameSync(join(idDir(ws, id), f), join(archiveDir, f));
|
|
332
|
+
}
|
|
333
|
+
archived.push(handle); // dryRun: the WOULD-archive list, same computation
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!opts.dryRun) rebuildArtifactIndex(ws); // latest-per-id is unchanged (only OLDER versions archived) — refresh anyway
|
|
337
|
+
return { archived, kept, protectedRefs: protectedSet.size };
|
|
338
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/** Resolve which provider/model to use and whether a key is present.
|
|
2
|
+
* Env-first; config.yaml is deferred. Keys are read by the AI SDK from env directly. */
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { YAML } from "bun";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { log } from "../core/logger";
|
|
7
|
+
|
|
8
|
+
export type Provider = "anthropic" | "openai" | "openrouter";
|
|
9
|
+
export interface ResolvedConfig { provider: Provider; model: string; }
|
|
10
|
+
export interface MissingConfig { missing: true; }
|
|
11
|
+
|
|
12
|
+
// OpenRouter has no sensible default slug (its catalog drifts), so it maps to "" — an empty
|
|
13
|
+
// sentinel meaning "model is required". buildModel/createModelResolver enforce non-empty there.
|
|
14
|
+
const DEFAULT_MODEL: Record<Provider, string> = {
|
|
15
|
+
anthropic: "claude-sonnet-4-6",
|
|
16
|
+
openai: "gpt-5.5",
|
|
17
|
+
openrouter: "",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const keyFor = (env: Record<string, string | undefined>, p: Provider): string | undefined =>
|
|
21
|
+
p === "anthropic" ? env.ANTHROPIC_API_KEY : p === "openai" ? env.OPENAI_API_KEY : env.OPENROUTER_API_KEY;
|
|
22
|
+
|
|
23
|
+
export function resolveConfig(env: Record<string, string | undefined> = process.env): ResolvedConfig | MissingConfig {
|
|
24
|
+
const tp = env.TAICHO_PROVIDER;
|
|
25
|
+
const wanted: Provider | null = tp === "openai" ? "openai" : tp === "anthropic" ? "anthropic" : tp === "openrouter" ? "openrouter" : null;
|
|
26
|
+
const pick = (p: Provider): ResolvedConfig => ({ provider: p, model: env.TAICHO_MODEL ?? DEFAULT_MODEL[p] });
|
|
27
|
+
|
|
28
|
+
if (wanted) return keyFor(env, wanted) ? pick(wanted) : { missing: true };
|
|
29
|
+
// Auto-detect: prefer a first-party key; OpenRouter is last so a stray key can't hijack a setup.
|
|
30
|
+
if (env.ANTHROPIC_API_KEY) return pick("anthropic");
|
|
31
|
+
if (env.OPENAI_API_KEY) return pick("openai");
|
|
32
|
+
if (env.OPENROUTER_API_KEY) return pick("openrouter");
|
|
33
|
+
return { missing: true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isMissing(c: ResolvedConfig | MissingConfig): c is MissingConfig {
|
|
37
|
+
return "missing" in c;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PartialBudgets = z.object({
|
|
41
|
+
maxIterationsPerRun: z.number().int().positive().optional(),
|
|
42
|
+
maxWorkItemsPerRequest: z.number().int().positive().optional(),
|
|
43
|
+
maxTokensPerRun: z.number().int().positive().optional(),
|
|
44
|
+
maxCostPerRunUsd: z.number().positive().optional(),
|
|
45
|
+
maxConcurrentRuns: z.number().int().positive().optional(), // Plan 04: per-agent background concurrency cap
|
|
46
|
+
}).optional();
|
|
47
|
+
|
|
48
|
+
const AgentOverride = z.object({
|
|
49
|
+
provider: z.enum(["anthropic", "openai", "openrouter"]).optional(),
|
|
50
|
+
model: z.string().optional(),
|
|
51
|
+
budgets: PartialBudgets,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** An MCP server: a local stdio subprocess, or a remote HTTP endpoint. Distinguished by
|
|
55
|
+
* `command` vs `url` (no explicit type tag). Secrets in env/headers use ${VAR} (see interpolateEnv). */
|
|
56
|
+
const McpStdioServer = z.object({
|
|
57
|
+
command: z.string(),
|
|
58
|
+
args: z.array(z.string()).optional(),
|
|
59
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
60
|
+
});
|
|
61
|
+
const McpHttpServer = z.object({
|
|
62
|
+
url: z.string().url(),
|
|
63
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
64
|
+
auth: z.literal("oauth").optional(), // omitted ⇒ no-auth or static-header auth
|
|
65
|
+
// Secrets stored WITH the server. `applyMcpEnv` loads them into process.env on add + every boot, so a
|
|
66
|
+
// ${VAR} ref in `url`/`headers` resolves (via interpolateEnv) both immediately and after a restart.
|
|
67
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
68
|
+
});
|
|
69
|
+
export const McpServerConfig = z.union([McpStdioServer, McpHttpServer]);
|
|
70
|
+
export type McpServerConfig = z.infer<typeof McpServerConfig>;
|
|
71
|
+
export const isStdioServer = (s: McpServerConfig): s is z.infer<typeof McpStdioServer> => "command" in s;
|
|
72
|
+
|
|
73
|
+
const McpConfig = z.object({
|
|
74
|
+
enabled: z.boolean().optional(), // default: on when servers are present
|
|
75
|
+
servers: z.record(z.string(), McpServerConfig).optional(),
|
|
76
|
+
}).optional();
|
|
77
|
+
export type McpConfig = z.infer<typeof McpConfig>;
|
|
78
|
+
|
|
79
|
+
/** Expand ${VAR} references against the environment so secrets stay in env, not the config file. */
|
|
80
|
+
export function interpolateEnv(value: string, env: Record<string, string | undefined> = process.env): string {
|
|
81
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_m, name: string) => env[name] ?? "");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Plan 09: squad-WIDE spend ceilings (all agents, all runs) enforced in the loop and persisted across
|
|
85
|
+
* sessions. Distinct from per-run/per-agent `budgets` above — these bound the whole squad's rolling
|
|
86
|
+
* daily/weekly spend. Any subset may be set; USD ceilings only constrain priced runs (a subscription
|
|
87
|
+
* squad is bounded by tokens, never a fabricated dollar figure). Plan 19 adds per-team ceilings under
|
|
88
|
+
* `teams.<id>.ceilings`, metered against the same SpendLedger with a different scope. */
|
|
89
|
+
const Ceilings = z.object({
|
|
90
|
+
dailyTokens: z.number().int().positive().optional(),
|
|
91
|
+
weeklyTokens: z.number().int().positive().optional(),
|
|
92
|
+
dailyCostUsd: z.number().positive().optional(),
|
|
93
|
+
weeklyCostUsd: z.number().positive().optional(),
|
|
94
|
+
});
|
|
95
|
+
const SquadCeilings = Ceilings.optional();
|
|
96
|
+
|
|
97
|
+
/** Plan 19: per-team configuration. `team.md` is canon for CAPABILITY (lead, charter, tool policy);
|
|
98
|
+
* taicho.yaml overrides MODEL and BUDGETS — exactly the division of authority agents already use
|
|
99
|
+
* between agent.md and `agents.<id>`. One rule, learned once.
|
|
100
|
+
*
|
|
101
|
+
* Resolution walks agent → team → defaults, so "the trading team runs opus with a tight daily ceiling"
|
|
102
|
+
* is one block instead of a line repeated on every member. `ceilings` bound the team's rolling spend
|
|
103
|
+
* across sessions, metered against the same SpendLedger as the squad ceiling, under its own scope. */
|
|
104
|
+
const TeamOverride = z.object({
|
|
105
|
+
provider: z.enum(["anthropic", "openai", "openrouter"]).optional(),
|
|
106
|
+
model: z.string().optional(),
|
|
107
|
+
budgets: PartialBudgets,
|
|
108
|
+
ceilings: Ceilings.optional(),
|
|
109
|
+
});
|
|
110
|
+
export type TeamOverride = z.infer<typeof TeamOverride>;
|
|
111
|
+
|
|
112
|
+
export const TaichoConfig = z.object({
|
|
113
|
+
defaults: z.object({
|
|
114
|
+
provider: z.enum(["anthropic", "openai", "openrouter"]).optional(),
|
|
115
|
+
model: z.string().optional(),
|
|
116
|
+
budgets: PartialBudgets,
|
|
117
|
+
// Plan 05: fraction (0..1) of a model's context window at which the loop folds the oldest tool
|
|
118
|
+
// round-trips into one compact summary. Default ~0.7 (see compaction.ts DEFAULT_COMPACT_AT).
|
|
119
|
+
compactAt: z.number().positive().max(1).optional(),
|
|
120
|
+
// Plan 05 Ph3: how many recent conversation turns boot-replay keeps VERBATIM; older turns fold
|
|
121
|
+
// into a rolling summary. Default 6 (conversation-replay.ts DEFAULT_REPLAY_KEEP_TURNS).
|
|
122
|
+
replayKeepTurns: z.number().int().nonnegative().optional(),
|
|
123
|
+
// Plan 12: per-request transport deadline (ms) for a model fetch. A genuinely hung request (open
|
|
124
|
+
// socket, zero tokens) aborts the connection and errors, routed through the AI SDK's maxRetries —
|
|
125
|
+
// replaces the deleted loop-level idle watchdog. Default 120s (request-timeout.ts).
|
|
126
|
+
modelRequestTimeoutMs: z.number().int().positive().optional(),
|
|
127
|
+
}).optional(),
|
|
128
|
+
// Squad-level ceilings (Plan 09) — top-level because they bound the whole squad, not one agent.
|
|
129
|
+
budgets: SquadCeilings,
|
|
130
|
+
// Plan 19: per-team model/budget overrides + the team's own spend ceiling.
|
|
131
|
+
teams: z.record(z.string(), TeamOverride).optional(),
|
|
132
|
+
agents: z.record(z.string(), AgentOverride).optional(),
|
|
133
|
+
auth: z.object({ chatgpt_signin: z.boolean().optional() }).optional(),
|
|
134
|
+
// Plan 04: a global ceiling on total in-flight + queued BACKGROUND runs (dispatch_task). Bounds
|
|
135
|
+
// system-wide fan-out independent of per-agent maxConcurrentRuns; default applied in the REPL (32).
|
|
136
|
+
tasks: z.object({ maxBackgroundRuns: z.number().int().positive().optional() }).optional(),
|
|
137
|
+
mcp: McpConfig,
|
|
138
|
+
embeddings: z.object({ provider: z.enum(["off", "local", "openai"]).optional() }).optional(), // semantic KB backend
|
|
139
|
+
}).default({});
|
|
140
|
+
export type TaichoConfig = z.infer<typeof TaichoConfig>;
|
|
141
|
+
|
|
142
|
+
export type AuthSource =
|
|
143
|
+
| { kind: "env"; provider: Provider; model: string }
|
|
144
|
+
| { kind: "oauth-openai-codex"; accountId: string; expiresAt: number }
|
|
145
|
+
| { kind: "none" };
|
|
146
|
+
|
|
147
|
+
/** Precedence: an explicit `TAICHO_PROVIDER` always wins (`openai-codex` → subscription;
|
|
148
|
+
* `openai`/`anthropic`/`openrouter` → that env API key). Otherwise a signed-in ChatGPT
|
|
149
|
+
* subscription is PREFERRED over env API keys (if you logged in, you meant it), then env keys,
|
|
150
|
+
* then none. `auth.chatgpt_signin: false` disables the subscription path entirely. */
|
|
151
|
+
export function resolveAuth(opts: {
|
|
152
|
+
env?: Record<string, string | undefined>;
|
|
153
|
+
config: TaichoConfig;
|
|
154
|
+
loadProfile: () => { account_id: string; expires_at: number } | null;
|
|
155
|
+
}): AuthSource {
|
|
156
|
+
const env = opts.env ?? process.env;
|
|
157
|
+
const flagOn = opts.config.auth?.chatgpt_signin !== false;
|
|
158
|
+
const profile = flagOn ? opts.loadProfile() : null;
|
|
159
|
+
const oauth = (): AuthSource =>
|
|
160
|
+
profile ? { kind: "oauth-openai-codex", accountId: profile.account_id, expiresAt: profile.expires_at } : { kind: "none" };
|
|
161
|
+
const envAuth = (): AuthSource => {
|
|
162
|
+
const envCfg = resolveConfig(env);
|
|
163
|
+
return isMissing(envCfg) ? { kind: "none" } : { kind: "env", provider: envCfg.provider, model: envCfg.model };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
if (env.TAICHO_PROVIDER === "openai-codex") return oauth();
|
|
167
|
+
if (env.TAICHO_PROVIDER === "openai" || env.TAICHO_PROVIDER === "anthropic" || env.TAICHO_PROVIDER === "openrouter") return envAuth();
|
|
168
|
+
return profile ? oauth() : envAuth(); // signed-in subscription preferred over env keys
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function loadConfig(ws: string): Promise<TaichoConfig> {
|
|
172
|
+
const file = join(ws, "taicho.yaml");
|
|
173
|
+
if (!(await Bun.file(file).exists())) return TaichoConfig.parse({});
|
|
174
|
+
let raw: unknown;
|
|
175
|
+
try {
|
|
176
|
+
raw = YAML.parse(await Bun.file(file).text());
|
|
177
|
+
} catch (e) {
|
|
178
|
+
log.warn(`failed to parse taicho.yaml — using defaults`, e);
|
|
179
|
+
return TaichoConfig.parse({});
|
|
180
|
+
}
|
|
181
|
+
const result = TaichoConfig.safeParse(raw);
|
|
182
|
+
if (!result.success) {
|
|
183
|
+
log.warn("invalid taicho.yaml — using defaults");
|
|
184
|
+
return TaichoConfig.parse({});
|
|
185
|
+
}
|
|
186
|
+
return result.data;
|
|
187
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Durable conversation evidence lives separately from prompt context.
|
|
2
|
+
* ledger.jsonl is append-only audit history; context.json says which turns are safe to replay. */
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { paths } from "./files";
|
|
6
|
+
import { clearThread } from "./thread";
|
|
7
|
+
|
|
8
|
+
export type LedgerStatus = "submitted" | "completed" | "failed" | "blocked" | "interrupted";
|
|
9
|
+
|
|
10
|
+
export interface ConversationLedgerTurn {
|
|
11
|
+
turnId: string;
|
|
12
|
+
runId: string;
|
|
13
|
+
timestamp: string;
|
|
14
|
+
agent: string;
|
|
15
|
+
role: "user" | "assistant" | "system";
|
|
16
|
+
content: unknown;
|
|
17
|
+
status: LedgerStatus;
|
|
18
|
+
parentRunId?: string;
|
|
19
|
+
/** Plan 01 Ph5: artifact HANDLES (id@vN) this turn produced. Carried by REFERENCE into replay —
|
|
20
|
+
* the replay cache resolves each to its summary (never the body); the ledger just records the ids. */
|
|
21
|
+
artifacts?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ContextDecision {
|
|
25
|
+
turnId: string;
|
|
26
|
+
runId: string;
|
|
27
|
+
reason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ConversationContext {
|
|
31
|
+
agent: string;
|
|
32
|
+
includedTurns: ContextDecision[];
|
|
33
|
+
excludedTurns: ContextDecision[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function newTurnId(agent: string, runId: string, role: string): string {
|
|
37
|
+
return `${agent}_${runId.slice(runId.indexOf("/") + 1)}_${role}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ledgerFile(ws: string, agent: string): string {
|
|
41
|
+
return join(paths.conversationDir(ws, agent), "ledger.jsonl");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function contextFile(ws: string, agent: string): string {
|
|
45
|
+
return join(paths.conversationDir(ws, agent), "context.json");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function appendLedgerTurn(ws: string, agent: string, turn: ConversationLedgerTurn): void {
|
|
49
|
+
mkdirSync(paths.conversationDir(ws, agent), { recursive: true });
|
|
50
|
+
appendFileSync(ledgerFile(ws, agent), JSON.stringify(turn) + "\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function loadLedger(ws: string, agent: string): ConversationLedgerTurn[] {
|
|
54
|
+
const f = ledgerFile(ws, agent);
|
|
55
|
+
if (!existsSync(f)) return [];
|
|
56
|
+
const out: ConversationLedgerTurn[] = [];
|
|
57
|
+
for (const line of readFileSync(f, "utf8").split("\n")) {
|
|
58
|
+
if (!line.trim()) continue;
|
|
59
|
+
try { out.push(JSON.parse(line) as ConversationLedgerTurn); } catch { /* ignore corrupt audit line */ }
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function loadContext(ws: string, agent: string): ConversationContext {
|
|
65
|
+
const f = contextFile(ws, agent);
|
|
66
|
+
if (!existsSync(f)) return { agent, includedTurns: [], excludedTurns: [] };
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(readFileSync(f, "utf8")) as ConversationContext;
|
|
69
|
+
return { agent, includedTurns: parsed.includedTurns ?? [], excludedTurns: parsed.excludedTurns ?? [] };
|
|
70
|
+
} catch {
|
|
71
|
+
return { agent, includedTurns: [], excludedTurns: [] };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function recordContextDecision(
|
|
76
|
+
ws: string,
|
|
77
|
+
agent: string,
|
|
78
|
+
decision: { include: boolean; turnId: string; runId: string; reason?: string },
|
|
79
|
+
): void {
|
|
80
|
+
mkdirSync(paths.conversationDir(ws, agent), { recursive: true });
|
|
81
|
+
const ctx = loadContext(ws, agent);
|
|
82
|
+
const entry = { turnId: decision.turnId, runId: decision.runId, reason: decision.reason };
|
|
83
|
+
const same = (x: ContextDecision) => x.turnId === decision.turnId;
|
|
84
|
+
ctx.includedTurns = ctx.includedTurns.filter((x) => !same(x));
|
|
85
|
+
ctx.excludedTurns = ctx.excludedTurns.filter((x) => !same(x));
|
|
86
|
+
if (decision.include) ctx.includedTurns.push(entry);
|
|
87
|
+
else ctx.excludedTurns.push(entry);
|
|
88
|
+
writeFileSync(contextFile(ws, agent), JSON.stringify(ctx, null, 2));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Durably reset an agent's conversation (a user-invoked `/clear`). The append-only ledger + context are
|
|
92
|
+
* TRUTH, so they are ARCHIVED — moved to conversations/.archive/<agent>-<ts>/ — never deleted. The
|
|
93
|
+
* derived replay cache (agents/<id>/thread.jsonl) is wiped too. A subsequent turn recreates a fresh
|
|
94
|
+
* ledger and a subsequent boot loads an empty thread, so the old conversation never rehydrates. Returns
|
|
95
|
+
* the archive path (or null if there was nothing to archive). */
|
|
96
|
+
export function clearConversation(ws: string, agent: string, now: number = Date.now()): string | null {
|
|
97
|
+
const dir = paths.conversationDir(ws, agent);
|
|
98
|
+
let archived: string | null = null;
|
|
99
|
+
if (existsSync(dir)) {
|
|
100
|
+
const archiveRoot = join(ws, "conversations", ".archive");
|
|
101
|
+
mkdirSync(archiveRoot, { recursive: true });
|
|
102
|
+
archived = join(archiveRoot, `${agent}-${now}`);
|
|
103
|
+
renameSync(dir, archived);
|
|
104
|
+
}
|
|
105
|
+
clearThread(ws, agent); // the derived replay cache lives under agents/<id>/, outside the conversation dir
|
|
106
|
+
return archived;
|
|
107
|
+
}
|
package/src/store/db.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { migrate } from "./migrate";
|
|
4
|
+
|
|
5
|
+
/** Embedded store: derived state only (registry cache, embeddings, indexes).
|
|
6
|
+
* Files are canon — deleting the DB and re-indexing must always work. */
|
|
7
|
+
export function openDb(workspace: string): Database {
|
|
8
|
+
const db = new Database(join(workspace, "taicho.db"), { create: true });
|
|
9
|
+
db.exec(`
|
|
10
|
+
PRAGMA journal_mode = WAL;
|
|
11
|
+
-- team (Plan 19) is declared here for a FRESH workspace only. CREATE TABLE IF NOT EXISTS cannot add
|
|
12
|
+
-- a column to a table that already exists, so every pre-Plan-19 database gets it from migration v8's
|
|
13
|
+
-- guarded ALTER instead. Both must be present and both idempotent: baseline alone would silently
|
|
14
|
+
-- skip existing workspaces, ALTER alone would throw "duplicate column" on new ones.
|
|
15
|
+
CREATE TABLE IF NOT EXISTS registry (
|
|
16
|
+
id TEXT PRIMARY KEY, role TEXT NOT NULL, is_root INTEGER DEFAULT 0, team TEXT
|
|
17
|
+
);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
19
|
+
ref TEXT PRIMARY KEY, -- policy/exemplar id or expansion key
|
|
20
|
+
kind TEXT NOT NULL, -- 'policy' | 'exemplar'
|
|
21
|
+
vec BLOB NOT NULL -- float32 array (brute-force cosine in v0; sqlite-vec later)
|
|
22
|
+
);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS task_ledger (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
goal_hash TEXT NOT NULL, -- dedup key for equivalent in-flight tasks
|
|
26
|
+
agent TEXT NOT NULL,
|
|
27
|
+
status TEXT NOT NULL DEFAULT 'claimed', -- claimed | done | blocked
|
|
28
|
+
run_id TEXT,
|
|
29
|
+
created INTEGER DEFAULT (unixepoch())
|
|
30
|
+
);
|
|
31
|
+
`);
|
|
32
|
+
migrate(db); // versioned tables on top of the baseline (kb_nodes/kb_edges, meta)
|
|
33
|
+
return db;
|
|
34
|
+
}
|