@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,57 @@
1
+ /** Access-token refresh + single-flight coalescing. A failed/revoked refresh -> AuthExpiredError. */
2
+ import { OPENAI_CODEX_AUTH } from "./constants";
3
+ import type { AuthProfile } from "./profile";
4
+
5
+ export class AuthExpiredError extends Error {
6
+ constructor(message = "ChatGPT session expired — run /login openai") {
7
+ super(message);
8
+ this.name = "AuthExpiredError";
9
+ }
10
+ }
11
+
12
+ interface RefreshResponse { access_token: string; refresh_token?: string; expires_in: number; }
13
+
14
+ export async function refreshToken(
15
+ profile: AuthProfile,
16
+ fetchImpl: typeof fetch = fetch,
17
+ now: () => number = Date.now,
18
+ ): Promise<AuthProfile> {
19
+ const res = await fetchImpl(OPENAI_CODEX_AUTH.tokenUrl, {
20
+ method: "POST",
21
+ headers: { "content-type": "application/x-www-form-urlencoded" },
22
+ body: new URLSearchParams({
23
+ grant_type: "refresh_token",
24
+ refresh_token: profile.refresh_token,
25
+ client_id: OPENAI_CODEX_AUTH.clientId,
26
+ }),
27
+ });
28
+ if (!res.ok) throw new AuthExpiredError();
29
+ const t = (await res.json()) as RefreshResponse;
30
+ return {
31
+ ...profile,
32
+ access_token: t.access_token,
33
+ refresh_token: t.refresh_token ?? profile.refresh_token,
34
+ expires_at: now() + t.expires_in * 1000,
35
+ };
36
+ }
37
+
38
+ /** Returns a refresh() that shares one in-flight refresh across concurrent callers. */
39
+ export function createRefresher(opts: {
40
+ load: () => AuthProfile | null;
41
+ save: (p: AuthProfile) => void;
42
+ fetchImpl?: typeof fetch;
43
+ now?: () => number;
44
+ }): () => Promise<AuthProfile> {
45
+ let inFlight: Promise<AuthProfile> | null = null;
46
+ return () => {
47
+ if (inFlight) return inFlight;
48
+ inFlight = (async () => {
49
+ const current = opts.load();
50
+ if (!current) throw new AuthExpiredError();
51
+ const refreshed = await refreshToken(current, opts.fetchImpl ?? fetch, opts.now ?? Date.now);
52
+ opts.save(refreshed);
53
+ return refreshed;
54
+ })().finally(() => { inFlight = null; });
55
+ return inFlight;
56
+ };
57
+ }
@@ -0,0 +1,26 @@
1
+ /** Pure presentation helpers for auth status / onboarding lines (unit-tested).
2
+ * No I/O, no token material — safe to call from the REPL render path. */
3
+ import type { AuthSource } from "../../store/config";
4
+
5
+ export function formatAuthStatus(source: AuthSource): string {
6
+ switch (source.kind) {
7
+ case "env": return `env:${source.provider} (${source.model})`;
8
+ case "oauth-openai-codex": {
9
+ const when = new Date(source.expiresAt).toISOString();
10
+ return `oauth:openai-codex (expires ${when})`;
11
+ }
12
+ case "none": return "none — set an API key or run /login openai";
13
+ }
14
+ }
15
+
16
+ export function noCredentialLines(): string[] {
17
+ return [
18
+ "taicho — no credentials configured.",
19
+ "Either set ANTHROPIC_API_KEY / OPENAI_API_KEY and relaunch,",
20
+ "or run /login openai to sign in with your ChatGPT subscription (no API key needed).",
21
+ ];
22
+ }
23
+
24
+ export function authExpiredMessage(): string {
25
+ return "ChatGPT session expired — run /login openai to sign in again.";
26
+ }
@@ -0,0 +1,126 @@
1
+ /** The command guard: decide whether a shell command is safe to auto-run (allow) or needs the
2
+ * captain's approval (block), by delegating to the external `dcg` binary. Fail SAFE — if dcg is
3
+ * absent or errors, we block (→ ask the human). runShell executes an approved/allowed command with
4
+ * output caps + a timeout; runSandboxed executes it CONFINED (Plan 08 sandbox-then-escalate). */
5
+ import { tmpdir } from "node:os";
6
+ import { realpathSync } from "node:fs";
7
+ import { resolve, isAbsolute, dirname, basename, join, sep } from "node:path";
8
+
9
+ export interface Verdict { decision: "allow" | "block"; reason?: string }
10
+
11
+ /** Default runner: spawn `dcg --robot test <command>`. Throws if dcg isn't installed (caught below). */
12
+ function dcgTest(command: string): { code: number; stdout: string } {
13
+ const p = Bun.spawnSync({ cmd: ["dcg", "--robot", "test", command] });
14
+ return { code: p.exitCode ?? 1, stdout: p.stdout.toString() };
15
+ }
16
+
17
+ export function classifyCommand(
18
+ command: string,
19
+ run: (command: string) => { code: number; stdout: string } = dcgTest,
20
+ ): Verdict {
21
+ try {
22
+ const { code, stdout } = run(command);
23
+ if (code === 0) return { decision: "allow" };
24
+ let reason: string | undefined;
25
+ try { reason = (JSON.parse(stdout) as { reason?: string }).reason; } catch { /* not JSON */ }
26
+ return { decision: "block", reason: reason ?? "flagged by the command guard" };
27
+ } catch {
28
+ return { decision: "block", reason: "command guard (dcg) unavailable — approve manually" };
29
+ }
30
+ }
31
+
32
+ const CAP = 10_000;
33
+ const cap = (b: { toString(): string }): string => { const s = b.toString(); return s.length > CAP ? s.slice(0, CAP) + "\n…(truncated)" : s; };
34
+
35
+ export function runShell(command: string, cwd: string): { exitCode: number; stdout: string; stderr: string } {
36
+ try {
37
+ const p = Bun.spawnSync({ cmd: ["bash", "-lc", command], cwd, timeout: 60_000 });
38
+ return { exitCode: p.exitCode ?? -1, stdout: cap(p.stdout), stderr: cap(p.stderr) };
39
+ } catch (e) {
40
+ return { exitCode: -1, stdout: "", stderr: `failed to run: ${e instanceof Error ? e.message : String(e)}` };
41
+ }
42
+ }
43
+
44
+ /** Result of a sandbox attempt. `enforced` is the honesty flag: TRUE means the command actually ran
45
+ * inside an OS-level sandbox (confined); FALSE means no sandbox mechanism was available on this host
46
+ * and the command was NOT run (we never silently drop confinement — the caller must escalate to an
47
+ * approved unsandboxed run). */
48
+ export interface SandboxResult { exitCode: number; stdout: string; stderr: string; enforced: boolean }
49
+
50
+ const realish = (p: string): string => { try { return realpathSync(p); } catch { return p; } };
51
+ const esc = (p: string): string => p.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
52
+
53
+ /** Realpath the deepest EXISTING ancestor of `p` (following symlinks), then re-append the
54
+ * not-yet-created tail. A leaf that doesn't exist yet can't itself be a symlink, but any of its
55
+ * existing ancestors could be — so we resolve as far down as the filesystem lets us. */
56
+ function resolveDeepest(p: string): string {
57
+ let cur = resolve(p);
58
+ const tail: string[] = [];
59
+ for (;;) {
60
+ try {
61
+ const base = realpathSync(cur);
62
+ return tail.length ? join(base, ...tail) : base;
63
+ } catch {
64
+ const parent = dirname(cur);
65
+ if (parent === cur) return tail.length ? join(cur, ...tail) : cur; // hit the fs root, nothing more to resolve
66
+ tail.unshift(basename(cur));
67
+ cur = parent;
68
+ }
69
+ }
70
+ }
71
+
72
+ /** True iff `target` resolves to a path INSIDE (or equal to) `root`, following symlinks so a
73
+ * symlinked cwd — or a symlinked ancestor of it — can't smuggle the effective directory outside the
74
+ * workspace. A relative `target` is resolved against `root`. This is the containment gate that keeps
75
+ * run_command's MODEL-SUPPLIED cwd from widening the sandbox's writable set beyond ctx.ws. */
76
+ export function isInsideWorkspace(root: string, target: string): boolean {
77
+ const rootReal = resolveDeepest(root);
78
+ const targetReal = resolveDeepest(isAbsolute(target) ? target : resolve(root, target));
79
+ return targetReal === rootReal || targetReal.startsWith(rootReal + sep);
80
+ }
81
+
82
+ /** A Seatbelt (macOS `sandbox-exec`) profile: deny-by-default, allow reads + process spawn, allow
83
+ * writes ONLY under the given paths (workspace + temp), and — by deny-default — deny all network. */
84
+ function seatbeltProfile(writable: string[]): string {
85
+ const writes = writable.map((p) => `(allow file-write* (subpath "${esc(p)}"))`).join("");
86
+ return [
87
+ "(version 1)",
88
+ "(deny default)",
89
+ "(allow process-fork)(allow process-exec)",
90
+ "(allow file-read*)",
91
+ "(allow sysctl-read)",
92
+ '(allow file-write-data (path "/dev/null") (path "/dev/stdout") (path "/dev/stderr") (path "/dev/tty"))',
93
+ writes,
94
+ ].join("");
95
+ }
96
+
97
+ /** Run a command CONFINED (Plan 08 sandbox-then-escalate).
98
+ *
99
+ * ENFORCED on macOS via Seatbelt (`sandbox-exec`): no outbound network, filesystem writes confined
100
+ * to the workspace + temp dirs, reads allowed. Returns `enforced: true` with the real captured
101
+ * output.
102
+ *
103
+ * On any OTHER platform (or if `sandbox-exec` is missing/errors), there is NO enforced sandbox here:
104
+ * we return `enforced: false` and do NOT run the command — the caller escalates to a human-approved
105
+ * unsandboxed run. This keeps the enforcement claim HONEST (we never fake confinement).
106
+ *
107
+ * SECURITY: the Seatbelt writable set is anchored to `writableRoot` (the WORKSPACE, ctx.ws) + scratch
108
+ * temp dirs ONLY — NEVER the model-supplied `cwd`. `cwd` is where the command RUNS, not what it may
109
+ * write to; the caller (run_command) separately rejects/escalates a cwd that escapes ctx.ws, so a
110
+ * model can't self-authorize writes outside the workspace by choosing its own cwd. `writableRoot`
111
+ * defaults to `cwd` only for back-compat callers that already pass a trusted, contained directory. */
112
+ export function runSandboxed(command: string, cwd: string, writableRoot: string = cwd): SandboxResult {
113
+ if (process.platform !== "darwin")
114
+ return { exitCode: -1, stdout: "", stderr: `no OS sandbox on this host (${process.platform}) — sandbox enforced on macOS only`, enforced: false };
115
+ try {
116
+ const writable = [...new Set([realish(writableRoot), realish(tmpdir()), "/tmp", "/private/tmp"])];
117
+ const profile = seatbeltProfile(writable);
118
+ const p = Bun.spawnSync({ cmd: ["sandbox-exec", "-p", profile, "bash", "-lc", command], cwd, timeout: 60_000 });
119
+ // Strip Seatbelt's own deprecation notice so it doesn't masquerade as command output.
120
+ const stderr = cap(p.stderr).replace(/^.*sandbox-exec.*deprecated.*$\n?/im, "");
121
+ return { exitCode: p.exitCode ?? -1, stdout: cap(p.stdout), stderr, enforced: true };
122
+ } catch (e) {
123
+ // sandbox-exec not found / spawn failure → NOT enforced; command NOT run (no fake confinement).
124
+ return { exitCode: -1, stdout: "", stderr: `sandbox unavailable: ${e instanceof Error ? e.message : String(e)}`, enforced: false };
125
+ }
126
+ }
@@ -0,0 +1,40 @@
1
+ /** Plan 15 — gather all artifacts produced by a conversation's run tree (root + delegated children).
2
+ * Walks the delegation subtree, collects artifact handles from each run's trace.artifacts and
3
+ * trace.outputArtifacts, de-dups by id (keep latest version), and returns them ordered by created
4
+ * desc (latest first). Returns the artifact envelopes (resolved via readArtifact), not the bodies.
5
+ *
6
+ * (Extracted from the retired trace-tree.ts when the /trace waterfall was removed — Plan 17. This is
7
+ * the artifact BROWSER's data source, not tracing; trace visualization is OpenTelemetry's job now.) */
8
+ import { readTrace } from "../store/trace";
9
+ import { readArtifact } from "../store/artifacts";
10
+ import { artifactHandle, type Artifact } from "@taicho-ai/contracts/artifact";
11
+
12
+ export function gatherConversationArtifacts(ws: string, rootRunId: string): Artifact[] {
13
+ const handles = new Set<string>();
14
+ const seen = new Set<string>();
15
+
16
+ function walk(runId: string) {
17
+ if (seen.has(runId)) return;
18
+ seen.add(runId);
19
+ const trace = readTrace(ws, runId);
20
+ if (!trace) return;
21
+ // Collect from both artifacts (produced) and outputArtifacts (handed up).
22
+ for (const h of trace.artifacts) handles.add(typeof h === "string" ? h : artifactHandle(h));
23
+ for (const h of trace.outputArtifacts) handles.add(typeof h === "string" ? h : artifactHandle(h));
24
+ // Recurse into delegated children.
25
+ for (const childId of trace.delegatedOut) walk(childId);
26
+ }
27
+
28
+ walk(rootRunId);
29
+
30
+ // Resolve handles to envelopes, de-dup by id (keep latest version), order by created desc.
31
+ const byId = new Map<string, Artifact>();
32
+ for (const h of handles) {
33
+ const a = readArtifact(ws, h);
34
+ if (!a) continue;
35
+ const existing = byId.get(a.id);
36
+ if (!existing || a.version > existing.version) byId.set(a.id, a);
37
+ }
38
+
39
+ return [...byId.values()].sort((x, y) => y.created.localeCompare(x.created));
40
+ }
@@ -0,0 +1,160 @@
1
+ /** Plan 05 Phase 3 — cross-turn (boot-replay) compaction, unblocked by Plan 01 Phase 5's seam.
2
+ *
3
+ * Plan 05 Phase 2 bounds context growth WITHIN a run; this bounds it ACROSS turns. `thread.jsonl`
4
+ * replayed EVERY completed turn at boot, forever — a long-lived squad accumulated conversation
5
+ * history until the assembled prompt no longer fit. Boot replay now becomes a rolling conversation
6
+ * SUMMARY (older turns, folded deterministically) + the recent-K turns kept VERBATIM.
7
+ *
8
+ * Two invariants, both load-bearing:
9
+ * 1. The LEDGER (`conversations/<agent>/ledger.jsonl`) is append-only TRUTH — compaction changes
10
+ * what REPLAYS, never what is RECORDED. `thread.jsonl` is a DERIVED cache, REBUILT from the
11
+ * ledger's INCLUDED turns each time a completed turn is recorded through the seam (so it is
12
+ * deterministic — the same ledger always yields the same replay; no drift).
13
+ * 2. Replay carries artifact HANDLES + summaries, never inlined BODIES (Plan 01 Phase 5) — each
14
+ * produced handle is resolved to `[id@vN] title — summary` via `readArtifact` (envelope only;
15
+ * `readArtifactBody` is never called on the replay path), so payloads can't re-enter context.
16
+ *
17
+ * Reuses the Plan 05 in-run machinery where it fits: the `estimateTokens` estimator and the
18
+ * marker-led summary shape mirror `core/compaction.ts` (a `[…COMPACTION]` marker keeps the fold
19
+ * VISIBLE in the replayed prompt). The FOLD itself is turn-aware (a conversation is user/assistant
20
+ * pairs, not tool round-trips), so it does not reuse `compactMessages`' round-trip grouping. */
21
+ import type { ModelMessage } from "ai";
22
+ import { loadContext, loadLedger } from "../store/conversation";
23
+ import { writeThread } from "../store/thread";
24
+ import { readArtifact } from "../store/artifacts";
25
+ import { artifactHandle } from "@taicho-ai/contracts/artifact";
26
+ import { estimateTokens } from "@taicho-ai/agent";
27
+ import { log } from "./logger";
28
+
29
+ /** How many recent conversation turns (a turn = one user message) replay VERBATIM; older ones fold
30
+ * into the rolling summary. Config disposes via `defaults.replayKeepTurns`; model never supplies it. */
31
+ export const DEFAULT_REPLAY_KEEP_TURNS = 6;
32
+
33
+ const SUMMARY_MARKER = "[CONVERSATION COMPACTION]";
34
+ const MAX_SUMMARY_CHARS = 2000;
35
+ const TURN_GIST_CHARS = 220;
36
+ // Headroom reserved (chars) for the header + a possible one-line elision note, so the character budget
37
+ // below never truncates either of them mid-string.
38
+ const SUMMARY_RESERVE_CHARS = 140;
39
+
40
+ export interface ReplayCompaction {
41
+ messages: ModelMessage[]; // [summary message?] + recent-K turns VERBATIM
42
+ summaryText?: string; // the rolling summary body (also the head message content); undefined ⇒ nothing folded
43
+ foldedTurns: number; // how many user-turns collapsed into the summary
44
+ }
45
+
46
+ function contentToText(content: unknown): string {
47
+ return typeof content === "string" ? content : JSON.stringify(content ?? "");
48
+ }
49
+
50
+ /** A one-line, deterministic gist of a message's text (whitespace-collapsed, capped). */
51
+ function gist(content: unknown): string {
52
+ return contentToText(content).replace(/\s+/g, " ").trim().slice(0, TURN_GIST_CHARS);
53
+ }
54
+
55
+ /** Build the visible, deterministic rolling-summary body from the folded (older) messages. No model
56
+ * call — pure extraction, mirroring core/compaction.ts's marker-led shape. Pairs each folded user
57
+ * message with the assistant reply that follows it.
58
+ *
59
+ * Bounded by CHARACTERS (not a fixed line count), filling from the MOST RECENT folded turns — the ones
60
+ * closest to the kept verbatim tail, so most relevant for continuity. Any older overflow is NOTED with
61
+ * an explicit elision line, never silently dropped. (Plan 05 Ph3 follow-up: the old code capped at the
62
+ * OLDEST ~39 gists and lost every folded turn after them — the more-recent, more-relevant folded turns
63
+ * vanished without a trace. Now every folded turn is either gisted or accounted for in the count.) */
64
+ function buildReplaySummary(folded: ModelMessage[], foldedTurns: number): string {
65
+ const header =
66
+ `${SUMMARY_MARKER} Folded ${foldedTurns} earlier turn${foldedTurns === 1 ? "" : "s"} of this ` +
67
+ `conversation. The recent turns replay verbatim below; the ledger keeps the full record. ` +
68
+ `Any artifacts are referenced by handle (read_artifact for the body).`;
69
+ // One gist line per folded USER turn (paired with the assistant reply that followed it), oldest→newest.
70
+ const gists: string[] = [];
71
+ for (let i = 0; i < folded.length; i++) {
72
+ const m = folded[i];
73
+ if (m.role !== "user") continue;
74
+ const you = gist(m.content);
75
+ const next = folded[i + 1];
76
+ const reply = next && next.role === "assistant" ? gist(next.content) : "";
77
+ gists.push(reply ? `· you: ${you} → ${reply}` : `· you: ${you}`);
78
+ }
79
+ // Fill from the newest folded gist backwards while the character budget (minus header/elision reserve)
80
+ // holds; whatever doesn't fit is the OLDEST overflow and is reported, not lost.
81
+ const kept: string[] = [];
82
+ let used = header.length + SUMMARY_RESERVE_CHARS;
83
+ for (let i = gists.length - 1; i >= 0; i--) {
84
+ if (used + gists[i]!.length + 1 > MAX_SUMMARY_CHARS) break;
85
+ kept.unshift(gists[i]!);
86
+ used += gists[i]!.length + 1;
87
+ }
88
+ const elided = gists.length - kept.length;
89
+ const lines = [header];
90
+ if (elided > 0) {
91
+ lines.push(`· (… ${elided} older folded turn${elided === 1 ? "" : "s"} elided — full record in the ledger …)`);
92
+ }
93
+ lines.push(...kept);
94
+ return lines.join("\n");
95
+ }
96
+
97
+ /** Deterministically fold the OLDEST turns into ONE rolling-summary message; keep the recent
98
+ * `keepRecentTurns` turns (counted by user message) VERBATIM. Returns the input unchanged (folded
99
+ * 0) when there are not more turns than the kept tail. Pure — no I/O, no model call. */
100
+ export function compactReplay(messages: ModelMessage[], keepRecentTurns: number): ReplayCompaction {
101
+ const keep = Math.max(0, keepRecentTurns);
102
+ const userIdxs = messages.map((m, i) => (m.role === "user" ? i : -1)).filter((i) => i >= 0);
103
+ if (userIdxs.length <= keep) return { messages, foldedTurns: 0 };
104
+ const cut = keep === 0 ? messages.length : userIdxs[userIdxs.length - keep];
105
+ const older = messages.slice(0, cut);
106
+ const tail = messages.slice(cut);
107
+ const foldedTurns = userIdxs.length - keep;
108
+ const summaryText = buildReplaySummary(older, foldedTurns);
109
+ const summaryMsg: ModelMessage = { role: "user", content: summaryText };
110
+ return { messages: [summaryMsg, ...tail], summaryText, foldedTurns };
111
+ }
112
+
113
+ /** Resolve produced handles to a compact `[id@vN] title — summary` reference string. Reads the
114
+ * ENVELOPE only (readArtifact) — the body is never inlined, which is the whole point of Plan 01. */
115
+ function artifactRefs(ws: string, handles: string[]): string {
116
+ const parts: string[] = [];
117
+ for (const h of handles) {
118
+ const a = readArtifact(ws, h);
119
+ if (!a) { parts.push(`[${h}] (unavailable)`); continue; }
120
+ parts.push(`[${artifactHandle(a)}] ${a.title}${a.summary ? " — " + a.summary : ""}`);
121
+ }
122
+ return parts.join("; ");
123
+ }
124
+
125
+ /** Reconstruct the replay message list from the ledger's INCLUDED turns (Plan 01 Ph5 decision C:
126
+ * ledger = truth, context.json = which turns are safe to replay). An assistant turn that produced
127
+ * artifacts gets a by-reference handle+summary block appended — handles, never bodies. */
128
+ export function buildReplayMessages(ws: string, agent: string): ModelMessage[] {
129
+ const included = new Set(loadContext(ws, agent).includedTurns.map((t) => t.turnId));
130
+ const messages: ModelMessage[] = [];
131
+ for (const turn of loadLedger(ws, agent)) {
132
+ if (!included.has(turn.turnId)) continue;
133
+ if (turn.role !== "user" && turn.role !== "assistant") continue;
134
+ let content = contentToText(turn.content);
135
+ if (turn.role === "assistant" && turn.artifacts?.length) {
136
+ const refs = artifactRefs(ws, turn.artifacts);
137
+ if (refs) content = `${content}\n\n[artifacts produced — by reference, read_artifact for the body] ${refs}`;
138
+ }
139
+ messages.push({ role: turn.role, content });
140
+ }
141
+ return messages;
142
+ }
143
+
144
+ /** Rebuild the DERIVED boot-replay cache (`thread.jsonl`) from the ledger, compacting older turns
145
+ * into the rolling summary. Called through the `recordTurnOutcome` seam (Plan 05 Ph3): the summary
146
+ * is written HERE, not recorded in the ledger — the ledger stays append-only truth. Returns the
147
+ * compaction so the caller can surface it. */
148
+ export function rebuildReplayCache(ws: string, agent: string, opts?: { keepRecentTurns?: number }): ReplayCompaction {
149
+ const keep = opts?.keepRecentTurns != null && opts.keepRecentTurns >= 0 ? opts.keepRecentTurns : DEFAULT_REPLAY_KEEP_TURNS;
150
+ const full = buildReplayMessages(ws, agent);
151
+ const compacted = compactReplay(full, keep);
152
+ writeThread(ws, agent, compacted.messages);
153
+ if (compacted.foldedTurns > 0) {
154
+ log.debug(
155
+ `conversation replay compacted for ${agent}: folded ${compacted.foldedTurns} turn(s), ` +
156
+ `${estimateTokens(contentToText(compacted.summaryText ?? ""))} summary tok, ${keep} kept verbatim`,
157
+ );
158
+ }
159
+ return compacted;
160
+ }
@@ -0,0 +1,97 @@
1
+ /** Plan 09: cross-session cost rollup for `/costs`. Reads run TRACES (the same source /runs and
2
+ * /trace use) and aggregates each run's OWN spend by agent, day, and model — never trace.aggregate
3
+ * (which folds in child runs, so summing across the list would double-count). Each run's OWN spend =
4
+ * its primary loop (tokens/costUsd) PLUS its delegation-checker (verifierTokens/verifierCostUsd): the
5
+ * verifier makes real, metered model calls but writes NO child trace, so adding its spend here counts
6
+ * it exactly once. HONESTY RULE: a subscription run records costUsd:null (unmeasurable) — its TOKENS
7
+ * (loop + verifier) are always reported, and it is NEVER counted as $0 nor mixed into a USD total.
8
+ * Tokens are the hard, always-honest number; USD is a supplement shown only where genuinely priced.
9
+ * SCOPE: /costs covers RUN TRACES — the agent loop and its delegation verifier. The `/teach` coaching
10
+ * distiller (src/coaching/teach.ts) runs OUTSIDE any run and produces no trace; its spend IS metered
11
+ * against the squad ceiling (spend-ledger.ts) but is not itemized here (there is no trace to attach it to). */
12
+ import type { RunTrace } from "@taicho-ai/contracts/trace";
13
+
14
+ export interface CostGroup {
15
+ key: string;
16
+ runs: number;
17
+ tokens: number;
18
+ costUsd: number; // sum over PRICED runs only (costUsd is a finite number)
19
+ subscriptionRuns: number; // runs with costUsd:null — USD unmeasurable, reported as tokens
20
+ }
21
+
22
+ export interface CostRollup {
23
+ totals: CostGroup;
24
+ byAgent: CostGroup[];
25
+ byDay: CostGroup[];
26
+ byModel: CostGroup[];
27
+ }
28
+
29
+ function blank(key: string): CostGroup {
30
+ return { key, runs: 0, tokens: 0, costUsd: 0, subscriptionRuns: 0 };
31
+ }
32
+
33
+ function accrue(g: CostGroup, t: RunTrace): void {
34
+ g.runs += 1;
35
+ // Own spend = primary loop + this run's delegation verifier (no child trace holds the verifier's
36
+ // spend, so it's counted exactly once here).
37
+ g.tokens += t.tokens + t.verifierTokens;
38
+ if (t.costUsd == null) g.subscriptionRuns += 1; // subscription/unmeasurable — tokens only, never a $0
39
+ else g.costUsd += t.costUsd + t.verifierCostUsd; // priced run ⇒ include the verifier's USD too
40
+ }
41
+
42
+ /** The "by provider" dimension. We record the resolved model id in the trace; group by it, falling
43
+ * back to the costNote (subscription) or "unknown" for pre-Plan-09 / headless traces without one. */
44
+ function modelKey(t: RunTrace): string {
45
+ return t.model ?? (t.costNote === "subscription" ? "subscription" : "unknown");
46
+ }
47
+
48
+ function groupBy(traces: RunTrace[], keyOf: (t: RunTrace) => string): CostGroup[] {
49
+ const map = new Map<string, CostGroup>();
50
+ for (const t of traces) {
51
+ const k = keyOf(t);
52
+ let g = map.get(k);
53
+ if (!g) { g = blank(k); map.set(k, g); }
54
+ accrue(g, t);
55
+ }
56
+ // Highest-spend first (by tokens — the always-present number).
57
+ return [...map.values()].sort((a, b) => b.tokens - a.tokens);
58
+ }
59
+
60
+ export function rollupCosts(traces: RunTrace[]): CostRollup {
61
+ const totals = blank("total");
62
+ for (const t of traces) accrue(totals, t);
63
+ return {
64
+ totals,
65
+ byAgent: groupBy(traces, (t) => t.agent),
66
+ byDay: groupBy(traces, (t) => t.started.slice(0, 10)),
67
+ byModel: groupBy(traces, modelKey),
68
+ };
69
+ }
70
+
71
+ /** Render one group line: always tokens; USD only when some priced spend exists; a subscription note
72
+ * when any run in the group was unmeasurable. Never prints "$0.00" as if it were a real measured cost. */
73
+ function fmtGroup(g: CostGroup, pad: number): string {
74
+ const usd = g.costUsd > 0 ? ` · $${g.costUsd.toFixed(4)}` : "";
75
+ const sub = g.subscriptionRuns > 0 ? ` · ${g.subscriptionRuns} subscription run(s) (tokens only)` : "";
76
+ return ` ${g.key.padEnd(pad)} ${g.runs} run(s) · ${g.tokens.toLocaleString()} tok${usd}${sub}`;
77
+ }
78
+
79
+ function section(title: string, groups: CostGroup[]): string[] {
80
+ if (!groups.length) return [];
81
+ const pad = Math.min(28, Math.max(...groups.map((g) => g.key.length)));
82
+ return [` ${title}:`, ...groups.map((g) => fmtGroup(g, pad))];
83
+ }
84
+
85
+ /** Format the rollup into REPL lines. Leads with the honest total (tokens), then by agent/day/model. */
86
+ export function formatCostRollup(r: CostRollup): string[] {
87
+ if (r.totals.runs === 0) return [" (no runs yet — nothing to cost)"];
88
+ const t = r.totals;
89
+ const usd = t.costUsd > 0 ? `, $${t.costUsd.toFixed(4)} priced` : "";
90
+ const sub = t.subscriptionRuns > 0 ? `, ${t.subscriptionRuns} subscription run(s) (tokens only — no USD)` : "";
91
+ return [
92
+ ` total: ${t.runs} run(s), ${t.tokens.toLocaleString()} tok${usd}${sub}`,
93
+ ...section("by agent", r.byAgent),
94
+ ...section("by day", r.byDay),
95
+ ...section("by model", r.byModel),
96
+ ];
97
+ }
@@ -0,0 +1,22 @@
1
+ /** Keyword discovery over the registry index — scales to large rosters via one SQL scan upstream.
2
+ * Semantic embeddings (store/vectors.ts) are a deferred upgrade. */
3
+ import type { RegistryRow } from "../store/roster";
4
+
5
+ export interface AgentHit { id: string; role: string; score: number; }
6
+
7
+ const tokenize = (s: string): string[] => s.toLowerCase().match(/[a-z0-9]+/g) ?? [];
8
+
9
+ export function rankAgents(rows: RegistryRow[], query: string, k: number): AgentHit[] {
10
+ const q = new Set(tokenize(query));
11
+ if (q.size === 0) return [];
12
+ const scored: AgentHit[] = [];
13
+ for (const r of rows) {
14
+ if (r.is_root) continue;
15
+ const terms = tokenize(`${r.id} ${r.role}`);
16
+ let overlap = 0;
17
+ for (const t of terms) if (q.has(t)) overlap++;
18
+ if (overlap > 0) scored.push({ id: r.id, role: r.role, score: overlap });
19
+ }
20
+ scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
21
+ return scored.slice(0, k);
22
+ }
@@ -0,0 +1,6 @@
1
+ /** Merge a proposal card's edited field strings over the original draft (non-empty overrides only). */
2
+ export function mergeDraft<T extends Record<string, unknown>>(original: T, edits: Record<string, string>): T {
3
+ const out: Record<string, unknown> = { ...original };
4
+ for (const [k, v] of Object.entries(edits)) if (v != null && v !== "") out[k] = v;
5
+ return out as T;
6
+ }