@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
@@ -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
@@ -20,6 +20,15 @@ interface MessageRow {
20
20
  }
21
21
  /** Project only existing ai_* fields: resumable and lastRunEventId belong to run state. */
22
22
  export declare function toMessage(row: MessageRow): ChatMessage;
23
+ type RawMessageRow = MessageRow & {
24
+ events_json_valid: number | null;
25
+ };
26
+ /** Keep malformed-column tolerance without reserializing event trees. SQLite validates in SQL;
27
+ * PostgreSQL 14 lacks a safe built-in JSON try-cast, so validate and discard one row's parsed value.
28
+ * @complexity O(row JSON size) time; PG14 temporarily allocates one event tree, SQLite none. */
29
+ export declare function validatedEventsJson({ row }: {
30
+ row: Pick<RawMessageRow, "events_json" | "events_json_valid">;
31
+ }, _optional?: {}): string | undefined;
23
32
  /**
24
33
  * Returns a {@link ChatHistoryStore} that can only ever see `scope`'s own conversations.
25
34
  *
@@ -1,3 +1,4 @@
1
+ import { sqliteReadVersion } from './read-version.js';
1
2
  import { withFolderReceipt, foldersFromReceipts } from '../../core/accepted-input.js';
2
3
  const MESSAGE_POSITION = 'm.position';
3
4
  const CONVERSATION_UPDATED_AT = 'c.updated_at';
@@ -92,6 +93,61 @@ function jsonFields(row) {
92
93
  ...(attachments === undefined ? {} : { attachments }),
93
94
  };
94
95
  }
96
+ /** Project metadata without retaining assistant event trees. User folder receipts still hydrate
97
+ * through the existing mapper; unusual assistant receipts with folders need the same projection. */
98
+ function rawMessageMetadata({ row }, _optional = {}) {
99
+ const hasFolderReceipt = row.events_json?.includes(`"name":"${MID_RUN_USER_MESSAGE_EVENT_NAME}"`) && row.events_json.includes('"folders"');
100
+ const needsReceipts = row.role === 'user' || hasFolderReceipt;
101
+ const message = toMessage({ ...row, events_json: needsReceipts ? row.events_json : null });
102
+ delete message.events;
103
+ return message;
104
+ }
105
+ /** Keep malformed-column tolerance without reserializing event trees. SQLite validates in SQL;
106
+ * PostgreSQL 14 lacks a safe built-in JSON try-cast, so validate and discard one row's parsed value.
107
+ * @complexity O(row JSON size) time; PG14 temporarily allocates one event tree, SQLite none. */
108
+ export function validatedEventsJson({ row }, _optional = {}) {
109
+ if (!row.events_json)
110
+ return undefined;
111
+ if (row.events_json_valid !== null)
112
+ return Number(row.events_json_valid) === 1 ? row.events_json : undefined;
113
+ try {
114
+ JSON.parse(row.events_json);
115
+ return row.events_json;
116
+ }
117
+ catch {
118
+ return undefined;
119
+ }
120
+ }
121
+ /** Emit metadata and opaque event JSON separately, so neither an aggregate response string nor
122
+ * all parsed assistant events coexist. The borrowed query still holds O(total stored JSON) strings.
123
+ * @complexity O(total JSON bytes) time/retained raw strings; PG validation peaks at one event tree. */
124
+ async function* messageJsonChunks({ rows }, { orderMessages = messages => messages } = {}) {
125
+ const byId = new Map(rows.map(row => [row.id, row]));
126
+ const messages = orderMessages(rows.map(row => rawMessageMetadata({ row })));
127
+ yield '{"messages":[';
128
+ for (let index = 0; index < messages.length; index++) {
129
+ const message = messages[index];
130
+ const eventsJson = validatedEventsJson({ row: byId.get(message.id) });
131
+ if (index > 0)
132
+ yield ',';
133
+ const metadataJson = JSON.stringify(message);
134
+ if (eventsJson === undefined) {
135
+ yield metadataJson;
136
+ continue;
137
+ }
138
+ yield metadataJson.slice(0, -1) + ',"events":';
139
+ yield eventsJson;
140
+ yield '}';
141
+ }
142
+ yield ']}';
143
+ }
144
+ /** Receipt-bearing user rows retain their public metadata while large assistant histories stay
145
+ * outside recovery/acceptance projections. Both dialects evaluate the same CASE expression. */
146
+ function messageEventsColumn({ includeEvents }, _options = {}) {
147
+ if (includeEvents === 'user')
148
+ return sql `case when m.role = 'user' then m.events_json else null end`;
149
+ return includeEvents ? sql `m.events_json` : sql `null`;
150
+ }
95
151
  /** Empty run columns are omitted. */
96
152
  function runFields(row) {
97
153
  return {
@@ -164,7 +220,7 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
164
220
  return row ? toConversation(row) : null;
165
221
  }
166
222
  /** One scoped projection for all message reads, including the row returned by an upsert. */
167
- function ownedMessages(db, conversationId) {
223
+ function ownedMessages(db, conversationId, { includeEvents = true } = {}) {
168
224
  return db
169
225
  .selectFrom("ai_chat_messages as m")
170
226
  .innerJoin("ai_chats as c", "c.id", "m.conversation_id")
@@ -174,7 +230,6 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
174
230
  "m.content",
175
231
  "m.agent_id",
176
232
  "m.agent_name",
177
- "m.events_json",
178
233
  "m.attachments_json",
179
234
  "m.run_id",
180
235
  "m.run_status",
@@ -182,14 +237,15 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
182
237
  "m.started_at",
183
238
  "m.ended_at",
184
239
  ])
240
+ .select(messageEventsColumn({ includeEvents }).as('events_json'))
185
241
  .where("m.conversation_id", "=", conversationId)
186
242
  .where("c.scope_id", "=", scopeId)
187
243
  .where("c.owner_kind", "=", ownerKind)
188
244
  .where("c.owner_id", "=", ownerId);
189
245
  }
190
246
  /** Read owned messages in persisted position order; foreign IDs produce no rows. */
191
- async function messages({ conversationId }, _optional = {}) {
192
- const rows = await run(db => ownedMessages(db, conversationId).orderBy(MESSAGE_POSITION).execute());
247
+ async function messages({ conversationId }, { includeEvents = true } = {}) {
248
+ const rows = await run(db => ownedMessages(db, conversationId, { includeEvents }).orderBy(MESSAGE_POSITION).execute());
193
249
  return rows.map(toMessage);
194
250
  }
195
251
  /** Writes and projects one message inside the borrowed transaction; null for absent/foreign IDs. */
@@ -369,6 +425,32 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
369
425
  .execute());
370
426
  },
371
427
  messages,
428
+ /** One indexed, owner-scoped row; browser save fences do not need the conversation's history. */
429
+ async message({ conversationId, messageId }, _optional = {}) {
430
+ const row = await run(db => ownedMessages(db, conversationId).where('m.id', '=', messageId).executeTakeFirst());
431
+ return row ? toMessage(row) : null;
432
+ },
433
+ /** SQLite's connection/external-commit counters avoid reading message rows at all. PG uses
434
+ * MVCC versions from only this conversation's indexed metadata, never large event columns.
435
+ * @complexity O(1) SQLite; O(n) PG message metadata for n messages; O(1) returned bytes.
436
+ */
437
+ async messagesVersion({ conversationId }, _optional = {}) {
438
+ const row = await run(db => db.selectFrom('ai_chats as c').select(eb => {
439
+ const version = kernel.dialect === 'sqlite' ? sqliteReadVersion({ kernel })
440
+ : sql `md5(coalesce(${eb.selectFrom('ai_chat_messages as m')
441
+ .select(sql `string_agg(m.id || ':' || cast(m.xmin as text), ',' order by m.position)`.as('versions'))
442
+ .whereRef('m.conversation_id', '=', 'c.id')}, ''))`;
443
+ return version.as('version');
444
+ })
445
+ .where('c.id', '=', conversationId).where('c.scope_id', '=', scopeId)
446
+ .where('c.owner_kind', '=', ownerKind).where('c.owner_id', '=', ownerId).executeTakeFirst());
447
+ return row?.version ?? null;
448
+ },
449
+ async messagesJson({ conversationId }, optional = {}) {
450
+ const valid = kernel.dialect === 'sqlite' ? sql `json_valid(m.events_json)` : sql `null`;
451
+ const rows = await run(db => ownedMessages(db, conversationId).select(valid.as('events_json_valid')).orderBy(MESSAGE_POSITION).execute());
452
+ return messageJsonChunks({ rows }, optional);
453
+ },
372
454
  async appendMessage({ conversationId, message }, _optional = {}) {
373
455
  const prepared = redactUserMessage({ message }, { ...(redactionOptions.redactUserText ? { redactUserText: redactionOptions.redactUserText } : {}) });
374
456
  const saved = await writeMessage(conversationId, prepared.message);
@@ -429,6 +511,9 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
429
511
  touch: (required, optional = {}) => atStoreBoundary({ operation: () => store.touch(required, optional) }),
430
512
  delete: (required, optional = {}) => atStoreBoundary({ operation: () => store.delete(required, optional) }),
431
513
  messages: (required, optional = {}) => atStoreBoundary({ operation: () => store.messages(required, optional) }),
514
+ message: (required, optional = {}) => atStoreBoundary({ operation: () => store.message(required, optional) }),
515
+ messagesVersion: (required, optional = {}) => atStoreBoundary({ operation: () => store.messagesVersion(required, optional) }),
516
+ messagesJson: (required, optional = {}) => atStoreBoundary({ operation: () => store.messagesJson(required, optional) }),
432
517
  appendMessage: (required, optional = {}) => atStoreBoundary({ operation: () => store.appendMessage(required, optional) }),
433
518
  pageConversations: (required, optional = {}) => atStoreBoundary({ operation: () => store.pageConversations(required, optional) }),
434
519
  pageMessages: (required, optional = {}) => atStoreBoundary({ operation: () => store.pageMessages(required, optional) }),
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @module store/sqlite/schema
3
3
  *
4
- * Canonical unchanged DDL for ai_chats and ai_chat_messages. Hosts own their database,
4
+ * Canonical DDL for ai_chats and ai_chat_messages. Hosts own their database,
5
5
  * migration ledger and connection configuration. A host with migrations retains its frozen
6
6
  * migration statements; a fresh fixture or host without a migrator may call the explicit helper.
7
7
  * Store construction never invokes it, chooses a file, or sets connection-wide pragmas.
@@ -30,7 +30,7 @@ import type { SqliteClient } from '@jini-ai/db/sqlite';
30
30
  * `expires_at IS NOT NULL`, so authenticated history is out of its reach by construction, not
31
31
  * by a predicate that could drift.
32
32
  */
33
- export declare const CHAT_HISTORY_DDL = "\nCREATE TABLE IF NOT EXISTS ai_chats (\n id TEXT PRIMARY KEY,\n scope_id TEXT NOT NULL,\n owner_kind TEXT NOT NULL CHECK (owner_kind IN ('user','guest')),\n owner_id TEXT NOT NULL,\n title TEXT,\n title_source TEXT NOT NULL DEFAULT 'fallback'\n CHECK (title_source IN ('fallback','generated','manual')),\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL,\n expires_at INTEGER\n);\n\nCREATE INDEX IF NOT EXISTS idx_ai_chats_owner\n ON ai_chats(scope_id, owner_kind, owner_id, updated_at DESC);\n\nCREATE INDEX IF NOT EXISTS idx_ai_chats_expiry\n ON ai_chats(expires_at) WHERE expires_at IS NOT NULL;\n\nCREATE TABLE IF NOT EXISTS ai_chat_messages (\n id TEXT PRIMARY KEY,\n conversation_id TEXT NOT NULL REFERENCES ai_chats(id) ON DELETE CASCADE,\n role TEXT NOT NULL CHECK (role IN ('user','assistant')),\n content TEXT NOT NULL,\n agent_id TEXT,\n agent_name TEXT,\n events_json TEXT,\n attachments_json TEXT,\n run_id TEXT,\n run_status TEXT,\n position INTEGER NOT NULL,\n created_at INTEGER NOT NULL,\n started_at INTEGER,\n ended_at INTEGER,\n UNIQUE (conversation_id, position)\n);\n\nCREATE INDEX IF NOT EXISTS idx_ai_chat_messages_order\n ON ai_chat_messages(conversation_id, position);\n";
33
+ export declare const CHAT_HISTORY_DDL = "\nCREATE TABLE IF NOT EXISTS ai_chats (\n id TEXT PRIMARY KEY,\n scope_id TEXT NOT NULL,\n owner_kind TEXT NOT NULL CHECK (owner_kind IN ('user','guest')),\n owner_id TEXT NOT NULL,\n title TEXT,\n title_source TEXT NOT NULL DEFAULT 'fallback'\n CHECK (title_source IN ('fallback','generated','manual')),\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL,\n expires_at INTEGER\n);\n\nCREATE INDEX IF NOT EXISTS idx_ai_chats_owner\n ON ai_chats(scope_id, owner_kind, owner_id, updated_at DESC);\n\nCREATE INDEX IF NOT EXISTS idx_ai_chats_expiry\n ON ai_chats(expires_at) WHERE expires_at IS NOT NULL;\n\nCREATE TABLE IF NOT EXISTS ai_chat_messages (\n id TEXT PRIMARY KEY,\n conversation_id TEXT NOT NULL REFERENCES ai_chats(id) ON DELETE CASCADE,\n role TEXT NOT NULL CHECK (role IN ('user','assistant')),\n content TEXT NOT NULL,\n agent_id TEXT,\n agent_name TEXT,\n events_json TEXT,\n attachments_json TEXT,\n run_id TEXT,\n run_status TEXT,\n position INTEGER NOT NULL,\n created_at INTEGER NOT NULL,\n started_at INTEGER,\n ended_at INTEGER,\n UNIQUE (conversation_id, position)\n);\n\nCREATE INDEX IF NOT EXISTS idx_ai_chat_messages_order\n ON ai_chat_messages(conversation_id, position);\n\n-- Idle probes read only this covering index, never the overflowing event rows.\nCREATE INDEX IF NOT EXISTS idx_ai_chat_messages_active\n ON ai_chat_messages(role, run_status, id) WHERE run_status IN ('queued', 'running');\n";
34
34
  /**
35
35
  * Creates the chat-history tables in the given database if they are absent.
36
36
  *
@@ -61,6 +61,10 @@ CREATE TABLE IF NOT EXISTS ai_chat_messages (
61
61
 
62
62
  CREATE INDEX IF NOT EXISTS idx_ai_chat_messages_order
63
63
  ON ai_chat_messages(conversation_id, position);
64
+
65
+ -- Idle probes read only this covering index, never the overflowing event rows.
66
+ CREATE INDEX IF NOT EXISTS idx_ai_chat_messages_active
67
+ ON ai_chat_messages(role, run_status, id) WHERE run_status IN ('queued', 'running');
64
68
  `;
65
69
  /**
66
70
  * Creates the chat-history tables in the given database if they are absent.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/chat",
3
- "version": "0.6.19",
3
+ "version": "0.6.21",
4
4
  "description": "Jini's chat/artifact frontend, consolidated into one package with two subpaths: framework-free chat vocabulary and pure parsers in `/core`, headless hooks + presentational components + slots in `/react`. Import only the layer you need.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -214,10 +214,10 @@
214
214
  },
215
215
  "dependencies": {
216
216
  "kysely": "^0.29.6",
217
- "@jini-ai/core": "^0.6.19",
218
- "@jini-ai/db": "^0.6.19",
217
+ "@jini-ai/core": "^0.6.21",
218
+ "@jini-ai/diagnostics": "^0.6.19",
219
219
  "@jini-ai/agentic": "^0.6.19",
220
- "@jini-ai/diagnostics": "^0.6.19"
220
+ "@jini-ai/db": "^0.6.19"
221
221
  },
222
222
  "peerDependencies": {
223
223
  "react": "^18.3.0 || ^19.0.0",
@@ -261,13 +261,13 @@
261
261
  "pg": "8.23.0",
262
262
  "@ag-ui/core": "0.0.58",
263
263
  "fast-check": "4.3.0",
264
- "@jini-ai/db": "0.6.19",
265
264
  "@jini-ai/agentic": "^0.6.19",
265
+ "@jini-ai/core": "^0.6.21",
266
266
  "@jini-ai/agent-runtime": "0.6.19",
267
- "@jini-ai/protocol": "^0.6.19",
268
267
  "@jini-ai/diagnostics": "^0.6.19",
269
- "@jini-ai/ui": "^0.6.19",
270
- "@jini-ai/core": "^0.6.19"
268
+ "@jini-ai/db": "0.6.19",
269
+ "@jini-ai/protocol": "^0.6.19",
270
+ "@jini-ai/ui": "^0.6.19"
271
271
  },
272
272
  "typesVersions": {
273
273
  "*": {