teapot-coding-agent 0.23.0 → 0.24.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.
@@ -88,6 +88,28 @@ function bgMapFor(cwd) {
88
88
  }
89
89
  return m;
90
90
  }
91
+ /**
92
+ * Environment for every shell the agent runs.
93
+ *
94
+ * `PWD` is deliberately removed: the server's own PWD would contradict the
95
+ * `cwd` we spawn with, so a command trusting `$PWD` would act on the wrong
96
+ * directory (#34). `bash` re-creates PWD from its actual cwd.
97
+ */
98
+ function shellEnv() {
99
+ const { PWD: _pwd, ...rest } = process.env;
100
+ return { ...rest, TERM: "dumb", GIT_PAGER: "cat", PAGER: "cat" };
101
+ }
102
+ /**
103
+ * Human-readable elapsed-time suffix appended to a shell result so the MODEL
104
+ * sees how long a command actually took (#26). Only the operator saw it — the
105
+ * model happily re-issued commands that ran for tens of minutes.
106
+ *
107
+ * Kept terse and single-line: it rides along with the tool output, and mirrors
108
+ * what bash_output already prints for a still-running job.
109
+ */
110
+ export function elapsed(t0, now = Date.now()) {
111
+ return `\n[took ${((now - t0) / 1000).toFixed(1)}s]`;
112
+ }
91
113
  function startBackgroundShell(cmd, ctx) {
92
114
  const map = bgMapFor(ctx.cwd);
93
115
  // opportunistic cleanup of long-dead jobs so the map can't grow forever
@@ -96,11 +118,13 @@ function startBackgroundShell(cmd, ctx) {
96
118
  void id;
97
119
  }
98
120
  const id = `bg${++bgSeq}`;
99
- const child = spawn("/bin/bash", ["-lc", cmd], {
121
+ // plain `-c`, not `-lc`: a login shell sources ~/.bash_profile, whose `cd`
122
+ // would move background jobs out of the workspace (#34)
123
+ const child = spawn("/bin/bash", ["-c", cmd], {
100
124
  cwd: ctx.cwd,
101
125
  detached: true,
102
126
  stdio: ["ignore", "pipe", "pipe"],
103
- env: { ...process.env, TERM: "dumb", GIT_PAGER: "cat", PAGER: "cat" },
127
+ env: shellEnv(),
104
128
  });
105
129
  const sh = {
106
130
  id, cmd, child,
@@ -183,11 +207,15 @@ export function hasRunningBgShells(cwd) {
183
207
  /** Run a command in its own process group; kill the whole group on timeout. */
184
208
  function runShell(cmd, ctx, timeoutMs) {
185
209
  return new Promise((resolve) => {
186
- const child = spawn("/bin/bash", ["-lc", cmd], {
210
+ const t0 = Date.now();
211
+ // `bash -l` sources /etc/profile + ~/.bash_profile, either of which may
212
+ // `cd` somewhere — which silently moved every later relative path out of
213
+ // the workspace (#34). Plain `-c` starts in ctx.cwd and stays there.
214
+ const child = spawn("/bin/bash", ["-c", cmd], {
187
215
  cwd: ctx.cwd,
188
216
  detached: true, // own process group
189
217
  stdio: ["ignore", "pipe", "pipe"],
190
- env: { ...process.env, TERM: "dumb", GIT_PAGER: "cat", PAGER: "cat" },
218
+ env: shellEnv(),
191
219
  });
192
220
  let out = "";
193
221
  let done = false;
@@ -227,7 +255,7 @@ function runShell(cmd, ctx, timeoutMs) {
227
255
  ctx.signal?.removeEventListener("abort", onAbort);
228
256
  if (!done) {
229
257
  done = true;
230
- resolve({ ok: false, result: `spawn error: ${err.message}` });
258
+ resolve({ ok: false, result: `spawn error: ${err.message}${elapsed(t0)}` });
231
259
  }
232
260
  });
233
261
  child.on("close", (code, signal) => {
@@ -239,14 +267,17 @@ function runShell(cmd, ctx, timeoutMs) {
239
267
  if (killReason) {
240
268
  resolve({
241
269
  ok: false,
242
- result: `${killReason}. Partial output:\n${clip(out.trim() || "(no output)", ctx.maxOutputBytes)}`,
270
+ result: `${killReason}. Partial output:\n${clip(out.trim() || "(no output)", ctx.maxOutputBytes)}${elapsed(t0)}`,
243
271
  });
244
272
  return;
245
273
  }
246
274
  resolve({
247
275
  ok: code === 0,
248
276
  result: (code === 0 ? "" : `exit=${code}${signal ? ` signal=${signal}` : ""}\n`) +
249
- clip(out.trim() || "(no output)", ctx.maxOutputBytes),
277
+ clip(out.trim() || "(no output)", ctx.maxOutputBytes) +
278
+ // the model, not just the operator, needs to know a command was slow —
279
+ // otherwise it happily re-issues something that takes 40 minutes (#26)
280
+ elapsed(t0),
250
281
  });
251
282
  });
252
283
  });
@@ -636,10 +667,140 @@ async function rollbackWrites(backups, temps) {
636
667
  await fs.writeFile(abs, orig, "utf8").catch(() => { });
637
668
  }
638
669
  }
670
+ /* ---------- hashline: stable, verifiable line anchors (#7 / #4) ----------
671
+ *
672
+ * The default `N| ` gutter is a trap: read_file prints it, but edit_file's
673
+ * old_text must NOT contain it — so the model strips the gutter by hand on
674
+ * every read, and the only mitigation is a sentence in the tool description.
675
+ *
676
+ * Format follows the established "hashline" convention used across AI coding
677
+ * harnesses (quangdang46/hashline, kebbbnnn/hashline, opencode-hashline,
678
+ * pi-hashline-edit-pro, …) so anchors read the same everywhere:
679
+ *
680
+ * 12:a3f1| function calculateTotal(items) {
681
+ *
682
+ * i.e. `LINE:HASH| content` — the line number is KEPT (humans and models both
683
+ * navigate by line) and the content hash is ADDED as an integrity anchor.
684
+ *
685
+ * Design points taken from those implementations rather than invented:
686
+ * - the hash covers the STRIPPED content, so re-indenting a line does not
687
+ * invalidate its anchor;
688
+ * - blank/whitespace-only lines get a fixed reserved hash;
689
+ * - when a hash occurs more than once, the anchor's line number disambiguates
690
+ * via PROXIMITY search, instead of silently taking the first match.
691
+ */
692
+ const HASH_BLANK = " "; // 4 spaces: reserved, cannot collide with a hex hash
693
+ // A real hash is 4 HEX chars, but the shape also accepts other 4-8 char
694
+ // alphanumerics: a model that mistypes or invents an anchor is common enough
695
+ // that we should still recognise the SHAPE and say "that anchor is malformed"
696
+ // rather than falling through to "no similar text found" (#7).
697
+ const ANCHOR_RE = /^\s*(\d+):([0-9a-z]{4,8}| {4})\|/;
698
+ /** 4-char content hash (FNV-1a 32-bit → hex), of the STRIPPED line */
699
+ export function hashLine(content) {
700
+ const stripped = content.trim();
701
+ if (!stripped)
702
+ return HASH_BLANK;
703
+ let h = 0x811c9dc5;
704
+ for (let i = 0; i < stripped.length; i++) {
705
+ h ^= stripped.charCodeAt(i);
706
+ h = Math.imul(h, 0x01000193) >>> 0;
707
+ }
708
+ return (h >>> 0).toString(16).padStart(8, "0").slice(-4);
709
+ }
710
+ /** `LINE:HASH| content` for each line, 1-indexed */
711
+ export function renderHashlines(lines, startLine = 1) {
712
+ return lines
713
+ .map((l, i) => `${startLine + i}:${hashLine(l)}| ${l}`)
714
+ .join("\n");
715
+ }
716
+ /** parse a `LINE:HASH` anchor; returns null when the text is not an anchor */
717
+ export function parseAnchor(anchor) {
718
+ const m = /^\s*(\d+):([0-9a-z]{4,8}| {4})\s*$/.exec(anchor);
719
+ if (!m)
720
+ return null;
721
+ return { line: Number(m[1]), hash: m[2] };
722
+ }
723
+ /**
724
+ * Locate the line an anchor refers to.
725
+ *
726
+ * The hash is authoritative; the line number only disambiguates when the same
727
+ * content appears twice (proximity search). Falling back to "nearest line" for
728
+ * an unknown hash is deliberately NOT done — that would silently edit the wrong
729
+ * line, which is the entire failure mode hashline exists to prevent.
730
+ */
731
+ export function resolveAnchor(lines, anchor) {
732
+ const hits = [];
733
+ for (let i = 0; i < lines.length; i++)
734
+ if (hashLine(lines[i]) === anchor.hash)
735
+ hits.push(i);
736
+ if (!hits.length)
737
+ return {
738
+ error: /^[0-9a-f]{4}$/.test(anchor.hash) || anchor.hash === HASH_BLANK
739
+ ? `no line matches hash ${anchor.hash} — the file changed since you read it (re-read it)`
740
+ : // distinguish "you invented/mistyped this anchor" from "the file moved",
741
+ // which sends the agent to fix the right thing (#7)
742
+ `hash ${JSON.stringify(anchor.hash)} is not a valid anchor (expected 4 hex chars) — re-read the file and copy the anchor verbatim`,
743
+ };
744
+ if (hits.length === 1)
745
+ return { index: hits[0], how: "unique" };
746
+ const target = anchor.line - 1;
747
+ const best = hits.reduce((a, b) => (Math.abs(b - target) < Math.abs(a - target) ? b : a));
748
+ return { index: best, how: `ambiguous, resolved by proximity to line ${anchor.line}` };
749
+ }
750
+ /** Is this whole block a hashline block (every line carries an anchor)? */
751
+ export function isHashlineBlock(block) {
752
+ const rows = block.split("\n").filter((l) => l !== "");
753
+ if (!rows.length)
754
+ return false;
755
+ return rows.every((l) => ANCHOR_RE.test(l));
756
+ }
757
+ /**
758
+ * Turn a copied hashline block back into file content.
759
+ *
760
+ * The anchors are verified against the CURRENT file, so a stale read is
761
+ * refused instead of editing the wrong bytes — the whole point of the format.
762
+ */
763
+ export function resolveHashlineBlock(block, lines) {
764
+ const rows = block.split("\n").filter((l) => l !== "");
765
+ const picked = [];
766
+ const content = [];
767
+ const notes = [];
768
+ const used = new Set();
769
+ for (const row of rows) {
770
+ const a = parseAnchor(row.slice(0, row.indexOf("|")));
771
+ if (!a)
772
+ return { error: `not a valid hashline anchor: ${row.slice(0, 12)}…` };
773
+ const r = resolveAnchor(lines, a);
774
+ if ("error" in r)
775
+ return { error: r.error };
776
+ if (used.has(r.index))
777
+ return { error: `anchor ${a.line}:${a.hash} resolves to a line already used in this block` };
778
+ used.add(r.index);
779
+ picked.push(r.index);
780
+ content.push(lines[r.index]);
781
+ if (r.how !== "unique")
782
+ notes.push(r.how);
783
+ }
784
+ if (!picked.length)
785
+ return { error: "empty hashline block" };
786
+ return { text: content.join("\n"), indices: picked, notes };
787
+ }
788
+ /** normalize the read_file line-reference argument */
789
+ export function lineIdMode(args) {
790
+ const v = args.line_ids ?? args.hashline;
791
+ if (v === true)
792
+ return "hash";
793
+ const s = String(v ?? "").trim().toLowerCase();
794
+ return s === "hash" || s === "hashline" || s === "true" ? "hash" : "none";
795
+ }
639
796
  export const TOOLS = [
640
797
  {
641
798
  name: "read_file",
642
799
  description: "Read a text file from the workspace. Returns numbered lines (`N| ` prefixes are display-only — never copy them into edit_file). " +
800
+ "With `line_ids:\"hash\"` it instead returns `LINE:HASH| content` anchors (the hashline format used by other coding harnesses), " +
801
+ "which edit_file accepts VERBATIM and re-verifies against the file — so prefer it when you intend to edit what you just read. " +
802
+ "Pass `paths` (max 10) to read SEVERAL files in one call instead of one call per file; " +
803
+ "pass `fuzzy` when you do not know the exact path. " +
643
804
  "With `pattern`, acts like grep: only matching lines (JS regex, optional `ignore_case`) plus `context` surrounding lines are returned. " +
644
805
  "A negative `offset` counts from the end (-30 → last 30 lines, or last 30 matches in pattern mode).",
645
806
  parameters: {
@@ -651,14 +812,79 @@ export const TOOLS = [
651
812
  pattern: { type: "string", description: "JS regex — return only matching lines (+context) instead of the whole file" },
652
813
  context: { type: "number", description: "context lines around each pattern match (max 5)" },
653
814
  ignore_case: { type: "boolean", description: "case-insensitive pattern matching" },
815
+ paths: {
816
+ type: "array",
817
+ items: { type: "string" },
818
+ description: "Read SEVERAL files in one call (max 10) instead of one call per file. " +
819
+ "Each comes back under its own `--- path ---` header, and a file that " +
820
+ "cannot be read is reported inline without failing the others.",
821
+ },
822
+ fuzzy: {
823
+ type: "string",
824
+ description: "Read the file when you do not know its exact path. Matches loosely " +
825
+ "(characters in order, ignoring separators and case); if several files " +
826
+ "match it lists them so you can pick the right one with `path`.",
827
+ },
828
+ line_ids: {
829
+ type: "string",
830
+ enum: ["none", "hash"],
831
+ description: "'hash' → `LINE:HASH| content` anchors that you can hand to edit_file VERBATIM; " +
832
+ "the hash is verified against the file, so a stale read is refused instead of editing the wrong lines. " +
833
+ "'none' (default) → `N| ` gutters, which are display-only and must be stripped by hand.",
834
+ },
654
835
  },
655
836
  required: ["path"],
656
837
  },
657
838
  async run(args, ctx) {
839
+ // #16: MULTI-FILE and FUZZY reads.
840
+ //
841
+ // Both are opt-in and handled HERE rather than by restructuring this
842
+ // tool: `paths` re-enters executeTool once per file, and `fuzzy` locates
843
+ // a file then re-enters it. Nothing about the single-file path below
844
+ // changes, so its output stays byte-identical — which #7's hashline
845
+ // anchors and the existing tests both depend on.
846
+ if (Array.isArray(args.paths) && args.paths.length) {
847
+ const wanted = args.paths
848
+ .map((v) => str(v).trim())
849
+ .filter(Boolean);
850
+ if (!wanted.length)
851
+ return { ok: false, result: "paths must contain at least one path" };
852
+ if (wanted.length > MAX_MULTI_READ) {
853
+ return {
854
+ ok: false,
855
+ result: `too many paths (${wanted.length}); the maximum is ${MAX_MULTI_READ} — read fewer files per call`,
856
+ };
857
+ }
858
+ const chunks = [];
859
+ for (const rel of wanted) {
860
+ const r = await executeTool("read_file", JSON.stringify({ ...args, path: rel, paths: undefined }), ctx);
861
+ // one bad file must not sink the others: report it inline
862
+ chunks.push(`--- ${rel} ---\n${r.ok ? r.result : `ERROR: ${r.result}`}`);
863
+ }
864
+ return { ok: true, result: chunks.join("\n\n") };
865
+ }
866
+ if (args.fuzzy)
867
+ return fuzzyRead(str(args.fuzzy), ctx, args);
658
868
  const p = safeJoin(ctx.cwd, str(args.path));
659
- const text = await readText(p);
869
+ let text;
870
+ try {
871
+ text = await readText(p);
872
+ }
873
+ catch (err) {
874
+ // name the file, not just the errno: a multi-file read reports each bad
875
+ // path inline, and "ENOENT … /tmp/xyz/nope.ts" leaves the model to work
876
+ // out which of the paths it asked for (#16)
877
+ const code = err.code;
878
+ return {
879
+ ok: false,
880
+ result: code === "ENOENT" ? `cannot read ${str(args.path)}: no such file` : `cannot read ${str(args.path)}: ${err.message}`,
881
+ };
882
+ }
660
883
  ctx.onFileRead?.(str(args.path));
661
884
  const lines = text.split("\n");
885
+ // #7/#4: opt-in hashline output. Default stays the historical `N| ` so
886
+ // nothing that depends on it changes until the mode is chosen.
887
+ const idMode = lineIdMode(args);
662
888
  // grep mode
663
889
  if (typeof args.pattern === "string" && args.pattern !== "") {
664
890
  const re = compileRegex(args.pattern, args.ignore_case === true);
@@ -685,7 +911,9 @@ export const TOOLS = [
685
911
  else
686
912
  regions.push([s, e]);
687
913
  }
688
- const parts = regions.map(([s, e]) => lines.slice(s, e + 1).map((l, k) => `${s + k + 1}| ${l}`).join("\n"));
914
+ const parts = regions.map(([s, e]) => idMode === "hash"
915
+ ? renderHashlines(lines.slice(s, e + 1), s + 1)
916
+ : lines.slice(s, e + 1).map((l, k) => `${s + k + 1}| ${l}`).join("\n"));
689
917
  let result = parts.join("\n--\n");
690
918
  if (idxs.length > page.length || off > 0)
691
919
  result += `\n(${off + 1}–${off + page.length} of ${idxs.length} matches)`;
@@ -695,9 +923,10 @@ export const TOOLS = [
695
923
  let off = num(args.offset, 1);
696
924
  off = off < 0 ? Math.max(0, lines.length + off) : Math.max(0, off - 1);
697
925
  const lim = num(args.limit, 2000);
698
- const slice = lines.slice(off, off + lim).map((l, i) => `${off + i + 1}| ${l}`);
926
+ const pageLines = lines.slice(off, off + lim);
927
+ const slice = idMode === "hash" ? renderHashlines(pageLines, off + 1) : pageLines.map((l, i) => `${off + i + 1}| ${l}`).join("\n");
699
928
  const more = off + lim < lines.length ? `\n... (${lines.length - off - lim} more lines)` : "";
700
- return { ok: true, result: slice.join("\n") + more };
929
+ return { ok: true, result: slice + more };
701
930
  },
702
931
  },
703
932
  {
@@ -744,6 +973,8 @@ export const TOOLS = [
744
973
  description: "Make exactly ONE small, unique replacement in one existing file — the cheapest tool for a single spot change. " +
745
974
  "Copy old_text from the file contents (NOT from read_file's `N| ` prefixed display); it must appear exactly once — " +
746
975
  "if it matches several places, add surrounding lines or pass replace_all=true. " +
976
+ "Or read with read_file(line_ids:\"hash\") and pass that block VERBATIM — `LINE:HASH| ` anchors are verified against the " +
977
+ "current file, and a block whose anchors no longer match is refused as a stale read instead of editing the wrong lines. " +
747
978
  "Two or more changes (or a rename/delete) → use apply_patch instead. " +
748
979
  "Pass base_content (the bytes you read) whenever the file may have changed under you — especially with replace_all, which otherwise rewrites occurrences that appeared after you read.",
749
980
  parameters: {
@@ -766,14 +997,37 @@ export const TOOLS = [
766
997
  const stale = staleGuard(str(args.path), args.base_content, text);
767
998
  if (stale)
768
999
  return stale;
769
- const oldText = str(args.old_text);
1000
+ let oldText = str(args.old_text);
770
1001
  const newText = str(args.new_text);
771
1002
  if (!oldText)
772
1003
  return { ok: false, result: "old_text is required" };
773
1004
  const replaceAll = args.replace_all === true;
774
- let count = text.split(oldText).length - 1;
1005
+ // #7/#4: accept a block copied straight out of read_file's hashline
1006
+ // output. When the anchors do not verify we FAIL with the specific reason
1007
+ // rather than silently falling back to literal matching — a literal match
1008
+ // that happened to succeed would edit the wrong bytes, which is the exact
1009
+ // failure mode hashline exists to prevent.
1010
+ //
1011
+ // On success we edit by RESOLVED LINE INDEX rather than by searching for
1012
+ // the text: the anchors already pinpointed the lines, so a block whose
1013
+ // content legitimately appears twice (the case proximity search exists
1014
+ // for) must not then fail the uniqueness check on the resolved text.
1015
+ let hashNote = "";
1016
+ let hashNotes = [];
1017
+ let hashSpan = null;
1018
+ if (isHashlineBlock(oldText)) {
1019
+ const srcLines = text.replace(/\r\n/g, "\n").split("\n");
1020
+ const r = resolveHashlineBlock(oldText, srcLines);
1021
+ if ("error" in r)
1022
+ hashNote = r.error;
1023
+ else {
1024
+ oldText = r.text;
1025
+ hashNotes = r.notes;
1026
+ hashSpan = { start: r.indices[0], end: r.indices[r.indices.length - 1] + 1 };
1027
+ }
1028
+ }
1029
+ let count = hashNote ? 0 : text.split(oldText).length - 1;
775
1030
  let normalized = false;
776
- // tolerate LF patterns against CRLF files (convert once, on success)
777
1031
  if (count === 0 && oldText.includes("\n") && text.includes("\r\n")) {
778
1032
  const lf = text.replace(/\r\n/g, "\n");
779
1033
  const lfCount = lf.split(oldText).length - 1;
@@ -817,21 +1071,45 @@ export const TOOLS = [
817
1071
  : `No similar text found — re-read ${str(args.path)} around the target area and copy old_text exactly.`;
818
1072
  return {
819
1073
  ok: false,
820
- result: `old_text not found in file. ${hint} Watch indentation/trailing spaces and drop the \`N| \` line-number prefixes.`,
1074
+ // a hashline block that did not resolve gets ITS reason — the generic
1075
+ // "not found" would send the model hunting for a whitespace problem
1076
+ // when the real issue is a stale read (#7)
1077
+ result: hashNote
1078
+ ? `old_text is a hashline block but did not resolve: ${hashNote}.`
1079
+ : `old_text not found in file. ${hint} Watch indentation/trailing spaces and drop the \`N| \` line-number prefixes.`,
821
1080
  };
822
1081
  }
823
- if (count > 1 && !replaceAll) {
1082
+ if (count > 1 && !replaceAll && !hashSpan) {
824
1083
  const at = matchLines(text, oldText);
825
1084
  return {
826
1085
  ok: false,
827
1086
  result: `old_text matched ${count} times (lines ${at.slice(0, 5).join(", ")}) and must be unique — add surrounding lines to old_text, or pass replace_all=true`,
828
1087
  };
829
1088
  }
830
- await fs.writeFile(p, replaceAll ? text.split(oldText).join(newText) : text.replace(oldText, newText), "utf8");
831
- const where = count > 1 ? ` (${count} occurrences)` : "";
1089
+ // A verified hashline block is addressed BY LINE INDEX: the anchors
1090
+ // already proved which lines it means, so a block whose content occurs
1091
+ // twice must still edit the anchored occurrence rather than fail the
1092
+ // uniqueness check (#4).
1093
+ const out = hashSpan
1094
+ ? text
1095
+ .split("\n")
1096
+ .slice(0, hashSpan.start)
1097
+ .concat(newText.split("\n"), text.split("\n").slice(hashSpan.end))
1098
+ .join("\n")
1099
+ : replaceAll
1100
+ ? text.split(oldText).join(newText)
1101
+ : text.replace(oldText, newText);
1102
+ await fs.writeFile(p, out, "utf8");
1103
+ const where = hashSpan
1104
+ ? ` (lines ${hashSpan.start + 1}–${hashSpan.end}, verified by hash anchor)`
1105
+ : count > 1
1106
+ ? ` (${count} occurrences)`
1107
+ : "";
832
1108
  return {
833
1109
  ok: true,
834
- result: `${replaceAll ? "replaced all" : "edited"}${where}${normalized ? " (file converted CRLF→LF)" : ""}`,
1110
+ result: `${replaceAll && !hashSpan ? "replaced all" : "edited"}${where}` +
1111
+ `${hashNotes.length ? ` (${hashNotes[0]})` : ""}` +
1112
+ `${normalized ? " (file converted CRLF→LF)" : ""}`,
835
1113
  };
836
1114
  },
837
1115
  },
@@ -1173,11 +1451,20 @@ export const TOOLS = [
1173
1451
  {
1174
1452
  name: "load_skill",
1175
1453
  description: "Load a skill's full instructions by name. Use when the system prompt's skill list " +
1176
- "matches your current task; follow the loaded playbook.",
1454
+ "matches your current task; follow the loaded playbook. " +
1455
+ // #25: the instruction used to need a SECOND, separately-queued message that
1456
+ // only arrived at the next turn boundary. Passing `instructions` delivers it
1457
+ // in the SAME turn as the playbook, so "load X and do Y" costs one round-trip.
1458
+ "Pass `instructions` with what you want done with it to receive both at once.",
1177
1459
  parameters: {
1178
1460
  type: "object",
1179
1461
  properties: {
1180
1462
  name: { type: "string", description: "skill name from the list in your system prompt" },
1463
+ instructions: {
1464
+ type: "string",
1465
+ description: "Optional: what you want done with this skill (the task, constraints, scope). " +
1466
+ "Delivered in this same turn — no need to queue a follow-up message.",
1467
+ },
1181
1468
  },
1182
1469
  required: ["name"],
1183
1470
  },
@@ -1208,6 +1495,13 @@ export const TOOLS = [
1208
1495
  listed.map((f) => `- ${f}`).join("\n") +
1209
1496
  `\nRun scripts with bash (chmod +x first if needed).`;
1210
1497
  }
1498
+ // #25: deliver the caller's instructions in THIS result, so the model does
1499
+ // not have to queue a second message that only lands next turn.
1500
+ const instructions = str(args.instructions).trim();
1501
+ if (instructions)
1502
+ result +=
1503
+ `\n\n--- Instructions for this task (from the same call):\n${instructions}\n` +
1504
+ "Apply the playbook above to these instructions now, in this turn.";
1211
1505
  return { ok: true, result };
1212
1506
  },
1213
1507
  },
@@ -1295,6 +1589,103 @@ export const TOOLS = [
1295
1589
  },
1296
1590
  },
1297
1591
  ];
1592
+ /** #16: how many files one read_file call may cover. Each costs context, so an
1593
+ * unbounded array would just be a way to overflow the window in one shot. */
1594
+ const MAX_MULTI_READ = 10;
1595
+ /**
1596
+ * Subsequence match: every character of `needle` appears in `hay`, in order,
1597
+ * gaps allowed (#16 "fuzzy").
1598
+ *
1599
+ * Deliberately not a ranked edit-distance search. This answers "is this the
1600
+ * file I meant", and it can only say yes or no — it cannot rank a plausible
1601
+ * WRONG file above the right one, which is the failure mode that actually costs
1602
+ * an agent time. Searching file CONTENT is what bash/grep are for.
1603
+ */
1604
+ export function fuzzyPathMatch(needle, hay) {
1605
+ const n = needle.toLowerCase().replace(/[\s._/-]+/g, "");
1606
+ if (!n)
1607
+ return false;
1608
+ const h = hay.toLowerCase();
1609
+ let i = 0;
1610
+ for (const ch of h) {
1611
+ if (ch === n[i])
1612
+ i++;
1613
+ if (i === n.length)
1614
+ return true;
1615
+ }
1616
+ return false;
1617
+ }
1618
+ /**
1619
+ * Locate a file by a loose name and read it (#16).
1620
+ *
1621
+ * Walks the workspace (skipping .git, node_modules and build noise) and ranks
1622
+ * matches: exact path, exact basename, ends-with, then subsequence. When
1623
+ * several match it LISTS them rather than picking one — guessing here would have
1624
+ * the agent edit the wrong file, which is the expensive mistake.
1625
+ */
1626
+ async function fuzzyRead(needle, ctx, args) {
1627
+ const q = needle.trim();
1628
+ if (!q)
1629
+ return { ok: false, result: "fuzzy needs a non-empty search string" };
1630
+ const SKIP = new Set([".git", "node_modules", "dist", "build", ".cache", "coverage", "__pycache__"]);
1631
+ const found = [];
1632
+ const walk = async (dir, rel, depth) => {
1633
+ if (depth > 12)
1634
+ return;
1635
+ let entries;
1636
+ try {
1637
+ entries = await fs.readdir(dir, { withFileTypes: true });
1638
+ }
1639
+ catch {
1640
+ return;
1641
+ }
1642
+ for (const e of entries) {
1643
+ if (e.name.startsWith("."))
1644
+ continue;
1645
+ const r = rel ? `${rel}/${e.name}` : e.name;
1646
+ if (e.isDirectory()) {
1647
+ if (SKIP.has(e.name))
1648
+ continue;
1649
+ await walk(path.join(dir, e.name), r, depth + 1);
1650
+ continue;
1651
+ }
1652
+ let rank = -1;
1653
+ if (r === q)
1654
+ rank = 0;
1655
+ else if (e.name === q)
1656
+ rank = 1;
1657
+ else if (r.endsWith("/" + q) || r.endsWith(q))
1658
+ rank = 2;
1659
+ else if (fuzzyPathMatch(q, r))
1660
+ rank = 3;
1661
+ if (rank >= 0)
1662
+ found.push({ rel: r, rank });
1663
+ }
1664
+ };
1665
+ await walk(ctx.cwd, "", 0);
1666
+ if (!found.length) {
1667
+ return {
1668
+ ok: false,
1669
+ result: `no file matches "${q}" under the workspace. ` +
1670
+ "Use list_dir to see what exists, or pass the exact path instead of fuzzy.",
1671
+ };
1672
+ }
1673
+ found.sort((a, b) => a.rank - b.rank || a.rel.length - b.rel.length || a.rel.localeCompare(b.rel));
1674
+ const best = found[0];
1675
+ // Only a confident match is read directly. If the query is vague enough that
1676
+ // several files match, LIST them: guessing would hand the agent the wrong
1677
+ // file, and the cost of that mistake is far higher than one extra call.
1678
+ // An exact path or basename is unambiguous, so those still read straight away.
1679
+ if (best.rank <= 1 || found.length === 1) {
1680
+ // re-enter the tool so a unique match behaves exactly like a normal read
1681
+ return executeTool("read_file", JSON.stringify({ ...args, path: best.rel, fuzzy: undefined }), ctx);
1682
+ }
1683
+ return {
1684
+ ok: true,
1685
+ result: `${found.length} files match "${q}" — pass the exact path (or a longer query) to read one:\n` +
1686
+ found.slice(0, 8).map((f) => ` ${f.rel}`).join("\n"),
1687
+ };
1688
+ }
1298
1689
  /** Current skills across the configured roots (for the system prompt listing). */
1299
1690
  export async function currentSkills(ctx) {
1300
1691
  return discoverSkills(ctx.skillRoots ?? []);
@@ -1307,6 +1698,218 @@ export function toolSpecs() {
1307
1698
  }
1308
1699
  /** tools that mutate the workspace — blocked for read-only personas */
1309
1700
  const MUTATING_TOOLS = new Set(["write_file", "edit_file", "apply_patch", "bash"]);
1701
+ /** per-tool aliases: the common wrong key -> the key the schema declares. */
1702
+ const ARG_ALIASES = {
1703
+ bash: { cmd: "command", script: "command", shell: "command", input: "command" },
1704
+ read_file: { file_path: "path", filepath: "path", file: "path", name: "path" },
1705
+ write_file: { file_path: "path", filepath: "path", file: "path" },
1706
+ edit_file: { file_path: "path", filepath: "path", oldValue: "old_text", newValue: "new_text" },
1707
+ apply_patch: { patch_text: "patch", diff: "patch" },
1708
+ list_dir: { file_path: "path", dir: "path", directory: "path" },
1709
+ list_children: { file_path: "path", dir: "path" },
1710
+ read_url: { uri: "url", link: "url" },
1711
+ };
1712
+ /**
1713
+ * The one argument each tool's whole call usually boils down to. Used to
1714
+ * salvage a bare word ("ls") that was never wrapped in JSON at all — but ONLY
1715
+ * for tools where that is unambiguous, so "12" is never mistaken for a path.
1716
+ */
1717
+ const PRIMARY_STRING_ARG = {
1718
+ bash: "command",
1719
+ read_file: "path",
1720
+ write_file: "content",
1721
+ list_dir: "path",
1722
+ list_children: "path",
1723
+ read_url: "url",
1724
+ load_skill: "name",
1725
+ save_skill: "content",
1726
+ };
1727
+ /** keys whose schema type is a number / boolean, per tool. */
1728
+ const NUMERIC_ARGS = {
1729
+ read_file: ["offset", "limit", "context"],
1730
+ bash: ["timeout_ms"],
1731
+ list_dir: [],
1732
+ write_file: [],
1733
+ };
1734
+ const BOOL_ARGS = {
1735
+ read_file: ["ignore_case"],
1736
+ bash: ["background"],
1737
+ edit_file: ["replace_all"],
1738
+ write_file: [],
1739
+ list_dir: [],
1740
+ };
1741
+ /** markdown/backtick framing models like to wrap a path in */
1742
+ function unframe(v) {
1743
+ const m = /^[`*_\s]+(.+?)[`*_\s]*$/.exec(v);
1744
+ return m ? m[1] : v;
1745
+ }
1746
+ /** coerce "100" -> 100, "true" -> true; returns undefined when not coercible */
1747
+ function coerceScalar(v) {
1748
+ if (typeof v !== "string")
1749
+ return v;
1750
+ const t = v.trim();
1751
+ if (t === "true")
1752
+ return true;
1753
+ if (t === "false")
1754
+ return false;
1755
+ if (t !== "" && !Number.isNaN(Number(t)) && /^[-+]?\d+(\.\d+)?$/.test(t))
1756
+ return Number(t);
1757
+ return v;
1758
+ }
1759
+ /** Turn a value into an array, tolerating a bare scalar or a JSON array. */
1760
+ function toArray(v) {
1761
+ if (Array.isArray(v))
1762
+ return v;
1763
+ if (typeof v === "string") {
1764
+ const t = v.trim();
1765
+ if (t.startsWith("[")) {
1766
+ try {
1767
+ const p = JSON.parse(t);
1768
+ if (Array.isArray(p))
1769
+ return p;
1770
+ }
1771
+ catch {
1772
+ /* fall through — treat as a single element */
1773
+ }
1774
+ }
1775
+ return [v];
1776
+ }
1777
+ if (v === undefined || v === null)
1778
+ return [];
1779
+ return [v];
1780
+ }
1781
+ export function repairToolInput(name, rawArgs) {
1782
+ const notes = [];
1783
+ let parsed;
1784
+ try {
1785
+ parsed = rawArgs?.trim() ? JSON.parse(rawArgs) : {};
1786
+ }
1787
+ catch {
1788
+ // not JSON at all: a whole call was stringified, or the model emitted a
1789
+ // bare word. Only salvageable when the tool takes a single obvious string.
1790
+ const bare = rawArgs?.trim().replace(/^[`*\s]+|[`*\s]+$/g, "");
1791
+ const primary = PRIMARY_STRING_ARG[name];
1792
+ // a bare number/boolean is never a path or a command — refusing is far
1793
+ // better than inventing one (#17)
1794
+ const isScalarish = bare !== "" && /^(true|false|null|-?\d+(\.\d+)?)$/i.test(bare);
1795
+ if (bare && primary && !isScalarish && !bare.startsWith("{") && !bare.startsWith("[")) {
1796
+ notes.push(`wrapped a bare string as {"${primary}": …}`);
1797
+ return { args: { [primary]: unframe(bare) }, notes };
1798
+ }
1799
+ return { args: {}, notes, error: `arguments were not valid JSON — send a JSON object, e.g. {"…"}` };
1800
+ }
1801
+ // a JSON-stringified object arrives as a string
1802
+ if (typeof parsed === "string" && /[{[]/.test(parsed.trim().slice(0, 2))) {
1803
+ try {
1804
+ parsed = JSON.parse(parsed);
1805
+ notes.push("unwrapped a JSON-stringified argument object");
1806
+ }
1807
+ catch {
1808
+ /* leave as-is; it may genuinely be a string argument */
1809
+ }
1810
+ }
1811
+ // a bare scalar where an object is required
1812
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1813
+ if (parsed === null || parsed === undefined)
1814
+ return { args: {}, notes, error: "arguments were empty" };
1815
+ // An ARRAY is a legitimate value for a path-ish argument, but only for the
1816
+ // tools that actually take one — inventing {path:[…]} for apply_patch would
1817
+ // hand the tool a key it has never heard of (#17).
1818
+ if (Array.isArray(parsed) && name === "read_file") {
1819
+ notes.push("kept the array as a paths list");
1820
+ return { args: { paths: parsed }, notes };
1821
+ }
1822
+ const primary = PRIMARY_STRING_ARG[name];
1823
+ if (primary && typeof parsed === "string") {
1824
+ notes.push(`wrapped a bare string as {"${primary}": …}`);
1825
+ return { args: { [primary]: unframe(parsed) }, notes };
1826
+ }
1827
+ return {
1828
+ args: {},
1829
+ notes,
1830
+ error: `expected a JSON object for ${name}, got a bare ${Array.isArray(parsed) ? "array" : typeof parsed}` +
1831
+ (primary ? ` — send {"${primary}": …}` : ""),
1832
+ };
1833
+ }
1834
+ const args = { ...parsed };
1835
+ // alias normalisation
1836
+ for (const [from, to] of Object.entries(ARG_ALIASES[name] ?? {})) {
1837
+ if (!(from in args))
1838
+ continue;
1839
+ if (!(to in args)) {
1840
+ args[to] = args[from];
1841
+ notes.push(`renamed "${from}" -> "${to}"`);
1842
+ }
1843
+ else {
1844
+ // both present: the schema key wins and the stray alias is DROPPED, not
1845
+ // left behind as an unknown argument the tool would ignore (#17)
1846
+ notes.push(`dropped "${from}" — "${to}" was also given and wins`);
1847
+ }
1848
+ delete args[from];
1849
+ }
1850
+ // string -> number / boolean for the keys whose schema says so
1851
+ for (const k of NUMERIC_ARGS[name] ?? []) {
1852
+ if (k in args) {
1853
+ const c = coerceScalar(args[k]);
1854
+ if (typeof c !== typeof args[k]) {
1855
+ args[k] = c;
1856
+ notes.push(`coerced "${k}" to ${typeof c}`);
1857
+ }
1858
+ }
1859
+ }
1860
+ for (const k of BOOL_ARGS[name] ?? []) {
1861
+ if (k in args) {
1862
+ const c = coerceScalar(args[k]);
1863
+ if (typeof c !== typeof args[k]) {
1864
+ args[k] = c;
1865
+ notes.push(`coerced "${k}" to ${typeof c}`);
1866
+ }
1867
+ }
1868
+ }
1869
+ // an array-typed arg arriving as a scalar
1870
+ if (name === "read_file" && "paths" in args) {
1871
+ const before = args.paths;
1872
+ args.paths = toArray(before);
1873
+ if (!Array.isArray(before))
1874
+ notes.push('coerced "paths" to an array');
1875
+ }
1876
+ // a JSON-stringified object hiding inside a single argument, e.g.
1877
+ // {"path": "{\"path\": \"a.ts\"}"} — unwrap it and merge its keys
1878
+ // Only PATH-like keys: `content`/`old_text`/`new_text` legitimately hold JSON
1879
+ // (writing a .json file!), and unwrapping those would REPLACE the file's
1880
+ // actual data with an inner field — silent data loss, not repair.
1881
+ for (const k of ["path", "command"]) {
1882
+ const v = args[k];
1883
+ if (typeof v !== "string")
1884
+ continue;
1885
+ const t = v.trim();
1886
+ if (!/^[{[]/.test(t))
1887
+ continue;
1888
+ try {
1889
+ const inner = JSON.parse(t);
1890
+ if (inner && typeof inner === "object" && !Array.isArray(inner)) {
1891
+ delete args[k];
1892
+ Object.assign(args, inner);
1893
+ notes.push(`unwrapped a JSON-stringified object out of "${k}"`);
1894
+ break;
1895
+ }
1896
+ }
1897
+ catch {
1898
+ /* genuinely not JSON — leave it alone */
1899
+ }
1900
+ }
1901
+ // markdown framing on a path
1902
+ for (const k of ["path", "file_path"]) {
1903
+ if (typeof args[k] === "string") {
1904
+ const f = unframe(args[k]);
1905
+ if (f !== args[k]) {
1906
+ args[k] = f;
1907
+ notes.push(`stripped markdown framing from "${k}"`);
1908
+ }
1909
+ }
1910
+ }
1911
+ return { args, notes };
1912
+ }
1310
1913
  export async function executeTool(name, rawArgs, ctx) {
1311
1914
  const def = TOOLS.find((t) => t.name === name);
1312
1915
  if (!def)
@@ -1316,13 +1919,15 @@ export async function executeTool(name, rawArgs, ctx) {
1316
1919
  // read-only personas (researcher/reviewer) are enforced, not just asked
1317
1920
  if (ctx.readOnly && MUTATING_TOOLS.has(name))
1318
1921
  return { ok: false, result: `${name} is blocked: this agent runs with read-only tools` };
1319
- let args;
1320
- try {
1321
- args = rawArgs ? JSON.parse(rawArgs) : {};
1322
- }
1323
- catch {
1324
- return { ok: false, result: `invalid JSON arguments: ${rawArgs.slice(0, 200)}` };
1325
- }
1922
+ // #17: repair the call before handing it to the tool. A model that sends
1923
+ // {"cmd":"ls"} or {"limit":"100"} should get the work done, not a generic parse
1924
+ // failure and a wasted turn. Unrepairable calls say what to send instead.
1925
+ const repaired = repairToolInput(name, rawArgs);
1926
+ if (repaired.error)
1927
+ return { ok: false, result: `${name}: ${repaired.error}` };
1928
+ const args = repaired.args;
1929
+ if (repaired.notes.length && process.env.TEAPOT_DEBUG_REPAIR)
1930
+ console.error(`[repair] ${name}: ${repaired.notes.join("; ")}`);
1326
1931
  try {
1327
1932
  return await def.run(args, ctx);
1328
1933
  }