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/src/diff.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { formatPatch, structuredPatch } from "diff";
2
+ import {
3
+ MAX_DIFF_FILE_BYTES,
4
+ MAX_DIFF_FILE_LINES,
5
+ MAX_DIFF_TO_BASE_RATIO,
6
+ } from "./constants.js";
7
+
8
+ const PATCH_SEPARATOR_LINE = "===================================================================";
9
+
10
+ export interface DiffLimits {
11
+ maxFileBytes: number;
12
+ maxFileLines: number;
13
+ maxDiffToBaseRatio: number;
14
+ }
15
+
16
+ export interface DiffComputation {
17
+ diffText: string;
18
+ changedLines: number;
19
+ addedLines: number;
20
+ removedLines: number;
21
+ diffBytes: number;
22
+ }
23
+
24
+ export const DEFAULT_DIFF_LIMITS: DiffLimits = {
25
+ maxFileBytes: MAX_DIFF_FILE_BYTES,
26
+ maxFileLines: MAX_DIFF_FILE_LINES,
27
+ maxDiffToBaseRatio: MAX_DIFF_TO_BASE_RATIO,
28
+ };
29
+
30
+ function sanitizePathForPatch(pathDisplay: string): string {
31
+ const trimmed = pathDisplay.trim();
32
+ if (!trimmed) {
33
+ return "unknown";
34
+ }
35
+ return trimmed.replace(/[\t\r\n]/g, "_");
36
+ }
37
+
38
+ function stripPatchSeparator(patch: string): string {
39
+ const lines = patch.split("\n");
40
+ if (lines[0] === PATCH_SEPARATOR_LINE) {
41
+ return lines.slice(1).join("\n").trimEnd();
42
+ }
43
+ return patch.trimEnd();
44
+ }
45
+
46
+ function lineCount(text: string): number {
47
+ if (text.length === 0) {
48
+ return 1;
49
+ }
50
+ return text.split("\n").length;
51
+ }
52
+
53
+ function countChangedLinesFromPatch(diffText: string): { added: number; removed: number } {
54
+ let added = 0;
55
+ let removed = 0;
56
+ for (const line of diffText.split("\n")) {
57
+ if (!line) {
58
+ continue;
59
+ }
60
+ if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@")) {
61
+ continue;
62
+ }
63
+ if (line.startsWith("+")) {
64
+ added += 1;
65
+ continue;
66
+ }
67
+ if (line.startsWith("-")) {
68
+ removed += 1;
69
+ }
70
+ }
71
+ return { added, removed };
72
+ }
73
+
74
+ export function computeUnifiedDiff(baseText: string, currentText: string, pathDisplay: string): DiffComputation | undefined {
75
+ const safePath = sanitizePathForPatch(pathDisplay);
76
+ const patch = structuredPatch(`a/${safePath}`, `b/${safePath}`, baseText, currentText, "", "", {
77
+ context: 3,
78
+ });
79
+ if (patch.hunks.length === 0) {
80
+ return undefined;
81
+ }
82
+
83
+ const diffText = stripPatchSeparator(formatPatch(patch));
84
+ if (!diffText.includes("@@")) {
85
+ return undefined;
86
+ }
87
+
88
+ const { added, removed } = countChangedLinesFromPatch(diffText);
89
+ return {
90
+ diffText,
91
+ changedLines: Math.max(added, removed),
92
+ addedLines: added,
93
+ removedLines: removed,
94
+ diffBytes: Buffer.byteLength(diffText, "utf-8"),
95
+ };
96
+ }
97
+
98
+ export function isDiffUseful(
99
+ diffText: string,
100
+ selectedBaseText: string,
101
+ selectedCurrentText: string,
102
+ limits: DiffLimits = DEFAULT_DIFF_LIMITS,
103
+ ): boolean {
104
+ if (diffText.trim().length === 0 || !diffText.includes("@@")) {
105
+ return false;
106
+ }
107
+
108
+ const baseBytes = Buffer.byteLength(selectedBaseText, "utf-8");
109
+ const currentBytes = Buffer.byteLength(selectedCurrentText, "utf-8");
110
+ const selectedBytes = Math.max(baseBytes, currentBytes);
111
+ if (selectedBytes === 0) {
112
+ return false;
113
+ }
114
+
115
+ if (selectedBytes > limits.maxFileBytes) {
116
+ return false;
117
+ }
118
+
119
+ const maxLines = Math.max(lineCount(selectedBaseText), lineCount(selectedCurrentText));
120
+ if (maxLines > limits.maxFileLines) {
121
+ return false;
122
+ }
123
+
124
+ const diffBytes = Buffer.byteLength(diffText, "utf-8");
125
+ if (diffBytes === 0) {
126
+ return false;
127
+ }
128
+
129
+ return diffBytes < selectedBytes * limits.maxDiffToBaseRatio;
130
+ }
package/src/meta.ts ADDED
@@ -0,0 +1,222 @@
1
+ import type { SessionEntry } from "@mariozechner/pi-coding-agent";
2
+ import { READCACHE_CUSTOM_TYPE, READCACHE_META_VERSION, SCOPE_FULL } from "./constants.js";
3
+ import type {
4
+ ReadCacheDebugV1,
5
+ ReadCacheInvalidationV1,
6
+ ReadCacheMetaV1,
7
+ ReadCacheMode,
8
+ ScopeKey,
9
+ } from "./types.js";
10
+
11
+ const RANGE_SCOPE_RE = /^r:(\d+):(\d+)$/;
12
+
13
+ function isRecord(value: unknown): value is Record<string, unknown> {
14
+ return typeof value === "object" && value !== null;
15
+ }
16
+
17
+ function isPositiveInteger(value: unknown): value is number {
18
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
19
+ }
20
+
21
+ function isNonNegativeInteger(value: unknown): value is number {
22
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
23
+ }
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
+ );
33
+ }
34
+
35
+ function isReadCacheDebugReason(value: unknown): value is ReadCacheDebugV1["reason"] {
36
+ return (
37
+ value === "no_base_hash" ||
38
+ value === "hash_match" ||
39
+ value === "base_object_missing" ||
40
+ value === "range_slice_unchanged" ||
41
+ value === "range_slice_changed" ||
42
+ value === "diff_file_too_large_bytes" ||
43
+ value === "diff_file_too_large_lines" ||
44
+ value === "diff_unavailable_or_empty" ||
45
+ value === "diff_not_useful" ||
46
+ value === "diff_payload_truncated" ||
47
+ value === "diff_emitted"
48
+ );
49
+ }
50
+
51
+ function isOptionalBoolean(value: unknown): value is boolean | undefined {
52
+ return value === undefined || typeof value === "boolean";
53
+ }
54
+
55
+ function isOptionalPositiveInteger(value: unknown): value is number | undefined {
56
+ return value === undefined || isPositiveInteger(value);
57
+ }
58
+
59
+ function isOptionalNonNegativeInteger(value: unknown): value is number | undefined {
60
+ return value === undefined || isNonNegativeInteger(value);
61
+ }
62
+
63
+ function isReadCacheDebugV1(value: unknown): value is ReadCacheDebugV1 {
64
+ if (!isRecord(value)) {
65
+ return false;
66
+ }
67
+
68
+ if (!isReadCacheDebugReason(value.reason)) {
69
+ return false;
70
+ }
71
+
72
+ if (value.scope !== "full" && value.scope !== "range") {
73
+ return false;
74
+ }
75
+
76
+ if (typeof value.baseHashFound !== "boolean" || typeof value.diffAttempted !== "boolean") {
77
+ return false;
78
+ }
79
+
80
+ return (
81
+ isOptionalBoolean(value.outsideRangeChanged) &&
82
+ isOptionalBoolean(value.baseObjectFound) &&
83
+ isOptionalPositiveInteger(value.largestBytes) &&
84
+ isOptionalPositiveInteger(value.maxLines) &&
85
+ isOptionalNonNegativeInteger(value.diffBytes) &&
86
+ isOptionalNonNegativeInteger(value.diffChangedLines)
87
+ );
88
+ }
89
+
90
+ export function isScopeKey(value: unknown): value is ScopeKey {
91
+ if (value === SCOPE_FULL) {
92
+ return true;
93
+ }
94
+ if (typeof value !== "string") {
95
+ return false;
96
+ }
97
+
98
+ const match = RANGE_SCOPE_RE.exec(value);
99
+ if (!match) {
100
+ return false;
101
+ }
102
+
103
+ const startRaw = match[1];
104
+ const endRaw = match[2];
105
+ if (!startRaw || !endRaw) {
106
+ return false;
107
+ }
108
+
109
+ const start = Number.parseInt(startRaw, 10);
110
+ const end = Number.parseInt(endRaw, 10);
111
+ return Number.isInteger(start) && Number.isInteger(end) && start > 0 && end >= start;
112
+ }
113
+
114
+ export function isReadCacheMetaV1(value: unknown): value is ReadCacheMetaV1 {
115
+ if (!isRecord(value)) {
116
+ return false;
117
+ }
118
+
119
+ if (value.v !== READCACHE_META_VERSION) {
120
+ return false;
121
+ }
122
+
123
+ if (typeof value.pathKey !== "string" || value.pathKey.length === 0) {
124
+ return false;
125
+ }
126
+
127
+ if (!isScopeKey(value.scopeKey)) {
128
+ return false;
129
+ }
130
+
131
+ if (typeof value.servedHash !== "string" || value.servedHash.length === 0) {
132
+ return false;
133
+ }
134
+
135
+ if (!isReadCacheMode(value.mode)) {
136
+ return false;
137
+ }
138
+
139
+ const requiresBaseHash = value.mode === "unchanged" || value.mode === "unchanged_range" || value.mode === "diff";
140
+ if (requiresBaseHash) {
141
+ if (typeof value.baseHash !== "string" || value.baseHash.length === 0) {
142
+ return false;
143
+ }
144
+ } else if (value.baseHash !== undefined && (typeof value.baseHash !== "string" || value.baseHash.length === 0)) {
145
+ return false;
146
+ }
147
+
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
+ );
156
+ }
157
+
158
+ export function isReadCacheInvalidationV1(value: unknown): value is ReadCacheInvalidationV1 {
159
+ if (!isRecord(value)) {
160
+ return false;
161
+ }
162
+
163
+ return (
164
+ value.v === READCACHE_META_VERSION &&
165
+ value.kind === "invalidate" &&
166
+ typeof value.pathKey === "string" &&
167
+ value.pathKey.length > 0 &&
168
+ isScopeKey(value.scopeKey) &&
169
+ isPositiveInteger(value.at)
170
+ );
171
+ }
172
+
173
+ export function buildReadCacheMetaV1(meta: Omit<ReadCacheMetaV1, "v">): ReadCacheMetaV1 {
174
+ return {
175
+ v: READCACHE_META_VERSION,
176
+ ...meta,
177
+ };
178
+ }
179
+
180
+ export function buildInvalidationV1(pathKey: string, scopeKey: ScopeKey, at = Date.now()): ReadCacheInvalidationV1 {
181
+ return {
182
+ v: READCACHE_META_VERSION,
183
+ kind: "invalidate",
184
+ pathKey,
185
+ scopeKey,
186
+ at,
187
+ };
188
+ }
189
+
190
+ export function extractReadMetaFromSessionEntry(entry: SessionEntry): ReadCacheMetaV1 | undefined {
191
+ if (entry.type !== "message") {
192
+ return undefined;
193
+ }
194
+
195
+ const message = entry.message;
196
+ if (message.role !== "toolResult" || message.toolName !== "read") {
197
+ return undefined;
198
+ }
199
+
200
+ if (!isRecord(message.details)) {
201
+ return undefined;
202
+ }
203
+
204
+ const candidate = message.details.readcache;
205
+ if (!isReadCacheMetaV1(candidate)) {
206
+ return undefined;
207
+ }
208
+
209
+ return candidate;
210
+ }
211
+
212
+ export function extractInvalidationFromSessionEntry(entry: SessionEntry): ReadCacheInvalidationV1 | undefined {
213
+ if (entry.type !== "custom" || entry.customType !== READCACHE_CUSTOM_TYPE) {
214
+ return undefined;
215
+ }
216
+
217
+ if (!isReadCacheInvalidationV1(entry.data)) {
218
+ return undefined;
219
+ }
220
+
221
+ return entry.data;
222
+ }
@@ -0,0 +1,190 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { access, mkdir, open, readdir, readFile, rename, stat, unlink } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { READCACHE_OBJECT_MAX_AGE_MS, READCACHE_OBJECTS_DIR, READCACHE_TMP_DIR } from "./constants.js";
6
+
7
+ const HASH_HEX_RE = /^[a-f0-9]{64}$/;
8
+
9
+ export interface ObjectStorePaths {
10
+ objectsDir: string;
11
+ tmpDir: string;
12
+ }
13
+
14
+ export interface PersistObjectResult {
15
+ hash: string;
16
+ path: string;
17
+ written: boolean;
18
+ }
19
+
20
+ export interface ObjectStoreStats {
21
+ objects: number;
22
+ bytes: number;
23
+ }
24
+
25
+ export interface PruneObjectsResult {
26
+ scanned: number;
27
+ deleted: number;
28
+ cutoffMs: number;
29
+ }
30
+
31
+ function ensureValidHash(hash: string): void {
32
+ if (!HASH_HEX_RE.test(hash)) {
33
+ throw new Error(`Invalid sha256 hash "${hash}".`);
34
+ }
35
+ }
36
+
37
+ function isObjectFileName(name: string): boolean {
38
+ return name.startsWith("sha256-") && name.endsWith(".txt");
39
+ }
40
+
41
+ export function hashBytes(buffer: Buffer): string {
42
+ return createHash("sha256").update(buffer).digest("hex");
43
+ }
44
+
45
+ export function hashText(text: string): string {
46
+ return hashBytes(Buffer.from(text, "utf-8"));
47
+ }
48
+
49
+ export function getStorePaths(repoRoot: string): ObjectStorePaths {
50
+ return {
51
+ objectsDir: join(repoRoot, READCACHE_OBJECTS_DIR),
52
+ tmpDir: join(repoRoot, READCACHE_TMP_DIR),
53
+ };
54
+ }
55
+
56
+ export function objectPathForHash(repoRoot: string, hash: string): string {
57
+ ensureValidHash(hash);
58
+ const { objectsDir } = getStorePaths(repoRoot);
59
+ return join(objectsDir, `sha256-${hash}.txt`);
60
+ }
61
+
62
+ export async function ensureStoreDirs(repoRoot: string): Promise<ObjectStorePaths> {
63
+ const paths = getStorePaths(repoRoot);
64
+ await mkdir(paths.objectsDir, { recursive: true, mode: 0o700 });
65
+ await mkdir(paths.tmpDir, { recursive: true, mode: 0o700 });
66
+ return paths;
67
+ }
68
+
69
+ async function exists(path: string): Promise<boolean> {
70
+ try {
71
+ await access(path, constants.F_OK);
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ export async function persistObjectIfAbsent(repoRoot: string, hash: string, text: string): Promise<PersistObjectResult> {
79
+ ensureValidHash(hash);
80
+ const { tmpDir } = await ensureStoreDirs(repoRoot);
81
+ const objectPath = objectPathForHash(repoRoot, hash);
82
+
83
+ if (await exists(objectPath)) {
84
+ return { hash, path: objectPath, written: false };
85
+ }
86
+
87
+ const tempPath = join(tmpDir, `sha256-${hash}-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`);
88
+ let tempFileCreated = false;
89
+
90
+ try {
91
+ const handle = await open(tempPath, "wx", 0o600);
92
+ tempFileCreated = true;
93
+ try {
94
+ await handle.writeFile(text, "utf-8");
95
+ await handle.sync();
96
+ } finally {
97
+ await handle.close();
98
+ }
99
+
100
+ await rename(tempPath, objectPath);
101
+ return { hash, path: objectPath, written: true };
102
+ } catch (error) {
103
+ const errorCode = (error as NodeJS.ErrnoException).code;
104
+ if (errorCode === "EEXIST") {
105
+ if (tempFileCreated && (await exists(tempPath))) {
106
+ await unlink(tempPath);
107
+ }
108
+ return { hash, path: objectPath, written: false };
109
+ }
110
+
111
+ if (tempFileCreated && (await exists(tempPath))) {
112
+ await unlink(tempPath);
113
+ }
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ export async function loadObject(repoRoot: string, hash: string): Promise<string | undefined> {
119
+ ensureValidHash(hash);
120
+ const objectPath = objectPathForHash(repoRoot, hash);
121
+ try {
122
+ return await readFile(objectPath, "utf-8");
123
+ } catch (error) {
124
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
125
+ return undefined;
126
+ }
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ export async function getStoreStats(repoRoot: string): Promise<ObjectStoreStats> {
132
+ const { objectsDir } = await ensureStoreDirs(repoRoot);
133
+ const entries = await readdir(objectsDir, { withFileTypes: true });
134
+
135
+ let objects = 0;
136
+ let bytes = 0;
137
+
138
+ for (const entry of entries) {
139
+ if (!entry.isFile() || !isObjectFileName(entry.name)) {
140
+ continue;
141
+ }
142
+ objects += 1;
143
+ const info = await stat(join(objectsDir, entry.name));
144
+ bytes += info.size;
145
+ }
146
+
147
+ return { objects, bytes };
148
+ }
149
+
150
+ export async function pruneObjectsOlderThan(
151
+ repoRoot: string,
152
+ maxAgeMs = READCACHE_OBJECT_MAX_AGE_MS,
153
+ nowMs = Date.now(),
154
+ ): Promise<PruneObjectsResult> {
155
+ if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) {
156
+ throw new Error(`Invalid maxAgeMs "${String(maxAgeMs)}".`);
157
+ }
158
+
159
+ const { objectsDir } = await ensureStoreDirs(repoRoot);
160
+ const entries = await readdir(objectsDir, { withFileTypes: true });
161
+ const cutoffMs = nowMs - maxAgeMs;
162
+
163
+ let scanned = 0;
164
+ let deleted = 0;
165
+
166
+ for (const entry of entries) {
167
+ if (!entry.isFile() || !isObjectFileName(entry.name)) {
168
+ continue;
169
+ }
170
+ scanned += 1;
171
+ const filePath = join(objectsDir, entry.name);
172
+ let info;
173
+ try {
174
+ info = await stat(filePath);
175
+ } catch {
176
+ continue;
177
+ }
178
+ if (info.mtimeMs > cutoffMs) {
179
+ continue;
180
+ }
181
+ try {
182
+ await unlink(filePath);
183
+ deleted += 1;
184
+ } catch {
185
+ // Fail-open: stale-object pruning must not break extension startup.
186
+ }
187
+ }
188
+
189
+ return { scanned, deleted, cutoffMs };
190
+ }