pi-readcache 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,8 @@
1
1
  # 🧠 pi-readcache
2
2
 
3
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)
4
+ [![npm version](https://img.shields.io/npm/v/pi-readcache.svg)](https://www.npmjs.com/package/pi-readcache)
5
+ [![license](https://img.shields.io/npm/l/pi-readcache.svg)](LICENSE)
5
6
 
6
7
  A pi extension that overrides the built-in `read` tool with hash-based, replay-aware caching.
7
8
 
@@ -127,15 +128,15 @@ Rules:
127
128
 
128
129
  ## File map
129
130
 
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
131
+ - `index.ts` - extension entrypoint + lifecycle reset hooks
132
+ - `src/tool.ts` - `read` override decision engine
133
+ - `src/replay.ts` - replay reconstruction, trust transitions, overlay
134
+ - `src/meta.ts` - metadata/invalidation validators and extractors
135
+ - `src/commands.ts` - `/readcache-status`, `/readcache-refresh`, `readcache_refresh`
136
+ - `src/object-store.ts` - content-addressed storage (`.pi/readcache/objects`)
137
+ - `src/diff.ts` - unified diff creation + usefulness gating
138
+ - `src/path.ts` - path/range parsing and normalization
139
+ - `src/telemetry.ts` - replay window/mode/savings reporting
139
140
 
140
141
  ## Tool-override compatibility contract
141
142
 
@@ -147,7 +148,6 @@ Because this overrides built-in `read`, it must preserve:
147
148
  ## Development
148
149
 
149
150
  ```bash
150
- cd extension
151
151
  npm install
152
152
  npm run typecheck
153
153
  npm test
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readcache",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "🧠 Pi extension that optimizes read tool calls with replay-aware caching and compaction-safe trust reconstruction",
5
5
  "author": "Gurpartap Singh",
6
6
  "license": "MIT",
package/src/replay.ts CHANGED
@@ -6,20 +6,28 @@ import type {
6
6
  ReadCacheInvalidationV1,
7
7
  ReadCacheMetaV1,
8
8
  ScopeKey,
9
+ ScopeRangeKey,
9
10
  ScopeTrust,
10
11
  } from "./types.js";
11
12
 
12
13
  type SessionManagerView = ExtensionContext["sessionManager"];
13
14
 
15
+ type RangeBlockersByPath = Map<string, Set<ScopeRangeKey>>;
16
+
14
17
  const OVERLAY_SEQ_START = 1_000_000_000;
15
18
 
19
+ interface ReplayMemoEntry {
20
+ knowledge: KnowledgeMap;
21
+ blockedRangesByPath: RangeBlockersByPath;
22
+ }
23
+
16
24
  interface OverlayState {
17
25
  leafId: string | null;
18
26
  knowledge: KnowledgeMap;
19
27
  }
20
28
 
21
29
  export interface ReplayRuntimeState {
22
- memoByLeaf: Map<string, KnowledgeMap>;
30
+ memoByLeaf: Map<string, ReplayMemoEntry>;
23
31
  overlayBySession: Map<string, OverlayState>;
24
32
  nextOverlaySeq: number;
25
33
  }
@@ -41,6 +49,21 @@ function cloneKnowledgeMap(source: KnowledgeMap): KnowledgeMap {
41
49
  return cloned;
42
50
  }
43
51
 
52
+ function cloneRangeBlockersByPath(source: RangeBlockersByPath): RangeBlockersByPath {
53
+ const cloned: RangeBlockersByPath = new Map();
54
+ for (const [pathKey, scopes] of source.entries()) {
55
+ cloned.set(pathKey, new Set(scopes));
56
+ }
57
+ return cloned;
58
+ }
59
+
60
+ function cloneReplayMemoEntry(source: ReplayMemoEntry): ReplayMemoEntry {
61
+ return {
62
+ knowledge: cloneKnowledgeMap(source.knowledge),
63
+ blockedRangesByPath: cloneRangeBlockersByPath(source.blockedRangesByPath),
64
+ };
65
+ }
66
+
44
67
  function getMemoKey(sessionId: string, leafId: string | null, boundaryKey: string): string {
45
68
  return `${sessionId}:${leafId ?? "null"}:${boundaryKey}`;
46
69
  }
@@ -55,6 +78,36 @@ function ensureScopeMap(knowledge: KnowledgeMap, pathKey: string): Map<ScopeKey,
55
78
  return created;
56
79
  }
57
80
 
81
+ function isRangeScope(scopeKey: ScopeKey): scopeKey is ScopeRangeKey {
82
+ return scopeKey !== SCOPE_FULL;
83
+ }
84
+
85
+ function ensureRangeBlockerSet(blockedRangesByPath: RangeBlockersByPath, pathKey: string): Set<ScopeRangeKey> {
86
+ const existing = blockedRangesByPath.get(pathKey);
87
+ if (existing) {
88
+ return existing;
89
+ }
90
+ const created = new Set<ScopeRangeKey>();
91
+ blockedRangesByPath.set(pathKey, created);
92
+ return created;
93
+ }
94
+
95
+ function setRangeBlocker(blockedRangesByPath: RangeBlockersByPath, pathKey: string, scopeKey: ScopeRangeKey): void {
96
+ const scopes = ensureRangeBlockerSet(blockedRangesByPath, pathKey);
97
+ scopes.add(scopeKey);
98
+ }
99
+
100
+ function clearRangeBlocker(blockedRangesByPath: RangeBlockersByPath, pathKey: string, scopeKey: ScopeRangeKey): void {
101
+ const scopes = blockedRangesByPath.get(pathKey);
102
+ if (!scopes) {
103
+ return;
104
+ }
105
+ scopes.delete(scopeKey);
106
+ if (scopes.size === 0) {
107
+ blockedRangesByPath.delete(pathKey);
108
+ }
109
+ }
110
+
58
111
  export function getTrust(knowledge: KnowledgeMap, pathKey: string, scopeKey: ScopeKey): ScopeTrust | undefined {
59
112
  return knowledge.get(pathKey)?.get(scopeKey);
60
113
  }
@@ -95,6 +148,61 @@ function leafHasChildren(sessionManager: SessionManagerView, leafId: string | nu
95
148
  return sessionManager.getEntries().some((entry) => entry.parentId === leafId);
96
149
  }
97
150
 
151
+ function replaySnapshotFromBranch(branchEntries: SessionEntry[], startIndex: number): ReplayMemoEntry {
152
+ const knowledge: KnowledgeMap = new Map();
153
+ const blockedRangesByPath: RangeBlockersByPath = new Map();
154
+ const normalizedStart = Math.max(0, Math.min(startIndex, branchEntries.length));
155
+ let seq = 0;
156
+
157
+ for (let index = normalizedStart; index < branchEntries.length; index += 1) {
158
+ const entry = branchEntries[index];
159
+ if (!entry) {
160
+ continue;
161
+ }
162
+
163
+ const meta = extractReadMetaFromSessionEntry(entry);
164
+ if (meta) {
165
+ seq += 1;
166
+ applyReadMetaTransition(knowledge, meta, seq, blockedRangesByPath);
167
+ continue;
168
+ }
169
+
170
+ const invalidation = extractInvalidationFromSessionEntry(entry);
171
+ if (invalidation) {
172
+ applyInvalidation(knowledge, invalidation, blockedRangesByPath);
173
+ }
174
+ }
175
+
176
+ return {
177
+ knowledge,
178
+ blockedRangesByPath,
179
+ };
180
+ }
181
+
182
+ function getReplayMemoEntryForLeaf(
183
+ sessionManager: SessionManagerView,
184
+ runtimeState: ReplayRuntimeState,
185
+ ): { memoEntry: ReplayMemoEntry; sessionId: string; leafId: string | null } {
186
+ const sessionId = sessionManager.getSessionId();
187
+ const leafId = sessionManager.getLeafId();
188
+ const branchEntries = sessionManager.getBranch();
189
+ const boundary = findReplayStartIndex(branchEntries);
190
+ const memoKey = getMemoKey(sessionId, leafId, boundary.boundaryKey);
191
+
192
+ let memoEntry = runtimeState.memoByLeaf.get(memoKey);
193
+ if (!memoEntry) {
194
+ const replayMemo = replaySnapshotFromBranch(branchEntries, boundary.startIndex);
195
+ memoEntry = cloneReplayMemoEntry(replayMemo);
196
+ runtimeState.memoByLeaf.set(memoKey, memoEntry);
197
+ }
198
+
199
+ return {
200
+ memoEntry,
201
+ sessionId,
202
+ leafId,
203
+ };
204
+ }
205
+
98
206
  export function createReplayRuntimeState(): ReplayRuntimeState {
99
207
  return {
100
208
  memoByLeaf: new Map(),
@@ -128,13 +236,21 @@ export function findReplayStartIndex(branchEntries: SessionEntry[]): ReplayBound
128
236
  };
129
237
  }
130
238
 
131
- export function applyReadMetaTransition(knowledge: KnowledgeMap, meta: ReadCacheMetaV1, seq: number): void {
239
+ export function applyReadMetaTransition(
240
+ knowledge: KnowledgeMap,
241
+ meta: ReadCacheMetaV1,
242
+ seq: number,
243
+ blockedRangesByPath?: RangeBlockersByPath,
244
+ ): void {
132
245
  const { pathKey, scopeKey, servedHash, baseHash, mode } = meta;
133
246
  const fullTrust = getTrust(knowledge, pathKey, SCOPE_FULL);
134
247
  const rangeTrust = scopeKey === SCOPE_FULL ? undefined : getTrust(knowledge, pathKey, scopeKey);
135
248
 
136
249
  if (mode === "full" || mode === "full_fallback") {
137
250
  setTrust(knowledge, pathKey, scopeKey, servedHash, seq);
251
+ if (blockedRangesByPath && isRangeScope(scopeKey)) {
252
+ clearRangeBlocker(blockedRangesByPath, pathKey, scopeKey);
253
+ }
138
254
  return;
139
255
  }
140
256
 
@@ -174,14 +290,24 @@ export function applyReadMetaTransition(knowledge: KnowledgeMap, meta: ReadCache
174
290
  }
175
291
  }
176
292
 
177
- export function applyInvalidation(knowledge: KnowledgeMap, invalidation: ReadCacheInvalidationV1): void {
293
+ export function applyInvalidation(
294
+ knowledge: KnowledgeMap,
295
+ invalidation: ReadCacheInvalidationV1,
296
+ blockedRangesByPath?: RangeBlockersByPath,
297
+ ): void {
178
298
  const scopes = knowledge.get(invalidation.pathKey);
179
- if (!scopes) {
180
- return;
181
- }
182
299
 
183
300
  if (invalidation.scopeKey === SCOPE_FULL) {
184
301
  knowledge.delete(invalidation.pathKey);
302
+ blockedRangesByPath?.delete(invalidation.pathKey);
303
+ return;
304
+ }
305
+
306
+ if (blockedRangesByPath && isRangeScope(invalidation.scopeKey)) {
307
+ setRangeBlocker(blockedRangesByPath, invalidation.pathKey, invalidation.scopeKey);
308
+ }
309
+
310
+ if (!scopes) {
185
311
  return;
186
312
  }
187
313
 
@@ -192,53 +318,40 @@ export function applyInvalidation(knowledge: KnowledgeMap, invalidation: ReadCac
192
318
  }
193
319
 
194
320
  export function replayKnowledgeFromBranch(branchEntries: SessionEntry[], startIndex: number): KnowledgeMap {
195
- const knowledge: KnowledgeMap = new Map();
196
- const normalizedStart = Math.max(0, Math.min(startIndex, branchEntries.length));
197
- let seq = 0;
198
-
199
- for (let index = normalizedStart; index < branchEntries.length; index += 1) {
200
- const entry = branchEntries[index];
201
- if (!entry) {
202
- continue;
203
- }
204
-
205
- const meta = extractReadMetaFromSessionEntry(entry);
206
- if (meta) {
207
- seq += 1;
208
- applyReadMetaTransition(knowledge, meta, seq);
209
- continue;
210
- }
211
-
212
- const invalidation = extractInvalidationFromSessionEntry(entry);
213
- if (invalidation) {
214
- applyInvalidation(knowledge, invalidation);
215
- }
216
- }
217
-
218
- return knowledge;
321
+ return replaySnapshotFromBranch(branchEntries, startIndex).knowledge;
219
322
  }
220
323
 
221
324
  export function buildKnowledgeForLeaf(
222
325
  sessionManager: SessionManagerView,
223
326
  runtimeState: ReplayRuntimeState,
224
327
  ): KnowledgeMap {
225
- const sessionId = sessionManager.getSessionId();
226
- const leafId = sessionManager.getLeafId();
227
- const branchEntries = sessionManager.getBranch();
228
- const boundary = findReplayStartIndex(branchEntries);
229
- const memoKey = getMemoKey(sessionId, leafId, boundary.boundaryKey);
230
-
231
- let replayKnowledge = runtimeState.memoByLeaf.get(memoKey);
232
- if (!replayKnowledge) {
233
- replayKnowledge = replayKnowledgeFromBranch(branchEntries, boundary.startIndex);
234
- runtimeState.memoByLeaf.set(memoKey, cloneKnowledgeMap(replayKnowledge));
235
- }
236
-
328
+ const { memoEntry, sessionId, leafId } = getReplayMemoEntryForLeaf(sessionManager, runtimeState);
237
329
  const overlayState = ensureOverlayForLeaf(runtimeState, sessionId, leafId);
238
330
  if (leafHasChildren(sessionManager, leafId)) {
239
331
  overlayState.knowledge.clear();
240
332
  }
241
- return mergeKnowledge(replayKnowledge, overlayState.knowledge);
333
+ return mergeKnowledge(memoEntry.knowledge, overlayState.knowledge);
334
+ }
335
+
336
+ export function isRangeScopeBlockedByInvalidation(
337
+ sessionManager: SessionManagerView,
338
+ runtimeState: ReplayRuntimeState,
339
+ pathKey: string,
340
+ scopeKey: ScopeKey,
341
+ ): boolean {
342
+ if (!isRangeScope(scopeKey)) {
343
+ return false;
344
+ }
345
+
346
+ const { memoEntry, sessionId, leafId } = getReplayMemoEntryForLeaf(sessionManager, runtimeState);
347
+ const blockedScopes = memoEntry.blockedRangesByPath.get(pathKey);
348
+ if (!blockedScopes?.has(scopeKey)) {
349
+ return false;
350
+ }
351
+
352
+ const overlayState = ensureOverlayForLeaf(runtimeState, sessionId, leafId);
353
+ const overlayScopeTrust = overlayState.knowledge.get(pathKey)?.get(scopeKey);
354
+ return overlayScopeTrust === undefined;
242
355
  }
243
356
 
244
357
  export function overlaySet(
package/src/tool.ts CHANGED
@@ -20,7 +20,13 @@ import { computeUnifiedDiff, isDiffUseful } from "./diff.js";
20
20
  import { buildReadCacheMetaV1 } from "./meta.js";
21
21
  import { hashBytes, loadObject, persistObjectIfAbsent } from "./object-store.js";
22
22
  import { normalizeOffsetLimit, parseTrailingRangeIfNeeded, scopeKeyForRange } from "./path.js";
23
- import { buildKnowledgeForLeaf, createReplayRuntimeState, overlaySet, type ReplayRuntimeState } from "./replay.js";
23
+ import {
24
+ buildKnowledgeForLeaf,
25
+ createReplayRuntimeState,
26
+ isRangeScopeBlockedByInvalidation,
27
+ overlaySet,
28
+ type ReplayRuntimeState,
29
+ } from "./replay.js";
24
30
  import { compareSlices, splitLines, truncateForReadcache } from "./text.js";
25
31
  import type {
26
32
  ReadCacheDebugReason,
@@ -161,7 +167,11 @@ function buildDebugInfo(
161
167
  };
162
168
  }
163
169
 
164
- function selectBaseTrust(pathKnowledge: Map<ScopeKey, ScopeTrust> | undefined, scopeKey: ScopeKey): ScopeTrust | undefined {
170
+ function selectBaseTrust(
171
+ pathKnowledge: Map<ScopeKey, ScopeTrust> | undefined,
172
+ scopeKey: ScopeKey,
173
+ rangeScopeBlocked: boolean,
174
+ ): ScopeTrust | undefined {
165
175
  if (!pathKnowledge) {
166
176
  return undefined;
167
177
  }
@@ -170,6 +180,10 @@ function selectBaseTrust(pathKnowledge: Map<ScopeKey, ScopeTrust> | undefined, s
170
180
  return pathKnowledge.get(SCOPE_FULL);
171
181
  }
172
182
 
183
+ if (rangeScopeBlocked) {
184
+ return undefined;
185
+ }
186
+
173
187
  const exactTrust = pathKnowledge.get(scopeKey);
174
188
  const fullTrust = pathKnowledge.get(SCOPE_FULL);
175
189
  if (!exactTrust) {
@@ -290,7 +304,13 @@ export function createReadOverrideTool(runtimeState: ReplayRuntimeState = create
290
304
  const scopeKey = scopeKeyForRange(start, end, totalLines);
291
305
  const knowledge = buildKnowledgeForLeaf(ctx.sessionManager, runtimeState);
292
306
  const pathKnowledge = knowledge.get(pathKey);
293
- const baseHash = selectBaseTrust(pathKnowledge, scopeKey)?.hash;
307
+ const rangeScopeBlocked = isRangeScopeBlockedByInvalidation(
308
+ ctx.sessionManager,
309
+ runtimeState,
310
+ pathKey,
311
+ scopeKey,
312
+ );
313
+ const baseHash = selectBaseTrust(pathKnowledge, scopeKey, rangeScopeBlocked)?.hash;
294
314
 
295
315
  if (!baseHash) {
296
316
  const meta = buildReadcacheMeta(