@yeaft/webchat-agent 1.0.336 → 1.0.337
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/local-runtime/server/handlers/agent-output.js +15 -0
- package/local-runtime/server/handlers/client-conversation.js +13 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +93 -94
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
- package/yeaft/conversation/history-index-state.js +294 -0
- package/yeaft/conversation/history-index-worker.js +574 -0
- package/yeaft/conversation/history-index.js +530 -0
- package/yeaft/conversation/persist.js +197 -94
- package/yeaft/conversation/visible-entry.js +143 -0
- package/yeaft/sessions/session-crud.js +65 -2
- package/yeaft/web-bridge.js +199 -23
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
5
|
+
import { dirname } from 'node:path';
|
|
6
|
+
import { ConversationStore } from './persist.js';
|
|
7
|
+
import {
|
|
8
|
+
VISIBLE_ENTRY_SCHEMA_VERSION,
|
|
9
|
+
findLiteralSearch,
|
|
10
|
+
normalizeLiteralSearch,
|
|
11
|
+
} from './visible-entry.js';
|
|
12
|
+
import { fingerprintConversationSources } from './history-index-state.js';
|
|
13
|
+
|
|
14
|
+
const INDEX_SCHEMA_VERSION = 2;
|
|
15
|
+
const SHORT_BLOOM_BYTES = 256;
|
|
16
|
+
const BUILD_YIELD_INTERVAL = 64;
|
|
17
|
+
const QUERY_BATCH_ROWS = 128;
|
|
18
|
+
const QUERY_BATCH_BYTES = 2 * 1024 * 1024;
|
|
19
|
+
const sourceDigestCache = new Map();
|
|
20
|
+
|
|
21
|
+
function codePoints(value) {
|
|
22
|
+
return Array.from(String(value || ''));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function shortGrams(value, sizes = [1, 2]) {
|
|
26
|
+
const chars = codePoints(normalizeLiteralSearch(value));
|
|
27
|
+
const out = new Set();
|
|
28
|
+
for (const size of sizes) {
|
|
29
|
+
if (size <= 0 || chars.length < size) continue;
|
|
30
|
+
for (let index = 0; index <= chars.length - size; index += 1) {
|
|
31
|
+
out.add(`${size}:${chars.slice(index, index + size).join('')}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function bloomPositions(value) {
|
|
38
|
+
const bytes = Buffer.from(value, 'utf8');
|
|
39
|
+
let first = 2166136261;
|
|
40
|
+
let second = 2246822519;
|
|
41
|
+
for (const byte of bytes) {
|
|
42
|
+
first = Math.imul(first ^ byte, 16777619) >>> 0;
|
|
43
|
+
second = Math.imul(second ^ byte, 3266489917) >>> 0;
|
|
44
|
+
}
|
|
45
|
+
const bitCount = SHORT_BLOOM_BYTES * 8;
|
|
46
|
+
return [
|
|
47
|
+
first % bitCount,
|
|
48
|
+
second % bitCount,
|
|
49
|
+
(first + second) % bitCount,
|
|
50
|
+
(first + Math.imul(second, 3)) % bitCount,
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function shortBloom(value) {
|
|
55
|
+
const bloom = Buffer.alloc(SHORT_BLOOM_BYTES);
|
|
56
|
+
for (const gram of shortGrams(value)) {
|
|
57
|
+
for (const bit of bloomPositions(gram)) bloom[bit >>> 3] |= 1 << (bit & 7);
|
|
58
|
+
}
|
|
59
|
+
return bloom;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function bloomMayContain(bloom, query) {
|
|
63
|
+
const chars = codePoints(query);
|
|
64
|
+
if (chars.length < 1 || chars.length > 2 || !bloom) return false;
|
|
65
|
+
const gram = `${chars.length}:${chars.join('')}`;
|
|
66
|
+
const bytes = Buffer.isBuffer(bloom) ? bloom : Buffer.from(bloom);
|
|
67
|
+
return bloomPositions(gram).every(bit => (bytes[bit >>> 3] & (1 << (bit & 7))) !== 0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function openDatabase(path, { create = false } = {}) {
|
|
71
|
+
if (create) mkdirSync(dirname(path), { recursive: true });
|
|
72
|
+
const db = new DatabaseSync(path, create ? {} : { readOnly: true });
|
|
73
|
+
if (create) {
|
|
74
|
+
db.exec(`
|
|
75
|
+
PRAGMA journal_mode = WAL;
|
|
76
|
+
PRAGMA synchronous = NORMAL;
|
|
77
|
+
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
78
|
+
CREATE TABLE entries (
|
|
79
|
+
entry_id TEXT PRIMARY KEY,
|
|
80
|
+
session_id TEXT NOT NULL,
|
|
81
|
+
role TEXT NOT NULL,
|
|
82
|
+
turn_id TEXT,
|
|
83
|
+
speaker_vp_id TEXT,
|
|
84
|
+
entry_start_seq INTEGER NOT NULL,
|
|
85
|
+
entry_end_seq INTEGER NOT NULL,
|
|
86
|
+
anchor_message_id TEXT NOT NULL,
|
|
87
|
+
anchor_seq INTEGER NOT NULL,
|
|
88
|
+
source_message_ids TEXT NOT NULL,
|
|
89
|
+
text_body TEXT NOT NULL,
|
|
90
|
+
short_bloom BLOB NOT NULL,
|
|
91
|
+
timestamp TEXT
|
|
92
|
+
);
|
|
93
|
+
CREATE INDEX entries_session_seq
|
|
94
|
+
ON entries(session_id, entry_end_seq DESC, entry_id DESC);
|
|
95
|
+
CREATE VIRTUAL TABLE entry_fts USING fts5(
|
|
96
|
+
normalized_text,
|
|
97
|
+
content='',
|
|
98
|
+
detail=none,
|
|
99
|
+
columnsize=0,
|
|
100
|
+
tokenize='trigram case_sensitive 1'
|
|
101
|
+
);
|
|
102
|
+
`);
|
|
103
|
+
}
|
|
104
|
+
db.function('yeaft_bloom_contains', { deterministic: true }, (bloom, query) => (
|
|
105
|
+
bloomMayContain(bloom, String(query || '')) ? 1 : 0
|
|
106
|
+
));
|
|
107
|
+
return db;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function setMeta(db, values) {
|
|
111
|
+
const insert = db.prepare('INSERT INTO meta(key,value) VALUES(?,?)');
|
|
112
|
+
for (const [key, value] of Object.entries(values)) insert.run(key, String(value));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readMeta(db) {
|
|
116
|
+
return Object.fromEntries(db.prepare('SELECT key,value FROM meta').all().map(row => [row.key, row.value]));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function updateSemanticHash(hash, entry) {
|
|
120
|
+
hash.update(JSON.stringify({
|
|
121
|
+
entryId: entry.entryId,
|
|
122
|
+
role: entry.role,
|
|
123
|
+
turnId: entry.turnId || null,
|
|
124
|
+
speakerVpId: entry.speakerVpId || null,
|
|
125
|
+
entryStartSeq: entry.entryStartSeq,
|
|
126
|
+
entryEndSeq: entry.entryEndSeq,
|
|
127
|
+
anchorMessageId: entry.anchorMessageId,
|
|
128
|
+
anchorSeq: entry.anchorSeq,
|
|
129
|
+
sourceMessageIds: entry.sourceMessageIds || [],
|
|
130
|
+
textParts: entry.textParts || [],
|
|
131
|
+
timestamp: entry.timestamp || null,
|
|
132
|
+
}), 'utf8');
|
|
133
|
+
hash.update('\0', 'utf8');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function semanticSourceToken(ownerRoot, sessionId) {
|
|
137
|
+
const rawBefore = fingerprintConversationSources(ownerRoot, sessionId, { forceHash: true });
|
|
138
|
+
const store = new ConversationStore(ownerRoot);
|
|
139
|
+
const hash = createHash('sha256');
|
|
140
|
+
hash.update(`visible-history-index-v${INDEX_SCHEMA_VERSION}\0${sessionId}\0`, 'utf8');
|
|
141
|
+
let entryCount = 0;
|
|
142
|
+
if (rawBefore.exists) {
|
|
143
|
+
for (const entry of store.iterateCanonicalVisibleEntriesBySession(sessionId)) {
|
|
144
|
+
updateSemanticHash(hash, entry);
|
|
145
|
+
entryCount += 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const rawAfter = fingerprintConversationSources(ownerRoot, sessionId, { forceHash: true });
|
|
149
|
+
if (rawBefore.fingerprint !== rawAfter.fingerprint) {
|
|
150
|
+
const error = new Error('history source changed during semantic scan');
|
|
151
|
+
error.code = 'source_changed';
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
fingerprint: hash.digest('hex'),
|
|
156
|
+
rawFingerprint: rawAfter.fingerprint,
|
|
157
|
+
files: rawAfter.files,
|
|
158
|
+
bytes: rawAfter.bytes,
|
|
159
|
+
exists: rawAfter.exists,
|
|
160
|
+
entryCount,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const yieldWorker = () => new Promise(resolve => setImmediate(resolve));
|
|
165
|
+
|
|
166
|
+
async function buildIndex() {
|
|
167
|
+
const { ownerRoot, sessionId, databasePath, generation, sourceRevision } = workerData;
|
|
168
|
+
for (const suffix of ['', '-wal', '-shm']) rmSync(`${databasePath}${suffix}`, { force: true });
|
|
169
|
+
const rawBefore = fingerprintConversationSources(ownerRoot, sessionId, { forceHash: true });
|
|
170
|
+
const store = new ConversationStore(ownerRoot);
|
|
171
|
+
const db = openDatabase(databasePath, { create: true });
|
|
172
|
+
const insertEntry = db.prepare(`
|
|
173
|
+
INSERT INTO entries(
|
|
174
|
+
entry_id, session_id, role, turn_id, speaker_vp_id,
|
|
175
|
+
entry_start_seq, entry_end_seq, anchor_message_id, anchor_seq,
|
|
176
|
+
source_message_ids, text_body, short_bloom, timestamp
|
|
177
|
+
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
178
|
+
`);
|
|
179
|
+
const insertFts = db.prepare('INSERT INTO entry_fts(rowid, normalized_text) VALUES(?,?)');
|
|
180
|
+
const semanticHash = createHash('sha256');
|
|
181
|
+
semanticHash.update(`visible-history-index-v${INDEX_SCHEMA_VERSION}\0${sessionId}\0`, 'utf8');
|
|
182
|
+
let entryCount = 0;
|
|
183
|
+
|
|
184
|
+
db.exec('BEGIN IMMEDIATE');
|
|
185
|
+
try {
|
|
186
|
+
if (rawBefore.exists) {
|
|
187
|
+
for (const entry of store.iterateCanonicalVisibleEntriesBySession(sessionId)) {
|
|
188
|
+
const text = entry.textParts.join(' ');
|
|
189
|
+
const normalized = normalizeLiteralSearch(text);
|
|
190
|
+
const inserted = insertEntry.run(
|
|
191
|
+
entry.entryId,
|
|
192
|
+
sessionId,
|
|
193
|
+
entry.role,
|
|
194
|
+
entry.turnId || null,
|
|
195
|
+
entry.speakerVpId || null,
|
|
196
|
+
entry.entryStartSeq,
|
|
197
|
+
entry.entryEndSeq,
|
|
198
|
+
entry.anchorMessageId,
|
|
199
|
+
entry.anchorSeq,
|
|
200
|
+
JSON.stringify(entry.sourceMessageIds || []),
|
|
201
|
+
text,
|
|
202
|
+
shortBloom(normalized),
|
|
203
|
+
entry.timestamp || null,
|
|
204
|
+
);
|
|
205
|
+
insertFts.run(Number(inserted.lastInsertRowid), normalized);
|
|
206
|
+
updateSemanticHash(semanticHash, entry);
|
|
207
|
+
entryCount += 1;
|
|
208
|
+
if (entryCount % BUILD_YIELD_INTERVAL === 0) await yieldWorker();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const rawAfter = fingerprintConversationSources(ownerRoot, sessionId, { forceHash: true });
|
|
212
|
+
if (rawBefore.fingerprint !== rawAfter.fingerprint) {
|
|
213
|
+
const error = new Error('history source changed during rebuild');
|
|
214
|
+
error.code = 'source_changed';
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
const semanticFingerprint = semanticHash.digest('hex');
|
|
218
|
+
setMeta(db, {
|
|
219
|
+
index_schema_version: INDEX_SCHEMA_VERSION,
|
|
220
|
+
visible_entry_schema_version: VISIBLE_ENTRY_SCHEMA_VERSION,
|
|
221
|
+
session_id: sessionId,
|
|
222
|
+
generation,
|
|
223
|
+
source_revision: sourceRevision,
|
|
224
|
+
source_fingerprint: semanticFingerprint,
|
|
225
|
+
raw_source_fingerprint: rawAfter.fingerprint,
|
|
226
|
+
source_files: rawAfter.files,
|
|
227
|
+
source_bytes: rawAfter.bytes,
|
|
228
|
+
entry_count: entryCount,
|
|
229
|
+
built_at: new Date().toISOString(),
|
|
230
|
+
});
|
|
231
|
+
db.exec('COMMIT');
|
|
232
|
+
db.close();
|
|
233
|
+
return {
|
|
234
|
+
generation,
|
|
235
|
+
databasePath,
|
|
236
|
+
sourceRevision,
|
|
237
|
+
fingerprint: semanticFingerprint,
|
|
238
|
+
rawFingerprint: rawAfter.fingerprint,
|
|
239
|
+
files: rawAfter.files,
|
|
240
|
+
bytes: rawAfter.bytes,
|
|
241
|
+
exists: rawAfter.exists,
|
|
242
|
+
entryCount,
|
|
243
|
+
};
|
|
244
|
+
} catch (error) {
|
|
245
|
+
try { db.exec('ROLLBACK'); } catch {}
|
|
246
|
+
db.close();
|
|
247
|
+
for (const suffix of ['', '-wal', '-shm']) rmSync(`${databasePath}${suffix}`, { force: true });
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function currentSourceToken({ forceHash = false } = {}) {
|
|
253
|
+
return fingerprintConversationSources(workerData.ownerRoot, workerData.sessionId, {
|
|
254
|
+
digestCache: sourceDigestCache,
|
|
255
|
+
forceHash,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function indexStats(meta) {
|
|
260
|
+
return {
|
|
261
|
+
indexSourceFiles: Number(meta.source_files) || 0,
|
|
262
|
+
indexSourceBytes: Number(meta.source_bytes) || 0,
|
|
263
|
+
indexEntryCount: Number(meta.entry_count) || 0,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function parseEntry(row, generation) {
|
|
268
|
+
let sourceMessageIds = [];
|
|
269
|
+
try { sourceMessageIds = JSON.parse(row.source_message_ids || '[]'); } catch {}
|
|
270
|
+
return {
|
|
271
|
+
indexGeneration: generation,
|
|
272
|
+
entryId: row.entry_id,
|
|
273
|
+
messageId: row.anchor_message_id,
|
|
274
|
+
seq: Number(row.anchor_seq),
|
|
275
|
+
entryStartSeq: Number(row.entry_start_seq),
|
|
276
|
+
entryEndSeq: Number(row.entry_end_seq),
|
|
277
|
+
role: row.role,
|
|
278
|
+
turnId: row.turn_id,
|
|
279
|
+
speakerVpId: row.speaker_vp_id,
|
|
280
|
+
sourceMessageIds: Array.isArray(sourceMessageIds) ? sourceMessageIds : [],
|
|
281
|
+
timestamp: row.timestamp || null,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function pageBoundary(db, request, generation) {
|
|
286
|
+
if (!request?.cursor) {
|
|
287
|
+
return {
|
|
288
|
+
beforeSeq: Number.isFinite(request?.beforeSeq) ? request.beforeSeq : Number.MAX_SAFE_INTEGER,
|
|
289
|
+
cursorEndSeq: null,
|
|
290
|
+
cursorEntryId: null,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const cursor = request.cursor;
|
|
294
|
+
const row = db.prepare('SELECT entry_start_seq,entry_end_seq FROM entries WHERE entry_id=? AND session_id=?')
|
|
295
|
+
.get(cursor.entryId, workerData.sessionId);
|
|
296
|
+
if (Number(cursor.indexGeneration) !== generation
|
|
297
|
+
|| !row
|
|
298
|
+
|| Number(row.entry_start_seq) !== Number(cursor.entryStartSeq)) {
|
|
299
|
+
const error = new Error('stale history cursor');
|
|
300
|
+
error.code = 'stale_result';
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
beforeSeq: Number.MAX_SAFE_INTEGER,
|
|
305
|
+
cursorEndSeq: Number(row.entry_end_seq),
|
|
306
|
+
cursorEntryId: cursor.entryId,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function nextCursor(generation, selected, hasMore) {
|
|
311
|
+
const oldest = selected.at(-1);
|
|
312
|
+
if (!hasMore || !oldest) return null;
|
|
313
|
+
return {
|
|
314
|
+
indexGeneration: generation,
|
|
315
|
+
entryId: oldest.entryId,
|
|
316
|
+
entryStartSeq: oldest.entryStartSeq,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function queryIndex(request) {
|
|
321
|
+
const db = openDatabase(workerData.databasePath);
|
|
322
|
+
try {
|
|
323
|
+
const meta = readMeta(db);
|
|
324
|
+
const generation = Number(meta.generation) || 0;
|
|
325
|
+
const boundary = pageBoundary(db, request, generation);
|
|
326
|
+
const senderKey = typeof request.senderKey === 'string' ? request.senderKey : '';
|
|
327
|
+
const normalized = normalizeLiteralSearch(String(request.query || '').trim());
|
|
328
|
+
const queryLength = codePoints(normalized).length;
|
|
329
|
+
const limit = Math.min(50, Math.max(1, Number(request.limit) || 20));
|
|
330
|
+
const matches = [];
|
|
331
|
+
let candidateRowsRead = 0;
|
|
332
|
+
let candidateBytesRead = 0;
|
|
333
|
+
let maxBatchRows = 0;
|
|
334
|
+
let maxBatchBytes = 0;
|
|
335
|
+
let batchEndSeq = boundary.cursorEndSeq;
|
|
336
|
+
let batchEntryId = boundary.cursorEntryId;
|
|
337
|
+
let exhausted = false;
|
|
338
|
+
|
|
339
|
+
while (matches.length <= limit && !exhausted) {
|
|
340
|
+
const params = [workerData.sessionId, boundary.beforeSeq];
|
|
341
|
+
let sql = 'SELECT rowid,* FROM entries WHERE session_id=? AND entry_start_seq<?';
|
|
342
|
+
if (batchEndSeq !== null) {
|
|
343
|
+
sql += ' AND (entry_end_seq < ? OR (entry_end_seq = ? AND entry_id < ?))';
|
|
344
|
+
params.push(batchEndSeq, batchEndSeq, batchEntryId);
|
|
345
|
+
}
|
|
346
|
+
if (queryLength >= 3) {
|
|
347
|
+
const firstTrigram = codePoints(normalized).slice(0, 3).join('');
|
|
348
|
+
sql += ' AND rowid IN (SELECT rowid FROM entry_fts WHERE entry_fts MATCH ?)';
|
|
349
|
+
params.push(`"${firstTrigram.replaceAll('"', '""')}"`);
|
|
350
|
+
} else if (queryLength > 0) {
|
|
351
|
+
sql += ' AND yeaft_bloom_contains(short_bloom, ?) = 1';
|
|
352
|
+
params.push(normalized);
|
|
353
|
+
}
|
|
354
|
+
if (senderKey === 'user') sql += " AND role='user'";
|
|
355
|
+
else if (senderKey.startsWith('vp:')) {
|
|
356
|
+
sql += " AND role='assistant' AND speaker_vp_id=?";
|
|
357
|
+
params.push(senderKey.slice(3));
|
|
358
|
+
}
|
|
359
|
+
sql += ' ORDER BY entry_end_seq DESC, entry_id DESC LIMIT ?';
|
|
360
|
+
params.push(QUERY_BATCH_ROWS);
|
|
361
|
+
|
|
362
|
+
let batchRows = 0;
|
|
363
|
+
let batchBytes = 0;
|
|
364
|
+
let batchByteCapped = false;
|
|
365
|
+
let lastRow = null;
|
|
366
|
+
for (const row of db.prepare(sql).iterate(...params)) {
|
|
367
|
+
const bytes = Buffer.byteLength(row.text_body || '', 'utf8');
|
|
368
|
+
if (batchRows > 0 && batchBytes + bytes > QUERY_BATCH_BYTES) {
|
|
369
|
+
batchByteCapped = true;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
batchRows += 1;
|
|
373
|
+
batchBytes += bytes;
|
|
374
|
+
candidateRowsRead += 1;
|
|
375
|
+
candidateBytesRead += bytes;
|
|
376
|
+
lastRow = row;
|
|
377
|
+
const matchIndex = normalized ? findLiteralSearch(row.text_body, normalized) : 0;
|
|
378
|
+
if (matchIndex < 0) continue;
|
|
379
|
+
matches.push({ row, matchIndex });
|
|
380
|
+
if (matches.length > limit) break;
|
|
381
|
+
}
|
|
382
|
+
maxBatchRows = Math.max(maxBatchRows, batchRows);
|
|
383
|
+
maxBatchBytes = Math.max(maxBatchBytes, batchBytes);
|
|
384
|
+
if (!lastRow || (!batchByteCapped && batchRows < QUERY_BATCH_ROWS) || matches.length > limit) {
|
|
385
|
+
exhausted = true;
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
batchEndSeq = Number(lastRow.entry_end_seq);
|
|
389
|
+
batchEntryId = lastRow.entry_id;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const hasMore = matches.length > limit;
|
|
394
|
+
const selected = matches.slice(0, limit).map(({ row, matchIndex }) => {
|
|
395
|
+
const result = parseEntry(row, generation);
|
|
396
|
+
const radius = 90;
|
|
397
|
+
const start = Math.max(0, matchIndex - radius);
|
|
398
|
+
const end = Math.min(row.text_body.length, matchIndex + normalized.length + radius);
|
|
399
|
+
return {
|
|
400
|
+
...result,
|
|
401
|
+
snippet: normalized
|
|
402
|
+
? `${start > 0 ? '…' : ''}${row.text_body.slice(start, end)}${end < row.text_body.length ? '…' : ''}`
|
|
403
|
+
: row.text_body.slice(0, 180),
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
return {
|
|
407
|
+
results: selected,
|
|
408
|
+
hasMore,
|
|
409
|
+
nextBeforeSeq: hasMore ? selected.at(-1)?.entryStartSeq ?? null : null,
|
|
410
|
+
nextCursor: nextCursor(generation, selected, hasMore),
|
|
411
|
+
indexGeneration: generation,
|
|
412
|
+
candidateRowsRead,
|
|
413
|
+
candidateBytesRead,
|
|
414
|
+
maxBatchRows,
|
|
415
|
+
maxBatchBytes,
|
|
416
|
+
...indexStats(meta),
|
|
417
|
+
};
|
|
418
|
+
} finally {
|
|
419
|
+
db.close();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function outlineIndex(request) {
|
|
424
|
+
const db = openDatabase(workerData.databasePath);
|
|
425
|
+
try {
|
|
426
|
+
const meta = readMeta(db);
|
|
427
|
+
const generation = Number(meta.generation) || 0;
|
|
428
|
+
const boundary = pageBoundary(db, request, generation);
|
|
429
|
+
const limit = Math.min(100, Math.max(1, Number(request.limit) || 50));
|
|
430
|
+
const params = [workerData.sessionId, boundary.beforeSeq];
|
|
431
|
+
let sql = 'SELECT * FROM entries WHERE session_id=? AND entry_start_seq<?';
|
|
432
|
+
if (boundary.cursorEndSeq !== null) {
|
|
433
|
+
sql += ' AND (entry_end_seq < ? OR (entry_end_seq = ? AND entry_id < ?))';
|
|
434
|
+
params.push(boundary.cursorEndSeq, boundary.cursorEndSeq, boundary.cursorEntryId);
|
|
435
|
+
}
|
|
436
|
+
sql += ' ORDER BY entry_end_seq DESC, entry_id DESC LIMIT ?';
|
|
437
|
+
params.push(limit + 1);
|
|
438
|
+
const rows = db.prepare(sql).all(...params);
|
|
439
|
+
const hasMore = rows.length > limit;
|
|
440
|
+
const selected = rows.slice(0, limit).map(row => ({
|
|
441
|
+
...parseEntry(row, generation),
|
|
442
|
+
snippet: row.text_body.length > 180 ? `${row.text_body.slice(0, 180).trimEnd()}…` : row.text_body,
|
|
443
|
+
}));
|
|
444
|
+
const totalCount = request.includeTotal === true
|
|
445
|
+
? Number(db.prepare('SELECT COUNT(*) AS n FROM entries WHERE session_id=?').get(workerData.sessionId)?.n) || 0
|
|
446
|
+
: null;
|
|
447
|
+
return {
|
|
448
|
+
results: selected.slice().reverse(),
|
|
449
|
+
hasMore,
|
|
450
|
+
nextBeforeSeq: hasMore ? selected.at(-1)?.entryStartSeq ?? null : null,
|
|
451
|
+
nextCursor: nextCursor(generation, selected, hasMore),
|
|
452
|
+
totalCount,
|
|
453
|
+
indexGeneration: generation,
|
|
454
|
+
...indexStats(meta),
|
|
455
|
+
};
|
|
456
|
+
} finally {
|
|
457
|
+
db.close();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function validatedEntry(db, request, generation) {
|
|
462
|
+
if (Number(request.indexGeneration) !== generation) return null;
|
|
463
|
+
const row = db.prepare('SELECT * FROM entries WHERE entry_id=? AND session_id=?')
|
|
464
|
+
.get(request.entryId, workerData.sessionId);
|
|
465
|
+
if (!row
|
|
466
|
+
|| row.anchor_message_id !== request.anchorMessageId
|
|
467
|
+
|| Number(row.anchor_seq) !== Number(request.anchorSeq)
|
|
468
|
+
|| Number(row.entry_start_seq) !== Number(request.entryStartSeq)) return null;
|
|
469
|
+
return row;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function validateAnchor(request) {
|
|
473
|
+
const db = openDatabase(workerData.databasePath);
|
|
474
|
+
try {
|
|
475
|
+
const meta = readMeta(db);
|
|
476
|
+
const generation = Number(meta.generation) || 0;
|
|
477
|
+
const row = validatedEntry(db, request, generation);
|
|
478
|
+
if (!row) return { ok: false, code: 'stale_result', indexGeneration: generation };
|
|
479
|
+
return { ok: true, entry: parseEntry(row, generation), indexGeneration: generation };
|
|
480
|
+
} finally {
|
|
481
|
+
db.close();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function validateAndReadWindow(request) {
|
|
486
|
+
const db = openDatabase(workerData.databasePath);
|
|
487
|
+
try {
|
|
488
|
+
const meta = readMeta(db);
|
|
489
|
+
const generation = Number(meta.generation) || 0;
|
|
490
|
+
const sourceBefore = currentSourceToken();
|
|
491
|
+
if (sourceBefore.fingerprint !== meta.raw_source_fingerprint) {
|
|
492
|
+
return { ok: false, code: 'stale_result', indexGeneration: generation };
|
|
493
|
+
}
|
|
494
|
+
const row = validatedEntry(db, request, generation);
|
|
495
|
+
if (!row) return { ok: false, code: 'stale_result', indexGeneration: generation };
|
|
496
|
+
if (workerData.testHooksEnabled && request._testBarrier instanceof SharedArrayBuffer) {
|
|
497
|
+
const barrier = new Int32Array(request._testBarrier);
|
|
498
|
+
Atomics.store(barrier, 0, 1);
|
|
499
|
+
Atomics.notify(barrier, 0);
|
|
500
|
+
while (Atomics.load(barrier, 1) === 0) Atomics.wait(barrier, 1, 0, 100);
|
|
501
|
+
}
|
|
502
|
+
const entry = parseEntry(row, generation);
|
|
503
|
+
const store = new ConversationStore(workerData.ownerRoot);
|
|
504
|
+
const window = store.loadVisibleWindowBySession(workerData.sessionId, request.anchorSeq, {
|
|
505
|
+
beforeTurns: request.beforeTurns,
|
|
506
|
+
afterTurns: request.afterTurns,
|
|
507
|
+
entryStartSeq: entry.entryStartSeq,
|
|
508
|
+
entryEndSeq: entry.entryEndSeq,
|
|
509
|
+
sourceMessageIds: entry.sourceMessageIds,
|
|
510
|
+
maxRows: request.maxRows,
|
|
511
|
+
maxBytes: request.maxBytes,
|
|
512
|
+
});
|
|
513
|
+
const sourceAfter = currentSourceToken();
|
|
514
|
+
if (sourceBefore.fingerprint !== sourceAfter.fingerprint) {
|
|
515
|
+
return { ok: false, code: 'stale_result', indexGeneration: generation };
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
ok: true,
|
|
519
|
+
entry,
|
|
520
|
+
window,
|
|
521
|
+
indexGeneration: generation,
|
|
522
|
+
rawSourceFingerprint: sourceAfter.fingerprint,
|
|
523
|
+
};
|
|
524
|
+
} finally {
|
|
525
|
+
db.close();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function run() {
|
|
530
|
+
if (workerData.mode === 'rebuild') return buildIndex();
|
|
531
|
+
if (workerData.mode === 'fingerprint') return semanticSourceToken(workerData.ownerRoot, workerData.sessionId);
|
|
532
|
+
if (!existsSync(workerData.databasePath)) throw new Error('history index database missing');
|
|
533
|
+
return await new Promise(resolve => {
|
|
534
|
+
const close = () => {
|
|
535
|
+
try { parentPort.close(); } catch {}
|
|
536
|
+
resolve({ closed: true });
|
|
537
|
+
};
|
|
538
|
+
parentPort.on('message', message => {
|
|
539
|
+
const { requestId, op, payload = {} } = message || {};
|
|
540
|
+
if (op === 'close') return close();
|
|
541
|
+
try {
|
|
542
|
+
let result;
|
|
543
|
+
if (op === 'search') result = queryIndex(payload);
|
|
544
|
+
else if (op === 'outline') result = outlineIndex(payload);
|
|
545
|
+
else if (op === 'validate-anchor') result = validateAnchor(payload);
|
|
546
|
+
else if (op === 'validate-and-read-window') result = validateAndReadWindow(payload);
|
|
547
|
+
else if (op === 'source-token') result = currentSourceToken(payload);
|
|
548
|
+
else throw new Error(`unknown history index operation: ${op}`);
|
|
549
|
+
parentPort.postMessage({ requestId, result });
|
|
550
|
+
} catch (error) {
|
|
551
|
+
parentPort.postMessage({
|
|
552
|
+
requestId,
|
|
553
|
+
error: error?.message || String(error),
|
|
554
|
+
...(error?.code ? { code: error.code } : {}),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
run()
|
|
562
|
+
.then(result => {
|
|
563
|
+
if (workerData.mode === 'rebuild') {
|
|
564
|
+
parentPort.postMessage({ type: 'rebuilt', result });
|
|
565
|
+
parentPort.close();
|
|
566
|
+
} else if (workerData.mode === 'fingerprint') {
|
|
567
|
+
parentPort.postMessage({ type: 'fingerprint', result });
|
|
568
|
+
parentPort.close();
|
|
569
|
+
}
|
|
570
|
+
})
|
|
571
|
+
.catch(error => {
|
|
572
|
+
parentPort.postMessage({ type: 'fatal', error: error?.stack || error?.message || String(error) });
|
|
573
|
+
process.exitCode = 1;
|
|
574
|
+
});
|