pi-hashline-edit-pro 6.4.5 → 6.4.7
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 +10 -6
- package/index.ts +17 -12
- package/package.json +1 -1
- package/src/anchor-registry.ts +25 -3
- package/src/batch.ts +2 -2
- package/src/commit.ts +26 -14
- package/src/config-ui.ts +3 -2
- package/src/config.ts +26 -0
- package/src/copy-move.ts +14 -2
- package/src/edit-common.ts +25 -31
- package/src/hash-store.ts +15 -0
- package/src/read.ts +9 -7
- package/src/structured.ts +10 -7
- package/src/write-hook.ts +6 -2
package/README.md
CHANGED
|
@@ -302,6 +302,7 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
|
|
|
302
302
|
|
|
303
303
|
- History is per-file and single-level: only the most recent `replace`, `replace_match`, `insert`, `copy`, or `move` can be reverted. A same-message batch of `replace`/`insert` calls on one file counts as one entry: one undo reverts the whole batch.
|
|
304
304
|
- History is persisted and survives session restarts. A failed `write` does not clear it.
|
|
305
|
+
- Undo records older than `undoRetentionDays` (default 30; `0` keeps them forever) are deleted when the store opens.
|
|
305
306
|
- Every applied `replace`, `replace_match`, `insert`, `copy`, or `move` is undoable; the undo record is saved before the edit is written.
|
|
306
307
|
- A cross-file `move` stores one undo entry per file; `undo_last_change` reverts the file you name, so revert both sides to undo the whole move; a batched cross-file `move` reverts its destination side with the batch's undo and its source side with its own entry.
|
|
307
308
|
- A successful `write` clears the history for that file.
|
|
@@ -353,7 +354,7 @@ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as
|
|
|
353
354
|
|
|
354
355
|
| Command | Description |
|
|
355
356
|
| --- | --- |
|
|
356
|
-
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, git repos only, ignore folders/files, disable on models, read on disabled models, diff context lines, `grep` tool, copy/move tools, replace_match tool, required `path`, and
|
|
357
|
+
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, git repos only, ignore folders/files, disable on models, read on disabled models, diff context lines, `grep` tool, copy/move tools, replace_match tool, required `path`, strict input, and undo retention. Persists across sessions. |
|
|
357
358
|
| `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
|
|
358
359
|
|
|
359
360
|
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
|
|
@@ -370,6 +371,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
370
371
|
"requirePath": false,
|
|
371
372
|
"strictInput": false,
|
|
372
373
|
"diffContextLines": 1,
|
|
374
|
+
"undoRetentionDays": 30,
|
|
373
375
|
"disableOnModels": [],
|
|
374
376
|
"readOnDisabledModels": "vanilla"
|
|
375
377
|
}
|
|
@@ -389,6 +391,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
389
391
|
| `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
|
|
390
392
|
| `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 anchored edit tools, no auto-read, and no write-echo refusal; `read` stays available as pi's ordinary read unless Read on disabled models is `remove`. |
|
|
391
393
|
| `readOnDisabledModels` | Read on disabled models | `"vanilla"` | `read` for a model matched by `disableOnModels`: `"vanilla"` keeps pi's ordinary `read` active while the anchored edit tools stay off; `"remove"` disables `read` with the rest of the anchored tools. |
|
|
394
|
+
| `undoRetentionDays` | Undo retention | `30` | Days to keep undo history; records older than this are deleted when the store opens. `0` keeps them forever (0-3650). |
|
|
392
395
|
|
|
393
396
|
`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 `replace`, `replace_match`, `insert`, `copy`, `move`, `grep`, and `undo_last_change` from the active tools, and also `read` when Read on disabled models is `remove`, registering pi's plain `grep` for those models, 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. Because the extension's `read` replaces the built-in one, a matched model has no `read` at all under `remove` unless another extension provides one; the default `vanilla` gives those models pi's ordinary `read` while the anchored edit tools stay off. 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.
|
|
394
397
|
|
|
@@ -411,6 +414,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
|
|
|
411
414
|
| Auto-read all budget | 200KB floor; 2MB ceiling in `full` | total injection size derived from the model context window; `outline` is not capped at 2MB |
|
|
412
415
|
| Stale-range feedback | first 100 lines | rows returned with `[E_RANGE_STALE]` |
|
|
413
416
|
| Session anchors | 1,353,139 anchors in use | all tools; the least recently read or edited files are freed when the quota is exhausted (`[W_ANCHOR_RECLAIMED]`) |
|
|
417
|
+
| Undo retention | 30 days by default (`undoRetentionDays` 0-3650) | `undo_last_change` history |
|
|
414
418
|
|
|
415
419
|
`grep` uses the same 100MB file-size cutoff as `read`. Files over the line cap are skipped silently in directory searches.
|
|
416
420
|
|
|
@@ -418,9 +422,9 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
|
|
|
418
422
|
|
|
419
423
|
All eight tools return machine-readable metadata in `details` alongside the model-visible text.
|
|
420
424
|
|
|
421
|
-
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.
|
|
425
|
+
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. For `read`, `text` is the raw file content of the served window and `lines` carries the anchored rows, so a script parses the file without anchor prefixes. 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.
|
|
422
426
|
|
|
423
|
-
`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 `{
|
|
427
|
+
`read` returns `{ kind: "read", path, text, lines, totalLines, startLine, nextOffset, truncated, blockedByLongLine, hadUtf8DecodeErrors, notices }`, where `text` is the raw file content of the served window (no anchors, no footers) and each entry of `lines` is `{ line, text, anchor, rendered }` for a row that was served (`text` there is what was shown, so an oversized line appears as its marker while keeping its anchor). `notices` carries non-file notes such as the UTF-8 rewrite note and `[W_ANCHOR_RECLAIMED]`. An image read returns `{ type: "image", data, mimeType, note }`.
|
|
424
428
|
|
|
425
429
|
`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.
|
|
426
430
|
|
|
@@ -482,12 +486,12 @@ Full reference:
|
|
|
482
486
|
| `[E_UNDO_NONE]` | `undo_last_change` found no recorded edit for the file. Nothing was changed; make an edit first. |
|
|
483
487
|
| `[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. |
|
|
484
488
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
|
|
485
|
-
| `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran
|
|
489
|
+
| `[E_REGISTRY]` | The anchor registry was not initialized, or the session's registry was released because the session was replaced or reloaded; a serve or edit ran without a usable session. Retry the operation in the current session. |
|
|
486
490
|
| `[E_STORE_UNAVAILABLE]` | No SQLite runtime could be loaded: the host exposes neither `node:sqlite` (Node 22.19+) nor `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`; run pi under Node or a Bun build that ships SQLite. |
|
|
487
491
|
| `[E_WRITE_HASH_ECHO]` | A `write` `content` line reproduces a served row for this file (a bare `anchor│` read row, a `+anchor│`, ` anchor│`, or `-anchor│` diff row, or a `lineNumber:│ anchor│content` grep row). The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
|
|
488
492
|
| `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
|
|
489
493
|
| `[E_BATCH_OVERLAP]` | Batched edit calls target overlapping ranges; the whole batch was refused. One `before` plus one `after` insert on the same anchor line is not an overlap. Retry with disjoint ranges. |
|
|
490
|
-
| `[E_OP_ABORTED]` |
|
|
494
|
+
| `[E_OP_ABORTED]` | The edit was aborted before writing: the file changed or was deleted after it was read (a single edit or a cross-file move reports the file; a batch reports the batch and the failing call), the base read failed, a cross-file member could not be prepared, or the composed result exceeded a limit. Nothing was written; a call whose own request is invalid is left out of the batch instead. Fix the cause and retry, otherwise call `read` for fresh anchors and retry. The batch abort names the failing call and its error code when one is known. |
|
|
491
495
|
| `[E_GREP_FAILED]` | `grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences or look-around, which ripgrep's regex engine does not support); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
|
|
492
496
|
| `[E_GREP_TIMEOUT]` | `grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
|
|
493
497
|
|
|
@@ -513,7 +517,7 @@ All state lives under the config directory (see [Configuration](#configuration))
|
|
|
513
517
|
| `hash-store.sqlite` (+ `-wal`, `-shm`) | Per-file snapshots of allocated anchors keyed by content checksum, plus the undo table. |
|
|
514
518
|
| `sessions/<key>.registry.jsonl` | The session's anchor ownership log (`allocate`/`free`/`clear` events). |
|
|
515
519
|
|
|
516
|
-
The undo table contains the complete pre-edit and post-edit text for the latest edit to each file, so treat the store as sensitive data. On POSIX systems the state directory is restricted to mode `0700` and the SQLite database plus its WAL/SHM sidecars to `0600`. Sidecar logs whose session file is gone are garbage-collected at startup (never the sidecar of a session that is currently loaded in this process); the in-memory ownership of a session is released when that session shuts down and rebuilt from its sidecar on next use. Served records live in memory only and are recomputed on the next read.
|
|
520
|
+
The undo table contains the complete pre-edit and post-edit text for the latest edit to each file, so treat the store as sensitive data. Undo records older than `undoRetentionDays` are deleted when the store opens, and SQLite's `secure_delete` overwrites deleted content. On POSIX systems the state directory is restricted to mode `0700` and the SQLite database plus its WAL/SHM sidecars to `0600`. Sidecar logs whose session file is gone are garbage-collected at startup (never the sidecar of a session that is currently loaded in this process); the in-memory ownership of a session is released when that session shuts down and rebuilt from its sidecar on next use. Served records live in memory only and are recomputed on the next read.
|
|
517
521
|
|
|
518
522
|
### Isolated state
|
|
519
523
|
|
package/index.ts
CHANGED
|
@@ -29,11 +29,13 @@ import {
|
|
|
29
29
|
toggleRequirePath,
|
|
30
30
|
toggleStrictInput,
|
|
31
31
|
adjustDiffContextLines,
|
|
32
|
+
adjustUndoRetentionDays,
|
|
33
|
+
getUndoRetentionDays,
|
|
32
34
|
setAutoReadAllIgnoreFromText,
|
|
33
35
|
setDisableOnModelsFromText,
|
|
34
36
|
} from "./src/config";
|
|
35
|
-
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
36
|
-
import { type AnchorSessionContext, initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
|
|
37
|
+
import { loadHashStore, pruneExpiredUndo, pruneMissing } from "./src/hash-store";
|
|
38
|
+
import { type AnchorSessionContext, initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, sessionContextReadable, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
|
|
37
39
|
import { serveRows } from "./src/served";
|
|
38
40
|
import { finalizeTurn, planAssistantMessage } from "./src/batch";
|
|
39
41
|
import { currentEditFlags } from "./src/edit-common";
|
|
@@ -69,6 +71,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
69
71
|
let autoReadAllRequireGit = true;
|
|
70
72
|
let disableOnModels: string[] = [];
|
|
71
73
|
let readOnDisabledModels: ReadOnDisabledModels = "vanilla";
|
|
74
|
+
let codemodeActive = false;
|
|
72
75
|
let readFlavor: "anchored" | "vanilla" = "anchored";
|
|
73
76
|
let readFlavorCwd: string | undefined;
|
|
74
77
|
let autoReadAllInjected = false;
|
|
@@ -81,7 +84,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
81
84
|
|
|
82
85
|
async function refreshEditTools(): Promise<void> {
|
|
83
86
|
try {
|
|
84
|
-
|
|
87
|
+
codemodeActive = pi.getActiveTools().includes("codemode");
|
|
88
|
+
const flags = await currentEditFlags(codemodeActive);
|
|
85
89
|
if (readFlavor !== "vanilla") regRead(pi, flags);
|
|
86
90
|
regReplace(pi, flags);
|
|
87
91
|
regReplaceMatch(pi, flags);
|
|
@@ -154,14 +158,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
154
158
|
const key = sessionKeyFor(ctx);
|
|
155
159
|
const entry: PendingRegistrySession = {
|
|
156
160
|
key,
|
|
157
|
-
alive: () =>
|
|
158
|
-
try {
|
|
159
|
-
void ctx?.sessionManager;
|
|
160
|
-
return true;
|
|
161
|
-
} catch {
|
|
162
|
-
return false;
|
|
163
|
-
}
|
|
164
|
-
},
|
|
161
|
+
alive: () => sessionContextReadable(ctx),
|
|
165
162
|
};
|
|
166
163
|
const index = key === undefined ? -1 : pendingRegistrySessions.findIndex((candidate) => candidate.key === key);
|
|
167
164
|
if (index >= 0) pendingRegistrySessions[index] = entry;
|
|
@@ -174,7 +171,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
174
171
|
releaseRegistrySession(key);
|
|
175
172
|
}
|
|
176
173
|
|
|
177
|
-
async function withRegistrySession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T> {
|
|
174
|
+
async function withRegistrySession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T | undefined> {
|
|
175
|
+
if (!sessionContextReadable(ctx)) return undefined;
|
|
178
176
|
captureRegistrySession(ctx);
|
|
179
177
|
return withAnchorSession(ctx, fn);
|
|
180
178
|
}
|
|
@@ -190,6 +188,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
190
188
|
loadHashStore()
|
|
191
189
|
.then(async store => {
|
|
192
190
|
freeAnchorsForPaths(await pruneMissing(store));
|
|
191
|
+
try {
|
|
192
|
+
pruneExpiredUndo(store, await getUndoRetentionDays());
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.error("Failed to prune expired undo records:", error);
|
|
195
|
+
}
|
|
193
196
|
})
|
|
194
197
|
.catch(err => {
|
|
195
198
|
console.error("Failed to load hash store:", err);
|
|
@@ -258,6 +261,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
258
261
|
}));
|
|
259
262
|
|
|
260
263
|
pi.on("before_agent_start", async (_event, ctx) => withRegistrySession(ctx, async () => {
|
|
264
|
+
if (pi.getActiveTools().includes("codemode") !== codemodeActive) await refreshEditTools();
|
|
261
265
|
await syncModelGate(ctx.model, ctx.cwd);
|
|
262
266
|
if (autoReadAll === "off" || autoReadAllInjected) return;
|
|
263
267
|
if (modelDisabled(ctx.model, disableOnModels)) return;
|
|
@@ -294,6 +298,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
294
298
|
else if (key === "disableOnModels") disableOnModels = await setDisableOnModelsFromText(value ?? "");
|
|
295
299
|
else if (key === "readOnDisabledModels") readOnDisabledModels = await cycleReadOnDisabledModels(delta);
|
|
296
300
|
else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
|
|
301
|
+
else if (key === "undoRetentionDays") await adjustUndoRetentionDays((delta ?? 1) * 5);
|
|
297
302
|
else if (key === "anchorGrepEnabled") {
|
|
298
303
|
const enabled = await toggleAnchorGrep();
|
|
299
304
|
grepEnabled = enabled;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "6.4.
|
|
3
|
+
"version": "6.4.7",
|
|
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",
|
package/src/anchor-registry.ts
CHANGED
|
@@ -406,7 +406,7 @@ async function ensureRegistryForKey(key: string, sessionFile: string | undefined
|
|
|
406
406
|
try {
|
|
407
407
|
await promise;
|
|
408
408
|
} finally {
|
|
409
|
-
pendingInits.delete(key);
|
|
409
|
+
if (pendingInits.get(key) === promise) pendingInits.delete(key);
|
|
410
410
|
}
|
|
411
411
|
}
|
|
412
412
|
|
|
@@ -435,6 +435,15 @@ function sessionFileFor(ctx: AnchorSessionContext | undefined): string | undefin
|
|
|
435
435
|
return typeof sessionFile === "string" && sessionFile.length > 0 ? sessionFile : undefined;
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
export function sessionContextReadable(ctx: AnchorSessionContext | undefined): boolean {
|
|
439
|
+
try {
|
|
440
|
+
void ctx?.sessionManager;
|
|
441
|
+
return true;
|
|
442
|
+
} catch {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
438
447
|
export async function withAnchorSession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T> {
|
|
439
448
|
const key = sessionKeyFor(ctx);
|
|
440
449
|
if (key === undefined) return fn();
|
|
@@ -712,6 +721,9 @@ export function ownersForPath(path: string): Map<string, string> {
|
|
|
712
721
|
export function ensureRegistry(): void {
|
|
713
722
|
const key = activeKey();
|
|
714
723
|
if (key && registries.has(key)) return;
|
|
724
|
+
if (sessionScope.getStore() !== undefined) {
|
|
725
|
+
throw new Error("[E_REGISTRY] The anchor registry for this session is no longer available; the session was replaced or reloaded. Retry the operation in the new session.");
|
|
726
|
+
}
|
|
715
727
|
initRegistry(undefined).catch(() => undefined);
|
|
716
728
|
}
|
|
717
729
|
|
|
@@ -973,8 +985,18 @@ export async function allocateFileAnchors(
|
|
|
973
985
|
if (previousState && snapshotMatchesAllocation(registry, path, previousState.contentChecksum)) {
|
|
974
986
|
prevAnchors = previousState.anchors;
|
|
975
987
|
prevChecksums = previousState.checksums;
|
|
976
|
-
if (!prevChecksums
|
|
977
|
-
|
|
988
|
+
if (!prevChecksums) {
|
|
989
|
+
if (previousState.contentChecksum === contentChecksum(content)) {
|
|
990
|
+
prevChecksums = checksums;
|
|
991
|
+
} else if (!shadow) {
|
|
992
|
+
const state = current();
|
|
993
|
+
const stale = state && prevAnchors ? prevAnchors.filter((anchor) => state.owned.get(anchor)?.path === path) : [];
|
|
994
|
+
if (state && stale.length > 0) {
|
|
995
|
+
const usedAt = state.lastUsedAt.get(path);
|
|
996
|
+
freeAnchors(path, stale);
|
|
997
|
+
if (usedAt !== undefined) state.lastUsedAt.set(path, usedAt);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
978
1000
|
}
|
|
979
1001
|
}
|
|
980
1002
|
}
|
package/src/batch.ts
CHANGED
|
@@ -505,7 +505,7 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
|
|
|
505
505
|
edits_noop: piece.noop ? 1 : 0,
|
|
506
506
|
warnings: 0,
|
|
507
507
|
classification: piece.noop ? "noop" : "applied",
|
|
508
|
-
...(piece.noop ? {} : { added_lines: added, removed_lines: piece.end - piece.start + 1 }),
|
|
508
|
+
...(piece.noop ? {} : { added_lines: added, removed_lines: Math.max(0, piece.end - piece.start + 1 - piece.foldedLines) }),
|
|
509
509
|
};
|
|
510
510
|
const structuredContent: EditStructured = {
|
|
511
511
|
ok: true,
|
|
@@ -1102,7 +1102,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
1102
1102
|
let added = 0;
|
|
1103
1103
|
let removed = 0;
|
|
1104
1104
|
for (const piece of appliedPieces) {
|
|
1105
|
-
removed += piece.end - piece.start + 1;
|
|
1105
|
+
removed += Math.max(0, piece.end - piece.start + 1 - piece.foldedLines);
|
|
1106
1106
|
added += Math.max(0, piece.newLines.length - piece.foldedLines);
|
|
1107
1107
|
}
|
|
1108
1108
|
const verb = batchVerb(runtime);
|
package/src/commit.ts
CHANGED
|
@@ -14,6 +14,28 @@ import { spanForEdit } from "./replace";
|
|
|
14
14
|
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
15
15
|
import { applyEndingOverrides, joinSeparators, separatorsForSpans } from "./line-endings";
|
|
16
16
|
import { toEditVerb } from "./structured";
|
|
17
|
+
export type FileStateSinceRead = "unchanged" | "deleted" | "changed";
|
|
18
|
+
|
|
19
|
+
export async function fileStateSinceRead(
|
|
20
|
+
mutationTargetPath: string,
|
|
21
|
+
displayPath: string,
|
|
22
|
+
expectedNormalized: string,
|
|
23
|
+
signal?: AbortSignal,
|
|
24
|
+
): Promise<FileStateSinceRead> {
|
|
25
|
+
abortIf(signal);
|
|
26
|
+
let raw: string | undefined;
|
|
27
|
+
try {
|
|
28
|
+
raw = await readFile(mutationTargetPath, "utf-8");
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const code = errCode(error);
|
|
31
|
+
if (code === "ENOENT") raw = undefined;
|
|
32
|
+
else if (code === "EACCES" || code === "EPERM") throw new Error(`[E_ACCESS] File is not readable: ${displayPath}`);
|
|
33
|
+
else if (code === "ELOOP") throw new Error(`[E_ACCESS] Too many symbolic links while resolving: ${displayPath}`);
|
|
34
|
+
else throw mapIoError(error, "read", displayPath);
|
|
35
|
+
}
|
|
36
|
+
if (raw === undefined) return "deleted";
|
|
37
|
+
return toLF(stripBOM(raw).text) === expectedNormalized ? "unchanged" : "changed";
|
|
38
|
+
}
|
|
17
39
|
export interface CommitMeta {
|
|
18
40
|
editAnchors?: [string, string];
|
|
19
41
|
anchorCarry?: number;
|
|
@@ -91,21 +113,11 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
91
113
|
);
|
|
92
114
|
}
|
|
93
115
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
currentRaw = await readFile(mutationTargetPath, "utf-8");
|
|
98
|
-
} catch (error) {
|
|
99
|
-
const code = errCode(error);
|
|
100
|
-
if (code === "ENOENT") currentRaw = undefined;
|
|
101
|
-
else if (code === "EACCES" || code === "EPERM") throw new Error(`[E_ACCESS] File is not readable: ${path}`);
|
|
102
|
-
else if (code === "ELOOP") throw new Error(`[E_ACCESS] Too many symbolic links while resolving: ${path}`);
|
|
103
|
-
else throw mapIoError(error, "read", path);
|
|
104
|
-
}
|
|
105
|
-
if (currentRaw === undefined) {
|
|
116
|
+
const state = await fileStateSinceRead(mutationTargetPath, path, pipe.originalNormalized, signal);
|
|
117
|
+
if (state === "deleted") {
|
|
106
118
|
throw new Error(`[E_OP_ABORTED] Edit aborted: the file was deleted after the edit started.`);
|
|
107
119
|
}
|
|
108
|
-
if (
|
|
120
|
+
if (state === "changed") {
|
|
109
121
|
throw new Error(`[E_OP_ABORTED] Edit aborted: the file changed after the edit started. Call read for fresh anchors and retry.`);
|
|
110
122
|
}
|
|
111
123
|
const undo = await saveUndo(mutationTargetPath, {
|
|
@@ -141,7 +153,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
141
153
|
firstChangedLine: pipe.firstChangedLine,
|
|
142
154
|
lastChangedLine: pipe.lastChangedLine,
|
|
143
155
|
addedLines: Math.max(0, pipe.totalAddedLines - (meta.foldedAnchorLines ?? 0)),
|
|
144
|
-
removedLines: pipe.totalRemovedLines,
|
|
156
|
+
removedLines: Math.max(0, pipe.totalRemovedLines - (meta.foldedAnchorLines ?? 0)),
|
|
145
157
|
};
|
|
146
158
|
|
|
147
159
|
let resultHashes: string[];
|
package/src/config-ui.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
2
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { readConfig, type Config } from "./config";
|
|
3
|
+
import { DEFAULT_UNDO_RETENTION_DAYS, readConfig, type Config } from "./config";
|
|
4
4
|
|
|
5
|
-
export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllRequireGit" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "diffContextLines" | "disableOnModels" | "readOnDisabledModels";
|
|
5
|
+
export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllRequireGit" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "diffContextLines" | "disableOnModels" | "readOnDisabledModels" | "undoRetentionDays";
|
|
6
6
|
|
|
7
7
|
export interface ConfigRow {
|
|
8
8
|
key: ConfigToggleKey;
|
|
@@ -40,6 +40,7 @@ export function configRows(config: Config): ConfigRow[] {
|
|
|
40
40
|
{ key: "strictInput", group: "Edit behavior", depth: 0, label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
|
|
41
41
|
{ key: "disableOnModels", group: "Model gating", depth: 0, label: "Disable on models", hint: "Model globs that turn off anchored tools (comma-separated)", enabled: (config.disableOnModels ?? []).length > 0, entries: config.disableOnModels ?? [] },
|
|
42
42
|
{ key: "readOnDisabledModels", group: "Model gating", depth: 1, dependsOn: "disableOnModels", gated: true, label: "Read on disabled models", hint: "Keep read available for models disabled above", enabled: (config.readOnDisabledModels ?? "vanilla") !== "remove", mode: config.readOnDisabledModels ?? "vanilla", cycle: ["remove", "vanilla"] },
|
|
43
|
+
{ key: "undoRetentionDays", group: "State", depth: 0, label: "Undo retention", hint: "Days to keep undo history; 0 keeps it forever", enabled: (config.undoRetentionDays ?? DEFAULT_UNDO_RETENTION_DAYS) > 0, value: config.undoRetentionDays ?? DEFAULT_UNDO_RETENTION_DAYS },
|
|
43
44
|
];
|
|
44
45
|
for (const row of rows) {
|
|
45
46
|
if (row.gated !== true || row.dependsOn === undefined) continue;
|
package/src/config.ts
CHANGED
|
@@ -12,6 +12,9 @@ const READ_ON_DISABLED_MODELS: ReadOnDisabledModels[] = ["remove", "vanilla"];
|
|
|
12
12
|
export const DEFAULT_DIFF_CONTEXT_LINES = 1;
|
|
13
13
|
export const MIN_DIFF_CONTEXT_LINES = 0;
|
|
14
14
|
export const MAX_DIFF_CONTEXT_LINES = 10;
|
|
15
|
+
export const DEFAULT_UNDO_RETENTION_DAYS = 30;
|
|
16
|
+
export const MIN_UNDO_RETENTION_DAYS = 0;
|
|
17
|
+
export const MAX_UNDO_RETENTION_DAYS = 3650;
|
|
15
18
|
|
|
16
19
|
export interface Config {
|
|
17
20
|
autoRead: boolean;
|
|
@@ -26,6 +29,7 @@ export interface Config {
|
|
|
26
29
|
diffContextLines?: number;
|
|
27
30
|
disableOnModels?: string[];
|
|
28
31
|
readOnDisabledModels?: ReadOnDisabledModels;
|
|
32
|
+
undoRetentionDays?: number;
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
const DEFAULT_CONFIG: Config = {
|
|
@@ -41,6 +45,7 @@ const DEFAULT_CONFIG: Config = {
|
|
|
41
45
|
diffContextLines: DEFAULT_DIFF_CONTEXT_LINES,
|
|
42
46
|
disableOnModels: [],
|
|
43
47
|
readOnDisabledModels: "vanilla",
|
|
48
|
+
undoRetentionDays: DEFAULT_UNDO_RETENTION_DAYS,
|
|
44
49
|
};
|
|
45
50
|
|
|
46
51
|
function parseAutoReadAllMode(value: unknown): AutoReadAllMode {
|
|
@@ -101,6 +106,14 @@ export function normalizeDiffContextLines(value: unknown): number {
|
|
|
101
106
|
return floored;
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
export function normalizeUndoRetentionDays(value: unknown): number {
|
|
110
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_UNDO_RETENTION_DAYS;
|
|
111
|
+
const floored = Math.floor(value);
|
|
112
|
+
if (floored < MIN_UNDO_RETENTION_DAYS) return MIN_UNDO_RETENTION_DAYS;
|
|
113
|
+
if (floored > MAX_UNDO_RETENTION_DAYS) return MAX_UNDO_RETENTION_DAYS;
|
|
114
|
+
return floored;
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
function parseConfig(content: string): Config {
|
|
105
118
|
const parsed = JSON.parse(content) as unknown;
|
|
106
119
|
if (!isRec(parsed) || (parsed.autoRead !== undefined && typeof parsed.autoRead !== "boolean")) {
|
|
@@ -118,6 +131,7 @@ function parseConfig(content: string): Config {
|
|
|
118
131
|
const autoReadAllIgnore = parsed.autoReadAllIgnore;
|
|
119
132
|
const disableOnModels = parsed.disableOnModels;
|
|
120
133
|
const readOnDisabledModels = parsed.readOnDisabledModels;
|
|
134
|
+
const undoRetentionDays = parsed.undoRetentionDays;
|
|
121
135
|
const legacyDisableReadOnModels = parsed.disableReadOnModels;
|
|
122
136
|
return {
|
|
123
137
|
autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
|
|
@@ -132,6 +146,7 @@ function parseConfig(content: string): Config {
|
|
|
132
146
|
autoReadAllIgnore: parseAutoReadAllIgnore(autoReadAllIgnore),
|
|
133
147
|
disableOnModels: parseDisableOnModels(disableOnModels),
|
|
134
148
|
readOnDisabledModels: parseReadOnDisabledModels(readOnDisabledModels, legacyDisableReadOnModels),
|
|
149
|
+
undoRetentionDays: normalizeUndoRetentionDays(undoRetentionDays),
|
|
135
150
|
};
|
|
136
151
|
}
|
|
137
152
|
|
|
@@ -277,6 +292,9 @@ export const toggleStrictInput = (): Promise<boolean> => toggleFlag("strictInput
|
|
|
277
292
|
export async function getDiffContextLines(): Promise<number> {
|
|
278
293
|
return normalizeDiffContextLines((await readConfig()).diffContextLines);
|
|
279
294
|
}
|
|
295
|
+
export async function getUndoRetentionDays(): Promise<number> {
|
|
296
|
+
return normalizeUndoRetentionDays((await readConfig()).undoRetentionDays);
|
|
297
|
+
}
|
|
280
298
|
export async function adjustDiffContextLines(delta: number): Promise<number> {
|
|
281
299
|
let next = DEFAULT_DIFF_CONTEXT_LINES;
|
|
282
300
|
await updateConfig((c) => {
|
|
@@ -285,6 +303,14 @@ export async function adjustDiffContextLines(delta: number): Promise<number> {
|
|
|
285
303
|
});
|
|
286
304
|
return next;
|
|
287
305
|
}
|
|
306
|
+
export async function adjustUndoRetentionDays(delta: number): Promise<number> {
|
|
307
|
+
let next = DEFAULT_UNDO_RETENTION_DAYS;
|
|
308
|
+
await updateConfig((c) => {
|
|
309
|
+
next = normalizeUndoRetentionDays(normalizeUndoRetentionDays(c.undoRetentionDays) + delta);
|
|
310
|
+
c.undoRetentionDays = next;
|
|
311
|
+
});
|
|
312
|
+
return next;
|
|
313
|
+
}
|
|
288
314
|
export async function setAutoReadAllIgnore(dirs: string[]): Promise<string[]> {
|
|
289
315
|
let next: string[] = [];
|
|
290
316
|
await updateConfig((c) => {
|
package/src/copy-move.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, withFil
|
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { constants } from "node:fs";
|
|
5
5
|
import { execPipeline, noteAnchorError, previewFromPipe, previewError, type PipelineResult, type ReplaceDetails } from "./replace";
|
|
6
|
-
import { commitEdit } from "./commit";
|
|
6
|
+
import { commitEdit, fileStateSinceRead } from "./commit";
|
|
7
7
|
import { readNormFile, safeSnapId, type NormFile } from "./file-reader";
|
|
8
8
|
import { preEditSnapshotFor } from "./pre-edit-snapshot";
|
|
9
9
|
import {
|
|
@@ -377,6 +377,17 @@ async function commitMovePair(input: {
|
|
|
377
377
|
warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
const destinationState = await fileStateSinceRead(input.destination.mutationTargetPath, input.destination.displayPath, input.destination.pipe.originalNormalized, input.signal);
|
|
381
|
+
if (destinationState !== "unchanged") {
|
|
382
|
+
const verb = destinationState === "deleted" ? "was deleted" : "changed";
|
|
383
|
+
throw new Error(`[E_OP_ABORTED] Edit aborted: ${input.destination.displayPath} ${verb} after the move started. Call read for fresh anchors and retry.`);
|
|
384
|
+
}
|
|
385
|
+
const sourceState = await fileStateSinceRead(input.source.mutationTargetPath, input.source.displayPath, input.source.pipe.originalNormalized, input.signal);
|
|
386
|
+
if (sourceState !== "unchanged") {
|
|
387
|
+
const verb = sourceState === "deleted" ? "was deleted" : "changed";
|
|
388
|
+
throw new Error(`[E_OP_ABORTED] Edit aborted: ${input.source.displayPath} ${verb} after the move started. Call read for fresh anchors and retry.`);
|
|
389
|
+
}
|
|
390
|
+
|
|
380
391
|
const undoDestination = await saveUndo(input.destination.mutationTargetPath, pairEntry(input.destination.pipe, destinationSeparators));
|
|
381
392
|
if (!undoDestination.persisted) {
|
|
382
393
|
throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${input.destination.displayPath}. Nothing was written.`);
|
|
@@ -451,7 +462,7 @@ async function commitMovePair(input: {
|
|
|
451
462
|
firstChangedLine: input.destination.pipe.firstChangedLine,
|
|
452
463
|
lastChangedLine: input.destination.pipe.lastChangedLine,
|
|
453
464
|
addedLines: Math.max(0, input.destination.pipe.totalAddedLines - input.destination.foldedAnchorLines),
|
|
454
|
-
removedLines: input.destination.pipe.totalRemovedLines,
|
|
465
|
+
removedLines: Math.max(0, input.destination.pipe.totalRemovedLines - input.destination.foldedAnchorLines),
|
|
455
466
|
},
|
|
456
467
|
...(destinationSpan !== undefined ? { spans: [destinationSpan] } : {}),
|
|
457
468
|
}, "moved", contextLines);
|
|
@@ -932,6 +943,7 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
|
|
|
932
943
|
],
|
|
933
944
|
},
|
|
934
945
|
flags,
|
|
946
|
+
{ scriptGuidelines: kind === "copy" },
|
|
935
947
|
);
|
|
936
948
|
return {
|
|
937
949
|
name: kind,
|
package/src/edit-common.ts
CHANGED
|
@@ -55,6 +55,13 @@ const SHARED_PAYLOAD_OPS = ["replace", "replace_match", "insert"];
|
|
|
55
55
|
const SHARED_DIFF_OPS = ["replace", "replace_match", "insert", "copy", "move", "undo_last_change"];
|
|
56
56
|
const RESULT_CONTRACT_GUIDELINE =
|
|
57
57
|
'When called from a codemode script, failures resolve to `{ ok: false, kind: "error", error: { code, message } }`; branch on `ok` instead of `try`/`catch`.';
|
|
58
|
+
const READ_SCRIPT_GUIDELINE =
|
|
59
|
+
'`read`: a codemode script receives `{ text, lines, notices }`; `text` is the raw file content of the served window and `lines[].anchor` is the editable anchor. An image read resolves to an image block.';
|
|
60
|
+
const GREP_SCRIPT_GUIDELINE =
|
|
61
|
+
'`grep`: a codemode script receives `{ text, results }`; each `results[].lines[]` entry carries the matching line\'s `anchor` and `text`, so a result is editable without a fresh read.';
|
|
62
|
+
function editScriptGuideline(flags: EditToolFlags): string {
|
|
63
|
+
return `${operationNames(SHARED_EDIT_OPS, flags)}: a codemode script receives \`{ ok, kind: "edit", classification, diff, anchors, anchorsOmitted }\`; reuse \`anchors[].anchor\` to chain the next edit, and read again when \`anchorsOmitted\` is true.`;
|
|
64
|
+
}
|
|
58
65
|
const SCRIPT_BATCH_GUIDELINE =
|
|
59
66
|
"Script calls apply immediately in order, and only the most recent edit per file is undoable.";
|
|
60
67
|
const SCRIPT_TRANSFER_GUIDELINE =
|
|
@@ -106,31 +113,20 @@ function strictInputGuideline(flags: EditToolFlags): string {
|
|
|
106
113
|
return `${tools}: strict-input mode is on; auto-fixable slips are rejected instead of fixed with warnings.`;
|
|
107
114
|
}
|
|
108
115
|
|
|
109
|
-
function
|
|
110
|
-
description
|
|
111
|
-
snippet: string,
|
|
112
|
-
guidelines: string[],
|
|
113
|
-
flags: EditToolFlags,
|
|
114
|
-
options?: { stringPayload?: boolean },
|
|
115
|
-
): { description: string; snippet: string; guidelines: string[] } {
|
|
116
|
+
export function withReplacePrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
117
|
+
let description = base.description;
|
|
116
118
|
const shared = [batchGuideline(flags), pathGuideline(flags)];
|
|
117
|
-
if (flags.codemode) shared.push(
|
|
119
|
+
if (flags.codemode) shared.push(editScriptGuideline(flags));
|
|
118
120
|
if (flags.autoRead) shared.push(diffGuideline(flags));
|
|
119
|
-
|
|
121
|
+
shared.push(payloadGuideline(flags));
|
|
120
122
|
if (flags.strictInput) shared.push(strictInputGuideline(flags));
|
|
121
|
-
return { description, snippet, guidelines: [...guidelines, ...shared] };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function withReplacePrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
125
|
-
let description = base.description;
|
|
126
|
-
const guidelines = [preferenceGuideline(flags), ...base.guidelines.map((guideline) => guideline.text)];
|
|
127
123
|
if (!flags.replaceMatchEnabled) {
|
|
128
124
|
description = description
|
|
129
125
|
.split(/(?<=\.)\s+/)
|
|
130
126
|
.filter((sentence) => !sentence.includes("`replace_match`"))
|
|
131
127
|
.join(" ");
|
|
132
128
|
}
|
|
133
|
-
return
|
|
129
|
+
return { description, snippet: base.snippet, guidelines: [...base.guidelines.map((guideline) => guideline.text), ...shared] };
|
|
134
130
|
}
|
|
135
131
|
|
|
136
132
|
function readGuidelineApplies(guideline: Guideline, flags: EditToolFlags): boolean {
|
|
@@ -141,7 +137,7 @@ function readGuidelineApplies(guideline: Guideline, flags: EditToolFlags): boole
|
|
|
141
137
|
|
|
142
138
|
export function withReadPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
143
139
|
const preference = preferenceGuideline(flags);
|
|
144
|
-
const script = flags.codemode ? [RESULT_CONTRACT_GUIDELINE] : [];
|
|
140
|
+
const script = flags.codemode ? [READ_SCRIPT_GUIDELINE, RESULT_CONTRACT_GUIDELINE] : [];
|
|
145
141
|
const applicable = base.guidelines.filter((guideline) => readGuidelineApplies(guideline, flags));
|
|
146
142
|
if (flags.autoReadAllActive) {
|
|
147
143
|
const rewritten = applicable.filter((guideline) => guideline.id !== READ_REREAD_ID).map((guideline) => guideline.text);
|
|
@@ -153,16 +149,14 @@ export function withReadPrompts(base: { description: string; snippet: string; gu
|
|
|
153
149
|
return { description: base.description, snippet: base.snippet, guidelines: [preference, ...guidelines, ...script] };
|
|
154
150
|
}
|
|
155
151
|
|
|
156
|
-
export function withInsertPrompts(base: { description: string; snippet: string; guidelines: Guideline[] },
|
|
157
|
-
|
|
158
|
-
return finalizePrompts(base.description, base.snippet, guidelines, flags);
|
|
152
|
+
export function withInsertPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, _flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
153
|
+
return { description: base.description, snippet: base.snippet, guidelines: base.guidelines.map((guideline) => guideline.text) };
|
|
159
154
|
}
|
|
160
155
|
|
|
161
156
|
export function withReplaceMatchPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
162
|
-
const guidelines =
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return prompted;
|
|
157
|
+
const guidelines = base.guidelines.map((guideline) => guideline.text);
|
|
158
|
+
if (flags.strictInput) guidelines.push("`replace_match`: strict-input mode rejects an `old_string` that covers the whole range or retypes unchanged text.");
|
|
159
|
+
return { description: base.description, snippet: base.snippet, guidelines };
|
|
166
160
|
}
|
|
167
161
|
|
|
168
162
|
function gatedEditOps(ops: string[], flags: EditToolFlags): string[] {
|
|
@@ -183,8 +177,8 @@ function joinOps(ops: string[], options?: { backtick?: boolean; separator?: "/"
|
|
|
183
177
|
}
|
|
184
178
|
|
|
185
179
|
export function withGrepPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
186
|
-
const guidelines =
|
|
187
|
-
if (flags.codemode) guidelines.push(
|
|
180
|
+
const guidelines = base.guidelines.map((guideline) => guideline.text);
|
|
181
|
+
if (flags.codemode) guidelines.push(GREP_SCRIPT_GUIDELINE);
|
|
188
182
|
if (flags.copyMoveEnabled) return { description: base.description, snippet: base.snippet, guidelines };
|
|
189
183
|
const description = base.description.replaceAll(GREP_OPS_PHRASE, joinOps(gatedEditOps(["replace", "insert", "copy", "move"], flags)));
|
|
190
184
|
return { description, snippet: base.snippet, guidelines };
|
|
@@ -192,10 +186,9 @@ export function withGrepPrompts(base: { description: string; snippet: string; gu
|
|
|
192
186
|
|
|
193
187
|
export function withUndoPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
194
188
|
const ops = gatedEditOps(["replace", "replace_match", "insert", "copy", "move"], flags);
|
|
195
|
-
const script = flags.codemode ? [
|
|
189
|
+
const script = flags.codemode ? [SCRIPT_UNDO_GUIDELINE] : [];
|
|
196
190
|
let description = base.description;
|
|
197
191
|
let guidelines = [
|
|
198
|
-
preferenceGuideline(flags),
|
|
199
192
|
...base.guidelines
|
|
200
193
|
.filter((guideline) => flags.copyMoveEnabled || guideline.id !== UNDO_CROSS_FILE_MOVE_ID)
|
|
201
194
|
.map((guideline) => (guideline.id === UNDO_WRITE_CLEARS_ID && !flags.autoRead ? NEUTRAL_UNDO_WRITE_CLEARS : guideline.text)),
|
|
@@ -208,9 +201,10 @@ export function withUndoPrompts(base: { description: string; snippet: string; gu
|
|
|
208
201
|
return { description, snippet: base.snippet, guidelines };
|
|
209
202
|
}
|
|
210
203
|
|
|
211
|
-
export function withTransferPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
212
|
-
const guidelines =
|
|
213
|
-
|
|
204
|
+
export function withTransferPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags, options?: { scriptGuidelines?: boolean }): { description: string; snippet: string; guidelines: string[] } {
|
|
205
|
+
const guidelines = base.guidelines.map((guideline) => guideline.text);
|
|
206
|
+
if (flags.codemode && options?.scriptGuidelines !== false) guidelines.push(SCRIPT_TRANSFER_GUIDELINE);
|
|
207
|
+
return { description: base.description, snippet: base.snippet, guidelines };
|
|
214
208
|
}
|
|
215
209
|
|
|
216
210
|
function staleAnchorMessage(ref: string, hash: string, owners: Array<OwnedAnchor | undefined>): string {
|
package/src/hash-store.ts
CHANGED
|
@@ -136,6 +136,7 @@ interface Prepared {
|
|
|
136
136
|
undoDelete: (...params: SqlParams) => void;
|
|
137
137
|
snapshotTime: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
138
138
|
undoTime: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
139
|
+
undoExpired: (...params: SqlParams) => Record<string, unknown>[];
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
export interface HashStore {
|
|
@@ -183,6 +184,7 @@ function addColumnIfMissing(db: RawDb, statement: string): void {
|
|
|
183
184
|
function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
184
185
|
db.exec("PRAGMA journal_mode = WAL");
|
|
185
186
|
db.exec("PRAGMA synchronous = NORMAL");
|
|
187
|
+
db.exec("PRAGMA secure_delete = ON");
|
|
186
188
|
db.exec(
|
|
187
189
|
"CREATE TABLE IF NOT EXISTS snapshots (" +
|
|
188
190
|
"path TEXT PRIMARY KEY, " +
|
|
@@ -242,6 +244,7 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
|
242
244
|
const undoDelStmt = db.prepare("DELETE FROM undo WHERE path = ?");
|
|
243
245
|
const snapshotTimeStmt = db.prepare("SELECT updated_at FROM snapshots WHERE path = ?");
|
|
244
246
|
const undoTimeStmt = db.prepare("SELECT updated_at FROM undo WHERE path = ?");
|
|
247
|
+
const undoExpiredStmt = db.prepare("SELECT path FROM undo WHERE updated_at < ?");
|
|
245
248
|
const stmts: Prepared = {
|
|
246
249
|
get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
|
|
247
250
|
getState: (...params) => getStateStmt.get(...params) as Record<string, unknown> | undefined,
|
|
@@ -253,6 +256,7 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
|
253
256
|
undoDelete: retriedWrite(undoDelStmt),
|
|
254
257
|
snapshotTime: (...params) => snapshotTimeStmt.get(...params) as Record<string, unknown> | undefined,
|
|
255
258
|
undoTime: (...params) => undoTimeStmt.get(...params) as Record<string, unknown> | undefined,
|
|
259
|
+
undoExpired: (...params) => withBusyRetry(() => undoExpiredStmt.all(...params) as Record<string, unknown>[]),
|
|
256
260
|
};
|
|
257
261
|
return { db, stmts };
|
|
258
262
|
}
|
|
@@ -651,6 +655,17 @@ export async function pruneMissing(store: HashStore): Promise<string[]> {
|
|
|
651
655
|
for (const path of missing) snapshotCache.delete(path);
|
|
652
656
|
return missing;
|
|
653
657
|
}
|
|
658
|
+
export function pruneExpiredUndo(store: HashStore, retentionDays: number, now = Date.now()): string[] {
|
|
659
|
+
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return [];
|
|
660
|
+
const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
|
|
661
|
+
const rows = store.stmts.undoExpired(cutoff) as { path?: unknown }[];
|
|
662
|
+
const paths = rows.map((row) => row.path).filter((path): path is string => typeof path === "string");
|
|
663
|
+
if (paths.length === 0) return [];
|
|
664
|
+
withStore(() => {
|
|
665
|
+
for (const path of paths) store.stmts.undoDelete(path);
|
|
666
|
+
});
|
|
667
|
+
return paths;
|
|
668
|
+
}
|
|
654
669
|
|
|
655
670
|
|
|
656
671
|
|
package/src/read.ts
CHANGED
|
@@ -346,8 +346,7 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
346
346
|
context: typeof ctx,
|
|
347
347
|
) => ReturnType<typeof builtinRead.execute>;
|
|
348
348
|
const imageResult = await executeBuiltinRead(_toolCallId, params, signal, _onUpdate, ctx);
|
|
349
|
-
|
|
350
|
-
return { ...imageResult, structuredContent: imageStructured };
|
|
349
|
+
return { ...imageResult, structuredContent: imageResult.structuredContent as ReadResult };
|
|
351
350
|
}
|
|
352
351
|
const { normalized, fileHashes, hadUtf8DecodeErrors, absolutePath: resolvedPath } = await readNormFile(
|
|
353
352
|
rawPath, ctx.cwd, { signal, preloadedFile: file, maxLines: MAX_HASH_LINES },
|
|
@@ -363,24 +362,27 @@ export function regRead(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
363
362
|
serveRows(resolvedPath, fileHashes, fileLines, preview.servedHashes);
|
|
364
363
|
const snapshotId = await safeSnapId(absolutePath, "read");
|
|
365
364
|
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
366
|
-
const
|
|
367
|
-
preview.text,
|
|
365
|
+
const notices = [
|
|
368
366
|
hadUtf8DecodeErrors ? "[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]" : undefined,
|
|
369
367
|
reclaimNotice,
|
|
370
|
-
].filter((part): part is string => part !== undefined)
|
|
368
|
+
].filter((part): part is string => part !== undefined);
|
|
369
|
+
const previewText = [preview.text, ...notices].join("\n\n");
|
|
370
|
+
const servedRawText = preview.anchoredLines.map((row) => fileLines[row.line - 1] ?? "").join("\n");
|
|
371
|
+
const rawTruncation = servedRawText.length > 0 ? truncateHead(servedRawText, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES }) : undefined;
|
|
371
372
|
|
|
372
373
|
const structuredContent: ReadResult = {
|
|
373
374
|
ok: true,
|
|
374
375
|
kind: "read",
|
|
375
376
|
path: rawPath,
|
|
376
|
-
text:
|
|
377
|
+
text: rawTruncation?.content ?? "",
|
|
377
378
|
lines: preview.anchoredLines,
|
|
378
379
|
totalLines: preview.totalLines,
|
|
379
380
|
startLine: preview.startLine,
|
|
380
381
|
nextOffset: preview.nextOffset ?? null,
|
|
381
|
-
truncated: preview.truncation !== undefined,
|
|
382
|
+
truncated: preview.truncation !== undefined || rawTruncation?.truncated === true,
|
|
382
383
|
blockedByLongLine: preview.blockedByLongLine,
|
|
383
384
|
hadUtf8DecodeErrors,
|
|
385
|
+
notices,
|
|
384
386
|
};
|
|
385
387
|
return {
|
|
386
388
|
content: [{ type: "text", text: previewText }],
|
package/src/structured.ts
CHANGED
|
@@ -45,16 +45,17 @@ export type ReadTextResult = {
|
|
|
45
45
|
truncated: boolean;
|
|
46
46
|
blockedByLongLine: boolean;
|
|
47
47
|
hadUtf8DecodeErrors: boolean;
|
|
48
|
+
notices: string[];
|
|
48
49
|
};
|
|
49
50
|
|
|
50
51
|
export type ReadImageResult = {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
path: string;
|
|
52
|
+
type: "image";
|
|
53
|
+
data: string;
|
|
54
54
|
mimeType: string;
|
|
55
|
+
note: string;
|
|
55
56
|
};
|
|
56
57
|
|
|
57
|
-
export type ReadResult = ReadTextResult | ReadImageResult | StructuredError;
|
|
58
|
+
export type ReadResult = ReadTextResult | ReadImageResult | string | StructuredError;
|
|
58
59
|
|
|
59
60
|
export const readResultSchema = Type.Union([
|
|
60
61
|
Type.Object({
|
|
@@ -69,13 +70,15 @@ export const readResultSchema = Type.Union([
|
|
|
69
70
|
truncated: Type.Boolean(),
|
|
70
71
|
blockedByLongLine: Type.Boolean(),
|
|
71
72
|
hadUtf8DecodeErrors: Type.Boolean(),
|
|
73
|
+
notices: Type.Array(Type.String()),
|
|
72
74
|
}),
|
|
73
75
|
Type.Object({
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
path: Type.String(),
|
|
76
|
+
type: Type.Literal("image"),
|
|
77
|
+
data: Type.String(),
|
|
77
78
|
mimeType: Type.String(),
|
|
79
|
+
note: Type.String(),
|
|
78
80
|
}),
|
|
81
|
+
Type.String(),
|
|
79
82
|
structuredErrorSchema,
|
|
80
83
|
]);
|
|
81
84
|
|
package/src/write-hook.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { HASH_CLASS } from "./hashline/alphabet";
|
|
3
3
|
import { HASH_SEP } from "./hashline/hash";
|
|
4
|
-
import { servedForPath, withAnchorSession } from "./anchor-registry";
|
|
4
|
+
import { sessionContextReadable, servedForPath, withAnchorSession, type AnchorSessionContext } from "./anchor-registry";
|
|
5
5
|
import { resolveInCwd } from "./fs-write";
|
|
6
6
|
import { abortIf, splitLines, isRec } from "./utils";
|
|
7
7
|
import type { ModelLike } from "./model-gate";
|
|
@@ -31,8 +31,12 @@ export async function servedHashEchoDenial(rawPath: string, content: string, cwd
|
|
|
31
31
|
return `[E_WRITE_HASH_ECHO] Refused write to ${rawPath}: line ${echo.line} contains the copied ${echo.hash}${HASH_SEP} anchor served for this file. Remove the copied anchors and retry.`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function withReadableSession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T | undefined> {
|
|
35
|
+
return sessionContextReadable(ctx) ? withAnchorSession(ctx, fn) : Promise.resolve(undefined);
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
export function registerWriteHook(pi: ExtensionAPI, isModelDisabled?: (model: ModelLike | undefined) => boolean): void {
|
|
35
|
-
pi.on("tool_call", async (event, ctx) =>
|
|
39
|
+
pi.on("tool_call", async (event, ctx) => withReadableSession(ctx, async () => {
|
|
36
40
|
if (event.toolName !== "write") return;
|
|
37
41
|
if (isModelDisabled?.(ctx.model)) return;
|
|
38
42
|
const input = event.input as Record<string, unknown> | undefined;
|