@theokit/sdk-tools 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bd10da3: SOTA default descriptions for the 9 built-in tools (`read_file`/`write_file`/`edit_file`/`glob_files`/`search_text`/`shell_exec`/`todolist`/`web_fetch`/`web_search`).
8
+
9
+ Each tool's default `description` is upgraded from terse mechanics-only copy to rich, behavior-accurate ACI copy (preconditions, when-to-prefer-which-tool, return shape) — the Agent-Computer Interface the model reads to choose tools, which measurably improves tool-selection. Every claim is verified against the tool's own handler (e.g. `search_text` is described as LITERAL + CASE-SENSITIVE because it matches via `line.includes`; `web_fetch` is described as SSRF-guarded because `screenedFetch` defaults `allowPrivateHosts: false`), so the description lives next to the implementation it describes and cannot drift. Descriptions are generalized (no app-specific cross-tool references). `edit_file` now also ENFORCES the documented `old_string !== new_string` precondition (a no-op edit returns `{ ok: false, error: "no_change" }` instead of a misleading `replacements: 1`), so the description matches behavior. Otherwise no API change: same factory signatures, same return shapes; only the default description string changed. Consumers no longer need to override these descriptions app-side — `withDescription` remains for genuine per-consumer customization. Added `tests/sota-descriptions.test.ts` asserting each description's load-bearing behavioral phrases.
10
+
11
+ ## 0.3.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 986f340: V3-1 — harden `catastrophicShellReason` to theocode's security-reviewed shell-guard. The `shell_exec` guardrail previously missed 18 of 42 catastrophic commands (empirical probe): `git reset --hard`, `git clean -fd`, secret-file exfiltration (`cat .env | curl`, `tar ~/.aws | nc`), command-substitution / eval RCE (`eval "$(curl)"`, `. <(curl)`, `bash -c "$(curl)"`), `find / -delete` / `-exec rm`, `truncate /dev/sda`, and a range of `rm -rf` targets (`~/sub`, `/usr/local`, `../..`, `$HOME/x`, flags after the operand, any absolute non-scratch path). The rules are ported from theocode's hardened guard (proven by a 42-blocked + 24-allowed corpus at 0 misses / 0 false-positives) as a SUPERSET — the SDK's extra screens (recursive `chmod`/`chown` on a root path, extra block-device families, `//` collapse) are kept, and the segment splitter now also covers `&` and newlines. Reason strings widened to describe each category; the public API (`catastrophicShellReason(cmd): string | null`, `CatastrophicCommandError`) is unchanged. No new dependency.
16
+
3
17
  ## 0.2.0
4
18
 
5
19
  ### Minor Changes
package/README.md CHANGED
@@ -4,6 +4,8 @@ Built-in tools for `@theokit/sdk` agents. File system, git, subprocess, search-t
4
4
 
5
5
  Extracted from `@theokit/sdk@1.7.0` as part of the SDK 2.0 package split.
6
6
 
7
+ See the [**Theo Harness Capability Map**](../../docs/harness-capability-map.md) for every tool + guard primitive (`buildRepoMap`, `isBlockedIp`, `screenedFetch`, `catastrophicShellReason`, ...) with import paths and examples.
8
+
7
9
  ## Install
8
10
 
9
11
  ```bash
package/dist/index.cjs CHANGED
@@ -96,8 +96,8 @@ function isForbiddenPath(input) {
96
96
  if (first === ".git") return true;
97
97
  if (first === "node_modules") return true;
98
98
  if (first === ".theo") return true;
99
- const basename2 = segments[segments.length - 1];
100
- if (LOCK_FILES.has(basename2)) return true;
99
+ const basename = segments[segments.length - 1];
100
+ if (LOCK_FILES.has(basename)) return true;
101
101
  return false;
102
102
  }
103
103
 
@@ -268,7 +268,7 @@ function createEditFileTool(opts) {
268
268
  const { projectRoot } = opts;
269
269
  return sdk.defineTool({
270
270
  name: "edit_file",
271
- description: "Replace the first occurrence of old_string with new_string in a project-relative file. Falls back to whitespace-normalized matching when the exact match fails. Creates a .bak backup before editing. Returns { ok, replacements } or { ok: false, error }.",
271
+ description: "Make an exact string replacement in a project-relative file. Replaces the FIRST occurrence of old_string with new_string (a whitespace-normalized fallback is attempted if the exact match fails) and writes a .bak backup first. Read the file first so old_string matches the on-disk text exactly; include enough surrounding context to make it unique \u2014 only the first match is replaced, so a too-short old_string can edit the wrong location. old_string must be non-empty and differ from new_string; to change every occurrence, call edit_file repeatedly. Returns { ok, replacements } or { ok: false, error }.",
272
272
  inputSchema: zod.z.object({
273
273
  path: zod.z.string().min(1).describe("Project-relative file path."),
274
274
  old_string: zod.z.string().min(1).describe("String to find in the file."),
@@ -276,6 +276,9 @@ function createEditFileTool(opts) {
276
276
  }),
277
277
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: unified diff parsing is inherently complex
278
278
  handler: async ({ path, old_string, new_string }) => {
279
+ if (old_string === new_string) {
280
+ return JSON.stringify({ ok: false, error: "no_change", path });
281
+ }
279
282
  if (isForbiddenPath(path)) {
280
283
  return JSON.stringify({ ok: false, error: "forbidden_path", path });
281
284
  }
@@ -524,7 +527,7 @@ function createGlobTool(opts) {
524
527
  const { projectRoot } = opts;
525
528
  return sdk.defineTool({
526
529
  name: "glob_files",
527
- description: "List project files matching a glob-like pattern. Excludes node_modules, .git, dist, .theo by default. Returns relative paths. Pattern supports * and ** wildcards. Returns { ok, files } or { ok: false, error }.",
530
+ description: "Find files by glob pattern across the project \u2014 fast at any repo size. Use glob_files when you know the filename SHAPE; use search_text when you know the file CONTENT; use read_file when you know the exact path. The pattern supports * and ** wildcards (e.g. '**/*.ts', 'src/**/*.json'); node_modules/.git/dist/.theo are excluded and results are relative paths. Returns { ok, files } or { ok: false, error }.",
528
531
  inputSchema: zod.z.object({
529
532
  pattern: zod.z.string().min(1).describe("Glob pattern (e.g. '**/*.ts', 'src/**/*.json')."),
530
533
  cwd: zod.z.string().optional().describe("Project-relative subdirectory to search from.")
@@ -601,128 +604,96 @@ var CatastrophicCommandError = class extends sdk.ConfigurationError {
601
604
  });
602
605
  }
603
606
  };
604
- var SHELL_NAMES = /* @__PURE__ */ new Set(["sh", "bash", "zsh", "dash", "ksh", "ash"]);
605
- var PREFIX_TOKENS = /* @__PURE__ */ new Set([
606
- "sudo",
607
- "doas",
608
- "env",
609
- "command",
610
- "time",
611
- "nice",
612
- "nohup",
613
- "exec",
614
- "builtin"
615
- ]);
616
- var FORK_BOMB = /:\s*\(\s*\)\s*\{[^}]*\|[^}]*&[^}]*\}/;
617
- var DEVICE_REDIRECT = /[>]\s*\/dev\/(?:sd|nvme|hd|vd|mmcblk|disk|loop|dm-)\w*/;
618
- var SYSTEM_DIR = /^\/(?:etc|usr|bin|sbin|lib|lib64|var|boot|home|root|opt|sys|proc|dev)(?:\/\*?)?$/;
619
- function basename(p) {
620
- const i = p.lastIndexOf("/");
621
- return i >= 0 ? p.slice(i + 1) : p;
622
- }
623
- function unquote(t) {
624
- if (t.length >= 2) {
625
- const a = t[0];
626
- const b = t[t.length - 1];
627
- if (a === '"' && b === '"' || a === "'" && b === "'") return t.slice(1, -1);
628
- }
629
- return t;
630
- }
631
- function tokenize(s) {
632
- return s.trim().split(/\s+/).filter(Boolean);
633
- }
634
- function splitSegments(cmd) {
635
- return cmd.split(/&&|\|\||;|\|/);
636
- }
637
- function stripPrefixTokens(tokens) {
638
- let t = tokens;
639
- let head = t[0];
640
- while (head !== void 0 && PREFIX_TOKENS.has(basename(unquote(head)))) {
641
- t = t.slice(1);
642
- head = t[0];
643
- }
644
- return t;
645
- }
646
- function operandsOf(tokens) {
647
- return tokens.slice(1).filter((t) => !t.startsWith("-")).map(unquote);
648
- }
649
- var HOME_VAR = /^\$\{?HOME\}?$/;
650
- function isRootishPath(op) {
651
- if (op === "~" || op === "*" || op === "." || HOME_VAR.test(op)) return true;
652
- let collapsed = op.replace(/\/+/g, "/");
653
- if (collapsed.length > 1 && collapsed.endsWith("/")) collapsed = collapsed.slice(0, -1);
654
- if (collapsed === "/" || collapsed === "/*" || collapsed === "/.") return true;
655
- return SYSTEM_DIR.test(collapsed);
656
- }
657
- function hasRecursiveForce(tokens) {
658
- const flags = tokens.slice(1).filter((t) => t.startsWith("-"));
659
- const recursive = flags.some(
660
- (f) => f === "--recursive" || !f.startsWith("--") && /[rR]/.test(f)
661
- );
662
- const force = flags.some((f) => f === "--force" || !f.startsWith("--") && f.includes("f"));
607
+ function unquote(token) {
608
+ return token.replace(/^(['"])(.*)\1$/, "$2").replace(/^['"]|['"]$/g, "");
609
+ }
610
+ function commandSegments(command) {
611
+ return command.split(/&&|\|\||[;|&\n]/).map((s) => s.trim()).filter((s) => s.length > 0);
612
+ }
613
+ function commandArgs(segment, name) {
614
+ const tokens = segment.split(/\s+/);
615
+ let i = 0;
616
+ if (tokens[i] === "sudo") i += 1;
617
+ if (tokens[i] !== name) return null;
618
+ return tokens.slice(i + 1);
619
+ }
620
+ function commandSegmentsNamed(command, name) {
621
+ return commandSegments(command).map((s) => commandArgs(s, name)).filter((args) => args !== null);
622
+ }
623
+ function isRecursiveForce(args) {
624
+ const flags = args.filter((t) => t.startsWith("-")).join(" ");
625
+ if (flags.length === 0) return false;
626
+ const recursive = /-[a-z]*r/i.test(flags) || /--recursive/.test(flags);
627
+ const force = /-[a-z]*f/i.test(flags) || /--force/.test(flags);
663
628
  return recursive && force;
664
629
  }
665
- function hasRecursiveFlag(tokens) {
666
- return tokens.slice(1).some(
667
- (t) => t === "--recursive" || t.startsWith("-") && !t.startsWith("--") && /[rR]/.test(t)
668
- );
630
+ function isRecursive(args) {
631
+ const flags = args.filter((t) => t.startsWith("-")).join(" ");
632
+ return /-[a-z]*r/i.test(flags) || /--recursive/.test(flags);
633
+ }
634
+ var SAFE_ABSOLUTE_TARGET = /^\/(tmp|var\/tmp)(\/|$)/;
635
+ function targetsDangerousPath(args) {
636
+ const targets = args.filter((token) => token.length > 0 && !token.startsWith("-")).map(unquote);
637
+ return targets.some((raw) => {
638
+ const t = raw.replace(/\/+/g, "/");
639
+ if (t === "/dev/null" || SAFE_ABSOLUTE_TARGET.test(t)) return false;
640
+ return /^\/($|\*)/.test(t) || // "/" or "/*"
641
+ /^\/[^/]/.test(t) || // an absolute path like /etc, /usr/local, /home/user/x
642
+ t === "~" || t.startsWith("~/") || /\$\{?HOME\b\}?/.test(t) || // $HOME or ${HOME}
643
+ t === ".." || t.startsWith("../") || t.includes("/..") || t === "*";
644
+ });
669
645
  }
670
- function isCurlPipedToShell(cmd) {
671
- const segs = cmd.replace(/\|\|/g, ";").split(/[;|]/).map((s) => s.trim()).filter(Boolean);
672
- let fetcher = false;
673
- let shell = false;
674
- for (const s of segs) {
675
- const tk = stripPrefixTokens(tokenize(s));
676
- const head = tk[0];
677
- if (head === void 0) continue;
678
- const c = basename(unquote(head));
679
- if (c === "curl" || c === "wget") fetcher = true;
680
- if (SHELL_NAMES.has(c)) shell = true;
681
- }
682
- return fetcher && shell;
683
- }
684
- var rmCheck = (cmd0, tokens) => {
685
- if (cmd0 !== "rm" || !hasRecursiveForce(tokens)) return null;
686
- const ops = operandsOf(tokens);
687
- return ops.length === 0 || ops.some(isRootishPath) ? "rm -rf of a root/home/glob path" : null;
688
- };
689
- var mkfsCheck = (cmd0) => cmd0.startsWith("mkfs") ? "mkfs on a device" : null;
690
- var ddCheck = (cmd0, tokens) => {
691
- if (cmd0 !== "dd") return null;
692
- return tokens.some((t) => unquote(t).startsWith("of=/dev/")) ? "dd writing to a device" : null;
693
- };
694
- var gitForceCheck = (cmd0, tokens) => {
695
- if (cmd0 !== "git" || !tokens.includes("push") || tokens.includes("--force-with-lease")) {
696
- return null;
646
+ var DEVICE_WIPE = [
647
+ /\bmkfs(\.\w+)?\b/,
648
+ /\bdd\b[^\n]*\bof=\/dev\//,
649
+ /\btruncate\b[^\n]*\s\/dev\//,
650
+ />\s*\/dev\/(sd|nvme|hd|vd|mmcblk|disk|loop|dm-)/
651
+ ];
652
+ var checkRemoteExec = (cmd) => /\b(curl|wget|fetch)\b[^\n]*\|\s*(sudo\s+)?(sh|bash|zsh|dash|python[0-9.]*|node|ruby|perl)\b/i.test(
653
+ cmd
654
+ ) || /(\$\(|<\()\s*(sudo\s+)?(curl|wget|fetch)\b/i.test(cmd) || /\b(eval|source)\b[^\n]*\b(curl|wget|fetch)\b/i.test(cmd) ? "executes a remote download (pipe / command-substitution / eval) \u2014 remote code execution" : null;
655
+ var checkDeviceWipe = (cmd) => DEVICE_WIPE.some((re) => re.test(cmd)) ? "writes to a raw block device / formats a disk" : null;
656
+ var checkForkBomb = (cmd) => /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(cmd) ? "fork bomb" : null;
657
+ var checkDestructiveGit = (cmd) => {
658
+ if (/\bgit\b[^\n]*\bpush\b[^\n]*(--force(?!-with-lease)\b|\s-f\b|\s\+\S)/.test(cmd)) {
659
+ return "git force-push (overwrites remote history)";
660
+ }
661
+ if (/\bgit\b[^\n]*\breset\b[^\n]*--hard\b/.test(cmd)) {
662
+ return "git reset --hard (discards committed and working changes)";
697
663
  }
698
- const force = tokens.includes("--force") || tokens.some((t) => /^-[a-z]*f[a-z]*$/.test(t)) || operandsOf(tokens).some((op) => /^\+[^+]/.test(op));
699
- return force ? "git push --force" : null;
664
+ if (/\bgit\b[^\n]*\bclean\b[^\n]*(-[a-z]*f[a-z]*d|-[a-z]*d[a-z]*f)/.test(cmd)) {
665
+ return "git clean -fd (permanently deletes untracked files)";
666
+ }
667
+ return null;
700
668
  };
701
- var permCheck = (cmd0, tokens) => {
702
- if (cmd0 !== "chmod" && cmd0 !== "chown" || !hasRecursiveFlag(tokens)) return null;
703
- return operandsOf(tokens).some(isRootishPath) ? `${cmd0} -R on a root path` : null;
669
+ var checkRm = (cmd) => commandSegmentsNamed(cmd, "rm").some((a) => isRecursiveForce(a) && targetsDangerousPath(a)) ? "recursive force-delete of an absolute, home, or parent path" : null;
670
+ var checkPerm = (cmd) => ["chmod", "chown"].some(
671
+ (name) => commandSegmentsNamed(cmd, name).some((a) => isRecursive(a) && targetsDangerousPath(a))
672
+ ) ? "recursive permission change on an absolute, home, or parent path" : null;
673
+ var checkFind = (cmd) => /\bfind\s+(\/\S*|~\S*|\$\{?HOME\}?\S*)\s[^\n]*(-delete\b|-exec\s+rm\b)/.test(cmd) ? "find -delete / -exec rm on an absolute or home path" : null;
674
+ var checkExfiltration = (cmd) => {
675
+ const touchesSecret = /(^|[\s/'"])(\.env(\.\w+)?|id_rsa|id_ed25519|\.ssh(\/|\b)|credentials|\.aws(\/|\b)|\.npmrc)\b/.test(
676
+ cmd
677
+ );
678
+ const sendsNetwork = /\b(curl|wget|nc|netcat|scp|ftp|telnet)\b/.test(cmd) || /\bpython[0-9.]*\s+-m\s+http/.test(cmd);
679
+ return touchesSecret && sendsNetwork ? "sends a secret/credential file over the network (exfiltration)" : null;
704
680
  };
705
- var redirectCheck = (_cmd0, _tokens, seg) => DEVICE_REDIRECT.test(seg) ? "redirect to a device" : null;
706
- var SEGMENT_CHECKS = [
707
- rmCheck,
708
- mkfsCheck,
709
- ddCheck,
710
- gitForceCheck,
711
- permCheck,
712
- redirectCheck
681
+ var CATEGORY_CHECKS = [
682
+ checkRemoteExec,
683
+ checkDeviceWipe,
684
+ checkForkBomb,
685
+ checkDestructiveGit,
686
+ checkRm,
687
+ checkPerm,
688
+ checkFind,
689
+ checkExfiltration
713
690
  ];
714
- function catastrophicShellReason(cmd) {
715
- if (FORK_BOMB.test(cmd)) return "fork bomb";
716
- if (isCurlPipedToShell(cmd)) return "curl/wget piped into a shell";
717
- for (const seg of splitSegments(cmd)) {
718
- const tokens = stripPrefixTokens(tokenize(seg));
719
- const head = tokens[0];
720
- if (head === void 0) continue;
721
- const cmd0 = basename(unquote(head));
722
- for (const check of SEGMENT_CHECKS) {
723
- const reason = check(cmd0, tokens, seg);
724
- if (reason) return reason;
725
- }
691
+ function catastrophicShellReason(command) {
692
+ const cmd = command.trim();
693
+ if (cmd.length === 0) return null;
694
+ for (const check of CATEGORY_CHECKS) {
695
+ const reason = check(cmd);
696
+ if (reason) return reason;
726
697
  }
727
698
  return null;
728
699
  }
@@ -1243,7 +1214,7 @@ function createReadFileTool(opts) {
1243
1214
  const { projectRoot } = opts;
1244
1215
  return sdk.defineTool({
1245
1216
  name: "read_file",
1246
- description: "Read a single project-relative text file as UTF-8. Refuses paths that escape the project root, are in the sensitive-file blocklist (.env, .git/, node_modules/, .theo/, lock files), or contain a null byte in the first 8 KB (binary file). Returns { ok, content } or { ok: false, error }.",
1217
+ description: "Read a project-relative text file as UTF-8. ALWAYS read a file before you edit it (edit_file) or overwrite it (write_file), so your old_string / new content matches the real bytes exactly. Returns the WHOLE file (there is no offset or line-range parameter); to locate a symbol inside a large file, use search_text instead of re-reading. Refuses paths that escape the project root, sensitive files (.env, .git/, node_modules/, .theo/, lock files), and binary files (null byte in the first 8 KB); caps at 5 MB. Returns { ok, content, size } or { ok: false, error }.",
1247
1218
  inputSchema: zod.z.object({
1248
1219
  path: zod.z.string().min(1).describe("Project-relative file path.")
1249
1220
  }),
@@ -1433,7 +1404,7 @@ function createSearchTextTool(opts) {
1433
1404
  } = opts;
1434
1405
  return sdk.defineTool({
1435
1406
  name: "search_text",
1436
- description: `Search the project tree for a literal text query. Skips sensitive dirs (.env/.git/node_modules/.theo), binary files, and files over 1 MB. Returns up to ${String(maxMatches)} matches as { file, line, preview }. Use 'path' to scope the search to a subdirectory.`,
1407
+ description: `Search file CONTENTS for a LITERAL, CASE-SENSITIVE query across the project tree (the query is matched as a substring, not a regex). Use search_text when you know the content; use glob_files when you know the filename shape; use read_file when you know the exact path. Skips sensitive dirs (.env/.git/node_modules/.theo), binary files, and files over 1 MB; 'path' scopes the search to a subdirectory. Returns up to ${String(maxMatches)} matches as { file, line, preview } \u2014 cite locations to the user as file:line. Returns { ok, matches } or { ok: false, error }.`,
1437
1408
  inputSchema: zod.z.object({
1438
1409
  query: zod.z.string().min(1).describe("Literal text to search for. Case-sensitive."),
1439
1410
  path: zod.z.string().optional().describe("Optional project-relative directory to scope the search.")
@@ -1544,7 +1515,7 @@ function createShellTool(opts) {
1544
1515
  const { projectRoot, defaultTimeoutMs = DEFAULT_TIMEOUT_MS3, allowCatastrophic = false } = opts;
1545
1516
  return sdk.defineTool({
1546
1517
  name: "shell_exec",
1547
- description: "Execute a shell command in the project directory. Returns stdout, stderr, and exit code. Default timeout 30s, max 5 minutes. Output capped at 5 MB. Returns { ok, stdout, stderr, exit_code } or { ok: false, error }.",
1518
+ description: "Execute a shell command in the project directory. Use this for terminal operations \u2014 running tests, git, package managers, build tools. Do NOT use it for file operations (reading, writing, editing, finding files): prefer the specialized read_file/write_file/edit_file/glob_files/search_text tools, which are path-checked and safer. Only commit, push, or change git state when the user explicitly asks. timeout_ms defaults to 30000 (max 300000); stdout/stderr are capped (~5 MB). Returns { ok, stdout, stderr, exit_code } or { ok: false, error }.",
1548
1519
  inputSchema: zod.z.object({
1549
1520
  command: zod.z.string().min(1).describe("Shell command to execute."),
1550
1521
  timeout_ms: zod.z.number().int().positive().optional().describe("Timeout in milliseconds (default 30000, max 300000).")
@@ -1722,7 +1693,7 @@ ${done}/${items.length} done | ${inProg} in progress | ${pending} pending`);
1722
1693
  };
1723
1694
  return {
1724
1695
  name: "todolist",
1725
- description: "Track multi-step task progress. Actions: 'add' (create task with title), 'complete' (mark done by id), 'in_progress' (mark started by id), 'remove' (delete by id), 'list' (show all), 'clear_completed' (remove done items). Returns { ok, items, items_summary } (items = structured array; items_summary = formatted text).",
1696
+ description: "Create and maintain a structured task list for the current session \u2014 tracks progress and keeps a multi-step plan visible across turns. Use it proactively when the work has 3+ steps or the user gave multiple tasks; skip it for a single trivial step. Keep exactly ONE item 'in_progress' at a time, and mark 'complete' only after the work is actually done. Actions: 'add' (create with title), 'in_progress' (mark started by id), 'complete' (mark done by id), 'remove' (delete by id), 'list' (show all), 'clear_completed' (remove done items). Returns { ok, items, items_summary } (items = structured array; items_summary = formatted text).",
1726
1697
  inputSchema: {
1727
1698
  type: "object",
1728
1699
  properties: {
@@ -1778,7 +1749,7 @@ function createWebFetchTool(opts) {
1778
1749
  const allowPrivateHosts = opts?.allowPrivateHosts ?? false;
1779
1750
  return sdk.defineTool({
1780
1751
  name: "web_fetch",
1781
- description: "Fetch content from a URL via HTTP/HTTPS. Rejects non-http(s) URLs. Response body capped at 1 MB. Returns { ok, content, status_code } or { ok: false, error }.",
1752
+ description: "Fetch the contents of a URL via HTTP/HTTPS. Use only for URLs the user provided or that you are confident help with the task; never invent or guess URLs. Rejects non-http(s) URLs and is SSRF-guarded by default (private/loopback/link-local/cloud-metadata hosts are refused with an ssrf_blocked error). The response body is capped at 1 MB. Returns { ok, content, status_code, content_type } or { ok: false, error }.",
1782
1753
  inputSchema: zod.z.object({
1783
1754
  url: zod.z.string().min(1).describe("URL to fetch (http or https only)."),
1784
1755
  timeout_ms: zod.z.number().int().positive().optional().describe("Timeout in milliseconds (default 30000).")
@@ -1859,7 +1830,7 @@ function createWebSearchTool(opts) {
1859
1830
  const { search, defaultMaxResults = 5 } = opts;
1860
1831
  return sdk.defineTool({
1861
1832
  name: "web_search",
1862
- description: "Search the web for a query. Returns a list of results with title, URL, and snippet. The search provider is injected by the consumer. Returns { ok, results } or { ok: false, error }.",
1833
+ description: "Search the web for a query \u2014 use when you need current information beyond the repo or your training cutoff (library docs, an error message, an API). Returns a list of results with title, URL, and snippet; follow up with web_fetch on a promising result to read it in full. The search provider is injected by the consumer. Returns { ok, results } or { ok: false, error }.",
1863
1834
  inputSchema: zod.z.object({
1864
1835
  query: zod.z.string().min(1).describe("Search query."),
1865
1836
  max_results: zod.z.number().int().positive().max(20).optional().describe("Maximum results to return (default 5, max 20).")
@@ -1917,7 +1888,7 @@ function createWriteFileTool(opts) {
1917
1888
  const { projectRoot } = opts;
1918
1889
  return sdk.defineTool({
1919
1890
  name: "write_file",
1920
- description: "Write UTF-8 content to a project-relative file. Creates parent directories recursively. Refuses paths that escape the project root, sensitive files (.env, .git/, node_modules/, .theo/, lock files), and binary-file overwrites. Returns { ok, path, bytes } or { ok: false, error }.",
1891
+ description: "Write UTF-8 content to a project-relative file, creating parent directories as needed. OVERWRITES any existing file at the path. Prefer editing an existing file with edit_file over rewriting it; use write_file to create a NEW file or fully replace a small one. If the file already exists, read_file it first so you do not discard content you have not seen. Refuses paths that escape the project root, sensitive files (.env, .git/, node_modules/, .theo/, lock files), and binary-file overwrites. Returns { ok, path, bytes } or { ok: false, error }.",
1921
1892
  inputSchema: zod.z.object({
1922
1893
  path: zod.z.string().min(1).describe("Project-relative file path."),
1923
1894
  content: zod.z.string().describe("UTF-8 content to write.")