@jini-ai/chat 0.6.19 → 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 (42) hide show
  1. package/CHANGELOG.md +6 -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/index.d.ts +1 -0
  14. package/dist/core/index.js +1 -0
  15. package/dist/core/tool-events.d.ts +5 -2
  16. package/dist/core/tool-events.js +1 -16
  17. package/dist/react/components/Composer.d.ts +3 -3
  18. package/dist/react/components/Composer.js +3 -3
  19. package/dist/react/components/MessageList.js +2 -1
  20. package/dist/react/components/MidRunUserMessage.js +1 -1
  21. package/dist/react/features/chat-pane/components/ChatPane.js +1 -1
  22. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +5 -4
  23. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +3 -9
  24. package/dist/react/hooks/useConversation.js +13 -2
  25. package/dist/server/durable-runs/recover.js +3 -3
  26. package/dist/store/ports.d.ts +17 -3
  27. package/dist/store/runtime/chat-history-store.js +6 -0
  28. package/dist/store/runtime/checkpoint-delta.d.ts +35 -0
  29. package/dist/store/runtime/checkpoint-delta.js +75 -0
  30. package/dist/store/runtime/durable-run-store.js +92 -25
  31. package/dist/store/runtime/events-json.d.ts +17 -0
  32. package/dist/store/runtime/events-json.js +19 -0
  33. package/dist/store/runtime/run-guarded-chat-store.js +8 -3
  34. package/dist/store/runtime/run-ledger.d.ts +2 -2
  35. package/dist/store/runtime/run-ledger.js +120 -45
  36. package/dist/store/sql/read-version.d.ts +15 -0
  37. package/dist/store/sql/read-version.js +21 -0
  38. package/dist/store/sql/store.d.ts +9 -0
  39. package/dist/store/sql/store.js +89 -4
  40. package/dist/store/sqlite/schema.d.ts +2 -2
  41. package/dist/store/sqlite/schema.js +4 -0
  42. package/package.json +8 -8
@@ -37,6 +37,15 @@ function pendingUserMessage({ content, sendOptions, createMessageId }, options =
37
37
  ...(sendOptions.attachments ? { attachments: sendOptions.attachments } : {}),
38
38
  } }, options).message;
39
39
  }
40
+ /** A checkpoint's run id proves acceptance even if the original start request is still pending.
41
+ * @returns A copy with pending state cleared only for this run's preceding user row; no I/O.
42
+ * @complexity O(1) time/space, called in the existing transcript reconciliation pass.
43
+ */
44
+ function acceptPendingUserMessage({ message, reply, assistantId, runId }, _options = {}) {
45
+ if (!message.inputPending || reply?.id !== assistantId || runId === null)
46
+ return message;
47
+ return { ...message, inputPending: false };
48
+ }
40
49
  /** The queued assistant bubble a send opens; the run's events fill it in. */
41
50
  function pendingAssistantMessage({ id }, { agentId } = {}) {
42
51
  return {
@@ -164,9 +173,11 @@ export function useConversation(options) {
164
173
  const assistantId = activeAssistantIdRef.current;
165
174
  if (!assistantId)
166
175
  return;
167
- setMessagesState((prev) => prev.map((m) => {
176
+ setMessagesState((prev) => prev.map((m, index) => {
168
177
  if (m.id !== assistantId)
169
- return m;
178
+ return acceptPendingUserMessage({
179
+ message: m, reply: prev[index + 1], assistantId, runId: run.runId,
180
+ });
170
181
  // A plain if/else chain (not a nested ternary) — deliberately, so
171
182
  // each of the four mapped statuses is its own independently
172
183
  // trackable branch under coverage instrumentation. The fallback
@@ -165,7 +165,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
165
165
  const events = recoveredRunEvents({ saved: savedWork({ run }) }, { notice: missingResume ? 'silent' : 'visible' });
166
166
  if (!await ports.store.advance({ run, nextRunId, now: ports.now(), events }, {}))
167
167
  return "superseded";
168
- const next = await ports.store.load({ messageId: run.messageId }, {});
168
+ const next = await ports.store.load({ messageId: run.messageId }, { includeTranscriptEvents: false });
169
169
  // Cancel/deletion can win while the old attempt is being fenced. Re-read after CAS and do
170
170
  // not launch against a removed binding or a newer generation.
171
171
  if (!next || next.runId !== nextRunId)
@@ -234,7 +234,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
234
234
  }
235
235
  /** The saved row after an await, or the outcome that ends this decision. O(1) store reads. */
236
236
  async function reload(run) {
237
- const current = await ports.store.load({ messageId: run.messageId }, {});
237
+ const current = await ports.store.load({ messageId: run.messageId }, { includeTranscriptEvents: false });
238
238
  if (!current)
239
239
  return "gone";
240
240
  // A probe only speaks about the generation it inspected. Never apply its dead result to
@@ -264,7 +264,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
264
264
  }
265
265
  return {
266
266
  async recover({ messageId, trigger, expectedRunId }, options) {
267
- const run = await ports.store.load({ messageId }, {});
267
+ const run = await ports.store.load({ messageId }, { includeTranscriptEvents: false });
268
268
  if (!run)
269
269
  return "gone";
270
270
  if (run.message.runStatus !== "queued" && run.message.runStatus !== "running")
@@ -88,8 +88,22 @@ export interface ChatStore {
88
88
  delete(required: {
89
89
  readonly id: string;
90
90
  }, optional?: Record<string, never>): Promise<void>;
91
- /** Compatibility read: all messages in stored position order; unauthorized returns []. */
92
- messages(required: ChatMessagePageInput, optional?: Record<string, never>): Promise<ChatMessage[]>;
91
+ /** Compatibility read in stored order; unauthorized returns []. `false` omits event columns;
92
+ * `user` keeps input receipts/folders without reading prior assistant payloads. */
93
+ messages(required: ChatMessagePageInput, optional?: {
94
+ readonly includeEvents?: boolean | "user";
95
+ }): Promise<ChatMessage[]>;
96
+ /** Optional indexed compatibility read; unknown and other-owned rows both return null. */
97
+ message?(required: ChatMessagePageInput & {
98
+ readonly messageId: string;
99
+ }, optional?: Record<string, never>): Promise<ChatMessage | null>;
100
+ /** Cheap owner-scoped history validator; reads metadata, never message/event payloads. */
101
+ messagesVersion?(required: ChatMessagePageInput, optional?: Record<string, never>): Promise<string | null>;
102
+ /** JSON response chunks, preserving stored event JSON instead of serializing event object trees.
103
+ * The optional ordering policy sees message metadata and receipt-derived folders, without events. */
104
+ messagesJson?(required: ChatMessagePageInput, optional?: {
105
+ readonly orderMessages?: (messages: ChatMessage[]) => ChatMessage[];
106
+ }): Promise<AsyncIterable<string>>;
93
107
  /** Idempotent update by message ID within its original conversation; unauthorized: null. */
94
108
  appendMessage(required: {
95
109
  readonly conversationId: string;
@@ -100,7 +114,7 @@ export interface ChatStore {
100
114
  readonly search?: ChatSearchCapability;
101
115
  }
102
116
  /** Eight-method compatibility subset plus optional bounded history; old hosts/fakes still fit. */
103
- export type ChatHistoryStore = Pick<ChatStore, 'list' | 'get' | 'create' | 'rename' | 'touch' | 'delete' | 'messages' | 'appendMessage' | 'history'>;
117
+ export type ChatHistoryStore = Pick<ChatStore, 'list' | 'get' | 'create' | 'rename' | 'touch' | 'delete' | 'messages' | 'message' | 'messagesJson' | 'messagesVersion' | 'appendMessage' | 'history'>;
104
118
  /** Internal persistence projections. Model-facing tools redact and cap these before returning. */
105
119
  export interface ChatHistoryReader {
106
120
  list(required: Record<string, never>, optional?: {
@@ -50,6 +50,12 @@ export function createChatHistoryStore({ kernel: hostKernel, scope, isDaemonRunI
50
50
  async messages(required, optional = {}) {
51
51
  return repairLegacyTurnOrder({ messages: await store.messages(required, optional), isDaemonRunId }, {});
52
52
  },
53
+ // Raw history must apply the same repair before emitting chunks; ordering never needs events.
54
+ async messagesJson(required, optional = {}) {
55
+ return store.messagesJson(required, { ...optional,
56
+ orderMessages: messages => repairLegacyTurnOrder({ messages, isDaemonRunId }, {}),
57
+ });
58
+ },
53
59
  };
54
60
  }
55
61
  function storeFactory(transport) {
@@ -0,0 +1,35 @@
1
+ import type { AgentEvent } from "../../core/index.js";
2
+ import type { CheckpointState, RunProgress } from "../../core/durable-runs/ledger-contracts.js";
3
+ /** A delta may append events or replace the final coalesced text event. Every earlier event
4
+ * must match; replay reordering, thinking replacement and tool identity deduplication fall back.
5
+ * @complexity O(prefix bytes + new suffix bytes) time; O(new suffix bytes) temporary space. */
6
+ export declare function checkpointDelta({ previous, progress }: {
7
+ previous: CheckpointState;
8
+ progress: RunProgress;
9
+ }, _options?: {}): {
10
+ removeCharacters: number;
11
+ suffix: string;
12
+ grown: boolean;
13
+ } | undefined;
14
+ /** Same tool identity rule as the full checkpoint's progress check. O(saved + incoming events). */
15
+ export declare function hasNewToolProgress({ saved, incoming }: {
16
+ saved: readonly AgentEvent[];
17
+ incoming: readonly AgentEvent[];
18
+ }, _options?: {}): boolean;
19
+ /** Durable tool identity shared by the full merge and delta progress check. */
20
+ export declare function toolKey({ event }: {
21
+ event: AgentEvent;
22
+ }, _options?: {}): string;
23
+ /** Build a receipt only for an exact incoming projection; callers own its watch lifetime.
24
+ * SQL supplies content/event lengths; the removed tail counts Unicode code points. Retain only
25
+ * a bounded suffix for CAS, so a multi-MB tool result never becomes a guard parameter twice.
26
+ * @complexity O(last event bytes) serialization/counting; O(last event bytes) transient space,
27
+ * with at most 256 UTF-16 units retained beyond the existing projection references.
28
+ */
29
+ export declare function checkpointState({ progress, length, contentLength, version }: {
30
+ progress: RunProgress;
31
+ length: number;
32
+ contentLength: number;
33
+ version: string;
34
+ }, _options?: {}): CheckpointState;
35
+ //# sourceMappingURL=checkpoint-delta.d.ts.map
@@ -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>;