@nanobpm/nano-workforce 0.168.0 → 0.168.2
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.
- package/CHANGELOG.md +12 -0
- package/app/agentic/cockpit/cockpit-browser-bundle.test.ts +102 -0
- package/app/agentic/cockpit/mount.test.ts +162 -0
- package/package.json +4 -2
- package/pages/cockpit/cockpit.css +118 -0
- package/pages/cockpit/embed.html +1 -3
- package/pages/cockpit/generated/transcript-derive.js +260 -0
- package/pages/cockpit/generated/transcript-events.js +488 -0
- package/pages/cockpit/mount.js +85 -39
- package/pages/cockpit/standalone.html +7 -8
- package/scripts/build-cockpit-browser.ts +110 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
// @generated from app/agentic/transcript-events.ts by scripts/build-cockpit-browser.ts — DO NOT EDIT.
|
|
2
|
+
//
|
|
3
|
+
// Browser ESM derived (type-strip only) from the typed transcript core so pages/cockpit/mount.js
|
|
4
|
+
// renders the agentic transcript from ONE source of truth (#660). Regenerate with:
|
|
5
|
+
// node --experimental-strip-types scripts/build-cockpit-browser.ts
|
|
6
|
+
|
|
7
|
+
// nano-workforce — the transcript EVENT vocabulary + the single derive() fold (ADR 0056, #251).
|
|
8
|
+
//
|
|
9
|
+
// This is the "event-sourced session" layer over the H3 transcript store (#146/#222). The store is
|
|
10
|
+
// already append-only and offset-keyed — chunks are appended, never mutated — which is half of the
|
|
11
|
+
// dsh (DeepSeek Harness) event-sourced-session pattern. The gap it left is that chunks are opaque
|
|
12
|
+
// `TEXT`: every richer view (structured message history, tool cards, per-turn boundaries, token
|
|
13
|
+
// accounting) had to re-parse the raw frame bytes ad hoc, a DRIFT SURFACE (two parsers of the same
|
|
14
|
+
// bytes), which our "Derivation Over Duplication" doctrine forbids.
|
|
15
|
+
//
|
|
16
|
+
// This module closes that gap the way dsh does: the append-only log of TYPED events is the single
|
|
17
|
+
// source of truth, and every higher-level view is a DERIVATION of that one log via a single
|
|
18
|
+
// {@link deriveView} fold — "the log IS the state, so divergence is structurally impossible". A raw
|
|
19
|
+
// terminal chunk is retained verbatim as a `stream-chunk` event (byte-level replay fidelity is
|
|
20
|
+
// preserved); a producer that emits a structured, marker-tagged JSON envelope is decoded into the
|
|
21
|
+
// authoritative typed events (message / tool-call / tool-result / turn / step / lifecycle) the derived
|
|
22
|
+
// views fold over — mirroring dsh (raw chunks for token-replay, `assistant/message` authoritative).
|
|
23
|
+
//
|
|
24
|
+
// THE ONE PARSER. {@link parseTranscriptEvent} is the SINGLE place a stored chunk is classified into a
|
|
25
|
+
// typed event; every consumer (cockpit, search, token accounting, export) reads the derived view, not
|
|
26
|
+
// the raw bytes. A drift-guard test (`transcript-events.drift.test.ts`) asserts the event marker — and
|
|
27
|
+
// therefore the raw→event parse — appears in exactly this module, so a second parser cannot creep in.
|
|
28
|
+
//
|
|
29
|
+
// MERGE-EXTENSIBLE. The vocabulary is a small core ({@link CORE_TRANSCRIPT_VOCAB}) authors extend in the
|
|
30
|
+
// same schema with {@link mergeTranscriptVocab} (cribbed from dsh's merge-extensible event taxonomy and
|
|
31
|
+
// the S3 `mergeVocab`), so a new event kind is an additive merge, never a fork of the parser.
|
|
32
|
+
//
|
|
33
|
+
// Pure and side-effect-free: no I/O, unit-testable on Node, and it never touches the engine or a BPMN
|
|
34
|
+
// flow (ADR 0056: app-tier only, advisory).
|
|
35
|
+
/**
|
|
36
|
+
* Runtime-safe UTF-8 byte length. This module is imported by cockpit code that runs in the BROWSER
|
|
37
|
+
* (via `cockpit/transcript-derive.ts`), where Node's `Buffer` global is not available — a bare
|
|
38
|
+
* `Buffer.byteLength` would throw at runtime when deriving the view for a replayed transcript. Prefer
|
|
39
|
+
* `Buffer` when present (Node) and fall back to `TextEncoder` (a Web/Node standard) otherwise, so the
|
|
40
|
+
* single derive fold is portable across both hosts. This is the one canonical UTF-8 byte-length
|
|
41
|
+
* implementation the transcript plane derives from (reused by `transcript-read.ts`).
|
|
42
|
+
*/
|
|
43
|
+
let cachedTextEncoder;
|
|
44
|
+
export function utf8ByteLength(text) {
|
|
45
|
+
if (typeof Buffer !== "undefined")
|
|
46
|
+
return Buffer.byteLength(text, "utf8");
|
|
47
|
+
// Cache one TextEncoder in the browser hot path (folding many stream-chunk events) to avoid
|
|
48
|
+
// allocating a new encoder — and the GC pressure it creates — on every call.
|
|
49
|
+
cachedTextEncoder ??= new TextEncoder();
|
|
50
|
+
return cachedTextEncoder.encode(text).length;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The reserved marker field that distinguishes a structured transcript-event envelope from raw
|
|
54
|
+
* terminal bytes. A stored chunk is decoded as a typed event ONLY when it is a JSON object carrying
|
|
55
|
+
* this field set to the schema version — otherwise it is retained verbatim as a raw `stream-chunk`, so
|
|
56
|
+
* a raw ANSI frame that happens to be valid JSON is never mis-classified. Namespaced to nano-workforce
|
|
57
|
+
* so it cannot collide with a producer's own payload keys.
|
|
58
|
+
*/
|
|
59
|
+
export const TRANSCRIPT_EVENT_MARKER = "nwfTranscriptEvent";
|
|
60
|
+
/** The current transcript-event envelope schema version (the value {@link TRANSCRIPT_EVENT_MARKER} carries). */
|
|
61
|
+
export const TRANSCRIPT_EVENT_VERSION = 1;
|
|
62
|
+
/** Pure, canonical: does a permission option kind ALLOW (true) or REJECT (false) the proposed action?
|
|
63
|
+
* The `allow-*` vs `reject-*` prefix is the single source of truth. This lives beside
|
|
64
|
+
* {@link PermissionOptionKind} so every consumer (the cockpit render seam and the permission-escalation
|
|
65
|
+
* bridge) derives allow/deny from ONE implementation — the two paths can never disagree on what a
|
|
66
|
+
* chosen option means (no drift surface). */
|
|
67
|
+
export function optionKindAllows(kind) {
|
|
68
|
+
return kind === "allow-once" || kind === "allow-always";
|
|
69
|
+
}
|
|
70
|
+
function str(body, key) {
|
|
71
|
+
const v = body[key];
|
|
72
|
+
return typeof v === "string" ? v : undefined;
|
|
73
|
+
}
|
|
74
|
+
function num(body, key) {
|
|
75
|
+
const v = body[key];
|
|
76
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
77
|
+
}
|
|
78
|
+
const ROLES = ["assistant", "user", "system", "tool"];
|
|
79
|
+
/** Narrow an arbitrary string to a known {@link TranscriptRole}, defaulting to `assistant`. */
|
|
80
|
+
function toRole(value) {
|
|
81
|
+
return ROLES.find((role) => role === value) ?? "assistant";
|
|
82
|
+
}
|
|
83
|
+
/** A structural guard: a non-null, non-array object is a plain record of unknown values. */
|
|
84
|
+
function isRecord(value) {
|
|
85
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
86
|
+
}
|
|
87
|
+
const PERMISSION_OPTION_KINDS = [
|
|
88
|
+
"allow-once",
|
|
89
|
+
"allow-always",
|
|
90
|
+
"reject-once",
|
|
91
|
+
"reject-always",
|
|
92
|
+
];
|
|
93
|
+
/**
|
|
94
|
+
* Decode ACP's `options[]` into typed {@link PermissionOption}s, or `undefined` if the array is
|
|
95
|
+
* missing/empty or any member is malformed (so the whole request envelope is rejected → `stream-chunk`).
|
|
96
|
+
*/
|
|
97
|
+
function decodePermissionOptions(value) {
|
|
98
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
99
|
+
return undefined;
|
|
100
|
+
const options = [];
|
|
101
|
+
for (const raw of value) {
|
|
102
|
+
if (!isRecord(raw))
|
|
103
|
+
return undefined;
|
|
104
|
+
const optionId = str(raw, "optionId");
|
|
105
|
+
const name = str(raw, "name");
|
|
106
|
+
const kindRaw = str(raw, "kind");
|
|
107
|
+
const kind = PERMISSION_OPTION_KINDS.find((k) => k === kindRaw);
|
|
108
|
+
if (optionId === undefined || name === undefined || kind === undefined)
|
|
109
|
+
return undefined;
|
|
110
|
+
options.push({ optionId, name, kind });
|
|
111
|
+
}
|
|
112
|
+
return options;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The opinionated core vocabulary — the built-in event kinds every consumer understands out of the
|
|
116
|
+
* box. Authors extend it in the SAME schema via {@link mergeTranscriptVocab}; they never fork the
|
|
117
|
+
* parser. (`stream-chunk` is not decoded here — it is the fallback the parser applies to any chunk
|
|
118
|
+
* that is not a well-formed typed envelope, so raw fidelity needs no decoder.)
|
|
119
|
+
*/
|
|
120
|
+
export const CORE_TRANSCRIPT_VOCAB = Object.freeze({
|
|
121
|
+
message: (body, offset) => {
|
|
122
|
+
const text = str(body, "text");
|
|
123
|
+
if (text === undefined)
|
|
124
|
+
return undefined;
|
|
125
|
+
const roleRaw = str(body, "role");
|
|
126
|
+
return { kind: "message", offset, role: toRole(roleRaw), text };
|
|
127
|
+
},
|
|
128
|
+
"tool-call": (body, offset) => {
|
|
129
|
+
const name = str(body, "name");
|
|
130
|
+
if (name === undefined)
|
|
131
|
+
return undefined;
|
|
132
|
+
const event = { kind: "tool-call", offset, name };
|
|
133
|
+
const callId = str(body, "callId");
|
|
134
|
+
return {
|
|
135
|
+
...event,
|
|
136
|
+
...(callId !== undefined ? { callId } : {}),
|
|
137
|
+
...("args" in body ? { args: body.args } : {}),
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
"tool-result": (body, offset) => {
|
|
141
|
+
const ok = typeof body.ok === "boolean" ? body.ok : true;
|
|
142
|
+
const event = { kind: "tool-result", offset, ok };
|
|
143
|
+
const callId = str(body, "callId");
|
|
144
|
+
const content = str(body, "content");
|
|
145
|
+
return {
|
|
146
|
+
...event,
|
|
147
|
+
...(callId !== undefined ? { callId } : {}),
|
|
148
|
+
...(content !== undefined ? { content } : {}),
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
// ACP `plan` mapping: ACP `session/update` plan updates map onto the EXISTING `step`/`turn`
|
|
152
|
+
// vocabulary rather than a new kind — an ACP plan ENTRY becomes a `step` (its `label` is the plan
|
|
153
|
+
// entry's title; the entry ordinal is not preserved, as `StepEvent` carries only a `label`), and a
|
|
154
|
+
// plan/turn BOUNDARY becomes a `turn` (its `index` the ACP turn/plan ordinal). The decoders below
|
|
155
|
+
// already cope with an ACP-shaped `label` (`step`) / `index` (`turn`), so no new kind is needed.
|
|
156
|
+
turn: (body, offset) => {
|
|
157
|
+
const index = num(body, "index");
|
|
158
|
+
return index !== undefined ? { kind: "turn", offset, index } : { kind: "turn", offset };
|
|
159
|
+
},
|
|
160
|
+
step: (body, offset) => {
|
|
161
|
+
const label = str(body, "label");
|
|
162
|
+
return label !== undefined ? { kind: "step", offset, label } : { kind: "step", offset };
|
|
163
|
+
},
|
|
164
|
+
lifecycle: (body, offset) => {
|
|
165
|
+
const phase = str(body, "phase");
|
|
166
|
+
if (phase !== "open" && phase !== "completed" && phase !== "exited")
|
|
167
|
+
return undefined;
|
|
168
|
+
return { kind: "lifecycle", offset, phase };
|
|
169
|
+
},
|
|
170
|
+
// A single `permission` decoder handles BOTH shapes (never a parser fork), branching on `phase`.
|
|
171
|
+
// Malformed envelopes return `undefined` and fall back to `stream-chunk`, like the other decoders.
|
|
172
|
+
permission: (body, offset) => {
|
|
173
|
+
const callId = str(body, "callId");
|
|
174
|
+
if (callId === undefined)
|
|
175
|
+
return undefined;
|
|
176
|
+
const phase = str(body, "phase");
|
|
177
|
+
if (phase === "request") {
|
|
178
|
+
const policy = str(body, "policy");
|
|
179
|
+
if (policy !== "escalate" && policy !== "yolo")
|
|
180
|
+
return undefined;
|
|
181
|
+
const options = decodePermissionOptions(body.options);
|
|
182
|
+
if (options === undefined)
|
|
183
|
+
return undefined;
|
|
184
|
+
const toolName = str(body, "toolName");
|
|
185
|
+
const title = str(body, "title");
|
|
186
|
+
const reason = str(body, "reason");
|
|
187
|
+
const event = { kind: "permission", phase: "request", offset, callId, policy, options };
|
|
188
|
+
return {
|
|
189
|
+
...event,
|
|
190
|
+
...(toolName !== undefined ? { toolName } : {}),
|
|
191
|
+
...(title !== undefined ? { title } : {}),
|
|
192
|
+
...(reason !== undefined ? { reason } : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (phase === "resolution") {
|
|
196
|
+
const optionId = str(body, "optionId");
|
|
197
|
+
if (optionId === undefined)
|
|
198
|
+
return undefined;
|
|
199
|
+
if (typeof body.allowed !== "boolean")
|
|
200
|
+
return undefined;
|
|
201
|
+
const by = str(body, "by");
|
|
202
|
+
// Reject a malformed `by` rather than silently dropping it: a present-but-unknown provenance is a
|
|
203
|
+
// producer bug, and swallowing it would make the typed event diverge from the on-wire JSON. This
|
|
204
|
+
// covers BOTH a present-but-non-string `by` (e.g. `by: 123`, where str() coerces to undefined) and
|
|
205
|
+
// a string that isn't a known provenance — either way the on-wire `by` is present but invalid.
|
|
206
|
+
if (body.by !== undefined && by === undefined)
|
|
207
|
+
return undefined;
|
|
208
|
+
if (by !== undefined && by !== "operator" && by !== "auto")
|
|
209
|
+
return undefined;
|
|
210
|
+
const event = {
|
|
211
|
+
kind: "permission",
|
|
212
|
+
phase: "resolution",
|
|
213
|
+
offset,
|
|
214
|
+
callId,
|
|
215
|
+
optionId,
|
|
216
|
+
allowed: body.allowed,
|
|
217
|
+
};
|
|
218
|
+
return { ...event, ...(by !== undefined ? { by } : {}) };
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
/**
|
|
224
|
+
* Extend a vocabulary additively: later entries win on a key clash, so an author can either register a
|
|
225
|
+
* brand-new kind or deliberately override a core decoder. Returns a NEW frozen vocab — neither input is
|
|
226
|
+
* mutated — so the core stays canonical. (Cribbed from dsh's merge-extensible taxonomy / the S3
|
|
227
|
+
* `mergeVocab`: one schema, extended by merge, never a second parser.)
|
|
228
|
+
*/
|
|
229
|
+
export function mergeTranscriptVocab(base, ...extensions) {
|
|
230
|
+
return Object.freeze(Object.assign({}, base, ...extensions));
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* THE ONE PARSER. Classify a single stored chunk into a typed {@link TranscriptEvent}.
|
|
234
|
+
*
|
|
235
|
+
* A chunk is decoded as a structured event ONLY when it is a JSON object carrying the
|
|
236
|
+
* {@link TRANSCRIPT_EVENT_MARKER} at the current version AND a `kind` the vocab knows AND its decoder
|
|
237
|
+
* accepts the body. Anything else — raw terminal bytes, non-JSON, a JSON value without the marker, an
|
|
238
|
+
* unknown kind, a decoder rejection — is retained verbatim as a `stream-chunk`, so byte-level replay
|
|
239
|
+
* fidelity is never lost. This is the SINGLE point at which raw bytes become typed events; every view
|
|
240
|
+
* folds over the result of this function, so there is exactly one parser of the log.
|
|
241
|
+
*/
|
|
242
|
+
export function parseTranscriptEvent(entry, vocab = CORE_TRANSCRIPT_VOCAB) {
|
|
243
|
+
const raw = { kind: "stream-chunk", offset: entry.offset, chunk: entry.chunk };
|
|
244
|
+
const body = decodeEnvelope(entry.chunk);
|
|
245
|
+
if (body === undefined)
|
|
246
|
+
return raw;
|
|
247
|
+
const kind = typeof body.kind === "string" ? body.kind : undefined;
|
|
248
|
+
if (kind === undefined)
|
|
249
|
+
return raw;
|
|
250
|
+
const decoder = vocab[kind];
|
|
251
|
+
if (decoder === undefined)
|
|
252
|
+
return raw;
|
|
253
|
+
return decoder(body, entry.offset) ?? raw;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Decode a chunk into a marker-tagged envelope body, or `undefined` when it is not one. Kept private
|
|
257
|
+
* so `JSON.parse` of a chunk lives in exactly one place (the drift-guard depends on this).
|
|
258
|
+
*/
|
|
259
|
+
function decodeEnvelope(chunk) {
|
|
260
|
+
// Cheap reject before the parse: a valid envelope is a JSON object mentioning the marker key.
|
|
261
|
+
const trimmed = chunk.trimStart();
|
|
262
|
+
if (!trimmed.startsWith("{") || !chunk.includes(TRANSCRIPT_EVENT_MARKER))
|
|
263
|
+
return undefined;
|
|
264
|
+
let parsed;
|
|
265
|
+
try {
|
|
266
|
+
parsed = JSON.parse(chunk);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
if (!isRecord(parsed))
|
|
272
|
+
return undefined;
|
|
273
|
+
return parsed[TRANSCRIPT_EVENT_MARKER] === TRANSCRIPT_EVENT_VERSION ? parsed : undefined;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Encode a typed event into the stored-chunk wire form a structured producer appends. The inverse of
|
|
277
|
+
* {@link parseTranscriptEvent} for every non-raw kind (a `stream-chunk` is stored as its own raw bytes,
|
|
278
|
+
* so it is returned verbatim). Provided so producers and tests speak the one envelope grammar rather
|
|
279
|
+
* than hand-rolling the marker — the derivation-over-duplication rule applied to the write side too.
|
|
280
|
+
*/
|
|
281
|
+
export function encodeTranscriptEvent(event) {
|
|
282
|
+
if (event.kind === "stream-chunk")
|
|
283
|
+
return event.chunk;
|
|
284
|
+
const { offset: _offset, ...rest } = event;
|
|
285
|
+
return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, ...rest });
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* THE SINGLE FOLD. Derive every higher-level view from the typed event log — "the log IS the state".
|
|
289
|
+
*
|
|
290
|
+
* Folds the events (assumed in offset order — the store's append order) into per-turn structure, a flat
|
|
291
|
+
* message history, tool cards (each call paired to its result by `callId`, else the most recent open
|
|
292
|
+
* call), raw-byte accounting for replay fidelity, and the session lifecycle. It is a pure reduction of
|
|
293
|
+
* one log: the cockpit, search, token accounting and export all read THIS, so there is never a second
|
|
294
|
+
* parser of the same bytes. Content that precedes the first explicit `turn` event opens an implicit
|
|
295
|
+
* turn 0, so a producer that never emits turn boundaries still derives a coherent single-turn view.
|
|
296
|
+
*/
|
|
297
|
+
export function deriveView(events) {
|
|
298
|
+
const turns = [];
|
|
299
|
+
const messages = [];
|
|
300
|
+
const tools = [];
|
|
301
|
+
const permissions = [];
|
|
302
|
+
const openTools = new Map();
|
|
303
|
+
let anonymousTool;
|
|
304
|
+
const openPermissions = new Map();
|
|
305
|
+
let rawByteLength = 0;
|
|
306
|
+
let rawChunkCount = 0;
|
|
307
|
+
let lifecycle = "open";
|
|
308
|
+
let eventCount = 0;
|
|
309
|
+
let current;
|
|
310
|
+
const ensureTurn = (offset) => {
|
|
311
|
+
if (current === undefined) {
|
|
312
|
+
current = { index: turns.length, startOffset: offset, messages: [], tools: [], permissions: [], steps: 0 };
|
|
313
|
+
turns.push(current);
|
|
314
|
+
}
|
|
315
|
+
return current;
|
|
316
|
+
};
|
|
317
|
+
for (const event of events) {
|
|
318
|
+
eventCount++;
|
|
319
|
+
switch (event.kind) {
|
|
320
|
+
case "turn": {
|
|
321
|
+
current = {
|
|
322
|
+
index: event.index ?? turns.length,
|
|
323
|
+
startOffset: event.offset,
|
|
324
|
+
messages: [],
|
|
325
|
+
tools: [],
|
|
326
|
+
permissions: [],
|
|
327
|
+
steps: 0,
|
|
328
|
+
};
|
|
329
|
+
turns.push(current);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
case "step": {
|
|
333
|
+
ensureTurn(event.offset).steps++;
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
case "message": {
|
|
337
|
+
const msg = { role: event.role, text: event.text, offset: event.offset };
|
|
338
|
+
messages.push(msg);
|
|
339
|
+
ensureTurn(event.offset).messages.push(msg);
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
case "tool-call": {
|
|
343
|
+
const tool = {
|
|
344
|
+
name: event.name,
|
|
345
|
+
offset: event.offset,
|
|
346
|
+
...(event.callId !== undefined ? { callId: event.callId } : {}),
|
|
347
|
+
...(event.args !== undefined ? { args: event.args } : {}),
|
|
348
|
+
};
|
|
349
|
+
tools.push(tool);
|
|
350
|
+
ensureTurn(event.offset).tools.push(tool);
|
|
351
|
+
if (event.callId !== undefined)
|
|
352
|
+
openTools.set(event.callId, tool);
|
|
353
|
+
else
|
|
354
|
+
anonymousTool = tool;
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case "tool-result": {
|
|
358
|
+
const target = event.callId !== undefined ? openTools.get(event.callId) : anonymousTool;
|
|
359
|
+
if (target !== undefined) {
|
|
360
|
+
pairResult(tools, target, event);
|
|
361
|
+
pairResultInTurns(turns, target, event);
|
|
362
|
+
if (event.callId !== undefined)
|
|
363
|
+
openTools.delete(event.callId);
|
|
364
|
+
else
|
|
365
|
+
anonymousTool = undefined;
|
|
366
|
+
}
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
case "permission": {
|
|
370
|
+
// A `permission` event is one of two phases (same discriminant `kind`); branch on `phase`. A
|
|
371
|
+
// REQUEST opens a pending DerivedPermission (paired to its turn); a RESOLUTION folds back into
|
|
372
|
+
// the open request by `callId` — mirroring the tool-call/tool-result open-map pairing above.
|
|
373
|
+
if (event.phase === "request") {
|
|
374
|
+
const permission = {
|
|
375
|
+
policy: event.policy,
|
|
376
|
+
options: event.options,
|
|
377
|
+
offset: event.offset,
|
|
378
|
+
callId: event.callId,
|
|
379
|
+
...(event.toolName !== undefined ? { toolName: event.toolName } : {}),
|
|
380
|
+
...(event.title !== undefined ? { title: event.title } : {}),
|
|
381
|
+
...(event.reason !== undefined ? { reason: event.reason } : {}),
|
|
382
|
+
};
|
|
383
|
+
permissions.push(permission);
|
|
384
|
+
ensureTurn(event.offset).permissions.push(permission);
|
|
385
|
+
openPermissions.set(event.callId, permission);
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
const target = openPermissions.get(event.callId);
|
|
389
|
+
if (target !== undefined) {
|
|
390
|
+
pairResolution(permissions, target, event);
|
|
391
|
+
pairResolutionInTurns(turns, target, event);
|
|
392
|
+
openPermissions.delete(event.callId);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case "lifecycle": {
|
|
398
|
+
lifecycle = event.phase;
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
case "stream-chunk": {
|
|
402
|
+
rawByteLength += utf8ByteLength(event.chunk);
|
|
403
|
+
rawChunkCount++;
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
turns: turns.map((t) => ({
|
|
410
|
+
index: t.index,
|
|
411
|
+
startOffset: t.startOffset,
|
|
412
|
+
messages: t.messages,
|
|
413
|
+
tools: t.tools,
|
|
414
|
+
permissions: t.permissions,
|
|
415
|
+
steps: t.steps,
|
|
416
|
+
})),
|
|
417
|
+
messages,
|
|
418
|
+
tools,
|
|
419
|
+
permissions,
|
|
420
|
+
rawByteLength,
|
|
421
|
+
rawChunkCount,
|
|
422
|
+
lifecycle,
|
|
423
|
+
eventCount,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
/** Replace a pending tool with its result in the flat list. A pending tool starts as the same object in
|
|
427
|
+
* both the flat list and its turn (pushed by reference), so {@link pairResultInTurns} locates it there by
|
|
428
|
+
* identity; each list is then replaced independently with its own resolved copy via {@link withResult}. */
|
|
429
|
+
function pairResult(list, target, result) {
|
|
430
|
+
const idx = list.indexOf(target);
|
|
431
|
+
if (idx >= 0)
|
|
432
|
+
list[idx] = withResult(target, result);
|
|
433
|
+
}
|
|
434
|
+
/** Replace a pending tool with its result inside whichever turn holds it. */
|
|
435
|
+
function pairResultInTurns(turns, target, result) {
|
|
436
|
+
for (const turn of turns) {
|
|
437
|
+
const idx = turn.tools.indexOf(target);
|
|
438
|
+
if (idx >= 0) {
|
|
439
|
+
turn.tools[idx] = withResult(target, result);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function withResult(tool, result) {
|
|
445
|
+
return {
|
|
446
|
+
...tool,
|
|
447
|
+
result: { ok: result.ok, offset: result.offset, ...(result.content !== undefined ? { content: result.content } : {}) },
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/** Replace a pending permission with its resolution in the flat list (by identity — see {@link pairResult}). */
|
|
451
|
+
function pairResolution(list, target, resolution) {
|
|
452
|
+
const idx = list.indexOf(target);
|
|
453
|
+
if (idx >= 0)
|
|
454
|
+
list[idx] = withResolution(target, resolution);
|
|
455
|
+
}
|
|
456
|
+
/** Replace a pending permission with its resolution inside whichever turn holds it. */
|
|
457
|
+
function pairResolutionInTurns(turns, target, resolution) {
|
|
458
|
+
for (const turn of turns) {
|
|
459
|
+
const idx = turn.permissions.indexOf(target);
|
|
460
|
+
if (idx >= 0) {
|
|
461
|
+
turn.permissions[idx] = withResolution(target, resolution);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
function withResolution(permission, resolution) {
|
|
467
|
+
return {
|
|
468
|
+
...permission,
|
|
469
|
+
resolved: {
|
|
470
|
+
allowed: resolution.allowed,
|
|
471
|
+
optionId: resolution.optionId,
|
|
472
|
+
offset: resolution.offset,
|
|
473
|
+
...(resolution.by !== undefined ? { by: resolution.by } : {}),
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Convenience: parse a run of stored chunks into typed events through {@link parseTranscriptEvent} (the
|
|
479
|
+
* one parser) and fold them with {@link deriveView} in a single call — the entry point a consumer uses
|
|
480
|
+
* to go from stored bytes to a derived view without ever touching a second parser.
|
|
481
|
+
*/
|
|
482
|
+
export function deriveViewFromChunks(chunks, vocab = CORE_TRANSCRIPT_VOCAB) {
|
|
483
|
+
function* parsed() {
|
|
484
|
+
for (const entry of chunks)
|
|
485
|
+
yield parseTranscriptEvent(entry, vocab);
|
|
486
|
+
}
|
|
487
|
+
return deriveView(parsed());
|
|
488
|
+
}
|