pi-hashline-edit-pro 6.4.6 → 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 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 strict input. Persists across sessions. |
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 `{ kind: "image", path, mimeType }`.
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
 
@@ -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,10 +29,12 @@ 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";
37
+ import { loadHashStore, pruneExpiredUndo, pruneMissing } from "./src/hash-store";
36
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";
@@ -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
- const flags = await currentEditFlags(pi.getActiveTools().includes("codemode"));
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);
@@ -184,6 +188,11 @@ export default function (pi: ExtensionAPI): void {
184
188
  loadHashStore()
185
189
  .then(async store => {
186
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
+ }
187
196
  })
188
197
  .catch(err => {
189
198
  console.error("Failed to load hash store:", err);
@@ -252,6 +261,7 @@ export default function (pi: ExtensionAPI): void {
252
261
  }));
253
262
 
254
263
  pi.on("before_agent_start", async (_event, ctx) => withRegistrySession(ctx, async () => {
264
+ if (pi.getActiveTools().includes("codemode") !== codemodeActive) await refreshEditTools();
255
265
  await syncModelGate(ctx.model, ctx.cwd);
256
266
  if (autoReadAll === "off" || autoReadAllInjected) return;
257
267
  if (modelDisabled(ctx.model, disableOnModels)) return;
@@ -288,6 +298,7 @@ export default function (pi: ExtensionAPI): void {
288
298
  else if (key === "disableOnModels") disableOnModels = await setDisableOnModelsFromText(value ?? "");
289
299
  else if (key === "readOnDisabledModels") readOnDisabledModels = await cycleReadOnDisabledModels(delta);
290
300
  else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
301
+ else if (key === "undoRetentionDays") await adjustUndoRetentionDays((delta ?? 1) * 5);
291
302
  else if (key === "anchorGrepEnabled") {
292
303
  const enabled = await toggleAnchorGrep();
293
304
  grepEnabled = enabled;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.4.6",
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/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
@@ -943,6 +943,7 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
943
943
  ],
944
944
  },
945
945
  flags,
946
+ { scriptGuidelines: kind === "copy" },
946
947
  );
947
948
  return {
948
949
  name: kind,
@@ -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 finalizePrompts(
110
- description: string,
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(RESULT_CONTRACT_GUIDELINE);
119
+ if (flags.codemode) shared.push(editScriptGuideline(flags));
118
120
  if (flags.autoRead) shared.push(diffGuideline(flags));
119
- if (options?.stringPayload !== false) shared.push(payloadGuideline(flags));
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 finalizePrompts(description, base.snippet, guidelines, flags);
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[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
157
- const guidelines = [preferenceGuideline(flags), ...base.guidelines.map((guideline) => guideline.text)];
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 = [preferenceGuideline(flags), ...base.guidelines.map((guideline) => guideline.text)];
163
- const prompted = finalizePrompts(base.description, base.snippet, guidelines, flags);
164
- if (flags.strictInput) prompted.guidelines.push("`replace_match`: strict-input mode rejects an `old_string` that covers the whole range or retypes unchanged text.");
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 = [preferenceGuideline(flags), ...base.guidelines.map((guideline) => guideline.text)];
187
- if (flags.codemode) guidelines.push(RESULT_CONTRACT_GUIDELINE);
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 ? [RESULT_CONTRACT_GUIDELINE, SCRIPT_UNDO_GUIDELINE] : [];
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 = [preferenceGuideline(flags), ...base.guidelines.map((guideline) => guideline.text), ...(flags.codemode ? [SCRIPT_TRANSFER_GUIDELINE] : [])];
213
- return finalizePrompts(base.description, base.snippet, guidelines, flags, { stringPayload: false });
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
- const imageStructured: ReadResult = { ok: true, kind: "image", path: rawPath, mimeType: file.mimeType };
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 previewText = [
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).join("\n\n");
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: previewText,
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
- ok: true;
52
- kind: "image";
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
- ok: Type.Literal(true),
75
- kind: Type.Literal("image"),
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