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.
- package/.agents/skills/web-retrieval/SKILL.md +163 -0
- package/.agents/skills/wiki-autoresearch/SKILL.md +6 -6
- package/.pi/SYSTEM.md +30 -12
- package/.pi/agents/harness/planning/implementation-researcher.md +1 -1
- package/.pi/agents/harness/planning/stack-researcher.md +5 -1
- package/.pi/agents/harness/running/executor.md +42 -1
- package/.pi/agents/harness/web-retrieval/web-answerer.md +35 -0
- package/.pi/agents/harness/web-retrieval/web-criteria-verifier.md +28 -0
- package/.pi/agents/harness/web-retrieval/web-gap-analyzer.md +31 -0
- package/.pi/agents/harness/web-retrieval/web-query-expander-fast.md +34 -0
- package/.pi/agents/harness/web-retrieval/web-query-expander.md +60 -0
- package/.pi/agents/harness/web-retrieval/web-summarizer.md +18 -0
- package/.pi/extensions/harness-anchored-edit.ts +141 -0
- package/.pi/extensions/harness-web-guard.ts +2 -1
- package/.pi/extensions/harness-web-tools.ts +689 -51
- package/.pi/harness/agents.manifest.json +30 -6
- package/.pi/harness/agents.policy.yaml +37 -4
- package/.pi/harness/docs/adrs/0050-agentic-web-retrieval-stack.md +46 -0
- package/.pi/harness/docs/adrs/0051-hash-anchored-executor-edits.md +41 -0
- package/.pi/harness/docs/adrs/README.md +2 -0
- package/.pi/harness/docs/harness-web-search.md +97 -0
- package/.pi/harness/docs/practice-map.md +11 -0
- package/.pi/harness/env.harness.template +9 -1
- package/.pi/harness/examples/web-heuristic-angles.project.yaml +22 -0
- package/.pi/harness/web-heuristic-angles.json +278 -0
- package/.pi/harness/web-heuristic-angles.yaml +182 -0
- package/.pi/lib/agents-policy.d.mts +4 -0
- package/.pi/lib/agents-policy.mjs +49 -1
- package/.pi/lib/agents-policy.ts +1 -0
- package/.pi/lib/harness-anchored-edit/.hash_anchors +1721 -0
- package/.pi/lib/harness-anchored-edit/anchor-state.ts +320 -0
- package/.pi/lib/harness-anchored-edit/apply-anchored-edits.ts +161 -0
- package/.pi/lib/harness-anchored-edit/edit-executor.ts +146 -0
- package/.pi/lib/harness-anchored-edit/index.ts +9 -0
- package/.pi/lib/harness-anchored-edit/line-protocol.ts +38 -0
- package/.pi/lib/harness-anchored-edit/settings.ts +1 -0
- package/.pi/lib/harness-anchored-edit/task-id.ts +8 -0
- package/.pi/lib/harness-anchored-edit/types.ts +19 -0
- package/.pi/lib/harness-lens/clients/anchored-edit-autopatch.ts +158 -0
- package/.pi/lib/harness-lens/index.ts +24 -7
- package/.pi/lib/harness-subagent-auth.ts +39 -9
- package/.pi/lib/harness-subagents-bridge.ts +24 -1
- package/.pi/lib/harness-web/artifacts.ts +200 -0
- package/.pi/lib/harness-web/cache.ts +369 -0
- package/.pi/lib/harness-web/run-cli.ts +42 -2
- package/.pi/prompts/harness-plan.md +1 -0
- package/.pi/prompts/harness-setup.md +3 -1
- package/.pi/prompts/harness-steer.md +1 -1
- package/.pi/scripts/gen-web-heuristic-angles-json.mjs +24 -0
- package/.pi/scripts/harness-anchored-edit-smoke.mjs +45 -0
- package/.pi/scripts/harness-cli-verify.sh +5 -0
- package/.pi/scripts/harness-verify.mjs +145 -0
- package/.pi/scripts/harness-web-policy-guard.mjs +1 -1
- package/.pi/scripts/harness-web.py +218 -15
- package/.pi/scripts/harness_web/deep_search.py +55 -0
- package/.pi/scripts/harness_web/evidence_bundle.py +47 -0
- package/.pi/scripts/harness_web/find_similar.py +88 -0
- package/.pi/scripts/harness_web/heuristic_angles_shipped.py +85 -0
- package/.pi/scripts/harness_web/heuristic_config.py +251 -0
- package/.pi/scripts/harness_web/highlights.py +47 -0
- package/.pi/scripts/harness_web/multi_search.py +59 -0
- package/.pi/scripts/harness_web/output.py +24 -0
- package/.pi/scripts/harness_web/query_angles.py +116 -0
- package/.pi/scripts/harness_web/rank.py +163 -0
- package/.pi/scripts/harness_web/scrape.py +30 -0
- package/.pi/scripts/run-tests.mjs +64 -0
- package/.pi/scripts/tests/test_harness_web_heuristic_config.py +132 -0
- package/.pi/scripts/tests/test_harness_web_query_angles.py +45 -0
- package/.pi/scripts/tests/test_harness_web_rank.py +56 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +12 -0
- package/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +7 -4
- package/vendor/pi-subagents/src/agents.ts +5 -0
- package/vendor/pi-subagents/src/subagents.ts +22 -3
- package/.agents/skills/scrapling-web/SKILL.md +0 -98
- package/.pi/extensions/00-posthog-network-bootstrap.ts +0 -11
- package/.pi/scripts/harness_web/__pycache__/__init__.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/config.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/output.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/scrape.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/search.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/search_ddg.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/search_searxng.cpython-314.pyc +0 -0
- 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,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
|
+
}
|