pi-hashline-edit-pro 4.3.0 → 4.3.1

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
@@ -160,11 +160,11 @@ Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff
160
160
 
161
161
  ### Auto-read all
162
162
 
163
- Auto-read all is off by default; enable it in `/hashline-config`. On the first turn of a session, the extension discovers every file in the working directory that is not git-ignored (`git ls-files`, falling back to `ripgrep`, then to a directory walk), reads each one, and attaches the resulting `anchor│content` rows to the conversation as one extension message before the model answers. Those anchors are served exactly like `read` output, so the model can `replace` and `insert` immediately without calling `read` first. The message is injected once per session; resumed, forked, and cloned sessions that already contain it skip the injection.
163
+ Auto-read all is off by default and has three modes, selected in `/hashline-config`: `off` injects nothing, `on` discovers every file in the working directory that is not git-ignored (`git ls-files`, falling back to `ripgrep`, then to a directory walk), and `git` uses `git ls-files` only, injecting nothing when the working directory is not a git repository. On the first turn of a session, the extension discovers the files, reads each one, and attaches the resulting `anchor│content` rows to the conversation as one extension message before the model answers. Those anchors are served exactly like `read` output, so the model can `replace` and `insert` immediately without calling `read` first. The message is injected once per session; resumed, forked, and cloned sessions that already contain it skip the injection.
164
164
 
165
165
  Files are filtered before injection: symlinks, directories, image extensions, binary files (a NUL byte in the first 8KB), files over 200KB, and any file whose name is in the built-in skip list (currently `package-lock.json`, matched by file name anywhere in the tree) are skipped. The attachment stops at 500 files or at a byte budget derived from the model's context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A file whose `read` output is truncated keeps its truncation hint, so the rest can be paged in with `read`.
166
166
 
167
- The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll`.
167
+ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"on"`, or `"git"`; older configs with `true` or `false` are read as `"on"` or `"off"`).
168
168
 
169
169
  ## Tool result details
170
170
 
@@ -181,7 +181,7 @@ All five tools return machine-readable metadata in `details` alongside the model
181
181
 
182
182
  | Command | Description |
183
183
  | --- | --- |
184
- | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all files, diff context lines, `anchor_grep` tool, required `path`, strict input, and boundary dedup. Persists across sessions. |
184
+ | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, diff context lines, `anchor_grep` tool, required `path`, strict input, and boundary dedup. Persists across sessions. |
185
185
  | `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
186
186
 
187
187
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
@@ -189,7 +189,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
189
189
  ```json
190
190
  {
191
191
  "autoRead": true,
192
- "autoReadAll": false,
192
+ "autoReadAll": "off",
193
193
  "anchorGrepEnabled": true,
194
194
  "requirePath": false,
195
195
  "strictInput": false,
package/index.ts CHANGED
@@ -11,10 +11,11 @@ import type { RMetrics } from "./src/replace-response";
11
11
  import type { ReplaceDetails } from "./src/replace";
12
12
  import { extractWarnings } from "./src/replace-render";
13
13
  import { MAX_HASH_LINES } from "./src/hashline";
14
+ import type { AutoReadAllMode } from "./src/config";
14
15
  import {
15
16
  readConfigWithStatus,
16
17
  toggleAutoRead,
17
- toggleAutoReadAll,
18
+ cycleAutoReadAllMode,
18
19
  toggleAnchorGrep,
19
20
  toggleRequirePath,
20
21
  toggleStrictInput,
@@ -45,7 +46,7 @@ export default function (pi: ExtensionAPI): void {
45
46
  registerWriteHook(pi);
46
47
 
47
48
  let autoRead = true;
48
- let autoReadAll = false;
49
+ let autoReadAll: AutoReadAllMode = "off";
49
50
  let autoReadAllInjected = false;
50
51
  let grepWasActive = false;
51
52
 
@@ -80,7 +81,7 @@ export default function (pi: ExtensionAPI): void {
80
81
  const { config, corrupted } = await readConfigWithStatus();
81
82
  if (corrupted && (ctx as { hasUI?: boolean }).hasUI) ctx.ui.notify("Hashline config was corrupt and was reset to defaults", "warning");
82
83
  autoRead = config.autoRead;
83
- autoReadAll = config.autoReadAll === true;
84
+ autoReadAll = config.autoReadAll ?? "off";
84
85
  const sessionBranch = (ctx as { sessionManager?: { getBranch?: () => Array<{ type?: string; customType?: string }> } }).sessionManager?.getBranch?.() ?? [];
85
86
  autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
86
87
  await refreshEditTools();
@@ -105,10 +106,10 @@ export default function (pi: ExtensionAPI): void {
105
106
  });
106
107
 
107
108
  pi.on("before_agent_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
108
- if (!autoReadAll || autoReadAllInjected) return;
109
+ if (autoReadAll === "off" || autoReadAllInjected) return;
109
110
  autoReadAllInjected = true;
110
111
  try {
111
- const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model));
112
+ const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll);
112
113
  if (!injection) return;
113
114
  if (ctx.hasUI) ctx.ui.notify(`Auto-read all: attached ${injection.files} file(s) with anchors`, "info");
114
115
  return { message: { customType: AUTO_READ_ALL_CUSTOM_TYPE, content: injection.text, display: false } };
@@ -132,7 +133,7 @@ export default function (pi: ExtensionAPI): void {
132
133
  done,
133
134
  onToggle: async (key, delta) => {
134
135
  if (key === "autoRead") autoRead = await toggleAutoRead();
135
- else if (key === "autoReadAll") { autoReadAll = await toggleAutoReadAll(); autoReadAllInjected = false; }
136
+ else if (key === "autoReadAll") { autoReadAll = await cycleAutoReadAllMode(); autoReadAllInjected = false; }
136
137
  else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
137
138
  else if (key === "anchorGrepEnabled") {
138
139
  const enabled = await toggleAnchorGrep();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.3.0",
3
+ "version": "4.3.1",
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/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 insert 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 the output says truncated, refine `pattern` or raise `limit`.
1
+ Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace or insert 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 the output says truncated, refine `pattern` or raise `limit` (default 100).
@@ -1,3 +1,2 @@
1
1
  - `insert`: the anchor must have been shown by `read`, a post-edit diff (`+anchor│`/` anchor│`), or any served `anchor│content` row. Empty file: `read` shows one `anchor│` row — insert `after` it.
2
- - `insert`: same-file calls in one message join the file's batch: earlier calls reply `In batch N`, the last call shows the combined diff.
3
2
  - `insert`: a batch may pair one `before` and one `after` on the same anchor line; the pair composes into a single insertion. Any other same-line pair is an overlap.
package/prompts/insert.md CHANGED
@@ -1 +1 @@
1
- Insert lines after or before one existing line in a text file, addressed by a bare anchor from read output or a diff 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, no embedded newlines. Lines are added literally, even when they duplicate neighbors. Multiple `replace`/`insert` calls on the same file in one message form one batch per file with a single combined diff and a single undo.
1
+ Insert lines after or before one existing line in a text file, addressed by a bare anchor from read output or a diff 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, no embedded newlines. Lines are added literally, even when they duplicate neighbors. Multiple `replace`/`insert` calls on the same file in one message form one batch per file: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
@@ -1,5 +1,5 @@
1
1
  - `replace`: edit with `replace`/`insert`, not `sed -i` or heredocs — anchor edits are verified against what was shown and undoable.
2
2
  - `replace`: `replacement_lines` takes bare lines without `│`; `[""]` is one blank line; pasted `anchor│` prefixes are stripped automatically (single line: same anchor for `remove_from` and `remove_to`).
3
- - `replace`: post-edit diff `+anchor│`/` anchor│` rows are fresh anchors for the next edit — no new `read` needed. Same-file calls in one message form one batch with a single combined diff; check each batch diff before the next turn's edits on that file.
3
+ - `replace`: post-edit diff `+anchor│`/` anchor│` rows are fresh anchors for the next edit — no new `read` needed; never anchor on `-anchor│` rows, those anchors were freed by the edit. Check each batch diff before the next turn's edits on that file.
4
4
  - `replace`: batched calls must target disjoint ranges and all be valid; an overlap or any failure aborts the whole batch with nothing applied.
5
5
  - `replace`: if `replacement_lines` re-include the boundary line adjacent to the range, it is deduplicated automatically, shown as `dedup│content` rows in the diff (not editable, never use `dedup` as an anchor).
@@ -1 +1 @@
1
- - `undo_last_change`: only the last `replace`/`insert` per file is undoable; a `write` clears it, so undo right after a bad diff (check the `-anchor│` lines you wanted to keep).
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 +1 @@
1
- Undo last `replace`/`insert` on a file; restores deleted file, keeps record on `[E_UNDO_STALE]`
1
+ Single-level undo: reverts a file's last `replace` or `insert`
@@ -9,6 +9,7 @@ import {
9
9
  AUTO_READ_ALL_MIN_BUDGET_BYTES,
10
10
  SNIFF_BYTES,
11
11
  } from "./constants";
12
+ import type { AutoReadAllMode } from "./config";
12
13
  import { serveRows } from "./served";
13
14
  import { readNormFile } from "./file-reader";
14
15
  import { resolveRgPath } from "./grep";
@@ -180,10 +181,13 @@ async function forEachLimit<T>(items: T[], limit: number, work: (item: T) => Pro
180
181
  await Promise.all(workers);
181
182
  }
182
183
 
183
- export async function discoverAutoReadAllFiles(cwd: string): Promise<AutoReadAllDiscovery> {
184
+ export async function discoverAutoReadAllFiles(cwd: string, mode: AutoReadAllMode = "on"): Promise<AutoReadAllDiscovery> {
184
185
  let source: AutoReadAllSource = "git";
185
186
  let candidates = await listFromGit(cwd);
186
187
  if (candidates === undefined) {
188
+ if (mode === "git") {
189
+ return { files: [], source: "git", discovered: 0, skippedBinary: 0, skippedLarge: 0, skippedOther: 0, skippedByName: 0 };
190
+ }
187
191
  candidates = await listFromRg(cwd);
188
192
  source = "rg";
189
193
  }
@@ -273,8 +277,8 @@ function buildFooter(attached: number, discovery: AutoReadAllDiscovery, omitted:
273
277
  return `[hashline auto-read-all: ${attached} file(s) attached from ${discovery.source}; ${summary}${omissionNote}]`;
274
278
  }
275
279
 
276
- export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number): Promise<AutoReadAllInjection | undefined> {
277
- const discovery = await discoverAutoReadAllFiles(cwd);
280
+ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number, mode: AutoReadAllMode = "on"): Promise<AutoReadAllInjection | undefined> {
281
+ const discovery = await discoverAutoReadAllFiles(cwd, mode);
278
282
  if (discovery.files.length === 0) return undefined;
279
283
  const sections: string[] = [];
280
284
  const omitted: string[] = [];
package/src/config-ui.ts CHANGED
@@ -18,7 +18,7 @@ export interface ConfigRow {
18
18
  export function configRows(config: Config): ConfigRow[] {
19
19
  return [
20
20
  { key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
21
- { key: "autoReadAll", label: "Auto-read all", hint: "Attach every non-ignored file with anchors on the first turn", enabled: config.autoReadAll === true },
21
+ { key: "autoReadAll", label: "Auto-read all", hint: "Attach files on the first turn: off, on, git (git repos only)", enabled: (config.autoReadAll ?? "off") !== "off", mode: config.autoReadAll ?? "off", cycle: ["off", "on", "git"] },
22
22
  { key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
23
23
  { key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
24
24
  { key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
package/src/config.ts CHANGED
@@ -4,6 +4,8 @@ import { configPath } from "./paths";
4
4
  import { errCode, isRec } from "./utils";
5
5
  import { writeAtomic } from "./fs-write";
6
6
  export type BoundaryDedupMode = "on" | "off" | "strict";
7
+ export type AutoReadAllMode = "off" | "on" | "git";
8
+ const AUTO_READ_ALL_MODES: AutoReadAllMode[] = ["off", "on", "git"];
7
9
 
8
10
  export const DEFAULT_DIFF_CONTEXT_LINES = 1;
9
11
  export const MIN_DIFF_CONTEXT_LINES = 0;
@@ -12,7 +14,7 @@ export const MAX_DIFF_CONTEXT_LINES = 10;
12
14
  export interface Config {
13
15
  autoRead: boolean;
14
16
  anchorGrepEnabled: boolean;
15
- autoReadAll?: boolean;
17
+ autoReadAll?: AutoReadAllMode;
16
18
  requirePath?: boolean;
17
19
  strictInput?: boolean;
18
20
  boundaryDedupMode?: BoundaryDedupMode;
@@ -22,7 +24,7 @@ export interface Config {
22
24
  const DEFAULT_CONFIG: Config = {
23
25
  autoRead: true,
24
26
  anchorGrepEnabled: true,
25
- autoReadAll: false,
27
+ autoReadAll: "off",
26
28
  requirePath: false,
27
29
  strictInput: false,
28
30
  boundaryDedupMode: "on",
@@ -38,6 +40,13 @@ function parseBoundaryDedupMode(mode: unknown, legacy: unknown): BoundaryDedupMo
38
40
  return DEFAULT_CONFIG.boundaryDedupMode ?? "on";
39
41
  }
40
42
 
43
+ function parseAutoReadAllMode(value: unknown): AutoReadAllMode {
44
+ if (value === "off" || value === "on" || value === "git") return value;
45
+ if (value === true) return "on";
46
+ if (value === false) return "off";
47
+ return DEFAULT_CONFIG.autoReadAll ?? "off";
48
+ }
49
+
41
50
  export function normalizeDiffContextLines(value: unknown): number {
42
51
  if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_DIFF_CONTEXT_LINES;
43
52
  const floored = Math.floor(value);
@@ -62,7 +71,7 @@ function parseConfig(content: string): Config {
62
71
  return {
63
72
  autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
64
73
  anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
65
- autoReadAll: typeof autoReadAll === "boolean" ? autoReadAll : DEFAULT_CONFIG.autoReadAll,
74
+ autoReadAll: parseAutoReadAllMode(autoReadAll),
66
75
  requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
67
76
  strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
68
77
  boundaryDedupMode: parseBoundaryDedupMode(boundaryDedupMode, legacyBoundaryDedup),
@@ -174,7 +183,7 @@ export async function writeConfig(config: Config): Promise<void> {
174
183
  }
175
184
 
176
185
 
177
- type ToggleKey = "autoRead" | "anchorGrepEnabled" | "autoReadAll" | "requirePath" | "strictInput";
186
+ type ToggleKey = "autoRead" | "anchorGrepEnabled" | "requirePath" | "strictInput";
178
187
 
179
188
  async function toggleFlag(key: ToggleKey): Promise<boolean> {
180
189
  const config = await updateConfig((c) => { c[key] = !(c[key] === true); });
@@ -182,7 +191,15 @@ async function toggleFlag(key: ToggleKey): Promise<boolean> {
182
191
  }
183
192
  export const toggleAutoRead = (): Promise<boolean> => toggleFlag("autoRead");
184
193
  export const toggleAnchorGrep = (): Promise<boolean> => toggleFlag("anchorGrepEnabled");
185
- export const toggleAutoReadAll = (): Promise<boolean> => toggleFlag("autoReadAll");
194
+ export async function cycleAutoReadAllMode(): Promise<AutoReadAllMode> {
195
+ let next: AutoReadAllMode = "off";
196
+ await updateConfig((c) => {
197
+ const current = c.autoReadAll ?? "off";
198
+ next = AUTO_READ_ALL_MODES[(AUTO_READ_ALL_MODES.indexOf(current) + 1) % AUTO_READ_ALL_MODES.length] ?? "off";
199
+ c.autoReadAll = next;
200
+ });
201
+ return next;
202
+ }
186
203
  export const toggleRequirePath = (): Promise<boolean> => toggleFlag("requirePath");
187
204
  export const toggleStrictInput = (): Promise<boolean> => toggleFlag("strictInput");
188
205
  export async function cycleBoundaryDedupMode(): Promise<BoundaryDedupMode> {
@@ -45,6 +45,9 @@ export function withReplacePrompts(base: { description: string; snippet: string;
45
45
  descriptionParts.push("Also give `path` matching the file the anchors were served for; it is required and must match anchor ownership.");
46
46
  snippetParts.push("; include `path` (required)");
47
47
  guidelines.push("`replace`: include `path` matching the file the anchors were served for; it is required.");
48
+ } else {
49
+ descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
50
+ guidelines.push("`replace`: path resolution is anchor-only; don't pass `path`.");
48
51
  }
49
52
  if (flags.strictInput) {
50
53
  descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
@@ -74,6 +77,9 @@ export function withInsertPrompts(base: { description: string; snippet: string;
74
77
  descriptionParts.push("Also give `path` matching the file the anchor was served for; it is required and must match anchor ownership.");
75
78
  snippetParts.push("; include `path` (required)");
76
79
  guidelines.push("`insert`: include `path` matching the file the anchor was served for; it is required.");
80
+ } else {
81
+ descriptionParts.push("Path resolution is anchor-only; do not pass `path`.");
82
+ guidelines.push("`insert`: path resolution is anchor-only; don't pass `path`.");
77
83
  }
78
84
  if (flags.strictInput) {
79
85
  descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
@@ -8,7 +8,7 @@ const replacementLinesSchema = Type.Array(
8
8
  }),
9
9
  {
10
10
  description:
11
- "One string per line. Use [] to delete the range.",
11
+ "One string per line. Use [] to delete the range; [\"\"] is a single blank line.",
12
12
  },
13
13
  );
14
14