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 +31 -16
- package/index.ts +2 -2
- package/package.json +1 -1
- package/src/commands.ts +3 -2
- package/src/constants.ts +2 -1
- package/src/diff.ts +13 -1
- package/src/meta.ts +60 -33
- package/src/replay.ts +157 -44
- package/src/telemetry.ts +1 -1
- package/src/tool.ts +49 -5
- package/src/types.ts +3 -2
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# 🧠 pi-readcache
|
|
2
2
|
|
|
3
3
|
[](https://pi.dev/)
|
|
4
|
-
[](https://www.npmjs.com/package/pi-readcache)
|
|
5
|
+
[](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:
|
|
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:
|
|
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`, `
|
|
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`/`
|
|
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=
|
|
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
|
-
- `
|
|
131
|
-
- `
|
|
132
|
-
- `
|
|
133
|
-
- `
|
|
134
|
-
- `
|
|
135
|
-
- `
|
|
136
|
-
- `
|
|
137
|
-
- `
|
|
138
|
-
- `
|
|
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
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
|
-
`
|
|
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 =
|
|
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
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
124
|
+
function parseReadCacheMetaV1(value: unknown): ReadCacheMetaV1 | undefined {
|
|
115
125
|
if (!isRecord(value)) {
|
|
116
|
-
return
|
|
126
|
+
return undefined;
|
|
117
127
|
}
|
|
118
128
|
|
|
119
129
|
if (value.v !== READCACHE_META_VERSION) {
|
|
120
|
-
return
|
|
130
|
+
return undefined;
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
if (typeof value.pathKey !== "string" || value.pathKey.length === 0) {
|
|
124
|
-
return
|
|
134
|
+
return undefined;
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
if (!isScopeKey(value.scopeKey)) {
|
|
128
|
-
return
|
|
138
|
+
return undefined;
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
if (typeof value.servedHash !== "string" || value.servedHash.length === 0) {
|
|
132
|
-
return
|
|
142
|
+
return undefined;
|
|
133
143
|
}
|
|
134
144
|
|
|
135
|
-
|
|
136
|
-
|
|
145
|
+
const mode = normalizeReadCacheMode(value.mode);
|
|
146
|
+
if (!mode) {
|
|
147
|
+
return undefined;
|
|
137
148
|
}
|
|
138
149
|
|
|
139
|
-
const requiresBaseHash =
|
|
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
|
|
153
|
+
return undefined;
|
|
143
154
|
}
|
|
144
155
|
} else if (value.baseHash !== undefined && (typeof value.baseHash !== "string" || value.baseHash.length === 0)) {
|
|
145
|
-
return
|
|
156
|
+
return undefined;
|
|
146
157
|
}
|
|
147
158
|
|
|
148
|
-
|
|
149
|
-
isPositiveInteger(value.totalLines)
|
|
150
|
-
isPositiveInteger(value.rangeStart)
|
|
151
|
-
isPositiveInteger(value.rangeEnd)
|
|
152
|
-
value.rangeEnd
|
|
153
|
-
isNonNegativeInteger(value.bytes)
|
|
154
|
-
(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
|
-
|
|
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,
|
|
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(
|
|
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 === "
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
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 {
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
"
|
|
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" | "
|
|
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;
|