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,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;
|