@wrongstack/tools 0.275.1 → 0.276.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{background-indexer-BoTUw0EM.d.ts → background-indexer-BeDBxfSh.d.ts} +6 -0
- package/dist/builtin.js +1056 -246
- package/dist/builtin.js.map +1 -1
- package/dist/codebase-index/index.d.ts +30 -2
- package/dist/codebase-index/index.js +201 -24
- package/dist/codebase-index/index.js.map +1 -1
- package/dist/codebase-index/worker.js +196 -23
- package/dist/codebase-index/worker.js.map +1 -1
- package/dist/document.js +2 -2
- package/dist/document.js.map +1 -1
- package/dist/edit.js +52 -15
- package/dist/edit.js.map +1 -1
- package/dist/fetch.js +89 -18
- package/dist/fetch.js.map +1 -1
- package/dist/glob.js +35 -1
- package/dist/glob.js.map +1 -1
- package/dist/grep.js +15 -4
- package/dist/grep.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1090 -255
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +7 -0
- package/dist/install.js +6 -0
- package/dist/install.js.map +1 -1
- package/dist/json.d.ts +26 -1
- package/dist/json.js +453 -46
- package/dist/json.js.map +1 -1
- package/dist/memory.js +26 -4
- package/dist/memory.js.map +1 -1
- package/dist/outdated.js +2 -2
- package/dist/outdated.js.map +1 -1
- package/dist/pack.js +1056 -246
- package/dist/pack.js.map +1 -1
- package/dist/read.js +36 -6
- package/dist/read.js.map +1 -1
- package/dist/replace.js +27 -9
- package/dist/replace.js.map +1 -1
- package/dist/search.d.ts +5 -1
- package/dist/search.js +179 -62
- package/dist/search.js.map +1 -1
- package/dist/tool-help.js +2 -2
- package/dist/tool-help.js.map +1 -1
- package/dist/tool-search.js +2 -2
- package/dist/tool-search.js.map +1 -1
- package/dist/write.js +13 -3
- package/dist/write.js.map +1 -1
- package/package.json +2 -2
package/dist/document.js
CHANGED
|
@@ -29,8 +29,8 @@ function safeResolve(input, ctx) {
|
|
|
29
29
|
var documentTool = {
|
|
30
30
|
name: "document",
|
|
31
31
|
category: "Project",
|
|
32
|
-
description: "
|
|
33
|
-
usageHint: "
|
|
32
|
+
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.",
|
|
33
|
+
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.",
|
|
34
34
|
permission: "auto",
|
|
35
35
|
mutating: false,
|
|
36
36
|
timeoutMs: 3e4,
|
package/dist/document.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/_util.ts","../src/document.ts"],"names":["stat"],"mappings":"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","file":"document.js","sourcesContent":["import * as fsp from 'node:fs/promises';\nimport * as path from 'node:path';\nimport * as Core from '@wrongstack/core';\nimport type { Context } from '@wrongstack/core';\n/** Detected package manager for a project directory. */\nexport type PackageManager = 'pnpm' | 'yarn' | 'npm';\n\n/**\n * Detect the project's package manager by inspecting lockfiles in `cwd`.\n * Order: pnpm → yarn → npm (default). Missing or unreadable directories fall\n * back to `npm` rather than throwing, so a `safeResolve`-checked cwd that\n * happens to be empty never aborts the tool.\n */\nexport async function detectPackageManager(cwd: string): Promise<PackageManager> {\n const { stat } = await import('node:fs/promises');\n try {\n await stat(`${cwd}/pnpm-lock.yaml`);\n return 'pnpm';\n } catch {\n /* not pnpm */\n }\n try {\n await stat(`${cwd}/yarn.lock`);\n return 'yarn';\n } catch {\n /* not yarn */\n }\n return 'npm';\n}\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);\n}\n\n/**\n * Roots every file tool may always reach, even in restricted mode: the\n * project root and the user-global `~/.wrongstack` directory (config, memory,\n * sessions, skills). `~/.wrongstack` honors the `WRONGSTACK_HOME` override.\n */\nfunction allowedRoots(ctx: Context): string[] {\n return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];\n}\n\n/** True if `target` is `root` itself or nested inside any of `roots`. */\nfunction isInsideAny(target: string, roots: string[]): boolean {\n return roots.some((root) => {\n const rel = path.relative(root, target);\n return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));\n });\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n const target = path.resolve(absPath);\n // Unrestricted filesystem access: skip the project-root containment check.\n if (ctx.allowOutsideProjectRoot) return target;\n if (isInsideAny(target, allowedRoots(ctx))) return target;\n throw new Error(`Path \"${absPath}\" is outside project root \"${path.resolve(ctx.projectRoot)}\"`);\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\n/**\n * Defense against in-root→out-of-root symlink escape (CWE-59). `safeResolve`\n * only does a syntactic `../` check, so a symlink that lives *inside* the\n * project root but points outside still passes it. This resolves the path\n * through `fs.realpath` and re-verifies containment against the realpath of\n * the project root (comparing like-for-like, since the root itself may be a\n * symlink — macOS `/var`→`/private/var`, Windows 8.3 short names). For a path\n * that does not exist yet (e.g. a `write` to a new file) the nearest existing\n * ancestor directory is checked instead. Throws if the real target escapes.\n *\n * Mirrors the per-file guard already used in `replace.ts`/`grep.ts`; applied\n * to single-file `read`/`edit`/`write` it throws (rather than skips) because\n * the caller named exactly one file.\n */\nexport async function assertRealInsideRoot(absPath: string, ctx: Context): Promise<void> {\n // Unrestricted filesystem access: no symlink-escape check to perform.\n if (ctx.allowOutsideProjectRoot) return;\n // Compare like-for-like against the realpath of each always-allowed root\n // (project root + ~/.wrongstack), since a root may itself be a symlink.\n const realRoots = await Promise.all(\n allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r))),\n );\n let probe = absPath;\n for (;;) {\n let real: string;\n try {\n real = await fsp.realpath(probe);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n const parent = path.dirname(probe);\n if (parent === probe) return; // reached fs root without escaping\n probe = parent;\n continue;\n }\n throw err;\n }\n if (isInsideAny(real, realRoots)) return;\n throw new Error(\n `Path \"${absPath}\" resolves through a symlink outside project root \"${realRoots[0]}\"`,\n );\n }\n}\n\n/** `safeResolve` + symlink realpath containment check. Async. */\nexport async function safeResolveReal(input: string, ctx: Context): Promise<string> {\n const abs = safeResolve(input, ctx);\n await assertRealInsideRoot(abs, ctx);\n return abs;\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n…[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]…\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n\n// ─── Command-output normalization (token-saving) ────────────────────────────\n//\n// Raw process output is full of tokens the model gains nothing from: ANSI\n// escapes, carriage-return progress spam, runs of identical warning lines, and\n// huge tails of build noise. These helpers strip that noise before the output\n// reaches the LLM. They are scoped to COMMAND tools (bash/git/exec and the\n// _spawn-stream consumers) — never applied to structured/code outputs.\n\n/** Unified byte cap for all command tool output fed to the model. */\nexport const COMMAND_OUTPUT_MAX_BYTES = 32_768;\n\n/** Runs of >= this many identical consecutive lines are collapsed. */\nconst REPEAT_RUN_THRESHOLD = 3;\n\n/**\n * Collapse carriage-return overwrites the way a terminal would: `\\r\\n` becomes\n * `\\n`, and a bare `\\r` (progress redraw) keeps only the text after the LAST\n * `\\r` on its physical line. Without this, a single progress bar that redraws\n * 200 times explodes into 200 lines.\n */\nexport function collapseCarriageReturns(text: string): string {\n const lf = text.replace(/\\r\\n/g, '\\n');\n if (!lf.includes('\\r')) return lf;\n return lf\n .split('\\n')\n .map((line) => (line.includes('\\r') ? line.slice(line.lastIndexOf('\\r') + 1) : line))\n .join('\\n');\n}\n\n/**\n * Collapse a run of `minRun`+ identical consecutive lines into the line once\n * plus a marker. Consecutive-only — it never reorders or dedups non-adjacent\n * lines, so diffs/source stay intact.\n */\nexport function collapseConsecutiveDuplicates(text: string, minRun = REPEAT_RUN_THRESHOLD): string {\n const lines = text.split('\\n');\n const out: string[] = [];\n let i = 0;\n while (i < lines.length) {\n let j = i + 1;\n while (j < lines.length && lines[j] === lines[i]) j++;\n const run = j - i;\n if (run >= minRun) {\n out.push(lines[i]!, `… ⟨repeated ${run}×⟩`);\n } else {\n for (let k = i; k < j; k++) out.push(lines[k]!);\n }\n i = j;\n }\n return out.join('\\n');\n}\n\n/** Largest prefix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeHeadBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(0, mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(0, lo);\n}\n\n/** Largest suffix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeTailBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(s.length - mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(s.length - lo);\n}\n\n/**\n * Truncate to `maxBytes` keeping BOTH ends — the head (what ran / early context)\n * and the tail (errors and summaries usually land last), biased ~45/55 toward\n * the tail. The result never exceeds `maxBytes`.\n */\nexport function truncateHeadTail(s: string, maxBytes: number): string {\n const total = Buffer.byteLength(s, 'utf8');\n if (total <= maxBytes) return s;\n // Reserve a fixed allowance for the marker so the final string can't exceed\n // the cap even though the dropped-byte count's digit width varies.\n const MARKER_RESERVE = 64;\n const avail = Math.max(0, maxBytes - MARKER_RESERVE);\n const headBudget = Math.floor(avail * 0.45);\n const head = takeHeadBytes(s, headBudget);\n const tail = takeTailBytes(s, avail - Buffer.byteLength(head, 'utf8'));\n const kept = Buffer.byteLength(head, 'utf8') + Buffer.byteLength(tail, 'utf8');\n return `${head}\\n…[truncated ${total - kept} bytes]…\\n${tail}`;\n}\n\n/**\n * Full token-saving pipeline for command tool output: strip ANSI → collapse\n * carriage-return progress → trim trailing whitespace → collapse identical\n * consecutive lines → squeeze blank-line runs → head+tail truncate to the cap.\n */\nexport function normalizeCommandOutput(\n raw: string,\n opts: { maxBytes?: number | undefined } = {},\n): string {\n if (!raw) return raw;\n let text = Core.stripAnsi(raw);\n text = collapseCarriageReturns(text);\n text = text.replace(/[ \\t]+$/gm, ''); // trailing whitespace per line\n text = collapseConsecutiveDuplicates(text);\n text = text.replace(/\\n{3,}/g, '\\n\\n'); // >=2 blank lines → 1\n return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);\n}\n","import * as fs from 'node:fs/promises';\nimport type { Tool } from '@wrongstack/core';\nimport { safeResolve } from './_util.js';\n\ninterface DocumentInput {\n target: 'file' | 'function' | 'class' | 'type' | 'all';\n path?: string | undefined;\n files?: string | string[] | undefined;\n style?: 'jsdoc' | 'tsdoc' | 'block' | undefined;\n overwrite?: boolean | undefined;\n cwd?: string | undefined;\n}\n\ninterface DocumentedItem {\n path: string;\n name: string;\n signature: string;\n docstring: string;\n status: 'documented' | 'skipped' | 'error';\n error?: string | undefined;\n}\n\ninterface DocumentOutput {\n files_processed: number;\n items_documented: number;\n results: DocumentedItem[];\n style: string;\n}\n\nexport const documentTool: Tool<DocumentInput, DocumentOutput> = {\n name: 'document',\n category: 'Project',\n description:\n 'Preview documentation comments (JSDoc/TSDoc style) that would be generated for code symbols. ' +\n 'Returns a list of candidates with status `skipped` — the tool is currently a read-only preview and does NOT write to files.',\n usageHint:\n 'USE FOR IMPROVING CODE DOCUMENTATION:\\n\\n' +\n '- Good for adding missing docs to public APIs or complex functions.\\n' +\n '- Currently this is a PREVIEW-ONLY tool: it does not modify files.\\n' +\n '- Use the output to decide which symbols to document manually, or pass the candidates to `edit` / `patch`.\\n' +\n '- `overwrite`, `style`, and `target` parameters are accepted for future expansion but are ignored today.\\n' +\n 'Always review the proposed documentation before applying it — the model can hallucinate details.',\n permission: 'auto',\n mutating: false,\n timeoutMs: 30_000,\n capabilities: ['fs.read'],\n icon: 'document',\n inputSchema: {\n type: 'object',\n properties: {\n target: {\n type: 'string',\n enum: ['file', 'function', 'class', 'type', 'all'],\n description: 'What to document',\n },\n path: {\n type: 'string',\n description: 'Specific file path to document',\n },\n files: {\n type: 'string',\n description: 'File(s) to process: single path, comma-separated list, or glob',\n },\n style: {\n type: 'string',\n enum: ['jsdoc', 'tsdoc', 'block'],\n description: 'Documentation style (default: jsdoc)',\n },\n\n cwd: { type: 'string', description: 'Working directory (default: cwd)' },\n },\n },\n async execute(input, ctx) {\n const cwd = input.cwd ? safeResolve(input.cwd, ctx) : ctx.cwd;\n const style = input.style ?? 'jsdoc';\n const results: DocumentedItem[] = [];\n let filesProcessed = 0;\n let itemsDocumented = 0;\n\n const fileList = input.files\n ? await resolveFiles(Array.isArray(input.files) ? input.files.join(',') : input.files, cwd)\n : input.path\n ? [safeResolve(input.path, ctx)]\n : [];\n\n for (const absPath of fileList) {\n try {\n const content = await fs.readFile(absPath, 'utf8');\n filesProcessed++;\n const processed = processFile(\n content,\n absPath,\n style,\n input.overwrite ?? false,\n input.target ?? 'all',\n );\n results.push(...processed);\n itemsDocumented += processed.filter((r) => r.status === 'documented').length;\n } catch (e) {\n results.push({\n path: absPath,\n name: absPath.split('/').pop() ?? absPath,\n signature: '',\n docstring: '',\n status: 'error',\n error: e instanceof Error ? e.message : String(e),\n });\n }\n }\n\n return {\n files_processed: filesProcessed,\n items_documented: itemsDocumented,\n results,\n style,\n };\n },\n};\n\nasync function resolveFiles(filesInput: string, cwd: string): Promise<string[]> {\n const files = Array.isArray(filesInput) ? filesInput : filesInput.split(',');\n const resolved: string[] = [];\n\n for (const f of files) {\n const absPath = f.trim().startsWith('/') ? f.trim() : `${cwd}/${f.trim()}`;\n try {\n const stat = await fs.stat(absPath);\n if (stat.isFile()) resolved.push(absPath);\n } catch {\n // skip\n }\n }\n\n return resolved;\n}\n\nfunction processFile(\n content: string,\n absPath: string,\n _style: string,\n _overwrite: boolean,\n target: string,\n): DocumentedItem[] {\n const results: DocumentedItem[] = [];\n // These must be global — `String.prototype.matchAll` throws on a non-global\n // RegExp, which previously made processFile throw and report every file as an\n // error instead of returning documentation candidates.\n const functionRegex = /(?:async\\s+)?function\\s+(\\w+)\\s*\\(([^)]*)\\)/g;\n const arrowRegex = /(?:const|let|var)\\s+(\\w+)\\s*=\\s*(?:async\\s+)?\\(([^)]*)\\)\\s*=>/g;\n const classRegex = /class\\s+(\\w+)/g;\n const typeRegex = /(?:type|interface)\\s+(\\w+)\\s*[=<]/g;\n\n const allMatches: { name: string; sig: string; type: string; line: number }[] = [];\n\n if (target === 'all' || target === 'function') {\n for (const m of content.matchAll(functionRegex)) {\n /* v8 ignore next -- capture group (\\w+) is mandatory, so m[1] is always defined; defensive. */\n if (!m[1]) continue;\n allMatches.push({\n name: m[1],\n sig: m[2] ?? '',\n type: 'function',\n line: content.slice(0, m.index).split('\\n').length,\n });\n }\n for (const m of content.matchAll(arrowRegex)) {\n /* v8 ignore next -- capture group (\\w+) is mandatory, so m[1] is always defined; defensive. */\n if (!m[1]) continue;\n allMatches.push({\n name: m[1],\n sig: m[2] ?? '',\n type: 'arrow',\n line: content.slice(0, m.index).split('\\n').length,\n });\n }\n }\n\n if (target === 'all' || target === 'class') {\n for (const m of content.matchAll(classRegex)) {\n /* v8 ignore next -- capture group (\\w+) is mandatory, so m[1] is always defined; defensive. */\n if (!m[1]) continue;\n allMatches.push({\n name: m[1],\n sig: '',\n type: 'class',\n line: content.slice(0, m.index).split('\\n').length,\n });\n }\n }\n\n if (target === 'all' || target === 'type') {\n for (const m of content.matchAll(typeRegex)) {\n /* v8 ignore next -- capture group (\\w+) is mandatory, so m[1] is always defined; defensive. */\n if (!m[1]) continue;\n allMatches.push({\n name: m[1],\n sig: m[0] ?? '',\n type: 'type',\n line: content.slice(0, m.index).split('\\n').length,\n });\n }\n }\n\n for (const m of allMatches) {\n results.push({\n path: absPath,\n name: m.name,\n signature: m.sig,\n docstring: `/** ${m.name} - documented at line ${m.line} */`,\n status: 'skipped',\n });\n }\n\n return results;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/_util.ts","../src/document.ts"],"names":["stat"],"mappings":"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","file":"document.js","sourcesContent":["import * as fsp from 'node:fs/promises';\nimport * as path from 'node:path';\nimport * as Core from '@wrongstack/core';\nimport type { Context } from '@wrongstack/core';\n/** Detected package manager for a project directory. */\nexport type PackageManager = 'pnpm' | 'yarn' | 'npm';\n\n/**\n * Detect the project's package manager by inspecting lockfiles in `cwd`.\n * Order: pnpm → yarn → npm (default). Missing or unreadable directories fall\n * back to `npm` rather than throwing, so a `safeResolve`-checked cwd that\n * happens to be empty never aborts the tool.\n */\nexport async function detectPackageManager(cwd: string): Promise<PackageManager> {\n const { stat } = await import('node:fs/promises');\n try {\n await stat(`${cwd}/pnpm-lock.yaml`);\n return 'pnpm';\n } catch {\n /* not pnpm */\n }\n try {\n await stat(`${cwd}/yarn.lock`);\n return 'yarn';\n } catch {\n /* not yarn */\n }\n return 'npm';\n}\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);\n}\n\n/**\n * Roots every file tool may always reach, even in restricted mode: the\n * project root and the user-global `~/.wrongstack` directory (config, memory,\n * sessions, skills). `~/.wrongstack` honors the `WRONGSTACK_HOME` override.\n */\nfunction allowedRoots(ctx: Context): string[] {\n return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];\n}\n\n/** True if `target` is `root` itself or nested inside any of `roots`. */\nfunction isInsideAny(target: string, roots: string[]): boolean {\n return roots.some((root) => {\n const rel = path.relative(root, target);\n return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));\n });\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n const target = path.resolve(absPath);\n // Unrestricted filesystem access: skip the project-root containment check.\n if (ctx.allowOutsideProjectRoot) return target;\n if (isInsideAny(target, allowedRoots(ctx))) return target;\n throw new Error(`Path \"${absPath}\" is outside project root \"${path.resolve(ctx.projectRoot)}\"`);\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\n/**\n * Defense against in-root→out-of-root symlink escape (CWE-59). `safeResolve`\n * only does a syntactic `../` check, so a symlink that lives *inside* the\n * project root but points outside still passes it. This resolves the path\n * through `fs.realpath` and re-verifies containment against the realpath of\n * the project root (comparing like-for-like, since the root itself may be a\n * symlink — macOS `/var`→`/private/var`, Windows 8.3 short names). For a path\n * that does not exist yet (e.g. a `write` to a new file) the nearest existing\n * ancestor directory is checked instead. Throws if the real target escapes.\n *\n * Mirrors the per-file guard already used in `replace.ts`/`grep.ts`; applied\n * to single-file `read`/`edit`/`write` it throws (rather than skips) because\n * the caller named exactly one file.\n */\nexport async function assertRealInsideRoot(absPath: string, ctx: Context): Promise<void> {\n // Unrestricted filesystem access: no symlink-escape check to perform.\n if (ctx.allowOutsideProjectRoot) return;\n // Compare like-for-like against the realpath of each always-allowed root\n // (project root + ~/.wrongstack), since a root may itself be a symlink.\n const realRoots = await Promise.all(\n allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r))),\n );\n let probe = absPath;\n for (;;) {\n let real: string;\n try {\n real = await fsp.realpath(probe);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n const parent = path.dirname(probe);\n if (parent === probe) return; // reached fs root without escaping\n probe = parent;\n continue;\n }\n throw err;\n }\n if (isInsideAny(real, realRoots)) return;\n throw new Error(\n `Path \"${absPath}\" resolves through a symlink outside project root \"${realRoots[0]}\"`,\n );\n }\n}\n\n/** `safeResolve` + symlink realpath containment check. Async. */\nexport async function safeResolveReal(input: string, ctx: Context): Promise<string> {\n const abs = safeResolve(input, ctx);\n await assertRealInsideRoot(abs, ctx);\n return abs;\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n…[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]…\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n\n// ─── Command-output normalization (token-saving) ────────────────────────────\n//\n// Raw process output is full of tokens the model gains nothing from: ANSI\n// escapes, carriage-return progress spam, runs of identical warning lines, and\n// huge tails of build noise. These helpers strip that noise before the output\n// reaches the LLM. They are scoped to COMMAND tools (bash/git/exec and the\n// _spawn-stream consumers) — never applied to structured/code outputs.\n\n/** Unified byte cap for all command tool output fed to the model. */\nexport const COMMAND_OUTPUT_MAX_BYTES = 32_768;\n\n/** Runs of >= this many identical consecutive lines are collapsed. */\nconst REPEAT_RUN_THRESHOLD = 3;\n\n/**\n * Collapse carriage-return overwrites the way a terminal would: `\\r\\n` becomes\n * `\\n`, and a bare `\\r` (progress redraw) keeps only the text after the LAST\n * `\\r` on its physical line. Without this, a single progress bar that redraws\n * 200 times explodes into 200 lines.\n */\nexport function collapseCarriageReturns(text: string): string {\n const lf = text.replace(/\\r\\n/g, '\\n');\n if (!lf.includes('\\r')) return lf;\n return lf\n .split('\\n')\n .map((line) => (line.includes('\\r') ? line.slice(line.lastIndexOf('\\r') + 1) : line))\n .join('\\n');\n}\n\n/**\n * Collapse a run of `minRun`+ identical consecutive lines into the line once\n * plus a marker. Consecutive-only — it never reorders or dedups non-adjacent\n * lines, so diffs/source stay intact.\n */\nexport function collapseConsecutiveDuplicates(text: string, minRun = REPEAT_RUN_THRESHOLD): string {\n const lines = text.split('\\n');\n const out: string[] = [];\n let i = 0;\n while (i < lines.length) {\n let j = i + 1;\n while (j < lines.length && lines[j] === lines[i]) j++;\n const run = j - i;\n if (run >= minRun) {\n out.push(lines[i]!, `… ⟨repeated ${run}×⟩`);\n } else {\n for (let k = i; k < j; k++) out.push(lines[k]!);\n }\n i = j;\n }\n return out.join('\\n');\n}\n\n/** Largest prefix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeHeadBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(0, mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(0, lo);\n}\n\n/** Largest suffix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeTailBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(s.length - mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(s.length - lo);\n}\n\n/**\n * Truncate to `maxBytes` keeping BOTH ends — the head (what ran / early context)\n * and the tail (errors and summaries usually land last), biased ~45/55 toward\n * the tail. The result never exceeds `maxBytes`.\n */\nexport function truncateHeadTail(s: string, maxBytes: number): string {\n const total = Buffer.byteLength(s, 'utf8');\n if (total <= maxBytes) return s;\n // Reserve a fixed allowance for the marker so the final string can't exceed\n // the cap even though the dropped-byte count's digit width varies.\n const MARKER_RESERVE = 64;\n const avail = Math.max(0, maxBytes - MARKER_RESERVE);\n const headBudget = Math.floor(avail * 0.45);\n const head = takeHeadBytes(s, headBudget);\n const tail = takeTailBytes(s, avail - Buffer.byteLength(head, 'utf8'));\n const kept = Buffer.byteLength(head, 'utf8') + Buffer.byteLength(tail, 'utf8');\n return `${head}\\n…[truncated ${total - kept} bytes]…\\n${tail}`;\n}\n\n/**\n * Full token-saving pipeline for command tool output: strip ANSI → collapse\n * carriage-return progress → trim trailing whitespace → collapse identical\n * consecutive lines → squeeze blank-line runs → head+tail truncate to the cap.\n */\nexport function normalizeCommandOutput(\n raw: string,\n opts: { maxBytes?: number | undefined } = {},\n): string {\n if (!raw) return raw;\n let text = Core.stripAnsi(raw);\n text = collapseCarriageReturns(text);\n text = text.replace(/[ \\t]+$/gm, ''); // trailing whitespace per line\n text = collapseConsecutiveDuplicates(text);\n text = text.replace(/\\n{3,}/g, '\\n\\n'); // >=2 blank lines → 1\n return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);\n}\n","import * as fs from 'node:fs/promises';\nimport type { Tool } from '@wrongstack/core';\nimport { safeResolve } from './_util.js';\n\ninterface DocumentInput {\n target: 'file' | 'function' | 'class' | 'type' | 'all';\n path?: string | undefined;\n files?: string | string[] | undefined;\n style?: 'jsdoc' | 'tsdoc' | 'block' | undefined;\n overwrite?: boolean | undefined;\n cwd?: string | undefined;\n}\n\ninterface DocumentedItem {\n path: string;\n name: string;\n signature: string;\n docstring: string;\n status: 'documented' | 'skipped' | 'error';\n error?: string | undefined;\n}\n\ninterface DocumentOutput {\n files_processed: number;\n items_documented: number;\n results: DocumentedItem[];\n style: string;\n}\n\nexport const documentTool: Tool<DocumentInput, DocumentOutput> = {\n name: 'document',\n category: 'Project',\n description:\n 'DEPRECATED — use the `auto_doc` tool with `dryRun: true` instead. ' +\n 'This tool is a read-only preview stub that returns `skipped` candidates without generating real docstrings.',\n usageHint:\n 'Deprecated: prefer `auto_doc` with `dryRun: true` for previewing, or `auto_doc` without dryRun for writing. ' +\n 'This tool only lists undocumented symbols with placeholder comments — it does not generate real JSDoc/TSDoc.',\n permission: 'auto',\n mutating: false,\n timeoutMs: 30_000,\n capabilities: ['fs.read'],\n icon: 'document',\n inputSchema: {\n type: 'object',\n properties: {\n target: {\n type: 'string',\n enum: ['file', 'function', 'class', 'type', 'all'],\n description: 'What to document',\n },\n path: {\n type: 'string',\n description: 'Specific file path to document',\n },\n files: {\n type: 'string',\n description: 'File(s) to process: single path, comma-separated list, or glob',\n },\n style: {\n type: 'string',\n enum: ['jsdoc', 'tsdoc', 'block'],\n description: 'Documentation style (default: jsdoc)',\n },\n\n cwd: { type: 'string', description: 'Working directory (default: cwd)' },\n },\n },\n async execute(input, ctx) {\n const cwd = input.cwd ? safeResolve(input.cwd, ctx) : ctx.cwd;\n const style = input.style ?? 'jsdoc';\n const results: DocumentedItem[] = [];\n let filesProcessed = 0;\n let itemsDocumented = 0;\n\n const fileList = input.files\n ? await resolveFiles(Array.isArray(input.files) ? input.files.join(',') : input.files, cwd)\n : input.path\n ? [safeResolve(input.path, ctx)]\n : [];\n\n for (const absPath of fileList) {\n try {\n const content = await fs.readFile(absPath, 'utf8');\n filesProcessed++;\n const processed = processFile(\n content,\n absPath,\n style,\n input.overwrite ?? false,\n input.target ?? 'all',\n );\n results.push(...processed);\n itemsDocumented += processed.filter((r) => r.status === 'documented').length;\n } catch (e) {\n results.push({\n path: absPath,\n name: absPath.split('/').pop() ?? absPath,\n signature: '',\n docstring: '',\n status: 'error',\n error: e instanceof Error ? e.message : String(e),\n });\n }\n }\n\n return {\n files_processed: filesProcessed,\n items_documented: itemsDocumented,\n results,\n style,\n };\n },\n};\n\nasync function resolveFiles(filesInput: string, cwd: string): Promise<string[]> {\n const files = Array.isArray(filesInput) ? filesInput : filesInput.split(',');\n const resolved: string[] = [];\n\n for (const f of files) {\n const absPath = f.trim().startsWith('/') ? f.trim() : `${cwd}/${f.trim()}`;\n try {\n const stat = await fs.stat(absPath);\n if (stat.isFile()) resolved.push(absPath);\n } catch {\n // skip\n }\n }\n\n return resolved;\n}\n\nfunction processFile(\n content: string,\n absPath: string,\n _style: string,\n _overwrite: boolean,\n target: string,\n): DocumentedItem[] {\n const results: DocumentedItem[] = [];\n // These must be global — `String.prototype.matchAll` throws on a non-global\n // RegExp, which previously made processFile throw and report every file as an\n // error instead of returning documentation candidates.\n const functionRegex = /(?:async\\s+)?function\\s+(\\w+)\\s*\\(([^)]*)\\)/g;\n const arrowRegex = /(?:const|let|var)\\s+(\\w+)\\s*=\\s*(?:async\\s+)?\\(([^)]*)\\)\\s*=>/g;\n const classRegex = /class\\s+(\\w+)/g;\n const typeRegex = /(?:type|interface)\\s+(\\w+)\\s*[=<]/g;\n\n const allMatches: { name: string; sig: string; type: string; line: number }[] = [];\n\n if (target === 'all' || target === 'function') {\n for (const m of content.matchAll(functionRegex)) {\n /* v8 ignore next -- capture group (\\w+) is mandatory, so m[1] is always defined; defensive. */\n if (!m[1]) continue;\n allMatches.push({\n name: m[1],\n sig: m[2] ?? '',\n type: 'function',\n line: content.slice(0, m.index).split('\\n').length,\n });\n }\n for (const m of content.matchAll(arrowRegex)) {\n /* v8 ignore next -- capture group (\\w+) is mandatory, so m[1] is always defined; defensive. */\n if (!m[1]) continue;\n allMatches.push({\n name: m[1],\n sig: m[2] ?? '',\n type: 'arrow',\n line: content.slice(0, m.index).split('\\n').length,\n });\n }\n }\n\n if (target === 'all' || target === 'class') {\n for (const m of content.matchAll(classRegex)) {\n /* v8 ignore next -- capture group (\\w+) is mandatory, so m[1] is always defined; defensive. */\n if (!m[1]) continue;\n allMatches.push({\n name: m[1],\n sig: '',\n type: 'class',\n line: content.slice(0, m.index).split('\\n').length,\n });\n }\n }\n\n if (target === 'all' || target === 'type') {\n for (const m of content.matchAll(typeRegex)) {\n /* v8 ignore next -- capture group (\\w+) is mandatory, so m[1] is always defined; defensive. */\n if (!m[1]) continue;\n allMatches.push({\n name: m[1],\n sig: m[0] ?? '',\n type: 'type',\n line: content.slice(0, m.index).split('\\n').length,\n });\n }\n }\n\n for (const m of allMatches) {\n results.push({\n path: absPath,\n name: m.name,\n signature: m.sig,\n docstring: `/** ${m.name} - documented at line ${m.line} */`,\n status: 'skipped',\n });\n }\n\n return results;\n}\n"]}
|
package/dist/edit.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import * as Core from '@wrongstack/core';
|
|
3
|
-
import { detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff } from '@wrongstack/core';
|
|
3
|
+
import { ToolValidationError, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff } from '@wrongstack/core';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
|
|
6
6
|
// src/edit.ts
|
|
@@ -78,28 +78,62 @@ var editTool = {
|
|
|
78
78
|
required: ["path", "old_string", "new_string"]
|
|
79
79
|
},
|
|
80
80
|
async execute(input, ctx) {
|
|
81
|
-
if (!input?.path)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (input.old_string ===
|
|
81
|
+
if (!input?.path) {
|
|
82
|
+
throw new ToolValidationError({ message: "edit: path is required", field: "path" });
|
|
83
|
+
}
|
|
84
|
+
if (input.old_string === void 0) {
|
|
85
|
+
throw new ToolValidationError({
|
|
86
|
+
message: "edit: old_string is required",
|
|
87
|
+
field: "old_string"
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (input.new_string === void 0) {
|
|
91
|
+
throw new ToolValidationError({
|
|
92
|
+
message: "edit: new_string is required",
|
|
93
|
+
field: "new_string"
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (input.old_string === "") {
|
|
97
|
+
throw new ToolValidationError({
|
|
98
|
+
message: "edit: old_string cannot be empty",
|
|
99
|
+
field: "old_string"
|
|
100
|
+
});
|
|
101
|
+
}
|
|
85
102
|
const absPath = await safeResolveReal(input.path, ctx);
|
|
86
103
|
const stat2 = await fs.stat(absPath).catch((err) => {
|
|
87
104
|
if (err.code === "ENOENT") {
|
|
88
|
-
throw new
|
|
105
|
+
throw new ToolValidationError({
|
|
106
|
+
message: `edit: file "${input.path}" does not exist. Use \`write\` instead.`,
|
|
107
|
+
field: "path",
|
|
108
|
+
context: { exists: false }
|
|
109
|
+
});
|
|
89
110
|
}
|
|
90
111
|
throw err;
|
|
91
112
|
});
|
|
92
|
-
if (!stat2.isFile())
|
|
113
|
+
if (!stat2.isFile()) {
|
|
114
|
+
throw new ToolValidationError({
|
|
115
|
+
message: `edit: "${input.path}" is not a regular file`,
|
|
116
|
+
field: "path"
|
|
117
|
+
});
|
|
118
|
+
}
|
|
93
119
|
const autoRead = !ctx.hasRead(absPath);
|
|
94
120
|
const original = await fs.readFile(absPath, "utf8");
|
|
95
121
|
const updated = await fs.stat(absPath);
|
|
96
122
|
const mtimeTolerance = process.platform === "win32" ? 2e3 : 1;
|
|
97
123
|
const lastReadMtime = ctx.lastReadMtime(absPath);
|
|
98
124
|
if (lastReadMtime !== void 0 && updated.mtimeMs > lastReadMtime + mtimeTolerance) {
|
|
99
|
-
throw new
|
|
125
|
+
throw new ToolValidationError({
|
|
126
|
+
message: `edit: file "${input.path}" was modified externally. Re-read it first.`,
|
|
127
|
+
field: "path",
|
|
128
|
+
context: { reason: "external_modification" }
|
|
129
|
+
});
|
|
100
130
|
}
|
|
101
131
|
if (autoRead && updated.mtimeMs > stat2.mtimeMs + mtimeTolerance) {
|
|
102
|
-
throw new
|
|
132
|
+
throw new ToolValidationError({
|
|
133
|
+
message: `edit: file "${input.path}" changed while being auto-read. Retry the edit.`,
|
|
134
|
+
field: "path",
|
|
135
|
+
context: { reason: "auto_read_race" }
|
|
136
|
+
});
|
|
103
137
|
}
|
|
104
138
|
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;
|
|
105
139
|
const style = detectNewlineStyle(original);
|
|
@@ -125,15 +159,18 @@ var editTool = {
|
|
|
125
159
|
}
|
|
126
160
|
if (count === 0) {
|
|
127
161
|
const hint = findSimilarity(fileLf, oldLf);
|
|
128
|
-
throw new
|
|
129
|
-
`edit: no match for old_string in "${input.path}".${hint ? ` Nearest match near line ${hint}.` : ""}
|
|
130
|
-
|
|
162
|
+
throw new ToolValidationError({
|
|
163
|
+
message: `edit: no match for old_string in "${input.path}".${hint ? ` Nearest match near line ${hint}.` : ""}`,
|
|
164
|
+
field: "old_string"
|
|
165
|
+
});
|
|
131
166
|
}
|
|
132
167
|
if (count > 1 && !input.replace_all) {
|
|
133
168
|
const lines = lineNumbersFor(fileLf, matches);
|
|
134
|
-
throw new
|
|
135
|
-
`edit: old_string matched ${count} times in "${input.path}" (lines: ${lines.join(", ")}). Add more context to make it unique, or set replace_all: true
|
|
136
|
-
|
|
169
|
+
throw new ToolValidationError({
|
|
170
|
+
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.`,
|
|
171
|
+
field: "old_string",
|
|
172
|
+
context: { occurrences: count }
|
|
173
|
+
});
|
|
137
174
|
}
|
|
138
175
|
const newFileLf = input.replace_all ? fileLf.split(oldLf).join(newLf) : fileLf.replace(oldLf, newLf);
|
|
139
176
|
const newFile = toStyle(newFileLf, style);
|
package/dist/edit.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/_util.ts","../src/edit.ts"],"names":["fsp","stat"],"mappings":"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","file":"edit.js","sourcesContent":["import * as fsp from 'node:fs/promises';\nimport * as path from 'node:path';\nimport * as Core from '@wrongstack/core';\nimport type { Context } from '@wrongstack/core';\n/** Detected package manager for a project directory. */\nexport type PackageManager = 'pnpm' | 'yarn' | 'npm';\n\n/**\n * Detect the project's package manager by inspecting lockfiles in `cwd`.\n * Order: pnpm → yarn → npm (default). Missing or unreadable directories fall\n * back to `npm` rather than throwing, so a `safeResolve`-checked cwd that\n * happens to be empty never aborts the tool.\n */\nexport async function detectPackageManager(cwd: string): Promise<PackageManager> {\n const { stat } = await import('node:fs/promises');\n try {\n await stat(`${cwd}/pnpm-lock.yaml`);\n return 'pnpm';\n } catch {\n /* not pnpm */\n }\n try {\n await stat(`${cwd}/yarn.lock`);\n return 'yarn';\n } catch {\n /* not yarn */\n }\n return 'npm';\n}\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);\n}\n\n/**\n * Roots every file tool may always reach, even in restricted mode: the\n * project root and the user-global `~/.wrongstack` directory (config, memory,\n * sessions, skills). `~/.wrongstack` honors the `WRONGSTACK_HOME` override.\n */\nfunction allowedRoots(ctx: Context): string[] {\n return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];\n}\n\n/** True if `target` is `root` itself or nested inside any of `roots`. */\nfunction isInsideAny(target: string, roots: string[]): boolean {\n return roots.some((root) => {\n const rel = path.relative(root, target);\n return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));\n });\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n const target = path.resolve(absPath);\n // Unrestricted filesystem access: skip the project-root containment check.\n if (ctx.allowOutsideProjectRoot) return target;\n if (isInsideAny(target, allowedRoots(ctx))) return target;\n throw new Error(`Path \"${absPath}\" is outside project root \"${path.resolve(ctx.projectRoot)}\"`);\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\n/**\n * Defense against in-root→out-of-root symlink escape (CWE-59). `safeResolve`\n * only does a syntactic `../` check, so a symlink that lives *inside* the\n * project root but points outside still passes it. This resolves the path\n * through `fs.realpath` and re-verifies containment against the realpath of\n * the project root (comparing like-for-like, since the root itself may be a\n * symlink — macOS `/var`→`/private/var`, Windows 8.3 short names). For a path\n * that does not exist yet (e.g. a `write` to a new file) the nearest existing\n * ancestor directory is checked instead. Throws if the real target escapes.\n *\n * Mirrors the per-file guard already used in `replace.ts`/`grep.ts`; applied\n * to single-file `read`/`edit`/`write` it throws (rather than skips) because\n * the caller named exactly one file.\n */\nexport async function assertRealInsideRoot(absPath: string, ctx: Context): Promise<void> {\n // Unrestricted filesystem access: no symlink-escape check to perform.\n if (ctx.allowOutsideProjectRoot) return;\n // Compare like-for-like against the realpath of each always-allowed root\n // (project root + ~/.wrongstack), since a root may itself be a symlink.\n const realRoots = await Promise.all(\n allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r))),\n );\n let probe = absPath;\n for (;;) {\n let real: string;\n try {\n real = await fsp.realpath(probe);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n const parent = path.dirname(probe);\n if (parent === probe) return; // reached fs root without escaping\n probe = parent;\n continue;\n }\n throw err;\n }\n if (isInsideAny(real, realRoots)) return;\n throw new Error(\n `Path \"${absPath}\" resolves through a symlink outside project root \"${realRoots[0]}\"`,\n );\n }\n}\n\n/** `safeResolve` + symlink realpath containment check. Async. */\nexport async function safeResolveReal(input: string, ctx: Context): Promise<string> {\n const abs = safeResolve(input, ctx);\n await assertRealInsideRoot(abs, ctx);\n return abs;\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n…[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]…\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n\n// ─── Command-output normalization (token-saving) ────────────────────────────\n//\n// Raw process output is full of tokens the model gains nothing from: ANSI\n// escapes, carriage-return progress spam, runs of identical warning lines, and\n// huge tails of build noise. These helpers strip that noise before the output\n// reaches the LLM. They are scoped to COMMAND tools (bash/git/exec and the\n// _spawn-stream consumers) — never applied to structured/code outputs.\n\n/** Unified byte cap for all command tool output fed to the model. */\nexport const COMMAND_OUTPUT_MAX_BYTES = 32_768;\n\n/** Runs of >= this many identical consecutive lines are collapsed. */\nconst REPEAT_RUN_THRESHOLD = 3;\n\n/**\n * Collapse carriage-return overwrites the way a terminal would: `\\r\\n` becomes\n * `\\n`, and a bare `\\r` (progress redraw) keeps only the text after the LAST\n * `\\r` on its physical line. Without this, a single progress bar that redraws\n * 200 times explodes into 200 lines.\n */\nexport function collapseCarriageReturns(text: string): string {\n const lf = text.replace(/\\r\\n/g, '\\n');\n if (!lf.includes('\\r')) return lf;\n return lf\n .split('\\n')\n .map((line) => (line.includes('\\r') ? line.slice(line.lastIndexOf('\\r') + 1) : line))\n .join('\\n');\n}\n\n/**\n * Collapse a run of `minRun`+ identical consecutive lines into the line once\n * plus a marker. Consecutive-only — it never reorders or dedups non-adjacent\n * lines, so diffs/source stay intact.\n */\nexport function collapseConsecutiveDuplicates(text: string, minRun = REPEAT_RUN_THRESHOLD): string {\n const lines = text.split('\\n');\n const out: string[] = [];\n let i = 0;\n while (i < lines.length) {\n let j = i + 1;\n while (j < lines.length && lines[j] === lines[i]) j++;\n const run = j - i;\n if (run >= minRun) {\n out.push(lines[i]!, `… ⟨repeated ${run}×⟩`);\n } else {\n for (let k = i; k < j; k++) out.push(lines[k]!);\n }\n i = j;\n }\n return out.join('\\n');\n}\n\n/** Largest prefix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeHeadBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(0, mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(0, lo);\n}\n\n/** Largest suffix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeTailBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(s.length - mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(s.length - lo);\n}\n\n/**\n * Truncate to `maxBytes` keeping BOTH ends — the head (what ran / early context)\n * and the tail (errors and summaries usually land last), biased ~45/55 toward\n * the tail. The result never exceeds `maxBytes`.\n */\nexport function truncateHeadTail(s: string, maxBytes: number): string {\n const total = Buffer.byteLength(s, 'utf8');\n if (total <= maxBytes) return s;\n // Reserve a fixed allowance for the marker so the final string can't exceed\n // the cap even though the dropped-byte count's digit width varies.\n const MARKER_RESERVE = 64;\n const avail = Math.max(0, maxBytes - MARKER_RESERVE);\n const headBudget = Math.floor(avail * 0.45);\n const head = takeHeadBytes(s, headBudget);\n const tail = takeTailBytes(s, avail - Buffer.byteLength(head, 'utf8'));\n const kept = Buffer.byteLength(head, 'utf8') + Buffer.byteLength(tail, 'utf8');\n return `${head}\\n…[truncated ${total - kept} bytes]…\\n${tail}`;\n}\n\n/**\n * Full token-saving pipeline for command tool output: strip ANSI → collapse\n * carriage-return progress → trim trailing whitespace → collapse identical\n * consecutive lines → squeeze blank-line runs → head+tail truncate to the cap.\n */\nexport function normalizeCommandOutput(\n raw: string,\n opts: { maxBytes?: number | undefined } = {},\n): string {\n if (!raw) return raw;\n let text = Core.stripAnsi(raw);\n text = collapseCarriageReturns(text);\n text = text.replace(/[ \\t]+$/gm, ''); // trailing whitespace per line\n text = collapseConsecutiveDuplicates(text);\n text = text.replace(/\\n{3,}/g, '\\n\\n'); // >=2 blank lines → 1\n return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);\n}\n","import * as fs from 'node:fs/promises';\nimport type { Tool } from '@wrongstack/core';\nimport {\n atomicWrite,\n detectNewlineStyle,\n normalizeToLf,\n toStyle,\n unifiedDiff,\n} from '@wrongstack/core';\nimport { safeResolveReal } from './_util.js';\n\ninterface EditInput {\n path: string;\n old_string: string;\n new_string: string;\n /**\n * When true, replaces all occurrences of `old_string`.\n * When false (default), replaces only the first occurrence and errors\n * if more than one match exists — use this to ensure you target the\n * right location.\n */\n replace_all?: boolean | undefined;\n}\n\ninterface EditOutput {\n path: string;\n replacements: number;\n diff: string;\n note?: string | undefined;\n}\n\nexport const editTool: Tool<EditInput, EditOutput> = {\n name: 'edit',\n category: 'Filesystem',\n description:\n 'Perform a precise, surgical text replacement in a file. This is the preferred tool for modifying existing code. ' +\n 'It works best after a prior `read`, but can auto-read the current file when the replacement is still unambiguous. ' +\n 'Fails safely if the `old_string` appears more than once unless `replace_all` is set.',\n usageHint:\n 'RECOMMENDED WORKFLOW:\\n' +\n '1. Prefer calling `read` on the target file first when planning an edit.\\n' +\n '2. Use a sufficiently unique `old_string` (include surrounding lines/context if needed).\\n' +\n '3. If the string appears multiple times and you want to change all of them, set `replace_all: true`.\\n' +\n '4. `new_string` must be the exact replacement text.\\n\\n' +\n 'If no prior read is recorded, the tool auto-reads the current file and only applies the edit after the same ambiguity checks pass.',\n permission: 'confirm',\n mutating: true,\n capabilities: ['fs.write'],\n icon: 'edit',\n timeoutMs: 5_000,\n inputSchema: {\n type: 'object',\n properties: {\n path: { type: 'string' },\n old_string: { type: 'string' },\n new_string: { type: 'string' },\n replace_all: { type: 'boolean' },\n },\n required: ['path', 'old_string', 'new_string'],\n },\n async execute(input, ctx) {\n if (!input?.path) throw new Error('edit: path is required');\n if (input.old_string === undefined) throw new Error('edit: old_string is required');\n if (input.new_string === undefined) throw new Error('edit: new_string is required');\n if (input.old_string === '') throw new Error('edit: old_string cannot be empty');\n\n const absPath = await safeResolveReal(input.path, ctx);\n const stat = await fs.stat(absPath).catch((err) => {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n throw new Error(`edit: file \"${input.path}\" does not exist. Use \\`write\\` instead.`);\n }\n throw err;\n });\n if (!stat.isFile()) throw new Error(`edit: \"${input.path}\" is not a regular file`);\n\n const autoRead = !ctx.hasRead(absPath);\n // Read BEFORE mtime check to eliminate TOCTOU window.\n // The sequence must be: read content → check mtime → apply edit.\n // If we check mtime first, a concurrent modification between the\n // stat call and the read gives us stale content to search/replace.\n const original = await fs.readFile(absPath, 'utf8');\n const updated = await fs.stat(absPath);\n const mtimeTolerance = process.platform === 'win32' ? 2000 : 1;\n const lastReadMtime = ctx.lastReadMtime(absPath);\n if (lastReadMtime !== undefined && updated.mtimeMs > lastReadMtime + mtimeTolerance) {\n throw new Error(`edit: file \"${input.path}\" was modified externally. Re-read it first.`);\n }\n if (autoRead && updated.mtimeMs > stat.mtimeMs + mtimeTolerance) {\n throw new Error(`edit: file \"${input.path}\" changed while being auto-read. Retry the edit.`);\n }\n const autoReadNote = autoRead\n ? `No prior read was recorded for \"${input.path}\"; edit auto-read the current file and applied the replacement only after the ambiguity checks passed.`\n : undefined;\n const style = detectNewlineStyle(original);\n const fileLf = normalizeToLf(original);\n const oldLf = normalizeToLf(input.old_string);\n const newLf = normalizeToLf(input.new_string);\n\n if (oldLf === newLf) {\n if (autoRead) ctx.recordRead(absPath, updated.mtimeMs);\n return {\n path: absPath,\n replacements: 0,\n diff: '(no-op: old and new are identical)',\n note: autoReadNote,\n };\n }\n\n let count = 0;\n let idx = fileLf.indexOf(oldLf);\n const matches: number[] = [];\n while (idx !== -1) {\n matches.push(idx);\n count++;\n idx = fileLf.indexOf(oldLf, idx + 1);\n }\n\n if (count === 0) {\n const hint = findSimilarity(fileLf, oldLf);\n throw new Error(\n `edit: no match for old_string in \"${input.path}\".${\n hint ? ` Nearest match near line ${hint}.` : ''\n }`,\n );\n }\n\n if (count > 1 && !input.replace_all) {\n const lines = lineNumbersFor(fileLf, matches);\n throw new Error(\n `edit: old_string matched ${count} times in \"${input.path}\" (lines: ${lines.join(', ')}). ` +\n `Add more context to make it unique, or set replace_all: true.`,\n );\n }\n\n const newFileLf = input.replace_all\n ? fileLf.split(oldLf).join(newLf)\n : fileLf.replace(oldLf, newLf);\n const newFile = toStyle(newFileLf, style);\n\n await atomicWrite(absPath, newFile, { mode: updated.mode & 0o777 });\n const written = await fs.stat(absPath);\n // Record mtime so a later edit detects external modification, but tag as\n // 'write' so the permission policy's write-smart-bypass does NOT treat\n // this as \"user already saw the content\" (P1 #1).\n ctx.recordRead(absPath, written.mtimeMs, 'write');\n\n // Record for session rewind\n ctx.session.recordFileChange({\n path: absPath,\n action: 'modified',\n before: original,\n after: newFile,\n });\n\n const diff = unifiedDiff(original, newFile, {\n fromFile: input.path,\n toFile: input.path,\n });\n\n return {\n path: absPath,\n replacements: input.replace_all ? count : 1,\n diff,\n note: autoReadNote,\n };\n },\n};\n\nfunction lineNumbersFor(text: string, indices: number[]): number[] {\n const out: number[] = [];\n let pos = 0;\n let line = 1;\n for (const target of indices) {\n while (pos < target) {\n if (text.charCodeAt(pos) === 0x0a) line++;\n pos++;\n }\n out.push(line);\n }\n return out;\n}\n\nfunction findSimilarity(haystack: string, needle: string): number | undefined {\n if (needle.length < 20) return undefined;\n const probe = needle.slice(0, Math.min(40, needle.length));\n const idx = haystack.indexOf(probe);\n if (idx === -1) return undefined;\n let line = 1;\n for (let i = 0; i < idx; i++) {\n if (haystack.charCodeAt(i) === 0x0a) line++;\n }\n return line;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/_util.ts","../src/edit.ts"],"names":["fsp","stat"],"mappings":"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","file":"edit.js","sourcesContent":["import * as fsp from 'node:fs/promises';\nimport * as path from 'node:path';\nimport * as Core from '@wrongstack/core';\nimport type { Context } from '@wrongstack/core';\n/** Detected package manager for a project directory. */\nexport type PackageManager = 'pnpm' | 'yarn' | 'npm';\n\n/**\n * Detect the project's package manager by inspecting lockfiles in `cwd`.\n * Order: pnpm → yarn → npm (default). Missing or unreadable directories fall\n * back to `npm` rather than throwing, so a `safeResolve`-checked cwd that\n * happens to be empty never aborts the tool.\n */\nexport async function detectPackageManager(cwd: string): Promise<PackageManager> {\n const { stat } = await import('node:fs/promises');\n try {\n await stat(`${cwd}/pnpm-lock.yaml`);\n return 'pnpm';\n } catch {\n /* not pnpm */\n }\n try {\n await stat(`${cwd}/yarn.lock`);\n return 'yarn';\n } catch {\n /* not yarn */\n }\n return 'npm';\n}\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);\n}\n\n/**\n * Roots every file tool may always reach, even in restricted mode: the\n * project root and the user-global `~/.wrongstack` directory (config, memory,\n * sessions, skills). `~/.wrongstack` honors the `WRONGSTACK_HOME` override.\n */\nfunction allowedRoots(ctx: Context): string[] {\n return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];\n}\n\n/** True if `target` is `root` itself or nested inside any of `roots`. */\nfunction isInsideAny(target: string, roots: string[]): boolean {\n return roots.some((root) => {\n const rel = path.relative(root, target);\n return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));\n });\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n const target = path.resolve(absPath);\n // Unrestricted filesystem access: skip the project-root containment check.\n if (ctx.allowOutsideProjectRoot) return target;\n if (isInsideAny(target, allowedRoots(ctx))) return target;\n throw new Error(`Path \"${absPath}\" is outside project root \"${path.resolve(ctx.projectRoot)}\"`);\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\n/**\n * Defense against in-root→out-of-root symlink escape (CWE-59). `safeResolve`\n * only does a syntactic `../` check, so a symlink that lives *inside* the\n * project root but points outside still passes it. This resolves the path\n * through `fs.realpath` and re-verifies containment against the realpath of\n * the project root (comparing like-for-like, since the root itself may be a\n * symlink — macOS `/var`→`/private/var`, Windows 8.3 short names). For a path\n * that does not exist yet (e.g. a `write` to a new file) the nearest existing\n * ancestor directory is checked instead. Throws if the real target escapes.\n *\n * Mirrors the per-file guard already used in `replace.ts`/`grep.ts`; applied\n * to single-file `read`/`edit`/`write` it throws (rather than skips) because\n * the caller named exactly one file.\n */\nexport async function assertRealInsideRoot(absPath: string, ctx: Context): Promise<void> {\n // Unrestricted filesystem access: no symlink-escape check to perform.\n if (ctx.allowOutsideProjectRoot) return;\n // Compare like-for-like against the realpath of each always-allowed root\n // (project root + ~/.wrongstack), since a root may itself be a symlink.\n const realRoots = await Promise.all(\n allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r))),\n );\n let probe = absPath;\n for (;;) {\n let real: string;\n try {\n real = await fsp.realpath(probe);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n const parent = path.dirname(probe);\n if (parent === probe) return; // reached fs root without escaping\n probe = parent;\n continue;\n }\n throw err;\n }\n if (isInsideAny(real, realRoots)) return;\n throw new Error(\n `Path \"${absPath}\" resolves through a symlink outside project root \"${realRoots[0]}\"`,\n );\n }\n}\n\n/** `safeResolve` + symlink realpath containment check. Async. */\nexport async function safeResolveReal(input: string, ctx: Context): Promise<string> {\n const abs = safeResolve(input, ctx);\n await assertRealInsideRoot(abs, ctx);\n return abs;\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n…[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]…\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n\n// ─── Command-output normalization (token-saving) ────────────────────────────\n//\n// Raw process output is full of tokens the model gains nothing from: ANSI\n// escapes, carriage-return progress spam, runs of identical warning lines, and\n// huge tails of build noise. These helpers strip that noise before the output\n// reaches the LLM. They are scoped to COMMAND tools (bash/git/exec and the\n// _spawn-stream consumers) — never applied to structured/code outputs.\n\n/** Unified byte cap for all command tool output fed to the model. */\nexport const COMMAND_OUTPUT_MAX_BYTES = 32_768;\n\n/** Runs of >= this many identical consecutive lines are collapsed. */\nconst REPEAT_RUN_THRESHOLD = 3;\n\n/**\n * Collapse carriage-return overwrites the way a terminal would: `\\r\\n` becomes\n * `\\n`, and a bare `\\r` (progress redraw) keeps only the text after the LAST\n * `\\r` on its physical line. Without this, a single progress bar that redraws\n * 200 times explodes into 200 lines.\n */\nexport function collapseCarriageReturns(text: string): string {\n const lf = text.replace(/\\r\\n/g, '\\n');\n if (!lf.includes('\\r')) return lf;\n return lf\n .split('\\n')\n .map((line) => (line.includes('\\r') ? line.slice(line.lastIndexOf('\\r') + 1) : line))\n .join('\\n');\n}\n\n/**\n * Collapse a run of `minRun`+ identical consecutive lines into the line once\n * plus a marker. Consecutive-only — it never reorders or dedups non-adjacent\n * lines, so diffs/source stay intact.\n */\nexport function collapseConsecutiveDuplicates(text: string, minRun = REPEAT_RUN_THRESHOLD): string {\n const lines = text.split('\\n');\n const out: string[] = [];\n let i = 0;\n while (i < lines.length) {\n let j = i + 1;\n while (j < lines.length && lines[j] === lines[i]) j++;\n const run = j - i;\n if (run >= minRun) {\n out.push(lines[i]!, `… ⟨repeated ${run}×⟩`);\n } else {\n for (let k = i; k < j; k++) out.push(lines[k]!);\n }\n i = j;\n }\n return out.join('\\n');\n}\n\n/** Largest prefix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeHeadBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(0, mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(0, lo);\n}\n\n/** Largest suffix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeTailBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(s.length - mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(s.length - lo);\n}\n\n/**\n * Truncate to `maxBytes` keeping BOTH ends — the head (what ran / early context)\n * and the tail (errors and summaries usually land last), biased ~45/55 toward\n * the tail. The result never exceeds `maxBytes`.\n */\nexport function truncateHeadTail(s: string, maxBytes: number): string {\n const total = Buffer.byteLength(s, 'utf8');\n if (total <= maxBytes) return s;\n // Reserve a fixed allowance for the marker so the final string can't exceed\n // the cap even though the dropped-byte count's digit width varies.\n const MARKER_RESERVE = 64;\n const avail = Math.max(0, maxBytes - MARKER_RESERVE);\n const headBudget = Math.floor(avail * 0.45);\n const head = takeHeadBytes(s, headBudget);\n const tail = takeTailBytes(s, avail - Buffer.byteLength(head, 'utf8'));\n const kept = Buffer.byteLength(head, 'utf8') + Buffer.byteLength(tail, 'utf8');\n return `${head}\\n…[truncated ${total - kept} bytes]…\\n${tail}`;\n}\n\n/**\n * Full token-saving pipeline for command tool output: strip ANSI → collapse\n * carriage-return progress → trim trailing whitespace → collapse identical\n * consecutive lines → squeeze blank-line runs → head+tail truncate to the cap.\n */\nexport function normalizeCommandOutput(\n raw: string,\n opts: { maxBytes?: number | undefined } = {},\n): string {\n if (!raw) return raw;\n let text = Core.stripAnsi(raw);\n text = collapseCarriageReturns(text);\n text = text.replace(/[ \\t]+$/gm, ''); // trailing whitespace per line\n text = collapseConsecutiveDuplicates(text);\n text = text.replace(/\\n{3,}/g, '\\n\\n'); // >=2 blank lines → 1\n return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);\n}\n","import * as fs from 'node:fs/promises';\nimport type { Tool } from '@wrongstack/core';\nimport {\n atomicWrite,\n detectNewlineStyle,\n normalizeToLf,\n ToolValidationError,\n toStyle,\n unifiedDiff,\n} from '@wrongstack/core';\nimport { safeResolveReal } from './_util.js';\n\ninterface EditInput {\n path: string;\n old_string: string;\n new_string: string;\n /**\n * When true, replaces all occurrences of `old_string`.\n * When false (default), replaces only the first occurrence and errors\n * if more than one match exists — use this to ensure you target the\n * right location.\n */\n replace_all?: boolean | undefined;\n}\n\ninterface EditOutput {\n path: string;\n replacements: number;\n diff: string;\n note?: string | undefined;\n}\n\nexport const editTool: Tool<EditInput, EditOutput> = {\n name: 'edit',\n category: 'Filesystem',\n description:\n 'Perform a precise, surgical text replacement in a file. This is the preferred tool for modifying existing code. ' +\n 'It works best after a prior `read`, but can auto-read the current file when the replacement is still unambiguous. ' +\n 'Fails safely if the `old_string` appears more than once unless `replace_all` is set.',\n usageHint:\n 'RECOMMENDED WORKFLOW:\\n' +\n '1. Prefer calling `read` on the target file first when planning an edit.\\n' +\n '2. Use a sufficiently unique `old_string` (include surrounding lines/context if needed).\\n' +\n '3. If the string appears multiple times and you want to change all of them, set `replace_all: true`.\\n' +\n '4. `new_string` must be the exact replacement text.\\n\\n' +\n 'If no prior read is recorded, the tool auto-reads the current file and only applies the edit after the same ambiguity checks pass.',\n permission: 'confirm',\n mutating: true,\n capabilities: ['fs.write'],\n icon: 'edit',\n timeoutMs: 5_000,\n inputSchema: {\n type: 'object',\n properties: {\n path: { type: 'string' },\n old_string: { type: 'string' },\n new_string: { type: 'string' },\n replace_all: { type: 'boolean' },\n },\n required: ['path', 'old_string', 'new_string'],\n },\n async execute(input, ctx) {\n if (!input?.path) {\n throw new ToolValidationError({ message: 'edit: path is required', field: 'path' });\n }\n if (input.old_string === undefined) {\n throw new ToolValidationError({\n message: 'edit: old_string is required',\n field: 'old_string',\n });\n }\n if (input.new_string === undefined) {\n throw new ToolValidationError({\n message: 'edit: new_string is required',\n field: 'new_string',\n });\n }\n if (input.old_string === '') {\n throw new ToolValidationError({\n message: 'edit: old_string cannot be empty',\n field: 'old_string',\n });\n }\n\n const absPath = await safeResolveReal(input.path, ctx);\n const stat = await fs.stat(absPath).catch((err) => {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n throw new ToolValidationError({\n message: `edit: file \"${input.path}\" does not exist. Use \\`write\\` instead.`,\n field: 'path',\n context: { exists: false },\n });\n }\n throw err;\n });\n if (!stat.isFile()) {\n throw new ToolValidationError({\n message: `edit: \"${input.path}\" is not a regular file`,\n field: 'path',\n });\n }\n\n const autoRead = !ctx.hasRead(absPath);\n // Read BEFORE mtime check to eliminate TOCTOU window.\n // The sequence must be: read content → check mtime → apply edit.\n // If we check mtime first, a concurrent modification between the\n // stat call and the read gives us stale content to search/replace.\n const original = await fs.readFile(absPath, 'utf8');\n const updated = await fs.stat(absPath);\n const mtimeTolerance = process.platform === 'win32' ? 2000 : 1;\n const lastReadMtime = ctx.lastReadMtime(absPath);\n if (lastReadMtime !== undefined && updated.mtimeMs > lastReadMtime + mtimeTolerance) {\n throw new ToolValidationError({\n message: `edit: file \"${input.path}\" was modified externally. Re-read it first.`,\n field: 'path',\n context: { reason: 'external_modification' },\n });\n }\n if (autoRead && updated.mtimeMs > stat.mtimeMs + mtimeTolerance) {\n throw new ToolValidationError({\n message: `edit: file \"${input.path}\" changed while being auto-read. Retry the edit.`,\n field: 'path',\n context: { reason: 'auto_read_race' },\n });\n }\n const autoReadNote = autoRead\n ? `No prior read was recorded for \"${input.path}\"; edit auto-read the current file and applied the replacement only after the ambiguity checks passed.`\n : undefined;\n const style = detectNewlineStyle(original);\n const fileLf = normalizeToLf(original);\n const oldLf = normalizeToLf(input.old_string);\n const newLf = normalizeToLf(input.new_string);\n\n if (oldLf === newLf) {\n if (autoRead) ctx.recordRead(absPath, updated.mtimeMs);\n return {\n path: absPath,\n replacements: 0,\n diff: '(no-op: old and new are identical)',\n note: autoReadNote,\n };\n }\n\n let count = 0;\n let idx = fileLf.indexOf(oldLf);\n const matches: number[] = [];\n while (idx !== -1) {\n matches.push(idx);\n count++;\n idx = fileLf.indexOf(oldLf, idx + 1);\n }\n\n if (count === 0) {\n const hint = findSimilarity(fileLf, oldLf);\n throw new ToolValidationError({\n message: `edit: no match for old_string in \"${input.path}\".${\n hint ? ` Nearest match near line ${hint}.` : ''\n }`,\n field: 'old_string',\n });\n }\n\n if (count > 1 && !input.replace_all) {\n const lines = lineNumbersFor(fileLf, matches);\n throw new ToolValidationError({\n message:\n `edit: old_string matched ${count} times in \"${input.path}\" (lines: ${lines.join(', ')}). ` +\n `Add more context to make it unique, or set replace_all: true.`,\n field: 'old_string',\n context: { occurrences: count },\n });\n }\n\n const newFileLf = input.replace_all\n ? fileLf.split(oldLf).join(newLf)\n : fileLf.replace(oldLf, newLf);\n const newFile = toStyle(newFileLf, style);\n\n await atomicWrite(absPath, newFile, { mode: updated.mode & 0o777 });\n const written = await fs.stat(absPath);\n // Record mtime so a later edit detects external modification, but tag as\n // 'write' so the permission policy's write-smart-bypass does NOT treat\n // this as \"user already saw the content\" (P1 #1).\n ctx.recordRead(absPath, written.mtimeMs, 'write');\n\n // Record for session rewind\n ctx.session.recordFileChange({\n path: absPath,\n action: 'modified',\n before: original,\n after: newFile,\n });\n\n const diff = unifiedDiff(original, newFile, {\n fromFile: input.path,\n toFile: input.path,\n });\n\n return {\n path: absPath,\n replacements: input.replace_all ? count : 1,\n diff,\n note: autoReadNote,\n };\n },\n};\n\nfunction lineNumbersFor(text: string, indices: number[]): number[] {\n const out: number[] = [];\n let pos = 0;\n let line = 1;\n for (const target of indices) {\n while (pos < target) {\n if (text.charCodeAt(pos) === 0x0a) line++;\n pos++;\n }\n out.push(line);\n }\n return out;\n}\n\nfunction findSimilarity(haystack: string, needle: string): number | undefined {\n if (needle.length < 20) return undefined;\n const probe = needle.slice(0, Math.min(40, needle.length));\n const idx = haystack.indexOf(probe);\n if (idx === -1) return undefined;\n let line = 1;\n for (let i = 0; i < idx; i++) {\n if (haystack.charCodeAt(i) === 0x0a) line++;\n }\n return line;\n}\n"]}
|
package/dist/fetch.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as dns from 'node:dns/promises';
|
|
2
2
|
import * as net from 'node:net';
|
|
3
|
-
import { isPrivateIPv4, isPrivateIPv6 } from '@wrongstack/core';
|
|
3
|
+
import { isPrivateIPv4, isPrivateIPv6, ToolValidationError, FetchError, ToolError } from '@wrongstack/core';
|
|
4
4
|
import { Agent } from 'undici';
|
|
5
5
|
import TurndownService from 'turndown';
|
|
6
6
|
|
|
@@ -92,10 +92,16 @@ async function guardedFetch(url, maxRedirects, signal, headers = {
|
|
|
92
92
|
for (; ; ) {
|
|
93
93
|
const parsed = new URL(currentUrl);
|
|
94
94
|
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
95
|
-
throw new
|
|
95
|
+
throw new ToolValidationError({
|
|
96
|
+
message: `fetch: redirect to unsupported protocol "${parsed.protocol}"`,
|
|
97
|
+
field: "url"
|
|
98
|
+
});
|
|
96
99
|
}
|
|
97
100
|
if (parsed.protocol === "http:" && !ALLOW_PRIVATE) {
|
|
98
|
-
throw new
|
|
101
|
+
throw new ToolValidationError({
|
|
102
|
+
message: "fetch: redirect to http:// blocked (HTTPS required by default)",
|
|
103
|
+
field: "url"
|
|
104
|
+
});
|
|
99
105
|
}
|
|
100
106
|
await assertNotPrivate(parsed.hostname);
|
|
101
107
|
const init = {
|
|
@@ -110,11 +116,19 @@ async function guardedFetch(url, maxRedirects, signal, headers = {
|
|
|
110
116
|
}
|
|
111
117
|
redirectCount++;
|
|
112
118
|
if (redirectCount > maxRedirects) {
|
|
113
|
-
throw new
|
|
119
|
+
throw new FetchError({
|
|
120
|
+
message: `fetch: exceeded ${maxRedirects} redirects`,
|
|
121
|
+
status: res.status,
|
|
122
|
+
context: { url: currentUrl, maxRedirects, redirectCount }
|
|
123
|
+
});
|
|
114
124
|
}
|
|
115
125
|
const location = res.headers.get("location");
|
|
116
126
|
if (!location) {
|
|
117
|
-
throw new
|
|
127
|
+
throw new FetchError({
|
|
128
|
+
message: "fetch: redirect status with no location header",
|
|
129
|
+
status: res.status,
|
|
130
|
+
context: { url: currentUrl, redirectCount }
|
|
131
|
+
});
|
|
118
132
|
}
|
|
119
133
|
currentUrl = new URL(location, currentUrl).toString();
|
|
120
134
|
}
|
|
@@ -153,26 +167,53 @@ var fetchTool = {
|
|
|
153
167
|
async execute(input, ctx, opts) {
|
|
154
168
|
let final;
|
|
155
169
|
const executeStream = fetchTool.executeStream;
|
|
156
|
-
if (!executeStream)
|
|
170
|
+
if (!executeStream) {
|
|
171
|
+
throw new ToolError({
|
|
172
|
+
message: "fetchTool: stream execution unavailable",
|
|
173
|
+
code: "TOOL_EXECUTION_FAILED",
|
|
174
|
+
toolName: "fetch"
|
|
175
|
+
});
|
|
176
|
+
}
|
|
157
177
|
for await (const ev of executeStream(input, ctx, opts)) {
|
|
158
178
|
if (ev.type === "final") final = ev.output;
|
|
159
179
|
}
|
|
160
|
-
if (!final)
|
|
180
|
+
if (!final) {
|
|
181
|
+
throw new ToolError({
|
|
182
|
+
message: "fetch: stream ended without final event",
|
|
183
|
+
code: "TOOL_EXECUTION_FAILED",
|
|
184
|
+
toolName: "fetch"
|
|
185
|
+
});
|
|
186
|
+
}
|
|
161
187
|
return final;
|
|
162
188
|
},
|
|
163
189
|
async *executeStream(input, ctx, opts) {
|
|
164
|
-
if (!input?.url)
|
|
190
|
+
if (!input?.url) {
|
|
191
|
+
throw new ToolValidationError({
|
|
192
|
+
message: "fetch: url is required",
|
|
193
|
+
field: "url"
|
|
194
|
+
});
|
|
195
|
+
}
|
|
165
196
|
const u = new URL(input.url);
|
|
166
197
|
if (u.protocol !== "https:" && u.protocol !== "http:") {
|
|
167
|
-
throw new
|
|
198
|
+
throw new ToolValidationError({
|
|
199
|
+
message: `fetch: unsupported protocol "${u.protocol}"`,
|
|
200
|
+
field: "url"
|
|
201
|
+
});
|
|
168
202
|
}
|
|
169
203
|
if (u.protocol === "http:" && !ALLOW_PRIVATE) {
|
|
170
|
-
throw new
|
|
204
|
+
throw new ToolValidationError({
|
|
205
|
+
message: "fetch: http:// blocked (HTTPS required by default)",
|
|
206
|
+
field: "url"
|
|
207
|
+
});
|
|
171
208
|
}
|
|
172
209
|
await assertNotPrivate(u.hostname);
|
|
173
210
|
yield { type: "log", text: `GET ${input.url}` };
|
|
174
211
|
const ctrl = new AbortController();
|
|
175
|
-
const timer = setTimeout(() => ctrl.abort(new
|
|
212
|
+
const timer = setTimeout(() => ctrl.abort(new ToolError({
|
|
213
|
+
message: "fetch timeout",
|
|
214
|
+
code: "TOOL_TIMEOUT",
|
|
215
|
+
toolName: "fetch"
|
|
216
|
+
})), TIMEOUT_MS);
|
|
176
217
|
const combined = combineSignals([opts.signal, ctrl.signal]);
|
|
177
218
|
try {
|
|
178
219
|
let res;
|
|
@@ -184,7 +225,11 @@ var fetchTool = {
|
|
|
184
225
|
}
|
|
185
226
|
const ct = res.headers.get("content-type") ?? "application/octet-stream";
|
|
186
227
|
if (/^image\/|^audio\/|^video\/|application\/octet-stream/.test(ct)) {
|
|
187
|
-
throw new
|
|
228
|
+
throw new FetchError({
|
|
229
|
+
message: `fetch: refusing to read binary content-type "${ct}"`,
|
|
230
|
+
status: res.status,
|
|
231
|
+
context: { url: res.url, contentType: ct }
|
|
232
|
+
});
|
|
188
233
|
}
|
|
189
234
|
yield {
|
|
190
235
|
type: "log",
|
|
@@ -249,16 +294,25 @@ async function assertNotPrivate(hostname) {
|
|
|
249
294
|
if (ALLOW_PRIVATE) return;
|
|
250
295
|
const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
251
296
|
if (host === "localhost" || host.endsWith(".localhost")) {
|
|
252
|
-
throw new
|
|
297
|
+
throw new ToolValidationError({
|
|
298
|
+
message: "fetch: blocked localhost target",
|
|
299
|
+
field: "url"
|
|
300
|
+
});
|
|
253
301
|
}
|
|
254
302
|
const ipVersion = net.isIP(host);
|
|
255
303
|
if (ipVersion === 4) {
|
|
256
304
|
if (isPrivateIPv4(host)) {
|
|
257
|
-
throw new
|
|
305
|
+
throw new ToolValidationError({
|
|
306
|
+
message: `fetch: blocked private/loopback address "${host}"`,
|
|
307
|
+
field: "url"
|
|
308
|
+
});
|
|
258
309
|
}
|
|
259
310
|
} else if (ipVersion === 6) {
|
|
260
311
|
if (isPrivateIPv6(host)) {
|
|
261
|
-
throw new
|
|
312
|
+
throw new ToolValidationError({
|
|
313
|
+
message: `fetch: blocked private/loopback address "${host}"`,
|
|
314
|
+
field: "url"
|
|
315
|
+
});
|
|
262
316
|
}
|
|
263
317
|
} else {
|
|
264
318
|
try {
|
|
@@ -266,7 +320,10 @@ async function assertNotPrivate(hostname) {
|
|
|
266
320
|
for (const r of records) {
|
|
267
321
|
const bad = r.family === 4 ? isPrivateIPv4(r.address) : isPrivateIPv6(r.address);
|
|
268
322
|
if (bad) {
|
|
269
|
-
throw new
|
|
323
|
+
throw new ToolValidationError({
|
|
324
|
+
message: `fetch: resolved to private address ${r.address}`,
|
|
325
|
+
field: "url"
|
|
326
|
+
});
|
|
270
327
|
}
|
|
271
328
|
}
|
|
272
329
|
} catch (err) {
|
|
@@ -276,7 +333,13 @@ async function assertNotPrivate(hostname) {
|
|
|
276
333
|
}
|
|
277
334
|
function describeFetchError(err, url, timedOut) {
|
|
278
335
|
if (timedOut) {
|
|
279
|
-
return new
|
|
336
|
+
return new ToolError({
|
|
337
|
+
message: `fetch: GET ${url} timed out after ${TIMEOUT_MS}ms`,
|
|
338
|
+
code: "TOOL_TIMEOUT",
|
|
339
|
+
toolName: "fetch",
|
|
340
|
+
context: { url, timedOut: true, timeoutMs: TIMEOUT_MS },
|
|
341
|
+
cause: err
|
|
342
|
+
});
|
|
280
343
|
}
|
|
281
344
|
const parts = [];
|
|
282
345
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -289,7 +352,15 @@ function describeFetchError(err, url, timedOut) {
|
|
|
289
352
|
cur = cur.cause;
|
|
290
353
|
}
|
|
291
354
|
const detail = parts.length > 0 ? parts.join(" \u2192 ") : "fetch failed";
|
|
292
|
-
return new
|
|
355
|
+
return new FetchError({
|
|
356
|
+
message: `fetch: GET ${url} failed \u2014 ${detail}`,
|
|
357
|
+
status: 502,
|
|
358
|
+
context: { url, timedOut: false, transportErrors: parts },
|
|
359
|
+
// Preserve the original undici / DNS / TLS chain so callers can inspect
|
|
360
|
+
// it via `err.cause` and structured `instanceof` checks. The flattened
|
|
361
|
+
// text version stays in the message for human readability.
|
|
362
|
+
cause: err
|
|
363
|
+
});
|
|
293
364
|
}
|
|
294
365
|
function prettyJson(s) {
|
|
295
366
|
try {
|