pi-readcache 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gurpartap Singh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # 🧠 pi-readcache
2
+
3
+ [![pi coding agent](https://img.shields.io/badge/pi-coding%20agent-6f6bff?logo=terminal&logoColor=white)](https://pi.dev/)
4
+ [![license](https://img.shields.io/npm/l/pi-mermaid.svg)](LICENSE)
5
+
6
+ A pi extension that overrides the built-in `read` tool with hash-based, replay-aware caching.
7
+
8
+ It reduces token usage and context bloat from repeated file reads while preserving correctness as session state evolves.
9
+
10
+ Correctness is maintained across:
11
+ - range reads (`path:START-END`)
12
+ - tree navigation (`/tree`)
13
+ - compaction boundaries
14
+ - restart/resume replay
15
+
16
+ ## What you get
17
+
18
+ `pi-readcache` runs automatically in the background by overriding `read` and managing replay trust for you. Refresh/invalidation can also be triggered by the model itself via the `readcache_refresh` tool when it decides a fresh baseline is needed. You can still run `/readcache-refresh` manually for explicit control.
19
+
20
+ When the extension is active, `read` may return:
21
+ - full content (`mode: full`)
22
+ - unchanged marker (`mode: unchanged`)
23
+ - unchanged range marker (`mode: unchanged_range`)
24
+ - unified diff for full-file reads (`mode: diff`)
25
+ - baseline fallback (`mode: full_fallback`)
26
+
27
+ Plus:
28
+ - `/readcache-status` to inspect replay/coverage/savings
29
+ - `/readcache-refresh <path> [start-end]` to invalidate trust for next read
30
+ - `readcache_refresh` tool (same semantics as command)
31
+
32
+ ## Install
33
+
34
+ Preferred (npm):
35
+
36
+ ```bash
37
+ pi install npm:pi-readcache
38
+ ```
39
+
40
+ Alternative (git):
41
+
42
+ ```bash
43
+ pi install git:https://github.com/Gurpartap/pi-readcache
44
+ ```
45
+
46
+ After installation, you can use pi normally. If pi is already running when you install or update, run `/reload` in that session.
47
+
48
+ ## Day-to-day usage
49
+
50
+ | Action | Command | Expected result |
51
+ |---|---|---|
52
+ | Baseline read | `read src/foo.ts` | `mode: full` or `mode: full_fallback` |
53
+ | Repeat read (no file change) | `read src/foo.ts` | `[readcache: unchanged, ...]` |
54
+ | Range read | `read src/foo.ts:1-120` | `mode: full`, `full_fallback`, or `unchanged_range` |
55
+ | Inspect replay/cache state | `/readcache-status` | tracked scopes, replay window, mode counts, estimated savings |
56
+ | Invalidate full scope | `/readcache-refresh src/foo.ts` | next full read re-anchors |
57
+ | Invalidate range scope | `/readcache-refresh src/foo.ts 1-120` | next range read re-anchors |
58
+
59
+ ## Important behavior notes
60
+
61
+ - Sensitive-path bypass: readcache does not cache/diff these patterns and falls back to baseline `read` output: `.env*`, `*.pem`, `*.key`, `*.p12`, `*.pfx`, `*.crt`, `*.cer`, `*.der`, `*.pk8`, `id_rsa`, `id_ed25519`, `.npmrc`, `.netrc`.
62
+ - Compaction is a strict replay barrier for trust reconstruction:
63
+ - replay starts at the latest active `compaction + 1`.
64
+ - pre-compaction trust is not used after that barrier.
65
+ - First read after that barrier for a path/scope will re-anchor with baseline (`full`/`full_fallback`).
66
+ - For exact current file text, the assistant should still perform an actual `read` in current context.
67
+
68
+ ---
69
+
70
+ ## For extension developers (and curious cats)
71
+
72
+ ## Design docs
73
+
74
+ - [IMPLEMENTATION_SPEC.md](IMPLEMENTATION_SPEC.md)
75
+ - [IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md)
76
+ - [EVOLUTION_PLAN_1.md](EVOLUTION_PLAN_1.md)
77
+ - [EVOLUTION_PLAN_2.md](EVOLUTION_PLAN_2.md)
78
+
79
+ ## High-level architecture
80
+
81
+ ```mermaid
82
+ flowchart TD
83
+ A[LLM calls read] --> B[read override tool]
84
+ B --> C[Run baseline built-in read]
85
+ B --> D[Load current file bytes/text + sha256]
86
+ B --> E[Rebuild replay knowledge for active leaf]
87
+ E --> F{base trust exists?}
88
+ F -- no --> G[mode=full, attach metadata]
89
+ F -- yes + same hash --> H[mode=unchanged/unchanged_range]
90
+ F -- yes + full scope + useful diff --> I[mode=diff]
91
+ F -- otherwise --> J[mode=full_fallback]
92
+ G --> K[persist object + overlay trust]
93
+ H --> K
94
+ I --> K
95
+ J --> K
96
+ K --> L[tool result + readcache metadata]
97
+ ```
98
+
99
+ ## Runtime model
100
+
101
+ - Trust key: `(pathKey, scopeKey)` where scope is:
102
+ - `full`
103
+ - `r:<start>:<end>`
104
+ - Trust value: `{ hash, seq }`
105
+ - Replay source:
106
+ - prior `read` tool result metadata (`details.readcache`)
107
+ - custom invalidation entries (`customType: "pi-readcache"`)
108
+ - Overlay:
109
+ - in-memory, per `(sessionId, leafId)`, high seq namespace for same-turn freshness
110
+
111
+ ## Compaction/tree semantics
112
+
113
+ ```mermaid
114
+ flowchart LR
115
+ R[root] --> C1[compaction #1]
116
+ C1 --> N1[reads...]
117
+ N1 --> C2[compaction #2]
118
+ C2 --> L[active leaf]
119
+
120
+ B[replay start] --> S[latest compaction + 1 on active path]
121
+ ```
122
+
123
+ Rules:
124
+ - replay boundary = latest compaction on active branch path + 1
125
+ - if no compaction on path, replay starts at root
126
+ - tree/fork/switch/compact/shutdown clear in-memory memo/overlay caches
127
+
128
+ ## File map
129
+
130
+ - `extension/index.ts` - extension entrypoint + lifecycle reset hooks
131
+ - `extension/src/tool.ts` - `read` override decision engine
132
+ - `extension/src/replay.ts` - replay reconstruction, trust transitions, overlay
133
+ - `extension/src/meta.ts` - metadata/invalidation validators and extractors
134
+ - `extension/src/commands.ts` - `/readcache-status`, `/readcache-refresh`, `readcache_refresh`
135
+ - `extension/src/object-store.ts` - content-addressed storage (`.pi/readcache/objects`)
136
+ - `extension/src/diff.ts` - unified diff creation + usefulness gating
137
+ - `extension/src/path.ts` - path/range parsing and normalization
138
+ - `extension/src/telemetry.ts` - replay window/mode/savings reporting
139
+
140
+ ## Tool-override compatibility contract
141
+
142
+ Because this overrides built-in `read`, it must preserve:
143
+ - same tool name + parameters (`path`, `offset?`, `limit?`)
144
+ - baseline-compatible content shapes (including image passthrough)
145
+ - truncation behavior and `details.truncation` compatibility
146
+
147
+ ## Development
148
+
149
+ ```bash
150
+ cd extension
151
+ npm install
152
+ npm run typecheck
153
+ npm test
154
+ ```
155
+
156
+ Targeted suites:
157
+
158
+ ```bash
159
+ npm test -- test/unit/replay.test.ts
160
+ npm test -- test/integration/compaction-boundary.test.ts
161
+ npm test -- test/integration/tree-navigation.test.ts
162
+ npm test -- test/integration/selective-range.test.ts
163
+ npm test -- test/integration/refresh-invalidation.test.ts
164
+ npm test -- test/integration/restart-resume.test.ts
165
+ ```
166
+
167
+ ## License
168
+ MIT © 2026 Gurpartap Singh (https://x.com/Gurpartap)
package/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { registerReadcacheCommands } from "./src/commands.js";
3
+ import { clearReplayRuntimeState, createReplayRuntimeState } from "./src/replay.js";
4
+ import { pruneObjectsOlderThan } from "./src/object-store.js";
5
+ import { createReadOverrideTool } from "./src/tool.js";
6
+
7
+ export default function (pi: ExtensionAPI): void {
8
+ const runtimeState = createReplayRuntimeState();
9
+ pi.registerTool(createReadOverrideTool(runtimeState));
10
+ registerReadcacheCommands(pi, runtimeState);
11
+
12
+ const clearCaches = (): void => {
13
+ clearReplayRuntimeState(runtimeState);
14
+ };
15
+
16
+ pi.on("session_start", (_event, ctx) => {
17
+ void pruneObjectsOlderThan(ctx.cwd).catch(() => {
18
+ // Fail-open: object pruning should never disrupt session startup.
19
+ });
20
+ });
21
+
22
+ pi.on("session_compact", clearCaches);
23
+ pi.on("session_tree", clearCaches);
24
+ pi.on("session_fork", clearCaches);
25
+ pi.on("session_switch", clearCaches);
26
+ pi.on("session_shutdown", clearCaches);
27
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "pi-readcache",
3
+ "version": "0.1.0",
4
+ "description": "🧠 Pi extension that optimizes read tool calls with replay-aware caching and compaction-safe trust reconstruction",
5
+ "author": "Gurpartap Singh",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Gurpartap/pi-readcache.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/Gurpartap/pi-readcache/issues"
16
+ },
17
+ "homepage": "https://github.com/Gurpartap/pi-readcache",
18
+ "keywords": [
19
+ "pi",
20
+ "pi-extension",
21
+ "pi-package",
22
+ "readcache",
23
+ "read",
24
+ "cache",
25
+ "compaction"
26
+ ],
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "files": [
31
+ "index.ts",
32
+ "src",
33
+ "README.md",
34
+ "LICENSE",
35
+ "package.json"
36
+ ],
37
+ "scripts": {
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "vitest run"
40
+ },
41
+ "dependencies": {
42
+ "@sinclair/typebox": "^0.34.40",
43
+ "diff": "^8.0.2"
44
+ },
45
+ "peerDependencies": {
46
+ "@mariozechner/pi-coding-agent": "*"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^24.5.2",
50
+ "typescript": "^5.9.2",
51
+ "vitest": "^3.2.4"
52
+ },
53
+ "pi": {
54
+ "extensions": [
55
+ "./index.ts"
56
+ ]
57
+ }
58
+ }
@@ -0,0 +1,328 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { access, readFile } from "node:fs/promises";
3
+ import type {
4
+ AgentToolResult,
5
+ ExtensionAPI,
6
+ ExtensionCommandContext,
7
+ ExtensionContext,
8
+ } from "@mariozechner/pi-coding-agent";
9
+ import { type Static, Type } from "@sinclair/typebox";
10
+ import { READCACHE_CUSTOM_TYPE, SCOPE_FULL, scopeRange } from "./constants.js";
11
+ import { buildInvalidationV1 } from "./meta.js";
12
+ import { getStoreStats } from "./object-store.js";
13
+ import { normalizeOffsetLimit, parseTrailingRangeIfNeeded, resolveReadPath, scopeKeyForRange } from "./path.js";
14
+ import { buildKnowledgeForLeaf, clearReplayRuntimeState, type ReplayRuntimeState } from "./replay.js";
15
+ import { collectReplayTelemetry, summarizeKnowledge } from "./telemetry.js";
16
+ import { splitLines } from "./text.js";
17
+ import type { ScopeKey } from "./types.js";
18
+
19
+ const STATUS_MESSAGE_TYPE = "pi-readcache-status";
20
+ const REFRESH_MESSAGE_TYPE = "pi-readcache-refresh";
21
+ const UTF8_STRICT_DECODER = new TextDecoder("utf-8", { fatal: true });
22
+
23
+ const readcacheRefreshSchema = Type.Object({
24
+ path: Type.String({ description: "Path to refresh (same input semantics as read)" }),
25
+ offset: Type.Optional(Type.Number({ description: "Line number to start from (1-indexed)" })),
26
+ limit: Type.Optional(Type.Number({ description: "Maximum number of lines" })),
27
+ });
28
+
29
+ export type ReadcacheRefreshParams = Static<typeof readcacheRefreshSchema>;
30
+
31
+ interface RefreshResolution {
32
+ pathKey: string;
33
+ scopeKey: ScopeKey;
34
+ pathInput: string;
35
+ }
36
+
37
+ function formatBytes(bytes: number): string {
38
+ if (bytes < 1024) {
39
+ return `${bytes} B`;
40
+ }
41
+ if (bytes < 1024 * 1024) {
42
+ return `${(bytes / 1024).toFixed(1)} KiB`;
43
+ }
44
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
45
+ }
46
+
47
+ function formatModeCounts(modeCounts: ReturnType<typeof collectReplayTelemetry>["modeCounts"]): string {
48
+ return [
49
+ `full=${modeCounts.full}`,
50
+ `unchanged=${modeCounts.unchanged}`,
51
+ `unchanged_range=${modeCounts.unchanged_range}`,
52
+ `diff=${modeCounts.diff}`,
53
+ `full_fallback=${modeCounts.full_fallback}`,
54
+ ].join(", ");
55
+ }
56
+
57
+ function emitStatusReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, report: string): void {
58
+ pi.sendMessage({
59
+ customType: STATUS_MESSAGE_TYPE,
60
+ content: report,
61
+ display: true,
62
+ });
63
+
64
+ if (ctx.hasUI) {
65
+ ctx.ui.notify("Readcache status generated", "info");
66
+ }
67
+ }
68
+
69
+ function emitRefreshReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, report: string): void {
70
+ pi.sendMessage({
71
+ customType: REFRESH_MESSAGE_TYPE,
72
+ content: report,
73
+ display: true,
74
+ });
75
+
76
+ if (ctx.hasUI) {
77
+ ctx.ui.notify("Readcache refresh invalidation appended", "info");
78
+ }
79
+ }
80
+
81
+ function stripWrappingQuotes(value: string): string {
82
+ if (value.length < 2) {
83
+ return value;
84
+ }
85
+ const first = value[0];
86
+ const last = value[value.length - 1];
87
+ if (!first || !last) {
88
+ return value;
89
+ }
90
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
91
+ return value.slice(1, -1);
92
+ }
93
+ return value;
94
+ }
95
+
96
+ async function pathExists(path: string): Promise<boolean> {
97
+ try {
98
+ await access(path, fsConstants.F_OK);
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function isPositiveInteger(value: number | undefined): value is number {
106
+ return value !== undefined && Number.isInteger(value) && value > 0;
107
+ }
108
+
109
+ function parseRangeToken(value: string): { offset: number; limit?: number } {
110
+ const singleMatch = /^(\d+)$/.exec(value);
111
+ if (singleMatch) {
112
+ const offsetRaw = singleMatch[1];
113
+ if (!offsetRaw) {
114
+ throw new Error(`Invalid range token "${value}".`);
115
+ }
116
+ const offset = Number.parseInt(offsetRaw, 10);
117
+ if (!isPositiveInteger(offset)) {
118
+ throw new Error(`Invalid line number "${value}". Line numbers must be positive integers.`);
119
+ }
120
+ return { offset };
121
+ }
122
+
123
+ const rangeMatch = /^(\d+)-(\d+)$/.exec(value);
124
+ if (!rangeMatch) {
125
+ throw new Error(`Invalid range token "${value}". Use <start> or <start>-<end>.`);
126
+ }
127
+ const startRaw = rangeMatch[1];
128
+ const endRaw = rangeMatch[2];
129
+ if (!startRaw || !endRaw) {
130
+ throw new Error(`Invalid range token "${value}". Use <start> or <start>-<end>.`);
131
+ }
132
+
133
+ const start = Number.parseInt(startRaw, 10);
134
+ const end = Number.parseInt(endRaw, 10);
135
+ if (!isPositiveInteger(start) || !isPositiveInteger(end)) {
136
+ throw new Error(`Invalid range token "${value}". Line numbers must be positive integers.`);
137
+ }
138
+ if (end < start) {
139
+ throw new Error(`Invalid range "${value}": end line must be greater than or equal to start line.`);
140
+ }
141
+
142
+ return {
143
+ offset: start,
144
+ limit: end - start + 1,
145
+ };
146
+ }
147
+
148
+ async function parseRefreshCommandArgs(args: string, cwd: string): Promise<ReadcacheRefreshParams> {
149
+ const trimmed = args.trim();
150
+ if (!trimmed) {
151
+ throw new Error("Usage: /readcache-refresh <path> [start-end]");
152
+ }
153
+
154
+ const rangeSuffixMatch = /^(.*)\s+(\d+(?:-\d+)?)$/.exec(trimmed);
155
+ if (!rangeSuffixMatch) {
156
+ return { path: stripWrappingQuotes(trimmed) };
157
+ }
158
+
159
+ const fullPathCandidate = stripWrappingQuotes(trimmed);
160
+ const fullPathResolved = resolveReadPath(fullPathCandidate, cwd);
161
+ if (await pathExists(fullPathResolved)) {
162
+ return { path: fullPathCandidate };
163
+ }
164
+
165
+ const pathPartRaw = stripWrappingQuotes(rangeSuffixMatch[1]?.trim() ?? "");
166
+ const rangeToken = rangeSuffixMatch[2];
167
+ if (!pathPartRaw || !rangeToken) {
168
+ return { path: fullPathCandidate };
169
+ }
170
+
171
+ const range = parseRangeToken(rangeToken);
172
+ return {
173
+ path: pathPartRaw,
174
+ ...range,
175
+ };
176
+ }
177
+
178
+ function validateExplicitRange(offset: number | undefined, limit: number | undefined): void {
179
+ if (offset !== undefined && !isPositiveInteger(offset)) {
180
+ throw new Error(`Invalid offset "${offset}". Offset must be a positive integer.`);
181
+ }
182
+ if (limit !== undefined && !isPositiveInteger(limit)) {
183
+ throw new Error(`Invalid limit "${limit}". Limit must be a positive integer.`);
184
+ }
185
+ }
186
+
187
+ async function resolveInvalidationScopeKey(
188
+ absolutePath: string,
189
+ offset: number | undefined,
190
+ limit: number | undefined,
191
+ ): Promise<ScopeKey> {
192
+ if (offset === undefined && limit === undefined) {
193
+ return SCOPE_FULL;
194
+ }
195
+
196
+ validateExplicitRange(offset, limit);
197
+
198
+ try {
199
+ const bytes = await readFile(absolutePath);
200
+ const text = UTF8_STRICT_DECODER.decode(bytes);
201
+ const totalLines = splitLines(text).length;
202
+ const normalized = normalizeOffsetLimit(offset, limit, totalLines);
203
+ return scopeKeyForRange(normalized.start, normalized.end, normalized.totalLines);
204
+ } catch {
205
+ if (offset === undefined) {
206
+ return SCOPE_FULL;
207
+ }
208
+ if (limit !== undefined) {
209
+ return scopeRange(offset, offset + limit - 1);
210
+ }
211
+ return SCOPE_FULL;
212
+ }
213
+ }
214
+
215
+ function formatScope(scopeKey: ScopeKey): string {
216
+ if (scopeKey === SCOPE_FULL) {
217
+ return "full scope";
218
+ }
219
+ const parts = scopeKey.split(":");
220
+ const start = parts[1];
221
+ const end = parts[2];
222
+ if (!start || !end) {
223
+ return `scope ${scopeKey}`;
224
+ }
225
+ return `lines ${start}-${end}`;
226
+ }
227
+
228
+ function buildRefreshConfirmation(scopeKey: ScopeKey, pathInput: string): string {
229
+ return `[readcache-refresh] invalidated ${formatScope(scopeKey)} for ${pathInput}`;
230
+ }
231
+
232
+ export async function appendReadcacheInvalidation(
233
+ pi: Pick<ExtensionAPI, "appendEntry">,
234
+ runtimeState: ReplayRuntimeState,
235
+ cwd: string,
236
+ params: ReadcacheRefreshParams,
237
+ ): Promise<RefreshResolution> {
238
+ const parsed = parseTrailingRangeIfNeeded(params.path, params.offset, params.limit, cwd);
239
+ const scopeKey = await resolveInvalidationScopeKey(parsed.absolutePath, parsed.offset, parsed.limit);
240
+
241
+ const invalidation = buildInvalidationV1(parsed.absolutePath, scopeKey, Date.now());
242
+ pi.appendEntry(READCACHE_CUSTOM_TYPE, invalidation);
243
+ clearReplayRuntimeState(runtimeState);
244
+
245
+ return {
246
+ pathKey: parsed.absolutePath,
247
+ scopeKey,
248
+ pathInput: parsed.pathInput,
249
+ };
250
+ }
251
+
252
+ export function createReadcacheRefreshTool(
253
+ pi: Pick<ExtensionAPI, "appendEntry">,
254
+ runtimeState: ReplayRuntimeState,
255
+ ) {
256
+ return {
257
+ name: "readcache_refresh",
258
+ label: "readcache_refresh",
259
+ description: "Invalidate readcache state for a path or range so the next read returns baseline output",
260
+ parameters: readcacheRefreshSchema,
261
+ execute: async (
262
+ _toolCallId: string,
263
+ params: ReadcacheRefreshParams,
264
+ signal: AbortSignal | undefined,
265
+ _onUpdate: unknown,
266
+ ctx: ExtensionContext,
267
+ ): Promise<AgentToolResult<undefined>> => {
268
+ if (signal?.aborted) {
269
+ throw new Error("Operation aborted");
270
+ }
271
+
272
+ const refreshed = await appendReadcacheInvalidation(pi, runtimeState, ctx.cwd, params);
273
+ return {
274
+ content: [{ type: "text", text: buildRefreshConfirmation(refreshed.scopeKey, refreshed.pathInput) }],
275
+ details: undefined,
276
+ };
277
+ },
278
+ };
279
+ }
280
+
281
+ export function registerReadcacheCommands(pi: ExtensionAPI, runtimeState: ReplayRuntimeState): void {
282
+ pi.registerCommand("readcache-status", {
283
+ description: "Show replay-context readcache status and object store stats",
284
+ handler: async (_args, ctx) => {
285
+ const replayTelemetry = collectReplayTelemetry(ctx.sessionManager);
286
+ const knowledge = buildKnowledgeForLeaf(ctx.sessionManager, runtimeState);
287
+ const knowledgeSummary = summarizeKnowledge(knowledge);
288
+
289
+ let storeLine = "object store: unavailable";
290
+ try {
291
+ const storeStats = await getStoreStats(ctx.cwd);
292
+ storeLine = `object store: ${storeStats.objects} objects, ${formatBytes(storeStats.bytes)}`;
293
+ } catch {
294
+ // Best effort only.
295
+ }
296
+
297
+ const reportLines = [
298
+ "[readcache-status]",
299
+ `tracked scopes: ${knowledgeSummary.trackedScopes} across ${knowledgeSummary.trackedFiles} files`,
300
+ `replay window: ${replayTelemetry.replayEntryCount} entries (start index ${replayTelemetry.replayStartIndex})`,
301
+ `mode counts: ${formatModeCounts(replayTelemetry.modeCounts)}`,
302
+ `estimated savings: ~${replayTelemetry.estimatedTokensSaved} tokens (${formatBytes(replayTelemetry.estimatedBytesSaved)})`,
303
+ storeLine,
304
+ ];
305
+
306
+ emitStatusReport(pi, ctx, reportLines.join("\n"));
307
+ },
308
+ });
309
+
310
+ pi.registerCommand("readcache-refresh", {
311
+ description: "Invalidate readcache state for a path and optional line range",
312
+ handler: async (args, ctx) => {
313
+ try {
314
+ const parsed = await parseRefreshCommandArgs(args, ctx.cwd);
315
+ const refreshed = await appendReadcacheInvalidation(pi, runtimeState, ctx.cwd, parsed);
316
+ emitRefreshReport(pi, ctx, buildRefreshConfirmation(refreshed.scopeKey, refreshed.pathInput));
317
+ } catch (error) {
318
+ const message = error instanceof Error ? error.message : String(error);
319
+ if (ctx.hasUI) {
320
+ ctx.ui.notify(message, "error");
321
+ }
322
+ throw error instanceof Error ? error : new Error(message);
323
+ }
324
+ },
325
+ });
326
+
327
+ pi.registerTool(createReadcacheRefreshTool(pi, runtimeState));
328
+ }
@@ -0,0 +1,33 @@
1
+ export const READCACHE_META_VERSION = 1 as const;
2
+ export const READCACHE_CUSTOM_TYPE = "pi-readcache" as const;
3
+
4
+ export const SCOPE_FULL = "full" as const;
5
+
6
+ export const MAX_DIFF_FILE_BYTES = 2 * 1024 * 1024;
7
+ export const MAX_DIFF_FILE_LINES = 12_000;
8
+ export const MAX_DIFF_TO_BASE_RATIO = 1.0;
9
+
10
+ export const DEFAULT_EXCLUDED_PATH_PATTERNS = [
11
+ ".env*",
12
+ "*.pem",
13
+ "*.key",
14
+ "*.p12",
15
+ "*.pfx",
16
+ "*.crt",
17
+ "*.cer",
18
+ "*.der",
19
+ "*.pk8",
20
+ "id_rsa",
21
+ "id_ed25519",
22
+ ".npmrc",
23
+ ".netrc",
24
+ ] as const;
25
+
26
+ export const READCACHE_ROOT_DIR = ".pi/readcache";
27
+ export const READCACHE_OBJECTS_DIR = `${READCACHE_ROOT_DIR}/objects`;
28
+ export const READCACHE_TMP_DIR = `${READCACHE_ROOT_DIR}/tmp`;
29
+ export const READCACHE_OBJECT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
30
+
31
+ export function scopeRange(start: number, end: number): `r:${number}:${number}` {
32
+ return `r:${start}:${end}`;
33
+ }