@genesislcap/ai-assistant 15.15.1 → 15.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/ai-assistant.api.json +367 -0
  2. package/dist/ai-assistant.d.ts +129 -1
  3. package/dist/chat-driver.cjs +3 -0
  4. package/dist/chat-driver.cjs.map +2 -2
  5. package/dist/chat-driver.mjs +3 -0
  6. package/dist/chat-driver.mjs.map +2 -2
  7. package/dist/custom-elements.json +232 -3
  8. package/dist/dts/main/main.d.ts +129 -0
  9. package/dist/dts/main/main.d.ts.map +1 -1
  10. package/dist/dts/main/main.template.d.ts +0 -1
  11. package/dist/dts/main/main.template.d.ts.map +1 -1
  12. package/dist/dts/main/persistence-broken-sources.test.d.ts +2 -0
  13. package/dist/dts/main/persistence-broken-sources.test.d.ts.map +1 -0
  14. package/dist/dts/state/debug-event-log.d.ts +1 -1
  15. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  16. package/dist/dts/state/persistence/session-persister.d.ts +76 -0
  17. package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
  18. package/dist/esm/main/main.js +205 -0
  19. package/dist/esm/main/main.template.js +89 -0
  20. package/dist/esm/main/persistence-broken-sources.test.js +180 -0
  21. package/dist/esm/state/debug-event-log.js +3 -0
  22. package/dist/esm/state/persistence/session-persister.js +224 -43
  23. package/dist/esm/state/persistence/session-persister.test.js +237 -3
  24. package/dist/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +17 -17
  26. package/src/main/main.template.ts +101 -0
  27. package/src/main/main.ts +230 -0
  28. package/src/main/persistence-broken-sources.test.ts +219 -0
  29. package/src/state/debug-event-log.ts +4 -0
  30. package/src/state/persistence/session-persister.test.ts +302 -33
  31. package/src/state/persistence/session-persister.ts +260 -52
@@ -28,6 +28,13 @@ const DEFAULT_DIAGNOSTICS_DEBOUNCE_MS = 4000;
28
28
  * (GENC-1351 §5.9) and set no `notResumableMessage`. Paired with the composer
29
29
  * pre-filled with the flow-activating prompt, so "send it again" is one keystroke.
30
30
  */
31
+ /**
32
+ * Consecutive failed saves before persistence is declared broken (GENC-1511). Two, not one: a
33
+ * single failure is usually a transient blip that the next autosave clears on its own, and a
34
+ * modal about a problem that has already fixed itself trains people to dismiss the one that
35
+ * matters. At the 1 s autosave debounce this is ~2 s of exposure.
36
+ */
37
+ const SAVE_FAILURES_BEFORE_BROKEN = 2;
31
38
  const DEFAULT_NOT_RESUMABLE_MESSAGE = "Sorry, I didn't manage to finish the task from your previous message. Send it again if you'd like me to try it again";
32
39
  /**
33
40
  * Owns **restore-core** persistence for one session (GENC-1351 §5.10) — the
@@ -55,6 +62,26 @@ export class SessionPersister {
55
62
  * empty). Seeded on restore. See {@link reconcileAgentSnapshots}.
56
63
  */
57
64
  this._cache = {};
65
+ /**
66
+ * How many messages the last SUCCESSFUL write persisted. Two jobs, both GENC-1511:
67
+ * it is the monotonic floor for a freeze boundary (the stored transcript must never
68
+ * shrink), and it is what lets a running flow skip a re-write when its boundary has
69
+ * not moved. Content-keyed, so unlike the `interrupted` latch it replaces it can never
70
+ * become a one-way "never write again" state.
71
+ */
72
+ this._persistedMessages = 0;
73
+ /**
74
+ * Whether the CURRENT non-resumable episode has already stamped its `interrupted`
75
+ * marker. Reset by every resumable save, so each new episode stamps exactly once and
76
+ * the "boundary unmoved" skip below can never swallow the stamp itself (GENC-1511).
77
+ */
78
+ this._interruptedStamped = false;
79
+ /** Consecutive failed saves; reset by any success. GENC-1511. */
80
+ this._saveFailures = 0;
81
+ /** Whether this broken episode has already been reported to the host. */
82
+ this._brokenReported = false;
83
+ /** Provider's reason for the most recent failure, for the host's message. */
84
+ this._lastFailureReason = '';
58
85
  /**
59
86
  * The loaded preferences blob (GENC-1351 §5.10 Phase 2), owned here so it's
60
87
  * **shared per session** — the element's `applyPreferenceBaseline` reconciler reads
@@ -130,6 +157,90 @@ export class SessionPersister {
130
157
  this._saveTimer = undefined;
131
158
  }
132
159
  }
160
+ /**
161
+ * Record a save outcome and, when persistence has been failing, decide whether now is the
162
+ * moment to tell the user (GENC-1511).
163
+ *
164
+ * The gate is `resumable`, NOT "is a turn running". `busy` was the obvious choice and it is
165
+ * useless here: a bootstrap journey is ONE turn — measured at 2817 s containing 82 widget
166
+ * interactions — so waiting for a turn boundary would hold the warning for 47 minutes. What
167
+ * actually matters is whether a reload can be resumed from the stored snapshot, and that is
168
+ * exactly what `resumable` already answers for the freeze. Bootstrap keeps everything in its
169
+ * machine context, so it is always resumable and gets told immediately; UI Builder keeps
170
+ * in-flight work in an out-of-band VFS buffer, so it waits for `idle`/`done` — which is also
171
+ * the point at which its files have been flushed and a reload costs nothing.
172
+ */
173
+ recordSaveOutcome(ok, reason, savedAt) {
174
+ var _a, _b;
175
+ if (ok) {
176
+ const wasBroken = this._brokenSince !== undefined;
177
+ this._saveFailures = 0;
178
+ this._brokenSince = undefined;
179
+ this._lastSavedAt = savedAt !== null && savedAt !== void 0 ? savedAt : this._lastSavedAt;
180
+ if (wasBroken) {
181
+ // Withdraw the warning: a save landing means the store is caught up, which we have
182
+ // watched happen twice in the wild — a broken window is not necessarily permanent.
183
+ this._brokenReported = false;
184
+ (_b = (_a = this.deps).onPersistenceRecovered) === null || _b === void 0 ? void 0 : _b.call(_a);
185
+ }
186
+ return;
187
+ }
188
+ this._saveFailures += 1;
189
+ this._lastFailureReason = reason;
190
+ if (this._saveFailures >= SAVE_FAILURES_BEFORE_BROKEN && this._brokenSince === undefined) {
191
+ this._brokenSince = Date.now();
192
+ }
193
+ }
194
+ /**
195
+ * Surface a broken episode if it is now safe to advise a reload.
196
+ *
197
+ * Gated on `resumable` and NOTHING else. An earlier draft added a 90 s backstop so a flow that
198
+ * errored and stuck mid-state could not hold the warning forever — that was wrong, and the
199
+ * review caught it: the modal's only action is a reload, so firing it while the agent is
200
+ * non-resumable forces the user to destroy exactly the out-of-band, in-flight work the gate
201
+ * exists to protect. Being told late is bad; being told to do the damaging thing is worse.
202
+ * Surfacing a stuck flow earlier needs non-blocking warning UI, not this modal.
203
+ */
204
+ maybeReportBroken(resumable, liveMessages) {
205
+ var _a, _b;
206
+ if (this._brokenSince === undefined || this._brokenReported || !resumable)
207
+ return;
208
+ this._brokenReported = true;
209
+ (_b = (_a = this.deps).onPersistenceBroken) === null || _b === void 0 ? void 0 : _b.call(_a, {
210
+ unsavedMessages: Math.max(0, liveMessages - this._persistedMessages),
211
+ lastSavedAt: this._lastSavedAt,
212
+ reason: this._lastFailureReason,
213
+ });
214
+ }
215
+ /**
216
+ * Non-resumable-workflow gate (GENC-1351 §5.9): consult the active agent (the flow-owner
217
+ * during a locked flow, like `serialize`). A thrown predicate fails safe to persisting; no
218
+ * live active agent (e.g. the post-restore, pre-reactivation save) leaves it resumable and
219
+ * persists normally.
220
+ *
221
+ * Extracted from `persistSession` so that method stays under the complexity cap, and because
222
+ * this answer now has two consumers: the freeze decision, and whether it is safe to advise the
223
+ * user to reload (GENC-1511).
224
+ */
225
+ resolveResumable(liveActive, key, agentSnapshots, messages, s) {
226
+ return __awaiter(this, void 0, void 0, function* () {
227
+ if (!(liveActive === null || liveActive === void 0 ? void 0 : liveActive.resumable))
228
+ return true;
229
+ try {
230
+ return yield liveActive.resumable({
231
+ agentName: liveActive.name,
232
+ sessionKey: key,
233
+ state: agentSnapshots[liveActive.name],
234
+ messages,
235
+ isFlowOwner: s.flowOwnerAgentName === liveActive.name,
236
+ });
237
+ }
238
+ catch (e) {
239
+ logger.error('Session save: resumable() threw — persisting (fail-safe):', e);
240
+ return true;
241
+ }
242
+ });
243
+ }
133
244
  /**
134
245
  * Capture the current session to durable storage (GENC-1351 §5.1). Reads the
135
246
  * full transcript from the driver and the restorable scalars from the store, and
@@ -137,7 +248,7 @@ export class SessionPersister {
137
248
  */
138
249
  persistSession() {
139
250
  return __awaiter(this, void 0, void 0, function* () {
140
- var _a, _b, _c, _d, _e, _f, _g;
251
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
141
252
  const { provider, enabled } = this.deps.getPersistence();
142
253
  if (!enabled || !provider)
143
254
  return;
@@ -175,53 +286,44 @@ export class SessionPersister {
175
286
  // image forward (GENC-1351 §6). Pure + unit-tested.
176
287
  const { snapshots: agentSnapshots, cache } = reconcileAgentSnapshots(liveSnapshots, s.flowOwnerAgentName, this._cache);
177
288
  this._cache = cache;
178
- // Non-resumable-workflow gate (GENC-1351 §5.9): consult the active agent (the
179
- // flow-owner during a locked flow, like `serialize` above). A thrown predicate
180
- // fails safe to persisting; no live active agent (e.g. the post-restore,
181
- // pre-reactivation save) leaves it resumable and persists normally.
182
- let resumable = true;
183
- if (liveActive === null || liveActive === void 0 ? void 0 : liveActive.resumable) {
184
- try {
185
- resumable = yield liveActive.resumable({
186
- agentName: liveActive.name,
187
- sessionKey: key,
188
- state: agentSnapshots[liveActive.name],
189
- messages,
190
- isFlowOwner: s.flowOwnerAgentName === liveActive.name,
191
- });
192
- }
193
- catch (e) {
194
- logger.error('Session save: resumable() threw — persisting (fail-safe):', e);
195
- resumable = true;
196
- }
197
- }
289
+ const resumable = yield this.resolveResumable(liveActive, key, agentSnapshots, messages, s);
290
+ // Report a broken episode at the first SAFE moment, which is what `resumable` decides.
291
+ // Deliberately before either branch acts, so the warning does not wait on the very write
292
+ // that is failing.
293
+ this.maybeReportBroken(resumable, messages.length);
198
294
  if (!resumable) {
199
- // FREEZE while the flow runs: stop advancing the stored snapshot so the
200
- // in-flight agent's work is never persisted and the *previous* conversation is
201
- // kept intact ("infinite debounce while running"). The freeze target is the
202
- // **currently-persisted** snapshot the shared source of truth NOT any
203
- // per-view memory. Stamp it once with an `interrupted` marker so restore shows
204
- // an assistant "start again" message; once stamped, subsequent saves no-op.
205
- const existing = yield provider.load(key).catch(() => null);
206
- if (existing === null || existing === void 0 ? void 0 : existing.interrupted)
207
- return; // already frozen + stamped by some instance
295
+ // FREEZE THE FLOW STATE, NOT THE TRANSCRIPT (GENC-1511).
296
+ //
297
+ // The intent of §5.9 is the one stated on `PersistedSession.interrupted`: the
298
+ // conversation is persisted and restored in full, and only the running agent's
299
+ // flow-state is dropped. The original implementation did the opposite whenever a
300
+ // snapshot already existed it froze at the **stored** blob (`existing ?? …`),
301
+ // so everything since the last resumable save was silently rolled back, transcript
302
+ // included, and `if (existing?.interrupted) return` then latched that rollback in
303
+ // for the life of the page. A UI-builder session spent 3h17m in that state: 256
304
+ // loads, zero writes, and a reload that took the user back to a mid-bootstrap
305
+ // snapshot from three hours earlier.
306
+ //
307
+ // So the freeze target is now ALWAYS the live transcript through the last user
308
+ // message — the conversation plus the prompt that started the flow, with the
309
+ // agent's in-flight turns and flow-state dropped. Advancing on each new boundary
310
+ // means a long-running flow can no longer cost the user the work that preceded it.
208
311
  const { resumeMessage, resumePrompt } = resolveNonResumableNotice({
209
312
  notResumableMessage: liveActive === null || liveActive === void 0 ? void 0 : liveActive.notResumableMessage,
210
313
  flowActivationPrompt: s.flowActivationPrompt,
211
314
  messages,
212
315
  defaultMessage: DEFAULT_NOT_RESUMABLE_MESSAGE,
213
316
  });
214
- // Freeze at the persisted snapshot (the last resumable state). Fallback when
215
- // nothing is stored yeta genuinely fresh session that goes non-resumable on
216
- // its first turn, OR a load that failed (a corrupt read, a transient network
217
- // error): freeze at the current transcript **through the last user message**
218
- // (flow-state stripped), so we keep the conversation + the prompt that started
219
- // the flow but drop the agent's in-flight turns. NEVER an empty snapshot —
220
- // losing the conversation is the one outcome we must avoid.
221
- const frozen = existing !== null && existing !== void 0 ? existing : createPersistedSession({
317
+ // Never regress the stored transcript. `keepThroughLastUserMessage` alone can be
318
+ // SHORTER than what is already persistedan agent that goes non-resumable without
319
+ // a fresh user prompt (auto-advance, a resumed journey step) would otherwise drop
320
+ // completed exchanges that were already safely stored. Taking the max of the two
321
+ // makes the boundary monotonic: it advances with each new prompt and never rewinds.
322
+ const boundary = Math.max(keepThroughLastUserMessage(messages).length, this._persistedMessages);
323
+ const frozen = createPersistedSession({
222
324
  sessionKey: key,
223
325
  host: window.location.host,
224
- messages: keepThroughLastUserMessage(messages),
326
+ messages: messages.slice(0, Math.min(boundary, messages.length)),
225
327
  activeAgentName: (_d = s.activeAgent) === null || _d === void 0 ? void 0 : _d.name,
226
328
  pinnedAgentName: s.pinnedAgentName,
227
329
  flowOwnerAgentName: null,
@@ -232,6 +334,16 @@ export class SessionPersister {
232
334
  activeProviderName: s.activeProviderName,
233
335
  agentSnapshots: {},
234
336
  });
337
+ // Skip the write when the frozen boundary has not moved — a flow autosaves on every
338
+ // transcript change, and while it runs the last user message (and therefore the
339
+ // frozen content) is unchanged, so re-writing it would put one ~MB PUT on the wire
340
+ // per tool call for nothing. Keyed on CONTENT, unlike the old `interrupted` latch:
341
+ // it re-arms the moment the boundary moves, and the resumable path below clears it,
342
+ // so it can never become the one-way "never write again" state it replaces. It also
343
+ // drops the `provider.load` this branch used to issue on every single autosave.
344
+ if (this._persistedMessages === frozen.messages.length && this._interruptedStamped) {
345
+ return;
346
+ }
235
347
  this.logMeta('persistence.suppressed', {
236
348
  agent: (_e = liveActive === null || liveActive === void 0 ? void 0 : liveActive.name) !== null && _e !== void 0 ? _e : null,
237
349
  isFlowOwner: s.flowOwnerAgentName === (liveActive === null || liveActive === void 0 ? void 0 : liveActive.name),
@@ -240,9 +352,35 @@ export class SessionPersister {
240
352
  });
241
353
  try {
242
354
  yield provider.save(key, Object.assign(Object.assign({}, frozen), { interrupted: { message: resumeMessage, prompt: resumePrompt } }));
355
+ this._persistedMessages = frozen.messages.length;
356
+ this._interruptedStamped = true;
357
+ this.recordSaveOutcome(true, '', frozen.savedAt);
243
358
  logger.debug('Flow non-resumable — froze snapshot + stamped interrupted marker', { key });
244
359
  }
245
360
  catch (e) {
361
+ // GENC-1511: this catch used to be the ONE swallowed failure left after the save-failed
362
+ // event was added to the resumable branch — so a session that broke while an agent was
363
+ // mid-flow still reported nothing at all, which is precisely the blind spot the
364
+ // user-facing warning exists to cover.
365
+ this.recordSaveOutcome(false, (_f = e === null || e === void 0 ? void 0 : e.message) !== null && _f !== void 0 ? _f : String(e));
366
+ // Deliberately NOT reporting from here. This is the non-resumable branch and a reload is
367
+ // the modal's only action, so advising one now would destroy the in-flight work the
368
+ // snapshot could not carry. `recordSaveOutcome` has already set `_brokenSince`, so the
369
+ // next `persistSession` whose `resolveResumable()` returns true reports it — late, but at
370
+ // a point where the advice is safe to take.
371
+ //
372
+ // (An earlier version called `maybeReportBroken(false, …)` here. Review caught that it was
373
+ // an unconditional no-op — that method returns immediately on `!resumable` — and that its
374
+ // comment pointed at a 90 s backstop which had itself been removed earlier in review.)
375
+ this.logMeta('persistence.save-failed', {
376
+ messages: frozen.messages.length,
377
+ agents: [],
378
+ active: (_g = liveActive === null || liveActive === void 0 ? void 0 : liveActive.name) !== null && _g !== void 0 ? _g : null,
379
+ hasSerialize: !!(liveActive === null || liveActive === void 0 ? void 0 : liveActive.serialize),
380
+ frozen: true,
381
+ error: (_h = e === null || e === void 0 ? void 0 : e.name) !== null && _h !== void 0 ? _h : typeof e,
382
+ message: (_j = e === null || e === void 0 ? void 0 : e.message) !== null && _j !== void 0 ? _j : String(e),
383
+ });
246
384
  logger.error('Session save (interrupted marker) failed:', e);
247
385
  }
248
386
  return;
@@ -253,7 +391,7 @@ export class SessionPersister {
253
391
  sessionKey: key,
254
392
  host: window.location.host,
255
393
  messages: [...messages],
256
- activeAgentName: (_f = s.activeAgent) === null || _f === void 0 ? void 0 : _f.name,
394
+ activeAgentName: (_k = s.activeAgent) === null || _k === void 0 ? void 0 : _k.name,
257
395
  pinnedAgentName: s.pinnedAgentName,
258
396
  flowOwnerAgentName: s.flowOwnerAgentName,
259
397
  sessionCostUsd: s.sessionUsage.costUsd,
@@ -266,18 +404,56 @@ export class SessionPersister {
266
404
  // Observability (GENC-1351): record what each autosave captured for stateful
267
405
  // agents — `agents: []` here means no machine snapshot was taken (the bug to
268
406
  // watch for: the active agent's `serialize()` returned nothing).
269
- this.logMeta('persistence.saved', {
407
+ //
408
+ // GENC-1511: this event is emitted AFTER the write resolves, never before. It used
409
+ // to be logged ahead of `provider.save`, which made it a record of an ATTEMPT while
410
+ // reading as a record of a SAVE — and the failure below was swallowed into
411
+ // `logger.error`, which is not wired to Sentry. A session whose writes had been
412
+ // failing for three hours therefore showed 52 cheerful `persistence.saved` events
413
+ // and no failures anywhere: not in the debug log, not in Loki, not in Sentry. The
414
+ // whole incident was invisible until the box's nginx access log was read by hand.
415
+ const captured = {
270
416
  messages: messages.length,
271
417
  agents: Object.keys(agentSnapshots),
272
- active: (_g = liveActive === null || liveActive === void 0 ? void 0 : liveActive.name) !== null && _g !== void 0 ? _g : null,
418
+ active: (_l = liveActive === null || liveActive === void 0 ? void 0 : liveActive.name) !== null && _l !== void 0 ? _l : null,
273
419
  hasSerialize: !!(liveActive === null || liveActive === void 0 ? void 0 : liveActive.serialize),
274
420
  agentSnapshots,
275
- });
421
+ };
276
422
  try {
277
423
  yield provider.save(key, snapshot);
424
+ this._persistedMessages = messages.length;
425
+ this._interruptedStamped = false;
426
+ this.recordSaveOutcome(true, '', snapshot.savedAt);
427
+ this.logMeta('persistence.saved', captured);
278
428
  logger.debug('Session saved', { key, messages: messages.length });
279
429
  }
280
430
  catch (e) {
431
+ // `high` importance, and deliberately verbose: this is the one event that says
432
+ // "the transcript on screen is no longer backed by the store". `name` separates a
433
+ // provider/transport rejection from a serialisation fault — a `TypeError` here
434
+ // means the body never reached the wire at all, because a provider builds its
435
+ // request body inside the `fetch` init, so a throw while serialising creates no
436
+ // request and leaves no server-side trace.
437
+ this.recordSaveOutcome(false, (_m = e === null || e === void 0 ? void 0 : e.message) !== null && _m !== void 0 ? _m : String(e));
438
+ // Report straight away rather than waiting for the next attempt: we are on the resumable
439
+ // path, so a reload is safe NOW, and a one-cycle delay is a second of extra exposure for
440
+ // no benefit.
441
+ this.maybeReportBroken(true, messages.length);
442
+ // Deliberately NOT `...captured`: that carries the raw `agentSnapshots` object, and
443
+ // `recordMetaEvent` stores the detail BY REFERENCE. When the save failed *because* that
444
+ // object is circular — the fault this catch exists for — the high-importance failure event
445
+ // became circular too, so `assembleDebugLog` threw while stringifying it and
446
+ // `downloadDebugLog` could not produce the log this modal tells the user to save. Names
447
+ // and scalars only; the success event above may keep the snapshots, because a landed write
448
+ // has already proved them serialisable.
449
+ this.logMeta('persistence.save-failed', {
450
+ messages: captured.messages,
451
+ agents: captured.agents,
452
+ active: captured.active,
453
+ hasSerialize: captured.hasSerialize,
454
+ error: (_o = e === null || e === void 0 ? void 0 : e.name) !== null && _o !== void 0 ? _o : typeof e,
455
+ message: (_p = e === null || e === void 0 ? void 0 : e.message) !== null && _p !== void 0 ? _p : String(e),
456
+ });
281
457
  logger.error('Session save failed:', e);
282
458
  }
283
459
  });
@@ -346,6 +522,9 @@ export class SessionPersister {
346
522
  // by the history change below, before any agent re-activates) preserves the
347
523
  // machine snapshot instead of overwriting it with an empty one (GENC-1351).
348
524
  this._cache = Object.assign({}, snapshot.agentSnapshots);
525
+ // Seed the monotonic freeze floor too (GENC-1511): a flow that goes non-resumable
526
+ // straight after a restore must not freeze BELOW what the restore just brought back.
527
+ this._persistedMessages = trimmed.length;
349
528
  // Observability (GENC-1351): record exactly what the restore primed, so a
350
529
  // post-reload debug-log export shows whether a stateful agent's snapshot
351
530
  // actually came back (meta events reset on reload, so this is the only window
@@ -478,6 +657,8 @@ export class SessionPersister {
478
657
  clearRestoreCore() {
479
658
  return __awaiter(this, void 0, void 0, function* () {
480
659
  this._cache = {};
660
+ this._persistedMessages = 0;
661
+ this._interruptedStamped = false;
481
662
  const { provider } = this.deps.getPersistence();
482
663
  const key = this.stateKey;
483
664
  if (!provider)