dsh-reference-anything 0.3.0 → 0.3.2
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 -22
- package/NOTICE.md +52 -25
- package/README.md +339 -286
- package/cordis.patch.yml +89 -18
- package/lib/client.js +13538 -591
- package/lib/contract.d.ts +155 -16
- package/lib/contract.js +68 -7
- package/lib/download-directory.d.ts +18 -0
- package/lib/download-directory.js +55 -0
- package/lib/errors.d.ts +4 -0
- package/lib/host.d.ts +96 -8
- package/lib/host.js +88 -19
- package/lib/index.d.ts +22 -2
- package/lib/index.js +57 -7
- package/lib/opencli.d.ts +5 -3
- package/lib/opencli.js +13 -6
- package/lib/openlist/host.d.ts +157 -0
- package/lib/openlist/host.js +873 -0
- package/lib/openlist/index.d.ts +84 -0
- package/lib/openlist/index.js +507 -0
- package/lib/render.d.ts +12 -7
- package/lib/render.js +27 -21
- package/lib/sources/cloud-drive/cache.d.ts +60 -0
- package/lib/sources/cloud-drive/cache.js +107 -0
- package/lib/sources/cloud-drive/index.d.ts +190 -0
- package/lib/sources/cloud-drive/index.js +526 -0
- package/lib/sources/cloud-drive/providers/http.d.ts +27 -0
- package/lib/sources/cloud-drive/providers/http.js +73 -0
- package/lib/sources/cloud-drive/providers/openlist.d.ts +59 -0
- package/lib/sources/cloud-drive/providers/openlist.js +535 -0
- package/lib/sources/cloud-drive/registry.d.ts +53 -0
- package/lib/sources/cloud-drive/registry.js +101 -0
- package/lib/sources/cloud-drive/types.d.ts +169 -0
- package/lib/sources/cloud-drive/types.js +17 -0
- package/lib/sources/file.d.ts +2 -0
- package/lib/sources/file.js +2 -0
- package/lib/sources/local-agent/adapters/claude-code.d.ts +16 -0
- package/lib/sources/local-agent/adapters/claude-code.js +171 -0
- package/lib/sources/local-agent/adapters/codex.d.ts +20 -0
- package/lib/sources/local-agent/adapters/codex.js +204 -0
- package/lib/sources/local-agent/adapters/cursor.d.ts +23 -0
- package/lib/sources/local-agent/adapters/cursor.js +121 -0
- package/lib/sources/local-agent/adapters/gemini-cli.d.ts +20 -0
- package/lib/sources/local-agent/adapters/gemini-cli.js +141 -0
- package/lib/sources/local-agent/adapters/grokbuild.d.ts +22 -0
- package/lib/sources/local-agent/adapters/grokbuild.js +76 -0
- package/lib/sources/local-agent/adapters/hermes.d.ts +19 -0
- package/lib/sources/local-agent/adapters/hermes.js +85 -0
- package/lib/sources/local-agent/adapters/kimi.d.ts +28 -0
- package/lib/sources/local-agent/adapters/kimi.js +261 -0
- package/lib/sources/local-agent/adapters/openclaw.d.ts +21 -0
- package/lib/sources/local-agent/adapters/openclaw.js +120 -0
- package/lib/sources/local-agent/adapters/opencode.d.ts +44 -0
- package/lib/sources/local-agent/adapters/opencode.js +236 -0
- package/lib/sources/local-agent/adapters/pi.d.ts +26 -0
- package/lib/sources/local-agent/adapters/pi.js +242 -0
- package/lib/sources/local-agent/adapters/qoder.d.ts +19 -0
- package/lib/sources/local-agent/adapters/qoder.js +93 -0
- package/lib/sources/local-agent/adapters/reasonix.d.ts +23 -0
- package/lib/sources/local-agent/adapters/reasonix.js +90 -0
- package/lib/sources/local-agent/adapters/shared.d.ts +163 -0
- package/lib/sources/local-agent/adapters/shared.js +415 -0
- package/lib/sources/local-agent/adapters/sqlite-shared.d.ts +92 -0
- package/lib/sources/local-agent/adapters/sqlite-shared.js +215 -0
- package/lib/sources/local-agent/adapters/zcode.d.ts +41 -0
- package/lib/sources/local-agent/adapters/zcode.js +159 -0
- package/lib/sources/local-agent/index.d.ts +222 -0
- package/lib/sources/local-agent/index.js +656 -0
- package/lib/sources/local-agent/page.d.ts +55 -0
- package/lib/sources/local-agent/page.js +83 -0
- package/lib/sources/local-agent/path.d.ts +8 -0
- package/lib/sources/local-agent/path.js +43 -0
- package/lib/sources/local-agent/registry.d.ts +68 -0
- package/lib/sources/local-agent/registry.js +116 -0
- package/lib/sources/local-agent/scan.d.ts +175 -0
- package/lib/sources/local-agent/scan.js +472 -0
- package/lib/sources/local-agent/sqlite.d.ts +45 -0
- package/lib/sources/local-agent/sqlite.js +113 -0
- package/lib/sources/local-agent/store.d.ts +93 -0
- package/lib/sources/local-agent/store.js +113 -0
- package/lib/sources/local-agent/types.d.ts +338 -0
- package/lib/sources/local-agent/types.js +20 -0
- package/lib/sources/web-chat/index.d.ts +5 -1
- package/lib/sources/web-chat/index.js +47 -8
- package/lib/store/spec.d.ts +50 -4
- package/lib/store/spec.js +2 -2
- package/lib/sync/index.d.ts +1 -0
- package/lib/sync/index.js +28 -8
- package/lib/tool.d.ts +5 -0
- package/lib/tool.js +418 -45
- package/lib/typert.js +13 -2
- package/lib/types.d.ts +28 -2
- package/lib/update.d.ts +6 -0
- package/lib/update.js +29 -1
- package/lib/web.js +5 -1
- package/lib/wire.d.ts +80 -9
- package/lib/wire.js +30 -4
- package/opencli-plugin/chatgpt.js +30 -27
- package/opencli-plugin/claude.js +12 -8
- package/opencli-plugin/common.js +266 -187
- package/opencli-plugin/deepseek.js +8 -5
- package/opencli-plugin/gemini.js +42 -38
- package/opencli-plugin/grok.js +22 -21
- package/opencli-plugin/kimi.js +34 -31
- package/opencli-plugin/opencli-plugin.json +6 -6
- package/opencli-plugin/package.json +8 -8
- package/package.json +182 -167
- package/lib/profile-mode.d.ts +0 -30
- package/lib/profile-mode.js +0 -90
|
@@ -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
|
+
}
|