pi-repoprompt-cli 0.2.0 → 0.2.6
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 +44 -2
- package/extensions/repoprompt-cli/README.md +94 -0
- package/extensions/repoprompt-cli/config.json.example +3 -0
- package/extensions/repoprompt-cli/config.ts +47 -0
- package/extensions/repoprompt-cli/index.ts +1674 -0
- package/extensions/repoprompt-cli/readcache/LICENSE +23 -0
- package/extensions/repoprompt-cli/readcache/constants.ts +38 -0
- package/extensions/repoprompt-cli/readcache/diff.ts +129 -0
- package/extensions/repoprompt-cli/readcache/meta.ts +241 -0
- package/extensions/repoprompt-cli/readcache/object-store.ts +184 -0
- package/extensions/repoprompt-cli/readcache/read-file.ts +438 -0
- package/extensions/repoprompt-cli/readcache/replay.ts +366 -0
- package/extensions/repoprompt-cli/readcache/resolve.ts +235 -0
- package/extensions/repoprompt-cli/readcache/text.ts +43 -0
- package/extensions/repoprompt-cli/readcache/types.ts +73 -0
- package/extensions/repoprompt-cli/types.ts +6 -0
- package/package.json +26 -3
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// readcache/replay.ts - replay-aware trust reconstruction for rp_exec(read_file)
|
|
2
|
+
|
|
3
|
+
import type { ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { SCOPE_FULL } from "./constants.js";
|
|
6
|
+
import { extractInvalidationFromSessionEntry, extractReadMetaFromSessionEntry } from "./meta.js";
|
|
7
|
+
import type {
|
|
8
|
+
KnowledgeMap,
|
|
9
|
+
RpReadcacheInvalidationV1,
|
|
10
|
+
RpReadcacheMetaV1,
|
|
11
|
+
ScopeKey,
|
|
12
|
+
ScopeRangeKey,
|
|
13
|
+
ScopeTrust,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
type SessionManagerView = ExtensionContext["sessionManager"];
|
|
17
|
+
|
|
18
|
+
type RangeBlockersByPath = Map<string, Set<ScopeRangeKey>>;
|
|
19
|
+
|
|
20
|
+
const OVERLAY_SEQ_START = 1_000_000_000;
|
|
21
|
+
|
|
22
|
+
interface ReplayMemoEntry {
|
|
23
|
+
knowledge: KnowledgeMap;
|
|
24
|
+
blockedRangesByPath: RangeBlockersByPath;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface OverlayState {
|
|
28
|
+
leafId: string | null;
|
|
29
|
+
knowledge: KnowledgeMap;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ReplayRuntimeState {
|
|
33
|
+
memoByLeaf: Map<string, ReplayMemoEntry>;
|
|
34
|
+
overlayBySession: Map<string, OverlayState>;
|
|
35
|
+
nextOverlaySeq: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ReplayBoundary {
|
|
39
|
+
startIndex: number;
|
|
40
|
+
boundaryKey: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cloneKnowledgeMap(source: KnowledgeMap): KnowledgeMap {
|
|
44
|
+
const cloned: KnowledgeMap = new Map();
|
|
45
|
+
for (const [pathKey, scopes] of source.entries()) {
|
|
46
|
+
const clonedScopes = new Map<ScopeKey, ScopeTrust>();
|
|
47
|
+
for (const [scopeKey, trust] of scopes.entries()) {
|
|
48
|
+
clonedScopes.set(scopeKey, { ...trust });
|
|
49
|
+
}
|
|
50
|
+
cloned.set(pathKey, clonedScopes);
|
|
51
|
+
}
|
|
52
|
+
return cloned;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cloneRangeBlockersByPath(source: RangeBlockersByPath): RangeBlockersByPath {
|
|
56
|
+
const cloned: RangeBlockersByPath = new Map();
|
|
57
|
+
for (const [pathKey, scopes] of source.entries()) {
|
|
58
|
+
cloned.set(pathKey, new Set(scopes));
|
|
59
|
+
}
|
|
60
|
+
return cloned;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cloneReplayMemoEntry(source: ReplayMemoEntry): ReplayMemoEntry {
|
|
64
|
+
return {
|
|
65
|
+
knowledge: cloneKnowledgeMap(source.knowledge),
|
|
66
|
+
blockedRangesByPath: cloneRangeBlockersByPath(source.blockedRangesByPath),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getMemoKey(sessionId: string, leafId: string | null, boundaryKey: string): string {
|
|
71
|
+
return `${sessionId}:${leafId ?? "null"}:${boundaryKey}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ensureScopeMap(knowledge: KnowledgeMap, pathKey: string): Map<ScopeKey, ScopeTrust> {
|
|
75
|
+
const existing = knowledge.get(pathKey);
|
|
76
|
+
if (existing) {
|
|
77
|
+
return existing;
|
|
78
|
+
}
|
|
79
|
+
const created = new Map<ScopeKey, ScopeTrust>();
|
|
80
|
+
knowledge.set(pathKey, created);
|
|
81
|
+
return created;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isRangeScope(scopeKey: ScopeKey): scopeKey is ScopeRangeKey {
|
|
85
|
+
return scopeKey !== SCOPE_FULL;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ensureRangeBlockerSet(blockedRangesByPath: RangeBlockersByPath, pathKey: string): Set<ScopeRangeKey> {
|
|
89
|
+
const existing = blockedRangesByPath.get(pathKey);
|
|
90
|
+
if (existing) {
|
|
91
|
+
return existing;
|
|
92
|
+
}
|
|
93
|
+
const created = new Set<ScopeRangeKey>();
|
|
94
|
+
blockedRangesByPath.set(pathKey, created);
|
|
95
|
+
return created;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function setRangeBlocker(blockedRangesByPath: RangeBlockersByPath, pathKey: string, scopeKey: ScopeRangeKey): void {
|
|
99
|
+
const scopes = ensureRangeBlockerSet(blockedRangesByPath, pathKey);
|
|
100
|
+
scopes.add(scopeKey);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function clearRangeBlocker(blockedRangesByPath: RangeBlockersByPath, pathKey: string, scopeKey: ScopeRangeKey): void {
|
|
104
|
+
const scopes = blockedRangesByPath.get(pathKey);
|
|
105
|
+
if (!scopes) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
scopes.delete(scopeKey);
|
|
109
|
+
if (scopes.size === 0) {
|
|
110
|
+
blockedRangesByPath.delete(pathKey);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function getTrust(knowledge: KnowledgeMap, pathKey: string, scopeKey: ScopeKey): ScopeTrust | undefined {
|
|
115
|
+
return knowledge.get(pathKey)?.get(scopeKey);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function setTrust(knowledge: KnowledgeMap, pathKey: string, scopeKey: ScopeKey, hash: string, seq: number): void {
|
|
119
|
+
const scopes = ensureScopeMap(knowledge, pathKey);
|
|
120
|
+
scopes.set(scopeKey, { hash, seq });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function mergeKnowledge(base: KnowledgeMap, overlay: KnowledgeMap): KnowledgeMap {
|
|
124
|
+
const merged = cloneKnowledgeMap(base);
|
|
125
|
+
for (const [pathKey, overlayScopes] of overlay.entries()) {
|
|
126
|
+
const targetScopes = ensureScopeMap(merged, pathKey);
|
|
127
|
+
for (const [scopeKey, trust] of overlayScopes.entries()) {
|
|
128
|
+
targetScopes.set(scopeKey, { ...trust });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return merged;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ensureOverlayForLeaf(runtimeState: ReplayRuntimeState, sessionId: string, leafId: string | null): OverlayState {
|
|
135
|
+
const existing = runtimeState.overlayBySession.get(sessionId);
|
|
136
|
+
if (!existing || existing.leafId !== leafId) {
|
|
137
|
+
const fresh: OverlayState = {
|
|
138
|
+
leafId,
|
|
139
|
+
knowledge: new Map(),
|
|
140
|
+
};
|
|
141
|
+
runtimeState.overlayBySession.set(sessionId, fresh);
|
|
142
|
+
return fresh;
|
|
143
|
+
}
|
|
144
|
+
return existing;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function leafHasChildren(sessionManager: SessionManagerView, leafId: string | null): boolean {
|
|
148
|
+
if (!leafId) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
return sessionManager.getEntries().some((entry) => entry.parentId === leafId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function replaySnapshotFromBranch(branchEntries: SessionEntry[], startIndex: number): ReplayMemoEntry {
|
|
155
|
+
const knowledge: KnowledgeMap = new Map();
|
|
156
|
+
const blockedRangesByPath: RangeBlockersByPath = new Map();
|
|
157
|
+
const normalizedStart = Math.max(0, Math.min(startIndex, branchEntries.length));
|
|
158
|
+
let seq = 0;
|
|
159
|
+
|
|
160
|
+
for (let index = normalizedStart; index < branchEntries.length; index += 1) {
|
|
161
|
+
const entry = branchEntries[index];
|
|
162
|
+
if (!entry) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const meta = extractReadMetaFromSessionEntry(entry);
|
|
167
|
+
if (meta) {
|
|
168
|
+
seq += 1;
|
|
169
|
+
applyReadMetaTransition(knowledge, meta, seq, blockedRangesByPath);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const invalidation = extractInvalidationFromSessionEntry(entry);
|
|
174
|
+
if (invalidation) {
|
|
175
|
+
applyInvalidation(knowledge, invalidation, blockedRangesByPath);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
knowledge,
|
|
181
|
+
blockedRangesByPath,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function getReplayMemoEntryForLeaf(
|
|
186
|
+
sessionManager: SessionManagerView,
|
|
187
|
+
runtimeState: ReplayRuntimeState,
|
|
188
|
+
): { memoEntry: ReplayMemoEntry; sessionId: string; leafId: string | null } {
|
|
189
|
+
const sessionId = sessionManager.getSessionId();
|
|
190
|
+
const leafId = sessionManager.getLeafId();
|
|
191
|
+
const branchEntries = sessionManager.getBranch();
|
|
192
|
+
const boundary = findReplayStartIndex(branchEntries);
|
|
193
|
+
const memoKey = getMemoKey(sessionId, leafId, boundary.boundaryKey);
|
|
194
|
+
|
|
195
|
+
let memoEntry = runtimeState.memoByLeaf.get(memoKey);
|
|
196
|
+
if (!memoEntry) {
|
|
197
|
+
const replayMemo = replaySnapshotFromBranch(branchEntries, boundary.startIndex);
|
|
198
|
+
memoEntry = cloneReplayMemoEntry(replayMemo);
|
|
199
|
+
runtimeState.memoByLeaf.set(memoKey, memoEntry);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
memoEntry,
|
|
204
|
+
sessionId,
|
|
205
|
+
leafId,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function createReplayRuntimeState(): ReplayRuntimeState {
|
|
210
|
+
return {
|
|
211
|
+
memoByLeaf: new Map(),
|
|
212
|
+
overlayBySession: new Map(),
|
|
213
|
+
nextOverlaySeq: OVERLAY_SEQ_START,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function clearReplayRuntimeState(runtimeState: ReplayRuntimeState): void {
|
|
218
|
+
runtimeState.memoByLeaf.clear();
|
|
219
|
+
runtimeState.overlayBySession.clear();
|
|
220
|
+
runtimeState.nextOverlaySeq = OVERLAY_SEQ_START;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function findReplayStartIndex(branchEntries: SessionEntry[]): ReplayBoundary {
|
|
224
|
+
for (let index = branchEntries.length - 1; index >= 0; index -= 1) {
|
|
225
|
+
const entry = branchEntries[index];
|
|
226
|
+
if (!entry || entry.type !== "compaction") {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
startIndex: Math.min(index + 1, branchEntries.length),
|
|
232
|
+
boundaryKey: `compaction:${entry.id}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
startIndex: 0,
|
|
238
|
+
boundaryKey: "root",
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function applyReadMetaTransition(
|
|
243
|
+
knowledge: KnowledgeMap,
|
|
244
|
+
meta: RpReadcacheMetaV1,
|
|
245
|
+
seq: number,
|
|
246
|
+
blockedRangesByPath?: RangeBlockersByPath,
|
|
247
|
+
): void {
|
|
248
|
+
const { pathKey, scopeKey, servedHash, baseHash, mode } = meta;
|
|
249
|
+
const fullTrust = getTrust(knowledge, pathKey, SCOPE_FULL);
|
|
250
|
+
const rangeTrust = scopeKey === SCOPE_FULL ? undefined : getTrust(knowledge, pathKey, scopeKey);
|
|
251
|
+
|
|
252
|
+
if (mode === "full" || mode === "baseline_fallback") {
|
|
253
|
+
setTrust(knowledge, pathKey, scopeKey, servedHash, seq);
|
|
254
|
+
if (blockedRangesByPath && isRangeScope(scopeKey)) {
|
|
255
|
+
clearRangeBlocker(blockedRangesByPath, pathKey, scopeKey);
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (mode === "unchanged" && scopeKey === SCOPE_FULL) {
|
|
261
|
+
if (!baseHash) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (!fullTrust || fullTrust.hash !== baseHash) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (servedHash !== baseHash) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
setTrust(knowledge, pathKey, SCOPE_FULL, servedHash, seq);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (mode === "diff" && scopeKey === SCOPE_FULL) {
|
|
275
|
+
if (!baseHash) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (!fullTrust || fullTrust.hash !== baseHash) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
setTrust(knowledge, pathKey, SCOPE_FULL, servedHash, seq);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (mode === "unchanged_range" && scopeKey !== SCOPE_FULL) {
|
|
286
|
+
if (!baseHash) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (rangeTrust?.hash !== baseHash && fullTrust?.hash !== baseHash) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
setTrust(knowledge, pathKey, scopeKey, servedHash, seq);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function applyInvalidation(
|
|
297
|
+
knowledge: KnowledgeMap,
|
|
298
|
+
invalidation: RpReadcacheInvalidationV1,
|
|
299
|
+
blockedRangesByPath?: RangeBlockersByPath,
|
|
300
|
+
): void {
|
|
301
|
+
const scopes = knowledge.get(invalidation.pathKey);
|
|
302
|
+
|
|
303
|
+
if (invalidation.scopeKey === SCOPE_FULL) {
|
|
304
|
+
knowledge.delete(invalidation.pathKey);
|
|
305
|
+
blockedRangesByPath?.delete(invalidation.pathKey);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (blockedRangesByPath && isRangeScope(invalidation.scopeKey)) {
|
|
310
|
+
setRangeBlocker(blockedRangesByPath, invalidation.pathKey, invalidation.scopeKey);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!scopes) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
scopes.delete(invalidation.scopeKey);
|
|
318
|
+
if (scopes.size === 0) {
|
|
319
|
+
knowledge.delete(invalidation.pathKey);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function buildKnowledgeForLeaf(sessionManager: SessionManagerView, runtimeState: ReplayRuntimeState): KnowledgeMap {
|
|
324
|
+
const { memoEntry, sessionId, leafId } = getReplayMemoEntryForLeaf(sessionManager, runtimeState);
|
|
325
|
+
const overlayState = ensureOverlayForLeaf(runtimeState, sessionId, leafId);
|
|
326
|
+
if (leafHasChildren(sessionManager, leafId)) {
|
|
327
|
+
overlayState.knowledge.clear();
|
|
328
|
+
}
|
|
329
|
+
return mergeKnowledge(memoEntry.knowledge, overlayState.knowledge);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function isRangeScopeBlockedByInvalidation(
|
|
333
|
+
sessionManager: SessionManagerView,
|
|
334
|
+
runtimeState: ReplayRuntimeState,
|
|
335
|
+
pathKey: string,
|
|
336
|
+
scopeKey: ScopeKey,
|
|
337
|
+
): boolean {
|
|
338
|
+
if (!isRangeScope(scopeKey)) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const { memoEntry, sessionId, leafId } = getReplayMemoEntryForLeaf(sessionManager, runtimeState);
|
|
343
|
+
const blockedScopes = memoEntry.blockedRangesByPath.get(pathKey);
|
|
344
|
+
if (!blockedScopes?.has(scopeKey)) {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const overlayState = ensureOverlayForLeaf(runtimeState, sessionId, leafId);
|
|
349
|
+
const overlayScopeTrust = overlayState.knowledge.get(pathKey)?.get(scopeKey);
|
|
350
|
+
return overlayScopeTrust === undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function overlaySet(
|
|
354
|
+
runtimeState: ReplayRuntimeState,
|
|
355
|
+
sessionManager: SessionManagerView,
|
|
356
|
+
pathKey: string,
|
|
357
|
+
scopeKey: ScopeKey,
|
|
358
|
+
servedHash: string,
|
|
359
|
+
): void {
|
|
360
|
+
const sessionId = sessionManager.getSessionId();
|
|
361
|
+
const leafId = sessionManager.getLeafId();
|
|
362
|
+
const overlayState = ensureOverlayForLeaf(runtimeState, sessionId, leafId);
|
|
363
|
+
const seq = runtimeState.nextOverlaySeq;
|
|
364
|
+
runtimeState.nextOverlaySeq += 1;
|
|
365
|
+
setTrust(overlayState.knowledge, pathKey, scopeKey, servedHash, seq);
|
|
366
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// readcache/resolve.ts - resolve rp-cli read_file paths to local absolute paths
|
|
2
|
+
|
|
3
|
+
import { access } from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
export interface ResolvePathResult {
|
|
9
|
+
absolutePath: string | null;
|
|
10
|
+
repoRoot: string | null;
|
|
11
|
+
pathDisplay: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function fileExists(absolutePath: string): Promise<boolean> {
|
|
15
|
+
try {
|
|
16
|
+
await access(absolutePath);
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeRootLine(line: string): string | null {
|
|
24
|
+
let trimmed = line.trim();
|
|
25
|
+
|
|
26
|
+
if (!trimmed) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Handle bullet lists like "- /path" or "• /path"
|
|
31
|
+
trimmed = trimmed.replace(/^[-*•]\s+/, "");
|
|
32
|
+
|
|
33
|
+
// Expand home
|
|
34
|
+
if (trimmed.startsWith("~")) {
|
|
35
|
+
// We intentionally don't expand here; rp-cli roots should be absolute already
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (path.isAbsolute(trimmed)) {
|
|
40
|
+
return trimmed;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseRootList(text: string): string[] {
|
|
47
|
+
const roots = new Set<string>();
|
|
48
|
+
|
|
49
|
+
for (const line of text.split("\n")) {
|
|
50
|
+
const root = normalizeRootLine(line);
|
|
51
|
+
if (root) {
|
|
52
|
+
roots.add(root);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return [...roots];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ROOTS_CACHE = new Map<number, string[]>();
|
|
60
|
+
|
|
61
|
+
export function clearRootsCache(windowId?: number): void {
|
|
62
|
+
if (windowId !== undefined) {
|
|
63
|
+
ROOTS_CACHE.delete(windowId);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
ROOTS_CACHE.clear();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function fetchWindowRootsViaCli(pi: ExtensionAPI, windowId: number, tab?: string): Promise<string[]> {
|
|
70
|
+
const args: string[] = ["-w", String(windowId)];
|
|
71
|
+
if (tab) {
|
|
72
|
+
args.push("-t", tab);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Use rp-cli exec mode to call the underlying MCP tool. This reliably returns one absolute root per line.
|
|
76
|
+
// (Using `tree type=roots` is NOT supported by rp-cli and can return an error string instead of roots.)
|
|
77
|
+
args.push("-q", "-e", 'call get_file_tree {"type":"roots"}');
|
|
78
|
+
|
|
79
|
+
const result = await pi.exec("rp-cli", args, { timeout: 10_000 });
|
|
80
|
+
const stdout = result.stdout ?? "";
|
|
81
|
+
const stderr = result.stderr ?? "";
|
|
82
|
+
const output = [stdout, stderr].filter(Boolean).join("\n");
|
|
83
|
+
|
|
84
|
+
return parseRootList(output);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function getWindowRootsCached(pi: ExtensionAPI, windowId: number, tab?: string): Promise<string[]> {
|
|
88
|
+
const cached = ROOTS_CACHE.get(windowId);
|
|
89
|
+
if (cached) {
|
|
90
|
+
return cached;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const roots = await fetchWindowRootsViaCli(pi, windowId, tab);
|
|
94
|
+
ROOTS_CACHE.set(windowId, roots);
|
|
95
|
+
return roots;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseRootPrefixedPath(rawPath: string): { rootHint: string; relPath: string } | null {
|
|
99
|
+
// rootHint:rel/path
|
|
100
|
+
const colonIdx = rawPath.indexOf(":");
|
|
101
|
+
if (colonIdx > 0) {
|
|
102
|
+
const rootHint = rawPath.slice(0, colonIdx).trim();
|
|
103
|
+
const relPath = rawPath.slice(colonIdx + 1).replace(/^\/+/, "");
|
|
104
|
+
if (rootHint && relPath) {
|
|
105
|
+
return { rootHint, relPath };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// rootHint/rel/path (common in RP outputs)
|
|
110
|
+
const parts = rawPath.split(/[\\/]+/).filter(Boolean);
|
|
111
|
+
if (parts.length >= 2) {
|
|
112
|
+
return { rootHint: parts[0] ?? "", relPath: parts.slice(1).join("/") };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function pickRepoRootForFile(absolutePath: string, roots: string[]): string | null {
|
|
119
|
+
const normalized = path.resolve(absolutePath);
|
|
120
|
+
|
|
121
|
+
let best: string | null = null;
|
|
122
|
+
let bestDepth = -1;
|
|
123
|
+
|
|
124
|
+
for (const root of roots) {
|
|
125
|
+
const resolvedRoot = path.resolve(root);
|
|
126
|
+
|
|
127
|
+
if (normalized === resolvedRoot) {
|
|
128
|
+
const depth = resolvedRoot.split(path.sep).filter(Boolean).length;
|
|
129
|
+
if (depth > bestDepth) {
|
|
130
|
+
best = resolvedRoot;
|
|
131
|
+
bestDepth = depth;
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const rel = path.relative(resolvedRoot, normalized);
|
|
137
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const depth = resolvedRoot.split(path.sep).filter(Boolean).length;
|
|
142
|
+
if (depth > bestDepth) {
|
|
143
|
+
best = resolvedRoot;
|
|
144
|
+
bestDepth = depth;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return best;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function resolveReadFilePath(
|
|
152
|
+
pi: ExtensionAPI,
|
|
153
|
+
inputPath: string,
|
|
154
|
+
cwd: string,
|
|
155
|
+
windowId: number,
|
|
156
|
+
tab?: string,
|
|
157
|
+
): Promise<ResolvePathResult> {
|
|
158
|
+
const pathDisplay = inputPath;
|
|
159
|
+
|
|
160
|
+
const roots = await getWindowRootsCached(pi, windowId, tab);
|
|
161
|
+
|
|
162
|
+
if (path.isAbsolute(inputPath)) {
|
|
163
|
+
const absolutePath = (await fileExists(inputPath)) ? inputPath : null;
|
|
164
|
+
if (!absolutePath) {
|
|
165
|
+
return { absolutePath: null, repoRoot: null, pathDisplay };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
absolutePath,
|
|
170
|
+
repoRoot: pickRepoRootForFile(absolutePath, roots),
|
|
171
|
+
pathDisplay,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// rootHint:relPath or rootHint/relPath
|
|
176
|
+
const rootPrefixed = parseRootPrefixedPath(inputPath);
|
|
177
|
+
if (rootPrefixed) {
|
|
178
|
+
const { rootHint, relPath } = rootPrefixed;
|
|
179
|
+
|
|
180
|
+
const matches: Array<{ abs: string; root: string }> = [];
|
|
181
|
+
|
|
182
|
+
for (const root of roots) {
|
|
183
|
+
const baseName = path.basename(root);
|
|
184
|
+
if (baseName !== rootHint) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const abs = path.join(root, relPath);
|
|
189
|
+
if (await fileExists(abs)) {
|
|
190
|
+
matches.push({ abs, root });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (matches.length === 1) {
|
|
195
|
+
const match = matches[0];
|
|
196
|
+
return {
|
|
197
|
+
absolutePath: match?.abs ?? null,
|
|
198
|
+
repoRoot: match?.root ?? null,
|
|
199
|
+
pathDisplay,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (matches.length > 1) {
|
|
204
|
+
// Ambiguous: same rootHint resolves to multiple roots
|
|
205
|
+
return { absolutePath: null, repoRoot: null, pathDisplay };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Try path under each root
|
|
210
|
+
const matches: Array<{ abs: string; root: string }> = [];
|
|
211
|
+
for (const root of roots) {
|
|
212
|
+
const abs = path.join(root, inputPath);
|
|
213
|
+
if (await fileExists(abs)) {
|
|
214
|
+
matches.push({ abs, root });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (matches.length === 1) {
|
|
219
|
+
const match = matches[0];
|
|
220
|
+
return {
|
|
221
|
+
absolutePath: match?.abs ?? null,
|
|
222
|
+
repoRoot: match?.root ?? null,
|
|
223
|
+
pathDisplay,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (matches.length > 1) {
|
|
228
|
+
// Ambiguous: multiple roots contain this relative path
|
|
229
|
+
// Fail open: do NOT fall back to Pi's process cwd, as that can mismatch RepoPrompt's own path resolution
|
|
230
|
+
return { absolutePath: null, repoRoot: null, pathDisplay };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// No match under any root
|
|
234
|
+
return { absolutePath: null, repoRoot: null, pathDisplay };
|
|
235
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// readcache/text.ts - line helpers + truncation
|
|
2
|
+
|
|
3
|
+
import { truncateHead, type TruncationOptions, type TruncationResult } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
function validateRange(start: number, end: number): void {
|
|
6
|
+
if (!Number.isInteger(start) || start <= 0) {
|
|
7
|
+
throw new Error(`Invalid start line "${start}". Line numbers must be positive integers.`);
|
|
8
|
+
}
|
|
9
|
+
if (!Number.isInteger(end) || end <= 0) {
|
|
10
|
+
throw new Error(`Invalid end line "${end}". Line numbers must be positive integers.`);
|
|
11
|
+
}
|
|
12
|
+
if (end < start) {
|
|
13
|
+
throw new Error(`Invalid range ${start}-${end}. End line must be greater than or equal to start line.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function splitLines(text: string): string[] {
|
|
18
|
+
// Match typical editor line counting semantics: a trailing newline does not create an extra empty line
|
|
19
|
+
const lines = text.split("\n");
|
|
20
|
+
if (lines.length > 1 && lines[lines.length - 1] === "") {
|
|
21
|
+
return lines.slice(0, -1);
|
|
22
|
+
}
|
|
23
|
+
return lines;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sliceByLineRange(text: string, start: number, end: number): string {
|
|
27
|
+
validateRange(start, end);
|
|
28
|
+
const lines = splitLines(text);
|
|
29
|
+
if (start > lines.length) {
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const clampedEnd = Math.min(end, lines.length);
|
|
34
|
+
return lines.slice(start - 1, clampedEnd).join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function compareSlices(oldText: string, newText: string, start: number, end: number): boolean {
|
|
38
|
+
return sliceByLineRange(oldText, start, end) === sliceByLineRange(newText, start, end);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function truncateForReadcache(content: string, options?: TruncationOptions): TruncationResult {
|
|
42
|
+
return truncateHead(content, options);
|
|
43
|
+
}
|