@ouro.bot/cli 0.1.0-alpha.993 → 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 CHANGED
@@ -1,6 +1,13 @@
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
+ },
4
11
  {
5
12
  "version": "0.1.0-alpha.993",
6
13
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.993",
2
+ "runtimeVersion": "0.1.0-alpha.994",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -2,7 +2,7 @@
2
2
  "version": 2,
3
3
  "profiles": {
4
4
  "sanctuary-owner": {
5
- "version": 18,
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": 8,
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.993</Repository>
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": 18, "sanctuary-household": 5, "sanctuary-event": 4 };
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");
@@ -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 = [process.cwd()];
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
- return word === undefined || unknownShellWord(word) ? null : expandHome(word);
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
- const cwds = [process.cwd()];
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
- /** The directories read_file, glob and grep would read for these arguments, as the tools resolve them. */
1701
- function readToolTargets(toolName, args) {
1702
- const local = (p) => path.isAbsolute(p) ? p : path.resolve((0, identity_1.getRepoRoot)(), p);
1703
- if (toolName === "read_file")
1704
- return [{ target: local(args.path ?? ""), recursive: false }];
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 [{ target: local(args.path ?? ""), recursive: true }];
1707
- const cwd = args.cwd ? local(args.cwd) : process.cwd();
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
- function resolveLocalToolPath(targetPath) {
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.getRepoRoot)(), targetPath);
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 contents. Results include line numbers. Use offset/limit for large files -- don't read the whole thing if you only need a section. Use this tool before editing any file. When reading code, read enough context to understand the surrounding logic, not just the target line.",
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 content = fs.readFileSync(resolvedPath, "utf-8");
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
- const readContent = (offset === undefined && limit === undefined)
105
- ? content
106
- : content.split("\n").slice(offset ? offset - 1 : 0, limit !== undefined ? (offset ? offset - 1 : 0) + limit : undefined).join("\n");
107
- file_state_1.fileStateCache.record(resolvedPath, readContent, mtime, offset, limit);
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 (offset === undefined && limit === undefined)
113
- return content;
114
- const lines = content.split("\n");
115
- const start = offset ? offset - 1 : 0;
116
- const end = limit !== undefined ? start + limit : lines.length;
117
- return lines.slice(start, end).join("\n");
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. Do not create documentation files (*.md, README) by default; only do so when explicitly asked or when documentation is clearly part of the requested change.",
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 alphabetically. Use this instead of shell commands like `find` or `ls`. For broad exploratory searches that would require multiple rounds of globbing and grepping, consider using claude or coding_spawn.",
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. **/*.ts)" },
240
- cwd: { type: "string", description: "directory to search from (defaults to process.cwd())" },
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) : process.cwd();
248
- const matches = fg.globSync(a.pattern, { cwd, dot: true });
249
- return matches.sort().join("\n");
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 for lines matching a regex pattern. Searches recursively in directories. Use this instead of shell commands like `grep` or `rg`. Returns matching lines with file path and line numbers. Use context_lines for surrounding context. Use include to filter file types (e.g., '*.ts').",
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. *.ts)" },
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", "path"],
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 content;
355
+ let buffer;
278
356
  try {
279
- content = fs.readFileSync(filePath, "utf-8");
357
+ buffer = asBuffer(fs.readFileSync(filePath));
280
358
  }
281
359
  catch {
282
360
  return [];
283
361
  }
284
- const lines = content.split("\n");
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
- matchIndices.add(i);
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
- const sortedIndices = [...outputIndices].sort((a, b) => a - b);
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
- walk(fullPath);
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
- if (stat.isFile()) {
348
- return searchFile(targetPath).join("\n");
349
- }
350
- let files = collectFiles(targetPath);
351
- if (includeGlob) {
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 allResults = [];
432
+ const results = [];
355
433
  for (const file of files) {
356
- allResults.push(...searchFile(file));
434
+ results.push(...searchFile(file));
435
+ if (stopped)
436
+ break;
357
437
  }
358
- return allResults.join("\n");
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 and return stdout/stderr. Each call starts in the runtime's working directory; cd does not persist between calls. Use dedicated tools instead of shell when available: read_file instead of cat, edit_file instead of sed, glob instead of find, grep instead of grep/rg. Reserve shell for operations that genuinely need the shell: installing packages, running builds/tests, git operations, process management. Be careful with destructive commands -- consider reversibility before running. If a command fails, read the error output before retrying with a different approach.",
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 output = await new Promise((resolve, reject) => {
88
- const child = (0, child_process_1.exec)(a.command, { encoding: "utf-8", timeout }, (error, stdout, stderr) => {
89
- if (error)
90
- reject(Object.assign(error, { stdout, stderr }));
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
+ ];
@@ -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"]);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.993",
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.993",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.993",
3
+ "version": "0.1.0-alpha.994",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },