@vauban-org/agent-sdk 1.2.0 → 1.3.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 (82) hide show
  1. package/CONTRACT.md +595 -7
  2. package/dist/index.d.ts +4 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/orchestration/ooda/agent.d.ts.map +1 -1
  7. package/dist/orchestration/ooda/agent.js +36 -0
  8. package/dist/orchestration/ooda/agent.js.map +1 -1
  9. package/dist/orchestration/ooda/types.d.ts +11 -0
  10. package/dist/orchestration/ooda/types.d.ts.map +1 -1
  11. package/dist/skills/_secrets.d.ts +16 -0
  12. package/dist/skills/_secrets.d.ts.map +1 -0
  13. package/dist/skills/_secrets.js +20 -0
  14. package/dist/skills/_secrets.js.map +1 -0
  15. package/dist/skills/alpaca-quote.d.ts +2 -2
  16. package/dist/skills/alpaca-quote.d.ts.map +1 -1
  17. package/dist/skills/alpaca-quote.js +51 -20
  18. package/dist/skills/alpaca-quote.js.map +1 -1
  19. package/dist/skills/send-email.d.ts +2 -2
  20. package/dist/skills/send-email.d.ts.map +1 -1
  21. package/dist/skills/send-email.js +1 -12
  22. package/dist/skills/send-email.js.map +1 -1
  23. package/dist/skills/slack-notify.d.ts.map +1 -1
  24. package/dist/skills/slack-notify.js +52 -21
  25. package/dist/skills/slack-notify.js.map +1 -1
  26. package/dist/skills/telegram-notify.d.ts.map +1 -1
  27. package/dist/skills/telegram-notify.js +48 -19
  28. package/dist/skills/telegram-notify.js.map +1 -1
  29. package/dist/skills/web-search.d.ts.map +1 -1
  30. package/dist/skills/web-search.js +85 -40
  31. package/dist/skills/web-search.js.map +1 -1
  32. package/dist/telemetry/bus.d.ts +54 -0
  33. package/dist/telemetry/bus.d.ts.map +1 -0
  34. package/dist/telemetry/bus.js +159 -0
  35. package/dist/telemetry/bus.js.map +1 -0
  36. package/dist/telemetry/index.d.ts +35 -0
  37. package/dist/telemetry/index.d.ts.map +1 -0
  38. package/dist/telemetry/index.js +30 -0
  39. package/dist/telemetry/index.js.map +1 -0
  40. package/dist/telemetry/port.d.ts +121 -0
  41. package/dist/telemetry/port.d.ts.map +1 -0
  42. package/dist/telemetry/port.js +48 -0
  43. package/dist/telemetry/port.js.map +1 -0
  44. package/dist/telemetry/sinks/otlp.d.ts +45 -0
  45. package/dist/telemetry/sinks/otlp.d.ts.map +1 -0
  46. package/dist/telemetry/sinks/otlp.js +195 -0
  47. package/dist/telemetry/sinks/otlp.js.map +1 -0
  48. package/dist/telemetry/sinks/sqlite.d.ts +32 -0
  49. package/dist/telemetry/sinks/sqlite.d.ts.map +1 -0
  50. package/dist/telemetry/sinks/sqlite.js +170 -0
  51. package/dist/telemetry/sinks/sqlite.js.map +1 -0
  52. package/dist/telemetry/sinks/stdout.d.ts +22 -0
  53. package/dist/telemetry/sinks/stdout.d.ts.map +1 -0
  54. package/dist/telemetry/sinks/stdout.js +38 -0
  55. package/dist/telemetry/sinks/stdout.js.map +1 -0
  56. package/docs/telemetry/migration.md +155 -0
  57. package/docs/telemetry/overview.md +154 -0
  58. package/docs/telemetry/privacy.md +127 -0
  59. package/docs/telemetry/sinks/cc.md +155 -0
  60. package/docs/telemetry/sinks/otlp.md +146 -0
  61. package/docs/telemetry/sinks/sqlite.md +126 -0
  62. package/docs/telemetry/sinks/stdout.md +82 -0
  63. package/package.json +18 -19
  64. package/src/index.ts +30 -1
  65. package/src/orchestration/ooda/agent.ts +50 -0
  66. package/src/orchestration/ooda/types.ts +12 -0
  67. package/src/skills/_secrets.ts +25 -0
  68. package/src/skills/alpaca-quote.ts +68 -23
  69. package/src/skills/send-email.ts +1 -12
  70. package/src/skills/slack-notify.ts +73 -30
  71. package/src/skills/telegram-notify.ts +70 -24
  72. package/src/skills/web-search.ts +132 -50
  73. package/src/telemetry/bus.test.ts +231 -0
  74. package/src/telemetry/bus.ts +241 -0
  75. package/src/telemetry/index.ts +49 -0
  76. package/src/telemetry/port.ts +160 -0
  77. package/src/telemetry/sinks/otlp.test.ts +146 -0
  78. package/src/telemetry/sinks/otlp.ts +250 -0
  79. package/src/telemetry/sinks/sqlite.test.ts +121 -0
  80. package/src/telemetry/sinks/sqlite.ts +260 -0
  81. package/src/telemetry/sinks/stdout.test.ts +109 -0
  82. package/src/telemetry/sinks/stdout.ts +59 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * localSqliteTelemetrySink — schema, write, idempotency tests.
3
+ *
4
+ * Uses `:memory:` SQLite so tests are hermetic. Requires `better-sqlite3`
5
+ * installed as a dev/peer dep. If absent, the sink degrades to no-op and
6
+ * the assertions tagged "degraded" pass.
7
+ *
8
+ * Ref: command-center:sprint-693:sink-sqlite
9
+ */
10
+
11
+ import { describe, expect, it } from "vitest";
12
+
13
+ import { localSqliteTelemetrySink } from "./sqlite.js";
14
+
15
+ function isBetterSqliteAvailable(): boolean {
16
+ try {
17
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
18
+ require("better-sqlite3");
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ const RUN_START = {
26
+ runId: "11111111-1111-1111-1111-111111111111",
27
+ agentId: "agent-x",
28
+ agentVersion: "1.0.0",
29
+ model: "test",
30
+ provider: "test",
31
+ startedAt: "2026-05-16T17:00:00.000Z",
32
+ };
33
+
34
+ describe("localSqliteTelemetrySink", () => {
35
+ it("has a stable sink name", () => {
36
+ const sink = localSqliteTelemetrySink({ path: ":memory:" });
37
+ expect(
38
+ sink.name === "sqlite" || sink.name.startsWith("sqlite:degraded")
39
+ ).toBe(true);
40
+ });
41
+
42
+ it("never throws on start/step/finish", async () => {
43
+ const sink = localSqliteTelemetrySink({ path: ":memory:" });
44
+ await expect(sink.start(RUN_START)).resolves.toBeUndefined();
45
+ await expect(
46
+ sink.step("run-1", {
47
+ stepIndex: 0,
48
+ kind: "observe",
49
+ status: "completed",
50
+ inputTokens: 5,
51
+ outputTokens: 10,
52
+ costUsd: 0,
53
+ })
54
+ ).resolves.toBeUndefined();
55
+ await expect(
56
+ sink.finish("run-1", {
57
+ status: "success",
58
+ finishedAt: "2026-05-16T17:00:01.000Z",
59
+ })
60
+ ).resolves.toBeUndefined();
61
+ });
62
+
63
+ it.skipIf(!isBetterSqliteAvailable())(
64
+ "persists agent_run rows and supports finish update",
65
+ async () => {
66
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
67
+ const BetterSqlite = require("better-sqlite3");
68
+ const sink = localSqliteTelemetrySink({ path: ":memory:" });
69
+
70
+ await sink.start(RUN_START);
71
+ await sink.finish(RUN_START.runId, {
72
+ status: "success",
73
+ finishedAt: "2026-05-16T17:00:01.000Z",
74
+ totalInputTokens: 100,
75
+ totalOutputTokens: 50,
76
+ totalCostUsd: 0.01,
77
+ });
78
+
79
+ // Re-open the file via the same in-memory path is impossible; instead,
80
+ // we trust the contract that no exceptions = persisted (since SQLite
81
+ // writes are sync). Spot-check by writing a separate file.
82
+ const path = `/tmp/vauban-telemetry-test-${Date.now()}.db`;
83
+ const sink2 = localSqliteTelemetrySink({ path });
84
+ await sink2.start(RUN_START);
85
+ await sink2.finish(RUN_START.runId, {
86
+ status: "success",
87
+ finishedAt: "2026-05-16T17:00:01.000Z",
88
+ });
89
+
90
+ const db = new BetterSqlite(path);
91
+ const row = db
92
+ .prepare("SELECT * FROM agent_run WHERE run_id = ?")
93
+ .get(RUN_START.runId);
94
+ db.close();
95
+
96
+ expect(row).toBeDefined();
97
+ expect(row.agent_id).toBe("agent-x");
98
+ expect(row.status).toBe("success");
99
+ expect(row.started_at).toBe(RUN_START.startedAt);
100
+ }
101
+ );
102
+
103
+ it.skipIf(!isBetterSqliteAvailable())(
104
+ "idempotent start (ON CONFLICT DO NOTHING)",
105
+ async () => {
106
+ const path = `/tmp/vauban-telemetry-test-${Date.now()}.db`;
107
+ const sink = localSqliteTelemetrySink({ path });
108
+ await sink.start(RUN_START);
109
+ await sink.start(RUN_START); // second call — should not throw or duplicate
110
+
111
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
112
+ const BetterSqlite = require("better-sqlite3");
113
+ const db = new BetterSqlite(path);
114
+ const count = db
115
+ .prepare("SELECT COUNT(*) c FROM agent_run WHERE run_id = ?")
116
+ .get(RUN_START.runId);
117
+ db.close();
118
+ expect(count.c).toBe(1);
119
+ }
120
+ );
121
+ });
@@ -0,0 +1,260 @@
1
+ /**
2
+ * localSqliteTelemetrySink — sovereign local mirror of agent runs.
3
+ *
4
+ * Per ADR-ECO-039 §5 : SQLite sink is ON by default. It is the exit plan for
5
+ * any remote sink (CC SaaS, OTLP backend) — if the network sink is revoked,
6
+ * the local SQLite file retains the full history.
7
+ *
8
+ * Schema (created on first use) :
9
+ * - `agent_run` : one row per OODA cycle (start + finish join)
10
+ * - `agent_run_step` : one row per OODA step
11
+ *
12
+ * Dependency : `better-sqlite3` declared as **peerDependency optional**.
13
+ * If absent, this sink degrades gracefully — logs a warning and no-ops.
14
+ *
15
+ * Ref: command-center:sprint-693:sink-sqlite
16
+ */
17
+
18
+ import { homedir } from "node:os";
19
+ import { dirname, resolve as resolvePath } from "node:path";
20
+ import { mkdirSync } from "node:fs";
21
+
22
+ import type {
23
+ TelemetryRunFinish,
24
+ TelemetryRunStart,
25
+ TelemetryRunStep,
26
+ TelemetrySink,
27
+ } from "../port.js";
28
+
29
+ export interface LocalSqliteTelemetrySinkOptions {
30
+ /**
31
+ * Database file path. Default `~/.vauban/runs.db`.
32
+ * Pass `:memory:` for ephemeral test mode.
33
+ */
34
+ path?: string;
35
+ /** Synchronous mode (better-sqlite3 default). Tests may override. */
36
+ readonly?: boolean;
37
+ }
38
+
39
+ // ─── better-sqlite3 dynamic type stub ────────────────────────────────────────
40
+
41
+ interface Statement {
42
+ run(...params: unknown[]): { changes: number; lastInsertRowid: number };
43
+ get(...params: unknown[]): unknown;
44
+ all(...params: unknown[]): unknown[];
45
+ }
46
+
47
+ interface Database {
48
+ prepare(sql: string): Statement;
49
+ exec(sql: string): void;
50
+ close(): void;
51
+ pragma(sql: string, options?: { simple: boolean }): unknown;
52
+ }
53
+
54
+ // ─── Schema ──────────────────────────────────────────────────────────────────
55
+
56
+ const SCHEMA = `
57
+ CREATE TABLE IF NOT EXISTS agent_run (
58
+ run_id TEXT PRIMARY KEY,
59
+ agent_id TEXT NOT NULL,
60
+ agent_version TEXT NOT NULL,
61
+ model TEXT,
62
+ provider TEXT,
63
+ tenant_id TEXT,
64
+ trace_id TEXT,
65
+ started_at TEXT NOT NULL,
66
+ finished_at TEXT,
67
+ status TEXT,
68
+ stop_reason TEXT,
69
+ error_message TEXT,
70
+ total_input_tokens INTEGER DEFAULT 0,
71
+ total_output_tokens INTEGER DEFAULT 0,
72
+ total_cost_usd REAL DEFAULT 0,
73
+ total_tool_calls INTEGER DEFAULT 0
74
+ );
75
+
76
+ CREATE INDEX IF NOT EXISTS idx_agent_run_agent_started
77
+ ON agent_run (agent_id, started_at DESC);
78
+
79
+ CREATE INDEX IF NOT EXISTS idx_agent_run_status
80
+ ON agent_run (status, started_at DESC);
81
+
82
+ CREATE TABLE IF NOT EXISTS agent_run_step (
83
+ run_id TEXT NOT NULL,
84
+ step_index INTEGER NOT NULL,
85
+ kind TEXT NOT NULL,
86
+ status TEXT NOT NULL,
87
+ input_tokens INTEGER DEFAULT 0,
88
+ output_tokens INTEGER DEFAULT 0,
89
+ tool_calls INTEGER DEFAULT 0,
90
+ cost_usd REAL DEFAULT 0,
91
+ duration_ms INTEGER,
92
+ metadata TEXT,
93
+ recorded_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
94
+ PRIMARY KEY (run_id, step_index)
95
+ );
96
+
97
+ CREATE INDEX IF NOT EXISTS idx_agent_run_step_run
98
+ ON agent_run_step (run_id, step_index);
99
+ `;
100
+
101
+ // ─── Factory ─────────────────────────────────────────────────────────────────
102
+
103
+ /**
104
+ * Build a local SQLite sink. Returns a no-op sink if `better-sqlite3` is not
105
+ * installed — the caller is warned via `console.warn` once.
106
+ */
107
+ export function localSqliteTelemetrySink(
108
+ opts: LocalSqliteTelemetrySinkOptions = {}
109
+ ): TelemetrySink {
110
+ const dbPath = resolveDbPath(opts.path);
111
+ const db = openDatabaseSafely(dbPath, opts.readonly ?? false);
112
+
113
+ if (!db) {
114
+ return degradedSink(dbPath);
115
+ }
116
+
117
+ db.exec(SCHEMA);
118
+ db.pragma("journal_mode = WAL");
119
+ db.pragma("synchronous = NORMAL");
120
+
121
+ const insertRun = db.prepare(`
122
+ INSERT INTO agent_run
123
+ (run_id, agent_id, agent_version, model, provider,
124
+ tenant_id, trace_id, started_at)
125
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
126
+ ON CONFLICT (run_id) DO NOTHING
127
+ `);
128
+
129
+ const insertStep = db.prepare(`
130
+ INSERT INTO agent_run_step
131
+ (run_id, step_index, kind, status,
132
+ input_tokens, output_tokens, tool_calls, cost_usd,
133
+ duration_ms, metadata)
134
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
135
+ ON CONFLICT (run_id, step_index) DO NOTHING
136
+ `);
137
+
138
+ const updateFinish = db.prepare(`
139
+ UPDATE agent_run
140
+ SET finished_at = ?,
141
+ status = ?,
142
+ stop_reason = ?,
143
+ error_message = ?,
144
+ total_input_tokens = COALESCE(?, total_input_tokens),
145
+ total_output_tokens = COALESCE(?, total_output_tokens),
146
+ total_cost_usd = COALESCE(?, total_cost_usd),
147
+ total_tool_calls = COALESCE(?, total_tool_calls)
148
+ WHERE run_id = ?
149
+ `);
150
+
151
+ return {
152
+ name: "sqlite",
153
+
154
+ async start(event: TelemetryRunStart) {
155
+ insertRun.run(
156
+ event.runId,
157
+ event.agentId,
158
+ event.agentVersion,
159
+ event.model ?? null,
160
+ event.provider ?? null,
161
+ event.tenantId ?? null,
162
+ event.traceId ?? null,
163
+ event.startedAt
164
+ );
165
+ },
166
+
167
+ async step(runId: string, delta: TelemetryRunStep) {
168
+ insertStep.run(
169
+ runId,
170
+ delta.stepIndex,
171
+ delta.kind,
172
+ delta.status,
173
+ delta.inputTokens,
174
+ delta.outputTokens,
175
+ delta.toolCalls ?? 0,
176
+ delta.costUsd,
177
+ delta.durationMs ?? null,
178
+ delta.metadata ? JSON.stringify(delta.metadata) : null
179
+ );
180
+ },
181
+
182
+ async finish(runId: string, event: TelemetryRunFinish) {
183
+ updateFinish.run(
184
+ event.finishedAt,
185
+ event.status,
186
+ event.stopReason ?? null,
187
+ event.errorMessage ?? null,
188
+ event.totalInputTokens ?? null,
189
+ event.totalOutputTokens ?? null,
190
+ event.totalCostUsd ?? null,
191
+ event.totalToolCalls ?? null,
192
+ runId
193
+ );
194
+ },
195
+
196
+ async flush() {
197
+ // better-sqlite3 is synchronous — no buffer to flush.
198
+ },
199
+ };
200
+ }
201
+
202
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
203
+
204
+ function resolveDbPath(input?: string): string {
205
+ if (!input) return resolvePath(homedir(), ".vauban", "runs.db");
206
+ if (input === ":memory:") return input;
207
+ // Expand leading ~ manually (Node doesn't do it).
208
+ if (input.startsWith("~/")) {
209
+ return resolvePath(homedir(), input.slice(2));
210
+ }
211
+ return resolvePath(input);
212
+ }
213
+
214
+ let warnedMissing = false;
215
+
216
+ function openDatabaseSafely(path: string, readonly: boolean): Database | null {
217
+ try {
218
+ // Dynamic require so better-sqlite3 stays optional.
219
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
220
+ const createRequire = require("node:module").createRequire as (
221
+ specifier: string
222
+ ) => NodeJS.Require;
223
+ const req = createRequire(import.meta.url);
224
+ const BetterSqlite = req("better-sqlite3") as new (
225
+ path: string,
226
+ opts?: { readonly?: boolean }
227
+ ) => Database;
228
+
229
+ if (path !== ":memory:") {
230
+ mkdirSync(dirname(path), { recursive: true });
231
+ }
232
+ return new BetterSqlite(path, { readonly });
233
+ } catch (err) {
234
+ if (!warnedMissing) {
235
+ warnedMissing = true;
236
+ // eslint-disable-next-line no-console
237
+ console.warn(
238
+ "[telemetry:sqlite] better-sqlite3 not installed — sink degraded to no-op.",
239
+ "Install with: `pnpm add better-sqlite3` (peerDependencyOptional).",
240
+ err instanceof Error ? err.message : ""
241
+ );
242
+ }
243
+ return null;
244
+ }
245
+ }
246
+
247
+ function degradedSink(path: string): TelemetrySink {
248
+ return {
249
+ name: `sqlite:degraded(${path})`,
250
+ async start() {
251
+ /* no-op when better-sqlite3 missing */
252
+ },
253
+ async step() {
254
+ /* no-op */
255
+ },
256
+ async finish() {
257
+ /* no-op */
258
+ },
259
+ };
260
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * stdoutTelemetrySink — JSON emission tests.
3
+ *
4
+ * Ref: command-center:sprint-693:sink-stdout
5
+ */
6
+
7
+ import { describe, expect, it } from "vitest";
8
+ import { Writable } from "node:stream";
9
+
10
+ import { stdoutTelemetrySink } from "./stdout.js";
11
+
12
+ function collectingStream(): {
13
+ stream: Writable;
14
+ lines: () => string[];
15
+ } {
16
+ const chunks: string[] = [];
17
+ const stream = new Writable({
18
+ write(chunk, _enc, cb) {
19
+ chunks.push(chunk.toString());
20
+ cb();
21
+ },
22
+ });
23
+ return {
24
+ stream,
25
+ lines: () =>
26
+ chunks
27
+ .join("")
28
+ .split("\n")
29
+ .filter((l) => l.length > 0),
30
+ };
31
+ }
32
+
33
+ const RUN_START = {
34
+ runId: "run-1",
35
+ agentId: "a",
36
+ agentVersion: "1.0.0",
37
+ model: "test",
38
+ provider: "test",
39
+ startedAt: "2026-05-16T17:00:00.000Z",
40
+ };
41
+
42
+ describe("stdoutTelemetrySink", () => {
43
+ it("emits one JSON line per event", async () => {
44
+ const { stream, lines } = collectingStream();
45
+ const sink = stdoutTelemetrySink({ stream });
46
+
47
+ await sink.start(RUN_START);
48
+ await sink.step("run-1", {
49
+ stepIndex: 0,
50
+ kind: "observe",
51
+ status: "completed",
52
+ inputTokens: 5,
53
+ outputTokens: 10,
54
+ costUsd: 0,
55
+ });
56
+ await sink.finish("run-1", {
57
+ status: "success",
58
+ finishedAt: "2026-05-16T17:00:01.000Z",
59
+ });
60
+
61
+ const out = lines();
62
+ expect(out).toHaveLength(3);
63
+ expect(JSON.parse(out[0]!)).toMatchObject({
64
+ telemetry: "run.start",
65
+ runId: "run-1",
66
+ agentId: "a",
67
+ });
68
+ expect(JSON.parse(out[1]!)).toMatchObject({
69
+ telemetry: "run.step",
70
+ runId: "run-1",
71
+ kind: "observe",
72
+ });
73
+ expect(JSON.parse(out[2]!)).toMatchObject({
74
+ telemetry: "run.finish",
75
+ runId: "run-1",
76
+ status: "success",
77
+ });
78
+ });
79
+
80
+ it("emits key=value when json=false", async () => {
81
+ const { stream, lines } = collectingStream();
82
+ const sink = stdoutTelemetrySink({ stream, json: false });
83
+ await sink.start(RUN_START);
84
+ expect(lines()[0]).toContain('runId="run-1"');
85
+ expect(lines()[0]).toContain('agentId="a"');
86
+ });
87
+
88
+ it("emits a JSON shape compatible with `jq`", async () => {
89
+ const { stream, lines } = collectingStream();
90
+ const sink = stdoutTelemetrySink({ stream });
91
+ await sink.start(RUN_START);
92
+ const parsed = JSON.parse(lines()[0]!);
93
+ // jq-friendly: top-level keys are sortable strings, no nested undefined
94
+ expect(parsed).toHaveProperty("telemetry");
95
+ expect(parsed).toHaveProperty("runId");
96
+ expect(parsed).toHaveProperty("startedAt");
97
+ });
98
+
99
+ it("has name 'stdout'", () => {
100
+ const sink = stdoutTelemetrySink();
101
+ expect(sink.name).toBe("stdout");
102
+ });
103
+
104
+ it("defaults to stderr when no stream given", () => {
105
+ // Just verify constructor doesn't throw; can't capture global stderr in jsdom.
106
+ const sink = stdoutTelemetrySink();
107
+ expect(typeof sink.start).toBe("function");
108
+ });
109
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * stdoutTelemetrySink — JSON line emission to stderr (dev-friendly default).
3
+ *
4
+ * One JSON object per event, suitable for `jq`, `pino-pretty`, or any
5
+ * structured-log pipeline. Writes to stderr (not stdout) so that the agent's
6
+ * own stdout remains free for user output.
7
+ *
8
+ * Ref: command-center:sprint-693:sink-stdout
9
+ */
10
+
11
+ import type {
12
+ TelemetryRunFinish,
13
+ TelemetryRunStart,
14
+ TelemetryRunStep,
15
+ TelemetrySink,
16
+ } from "../port.js";
17
+
18
+ export interface StdoutTelemetrySinkOptions {
19
+ /** Stream to write to. Defaults to `process.stderr`. */
20
+ stream?: NodeJS.WritableStream;
21
+ /** When true (default), emits as `key=value` instead of JSON. */
22
+ json?: boolean;
23
+ }
24
+
25
+ /**
26
+ * Default sink for dev visibility. Zero dependency, zero I/O latency
27
+ * (synchronous write to stream).
28
+ */
29
+ export function stdoutTelemetrySink(
30
+ opts: StdoutTelemetrySinkOptions = {}
31
+ ): TelemetrySink {
32
+ const stream = opts.stream ?? process.stderr;
33
+ const useJson = opts.json ?? true;
34
+
35
+ function emit(event: Record<string, unknown>): void {
36
+ const line = useJson
37
+ ? JSON.stringify(event)
38
+ : Object.entries(event)
39
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
40
+ .join(" ");
41
+ stream.write(`${line}\n`);
42
+ }
43
+
44
+ return {
45
+ name: "stdout",
46
+
47
+ async start(event: TelemetryRunStart) {
48
+ emit({ telemetry: "run.start", ...event });
49
+ },
50
+
51
+ async step(runId: string, delta: TelemetryRunStep) {
52
+ emit({ telemetry: "run.step", runId, ...delta });
53
+ },
54
+
55
+ async finish(runId: string, event: TelemetryRunFinish) {
56
+ emit({ telemetry: "run.finish", runId, ...event });
57
+ },
58
+ };
59
+ }