From 06f0b9d69724b214516e07717aab4f46d7397bd0 Mon Sep 17 00:00:00 2001 From: Ronit Date: Mon, 18 May 2026 11:57:42 +0530 Subject: [PATCH 1/7] =?UTF-8?q?docs(plan):=20PR-R1=20read=20primitives=20?= =?UTF-8?q?=E2=80=94=20read=5Ffile=20slice/gutter=20+=20grep=5Ffile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bite-sized plan covering: - read_file: add offset/limit (1-based line slicing), line-number gutter on output, touchesFS:false. Keeps existing 64KB truncate semantics for the no-args case. - grep_file: new server-side regex tool over R2 text files. path | prefix scope, regex_flags, context_lines, max_matches. Binary files skipped. - One-sentence main-agent system prompt update. - 9-step agent-driven smoke (no unit tests per memory). - PR raised against main, independent of the sync-agent spec PR. Decisions locked pre-plan: keep truncate semantics (not error-on-overflow), line gutter on output, regex string for grep pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-18-pr-r1-read-primitives.md | 605 ++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-18-pr-r1-read-primitives.md diff --git a/docs/superpowers/plans/2026-05-18-pr-r1-read-primitives.md b/docs/superpowers/plans/2026-05-18-pr-r1-read-primitives.md new file mode 100644 index 0000000..367cecb --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-pr-r1-read-primitives.md @@ -0,0 +1,605 @@ +# PR-R1 — Main-agent R2 read primitives Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Model selection:** opus for all tasks (per memory `feedback_subagent_opus_default`). +> +> **Tests:** per memory `feedback_minimal_tests_in_plans`, no TDD steps. One agent-driven smoke at the end covers the matrix. + +**Goal:** Extend `read_file` with line-based pagination + a line-number gutter, and add a new `grep_file` tool that runs server-side regex over R2 files and returns only matching lines plus context. Together these stop the main agent from torching its context window on multi-MB mirror files. + +**Architecture:** Both tools live inside `buildFSTools` in `apps/kernel/src/tools/fs-tools.ts` (auto-picked up by `buildTools` and `buildSubAgentTools` in `tools/index.ts`). Both use the existing `EnvFS` surface via `buildKernelEnvFS(env, threadId, callId)` — no new R2 plumbing. Both are `wrappedTool({ touchesFS: false })` (read-only) so they skip the per-call worktree. + +**Tech Stack:** TypeScript / Cloudflare Workers / AI SDK v5 / Zod / existing `EnvFS` + `R2Bucket` bindings. + +**Spec reference:** `docs/superpowers/specs/2026-05-17-sync-agent-design.md` §3.1.1 ("Main-agent read primitives over R2") and §6 PR-R1. + +**Decisions confirmed pre-plan (2026-05-18):** +- 64KB cap on `read_file` keeps **truncate** semantics (existing behavior). New `offset`/`limit` is the opt-in path for explicit slices and bypasses the cap. +- `read_file` content gets a **line-number gutter** (`\t` per line) so the agent can address sub-ranges by number in follow-up calls. Return shape's `content` field changes accordingly. +- `grep_file` pattern is a **regex string** compiled with `new RegExp(pattern, regex_flags ?? "m")`. + +--- + +## House-keeping rules + +Apply to every task. The implementer must NOT generate any of the following inside source files: + +- **No PR-letter markers** — no `// NEW (PR-R1)`, no `// ─── PR-R1 ───`, etc. `git blame` already tracks provenance. +- **No "// NEW" / "// MODIFIED" / "// CHANGED" stickers.** +- **JSDoc explaining WHY a field exists is fine.** Comments explaining "this is new" are not. + +If a code sample in this plan accidentally carries such a marker, strip it before pasting. + +--- + +## File structure + +- **Modify** `apps/kernel/src/tools/fs-tools.ts` — extend `read_file`, add `grep_file`. Both inside `buildFSTools`. +- **Modify** `apps/kernel/src/agent/system-prompt.ts` — one-sentence addition to the FS-tools paragraph (around line 77) that names `grep_file` and the 64KB/offset-limit pattern. +- **No changes** to `apps/kernel/src/tools/index.ts` — `buildFSTools` is already wired into `buildTools` and `buildSubAgentTools`; `grep_file` rides along automatically. +- **No changes** to `apps/kernel/src/fs/env-fs.ts` or `path.ts` — both tools use existing primitives (`fs.stat`, `fs.readFile`, `fs.listFiles`). +- **No new files** in this PR. + +--- + +## Task 1: Extend `read_file` with offset/limit + line gutter + touchesFS:false + +**Files:** +- Modify: `apps/kernel/src/tools/fs-tools.ts` — the `readFile: wrappedTool({ name: "read_file", ... })` block (currently spans roughly lines 15–72). + +The current tool truncates content past 64KB and returns the raw decoded text. We keep that for the no-offset case but layer in (a) a line-number gutter on output, (b) explicit `offset` / `limit` line-based slicing that bypasses the cap, and (c) `touchesFS: false` so reads don't open a worktree. + +- [ ] **Step 1: Add a `formatWithLineGutter` helper at module top** + +Place near the existing constants (`TEXT_CT_RE`, `READ_CAP`, `WRITE_CAP`). + +```ts +/** + * Render text as `\t` per line so the agent can address + * sub-ranges by number in follow-up read_file / grep_file calls. + * Line numbers start at `startLine` (1-based; caller chooses). + */ +function formatWithLineGutter(text: string, startLine: number): string { + const lines = text.split("\n"); + // Trailing newline produces an empty final element — preserve it as a + // blank line so the rendered output round-trips with the file. + return lines + .map((line, i) => `${startLine + i}\t${line}`) + .join("\n"); +} +``` + +- [ ] **Step 2: Extend the `inputSchema` with optional `offset` and `limit`** + +Replace the existing `inputSchema: z.object({ path: ... })` with: + +```ts +inputSchema: z.object({ + path: z + .string() + .startsWith("r2://") + .describe( + "Full r2:// path, e.g. r2://memory/USER_PROFILE.md, r2://my-files/notes.csv, r2://artifacts/data.json" + ), + offset: z + .number() + .int() + .min(1) + .optional() + .describe( + "1-based line number to start reading from. Combined with `limit`, returns a contiguous line range. Bypasses the 64KB cap (use this to read deeper into big files like NDJSON indexes)." + ), + limit: z + .number() + .int() + .min(1) + .max(10000) + .optional() + .describe( + "Maximum number of lines to return when `offset` is set. Defaults to 2000." + ) +}) +``` + +- [ ] **Step 3: Update the description to teach the new contract** + +Replace the current `description` string for `read_file` with: + +```ts +description: + "Read a text file from R2 with a line-number gutter (`\\t` per line) so you can address sub-ranges in follow-up calls. Default behavior (no offset/limit): returns the first 64KB with `truncated:true` + full `size` if the file is bigger — switch to `grep_file` for substring/regex search or call again with {offset, limit} for a deeper slice. With `offset` (1-based line) + optional `limit` (default 2000 lines), returns the explicit line range and bypasses the 64KB cap. Memory files, CSV/JSON/NDJSON/YAML/XML/HTML/code, markdown, logs, configs all work; refuses binary content (PDFs, images, archives — use process_attachment or env.FS.readFile inside exec_code).", +``` + +- [ ] **Step 4: Add `touchesFS: false` to the tool def** + +In the `readFile: wrappedTool({ ... })` block, insert `touchesFS: false,` next to `needsApproval: false,`. This is a small bug fix — read-only tools should not open a worktree per call (per memory `feedback_wrappedtool_default_readonly`). + +- [ ] **Step 5: Rewrite the `execute` body to handle three paths** + +Replace the existing `execute` function for `read_file` with: + +```ts +execute: async ({ path, offset, limit }, ctx) => { + const fs = buildKernelEnvFS(ctx.env, ctx.threadId, ctx.callId); + try { + const meta = await fs.stat(path); + if (!meta) { + return { ok: false as const, error: "not_found" as const, path }; + } + if (!TEXT_CT_RE.test(meta.contentType)) { + return { + ok: false as const, + error: "binary_not_supported" as const, + detail: `contentType ${meta.contentType} is binary; use process_attachment(path, prompt) or env.FS.readFile in exec_code`, + path, + contentType: meta.contentType, + size: meta.size + }; + } + const { content, contentType, size } = await fs.readFile(path); + const text = new TextDecoder().decode(content); + + // Explicit-slice path: offset/limit set. Bypass the 64KB cap; the + // caller asked for a specific line range on purpose. + if (offset !== undefined) { + const effLimit = limit ?? 2000; + const lines = text.split("\n"); + const startIdx = offset - 1; + if (startIdx >= lines.length) { + return { + ok: true as const, + path, + content: "", + contentType, + size, + startLine: offset, + endLine: offset - 1, + totalLines: lines.length, + truncated: false as const + }; + } + const endIdx = Math.min(startIdx + effLimit, lines.length); + const slice = lines.slice(startIdx, endIdx).join("\n"); + return { + ok: true as const, + path, + content: formatWithLineGutter(slice, offset), + contentType, + size, + startLine: offset, + endLine: startIdx + (endIdx - startIdx), + totalLines: lines.length, + truncated: false as const + }; + } + + // Default path: no offset/limit. Apply 64KB cap, truncate with flag. + if (text.length > READ_CAP) { + const head = text.slice(0, READ_CAP); + return { + ok: true as const, + path, + content: formatWithLineGutter(head, 1), + contentType, + size, + truncated: true as const + }; + } + return { + ok: true as const, + path, + content: formatWithLineGutter(text, 1), + contentType, + size, + truncated: false as const + }; + } catch (e) { + return mapEnvFSError("read_file", e); + } +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/kernel/src/tools/fs-tools.ts +git commit -m "feat(tools): read_file gets offset/limit + line gutter; touchesFS=false" +``` + +--- + +## Task 2: Implement `grep_file` + +**Files:** +- Modify: `apps/kernel/src/tools/fs-tools.ts` — add a new entry to the object returned by `buildFSTools` (place it after `findFiles` and before `deleteFile` so read tools cluster). + +Server-side regex over R2 files. Scope by single `path` OR by `prefix` (recurses). Returns matching lines with surrounding context — only matches cross into the agent's context window, never the whole file. + +- [ ] **Step 1: Add a `scanFileForMatches` helper near the top of the file** + +Place after `formatWithLineGutter` from Task 1. + +```ts +interface GrepMatch { + file: string; + line: number; + match: string; + before: string[]; + after: string[]; +} + +/** + * Scan a single file's text for regex matches. Returns up to `remaining` + * matches, each with `contextLines` lines of before/after context. + * + * The regex is reset between line scans (no global-flag state leakage). + */ +function scanFileForMatches( + filePath: string, + text: string, + regex: RegExp, + contextLines: number, + remaining: number +): GrepMatch[] { + if (remaining <= 0) return []; + const lines = text.split("\n"); + const out: GrepMatch[] = []; + for (let i = 0; i < lines.length && out.length < remaining; i++) { + // `lastIndex` reset guards against /g flag accumulating state. + regex.lastIndex = 0; + if (!regex.test(lines[i]!)) continue; + const before = lines.slice(Math.max(0, i - contextLines), i); + const after = lines.slice(i + 1, Math.min(lines.length, i + 1 + contextLines)); + out.push({ + file: filePath, + line: i + 1, // 1-based to match read_file's gutter + match: lines[i]!, + before, + after + }); + } + return out; +} +``` + +- [ ] **Step 2: Add the `grepFile` tool to `buildFSTools`'s returned object** + +Insert this block between `findFiles: wrappedTool({...}),` and `deleteFile: wrappedTool({...}),`. Keep return-object property order: read tools first, then write tools. + +```ts +grepFile: wrappedTool( + { + name: "grep_file", + description: + "Server-side regex search over R2 text files. Returns only matching lines + N lines of context — the file itself stays in the Worker, so your context budget tracks matches, not file size. Use this instead of read_file whenever a mirror file might exceed 64KB or you need to find by predicate across many files. Scope: exactly one of `path` (single file) or `prefix` (recurses, reads every text file under the prefix). `pattern` is a JS regex string; default flags are `m` (line-anchored). Caps: 100 matches across all files in scope by default, set `max_matches` to raise (max 1000). Binary files are skipped automatically.", + inputSchema: z.object({ + pattern: z + .string() + .min(1) + .describe( + "JS regex string (no slashes). Examples: '\"path\":\"/Personal/Tax/' (substring), '^id:[a-f0-9]+\\\\b' (anchored), 'TODO|FIXME' (alternation)." + ), + path: z + .string() + .startsWith("r2://") + .optional() + .describe( + "Full r2:// path of a single file to search. Mutually exclusive with `prefix`." + ), + prefix: z + .string() + .startsWith("r2://") + .optional() + .describe( + "r2:// prefix to recurse under (e.g. 'r2://skills/' or 'r2://integrations/dropbox/'). Mutually exclusive with `path`. Reads every text file at that prefix and runs the pattern across each." + ), + regex_flags: z + .string() + .regex(/^[gimsuy]*$/) + .optional() + .describe( + "Regex flags. Default 'm' (line-anchored). Pass 'mi' for case-insensitive, 'ms' for dotall, etc. Avoid 'g' — it's already implicit per-line." + ), + context_lines: z + .number() + .int() + .min(0) + .max(10) + .optional() + .describe( + "Lines of context before/after each match. Default 2." + ), + max_matches: z + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe( + "Cap on total matches returned across all files in scope. Default 100. Past the cap the result carries truncated:true so you know to narrow." + ) + }), + needsApproval: false, + touchesFS: false, + execute: async ( + { pattern, path, prefix, regex_flags, context_lines, max_matches }, + ctx + ) => { + // Exactly-one-of scope validation. + if ((path === undefined) === (prefix === undefined)) { + return { + ok: false as const, + error: "bad_scope" as const, + detail: + "specify exactly one of `path` (single file) or `prefix` (recursive search)" + }; + } + + // Compile the regex up front so a bad pattern surfaces before any R2 read. + let regex: RegExp; + try { + regex = new RegExp(pattern, regex_flags ?? "m"); + } catch (e) { + return { + ok: false as const, + error: "bad_pattern" as const, + detail: e instanceof Error ? e.message : String(e) + }; + } + + const ctxLines = context_lines ?? 2; + const cap = max_matches ?? 100; + const fs = buildKernelEnvFS(ctx.env, ctx.threadId, ctx.callId); + const matches: GrepMatch[] = []; + + try { + if (path !== undefined) { + const meta = await fs.stat(path); + if (!meta) { + return { ok: false as const, error: "not_found" as const, path }; + } + if (!TEXT_CT_RE.test(meta.contentType)) { + return { + ok: false as const, + error: "binary_not_supported" as const, + detail: `contentType ${meta.contentType} is binary; grep_file is text-only`, + path, + contentType: meta.contentType + }; + } + const { content } = await fs.readFile(path); + const text = new TextDecoder().decode(content); + matches.push( + ...scanFileForMatches(path, text, regex, ctxLines, cap - matches.length) + ); + } else { + // prefix scope — enumerate files, skip binaries, scan each. + const files = await fs.listFiles(prefix!); + for (const f of files) { + if (matches.length >= cap) break; + if (!TEXT_CT_RE.test(f.contentType)) continue; + const { content } = await fs.readFile(f.path); + const text = new TextDecoder().decode(content); + matches.push( + ...scanFileForMatches(f.path, text, regex, ctxLines, cap - matches.length) + ); + } + } + } catch (e) { + return mapEnvFSError("grep_file", e); + } + + return { + ok: true as const, + matches, + truncated: matches.length >= cap + }; + } + }, + perTurn +), +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/kernel/src/tools/fs-tools.ts +git commit -m "feat(tools): add grep_file for server-side regex over R2" +``` + +--- + +## Task 3: Update the main-agent system prompt + +**Files:** +- Modify: `apps/kernel/src/agent/system-prompt.ts` — the `` block's FS-tools sentence (currently around line 77). + +The convention line about `skills/integrations/.md` is **PR-F1's** job, not R1's. R1's only prompt change is naming `grep_file` and teaching the cap pattern in the existing FS-tools paragraph. + +- [ ] **Step 1: Locate the FS-tools sentence** + +In `system-prompt.ts`, find the line that begins: + +``` +**read_file / write_file / edit_file / list_files / find_files / move_file / delete_file / get_signed_url** for direct R2 ops on text content. +``` + +- [ ] **Step 2: Replace with the extended version** + +Use Edit tool with `old_string` = the full existing sentence + its trailing paragraph (up to the blank line before the next `**...**` capability). `new_string`: + +``` +**read_file / grep_file / write_file / edit_file / list_files / find_files / move_file / delete_file / get_signed_url** for direct R2 ops on text content. \`grep_file\` runs regex server-side and returns only matching lines + context — reach for it whenever the file might exceed 64KB or you need to find by predicate across many files. \`read_file\` returns a line-number gutter; if you get \`truncated:true\` on a big file, switch to \`grep_file\` or call \`read_file\` again with \`{offset, limit}\` for a deeper slice. \`edit_file\` is the surgical one: read-then-replace a unique anchor, no full-file rewrite. Use it for memory updates and any single-section edit. If your work around the FS call is more than three lines of logic, switch to \`env.FS\` inside exec_code instead. +``` + +(The backtick-escaping is for the template-literal context — keep the literal backticks in the file since `SYSTEM_PROMPT` is a backtick-quoted string.) + +- [ ] **Step 3: Commit** + +```bash +git add apps/kernel/src/agent/system-prompt.ts +git commit -m "feat(prompt): teach main agent grep_file + read_file slice pattern" +``` + +--- + +## Task 4: Type-check and lint + +- [ ] **Step 1: Run the typechecker on the kernel package** + +```bash +pnpm --filter @agent-os/kernel typecheck +``` + +Expected: clean, no errors. If `wrappedTool`'s inferred return type complains about the new return-shape union members on `read_file` (added `startLine` / `endLine` / `totalLines` in some branches but not others), normalize by always returning those fields with sensible defaults — or accept the union and let TS narrow naturally; depends on what the typechecker actually says. + +- [ ] **Step 2: Run oxlint + oxfmt** + +Per memory `feedback_lint_format`, agent-os uses oxc tooling (not Prettier/ESLint). + +```bash +pnpm oxlint apps/kernel/src/tools/fs-tools.ts apps/kernel/src/agent/system-prompt.ts +pnpm oxfmt apps/kernel/src/tools/fs-tools.ts apps/kernel/src/agent/system-prompt.ts +``` + +Expected: no findings; oxfmt may rewrite formatting in place — re-stage if it does. + +- [ ] **Step 3: Commit any oxfmt-driven changes** + +If oxfmt rewrote anything: + +```bash +git add apps/kernel/src/tools/fs-tools.ts apps/kernel/src/agent/system-prompt.ts +git commit -m "style: oxfmt" +``` + +If nothing changed, skip. + +--- + +## Task 5: Agent-driven smoke (the actual verification) + +Per memory `feedback_minimal_tests_in_plans`, we don't write unit tests for tool features. Instead, an end-to-end smoke through the agent surface covers the matrix more authentically. + +**Prerequisites:** local kernel running (`pnpm dev` or whatever the current dev entrypoint is). Have one small (<64KB) text file and one large (>64KB) text file already seeded under any allowed R2 prefix — `r2://my-files/` is the simplest. If none exist, the smoke includes a write step to create them. + +- [ ] **Step 1: Seed test fixtures via the CLI (or skip if files already exist)** + +In the running CLI, ask the agent: +> "Write a small file at `r2://my-files/r1-smoke-small.md` with the markdown `# Hello\nworld` and a large file at `r2://my-files/r1-smoke-large.ndjson` with 5000 lines of NDJSON like `{\"id\":\"id-N\",\"path\":\"/folder/file-N.txt\"}` where N is the 1-indexed line number." + +Wait for both write_file confirmations. Verify the large file's size via `list_files r2://my-files/` — should be ~200KB+. + +- [ ] **Step 2: Verify `read_file` truncates large files and shows the gutter** + +Ask the agent: +> "Read `r2://my-files/r1-smoke-large.ndjson`." + +Expected agent output (paraphrased): notes the file is large, gets `truncated:true` with `size: ~200000+`. The content block in the tool result has `\t` per line. Confirm by asking the agent: "What was the line number prefix format? Was the read truncated?" + +- [ ] **Step 3: Verify `read_file` with offset/limit returns a specific slice** + +Ask the agent: +> "Read lines 2500–2510 of that NDJSON file." + +Expected: agent calls `read_file` with `offset: 2500, limit: 11` (or similar). Tool result contains lines 2500-2510 only, prefixed with their line numbers, `truncated: false`, `totalLines: 5000`. Agent reports the entry IDs back correctly. + +- [ ] **Step 4: Verify `grep_file` on a single file** + +Ask the agent: +> "Find the entry with id `id-4242` in that NDJSON file using grep_file." + +Expected: agent calls `grep_file` with `pattern: "\"id\":\"id-4242\""` and the explicit `path`. Result has exactly one match with `line: 4242`, the matching JSON, and 2 lines of context before/after. Agent reports the path field correctly (`/folder/file-4242.txt`). + +- [ ] **Step 5: Verify `grep_file` on a prefix scope** + +Ask the agent: +> "Find every line that says 'Hello' across `r2://my-files/`." + +Expected: agent calls `grep_file` with `pattern: "Hello"` and `prefix: "r2://my-files/"`. Result includes the match from the small file. NDJSON file has none. `truncated: false`. + +- [ ] **Step 6: Verify `grep_file` cap + truncation flag** + +Ask the agent: +> "Find every line that contains 'id-' in that NDJSON file. Cap matches at 50." + +Expected: agent calls `grep_file` with `pattern: "id-", path: "r2://my-files/r1-smoke-large.ndjson", max_matches: 50`. Result has 50 matches and `truncated: true`. Agent recognizes the cap and either narrows or proceeds. + +- [ ] **Step 7: Verify bad-pattern error path** + +Ask the agent: +> "Run grep_file with the pattern `[unclosed` against any file." + +Expected: agent calls grep_file, gets `{ok: false, error: "bad_pattern", detail: "..."}`. Agent does NOT retry blindly; explains the regex error to the user. + +- [ ] **Step 8: Verify binary-file refusal** + +If a PDF or image exists under any prefix, ask: +> "Grep that pdf for 'foo'." + +Expected: `{ok: false, error: "binary_not_supported", contentType: "application/pdf"}`. Agent reports the file is binary. + +(If no binary file is handy, skip — covered by the contentType filter logic; not worth seeding a binary just for one path.) + +- [ ] **Step 9: Cleanup** + +Ask the agent: +> "Delete `r2://my-files/r1-smoke-small.md` and `r2://my-files/r1-smoke-large.ndjson`." + +Expected: two delete_file calls, both succeed. + +--- + +## Task 6: Raise the PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin feat/sync-r1-read-primitives +``` + +- [ ] **Step 2: Open the PR via gh** + +Base the PR on `main` (per memory `feedback_main_branch_workflow`). The spec lives on `feat/sync-agent-design` (open as PR #19). PR-R1 stands alone — it doesn't depend on the spec PR merging first, so basing on `main` keeps the stack simple and lets reviewers merge in any order. + +```bash +gh pr create --base main --title "feat(tools): read_file slice/gutter + new grep_file (PR-R1)" --body "$(cat <<'EOF' +## Summary +- Extends `read_file` with `offset`/`limit` (1-based line slicing) plus a line-number gutter on output. Default no-args behavior unchanged except for the gutter (still truncates at 64KB with `truncated:true`). +- Adds `grep_file`: server-side regex over R2 text files. Scope by single `path` or recursive `prefix`. Returns matching lines + N context lines. Caps at 100 matches by default. Binary files skipped automatically. +- Sets `touchesFS: false` on `read_file` (small bug fix — read-only tools shouldn't open a per-call worktree). +- One-sentence prompt update naming `grep_file` and teaching the `truncated:true → switch to grep` pattern. + +## Spec reference +- `docs/superpowers/specs/2026-05-17-sync-agent-design.md` §3.1.1 + §6 PR-R1 +- Independent prerequisite for the sync-agent stack (F1/F2/F3) — unblocks NDJSON-mirror design assumptions but has no compile-time dependency on them. + +## Test plan +- [ ] Seed a small md + a 5000-line NDJSON in `r2://my-files/` +- [ ] `read_file` on the NDJSON returns 64KB truncated head with line gutter + size +- [ ] `read_file` with `{offset: 2500, limit: 11}` returns the explicit slice, no truncation +- [ ] `grep_file` on `path` for a single id returns exactly one match with context +- [ ] `grep_file` on `prefix` matches across the small file +- [ ] `grep_file` with `max_matches: 50` returns 50 matches + `truncated: true` +- [ ] `grep_file` with `pattern: "[unclosed"` returns `{ok:false, error:"bad_pattern"}` +- [ ] Binary file → `{ok:false, error:"binary_not_supported"}` +- [ ] Cleanup deletes succeed + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 3: Return the PR URL** + +Echo the URL so it's visible at the end of the run. + +--- + +## Done criteria + +- All checkboxes above are checked. +- The 9-step agent smoke (Task 5) passes end-to-end through the CLI. +- `typecheck` is clean, oxlint/oxfmt are quiet. +- PR is opened against `main` with the test-plan checklist visible. From 92195bd767ba29853ed8e1b2af023adb72f33064 Mon Sep 17 00:00:00 2001 From: Ronit Date: Mon, 18 May 2026 12:12:13 +0530 Subject: [PATCH 2/7] feat(tools): read_file gets offset/limit + line gutter; touchesFS=false --- apps/kernel/src/tools/fs-tools.ts | 77 +++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/apps/kernel/src/tools/fs-tools.ts b/apps/kernel/src/tools/fs-tools.ts index 01f9635..6cfa1d3 100644 --- a/apps/kernel/src/tools/fs-tools.ts +++ b/apps/kernel/src/tools/fs-tools.ts @@ -10,23 +10,55 @@ const TEXT_CT_RE = const READ_CAP = 64 * 1024; const WRITE_CAP = 1024 * 1024; +/** + * Render text as `\t` per line so the agent can address + * sub-ranges by number in follow-up read_file / grep_file calls. + * Line numbers start at `startLine` (1-based; caller chooses). + */ +function formatWithLineGutter(text: string, startLine: number): string { + const lines = text.split("\n"); + // Trailing newline produces an empty final element — preserve it as a + // blank line so the rendered output round-trips with the file. + return lines + .map((line, i) => `${startLine + i}\t${line}`) + .join("\n"); +} + export const buildFSTools = (perTurn: PerTurnContext) => { return { readFile: wrappedTool( { name: "read_file", description: - "Read a text file from R2: memory files, CSV/JSON/YAML/XML/HTML/code, markdown, logs, configs. Refuses binary content (PDFs, images, archives); for those, process_attachment understands them, or env.FS.readFile inside exec_code gets you the raw bytes. Truncates at 64KB with truncated:true; the full size comes back so you can switch to exec_code (no cap there) when needed.", + "Read a text file from R2 with a line-number gutter (`\\t` per line) so you can address sub-ranges in follow-up calls. Default behavior (no offset/limit): returns the first 64KB with `truncated:true` + full `size` if the file is bigger — switch to `grep_file` for substring/regex search or call again with {offset, limit} for a deeper slice. With `offset` (1-based line) + optional `limit` (default 2000 lines), returns the explicit line range and bypasses the 64KB cap. Memory files, CSV/JSON/NDJSON/YAML/XML/HTML/code, markdown, logs, configs all work; refuses binary content (PDFs, images, archives — use process_attachment or env.FS.readFile inside exec_code).", inputSchema: z.object({ path: z .string() .startsWith("r2://") .describe( "Full r2:// path, e.g. r2://memory/USER_PROFILE.md, r2://my-files/notes.csv, r2://artifacts/data.json" + ), + offset: z + .number() + .int() + .min(1) + .optional() + .describe( + "1-based line number to start reading from. Combined with `limit`, returns a contiguous line range. Bypasses the 64KB cap (use this to read deeper into big files like NDJSON indexes)." + ), + limit: z + .number() + .int() + .min(1) + .max(10000) + .optional() + .describe( + "Maximum number of lines to return when `offset` is set. Defaults to 2000." ) }), needsApproval: false, - execute: async ({ path }, ctx) => { + touchesFS: false, + execute: async ({ path, offset, limit }, ctx) => { const fs = buildKernelEnvFS(ctx.env, ctx.threadId, ctx.callId); try { const meta = await fs.stat(path); @@ -45,11 +77,48 @@ export const buildFSTools = (perTurn: PerTurnContext) => { } const { content, contentType, size } = await fs.readFile(path); const text = new TextDecoder().decode(content); + + // Explicit-slice path: offset/limit set. Bypass the 64KB cap; the + // caller asked for a specific line range on purpose. + if (offset !== undefined) { + const effLimit = limit ?? 2000; + const lines = text.split("\n"); + const startIdx = offset - 1; + if (startIdx >= lines.length) { + return { + ok: true as const, + path, + content: "", + contentType, + size, + startLine: offset, + endLine: offset - 1, + totalLines: lines.length, + truncated: false as const + }; + } + const endIdx = Math.min(startIdx + effLimit, lines.length); + const slice = lines.slice(startIdx, endIdx).join("\n"); + return { + ok: true as const, + path, + content: formatWithLineGutter(slice, offset), + contentType, + size, + startLine: offset, + endLine: startIdx + (endIdx - startIdx), + totalLines: lines.length, + truncated: false as const + }; + } + + // Default path: no offset/limit. Apply 64KB cap, truncate with flag. if (text.length > READ_CAP) { + const head = text.slice(0, READ_CAP); return { ok: true as const, path, - content: text.slice(0, READ_CAP), + content: formatWithLineGutter(head, 1), contentType, size, truncated: true as const @@ -58,7 +127,7 @@ export const buildFSTools = (perTurn: PerTurnContext) => { return { ok: true as const, path, - content: text, + content: formatWithLineGutter(text, 1), contentType, size, truncated: false as const From e216615bb80fe36b37112b2ac0b7db651cb536da Mon Sep 17 00:00:00 2001 From: Ronit Date: Mon, 18 May 2026 12:12:50 +0530 Subject: [PATCH 3/7] feat(tools): add grep_file for server-side regex over R2 --- apps/kernel/src/tools/fs-tools.ts | 173 ++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/apps/kernel/src/tools/fs-tools.ts b/apps/kernel/src/tools/fs-tools.ts index 6cfa1d3..f9dcf97 100644 --- a/apps/kernel/src/tools/fs-tools.ts +++ b/apps/kernel/src/tools/fs-tools.ts @@ -24,6 +24,47 @@ function formatWithLineGutter(text: string, startLine: number): string { .join("\n"); } +interface GrepMatch { + file: string; + line: number; + match: string; + before: string[]; + after: string[]; +} + +/** + * Scan a single file's text for regex matches. Returns up to `remaining` + * matches, each with `contextLines` lines of before/after context. + * + * The regex is reset between line scans (no global-flag state leakage). + */ +function scanFileForMatches( + filePath: string, + text: string, + regex: RegExp, + contextLines: number, + remaining: number +): GrepMatch[] { + if (remaining <= 0) return []; + const lines = text.split("\n"); + const out: GrepMatch[] = []; + for (let i = 0; i < lines.length && out.length < remaining; i++) { + // `lastIndex` reset guards against /g flag accumulating state. + regex.lastIndex = 0; + if (!regex.test(lines[i]!)) continue; + const before = lines.slice(Math.max(0, i - contextLines), i); + const after = lines.slice(i + 1, Math.min(lines.length, i + 1 + contextLines)); + out.push({ + file: filePath, + line: i + 1, // 1-based to match read_file's gutter + match: lines[i]!, + before, + after + }); + } + return out; +} + export const buildFSTools = (perTurn: PerTurnContext) => { return { readFile: wrappedTool( @@ -353,6 +394,138 @@ export const buildFSTools = (perTurn: PerTurnContext) => { perTurn ), + grepFile: wrappedTool( + { + name: "grep_file", + description: + "Server-side regex search over R2 text files. Returns only matching lines + N lines of context — the file itself stays in the Worker, so your context budget tracks matches, not file size. Use this instead of read_file whenever a mirror file might exceed 64KB or you need to find by predicate across many files. Scope: exactly one of `path` (single file) or `prefix` (recurses, reads every text file under the prefix). `pattern` is a JS regex string; default flags are `m` (line-anchored). Caps: 100 matches across all files in scope by default, set `max_matches` to raise (max 1000). Binary files are skipped automatically.", + inputSchema: z.object({ + pattern: z + .string() + .min(1) + .describe( + "JS regex string (no slashes). Examples: '\"path\":\"/Personal/Tax/' (substring), '^id:[a-f0-9]+\\\\b' (anchored), 'TODO|FIXME' (alternation)." + ), + path: z + .string() + .startsWith("r2://") + .optional() + .describe( + "Full r2:// path of a single file to search. Mutually exclusive with `prefix`." + ), + prefix: z + .string() + .startsWith("r2://") + .optional() + .describe( + "r2:// prefix to recurse under (e.g. 'r2://skills/' or 'r2://integrations/dropbox/'). Mutually exclusive with `path`. Reads every text file at that prefix and runs the pattern across each." + ), + regex_flags: z + .string() + .regex(/^[gimsuy]*$/) + .optional() + .describe( + "Regex flags. Default 'm' (line-anchored). Pass 'mi' for case-insensitive, 'ms' for dotall, etc. Avoid 'g' — it's already implicit per-line." + ), + context_lines: z + .number() + .int() + .min(0) + .max(10) + .optional() + .describe( + "Lines of context before/after each match. Default 2." + ), + max_matches: z + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe( + "Cap on total matches returned across all files in scope. Default 100. Past the cap the result carries truncated:true so you know to narrow." + ) + }), + needsApproval: false, + touchesFS: false, + execute: async ( + { pattern, path, prefix, regex_flags, context_lines, max_matches }, + ctx + ) => { + // Exactly-one-of scope validation. + if ((path === undefined) === (prefix === undefined)) { + return { + ok: false as const, + error: "bad_scope" as const, + detail: + "specify exactly one of `path` (single file) or `prefix` (recursive search)" + }; + } + + // Compile the regex up front so a bad pattern surfaces before any R2 read. + let regex: RegExp; + try { + regex = new RegExp(pattern, regex_flags ?? "m"); + } catch (e) { + return { + ok: false as const, + error: "bad_pattern" as const, + detail: e instanceof Error ? e.message : String(e) + }; + } + + const ctxLines = context_lines ?? 2; + const cap = max_matches ?? 100; + const fs = buildKernelEnvFS(ctx.env, ctx.threadId, ctx.callId); + const matches: GrepMatch[] = []; + + try { + if (path !== undefined) { + const meta = await fs.stat(path); + if (!meta) { + return { ok: false as const, error: "not_found" as const, path }; + } + if (!TEXT_CT_RE.test(meta.contentType)) { + return { + ok: false as const, + error: "binary_not_supported" as const, + detail: `contentType ${meta.contentType} is binary; grep_file is text-only`, + path, + contentType: meta.contentType + }; + } + const { content } = await fs.readFile(path); + const text = new TextDecoder().decode(content); + matches.push( + ...scanFileForMatches(path, text, regex, ctxLines, cap - matches.length) + ); + } else { + // prefix scope — enumerate files, skip binaries, scan each. + const files = await fs.listFiles(prefix!); + for (const f of files) { + if (matches.length >= cap) break; + if (!TEXT_CT_RE.test(f.contentType)) continue; + const { content } = await fs.readFile(f.path); + const text = new TextDecoder().decode(content); + matches.push( + ...scanFileForMatches(f.path, text, regex, ctxLines, cap - matches.length) + ); + } + } + } catch (e) { + return mapEnvFSError("grep_file", e); + } + + return { + ok: true as const, + matches, + truncated: matches.length >= cap + }; + } + }, + perTurn + ), + deleteFile: wrappedTool( { name: "delete_file", From bb5a0321f1d4e1f81294d3918121af5e51e6d6b4 Mon Sep 17 00:00:00 2001 From: Ronit Date: Mon, 18 May 2026 12:13:07 +0530 Subject: [PATCH 4/7] feat(prompt): teach main agent grep_file + read_file slice pattern --- apps/kernel/src/agent/system-prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/kernel/src/agent/system-prompt.ts b/apps/kernel/src/agent/system-prompt.ts index 5772f3c..e5f8b99 100644 --- a/apps/kernel/src/agent/system-prompt.ts +++ b/apps/kernel/src/agent/system-prompt.ts @@ -74,7 +74,7 @@ You have five surfaces. Pick the one that matches the work, not the one that's m **computer_bash and the sandbox suite** for work that needs real Unix: pip, apt, ffmpeg, pandoc, git clones, multi-step builds, dev servers. Backed by an R2-mounted FUSE so \`/r2/*\` survives across turns. Slower than exec_code, much more capable. Use it when JS-only would be a stretch, not by default. -**read_file / write_file / edit_file / list_files / find_files / move_file / delete_file / get_signed_url** for direct R2 ops on text content. \`edit_file\` is the surgical one: read-then-replace a unique anchor, no full-file rewrite. Use it for memory updates and any single-section edit. If your work around the FS call is more than three lines of logic, switch to \`env.FS\` inside exec_code instead. +**read_file / grep_file / write_file / edit_file / list_files / find_files / move_file / delete_file / get_signed_url** for direct R2 ops on text content. \`grep_file\` runs regex server-side and returns only matching lines + context — reach for it whenever the file might exceed 64KB or you need to find by predicate across many files. \`read_file\` returns a line-number gutter; if you get \`truncated:true\` on a big file, switch to \`grep_file\` or call \`read_file\` again with \`{offset, limit}\` for a deeper slice. \`edit_file\` is the surgical one: read-then-replace a unique anchor, no full-file rewrite. Use it for memory updates and any single-section edit. If your work around the FS call is more than three lines of logic, switch to \`env.FS\` inside exec_code instead. **process_attachment** to understand binary content (PDFs, images) without bloating your context with raw bytes. PDFs go through Sonnet 4.6 server-side rendering, full text plus vision. For modifying binary files (writing PDFs, generating derivatives) use exec_code with a real parser. From cc1047d9e67ab6e1b72551726980804d0db70c45 Mon Sep 17 00:00:00 2001 From: Ronit Date: Mon, 18 May 2026 12:15:34 +0530 Subject: [PATCH 5/7] style: oxfmt --- apps/kernel/src/tools/fs-tools.ts | 41 ++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/kernel/src/tools/fs-tools.ts b/apps/kernel/src/tools/fs-tools.ts index f9dcf97..0906399 100644 --- a/apps/kernel/src/tools/fs-tools.ts +++ b/apps/kernel/src/tools/fs-tools.ts @@ -19,12 +19,10 @@ function formatWithLineGutter(text: string, startLine: number): string { const lines = text.split("\n"); // Trailing newline produces an empty final element — preserve it as a // blank line so the rendered output round-trips with the file. - return lines - .map((line, i) => `${startLine + i}\t${line}`) - .join("\n"); + return lines.map((line, i) => `${startLine + i}\t${line}`).join("\n"); } -interface GrepMatch { +export interface GrepMatch { file: string; line: number; match: string; @@ -53,7 +51,10 @@ function scanFileForMatches( regex.lastIndex = 0; if (!regex.test(lines[i]!)) continue; const before = lines.slice(Math.max(0, i - contextLines), i); - const after = lines.slice(i + 1, Math.min(lines.length, i + 1 + contextLines)); + const after = lines.slice( + i + 1, + Math.min(lines.length, i + 1 + contextLines) + ); out.push({ file: filePath, line: i + 1, // 1-based to match read_file's gutter @@ -280,7 +281,9 @@ export const buildFSTools = (perTurn: PerTurnContext) => { }; } const next = - text.slice(0, first) + new_string + text.slice(first + old_string.length); + text.slice(0, first) + + new_string + + text.slice(first + old_string.length); if (next.length > WRITE_CAP) { return { ok: false as const, @@ -433,9 +436,7 @@ export const buildFSTools = (perTurn: PerTurnContext) => { .min(0) .max(10) .optional() - .describe( - "Lines of context before/after each match. Default 2." - ), + .describe("Lines of context before/after each match. Default 2."), max_matches: z .number() .int() @@ -483,7 +484,11 @@ export const buildFSTools = (perTurn: PerTurnContext) => { if (path !== undefined) { const meta = await fs.stat(path); if (!meta) { - return { ok: false as const, error: "not_found" as const, path }; + return { + ok: false as const, + error: "not_found" as const, + path + }; } if (!TEXT_CT_RE.test(meta.contentType)) { return { @@ -497,7 +502,13 @@ export const buildFSTools = (perTurn: PerTurnContext) => { const { content } = await fs.readFile(path); const text = new TextDecoder().decode(content); matches.push( - ...scanFileForMatches(path, text, regex, ctxLines, cap - matches.length) + ...scanFileForMatches( + path, + text, + regex, + ctxLines, + cap - matches.length + ) ); } else { // prefix scope — enumerate files, skip binaries, scan each. @@ -508,7 +519,13 @@ export const buildFSTools = (perTurn: PerTurnContext) => { const { content } = await fs.readFile(f.path); const text = new TextDecoder().decode(content); matches.push( - ...scanFileForMatches(f.path, text, regex, ctxLines, cap - matches.length) + ...scanFileForMatches( + f.path, + text, + regex, + ctxLines, + cap - matches.length + ) ); } } From 7e85f7f379a657fad2c7b27f78b0a8bd20cb4672 Mon Sep 17 00:00:00 2001 From: Ronit Date: Mon, 18 May 2026 14:33:20 +0530 Subject: [PATCH 6/7] fix(fs): recognize .ndjson / .jsonl as text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoke caught read_file refusing seed-sample.ndjson as binary. Root cause: .ndjson wasn't in the EXT_CONTENT_TYPE map, so the path-extension sniff fell back to application/octet-stream — and per commit-driver.ts:637 the materialize step always re-sniffs from path on canonical writes, so custom contentTypes passed at writeFile() time don't survive the git-pipeline commit anyway. The only viable fix is the extension map. - env-fs.ts: map ndjson + jsonl → application/x-ndjson. - fs-tools.ts: extend TEXT_CT_RE to accept application/x-ndjson and application/x-jsonl so read_file / grep_file recognize them as text. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/kernel/src/fs/env-fs.ts | 2 ++ apps/kernel/src/tools/fs-tools.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/kernel/src/fs/env-fs.ts b/apps/kernel/src/fs/env-fs.ts index 201094e..0e826a5 100644 --- a/apps/kernel/src/fs/env-fs.ts +++ b/apps/kernel/src/fs/env-fs.ts @@ -196,6 +196,8 @@ const EXT_CONTENT_TYPE: Record = { csv: "text/csv; charset=utf-8", tsv: "text/tab-separated-values; charset=utf-8", json: "application/json", + ndjson: "application/x-ndjson", + jsonl: "application/x-ndjson", yaml: "application/yaml", yml: "application/yaml", xml: "application/xml", diff --git a/apps/kernel/src/tools/fs-tools.ts b/apps/kernel/src/tools/fs-tools.ts index 0906399..338949a 100644 --- a/apps/kernel/src/tools/fs-tools.ts +++ b/apps/kernel/src/tools/fs-tools.ts @@ -6,7 +6,7 @@ import { R2PathError } from "../fs/path"; import { wrappedTool, type PerTurnContext } from "./wrapped-tool"; const TEXT_CT_RE = - /^(text\/|application\/(json|xml|yaml|javascript|typescript|sql|toml|.*\+json|.*\+xml)|image\/svg\+xml)/i; + /^(text\/|application\/(json|xml|yaml|javascript|typescript|sql|toml|x-ndjson|x-jsonl|.*\+json|.*\+xml)|image\/svg\+xml)/i; const READ_CAP = 64 * 1024; const WRITE_CAP = 1024 * 1024; From 7ab0e1ba97c6db6af43a7de81df40d4e8809f7c4 Mon Sep 17 00:00:00 2001 From: Ronit Date: Mon, 18 May 2026 14:43:05 +0530 Subject: [PATCH 7/7] fix(tools): fall back to path-sniff when stored contentType is stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoke caught a second issue: even after fixing the EXT_CONTENT_TYPE map, canonical R2 holds the old "application/octet-stream" httpMetadata for files written before the fix. materializeMainToCanonical only writes canonical when the git blob sha changes — same-content rewrites don't refresh stored metadata. So a fresh sniff returns x-ndjson, but the stored type stays binary, and the tool's gate rejects. Adds `resolveTextContentType(stored, path)`: trust stored if it's already text-shaped; otherwise re-sniff from the path extension and prefer that when it lands in TEXT_CT_RE. Applied to read_file, edit_file, and grep_file (both single-path and prefix-loop branches). The error path still surfaces the stored type so the agent's reasoning matches what the agent sees in stat() output. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/kernel/src/tools/fs-tools.ts | 41 +++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/kernel/src/tools/fs-tools.ts b/apps/kernel/src/tools/fs-tools.ts index 338949a..067ee49 100644 --- a/apps/kernel/src/tools/fs-tools.ts +++ b/apps/kernel/src/tools/fs-tools.ts @@ -1,7 +1,11 @@ import { z } from "zod"; import { buildKernelEnvFS } from "../fs/build-kernel-env-fs"; -import { EnvFSError, type FileMeta } from "../fs/env-fs"; +import { + EnvFSError, + sniffContentTypeFromKey, + type FileMeta +} from "../fs/env-fs"; import { R2PathError } from "../fs/path"; import { wrappedTool, type PerTurnContext } from "./wrapped-tool"; @@ -10,6 +14,24 @@ const TEXT_CT_RE = const READ_CAP = 64 * 1024; const WRITE_CAP = 1024 * 1024; +/** + * Pick the contentType we'll trust for the text-or-binary gate. If R2's + * stored httpMetadata is already text-shaped, keep it. Otherwise re-sniff + * from the path extension and prefer that if it lands in TEXT_CT_RE. + * + * Why: `materializeMainToCanonical` in commit-driver.ts re-sniffs from path + * on every write, but skips writes when the git blob sha is unchanged. A + * file written before its extension was added to EXT_CONTENT_TYPE keeps a + * stale `application/octet-stream` on canonical R2 even after the sniff is + * fixed; only a content-change rewrite would refresh it. Path-based fallback + * makes the tool resilient to that drift. + */ +function resolveTextContentType(storedCT: string, path: string): string { + if (TEXT_CT_RE.test(storedCT)) return storedCT; + const sniffed = sniffContentTypeFromKey(path); + return TEXT_CT_RE.test(sniffed) ? sniffed : storedCT; +} + /** * Render text as `\t` per line so the agent can address * sub-ranges by number in follow-up read_file / grep_file calls. @@ -107,7 +129,8 @@ export const buildFSTools = (perTurn: PerTurnContext) => { if (!meta) { return { ok: false as const, error: "not_found" as const, path }; } - if (!TEXT_CT_RE.test(meta.contentType)) { + const effectiveCT = resolveTextContentType(meta.contentType, path); + if (!TEXT_CT_RE.test(effectiveCT)) { return { ok: false as const, error: "binary_not_supported" as const, @@ -117,7 +140,8 @@ export const buildFSTools = (perTurn: PerTurnContext) => { size: meta.size }; } - const { content, contentType, size } = await fs.readFile(path); + const { content, size } = await fs.readFile(path); + const contentType = effectiveCT; const text = new TextDecoder().decode(content); // Explicit-slice path: offset/limit set. Bypass the 64KB cap; the @@ -249,7 +273,8 @@ export const buildFSTools = (perTurn: PerTurnContext) => { if (!meta) { return { ok: false as const, error: "not_found" as const, path }; } - if (!TEXT_CT_RE.test(meta.contentType)) { + const effectiveCT = resolveTextContentType(meta.contentType, path); + if (!TEXT_CT_RE.test(effectiveCT)) { return { ok: false as const, error: "binary_not_supported" as const, @@ -258,7 +283,8 @@ export const buildFSTools = (perTurn: PerTurnContext) => { contentType: meta.contentType }; } - const { content, contentType } = await fs.readFile(path); + const { content } = await fs.readFile(path); + const contentType = effectiveCT; const text = new TextDecoder().decode(content); const first = text.indexOf(old_string); if (first === -1) { @@ -490,7 +516,8 @@ export const buildFSTools = (perTurn: PerTurnContext) => { path }; } - if (!TEXT_CT_RE.test(meta.contentType)) { + const effectiveCT = resolveTextContentType(meta.contentType, path); + if (!TEXT_CT_RE.test(effectiveCT)) { return { ok: false as const, error: "binary_not_supported" as const, @@ -515,7 +542,7 @@ export const buildFSTools = (perTurn: PerTurnContext) => { const files = await fs.listFiles(prefix!); for (const f of files) { if (matches.length >= cap) break; - if (!TEXT_CT_RE.test(f.contentType)) continue; + if (!TEXT_CT_RE.test(resolveTextContentType(f.contentType, f.path))) continue; const { content } = await fs.readFile(f.path); const text = new TextDecoder().decode(content); matches.push(