@ouro.bot/cli 0.1.0-alpha.992 → 0.1.0-alpha.994
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.json +13 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +7 -2
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
- package/dist/heart/providers/anthropic.js +15 -4
- package/dist/repertoire/guardrails.js +31 -21
- package/dist/repertoire/shell-sessions.js +1 -0
- package/dist/repertoire/tool-limits.js +20 -0
- package/dist/repertoire/tools-base.js +2 -0
- package/dist/repertoire/tools-files.js +142 -60
- package/dist/repertoire/tools-shell.js +80 -5
- package/dist/repertoire/tools-web.js +230 -0
- package/dist/repertoire/tools.js +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.994",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Every agent wakes up with a home kit: paths start in its own home, ls, fetch_url for reading web pages (local and private addresses refused), read, grep, glob and shell that cap their output and say how to see the rest, and multi-edit. The Butler also gets ls, fetch_url, note and diary_write.",
|
|
8
|
+
"Shell guards follow the new start directory: git inside an agent's home stays with sync, and cd -, ~- and ~+ count as directories the guard cannot follow."
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"version": "0.1.0-alpha.993",
|
|
13
|
+
"changes": [
|
|
14
|
+
"An agent's reply on Anthropic no longer turns into an earlier note when its answer contains a bracket or a brace: streamed tool arguments stay intact."
|
|
15
|
+
]
|
|
16
|
+
},
|
|
4
17
|
{
|
|
5
18
|
"version": "0.1.0-alpha.992",
|
|
6
19
|
"changes": [
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"version": 2,
|
|
3
3
|
"profiles": {
|
|
4
4
|
"sanctuary-owner": {
|
|
5
|
-
"version":
|
|
5
|
+
"version": 19,
|
|
6
6
|
"contextScopes": [
|
|
7
7
|
"household.status",
|
|
8
8
|
"household.policy",
|
|
@@ -53,7 +53,11 @@
|
|
|
53
53
|
"edit_file",
|
|
54
54
|
"glob",
|
|
55
55
|
"grep",
|
|
56
|
+
"ls",
|
|
56
57
|
"web_search",
|
|
58
|
+
"fetch_url",
|
|
59
|
+
"note",
|
|
60
|
+
"diary_write",
|
|
57
61
|
"search_facts",
|
|
58
62
|
"consult_diary",
|
|
59
63
|
"consult_notes",
|
|
@@ -148,7 +152,7 @@
|
|
|
148
152
|
]
|
|
149
153
|
},
|
|
150
154
|
"sanctuary-agent-peer": {
|
|
151
|
-
"version":
|
|
155
|
+
"version": 9,
|
|
152
156
|
"toolNames": [
|
|
153
157
|
"external_event_disposition",
|
|
154
158
|
"query_active_work",
|
|
@@ -192,6 +196,7 @@
|
|
|
192
196
|
"glob",
|
|
193
197
|
"grep",
|
|
194
198
|
"web_search",
|
|
199
|
+
"fetch_url",
|
|
195
200
|
"search_facts",
|
|
196
201
|
"consult_diary",
|
|
197
202
|
"consult_notes",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.994</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -60,7 +60,7 @@ function exactSanctuaryContainmentProfileBoundaries(value) {
|
|
|
60
60
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
61
61
|
return false;
|
|
62
62
|
const boundaries = value;
|
|
63
|
-
const versions = { "sanctuary-owner":
|
|
63
|
+
const versions = { "sanctuary-owner": 19, "sanctuary-household": 5, "sanctuary-event": 4 };
|
|
64
64
|
if (!(0, node_util_1.isDeepStrictEqual)(Object.keys(boundaries).sort(), Object.keys(versions).sort()))
|
|
65
65
|
return false;
|
|
66
66
|
const packageRoot = path.resolve(__dirname, "../../../deploy/unraid/sanctuary.ouro");
|
|
@@ -204,19 +204,30 @@ function toAnthropicUsage(raw) {
|
|
|
204
204
|
total_tokens: totalInputTokens + outputTokens,
|
|
205
205
|
};
|
|
206
206
|
}
|
|
207
|
+
function parsesAsJson(text) {
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(text);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
207
215
|
function mergeAnthropicToolArguments(current, partial) {
|
|
208
216
|
if (!partial)
|
|
209
217
|
return current;
|
|
210
|
-
|
|
218
|
+
// Restart and merge only apply while the text so far is a complete JSON value (the
|
|
219
|
+
// block's starting input). Mid-stream, a fragment may legitimately start with "{" or
|
|
220
|
+
// "[", or the text so far may end in "}", inside a string: those fragments append.
|
|
221
|
+
const completed = parsesAsJson(current);
|
|
211
222
|
const trimmedPartial = partial.trim();
|
|
212
223
|
// If streaming restarts with a full JSON object/array fragment, trust it.
|
|
213
|
-
if (trimmedPartial.startsWith("{") || trimmedPartial.startsWith("[")) {
|
|
224
|
+
if (completed !== undefined && (trimmedPartial.startsWith("{") || trimmedPartial.startsWith("["))) {
|
|
214
225
|
return partial;
|
|
215
226
|
}
|
|
216
227
|
// Anthropic can emit block.input as a complete object and then emit
|
|
217
228
|
// additional members as ',\"k\":v' deltas; merge those safely.
|
|
218
|
-
if (
|
|
219
|
-
const inner =
|
|
229
|
+
if (completed !== null && typeof completed === "object" && !Array.isArray(completed)) {
|
|
230
|
+
const inner = current.trim().slice(1, -1).trim();
|
|
220
231
|
let suffix = partial;
|
|
221
232
|
if (!inner && suffix.startsWith(",")) {
|
|
222
233
|
suffix = suffix.slice(1);
|
|
@@ -65,7 +65,7 @@ const REASONS = {
|
|
|
65
65
|
needsTrustForWrite: "i'd need a closer friend to vouch for you before i can write files outside my home.",
|
|
66
66
|
};
|
|
67
67
|
// --- read-only tools that never need guardrails ---
|
|
68
|
-
const READ_ONLY_TOOLS = new Set(["read_file", "glob", "grep"]);
|
|
68
|
+
const READ_ONLY_TOOLS = new Set(["read_file", "ls", "glob", "grep"]);
|
|
69
69
|
// --- protected path detection ---
|
|
70
70
|
const PROTECTED_PATH_SEGMENTS = [
|
|
71
71
|
".git/",
|
|
@@ -391,13 +391,13 @@ function friendsShellWriteRefused(command, agentRoot) {
|
|
|
391
391
|
// A command can name one of this agent's folders by an absolute path, by ~/ or $HOME/, or relative to wherever it cd's (or
|
|
392
392
|
// git -C's) first. Each word whose path has a part exactly `<name>` is resolved against every directory the command could be
|
|
393
393
|
// working in, lexically and canonically, and counts when it lands in this agent's <name>/.
|
|
394
|
-
const UNFOLLOWABLE_WORKING_DIRECTORY = /(?:^|[\s;&|(`])(?:pushd|popd)(?:\s|$)|(?:^|[\s;&|(`])cd\s+(?:--|["']?\$)|\s-C\s*["']?\$|(?:^|[\s;&|(`/])(?:sh|bash|zsh|dash|ksh)\s+(?:-\w+\s+)*-\w*c\b/;
|
|
394
|
+
const UNFOLLOWABLE_WORKING_DIRECTORY = /(?:^|[\s;&|(`])(?:pushd|popd)(?:\s|$)|(?:^|[\s;&|(`])cd\s+(?:--|["']?\$)|\s-C\s*["']?\$|(?:^|[\s;&|(`=<>])~[^/\s]|(?:^|[\s;&|(`/])(?:sh|bash|zsh|dash|ksh)\s+(?:-\w+\s+)*-\w*c\b/;
|
|
395
395
|
/** Words of a shell command that name a path whose part is exactly `name`, wherever they sit (code and quotes included). */
|
|
396
396
|
function namedFolderWords(command, name) {
|
|
397
397
|
const part = new RegExp(`(?:^|/)${name}(?:/|$)`);
|
|
398
398
|
return command.split(/[\s'"=();|&<>`]+/).filter((token) => part.test(token));
|
|
399
399
|
}
|
|
400
|
-
function commandMentionsAgentFolder(command, words, agentRoot, name) {
|
|
400
|
+
function commandMentionsAgentFolder(command, words, agentRoot, name, startIn = [process.cwd()]) {
|
|
401
401
|
const lexicalFolder = path.resolve(agentRoot, name);
|
|
402
402
|
if (globOrExpansionCouldReach(command, lexicalFolder, agentRoot))
|
|
403
403
|
return true;
|
|
@@ -419,7 +419,7 @@ function commandMentionsAgentFolder(command, words, agentRoot, name) {
|
|
|
419
419
|
return true;
|
|
420
420
|
const folders = [lexicalFolder, path.join(canonicalize(path.resolve(agentRoot)), name)];
|
|
421
421
|
// Every directory the command could be working in: where the shell starts, and each cd target, from each earlier one.
|
|
422
|
-
const cwds = [
|
|
422
|
+
const cwds = [...startIn];
|
|
423
423
|
for (const [index, word] of words.entries()) {
|
|
424
424
|
// `git -C <dir>` works in <dir> just as `cd <dir>` does.
|
|
425
425
|
if (words[index - 1] !== "cd" && words[index - 1] !== "-C")
|
|
@@ -442,7 +442,7 @@ function productShellWriteRefused(command, agentRoot) {
|
|
|
442
442
|
if (!agentRoot)
|
|
443
443
|
return false;
|
|
444
444
|
try {
|
|
445
|
-
return protectedFolderShellWriteRefused(command, (words) => commandMentionsAgentFolder(command, words, agentRoot, "product"));
|
|
445
|
+
return protectedFolderShellWriteRefused(command, (words) => commandMentionsAgentFolder(command, words, agentRoot, "product", [path.resolve(agentRoot)]));
|
|
446
446
|
}
|
|
447
447
|
catch {
|
|
448
448
|
// The filesystem could not say where a path that names product/ lands: refuse rather than guess.
|
|
@@ -451,13 +451,15 @@ function productShellWriteRefused(command, agentRoot) {
|
|
|
451
451
|
}
|
|
452
452
|
// --- structural guardrail checks (always on, all trust levels) ---
|
|
453
453
|
function checkReadBeforeWrite(toolName, args, context) {
|
|
454
|
+
// The file tools start relative paths in the agent's home and record what they read by absolute path.
|
|
455
|
+
const target = (p) => path.isAbsolute(p) || !context.agentRoot ? p : path.resolve(context.agentRoot, p);
|
|
454
456
|
if (toolName === "edit_file") {
|
|
455
|
-
const filePath = args.path || "";
|
|
457
|
+
const filePath = target(args.path || "");
|
|
456
458
|
if (!context.readPaths.has(filePath))
|
|
457
459
|
return deny(REASONS.readBeforeEdit);
|
|
458
460
|
}
|
|
459
461
|
if (toolName === "write_file") {
|
|
460
|
-
const filePath = args.path || "";
|
|
462
|
+
const filePath = target(args.path || "");
|
|
461
463
|
if (context.readPaths.has(filePath))
|
|
462
464
|
return allow;
|
|
463
465
|
if (!fs.existsSync(filePath))
|
|
@@ -1472,7 +1474,8 @@ function unknownShellWord(word) {
|
|
|
1472
1474
|
return word.includes(SHELL_PARAMETER_MARKER) || word.includes(SHELL_ARRAY_EXPANSION) || /[*?[\]{}]/.test(word);
|
|
1473
1475
|
}
|
|
1474
1476
|
function shellPathWord(word) {
|
|
1475
|
-
|
|
1477
|
+
// ~-, ~+ and ~user expand to directories this guard cannot know (the previous one, the current one, another home).
|
|
1478
|
+
return word === undefined || unknownShellWord(word) || /^~[^/]/.test(word) ? null : expandHome(word);
|
|
1476
1479
|
}
|
|
1477
1480
|
function noteGitAssignment(token, scan, known) {
|
|
1478
1481
|
const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s);
|
|
@@ -1664,8 +1667,11 @@ function bundleGitRefused(command, agentRoot) {
|
|
|
1664
1667
|
return true;
|
|
1665
1668
|
if (scan.calls.length === 0)
|
|
1666
1669
|
return false;
|
|
1667
|
-
// Every directory the command could be working in: where the shell starts, and each change, from each earlier one.
|
|
1668
|
-
|
|
1670
|
+
// Every directory the command could be working in: where the shell starts (the agent's home), and each change, from each earlier one.
|
|
1671
|
+
// `cd /abs && ...` with nothing that can run apart from the cd succeeding: the rest never runs in the home.
|
|
1672
|
+
const chained = /^\s*cd\s+\/[^\s;&|<>$`'"\\()]*\s*&&(.*)$/s.exec(command);
|
|
1673
|
+
const startsAway = chained !== null && !/[;&|\n`$()<>]/.test(chained[1].replace(/&&/g, ""));
|
|
1674
|
+
const cwds = startsAway ? [] : [path.resolve(agentRoot)];
|
|
1669
1675
|
for (const target of scan.cwdTargets) {
|
|
1670
1676
|
if (target === null)
|
|
1671
1677
|
cwds.push(null);
|
|
@@ -1697,25 +1703,29 @@ function bundleGitRefused(command, agentRoot) {
|
|
|
1697
1703
|
}
|
|
1698
1704
|
}
|
|
1699
1705
|
// --- the sync key's directory, closed to the read-only file tools ---
|
|
1700
|
-
/**
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1706
|
+
/**
|
|
1707
|
+
* The directories read_file, glob and grep would read for these arguments. A relative path starts in the agent's home;
|
|
1708
|
+
* the repo root and the process directory are checked too, so a tool that resolves differently still cannot reach the key.
|
|
1709
|
+
*/
|
|
1710
|
+
function readToolTargets(toolName, args, agentRoot) {
|
|
1711
|
+
const home = agentRoot ? [agentRoot] : [];
|
|
1712
|
+
const local = (p) => path.isAbsolute(p) ? [p] : [...home, (0, identity_1.getRepoRoot)()].map((base) => path.resolve(base, p));
|
|
1713
|
+
if (toolName === "read_file" || toolName === "ls")
|
|
1714
|
+
return local(args.path ?? "").map((target) => ({ target, recursive: false }));
|
|
1705
1715
|
if (toolName === "grep")
|
|
1706
|
-
return
|
|
1707
|
-
const
|
|
1716
|
+
return local(args.path ?? "").map((target) => ({ target, recursive: true }));
|
|
1717
|
+
const cwds = args.cwd ? local(args.cwd) : [...home, process.cwd()];
|
|
1708
1718
|
// The part of the pattern before its first glob segment is a directory the glob reads.
|
|
1709
1719
|
const segments = (args.pattern ?? "").split("/");
|
|
1710
1720
|
const firstGlob = segments.findIndex((segment) => /[*?[\]{}()!]/.test(segment));
|
|
1711
1721
|
const literal = (firstGlob < 0 ? segments : segments.slice(0, firstGlob)).join("/");
|
|
1712
|
-
return [{ target: cwd, recursive: false }, { target: path.resolve(cwd, literal || "."), recursive: false }];
|
|
1722
|
+
return cwds.flatMap((cwd) => [{ target: cwd, recursive: false }, { target: path.resolve(cwd, literal || "."), recursive: false }]);
|
|
1713
1723
|
}
|
|
1714
|
-
function syncSecretReadRefused(toolName, args) {
|
|
1724
|
+
function syncSecretReadRefused(toolName, args, agentRoot) {
|
|
1715
1725
|
try {
|
|
1716
1726
|
const lexical = path.resolve((0, sync_1.syncSecretDir)());
|
|
1717
1727
|
const secrets = [lexical, canonicalize(lexical)];
|
|
1718
|
-
return readToolTargets(toolName, args).some(({ target, recursive }) => [target, canonicalize(target)].some((form) => secrets.some((secret) => form === secret || form.startsWith(`${secret}${path.sep}`) || (recursive && (form === path.parse(form).root || secret.startsWith(`${form}${path.sep}`))))));
|
|
1728
|
+
return readToolTargets(toolName, args, agentRoot).some(({ target, recursive }) => [target, canonicalize(target)].some((form) => secrets.some((secret) => form === secret || form.startsWith(`${secret}${path.sep}`) || (recursive && (form === path.parse(form).root || secret.startsWith(`${form}${path.sep}`))))));
|
|
1719
1729
|
}
|
|
1720
1730
|
catch {
|
|
1721
1731
|
return true;
|
|
@@ -1738,7 +1748,7 @@ function guardInvocation(toolName, args, context) {
|
|
|
1738
1748
|
});
|
|
1739
1749
|
// Read-only tools skip the structural and trust guardrails; only the sync key's directory is closed to them.
|
|
1740
1750
|
if (READ_ONLY_TOOLS.has(toolName))
|
|
1741
|
-
return syncSecretReadRefused(toolName, args) ? deny(REASONS.unreadablePath) : allow;
|
|
1751
|
+
return syncSecretReadRefused(toolName, args, context.agentRoot) ? deny(REASONS.unreadablePath) : allow;
|
|
1742
1752
|
// Layer 1: structural guardrails (always on)
|
|
1743
1753
|
const structuralResult = checkStructuralGuardrails(toolName, args, context);
|
|
1744
1754
|
if (!structuralResult.allowed)
|
|
@@ -14,6 +14,7 @@ const MAX_OUTPUT_LINES = 200;
|
|
|
14
14
|
function spawnBackgroundShell(command, owner) {
|
|
15
15
|
const id = (0, crypto_1.randomUUID)();
|
|
16
16
|
const proc = (0, child_process_1.spawn)("sh", ["-c", command], {
|
|
17
|
+
cwd: owner.agentRoot,
|
|
17
18
|
stdio: ["pipe", "pipe", "pipe"],
|
|
18
19
|
});
|
|
19
20
|
const session = {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SKIP_DIRS = exports.TOOL_MAX_CHARS = exports.TOOL_MAX_LINES = void 0;
|
|
4
|
+
exports.looksBinary = looksBinary;
|
|
5
|
+
exports.cutNotice = cutNotice;
|
|
6
|
+
const runtime_1 = require("../nerves/runtime");
|
|
7
|
+
/** What one home tool shows at most. Kept under the engine's 32,000-character cap so a tool's own notice is what the model sees. */
|
|
8
|
+
exports.TOOL_MAX_LINES = 2000;
|
|
9
|
+
exports.TOOL_MAX_CHARS = 30_000;
|
|
10
|
+
exports.SKIP_DIRS = [".git", "node_modules"];
|
|
11
|
+
const BINARY_SNIFF_BYTES = 8192;
|
|
12
|
+
/** True when the first 8 KB holds a NUL byte, which text never does. */
|
|
13
|
+
function looksBinary(buffer) {
|
|
14
|
+
return buffer.subarray(0, BINARY_SNIFF_BYTES).includes(0);
|
|
15
|
+
}
|
|
16
|
+
/** Record that a tool cut its output short, and return the notice for the model. */
|
|
17
|
+
function cutNotice(tool, notice, meta = {}) {
|
|
18
|
+
(0, runtime_1.emitNervesEvent)({ component: "repertoire", event: "repertoire.tool_output_cut", message: `${tool} cut its output short`, meta: { tool, ...meta } });
|
|
19
|
+
return notice;
|
|
20
|
+
}
|
|
@@ -5,6 +5,7 @@ exports.routineActionDenied = routineActionDenied;
|
|
|
5
5
|
const runtime_1 = require("../nerves/runtime");
|
|
6
6
|
const tools_files_1 = require("./tools-files");
|
|
7
7
|
const tools_shell_1 = require("./tools-shell");
|
|
8
|
+
const tools_web_1 = require("./tools-web");
|
|
8
9
|
const tools_notes_1 = require("./tools-notes");
|
|
9
10
|
const tools_record_1 = require("./tools-record");
|
|
10
11
|
const tools_bridge_1 = require("./tools-bridge");
|
|
@@ -60,6 +61,7 @@ exports.editFileReadTracker = new Set();
|
|
|
60
61
|
exports.baseToolDefinitions = [
|
|
61
62
|
...tools_files_1.fileToolDefinitions,
|
|
62
63
|
...tools_shell_1.shellToolDefinitions,
|
|
64
|
+
...tools_web_1.webToolDefinitions,
|
|
63
65
|
...tools_notes_1.notesToolDefinitions,
|
|
64
66
|
...tools_record_1.recordToolDefinitions,
|
|
65
67
|
...tools_bridge_1.bridgeToolDefinitions,
|
|
@@ -42,6 +42,10 @@ const file_state_1 = require("../mind/file-state");
|
|
|
42
42
|
const scrutiny_1 = require("../mind/scrutiny");
|
|
43
43
|
const tools_base_1 = require("./tools-base");
|
|
44
44
|
const guardrails_1 = require("./guardrails");
|
|
45
|
+
const tool_limits_1 = require("./tool-limits");
|
|
46
|
+
const LIST_CAP = 500;
|
|
47
|
+
const GREP_MAX_MATCHES = 100;
|
|
48
|
+
const GREP_MAX_LINE_CHARS = 500;
|
|
45
49
|
/** The psyche edit note for a write that landed on one of the agent's identity files, or "" for any other file. */
|
|
46
50
|
function psycheNoteFor(resolvedPath, ctx) {
|
|
47
51
|
try {
|
|
@@ -57,12 +61,28 @@ function psycheNoteFor(resolvedPath, ctx) {
|
|
|
57
61
|
return "";
|
|
58
62
|
}
|
|
59
63
|
}
|
|
60
|
-
|
|
64
|
+
/** Relative paths start in the agent's home (its bundle); absolute paths are used as given. */
|
|
65
|
+
function resolveLocalToolPath(targetPath, ctx) {
|
|
61
66
|
if (!path.isAbsolute(targetPath)) {
|
|
62
|
-
return path.resolve((0, identity_1.
|
|
67
|
+
return path.resolve(ctx?.agentRoot ?? (0, identity_1.getAgentRoot)(), targetPath);
|
|
63
68
|
}
|
|
64
69
|
return targetPath;
|
|
65
70
|
}
|
|
71
|
+
function homeOf(ctx) {
|
|
72
|
+
return ctx?.agentRoot ?? (0, identity_1.getAgentRoot)();
|
|
73
|
+
}
|
|
74
|
+
/** Keep the whole lines that fit the line and character caps. A first line over the character cap is cut to it. */
|
|
75
|
+
function fitLines(lines) {
|
|
76
|
+
const kept = [];
|
|
77
|
+
let chars = 0;
|
|
78
|
+
for (const line of lines.slice(0, tool_limits_1.TOOL_MAX_LINES)) {
|
|
79
|
+
if (chars + line.length + 1 > tool_limits_1.TOOL_MAX_CHARS)
|
|
80
|
+
break;
|
|
81
|
+
kept.push(line);
|
|
82
|
+
chars += line.length + 1;
|
|
83
|
+
}
|
|
84
|
+
return kept.length > 0 || lines.length === 0 ? kept : [lines[0].slice(0, tool_limits_1.TOOL_MAX_CHARS)];
|
|
85
|
+
}
|
|
66
86
|
function buildContextDiff(lines, changeStart, changeEnd, contextSize = 3) {
|
|
67
87
|
const start = Math.max(0, changeStart - contextSize);
|
|
68
88
|
const end = Math.min(lines.length, changeEnd + contextSize);
|
|
@@ -74,47 +94,69 @@ function buildContextDiff(lines, changeStart, changeEnd, contextSize = 3) {
|
|
|
74
94
|
}
|
|
75
95
|
return result.join("\n");
|
|
76
96
|
}
|
|
97
|
+
/** A file's bytes, whatever readFileSync handed back (a stubbed reader may return a string). */
|
|
98
|
+
function asBuffer(raw) {
|
|
99
|
+
return Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
100
|
+
}
|
|
77
101
|
exports.fileToolDefinitions = [
|
|
78
102
|
{
|
|
79
103
|
tool: {
|
|
80
104
|
type: "function",
|
|
81
105
|
function: {
|
|
82
106
|
name: "read_file",
|
|
83
|
-
description: "Read file
|
|
107
|
+
description: "Read a file in your home, with a relative path or an absolute one. Shows up to 2000 lines or 30,000 characters and tells you the offset to read on from; binary files are not shown.",
|
|
84
108
|
parameters: {
|
|
85
109
|
type: "object",
|
|
86
110
|
properties: {
|
|
87
111
|
path: { type: "string" },
|
|
88
112
|
offset: { type: "number", description: "1-based line number to start reading from" },
|
|
89
113
|
limit: { type: "number", description: "maximum number of lines to return" },
|
|
114
|
+
char_offset: { type: "number", description: "1-based character to start from within the first line shown, to read on through a line over 30,000 characters" },
|
|
90
115
|
},
|
|
91
116
|
required: ["path"],
|
|
92
117
|
},
|
|
93
118
|
},
|
|
94
119
|
},
|
|
95
|
-
handler: (a) => {
|
|
96
|
-
const resolvedPath = resolveLocalToolPath(a.path);
|
|
97
|
-
const
|
|
120
|
+
handler: (a, ctx) => {
|
|
121
|
+
const resolvedPath = resolveLocalToolPath(a.path, ctx);
|
|
122
|
+
const raw = asBuffer(fs.readFileSync(resolvedPath));
|
|
123
|
+
if ((0, tool_limits_1.looksBinary)(raw))
|
|
124
|
+
return `[binary file, ${raw.length} bytes; not shown]`;
|
|
125
|
+
const full = raw.toString("utf-8");
|
|
126
|
+
// A final newline ends the last line; it is not another, empty line.
|
|
127
|
+
const endsWithNewline = full.endsWith("\n");
|
|
128
|
+
const lines = (endsWithNewline ? full.slice(0, -1) : full).split("\n");
|
|
98
129
|
tools_base_1.editFileReadTracker.add(resolvedPath);
|
|
99
130
|
const offset = a.offset ? parseInt(a.offset, 10) : undefined;
|
|
100
131
|
const limit = a.limit ? parseInt(a.limit, 10) : undefined;
|
|
132
|
+
const charStart = a.char_offset ? Math.max(0, parseInt(a.char_offset, 10) - 1) : 0;
|
|
133
|
+
const start = offset ? offset - 1 : 0;
|
|
134
|
+
const wanted = lines.slice(start, limit !== undefined ? start + limit : undefined);
|
|
135
|
+
const firstLine = wanted.length > 0 ? wanted[0].slice(charStart) : "";
|
|
136
|
+
const kept = fitLines(wanted.length > 0 ? [firstLine, ...wanted.slice(1)] : wanted);
|
|
137
|
+
// The first line alone is over the character cap: show its next stretch and say where to continue.
|
|
138
|
+
const firstLineCut = kept.length === 1 && wanted.length > 0 && kept[0].length < firstLine.length;
|
|
139
|
+
const text = kept.join("\n") + (!firstLineCut && start + kept.length >= lines.length && endsWithNewline && kept.length > 0 ? "\n" : "");
|
|
140
|
+
const whole = offset === undefined && limit === undefined && charStart === 0 && !firstLineCut && kept.length === wanted.length;
|
|
101
141
|
// Record in file state cache for staleness detection
|
|
102
142
|
try {
|
|
103
143
|
const mtime = fs.statSync(resolvedPath).mtimeMs;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
144
|
+
if (whole)
|
|
145
|
+
file_state_1.fileStateCache.record(resolvedPath, text, mtime);
|
|
146
|
+
else
|
|
147
|
+
file_state_1.fileStateCache.record(resolvedPath, text, mtime, start + 1, kept.length);
|
|
108
148
|
}
|
|
109
149
|
catch {
|
|
110
150
|
// stat failed -- skip cache recording
|
|
111
151
|
}
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
152
|
+
if (firstLineCut) {
|
|
153
|
+
const shownTo = charStart + kept[0].length;
|
|
154
|
+
return `${text}\n${(0, tool_limits_1.cutNotice)("read_file", `[line ${start + 1} is ${wanted[0].length} characters; characters ${charStart + 1}-${shownTo} shown; read on with offset=${start + 1} and char_offset=${shownTo + 1}]`, { path: resolvedPath })}`;
|
|
155
|
+
}
|
|
156
|
+
if (kept.length === wanted.length)
|
|
157
|
+
return text;
|
|
158
|
+
const last = start + kept.length;
|
|
159
|
+
return `${text}\n${(0, tool_limits_1.cutNotice)("read_file", `[lines ${start + 1}-${last} of ${lines.length} shown; read on with offset=${last + 1}]`, { path: resolvedPath })}`;
|
|
118
160
|
},
|
|
119
161
|
summaryKeys: ["path"],
|
|
120
162
|
},
|
|
@@ -123,7 +165,7 @@ exports.fileToolDefinitions = [
|
|
|
123
165
|
type: "function",
|
|
124
166
|
function: {
|
|
125
167
|
name: "write_file",
|
|
126
|
-
description: "Prefer this tool for creating new files or fully replacing existing ones. You MUST read an existing file with read_file before overwriting it. Prefer edit_file for modifying existing files -- it only sends the diff.
|
|
168
|
+
description: "Prefer this tool for creating new files or fully replacing existing ones. You MUST read an existing file with read_file before overwriting it. Prefer edit_file for modifying existing files -- it only sends the diff.",
|
|
127
169
|
parameters: {
|
|
128
170
|
type: "object",
|
|
129
171
|
properties: { path: { type: "string" }, content: { type: "string" } },
|
|
@@ -132,7 +174,7 @@ exports.fileToolDefinitions = [
|
|
|
132
174
|
},
|
|
133
175
|
},
|
|
134
176
|
handler: (a, ctx) => {
|
|
135
|
-
const resolvedPath = resolveLocalToolPath(a.path);
|
|
177
|
+
const resolvedPath = resolveLocalToolPath(a.path, ctx);
|
|
136
178
|
fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
|
|
137
179
|
fs.writeFileSync(resolvedPath, a.content, "utf-8");
|
|
138
180
|
(0, scrutiny_1.trackModifiedFile)(resolvedPath);
|
|
@@ -162,7 +204,7 @@ exports.fileToolDefinitions = [
|
|
|
162
204
|
},
|
|
163
205
|
},
|
|
164
206
|
handler: (a, ctx) => {
|
|
165
|
-
const resolvedPath = resolveLocalToolPath(a.path);
|
|
207
|
+
const resolvedPath = resolveLocalToolPath(a.path, ctx);
|
|
166
208
|
if (!tools_base_1.editFileReadTracker.has(resolvedPath)) {
|
|
167
209
|
return `error: you must read the file with read_file before editing it. call read_file on ${a.path} first.`;
|
|
168
210
|
}
|
|
@@ -227,26 +269,50 @@ exports.fileToolDefinitions = [
|
|
|
227
269
|
summaryKeys: ["path"],
|
|
228
270
|
riskProfile: { mutates: "durable_state_write", risk: "high", reason: "edits file contents" },
|
|
229
271
|
},
|
|
272
|
+
{
|
|
273
|
+
tool: {
|
|
274
|
+
type: "function",
|
|
275
|
+
function: {
|
|
276
|
+
name: "ls",
|
|
277
|
+
description: "List a directory in your home (default: home itself), one entry per line; directories end in /, files show their size. Shows at most 500 entries.",
|
|
278
|
+
parameters: { type: "object", properties: { path: { type: "string", description: "directory to list (defaults to your home)" } } },
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
handler: (a, ctx) => {
|
|
282
|
+
const dir = a.path ? resolveLocalToolPath(a.path, ctx) : homeOf(ctx);
|
|
283
|
+
const names = fs.readdirSync(dir).sort();
|
|
284
|
+
const lines = names.slice(0, LIST_CAP).map((name) => {
|
|
285
|
+
const info = fs.statSync(path.join(dir, name), { throwIfNoEntry: false });
|
|
286
|
+
return info?.isDirectory() ? `${name}/` : `${name} ${info?.size ?? 0}`;
|
|
287
|
+
});
|
|
288
|
+
if (names.length > LIST_CAP)
|
|
289
|
+
lines.push((0, tool_limits_1.cutNotice)("ls", `[${LIST_CAP} of ${names.length} entries shown; pass a subdirectory path to see the rest]`, { path: dir }));
|
|
290
|
+
return lines.length > 0 ? lines.join("\n") : "(empty)";
|
|
291
|
+
},
|
|
292
|
+
summaryKeys: ["path"],
|
|
293
|
+
},
|
|
230
294
|
{
|
|
231
295
|
tool: {
|
|
232
296
|
type: "function",
|
|
233
297
|
function: {
|
|
234
298
|
name: "glob",
|
|
235
|
-
description: "Find files matching a glob pattern, sorted
|
|
299
|
+
description: "Find files in your home matching a glob pattern, sorted. Skips .git and node_modules and shows at most 500 matches.",
|
|
236
300
|
parameters: {
|
|
237
301
|
type: "object",
|
|
238
302
|
properties: {
|
|
239
|
-
pattern: { type: "string", description: "glob pattern (e.g. **/*.
|
|
240
|
-
cwd: { type: "string", description: "directory to search from (defaults to
|
|
303
|
+
pattern: { type: "string", description: "glob pattern (e.g. **/*.md)" },
|
|
304
|
+
cwd: { type: "string", description: "directory to search from (defaults to your home)" },
|
|
241
305
|
},
|
|
242
306
|
required: ["pattern"],
|
|
243
307
|
},
|
|
244
308
|
},
|
|
245
309
|
},
|
|
246
|
-
handler: (a) => {
|
|
247
|
-
const cwd = a.cwd ? resolveLocalToolPath(a.cwd) :
|
|
248
|
-
const matches = fg.globSync(a.pattern, { cwd, dot: true });
|
|
249
|
-
|
|
310
|
+
handler: (a, ctx) => {
|
|
311
|
+
const cwd = a.cwd ? resolveLocalToolPath(a.cwd, ctx) : homeOf(ctx);
|
|
312
|
+
const matches = fg.globSync(a.pattern, { cwd, dot: true, ignore: tool_limits_1.SKIP_DIRS.map((d) => `**/${d}/**`) }).sort();
|
|
313
|
+
if (matches.length <= LIST_CAP)
|
|
314
|
+
return matches.join("\n");
|
|
315
|
+
return [...matches.slice(0, LIST_CAP), (0, tool_limits_1.cutNotice)("glob", `[${LIST_CAP} of ${matches.length} matches shown; narrow the pattern or cwd]`, { pattern: a.pattern })].join("\n");
|
|
250
316
|
},
|
|
251
317
|
summaryKeys: ["pattern", "cwd"],
|
|
252
318
|
},
|
|
@@ -255,41 +321,64 @@ exports.fileToolDefinitions = [
|
|
|
255
321
|
type: "function",
|
|
256
322
|
function: {
|
|
257
323
|
name: "grep",
|
|
258
|
-
description: "Search file contents
|
|
324
|
+
description: "Search file contents in your home for a regex, with file path and line numbers; skips .git, node_modules and binary files. Shows at most 100 matches and tells you to narrow the search when it stops.",
|
|
259
325
|
parameters: {
|
|
260
326
|
type: "object",
|
|
261
327
|
properties: {
|
|
262
328
|
pattern: { type: "string", description: "regex pattern to search for" },
|
|
263
|
-
path: { type: "string", description: "file or directory to search" },
|
|
329
|
+
path: { type: "string", description: "file or directory to search (defaults to your home)" },
|
|
264
330
|
context_lines: { type: "number", description: "number of surrounding context lines (default 0)" },
|
|
265
|
-
include: { type: "string", description: "glob filter to limit searched files (e.g. *.
|
|
331
|
+
include: { type: "string", description: "glob filter to limit searched files (e.g. *.md)" },
|
|
332
|
+
ignore_case: { type: "boolean", description: "match without regard to case" },
|
|
333
|
+
files_only: { type: "boolean", description: "list only the names of files that match" },
|
|
266
334
|
},
|
|
267
|
-
required: ["pattern"
|
|
335
|
+
required: ["pattern"],
|
|
268
336
|
},
|
|
269
337
|
},
|
|
270
338
|
},
|
|
271
|
-
handler: (a) => {
|
|
272
|
-
const targetPath = resolveLocalToolPath(a.path);
|
|
273
|
-
const regex = new RegExp(a.pattern);
|
|
339
|
+
handler: (a, ctx) => {
|
|
340
|
+
const targetPath = a.path ? resolveLocalToolPath(a.path, ctx) : homeOf(ctx);
|
|
341
|
+
const regex = new RegExp(a.pattern, a.ignore_case ? "i" : "");
|
|
274
342
|
const contextLines = parseInt(a.context_lines || "0", 10);
|
|
275
343
|
const includeGlob = a.include || undefined;
|
|
344
|
+
const filesOnly = Boolean(a.files_only);
|
|
345
|
+
let matchCount = 0;
|
|
346
|
+
let stopped = false;
|
|
347
|
+
const clipLine = (line) => line.length > GREP_MAX_LINE_CHARS ? `${line.slice(0, GREP_MAX_LINE_CHARS - 3)}...` : line;
|
|
348
|
+
const home = homeOf(ctx);
|
|
349
|
+
// Paths inside the home are shown relative to it.
|
|
350
|
+
const shown = (filePath) => {
|
|
351
|
+
const relative = path.relative(home, filePath);
|
|
352
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : filePath;
|
|
353
|
+
};
|
|
276
354
|
function searchFile(filePath) {
|
|
277
|
-
let
|
|
355
|
+
let buffer;
|
|
278
356
|
try {
|
|
279
|
-
|
|
357
|
+
buffer = asBuffer(fs.readFileSync(filePath));
|
|
280
358
|
}
|
|
281
359
|
catch {
|
|
282
360
|
return [];
|
|
283
361
|
}
|
|
284
|
-
|
|
362
|
+
if ((0, tool_limits_1.looksBinary)(buffer))
|
|
363
|
+
return [];
|
|
364
|
+
const lines = buffer.toString("utf-8").split("\n");
|
|
285
365
|
const matchIndices = new Set();
|
|
286
366
|
for (let i = 0; i < lines.length; i++) {
|
|
287
|
-
if (regex.test(lines[i]))
|
|
288
|
-
|
|
367
|
+
if (!regex.test(lines[i]))
|
|
368
|
+
continue;
|
|
369
|
+
if (matchCount >= GREP_MAX_MATCHES) {
|
|
370
|
+
stopped = true;
|
|
371
|
+
break;
|
|
289
372
|
}
|
|
373
|
+
matchCount++;
|
|
374
|
+
matchIndices.add(i);
|
|
375
|
+
if (filesOnly)
|
|
376
|
+
break;
|
|
290
377
|
}
|
|
291
378
|
if (matchIndices.size === 0)
|
|
292
379
|
return [];
|
|
380
|
+
if (filesOnly)
|
|
381
|
+
return [shown(filePath)];
|
|
293
382
|
const outputIndices = new Set();
|
|
294
383
|
for (const idx of matchIndices) {
|
|
295
384
|
const start = Math.max(0, idx - contextLines);
|
|
@@ -298,18 +387,7 @@ exports.fileToolDefinitions = [
|
|
|
298
387
|
outputIndices.add(i);
|
|
299
388
|
}
|
|
300
389
|
}
|
|
301
|
-
|
|
302
|
-
const results = [];
|
|
303
|
-
for (const idx of sortedIndices) {
|
|
304
|
-
const lineNum = idx + 1;
|
|
305
|
-
if (matchIndices.has(idx)) {
|
|
306
|
-
results.push(`${filePath}:${lineNum}: ${lines[idx]}`);
|
|
307
|
-
}
|
|
308
|
-
else {
|
|
309
|
-
results.push(`-${filePath}:${lineNum}: ${lines[idx]}`);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return results;
|
|
390
|
+
return [...outputIndices].sort((x, y) => x - y).map((idx) => `${matchIndices.has(idx) ? "" : "-"}${shown(filePath)}:${idx + 1}: ${clipLine(lines[idx])}`);
|
|
313
391
|
}
|
|
314
392
|
function collectFiles(dirPath) {
|
|
315
393
|
const files = [];
|
|
@@ -324,7 +402,8 @@ exports.fileToolDefinitions = [
|
|
|
324
402
|
for (const entry of entries) {
|
|
325
403
|
const fullPath = path.join(dir, entry.name);
|
|
326
404
|
if (entry.isDirectory()) {
|
|
327
|
-
|
|
405
|
+
if (!tool_limits_1.SKIP_DIRS.includes(entry.name))
|
|
406
|
+
walk(fullPath);
|
|
328
407
|
}
|
|
329
408
|
else if (entry.isFile()) {
|
|
330
409
|
files.push(fullPath);
|
|
@@ -344,18 +423,21 @@ exports.fileToolDefinitions = [
|
|
|
344
423
|
const stat = fs.statSync(targetPath, { throwIfNoEntry: false });
|
|
345
424
|
if (!stat)
|
|
346
425
|
return "";
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
files = files.filter((f) => matchesGlob(f, includeGlob));
|
|
426
|
+
let files = [targetPath];
|
|
427
|
+
if (!stat.isFile()) {
|
|
428
|
+
files = collectFiles(targetPath);
|
|
429
|
+
if (includeGlob)
|
|
430
|
+
files = files.filter((f) => matchesGlob(f, includeGlob));
|
|
353
431
|
}
|
|
354
|
-
const
|
|
432
|
+
const results = [];
|
|
355
433
|
for (const file of files) {
|
|
356
|
-
|
|
434
|
+
results.push(...searchFile(file));
|
|
435
|
+
if (stopped)
|
|
436
|
+
break;
|
|
357
437
|
}
|
|
358
|
-
|
|
438
|
+
if (stopped)
|
|
439
|
+
results.push((0, tool_limits_1.cutNotice)("grep", `[stopped at ${GREP_MAX_MATCHES} matches; narrow the pattern or path]`, { pattern: a.pattern }));
|
|
440
|
+
return results.join("\n");
|
|
359
441
|
},
|
|
360
442
|
summaryKeys: ["pattern", "path", "include"],
|
|
361
443
|
},
|
|
@@ -1,11 +1,81 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.shellToolDefinitions = void 0;
|
|
4
37
|
const child_process_1 = require("child_process");
|
|
38
|
+
const crypto_1 = require("crypto");
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
5
41
|
const shell_sessions_1 = require("./shell-sessions");
|
|
6
42
|
const identity_1 = require("../heart/identity");
|
|
7
43
|
const runtime_1 = require("../nerves/runtime");
|
|
8
44
|
const tool_arguments_1 = require("./tool-arguments");
|
|
45
|
+
const tool_limits_1 = require("./tool-limits");
|
|
46
|
+
const MAX_BUFFER = 16 * 1024 * 1024;
|
|
47
|
+
/** The agent's home, or the process directory for a legacy caller that has no agent. */
|
|
48
|
+
function shellHome(ctx) {
|
|
49
|
+
try {
|
|
50
|
+
return ctx?.agentRoot ?? (0, identity_1.getAgentRoot)();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return process.cwd();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Keep the last 2000 lines or 30,000 characters of a command's output. When something is cut, the whole output
|
|
58
|
+
* is saved under the home's state/tool-output folder (owner-only) and the notice names that file.
|
|
59
|
+
*/
|
|
60
|
+
function tailWithNotice(output, home) {
|
|
61
|
+
const body = output.endsWith("\n") ? output.slice(0, -1) : output;
|
|
62
|
+
const lines = body.split("\n");
|
|
63
|
+
let tail = lines.slice(-tool_limits_1.TOOL_MAX_LINES).join("\n");
|
|
64
|
+
if (tail.length > tool_limits_1.TOOL_MAX_CHARS) {
|
|
65
|
+
tail = tail.slice(tail.length - tool_limits_1.TOOL_MAX_CHARS);
|
|
66
|
+
const firstBreak = tail.indexOf("\n");
|
|
67
|
+
if (firstBreak >= 0)
|
|
68
|
+
tail = tail.slice(firstBreak + 1);
|
|
69
|
+
}
|
|
70
|
+
if (tail.length === body.length)
|
|
71
|
+
return output;
|
|
72
|
+
const dir = path.join(home, "state", "tool-output");
|
|
73
|
+
const file = path.join(dir, `${Date.now()}-${(0, crypto_1.randomBytes)(3).toString("hex")}.txt`);
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
fs.writeFileSync(file, output, { mode: 0o600 });
|
|
76
|
+
const shown = tail.split("\n").length;
|
|
77
|
+
return `${(0, tool_limits_1.cutNotice)("shell", `[output cut to its last ${shown} of ${lines.length} lines; full output saved to ${file}; read_file it with an offset]`, { file })}\n${tail}`;
|
|
78
|
+
}
|
|
9
79
|
function shellOwner(ctx) {
|
|
10
80
|
(0, tool_arguments_1.assertRelationshipToolOwner)(ctx);
|
|
11
81
|
const agentName = ctx?.agentName ?? (0, identity_1.getAgentName)();
|
|
@@ -17,7 +87,7 @@ exports.shellToolDefinitions = [
|
|
|
17
87
|
type: "function",
|
|
18
88
|
function: {
|
|
19
89
|
name: "shell",
|
|
20
|
-
description: "Run a shell command
|
|
90
|
+
description: "Run a shell command in your home; cd does not persist between calls. Keeps the last 2000 lines or 30,000 characters of output and saves the full output to a file it names when it cuts.",
|
|
21
91
|
parameters: {
|
|
22
92
|
type: "object",
|
|
23
93
|
properties: {
|
|
@@ -84,15 +154,20 @@ exports.shellToolDefinitions = [
|
|
|
84
154
|
}
|
|
85
155
|
const baseTimeout = requestedTimeout > 0 ? requestedTimeout : configDefault;
|
|
86
156
|
const timeout = Math.min(baseTimeout, MAX_TIMEOUT);
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
157
|
+
const home = shellHome(ctx);
|
|
158
|
+
const raw = await new Promise((resolve, reject) => {
|
|
159
|
+
const child = (0, child_process_1.exec)(a.command, { encoding: "utf-8", timeout, cwd: home, maxBuffer: MAX_BUFFER }, (error, stdout, stderr) => {
|
|
160
|
+
if (error) {
|
|
161
|
+
// A failing command's output gets the same tail cap and saved file.
|
|
162
|
+
error.message = tailWithNotice(error.message, home);
|
|
163
|
+
reject(Object.assign(error, { stdout: tailWithNotice(stdout, home), stderr: tailWithNotice(stderr, home) }));
|
|
164
|
+
}
|
|
91
165
|
else
|
|
92
166
|
resolve(stdout);
|
|
93
167
|
});
|
|
94
168
|
child.stdin?.end();
|
|
95
169
|
});
|
|
170
|
+
const output = tailWithNotice(raw, home);
|
|
96
171
|
if (destructivePatterns.length > 0) {
|
|
97
172
|
return `${output}\n\n--- destructive pattern detected: ${destructivePatterns.join(", ")} ---`;
|
|
98
173
|
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.webToolDefinitions = exports.webTestHooks = void 0;
|
|
37
|
+
exports.isPrivateAddress = isPrivateAddress;
|
|
38
|
+
exports.htmlToText = htmlToText;
|
|
39
|
+
const promises_1 = require("node:dns/promises");
|
|
40
|
+
const http = __importStar(require("node:http"));
|
|
41
|
+
const https = __importStar(require("node:https"));
|
|
42
|
+
const node_net_1 = require("node:net");
|
|
43
|
+
const runtime_1 = require("../nerves/runtime");
|
|
44
|
+
const tool_limits_1 = require("./tool-limits");
|
|
45
|
+
const MAX_REDIRECTS = 5;
|
|
46
|
+
/** Test hook: the request deadline. */
|
|
47
|
+
exports.webTestHooks = { timeoutMs: 20_000 };
|
|
48
|
+
const MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024;
|
|
49
|
+
function ipv4Private(a, b, c) {
|
|
50
|
+
return a === 0 || a === 10 || a === 127 || a >= 224
|
|
51
|
+
|| (a === 100 && b >= 64 && b <= 127)
|
|
52
|
+
|| (a === 169 && b === 254)
|
|
53
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
54
|
+
|| (a === 192 && b === 168)
|
|
55
|
+
|| (a === 192 && b === 0 && c === 0)
|
|
56
|
+
|| (a === 198 && (b === 18 || b === 19));
|
|
57
|
+
}
|
|
58
|
+
function ipv6Groups(ip) {
|
|
59
|
+
const [left, right] = ip.split("%")[0].split("::");
|
|
60
|
+
const head = left ? left.split(":") : [];
|
|
61
|
+
const tail = right ? right.split(":") : [];
|
|
62
|
+
const gap = right === undefined ? [] : Array(8 - head.length - tail.length).fill("0");
|
|
63
|
+
return [...head, ...gap, ...tail].map((p) => parseInt(p, 16));
|
|
64
|
+
}
|
|
65
|
+
/** True for loopback, private, link-local, CGNAT, multicast and other addresses that are not on the public internet. */
|
|
66
|
+
function isPrivateAddress(ip) {
|
|
67
|
+
if ((0, node_net_1.isIP)(ip) === 4) {
|
|
68
|
+
const [a, b, c] = ip.split(".").map(Number);
|
|
69
|
+
return ipv4Private(a, b, c);
|
|
70
|
+
}
|
|
71
|
+
const g = ipv6Groups(ip);
|
|
72
|
+
const embeddedV4 = ipv4Private(g[6] >> 8, g[6] & 255, g[7] >> 8);
|
|
73
|
+
if (g.slice(0, 5).every((n) => n === 0) && (g[5] === 0 || g[5] === 0xffff))
|
|
74
|
+
return embeddedV4;
|
|
75
|
+
if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((n) => n === 0))
|
|
76
|
+
return embeddedV4;
|
|
77
|
+
return (g[0] & 0xffc0) === 0xfe80 || (g[0] & 0xffc0) === 0xfec0 || (g[0] & 0xfe00) === 0xfc00 || (g[0] & 0xff00) === 0xff00;
|
|
78
|
+
}
|
|
79
|
+
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'", nbsp: " " };
|
|
80
|
+
function decodeEntities(text) {
|
|
81
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, body) => {
|
|
82
|
+
if (body[0] !== "#")
|
|
83
|
+
return ENTITIES[body.toLowerCase()] ?? whole;
|
|
84
|
+
const code = body[1].toLowerCase() === "x" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
85
|
+
return code <= 0x10ffff ? String.fromCodePoint(code) : whole;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** Readable text from HTML: the title first, script/style/nav dropped, block tags as line breaks, entities decoded. */
|
|
89
|
+
function htmlToText(html) {
|
|
90
|
+
const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1];
|
|
91
|
+
const body = html
|
|
92
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
93
|
+
.replace(/<(script|style|nav|noscript|head|title)\b[\s\S]*?<\/\1>/gi, "")
|
|
94
|
+
.replace(/<\/?(p|div|br|li|ul|ol|h[1-6]|tr|table|section|article|header|footer|main|pre|blockquote|form)\b[^>]*>/gi, "\n")
|
|
95
|
+
.replace(/<[^>]+>/g, "");
|
|
96
|
+
const text = decodeEntities(body).split("\n").map((l) => l.replace(/[ \t]+/g, " ").trim()).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
97
|
+
const heading = title ? decodeEntities(title).replace(/\s+/g, " ").trim() : "";
|
|
98
|
+
return heading ? `${heading}\n\n${text}`.trim() : text;
|
|
99
|
+
}
|
|
100
|
+
class RefusedError extends Error {
|
|
101
|
+
constructor(hostname, address) {
|
|
102
|
+
super(`refused: ${hostname} resolves to a private or local address (${address})`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** A person in the family circle, not an agent: they may fetch addresses inside the house. */
|
|
106
|
+
function isFamilyHuman(ctx) {
|
|
107
|
+
const friend = ctx?.context?.friend;
|
|
108
|
+
return friend?.kind === "human" && friend.trustLevel === "family";
|
|
109
|
+
}
|
|
110
|
+
/** Resolves the name, refuses private addresses, and hands the socket the address that was checked. */
|
|
111
|
+
function checkedLookup(exempt) {
|
|
112
|
+
return (hostname, options, callback) => {
|
|
113
|
+
(0, promises_1.lookup)(hostname, { all: true }).then((records) => {
|
|
114
|
+
const bad = exempt ? undefined : records.map((r) => r.address).find(isPrivateAddress);
|
|
115
|
+
if (bad)
|
|
116
|
+
return callback(new RefusedError(hostname, bad), "", 4);
|
|
117
|
+
if (options.all)
|
|
118
|
+
return callback(null, records);
|
|
119
|
+
return callback(null, records[0].address, records[0].family);
|
|
120
|
+
}, (error) => callback(error, "", 4));
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function get(url, exempt, signal) {
|
|
124
|
+
const lib = url.protocol === "https:" ? https : http;
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const req = lib.request(url, {
|
|
127
|
+
method: "GET", signal, lookup: checkedLookup(exempt),
|
|
128
|
+
headers: { "user-agent": "ouro-agent/1.0", accept: "text/html,text/plain,application/json;q=0.9,*/*;q=0.5" },
|
|
129
|
+
}, resolve);
|
|
130
|
+
req.on("error", reject);
|
|
131
|
+
req.end();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async function readCapped(response) {
|
|
135
|
+
const chunks = [];
|
|
136
|
+
let size = 0;
|
|
137
|
+
for await (const chunk of response) {
|
|
138
|
+
chunks.push(chunk);
|
|
139
|
+
size += chunk.length;
|
|
140
|
+
if (size >= MAX_DOWNLOAD_BYTES) {
|
|
141
|
+
response.destroy();
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { bytes: Buffer.concat(chunks).subarray(0, MAX_DOWNLOAD_BYTES), cut: size >= MAX_DOWNLOAD_BYTES };
|
|
146
|
+
}
|
|
147
|
+
async function fetchPage(rawUrl, offset, exempt) {
|
|
148
|
+
let url;
|
|
149
|
+
try {
|
|
150
|
+
url = new URL(rawUrl);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return `error: not a valid URL: ${rawUrl}`;
|
|
154
|
+
}
|
|
155
|
+
const signal = AbortSignal.timeout(exports.webTestHooks.timeoutMs);
|
|
156
|
+
for (let hop = 0;; hop++) {
|
|
157
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
158
|
+
return `error: only http and https URLs can be fetched, not ${url.protocol}`;
|
|
159
|
+
const literal = url.hostname.replace(/^\[|\]$/g, "");
|
|
160
|
+
if (!exempt && (0, node_net_1.isIP)(literal) && isPrivateAddress(literal))
|
|
161
|
+
return `error: ${new RefusedError(url.hostname, literal).message}`;
|
|
162
|
+
const response = await get(url, exempt, signal);
|
|
163
|
+
const status = response.statusCode;
|
|
164
|
+
const location = response.headers.location;
|
|
165
|
+
if (status >= 300 && status < 400 && location) {
|
|
166
|
+
response.destroy();
|
|
167
|
+
if (hop >= MAX_REDIRECTS)
|
|
168
|
+
return `error: more than ${MAX_REDIRECTS} redirects fetching ${rawUrl}`;
|
|
169
|
+
url = new URL(location, url);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (status === 204) {
|
|
173
|
+
response.destroy();
|
|
174
|
+
return "(no content: HTTP 204)";
|
|
175
|
+
}
|
|
176
|
+
if (status < 200 || status >= 300) {
|
|
177
|
+
response.destroy();
|
|
178
|
+
return `error: HTTP ${status} fetching ${url.href}`;
|
|
179
|
+
}
|
|
180
|
+
const type = (response.headers["content-type"] ?? "text/plain").split(";")[0].trim().toLowerCase();
|
|
181
|
+
const isHtml = type === "text/html" || type === "application/xhtml+xml";
|
|
182
|
+
if (!isHtml && !type.startsWith("text/") && !type.includes("json") && !type.endsWith("xml")) {
|
|
183
|
+
response.destroy();
|
|
184
|
+
return `[content type ${type}; not shown]`;
|
|
185
|
+
}
|
|
186
|
+
const { bytes, cut } = await readCapped(response);
|
|
187
|
+
const decoded = new TextDecoder().decode(bytes);
|
|
188
|
+
const text = isHtml ? htmlToText(decoded) : decoded;
|
|
189
|
+
const end = Math.min(text.length, offset + tool_limits_1.TOOL_MAX_CHARS);
|
|
190
|
+
const page = text.slice(offset, end);
|
|
191
|
+
const notes = [];
|
|
192
|
+
if (end < text.length)
|
|
193
|
+
notes.push(`[characters ${offset}-${end} of ${text.length}; read on with offset=${end}]`);
|
|
194
|
+
if (cut)
|
|
195
|
+
notes.push("[download cut at 2 MB]");
|
|
196
|
+
(0, runtime_1.emitNervesEvent)({ component: "repertoire", event: "repertoire.fetch_url", message: "fetched a web page", meta: { host: url.hostname, status, chars: text.length, cut } });
|
|
197
|
+
return notes.length > 0 ? `${page}\n${notes.join("\n")}` : page;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
exports.webToolDefinitions = [
|
|
201
|
+
{
|
|
202
|
+
tool: {
|
|
203
|
+
type: "function",
|
|
204
|
+
function: {
|
|
205
|
+
name: "fetch_url",
|
|
206
|
+
description: "Read a web page (http or https) as text; HTML is turned into readable text, and local or private addresses are refused. Shows up to 30,000 characters and tells you the offset to read on from; downloads stop at 2 MB and the request times out after 20 seconds.",
|
|
207
|
+
parameters: {
|
|
208
|
+
type: "object",
|
|
209
|
+
properties: {
|
|
210
|
+
url: { type: "string" },
|
|
211
|
+
offset: { type: "number", description: "character offset to start from, as given in a read-on notice" },
|
|
212
|
+
},
|
|
213
|
+
required: ["url"],
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
handler: async (a, ctx) => {
|
|
218
|
+
try {
|
|
219
|
+
return await fetchPage(a.url, a.offset ? Math.max(0, parseInt(a.offset, 10) || 0) : 0, isFamilyHuman(ctx));
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (error instanceof RefusedError)
|
|
223
|
+
return `error: ${error.message}`;
|
|
224
|
+
const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
225
|
+
return timedOut ? `error: timed out after ${exports.webTestHooks.timeoutMs / 1000} seconds fetching ${a.url}` : `error: could not fetch ${a.url}: ${error instanceof Error ? error.message : String(error)}`;
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
summaryKeys: ["url"],
|
|
229
|
+
},
|
|
230
|
+
];
|
package/dist/repertoire/tools.js
CHANGED
|
@@ -85,6 +85,7 @@ exports.SANCTUARY_OWNER_ADDITIONS = new Set([
|
|
|
85
85
|
"shell", "shell_status", "shell_tail", "read_file", "write_file", "edit_file", "glob", "grep",
|
|
86
86
|
"web_search", "search_facts", "consult_diary", "consult_notes", "get_friend_note",
|
|
87
87
|
"session_summary", "query_session", "set_reasoning_effort", "restart_runtime", "revive_sense",
|
|
88
|
+
"ls", "fetch_url", "note", "diary_write",
|
|
88
89
|
]);
|
|
89
90
|
/** The bundle sync tools, granted to the Sanctuary owner alone. */
|
|
90
91
|
const SANCTUARY_OWNER_SYNC_TOOLS = new Set(["bundle_push", "bundle_pull_rebase"]);
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.994",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.994",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|