pi-hashline-edit-pro 0.18.1 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -3
- package/index.ts +18 -5
- package/package.json +4 -1
- package/prompts/read-guidelines.md +2 -1
- package/prompts/read.md +1 -3
- package/prompts/replace-guidelines.md +2 -2
- package/prompts/undo-last-replace-guidelines.md +1 -0
- package/src/config.ts +4 -4
- package/src/constants.ts +0 -3
- package/src/file-kind.ts +1 -1
- package/src/fs-write.ts +44 -2
- package/src/hash-store.ts +49 -10
- package/src/hashline/hash.ts +54 -15
- package/src/hashline/index.ts +3 -0
- package/src/hashline/parse.ts +3 -1
- package/src/read.ts +3 -8
- package/src/replace.ts +42 -38
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
|
|
|
94
94
|
|
|
95
95
|
### Stable hashing across edits
|
|
96
96
|
|
|
97
|
-
Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that preserves hashes for unchanged lines across edits. When you replace lines in a file, the runtime maps the old content against the new content and copies hashes for unchanged lines to their new positions. This means editing one part of a file does not change the hashes of unrelated lines elsewhere — the model can keep using previously seen anchors for untouched regions.
|
|
97
|
+
Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that preserves hashes for unchanged lines across edits. When you replace lines in a file, the runtime maps the old content against the new content and copies hashes for unchanged lines to their new positions. This means editing one part of a file does not change the hashes of unrelated lines elsewhere — the model can keep using previously seen anchors for untouched regions. A replace that produces identical content (a no-op, reported as "No changes made") never rotates hashes: no file change means no anchor change, so previously read anchors remain valid after a no-op.
|
|
98
98
|
|
|
99
99
|
The store is a SQLite database (WAL journal mode) keyed by canonical file path. Each snapshot stores a 64-bit content checksum (`xxhash64`) plus the per-line hashes, not the full text, so a cache hit is a single keyed lookup and a one-row write. Reads, replaces, undo, and pruning all share one transactional store, so concurrent Pi sessions editing different files never silently clobber each other's snapshots (per-path writers serialize via `BEGIN IMMEDIATE`; same-path concurrent edits still fail safe — stale anchors are rejected by content matching). Stale snapshots (for files that no longer exist) are pruned on session start.
|
|
100
100
|
|
|
@@ -138,16 +138,34 @@ Settings are stored in `~/.config/pi-hashline-edit-pro/config.json`:
|
|
|
138
138
|
|
|
139
139
|
The file is created automatically when any setting is toggled. Both fields are independent — toggling one never clobbers the other.
|
|
140
140
|
|
|
141
|
+
### Error codes
|
|
142
|
+
|
|
143
|
+
| Code | Meaning |
|
|
144
|
+
| --- | --- |
|
|
145
|
+
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields. |
|
|
146
|
+
| `[E_BAD_REF]` | An anchor in `hash_range_inclusive` is not a bare 3-char hash. |
|
|
147
|
+
| `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
|
|
148
|
+
| `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
|
|
149
|
+
| `[E_INVALID_PATCH]` | `content_lines` contains diff-preview rows (`+HASH│`, `-HASH│`, `- │`, `-N `). |
|
|
150
|
+
| `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix. |
|
|
151
|
+
| `[E_LEGACY_SHAPE]` | The request uses the unsupported `oldText`/`newText` dialect. |
|
|
152
|
+
| `[E_BAD_OP]` | Range start line is after range end line. |
|
|
153
|
+
| `[E_EDIT_CONFLICT]` | Two edits in one batch overlap the same original lines. |
|
|
154
|
+
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
155
|
+
| `[E_FILE_TOO_LARGE]` | The file exceeds the 262,144-line hashline limit. |
|
|
156
|
+
|
|
141
157
|
## Design Decisions
|
|
142
158
|
|
|
143
159
|
- **Stale anchors fail (per-line).** A hash mismatch means that specific line's content changed since the last `read`; the error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced into `hash_range_inclusive` of the next replace call. Because staleness is per-line, editing or appending lines does **not** invalidate anchors for lines whose content is unchanged — anchors for untouched regions stay valid across edits to other regions.
|
|
144
160
|
- **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
|
|
145
|
-
- **Strict patch content.** If `content_lines` contains `+HASH│`
|
|
161
|
+
- **Strict patch content.** If `content_lines` contains diff-preview rows — `+HASH│` addition prefixes, `-HASH│` or `- │` deletion rows (the padded format the diff preview emits), or `-N ` numbered deletion rows — the edit is rejected with `[E_INVALID_PATCH]`. This narrowly guards against pasting the tool's own diff-preview rows back as content; standard unified-diff lines (`+x`, `-x`, ` x`, `@@ … @@`) are **not** rejected — they are written literally, since literal content must never be silently altered. Bare `HASH│` content (the first 4 chars of a `content_lines` entry looking like 3 base64 chars + `│`) is rejected with `[E_BARE_HASH_PREFIX]`. When the suspect's prefix happens to match a real file-line anchor, the error message flags that as strong evidence the model copied an anchor from the read output.
|
|
162
|
+
|
|
163
|
+
- **BOM preservation.** A UTF-8 BOM is stripped for display and hashing but restored on write, so edits (and undo) never silently strip a BOM from a file that has one.
|
|
146
164
|
- **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
|
|
147
165
|
- **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
|
|
148
166
|
- **Boundary duplication auto-fix.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), the runtime automatically strips the duplicate from `content_lines` before applying the edit. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The auto-fix is completely silent — the model sees a normal successful edit. The duplicate never reaches the file. Raw line comparison (not trimmed) avoids false positives when indentation differs.
|
|
149
167
|
- **Flat mode normalization.** When flat mode is active, the tool's `execute` function wraps the top-level `hash_range_inclusive` and `content_lines` into a single-element `changes` array internally, then runs the same pipeline as bulk mode. The `normReq` function in `replace-normalize.ts` also handles flat format directly, so any code path that normalizes input (e.g. `compPreview`) works with both formats.
|
|
150
|
-
- **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is a SQLite database at `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (per-path snapshots keyed by resolved path storing a 64-bit content checksum + line hashes; auto-created on first use). When called from the replace pipeline, it maps old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content's checksum matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines. Per-operation work scales with the target file, not cumulative history.
|
|
168
|
+
- **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is a SQLite database at `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (per-path snapshots keyed by resolved path storing a 64-bit content checksum + line hashes; auto-created on first use). When called from the replace pipeline, it maps old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content's checksum matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines. Per-operation work scales with the target file, not cumulative history. If the database is corrupt or unreadable it is quarantined (renamed to `hash-store.sqlite.corrupt-<timestamp>`) and rebuilt from content on the next session start — the store is a cache, never a source of truth.
|
|
151
169
|
## Hashing
|
|
152
170
|
|
|
153
171
|
Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32 via WebAssembly), then mapped to a 3-character string from the URL-safe base64 alphabet `A-Za-z0-9-_`. That's 64 distinct characters, 6 bits per position, 18 bits of entropy per anchor.
|
|
@@ -160,6 +178,8 @@ Before hashing, each line is normalized: carriage returns are stripped and trail
|
|
|
160
178
|
The runtime always precomputes the full per-line hash array for a file via `lineHashes(content, path)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read. When `path` is provided, `lineHashes` uses a persistent store to preserve hashes for unchanged lines across edits — see [Stable hashing across edits](#stable-hashing-across-edits).
|
|
161
179
|
`HASH_LEN` in `src/hashline/hash.ts` sets the hash body length; bump it to 4 if you need even more entropy without collision resolution.
|
|
162
180
|
|
|
181
|
+
The 3-character space holds 262,144 unique anchors, so files are capped at 262,144 lines: `read` and `replace` reject larger files with `[E_FILE_TOO_LARGE]` (use `write` or a non-line-based approach for very large files).
|
|
182
|
+
|
|
163
183
|
### Bare-prefix detector
|
|
164
184
|
|
|
165
185
|
With the `│` delimiter format, the bare-prefix detector regex `^\s*([A-Za-z0-9_\-]{3})│` is highly specific. It only matches lines starting with a hash-like prefix. This eliminates false positives from common code patterns like `init:`, `data:`, `else:`, etc. The detector rejects edit lines matching this pattern with `[E_BARE_HASH_PREFIX]` to prevent the model from accidentally pasting hash anchors into file content.
|
package/index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { initHasher } from "./src/hashline";
|
|
3
3
|
import { regReplace, regReplaceFlat } from "./src/replace";
|
|
4
|
-
import { regReplaceUndo } from "./src/replace-undo";
|
|
4
|
+
import { regReplaceUndo, clearUndo } from "./src/replace-undo";
|
|
5
5
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
6
6
|
import { visLines } from "./src/utils";
|
|
7
7
|
import { AUTO_READ_MAX } from "./src/constants";
|
|
8
|
+
import { MAX_HASH_LINES } from "./src/hashline";
|
|
8
9
|
import {
|
|
9
10
|
readConfig,
|
|
10
11
|
toggleReplaceMode,
|
|
@@ -12,7 +13,8 @@ import {
|
|
|
12
13
|
} from "./src/config";
|
|
13
14
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
14
15
|
import { readNormFile } from "./src/file-reader";
|
|
15
|
-
|
|
16
|
+
import { toCwd } from "./src/paths";
|
|
17
|
+
import { resolveTarget } from "./src/fs-write";
|
|
16
18
|
function registerReplaceTool(pi: ExtensionAPI, mode: string, autoRead?: boolean): void {
|
|
17
19
|
if (mode === "flat") {
|
|
18
20
|
regReplaceFlat(pi, autoRead);
|
|
@@ -73,16 +75,27 @@ export default function (pi: ExtensionAPI): void {
|
|
|
73
75
|
});
|
|
74
76
|
|
|
75
77
|
pi.on("tool_result", async (event, ctx) => {
|
|
76
|
-
if (!autoRead) return;
|
|
77
78
|
if (event.isError) return;
|
|
79
|
+
if (event.toolName === "write") {
|
|
80
|
+
const writtenPath = (event.input as Record<string, unknown>)?.path;
|
|
81
|
+
if (typeof writtenPath === "string") {
|
|
82
|
+
try {
|
|
83
|
+
clearUndo(await resolveTarget(toCwd(writtenPath, ctx.cwd)));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error("Failed to clear undo after write:", error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!autoRead) return;
|
|
78
90
|
if (event.toolName !== "write" && event.toolName !== "replace") return;
|
|
79
91
|
|
|
80
92
|
const filePath = (event.input as Record<string, unknown>)?.path;
|
|
81
93
|
if (typeof filePath !== "string") return;
|
|
82
94
|
|
|
83
95
|
try {
|
|
84
|
-
const { normalized, fileHashes, absolutePath } = await readNormFile(
|
|
85
|
-
|
|
96
|
+
const { normalized, fileHashes, absolutePath } = await readNormFile(
|
|
97
|
+
filePath, ctx.cwd, { maxLines: MAX_HASH_LINES },
|
|
98
|
+
);
|
|
86
99
|
if (visLines(normalized).length === 0) return;
|
|
87
100
|
|
|
88
101
|
const preview = await fmtReadPreview(normalized, { limit: AUTO_READ_MAX }, fileHashes, absolutePath);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -40,6 +40,9 @@
|
|
|
40
40
|
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
41
41
|
"@earendil-works/pi-tui": "*"
|
|
42
42
|
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22.13.0"
|
|
45
|
+
},
|
|
43
46
|
"scripts": {
|
|
44
47
|
"test": "vitest run",
|
|
45
48
|
"test:watch": "vitest",
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
- `read`: call before `replace` when you need fresh HASH anchors for a file.
|
|
1
|
+
- `read`: call before `replace` when you need fresh HASH anchors for a file.
|
|
2
|
+
- `read`: call again after any edit to that file — changed lines get new anchors.
|
package/prompts/read.md
CHANGED
|
@@ -1,3 +1 @@
|
|
|
1
|
-
Read a text file
|
|
2
|
-
|
|
3
|
-
Text → HASH│content lines. Images → visual attachments. Binary/directory → rejected. Empty → HASH│ (replace to insert). Pageable with offset/limit. BOM stripped; non-UTF-8 shown as U+FFFD.
|
|
1
|
+
Read a text file; each line returned as HASH│content with a 3-char URL-safe base64 hash. No line numbers — use the HASH as the anchor in replace calls. Images → visual attachments; Binary/directory → rejected; empty → HASH│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
- `replace`:
|
|
2
|
-
- `replace`:
|
|
1
|
+
- `replace`: content_lines is a native JSON array of strings — never a serialized JSON string; strip the HASH│ prefix from read output and keep leading whitespace exactly as shown after │; no line numbers or diff markers.
|
|
2
|
+
- `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file; on [E_STALE_ANCHOR], re-read the file and retry with fresh anchors.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- `undo_last_replace`: reverts only the most recent replace on the file — any write to the file clears the undo history, so call it immediately after a bad replace.
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFile
|
|
2
|
-
import {
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import { configPath } from "./paths";
|
|
3
3
|
import { errCode } from "./utils";
|
|
4
|
+
import { writeAtomic } from "./fs-write";
|
|
4
5
|
|
|
5
6
|
export type ReplaceMode = "bulk" | "flat";
|
|
6
7
|
export interface Config {
|
|
@@ -32,8 +33,7 @@ export async function readConfig(): Promise<Config> {
|
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
export async function writeConfig(config: Config): Promise<void> {
|
|
35
|
-
await
|
|
36
|
-
await writeFile(configPath(), JSON.stringify(config, null, 2), "utf-8");
|
|
36
|
+
await writeAtomic(configPath(), JSON.stringify(config, null, 2));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
|
package/src/constants.ts
CHANGED
|
@@ -2,9 +2,6 @@ export const AUTO_READ_MAX = 2000;
|
|
|
2
2
|
export const SNIFF_BYTES = 8192;
|
|
3
3
|
export const MAX_BYTES = 100 * 1024 * 1024;
|
|
4
4
|
|
|
5
|
-
export const MAX_HASH_LINES = 1_000_000;
|
|
6
|
-
export const MAX_HASH_RETRIES = 262_144;
|
|
7
|
-
|
|
8
5
|
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
9
6
|
export const HASH_STORE_VERSION = 3;
|
|
10
7
|
export const CONTENT_LINES_NOT_STRING_MSG =
|
package/src/file-kind.ts
CHANGED
|
@@ -81,7 +81,7 @@ export async function loadFileKindAndText(
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
84
|
+
const decoder = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true });
|
|
85
85
|
let hadUtf8DecodeErrors = false;
|
|
86
86
|
const parts: string[] = [];
|
|
87
87
|
|
package/src/fs-write.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
lstat,
|
|
4
4
|
mkdir,
|
|
5
5
|
open,
|
|
6
|
+
readdir,
|
|
6
7
|
readlink,
|
|
7
8
|
rename,
|
|
8
9
|
rm,
|
|
@@ -71,6 +72,44 @@ export async function resolveTarget(path: string): Promise<string> {
|
|
|
71
72
|
return resParts(root, parts);
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
const TEMP_PREFIX = ".tmp-";
|
|
76
|
+
const STALE_TEMP_MS = 60 * 60 * 1000;
|
|
77
|
+
const sweptDirs = new Set<string>();
|
|
78
|
+
|
|
79
|
+
async function sweepStaleTemps(dir: string): Promise<void> {
|
|
80
|
+
if (sweptDirs.has(dir)) return;
|
|
81
|
+
sweptDirs.add(dir);
|
|
82
|
+
try {
|
|
83
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (!entry.isFile() || !entry.name.startsWith(TEMP_PREFIX)) continue;
|
|
87
|
+
const tempPath = join(dir, entry.name);
|
|
88
|
+
try {
|
|
89
|
+
const stats = await stat(tempPath);
|
|
90
|
+
if (now - stats.mtimeMs > STALE_TEMP_MS) {
|
|
91
|
+
await rm(tempPath, { force: true });
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function syncDir(dir: string): Promise<void> {
|
|
101
|
+
if (process.platform === "win32") return;
|
|
102
|
+
try {
|
|
103
|
+
const handle = await open(dir, "r");
|
|
104
|
+
try {
|
|
105
|
+
await handle.sync();
|
|
106
|
+
} finally {
|
|
107
|
+
await handle.close();
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
74
113
|
export async function writeAtomic(
|
|
75
114
|
path: string,
|
|
76
115
|
content: string,
|
|
@@ -92,7 +131,8 @@ export async function writeAtomic(
|
|
|
92
131
|
}
|
|
93
132
|
|
|
94
133
|
const dir = dirname(targetPath);
|
|
95
|
-
|
|
134
|
+
await sweepStaleTemps(dir);
|
|
135
|
+
const tempPath = join(dir, `${TEMP_PREFIX}${randomUUID()}`);
|
|
96
136
|
await mkdir(dir, { recursive: true });
|
|
97
137
|
const tempHandle = await open(tempPath, "wx", 0o600);
|
|
98
138
|
try {
|
|
@@ -100,6 +140,7 @@ export async function writeAtomic(
|
|
|
100
140
|
if (existingStats) {
|
|
101
141
|
await tempHandle.chmod(existingStats.mode & 0o7777);
|
|
102
142
|
}
|
|
143
|
+
await tempHandle.sync();
|
|
103
144
|
} catch (error: unknown) {
|
|
104
145
|
await tempHandle.close();
|
|
105
146
|
try { await rm(tempPath, { force: true }); } catch {}
|
|
@@ -108,12 +149,13 @@ export async function writeAtomic(
|
|
|
108
149
|
try {
|
|
109
150
|
await tempHandle.close();
|
|
110
151
|
await rename(tempPath, targetPath);
|
|
152
|
+
await syncDir(dir);
|
|
111
153
|
} catch (error: unknown) {
|
|
112
154
|
if (errCode(error) === "EXDEV") {
|
|
113
155
|
try {
|
|
114
|
-
await tempHandle.close();
|
|
115
156
|
await copyFile(tempPath, targetPath);
|
|
116
157
|
await rm(tempPath, { force: true });
|
|
158
|
+
await syncDir(dir);
|
|
117
159
|
return;
|
|
118
160
|
} catch {
|
|
119
161
|
try { await rm(tempPath, { force: true }); } catch {}
|
package/src/hash-store.ts
CHANGED
|
@@ -40,8 +40,7 @@ let exitHandlerRegistered = false;
|
|
|
40
40
|
function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
|
|
41
41
|
const db = new DatabaseSync(storePath, {
|
|
42
42
|
timeout: HASH_STORE_BUSY_TIMEOUT,
|
|
43
|
-
|
|
44
|
-
} as any);
|
|
43
|
+
});
|
|
45
44
|
db.exec("PRAGMA journal_mode = WAL");
|
|
46
45
|
db.exec("PRAGMA synchronous = NORMAL");
|
|
47
46
|
db.exec(
|
|
@@ -72,6 +71,36 @@ function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
|
|
|
72
71
|
return { db, stmts };
|
|
73
72
|
}
|
|
74
73
|
|
|
74
|
+
function isHealthy(db: DatabaseSync): boolean {
|
|
75
|
+
try {
|
|
76
|
+
const row = db.prepare("PRAGMA quick_check").get() as { quick_check?: string } | undefined;
|
|
77
|
+
return row?.quick_check === "ok";
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function quarantineStore(storePath: string): Promise<void> {
|
|
84
|
+
const suffix = `.corrupt-${Date.now()}`;
|
|
85
|
+
for (const candidate of [storePath, `${storePath}-wal`, `${storePath}-shm`]) {
|
|
86
|
+
try {
|
|
87
|
+
await rename(candidate, `${candidate}${suffix}`);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (errCode(error) !== "ENOENT") {
|
|
90
|
+
console.error("Failed to quarantine corrupt hash store file:", error);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function shutdownDb(db: DatabaseSync): void {
|
|
97
|
+
try {
|
|
98
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
db.close();
|
|
102
|
+
}
|
|
103
|
+
|
|
75
104
|
export async function loadHashStore(): Promise<HashStore> {
|
|
76
105
|
const storePath = hashStorePath();
|
|
77
106
|
if (cachedDb && cachedDb.path === storePath && cachedDb.db.isOpen) {
|
|
@@ -83,13 +112,27 @@ export async function loadHashStore(): Promise<HashStore> {
|
|
|
83
112
|
await initHasher();
|
|
84
113
|
await mkdir(hashStoreDir(), { recursive: true });
|
|
85
114
|
|
|
86
|
-
|
|
87
|
-
|
|
115
|
+
let existed = existsSync(storePath);
|
|
116
|
+
let opened: { db: DatabaseSync; stmts: Prepared };
|
|
117
|
+
try {
|
|
118
|
+
opened = openDb(storePath);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error("Hash store failed to open, rebuilding:", error);
|
|
121
|
+
await quarantineStore(storePath);
|
|
122
|
+
existed = false;
|
|
123
|
+
opened = openDb(storePath);
|
|
124
|
+
}
|
|
125
|
+
if (!isHealthy(opened.db)) {
|
|
126
|
+
shutdownDb(opened.db);
|
|
127
|
+
await quarantineStore(storePath);
|
|
128
|
+
existed = false;
|
|
129
|
+
opened = openDb(storePath);
|
|
130
|
+
}
|
|
131
|
+
const { db, stmts } = opened;
|
|
88
132
|
|
|
89
133
|
if (!existed) {
|
|
90
134
|
await migrateLegacy(db);
|
|
91
135
|
}
|
|
92
|
-
|
|
93
136
|
cachedDb = { path: storePath, db, stmts };
|
|
94
137
|
|
|
95
138
|
if (!exitHandlerRegistered) {
|
|
@@ -108,11 +151,7 @@ export async function loadHashStore(): Promise<HashStore> {
|
|
|
108
151
|
|
|
109
152
|
export function shutdownHashStore(): void {
|
|
110
153
|
if (cachedDb) {
|
|
111
|
-
|
|
112
|
-
cachedDb.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
113
|
-
} catch {
|
|
114
|
-
}
|
|
115
|
-
cachedDb.db.close();
|
|
154
|
+
shutdownDb(cachedDb.db);
|
|
116
155
|
cachedDb = null;
|
|
117
156
|
}
|
|
118
157
|
}
|
package/src/hashline/hash.ts
CHANGED
|
@@ -21,6 +21,9 @@ const ALPH_SAFE = ALPH.replace(/-/g, "\\-");
|
|
|
21
21
|
const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
|
|
22
22
|
export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
|
|
23
23
|
|
|
24
|
+
export const HASH_SPACE = ALPH.length ** HASH_LEN;
|
|
25
|
+
export const MAX_HASH_LINES = HASH_SPACE;
|
|
26
|
+
|
|
24
27
|
function idxToHash(idx: number): string {
|
|
25
28
|
let out = "";
|
|
26
29
|
for (let j = 0; j < HASH_LEN; j++) {
|
|
@@ -30,7 +33,7 @@ function idxToHash(idx: number): string {
|
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
const HASH_TABLE: string[] = Array.from(
|
|
33
|
-
{ length:
|
|
36
|
+
{ length: HASH_SPACE },
|
|
34
37
|
(_, i) => idxToHash(i),
|
|
35
38
|
);
|
|
36
39
|
|
|
@@ -40,6 +43,9 @@ export const HL_PREFIX_RE = new RegExp(
|
|
|
40
43
|
export const HL_PREFIX_PLUS_RE = new RegExp(
|
|
41
44
|
`^\\+\\s*${HASH_CLASS}│`,
|
|
42
45
|
);
|
|
46
|
+
export const HL_PREFIX_MINUS_RE = new RegExp(
|
|
47
|
+
`^-(?:\\s*${HASH_CLASS}│| {${ANCHOR_LEN}}│)`,
|
|
48
|
+
);
|
|
43
49
|
export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
|
|
44
50
|
|
|
45
51
|
export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
|
|
@@ -48,7 +54,7 @@ function canon(line: string): string {
|
|
|
48
54
|
return line.replace(/\r/g, "").trimEnd();
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
const BITSET_WORDS =
|
|
57
|
+
const BITSET_WORDS = Math.ceil(HASH_SPACE / 32);
|
|
52
58
|
|
|
53
59
|
function getBit(bits: Uint32Array, idx: number): boolean {
|
|
54
60
|
return (bits[idx >>> 5] >>> (idx & 31) & 1) !== 0;
|
|
@@ -95,7 +101,9 @@ function nextZeroBit(bits: Uint32Array, start: number): number {
|
|
|
95
101
|
if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
|
|
96
102
|
}
|
|
97
103
|
|
|
98
|
-
throw new Error(
|
|
104
|
+
throw new Error(
|
|
105
|
+
`[E_FILE_TOO_LARGE] Cannot allocate a unique hash anchor: the file exceeds the ${HASH_SPACE}-line limit for ${HASH_LEN}-char hashline anchors. For very large files use write or a non-line-based approach.`,
|
|
106
|
+
);
|
|
99
107
|
}
|
|
100
108
|
|
|
101
109
|
function assignHash(used: Uint32Array, baseIdx: number, hint: { value: number }): string {
|
|
@@ -172,6 +180,47 @@ function hashToIndex(hash: string): number {
|
|
|
172
180
|
return idx;
|
|
173
181
|
}
|
|
174
182
|
|
|
183
|
+
function findNearestCandidate(
|
|
184
|
+
candidates: { index: number; hash: string }[],
|
|
185
|
+
target: number,
|
|
186
|
+
removedHashes?: Set<string>,
|
|
187
|
+
): number {
|
|
188
|
+
let lo = 0;
|
|
189
|
+
let hi = candidates.length;
|
|
190
|
+
while (lo < hi) {
|
|
191
|
+
const mid = (lo + hi) >>> 1;
|
|
192
|
+
if (candidates[mid]!.index < target) lo = mid + 1;
|
|
193
|
+
else hi = mid;
|
|
194
|
+
}
|
|
195
|
+
let left = lo - 1;
|
|
196
|
+
let right = lo;
|
|
197
|
+
while (left >= 0 || right < candidates.length) {
|
|
198
|
+
let bestPos = -1;
|
|
199
|
+
let bestDist = Infinity;
|
|
200
|
+
if (left >= 0) {
|
|
201
|
+
const candidate = candidates[left]!;
|
|
202
|
+
if (!removedHashes?.has(candidate.hash)) {
|
|
203
|
+
bestPos = left;
|
|
204
|
+
bestDist = target - candidate.index;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (right < candidates.length) {
|
|
208
|
+
const candidate = candidates[right]!;
|
|
209
|
+
if (!removedHashes?.has(candidate.hash)) {
|
|
210
|
+
const dist = candidate.index - target;
|
|
211
|
+
if (dist < bestDist) {
|
|
212
|
+
bestPos = right;
|
|
213
|
+
bestDist = dist;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (bestPos >= 0) return bestPos;
|
|
218
|
+
left--;
|
|
219
|
+
right++;
|
|
220
|
+
}
|
|
221
|
+
return -1;
|
|
222
|
+
}
|
|
223
|
+
|
|
175
224
|
function mapStableHashes(
|
|
176
225
|
oldContent: string,
|
|
177
226
|
oldHashes: string[],
|
|
@@ -208,18 +257,8 @@ function mapStableHashes(
|
|
|
208
257
|
const candidates = contentMap.get(line);
|
|
209
258
|
if (!candidates || candidates.length === 0) continue;
|
|
210
259
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
for (let j = 0; j < candidates.length; j++) {
|
|
214
|
-
if (removedHashes?.has(candidates[j]!.hash)) continue;
|
|
215
|
-
const dist = Math.abs(candidates[j]!.index - i);
|
|
216
|
-
if (dist < bestDist) {
|
|
217
|
-
bestDist = dist;
|
|
218
|
-
bestIdx = j;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
if (removedHashes?.has(candidates[bestIdx]!.hash)) continue;
|
|
260
|
+
const bestIdx = findNearestCandidate(candidates, i, removedHashes);
|
|
261
|
+
if (bestIdx < 0) continue;
|
|
223
262
|
const match = candidates.splice(bestIdx, 1)[0]!;
|
|
224
263
|
newHashes[i] = match.hash;
|
|
225
264
|
const matchIdx = hashToIndex(match.hash);
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/parse.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
ANCHOR_LEN,
|
|
3
3
|
ALPH_RE,
|
|
4
4
|
HL_PREFIX_PLUS_RE,
|
|
5
|
+
HL_PREFIX_MINUS_RE,
|
|
5
6
|
DIFF_MINUS_RE,
|
|
6
7
|
} from "./hash";
|
|
7
8
|
import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
|
|
@@ -46,10 +47,11 @@ function assertNoPrefixes(lines: string[]): void {
|
|
|
46
47
|
if (!line.length) continue;
|
|
47
48
|
if (
|
|
48
49
|
HL_PREFIX_PLUS_RE.test(line) ||
|
|
50
|
+
HL_PREFIX_MINUS_RE.test(line) ||
|
|
49
51
|
DIFF_MINUS_RE.test(line)
|
|
50
52
|
) {
|
|
51
53
|
throw new Error(
|
|
52
|
-
`[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like
|
|
54
|
+
`[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like a diff preview row (e.g. +HASH│ or -HASH│): ${JSON.stringify(line)}. Use literal file content only — plain + or - lines are written literally.`
|
|
53
55
|
);
|
|
54
56
|
}
|
|
55
57
|
}
|
package/src/read.ts
CHANGED
|
@@ -2,15 +2,13 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import {
|
|
3
3
|
createReadTool,
|
|
4
4
|
formatSize,
|
|
5
|
-
DEFAULT_MAX_BYTES,
|
|
6
|
-
DEFAULT_MAX_LINES,
|
|
7
5
|
truncateHead,
|
|
8
6
|
type TruncationResult,
|
|
9
7
|
} from "@earendil-works/pi-coding-agent";
|
|
10
8
|
import { Type } from "typebox";
|
|
11
9
|
import { loadFileKindAndText } from "./file-kind";
|
|
12
10
|
import { readNormFile } from "./file-reader";
|
|
13
|
-
import { lineHashes, fmtRegion, HASH_SEP } from "./hashline";
|
|
11
|
+
import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
|
|
14
12
|
import { toCwd } from "./paths";
|
|
15
13
|
import { abortIf } from "./utils";
|
|
16
14
|
import { fileSnap } from "./file-reader";
|
|
@@ -18,10 +16,7 @@ import { visLines } from "./utils";
|
|
|
18
16
|
import { loadP, loadGuide } from "./prompts";
|
|
19
17
|
import { valAccess } from "./validation";
|
|
20
18
|
|
|
21
|
-
const R_DESC = loadP("../prompts/read.md"
|
|
22
|
-
DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
|
|
23
|
-
DEFAULT_MAX_BYTES: formatSize(DEFAULT_MAX_BYTES),
|
|
24
|
-
});
|
|
19
|
+
const R_DESC = loadP("../prompts/read.md");
|
|
25
20
|
|
|
26
21
|
const R_SNIPPET = loadP("../prompts/read-snippet.md");
|
|
27
22
|
const R_GUIDE = loadGuide("../prompts/read-guidelines.md");
|
|
@@ -164,7 +159,7 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
164
159
|
return executeBuiltinRead(_toolCallId, params, signal, _onUpdate, ctx);
|
|
165
160
|
}
|
|
166
161
|
const { normalized, fileHashes, hadUtf8DecodeErrors } = await readNormFile(
|
|
167
|
-
rawPath, ctx.cwd, { signal, preloadedFile: file },
|
|
162
|
+
rawPath, ctx.cwd, { signal, preloadedFile: file, maxLines: MAX_HASH_LINES },
|
|
168
163
|
);
|
|
169
164
|
const preview = await fmtReadPreview(
|
|
170
165
|
normalized,
|
package/src/replace.ts
CHANGED
|
@@ -13,12 +13,11 @@ import {
|
|
|
13
13
|
import { readNormFile } from "./file-reader";
|
|
14
14
|
import { normReq, normalizeFilePath, tryParseContentLines } from "./replace-normalize";
|
|
15
15
|
import { isRec, has, rejectUnknownFields, abortIf } from "./utils";
|
|
16
|
-
import { MAX_HASH_LINES } from "./constants";
|
|
17
16
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
18
|
-
import {
|
|
19
|
-
applyEdits,
|
|
17
|
+
import { applyEdits,
|
|
20
18
|
lineHashes,
|
|
21
19
|
resEdits,
|
|
20
|
+
MAX_HASH_LINES,
|
|
22
21
|
type HTEdit,
|
|
23
22
|
} from "./hashline";
|
|
24
23
|
import { toCwd } from "./paths";
|
|
@@ -68,7 +67,7 @@ const changeItemSchema = Type.Object(
|
|
|
68
67
|
|
|
69
68
|
export const editToolSchema = Type.Object(
|
|
70
69
|
{
|
|
71
|
-
changes: Type.Array(changeItemSchema, { description: "Array of edits
|
|
70
|
+
changes: Type.Array(changeItemSchema, { description: "Array of edits applied atomically against the same pre-edit snapshot." }),
|
|
72
71
|
path: Type.String({ description: "Path to edit" }),
|
|
73
72
|
},
|
|
74
73
|
{ additionalProperties: false },
|
|
@@ -117,6 +116,19 @@ interface PipelineResult {
|
|
|
117
116
|
|
|
118
117
|
const ROOT_KS = new Set(["path", "changes", "content_lines", "hash_range_inclusive"]);
|
|
119
118
|
|
|
119
|
+
const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"];
|
|
120
|
+
|
|
121
|
+
export function assertNoLegacyKeys(request: unknown): void {
|
|
122
|
+
if (!isRec(request)) return;
|
|
123
|
+
for (const legacyKey of LEGACY_KS) {
|
|
124
|
+
if (has(request, legacyKey)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}.`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
120
132
|
export function assertReq(
|
|
121
133
|
request: unknown,
|
|
122
134
|
flat?: boolean
|
|
@@ -125,13 +137,7 @@ export function assertReq(
|
|
|
125
137
|
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
126
138
|
}
|
|
127
139
|
|
|
128
|
-
|
|
129
|
-
if (has(request, legacyKey)) {
|
|
130
|
-
throw new Error(
|
|
131
|
-
`[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}.`
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
140
|
+
assertNoLegacyKeys(request);
|
|
135
141
|
|
|
136
142
|
rejectUnknownFields(request, ROOT_KS, "Edit request");
|
|
137
143
|
|
|
@@ -159,9 +165,11 @@ export interface ExecPipelineOptions {
|
|
|
159
165
|
function collectRemovedHashes(
|
|
160
166
|
resolved: { hash_range_inclusive: [{ hash: string }, { hash: string }] }[],
|
|
161
167
|
originalHashes: string[],
|
|
168
|
+
skipIndices?: Set<number>,
|
|
162
169
|
): Set<string> {
|
|
163
170
|
const removedHashes = new Set<string>();
|
|
164
|
-
for (const edit of resolved) {
|
|
171
|
+
for (const [index, edit] of resolved.entries()) {
|
|
172
|
+
if (skipIndices?.has(index)) continue;
|
|
165
173
|
const startHash = edit.hash_range_inclusive[0].hash;
|
|
166
174
|
const endHash = edit.hash_range_inclusive[1].hash;
|
|
167
175
|
const startLine = originalHashes.indexOf(startHash);
|
|
@@ -227,16 +235,20 @@ export async function execPipeline(
|
|
|
227
235
|
);
|
|
228
236
|
|
|
229
237
|
const result = anchorResult.content;
|
|
230
|
-
|
|
231
|
-
const removedHashes = collectRemovedHashes(resolved, originalHashes);
|
|
238
|
+
const isNoop = result === originalNormalized;
|
|
232
239
|
|
|
233
240
|
const noPersist = options?.noPersist;
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
241
|
+
const noopIndices = new Set(anchorResult.noopEdits?.map((n) => n.editIndex) ?? []);
|
|
242
|
+
const removedHashes = isNoop
|
|
243
|
+
? undefined
|
|
244
|
+
: collectRemovedHashes(resolved, originalHashes, noopIndices);
|
|
245
|
+
const resultHashes = isNoop
|
|
246
|
+
? originalHashes
|
|
247
|
+
: await lineHashes(result, absolutePath, {
|
|
248
|
+
content: originalNormalized,
|
|
249
|
+
hashes: originalHashes,
|
|
250
|
+
removedHashes,
|
|
251
|
+
}, hashStore, noPersist !== true);
|
|
240
252
|
const warnings = [...(anchorResult.warnings ?? [])];
|
|
241
253
|
|
|
242
254
|
const { totalAddedLines, totalRemovedLines } = countLineChanges(
|
|
@@ -269,6 +281,11 @@ export async function compPreview(
|
|
|
269
281
|
): Promise<RPreview> {
|
|
270
282
|
try {
|
|
271
283
|
const normalized = normReq(request);
|
|
284
|
+
if (flat && isRec(request) && Array.isArray(request.changes)) {
|
|
285
|
+
return {
|
|
286
|
+
error: `[E_BAD_SHAPE] Flat mode does not accept a "changes" array. Send content_lines and hash_range_inclusive at the top level (one edit per call), or use bulk mode for multiple edits per call.`
|
|
287
|
+
};
|
|
288
|
+
}
|
|
272
289
|
assertReq(normalized, flat);
|
|
273
290
|
const { path, originalNormalized, originalHashes, result, resultHashes } = await execPipeline(
|
|
274
291
|
normalized,
|
|
@@ -312,25 +329,9 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
|
|
|
312
329
|
|
|
313
330
|
const MODE_CFG = {
|
|
314
331
|
flat: {
|
|
315
|
-
desc: " Only one edit per call. The `hash_range_inclusive` and `content_lines` fields sit at the top level of the request object.",
|
|
316
|
-
examples: [
|
|
317
|
-
"", "Single line:", "{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"], \"path\": \"src/main.ts\" }",
|
|
318
|
-
].join("\n"),
|
|
319
|
-
rules: "",
|
|
320
|
-
requestStructure: [
|
|
321
|
-
"Flat mode:", "```json", "{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"], \"path\": \"...\" }", "```",
|
|
322
|
-
].join("\n"),
|
|
323
332
|
prefix: "performing one edit per call",
|
|
324
333
|
},
|
|
325
334
|
bulk: {
|
|
326
|
-
desc: "\n\nPut all operations on one file in a single `replace` call. Stack every region into the `changes` array, even when they are far apart. Anchors within one call must all come from the same pre-edit read; the runtime applies them atomically against that one snapshot.",
|
|
327
|
-
examples: [
|
|
328
|
-
"", "Single line:", "{ \"changes\": [{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"] }], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"changes\": [{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"] }], \"path\": \"src/main.ts\" }",
|
|
329
|
-
].join("\n"),
|
|
330
|
-
rules: "- Multiple edits in one call must not overlap. Overlapping ranges are rejected with [E_EDIT_CONFLICT].",
|
|
331
|
-
requestStructure: [
|
|
332
|
-
"Bulk mode (default):", "```json", "{ \"changes\": [{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"] }], \"path\": \"...\" }", "```",
|
|
333
|
-
].join("\n"),
|
|
334
335
|
prefix: "batching all changes to a file in one call",
|
|
335
336
|
},
|
|
336
337
|
} as const;
|
|
@@ -355,6 +356,7 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
|
|
|
355
356
|
promptGuidelines: E_GUIDE,
|
|
356
357
|
prepareArguments: opts.flat
|
|
357
358
|
? (args: unknown) => {
|
|
359
|
+
assertNoLegacyKeys(args);
|
|
358
360
|
if (!isRec(args)) return args as any;
|
|
359
361
|
const record = { ...args };
|
|
360
362
|
normalizeFilePath(record);
|
|
@@ -363,8 +365,10 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
|
|
|
363
365
|
}
|
|
364
366
|
return record;
|
|
365
367
|
}
|
|
366
|
-
: (args: unknown) =>
|
|
367
|
-
|
|
368
|
+
: (args: unknown) => {
|
|
369
|
+
assertNoLegacyKeys(args);
|
|
370
|
+
return normReq(args) as ReqParams;
|
|
371
|
+
},
|
|
368
372
|
renderShell: "default",
|
|
369
373
|
renderCall(args, theme, context) {
|
|
370
374
|
const previewInput = getPreviewInput(args);
|