@zanii/blackbox 0.0.0-stage → 0.2.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 (135) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +109 -2
  3. package/dist/agents/index.d.ts +34 -0
  4. package/dist/agents/index.js +73 -0
  5. package/dist/analysis/detectors.d.ts +36 -0
  6. package/dist/analysis/detectors.js +339 -0
  7. package/dist/analysis/faults.d.ts +9 -0
  8. package/dist/analysis/faults.js +250 -0
  9. package/dist/analysis/index.d.ts +68 -0
  10. package/dist/analysis/index.js +388 -0
  11. package/dist/analysis/landing.d.ts +25 -0
  12. package/dist/analysis/landing.js +225 -0
  13. package/dist/analysis/memory.d.ts +13 -0
  14. package/dist/analysis/memory.js +33 -0
  15. package/dist/analysis/waste.d.ts +29 -0
  16. package/dist/analysis/waste.js +79 -0
  17. package/dist/approvals/index.d.ts +11 -0
  18. package/dist/approvals/index.js +27 -0
  19. package/dist/approvals/warnings.d.ts +2 -0
  20. package/dist/approvals/warnings.js +28 -0
  21. package/dist/attest/index.d.ts +17 -0
  22. package/dist/attest/index.js +106 -0
  23. package/dist/authority/index.d.ts +24 -0
  24. package/dist/authority/index.js +77 -0
  25. package/dist/billing/index.d.ts +99 -0
  26. package/dist/billing/index.js +174 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +1057 -0
  29. package/dist/client/index.d.ts +146 -0
  30. package/dist/client/index.js +210 -0
  31. package/dist/compliance/index.d.ts +41 -0
  32. package/dist/compliance/index.js +96 -0
  33. package/dist/cost/index.d.ts +133 -0
  34. package/dist/cost/index.js +293 -0
  35. package/dist/data/index.d.ts +191 -0
  36. package/dist/data/index.js +762 -0
  37. package/dist/directives/index.d.ts +35 -0
  38. package/dist/directives/index.js +80 -0
  39. package/dist/drills/index.d.ts +43 -0
  40. package/dist/drills/index.js +101 -0
  41. package/dist/duty/index.d.ts +21 -0
  42. package/dist/duty/index.js +68 -0
  43. package/dist/fleet/index.d.ts +141 -0
  44. package/dist/fleet/index.js +454 -0
  45. package/dist/hooks/ai-sdk.d.ts +42 -0
  46. package/dist/hooks/ai-sdk.js +62 -0
  47. package/dist/hooks/claude-agent-sdk.d.ts +14 -0
  48. package/dist/hooks/claude-agent-sdk.js +70 -0
  49. package/dist/hooks/index.d.ts +7 -0
  50. package/dist/hooks/index.js +10 -0
  51. package/dist/hooks/langchain-agent.d.ts +69 -0
  52. package/dist/hooks/langchain-agent.js +163 -0
  53. package/dist/hooks/langchain.d.ts +41 -0
  54. package/dist/hooks/langchain.js +216 -0
  55. package/dist/hooks/langgraph-checkpoint.d.ts +12 -0
  56. package/dist/hooks/langgraph-checkpoint.js +73 -0
  57. package/dist/hooks/memory.d.ts +17 -0
  58. package/dist/hooks/memory.js +64 -0
  59. package/dist/hooks/openai-agents.d.ts +6 -0
  60. package/dist/hooks/openai-agents.js +40 -0
  61. package/dist/hooks/protect.d.ts +7 -0
  62. package/dist/hooks/protect.js +39 -0
  63. package/dist/hooks/providers.d.ts +16 -0
  64. package/dist/hooks/providers.js +149 -0
  65. package/dist/hooks/shared.d.ts +11 -0
  66. package/dist/hooks/shared.js +39 -0
  67. package/dist/index.d.ts +46 -0
  68. package/dist/index.js +48 -0
  69. package/dist/investigate/index.d.ts +66 -0
  70. package/dist/investigate/index.js +119 -0
  71. package/dist/mcp-server/index.d.ts +85 -0
  72. package/dist/mcp-server/index.js +216 -0
  73. package/dist/mcp-wrap/index.d.ts +17 -0
  74. package/dist/mcp-wrap/index.js +170 -0
  75. package/dist/money/index.d.ts +114 -0
  76. package/dist/money/index.js +622 -0
  77. package/dist/occurrence/index.d.ts +108 -0
  78. package/dist/occurrence/index.js +168 -0
  79. package/dist/ocsf/index.d.ts +22 -0
  80. package/dist/ocsf/index.js +168 -0
  81. package/dist/otlp/index.d.ts +24 -0
  82. package/dist/otlp/index.js +143 -0
  83. package/dist/packs/index.d.ts +48 -0
  84. package/dist/packs/index.js +343 -0
  85. package/dist/policy/delta.d.ts +11 -0
  86. package/dist/policy/delta.js +39 -0
  87. package/dist/policy/drafts.d.ts +34 -0
  88. package/dist/policy/drafts.js +129 -0
  89. package/dist/policy/index.d.ts +47 -0
  90. package/dist/policy/index.js +154 -0
  91. package/dist/precog/index.d.ts +96 -0
  92. package/dist/precog/index.js +167 -0
  93. package/dist/precog/intervention.d.ts +22 -0
  94. package/dist/precog/intervention.js +44 -0
  95. package/dist/precog/normal.d.ts +31 -0
  96. package/dist/precog/normal.js +89 -0
  97. package/dist/preflight/index.d.ts +11 -0
  98. package/dist/preflight/index.js +19 -0
  99. package/dist/ratings/index.d.ts +21 -0
  100. package/dist/ratings/index.js +48 -0
  101. package/dist/reconcile/claude-code.d.ts +19 -0
  102. package/dist/reconcile/claude-code.js +220 -0
  103. package/dist/reconcile/codex.d.ts +5 -0
  104. package/dist/reconcile/codex.js +191 -0
  105. package/dist/reconcile/index.d.ts +19 -0
  106. package/dist/reconcile/index.js +50 -0
  107. package/dist/reconcile/record.d.ts +49 -0
  108. package/dist/reconcile/record.js +225 -0
  109. package/dist/reconcile/shared.d.ts +65 -0
  110. package/dist/reconcile/shared.js +113 -0
  111. package/dist/replay/index.d.ts +11 -0
  112. package/dist/replay/index.js +64 -0
  113. package/dist/replay/repair.d.ts +10 -0
  114. package/dist/replay/repair.js +62 -0
  115. package/dist/session/drain.d.ts +13 -0
  116. package/dist/session/drain.js +35 -0
  117. package/dist/session/index.d.ts +275 -0
  118. package/dist/session/index.js +681 -0
  119. package/dist/undo/index.d.ts +45 -0
  120. package/dist/undo/index.js +212 -0
  121. package/dist/verify/anchor.d.ts +54 -0
  122. package/dist/verify/anchor.js +77 -0
  123. package/dist/verify/chain.d.ts +27 -0
  124. package/dist/verify/chain.js +105 -0
  125. package/dist/verify/envelope.d.ts +28 -0
  126. package/dist/verify/envelope.js +55 -0
  127. package/dist/verify/index.d.ts +3 -0
  128. package/dist/verify/index.js +3 -0
  129. package/dist/version.d.ts +1 -0
  130. package/dist/version.js +2 -0
  131. package/dist/weather/index.d.ts +24 -0
  132. package/dist/weather/index.js +45 -0
  133. package/dist/workspace-receipt/index.d.ts +15 -0
  134. package/dist/workspace-receipt/index.js +121 -0
  135. package/package.json +56 -3
@@ -0,0 +1,225 @@
1
+ // What the record says happened (spec/reconcile.md §2): one Call per llm.request, with the response
2
+ // rebuilt from its chunks. Mirrors sdks/python/src/zanii_blackbox/reconcile/record.py.
3
+ const decoder = new TextDecoder();
4
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5
+ export function parseJson(text) {
6
+ try {
7
+ return JSON.parse(text);
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ }
13
+ /** The calls in a verified record, in request order. */
14
+ export function callsOf(lines, bodies) {
15
+ const events = lines.map((l) => JSON.parse(l));
16
+ const calls = new Map();
17
+ const chunks = new Map();
18
+ // ponytail: records from before request_seq existed (pre-Stage 2.1) attach to the latest request,
19
+ // which is right unless streams interleaved.
20
+ let latest = null;
21
+ const text = (hash) => {
22
+ const b = bodies(hash);
23
+ return b === undefined ? "" : decoder.decode(b);
24
+ };
25
+ for (const e of events) {
26
+ const meta = e.meta;
27
+ if (e.kind === "llm.request") {
28
+ latest = e.seq;
29
+ const request = parseJson(text(e.body_hash));
30
+ calls.set(e.seq, {
31
+ requestSeq: e.seq,
32
+ endSeq: e.seq,
33
+ path: typeof meta.path === "string" ? meta.path : "",
34
+ headers: isObj(meta.headers) ? meta.headers : {},
35
+ request: isObj(request) ? request : null,
36
+ complete: false,
37
+ status: null,
38
+ redacted: meta.redacted !== undefined,
39
+ requestRedacted: meta.redacted !== undefined,
40
+ messageId: null,
41
+ responseId: null,
42
+ model: null,
43
+ usage: null,
44
+ provider: typeof meta.provider === "string" ? meta.provider : null,
45
+ serviceTier: null,
46
+ reportedMicroUsd: null,
47
+ blocks: new Map(),
48
+ items: [],
49
+ });
50
+ chunks.set(e.seq, []);
51
+ continue;
52
+ }
53
+ if (e.kind !== "llm.chunk" && e.kind !== "llm.response" && e.kind !== "llm.incomplete")
54
+ continue;
55
+ const owner = typeof meta.request_seq === "number" ? meta.request_seq : latest;
56
+ const call = owner === null ? undefined : calls.get(owner);
57
+ if (!call)
58
+ continue;
59
+ if (meta.redacted !== undefined)
60
+ call.redacted = true;
61
+ if (e.kind === "llm.chunk") {
62
+ chunks.get(call.requestSeq)?.push(text(e.body_hash));
63
+ continue;
64
+ }
65
+ call.endSeq = e.seq;
66
+ call.complete = e.kind === "llm.response";
67
+ call.status = typeof meta.status === "number" ? meta.status : null;
68
+ if (typeof meta.message_id === "string")
69
+ call.messageId = meta.message_id;
70
+ if (typeof meta.response_id === "string")
71
+ call.responseId = meta.response_id;
72
+ if (typeof meta.model === "string")
73
+ call.model = meta.model;
74
+ if (isObj(meta.usage))
75
+ call.usage = meta.usage;
76
+ if (typeof meta.service_tier === "string")
77
+ call.serviceTier = meta.service_tier;
78
+ if (Number.isSafeInteger(meta.reported_cost_micro_usd))
79
+ call.reportedMicroUsd = meta.reported_cost_micro_usd;
80
+ rebuild(call, (chunks.get(call.requestSeq) ?? []).join(""));
81
+ }
82
+ return [...calls.values()];
83
+ }
84
+ /** The workspace receipts in a record (spec/reconcile.md §7), in seq order. */
85
+ export function receiptsOf(lines) {
86
+ const out = [];
87
+ for (const line of lines) {
88
+ const e = JSON.parse(line);
89
+ if (e.kind !== "workspace.receipt")
90
+ continue;
91
+ const m = e.meta;
92
+ if ((m.phase !== "pre" && m.phase !== "post") || typeof m.tool_use_id !== "string")
93
+ continue;
94
+ out.push({
95
+ seq: e.seq,
96
+ phase: m.phase,
97
+ toolUseId: m.tool_use_id,
98
+ agentSession: typeof m.agent_session === "string" ? m.agent_session : null,
99
+ tree: typeof m.tree === "string" ? m.tree : null,
100
+ });
101
+ }
102
+ return out;
103
+ }
104
+ /** Rebuilds the response content from its bytes: an SSE stream, or one JSON body. */
105
+ function rebuild(call, body) {
106
+ const trimmed = body.trimStart();
107
+ if (trimmed.startsWith("{")) {
108
+ const json = parseJson(trimmed);
109
+ if (!isObj(json))
110
+ return;
111
+ if (Array.isArray(json.content)) {
112
+ if (typeof json.id === "string")
113
+ call.messageId ??= json.id;
114
+ json.content.forEach((b, i) => {
115
+ if (isObj(b) && typeof b.type === "string")
116
+ call.blocks.set(i, b);
117
+ });
118
+ }
119
+ if (Array.isArray(json.output)) {
120
+ if (typeof json.id === "string")
121
+ call.responseId ??= json.id;
122
+ call.items = json.output.filter(isObj);
123
+ }
124
+ if (Array.isArray(json.choices)) {
125
+ if (typeof json.id === "string")
126
+ call.messageId ??= json.id;
127
+ const message = json.choices.find(isObj)?.message;
128
+ if (isObj(message))
129
+ chatBlocks(call, message.content, message.tool_calls);
130
+ }
131
+ return;
132
+ }
133
+ const partial = new Map();
134
+ // Chat Completions streams: text and tool-call deltas, gathered then mapped onto blocks.
135
+ let chatText = null;
136
+ const chatCalls = new Map();
137
+ for (const line of body.split(/\r?\n/)) {
138
+ if (!line.startsWith("data:"))
139
+ continue;
140
+ const event = parseJson(line.slice(5).trim());
141
+ if (!isObj(event))
142
+ continue;
143
+ const type = event.type;
144
+ if (Array.isArray(event.choices)) {
145
+ if (typeof event.id === "string")
146
+ call.messageId ??= event.id;
147
+ const delta = event.choices.find(isObj)?.delta;
148
+ if (!isObj(delta))
149
+ continue;
150
+ if (typeof delta.content === "string")
151
+ chatText = (chatText ?? "") + delta.content;
152
+ for (const tc of Array.isArray(delta.tool_calls) ? delta.tool_calls.filter(isObj) : []) {
153
+ const k = typeof tc.index === "number" ? tc.index : 0;
154
+ const acc = chatCalls.get(k) ?? { arguments: "" };
155
+ const fn = isObj(tc.function) ? tc.function : {};
156
+ if (typeof tc.id === "string")
157
+ acc.id = tc.id;
158
+ if (typeof fn.name === "string")
159
+ acc.name = (acc.name ?? "") + fn.name;
160
+ if (typeof fn.arguments === "string")
161
+ acc.arguments += fn.arguments;
162
+ chatCalls.set(k, acc);
163
+ }
164
+ continue;
165
+ }
166
+ if (type === "message_start" && isObj(event.message) && typeof event.message.id === "string")
167
+ call.messageId ??= event.message.id;
168
+ else if (type === "content_block_start" &&
169
+ typeof event.index === "number" &&
170
+ isObj(event.content_block))
171
+ call.blocks.set(event.index, { ...event.content_block });
172
+ else if (type === "content_block_delta" &&
173
+ typeof event.index === "number" &&
174
+ isObj(event.delta)) {
175
+ const block = call.blocks.get(event.index);
176
+ const d = event.delta;
177
+ if (!block)
178
+ continue;
179
+ if (d.type === "text_delta" && typeof d.text === "string")
180
+ block.text = (block.text ?? "") + d.text;
181
+ else if (d.type === "thinking_delta" && typeof d.thinking === "string")
182
+ block.thinking = (block.thinking ?? "") + d.thinking;
183
+ else if (d.type === "input_json_delta" && typeof d.partial_json === "string")
184
+ partial.set(event.index, (partial.get(event.index) ?? "") + d.partial_json);
185
+ }
186
+ else if (type === "content_block_stop" && typeof event.index === "number") {
187
+ const block = call.blocks.get(event.index);
188
+ const json = partial.get(event.index);
189
+ if (block && json !== undefined && json !== "")
190
+ block.input = parseJson(json);
191
+ }
192
+ else if (type === "response.output_item.done" && isObj(event.item))
193
+ call.items.push(event.item);
194
+ else if ((type === "response.created" || type === "response.completed") &&
195
+ isObj(event.response) &&
196
+ typeof event.response.id === "string")
197
+ call.responseId ??= event.response.id;
198
+ }
199
+ if (chatText !== null || chatCalls.size > 0)
200
+ chatBlocks(call, chatText, [...chatCalls.entries()]
201
+ .sort(([a], [b]) => a - b)
202
+ .map(([, c]) => ({ id: c.id, function: { name: c.name, arguments: c.arguments } })));
203
+ }
204
+ /**
205
+ * spec/reconcile.md §2: a Chat Completions message as content blocks, so every reader of blocks sees
206
+ * its text and tool calls: a text block (when there's text), then one tool_use per tool call, its
207
+ * arguments parsed from their JSON string.
208
+ */
209
+ function chatBlocks(call, content, toolCalls) {
210
+ let i = 0;
211
+ if (typeof content === "string" && content !== "")
212
+ call.blocks.set(i++, { type: "text", text: content });
213
+ for (const tc of Array.isArray(toolCalls) ? toolCalls.filter(isObj) : []) {
214
+ const fn = isObj(tc.function) ? tc.function : {};
215
+ if (typeof fn.name !== "string")
216
+ continue;
217
+ const args = typeof fn.arguments === "string" ? parseJson(fn.arguments || "{}") : fn.arguments;
218
+ call.blocks.set(i++, {
219
+ type: "tool_use",
220
+ name: fn.name,
221
+ input: args ?? {},
222
+ ...(typeof tc.id === "string" ? { id: tc.id } : {}),
223
+ });
224
+ }
225
+ }
@@ -0,0 +1,65 @@
1
+ import type { Obj } from "./record.ts";
2
+ export type Harness = "claude-code" | "codex";
3
+ export type FindingCode = "MISSING_LOCAL" | "ALTERED" | "EXTRA_LOCAL" | "LOCAL_GAP" | "RECEIPT_MISSING" | "UNATTRIBUTED_CHANGE";
4
+ export interface Finding {
5
+ code: FindingCode;
6
+ ref: Record<string, string | number>;
7
+ gateway_seq?: number;
8
+ local?: {
9
+ file: string;
10
+ line: number;
11
+ };
12
+ }
13
+ export interface Report {
14
+ v: 1;
15
+ harness: Harness;
16
+ ok: boolean;
17
+ record: {
18
+ session_id: string;
19
+ events: number;
20
+ calls: number;
21
+ calls_expected: number;
22
+ calls_not_expected: number;
23
+ };
24
+ local: {
25
+ files: number;
26
+ records: number;
27
+ agent_sessions: string[];
28
+ };
29
+ matched: {
30
+ calls: number;
31
+ tool_results: number;
32
+ };
33
+ findings: Finding[];
34
+ /** Claude Code, when workspace receipts are required (spec/reconcile.md §7). */
35
+ workspace?: {
36
+ receipts: number;
37
+ changed_by: string[];
38
+ };
39
+ notes: string[];
40
+ warnings: string[];
41
+ }
42
+ export interface LocalRecord {
43
+ file: string;
44
+ line: number;
45
+ json: Obj;
46
+ }
47
+ export interface LocalLog {
48
+ files: string[];
49
+ records: LocalRecord[];
50
+ /** Offloaded tool outputs (Claude Code `tool-results/`), as text. */
51
+ offloaded: Set<string>;
52
+ warnings: string[];
53
+ }
54
+ export declare const isObj: (v: unknown) => v is Obj;
55
+ /** Keys sorted, no whitespace. */
56
+ export declare function canonical(value: unknown): string;
57
+ /** Text of a string or of a list of content blocks. */
58
+ export declare function normText(content: unknown): string;
59
+ /**
60
+ * Reads the local log. A missing path is an empty log. For a Claude Code session file, its
61
+ * `<id>/subagents/*.jsonl` and `<id>/tool-results/*` are read too.
62
+ */
63
+ export declare function loadLocal(path: string, harness: Harness): LocalLog;
64
+ /** "unknown record type" warnings, one per type, with a count. */
65
+ export declare function unknownTypes(records: LocalRecord[], known: ReadonlySet<string>): string[];
@@ -0,0 +1,113 @@
1
+ // Types and helpers both harnesses share (spec/reconcile.md). Mirrors reconcile/shared.py.
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { basename, dirname, join, relative, sep } from "node:path";
5
+ export const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6
+ /** Keys sorted, no whitespace. */
7
+ export function canonical(value) {
8
+ if (Array.isArray(value))
9
+ return `[${value.map(canonical).join(",")}]`;
10
+ if (isObj(value))
11
+ return `{${Object.keys(value)
12
+ .sort()
13
+ .map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
14
+ .join(",")}}`;
15
+ return JSON.stringify(value) ?? "null";
16
+ }
17
+ /** Text of a string or of a list of content blocks. */
18
+ export function normText(content) {
19
+ if (typeof content === "string")
20
+ return content;
21
+ if (!Array.isArray(content))
22
+ return canonical(content);
23
+ return content
24
+ .map((b) => {
25
+ if (!isObj(b))
26
+ return "";
27
+ if (b.type === "text")
28
+ return typeof b.text === "string" ? b.text : "";
29
+ if (b.type === "image") {
30
+ const data = isObj(b.source) && typeof b.source.data === "string" ? b.source.data : "";
31
+ return `[image:${createHash("sha256").update(data).digest("hex")}]`;
32
+ }
33
+ return `[${String(b.type)}]`;
34
+ })
35
+ .join("");
36
+ }
37
+ /**
38
+ * Reads the local log. A missing path is an empty log. For a Claude Code session file, its
39
+ * `<id>/subagents/*.jsonl` and `<id>/tool-results/*` are read too.
40
+ */
41
+ export function loadLocal(path, harness) {
42
+ const log = { files: [], records: [], offloaded: new Set(), warnings: [] };
43
+ if (!existsSync(path))
44
+ return log;
45
+ let base;
46
+ const jsonl = [];
47
+ const offloaded = [];
48
+ if (statSync(path).isDirectory()) {
49
+ base = path;
50
+ for (const f of walk(path)) {
51
+ if (f.endsWith(".jsonl"))
52
+ jsonl.push(f);
53
+ else if (harness === "claude-code" && f.split(sep).includes("tool-results"))
54
+ offloaded.push(f);
55
+ }
56
+ }
57
+ else {
58
+ base = dirname(path);
59
+ jsonl.push(path);
60
+ const sessionDir = join(base, basename(path, ".jsonl"));
61
+ if (harness === "claude-code" && existsSync(sessionDir))
62
+ for (const f of walk(sessionDir)) {
63
+ if (f.endsWith(".jsonl") && f.split(sep).includes("subagents"))
64
+ jsonl.push(f);
65
+ else if (f.split(sep).includes("tool-results"))
66
+ offloaded.push(f);
67
+ }
68
+ }
69
+ const rel = (f) => relative(base, f).split(sep).join("/");
70
+ for (const f of offloaded)
71
+ log.offloaded.add(readFileSync(f, "utf8"));
72
+ const files = jsonl
73
+ .map((f) => ({ f, r: rel(f) }))
74
+ .sort((a, b) => (a.r < b.r ? -1 : a.r > b.r ? 1 : 0));
75
+ for (const { f, r } of files) {
76
+ log.files.push(r);
77
+ readFileSync(f, "utf8")
78
+ .split("\n")
79
+ .forEach((line, i) => {
80
+ if (line.trim() === "")
81
+ return;
82
+ try {
83
+ const json = JSON.parse(line);
84
+ if (isObj(json))
85
+ log.records.push({ file: r, line: i + 1, json });
86
+ else
87
+ log.warnings.push(`${r}:${i + 1}: not a JSON object`);
88
+ }
89
+ catch {
90
+ log.warnings.push(`${r}:${i + 1}: not JSON`);
91
+ }
92
+ });
93
+ }
94
+ return log;
95
+ }
96
+ function walk(dir) {
97
+ return readdirSync(dir).flatMap((name) => {
98
+ const p = join(dir, name);
99
+ return statSync(p).isDirectory() ? walk(p) : [p];
100
+ });
101
+ }
102
+ /** "unknown record type" warnings, one per type, with a count. */
103
+ export function unknownTypes(records, known) {
104
+ const counts = new Map();
105
+ for (const r of records) {
106
+ const t = typeof r.json.type === "string" ? r.json.type : "(none)";
107
+ if (!known.has(t))
108
+ counts.set(t, (counts.get(t) ?? 0) + 1);
109
+ }
110
+ return [...counts]
111
+ .sort(([a], [b]) => (a < b ? -1 : 1))
112
+ .map(([t, n]) => `unknown record type "${t}" (${n})`);
113
+ }
@@ -0,0 +1,11 @@
1
+ /** `rk:` + sha256 over the canonical key fields of a JSON request; of the raw bytes otherwise. */
2
+ export declare function requestKey(body: Uint8Array): string;
3
+ export interface Take {
4
+ request_seq: number;
5
+ key: string;
6
+ status: number;
7
+ content_type: string;
8
+ body: Uint8Array;
9
+ }
10
+ /** Every completed model call, in request order: its key and the exact response bytes recorded. */
11
+ export declare function cassette(lines: readonly string[], bodies: (hash: string) => Uint8Array | undefined): Take[];
@@ -0,0 +1,64 @@
1
+ // Replay (spec/replay.md): a recorded session as a cassette of model calls the gateway can serve
2
+ // again, and the key that says whether a new request is the same call. Mirrors replay.py.
3
+ import { createHash } from "node:crypto";
4
+ import { canonical } from "../reconcile/shared.js";
5
+ /** The fields that make two requests the same call; everything else (metadata, stream flags) is noise. */
6
+ const KEY_FIELDS = ["model", "system", "instructions", "messages", "input", "tools"];
7
+ /** `rk:` + sha256 over the canonical key fields of a JSON request; of the raw bytes otherwise. */
8
+ export function requestKey(body) {
9
+ let parsed;
10
+ try {
11
+ parsed = JSON.parse(new TextDecoder().decode(body));
12
+ }
13
+ catch {
14
+ parsed = undefined;
15
+ }
16
+ const h = createHash("sha256");
17
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
18
+ const o = parsed;
19
+ h.update(canonical(Object.fromEntries(KEY_FIELDS.filter((k) => k in o).map((k) => [k, o[k]]))));
20
+ }
21
+ else
22
+ h.update(body);
23
+ return `rk:${h.digest("hex")}`;
24
+ }
25
+ /** Every completed model call, in request order: its key and the exact response bytes recorded. */
26
+ export function cassette(lines, bodies) {
27
+ const events = lines.map((l) => JSON.parse(l));
28
+ const chunks = new Map();
29
+ const done = new Map();
30
+ let last;
31
+ for (const e of events) {
32
+ if (e.kind === "llm.request")
33
+ last = e.seq;
34
+ // Records made before request_seq (Stage 2.1): an unlinked event belongs to the latest request.
35
+ const rs = typeof e.meta.request_seq === "number" ? e.meta.request_seq : last;
36
+ if (rs === undefined)
37
+ continue;
38
+ if (e.kind === "llm.chunk") {
39
+ const list = chunks.get(rs) ?? [];
40
+ list.push(bodies(e.body_hash) ?? new Uint8Array());
41
+ chunks.set(rs, list);
42
+ }
43
+ else if (e.kind === "llm.response" && typeof e.meta.status === "number")
44
+ done.set(rs, e.meta.status);
45
+ }
46
+ const takes = [];
47
+ for (const e of events) {
48
+ const status = done.get(e.seq);
49
+ if (e.kind !== "llm.request" || status === undefined)
50
+ continue;
51
+ const body = Buffer.concat(chunks.get(e.seq) ?? []);
52
+ const head = body.subarray(0, 16).toString("utf8").trimStart();
53
+ takes.push({
54
+ request_seq: e.seq,
55
+ key: requestKey(bodies(e.body_hash) ?? new Uint8Array()),
56
+ status,
57
+ content_type: head.startsWith("event:") || head.startsWith("data:")
58
+ ? "text/event-stream"
59
+ : "application/json",
60
+ body: new Uint8Array(body),
61
+ });
62
+ }
63
+ return takes;
64
+ }
@@ -0,0 +1,10 @@
1
+ type Msg = Record<string, unknown>;
2
+ export declare const ORPHAN_TEXT = "blackbox: this call didn't finish (the run stopped mid-call). Check what it did before calling it again.";
3
+ /** The messages with an error result for every tool call that has none, and the ids it repaired.
4
+ * OpenAI / LangChain style (`tool_calls` + `tool_call_id` messages) and Anthropic style
5
+ * (`tool_use` blocks + `tool_result` blocks in the next user message). The input isn't changed. */
6
+ export declare function repairToolCalls(messages: readonly Msg[]): {
7
+ messages: Msg[];
8
+ repaired: string[];
9
+ };
10
+ export {};
@@ -0,0 +1,62 @@
1
+ // Repairing orphaned tool calls (spec/replay.md §5, idea R9): a run that crashed mid-call left a
2
+ // tool call with no result, and a provider refuses a history like that. Before a fork or a resume,
3
+ // each one gets an error result that says so. Mirrors repair.py.
4
+ export const ORPHAN_TEXT = "blackbox: this call didn't finish (the run stopped mid-call). Check what it did before calling it again.";
5
+ const isAi = (m) => m.role === "assistant" || m.type === "ai";
6
+ const isTool = (m) => m?.role === "tool" || m?.type === "tool";
7
+ const blocks = (m) => Array.isArray(m?.content) ? m.content : [];
8
+ /** The messages with an error result for every tool call that has none, and the ids it repaired.
9
+ * OpenAI / LangChain style (`tool_calls` + `tool_call_id` messages) and Anthropic style
10
+ * (`tool_use` blocks + `tool_result` blocks in the next user message). The input isn't changed. */
11
+ export function repairToolCalls(messages) {
12
+ const answered = new Set();
13
+ for (const m of messages) {
14
+ if (isTool(m) && typeof m.tool_call_id === "string")
15
+ answered.add(m.tool_call_id);
16
+ for (const b of blocks(m))
17
+ if (b?.type === "tool_result" && typeof b.tool_use_id === "string")
18
+ answered.add(b.tool_use_id);
19
+ }
20
+ const out = [];
21
+ const repaired = [];
22
+ let i = 0;
23
+ while (i < messages.length) {
24
+ const m = messages[i++];
25
+ out.push(m);
26
+ if (!isAi(m))
27
+ continue;
28
+ while (isTool(messages[i]))
29
+ out.push(messages[i++]); // this turn's own results first
30
+ const calls = Array.isArray(m.tool_calls) ? m.tool_calls : [];
31
+ for (const c of calls)
32
+ if (typeof c?.id === "string" && !answered.has(c.id)) {
33
+ repaired.push(c.id);
34
+ out.push({
35
+ ...(m.type === "ai" ? { type: "tool" } : { role: "tool" }),
36
+ tool_call_id: c.id,
37
+ content: ORPHAN_TEXT,
38
+ });
39
+ }
40
+ const unanswered = blocks(m)
41
+ .filter((b) => b?.type === "tool_use" && typeof b.id === "string" && !answered.has(b.id))
42
+ .map((b) => b.id);
43
+ if (unanswered.length === 0)
44
+ continue;
45
+ repaired.push(...unanswered);
46
+ const results = unanswered.map((id) => ({
47
+ type: "tool_result",
48
+ tool_use_id: id,
49
+ is_error: true,
50
+ content: ORPHAN_TEXT,
51
+ }));
52
+ const next = messages[i];
53
+ if (next?.role === "user") {
54
+ const content = typeof next.content === "string" ? [{ type: "text", text: next.content }] : blocks(next);
55
+ out.push({ ...next, content: [...results, ...content] });
56
+ i++;
57
+ }
58
+ else
59
+ out.push({ role: "user", content: results });
60
+ }
61
+ return { messages: out, repaired };
62
+ }
@@ -0,0 +1,13 @@
1
+ export interface DrainResult {
2
+ session_id: string;
3
+ shipped: boolean;
4
+ /** Events the gateway still doesn't have (0 when shipped). */
5
+ left: number;
6
+ }
7
+ /** Only for agents that have stopped: a running agent ships its own spool. Never throws. */
8
+ export declare function drainSpools(options: {
9
+ url: string;
10
+ spoolDir?: string;
11
+ timeoutMs?: number;
12
+ logger?: (message: string) => void;
13
+ }): Promise<DrainResult[]>;
@@ -0,0 +1,35 @@
1
+ // Audit K5: ships the events a stopped agent left in its spool. The SDK keeps the token of a session
2
+ // it opened itself next to the spool (`<id>.token`, mode 0600), since only it knew the token; this
3
+ // reads each one and ships what the gateway hasn't acknowledged. The session isn't closed: the
4
+ // gateway already recorded the lost contact, and an operator closes it (or it lapses, spec/api.md).
5
+ import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { BlackboxSession } from "./index.js";
9
+ /** Only for agents that have stopped: a running agent ships its own spool. Never throws. */
10
+ export async function drainSpools(options) {
11
+ const dir = options.spoolDir ?? process.env.BLACKBOX_SPOOL_DIR ?? join(tmpdir(), "zanii-blackbox-spool");
12
+ if (!existsSync(dir))
13
+ return [];
14
+ const out = [];
15
+ for (const file of readdirSync(dir)
16
+ .filter((f) => f.endsWith(".token"))
17
+ .sort()) {
18
+ const id = file.slice(0, -".token".length);
19
+ const token = readFileSync(join(dir, file), "utf8").trim();
20
+ const s = new BlackboxSession({
21
+ url: options.url,
22
+ spoolDir: dir,
23
+ heartbeatMs: 0,
24
+ flushMs: 60_000,
25
+ ...(options.logger ? { logger: options.logger } : {}),
26
+ }, id, token);
27
+ const shipped = await s.flush({ timeoutMs: options.timeoutMs ?? 30_000 });
28
+ const left = s.stats.recorded - s.stats.acked;
29
+ s.stop();
30
+ if (shipped)
31
+ rmSync(join(dir, file), { force: true });
32
+ out.push({ session_id: id, shipped, left: shipped ? 0 : Math.max(0, left) });
33
+ }
34
+ return out;
35
+ }