pi-repoprompt-cli 0.2.6 → 0.2.8
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 +6 -1
- package/extensions/repoprompt-cli/README.md +20 -1
- package/extensions/repoprompt-cli/auto-select.ts +288 -0
- package/extensions/repoprompt-cli/config.json.example +2 -1
- package/extensions/repoprompt-cli/config.ts +1 -0
- package/extensions/repoprompt-cli/index.ts +1656 -192
- package/extensions/repoprompt-cli/types.ts +34 -2
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -7,8 +7,10 @@ Provides two tools:
|
|
|
7
7
|
- `rp_exec` — run `rp-cli -e <cmd>` against that binding (quiet defaults + output truncation)
|
|
8
8
|
|
|
9
9
|
Optional:
|
|
10
|
+
- Diff blocks in `rp_exec` output use `delta` when installed (honoring the user's global git/delta color config), with graceful fallback otherwise
|
|
10
11
|
- [Gurpartap/pi-readcache](https://github.com/Gurpartap/pi-readcache)-like caching for `rp_exec` calls that read files (`read` / `cat` / `read_file`) to save on tokens
|
|
11
12
|
- returns unchanged markers and diffs on repeat reads
|
|
13
|
+
- Auto-selection (in the RP app, e.g. for use in RP Chat) of slices/files the agent has read; these selections are also branch-safe across `/tree` navigation and `/fork`ed session
|
|
12
14
|
|
|
13
15
|
Also provides convenience commands:
|
|
14
16
|
- `/rpbind <window_id> <tab>`
|
|
@@ -53,7 +55,8 @@ Create `~/.pi/agent/extensions/repoprompt-cli/config.json`:
|
|
|
53
55
|
|
|
54
56
|
```json
|
|
55
57
|
{
|
|
56
|
-
"readcacheReadFile": true
|
|
58
|
+
"readcacheReadFile": true,
|
|
59
|
+
"autoSelectReadSlices": true
|
|
57
60
|
}
|
|
58
61
|
```
|
|
59
62
|
|
|
@@ -94,6 +97,8 @@ Notes:
|
|
|
94
97
|
- Readcache only triggers for **single-command** reads. Compound commands (`&&`, `;`, `|`) fail open to baseline output
|
|
95
98
|
- When `just-bash` AST parsing is unavailable, caching only applies to unquoted/unescaped single-command reads; quoted/escaped forms fail open
|
|
96
99
|
- `rawJson=true` disables caching
|
|
100
|
+
- Read-driven selection replay is enabled by default (`autoSelectReadSlices: true`); set it to `false` to disable
|
|
101
|
+
- Manual selection is always preserved for paths not managed by this feature; for managed paths, branch replay may restore the branch snapshot
|
|
97
102
|
|
|
98
103
|
## Readcache gotchas
|
|
99
104
|
|
|
@@ -7,6 +7,10 @@ It provides two Pi tools:
|
|
|
7
7
|
- `rp_bind` — bind to a specific RepoPrompt **window id** + **compose tab** (routing)
|
|
8
8
|
- `rp_exec` — run `rp-cli -e <cmd>` against that binding
|
|
9
9
|
|
|
10
|
+
Diff blocks in `rp_exec` output use `delta` when installed (honoring the user's global git/delta color config), with graceful fallback otherwise
|
|
11
|
+
|
|
12
|
+
When enabled (default), `rp_exec` also auto-tracks `read` / `cat` / `read_file` calls and updates RepoPrompt selection with owned file/slice context. The owned selection state is branch-safe across `/tree` and `/fork`, and replays after reconnect/restart using workspace-aware rebinding
|
|
13
|
+
|
|
10
14
|
## Optional: readcache for `rp_exec read`
|
|
11
15
|
|
|
12
16
|
If enabled, `rp_exec` will apply [Gurpartap/pi-readcache](https://github.com/Gurpartap/pi-readcache)-like token savings for single-command file reads, returning:
|
|
@@ -23,7 +27,8 @@ Create:
|
|
|
23
27
|
|
|
24
28
|
```json
|
|
25
29
|
{
|
|
26
|
-
"readcacheReadFile": true
|
|
30
|
+
"readcacheReadFile": true,
|
|
31
|
+
"autoSelectReadSlices": true
|
|
27
32
|
}
|
|
28
33
|
```
|
|
29
34
|
|
|
@@ -49,6 +54,20 @@ Add `bypass_cache=true` to the `cmd`:
|
|
|
49
54
|
rp_exec cmd="read path=src/main.ts start_line=1 limit=120 bypass_cache=true"
|
|
50
55
|
```
|
|
51
56
|
|
|
57
|
+
## Auto-selection with branch-safe replay
|
|
58
|
+
|
|
59
|
+
This feature is enabled by default (no config change needed). To disable it, set `"autoSelectReadSlices": false` in your `config.json`.
|
|
60
|
+
|
|
61
|
+
Behavior:
|
|
62
|
+
|
|
63
|
+
- `read_file`/`read`/`cat` with range → tracked as slice selection in the RP app (e.g., for use as context in RP Chat)
|
|
64
|
+
- full reads (no representable range) → tracked as full-file selection in the RP app
|
|
65
|
+
- tail reads (`start_line < 0`) convert to explicit ranges when file line count is available
|
|
66
|
+
- replay is owned-state-only: only paths/slices added by the extension are reconciled (manual selection outside owned state is preserved)
|
|
67
|
+
- for a path already managed by this feature, later branch replay may restore that path to the branch snapshot (including overriding manual tweaks on that same path)
|
|
68
|
+
- branch-local snapshots are restored across `/tree` navigation and `/fork` branch divergence
|
|
69
|
+
- if RepoPrompt restarts and window IDs change, replay remaps by workspace identity when possible
|
|
70
|
+
|
|
52
71
|
## Readcache gotchas
|
|
53
72
|
|
|
54
73
|
- `rawJson=true` disables readcache. Don't use unless debugging
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// auto-select.ts - helpers for automatically selecting read_file context in RepoPrompt
|
|
2
|
+
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import { stat } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
export type SelectionMode = "full" | "slices" | "codemap_only";
|
|
7
|
+
|
|
8
|
+
export interface SelectionStatus {
|
|
9
|
+
mode: SelectionMode;
|
|
10
|
+
|
|
11
|
+
// Only relevant for codemap-only selection entries
|
|
12
|
+
codemapManual?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SliceRange {
|
|
16
|
+
start_line: number;
|
|
17
|
+
end_line: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function toPosixPath(inputPath: string): string {
|
|
21
|
+
return inputPath.replace(/\\/g, "/");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function inferSelectionStatus(selectionText: string, selectionPath: string): SelectionStatus | null {
|
|
25
|
+
if (!selectionText || !selectionPath) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const target = toPosixPath(selectionPath).replace(/\/+$/, "");
|
|
30
|
+
|
|
31
|
+
const normalizeDir = (dir: string): string => {
|
|
32
|
+
const normalized = toPosixPath(dir).trim();
|
|
33
|
+
return normalized.endsWith("/") ? normalized : `${normalized}/`;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const joinPath = (dir: string, name: string): string => {
|
|
37
|
+
if (!dir) {
|
|
38
|
+
return name;
|
|
39
|
+
}
|
|
40
|
+
return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const lines = selectionText.split("\n");
|
|
44
|
+
|
|
45
|
+
let section: "selected" | "codemaps" | null = null;
|
|
46
|
+
|
|
47
|
+
// Base directory for the current tree (RepoPrompt prints a root line like `agent/src/`)
|
|
48
|
+
let baseDir: string | null = null;
|
|
49
|
+
|
|
50
|
+
// Prefix stack for tree-indented directory lines
|
|
51
|
+
const prefixes: string[] = [];
|
|
52
|
+
|
|
53
|
+
const stripTrailingSelectionMetadata = (value: string): string => {
|
|
54
|
+
let current = value.trim();
|
|
55
|
+
|
|
56
|
+
// RepoPrompt usually separates metadata with an em dash, but not always
|
|
57
|
+
// We normalize both forms:
|
|
58
|
+
// file.ts — 120 tokens (lines 1-20)
|
|
59
|
+
// file.ts (lines 1-20)
|
|
60
|
+
const dashIndex = current.indexOf(" — ");
|
|
61
|
+
if (dashIndex !== -1) {
|
|
62
|
+
current = current.slice(0, dashIndex).trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const metadataPatterns = [
|
|
66
|
+
// Slice metadata at end of line
|
|
67
|
+
// Examples:
|
|
68
|
+
// file.ts (lines 1-20)
|
|
69
|
+
// file.ts (lines 1-20, 120-129)
|
|
70
|
+
// file.ts (line 5)
|
|
71
|
+
/\s+\(lines?\s+\d+\s*-\s*\d+(?:\s*,\s*\d+\s*-\s*\d+)*\)\s*$/i,
|
|
72
|
+
/\s+\(line\s+\d+\)\s*$/i,
|
|
73
|
+
|
|
74
|
+
/\s+\((?:manual|auto|full)\)\s*$/i,
|
|
75
|
+
|
|
76
|
+
// Token metadata at end of line
|
|
77
|
+
/\s+\d[\d,]*\s+tokens(?:\s+\((?:manual|auto|full|lines?\s+\d+\s*-\s*\d+(?:\s*,\s*\d+\s*-\s*\d+)*)\))?\s*$/i,
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
let changed = true;
|
|
81
|
+
while (changed) {
|
|
82
|
+
changed = false;
|
|
83
|
+
|
|
84
|
+
for (const pattern of metadataPatterns) {
|
|
85
|
+
const next = current.replace(pattern, "").trimEnd();
|
|
86
|
+
if (next !== current) {
|
|
87
|
+
current = next;
|
|
88
|
+
changed = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return current.trim();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const considerMatch = (nodePath: string, rest: string): SelectionStatus | null => {
|
|
97
|
+
const normalizedNode = toPosixPath(nodePath).replace(/\/+$/, "");
|
|
98
|
+
if (normalizedNode !== target) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (section === "selected") {
|
|
103
|
+
// RepoPrompt may render multiple ranges as: (lines 1-20, 120-129)
|
|
104
|
+
// We treat anything that looks like a line/lines annotation as slice selection
|
|
105
|
+
if (/\(\s*lines?\s+\d+/i.test(rest) || /\(\s*line\s+\d+/i.test(rest)) {
|
|
106
|
+
return { mode: "slices" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { mode: "full" };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (section === "codemaps") {
|
|
113
|
+
return {
|
|
114
|
+
mode: "codemap_only",
|
|
115
|
+
codemapManual: /\(manual\)/i.test(rest),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
for (const rawLine of lines) {
|
|
123
|
+
const line = toPosixPath(rawLine).trimEnd();
|
|
124
|
+
|
|
125
|
+
if (line.includes("### Selected Files")) {
|
|
126
|
+
section = "selected";
|
|
127
|
+
baseDir = null;
|
|
128
|
+
prefixes.length = 0;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (line.includes("### Codemaps")) {
|
|
133
|
+
section = "codemaps";
|
|
134
|
+
baseDir = null;
|
|
135
|
+
prefixes.length = 0;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Root directory line: `agent/extensions/.../`
|
|
140
|
+
if (line.endsWith("/") && !line.includes("──") && !line.includes(" — ")) {
|
|
141
|
+
baseDir = normalizeDir(line);
|
|
142
|
+
prefixes.length = 0;
|
|
143
|
+
prefixes.push(baseDir);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const markerMatch = line.match(/(├──|└──)\s+(.*)$/);
|
|
148
|
+
if (!markerMatch) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const marker = markerMatch[1] ?? "";
|
|
153
|
+
const markerIdx = line.indexOf(marker);
|
|
154
|
+
const indentPrefix = markerIdx >= 0 ? line.slice(0, markerIdx) : "";
|
|
155
|
+
|
|
156
|
+
// Tree indentation uses 4-char groups: `│ ` or ` `
|
|
157
|
+
const indentDepth = Math.floor(indentPrefix.length / 4);
|
|
158
|
+
|
|
159
|
+
const rest = (markerMatch[2] ?? "").trim();
|
|
160
|
+
const name = stripTrailingSelectionMetadata(rest);
|
|
161
|
+
|
|
162
|
+
if (!name) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const parentPrefix = prefixes[indentDepth] ?? baseDir;
|
|
167
|
+
if (!parentPrefix) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const nodePath = joinPath(parentPrefix, name);
|
|
172
|
+
|
|
173
|
+
if (name.endsWith("/")) {
|
|
174
|
+
prefixes[indentDepth + 1] = normalizeDir(nodePath);
|
|
175
|
+
prefixes.length = indentDepth + 2;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const match = considerMatch(nodePath, rest);
|
|
180
|
+
if (match) {
|
|
181
|
+
return match;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const LINE_COUNT_CACHE = new Map<string, { mtimeMs: number; size: number; lines: number }>();
|
|
189
|
+
const LINE_COUNT_CACHE_MAX_ENTRIES = 512;
|
|
190
|
+
|
|
191
|
+
async function countFileLinesUncached(absolutePath: string): Promise<number> {
|
|
192
|
+
return await new Promise<number>((resolve, reject) => {
|
|
193
|
+
let newlineCount = 0;
|
|
194
|
+
let sawData = false;
|
|
195
|
+
let lastByte: number | null = null;
|
|
196
|
+
|
|
197
|
+
const stream = fs.createReadStream(absolutePath);
|
|
198
|
+
|
|
199
|
+
stream.on("data", (chunk: Buffer | string) => {
|
|
200
|
+
const buf = (typeof chunk === "string") ? Buffer.from(chunk, "utf8") : chunk;
|
|
201
|
+
|
|
202
|
+
sawData = true;
|
|
203
|
+
|
|
204
|
+
for (let i = 0; i < buf.length; i++) {
|
|
205
|
+
if (buf[i] === 10) {
|
|
206
|
+
newlineCount += 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (buf.length > 0) {
|
|
211
|
+
lastByte = buf[buf.length - 1] ?? lastByte;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
stream.on("error", (err) => reject(err));
|
|
216
|
+
|
|
217
|
+
stream.on("end", () => {
|
|
218
|
+
if (!sawData) {
|
|
219
|
+
resolve(0);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const lines = (lastByte === 10) ? newlineCount : newlineCount + 1;
|
|
224
|
+
resolve(lines);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function countFileLines(absolutePath: string): Promise<number> {
|
|
230
|
+
const st = await stat(absolutePath);
|
|
231
|
+
|
|
232
|
+
const cached = LINE_COUNT_CACHE.get(absolutePath);
|
|
233
|
+
if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
|
|
234
|
+
return cached.lines;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const lines = await countFileLinesUncached(absolutePath);
|
|
238
|
+
LINE_COUNT_CACHE.set(absolutePath, { mtimeMs: st.mtimeMs, size: st.size, lines });
|
|
239
|
+
|
|
240
|
+
while (LINE_COUNT_CACHE.size > LINE_COUNT_CACHE_MAX_ENTRIES) {
|
|
241
|
+
const oldestKey = LINE_COUNT_CACHE.keys().next().value;
|
|
242
|
+
if (oldestKey === undefined) {
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
LINE_COUNT_CACHE.delete(oldestKey);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return lines;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function computeSliceRangeFromReadArgs(
|
|
252
|
+
startLine: number | undefined,
|
|
253
|
+
limit: number | undefined,
|
|
254
|
+
totalLines: number | undefined
|
|
255
|
+
): SliceRange | null {
|
|
256
|
+
if (typeof startLine !== "number") {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Positive range reads
|
|
261
|
+
if (startLine > 0) {
|
|
262
|
+
if (typeof limit !== "number" || limit <= 0) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
start_line: startLine,
|
|
268
|
+
end_line: startLine + limit - 1,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Tail reads (-N)
|
|
273
|
+
if (startLine < 0) {
|
|
274
|
+
if (typeof totalLines !== "number" || totalLines <= 0) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const n = Math.abs(startLine);
|
|
279
|
+
const start = Math.max(1, totalLines - n + 1);
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
start_line: start,
|
|
283
|
+
end_line: totalLines,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return null;
|
|
288
|
+
}
|