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