@jini-ai/chat 0.6.17 → 0.6.21

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 (75) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/core/accepted-input.d.ts +10 -2
  3. package/dist/core/accepted-input.js +87 -33
  4. package/dist/core/assistant-client/durable-run-subscription.js +6 -0
  5. package/dist/core/assistant-client/entry.d.ts +1 -1
  6. package/dist/core/assistant-client/entry.js +1 -1
  7. package/dist/core/assistant-client/messages.d.ts +21 -0
  8. package/dist/core/assistant-client/messages.js +31 -0
  9. package/dist/core/checkpoint-interval.d.ts +25 -0
  10. package/dist/core/checkpoint-interval.js +38 -0
  11. package/dist/core/durable-runs/ledger-contracts.d.ts +27 -1
  12. package/dist/core/durable-runs/ports.d.ts +25 -1
  13. package/dist/core/events.d.ts +6 -0
  14. package/dist/core/index.d.ts +1 -0
  15. package/dist/core/index.js +1 -0
  16. package/dist/core/run-events/entry.js +15 -2
  17. package/dist/core/surface-recovery.d.ts +19 -0
  18. package/dist/core/surface-recovery.js +26 -0
  19. package/dist/core/tool-events.d.ts +11 -3
  20. package/dist/core/tool-events.js +75 -43
  21. package/dist/core/turn-usage.d.ts +1 -1
  22. package/dist/core/turn-usage.js +5 -3
  23. package/dist/embed-widget/browser/frontend-session-bridge.d.ts +14 -1
  24. package/dist/embed-widget/browser/frontend-session-bridge.js +120 -47
  25. package/dist/react/components/Composer.d.ts +3 -3
  26. package/dist/react/components/Composer.js +5 -4
  27. package/dist/react/components/ConversationList.js +2 -1
  28. package/dist/react/components/ConversationTitleEditor.js +2 -1
  29. package/dist/react/components/ExtEventErrorBoundary.js +2 -1
  30. package/dist/react/components/McpUiSurfaceCard.js +12 -5
  31. package/dist/react/components/MessageList.js +2 -1
  32. package/dist/react/components/MidRunUserMessage.js +1 -1
  33. package/dist/react/components/ToolCard.js +3 -2
  34. package/dist/react/components/ToolCard.rules.js +2 -1
  35. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -3
  36. package/dist/react/features/chat-pane/components/ChatPane.js +1 -1
  37. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.d.ts +15 -2
  38. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +19 -3
  39. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +5 -4
  40. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +17 -9
  41. package/dist/react/features/chat-pane/hooks/useChatPaneFileDrop.hooks.js +3 -2
  42. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +20 -3
  43. package/dist/react/features/chat-pane/model-options.d.ts +10 -0
  44. package/dist/react/features/chat-pane/model-options.js +22 -3
  45. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  46. package/dist/react/features/chat-pane/styles.js +3 -1
  47. package/dist/react/features/chat-pane/surface-recovery-stream.d.ts +12 -0
  48. package/dist/react/features/chat-pane/surface-recovery-stream.js +47 -0
  49. package/dist/react/hooks/composer-draft-cache.d.ts +6 -6
  50. package/dist/react/hooks/composer-draft-cache.js +18 -17
  51. package/dist/react/hooks/useComposer.js +52 -4
  52. package/dist/react/hooks/useConversation.js +41 -22
  53. package/dist/react/hooks/useRunStream.js +2 -1
  54. package/dist/react/hooks/useSurfaceRecovery.d.ts +23 -0
  55. package/dist/react/hooks/useSurfaceRecovery.js +84 -0
  56. package/dist/react/styles/reference.css +4 -5
  57. package/dist/server/durable-runs/recover.js +3 -3
  58. package/dist/store/ports.d.ts +17 -3
  59. package/dist/store/runtime/chat-expiry-sweep.js +3 -1
  60. package/dist/store/runtime/chat-history-store.js +6 -0
  61. package/dist/store/runtime/checkpoint-delta.d.ts +35 -0
  62. package/dist/store/runtime/checkpoint-delta.js +75 -0
  63. package/dist/store/runtime/durable-run-store.js +92 -25
  64. package/dist/store/runtime/events-json.d.ts +17 -0
  65. package/dist/store/runtime/events-json.js +19 -0
  66. package/dist/store/runtime/run-guarded-chat-store.js +8 -3
  67. package/dist/store/runtime/run-ledger.d.ts +2 -2
  68. package/dist/store/runtime/run-ledger.js +120 -45
  69. package/dist/store/sql/read-version.d.ts +15 -0
  70. package/dist/store/sql/read-version.js +21 -0
  71. package/dist/store/sql/store.d.ts +9 -0
  72. package/dist/store/sql/store.js +89 -4
  73. package/dist/store/sqlite/schema.d.ts +2 -2
  74. package/dist/store/sqlite/schema.js +4 -0
  75. package/package.json +12 -12
@@ -0,0 +1,75 @@
1
+ import { dedupeToolUsesById } from "../../core/tool-events.js";
2
+ /** Compare unchanged prefix values; compacting text creates fresh objects, while large tool
3
+ * payloads normally retain their reference. Never parse the saved SQL column here. */
4
+ function sameEvent(left, right) {
5
+ return left === right || JSON.stringify(left) === JSON.stringify(right);
6
+ }
7
+ /** A delta may append events or replace the final coalesced text event. Every earlier event
8
+ * must match; replay reordering, thinking replacement and tool identity deduplication fall back.
9
+ * @complexity O(prefix bytes + new suffix bytes) time; O(new suffix bytes) temporary space. */
10
+ export function checkpointDelta({ previous, progress }, _options = {}) {
11
+ if (progress.content.length < previous.content.length)
12
+ return undefined;
13
+ const incoming = progress.events;
14
+ const saved = previous.events;
15
+ if (incoming.length < saved.length)
16
+ return undefined;
17
+ if (dedupeToolUsesById({ events: incoming }).length !== incoming.length)
18
+ return undefined;
19
+ const prefix = saved.slice(0, -1);
20
+ if (!prefix.every((event, index) => sameEvent(event, incoming[index])))
21
+ return undefined;
22
+ const last = saved.at(-1);
23
+ const next = incoming[saved.length - 1];
24
+ const replacing = last !== undefined && !sameEvent(last, next);
25
+ if (replacing && !extendsText({ last, next }))
26
+ return undefined;
27
+ return { ...deltaSuffix({ previous, incoming, replacing }),
28
+ grown: progress.content.length > previous.content.length || hasNewToolProgress({ saved, incoming }) };
29
+ }
30
+ /** SQL counts characters in the removed tail, so non-BMP text never uses JS UTF-16 offsets. */
31
+ function deltaSuffix({ previous, incoming, replacing }, _options = {}) {
32
+ const from = previous.events.length - (replacing ? 1 : 0);
33
+ const suffixEvents = incoming.slice(from);
34
+ const separator = !replacing && from > 0 && suffixEvents.length > 0 ? ',' : '';
35
+ return { removeCharacters: replacing ? previous.tailLength : 1,
36
+ suffix: separator + JSON.stringify(suffixEvents).slice(1) };
37
+ }
38
+ /** Extending text must retain every other field (including any future event metadata). */
39
+ function extendsText({ last, next }, _options = {}) {
40
+ if (last.kind !== 'text' || next?.kind !== 'text')
41
+ return false;
42
+ return next.text.startsWith(last.text) && sameEvent({ ...last, text: next.text }, next);
43
+ }
44
+ /** Same tool identity rule as the full checkpoint's progress check. O(saved + incoming events). */
45
+ export function hasNewToolProgress({ saved, incoming }, _options = {}) {
46
+ const keys = new Set(saved.map(event => toolKey({ event })));
47
+ return incoming.some(event => { const key = toolKey({ event }); return key !== '' && !keys.has(key); });
48
+ }
49
+ /** Durable tool identity shared by the full merge and delta progress check. */
50
+ export function toolKey({ event }, _options = {}) {
51
+ if (event.kind === 'tool_use')
52
+ return 'use:' + event.id;
53
+ if (event.kind === 'tool_result')
54
+ return 'result:' + event.toolUseId;
55
+ return '';
56
+ }
57
+ /** Build a receipt only for an exact incoming projection; callers own its watch lifetime.
58
+ * SQL supplies content/event lengths; the removed tail counts Unicode code points. Retain only
59
+ * a bounded suffix for CAS, so a multi-MB tool result never becomes a guard parameter twice.
60
+ * @complexity O(last event bytes) serialization/counting; O(last event bytes) transient space,
61
+ * with at most 256 UTF-16 units retained beyond the existing projection references.
62
+ */
63
+ export function checkpointState({ progress, length, contentLength, version }, _options = {}) {
64
+ const last = progress.events.at(-1);
65
+ const fullTail = last === undefined ? '[]' : JSON.stringify(last) + ']';
66
+ // Count SQL characters without allocating an array of code points for a multi-MB tool result.
67
+ let tailLength = 0;
68
+ for (const _character of fullTail)
69
+ tailLength++;
70
+ let tail = fullTail.slice(-256);
71
+ if (/^[\uDC00-\uDFFF]/u.test(tail))
72
+ tail = tail.slice(1);
73
+ return { content: progress.content, events: progress.events, version, length, contentLength, tailLength, tail };
74
+ }
75
+ //# sourceMappingURL=checkpoint-delta.js.map
@@ -1,3 +1,4 @@
1
+ import { sqliteReadVersion } from '../sql/read-version.js';
1
2
  import { acceptedInputEvent, acceptedInputUserEvent } from "../../core/accepted-input.js";
2
3
  import { redactUserMessage } from "../../core/user-text-redaction.js";
3
4
  import { recoveredRunEvents } from "../../core/index.js";
@@ -8,7 +9,8 @@ import { RECOVERY_WINDOW_MS } from "../../core/durable-runs/policies.js";
8
9
  import { createChatHistoryStore } from "./chat-history-store.js";
9
10
  import { runLockKey } from "../../core/durable-runs/run-lock.js";
10
11
  import { runContentFromEvents } from "../../core/run-events/entry.js";
11
- import { toMessage } from "../sql/store.js";
12
+ import { appendedEventsJson } from "./events-json.js";
13
+ import { toMessage, validatedEventsJson } from "../sql/store.js";
12
14
  export const RUN_SLOT_BUSY = "Another answer in this chat is still running. This turn is queued in your browser.";
13
15
  export class RunSlotBusyError extends Error {
14
16
  constructor() { super(RUN_SLOT_BUSY); }
@@ -150,15 +152,6 @@ function continuationEvents({ current, incoming }, _optional = {}) {
150
152
  }
151
153
  return runContentFromEvents({ events: incoming }).length >= current.message.content.length ? incoming : saved;
152
154
  }
153
- /** `events_json` with one more event, built in SQL: no dialect parses or re-serializes the saved
154
- * array in JS. Writers store `JSON.stringify` output (no whitespace), so a non-empty array is
155
- * `[...]` and gains `,event`; `[]`, NULL and anything not array-shaped (a truncated legacy value,
156
- * which the old JS read also treated as no events) become `[event]`. Plain `substr`/`length`/`||`
157
- * run unchanged on SQLite and PostgreSQL. */
158
- function appendedEventsJson({ event }, _optional = {}) {
159
- const item = sql `cast(${JSON.stringify(event)} as text)`;
160
- return sql `case when events_json like '[_%]' then substr(events_json, 1, length(events_json) - 1) || ',' || ${item} || ']' else '[' || ${item} || ']' end`;
161
- }
162
155
  function sameOwner({ run, required }, _optional = {}) {
163
156
  return run.principalId === required.principalId && run.conversationId === required.conversationId;
164
157
  }
@@ -176,10 +169,28 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
176
169
  .select(eventsColumn.as("events_json"))
177
170
  .where(match.column, "=", match.value).where("role", "=", "assistant").executeTakeFirst());
178
171
  }
179
- /** Every message of the run's conversation; only `load` and legacy rows without attempt metadata pay this. */
180
- async function conversationTranscript({ row }, _options = {}) {
172
+ /** Conversation projection: callers can omit assistant event payloads; legacy heads need only content. */
173
+ async function conversationTranscript({ row }, { includeEvents = true } = {}) {
181
174
  const history = createChatHistoryStore({ kernel, isDaemonRunId, scope: { scopeId: row.scope_id, ownerKind: row.owner_kind, ownerId: row.owner_id } }, {});
182
- return history.messages({ conversationId: row.conversation_id });
175
+ return history.messages({ conversationId: row.conversation_id }, { includeEvents });
176
+ }
177
+ /** Legacy accepted requests and recovery reconstruction consume prior text only. */
178
+ async function readTranscript({ row, metadata }, options = {}) {
179
+ if (!options.transcript && metadata)
180
+ return [];
181
+ const includeEvents = transcriptEventProjection(options);
182
+ return conversationTranscript({ row }, { includeEvents });
183
+ }
184
+ /** Accepted user receipts split queued inputs and hold folder references. Keep that small
185
+ * public metadata even when recovery omits prior assistant event payloads. */
186
+ function transcriptEventProjection(options) {
187
+ if (options.includeTranscriptEvents === false && options.transcript)
188
+ return 'user';
189
+ return options.includeTranscriptEvents ?? options.transcript === true;
190
+ }
191
+ function currentMessage({ row, transcript }, options = {}) {
192
+ return options.transcript && options.includeTranscriptEvents !== false
193
+ ? transcript.find(item => item.id === row.id) : toMessage(row);
183
194
  }
184
195
  /** One run's head, plus its transcript when that was read (`[]` otherwise). */
185
196
  async function readRun({ match }, options = {}) {
@@ -189,11 +200,12 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
189
200
  const messageId = row.id;
190
201
  const metadata = await kernel.run((db) => db.selectFrom("assistant_run_attempts").selectAll().where("message_id", "=", messageId).executeTakeFirst());
191
202
  // A legacy row's accepted request is derived from its transcript, so only it reads one here.
192
- const transcript = options.transcript || !metadata ? await conversationTranscript({ row }) : [];
193
- const message = options.transcript ? transcript.find((item) => item.id === messageId) : toMessage(row);
203
+ const transcript = await readTranscript({ row, metadata }, options);
204
+ const message = currentMessage({ row, transcript }, options);
194
205
  if (!message)
195
206
  return null; // Deletion may have cascaded between the two reads.
196
- return { head: runHead({ row, runId: row.run_id, metadata, message, transcript }, { isDaemonRunId }), transcript };
207
+ return { head: runHead({ row, runId: row.run_id, metadata, message, transcript }, { isDaemonRunId }),
208
+ transcript: transcript.map(item => item.id === messageId ? message : item) };
197
209
  }
198
210
  /** Locked read of one message; `transcript` decides whether it is a whole run or only its head. */
199
211
  async function lockedRead({ messageId, conversationId }, options = {}) {
@@ -204,15 +216,70 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
204
216
  return readRun({ match: { column: "ai_chat_messages.id", value: messageId } }, options);
205
217
  });
206
218
  }
207
- async function load({ messageId }, _options = {}) {
219
+ async function load({ messageId }, { includeTranscriptEvents = true } = {}) {
208
220
  const binding = await kernel.run((db) => db.selectFrom("ai_chat_messages").select("conversation_id")
209
221
  .where("id", "=", messageId).executeTakeFirst());
210
222
  if (!binding)
211
223
  return null;
212
- // The message comes from the transcript, so its row's events are not selected twice.
213
- const read = await lockedRead({ messageId, conversationId: binding.conversation_id }, { transcript: true });
224
+ // Full reads take the message from the transcript. Lean reads select its own events once,
225
+ // omitting prior assistant payloads while retaining accepted user receipts.
226
+ const read = await lockedRead({ messageId, conversationId: binding.conversation_id }, { transcript: true, includeTranscriptEvents, events: !includeTranscriptEvents });
214
227
  return read && { ...read.head, transcript: read.transcript };
215
228
  }
229
+ /** Single-message read; message identity survives continuation while run_id changes.
230
+ * Legacy request reconstruction needs only transcript content, never its old event columns. */
231
+ async function head({ messageId }, { events = true } = {}) {
232
+ const binding = await kernel.run((db) => db.selectFrom("ai_chat_messages").select("conversation_id")
233
+ .where("id", "=", messageId).executeTakeFirst());
234
+ if (!binding)
235
+ return null;
236
+ return (await lockedRead({ messageId, conversationId: binding.conversation_id }, { events }))?.head ?? null;
237
+ }
238
+ /** Read only one owned projection, without a run lock or accepted-request reconstruction.
239
+ * Native database versions follow checkpoint growth, replacements, attempts and settlement. SQL omits
240
+ * content/events on a hit; only changed rows cross into JS as raw JSON, never parsed events.
241
+ * @complexity One indexed row read; O(1) unchanged payload and JS space; changed payload is O(current message bytes).
242
+ */
243
+ async function poll({ messageId, principalId, workspaceId }, { cursor } = {}) {
244
+ // SQLite has no per-row version: combine this connection's writes with the external-commit
245
+ // generation. Unrelated writes may invalidate the cursor, but equal-length rewrites cannot
246
+ // hide. PostgreSQL supplies the row's own MVCC version. Neither reads the event column.
247
+ const version = kernel.dialect === 'sqlite'
248
+ ? sqliteReadVersion({ kernel })
249
+ : sql `cast(ai_chat_messages.xmin as text)`;
250
+ const changed = sql `${version} <> ${cursor ?? ''}`;
251
+ // Run writers persist compact JSON arrays on both engines. SQLite also tolerates malformed
252
+ // legacy rows through its native validator; PG14 has no safe try-cast (do not parse in JS).
253
+ const valid = kernel.dialect === 'sqlite' ? sql `json_valid(events_json)` : sql `1`;
254
+ const row = await kernel.run(db => db.selectFrom('ai_chat_messages')
255
+ .innerJoin('ai_chats', 'ai_chats.id', 'ai_chat_messages.conversation_id')
256
+ .select(['ai_chat_messages.id', 'conversation_id', 'role'])
257
+ .select([
258
+ sql `case when ${changed} then run_id else null end`.as('run_id'),
259
+ sql `case when ${changed} then run_status else null end`.as('run_status'),
260
+ sql `case when ${changed} then agent_id else null end`.as('agent_id'),
261
+ sql `case when ${changed} then agent_name else null end`.as('agent_name'),
262
+ sql `case when ${changed} then attachments_json else null end`.as('attachments_json'),
263
+ sql `case when ${changed} then ai_chat_messages.created_at else 0 end`.as('created_at'),
264
+ sql `case when ${changed} then started_at else null end`.as('started_at'),
265
+ sql `case when ${changed} then ended_at else null end`.as('ended_at')
266
+ ])
267
+ .select([version.as('cursor'),
268
+ sql `case when ${changed} then content else '' end`.as('content'),
269
+ sql `case when ${changed} then events_json else null end`.as('events_json'),
270
+ sql `case when ${changed} then ${valid} else null end`.as('events_json_valid')])
271
+ .where('ai_chat_messages.id', '=', messageId).where('role', '=', 'assistant')
272
+ .where('owner_kind', '=', 'user').where('owner_id', '=', principalId).where('scope_id', '=', workspaceId)
273
+ .executeTakeFirst());
274
+ if (!row)
275
+ return null;
276
+ if (row.cursor === cursor)
277
+ return { cursor: row.cursor };
278
+ const metadata = JSON.stringify(toMessage({ ...row, events_json: null }));
279
+ const events = validatedEventsJson({ row });
280
+ const messageJson = events === undefined ? metadata : metadata.slice(0, -1) + ',"events":' + events + '}';
281
+ return { cursor: row.cursor, conversationId: row.conversation_id, messageJson };
282
+ }
216
283
  /** Unlocked probe: the message and conversation an attempt id currently names. */
217
284
  async function probeRun({ runId }, _options = {}) {
218
285
  return kernel.run((db) => db.selectFrom("ai_chat_messages").select(["id", "conversation_id"])
@@ -267,7 +334,7 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
267
334
  const history = await lockOwnedConversation({ required });
268
335
  if (!history)
269
336
  return null;
270
- const existing = await load({ messageId: required.messageId }, {});
337
+ const existing = await load({ messageId: required.messageId }, { includeTranscriptEvents: false });
271
338
  if (existing)
272
339
  return sameOwner({ run: existing, required }) ? existing : null;
273
340
  await assertSlotFree({ conversationId: required.conversationId });
@@ -282,7 +349,7 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
282
349
  recovery_elapsed_ms: 0, attempt_started_at: required.now,
283
350
  session_id: null, session_confirmed: 0, child_pid: null, child_started_at: null, attempt_base_json: "[]",
284
351
  }).execute());
285
- return load({ messageId: required.messageId }, {});
352
+ return load({ messageId: required.messageId }, { includeTranscriptEvents: false });
286
353
  });
287
354
  }
288
355
  async function writeAttemptMetadata({ values, updates }) {
@@ -340,18 +407,18 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
340
407
  const history = await lockOwnedConversation({ required });
341
408
  if (!history)
342
409
  return null;
343
- const existing = await load({ messageId: required.messageId }, {});
410
+ const existing = await load({ messageId: required.messageId }, { includeTranscriptEvents: false });
344
411
  if (existing) {
345
412
  if (!sameOwner({ run: existing, required }))
346
413
  return null;
347
414
  await cancel({ runId: existing.runId, reason: "user-stop" }, {});
348
- return load({ messageId: existing.messageId }, {});
415
+ return load({ messageId: existing.messageId }, { includeTranscriptEvents: false });
349
416
  }
350
417
  // Stop may beat acceptance or its response. A terminal tombstone for the same logical
351
418
  // identity makes any later duplicate acceptance a no-op, without reserving the chat slot.
352
419
  await history.appendMessage({ conversationId: required.conversationId, message: { id: required.messageId, role: "assistant", content: "",
353
420
  events: [{ kind: "status", label: "Stopped." }], runId: required.runId, runStatus: "canceled", createdAt: required.now, endedAt: required.now } });
354
- return load({ messageId: required.messageId }, {});
421
+ return load({ messageId: required.messageId }, { includeTranscriptEvents: false });
355
422
  });
356
423
  }
357
424
  async function saveAgentSession({ run, sessionId }) {
@@ -430,6 +497,6 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
430
497
  await writeAttemptMetadata({ values: { ...row, recovery_deadline: deadline, recovery_elapsed_ms: elapsed }, updates: { recovery_deadline: deadline, recovery_elapsed_ms: elapsed } });
431
498
  });
432
499
  }
433
- return { load, find, accept, advance, cancel, cancelPending, captureSession, clearSession, guardTool, completeTool, recoveryClock };
500
+ return { load, head, poll, find, accept, advance, cancel, cancelPending, captureSession, clearSession, guardTool, completeTool, recoveryClock };
434
501
  }
435
502
  //# sourceMappingURL=durable-run-store.js.map
@@ -0,0 +1,17 @@
1
+ import type { AgentEvent } from "../../core/index.js";
2
+ /** Replace a compact array's suffix without transferring the stored prefix to JS.
3
+ * SQL length/substr count characters on SQLite and PostgreSQL, including non-BMP text.
4
+ * @complexity One SQL string splice; only the new suffix crosses the kernel boundary. */
5
+ export declare function spliceEventsJson({ removeCharacters, suffix }: {
6
+ removeCharacters: number;
7
+ suffix: string;
8
+ }, _options?: {}): import("kysely").RawBuilder<string>;
9
+ /** `events_json` with one more event, built in SQL: no dialect parses or re-serializes the saved
10
+ * array in JS. Writers store `JSON.stringify` output (no whitespace), so a non-empty array is
11
+ * `[...]` and gains `,event`; `[]`, NULL and anything not array-shaped (a truncated legacy value,
12
+ * which the old JS read also treated as no events) become `[event]`. Plain `substr`/`length`/`||`
13
+ * run unchanged on SQLite and PostgreSQL. */
14
+ export declare function appendedEventsJson({ event }: {
15
+ event: AgentEvent;
16
+ }, _optional?: {}): import("kysely").RawBuilder<string>;
17
+ //# sourceMappingURL=events-json.d.ts.map
@@ -0,0 +1,19 @@
1
+ import { sql } from "kysely";
2
+ /** Replace a compact array's suffix without transferring the stored prefix to JS.
3
+ * SQL length/substr count characters on SQLite and PostgreSQL, including non-BMP text.
4
+ * @complexity One SQL string splice; only the new suffix crosses the kernel boundary. */
5
+ export function spliceEventsJson({ removeCharacters, suffix }, _options = {}) {
6
+ return sql `substr(events_json, 1, length(events_json) - ${removeCharacters}) || cast(${suffix} as text)`;
7
+ }
8
+ /** `events_json` with one more event, built in SQL: no dialect parses or re-serializes the saved
9
+ * array in JS. Writers store `JSON.stringify` output (no whitespace), so a non-empty array is
10
+ * `[...]` and gains `,event`; `[]`, NULL and anything not array-shaped (a truncated legacy value,
11
+ * which the old JS read also treated as no events) become `[event]`. Plain `substr`/`length`/`||`
12
+ * run unchanged on SQLite and PostgreSQL. */
13
+ export function appendedEventsJson({ event }, _optional = {}) {
14
+ const item = JSON.stringify(event);
15
+ const suffix = spliceEventsJson({ removeCharacters: 1, suffix: ',' + item + ']' });
16
+ const empty = '[' + item + ']';
17
+ return sql `case when events_json like '[_%]' then ${suffix} else cast(${empty} as text) end`;
18
+ }
19
+ //# sourceMappingURL=events-json.js.map
@@ -1,5 +1,11 @@
1
1
  /** Bind the server-owned run write fence to an already owner-scoped transcript. */
2
2
  export function createRunGuardedChatStore({ store, ledger, isDaemonRunId }, _optional = {}) {
3
+ async function savedMessage({ conversationId, messageId }, _optional = {}) {
4
+ if (store.message)
5
+ return store.message({ conversationId, messageId }, {});
6
+ // Compatibility for hosts/fakes without the indexed read capability.
7
+ return (await store.messages({ conversationId })).find(message => message.id === messageId) ?? null;
8
+ }
3
9
  return {
4
10
  ...store,
5
11
  /*
@@ -24,7 +30,7 @@ export function createRunGuardedChatStore({ store, ledger, isDaemonRunId }, _opt
24
30
  // attempt id or a transport-only failure; returning the stored row fences both without
25
31
  // matching presentation text. BYOK/AG-UI remain request-bound.
26
32
  if (isDaemonRunId({ runId })) {
27
- const saved = (await store.messages({ conversationId })).find((m) => m.id === message.id);
33
+ const saved = await savedMessage({ conversationId, messageId: message.id });
28
34
  if (saved)
29
35
  return saved;
30
36
  if (message.runStatus !== "queued" && message.runStatus !== "running")
@@ -34,8 +40,7 @@ export function createRunGuardedChatStore({ store, ledger, isDaemonRunId }, _opt
34
40
  } }, {});
35
41
  if (outcome.written)
36
42
  return outcome.value;
37
- const saved = await store.messages({ conversationId });
38
- return saved.find((m) => m.id === message.id) ?? null;
43
+ return savedMessage({ conversationId, messageId: message.id });
39
44
  },
40
45
  };
41
46
  }
@@ -6,8 +6,8 @@ export { runLockKey } from "../../core/durable-runs/run-lock.js";
6
6
  /**
7
7
  * @param store the chat kernel, or the open `chat.db` handle `createChatStoreFactory` also wraps.
8
8
  * @param now Injectable epoch-millisecond clock for progress stamps and the default cancel time.
9
- * @complexity each method is one or two indexed statements, except `reconcileInterrupted`, which
10
- * is O(stuck rows).
9
+ * @complexity indexed guards; checkpoints cost O(delta bytes) plus a SQLite scalar receipt read,
10
+ * or O(saved + incoming bytes) on fallback. Reconcile costs O(stuck rows + their event bytes).
11
11
  */
12
12
  export declare function createChatRunLedger<DB extends ChatDatabase>({ kernel: hostKernel, isDaemonRunId }: {
13
13
  kernel: ChatKernel<DB>;
@@ -1,4 +1,8 @@
1
1
  import { dedupeToolUsesById } from "../../core/tool-events.js";
2
+ import { sql } from "kysely";
3
+ import { sqliteReadVersion } from "../sql/read-version.js";
4
+ import { spliceEventsJson } from "./events-json.js";
5
+ import { checkpointDelta, checkpointState, hasNewToolProgress, toolKey } from "./checkpoint-delta.js";
2
6
  import { chatKernelTables } from "../sql/boundary.js";
3
7
  import { createDurableRunStore } from "./durable-run-store.js";
4
8
  /**
@@ -48,18 +52,6 @@ function readEvents(eventsJson) {
48
52
  }
49
53
  return Array.isArray(events) ? events : [];
50
54
  }
51
- /** Tool identity shared by progress detection and checkpoint merging; "" for non-tool events. */
52
- function toolKey(event) {
53
- if (event.kind === "tool_use")
54
- return `use:${event.id}`;
55
- if (event.kind === "tool_result")
56
- return `result:${event.toolUseId}`;
57
- return "";
58
- }
59
- function hasNewToolProgress(current, next) {
60
- const saved = new Set(current.map(toolKey));
61
- return next.some((event) => { const id = toolKey(event); return id !== "" && !saved.has(id); });
62
- }
63
55
  /** Retain a longer checkpoint's text and merge non-text replay events under the shared ID rule.
64
56
  * @complexity O(b + n) time and space for b serialized bytes across n events. */
65
57
  function retainSavedEvents(saved, incoming) {
@@ -72,9 +64,11 @@ function retainSavedEvents(saved, incoming) {
72
64
  *
73
65
  * The saved `events_json` is parsed ONCE and reused by both the merge and the progress check:
74
66
  * a long turn's column is many MB, and a host checkpoints every second (memory cause 3).
75
- * Appending only new events instead of rewriting the column is NOT equivalent: storage coalesces
76
- * text deltas (the last text event grows in place) and the merge puts saved-only tool barriers
77
- * after the replay's events, so an existing element or the tail changes on most checkpoints.
67
+ * Blindly appending only new events is NOT equivalent: storage coalesces text deltas (the last
68
+ * text event grows in place) and the merge puts saved-only tool barriers after replay events.
69
+ * The watch-owned delta may replace that text tail only after an exact incoming write; saved-only
70
+ * barriers disable it. Native versions, SQL lengths and bounded suffix CAS detect concurrent
71
+ * appends and equal-length replacements; any miss uses this authoritative merge, never a ledger cache or freshness stamp.
78
72
  * @complexity O(b + n) time and space for b serialized bytes across n events; one parse, one stringify.
79
73
  */
80
74
  function planCheckpoint({ current, progress }, _optional = {}) {
@@ -86,27 +80,30 @@ function planCheckpoint({ current, progress }, _optional = {}) {
86
80
  const runStatus = current.run_status === "queued" ? "running" : current.run_status;
87
81
  return {
88
82
  eventsJson,
83
+ exact: events.length === progress.events.length && events.every((event, index) => event === progress.events[index]),
89
84
  runStatus,
90
- grown: progress.content.length > current.content.length || hasNewToolProgress(saved, events),
85
+ grown: progress.content.length > current.content.length || hasNewToolProgress({ saved, incoming: events }),
91
86
  unchanged: runStatus === current.run_status && progress.content === current.content && eventsJson === current.events_json,
92
87
  };
93
88
  }
94
89
  /** Preserve tool barriers absent from a replay, with one persisted use per tool ID.
95
90
  * @complexity O(n) time and space in the combined saved and incoming event count. */
96
91
  function keepToolCheckpoints(saved, incoming) {
97
- const seen = new Set(incoming.map(toolKey));
92
+ const seen = new Set(incoming.map(event => toolKey({ event })));
98
93
  // Deduplicate after merging: a legacy checkpoint or replay can repeat an ID with different
99
94
  // serialized input. The shared ID rule owns identity; JSON equality does not.
100
- return dedupeToolUsesById({ events: [...incoming, ...saved.filter((event) => { const id = toolKey(event); return id !== "" && !seen.has(id); })] });
95
+ return dedupeToolUsesById({ events: [...incoming, ...saved.filter((event) => { const id = toolKey({ event }); return id !== "" && !seen.has(id); })] });
101
96
  }
102
97
  /**
103
98
  * @param store the chat kernel, or the open `chat.db` handle `createChatStoreFactory` also wraps.
104
99
  * @param now Injectable epoch-millisecond clock for progress stamps and the default cancel time.
105
- * @complexity each method is one or two indexed statements, except `reconcileInterrupted`, which
106
- * is O(stuck rows).
100
+ * @complexity indexed guards; checkpoints cost O(delta bytes) plus a SQLite scalar receipt read,
101
+ * or O(saved + incoming bytes) on fallback. Reconcile costs O(stuck rows + their event bytes).
107
102
  */
108
103
  export function createChatRunLedger({ kernel: hostKernel, isDaemonRunId }, { now = () => Date.now() } = {}) {
109
104
  const kernel = chatKernelTables({ kernel: hostKernel }, {});
105
+ const version = kernel.dialect === 'sqlite' ? sqliteReadVersion({ kernel }) : sql `cast(ai_chat_messages.xmin as text)`;
106
+ const returnedVersion = kernel.dialect === 'sqlite' ? sql `null` : version;
110
107
  async function isSettled(run) {
111
108
  const row = await kernel.run((db) => db
112
109
  .selectFrom("ai_chat_messages")
@@ -117,8 +114,99 @@ export function createChatRunLedger({ kernel: hostKernel, isDaemonRunId }, { now
117
114
  .executeTakeFirst());
118
115
  return isTerminal(row?.run_status);
119
116
  }
117
+ /** Progress earns a fresh recovery budget. Replayed prefixes/dividers cannot keep a failing
118
+ * attempt alive forever. Kept separate so full and delta writes use exactly the same reset. */
119
+ async function recordProgress({ progress }, _options = {}) {
120
+ await kernel.run((db) => db.updateTable("assistant_run_attempts")
121
+ .set({ recovery_count: 0, recovery_deadline: null, recovery_elapsed_ms: 0, last_progress_at: now() })
122
+ .where("message_id", "=", progress.messageId).execute());
123
+ }
124
+ /** One guarded delta UPDATE, no saved-column read or parse. A miss is deliberately indistinguishable
125
+ * from an ineligible delta: both must consult the authoritative checkpoint path under this lock. */
126
+ async function writeDelta({ progress, optional }, _options = {}) {
127
+ const { previous, expected } = optional;
128
+ if (!previous || expected !== previous.length)
129
+ return false;
130
+ const delta = checkpointDelta({ previous, progress });
131
+ if (!delta)
132
+ return false;
133
+ const result = await kernel.run((db) => db.updateTable("ai_chat_messages")
134
+ .set({ content: progress.content, events_json: spliceEventsJson(delta),
135
+ run_status: sql `case when run_status = 'queued' then 'running' else run_status end` })
136
+ .where(activeRunPredicate({ run: progress }))
137
+ .where(sql `${version} = ${previous.version}`)
138
+ .where(sql `length(events_json) = ${expected}`)
139
+ // BYOK/AG-UI browser upserts can replace a queued/running projection at the same length.
140
+ // Durable tool appends grow the length; recovery replacements also change run_id.
141
+ .where(sql `substr(events_json, length(events_json) - length(cast(${previous.tail} as text)) + 1) = ${previous.tail}`)
142
+ .where(sql `length(content) = ${previous.contentLength}`)
143
+ .returning([sql `length(events_json)`.as("length"), sql `length(content)`.as("contentLength"), returnedVersion.as("version")])
144
+ .executeTakeFirst());
145
+ if (!result)
146
+ return false;
147
+ if (delta.grown)
148
+ await recordProgress({ progress });
149
+ await notifyPersisted({ progress, optional, length: Number(result.length), contentLength: Number(result.contentLength), nativeVersion: result.version, exact: true });
150
+ return true;
151
+ }
152
+ /** A saved-only barrier invalidates the receipt. SQLite samples its connection/external-commit
153
+ * generation after ALL writes, while still under the transaction lock. Never capture a later
154
+ * writer's version beside this projection. PG returns the updated row's own MVCC version.
155
+ * @complexity O(1) scalar SQLite read per requested receipt; no payload query or PG extra read.
156
+ */
157
+ async function notifyPersisted({ progress, optional, length, contentLength, nativeVersion, exact }, _options = {}) {
158
+ if (!optional.onPersisted)
159
+ return;
160
+ if (!exact) {
161
+ optional.onPersisted(undefined);
162
+ return;
163
+ }
164
+ const receiptVersion = kernel.dialect === 'sqlite'
165
+ ? (await kernel.query(sql `select ${version} as version`))[0].version : nativeVersion;
166
+ optional.onPersisted(checkpointState({ progress, length, contentLength, version: receiptVersion }));
167
+ }
168
+ /** Full merge fallback, including identical-checkpoint write elision. One parse/stringify only. */
169
+ async function writeFullCheckpoint({ progress, optional }, _options = {}) {
170
+ const current = await kernel.run((db) => db.selectFrom("ai_chat_messages")
171
+ .select(["content", "events_json", "run_status", sql `length(events_json)`.as("length"), sql `length(content)`.as("contentLength"), returnedVersion.as("version")])
172
+ .where(activeRunPredicate({ run: progress })).executeTakeFirst());
173
+ if (!current || progress.content.length < current.content.length)
174
+ return false;
175
+ const plan = planCheckpoint({ current, progress });
176
+ // Identical content/events are not progress; accepting without rewriting leaves both the
177
+ // multi-MB column and recovery budget exactly as the guarded UPDATE would have.
178
+ // Under this lock the row was just matched by the update's own predicate, so the update
179
+ // would match (true); returning true without writing preserves that contract.
180
+ if (plan.unchanged) {
181
+ await notifyPersisted({ progress, optional, length: Number(current.length), contentLength: Number(current.contentLength), nativeVersion: current.version, exact: plan.exact });
182
+ return true;
183
+ }
184
+ const result = await kernel.run((db) => db.updateTable("ai_chat_messages")
185
+ .set({ content: progress.content, events_json: plan.eventsJson, run_status: plan.runStatus })
186
+ .where(activeRunPredicate({ run: progress }))
187
+ .returning([sql `length(events_json)`.as("length"), sql `length(content)`.as("contentLength"), returnedVersion.as("version")]).executeTakeFirst());
188
+ if (!result)
189
+ return false;
190
+ if (plan.grown)
191
+ await recordProgress({ progress });
192
+ await notifyPersisted({ progress, optional, length: Number(result.length), contentLength: Number(result.contentLength), nativeVersion: result.version, exact: plan.exact });
193
+ return true;
194
+ }
120
195
  return {
121
196
  durable: createDurableRunStore({ kernel, isDaemonRunId }, {}),
197
+ /** One bounded existence read, across owners and engines. Stale active rows conservatively
198
+ * keep the host awake; recovery belongs to the serving boot, never an idle probe.
199
+ * @complexity O(log n) covering-index lookup for n messages; O(1) returned space, no payload reads.
200
+ */
201
+ async hasActiveRuns(_required = {}, _optional = {}) {
202
+ const row = await kernel.run(db => db.selectFrom("ai_chat_messages").select("id")
203
+ .where("role", "=", "assistant")
204
+ // Keep the fixed literal predicate identical to the partial index. Parameterized IN
205
+ // cannot establish SQLite's partial-index implication at plan time.
206
+ .where(sql `run_status in ('queued', 'running')`)
207
+ .limit(1).executeTakeFirst());
208
+ return row !== undefined;
209
+ },
122
210
  unlessSettled({ run, write }, _optional = {}) {
123
211
  return kernel.transaction(async () => {
124
212
  await kernel.lockKey(runLockKey(run));
@@ -161,34 +249,21 @@ export function createChatRunLedger({ kernel: hostKernel, isDaemonRunId }, { now
161
249
  return true;
162
250
  });
163
251
  },
164
- async checkpoint(progress, _optional = {}) {
165
- return kernel.transaction(async () => {
252
+ async checkpoint(progress, optional = {}) {
253
+ let persisted = false;
254
+ let receipt;
255
+ // Publish the watch receipt only after COMMIT. A rollback must not advance its CAS base.
256
+ const transactionOptions = optional.onPersisted
257
+ ? { ...optional, onPersisted: (value) => { persisted = true; receipt = value; } } : optional;
258
+ const written = await kernel.transaction(async () => {
166
259
  await kernel.lockKey(runLockKey(progress));
167
- const current = await kernel.run((db) => db.selectFrom("ai_chat_messages").select(["content", "events_json", "run_status"])
168
- .where(activeRunPredicate({ run: progress })).executeTakeFirst());
169
- if (!current || progress.content.length < current.content.length)
170
- return false;
171
- const { eventsJson, runStatus, grown, unchanged } = planCheckpoint({ current, progress });
172
- // An identical checkpoint would rewrite the same multi-MB column for nothing. Under this lock
173
- // the row was just matched by the update's own predicate, so the update would match (true),
174
- // and identical content and events are never progress (`grown` is false), so skipping it
175
- // leaves the row and the recovery budget exactly as the write would have.
176
- if (unchanged)
260
+ if (await writeDelta({ progress, optional: transactionOptions }))
177
261
  return true;
178
- const result = await kernel.run((db) => db
179
- .updateTable("ai_chat_messages")
180
- .set({ content: progress.content, events_json: eventsJson, run_status: runStatus })
181
- .where(activeRunPredicate({ run: progress }))
182
- .executeTakeFirst());
183
- if (Number(result.numUpdatedRows) > 0 && grown) {
184
- // Progress earns a fresh recovery budget. Replayed prefixes and the coordinator's
185
- // divider are not progress and cannot keep a repeatedly failing attempt alive forever.
186
- await kernel.run((db) => db.updateTable("assistant_run_attempts")
187
- .set({ recovery_count: 0, recovery_deadline: null, recovery_elapsed_ms: 0, last_progress_at: now() })
188
- .where("message_id", "=", progress.messageId).execute());
189
- }
190
- return Number(result.numUpdatedRows) > 0;
262
+ return writeFullCheckpoint({ progress, optional: transactionOptions });
191
263
  });
264
+ if (persisted)
265
+ optional.onPersisted?.(receipt);
266
+ return written;
192
267
  },
193
268
  async reconcileInterrupted(required = {}, optional = {}) {
194
269
  const stuck = await kernel.run((db) => db
@@ -0,0 +1,15 @@
1
+ import { type RawBuilder } from 'kysely';
2
+ /** SQLite invalidation without scanning message rows. Both same-connection writes and external
3
+ * commits invalidate; the kernel epoch prevents counters reused after reopen from hiding changes.
4
+ * @param required.kernel The existing borrowed connection identity; never opens or changes it.
5
+ * @param optional.mintEpoch Injectable epoch source, called once per live kernel.
6
+ * @returns A SQL scalar with bounded output, without event/content reads or writes.
7
+ * @complexity O(1) time and weak identity space per kernel.
8
+ * @example sqliteReadVersion({ kernel });
9
+ */
10
+ export declare function sqliteReadVersion({ kernel }: {
11
+ kernel: object;
12
+ }, { mintEpoch }?: {
13
+ mintEpoch?: () => string;
14
+ }): RawBuilder<string>;
15
+ //# sourceMappingURL=read-version.d.ts.map
@@ -0,0 +1,21 @@
1
+ import { sql } from 'kysely';
2
+ // Weak identity metadata only, never row data. Closing/replacing a kernel releases its epoch with GC.
3
+ const epochs = new WeakMap();
4
+ /** SQLite invalidation without scanning message rows. Both same-connection writes and external
5
+ * commits invalidate; the kernel epoch prevents counters reused after reopen from hiding changes.
6
+ * @param required.kernel The existing borrowed connection identity; never opens or changes it.
7
+ * @param optional.mintEpoch Injectable epoch source, called once per live kernel.
8
+ * @returns A SQL scalar with bounded output, without event/content reads or writes.
9
+ * @complexity O(1) time and weak identity space per kernel.
10
+ * @example sqliteReadVersion({ kernel });
11
+ */
12
+ export function sqliteReadVersion({ kernel }, { mintEpoch = () => globalThis.crypto.randomUUID() } = {}) {
13
+ let epoch = epochs.get(kernel);
14
+ if (epoch === undefined) {
15
+ epoch = mintEpoch();
16
+ epochs.set(kernel, epoch);
17
+ }
18
+ // Connection-wide counters invalidate every row on any chat.db write; concurrent chats can erase the cache savings.
19
+ return sql `${epoch} || ':' || cast(total_changes() as text) || ':' || cast((select data_version from pragma_data_version) as text)`;
20
+ }
21
+ //# sourceMappingURL=read-version.js.map