@nanobpm/nano-workforce 0.185.0 → 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.
@@ -75,6 +75,12 @@ function num(body, key) {
75
75
  const v = body[key];
76
76
  return typeof v === "number" && Number.isFinite(v) ? v : undefined;
77
77
  }
78
+ /** Read a strictly-boolean field, or `undefined` when absent. A present-but-non-boolean value returns
79
+ * `undefined` too, so the caller can reject a malformed envelope rather than silently coercing it. */
80
+ function bool(body, key) {
81
+ const v = body[key];
82
+ return typeof v === "boolean" ? v : undefined;
83
+ }
78
84
  const ROLES = ["assistant", "user", "system", "tool"];
79
85
  /** Narrow an arbitrary string to a known {@link TranscriptRole}, defaulting to `assistant`. */
80
86
  function toRole(value) {
@@ -123,7 +129,35 @@ export const CORE_TRANSCRIPT_VOCAB = Object.freeze(Object.assign(Object.create(n
123
129
  if (text === undefined)
124
130
  return undefined;
125
131
  const roleRaw = str(body, "role");
126
- return { kind: "message", offset, role: toRole(roleRaw), text };
132
+ // The additive display metadata (all optional). Mirror the permission decoder's discipline: a
133
+ // present-but-malformed optional field REJECTS the whole envelope (→ raw stream-chunk) rather than
134
+ // being silently dropped, so a decoded event never diverges from the on-wire JSON.
135
+ const messageId = str(body, "messageId");
136
+ if (body.messageId !== undefined && messageId === undefined)
137
+ return undefined;
138
+ let mode;
139
+ if (body.mode !== undefined) {
140
+ const modeRaw = str(body, "mode");
141
+ if (modeRaw !== "delta" && modeRaw !== "snapshot")
142
+ return undefined;
143
+ mode = modeRaw;
144
+ }
145
+ const start = bool(body, "start");
146
+ if (body.start !== undefined && start === undefined)
147
+ return undefined;
148
+ const final = bool(body, "final");
149
+ if (body.final !== undefined && final === undefined)
150
+ return undefined;
151
+ return {
152
+ kind: "message",
153
+ offset,
154
+ role: toRole(roleRaw),
155
+ text,
156
+ ...(messageId !== undefined ? { messageId } : {}),
157
+ ...(mode !== undefined ? { mode } : {}),
158
+ ...(start !== undefined ? { start } : {}),
159
+ ...(final !== undefined ? { final } : {}),
160
+ };
127
161
  },
128
162
  "tool-call": (body, offset) => {
129
163
  const name = str(body, "name");
@@ -25,7 +25,7 @@
25
25
  // (`app/agentic/cockpit/transcript-derive.ts` + `app/agentic/transcript-events.ts`) that a drift-guard
26
26
  // test keeps byte-identical to its source.
27
27
  import { RelayChannelClient, TerminalSession } from "@nanobpm/agentic/cockpit";
28
- import { renderDerivedTranscript } from "./generated/transcript-derive.js";
28
+ import { createIncrementalTranscript } from "./generated/transcript-derive.js";
29
29
 
30
30
  const DEFAULT_REFRESH_MS = 2000;
31
31
  const DEFAULT_STALE_AFTER_MS = 15_000;
@@ -390,42 +390,96 @@ function renderTranscripts(host, doc, view, onReplay, activeStream, title = "Pas
390
390
  host.appendChild(root);
391
391
  }
392
392
 
393
- // ── transcript rendering (#660) ────────────────────────────────────────────────────────────────
393
+ // ── transcript rendering (#660, #757) ───────────────────────────────────────────────────────────
394
394
  //
395
- // A rendered-transcript sink over `host`: it accumulates the relay stream's `{offset, chunk}` entries
396
- // and (re-)draws them through the shared `renderDerivedTranscript` the SAME parse→derive→render path
397
- // the typed core uses — so the operator sees readable message turns + tool/diff/permission cards, never
398
- // the raw `nwfTranscriptEvent` envelopes. Chunks are keyed by offset, so a resume-from-offset reconnect
399
- // that re-delivers a chunk coalesces instead of doubling it (mirrors TerminalSession's own dedup). The
400
- // derive/render logic is NOT re-copied here that hand-copy was the #660 drift; this only collects
401
- // offsets and hands the whole page to the imported renderer, which parses + folds it exactly once.
395
+ // An INCREMENTAL rendered-transcript sink over `host`: it feeds each relay `{offset, chunk}` into the
396
+ // shared {@link createIncrementalTranscript} projection the SAME ordered-display fold the typed core
397
+ // uses (#757) — so the operator sees ONE growing text block per logical message with tool/diff/permission
398
+ // activity interleaved in chronological order, never one bordered card per streamed delta and never the
399
+ // raw `nwfTranscriptEvent` envelopes. The renderer patches only the ONE touched block's DOM node per
400
+ // chunk (append a new block, or grow/settle an existing one) instead of rebuilding the whole tree, so the
401
+ // operator's text selection and expansion survive live streaming.
402
+ //
403
+ // Redraws are BATCHED to an animation frame when the host provides one (bounded scheduling: many deltas
404
+ // arriving in one frame apply together, once), falling back to a synchronous flush where no rAF exists
405
+ // (Node/tests). Scroll is AUTO-FOLLOWED only when the operator is already at the tail — captured before
406
+ // each flush mutates the DOM — so reading back through history is not yanked to the bottom by new output.
407
+ // The derive/render logic is NOT re-copied here (that hand-copy was the #660 drift); this only schedules
408
+ // and collects chunks and hands them to the imported projection.
402
409
  function transcriptSink(host, stream, opts = {}) {
403
- const byOffset = new Map();
404
- let gap = false;
405
- let nextOffset = 0;
406
- const draw = () => {
407
- const entries = [...byOffset.entries()].sort((a, b) => a[0] - b[0]).map(([offset, chunk]) => ({ offset, chunk }));
408
- renderDerivedTranscript(host, host.ownerDocument, { stream, from: 0, gap, nextOffset, entries }, opts);
409
- };
410
+ const doc = host.ownerDocument;
411
+ const win = doc?.defaultView ?? (typeof window !== "undefined" ? window : undefined);
412
+ const raf = win && typeof win.requestAnimationFrame === "function" ? win.requestAnimationFrame.bind(win) : undefined;
413
+ const caf = win && typeof win.cancelAnimationFrame === "function" ? win.cancelAnimationFrame.bind(win) : undefined;
414
+
415
+ let renderer = createIncrementalTranscript(host, doc, stream, opts);
416
+ const pending = []; // buffered {offset, chunk} awaiting the next flush
417
+ let gapPending = false; // a retention gap to open before the next flush's chunks
418
+ let frame = 0; // scheduled animation-frame handle (0 = none)
419
+ let disposed = false;
420
+
421
+ // Is the operator following the tail (so new output should auto-scroll)? Only meaningful with real
422
+ // layout; a host without numeric scroll metrics (the Node fake DOM) is treated as following.
423
+ function atTail() {
424
+ const sh = host.scrollHeight;
425
+ const st = host.scrollTop;
426
+ const ch = host.clientHeight;
427
+ if (typeof sh !== "number" || typeof st !== "number" || typeof ch !== "number") return true;
428
+ return st + ch >= sh - 4;
429
+ }
430
+ function followTail() {
431
+ if (typeof host.scrollHeight === "number") host.scrollTop = host.scrollHeight;
432
+ }
433
+
434
+ function flush() {
435
+ frame = 0;
436
+ if (disposed) return;
437
+ // Capture follow-state BEFORE mutating so only an operator already at the tail is auto-scrolled.
438
+ const follow = atTail();
439
+ if (gapPending) {
440
+ renderer.noteGap();
441
+ gapPending = false;
442
+ }
443
+ const batch = pending.splice(0, pending.length).sort((a, b) => a.offset - b.offset);
444
+ for (const entry of batch) renderer.applyChunk(entry);
445
+ if (follow) followTail();
446
+ }
447
+
448
+ function schedule() {
449
+ if (disposed || frame !== 0) return;
450
+ if (raf) frame = raf(flush);
451
+ else flush(); // no animation-frame scheduler (Node/tests) — flush synchronously so the DOM is current
452
+ }
453
+
410
454
  return {
411
- /** Fold in the resume ack (offset/gap metadata) and redraw. */
455
+ /** Fold in the resume ack: a gap flag opens a visible retention break before the following chunks. */
412
456
  ack: (message) => {
413
- if (typeof message?.nextOffset === "number") nextOffset = message.nextOffset;
414
- if (typeof message?.gap === "boolean") gap = message.gap;
415
- draw();
457
+ if (message != null && message.gap === true) gapPending = true;
458
+ schedule();
416
459
  },
417
- /** Fold in one relay data chunk (deduped by offset) and redraw. */
460
+ /** Buffer one relay data chunk (deduped by offset inside the idempotent projection) and schedule a flush. */
418
461
  add: (offset, chunk) => {
419
- byOffset.set(offset, chunk);
420
- draw();
462
+ pending.push({ offset, chunk });
463
+ schedule();
421
464
  },
422
- /** Render a whole fetched transcript page in one pass (static replay). */
465
+ /** Render a whole fetched transcript page in one pass (static replay) — rebuild from a fresh projection. */
423
466
  replace: (data) => {
424
- byOffset.clear();
425
- gap = Boolean(data.gap);
426
- nextOffset = data.nextOffset ?? 0;
427
- for (const entry of data.entries ?? []) byOffset.set(entry.offset, entry.chunk);
428
- draw();
467
+ if (frame !== 0 && caf) caf(frame);
468
+ frame = 0;
469
+ pending.length = 0;
470
+ gapPending = false;
471
+ renderer = createIncrementalTranscript(host, doc, data.stream ?? stream, opts);
472
+ if (data.gap) renderer.noteGap();
473
+ const entries = [...(data.entries ?? [])].sort((a, b) => a.offset - b.offset);
474
+ for (const entry of entries) renderer.applyChunk(entry);
475
+ followTail();
476
+ },
477
+ /** Tear down the scheduler when the terminal region is claimed by another session (or disposed). */
478
+ dispose: () => {
479
+ disposed = true;
480
+ if (frame !== 0 && caf) caf(frame);
481
+ frame = 0;
482
+ pending.length = 0;
429
483
  },
430
484
  };
431
485
  }
@@ -40,12 +40,22 @@ interface BundleModule {
40
40
  // self-contained ESM (`dist/transcript/events.js` has no runtime imports), the SAME source of truth the
41
41
  // Node core re-exports via the `transcript-events.ts` barrel. This keeps "one grammar, no drift surface"
42
42
  // true across BOTH hosts: the cockpit renders from agentic's grammar, never a hand-rolled second copy.
43
+ // The ordered DISPLAY projection (agentic #566) is a THIRD browser-safe agentic module
44
+ // (`dist/transcript/display.js`, self-contained — its only import is `import type` from events.ts,
45
+ // erased on transpile). The cockpit's derive+render now folds through it (one growing block per message,
46
+ // chronological interleave), so it joins the bundle beside the events grammar. `transcript-derive.ts`
47
+ // imports the display fold from the `../transcript-display.ts` barrel, rewritten here to the generated
48
+ // display sibling — the SAME source of truth the Node core re-exports.
43
49
  const MODULES: readonly BundleModule[] = [
44
50
  { src: "node_modules/@nanobpm/agentic/dist/transcript/events.js", out: "pages/cockpit/generated/transcript-events.js" },
51
+ { src: "node_modules/@nanobpm/agentic/dist/transcript/display.js", out: "pages/cockpit/generated/transcript-display.js" },
45
52
  {
46
53
  src: "app/agentic/cockpit/transcript-derive.ts",
47
54
  out: "pages/cockpit/generated/transcript-derive.js",
48
- rewrites: [["../transcript-events.ts", "./transcript-events.js"]],
55
+ rewrites: [
56
+ ["../transcript-events.ts", "./transcript-events.js"],
57
+ ["../transcript-display.ts", "./transcript-display.js"],
58
+ ],
49
59
  },
50
60
  ];
51
61