@nia-teams/four-tools 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -2
- package/README_CN.md +144 -0
- package/dist/core/contracts.d.ts +118 -0
- package/dist/core/contracts.js +125 -0
- package/dist/core/edit-engine.d.ts +11 -0
- package/dist/core/edit-engine.js +93 -0
- package/dist/core/env.d.ts +149 -0
- package/dist/core/env.js +1 -0
- package/dist/core/index.d.ts +23 -0
- package/dist/core/index.js +33 -0
- package/dist/core/mutation-queue.d.ts +6 -0
- package/dist/core/mutation-queue.js +24 -0
- package/dist/core/tools/edit.d.ts +4 -0
- package/dist/core/tools/edit.js +43 -0
- package/dist/core/tools/read.d.ts +10 -0
- package/dist/core/tools/read.js +45 -0
- package/dist/core/tools/shared.d.ts +20 -0
- package/dist/core/tools/shared.js +51 -0
- package/dist/core/tools/shell.d.ts +10 -0
- package/dist/core/tools/shell.js +166 -0
- package/dist/core/tools/write.d.ts +4 -0
- package/dist/core/tools/write.js +33 -0
- package/dist/core/types.d.ts +65 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node/index.d.ts +16 -0
- package/dist/node/index.js +16 -0
- package/dist/node/posix-env.d.ts +46 -0
- package/dist/node/posix-env.js +239 -0
- package/dist/node/process-group.d.ts +2 -0
- package/dist/node/process-group.js +30 -0
- package/dist/node/process-runner.d.ts +43 -0
- package/dist/node/process-runner.js +221 -0
- package/dist/node/read-range.d.ts +7 -0
- package/dist/node/read-range.js +69 -0
- package/dist/node/terminal-manager.d.ts +36 -0
- package/dist/node/terminal-manager.js +228 -0
- package/examples/local-agent.ts +164 -0
- package/examples/quickstart.ts +30 -0
- package/examples/web-agent.ts +479 -0
- package/package.json +44 -4
- package/skills/four-tools/SKILL.md +21 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const normalize = (s) => s
|
|
2
|
+
.normalize('NFKC')
|
|
3
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
|
4
|
+
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
|
|
5
|
+
.replace(/[\u2010-\u2015\u2212]/g, '-')
|
|
6
|
+
.replace(/[\u00A0\u202F\u2007]/g, ' ');
|
|
7
|
+
/** Only grapheme boundaries map back to source offsets; expansions are indivisible. */
|
|
8
|
+
function mapped(source) {
|
|
9
|
+
let text = '';
|
|
10
|
+
const starts = new Map();
|
|
11
|
+
const ends = new Map();
|
|
12
|
+
const lines = source.split(/\r\n|\n|\r/);
|
|
13
|
+
let offset = 0;
|
|
14
|
+
for (let i = 0; i < lines.length; i++) {
|
|
15
|
+
const line = lines[i];
|
|
16
|
+
const trimmed = line.trimEnd();
|
|
17
|
+
starts.set(text.length, offset);
|
|
18
|
+
ends.set(text.length, offset);
|
|
19
|
+
for (const segment of new Intl.Segmenter(undefined, {
|
|
20
|
+
granularity: 'grapheme',
|
|
21
|
+
}).segment(trimmed)) {
|
|
22
|
+
starts.set(text.length, offset + segment.index);
|
|
23
|
+
text += normalize(segment.segment);
|
|
24
|
+
ends.set(text.length, offset + segment.index + segment.segment.length);
|
|
25
|
+
}
|
|
26
|
+
const eol = source.slice(offset + line.length).startsWith('\r\n') ? 2 : 1;
|
|
27
|
+
if (i < lines.length - 1) {
|
|
28
|
+
text += '\n';
|
|
29
|
+
offset += line.length + eol;
|
|
30
|
+
starts.set(text.length, offset);
|
|
31
|
+
ends.set(text.length, offset);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { text, starts, ends };
|
|
35
|
+
}
|
|
36
|
+
function occurrences(haystack, needle, overlapping = false) {
|
|
37
|
+
const found = [];
|
|
38
|
+
for (let at = 0; (at = haystack.indexOf(needle, at)) !== -1; at += overlapping ? 1 : needle.length)
|
|
39
|
+
found.push(at);
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
export function applyUniqueEdit(original, oldString, newString) {
|
|
43
|
+
if (!oldString)
|
|
44
|
+
return { ok: false, reason: 'empty_old_string' };
|
|
45
|
+
if (oldString === newString)
|
|
46
|
+
return { ok: false, reason: 'unchanged' };
|
|
47
|
+
const bom = original.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
48
|
+
const source = original.slice(bom.length);
|
|
49
|
+
const exact = occurrences(source, oldString);
|
|
50
|
+
if (exact.length > 1)
|
|
51
|
+
return { ok: false, reason: 'not_unique', count: exact.length };
|
|
52
|
+
let start, end;
|
|
53
|
+
let matchedBy = 'exact';
|
|
54
|
+
if (exact.length) {
|
|
55
|
+
start = exact[0];
|
|
56
|
+
end = start + oldString.length;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const mapping = mapped(source);
|
|
60
|
+
const needle = mapped(oldString).text;
|
|
61
|
+
if (!needle)
|
|
62
|
+
return { ok: false, reason: 'not_found' };
|
|
63
|
+
const matches = occurrences(mapping.text, needle, true).filter((at) => mapping.starts.has(at) && mapping.ends.has(at + needle.length));
|
|
64
|
+
if (!matches.length)
|
|
65
|
+
return { ok: false, reason: 'not_found' };
|
|
66
|
+
if (matches.length > 1)
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
reason: 'ambiguous_normalized',
|
|
70
|
+
count: matches.length,
|
|
71
|
+
};
|
|
72
|
+
start = mapping.starts.get(matches[0]);
|
|
73
|
+
end = mapping.ends.get(matches[0] + needle.length);
|
|
74
|
+
matchedBy = 'normalized';
|
|
75
|
+
// Prefer the first replaced newline, then the nearest surrounding newline.
|
|
76
|
+
const local = source.slice(start, end).match(/\r\n|\n/);
|
|
77
|
+
const before = [...source.slice(0, start).matchAll(/\r\n|\n/g)].at(-1);
|
|
78
|
+
const after = source.slice(end).match(/\r\n|\n/);
|
|
79
|
+
const nearby = before &&
|
|
80
|
+
(!after || start - (before.index + before[0].length) <= after.index)
|
|
81
|
+
? before
|
|
82
|
+
: after;
|
|
83
|
+
const eol = local?.[0] ?? nearby?.[0];
|
|
84
|
+
if (eol)
|
|
85
|
+
newString = newString.replace(/\r\n|\n/g, eol);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
content: bom + source.slice(0, start) + newString + source.slice(end),
|
|
90
|
+
matchedBy,
|
|
91
|
+
line: source.slice(0, start).split(/\r\n|\n|\r/).length,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export type Platform = 'linux' | 'darwin';
|
|
2
|
+
export type ResolvedPath = {
|
|
3
|
+
resolvedPath: string;
|
|
4
|
+
canonicalPath: string;
|
|
5
|
+
};
|
|
6
|
+
export type FileStat = {
|
|
7
|
+
exists: boolean;
|
|
8
|
+
isDirectory: boolean;
|
|
9
|
+
isSymbolicLink: boolean;
|
|
10
|
+
size: number;
|
|
11
|
+
mode: number;
|
|
12
|
+
mtimeMs: number;
|
|
13
|
+
};
|
|
14
|
+
export type ReadRangeResult = {
|
|
15
|
+
content: string;
|
|
16
|
+
startLine: number;
|
|
17
|
+
endLine: number;
|
|
18
|
+
totalLines: number;
|
|
19
|
+
truncatedByBytes: boolean;
|
|
20
|
+
isBinary?: boolean;
|
|
21
|
+
singleLineExceeded?: boolean;
|
|
22
|
+
};
|
|
23
|
+
export type ExecOptions = {
|
|
24
|
+
cwd?: string;
|
|
25
|
+
env?: Record<string, string>;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
maxOutputBytes?: number;
|
|
29
|
+
onOutput?: (chunk: Uint8Array, stream: 'stdout' | 'stderr') => void;
|
|
30
|
+
};
|
|
31
|
+
export type ExecResult = {
|
|
32
|
+
output: string;
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
exitCode: number | null;
|
|
36
|
+
signal: string | null;
|
|
37
|
+
timedOut: boolean;
|
|
38
|
+
aborted: boolean;
|
|
39
|
+
truncated: boolean;
|
|
40
|
+
spillPath?: string;
|
|
41
|
+
durationMs?: number;
|
|
42
|
+
};
|
|
43
|
+
export type TerminalStatus = 'running' | 'exited' | 'signaled' | 'error';
|
|
44
|
+
export type TerminalInfo = {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
command: string;
|
|
48
|
+
cwd: string;
|
|
49
|
+
status: TerminalStatus;
|
|
50
|
+
exitCode: number | null;
|
|
51
|
+
signal: string | null;
|
|
52
|
+
startedAt: number;
|
|
53
|
+
endedAt?: number;
|
|
54
|
+
linesBuffered?: number;
|
|
55
|
+
};
|
|
56
|
+
export type TerminalExitEvent = {
|
|
57
|
+
id: string;
|
|
58
|
+
name: string;
|
|
59
|
+
command: string;
|
|
60
|
+
cwd: string;
|
|
61
|
+
status: Exclude<TerminalStatus, 'running'>;
|
|
62
|
+
exitCode: number | null;
|
|
63
|
+
signal: string | null;
|
|
64
|
+
durationMs: number;
|
|
65
|
+
outputPreview: string;
|
|
66
|
+
logPath?: string;
|
|
67
|
+
};
|
|
68
|
+
export interface ManagedTerminalBackend {
|
|
69
|
+
execWithHandoff(input: {
|
|
70
|
+
command: string;
|
|
71
|
+
cwd?: string;
|
|
72
|
+
env?: Record<string, string>;
|
|
73
|
+
handoffAfterMs: number;
|
|
74
|
+
maxOutputBytes?: number;
|
|
75
|
+
signal?: AbortSignal;
|
|
76
|
+
onAbort?: 'handoff' | 'kill';
|
|
77
|
+
}): Promise<{
|
|
78
|
+
completed: true;
|
|
79
|
+
output: string;
|
|
80
|
+
stdout?: string;
|
|
81
|
+
stderr?: string;
|
|
82
|
+
durationMs?: number;
|
|
83
|
+
exitCode: number | null;
|
|
84
|
+
signal: string | null;
|
|
85
|
+
truncated: boolean;
|
|
86
|
+
spillPath?: string;
|
|
87
|
+
} | {
|
|
88
|
+
completed: false;
|
|
89
|
+
terminal: TerminalInfo;
|
|
90
|
+
partialOutput: string;
|
|
91
|
+
handoffReason?: 'timeout' | 'abort';
|
|
92
|
+
}>;
|
|
93
|
+
create(input: {
|
|
94
|
+
command: string;
|
|
95
|
+
cwd?: string;
|
|
96
|
+
name?: string;
|
|
97
|
+
env?: Record<string, string>;
|
|
98
|
+
notifyOnExit?: boolean;
|
|
99
|
+
}): Promise<TerminalInfo>;
|
|
100
|
+
list(): Promise<TerminalInfo[]>;
|
|
101
|
+
read(input: {
|
|
102
|
+
id: string;
|
|
103
|
+
offset?: number;
|
|
104
|
+
limit?: number;
|
|
105
|
+
}): Promise<{
|
|
106
|
+
content: string;
|
|
107
|
+
lines?: string[];
|
|
108
|
+
nextOffset?: number;
|
|
109
|
+
startLine: number;
|
|
110
|
+
endLine: number;
|
|
111
|
+
totalLines: number;
|
|
112
|
+
status: TerminalStatus;
|
|
113
|
+
exitCode: number | null;
|
|
114
|
+
}>;
|
|
115
|
+
signal(input: {
|
|
116
|
+
id: string;
|
|
117
|
+
signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL';
|
|
118
|
+
}): Promise<TerminalInfo>;
|
|
119
|
+
destroy(input: {
|
|
120
|
+
id: string;
|
|
121
|
+
}): Promise<void>;
|
|
122
|
+
}
|
|
123
|
+
export interface ExecutionEnv {
|
|
124
|
+
readonly id: string;
|
|
125
|
+
readonly platform: Platform;
|
|
126
|
+
readonly cwd: string;
|
|
127
|
+
resolvePath(inputPath: string, options?: {
|
|
128
|
+
cwd?: string;
|
|
129
|
+
mustExist?: boolean;
|
|
130
|
+
}): Promise<ResolvedPath>;
|
|
131
|
+
stat(resolvedPath: string): Promise<FileStat>;
|
|
132
|
+
readRange(resolvedPath: string, options?: {
|
|
133
|
+
offsetLine?: number;
|
|
134
|
+
limitLines?: number;
|
|
135
|
+
maxBytes?: number;
|
|
136
|
+
signal?: AbortSignal;
|
|
137
|
+
}): Promise<ReadRangeResult>;
|
|
138
|
+
readText(resolvedPath: string, options?: {
|
|
139
|
+
maxBytes?: number;
|
|
140
|
+
signal?: AbortSignal;
|
|
141
|
+
}): Promise<string>;
|
|
142
|
+
writeText(resolvedPath: string, content: string, options?: {
|
|
143
|
+
signal?: AbortSignal;
|
|
144
|
+
}): Promise<{
|
|
145
|
+
bytesWritten: number;
|
|
146
|
+
}>;
|
|
147
|
+
exec(command: string, options?: ExecOptions): Promise<ExecResult>;
|
|
148
|
+
terminals?: ManagedTerminalBackend;
|
|
149
|
+
}
|
package/dist/core/env.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export * from './env.js';
|
|
2
|
+
export * from './types.js';
|
|
3
|
+
export * from './mutation-queue.js';
|
|
4
|
+
export * from './edit-engine.js';
|
|
5
|
+
export * from './tools/read.js';
|
|
6
|
+
export * from './tools/write.js';
|
|
7
|
+
export * from './tools/edit.js';
|
|
8
|
+
export * from './tools/shell.js';
|
|
9
|
+
export type { FileToolOptions } from './tools/shared.js';
|
|
10
|
+
import type { ExecutionEnv } from './env.js';
|
|
11
|
+
import type { FourTool, ToolHooks } from './types.js';
|
|
12
|
+
import { FileMutationQueue } from './mutation-queue.js';
|
|
13
|
+
import { type ReadToolOptions } from './tools/read.js';
|
|
14
|
+
import { type ShellToolOptions } from './tools/shell.js';
|
|
15
|
+
export interface FourToolsOptions {
|
|
16
|
+
hooks?: ToolHooks;
|
|
17
|
+
mutationQueue?: FileMutationQueue;
|
|
18
|
+
maxReadBytes?: number;
|
|
19
|
+
read?: ReadToolOptions;
|
|
20
|
+
maxEditBytes?: number;
|
|
21
|
+
shell?: ShellToolOptions;
|
|
22
|
+
}
|
|
23
|
+
export declare function createFourTools(env: ExecutionEnv, options?: FourToolsOptions): FourTool[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export * from './env.js';
|
|
2
|
+
export * from './types.js';
|
|
3
|
+
export * from './mutation-queue.js';
|
|
4
|
+
export * from './edit-engine.js';
|
|
5
|
+
export * from './tools/read.js';
|
|
6
|
+
export * from './tools/write.js';
|
|
7
|
+
export * from './tools/edit.js';
|
|
8
|
+
export * from './tools/shell.js';
|
|
9
|
+
import { FileMutationQueue } from './mutation-queue.js';
|
|
10
|
+
import { createReadTool } from './tools/read.js';
|
|
11
|
+
import { createWriteTool } from './tools/write.js';
|
|
12
|
+
import { createEditTool } from './tools/edit.js';
|
|
13
|
+
import { createShellTool } from './tools/shell.js';
|
|
14
|
+
export function createFourTools(env, options = {}) {
|
|
15
|
+
const queue = options.mutationQueue ?? new FileMutationQueue();
|
|
16
|
+
return [
|
|
17
|
+
createReadTool(env, {
|
|
18
|
+
hooks: options.hooks,
|
|
19
|
+
maxBytes: options.maxReadBytes,
|
|
20
|
+
...options.read,
|
|
21
|
+
}),
|
|
22
|
+
createWriteTool(env, { queue, hooks: options.hooks }),
|
|
23
|
+
createEditTool(env, {
|
|
24
|
+
queue,
|
|
25
|
+
hooks: options.hooks,
|
|
26
|
+
maxBytes: options.maxEditBytes,
|
|
27
|
+
}),
|
|
28
|
+
createShellTool(env, {
|
|
29
|
+
...options.shell,
|
|
30
|
+
hooks: options.shell?.hooks ?? options.hooks,
|
|
31
|
+
}),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare class FileMutationQueue {
|
|
2
|
+
private readonly tails;
|
|
3
|
+
run<T>(envId: string, canonicalPath: string, mutate: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
4
|
+
}
|
|
5
|
+
/** Independent factory calls also share file serialization by default. */
|
|
6
|
+
export declare const defaultMutationQueue: FileMutationQueue;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class FileMutationQueue {
|
|
2
|
+
tails = new Map();
|
|
3
|
+
async run(envId, canonicalPath, mutate, signal) {
|
|
4
|
+
const key = `${envId}\0${canonicalPath}`;
|
|
5
|
+
const previous = this.tails.get(key) ?? Promise.resolve();
|
|
6
|
+
let release;
|
|
7
|
+
const tail = new Promise((resolve) => {
|
|
8
|
+
release = resolve;
|
|
9
|
+
});
|
|
10
|
+
this.tails.set(key, tail);
|
|
11
|
+
await previous;
|
|
12
|
+
try {
|
|
13
|
+
signal?.throwIfAborted();
|
|
14
|
+
return await mutate();
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
release();
|
|
18
|
+
if (this.tails.get(key) === tail)
|
|
19
|
+
this.tails.delete(key);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Independent factory calls also share file serialization by default. */
|
|
24
|
+
export const defaultMutationQueue = new FileMutationQueue();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { toolContracts } from '../contracts.js';
|
|
2
|
+
import { applyUniqueEdit } from '../edit-engine.js';
|
|
3
|
+
import { defaultMutationQueue } from '../mutation-queue.js';
|
|
4
|
+
import { aliases, safe, text } from './shared.js';
|
|
5
|
+
export function createEditTool(env, options = {}) {
|
|
6
|
+
return safe({
|
|
7
|
+
name: 'edit',
|
|
8
|
+
...structuredClone(toolContracts.edit),
|
|
9
|
+
concurrency: 'parallel',
|
|
10
|
+
async execute(raw, ctx = {}) {
|
|
11
|
+
ctx.signal?.throwIfAborted();
|
|
12
|
+
const input = aliases(raw), displayPath = text(input, 'path'), oldString = text(input, 'old_string'), newString = text(input, 'new_string');
|
|
13
|
+
const path = await env.resolvePath(displayPath, { mustExist: true });
|
|
14
|
+
return (options.queue ?? defaultMutationQueue).run(env.id, path.canonicalPath, async () => {
|
|
15
|
+
const original = await env.readText(path.resolvedPath, {
|
|
16
|
+
maxBytes: options.maxBytes,
|
|
17
|
+
signal: ctx.signal,
|
|
18
|
+
});
|
|
19
|
+
const result = applyUniqueEdit(original, oldString, newString);
|
|
20
|
+
if (!result.ok) {
|
|
21
|
+
if (result.count)
|
|
22
|
+
return `ERROR: old_string found ${result.count} times, must be unique\n`;
|
|
23
|
+
if (result.reason === 'not_found')
|
|
24
|
+
return 'ERROR: old_string not found in file\n';
|
|
25
|
+
return `ERROR: ${result.reason}`;
|
|
26
|
+
}
|
|
27
|
+
await env.writeText(path.resolvedPath, result.content, {
|
|
28
|
+
signal: ctx.signal,
|
|
29
|
+
});
|
|
30
|
+
const message = 'OK: replacement applied';
|
|
31
|
+
const guidance = await options.hooks?.afterMutate?.({
|
|
32
|
+
path: path.resolvedPath,
|
|
33
|
+
canonicalPath: path.canonicalPath,
|
|
34
|
+
operation: 'edit',
|
|
35
|
+
charCount: [...result.content].length,
|
|
36
|
+
content: result.content,
|
|
37
|
+
context: ctx,
|
|
38
|
+
});
|
|
39
|
+
return guidance ? { content: message, guidance } : message;
|
|
40
|
+
}, ctx.signal);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ExecutionEnv } from '../env.js';
|
|
2
|
+
import type { FourTool } from '../types.js';
|
|
3
|
+
import { type FileToolOptions } from './shared.js';
|
|
4
|
+
export interface ReadToolOptions extends Omit<FileToolOptions, 'maxBytes'> {
|
|
5
|
+
maxBytes?: number | false;
|
|
6
|
+
maxLines?: number | false;
|
|
7
|
+
/** Delegate output budgeting to the runtime instead of truncating in the tool. */
|
|
8
|
+
truncate?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function createReadTool(env: ExecutionEnv, options?: ReadToolOptions): FourTool;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { toolContracts } from '../contracts.js';
|
|
2
|
+
import { aliases, number, safe, text } from './shared.js';
|
|
3
|
+
export function createReadTool(env, options = {}) {
|
|
4
|
+
return safe({
|
|
5
|
+
name: 'read',
|
|
6
|
+
...structuredClone(toolContracts.read),
|
|
7
|
+
concurrency: 'parallel',
|
|
8
|
+
async execute(input, ctx = {}) {
|
|
9
|
+
ctx.signal?.throwIfAborted();
|
|
10
|
+
const path = await env.resolvePath(text(aliases(input), 'path'), {
|
|
11
|
+
mustExist: true,
|
|
12
|
+
});
|
|
13
|
+
const maxLines = options.truncate === false || options.maxLines === false
|
|
14
|
+
? Number.MAX_SAFE_INTEGER
|
|
15
|
+
: number(options.maxLines, 2000);
|
|
16
|
+
const maxBytes = options.truncate === false || options.maxBytes === false
|
|
17
|
+
? Number.MAX_SAFE_INTEGER
|
|
18
|
+
: number(options.maxBytes, 50 * 1024);
|
|
19
|
+
const requested = number(input.limit, Number.MAX_SAFE_INTEGER);
|
|
20
|
+
const result = await env.readRange(path.resolvedPath, {
|
|
21
|
+
offsetLine: number(input.offset, 1),
|
|
22
|
+
limitLines: Math.min(requested, maxLines),
|
|
23
|
+
maxBytes,
|
|
24
|
+
signal: ctx.signal,
|
|
25
|
+
});
|
|
26
|
+
if (result.isBinary)
|
|
27
|
+
return 'Error reading file: Binary file cannot be displayed as text';
|
|
28
|
+
const capped = result.truncatedByBytes ||
|
|
29
|
+
(requested > maxLines && result.endLine < result.totalLines);
|
|
30
|
+
const content = result.content +
|
|
31
|
+
(capped
|
|
32
|
+
? `\n\n[Showing lines ${result.startLine}-${result.endLine} of ${result.totalLines}. Use offset=${result.endLine + 1} to continue.]`
|
|
33
|
+
: '');
|
|
34
|
+
const guidance = await options.hooks?.afterRead?.({
|
|
35
|
+
path: path.resolvedPath,
|
|
36
|
+
canonicalPath: path.canonicalPath,
|
|
37
|
+
content: input.offset === undefined && input.limit === undefined && !capped
|
|
38
|
+
? result.content
|
|
39
|
+
: undefined,
|
|
40
|
+
context: ctx,
|
|
41
|
+
});
|
|
42
|
+
return guidance ? { content, guidance } : content;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { FourTool, FourToolContext, FourToolResult, ToolHooks } from '../types.js';
|
|
2
|
+
import type { FileMutationQueue } from '../mutation-queue.js';
|
|
3
|
+
export interface FileToolOptions {
|
|
4
|
+
maxBytes?: number;
|
|
5
|
+
hooks?: ToolHooks;
|
|
6
|
+
queue?: FileMutationQueue;
|
|
7
|
+
}
|
|
8
|
+
export declare const schema: (properties: Record<string, unknown>, required?: string[]) => {
|
|
9
|
+
type: string;
|
|
10
|
+
additionalProperties: boolean;
|
|
11
|
+
properties: Record<string, unknown>;
|
|
12
|
+
required: string[];
|
|
13
|
+
};
|
|
14
|
+
export declare function aliases(input: Record<string, unknown>): Record<string, unknown>;
|
|
15
|
+
export declare function text(input: Record<string, unknown>, key: string): string;
|
|
16
|
+
export declare function number(value: unknown, fallback: number, minimum?: number): number;
|
|
17
|
+
export type ToolImplementation = Omit<FourTool, 'execute'> & {
|
|
18
|
+
execute(input: Record<string, unknown>, ctx?: FourToolContext): Promise<FourToolResult>;
|
|
19
|
+
};
|
|
20
|
+
export declare function safe(tool: ToolImplementation): FourTool;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const schema = (properties, required = []) => ({ type: 'object', additionalProperties: false, properties, required });
|
|
2
|
+
export function aliases(input) {
|
|
3
|
+
return {
|
|
4
|
+
...input,
|
|
5
|
+
path: input.path ?? input.file_path ?? input.filepath,
|
|
6
|
+
old_string: input.old_string ?? input.old_str ?? input.oldText ?? input.oldString,
|
|
7
|
+
new_string: input.new_string ?? input.new_str ?? input.newText ?? input.newString,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export function text(input, key) {
|
|
11
|
+
if (typeof input[key] !== 'string')
|
|
12
|
+
throw new Error(`${key} must be a string`);
|
|
13
|
+
return input[key];
|
|
14
|
+
}
|
|
15
|
+
export function number(value, fallback, minimum = 1) {
|
|
16
|
+
if (value === undefined)
|
|
17
|
+
return fallback;
|
|
18
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
19
|
+
throw new Error('Expected a finite number');
|
|
20
|
+
return Math.max(minimum, Math.trunc(value));
|
|
21
|
+
}
|
|
22
|
+
export function safe(tool) {
|
|
23
|
+
async function execute(input, ctx = {}) {
|
|
24
|
+
try {
|
|
25
|
+
// Match optional-field cleaning in the native host, while accepting file aliases.
|
|
26
|
+
const clean = Object.fromEntries(Object.entries(input).filter(([, value]) => value !== null && value !== undefined));
|
|
27
|
+
const result = await tool.execute(clean, ctx);
|
|
28
|
+
if (typeof result !== 'string' &&
|
|
29
|
+
typeof ctx.workspaceId === 'string' &&
|
|
30
|
+
typeof ctx.teamId === 'string' &&
|
|
31
|
+
typeof ctx.model === 'string') {
|
|
32
|
+
if (result.guidance?.length)
|
|
33
|
+
ctx.aiPostContext = [
|
|
34
|
+
...(ctx.aiPostContext ?? []),
|
|
35
|
+
...result.guidance,
|
|
36
|
+
];
|
|
37
|
+
return result.content;
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const prefix = tool.name === 'read'
|
|
43
|
+
? 'Error reading file:'
|
|
44
|
+
: tool.name === 'write'
|
|
45
|
+
? 'Error writing file:'
|
|
46
|
+
: 'ERROR:';
|
|
47
|
+
return (prefix + ' ' + (error instanceof Error ? error.message : String(error)));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { ...tool, execute };
|
|
51
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ExecutionEnv } from '../env.js';
|
|
2
|
+
import type { FourTool, ToolHooks } from '../types.js';
|
|
3
|
+
export interface ShellToolOptions {
|
|
4
|
+
name?: string;
|
|
5
|
+
allowBackground?: boolean;
|
|
6
|
+
maxOutputBytes?: number;
|
|
7
|
+
hooks?: ToolHooks;
|
|
8
|
+
}
|
|
9
|
+
export declare function signalExitCode(exitCode: number | null, signal: string | null): number;
|
|
10
|
+
export declare function createShellTool(env: ExecutionEnv, options?: ShellToolOptions): FourTool;
|