ultimate-pi 0.19.0 → 0.20.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 (85) hide show
  1. package/.agents/skills/web-retrieval/SKILL.md +163 -0
  2. package/.agents/skills/wiki-autoresearch/SKILL.md +6 -6
  3. package/.pi/SYSTEM.md +30 -12
  4. package/.pi/agents/harness/planning/implementation-researcher.md +1 -1
  5. package/.pi/agents/harness/planning/stack-researcher.md +5 -1
  6. package/.pi/agents/harness/running/executor.md +42 -1
  7. package/.pi/agents/harness/web-retrieval/web-answerer.md +35 -0
  8. package/.pi/agents/harness/web-retrieval/web-criteria-verifier.md +28 -0
  9. package/.pi/agents/harness/web-retrieval/web-gap-analyzer.md +31 -0
  10. package/.pi/agents/harness/web-retrieval/web-query-expander-fast.md +34 -0
  11. package/.pi/agents/harness/web-retrieval/web-query-expander.md +60 -0
  12. package/.pi/agents/harness/web-retrieval/web-summarizer.md +18 -0
  13. package/.pi/extensions/harness-anchored-edit.ts +141 -0
  14. package/.pi/extensions/harness-web-guard.ts +2 -1
  15. package/.pi/extensions/harness-web-tools.ts +689 -51
  16. package/.pi/harness/agents.manifest.json +30 -6
  17. package/.pi/harness/agents.policy.yaml +37 -4
  18. package/.pi/harness/docs/adrs/0050-agentic-web-retrieval-stack.md +46 -0
  19. package/.pi/harness/docs/adrs/0051-hash-anchored-executor-edits.md +41 -0
  20. package/.pi/harness/docs/adrs/README.md +2 -0
  21. package/.pi/harness/docs/harness-web-search.md +97 -0
  22. package/.pi/harness/docs/practice-map.md +11 -0
  23. package/.pi/harness/env.harness.template +9 -1
  24. package/.pi/harness/examples/web-heuristic-angles.project.yaml +22 -0
  25. package/.pi/harness/web-heuristic-angles.json +278 -0
  26. package/.pi/harness/web-heuristic-angles.yaml +182 -0
  27. package/.pi/lib/agents-policy.d.mts +4 -0
  28. package/.pi/lib/agents-policy.mjs +49 -1
  29. package/.pi/lib/agents-policy.ts +1 -0
  30. package/.pi/lib/harness-anchored-edit/.hash_anchors +1721 -0
  31. package/.pi/lib/harness-anchored-edit/anchor-state.ts +320 -0
  32. package/.pi/lib/harness-anchored-edit/apply-anchored-edits.ts +161 -0
  33. package/.pi/lib/harness-anchored-edit/edit-executor.ts +146 -0
  34. package/.pi/lib/harness-anchored-edit/index.ts +9 -0
  35. package/.pi/lib/harness-anchored-edit/line-protocol.ts +38 -0
  36. package/.pi/lib/harness-anchored-edit/settings.ts +1 -0
  37. package/.pi/lib/harness-anchored-edit/task-id.ts +8 -0
  38. package/.pi/lib/harness-anchored-edit/types.ts +19 -0
  39. package/.pi/lib/harness-lens/clients/anchored-edit-autopatch.ts +158 -0
  40. package/.pi/lib/harness-lens/index.ts +24 -7
  41. package/.pi/lib/harness-subagent-auth.ts +39 -9
  42. package/.pi/lib/harness-subagents-bridge.ts +24 -1
  43. package/.pi/lib/harness-web/artifacts.ts +200 -0
  44. package/.pi/lib/harness-web/cache.ts +369 -0
  45. package/.pi/lib/harness-web/run-cli.ts +42 -2
  46. package/.pi/prompts/harness-plan.md +1 -0
  47. package/.pi/prompts/harness-setup.md +3 -1
  48. package/.pi/prompts/harness-steer.md +1 -1
  49. package/.pi/scripts/gen-web-heuristic-angles-json.mjs +24 -0
  50. package/.pi/scripts/harness-anchored-edit-smoke.mjs +45 -0
  51. package/.pi/scripts/harness-cli-verify.sh +5 -0
  52. package/.pi/scripts/harness-verify.mjs +145 -0
  53. package/.pi/scripts/harness-web-policy-guard.mjs +1 -1
  54. package/.pi/scripts/harness-web.py +218 -15
  55. package/.pi/scripts/harness_web/deep_search.py +55 -0
  56. package/.pi/scripts/harness_web/evidence_bundle.py +47 -0
  57. package/.pi/scripts/harness_web/find_similar.py +88 -0
  58. package/.pi/scripts/harness_web/heuristic_angles_shipped.py +85 -0
  59. package/.pi/scripts/harness_web/heuristic_config.py +251 -0
  60. package/.pi/scripts/harness_web/highlights.py +47 -0
  61. package/.pi/scripts/harness_web/multi_search.py +59 -0
  62. package/.pi/scripts/harness_web/output.py +24 -0
  63. package/.pi/scripts/harness_web/query_angles.py +116 -0
  64. package/.pi/scripts/harness_web/rank.py +163 -0
  65. package/.pi/scripts/harness_web/scrape.py +30 -0
  66. package/.pi/scripts/run-tests.mjs +64 -0
  67. package/.pi/scripts/tests/test_harness_web_heuristic_config.py +132 -0
  68. package/.pi/scripts/tests/test_harness_web_query_angles.py +45 -0
  69. package/.pi/scripts/tests/test_harness_web_rank.py +56 -0
  70. package/AGENTS.md +2 -2
  71. package/CHANGELOG.md +12 -0
  72. package/THIRD_PARTY_NOTICES.md +7 -0
  73. package/package.json +7 -4
  74. package/vendor/pi-subagents/src/agents.ts +5 -0
  75. package/vendor/pi-subagents/src/subagents.ts +22 -3
  76. package/.agents/skills/scrapling-web/SKILL.md +0 -98
  77. package/.pi/extensions/00-posthog-network-bootstrap.ts +0 -11
  78. package/.pi/scripts/harness_web/__pycache__/__init__.cpython-314.pyc +0 -0
  79. package/.pi/scripts/harness_web/__pycache__/config.cpython-314.pyc +0 -0
  80. package/.pi/scripts/harness_web/__pycache__/output.cpython-314.pyc +0 -0
  81. package/.pi/scripts/harness_web/__pycache__/scrape.cpython-314.pyc +0 -0
  82. package/.pi/scripts/harness_web/__pycache__/search.cpython-314.pyc +0 -0
  83. package/.pi/scripts/harness_web/__pycache__/search_ddg.cpython-314.pyc +0 -0
  84. package/.pi/scripts/harness_web/__pycache__/search_searxng.cpython-314.pyc +0 -0
  85. package/.pi/scripts/release.sh +0 -338
@@ -0,0 +1,320 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import * as diff from "diff";
5
+
6
+ interface TrackedDocument {
7
+ hashes: Uint32Array;
8
+ anchors: string[];
9
+ usedWords: Set<string>;
10
+ availablePool?: string[];
11
+ }
12
+
13
+ export class AnchorStateManager {
14
+ private static storage = new Map<string, Map<string, TrackedDocument>>();
15
+ private static dictionary: string[] = [];
16
+ private static readonly MAX_TRACKED_LINES = 50000;
17
+ private static readonly MAX_TRACKED_FILES = 1024;
18
+ private static readonly MAX_TRACKED_TASKS = 50;
19
+
20
+ private static computeHashes(lines: string[]): Uint32Array {
21
+ const hashes = new Uint32Array(lines.length);
22
+ for (let i = 0; i < lines.length; i++) {
23
+ const line = lines[i];
24
+ let h = 2166136261;
25
+ for (let j = 0; j < line.length; j++) {
26
+ h = Math.imul(h ^ line.charCodeAt(j), 16777619);
27
+ }
28
+ hashes[i] = h >>> 0;
29
+ }
30
+ return hashes;
31
+ }
32
+
33
+ private static getDictionary(): string[] {
34
+ if (AnchorStateManager.dictionary.length === 0) {
35
+ const dictionaryPath = path.join(
36
+ path.dirname(fileURLToPath(import.meta.url)),
37
+ ".hash_anchors",
38
+ );
39
+ // do not catch errors here, we should fail loudly
40
+ AnchorStateManager.dictionary = fs
41
+ .readFileSync(dictionaryPath, "utf8")
42
+ .split(/\r?\n/)
43
+ .filter(Boolean);
44
+ }
45
+ return AnchorStateManager.dictionary;
46
+ }
47
+
48
+ private static refill(usedWords: Set<string>, pool: string[]) {
49
+ const dict = AnchorStateManager.getDictionary();
50
+ const dictLen = dict.length;
51
+ const newWords: string[] = [];
52
+
53
+ // Try to find 10,000 unique two-word combinations
54
+ let attempts = 0;
55
+ while (newWords.length < 10000 && attempts < 50000) {
56
+ const w1 = dict[Math.floor(Math.random() * dictLen)];
57
+ const w2 = dict[Math.floor(Math.random() * dictLen)];
58
+ const word = `${w1}${w2}`;
59
+ if (!usedWords.has(word)) {
60
+ newWords.push(word);
61
+ }
62
+ attempts++;
63
+ }
64
+
65
+ // Extreme fallback: three-word combinations if we are struggling
66
+ if (newWords.length < 100) {
67
+ for (let i = 0; i < 100; i++) {
68
+ const w1 = dict[Math.floor(Math.random() * dictLen)];
69
+ const w2 = dict[Math.floor(Math.random() * dictLen)];
70
+ const w3 = dict[Math.floor(Math.random() * dictLen)];
71
+ const word = `${w1}${w2}${w3}`;
72
+ if (!usedWords.has(word)) {
73
+ newWords.push(word);
74
+ }
75
+ }
76
+ }
77
+
78
+ // Shuffle the new batch and add to pool
79
+ for (let i = newWords.length - 1; i > 0; i--) {
80
+ const j = Math.floor(Math.random() * (i + 1));
81
+ [newWords[i], newWords[j]] = [newWords[j], newWords[i]];
82
+ }
83
+ pool.push(...newWords);
84
+ }
85
+
86
+ private static getUniqueWord(usedWords: Set<string>, pool: string[]): string {
87
+ while (true) {
88
+ if (pool.length === 0) {
89
+ AnchorStateManager.refill(usedWords, pool);
90
+ }
91
+
92
+ const word = pool.pop()!;
93
+ if (!usedWords.has(word)) {
94
+ return word;
95
+ }
96
+ // If we hit a collision (word was in usedWords but not in pool),
97
+ // just pop the next one.
98
+ }
99
+ }
100
+
101
+ private static getTaskState(
102
+ taskId = "default",
103
+ ): Map<string, TrackedDocument> {
104
+ let state = AnchorStateManager.storage.get(taskId);
105
+ if (!state) {
106
+ state = new Map<string, TrackedDocument>();
107
+ AnchorStateManager.storage.set(taskId, state);
108
+
109
+ // Implement LRU for tasks
110
+ if (
111
+ AnchorStateManager.storage.size > AnchorStateManager.MAX_TRACKED_TASKS
112
+ ) {
113
+ const oldestTaskId = AnchorStateManager.storage.keys().next().value;
114
+ if (oldestTaskId !== undefined) {
115
+ AnchorStateManager.storage.delete(oldestTaskId);
116
+ }
117
+ }
118
+ } else {
119
+ // Refresh LRU position for existing task
120
+ AnchorStateManager.storage.delete(taskId);
121
+ AnchorStateManager.storage.set(taskId, state);
122
+ }
123
+ return state;
124
+ }
125
+
126
+ /**
127
+ * Reconciles the current file content with our saved state using Myers Diff.
128
+ * Unchanged lines keep their exact word anchors. New lines get new words.
129
+ */
130
+ public static reconcile(
131
+ absolutePath: string,
132
+ currentLines: string[],
133
+ taskId?: string,
134
+ ): string[] {
135
+ // Safeguard for massive files
136
+ if (currentLines.length > AnchorStateManager.MAX_TRACKED_LINES) {
137
+ return currentLines.map((_, i) => `L${i + 1}`);
138
+ }
139
+
140
+ const state = AnchorStateManager.getTaskState(taskId);
141
+ const currentHashes = AnchorStateManager.computeHashes(currentLines);
142
+ let tracked = state.get(absolutePath);
143
+
144
+ // Fast path: if hashes are identical, nothing changed
145
+ if (tracked && tracked.hashes.length === currentHashes.length) {
146
+ let identical = true;
147
+ for (let i = 0; i < currentHashes.length; i++) {
148
+ if (tracked.hashes[i] !== currentHashes[i]) {
149
+ identical = false;
150
+ break;
151
+ }
152
+ }
153
+ if (identical) {
154
+ // Refresh LRU position
155
+ AnchorStateManager.updateState(absolutePath, tracked, taskId);
156
+ return tracked.anchors;
157
+ }
158
+ }
159
+
160
+ // First time seeing this file? Assign unique random words to every line.
161
+ if (!tracked) {
162
+ const usedWords = new Set<string>();
163
+ const pool = [...AnchorStateManager.getDictionary()];
164
+ // Initial shuffle of dictionary
165
+ for (let i = pool.length - 1; i > 0; i--) {
166
+ const j = Math.floor(Math.random() * (i + 1));
167
+ [pool[i], pool[j]] = [pool[j], pool[i]];
168
+ }
169
+
170
+ const anchors = currentLines.map(() => {
171
+ const w = AnchorStateManager.getUniqueWord(usedWords, pool);
172
+ usedWords.add(w);
173
+ return w;
174
+ });
175
+
176
+ tracked = {
177
+ hashes: currentHashes,
178
+ anchors,
179
+ usedWords,
180
+ availablePool: pool,
181
+ };
182
+ AnchorStateManager.updateState(absolutePath, tracked, taskId);
183
+ return anchors;
184
+ }
185
+
186
+ // We have history! Run Myers Diff on hashes (integers) instead of strings.
187
+ // Note: diffArrays accepts any array-like, but we convert Uint32Array to regular Array
188
+ // because jsdiff's internal comparisons are more reliable with standard Arrays.
189
+ const changes = diff.diffArrays(
190
+ Array.from(tracked.hashes),
191
+ Array.from(currentHashes),
192
+ );
193
+
194
+ const newAnchors: string[] = [];
195
+ const newUsedWords = new Set<string>(tracked.usedWords);
196
+ const pool = tracked.availablePool || [];
197
+ // If pool was lost (e.g. from an older version of state), initialize it
198
+ if (
199
+ pool.length === 0 &&
200
+ newUsedWords.size < AnchorStateManager.getDictionary().length
201
+ ) {
202
+ const dict = AnchorStateManager.getDictionary();
203
+ for (const word of dict) {
204
+ if (!newUsedWords.has(word)) {
205
+ pool.push(word);
206
+ }
207
+ }
208
+ // Shuffle initial single-word pool
209
+ for (let i = pool.length - 1; i > 0; i--) {
210
+ const j = Math.floor(Math.random() * (i + 1));
211
+ [pool[i], pool[j]] = [pool[j], pool[i]];
212
+ }
213
+ }
214
+
215
+ let oldIdx = 0;
216
+
217
+ for (const change of changes) {
218
+ if (change.added) {
219
+ // New lines (typed by user or added by LLM) get NEW words
220
+ for (let i = 0; i < change.count!; i++) {
221
+ const word = AnchorStateManager.getUniqueWord(newUsedWords, pool);
222
+ newAnchors.push(word);
223
+ newUsedWords.add(word);
224
+ }
225
+ } else if (change.removed) {
226
+ // Deleted lines: We just advance the old index.
227
+ oldIdx += change.count!;
228
+ } else {
229
+ // Unchanged lines: CARRY OVER THE EXACT SAME WORD ANCHOR
230
+ for (let i = 0; i < change.count!; i++) {
231
+ const preservedWord = tracked.anchors[oldIdx];
232
+ newAnchors.push(preservedWord);
233
+ newUsedWords.add(preservedWord);
234
+ oldIdx++;
235
+ }
236
+ }
237
+ }
238
+
239
+ // Update the state cache
240
+ tracked = {
241
+ hashes: currentHashes,
242
+ anchors: newAnchors,
243
+ usedWords: newUsedWords,
244
+ availablePool: pool,
245
+ };
246
+ AnchorStateManager.updateState(absolutePath, tracked, taskId);
247
+ return newAnchors;
248
+ }
249
+
250
+ private static updateState(
251
+ absolutePath: string,
252
+ document: TrackedDocument,
253
+ taskId?: string,
254
+ ) {
255
+ const state = AnchorStateManager.getTaskState(taskId);
256
+ // Implement LRU by deleting and re-inserting
257
+ state.delete(absolutePath);
258
+ state.set(absolutePath, document);
259
+
260
+ // Evict oldest if limit exceeded
261
+ if (state.size > AnchorStateManager.MAX_TRACKED_FILES) {
262
+ const oldestKey = state.keys().next().value;
263
+ if (oldestKey !== undefined) {
264
+ state.delete(oldestKey);
265
+ }
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Returns true if the file is currently being tracked.
271
+ */
272
+ public static isTracking(absolutePath: string, taskId?: string): boolean {
273
+ return AnchorStateManager.getTaskState(taskId).has(absolutePath);
274
+ }
275
+
276
+ /**
277
+ * Gets current anchors for a file if it's being tracked, otherwise returns null.
278
+ */
279
+ public static getAnchors(
280
+ absolutePath: string,
281
+ taskId?: string,
282
+ ): string[] | null {
283
+ return (
284
+ AnchorStateManager.getTaskState(taskId).get(absolutePath)?.anchors || null
285
+ );
286
+ }
287
+
288
+ /**
289
+ * Clear state for a file (useful if needed for cleanup)
290
+ */
291
+ public static clearState(absolutePath: string, taskId?: string) {
292
+ AnchorStateManager.getTaskState(taskId).delete(absolutePath);
293
+ }
294
+
295
+ /**
296
+ * Resets all anchors for a specific task or all tasks.
297
+ */
298
+ public static reset(taskId?: string) {
299
+ if (taskId) {
300
+ AnchorStateManager.storage.delete(taskId);
301
+ } else {
302
+ AnchorStateManager.storage.clear();
303
+ }
304
+ }
305
+ }
306
+
307
+ import { formatLineWithHash } from "./line-protocol.js";
308
+
309
+ export function hashLinesStateful(
310
+ absolutePath: string,
311
+ content: string,
312
+ taskId?: string,
313
+ ): string {
314
+ if (!content) return "";
315
+ const lines = content.split(/\r?\n/);
316
+ const anchors = AnchorStateManager.reconcile(absolutePath, lines, taskId);
317
+ return lines
318
+ .map((line, index) => formatLineWithHash(line, anchors[index]!))
319
+ .join("\n");
320
+ }
@@ -0,0 +1,161 @@
1
+ import { constants } from "node:fs";
2
+ import { access, readFile, writeFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { AnchorStateManager } from "./anchor-state.js";
7
+ import { EditExecutor } from "./edit-executor.js";
8
+ import type { AnchoredEdit } from "./types.js";
9
+
10
+ type EditDiffHelpers = {
11
+ detectLineEnding: (content: string) => "\r\n" | "\n";
12
+ generateDiffString: (
13
+ oldContent: string,
14
+ newContent: string,
15
+ ) => { diff: string; firstChangedLine?: number };
16
+ normalizeToLF: (text: string) => string;
17
+ restoreLineEndings: (text: string, ending: "\r\n" | "\n") => string;
18
+ stripBom: (content: string) => { bom: string; text: string };
19
+ };
20
+
21
+ type FileMutationQueue = <T>(path: string, fn: () => Promise<T>) => Promise<T>;
22
+
23
+ let editDiffHelpers: EditDiffHelpers | undefined;
24
+ let fileMutationQueue: FileMutationQueue | undefined;
25
+
26
+ function resolvePiDistEntry(): string {
27
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../../..");
28
+ return join(
29
+ repoRoot,
30
+ "node_modules/@earendil-works/pi-coding-agent/dist/index.js",
31
+ );
32
+ }
33
+
34
+ async function loadPiEditRuntime(): Promise<{
35
+ editDiff: EditDiffHelpers;
36
+ withFileMutationQueue: FileMutationQueue;
37
+ }> {
38
+ if (editDiffHelpers && fileMutationQueue) {
39
+ return {
40
+ editDiff: editDiffHelpers,
41
+ withFileMutationQueue: fileMutationQueue,
42
+ };
43
+ }
44
+ const piEntry = resolvePiDistEntry();
45
+ const pi = await import("@earendil-works/pi-coding-agent");
46
+ const require = createRequire(piEntry);
47
+ editDiffHelpers = require(
48
+ join(dirname(piEntry), "core/tools/edit-diff.js"),
49
+ ) as EditDiffHelpers;
50
+ fileMutationQueue = pi.withFileMutationQueue as FileMutationQueue;
51
+ return {
52
+ editDiff: editDiffHelpers,
53
+ withFileMutationQueue: fileMutationQueue,
54
+ };
55
+ }
56
+
57
+ export type ApplyAnchoredEditsResult =
58
+ | {
59
+ ok: true;
60
+ details: { diff: string; firstChangedLine?: number };
61
+ message: string;
62
+ }
63
+ | { ok: false; error: string };
64
+
65
+ export async function applyAnchoredEditsToFile(
66
+ absolutePath: string,
67
+ edits: AnchoredEdit[],
68
+ taskId: string,
69
+ ): Promise<ApplyAnchoredEditsResult> {
70
+ if (!edits.length) {
71
+ return {
72
+ ok: false,
73
+ error: "edit: edits must contain at least one anchored edit.",
74
+ };
75
+ }
76
+
77
+ const { editDiff, withFileMutationQueue } = await loadPiEditRuntime();
78
+ const {
79
+ detectLineEnding,
80
+ generateDiffString,
81
+ normalizeToLF,
82
+ restoreLineEndings,
83
+ stripBom,
84
+ } = editDiff;
85
+
86
+ return withFileMutationQueue(absolutePath, async () => {
87
+ try {
88
+ await access(absolutePath, constants.R_OK | constants.W_OK);
89
+ } catch (error) {
90
+ const code =
91
+ error instanceof Error && "code" in error
92
+ ? String(error.code)
93
+ : String(error);
94
+ return {
95
+ ok: false,
96
+ error: `Could not edit file: ${absolutePath}. Error code: ${code}.`,
97
+ };
98
+ }
99
+
100
+ const buffer = await readFile(absolutePath);
101
+ const rawContent = buffer.toString("utf-8");
102
+ const { bom, text: content } = stripBom(rawContent);
103
+ const originalEnding = detectLineEnding(content);
104
+ const normalizedContent = normalizeToLF(content);
105
+ const lines = normalizedContent.split("\n");
106
+
107
+ const lineAnchors = AnchorStateManager.reconcile(
108
+ absolutePath,
109
+ lines,
110
+ taskId,
111
+ );
112
+ const executor = new EditExecutor();
113
+ const { resolvedEdits, failedEdits } = executor.resolveEdits(
114
+ edits,
115
+ lines,
116
+ lineAnchors,
117
+ );
118
+
119
+ if (failedEdits.length > 0) {
120
+ return {
121
+ ok: false,
122
+ error: failedEdits
123
+ .map((f) => executor.formatFailureMessage(f.edit, f.error))
124
+ .join("\n"),
125
+ };
126
+ }
127
+
128
+ const { finalLines } = executor.applyEdits(lines, resolvedEdits);
129
+ const newContent = finalLines.join("\n");
130
+
131
+ if (newContent === normalizedContent) {
132
+ return {
133
+ ok: false,
134
+ error:
135
+ "Anchored edit made no changes. Re-read the file and verify anchors and text.",
136
+ };
137
+ }
138
+
139
+ const finalContent = bom + restoreLineEndings(newContent, originalEnding);
140
+ await writeFile(absolutePath, finalContent, "utf-8");
141
+
142
+ const diffResult = generateDiffString(normalizedContent, newContent);
143
+ return {
144
+ ok: true,
145
+ details: {
146
+ diff: diffResult.diff,
147
+ firstChangedLine: diffResult.firstChangedLine,
148
+ },
149
+ message: `Successfully applied ${edits.length} anchored edit(s).`,
150
+ };
151
+ });
152
+ }
153
+
154
+ export function isAnchoredEditInput(input: unknown): boolean {
155
+ if (!input || typeof input !== "object") return false;
156
+ const edits = (input as { edits?: unknown }).edits;
157
+ if (!Array.isArray(edits) || edits.length === 0) return false;
158
+ const first = edits[0];
159
+ if (!first || typeof first !== "object") return false;
160
+ return typeof (first as { anchor?: unknown }).anchor === "string";
161
+ }
@@ -0,0 +1,146 @@
1
+ import { getDelimiter, splitAnchor, stripHashes } from "./line-protocol.js";
2
+ import type {
3
+ AnchoredEdit,
4
+ FailedAnchoredEdit,
5
+ ResolvedAnchoredEdit,
6
+ } from "./types.js";
7
+
8
+ export class EditExecutor {
9
+ resolveEdits(
10
+ edits: AnchoredEdit[],
11
+ lines: string[],
12
+ lineAnchors: string[],
13
+ ): {
14
+ resolvedEdits: ResolvedAnchoredEdit[];
15
+ failedEdits: FailedAnchoredEdit[];
16
+ } {
17
+ const failedEdits: FailedAnchoredEdit[] = [];
18
+ const resolvedEdits: ResolvedAnchoredEdit[] = [];
19
+ const normalized = lineAnchors.map((h) => h.trim());
20
+
21
+ for (const edit of edits) {
22
+ const diagnostics: string[] = [];
23
+ const editType = edit.edit_type ?? "replace";
24
+
25
+ const { index: lineIdx, error: startError } = this.resolveAnchor(
26
+ "anchor",
27
+ edit.anchor,
28
+ normalized,
29
+ lines,
30
+ );
31
+ if (startError) diagnostics.push(startError);
32
+
33
+ let endIdx = lineIdx;
34
+ if (editType === "replace") {
35
+ const endAnchorRaw = edit.end_anchor ?? edit.anchor;
36
+ const { index: resolvedEndIdx, error: endError } = this.resolveAnchor(
37
+ "end_anchor",
38
+ endAnchorRaw,
39
+ normalized,
40
+ lines,
41
+ );
42
+ if (endError) diagnostics.push(endError);
43
+ endIdx = resolvedEndIdx;
44
+ }
45
+
46
+ if (lineIdx !== -1 && endIdx !== -1 && endIdx < lineIdx) {
47
+ diagnostics.push(
48
+ "Range error: anchor must refer to a line that precedes or is the same as end_anchor.",
49
+ );
50
+ }
51
+
52
+ if (diagnostics.length > 0) {
53
+ failedEdits.push({ edit, error: diagnostics.join(" ") });
54
+ } else {
55
+ resolvedEdits.push({ lineIdx, endIdx, edit });
56
+ }
57
+ }
58
+ return { resolvedEdits, failedEdits };
59
+ }
60
+
61
+ resolveAnchor(
62
+ type: "anchor" | "end_anchor",
63
+ rawAnchor: string | undefined,
64
+ normalizedLineHashes: string[],
65
+ lines: string[],
66
+ ): { index: number; error?: string } {
67
+ const anchorRaw = rawAnchor || "";
68
+ if (!anchorRaw.trim()) return { index: -1, error: `${type} is missing.` };
69
+
70
+ const { anchor: anchorName, content: providedContent } =
71
+ splitAnchor(anchorRaw);
72
+ const anchorExtractRegex = /^[A-Z][a-zA-Z]*$/;
73
+ if (!anchorExtractRegex.test(anchorName)) {
74
+ return {
75
+ index: -1,
76
+ error: `${type} is missing or incorrectly formatted. It must start with a single word followed by the delimiter (e.g., "Apple${getDelimiter()}").`,
77
+ };
78
+ }
79
+
80
+ const index = normalizedLineHashes.indexOf(anchorName);
81
+ if (index === -1) {
82
+ return {
83
+ index: -1,
84
+ error: `${type} "${anchorName}" not found in the file. Re-read the file for current anchors.`,
85
+ };
86
+ }
87
+
88
+ if (providedContent.includes("\n") || providedContent.includes("\r")) {
89
+ return {
90
+ index: -1,
91
+ error: `${type} "${anchorName}" must refer to a single line (Anchor${getDelimiter()}{line_text}).`,
92
+ };
93
+ }
94
+
95
+ const actualContent = lines[index];
96
+ if (providedContent !== actualContent) {
97
+ return {
98
+ index: -1,
99
+ error: `${type} "${anchorName}" line mismatch. Expected: "${actualContent}", Provided: "${providedContent}".`,
100
+ };
101
+ }
102
+
103
+ return { index };
104
+ }
105
+
106
+ applyEdits(
107
+ lines: string[],
108
+ resolvedEdits: ResolvedAnchoredEdit[],
109
+ ): { finalLines: string[] } {
110
+ const sortedEdits = [...resolvedEdits].sort(
111
+ (a, b) => b.lineIdx - a.lineIdx,
112
+ );
113
+ const newLines = [...lines];
114
+
115
+ for (const { lineIdx, endIdx, edit } of sortedEdits) {
116
+ const editType = edit.edit_type ?? "replace";
117
+ const cleanText = stripHashes(edit.text || "");
118
+ const replacementLines = cleanText === "" ? [] : cleanText.split(/\r?\n/);
119
+
120
+ let removedInThisEdit: number;
121
+ let spliceIndex: number;
122
+
123
+ if (editType === "insert_after") {
124
+ spliceIndex = lineIdx + 1;
125
+ removedInThisEdit = 0;
126
+ } else if (editType === "insert_before") {
127
+ spliceIndex = lineIdx;
128
+ removedInThisEdit = 0;
129
+ } else {
130
+ spliceIndex = lineIdx;
131
+ removedInThisEdit = endIdx - lineIdx + 1;
132
+ }
133
+
134
+ newLines.splice(spliceIndex, removedInThisEdit, ...replacementLines);
135
+ }
136
+
137
+ return { finalLines: newLines };
138
+ }
139
+
140
+ formatFailureMessage(edit: AnchoredEdit, error?: string): string {
141
+ const diagnostic = error
142
+ ? ` ${error}`
143
+ : " Check anchors and line content from the latest read.";
144
+ return `Anchored edit failed (anchor: "${edit.anchor}", end_anchor: "${edit.end_anchor ?? ""}").${diagnostic}`;
145
+ }
146
+ }
@@ -0,0 +1,9 @@
1
+ export { AnchorStateManager, hashLinesStateful } from "./anchor-state.js";
2
+ export {
3
+ type ApplyAnchoredEditsResult,
4
+ applyAnchoredEditsToFile,
5
+ isAnchoredEditInput,
6
+ } from "./apply-anchored-edits.js";
7
+ export { ANCHOR_DELIMITER, formatLineWithHash } from "./line-protocol.js";
8
+ export { anchoredEditTaskId } from "./task-id.js";
9
+ export type { AnchoredEdit, AnchoredEditType } from "./types.js";
@@ -0,0 +1,38 @@
1
+ /** Hash-anchored line protocol (from Dirac, Apache-2.0). */
2
+
3
+ export const ANCHOR_DELIMITER = "§";
4
+
5
+ export function getDelimiter(): string {
6
+ return ANCHOR_DELIMITER;
7
+ }
8
+
9
+ export function escapeRegExp(string: string): string {
10
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11
+ }
12
+
13
+ export function stripHashes(content: string): string {
14
+ if (!content) return "";
15
+ const delimiterRegex = new RegExp(
16
+ `\\b[A-Z][a-zA-Z]*?${escapeRegExp(ANCHOR_DELIMITER)}`,
17
+ "g",
18
+ );
19
+ return content.replace(delimiterRegex, "");
20
+ }
21
+
22
+ export function splitAnchor(rawAnchor: string): {
23
+ anchor: string;
24
+ content: string;
25
+ } {
26
+ const delimiterIndex = rawAnchor.indexOf(ANCHOR_DELIMITER);
27
+ if (delimiterIndex === -1) {
28
+ return { anchor: rawAnchor.trim(), content: "" };
29
+ }
30
+ return {
31
+ anchor: rawAnchor.substring(0, delimiterIndex).trim(),
32
+ content: rawAnchor.substring(delimiterIndex + ANCHOR_DELIMITER.length),
33
+ };
34
+ }
35
+
36
+ export function formatLineWithHash(content: string, anchor: string): string {
37
+ return `${anchor}${ANCHOR_DELIMITER}${content}`;
38
+ }
@@ -0,0 +1 @@
1
+ export { anchoredEditTaskId } from "./task-id.js";
@@ -0,0 +1,8 @@
1
+ /** Per-session anchor state scope for hash reconciliation. */
2
+ export function anchoredEditTaskId(ctx?: { sessionId?: string }): string {
3
+ return (
4
+ ctx?.sessionId?.trim() ||
5
+ process.env.PI_SESSION_ID?.trim() ||
6
+ "harness-default"
7
+ );
8
+ }
@@ -0,0 +1,19 @@
1
+ export type AnchoredEditType = "replace" | "insert_after" | "insert_before";
2
+
3
+ export interface AnchoredEdit {
4
+ anchor: string;
5
+ end_anchor?: string;
6
+ edit_type?: AnchoredEditType;
7
+ text: string;
8
+ }
9
+
10
+ export interface ResolvedAnchoredEdit {
11
+ lineIdx: number;
12
+ endIdx: number;
13
+ edit: AnchoredEdit;
14
+ }
15
+
16
+ export interface FailedAnchoredEdit {
17
+ edit: AnchoredEdit;
18
+ error: string;
19
+ }