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.
- package/README.md +86 -64
- package/index.ts +31 -28
- package/package.json +2 -2
- package/src/anchor-registry.ts +15 -22
- package/src/auto-read-all.ts +3 -3
- package/src/config-ui.ts +6 -1
- package/src/config.ts +6 -1
- package/src/constants.ts +1 -1
- package/src/copy-move.ts +11 -10
- package/src/edit-common.ts +23 -21
- package/src/edit-fidelity.ts +3 -3
- package/src/grep.ts +0 -9
- package/src/hashline/alphabet.ts +3 -3
- package/src/hashline/anchor-table.json +1 -1
- package/src/hashline/parse.ts +21 -14
- package/src/hashline/resolve.ts +23 -9
- package/src/insert.ts +7 -6
- package/src/model-gate.ts +1 -0
- package/src/outline/parsers.ts +0 -1
- package/src/outline/render.ts +154 -82
- package/src/outline/vendor/README.md +4 -0
- package/src/outline/vendor/parsers/blade.ts +1 -1
- package/src/outline/vendor/parsers/java.ts +144 -331
- package/src/outline/vendor/parsers/manager.ts +12 -183
- package/src/outline/vendor/parsers/php.ts +16 -81
- package/src/outline/vendor/parsers/sass.ts +0 -11
- package/src/outline/vendor/parsers/scss.ts +0 -8
- package/src/outline/vendor/types.ts +0 -1
- package/src/outline-tool.ts +184 -0
- package/src/payload-contract.ts +27 -15
- package/src/prompts.ts +2 -1
- package/src/read.ts +4 -5
- package/src/replace-diff.ts +1 -1
- package/src/replace-match.ts +36 -28
- package/src/replace.ts +12 -2
- package/src/structured.ts +32 -0
- package/src/write-hook.ts +4 -3
- package/tool-prompts/copy-guidelines.md +1 -1
- package/tool-prompts/grep-guidelines.md +1 -2
- package/tool-prompts/insert.md +1 -1
- package/tool-prompts/move.md +2 -2
- package/tool-prompts/outline-guidelines.md +2 -0
- package/tool-prompts/outline-snippet.md +1 -0
- package/tool-prompts/outline.md +3 -0
- package/tool-prompts/read-guidelines.md +1 -1
- package/tool-prompts/read.md +1 -1
- package/tool-prompts/replace-match-guidelines.md +1 -1
- package/tool-prompts/replace-match-snippet.md +1 -1
- 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
|
-
|
|
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((
|
|
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
|
-
|
|
312
|
-
|
|
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": "
|
|
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
|
|
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",
|
package/src/anchor-registry.ts
CHANGED
|
@@ -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
|
|
253
|
-
return
|
|
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 &&
|
|
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 ||
|
|
266
|
-
if (value.kind === "minted") return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 });
|
package/src/auto-read-all.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
-
"
|
|
893
|
+
"5-char anchor of the FIRST source line.",
|
|
894
894
|
});
|
|
895
895
|
const transferSourceToSchema = Type.String({
|
|
896
896
|
description:
|
|
897
|
-
"
|
|
897
|
+
"5-char anchor of the LAST source line.",
|
|
898
898
|
});
|
|
899
899
|
const transferAnchorSchema = Type.String({
|
|
900
900
|
description:
|
|
901
|
-
"
|
|
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.",
|
package/src/edit-common.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
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(
|
|
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;
|
package/src/edit-fidelity.ts
CHANGED
|
@@ -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 =
|
|
401
|
-
const UNICODE_LOST_DETAIL_RE =
|
|
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;
|
package/src/hashline/alphabet.ts
CHANGED
|
@@ -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 %
|
|
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 =
|
|
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 = `[
|
|
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
|
|