pi-readcache 0.1.0 → 0.2.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/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
 
@@ -22,7 +23,7 @@ When the extension is active, `read` may return:
22
23
  - unchanged marker (`mode: unchanged`)
23
24
  - unchanged range marker (`mode: unchanged_range`)
24
25
  - unified diff for full-file reads (`mode: diff`)
25
- - baseline fallback (`mode: full_fallback`)
26
+ - baseline fallback (`mode: baseline_fallback`)
26
27
 
27
28
  Plus:
28
29
  - `/readcache-status` to inspect replay/coverage/savings
@@ -49,9 +50,9 @@ After installation, you can use pi normally. If pi is already running when you i
49
50
 
50
51
  | Action | Command | Expected result |
51
52
  |---|---|---|
52
- | Baseline read | `read src/foo.ts` | `mode: full` or `mode: full_fallback` |
53
+ | Baseline read | `read src/foo.ts` | `mode: full` or `mode: baseline_fallback` |
53
54
  | 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
+ | Range read | `read src/foo.ts:1-120` | `mode: full`, `baseline_fallback`, or `unchanged_range` |
55
56
  | Inspect replay/cache state | `/readcache-status` | tracked scopes, replay window, mode counts, estimated savings |
56
57
  | Invalidate full scope | `/readcache-refresh src/foo.ts` | next full read re-anchors |
57
58
  | Invalidate range scope | `/readcache-refresh src/foo.ts 1-120` | next range read re-anchors |
@@ -62,9 +63,24 @@ After installation, you can use pi normally. If pi is already running when you i
62
63
  - Compaction is a strict replay barrier for trust reconstruction:
63
64
  - replay starts at the latest active `compaction + 1`.
64
65
  - 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
+ - First read after that barrier for a path/scope will re-anchor with baseline (`full`/`baseline_fallback`).
66
67
  - For exact current file text, the assistant should still perform an actual `read` in current context.
67
68
 
69
+ ### Diff usefulness gate (why you sometimes get full content instead of a diff)
70
+
71
+ For full-file reads, `mode: diff` is emitted only when the generated patch is clearly more useful than baseline text.
72
+
73
+ Current defaults:
74
+ - `MAX_DIFF_TO_BASE_RATIO = 0.9`
75
+ - if `diffBytes >= selectedBytes * 0.9`, diff is considered not useful and falls back to baseline (`mode: baseline_fallback`)
76
+ - `MAX_DIFF_TO_BASE_LINE_RATIO = 0.85`
77
+ - if patch line count is greater than `selectedRequestedLines * 0.85`, diff is considered too large/noisy and falls back to baseline
78
+
79
+ Why these defaults:
80
+ - avoids near-full-file patch spam on high-churn edits
81
+ - improves readability for the model versus very large hunks
82
+ - keeps token savings meaningful when diff mode is used
83
+
68
84
  ---
69
85
 
70
86
  ## For extension developers (and curious cats)
@@ -88,7 +104,7 @@ flowchart TD
88
104
  F -- no --> G[mode=full, attach metadata]
89
105
  F -- yes + same hash --> H[mode=unchanged/unchanged_range]
90
106
  F -- yes + full scope + useful diff --> I[mode=diff]
91
- F -- otherwise --> J[mode=full_fallback]
107
+ F -- otherwise --> J[mode=baseline_fallback]
92
108
  G --> K[persist object + overlay trust]
93
109
  H --> K
94
110
  I --> K
@@ -127,15 +143,15 @@ Rules:
127
143
 
128
144
  ## File map
129
145
 
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
146
+ - `index.ts` - extension entrypoint + lifecycle reset hooks
147
+ - `src/tool.ts` - `read` override decision engine
148
+ - `src/replay.ts` - replay reconstruction, trust transitions, overlay
149
+ - `src/meta.ts` - metadata/invalidation validators and extractors
150
+ - `src/commands.ts` - `/readcache-status`, `/readcache-refresh`, `readcache_refresh`
151
+ - `src/object-store.ts` - content-addressed storage (`.pi/readcache/objects`)
152
+ - `src/diff.ts` - unified diff creation + usefulness gating
153
+ - `src/path.ts` - path/range parsing and normalization
154
+ - `src/telemetry.ts` - replay window/mode/savings reporting
139
155
 
140
156
  ## Tool-override compatibility contract
141
157
 
@@ -147,7 +163,6 @@ Because this overrides built-in `read`, it must preserve:
147
163
  ## Development
148
164
 
149
165
  ```bash
150
- cd extension
151
166
  npm install
152
167
  npm run typecheck
153
168
  npm test
package/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ToolDefinition } from "@mariozechner/pi-coding-agent";
2
2
  import { registerReadcacheCommands } from "./src/commands.js";
3
3
  import { clearReplayRuntimeState, createReplayRuntimeState } from "./src/replay.js";
4
4
  import { pruneObjectsOlderThan } from "./src/object-store.js";
@@ -6,7 +6,7 @@ import { createReadOverrideTool } from "./src/tool.js";
6
6
 
7
7
  export default function (pi: ExtensionAPI): void {
8
8
  const runtimeState = createReplayRuntimeState();
9
- pi.registerTool(createReadOverrideTool(runtimeState));
9
+ pi.registerTool(createReadOverrideTool(runtimeState) as unknown as ToolDefinition);
10
10
  registerReadcacheCommands(pi, runtimeState);
11
11
 
12
12
  const clearCaches = (): void => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readcache",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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/commands.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  ExtensionAPI,
6
6
  ExtensionCommandContext,
7
7
  ExtensionContext,
8
+ ToolDefinition,
8
9
  } from "@mariozechner/pi-coding-agent";
9
10
  import { type Static, Type } from "@sinclair/typebox";
10
11
  import { READCACHE_CUSTOM_TYPE, SCOPE_FULL, scopeRange } from "./constants.js";
@@ -50,7 +51,7 @@ function formatModeCounts(modeCounts: ReturnType<typeof collectReplayTelemetry>[
50
51
  `unchanged=${modeCounts.unchanged}`,
51
52
  `unchanged_range=${modeCounts.unchanged_range}`,
52
53
  `diff=${modeCounts.diff}`,
53
- `full_fallback=${modeCounts.full_fallback}`,
54
+ `baseline_fallback=${modeCounts.baseline_fallback}`,
54
55
  ].join(", ");
55
56
  }
56
57
 
@@ -324,5 +325,5 @@ export function registerReadcacheCommands(pi: ExtensionAPI, runtimeState: Replay
324
325
  },
325
326
  });
326
327
 
327
- pi.registerTool(createReadcacheRefreshTool(pi, runtimeState));
328
+ pi.registerTool(createReadcacheRefreshTool(pi, runtimeState) as unknown as ToolDefinition);
328
329
  }
package/src/constants.ts CHANGED
@@ -5,7 +5,8 @@ export const SCOPE_FULL = "full" as const;
5
5
 
6
6
  export const MAX_DIFF_FILE_BYTES = 2 * 1024 * 1024;
7
7
  export const MAX_DIFF_FILE_LINES = 12_000;
8
- export const MAX_DIFF_TO_BASE_RATIO = 1.0;
8
+ export const MAX_DIFF_TO_BASE_RATIO = 0.9;
9
+ export const MAX_DIFF_TO_BASE_LINE_RATIO = 0.85;
9
10
 
10
11
  export const DEFAULT_EXCLUDED_PATH_PATTERNS = [
11
12
  ".env*",
package/src/diff.ts CHANGED
@@ -2,6 +2,7 @@ import { formatPatch, structuredPatch } from "diff";
2
2
  import {
3
3
  MAX_DIFF_FILE_BYTES,
4
4
  MAX_DIFF_FILE_LINES,
5
+ MAX_DIFF_TO_BASE_LINE_RATIO,
5
6
  MAX_DIFF_TO_BASE_RATIO,
6
7
  } from "./constants.js";
7
8
 
@@ -11,6 +12,7 @@ export interface DiffLimits {
11
12
  maxFileBytes: number;
12
13
  maxFileLines: number;
13
14
  maxDiffToBaseRatio: number;
15
+ maxDiffToBaseLineRatio: number;
14
16
  }
15
17
 
16
18
  export interface DiffComputation {
@@ -25,6 +27,7 @@ export const DEFAULT_DIFF_LIMITS: DiffLimits = {
25
27
  maxFileBytes: MAX_DIFF_FILE_BYTES,
26
28
  maxFileLines: MAX_DIFF_FILE_LINES,
27
29
  maxDiffToBaseRatio: MAX_DIFF_TO_BASE_RATIO,
30
+ maxDiffToBaseLineRatio: MAX_DIFF_TO_BASE_LINE_RATIO,
28
31
  };
29
32
 
30
33
  function sanitizePathForPatch(pathDisplay: string): string {
@@ -125,6 +128,15 @@ export function isDiffUseful(
125
128
  if (diffBytes === 0) {
126
129
  return false;
127
130
  }
131
+ if (diffBytes >= selectedBytes * limits.maxDiffToBaseRatio) {
132
+ return false;
133
+ }
134
+
135
+ const selectedRequestedLines = lineCount(selectedCurrentText);
136
+ const diffLines = lineCount(diffText);
137
+ if (diffLines > selectedRequestedLines * limits.maxDiffToBaseLineRatio) {
138
+ return false;
139
+ }
128
140
 
129
- return diffBytes < selectedBytes * limits.maxDiffToBaseRatio;
141
+ return true;
130
142
  }
package/src/meta.ts CHANGED
@@ -22,14 +22,23 @@ function isNonNegativeInteger(value: unknown): value is number {
22
22
  return typeof value === "number" && Number.isInteger(value) && value >= 0;
23
23
  }
24
24
 
25
- function isReadCacheMode(value: unknown): value is ReadCacheMode {
26
- return (
27
- value === "full" ||
28
- value === "unchanged" ||
29
- value === "unchanged_range" ||
30
- value === "diff" ||
31
- value === "full_fallback"
32
- );
25
+ function normalizeReadCacheMode(value: unknown): ReadCacheMode | undefined {
26
+ if (value === "full") {
27
+ return "full";
28
+ }
29
+ if (value === "unchanged") {
30
+ return "unchanged";
31
+ }
32
+ if (value === "unchanged_range") {
33
+ return "unchanged_range";
34
+ }
35
+ if (value === "diff") {
36
+ return "diff";
37
+ }
38
+ if (value === "baseline_fallback") {
39
+ return "baseline_fallback";
40
+ }
41
+ return undefined;
33
42
  }
34
43
 
35
44
  function isReadCacheDebugReason(value: unknown): value is ReadCacheDebugV1["reason"] {
@@ -44,7 +53,8 @@ function isReadCacheDebugReason(value: unknown): value is ReadCacheDebugV1["reas
44
53
  value === "diff_unavailable_or_empty" ||
45
54
  value === "diff_not_useful" ||
46
55
  value === "diff_payload_truncated" ||
47
- value === "diff_emitted"
56
+ value === "diff_emitted" ||
57
+ value === "bypass_cache"
48
58
  );
49
59
  }
50
60
 
@@ -111,48 +121,69 @@ export function isScopeKey(value: unknown): value is ScopeKey {
111
121
  return Number.isInteger(start) && Number.isInteger(end) && start > 0 && end >= start;
112
122
  }
113
123
 
114
- export function isReadCacheMetaV1(value: unknown): value is ReadCacheMetaV1 {
124
+ function parseReadCacheMetaV1(value: unknown): ReadCacheMetaV1 | undefined {
115
125
  if (!isRecord(value)) {
116
- return false;
126
+ return undefined;
117
127
  }
118
128
 
119
129
  if (value.v !== READCACHE_META_VERSION) {
120
- return false;
130
+ return undefined;
121
131
  }
122
132
 
123
133
  if (typeof value.pathKey !== "string" || value.pathKey.length === 0) {
124
- return false;
134
+ return undefined;
125
135
  }
126
136
 
127
137
  if (!isScopeKey(value.scopeKey)) {
128
- return false;
138
+ return undefined;
129
139
  }
130
140
 
131
141
  if (typeof value.servedHash !== "string" || value.servedHash.length === 0) {
132
- return false;
142
+ return undefined;
133
143
  }
134
144
 
135
- if (!isReadCacheMode(value.mode)) {
136
- return false;
145
+ const mode = normalizeReadCacheMode(value.mode);
146
+ if (!mode) {
147
+ return undefined;
137
148
  }
138
149
 
139
- const requiresBaseHash = value.mode === "unchanged" || value.mode === "unchanged_range" || value.mode === "diff";
150
+ const requiresBaseHash = mode === "unchanged" || mode === "unchanged_range" || mode === "diff";
140
151
  if (requiresBaseHash) {
141
152
  if (typeof value.baseHash !== "string" || value.baseHash.length === 0) {
142
- return false;
153
+ return undefined;
143
154
  }
144
155
  } else if (value.baseHash !== undefined && (typeof value.baseHash !== "string" || value.baseHash.length === 0)) {
145
- return false;
156
+ return undefined;
146
157
  }
147
158
 
148
- return (
149
- isPositiveInteger(value.totalLines) &&
150
- isPositiveInteger(value.rangeStart) &&
151
- isPositiveInteger(value.rangeEnd) &&
152
- value.rangeEnd >= value.rangeStart &&
153
- isNonNegativeInteger(value.bytes) &&
154
- (value.debug === undefined || isReadCacheDebugV1(value.debug))
155
- );
159
+ if (
160
+ !isPositiveInteger(value.totalLines) ||
161
+ !isPositiveInteger(value.rangeStart) ||
162
+ !isPositiveInteger(value.rangeEnd) ||
163
+ value.rangeEnd < value.rangeStart ||
164
+ !isNonNegativeInteger(value.bytes) ||
165
+ (value.debug !== undefined && !isReadCacheDebugV1(value.debug))
166
+ ) {
167
+ return undefined;
168
+ }
169
+
170
+ return {
171
+ v: READCACHE_META_VERSION,
172
+ pathKey: value.pathKey,
173
+ scopeKey: value.scopeKey,
174
+ servedHash: value.servedHash,
175
+ ...(typeof value.baseHash === "string" ? { baseHash: value.baseHash } : {}),
176
+ mode,
177
+ totalLines: value.totalLines,
178
+ rangeStart: value.rangeStart,
179
+ rangeEnd: value.rangeEnd,
180
+ bytes: value.bytes,
181
+ ...(value.debug !== undefined ? { debug: value.debug } : {}),
182
+ };
183
+ }
184
+
185
+ export function isReadCacheMetaV1(value: unknown): value is ReadCacheMetaV1 {
186
+ return parseReadCacheMetaV1(value) !== undefined;
156
187
  }
157
188
 
158
189
  export function isReadCacheInvalidationV1(value: unknown): value is ReadCacheInvalidationV1 {
@@ -202,11 +233,7 @@ export function extractReadMetaFromSessionEntry(entry: SessionEntry): ReadCacheM
202
233
  }
203
234
 
204
235
  const candidate = message.details.readcache;
205
- if (!isReadCacheMetaV1(candidate)) {
206
- return undefined;
207
- }
208
-
209
- return candidate;
236
+ return parseReadCacheMetaV1(candidate);
210
237
  }
211
238
 
212
239
  export function extractInvalidationFromSessionEntry(entry: SessionEntry): ReadCacheInvalidationV1 | undefined {
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
- if (mode === "full" || mode === "full_fallback") {
249
+ if (mode === "full" || mode === "baseline_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/telemetry.ts CHANGED
@@ -19,7 +19,7 @@ function createModeCounts(): ReadCacheModeCounts {
19
19
  unchanged: 0,
20
20
  unchanged_range: 0,
21
21
  diff: 0,
22
- full_fallback: 0,
22
+ baseline_fallback: 0,
23
23
  };
24
24
  }
25
25
 
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,
@@ -44,12 +50,18 @@ export const readToolSchema = Type.Object({
44
50
  path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
45
51
  offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
46
52
  limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
53
+ bypass_cache: Type.Optional(
54
+ Type.Boolean({
55
+ description:
56
+ "If true, bypass readcache optimization for this call and return baseline read output for the requested scope",
57
+ }),
58
+ ),
47
59
  });
48
60
 
49
61
  export type ReadToolParams = Static<typeof readToolSchema>;
50
62
 
51
63
  function buildReadDescription(): string {
52
- return `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. Returns full text, unchanged marker, or unified diff. Treat output as authoritative for requested scope. Use readcache_refresh only when output is insufficient for correctness; it forces full baseline payload for file and increases repeated-read context usage.`;
64
+ return `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. Returns full text, unchanged marker, or unified diff. Treat output as authoritative for requested scope. Set bypass_cache=true to force baseline output for this call only. If an edit fails because exact old text was not found, re-read the same path and scope with bypass_cache=true before retrying edit. Use readcache_refresh only when output is insufficient for correctness across calls; it invalidates trust for the selected scope until that scope is re-anchored by a baseline read, and increases repeated-read context usage.`;
53
65
  }
54
66
 
55
67
  function hasImageContent(result: AgentToolResult<ReadToolDetails | undefined>): boolean {
@@ -161,7 +173,11 @@ function buildDebugInfo(
161
173
  };
162
174
  }
163
175
 
164
- function selectBaseTrust(pathKnowledge: Map<ScopeKey, ScopeTrust> | undefined, scopeKey: ScopeKey): ScopeTrust | undefined {
176
+ function selectBaseTrust(
177
+ pathKnowledge: Map<ScopeKey, ScopeTrust> | undefined,
178
+ scopeKey: ScopeKey,
179
+ rangeScopeBlocked: boolean,
180
+ ): ScopeTrust | undefined {
165
181
  if (!pathKnowledge) {
166
182
  return undefined;
167
183
  }
@@ -170,6 +186,10 @@ function selectBaseTrust(pathKnowledge: Map<ScopeKey, ScopeTrust> | undefined, s
170
186
  return pathKnowledge.get(SCOPE_FULL);
171
187
  }
172
188
 
189
+ if (rangeScopeBlocked) {
190
+ return undefined;
191
+ }
192
+
173
193
  const exactTrust = pathKnowledge.get(scopeKey);
174
194
  const fullTrust = pathKnowledge.get(SCOPE_FULL);
175
195
  if (!exactTrust) {
@@ -288,9 +308,33 @@ export function createReadOverrideTool(runtimeState: ReplayRuntimeState = create
288
308
  throwIfAborted(signal);
289
309
  const pathKey = parsed.absolutePath;
290
310
  const scopeKey = scopeKeyForRange(start, end, totalLines);
311
+
312
+ if (params.bypass_cache === true) {
313
+ const meta = buildReadcacheMeta(
314
+ pathKey,
315
+ scopeKey,
316
+ current.currentHash,
317
+ "full",
318
+ totalLines,
319
+ start,
320
+ end,
321
+ current.bytes.byteLength,
322
+ undefined,
323
+ buildDebugInfo(scopeKey, undefined, "bypass_cache"),
324
+ );
325
+ await persistAndOverlay(runtimeState, ctx, pathKey, scopeKey, current.currentHash, current.text);
326
+ return attachMetaToBaseline(baselineResult, meta);
327
+ }
328
+
291
329
  const knowledge = buildKnowledgeForLeaf(ctx.sessionManager, runtimeState);
292
330
  const pathKnowledge = knowledge.get(pathKey);
293
- const baseHash = selectBaseTrust(pathKnowledge, scopeKey)?.hash;
331
+ const rangeScopeBlocked = isRangeScopeBlockedByInvalidation(
332
+ ctx.sessionManager,
333
+ runtimeState,
334
+ pathKey,
335
+ scopeKey,
336
+ );
337
+ const baseHash = selectBaseTrust(pathKnowledge, scopeKey, rangeScopeBlocked)?.hash;
294
338
 
295
339
  if (!baseHash) {
296
340
  const meta = buildReadcacheMeta(
@@ -346,7 +390,7 @@ export function createReadOverrideTool(runtimeState: ReplayRuntimeState = create
346
390
  pathKey,
347
391
  scopeKey,
348
392
  current.currentHash,
349
- "full_fallback",
393
+ "baseline_fallback",
350
394
  totalLines,
351
395
  start,
352
396
  end,
package/src/types.ts CHANGED
@@ -10,7 +10,7 @@ export interface ScopeTrust {
10
10
  seq: number;
11
11
  }
12
12
 
13
- export type ReadCacheMode = "full" | "unchanged" | "unchanged_range" | "diff" | "full_fallback";
13
+ export type ReadCacheMode = "full" | "unchanged" | "unchanged_range" | "diff" | "baseline_fallback";
14
14
 
15
15
  export type ReadCacheDebugReason =
16
16
  | "no_base_hash"
@@ -23,7 +23,8 @@ export type ReadCacheDebugReason =
23
23
  | "diff_unavailable_or_empty"
24
24
  | "diff_not_useful"
25
25
  | "diff_payload_truncated"
26
- | "diff_emitted";
26
+ | "diff_emitted"
27
+ | "bypass_cache";
27
28
 
28
29
  export interface ReadCacheDebugV1 {
29
30
  reason: ReadCacheDebugReason;