@wrongstack/tools 0.275.1 → 0.276.2

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.
Files changed (47) hide show
  1. package/dist/{background-indexer-BoTUw0EM.d.ts → background-indexer-BeDBxfSh.d.ts} +6 -0
  2. package/dist/builtin.js +1007 -204
  3. package/dist/builtin.js.map +1 -1
  4. package/dist/codebase-index/index.d.ts +30 -2
  5. package/dist/codebase-index/index.js +201 -24
  6. package/dist/codebase-index/index.js.map +1 -1
  7. package/dist/codebase-index/worker.js +196 -23
  8. package/dist/codebase-index/worker.js.map +1 -1
  9. package/dist/document.js +2 -2
  10. package/dist/document.js.map +1 -1
  11. package/dist/edit.js +52 -15
  12. package/dist/edit.js.map +1 -1
  13. package/dist/fetch.js +89 -18
  14. package/dist/fetch.js.map +1 -1
  15. package/dist/glob.js +35 -1
  16. package/dist/glob.js.map +1 -1
  17. package/dist/grep.js +15 -4
  18. package/dist/grep.js.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +1041 -213
  21. package/dist/index.js.map +1 -1
  22. package/dist/install.d.ts +7 -0
  23. package/dist/install.js +6 -0
  24. package/dist/install.js.map +1 -1
  25. package/dist/json.d.ts +26 -1
  26. package/dist/json.js +396 -44
  27. package/dist/json.js.map +1 -1
  28. package/dist/memory.js +26 -4
  29. package/dist/memory.js.map +1 -1
  30. package/dist/outdated.js +2 -2
  31. package/dist/outdated.js.map +1 -1
  32. package/dist/pack.js +1007 -204
  33. package/dist/pack.js.map +1 -1
  34. package/dist/read.js +36 -6
  35. package/dist/read.js.map +1 -1
  36. package/dist/replace.js +27 -9
  37. package/dist/replace.js.map +1 -1
  38. package/dist/search.d.ts +5 -1
  39. package/dist/search.js +179 -62
  40. package/dist/search.js.map +1 -1
  41. package/dist/tool-help.js +2 -2
  42. package/dist/tool-help.js.map +1 -1
  43. package/dist/tool-search.js +2 -2
  44. package/dist/tool-search.js.map +1 -1
  45. package/dist/write.js +13 -3
  46. package/dist/write.js.map +1 -1
  47. package/package.json +2 -2
package/dist/pack.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn, execFileSync } from 'node:child_process';
2
2
  import * as Core from '@wrongstack/core';
3
- import { buildChildEnv, getDesignKitLoader, isDesignStack, loadActiveKit, applyTokenOverrides, setActiveKit, recordKitChoice, recordOverrides, setDesignOverrides, materializeTokens, runDesignVerify, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, isPrivateIPv4, isPrivateIPv6, assessCommitSafety, compileGlob, expectDefined, recordPackageAction, detectPackageEcosystem, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, mutateTasks, formatTaskList, formatPlan, toErrorMessage as toErrorMessage$2, computeTaskItemProgress, loadPlan, savePlan, loadTasks, saveTasks, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
3
+ import { buildChildEnv, getDesignKitLoader, isDesignStack, loadActiveKit, applyTokenOverrides, setActiveKit, recordKitChoice, recordOverrides, setDesignOverrides, materializeTokens, runDesignVerify, ToolValidationError, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, ToolError, FetchError, isPrivateIPv4, isPrivateIPv6, assessCommitSafety, compileGlob, expectDefined, recordPackageAction, detectPackageEcosystem, deepMerge, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, mutateTasks, formatTaskList, formatPlan, FsError, toErrorMessage as toErrorMessage$2, computeTaskItemProgress, loadPlan, savePlan, loadTasks, saveTasks, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
4
4
  import * as fs from 'node:fs';
5
5
  import { statSync, mkdirSync, createWriteStream } from 'node:fs';
6
6
  import * as fs2 from 'node:fs/promises';
@@ -2536,8 +2536,8 @@ var Bm25Index = class {
2536
2536
  df;
2537
2537
  N;
2538
2538
  safeAvgLen;
2539
- score(query2, filter) {
2540
- const qTokens = tokenise(query2);
2539
+ score(query, filter) {
2540
+ const qTokens = tokenise(query);
2541
2541
  if (qTokens.length === 0) return [];
2542
2542
  const results = [];
2543
2543
  for (const doc of this.documents) {
@@ -2860,7 +2860,7 @@ var IndexStore = class {
2860
2860
  );
2861
2861
  }
2862
2862
  // ─── Search ──────────────────────────────────────────────────────────────────
2863
- search(query2, filter) {
2863
+ search(query, filter) {
2864
2864
  const conditions = [];
2865
2865
  const values = [];
2866
2866
  let effectiveKind = filter?.kind;
@@ -2884,8 +2884,8 @@ var IndexStore = class {
2884
2884
  conditions.push("file LIKE ?");
2885
2885
  values.push(`%${filter.file}%`);
2886
2886
  }
2887
- if (query2.trim()) {
2888
- const tokens = query2.toLowerCase().split(/\s+/).filter(Boolean);
2887
+ if (query.trim()) {
2888
+ const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
2889
2889
  const tokenConds = tokens.map(() => "text LIKE ?");
2890
2890
  conditions.push(`(${tokenConds.join(" OR ")})`);
2891
2891
  for (const t of tokens) values.push(`%${t}%`);
@@ -2919,10 +2919,10 @@ var IndexStore = class {
2919
2919
  * `LIKE '%tok%'` recall for the common symbol-search shapes ("user" finds
2920
2920
  * "users", camelCase-split text makes "complex" find "complexOperation").
2921
2921
  */
2922
- searchRanked(query2, filter, limit) {
2923
- const tokens = tokenise(query2);
2922
+ searchRanked(query, filter, limit) {
2923
+ const tokens = tokenise(query);
2924
2924
  if (tokens.length === 0 || !this.ftsAvailable) {
2925
- return this.searchRankedFallback(query2, filter, limit);
2925
+ return this.searchRankedFallback(query, filter, limit);
2926
2926
  }
2927
2927
  let effectiveKind = filter?.kind;
2928
2928
  if (filter?.lspKind !== void 0) {
@@ -2979,19 +2979,19 @@ var IndexStore = class {
2979
2979
  };
2980
2980
  }
2981
2981
  /** Legacy ranked path: LIKE candidates + in-process BM25 + JS snippets. */
2982
- searchRankedFallback(query2, filter, limit) {
2983
- const candidates = this.search(query2, filter);
2982
+ searchRankedFallback(query, filter, limit) {
2983
+ const candidates = this.search(query, filter);
2984
2984
  if (candidates.length === 0) return { results: [], total: 0 };
2985
- if (!query2.trim()) {
2985
+ if (!query.trim()) {
2986
2986
  return { results: candidates.slice(0, limit), total: candidates.length };
2987
2987
  }
2988
2988
  const candidateById = new Map(candidates.map((c) => [c.id, c]));
2989
2989
  const bm25 = buildBm25Index(
2990
2990
  candidates.map((c) => ({ id: c.id, text: buildIndexableText(c.name, c.signature, c.docComment) }))
2991
2991
  );
2992
- const scored = bm25.score(query2, (id) => candidateById.has(id));
2992
+ const scored = bm25.score(query, (id) => candidateById.has(id));
2993
2993
  scored.sort((a, b) => b.score - a.score);
2994
- const qTokens = tokenise(query2);
2994
+ const qTokens = tokenise(query);
2995
2995
  const results = scored.slice(0, limit).map(({ id, score }) => {
2996
2996
  const c = expectDefined(candidateById.get(id));
2997
2997
  return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
@@ -3102,6 +3102,104 @@ var IndexStore = class {
3102
3102
  }
3103
3103
  });
3104
3104
  }
3105
+ /**
3106
+ * Commit a batch of file-level symbol/refs/upserts in a single transaction.
3107
+ *
3108
+ * Used by the indexer to amortize SQLite commit overhead across many files.
3109
+ * Before this, the indexer issued one transaction per file (BEGIN IMMEDIATE
3110
+ * for symbols, plus per-file deletes and an upsertFile call), so a 20-file
3111
+ * parallel batch cost ~5+ transactions × 20 files = 100+ commits. With
3112
+ * this entry point we do exactly one BEGIN/COMMIT per parallel batch.
3113
+ *
3114
+ * Each entry must already be a fully-parsed FileSymbols (symbols + refs).
3115
+ * The caller is responsible for the per-file prefix accounting
3116
+ * (refsByLine → flat list with `fromId` populated). `deleteForFiles` lets
3117
+ * the caller clear stale symbols/refs for any files being re-indexed before
3118
+ * the inserts run (required to keep refs → symbols FK invariants).
3119
+ *
3120
+ * Returns the symbols back with their assigned `id` (same shape as
3121
+ * {@link insertSymbols}) so callers can build final per-file results.
3122
+ */
3123
+ commitBatch(entries, options = {}) {
3124
+ if (entries.length === 0 && (options.deleteForFiles?.length ?? 0) === 0) {
3125
+ return [];
3126
+ }
3127
+ return this.runWithRetry(() => {
3128
+ this.db.exec("BEGIN IMMEDIATE");
3129
+ try {
3130
+ if (options.deleteForFiles && options.deleteForFiles.length > 0) {
3131
+ const placeholders = options.deleteForFiles.map(() => "?").join(",");
3132
+ if (this.ftsAvailable) {
3133
+ this.db.prepare(
3134
+ `DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
3135
+ ).run(...options.deleteForFiles);
3136
+ }
3137
+ this.db.prepare(
3138
+ `DELETE FROM refs WHERE from_id IN (SELECT id FROM symbols WHERE file IN (${placeholders}))`
3139
+ ).run(...options.deleteForFiles);
3140
+ this.db.prepare(`DELETE FROM symbols WHERE file IN (${placeholders})`).run(...options.deleteForFiles);
3141
+ }
3142
+ const maxRows = this.db.prepare("SELECT MAX(id) AS m FROM symbols").all();
3143
+ let nextId = (maxRows[0]?.m ?? 0) + 1;
3144
+ const symStmt = this.db.prepare(
3145
+ `INSERT INTO symbols(id, lang, kind, name, file, line, col, signature, doc_comment, scope, text, file_fk)
3146
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
3147
+ );
3148
+ const ftsStmt = this.ftsAvailable ? this.db.prepare("INSERT INTO symbols_fts(rowid, text) VALUES (?, ?)") : null;
3149
+ const allInserted = [];
3150
+ const refsToInsert = [];
3151
+ for (const entry of entries) {
3152
+ for (const s of entry.symbols) {
3153
+ const id = nextId++;
3154
+ symStmt.run(
3155
+ id,
3156
+ s.lang,
3157
+ s.kind,
3158
+ s.name,
3159
+ s.file,
3160
+ s.line,
3161
+ s.col,
3162
+ s.signature,
3163
+ s.docComment,
3164
+ s.scope,
3165
+ s.text,
3166
+ s.file
3167
+ );
3168
+ ftsStmt?.run(id, buildIndexableText(s.name, s.signature, s.docComment));
3169
+ allInserted.push({ ...s, id });
3170
+ }
3171
+ for (const r of entry.refs) refsToInsert.push(r);
3172
+ }
3173
+ if (refsToInsert.length > 0) {
3174
+ const refStmt = this.db.prepare(
3175
+ `INSERT INTO refs(from_id, to_name, to_id, call_type, line)
3176
+ VALUES (?, ?, ?, ?, ?)`
3177
+ );
3178
+ for (const ref of refsToInsert) {
3179
+ refStmt.run(ref.fromId, ref.toName, ref.toId ?? null, ref.callType, ref.line);
3180
+ }
3181
+ }
3182
+ const upsertStmt = this.db.prepare(
3183
+ `INSERT INTO files(file, lang, mtime_ms, symbol_count, last_indexed)
3184
+ VALUES (?, ?, ?, ?, ?)
3185
+ ON CONFLICT(file) DO UPDATE SET
3186
+ lang = excluded.lang,
3187
+ mtime_ms = excluded.mtime_ms,
3188
+ symbol_count = excluded.symbol_count,
3189
+ last_indexed = excluded.last_indexed`
3190
+ );
3191
+ const now = Date.now();
3192
+ for (const entry of entries) {
3193
+ upsertStmt.run(entry.file, entry.lang, entry.mtimeMs, entry.symbolCount, now);
3194
+ }
3195
+ this.db.exec("COMMIT");
3196
+ return allInserted;
3197
+ } catch (err) {
3198
+ this.db.exec("ROLLBACK");
3199
+ throw err;
3200
+ }
3201
+ });
3202
+ }
3105
3203
  /**
3106
3204
  * Delete all refs whose source symbols are in a given file.
3107
3205
  * Used when re-indexing a file to clear stale refs.
@@ -4633,6 +4731,8 @@ async function runIndexerWithStore(store, opts) {
4633
4731
  return { file, stat: stat11, lang, parsed, content };
4634
4732
  })
4635
4733
  );
4734
+ const batchEntries = [];
4735
+ const deleteForFiles = [];
4636
4736
  for (let fi = 0; fi < statReadParse.length; fi++) {
4637
4737
  const settled = statReadParse[fi];
4638
4738
  const file = expectDefined(batchFiles[fi]);
@@ -4657,43 +4757,116 @@ async function runIndexerWithStore(store, opts) {
4657
4757
  }
4658
4758
  if (!lang || !parsed) {
4659
4759
  if (lang) {
4660
- store.upsertFile({ file, lang, mtimeMs: Math.floor(stat11.mtimeMs), symbolCount: 0, lastIndexed: Date.now() });
4760
+ store.upsertFile({
4761
+ file,
4762
+ lang,
4763
+ mtimeMs: Math.floor(stat11.mtimeMs),
4764
+ symbolCount: 0,
4765
+ lastIndexed: Date.now()
4766
+ });
4661
4767
  filesIndexed++;
4662
4768
  }
4663
4769
  continue;
4664
4770
  }
4665
- store.deleteRefsForFile(file);
4666
- store.deleteSymbolsForFile(file);
4667
4771
  if (parsed.symbols.length === 0) {
4668
- store.upsertFile({ file, lang, mtimeMs: Math.floor(stat11.mtimeMs), symbolCount: 0, lastIndexed: Date.now() });
4772
+ store.upsertFile({
4773
+ file,
4774
+ lang,
4775
+ mtimeMs: Math.floor(stat11.mtimeMs),
4776
+ symbolCount: 0,
4777
+ lastIndexed: Date.now()
4778
+ });
4669
4779
  filesIndexed++;
4670
4780
  continue;
4671
4781
  }
4672
- const symbolsWithIds = store.insertSymbols(parsed.symbols);
4673
- const count = symbolsWithIds.length;
4674
- symbolsIndexed += count;
4675
- langStats[lang] = (langStats[lang] ?? 0) + count;
4782
+ const refs = [];
4676
4783
  if (parsed.refs && parsed.refs.length > 0) {
4677
- const refsByLine = /* @__PURE__ */ new Map();
4678
- for (const r of parsed.refs) {
4679
- let arr = refsByLine.get(r.line);
4680
- if (!arr) {
4681
- arr = [];
4682
- refsByLine.set(r.line, arr);
4784
+ for (const r of parsed.refs) refs.push({ ...r, fromId: 0 });
4785
+ }
4786
+ batchEntries.push({
4787
+ file,
4788
+ lang,
4789
+ symbols: parsed.symbols,
4790
+ refs,
4791
+ mtimeMs: Math.floor(stat11.mtimeMs),
4792
+ symbolCount: parsed.symbols.length
4793
+ });
4794
+ deleteForFiles.push(file);
4795
+ }
4796
+ if (batchEntries.length > 0) {
4797
+ try {
4798
+ const inserted = store.commitBatch(batchEntries, { deleteForFiles });
4799
+ let cursor = 0;
4800
+ for (const entry of batchEntries) {
4801
+ const count = entry.symbols.length;
4802
+ const symbolsWithIds = inserted.slice(cursor, cursor + count);
4803
+ cursor += count;
4804
+ symbolsIndexed += count;
4805
+ langStats[entry.lang] = (langStats[entry.lang] ?? 0) + count;
4806
+ filesIndexed++;
4807
+ if (entry.refs.length > 0 && symbolsWithIds.length > 0) {
4808
+ const refsByLine = /* @__PURE__ */ new Map();
4809
+ for (let i = 0; i < symbolsWithIds.length; i++) {
4810
+ const sym = symbolsWithIds[i];
4811
+ let arr = refsByLine.get(sym.line);
4812
+ if (!arr) {
4813
+ arr = [];
4814
+ refsByLine.set(sym.line, arr);
4815
+ }
4816
+ arr.push(i);
4817
+ }
4818
+ for (const ref of entry.refs) {
4819
+ const indices = refsByLine.get(ref.line);
4820
+ if (indices && indices.length > 0) {
4821
+ const idx = indices.shift();
4822
+ ref.fromId = symbolsWithIds[idx].id;
4823
+ }
4824
+ }
4683
4825
  }
4684
- arr.push(r);
4685
4826
  }
4686
- const batch = [];
4687
- for (const sym of symbolsWithIds) {
4688
- const symRefs = refsByLine.get(sym.line);
4689
- if (symRefs) {
4690
- for (const r of symRefs) batch.push({ ...r, fromId: sym.id });
4827
+ } catch (err) {
4828
+ const message = err instanceof Error ? err.message : String(err);
4829
+ errors.push(`commitBatch failed: ${message} \u2014 falling back to per-file writes`);
4830
+ for (const entry of batchEntries) {
4831
+ try {
4832
+ store.deleteRefsForFile(entry.file);
4833
+ store.deleteSymbolsForFile(entry.file);
4834
+ const symbolsWithIds = store.insertSymbols(entry.symbols);
4835
+ symbolsIndexed += symbolsWithIds.length;
4836
+ langStats[entry.lang] = (langStats[entry.lang] ?? 0) + symbolsWithIds.length;
4837
+ filesIndexed++;
4838
+ if (entry.refs.length > 0 && symbolsWithIds.length > 0) {
4839
+ const refsByLine = /* @__PURE__ */ new Map();
4840
+ for (const sym of symbolsWithIds) {
4841
+ let arr = refsByLine.get(sym.line);
4842
+ if (!arr) {
4843
+ arr = [];
4844
+ refsByLine.set(sym.line, arr);
4845
+ }
4846
+ arr.push(sym);
4847
+ }
4848
+ const fallbackBatch = [];
4849
+ for (const ref of entry.refs) {
4850
+ const syms = refsByLine.get(ref.line);
4851
+ if (syms && syms.length > 0) {
4852
+ const sym = syms.shift();
4853
+ fallbackBatch.push({ ...ref, fromId: sym.id });
4854
+ }
4855
+ }
4856
+ if (fallbackBatch.length > 0) store.insertRefsBatch(fallbackBatch);
4857
+ }
4858
+ store.upsertFile({
4859
+ file: entry.file,
4860
+ lang: entry.lang,
4861
+ mtimeMs: entry.mtimeMs,
4862
+ symbolCount: entry.symbolCount,
4863
+ lastIndexed: Date.now()
4864
+ });
4865
+ } catch (innerErr) {
4866
+ errors.push(`fallback write failed: ${entry.file}: ${innerErr instanceof Error ? innerErr.message : String(innerErr)}`);
4691
4867
  }
4692
4868
  }
4693
- if (batch.length > 0) store.insertRefsBatch(batch);
4694
4869
  }
4695
- store.upsertFile({ file, lang, mtimeMs: Math.floor(stat11.mtimeMs), symbolCount: count, lastIndexed: Date.now() });
4696
- filesIndexed++;
4697
4870
  }
4698
4871
  }
4699
4872
  if (discoveredFiles) {
@@ -5076,7 +5249,7 @@ var codebaseSearchTool = {
5076
5249
  name: "codebase-search",
5077
5250
  category: "Project",
5078
5251
  icon: "index",
5079
- description: "Semantic/keyword search over the indexed codebase symbols (functions, classes, interfaces, etc.). Uses BM25 ranking. Much more powerful and structured than raw `grep` for finding code by name or concept.",
5252
+ description: "Search code symbols using a fast SQLite+BM25 index, with optional LSP fallback. Much more powerful and structured than raw `grep` for finding code by name or concept. Set `preferLsp: true` for live precision when the LSP plugin is active (supersedes codebase-lsp-search).",
5080
5253
  usageHint: "PREFERRED FOR CODE UNDERSTANDING:\n\n- Use when you need to find where something is defined or used by name.\n- `kind` filter is very useful (e.g. only functions or only interfaces).\n- Combine with `file` filter to scope to a specific directory or module.\nThis is generally better than `grep` when you are looking for symbols rather than arbitrary text patterns.",
5081
5254
  permission: "auto",
5082
5255
  mutating: false,
@@ -5110,6 +5283,10 @@ var codebaseSearchTool = {
5110
5283
  description: "Maximum results to return (default 20, max 100)",
5111
5284
  minimum: 1,
5112
5285
  maximum: 100
5286
+ },
5287
+ preferLsp: {
5288
+ type: "boolean",
5289
+ description: "Prefer live LSP results over the index. Index-only when the LSP plugin is not active. When the LSP plugin is active and this is true, results come from live workspaceSymbol queries."
5113
5290
  }
5114
5291
  },
5115
5292
  required: ["query"]
@@ -5589,8 +5766,8 @@ ${numbered}`;
5589
5766
  var documentTool = {
5590
5767
  name: "document",
5591
5768
  category: "Project",
5592
- description: "Preview documentation comments (JSDoc/TSDoc style) that would be generated for code symbols. Returns a list of candidates with status `skipped` \u2014 the tool is currently a read-only preview and does NOT write to files.",
5593
- usageHint: "USE FOR IMPROVING CODE DOCUMENTATION:\n\n- Good for adding missing docs to public APIs or complex functions.\n- Currently this is a PREVIEW-ONLY tool: it does not modify files.\n- Use the output to decide which symbols to document manually, or pass the candidates to `edit` / `patch`.\n- `overwrite`, `style`, and `target` parameters are accepted for future expansion but are ignored today.\nAlways review the proposed documentation before applying it \u2014 the model can hallucinate details.",
5769
+ description: "DEPRECATED \u2014 use the `auto_doc` tool with `dryRun: true` instead. This tool is a read-only preview stub that returns `skipped` candidates without generating real docstrings.",
5770
+ usageHint: "Deprecated: prefer `auto_doc` with `dryRun: true` for previewing, or `auto_doc` without dryRun for writing. This tool only lists undocumented symbols with placeholder comments \u2014 it does not generate real JSDoc/TSDoc.",
5594
5771
  permission: "auto",
5595
5772
  mutating: false,
5596
5773
  timeoutMs: 3e4,
@@ -5753,28 +5930,62 @@ var editTool = {
5753
5930
  required: ["path", "old_string", "new_string"]
5754
5931
  },
5755
5932
  async execute(input, ctx) {
5756
- if (!input?.path) throw new Error("edit: path is required");
5757
- if (input.old_string === void 0) throw new Error("edit: old_string is required");
5758
- if (input.new_string === void 0) throw new Error("edit: new_string is required");
5759
- if (input.old_string === "") throw new Error("edit: old_string cannot be empty");
5933
+ if (!input?.path) {
5934
+ throw new ToolValidationError({ message: "edit: path is required", field: "path" });
5935
+ }
5936
+ if (input.old_string === void 0) {
5937
+ throw new ToolValidationError({
5938
+ message: "edit: old_string is required",
5939
+ field: "old_string"
5940
+ });
5941
+ }
5942
+ if (input.new_string === void 0) {
5943
+ throw new ToolValidationError({
5944
+ message: "edit: new_string is required",
5945
+ field: "new_string"
5946
+ });
5947
+ }
5948
+ if (input.old_string === "") {
5949
+ throw new ToolValidationError({
5950
+ message: "edit: old_string cannot be empty",
5951
+ field: "old_string"
5952
+ });
5953
+ }
5760
5954
  const absPath = await safeResolveReal(input.path, ctx);
5761
5955
  const stat11 = await fs2.stat(absPath).catch((err) => {
5762
5956
  if (err.code === "ENOENT") {
5763
- throw new Error(`edit: file "${input.path}" does not exist. Use \`write\` instead.`);
5957
+ throw new ToolValidationError({
5958
+ message: `edit: file "${input.path}" does not exist. Use \`write\` instead.`,
5959
+ field: "path",
5960
+ context: { exists: false }
5961
+ });
5764
5962
  }
5765
5963
  throw err;
5766
5964
  });
5767
- if (!stat11.isFile()) throw new Error(`edit: "${input.path}" is not a regular file`);
5965
+ if (!stat11.isFile()) {
5966
+ throw new ToolValidationError({
5967
+ message: `edit: "${input.path}" is not a regular file`,
5968
+ field: "path"
5969
+ });
5970
+ }
5768
5971
  const autoRead = !ctx.hasRead(absPath);
5769
5972
  const original = await fs2.readFile(absPath, "utf8");
5770
5973
  const updated = await fs2.stat(absPath);
5771
5974
  const mtimeTolerance = process.platform === "win32" ? 2e3 : 1;
5772
5975
  const lastReadMtime = ctx.lastReadMtime(absPath);
5773
5976
  if (lastReadMtime !== void 0 && updated.mtimeMs > lastReadMtime + mtimeTolerance) {
5774
- throw new Error(`edit: file "${input.path}" was modified externally. Re-read it first.`);
5977
+ throw new ToolValidationError({
5978
+ message: `edit: file "${input.path}" was modified externally. Re-read it first.`,
5979
+ field: "path",
5980
+ context: { reason: "external_modification" }
5981
+ });
5775
5982
  }
5776
5983
  if (autoRead && updated.mtimeMs > stat11.mtimeMs + mtimeTolerance) {
5777
- throw new Error(`edit: file "${input.path}" changed while being auto-read. Retry the edit.`);
5984
+ throw new ToolValidationError({
5985
+ message: `edit: file "${input.path}" changed while being auto-read. Retry the edit.`,
5986
+ field: "path",
5987
+ context: { reason: "auto_read_race" }
5988
+ });
5778
5989
  }
5779
5990
  const autoReadNote = autoRead ? `No prior read was recorded for "${input.path}"; edit auto-read the current file and applied the replacement only after the ambiguity checks passed.` : void 0;
5780
5991
  const style = detectNewlineStyle(original);
@@ -5800,15 +6011,18 @@ var editTool = {
5800
6011
  }
5801
6012
  if (count === 0) {
5802
6013
  const hint = findSimilarity(fileLf, oldLf);
5803
- throw new Error(
5804
- `edit: no match for old_string in "${input.path}".${hint ? ` Nearest match near line ${hint}.` : ""}`
5805
- );
6014
+ throw new ToolValidationError({
6015
+ message: `edit: no match for old_string in "${input.path}".${hint ? ` Nearest match near line ${hint}.` : ""}`,
6016
+ field: "old_string"
6017
+ });
5806
6018
  }
5807
6019
  if (count > 1 && !input.replace_all) {
5808
6020
  const lines = lineNumbersFor(fileLf, matches);
5809
- throw new Error(
5810
- `edit: old_string matched ${count} times in "${input.path}" (lines: ${lines.join(", ")}). Add more context to make it unique, or set replace_all: true.`
5811
- );
6021
+ throw new ToolValidationError({
6022
+ message: `edit: old_string matched ${count} times in "${input.path}" (lines: ${lines.join(", ")}). Add more context to make it unique, or set replace_all: true.`,
6023
+ field: "old_string",
6024
+ context: { occurrences: count }
6025
+ });
5812
6026
  }
5813
6027
  const newFileLf = input.replace_all ? fileLf.split(oldLf).join(newLf) : fileLf.replace(oldLf, newLf);
5814
6028
  const newFile = toStyle(newFileLf, style);
@@ -6327,10 +6541,16 @@ async function guardedFetch(url, maxRedirects, signal, headers = {
6327
6541
  for (; ; ) {
6328
6542
  const parsed = new URL(currentUrl);
6329
6543
  if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
6330
- throw new Error(`fetch: redirect to unsupported protocol "${parsed.protocol}"`);
6544
+ throw new ToolValidationError({
6545
+ message: `fetch: redirect to unsupported protocol "${parsed.protocol}"`,
6546
+ field: "url"
6547
+ });
6331
6548
  }
6332
6549
  if (parsed.protocol === "http:" && !ALLOW_PRIVATE) {
6333
- throw new Error("fetch: redirect to http:// blocked (HTTPS required by default)");
6550
+ throw new ToolValidationError({
6551
+ message: "fetch: redirect to http:// blocked (HTTPS required by default)",
6552
+ field: "url"
6553
+ });
6334
6554
  }
6335
6555
  await assertNotPrivate(parsed.hostname);
6336
6556
  const init = {
@@ -6345,11 +6565,19 @@ async function guardedFetch(url, maxRedirects, signal, headers = {
6345
6565
  }
6346
6566
  redirectCount++;
6347
6567
  if (redirectCount > maxRedirects) {
6348
- throw new Error(`fetch: exceeded ${maxRedirects} redirects`);
6568
+ throw new FetchError({
6569
+ message: `fetch: exceeded ${maxRedirects} redirects`,
6570
+ status: res.status,
6571
+ context: { url: currentUrl, maxRedirects, redirectCount }
6572
+ });
6349
6573
  }
6350
6574
  const location = res.headers.get("location");
6351
6575
  if (!location) {
6352
- throw new Error("fetch: redirect status with no location header");
6576
+ throw new FetchError({
6577
+ message: "fetch: redirect status with no location header",
6578
+ status: res.status,
6579
+ context: { url: currentUrl, redirectCount }
6580
+ });
6353
6581
  }
6354
6582
  currentUrl = new URL(location, currentUrl).toString();
6355
6583
  }
@@ -6388,26 +6616,53 @@ var fetchTool = {
6388
6616
  async execute(input, ctx, opts) {
6389
6617
  let final;
6390
6618
  const executeStream = fetchTool.executeStream;
6391
- if (!executeStream) throw new Error("fetchTool: stream execution unavailable");
6619
+ if (!executeStream) {
6620
+ throw new ToolError({
6621
+ message: "fetchTool: stream execution unavailable",
6622
+ code: "TOOL_EXECUTION_FAILED",
6623
+ toolName: "fetch"
6624
+ });
6625
+ }
6392
6626
  for await (const ev of executeStream(input, ctx, opts)) {
6393
6627
  if (ev.type === "final") final = ev.output;
6394
6628
  }
6395
- if (!final) throw new Error("fetch: stream ended without final event");
6629
+ if (!final) {
6630
+ throw new ToolError({
6631
+ message: "fetch: stream ended without final event",
6632
+ code: "TOOL_EXECUTION_FAILED",
6633
+ toolName: "fetch"
6634
+ });
6635
+ }
6396
6636
  return final;
6397
6637
  },
6398
6638
  async *executeStream(input, ctx, opts) {
6399
- if (!input?.url) throw new Error("fetch: url is required");
6639
+ if (!input?.url) {
6640
+ throw new ToolValidationError({
6641
+ message: "fetch: url is required",
6642
+ field: "url"
6643
+ });
6644
+ }
6400
6645
  const u = new URL(input.url);
6401
6646
  if (u.protocol !== "https:" && u.protocol !== "http:") {
6402
- throw new Error(`fetch: unsupported protocol "${u.protocol}"`);
6647
+ throw new ToolValidationError({
6648
+ message: `fetch: unsupported protocol "${u.protocol}"`,
6649
+ field: "url"
6650
+ });
6403
6651
  }
6404
6652
  if (u.protocol === "http:" && !ALLOW_PRIVATE) {
6405
- throw new Error("fetch: http:// blocked (HTTPS required by default)");
6653
+ throw new ToolValidationError({
6654
+ message: "fetch: http:// blocked (HTTPS required by default)",
6655
+ field: "url"
6656
+ });
6406
6657
  }
6407
6658
  await assertNotPrivate(u.hostname);
6408
6659
  yield { type: "log", text: `GET ${input.url}` };
6409
6660
  const ctrl = new AbortController();
6410
- const timer = setTimeout(() => ctrl.abort(new Error("fetch timeout")), TIMEOUT_MS);
6661
+ const timer = setTimeout(() => ctrl.abort(new ToolError({
6662
+ message: "fetch timeout",
6663
+ code: "TOOL_TIMEOUT",
6664
+ toolName: "fetch"
6665
+ })), TIMEOUT_MS);
6411
6666
  const combined = combineSignals([opts.signal, ctrl.signal]);
6412
6667
  try {
6413
6668
  let res;
@@ -6419,7 +6674,11 @@ var fetchTool = {
6419
6674
  }
6420
6675
  const ct = res.headers.get("content-type") ?? "application/octet-stream";
6421
6676
  if (/^image\/|^audio\/|^video\/|application\/octet-stream/.test(ct)) {
6422
- throw new Error(`fetch: refusing to read binary content-type "${ct}"`);
6677
+ throw new FetchError({
6678
+ message: `fetch: refusing to read binary content-type "${ct}"`,
6679
+ status: res.status,
6680
+ context: { url: res.url, contentType: ct }
6681
+ });
6423
6682
  }
6424
6683
  yield {
6425
6684
  type: "log",
@@ -6484,16 +6743,25 @@ async function assertNotPrivate(hostname2) {
6484
6743
  if (ALLOW_PRIVATE) return;
6485
6744
  const host = hostname2.startsWith("[") && hostname2.endsWith("]") ? hostname2.slice(1, -1) : hostname2;
6486
6745
  if (host === "localhost" || host.endsWith(".localhost")) {
6487
- throw new Error("fetch: blocked localhost target");
6746
+ throw new ToolValidationError({
6747
+ message: "fetch: blocked localhost target",
6748
+ field: "url"
6749
+ });
6488
6750
  }
6489
6751
  const ipVersion = net.isIP(host);
6490
6752
  if (ipVersion === 4) {
6491
6753
  if (isPrivateIPv4(host)) {
6492
- throw new Error(`fetch: blocked private/loopback address "${host}"`);
6754
+ throw new ToolValidationError({
6755
+ message: `fetch: blocked private/loopback address "${host}"`,
6756
+ field: "url"
6757
+ });
6493
6758
  }
6494
6759
  } else if (ipVersion === 6) {
6495
6760
  if (isPrivateIPv6(host)) {
6496
- throw new Error(`fetch: blocked private/loopback address "${host}"`);
6761
+ throw new ToolValidationError({
6762
+ message: `fetch: blocked private/loopback address "${host}"`,
6763
+ field: "url"
6764
+ });
6497
6765
  }
6498
6766
  } else {
6499
6767
  try {
@@ -6501,7 +6769,10 @@ async function assertNotPrivate(hostname2) {
6501
6769
  for (const r of records) {
6502
6770
  const bad = r.family === 4 ? isPrivateIPv4(r.address) : isPrivateIPv6(r.address);
6503
6771
  if (bad) {
6504
- throw new Error(`fetch: resolved to private address ${r.address}`);
6772
+ throw new ToolValidationError({
6773
+ message: `fetch: resolved to private address ${r.address}`,
6774
+ field: "url"
6775
+ });
6505
6776
  }
6506
6777
  }
6507
6778
  } catch (err) {
@@ -6511,7 +6782,13 @@ async function assertNotPrivate(hostname2) {
6511
6782
  }
6512
6783
  function describeFetchError(err, url, timedOut) {
6513
6784
  if (timedOut) {
6514
- return new Error(`fetch: GET ${url} timed out after ${TIMEOUT_MS}ms`);
6785
+ return new ToolError({
6786
+ message: `fetch: GET ${url} timed out after ${TIMEOUT_MS}ms`,
6787
+ code: "TOOL_TIMEOUT",
6788
+ toolName: "fetch",
6789
+ context: { url, timedOut: true, timeoutMs: TIMEOUT_MS },
6790
+ cause: err
6791
+ });
6515
6792
  }
6516
6793
  const parts = [];
6517
6794
  const seen = /* @__PURE__ */ new Set();
@@ -6524,7 +6801,15 @@ function describeFetchError(err, url, timedOut) {
6524
6801
  cur = cur.cause;
6525
6802
  }
6526
6803
  const detail = parts.length > 0 ? parts.join(" \u2192 ") : "fetch failed";
6527
- return new Error(`fetch: GET ${url} failed \u2014 ${detail}`);
6804
+ return new FetchError({
6805
+ message: `fetch: GET ${url} failed \u2014 ${detail}`,
6806
+ status: 502,
6807
+ context: { url, timedOut: false, transportErrors: parts },
6808
+ // Preserve the original undici / DNS / TLS chain so callers can inspect
6809
+ // it via `err.cause` and structured `instanceof` checks. The flattened
6810
+ // text version stays in the message for human readability.
6811
+ cause: err
6812
+ });
6528
6813
  }
6529
6814
  function prettyJson(s) {
6530
6815
  try {
@@ -6964,7 +7249,7 @@ var globTool = {
6964
7249
  },
6965
7250
  async execute(input, ctx) {
6966
7251
  if (!input?.pattern) throw new Error("glob: pattern is required");
6967
- const base = input.path ? safeResolve(input.path, ctx) : ctx.cwd;
7252
+ const base = input.path ? await safeResolveReal(input.path, ctx) : ctx.cwd;
6968
7253
  const limit = Math.max(1, Math.min(input.limit ?? 1e3, 5e3));
6969
7254
  const ignored = await readGitignore(base);
6970
7255
  const re = compileGlob(input.pattern);
@@ -7019,8 +7304,12 @@ var globTool = {
7019
7304
  try {
7020
7305
  const st = await fs2.stat(full);
7021
7306
  if (st.isDirectory()) {
7307
+ const real = await fs2.realpath(full);
7308
+ await assertRealInsideRoot(real, ctx);
7022
7309
  subdirs.push({ full, rel });
7023
7310
  } else if (st.isFile()) {
7311
+ const real = await fs2.realpath(full);
7312
+ await assertRealInsideRoot(real, ctx);
7024
7313
  re.lastIndex = 0;
7025
7314
  const relMatch = re.test(rel);
7026
7315
  re.lastIndex = 0;
@@ -7158,13 +7447,21 @@ var grepTool = {
7158
7447
  return final;
7159
7448
  },
7160
7449
  async *executeStream(input, ctx, opts) {
7161
- if (!input?.pattern) throw new Error("grep: pattern is required");
7450
+ if (!input?.pattern) {
7451
+ throw new ToolValidationError({
7452
+ message: "grep: pattern is required",
7453
+ field: "pattern"
7454
+ });
7455
+ }
7162
7456
  const base = input.path ? safeResolve(input.path, ctx) : ctx.cwd;
7163
7457
  const mode = input.output_mode ?? "content";
7164
7458
  const limit = Math.max(1, Math.min(input.limit ?? 200, 2e3));
7165
7459
  const validation = compileUserRegex(input.pattern, input.case_insensitive ? "i" : "");
7166
7460
  if (!validation.ok) {
7167
- throw new Error(`grep: ${validation.reason}`);
7461
+ throw new ToolValidationError({
7462
+ message: `grep: ${validation.reason}`,
7463
+ field: "pattern"
7464
+ });
7168
7465
  }
7169
7466
  const rgAvailable = await detectRg(opts.signal);
7170
7467
  if (rgAvailable) {
@@ -7320,7 +7617,10 @@ async function runNative(input, base, mode, limit, signal) {
7320
7617
  const flags = input.case_insensitive ? "i" : "";
7321
7618
  const compiled = compileUserRegex(input.pattern, flags);
7322
7619
  if (!compiled.ok) {
7323
- throw new Error(`grep: ${compiled.reason}`);
7620
+ throw new ToolValidationError({
7621
+ message: `grep: ${compiled.reason}`,
7622
+ field: "pattern"
7623
+ });
7324
7624
  }
7325
7625
  const re = compiled.regex;
7326
7626
  const globRe = input.glob ? compileGlob(input.glob) : null;
@@ -7477,6 +7777,10 @@ var installTool = {
7477
7777
  global: {
7478
7778
  type: "boolean",
7479
7779
  description: "Whether to perform a global install (use with caution)."
7780
+ },
7781
+ lifecycleScripts: {
7782
+ type: "boolean",
7783
+ description: "Opt in to running package lifecycle scripts (preinstall / install / postinstall / prepare / \u2026). Default: false \u2014 installs pass --ignore-scripts so a malicious package cannot execute arbitrary code at install time. Set true to opt back in to the legacy npm/pnpm/yarn default."
7480
7784
  }
7481
7785
  }
7482
7786
  },
@@ -7496,8 +7800,10 @@ var installTool = {
7496
7800
  yield { type: "log", text: `Resolving with ${pkgManager}\u2026`, data: { phase: "resolve" } };
7497
7801
  const save = input.save === "dev" ? "-D" : input.save === "optional" ? "-O" : "";
7498
7802
  const globalFlag = input.global ? ["-g"] : [];
7803
+ const ignoreScripts = input.lifecycleScripts !== true;
7499
7804
  const args = [];
7500
7805
  if (input.dry_run) args.push("--dry-run");
7806
+ if (ignoreScripts) args.push("--ignore-scripts");
7501
7807
  if (pkgManager === "pnpm") {
7502
7808
  if (save) args.push(save);
7503
7809
  args.push("add", ...globalFlag);
@@ -7592,8 +7898,8 @@ function resolveManifestPath(cwd, pkgManager) {
7592
7898
  var jsonTool = {
7593
7899
  name: "json",
7594
7900
  category: "Data",
7595
- description: "Parse, pretty-print, query, and convert between JSON, JSON5, and YAML. Supports simple path-based queries.",
7596
- usageHint: "VERY USEFUL FOR DATA INSPECTION:\n\n- Use on package.json, tsconfig, config files, or any structured data.\n- `query` lets you extract specific values without reading the whole file.\n- Great for validating that a file has the expected structure.\nPrefer this over raw `read` + manual parsing when dealing with configuration or data files.",
7901
+ description: "Parse, pretty-print, query, validate, transform, and merge JSON/JSON5/YAML. Use `action` to select the operation: parse (default), query, validate, transform, or merge.",
7902
+ usageHint: 'VERY USEFUL FOR DATA INSPECTION:\n\n- `action: "parse"` (default): read/pretty-print/convert JSON, JSON5, or YAML from `file` or `data`.\n- `action: "query"`: JMESPath-like query (`a.b[0].c`, `items[*].name`, filters, functions).\n- `action: "validate"`: validate data against a JSON Schema (`schema` param).\n- `action: "transform"`: chain multiple JMESPath transforms (`transforms` param).\n- `action: "merge"`: deep merge `base` and `patch` objects (`conflictResolution` param).\nPrefer this over raw `read` + manual parsing when dealing with configuration or data files.',
7597
7903
  permission: "auto",
7598
7904
  mutating: false,
7599
7905
  timeoutMs: 5e3,
@@ -7602,69 +7908,420 @@ var jsonTool = {
7602
7908
  inputSchema: {
7603
7909
  type: "object",
7604
7910
  properties: {
7605
- file: { type: "string", description: "Path to JSON/JSON5/YAML file" },
7606
- data: { type: "string", description: "JSON/JSON5/YAML string (alternative to file)" },
7607
- query: {
7911
+ action: {
7608
7912
  type: "string",
7609
- description: 'JMESPath-like query (e.g. "a.b[0].c" or "a[*].name")'
7913
+ enum: ["parse", "query", "validate", "transform", "merge"],
7914
+ description: "Operation (default: parse). parse=read/pretty-print, query=JMESPath, validate=schema, transform=chained queries, merge=deep merge."
7610
7915
  },
7916
+ file: { type: "string", description: "Path to JSON/JSON5/YAML file (parse/query/validate)" },
7917
+ data: { type: "string", description: "JSON/JSON5/YAML string (parse/query/validate, alternative to file)" },
7611
7918
  format: {
7612
7919
  type: "string",
7613
7920
  enum: ["json", "json5", "yaml"],
7614
- description: "Output format (default: json)"
7921
+ description: "Output format for parse/query/transform (default: json)"
7922
+ },
7923
+ query: {
7924
+ type: "string",
7925
+ description: "JMESPath-like query expression (query action)"
7926
+ },
7927
+ transforms: {
7928
+ type: "array",
7929
+ items: { type: "string" },
7930
+ description: "Ordered JMESPath query strings (transform action)"
7931
+ },
7932
+ schema: {
7933
+ type: "object",
7934
+ description: "JSON Schema to validate against (validate action)"
7935
+ },
7936
+ base: { description: "Base JSON object (merge action)" },
7937
+ patch: { description: "Patch JSON object to merge in (merge action)" },
7938
+ conflictResolution: {
7939
+ type: "string",
7940
+ enum: ["prefer-base", "prefer-patch"],
7941
+ description: "Merge conflict resolution (default: prefer-patch)"
7615
7942
  },
7616
7943
  validate: {
7617
7944
  type: "boolean",
7618
- description: "Validate syntax only, no output (default: false)"
7945
+ description: "Validate syntax only, no output (parse action, default: false)"
7619
7946
  }
7620
7947
  }
7621
7948
  },
7622
7949
  async execute(input) {
7623
- const format = input.format ?? "json";
7624
- let parsed;
7625
- let raw;
7626
- if (input.file) {
7627
- try {
7628
- raw = await fs2.readFile(input.file, "utf8");
7629
- } catch {
7630
- return { data: null, formatted: "", type: "unknown", error: `Could not read file` };
7631
- }
7632
- } else if (input.data) {
7633
- raw = input.data;
7634
- } else {
7635
- return { data: null, formatted: "", type: "unknown", error: "Provide file or data" };
7950
+ const action = input.action ?? "parse";
7951
+ switch (action) {
7952
+ case "query":
7953
+ return executeQuery(input);
7954
+ case "validate":
7955
+ return executeValidate(input);
7956
+ case "transform":
7957
+ return executeTransform(input);
7958
+ case "merge":
7959
+ return executeMerge(input);
7960
+ case "parse":
7961
+ default:
7962
+ return executeParse(input);
7636
7963
  }
7964
+ }
7965
+ };
7966
+ async function executeParse(input) {
7967
+ const format = input.format ?? "json";
7968
+ let parsed;
7969
+ let raw;
7970
+ if (input.file) {
7637
7971
  try {
7638
- parsed = JSON.parse(raw);
7639
- } catch (e) {
7640
- return {
7641
- data: null,
7642
- formatted: "",
7643
- type: "unknown",
7644
- /* v8 ignore next -- JSON.parse only throws SyntaxError (an Error); the String(e) side is defensive. */
7645
- error: `Parse failed: ${e instanceof Error ? e.message : String(e)}`
7646
- };
7647
- }
7648
- if (input.validate) {
7649
- return {
7650
- data: parsed,
7651
- formatted: "valid",
7652
- type: Array.isArray(parsed) ? "array" : typeof parsed,
7653
- keys: typeof parsed === "object" && parsed !== null ? Object.keys(parsed) : void 0
7654
- };
7972
+ raw = await fs2.readFile(input.file, "utf8");
7973
+ } catch {
7974
+ return { data: null, formatted: "", type: "unknown", action: "parse", error: "Could not read file" };
7655
7975
  }
7656
- const queryResult = input.query ? query(parsed, input.query) : void 0;
7657
- const formatted = formatOutput(queryResult ?? parsed, format);
7976
+ } else if (input.data) {
7977
+ raw = input.data;
7978
+ } else {
7979
+ return { data: null, formatted: "", type: "unknown", action: "parse", error: "Provide file or data" };
7980
+ }
7981
+ try {
7982
+ parsed = JSON.parse(raw);
7983
+ } catch (e) {
7984
+ return {
7985
+ data: null,
7986
+ formatted: "",
7987
+ type: "unknown",
7988
+ action: "parse",
7989
+ /* v8 ignore next -- JSON.parse only throws SyntaxError (an Error); the String(e) side is defensive. */
7990
+ error: `Parse failed: ${e instanceof Error ? e.message : String(e)}`
7991
+ };
7992
+ }
7993
+ if (input.validate) {
7658
7994
  return {
7659
7995
  data: parsed,
7660
- formatted,
7996
+ formatted: "valid",
7661
7997
  type: Array.isArray(parsed) ? "array" : typeof parsed,
7998
+ action: "parse",
7999
+ keys: typeof parsed === "object" && parsed !== null ? Object.keys(parsed) : void 0
8000
+ };
8001
+ }
8002
+ if (input.query) {
8003
+ const queryResult = simpleQuery(parsed, input.query);
8004
+ const formatted2 = formatOutput(queryResult, format);
8005
+ return {
8006
+ data: parsed,
8007
+ formatted: formatted2,
8008
+ type: Array.isArray(parsed) ? "array" : typeof parsed,
8009
+ action: "parse",
7662
8010
  keys: typeof parsed === "object" && parsed !== null ? Object.keys(parsed) : void 0,
7663
8011
  query_result: queryResult
7664
8012
  };
7665
8013
  }
7666
- };
7667
- function query(data, path22) {
8014
+ const formatted = formatOutput(parsed, format);
8015
+ return {
8016
+ data: parsed,
8017
+ formatted,
8018
+ type: Array.isArray(parsed) ? "array" : typeof parsed,
8019
+ action: "parse",
8020
+ keys: typeof parsed === "object" && parsed !== null ? Object.keys(parsed) : void 0
8021
+ };
8022
+ }
8023
+ async function executeQuery(input) {
8024
+ if (!input.query) {
8025
+ return { data: null, formatted: "", type: "unknown", action: "query", error: "query is required for action: query" };
8026
+ }
8027
+ let parsed;
8028
+ if (input.file) {
8029
+ try {
8030
+ const raw = await fs2.readFile(input.file, "utf8");
8031
+ parsed = JSON.parse(raw);
8032
+ } catch {
8033
+ return { data: null, formatted: "", type: "unknown", action: "query", error: "Could not read/parse file" };
8034
+ }
8035
+ } else if (input.data) {
8036
+ try {
8037
+ parsed = JSON.parse(input.data);
8038
+ } catch {
8039
+ return { data: null, formatted: "", type: "unknown", action: "query", error: "Could not parse data string" };
8040
+ }
8041
+ } else {
8042
+ return { data: null, formatted: "", type: "unknown", action: "query", error: "Provide file or data" };
8043
+ }
8044
+ try {
8045
+ const result = jmespathSearch(parsed, input.query);
8046
+ const format = input.format ?? "json";
8047
+ return {
8048
+ data: parsed,
8049
+ formatted: formatOutput(result, format),
8050
+ type: result === null ? "null" : Array.isArray(result) ? "array" : typeof result,
8051
+ action: "query",
8052
+ query_result: result
8053
+ };
8054
+ } catch (e) {
8055
+ return {
8056
+ data: null,
8057
+ formatted: "",
8058
+ type: "unknown",
8059
+ action: "query",
8060
+ /* v8 ignore next -- defensive String(e) */
8061
+ error: `Query failed: ${e instanceof Error ? e.message : String(e)}`
8062
+ };
8063
+ }
8064
+ }
8065
+ async function executeValidate(input) {
8066
+ if (!input.schema) {
8067
+ return { data: null, formatted: "", type: "unknown", action: "validate", error: "schema is required for action: validate" };
8068
+ }
8069
+ let parsed;
8070
+ if (input.file) {
8071
+ try {
8072
+ const raw = await fs2.readFile(input.file, "utf8");
8073
+ parsed = JSON.parse(raw);
8074
+ } catch {
8075
+ return { data: null, formatted: "", type: "unknown", action: "validate", error: "Could not read/parse file" };
8076
+ }
8077
+ } else if (input.data) {
8078
+ try {
8079
+ parsed = JSON.parse(input.data);
8080
+ } catch {
8081
+ return { data: null, formatted: "", type: "unknown", action: "validate", error: "Could not parse data string" };
8082
+ }
8083
+ } else {
8084
+ return { data: null, formatted: "", type: "unknown", action: "validate", error: "Provide file or data" };
8085
+ }
8086
+ try {
8087
+ const { valid, errors } = validateJsonSchema(parsed, input.schema);
8088
+ return {
8089
+ data: parsed,
8090
+ formatted: valid ? "valid" : "invalid",
8091
+ type: Array.isArray(parsed) ? "array" : typeof parsed,
8092
+ action: "validate",
8093
+ valid,
8094
+ errors
8095
+ };
8096
+ } catch (e) {
8097
+ return {
8098
+ data: null,
8099
+ formatted: "",
8100
+ type: "unknown",
8101
+ action: "validate",
8102
+ /* v8 ignore next -- defensive String(e) */
8103
+ error: `Validation failed: ${e instanceof Error ? e.message : String(e)}`
8104
+ };
8105
+ }
8106
+ }
8107
+ async function executeTransform(input) {
8108
+ if (!input.transforms || input.transforms.length === 0) {
8109
+ return { data: null, formatted: "", type: "unknown", action: "transform", error: "transforms array is required for action: transform" };
8110
+ }
8111
+ let parsed;
8112
+ if (input.file) {
8113
+ try {
8114
+ const raw = await fs2.readFile(input.file, "utf8");
8115
+ parsed = JSON.parse(raw);
8116
+ } catch {
8117
+ return { data: null, formatted: "", type: "unknown", action: "transform", error: "Could not read/parse file" };
8118
+ }
8119
+ } else if (input.data) {
8120
+ try {
8121
+ parsed = JSON.parse(input.data);
8122
+ } catch {
8123
+ return { data: null, formatted: "", type: "unknown", action: "transform", error: "Could not parse data string" };
8124
+ }
8125
+ } else {
8126
+ return { data: null, formatted: "", type: "unknown", action: "transform", error: "Provide file or data" };
8127
+ }
8128
+ try {
8129
+ let current = parsed;
8130
+ const steps = [];
8131
+ for (const t of input.transforms) {
8132
+ current = jmespathSearch(current, t);
8133
+ steps.push({ transform: t, result: current });
8134
+ }
8135
+ const format = input.format ?? "json";
8136
+ return {
8137
+ data: parsed,
8138
+ formatted: formatOutput(current, format),
8139
+ type: current === null ? "null" : Array.isArray(current) ? "array" : typeof current,
8140
+ action: "transform",
8141
+ result: current,
8142
+ steps
8143
+ };
8144
+ } catch (e) {
8145
+ return {
8146
+ data: null,
8147
+ formatted: "",
8148
+ type: "unknown",
8149
+ action: "transform",
8150
+ /* v8 ignore next -- defensive String(e) */
8151
+ error: `Transform failed: ${e instanceof Error ? e.message : String(e)}`
8152
+ };
8153
+ }
8154
+ }
8155
+ async function executeMerge(input) {
8156
+ if (input.base === void 0 || input.patch === void 0) {
8157
+ return { data: null, formatted: "", type: "unknown", action: "merge", error: "base and patch are required for action: merge" };
8158
+ }
8159
+ const conflictResolution = input.conflictResolution ?? "prefer-patch";
8160
+ try {
8161
+ const result = deepMerge(input.base, input.patch, { conflictResolution });
8162
+ const format = input.format ?? "json";
8163
+ return {
8164
+ data: result,
8165
+ formatted: formatOutput(result, format),
8166
+ type: result === null ? "null" : Array.isArray(result) ? "array" : typeof result,
8167
+ action: "merge",
8168
+ result
8169
+ };
8170
+ } catch (e) {
8171
+ return {
8172
+ data: null,
8173
+ formatted: "",
8174
+ type: "unknown",
8175
+ action: "merge",
8176
+ /* v8 ignore next -- defensive String(e) */
8177
+ error: `Merge failed: ${e instanceof Error ? e.message : String(e)}`
8178
+ };
8179
+ }
8180
+ }
8181
+ function jmespathSearch(data, query) {
8182
+ if (!query || query === "@") return data;
8183
+ if (query === "$") return data;
8184
+ const dotMatch = query.match(/^([a-zA-Z_][a-zA-Z0-9_]*)(?:\.(.+))?$/);
8185
+ if (dotMatch) {
8186
+ const key = dotMatch[1];
8187
+ const rest = dotMatch[2];
8188
+ const val = data?.[key];
8189
+ if (rest === void 0) return val;
8190
+ return jmespathSearch(val, rest);
8191
+ }
8192
+ const arrMatch = query.match(/^\[(\d+)\](?:\.(.+))?$/);
8193
+ if (arrMatch) {
8194
+ const idx = Number.parseInt(arrMatch[1], 10);
8195
+ const rest = arrMatch[2];
8196
+ const arr = data;
8197
+ const val = arr?.[idx];
8198
+ if (rest === void 0) return val;
8199
+ return jmespathSearch(val, rest);
8200
+ }
8201
+ if (query === "[*]") {
8202
+ if (Array.isArray(data)) {
8203
+ return data;
8204
+ }
8205
+ return data;
8206
+ }
8207
+ const multiMatch = query.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[\*\](?:\.(.+))?$/);
8208
+ if (multiMatch) {
8209
+ const key = multiMatch[1];
8210
+ const rest = multiMatch[2];
8211
+ const arr = data?.[key];
8212
+ if (!Array.isArray(arr)) return [];
8213
+ if (rest === void 0) return arr;
8214
+ return arr.map((item) => jmespathSearch(item, rest));
8215
+ }
8216
+ const filterMatch = query.match(/^\[\\?([a-zA-Z_][a-zA-Z0-9_]*)(==|!=|<|>|<=|>=)(`[^`]+`|'[^']*')\](?:\.(.+))?$/);
8217
+ if (filterMatch) {
8218
+ const field = filterMatch[1];
8219
+ const op = filterMatch[2];
8220
+ const rawVal = filterMatch[3];
8221
+ const rest = filterMatch[4];
8222
+ const cmpVal = JSON.parse(rawVal.slice(1, -1));
8223
+ const arr = data;
8224
+ if (!Array.isArray(arr)) return [];
8225
+ const filtered = arr.filter((item) => {
8226
+ const itemVal = item[field];
8227
+ switch (op) {
8228
+ case "==":
8229
+ return itemVal === cmpVal;
8230
+ case "!=":
8231
+ return itemVal !== cmpVal;
8232
+ case ">":
8233
+ return Number(itemVal) > Number(cmpVal);
8234
+ case "<":
8235
+ return Number(itemVal) < Number(cmpVal);
8236
+ case ">=":
8237
+ return Number(itemVal) >= Number(cmpVal);
8238
+ case "<=":
8239
+ return Number(itemVal) <= Number(cmpVal);
8240
+ /* v8 ignore next -- op is constrained to the six operators by the filter regex; default is unreachable. */
8241
+ default:
8242
+ return true;
8243
+ }
8244
+ });
8245
+ if (rest === void 0) return filtered;
8246
+ return filtered.map((item) => jmespathSearch(item, rest));
8247
+ }
8248
+ const fnMatch = query.match(/^(length|keys|values|type)\(@\)$/);
8249
+ if (fnMatch) {
8250
+ const fn = fnMatch[1];
8251
+ switch (fn) {
8252
+ case "length":
8253
+ if (Array.isArray(data)) return data.length;
8254
+ if (typeof data === "string") return data.length;
8255
+ if (typeof data === "object" && data !== null) return Object.keys(data).length;
8256
+ return 0;
8257
+ case "keys":
8258
+ if (typeof data === "object" && data !== null && !Array.isArray(data)) return Object.keys(data);
8259
+ return [];
8260
+ case "values":
8261
+ if (typeof data === "object" && data !== null && !Array.isArray(data)) return Object.values(data);
8262
+ return [];
8263
+ case "type":
8264
+ if (data === null) return "null";
8265
+ if (Array.isArray(data)) return "array";
8266
+ return typeof data;
8267
+ /* v8 ignore next 2 -- fn is constrained to the four names by the function regex; default is unreachable. */
8268
+ default:
8269
+ return null;
8270
+ }
8271
+ }
8272
+ return null;
8273
+ }
8274
+ function validateJsonSchema(data, schema) {
8275
+ const errors = [];
8276
+ function check(value, s, path22) {
8277
+ if (s["type"]) {
8278
+ const expectedType = s["type"];
8279
+ const actualType = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
8280
+ if (expectedType === "integer") {
8281
+ if (!Number.isInteger(value)) errors.push(`${path22}: expected integer, got ${actualType}`);
8282
+ } else if (expectedType !== actualType) {
8283
+ errors.push(`${path22}: expected ${expectedType}, got ${actualType}`);
8284
+ }
8285
+ }
8286
+ if (typeof value === "string" && s["format"] === "uri" && value) {
8287
+ try {
8288
+ new URL(value);
8289
+ } catch {
8290
+ errors.push(`${path22}: not a valid URI`);
8291
+ }
8292
+ }
8293
+ if (typeof value === "string" && s["pattern"]) {
8294
+ const re = new RegExp(s["pattern"]);
8295
+ if (!re.test(value)) errors.push(`${path22}: does not match pattern ${s["pattern"]}`);
8296
+ }
8297
+ if (typeof value === "string" && s["minLength"] !== void 0 && value.length < s["minLength"]) {
8298
+ errors.push(`${path22}: string too short (min ${s["minLength"]})`);
8299
+ }
8300
+ if (typeof value === "string" && s["maxLength"] !== void 0 && value.length > s["maxLength"]) {
8301
+ errors.push(`${path22}: string too long (max ${s["maxLength"]})`);
8302
+ }
8303
+ if (typeof value === "number" && s["minimum"] !== void 0 && value < s["minimum"]) {
8304
+ errors.push(`${path22}: below minimum ${s["minimum"]}`);
8305
+ }
8306
+ if (typeof value === "number" && s["maximum"] !== void 0 && value > s["maximum"]) {
8307
+ errors.push(`${path22}: above maximum ${s["maximum"]}`);
8308
+ }
8309
+ if (Array.isArray(value) && s["items"] && Array.isArray(s["items"])) {
8310
+ for (let i = 0; i < value.length; i++) {
8311
+ check(value[i], s["items"], `${path22}[${i}]`);
8312
+ }
8313
+ }
8314
+ if (typeof value === "object" && value !== null && !Array.isArray(value) && s["properties"]) {
8315
+ const props = s["properties"];
8316
+ for (const [k, propSchema] of Object.entries(props)) {
8317
+ check(value[k], propSchema, `${path22}.${k}`);
8318
+ }
8319
+ }
8320
+ }
8321
+ check(data, schema, "$");
8322
+ return { valid: errors.length === 0, errors };
8323
+ }
8324
+ function simpleQuery(data, path22) {
7668
8325
  const parts = path22.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
7669
8326
  let current = data;
7670
8327
  for (const part of parts) {
@@ -8027,8 +8684,8 @@ var outdatedTool = {
8027
8684
  // read-only, but `outdated` makes outbound HTTP calls to the
8028
8685
  // registry. The 'confirm' permission routes the call through the
8029
8686
  // tool.confirm_needed flow on every invocation. M-1 originally
8030
- // fixed four sibling tools (mcp_control, shellcheck, shellcheck_scan,
8031
- // web_search) but missed this one; applying the same contract here.
8687
+ // fixed four sibling tools (mcp_control, shellcheck, shellcheck (scan mode),
8688
+ // search) but missed this one; applying the same contract here.
8032
8689
  mutating: true,
8033
8690
  // Capability is outbound network — the tool only hits the package
8034
8691
  // registry over HTTP, never touches the filesystem or runs shell.
@@ -8542,19 +9199,49 @@ var readTool = {
8542
9199
  required: ["path"]
8543
9200
  },
8544
9201
  async execute(input, ctx) {
8545
- if (!input?.path) throw new Error("read: path is required");
9202
+ if (!input?.path) {
9203
+ throw new ToolValidationError({
9204
+ message: "read: path is required",
9205
+ field: "path"
9206
+ });
9207
+ }
8546
9208
  const absPath = await safeResolveReal(input.path, ctx);
8547
9209
  let stat11;
8548
9210
  try {
8549
9211
  stat11 = await fs2.stat(absPath);
8550
9212
  } catch (err) {
8551
9213
  const code = err.code;
8552
- if (code === "ENOENT") throw new Error(`read: file not found "${input.path}"`);
8553
- throw new Error(`read: failed to stat "${input.path}": ${toErrorMessage$2(err)}`);
9214
+ if (code === "ENOENT") {
9215
+ throw new FsError({
9216
+ message: `read: file not found "${input.path}"`,
9217
+ code: "FS_READ_FAILED",
9218
+ path: absPath,
9219
+ context: { errno: "ENOENT" }
9220
+ });
9221
+ }
9222
+ throw new FsError({
9223
+ message: `read: failed to stat "${input.path}": ${toErrorMessage$2(err)}`,
9224
+ code: "FS_READ_FAILED",
9225
+ path: absPath,
9226
+ context: { errno: code },
9227
+ cause: err
9228
+ });
9229
+ }
9230
+ if (!stat11.isFile()) {
9231
+ throw new FsError({
9232
+ message: `read: "${input.path}" is not a regular file`,
9233
+ code: "FS_READ_FAILED",
9234
+ path: absPath,
9235
+ context: { reason: "not-a-regular-file" }
9236
+ });
8554
9237
  }
8555
- if (!stat11.isFile()) throw new Error(`read: "${input.path}" is not a regular file`);
8556
9238
  if (stat11.size > MAX_BYTES2) {
8557
- throw new Error(`read: file too large (${stat11.size} bytes, limit ${MAX_BYTES2})`);
9239
+ throw new FsError({
9240
+ message: `read: file too large (${stat11.size} bytes, limit ${MAX_BYTES2})`,
9241
+ code: "FS_READ_FAILED",
9242
+ path: absPath,
9243
+ context: { size: stat11.size, limit: MAX_BYTES2, reason: "too-large" }
9244
+ });
8558
9245
  }
8559
9246
  const offset = Math.max(1, input.offset ?? 1);
8560
9247
  const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
@@ -8678,8 +9365,8 @@ var DEFAULT_IGNORE4 = ["node_modules", ".git", "dist", "build", ".next", "covera
8678
9365
  var replaceTool = {
8679
9366
  name: "replace",
8680
9367
  category: "Transform",
8681
- description: "Perform a search-and-replace across multiple files using a regex pattern. This is a powerful bulk transformation tool. Always use `dry_run: true` first on anything non-trivial.",
8682
- usageHint: "DANGEROUS IF USED CARELESSLY \u2014 review the diff output carefully.\n\nRecommended workflow:\n1. Start with `dry_run: true` to see exactly what would change.\n2. Use a specific enough `pattern` (and `glob` / `files`) to avoid accidental broad changes.\n3. `replace_all` controls whether only the first match per file or all matches are replaced.\nThis tool is excellent for large-scale refactors (renaming, import updates, etc.) but must be used with caution.",
9368
+ description: "Perform a search-and-replace across multiple files using a regex pattern. This is a powerful bulk transformation tool. Dry-run is ON by default \u2014 set `dry_run: false` to apply changes.",
9369
+ usageHint: "DANGEROUS IF USED CARELESSLY \u2014 review the diff output carefully.\n\nRecommended workflow:\n1. Run without `dry_run: false` first to see exactly what would change (dry-run is the default).\n2. Review the diff output, then re-run with `dry_run: false` to apply.\n3. Use a specific enough `pattern` (and `glob` / `files`) to avoid accidental broad changes.\n4. `replace_all` controls whether only the first match per file or all matches are replaced.\nThis tool is excellent for large-scale refactors (renaming, import updates, etc.) but must be used with caution.",
8683
9370
  permission: "confirm",
8684
9371
  mutating: true,
8685
9372
  capabilities: ["fs.write"],
@@ -8699,22 +9386,40 @@ var replaceTool = {
8699
9386
  type: "boolean",
8700
9387
  description: "Replace all occurrences in each file (default: true)"
8701
9388
  },
8702
- dry_run: { type: "boolean", description: "Preview changes without writing" }
9389
+ dry_run: { type: "boolean", description: "Preview changes without writing (default: true)" }
8703
9390
  },
8704
9391
  required: ["pattern", "replacement", "files"]
8705
9392
  },
8706
9393
  async execute(input, ctx) {
8707
- if (!input?.pattern) throw new Error("replace: pattern is required");
8708
- if (input.replacement === void 0) throw new Error("replace: replacement is required");
8709
- if (!input?.files) throw new Error("replace: files is required");
9394
+ if (!input?.pattern) {
9395
+ throw new ToolValidationError({
9396
+ message: "replace: pattern is required",
9397
+ field: "pattern"
9398
+ });
9399
+ }
9400
+ if (input.replacement === void 0) {
9401
+ throw new ToolValidationError({
9402
+ message: "replace: replacement is required",
9403
+ field: "replacement"
9404
+ });
9405
+ }
9406
+ if (!input?.files) {
9407
+ throw new ToolValidationError({
9408
+ message: "replace: files is required",
9409
+ field: "files"
9410
+ });
9411
+ }
8710
9412
  const replaceAll = input.replace_all ?? true;
8711
9413
  const compiled = compileUserRegex(input.pattern, "g");
8712
9414
  if (!compiled.ok) {
8713
- throw new Error(`replace: ${compiled.reason}`);
9415
+ throw new ToolValidationError({
9416
+ message: `replace: ${compiled.reason}`,
9417
+ field: "pattern"
9418
+ });
8714
9419
  }
8715
9420
  const re = compiled.regex;
8716
9421
  const globRe = input.glob ? compileGlob(input.glob) : null;
8717
- const dryRun = input.dry_run ?? false;
9422
+ const dryRun = input.dry_run ?? true;
8718
9423
  const filesInput = Array.isArray(input.files) ? input.files.join(",") : input.files;
8719
9424
  const fileList = await resolveFiles2(filesInput, ctx, globRe);
8720
9425
  const realRoot = await fs2.realpath(ctx.projectRoot).catch(() => ctx.projectRoot);
@@ -9063,11 +9768,13 @@ function substituteVars(content, name, vars) {
9063
9768
  var DEFAULT_NUM = 10;
9064
9769
  var MAX_RESULTS = 50;
9065
9770
  var TIMEOUT_MS3 = 15e3;
9771
+ var CACHE_TTL_MS = 3e5;
9772
+ var cache = /* @__PURE__ */ new Map();
9066
9773
  var searchTool = {
9067
9774
  name: "search",
9068
9775
  category: "Search",
9069
- description: "Perform a web search and return results with title, URL, and snippet. Use this when you need up-to-date external information that is not in the local codebase.",
9070
- usageHint: "Good for: API documentation, error messages, library usage examples, current best practices.\n\n- Prefer specific queries over very broad ones.\n- Results go through the guarded fetch system (same protections as the `fetch` tool).\n- This is often better than the model trying to recall outdated knowledge.",
9776
+ description: "Perform a web search and return results with title, URL, and snippet. Use this when you need up-to-date external information that is not in the local codebase. Results are cached (5 min TTL) and deduplicated by URL.",
9777
+ usageHint: "Good for: API documentation, error messages, library usage examples, current best practices.\n\n- Prefer specific queries over very broad ones.\n- Results go through the guarded fetch system (same protections as the `fetch` tool).\n- Supports duckduckgo (default), google, and bing sources.\n- Set `skip_cache: true` to force a fresh search.\n- This is often better than the model trying to recall outdated knowledge.",
9071
9778
  permission: "confirm",
9072
9779
  mutating: false,
9073
9780
  capabilities: ["net.outbound"],
@@ -9087,6 +9794,10 @@ var searchTool = {
9087
9794
  type: "string",
9088
9795
  enum: ["duckduckgo", "google", "bing"],
9089
9796
  description: "Search engine to use (default: duckduckgo)"
9797
+ },
9798
+ skip_cache: {
9799
+ type: "boolean",
9800
+ description: "Skip the in-memory cache and force a fresh search (default: false)"
9090
9801
  }
9091
9802
  },
9092
9803
  required: ["query"]
@@ -9102,57 +9813,135 @@ var searchTool = {
9102
9813
  return final;
9103
9814
  },
9104
9815
  async *executeStream(input, _ctx, opts) {
9105
- if (!input?.query) throw new Error("search: query is required");
9816
+ if (!input?.query || input.query.trim() === "") {
9817
+ throw new ToolValidationError({
9818
+ message: "search: query is required and must be a non-empty string",
9819
+ field: "query"
9820
+ });
9821
+ }
9106
9822
  const num = Math.max(1, Math.min(input.num_results ?? DEFAULT_NUM, MAX_RESULTS));
9107
9823
  const source = input.source ?? "duckduckgo";
9824
+ const skipCache = input.skip_cache ?? false;
9825
+ const cacheKey = `${source}:${input.query}`;
9826
+ if (!skipCache) {
9827
+ const entry = cache.get(cacheKey);
9828
+ if (entry && Date.now() - entry.timestamp < CACHE_TTL_MS) {
9829
+ const results = entry.results.map((r) => ({
9830
+ title: r.title,
9831
+ url: r.url,
9832
+ snippet: r.snippet
9833
+ }));
9834
+ yield {
9835
+ type: "log",
9836
+ text: `Cache hit for "${input.query}" (${source})`,
9837
+ data: { source, query: input.query, cached: true }
9838
+ };
9839
+ yield {
9840
+ type: "partial_output",
9841
+ text: `${results.length} cached results from ${source}`,
9842
+ data: { count: results.length, cached: true }
9843
+ };
9844
+ yield {
9845
+ type: "final",
9846
+ output: {
9847
+ query: input.query,
9848
+ results: results.slice(0, num),
9849
+ source,
9850
+ truncated: results.length >= num,
9851
+ cached: true
9852
+ }
9853
+ };
9854
+ return;
9855
+ }
9856
+ }
9108
9857
  yield {
9109
9858
  type: "log",
9110
9859
  text: `Querying ${source} for "${input.query}"\u2026`,
9111
- data: { source, query: input.query }
9860
+ data: { source, query: input.query, cached: false }
9112
9861
  };
9113
- let output;
9862
+ let rawResults;
9114
9863
  switch (source) {
9115
9864
  case "duckduckgo":
9116
- output = await duckduckgoSearch(input.query, num, opts.signal);
9865
+ rawResults = await duckduckgoSearch(input.query, num, opts.signal);
9117
9866
  break;
9118
9867
  case "google":
9119
- output = await googleSearch(input.query, num, opts.signal);
9868
+ rawResults = await googleSearch(input.query, num, opts.signal);
9120
9869
  break;
9121
9870
  case "bing":
9122
- output = await bingSearch(input.query, num, opts.signal);
9871
+ rawResults = await bingSearch(input.query, num, opts.signal);
9123
9872
  break;
9124
9873
  default:
9125
- throw new Error(`search: unknown source "${source}"`);
9874
+ throw new ToolValidationError({
9875
+ message: `search: unknown source "${source}"`,
9876
+ field: "source"
9877
+ });
9878
+ }
9879
+ const seenUrls = /* @__PURE__ */ new Set();
9880
+ const deduped = [];
9881
+ for (const r of rawResults) {
9882
+ const noQuery = r.url.split("?")[0] ?? r.url;
9883
+ const normalized = noQuery.split("#")[0] ?? r.url;
9884
+ if (!seenUrls.has(normalized) && r.url.startsWith("http")) {
9885
+ seenUrls.add(normalized);
9886
+ deduped.push(r);
9887
+ }
9126
9888
  }
9889
+ const ranked = scoreResults(deduped, input.query);
9890
+ const finalResults = ranked.slice(0, num);
9891
+ cache.set(cacheKey, { results: ranked, timestamp: Date.now() });
9892
+ pruneStaleCacheEntries();
9127
9893
  yield {
9128
9894
  type: "partial_output",
9129
- text: `${output.results.length} results from ${output.source}`,
9130
- data: { count: output.results.length }
9895
+ text: `${finalResults.length} results from ${source}`,
9896
+ data: { count: finalResults.length, cached: false }
9897
+ };
9898
+ yield {
9899
+ type: "final",
9900
+ output: {
9901
+ query: input.query,
9902
+ results: finalResults.map((r) => ({
9903
+ title: r.title,
9904
+ url: r.url,
9905
+ snippet: r.snippet
9906
+ })),
9907
+ source,
9908
+ truncated: finalResults.length >= num,
9909
+ cached: false
9910
+ }
9131
9911
  };
9132
- yield { type: "final", output };
9133
9912
  }
9134
9913
  };
9135
- async function duckduckgoSearch(query2, num, signal) {
9136
- const encoded = encodeURIComponent(query2);
9914
+ function pruneStaleCacheEntries() {
9915
+ const cutoff = Date.now() - CACHE_TTL_MS * 2;
9916
+ for (const [key, entry] of cache.entries()) {
9917
+ if (entry.timestamp < cutoff) cache.delete(key);
9918
+ }
9919
+ }
9920
+ function scoreResults(results, query) {
9921
+ const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
9922
+ return results.map((r) => {
9923
+ const titleLower = r.title.toLowerCase();
9924
+ const snippetLower = r.snippet.toLowerCase();
9925
+ let score = r.score;
9926
+ for (const term of terms) {
9927
+ if (titleLower.includes(term)) score += 2;
9928
+ if (snippetLower.includes(term)) score += 1;
9929
+ }
9930
+ return { ...r, score };
9931
+ }).sort((a, b) => b.score - a.score);
9932
+ }
9933
+ async function duckduckgoSearch(query, num, signal) {
9934
+ const encoded = encodeURIComponent(query);
9137
9935
  const url = `https://lite.duckduckgo.com/lite/?q=${encoded}&kd=-1&kl=wt-wt`;
9138
9936
  try {
9139
9937
  const response = await fetchWithTimeout(url, signal, TIMEOUT_MS3);
9140
9938
  const html = await response.text();
9141
- const results = parseDuckDuckGo(html, num);
9142
- return {
9143
- query: query2,
9144
- results,
9145
- source: "duckduckgo",
9146
- truncated: results.length >= num
9147
- };
9939
+ return parseDuckDuckGo(html, num);
9148
9940
  } catch (err) {
9149
- console.log(JSON.stringify({ level: "debug", event: "search_failed", query: query2, error: toErrorMessage$3(err) }));
9150
- return {
9151
- query: query2,
9152
- results: [{ title: "Search unavailable", url: "", snippet: "Could not reach DuckDuckGo" }],
9153
- source: "duckduckgo",
9154
- truncated: false
9155
- };
9941
+ console.log(
9942
+ JSON.stringify({ level: "debug", event: "search_failed", query, error: toErrorMessage$3(err) })
9943
+ );
9944
+ return [{ title: "Search unavailable", url: "https://duckduckgo.com/unavailable", snippet: "Could not reach DuckDuckGo", score: 0 }];
9156
9945
  }
9157
9946
  }
9158
9947
  function takeFrom(iter, max) {
@@ -9177,25 +9966,22 @@ function parseDuckDuckGo(html, num) {
9177
9966
  );
9178
9967
  for (let i = 0; i < linkMatches.length && i < num; i++) {
9179
9968
  const entry = linkMatches[i];
9180
- results.push({
9181
- title: entry?.title ?? "",
9182
- url: entry?.url ?? "",
9183
- snippet: snippetMatches[i] ?? ""
9184
- });
9969
+ if (entry) {
9970
+ results.push({
9971
+ title: entry.title ?? "",
9972
+ url: entry.url ?? "",
9973
+ snippet: snippetMatches[i] ?? "",
9974
+ score: 1
9975
+ });
9976
+ }
9185
9977
  }
9186
9978
  return results;
9187
9979
  }
9188
- async function googleSearch(query2, num, signal) {
9189
- const encoded = encodeURIComponent(query2);
9980
+ async function googleSearch(query, num, signal) {
9981
+ const encoded = encodeURIComponent(query);
9190
9982
  const url = `https://www.google.com/search?q=${encoded}&hl=en`;
9191
9983
  const html = await fetchWithTimeout(url, signal, TIMEOUT_MS3).then((r) => r.text()).catch(() => "");
9192
- const results = parseGoogleResults(html, num);
9193
- return {
9194
- query: query2,
9195
- results,
9196
- source: "google",
9197
- truncated: results.length >= num
9198
- };
9984
+ return parseGoogleResults(html, num);
9199
9985
  }
9200
9986
  function parseGoogleResults(html, num) {
9201
9987
  const results = [];
@@ -9218,22 +10004,17 @@ function parseGoogleResults(html, num) {
9218
10004
  results.push({
9219
10005
  title: titles[i] ?? "",
9220
10006
  url: urls[i] ?? "",
9221
- snippet: snippets[i] ?? ""
10007
+ snippet: snippets[i] ?? "",
10008
+ score: 1
9222
10009
  });
9223
10010
  }
9224
10011
  return results;
9225
10012
  }
9226
- async function bingSearch(query2, num, signal) {
9227
- const encoded = encodeURIComponent(query2);
10013
+ async function bingSearch(query, num, signal) {
10014
+ const encoded = encodeURIComponent(query);
9228
10015
  const url = `https://www.bing.com/search?q=${encoded}`;
9229
10016
  const html = await fetchWithTimeout(url, signal, TIMEOUT_MS3).then((r) => r.text()).catch(() => "");
9230
- const results = parseBingResults(html, num);
9231
- return {
9232
- query: query2,
9233
- results,
9234
- source: "bing",
9235
- truncated: results.length >= num
9236
- };
10017
+ return parseBingResults(html, num);
9237
10018
  }
9238
10019
  function parseBingResults(html, num) {
9239
10020
  const results = [];
@@ -9248,11 +10029,15 @@ function parseBingResults(html, num) {
9248
10029
  num
9249
10030
  );
9250
10031
  for (let i = 0; i < entries.length; i++) {
9251
- results.push({
9252
- title: entries[i]?.title ?? "",
9253
- url: entries[i]?.url ?? "",
9254
- snippet: snippets[i] ?? ""
9255
- });
10032
+ const entry = entries[i];
10033
+ if (entry) {
10034
+ results.push({
10035
+ title: entry.title ?? "",
10036
+ url: entry.url ?? "",
10037
+ snippet: snippets[i] ?? "",
10038
+ score: 1
10039
+ });
10040
+ }
9256
10041
  }
9257
10042
  return results;
9258
10043
  }
@@ -9268,7 +10053,15 @@ async function fetchWithTimeout(url, signal, timeoutMs) {
9268
10053
  return res;
9269
10054
  } catch (e) {
9270
10055
  clearTimeout(timer);
9271
- throw e;
10056
+ if (e instanceof FetchError) {
10057
+ throw e;
10058
+ }
10059
+ throw new FetchError({
10060
+ message: `search: failed to fetch ${url}`,
10061
+ status: 0,
10062
+ context: { url },
10063
+ cause: e
10064
+ });
9272
10065
  }
9273
10066
  }
9274
10067
  function anySignal(...signals) {
@@ -9332,15 +10125,15 @@ var setWorkingDirTool = {
9332
10125
  };
9333
10126
  }
9334
10127
  };
9335
- function findTaskIndex(tasks, query2) {
9336
- const asNum = Number.parseInt(query2, 10);
10128
+ function findTaskIndex(tasks, query) {
10129
+ const asNum = Number.parseInt(query, 10);
9337
10130
  if (!Number.isNaN(asNum)) {
9338
10131
  const idx = asNum - 1;
9339
10132
  if (tasks[idx]) return idx;
9340
10133
  }
9341
- const byId = tasks.findIndex((t) => t.id === query2);
10134
+ const byId = tasks.findIndex((t) => t.id === query);
9342
10135
  if (byId >= 0) return byId;
9343
- const lower = query2.toLowerCase();
10136
+ const lower = query.toLowerCase();
9344
10137
  return tasks.findIndex((t) => t.title.toLowerCase().includes(lower));
9345
10138
  }
9346
10139
  var taskTool = {
@@ -9964,8 +10757,8 @@ var todoTool = {
9964
10757
  var toolHelpTool = {
9965
10758
  name: "tool_help",
9966
10759
  category: "Meta",
9967
- description: "Get detailed help for one or more tools, including their full schema and usage guidance. This is the best way to understand exactly how to call a specific tool.",
9968
- usageHint: "USE WHEN YOU NEED PRECISE TOOL INFORMATION:\n\n- Call with a specific `tool` name when you want the full schema and current usageHint.\n- Omit `tool` (or use a broad query) to get an overview of available tools.\n- Different `format` options give you different levels of detail.\nThis tool is extremely valuable for self-correction when you are unsure about a tool's interface.",
10760
+ description: "Get detailed help for a specific tool, including its full input schema and usage guidance. If you do not know which tool to use, search with `tool_search` first, then call this with the tool name.",
10761
+ usageHint: "USE WHEN YOU NEED PRECISE TOOL INFORMATION:\n\n- Call with a specific `tool` name when you want the full schema and current usageHint.\n- Omit `tool` to get an overview of all available tools.\n- Different `format` options give you different levels of detail.\n- Tip: use `tool_search` to find the right tool name, then `tool_help` for the full schema.\nThis tool is extremely valuable for self-correction when you are unsure about a tool's interface.",
9969
10762
  permission: "auto",
9970
10763
  mutating: false,
9971
10764
  timeoutMs: 5e3,
@@ -10088,8 +10881,8 @@ function formatAllToolsMarkdown(tools) {
10088
10881
  var toolSearchTool = {
10089
10882
  name: "tool_search",
10090
10883
  category: "Meta",
10091
- description: "Search the catalog of available tools. Very useful when you are unsure which tool to use for a task.",
10092
- usageHint: "SELF-DISCOVERY TOOL:\n\n- Use when you need to find the right tool for a job.\n- `query` searches names and descriptions.\n- You can filter by `tags` (category), `permission`, or `mutating`.\nCall this before guessing tool names. It helps you discover the best tool for the current situation.",
10884
+ description: "Search the catalog of available tools by name or description. Use this to discover which tool to use for a task. For the full schema and usage details of a specific tool, use `tool_help` instead.",
10885
+ usageHint: "SELF-DISCOVERY TOOL:\n\n- Use when you need to find the right tool for a job.\n- `query` searches names and descriptions.\n- You can filter by `tags` (category), `permission`, or `mutating`.\n- Once you find the right tool name, use `tool_help` with that name for full schema details.\nCall this before guessing tool names. It helps you discover the best tool for the current situation.",
10093
10886
  permission: "auto",
10094
10887
  mutating: false,
10095
10888
  timeoutMs: 1e3,
@@ -10127,9 +10920,9 @@ var toolSearchTool = {
10127
10920
  async execute(input, ctx) {
10128
10921
  const limit = Math.min(input.limit ?? 20, 100);
10129
10922
  const tools = ctx.tools;
10130
- const query2 = input.query?.toLowerCase() ?? "";
10923
+ const query = input.query?.toLowerCase() ?? "";
10131
10924
  const filtered = tools.filter((t) => {
10132
- if (query2 && !t.name.toLowerCase().includes(query2) && !t.description.toLowerCase().includes(query2)) {
10925
+ if (query && !t.name.toLowerCase().includes(query) && !t.description.toLowerCase().includes(query)) {
10133
10926
  return false;
10134
10927
  }
10135
10928
  if (input.tags && input.tags.length > 0) {
@@ -10153,7 +10946,7 @@ var toolSearchTool = {
10153
10946
  mutating: t.mutating
10154
10947
  }));
10155
10948
  const totalAvailable = tools.length;
10156
- const hint = results.length === 0 && query2 ? `No tools matched "${input.query}". Use tool-help (without arguments) to see all ${totalAvailable} available tools.` : void 0;
10949
+ const hint = results.length === 0 && query ? `No tools matched "${input.query}". Use tool-help (without arguments) to see all ${totalAvailable} available tools.` : void 0;
10157
10950
  return {
10158
10951
  tools: results,
10159
10952
  total: filtered.length,
@@ -10526,8 +11319,18 @@ var writeTool = {
10526
11319
  required: ["path", "content"]
10527
11320
  },
10528
11321
  async execute(input, ctx) {
10529
- if (!input?.path) throw new Error("write: path is required");
10530
- if (input.content === void 0) throw new Error("write: content is required");
11322
+ if (!input?.path) {
11323
+ throw new ToolValidationError({
11324
+ message: "write: path is required",
11325
+ field: "path"
11326
+ });
11327
+ }
11328
+ if (input.content === void 0) {
11329
+ throw new ToolValidationError({
11330
+ message: "write: content is required",
11331
+ field: "content"
11332
+ });
11333
+ }
10531
11334
  const absPath = await safeResolveReal(input.path, ctx);
10532
11335
  let existed = false;
10533
11336
  let prev = "";