@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,315 @@
1
+ /** Deterministic model used only by real-binary e2e tests.
2
+ * It lets tui-test drive the compiled CLI through multi-agent flows without external network.
3
+ *
4
+ * Plan 07: the loop now unifies on `streamText` for EVERY provider, so the AI SDK calls a model's
5
+ * `doStream` (not `doGenerate`). This model therefore implements `doStream` — emitting the same
6
+ * text / tool-call it used to return from `doGenerate`, drained to completion by the loop. */
7
+ import { MockLanguageModelV3 } from "ai/test";
8
+ import { simulateReadableStream } from "ai";
9
+ import type { Model } from "./model";
10
+
11
+ // Raw provider-level usage (LanguageModelV3Usage: `{ inputTokens: { total }, outputTokens: { total } }`);
12
+ // the SDK normalizes it to the user-facing `{ inputTokens, outputTokens }` the loop meters.
13
+ const usage = { inputTokens: { total: 3 }, outputTokens: { total: 2 } } as const;
14
+
15
+ // A doStream result: the given LanguageModelV3 stream parts. `initialDelayInMs` holds the FIRST
16
+ // chunk back (the whole call stays in-flight that long) — used by the slow-mode scenarios below to
17
+ // keep an agent visibly live long enough for VHS to freeze-frame its pane + bar segment.
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ function stream(chunks: unknown[], initialDelayInMs = 0): any {
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ return { stream: simulateReadableStream({ initialDelayInMs, chunkDelayInMs: 0, chunks: chunks as any }) };
22
+ }
23
+
24
+ // Streamed text response: start → one delta carrying the whole text → end → finish(stop).
25
+ // `delayMs` (default 0) holds the model call in-flight before ANY output — the loop has already
26
+ // emitted `model_start` (→ live "thinking" status) by then, so the agent renders as a live pane +
27
+ // bar segment for the whole delay. Plan 12: there is no loop-level watchdog; the only deadline is the
28
+ // provider fetch's transport timeout (120s default), which this mock never touches. Deterministic
29
+ // (fixed delay, no network).
30
+ function text(t: string, delayMs = 0) {
31
+ return stream([
32
+ { type: "stream-start", warnings: [] },
33
+ { type: "text-start", id: "t" },
34
+ { type: "text-delta", id: "t", delta: t },
35
+ { type: "text-end", id: "t" },
36
+ { type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage },
37
+ ], delayMs);
38
+ }
39
+
40
+ // Streamed tool call: a single tool-call part → finish(tool-calls). The loop drains it, counts the
41
+ // call, executes the tool, and loops.
42
+ function call(name: string, input: object) {
43
+ return stream([
44
+ { type: "stream-start", warnings: [] },
45
+ { type: "tool-call", toolCallId: "c1", toolName: name, input: JSON.stringify(input) },
46
+ { type: "finish", finishReason: { unified: "tool-calls", raw: "tool_use" }, usage },
47
+ ]);
48
+ }
49
+
50
+ /** agent-flow: create_agent → approve → delegate_task → roll the child's proof back up. */
51
+ function agentFlowModel(): Model {
52
+ let n = 0;
53
+ return new MockLanguageModelV3({
54
+ provider: "taicho-e2e",
55
+ modelId: "agent-flow",
56
+ doStream: async () => {
57
+ n += 1;
58
+ if (n === 1) return call("create_agent", {
59
+ id: "proof-agent",
60
+ role: "Proof worker",
61
+ identity: "You are proof-agent. Complete delegated work with a concise proof message.",
62
+ });
63
+ if (n === 2) return text("Created proof-agent.");
64
+ if (n === 3) return call("delegate_task", {
65
+ to: "proof-agent",
66
+ goal: "Produce proof that the created agent was used.",
67
+ });
68
+ if (n === 4) return text("proof-agent completed delegated work");
69
+ return text("Root used proof-agent: proof-agent completed delegated work");
70
+ },
71
+ }) as unknown as Model;
72
+ }
73
+
74
+ /** conversation-audit: the run gets one quick, offline tool cycle, then the NEXT model call HANGS
75
+ * until the run's abort signal fires — so the run is genuinely in-flight and an Esc mid-run marks it
76
+ * `interrupted` (not a race with an instant return). On abort we error the stream with the signal's
77
+ * reason (a real AbortError), which the loop treats as a clean cancellation — the same
78
+ * interrupted-turn path the tui-test drives, but deterministic under vhs.
79
+ *
80
+ * Under the unified streaming path (Plan 07) the model implements `doStream`: the hang is a
81
+ * ReadableStream that emits nothing and only errors when the abort fires (immediate, not on the
82
+ * idle-timeout grace), so the interrupt stays deterministic.
83
+ *
84
+ * Why the first call is a `find_skills` tool call (not an immediate hang): it makes the loop finish
85
+ * one full model→tool cycle BEFORE hanging, which (a) writes model_request/model_response/tool_call
86
+ * to `transcript.jsonl` so the interrupted turn still has a transcript (the tui-test asserts this),
87
+ * and (b) emits a "↳ root → find_skills()" breadcrumb the tape gates the Esc on — a deterministic
88
+ * screen signal that the run has moved past first-turn workspace setup into a hanging model call, so
89
+ * no load-bearing fixed Sleep is needed. `find_skills` is granted to every agent and runs offline
90
+ * (keyword ranking over the seeded skills — no network, no approval, no side effects). */
91
+ function conversationAuditModel(): Model {
92
+ let n = 0;
93
+ return new MockLanguageModelV3({
94
+ provider: "taicho-e2e",
95
+ modelId: "conversation-audit",
96
+ doStream: async (options: { abortSignal?: AbortSignal }) => {
97
+ n += 1;
98
+ if (n === 1) return call("find_skills", { query: "delegate work to a worker agent" });
99
+ const signal = options.abortSignal;
100
+ // A stream that emits nothing and errors only when the run aborts. consumeStream then settles,
101
+ // the loop surfaces the abort, and the top-of-iteration abort check marks the turn interrupted.
102
+ const hanging = new ReadableStream({
103
+ start(controller) {
104
+ const onAbort = () =>
105
+ controller.error(signal?.reason ?? new Error("aborted by e2e conversation-audit model"));
106
+ if (signal?.aborted) { onAbort(); return; }
107
+ signal?.addEventListener("abort", onAbort, { once: true });
108
+ },
109
+ });
110
+ return { stream: hanging };
111
+ },
112
+ }) as unknown as Model;
113
+ }
114
+
115
+ /** artifact-handoff (Plan 01): prove the hand-off store end-to-end through the real binary.
116
+ * root creates a researcher (agent A) and a writer (agent B), then wires A→B BY REFERENCE:
117
+ * - A (researcher) save_artifacts a dossier whose body carries a distinctive payload marker.
118
+ * - root delegates to B (writer) with inputArtifacts:[dossier@v1] — a HANDLE, not the body.
119
+ * - B read_artifacts the dossier (the consumer legitimately pulls it) and save_artifacts a brief
120
+ * linked back via parents:[dossier@v1].
121
+ * - root's own context only ever sees handles + thin summaries — the dossier BODY payload never
122
+ * enters root's transcript. The scenario asserts exactly that (parent context stays thin).
123
+ * One shared counter drives the interleaved root/child turns in execution order (delegate BLOCKS, so
124
+ * the child's turns run inline between the two root delegations — a deterministic linear script). */
125
+ export const DOSSIER_PAYLOAD = "DOSSIER_PAYLOAD_XYZZY_do_not_inline_into_the_parent_context";
126
+ function artifactHandoffModel(): Model {
127
+ let n = 0;
128
+ return new MockLanguageModelV3({
129
+ provider: "taicho-e2e",
130
+ modelId: "artifact-handoff",
131
+ doStream: async () => {
132
+ n += 1;
133
+ // root run 1 — create agent A (researcher)
134
+ if (n === 1) return call("create_agent", {
135
+ id: "researcher", role: "Researches topics and writes dossiers",
136
+ identity: "You are researcher. Produce a dossier artifact and hand it off by reference.",
137
+ });
138
+ if (n === 2) return text("Created researcher.");
139
+ // root run 2 — create agent B (writer)
140
+ if (n === 3) return call("create_agent", {
141
+ id: "writer", role: "Turns dossiers into briefs",
142
+ identity: "You are writer. Read the input dossier by reference and produce a brief.",
143
+ });
144
+ if (n === 4) return text("Created writer.");
145
+ // root run 3 — A produces, root wires A→B by reference, B consumes + derives
146
+ if (n === 5) return call("delegate_task", { to: "researcher", goal: "Produce a research dossier on foo." });
147
+ if (n === 6) return call("save_artifact", {
148
+ id: "dossier", title: "Foo dossier", type: "dossier",
149
+ summary: "a short summary of foo (safe to carry in context)",
150
+ body: `# Foo dossier\n\n${DOSSIER_PAYLOAD}\n\n(...the heavy body that must NOT pollute the parent...)`,
151
+ });
152
+ if (n === 7) return text("Saved dossier@v1.");
153
+ if (n === 8) return call("delegate_task", { to: "writer", goal: "Turn the dossier into a one-paragraph brief.", inputArtifacts: ["dossier@v1"] });
154
+ if (n === 9) return call("read_artifact", { id: "dossier@v1", includeBody: true });
155
+ if (n === 10) return call("save_artifact", { id: "brief", title: "Foo brief", type: "brief", summary: "the brief derived from the dossier", body: "Foo, briefly: it is what the dossier says.", parents: ["dossier@v1"] });
156
+ if (n === 11) return text("Wrote brief@v1 from dossier@v1.");
157
+ return text("Root wired researcher to writer by reference: brief@v1 from dossier@v1.");
158
+ },
159
+ }) as unknown as Model;
160
+ }
161
+
162
+ /** How long (ms) the squad-panes child holds its model call in-flight so its live pane + bar segment
163
+ * are on screen long enough for VHS to `Wait+Screen` + screenshot. Fixed default, overridable via
164
+ * TAICHO_E2E_SLOW_MS (deterministic — a duration, never a race). ~4s dwarfs VHS's poll interval and
165
+ * sits far under the provider fetch's 120s transport deadline (Plan 12; no loop-level watchdog). */
166
+ const SQUAD_PANES_SLOW_MS = Number(process.env.TAICHO_E2E_SLOW_MS ?? 4000);
167
+
168
+ /** How long (ms) the consistent-blocks child holds its model call in-flight so its live block + bar
169
+ * segment are on screen long enough for VHS to `Wait+Screen` + screenshot. Same rationale as above. */
170
+ const CONSISTENT_BLOCKS_SLOW_MS = Number(process.env.TAICHO_E2E_SLOW_MS ?? 4000);
171
+
172
+ /** How long (ms) the artifact-viewer child holds its model call in-flight so the completion bar +
173
+ * viewer are on screen long enough for VHS to `Wait+Screen` + screenshot. Same rationale as above. */
174
+ const ARTIFACT_VIEWER_SLOW_MS = Number(process.env.TAICHO_E2E_SLOW_MS ?? 4000);
175
+
176
+ /** squad-panes (Plan 10 Phase 5): the SLOW-MODE delegation that makes the split-pane view provable.
177
+ * Same shape as agent-flow (create proof-agent → approve → delegate → roll the proof up), but the
178
+ * child's single model call is HELD in-flight for SQUAD_PANES_SLOW_MS. During that window two agents
179
+ * are live at once — root `delegating` (its delegate_task tool is blocked on the child) and
180
+ * proof-agent `thinking` (its model call is running) — so taicho renders a pane + bar segment for
181
+ * each, long enough for VHS to freeze-frame the two-agents-in-panes+bar state before it completes.
182
+ * Without the hold the child returns sub-second and the pane flashes faster than a recorded frame. */
183
+ function squadPanesModel(): Model {
184
+ let n = 0;
185
+ return new MockLanguageModelV3({
186
+ provider: "taicho-e2e",
187
+ modelId: "squad-panes",
188
+ doStream: async () => {
189
+ n += 1;
190
+ // root run 1 — create proof-agent
191
+ if (n === 1) return call("create_agent", {
192
+ id: "proof-agent",
193
+ role: "Proof worker",
194
+ identity: "You are proof-agent. Complete delegated work with a concise proof message.",
195
+ });
196
+ if (n === 2) return text("Created proof-agent.");
197
+ // root run 2 — delegate (root stays `delegating`, blocked on the child below)
198
+ if (n === 3) return call("delegate_task", {
199
+ to: "proof-agent",
200
+ goal: "Produce proof that the created agent was used.",
201
+ });
202
+ // the child's only model call — SLOW: proof-agent stays visibly `thinking` while root delegates
203
+ if (n === 4) return text("proof-agent completed delegated work", SQUAD_PANES_SLOW_MS);
204
+ // root rolls the child's proof back up
205
+ return text("Root used proof-agent: proof-agent completed delegated work");
206
+ },
207
+ }) as unknown as Model;
208
+ }
209
+
210
+ /** consistent-blocks (Plan 13 corrected): the SLOW-MODE delegation that makes the consistent-blocks
211
+ * view provable. Same shape as squad-panes, but proves the consistent-blocks view: during the
212
+ * delegation, taicho renders a BLOCK (header + 2-line body) for each sub-agent, and the sub-agent's
213
+ * full reply text does NOT appear in scrollback. */
214
+ function consistentBlocksModel(): Model {
215
+ let n = 0;
216
+ return new MockLanguageModelV3({
217
+ provider: "taicho-e2e",
218
+ modelId: "consistent-blocks",
219
+ doStream: async () => {
220
+ n += 1;
221
+ // root run 1 — create proof-agent
222
+ if (n === 1) return call("create_agent", {
223
+ id: "proof-agent",
224
+ role: "Proof worker",
225
+ identity: "You are proof-agent. Complete delegated work with a concise proof message.",
226
+ });
227
+ if (n === 2) return text("Created proof-agent.");
228
+ // root run 2 — delegate (root stays `delegating`, blocked on the child below)
229
+ if (n === 3) return call("delegate_task", {
230
+ to: "proof-agent",
231
+ goal: "Produce proof that the created agent was used.",
232
+ });
233
+ // the child's only model call — SLOW: proof-agent stays visibly `thinking` while root delegates
234
+ if (n === 4) return text("proof-agent completed delegated work", CONSISTENT_BLOCKS_SLOW_MS);
235
+ // root rolls the child's proof back up
236
+ return text("Root used proof-agent: proof-agent completed delegated work");
237
+ },
238
+ }) as unknown as Model;
239
+ }
240
+
241
+ /** artifact-viewer (Plan 15): the SLOW-MODE delegation that makes the completion action bar + artifact
242
+ * viewer provable. Same shape as squad-panes, but the child saves an artifact (so the completion bar
243
+ * appears), and root names the handle (not the body). The tape opens the viewer to prove the markdown
244
+ * body renders on screen. */
245
+ function artifactViewerModel(): Model {
246
+ let n = 0;
247
+ return new MockLanguageModelV3({
248
+ provider: "taicho-e2e",
249
+ modelId: "artifact-viewer",
250
+ doStream: async () => {
251
+ n += 1;
252
+ // root run 1 — create proof-agent
253
+ if (n === 1) return call("create_agent", {
254
+ id: "proof-agent",
255
+ role: "Proof worker",
256
+ identity: "You are proof-agent. Save the deliverable as an artifact.",
257
+ });
258
+ if (n === 2) return text("Created proof-agent.");
259
+ // root run 2 — delegate (root stays `delegating`, blocked on the child below)
260
+ if (n === 3) return call("delegate_task", {
261
+ to: "proof-agent",
262
+ goal: "Save a proof document as an artifact.",
263
+ });
264
+ // the child saves an artifact — SLOW so the completion bar is visible
265
+ if (n === 4) return call("save_artifact", {
266
+ id: "proof-doc",
267
+ title: "Proof Document",
268
+ type: "document",
269
+ summary: "A proof that the artifact viewer works.",
270
+ body: "# Proof Document\n\nThis document proves the artifact viewer renders markdown bodies correctly.\n\n## Section 1\n\nThe completion action bar appeared after the run finished.\n\n## Section 2\n\nThe viewer opened on the newest artifact and rendered the full body.",
271
+ });
272
+ if (n === 5) return text("Saved proof-doc@v1.", ARTIFACT_VIEWER_SLOW_MS);
273
+ // root names the handle (not the body) — the completion bar makes it one keystroke away
274
+ return text("Done. See artifact proof-doc@v1.");
275
+ },
276
+ }) as unknown as Model;
277
+ }
278
+
279
+
280
+ /** plan-teams (Plan 18 + Plan 19): root opens a plan, delegates one item to a leadless TEAM (which the
281
+ * engine routes to the best-ranked member), the child works, and root finishes. Exercises, in one real
282
+ * binary run: the plan store, the tail-slot injection, team resolution, engine-owned correlation, and
283
+ * every OTel span the run emits. Used by scripts/otel-verify.ts against a real OTLP endpoint. */
284
+ function planTeamsModel(): Model {
285
+ let n = 0;
286
+ return new MockLanguageModelV3({
287
+ provider: "taicho-e2e",
288
+ modelId: "plan-teams",
289
+ doStream: async () => {
290
+ n += 1;
291
+ if (n === 1) return call("write_plan", {
292
+ goal: "ship the notifier",
293
+ items: [
294
+ { id: "it_survey", text: "survey the existing code" },
295
+ { id: "it_story", text: "file the announcement", assignee: "news" },
296
+ ],
297
+ });
298
+ if (n === 2) return call("update_plan_item", { itemId: "it_survey", status: "done" });
299
+ if (n === 3) return call("delegate_task", { to: "news", goal: "files stories on deadline", itemId: "it_story" });
300
+ if (n === 4) return text("Filed the announcement."); // the routed team member
301
+ return text("Plan complete: surveyed the code and filed the announcement.");
302
+ },
303
+ }) as unknown as Model;
304
+ }
305
+
306
+ export function createE2eModel(mode: string | undefined): Model | null {
307
+ if (mode === "agent-flow") return agentFlowModel();
308
+ if (mode === "plan-teams") return planTeamsModel();
309
+ if (mode === "conversation-audit") return conversationAuditModel();
310
+ if (mode === "artifact-handoff") return artifactHandoffModel();
311
+ if (mode === "squad-panes") return squadPanesModel();
312
+ if (mode === "consistent-blocks") return consistentBlocksModel();
313
+ if (mode === "artifact-viewer") return artifactViewerModel();
314
+ return null;
315
+ }
@@ -0,0 +1,221 @@
1
+ /** The KB embedder: turns text into a vector for semantic recall. Two optional, null-degrading
2
+ * backends (→ keyword+graph when absent): a LOCAL model (transformers.js all-MiniLM-L6-v2 — no API
3
+ * key) and OpenAI text-embedding-3-small (needs OPENAI_API_KEY). Config-selected; both loaded via
4
+ * RUNTIME dynamic import so neither is statically bundled into the single binary.
5
+ *
6
+ * The local backend runs the native ONNX model, which CANNOT bundle into a `bun build --compile`
7
+ * binary (native `onnxruntime-node` fails `dlopen` from bunfs). So it has two paths, tried in order:
8
+ * 1. IN-PROCESS — `import(pkg)` resolves from an ambient node_modules (i.e. `bun run dev`/source).
9
+ * 2. SIDECAR — the shipped binary has no node_modules, so we self-install the package into
10
+ * `~/.taicho/runtime` (once) and run the model in a spawned `bun`/`node` worker over stdin/stdout
11
+ * — exactly how taicho already spawns MCP servers. Native resolution works in a normal runtime.
12
+ * Any failure (no package manager, offline, spawn error) rejects → callers fall back to keyword+graph.
13
+ * Model weights cache in ~/.taicho/models; the sidecar package installs into ~/.taicho/runtime. */
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
17
+
18
+ export interface Embedder {
19
+ model: string;
20
+ dim: number;
21
+ embed: (text: string) => Promise<Float32Array>;
22
+ embedMany: (texts: string[]) => Promise<Float32Array[]>;
23
+ }
24
+
25
+ export type EmbedProvider = "off" | "local" | "openai";
26
+
27
+ const LOCAL = { pkg: "@huggingface/transformers", version: "4.2.0", model: "Xenova/all-MiniLM-L6-v2", dim: 384 };
28
+ const OPENAI = { pkg: "@ai-sdk/openai", model: "text-embedding-3-small", dim: 1536 };
29
+ const WORKER_FILE = "embed-worker.mjs";
30
+ const READY_TIMEOUT_MS = 180_000; // first start self-installs + downloads weights; generous, then fast.
31
+
32
+ /** Build the configured embedder, or null (→ keyword+graph). Default: openai when OPENAI_API_KEY is
33
+ * set, else local. Never throws — an unavailable backend degrades at embed()-time. */
34
+ export function createEmbedder(opts: { provider?: EmbedProvider; env?: Record<string, string | undefined>; home?: string } = {}): Embedder | null {
35
+ const env = opts.env ?? process.env;
36
+ const provider: EmbedProvider = opts.provider ?? (env.OPENAI_API_KEY ? "openai" : "local");
37
+ if (provider === "off") return null;
38
+ if (provider === "openai") return env.OPENAI_API_KEY ? openaiEmbedder() : null;
39
+ const home = opts.home ?? join(homedir(), ".taicho");
40
+ return localEmbedder(join(home, "models"), join(home, "runtime"));
41
+ }
42
+
43
+ function openaiEmbedder(): Embedder {
44
+ const embedMany = async (texts: string[]): Promise<Float32Array[]> => {
45
+ const { openai } = await import(OPENAI.pkg); // installed; dynamic keeps it out of the hot path
46
+ const { embedMany: aiEmbedMany } = await import("ai");
47
+ const { embeddings } = await aiEmbedMany({ model: openai.embedding(OPENAI.model), values: texts });
48
+ return embeddings.map((e: number[]) => new Float32Array(e));
49
+ };
50
+ return { model: OPENAI.model, dim: OPENAI.dim, embed: async (t) => (await embedMany([t]))[0]!, embedMany };
51
+ }
52
+
53
+ // --- local backend selection helpers (pure; unit-tested) --------------------------------------
54
+
55
+ /** Runtime to run the sidecar worker in — prefer bun, else node. null ⇒ neither on PATH. */
56
+ export function pickRuntime(which: (cmd: string) => string | null): "bun" | "node" | null {
57
+ return which("bun") ? "bun" : which("node") ? "node" : null;
58
+ }
59
+
60
+ /** Package manager to self-install the embedder — prefer bun, else npm. null ⇒ neither on PATH. */
61
+ export function pickPackageManager(which: (cmd: string) => string | null): "bun" | "npm" | null {
62
+ return which("bun") ? "bun" : which("npm") ? "npm" : null;
63
+ }
64
+
65
+ export function installArgs(pm: "bun" | "npm", spec: string): string[] {
66
+ return pm === "bun" ? ["add", spec] : ["install", spec, "--no-save", "--no-audit", "--no-fund"];
67
+ }
68
+
69
+ /** The sidecar worker: a standalone ESM module that loads the model and serves embeddings over
70
+ * stdin/stdout as newline-delimited JSON ({id,text} → {id,vec} | {id,error}). Runs in a normal
71
+ * bun/node process (native onnxruntime resolves), NOT inside the compiled binary. */
72
+ export const WORKER_SOURCE = `import { createInterface } from "node:readline";
73
+ import { pipeline, env } from "@huggingface/transformers";
74
+ if (process.env.TAICHO_MODELS_DIR) env.cacheDir = process.env.TAICHO_MODELS_DIR;
75
+ env.allowRemoteModels = true;
76
+ const extractor = await pipeline("feature-extraction", process.env.TAICHO_EMBED_MODEL);
77
+ process.stdout.write("READY\\n");
78
+ const rl = createInterface({ input: process.stdin });
79
+ for await (const line of rl) {
80
+ const t = line.trim();
81
+ if (!t) continue;
82
+ const { id, text } = JSON.parse(t);
83
+ try {
84
+ const out = await extractor(text, { pooling: "mean", normalize: true });
85
+ process.stdout.write(JSON.stringify({ id, vec: Array.from(out.data) }) + "\\n");
86
+ } catch (e) {
87
+ process.stdout.write(JSON.stringify({ id, error: String(e && e.message || e) }) + "\\n");
88
+ }
89
+ }
90
+ `;
91
+
92
+ // --- local backend orchestration --------------------------------------------------------------
93
+
94
+ type EmbedFn = (text: string) => Promise<Float32Array>;
95
+
96
+ function localEmbedder(modelsDir: string, runtimeDir: string): Embedder {
97
+ let backend: Promise<EmbedFn> | null = null;
98
+ const load = () => (backend ??= startLocalBackend(modelsDir, runtimeDir));
99
+ const embed = async (t: string) => (await load())(t);
100
+ return { model: LOCAL.model, dim: LOCAL.dim, embed, embedMany: (ts) => Promise.all(ts.map(embed)) };
101
+ }
102
+
103
+ async function startLocalBackend(modelsDir: string, runtimeDir: string): Promise<EmbedFn> {
104
+ try {
105
+ return await inProcessBackend(modelsDir); // dev / source run: ambient node_modules
106
+ } catch {
107
+ return await sidecarBackend(modelsDir, runtimeDir); // shipped binary: self-installed worker
108
+ }
109
+ }
110
+
111
+ /** Load the model in-process. Works wherever the package resolves from an ambient node_modules
112
+ * (dev). In the compiled binary `import(pkg)` throws (module not found / dlopen) → sidecar path. */
113
+ async function inProcessBackend(modelsDir: string): Promise<EmbedFn> {
114
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
115
+ const tf: any = await import(LOCAL.pkg); // variable specifier ⇒ NOT statically bundled
116
+ tf.env.cacheDir = modelsDir;
117
+ tf.env.allowRemoteModels = true;
118
+ const extractor = await tf.pipeline("feature-extraction", LOCAL.model);
119
+ return async (t: string) => {
120
+ const out = await extractor(t, { pooling: "mean", normalize: true });
121
+ return new Float32Array(out.data as Float32Array);
122
+ };
123
+ }
124
+
125
+ /** Self-install the package into runtimeDir (once) + write the worker, then drive it as a subprocess. */
126
+ async function sidecarBackend(modelsDir: string, runtimeDir: string): Promise<EmbedFn> {
127
+ await ensureRuntime(runtimeDir);
128
+ const runtime = pickRuntime((c) => Bun.which(c));
129
+ if (!runtime) throw new Error("no bun/node on PATH to run the local embedder");
130
+ const client = await startWorker(runtime, join(runtimeDir, WORKER_FILE), runtimeDir, {
131
+ TAICHO_MODELS_DIR: modelsDir,
132
+ TAICHO_EMBED_MODEL: LOCAL.model,
133
+ });
134
+ return (t) => client.submit(t);
135
+ }
136
+
137
+ /** Ensure runtimeDir has @huggingface/transformers installed and a fresh worker script. The install
138
+ * is async (Bun.spawn, not spawnSync) so it never blocks the event loop / freezes the Ink TUI. */
139
+ async function ensureRuntime(runtimeDir: string): Promise<void> {
140
+ const pkgDir = join(runtimeDir, "node_modules", "@huggingface", "transformers");
141
+ if (!existsSync(pkgDir)) {
142
+ mkdirSync(runtimeDir, { recursive: true });
143
+ const manifest = join(runtimeDir, "package.json");
144
+ if (!existsSync(manifest)) writeFileSync(manifest, JSON.stringify({ name: "taicho-embed-runtime", private: true }) + "\n");
145
+ const pm = pickPackageManager((c) => Bun.which(c));
146
+ if (!pm) throw new Error("no bun/npm on PATH to install the local embedder");
147
+ const proc = Bun.spawn([pm, ...installArgs(pm, `${LOCAL.pkg}@${LOCAL.version}`)], { cwd: runtimeDir, stdout: "ignore", stderr: "pipe" });
148
+ const code = await proc.exited;
149
+ if (code !== 0) throw new Error("local embedder install failed: " + (await new Response(proc.stderr).text()).slice(-300));
150
+ }
151
+ writeFileSync(join(runtimeDir, WORKER_FILE), WORKER_SOURCE); // (re)write so it tracks this build
152
+ }
153
+
154
+ interface WorkerClient { submit: EmbedFn; close: () => void; }
155
+
156
+ /** Spawn the worker and speak newline-delimited JSON to it. Resolves once the worker signals READY
157
+ * (model loaded). Correlates concurrent requests by id; a worker exit rejects all pending + future
158
+ * calls so the embedder degrades to keyword+graph rather than hanging. */
159
+ function startWorker(runtime: string, workerPath: string, cwd: string, envVars: Record<string, string>): Promise<WorkerClient> {
160
+ const proc = Bun.spawn([runtime, workerPath], {
161
+ cwd, stdin: "pipe", stdout: "pipe", stderr: "pipe",
162
+ env: { ...process.env, ...envVars },
163
+ });
164
+ const pending = new Map<number, { resolve: (v: Float32Array) => void; reject: (e: Error) => void }>();
165
+ let nextId = 1;
166
+ let dead: Error | null = null;
167
+ let onReady!: () => void;
168
+ let onReadyFail!: (e: Error) => void;
169
+ const ready = new Promise<void>((res, rej) => { onReady = res; onReadyFail = rej; });
170
+
171
+ const failAll = (e: Error) => {
172
+ dead = e;
173
+ for (const p of pending.values()) p.reject(e);
174
+ pending.clear();
175
+ onReadyFail(e);
176
+ };
177
+
178
+ void (async () => {
179
+ const reader = proc.stdout.getReader();
180
+ const dec = new TextDecoder();
181
+ let buf = "";
182
+ let isReady = false;
183
+ for (;;) {
184
+ const chunk = await reader.read().catch(() => ({ done: true as const, value: undefined }));
185
+ if (chunk.done) break;
186
+ buf += dec.decode(chunk.value, { stream: true });
187
+ let nl: number;
188
+ while ((nl = buf.indexOf("\n")) >= 0) {
189
+ const line = buf.slice(0, nl).trim();
190
+ buf = buf.slice(nl + 1);
191
+ if (!line) continue;
192
+ if (!isReady) { if (line === "READY") { isReady = true; onReady(); } continue; }
193
+ try {
194
+ const msg = JSON.parse(line) as { id: number; vec?: number[]; error?: string };
195
+ const p = pending.get(msg.id);
196
+ if (!p) continue;
197
+ pending.delete(msg.id);
198
+ if (msg.error) p.reject(new Error(msg.error));
199
+ else p.resolve(new Float32Array(msg.vec!));
200
+ } catch { /* ignore non-JSON noise on stdout */ }
201
+ }
202
+ }
203
+ })();
204
+
205
+ void proc.exited.then((code) => { if (!dead) failAll(new Error(`embed worker exited (code ${code})`)); });
206
+
207
+ const timer = setTimeout(() => { if (!dead) { proc.kill(); failAll(new Error("embed worker did not become ready in time")); } }, READY_TIMEOUT_MS);
208
+ if (typeof timer.unref === "function") timer.unref();
209
+
210
+ const submit: EmbedFn = (text) => {
211
+ if (dead) return Promise.reject(dead);
212
+ const id = nextId++;
213
+ return new Promise<Float32Array>((resolve, reject) => {
214
+ pending.set(id, { resolve, reject });
215
+ proc.stdin.write(JSON.stringify({ id, text }) + "\n");
216
+ void proc.stdin.flush();
217
+ });
218
+ };
219
+
220
+ return ready.then(() => { clearTimeout(timer); return { submit, close: () => proc.kill() }; });
221
+ }
@@ -0,0 +1,133 @@
1
+ /** Reader-side helpers for the on-disk run event stream — the observable surface a headless or
2
+ * external observer watches (documented in `docs/events.md`).
3
+ *
4
+ * Each run writes `runs/<agent>/<recordId>/transcript.jsonl`: one JSON object per line, appended in
5
+ * order. This module reads that stream, finds the latest run, formats an event as a single human
6
+ * line, and provides a simple *tail* (print-then-follow) so an observer no longer has to poll files
7
+ * by hand. All output is passed through `redact()` so a tail can never leak auth material. */
8
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { paths } from "../store/files";
11
+ import type { RunTranscriptEvent } from "../store/run-transcript";
12
+ import { redact } from "./logger";
13
+
14
+ export type { RunTranscriptEvent } from "../store/run-transcript";
15
+
16
+ /** Parse a run's transcript.jsonl into events (skips blank/half-written trailing lines). */
17
+ export function readTranscript(ws: string, runId: string): RunTranscriptEvent[] {
18
+ const file = join(paths.runRecordDir(ws, runId), "transcript.jsonl");
19
+ if (!existsSync(file)) return [];
20
+ const out: RunTranscriptEvent[] = [];
21
+ for (const line of readFileSync(file, "utf8").split("\n")) {
22
+ if (!line.trim()) continue;
23
+ try { out.push(JSON.parse(line) as RunTranscriptEvent); } catch { /* skip a partially-flushed line */ }
24
+ }
25
+ return out;
26
+ }
27
+
28
+ /** All known run ids (`<agent>/<recordId>`), discovered from the per-agent trace JSON files. */
29
+ export function listRunIds(ws: string): string[] {
30
+ const runsDir = join(ws, "runs");
31
+ if (!existsSync(runsDir)) return [];
32
+ const ids: string[] = [];
33
+ for (const agent of readdirSync(runsDir)) {
34
+ const agentDir = join(runsDir, agent);
35
+ let entries: string[];
36
+ try { entries = readdirSync(agentDir); } catch { continue; }
37
+ for (const f of entries) {
38
+ if (f.endsWith(".json")) ids.push(`${agent}/${f.slice(0, -".json".length)}`);
39
+ }
40
+ }
41
+ return ids;
42
+ }
43
+
44
+ /** The most recently written run id, by trace-file mtime. Undefined when there are no runs. */
45
+ export function latestRunId(ws: string): string | undefined {
46
+ let best: { id: string; mtime: number } | undefined;
47
+ for (const id of listRunIds(ws)) {
48
+ const i = id.indexOf("/");
49
+ const traceFile = join(ws, "runs", id.slice(0, i), `${id.slice(i + 1)}.json`);
50
+ let mtime = 0;
51
+ try { mtime = statSync(traceFile).mtimeMs; } catch { /* ignore */ }
52
+ if (!best || mtime > best.mtime) best = { id, mtime };
53
+ }
54
+ return best?.id;
55
+ }
56
+
57
+ /** One truncated, redacted line for an event (used by the tail). Never assumes a payload shape. */
58
+ export function formatEvent(event: RunTranscriptEvent): string {
59
+ const iter = event.iteration != null ? ` iter ${event.iteration}` : "";
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+ const d = event.data as any;
62
+ let detail = "";
63
+ switch (event.kind) {
64
+ case "model_request":
65
+ detail = [d?.messageCount != null ? `${d.messageCount} msgs` : "", d?.contextTokens != null ? `~${d.contextTokens} tok` : "", d?.compacted ? "compacted" : ""].filter(Boolean).join(" · ");
66
+ break;
67
+ case "compaction":
68
+ detail = `folded ${d?.foldedRoundTrips ?? "?"} round-trip(s) / ${d?.foldedMessages ?? "?"} msgs · ${d?.before ?? "?"}→${d?.after ?? "?"} tok`;
69
+ break;
70
+ case "model_response": {
71
+ const text = typeof d?.text === "string" ? d.text.replace(/\s+/g, " ").trim() : "";
72
+ const calls = Array.isArray(d?.toolCalls) ? d.toolCalls.length : 0;
73
+ detail = [text ? `"${truncate(text, 60)}"` : "", calls ? `${calls} tool call(s)` : ""].filter(Boolean).join(" · ");
74
+ break;
75
+ }
76
+ case "model_error":
77
+ detail = typeof d?.error === "string" ? d.error : "";
78
+ break;
79
+ case "tool_call":
80
+ detail = d?.toolName ? `${d.toolName}(${truncate(JSON.stringify(d.input ?? {}), 60)})` : "";
81
+ break;
82
+ case "verification":
83
+ detail = d?.verdict ? `${d.verdict.pass ? "pass" : "FAIL"}${d.criteria ? ` — ${truncate(d.criteria, 50)}` : ""}` : "";
84
+ break;
85
+ default:
86
+ detail = d ? truncate(JSON.stringify(d), 60) : "";
87
+ }
88
+ return redact(`[${event.ts}]${iter} ${event.kind}${detail ? `: ${detail}` : ""}`);
89
+ }
90
+
91
+ function truncate(s: string, n: number): string {
92
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
93
+ }
94
+
95
+ /** Print a run's events then (optionally) follow appends. Returns when done (non-follow) or when the
96
+ * signal aborts (follow). Polls rather than fs.watch — robust on macOS and fine for a human tail. */
97
+ export async function tailRun(opts: {
98
+ ws: string;
99
+ runId?: string;
100
+ follow?: boolean;
101
+ out?: (line: string) => void;
102
+ intervalMs?: number;
103
+ signal?: AbortSignal;
104
+ /** In follow mode, wait up to this long for a not-yet-created transcript to appear. */
105
+ waitForMs?: number;
106
+ }): Promise<void> {
107
+ const out = opts.out ?? ((l: string) => process.stdout.write(l + "\n"));
108
+ const interval = opts.intervalMs ?? 250;
109
+ const runId = opts.runId ?? latestRunId(opts.ws);
110
+ if (!runId) { out("(no runs found)"); return; }
111
+
112
+ let printed = 0;
113
+ const flush = () => {
114
+ const events = readTranscript(opts.ws, runId);
115
+ for (const e of events.slice(printed)) out(formatEvent(e));
116
+ printed = events.length;
117
+ };
118
+
119
+ flush();
120
+ if (!opts.follow) return;
121
+
122
+ const deadline = Date.now() + (opts.waitForMs ?? 0);
123
+ while (!opts.signal?.aborted) {
124
+ await sleep(interval);
125
+ flush();
126
+ // If we've never seen an event and the grace window for a pending run elapsed, stop waiting.
127
+ if (printed === 0 && opts.waitForMs != null && Date.now() > deadline) break;
128
+ }
129
+ }
130
+
131
+ function sleep(ms: number): Promise<void> {
132
+ return new Promise((r) => setTimeout(r, ms));
133
+ }