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,91 @@
1
+ import type { ChatProvider } from '../store/spec.ts';
2
+ import { ConversationStore } from '../store/store.ts';
3
+ import { OpenCliRunner } from '../opencli.ts';
4
+ export type SyncMode = 'incremental' | 'full';
5
+ export interface ProviderSyncProgress {
6
+ provider: ChatProvider;
7
+ phase: 'listing' | 'syncing' | 'complete' | 'failed' | 'cancelled';
8
+ completed: number;
9
+ total: number;
10
+ error?: string;
11
+ }
12
+ export interface SyncJobStatus {
13
+ jobId: string;
14
+ /** `partial` means at least one provider succeeded and at least one did not. */
15
+ status: 'running' | 'complete' | 'partial' | 'cancelled' | 'failed';
16
+ providers: readonly ChatProvider[];
17
+ provider?: ChatProvider;
18
+ completed: number;
19
+ total: number;
20
+ providerProgress: readonly ProviderSyncProgress[];
21
+ error?: string;
22
+ }
23
+ /** Knobs the background scheduler needs and the RPC surface deliberately does not expose. */
24
+ export interface SyncStartOptions {
25
+ /**
26
+ * Abort the job once it has run this long. Auto-sync sets one because its
27
+ * own gate is {@link ConversationSyncManager.isRunning} — a job that hangs
28
+ * forever would otherwise silently stop every later tick.
29
+ */
30
+ deadlineMs?: number;
31
+ }
32
+ /** The slice of cordis's logger this manager uses, kept structural so tests need no context. */
33
+ export interface SyncLogger {
34
+ info(message: string): void;
35
+ warn(message: string): void;
36
+ }
37
+ /** How stale a complete enumeration may get before incremental listing is refused. */
38
+ export declare const FULL_SCAN_INTERVAL_MS: number;
39
+ export declare class ConversationSyncManager {
40
+ readonly store: ConversationStore;
41
+ readonly runnerFactory: () => OpenCliRunner;
42
+ private readonly logger?;
43
+ private readonly jobs;
44
+ private lastCollectAt;
45
+ constructor(store: ConversationStore, runnerFactory: () => OpenCliRunner, logger?: SyncLogger | undefined);
46
+ start(providers: readonly ChatProvider[], mode: SyncMode, options?: SyncStartOptions): string;
47
+ status(jobId: string): SyncJobStatus | undefined;
48
+ cancel(jobId: string): boolean;
49
+ /**
50
+ * Whether any job — manual or auto-sync-triggered — is still touching the
51
+ * store. Stays true after a cancel until the workers have actually stopped,
52
+ * so a delete cannot interleave with a revision commit that is still landing.
53
+ */
54
+ isRunning(): boolean;
55
+ /**
56
+ * Providers auto-sync should attempt right now: ones that have completed a
57
+ * scan under some account, minus any still inside a failure backoff window.
58
+ * @returns the eligible providers, or an empty list when none are.
59
+ */
60
+ eligibleProviders(candidates: readonly ChatProvider[], now?: number): ChatProvider[];
61
+ private pruneFinishedJobs;
62
+ private run;
63
+ /** Revision GC, rate-limited: it walks every revision and nothing expires in minutes. */
64
+ private collect;
65
+ private syncProvider;
66
+ /**
67
+ * Mirror one conversation.
68
+ *
69
+ * The provider's `updatedAt` is the only pre-fetch change signal there is
70
+ * ({@link ConversationStore.needsDetail}), so it may only be persisted once
71
+ * the transcript it describes has actually landed. Writing it first — as
72
+ * this used to — left a conversation whose detail fetch failed looking
73
+ * up-to-date forever, pinned to a stale revision until someone ran a full
74
+ * rescan by hand.
75
+ */
76
+ private syncConversation;
77
+ /**
78
+ * Watermark for an incremental listing, or `''` to demand a full one.
79
+ * @returns an ISO instant the adapter may page back to, exclusive of slack.
80
+ */
81
+ private listingSince;
82
+ private progressWriter;
83
+ /** Newest durable account scope for a provider, used only as an incremental listing hint. */
84
+ private latestAccountScope;
85
+ private providerProgress;
86
+ private saveSyncState;
87
+ }
88
+ /** Backoff after `n` consecutive provider failures: 30m, 1h, 2h… capped at a day. */
89
+ export declare function backoffMs(consecutiveFailures: number): number;
90
+ export { OpenCliRunner } from '../opencli.ts';
91
+ export type { ChatProvider } from '../store/spec.ts';
@@ -0,0 +1,425 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { ConversationStore } from "../store/store.js";
3
+ import { OpenCliError, OpenCliRunner } from "../opencli.js";
4
+ /** How long a finished job stays visible to {@link ConversationSyncManager.status} before it is swept. */
5
+ const PRUNE_JOB_AFTER_MS = 10 * 60_000;
6
+ /**
7
+ * Minimum gap between durable progress writes. Under the JSON storage backend
8
+ * every record write re-serializes and fsyncs the whole domain, so writing
9
+ * once per conversation made progress reporting cost more than the sync.
10
+ */
11
+ const PROGRESS_WRITE_INTERVAL_MS = 2_000;
12
+ /** Least often revision GC runs; it walks every revision, and nothing expires in minutes. */
13
+ const COLLECT_INTERVAL_MS = 6 * 60 * 60_000;
14
+ /** How stale a complete enumeration may get before incremental listing is refused. */
15
+ export const FULL_SCAN_INTERVAL_MS = 24 * 60 * 60_000;
16
+ /** Slack subtracted from the incremental listing watermark, covering clock skew and in-flight edits. */
17
+ const LISTING_OVERLAP_MS = 10 * 60_000;
18
+ /** First backoff step after a provider-level failure; doubles per consecutive failure. */
19
+ const BACKOFF_BASE_MS = 30 * 60_000;
20
+ const BACKOFF_MAX_MS = 24 * 60 * 60_000;
21
+ /** Conversation-level failures tolerated before the provider itself is called failed. */
22
+ const MIN_TOLERATED_FAILURES = 10;
23
+ const FAILURE_TOLERANCE_RATIO = 0.25;
24
+ export class ConversationSyncManager {
25
+ store;
26
+ runnerFactory;
27
+ logger;
28
+ jobs = new Map();
29
+ lastCollectAt = 0;
30
+ constructor(store, runnerFactory, logger) {
31
+ this.store = store;
32
+ this.runnerFactory = runnerFactory;
33
+ this.logger = logger;
34
+ }
35
+ start(providers, mode, options = {}) {
36
+ if (providers.length === 0)
37
+ throw new Error('at least one provider is required');
38
+ this.pruneFinishedJobs();
39
+ const jobId = randomUUID();
40
+ const job = {
41
+ jobId, status: 'running', providers: [...new Set(providers)],
42
+ completed: 0, total: 0,
43
+ providerProgress: [...new Set(providers)].map(provider => ({ provider, phase: 'listing', completed: 0, total: 0 })),
44
+ active: true, controller: new AbortController(),
45
+ };
46
+ this.jobs.set(jobId, job);
47
+ void this.run(job, mode, options);
48
+ return jobId;
49
+ }
50
+ status(jobId) {
51
+ const job = this.jobs.get(jobId);
52
+ if (!job)
53
+ return undefined;
54
+ const { controller: _controller, completedAt: _completedAt, active: _active, ...status } = job;
55
+ return { ...status, providerProgress: status.providerProgress.map(progress => ({ ...progress })) };
56
+ }
57
+ cancel(jobId) {
58
+ const job = this.jobs.get(jobId);
59
+ if (!job || !job.active)
60
+ return false;
61
+ job.controller.abort(new Error('sync cancelled'));
62
+ return true;
63
+ }
64
+ /**
65
+ * Whether any job — manual or auto-sync-triggered — is still touching the
66
+ * store. Stays true after a cancel until the workers have actually stopped,
67
+ * so a delete cannot interleave with a revision commit that is still landing.
68
+ */
69
+ isRunning() {
70
+ for (const job of this.jobs.values())
71
+ if (job.active)
72
+ return true;
73
+ return false;
74
+ }
75
+ /**
76
+ * Providers auto-sync should attempt right now: ones that have completed a
77
+ * scan under some account, minus any still inside a failure backoff window.
78
+ * @returns the eligible providers, or an empty list when none are.
79
+ */
80
+ eligibleProviders(candidates, now = Date.now()) {
81
+ const proven = new Set();
82
+ const blocked = new Map();
83
+ for (const [, row] of this.store.syncStates.entries()) {
84
+ if (row.lastCompleteScanAt)
85
+ proven.add(row.provider);
86
+ const until = Date.parse(row.nextEligibleAt || '');
87
+ if (!Number.isNaN(until) && until > now) {
88
+ blocked.set(row.provider, Math.max(blocked.get(row.provider) ?? 0, until));
89
+ }
90
+ }
91
+ // Nothing has ever completed a scan: this is a first run (or a fresh
92
+ // profile), and refusing every provider would mean auto-sync never
93
+ // discovers which ones are usable.
94
+ const pool = proven.size === 0 ? candidates : candidates.filter(provider => proven.has(provider));
95
+ return pool.filter(provider => !blocked.has(provider));
96
+ }
97
+ pruneFinishedJobs() {
98
+ const cutoff = Date.now() - PRUNE_JOB_AFTER_MS;
99
+ for (const [jobId, job] of this.jobs) {
100
+ if (job.completedAt !== undefined && job.completedAt < cutoff)
101
+ this.jobs.delete(jobId);
102
+ }
103
+ }
104
+ async run(job, mode, options) {
105
+ const watchdog = options.deadlineMs === undefined ? undefined : setTimeout(() => {
106
+ this.logger?.warn(`reference sync: job ${job.jobId} exceeded ${String(options.deadlineMs)}ms; aborting`);
107
+ job.controller.abort(new Error('sync exceeded its deadline'));
108
+ }, options.deadlineMs);
109
+ const failures = new Map();
110
+ let succeeded = 0;
111
+ try {
112
+ await Promise.all(job.providers.map(async (provider) => {
113
+ if (job.controller.signal.aborted)
114
+ return;
115
+ const started = Date.now();
116
+ try {
117
+ await this.syncProvider(job, provider, mode, options);
118
+ succeeded++;
119
+ this.logger?.info(`reference sync: ${provider} finished in ${String(Date.now() - started)}ms`);
120
+ }
121
+ catch (error) {
122
+ // One provider must not decide the fate of the others: a single
123
+ // logged-out account would otherwise permanently starve every
124
+ // provider after it in this list.
125
+ if (job.controller.signal.aborted)
126
+ return;
127
+ failures.set(provider, describe(error));
128
+ this.logger?.warn(`reference sync: ${provider} failed: ${describe(error)}`);
129
+ }
130
+ }));
131
+ job.status = job.controller.signal.aborted ? 'cancelled'
132
+ : failures.size === 0 ? 'complete'
133
+ : succeeded === 0 ? 'failed' : 'partial';
134
+ if (failures.size > 0) {
135
+ job.error = [...failures].map(([provider, message]) => `${provider}: ${message}`).join('; ');
136
+ }
137
+ if (job.status === 'complete')
138
+ await this.collect();
139
+ }
140
+ catch (error) {
141
+ job.status = job.controller.signal.aborted ? 'cancelled' : 'failed';
142
+ job.error = describe(error);
143
+ }
144
+ finally {
145
+ if (job.controller.signal.aborted) {
146
+ for (const progress of job.providerProgress) {
147
+ if (progress.phase === 'listing' || progress.phase === 'syncing')
148
+ progress.phase = 'cancelled';
149
+ }
150
+ }
151
+ if (watchdog !== undefined)
152
+ clearTimeout(watchdog);
153
+ job.active = false;
154
+ job.completedAt = Date.now();
155
+ }
156
+ }
157
+ /** Revision GC, rate-limited: it walks every revision and nothing expires in minutes. */
158
+ async collect() {
159
+ const now = Date.now();
160
+ if (now - this.lastCollectAt < COLLECT_INTERVAL_MS)
161
+ return;
162
+ this.lastCollectAt = now;
163
+ try {
164
+ await this.store.collectExpired(now);
165
+ }
166
+ catch (error) {
167
+ // The mirror is already correct; failing to reclaim an expired revision
168
+ // is not a reason to report a completed sync as failed.
169
+ this.logger?.warn(`reference sync: revision cleanup failed: ${describe(error)}`);
170
+ }
171
+ }
172
+ async syncProvider(job, provider, mode, options) {
173
+ const runner = this.runnerFactory();
174
+ const signal = job.controller.signal;
175
+ let accountScope = '';
176
+ let progress;
177
+ try {
178
+ const sinceFor = (scope) => mode === 'incremental' ? this.listingSince(provider, scope) : '';
179
+ let rows;
180
+ let since = '';
181
+ if (typeof runner.syncIndex === 'function') {
182
+ // The first pass has no account scope yet, so use the newest known
183
+ // scope for this provider only as a listing hint. The adapter returns
184
+ // the authoritative scope together with the rows in the same tab.
185
+ const knownScope = this.latestAccountScope(provider);
186
+ const index = await retry(() => runner.syncIndex(provider, signal, sinceFor(knownScope), knownScope), signal);
187
+ accountScope = index.accountScope;
188
+ this.store.setActiveAccountScope(provider, accountScope);
189
+ rows = index.rows;
190
+ since = index.sinceApplied;
191
+ }
192
+ else {
193
+ // Compatibility for injected runners and adapters predating
194
+ // `sync-index`; production OpenCliRunner always takes the branch above.
195
+ accountScope = await retry(() => runner.whoami(provider, signal), signal);
196
+ this.store.setActiveAccountScope(provider, accountScope);
197
+ since = sinceFor(accountScope);
198
+ rows = await retry(() => runner.history(provider, signal, since), signal);
199
+ }
200
+ await this.store.syncStates.delete(`${provider}:pending`);
201
+ // Incremental means incremental at both levels: list only recently
202
+ // changed rows when a trustworthy watermark exists, then fetch detail
203
+ // only for rows whose provider timestamp changed. Full always enumerates
204
+ // and re-fetches everything, replacing each current local revision.
205
+ const seen = new Set(rows.map(row => row.id));
206
+ job.total += rows.length;
207
+ const providerProgress = this.providerProgress(job, provider);
208
+ providerProgress.phase = 'syncing';
209
+ providerProgress.total = rows.length;
210
+ progress = this.progressWriter(provider, accountScope, job, rows.length);
211
+ await progress.save('running');
212
+ const tolerated = Math.max(MIN_TOLERATED_FAILURES, Math.floor(rows.length * FAILURE_TOLERANCE_RATIO));
213
+ const failures = [];
214
+ let fatal;
215
+ let next = 0;
216
+ const concurrency = Math.max(1, Math.min(8, this.store.settings.detailConcurrency));
217
+ await Promise.all(Array.from({ length: concurrency }, async () => {
218
+ while (next < rows.length && !signal.aborted && fatal === undefined) {
219
+ const row = rows[next++];
220
+ try {
221
+ await this.syncConversation(runner, provider, accountScope, row, mode === 'full', signal);
222
+ progress?.advance();
223
+ await progress?.tick();
224
+ }
225
+ catch (error) {
226
+ if (signal.aborted)
227
+ return;
228
+ // A broken account or adapter is not going to fix itself on the
229
+ // next row, so it stops the provider outright. One conversation
230
+ // the provider cannot render is just one conversation.
231
+ if (isFatal(error)) {
232
+ fatal = error;
233
+ return;
234
+ }
235
+ failures.push(`${row.id}: ${describe(error)}`);
236
+ if (failures.length > tolerated) {
237
+ fatal = new Error(`${String(failures.length)} conversations failed; ${summarize(failures)}`);
238
+ return;
239
+ }
240
+ }
241
+ }
242
+ }));
243
+ if (fatal !== undefined)
244
+ throw fatal;
245
+ if (signal.aborted)
246
+ throw signal.reason;
247
+ // Retiring rows the provider no longer lists needs the complete set of
248
+ // its ids. An incremental listing does not have one, and neither does a
249
+ // pass that could not read every conversation.
250
+ const enumerated = since === '' && failures.length === 0;
251
+ if (enumerated)
252
+ await this.store.markRemoteMissing(provider, accountScope, seen);
253
+ await progress.save('idle', {
254
+ cursor: rows.at(-1)?.cursor ?? '',
255
+ complete: enumerated,
256
+ ...(failures.length > 0 ? { error: `${String(failures.length)} conversations failed; ${summarize(failures)}` } : {}),
257
+ });
258
+ providerProgress.phase = 'complete';
259
+ }
260
+ catch (error) {
261
+ const cancelled = signal.aborted;
262
+ const providerProgress = this.providerProgress(job, provider);
263
+ providerProgress.phase = cancelled ? 'cancelled' : 'failed';
264
+ providerProgress.error = describe(error);
265
+ await this.saveSyncState(provider, accountScope, cancelled ? 'cancelled' : 'failed', { completed: progress?.completed ?? 0, total: progress?.total ?? 0 }, { error: describe(error), ...(cancelled ? {} : { failure: true }) });
266
+ throw error;
267
+ }
268
+ }
269
+ /**
270
+ * Mirror one conversation.
271
+ *
272
+ * The provider's `updatedAt` is the only pre-fetch change signal there is
273
+ * ({@link ConversationStore.needsDetail}), so it may only be persisted once
274
+ * the transcript it describes has actually landed. Writing it first — as
275
+ * this used to — left a conversation whose detail fetch failed looking
276
+ * up-to-date forever, pinned to a stale revision until someone ran a full
277
+ * rescan by hand.
278
+ */
279
+ async syncConversation(runner, provider, accountScope, row, full, signal) {
280
+ if (this.store.settings.historyMode === 'metadata-only') {
281
+ await this.store.putConversation(row, accountScope);
282
+ return;
283
+ }
284
+ const key = ConversationStore.conversationKey(provider, accountScope, row.id);
285
+ const known = this.store.conversations.get(key);
286
+ if (!this.store.needsDetail(key, row, full)) {
287
+ // Metadata refresh only, and `putConversation` skips the write entirely
288
+ // when nothing actually differs.
289
+ await this.store.putConversation(row, accountScope);
290
+ return;
291
+ }
292
+ // A conversation with no row yet has no watermark to corrupt, and
293
+ // `commitRevision` needs the row to exist.
294
+ if (!known)
295
+ await this.store.putConversation(row, accountScope);
296
+ const detail = await retry(() => runner.detail(provider, row.id, signal, accountScope), signal);
297
+ await this.store.commitRevision(key, detail, row);
298
+ }
299
+ /**
300
+ * Watermark for an incremental listing, or `''` to demand a full one.
301
+ * @returns an ISO instant the adapter may page back to, exclusive of slack.
302
+ */
303
+ listingSince(provider, accountScope) {
304
+ const state = this.store.syncStates.get(`${provider}:${accountScope}`);
305
+ const lastComplete = Date.parse(state?.lastCompleteScanAt ?? '');
306
+ const lastSync = Date.parse(state?.lastSyncAt ?? '');
307
+ if (Number.isNaN(lastComplete) || Number.isNaN(lastSync))
308
+ return '';
309
+ if (Date.now() - lastComplete >= FULL_SCAN_INTERVAL_MS)
310
+ return '';
311
+ return new Date(lastSync - LISTING_OVERLAP_MS).toISOString();
312
+ }
313
+ progressWriter(provider, accountScope, job, total) {
314
+ let completed = 0;
315
+ let lastWriteAt = 0;
316
+ const save = async (status, patch = {}) => {
317
+ // Stamped before the await so two workers cannot both pass `tick`'s gate.
318
+ lastWriteAt = Date.now();
319
+ await this.saveSyncState(provider, accountScope, status, { completed, total }, patch);
320
+ };
321
+ return {
322
+ get completed() { return completed; },
323
+ get total() { return total; },
324
+ advance: () => {
325
+ completed++;
326
+ job.completed++;
327
+ this.providerProgress(job, provider).completed = completed;
328
+ },
329
+ save,
330
+ async tick() {
331
+ if (Date.now() - lastWriteAt < PROGRESS_WRITE_INTERVAL_MS)
332
+ return;
333
+ await save('running');
334
+ },
335
+ };
336
+ }
337
+ /** Newest durable account scope for a provider, used only as an incremental listing hint. */
338
+ latestAccountScope(provider) {
339
+ let scope = '';
340
+ let newest = 0;
341
+ for (const [, state] of this.store.syncStates.entries()) {
342
+ if (state.provider !== provider || !state.accountScope)
343
+ continue;
344
+ const at = Date.parse(state.lastSyncAt || '');
345
+ if (!Number.isNaN(at) && at >= newest) {
346
+ newest = at;
347
+ scope = state.accountScope;
348
+ }
349
+ }
350
+ return scope;
351
+ }
352
+ providerProgress(job, provider) {
353
+ const progress = job.providerProgress.find(row => row.provider === provider);
354
+ if (!progress)
355
+ throw new Error(`missing sync progress for ${provider}`);
356
+ return progress;
357
+ }
358
+ async saveSyncState(provider, accountScope, status, counts, patch = {}) {
359
+ const now = Date.now();
360
+ const stamp = new Date(now).toISOString();
361
+ const key = `${provider}:${accountScope || 'pending'}`;
362
+ const prior = this.store.syncStates.get(key);
363
+ const consecutiveFailures = patch.failure === true ? (prior?.consecutiveFailures ?? 0) + 1
364
+ : status === 'idle' ? 0 : prior?.consecutiveFailures ?? 0;
365
+ await this.store.syncStates.put(key, {
366
+ provider, profile: this.store.settings.profile, accountScope,
367
+ // `put` replaces the whole record, so a failed attempt would otherwise
368
+ // erase the watermark a successful one left behind.
369
+ cursor: patch.cursor ?? prior?.cursor ?? '',
370
+ status, lastSyncAt: stamp,
371
+ lastCompleteScanAt: patch.complete === true ? stamp : prior?.lastCompleteScanAt || '',
372
+ error: patch.error ?? '',
373
+ completed: counts.completed, total: counts.total,
374
+ consecutiveFailures,
375
+ nextEligibleAt: patch.failure === true
376
+ ? new Date(now + backoffMs(consecutiveFailures)).toISOString()
377
+ : '',
378
+ });
379
+ }
380
+ }
381
+ /** Backoff after `n` consecutive provider failures: 30m, 1h, 2h… capped at a day. */
382
+ export function backoffMs(consecutiveFailures) {
383
+ if (consecutiveFailures <= 0)
384
+ return 0;
385
+ return Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (consecutiveFailures - 1));
386
+ }
387
+ async function retry(operation, signal) {
388
+ let delay = 500;
389
+ for (let attempt = 0;; attempt++) {
390
+ try {
391
+ return await operation();
392
+ }
393
+ catch (error) {
394
+ if (signal.aborted || attempt >= 4 || !isRetryable(error))
395
+ throw error;
396
+ await new Promise((resolve, reject) => {
397
+ let timer;
398
+ const onAbort = () => { clearTimeout(timer); reject(signal.reason); };
399
+ // Removed on the normal path too: one long job shares a single signal
400
+ // across every provider and worker, so listeners left behind by
401
+ // resolved sleeps accumulate for its whole lifetime.
402
+ timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, delay);
403
+ signal.addEventListener('abort', onAbort, { once: true });
404
+ });
405
+ delay = Math.min(delay * 2, 8_000);
406
+ }
407
+ }
408
+ }
409
+ function isRetryable(error) {
410
+ return error instanceof OpenCliError && (error.code === 'OPENCLI_FAILED' || error.code === 'PROVIDER_TIMEOUT');
411
+ }
412
+ /** Failures that describe the provider or its adapter, not one conversation. */
413
+ function isFatal(error) {
414
+ return error instanceof OpenCliError && (error.code === 'OPENCLI_CONFIGURATION'
415
+ || error.code === 'PROVIDER_NOT_LOGGED_IN' || error.code === 'PROVIDER_ACCOUNT_MISMATCH'
416
+ || error.code === 'EXTENSION_NOT_CONNECTED');
417
+ }
418
+ function summarize(failures) {
419
+ const head = failures.slice(0, 3).join('; ');
420
+ return failures.length > 3 ? `${head}; …` : head;
421
+ }
422
+ function describe(error) {
423
+ return error instanceof Error ? error.message : String(error);
424
+ }
425
+ export { OpenCliRunner } from "../opencli.js";
package/lib/tool.d.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Model-facing tools for finding and reading outside conversations.
3
+ *
4
+ * The mention expander covers the case where the user already knows which
5
+ * conversation they mean. These cover the case where the model has to go
6
+ * looking — "check what we decided in that chat about the cache" — and they
7
+ * work on every surface, including headless runs with no interactive input.
8
+ *
9
+ * @module dsh-reference-anything/tool
10
+ */
11
+ import type { Context } from '@deepseek-ai/cordis';
12
+ import z from '@deepseek-ai/schemastery';
13
+ import type { ReferenceWindow } from './types.ts';
14
+ /** Cordis plugin name used by loader diagnostics. */
15
+ export declare const name = "reference-tool";
16
+ /** The registries these tools read and register through. */
17
+ export declare const inject: string[];
18
+ /** Serialized byte budget for one tool-read conversation when none is configured. */
19
+ export declare const DEFAULT_MAX_OUTPUT_BYTES = 65536;
20
+ /** Cooperative deadline for both tools when none is configured. */
21
+ export declare const DEFAULT_TIMEOUT_MS = 30000;
22
+ /** Turns one read returns when the model names no limit. */
23
+ export declare const DEFAULT_READ_TURNS = 10;
24
+ /** Ceiling on the model-supplied turn limit when none is configured. */
25
+ export declare const DEFAULT_MAX_READ_TURNS = 100;
26
+ /** Deployment settings for the model-facing tools. */
27
+ export interface Config {
28
+ /** Items `reference_list` may return; also the cap the model cannot raise. */
29
+ listLimit?: number;
30
+ /** Serialized byte budget for one `reference_read` result. */
31
+ maxOutputBytes?: number;
32
+ /** Turns one read returns when the model names none. */
33
+ readTurns?: number;
34
+ /** Ceiling on the model-supplied turn limit; a larger request is refused. */
35
+ maxReadTurns?: number;
36
+ /** Cooperative deadline applied to both tools. */
37
+ timeoutMs?: number;
38
+ }
39
+ /** Runtime schema for {@link Config}. */
40
+ export declare const Config: z<Config>;
41
+ /**
42
+ * Register `reference_list` and `reference_read`.
43
+ * @param ctx - the mounting context.
44
+ * @param config - validated deployment settings.
45
+ */
46
+ export declare function apply(ctx: Context, config?: Config): void;
47
+ /**
48
+ * Turn the model's arguments into a window.
49
+ *
50
+ * The limit has a deployment ceiling and exceeding it is refused rather than
51
+ * silently clamped, so a model that asks for a hundred turns learns the bound
52
+ * instead of quietly receiving fifty and assuming that was all.
53
+ * @param args - validated tool arguments.
54
+ * @param fallback - turns to read when the model names none.
55
+ * @param ceiling - the largest limit this deployment allows.
56
+ * @returns the window to request.
57
+ */
58
+ export declare function parseWindow(args: {
59
+ limit?: number;
60
+ before?: number;
61
+ cursor?: string;
62
+ }, fallback: number, ceiling: number): ReferenceWindow;