pi-hashline-edit-pro 4.4.3 → 4.5.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 +312 -63
- package/index.ts +22 -6
- package/package.json +4 -3
- package/prompts/copy-guidelines.md +1 -0
- package/prompts/copy-snippet.md +1 -0
- package/prompts/copy.md +3 -0
- package/prompts/grep.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/move-guidelines.md +1 -0
- package/prompts/move-snippet.md +1 -0
- package/prompts/move.md +3 -0
- package/prompts/read-guidelines.md +2 -1
- package/prompts/replace-guidelines.md +0 -2
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +2 -1
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/anchor-registry.ts +128 -39
- package/src/auto-read-all.ts +3 -1
- package/src/batch.ts +35 -7
- package/src/commit.ts +22 -4
- package/src/config-ui.ts +2 -1
- package/src/config.ts +6 -1
- package/src/constants.ts +4 -1
- package/src/copy-move.ts +793 -0
- package/src/edit-common.ts +21 -1
- package/src/file-reader.ts +4 -0
- package/src/grep.ts +3 -1
- package/src/hash-store/validation.ts +22 -0
- package/src/hash-store.ts +33 -5
- package/src/hashline/apply.ts +8 -3
- package/src/hashline/index.ts +3 -0
- package/src/hashline/parse.ts +22 -10
- package/src/hashline/resolve.ts +23 -8
- package/src/insert.ts +28 -10
- package/src/line-endings.ts +151 -0
- package/src/paths.ts +23 -3
- package/src/payload-contract.ts +29 -1
- package/src/read.ts +7 -5
- package/src/replace-render.ts +4 -4
- package/src/replace-undo.ts +13 -4
- package/src/replace.ts +17 -5
- package/src/utils.ts +40 -5
package/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-
|
|
|
3
3
|
import { initHasher } from "./src/hashline";
|
|
4
4
|
import { regReplace } from "./src/replace";
|
|
5
5
|
import { regInsert } from "./src/insert";
|
|
6
|
+
import { regCopy, regMove } from "./src/copy-move";
|
|
6
7
|
import { regGrep } from "./src/grep";
|
|
7
8
|
import { regUndo, clearUndo } from "./src/replace-undo";
|
|
8
9
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
@@ -18,13 +19,14 @@ import {
|
|
|
18
19
|
toggleAutoRead,
|
|
19
20
|
cycleAutoReadAllMode,
|
|
20
21
|
toggleAnchorGrep,
|
|
22
|
+
toggleCopyMove,
|
|
21
23
|
toggleRequirePath,
|
|
22
24
|
toggleStrictInput,
|
|
23
25
|
adjustDiffContextLines,
|
|
24
26
|
setAutoReadAllIgnoreFromText,
|
|
25
27
|
} from "./src/config";
|
|
26
28
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
27
|
-
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession } from "./src/anchor-registry";
|
|
29
|
+
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
|
|
28
30
|
import { serveRows } from "./src/served";
|
|
29
31
|
import { finalizeTurn, planAssistantMessage } from "./src/batch";
|
|
30
32
|
import { currentEditFlags } from "./src/edit-common";
|
|
@@ -42,6 +44,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
42
44
|
|
|
43
45
|
regReplace(pi);
|
|
44
46
|
regInsert(pi);
|
|
47
|
+
regCopy(pi);
|
|
48
|
+
regMove(pi);
|
|
45
49
|
regGrep(pi);
|
|
46
50
|
regUndo(pi);
|
|
47
51
|
registerWriteHook(pi);
|
|
@@ -58,6 +62,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
58
62
|
regRead(pi, flags);
|
|
59
63
|
regReplace(pi, flags);
|
|
60
64
|
regInsert(pi, flags);
|
|
65
|
+
regCopy(pi, flags);
|
|
66
|
+
regMove(pi, flags);
|
|
61
67
|
regUndo(pi, flags);
|
|
62
68
|
} catch (error) {
|
|
63
69
|
console.error("Failed to refresh edit tools:", error);
|
|
@@ -90,9 +96,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
90
96
|
autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
|
|
91
97
|
await refreshEditTools();
|
|
92
98
|
pi.setActiveTools(
|
|
93
|
-
pi.getActiveTools().filter((t) =>
|
|
94
|
-
config.anchorGrepEnabled ? t
|
|
95
|
-
|
|
99
|
+
pi.getActiveTools().filter((t) => {
|
|
100
|
+
if (config.anchorGrepEnabled ? t === "grep" : t === "anchor_grep") return false;
|
|
101
|
+
if (config.copyMoveEnabled === false && (t === "copy" || t === "move")) return false;
|
|
102
|
+
return true;
|
|
103
|
+
}),
|
|
96
104
|
);
|
|
97
105
|
const debugValue = process.env.PI_HASHLINE_DEBUG;
|
|
98
106
|
if (debugValue === "1" || debugValue === "true") {
|
|
@@ -125,7 +133,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
125
133
|
}));
|
|
126
134
|
|
|
127
135
|
pi.registerCommand("hashline-config", {
|
|
128
|
-
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, path, strict input)",
|
|
136
|
+
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, copy/move, path, strict input)",
|
|
129
137
|
handler: async (_args, ctx) => {
|
|
130
138
|
if (!ctx.hasUI) {
|
|
131
139
|
ctx.ui.notify("/hashline-config requires interactive mode", "error");
|
|
@@ -146,6 +154,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
146
154
|
const active = pi.getActiveTools();
|
|
147
155
|
pi.setActiveTools(enabled ? [...new Set([...active.filter((t) => t !== "grep"), "anchor_grep"])] : [...new Set([...active.filter((t) => t !== "anchor_grep"), ...(grepWasActive ? ["grep"] : [])])]);
|
|
148
156
|
}
|
|
157
|
+
else if (key === "copyMoveEnabled") {
|
|
158
|
+
const enabled = await toggleCopyMove();
|
|
159
|
+
const active = pi.getActiveTools();
|
|
160
|
+
pi.setActiveTools(enabled ? [...new Set([...active, "copy", "move"])] : active.filter((t) => t !== "copy" && t !== "move"));
|
|
161
|
+
}
|
|
149
162
|
else if (key === "requirePath") await toggleRequirePath();
|
|
150
163
|
else if (key === "strictInput") await toggleStrictInput();
|
|
151
164
|
await refreshEditTools();
|
|
@@ -217,10 +230,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
217
230
|
);
|
|
218
231
|
const fileLines = splitLines(normalized);
|
|
219
232
|
serveRows(absolutePath, fileHashes, fileLines, preview.servedHashes);
|
|
233
|
+
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
220
234
|
return {
|
|
221
235
|
content: [
|
|
222
236
|
...(event.content ?? []),
|
|
223
|
-
{ type: "text", text: `\n\n--- Auto-read (hashline anchors) ---\n${preview.text}` },
|
|
237
|
+
{ type: "text", text: `\n\n--- Auto-read (hashline anchors) ---\n${preview.text}${reclaimNotice !== undefined ? `\n\n${reclaimNotice}` : ""}` },
|
|
224
238
|
],
|
|
225
239
|
};
|
|
226
240
|
} catch (error) {
|
|
@@ -238,6 +252,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
238
252
|
if (
|
|
239
253
|
event.toolName !== "replace" &&
|
|
240
254
|
event.toolName !== "insert" &&
|
|
255
|
+
event.toolName !== "copy" &&
|
|
256
|
+
event.toolName !== "move" &&
|
|
241
257
|
event.toolName !== "undo_last_change"
|
|
242
258
|
) return;
|
|
243
259
|
if (!autoRead) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.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",
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"diff": "^9.0.0",
|
|
43
43
|
"file-type": "^22.0.2",
|
|
44
|
-
"typebox": "^1.3.7",
|
|
45
44
|
"xxhash-wasm": "^1.1.0"
|
|
46
45
|
},
|
|
47
46
|
"peerDependencies": {
|
|
48
47
|
"@earendil-works/pi-coding-agent": ">=0.84.0",
|
|
49
|
-
"@earendil-works/pi-tui": "*"
|
|
48
|
+
"@earendil-works/pi-tui": "*",
|
|
49
|
+
"typebox": "*"
|
|
50
50
|
},
|
|
51
51
|
"engines": {
|
|
52
52
|
"node": ">=22.19.0"
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"@types/node": "^24",
|
|
66
66
|
"@vitest/coverage-v8": "^4.1.11",
|
|
67
67
|
"eslint": "^10.9.1",
|
|
68
|
+
"typebox": "^1.3.7",
|
|
68
69
|
"typescript": "^5.9.3",
|
|
69
70
|
"typescript-eslint": "^8.68.0",
|
|
70
71
|
"vitest": "^4.1.11"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- `copy`: the same anchor in `source_from` and `source_to` copies one line; copied lines get fresh anchors in the post-edit diff and the source rows keep theirs.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Copy lines to another position by anchor: `source_from`/`source_to` mark the range, the copy lands after `insert_after`; the anchors resolve both files
|
package/prompts/copy.md
ADDED
|
@@ -0,0 +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. `source_from` and `source_to` may belong to a different file than `insert_after`: the lines are copied from the source file and inserted into the destination file, and an empty destination file is seeded with the copied lines. The source lines stay in place, and the copied lines are written exactly as the source file holds them.
|
|
2
|
+
|
|
3
|
+
Example: read served `Hasu│old` in `a.ts` and `Qwer│top` in `b.ts`. Call { "source_from": "Hasu", "source_to": "Hasu", "insert_after": "Qwer" } copies `old` from `a.ts` into `b.ts` below `top`; the source `Hasu│old` row stays live.
|
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 or
|
|
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.
|
package/prompts/insert.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes
|
|
1
|
+
Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escape sequences such as `\n` or `\u200b` are not decoded. Inserted lines are written exactly as given; nothing else in the file changes.
|
|
2
2
|
|
|
3
3
|
Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- `move`: a cross-file move records one undo entry per file — undo both sides or the moved lines stay duplicated or missing. Lines between source and target may be re-anchored.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Move lines to another position by anchor: `source_from`/`source_to` mark the range, it lands after `insert_after`; the anchors resolve both files
|
package/prompts/move.md
ADDED
|
@@ -0,0 +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. `source_from` and `source_to` may belong to a different file than `insert_after`: the range is removed from the source file and inserted into the destination file in one call, and an empty destination file is seeded with the moved lines. `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
|
+
|
|
3
|
+
Example: read served `Hasu│a` in `lib.ts` and `Qwer│top` in `app.ts`. Call { "source_from": "Hasu", "source_to": "Hasu", "insert_after": "Qwer" } moves `a` from `lib.ts` into `app.ts` below `top`.
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
- `read`: call again after an edit when you need anchors you lack — post-edit diff `+anchor│`/` anchor│` rows and any served `anchor│content` rows already carry fresh anchors for the changed range.
|
|
2
|
-
- `read`: `E_AUTO_READ_ALL` on an attached file means its content is still exactly as it was when attached at the start of this session.
|
|
2
|
+
- `read`: `E_AUTO_READ_ALL` on an attached file means its content is still exactly as it was when attached at the start of this session.
|
|
3
|
+
- `read`: `[W_ANCHOR_RECLAIMED]` means the session's anchor quota was tight and the listed files' anchors were freed; read a freed file again before editing it.
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
- `replace`: `-anchor│` rows in a post-edit diff are dead anchors; only `+anchor│` and ` anchor│` rows are live. Check the post-edit diff before the next turn's edits on that file.
|
|
2
2
|
- `replace`: same-file same-message calls batch: disjoint ranges, one undo. A call whose anchors resolve nowhere fails alone; its file's batch still commits.
|
|
3
|
-
- `replace`: `[E_STALE_ANCHOR]` means the anchor is not owned in this session: call `read` on the file for fresh anchors. `[E_RANGE_STALE]` means a line in the range changed since it was shown, or was never shown: retry with the fresh anchors the error returns.
|
|
4
|
-
- `replace`: `[E_BATCH_OVERLAP]` means two batch calls share lines: retry with disjoint ranges. `[E_OP_ABORTED]` means another batch call failed or the file changed: fix that call, then retry, or call `read` for fresh anchors.
|
|
5
3
|
- `replace`: use `replace`/`insert`, not `sed -i` or heredocs — anchor edits are verified against what was shown and undoable.
|
|
6
4
|
- `replace`: `replacement_lines`: one string per line, `[""]` is one blank line, `[]` deletes. Pasted `anchor│` prefixes are stripped. Single line: same anchor for `remove_from` and `remove_to`.
|
package/prompts/replace.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes and
|
|
1
|
+
Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escape sequences such as `\n` or `\u200b` are not decoded. `[]` deletes the range.
|
|
2
2
|
|
|
3
3
|
Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
|
4
4
|
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
- `undo_last_change`: only the last `replace`/`insert` per file is undoable; a `write` clears it, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
|
|
1
|
+
- `undo_last_change`: only the last `replace`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
|
|
2
|
+
- `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
|
-
Single-level undo: reverts a file's last `replace` or `
|
|
1
|
+
Single-level undo: reverts a file's last `replace`, `insert`, `copy`, or `move`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Undo the last replace or
|
|
1
|
+
Undo the last replace, 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 with `[E_UNDO_STALE]` and nothing is modified: the record is kept, so verify the current state with read and stop instead of forcing the undo. A file deleted since the edit is restored. If the output says truncated, use read to see the full file.
|
package/src/anchor-registry.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { errCode, splitLines } from "./utils";
|
|
|
11
11
|
import * as Diff from "diff";
|
|
12
12
|
import { lineChecksum } from "./hashline";
|
|
13
13
|
import { getAllocatedState, persistSnapshot, type HashStore } from "./hash-store";
|
|
14
|
-
import { ANCHOR_POOL_EXHAUSTED_PREFIX } from "./constants";
|
|
14
|
+
import { ANCHOR_POOL_EXHAUSTED_PREFIX, ANCHOR_RECLAIM_WARNING_CODE, MAX_RECLAIMED_PATHS_REPORTED } from "./constants";
|
|
15
15
|
|
|
16
16
|
export type RegistryEvent =
|
|
17
17
|
| { kind: "session"; sessionFile: string }
|
|
@@ -153,6 +153,10 @@ interface SessionState {
|
|
|
153
153
|
everMinted: AnchorMintedSet;
|
|
154
154
|
probe: number;
|
|
155
155
|
allocatedChecksum: Map<string, string>;
|
|
156
|
+
lastUsedAt: Map<string, number>;
|
|
157
|
+
usageClock: number;
|
|
158
|
+
reclaimed: string[];
|
|
159
|
+
shadow?: boolean;
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
const SIDECAR_SUFFIX = ".registry.jsonl";
|
|
@@ -176,7 +180,7 @@ function newSessionState(seed?: string): SessionState {
|
|
|
176
180
|
probe = (probe * 256 + byte) % ANCHOR_COUNT;
|
|
177
181
|
}
|
|
178
182
|
}
|
|
179
|
-
return { owned: new Map(), served: new Map(), everMinted: new Set(), probe, allocatedChecksum: new Map() };
|
|
183
|
+
return { owned: new Map(), served: new Map(), everMinted: new Set(), probe, allocatedChecksum: new Map(), lastUsedAt: new Map(), usageClock: 0, reclaimed: [] };
|
|
180
184
|
}
|
|
181
185
|
|
|
182
186
|
function seedServedFromOwned(state: SessionState): void {
|
|
@@ -195,9 +199,12 @@ export function foldRegistryEvents(events: RegistryEvent[], seed?: string): Sess
|
|
|
195
199
|
for (const event of events) {
|
|
196
200
|
if (event.kind === "clear") {
|
|
197
201
|
state.owned.clear();
|
|
202
|
+
state.lastUsedAt.clear();
|
|
198
203
|
} else if (event.kind === "minted") {
|
|
199
204
|
for (const anchor of event.anchors) state.everMinted.add(anchor);
|
|
200
205
|
} else if (event.kind === "allocate") {
|
|
206
|
+
state.usageClock += 1;
|
|
207
|
+
state.lastUsedAt.set(event.path, state.usageClock);
|
|
201
208
|
for (const [anchor, checksum] of event.rows) {
|
|
202
209
|
state.owned.set(anchor, { path: event.path, checksum });
|
|
203
210
|
state.everMinted.add(anchor);
|
|
@@ -213,9 +220,15 @@ export function foldRegistryEvents(events: RegistryEvent[], seed?: string): Sess
|
|
|
213
220
|
state.owned.delete(anchor);
|
|
214
221
|
}
|
|
215
222
|
}
|
|
223
|
+
state.lastUsedAt.delete(event.path);
|
|
216
224
|
}
|
|
217
225
|
}
|
|
218
226
|
}
|
|
227
|
+
const ownedPaths = new Set<string>();
|
|
228
|
+
for (const entry of state.owned.values()) ownedPaths.add(entry.path);
|
|
229
|
+
for (const path of [...state.lastUsedAt.keys()]) {
|
|
230
|
+
if (!ownedPaths.has(path)) state.lastUsedAt.delete(path);
|
|
231
|
+
}
|
|
219
232
|
return state;
|
|
220
233
|
}
|
|
221
234
|
|
|
@@ -468,32 +481,96 @@ function fingerprintIndex(state: SessionState, path: string): Map<string, string
|
|
|
468
481
|
|
|
469
482
|
export const MINT_PROBE_LIMIT = 8192;
|
|
470
483
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
484
|
+
function touchPath(state: SessionState, path: string): void {
|
|
485
|
+
state.usageClock += 1;
|
|
486
|
+
state.lastUsedAt.set(path, state.usageClock);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function hasOwnedPath(state: SessionState, path: string): boolean {
|
|
490
|
+
for (const entry of state.owned.values()) {
|
|
491
|
+
if (entry.path === path) return true;
|
|
492
|
+
}
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function evictPath(state: SessionState, path: string): string[] {
|
|
497
|
+
const anchors: string[] = [];
|
|
498
|
+
for (const [anchor, entry] of [...state.owned]) {
|
|
499
|
+
if (entry.path === path) {
|
|
500
|
+
anchors.push(anchor);
|
|
501
|
+
state.owned.delete(anchor);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
state.served.delete(path);
|
|
505
|
+
state.lastUsedAt.delete(path);
|
|
506
|
+
if (anchors.length > 0 && state === current()) {
|
|
507
|
+
appendEvent({ kind: "free", path, anchors });
|
|
508
|
+
}
|
|
509
|
+
return anchors;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function reclaimAnchorSpace(state: SessionState, protectPath?: string): string | undefined {
|
|
513
|
+
if (state.shadow) return undefined;
|
|
514
|
+
let victim: string | undefined;
|
|
515
|
+
let victimTime = Number.POSITIVE_INFINITY;
|
|
516
|
+
for (const [, entry] of state.owned) {
|
|
517
|
+
if (entry.path === protectPath) continue;
|
|
518
|
+
const time = state.lastUsedAt.get(entry.path) ?? -1;
|
|
519
|
+
if (time < victimTime) {
|
|
520
|
+
victimTime = time;
|
|
521
|
+
victim = entry.path;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (victim === undefined) return undefined;
|
|
525
|
+
evictPath(state, victim);
|
|
526
|
+
state.reclaimed.push(victim);
|
|
527
|
+
return victim;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function mintAnchor(state: SessionState, protectPath?: string): string {
|
|
531
|
+
for (;;) {
|
|
532
|
+
for (let probe = 0; probe < MINT_PROBE_LIMIT; probe++) {
|
|
533
|
+
state.probe = (state.probe + HASH_PROBE_STRIDE) % ANCHOR_COUNT;
|
|
534
|
+
const candidate = anchorAt(state.probe);
|
|
535
|
+
if (!state.owned.has(candidate) && !state.everMinted.has(candidate)) {
|
|
536
|
+
return candidate;
|
|
537
|
+
}
|
|
477
538
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
539
|
+
for (let step = 1; step <= ANCHOR_COUNT; step++) {
|
|
540
|
+
const candidate = anchorAt((state.probe + step) % ANCHOR_COUNT);
|
|
541
|
+
if (!state.owned.has(candidate)) {
|
|
542
|
+
for (const served of state.served.values()) served.delete(candidate);
|
|
543
|
+
return candidate;
|
|
544
|
+
}
|
|
484
545
|
}
|
|
546
|
+
if (reclaimAnchorSpace(state, protectPath) === undefined) break;
|
|
485
547
|
}
|
|
486
548
|
throw new Error(
|
|
487
549
|
`${ANCHOR_POOL_EXHAUSTED_PREFIX}; use write for very large files.`,
|
|
488
550
|
);
|
|
489
551
|
}
|
|
490
552
|
|
|
553
|
+
export function takeReclaimedPaths(): string[] {
|
|
554
|
+
const state = current();
|
|
555
|
+
if (!state || state.reclaimed.length === 0) return [];
|
|
556
|
+
return state.reclaimed.splice(0, state.reclaimed.length);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export function formatAnchorReclaimNotice(paths: string[]): string | undefined {
|
|
560
|
+
const unique = [...new Set(paths)];
|
|
561
|
+
if (unique.length === 0) return undefined;
|
|
562
|
+
const shown = unique.slice(0, MAX_RECLAIMED_PATHS_REPORTED);
|
|
563
|
+
const more = unique.length - shown.length;
|
|
564
|
+
const list = more > 0 ? `${shown.join(", ")} (+${more} more)` : shown.join(", ");
|
|
565
|
+
return `${ANCHOR_RECLAIM_WARNING_CODE} The session's anchor quota is nearly exhausted; freed all anchors of ${list}, the files least recently read or edited. Read each freed file again before editing it; for read-heavy work, ask the user to run /clear-anchors or start a new session.`;
|
|
566
|
+
}
|
|
567
|
+
|
|
491
568
|
export function allocateAnchor(path: string, checksum: string): string {
|
|
492
569
|
const state = current();
|
|
493
570
|
if (!state) {
|
|
494
571
|
throw new Error("[E_REGISTRY] The anchor registry is not initialized; call initRegistry on session_start first.");
|
|
495
572
|
}
|
|
496
|
-
const anchor = mintAnchor(state);
|
|
573
|
+
const anchor = mintAnchor(state, path);
|
|
497
574
|
state.everMinted.add(anchor);
|
|
498
575
|
state.owned.set(anchor, { path, checksum });
|
|
499
576
|
appendEvent({ kind: "allocate", path, rows: [[anchor, checksum]] });
|
|
@@ -505,26 +582,21 @@ export function allocateAnchor(path: string, checksum: string): string {
|
|
|
505
582
|
export function freeAnchors(path: string, anchors?: string[]): void {
|
|
506
583
|
const state = current();
|
|
507
584
|
if (!state) return;
|
|
585
|
+
if (!anchors) {
|
|
586
|
+
evictPath(state, path);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
508
589
|
const freed: string[] = [];
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
state.served.get(path)?.delete(anchor);
|
|
515
|
-
}
|
|
590
|
+
for (const anchor of anchors) {
|
|
591
|
+
if (state.owned.has(anchor)) {
|
|
592
|
+
freed.push(anchor);
|
|
593
|
+
state.owned.delete(anchor);
|
|
594
|
+
state.served.get(path)?.delete(anchor);
|
|
516
595
|
}
|
|
517
|
-
} else {
|
|
518
|
-
for (const [anchor, entry] of [...state.owned]) {
|
|
519
|
-
if (entry.path === path) {
|
|
520
|
-
freed.push(anchor);
|
|
521
|
-
state.owned.delete(anchor);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
state.served.delete(path);
|
|
525
596
|
}
|
|
526
597
|
if (freed.length > 0) {
|
|
527
|
-
|
|
598
|
+
if (!hasOwnedPath(state, path)) state.lastUsedAt.delete(path);
|
|
599
|
+
appendEvent({ kind: "free", path, anchors });
|
|
528
600
|
}
|
|
529
601
|
}
|
|
530
602
|
|
|
@@ -534,6 +606,8 @@ export function clearRegistry(): void {
|
|
|
534
606
|
state.owned.clear();
|
|
535
607
|
state.served.clear();
|
|
536
608
|
state.allocatedChecksum.clear();
|
|
609
|
+
state.lastUsedAt.clear();
|
|
610
|
+
state.reclaimed.length = 0;
|
|
537
611
|
appendEvent({ kind: "clear" });
|
|
538
612
|
}
|
|
539
613
|
|
|
@@ -609,6 +683,10 @@ export function shadowStateFrom(state: SessionState): SessionState {
|
|
|
609
683
|
everMinted: new ShadowMintedSet(state.everMinted),
|
|
610
684
|
probe: state.probe,
|
|
611
685
|
allocatedChecksum: state.allocatedChecksum,
|
|
686
|
+
lastUsedAt: new Map(state.lastUsedAt),
|
|
687
|
+
usageClock: state.usageClock,
|
|
688
|
+
reclaimed: [],
|
|
689
|
+
shadow: true,
|
|
612
690
|
};
|
|
613
691
|
}
|
|
614
692
|
|
|
@@ -721,7 +799,7 @@ export function alignOwnershipWithSpans(
|
|
|
721
799
|
}
|
|
722
800
|
for (let k = 0; k < span.replacementCount; k++) {
|
|
723
801
|
if (replacement[k] !== undefined) continue;
|
|
724
|
-
const anchor = mintAnchor(state);
|
|
802
|
+
const anchor = mintAnchor(state, path);
|
|
725
803
|
const checksum = newChecksums[start + k]!;
|
|
726
804
|
minted.push({ index: start + k, anchor, checksum });
|
|
727
805
|
state.owned.set(anchor, { path, checksum });
|
|
@@ -769,7 +847,7 @@ export function alignOwnership(
|
|
|
769
847
|
if (part.added) {
|
|
770
848
|
for (let k = 0; k < count; k++) {
|
|
771
849
|
const checksum = newChecksums[newIdx + k]!;
|
|
772
|
-
const anchor = mintAnchor(state);
|
|
850
|
+
const anchor = mintAnchor(state, path);
|
|
773
851
|
state.everMinted.add(anchor);
|
|
774
852
|
state.owned.set(anchor, { path, checksum });
|
|
775
853
|
minted.push({ index: newIdx + k, anchor, checksum });
|
|
@@ -792,7 +870,7 @@ export function alignOwnership(
|
|
|
792
870
|
const entry = state.owned.get(anchor);
|
|
793
871
|
const checksum = newChecksums[newIdx + k]!;
|
|
794
872
|
if (entry && entry.path !== path) {
|
|
795
|
-
const fresh = mintAnchor(state);
|
|
873
|
+
const fresh = mintAnchor(state, path);
|
|
796
874
|
state.owned.set(fresh, { path, checksum });
|
|
797
875
|
anchors[newIdx + k] = fresh;
|
|
798
876
|
} else {
|
|
@@ -834,6 +912,7 @@ export async function allocateFileAnchors(
|
|
|
834
912
|
ensureRegistry();
|
|
835
913
|
const registry = current();
|
|
836
914
|
const shadow = options?.shadow === true;
|
|
915
|
+
if (!shadow && registry) touchPath(registry, path);
|
|
837
916
|
const lines = splitLines(content);
|
|
838
917
|
const checksums = lines.map(lineChecksum);
|
|
839
918
|
if (options?.previous?.spans) {
|
|
@@ -870,7 +949,7 @@ export async function allocateFileAnchors(
|
|
|
870
949
|
const candidates = reuseIndex.get(checksum) ?? [];
|
|
871
950
|
const taken = reuseTaken.get(checksum) ?? 0;
|
|
872
951
|
reuseTaken.set(checksum, taken + 1);
|
|
873
|
-
const anchor = taken < candidates.length ? candidates[taken]! : mintAnchor(state);
|
|
952
|
+
const anchor = taken < candidates.length ? candidates[taken]! : mintAnchor(state, path);
|
|
874
953
|
state.everMinted.add(anchor);
|
|
875
954
|
state.owned.set(anchor, { path, checksum });
|
|
876
955
|
return anchor;
|
|
@@ -890,6 +969,7 @@ export async function allocateFileAnchors(
|
|
|
890
969
|
export function adoptAnchors(path: string, entries: Map<string, string>): void {
|
|
891
970
|
ensureRegistry();
|
|
892
971
|
const state = current()!;
|
|
972
|
+
touchPath(state, path);
|
|
893
973
|
let served = state.served.get(path);
|
|
894
974
|
if (!served) {
|
|
895
975
|
served = new Map();
|
|
@@ -967,12 +1047,17 @@ function isClaimedSidecar(sidecar: string): boolean {
|
|
|
967
1047
|
return false;
|
|
968
1048
|
}
|
|
969
1049
|
|
|
1050
|
+
function isExpectedAccessError(error: unknown): boolean {
|
|
1051
|
+
const code = errCode(error);
|
|
1052
|
+
return code === "EPERM" || code === "EACCES";
|
|
1053
|
+
}
|
|
1054
|
+
|
|
970
1055
|
export async function gcRegistrySidecars(): Promise<void> {
|
|
971
1056
|
let names: string[];
|
|
972
1057
|
try {
|
|
973
1058
|
names = await readdir(sessionClaimsDir());
|
|
974
1059
|
} catch (error) {
|
|
975
|
-
if (errCode(error) !== "ENOENT") console.error("Failed to list registry sidecars:", error);
|
|
1060
|
+
if (errCode(error) !== "ENOENT" && !isExpectedAccessError(error)) console.error("Failed to list registry sidecars:", error);
|
|
976
1061
|
return;
|
|
977
1062
|
}
|
|
978
1063
|
for (const name of names) {
|
|
@@ -982,7 +1067,7 @@ export async function gcRegistrySidecars(): Promise<void> {
|
|
|
982
1067
|
const tmpStat = await stat(tmpPath);
|
|
983
1068
|
if (Date.now() - tmpStat.mtimeMs > 60 * 60 * 1000) await rm(tmpPath, { force: true });
|
|
984
1069
|
} catch (error) {
|
|
985
|
-
if (errCode(error) !== "ENOENT") console.error("Failed to inspect registry sidecar:", error);
|
|
1070
|
+
if (errCode(error) !== "ENOENT" && !isExpectedAccessError(error)) console.error("Failed to inspect registry sidecar:", error);
|
|
986
1071
|
}
|
|
987
1072
|
continue;
|
|
988
1073
|
}
|
|
@@ -995,8 +1080,12 @@ export async function gcRegistrySidecars(): Promise<void> {
|
|
|
995
1080
|
await stat(sessionFile);
|
|
996
1081
|
} catch (error) {
|
|
997
1082
|
if (errCode(error) === "ENOENT") {
|
|
998
|
-
|
|
999
|
-
|
|
1083
|
+
try {
|
|
1084
|
+
await rm(sidecar, { force: true });
|
|
1085
|
+
} catch (removeError) {
|
|
1086
|
+
if (!isExpectedAccessError(removeError)) throw removeError;
|
|
1087
|
+
}
|
|
1088
|
+
} else if (!isExpectedAccessError(error)) {
|
|
1000
1089
|
console.error("Failed to inspect registry sidecar:", error);
|
|
1001
1090
|
}
|
|
1002
1091
|
}
|
package/src/auto-read-all.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "./constants";
|
|
12
12
|
import { normalizeAutoReadAllIgnoreEntry, type AutoReadAllMode } from "./config";
|
|
13
13
|
import { serveRows } from "./served";
|
|
14
|
+
import { formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
14
15
|
import { readNormFile, safeSnapId } from "./file-reader";
|
|
15
16
|
import { resolveRgPath } from "./grep";
|
|
16
17
|
import { globToRegex } from "./glob";
|
|
@@ -438,7 +439,8 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
|
|
|
438
439
|
const coverage = `[coverage: ${completeFiles} complete]`;
|
|
439
440
|
const completeNames = sections.map((section) => section.file);
|
|
440
441
|
const machineList = `[files complete: ${JSON.stringify(completeNames)} omitted: ${JSON.stringify(omitted)}]`;
|
|
441
|
-
const
|
|
442
|
+
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
443
|
+
const text = `${HEADER}\n\n${coverage}\n${machineList}\n\n${sectionTexts.join("\n\n")}\n\n${buildFooter(sections.length, discovery, omitted)}${reclaimNotice !== undefined ? `\n${reclaimNotice}` : ""}`;
|
|
442
444
|
return { text, files: sections.length, bytes, omitted, completeFiles };
|
|
443
445
|
}
|
|
444
446
|
|