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.
- package/LICENSE +22 -0
- package/NOTICE.md +54 -0
- package/README.md +228 -0
- package/cordis.patch.yml +63 -0
- package/images/logo.png +0 -0
- package/lib/client/index.d.ts +3 -0
- package/lib/client.js +18151 -0
- package/lib/contract.d.ts +300 -0
- package/lib/contract.js +102 -0
- package/lib/errors.d.ts +56 -0
- package/lib/errors.js +21 -0
- package/lib/host.d.ts +102 -0
- package/lib/host.js +55 -0
- package/lib/index.d.ts +83 -0
- package/lib/index.js +153 -0
- package/lib/opencli.d.ts +116 -0
- package/lib/opencli.js +356 -0
- package/lib/reference.d.ts +49 -0
- package/lib/reference.js +228 -0
- package/lib/render.d.ts +101 -0
- package/lib/render.js +250 -0
- package/lib/retain.d.ts +35 -0
- package/lib/retain.js +116 -0
- package/lib/search.d.ts +75 -0
- package/lib/search.js +126 -0
- package/lib/serialize.d.ts +22 -0
- package/lib/serialize.js +24 -0
- package/lib/sources/file.d.ts +100 -0
- package/lib/sources/file.js +236 -0
- package/lib/sources/web-chat/index.d.ts +114 -0
- package/lib/sources/web-chat/index.js +359 -0
- package/lib/store/spec.d.ts +312 -0
- package/lib/store/spec.js +94 -0
- package/lib/store/store.d.ts +189 -0
- package/lib/store/store.js +638 -0
- package/lib/sync/index.d.ts +91 -0
- package/lib/sync/index.js +425 -0
- package/lib/tool.d.ts +62 -0
- package/lib/tool.js +364 -0
- package/lib/typert.d.ts +2 -0
- package/lib/typert.js +34 -0
- package/lib/types.d.ts +215 -0
- package/lib/types.js +12 -0
- package/lib/uri-codec.d.ts +5 -0
- package/lib/uri-codec.js +39 -0
- package/lib/uri.d.ts +59 -0
- package/lib/uri.js +86 -0
- package/lib/web.d.ts +4 -0
- package/lib/web.js +11 -0
- package/lib/window.d.ts +23 -0
- package/lib/window.js +35 -0
- package/lib/wire.d.ts +117 -0
- package/lib/wire.js +49 -0
- package/lib/workspace.d.ts +18 -0
- package/lib/workspace.js +81 -0
- package/opencli-plugin/chatgpt.js +125 -0
- package/opencli-plugin/claude.js +134 -0
- package/opencli-plugin/common.js +307 -0
- package/opencli-plugin/deepseek.js +152 -0
- package/opencli-plugin/gemini.js +165 -0
- package/opencli-plugin/grok.js +123 -0
- package/opencli-plugin/kimi.js +123 -0
- package/opencli-plugin/opencli-plugin.json +6 -0
- package/opencli-plugin/package.json +8 -0
- package/opencli-plugin/since-guard.js +45 -0
- 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
|
+
}
|