@zanii/blackbox 0.21.2 → 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"]);
@@ -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.2";
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.2";
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.2",
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",