@genesislcap/ai-assistant 15.26.0 → 15.27.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 (43) hide show
  1. package/dist/ai-assistant.api.json +150 -7
  2. package/dist/ai-assistant.d.ts +82 -99
  3. package/dist/chat-driver.cjs +460 -373
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +460 -373
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +682 -202
  8. package/dist/dts/components/chat-driver/chat-driver.d.ts +28 -200
  9. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/interaction-controller.d.ts +99 -0
  11. package/dist/dts/components/chat-driver/interaction-controller.d.ts.map +1 -0
  12. package/dist/dts/components/chat-driver/turn-recorder.d.ts +191 -0
  13. package/dist/dts/components/chat-driver/turn-recorder.d.ts.map +1 -0
  14. package/dist/dts/components/settings-modal/settings-modal.template.d.ts +20 -0
  15. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  16. package/dist/dts/main/main.d.ts +32 -1
  17. package/dist/dts/main/main.d.ts.map +1 -1
  18. package/dist/dts/main/main.styles.d.ts.map +1 -1
  19. package/dist/dts/main/main.template.d.ts +19 -0
  20. package/dist/dts/main/main.template.d.ts.map +1 -1
  21. package/dist/dts/state/ai-assistant-slice.d.ts +7 -1
  22. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  23. package/dist/dts/state/persistence/session-persistence-provider.d.ts +5 -0
  24. package/dist/dts/state/persistence/session-persistence-provider.d.ts.map +1 -1
  25. package/dist/dts/state/session-store.d.ts +1 -0
  26. package/dist/dts/state/session-store.d.ts.map +1 -1
  27. package/dist/dts/utils/format-message-timestamp.d.ts +77 -0
  28. package/dist/dts/utils/format-message-timestamp.d.ts.map +1 -0
  29. package/dist/dts/utils/resolve-preference-baseline.d.ts +18 -1
  30. package/dist/dts/utils/resolve-preference-baseline.d.ts.map +1 -1
  31. package/dist/esm/components/chat-driver/chat-driver.js +68 -404
  32. package/dist/esm/components/chat-driver/interaction-controller.js +235 -0
  33. package/dist/esm/components/chat-driver/turn-recorder.js +206 -0
  34. package/dist/esm/components/settings-modal/settings-modal.template.js +53 -2
  35. package/dist/esm/main/main.js +80 -17
  36. package/dist/esm/main/main.styles.js +75 -0
  37. package/dist/esm/main/main.template.js +43 -2
  38. package/dist/esm/state/ai-assistant-slice.js +5 -0
  39. package/dist/esm/utils/format-message-timestamp.js +126 -0
  40. package/dist/esm/utils/resolve-preference-baseline.js +54 -2
  41. package/package.json +16 -16
  42. package/sandbox/README.md +45 -2
  43. package/sandbox/sandbox.ts +95 -3
@@ -0,0 +1,235 @@
1
+ import { recordMetaEvent } from '../../state/debug-event-log';
2
+ import { createInteractionContext, } from '../../state/interaction-context';
3
+ import { logger } from '../../utils/logger';
4
+ /**
5
+ * Custom UI interactions: parking a tool handler on a widget, and settling it when the user (or a
6
+ * timeout) answers.
7
+ *
8
+ * Owns the pending call and its live context, which are created and destroyed together. Everything
9
+ * else it needs is the driver's history and event target, reached through `InteractionDeps`.
10
+ */
11
+ export class InteractionController {
12
+ constructor(deps) {
13
+ this.deps = deps;
14
+ this.pending = new Map();
15
+ /**
16
+ * Live per-interaction contexts (GENC-1390), keyed by interactionId. Created in `request`
17
+ * alongside the pending entry, disposed + removed in `resolve`. Non-serialisable (sockets,
18
+ * timers, subscriber sets), so they live here — not on the DOM, not in the persisted store — and
19
+ * therefore survive the element remounts (layout reflow/drag, transcript re-render) that this
20
+ * feature exists to make harmless. At most one is live at a time (see the
21
+ * one-interaction-at-a-time guard in `request`).
22
+ */
23
+ this.contexts = new Map();
24
+ }
25
+ /** See `ChatDriver.setHostInteractionRequester`. */
26
+ setHostRequester(fn) {
27
+ this.hostRequester = fn;
28
+ }
29
+ /** See `ChatDriver.requestInteraction`. */
30
+ request(componentName, data, options) {
31
+ if (this.hostRequester) {
32
+ return this.hostRequester(componentName, data, options);
33
+ }
34
+ if (this.pending.size > 0) {
35
+ throw new Error('requestInteraction: another user interaction is already in flight. ' +
36
+ 'Only one interaction may be active at a time — sequence them in a single tool handler ' +
37
+ 'rather than spawning widgets from parallel sub-agents or parallel tool calls.');
38
+ }
39
+ const interactionId = crypto.randomUUID();
40
+ const chatInputDuringExecution = options === null || options === void 0 ? void 0 : options.chatInputDuringExecution;
41
+ const timeoutMs = options === null || options === void 0 ? void 0 : options.timeoutMs;
42
+ const presentation = options === null || options === void 0 ? void 0 : options.presentation;
43
+ return new Promise((resolve, reject) => {
44
+ this.pending.set(interactionId, {
45
+ resolve,
46
+ reject,
47
+ overrideId: chatInputDuringExecution ? interactionId : undefined,
48
+ // Resolve the interaction with a timeout result if the user does not
49
+ // respond in time. `resolve` runs the same teardown a normal resolution
50
+ // does (marks the widget read-only, reverts any input override) and
51
+ // clears any still-pending handle.
52
+ timeoutHandle: timeoutMs != null
53
+ ? setTimeout(() => {
54
+ this.resolve(interactionId, {
55
+ status: 'timeout',
56
+ message: `Interaction timed out after ${timeoutMs}ms.`,
57
+ });
58
+ }, timeoutMs)
59
+ : undefined,
60
+ });
61
+ recordMetaEvent(this.deps.sessionKey, 'interaction.requested', {
62
+ interactionId,
63
+ component: componentName,
64
+ agent: this.deps.activeAgentName(),
65
+ });
66
+ // Signal the park boundary on the activity bus so turn-aware UI can tell
67
+ // "actively computing" from "parked awaiting the user" — the latter is a
68
+ // safe window for actions disallowed mid-request (e.g. switching provider
69
+ // during a long journey step). Paired with `interaction-resolved`.
70
+ this.deps.activityBus.publish('interaction-requested', undefined);
71
+ if (chatInputDuringExecution) {
72
+ this.deps.emit(new CustomEvent('interaction-start', {
73
+ detail: { interactionId, chatInputDuringExecution },
74
+ }));
75
+ }
76
+ // Create the live context (GENC-1390) BEFORE the widget can mount, so the
77
+ // wrapper finds it when it builds the pending `interaction` descriptor.
78
+ this.contexts.set(interactionId, createInteractionContext(interactionId));
79
+ this.deps.appendToHistory({
80
+ role: 'assistant',
81
+ content: '',
82
+ interaction: Object.assign({ interactionId,
83
+ componentName,
84
+ data }, (presentation ? { presentation } : {})),
85
+ });
86
+ });
87
+ }
88
+ /**
89
+ * See `ChatDriver.resolveInteraction`. Warns and returns when the id is unknown — a widget can
90
+ * report twice, and the second report has nothing left to settle.
91
+ */
92
+ resolve(interactionId, result) {
93
+ var _a;
94
+ const interaction = this.pending.get(interactionId);
95
+ if (!interaction) {
96
+ logger.warn(`Interaction with ID ${interactionId} not found.`);
97
+ return;
98
+ }
99
+ // Clear the timeout so a user resolution doesn't later fire a stale
100
+ // timeout (and a timeout firing here clears its own already-fired handle).
101
+ if (interaction.timeoutHandle)
102
+ clearTimeout(interaction.timeoutHandle);
103
+ // Harvest any out-of-band diagnostic entries the widget collated (e.g. from a server-side
104
+ // ChatDriver) so the host's debug log + persisted diagnostics include them. Independent of the
105
+ // message patched below — they describe the widget's own work, not this message.
106
+ this.deps.foldDiagnostics(interactionId, result === null || result === void 0 ? void 0 : result.diagnostics);
107
+ // Fold any widget-reported external (non-LLM) cost onto the message, so
108
+ // the host's `sumCosts` recompute includes it in the session total.
109
+ // Accumulates defensively in case a widget reports more than once.
110
+ const costUsd = typeof (result === null || result === void 0 ? void 0 : result.costUsd) === 'number' && Number.isFinite(result.costUsd) && result.costUsd > 0
111
+ ? result.costUsd
112
+ : undefined;
113
+ const patched = this.deps.patchInteractionMessage(interactionId, (message) => {
114
+ var _a;
115
+ return (Object.assign(Object.assign(Object.assign({}, message), { interaction: Object.assign(Object.assign({}, message.interaction), { resolved: result }) }), (costUsd != null ? { externalCostUsd: ((_a = message.externalCostUsd) !== null && _a !== void 0 ? _a : 0) + costUsd } : {})));
116
+ });
117
+ if (patched)
118
+ this.deps.notifyHistoryUpdated();
119
+ // A widget may opt into echoing its outcome on the user's side of the
120
+ // chat by returning `userMessage`. Appended as a display-only
121
+ // `'synthetic-user'` message: it is tracked in history (and the debug log)
122
+ // and rendered like a user message, but transports never serialize it —
123
+ // the model already learns the outcome from this handler's return value.
124
+ // Appended before `interaction.resolve` so it precedes the tool result and
125
+ // any follow-up turn the resumed handler produces.
126
+ if (typeof (result === null || result === void 0 ? void 0 : result.userMessage) === 'string' && result.userMessage.trim()) {
127
+ this.deps.appendToHistory({ role: 'synthetic-user', content: result.userMessage });
128
+ }
129
+ if (interaction.overrideId) {
130
+ this.deps.emit(new CustomEvent('interaction-stop', { detail: { interactionId } }));
131
+ }
132
+ recordMetaEvent(this.deps.sessionKey, 'interaction.resolved', { interactionId });
133
+ // The park is ending and the loop is about to resume computing — paired
134
+ // with `interaction-requested`. Fires for every resolution path (user
135
+ // completion, timeout, cancellation), since all route through here.
136
+ this.deps.activityBus.publish('interaction-resolved', undefined);
137
+ interaction.resolve(result);
138
+ this.pending.delete(interactionId);
139
+ // Tear down the live context on RESOLVE (not on element unmount) — this closes
140
+ // any sockets/timers the widget parked on it via `onDispose`. (GENC-1390)
141
+ (_a = this.contexts.get(interactionId)) === null || _a === void 0 ? void 0 : _a.dispose();
142
+ this.contexts.delete(interactionId);
143
+ }
144
+ /**
145
+ * See `ChatDriver.recordExternalCost`.
146
+ *
147
+ * **Why this exists separately from `resolve`.** A widget's spend reaches the session total only
148
+ * through `InteractionResult.costUsd`, which is read exactly once — `resolve` does its whole job
149
+ * against a live pending entry and then deletes it. That is correct for the resolution itself,
150
+ * but it assumes a widget stops costing money when it resolves, and several deliberately do not:
151
+ * a widget that resolves EARLY so the assistant stops waiting (options shown, a preview
152
+ * rendered) may stay interactive for minutes afterwards and do more paid work on the user's
153
+ * behalf. Re-emitting `interaction-completed` does not help — with the interaction gone from the
154
+ * map the call is a silent no-op — so that spend had nowhere to go and was simply missing from
155
+ * the figure shown to the user.
156
+ *
157
+ * Touches only `externalCostUsd`: the recorded `interaction.resolved` payload is left exactly as
158
+ * the resolution wrote it, because this is not a second resolution and must not read like one.
159
+ */
160
+ recordExternalCost(interactionId, costUsd) {
161
+ const drop = (reason) => {
162
+ recordMetaEvent(this.deps.sessionKey, 'interaction.external-cost-dropped', {
163
+ interactionId,
164
+ costUsd,
165
+ reason,
166
+ });
167
+ return false;
168
+ };
169
+ if (!interactionId)
170
+ return drop('no interactionId');
171
+ if (typeof costUsd !== 'number' || !Number.isFinite(costUsd) || costUsd <= 0) {
172
+ return drop('not a positive finite amount');
173
+ }
174
+ const patched = this.deps.patchInteractionMessage(interactionId, (message) => {
175
+ var _a;
176
+ return (Object.assign(Object.assign({}, message), { externalCostUsd: ((_a = message.externalCostUsd) !== null && _a !== void 0 ? _a : 0) + costUsd }));
177
+ });
178
+ // The message is gone (trimmed or condensed): the spend is real and already billed, so it is
179
+ // recorded as dropped rather than silently discarded — an invisible gap in the session total
180
+ // is exactly what this whole channel exists to close.
181
+ if (!patched)
182
+ return drop('interaction message no longer in history');
183
+ recordMetaEvent(this.deps.sessionKey, 'interaction.external-cost', { interactionId, costUsd });
184
+ // A NARROW event, deliberately not `history-updated`.
185
+ //
186
+ // The host answers `history-updated` by reassigning `messages`, which re-evaluates
187
+ // `activeInteractionRow` — and the trailing interaction slot renders through a
188
+ // `repeat(..., { recycle: false })`, so every view in it is disposed and rebuilt regardless of
189
+ // item identity. That is correct for a PENDING interaction (GENC-1429: a recycled wrapper
190
+ // carries the previous interaction's truthy `resolved` and swallows the next completion), and
191
+ // survivable because `buildInteraction` hands a remounted pending widget the same driver-owned
192
+ // `interactionContext`.
193
+ //
194
+ // A RESOLVED one has no such context — `resolve` disposed and deleted it — so announcing this
195
+ // on `history-updated` would tear down and blank the very widget that just reported, in
196
+ // exactly the flow this channel exists for (a user tweaking an option after the interaction
197
+ // resolved). Bookkeeping about a finished turn must stay invisible, so the host recomputes its
198
+ // totals from this driver's history and saves, without touching what is rendered. Reviewer
199
+ // catch on #2474 — confirmed against the layout widget, whose tweak result went off screen
200
+ // because the rebuild raced the session write that would have restored it.
201
+ this.deps.emit(new CustomEvent('external-cost-recorded', {
202
+ detail: { interactionId, costUsd },
203
+ }));
204
+ return true;
205
+ }
206
+ /** See `ChatDriver.getInteractionContext`. */
207
+ context(interactionId) {
208
+ var _a;
209
+ return (_a = this.contexts.get(interactionId)) === null || _a === void 0 ? void 0 : _a.context;
210
+ }
211
+ /**
212
+ * Resolve every in-flight interaction with a `'timeout'` result, closing its
213
+ * widget read-only. Used when a sub-agent that owns the open widget times out:
214
+ * the widget is parked in *this* driver's map (interactions resolve on the
215
+ * host, not the child), so cancelling it here unblocks the sub-agent handler
216
+ * awaiting it. The "one interaction at a time" invariant means there is at
217
+ * most one.
218
+ */
219
+ cancelAll(message) {
220
+ // Snapshot the keys: `resolve` deletes from the map it would otherwise be iterating live.
221
+ for (const interactionId of [...this.pending.keys()]) {
222
+ this.resolve(interactionId, { status: 'timeout', message });
223
+ }
224
+ }
225
+ /**
226
+ * Drop any live interaction contexts on driver teardown. Normally empty (a context is created
227
+ * and destroyed with its pending interaction, and dispose is not reached mid-interaction);
228
+ * disposed defensively so a stray context can never leak an open socket/timer.
229
+ */
230
+ disposeContexts() {
231
+ for (const handle of this.contexts.values())
232
+ handle.dispose();
233
+ this.contexts.clear();
234
+ }
235
+ }
@@ -0,0 +1,206 @@
1
+ import { __rest } from "tslib";
2
+ import { mergeMetaEvents, recordMetaEvent } from '../../state/debug-event-log';
3
+ /**
4
+ * Everything the exported debug log draws from one driver: its own per-LLM-call snapshots, the
5
+ * ones forwarded up from sub-agents, and the diagnostics harvested from resolved interactions.
6
+ *
7
+ * All three feed the same timeline and all three are keyed in this driver's turn space, which is
8
+ * why they share an owner — a forwarded child turn and a harvested server turn are both re-labelled
9
+ * against the counter kept here.
10
+ */
11
+ export class TurnRecorder {
12
+ constructor(sessionKey, maxSnapshots) {
13
+ this.sessionKey = sessionKey;
14
+ this.maxSnapshots = maxSnapshots;
15
+ /**
16
+ * Ring buffer of per-LLM-call snapshots, capped at `maxSnapshots`; older entries drop off as
17
+ * new ones arrive.
18
+ */
19
+ this.buffer = [];
20
+ /** Monotonic counter that survives agent swaps — useful for cross-referencing with history. */
21
+ this.turnIndex = 0;
22
+ /**
23
+ * `turn` + `message` diagnostic entries harvested from an out-of-band source. `event` entries
24
+ * are NOT kept here — they are folded into the session's meta-event registry so they inherit
25
+ * fresh host indices, and reach the log through `getMetaEvents`. Append-only, and never cleared:
26
+ * it lives with the recorder, so a new chat (fresh driver) starts empty while a compaction (same
27
+ * instance) keeps it. (GENC-1461.)
28
+ */
29
+ this.externalEntries = [];
30
+ /**
31
+ * Monotonic count of external-diagnostics batches folded this driver-lifetime. Namespaces each
32
+ * batch's re-keyed `turn` entries so distinct server generations in one chat session can't
33
+ * collide on `turnIndex` in the forward-capture persister (which dedups turns by that key).
34
+ */
35
+ this.externalBatches = 0;
36
+ }
37
+ /** Point at the incoming agent's `getDebugSnapshot`, or clear it when the agent supplies none. */
38
+ setSnapshotter(snapshotter) {
39
+ this.snapshotter = snapshotter;
40
+ }
41
+ /**
42
+ * The per-turn snapshots captured so far — what the LLM saw on each turn: system prompt, tool
43
+ * surface, and agent-supplied state (e.g. a machine snapshot).
44
+ */
45
+ snapshots() {
46
+ return this.buffer;
47
+ }
48
+ /**
49
+ * The harvested `turn` + `message` diagnostic entries. Opaque, and chronologically sorted by the
50
+ * host at assemble time.
51
+ */
52
+ externalDiagnostics() {
53
+ return this.externalEntries;
54
+ }
55
+ /**
56
+ * Push one snapshot to the ring buffer. Called inside `runToolLoop` just
57
+ * before each LLM call — that's the latest point where the prompt, tool
58
+ * surface, and agent state line up with what the model is about to see.
59
+ *
60
+ * Returns the pushed object so the caller can back-fill what only the response
61
+ * knows (`usage`). Mutating it after the fact is safe whether or not the ring
62
+ * buffer has since evicted it — an evicted snapshot is simply no longer exported.
63
+ */
64
+ record(turn) {
65
+ let agentSnapshot;
66
+ if (this.snapshotter) {
67
+ try {
68
+ agentSnapshot = this.snapshotter();
69
+ }
70
+ catch (e) {
71
+ // A snapshotter throwing must not derail the LLM call — capture the
72
+ // error string in place of the snapshot so the export still shows
73
+ // *something* happened.
74
+ agentSnapshot = `<getDebugSnapshot threw: ${e instanceof Error ? e.message : String(e)}>`;
75
+ }
76
+ }
77
+ const turnIndex = String(this.turnIndex);
78
+ this.turnIndex += 1;
79
+ const snapshot = Object.assign(Object.assign({}, turn), { turnIndex, timestamp: new Date().toISOString(), agentSnapshot });
80
+ this.buffer.push(snapshot);
81
+ this.trim();
82
+ return snapshot;
83
+ }
84
+ /**
85
+ * Merge a sub-agent's turn snapshots into this driver's buffer so they surface
86
+ * as `kind:'turn'` entries in the exported debug log. The child runs as a
87
+ * separate, discarded driver, so its snapshots would otherwise be lost. Each is
88
+ * re-labelled under the parent turn that activated the sub-agent: the child's
89
+ * own (numeric) turns become `"<parentTurn>-1"`, `"-2"`, … (1-based, in order);
90
+ * any already-forwarded grand-child labels (strings) have their leading segment
91
+ * remapped the same way, so nesting composes (`"5-2"` → `"5-2-1"`).
92
+ *
93
+ * Note: two sub-agents invoked in the *same* parent turn share the prefix, so
94
+ * their labels can repeat — `agentName` on each snapshot disambiguates them.
95
+ */
96
+ forwardSubAgent(childSnapshots) {
97
+ var _a;
98
+ if (childSnapshots.length === 0)
99
+ return;
100
+ // The activating parent turn = the most recent snapshot this driver recorded
101
+ // before entering the tool handler that invoked the sub-agent.
102
+ const parentTurn = Math.max(0, this.turnIndex - 1);
103
+ const ownTurnLabel = new Map();
104
+ let ownPos = 0;
105
+ for (const snap of childSnapshots) {
106
+ let turnIndex;
107
+ if (!snap.turnIndex.includes('-')) {
108
+ // The child's own turn (a bare counter) → number it under the parent turn.
109
+ ownPos += 1;
110
+ turnIndex = `${parentTurn}-${ownPos}`;
111
+ ownTurnLabel.set(snap.turnIndex, turnIndex);
112
+ }
113
+ else {
114
+ // An already-forwarded grand-child label — remap its leading segment.
115
+ const [lead, ...rest] = snap.turnIndex.split('-');
116
+ const leadLabel = (_a = ownTurnLabel.get(lead)) !== null && _a !== void 0 ? _a : `${parentTurn}-${lead}`;
117
+ turnIndex = [leadLabel, ...rest].join('-');
118
+ }
119
+ this.buffer.push(Object.assign(Object.assign({}, snap), { turnIndex }));
120
+ }
121
+ this.trim();
122
+ }
123
+ /**
124
+ * Fold a widget's out-of-band diagnostics into this session — typically a server-side
125
+ * ChatDriver's collated log, arriving on `InteractionResult.diagnostics` — so the host's debug
126
+ * log and persisted diagnostics stream include them. Independent of the interaction's own
127
+ * message: these describe the widget's work, not that message. A non-array or empty harvest is
128
+ * a no-op.
129
+ */
130
+ foldDiagnostics(interactionId, harvested) {
131
+ // The harvested entries carry the SOURCE engine's own index space (0-based turns/events), which
132
+ // would collide with this host's cursors in the forward-capture persister — turns dedup by
133
+ // `turnIndex`, events by an `index` high-water mark — so foreign entries get silently dropped
134
+ // from the persisted stream. Route each kind through the host's own collision-safe machinery,
135
+ // mirroring how a sub-agent's log is folded in (see `invokeSubAgent`):
136
+ // • `event` → `mergeMetaEvents`, which re-indexes them onto this session's monotonic event
137
+ // counter (clearing the high-water) while preserving their timestamps; they then
138
+ // reach the log via `getMetaEvents` in `buildTimelineEntries`, not this buffer.
139
+ // • `turn` → re-key `turnIndex` to the turn's local position within a per-batch namespace,
140
+ // so turns stay unique even when one interaction folds in several independent
141
+ // server runs that each number their own turns from 0 (a consolidator carries a
142
+ // consolidator-code run AND a table-code run), and never collide with the host's
143
+ // own bare-integer turn keys.
144
+ // • `message` → kept as-is; the persister already keys messages by content, so they survive.
145
+ // TODO(GENC-1461, option 2 / streaming): expose an imperative `appendDiagnostics(entries)` on
146
+ // the handler context (see buildHandlerContext) that runs this same decomposition as SSE ticks
147
+ // arrive, so a streaming widget can surface its external engine's log live rather than only at
148
+ // resolve time. Batch-at-resolve (here) is the current, non-streaming path.
149
+ if (!Array.isArray(harvested) || !harvested.length)
150
+ return;
151
+ const batch = (this.externalBatches += 1);
152
+ const externalEvents = [];
153
+ let turnCount = 0;
154
+ let messageCount = 0;
155
+ for (const raw of harvested) {
156
+ // The harvest is external and typed `unknown[]`, so an element could be a primitive, null,
157
+ // or an object without a string `kind`. Guard before reading `.kind` so a malformed entry
158
+ // can't throw here (and gets skipped rather than derailing the whole fold).
159
+ if (!raw || typeof raw !== 'object' || typeof raw.kind !== 'string') {
160
+ continue;
161
+ }
162
+ const entry = raw;
163
+ if (entry.kind === 'event') {
164
+ const { kind: _kind } = entry, event = __rest(entry, ["kind"]);
165
+ externalEvents.push(event);
166
+ }
167
+ else if (entry.kind === 'turn') {
168
+ // Local position within THIS harvest, not the source engine's own `turnIndex`: one
169
+ // interaction can carry several independent server runs (a consolidator folds in its
170
+ // consolidator-code run and its table-code run), each numbering its turns from 0, so
171
+ // keying by the source index would collide them. The local position keeps every turn key
172
+ // distinct within the batch, so the persister's per-`turnIndex` dedup keeps them all.
173
+ this.externalEntries.push(Object.assign(Object.assign({}, entry), { turnIndex: `server-generation.${batch}:${turnCount}` }));
174
+ turnCount += 1;
175
+ }
176
+ else if (entry.kind === 'message') {
177
+ messageCount += 1;
178
+ this.externalEntries.push(entry);
179
+ }
180
+ // Any other kind is intentionally dropped, not folded. A sibling's `buildTimelineEntries`
181
+ // only ever emits message/turn/event; `meta-snapshot` is host/DOM-owned (latest-wins on
182
+ // reassembly, plus its own persister dedup), so folding a foreign one would pollute this
183
+ // host's timeline and meta cursor. Restricting to the three real kinds also keeps the
184
+ // manifest counts accurate (nothing miscounted as a `message`).
185
+ }
186
+ if (externalEvents.length)
187
+ mergeMetaEvents(this.sessionKey, externalEvents);
188
+ // Manifest: one provenance marker for the fold itself — records that an out-of-band engine's
189
+ // log crossed into this session and how much of each kind, so a regression that silently
190
+ // drops a generation's diagnostics (e.g. a new path that forgets to thread them across the
191
+ // boundary) surfaces as a zero/absent manifest rather than only by diffing a download. Also
192
+ // the natural seed for the streaming PR's open/close bracket. Stamped at resolve, so it
193
+ // closes the block on the sorted timeline. (GENC-1461.)
194
+ recordMetaEvent(this.sessionKey, 'external-diagnostics.folded', {
195
+ interactionId,
196
+ batch,
197
+ counts: { turn: turnCount, event: externalEvents.length, message: messageCount },
198
+ });
199
+ }
200
+ /** Hold the ring buffer at its cap, dropping oldest-first. */
201
+ trim() {
202
+ while (this.buffer.length > this.maxSnapshots) {
203
+ this.buffer.shift();
204
+ }
205
+ }
206
+ }
@@ -31,6 +31,54 @@ const toggleAnimationEnabled = (host, value, enabled) => {
31
31
  host.setEnabledAnimations(next);
32
32
  };
33
33
  const readSwitchChecked = (event) => event.target.checked;
34
+ /**
35
+ * The timestamp modes, in the order they read as an increasing amount of chrome.
36
+ *
37
+ * Keyed by {@link TimestampDisplay} rather than listed as an array of `{ value, label }`,
38
+ * so adding a mode to the union is a compile error here instead of a picker that silently
39
+ * cannot reach it.
40
+ */
41
+ const TIMESTAMP_MODE_LABELS = {
42
+ off: 'Off',
43
+ hover: 'On hover',
44
+ always: 'Always',
45
+ };
46
+ const TIMESTAMP_MODE_OPTIONS = Object.keys(TIMESTAMP_MODE_LABELS).map((value) => ({ value, label: TIMESTAMP_MODE_LABELS[value] }));
47
+ /**
48
+ * Three-way picker for {@link TimestampDisplay} (GENC-1257).
49
+ *
50
+ * The design system's segmented control rather than the `switch` every other row uses,
51
+ * because the setting genuinely has three states — and rather than hand-rolled buttons
52
+ * carrying `role="radio"`, because ARIA roles supply semantics but never behaviour:
53
+ * promising a radio group means owning roving `tabindex` and arrow-key navigation.
54
+ *
55
+ * `SegmentedControl extends FASTRadioGroup` (and `SegmentedItem extends FASTRadio`), so
56
+ * this IS the DS radio group — it inherits the whole keyboard pattern — just skinned as
57
+ * segments rather than dots, which is the right affordance for three short mode labels.
58
+ *
59
+ * The usual reason to avoid a DS control here does not apply. `RadioGroup.valueChanged`
60
+ * emits `change` whenever `value` is written — including the write this template's own
61
+ * binding performs on render, exactly the phantom-change footgun the DS switch has
62
+ * (GENC-1351 #1). It is harmless because the `timestampDisplay` setter guards on
63
+ * `preferenceUnchanged`, as every preference setter does, so a render-time echo returns early
64
+ * and never reaches the store or marks preferences user-changed.
65
+ */
66
+ export const timestampModePickerTemplate = (segmentedControlTag, segmentedItemTag) => html `
67
+ <${segmentedControlTag}
68
+ part="toggle-timestamps"
69
+ aria-label="Show timestamps"
70
+ :value=${(x) => x.timestampDisplay}
71
+ @change=${(x, c) => {
72
+ x.timestampDisplay = c.event.target.value;
73
+ }}
74
+ >
75
+ ${repeat(() => TIMESTAMP_MODE_OPTIONS, html `
76
+ <${segmentedItemTag} part="timestamp-mode-option" value="${(option) => option.value}"
77
+ >${(option) => option.label}</${segmentedItemTag}
78
+ >
79
+ `)}
80
+ </${segmentedControlTag}>
81
+ `;
34
82
  /** Inline animation picker — categories and options always visible (no multiselect popup). */
35
83
  const animationsPickerTemplate = (switchTag) => html `
36
84
  <div class="settings-animation-list" part="toggle-animations">
@@ -198,7 +246,7 @@ export const settingsUsageMetersTemplate = (progressTag, buttonTag, iconTag) =>
198
246
  </div>
199
247
  `)}
200
248
  `;
201
- const chatBotSettingsTemplate = (switchTag, buttonTag) => html `
249
+ const chatBotSettingsTemplate = (switchTag, buttonTag, segmentedControlTag, segmentedItemTag) => html `
202
250
  ${when((x) => { var _a; return ((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showToolCalls) != null; }, chatBotToggleRow('Show tool calls', "Display each tool the chatbot invokes — file reads, searches, edits and commands — inline as it works, so you can follow exactly what it's doing behind the scenes.", switchTag, 'toggle-tool-calls', (x) => x.showToolCalls, (x, c) => {
203
251
  x.showToolCalls = readSwitchChecked(c.event);
204
252
  }))}
@@ -211,6 +259,7 @@ const chatBotSettingsTemplate = (switchTag, buttonTag) => html `
211
259
  ${when((x) => { var _a; return ((_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showAgentSwitchIndicator) != null; }, chatBotToggleRow('Show delegation', 'Show in the chat window when tasks are delegated to different agents.', switchTag, 'toggle-agent-switch', (x) => x.showAgentSwitchIndicator, (x, c) => {
212
260
  x.showAgentSwitchIndicator = readSwitchChecked(c.event);
213
261
  }))}
262
+ ${settingsToggleRow('Show timestamps', 'Show when each message was sent, next to the sender name. Screen readers announce the full date in either visible mode.', 'toggle-timestamps', timestampModePickerTemplate(segmentedControlTag, segmentedItemTag))}
214
263
  ${when((x) => x.persistenceToggleable, chatBotToggleRow('Remember chat', 'Save this conversation so it can be restored after a page refresh. Turning this off deletes the saved copy.', switchTag, 'toggle-remember-chat', (x) => x.persistenceActive, (x, c) => {
215
264
  x.setPersistenceEnabled(readSwitchChecked(c.event));
216
265
  }))}
@@ -526,6 +575,8 @@ export const SettingsModalTemplate = (designSystemPrefix) => {
526
575
  const tabsTag = `${designSystemPrefix}-tabs`;
527
576
  const tabTag = `${designSystemPrefix}-tab`;
528
577
  const tabPanelTag = `${designSystemPrefix}-tab-panel`;
578
+ const segmentedControlTag = `${designSystemPrefix}-segmented-control`;
579
+ const segmentedItemTag = `${designSystemPrefix}-segmented-item`;
529
580
  const settingsTabPanel = html `
530
581
  <${tabPanelTag}
531
582
  slot="tabpanel"
@@ -595,7 +646,7 @@ export const SettingsModalTemplate = (designSystemPrefix) => {
595
646
  </h3>
596
647
  <p class="settings-modal-section-note">These options only control what you see in the chat window and do not control model behaviour.</p>
597
648
  <div class="settings-modal-section-body">
598
- ${chatBotSettingsTemplate(switchTag, buttonTag)}
649
+ ${chatBotSettingsTemplate(switchTag, buttonTag, segmentedControlTag, segmentedItemTag)}
599
650
  </div>
600
651
  </section>
601
652
  </div>