@zanii/blackbox 0.21.1 → 0.22.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.
@@ -0,0 +1,29 @@
1
+ import { type Bodies, type Call } from "../reconcile/record.ts";
2
+ export interface Reasoning {
3
+ /** `text` (the model's own words), `summary` (the provider's), `encrypted` (there, unreadable) or `none`. */
4
+ kind: "text" | "summary" | "encrypted" | "none";
5
+ text: string | null;
6
+ }
7
+ export interface Action {
8
+ seq: number;
9
+ request_seq: number;
10
+ model: string | null;
11
+ tool: string;
12
+ id: string | null;
13
+ args: unknown;
14
+ reasoning: Reasoning;
15
+ /** What the model wrote before the action, in the same answer. */
16
+ said: string | null;
17
+ }
18
+ /** The tool calls each model answer asked for, in order. Each takes the reasoning and text that came
19
+ * before it in the same answer, since the action before it; one with none of its own (a second call
20
+ * in a row) takes the answer's latest. */
21
+ export declare function actionsFromCalls(calls: readonly Call[]): Action[];
22
+ /** The actions in a verified record, each with the reasoning before it (spec/replay.md §6). */
23
+ export declare function actionsOf(lines: readonly string[], bodies: Bodies): Action[];
24
+ /** The actions for a reader: the first `limit`, each reasoning and `said` cut to `textMax` characters. */
25
+ export declare function actionsPage(actions: readonly Action[], limit?: number, textMax?: number): {
26
+ total: number;
27
+ capped: boolean;
28
+ actions: Action[];
29
+ };
@@ -0,0 +1,114 @@
1
+ // Each action an agent took, with the reasoning the model gave before it (spec/replay.md §6): the
2
+ // thinking (Anthropic `thinking`, Gemini thoughts, a Chat Completions `reasoning_content`), the
3
+ // provider's reasoning summary (OpenAI Responses), or a note that it was there but encrypted. Pure;
4
+ // mirrors sdks/python/src/zanii_blackbox/actions.py; pinned by spec/vectors/actions.json.
5
+ import { callsOf } from "../reconcile/record.js";
6
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
7
+ /** Text parts win over a summary, either over encrypted. */
8
+ function reasoningOf(parts) {
9
+ for (const kind of ["text", "summary"]) {
10
+ const texts = parts.filter(([k, t]) => k === kind && t).map(([, t]) => t);
11
+ if (texts.length)
12
+ return { kind, text: texts.join("\n\n") };
13
+ }
14
+ if (parts.some(([k]) => k === "encrypted"))
15
+ return { kind: "encrypted", text: null };
16
+ return { kind: "none", text: null };
17
+ }
18
+ const action = (c, tool, id, args, why, said) => ({
19
+ seq: c.endSeq,
20
+ request_seq: c.requestSeq,
21
+ model: c.model,
22
+ tool,
23
+ id: typeof id === "string" ? id : null,
24
+ args: args ?? {},
25
+ reasoning: reasoningOf(why),
26
+ said: said.filter(Boolean).join("\n\n") || null,
27
+ });
28
+ /** The tool calls each model answer asked for, in order. Each takes the reasoning and text that came
29
+ * before it in the same answer, since the action before it; one with none of its own (a second call
30
+ * in a row) takes the answer's latest. */
31
+ export function actionsFromCalls(calls) {
32
+ const out = [];
33
+ for (const c of [...calls].sort((a, b) => a.endSeq - b.endSeq)) {
34
+ let why = c.reasoning ? [["text", c.reasoning]] : [];
35
+ let said = [];
36
+ let last = [[], []];
37
+ for (const [, b] of [...c.blocks.entries()].sort(([x], [y]) => x - y)) {
38
+ if (b.type === "thinking")
39
+ why.push(typeof b.thinking === "string" && b.thinking ? ["text", b.thinking] : ["encrypted", ""]);
40
+ else if (b.type === "redacted_thinking")
41
+ why.push(["encrypted", ""]);
42
+ else if (b.type === "text" && typeof b.text === "string")
43
+ said.push(b.text);
44
+ else if (b.type === "tool_use" && typeof b.name === "string") {
45
+ if (why.length || said.length)
46
+ last = [why, said];
47
+ out.push(action(c, b.name, b.id, b.input, ...last));
48
+ why = [];
49
+ said = [];
50
+ }
51
+ }
52
+ for (const item of c.items) {
53
+ const t = item.type;
54
+ if (t === "reasoning") {
55
+ const texts = (Array.isArray(item.summary) ? item.summary : [])
56
+ .filter(isObj)
57
+ .map((s) => String(s.text ?? ""));
58
+ const own = (Array.isArray(item.content) ? item.content : [])
59
+ .filter(isObj)
60
+ .map((s) => String(s.text ?? ""));
61
+ if (own.some(Boolean))
62
+ why.push(...own.map((x) => ["text", x]));
63
+ else if (texts.some(Boolean))
64
+ why.push(...texts.map((x) => ["summary", x]));
65
+ else
66
+ why.push(["encrypted", ""]);
67
+ }
68
+ else if (t === "message") {
69
+ for (const s of Array.isArray(item.content) ? item.content : [])
70
+ if (isObj(s) && typeof s.text === "string")
71
+ said.push(s.text);
72
+ }
73
+ else if ((t === "function_call" || t === "custom_tool_call") &&
74
+ typeof item.name === "string") {
75
+ let args = t === "function_call" ? item.arguments : item.input;
76
+ if (typeof args === "string" && t === "function_call") {
77
+ try {
78
+ args = JSON.parse(args);
79
+ }
80
+ catch { }
81
+ }
82
+ if (why.length || said.length)
83
+ last = [why, said];
84
+ out.push(action(c, item.name, item.call_id, args, ...last));
85
+ why = [];
86
+ said = [];
87
+ }
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+ /** The actions in a verified record, each with the reasoning before it (spec/replay.md §6). */
93
+ export function actionsOf(lines, bodies) {
94
+ return actionsFromCalls(callsOf(lines, bodies));
95
+ }
96
+ /** Cut to `most` code points (as Python counts them), then `…`. */
97
+ function cut(text, most) {
98
+ if (text === null)
99
+ return null;
100
+ const points = Array.from(text);
101
+ return points.length <= most ? text : `${points.slice(0, most).join("")}…`;
102
+ }
103
+ /** The actions for a reader: the first `limit`, each reasoning and `said` cut to `textMax` characters. */
104
+ export function actionsPage(actions, limit = 1000, textMax = 8000) {
105
+ return {
106
+ total: actions.length,
107
+ capped: actions.length > limit,
108
+ actions: actions.slice(0, limit).map((a) => ({
109
+ ...a,
110
+ reasoning: { ...a.reasoning, text: cut(a.reasoning.text, textMax) },
111
+ said: cut(a.said, textMax),
112
+ })),
113
+ };
114
+ }
@@ -0,0 +1,30 @@
1
+ export declare const REGISTER_TYPE = "blackbox.service-register";
2
+ export declare const ACCREDITATION_TYPE = "blackbox.accreditation";
3
+ export declare const SERVICE_ID: RegExp;
4
+ type Doc = Record<string, unknown>;
5
+ export interface ServiceRow {
6
+ id: string;
7
+ name: string;
8
+ name_ar?: string;
9
+ tier: number;
10
+ }
11
+ /** `sha256:` + hex of the RFC 8785 JSON of the whole signed document. */
12
+ export declare const docHash: (doc: Doc) => string;
13
+ /** §1: null when the register is well formed and signed by one of `signers`, else why not. */
14
+ export declare function checkRegister(doc: unknown, signers: readonly string[]): string | null;
15
+ /** §1: a service's tier and its row; an unknown service (or none) gets `default_tier`. */
16
+ export declare function serviceTier(register: Doc, serviceId: string | null | undefined): {
17
+ tier: number;
18
+ service: ServiceRow | null;
19
+ };
20
+ export type AccreditationRefusal = "malformed" | "unsigned" | "wrong_issuer" | "revoked" | "not_yet" | "expired" | "wrong_vendor" | "agent_not_listed" | "tier_too_high";
21
+ /** §2: null when `cert` lets this vendor's agent open a session for a service of `tier` now, else
22
+ * the reason. */
23
+ export declare function checkAccreditation(cert: unknown, issuers: readonly string[], opts: {
24
+ now: string;
25
+ vendor?: string | null;
26
+ agentId?: string | null;
27
+ tier?: number | null;
28
+ revoked?: readonly string[];
29
+ }): AccreditationRefusal | null;
30
+ export {};
@@ -0,0 +1,106 @@
1
+ // Who and what may open a session (spec/admission.md): the signed service risk register (each
2
+ // service's tier: 1 allowed, 2 a person approves each action, 3 off limits) and a vendor's signed
3
+ // accreditation. Both are signed like certificates (spec/fleet.md §5) and checked offline. Pure;
4
+ // mirrors sdks/python/src/zanii_blackbox/admission.py; pinned by spec/vectors/admission.json.
5
+ import { createHash } from "node:crypto";
6
+ import { verifyCertificate } from "../fleet/index.js";
7
+ import { canonical } from "../reconcile/shared.js";
8
+ export const REGISTER_TYPE = "blackbox.service-register";
9
+ export const ACCREDITATION_TYPE = "blackbox.accreditation";
10
+ export const SERVICE_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
11
+ const VENDOR_ID = /^[a-z0-9][a-z0-9-]{0,62}$/;
12
+ const AGENT_ID = /^(?:[A-Za-z0-9._:@/-]{1,128}|\*)$/;
13
+ const MAX_SERVICES = 10_000;
14
+ const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/;
15
+ /** `sha256:` + hex of the RFC 8785 JSON of the whole signed document. */
16
+ export const docHash = (doc) => `sha256:${createHash("sha256").update(canonical(doc)).digest("hex")}`;
17
+ const isTier = (x) => Number.isInteger(x) && x >= 1 && x <= 3;
18
+ const when = (x) => {
19
+ if (typeof x !== "string" || !UTC.test(x))
20
+ return null;
21
+ const t = Date.parse(x);
22
+ return Number.isNaN(t) ? null : t;
23
+ };
24
+ const isObject = (x) => !!x && typeof x === "object" && !Array.isArray(x);
25
+ /** §1: null when the register is well formed and signed by one of `signers`, else why not. */
26
+ export function checkRegister(doc, signers) {
27
+ if (!isObject(doc))
28
+ return "the register must be a JSON object";
29
+ if (doc.v !== 1 || doc.type !== REGISTER_TYPE)
30
+ return `not a ${REGISTER_TYPE} v1`;
31
+ if (typeof doc.name !== "string" || !doc.name)
32
+ return "name is required";
33
+ if (!isTier(doc.default_tier))
34
+ return "default_tier must be 1, 2 or 3";
35
+ const services = doc.services;
36
+ if (!Array.isArray(services) || services.length > MAX_SERVICES)
37
+ return `services must be a list of at most ${MAX_SERVICES}`;
38
+ const seen = new Set();
39
+ for (const [i, s] of services.entries()) {
40
+ if (!isObject(s))
41
+ return `services[${i}] must be an object`;
42
+ if (typeof s.id !== "string" || !SERVICE_ID.test(s.id))
43
+ return `services[${i}].id must be 1-64 of a-z 0-9 . _ - (like visa-renewal)`;
44
+ if (seen.has(s.id))
45
+ return `services[${i}].id ${s.id} is listed twice`;
46
+ seen.add(s.id);
47
+ if (typeof s.name !== "string" || !s.name)
48
+ return `services[${i}].name is required`;
49
+ if (!isTier(s.tier))
50
+ return `services[${i}].tier must be 1, 2 or 3`;
51
+ }
52
+ const v = verifyCertificate(doc);
53
+ if (!v.ok)
54
+ return `the register's signature doesn't verify (${v.reason})`;
55
+ if (!signers.includes(v.by))
56
+ return `the register is signed by ${v.by}, who isn't a trusted signer`;
57
+ return null;
58
+ }
59
+ /** §1: a service's tier and its row; an unknown service (or none) gets `default_tier`. */
60
+ export function serviceTier(register, serviceId) {
61
+ const found = register.services.find((s) => s.id === serviceId);
62
+ return found
63
+ ? { tier: found.tier, service: found }
64
+ : { tier: register.default_tier, service: null };
65
+ }
66
+ /** §2: null when `cert` lets this vendor's agent open a session for a service of `tier` now, else
67
+ * the reason. */
68
+ export function checkAccreditation(cert, issuers, opts) {
69
+ if (!isObject(cert))
70
+ return "malformed";
71
+ const agents = cert.agents;
72
+ const issued = when(cert.issued_at);
73
+ const expires = when(cert.expires_at);
74
+ const at = when(opts.now);
75
+ if (cert.v !== 1 ||
76
+ cert.type !== ACCREDITATION_TYPE ||
77
+ typeof cert.vendor !== "string" ||
78
+ !VENDOR_ID.test(cert.vendor) ||
79
+ !Array.isArray(agents) ||
80
+ !agents.length ||
81
+ !agents.every((a) => typeof a === "string" && AGENT_ID.test(a)) ||
82
+ (cert.max_tier !== 1 && cert.max_tier !== 2) ||
83
+ issued === null ||
84
+ expires === null ||
85
+ at === null)
86
+ return "malformed";
87
+ const v = verifyCertificate(cert);
88
+ if (!v.ok)
89
+ return "unsigned";
90
+ if (!issuers.includes(v.by))
91
+ return "wrong_issuer";
92
+ if ((opts.revoked ?? []).includes(docHash(cert)))
93
+ return "revoked";
94
+ if (at < issued)
95
+ return "not_yet";
96
+ if (at >= expires)
97
+ return "expired";
98
+ if (opts.vendor != null && cert.vendor !== opts.vendor)
99
+ return "wrong_vendor";
100
+ const listed = agents;
101
+ if (!listed.includes("*") && (opts.agentId == null || !listed.includes(opts.agentId)))
102
+ return "agent_not_listed";
103
+ if (opts.tier != null && opts.tier > cert.max_tier)
104
+ return "tier_too_high";
105
+ return null;
106
+ }
package/dist/cli.js CHANGED
@@ -43,6 +43,7 @@ const USAGE = `usage (spec/cli.md):
43
43
  blackbox investigate <bundle.json> [--bodies <dir>] [--directives <file>] [--identity <file>] [--proofs] [--out <prefix>]
44
44
  blackbox evidence-pack <bundle.json> [--proofs] [--identity <file>] [--lang en|ar|both] [--out <prefix>]
45
45
  blackbox sign-constitution <draft.json> --identity <owner.json> [--out <file>]
46
+ blackbox sign <draft.json> --identity <owner.json> [--out <file>] (a service register or an accreditation)
46
47
  blackbox policy-from-zanii <zanii-policy.json> [--out <file>]
47
48
  blackbox policy-to-zanii <policy.json> [--out <file>]
48
49
  blackbox statement --provider anthropic|openai --from <YYYY-MM-DD> --to <YYYY-MM-DD> [--out <file>]
@@ -111,6 +112,8 @@ async function main(argv) {
111
112
  return evidencePackCommand(rest);
112
113
  if (command === "sign-constitution")
113
114
  return signConstitutionCommand(rest);
115
+ if (command === "sign")
116
+ return signCommand(rest);
114
117
  if (command === "policy-from-zanii" || command === "policy-to-zanii")
115
118
  return policyZaniiCommand(command, rest);
116
119
  if (command === "statement")
@@ -863,6 +866,30 @@ async function signConstitutionCommand(rest) {
863
866
  return 2;
864
867
  }
865
868
  }
869
+ /** spec/admission.md §3: signs a draft (a service register, an accreditation) as the identity. */
870
+ async function signCommand(rest) {
871
+ const { flags, positional } = parse(rest, []);
872
+ if (positional.length !== 1 || !flags.identity)
873
+ return usage();
874
+ const { signCertificate } = await import("./fleet/index.js");
875
+ const { docHash } = await import("./admission/index.js");
876
+ try {
877
+ const draft = JSON.parse(readFileSync(positional[0], "utf8"));
878
+ if (!draft || typeof draft !== "object" || Array.isArray(draft))
879
+ throw new Error("the draft must be a JSON object");
880
+ const { signed: _old, ...rest_ } = draft;
881
+ const identity = await readIdentity(flags.identity);
882
+ const doc = signCertificate(rest_, identity.did, identity.seed);
883
+ const out = flags.out || "signed.json";
884
+ writeFileSync(out, `${JSON.stringify(doc, null, 2)}\n`);
885
+ process.stdout.write(`${JSON.stringify({ ok: true, file: out, sha256: docHash(doc), by: identity.did })}\n`);
886
+ return 0;
887
+ }
888
+ catch (error) {
889
+ process.stdout.write(`${JSON.stringify({ ok: false, error: error.message })}\n`);
890
+ return 2;
891
+ }
892
+ }
866
893
  /** spec/investigations.md §7: the court evidence pack, `<prefix>.json` (signed) and `<prefix>.md`. */
867
894
  async function evidencePackCommand(rest) {
868
895
  const { flags, positional } = parse(rest, ["--proofs"]);
@@ -17,6 +17,9 @@ export interface ModelPrice {
17
17
  /** Audio in and out (else `input` / `output`). */
18
18
  input_audio?: number;
19
19
  output_audio?: number;
20
+ /** Image tokens in and out (else `input` / `output`): OpenAI's and Gemini's image models. */
21
+ input_image?: number;
22
+ output_image?: number;
20
23
  /** µUSD per 1,000 web-search requests (Anthropic's server tool); absent: not priced. */
21
24
  web_search_per_1k?: number;
22
25
  /** Prices once the call's prompt is over `above` tokens (Gemini's >200k tier), highest first. */
@@ -56,6 +59,8 @@ export interface Tokens {
56
59
  cache_write_1h?: number;
57
60
  input_audio?: number;
58
61
  output_audio?: number;
62
+ input_image?: number;
63
+ output_image?: number;
59
64
  }
60
65
  export interface CallCost {
61
66
  seq: number;
@@ -74,6 +79,8 @@ export interface CallCost {
74
79
  /** spec/cost.md §1a: the uplift applied, when one matched. */
75
80
  uplift_percent?: number;
76
81
  source?: "reported";
82
+ /** The answer carried no usage (`usage_missing`): unpriced, never counted as free. */
83
+ no_usage?: true;
77
84
  }
78
85
  export interface CostReport {
79
86
  v: 1;
@@ -2,12 +2,20 @@
2
2
  // Mirrors sdks/python/src/zanii_blackbox/cost.py.
3
3
  import { createHash } from "node:crypto";
4
4
  const CLASSES = ["input", "output", "cache_read", "cache_write"];
5
- const EXTRA = ["cache_write_1h", "input_audio", "output_audio"];
5
+ const EXTRA = [
6
+ "cache_write_1h",
7
+ "input_audio",
8
+ "output_audio",
9
+ "input_image",
10
+ "output_image",
11
+ ];
6
12
  const OPTIONAL_PRICES = [
7
13
  "context_window",
8
14
  "cache_write_1h",
9
15
  "input_audio",
10
16
  "output_audio",
17
+ "input_image",
18
+ "output_image",
11
19
  "web_search_per_1k",
12
20
  ];
13
21
  /** Parses and checks a price table; its sha256 is of the exact file bytes. */
@@ -99,12 +107,20 @@ export function tokensOf(usage) {
99
107
  const cached = n("cachedContentTokenCount");
100
108
  const audioIn = n("promptTokensDetails_AUDIO");
101
109
  const audioOut = n("candidatesTokensDetails_AUDIO");
110
+ const imageIn = n("promptTokensDetails_IMAGE");
111
+ const imageOut = n("candidatesTokensDetails_IMAGE");
102
112
  return extras({
103
- input: Math.max(0, n("promptTokenCount") - cached - audioIn) + n("toolUsePromptTokenCount"),
104
- output: Math.max(0, n("candidatesTokenCount") - audioOut) + n("thoughtsTokenCount"),
113
+ input: Math.max(0, n("promptTokenCount") - cached - audioIn - imageIn) +
114
+ n("toolUsePromptTokenCount"),
115
+ output: Math.max(0, n("candidatesTokenCount") - audioOut - imageOut) + n("thoughtsTokenCount"),
105
116
  cache_read: cached,
106
117
  cache_write: 0,
107
- }, { input_audio: audioIn, output_audio: audioOut });
118
+ }, {
119
+ input_audio: audioIn,
120
+ output_audio: audioOut,
121
+ input_image: imageIn,
122
+ output_image: imageOut,
123
+ });
108
124
  }
109
125
  if ("inputTokens" in usage)
110
126
  // Bedrock Converse (spec/providers.md §5)
@@ -130,18 +146,28 @@ export function tokensOf(usage) {
130
146
  // Anthropic's cache names are its own: they win over the Responses shape, since Anthropic also
131
147
  // sends output_tokens_details (thinking_tokens) since Claude Sonnet 5
132
148
  const anthropicCache = "cache_read_input_tokens" in usage || "cache_creation_input_tokens" in usage;
149
+ // OpenAI's transcriptions write input_token_details (singular); its images, image_tokens
133
150
  if (!anthropicCache &&
134
- Object.keys(usage).some((k) => k.startsWith("input_tokens_details_") || k.startsWith("output_tokens_details_"))) {
151
+ Object.keys(usage).some((k) => k.startsWith("input_tokens_details_") ||
152
+ k.startsWith("output_tokens_details_") ||
153
+ k.startsWith("input_token_details_"))) {
135
154
  const cached = n("input_tokens_details_cached_tokens");
136
155
  const written = n("input_tokens_details_cache_write_tokens");
137
- const audioIn = n("input_tokens_details_audio_tokens");
156
+ const audioIn = n("input_tokens_details_audio_tokens") + n("input_token_details_audio_tokens");
138
157
  const audioOut = n("output_tokens_details_audio_tokens");
158
+ const imageIn = n("input_tokens_details_image_tokens");
159
+ const imageOut = n("output_tokens_details_image_tokens");
139
160
  return extras({
140
- input: Math.max(0, n("input_tokens") - cached - written - audioIn),
141
- output: Math.max(0, n("output_tokens") - audioOut),
161
+ input: Math.max(0, n("input_tokens") - cached - written - audioIn - imageIn),
162
+ output: Math.max(0, n("output_tokens") - audioOut - imageOut),
142
163
  cache_read: cached,
143
164
  cache_write: written,
144
- }, { input_audio: audioIn, output_audio: audioOut });
165
+ }, {
166
+ input_audio: audioIn,
167
+ output_audio: audioOut,
168
+ input_image: imageIn,
169
+ output_image: imageOut,
170
+ });
145
171
  }
146
172
  // Anthropic: cache_creation_input_tokens is both lifetimes; the 1-hour part is priced apart
147
173
  const hour = n("cache_creation_ephemeral_1h_input_tokens");
@@ -167,7 +193,12 @@ export function requestsOf(usage) {
167
193
  }
168
194
  /** A call's prompt, in tokens: what a context tier is decided on. */
169
195
  export function promptTokens(t) {
170
- return t.input + t.cache_read + t.cache_write + (t.cache_write_1h ?? 0) + (t.input_audio ?? 0);
196
+ return (t.input +
197
+ t.cache_read +
198
+ t.cache_write +
199
+ (t.cache_write_1h ?? 0) +
200
+ (t.input_audio ?? 0) +
201
+ (t.input_image ?? 0));
171
202
  }
172
203
  /** The prices a call pays: a context tier when its prompt is over one, then its service tier. */
173
204
  export function effectivePrice(price, tokens, serviceTier) {
@@ -203,6 +234,8 @@ export function callMicroUsd(tokens, price, requests) {
203
234
  sum += (tokens.cache_write_1h ?? 0) * (price.cache_write_1h ?? price.cache_write);
204
235
  sum += (tokens.input_audio ?? 0) * (price.input_audio ?? price.input);
205
236
  sum += (tokens.output_audio ?? 0) * (price.output_audio ?? price.output);
237
+ sum += (tokens.input_image ?? 0) * (price.input_image ?? price.input);
238
+ sum += (tokens.output_image ?? 0) * (price.output_image ?? price.output);
206
239
  sum += (requests?.web_search ?? 0) * (price.web_search_per_1k ?? 0) * 1000;
207
240
  if (!Number.isSafeInteger(sum))
208
241
  throw new RangeError("cost overflow: tokens × price beyond 2^53");
@@ -288,9 +321,19 @@ export function sessionCost(lines, prices) {
288
321
  if (replayed.has(e.meta.request_seq))
289
322
  continue;
290
323
  const usage = e.meta.usage;
291
- if (typeof usage !== "object" || usage === null)
292
- continue;
293
324
  const model = typeof e.meta.model === "string" ? e.meta.model : null;
325
+ if (typeof usage !== "object" || usage === null) {
326
+ // an answer that said nothing of what it cost is unpriced, never silently free
327
+ if (e.meta.usage_missing === true)
328
+ calls.push({
329
+ seq: e.seq,
330
+ model,
331
+ tokens: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
332
+ micro_usd: null,
333
+ no_usage: true,
334
+ });
335
+ continue;
336
+ }
294
337
  const priced = priceCall({
295
338
  model,
296
339
  usage: usage,
@@ -1,3 +1,4 @@
1
+ import type { actionsPage } from "../actions/index.ts";
1
2
  import type { Bundle, BundleReport } from "../verify/index.ts";
2
3
  type Text = {
3
4
  en: string;
@@ -7,7 +8,10 @@ export interface EvidencePackOptions {
7
8
  generated_at: string;
8
9
  bundle_sha256: string;
9
10
  proofs: "checked" | "not_checked";
11
+ /** §7a: an `actionsPage`, when the bodies are at hand: each action with the reasoning before it. */
12
+ actions?: ActionsPage;
10
13
  }
14
+ type ActionsPage = ReturnType<typeof actionsPage>;
11
15
  /** spec/investigations.md §7. */
12
16
  export declare function evidencePack(bundle: Bundle, report: BundleReport, opts: EvidencePackOptions): {
13
17
  v: 1;
@@ -53,6 +57,11 @@ export declare function evidencePack(bundle: Bundle, report: BundleReport, opts:
53
57
  reproduce: string[];
54
58
  generated_at: string;
55
59
  bundle_sha256: string;
60
+ actions?: {
61
+ total: number;
62
+ capped: boolean;
63
+ actions: import("../actions/index.ts").Action[];
64
+ };
56
65
  };
57
66
  export type EvidencePack = ReturnType<typeof evidencePack>;
58
67
  /** The Markdown copy (spec/investigations.md §7): `en`, `ar` or `both`. */
@@ -97,6 +97,7 @@ export function evidencePack(bundle, report, opts) {
97
97
  reproduce: REPRODUCE,
98
98
  generated_at: opts.generated_at,
99
99
  bundle_sha256: opts.bundle_sha256,
100
+ ...(opts.actions ? { actions: opts.actions } : {}),
100
101
  };
101
102
  }
102
103
  // U+2067 … U+2069 isolate the Arabic paragraphs, so they read right to left in any viewer.
@@ -147,5 +148,54 @@ export function renderEvidencePack(pack, lang = "both") {
147
148
  ...pack.reproduce,
148
149
  "```",
149
150
  "",
151
+ ...actionsMd(pack.actions, label),
150
152
  ].join("\n");
151
153
  }
154
+ const KIND = {
155
+ text: ["the model's own reasoning", "تفكير النموذج نفسه"],
156
+ summary: ["the provider's summary of it", "ملخّص المزوّد له"],
157
+ encrypted: ["recorded, but encrypted by the provider", "مسجّل لكنه مشفّر من المزوّد"],
158
+ none: ["none recorded", "لم يُسجَّل شيء"],
159
+ };
160
+ const quote = (text) => text
161
+ .split(/\r?\n|\r/)
162
+ .map((l) => ` > ${l}`)
163
+ .join("\n") || ">";
164
+ /** Python's json.dumps(…, ensure_ascii=False, sort_keys=True): keys sorted, ", " and ": ". */
165
+ function pyJson(v) {
166
+ if (Array.isArray(v))
167
+ return `[${v.map(pyJson).join(", ")}]`;
168
+ if (v && typeof v === "object")
169
+ return `{${Object.keys(v)
170
+ .sort()
171
+ .map((k) => `${JSON.stringify(k)}: ${pyJson(v[k])}`)
172
+ .join(", ")}}`;
173
+ return JSON.stringify(v) ?? "null";
174
+ }
175
+ /** §7a: each action, then the reasoning recorded before it. */
176
+ function actionsMd(page, label) {
177
+ if (!page)
178
+ return [];
179
+ const out = [
180
+ `## ${label("Actions and the reasoning before each", "الإجراءات والتفكير الذي سبق كلًّا منها")}`,
181
+ "",
182
+ ];
183
+ if (!page.actions.length)
184
+ out.push(label("No actions recorded.", "لم تُسجَّل أي إجراءات."), "");
185
+ page.actions.forEach((a, i) => {
186
+ const all = pyJson(a.args);
187
+ const points = Array.from(all);
188
+ const args = points.length <= 300 ? all : `${points.slice(0, 300).join("")}…`;
189
+ const [en, ar] = KIND[a.reasoning.kind] ?? KIND.none;
190
+ out.push(`${i + 1}. **\`${a.tool}\`** · ${label("event", "الحدث")} ${a.seq} · \`${args}\``, "", ` ${label("Reasoning", "التفكير")}: ${label(en, ar)}`, "");
191
+ if (a.reasoning.text)
192
+ out.push(quote(a.reasoning.text), "");
193
+ if (a.said)
194
+ out.push(` ${label("It wrote", "كتب")}:`, "", quote(a.said), "");
195
+ });
196
+ if (page.capped) {
197
+ const n = page.actions.length;
198
+ out.push(label(`The first ${n} of ${page.total} actions.`, `أول ${n} من أصل ${page.total} إجراءات.`), "");
199
+ }
200
+ return out;
201
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { A2A_STREAMING, type AgentCardReport, a2aMethod, a2aRequestMeta, a2aResponseMeta, a2aRestMethod, a2aRestRequestMeta, a2aRestResponseMeta, agentCardDigest, agentCardPayload, gatewayCardKey, rewriteAgentCard, verifyAgentCard, } from "./a2a/index.ts";
2
+ export { type Action, actionsFromCalls, actionsOf, actionsPage, type Reasoning, } from "./actions/index.ts";
3
+ export { ACCREDITATION_TYPE, type AccreditationRefusal, checkAccreditation, checkRegister, docHash, REGISTER_TYPE, SERVICE_ID, type ServiceRow, serviceTier, } from "./admission/index.ts";
2
4
  export { causality, memoryXray } from "./agents/index.ts";
3
5
  export { type DetectorOptions, detectors, fingerprint } from "./analysis/detectors.ts";
4
6
  export { FAULTS, type Fault, faultOf } from "./analysis/faults.ts";
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Public API of @zanii/blackbox. Everything customers can import is re-exported here.
2
2
  // Keep this file in step with sdks/python/src/zanii_blackbox/__init__.py (the SDKs mirror each other).
3
3
  export { A2A_STREAMING, a2aMethod, a2aRequestMeta, a2aResponseMeta, a2aRestMethod, a2aRestRequestMeta, a2aRestResponseMeta, agentCardDigest, agentCardPayload, gatewayCardKey, rewriteAgentCard, verifyAgentCard, } from "./a2a/index.js";
4
+ export { actionsFromCalls, actionsOf, actionsPage, } from "./actions/index.js";
5
+ export { ACCREDITATION_TYPE, checkAccreditation, checkRegister, docHash, REGISTER_TYPE, SERVICE_ID, serviceTier, } from "./admission/index.js";
4
6
  export { causality, memoryXray } from "./agents/index.js";
5
7
  export { detectors, fingerprint } from "./analysis/detectors.js";
6
8
  export { FAULTS, faultOf } from "./analysis/faults.js";
@@ -35,6 +35,8 @@ export interface Call {
35
35
  blocks: Map<number, Block>;
36
36
  /** OpenAI Responses output items, in order. */
37
37
  items: Obj[];
38
+ /** A Chat Completions message's own reasoning (`reasoning_content` or `reasoning`), when given. */
39
+ reasoning?: string;
38
40
  }
39
41
  export type Bodies = (hash: string) => Uint8Array | undefined;
40
42
  export declare function parseJson(text: string): unknown;
@@ -125,9 +125,13 @@ function rebuild(call, body) {
125
125
  if (typeof json.id === "string")
126
126
  call.messageId ??= json.id;
127
127
  const message = json.choices.find(isObj)?.message;
128
- if (isObj(message))
128
+ if (isObj(message)) {
129
129
  chatBlocks(call, message.content, message.tool_calls);
130
+ chatReasoning(call, message);
131
+ }
130
132
  }
133
+ if (Array.isArray(json.candidates))
134
+ geminiParts(call, json);
131
135
  return;
132
136
  }
133
137
  const partial = new Map();
@@ -141,6 +145,10 @@ function rebuild(call, body) {
141
145
  if (!isObj(event))
142
146
  continue;
143
147
  const type = event.type;
148
+ if (Array.isArray(event.candidates)) {
149
+ geminiParts(call, event);
150
+ continue;
151
+ }
144
152
  if (Array.isArray(event.choices)) {
145
153
  if (typeof event.id === "string")
146
154
  call.messageId ??= event.id;
@@ -149,6 +157,7 @@ function rebuild(call, body) {
149
157
  continue;
150
158
  if (typeof delta.content === "string")
151
159
  chatText = (chatText ?? "") + delta.content;
160
+ chatReasoning(call, delta);
152
161
  for (const tc of Array.isArray(delta.tool_calls) ? delta.tool_calls.filter(isObj) : []) {
153
162
  const k = typeof tc.index === "number" ? tc.index : 0;
154
163
  const acc = chatCalls.get(k) ?? { arguments: "" };
@@ -210,6 +219,43 @@ function rebuild(call, body) {
210
219
  * its text and tool calls: a text block (when there's text), then one tool_use per tool call, its
211
220
  * arguments parsed from their JSON string.
212
221
  */
222
+ /** spec/reconcile.md §2: a Chat Completions message's (or a stream delta's) own reasoning. */
223
+ function chatReasoning(call, m) {
224
+ for (const key of ["reasoning_content", "reasoning"]) {
225
+ const v = m[key];
226
+ if (typeof v === "string" && v) {
227
+ call.reasoning = (call.reasoning ?? "") + v;
228
+ return;
229
+ }
230
+ }
231
+ }
232
+ /** spec/reconcile.md §2: Gemini's parts (a whole answer, or one streamed chunk) as blocks: its
233
+ * thoughts as thinking, its text as text, each functionCall as a tool_use. */
234
+ function geminiParts(call, event) {
235
+ if (typeof event.responseId === "string")
236
+ call.messageId ??= event.responseId;
237
+ const first = event.candidates.find(isObj) ?? {};
238
+ const content = first.content;
239
+ const parts = isObj(content) && Array.isArray(content.parts) ? content.parts.filter(isObj) : [];
240
+ for (const p of parts) {
241
+ const fc = p.functionCall;
242
+ if (isObj(fc) && typeof fc.name === "string") {
243
+ const block = { type: "tool_use", name: fc.name, input: fc.args ?? {} };
244
+ if (typeof fc.id === "string")
245
+ block.id = fc.id;
246
+ call.blocks.set(call.blocks.size, block);
247
+ continue;
248
+ }
249
+ if (typeof p.text !== "string")
250
+ continue;
251
+ const kind = p.thought === true ? "thinking" : "text";
252
+ const last = call.blocks.get(call.blocks.size - 1);
253
+ if (last && last.type === kind)
254
+ last[kind] = (last[kind] ?? "") + p.text; // a streamed chunk
255
+ else
256
+ call.blocks.set(call.blocks.size, { type: kind, [kind]: p.text });
257
+ }
258
+ }
213
259
  function chatBlocks(call, content, toolCalls) {
214
260
  let i = 0;
215
261
  if (typeof content === "string" && content !== "")
@@ -23,5 +23,9 @@ export interface VerifyOptions {
23
23
  count: number;
24
24
  head: string;
25
25
  }>;
26
+ /** A continuation (spec/tower.md §3): the first line's seq, and the event_hash it follows.
27
+ * Anchors need the whole chain. */
28
+ startSeq?: number;
29
+ startPrev?: string;
26
30
  }
27
31
  export declare function verifyChain(lines: readonly string[], options?: VerifyOptions): VerifyResult;
@@ -7,7 +7,8 @@ const SESSION = /^ses_[A-Za-z0-9]{16,64}$/;
7
7
  const TS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/;
8
8
  const KINDS = new Set(EVENT_KINDS);
9
9
  export function verifyChain(lines, options = {}) {
10
- let prev = GENESIS;
10
+ let prev = options.startPrev ?? GENESIS;
11
+ const start = options.startSeq ?? 0;
11
12
  const hashes = [];
12
13
  let session = options.sessionId;
13
14
  const fail = (code, index, message) => ({
@@ -34,8 +35,8 @@ export function verifyChain(lines, options = {}) {
34
35
  session ??= env.session_id;
35
36
  if (env.session_id !== session)
36
37
  return fail("WRONG_SESSION", i, `session_id ${env.session_id} ≠ ${session}`);
37
- if (env.seq !== i)
38
- return fail("SEQ_GAP", i, `expected seq ${i}, found ${env.seq}`);
38
+ if (env.seq !== start + i)
39
+ return fail("SEQ_GAP", i, `expected seq ${start + i}, found ${env.seq}`);
39
40
  if (env.prev !== prev)
40
41
  return fail("BROKEN_LINK", i, "prev does not match the previous event_hash");
41
42
  const body = options.body?.(env.body_hash);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.21.1";
1
+ export declare const VERSION = "0.22.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // The SDK version, on its own so the CLI can print it without loading the whole SDK.
2
- export const VERSION = "0.21.1";
2
+ export const VERSION = "0.22.0";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zanii/blackbox",
3
3
  "license": "Apache-2.0",
4
- "version": "0.21.1",
4
+ "version": "0.22.0",
5
5
  "description": "The flight recorder for AI agents: sessions, a zero-loss spool, framework hooks, approvals, and offline verification of the gateway's hash-chained record.",
6
6
  "keywords": [
7
7
  "ai-agents",