pi-readcache 0.1.1 → 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
@@ -23,7 +23,7 @@ When the extension is active, `read` may return:
23
23
  - unchanged marker (`mode: unchanged`)
24
24
  - unchanged range marker (`mode: unchanged_range`)
25
25
  - unified diff for full-file reads (`mode: diff`)
26
- - baseline fallback (`mode: full_fallback`)
26
+ - baseline fallback (`mode: baseline_fallback`)
27
27
 
28
28
  Plus:
29
29
  - `/readcache-status` to inspect replay/coverage/savings
@@ -50,9 +50,9 @@ After installation, you can use pi normally. If pi is already running when you i
50
50
 
51
51
  | Action | Command | Expected result |
52
52
  |---|---|---|
53
- | 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` |
54
54
  | Repeat read (no file change) | `read src/foo.ts` | `[readcache: unchanged, ...]` |
55
- | 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` |
56
56
  | Inspect replay/cache state | `/readcache-status` | tracked scopes, replay window, mode counts, estimated savings |
57
57
  | Invalidate full scope | `/readcache-refresh src/foo.ts` | next full read re-anchors |
58
58
  | Invalidate range scope | `/readcache-refresh src/foo.ts 1-120` | next range read re-anchors |
@@ -63,9 +63,24 @@ After installation, you can use pi normally. If pi is already running when you i
63
63
  - Compaction is a strict replay barrier for trust reconstruction:
64
64
  - replay starts at the latest active `compaction + 1`.
65
65
  - pre-compaction trust is not used after that barrier.
66
- - 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`).
67
67
  - For exact current file text, the assistant should still perform an actual `read` in current context.
68
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
+
69
84
  ---
70
85
 
71
86
  ## For extension developers (and curious cats)
@@ -89,7 +104,7 @@ flowchart TD
89
104
  F -- no --> G[mode=full, attach metadata]
90
105
  F -- yes + same hash --> H[mode=unchanged/unchanged_range]
91
106
  F -- yes + full scope + useful diff --> I[mode=diff]
92
- F -- otherwise --> J[mode=full_fallback]
107
+ F -- otherwise --> J[mode=baseline_fallback]
93
108
  G --> K[persist object + overlay trust]
94
109
  H --> K
95
110
  I --> K
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.1",
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
@@ -246,7 +246,7 @@ export function applyReadMetaTransition(
246
246
  const fullTrust = getTrust(knowledge, pathKey, SCOPE_FULL);
247
247
  const rangeTrust = scopeKey === SCOPE_FULL ? undefined : getTrust(knowledge, pathKey, scopeKey);
248
248
 
249
- if (mode === "full" || mode === "full_fallback") {
249
+ if (mode === "full" || mode === "baseline_fallback") {
250
250
  setTrust(knowledge, pathKey, scopeKey, servedHash, seq);
251
251
  if (blockedRangesByPath && isRangeScope(scopeKey)) {
252
252
  clearRangeBlocker(blockedRangesByPath, pathKey, scopeKey);
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
@@ -50,12 +50,18 @@ export const readToolSchema = Type.Object({
50
50
  path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
51
51
  offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
52
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
+ ),
53
59
  });
54
60
 
55
61
  export type ReadToolParams = Static<typeof readToolSchema>;
56
62
 
57
63
  function buildReadDescription(): string {
58
- 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.`;
59
65
  }
60
66
 
61
67
  function hasImageContent(result: AgentToolResult<ReadToolDetails | undefined>): boolean {
@@ -302,6 +308,24 @@ export function createReadOverrideTool(runtimeState: ReplayRuntimeState = create
302
308
  throwIfAborted(signal);
303
309
  const pathKey = parsed.absolutePath;
304
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
+
305
329
  const knowledge = buildKnowledgeForLeaf(ctx.sessionManager, runtimeState);
306
330
  const pathKnowledge = knowledge.get(pathKey);
307
331
  const rangeScopeBlocked = isRangeScopeBlockedByInvalidation(
@@ -366,7 +390,7 @@ export function createReadOverrideTool(runtimeState: ReplayRuntimeState = create
366
390
  pathKey,
367
391
  scopeKey,
368
392
  current.currentHash,
369
- "full_fallback",
393
+ "baseline_fallback",
370
394
  totalLines,
371
395
  start,
372
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;