@nanobpm/nano-workforce 0.185.1 → 0.186.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.
- package/CHANGELOG.md +6 -0
- package/app/agentic/cockpit/cockpit-browser-bundle.test.ts +14 -5
- package/app/agentic/cockpit/transcript-derive.display.test.ts +231 -0
- package/app/agentic/cockpit/transcript-derive.ts +297 -63
- package/app/agentic/transcript-display.ts +17 -0
- package/package.json +2 -2
- package/pages/cockpit/generated/transcript-derive.js +213 -50
- package/pages/cockpit/generated/transcript-display.js +316 -0
- package/pages/cockpit/generated/transcript-events.js +35 -1
- package/pages/cockpit/mount.js +83 -29
- package/scripts/build-cockpit-browser.ts +11 -1
|
@@ -1,30 +1,53 @@
|
|
|
1
|
-
// The cockpit STRUCTURED transcript view — derived from the one
|
|
1
|
+
// The cockpit STRUCTURED transcript view — derived from the one ORDERED DISPLAY projection (#566, #757).
|
|
2
2
|
//
|
|
3
3
|
// Beside the byte-level replay (`transcript-render.ts` feeds stored chunks through the live terminal
|
|
4
|
-
// renderer for pixel-faithful playback), the cockpit
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
4
|
+
// renderer for pixel-faithful playback), the cockpit shows a STRUCTURED view of a captured session. That
|
|
5
|
+
// view is a DERIVATION of the one typed event log — it re-parses nothing. It folds the stored chunks
|
|
6
|
+
// through the single {@link parseTranscriptEvent} parser and the canonical ORDERED DISPLAY projection
|
|
7
|
+
// ({@link createDisplayProjection}, agentic #566): the projection coalesces transport-fragmented message
|
|
8
|
+
// deltas ("I", "not", "ice I am act", …) back into ONE growing block per logical message, and interleaves
|
|
9
|
+
// text blocks, tool cards and permission prompts in strict chronological (offset) order. So a split word
|
|
10
|
+
// reconstructs into exactly that word in one coherent block — never one bordered card per delta — and a
|
|
11
|
+
// tool call issued mid-message renders between the text before and after it (issue #757). The raw log,
|
|
12
|
+
// its offsets and byte-faithful replay are untouched; the drift-guard test enforces this module never
|
|
13
|
+
// parses chunks itself.
|
|
11
14
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
+
// INCREMENTAL, not rebuild-on-every-chunk. {@link createIncrementalTranscript} keeps the display
|
|
16
|
+
// projection and a `blockId → DOM node` map as mutable state, so a live delta updates the ONE active
|
|
17
|
+
// block's node in place (append a new node, or patch an existing one) instead of rebuilding the whole
|
|
18
|
+
// transcript tree. That is what lets the browser adapter (`pages/cockpit/mount.js`) preserve the
|
|
19
|
+
// operator's selection, expansion and scroll position and auto-follow only at the tail. {@link
|
|
20
|
+
// renderDerivedTranscript} is the pure batch convenience over the same fold — historical replay renders
|
|
21
|
+
// IDENTICALLY to the final live rendering because both drive the one incremental renderer.
|
|
22
|
+
//
|
|
23
|
+
// Framework-free and DOM-agnostic, like the sibling cockpit views: it draws into the injected {@link
|
|
24
|
+
// DocumentLike} subset so a real DOM satisfies it at runtime and an in-memory fake satisfies it for
|
|
25
|
+
// DOM-free Node tests.
|
|
15
26
|
import type { DocumentLike, ElementLike } from "@nanobpm/agentic/cockpit";
|
|
27
|
+
import { createDisplayProjection } from "../transcript-display.ts";
|
|
16
28
|
import {
|
|
17
29
|
type DerivedPermission,
|
|
18
30
|
type DerivedTool,
|
|
19
31
|
type DerivedView,
|
|
32
|
+
type DisplayBlock,
|
|
33
|
+
type DisplayGapBlock,
|
|
34
|
+
type DisplayProjection,
|
|
35
|
+
type DisplayTextBlock,
|
|
20
36
|
deriveViewFromChunks,
|
|
21
37
|
optionKindAllows,
|
|
38
|
+
parseTranscriptEvent,
|
|
39
|
+
type StoredChunk,
|
|
40
|
+
type TranscriptEvent,
|
|
41
|
+
utf8ByteLength,
|
|
22
42
|
} from "../transcript-events.ts";
|
|
23
43
|
import type { TranscriptDataReport } from "./transcript-render.ts";
|
|
24
44
|
|
|
25
45
|
/**
|
|
26
|
-
* Derive the structured view of a fetched transcript page
|
|
27
|
-
*
|
|
46
|
+
* Derive the structured (event-fold) view of a fetched transcript page — the flat message/tool/permission
|
|
47
|
+
* history, per-turn structure and raw-byte accounting. This is the {@link DerivedView} fold, kept for the
|
|
48
|
+
* raw-fidelity footer and summary counts; the ordered, human-facing block SEQUENCE is the separate
|
|
49
|
+
* display projection {@link renderDerivedTranscript} draws. Pure: the cockpit reads THESE instead of
|
|
50
|
+
* re-parsing raw frame bytes.
|
|
28
51
|
*/
|
|
29
52
|
export function deriveTranscript(data: TranscriptDataReport): DerivedView {
|
|
30
53
|
return deriveViewFromChunks(data.entries);
|
|
@@ -156,9 +179,11 @@ function detectDiff(tool: DerivedTool): DetectedDiff | undefined {
|
|
|
156
179
|
return undefined;
|
|
157
180
|
}
|
|
158
181
|
|
|
159
|
-
/**
|
|
160
|
-
|
|
161
|
-
|
|
182
|
+
/** Fill an EXISTING tool card node with one tool's content (name, status, args/result, diff block). Clears
|
|
183
|
+
* the node first so it is safe to re-invoke in place when the tool's result later arrives (bounded to
|
|
184
|
+
* this one card — no sibling block is touched). */
|
|
185
|
+
function applyTool(card: ElementLike, doc: DocumentLike, tool: DerivedTool): void {
|
|
186
|
+
card.replaceChildren();
|
|
162
187
|
card.setAttribute("data-tool", tool.name);
|
|
163
188
|
card.setAttribute("data-offset", String(tool.offset));
|
|
164
189
|
card.setAttribute("data-status", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
|
|
@@ -195,6 +220,12 @@ function renderTool(doc: DocumentLike, tool: DerivedTool): ElementLike {
|
|
|
195
220
|
resEl.setAttribute("data-tool-result", "true");
|
|
196
221
|
card.appendChild(resEl);
|
|
197
222
|
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Render one tool card: name, status, args + result content, and a distinguishable diff block. */
|
|
226
|
+
function renderTool(doc: DocumentLike, tool: DerivedTool): ElementLike {
|
|
227
|
+
const card = el(doc, "div", "cockpit-transcript-tool");
|
|
228
|
+
applyTool(card, doc, tool);
|
|
198
229
|
return card;
|
|
199
230
|
}
|
|
200
231
|
|
|
@@ -204,8 +235,8 @@ function renderTool(doc: DocumentLike, tool: DerivedTool): ElementLike {
|
|
|
204
235
|
* - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
|
|
205
236
|
* - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
|
|
206
237
|
*/
|
|
207
|
-
function
|
|
208
|
-
|
|
238
|
+
function applyPermission(card: ElementLike, doc: DocumentLike, perm: DerivedPermission, options: RenderDerivedTranscriptOptions): void {
|
|
239
|
+
card.replaceChildren();
|
|
209
240
|
card.setAttribute("data-permission", "request");
|
|
210
241
|
card.setAttribute("data-policy", perm.policy);
|
|
211
242
|
card.setAttribute("data-call-id", perm.callId);
|
|
@@ -222,14 +253,14 @@ function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: R
|
|
|
222
253
|
settled.setAttribute("data-chosen-option", perm.resolved.optionId);
|
|
223
254
|
if (perm.resolved.by !== undefined) settled.setAttribute("data-by", perm.resolved.by);
|
|
224
255
|
card.appendChild(settled);
|
|
225
|
-
return
|
|
256
|
+
return;
|
|
226
257
|
}
|
|
227
258
|
|
|
228
259
|
if (perm.policy === "yolo") {
|
|
229
260
|
// Informational: yolo auto-allows and never prompts a human, so no Allow/Deny buttons.
|
|
230
261
|
card.setAttribute("data-status", "auto");
|
|
231
262
|
card.appendChild(el(doc, "div", "cockpit-transcript-permission-note", "Auto-allowed (yolo) — no operator prompt."));
|
|
232
|
-
return
|
|
263
|
+
return;
|
|
233
264
|
}
|
|
234
265
|
|
|
235
266
|
// Pending escalate: one interactive button per offered option, wired to the resolve seam.
|
|
@@ -249,65 +280,268 @@ function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: R
|
|
|
249
280
|
actions.appendChild(button);
|
|
250
281
|
}
|
|
251
282
|
card.appendChild(actions);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Render one permission prompt card from a {@link DerivedPermission}:
|
|
287
|
+
* - a pending `escalate` request → interactive Allow/Deny buttons wired to `onPermissionResolve`;
|
|
288
|
+
* - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
|
|
289
|
+
* - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
|
|
290
|
+
*/
|
|
291
|
+
function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: RenderDerivedTranscriptOptions): ElementLike {
|
|
292
|
+
const card = el(doc, "div", "cockpit-transcript-permission");
|
|
293
|
+
applyPermission(card, doc, perm, options);
|
|
252
294
|
return card;
|
|
253
295
|
}
|
|
254
296
|
|
|
297
|
+
/** Fill an EXISTING text-block node with a coalesced message's text + offsets. The block is ONE growing
|
|
298
|
+
* node per logical message — a delta patches this node's `textContent` in place (never a new card per
|
|
299
|
+
* fragment), so a split word reconstructs into exactly that word. */
|
|
300
|
+
function applyText(node: ElementLike, block: DisplayTextBlock): void {
|
|
301
|
+
node.setAttribute("data-role", block.role);
|
|
302
|
+
node.setAttribute("data-offset", String(block.startOffset));
|
|
303
|
+
node.setAttribute("data-end-offset", String(block.endOffset));
|
|
304
|
+
node.setAttribute("data-block-id", block.id);
|
|
305
|
+
if (block.messageId !== undefined) node.setAttribute("data-message-id", block.messageId);
|
|
306
|
+
node.setAttribute("data-complete", String(block.complete));
|
|
307
|
+
node.textContent = block.text;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Render one coalesced-message text block (a single growing node). */
|
|
311
|
+
function renderText(doc: DocumentLike, block: DisplayTextBlock): ElementLike {
|
|
312
|
+
const node = el(doc, "div", "cockpit-transcript-message");
|
|
313
|
+
applyText(node, block);
|
|
314
|
+
return node;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Fill an EXISTING retention-gap node. A gap is a first-class visible break so a reattach that dropped
|
|
318
|
+
* chunks never implies the surrounding text is continuous; its `beforeOffset` is anchored once the first
|
|
319
|
+
* post-gap block opens. */
|
|
320
|
+
function applyGap(node: ElementLike, block: DisplayGapBlock): void {
|
|
321
|
+
node.setAttribute("data-gap", "true");
|
|
322
|
+
node.setAttribute("data-block-id", block.id);
|
|
323
|
+
if (block.beforeOffset !== undefined) node.setAttribute("data-before-offset", String(block.beforeOffset));
|
|
324
|
+
node.textContent = "⋯ retained-data gap — earlier output was evicted ⋯";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Render one retention-gap block. */
|
|
328
|
+
function renderGap(doc: DocumentLike, block: DisplayGapBlock): ElementLike {
|
|
329
|
+
const node = el(doc, "div", "cockpit-transcript-gap");
|
|
330
|
+
applyGap(node, block);
|
|
331
|
+
return node;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Build a fresh DOM node for any display block kind. */
|
|
335
|
+
function renderBlock(doc: DocumentLike, block: DisplayBlock, options: RenderDerivedTranscriptOptions): ElementLike {
|
|
336
|
+
switch (block.kind) {
|
|
337
|
+
case "text":
|
|
338
|
+
return renderText(doc, block);
|
|
339
|
+
case "tool":
|
|
340
|
+
return renderTool(doc, block.tool);
|
|
341
|
+
case "permission":
|
|
342
|
+
return renderPermission(doc, block.permission, options);
|
|
343
|
+
case "gap":
|
|
344
|
+
return renderGap(doc, block);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Patch an EXISTING block node in place (bounded to that one block — no sibling node is touched). */
|
|
349
|
+
function patchBlock(node: ElementLike, doc: DocumentLike, block: DisplayBlock, options: RenderDerivedTranscriptOptions): void {
|
|
350
|
+
switch (block.kind) {
|
|
351
|
+
case "text":
|
|
352
|
+
applyText(node, block);
|
|
353
|
+
return;
|
|
354
|
+
case "tool":
|
|
355
|
+
applyTool(node, doc, block.tool);
|
|
356
|
+
return;
|
|
357
|
+
case "permission":
|
|
358
|
+
applyPermission(node, doc, block.permission, options);
|
|
359
|
+
return;
|
|
360
|
+
case "gap":
|
|
361
|
+
applyGap(node, block);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
255
366
|
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
367
|
+
* A STATEFUL, incremental transcript renderer over a `host` element. It maintains the ordered
|
|
368
|
+
* {@link DisplayBlock} sequence (via the canonical {@link createDisplayProjection}) and a `blockId → DOM
|
|
369
|
+
* node` map, so feeding it one live chunk at a time updates just the ONE touched block's node in place —
|
|
370
|
+
* append a brand-new block node, or patch an existing block's node (a growing text delta, a tool result
|
|
371
|
+
* pairing, a permission resolution, or a now-anchored gap) — instead of rebuilding the whole transcript
|
|
372
|
+
* tree. That bounded update is what lets the browser adapter preserve selection, expansion and scroll.
|
|
373
|
+
*
|
|
374
|
+
* DOM shape (stable across live growth so unaffected nodes are never replaced):
|
|
375
|
+
* div.cockpit-transcript-derived[data-*]
|
|
376
|
+
* div.cockpit-transcript-blocks ← ordered block nodes are appended here / patched in place
|
|
377
|
+
* div.cockpit-transcript-empty ← shown (data-empty="true") only while there are zero blocks
|
|
378
|
+
* footer.cockpit-transcript-raw ← retained raw bytes/chunks (byte-replay is preserved alongside)
|
|
262
379
|
*/
|
|
263
|
-
export
|
|
380
|
+
export interface IncrementalTranscript {
|
|
381
|
+
/** The rendered root (a `cockpit-transcript-derived` element) appended under the host. */
|
|
382
|
+
readonly root: ElementLike;
|
|
383
|
+
/**
|
|
384
|
+
* Fold ONE stored chunk (by offset) into the display and update the DOM minimally. Idempotent on
|
|
385
|
+
* offset — re-feeding an already-applied offset (reconnect, pagination overlap, a duplicated chunk) is
|
|
386
|
+
* a no-op, so replayed text never doubles. Feed chunks in offset order (the projection drops a late
|
|
387
|
+
* lower offset rather than merging it out of place).
|
|
388
|
+
*/
|
|
389
|
+
applyChunk(chunk: StoredChunk): void;
|
|
390
|
+
/**
|
|
391
|
+
* Record a retention gap at the current tail BEFORE feeding the post-gap chunks: the consumer resumed
|
|
392
|
+
* from an offset older than the oldest retained chunk, so what follows is NOT continuous with what
|
|
393
|
+
* precedes. Renders a visible break; its `beforeOffset` is anchored when the next block opens.
|
|
394
|
+
*/
|
|
395
|
+
noteGap(): void;
|
|
396
|
+
/** A snapshot of the ordered display blocks as they stand now (for tests/inspection). */
|
|
397
|
+
blocks(): readonly DisplayBlock[];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Running summary tallies for the root attributes + raw footer, maintained WITHOUT re-folding. */
|
|
401
|
+
interface Tallies {
|
|
402
|
+
text: number;
|
|
403
|
+
tool: number;
|
|
404
|
+
permission: number;
|
|
405
|
+
gap: number;
|
|
406
|
+
turns: number;
|
|
407
|
+
rawBytes: number;
|
|
408
|
+
rawChunks: number;
|
|
409
|
+
lifecycle: "open" | "completed" | "exited";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Build the incremental renderer's stable DOM scaffold under `host` and return the mutable render state.
|
|
414
|
+
* Shared by {@link createIncrementalTranscript} (live) and {@link renderDerivedTranscript} (batch) so
|
|
415
|
+
* historical replay renders IDENTICALLY to the final live rendering.
|
|
416
|
+
*/
|
|
417
|
+
export function createIncrementalTranscript(
|
|
264
418
|
host: ElementLike,
|
|
265
419
|
doc: DocumentLike,
|
|
266
|
-
|
|
420
|
+
stream: string,
|
|
267
421
|
options: RenderDerivedTranscriptOptions = {},
|
|
268
|
-
):
|
|
269
|
-
const view = deriveTranscript(data);
|
|
422
|
+
): IncrementalTranscript {
|
|
270
423
|
host.replaceChildren();
|
|
424
|
+
const projection: DisplayProjection = createDisplayProjection();
|
|
425
|
+
const nodes = new Map<string, ElementLike>();
|
|
426
|
+
const tallies: Tallies = { text: 0, tool: 0, permission: 0, gap: 0, turns: 0, rawBytes: 0, rawChunks: 0, lifecycle: "open" };
|
|
427
|
+
// A turn opens implicitly before the first structured block even without an explicit `turn` event
|
|
428
|
+
// (mirrors deriveView's implicit turn 0), so any structured content means at least one turn.
|
|
429
|
+
let structured = false;
|
|
430
|
+
|
|
271
431
|
const root = el(doc, "div", "cockpit-transcript-derived");
|
|
272
|
-
root.setAttribute("data-stream",
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
root.
|
|
277
|
-
root.
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
432
|
+
root.setAttribute("data-stream", stream);
|
|
433
|
+
const blocksHost = el(doc, "div", "cockpit-transcript-blocks");
|
|
434
|
+
const empty = el(doc, "div", "cockpit-transcript-empty");
|
|
435
|
+
const footer = el(doc, "footer", "cockpit-transcript-raw");
|
|
436
|
+
root.appendChild(blocksHost);
|
|
437
|
+
root.appendChild(empty);
|
|
438
|
+
root.appendChild(footer);
|
|
439
|
+
host.appendChild(root);
|
|
440
|
+
|
|
441
|
+
function refreshSummary(): void {
|
|
442
|
+
const turnCount = tallies.turns > 0 ? tallies.turns : structured ? 1 : 0;
|
|
443
|
+
root.setAttribute("data-lifecycle", tallies.lifecycle);
|
|
444
|
+
root.setAttribute("data-turn-count", String(turnCount));
|
|
445
|
+
root.setAttribute("data-message-count", String(tallies.text));
|
|
446
|
+
root.setAttribute("data-tool-count", String(tallies.tool));
|
|
447
|
+
root.setAttribute("data-permission-count", String(tallies.permission));
|
|
448
|
+
root.setAttribute("data-gap-count", String(tallies.gap));
|
|
449
|
+
root.setAttribute("data-block-count", String(nodes.size));
|
|
450
|
+
|
|
451
|
+
const hasBlocks = tallies.text + tallies.tool + tallies.permission > 0;
|
|
452
|
+
// Toggle (never remove — ElementLike has no removeChild) so a live first block clears the empty note
|
|
453
|
+
// without rebuilding, and an all-raw page still shows exactly one data-empty="true" element.
|
|
454
|
+
empty.setAttribute("data-empty", String(!hasBlocks));
|
|
455
|
+
empty.textContent = hasBlocks ? "" : "No structured events derived — raw replay only.";
|
|
456
|
+
|
|
457
|
+
footer.setAttribute("data-raw-bytes", String(tallies.rawBytes));
|
|
458
|
+
footer.setAttribute("data-raw-chunks", String(tallies.rawChunks));
|
|
459
|
+
footer.textContent = `${tallies.rawChunks} raw chunk(s) · ${tallies.rawBytes} B retained for replay`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function countAppended(block: DisplayBlock): void {
|
|
463
|
+
structured = structured || block.kind !== "gap";
|
|
464
|
+
if (block.kind === "text") tallies.text++;
|
|
465
|
+
else if (block.kind === "tool") tallies.tool++;
|
|
466
|
+
else if (block.kind === "permission") tallies.permission++;
|
|
467
|
+
else tallies.gap++;
|
|
283
468
|
}
|
|
284
469
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
470
|
+
/** Reconcile ONE projection apply-result into the DOM: append a new node, patch an existing one, and/or
|
|
471
|
+
* patch a secondary now-anchored gap. Bounded to the touched block(s) — no unaffected node is replaced. */
|
|
472
|
+
function reconcile(changed: DisplayBlock | undefined, appended: boolean, anchored: DisplayBlock | undefined): void {
|
|
473
|
+
if (changed !== undefined) {
|
|
474
|
+
if (appended) {
|
|
475
|
+
const node = renderBlock(doc, changed, options);
|
|
476
|
+
nodes.set(changed.id, node);
|
|
477
|
+
blocksHost.appendChild(node);
|
|
478
|
+
countAppended(changed);
|
|
479
|
+
} else {
|
|
480
|
+
const node = nodes.get(changed.id);
|
|
481
|
+
if (node !== undefined) patchBlock(node, doc, changed, options);
|
|
482
|
+
}
|
|
295
483
|
}
|
|
296
|
-
|
|
297
|
-
|
|
484
|
+
if (anchored !== undefined) {
|
|
485
|
+
const node = nodes.get(anchored.id);
|
|
486
|
+
if (node !== undefined) patchBlock(node, doc, anchored, options);
|
|
298
487
|
}
|
|
299
|
-
|
|
300
|
-
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function applyEvent(event: TranscriptEvent): void {
|
|
491
|
+
// Raw bytes feed the byte-replay footer but produce no display block (the projection ignores them).
|
|
492
|
+
if (event.kind === "stream-chunk") {
|
|
493
|
+
tallies.rawChunks++;
|
|
494
|
+
tallies.rawBytes += utf8ByteLength(event.chunk);
|
|
495
|
+
} else if (event.kind === "turn") {
|
|
496
|
+
tallies.turns++;
|
|
497
|
+
} else if (event.kind === "lifecycle") {
|
|
498
|
+
tallies.lifecycle = event.phase;
|
|
301
499
|
}
|
|
302
|
-
|
|
500
|
+
const result = projection.apply(event);
|
|
501
|
+
reconcile(result.changed, result.appended, result.anchored);
|
|
303
502
|
}
|
|
304
503
|
|
|
305
|
-
|
|
306
|
-
footer.setAttribute("data-raw-bytes", String(view.rawByteLength));
|
|
307
|
-
footer.setAttribute("data-raw-chunks", String(view.rawChunkCount));
|
|
308
|
-
footer.textContent = `${view.rawChunkCount} raw chunk(s) · ${view.rawByteLength} B retained for replay`;
|
|
309
|
-
root.appendChild(footer);
|
|
504
|
+
refreshSummary();
|
|
310
505
|
|
|
311
|
-
|
|
312
|
-
|
|
506
|
+
return {
|
|
507
|
+
root,
|
|
508
|
+
applyChunk(chunk: StoredChunk): void {
|
|
509
|
+
applyEvent(parseTranscriptEvent(chunk));
|
|
510
|
+
refreshSummary();
|
|
511
|
+
},
|
|
512
|
+
noteGap(): void {
|
|
513
|
+
const result = projection.noteGap();
|
|
514
|
+
reconcile(result.changed, result.appended, result.anchored);
|
|
515
|
+
refreshSummary();
|
|
516
|
+
},
|
|
517
|
+
blocks(): readonly DisplayBlock[] {
|
|
518
|
+
return projection.blocks();
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Render the DERIVED, ORDERED display of a fetched transcript page into `host`, replacing whatever was
|
|
525
|
+
* there — the pure BATCH convenience over {@link createIncrementalTranscript}. Draws one growing text
|
|
526
|
+
* block per logical message, with tool/diff cards and permission prompts interleaved in chronological
|
|
527
|
+
* (offset) order, plus a raw-fidelity footer (retained bytes/chunks) so the byte-replay stays visibly
|
|
528
|
+
* preserved. A retention `gap` on the page (`data.gap`) renders a leading visible break. Idempotent —
|
|
529
|
+
* call again on each refresh. Everything it shows is a derivation of the one event log, and it renders
|
|
530
|
+
* IDENTICALLY to the final live rendering (same incremental fold). `options.onPermissionResolve`, when
|
|
531
|
+
* provided, is invoked by a pending escalate-permission prompt's Allow/Deny buttons.
|
|
532
|
+
*/
|
|
533
|
+
export function renderDerivedTranscript(
|
|
534
|
+
host: ElementLike,
|
|
535
|
+
doc: DocumentLike,
|
|
536
|
+
data: TranscriptDataReport,
|
|
537
|
+
options: RenderDerivedTranscriptOptions = {},
|
|
538
|
+
): DerivedTranscriptDom {
|
|
539
|
+
const incremental = createIncrementalTranscript(host, doc, data.stream, options);
|
|
540
|
+
// A page-level retention gap precedes the page's first chunk: note it before folding so a leading
|
|
541
|
+
// visible break renders (a reattach that dropped chunks never implies false continuity).
|
|
542
|
+
if (data.gap) incremental.noteGap();
|
|
543
|
+
// The batch path folds the SAME chunks the live path does, in offset order, through the ONE projection,
|
|
544
|
+
// so historical and live rendering are byte-for-byte the same tree.
|
|
545
|
+
for (const chunk of [...data.entries].sort((a, b) => a.offset - b.offset)) incremental.applyChunk(chunk);
|
|
546
|
+
return { root: incremental.root };
|
|
313
547
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// nano-workforce — the ordered DISPLAY projection, DERIVED from its one owner (agentic #566).
|
|
2
|
+
//
|
|
3
|
+
// Sibling of `transcript-events.ts`: where that barrel re-exports the transcript EVENT grammar (the one
|
|
4
|
+
// parser + fold), THIS barrel re-exports the canonical ordered DISPLAY derivation — the projection that
|
|
5
|
+
// coalesces transport-fragmented message deltas back into logical blocks and interleaves them
|
|
6
|
+
// chronologically with tool cards and permission prompts (`createDisplayProjection` / `deriveDisplay`).
|
|
7
|
+
// The cockpit renders THIS instead of the raw per-event groups, so a split word reconstructs into one
|
|
8
|
+
// block and text/tool/permission order never scrambles.
|
|
9
|
+
//
|
|
10
|
+
// Why a SEPARATE barrel rather than folding these into `transcript-events.ts`: the browser bundle
|
|
11
|
+
// (`scripts/build-cockpit-browser.ts`) emits one generated ESM sibling per agentic source module, and
|
|
12
|
+
// the display projection lives in agentic's own `dist/transcript/display.js` — a self-contained,
|
|
13
|
+
// browser-safe module distinct from `events.js`. Keeping the display import on its own specifier lets
|
|
14
|
+
// the bundle rewrite it to `./transcript-display.js` (agentic's display module) while the event import
|
|
15
|
+
// still rewrites to `./transcript-events.js`. Both remain thin re-exports of the ONE agentic source of
|
|
16
|
+
// truth — there is no local display algorithm here (Derivation Over Duplication).
|
|
17
|
+
export { createDisplayProjection, deriveDisplay } from "@nanobpm/agentic/transcript";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.186.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@nanobpm/agentic": "^0.
|
|
67
|
+
"@nanobpm/agentic": "^0.14.0",
|
|
68
68
|
"@nanobpm/urban": "^0.93.0",
|
|
69
69
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
70
70
|
},
|