pi-hashline-edit-pro 6.5.1 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +86 -64
  2. package/index.ts +31 -28
  3. package/package.json +2 -2
  4. package/src/anchor-registry.ts +15 -22
  5. package/src/auto-read-all.ts +3 -3
  6. package/src/config-ui.ts +6 -1
  7. package/src/config.ts +6 -1
  8. package/src/constants.ts +1 -1
  9. package/src/copy-move.ts +11 -10
  10. package/src/edit-common.ts +23 -21
  11. package/src/edit-fidelity.ts +3 -3
  12. package/src/grep.ts +0 -9
  13. package/src/hashline/alphabet.ts +3 -3
  14. package/src/hashline/anchor-table.json +1 -1
  15. package/src/hashline/parse.ts +21 -14
  16. package/src/hashline/resolve.ts +23 -9
  17. package/src/insert.ts +7 -6
  18. package/src/model-gate.ts +1 -0
  19. package/src/outline/parsers.ts +0 -1
  20. package/src/outline/render.ts +154 -82
  21. package/src/outline/vendor/README.md +4 -0
  22. package/src/outline/vendor/parsers/blade.ts +1 -1
  23. package/src/outline/vendor/parsers/java.ts +144 -331
  24. package/src/outline/vendor/parsers/manager.ts +12 -183
  25. package/src/outline/vendor/parsers/php.ts +16 -81
  26. package/src/outline/vendor/parsers/sass.ts +0 -11
  27. package/src/outline/vendor/parsers/scss.ts +0 -8
  28. package/src/outline/vendor/types.ts +0 -1
  29. package/src/outline-tool.ts +184 -0
  30. package/src/payload-contract.ts +27 -15
  31. package/src/prompts.ts +2 -1
  32. package/src/read.ts +4 -5
  33. package/src/replace-diff.ts +1 -1
  34. package/src/replace-match.ts +36 -28
  35. package/src/replace.ts +12 -2
  36. package/src/structured.ts +32 -0
  37. package/src/write-hook.ts +4 -3
  38. package/tool-prompts/copy-guidelines.md +1 -1
  39. package/tool-prompts/grep-guidelines.md +1 -2
  40. package/tool-prompts/insert.md +1 -1
  41. package/tool-prompts/move.md +2 -2
  42. package/tool-prompts/outline-guidelines.md +2 -0
  43. package/tool-prompts/outline-snippet.md +1 -0
  44. package/tool-prompts/outline.md +3 -0
  45. package/tool-prompts/read-guidelines.md +1 -1
  46. package/tool-prompts/read.md +1 -1
  47. package/tool-prompts/replace-match-guidelines.md +1 -1
  48. package/tool-prompts/replace-match-snippet.md +1 -1
  49. package/tool-prompts/replace-match.md +1 -1
package/index.ts CHANGED
@@ -8,6 +8,7 @@ import { regCopy, regMove } from "./src/copy-move";
8
8
  import { regGrep, regVanillaGrep } from "./src/grep";
9
9
  import { regUndo, clearUndo } from "./src/replace-undo";
10
10
  import { regRead, fmtReadPreview } from "./src/read";
11
+ import { regOutline } from "./src/outline-tool";
11
12
  import { ANCHOR_TOOL_NAMES, modelDisabled, type ModelLike } from "./src/model-gate";
12
13
  import { buildAutoReadAllInjection, autoReadAllBudget, isOutlineAutoReadAll } from "./src/auto-read-all";
13
14
  import type { RMetrics } from "./src/replace-response";
@@ -15,7 +16,7 @@ import type { ReplaceDetails } from "./src/replace";
15
16
  import { extractHints, extractWarnings } from "./src/replace-render";
16
17
  import { MAX_HASH_LINES } from "./src/hashline";
17
18
  import { withStructuredText } from "./src/structured";
18
- import type { AutoReadAllMode, ReadOnDisabledModels } from "./src/config";
19
+ import type { AutoReadAllMode, Config, ReadOnDisabledModels } from "./src/config";
19
20
  import {
20
21
  readConfig,
21
22
  readConfigWithStatus,
@@ -25,6 +26,7 @@ import {
25
26
  toggleAnchorGrep,
26
27
  toggleCopyMove,
27
28
  toggleReplaceMatch,
29
+ toggleOutline,
28
30
  cycleReadOnDisabledModels,
29
31
  toggleRequirePath,
30
32
  toggleStrictInput,
@@ -38,7 +40,7 @@ import { loadHashStore, pruneExpiredUndo, pruneMissing } from "./src/hash-store"
38
40
  import { type AnchorSessionContext, initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, sessionContextReadable, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
39
41
  import { serveRows } from "./src/served";
40
42
  import { finalizeTurn, planAssistantMessage } from "./src/batch";
41
- import { currentEditFlags } from "./src/edit-common";
43
+ import { currentEditFlags, editToolEnabled } from "./src/edit-common";
42
44
  import { HashlineConfigOverlay } from "./src/config-ui";
43
45
  import { registerWriteHook } from "./src/write-hook";
44
46
  import { readNormFile } from "./src/file-reader";
@@ -53,8 +55,18 @@ interface PendingRegistrySession {
53
55
  alive: () => boolean;
54
56
  }
55
57
 
58
+ function configEnablesTool(tool: string, config: Config): boolean {
59
+ if (tool === "grep") return config.anchorGrepEnabled === true;
60
+ return editToolEnabled(tool, {
61
+ replaceMatchEnabled: config.replaceMatchEnabled !== false,
62
+ copyMoveEnabled: config.copyMoveEnabled !== false,
63
+ outlineEnabled: config.outlineEnabled === true,
64
+ });
65
+ }
66
+
56
67
  export default function (pi: ExtensionAPI): void {
57
68
  regRead(pi);
69
+ regOutline(pi);
58
70
 
59
71
  regReplace(pi);
60
72
  regReplaceMatch(pi);
@@ -82,11 +94,21 @@ export default function (pi: ExtensionAPI): void {
82
94
  let gateApplied = false;
83
95
  const pendingRegistrySessions: PendingRegistrySession[] = [];
84
96
 
97
+ function setAnchorToolEnabled(tools: readonly string[], enabled: boolean): void {
98
+ const active = pi.getActiveTools();
99
+ pi.setActiveTools(enabled ? [...new Set([...active, ...tools])] : active.filter((tool) => !tools.includes(tool)));
100
+ for (const tool of tools) {
101
+ if (enabled) baseAnchorTools.add(tool);
102
+ else baseAnchorTools.delete(tool);
103
+ }
104
+ }
105
+
85
106
  async function refreshEditTools(): Promise<void> {
86
107
  try {
87
108
  codemodeActive = pi.getActiveTools().includes("codemode");
88
109
  const flags = await currentEditFlags(codemodeActive);
89
110
  if (readFlavor !== "vanilla") regRead(pi, flags);
111
+ regOutline(pi, flags);
90
112
  regReplace(pi, flags);
91
113
  regReplaceMatch(pi, flags);
92
114
  regInsert(pi, flags);
@@ -144,10 +166,7 @@ export default function (pi: ExtensionAPI): void {
144
166
  const enabled = ANCHOR_TOOL_NAMES.filter((tool) => {
145
167
  if (tool === "read") return true;
146
168
  if (!baseAnchorTools.has(tool)) return false;
147
- if (tool === "replace_match") return config.replaceMatchEnabled !== false;
148
- if (tool === "copy" || tool === "move") return config.copyMoveEnabled !== false;
149
- if (tool === "grep") return config.anchorGrepEnabled === true;
150
- return true;
169
+ return configEnablesTool(tool, config);
151
170
  });
152
171
  const active = pi.getActiveTools().filter((tool) => tool !== "edit");
153
172
  const base = enabled.includes("grep") ? active.filter((tool) => tool !== "grep") : active;
@@ -216,12 +235,9 @@ export default function (pi: ExtensionAPI): void {
216
235
  autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
217
236
  await refreshEditTools();
218
237
  pi.setActiveTools(
219
- pi.getActiveTools().filter((t) => {
220
- if (config.copyMoveEnabled === false && (t === "copy" || t === "move")) return false;
221
- if (config.replaceMatchEnabled === false && t === "replace_match") return false;
222
- return true;
223
- }),
238
+ pi.getActiveTools().filter((tool) => tool === "grep" || configEnablesTool(tool, config)),
224
239
  );
240
+ setAnchorToolEnabled(["outline"], config.outlineEnabled === true);
225
241
  await syncModelGate(ctx.model, ctx.cwd);
226
242
  const debugValue = process.env.PI_HASHLINE_DEBUG;
227
243
  if (debugValue === "1" || debugValue === "true") {
@@ -278,7 +294,7 @@ export default function (pi: ExtensionAPI): void {
278
294
  }));
279
295
 
280
296
  pi.registerCommand("hashline-config", {
281
- description: "Open the hashline settings window (auto-read, diff context, auto-read all, git repos only, ignore folders/files, grep, copy/move, replace_match, path, strict input, disable on models, read on disabled models)",
297
+ description: "Open the hashline settings window (auto-read, diff context, auto-read all, git repos only, ignore folders/files, grep, copy/move, replace_match, outline, path, strict input, disable on models, read on disabled models)",
282
298
  handler: async (_args, ctx) => {
283
299
  if (!ctx.hasUI) {
284
300
  ctx.ui.notify("/hashline-config requires interactive mode", "error");
@@ -307,22 +323,9 @@ export default function (pi: ExtensionAPI): void {
307
323
  if (enabled) baseAnchorTools.add("grep");
308
324
  else baseAnchorTools.delete("grep");
309
325
  }
310
- else if (key === "copyMoveEnabled") {
311
- const enabled = await toggleCopyMove();
312
- const active = pi.getActiveTools();
313
- pi.setActiveTools(enabled ? [...new Set([...active, "copy", "move"])] : active.filter((t) => t !== "copy" && t !== "move"));
314
- for (const tool of ["copy", "move"]) {
315
- if (enabled) baseAnchorTools.add(tool);
316
- else baseAnchorTools.delete(tool);
317
- }
318
- }
319
- else if (key === "replaceMatchEnabled") {
320
- const enabled = await toggleReplaceMatch();
321
- const active = pi.getActiveTools();
322
- pi.setActiveTools(enabled ? [...new Set([...active, "replace_match"])] : active.filter((t) => t !== "replace_match"));
323
- if (enabled) baseAnchorTools.add("replace_match");
324
- else baseAnchorTools.delete("replace_match");
325
- }
326
+ else if (key === "copyMoveEnabled") setAnchorToolEnabled(["copy", "move"], await toggleCopyMove());
327
+ else if (key === "replaceMatchEnabled") setAnchorToolEnabled(["replace_match"], await toggleReplaceMatch());
328
+ else if (key === "outlineEnabled") setAnchorToolEnabled(["outline"], await toggleOutline());
326
329
  else if (key === "requirePath") await toggleRequirePath();
327
330
  else if (key === "strictInput") await toggleStrictInput();
328
331
  await refreshEditTools();
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.5.1",
3
+ "version": "7.1.0",
4
4
  "type": "module",
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.",
5
+ "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 5-character lowercase 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",
7
7
  "repository": {
8
8
  "type": "git",
@@ -5,7 +5,7 @@ import { createHash } from "node:crypto";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { sessionClaimsDir } from "./paths";
7
7
  import { contentChecksum } from "./hashline/hasher";
8
- import { ANCHOR_COUNT, anchorAt } from "./hashline/alphabet";
8
+ import { ANCHOR_COUNT, HASH_RE, anchorAt } from "./hashline/alphabet";
9
9
  import { HASH_PROBE_STRIDE, MAX_HASH_LINES } from "./hashline/hash";
10
10
  import { assertLineCount, errCode, isRec, splitLines, spliceAll } from "./utils";
11
11
  import * as Diff from "diff";
@@ -249,8 +249,12 @@ export function foldRegistryEvents(events: RegistryEvent[], seed?: string): Sess
249
249
  return state;
250
250
  }
251
251
 
252
- function isStringArray(value: unknown): value is string[] {
253
- return Array.isArray(value) && value.every((entry) => typeof entry === "string");
252
+ function isAnchor(value: unknown): value is string {
253
+ return typeof value === "string" && HASH_RE.test(value);
254
+ }
255
+
256
+ function isAnchorList(value: unknown): value is string[] {
257
+ return Array.isArray(value) && value.every(isAnchor);
254
258
  }
255
259
 
256
260
  function isRegistryEvent(value: unknown): value is RegistryEvent {
@@ -260,10 +264,10 @@ function isRegistryEvent(value: unknown): value is RegistryEvent {
260
264
  if (value.kind === "allocate") {
261
265
  return typeof value.path === "string"
262
266
  && Array.isArray(value.rows)
263
- && value.rows.every((row) => Array.isArray(row) && row.length === 2 && typeof row[0] === "string" && typeof row[1] === "string");
267
+ && value.rows.every((row) => Array.isArray(row) && row.length === 2 && isAnchor(row[0]) && typeof row[1] === "string");
264
268
  }
265
- if (value.kind === "free") return typeof value.path === "string" && (value.anchors === undefined || isStringArray(value.anchors));
266
- if (value.kind === "minted") return isStringArray(value.anchors);
269
+ if (value.kind === "free") return typeof value.path === "string" && (value.anchors === undefined || isAnchorList(value.anchors));
270
+ if (value.kind === "minted") return isAnchorList(value.anchors);
267
271
  return false;
268
272
  }
269
273
 
@@ -386,7 +390,8 @@ async function loadRegistryState(key: string, sessionFile: string, token: object
386
390
  registries.set(key, folded);
387
391
  sidecarByKey.set(key, sidecar);
388
392
  if (rawLog.length > 0) {
389
- await compactSidecarIfNeeded(sidecar, rawLog, sessionFile, folded, firstLineSessionFile(rawLog) !== sessionFile);
393
+ const loggedEvents = rawLog.split("\n").filter((line) => line.trim().length > 0).length;
394
+ await compactSidecarIfNeeded(sidecar, rawLog, sessionFile, folded, loggedEvents > events.length || firstLineSessionFile(rawLog) !== sessionFile);
390
395
  }
391
396
  try {
392
397
  await mkdir(sessionClaimsDir(), { recursive: true, mode: 0o700 });
@@ -709,22 +714,9 @@ export function ownerOf(anchor: string): OwnedAnchor | undefined {
709
714
  return current()?.owned.get(anchor);
710
715
  }
711
716
 
712
- export interface CaseFoldedOwner {
713
- anchor: string;
714
- path: string;
715
- }
716
-
717
- export function ownersDifferingOnlyByCase(anchor: string, paths?: ReadonlySet<string>): CaseFoldedOwner[] {
717
+ export function anchorWasMinted(anchor: string): boolean {
718
718
  const state = current();
719
- if (!state) return [];
720
- const lower = anchor.toLowerCase();
721
- const matches: CaseFoldedOwner[] = [];
722
- for (const [owned, entry] of state.owned) {
723
- if (owned === anchor || owned.toLowerCase() !== lower) continue;
724
- if (paths && paths.size > 0 && !paths.has(entry.path)) continue;
725
- matches.push({ anchor: owned, path: entry.path });
726
- }
727
- return matches;
719
+ return state !== undefined && state.everMinted.has(anchor);
728
720
  }
729
721
 
730
722
  export function ownersForPath(path: string): Map<string, string> {
@@ -1058,6 +1050,7 @@ export function adoptAnchors(path: string, entries: Map<string, string>): void {
1058
1050
  }
1059
1051
  const adopted: Array<[string, string]> = [];
1060
1052
  for (const [anchor, checksum] of entries) {
1053
+ if (!HASH_RE.test(anchor)) continue;
1061
1054
  const existing = state.owned.get(anchor);
1062
1055
  if (existing && existing.path !== path) continue;
1063
1056
  state.owned.set(anchor, { path, checksum });
@@ -28,10 +28,10 @@ const SCAN_LIMIT_MULTIPLIER = 4;
28
28
  const MAX_REPORTED_OMISSIONS = 50;
29
29
 
30
30
  const HEADER =
31
- "[hashline auto-read-all] Every non-ignored project file is attached below as `=== path ===` then `anchor│content` rows with live anchors.\nEdit directly from the attachment with replace and insert; do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
31
+ "[hashline auto-read-all] Every non-ignored project file is attached below as `=== path ===` then `anchor│content` rows with live anchors.\nEdit directly from the attachment with replace and insert; do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are lowercase and stay valid until their line is edited.";
32
32
 
33
33
  const OUTLINE_HEADER =
34
- "[hashline auto-read-all] Every non-ignored project file is outlined below as `=== path (language) — lines ===` then `anchor│...` rows with live anchors: multi-line symbols show a label with `[limit N]`, the span to request when reading from the anchor, and single-line symbols show the line content.\nEdit a row's anchor with replace or insert, or read from it with `offset` set to that anchor (and the row's `limit` when it shows one); do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
34
+ "[hashline auto-read-all] Every non-ignored project file is outlined below as `=== path (language) — lines ===` then `anchor│content` rows with live anchors: each symbol row is the first line of that symbol's declaration (annotations and attributes excluded), a `[limit N]` line under a multi-line symbol is the span to request when reading from that anchor, a `[N nested]` line marks children hidden by the six-level depth cap, and a file the parser cannot outline falls back to a head-and-tail preview.\nEdit a row's anchor with replace or insert, or read from it with `offset` set to that anchor (and the row's `limit` when the next line shows one); the outline shows only symbol declaration lines, so read a span before editing inside a body. A row whose line is too long is clipped with a trailing `...` and is not served for editing; read that line first. Do not call read for attached files except to fetch a symbol span or a clipped row.\nFiles listed as not attached can be read normally.\nAnchors are lowercase and stay valid until their line is edited.";
35
35
 
36
36
  const IMAGE_EXTENSIONS = new Set([
37
37
  ".avif",
@@ -554,7 +554,7 @@ async function renderFile(file: string, cwd: string, outline: boolean): Promise<
554
554
  if (outline) {
555
555
  const section = await buildFileOutline({ displayPath: file, content: normalized, hashes: fileHashes });
556
556
  serveRows(absolutePath, fileHashes, fileLines, section.servedHashes);
557
- return { file, text: section.text, totalLines: fileHashes.length, absolutePath, complete: !section.truncated };
557
+ return { file, text: section.text, totalLines: fileHashes.length, absolutePath, complete: section.complete };
558
558
  }
559
559
  const preview = await fmtReadPreview(normalized, {}, fileHashes, absolutePath, AUTO_READ_ALL_MAX_BUDGET_BYTES, MAX_HASH_LINES);
560
560
  serveRows(absolutePath, fileHashes, fileLines, preview.servedHashes);
package/src/config-ui.ts CHANGED
@@ -2,7 +2,7 @@ import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
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" | "undoRetentionDays";
5
+ export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllRequireGit" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "outlineEnabled" | "requirePath" | "strictInput" | "diffContextLines" | "disableOnModels" | "readOnDisabledModels" | "undoRetentionDays";
6
6
 
7
7
  export interface ConfigRow {
8
8
  key: ConfigToggleKey;
@@ -36,6 +36,7 @@ export function configRows(config: Config): ConfigRow[] {
36
36
  { key: "anchorGrepEnabled", group: "Tools", depth: 0, label: "Anchor grep", hint: "Use the anchored grep in place of pi's own grep", enabled: config.anchorGrepEnabled === true },
37
37
  { key: "copyMoveEnabled", group: "Tools", depth: 0, label: "Copy/move", hint: "Enable the copy and move tools", enabled: config.copyMoveEnabled !== false },
38
38
  { key: "replaceMatchEnabled", group: "Tools", depth: 0, label: "Replace match", hint: "Enable the replace_match tool", enabled: config.replaceMatchEnabled !== false },
39
+ { key: "outlineEnabled", group: "Tools", depth: 0, label: "Outline tool", hint: "Offer the outline tool for one file's structure on demand", enabled: config.outlineEnabled === true },
39
40
  { key: "requirePath", group: "Edit behavior", depth: 0, label: "Require path", hint: "Edit tools must also send a matching path", enabled: config.requirePath === true },
40
41
  { key: "strictInput", group: "Edit behavior", depth: 0, label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
41
42
  { 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 ?? [] },
@@ -101,6 +102,10 @@ export class HashlineConfigOverlay {
101
102
  this.rows = configRows(await readConfig());
102
103
  }
103
104
 
105
+ rowKeys(): ConfigToggleKey[] {
106
+ return this.rows.map((row) => row.key);
107
+ }
108
+
104
109
  private runToggle(row: ConfigRow, delta?: number, value?: string): void {
105
110
  this.opts.tui.requestRender(true);
106
111
  void this.opts.onToggle(row.key, delta, value).then(async () => {
package/src/config.ts CHANGED
@@ -21,6 +21,7 @@ export interface Config {
21
21
  anchorGrepEnabled: boolean;
22
22
  copyMoveEnabled?: boolean;
23
23
  replaceMatchEnabled?: boolean;
24
+ outlineEnabled?: boolean;
24
25
  autoReadAll?: AutoReadAllMode;
25
26
  autoReadAllRequireGit?: boolean;
26
27
  autoReadAllIgnore?: string[];
@@ -37,6 +38,7 @@ const DEFAULT_CONFIG: Config = {
37
38
  anchorGrepEnabled: true,
38
39
  copyMoveEnabled: true,
39
40
  replaceMatchEnabled: true,
41
+ outlineEnabled: false,
40
42
  autoReadAll: "off",
41
43
  autoReadAllRequireGit: true,
42
44
  autoReadAllIgnore: [],
@@ -123,6 +125,7 @@ function parseConfig(content: string): Config {
123
125
  const anchorGrepEnabled = parsed.anchorGrepEnabled;
124
126
  const copyMoveEnabled = parsed.copyMoveEnabled;
125
127
  const replaceMatchEnabled = parsed.replaceMatchEnabled;
128
+ const outlineEnabled = parsed.outlineEnabled;
126
129
  const autoReadAll = parsed.autoReadAll;
127
130
  const autoReadAllRequireGit = parsed.autoReadAllRequireGit;
128
131
  const requirePath = parsed.requirePath;
@@ -138,6 +141,7 @@ function parseConfig(content: string): Config {
138
141
  anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
139
142
  copyMoveEnabled: typeof copyMoveEnabled === "boolean" ? copyMoveEnabled : DEFAULT_CONFIG.copyMoveEnabled,
140
143
  replaceMatchEnabled: typeof replaceMatchEnabled === "boolean" ? replaceMatchEnabled : DEFAULT_CONFIG.replaceMatchEnabled,
144
+ outlineEnabled: typeof outlineEnabled === "boolean" ? outlineEnabled : DEFAULT_CONFIG.outlineEnabled,
141
145
  autoReadAll: parseAutoReadAllMode(autoReadAll),
142
146
  autoReadAllRequireGit: parseAutoReadAllRequireGit(autoReadAllRequireGit, autoReadAll),
143
147
  requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
@@ -254,7 +258,7 @@ export async function writeConfig(config: Config): Promise<void> {
254
258
  }
255
259
 
256
260
 
257
- type ToggleKey = "autoRead" | "autoReadAllRequireGit" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput";
261
+ type ToggleKey = "autoRead" | "autoReadAllRequireGit" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "outlineEnabled" | "requirePath" | "strictInput";
258
262
 
259
263
  async function toggleFlag(key: ToggleKey): Promise<boolean> {
260
264
  const config = await updateConfig((c) => { c[key] = !(c[key] === true); });
@@ -265,6 +269,7 @@ export const toggleAutoReadAllRequireGit = (): Promise<boolean> => toggleFlag("a
265
269
  export const toggleAnchorGrep = (): Promise<boolean> => toggleFlag("anchorGrepEnabled");
266
270
  export const toggleCopyMove = (): Promise<boolean> => toggleFlag("copyMoveEnabled");
267
271
  export const toggleReplaceMatch = (): Promise<boolean> => toggleFlag("replaceMatchEnabled");
272
+ export const toggleOutline = (): Promise<boolean> => toggleFlag("outlineEnabled");
268
273
  function steppedIndex(length: number, index: number, delta: number): number {
269
274
  return ((index + delta) % length + length) % length;
270
275
  }
package/src/constants.ts CHANGED
@@ -12,7 +12,7 @@ export const TEXT_ENDING_NOTE =
12
12
  "A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line.";
13
13
 
14
14
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
15
- export const HASH_STORE_VERSION = 9;
15
+ export const HASH_STORE_VERSION = 10;
16
16
  export const NEW_CONTENT_NOT_ARRAY_MSG =
17
17
  `[E_BAD_SHAPE] "text" must be an array of strings, one per line (use [] to delete).`;
18
18
 
package/src/copy-move.ts CHANGED
@@ -94,9 +94,9 @@ export function parseTransferAnchors(req: TransferReq): { refs: TransferRefs; wa
94
94
  const destination = stripAnchorRow(req.anchor.trim(), "anchor entry", warnings);
95
95
  return {
96
96
  refs: {
97
- sourceFrom: parseHashRef(from),
98
- sourceTo: parseHashRef(to),
99
- anchor: parseHashRef(destination),
97
+ sourceFrom: parseHashRef(from, warnings, "source_from"),
98
+ sourceTo: parseHashRef(to, warnings, "source_to"),
99
+ anchor: parseHashRef(destination, warnings, "anchor"),
100
100
  direction: req.direction,
101
101
  },
102
102
  warnings,
@@ -890,20 +890,21 @@ function getTransferInput(args: unknown): { path?: string; source_from?: string;
890
890
 
891
891
  const transferSourceFromSchema = Type.String({
892
892
  description:
893
- "4-char anchor of the FIRST source line to copy.",
893
+ "5-char anchor of the FIRST source line.",
894
894
  });
895
895
  const transferSourceToSchema = Type.String({
896
896
  description:
897
- "4-char anchor of the LAST source line to copy.",
897
+ "5-char anchor of the LAST source line.",
898
898
  });
899
899
  const transferAnchorSchema = Type.String({
900
900
  description:
901
- "4-char anchor of the destination line; the block lands before or after it and it may live in another file.",
901
+ "5-char anchor of the destination line; the block lands before or after it and it may live in another file.",
902
+ });
903
+ const transferDirectionSchema = Type.Unsafe<"after" | "before">({
904
+ type: "string",
905
+ enum: ["after", "before"],
906
+ description: '"after" places the block below the anchor line, "before" above it',
902
907
  });
903
- const transferDirectionSchema = Type.Union(
904
- [Type.Literal("after"), Type.Literal("before")],
905
- { description: '"after" places the block below the anchor line, "before" above it' },
906
- );
907
908
  const transferPathRequiredSchema = Type.String({
908
909
  description:
909
910
  "Path to the source or destination file the anchors were served for; required and must match anchor ownership.",
@@ -1,7 +1,7 @@
1
1
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
2
2
  import { resolveInCwd } from "./fs-write";
3
3
  import { abortIf, makePrepareArguments } from "./utils";
4
- import { ownerOf, ownersDifferingOnlyByCase, type OwnedAnchor } from "./anchor-registry";
4
+ import { anchorWasMinted, ownerOf } from "./anchor-registry";
5
5
  import { assertRangeServed, lineChecksum, parseHashRef, stripAnchorRow } from "./hashline";
6
6
  import { readConfig } from "./config";
7
7
  import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
@@ -17,6 +17,7 @@ export interface EditToolFlags {
17
17
  autoReadAllOutline: boolean;
18
18
  replaceMatchEnabled: boolean;
19
19
  copyMoveEnabled: boolean;
20
+ outlineEnabled: boolean;
20
21
  codemode: boolean;
21
22
  }
22
23
 
@@ -28,9 +29,19 @@ export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
28
29
  autoReadAllOutline: false,
29
30
  replaceMatchEnabled: true,
30
31
  copyMoveEnabled: true,
32
+ outlineEnabled: false,
31
33
  codemode: false,
32
34
  };
33
35
 
36
+ export type EditToolGate = Pick<EditToolFlags, "replaceMatchEnabled" | "copyMoveEnabled" | "outlineEnabled">;
37
+
38
+ export function editToolEnabled(tool: string, flags: EditToolGate): boolean {
39
+ if (tool === "outline") return flags.outlineEnabled;
40
+ if (tool === "replace_match") return flags.replaceMatchEnabled;
41
+ if (tool === "copy" || tool === "move") return flags.copyMoveEnabled;
42
+ return true;
43
+ }
44
+
34
45
  export async function currentEditFlags(codemode = false): Promise<EditToolFlags> {
35
46
  const config = await readConfig();
36
47
  return {
@@ -41,19 +52,20 @@ export async function currentEditFlags(codemode = false): Promise<EditToolFlags>
41
52
  autoReadAllOutline: (config.autoReadAll ?? "off") === "outline",
42
53
  replaceMatchEnabled: config.replaceMatchEnabled !== false,
43
54
  copyMoveEnabled: config.copyMoveEnabled !== false,
55
+ outlineEnabled: config.outlineEnabled === true,
44
56
  codemode
45
57
  };
46
58
  }
47
59
 
48
60
  function preferenceGuideline(flags: EditToolFlags): string {
49
- const tools = gatedEditOps(["read", "replace", "replace_match", "insert", "copy", "move", "undo_last_change"], flags);
61
+ const tools = gatedEditOps(["read", "outline", "replace", "replace_match", "insert", "copy", "move", "undo_last_change"], flags);
50
62
  return `Remember to use the hashline tools for every file read and edit: ${joinOps(tools, { backtick: true })}.`;
51
63
  }
52
64
 
53
65
  const SHARED_EDIT_OPS = ["replace", "replace_match", "insert", "copy", "move"];
54
66
  const SHARED_PAYLOAD_OPS = ["replace", "replace_match", "insert"];
55
67
  const SHARED_DIFF_OPS = ["replace", "replace_match", "insert", "copy", "move", "undo_last_change"];
56
- const SHARED_SCRIPT_OPS = ["read", "replace", "replace_match", "insert", "copy", "move", "grep", "undo_last_change"];
68
+ const SHARED_SCRIPT_OPS = ["read", "outline", "replace", "replace_match", "insert", "copy", "move", "grep", "undo_last_change"];
57
69
 
58
70
  function resultContractGuideline(flags: EditToolFlags): string {
59
71
  const tools = operationNames(SHARED_SCRIPT_OPS, flags);
@@ -161,11 +173,7 @@ export function withReplaceMatchPrompts(base: { description: string; snippet: st
161
173
  }
162
174
 
163
175
  function gatedEditOps(ops: string[], flags: EditToolFlags): string[] {
164
- return ops.filter((op) => {
165
- if (op === "replace_match") return flags.replaceMatchEnabled;
166
- if (op === "copy" || op === "move") return flags.copyMoveEnabled;
167
- return true;
168
- });
176
+ return ops.filter((op) => editToolEnabled(op, flags));
169
177
  }
170
178
 
171
179
  function joinOps(ops: string[], options?: { backtick?: boolean; separator?: "/" }): string {
@@ -206,17 +214,11 @@ export function withTransferPrompts(base: { description: string; snippet: string
206
214
  return { description: base.description, snippet: base.snippet, guidelines };
207
215
  }
208
216
 
209
- function staleAnchorMessage(ref: string, hash: string, owners: Array<OwnedAnchor | undefined>): string {
210
- const knownPaths = new Set<string>();
211
- for (const owner of owners) {
212
- if (owner) knownPaths.add(owner.path);
213
- }
214
- const folded = ownersDifferingOnlyByCase(hash, knownPaths);
215
- const hint =
216
- folded.length > 0
217
- ? ` Anchors are case-sensitive; ${folded.map((match) => `"${match.anchor}"`).join(", ")} differs only in case.`
218
- : "";
219
- return `[E_STALE_ANCHOR] "${ref}" is not owned in this session.${hint} Call read() on the target file first.`;
217
+ export function staleAnchorMessage(hash: string, next = "Call read() on the target file for fresh anchors."): string {
218
+ const state = anchorWasMinted(hash)
219
+ ? "is stale: it was valid earlier in this session, but its line was edited or its anchors were freed since."
220
+ : "was never served in this session, so it addresses no line here.";
221
+ return `[E_STALE_ANCHOR] "${hash}" ${state} ${next}`;
220
222
  }
221
223
 
222
224
  export function resolveEditTarget(removeFrom: string, removeTo?: string): string {
@@ -225,7 +227,7 @@ export function resolveEditTarget(removeFrom: string, removeTo?: string): string
225
227
  const owners = hashes.map((hash) => ownerOf(hash));
226
228
  const missing = owners.findIndex((owner) => !owner);
227
229
  if (missing >= 0) {
228
- throw new Error(staleAnchorMessage(refs[missing]!, hashes[missing]!, owners));
230
+ throw new Error(staleAnchorMessage(hashes[missing]!));
229
231
  }
230
232
  const paths = new Set(owners.map((owner) => owner!.path));
231
233
  if (paths.size > 1) {
@@ -294,7 +296,7 @@ export async function resolveEditTargetWithRequirement(input: PathRequirementInp
294
296
  return anchorTarget;
295
297
  }
296
298
 
297
- const STRICT_INPUT_REJECT_CODES = ["[W_BAD_SHAPE]", "[W_BAD_REF]", "[W_INVALID_PATCH]", "[W_BARE_HASH_PREFIX]"];
299
+ const STRICT_INPUT_REJECT_CODES = ["[W_BAD_SHAPE]", "[W_BAD_REF]", "[W_INVALID_PATCH]", "[W_BARE_HASH_PREFIX]", "[W_ANCHOR_CASE]"];
298
300
  export async function throwIfStrictInput(warnings: string[]): Promise<void> {
299
301
  const fixes = warnings.filter((warning) => STRICT_INPUT_REJECT_CODES.some((code) => warning.startsWith(code)));
300
302
  if (fixes.length === 0) return;
@@ -1,5 +1,5 @@
1
1
  import { isBlankLine, splitLines } from "./utils";
2
- import { HASH_SEP, canon } from "./hashline";
2
+ import { HASH_CLASS, HASH_SEP, canon } from "./hashline";
3
3
  import type { DiffSpan } from "./replace-diff";
4
4
 
5
5
  const INVISIBLE_RE = /\p{Default_Ignorable_Code_Point}/u;
@@ -397,8 +397,8 @@ export function annotateLiteralEscapeHints(
397
397
  }
398
398
 
399
399
  const LITERAL_ESCAPE_DETAIL_RE = /^\[H_LITERAL_ESCAPE\] [^:]*: "(.*?)" written as literal text(?: \((.*)\))?\.?$/;
400
- const LITERAL_ESCAPE_ANCHOR_RE = /([A-Za-z]{4})│/g;
401
- const UNICODE_LOST_DETAIL_RE = /^\[H_UNICODE_LOST\] ([A-Za-z]{4})│ missing "([^"]*)"/;
400
+ const LITERAL_ESCAPE_ANCHOR_RE = new RegExp(`(${HASH_CLASS})│`, "g");
401
+ const UNICODE_LOST_DETAIL_RE = new RegExp(`^\\[H_UNICODE_LOST\\] (${HASH_CLASS})│ missing "([^"]*)"`);
402
402
 
403
403
  export function dedupeLiteralEscapeHints(hints: readonly string[]): string[] {
404
404
  const suppressed = new Set<string>();
package/src/grep.ts CHANGED
@@ -70,14 +70,6 @@ function regexSource(pattern: string, literal: boolean): string {
70
70
  return literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
71
71
  }
72
72
 
73
- function assertPattern(pattern: string, literal: boolean, ignoreCase: boolean): void {
74
- try {
75
- new RegExp(regexSource(pattern, literal), ignoreCase ? "ui" : "u");
76
- } catch {
77
- throw new Error(`[E_BAD_SHAPE] Invalid pattern: ${pattern}`);
78
- }
79
- }
80
-
81
73
  interface RgMatchLine {
82
74
  line: number;
83
75
  start: number;
@@ -524,7 +516,6 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
524
516
  const globPath = toDisplayPath(globRoot, absPath);
525
517
  return globRegex.test(globPath) || globRegex.test(displayPath);
526
518
  };
527
- assertPattern(req.pattern, req.literal === true, req.ignoreCase === true);
528
519
  const rgPath = await resolveRgPath();
529
520
  const hits: FileHit[] = [];
530
521
  let matches = 0;
@@ -1,16 +1,16 @@
1
1
  import anchorData from "./anchor-table.json";
2
2
  const rawAnchors: unknown = (anchorData as { anchors?: unknown }).anchors;
3
- if (typeof rawAnchors !== "string" || rawAnchors.length === 0 || rawAnchors.length % 4 !== 0) {
3
+ if (typeof rawAnchors !== "string" || rawAnchors.length === 0 || rawAnchors.length % 5 !== 0) {
4
4
  throw new Error("[E_REGISTRY] Anchor table is missing or corrupt; reinstall pi-hashline-edit-pro.");
5
5
  }
6
6
  const TABLE: string = rawAnchors;
7
- export const HASH_LEN = 4;
7
+ export const HASH_LEN = 5;
8
8
  export const ANCHOR_COUNT = TABLE.length / HASH_LEN;
9
9
  const LETTERS = "A-Za-z";
10
10
 
11
11
  export const ALPH_RE = new RegExp(`^[${LETTERS}]+$`);
12
12
 
13
- export const HASH_CLASS = `[${LETTERS}]{${HASH_LEN}}`;
13
+ export const HASH_CLASS = `[a-z]{${HASH_LEN}}`;
14
14
 
15
15
  export const HASH_RUN = `[${LETTERS}]{${HASH_LEN},${HASH_LEN + 1}}`;
16
16