dsh-reference-anything 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +22 -0
  2. package/NOTICE.md +54 -0
  3. package/README.md +228 -0
  4. package/cordis.patch.yml +63 -0
  5. package/images/logo.png +0 -0
  6. package/lib/client/index.d.ts +3 -0
  7. package/lib/client.js +18151 -0
  8. package/lib/contract.d.ts +300 -0
  9. package/lib/contract.js +102 -0
  10. package/lib/errors.d.ts +56 -0
  11. package/lib/errors.js +21 -0
  12. package/lib/host.d.ts +102 -0
  13. package/lib/host.js +55 -0
  14. package/lib/index.d.ts +83 -0
  15. package/lib/index.js +153 -0
  16. package/lib/opencli.d.ts +116 -0
  17. package/lib/opencli.js +356 -0
  18. package/lib/reference.d.ts +49 -0
  19. package/lib/reference.js +228 -0
  20. package/lib/render.d.ts +101 -0
  21. package/lib/render.js +250 -0
  22. package/lib/retain.d.ts +35 -0
  23. package/lib/retain.js +116 -0
  24. package/lib/search.d.ts +75 -0
  25. package/lib/search.js +126 -0
  26. package/lib/serialize.d.ts +22 -0
  27. package/lib/serialize.js +24 -0
  28. package/lib/sources/file.d.ts +100 -0
  29. package/lib/sources/file.js +236 -0
  30. package/lib/sources/web-chat/index.d.ts +114 -0
  31. package/lib/sources/web-chat/index.js +359 -0
  32. package/lib/store/spec.d.ts +312 -0
  33. package/lib/store/spec.js +94 -0
  34. package/lib/store/store.d.ts +189 -0
  35. package/lib/store/store.js +638 -0
  36. package/lib/sync/index.d.ts +91 -0
  37. package/lib/sync/index.js +425 -0
  38. package/lib/tool.d.ts +62 -0
  39. package/lib/tool.js +364 -0
  40. package/lib/typert.d.ts +2 -0
  41. package/lib/typert.js +34 -0
  42. package/lib/types.d.ts +215 -0
  43. package/lib/types.js +12 -0
  44. package/lib/uri-codec.d.ts +5 -0
  45. package/lib/uri-codec.js +39 -0
  46. package/lib/uri.d.ts +59 -0
  47. package/lib/uri.js +86 -0
  48. package/lib/web.d.ts +4 -0
  49. package/lib/web.js +11 -0
  50. package/lib/window.d.ts +23 -0
  51. package/lib/window.js +35 -0
  52. package/lib/wire.d.ts +117 -0
  53. package/lib/wire.js +49 -0
  54. package/lib/workspace.d.ts +18 -0
  55. package/lib/workspace.js +81 -0
  56. package/opencli-plugin/chatgpt.js +125 -0
  57. package/opencli-plugin/claude.js +134 -0
  58. package/opencli-plugin/common.js +307 -0
  59. package/opencli-plugin/deepseek.js +152 -0
  60. package/opencli-plugin/gemini.js +165 -0
  61. package/opencli-plugin/grok.js +123 -0
  62. package/opencli-plugin/kimi.js +123 -0
  63. package/opencli-plugin/opencli-plugin.json +6 -0
  64. package/opencli-plugin/package.json +8 -0
  65. package/opencli-plugin/since-guard.js +45 -0
  66. package/package.json +180 -0
package/lib/opencli.js ADDED
@@ -0,0 +1,356 @@
1
+ import { execFile as nodeExecFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { createHash } from 'node:crypto';
6
+ const execFile = promisify(nodeExecFile);
7
+ export const MIN_OPENCLI_VERSION = '1.8.6';
8
+ export const MIN_ADAPTER_VERSION = '0.2.0';
9
+ export const OPENCLI_NPM_PACKAGE = '@jackwener/opencli';
10
+ const SITE = {
11
+ chatgpt: 'dsh-chatgpt', claude: 'dsh-claude', gemini: 'dsh-gemini',
12
+ deepseek: 'dsh-deepseek', grok: 'dsh-grok', kimi: 'dsh-kimi',
13
+ };
14
+ const REQUIRED_ADAPTER_COMMANDS = ['chatgpt', 'claude', 'gemini', 'deepseek', 'grok', 'kimi'];
15
+ export class OpenCliError extends Error {
16
+ code;
17
+ constructor(message, code, options) {
18
+ super(message, options);
19
+ this.code = code;
20
+ }
21
+ }
22
+ /**
23
+ * Parse the human-readable `opencli daemon status` output into structured
24
+ * bridge state. Empty output — including a probe failure — degrades to
25
+ * `daemon-offline` rather than throwing, so one broken probe never hides the
26
+ * rest of the viability panel.
27
+ */
28
+ export function parseDaemonStatus(output) {
29
+ const daemonRunning = /^Daemon:\s+(?:running|stale)\b/m.test(output);
30
+ const connected = /^Extension:\s+connected\b/m.test(output);
31
+ const version = connected ? output.match(/^Extension:\s+connected \((v)?([^)\s]+)\)/m)?.[2] : undefined;
32
+ const profileRequired = output.match(/^Extension:\s+(\d+) profiles? connected, none selected/m);
33
+ const profileDisconnected = /^Extension:\s+requested profile not connected/m.test(output);
34
+ const extensionState = !daemonRunning ? 'daemon-offline'
35
+ : connected ? 'connected'
36
+ : profileRequired ? 'profile-required'
37
+ : profileDisconnected ? 'profile-disconnected'
38
+ : 'disconnected';
39
+ const profileCount = profileRequired ? Number(profileRequired[1]) : undefined;
40
+ return {
41
+ daemonRunning, extensionState, extensionConnected: extensionState === 'connected',
42
+ ...(version ? { extensionVersion: version } : {}),
43
+ ...(profileCount !== undefined ? { profileCount } : {}),
44
+ };
45
+ }
46
+ export class OpenCliRunner {
47
+ executable;
48
+ profile;
49
+ prefixArgs;
50
+ timeoutMs;
51
+ maxStdoutBytes;
52
+ constructor(options = {}) {
53
+ this.executable = options.executable || 'opencli';
54
+ this.profile = options.profile || '';
55
+ this.prefixArgs = options.prefixArgs ?? [];
56
+ this.timeoutMs = options.timeoutMs ?? 60_000;
57
+ this.maxStdoutBytes = options.maxStdoutBytes ?? 32 * 1024 * 1024;
58
+ }
59
+ async whoami(provider, signal) {
60
+ const rows = await this.json(SITE[provider], 'whoami', [], signal);
61
+ const identity = stringField(rows[0], 'identity');
62
+ if (!identity)
63
+ throw new OpenCliError(`${provider} returned no stable account identity`, 'PROVIDER_NOT_LOGGED_IN');
64
+ return createHash('sha256').update(`${provider}\0${identity}`).digest('hex');
65
+ }
66
+ /**
67
+ * List a provider's conversations.
68
+ * @param provider - which provider to list.
69
+ * @param signal - cancellation from the caller.
70
+ * @param since - ISO instant the adapter may stop paging at. Best-effort:
71
+ * adapters that page in an order they cannot vouch for ignore it, and one
72
+ * installed before the flag existed is retried without it rather than
73
+ * failing the pass.
74
+ * @returns the listed conversations.
75
+ */
76
+ async history(provider, signal, since = '') {
77
+ const rows = await this.historyRows(provider, since, signal);
78
+ return conversationRows(provider, rows);
79
+ }
80
+ /** Resolve account identity and history through one OpenCLI browser command. */
81
+ async syncIndex(provider, signal, since = '', accountScope = '') {
82
+ const args = [...(since ? ['--since', since] : []), ...(accountScope ? ['--accountScope', accountScope] : [])];
83
+ const rows = await this.json(SITE[provider], 'sync-index', args, signal);
84
+ const identity = rows.find(row => stringField(row, 'kind') === 'identity');
85
+ const stableIdentity = stringField(identity, 'identity');
86
+ if (!stableIdentity)
87
+ throw new OpenCliError(`${provider} returned no stable account identity`, 'PROVIDER_NOT_LOGGED_IN');
88
+ return {
89
+ accountScope: createHash('sha256').update(`${provider}\0${stableIdentity}`).digest('hex'),
90
+ sinceApplied: stringField(identity, 'sinceApplied'),
91
+ rows: conversationRows(provider, rows.filter(row => stringField(row, 'kind') === 'conversation')),
92
+ };
93
+ }
94
+ async detail(provider, id, signal, accountScope = '') {
95
+ const rows = await this.json(SITE[provider], 'detail', [id, ...(accountScope ? ['--accountScope', accountScope] : [])], signal);
96
+ return rows.map((raw, index) => {
97
+ const role = stringField(raw, 'role');
98
+ if (role !== 'user' && role !== 'assistant')
99
+ throw new OpenCliError('provider detail returned an invalid role', 'OPENCLI_CONFIGURATION');
100
+ return {
101
+ conversationId: required(raw, 'conversationId'), ordinal: integerField(raw, 'ordinal', index),
102
+ messageId: stringField(raw, 'messageId'), parentId: stringField(raw, 'parentId'),
103
+ branchId: stringField(raw, 'branchId'), activeBranch: booleanField(raw, 'activeBranch', true),
104
+ role, text: stringField(raw, 'text'), createdAt: stringField(raw, 'createdAt'),
105
+ attachmentsJson: stringField(raw, 'attachmentsJson', '[]'), partial: booleanField(raw, 'partial'),
106
+ };
107
+ });
108
+ }
109
+ async attachment(provider, locator, output, maxBytes, signal) {
110
+ const rows = await this.json(SITE[provider], 'attachment', [locator, '--output', output, '--maxBytes', String(maxBytes)], signal);
111
+ return rows[0] ?? {};
112
+ }
113
+ async health(signal) {
114
+ const [version, daemon, plugins, doctor] = await Promise.all([
115
+ this.probe(['--version'], signal), this.probe(['daemon', 'status'], signal), this.probe(['plugin', 'list'], signal),
116
+ this.probe(['doctor'], signal),
117
+ ]);
118
+ const status = parseDaemonStatus(daemon.value);
119
+ const cliVersion = normalizeVersion(version.value);
120
+ const daemonVersion = daemon.value.match(/^Version:\s+v?([^\s(]+)/m)?.[1];
121
+ const pluginVersion = plugins.value.match(/dsh-chat-history\s+@([^\s—]+)/i)?.[1];
122
+ const pluginInstalled = /dsh-chat-history/i.test(plugins.value);
123
+ const adapterCommandsReady = REQUIRED_ADAPTER_COMMANDS.every(command => new RegExp(`\\b${command}\\b`, 'i').test(plugins.value));
124
+ const opencliCompatible = versionAtLeast(cliVersion, MIN_OPENCLI_VERSION);
125
+ const daemonStale = Boolean(status.daemonRunning && (!daemonVersion || normalizeVersion(daemonVersion) !== cliVersion));
126
+ const connectivityOk = /\[OK\]\s+Connectivity:/i.test(doctor.value);
127
+ return {
128
+ version: cliVersion, daemon: daemon.value.trim(), pluginInstalled,
129
+ daemonRunning: status.daemonRunning, extensionConnected: status.extensionConnected, extensionState: status.extensionState,
130
+ opencliCompatible, daemonStale, connectivityOk,
131
+ ...(status.extensionVersion ? { extensionVersion: status.extensionVersion } : {}),
132
+ ...(status.profileCount !== undefined ? { profileCount: status.profileCount } : {}),
133
+ ...(daemonVersion ? { daemonVersion: normalizeVersion(daemonVersion) } : {}),
134
+ ...(pluginVersion ? { pluginVersion: normalizeVersion(pluginVersion) } : {}),
135
+ adapterCommandsReady,
136
+ adapterCompatible: pluginInstalled && adapterCommandsReady && versionAtLeast(pluginVersion ?? '', MIN_ADAPTER_VERSION),
137
+ ...(version.error ? { versionError: version.error } : {}),
138
+ ...(daemon.error ? { daemonError: daemon.error } : {}),
139
+ ...(plugins.error ? { pluginError: plugins.error } : {}),
140
+ ...(doctor.error ? { doctorError: doctor.error } : {}),
141
+ };
142
+ }
143
+ /** Lightweight executable validation used by automatic path discovery. */
144
+ async version(signal) { return normalizeVersion(await this.raw(['--version'], signal)); }
145
+ /** Confirm the executable also exposes OpenCLI's plugin command, not merely a semver-looking --version. */
146
+ async verifyInstallation(signal) { await this.raw(['plugin', 'list'], signal); }
147
+ async profiles(signal) {
148
+ const output = await this.raw(['profile', 'list'], signal);
149
+ const disconnected = output.indexOf('Disconnected saved profiles:');
150
+ return output.split(/\r?\n/).flatMap((line) => {
151
+ const match = line.match(/^\s{2}([A-Za-z0-9_-]+)(?:\s+([A-Za-z0-9_-]+))?(?:\s+default)?\s+—\s+(connected|not connected)/);
152
+ if (!match)
153
+ return [];
154
+ const position = output.indexOf(line);
155
+ return [{ id: match[1], ...(match[2] && match[2] !== 'default' ? { alias: match[2] } : {}),
156
+ connected: match[3] === 'connected' && (disconnected < 0 || position < disconnected), isDefault: /\sdefault\s+—/.test(line) }];
157
+ });
158
+ }
159
+ async installPlugin(pluginUrl, signal) {
160
+ await this.raw(['plugin', 'install', pluginUrl], signal);
161
+ }
162
+ async restartDaemon(signal) { await this.raw(['daemon', 'restart'], signal); }
163
+ async probe(args, signal) {
164
+ try {
165
+ return { value: await this.raw(args, signal) };
166
+ }
167
+ catch (error) {
168
+ return { value: '', error: error instanceof Error ? error.message : String(error) };
169
+ }
170
+ }
171
+ async historyRows(provider, since, signal) {
172
+ if (!since)
173
+ return this.json(SITE[provider], 'history-all', [], signal);
174
+ try {
175
+ return await this.json(SITE[provider], 'history-all', ['--since', since], signal);
176
+ }
177
+ catch (error) {
178
+ // An adapter installed before `--since` existed rejects the flag as a
179
+ // configuration error. Walking the whole history is slower but correct,
180
+ // and beats failing every background pass until the user remembers to
181
+ // reinstall the OpenCLI plugin.
182
+ if (!(error instanceof OpenCliError) || error.code !== 'OPENCLI_CONFIGURATION')
183
+ throw error;
184
+ return this.json(SITE[provider], 'history-all', [], signal);
185
+ }
186
+ }
187
+ async json(site, operation, args, signal) {
188
+ // Pin adapter traffic to a background tab. OpenCLI otherwise lets the
189
+ // process-wide OPENCLI_WINDOW environment variable override the adapter's
190
+ // defaultWindowMode, which could make an unattended sync steal focus.
191
+ const stdout = await this.raw([site, operation, ...args, '--window', 'background', '-f', 'json'], signal);
192
+ try {
193
+ const parsed = JSON.parse(stdout);
194
+ if (!Array.isArray(parsed) || parsed.some(row => !row || typeof row !== 'object' || Array.isArray(row)))
195
+ throw new Error('expected object array');
196
+ return parsed;
197
+ }
198
+ catch (error) {
199
+ throw new OpenCliError('OpenCLI returned malformed JSON', 'OPENCLI_CONFIGURATION', { cause: error });
200
+ }
201
+ }
202
+ async raw(args, signal) {
203
+ const invocation = resolveInvocation(this.executable);
204
+ const fullArgs = [...invocation.prefix, ...this.prefixArgs, ...(this.profile ? ['--profile', this.profile] : []), ...args];
205
+ try {
206
+ const result = await execFile(invocation.file, fullArgs, {
207
+ encoding: 'utf8', timeout: this.timeoutMs, maxBuffer: this.maxStdoutBytes,
208
+ windowsHide: true, shell: false, signal, env: { ...process.env, OPENCLI_WINDOW: 'background' },
209
+ });
210
+ return result.stdout;
211
+ }
212
+ catch (error) {
213
+ const detail = error;
214
+ if (detail.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER')
215
+ throw new OpenCliError('OpenCLI output exceeded the configured limit', 'OPENCLI_OUTPUT_TOO_LARGE', { cause: error });
216
+ if (detail.killed || detail.signal === 'SIGTERM')
217
+ throw new OpenCliError('OpenCLI provider request timed out', 'PROVIDER_TIMEOUT', { cause: error });
218
+ const stderr = String(detail.stderr || detail.stdout || '').trim().slice(0, 2_000);
219
+ const exit = typeof detail.code === 'number' ? detail.code : undefined;
220
+ const code = stderr.includes('DSH_ACCOUNT_SCOPE_MISMATCH') ? 'PROVIDER_ACCOUNT_MISMATCH'
221
+ : exit === 69 ? 'EXTENSION_NOT_CONNECTED' : exit === 75 ? 'PROVIDER_TIMEOUT'
222
+ : exit === 77 ? 'PROVIDER_NOT_LOGGED_IN' : exit === 78 ? 'OPENCLI_CONFIGURATION' : 'OPENCLI_FAILED';
223
+ throw new OpenCliError(stderr || `OpenCLI exited with ${String(detail.code)}`, code, { cause: error });
224
+ }
225
+ }
226
+ }
227
+ /** Locate a working OpenCLI without asking a command-line newcomer for PATH details. */
228
+ export async function discoverOpenCli(configured = 'opencli', signal) {
229
+ const candidates = openCliCandidates(configured);
230
+ let lastError = '';
231
+ for (const executable of candidates) {
232
+ try {
233
+ const runner = new OpenCliRunner({ executable, timeoutMs: 15_000 });
234
+ const version = await runner.version(signal);
235
+ await runner.verifyInstallation(signal);
236
+ if (version)
237
+ return { found: true, executable, version };
238
+ }
239
+ catch (error) {
240
+ lastError = error instanceof Error ? error.message : String(error);
241
+ }
242
+ }
243
+ return { found: false, executable: configured, version: '', ...(lastError ? { error: lastError } : {}) };
244
+ }
245
+ /** Install or upgrade the supported OpenCLI package through this Node runtime's npm. */
246
+ export async function installOpenCli(signal) {
247
+ const npm = resolveNpmInvocation();
248
+ if (!npm) {
249
+ throw new OpenCliError('npm was not found next to the Node.js runtime; install Node.js/npm, then retry', 'OPENCLI_INSTALL_FAILED');
250
+ }
251
+ try {
252
+ await execFile(npm.file, [...npm.prefix, 'install', '--global', `${OPENCLI_NPM_PACKAGE}@>=${MIN_OPENCLI_VERSION}`], {
253
+ encoding: 'utf8', timeout: 5 * 60_000, maxBuffer: 8 * 1024 * 1024,
254
+ windowsHide: true, shell: false, signal,
255
+ });
256
+ }
257
+ catch (error) {
258
+ const detail = error;
259
+ const output = String(detail.stderr || detail.stdout || '').trim().slice(0, 2_000);
260
+ throw new OpenCliError(output || 'npm could not install OpenCLI globally', 'OPENCLI_INSTALL_FAILED', { cause: error });
261
+ }
262
+ const discovery = await discoverOpenCli('opencli', signal);
263
+ if (!discovery.found)
264
+ throw new OpenCliError(discovery.error || 'OpenCLI was installed but could not be located', 'OPENCLI_INSTALL_FAILED');
265
+ return discovery;
266
+ }
267
+ function openCliCandidates(configured) {
268
+ const candidates = [configured, 'opencli'];
269
+ const prefix = process.env.npm_config_prefix;
270
+ if (process.platform === 'win32') {
271
+ if (process.env.APPDATA)
272
+ candidates.push(join(process.env.APPDATA, 'npm', 'opencli.cmd'));
273
+ if (prefix)
274
+ candidates.push(join(prefix, 'opencli.cmd'));
275
+ }
276
+ else {
277
+ if (prefix)
278
+ candidates.push(join(prefix, 'bin', 'opencli'));
279
+ candidates.push('/usr/local/bin/opencli', '/opt/homebrew/bin/opencli');
280
+ }
281
+ return [...new Set(candidates.filter(Boolean))];
282
+ }
283
+ function resolveNpmInvocation() {
284
+ const npmExecPath = process.env.npm_execpath;
285
+ if (npmExecPath && existsSync(npmExecPath)) {
286
+ return /\.(?:c?js|mjs)$/i.test(npmExecPath)
287
+ ? { file: process.execPath, prefix: [npmExecPath] }
288
+ : { file: npmExecPath, prefix: [] };
289
+ }
290
+ const besideNode = join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
291
+ if (existsSync(besideNode))
292
+ return { file: process.execPath, prefix: [besideNode] };
293
+ if (process.platform !== 'win32')
294
+ return { file: 'npm', prefix: [] };
295
+ return undefined;
296
+ }
297
+ function normalizeVersion(value) { return value.trim().replace(/^v/i, '').split(/\s+/)[0] ?? ''; }
298
+ /** Numeric semver comparison for the stable x.y.z versions OpenCLI publishes. */
299
+ export function versionAtLeast(value, minimum) {
300
+ const parse = (input) => normalizeVersion(input).split('.').slice(0, 3).map(part => Number.parseInt(part, 10));
301
+ const actual = parse(value);
302
+ const required = parse(minimum);
303
+ if (actual.length < 3 || actual.some(Number.isNaN))
304
+ return false;
305
+ for (let index = 0; index < 3; index++) {
306
+ const left = actual[index] ?? 0;
307
+ const right = required[index] ?? 0;
308
+ if (left !== right)
309
+ return left > right;
310
+ }
311
+ return true;
312
+ }
313
+ function resolveInvocation(configured) {
314
+ const explicit = resolve(configured);
315
+ if (/\.cmd$/i.test(configured) && existsSync(explicit)) {
316
+ const entry = join(explicit, '..', 'node_modules', '@jackwener', 'opencli', 'dist', 'src', 'main.js');
317
+ if (existsSync(entry))
318
+ return { file: process.execPath, prefix: [entry] };
319
+ }
320
+ if (process.platform === 'win32' && configured === 'opencli') {
321
+ const entry = process.env.APPDATA && join(process.env.APPDATA, 'npm', 'node_modules', '@jackwener', 'opencli', 'dist', 'src', 'main.js');
322
+ if (entry && existsSync(entry))
323
+ return { file: process.execPath, prefix: [entry] };
324
+ }
325
+ return { file: configured, prefix: [] };
326
+ }
327
+ function stringField(row, key, fallback = '') {
328
+ const value = row?.[key];
329
+ return value === undefined || value === null ? fallback : String(value);
330
+ }
331
+ function conversationRows(provider, rows) {
332
+ return rows.map(raw => ({
333
+ provider,
334
+ accountScope: '',
335
+ id: required(raw, 'id'), title: required(raw, 'title'), url: required(raw, 'url'),
336
+ createdAt: stringField(raw, 'createdAt'), updatedAt: stringField(raw, 'updatedAt'),
337
+ messageCount: integerField(raw, 'messageCount'), cursor: stringField(raw, 'cursor'),
338
+ partial: booleanField(raw, 'partial'),
339
+ }));
340
+ }
341
+ function required(row, key) {
342
+ const value = stringField(row, key).trim();
343
+ if (!value)
344
+ throw new OpenCliError(`provider row is missing ${key}`, 'OPENCLI_CONFIGURATION');
345
+ return value;
346
+ }
347
+ function integerField(row, key, fallback = 0) {
348
+ const value = Number(row[key] ?? fallback);
349
+ return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : fallback;
350
+ }
351
+ function booleanField(row, key, fallback = false) {
352
+ const value = row[key];
353
+ if (value === undefined)
354
+ return fallback;
355
+ return value === true || value === 'true' || value === 1 || value === '1';
356
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Expands reference mentions in a user's prompt into untrusted background.
3
+ *
4
+ * This runs at the loop's pre-step boundary rather than at prompt submission,
5
+ * because submission is owned by each host and has no extension point, while
6
+ * pre-step is shared by every surface — Web, headless, ACP, scheduled runs,
7
+ * and subagents all pass through it. Whatever this listener returns is what
8
+ * the loop durably appends, so injected material is logged by construction and
9
+ * a replay reconstructs the request exactly.
10
+ *
11
+ * Two schemes are expanded. `dsh-ref:` names material outside the harness and
12
+ * is resolved through `ctx.references`. `dsh-session:` names another harness
13
+ * session and is delegated to `ctx.sessionReferenceResolver`, which owns
14
+ * session-surface semantics — compaction folding, shadowed events, cited event
15
+ * seqs — that do not generalize to an outside conversation.
16
+ *
17
+ * @module dsh-reference-anything/reference
18
+ */
19
+ import type { Context } from '@deepseek-ai/cordis';
20
+ import z from '@deepseek-ai/schemastery';
21
+ /** Cordis plugin name used by loader diagnostics. */
22
+ export declare const name = "reference-mention";
23
+ /** The registry this expander reads through. */
24
+ export declare const inject: string[];
25
+ /** Upper bound on distinct references in one message, whatever the config says. */
26
+ export declare const MAX_REFERENCES = 3;
27
+ /** Deployment settings for mention expansion. */
28
+ export interface Config {
29
+ /**
30
+ * Distinct `dsh-ref:` references honored in one message. Capped at
31
+ * {@link MAX_REFERENCES}: past a few sources the background stops being
32
+ * background, and the byte budget multiplies by the same factor.
33
+ */
34
+ maxReferences?: number;
35
+ /**
36
+ * Also expand `dsh-session:` mentions. Deployments that deliberately do not
37
+ * mount the cross-session resolver set this false, so such a mention is
38
+ * refused as unsupported rather than probed for on every message.
39
+ */
40
+ serveSessionScheme?: boolean;
41
+ }
42
+ /** Runtime schema for {@link Config}. */
43
+ export declare const Config: z<Config>;
44
+ /**
45
+ * Register the pre-step mention expander.
46
+ * @param ctx - the mounting context.
47
+ * @param config - validated deployment settings.
48
+ */
49
+ export declare function apply(ctx: Context, config?: Config): void;
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Expands reference mentions in a user's prompt into untrusted background.
3
+ *
4
+ * This runs at the loop's pre-step boundary rather than at prompt submission,
5
+ * because submission is owned by each host and has no extension point, while
6
+ * pre-step is shared by every surface — Web, headless, ACP, scheduled runs,
7
+ * and subagents all pass through it. Whatever this listener returns is what
8
+ * the loop durably appends, so injected material is logged by construction and
9
+ * a replay reconstructs the request exactly.
10
+ *
11
+ * Two schemes are expanded. `dsh-ref:` names material outside the harness and
12
+ * is resolved through `ctx.references`. `dsh-session:` names another harness
13
+ * session and is delegated to `ctx.sessionReferenceResolver`, which owns
14
+ * session-surface semantics — compaction folding, shadowed events, cited event
15
+ * seqs — that do not generalize to an outside conversation.
16
+ *
17
+ * @module dsh-reference-anything/reference
18
+ */
19
+ import z from '@deepseek-ai/schemastery';
20
+ import { boundContextSummary, createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm';
21
+ import { SESSION_REFERENCE_SCHEME, parseSessionReferenceText, } from '@deepseek-ai/dsh-session-reference';
22
+ import { ReferenceAnythingError } from "./errors.js";
23
+ import { renderDeferredReferences } from "./render.js";
24
+ import { mayContainReference, parseReferenceText } from "./uri.js";
25
+ import { WORKSPACE_REFERENCE_SCHEME, parseWorkspaceReferenceText, renderWorkspaceReferences } from "./workspace.js";
26
+ /** Cordis plugin name used by loader diagnostics. */
27
+ export const name = 'reference-mention';
28
+ /** The registry this expander reads through. */
29
+ export const inject = ['references'];
30
+ /** Upper bound on distinct references in one message, whatever the config says. */
31
+ export const MAX_REFERENCES = 3;
32
+ /** Runtime schema for {@link Config}. */
33
+ export const Config = z.object({
34
+ maxReferences: z.number().step(1).min(1).max(MAX_REFERENCES).default(MAX_REFERENCES),
35
+ serveSessionScheme: z.boolean().default(true),
36
+ });
37
+ /**
38
+ * Register the pre-step mention expander.
39
+ * @param ctx - the mounting context.
40
+ * @param config - validated deployment settings.
41
+ */
42
+ export function apply(ctx, config = {}) {
43
+ const maxReferences = Math.min(config.maxReferences ?? MAX_REFERENCES, MAX_REFERENCES);
44
+ const serveSessionScheme = config.serveSessionScheme ?? true;
45
+ ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
46
+ const decision = await next();
47
+ if (decision.kind === 'reject' || signal.aborted)
48
+ return decision;
49
+ // The overwhelmingly common case is a prompt with no mentions at all, and
50
+ // this runs on every step of every turn. Screening with substring scans
51
+ // before any parsing keeps that case free.
52
+ const target = decision.messages.findIndex(candidate => candidate.source.kind === 'user' && messageText(candidate).some(text => mentions(text, serveSessionScheme)));
53
+ if (target < 0)
54
+ return decision;
55
+ const original = decision.messages[target];
56
+ if (original === undefined)
57
+ return decision;
58
+ let parsed;
59
+ try {
60
+ parsed = parseMessage(original, serveSessionScheme);
61
+ }
62
+ catch (error) {
63
+ // A malformed URI inside an explicit mention is the user asking for
64
+ // something that does not exist. Saying so beats sending a prompt whose
65
+ // reference silently resolves to nothing.
66
+ return entered(decision, target, [notice(describe(error))], original);
67
+ }
68
+ if (parsed.references.length === 0 && parsed.sessionReferences.length === 0 && parsed.workspaceReferences.length === 0)
69
+ return decision;
70
+ let contexts;
71
+ try {
72
+ contexts = await resolve(ctx, agent, parsed, { maxReferences, serveSessionScheme }, signal);
73
+ }
74
+ catch (error) {
75
+ if (signal.aborted)
76
+ return decision;
77
+ contexts = [notice(describe(error))];
78
+ }
79
+ if (signal.aborted)
80
+ return decision;
81
+ return entered(decision, target, contexts, parsed.message);
82
+ }, { prepend: true });
83
+ }
84
+ /**
85
+ * Authorize and render the named references without reading their bodies.
86
+ * The agent decides whether the current request warrants `reference_read`.
87
+ */
88
+ async function resolve(ctx, agent, parsed, limits, signal) {
89
+ const contexts = [];
90
+ if (parsed.workspaceReferences.length > 0) {
91
+ contexts.push(createUserMessage({
92
+ content: [{ type: 'text', text: await renderWorkspaceReferences(agent, parsed.workspaceReferences) }],
93
+ source: { kind: 'plugin', plugin: name, form: 'recall' },
94
+ }));
95
+ }
96
+ if (parsed.sessionReferences.length > 0) {
97
+ const resolver = ctx.get('sessionReferenceResolver');
98
+ if (resolver === undefined) {
99
+ throw new ReferenceAnythingError('this deployment cannot reference other harness sessions: the cross-session resolver is not mounted', 'SOURCE_UNAVAILABLE');
100
+ }
101
+ const prepared = await resolver.prepare(agent, contentOf(parsed.message), [...parsed.sessionReferences], signal);
102
+ if (prepared.additionalContext !== undefined)
103
+ contexts.push(prepared.additionalContext);
104
+ }
105
+ if (parsed.references.length > 0) {
106
+ const inputs = dedupe(parsed.references);
107
+ if (inputs.length > limits.maxReferences) {
108
+ throw new ReferenceAnythingError(`a message may reference at most ${limits.maxReferences} outside conversation`
109
+ + `${limits.maxReferences === 1 ? '' : 's'}; this one named ${inputs.length}`, 'REFERENCE_TOO_MANY');
110
+ }
111
+ for (const input of inputs)
112
+ ctx.references.grant(String(agent.session.id), input.ref);
113
+ const rendered = renderDeferredReferences(inputs);
114
+ contexts.push(createUserMessage({
115
+ content: [{ type: 'text', text: rendered.text }],
116
+ source: {
117
+ kind: 'reference-anything',
118
+ form: 'recall',
119
+ version: 1,
120
+ references: rendered.provenance,
121
+ },
122
+ }));
123
+ }
124
+ return contexts;
125
+ }
126
+ /**
127
+ * Replace the claimed message and place the background immediately before it.
128
+ *
129
+ * Adjacency is the point: background is only meaningful next to the prompt
130
+ * that named it, and the prompt stays last so it sits closest to the model's
131
+ * answer.
132
+ */
133
+ function entered(decision, target, contexts, message) {
134
+ const replaced = decision.messages.with(target, message);
135
+ return { kind: 'enter', messages: replaced.toSpliced(target, 0, ...contexts) };
136
+ }
137
+ /** Rewrite both schemes' mentions to readable text, preserving message identity. */
138
+ function parseMessage(message, serveSessionScheme) {
139
+ const references = [];
140
+ const sessionReferences = [];
141
+ const workspaceReferences = [];
142
+ let changed = false;
143
+ const content = message.content.map((block) => {
144
+ if (block.type !== 'text')
145
+ return block;
146
+ const own = parseReferenceText(block.text);
147
+ references.push(...own.references);
148
+ let text = own.text;
149
+ if (text.includes(WORKSPACE_REFERENCE_SCHEME)) {
150
+ const workspace = parseWorkspaceReferenceText(text);
151
+ workspaceReferences.push(...workspace.references);
152
+ text = workspace.text;
153
+ }
154
+ if (serveSessionScheme && text.includes(SESSION_REFERENCE_SCHEME)) {
155
+ const session = parseSessionReferenceText(text);
156
+ sessionReferences.push(...session.references);
157
+ text = session.text;
158
+ }
159
+ if (text === block.text)
160
+ return block;
161
+ changed = true;
162
+ return { ...block, text };
163
+ });
164
+ // freezeMessage keeps the existing identity: the loop claimed this message
165
+ // by id, so minting a new one would log something the inbox never claimed.
166
+ return {
167
+ message: changed ? freezeMessage({ ...message, content }) : message,
168
+ references,
169
+ sessionReferences,
170
+ workspaceReferences,
171
+ };
172
+ }
173
+ /** First-appearance order, one entry per distinct reference. */
174
+ function dedupe(references) {
175
+ const seen = new Set();
176
+ const unique = [];
177
+ for (const reference of references) {
178
+ const key = JSON.stringify([reference.ref.source, reference.ref.id]);
179
+ if (seen.has(key))
180
+ continue;
181
+ seen.add(key);
182
+ unique.push(reference);
183
+ }
184
+ return unique;
185
+ }
186
+ function mentions(text, serveSessionScheme) {
187
+ return mayContainReference(text) || text.includes(WORKSPACE_REFERENCE_SCHEME) || (serveSessionScheme && text.includes(SESSION_REFERENCE_SCHEME));
188
+ }
189
+ function messageText(message) {
190
+ return message.content.flatMap(block => block.type === 'text' ? [block.text] : []);
191
+ }
192
+ function contentOf(message) {
193
+ return message.content.map(block => ({ ...block }));
194
+ }
195
+ function notice(summary) {
196
+ return createUserMessage({
197
+ content: [{
198
+ type: 'text',
199
+ text: `A conversation reference in the previous message could not be resolved: ${summary}`,
200
+ }],
201
+ source: {
202
+ kind: 'plugin',
203
+ plugin: name,
204
+ form: 'notice',
205
+ summary: boundContextSummary(summary),
206
+ },
207
+ });
208
+ }
209
+ /**
210
+ * Flatten an error and its causes into one line for the user.
211
+ *
212
+ * The outer error names which reference failed; the cause names why, and why
213
+ * is the actionable half — "the browser is closed" tells someone what to do
214
+ * where "failed to read chat.json" does not. Bounded in depth so a long
215
+ * provider chain cannot crowd out the message.
216
+ */
217
+ function describe(error) {
218
+ const parts = [];
219
+ const seen = new Set();
220
+ let current = error;
221
+ while (current instanceof Error && !seen.has(current) && parts.length < 3) {
222
+ seen.add(current);
223
+ const code = current instanceof ReferenceAnythingError ? `${current.code} — ` : '';
224
+ parts.push(`${code}${current.message}`);
225
+ current = current.cause;
226
+ }
227
+ return parts.length === 0 ? String(error) : parts.join(': ');
228
+ }