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.
@@ -0,0 +1,23 @@
1
+ Derived from pi-readcache (https://github.com/Gurpartap/pi-readcache)
2
+
3
+ MIT License
4
+
5
+ Copyright (c) 2026 Gurpartap Singh
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
@@ -0,0 +1,38 @@
1
+ // readcache/constants.ts - constants for read_file caching via rp-cli
2
+
3
+ export const RP_READCACHE_META_VERSION = 1 as const;
4
+ export const RP_READCACHE_CUSTOM_TYPE = "repoprompt-cli-readcache" as const;
5
+
6
+ export const SCOPE_FULL = "full" as const;
7
+
8
+ export const MAX_DIFF_FILE_BYTES = 2 * 1024 * 1024;
9
+ export const MAX_DIFF_FILE_LINES = 12_000;
10
+ export const MAX_DIFF_TO_BASE_RATIO = 0.9;
11
+ export const MAX_DIFF_TO_BASE_LINE_RATIO = 0.85;
12
+
13
+ export const DEFAULT_EXCLUDED_PATH_PATTERNS = [
14
+ ".env*",
15
+ "*.pem",
16
+ "*.key",
17
+ "*.p12",
18
+ "*.pfx",
19
+ "*.crt",
20
+ "*.cer",
21
+ "*.der",
22
+ "*.pk8",
23
+ "id_rsa",
24
+ "id_ed25519",
25
+ ".npmrc",
26
+ ".netrc",
27
+ ] as const;
28
+
29
+ // NOTE: share the same on-disk object store layout as pi-readcache
30
+ export const READCACHE_ROOT_DIR = ".pi/readcache";
31
+ export const READCACHE_OBJECTS_DIR = `${READCACHE_ROOT_DIR}/objects`;
32
+ export const READCACHE_TMP_DIR = `${READCACHE_ROOT_DIR}/tmp`;
33
+
34
+ export const READCACHE_OBJECT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
35
+
36
+ export function scopeRange(start: number, end: number): `r:${number}:${number}` {
37
+ return `r:${start}:${end}`;
38
+ }
@@ -0,0 +1,129 @@
1
+ // readcache/diff.ts - unified diff computation + usefulness gating
2
+
3
+ import { formatPatch, structuredPatch } from "diff";
4
+
5
+ import {
6
+ MAX_DIFF_FILE_BYTES,
7
+ MAX_DIFF_FILE_LINES,
8
+ MAX_DIFF_TO_BASE_LINE_RATIO,
9
+ MAX_DIFF_TO_BASE_RATIO,
10
+ } from "./constants.js";
11
+
12
+ const PATCH_SEPARATOR_LINE = "===================================================================";
13
+
14
+ export interface DiffComputation {
15
+ diffText: string;
16
+ changedLines: number;
17
+ addedLines: number;
18
+ removedLines: number;
19
+ diffBytes: number;
20
+ }
21
+
22
+ function sanitizePathForPatch(pathDisplay: string): string {
23
+ const trimmed = pathDisplay.trim();
24
+ if (!trimmed) {
25
+ return "unknown";
26
+ }
27
+ return trimmed.replace(/[\t\r\n]/g, "_");
28
+ }
29
+
30
+ function stripPatchSeparator(patch: string): string {
31
+ const lines = patch.split("\n");
32
+ if (lines[0] === PATCH_SEPARATOR_LINE) {
33
+ return lines.slice(1).join("\n").trimEnd();
34
+ }
35
+ return patch.trimEnd();
36
+ }
37
+
38
+ function lineCount(text: string): number {
39
+ if (text.length === 0) {
40
+ return 1;
41
+ }
42
+ return text.split("\n").length;
43
+ }
44
+
45
+ function countChangedLinesFromPatch(diffText: string): { added: number; removed: number } {
46
+ let added = 0;
47
+ let removed = 0;
48
+ for (const line of diffText.split("\n")) {
49
+ if (!line) {
50
+ continue;
51
+ }
52
+ if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@")) {
53
+ continue;
54
+ }
55
+ if (line.startsWith("+")) {
56
+ added += 1;
57
+ continue;
58
+ }
59
+ if (line.startsWith("-")) {
60
+ removed += 1;
61
+ }
62
+ }
63
+ return { added, removed };
64
+ }
65
+
66
+ export function computeUnifiedDiff(
67
+ baseText: string,
68
+ currentText: string,
69
+ pathDisplay: string,
70
+ ): DiffComputation | undefined {
71
+ const safePath = sanitizePathForPatch(pathDisplay);
72
+ const patch = structuredPatch(`a/${safePath}`, `b/${safePath}`, baseText, currentText, "", "", { context: 3 });
73
+ if (patch.hunks.length === 0) {
74
+ return undefined;
75
+ }
76
+
77
+ const diffText = stripPatchSeparator(formatPatch(patch));
78
+ if (!diffText.includes("@@")) {
79
+ return undefined;
80
+ }
81
+
82
+ const { added, removed } = countChangedLinesFromPatch(diffText);
83
+ return {
84
+ diffText,
85
+ changedLines: Math.max(added, removed),
86
+ addedLines: added,
87
+ removedLines: removed,
88
+ diffBytes: Buffer.byteLength(diffText, "utf-8"),
89
+ };
90
+ }
91
+
92
+ export function isDiffUseful(diffText: string, baseText: string, currentText: string): boolean {
93
+ if (diffText.trim().length === 0 || !diffText.includes("@@")) {
94
+ return false;
95
+ }
96
+
97
+ const baseBytes = Buffer.byteLength(baseText, "utf-8");
98
+ const currentBytes = Buffer.byteLength(currentText, "utf-8");
99
+ const selectedBytes = Math.max(baseBytes, currentBytes);
100
+ if (selectedBytes === 0) {
101
+ return false;
102
+ }
103
+
104
+ if (selectedBytes > MAX_DIFF_FILE_BYTES) {
105
+ return false;
106
+ }
107
+
108
+ const maxLines = Math.max(lineCount(baseText), lineCount(currentText));
109
+ if (maxLines > MAX_DIFF_FILE_LINES) {
110
+ return false;
111
+ }
112
+
113
+ const diffBytes = Buffer.byteLength(diffText, "utf-8");
114
+ if (diffBytes === 0) {
115
+ return false;
116
+ }
117
+
118
+ if (diffBytes >= selectedBytes * MAX_DIFF_TO_BASE_RATIO) {
119
+ return false;
120
+ }
121
+
122
+ const requestedLines = lineCount(currentText);
123
+ const diffLines = lineCount(diffText);
124
+ if (diffLines > requestedLines * MAX_DIFF_TO_BASE_LINE_RATIO) {
125
+ return false;
126
+ }
127
+
128
+ return true;
129
+ }
@@ -0,0 +1,241 @@
1
+ // readcache/meta.ts - validation + extraction from session entries
2
+
3
+ import type { SessionEntry } from "@mariozechner/pi-coding-agent";
4
+
5
+ import { RP_READCACHE_CUSTOM_TYPE, RP_READCACHE_META_VERSION, SCOPE_FULL } from "./constants.js";
6
+ import type {
7
+ ReadCacheDebugV1,
8
+ ReadCacheMode,
9
+ RpReadcacheInvalidationV1,
10
+ RpReadcacheMetaV1,
11
+ ScopeKey,
12
+ } from "./types.js";
13
+
14
+ const RANGE_SCOPE_RE = /^r:(\d+):(\d+)$/;
15
+
16
+ function isRecord(value: unknown): value is Record<string, unknown> {
17
+ return typeof value === "object" && value !== null;
18
+ }
19
+
20
+ function isPositiveInteger(value: unknown): value is number {
21
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
22
+ }
23
+
24
+ function isNonNegativeInteger(value: unknown): value is number {
25
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
26
+ }
27
+
28
+ function normalizeReadCacheMode(value: unknown): ReadCacheMode | undefined {
29
+ if (value === "full") return "full";
30
+ if (value === "unchanged") return "unchanged";
31
+ if (value === "unchanged_range") return "unchanged_range";
32
+ if (value === "diff") return "diff";
33
+ if (value === "baseline_fallback") return "baseline_fallback";
34
+ return undefined;
35
+ }
36
+
37
+ function isReadCacheDebugReason(value: unknown): value is ReadCacheDebugV1["reason"] {
38
+ return (
39
+ value === "no_base_hash" ||
40
+ value === "hash_match" ||
41
+ value === "base_object_missing" ||
42
+ value === "range_slice_unchanged" ||
43
+ value === "range_slice_changed" ||
44
+ value === "diff_file_too_large_bytes" ||
45
+ value === "diff_file_too_large_lines" ||
46
+ value === "diff_unavailable_or_empty" ||
47
+ value === "diff_not_useful" ||
48
+ value === "diff_payload_truncated" ||
49
+ value === "diff_emitted" ||
50
+ value === "bypass_cache"
51
+ );
52
+ }
53
+
54
+ function isOptionalBoolean(value: unknown): value is boolean | undefined {
55
+ return value === undefined || typeof value === "boolean";
56
+ }
57
+
58
+ function isOptionalPositiveInteger(value: unknown): value is number | undefined {
59
+ return value === undefined || isPositiveInteger(value);
60
+ }
61
+
62
+ function isOptionalNonNegativeInteger(value: unknown): value is number | undefined {
63
+ return value === undefined || isNonNegativeInteger(value);
64
+ }
65
+
66
+ function isReadCacheDebugV1(value: unknown): value is ReadCacheDebugV1 {
67
+ if (!isRecord(value)) {
68
+ return false;
69
+ }
70
+
71
+ if (!isReadCacheDebugReason(value.reason)) {
72
+ return false;
73
+ }
74
+
75
+ if (value.scope !== "full" && value.scope !== "range") {
76
+ return false;
77
+ }
78
+
79
+ if (typeof value.baseHashFound !== "boolean" || typeof value.diffAttempted !== "boolean") {
80
+ return false;
81
+ }
82
+
83
+ return (
84
+ isOptionalBoolean(value.outsideRangeChanged) &&
85
+ isOptionalBoolean(value.baseObjectFound) &&
86
+ isOptionalPositiveInteger(value.largestBytes) &&
87
+ isOptionalPositiveInteger(value.maxLines) &&
88
+ isOptionalNonNegativeInteger(value.diffBytes) &&
89
+ isOptionalNonNegativeInteger(value.diffChangedLines)
90
+ );
91
+ }
92
+
93
+ export function isScopeKey(value: unknown): value is ScopeKey {
94
+ if (value === SCOPE_FULL) {
95
+ return true;
96
+ }
97
+ if (typeof value !== "string") {
98
+ return false;
99
+ }
100
+
101
+ const match = RANGE_SCOPE_RE.exec(value);
102
+ if (!match) {
103
+ return false;
104
+ }
105
+
106
+ const start = Number.parseInt(match[1] ?? "", 10);
107
+ const end = Number.parseInt(match[2] ?? "", 10);
108
+
109
+ return Number.isInteger(start) && Number.isInteger(end) && start > 0 && end >= start;
110
+ }
111
+
112
+ function parseMetaV1(value: unknown): RpReadcacheMetaV1 | undefined {
113
+ if (!isRecord(value)) {
114
+ return undefined;
115
+ }
116
+
117
+ if (value.v !== RP_READCACHE_META_VERSION) {
118
+ return undefined;
119
+ }
120
+
121
+ if (value.tool !== "read_file") {
122
+ return undefined;
123
+ }
124
+
125
+ if (typeof value.pathKey !== "string" || value.pathKey.length === 0) {
126
+ return undefined;
127
+ }
128
+
129
+ if (!isScopeKey(value.scopeKey)) {
130
+ return undefined;
131
+ }
132
+
133
+ if (typeof value.servedHash !== "string" || value.servedHash.length === 0) {
134
+ return undefined;
135
+ }
136
+
137
+ const mode = normalizeReadCacheMode(value.mode);
138
+ if (!mode) {
139
+ return undefined;
140
+ }
141
+
142
+ const requiresBaseHash = mode === "unchanged" || mode === "unchanged_range" || mode === "diff";
143
+ if (requiresBaseHash) {
144
+ if (typeof value.baseHash !== "string" || value.baseHash.length === 0) {
145
+ return undefined;
146
+ }
147
+ } else if (value.baseHash !== undefined && (typeof value.baseHash !== "string" || value.baseHash.length === 0)) {
148
+ return undefined;
149
+ }
150
+
151
+ if (
152
+ !isPositiveInteger(value.totalLines) ||
153
+ !isPositiveInteger(value.rangeStart) ||
154
+ !isPositiveInteger(value.rangeEnd) ||
155
+ value.rangeEnd < value.rangeStart ||
156
+ !isNonNegativeInteger(value.bytes) ||
157
+ (value.debug !== undefined && !isReadCacheDebugV1(value.debug))
158
+ ) {
159
+ return undefined;
160
+ }
161
+
162
+ return {
163
+ v: RP_READCACHE_META_VERSION,
164
+ tool: "read_file",
165
+ pathKey: value.pathKey,
166
+ scopeKey: value.scopeKey,
167
+ servedHash: value.servedHash,
168
+ ...(typeof value.baseHash === "string" ? { baseHash: value.baseHash } : {}),
169
+ mode,
170
+ totalLines: value.totalLines,
171
+ rangeStart: value.rangeStart,
172
+ rangeEnd: value.rangeEnd,
173
+ bytes: value.bytes,
174
+ ...(value.debug !== undefined ? { debug: value.debug } : {}),
175
+ };
176
+ }
177
+
178
+ export function buildRpReadcacheMetaV1(meta: Omit<RpReadcacheMetaV1, "v" | "tool">): RpReadcacheMetaV1 {
179
+ return {
180
+ v: RP_READCACHE_META_VERSION,
181
+ tool: "read_file",
182
+ ...meta,
183
+ };
184
+ }
185
+
186
+ export function isRpReadcacheInvalidationV1(value: unknown): value is RpReadcacheInvalidationV1 {
187
+ if (!isRecord(value)) {
188
+ return false;
189
+ }
190
+
191
+ return (
192
+ value.v === RP_READCACHE_META_VERSION &&
193
+ value.kind === "invalidate" &&
194
+ value.tool === "read_file" &&
195
+ typeof value.pathKey === "string" &&
196
+ value.pathKey.length > 0 &&
197
+ isScopeKey(value.scopeKey) &&
198
+ isPositiveInteger(value.at)
199
+ );
200
+ }
201
+
202
+ export function buildInvalidationV1(pathKey: string, scopeKey: ScopeKey, at = Date.now()): RpReadcacheInvalidationV1 {
203
+ return {
204
+ v: RP_READCACHE_META_VERSION,
205
+ kind: "invalidate",
206
+ tool: "read_file",
207
+ pathKey,
208
+ scopeKey,
209
+ at,
210
+ };
211
+ }
212
+
213
+ export function extractReadMetaFromSessionEntry(entry: SessionEntry): RpReadcacheMetaV1 | undefined {
214
+ if (entry.type !== "message") {
215
+ return undefined;
216
+ }
217
+
218
+ const message = entry.message;
219
+ if (message.role !== "toolResult" || message.toolName !== "rp_exec") {
220
+ return undefined;
221
+ }
222
+
223
+ if (!isRecord(message.details)) {
224
+ return undefined;
225
+ }
226
+
227
+ const candidate = (message.details as Record<string, unknown>).rpReadcache;
228
+ return parseMetaV1(candidate);
229
+ }
230
+
231
+ export function extractInvalidationFromSessionEntry(entry: SessionEntry): RpReadcacheInvalidationV1 | undefined {
232
+ if (entry.type !== "custom" || entry.customType !== RP_READCACHE_CUSTOM_TYPE) {
233
+ return undefined;
234
+ }
235
+
236
+ if (!isRpReadcacheInvalidationV1(entry.data)) {
237
+ return undefined;
238
+ }
239
+
240
+ return entry.data;
241
+ }
@@ -0,0 +1,184 @@
1
+ // readcache/object-store.ts - content-addressed storage for file snapshots
2
+
3
+ import { createHash } from "node:crypto";
4
+ import { constants } from "node:fs";
5
+ import { access, mkdir, open, readdir, readFile, rename, stat, unlink } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+
8
+ import { READCACHE_OBJECT_MAX_AGE_MS, READCACHE_OBJECTS_DIR, READCACHE_TMP_DIR } from "./constants.js";
9
+
10
+ const HASH_HEX_RE = /^[a-f0-9]{64}$/;
11
+
12
+ export interface ObjectStoreStats {
13
+ objects: number;
14
+ bytes: number;
15
+ }
16
+
17
+ export interface PruneObjectsResult {
18
+ scanned: number;
19
+ deleted: number;
20
+ cutoffMs: number;
21
+ }
22
+
23
+ function ensureValidHash(hash: string): void {
24
+ if (!HASH_HEX_RE.test(hash)) {
25
+ throw new Error(`Invalid sha256 hash "${hash}"`);
26
+ }
27
+ }
28
+
29
+ function isObjectFileName(name: string): boolean {
30
+ return name.startsWith("sha256-") && name.endsWith(".txt");
31
+ }
32
+
33
+ export function hashBytes(buffer: Buffer): string {
34
+ return createHash("sha256").update(buffer).digest("hex");
35
+ }
36
+
37
+ export function objectPathForHash(repoRoot: string, hash: string): string {
38
+ ensureValidHash(hash);
39
+ return join(repoRoot, READCACHE_OBJECTS_DIR, `sha256-${hash}.txt`);
40
+ }
41
+
42
+ async function ensureStoreDirs(repoRoot: string): Promise<{ objectsDir: string; tmpDir: string }> {
43
+ const objectsDir = join(repoRoot, READCACHE_OBJECTS_DIR);
44
+ const tmpDir = join(repoRoot, READCACHE_TMP_DIR);
45
+
46
+ await mkdir(objectsDir, { recursive: true, mode: 0o700 });
47
+ await mkdir(tmpDir, { recursive: true, mode: 0o700 });
48
+
49
+ return { objectsDir, tmpDir };
50
+ }
51
+
52
+ async function exists(filePath: string): Promise<boolean> {
53
+ try {
54
+ await access(filePath, constants.F_OK);
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ export async function persistObjectIfAbsent(repoRoot: string, hash: string, text: string): Promise<void> {
62
+ ensureValidHash(hash);
63
+
64
+ const { tmpDir } = await ensureStoreDirs(repoRoot);
65
+ const objectPath = objectPathForHash(repoRoot, hash);
66
+
67
+ if (await exists(objectPath)) {
68
+ return;
69
+ }
70
+
71
+ const tempPath = join(
72
+ tmpDir,
73
+ `sha256-${hash}-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`,
74
+ );
75
+ let tempFileCreated = false;
76
+
77
+ try {
78
+ const handle = await open(tempPath, "wx", 0o600);
79
+ tempFileCreated = true;
80
+ try {
81
+ await handle.writeFile(text, "utf-8");
82
+ await handle.sync();
83
+ } finally {
84
+ await handle.close();
85
+ }
86
+
87
+ await rename(tempPath, objectPath);
88
+ } catch (error) {
89
+ const errorCode = (error as NodeJS.ErrnoException).code;
90
+
91
+ if (errorCode === "EEXIST") {
92
+ if (tempFileCreated && (await exists(tempPath))) {
93
+ await unlink(tempPath);
94
+ }
95
+ return;
96
+ }
97
+
98
+ if (tempFileCreated && (await exists(tempPath))) {
99
+ await unlink(tempPath);
100
+ }
101
+
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ export async function loadObject(repoRoot: string, hash: string): Promise<string | undefined> {
107
+ ensureValidHash(hash);
108
+ const objectPath = objectPathForHash(repoRoot, hash);
109
+
110
+ try {
111
+ return await readFile(objectPath, "utf-8");
112
+ } catch (error) {
113
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
114
+ return undefined;
115
+ }
116
+ throw error;
117
+ }
118
+ }
119
+
120
+ export async function getStoreStats(repoRoot: string): Promise<ObjectStoreStats> {
121
+ const { objectsDir } = await ensureStoreDirs(repoRoot);
122
+ const entries = await readdir(objectsDir, { withFileTypes: true });
123
+
124
+ let objects = 0;
125
+ let bytes = 0;
126
+
127
+ for (const entry of entries) {
128
+ if (!entry.isFile() || !isObjectFileName(entry.name)) {
129
+ continue;
130
+ }
131
+
132
+ objects += 1;
133
+ const info = await stat(join(objectsDir, entry.name));
134
+ bytes += info.size;
135
+ }
136
+
137
+ return { objects, bytes };
138
+ }
139
+
140
+ export async function pruneObjectsOlderThan(
141
+ repoRoot: string,
142
+ maxAgeMs = READCACHE_OBJECT_MAX_AGE_MS,
143
+ nowMs = Date.now(),
144
+ ): Promise<PruneObjectsResult> {
145
+ if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) {
146
+ throw new Error(`Invalid maxAgeMs "${String(maxAgeMs)}"`);
147
+ }
148
+
149
+ const { objectsDir } = await ensureStoreDirs(repoRoot);
150
+ const entries = await readdir(objectsDir, { withFileTypes: true });
151
+ const cutoffMs = nowMs - maxAgeMs;
152
+
153
+ let scanned = 0;
154
+ let deleted = 0;
155
+
156
+ for (const entry of entries) {
157
+ if (!entry.isFile() || !isObjectFileName(entry.name)) {
158
+ continue;
159
+ }
160
+
161
+ scanned += 1;
162
+ const filePath = join(objectsDir, entry.name);
163
+
164
+ let info;
165
+ try {
166
+ info = await stat(filePath);
167
+ } catch {
168
+ continue;
169
+ }
170
+
171
+ if (info.mtimeMs > cutoffMs) {
172
+ continue;
173
+ }
174
+
175
+ try {
176
+ await unlink(filePath);
177
+ deleted += 1;
178
+ } catch {
179
+ // Fail-open
180
+ }
181
+ }
182
+
183
+ return { scanned, deleted, cutoffMs };
184
+ }