pi-readseek 0.5.19 → 0.6.1

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/README.md CHANGED
@@ -23,8 +23,8 @@ npm install --save-dev @jarkkojs/readseek
23
23
 
24
24
  ## Tools
25
25
 
26
- - **readSeek_read:** reads text files with `LINE:HASH` anchors; images can
27
- include local OCR, captions, and object text.
26
+ - **readSeek_read:** reads text with `LINE:HASH` anchors; images and PDFs can
27
+ be returned as base64 or analyzed locally.
28
28
  - **readSeek_edit:** edits existing text files using fresh `LINE:HASH` anchors.
29
29
  - **readSeek_grep:** searches text and returns edit-ready anchors.
30
30
  - **readSeek_search:** searches code by structural AST pattern.
@@ -49,7 +49,7 @@ optional (defaults shown):
49
49
  {
50
50
  "readseek": {
51
51
  "replacedTools": [],
52
- "imageMode": "force",
52
+ "imageMode": "auto",
53
53
  "syntaxValidation": "warn",
54
54
  "timeoutMs": 120000,
55
55
  "grep": {
@@ -63,10 +63,9 @@ optional (defaults shown):
63
63
  - **replacedTools:** built-in tool names to replace with their `readSeek_*` equivalents.
64
64
  Valid values are `"read"`, `"edit"`, `"write"`, and `"grep"`. For a
65
65
  readseek-only file surface, use `["read", "edit", "write", "grep"]`.
66
- - **imageMode:** image OCR/caption/object analysis in `readSeek_read`: `"force"`
67
- (or its alias `"on"`) always runs it, `"off"` returns only the image
68
- attachment, and `"auto"` runs it only when the active model does not support
69
- native image input.
66
+ - **imageMode:** `"auto"` exposes `none`, `ocr`, `caption`, and `objects`;
67
+ `"on"` omits `none`; `"off"` skips image/PDF files. Omitting `image` also
68
+ skips visual files.
70
69
  - **syntaxValidation:** pre-write syntax-regression check in `readSeek_edit`:
71
70
  `"warn"` writes with a warning, `"block"` aborts without writing, `"off"`
72
71
  skips the check.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readseek",
3
- "version": "0.5.19",
3
+ "version": "0.6.1",
4
4
  "description": "Pi extension for readseek-backed hash-anchored read/edit/grep, structural code maps, structural search, and file exploration",
5
5
  "type": "module",
6
6
  "exports": {
@@ -39,7 +39,7 @@
39
39
  "node": ">=20.0.0"
40
40
  },
41
41
  "dependencies": {
42
- "@jarkkojs/readseek": "^0.5.19",
42
+ "@jarkkojs/readseek": "^0.6.1",
43
43
  "diff": "^8.0.3",
44
44
  "xxhash-wasm": "^1.1.0"
45
45
  },
package/prompts/def.md CHANGED
@@ -1,17 +1,14 @@
1
- Find structural symbol definitions. Calls `readseek def` which searches
2
- for the definition of a named symbol across a file or directory.
1
+ Find structural definitions by qualified or unqualified name across a file or
2
+ directory.
3
3
 
4
4
  ## Parameters
5
5
 
6
- - `path` (optional): File or directory to search (default: ".").
7
- - `name`: Qualified or unqualified symbol name.
8
- - `lang` (optional): Language override.
9
- - `cached` (optional): Search tracked/indexed files in a Git repository.
10
- - `others` (optional): Search untracked files.
11
- - `ignored` (optional): Include ignored untracked files.
6
+ - `path` file or directory, default `.`.
7
+ - `name` qualified or unqualified symbol name.
8
+ - `lang` language override.
9
+ - `cached`, `others`, `ignored` Git file selection; `ignored` requires `others`.
12
10
 
13
11
  ## When to use
14
12
 
15
- - After a `readSeek_hover` call, use the qualified symbol name to jump to its definition.
16
- - When the user asks "where is X defined?".
17
- - To find a function/class/type definition by its qualified name.
13
+ - After `readSeek_hover`, use its qualified symbol name.
14
+ - When asked where a function, class, or type is defined.
package/prompts/edit.md CHANGED
@@ -1,6 +1,6 @@
1
- Surgically edit existing text files. Prefer hash-verified anchors from fresh `readSeek_read`, `readSeek_grep`, `readSeek_search`, or `readSeek_write` output; copy `LINE:HASH` anchors exactly.
2
-
3
- `readSeek_edit` requires the target file to have been anchored earlier in the current session. If you get `file-not-read`, run `readSeek_read`, `readSeek_grep`, `readSeek_search`, or `readSeek_write` first.
1
+ Edit existing text files with fresh `LINE:HASH` anchors from `readSeek_read`,
2
+ `readSeek_grep`, `readSeek_search`, or `readSeek_write`. The file must have
3
+ been anchored in this session; on `file-not-read`, read or search it first.
4
4
 
5
5
  ## Variants
6
6
 
@@ -9,12 +9,11 @@ Surgically edit existing text files. Prefer hash-verified anchors from fresh `re
9
9
  | `set_line` | Replace or delete one line | 1 |
10
10
  | `replace_lines` | Replace or delete one contiguous range | 2 |
11
11
  | `insert_after` | Insert after an existing line | 1 |
12
- | `replace_symbol` | Replace one function/class/method/interface/type/enum/etc. | 0 (`symbol`) |
13
- | `replace` | Exact string replacement escape hatch; one match by default, all with `all: true` | 0 |
14
-
15
- Set `new_text` (or `replace_lines.new_text`) to `""` to delete anchored line(s). For an intentionally blank line, use `"\n"` or whitespace content, not `""`.
12
+ | `replace_symbol` | Replace one mapped symbol | 0 (`symbol`) |
13
+ | `replace` | Exact string replacement; one match unless `all: true` | 0 |
16
14
 
17
- Prefer `set_line`, `replace_lines`, and `insert_after`: they verify that the file still matches the anchored content. Use `replace` only when anchors are impractical, such as repeated text across many unrelated lines.
15
+ Set `new_text` to `""` to delete lines; use `"\n"` for a blank line. Prefer
16
+ anchored variants. Use `replace` only when anchors are impractical.
18
17
 
19
18
  ## Input shape
20
19
 
@@ -31,92 +30,41 @@ Prefer `set_line`, `replace_lines`, and `insert_after`: they verify that the fil
31
30
  }
32
31
  ```
33
32
 
34
- Use only the needed variant(s); the example shows all shapes for reference. Each `edits[]` entry must contain exactly one variant key. `new_text` / `new_body` is plain file content — no hash prefixes or diff markers.
33
+ Use only needed variants. Each `edits[]` entry has exactly one key. `new_text`
34
+ and `new_body` are plain file content, not diffs or hashlines.
35
35
 
36
36
  ## Exact and fuzzy replacement
37
37
 
38
- `replace` is exact-only by default. Missing `old_text` fails with `text-not-found`.
39
-
40
- Wrap string replacements as `{ "replace": { "old_text": "...", "new_text": "..." } }`; a bare top-level `{ old_text, new_text }` inside `edits[]` is rejected with guidance.
41
-
42
- `fuzzy: true` is a narrow fallback after exact matching fails. It normalizes whitespace and confusable Unicode such as smart hyphens; it is **not** approximate, Levenshtein, or semantic matching. Fuzzy successes return a warning.
38
+ `replace` is exact by default; missing text fails. `fuzzy: true` only normalizes
39
+ whitespace and confusable Unicode after exact matching fails. Verify warned fuzzy
40
+ matches before continuing.
43
41
 
44
42
  ## `replace_symbol`
45
43
 
46
- Use `replace_symbol` when you want to replace one whole mapped symbol without line anchors. Query symbols like `readSeek_read({ symbol })`: `Name`, `Class.method`, or `Name@<line>`.
47
-
48
- Rules:
49
-
50
- - Use an exact name, dotted path, or `@<line>`. If `readSeek_read({ symbol })` returned a fuzzy match, confirm the exact symbol first.
51
- - Supported for TypeScript, JavaScript, Rust, and Java. For other languages, use anchored edits.
52
- - `new_body` must not be empty or whitespace-only.
53
- - Write `new_body` without extra leading indentation; `readSeek_edit` re-indents it to match the original symbol.
54
- - If `new_body` appears to declare a different symbol name, the edit still applies but returns a `name-mismatch` warning.
55
- - Do not combine `replace_symbol` with anchored edits that touch the same lines. Duplicate or overlapping `replace_symbol` ranges are rejected.
44
+ Use `replace_symbol` for one whole mapped `Name`, `Class.method`, or
45
+ `Name@<line>` in TypeScript, JavaScript, Rust, or Java. `new_body` must be
46
+ non-empty and unindented. Confirm fuzzy symbol matches first; do not overlap it
47
+ with anchored edits.
56
48
 
57
49
  ## Stale anchors
58
50
 
59
- If anchors no longer match, `readSeek_edit` fails with `hash-mismatch` and shows nearby current lines. Lines marked `>>>` include updated anchors:
51
+ On `hash-mismatch`, nearby lines marked `>>>` include fresh anchors:
60
52
 
61
53
  ```text
62
54
  >>> 41:b34| const renamed = 3;
63
55
  ```
64
56
 
65
- Copy the updated `LINE:HASH` and retry. If the target moved farther away, re-run `readSeek_read`, `readSeek_grep`, `readSeek_search`, or `readSeek_write` for fresh anchors.
66
-
67
- If `readSeek_edit` auto-relocates an anchor, read the warning and verify that the edit landed in the intended place.
57
+ Retry with those anchors, or read/search again. Verify any auto-relocation warning.
68
58
 
69
59
  ## Validation and warnings
70
60
 
71
- - All edits are checked before writing; if a hard validation fails, nothing is written.
72
- - Anchored edits are applied bottom-up so line numbers stay stable.
73
- - `no-op` means the requested edit matched the current file already or produced identical content.
74
- - Whitespace-only warnings mean formatting changed but behavior probably did not.
75
- - A `replace`-only success may remind you to prefer anchored edits next time.
76
-
77
- Syntax validation runs before writing when supported:
78
-
79
- - Supported: Rust, C++, C headers, Java.
80
- - Default `warn`: write succeeds, but warnings include `syntax-regression: lines X-Y`.
81
- - `block`: aborts without writing.
82
- - `off`: skips validation.
83
- - The `readseek.syntaxValidation` setting can change the default mode.
84
-
85
- Existing syntax errors are tolerated; warnings are for newly introduced parser errors.
61
+ All edits validate before writing; hard failures write nothing. Anchored edits run
62
+ bottom-up. `no-op` means no change. Syntax validation for Rust, C++, C headers,
63
+ and Java follows `readseek.syntaxValidation`: `warn` (default), `block`, or `off`.
64
+ It reports only newly introduced parser errors.
86
65
 
87
66
  ## Optional post-edit verification
88
67
 
89
- `postEditVerify: true` opts into read-back verification for this call. It is off by default. When enabled, `readSeek_edit` writes normally, then reads the file back and compares persisted content to the intended content, including BOM and original line endings. This is not syntax validation.
90
-
91
- ## Diff data contract
92
-
93
- Successful `readSeek_edit` results include:
94
-
95
- - `details.diff` and `details.readSeekValue.diff`: compact human-readable hashline diff strings.
96
- - `details.patch`: standard unified diff with file and hunk headers.
97
- - `details.diffData` and `details.readSeekValue.diffData`: stable structured diff data.
98
-
99
- `diffData` shape:
100
-
101
- ```ts
102
- type DiffData = {
103
- version: 1;
104
- entries: Array<
105
- | { kind: "context"; oldLine: number; newLine: number; text: string }
106
- | { kind: "add"; newLine: number; text: string }
107
- | { kind: "remove"; oldLine: number; text: string }
108
- | { kind: "meta"; text: string }
109
- >;
110
- stats: { added: number; removed: number; context: number };
111
- language?: string;
112
- blockRanges?: Array<{ kind: "add" | "remove"; startLine: number; endLine: number }>;
113
- inlineDiffs?: Array<{
114
- removeLineIndex: number;
115
- addLineIndex: number;
116
- removeSpans: Array<{ kind: "equal" | "remove" | "add"; text: string }>;
117
- addSpans: Array<{ kind: "equal" | "remove" | "add"; text: string }>;
118
- }>;
119
- };
120
- ```
121
-
122
- For compact one-line hashline diffs, `details.diff` remains compact while `diffData.entries` uses expanded remove/add rows so renderers can show inline word changes without breaking hashline output.
68
+ `postEditVerify: true` reads back the written file and compares the persisted
69
+ content, including BOM and line endings. Results provide a compact hashline diff,
70
+ a unified patch, and structured `details.diffData`.
package/prompts/hover.md CHANGED
@@ -1,15 +1,11 @@
1
- Use `readSeek_hover` to identify the identifier and enclosing symbol at a cursor
2
- position. Calls `readseek identify` with the file content sent via stdin
3
- so unsaved editor content is included.
1
+ Identify the identifier and enclosing symbol at a cursor. Unsaved editor content
2
+ is included.
4
3
 
5
4
  ## Parameters
6
5
 
7
- - `path` (required): File path.
8
- - `line` (required): One-based cursor line.
9
- - `column` (optional): One-based cursor byte column.
6
+ - `path`, `line` required file path and one-based cursor line.
7
+ - `column` optional one-based cursor byte column.
10
8
 
11
9
  ## When to use
12
10
 
13
- - Before a `readSeek_rename`, to confirm the identifier under the cursor.
14
- - Before a go-to-definition, to get the qualified symbol name.
15
- - To inspect what symbol a specific line belongs to.
11
+ - Before rename or go-to-definition, or to identify a line's enclosing symbol.
package/prompts/read.md CHANGED
@@ -1,21 +1,22 @@
1
- Read files through readseek. Text output uses `LINE:HASH|content` anchors that can be copied directly into `readSeek_edit`; supported images return attachments and may append OCR/caption/object text depending on `readseek.imageMode`. Default cap: {{DEFAULT_MAX_LINES}} lines or {{DEFAULT_MAX_BYTES}}.
1
+ Read files through readseek. Text results use `LINE:HASH|content` anchors for `readSeek_edit`. Images and PDFs require an explicit available `image` mode; omitting it skips them. Default cap: {{DEFAULT_MAX_LINES}} lines or {{DEFAULT_MAX_BYTES}}.
2
2
 
3
3
  ## Choose the right read
4
4
 
5
- - Normal read: inspect a whole small file or a targeted `offset` / `limit` range.
6
- - `map: true`: append a structural map for navigation before reading more code.
7
- - `symbol: "Name"`: read one function, class, method, interface, type, enum, or similar symbol.
8
- - `bundle: "local"`: with `symbol`, include direct same-file local support when readseek can identify it.
5
+ - Normal read: a small file or an `offset` / `limit` range.
6
+ - `map: true`: append a structural map.
7
+ - `symbol: "Name"`: read one mapped symbol.
8
+ - `bundle: "local"`: include direct same-file support for a symbol.
9
9
 
10
10
  ## Parameters
11
11
 
12
12
  - `path` — file path.
13
- - `offset` / `limit` — positive line numbers; `offset` is 1-indexed.
14
- - `map` — append the full-file structural map; cannot combine with `symbol` or `bundle`.
15
- - `symbol` — symbol query; supports `Class.method`, package-relative Java names, and `Name@<line>` disambiguation; cannot combine with `offset` / `limit`.
16
- - `bundle` — only `"local"`; requires `symbol` and cannot combine with `map`.
13
+ - `offset` / `limit` — positive lines; `offset` is 1-indexed.
14
+ - `map` — full-file map; incompatible with `symbol` or `bundle`.
15
+ - `symbol` — `Name`, `Class.method`, or `Name@<line>`; incompatible with `offset` / `limit`.
16
+ - `bundle` — only `"local"`; requires `symbol` and excludes `map`.
17
+ - `image` — an exposed image/PDF mode.
17
18
 
18
- When a full-file read is truncated, readseek appends a structural map automatically when available. Use map line ranges for follow-up `readSeek_read({ offset, limit })` calls.
19
+ Truncated full-file reads append a map when available. Use its ranges for follow-up reads.
19
20
 
20
21
  ## Symbol examples
21
22
 
@@ -29,13 +30,13 @@ When a full-file read is truncated, readseek appends a structural map automatica
29
30
 
30
31
  ## Symbol resolution
31
32
 
32
- `@<line>` only applies as a trailing suffix like `Foo.bar@42`; names such as `foo@bar` are ordinary queries. Resolution order: containing range nearest symbol starting at/after the requested line nearest symbol above it.
33
+ `@<line>` is only a trailing suffix, as in `Foo.bar@42`; `foo@bar` is an ordinary name. Resolution prefers the containing range, then the nearest symbol at or after the line, then one above it.
33
34
 
34
35
  Result behavior:
35
36
 
36
- - **Found**: returns only the symbol range with `[Symbol: name (kind), lines X-Y of Z]`.
37
- - **Ambiguous**: lists candidates and retry hints such as `name@<startLine>`.
38
- - **Fuzzy**: returns the best camelCase/substring match with a warning; verify before editing from those anchors.
39
- - **Not found** or **unmappable**: falls back to normal read with a warning and, when available, symbol suggestions.
37
+ - **Found:** the symbol range.
38
+ - **Ambiguous:** candidates and `name@<startLine>` retry hints.
39
+ - **Fuzzy:** a warned best match; verify before editing.
40
+ - **Not found/unmappable:** normal read with a warning and suggestions when available.
40
41
 
41
42
  Hash anchors from normal, symbol, and bundled reads are valid for `readSeek_edit` until the file changes.
package/prompts/refs.md CHANGED
@@ -1,10 +1,11 @@
1
- Find binding-accurate references to an identifier with readseek. Use it when you need every place a name is used — before renaming, deleting, or changing a symbol — and plain text search would be too broad. Results are grouped by file with edit-ready hashline anchors and the enclosing symbol for each reference.
1
+ Find identifier references before renaming, deleting, or changing a symbol when
2
+ text search is too broad. Results include edit-ready anchors and enclosing symbols.
2
3
 
3
4
  ## Parameters
4
5
 
5
6
  - `name` — identifier to find references for.
6
7
  - `path` — file or directory, default cwd.
7
- - `lang` — language hint; set it when syntax is ambiguous, extensionless, or generated.
8
+ - `lang` — language hint for ambiguous, extensionless, or generated code.
8
9
  - `scope` — restrict results to the binding under `line`/`column` in a single file. Requires `line`.
9
10
  - `line` — one-based cursor line, used with `scope`.
10
11
  - `column` — one-based cursor byte column, used with `scope`.
@@ -14,10 +15,13 @@ Find binding-accurate references to an identifier with readseek. Use it when you
14
15
 
15
16
  ## Scope
16
17
 
17
- Without `scope`, references match by identifier name across the target. With `scope` plus `line` (and optionally `column`), results are limited to the specific binding under that cursor in a single file, so shadowed or unrelated same-named identifiers are excluded.
18
+ Without `scope`, references match by name. With `scope` and `line` (optionally
19
+ `column`), results are limited to the cursor binding in one file and exclude
20
+ shadows.
18
21
 
19
22
  ## Git selection
20
23
 
21
- When searching a directory inside a Git repository, readseek defaults to tracked/indexed files plus untracked non-ignored files. Use `cached`, `others`, and `ignored` to narrow or expand that selection. `ignored` requires `others`.
24
+ In Git repositories, directory search includes tracked/indexed and untracked
25
+ non-ignored files. `ignored` requires `others`.
22
26
 
23
27
  Use `readSeek_grep` for plain text, `readSeek_search` for code shape, and `readSeek_refs` for identifier usage.
package/prompts/rename.md CHANGED
@@ -1,27 +1,18 @@
1
- Use `readSeek_rename` to rename an identifier (variable, function, type, etc.) with
2
- binding accuracy. The tool calls `readseek rename` which resolves the
3
- lexical binding under the cursor and renames every occurrence that binds
4
- to the same declaration.
1
+ Rename an identifier with binding accuracy. The cursor binding is resolved and
2
+ only occurrences of that declaration are changed.
5
3
 
6
4
  ## Parameters
7
5
 
8
- - `path` (required): File holding the binding to rename (a single regular file).
9
- - `line` (required): One-based cursor line of the binding to rename.
10
- - `column` (optional): One-based cursor byte column of the binding.
11
- - `to` (required): New name for the binding. Must be a plain identifier.
12
- - `workspace` (optional): When true, expands the rename across the project
13
- root. The cursor file remains binding-accurate; other files are matched
14
- by name (free uses only, local shadows excluded).
15
- - `apply` (optional, default true): When true, writes the edits to disk
16
- after verifying line hashes. When false, returns the plan only.
6
+ - `path`, `line`, `to` required file, one-based cursor line, and plain new name.
7
+ - `column` optional one-based cursor byte column.
8
+ - `workspace` expand across the project; local shadows in other files are excluded.
9
+ - `apply` default `true`; set `false` to return only the verified plan.
17
10
 
18
11
  ## When to use
19
12
 
20
- - The user asks to rename a symbol (function, variable, class, etc.).
21
- - The cursor position (line, optionally column) is known from an identify
22
- call or from the user's editor context.
13
+ - Use when the user requests a symbol rename and the cursor is known.
23
14
 
24
15
  ## When not to use
25
16
 
26
- - For search-and-replace across files, use `readSeek_grep` or `readSeek_search`.
27
- - For refactoring that requires adding/removing parameters, use `readSeek_edit`.
17
+ - Use `readSeek_grep`/`readSeek_search` for broad replacement and
18
+ `readSeek_edit` for other refactors.
package/prompts/sg.md CHANGED
@@ -1,9 +1,11 @@
1
- Search code with readseek AST patterns. Use it when text search is too broad or brittle and the query depends on syntax: calls, imports, declarations, JSX, object fields, control flow, and similar code shapes. Results are grouped by file with edit-ready hashline anchors.
1
+ Search code with AST patterns when text search is too broad. Use it for calls,
2
+ imports, declarations, JSX, object fields, and control flow. Results include
3
+ edit-ready anchors.
2
4
 
3
5
  ## Parameters
4
6
 
5
7
  - `pattern` — ast-grep-style pattern to match.
6
- - `lang` — language hint; set it when syntax is ambiguous, extensionless, generated, or TSX/JSX-like.
8
+ - `lang` — language hint for ambiguous, extensionless, generated, or JSX-like code.
7
9
  - `path` — file or directory, default cwd.
8
10
  - `cached` — in a Git repository, search tracked/indexed files.
9
11
  - `others` — in a Git repository, search untracked files.
@@ -16,7 +18,8 @@ Search code with readseek AST patterns. Use it when text search is too broad or
16
18
  - `$$$ARGS` matches zero or more sibling nodes. Use it for function args, body statements, object fields, JSX children, etc.
17
19
  - Reusing a metavariable name requires every occurrence to match the same source text.
18
20
 
19
- Patterns are parsed as code, not text. Formatting is mostly ignored, but syntax must be valid for the selected language. Include punctuation that the language grammar requires.
21
+ Patterns are code, not text: formatting is mostly ignored, but syntax and required
22
+ punctuation must be valid for the selected language.
20
23
 
21
24
  ## Examples
22
25
 
@@ -27,14 +30,10 @@ Patterns are parsed as code, not text. Formatting is mostly ignored, but syntax
27
30
  - `<$TAG $$$ATTRS>$$$CHILDREN</$TAG>` — JSX/TSX elements.
28
31
  - `if ($COND) { $$$BODY }` — control-flow blocks.
29
32
 
30
- ## Languages
31
-
32
- Useful `lang` values include `assembly`, `bash`, `c`, `cpp`, `csharp`, `css`, `dockerfile`, `gdscript`, `go`, `html`, `java`, `javascript`, `json`, `jsx`, `just`, `kconfig`, `latex`, `lua`, `make`, `markdown`, `meson`, `nix`, `perl`, `php`, `puppet`, `python`, `riscv`, `ruby`, `rust`, `sql`, `swift`, `toml`, `tsx`, `typescript`, `typst`, `xml`, `yaml`, `zig`, and `unknown`.
33
-
34
- `unknown` forces text-only handling and is not useful for parser-backed search.
35
-
36
33
  ## Git selection
37
34
 
38
- When searching a directory inside a Git repository, readseek defaults to tracked/indexed files plus untracked non-ignored files. Use `cached`, `others`, and `ignored` to narrow or expand that selection. `ignored` requires `others`.
35
+ In Git repositories, directory search includes tracked/indexed and untracked
36
+ non-ignored files. Use `cached`, `others`, and `ignored` to choose; `ignored`
37
+ requires `others`.
39
38
 
40
39
  Use `readSeek_grep` for plain text and `readSeek_search` for structure.
package/prompts/write.md CHANGED
@@ -1,46 +1,21 @@
1
- Create or overwrite a whole file and return `LINE:HASH` anchors for immediate follow-up `readSeek_edit` calls.
1
+ Create or replace a whole file and return `LINE:HASH` anchors for follow-up edits.
2
2
 
3
3
  ## Use / avoid
4
4
 
5
- Use `readSeek_write` for new files, generated files, or intentional full-file replacement. For small changes or appends to an existing file, run `readSeek_read` or `readSeek_search` first and use `readSeek_edit` (`insert_after` for appends).
5
+ Use it for new, generated, or intentionally replaced files. For small changes or
6
+ appends, read/search first and use `readSeek_edit`.
6
7
 
7
- Existing files are overwritten without confirmation. Binary-looking content can be written, but hashlines are not generated, so there are no anchors for `readSeek_edit`.
8
+ Existing files are overwritten without confirmation. Binary-looking content gets
9
+ no anchors.
8
10
 
9
11
  ## Parameters
10
12
 
11
- - `path` — relative or absolute file path.
13
+ - `path` — file path.
12
14
  - `content` — complete file contents.
13
- - `map` — optional; append a structural map when possible. Map generation is best-effort and does not make the write fail.
15
+ - `map` — append a best-effort structural map.
14
16
 
15
17
  ## Output
16
18
 
17
- Successful text writes return `LINE:HASH|content`. Display hashlines escape control characters for safe rendering. Visible output is capped at 2000 lines or 50 KB, but full anchors remain available in `readSeekValue`.
18
-
19
- ## Diff data contract
20
-
21
- Successful text `readSeek_write` results include additive final `details.diff`, `details.readSeekValue.diff`, `details.diffData`, and `details.readSeekValue.diffData` fields. String diff fields remain the backward-compatible human-readable fallback.
22
-
23
- `diffData` is a stable versioned contract:
24
-
25
- ```ts
26
- type DiffData = {
27
- version: 1;
28
- entries: Array<
29
- | { kind: "context"; oldLine: number; newLine: number; text: string }
30
- | { kind: "add"; newLine: number; text: string }
31
- | { kind: "remove"; oldLine: number; text: string }
32
- | { kind: "meta"; text: string }
33
- >;
34
- stats: { added: number; removed: number; context: number };
35
- language?: string;
36
- blockRanges?: Array<{ kind: "add" | "remove"; startLine: number; endLine: number }>;
37
- inlineDiffs?: Array<{
38
- removeLineIndex: number;
39
- addLineIndex: number;
40
- removeSpans: Array<{ kind: "equal" | "remove" | "add"; text: string }>;
41
- addSpans: Array<{ kind: "equal" | "remove" | "add"; text: string }>;
42
- }>;
43
- };
44
- ```
45
-
46
- For compact one-line hashline diffs, `details.diff` remains compact while `diffData.entries` uses expanded remove/add rows so renderers can show inline word changes without breaking hashline output.
19
+ Text writes return `LINE:HASH|content`. Visible output is capped at 2000 lines or
20
+ 50 KB; full anchors remain in `readSeekValue`. Results also include a compact
21
+ diff, unified patch, and structured `details.diffData`.
package/src/edit.ts CHANGED
@@ -627,7 +627,7 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
627
627
  const { isPartial, isError, expanded, width, context } = resolveRenderResultContext(options, rest);
628
628
 
629
629
  if (isPartial) {
630
- return renderPendingResult("pending edit", width);
630
+ return renderPendingResult("pending edit", width, theme);
631
631
  }
632
632
 
633
633
  // Extract data from result
@@ -659,16 +659,16 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
659
659
  let text = "";
660
660
 
661
661
  if (info.noOp) {
662
- text = summaryLine("no-op");
662
+ text = summaryLine("no-op", { theme, style: "dim" });
663
663
  if (expanded && info.errorText) text += `\n${theme.fg("error", info.errorText)}`;
664
664
  } else if (info.errorText) {
665
665
  const firstLine = info.errorText.split("\n")[0] || "Error";
666
- text = summaryLine(expanded ? info.errorText : firstLine);
666
+ text = summaryLine(expanded ? info.errorText : firstLine, { theme, style: "error" });
667
667
  } else {
668
668
  const badges: string[] = [`edited +${stats.added} -${stats.removed}`];
669
669
  if (info.semanticBadge) badges.push(info.semanticBadge.replace(/^✓\s*/, ""));
670
670
  if (info.warningsBadge) badges.push(info.warningsBadge);
671
- text = summaryLine(badges.join(" • "), { hidden: !!diffData && !expanded });
671
+ text = summaryLine(badges.join(" • "), { hidden: !!diffData && !expanded, theme, style: "success" });
672
672
  if (expanded && diffData) {
673
673
  return upsertDiffComponent(context.lastComponent, { prefixLines: text.split("\n"), diffData, theme, expanded: true });
674
674
  }
package/src/grep.ts CHANGED
@@ -19,7 +19,7 @@ import { resolveToCwd } from "./path-utils.js";
19
19
  import { throwIfAborted } from "./runtime.js";
20
20
  import { formatGrepCallText, formatGrepResultText, GREP_TRUNCATION_THRESHOLD } from "./grep-render-helpers.js";
21
21
  import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
22
- import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderErrorResult, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
22
+ import { clampLineToWidth, clampLinesToWidth, linkedPathLine, linkToolPath, renderErrorResult, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
23
23
  import type { FileAnchoredCallback } from "./tool-types.js";
24
24
  import { optionalIntOrString, registerReadSeekTool } from "./register-tool.js";
25
25
  import { searchPathParam } from "./readseek-params.js";
@@ -554,12 +554,12 @@ export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}
554
554
  renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
555
555
  const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
556
556
 
557
- if (isPartial) return renderPendingResult("pending grep", width);
557
+ if (isPartial) return renderPendingResult("pending grep", width, theme);
558
558
 
559
559
  const content = result.content?.[0];
560
560
  const textContent = content?.type === "text" ? content.text : "";
561
561
 
562
- if (isError || result.isError) return renderErrorResult(textContent, { expanded, width });
562
+ if (isError || result.isError) return renderErrorResult(textContent, { expanded, width, theme });
563
563
 
564
564
  const readSeekValue = (result.details as any)?.readSeekValue as {
565
565
  tool: "grep";
@@ -583,17 +583,21 @@ export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}
583
583
  hasBinaryWarning,
584
584
  });
585
585
 
586
- if (info.noMatches && !hasBinaryWarning) return new Text(summaryLine("no matches"), 0, 0);
586
+ if (info.noMatches && !hasBinaryWarning) return new Text(summaryLine("no matches", { theme, style: "dim" }), 0, 0);
587
587
  const matchCount = readSeekValue?.totalMatches ?? 0;
588
588
  const matchWord = matchCount === 1 ? "match" : "matches";
589
- let text = summaryLine(`${matchCount} ${matchWord} returned`, { hidden: !!textContent && !expanded });
589
+ let text = summaryLine(`${matchCount} ${matchWord} returned`, {
590
+ hidden: !!textContent && !expanded,
591
+ theme,
592
+ style: "success",
593
+ });
590
594
  for (const badge of info.badges) text += theme.fg(badge.startsWith("⚠") ? "warning" : "dim", ` ${badge}`);
591
595
  if (expanded && readSeekValue?.records) {
592
596
  const fileCounts = new Map<string, number>();
593
597
  for (const r of readSeekValue.records) if (r.path && r.kind === "match") fileCounts.set(r.path, (fileCounts.get(r.path) ?? 0) + 1);
594
598
  for (const [filePath, count] of [...fileCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20)) {
595
599
  const display = path.relative(cwd, filePath) || filePath;
596
- text += "\n" + theme.fg("dim", ` ${display} (${count})`);
600
+ text += `\n${linkedPathLine(theme, filePath, display, cwd, ` (${count})`, width)}`;
597
601
  }
598
602
  if (fileCounts.size > 20) text += "\n" + theme.fg("muted", ` … and ${fileCounts.size - 20} more files`);
599
603
  }
package/src/hover.ts CHANGED
@@ -11,7 +11,7 @@ import { formatFsError } from "./fs-error.js";
11
11
  import { classifyReadSeekFailure, readSeekIdentify } from "./readseek-client.js";
12
12
  import { filePathParam, registerReadSeekTool } from "./register-tool.js";
13
13
 
14
- import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
14
+ import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderErrorResult, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
15
15
 
16
16
  const HOVER_PROMPT_METADATA = defineToolPromptMetadata({
17
17
  promptUrl: new URL("../prompts/hover.md", import.meta.url),
@@ -112,18 +112,26 @@ export function registerHoverTool(pi: ExtensionAPI) {
112
112
  return new Text(clampLineToWidth(text, context.width), 0, 0);
113
113
  },
114
114
  renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
115
- const { isPartial, isError, width } = resolveRenderResultContext(options, rest);
115
+ const { isPartial, isError, expanded, width } = resolveRenderResultContext(options, rest);
116
116
 
117
- if (isPartial) return renderPendingResult("pending hover", width);
117
+ if (isPartial) return renderPendingResult("pending hover", width, theme);
118
118
 
119
119
  const content = result.content?.[0];
120
120
  const textContent = content?.type === "text" ? content.text : "";
121
121
 
122
122
  if (isError || result.isError) {
123
- return new Text(textContent || "hover failed", 0, 0);
123
+ return renderErrorResult(textContent, { expanded, width, fallback: "hover failed", theme });
124
124
  }
125
125
 
126
- return new Text(textContent.split("\n")[0] || "", 0, 0);
126
+ const output = (result.details as any)?.readSeekValue?.output;
127
+ const label = output?.identifier?.text
128
+ ? `identified ${output.identifier.text}`
129
+ : output?.symbol?.name
130
+ ? `symbol ${output.symbol.name}`
131
+ : "identified cursor";
132
+ let text = summaryLine(label, { hidden: !!textContent && !expanded, theme, style: "success" });
133
+ if (expanded && textContent) text += `\n${textContent}`;
134
+ return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
127
135
  },
128
136
  });
129
137
  }
package/src/read.ts CHANGED
@@ -2,7 +2,6 @@ import { readFile as fsReadFile } from "node:fs/promises";
2
2
 
3
3
  import type { ExtensionAPI, ToolRenderResultOptions, AgentToolResult } from "@earendil-works/pi-coding-agent";
4
4
  import {
5
- createReadTool,
6
5
  truncateHead,
7
6
  DEFAULT_MAX_BYTES,
8
7
  DEFAULT_MAX_LINES,
@@ -26,7 +25,7 @@ import { buildReadOutput } from "./read-output.js";
26
25
 
27
26
  import { buildLocalBundle } from "./read-local-bundle.js";
28
27
  import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
29
- import { readSeekRead, readSeekDetect, readSeekImage, type ReadSeekDetection } from "./readseek-client.js";
28
+ import { readSeekRead, readSeekDetect, readSeekImage, readSeekPdf, readSeekPreparedImage, type ReadSeekDetection, type ReadSeekPdfOutput } from "./readseek-client.js";
30
29
  import { formatReadCallText, formatReadResultText } from "./read-render-helpers.js";
31
30
  import { resolveReadSeekImageMode } from "./readseek-settings.js";
32
31
  import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidthCached } from "./tui-render-utils.js";
@@ -41,6 +40,7 @@ interface ReadParams {
41
40
  symbol?: string;
42
41
  map?: boolean;
43
42
  bundle?: string;
43
+ image?: "none" | "ocr" | "caption" | "objects";
44
44
  }
45
45
 
46
46
  interface ReadToolOptions {
@@ -55,8 +55,6 @@ export interface ExecuteReadOptions {
55
55
  onUpdate: any;
56
56
  cwd: string;
57
57
  onSuccessfulRead?: FileAnchoredCallback;
58
- /** Whether the active model accepts image input natively. Used when imageMode is auto. */
59
- modelSupportsImages?: boolean;
60
58
  }
61
59
 
62
60
  function hasReadAnchors(result: AgentToolResult<any>): boolean {
@@ -82,8 +80,32 @@ function formatImageAnalysis(detection: ReadSeekDetection): string | undefined {
82
80
  return sections.length > 0 ? sections.join("\n\n") : undefined;
83
81
  }
84
82
 
83
+ function formatPdfAnalysis(pdf: ReadSeekPdfOutput): string {
84
+ const sections = [pdf.markdown.trimEnd()];
85
+ for (const image of pdf.images) {
86
+ const details: string[] = [];
87
+ const ocr = image.ocr?.trim();
88
+ if (ocr) details.push(`OCR text:\n${ocr}`);
89
+ const caption = image.caption?.trim();
90
+ if (caption) details.push(`Caption:\n${caption}`);
91
+ if (image.objects?.length) {
92
+ const lines = image.objects.map((object) => `- ${object.label} [${object.bbox.join(", ")}]`);
93
+ details.push(`Detected objects:\n${lines.join("\n")}`);
94
+ }
95
+ if (details.length > 0) sections.push(`PDF page ${image.page} image:\n${details.join("\n\n")}`);
96
+ }
97
+ return sections.filter(Boolean).join("\n\n");
98
+ }
99
+
100
+ function skippedVisualFile(path: string): AgentToolResult<any> {
101
+ return {
102
+ content: [{ type: "text", text: `[Skipped image/PDF: ${path}; no image mode selected]` }],
103
+ details: {},
104
+ };
105
+ }
106
+
85
107
  export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolResult<any>> {
86
- const { toolCallId, params, signal, onUpdate, cwd, onSuccessfulRead } = opts;
108
+ const { params, signal, cwd, onSuccessfulRead } = opts;
87
109
  await ensureHashInit();
88
110
  const rawParams = params as ReadParams;
89
111
  const offset = coerceObviousBase10Int(rawParams.offset, "offset");
@@ -165,41 +187,82 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
165
187
  }
166
188
 
167
189
  const hasBinaryContent = looksLikeBinary(rawBuffer);
168
- if (hasBinaryContent) {
190
+ const hasPdfHeader = rawBuffer.subarray(0, 1024).includes(Buffer.from("%PDF-"));
191
+ if (hasBinaryContent || ext === "pdf" || hasPdfHeader) {
169
192
  let detection: ReadSeekDetection | undefined;
170
193
  try {
171
194
  detection = await readSeekDetect(absolutePath, { signal });
172
195
  } catch {
196
+ return buildToolErrorResult("read", "read-error", `Visual file detection failed for ${absolutePath}.`, {
197
+ path: rawParams.path,
198
+ });
199
+ }
200
+ if (!detection) {
201
+ return buildToolErrorResult("read", "read-error", `Visual file detection returned no data for ${absolutePath}.`, {
202
+ path: rawParams.path,
203
+ });
173
204
  }
174
- if (detection?.kind === "image") {
175
- const builtinRead = createReadTool(cwd);
176
- const builtinResult = await builtinRead.execute(toolCallId, p, signal, onUpdate);
205
+ if (detection.kind === "image" || detection.type === "application/pdf") {
177
206
  const imageMode = resolveReadSeekImageMode();
178
- const shouldRunVision = imageMode === "force" || (imageMode === "auto" && !opts.modelSupportsImages);
179
- if (!shouldRunVision) return succeed(builtinResult);
207
+ if (imageMode === "off" || p.image === undefined) {
208
+ return skippedVisualFile(rawParams.path);
209
+ }
210
+ if (imageMode === "on" && p.image === "none") {
211
+ return buildToolErrorResult("read", "invalid-params-combo", 'image="none" is only available when imageMode is "auto".', {
212
+ path: rawParams.path,
213
+ });
214
+ }
215
+ if (p.image === "none") {
216
+ try {
217
+ if (detection.kind === "pdf") {
218
+ const pdf = await readSeekPdf(absolutePath, p.image, { signal });
219
+ const content: AgentToolResult<any>["content"] = [{ type: "text", text: pdf.markdown }];
220
+ for (const image of pdf.images) {
221
+ if (image.encoding !== "base64" || image.data === undefined) continue;
222
+ content.push({ type: "text", text: `[PDF page ${image.page} image]` });
223
+ content.push({ type: "image", data: image.data, mimeType: image.mime });
224
+ }
225
+ return succeed({ content, details: {} });
226
+ }
227
+ const image = await readSeekPreparedImage(absolutePath, { signal });
228
+ return succeed({
229
+ content: [{ type: "image" as const, data: image.data, mimeType: image.mime }],
230
+ details: {},
231
+ });
232
+ } catch (error) {
233
+ const message = error instanceof Error ? error.message : String(error);
234
+ return buildToolErrorResult("read", "read-error", `Image preprocessing unavailable: ${message}`, {
235
+ path: rawParams.path,
236
+ });
237
+ }
238
+ }
180
239
 
181
240
  try {
182
- const ocrDetection = await readSeekImage(absolutePath, ["ocr", "caption", "objects"], { signal });
183
- const imageAnalysis = formatImageAnalysis(ocrDetection);
241
+ if (detection.kind === "pdf") {
242
+ const pdf = await readSeekPdf(absolutePath, p.image, { signal });
243
+ return succeed({
244
+ content: [{ type: "text" as const, text: formatPdfAnalysis(pdf) }],
245
+ details: {},
246
+ });
247
+ }
248
+ const analysis = await readSeekImage(absolutePath, [p.image], { signal });
249
+ const imageAnalysis = formatImageAnalysis(analysis);
184
250
  if (imageAnalysis) {
185
251
  return succeed({
186
- ...builtinResult,
187
- content: [
188
- ...(builtinResult.content ?? []),
189
- { type: "text" as const, text: imageAnalysis },
190
- ],
252
+ content: [{ type: "text" as const, text: imageAnalysis }],
253
+ details: {},
191
254
  });
192
255
  }
193
- } catch {
194
- return succeed({
195
- ...builtinResult,
196
- content: [
197
- ...(builtinResult.content ?? []),
198
- { type: "text" as const, text: "[Warning: local image analysis unavailable — showing image attachment only]" },
199
- ],
256
+ } catch (error) {
257
+ const message = error instanceof Error ? error.message : String(error);
258
+ const label = detection.kind === "pdf" ? "PDF" : "Image";
259
+ return buildToolErrorResult("read", "read-error", `${label} analysis unavailable: ${message}`, {
260
+ path: rawParams.path,
200
261
  });
201
262
  }
202
- return succeed(builtinResult);
263
+ return buildToolErrorResult("read", "read-error", "Image analysis returned no content.", {
264
+ path: rawParams.path,
265
+ });
203
266
  }
204
267
  }
205
268
  throwIfAborted(signal);
@@ -457,6 +520,8 @@ function splitReadSeekLines(text: string): string[] {
457
520
 
458
521
  export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}) {
459
522
  const name = options.name ?? "readSeek_read";
523
+ const imageMode = resolveReadSeekImageMode();
524
+ const imageModes = imageMode === "auto" ? ["none", "ocr", "caption", "objects"] as const : ["ocr", "caption", "objects"] as const;
460
525
  const promptMetadata = defineToolPromptMetadata({
461
526
  promptUrl: new URL("../prompts/read.md", import.meta.url),
462
527
  promptSnippet: "Read files or images with anchors, maps, symbols, and OCR",
@@ -467,7 +532,12 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
467
532
  label: "Read",
468
533
  description: promptMetadata.description,
469
534
  promptSnippet: promptMetadata.promptSnippet,
470
- promptGuidelines: promptMetadata.promptGuidelines,
535
+ promptGuidelines: [
536
+ ...promptMetadata.promptGuidelines,
537
+ imageMode === "off"
538
+ ? "Image and PDF reads are disabled."
539
+ : `For an image or PDF, explicitly choose image: ${imageModes.join(", ")}; omitting image skips the file.`,
540
+ ],
471
541
  parameters: Type.Object({
472
542
  path: filePathParam(),
473
543
  offset: optionalIntOrString("Start line (1-indexed)"),
@@ -479,6 +549,13 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
479
549
  description: "Include same-file local support",
480
550
  }),
481
551
  ),
552
+ ...(imageMode === "off"
553
+ ? {}
554
+ : {
555
+ image: Type.Optional(Type.Union(imageModes.map((mode) => Type.Literal(mode)), {
556
+ description: `Image/PDF mode: ${imageModes.join(", ")}. Must be selected explicitly.`,
557
+ })),
558
+ }),
482
559
  }),
483
560
  async execute(toolCallId, params, signal, onUpdate, ctx) {
484
561
  return executeRead({
@@ -488,7 +565,6 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
488
565
  onUpdate,
489
566
  cwd: ctx.cwd,
490
567
  onSuccessfulRead: options.onSuccessfulRead,
491
- modelSupportsImages: ctx.model?.input.includes("image") ?? false,
492
568
  });
493
569
  },
494
570
  renderCall(args: any, theme: any, ...rest: any[]) {
@@ -509,20 +585,28 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
509
585
  },
510
586
  renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
511
587
  const { isPartial, isError, expanded, width } = resolveRenderResultContext(options, rest);
512
- if (isPartial) return renderPendingResult("pending read", width);
588
+ if (isPartial) return renderPendingResult("pending read", width, theme);
513
589
 
514
590
  const content = result.content?.[0];
515
591
  const textContent = content?.type === "text" ? content.text : "";
516
592
  if (isError || result.isError) {
517
593
  const firstLine = textContent.split("\n")[0] || "Error";
518
594
  const errorText = expanded ? (textContent || firstLine) : firstLine;
519
- return new Text(clampLinesToWidth([summaryLine(errorText)], width).join("\n"), 0, 0);
595
+ return new Text(clampLinesToWidth([summaryLine(errorText, { theme, style: "error" })], width).join("\n"), 0, 0);
520
596
  }
521
597
 
522
598
  const readSeekValue = (result.details as any)?.readSeekValue as { range: { startLine: number; endLine: number; totalLines: number }; truncation: any; symbol: any; map: any; warnings: ReadSeekWarning[] } | undefined;
523
599
  if (!readSeekValue) {
524
600
  const lines = textContent.split("\n").filter(Boolean).length || textContent.split("\n").length;
525
- return new Text(summaryLine(`loaded ${lines} ${lines === 1 ? "line" : "lines"}`, { hidden: !!textContent && !expanded }), 0, 0);
601
+ return new Text(
602
+ summaryLine(`loaded ${lines} ${lines === 1 ? "line" : "lines"}`, {
603
+ hidden: !!textContent && !expanded,
604
+ theme,
605
+ style: "success",
606
+ }),
607
+ 0,
608
+ 0,
609
+ );
526
610
  }
527
611
 
528
612
  const info = formatReadResultText({ range: readSeekValue.range, truncation: readSeekValue.truncation, symbol: readSeekValue.symbol, map: readSeekValue.map, warnings: readSeekValue.warnings });
@@ -532,7 +616,7 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
532
616
  if (info.symbolBadge) summaryParts.push(info.symbolBadge);
533
617
  for (const badge of info.badges) summaryParts.push(badge);
534
618
  const summary = summaryParts.join(" • ");
535
- let text = summaryLine(summary, { hidden: !!textContent && !expanded });
619
+ let text = summaryLine(summary, { hidden: !!textContent && !expanded, theme, style: "success" });
536
620
  if (expanded && textContent) text += "\n" + wrapReadHashlinesForWidthCached(content, textContent, width);
537
621
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
538
622
  },
@@ -118,6 +118,32 @@ export interface ReadSeekDetectedObject {
118
118
 
119
119
  export type ReadSeekImageMode = "ocr" | "caption" | "objects";
120
120
 
121
+ export interface ReadSeekPreparedImage {
122
+ mime: string;
123
+ encoding: "base64";
124
+ data: string;
125
+ }
126
+
127
+ export interface ReadSeekPdfImage {
128
+ page: number;
129
+ width: number;
130
+ height: number;
131
+ mime: string;
132
+ mode: "none" | ReadSeekImageMode;
133
+ encoding?: "base64";
134
+ data?: string;
135
+ ocr?: ReadSeekOcrText;
136
+ caption?: string;
137
+ objects?: ReadSeekDetectedObject[];
138
+ }
139
+
140
+ export interface ReadSeekPdfOutput {
141
+ format: "pdf";
142
+ pages: number;
143
+ markdown: string;
144
+ images: ReadSeekPdfImage[];
145
+ }
146
+
121
147
  export type ReadSeekDetection =
122
148
  | {
123
149
  kind: "source";
@@ -138,6 +164,8 @@ export type ReadSeekDetection =
138
164
  width: number;
139
165
  height: number;
140
166
  animated: boolean;
167
+ encoding?: "base64";
168
+ data?: string;
141
169
  ocr?: ReadSeekOcrText;
142
170
  caption?: string;
143
171
  objects?: ReadSeekDetectedObject[];
@@ -147,6 +175,14 @@ export type ReadSeekDetection =
147
175
  type: string;
148
176
  file: string;
149
177
  mime?: string;
178
+ }
179
+ | {
180
+ kind: "pdf";
181
+ type: "application/pdf";
182
+ file: string;
183
+ mime?: string;
184
+ format: "pdf";
185
+ pages: number;
150
186
  };
151
187
 
152
188
  type ReadSeekImageDetection = Extract<ReadSeekDetection, { kind: "image" }>;
@@ -219,6 +255,7 @@ function readSeekPackageDir(): string {
219
255
 
220
256
  const READSEEK_PLATFORM_PACKAGES: Record<string, string> = {
221
257
  "darwin-arm64": "@jarkkojs/readseek-darwin-arm64",
258
+ "linux-arm64": "@jarkkojs/readseek-linux-arm64",
222
259
  "linux-x64": "@jarkkojs/readseek-linux-x64",
223
260
  "win32-x64": "@jarkkojs/readseek-win32-x64",
224
261
  };
@@ -725,7 +762,18 @@ function parseDetectOutput(value: unknown): ReadSeekDetection {
725
762
  // carry `format`/`width`/`height`/`animated`; source detections carry
726
763
  // `language`. Text and binary are byte-identical on the wire and collapse
727
764
  // to the text variant.
728
- if (output.format !== undefined || output.width !== undefined || output.height !== undefined) {
765
+ if (type === "application/pdf") {
766
+ if (output.format !== "pdf") throw new Error("invalid readseek PDF format");
767
+ return {
768
+ kind: "pdf",
769
+ type,
770
+ file,
771
+ mime,
772
+ format: output.format,
773
+ pages: requireNumber(output.pages, "pages"),
774
+ };
775
+ }
776
+ if (output.width !== undefined || output.height !== undefined) {
729
777
  return {
730
778
  kind: "image",
731
779
  type,
@@ -735,6 +783,8 @@ function parseDetectOutput(value: unknown): ReadSeekDetection {
735
783
  width: requireNumber(output.width, "width"),
736
784
  height: requireNumber(output.height, "height"),
737
785
  animated: requireBoolean(output.animated, "animated"),
786
+ encoding: output.encoding === undefined ? undefined : parseImageEncoding(output.encoding),
787
+ data: output.data === undefined ? undefined : requireString(output.data, "data"),
738
788
  ocr: parseOcrText(output.ocr),
739
789
  caption: optionalString(output.caption, "caption"),
740
790
  objects: parseDetectedObjects(output.objects),
@@ -755,6 +805,11 @@ function parseDetectOutput(value: unknown): ReadSeekDetection {
755
805
  return { kind: "text", type, file, mime };
756
806
  }
757
807
 
808
+ function parseImageEncoding(value: unknown): "base64" {
809
+ if (value === "base64") return value;
810
+ throw new Error("invalid image encoding");
811
+ }
812
+
758
813
  export async function readSeekDetect(
759
814
  filePath: string,
760
815
  options: { signal?: AbortSignal } = {},
@@ -799,6 +854,58 @@ export async function readSeekImage(
799
854
  throw new Error(`readseek returned no image analysis for ${filePath}`);
800
855
  }
801
856
 
857
+ export async function readSeekPreparedImage(
858
+ filePath: string,
859
+ options: { signal?: AbortSignal } = {},
860
+ ): Promise<ReadSeekPreparedImage> {
861
+ const output = parseDetectOutput(await runReadSeek(["read", "--image", "none", filePath], { signal: options.signal }));
862
+ if (output.kind !== "image" || output.encoding !== "base64" || output.data === undefined || output.mime === undefined) {
863
+ throw new Error(`readseek returned no prepared image for ${filePath}`);
864
+ }
865
+ return { mime: output.mime, encoding: output.encoding, data: output.data };
866
+ }
867
+
868
+ function parsePdfImage(value: unknown): ReadSeekPdfImage {
869
+ if (!value || typeof value !== "object") throw new Error("invalid readseek PDF image");
870
+ const image = value as Record<string, unknown>;
871
+ const mode = requireString(image.mode, "PDF image.mode");
872
+ if (mode !== "none" && mode !== "ocr" && mode !== "caption" && mode !== "objects") {
873
+ throw new Error("invalid readseek PDF image.mode");
874
+ }
875
+ return {
876
+ page: requireNumber(image.page, "PDF image.page"),
877
+ width: requireNumber(image.width, "PDF image.width"),
878
+ height: requireNumber(image.height, "PDF image.height"),
879
+ mime: requireString(image.mime, "PDF image.mime"),
880
+ mode,
881
+ encoding: image.encoding === undefined ? undefined : parseImageEncoding(image.encoding),
882
+ data: optionalString(image.data, "PDF image.data"),
883
+ ocr: parseOcrText(image.ocr),
884
+ caption: optionalString(image.caption, "PDF image.caption"),
885
+ objects: parseDetectedObjects(image.objects),
886
+ };
887
+ }
888
+
889
+ function parsePdfOutput(value: unknown): ReadSeekPdfOutput {
890
+ if (!value || typeof value !== "object") throw new Error("invalid readseek PDF output");
891
+ const output = value as Record<string, unknown>;
892
+ if (output.format !== "pdf" || !Array.isArray(output.images)) throw new Error("invalid readseek PDF output");
893
+ return {
894
+ format: output.format,
895
+ pages: requireNumber(output.pages, "PDF pages"),
896
+ markdown: requireString(output.markdown, "PDF markdown"),
897
+ images: output.images.map(parsePdfImage),
898
+ };
899
+ }
900
+
901
+ export async function readSeekPdf(
902
+ filePath: string,
903
+ mode: "none" | ReadSeekImageMode,
904
+ options: { signal?: AbortSignal } = {},
905
+ ): Promise<ReadSeekPdfOutput> {
906
+ return parsePdfOutput(await runReadSeek(["read", "--image", mode, filePath], { signal: options.signal }));
907
+ }
908
+
802
909
  // --- Rename ---
803
910
 
804
911
  export interface RenameConflict {
@@ -2,7 +2,7 @@ import { readFileSync, statSync, type Stats } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
- export type ReadSeekImageAnalysisMode = "force" | "off" | "auto";
5
+ export type ReadSeekImageAnalysisMode = "on" | "off" | "auto";
6
6
  export type ReadSeekSyntaxValidationMode = "warn" | "block" | "off";
7
7
  interface ReadSeekGrepSettings {
8
8
  maxLines?: number;
@@ -106,8 +106,7 @@ function readImageMode(
106
106
  ): ReadSeekImageAnalysisMode | undefined {
107
107
  if (!("imageMode" in raw)) return undefined;
108
108
  const val = raw.imageMode;
109
- if (val === "on") return "force";
110
- if (val === "force" || val === "off" || val === "auto") return val;
109
+ if (val === "on" || val === "off" || val === "auto") return val;
111
110
  warnings.push(invalid(source, "readseek.imageMode"));
112
111
  return undefined;
113
112
  }
@@ -252,7 +251,7 @@ export function resolveReadSeekJsonSettings(): ReadSeekSettingsResult {
252
251
  }
253
252
 
254
253
  export function resolveReadSeekImageMode(): ReadSeekImageAnalysisMode {
255
- return resolveReadSeekJsonSettings().settings.imageMode ?? "force";
254
+ return resolveReadSeekJsonSettings().settings.imageMode ?? "auto";
256
255
  }
257
256
 
258
257
  export function resolveReadSeekSyntaxValidation(): ReadSeekSyntaxValidationMode | undefined {
package/src/rename.ts CHANGED
@@ -9,7 +9,7 @@ import { resolveToCwd } from "./path-utils.js";
9
9
  import { classifyReadSeekFailure, readSeekRename, type RenameOutput } from "./readseek-client.js";
10
10
  import { filePathParam, registerReadSeekTool } from "./register-tool.js";
11
11
 
12
- import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
12
+ import { clampLineToWidth, clampLinesToWidth, linkedPathLine, linkToolPath, renderErrorResult, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
13
13
 
14
14
  const RENAME_PROMPT_METADATA = defineToolPromptMetadata({
15
15
  promptUrl: new URL("../prompts/rename.md", import.meta.url),
@@ -121,16 +121,15 @@ export function registerRenameTool(pi: ExtensionAPI) {
121
121
  const context = rest[0] ?? {};
122
122
  const cwd = context.cwd ?? process.cwd();
123
123
  const displayPath = typeof args?.path === "string" ? args.path : "?";
124
- const oldName = typeof args?.to === "string" ? "" : "";
125
124
  let text = theme.fg("toolTitle", theme.bold("rename"));
126
125
  text += ` ${linkToolPath(theme.fg("accent", displayPath), displayPath, cwd)}`;
127
126
  if (args?.to) text += theme.fg("dim", ` → ${args.to}`);
128
127
  return new Text(clampLineToWidth(text, context.width), 0, 0);
129
128
  },
130
129
  renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
131
- const { isPartial, isError, expanded, width } = resolveRenderResultContext(options, rest);
130
+ const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
132
131
 
133
- if (isPartial) return renderPendingResult("pending rename", width);
132
+ if (isPartial) return renderPendingResult("pending rename", width, theme);
134
133
 
135
134
  const content = result.content?.[0];
136
135
  const textContent = content?.type === "text" ? content.text : "";
@@ -138,21 +137,29 @@ export function registerRenameTool(pi: ExtensionAPI) {
138
137
  const output = readSeekValue?.output as RenameOutput | undefined;
139
138
 
140
139
  if (isError || result.isError) {
141
- return new Text(textContent || "rename failed", 0, 0);
140
+ return renderErrorResult(textContent, { expanded, width, fallback: "rename failed", theme });
142
141
  }
143
142
 
144
- let text = summaryLine(
145
- output?.applied
143
+ const outputs = output ? [output, ...output.others] : [];
144
+ const editCount = outputs.reduce((total, file) => total + file.edits.length, 0);
145
+ const conflictCount = outputs.reduce((total, file) => total + file.conflicts.length, 0);
146
+ const fileCount = outputs.length;
147
+ const action = output?.applied
146
148
  ? `renamed ${output.old_name} → ${output.new_name}`
147
- : `rename plan for ${output?.old_name ?? "?"} → ${output?.new_name ?? "?"} (dry-run)`,
148
- );
149
+ : `rename plan for ${output?.old_name ?? "?"} → ${output?.new_name ?? "?"} (dry-run)`;
150
+ const details = output
151
+ ? ` • ${editCount} ${editCount === 1 ? "edit" : "edits"} in ${fileCount} ${fileCount === 1 ? "file" : "files"}`
152
+ : "";
153
+ const conflicts = conflictCount > 0 ? ` • ${conflictCount} ${conflictCount === 1 ? "conflict" : "conflicts"}` : "";
154
+ let text = summaryLine(`${action}${details}${conflicts}`, {
155
+ theme,
156
+ style: conflictCount > 0 ? "warning" : "success",
157
+ });
149
158
 
150
159
  if (expanded && output) {
151
- const files = new Set<string>();
152
- files.add(output.file);
153
- for (const o of output.others) files.add(o.file);
154
- for (const f of files) {
155
- text += `\n ${f}`;
160
+ for (const file of outputs) {
161
+ const display = path.relative(cwd, file.file) || file.file;
162
+ text += `\n${linkedPathLine(theme, file.file, display, cwd, ` (${file.edits.length} edits)`, width)}`;
156
163
  }
157
164
  }
158
165
 
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "@earendil-works/pi-coding-agent";
4
4
 
5
5
  const COMPACT_DESCRIPTIONS: Record<string, string> = {
6
- "read.md": "Read text files/images by path; text has LINE:HASH anchors, images return the attachment plus OCR text, a caption, and detected objects.",
6
+ "read.md": "Read text files, images, or PDFs by path; text has LINE:HASH anchors and visual files require an explicit image mode.",
7
7
  "edit.md": "Edit existing text files using fresh LINE:HASH anchors from readSeek_read, readSeek_grep, readSeek_search, or readSeek_write.",
8
8
  "grep.md": "Search file contents; non-summary results include LINE:HASH anchors for edits.",
9
9
 
@@ -38,7 +38,7 @@ const REPLACEABLE_TOOL_GUIDELINES: Record<string, { readSeekName: string; builtI
38
38
  const COMPACT_GUIDELINES: Record<string, string[]> = {
39
39
  "read.md": [
40
40
  "Use readSeek_read map or symbol mode before pulling large code files into context.",
41
- "Use readSeek_read for images; it returns the image attachment plus OCR text, a caption, and detected objects.",
41
+ "For images and PDFs, explicitly choose one of the image modes exposed by readSeek_read.",
42
42
  ],
43
43
  "edit.md": [
44
44
  "With readSeek_edit, prefer set_line, replace_lines, and insert_after; use replace only when anchors are impractical.",
@@ -28,12 +28,30 @@ export function linkToolPath(styledText: string, rawPath: string, cwd: string):
28
28
  }
29
29
  }
30
30
 
31
+ export function linkedPathLine(
32
+ theme: RendererTheme,
33
+ rawPath: string,
34
+ displayPath: string,
35
+ cwd: string,
36
+ suffix: string,
37
+ width: number | undefined,
38
+ ): string {
39
+ const pathWidth = width === undefined ? undefined : Math.max(1, width - 2 - visibleWidth(suffix));
40
+ const visiblePath = pathWidth === undefined ? displayPath : truncateToWidth(displayPath, pathWidth);
41
+ const linkedPath = linkToolPath(theme.fg("dim", visiblePath), rawPath, cwd);
42
+ return ` ${linkedPath}${theme.fg("dim", suffix)}`;
43
+ }
44
+
31
45
  export function appendExpandHint(text: string, hidden: boolean): string {
32
46
  return hidden ? `${text}${EXPAND_HINT}` : text;
33
47
  }
34
48
 
35
- export function summaryLine(summary: string, options: { hidden?: boolean } = {}): string {
36
- return appendExpandHint(`${SUMMARY_PREFIX} ${summary}`, !!options.hidden);
49
+ export function summaryLine(
50
+ summary: string,
51
+ options: { hidden?: boolean; theme?: RendererTheme; style?: string } = {},
52
+ ): string {
53
+ const prefix = options.theme ? options.theme.fg(options.style ?? "dim", SUMMARY_PREFIX) : SUMMARY_PREFIX;
54
+ return appendExpandHint(`${prefix} ${summary}`, !!options.hidden);
37
55
  }
38
56
 
39
57
  export function isRendererExpanded(options?: { expanded?: boolean }, context?: { expanded?: boolean }): boolean {
@@ -172,8 +190,8 @@ export function wrapReadHashlinesForWidthCached(
172
190
  /**
173
191
  * Render the `isPartial` placeholder line shared by every tool's `renderResult`.
174
192
  */
175
- export function renderPendingResult(pendingLabel: string, width: number | undefined): Text {
176
- return new Text(clampLinesToWidth([summaryLine(pendingLabel)], width).join("\n"), 0, 0);
193
+ export function renderPendingResult(pendingLabel: string, width: number | undefined, theme?: RendererTheme): Text {
194
+ return new Text(clampLinesToWidth([summaryLine(pendingLabel, { theme, style: "muted" })], width).join("\n"), 0, 0);
177
195
  }
178
196
 
179
197
  /**
@@ -183,11 +201,15 @@ export function renderPendingResult(pendingLabel: string, width: number | undefi
183
201
  */
184
202
  export function renderErrorResult(
185
203
  textContent: string,
186
- options: { expanded: boolean; width: number | undefined; fallback?: string },
204
+ options: { expanded: boolean; width: number | undefined; fallback?: string; theme?: RendererTheme },
187
205
  ): Text {
188
206
  const firstLine = textContent.split("\n")[0] || (options.fallback ?? "Error");
189
207
  const body = options.expanded && textContent ? textContent : firstLine;
190
- return new Text(clampLinesToWidth(summaryLine(body).split("\n"), options.width).join("\n"), 0, 0);
208
+ return new Text(
209
+ clampLinesToWidth(summaryLine(body, { theme: options.theme, style: "error" }).split("\n"), options.width).join("\n"),
210
+ 0,
211
+ 0,
212
+ );
191
213
  }
192
214
 
193
215
  export interface AnchoredFilesLabels {
@@ -232,27 +254,31 @@ export function renderAnchoredFilesResult(
232
254
  ): Text {
233
255
  const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
234
256
 
235
- if (isPartial) return renderPendingResult(labels.pendingLabel, width);
257
+ if (isPartial) return renderPendingResult(labels.pendingLabel, width, theme);
236
258
 
237
259
  const content = result.content?.[0];
238
260
  const textContent = content?.type === "text" ? content.text : "";
239
- if (isError || result.isError) return renderErrorResult(textContent, { expanded, width });
261
+ if (isError || result.isError) return renderErrorResult(textContent, { expanded, width, theme });
240
262
 
241
263
  const readSeekValue = (result.details as any)?.readSeekValue as
242
264
  | { files: Array<{ path: string; lines: any[] }> }
243
265
  | undefined;
244
266
  const files = readSeekValue?.files ?? [];
245
- if (files.length === 0) return new Text(summaryLine(labels.emptyLabel), 0, 0);
267
+ if (files.length === 0) return new Text(summaryLine(labels.emptyLabel, { theme, style: "dim" }), 0, 0);
246
268
 
247
269
  const fileCount = files.length;
248
270
  const total = files.reduce((sum: number, f: any) => sum + f.lines.length, 0);
249
271
  const unitWord = total === 1 ? labels.unitSingular : labels.unitPlural;
250
272
  const fileWord = fileCount === 1 ? "file" : "files";
251
- let text = summaryLine(`${total} ${unitWord} in ${fileCount} ${fileWord}`, { hidden: !expanded });
273
+ let text = summaryLine(`${total} ${unitWord} in ${fileCount} ${fileWord}`, {
274
+ hidden: !expanded,
275
+ theme,
276
+ style: "success",
277
+ });
252
278
  if (expanded) {
253
279
  for (const file of files.slice(0, 20)) {
254
280
  const display = relative(cwd, file.path) || file.path;
255
- text += "\n" + theme.fg("dim", ` ${display} (${file.lines.length})`);
281
+ text += `\n${linkedPathLine(theme, file.path, display, cwd, ` (${file.lines.length})`, width)}`;
256
282
  }
257
283
  if (files.length > 20) text += "\n" + theme.fg("muted", ` … and ${files.length - 20} more files`);
258
284
  }
package/src/write.ts CHANGED
@@ -322,11 +322,11 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
322
322
  },
323
323
  renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
324
324
  const { isPartial, expanded, width, context } = resolveRenderResultContext(options, rest);
325
- if (isPartial) return renderPendingResult("pending write", width);
325
+ if (isPartial) return renderPendingResult("pending write", width, theme);
326
326
  const details = result.details ?? {};
327
327
  const output = result.content?.[0]?.type === "text" ? result.content[0].text : "";
328
328
  if (result.isError || details.readSeekValue?.ok === false) {
329
- return renderErrorResult(output, { expanded, width, fallback: "write failed" });
329
+ return renderErrorResult(output, { expanded, width, fallback: "write failed", theme });
330
330
  }
331
331
  const diffData = details.diffData;
332
332
  const state = details.writeState === "overwritten" ? "overwritten" : "created";
@@ -336,7 +336,7 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
336
336
  if (state === "created") {
337
337
  const readSeekLines = (details.readSeekValue?.lines ?? []) as Array<{ raw: string }>;
338
338
  const hasContent = readSeekLines.length > 0;
339
- const header = summaryLine(state, { hidden: hasContent && !expanded });
339
+ const header = summaryLine(state, { hidden: hasContent && !expanded, theme, style: "success" });
340
340
  const lines = header.split("\n");
341
341
  if (expanded && hasContent) {
342
342
  const content = readSeekLines.map((l) => l.raw).join("\n");
@@ -346,7 +346,7 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
346
346
  }
347
347
  // Overwrite: the old vs new comparison still carries signal — keep the diff UI.
348
348
  const hasExpandableDiff = !!diffData;
349
- let text = summaryLine(state, { hidden: hasExpandableDiff && !expanded });
349
+ let text = summaryLine(state, { hidden: hasExpandableDiff && !expanded, theme, style: "success" });
350
350
  if (expanded && hasExpandableDiff) {
351
351
  return upsertDiffComponent(context.lastComponent, { prefixLines: text.split("\n"), diffData, theme, expanded: true });
352
352
  }