pi-hashline-edit-pro 6.0.0 → 6.1.0
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 +18 -5
- package/index.ts +60 -5
- package/package.json +3 -3
- package/prompts/copy-snippet.md +1 -1
- package/prompts/copy.md +2 -4
- package/prompts/grep-guidelines.md +1 -0
- package/prompts/grep.md +1 -1
- package/prompts/insert.md +1 -3
- package/prompts/move-guidelines.md +1 -0
- package/prompts/move-snippet.md +1 -1
- package/prompts/move.md +2 -4
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +2 -3
- package/prompts/replace-match-guidelines.md +1 -1
- package/prompts/replace-match.md +1 -5
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -5
- package/prompts/transfer-guidelines.md +2 -0
- package/prompts/undo-last-change-guidelines.md +1 -0
- package/prompts/undo-last-change.md +1 -1
- package/src/batch.ts +19 -0
- package/src/commit.ts +2 -0
- package/src/config-ui.ts +31 -30
- package/src/config.ts +26 -3
- package/src/constants.ts +1 -1
- package/src/copy-move.ts +19 -2
- package/src/edit-common.ts +75 -35
- package/src/fs-write.ts +0 -10
- package/src/grep.ts +39 -6
- package/src/insert.ts +5 -3
- package/src/model-gate.ts +56 -0
- package/src/read.ts +50 -4
- package/src/replace-match.ts +5 -3
- package/src/replace-response.ts +37 -1
- package/src/replace-undo.ts +22 -23
- package/src/replace.ts +4 -2
- package/src/structured.ts +240 -0
- package/src/write-hook.ts +3 -1
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ What "pro" adds over upstream `pi-hashline-edit`: the anchor table is built from
|
|
|
68
68
|
|
|
69
69
|
Prerequisites:
|
|
70
70
|
|
|
71
|
-
- [pi-coding-agent](https://github.com/earendil-works/pi) `>= 0.
|
|
71
|
+
- [pi-coding-agent](https://github.com/earendil-works/pi) `>= 0.99.0` (`@earendil-works/pi-coding-agent`).
|
|
72
72
|
- Node.js 22.19 or newer, or a Bun build that ships `bun:sqlite`.
|
|
73
73
|
- An SQLite runtime. The extension uses `node:sqlite` on Node 22.19+ and falls back to `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`, so run pi under Node or a Bun build that ships SQLite. Without a runtime, every tool fails with `[E_STORE_UNAVAILABLE]`.
|
|
74
74
|
|
|
@@ -322,7 +322,7 @@ The hashline tools are sequential in pi, so a message that contains one runs all
|
|
|
322
322
|
|
|
323
323
|
## Auto-read
|
|
324
324
|
|
|
325
|
-
Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call.
|
|
325
|
+
Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call. A model matched by `disableOnModels` skips the block, and the post-edit diff substitution below is skipped with it.
|
|
326
326
|
|
|
327
327
|
After `replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N (queued)`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
|
|
328
328
|
|
|
@@ -334,7 +334,7 @@ Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff
|
|
|
334
334
|
|
|
335
335
|
Auto-read all is off by default and has three modes, selected in `/hashline-config`: `off` injects nothing, `on` discovers every file in the working directory that is not git-ignored (`git ls-files`, falling back to `ripgrep`, then to a directory walk), and `git` uses `git ls-files` only, injecting nothing when the working directory is not a git repository. On the first turn of a session, the extension discovers the files, reads each one, and attaches the resulting `anchor│content` rows to the conversation as one extension message before the model answers. Those anchors are served exactly like `read` output, so the model can `replace` and `insert` immediately without calling `read` first. The message is injected once per session; resumed, forked, and cloned sessions that already contain it skip the injection.
|
|
336
336
|
|
|
337
|
-
Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand.
|
|
337
|
+
Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A model matched by `disableOnModels` skips the injection entirely.
|
|
338
338
|
|
|
339
339
|
Each attached file is shown as `=== path ===` followed by its `anchor│content` rows. Edit directly from the attachment with replace and insert, so no `read` is needed. Files attach whole.
|
|
340
340
|
|
|
@@ -346,7 +346,7 @@ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as
|
|
|
346
346
|
|
|
347
347
|
| Command | Description |
|
|
348
348
|
| --- | --- |
|
|
349
|
-
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, copy/move tools, replace_match tool, required `path`, and strict input. Persists across sessions. |
|
|
349
|
+
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, disable on models, diff context lines, `anchor_grep` tool, copy/move tools, replace_match tool, required `path`, and strict input. Persists across sessions. |
|
|
350
350
|
| `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
|
|
351
351
|
|
|
352
352
|
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
|
|
@@ -361,7 +361,8 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
361
361
|
"replaceMatchEnabled": true,
|
|
362
362
|
"requirePath": false,
|
|
363
363
|
"strictInput": false,
|
|
364
|
-
"diffContextLines": 1
|
|
364
|
+
"diffContextLines": 1,
|
|
365
|
+
"disableOnModels": []
|
|
365
366
|
}
|
|
366
367
|
```
|
|
367
368
|
|
|
@@ -376,6 +377,9 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
376
377
|
| `requirePath` | Require path | `false` | `replace`, `replace_match`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
|
|
377
378
|
| `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
|
|
378
379
|
| `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
|
|
380
|
+
| `disableOnModels` | Disable on models | `[]` | Model globs matched case-insensitively against `provider/id`, the bare `id`, and `api`, with `*` and `?` wildcards. A matching model gets no hashline tools, no auto-read, and no write-echo refusal. |
|
|
381
|
+
|
|
382
|
+
`disableOnModels` turns off the whole anchored surface for the models you name, so another edit tool (`apply_patch`, a shell read) can own the session without competing instructions. A match removes `read`, `replace`, `replace_match`, `insert`, `copy`, `move`, `anchor_grep`, and `undo_last_change` from the active tools, restoring the built-in `grep` when it was active, and it also skips the auto-read-all injection, the auto-read block after `write`, the post-edit diff substitution, and the `write` hook that refuses a `write` echoing a served anchor. The list is checked on `session_start`, on `model_select`, and again on `before_agent_start`, so a model change inside a session switches the surface immediately and another extension cannot re-add the tools mid-session. The default `[]` leaves every model unchanged.
|
|
379
383
|
|
|
380
384
|
When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFIG_HOME` when set (falling back to `~/.config`); on Windows the directory always uses `~/.config`, where `~` is `%USERPROFILE%`. To move the directory explicitly, see [Isolated state](#isolated-state).
|
|
381
385
|
|
|
@@ -403,6 +407,14 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
|
|
|
403
407
|
|
|
404
408
|
All eight tools return machine-readable metadata in `details` alongside the model-visible text.
|
|
405
409
|
|
|
410
|
+
All eight tools also declare an `outputSchema` and return `structuredContent`, so codemode scripts receive anchors, line numbers, diffs, and errors as data instead of parsing the rendered text. The model still receives `content`. Every structured result is either `{ ok: true, kind, ... }` or `{ ok: false, kind: "error", error: { code, message } }`; a failed call is still a tool error (`isError: true`) and also carries `structuredContent`, so a script can branch on the code without matching the message.
|
|
411
|
+
|
|
412
|
+
`read` returns `{ kind: "read", path, text, lines, totalLines, startLine, nextOffset, truncated, blockedByLongLine, hadUtf8DecodeErrors }`, where each entry of `lines` is `{ line, text, anchor, rendered }` for a row that was served (`text` is what was shown, so an oversized line appears as its marker while keeping its anchor). An image read returns `{ kind: "image", path, mimeType }`.
|
|
413
|
+
|
|
414
|
+
`replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change` return `{ kind: "edit", verb, classification, path, text, diff, warnings, hints, firstChangedLine, anchors, anchorsOmitted }`, where `verb` is `"replaced"`, `"inserted"`, `"copied"`, `"moved"`, or `"undone"`, `anchors` are the live rows of the post-edit diff, and `anchorsOmitted` is true when the diff was truncated or carried no rows, meaning a fresh read is needed. A cross-file move reports the destination file and its anchors.
|
|
415
|
+
|
|
416
|
+
`anchor_grep` returns `{ kind: "grep", text, matches, files, truncated, results }`, where each entry of `results` is `{ path, matchLines, lines, hadUtf8DecodeErrors }` and `lines` uses the same `{ line, text, anchor, rendered }` shape as `read`.
|
|
417
|
+
|
|
406
418
|
| Tool | `details` |
|
|
407
419
|
| --- | --- |
|
|
408
420
|
| `read` | `truncation` (set when output was truncated), `snapshotId` (a `v2\|path\|ino\|mtime\|ctime\|size` fingerprint), `nextOffset` (use as the next `offset`), and `metrics` with `truncated` and `next_offset`. |
|
|
@@ -453,6 +465,7 @@ Full reference:
|
|
|
453
465
|
| `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
|
|
454
466
|
| `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified after the last edit. The undo record is kept until the file matches the edited state again or a new edit replaces it. |
|
|
455
467
|
| `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
|
|
468
|
+
| `[E_UNDO_NONE]` | `undo_last_change` found no recorded edit for the file. Nothing was changed; make an edit first. |
|
|
456
469
|
| `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown; a pure deletion checks only its first and last line). The edit was refused; the current range is returned with fresh anchors. |
|
|
457
470
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
|
|
458
471
|
| `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
|
package/index.ts
CHANGED
|
@@ -8,14 +8,17 @@ import { regCopy, regMove } from "./src/copy-move";
|
|
|
8
8
|
import { regGrep } from "./src/grep";
|
|
9
9
|
import { regUndo, clearUndo } from "./src/replace-undo";
|
|
10
10
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
11
|
+
import { ANCHOR_TOOL_NAMES, modelDisabled, type ModelLike } from "./src/model-gate";
|
|
11
12
|
import { buildAutoReadAllInjection, autoReadAllBudget } from "./src/auto-read-all";
|
|
12
13
|
import { clearAutoReadAllComplete } from "./src/auto-read-all-state";
|
|
13
14
|
import type { RMetrics } from "./src/replace-response";
|
|
14
15
|
import type { ReplaceDetails } from "./src/replace";
|
|
15
16
|
import { extractHints, extractWarnings } from "./src/replace-render";
|
|
16
17
|
import { MAX_HASH_LINES } from "./src/hashline";
|
|
18
|
+
import { withStructuredText } from "./src/structured";
|
|
17
19
|
import type { AutoReadAllMode } from "./src/config";
|
|
18
20
|
import {
|
|
21
|
+
readConfig,
|
|
19
22
|
readConfigWithStatus,
|
|
20
23
|
toggleAutoRead,
|
|
21
24
|
cycleAutoReadAllMode,
|
|
@@ -26,6 +29,7 @@ import {
|
|
|
26
29
|
toggleStrictInput,
|
|
27
30
|
adjustDiffContextLines,
|
|
28
31
|
setAutoReadAllIgnoreFromText,
|
|
32
|
+
setDisableOnModelsFromText,
|
|
29
33
|
} from "./src/config";
|
|
30
34
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
31
35
|
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
|
|
@@ -51,17 +55,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
51
55
|
regMove(pi);
|
|
52
56
|
regGrep(pi);
|
|
53
57
|
regUndo(pi);
|
|
54
|
-
registerWriteHook(pi);
|
|
58
|
+
registerWriteHook(pi, (model) => modelDisabled(model, disableOnModels));
|
|
55
59
|
|
|
56
60
|
let autoRead = true;
|
|
57
61
|
let autoReadAll: AutoReadAllMode = "off";
|
|
58
62
|
let autoReadAllIgnore: string[] = [];
|
|
63
|
+
let disableOnModels: string[] = [];
|
|
59
64
|
let autoReadAllInjected = false;
|
|
60
65
|
let grepWasActive = false;
|
|
66
|
+
const baseAnchorTools = new Set<string>();
|
|
67
|
+
let gateApplied = false;
|
|
61
68
|
|
|
62
69
|
async function refreshEditTools(): Promise<void> {
|
|
63
70
|
try {
|
|
64
|
-
const flags = await currentEditFlags();
|
|
71
|
+
const flags = await currentEditFlags(pi.getActiveTools().includes("codemode"));
|
|
65
72
|
regRead(pi, flags);
|
|
66
73
|
regReplace(pi, flags);
|
|
67
74
|
regReplaceMatch(pi, flags);
|
|
@@ -75,9 +82,37 @@ export default function (pi: ExtensionAPI): void {
|
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
async function syncModelGate(model: ModelLike | undefined): Promise<void> {
|
|
86
|
+
if (disableOnModels.length > 0 && modelDisabled(model, disableOnModels)) {
|
|
87
|
+
const active = pi.getActiveTools();
|
|
88
|
+
const next = active.filter((tool) => !ANCHOR_TOOL_NAMES.includes(tool));
|
|
89
|
+
if (grepWasActive && !next.includes("grep")) next.push("grep");
|
|
90
|
+
pi.setActiveTools(next);
|
|
91
|
+
gateApplied = true;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!gateApplied) return;
|
|
95
|
+
gateApplied = false;
|
|
96
|
+
const config = await readConfig();
|
|
97
|
+
const enabled = ANCHOR_TOOL_NAMES.filter((tool) => {
|
|
98
|
+
if (!baseAnchorTools.has(tool)) return false;
|
|
99
|
+
if (tool === "replace_match") return config.replaceMatchEnabled !== false;
|
|
100
|
+
if (tool === "copy" || tool === "move") return config.copyMoveEnabled !== false;
|
|
101
|
+
if (tool === "anchor_grep") return config.anchorGrepEnabled === true;
|
|
102
|
+
return true;
|
|
103
|
+
});
|
|
104
|
+
let next = [...new Set([...pi.getActiveTools(), ...enabled])];
|
|
105
|
+
if (enabled.includes("anchor_grep") && grepWasActive) next = next.filter((tool) => tool !== "grep");
|
|
106
|
+
pi.setActiveTools(next);
|
|
107
|
+
}
|
|
108
|
+
|
|
78
109
|
pi.on("session_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
|
|
79
110
|
const active = pi.getActiveTools();
|
|
80
111
|
grepWasActive = active.includes("grep");
|
|
112
|
+
baseAnchorTools.clear();
|
|
113
|
+
for (const tool of ANCHOR_TOOL_NAMES) {
|
|
114
|
+
if (active.includes(tool)) baseAnchorTools.add(tool);
|
|
115
|
+
}
|
|
81
116
|
pi.setActiveTools(active.filter((t) => t !== "edit"));
|
|
82
117
|
await initHasher();
|
|
83
118
|
loadHashStore()
|
|
@@ -97,6 +132,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
97
132
|
autoRead = config.autoRead;
|
|
98
133
|
autoReadAll = config.autoReadAll ?? "off";
|
|
99
134
|
autoReadAllIgnore = config.autoReadAllIgnore ?? [];
|
|
135
|
+
disableOnModels = config.disableOnModels ?? [];
|
|
100
136
|
const sessionBranch = (ctx as { sessionManager?: { getBranch?: () => Array<{ type?: string; customType?: string }> } }).sessionManager?.getBranch?.() ?? [];
|
|
101
137
|
autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
|
|
102
138
|
await refreshEditTools();
|
|
@@ -108,6 +144,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
108
144
|
return true;
|
|
109
145
|
}),
|
|
110
146
|
);
|
|
147
|
+
await syncModelGate(ctx.model);
|
|
111
148
|
const debugValue = process.env.PI_HASHLINE_DEBUG;
|
|
112
149
|
if (debugValue === "1" || debugValue === "true") {
|
|
113
150
|
ctx.ui.notify(`Hashline Edit mode active`, "info");
|
|
@@ -123,9 +160,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
123
160
|
console.error("Failed to release anchor registry session:", error);
|
|
124
161
|
}
|
|
125
162
|
});
|
|
163
|
+
pi.on("model_select", async (event, ctx) => withAnchorSession(ctx, async () => {
|
|
164
|
+
await syncModelGate(event.model);
|
|
165
|
+
}));
|
|
126
166
|
|
|
127
167
|
pi.on("before_agent_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
|
|
168
|
+
await syncModelGate(ctx.model);
|
|
128
169
|
if (autoReadAll === "off" || autoReadAllInjected) return;
|
|
170
|
+
if (modelDisabled(ctx.model, disableOnModels)) return;
|
|
129
171
|
autoReadAllInjected = true;
|
|
130
172
|
try {
|
|
131
173
|
const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll, autoReadAllIgnore, sessionKeyFor(ctx));
|
|
@@ -139,7 +181,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
139
181
|
}));
|
|
140
182
|
|
|
141
183
|
pi.registerCommand("hashline-config", {
|
|
142
|
-
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, copy/move, replace_match, path, strict input)",
|
|
184
|
+
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, disable on models, diff context, grep, copy/move, replace_match, path, strict input)",
|
|
143
185
|
handler: async (_args, ctx) => {
|
|
144
186
|
if (!ctx.hasUI) {
|
|
145
187
|
ctx.ui.notify("/hashline-config requires interactive mode", "error");
|
|
@@ -154,25 +196,35 @@ export default function (pi: ExtensionAPI): void {
|
|
|
154
196
|
if (key === "autoRead") autoRead = await toggleAutoRead();
|
|
155
197
|
else if (key === "autoReadAll") { autoReadAll = await cycleAutoReadAllMode(); autoReadAllInjected = false; }
|
|
156
198
|
else if (key === "autoReadAllIgnore") autoReadAllIgnore = await setAutoReadAllIgnoreFromText(value ?? "");
|
|
199
|
+
else if (key === "disableOnModels") disableOnModels = await setDisableOnModelsFromText(value ?? "");
|
|
157
200
|
else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
|
|
158
201
|
else if (key === "anchorGrepEnabled") {
|
|
159
202
|
const enabled = await toggleAnchorGrep();
|
|
160
203
|
const active = pi.getActiveTools();
|
|
161
204
|
pi.setActiveTools(enabled ? [...new Set([...active.filter((t) => t !== "grep"), "anchor_grep"])] : [...new Set([...active.filter((t) => t !== "anchor_grep"), ...(grepWasActive ? ["grep"] : [])])]);
|
|
205
|
+
if (enabled) baseAnchorTools.add("anchor_grep");
|
|
206
|
+
else baseAnchorTools.delete("anchor_grep");
|
|
162
207
|
}
|
|
163
208
|
else if (key === "copyMoveEnabled") {
|
|
164
209
|
const enabled = await toggleCopyMove();
|
|
165
210
|
const active = pi.getActiveTools();
|
|
166
211
|
pi.setActiveTools(enabled ? [...new Set([...active, "copy", "move"])] : active.filter((t) => t !== "copy" && t !== "move"));
|
|
212
|
+
for (const tool of ["copy", "move"]) {
|
|
213
|
+
if (enabled) baseAnchorTools.add(tool);
|
|
214
|
+
else baseAnchorTools.delete(tool);
|
|
215
|
+
}
|
|
167
216
|
}
|
|
168
217
|
else if (key === "replaceMatchEnabled") {
|
|
169
218
|
const enabled = await toggleReplaceMatch();
|
|
170
219
|
const active = pi.getActiveTools();
|
|
171
220
|
pi.setActiveTools(enabled ? [...new Set([...active, "replace_match"])] : active.filter((t) => t !== "replace_match"));
|
|
221
|
+
if (enabled) baseAnchorTools.add("replace_match");
|
|
222
|
+
else baseAnchorTools.delete("replace_match");
|
|
172
223
|
}
|
|
173
224
|
else if (key === "requirePath") await toggleRequirePath();
|
|
174
225
|
else if (key === "strictInput") await toggleStrictInput();
|
|
175
226
|
await refreshEditTools();
|
|
227
|
+
await syncModelGate(ctx.model);
|
|
176
228
|
},
|
|
177
229
|
});
|
|
178
230
|
await overlay.load();
|
|
@@ -208,6 +260,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
208
260
|
});
|
|
209
261
|
pi.on("tool_result", async (event, ctx) => withAnchorSession(ctx, async () => {
|
|
210
262
|
if (event.isError) return;
|
|
263
|
+
const gated = modelDisabled(ctx.model, disableOnModels);
|
|
211
264
|
|
|
212
265
|
if (event.toolName === "write") {
|
|
213
266
|
const writtenPath = (event.input as Record<string, unknown>)?.path;
|
|
@@ -221,7 +274,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
221
274
|
console.error("Failed to clear undo after write:", error);
|
|
222
275
|
}
|
|
223
276
|
}
|
|
224
|
-
if (!autoRead) return;
|
|
277
|
+
if (!autoRead || gated) return;
|
|
225
278
|
if (typeof writtenPath !== "string") return;
|
|
226
279
|
try {
|
|
227
280
|
resolvedPath ??= (await resolveInCwd(writtenPath, ctx.cwd)).resolved;
|
|
@@ -268,7 +321,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
268
321
|
event.toolName !== "move" &&
|
|
269
322
|
event.toolName !== "undo_last_change"
|
|
270
323
|
) return;
|
|
271
|
-
if (!autoRead) return;
|
|
324
|
+
if (!autoRead || gated) return;
|
|
272
325
|
|
|
273
326
|
const metrics = (event.details as { metrics?: RMetrics } | undefined)?.metrics;
|
|
274
327
|
if (metrics?.classification === "noop") return;
|
|
@@ -294,6 +347,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
294
347
|
const notices = [warnings, hints].filter((part): part is string => part !== undefined).join("\n\n");
|
|
295
348
|
const emptyDiffNotice = "[post-edit] applied successfully; the diff is empty (no content change: whitespace or line endings only).";
|
|
296
349
|
const noticeText = hasDiff ? (notices ? `${diff}\n\n${notices}` : diff) : notices ? `${emptyDiffNotice}\n\n${notices}` : emptyDiffNotice;
|
|
350
|
+
const structured = (event as { structuredContent?: unknown }).structuredContent;
|
|
297
351
|
return {
|
|
298
352
|
content: [
|
|
299
353
|
{
|
|
@@ -301,6 +355,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
301
355
|
text: noticeText,
|
|
302
356
|
},
|
|
303
357
|
],
|
|
358
|
+
...(structured !== undefined ? { structuredContent: withStructuredText(structured, noticeText) } : {}),
|
|
304
359
|
};
|
|
305
360
|
}));
|
|
306
361
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"xxhash-wasm": "^1.1.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
47
|
+
"@earendil-works/pi-coding-agent": ">=0.99.0",
|
|
48
48
|
"@earendil-works/pi-tui": "*",
|
|
49
49
|
"typebox": "*"
|
|
50
50
|
},
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"typecheck": "tsc --noEmit"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
63
|
+
"@earendil-works/pi-coding-agent": "^1.0.3",
|
|
64
64
|
"@eslint/js": "^10.0.1",
|
|
65
65
|
"@types/node": "^24",
|
|
66
66
|
"@vitest/coverage-v8": "^4.1.11",
|
package/prompts/copy-snippet.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Copy lines to another position by anchor: `source_from`/`source_to` mark the range, the copy lands after `insert_after`;
|
|
1
|
+
Copy lines to another position by anchor: `source_from`/`source_to` mark the range, the copy lands after `insert_after`; each anchor resolves its own file
|
package/prompts/copy.md
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
Copy a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to copy in the source file, and `insert_after` as the bare anchor of the destination line after which the copy goes.
|
|
1
|
+
Copy a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to copy in the source file, and `insert_after` as the bare anchor of the destination line after which the copy goes. The source lines stay in place, and the copied lines are written exactly as the source file holds them.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Same-file calls in one message batch, and a cross-file copy joins the destination file's batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch. A copy always duplicates the content its source anchors were served from, so a same-message edit to the source file does not change what is copied.
|
|
3
|
+
A cross-file copy joins the destination file's batch. A copy always duplicates the content its source anchors were served from, so a same-message edit to the source file does not change what is copied.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- `anchor_grep`: prefer it over shell `rg`/`grep` for searching, so hits carry anchors and need no `read`; use `literal: true` when the pattern contains regex metacharacters.
|
package/prompts/grep.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace, insert, copy, or move without a new read; the `=== path ===` header and line numbers locate the match. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When cut by match limit, raise `limit` (default 100); when cut by rows/bytes, refine `pattern` / narrow path.
|
|
1
|
+
Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace, insert, copy, or move without a new read; the `=== path ===` header and line numbers locate the match. Patterns are regex by default; set `literal: true` to search exact text, `ignoreCase` for case-insensitive search, and `context` for surrounding rows. Avoid backreferences, nested quantifiers, and quantified alternation. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When cut by match limit, raise `limit` (default 100); when cut by rows/bytes, refine `pattern` / narrow path.
|
package/prompts/insert.md
CHANGED
|
@@ -1,3 +1 @@
|
|
|
1
|
-
Insert text after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. `text` goes after the anchor line with `direction: "after"` or before it with `direction: "before"`. `text` is one string holding the exact text to insert.
|
|
2
|
-
|
|
3
|
-
Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch.
|
|
1
|
+
Insert text after or before one existing line in a text file, addressed by the `anchor` field (a bare anchor from any served anchor│content row). `text` goes after the anchor line with `direction: "after"` or before it with `direction: "before"`. `text` is one string holding the exact text to insert. A trailing line break sets the last line's ending instead of adding a blank line, so a blank line next to the anchor line must be an extra break in `text`: `"x\n"` leaves the anchor line directly after `x`, and `"x\n\n"` leaves one blank line between them (`direction: "after"` mirrors this at the start of `text`).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- `move`: moving a range to where it already sits reports `No changes made` and leaves the anchors alone.
|
package/prompts/move-snippet.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Move lines to another position by anchor: `source_from`/`source_to` mark the range, it lands after `insert_after`;
|
|
1
|
+
Move lines to another position by anchor: `source_from`/`source_to` mark the range, it lands after `insert_after`; each anchor resolves its own file
|
package/prompts/move.md
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
Move a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to move in the source file, and `insert_after` as the bare anchor of the destination line after which the range goes. `
|
|
1
|
+
Move a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to move in the source file, and `insert_after` as the bare anchor of the destination line after which the range goes. `insert_after` must sit outside the source range when both anchors share a file; moving a range in one file to where it already sits reports no change.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
A same-file `move` and a cross-file `move` join the same-message batch of the destination file; a cross-file `move` whose source file also has batched edits in the message commits on its own. A batched cross-file `move` commits its source removal with the batch but shows only the destination diff; read the source file for fresh anchors.
|
|
3
|
+
A cross-file `move` whose source file also has batched edits in the message commits on its own. A batched cross-file `move` commits its source removal with the batch but shows only the destination diff; read the source file for fresh anchors.
|
package/prompts/read.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Read a text file and return it as `anchor│content` rows, one per line: a 4-character anchor (letters only, case-sensitive), the `│` separator, then the line content. Target lines in
|
|
1
|
+
Read a text file and return it as `anchor│content` rows, one per line: a 4-character anchor (letters only, case-sensitive), the `│` separator, then the line content. Target lines in edits by anchor, never by content or line number. Page long files with `offset` and `limit`; when the output says truncated, continue with the hinted `offset/limit`. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
- `replace`: same-message calls must target disjoint ranges
|
|
1
|
+
- `replace`: same-message calls must target disjoint ranges.
|
|
2
2
|
- `replace`: a pure deletion (`text: ""`) is the cheap way to clear a large range: it verifies only the first and last line against the served record and removes the interior as it currently stands.
|
|
3
|
-
- `replace`:
|
|
4
|
-
- `replace`: for a single line, use the same anchor for `remove_from` and `remove_to`; a pasted `anchor│` prefix in `text` is stripped.
|
|
3
|
+
- `replace`: for a single line, use the same anchor for `remove_from` and `remove_to`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
- `replace_match`:
|
|
1
|
+
- `replace_match`: `old_string` is usually a fragment of the line, not the whole line; every occurrence inside the range is replaced, so scope the range with anchors when only some occurrences should change.
|
package/prompts/replace-match.md
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
1
|
Replace part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range; use the same anchor for one line. `old_string` is the text to find inside that range, and `new_string` replaces every occurrence of it.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. A missing match is refused with the current rows, so the retry needs no read. The two boundary anchors must still match what was last shown; lines strictly inside the range are matched against the file as it is on disk.
|
|
6
|
-
|
|
7
|
-
Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch. A missing `old_string` aborts the whole batch unwritten.
|
|
3
|
+
The two boundary anchors must still match what was last shown; lines strictly inside the range are matched against the file as it is on disk.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Replace lines by anchor: bare anchors in `remove_from`/`remove_to`, the exact replacement text in `text`
|
|
1
|
+
Replace lines by anchor: bare anchors in `remove_from`/`remove_to`, the exact replacement text in `text`
|
package/prompts/replace.md
CHANGED
|
@@ -1,6 +1,2 @@
|
|
|
1
|
-
Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `text` is one string holding the exact replacement text.
|
|
1
|
+
Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `text` is one string holding the exact replacement text. The text is written exactly as given, and nothing else in the file changes.
|
|
2
2
|
To change only part of a line without retyping the rest, use `replace_match` instead; it preserves every character the request does not name. Deleting every line empties the file; the result names the new empty-line anchor, so a follow-up `replace` on it can seed content without a `read`.
|
|
3
|
-
|
|
4
|
-
Same-file calls in one message batch: earlier calls reply `In batch N (queued)` and the last call shows the combined diff, with one undo for the whole batch.
|
|
5
|
-
|
|
6
|
-
Example: read served `Hasu│old` and `arvm│old2`. Call { "remove_from": "Hasu", "remove_to": "arvm", "text": "new line 1\nnew line 2" }. The post-edit diff shows `-Hasu│old`, `-arvm│old2`, `+Qwer│new line 1`: the `-` rows are dead anchors now; the `+` and ` ` rows are live anchors for the next edit.
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
- `copy`/`move`: for a block transfer, only the source's two boundary rows and the destination line need serving; the interior transfers verbatim and the block lands in one commit. To append at the end, use the destination's last served line as `insert_after`.
|
|
2
|
+
- `copy`/`move`: `insert_after` must sit outside the source range; `source_to` places the block right after itself.
|
|
3
|
+
- `copy`/`move`: `source_from` and `source_to` may live in a different file than `insert_after`; an empty destination file is seeded with the copied or moved lines.
|
|
2
4
|
- `move`: a batched cross-file move does not display the source file's diff; its removal commits with the batch, and the source file needs a read for fresh anchors.
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
+
- `undo_last_change`: history is one slot per file, not a stack; a second undo returns `E_UNDO_NONE`.
|
|
1
2
|
- `undo_last_change`: a `write` clears the history, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
|
|
2
3
|
- `undo_last_change`: a cross-file `move` records one undo entry per file; undo both the source and the destination to revert the whole move, because undoing one side alone leaves the moved lines duplicated or missing.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Undo the last replace, replace_match, insert, copy, or move on a file, restoring the previous content, BOM, and line endings. Use it when an edit removed or changed the wrong lines. If the file changed since that edit, the undo is refused
|
|
1
|
+
Undo the last replace, replace_match, insert, copy, or move on a file, restoring the previous content, BOM, and line endings. Use it when an edit removed or changed the wrong lines. If the file changed since that edit, the undo is refused and the file is left untouched; do not force it. A file deleted since the edit is restored. If the diff is truncated, use read to see the full file.
|
package/src/batch.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { PipelineResult } from "./replace";
|
|
|
25
25
|
import { genPatch } from "./replace-diff";
|
|
26
26
|
import { saveUndo } from "./replace-undo";
|
|
27
27
|
import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
|
|
28
|
+
import { toEditVerb, withStructuredText, type EditStructured } from "./structured";
|
|
28
29
|
import { serveRows, servedHashesFromDiff } from "./served";
|
|
29
30
|
import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, splitLines } from "./utils";
|
|
30
31
|
|
|
@@ -472,6 +473,20 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
|
|
|
472
473
|
classification: piece.noop ? "noop" : "applied",
|
|
473
474
|
...(piece.noop ? {} : { added_lines: added, removed_lines: piece.end - piece.start + 1 }),
|
|
474
475
|
};
|
|
476
|
+
const structuredContent: EditStructured = {
|
|
477
|
+
ok: true,
|
|
478
|
+
kind: "edit",
|
|
479
|
+
verb: toEditVerb(piece.kind),
|
|
480
|
+
classification: piece.noop ? "noop" : "applied",
|
|
481
|
+
path: member.target,
|
|
482
|
+
text: `In batch ${member.display} (queued)`,
|
|
483
|
+
diff: "",
|
|
484
|
+
warnings: [],
|
|
485
|
+
hints: [],
|
|
486
|
+
firstChangedLine: piece.noop ? null : piece.start,
|
|
487
|
+
anchors: [],
|
|
488
|
+
anchorsOmitted: false,
|
|
489
|
+
};
|
|
475
490
|
return {
|
|
476
491
|
content: [
|
|
477
492
|
{
|
|
@@ -488,6 +503,7 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
|
|
|
488
503
|
metrics,
|
|
489
504
|
batch: { id: member.display, size: member.size, last: false, total: member.total },
|
|
490
505
|
},
|
|
506
|
+
structuredContent,
|
|
491
507
|
};
|
|
492
508
|
}
|
|
493
509
|
|
|
@@ -1044,6 +1060,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
1044
1060
|
: "";
|
|
1045
1061
|
const undoNote = sourceFiles.length > 0 ? "one undo reverts the destination edits" : "one undo reverts them";
|
|
1046
1062
|
changed.content[0]!.text = `${header}\n${changed.content[0]!.text}\nBatch ${member.display}: ${executed} edit${executed === 1 ? "" : "s"} applied as one commit; ${undoNote}.${sourceNote}`;
|
|
1063
|
+
changed.structuredContent = withStructuredText(changed.structuredContent, changed.content[0]!.text);
|
|
1047
1064
|
changed.details.batch = { id: member.display, size: member.size, last: true, total: member.total };
|
|
1048
1065
|
return changed;
|
|
1049
1066
|
}
|
|
@@ -1065,10 +1082,12 @@ async function combinedNoop(path: string, member: PlannedMember, runtime: BatchS
|
|
|
1065
1082
|
removedLines: 0,
|
|
1066
1083
|
},
|
|
1067
1084
|
warnings: dedupeWarnings(warnings),
|
|
1085
|
+
verb: "edited",
|
|
1068
1086
|
},
|
|
1069
1087
|
"Batch",
|
|
1070
1088
|
);
|
|
1071
1089
|
noop.content[0]!.text += `\nBatch ${member.display}: ${executed} edits produced no net change; undo history preserved.`;
|
|
1090
|
+
noop.structuredContent = withStructuredText(noop.structuredContent, noop.content[0]!.text);
|
|
1072
1091
|
noop.details.batch = { id: member.display, size: member.size, last: true, total: member.total };
|
|
1073
1092
|
return noop;
|
|
1074
1093
|
}
|
package/src/commit.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { lineHashes } from "./hashline";
|
|
|
12
12
|
import { spanForEdit } from "./replace";
|
|
13
13
|
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
14
14
|
import { applyEndingOverrides, joinSeparators, separatorsForSpans } from "./line-endings";
|
|
15
|
+
import { toEditVerb } from "./structured";
|
|
15
16
|
export interface CommitMeta {
|
|
16
17
|
editAnchors?: [string, string];
|
|
17
18
|
anchorCarry?: number;
|
|
@@ -62,6 +63,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
62
63
|
removedLines: 0,
|
|
63
64
|
},
|
|
64
65
|
warnings,
|
|
66
|
+
verb: toEditVerb(meta.verb),
|
|
65
67
|
},
|
|
66
68
|
meta.noopNoun,
|
|
67
69
|
);
|