@qvac/harness 0.8.9 → 0.8.10

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 (114) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/dist-lib/index.d.ts +1 -1
  3. package/dist-lib/index.js +51 -50
  4. package/dist-lib/lib/audio-transcode-bare.js +2 -2
  5. package/dist-lib/lib/capabilities/completion.d.ts +1 -2
  6. package/dist-lib/lib/capabilities/completion.js +29 -25
  7. package/dist-lib/lib/capabilities/tool-prompt-log.js +3 -3
  8. package/dist-lib/lib/capabilities/voice.js +7 -6
  9. package/dist-lib/lib/connect.js +18 -17
  10. package/dist-lib/lib/errors.d.ts +266 -0
  11. package/dist-lib/lib/errors.js +189 -0
  12. package/dist-lib/lib/helpers/load-stall.d.ts +1 -1
  13. package/dist-lib/lib/helpers/load-stall.js +2 -2
  14. package/dist-lib/lib/helpers/race-abort.d.ts +2 -1
  15. package/dist-lib/lib/helpers/race-abort.js +3 -4
  16. package/dist-lib/lib/hooks/built-in/arg-repair.js +2 -2
  17. package/dist-lib/lib/hooks/pipeline.js +4 -4
  18. package/dist-lib/lib/hooks/run.js +2 -2
  19. package/dist-lib/lib/log.d.ts +26 -2
  20. package/dist-lib/lib/log.js +66 -16
  21. package/dist-lib/lib/models/model-pool.js +11 -6
  22. package/dist-lib/lib/models/model-source.js +3 -2
  23. package/dist-lib/lib/no-redirect-fetch-bare.js +2 -1
  24. package/dist-lib/lib/no-redirect-fetch-impl.js +2 -1
  25. package/dist-lib/lib/office/extract.js +4 -3
  26. package/dist-lib/lib/office/index.js +2 -1
  27. package/dist-lib/lib/resolution-detector.js +3 -3
  28. package/dist-lib/lib/rpc-session.js +2 -1
  29. package/dist-lib/lib/sdk-loader.js +2 -2
  30. package/dist-lib/lib/serve.js +33 -29
  31. package/dist-lib/lib/spawn.d.ts +2 -4
  32. package/dist-lib/lib/spawn.js +14 -7
  33. package/dist-lib/lib/tools/edit-image.js +2 -1
  34. package/dist-lib/lib/tools/exec/adapters/python/attachments.js +8 -8
  35. package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.js +7 -6
  36. package/dist-lib/lib/tools/exec/adapters/python/interrupt-timer.js +2 -2
  37. package/dist-lib/lib/tools/exec/adapters/python/memfs.js +2 -2
  38. package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +8 -8
  39. package/dist-lib/lib/tools/exec/adapters/python/run-session.js +11 -7
  40. package/dist-lib/lib/tools/exec/adapters/python/wheels.js +5 -5
  41. package/dist-lib/lib/tools/exec/adapters/python/worker/active.js +2 -2
  42. package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +9 -8
  43. package/dist-lib/lib/tools/exec/adapters/python/worker/protocol.d.ts +2 -0
  44. package/dist-lib/lib/tools/exec/adapters/python/worker/serve.js +4 -4
  45. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn.d.ts +2 -1
  46. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn.js +2 -1
  47. package/dist-lib/lib/tools/exec/obsidian/note-name.js +2 -1
  48. package/dist-lib/lib/tools/exec/obsidian/safe-path.js +5 -4
  49. package/dist-lib/lib/tools/exec/obsidian/vault-create.js +3 -2
  50. package/dist-lib/lib/tools/exec/obsidian/vault-fs.js +2 -2
  51. package/dist-lib/lib/tools/exec/obsidian/vault-paths.js +3 -2
  52. package/dist-lib/lib/tools/exec/obsidian/vault-read.js +6 -5
  53. package/dist-lib/lib/tools/exec/obsidian/vault-write.js +13 -12
  54. package/dist-lib/lib/tools/exec/runner.js +2 -1
  55. package/dist-lib/lib/tools/files/edit-file.d.ts +3 -0
  56. package/dist-lib/lib/tools/files/edit-file.js +73 -0
  57. package/dist-lib/lib/tools/files/glob.d.ts +3 -0
  58. package/dist-lib/lib/tools/files/glob.js +63 -0
  59. package/dist-lib/lib/tools/files/grep.d.ts +3 -0
  60. package/dist-lib/lib/tools/files/grep.js +114 -0
  61. package/dist-lib/lib/tools/files/index.d.ts +3 -0
  62. package/dist-lib/lib/tools/files/index.js +23 -0
  63. package/dist-lib/lib/tools/files/list-dir.d.ts +5 -0
  64. package/dist-lib/lib/tools/files/list-dir.js +130 -0
  65. package/dist-lib/lib/tools/files/make-dir.d.ts +3 -0
  66. package/dist-lib/lib/tools/files/make-dir.js +47 -0
  67. package/dist-lib/lib/tools/files/read-file.d.ts +3 -0
  68. package/dist-lib/lib/tools/files/read-file.js +103 -0
  69. package/dist-lib/lib/tools/files/transfer.d.ts +4 -0
  70. package/dist-lib/lib/tools/files/transfer.js +82 -0
  71. package/dist-lib/lib/tools/files/trash-file.d.ts +4 -0
  72. package/dist-lib/lib/tools/files/trash-file.js +61 -0
  73. package/dist-lib/lib/tools/files/write-file.d.ts +3 -0
  74. package/dist-lib/lib/tools/files/write-file.js +55 -0
  75. package/dist-lib/lib/tools/fs/document-text.d.ts +2 -0
  76. package/dist-lib/lib/tools/fs/document-text.js +38 -0
  77. package/dist-lib/lib/tools/fs/edit-match.d.ts +11 -0
  78. package/dist-lib/lib/tools/fs/edit-match.js +85 -0
  79. package/dist-lib/lib/tools/fs/remote.d.ts +2 -1
  80. package/dist-lib/lib/tools/fs/remote.js +4 -4
  81. package/dist-lib/lib/tools/fs/scope.d.ts +1 -1
  82. package/dist-lib/lib/tools/fs/scope.js +3 -2
  83. package/dist-lib/lib/tools/fs/targets.d.ts +10 -0
  84. package/dist-lib/lib/tools/fs/targets.js +32 -0
  85. package/dist-lib/lib/tools/fs/tool-path.d.ts +24 -0
  86. package/dist-lib/lib/tools/fs/tool-path.js +58 -0
  87. package/dist-lib/lib/tools/fs/walk.js +3 -3
  88. package/dist-lib/lib/tools/generate-image.d.ts +2 -2
  89. package/dist-lib/lib/tools/generate-image.js +5 -6
  90. package/dist-lib/lib/tools/office.js +2 -1
  91. package/dist-lib/lib/tools/skill.js +2 -1
  92. package/dist-lib/lib/tools/skills/bundled-dir.js +4 -4
  93. package/dist-lib/lib/tools/skills/catalog.js +2 -2
  94. package/dist-lib/lib/tools/skills/credential-health.js +3 -3
  95. package/dist-lib/lib/tools/skills/overlay-crud.js +3 -2
  96. package/dist-lib/lib/tools/translate.d.ts +2 -2
  97. package/dist-lib/lib/tools/translate.js +5 -6
  98. package/dist-lib/lib/tools/weather-lookup/geocode.js +5 -4
  99. package/dist-lib/lib/web-search/engines/bing.js +2 -1
  100. package/dist-lib/lib/web-search/engines/google.js +2 -1
  101. package/dist-lib/lib/web-search/fetch-html.d.ts +5 -5
  102. package/dist-lib/lib/web-search/fetch-html.js +12 -15
  103. package/package.json +66 -24
  104. package/worker/harness-sidecar.mjs +12 -13
  105. package/dist-lib/lib/tools/create-documents.d.ts +0 -3
  106. package/dist-lib/lib/tools/create-documents.js +0 -95
  107. package/dist-lib/lib/tools/edit-documents.d.ts +0 -3
  108. package/dist-lib/lib/tools/edit-documents.js +0 -132
  109. package/dist-lib/lib/tools/file-glob.d.ts +0 -14
  110. package/dist-lib/lib/tools/file-glob.js +0 -118
  111. package/dist-lib/lib/tools/grep.d.ts +0 -5
  112. package/dist-lib/lib/tools/grep.js +0 -184
  113. package/dist-lib/lib/tools/read-files.d.ts +0 -5
  114. package/dist-lib/lib/tools/read-files.js +0 -157
@@ -1,15 +1,16 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { HarnessError } from '../../../errors.js';
3
4
  import { markdownFileName } from './note-name.js';
4
5
  import { appendToFile, dailyCandidates, ensureWithinRoot, FRONTMATTER_RE, getArg, getContentArg, moveFile, readFileSnapshot, resolveNotePath, todayStamp, updateFileText, writeIfUnchanged } from './vault-paths.js';
5
6
  export function handleAppend(vaultPath, args) {
6
7
  const notePath = resolveNotePath(vaultPath, args);
7
8
  if (!notePath || !fs.existsSync(notePath)) {
8
- throw new Error(`Note not found. Args: ${args.join(', ')}`);
9
+ throw new HarnessError('OBSIDIAN_NOTE_NOT_FOUND', [args]);
9
10
  }
10
11
  const content = getContentArg(args);
11
12
  if (!content) {
12
- throw new Error('Missing required argument: content=');
13
+ throw new HarnessError('OBSIDIAN_MISSING_ARGUMENT', ['content=']);
13
14
  }
14
15
  appendToFile(notePath, content);
15
16
  return `Appended to: ${path.relative(vaultPath, notePath)}`;
@@ -17,11 +18,11 @@ export function handleAppend(vaultPath, args) {
17
18
  export function handlePrepend(vaultPath, args) {
18
19
  const notePath = resolveNotePath(vaultPath, args);
19
20
  if (!notePath || !fs.existsSync(notePath)) {
20
- throw new Error(`Note not found. Args: ${args.join(', ')}`);
21
+ throw new HarnessError('OBSIDIAN_NOTE_NOT_FOUND', [args]);
21
22
  }
22
23
  const content = getContentArg(args);
23
24
  if (!content) {
24
- throw new Error('Missing required argument: content=');
25
+ throw new HarnessError('OBSIDIAN_MISSING_ARGUMENT', ['content=']);
25
26
  }
26
27
  updateFileText(notePath, (existing) => content + existing);
27
28
  return `Prepended to: ${path.relative(vaultPath, notePath)}`;
@@ -40,7 +41,7 @@ function uniqueTrashPath(base) {
40
41
  export function handleDelete(vaultPath, args) {
41
42
  const notePath = resolveNotePath(vaultPath, args);
42
43
  if (!notePath || !fs.existsSync(notePath)) {
43
- throw new Error(`Note not found. Args: ${args.join(', ')}`);
44
+ throw new HarnessError('OBSIDIAN_NOTE_NOT_FOUND', [args]);
44
45
  }
45
46
  const root = path.resolve(vaultPath);
46
47
  const trashPath = uniqueTrashPath(ensureWithinRoot(vaultPath, path.join('.trash', path.relative(root, notePath))));
@@ -51,11 +52,11 @@ export function handleDelete(vaultPath, args) {
51
52
  export function handleMove(vaultPath, args) {
52
53
  const notePath = resolveNotePath(vaultPath, args);
53
54
  if (!notePath || !fs.existsSync(notePath)) {
54
- throw new Error(`Note not found. Args: ${args.join(', ')}`);
55
+ throw new HarnessError('OBSIDIAN_NOTE_NOT_FOUND', [args]);
55
56
  }
56
57
  const to = getArg(args, 'to');
57
58
  if (!to) {
58
- throw new Error('Missing required argument: to=');
59
+ throw new HarnessError('OBSIDIAN_MISSING_ARGUMENT', ['to=']);
59
60
  }
60
61
  const destDir = ensureWithinRoot(vaultPath, to);
61
62
  if (!fs.existsSync(destDir)) {
@@ -68,11 +69,11 @@ export function handleMove(vaultPath, args) {
68
69
  export function handleRename(vaultPath, args) {
69
70
  const notePath = resolveNotePath(vaultPath, args);
70
71
  if (!notePath || !fs.existsSync(notePath)) {
71
- throw new Error(`Note not found. Args: ${args.join(', ')}`);
72
+ throw new HarnessError('OBSIDIAN_NOTE_NOT_FOUND', [args]);
72
73
  }
73
74
  const newName = getArg(args, 'name');
74
75
  if (!newName) {
75
- throw new Error('Missing required argument: name=');
76
+ throw new HarnessError('OBSIDIAN_MISSING_ARGUMENT', ['name=']);
76
77
  }
77
78
  const fileName = markdownFileName(newName);
78
79
  const relativeDir = path.relative(path.resolve(vaultPath), path.dirname(notePath));
@@ -96,11 +97,11 @@ function mergeIntoFrontmatter(fmBlock, newProps) {
96
97
  export function handlePropertySet(vaultPath, args) {
97
98
  const notePath = resolveNotePath(vaultPath, args);
98
99
  if (!notePath || !fs.existsSync(notePath)) {
99
- throw new Error(`Note not found. Args: ${args.join(', ')}`);
100
+ throw new HarnessError('OBSIDIAN_NOTE_NOT_FOUND', [args]);
100
101
  }
101
102
  const propArgs = args.filter((a) => !a.startsWith('file=') && !a.startsWith('path=') && a.includes('='));
102
103
  if (propArgs.length === 0) {
103
- throw new Error('No property key=value pairs provided.');
104
+ throw new HarnessError('OBSIDIAN_NO_PROPERTIES');
104
105
  }
105
106
  const newProps = propArgs.map((a) => {
106
107
  const eqIdx = a.indexOf('=');
@@ -120,7 +121,7 @@ export function handlePropertySet(vaultPath, args) {
120
121
  export function handleDailyAppend(vaultPath, args) {
121
122
  const content = getContentArg(args);
122
123
  if (!content) {
123
- throw new Error('Missing required argument: content=');
124
+ throw new HarnessError('OBSIDIAN_MISSING_ARGUMENT', ['content=']);
124
125
  }
125
126
  const dateStr = todayStamp();
126
127
  let target = null;
@@ -1,5 +1,6 @@
1
1
  import b4a from 'b4a';
2
2
  import process from 'process';
3
+ import { HarnessError } from '../../errors.js';
3
4
  import { AbortedError } from '../../helpers/race-abort.js';
4
5
  const DEFAULT_TIMEOUT_MS = 120_000;
5
6
  export const MAX_OUTPUT_CHARS = 8_192;
@@ -74,7 +75,7 @@ export function runCommand(command, options) {
74
75
  };
75
76
  options.signal?.addEventListener('abort', onAbort, { once: true });
76
77
  const timer = setTimeout(() => {
77
- finish(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
78
+ finish(() => reject(new HarnessError('COMMAND_TIMED_OUT', [timeoutMs])));
78
79
  terminateProcessTree(child, options.killProcess);
79
80
  }, timeoutMs);
80
81
  child.stdout?.on('data', (chunk) => appendOutput(stdout, chunk, maxOutputChars));
@@ -0,0 +1,3 @@
1
+ import type { ToolDefinition } from '../../capabilities/completion.ts';
2
+ import { type FsToolOptions } from '../fs/scope.ts';
3
+ export declare function createEditFileTool({ realpath, deniedRoots }?: FsToolOptions): ToolDefinition;
@@ -0,0 +1,73 @@
1
+ import b4a from 'b4a';
2
+ import { errorMessage } from '../../guards.js';
3
+ import { editMatch } from '../fs/edit-match.js';
4
+ import { defaultRealpath } from '../fs/scope.js';
5
+ import { resolveRooted, rootedLabel, stringArg } from '../fs/tool-path.js';
6
+ import { writeText } from '../fs/write.js';
7
+ export function createEditFileTool({ realpath = defaultRealpath, deniedRoots } = {}) {
8
+ return {
9
+ name: 'edit_file',
10
+ toolset: 'files',
11
+ description: 'Change part of a file in the shared folder: replaces the text in old with new. old must match ' +
12
+ 'one place in the file (copy it from read_file, without the line numbers); set replaceAll to ' +
13
+ 'change every match. Asks the user first.',
14
+ parameters: {
15
+ type: 'object',
16
+ properties: {
17
+ path: { type: 'string', description: 'File path, relative to the shared folder' },
18
+ old: { type: 'string', description: 'The exact text to replace' },
19
+ new: { type: 'string', description: 'The text to put in its place' },
20
+ replaceAll: { type: 'boolean', description: 'Replace every match instead of exactly one' }
21
+ },
22
+ required: ['path', 'old', 'new']
23
+ },
24
+ effect: () => 'write',
25
+ autoApprove: false,
26
+ writeRootsRequired: true,
27
+ validate(args) {
28
+ if (!stringArg(args, 'path'))
29
+ return 'path is required';
30
+ if (typeof args.old !== 'string' || !args.old) {
31
+ return 'old must be the text to replace';
32
+ }
33
+ return typeof args.new === 'string' ? null : 'new must be a string';
34
+ },
35
+ resource: (args, ctx) => ({
36
+ kind: 'file',
37
+ label: rootedLabel('edit', { grants: ctx, paths: [String(args.path)] })
38
+ }),
39
+ async execute(args, ctx) {
40
+ const requested = String(args.path);
41
+ const resolved = await resolveRooted(requested, {
42
+ grants: ctx,
43
+ write: true,
44
+ realpath,
45
+ deniedRoots
46
+ });
47
+ if ('error' in resolved) {
48
+ return resolved;
49
+ }
50
+ try {
51
+ const { drive } = resolved.root;
52
+ const entry = await drive.entry(resolved.key);
53
+ const blob = entry === null ? null : await drive.get(resolved.key);
54
+ if (entry === null || blob === null) {
55
+ return { error: `no such file: ${requested}` };
56
+ }
57
+ const result = editMatch(b4a.toString(blob, 'utf8'), {
58
+ old: String(args.old),
59
+ replacement: String(args.new),
60
+ replaceAll: args.replaceAll === true
61
+ });
62
+ if (result.error) {
63
+ return { error: result.error };
64
+ }
65
+ await writeText(drive, resolved.key, result.content, { executable: entry.value.executable });
66
+ return { edited: requested, replacements: result.count };
67
+ }
68
+ catch (err) {
69
+ return { error: errorMessage(err) };
70
+ }
71
+ }
72
+ };
73
+ }
@@ -0,0 +1,3 @@
1
+ import type { ToolDefinition } from '../../capabilities/completion.ts';
2
+ import { type FsToolOptions } from '../fs/scope.ts';
3
+ export declare function createGlobTool({ realpath, deniedRoots }?: FsToolOptions): ToolDefinition;
@@ -0,0 +1,63 @@
1
+ import { relativeKey, searchTargets } from '../fs/targets.js';
2
+ import { compileGlob } from '../fs/glob.js';
3
+ import { canonicalDenyRoots, defaultRealpath, isDeniedPath } from '../fs/scope.js';
4
+ import { anchorOf, shownPath, stringArg } from '../fs/tool-path.js';
5
+ import { walkFiles } from '../fs/walk.js';
6
+ import { folderArg } from './list-dir.js';
7
+ const GLOB_MATCHES = 100;
8
+ export function createGlobTool({ realpath = defaultRealpath, deniedRoots } = {}) {
9
+ return {
10
+ name: 'glob',
11
+ toolset: 'files',
12
+ description: 'Find files in the shared folder by name pattern, newest first. "**" crosses folders: ' +
13
+ '"**/*.md" finds every Markdown file, "*report*" matches names anywhere.',
14
+ parameters: {
15
+ type: 'object',
16
+ properties: {
17
+ pattern: { type: 'string', description: 'Name or path pattern, e.g. "**/*.pdf"' },
18
+ dir: {
19
+ type: 'string',
20
+ description: 'Folder to search, relative to the shared folder (optional)'
21
+ }
22
+ },
23
+ required: ['pattern']
24
+ },
25
+ effect: () => 'read',
26
+ stableRead: true,
27
+ autoApprove: true,
28
+ rootsRequired: true,
29
+ validate: (args) => (stringArg(args, 'pattern') ? null : 'pattern is required'),
30
+ resource: (args) => ({ kind: 'file', label: `glob ${String(args.pattern ?? '')}` }),
31
+ async execute(args, ctx) {
32
+ const roots = ctx?.fsRoots ?? [];
33
+ const deny = await canonicalDenyRoots(deniedRoots, realpath);
34
+ const scoped = await searchTargets(folderArg(args, ctx), roots, { realpath, deny });
35
+ if ('error' in scoped) {
36
+ return scoped;
37
+ }
38
+ const pattern = String(args.pattern);
39
+ const test = compileGlob(pattern);
40
+ const byName = !pattern.includes('/');
41
+ const hits = [];
42
+ let walkTruncated = false;
43
+ for (const target of scoped.targets) {
44
+ const walk = await walkFiles(target.root, target.folder);
45
+ walkTruncated ||= walk.truncated;
46
+ for (const file of walk.files) {
47
+ const rel = relativeKey(file.key, target.folder);
48
+ const name = rel.slice(rel.lastIndexOf('/') + 1);
49
+ if (isDeniedPath(file.path, deny) || !(test(rel) || (byName && test(name)))) {
50
+ continue;
51
+ }
52
+ const entry = await target.root.drive.entry(file.key);
53
+ hits.push({ path: shownPath(file.path, anchorOf(ctx)), mtime: entry?.mtime ?? 0 });
54
+ }
55
+ }
56
+ hits.sort((a, b) => b.mtime - a.mtime);
57
+ return {
58
+ matches: hits.slice(0, GLOB_MATCHES).map((hit) => hit.path),
59
+ truncated: walkTruncated || hits.length > GLOB_MATCHES || undefined
60
+ };
61
+ }
62
+ };
63
+ }
@@ -0,0 +1,3 @@
1
+ import type { ToolDefinition } from '../../capabilities/completion.ts';
2
+ import { type FsToolOptions } from '../fs/scope.ts';
3
+ export declare function createGrepTool({ realpath, deniedRoots }?: FsToolOptions): ToolDefinition;
@@ -0,0 +1,114 @@
1
+ import { errorMessage } from '../../guards.js';
2
+ import { searchTargets } from '../fs/targets.js';
3
+ import { compileGlob } from '../fs/glob.js';
4
+ import { readLines } from '../fs/read.js';
5
+ import { canonicalDenyRoots, defaultRealpath, isDeniedPath } from '../fs/scope.js';
6
+ import { anchorOf, shownPath, stringArg } from '../fs/tool-path.js';
7
+ import { walkFiles } from '../fs/walk.js';
8
+ import { folderArg } from './list-dir.js';
9
+ const GREP_MATCHES = 100;
10
+ const MAX_LINE_CHARS = 300;
11
+ export function createGrepTool({ realpath = defaultRealpath, deniedRoots } = {}) {
12
+ return {
13
+ name: 'grep',
14
+ toolset: 'files',
15
+ description: 'Search inside the files of the shared folder. The pattern is plain text, case-insensitive; set ' +
16
+ 'regex for a regular expression. Returns "path:line: text". Narrow with include (e.g. "*.ts").',
17
+ parameters: {
18
+ type: 'object',
19
+ properties: {
20
+ pattern: { type: 'string', description: 'Text to find' },
21
+ dir: {
22
+ type: 'string',
23
+ description: 'Folder to search, relative to the shared folder (optional)'
24
+ },
25
+ include: {
26
+ type: 'string',
27
+ description: 'Only files whose name matches this pattern (optional)'
28
+ },
29
+ regex: { type: 'boolean', description: 'Treat pattern as a regular expression' }
30
+ },
31
+ required: ['pattern']
32
+ },
33
+ effect: () => 'read',
34
+ stableRead: true,
35
+ autoApprove: true,
36
+ rootsRequired: true,
37
+ validate(args) {
38
+ if (!stringArg(args, 'pattern'))
39
+ return 'pattern is required';
40
+ if (args.regex !== true)
41
+ return null;
42
+ try {
43
+ new RegExp(String(args.pattern), 'i');
44
+ return null;
45
+ }
46
+ catch (err) {
47
+ return `invalid regex: ${errorMessage(err)}`;
48
+ }
49
+ },
50
+ resource: (args) => ({ kind: 'file', label: `grep ${String(args.pattern ?? '')}` }),
51
+ async execute(args, ctx) {
52
+ const roots = ctx?.fsRoots ?? [];
53
+ const deny = await canonicalDenyRoots(deniedRoots, realpath);
54
+ const scoped = await searchTargets(folderArg(args, ctx), roots, { realpath, deny });
55
+ if ('error' in scoped) {
56
+ return scoped;
57
+ }
58
+ const include = stringArg(args, 'include') ? compileGlob(String(args.include)) : null;
59
+ const scan = { matches: matcher(args), anchor: anchorOf(ctx), rows: [] };
60
+ const walkTruncated = await scanTargets(scoped.targets, scan, { deny, include });
61
+ return {
62
+ matches: scan.rows.slice(0, GREP_MATCHES).join('\n') || 'no matches',
63
+ truncated: walkTruncated || scan.rows.length > GREP_MATCHES || undefined
64
+ };
65
+ }
66
+ };
67
+ }
68
+ function matcher(args) {
69
+ const pattern = String(args.pattern);
70
+ if (args.regex === true) {
71
+ const rx = new RegExp(pattern, 'i');
72
+ return (line) => rx.test(line);
73
+ }
74
+ const needle = pattern.toLowerCase();
75
+ return (line) => line.toLowerCase().includes(needle);
76
+ }
77
+ async function scanTargets(targets, scan, { deny, include }) {
78
+ let truncated = false;
79
+ for (const target of targets) {
80
+ const walk = await walkFiles(target.root, target.folder);
81
+ truncated ||= walk.truncated;
82
+ for (const file of walk.files) {
83
+ const name = file.key.slice(file.key.lastIndexOf('/') + 1);
84
+ if (isDeniedPath(file.path, deny) || (include && !include(name))) {
85
+ continue;
86
+ }
87
+ await scanFile(target.root.drive, file, scan);
88
+ if (scan.rows.length > GREP_MATCHES) {
89
+ return truncated;
90
+ }
91
+ }
92
+ }
93
+ return truncated;
94
+ }
95
+ async function scanFile(drive, file, { matches, anchor, rows }) {
96
+ let index = 0;
97
+ try {
98
+ for await (const line of readLines(drive, file.key)) {
99
+ index++;
100
+ if (line.includes('\0')) {
101
+ break;
102
+ }
103
+ if (matches(line)) {
104
+ rows.push(`${shownPath(file.path, anchor)}:${index}: ${line.slice(0, MAX_LINE_CHARS)}`);
105
+ }
106
+ if (rows.length > GREP_MATCHES) {
107
+ break;
108
+ }
109
+ }
110
+ }
111
+ catch {
112
+ return;
113
+ }
114
+ }
@@ -0,0 +1,3 @@
1
+ import type { ToolDefinition } from '../../capabilities/completion.ts';
2
+ import type { FsToolOptions } from '../fs/scope.ts';
3
+ export declare function createFileTools(options?: FsToolOptions): ToolDefinition[];
@@ -0,0 +1,23 @@
1
+ import { createEditFileTool } from './edit-file.js';
2
+ import { createGlobTool } from './glob.js';
3
+ import { createGrepTool } from './grep.js';
4
+ import { createListDirTool } from './list-dir.js';
5
+ import { createMakeDirTool } from './make-dir.js';
6
+ import { createReadFileTool } from './read-file.js';
7
+ import { createCopyFileTool, createMoveFileTool } from './transfer.js';
8
+ import { createTrashFileTool } from './trash-file.js';
9
+ import { createWriteFileTool } from './write-file.js';
10
+ export function createFileTools(options = {}) {
11
+ return [
12
+ createReadFileTool(options),
13
+ createListDirTool(options),
14
+ createGlobTool(options),
15
+ createGrepTool(options),
16
+ createWriteFileTool(options),
17
+ createEditFileTool(options),
18
+ createMoveFileTool(options),
19
+ createCopyFileTool(options),
20
+ createMakeDirTool(options),
21
+ createTrashFileTool(options)
22
+ ];
23
+ }
@@ -0,0 +1,5 @@
1
+ import type { ToolDefinition } from '../../capabilities/completion.ts';
2
+ import { type FsToolOptions } from '../fs/scope.ts';
3
+ import { type Grants } from '../fs/tool-path.ts';
4
+ export declare function createListDirTool({ realpath, deniedRoots }?: FsToolOptions): ToolDefinition;
5
+ export declare function folderArg(args: Record<string, unknown>, grants: Grants | undefined): string | undefined;
@@ -0,0 +1,130 @@
1
+ import fs from 'fs';
2
+ import { relativeKey, searchTargets } from '../fs/targets.js';
3
+ import { canonicalDenyRoots, defaultRealpath, displayPath, hostPath, isDeniedPath } from '../fs/scope.js';
4
+ import { anchorOf, rootedPath, stringArg } from '../fs/tool-path.js';
5
+ import { WALK_SKIP_DIRS, walkFiles } from '../fs/walk.js';
6
+ const LIST_ENTRIES = 200;
7
+ export function createListDirTool({ realpath = defaultRealpath, deniedRoots } = {}) {
8
+ return {
9
+ name: 'list_dir',
10
+ toolset: 'files',
11
+ description: 'List a folder inside the shared folder: each entry with its type, size and last change. ' +
12
+ 'Leave path empty for the shared folder itself. Use this, not exec, to look inside it.',
13
+ parameters: {
14
+ type: 'object',
15
+ properties: {
16
+ path: { type: 'string', description: 'Folder, relative to the shared folder (optional)' }
17
+ }
18
+ },
19
+ effect: () => 'read',
20
+ stableRead: true,
21
+ autoApprove: true,
22
+ rootsRequired: true,
23
+ resource: (args, ctx) => ({
24
+ kind: 'file',
25
+ label: `list ${folderArg(args, ctx) ?? 'shared folder'}`
26
+ }),
27
+ async execute(args, ctx) {
28
+ const roots = ctx?.fsRoots ?? [];
29
+ const deny = await canonicalDenyRoots(deniedRoots, realpath);
30
+ const scoped = await searchTargets(folderArg(args, ctx), roots, { realpath, deny });
31
+ if ('error' in scoped) {
32
+ return scoped;
33
+ }
34
+ const { entries, truncated } = await collect(scoped.targets, { deny, anchor: anchorOf(ctx) });
35
+ const rows = await describe([...entries].slice(0, LIST_ENTRIES));
36
+ return {
37
+ entries: rows.join('\n') || '(empty)',
38
+ truncated: truncated || entries.size > LIST_ENTRIES || undefined
39
+ };
40
+ }
41
+ };
42
+ }
43
+ export function folderArg(args, grants) {
44
+ const folder = stringArg(args, 'dir') ?? stringArg(args, 'path');
45
+ return folder && folder !== '.' && folder !== '/'
46
+ ? rootedPath(folder, anchorOf(grants))
47
+ : undefined;
48
+ }
49
+ async function collect(targets, { deny, anchor }) {
50
+ const entries = new Map();
51
+ let truncated = false;
52
+ for (const target of targets) {
53
+ const own = targets.length === 1 || target.root === anchor;
54
+ const level = target.root.kind === 'local'
55
+ ? await localLevel(target.root, target.folder, deny)
56
+ : await walkedLevel(target, deny);
57
+ truncated ||= level.truncated;
58
+ for (const child of level.children) {
59
+ const name = own ? child.name : displayPath(target.root, child.key);
60
+ entries.set(name, { ...child, drive: target.root.drive });
61
+ }
62
+ }
63
+ return { entries, truncated };
64
+ }
65
+ async function localLevel(root, folder, deny) {
66
+ const children = [];
67
+ for (const dirent of await readdir(hostPath(root, folder))) {
68
+ const key = childKey(folder, dirent.name);
69
+ if (dirent.isSymbolicLink() || isDeniedPath(hostPath(root, key), deny)) {
70
+ continue;
71
+ }
72
+ if (dirent.isDirectory() && !WALK_SKIP_DIRS.has(dirent.name)) {
73
+ const items = (await readdir(hostPath(root, key))).length;
74
+ children.push({ name: dirent.name, key, folder: true, items });
75
+ }
76
+ else if (dirent.isFile()) {
77
+ children.push({ name: dirent.name, key, folder: false, items: 0 });
78
+ }
79
+ if (children.length > LIST_ENTRIES) {
80
+ return { children, truncated: true };
81
+ }
82
+ }
83
+ return { children, truncated: false };
84
+ }
85
+ async function walkedLevel(target, deny) {
86
+ const walk = await walkFiles(target.root, target.folder);
87
+ const children = [];
88
+ const folders = new Map();
89
+ for (const file of walk.files) {
90
+ if (isDeniedPath(file.path, deny))
91
+ continue;
92
+ const [name, below] = relativeKey(file.key, target.folder).split('/');
93
+ if (below === undefined) {
94
+ children.push({ name, key: file.key, folder: false, items: 0 });
95
+ continue;
96
+ }
97
+ folders.set(name, (folders.get(name) ?? new Set()).add(below));
98
+ }
99
+ for (const [name, items] of folders) {
100
+ children.push({ name, key: childKey(target.folder, name), folder: true, items: items.size });
101
+ }
102
+ return { children, truncated: walk.truncated };
103
+ }
104
+ async function readdir(dir) {
105
+ try {
106
+ return await fs.promises.readdir(dir, { withFileTypes: true });
107
+ }
108
+ catch {
109
+ return [];
110
+ }
111
+ }
112
+ function childKey(folder, name) {
113
+ return folder === '/' ? `/${name}` : `${folder}/${name}`;
114
+ }
115
+ async function describe(entries) {
116
+ const rows = [];
117
+ for (const [name, entry] of entries) {
118
+ if (entry.folder) {
119
+ rows.push(`${name}/ (folder, ${entry.items} ${entry.items === 1 ? 'item' : 'items'})`);
120
+ continue;
121
+ }
122
+ const stat = await entry.drive.entry(entry.key);
123
+ const size = stat?.value.blob?.byteLength ?? 0;
124
+ const changed = stat?.mtime
125
+ ? `, changed ${new Date(stat.mtime).toISOString().slice(0, 16)}`
126
+ : '';
127
+ rows.push(`${name} (${size} bytes${changed})`);
128
+ }
129
+ return rows;
130
+ }
@@ -0,0 +1,3 @@
1
+ import type { ToolDefinition } from '../../capabilities/completion.ts';
2
+ import { type FsToolOptions } from '../fs/scope.ts';
3
+ export declare function createMakeDirTool({ realpath, deniedRoots }?: FsToolOptions): ToolDefinition;
@@ -0,0 +1,47 @@
1
+ import fs from 'fs';
2
+ import { errorMessage } from '../../guards.js';
3
+ import { defaultRealpath } from '../fs/scope.js';
4
+ import { localPathOf, resolveRooted, rootedLabel, stringArg } from '../fs/tool-path.js';
5
+ export function createMakeDirTool({ realpath = defaultRealpath, deniedRoots } = {}) {
6
+ return {
7
+ name: 'make_dir',
8
+ toolset: 'files',
9
+ description: 'Create a folder (and any missing parents) inside the shared folder.',
10
+ parameters: {
11
+ type: 'object',
12
+ properties: {
13
+ path: { type: 'string', description: 'Folder path, relative to the shared folder' }
14
+ },
15
+ required: ['path']
16
+ },
17
+ effect: () => 'read',
18
+ writeRootsRequired: true,
19
+ validate: (args) => (stringArg(args, 'path') ? null : 'path is required'),
20
+ resource: (args, ctx) => ({
21
+ kind: 'file',
22
+ label: rootedLabel('mkdir', { grants: ctx, paths: [String(args.path)] })
23
+ }),
24
+ async execute(args, ctx) {
25
+ const resolved = await resolveRooted(String(args.path), {
26
+ grants: ctx,
27
+ write: true,
28
+ realpath,
29
+ deniedRoots
30
+ });
31
+ if ('error' in resolved) {
32
+ return resolved;
33
+ }
34
+ const target = localPathOf(resolved);
35
+ if (!target) {
36
+ return { error: 'folders can only be created in a folder on this device' };
37
+ }
38
+ try {
39
+ await fs.promises.mkdir(target, { recursive: true });
40
+ return { created: String(args.path) };
41
+ }
42
+ catch (err) {
43
+ return { error: errorMessage(err) };
44
+ }
45
+ }
46
+ };
47
+ }
@@ -0,0 +1,3 @@
1
+ import type { ToolDefinition } from '../../capabilities/completion.ts';
2
+ import { type FsToolOptions } from '../fs/scope.ts';
3
+ export declare function createReadFileTool({ realpath, deniedRoots }?: FsToolOptions): ToolDefinition;