@qvac/harness 0.8.8 → 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 +80 -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/mention.js +7 -2
- 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 +67 -25
- 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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import b4a from 'b4a';
|
|
2
|
+
import { errorMessage } from '../../guards.js';
|
|
3
|
+
import { documentText, isDocument } from '../fs/document-text.js';
|
|
4
|
+
import { defaultRealpath } from '../fs/scope.js';
|
|
5
|
+
import { anchorOf, positiveInt, resolveRooted, rootedPath, stringArg } from '../fs/tool-path.js';
|
|
6
|
+
const READ_LINES = 400;
|
|
7
|
+
const READ_CHARS = 20_000;
|
|
8
|
+
const BINARY_PROBE = 1024;
|
|
9
|
+
export function createReadFileTool({ realpath = defaultRealpath, deniedRoots } = {}) {
|
|
10
|
+
return {
|
|
11
|
+
name: 'read_file',
|
|
12
|
+
toolset: 'files',
|
|
13
|
+
description: 'Read one file from the shared folder. Lines come back numbered ("12| text"). For a long file, ' +
|
|
14
|
+
'read a range with startLine/endLine. PDF, Word and PowerPoint files come back as their text. ' +
|
|
15
|
+
'Use this, not exec, for files in the shared folder.',
|
|
16
|
+
parameters: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
path: { type: 'string', description: 'File path, relative to the shared folder' },
|
|
20
|
+
startLine: { type: 'integer', description: 'First line to read (1-based, optional)' },
|
|
21
|
+
endLine: { type: 'integer', description: 'Last line to read (optional)' }
|
|
22
|
+
},
|
|
23
|
+
required: ['path']
|
|
24
|
+
},
|
|
25
|
+
effect: () => 'read',
|
|
26
|
+
stableRead: true,
|
|
27
|
+
autoApprove: true,
|
|
28
|
+
rootsRequired: true,
|
|
29
|
+
validate: (args) => (stringArg(args, 'path') ? null : 'path is required'),
|
|
30
|
+
resource(args, ctx) {
|
|
31
|
+
const requested = stringArg(args, 'path');
|
|
32
|
+
return requested ? { kind: 'file', label: rootedPath(requested, anchorOf(ctx)) } : null;
|
|
33
|
+
},
|
|
34
|
+
async execute(args, ctx) {
|
|
35
|
+
const requested = String(args.path);
|
|
36
|
+
const resolved = await resolveRooted(requested, {
|
|
37
|
+
grants: ctx,
|
|
38
|
+
write: false,
|
|
39
|
+
realpath,
|
|
40
|
+
deniedRoots
|
|
41
|
+
});
|
|
42
|
+
if ('error' in resolved) {
|
|
43
|
+
return resolved;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const text = await textOf(resolved, requested);
|
|
47
|
+
if (typeof text !== 'string') {
|
|
48
|
+
return text;
|
|
49
|
+
}
|
|
50
|
+
return numbered(text, {
|
|
51
|
+
start: positiveInt(args.startLine),
|
|
52
|
+
end: positiveInt(args.endLine)
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
return { error: errorMessage(err) };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
async function textOf(resolved, requested) {
|
|
62
|
+
const { drive } = resolved.root;
|
|
63
|
+
const bytes = (await drive.entry(resolved.key)) ? await drive.get(resolved.key) : null;
|
|
64
|
+
if (bytes === null) {
|
|
65
|
+
return { error: `no such file: ${requested}. Use list_dir or glob to find the right path.` };
|
|
66
|
+
}
|
|
67
|
+
if (isDocument(resolved.key)) {
|
|
68
|
+
return documentText(resolved.key, bytes);
|
|
69
|
+
}
|
|
70
|
+
if (bytes.subarray(0, BINARY_PROBE).includes(0)) {
|
|
71
|
+
return { error: `${requested} is a binary file (not text, PDF, Word or PowerPoint)` };
|
|
72
|
+
}
|
|
73
|
+
return b4a.toString(bytes, 'utf8');
|
|
74
|
+
}
|
|
75
|
+
function numbered(text, { start, end }) {
|
|
76
|
+
const lines = text.split('\n');
|
|
77
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') {
|
|
78
|
+
lines.pop();
|
|
79
|
+
}
|
|
80
|
+
const total = lines.length;
|
|
81
|
+
const first = Math.min(start ?? 1, Math.max(total, 1));
|
|
82
|
+
const out = [];
|
|
83
|
+
let chars = 0;
|
|
84
|
+
let cut = false;
|
|
85
|
+
for (let n = first; n <= Math.min(total, end ?? first + READ_LINES - 1); n++) {
|
|
86
|
+
const row = `${n}| ${lines[n - 1]}`;
|
|
87
|
+
if (chars + row.length > READ_CHARS) {
|
|
88
|
+
cut = out.length === 0;
|
|
89
|
+
if (cut) {
|
|
90
|
+
out.push(row.slice(0, READ_CHARS));
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
out.push(row);
|
|
95
|
+
chars += row.length + 1;
|
|
96
|
+
}
|
|
97
|
+
const last = first + out.length - 1;
|
|
98
|
+
const shown = cut
|
|
99
|
+
? `line ${first} of ${total}, cut at ${READ_CHARS} characters`
|
|
100
|
+
: `lines ${first}-${last} of ${total}`;
|
|
101
|
+
const header = last < total ? `${shown}; continue with startLine ${last + 1}` : shown;
|
|
102
|
+
return { content: `${header}\n${out.join('\n')}` };
|
|
103
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../capabilities/completion.ts';
|
|
2
|
+
import { type FsToolOptions } from '../fs/scope.ts';
|
|
3
|
+
export declare function createMoveFileTool(options?: FsToolOptions): ToolDefinition;
|
|
4
|
+
export declare function createCopyFileTool(options?: FsToolOptions): ToolDefinition;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { errorMessage } from '../../guards.js';
|
|
4
|
+
import { defaultRealpath } from '../fs/scope.js';
|
|
5
|
+
import { localPathOf, resolveRooted, rootedLabel, stringArg } from '../fs/tool-path.js';
|
|
6
|
+
function fromTo(from) {
|
|
7
|
+
return {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
from: { type: 'string', description: from },
|
|
11
|
+
to: { type: 'string', description: 'New path, relative to the shared folder' }
|
|
12
|
+
},
|
|
13
|
+
required: ['from', 'to']
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function createMoveFileTool(options = {}) {
|
|
17
|
+
return {
|
|
18
|
+
...transferTool({ ...options, copy: false }),
|
|
19
|
+
name: 'move_file',
|
|
20
|
+
parameters: fromTo('Current path, relative to the shared folder'),
|
|
21
|
+
description: 'Move or rename a file or folder inside the shared folder. Fails if the destination exists. ' +
|
|
22
|
+
'Asks the user first.',
|
|
23
|
+
effect: () => 'write',
|
|
24
|
+
autoApprove: false
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function createCopyFileTool(options = {}) {
|
|
28
|
+
return {
|
|
29
|
+
...transferTool({ ...options, copy: true }),
|
|
30
|
+
name: 'copy_file',
|
|
31
|
+
parameters: fromTo('File to copy, relative to the shared folder'),
|
|
32
|
+
description: 'Copy a file inside the shared folder to a new path. Fails if the destination exists.',
|
|
33
|
+
effect: () => 'read'
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function transferTool({ realpath = defaultRealpath, deniedRoots, copy }) {
|
|
37
|
+
return {
|
|
38
|
+
toolset: 'files',
|
|
39
|
+
writeRootsRequired: true,
|
|
40
|
+
validate: (args) => stringArg(args, 'from') && stringArg(args, 'to') ? null : 'from and to are required',
|
|
41
|
+
resource: (args, ctx) => ({
|
|
42
|
+
kind: 'file',
|
|
43
|
+
label: rootedLabel(copy ? 'copy' : 'move', {
|
|
44
|
+
grants: ctx,
|
|
45
|
+
paths: [String(args.from), String(args.to)]
|
|
46
|
+
})
|
|
47
|
+
}),
|
|
48
|
+
execute: (args, ctx) => transfer(args, ctx, { realpath, deniedRoots, copy })
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function transfer(args, ctx, { realpath = defaultRealpath, deniedRoots, copy }) {
|
|
52
|
+
const scope = { grants: ctx, write: true, realpath, deniedRoots };
|
|
53
|
+
const from = await resolveRooted(String(args.from), scope);
|
|
54
|
+
if ('error' in from) {
|
|
55
|
+
return from;
|
|
56
|
+
}
|
|
57
|
+
const to = await resolveRooted(String(args.to), scope);
|
|
58
|
+
if ('error' in to) {
|
|
59
|
+
return to;
|
|
60
|
+
}
|
|
61
|
+
const source = localPathOf(from);
|
|
62
|
+
const target = localPathOf(to);
|
|
63
|
+
if (!source || !target) {
|
|
64
|
+
return { error: 'moving and copying work only in a folder on this device' };
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
if (!fs.existsSync(source)) {
|
|
68
|
+
return { error: `no such file: ${String(args.from)}` };
|
|
69
|
+
}
|
|
70
|
+
if (fs.existsSync(target)) {
|
|
71
|
+
return { error: `${String(args.to)} already exists` };
|
|
72
|
+
}
|
|
73
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
74
|
+
await (copy ? fs.promises.copyFile(source, target) : fs.promises.rename(source, target));
|
|
75
|
+
return copy
|
|
76
|
+
? { copied: String(args.from), to: String(args.to) }
|
|
77
|
+
: { moved: String(args.from), to: String(args.to) };
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
return { error: errorMessage(err) };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../capabilities/completion.ts';
|
|
2
|
+
import { type FsToolOptions } from '../fs/scope.ts';
|
|
3
|
+
export declare const TRASH_DIR = ".trash";
|
|
4
|
+
export declare function createTrashFileTool({ realpath, deniedRoots }?: FsToolOptions): ToolDefinition;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { errorMessage } from '../../guards.js';
|
|
4
|
+
import { defaultRealpath } from '../fs/scope.js';
|
|
5
|
+
import { localPathOf, resolveRooted, rootedLabel, stringArg } from '../fs/tool-path.js';
|
|
6
|
+
export const TRASH_DIR = '.trash';
|
|
7
|
+
export function createTrashFileTool({ realpath = defaultRealpath, deniedRoots } = {}) {
|
|
8
|
+
return {
|
|
9
|
+
name: 'trash_file',
|
|
10
|
+
toolset: 'files',
|
|
11
|
+
description: `Move a file or folder to the shared folder's ${TRASH_DIR} folder, where the user can restore it. ` +
|
|
12
|
+
'Nothing is deleted permanently. Asks the user first.',
|
|
13
|
+
parameters: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
path: { type: 'string', description: 'Path to trash, relative to the shared folder' }
|
|
17
|
+
},
|
|
18
|
+
required: ['path']
|
|
19
|
+
},
|
|
20
|
+
effect: () => 'write',
|
|
21
|
+
autoApprove: false,
|
|
22
|
+
writeRootsRequired: true,
|
|
23
|
+
validate: (args) => (stringArg(args, 'path') ? null : 'path is required'),
|
|
24
|
+
resource: (args, ctx) => ({
|
|
25
|
+
kind: 'file',
|
|
26
|
+
label: rootedLabel('trash', { grants: ctx, paths: [String(args.path)] })
|
|
27
|
+
}),
|
|
28
|
+
async execute(args, ctx) {
|
|
29
|
+
const requested = String(args.path);
|
|
30
|
+
const resolved = await resolveRooted(requested, {
|
|
31
|
+
grants: ctx,
|
|
32
|
+
write: true,
|
|
33
|
+
realpath,
|
|
34
|
+
deniedRoots
|
|
35
|
+
});
|
|
36
|
+
if ('error' in resolved) {
|
|
37
|
+
return resolved;
|
|
38
|
+
}
|
|
39
|
+
const source = localPathOf(resolved);
|
|
40
|
+
if (!source || resolved.root.kind !== 'local') {
|
|
41
|
+
return { error: 'trash works only in a folder on this device' };
|
|
42
|
+
}
|
|
43
|
+
if (resolved.key === '/') {
|
|
44
|
+
return { error: 'the shared folder itself cannot be trashed' };
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
if (!fs.existsSync(source)) {
|
|
48
|
+
return { error: `no such file: ${requested}` };
|
|
49
|
+
}
|
|
50
|
+
const bin = path.join(resolved.root.root, TRASH_DIR);
|
|
51
|
+
const name = `${Date.now().toString(36)}-${path.basename(source)}`;
|
|
52
|
+
await fs.promises.mkdir(bin, { recursive: true });
|
|
53
|
+
await fs.promises.rename(source, path.join(bin, name));
|
|
54
|
+
return { trashed: requested, restorableAt: `${TRASH_DIR}/${name}` };
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
return { error: errorMessage(err) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { errorMessage } from '../../guards.js';
|
|
2
|
+
import { defaultRealpath } from '../fs/scope.js';
|
|
3
|
+
import { existsNow, resolveRooted, rootedLabel, stringArg } from '../fs/tool-path.js';
|
|
4
|
+
import { writeText } from '../fs/write.js';
|
|
5
|
+
export function createWriteFileTool({ realpath = defaultRealpath, deniedRoots } = {}) {
|
|
6
|
+
return {
|
|
7
|
+
name: 'write_file',
|
|
8
|
+
toolset: 'files',
|
|
9
|
+
description: 'Write a whole file in the shared folder: creates it (and its folders) or replaces it completely. ' +
|
|
10
|
+
'Replacing an existing file asks the user first. For a small change, use edit_file. ' +
|
|
11
|
+
'Use this, not exec, to write files in the shared folder.',
|
|
12
|
+
parameters: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
path: { type: 'string', description: 'File path, relative to the shared folder' },
|
|
16
|
+
content: { type: 'string', description: 'The complete new content' }
|
|
17
|
+
},
|
|
18
|
+
required: ['path', 'content']
|
|
19
|
+
},
|
|
20
|
+
effect: (args, ctx) => (existsNow(stringArg(args, 'path'), ctx) ? 'write' : 'read'),
|
|
21
|
+
writeRootsRequired: true,
|
|
22
|
+
validate: (args) => stringArg(args, 'path') && typeof args.content === 'string'
|
|
23
|
+
? null
|
|
24
|
+
: 'path and content are required',
|
|
25
|
+
resource: (args, ctx) => ({
|
|
26
|
+
kind: 'file',
|
|
27
|
+
label: `${rootedLabel('write', { grants: ctx, paths: [String(args.path)] })}, ${String(args.content ?? '').length} chars`
|
|
28
|
+
}),
|
|
29
|
+
async execute(args, ctx) {
|
|
30
|
+
const requested = String(args.path);
|
|
31
|
+
const resolved = await resolveRooted(requested, {
|
|
32
|
+
grants: ctx,
|
|
33
|
+
write: true,
|
|
34
|
+
realpath,
|
|
35
|
+
deniedRoots
|
|
36
|
+
});
|
|
37
|
+
if ('error' in resolved) {
|
|
38
|
+
return resolved;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const existing = await resolved.root.drive.entry(resolved.key);
|
|
42
|
+
if (existing !== null && ctx?.approval === 'auto') {
|
|
43
|
+
return { error: `${requested} already exists; call write_file again to replace it` };
|
|
44
|
+
}
|
|
45
|
+
await writeText(resolved.root.drive, resolved.key, String(args.content), {
|
|
46
|
+
executable: existing?.value.executable
|
|
47
|
+
});
|
|
48
|
+
return { written: requested, replaced: existing !== null };
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return { error: errorMessage(err) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { HarnessError } from '../../errors.js';
|
|
2
|
+
import { docx, pptx } from '../../office/engine/index.js';
|
|
3
|
+
let pdfium = null;
|
|
4
|
+
function loadPdfium() {
|
|
5
|
+
pdfium ??= import('bare-pdfium').then((loaded) => loaded, () => null);
|
|
6
|
+
return pdfium;
|
|
7
|
+
}
|
|
8
|
+
export function isDocument(key) {
|
|
9
|
+
return /\.(pdf|docx|pptx)$/i.test(key);
|
|
10
|
+
}
|
|
11
|
+
export async function documentText(key, bytes) {
|
|
12
|
+
if (!/\.pdf$/i.test(key)) {
|
|
13
|
+
const read = /\.docx$/i.test(key) ? docx.open(bytes).read() : pptx.open(bytes).read();
|
|
14
|
+
return collectText(read).join('\n');
|
|
15
|
+
}
|
|
16
|
+
const lib = await loadPdfium();
|
|
17
|
+
if (!lib) {
|
|
18
|
+
throw new HarnessError('PDF_TEXT_UNAVAILABLE');
|
|
19
|
+
}
|
|
20
|
+
const doc = lib.open(bytes);
|
|
21
|
+
try {
|
|
22
|
+
return [...doc.textPages()]
|
|
23
|
+
.map(({ page, text }) => `--- page ${page + 1} ---\n${text.replace(/\r\n?/g, '\n').trim()}`)
|
|
24
|
+
.join('\n');
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
doc.close();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function collectText(value) {
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
return value.flatMap(collectText);
|
|
33
|
+
}
|
|
34
|
+
if (typeof value !== 'object' || value === null) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
return Object.entries(value).flatMap(([key, inner]) => key === 'text' && typeof inner === 'string' ? [inner] : collectText(inner));
|
|
38
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface EditRequest {
|
|
2
|
+
readonly old: string;
|
|
3
|
+
readonly replacement: string;
|
|
4
|
+
readonly replaceAll: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface EditMatch {
|
|
7
|
+
content: string;
|
|
8
|
+
count: number;
|
|
9
|
+
error: string | null;
|
|
10
|
+
}
|
|
11
|
+
export declare function editMatch(content: string, request: EditRequest): EditMatch;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const TYPOGRAPHIC = {
|
|
2
|
+
'“': '"',
|
|
3
|
+
'”': '"',
|
|
4
|
+
'‘': "'",
|
|
5
|
+
'’': "'",
|
|
6
|
+
'–': '-',
|
|
7
|
+
'—': '-',
|
|
8
|
+
' ': ' '
|
|
9
|
+
};
|
|
10
|
+
function plain(text) {
|
|
11
|
+
return text.replace(/[“”‘’–— ]/g, (c) => TYPOGRAPHIC[c] ?? c);
|
|
12
|
+
}
|
|
13
|
+
function occurrences(haystack, needle) {
|
|
14
|
+
const found = [];
|
|
15
|
+
let at = haystack.indexOf(needle);
|
|
16
|
+
while (at !== -1) {
|
|
17
|
+
found.push({ from: at, to: at + needle.length });
|
|
18
|
+
at = haystack.indexOf(needle, at + needle.length);
|
|
19
|
+
}
|
|
20
|
+
return found;
|
|
21
|
+
}
|
|
22
|
+
function lineStarts(lines) {
|
|
23
|
+
const starts = [];
|
|
24
|
+
let offset = 0;
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
starts.push(offset);
|
|
27
|
+
offset += line.length + 1;
|
|
28
|
+
}
|
|
29
|
+
return starts;
|
|
30
|
+
}
|
|
31
|
+
function trimmedLineSpans(content, old) {
|
|
32
|
+
const lines = content.split('\n');
|
|
33
|
+
const wanted = old.split('\n').map((line) => line.trimEnd());
|
|
34
|
+
const ownsNewline = wanted.length > 1 && wanted[wanted.length - 1] === '';
|
|
35
|
+
if (ownsNewline) {
|
|
36
|
+
wanted.pop();
|
|
37
|
+
}
|
|
38
|
+
const starts = lineStarts(lines);
|
|
39
|
+
const spans = [];
|
|
40
|
+
for (let i = 0; i + wanted.length <= lines.length; i++) {
|
|
41
|
+
const block = lines.slice(i, i + wanted.length);
|
|
42
|
+
if (block.every((line, k) => line.trimEnd() === wanted[k])) {
|
|
43
|
+
const last = i + wanted.length - 1;
|
|
44
|
+
const end = starts[last] + lines[last].length;
|
|
45
|
+
spans.push({ from: starts[i], to: ownsNewline && last < lines.length - 1 ? end + 1 : end });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return spans;
|
|
49
|
+
}
|
|
50
|
+
function spansFor(content, old) {
|
|
51
|
+
const exact = occurrences(content, old);
|
|
52
|
+
if (exact.length) {
|
|
53
|
+
return exact;
|
|
54
|
+
}
|
|
55
|
+
const typographic = occurrences(plain(content), plain(old));
|
|
56
|
+
if (typographic.length) {
|
|
57
|
+
return typographic;
|
|
58
|
+
}
|
|
59
|
+
return trimmedLineSpans(plain(content), plain(old));
|
|
60
|
+
}
|
|
61
|
+
function ambiguity(text, spans) {
|
|
62
|
+
const lines = spans.map((span) => text.slice(0, span.from).split('\n').length).join(', ');
|
|
63
|
+
return `old text matches ${spans.length} places (lines ${lines}). Include a neighbouring line so it matches once, or set replaceAll to change every one.`;
|
|
64
|
+
}
|
|
65
|
+
export function editMatch(content, request) {
|
|
66
|
+
const crlf = content.includes('\r\n');
|
|
67
|
+
const text = crlf ? content.replace(/\r\n/g, '\n') : content;
|
|
68
|
+
const spans = spansFor(text, request.old.replace(/\r\n/g, '\n'));
|
|
69
|
+
if (!spans.length) {
|
|
70
|
+
return {
|
|
71
|
+
content,
|
|
72
|
+
count: 0,
|
|
73
|
+
error: 'old text not found. Read the file with read_file and copy the exact lines you want to change, including their indentation.'
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (spans.length > 1 && !request.replaceAll) {
|
|
77
|
+
return { content, count: 0, error: ambiguity(text, spans) };
|
|
78
|
+
}
|
|
79
|
+
const replacement = request.replacement.replace(/\r\n/g, '\n');
|
|
80
|
+
let next = text;
|
|
81
|
+
for (const span of [...spans].sort((a, b) => b.from - a.from)) {
|
|
82
|
+
next = next.slice(0, span.from) + replacement + next.slice(span.to);
|
|
83
|
+
}
|
|
84
|
+
return { content: crlf ? next.replace(/\n/g, '\r\n') : next, count: spans.length, error: null };
|
|
85
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HarnessError } from '../../errors.ts';
|
|
1
2
|
import type { DriveRequest, DriveResponse } from './remote-protocol.ts';
|
|
2
3
|
import type { HyperRoot } from './scope.ts';
|
|
3
4
|
export interface GrantedDrive {
|
|
@@ -8,7 +9,7 @@ export type DriveOpFn = (request: DriveRequest) => Promise<DriveResponse>;
|
|
|
8
9
|
export declare const DRIVE_CALL_TIMEOUT_MS = 20000;
|
|
9
10
|
export declare const MAX_DRIVE_BYTES: number;
|
|
10
11
|
export declare const DRIVE_LIST_PAGE = 256;
|
|
11
|
-
export declare class DriveReadTruncatedError extends
|
|
12
|
+
export declare class DriveReadTruncatedError extends HarnessError<'DRIVE_READ_TRUNCATED'> {
|
|
12
13
|
constructor(key: string);
|
|
13
14
|
}
|
|
14
15
|
export declare function isDriveReadTruncated(err: unknown): err is DriveReadTruncatedError;
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { Writable } from 'streamx';
|
|
4
|
+
import { HarnessError } from '../../errors.js';
|
|
4
5
|
import { STAGING_DIR } from '../../staging.js';
|
|
5
6
|
export const DRIVE_CALL_TIMEOUT_MS = 20_000;
|
|
6
7
|
export const MAX_DRIVE_BYTES = 8 * 1024 * 1024;
|
|
7
8
|
export const DRIVE_LIST_PAGE = 256;
|
|
8
9
|
const noop = () => { };
|
|
9
|
-
export class DriveReadTruncatedError extends
|
|
10
|
+
export class DriveReadTruncatedError extends HarnessError {
|
|
10
11
|
constructor(key) {
|
|
11
|
-
super(
|
|
12
|
-
this.name = 'DriveReadTruncatedError';
|
|
12
|
+
super('DRIVE_READ_TRUNCATED', [MAX_DRIVE_BYTES, key]);
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
export function isDriveReadTruncated(err) {
|
|
@@ -17,7 +17,7 @@ export function isDriveReadTruncated(err) {
|
|
|
17
17
|
}
|
|
18
18
|
function withTimeout(promise, ms, op) {
|
|
19
19
|
return new Promise((resolve, reject) => {
|
|
20
|
-
const timer = setTimeout(() => reject(new
|
|
20
|
+
const timer = setTimeout(() => reject(new HarnessError('DRIVE_TIMED_OUT', [op])), ms);
|
|
21
21
|
promise.then((value) => {
|
|
22
22
|
clearTimeout(timer);
|
|
23
23
|
resolve(value);
|
|
@@ -22,7 +22,7 @@ export declare const NO_DIRS_GRANTED = "no directories are granted";
|
|
|
22
22
|
export interface FsToolError {
|
|
23
23
|
error: string;
|
|
24
24
|
}
|
|
25
|
-
export
|
|
25
|
+
export { NO_STORAGE_ROOT } from '../../errors.ts';
|
|
26
26
|
export declare function deniedRootsFor(sdkHome: string | undefined): readonly string[];
|
|
27
27
|
export declare const PROTECTED_PATH_ERROR = "path is inside a protected app directory";
|
|
28
28
|
export declare function canonicalDenyRoots(deniedRoots: readonly string[] | undefined, realpath: Realpath): Promise<readonly string[]>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import process from 'process';
|
|
4
|
+
import { HarnessError } from '../../errors.js';
|
|
4
5
|
export const defaultRealpath = (p) => fs.promises.realpath(p);
|
|
5
6
|
export const NO_DIRS_GRANTED = 'no directories are granted';
|
|
6
7
|
const HYPER_PREFIX = 'hyper://';
|
|
@@ -10,10 +11,10 @@ function rootLabel(root) {
|
|
|
10
11
|
function outside(roots) {
|
|
11
12
|
return { error: `path must be inside one of: ${roots.map(rootLabel).join(', ')}` };
|
|
12
13
|
}
|
|
13
|
-
export
|
|
14
|
+
export { NO_STORAGE_ROOT } from '../../errors.js';
|
|
14
15
|
export function deniedRootsFor(sdkHome) {
|
|
15
16
|
if (!sdkHome)
|
|
16
|
-
throw new
|
|
17
|
+
throw new HarnessError('NO_STORAGE_ROOT');
|
|
17
18
|
return [path.dirname(sdkHome)];
|
|
18
19
|
}
|
|
19
20
|
export const PROTECTED_PATH_ERROR = 'path is inside a protected app directory';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type DriveRoot, type FsToolError, type ResolveKeyOptions } from './scope.ts';
|
|
2
|
+
export interface SearchTarget {
|
|
3
|
+
root: DriveRoot;
|
|
4
|
+
folder: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ResolvedTargets {
|
|
7
|
+
targets: SearchTarget[];
|
|
8
|
+
}
|
|
9
|
+
export declare function searchTargets(dir: unknown, roots: readonly DriveRoot[], { realpath, deny }: ResolveKeyOptions): Promise<ResolvedTargets | FsToolError>;
|
|
10
|
+
export declare function relativeKey(key: string, folder: string): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NO_DIRS_GRANTED, resolveKey } from './scope.js';
|
|
2
|
+
export async function searchTargets(dir, roots, { realpath, deny }) {
|
|
3
|
+
if (typeof dir === 'string' && dir.trim()) {
|
|
4
|
+
const resolved = await resolveKey(dir, roots, { realpath, deny });
|
|
5
|
+
if ('error' in resolved) {
|
|
6
|
+
return resolved;
|
|
7
|
+
}
|
|
8
|
+
if (resolved.root.kind === 'local') {
|
|
9
|
+
if (!resolved.exists) {
|
|
10
|
+
return { error: `no such file: ${dir}` };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
let hasEntry = false;
|
|
15
|
+
for await (const _entry of resolved.root.drive.list(resolved.key)) {
|
|
16
|
+
hasEntry = true;
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
if (!hasEntry && (await resolved.root.drive.entry(resolved.key)) === null) {
|
|
20
|
+
return { error: `no such file: ${dir}` };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { targets: [{ root: resolved.root, folder: resolved.key }] };
|
|
24
|
+
}
|
|
25
|
+
if (roots.length === 0) {
|
|
26
|
+
return { error: NO_DIRS_GRANTED };
|
|
27
|
+
}
|
|
28
|
+
return { targets: roots.map((root) => ({ root, folder: '/' })) };
|
|
29
|
+
}
|
|
30
|
+
export function relativeKey(key, folder) {
|
|
31
|
+
return folder === '/' ? key.slice(1) : key.slice(folder.length + 1);
|
|
32
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type DriveRoot, type FsToolError, type Realpath, type ResolvedKey } from './scope.ts';
|
|
2
|
+
export interface Grants {
|
|
3
|
+
readonly fsRoots?: readonly DriveRoot[] | undefined;
|
|
4
|
+
readonly fsWriteRoots?: readonly DriveRoot[] | undefined;
|
|
5
|
+
}
|
|
6
|
+
export declare function anchorOf(grants: Grants | undefined): DriveRoot | undefined;
|
|
7
|
+
export declare function rootedPath(p: string, anchor: DriveRoot | undefined): string;
|
|
8
|
+
export declare function shownPath(full: string, anchor: DriveRoot | undefined): string;
|
|
9
|
+
export interface RootedScope {
|
|
10
|
+
readonly grants: Grants | undefined;
|
|
11
|
+
readonly write: boolean;
|
|
12
|
+
readonly realpath: Realpath;
|
|
13
|
+
readonly deniedRoots?: readonly string[] | undefined;
|
|
14
|
+
}
|
|
15
|
+
export declare function resolveRooted(p: string, { grants, write, realpath, deniedRoots }: RootedScope): Promise<ResolvedKey | FsToolError>;
|
|
16
|
+
export declare function stringArg(args: Record<string, unknown>, key: string): string | null;
|
|
17
|
+
export declare function positiveInt(value: unknown): number | undefined;
|
|
18
|
+
export declare function existsNow(p: string | null, grants: Grants | undefined): boolean;
|
|
19
|
+
export interface LabelPaths {
|
|
20
|
+
readonly grants: Grants | undefined;
|
|
21
|
+
readonly paths: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
export declare function rootedLabel(verb: string, { grants, paths }: LabelPaths): string;
|
|
24
|
+
export declare function localPathOf(resolved: ResolvedKey): string | null;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { canonicalDenyRoots, hostPath, resolveKey } from './scope.js';
|
|
4
|
+
const HYPER_PREFIX = 'hyper://';
|
|
5
|
+
export function anchorOf(grants) {
|
|
6
|
+
return grants?.fsRoots?.[0] ?? grants?.fsWriteRoots?.[0];
|
|
7
|
+
}
|
|
8
|
+
function baseOf(root) {
|
|
9
|
+
return root.kind === 'local' ? root.root : `${HYPER_PREFIX}${root.key}`;
|
|
10
|
+
}
|
|
11
|
+
export function rootedPath(p, anchor) {
|
|
12
|
+
if (!anchor || p.startsWith(HYPER_PREFIX) || path.isAbsolute(p) || p.startsWith('~')) {
|
|
13
|
+
return p;
|
|
14
|
+
}
|
|
15
|
+
if (anchor.kind === 'local') {
|
|
16
|
+
return path.join(anchor.root, p);
|
|
17
|
+
}
|
|
18
|
+
const prefix = anchor.prefix === '/' ? '' : anchor.prefix;
|
|
19
|
+
return `${HYPER_PREFIX}${anchor.key}${prefix}/${p.replace(/^\.?\//, '')}`;
|
|
20
|
+
}
|
|
21
|
+
export function shownPath(full, anchor) {
|
|
22
|
+
if (!anchor) {
|
|
23
|
+
return full;
|
|
24
|
+
}
|
|
25
|
+
const base = baseOf(anchor);
|
|
26
|
+
if (full === base) {
|
|
27
|
+
return '.';
|
|
28
|
+
}
|
|
29
|
+
return full.startsWith(base + '/') ? full.slice(base.length + 1) : full;
|
|
30
|
+
}
|
|
31
|
+
export async function resolveRooted(p, { grants, write, realpath, deniedRoots }) {
|
|
32
|
+
const roots = (write ? grants?.fsWriteRoots : grants?.fsRoots) ?? [];
|
|
33
|
+
const deny = await canonicalDenyRoots(deniedRoots, realpath);
|
|
34
|
+
return resolveKey(rootedPath(p, anchorOf(grants)), roots, { realpath, deny });
|
|
35
|
+
}
|
|
36
|
+
export function stringArg(args, key) {
|
|
37
|
+
const value = args[key];
|
|
38
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
39
|
+
}
|
|
40
|
+
export function positiveInt(value) {
|
|
41
|
+
const n = Number(value);
|
|
42
|
+
return value === undefined || value === null || !Number.isInteger(n) || n < 1 ? undefined : n;
|
|
43
|
+
}
|
|
44
|
+
export function existsNow(p, grants) {
|
|
45
|
+
const anchor = anchorOf(grants);
|
|
46
|
+
if (!p || !anchor || anchor.kind !== 'local') {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
const target = rootedPath(p, anchor);
|
|
50
|
+
return !path.isAbsolute(target) || fs.existsSync(target);
|
|
51
|
+
}
|
|
52
|
+
export function rootedLabel(verb, { grants, paths }) {
|
|
53
|
+
const anchor = anchorOf(grants);
|
|
54
|
+
return `${verb} ${paths.map((p) => rootedPath(p, anchor)).join(' → ')}`;
|
|
55
|
+
}
|
|
56
|
+
export function localPathOf(resolved) {
|
|
57
|
+
return resolved.root.kind === 'local' ? hostPath(resolved.root, resolved.key) : null;
|
|
58
|
+
}
|