dsh-reference-anything 0.3.1 → 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.
Files changed (107) hide show
  1. package/LICENSE +22 -22
  2. package/NOTICE.md +52 -25
  3. package/README.md +334 -285
  4. package/cordis.patch.yml +89 -18
  5. package/lib/client.js +13530 -517
  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
package/lib/tool.js CHANGED
@@ -8,12 +8,15 @@
8
8
  *
9
9
  * @module dsh-reference-anything/tool
10
10
  */
11
- import { mkdtemp, readFile, rm } from 'node:fs/promises';
11
+ import { lstat, mkdtemp, open, readFile, readdir, rm, rmdir, unlink } from 'node:fs/promises';
12
12
  import { tmpdir } from 'node:os';
13
- import { basename, join } from 'node:path';
13
+ import { basename } from 'node:path';
14
14
  import z from '@deepseek-ai/schemastery';
15
15
  import { defineTool } from '@deepseek-ai/dsh-tools';
16
16
  import { AttachmentId } from '@deepseek-ai/dsh-attachment';
17
+ import { appendDownloadDirectoryPrefix, validateDownloadDirectory } from "./download-directory.js";
18
+ import { ReferenceAnythingError } from "./errors.js";
19
+ import { DEFAULT_OPENCLI_MAX_STDOUT_BYTES, OpenCliError, OpenCliRunner } from "./opencli.js";
17
20
  import { addUnavailableAttachmentNotices, continuationFooter, frameReferenceBlock } from "./render.js";
18
21
  import { retainConversation } from "./retain.js";
19
22
  import { decodeReferenceUri, encodeReferenceUri } from "./uri.js";
@@ -30,6 +33,30 @@ export const DEFAULT_TIMEOUT_MS = 30_000;
30
33
  export const DEFAULT_READ_TURNS = 10;
31
34
  /** Ceiling on the model-supplied turn limit when none is configured. */
32
35
  export const DEFAULT_MAX_READ_TURNS = 100;
36
+ /** Hard attachment cap shared by drive and Web materialization. */
37
+ export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
38
+ /** Successful materializations remain usable for one hour. */
39
+ export const ATTACHMENT_LIFETIME_MS = 60 * 60 * 1000;
40
+ function fileIdentity(stats) {
41
+ return { dev: stats.dev, ino: stats.ino };
42
+ }
43
+ function sameFileIdentity(left, right) {
44
+ return left.dev === right.dev && left.ino === right.ino;
45
+ }
46
+ async function selectedBaseIdentity(path) {
47
+ try {
48
+ const stats = await lstat(path);
49
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
50
+ throw new ReferenceAnythingError('selected cloud-drive download directory must be a real directory, not a symbolic link', 'REFERENCE_INVALID_CONFIG');
51
+ }
52
+ return fileIdentity(stats);
53
+ }
54
+ catch (cause) {
55
+ if (cause instanceof ReferenceAnythingError)
56
+ throw cause;
57
+ throw new ReferenceAnythingError('selected cloud-drive download directory is unavailable', 'REFERENCE_INVALID_CONFIG', { cause });
58
+ }
59
+ }
33
60
  /** Runtime schema for {@link Config}. */
34
61
  export const Config = z.object({
35
62
  listLimit: z.number().step(1).min(1).default(20),
@@ -139,6 +166,11 @@ export function apply(ctx, config = {}) {
139
166
  type: 'string',
140
167
  description: 'Opaque nextCursor returned by the previous page. Do not combine with before.',
141
168
  },
169
+ detail: {
170
+ type: 'string',
171
+ enum: ['summary', 'full'],
172
+ description: 'For external Agent sessions: summary (default) keeps only tool names; full includes complete arguments and outputs.',
173
+ },
142
174
  },
143
175
  output: {
144
176
  schema: {
@@ -161,11 +193,18 @@ export function apply(ctx, config = {}) {
161
193
  type: 'object',
162
194
  additionalProperties: false,
163
195
  properties: {
164
- role: { type: 'string', required: true, enum: ['user', 'assistant'] },
196
+ role: {
197
+ type: 'string',
198
+ required: true,
199
+ enum: ['user', 'assistant', 'document'],
200
+ description: 'Who produced the text. `document` means it is file content rather than '
201
+ + 'anything anyone said, so it carries no speaker\'s authority at all.',
202
+ },
165
203
  text: { type: 'string', required: true },
166
204
  },
167
205
  },
168
206
  },
207
+ toolDetail: { type: 'string', enum: ['summary', 'full'] },
169
208
  },
170
209
  },
171
210
  render: (_args, value) => [{
@@ -181,7 +220,7 @@ export function apply(ctx, config = {}) {
181
220
  ...value.totalTurns === undefined ? {} : { totalTurns: value.totalTurns },
182
221
  hasOlder: value.hasOlder,
183
222
  ...value.nextCursor === undefined ? {} : { nextCursor: value.nextCursor },
184
- })}`,
223
+ })}${value.toolDetail === 'summary' ? '\n\n(Tool arguments and outputs are omitted. Rerun this window with detail: "full" when needed.)' : ''}`,
185
224
  }],
186
225
  presentationMeta: (_args, value) => ({
187
226
  label: value.label,
@@ -194,12 +233,14 @@ export function apply(ctx, config = {}) {
194
233
  isConcurrencySafe: () => true,
195
234
  async execute(args, exec) {
196
235
  const ref = decodeReferenceUri(args.uri);
197
- ctx.references.assertGranted(exec.agent ? String(exec.agent.session.id) : undefined, ref);
236
+ ctx.references.assertSessionGranted(exec.agent?.session, ref);
198
237
  const chatHistory = ctx.get('referenceChatHistory');
199
238
  const userMaximum = ref.source === 'web-chat' && chatHistory
200
239
  ? Math.min(maxReadTurns, chatHistory.store.settings.maxReadTurns)
201
240
  : maxReadTurns;
202
- const window = parseWindow(args, Math.min(readTurns, userMaximum), userMaximum);
241
+ const toolDetail = ref.source === 'local-agent' ? args.detail ?? 'summary' : undefined;
242
+ const window = { ...parseWindow(args, Math.min(readTurns, userMaximum), userMaximum),
243
+ ...toolDetail === undefined ? {} : { detail: toolDetail } };
203
244
  const snapshot = await ctx.references.read(ref, window, exec.signal);
204
245
  const slice = snapshot.body;
205
246
  // The turn window is the model's bound; this byte budget is only a
@@ -220,6 +261,7 @@ export function apply(ctx, config = {}) {
220
261
  omittedBytes: outcome.omittedBytes,
221
262
  ...(snapshot.revision ? { revision: snapshot.revision } : {}),
222
263
  ...(slice.nextCursor && dropped === 0 ? { nextCursor: slice.nextCursor } : {}),
264
+ ...toolDetail === undefined ? {} : { toolDetail },
223
265
  messages: outcome.items.map(item => ({ role: item.role, text: item.text })),
224
266
  };
225
267
  },
@@ -230,13 +272,235 @@ export function apply(ctx, config = {}) {
230
272
  rawInput: args.uri,
231
273
  }),
232
274
  })), 'reference-tool.reference_read()');
233
- const tempRoots = new Set();
275
+ const tempRoots = new Map();
276
+ let disposed = false;
277
+ const scheduleTempCleanup = (root, delay) => {
278
+ const state = tempRoots.get(root);
279
+ if (!state || disposed)
280
+ return;
281
+ if (state.timer !== undefined)
282
+ clearTimeout(state.timer);
283
+ const timer = setTimeout(() => {
284
+ void removeTempRoot(root).catch(() => {
285
+ ctx.logger.warn('reference attachment temporary cleanup failed; the tracked path will be retried');
286
+ });
287
+ }, delay);
288
+ timer.unref?.();
289
+ state.timer = timer;
290
+ };
291
+ const removeTempRoot = async (root) => {
292
+ const state = tempRoots.get(root);
293
+ if (!state) {
294
+ ctx.logger.warn('ignored cleanup request for an untracked attachment temporary directory');
295
+ return;
296
+ }
297
+ if (state.removal)
298
+ return state.removal;
299
+ if (state.timer !== undefined)
300
+ clearTimeout(state.timer);
301
+ state.timer = undefined;
302
+ state.removed = false;
303
+ const removal = (async () => {
304
+ if (state.selectedRootIdentity) {
305
+ if (!state.selectedBasePath || !state.selectedBaseIdentity) {
306
+ ctx.logger.warn('selected attachment temporary directory lost its base identity; cleanup was abandoned');
307
+ return 'abandoned';
308
+ }
309
+ let currentBase;
310
+ try {
311
+ const baseStats = await lstat(state.selectedBasePath);
312
+ if (!baseStats.isDirectory() || baseStats.isSymbolicLink()) {
313
+ ctx.logger.warn('selected attachment base directory changed type; cleanup was abandoned');
314
+ return 'abandoned';
315
+ }
316
+ currentBase = fileIdentity(baseStats);
317
+ }
318
+ catch (cause) {
319
+ if (cause.code === 'ENOENT') {
320
+ ctx.logger.warn('selected attachment base directory disappeared; cleanup was abandoned');
321
+ return 'abandoned';
322
+ }
323
+ throw cause;
324
+ }
325
+ if (!sameFileIdentity(currentBase, state.selectedBaseIdentity)) {
326
+ ctx.logger.warn('selected attachment base directory identity changed; cleanup was abandoned');
327
+ return 'abandoned';
328
+ }
329
+ let current;
330
+ try {
331
+ current = fileIdentity(await lstat(root));
332
+ }
333
+ catch (cause) {
334
+ if (cause.code === 'ENOENT')
335
+ return 'missing';
336
+ throw cause;
337
+ }
338
+ if (!sameFileIdentity(current, state.selectedRootIdentity)) {
339
+ ctx.logger.warn('selected attachment temporary directory identity changed; cleanup was abandoned');
340
+ return 'abandoned';
341
+ }
342
+ const selectedFiles = state.selectedFiles ?? new Map();
343
+ for (const [name, expected] of selectedFiles) {
344
+ let actual;
345
+ try {
346
+ actual = fileIdentity(await lstat(appendDownloadDirectoryPrefix(root, name)));
347
+ }
348
+ catch (cause) {
349
+ if (cause.code === 'ENOENT') {
350
+ selectedFiles.delete(name);
351
+ continue;
352
+ }
353
+ ctx.logger.warn('selected attachment temporary file identity could not be verified; cleanup was abandoned');
354
+ return 'abandoned';
355
+ }
356
+ if (!sameFileIdentity(actual, expected)) {
357
+ ctx.logger.warn('selected attachment temporary file identity changed; cleanup was abandoned');
358
+ return 'abandoned';
359
+ }
360
+ }
361
+ const rootBeforeUnlink = fileIdentity(await lstat(root));
362
+ if (!sameFileIdentity(rootBeforeUnlink, state.selectedRootIdentity)) {
363
+ ctx.logger.warn('selected attachment temporary directory identity changed; cleanup was abandoned');
364
+ return 'abandoned';
365
+ }
366
+ for (const name of [...selectedFiles.keys()]) {
367
+ try {
368
+ await unlink(appendDownloadDirectoryPrefix(root, name));
369
+ }
370
+ catch (cause) {
371
+ if (cause.code !== 'ENOENT')
372
+ throw cause;
373
+ }
374
+ selectedFiles.delete(name);
375
+ }
376
+ if ((await readdir(root)).length !== 0) {
377
+ ctx.logger.warn('selected attachment temporary directory contains foreign entries; directory removal was abandoned');
378
+ return 'abandoned';
379
+ }
380
+ const rootBeforeRemoval = fileIdentity(await lstat(root));
381
+ if (!sameFileIdentity(rootBeforeRemoval, state.selectedRootIdentity)) {
382
+ ctx.logger.warn('selected attachment temporary directory identity changed; cleanup was abandoned');
383
+ return 'abandoned';
384
+ }
385
+ await rmdir(root);
386
+ return 'removed';
387
+ }
388
+ await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
389
+ return 'removed';
390
+ })()
391
+ .then((outcome) => {
392
+ state.removal = undefined;
393
+ if (outcome === 'removed' || outcome === 'missing') {
394
+ state.removed = true;
395
+ if (state.activeOperations === 0)
396
+ tempRoots.delete(root);
397
+ }
398
+ else if (!disposed && state.retries < 3) {
399
+ state.retries += 1;
400
+ scheduleTempCleanup(root, 1_000);
401
+ }
402
+ })
403
+ .catch((cause) => {
404
+ state.removal = undefined;
405
+ if (!disposed && state.retries < 3) {
406
+ state.retries += 1;
407
+ scheduleTempCleanup(root, 1_000);
408
+ }
409
+ throw new ReferenceAnythingError('could not remove attachment temporary files', 'REFERENCE_READ_FAILED', { cause });
410
+ });
411
+ state.removal = removal;
412
+ return removal;
413
+ };
414
+ const createTempRoot = async (base, prefix, expectedBaseIdentity) => {
415
+ if (disposed)
416
+ throw new ReferenceAnythingError('attachment materializer has been disposed', 'REFERENCE_CANCELLED');
417
+ const selectedBase = expectedBaseIdentity !== undefined;
418
+ if (expectedBaseIdentity) {
419
+ const currentBaseIdentity = await selectedBaseIdentity(base);
420
+ if (!sameFileIdentity(currentBaseIdentity, expectedBaseIdentity)) {
421
+ throw new ReferenceAnythingError('selected cloud-drive download directory changed before temporary storage was created', 'REFERENCE_INVALID_CONFIG');
422
+ }
423
+ }
424
+ let root;
425
+ try {
426
+ root = await mkdtemp(appendDownloadDirectoryPrefix(base, prefix));
427
+ }
428
+ catch (cause) {
429
+ throw new ReferenceAnythingError(selectedBase
430
+ ? 'selected cloud-drive download directory is unavailable or not writable'
431
+ : 'could not create attachment temporary storage', selectedBase ? 'REFERENCE_INVALID_CONFIG' : 'REFERENCE_READ_FAILED', { cause });
432
+ }
433
+ let selectedRootIdentity;
434
+ if (selectedBase) {
435
+ try {
436
+ selectedRootIdentity = fileIdentity(await lstat(root));
437
+ }
438
+ catch (cause) {
439
+ ctx.logger.warn('could not record selected attachment temporary directory identity; cleanup was abandoned');
440
+ throw new ReferenceAnythingError('could not secure selected cloud-drive temporary storage', 'REFERENCE_INVALID_CONFIG', { cause });
441
+ }
442
+ }
443
+ tempRoots.set(root, {
444
+ retries: 0,
445
+ activeOperations: 0,
446
+ removed: false,
447
+ ...(selectedRootIdentity && expectedBaseIdentity ? {
448
+ selectedBasePath: base,
449
+ selectedBaseIdentity: expectedBaseIdentity,
450
+ selectedRootIdentity,
451
+ selectedFiles: new Map(),
452
+ } : {}),
453
+ });
454
+ if (expectedBaseIdentity) {
455
+ const baseAfterCreation = await selectedBaseIdentity(base);
456
+ if (!sameFileIdentity(baseAfterCreation, expectedBaseIdentity)) {
457
+ ctx.logger.warn('selected attachment base changed while temporary storage was being created; cleanup was abandoned');
458
+ throw new ReferenceAnythingError('selected cloud-drive download directory changed while temporary storage was created', 'REFERENCE_INVALID_CONFIG');
459
+ }
460
+ }
461
+ if (disposed) {
462
+ await removeTempRoot(root);
463
+ throw new ReferenceAnythingError('attachment materializer has been disposed', 'REFERENCE_CANCELLED');
464
+ }
465
+ return root;
466
+ };
467
+ const retainTempRoot = async (root) => {
468
+ if (disposed) {
469
+ await removeTempRoot(root);
470
+ throw new ReferenceAnythingError('attachment materializer has been disposed', 'REFERENCE_CANCELLED');
471
+ }
472
+ scheduleTempCleanup(root, ATTACHMENT_LIFETIME_MS);
473
+ };
474
+ const beginTempRootOperation = (root) => {
475
+ const state = tempRoots.get(root);
476
+ if (!state)
477
+ throw new ReferenceAnythingError('attachment temporary storage is no longer tracked', 'REFERENCE_CANCELLED');
478
+ state.activeOperations += 1;
479
+ };
480
+ const endTempRootOperation = (root) => {
481
+ const state = tempRoots.get(root);
482
+ if (!state)
483
+ return;
484
+ state.activeOperations = Math.max(0, state.activeOperations - 1);
485
+ if (state.activeOperations === 0 && state.removed && !state.removal)
486
+ tempRoots.delete(root);
487
+ };
488
+ const registerSelectedTempFile = (root, localPath, identity) => {
489
+ const selectedFiles = tempRoots.get(root)?.selectedFiles;
490
+ if (!selectedFiles)
491
+ return;
492
+ selectedFiles.set(basename(localPath), identity);
493
+ };
234
494
  ctx.effect(() => async () => {
235
- await Promise.all([...tempRoots].map(path => rm(path, { recursive: true, force: true }).catch(() => { })));
495
+ disposed = true;
496
+ const settled = await Promise.allSettled([...tempRoots.keys()].map(removeTempRoot));
497
+ const failure = settled.find(result => result.status === 'rejected');
498
+ if (failure?.status === 'rejected')
499
+ throw failure.reason;
236
500
  }, 'reference-tool.attachmentTempCleanup()');
237
501
  ctx.effect(() => ctx.tools.register(defineTool({
238
502
  name: 'reference_attachment_read',
239
- description: 'Read one attachment from an authorized Web conversation. The bytes are fetched on demand through the logged-in provider page.',
503
+ description: 'Materialize one authorized Web-conversation attachment or cloud-drive document on demand.',
240
504
  parameters: {
241
505
  uri: { type: 'string', required: true, description: 'The dsh-ref URI that exposed the attachment.' },
242
506
  attachmentId: { type: 'string', required: true, description: 'Attachment id exactly as shown in the conversation page.' },
@@ -270,45 +534,107 @@ export function apply(ctx, config = {}) {
270
534
  isConcurrencySafe: () => true,
271
535
  async execute(args, exec) {
272
536
  const ref = decodeReferenceUri(args.uri);
273
- ctx.references.assertGranted(exec.agent ? String(exec.agent.session.id) : undefined, ref);
274
- if (ref.source !== 'web-chat')
275
- throw new Error('attachments are available only for synchronized Web conversations');
537
+ ctx.references.assertSessionGranted(exec.agent?.session, ref);
538
+ if (ref.source === 'cloud-drive') {
539
+ if (args.attachmentId !== 'file') {
540
+ throw new ReferenceAnythingError('cloud-drive documents use attachmentId "file" exactly as shown by reference_read', 'ATTACHMENT_UNAVAILABLE');
541
+ }
542
+ const service = ctx.get('referenceCloudDrive');
543
+ if (!service)
544
+ throw new ReferenceAnythingError('cloud-drive service is not mounted', 'SOURCE_UNAVAILABLE');
545
+ const history = ctx.get('referenceChatHistory');
546
+ const configuredValue = history?.store.settings
547
+ ?.cloudDriveDownloadDirectory;
548
+ if (configuredValue !== undefined && typeof configuredValue !== 'string') {
549
+ throw new ReferenceAnythingError('selected cloud-drive download directory must be a string path', 'REFERENCE_INVALID_CONFIG');
550
+ }
551
+ const configuredBase = await validateDownloadDirectory(configuredValue ?? '');
552
+ const usesConfiguredBase = configuredBase !== '';
553
+ const configuredBaseIdentity = usesConfiguredBase ? await selectedBaseIdentity(configuredBase) : undefined;
554
+ let attachment;
555
+ try {
556
+ attachment = await service.attachment(ref, MAX_ATTACHMENT_BYTES, exec.signal);
557
+ }
558
+ catch (error) {
559
+ throw attachmentFailure(error, exec.signal, 'cloud-drive');
560
+ }
561
+ if (attachment.bytes.byteLength > MAX_ATTACHMENT_BYTES) {
562
+ throw new ReferenceAnythingError('cloud-drive attachment exceeds 25 MiB', 'ATTACHMENT_TOO_LARGE');
563
+ }
564
+ const root = await createTempRoot(usesConfiguredBase ? configuredBase : tmpdir(), 'dsh-reference-drive-', configuredBaseIdentity);
565
+ beginTempRootOperation(root);
566
+ try {
567
+ const localPath = attachmentLocalPath(root, attachment.name);
568
+ const handle = await open(localPath, 'wx', 0o600);
569
+ try {
570
+ registerSelectedTempFile(root, localPath, fileIdentity(await handle.stat()));
571
+ await handle.writeFile(attachment.bytes);
572
+ }
573
+ finally {
574
+ await handle.close();
575
+ }
576
+ const bytes = Buffer.from(attachment.bytes);
577
+ const mimeType = sniffMime(bytes, attachment.mimeType);
578
+ const image = isRenderableImage(mimeType) ? await saveImage(ctx, bytes, mimeType, attachment.name) : undefined;
579
+ await retainTempRoot(root);
580
+ return { name: attachment.name, mimeType, size: bytes.byteLength, localPath, ...(image ? { image } : {}) };
581
+ }
582
+ catch (error) {
583
+ await removeTempRoot(root);
584
+ throw attachmentFailure(error, exec.signal, 'cloud-drive');
585
+ }
586
+ finally {
587
+ endTempRootOperation(root);
588
+ }
589
+ }
590
+ if (ref.source !== 'web-chat') {
591
+ throw new ReferenceAnythingError('attachments are available only for synchronized Web conversations and cloud-drive documents', 'ATTACHMENT_UNAVAILABLE');
592
+ }
276
593
  const service = ctx.get('referenceChatHistory');
277
594
  if (!service)
278
- throw new Error('Web conversation history service is not mounted');
595
+ throw new ReferenceAnythingError('Web conversation history service is not mounted', 'SOURCE_UNAVAILABLE');
279
596
  const conversation = service.store.conversations.get(ref.id);
280
597
  const revision = conversation?.currentRevision;
281
598
  if (!conversation)
282
- throw new Error('conversation is not in the local title index');
599
+ throw new ReferenceAnythingError('conversation is not in the local title index', 'REFERENCE_NOT_FOUND');
600
+ if (!/^[a-f0-9]{64}$/.test(conversation.accountScope)) {
601
+ throw new ReferenceAnythingError(`conversation has no verified ${conversation.provider} account scope; ask the user to sync ${conversation.provider} and reselect the conversation from the refreshed @ list, then retry`, 'REFERENCE_ACCOUNT_MISMATCH');
602
+ }
283
603
  const attachment = service.store.settings.historyMode === 'metadata-only'
284
604
  ? service.liveAttachment(ref.id, args.attachmentId)
285
605
  : revision ? service.store.attachment(ref.id, revision, args.attachmentId) : undefined;
286
606
  if (!attachment?.locator || attachment.status !== 'available') {
287
- throw new Error('ATTACHMENT_UNAVAILABLE: provider supplied no stable attachment locator');
607
+ throw new ReferenceAnythingError('provider supplied no stable attachment locator', 'ATTACHMENT_UNAVAILABLE');
608
+ }
609
+ const root = await createTempRoot(tmpdir(), 'dsh-reference-attachment-');
610
+ beginTempRootOperation(root);
611
+ try {
612
+ const localPath = attachmentLocalPath(root, attachment.name);
613
+ const settings = service.store.settings;
614
+ const runner = new OpenCliRunner({
615
+ executable: settings.opencliPath, profile: settings.profile, timeoutMs,
616
+ maxStdoutBytes: DEFAULT_OPENCLI_MAX_STDOUT_BYTES,
617
+ });
618
+ await runner.attachment(conversation.provider, attachment.locator, localPath, MAX_ATTACHMENT_BYTES, exec.signal, conversation.accountScope);
619
+ const bytes = await readFile(localPath);
620
+ if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
621
+ throw new ReferenceAnythingError('attachment exceeds 25 MiB', 'ATTACHMENT_TOO_LARGE');
622
+ }
623
+ const mimeType = sniffMime(bytes, attachment.mimeType);
624
+ const image = isRenderableImage(mimeType) ? await saveImage(ctx, bytes, mimeType, attachment.name) : undefined;
625
+ await retainTempRoot(root);
626
+ return {
627
+ name: attachment.name, mimeType, size: bytes.byteLength, localPath,
628
+ ...(image ? { image } : {}),
629
+ };
630
+ }
631
+ catch (error) {
632
+ await removeTempRoot(root);
633
+ throw attachmentFailure(error, exec.signal, conversation.provider);
634
+ }
635
+ finally {
636
+ endTempRootOperation(root);
288
637
  }
289
- const root = await mkdtemp(join(tmpdir(), 'dsh-reference-attachment-'));
290
- tempRoots.add(root);
291
- const safeName = basename(attachment.name).replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') || 'attachment';
292
- const localPath = join(root, safeName);
293
- const settings = service.store.settings;
294
- const runner = new (await import("./opencli.js")).OpenCliRunner({
295
- executable: settings.opencliPath, profile: settings.profile, timeoutMs, maxStdoutBytes: 32 * 1024 * 1024,
296
- });
297
- await runner.attachment(conversation.provider, attachment.locator, localPath, 25 * 1024 * 1024, exec.signal);
298
- const bytes = await readFile(localPath);
299
- if (bytes.byteLength > 25 * 1024 * 1024)
300
- throw new Error('ATTACHMENT_TOO_LARGE: attachment exceeds 25 MiB');
301
- const mimeType = sniffMime(bytes, attachment.mimeType);
302
- const timer = setTimeout(() => {
303
- tempRoots.delete(root);
304
- void rm(root, { recursive: true, force: true });
305
- }, 60 * 60 * 1000);
306
- timer.unref?.();
307
- const image = mimeType.startsWith('image/') ? await saveImage(ctx, bytes, mimeType, attachment.name) : undefined;
308
- return {
309
- name: attachment.name, mimeType, size: bytes.byteLength, localPath,
310
- ...(image ? { image } : {}),
311
- };
312
638
  },
313
639
  presentCall: args => ({ card: 'generic', title: 'Read conversation attachment', kind: 'read', rawInput: args.attachmentId }),
314
640
  })), 'reference-tool.reference_attachment_read()');
@@ -349,16 +675,63 @@ function sniffMime(bytes, declared) {
349
675
  : detected.subarray(0, 4).toString('ascii') === 'RIFF' && detected.subarray(8, 12).toString('ascii') === 'WEBP' ? 'image/webp'
350
676
  : detected.subarray(0, 5).toString('ascii') === '%PDF-' ? 'application/pdf'
351
677
  : 'application/octet-stream';
352
- if (declared.startsWith('image/') && !mime.startsWith('image/'))
353
- throw new Error('ATTACHMENT_UNAVAILABLE: attachment MIME did not match its bytes');
678
+ if (isRenderableImage(declared) && mime !== declared) {
679
+ throw new ReferenceAnythingError('attachment MIME did not match its bytes', 'ATTACHMENT_UNAVAILABLE');
680
+ }
354
681
  return mime === 'application/octet-stream' && declared ? declared : mime;
355
682
  }
683
+ function isRenderableImage(mimeType) {
684
+ return mimeType === 'image/png' || mimeType === 'image/jpeg' || mimeType === 'image/webp' || mimeType === 'image/gif';
685
+ }
686
+ function safeAttachmentName(name) {
687
+ const leaf = basename(name).replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/[. ]+$/g, '_') || 'file';
688
+ return `attachment-${leaf}`;
689
+ }
690
+ function attachmentLocalPath(root, name) {
691
+ // `safeAttachmentName` guarantees one leaf segment. Preserve the exact root
692
+ // string so a validated symlink/`..` path keeps the same OS resolution
693
+ // semantics for creation, use, and cleanup.
694
+ return appendDownloadDirectoryPrefix(root, safeAttachmentName(name));
695
+ }
356
696
  async function saveImage(ctx, bytes, mimeType, name) {
357
697
  const attachments = ctx.get('attachments');
358
- if (!attachments)
359
- throw new Error('ATTACHMENT_UNAVAILABLE: no DSH attachment store is mounted for image output');
360
- if (mimeType !== 'image/png' && mimeType !== 'image/jpeg' && mimeType !== 'image/webp' && mimeType !== 'image/gif') {
361
- throw new Error(`ATTACHMENT_UNAVAILABLE: unsupported image type ${mimeType}`);
698
+ if (!attachments) {
699
+ throw new ReferenceAnythingError('no DSH attachment store is mounted for image output', 'ATTACHMENT_UNAVAILABLE');
700
+ }
701
+ let saved;
702
+ try {
703
+ saved = await attachments.saveImage({ data: bytes, mediaType: mimeType, name });
704
+ }
705
+ catch (error) {
706
+ throw new ReferenceAnythingError('DSH image storage refused the attachment', 'ATTACHMENT_UNAVAILABLE', { cause: error });
707
+ }
708
+ if (!saved || typeof saved.attachmentId !== 'string' || !isRenderableImage(saved.mediaType)
709
+ || !Number.isInteger(saved.bytes) || !Number.isInteger(saved.width) || !Number.isInteger(saved.height)) {
710
+ throw new ReferenceAnythingError('DSH image storage returned invalid metadata', 'ATTACHMENT_UNAVAILABLE');
711
+ }
712
+ return {
713
+ attachmentId: saved.attachmentId,
714
+ mediaType: saved.mediaType,
715
+ bytes: saved.bytes,
716
+ width: saved.width,
717
+ height: saved.height,
718
+ ...(typeof saved.name === 'string' ? { name: saved.name } : {}),
719
+ };
720
+ }
721
+ function attachmentFailure(error, signal, provider) {
722
+ if (error instanceof ReferenceAnythingError)
723
+ return error;
724
+ if (signal?.aborted) {
725
+ return new ReferenceAnythingError('attachment materialization was cancelled', 'REFERENCE_CANCELLED', { cause: signal.reason });
726
+ }
727
+ if (error instanceof OpenCliError && error.code === 'PROVIDER_ACCOUNT_MISMATCH') {
728
+ return new ReferenceAnythingError(`attachment belongs to a different logged-in ${provider} account; ask the user to sync ${provider} and reselect the conversation from the refreshed @ list, then retry`, 'REFERENCE_ACCOUNT_MISMATCH', { cause: error });
729
+ }
730
+ if (error instanceof OpenCliError && error.code === 'ATTACHMENT_TOO_LARGE') {
731
+ return new ReferenceAnythingError('attachment exceeds 25 MiB', 'ATTACHMENT_TOO_LARGE', { cause: error });
732
+ }
733
+ if (error instanceof OpenCliError && error.code === 'PROVIDER_RATE_LIMIT') {
734
+ return new ReferenceAnythingError(`${provider} rate-limited account verification; wait before retrying the attachment`, 'PROVIDER_RATE_LIMIT', { cause: error });
362
735
  }
363
- return await attachments.saveImage({ data: bytes, mediaType: mimeType, name });
736
+ return new ReferenceAnythingError(`could not materialize the attachment from ${provider}`, 'REFERENCE_READ_FAILED', { cause: error });
364
737
  }
package/lib/typert.js CHANGED
@@ -5,6 +5,8 @@ export const TYPERT_MANIFEST = {
5
5
  key: 'referenceAnything', exportName: 'ReferenceAnythingRemote',
6
6
  description: 'Local conversation mirror, synchronization, and settings for @Conversation.', tags: [],
7
7
  members: [
8
+ { kind: 'method', name: 'agentSearch', signature: 'agentSearch(agent: Agent, input: AgentSearchInput, signal: AbortSignal): Promise<AgentCandidate[]>' },
9
+ { kind: 'method', name: 'driveSearch', signature: 'driveSearch(input: DriveSearchInput, signal: AbortSignal): Promise<DriveCandidate[]>' },
8
10
  { kind: 'method', name: 'search', signature: 'search(input: SearchInput): Promise<SearchResult[]>' },
9
11
  { kind: 'method', name: 'health', signature: 'health(signal: AbortSignal): Promise<Health>' },
10
12
  { kind: 'method', name: 'quickHealth', signature: 'quickHealth(signal: AbortSignal): Promise<Health>' },
@@ -26,10 +28,20 @@ export const TYPERT_MANIFEST = {
26
28
  { kind: 'method', name: 'deleteConversation', signature: 'deleteConversation(input: DeleteInput): Promise<boolean>' },
27
29
  { kind: 'method', name: 'storageStats', signature: 'storageStats(): StorageStats' },
28
30
  { kind: 'method', name: 'clearProvider', signature: 'clearProvider(input: ClearProviderInput): Promise<number>' },
29
- { kind: 'method', name: 'clearOlder', signature: 'clearOlder(input: ClearOlderInput): Promise<number>' },
30
31
  { kind: 'method', name: 'clearRemoteMissing', signature: 'clearRemoteMissing(signal: AbortSignal): Promise<number>' },
31
32
  { kind: 'method', name: 'clearOldAccounts', signature: 'clearOldAccounts(signal: AbortSignal): Promise<number>' },
32
33
  { kind: 'method', name: 'syncStates', signature: 'syncStates(): ProviderSyncState[]' },
34
+ { kind: 'method', name: 'openListStatus', signature: 'openListStatus(signal: AbortSignal): Promise<OpenListStatus>' },
35
+ { kind: 'method', name: 'openListInstall', signature: 'openListInstall(signal: AbortSignal): Promise<OpenListStatus>' },
36
+ { kind: 'method', name: 'openListUpgrade', signature: 'openListUpgrade(input: OpenListUpgradeInput, signal: AbortSignal): Promise<OpenListStatus>' },
37
+ { kind: 'method', name: 'openListConnectExternal', signature: 'openListConnectExternal(input: OpenListExternalConnect, signal: AbortSignal): Promise<OpenListStatus>' },
38
+ { kind: 'method', name: 'openListDisconnect', signature: 'openListDisconnect(signal: AbortSignal): Promise<OpenListStatus>' },
39
+ { kind: 'method', name: 'openListDrivers', signature: 'openListDrivers(signal: AbortSignal): Promise<OpenListDriver[]>' },
40
+ { kind: 'method', name: 'openListMounts', signature: 'openListMounts(signal: AbortSignal): Promise<OpenListMount[]>' },
41
+ { kind: 'method', name: 'openListCreateMount', signature: 'openListCreateMount(input: OpenListMountCreate, signal: AbortSignal): Promise<OpenListMount>' },
42
+ { kind: 'method', name: 'openListDisableMount', signature: 'openListDisableMount(input: OpenListDisableMountInput, signal: AbortSignal): Promise<boolean>' },
43
+ { kind: 'method', name: 'openListRemoveMount', signature: 'openListRemoveMount(input: OpenListMountInput, signal: AbortSignal): Promise<boolean>' },
44
+ { kind: 'method', name: 'openListReindex', signature: 'openListReindex(input: OpenListMountInput, signal: AbortSignal): Promise<OpenListReindex>' },
33
45
  ], types: [],
34
46
  }], events: [], objects: [] },
35
47
  invocations: REFERENCE_ANYTHING_INVOCATIONS,
package/lib/types.d.ts CHANGED
@@ -44,10 +44,25 @@ export interface ReferenceSummary {
44
44
  readonly messageCount?: number;
45
45
  readonly partial?: boolean;
46
46
  readonly syncedAt?: number;
47
+ /** Discovery used a bounded fallback and may not cover the whole source. */
48
+ readonly searchIncomplete?: boolean;
49
+ /** Picker navigation row; directories are never inserted as references. */
50
+ readonly isDirectory?: boolean;
47
51
  }
48
- /** One projected turn of a referenced conversation. */
52
+ /** One projected turn of a referenced conversation, or one block of a referenced file. */
49
53
  export interface ConversationItem {
50
- readonly role: 'user' | 'assistant';
54
+ /**
55
+ * Who produced this text.
56
+ *
57
+ * `document` exists because not every referent is a conversation. A file
58
+ * pulled from a cloud drive has an author, but not one of these two, and
59
+ * labelling its contents `assistant` would assert that some model said them
60
+ * while labelling it `user` would assert the current user did — the second
61
+ * being the more dangerous lie, since text that appears to come from the
62
+ * user reads as instruction rather than as data. A third value states the
63
+ * provenance instead of picking the least-wrong falsehood.
64
+ */
65
+ readonly role: 'user' | 'assistant' | 'document';
51
66
  /** Visible text of that turn. Tool calls, reasoning, and injected context are not projected. */
52
67
  readonly text: string;
53
68
  /** Attachment metadata only; bytes stay behind an explicit authorized read. */
@@ -78,6 +93,8 @@ export interface ReferenceWindow {
78
93
  readonly before?: number;
79
94
  /** Opaque revision-pinned continuation returned by a previous read. */
80
95
  readonly cursor?: string;
96
+ /** Local-agent tool fidelity for this read; other sources ignore it. */
97
+ readonly detail?: 'summary' | 'full';
81
98
  }
82
99
  /**
83
100
  * The turns a source returned, and where they sit in the whole conversation.
@@ -131,6 +148,15 @@ export interface ReferenceSnapshot extends ReferenceSummary {
131
148
  export interface ReferenceSource {
132
149
  /** Stable registry key, also the `source` half of every {@link ReferenceRef} it owns. */
133
150
  readonly id: string;
151
+ /**
152
+ * Whether reading this source's items needs a per-task grant.
153
+ *
154
+ * Sources whose items live outside the task's own workspace — another
155
+ * assistant's transcript, a remote drive — opt in, so the model can only
156
+ * read what its user actually named. Omitting it means ungated, which is
157
+ * right only when the material is already inside the task's scope.
158
+ */
159
+ readonly requiresGrant?: boolean;
134
160
  /**
135
161
  * Whether this source can currently be used. Must stay cheap and local —
136
162
  * no network calls, no subprocess launches — because discovery calls it on