dsh-reference-anything 0.2.0

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 (66) hide show
  1. package/LICENSE +22 -0
  2. package/NOTICE.md +54 -0
  3. package/README.md +228 -0
  4. package/cordis.patch.yml +63 -0
  5. package/images/logo.png +0 -0
  6. package/lib/client/index.d.ts +3 -0
  7. package/lib/client.js +18151 -0
  8. package/lib/contract.d.ts +300 -0
  9. package/lib/contract.js +102 -0
  10. package/lib/errors.d.ts +56 -0
  11. package/lib/errors.js +21 -0
  12. package/lib/host.d.ts +102 -0
  13. package/lib/host.js +55 -0
  14. package/lib/index.d.ts +83 -0
  15. package/lib/index.js +153 -0
  16. package/lib/opencli.d.ts +116 -0
  17. package/lib/opencli.js +356 -0
  18. package/lib/reference.d.ts +49 -0
  19. package/lib/reference.js +228 -0
  20. package/lib/render.d.ts +101 -0
  21. package/lib/render.js +250 -0
  22. package/lib/retain.d.ts +35 -0
  23. package/lib/retain.js +116 -0
  24. package/lib/search.d.ts +75 -0
  25. package/lib/search.js +126 -0
  26. package/lib/serialize.d.ts +22 -0
  27. package/lib/serialize.js +24 -0
  28. package/lib/sources/file.d.ts +100 -0
  29. package/lib/sources/file.js +236 -0
  30. package/lib/sources/web-chat/index.d.ts +114 -0
  31. package/lib/sources/web-chat/index.js +359 -0
  32. package/lib/store/spec.d.ts +312 -0
  33. package/lib/store/spec.js +94 -0
  34. package/lib/store/store.d.ts +189 -0
  35. package/lib/store/store.js +638 -0
  36. package/lib/sync/index.d.ts +91 -0
  37. package/lib/sync/index.js +425 -0
  38. package/lib/tool.d.ts +62 -0
  39. package/lib/tool.js +364 -0
  40. package/lib/typert.d.ts +2 -0
  41. package/lib/typert.js +34 -0
  42. package/lib/types.d.ts +215 -0
  43. package/lib/types.js +12 -0
  44. package/lib/uri-codec.d.ts +5 -0
  45. package/lib/uri-codec.js +39 -0
  46. package/lib/uri.d.ts +59 -0
  47. package/lib/uri.js +86 -0
  48. package/lib/web.d.ts +4 -0
  49. package/lib/web.js +11 -0
  50. package/lib/window.d.ts +23 -0
  51. package/lib/window.js +35 -0
  52. package/lib/wire.d.ts +117 -0
  53. package/lib/wire.js +49 -0
  54. package/lib/workspace.d.ts +18 -0
  55. package/lib/workspace.js +81 -0
  56. package/opencli-plugin/chatgpt.js +125 -0
  57. package/opencli-plugin/claude.js +134 -0
  58. package/opencli-plugin/common.js +307 -0
  59. package/opencli-plugin/deepseek.js +152 -0
  60. package/opencli-plugin/gemini.js +165 -0
  61. package/opencli-plugin/grok.js +123 -0
  62. package/opencli-plugin/kimi.js +123 -0
  63. package/opencli-plugin/opencli-plugin.json +6 -0
  64. package/opencli-plugin/package.json +8 -0
  65. package/opencli-plugin/since-guard.js +45 -0
  66. package/package.json +180 -0
@@ -0,0 +1,638 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { ReferenceAnythingError } from "../errors.js";
3
+ import { compareMatches, scoreTitle, snippet } from "../search.js";
4
+ export const TURNS_PER_CHUNK = 50;
5
+ /**
6
+ * Shortest query that may trigger a body scan. One character matches almost
7
+ * every conversation, which is neither useful nor worth the scan.
8
+ */
9
+ export const CONTENT_MIN_QUERY = 2;
10
+ /** Conversations a single body scan may examine, newest first. */
11
+ export const CONTENT_SCAN_LIMIT = 300;
12
+ /** Characters of one conversation a body scan reads before giving up on it. */
13
+ export const CONTENT_SCAN_CHARS = 200_000;
14
+ /** Stable final tie-breaker for equally relevant, equally recent conversations. */
15
+ const PROVIDER_ORDER = {
16
+ chatgpt: 0, claude: 1, gemini: 2, deepseek: 3, grok: 4, kimi: 5,
17
+ };
18
+ export class ConversationStore {
19
+ domain;
20
+ conversations;
21
+ revisions;
22
+ chunks;
23
+ attachments;
24
+ syncStates;
25
+ observedAccountScopes = new Map();
26
+ constructor(domain) {
27
+ this.domain = domain;
28
+ this.conversations = domain.table('conversations');
29
+ this.revisions = domain.table('revisions');
30
+ this.chunks = domain.table('turn_chunks');
31
+ this.attachments = domain.table('attachments');
32
+ this.syncStates = domain.table('sync_states');
33
+ }
34
+ setActiveAccountScope(provider, accountScope) {
35
+ this.observedAccountScopes.set(provider, accountScope);
36
+ }
37
+ get settings() { return this.domain.global.get(); }
38
+ setSettings(settings) { return this.domain.global.set(settings); }
39
+ /** Remove every persisted transcript while retaining the title directory. */
40
+ async clearMirrorContent() {
41
+ for (const [key] of this.attachments.entries())
42
+ await retryWindowsReplace(() => this.attachments.delete(key));
43
+ for (const [key] of this.chunks.entries())
44
+ await retryWindowsReplace(() => this.chunks.delete(key));
45
+ for (const [key] of this.revisions.entries())
46
+ await retryWindowsReplace(() => this.revisions.delete(key));
47
+ for (const [key, row] of this.conversations.entries()) {
48
+ if (row.currentRevision === undefined)
49
+ continue;
50
+ const { currentRevision: _revision, ...metadata } = row;
51
+ await retryWindowsReplace(() => this.conversations.put(key, metadata));
52
+ }
53
+ }
54
+ static conversationKey(provider, accountScope, externalId) {
55
+ return Buffer.from(JSON.stringify([provider, accountScope, externalId])).toString('base64url');
56
+ }
57
+ static parseConversationKey(key) {
58
+ try {
59
+ const value = JSON.parse(Buffer.from(key, 'base64url').toString('utf8'));
60
+ if (!Array.isArray(value) || value.length !== 3 || !value.every(item => typeof item === 'string'))
61
+ throw new Error();
62
+ return value;
63
+ }
64
+ catch {
65
+ throw new ReferenceAnythingError('web conversation reference id is malformed', 'REFERENCE_INVALID_URI');
66
+ }
67
+ }
68
+ /**
69
+ * Rank conversations for a discovery surface, best match first.
70
+ *
71
+ * Three passes, each only reached when the one before it left room. Titles
72
+ * are matched loosely (see {@link scoreTitle}) because the `@` mention
73
+ * query can never contain a space; bodies are searched only when titles
74
+ * came up short, which is what makes the auto-generated "New chat" titles
75
+ * findable at all without paying for a scan on every keystroke.
76
+ * @param query - user query, provider prefix already stripped.
77
+ * @param provider - restrict to one provider.
78
+ * @param limit - maximum results.
79
+ * @returns matches, best first, capped at `limit`.
80
+ */
81
+ list(query, provider, limit) {
82
+ const needle = query.trim();
83
+ const activeScopes = this.activeAccountScopes();
84
+ const candidates = [...this.conversations.entries()]
85
+ .filter(([, row]) => !row.remoteMissing && (!provider || row.provider === provider)
86
+ && (!activeScopes.has(row.provider) || activeScopes.get(row.provider) === row.accountScope))
87
+ .sort(compareConversationRows);
88
+ if (needle === '') {
89
+ return candidates.slice(0, limit).map(([key, row]) => ({ key, row, via: 'recent' }));
90
+ }
91
+ const titled = [];
92
+ const unmatched = [];
93
+ for (const [key, row] of candidates) {
94
+ const match = scoreTitle(row.title, needle);
95
+ if (match)
96
+ titled.push({ key, row, match });
97
+ else
98
+ unmatched.push([key, row]);
99
+ }
100
+ titled.sort((a, b) => compareMatches(a.match, b.match)
101
+ || compareConversationRows([a.key, a.row], [b.key, b.row]));
102
+ const results = titled.slice(0, limit)
103
+ .map(({ key, row }) => ({ key, row, via: 'title' }));
104
+ if (results.length >= limit || needle.length < CONTENT_MIN_QUERY)
105
+ return results;
106
+ const pattern = new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'), 'iu');
107
+ for (const [key, row] of unmatched.slice(0, CONTENT_SCAN_LIMIT)) {
108
+ const hit = this.findInBody(key, row, pattern);
109
+ if (!hit)
110
+ continue;
111
+ results.push({ key, row, via: 'content', snippet: hit });
112
+ if (results.length >= limit)
113
+ break;
114
+ }
115
+ return results;
116
+ }
117
+ stats(providers) {
118
+ return providers.map((provider) => {
119
+ const conversations = [...this.conversations.entries()].filter(([, row]) => row.provider === provider && !row.remoteMissing);
120
+ const states = [...this.syncStates.entries()].map(([, row]) => row).filter(row => row.provider === provider);
121
+ const latest = newestByTimestamp(states, row => row.lastSyncAt);
122
+ const latestSuccessful = newestByTimestamp(states.filter(row => row.status === 'idle'), row => row.lastSyncAt);
123
+ // Old/imported records can have a blank or malformed `syncedAt` while
124
+ // still carrying a usable provider `updatedAt`. Consider both fields
125
+ // independently: `syncedAt || updatedAt` lets a truthy malformed value
126
+ // mask the valid fallback and leaves the provider card without a date.
127
+ const latestLocalUpdate = newestTimestamp(conversations.flatMap(([, row]) => [row.syncedAt, row.updatedAt]));
128
+ return {
129
+ provider, conversations: conversations.length, lastSyncedAt: latestSuccessful?.lastSyncAt || latestLocalUpdate,
130
+ status: latest?.status === 'running' ? 'syncing' : latest?.status === 'failed' ? 'error' : conversations.length ? 'ready' : 'empty',
131
+ ...(latest?.error ? { error: latest.error } : {}),
132
+ };
133
+ });
134
+ }
135
+ /**
136
+ * Search one conversation's stored turns for `pattern`.
137
+ *
138
+ * Reads chunks by key off the current revision rather than iterating the
139
+ * chunk table, whose `entries()` copies every record in the domain. Matching
140
+ * runs against the turn text in place — no lowercased copy is built, so the
141
+ * excerpt keeps its original casing and nothing is cached beside the
142
+ * records the domain already holds in memory.
143
+ * @returns the excerpt around the first hit, or undefined when there is none.
144
+ */
145
+ findInBody(conversationKey, row, pattern) {
146
+ if (!row.currentRevision)
147
+ return undefined;
148
+ const revision = this.revisions.get(`${conversationKey}:${row.currentRevision}`);
149
+ if (!revision)
150
+ return undefined;
151
+ let budget = CONTENT_SCAN_CHARS;
152
+ for (const chunkKey of revision.chunkKeys) {
153
+ for (const turn of this.chunks.get(chunkKey)?.turns ?? []) {
154
+ const at = turn.text.search(pattern);
155
+ if (at >= 0)
156
+ return snippet(turn.text, at);
157
+ budget -= turn.text.length;
158
+ if (budget <= 0)
159
+ return undefined;
160
+ }
161
+ }
162
+ return undefined;
163
+ }
164
+ /**
165
+ * Page through every stored conversation, including ones the provider no
166
+ * longer lists — a management view needs to surface `remoteMissing` rows
167
+ * so they can be cleaned up, unlike {@link list}'s mention/search surface.
168
+ */
169
+ page(query, provider, limit, offset) {
170
+ const needle = query.trim().toLocaleLowerCase();
171
+ const filtered = [...this.conversations.entries()]
172
+ .filter(([, row]) => !provider || row.provider === provider)
173
+ .filter(([, row]) => needle === '' || row.title.toLocaleLowerCase().includes(needle))
174
+ .sort((a, b) => recency(b[1]) - recency(a[1]));
175
+ return { items: filtered.slice(offset, offset + limit), total: filtered.length };
176
+ }
177
+ /**
178
+ * Permanently purge one conversation and everything derived from it.
179
+ *
180
+ * A sync job racing this call can resurrect a broken row (see
181
+ * {@link WebChatHistoryService.remove}, which guards against that at the
182
+ * service layer) — this method itself makes no atomicity promise beyond
183
+ * "delete whatever currently references this key."
184
+ * @returns whether a conversation with this key existed.
185
+ */
186
+ async remove(conversationKey) {
187
+ if (!this.conversations.get(conversationKey))
188
+ return false;
189
+ for (const [key, revision] of this.revisions.entries()) {
190
+ if (revision.conversationKey !== conversationKey)
191
+ continue;
192
+ for (const chunkKey of revision.chunkKeys)
193
+ await this.chunks.delete(chunkKey);
194
+ await this.revisions.delete(key);
195
+ }
196
+ for (const [key, row] of this.attachments.entries()) {
197
+ if (row.conversationKey === conversationKey)
198
+ await this.attachments.delete(key);
199
+ }
200
+ await this.conversations.delete(conversationKey);
201
+ return true;
202
+ }
203
+ /** Approximate payload bytes held by this domain; filesystem overhead is excluded. */
204
+ storageStats() {
205
+ const tables = [this.conversations, this.revisions, this.chunks, this.attachments, this.syncStates];
206
+ let bytes = 0;
207
+ for (const table of tables)
208
+ for (const [key, value] of table.entries()) {
209
+ bytes += Buffer.byteLength(JSON.stringify([key, value]), 'utf8');
210
+ }
211
+ const remoteMissing = [...this.conversations.entries()].filter(([, row]) => row.remoteMissing).length;
212
+ const oldAccountConversations = this.oldAccountConversationKeys().length;
213
+ return { bytes, conversations: this.conversations.size, remoteMissing, oldAccountConversations };
214
+ }
215
+ /** Permanently remove every row a complete remote scan marked as absent. */
216
+ async removeRemoteMissing() {
217
+ const keys = [...this.conversations.entries()].filter(([, row]) => row.remoteMissing).map(([key]) => key);
218
+ for (const key of keys)
219
+ await this.remove(key);
220
+ return keys.length;
221
+ }
222
+ /** Permanently remove conversations and sync metadata owned by inactive accounts. */
223
+ async removeOldAccounts() {
224
+ // Resolve the active scopes once. Removing sync-state rows while deriving
225
+ // this map could otherwise make the definition of "old" change mid-pass.
226
+ const activeScopes = this.activeAccountScopes();
227
+ const keys = this.oldAccountConversationKeys(activeScopes);
228
+ for (const key of keys)
229
+ await this.remove(key);
230
+ for (const [key, row] of this.syncStates.entries()) {
231
+ const activeScope = activeScopes.get(row.provider);
232
+ if (activeScope !== undefined && row.accountScope !== activeScope)
233
+ await this.syncStates.delete(key);
234
+ }
235
+ return keys.length;
236
+ }
237
+ async removeProvider(provider) {
238
+ const keys = [...this.conversations.entries()].filter(([, row]) => row.provider === provider).map(([key]) => key);
239
+ for (const key of keys)
240
+ await this.remove(key);
241
+ for (const [key, row] of this.syncStates.entries())
242
+ if (row.provider === provider)
243
+ await this.syncStates.delete(key);
244
+ return keys.length;
245
+ }
246
+ async removeOlderThan(days, now = Date.now()) {
247
+ const cutoff = now - Math.max(1, Math.trunc(days)) * 24 * 60 * 60 * 1000;
248
+ const keys = [...this.conversations.entries()].filter(([, row]) => recency(row) < cutoff).map(([key]) => key);
249
+ for (const key of keys)
250
+ await this.remove(key);
251
+ return keys.length;
252
+ }
253
+ /**
254
+ * One row per provider, aggregated across every account scope it has ever
255
+ * synced under. `status`/`error`/`lastSyncAt` come from whichever attempt is
256
+ * most recent — success or failure — so a background failure is never
257
+ * hidden behind an older successful scope's row. `lastCompleteScanAt` is
258
+ * the max across all of that provider's rows, so it never regresses just
259
+ * because the latest attempt failed before finishing.
260
+ */
261
+ syncStateSummary() {
262
+ const freshest = new Map();
263
+ const lastCompleteScan = new Map();
264
+ for (const [, row] of this.syncStates.entries()) {
265
+ const current = freshest.get(row.provider);
266
+ if (!current || Date.parse(row.lastSyncAt || '') > Date.parse(current.lastSyncAt || '')) {
267
+ freshest.set(row.provider, {
268
+ provider: row.provider, status: row.status, lastSyncAt: row.lastSyncAt,
269
+ lastCompleteScanAt: row.lastCompleteScanAt, error: row.error,
270
+ });
271
+ }
272
+ // `!seen` short-circuits the first real value in — `Date.parse('') > Date.parse(x)` is always
273
+ // false (NaN comparisons never succeed), so comparing straight through would silently drop it.
274
+ if (row.lastCompleteScanAt) {
275
+ const seen = lastCompleteScan.get(row.provider);
276
+ if (!seen || Date.parse(row.lastCompleteScanAt) > Date.parse(seen))
277
+ lastCompleteScan.set(row.provider, row.lastCompleteScanAt);
278
+ }
279
+ }
280
+ return [...freshest.values()].map(state => ({ ...state, lastCompleteScanAt: lastCompleteScan.get(state.provider) || state.lastCompleteScanAt }));
281
+ }
282
+ /**
283
+ * Record what the provider's listing says about one conversation.
284
+ *
285
+ * Skips the write when nothing but `syncedAt` would change: under the JSON
286
+ * storage backend a single record write re-serializes and fsyncs the entire
287
+ * domain, so an unchanged history would otherwise cost one whole-mirror
288
+ * rewrite per conversation on every pass.
289
+ * @returns the conversation key, written or not.
290
+ */
291
+ async putConversation(row, accountScope) {
292
+ const key = ConversationStore.conversationKey(row.provider, accountScope, row.id);
293
+ const current = this.conversations.get(key);
294
+ // Directory rows only discover conversations. Once a detail snapshot is
295
+ // mirrored, its metadata stays authoritative until the matching detail
296
+ // fetch succeeds and commitRevision atomically publishes the provider's
297
+ // listing timestamp.
298
+ const hasMirroredTurns = current?.currentRevision !== undefined;
299
+ const next = {
300
+ provider: row.provider, accountScope, externalId: row.id,
301
+ title: row.title.trim() || row.id, url: row.url,
302
+ ...(current?.currentRevision ? { currentRevision: current.currentRevision } : {}),
303
+ createdAt: row.createdAt || current?.createdAt || '',
304
+ updatedAt: hasMirroredTurns ? current.updatedAt : row.updatedAt.trim(),
305
+ // Once a revision exists its turn count is the honest one: the provider
306
+ // counts turns this mirror drops (empty ones), so letting its number
307
+ // back in would flip the value on every metadata refresh.
308
+ messageCount: current?.currentRevision ? current.messageCount : Math.max(0, Math.trunc(row.messageCount || 0)),
309
+ partial: row.partial, remoteMissing: false, syncedAt: new Date().toISOString(),
310
+ };
311
+ if (current && sameConversation(current, next))
312
+ return key;
313
+ await this.conversations.put(key, next);
314
+ return key;
315
+ }
316
+ needsDetail(key, row, full) {
317
+ if (full)
318
+ return true;
319
+ const current = this.conversations.get(key);
320
+ return !current?.currentRevision || current.updatedAt !== row.updatedAt || current.partial !== row.partial;
321
+ }
322
+ /**
323
+ * Store one fetched transcript as an immutable revision and make it current.
324
+ *
325
+ * @param conversationKey - the conversation being updated; it must already exist.
326
+ * @param rows - the provider's turns, in any order.
327
+ * @param next - the listing row this transcript was fetched for. Supplying
328
+ * it folds the metadata update into the same write that publishes the
329
+ * revision, which is what keeps `updatedAt` — the only change signal
330
+ * {@link needsDetail} has — from advancing ahead of the content it describes.
331
+ * @returns the revision id, `sha256:<hex>` over the normalized turns.
332
+ */
333
+ async commitRevision(conversationKey, rows, next) {
334
+ const conversation = this.conversations.get(conversationKey);
335
+ if (!conversation)
336
+ throw new ReferenceAnythingError('conversation disappeared before its revision commit', 'REFERENCE_NOT_FOUND');
337
+ const activeRows = rows.filter(row => row.activeBranch !== false)
338
+ .sort((a, b) => a.ordinal - b.ordinal);
339
+ const turns = activeRows.map((row, ordinal) => ({
340
+ ordinal, messageId: String(row.messageId || ordinal), parentId: String(row.parentId || ''),
341
+ branchId: String(row.branchId || ''), role: row.role, text: String(row.text || ''),
342
+ createdAt: String(row.createdAt || ''), attachments: parseAttachments(row.attachmentsJson),
343
+ })).filter(turn => turn.text.trim() !== '' || turn.attachments.length > 0);
344
+ const canonical = JSON.stringify(turns);
345
+ const digest = createHash('sha256').update(canonical).digest('hex');
346
+ const revision = `sha256:${digest}`;
347
+ const revisionKey = `${conversationKey}:${revision}`;
348
+ const now = Date.now();
349
+ // A provider listing timestamp is authoritative when present. DOM-backed
350
+ // or partially compatible listings can omit it, so retain the newest
351
+ // message timestamp as a fallback instead of publishing an unknown date.
352
+ const newestTurnAt = turns.reduce((latest, turn) => {
353
+ const latestAt = parseTimestamp(latest);
354
+ const candidateAt = parseTimestamp(turn.createdAt);
355
+ return candidateAt !== undefined && (latestAt === undefined || candidateAt > latestAt) ? turn.createdAt : latest;
356
+ }, '');
357
+ const merged = {
358
+ ...conversation,
359
+ ...(next ? {
360
+ title: next.title.trim() || next.id, url: next.url, createdAt: next.createdAt,
361
+ updatedAt: next.updatedAt || newestTurnAt || conversation.updatedAt, partial: next.partial, remoteMissing: false,
362
+ } : newestTurnAt ? { updatedAt: newestTurnAt } : {}),
363
+ currentRevision: revision, messageCount: turns.length, syncedAt: new Date(now).toISOString(),
364
+ };
365
+ // The revision id IS the content hash, so an unchanged transcript commits
366
+ // back to the key it already occupies. Rewriting its chunks and
367
+ // attachments would cost one whole-domain rewrite each (JSON backend) to
368
+ // store bytes that are already there — the common case for a full rescan,
369
+ // and for any conversation whose `updatedAt` moved without its content.
370
+ if (conversation.currentRevision === revision && this.revisions.get(revisionKey)) {
371
+ await this.conversations.put(conversationKey, merged);
372
+ return revision;
373
+ }
374
+ const chunkKeys = [];
375
+ for (let index = 0; index * TURNS_PER_CHUNK < turns.length; index++) {
376
+ const chunkKey = `${revisionKey}:${index}`;
377
+ chunkKeys.push(chunkKey);
378
+ await this.chunks.put(chunkKey, {
379
+ conversationKey, revision, index,
380
+ turns: turns.slice(index * TURNS_PER_CHUNK, (index + 1) * TURNS_PER_CHUNK),
381
+ });
382
+ }
383
+ await this.revisions.put(revisionKey, {
384
+ conversationKey, revision, contentHash: digest, turnCount: turns.length,
385
+ activeBranch: activeRows.find(row => row.branchId)?.branchId || '', chunkKeys,
386
+ partial: merged.partial || activeRows.some(row => row.partial),
387
+ syncedAt: new Date(now).toISOString(), expiresAt: new Date(now).toISOString(),
388
+ });
389
+ for (const turn of turns)
390
+ for (const attachment of turn.attachments) {
391
+ await this.attachments.put(`${revisionKey}:${turn.ordinal}:${attachment.attachmentId}`, {
392
+ ...attachment, conversationKey, revision, ordinal: turn.ordinal,
393
+ });
394
+ }
395
+ await this.conversations.put(conversationKey, merged);
396
+ await this.removeSupersededRevisions(conversationKey, revision);
397
+ return revision;
398
+ }
399
+ /**
400
+ * Most recently observed logged-in account per provider.
401
+ *
402
+ * A non-empty account scope is written as soon as account discovery and
403
+ * listing complete, even if later per-conversation work fails. Older local
404
+ * accounts remain manageable, but must not leak back into the @ picker.
405
+ */
406
+ activeAccountScopes() {
407
+ const active = new Map();
408
+ for (const [, row] of this.syncStates.entries()) {
409
+ if (!row.accountScope)
410
+ continue;
411
+ const at = Date.parse(row.lastSyncAt || '');
412
+ if (Number.isNaN(at))
413
+ continue;
414
+ const current = active.get(row.provider);
415
+ if (!current || at >= current.at)
416
+ active.set(row.provider, { scope: row.accountScope, at });
417
+ }
418
+ const result = new Map([...active].map(([provider, value]) => [provider, value.scope]));
419
+ for (const [provider, scope] of this.observedAccountScopes)
420
+ result.set(provider, scope);
421
+ return result;
422
+ }
423
+ oldAccountConversationKeys(activeScopes = this.activeAccountScopes()) {
424
+ return [...this.conversations.entries()].filter(([, row]) => {
425
+ const activeScope = activeScopes.get(row.provider);
426
+ // No known current account means there is no safe basis for deletion.
427
+ return activeScope !== undefined && row.accountScope !== activeScope;
428
+ }).map(([key]) => key);
429
+ }
430
+ /** Keep exactly the latest transcript revision for a conversation. */
431
+ async removeSupersededRevisions(conversationKey, keep) {
432
+ for (const [key, row] of this.revisions.entries()) {
433
+ if (row.conversationKey !== conversationKey || row.revision === keep)
434
+ continue;
435
+ for (const chunkKey of row.chunkKeys)
436
+ await this.chunks.delete(chunkKey);
437
+ for (const [attachmentKey, attachment] of this.attachments.entries()) {
438
+ if (attachment.conversationKey === conversationKey && attachment.revision === row.revision)
439
+ await this.attachments.delete(attachmentKey);
440
+ }
441
+ await this.revisions.delete(key);
442
+ }
443
+ }
444
+ read(conversationKey, window) {
445
+ const conversation = this.conversations.get(conversationKey);
446
+ if (!conversation?.currentRevision)
447
+ throw new ReferenceAnythingError('conversation has not been synchronized', 'REFERENCE_NOT_FOUND');
448
+ let revision = conversation.currentRevision;
449
+ let end = window.before;
450
+ if (window.cursor !== undefined) {
451
+ const cursor = decodeCursor(window.cursor);
452
+ if (cursor.ref !== conversationKey)
453
+ throw new ReferenceAnythingError('cursor belongs to another reference', 'REFERENCE_INVALID_CURSOR');
454
+ revision = cursor.revision;
455
+ end = cursor.nextOrdinal;
456
+ }
457
+ const revisionRecord = this.revisions.get(`${conversationKey}:${revision}`);
458
+ if (!revisionRecord)
459
+ throw new ReferenceAnythingError('reference cursor revision has expired', 'REFERENCE_CURSOR_EXPIRED');
460
+ if (revision !== conversation.currentRevision)
461
+ throw new ReferenceAnythingError('reference cursor revision has expired', 'REFERENCE_CURSOR_EXPIRED');
462
+ const all = revisionRecord.chunkKeys.flatMap(key => this.chunks.get(key)?.turns ?? []);
463
+ const boundedEnd = Math.max(0, Math.min(end ?? all.length, all.length));
464
+ const start = Math.max(0, boundedEnd - Math.max(1, Math.trunc(window.limit)));
465
+ const nextCursor = start > 0 ? encodeCursor({ v: 1, ref: conversationKey, revision, nextOrdinal: start }) : undefined;
466
+ return {
467
+ ref: { source: 'web-chat', id: conversationKey }, label: conversation.title, origin: conversation.url,
468
+ updatedAt: parseTimestamp(conversation.updatedAt), provider: conversation.provider, revision,
469
+ partial: revisionRecord.partial, capturedAt: Date.parse(revisionRecord.syncedAt),
470
+ body: {
471
+ kind: 'conversation',
472
+ items: all.slice(start, boundedEnd).map((turn) => ({
473
+ role: turn.role, text: turn.text,
474
+ ...(turn.attachments.length ? { attachments: turn.attachments.map(attachment => ({
475
+ attachmentId: attachment.attachmentId, kind: attachment.kind, name: attachment.name,
476
+ mimeType: attachment.mimeType, size: attachment.size, status: attachment.status,
477
+ })) } : {}),
478
+ })),
479
+ startIndex: start, totalTurns: all.length, hasOlder: start > 0,
480
+ ...(nextCursor ? { nextCursor } : {}), revision,
481
+ },
482
+ };
483
+ }
484
+ attachment(conversationKey, revision, attachmentId) {
485
+ return [...this.attachments.entries()].find(([, row]) => row.conversationKey === conversationKey
486
+ && row.revision === revision && row.attachmentId === attachmentId)?.[1];
487
+ }
488
+ async markRemoteMissing(provider, accountScope, seen) {
489
+ for (const [key, row] of this.conversations.entries()) {
490
+ if (row.provider !== provider || row.accountScope !== accountScope || seen.has(row.externalId) || row.remoteMissing)
491
+ continue;
492
+ await this.conversations.put(key, { ...row, remoteMissing: true });
493
+ }
494
+ }
495
+ async collectExpired(now = Date.now()) {
496
+ const expired = [];
497
+ for (const [key, revision] of this.revisions.entries()) {
498
+ const current = this.conversations.get(revision.conversationKey)?.currentRevision;
499
+ if (revision.revision === current)
500
+ continue;
501
+ expired.push([key, revision]);
502
+ }
503
+ if (expired.length === 0)
504
+ return;
505
+ // One pass over the attachments table for the whole sweep. Re-scanning it
506
+ // per expired revision was quadratic, and `entries()` copies every record
507
+ // each time it is called.
508
+ const byRevision = new Map();
509
+ for (const [key, row] of this.attachments.entries()) {
510
+ const group = `${row.conversationKey}\u0000${row.revision}`;
511
+ const bucket = byRevision.get(group);
512
+ if (bucket)
513
+ bucket.push(key);
514
+ else
515
+ byRevision.set(group, [key]);
516
+ }
517
+ for (const [key, revision] of expired) {
518
+ for (const chunkKey of revision.chunkKeys)
519
+ await this.chunks.delete(chunkKey);
520
+ for (const attachmentKey of byRevision.get(`${revision.conversationKey}\u0000${revision.revision}`) ?? []) {
521
+ await this.attachments.delete(attachmentKey);
522
+ }
523
+ await this.revisions.delete(key);
524
+ }
525
+ }
526
+ }
527
+ /** Antivirus/indexers may briefly hold the JSON target across its atomic rename on Windows. */
528
+ async function retryWindowsReplace(operation) {
529
+ for (let attempt = 0;; attempt++) {
530
+ try {
531
+ return await operation();
532
+ }
533
+ catch (error) {
534
+ const code = error.code;
535
+ if (process.platform !== 'win32' || (code !== 'EPERM' && code !== 'EBUSY') || attempt >= 7)
536
+ throw error;
537
+ await new Promise(resolve => setTimeout(resolve, 25 * 2 ** attempt));
538
+ }
539
+ }
540
+ }
541
+ /** Whether two conversation records differ in anything but `syncedAt`, which always does. */
542
+ function sameConversation(a, b) {
543
+ return a.provider === b.provider && a.accountScope === b.accountScope && a.externalId === b.externalId
544
+ && a.title === b.title && a.url === b.url && a.currentRevision === b.currentRevision
545
+ && a.createdAt === b.createdAt && a.updatedAt === b.updatedAt && a.messageCount === b.messageCount
546
+ && a.partial === b.partial && a.remoteMissing === b.remoteMissing;
547
+ }
548
+ /** Sort key for "most recently active", falling back to when we last saw it. */
549
+ function recency(row) {
550
+ return parseTimestamp(row.updatedAt) ?? parseTimestamp(row.syncedAt) ?? 0;
551
+ }
552
+ /** Recency first, then the canonical provider order, then identity for determinism. */
553
+ function compareConversationRows(a, b) {
554
+ return recency(b[1]) - recency(a[1])
555
+ || PROVIDER_ORDER[a[1].provider] - PROVIDER_ORDER[b[1].provider]
556
+ || a[0].localeCompare(b[0]);
557
+ }
558
+ function parseAttachments(raw) {
559
+ try {
560
+ const value = JSON.parse(raw || '[]');
561
+ if (!Array.isArray(value))
562
+ return [];
563
+ return value.map((item, index) => {
564
+ const row = item && typeof item === 'object' ? item : {};
565
+ const status = row.status === 'available' || row.status === 'expired' ? row.status : 'unavailable';
566
+ const locator = typeof row.locator === 'string' && row.locator ? row.locator : undefined;
567
+ const name = String(row.name ?? 'attachment');
568
+ const mimeType = String(row.mimeType ?? '');
569
+ return {
570
+ attachmentId: String(row.attachmentId ?? row.id ?? index),
571
+ kind: attachmentKind(row.kind ?? row.type, mimeType, name), name,
572
+ mimeType, size: Number(row.size ?? 0), status,
573
+ ...(locator ? { locator } : {}),
574
+ };
575
+ });
576
+ }
577
+ catch {
578
+ return [];
579
+ }
580
+ }
581
+ function attachmentKind(value, mimeType, name) {
582
+ if (value === 'image' || value === 'file')
583
+ return value;
584
+ if (mimeType.toLowerCase().startsWith('image/'))
585
+ return 'image';
586
+ return /\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|tiff?|webp)$/i.test(name) ? 'image' : 'file';
587
+ }
588
+ function encodeCursor(value) {
589
+ return Buffer.from(JSON.stringify(value)).toString('base64url');
590
+ }
591
+ function decodeCursor(value) {
592
+ try {
593
+ const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
594
+ const row = parsed;
595
+ if (row.v !== 1 || typeof row.ref !== 'string' || typeof row.revision !== 'string'
596
+ || !Number.isInteger(row.nextOrdinal) || Number(row.nextOrdinal) < 0)
597
+ throw new Error();
598
+ return row;
599
+ }
600
+ catch {
601
+ throw new ReferenceAnythingError('reference cursor is malformed', 'REFERENCE_INVALID_CURSOR');
602
+ }
603
+ }
604
+ function parseTimestamp(value) {
605
+ if (!value)
606
+ return undefined;
607
+ const number = Number(value);
608
+ if (Number.isFinite(number))
609
+ return number < 10_000_000_000 ? number * 1000 : number;
610
+ const date = Date.parse(value);
611
+ return Number.isNaN(date) ? undefined : date;
612
+ }
613
+ /** Return the original representation of the newest valid timestamp. */
614
+ function newestTimestamp(values) {
615
+ let newest = '';
616
+ let newestAt;
617
+ for (const value of values) {
618
+ const at = parseTimestamp(value);
619
+ if (at !== undefined && (newestAt === undefined || at > newestAt)) {
620
+ newest = value;
621
+ newestAt = at;
622
+ }
623
+ }
624
+ return newest;
625
+ }
626
+ /** Select the newest row while ignoring blank or malformed timestamps. */
627
+ function newestByTimestamp(rows, timestamp) {
628
+ let newest;
629
+ let newestAt;
630
+ for (const row of rows) {
631
+ const at = parseTimestamp(timestamp(row));
632
+ if (at !== undefined && (newestAt === undefined || at > newestAt)) {
633
+ newest = row;
634
+ newestAt = at;
635
+ }
636
+ }
637
+ return newest;
638
+ }