dsh-reference-anything 0.3.1 → 0.3.3

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 (107) hide show
  1. package/LICENSE +22 -22
  2. package/NOTICE.md +52 -25
  3. package/README.md +342 -284
  4. package/cordis.patch.yml +89 -18
  5. package/lib/client.js +13534 -521
  6. package/lib/contract.d.ts +155 -3
  7. package/lib/contract.js +68 -4
  8. package/lib/download-directory.d.ts +18 -0
  9. package/lib/download-directory.js +55 -0
  10. package/lib/errors.d.ts +4 -0
  11. package/lib/host.d.ts +96 -3
  12. package/lib/host.js +88 -5
  13. package/lib/index.d.ts +22 -2
  14. package/lib/index.js +57 -7
  15. package/lib/opencli.d.ts +5 -3
  16. package/lib/opencli.js +13 -6
  17. package/lib/openlist/host.d.ts +157 -0
  18. package/lib/openlist/host.js +873 -0
  19. package/lib/openlist/index.d.ts +84 -0
  20. package/lib/openlist/index.js +507 -0
  21. package/lib/render.d.ts +12 -7
  22. package/lib/render.js +27 -21
  23. package/lib/sources/cloud-drive/cache.d.ts +60 -0
  24. package/lib/sources/cloud-drive/cache.js +107 -0
  25. package/lib/sources/cloud-drive/index.d.ts +190 -0
  26. package/lib/sources/cloud-drive/index.js +526 -0
  27. package/lib/sources/cloud-drive/providers/http.d.ts +27 -0
  28. package/lib/sources/cloud-drive/providers/http.js +73 -0
  29. package/lib/sources/cloud-drive/providers/openlist.d.ts +59 -0
  30. package/lib/sources/cloud-drive/providers/openlist.js +535 -0
  31. package/lib/sources/cloud-drive/registry.d.ts +53 -0
  32. package/lib/sources/cloud-drive/registry.js +101 -0
  33. package/lib/sources/cloud-drive/types.d.ts +169 -0
  34. package/lib/sources/cloud-drive/types.js +17 -0
  35. package/lib/sources/file.d.ts +2 -0
  36. package/lib/sources/file.js +2 -0
  37. package/lib/sources/local-agent/adapters/claude-code.d.ts +16 -0
  38. package/lib/sources/local-agent/adapters/claude-code.js +171 -0
  39. package/lib/sources/local-agent/adapters/codex.d.ts +20 -0
  40. package/lib/sources/local-agent/adapters/codex.js +204 -0
  41. package/lib/sources/local-agent/adapters/cursor.d.ts +23 -0
  42. package/lib/sources/local-agent/adapters/cursor.js +121 -0
  43. package/lib/sources/local-agent/adapters/gemini-cli.d.ts +20 -0
  44. package/lib/sources/local-agent/adapters/gemini-cli.js +141 -0
  45. package/lib/sources/local-agent/adapters/grokbuild.d.ts +22 -0
  46. package/lib/sources/local-agent/adapters/grokbuild.js +76 -0
  47. package/lib/sources/local-agent/adapters/hermes.d.ts +19 -0
  48. package/lib/sources/local-agent/adapters/hermes.js +85 -0
  49. package/lib/sources/local-agent/adapters/kimi.d.ts +28 -0
  50. package/lib/sources/local-agent/adapters/kimi.js +261 -0
  51. package/lib/sources/local-agent/adapters/openclaw.d.ts +21 -0
  52. package/lib/sources/local-agent/adapters/openclaw.js +120 -0
  53. package/lib/sources/local-agent/adapters/opencode.d.ts +44 -0
  54. package/lib/sources/local-agent/adapters/opencode.js +236 -0
  55. package/lib/sources/local-agent/adapters/pi.d.ts +26 -0
  56. package/lib/sources/local-agent/adapters/pi.js +242 -0
  57. package/lib/sources/local-agent/adapters/qoder.d.ts +19 -0
  58. package/lib/sources/local-agent/adapters/qoder.js +93 -0
  59. package/lib/sources/local-agent/adapters/reasonix.d.ts +23 -0
  60. package/lib/sources/local-agent/adapters/reasonix.js +90 -0
  61. package/lib/sources/local-agent/adapters/shared.d.ts +163 -0
  62. package/lib/sources/local-agent/adapters/shared.js +415 -0
  63. package/lib/sources/local-agent/adapters/sqlite-shared.d.ts +92 -0
  64. package/lib/sources/local-agent/adapters/sqlite-shared.js +215 -0
  65. package/lib/sources/local-agent/adapters/zcode.d.ts +41 -0
  66. package/lib/sources/local-agent/adapters/zcode.js +159 -0
  67. package/lib/sources/local-agent/index.d.ts +222 -0
  68. package/lib/sources/local-agent/index.js +656 -0
  69. package/lib/sources/local-agent/page.d.ts +55 -0
  70. package/lib/sources/local-agent/page.js +83 -0
  71. package/lib/sources/local-agent/path.d.ts +8 -0
  72. package/lib/sources/local-agent/path.js +43 -0
  73. package/lib/sources/local-agent/registry.d.ts +68 -0
  74. package/lib/sources/local-agent/registry.js +116 -0
  75. package/lib/sources/local-agent/scan.d.ts +175 -0
  76. package/lib/sources/local-agent/scan.js +472 -0
  77. package/lib/sources/local-agent/sqlite.d.ts +45 -0
  78. package/lib/sources/local-agent/sqlite.js +113 -0
  79. package/lib/sources/local-agent/store.d.ts +93 -0
  80. package/lib/sources/local-agent/store.js +113 -0
  81. package/lib/sources/local-agent/types.d.ts +338 -0
  82. package/lib/sources/local-agent/types.js +20 -0
  83. package/lib/sources/web-chat/index.d.ts +5 -1
  84. package/lib/sources/web-chat/index.js +47 -8
  85. package/lib/store/spec.d.ts +50 -0
  86. package/lib/store/spec.js +2 -2
  87. package/lib/sync/index.d.ts +1 -0
  88. package/lib/sync/index.js +28 -8
  89. package/lib/tool.d.ts +5 -0
  90. package/lib/tool.js +418 -45
  91. package/lib/typert.js +13 -1
  92. package/lib/types.d.ts +28 -2
  93. package/lib/update.d.ts +6 -0
  94. package/lib/update.js +29 -1
  95. package/lib/web.js +5 -1
  96. package/lib/wire.d.ts +80 -0
  97. package/lib/wire.js +30 -1
  98. package/opencli-plugin/chatgpt.js +30 -27
  99. package/opencli-plugin/claude.js +12 -8
  100. package/opencli-plugin/common.js +266 -187
  101. package/opencli-plugin/deepseek.js +8 -5
  102. package/opencli-plugin/gemini.js +42 -38
  103. package/opencli-plugin/grok.js +22 -21
  104. package/opencli-plugin/kimi.js +34 -31
  105. package/opencli-plugin/opencli-plugin.json +6 -6
  106. package/opencli-plugin/package.json +8 -8
  107. package/package.json +200 -185
@@ -0,0 +1,526 @@
1
+ /**
2
+ * Reference source over files kept in a personal cloud drive.
3
+ *
4
+ * Bringing a 网盘 file into a session used to mean downloading it into the
5
+ * workspace first. This registers the drive itself as a referenceable source:
6
+ * the `@` menu lists what is there, and the bytes are fetched only when the
7
+ * model asks for one by reference.
8
+ *
9
+ * Two properties shape everything below. The first is that these are the
10
+ * user's own remote files, so reading one needs a per-task grant and no
11
+ * credential or signed URL may appear in anything a summary, a log, or an
12
+ * error carries. The second is that a drive holds binaries, so text decoding
13
+ * stays allowlisted while selected document and image formats are exposed as
14
+ * bounded, on-demand attachments rather than emitted as mojibake.
15
+ *
16
+ * @module dsh-reference-anything/cloud-drive
17
+ */
18
+ import { Context, Service } from '@deepseek-ai/cordis';
19
+ import z from '@deepseek-ai/schemastery';
20
+ import { ReferenceAnythingError } from "../../errors.js";
21
+ import { compareMatches, scoreTitle } from "../../search.js";
22
+ import { sliceTurns } from "../../window.js";
23
+ import { DriveCache } from "./cache.js";
24
+ import { DRIVE_KINDS, decodeDriveId, encodeDriveId, providerFor } from "./registry.js";
25
+ /** Cordis plugin name used by loader diagnostics. */
26
+ export const name = 'reference-cloud-drive';
27
+ /** The services this source is built on. */
28
+ export const inject = ['references', 'openListManager'];
29
+ /** Registry id, and the `source` half of every reference this source owns. */
30
+ export const CLOUD_DRIVE_SOURCE_ID = 'cloud-drive';
31
+ /**
32
+ * Extensions this source will decode.
33
+ *
34
+ * An allowlist rather than a denylist: a drive is mostly photos and archives,
35
+ * and the failure mode of guessing wrong is a screenful of replacement
36
+ * characters presented to the model as if it were the document.
37
+ */
38
+ const TEXT_EXTENSIONS = [
39
+ '.txt', '.md', '.markdown', '.rst', '.org', '.tex',
40
+ '.json', '.jsonl', '.ndjson', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.env',
41
+ '.csv', '.tsv', '.log', '.diff', '.patch',
42
+ '.xml', '.html', '.htm', '.svg', '.css', '.scss', '.less',
43
+ '.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx', '.vue', '.svelte',
44
+ '.py', '.rb', '.php', '.go', '.rs', '.java', '.kt', '.kts', '.scala', '.swift',
45
+ '.c', '.h', '.cc', '.cpp', '.hpp', '.cs', '.m', '.mm',
46
+ '.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat',
47
+ '.sql', '.graphql', '.proto', '.lua', '.r', '.jl', '.dart', '.pl',
48
+ '.srt', '.vtt', '.ass',
49
+ ];
50
+ const ATTACHMENT_EXTENSIONS = new Set([
51
+ '.pdf', '.doc', '.docx', '.odt', '.rtf', '.epub',
52
+ '.xls', '.xlsx', '.xlsm', '.ods', '.csv',
53
+ '.ppt', '.pptx', '.odp',
54
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg',
55
+ ]);
56
+ /**
57
+ * Bytes sniffed for a NUL before the decode is trusted.
58
+ *
59
+ * A NUL inside the first few kilobytes of a UTF-8 text file is not something
60
+ * that happens; in a binary it almost always is.
61
+ */
62
+ const SNIFF_BYTES = 4096;
63
+ /** What a recalled passage is prefixed with, so it is never read as the file. */
64
+ const RECALL_NOTICE = 'The file itself could not be downloaded. What follows is a passage the drive\'s '
65
+ + 'search index recalled for this file — an extract chosen by the provider, not the document.';
66
+ /**
67
+ * Runtime schema for {@link Config}.
68
+ *
69
+ * The three read defaults are chosen together rather than independently:
70
+ * 64 KiB at 4000 characters a block is at most seventeen blocks, which fits
71
+ * inside `maxReadTurns`. That is what makes the default read of an ordinary
72
+ * text file return the whole of it, beginning at its beginning — raising
73
+ * `maxReadBytes` past that point buys reach at the cost of a first page that
74
+ * lands at the end of the file and pages backwards, which is the right shape
75
+ * for a conversation and an awkward one for a document.
76
+ */
77
+ export const Config = z.object({
78
+ drives: z.array(z.union(DRIVE_KINDS)).default(['openlist']),
79
+ root: z.string().default(''),
80
+ roots: z.dict(z.string()).default({}),
81
+ listTtlMs: z.natural().default(30_000),
82
+ maxReadBytes: z.natural().default(64 * 1024),
83
+ maxReadTurns: z.natural().default(20),
84
+ blockChars: z.natural().default(4000),
85
+ extensions: z.array(z.string()).default([...TEXT_EXTENSIONS]),
86
+ });
87
+ /** Lists and reads text files held in the user's cloud drives. */
88
+ export class CloudDriveService extends Service {
89
+ static inject = inject;
90
+ static Config = Config;
91
+ id = CLOUD_DRIVE_SOURCE_ID;
92
+ /**
93
+ * Gated: these are the user's own remote files. The model may read one only
94
+ * after its user named it.
95
+ */
96
+ requiresGrant = true;
97
+ settings;
98
+ providers;
99
+ cache;
100
+ extensions;
101
+ credentialGeneration = -1;
102
+ constructor(ctx, config = {}) {
103
+ super(ctx, 'referenceCloudDrive');
104
+ this.settings = { ...defaults(), ...definedOnly(config) };
105
+ const built = new Map();
106
+ for (const kind of this.settings.drives) {
107
+ const root = this.settings.roots[kind] ?? this.settings.root;
108
+ // Credentials are supplied by the Host-only OpenList service on each
109
+ // request. They never live in plugin config, settings snapshots, or the
110
+ // browser bundle.
111
+ const provider = providerFor(kind, { ...(root === '' ? {} : { root }), credentials: refresh => this.ctx.openListManager.credentials(refresh) });
112
+ if (provider === undefined) {
113
+ throw new ReferenceAnythingError(`reference-cloud-drive: "${kind}" has no transport in this build; remove it from "drives"`, 'REFERENCE_INVALID_CONFIG');
114
+ }
115
+ built.set(kind, provider);
116
+ }
117
+ this.providers = built;
118
+ this.cache = new DriveCache(this.settings.listTtlMs);
119
+ this.extensions = new Set(this.settings.extensions.map(value => value.toLocaleLowerCase()));
120
+ }
121
+ async [Service.init]() {
122
+ this.ctx.effect(() => this.ctx.references.registerSource(this), 'reference-cloud-drive.registerSource()');
123
+ }
124
+ /**
125
+ * @returns whether any configured drive holds a usable credential.
126
+ */
127
+ async available() {
128
+ this.invalidateForCredentialChange();
129
+ const checks = await Promise.all([...this.providers.values()].map(async (provider) => {
130
+ try {
131
+ return await provider.credentialed();
132
+ }
133
+ catch {
134
+ // A drive that cannot answer locally is not configured, which is
135
+ // ordinary rather than an error worth propagating to discovery.
136
+ return false;
137
+ }
138
+ }));
139
+ return checks.includes(true);
140
+ }
141
+ /**
142
+ * Enumerate drive files matching a query.
143
+ *
144
+ * Fans out across every configured drive at once and merges the results, so
145
+ * one slow or unreachable drive costs latency rather than emptying the menu
146
+ * of the others. Directories and files this source could not decode are
147
+ * dropped by the same rule: neither has text to read, so offering one would
148
+ * produce a reference that can only fail.
149
+ *
150
+ * @param query - free text; empty means the default directory listing.
151
+ * @param limit - hard cap on returned items.
152
+ * @param signal - cancellation from the caller.
153
+ */
154
+ async list(query, limit, signal) {
155
+ this.invalidateForCredentialChange();
156
+ const bounded = Math.max(0, Math.trunc(limit));
157
+ if (bounded === 0)
158
+ return [];
159
+ const trimmed = query.trim();
160
+ const collected = [];
161
+ await Promise.all([...this.providers.values()].map(async (provider) => {
162
+ const key = `${provider.kind}\0${trimmed}`;
163
+ const cached = this.cache.listing(key);
164
+ if (cached !== undefined) {
165
+ collected.push(...cached);
166
+ return;
167
+ }
168
+ try {
169
+ // A drive that has never been logged into is absent rather than
170
+ // broken. Worth a branch of its own now that OpenList is host-managed:
171
+ // `available()` speaks for the source as a whole, so one credentialed
172
+ // drive keeps the group alive and the other would otherwise warn on
173
+ // every keystroke. Local by contract, and behind the cache, so it
174
+ // costs a stat only when a listing is actually about to happen.
175
+ if (!await provider.credentialed())
176
+ return;
177
+ const entries = await provider.list(trimmed, bounded, signal);
178
+ const files = entries.filter(entry => !entry.isDirectory && this.readable(entry));
179
+ this.cache.remember(key, files);
180
+ collected.push(...files);
181
+ }
182
+ catch (cause) {
183
+ if (cause instanceof ReferenceAnythingError && cause.code === 'REFERENCE_CANCELLED')
184
+ return;
185
+ // Only the drive and the error class are reported. The message may
186
+ // quote a request URL, and a request URL carries the credential.
187
+ const code = cause instanceof ReferenceAnythingError ? cause.code : 'REFERENCE_READ_FAILED';
188
+ this.ctx.logger.warn(`reference cloud-drive: listing ${provider.kind} failed (${code})`);
189
+ }
190
+ }));
191
+ return rank(collected, trimmed).slice(0, bounded).map(entry => this.summarize(entry));
192
+ }
193
+ /**
194
+ * Read one window of a drive file, as text.
195
+ *
196
+ * @param ref - a reference this source owns.
197
+ * @param window - which blocks to return.
198
+ * @param signal - cancellation from the caller.
199
+ */
200
+ async read(ref, window, signal) {
201
+ this.invalidateForCredentialChange();
202
+ const { kind, fileId } = decodeDriveId(ref.id);
203
+ const provider = this.providers.get(kind);
204
+ if (provider === undefined) {
205
+ throw new ReferenceAnythingError(`cloud-drive: "${kind}" is not among the configured drives`, 'SOURCE_UNAVAILABLE');
206
+ }
207
+ const entry = this.cache.entry(kind, fileId) ?? await provider.describe(fileId, signal);
208
+ if (entry === undefined) {
209
+ throw new ReferenceAnythingError('cloud-drive: no such file', 'REFERENCE_NOT_FOUND');
210
+ }
211
+ if (entry.isDirectory) {
212
+ throw new ReferenceAnythingError(`cloud-drive: ${JSON.stringify(entry.name)} is a folder, which has no text to read`, 'REFERENCE_READ_FAILED');
213
+ }
214
+ if (!this.readable(entry) && ATTACHMENT_EXTENSIONS.has(extensionOf(entry.name))) {
215
+ const item = {
216
+ role: 'document',
217
+ text: `Binary cloud-drive document: ${entry.name}. Use reference_attachment_read with this reference URI and attachmentId "file" to materialize it on demand.`,
218
+ attachments: [{ attachmentId: 'file', kind: imageExtension(entry.name) ? 'image' : 'file', name: entry.name, mimeType: mimeForName(entry.name), size: entry.size, status: 'available' }],
219
+ };
220
+ return { ...this.summarize(entry), body: { kind: 'conversation', items: [item], startIndex: 0, totalTurns: 1, hasOlder: false }, partial: false, capturedAt: Date.now(), provider: provider.displayName };
221
+ }
222
+ this.assertTextual(entry);
223
+ const loaded = await this.load(provider, entry, signal);
224
+ const blocks = splitBlocks(loaded.text, this.settings.blockChars);
225
+ const items = blocks.map(text => ({ role: 'document', text }));
226
+ if (loaded.recalled)
227
+ items.unshift({ role: 'document', text: RECALL_NOTICE });
228
+ if (items.length === 0) {
229
+ // An empty success is indistinguishable from a broken reader, so say
230
+ // which of the two this is.
231
+ throw new ReferenceAnythingError(`cloud-drive: ${JSON.stringify(entry.name)} decoded to no text at all`, 'REFERENCE_READ_FAILED');
232
+ }
233
+ const body = sliceTurns(items, {
234
+ ...window,
235
+ limit: Math.min(Math.max(0, Math.trunc(window.limit)), this.settings.maxReadTurns),
236
+ });
237
+ return {
238
+ ...this.summarize(entry),
239
+ body,
240
+ partial: loaded.partial,
241
+ capturedAt: Date.now(),
242
+ provider: provider.displayName,
243
+ };
244
+ }
245
+ /**
246
+ * Project one entry for discovery.
247
+ *
248
+ * Deliberately carries no excerpt and no URL: `origin` is the drive's own
249
+ * display path, which is user-facing only, and everything else a drive
250
+ * knows about a file either identifies it or is a secret.
251
+ */
252
+ summarize(entry) {
253
+ const provider = this.providers.get(entry.kind);
254
+ return {
255
+ ref: { source: this.id, id: encodeDriveId(entry.kind, entry.id) },
256
+ label: entry.name,
257
+ ...(entry.path === '' ? {} : { origin: entry.path }),
258
+ ...(entry.modifiedAt === undefined ? {} : { updatedAt: entry.modifiedAt }),
259
+ ...(provider === undefined ? {} : { provider: provider.displayName }),
260
+ ...(entry.searchIncomplete === true ? { searchIncomplete: true } : {}),
261
+ ...(entry.isDirectory ? { isDirectory: true } : {}),
262
+ };
263
+ }
264
+ /** Download one selected binary document without exposing its signed URL. */
265
+ async attachment(ref, maxBytes, signal) {
266
+ const { kind, fileId } = decodeDriveId(ref.id);
267
+ const provider = this.providers.get(kind);
268
+ if (!provider)
269
+ throw new ReferenceAnythingError('cloud-drive provider is unavailable', 'SOURCE_UNAVAILABLE');
270
+ const entry = this.cache.entry(kind, fileId) ?? await provider.describe(fileId, signal);
271
+ if (!entry || entry.isDirectory)
272
+ throw new ReferenceAnythingError('cloud-drive file was not found', 'REFERENCE_NOT_FOUND');
273
+ if (!ATTACHMENT_EXTENSIONS.has(extensionOf(entry.name)))
274
+ throw new ReferenceAnythingError('cloud-drive file type is not supported as an attachment', 'REFERENCE_READ_FAILED');
275
+ if (entry.size > maxBytes)
276
+ throw new ReferenceAnythingError('cloud-drive attachment exceeds the download limit', 'ATTACHMENT_TOO_LARGE');
277
+ const result = await provider.read(entry.id, 0, maxBytes + 1, signal);
278
+ const describedTotal = entry.size > 0 ? entry.size : undefined;
279
+ if ((result.totalSize !== undefined && result.totalSize > maxBytes) || result.bytes.byteLength > maxBytes) {
280
+ throw new ReferenceAnythingError('cloud-drive attachment exceeds the download limit', 'ATTACHMENT_TOO_LARGE');
281
+ }
282
+ if (result.totalSize !== undefined && describedTotal !== undefined && result.totalSize !== describedTotal) {
283
+ throw new ReferenceAnythingError('cloud-drive attachment size did not match its indexed metadata', 'REFERENCE_READ_FAILED');
284
+ }
285
+ const total = result.totalSize ?? describedTotal;
286
+ if (result.ranged && total === undefined) {
287
+ throw new ReferenceAnythingError('cloud-drive ranged attachment had no verifiable total size', 'REFERENCE_READ_FAILED');
288
+ }
289
+ if (total !== undefined && result.bytes.byteLength !== total) {
290
+ throw new ReferenceAnythingError('cloud-drive attachment download was incomplete', 'REFERENCE_READ_FAILED');
291
+ }
292
+ return { name: entry.name, bytes: result.bytes, mimeType: mimeForName(entry.name) };
293
+ }
294
+ /** Picker-only listing which keeps folders and safe downloadable documents. */
295
+ async pickerList(query, limit, signal) {
296
+ this.invalidateForCredentialChange();
297
+ const bounded = Math.max(0, Math.trunc(limit));
298
+ if (bounded === 0)
299
+ return [];
300
+ const trimmed = query.trim();
301
+ const collected = [];
302
+ await Promise.all([...this.providers.values()].map(async (provider) => {
303
+ try {
304
+ if (!await provider.credentialed())
305
+ return;
306
+ const entries = await provider.list(trimmed, bounded, signal);
307
+ collected.push(...entries.filter(entry => entry.isDirectory || this.readable(entry) || ATTACHMENT_EXTENSIONS.has(extensionOf(entry.name))));
308
+ }
309
+ catch (cause) {
310
+ if (cause instanceof ReferenceAnythingError && cause.code === 'REFERENCE_CANCELLED')
311
+ return;
312
+ const code = cause instanceof ReferenceAnythingError ? cause.code : 'REFERENCE_READ_FAILED';
313
+ this.ctx.logger.warn(`reference cloud-drive: picker listing ${provider.kind} failed (${code})`);
314
+ }
315
+ }));
316
+ const rows = trimmed.startsWith('/') ? collected : rank(collected, trimmed);
317
+ return rows.slice(0, bounded).map(entry => this.summarize(entry));
318
+ }
319
+ /** A token rotation or disconnect must invalidate the outer listing cache. */
320
+ invalidateForCredentialChange() {
321
+ const generation = this.ctx.openListManager.credentialGeneration();
322
+ if (generation === this.credentialGeneration)
323
+ return;
324
+ this.credentialGeneration = generation;
325
+ this.cache.clear();
326
+ }
327
+ /**
328
+ * Fetch the file's text, best tier first.
329
+ *
330
+ * Downloading comes first and the drive's own recalled passage second,
331
+ * rather than the other way around: the passage is an extract a search index
332
+ * chose, so answering "read this file" with it would be answering a
333
+ * different question. It earns its place as the fallback — for an image with
334
+ * OCR text, or a file too large or too binary to decode, it is the only text
335
+ * that exists.
336
+ */
337
+ async load(provider, entry, signal) {
338
+ const cap = this.settings.maxReadBytes;
339
+ const wanted = entry.size > 0 ? Math.min(entry.size, cap) : cap;
340
+ try {
341
+ if (wanted === 0)
342
+ throw new ReferenceAnythingError('cloud-drive: the file is empty', 'REFERENCE_READ_FAILED');
343
+ const result = await provider.read(entry.id, 0, wanted, signal);
344
+ if (looksBinary(result.bytes)) {
345
+ throw new ReferenceAnythingError(`cloud-drive: ${JSON.stringify(entry.name)} is not text`, 'REFERENCE_READ_FAILED');
346
+ }
347
+ const total = result.totalSize ?? entry.size;
348
+ return {
349
+ text: decodeText(result.bytes),
350
+ partial: total > result.bytes.byteLength,
351
+ recalled: false,
352
+ };
353
+ }
354
+ catch (cause) {
355
+ if (cause instanceof ReferenceAnythingError && cause.code === 'REFERENCE_CANCELLED')
356
+ throw cause;
357
+ const recalled = await provider.extractedText(entry.id, signal).catch(() => undefined);
358
+ if (recalled === undefined || recalled.trim() === '')
359
+ throw cause;
360
+ return { text: recalled, partial: true, recalled: true };
361
+ }
362
+ }
363
+ /** Whether the allowlist admits this file's extension. */
364
+ readable(entry) {
365
+ return this.extensions.has(extensionOf(entry.name));
366
+ }
367
+ /**
368
+ * Reject a file whose extension is not on the allowlist, naming what happened.
369
+ *
370
+ * `list` applies the same rule, so reaching this is either a reference kept
371
+ * from before the allowlist changed or one the model composed itself — both
372
+ * worth an explicit error rather than a download.
373
+ */
374
+ assertTextual(entry) {
375
+ if (this.readable(entry))
376
+ return;
377
+ const extension = extensionOf(entry.name);
378
+ throw new ReferenceAnythingError(`cloud-drive: ${JSON.stringify(entry.name)} is not a text file this source can read`
379
+ + `${extension === '' ? ' (it has no extension)' : ` (${extension})`}`, 'REFERENCE_READ_FAILED');
380
+ }
381
+ }
382
+ /** A filename's lowercased extension, including the dot; empty when it has none. */
383
+ function extensionOf(name) {
384
+ const dot = name.lastIndexOf('.');
385
+ return dot <= 0 ? '' : name.slice(dot).toLocaleLowerCase();
386
+ }
387
+ function imageExtension(name) { return ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'].includes(extensionOf(name)); }
388
+ function mimeForName(name) {
389
+ const ext = extensionOf(name);
390
+ return { '.pdf': 'application/pdf', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' }[ext] ?? 'application/octet-stream';
391
+ }
392
+ /**
393
+ * Register the cloud drive reference source.
394
+ * @param ctx - the mounting context.
395
+ * @param config - validated deployment settings.
396
+ */
397
+ export function apply(ctx, config = {}) {
398
+ const drives = config.drives ?? defaults().drives;
399
+ if (drives.length === 0) {
400
+ throw new ReferenceAnythingError('reference-cloud-drive needs at least one entry in "drives"; mounting it with none can never resolve a reference', 'REFERENCE_INVALID_CONFIG');
401
+ }
402
+ // Checked here as well as in the constructor, because only here does it reach
403
+ // the person who wrote the config: `ctx.plugin` starts a fiber of its own, and
404
+ // a constructor that throws inside it fails that fiber quietly rather than
405
+ // this one. `DRIVE_KINDS` is the durable reference vocabulary and stays wider
406
+ // than the set of transports, so a drive this build cannot reach should stop
407
+ // startup rather than become an empty menu group.
408
+ for (const kind of drives) {
409
+ if (providerFor(kind) === undefined) {
410
+ throw new ReferenceAnythingError(`reference-cloud-drive: "${kind}" has no transport in this build; remove it from "drives"`, 'REFERENCE_INVALID_CONFIG');
411
+ }
412
+ }
413
+ ctx.plugin(CloudDriveService, config);
414
+ }
415
+ /**
416
+ * Order entries for the menu.
417
+ *
418
+ * Matching is against the filename only. The body is never ranked over —
419
+ * a drive file's contents are untrusted material that belongs inside
420
+ * `reference_read`'s envelope, and scoring against it would leak what a file
421
+ * says into a surface that is supposed to say only that it exists.
422
+ */
423
+ function rank(entries, query) {
424
+ if (query === '') {
425
+ return [...entries].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
426
+ || (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0));
427
+ }
428
+ const scored = [];
429
+ // A semantic search returns hits whose relevance the filename cannot show,
430
+ // so an unmatched name is kept rather than dropped — just placed after the
431
+ // ones where the user can see why they matched.
432
+ const unmatched = [];
433
+ for (const entry of entries) {
434
+ const match = scoreTitle(entry.name, query);
435
+ if (match === undefined)
436
+ unmatched.push(entry);
437
+ else
438
+ scored.push({ entry, match });
439
+ }
440
+ scored.sort((a, b) => compareMatches(a.match, b.match));
441
+ return [...scored.map(hit => hit.entry), ...unmatched];
442
+ }
443
+ /**
444
+ * Split decoded text into blocks at line boundaries.
445
+ *
446
+ * Line-aligned because a block is what the model sees as one unit, and cutting
447
+ * mid-line puts half a statement at the end of one and half at the start of
448
+ * the next. A single line longer than the budget is hard-split rather than
449
+ * allowed to blow it.
450
+ */
451
+ export function splitBlocks(text, blockChars) {
452
+ const budget = Math.max(1, Math.trunc(blockChars));
453
+ const blocks = [];
454
+ let current = '';
455
+ for (const line of text.split('\n')) {
456
+ for (const piece of hardSplit(line, budget)) {
457
+ if (current !== '' && current.length + piece.length + 1 > budget) {
458
+ blocks.push(current);
459
+ current = '';
460
+ }
461
+ current = current === '' ? piece : `${current}\n${piece}`;
462
+ }
463
+ }
464
+ if (current.trim() !== '')
465
+ blocks.push(current);
466
+ else if (current !== '' && blocks.length === 0)
467
+ blocks.push(current);
468
+ return blocks.filter(block => block.trim() !== '');
469
+ }
470
+ /** Cut one over-long line into budget-sized pieces. */
471
+ function hardSplit(line, budget) {
472
+ if (line.length <= budget)
473
+ return [line];
474
+ const pieces = [];
475
+ for (let at = 0; at < line.length; at += budget)
476
+ pieces.push(line.slice(at, at + budget));
477
+ return pieces;
478
+ }
479
+ /**
480
+ * Whether these bytes are binary.
481
+ *
482
+ * A NUL in the first few kilobytes is the test, because it is the one byte
483
+ * valid UTF-8 text effectively never contains and the one nearly every binary
484
+ * format does.
485
+ */
486
+ export function looksBinary(bytes) {
487
+ const end = Math.min(bytes.byteLength, SNIFF_BYTES);
488
+ for (let at = 0; at < end; at += 1) {
489
+ if (bytes[at] === 0)
490
+ return true;
491
+ }
492
+ return false;
493
+ }
494
+ /**
495
+ * Decode bytes as UTF-8, dropping a trailing partial character.
496
+ *
497
+ * A capped read almost always lands mid-sequence, and letting the decoder turn
498
+ * that tail into a replacement character would put a visible corruption at the
499
+ * end of every truncated document.
500
+ */
501
+ export function decodeText(bytes) {
502
+ return new TextDecoder('utf-8').decode(trimPartialUtf8(bytes));
503
+ }
504
+ /** Drop up to three trailing bytes that begin a character the read cut short. */
505
+ function trimPartialUtf8(bytes) {
506
+ for (let back = 1; back <= 3 && back <= bytes.byteLength; back += 1) {
507
+ const byte = bytes[bytes.byteLength - back];
508
+ if (byte === undefined || byte < 0x80)
509
+ return bytes;
510
+ // A lead byte this close to the end starts a character whose continuation
511
+ // bytes were never read; anything shorter than its declared width is cut.
512
+ if (byte >= 0xc0) {
513
+ const width = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : 2;
514
+ return width > back ? bytes.subarray(0, bytes.byteLength - back) : bytes;
515
+ }
516
+ }
517
+ return bytes;
518
+ }
519
+ /** Every default, as one object. */
520
+ function defaults() {
521
+ return Config({});
522
+ }
523
+ /** Drop keys explicitly set to undefined so they do not shadow a default. */
524
+ function definedOnly(config) {
525
+ return Object.fromEntries(Object.entries(config).filter(([, value]) => value !== undefined));
526
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Response handling shared by every drive transport.
3
+ *
4
+ * Small on purpose: OpenList-specific authentication and filesystem envelopes
5
+ * belong in its provider; this module only bounds a response body and reads a
6
+ * disclosed total size.
7
+ *
8
+ * @module dsh-reference-anything/cloud-drive/providers/http
9
+ */
10
+ import type { DriveEntry } from '../types.ts';
11
+ /** Minimal injectable `fetch`, so tests need no network and no global patching. */
12
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
13
+ /** Narrowing predicate for `Array.prototype.filter`. */
14
+ export declare function isEntry(entry: DriveEntry | undefined): entry is DriveEntry;
15
+ /** Total size from `Content-Range`, falling back to `Content-Length` only for a complete response. */
16
+ export declare function totalFromResponse(response: Response): number | undefined;
17
+ /**
18
+ * Read at most `limit` bytes, then abandon the rest of the body.
19
+ *
20
+ * The cap is the whole point: when a CDN ignores `Range` it answers with the
21
+ * entire file, and a caller that awaited `arrayBuffer()` would buffer all of it
22
+ * before discovering the range was refused.
23
+ *
24
+ * @param response - a response whose body has not been consumed.
25
+ * @param limit - maximum bytes to retain.
26
+ */
27
+ export declare function drain(response: Response, limit: number): Promise<Uint8Array>;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Response handling shared by every drive transport.
3
+ *
4
+ * Small on purpose: OpenList-specific authentication and filesystem envelopes
5
+ * belong in its provider; this module only bounds a response body and reads a
6
+ * disclosed total size.
7
+ *
8
+ * @module dsh-reference-anything/cloud-drive/providers/http
9
+ */
10
+ /** Narrowing predicate for `Array.prototype.filter`. */
11
+ export function isEntry(entry) {
12
+ return entry !== undefined;
13
+ }
14
+ /** Total size from `Content-Range`, falling back to `Content-Length` only for a complete response. */
15
+ export function totalFromResponse(response) {
16
+ const range = response.headers.get('content-range');
17
+ const match = range?.match(/^bytes\s+(\d+)-(\d+)\/(\d+)$/i);
18
+ if (match) {
19
+ const start = Number(match[1]);
20
+ const end = Number(match[2]);
21
+ const total = Number(match[3]);
22
+ if (Number.isSafeInteger(start) && Number.isSafeInteger(end) && Number.isSafeInteger(total)
23
+ && start >= 0 && end >= start && total > end)
24
+ return total;
25
+ }
26
+ // A 206 Content-Length is only this range's length, never proof of the
27
+ // complete object's size. Callers must fall back to trusted file metadata.
28
+ if (response.status === 206)
29
+ return undefined;
30
+ const length = Number(response.headers.get('content-length'));
31
+ return Number.isFinite(length) && length > 0 ? length : undefined;
32
+ }
33
+ /**
34
+ * Read at most `limit` bytes, then abandon the rest of the body.
35
+ *
36
+ * The cap is the whole point: when a CDN ignores `Range` it answers with the
37
+ * entire file, and a caller that awaited `arrayBuffer()` would buffer all of it
38
+ * before discovering the range was refused.
39
+ *
40
+ * @param response - a response whose body has not been consumed.
41
+ * @param limit - maximum bytes to retain.
42
+ */
43
+ export async function drain(response, limit) {
44
+ const stream = response.body;
45
+ if (stream === null)
46
+ return new Uint8Array(0);
47
+ const reader = stream.getReader();
48
+ const chunks = [];
49
+ let total = 0;
50
+ try {
51
+ while (total < limit) {
52
+ const { done, value } = await reader.read();
53
+ if (done)
54
+ break;
55
+ if (value === undefined)
56
+ continue;
57
+ const take = Math.min(value.byteLength, limit - total);
58
+ chunks.push(take === value.byteLength ? value : value.subarray(0, take));
59
+ total += take;
60
+ }
61
+ }
62
+ finally {
63
+ // Releases the connection instead of letting the remainder stream in.
64
+ await reader.cancel().catch(() => { });
65
+ }
66
+ const out = new Uint8Array(total);
67
+ let offset = 0;
68
+ for (const chunk of chunks) {
69
+ out.set(chunk, offset);
70
+ offset += chunk.byteLength;
71
+ }
72
+ return out;
73
+ }