@jini-ai/chat 0.6.19 → 0.6.22
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.
- package/CHANGELOG.md +11 -0
- package/dist/core/accepted-input.d.ts +10 -2
- package/dist/core/accepted-input.js +87 -33
- package/dist/core/assistant-client/durable-run-subscription.js +6 -0
- package/dist/core/assistant-client/entry.d.ts +1 -1
- package/dist/core/assistant-client/entry.js +1 -1
- package/dist/core/assistant-client/messages.d.ts +21 -0
- package/dist/core/assistant-client/messages.js +31 -0
- package/dist/core/checkpoint-interval.d.ts +25 -0
- package/dist/core/checkpoint-interval.js +38 -0
- package/dist/core/durable-runs/ledger-contracts.d.ts +27 -1
- package/dist/core/durable-runs/ports.d.ts +25 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/tool-events.d.ts +5 -2
- package/dist/core/tool-events.js +1 -16
- package/dist/react/components/Composer.d.ts +3 -3
- package/dist/react/components/Composer.js +3 -3
- package/dist/react/components/MessageList.js +8 -3
- package/dist/react/components/MessageRow.d.ts +2 -0
- package/dist/react/components/MessageRow.js +8 -5
- package/dist/react/components/MidRunUserMessage.js +1 -1
- package/dist/react/ext-event-renderer-registry.d.ts +8 -0
- package/dist/react/ext-event-renderer-registry.js +13 -2
- package/dist/react/features/chat-pane/components/ChatPane.js +1 -1
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +5 -4
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +3 -9
- package/dist/react/hooks/useConversation.js +13 -2
- package/dist/server/durable-runs/recover.js +3 -3
- package/dist/store/ports.d.ts +17 -3
- package/dist/store/runtime/chat-history-store.js +6 -0
- package/dist/store/runtime/checkpoint-delta.d.ts +35 -0
- package/dist/store/runtime/checkpoint-delta.js +75 -0
- package/dist/store/runtime/durable-run-store.js +92 -25
- package/dist/store/runtime/events-json.d.ts +17 -0
- package/dist/store/runtime/events-json.js +19 -0
- package/dist/store/runtime/run-guarded-chat-store.js +8 -3
- package/dist/store/runtime/run-ledger.d.ts +2 -2
- package/dist/store/runtime/run-ledger.js +120 -45
- package/dist/store/sql/read-version.d.ts +15 -0
- package/dist/store/sql/read-version.js +21 -0
- package/dist/store/sql/store.d.ts +9 -0
- package/dist/store/sql/store.js +89 -4
- package/dist/store/sqlite/schema.d.ts +2 -2
- package/dist/store/sqlite/schema.js +4 -0
- package/package.json +9 -9
|
@@ -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
|
-
*
|
|
76
|
-
* text
|
|
77
|
-
*
|
|
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
|
|
106
|
-
*
|
|
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,
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*
|
package/dist/store/sql/store.js
CHANGED
|
@@ -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 },
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.6.22",
|
|
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.
|
|
218
|
-
"@jini-ai/db": "^0.6.19",
|
|
217
|
+
"@jini-ai/core": "^0.6.22",
|
|
219
218
|
"@jini-ai/agentic": "^0.6.19",
|
|
220
|
-
"@jini-ai/diagnostics": "^0.6.
|
|
219
|
+
"@jini-ai/diagnostics": "^0.6.22",
|
|
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/
|
|
264
|
+
"@jini-ai/agent-runtime": "0.6.22",
|
|
265
265
|
"@jini-ai/agentic": "^0.6.19",
|
|
266
|
-
"@jini-ai/agent-runtime": "0.6.19",
|
|
267
266
|
"@jini-ai/protocol": "^0.6.19",
|
|
268
|
-
"@jini-ai/
|
|
269
|
-
"@jini-ai/
|
|
270
|
-
"@jini-ai/
|
|
267
|
+
"@jini-ai/core": "^0.6.22",
|
|
268
|
+
"@jini-ai/diagnostics": "^0.6.22",
|
|
269
|
+
"@jini-ai/ui": "^0.6.22",
|
|
270
|
+
"@jini-ai/db": "0.6.19"
|
|
271
271
|
},
|
|
272
272
|
"typesVersions": {
|
|
273
273
|
"*": {
|