@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,25 @@
1
+ /** Firecrawl scrape client: turn a URL into clean markdown. Used by the read_url agent tool so the
2
+ * model can read MCP setup docs. fetch is injectable for tests; the API key comes from the env only. */
3
+ const FIRECRAWL_SCRAPE_URL = "https://api.firecrawl.dev/v1/scrape";
4
+
5
+ export async function scrapeUrl(
6
+ url: string,
7
+ opts: { apiKey?: string; fetchImpl?: typeof fetch } = {},
8
+ ): Promise<{ markdown: string } | { error: string }> {
9
+ const apiKey = opts.apiKey ?? process.env.FIRECRAWL_API_KEY;
10
+ if (!apiKey) return { error: "FIRECRAWL_API_KEY is not set — set it to let agents read web pages." };
11
+ const fetchImpl = opts.fetchImpl ?? fetch;
12
+ try {
13
+ const res = await fetchImpl(FIRECRAWL_SCRAPE_URL, {
14
+ method: "POST",
15
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
16
+ body: JSON.stringify({ url, formats: ["markdown"] }),
17
+ });
18
+ if (!res.ok) return { error: `firecrawl ${res.status}: ${(await res.text()).slice(0, 200)}` };
19
+ const json = (await res.json()) as { data?: { markdown?: string } };
20
+ const markdown = json.data?.markdown;
21
+ return markdown ? { markdown } : { error: "firecrawl returned no markdown for this page" };
22
+ } catch (e) {
23
+ return { error: e instanceof Error ? e.message : String(e) };
24
+ }
25
+ }
@@ -0,0 +1,260 @@
1
+ /** Headless surface — drive `executeRun` WITHOUT Ink.
2
+ *
3
+ * `RunDeps` is already the seam (model / approval / onStep are all injectable), so a headless run is
4
+ * just `makeDeps` + `executeRun` with a non-interactive approval channel and stdout instead of a
5
+ * React tree. This powers `taicho run "<goal>"` and `taicho tail`, and makes real-binary e2e cheap
6
+ * (a scripted assertion, no VHS tape).
7
+ *
8
+ * APPROVAL CHANNEL (the one real design point). A headless run is unattended by definition — there
9
+ * is no captain watching an approval card. The DEFAULT is therefore **auto-reject**: the run
10
+ * proceeds, but every privileged action (create_agent, run_command, add_mcp, propose_skill,
11
+ * propose_coaching, ask_human) is declined and the model continues or reports it could not proceed.
12
+ * Rejecting is the safe default — auto-approving would let a model spawn agents or run shell
13
+ * commands with no human in the loop. Two opt-ins exist for trusted/interactive use:
14
+ * --approve auto → approve everything (scripted, trusted flows only)
15
+ * --approve prompt → ask on stdin (y/N per request; free-text answer for ask_human)
16
+ * All three are deterministic and never touch the network beyond the model call itself. */
17
+ import type { Database } from "bun:sqlite";
18
+ import { createInterface } from "node:readline";
19
+ import { makeDeps, executeRun, type Model, type RunDeps, type ApprovalRequest, type ApprovalDecision } from "./run";
20
+ import { loadAgent } from "../store/roster";
21
+ import { tailRun } from "./events";
22
+ import { log } from "./logger";
23
+ import type { Schedule } from "@taicho-ai/contracts/schedule";
24
+
25
+ export type ApprovalMode = "reject" | "approve" | "prompt";
26
+
27
+ /** The `runHeadless` options for an UNATTENDED scheduled fire. The `triggeredBy: "schedule:<id>"` marker
28
+ * is what EXCLUDES the fire from the target agent's conversation ledger + boot-replay cache (it still
29
+ * gets full run evidence) — see runHeadless's `triggeredBy` note. Constructed in ONE place so every fire
30
+ * site — the REPL (App.tsx), the boot CLI + `taicho schedule run` (index.tsx / schedule-cli), and the
31
+ * audit test — share the SAME wiring and cannot drift. Callers spread the result and add `out`/`signal`. */
32
+ export function scheduleFireOptions(s: Schedule): { goal: string; agent: string; approve: ApprovalMode; triggeredBy: string } {
33
+ return { goal: s.goal, agent: s.agent, approve: s.approve, triggeredBy: `schedule:${s.id}` };
34
+ }
35
+
36
+ export type CliCommand =
37
+ | { kind: "run"; goal: string; agent: string; approve: ApprovalMode }
38
+ | { kind: "tail"; runId?: string; follow: boolean }
39
+ // Plan 04 Phase 6: manage/fire durable schedules. `args` are the raw tokens after `schedule`
40
+ // (--verbose already stripped); parsed by parseScheduleCommand in scheduler.ts so the CLI and the
41
+ // REPL's /schedules command share one grammar.
42
+ | { kind: "schedule"; args: string[] }
43
+ // Plan 22: manage teams from the command line (`taicho team add|list|remove|member`). Raw tokens after
44
+ // `team`, parsed by runTeamCli — a captain-owned, model-free front door onto the same service layer.
45
+ | { kind: "team"; args: string[] };
46
+
47
+ export interface ParsedCli {
48
+ /** `--verbose`/`-v` anywhere on the line → raise the log level to debug. */
49
+ verbose: boolean;
50
+ /** null ⇒ no subcommand ⇒ launch the interactive Ink REPL. */
51
+ command: CliCommand | null;
52
+ }
53
+
54
+ function normalizeApprove(v: string | undefined): ApprovalMode {
55
+ if (v === "approve" || v === "auto" || v === "yes" || v === "y") return "approve";
56
+ if (v === "prompt" || v === "ask" || v === "stdin") return "prompt";
57
+ return "reject";
58
+ }
59
+
60
+ /** Parse raw `process.argv` into a CLI command. Scans for the first bare `run`/`tail` token (position
61
+ * > 0 so the executable path is never mistaken for a command); everything before it is the
62
+ * interpreter/executable path, everything after is the command's args. No subcommand ⇒ REPL. */
63
+ export function parseCli(argv: string[]): ParsedCli {
64
+ const verbose = argv.includes("--verbose") || argv.includes("-v");
65
+ const cmdIdx = argv.findIndex((a, i) => i > 0 && (a === "run" || a === "tail" || a === "schedule" || a === "team"));
66
+ if (cmdIdx < 0) return { verbose, command: null };
67
+
68
+ const cmd = argv[cmdIdx];
69
+ const rest = argv.slice(cmdIdx + 1).filter((a) => a !== "--verbose" && a !== "-v");
70
+
71
+ if (cmd === "schedule") return { verbose, command: { kind: "schedule", args: rest } };
72
+ if (cmd === "team") return { verbose, command: { kind: "team", args: rest } };
73
+
74
+ if (cmd === "run") {
75
+ let agent = "root";
76
+ let approve: ApprovalMode = "reject";
77
+ const positional: string[] = [];
78
+ for (let i = 0; i < rest.length; i++) {
79
+ const a = rest[i]!;
80
+ if (a === "--agent" || a === "-a") agent = rest[++i] ?? agent;
81
+ else if (a.startsWith("--agent=")) agent = a.slice("--agent=".length);
82
+ else if (a === "--approve") approve = normalizeApprove(rest[++i]);
83
+ else if (a.startsWith("--approve=")) approve = normalizeApprove(a.slice("--approve=".length));
84
+ else if (a === "--yes" || a === "-y") approve = "approve";
85
+ else positional.push(a);
86
+ }
87
+ return { verbose, command: { kind: "run", goal: positional.join(" ").trim(), agent, approve } };
88
+ }
89
+
90
+ // tail
91
+ let follow = false;
92
+ const positional: string[] = [];
93
+ for (const a of rest) {
94
+ if (a === "--follow" || a === "-f") follow = true;
95
+ else positional.push(a);
96
+ }
97
+ return { verbose, command: { kind: "tail", runId: positional[0], follow } };
98
+ }
99
+
100
+ /** Build the non-interactive approval channel for a headless run. */
101
+ export function makeApprovalChannel(
102
+ mode: ApprovalMode,
103
+ io?: { input?: NodeJS.ReadableStream; out?: (line: string) => void },
104
+ ): (req: ApprovalRequest) => Promise<ApprovalDecision> {
105
+ if (mode === "reject") return async () => ({ type: "reject" });
106
+
107
+ if (mode === "approve") {
108
+ return async (req) =>
109
+ req.kind === "ask_human"
110
+ ? { type: "answered", answer: req.options[0] ?? "yes" } // no human to ask; take the first offered option
111
+ : { type: "approve" };
112
+ }
113
+
114
+ // prompt: one line of stdin per request. On EOF / no TTY, degrade to reject (never hang unattended).
115
+ // `rl` is created once and reused across requests. A readline interface can't be reopened after its
116
+ // input hits EOF (which fires 'close'), so once closed every subsequent `ask` must short-circuit to
117
+ // "" (→ reject) rather than call `rl.question` on a closed interface — that throws ERR_USE_AFTER_CLOSE
118
+ // and would turn the documented "degrade to reject" into a headless crash on the 2nd+ request.
119
+ const out = io?.out ?? ((l: string) => process.stdout.write(l + "\n"));
120
+ const rl = createInterface({ input: io?.input ?? process.stdin });
121
+ let closed = false;
122
+ rl.once("close", () => { closed = true; });
123
+ const ask = (q: string) => new Promise<string>((resolve) => {
124
+ if (closed) { resolve(""); return; } // already EOF'd → degrade to reject; don't touch the closed rl
125
+ let settled = false;
126
+ const done = (v: string) => { if (!settled) { settled = true; resolve(v); } };
127
+ rl.question(q, done);
128
+ rl.once("close", () => done(""));
129
+ });
130
+ return async (req) => {
131
+ if (req.kind === "ask_human") {
132
+ out(`? ${req.question}${req.options.length ? ` [${req.options.join(" / ")}]` : ""}`);
133
+ const answer = (await ask("> ")).trim();
134
+ return answer ? { type: "answered", answer } : { type: "reject" };
135
+ }
136
+ out(`? approve ${req.kind}${describe(req)}`);
137
+ const yn = (await ask("[y/N] ")).trim().toLowerCase();
138
+ return yn === "y" || yn === "yes" ? { type: "approve" } : { type: "reject" };
139
+ };
140
+ }
141
+
142
+ function describe(req: ApprovalRequest): string {
143
+ switch (req.kind) {
144
+ case "create_agent": return `: ${req.draft.id ?? "(new agent)"}`;
145
+ case "run_command": return `: ${req.command}${req.cwd ? ` [cwd: ${req.cwd}]` : ""}`;
146
+ case "add_mcp": return `: ${req.name}`;
147
+ case "propose_skill": return `: ${req.draft.name}`;
148
+ default: return "";
149
+ }
150
+ }
151
+
152
+ export interface HeadlessDeps {
153
+ ws: string;
154
+ db: Database;
155
+ model: Model | null;
156
+ resolveModel?: RunDeps["resolveModel"];
157
+ priceUsd?: RunDeps["priceUsd"];
158
+ configDefaults?: RunDeps["configDefaults"];
159
+ mcp?: RunDeps["mcp"];
160
+ embed?: RunDeps["embed"];
161
+ spendLedger?: RunDeps["spendLedger"]; // Plan 09: squad-wide ceilings enforced in the loop
162
+ telemetry?: RunDeps["telemetry"]; // Plan 16: OpenTelemetry export (undefined ⇒ off)
163
+ }
164
+
165
+ export interface HeadlessResult {
166
+ ok: boolean;
167
+ runId?: string;
168
+ text: string;
169
+ outcome?: string;
170
+ tokens?: number;
171
+ costUsd?: number | null;
172
+ }
173
+
174
+ /** Drive one run to completion without Ink. Prints breadcrumbs + the final text to `out`, mirrors
175
+ * everything to `taicho.log`, and returns a structured result the caller uses to pick an exit code. */
176
+ export async function runHeadless(
177
+ hd: HeadlessDeps,
178
+ opts: {
179
+ goal: string;
180
+ agent?: string;
181
+ approve?: ApprovalMode;
182
+ // Defaults to "user": an EXPLICIT `taicho run "<goal>"` is a real user turn and is audited into the
183
+ // agent's conversation ledger + boot-replay cache identically to the REPL. Scheduler fires (cron /
184
+ // interval / watch, and `taicho schedule run`) pass a distinct `schedule:<id>` marker so the run
185
+ // still gets full run evidence (trace/transcript) but is EXCLUDED from the conversation audit —
186
+ // mirroring how a background dispatch cascade (triggeredBy = taskId) is excluded. Without this every
187
+ // autonomous fire polluted the ledger and replayed as prior "conversation" on the next launch.
188
+ triggeredBy?: string;
189
+ out?: (line: string) => void;
190
+ signal?: AbortSignal;
191
+ input?: NodeJS.ReadableStream;
192
+ },
193
+ ): Promise<HeadlessResult> {
194
+ const out = opts.out ?? ((l: string) => process.stdout.write(l + "\n"));
195
+ const agentId = opts.agent ?? "root";
196
+
197
+ if (!opts.goal.trim()) {
198
+ out('taicho: run needs a goal — taicho run "<goal>"');
199
+ return { ok: false, text: "" };
200
+ }
201
+ if (!hd.model) {
202
+ out("taicho: no credentials — set ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY, or run /login openai in the REPL.");
203
+ return { ok: false, text: "" };
204
+ }
205
+
206
+ const agent = await loadAgent(hd.ws, agentId).catch(() => null);
207
+ if (!agent) {
208
+ out(`taicho: no agent "${agentId}".`);
209
+ return { ok: false, text: "" };
210
+ }
211
+
212
+ const approve = opts.approve ?? "reject";
213
+ log.info(`headless run start`, { agent: agentId, approve, goal: opts.goal });
214
+ out(`taicho: ${agentId} · ${opts.goal} (approvals: ${approve})`);
215
+
216
+ const deps = makeDeps({
217
+ ws: hd.ws,
218
+ db: hd.db,
219
+ model: hd.model,
220
+ requestApproval: makeApprovalChannel(approve, { input: opts.input, out }),
221
+ onStep: ({ tool, agent: a, note }) => {
222
+ // No Ink to fight: breadcrumbs go to stdout AND the log. Streamed token deltas are logged at
223
+ // debug only (they'd spam a headless stdout).
224
+ if (note) { out(` ${note}`); log.info(`step note`, { agent: a, note }); return; }
225
+ if (tool) { out(` ↳ ${a} → ${tool}()`); log.debug(`tool`, { agent: a, tool }); }
226
+ },
227
+ resolveModel: hd.resolveModel,
228
+ priceUsd: hd.priceUsd,
229
+ configDefaults: hd.configDefaults,
230
+ mcp: hd.mcp,
231
+ embed: hd.embed,
232
+ spendLedger: hd.spendLedger,
233
+ telemetry: hd.telemetry,
234
+ signal: opts.signal,
235
+ });
236
+
237
+ const res = await executeRun(deps, {
238
+ agent,
239
+ messages: [{ role: "user", content: opts.goal }],
240
+ triggeredBy: opts.triggeredBy ?? "user",
241
+ });
242
+
243
+ const { outcome, tokens, costUsd } = res.trace;
244
+ out("");
245
+ out(res.text);
246
+ out("");
247
+ out(`taicho: ${outcome} · run ${res.runId} · ${tokens} tok · ${costUsd == null ? "subscription" : "$" + costUsd.toFixed(4)}`);
248
+ log.info(`headless run done`, { runId: res.runId, outcome, tokens });
249
+
250
+ return { ok: outcome === "completed", runId: res.runId, text: res.text, outcome, tokens, costUsd };
251
+ }
252
+
253
+ /** `taicho tail [runId] [--follow]` — stream a run's events (latest run when no id). */
254
+ export async function runTail(
255
+ ws: string,
256
+ cmd: { runId?: string; follow: boolean },
257
+ opts?: { out?: (line: string) => void; signal?: AbortSignal },
258
+ ): Promise<void> {
259
+ await tailRun({ ws, runId: cmd.runId, follow: cmd.follow, out: opts?.out, signal: opts?.signal });
260
+ }
@@ -0,0 +1,88 @@
1
+ /** Pure helpers for the instrumentation seam: a one-line, redacted, length-capped render of tool
2
+ * args ("read_url https://foo…", "run_command bun test") plus a capped JSON for the waterfall
3
+ * drill-in. Transparency WITHOUT payload dumping, and NEVER logs auth material (mirrors the
4
+ * redactAuthHeader discipline). Redaction is DEFENSE IN DEPTH: key-name masking (isSecretKey) for
5
+ * secret-named fields PLUS value-level scrubbing (logger.redact) so a token embedded in the VALUE of
6
+ * an ordinary field — `read_url {url:"…?api_key=sk-live-…"}`, `run_command {command:"curl -H
7
+ * 'Authorization: Bearer …'"}` — never surfaces in the status bar, breadcrumb, inspector, or the
8
+ * persisted transcript. Value scrubbing runs BEFORE the length cap so a truncation can't expose a
9
+ * partial secret. No I/O — unit-tested in isolation. */
10
+ import { redact } from "./logger";
11
+
12
+ const PREVIEW_CAP = 80;
13
+ const JSON_CAP = 2000;
14
+ /** Keys whose values are secrets/auth material and must never surface in a preview or a stored arg. */
15
+ const SECRET_KEY = /(^|_|-)(token|secret|password|passwd|authorization|auth|bearer|api[_-]?key|apikey|key|credential|cookie|session)s?($|_|-)/i;
16
+ const REDACTED = "‹redacted›";
17
+
18
+ /** Cap a string to `n` chars, collapsing whitespace/newlines to single spaces first (one-liner).
19
+ * Value-level secret scrubbing (logger.redact) runs BEFORE the cap so truncation can't leave a
20
+ * partial secret visible. */
21
+ export function oneLine(s: string, n = PREVIEW_CAP): string {
22
+ const flat = redact(s.replace(/\s+/g, " ").trim());
23
+ return flat.length > n ? flat.slice(0, n - 1) + "…" : flat;
24
+ }
25
+
26
+ /** True when a key names auth/secret material we must redact. */
27
+ export function isSecretKey(key: string): boolean {
28
+ return SECRET_KEY.test(key);
29
+ }
30
+
31
+ /** Deep-redact secret-keyed values in a plain object/array (returns a new value). Non-plain values
32
+ * (strings, numbers) pass through unchanged. */
33
+ export function redactValue(value: unknown): unknown {
34
+ if (Array.isArray(value)) return value.map(redactValue);
35
+ if (value && typeof value === "object") {
36
+ const out: Record<string, unknown> = {};
37
+ for (const [k, v] of Object.entries(value as Record<string, unknown>))
38
+ out[k] = isSecretKey(k) ? REDACTED : redactValue(v);
39
+ return out;
40
+ }
41
+ return value;
42
+ }
43
+
44
+ /** A stable one-line preview of a tool's args. Known tools get a hand-picked field; everything else
45
+ * falls back to a redacted `k=v` join. Always redacted, always ≤ PREVIEW_CAP chars. */
46
+ export function argsPreview(tool: string, args: unknown): string {
47
+ const a = (args && typeof args === "object" ? (args as Record<string, unknown>) : {}) as Record<string, unknown>;
48
+ const str = (k: string): string | undefined => (typeof a[k] === "string" ? (a[k] as string) : undefined);
49
+ let picked: string | undefined;
50
+ switch (tool) {
51
+ case "read_url": picked = str("url"); break;
52
+ case "run_command": picked = str("command"); break;
53
+ case "delegate_task":
54
+ case "dispatch_task": picked = [str("to"), str("goal")].filter(Boolean).join(": "); break;
55
+ case "read_artifact": picked = str("id"); break;
56
+ case "save_artifact":
57
+ case "write_artifact": picked = str("title") ?? str("topicSlug"); break;
58
+ case "list_artifacts": picked = str("q") ?? str("type") ?? str("producer"); break;
59
+ case "create_agent": picked = str("id"); break;
60
+ case "find_agents":
61
+ case "find_skills":
62
+ case "recall": picked = str("query"); break;
63
+ case "use_skill": picked = str("name"); break;
64
+ case "ask_human": picked = str("question"); break;
65
+ case "remember": picked = str("title"); break;
66
+ case "add_mcp_server": picked = str("name"); break;
67
+ }
68
+ if (picked && picked.trim()) return oneLine(picked);
69
+ // Generic fallback: redacted scalar k=v pairs.
70
+ const parts: string[] = [];
71
+ for (const [k, v] of Object.entries(a)) {
72
+ if (isSecretKey(k)) { parts.push(`${k}=${REDACTED}`); continue; }
73
+ if (v == null || typeof v === "object") continue; // skip nested/empty in the one-liner
74
+ parts.push(`${k}=${String(v)}`);
75
+ }
76
+ return parts.length ? oneLine(parts.join(" ")) : "";
77
+ }
78
+
79
+ /** A capped, redacted JSON render of a value for the waterfall drill-in (args in / result out).
80
+ * Two-layer redaction: key-name masking (redactValue) then value-level scrubbing (logger.redact)
81
+ * for secrets embedded in ordinary-keyed values. Scrubbing runs BEFORE the cap. */
82
+ export function capJson(value: unknown, cap = JSON_CAP): string {
83
+ let s: string | undefined;
84
+ try { s = JSON.stringify(redactValue(value)); } catch { s = String(value); }
85
+ if (s == null) return "";
86
+ s = redact(s);
87
+ return s.length > cap ? s.slice(0, cap) + `…[+${s.length - cap} chars]` : s;
88
+ }
@@ -0,0 +1,143 @@
1
+ /** Leveled, file-captured logging that does NOT fight Ink.
2
+ *
3
+ * Under the full-screen TUI, a stray console.error/warn write corrupts or vanishes behind the Ink
4
+ * render — so a run failure or a swallowed error left no trace the user could read. This logger
5
+ * writes structured lines to a FILE (`taicho.log` in the workspace) instead, so diagnostics survive
6
+ * a session and are greppable after the fact.
7
+ *
8
+ * Auth material is NEVER written: every line passes through `redact()`, the same Bearer/token
9
+ * scrub as `redactAuthHeader` (providers/openai-codex.ts) plus a few common API-key shapes. Kept
10
+ * dependency-free so it can be imported from any layer (store/, core/, providers/) without pulling
11
+ * a heavy provider into every module.
12
+ *
13
+ * A single process-wide default logger (`log`) backs the scattered call sites. Boot calls
14
+ * `configureLogger({ ws, level })` once it knows the workspace + verbosity (`--verbose` / env).
15
+ * Tests use `createLogger({ file })` for isolation. */
16
+ import { appendFileSync, mkdirSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { trace, context } from "@opentelemetry/api";
19
+
20
+ export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
21
+
22
+ /** Numeric severity — a message is emitted only when its level >= the logger's threshold. */
23
+ const ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40, silent: 100 };
24
+
25
+ export interface Logger {
26
+ level: LogLevel;
27
+ debug(msg: string, data?: unknown): void;
28
+ info(msg: string, data?: unknown): void;
29
+ warn(msg: string, data?: unknown): void;
30
+ error(msg: string, data?: unknown): void;
31
+ }
32
+
33
+ /** Scrub anything that looks like a bearer token or API key BEFORE it reaches disk. Mirrors
34
+ * `redactAuthHeader` (Bearer <token> → Bearer ***) and adds the common key/secret shapes an error
35
+ * string, serialized profile, tool arg, or URL might carry. Targeted on purpose — it must not maul
36
+ * ordinary prose. Beyond Bearer/`sk-`/named-JSON-field shapes it covers GitHub tokens (`ghp_`/`gho_`/
37
+ * `ghu_`/`ghs_`/`ghr_` and fine-grained `github_pat_…`), Slack tokens (`xox[baprs]-…`), and bare
38
+ * JWTs, so a gh-backed MCP or a new provider can't leak a credential shape the original rules miss.
39
+ * It ALSO scrubs secret-carrying URL query params (`?api_key=…`, `&token=…`) so a secret leaks even
40
+ * when it sits in the VALUE of an innocuously-named field. Each rule needs a distinctive prefix +
41
+ * long opaque body (or a query-param delimiter), so it stays off ordinary text. Reused by the
42
+ * instrumentation seam (instrument.ts) so waterfall/status-bar/transcript arg previews scrub too. */
43
+ export function redact(s: string): string {
44
+ return s
45
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer ***")
46
+ .replace(/\bsk-[A-Za-z0-9._-]{8,}/g, "sk-***") // OpenAI/Anthropic/OpenRouter: sk-, sk-ant-, sk-proj-, sk-or-
47
+ .replace(/\bgithub_pat_[A-Za-z0-9_]{20,}/g, "github_pat_***") // GitHub fine-grained PAT
48
+ .replace(/\bgh[oprsu]_[A-Za-z0-9]{20,}/g, (m) => m.slice(0, 4) + "***") // GitHub gho_/ghp_/ghr_/ghs_/ghu_ tokens
49
+ .replace(/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, (m) => m.slice(0, 5) + "***") // Slack xoxb-/xoxa-/xoxp-/xoxr-/xoxs- tokens
50
+ .replace(/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g, "eyJ***") // bare JWT: header.payload.signature
51
+ // URL/query-string secrets: the value leaks even under an innocuous key name (?api_key=…, &token=…).
52
+ .replace(/([?&](?:access_token|api[_-]?key|token|secret|sig|key)=)[^&\s#"'\\]+/gi, "$1***")
53
+ .replace(/"(access_token|refresh_token|id_token|api[_-]?key|apiKey|authorization)"\s*:\s*"[^"]*"/gi, '"$1":"***"');
54
+ }
55
+
56
+ /** Serialize the optional structured payload for the line tail. Errors render as message + name;
57
+ * circular structures degrade to "[unserializable]" rather than throwing (logging must not crash). */
58
+ function serialize(data: unknown): string {
59
+ if (data === undefined) return "";
60
+ if (data instanceof Error) return `${data.name}: ${data.message}`;
61
+ if (typeof data === "string") return data;
62
+ try {
63
+ return JSON.stringify(data);
64
+ } catch {
65
+ return "[unserializable]";
66
+ }
67
+ }
68
+
69
+ function format(level: LogLevel, msg: string, data?: unknown): string {
70
+ const tail = serialize(data);
71
+ const line = tail ? `${msg} :: ${tail}` : msg;
72
+ // Plan 17: correlate the execution log with OpenTelemetry. When a span is active (a run/model/tool
73
+ // is in flight), stamp its trace_id/span_id so a taicho.log line lines up with the exact span in
74
+ // Jaeger/LangSmith/etc. No-op when telemetry is off (no active span) or outside a run.
75
+ const sc = trace.getSpanContext(context.active());
76
+ const corr = sc ? ` trace_id=${sc.traceId} span_id=${sc.spanId}` : "";
77
+ return `${new Date().toISOString()} ${level.toUpperCase().padEnd(5)} ${redact(line)}${corr}\n`;
78
+ }
79
+
80
+ class FileLogger implements Logger {
81
+ level: LogLevel;
82
+ private file: string;
83
+ private sink?: (line: string) => void;
84
+ private ensured = false;
85
+
86
+ constructor(opts: { file?: string; level?: LogLevel; sink?: (line: string) => void }) {
87
+ this.level = opts.level ?? "info";
88
+ this.file = opts.file ?? defaultFile();
89
+ this.sink = opts.sink;
90
+ }
91
+
92
+ /** Re-point the file and/or level after boot resolves the workspace + verbosity. */
93
+ configure(opts: { ws?: string; file?: string; level?: LogLevel; sink?: (line: string) => void }): void {
94
+ if (opts.sink) this.sink = opts.sink;
95
+ if (opts.file) { this.file = opts.file; this.ensured = false; }
96
+ else if (opts.ws) { this.file = join(opts.ws, "taicho.log"); this.ensured = false; }
97
+ if (opts.level) this.level = opts.level;
98
+ }
99
+
100
+ private emit(level: LogLevel, msg: string, data?: unknown): void {
101
+ if (ORDER[level] < ORDER[this.level]) return;
102
+ const line = format(level, msg, data);
103
+ if (this.sink) { this.sink(line); return; }
104
+ try {
105
+ if (!this.ensured) { mkdirSync(dirname(this.file), { recursive: true }); this.ensured = true; }
106
+ appendFileSync(this.file, line);
107
+ } catch {
108
+ // Logging must never take down the app. A failed write is silently dropped.
109
+ }
110
+ }
111
+
112
+ debug(msg: string, data?: unknown): void { this.emit("debug", msg, data); }
113
+ info(msg: string, data?: unknown): void { this.emit("info", msg, data); }
114
+ warn(msg: string, data?: unknown): void { this.emit("warn", msg, data); }
115
+ error(msg: string, data?: unknown): void { this.emit("error", msg, data); }
116
+ }
117
+
118
+ /** Default file, resolved from env or the cwd (which IS the workspace for the running app). */
119
+ function defaultFile(): string {
120
+ return process.env.TAICHO_LOG_FILE ?? join(process.cwd(), "taicho.log");
121
+ }
122
+
123
+ /** The starting level, from env. `TAICHO_LOG_LEVEL` wins; `TAICHO_VERBOSE`/`TAICHO_DEBUG` ⇒ debug.
124
+ * (Historically only the codex path honored `TAICHO_DEBUG`; it now raises the general log level.) */
125
+ export function envLevel(): LogLevel {
126
+ const raw = process.env.TAICHO_LOG_LEVEL?.toLowerCase();
127
+ if (raw && raw in ORDER) return raw as LogLevel;
128
+ if (process.env.TAICHO_VERBOSE || process.env.TAICHO_DEBUG) return "debug";
129
+ return "info";
130
+ }
131
+
132
+ /** Build an isolated logger — used by tests (explicit file) and anywhere a scoped sink is wanted. */
133
+ export function createLogger(opts: { file?: string; level?: LogLevel; sink?: (line: string) => void }): Logger {
134
+ return new FileLogger(opts);
135
+ }
136
+
137
+ /** The process-wide default logger every scattered call site imports. Re-pointed at boot. */
138
+ export const log = new FileLogger({ level: envLevel() });
139
+
140
+ /** Re-point the default logger once boot knows the workspace + verbosity. */
141
+ export function configureLogger(opts: { ws?: string; file?: string; level?: LogLevel; sink?: (line: string) => void }): void {
142
+ log.configure(opts);
143
+ }
@@ -0,0 +1,34 @@
1
+ /** Adapt an MCP tool (JSON-Schema input + content-block output) into an AI-SDK `tool()` so the
2
+ * agent loop treats it like any built-in tool. Decoupled from the MCP Client (takes a `call` fn)
3
+ * to stay trivially testable. */
4
+ import { tool, jsonSchema } from "ai";
5
+
6
+ export interface McpToolInfo { name: string; description?: string; inputSchema?: unknown }
7
+
8
+ type ContentBlock = { type: string; text?: string; resource?: unknown; [k: string]: unknown };
9
+ export interface McpCallResult { content?: ContentBlock[]; isError?: boolean; structuredContent?: unknown }
10
+
11
+ /** MCP returns an array of content blocks; the model wants plain text. Join text blocks, summarize
12
+ * non-text ones, and fall back to structuredContent. isError is surfaced inline so the model sees it. */
13
+ export function flattenMcpResult(res: McpCallResult): string {
14
+ const parts = (res.content ?? []).map((c) => {
15
+ if (c.type === "text") return c.text ?? "";
16
+ if (c.type === "resource") return typeof c.resource === "string" ? c.resource : JSON.stringify(c.resource ?? c);
17
+ if (c.type === "image" || c.type === "audio") return `[${c.type}]`;
18
+ return JSON.stringify(c);
19
+ });
20
+ let text = parts.join("\n").trim();
21
+ if (!text && res.structuredContent !== undefined) text = JSON.stringify(res.structuredContent);
22
+ return res.isError ? `Error: ${text || "tool call failed"}` : text;
23
+ }
24
+
25
+ export function mcpToolToAiTool(
26
+ call: (name: string, args: Record<string, unknown>) => Promise<McpCallResult>,
27
+ info: McpToolInfo,
28
+ ) {
29
+ return tool({
30
+ description: info.description ?? info.name,
31
+ inputSchema: jsonSchema((info.inputSchema as object | undefined) ?? { type: "object", properties: {} }),
32
+ execute: async (args) => flattenMcpResult(await call(info.name, (args ?? {}) as Record<string, unknown>)),
33
+ });
34
+ }