@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.
- package/CHANGELOG.md +62 -1
- package/dist-lib/index.d.ts +1 -1
- package/dist-lib/index.js +51 -50
- package/dist-lib/lib/audio-transcode-bare.js +2 -2
- package/dist-lib/lib/capabilities/completion.d.ts +1 -2
- package/dist-lib/lib/capabilities/completion.js +29 -25
- package/dist-lib/lib/capabilities/tool-prompt-log.js +3 -3
- package/dist-lib/lib/capabilities/voice.js +7 -6
- package/dist-lib/lib/connect.js +18 -17
- package/dist-lib/lib/errors.d.ts +266 -0
- package/dist-lib/lib/errors.js +189 -0
- package/dist-lib/lib/helpers/load-stall.d.ts +1 -1
- package/dist-lib/lib/helpers/load-stall.js +2 -2
- package/dist-lib/lib/helpers/race-abort.d.ts +2 -1
- package/dist-lib/lib/helpers/race-abort.js +3 -4
- package/dist-lib/lib/hooks/built-in/arg-repair.js +2 -2
- package/dist-lib/lib/hooks/pipeline.js +4 -4
- package/dist-lib/lib/hooks/run.js +2 -2
- package/dist-lib/lib/log.d.ts +26 -2
- package/dist-lib/lib/log.js +66 -16
- package/dist-lib/lib/models/model-pool.js +11 -6
- package/dist-lib/lib/models/model-source.js +3 -2
- package/dist-lib/lib/no-redirect-fetch-bare.js +2 -1
- package/dist-lib/lib/no-redirect-fetch-impl.js +2 -1
- package/dist-lib/lib/office/extract.js +4 -3
- package/dist-lib/lib/office/index.js +2 -1
- package/dist-lib/lib/resolution-detector.js +3 -3
- package/dist-lib/lib/rpc-session.js +2 -1
- package/dist-lib/lib/sdk-loader.js +2 -2
- package/dist-lib/lib/serve.js +33 -29
- package/dist-lib/lib/spawn.d.ts +2 -4
- package/dist-lib/lib/spawn.js +14 -7
- package/dist-lib/lib/tools/edit-image.js +2 -1
- package/dist-lib/lib/tools/exec/adapters/python/attachments.js +8 -8
- package/dist-lib/lib/tools/exec/adapters/python/guarded-fetch.js +7 -6
- package/dist-lib/lib/tools/exec/adapters/python/interrupt-timer.js +2 -2
- package/dist-lib/lib/tools/exec/adapters/python/memfs.js +2 -2
- package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +8 -8
- package/dist-lib/lib/tools/exec/adapters/python/run-session.js +11 -7
- package/dist-lib/lib/tools/exec/adapters/python/wheels.js +5 -5
- package/dist-lib/lib/tools/exec/adapters/python/worker/active.js +2 -2
- package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +9 -8
- package/dist-lib/lib/tools/exec/adapters/python/worker/protocol.d.ts +2 -0
- package/dist-lib/lib/tools/exec/adapters/python/worker/serve.js +4 -4
- package/dist-lib/lib/tools/exec/adapters/python/worker/spawn.d.ts +2 -1
- package/dist-lib/lib/tools/exec/adapters/python/worker/spawn.js +2 -1
- package/dist-lib/lib/tools/exec/obsidian/note-name.js +2 -1
- package/dist-lib/lib/tools/exec/obsidian/safe-path.js +5 -4
- package/dist-lib/lib/tools/exec/obsidian/vault-create.js +3 -2
- package/dist-lib/lib/tools/exec/obsidian/vault-fs.js +2 -2
- package/dist-lib/lib/tools/exec/obsidian/vault-paths.js +3 -2
- package/dist-lib/lib/tools/exec/obsidian/vault-read.js +6 -5
- package/dist-lib/lib/tools/exec/obsidian/vault-write.js +13 -12
- package/dist-lib/lib/tools/exec/runner.js +2 -1
- package/dist-lib/lib/tools/files/edit-file.d.ts +3 -0
- package/dist-lib/lib/tools/files/edit-file.js +73 -0
- package/dist-lib/lib/tools/files/glob.d.ts +3 -0
- package/dist-lib/lib/tools/files/glob.js +63 -0
- package/dist-lib/lib/tools/files/grep.d.ts +3 -0
- package/dist-lib/lib/tools/files/grep.js +114 -0
- package/dist-lib/lib/tools/files/index.d.ts +3 -0
- package/dist-lib/lib/tools/files/index.js +23 -0
- package/dist-lib/lib/tools/files/list-dir.d.ts +5 -0
- package/dist-lib/lib/tools/files/list-dir.js +130 -0
- package/dist-lib/lib/tools/files/make-dir.d.ts +3 -0
- package/dist-lib/lib/tools/files/make-dir.js +47 -0
- package/dist-lib/lib/tools/files/read-file.d.ts +3 -0
- package/dist-lib/lib/tools/files/read-file.js +103 -0
- package/dist-lib/lib/tools/files/transfer.d.ts +4 -0
- package/dist-lib/lib/tools/files/transfer.js +82 -0
- package/dist-lib/lib/tools/files/trash-file.d.ts +4 -0
- package/dist-lib/lib/tools/files/trash-file.js +61 -0
- package/dist-lib/lib/tools/files/write-file.d.ts +3 -0
- package/dist-lib/lib/tools/files/write-file.js +55 -0
- package/dist-lib/lib/tools/fs/document-text.d.ts +2 -0
- package/dist-lib/lib/tools/fs/document-text.js +38 -0
- package/dist-lib/lib/tools/fs/edit-match.d.ts +11 -0
- package/dist-lib/lib/tools/fs/edit-match.js +85 -0
- package/dist-lib/lib/tools/fs/remote.d.ts +2 -1
- package/dist-lib/lib/tools/fs/remote.js +4 -4
- package/dist-lib/lib/tools/fs/scope.d.ts +1 -1
- package/dist-lib/lib/tools/fs/scope.js +3 -2
- package/dist-lib/lib/tools/fs/targets.d.ts +10 -0
- package/dist-lib/lib/tools/fs/targets.js +32 -0
- package/dist-lib/lib/tools/fs/tool-path.d.ts +24 -0
- package/dist-lib/lib/tools/fs/tool-path.js +58 -0
- package/dist-lib/lib/tools/fs/walk.js +3 -3
- package/dist-lib/lib/tools/generate-image.d.ts +2 -2
- package/dist-lib/lib/tools/generate-image.js +5 -6
- package/dist-lib/lib/tools/office.js +2 -1
- package/dist-lib/lib/tools/skill.js +2 -1
- package/dist-lib/lib/tools/skills/bundled-dir.js +4 -4
- package/dist-lib/lib/tools/skills/catalog.js +2 -2
- package/dist-lib/lib/tools/skills/credential-health.js +3 -3
- package/dist-lib/lib/tools/skills/overlay-crud.js +3 -2
- package/dist-lib/lib/tools/translate.d.ts +2 -2
- package/dist-lib/lib/tools/translate.js +5 -6
- package/dist-lib/lib/tools/weather-lookup/geocode.js +5 -4
- package/dist-lib/lib/web-search/engines/bing.js +2 -1
- package/dist-lib/lib/web-search/engines/google.js +2 -1
- package/dist-lib/lib/web-search/fetch-html.d.ts +5 -5
- package/dist-lib/lib/web-search/fetch-html.js +12 -15
- package/package.json +66 -24
- package/worker/harness-sidecar.mjs +12 -13
- package/dist-lib/lib/tools/create-documents.d.ts +0 -3
- package/dist-lib/lib/tools/create-documents.js +0 -95
- package/dist-lib/lib/tools/edit-documents.d.ts +0 -3
- package/dist-lib/lib/tools/edit-documents.js +0 -132
- package/dist-lib/lib/tools/file-glob.d.ts +0 -14
- package/dist-lib/lib/tools/file-glob.js +0 -118
- package/dist-lib/lib/tools/grep.d.ts +0 -5
- package/dist-lib/lib/tools/grep.js +0 -184
- package/dist-lib/lib/tools/read-files.d.ts +0 -5
- 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
|
|
9
|
+
throw new HarnessError('OBSIDIAN_NOTE_NOT_FOUND', [args]);
|
|
9
10
|
}
|
|
10
11
|
const content = getContentArg(args);
|
|
11
12
|
if (!content) {
|
|
12
|
-
throw new
|
|
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
|
|
21
|
+
throw new HarnessError('OBSIDIAN_NOTE_NOT_FOUND', [args]);
|
|
21
22
|
}
|
|
22
23
|
const content = getContentArg(args);
|
|
23
24
|
if (!content) {
|
|
24
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,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,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,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,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,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
|
+
}
|