@genesislcap/ai-assistant 14.498.0 → 14.499.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.
Files changed (37) hide show
  1. package/dist/ai-assistant.api.json +132 -0
  2. package/dist/ai-assistant.d.ts +35 -0
  3. package/dist/chat-driver.cjs +157 -0
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +151 -0
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +156 -1
  8. package/dist/dts/chat-driver-node.d.ts +7 -1
  9. package/dist/dts/chat-driver-node.d.ts.map +1 -1
  10. package/dist/dts/components/ai-driver/ai-driver.d.ts +8 -0
  11. package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-driver/chat-driver.d.ts +27 -0
  13. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  14. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +3 -0
  15. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  16. package/dist/dts/main/main.d.ts.map +1 -1
  17. package/dist/dts/state/debug-event-log.d.ts +1 -1
  18. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  19. package/dist/dts/state/persistence/build-timeline-entries.d.ts +37 -0
  20. package/dist/dts/state/persistence/build-timeline-entries.d.ts.map +1 -0
  21. package/dist/esm/chat-driver-node.js +16 -1
  22. package/dist/esm/components/chat-driver/chat-driver.js +103 -0
  23. package/dist/esm/components/chat-driver/chat-driver.test.js +165 -0
  24. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +4 -0
  25. package/dist/esm/main/main.js +18 -28
  26. package/dist/esm/state/debug-event-log.js +1 -0
  27. package/dist/esm/state/persistence/build-timeline-entries.js +46 -0
  28. package/dist/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +17 -17
  30. package/src/chat-driver-node.ts +20 -0
  31. package/src/components/ai-driver/ai-driver.ts +9 -0
  32. package/src/components/chat-driver/chat-driver.test.ts +215 -0
  33. package/src/components/chat-driver/chat-driver.ts +109 -0
  34. package/src/components/orchestrating-driver/orchestrating-driver.ts +6 -0
  35. package/src/main/main.ts +17 -30
  36. package/src/state/debug-event-log.ts +3 -0
  37. package/src/state/persistence/build-timeline-entries.ts +66 -0
@@ -1462,6 +1462,221 @@ interactionPresentation('presentation is absent when the option is omitted', asy
1462
1462
 
1463
1463
  interactionPresentation.run();
1464
1464
 
1465
+ // ---------------------------------------------------------------------------
1466
+ // external diagnostics harvest (GENC-1461) — a widget may return an out-of-band
1467
+ // engine's collated debug log on `InteractionResult.diagnostics` (e.g. a
1468
+ // server-side ChatDriver's). Those entries carry the SOURCE engine's own 0-based
1469
+ // index space, which would collide with this host's forward-capture cursors and
1470
+ // be dropped: the persister dedups `turn` entries by `turnIndex` and `event`
1471
+ // entries by an `index` high-water mark. So the driver decomposes the harvest by
1472
+ // kind — events fold into the session's meta-event registry (re-indexed onto the
1473
+ // host's monotonic counter), turns are re-keyed into a per-batch namespace, and
1474
+ // messages pass through unchanged (the persister already keys those by content).
1475
+ // ---------------------------------------------------------------------------
1476
+
1477
+ const externalDiagnostics = createLogicSuite('ChatDriver external diagnostics harvest');
1478
+
1479
+ /** A `diagnostics` payload as a headless engine would collate it — one of each kind,
1480
+ * all carrying that engine's own 0-based indices (`turnIndex: '0'`, event `index: 0`). */
1481
+ const harvestPayload = (marker: string) => [
1482
+ { kind: 'message', timestamp: '2026-07-23T10:00:00.000Z', role: 'assistant', content: marker },
1483
+ { kind: 'turn', timestamp: '2026-07-23T10:00:01.000Z', turnIndex: '0', systemPrompt: 'sys' },
1484
+ {
1485
+ kind: 'event',
1486
+ index: 0,
1487
+ timestamp: '2026-07-23T10:00:02.000Z',
1488
+ type: 'turn.error',
1489
+ importance: 'high',
1490
+ detail: { marker },
1491
+ },
1492
+ ];
1493
+
1494
+ externalDiagnostics(
1495
+ 'routes harvested events to the meta-event registry (re-indexed) and keeps turns + messages',
1496
+ async () => {
1497
+ clearMetaEventRegistry();
1498
+ const sessionKey = 'external-diag-harvest';
1499
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), sessionKey);
1500
+
1501
+ // Requesting the interaction records a host `interaction.requested` meta-event, so the
1502
+ // registry's index counter is already advanced when the harvested event is merged — exactly
1503
+ // the condition that made the foreign 0-based `index` lose the persister's high-water race.
1504
+ const pending = driver.requestInteraction('w', {});
1505
+ const { interactionId } = driver.getHistory().at(-1)!.interaction!;
1506
+ const requestedIndex = getMetaEvents(sessionKey).find(
1507
+ (e) => e.type === 'interaction.requested',
1508
+ )!.index;
1509
+
1510
+ driver.resolveInteraction(interactionId, {
1511
+ status: 'approved',
1512
+ diagnostics: harvestPayload('m1'),
1513
+ });
1514
+ await pending;
1515
+
1516
+ // The event left the external buffer and landed in the registry, re-indexed ABOVE the host's
1517
+ // prior event (so it beats the persister's `index <= lastEventIndex` high-water), timestamp kept.
1518
+ const external = driver.getExternalDiagnostics();
1519
+ assert.is(
1520
+ external.filter((e) => e.kind === 'event').length,
1521
+ 0,
1522
+ 'events do not stay in the buffer',
1523
+ );
1524
+ const merged = getMetaEvents(sessionKey).find((e) => e.detail?.marker === 'm1');
1525
+ assert.ok(merged, 'the harvested event is folded into the session registry');
1526
+ assert.ok(
1527
+ merged!.index > requestedIndex,
1528
+ 're-indexed onto the host counter, above prior events',
1529
+ );
1530
+ assert.is(
1531
+ merged!.timestamp,
1532
+ '2026-07-23T10:00:02.000Z',
1533
+ 'original timestamp preserved for ordering',
1534
+ );
1535
+
1536
+ // The turn is kept in the buffer but re-keyed out of the host's bare-integer turn-key space,
1537
+ // and the message passes through untouched.
1538
+ const turn = external.find((e) => e.kind === 'turn');
1539
+ assert.ok(turn, 'the harvested turn is kept in the external buffer');
1540
+ assert.is(turn!.turnIndex, 'server-generation.1:0', 'turnIndex namespaced per batch');
1541
+ const message = external.find((e) => e.kind === 'message');
1542
+ assert.ok(message, 'the harvested message is kept in the external buffer');
1543
+ assert.is(message!.content, 'm1', 'message passes through unchanged');
1544
+
1545
+ // A single manifest event records the fold's provenance — the fragile boundary this feature
1546
+ // guards is now observable: a dropped generation shows up as a zero/absent manifest.
1547
+ const manifest = getMetaEvents(sessionKey).find(
1548
+ (e) => e.type === 'external-diagnostics.folded',
1549
+ );
1550
+ assert.ok(manifest, 'a manifest meta-event is recorded for the fold');
1551
+ assert.is(manifest!.detail?.interactionId, interactionId);
1552
+ assert.equal(manifest!.detail?.counts, { turn: 1, event: 1, message: 1 });
1553
+ },
1554
+ );
1555
+
1556
+ externalDiagnostics(
1557
+ 'namespaces each batch so distinct server generations cannot collide on turnIndex',
1558
+ async () => {
1559
+ clearMetaEventRegistry();
1560
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), 'external-diag-batches');
1561
+
1562
+ const first = driver.requestInteraction('w', {});
1563
+ const firstId = driver.getHistory().at(-1)!.interaction!.interactionId;
1564
+ driver.resolveInteraction(firstId, { status: 'approved', diagnostics: harvestPayload('a') });
1565
+ await first;
1566
+
1567
+ const second = driver.requestInteraction('w', {});
1568
+ const secondId = driver.getHistory().at(-1)!.interaction!.interactionId;
1569
+ driver.resolveInteraction(secondId, { status: 'approved', diagnostics: harvestPayload('b') });
1570
+ await second;
1571
+
1572
+ // Both harvests carried `turnIndex: '0'`; the per-batch namespace keeps them distinct, so the
1573
+ // persister's `emittedTurnKeys` can't fold the second generation's turn into the first.
1574
+ const turnKeys = driver
1575
+ .getExternalDiagnostics()
1576
+ .filter((e) => e.kind === 'turn')
1577
+ .map((e) => e.turnIndex);
1578
+ assert.equal(turnKeys, ['server-generation.1:0', 'server-generation.2:0']);
1579
+ },
1580
+ );
1581
+
1582
+ externalDiagnostics(
1583
+ 'keys turns by local position so several server runs in one harvest do not collide',
1584
+ async () => {
1585
+ clearMetaEventRegistry();
1586
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), 'external-diag-multirun');
1587
+
1588
+ // One interaction carrying two independent server runs (e.g. a consolidator's consolidator-code
1589
+ // run and its table-code run) — each numbers its own turns from 0, so the harvest has two
1590
+ // `turnIndex: '0'` entries. Keying by source index would collapse them; local position keeps
1591
+ // both, so the persister's per-turnIndex dedup drops neither.
1592
+ const pending = driver.requestInteraction('w', {});
1593
+ const { interactionId } = driver.getHistory().at(-1)!.interaction!;
1594
+ driver.resolveInteraction(interactionId, {
1595
+ status: 'approved',
1596
+ diagnostics: [
1597
+ {
1598
+ kind: 'turn',
1599
+ timestamp: '2026-07-23T10:00:00.000Z',
1600
+ turnIndex: '0',
1601
+ systemPrompt: 'run1',
1602
+ },
1603
+ {
1604
+ kind: 'turn',
1605
+ timestamp: '2026-07-23T10:00:01.000Z',
1606
+ turnIndex: '1',
1607
+ systemPrompt: 'run1',
1608
+ },
1609
+ {
1610
+ kind: 'turn',
1611
+ timestamp: '2026-07-23T10:00:02.000Z',
1612
+ turnIndex: '0',
1613
+ systemPrompt: 'run2',
1614
+ },
1615
+ ],
1616
+ });
1617
+ await pending;
1618
+
1619
+ const turnKeys = driver
1620
+ .getExternalDiagnostics()
1621
+ .filter((e) => e.kind === 'turn')
1622
+ .map((e) => e.turnIndex);
1623
+ assert.equal(turnKeys, [
1624
+ 'server-generation.1:0',
1625
+ 'server-generation.1:1',
1626
+ 'server-generation.1:2',
1627
+ ]);
1628
+ // All distinct — no key repeats despite the repeated source `turnIndex: '0'`.
1629
+ assert.is(new Set(turnKeys).size, 3, 'every harvested turn gets a distinct key');
1630
+ },
1631
+ );
1632
+
1633
+ externalDiagnostics(
1634
+ 'tolerates malformed entries and does not fold or miscount non-standard kinds',
1635
+ async () => {
1636
+ clearMetaEventRegistry();
1637
+ const sessionKey = 'external-diag-malformed';
1638
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]), sessionKey);
1639
+
1640
+ const pending = driver.requestInteraction('w', {});
1641
+ const { interactionId } = driver.getHistory().at(-1)!.interaction!;
1642
+ // The harvest is untrusted `unknown[]`: primitives/null and an object without a string `kind`
1643
+ // must be skipped (not throw), and a `meta-snapshot` (host-owned, never emitted by a sibling)
1644
+ // must be dropped rather than folded or miscounted as a message.
1645
+ driver.resolveInteraction(interactionId, {
1646
+ status: 'approved',
1647
+ diagnostics: [
1648
+ null,
1649
+ 'oops',
1650
+ 42,
1651
+ { timestamp: '2026-07-23T10:00:00.000Z' }, // no kind
1652
+ {
1653
+ kind: 'message',
1654
+ timestamp: '2026-07-23T10:00:01.000Z',
1655
+ role: 'assistant',
1656
+ content: 'real',
1657
+ },
1658
+ { kind: 'meta-snapshot', timestamp: '2026-07-23T10:00:02.000Z', meta: { host: 'foreign' } },
1659
+ ],
1660
+ });
1661
+ await pending;
1662
+
1663
+ // Only the one real message survives the buffer; the meta-snapshot is not folded.
1664
+ const external = driver.getExternalDiagnostics();
1665
+ assert.equal(
1666
+ external.map((e) => e.kind),
1667
+ ['message'],
1668
+ );
1669
+ assert.is(external[0].content, 'real');
1670
+ // Manifest counts only the three real kinds — the meta-snapshot is not miscounted as a message.
1671
+ const manifest = getMetaEvents(sessionKey).find(
1672
+ (e) => e.type === 'external-diagnostics.folded',
1673
+ );
1674
+ assert.equal(manifest!.detail?.counts, { turn: 0, event: 0, message: 1 });
1675
+ },
1676
+ );
1677
+
1678
+ externalDiagnostics.run();
1679
+
1465
1680
  // ---------------------------------------------------------------------------
1466
1681
  // interaction context lifecycle (GENC-1390) — the driver creates a live
1467
1682
  // InteractionContext on requestInteraction and disposes+drops it on resolve, so
@@ -44,6 +44,7 @@ import { resolveChatProvider } from '../../config/validate-providers';
44
44
  import {
45
45
  clearSession,
46
46
  getMetaEvents,
47
+ type MetaEvent,
47
48
  mergeMetaEvents,
48
49
  type MetaEventType,
49
50
  recordMetaEvent,
@@ -54,6 +55,7 @@ import {
54
55
  createInteractionContext,
55
56
  type InteractionContextHandle,
56
57
  } from '../../state/interaction-context';
58
+ import type { DiagnosticEntry } from '../../state/persistence/diagnostics';
57
59
  import type { InteractionContext } from '../../types/interaction-context';
58
60
  import { applyCondensation, type RegisteredCondensePolicy } from '../../utils/condense-history';
59
61
  import {
@@ -435,6 +437,23 @@ export class ChatDriver extends EventTarget implements AiDriver {
435
437
  * arrive. See {@link TurnSnapshot} for the captured shape.
436
438
  */
437
439
  private turnSnapshots: TurnSnapshot[] = [];
440
+ /**
441
+ * `turn` + `message` diagnostic-log entries harvested from an out-of-band source (a resolved
442
+ * interaction whose widget returned `InteractionResult.diagnostics` — e.g. a server-side
443
+ * ChatDriver's debug log). `event` entries are NOT kept here — they're folded into the session's
444
+ * meta-event registry via `mergeMetaEvents` (see `resolveInteraction`) so they inherit fresh host
445
+ * indices, and reach the log through `getMetaEvents`. Append-only; the host folds these into its
446
+ * debug log alongside this driver's own timeline. Not cleared here — it lives with the driver
447
+ * instance, like `turnSnapshots`, so a new chat (fresh driver) starts empty while a compaction
448
+ * (same instance) keeps it. (GENC-1461.)
449
+ */
450
+ private readonly externalDiagnostics: DiagnosticEntry[] = [];
451
+ /**
452
+ * Monotonic count of external-diagnostics batches harvested this driver-lifetime. Namespaces each
453
+ * batch's re-keyed `turn` entries so distinct server generations in one chat session can't collide
454
+ * on `turnIndex` in the forward-capture persister (which dedups turns by that key). (GENC-1461.)
455
+ */
456
+ private externalDiagnosticsBatches = 0;
438
457
  /** Monotonic counter that survives agent swaps — useful for cross-referencing with history. */
439
458
  private globalTurnIndex = 0;
440
459
  /** Captured from `applyAgent` so we don't store the whole `AgentConfig`. */
@@ -971,6 +990,18 @@ export class ChatDriver extends EventTarget implements AiDriver {
971
990
  return this.turnSnapshots;
972
991
  }
973
992
 
993
+ /**
994
+ * The `turn` + `message` diagnostic-log entries harvested from resolved interactions'
995
+ * `InteractionResult.diagnostics` (e.g. a server-side ChatDriver's collated debug log). The host
996
+ * concatenates these into its own debug log so `downloadDebugLog`/the persisted diagnostics stream
997
+ * include external-engine logs; `event` entries are excluded here because they're merged into the
998
+ * session's meta-event registry instead (see `resolveInteraction`) and reach the log via
999
+ * `getMetaEvents`. Opaque, chronologically sorted by the host at assemble time. (GENC-1461.)
1000
+ */
1001
+ getExternalDiagnostics(): ReadonlyArray<DiagnosticEntry> {
1002
+ return this.externalDiagnostics;
1003
+ }
1004
+
974
1005
  /**
975
1006
  * Merge a sub-agent's turn snapshots into this driver's buffer so they surface
976
1007
  * as `kind:'turn'` entries in the exported debug log. The child runs as a
@@ -1391,6 +1422,84 @@ export class ChatDriver extends EventTarget implements AiDriver {
1391
1422
  // Clear the timeout so a user resolution doesn't later fire a stale
1392
1423
  // timeout (and a timeout firing here clears its own already-fired handle).
1393
1424
  if (interaction.timeoutHandle) clearTimeout(interaction.timeoutHandle);
1425
+ // Harvest any out-of-band diagnostic entries the widget collated (e.g. from a server-side
1426
+ // ChatDriver) so the host's debug log + persisted diagnostics include them. Independent of the
1427
+ // message `idx` below — they describe the widget's own work, not this message.
1428
+ //
1429
+ // The harvested entries carry the SOURCE engine's own index space (0-based turns/events), which
1430
+ // would collide with this host's cursors in the forward-capture persister — turns dedup by
1431
+ // `turnIndex`, events by an `index` high-water mark — so foreign entries get silently dropped
1432
+ // from the persisted stream. Route each kind through the host's own collision-safe machinery,
1433
+ // mirroring how a sub-agent's log is folded in (see `invokeSubAgent`):
1434
+ // • `event` → `mergeMetaEvents`, which re-indexes them onto this session's monotonic event
1435
+ // counter (clearing the high-water) while preserving their timestamps; they then
1436
+ // reach the log via `getMetaEvents` in `buildTimelineEntries`, not this buffer.
1437
+ // • `turn` → re-key `turnIndex` to the turn's local position within a per-batch namespace,
1438
+ // so turns stay unique even when one interaction folds in several independent
1439
+ // server runs that each number their own turns from 0 (a consolidator carries a
1440
+ // consolidator-code run AND a table-code run), and never collide with the host's
1441
+ // own bare-integer turn keys.
1442
+ // • `message` → kept as-is; the persister already keys messages by content, so they survive.
1443
+ // TODO(GENC-1461, option 2 / streaming): expose an imperative `appendDiagnostics(entries)` on
1444
+ // the handler context (see buildHandlerContext) that runs this same decomposition as SSE ticks
1445
+ // arrive, so a streaming widget can surface its external engine's log live rather than only at
1446
+ // resolve time. Batch-at-resolve (here) is the current, non-streaming path.
1447
+ const harvestedDiagnostics = (result as { diagnostics?: readonly unknown[] } | undefined)
1448
+ ?.diagnostics;
1449
+ if (Array.isArray(harvestedDiagnostics) && harvestedDiagnostics.length) {
1450
+ const batch = (this.externalDiagnosticsBatches += 1);
1451
+ const externalEvents: MetaEvent[] = [];
1452
+ let turnCount = 0;
1453
+ let messageCount = 0;
1454
+ for (const raw of harvestedDiagnostics) {
1455
+ // The harvest is external and typed `unknown[]`, so an element could be a primitive, null,
1456
+ // or an object without a string `kind`. Guard before reading `.kind` so a malformed entry
1457
+ // can't throw here (and gets skipped rather than derailing the whole fold).
1458
+ if (
1459
+ !raw ||
1460
+ typeof raw !== 'object' ||
1461
+ typeof (raw as { kind?: unknown }).kind !== 'string'
1462
+ ) {
1463
+ continue;
1464
+ }
1465
+ const entry = raw as DiagnosticEntry;
1466
+ if (entry.kind === 'event') {
1467
+ const { kind: _kind, ...event } = entry;
1468
+ externalEvents.push(event as unknown as MetaEvent);
1469
+ } else if (entry.kind === 'turn') {
1470
+ // Local position within THIS harvest, not the source engine's own `turnIndex`: one
1471
+ // interaction can carry several independent server runs (a consolidator folds in its
1472
+ // consolidator-code run and its table-code run), each numbering its turns from 0, so
1473
+ // keying by the source index would collide them. The local position keeps every turn key
1474
+ // distinct within the batch, so the persister's per-`turnIndex` dedup keeps them all.
1475
+ this.externalDiagnostics.push({
1476
+ ...entry,
1477
+ turnIndex: `server-generation.${batch}:${turnCount}`,
1478
+ });
1479
+ turnCount += 1;
1480
+ } else if (entry.kind === 'message') {
1481
+ messageCount += 1;
1482
+ this.externalDiagnostics.push(entry);
1483
+ }
1484
+ // Any other kind is intentionally dropped, not folded. A sibling's `buildTimelineEntries`
1485
+ // only ever emits message/turn/event; `meta-snapshot` is host/DOM-owned (latest-wins on
1486
+ // reassembly, plus its own persister dedup), so folding a foreign one would pollute this
1487
+ // host's timeline and meta cursor. Restricting to the three real kinds also keeps the
1488
+ // manifest counts accurate (nothing miscounted as a `message`).
1489
+ }
1490
+ if (externalEvents.length) mergeMetaEvents(this.sessionKey, externalEvents);
1491
+ // Manifest: one provenance marker for the fold itself — records that an out-of-band engine's
1492
+ // log crossed into this session and how much of each kind, so a regression that silently
1493
+ // drops a generation's diagnostics (e.g. a new path that forgets to thread them across the
1494
+ // boundary) surfaces as a zero/absent manifest rather than only by diffing a download. Also
1495
+ // the natural seed for the streaming PR's open/close bracket. Stamped at resolve, so it
1496
+ // closes the block on the sorted timeline. (GENC-1461.)
1497
+ recordMetaEvent(this.sessionKey, 'external-diagnostics.folded', {
1498
+ interactionId,
1499
+ batch,
1500
+ counts: { turn: turnCount, event: externalEvents.length, message: messageCount },
1501
+ });
1502
+ }
1394
1503
  const idx = this.history.findIndex((m) => m.interaction?.interactionId === interactionId);
1395
1504
  if (idx !== -1) {
1396
1505
  // Fold any widget-reported external (non-LLM) cost onto the message, so
@@ -15,6 +15,7 @@ import type {
15
15
  } from '../../config/config';
16
16
  import { validateStaticAgentProviders } from '../../config/validate-providers';
17
17
  import { recordMetaEvent } from '../../state/debug-event-log';
18
+ import type { DiagnosticEntry } from '../../state/persistence/diagnostics';
18
19
  import type { InteractionContext } from '../../types/interaction-context';
19
20
  import { transformHistoryForAgent } from '../../utils/history-transform';
20
21
  import { logger } from '../../utils/logger';
@@ -269,6 +270,11 @@ export class OrchestratingDriver extends EventTarget implements AiDriver {
269
270
  return this.chatDriver.getTurnSnapshots();
270
271
  }
271
272
 
273
+ /** Delegates to the inner {@link ChatDriver} — interactions resolve there, so it holds the buffer. */
274
+ getExternalDiagnostics(): ReadonlyArray<DiagnosticEntry> {
275
+ return this.chatDriver.getExternalDiagnostics();
276
+ }
277
+
272
278
  async getSuggestions(
273
279
  history: ChatMessage[],
274
280
  prompt: string,
package/src/main/main.ts CHANGED
@@ -74,6 +74,7 @@ import {
74
74
  getDriverAgentsKey,
75
75
  deleteDriver,
76
76
  } from '../state/driver-registry';
77
+ import { buildTimelineEntries } from '../state/persistence/build-timeline-entries';
77
78
  import { assembleDebugLog } from '../state/persistence/diagnostics';
78
79
  import type { DebugLog, DiagnosticEntry } from '../state/persistence/diagnostics';
79
80
  import {
@@ -122,7 +123,6 @@ import {
122
123
  deriveCostSessionTitleFromMessages,
123
124
  resolveCostSessionTitle,
124
125
  } from '../utils/derive-cost-session-title';
125
- import { flattenSubAgentMessages } from '../utils/flatten-sub-agent-messages';
126
126
  import { logger } from '../utils/logger';
127
127
  import { filterVisibleMessages, trailingInteractionRow } from '../utils/message-partition';
128
128
  import {
@@ -2698,36 +2698,15 @@ export class FoundationAiAssistant extends GenesisElement {
2698
2698
  : undefined;
2699
2699
  const stateKey = this.getStateKey();
2700
2700
 
2701
- // Collapse repeated system prompts across consecutive turns. A stateful
2702
- // agent's prompt changes between turns, but a stable agent repeats the same
2703
- // (often multi-KB) prompt every turn so replace a turn's `systemPrompt`
2704
- // with a sentinel when it's byte-identical to the previous turn's. The
2705
- // prompt is still shown in full whenever it changes, so prompt evolution
2706
- // stays visible.
2707
- let lastFullPrompt: string | undefined;
2708
- let lastFullIndex = '';
2709
- const turns = (this.driver?.getTurnSnapshots?.() ?? []).map((t) => {
2710
- let { systemPrompt } = t;
2711
- if (systemPrompt != null && systemPrompt === lastFullPrompt) {
2712
- systemPrompt = `<repeated — identical to turn ${lastFullIndex}>`;
2713
- } else if (systemPrompt != null) {
2714
- lastFullPrompt = systemPrompt;
2715
- lastFullIndex = t.turnIndex;
2716
- }
2717
- return { kind: 'turn' as const, ...t, systemPrompt };
2701
+ // The message/turn/event timeline entries built by the shared, pure `buildTimelineEntries`
2702
+ // (the same helper a headless consumer uses to harvest its own log), from the driver's pull
2703
+ // surfaces. Prefer the driver's raw history (carries sub-agent traces) over the redux projection.
2704
+ const timelineEntries = buildTimelineEntries({
2705
+ turnSnapshots: this.driver?.getTurnSnapshots?.() ?? [],
2706
+ messages: this.driver?.getRawHistory?.() ?? this.messages,
2707
+ metaEvents: stateKey ? getMetaEvents(stateKey) : [],
2718
2708
  });
2719
2709
 
2720
- // Sub-agent conversations are stored nested on the parent tool call's
2721
- // `subAgentTrace`; `flattenSubAgentMessages` hoists them to top-level
2722
- // `kind: 'message'` entries (breadcrumbed + correlated, the nested copy moved
2723
- // out — not duplicated) so the timeline reads as one chronological sequence.
2724
- const messages = this.driver?.getRawHistory?.() ?? this.messages;
2725
- const messageEntries = flattenSubAgentMessages(messages);
2726
- const eventEntries = (stateKey ? getMetaEvents(stateKey) : []).map((e) => ({
2727
- kind: 'event' as const,
2728
- ...e,
2729
- }));
2730
-
2731
2710
  // The export-time `meta` block, carried on a `meta-snapshot` entry so it lives
2732
2711
  // in the same forward stream (the latest one wins on reassembly, and the
2733
2712
  // accumulated history exposes config/state evolution across the lifetime).
@@ -2797,7 +2776,15 @@ export class FoundationAiAssistant extends GenesisElement {
2797
2776
  activeFoldStack: m.activeFoldStack,
2798
2777
  });
2799
2778
 
2800
- return [...messageEntries, ...turns, ...eventEntries, metaSnapshot] as DiagnosticEntry[];
2779
+ // Fold in any external diagnostics harvested from an out-of-band driver (e.g. a server-side
2780
+ // ChatDriver whose collated log an interaction widget returned on its result). They ride the
2781
+ // same download + persisted-diagnostics path; `assembleDebugLog` sorts the whole timeline by
2782
+ // timestamp so they interleave chronologically. (GENC-1461 unified diagnostics.)
2783
+ return [
2784
+ ...timelineEntries,
2785
+ ...(this.driver?.getExternalDiagnostics?.() ?? []),
2786
+ metaSnapshot,
2787
+ ] as DiagnosticEntry[];
2801
2788
  }
2802
2789
 
2803
2790
  async downloadDebugLog(): Promise<void> {
@@ -59,6 +59,8 @@ export type MetaEventType =
59
59
  // Blocking interactions
60
60
  | 'interaction.requested'
61
61
  | 'interaction.resolved'
62
+ // External / unified diagnostics (GENC-1461) — an out-of-band engine's log folded into this session
63
+ | 'external-diagnostics.folded'
62
64
  // Context / cost
63
65
  | 'context.updated'
64
66
  | 'context.threshold-crossed'
@@ -125,6 +127,7 @@ export const META_EVENT_IMPORTANCE: Record<MetaEventType, MetaEventImportance> =
125
127
  'provider.selected': 'normal',
126
128
  'interaction.requested': 'normal',
127
129
  'interaction.resolved': 'normal',
130
+ 'external-diagnostics.folded': 'normal',
128
131
  'session.cleared': 'normal',
129
132
  'session.switched': 'low',
130
133
  'session.switch-deferred': 'low',
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Shared, pure builder for the debug-log timeline entries (`message` / `turn` / `event`) — the
3
+ * common core of the host element's `buildDiagnosticEntries` and any HEADLESS consumer that drives
4
+ * a `ChatDriver` of its own (e.g. `ai-service` running the engine server-side) and needs to harvest
5
+ * that engine's debug log for the client to fold into its own download / persisted stream
6
+ * (GENC-1461 unified diagnostics). DOM-clean and dependency-light so it works in bare Node.
7
+ *
8
+ * It deliberately excludes the `meta-snapshot` entry: that block is host/DOM-specific (it reads
9
+ * `window.location.host`, the element's agent list, live context/cost), and a merged log keeps only
10
+ * the newest snapshot anyway — a headless source's agent/prompt/tools are already captured in its
11
+ * `turn` entries.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+
16
+ import type { ChatMessage } from '@genesislcap/foundation-ai';
17
+ import { flattenSubAgentMessages } from '../../utils/flatten-sub-agent-messages';
18
+ import type { MetaEvent } from '../debug-event-log';
19
+ import type { DiagnosticEntry } from './diagnostics';
20
+
21
+ /**
22
+ * The minimal shape this builder reads off a turn snapshot. The driver's full `TurnSnapshot` is a
23
+ * structural superset, so it assigns to this without the builder depending on the component layer.
24
+ */
25
+ export interface TurnSnapshotLike {
26
+ turnIndex: string;
27
+ systemPrompt?: string;
28
+ }
29
+
30
+ /**
31
+ * Build the `message` / `turn` / `event` timeline entries from a driver's pull surfaces
32
+ * (`getRawHistory()` / `getTurnSnapshots()` / `getMetaEvents(sessionKey)`). Order within the array
33
+ * is message → turn → event; the caller (`assembleDebugLog`) sorts the whole timeline by timestamp,
34
+ * so this ordering only sets the co-timestamp tie-break. Pure.
35
+ */
36
+ export function buildTimelineEntries(input: {
37
+ turnSnapshots: readonly TurnSnapshotLike[];
38
+ messages: readonly ChatMessage[];
39
+ metaEvents: readonly MetaEvent[];
40
+ }): DiagnosticEntry[] {
41
+ const { turnSnapshots, messages, metaEvents } = input;
42
+
43
+ // Collapse a turn's systemPrompt when byte-identical to the previous turn's — a stable agent
44
+ // repeats the same (often multi-KB) prompt every turn, while a stateful agent's changes. The
45
+ // prompt is shown in full whenever it changes, so prompt evolution stays visible.
46
+ let lastFullPrompt: string | undefined;
47
+ let lastFullIndex = '';
48
+ const turns = turnSnapshots.map((t) => {
49
+ let { systemPrompt } = t;
50
+ if (systemPrompt != null && systemPrompt === lastFullPrompt) {
51
+ systemPrompt = `<repeated — identical to turn ${lastFullIndex}>`;
52
+ } else if (systemPrompt != null) {
53
+ lastFullPrompt = systemPrompt;
54
+ lastFullIndex = t.turnIndex;
55
+ }
56
+ return { kind: 'turn' as const, ...t, systemPrompt };
57
+ });
58
+
59
+ // Sub-agent conversations are stored nested on the parent tool call's `subAgentTrace`;
60
+ // `flattenSubAgentMessages` hoists them to top-level `kind: 'message'` entries (breadcrumbed +
61
+ // correlated, the nested copy moved out — not duplicated) so the timeline reads chronologically.
62
+ const messageEntries = flattenSubAgentMessages(messages);
63
+ const eventEntries = metaEvents.map((e) => ({ kind: 'event' as const, ...e }));
64
+
65
+ return [...messageEntries, ...turns, ...eventEntries] as DiagnosticEntry[];
66
+ }