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/path.ts ADDED
@@ -0,0 +1,246 @@
1
+ import { accessSync, constants } from "node:fs";
2
+ import * as os from "node:os";
3
+ import { isAbsolute, resolve as resolvePath } from "node:path";
4
+ import { SCOPE_FULL, scopeRange } from "./constants.js";
5
+ import type { ScopeKey } from "./types.js";
6
+
7
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
8
+ const NARROW_NO_BREAK_SPACE = "\u202F";
9
+
10
+ function normalizeUnicodeSpaces(value: string): string {
11
+ return value.replace(UNICODE_SPACES, " ");
12
+ }
13
+
14
+ function normalizeAtPrefix(value: string): string {
15
+ return value.startsWith("@") ? value.slice(1) : value;
16
+ }
17
+
18
+ function tryMacOSScreenshotPath(filePath: string): string {
19
+ return filePath.replace(/ (AM|PM)\./g, `${NARROW_NO_BREAK_SPACE}$1.`);
20
+ }
21
+
22
+ function tryNfdVariant(filePath: string): string {
23
+ return filePath.normalize("NFD");
24
+ }
25
+
26
+ function tryCurlyQuoteVariant(filePath: string): string {
27
+ return filePath.replace(/'/g, "\u2019");
28
+ }
29
+
30
+ function fileExists(filePath: string): boolean {
31
+ try {
32
+ accessSync(filePath, constants.F_OK);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function validateLineNumber(value: number, label: "start" | "end"): void {
40
+ if (!Number.isInteger(value) || value <= 0) {
41
+ throw new Error(`Invalid ${label} line number "${value}". Line numbers must be positive integers.`);
42
+ }
43
+ }
44
+
45
+ function parseRangeSuffix(suffix: string): { start: number; end?: number } {
46
+ const singleLineMatch = /^(\d+)$/.exec(suffix);
47
+ if (singleLineMatch) {
48
+ const startRaw = singleLineMatch[1];
49
+ if (!startRaw) {
50
+ throw new Error(`Invalid range suffix "${suffix}". Use :<start> or :<start>-<end>.`);
51
+ }
52
+ const start = Number.parseInt(startRaw, 10);
53
+ validateLineNumber(start, "start");
54
+ return { start };
55
+ }
56
+
57
+ const rangeMatch = /^(\d+)-(\d+)$/.exec(suffix);
58
+ if (rangeMatch) {
59
+ const startRaw = rangeMatch[1];
60
+ const endRaw = rangeMatch[2];
61
+ if (!startRaw || !endRaw) {
62
+ throw new Error(`Invalid range suffix "${suffix}". Use :<start> or :<start>-<end>.`);
63
+ }
64
+ const start = Number.parseInt(startRaw, 10);
65
+ const end = Number.parseInt(endRaw, 10);
66
+ validateLineNumber(start, "start");
67
+ validateLineNumber(end, "end");
68
+ if (end < start) {
69
+ throw new Error(`Invalid range "${suffix}": end line must be greater than or equal to start line.`);
70
+ }
71
+ return { start, end };
72
+ }
73
+
74
+ throw new Error(`Invalid range suffix "${suffix}". Use :<start> or :<start>-<end>.`);
75
+ }
76
+
77
+ export interface ParsedReadPath {
78
+ pathInput: string;
79
+ absolutePath: string;
80
+ offset?: number;
81
+ limit?: number;
82
+ parsedFromPath: boolean;
83
+ }
84
+
85
+ export interface NormalizedRange {
86
+ start: number;
87
+ end: number;
88
+ totalLines: number;
89
+ }
90
+
91
+ export function expandPath(filePath: string): string {
92
+ const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath));
93
+ if (normalized === "~") {
94
+ return os.homedir();
95
+ }
96
+ if (normalized.startsWith("~/")) {
97
+ return os.homedir() + normalized.slice(1);
98
+ }
99
+ return normalized;
100
+ }
101
+
102
+ export function resolveToCwd(filePath: string, cwd: string): string {
103
+ const expanded = expandPath(filePath);
104
+ if (isAbsolute(expanded)) {
105
+ return expanded;
106
+ }
107
+ return resolvePath(cwd, expanded);
108
+ }
109
+
110
+ export function resolveReadPath(filePath: string, cwd: string): string {
111
+ const resolved = resolveToCwd(filePath, cwd);
112
+ if (fileExists(resolved)) {
113
+ return resolved;
114
+ }
115
+
116
+ const amPmVariant = tryMacOSScreenshotPath(resolved);
117
+ if (amPmVariant !== resolved && fileExists(amPmVariant)) {
118
+ return amPmVariant;
119
+ }
120
+
121
+ const nfdVariant = tryNfdVariant(resolved);
122
+ if (nfdVariant !== resolved && fileExists(nfdVariant)) {
123
+ return nfdVariant;
124
+ }
125
+
126
+ const curlyVariant = tryCurlyQuoteVariant(resolved);
127
+ if (curlyVariant !== resolved && fileExists(curlyVariant)) {
128
+ return curlyVariant;
129
+ }
130
+
131
+ const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);
132
+ if (nfdCurlyVariant !== resolved && fileExists(nfdCurlyVariant)) {
133
+ return nfdCurlyVariant;
134
+ }
135
+
136
+ return resolved;
137
+ }
138
+
139
+ export function normalizeInputPath(rawPath: string, cwd: string): ParsedReadPath {
140
+ return {
141
+ pathInput: rawPath,
142
+ absolutePath: resolveReadPath(rawPath, cwd),
143
+ parsedFromPath: false,
144
+ };
145
+ }
146
+
147
+ export function parseTrailingRangeIfNeeded(
148
+ rawPath: string,
149
+ explicitOffset: number | undefined,
150
+ explicitLimit: number | undefined,
151
+ cwd: string,
152
+ ): ParsedReadPath {
153
+ if (explicitOffset !== undefined || explicitLimit !== undefined) {
154
+ const parsed: ParsedReadPath = {
155
+ pathInput: rawPath,
156
+ absolutePath: resolveReadPath(rawPath, cwd),
157
+ parsedFromPath: false,
158
+ };
159
+ if (explicitOffset !== undefined) {
160
+ parsed.offset = explicitOffset;
161
+ }
162
+ if (explicitLimit !== undefined) {
163
+ parsed.limit = explicitLimit;
164
+ }
165
+ return parsed;
166
+ }
167
+
168
+ const resolvedRawPath = resolveReadPath(rawPath, cwd);
169
+ if (fileExists(resolvedRawPath)) {
170
+ return {
171
+ pathInput: rawPath,
172
+ absolutePath: resolvedRawPath,
173
+ parsedFromPath: false,
174
+ };
175
+ }
176
+
177
+ const lastColonIndex = rawPath.lastIndexOf(":");
178
+ if (lastColonIndex <= 0 || lastColonIndex === rawPath.length - 1) {
179
+ return {
180
+ pathInput: rawPath,
181
+ absolutePath: resolvedRawPath,
182
+ parsedFromPath: false,
183
+ };
184
+ }
185
+
186
+ const candidatePath = rawPath.slice(0, lastColonIndex);
187
+ const suffix = rawPath.slice(lastColonIndex + 1);
188
+ const resolvedCandidatePath = resolveReadPath(candidatePath, cwd);
189
+ if (!fileExists(resolvedCandidatePath)) {
190
+ return {
191
+ pathInput: rawPath,
192
+ absolutePath: resolvedRawPath,
193
+ parsedFromPath: false,
194
+ };
195
+ }
196
+
197
+ const parsedRange = parseRangeSuffix(suffix);
198
+ const offset = parsedRange.start;
199
+ const limit = parsedRange.end !== undefined ? parsedRange.end - parsedRange.start + 1 : undefined;
200
+
201
+ const parsed: ParsedReadPath = {
202
+ pathInput: candidatePath,
203
+ absolutePath: resolvedCandidatePath,
204
+ offset,
205
+ parsedFromPath: true,
206
+ };
207
+ if (limit !== undefined) {
208
+ parsed.limit = limit;
209
+ }
210
+ return parsed;
211
+ }
212
+
213
+ export function normalizeOffsetLimit(
214
+ offset: number | undefined,
215
+ limit: number | undefined,
216
+ totalLines: number,
217
+ ): NormalizedRange {
218
+ if (offset !== undefined && (!Number.isInteger(offset) || offset <= 0)) {
219
+ throw new Error(`Invalid offset "${offset}". Offset must be a positive integer.`);
220
+ }
221
+ if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
222
+ throw new Error(`Invalid limit "${limit}". Limit must be a positive integer.`);
223
+ }
224
+
225
+ const normalizedTotalLines = Math.max(1, totalLines);
226
+ const start = offset ?? 1;
227
+ if (start > normalizedTotalLines) {
228
+ throw new Error(`Offset ${start} is beyond end of file (${normalizedTotalLines} lines total)`);
229
+ }
230
+
231
+ const unclampedEnd = limit !== undefined ? start + limit - 1 : normalizedTotalLines;
232
+ const end = Math.min(unclampedEnd, normalizedTotalLines);
233
+
234
+ return {
235
+ start,
236
+ end,
237
+ totalLines: normalizedTotalLines,
238
+ };
239
+ }
240
+
241
+ export function scopeKeyForRange(start: number, end: number, totalLines: number): ScopeKey {
242
+ if (start === 1 && end === totalLines) {
243
+ return SCOPE_FULL;
244
+ }
245
+ return scopeRange(start, end);
246
+ }
package/src/replay.ts ADDED
@@ -0,0 +1,257 @@
1
+ import type { ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
2
+ import { SCOPE_FULL } from "./constants.js";
3
+ import { extractInvalidationFromSessionEntry, extractReadMetaFromSessionEntry } from "./meta.js";
4
+ import type {
5
+ KnowledgeMap,
6
+ ReadCacheInvalidationV1,
7
+ ReadCacheMetaV1,
8
+ ScopeKey,
9
+ ScopeTrust,
10
+ } from "./types.js";
11
+
12
+ type SessionManagerView = ExtensionContext["sessionManager"];
13
+
14
+ const OVERLAY_SEQ_START = 1_000_000_000;
15
+
16
+ interface OverlayState {
17
+ leafId: string | null;
18
+ knowledge: KnowledgeMap;
19
+ }
20
+
21
+ export interface ReplayRuntimeState {
22
+ memoByLeaf: Map<string, KnowledgeMap>;
23
+ overlayBySession: Map<string, OverlayState>;
24
+ nextOverlaySeq: number;
25
+ }
26
+
27
+ export interface ReplayBoundary {
28
+ startIndex: number;
29
+ boundaryKey: string;
30
+ }
31
+
32
+ function cloneKnowledgeMap(source: KnowledgeMap): KnowledgeMap {
33
+ const cloned: KnowledgeMap = new Map();
34
+ for (const [pathKey, scopes] of source.entries()) {
35
+ const clonedScopes = new Map<ScopeKey, ScopeTrust>();
36
+ for (const [scopeKey, trust] of scopes.entries()) {
37
+ clonedScopes.set(scopeKey, { ...trust });
38
+ }
39
+ cloned.set(pathKey, clonedScopes);
40
+ }
41
+ return cloned;
42
+ }
43
+
44
+ function getMemoKey(sessionId: string, leafId: string | null, boundaryKey: string): string {
45
+ return `${sessionId}:${leafId ?? "null"}:${boundaryKey}`;
46
+ }
47
+
48
+ function ensureScopeMap(knowledge: KnowledgeMap, pathKey: string): Map<ScopeKey, ScopeTrust> {
49
+ const existing = knowledge.get(pathKey);
50
+ if (existing) {
51
+ return existing;
52
+ }
53
+ const created = new Map<ScopeKey, ScopeTrust>();
54
+ knowledge.set(pathKey, created);
55
+ return created;
56
+ }
57
+
58
+ export function getTrust(knowledge: KnowledgeMap, pathKey: string, scopeKey: ScopeKey): ScopeTrust | undefined {
59
+ return knowledge.get(pathKey)?.get(scopeKey);
60
+ }
61
+
62
+ export function setTrust(knowledge: KnowledgeMap, pathKey: string, scopeKey: ScopeKey, hash: string, seq: number): void {
63
+ const scopes = ensureScopeMap(knowledge, pathKey);
64
+ scopes.set(scopeKey, { hash, seq });
65
+ }
66
+
67
+ function mergeKnowledge(base: KnowledgeMap, overlay: KnowledgeMap): KnowledgeMap {
68
+ const merged = cloneKnowledgeMap(base);
69
+ for (const [pathKey, overlayScopes] of overlay.entries()) {
70
+ const targetScopes = ensureScopeMap(merged, pathKey);
71
+ for (const [scopeKey, trust] of overlayScopes.entries()) {
72
+ targetScopes.set(scopeKey, { ...trust });
73
+ }
74
+ }
75
+ return merged;
76
+ }
77
+
78
+ function ensureOverlayForLeaf(runtimeState: ReplayRuntimeState, sessionId: string, leafId: string | null): OverlayState {
79
+ const existing = runtimeState.overlayBySession.get(sessionId);
80
+ if (!existing || existing.leafId !== leafId) {
81
+ const fresh: OverlayState = {
82
+ leafId,
83
+ knowledge: new Map(),
84
+ };
85
+ runtimeState.overlayBySession.set(sessionId, fresh);
86
+ return fresh;
87
+ }
88
+ return existing;
89
+ }
90
+
91
+ function leafHasChildren(sessionManager: SessionManagerView, leafId: string | null): boolean {
92
+ if (!leafId) {
93
+ return false;
94
+ }
95
+ return sessionManager.getEntries().some((entry) => entry.parentId === leafId);
96
+ }
97
+
98
+ export function createReplayRuntimeState(): ReplayRuntimeState {
99
+ return {
100
+ memoByLeaf: new Map(),
101
+ overlayBySession: new Map(),
102
+ nextOverlaySeq: OVERLAY_SEQ_START,
103
+ };
104
+ }
105
+
106
+ export function clearReplayRuntimeState(runtimeState: ReplayRuntimeState): void {
107
+ runtimeState.memoByLeaf.clear();
108
+ runtimeState.overlayBySession.clear();
109
+ runtimeState.nextOverlaySeq = OVERLAY_SEQ_START;
110
+ }
111
+
112
+ export function findReplayStartIndex(branchEntries: SessionEntry[]): ReplayBoundary {
113
+ for (let index = branchEntries.length - 1; index >= 0; index -= 1) {
114
+ const entry = branchEntries[index];
115
+ if (!entry || entry.type !== "compaction") {
116
+ continue;
117
+ }
118
+
119
+ return {
120
+ startIndex: Math.min(index + 1, branchEntries.length),
121
+ boundaryKey: `compaction:${entry.id}`,
122
+ };
123
+ }
124
+
125
+ return {
126
+ startIndex: 0,
127
+ boundaryKey: "root",
128
+ };
129
+ }
130
+
131
+ export function applyReadMetaTransition(knowledge: KnowledgeMap, meta: ReadCacheMetaV1, seq: number): void {
132
+ const { pathKey, scopeKey, servedHash, baseHash, mode } = meta;
133
+ const fullTrust = getTrust(knowledge, pathKey, SCOPE_FULL);
134
+ const rangeTrust = scopeKey === SCOPE_FULL ? undefined : getTrust(knowledge, pathKey, scopeKey);
135
+
136
+ if (mode === "full" || mode === "full_fallback") {
137
+ setTrust(knowledge, pathKey, scopeKey, servedHash, seq);
138
+ return;
139
+ }
140
+
141
+ if (mode === "unchanged" && scopeKey === SCOPE_FULL) {
142
+ if (!baseHash) {
143
+ return;
144
+ }
145
+ if (!fullTrust || fullTrust.hash !== baseHash) {
146
+ return;
147
+ }
148
+ if (servedHash !== baseHash) {
149
+ return;
150
+ }
151
+ setTrust(knowledge, pathKey, SCOPE_FULL, servedHash, seq);
152
+ return;
153
+ }
154
+
155
+ if (mode === "diff" && scopeKey === SCOPE_FULL) {
156
+ if (!baseHash) {
157
+ return;
158
+ }
159
+ if (!fullTrust || fullTrust.hash !== baseHash) {
160
+ return;
161
+ }
162
+ setTrust(knowledge, pathKey, SCOPE_FULL, servedHash, seq);
163
+ return;
164
+ }
165
+
166
+ if (mode === "unchanged_range" && scopeKey !== SCOPE_FULL) {
167
+ if (!baseHash) {
168
+ return;
169
+ }
170
+ if (rangeTrust?.hash !== baseHash && fullTrust?.hash !== baseHash) {
171
+ return;
172
+ }
173
+ setTrust(knowledge, pathKey, scopeKey, servedHash, seq);
174
+ }
175
+ }
176
+
177
+ export function applyInvalidation(knowledge: KnowledgeMap, invalidation: ReadCacheInvalidationV1): void {
178
+ const scopes = knowledge.get(invalidation.pathKey);
179
+ if (!scopes) {
180
+ return;
181
+ }
182
+
183
+ if (invalidation.scopeKey === SCOPE_FULL) {
184
+ knowledge.delete(invalidation.pathKey);
185
+ return;
186
+ }
187
+
188
+ scopes.delete(invalidation.scopeKey);
189
+ if (scopes.size === 0) {
190
+ knowledge.delete(invalidation.pathKey);
191
+ }
192
+ }
193
+
194
+ export function replayKnowledgeFromBranch(branchEntries: SessionEntry[], startIndex: number): KnowledgeMap {
195
+ const knowledge: KnowledgeMap = new Map();
196
+ const normalizedStart = Math.max(0, Math.min(startIndex, branchEntries.length));
197
+ let seq = 0;
198
+
199
+ for (let index = normalizedStart; index < branchEntries.length; index += 1) {
200
+ const entry = branchEntries[index];
201
+ if (!entry) {
202
+ continue;
203
+ }
204
+
205
+ const meta = extractReadMetaFromSessionEntry(entry);
206
+ if (meta) {
207
+ seq += 1;
208
+ applyReadMetaTransition(knowledge, meta, seq);
209
+ continue;
210
+ }
211
+
212
+ const invalidation = extractInvalidationFromSessionEntry(entry);
213
+ if (invalidation) {
214
+ applyInvalidation(knowledge, invalidation);
215
+ }
216
+ }
217
+
218
+ return knowledge;
219
+ }
220
+
221
+ export function buildKnowledgeForLeaf(
222
+ sessionManager: SessionManagerView,
223
+ runtimeState: ReplayRuntimeState,
224
+ ): KnowledgeMap {
225
+ const sessionId = sessionManager.getSessionId();
226
+ const leafId = sessionManager.getLeafId();
227
+ const branchEntries = sessionManager.getBranch();
228
+ const boundary = findReplayStartIndex(branchEntries);
229
+ const memoKey = getMemoKey(sessionId, leafId, boundary.boundaryKey);
230
+
231
+ let replayKnowledge = runtimeState.memoByLeaf.get(memoKey);
232
+ if (!replayKnowledge) {
233
+ replayKnowledge = replayKnowledgeFromBranch(branchEntries, boundary.startIndex);
234
+ runtimeState.memoByLeaf.set(memoKey, cloneKnowledgeMap(replayKnowledge));
235
+ }
236
+
237
+ const overlayState = ensureOverlayForLeaf(runtimeState, sessionId, leafId);
238
+ if (leafHasChildren(sessionManager, leafId)) {
239
+ overlayState.knowledge.clear();
240
+ }
241
+ return mergeKnowledge(replayKnowledge, overlayState.knowledge);
242
+ }
243
+
244
+ export function overlaySet(
245
+ runtimeState: ReplayRuntimeState,
246
+ sessionManager: SessionManagerView,
247
+ pathKey: string,
248
+ scopeKey: ScopeKey,
249
+ servedHash: string,
250
+ ): void {
251
+ const sessionId = sessionManager.getSessionId();
252
+ const leafId = sessionManager.getLeafId();
253
+ const overlayState = ensureOverlayForLeaf(runtimeState, sessionId, leafId);
254
+ const seq = runtimeState.nextOverlaySeq;
255
+ runtimeState.nextOverlaySeq += 1;
256
+ setTrust(overlayState.knowledge, pathKey, scopeKey, servedHash, seq);
257
+ }
@@ -0,0 +1,108 @@
1
+ import type { ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
2
+ import { extractReadMetaFromSessionEntry } from "./meta.js";
3
+ import { findReplayStartIndex } from "./replay.js";
4
+ import type { KnowledgeMap, ReadCacheMode } from "./types.js";
5
+
6
+ export type ReadCacheModeCounts = Record<ReadCacheMode, number>;
7
+
8
+ export interface ReplayTelemetrySummary {
9
+ replayStartIndex: number;
10
+ replayEntryCount: number;
11
+ modeCounts: ReadCacheModeCounts;
12
+ estimatedBytesSaved: number;
13
+ estimatedTokensSaved: number;
14
+ }
15
+
16
+ function createModeCounts(): ReadCacheModeCounts {
17
+ return {
18
+ full: 0,
19
+ unchanged: 0,
20
+ unchanged_range: 0,
21
+ diff: 0,
22
+ full_fallback: 0,
23
+ };
24
+ }
25
+
26
+ function estimateSelectedBytes(meta: {
27
+ bytes: number;
28
+ rangeStart: number;
29
+ rangeEnd: number;
30
+ totalLines: number;
31
+ }): number {
32
+ const rangeLines = Math.max(1, meta.rangeEnd - meta.rangeStart + 1);
33
+ const totalLines = Math.max(1, meta.totalLines);
34
+ const ratio = Math.min(1, rangeLines / totalLines);
35
+ return Math.max(1, Math.round(meta.bytes * ratio));
36
+ }
37
+
38
+ function extractTextBytes(entry: SessionEntry): number {
39
+ if (entry.type !== "message" || entry.message.role !== "toolResult") {
40
+ return 0;
41
+ }
42
+
43
+ let total = 0;
44
+ for (const block of entry.message.content) {
45
+ if (block.type !== "text") {
46
+ continue;
47
+ }
48
+ total += Buffer.byteLength(block.text, "utf-8");
49
+ }
50
+ return total;
51
+ }
52
+
53
+ function estimateSavedBytes(entry: SessionEntry, baselineBytes: number): number {
54
+ const servedBytes = extractTextBytes(entry);
55
+ if (servedBytes <= 0) {
56
+ return 0;
57
+ }
58
+ return Math.max(0, baselineBytes - servedBytes);
59
+ }
60
+
61
+ function estimateTokensFromBytes(bytes: number): number {
62
+ if (bytes <= 0) {
63
+ return 0;
64
+ }
65
+ return Math.ceil(bytes / 4);
66
+ }
67
+
68
+ export function collectReplayTelemetry(sessionManager: ExtensionContext["sessionManager"]): ReplayTelemetrySummary {
69
+ const branchEntries = sessionManager.getBranch();
70
+ const boundary = findReplayStartIndex(branchEntries);
71
+ const modeCounts = createModeCounts();
72
+ let estimatedBytesSaved = 0;
73
+
74
+ for (let index = boundary.startIndex; index < branchEntries.length; index += 1) {
75
+ const entry = branchEntries[index];
76
+ if (!entry) {
77
+ continue;
78
+ }
79
+
80
+ const meta = extractReadMetaFromSessionEntry(entry);
81
+ if (!meta) {
82
+ continue;
83
+ }
84
+
85
+ modeCounts[meta.mode] += 1;
86
+ const baselineBytes = estimateSelectedBytes(meta);
87
+ estimatedBytesSaved += estimateSavedBytes(entry, baselineBytes);
88
+ }
89
+
90
+ return {
91
+ replayStartIndex: boundary.startIndex,
92
+ replayEntryCount: Math.max(0, branchEntries.length - boundary.startIndex),
93
+ modeCounts,
94
+ estimatedBytesSaved,
95
+ estimatedTokensSaved: estimateTokensFromBytes(estimatedBytesSaved),
96
+ };
97
+ }
98
+
99
+ export function summarizeKnowledge(knowledge: KnowledgeMap): { trackedFiles: number; trackedScopes: number } {
100
+ let trackedScopes = 0;
101
+ for (const scopes of knowledge.values()) {
102
+ trackedScopes += scopes.size;
103
+ }
104
+ return {
105
+ trackedFiles: knowledge.size,
106
+ trackedScopes,
107
+ };
108
+ }
package/src/text.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { truncateHead, type TruncationOptions, type TruncationResult } from "@mariozechner/pi-coding-agent";
2
+
3
+ function validateRange(start: number, end: number): void {
4
+ if (!Number.isInteger(start) || start <= 0) {
5
+ throw new Error(`Invalid start line "${start}". Line numbers must be positive integers.`);
6
+ }
7
+ if (!Number.isInteger(end) || end <= 0) {
8
+ throw new Error(`Invalid end line "${end}". Line numbers must be positive integers.`);
9
+ }
10
+ if (end < start) {
11
+ throw new Error(`Invalid range ${start}-${end}. End line must be greater than or equal to start line.`);
12
+ }
13
+ }
14
+
15
+ export function splitLines(text: string): string[] {
16
+ return text.split("\n");
17
+ }
18
+
19
+ export function sliceByLineRange(text: string, start: number, end: number): string {
20
+ validateRange(start, end);
21
+ const lines = splitLines(text);
22
+ if (start > lines.length) {
23
+ return "";
24
+ }
25
+
26
+ const clampedEnd = Math.min(end, lines.length);
27
+ return lines.slice(start - 1, clampedEnd).join("\n");
28
+ }
29
+
30
+ export function compareSlices(oldText: string, newText: string, start: number, end: number): boolean {
31
+ return sliceByLineRange(oldText, start, end) === sliceByLineRange(newText, start, end);
32
+ }
33
+
34
+ export function estimateTokens(text: string): number {
35
+ if (text.length === 0) {
36
+ return 0;
37
+ }
38
+ return Math.ceil(Buffer.byteLength(text, "utf-8") / 4);
39
+ }
40
+
41
+ export function truncateForReadcache(content: string, options?: TruncationOptions): TruncationResult {
42
+ return truncateHead(content, options);
43
+ }