pi-readseek 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.
Files changed (67) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +41 -0
  3. package/index.ts +142 -0
  4. package/package.json +73 -0
  5. package/prompts/edit.md +113 -0
  6. package/prompts/find.md +19 -0
  7. package/prompts/grep.md +26 -0
  8. package/prompts/ls.md +11 -0
  9. package/prompts/read.md +33 -0
  10. package/prompts/sg.md +25 -0
  11. package/prompts/write.md +46 -0
  12. package/src/binary-detect.ts +22 -0
  13. package/src/binary-resolution.ts +77 -0
  14. package/src/coerce-obvious-int.ts +39 -0
  15. package/src/context-application.ts +70 -0
  16. package/src/context-hygiene.ts +503 -0
  17. package/src/diff-data.ts +303 -0
  18. package/src/doom-loop-suggestions.ts +42 -0
  19. package/src/doom-loop.ts +216 -0
  20. package/src/edit-classify.ts +190 -0
  21. package/src/edit-diff.ts +354 -0
  22. package/src/edit-output.ts +107 -0
  23. package/src/edit-render-helpers.ts +141 -0
  24. package/src/edit-syntax-validate.ts +120 -0
  25. package/src/edit.ts +725 -0
  26. package/src/find-parsers.ts +89 -0
  27. package/src/find-stat.ts +36 -0
  28. package/src/find.ts +613 -0
  29. package/src/grep-budget.ts +79 -0
  30. package/src/grep-output.ts +197 -0
  31. package/src/grep-render-helpers.ts +77 -0
  32. package/src/grep-symbol-scope.ts +197 -0
  33. package/src/grep.ts +792 -0
  34. package/src/hashline.ts +747 -0
  35. package/src/ls.ts +293 -0
  36. package/src/map-cache.ts +152 -0
  37. package/src/path-utils.ts +24 -0
  38. package/src/pending-diff-preview.ts +269 -0
  39. package/src/persistent-map-cache.ts +251 -0
  40. package/src/read-local-bundle.ts +87 -0
  41. package/src/read-output.ts +212 -0
  42. package/src/read-render-helpers.ts +104 -0
  43. package/src/read.ts +748 -0
  44. package/src/readseek/constants.ts +21 -0
  45. package/src/readseek/enums.ts +38 -0
  46. package/src/readseek/formatter.ts +431 -0
  47. package/src/readseek/language-detect.ts +29 -0
  48. package/src/readseek/mapper.ts +69 -0
  49. package/src/readseek/parser-errors.ts +22 -0
  50. package/src/readseek/parser-loader.ts +83 -0
  51. package/src/readseek/symbol-error-format.ts +18 -0
  52. package/src/readseek/symbol-lookup.ts +294 -0
  53. package/src/readseek/types.ts +79 -0
  54. package/src/readseek-client.ts +343 -0
  55. package/src/readseek-error-codes.ts +54 -0
  56. package/src/readseek-settings.ts +287 -0
  57. package/src/readseek-value.ts +144 -0
  58. package/src/replace-symbol.ts +74 -0
  59. package/src/runtime.ts +3 -0
  60. package/src/sg-output.ts +88 -0
  61. package/src/sg.ts +308 -0
  62. package/src/syntax-validate-mode.ts +25 -0
  63. package/src/tool-prompt-metadata.ts +76 -0
  64. package/src/tui-diff-component.ts +86 -0
  65. package/src/tui-diff-renderer.ts +92 -0
  66. package/src/tui-render-utils.ts +129 -0
  67. package/src/write.ts +532 -0
@@ -0,0 +1,269 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { generateDiffString, normalizeToLF, replaceText } from "./edit-diff.js";
4
+ import { applyHashlineEdits, type HashlineEditItem } from "./hashline.js";
5
+ import { replaceSymbol } from "./replace-symbol.js";
6
+
7
+ export const PENDING_DIFF_MAX_BYTES = 1024 * 1024;
8
+
9
+ export interface PendingDiffPreviewData {
10
+ filePath: string;
11
+ previousContent: string;
12
+ nextContent: string;
13
+ fileExistedBeforeWrite: boolean;
14
+ headerLabel: "pending edit" | "pending overwrite" | "pending create";
15
+ diff: string;
16
+ }
17
+
18
+ export type PendingDiffPreviewResult =
19
+ | { type: "ok"; data: PendingDiffPreviewData }
20
+ | { type: "skip"; reason: string };
21
+
22
+ function skip(reason: string): PendingDiffPreviewResult {
23
+ return { type: "skip", reason };
24
+ }
25
+
26
+ function isInsidePath(parent: string, child: string): boolean {
27
+ const rel = relative(parent, child);
28
+ return rel === "" || (rel !== "" && !rel.startsWith("..") && !rel.startsWith(sep) && !resolve(rel).startsWith(".." + sep));
29
+ }
30
+
31
+ function resolveWorkspacePreviewPath(rawPath: unknown, cwd: string, allowMissing: boolean): { type: "ok"; path: string; existed: boolean } | { type: "skip"; reason: string } {
32
+ if (typeof rawPath !== "string" || rawPath.trim() === "") return { type: "skip", reason: "missing path" };
33
+ const workspace = realpathSync(cwd);
34
+ const normalizedPath = rawPath.replace(/^@/, "");
35
+ const explicitAbsolute = isAbsolute(normalizedPath);
36
+ const requested = resolve(workspace, normalizedPath);
37
+ if (existsSync(requested)) {
38
+ const realTarget = realpathSync(requested);
39
+ if (!explicitAbsolute && !isInsidePath(workspace, realTarget)) return { type: "skip", reason: "path outside workspace" };
40
+ return { type: "ok", path: realTarget, existed: true };
41
+ }
42
+
43
+ if (!explicitAbsolute && !isInsidePath(workspace, requested)) return { type: "skip", reason: "path outside workspace" };
44
+
45
+ if (!allowMissing) return { type: "skip", reason: "file not found" };
46
+ const parent = dirname(requested);
47
+ if (!existsSync(parent)) return { type: "skip", reason: "parent directory not found" };
48
+ return { type: "ok", path: requested, existed: false };
49
+ }
50
+
51
+ function readUtf8File(filePath: string): { type: "ok"; content: string } | { type: "skip"; reason: string } {
52
+ const stat = statSync(filePath);
53
+ if (!stat.isFile()) return { type: "skip", reason: "not a file" };
54
+ if (stat.size > PENDING_DIFF_MAX_BYTES) return { type: "skip", reason: "file too large" };
55
+ const content = readFileSync(filePath, "utf-8");
56
+ if (content.includes("\0")) return { type: "skip", reason: "binary file" };
57
+ return { type: "ok", content };
58
+ }
59
+
60
+ function buildData(
61
+ filePath: string,
62
+ previousContent: string,
63
+ nextContent: string,
64
+ existed: boolean,
65
+ headerLabel: PendingDiffPreviewData["headerLabel"],
66
+ ): PendingDiffPreviewResult {
67
+ const diff = generateDiffString(normalizeToLF(previousContent), normalizeToLF(nextContent)).diff;
68
+ return {
69
+ type: "ok",
70
+ data: {
71
+ filePath,
72
+ previousContent,
73
+ nextContent,
74
+ fileExistedBeforeWrite: existed,
75
+ headerLabel,
76
+ diff,
77
+ },
78
+ };
79
+ }
80
+
81
+ type ReplaceEdit = { replace: { old_text: string; new_text: string; all?: boolean; fuzzy?: boolean } };
82
+ type PendingEditInput = { path?: unknown; edits?: unknown[] };
83
+
84
+ function getEdits(input: PendingEditInput): unknown[] {
85
+ return Array.isArray(input.edits) ? input.edits : [];
86
+ }
87
+
88
+ function isReplaceEdit(edit: unknown): edit is ReplaceEdit {
89
+ if (!edit || typeof edit !== "object" || !("replace" in edit)) return false;
90
+ const replace = (edit as { replace?: { old_text?: unknown; new_text?: unknown } }).replace;
91
+ return typeof replace?.old_text === "string" && typeof replace?.new_text === "string";
92
+ }
93
+
94
+ type AnchorEdit =
95
+ | { set_line: { anchor: string; new_text: string } }
96
+ | { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
97
+ | { insert_after: { anchor: string; new_text: string; text?: string } };
98
+
99
+ function isAnchorEdit(edit: unknown): edit is AnchorEdit {
100
+ return !!edit && typeof edit === "object" && ("set_line" in edit || "replace_lines" in edit || "insert_after" in edit);
101
+ }
102
+
103
+ function applyAnchoredPreview(content: string, edits: AnchorEdit[]): { type: "ok"; content: string } | { type: "skip"; reason: string } {
104
+ try {
105
+ return { type: "ok", content: applyHashlineEdits(content, edits as HashlineEditItem[]).content };
106
+ } catch (err: any) {
107
+ return { type: "skip", reason: `anchor projection failed: ${err?.message ?? String(err)}` };
108
+ }
109
+ }
110
+
111
+ type ReplaceSymbolEdit = { replace_symbol: { symbol: string; new_body: string } };
112
+
113
+ function isReplaceSymbolEdit(edit: unknown): edit is ReplaceSymbolEdit {
114
+ if (!edit || typeof edit !== "object" || !("replace_symbol" in edit)) return false;
115
+ const replaceSymbol = (edit as { replace_symbol?: { symbol?: unknown; new_body?: unknown } }).replace_symbol;
116
+ return typeof replaceSymbol?.symbol === "string" && typeof replaceSymbol?.new_body === "string";
117
+ }
118
+
119
+ async function applyReplaceSymbolPreview(filePath: string, content: string, edit: ReplaceSymbolEdit): Promise<{ type: "ok"; content: string } | { type: "skip"; reason: string }> {
120
+ try {
121
+ if (!edit.replace_symbol.new_body.trim()) return { type: "skip", reason: "replace_symbol new_body is empty" };
122
+ const probe = await replaceSymbol({
123
+ filePath,
124
+ content,
125
+ symbol: edit.replace_symbol.symbol,
126
+ newBody: edit.replace_symbol.new_body,
127
+ });
128
+ if (probe.type !== "ok") return { type: "skip", reason: `symbol projection failed: ${probe.message}` };
129
+ return { type: "ok", content: probe.content };
130
+ } catch (err: any) {
131
+ return { type: "skip", reason: `symbol projection failed: ${err?.message ?? String(err)}` };
132
+ }
133
+ }
134
+
135
+
136
+ function applyReplacePreview(content: string, edit: ReplaceEdit): { type: "ok"; content: string } | { type: "skip"; reason: string } {
137
+ const { old_text, new_text } = edit.replace;
138
+ if (!old_text.length) return { type: "skip", reason: "replace old_text is empty" };
139
+ const replacement = replaceText(content, old_text, new_text, {
140
+ all: edit.replace.all ?? false,
141
+ fuzzy: edit.replace.fuzzy ?? false,
142
+ });
143
+ if (!replacement.count) return { type: "skip", reason: "replace old_text was not found" };
144
+ return { type: "ok", content: replacement.content };
145
+ }
146
+
147
+ export function buildPendingWritePreviewData(input: { path?: unknown; content?: unknown }, cwd: string): PendingDiffPreviewResult {
148
+ if (typeof input.content !== "string") return skip("missing content");
149
+ if (Buffer.byteLength(input.content, "utf8") > PENDING_DIFF_MAX_BYTES) return skip("content too large");
150
+ const resolved = resolveWorkspacePreviewPath(input.path, cwd, true);
151
+ if (resolved.type === "skip") return resolved;
152
+ const previous = resolved.existed ? readUtf8File(resolved.path) : { type: "ok" as const, content: "" };
153
+ if (previous.type === "skip") return previous;
154
+ return buildData(resolved.path, previous.content, input.content, resolved.existed, resolved.existed ? "pending overwrite" : "pending create");
155
+ }
156
+
157
+ export async function buildPendingEditPreviewData(input: PendingEditInput, cwd: string): Promise<PendingDiffPreviewResult> {
158
+ const edits = getEdits(input);
159
+ if (edits.length === 0) return skip("missing edits");
160
+ const resolved = resolveWorkspacePreviewPath(input.path, cwd, false);
161
+ if (resolved.type === "skip") return resolved;
162
+ const previous = readUtf8File(resolved.path);
163
+ if (previous.type === "skip") return previous;
164
+ let next = normalizeToLF(previous.content);
165
+ const anchorBatch: AnchorEdit[] = [];
166
+
167
+ for (const edit of edits) {
168
+ if (isReplaceEdit(edit)) {
169
+ if (anchorBatch.length > 0) {
170
+ const anchored = applyAnchoredPreview(next, anchorBatch);
171
+ if (anchored.type === "skip") return anchored;
172
+ next = anchored.content;
173
+ anchorBatch.length = 0;
174
+ }
175
+ const projected = applyReplacePreview(next, edit);
176
+ if (projected.type === "skip") return projected;
177
+ next = projected.content;
178
+ continue;
179
+ }
180
+ if (isAnchorEdit(edit)) {
181
+ anchorBatch.push(edit);
182
+ continue;
183
+ }
184
+ if (isReplaceSymbolEdit(edit)) {
185
+ if (anchorBatch.length > 0) {
186
+ const anchored = applyAnchoredPreview(next, anchorBatch);
187
+ if (anchored.type === "skip") return anchored;
188
+ next = anchored.content;
189
+ anchorBatch.length = 0;
190
+ }
191
+ const projected = await applyReplaceSymbolPreview(resolved.path, next, edit);
192
+ if (projected.type === "skip") return projected;
193
+ next = projected.content;
194
+ continue;
195
+ }
196
+ return skip("unsupported edit variant");
197
+ }
198
+
199
+ if (anchorBatch.length > 0) {
200
+ const anchored = applyAnchoredPreview(next, anchorBatch);
201
+ if (anchored.type === "skip") return anchored;
202
+ next = anchored.content;
203
+ }
204
+ return buildData(resolved.path, previous.content, next, true, "pending edit");
205
+ }
206
+
207
+ export interface PendingDiffPreviewCacheSlot<T = PendingDiffPreviewResult> {
208
+ key?: string;
209
+ data?: T;
210
+ pending?: boolean;
211
+ }
212
+
213
+ export function buildEditPreviewKey(input: PendingEditInput): string | undefined {
214
+ if (typeof input.path !== "string") return undefined;
215
+ const edits = getEdits(input);
216
+ if (edits.length === 0) return undefined;
217
+ return JSON.stringify({ path: input.path, edits });
218
+ }
219
+
220
+ export function buildWritePreviewKey(input: { path?: unknown; content?: unknown }): string | undefined {
221
+ if (typeof input.path !== "string" || typeof input.content !== "string") return undefined;
222
+ return JSON.stringify({ path: input.path, content: input.content });
223
+ }
224
+
225
+ export function resolvePendingDiffPreview<T extends PendingDiffPreviewResult>(
226
+ context: { state?: Record<string, PendingDiffPreviewCacheSlot<T>>; invalidate?: () => void } | undefined,
227
+ stateKey: string,
228
+ previewKey: string | undefined,
229
+ compute: () => T | Promise<T>,
230
+ ): T | undefined {
231
+ if (!previewKey) return undefined;
232
+ const root = context?.state;
233
+ if (!root) return undefined;
234
+ const slot = (root[stateKey] ??= {} as PendingDiffPreviewCacheSlot<T>);
235
+ if (slot.key !== previewKey) {
236
+ slot.key = previewKey;
237
+ slot.data = undefined;
238
+ slot.pending = false;
239
+ }
240
+ if (slot.data !== undefined) return slot.data;
241
+ if (slot.pending) return undefined;
242
+
243
+ let value: T | Promise<T>;
244
+ try {
245
+ value = compute();
246
+ } catch (err: any) {
247
+ const skipped = { type: "skip", reason: `projection failed: ${err?.message ?? String(err)}` } as T;
248
+ slot.data = skipped;
249
+ return skipped;
250
+ }
251
+ if (value && typeof (value as any).then === "function") {
252
+ slot.pending = true;
253
+ void (value as Promise<T>).then((resolved) => {
254
+ if (slot.key !== previewKey) return;
255
+ slot.data = resolved;
256
+ slot.pending = false;
257
+ context?.invalidate?.();
258
+ }).catch((err: any) => {
259
+ if (slot.key !== previewKey) return;
260
+ slot.data = { type: "skip", reason: `projection failed: ${err?.message ?? String(err)}` } as T;
261
+ slot.pending = false;
262
+ context?.invalidate?.();
263
+ });
264
+ return undefined;
265
+ }
266
+
267
+ slot.data = value as T;
268
+ return slot.data;
269
+ }
@@ -0,0 +1,251 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { open, readFile, writeFile as fsWriteFile, mkdir as fsMkdir, rename, readdir, stat, unlink } from "node:fs/promises";
5
+ import xxhashWasm from "xxhash-wasm";
6
+ import type { FileMap } from "./readseek/types.js";
7
+ import { resolveReadseekJsonSettings } from "./readseek-settings.js";
8
+
9
+ /**
10
+ * Resolve the on-disk map-cache directory using config precedence:
11
+ * 1. `PI_HASHLINE_MAP_CACHE_DIR` (when non-empty) — used verbatim.
12
+ * 2. JSON `mapCache.dir` (when configured).
13
+ * 3. `$XDG_CACHE_HOME/pi-readseek/maps` (when non-empty).
14
+ * 4. `~/.cache/pi-readseek/maps`.
15
+ */
16
+ export function resolveCacheDir(): string {
17
+ const explicit = process.env.PI_HASHLINE_MAP_CACHE_DIR;
18
+ if (explicit && explicit.length > 0) return explicit;
19
+
20
+ const configured = resolveReadseekJsonSettings().settings.mapCache?.dir;
21
+ if (configured && configured.length > 0) return configured;
22
+
23
+ const xdg = process.env.XDG_CACHE_HOME;
24
+ if (xdg && xdg.length > 0) return join(xdg, "pi-readseek/maps");
25
+
26
+ return join(homedir(), ".cache/pi-readseek/maps");
27
+ }
28
+
29
+ export function persistenceEnabled(): boolean {
30
+ if (process.env.PI_HASHLINE_NO_PERSIST_MAPS === "1") return false;
31
+ const configured = resolveReadseekJsonSettings().settings.mapCache?.enabled;
32
+ if (configured !== undefined) return configured;
33
+ return true;
34
+ }
35
+
36
+ /**
37
+ * Build a deterministic hex cache key from the five input components.
38
+ * Uses SHA-256 truncated to 32 hex chars; collision-safe for our purposes
39
+ * and short enough for readable filenames.
40
+ */
41
+ export function computeKey(
42
+ absolutePath: string,
43
+ mtimeMs: number,
44
+ contentHash: string,
45
+ mapperName: string,
46
+ mapperVersion: number,
47
+ ): string {
48
+ return createHash("sha256")
49
+ .update(`${absolutePath}\0${mtimeMs}\0${contentHash}\0${mapperName}\0${mapperVersion}`)
50
+ .digest("hex")
51
+ .slice(0, 32);
52
+ }
53
+
54
+ const CONTENT_HASH_WINDOW_BYTES = 64 * 1024;
55
+
56
+ let xxhashReady: Promise<{ h32Raw: (b: Uint8Array, seed?: number) => number }> | null = null;
57
+
58
+ function loadXxhash(): Promise<{ h32Raw: (b: Uint8Array, seed?: number) => number }> {
59
+ if (!xxhashReady) {
60
+ xxhashReady = xxhashWasm().then((h) => ({ h32Raw: h.h32Raw }));
61
+ }
62
+
63
+ return xxhashReady;
64
+ }
65
+
66
+ /**
67
+ * xxHash32 hex digest over the first 64 KB of `absPath`. Returns "" if the
68
+ * file cannot be opened or read — the caller treats that as a miss.
69
+ */
70
+ export async function contentHashFor64k(absPath: string): Promise<string> {
71
+ try {
72
+ const fh = await open(absPath, "r");
73
+ try {
74
+ const buf = Buffer.alloc(CONTENT_HASH_WINDOW_BYTES);
75
+ const { bytesRead } = await fh.read(buf, 0, CONTENT_HASH_WINDOW_BYTES, 0);
76
+ const view = buf.subarray(0, bytesRead);
77
+ const { h32Raw } = await loadXxhash();
78
+ const n = h32Raw(view, 0) >>> 0;
79
+ return n.toString(16).padStart(8, "0");
80
+ } finally {
81
+ await fh.close();
82
+ }
83
+ } catch {
84
+ return "";
85
+ }
86
+ }
87
+
88
+ function keyPath(key: string): string {
89
+ return join(resolveCacheDir(), `${key}.json`);
90
+ }
91
+
92
+ function isRecord(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === "object" && value !== null;
94
+ }
95
+
96
+ function isStringArray(value: unknown): value is string[] {
97
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
98
+ }
99
+
100
+ function isFileSymbolLike(value: unknown): boolean {
101
+ if (!isRecord(value)) return false;
102
+ if (typeof value.name !== "string") return false;
103
+ if (typeof value.kind !== "string") return false;
104
+ if (typeof value.startLine !== "number") return false;
105
+ if (typeof value.endLine !== "number") return false;
106
+ if (value.signature !== undefined && typeof value.signature !== "string") return false;
107
+ if (value.children !== undefined && (!Array.isArray(value.children) || !value.children.every(isFileSymbolLike))) {
108
+ return false;
109
+ }
110
+ if (value.modifiers !== undefined && !isStringArray(value.modifiers)) return false;
111
+ if (value.docstring !== undefined && typeof value.docstring !== "string") return false;
112
+ if (value.isExported !== undefined && typeof value.isExported !== "boolean") return false;
113
+ return true;
114
+ }
115
+
116
+ function isTruncatedInfoLike(value: unknown): boolean {
117
+ if (!isRecord(value)) return false;
118
+ return (
119
+ typeof value.totalSymbols === "number"
120
+ && typeof value.shownSymbols === "number"
121
+ && typeof value.omittedSymbols === "number"
122
+ );
123
+ }
124
+
125
+ const DETAIL_LEVELS = new Set(["full", "compact", "minimal", "outline", "truncated"]);
126
+
127
+ function isFileMap(value: unknown): value is FileMap {
128
+ if (!isRecord(value)) return false;
129
+ if (typeof value.path !== "string") return false;
130
+ if (typeof value.totalLines !== "number") return false;
131
+ if (typeof value.totalBytes !== "number") return false;
132
+ if (typeof value.language !== "string") return false;
133
+ if (!Array.isArray(value.symbols) || !value.symbols.every(isFileSymbolLike)) return false;
134
+ if (!isStringArray(value.imports)) return false;
135
+ if (typeof value.detailLevel !== "string" || !DETAIL_LEVELS.has(value.detailLevel)) return false;
136
+ if (value.truncatedInfo !== undefined && !isTruncatedInfoLike(value.truncatedInfo)) return false;
137
+ return true;
138
+ }
139
+
140
+ /** Read a cached FileMap. Returns null on any failure (missing, corrupt, unreadable). */
141
+ export async function readCached(key: string): Promise<FileMap | null> {
142
+ if (!persistenceEnabled()) return null;
143
+
144
+ try {
145
+ const raw = await readFile(keyPath(key), "utf-8");
146
+ const parsed: unknown = JSON.parse(raw);
147
+ return isFileMap(parsed) ? parsed : null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ async function tryWriteCachedRaw(key: string, map: FileMap): Promise<boolean> {
154
+ if (!persistenceEnabled()) return false;
155
+ const dir = resolveCacheDir();
156
+ const target = join(dir, `${key}.json`);
157
+ const tmp = join(dir, `${key}.${randomBytes(6).toString("hex")}.tmp`);
158
+ try {
159
+ await fsMkdir(dir, { recursive: true });
160
+ await fsWriteFile(tmp, JSON.stringify(map));
161
+ await rename(tmp, target);
162
+ return true;
163
+ } catch {
164
+ try {
165
+ await unlink(tmp);
166
+ } catch {
167
+ // ignore temp-file cleanup failures
168
+ }
169
+ return false;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Low-level atomic write. Internal helper (exported so tests can seed fixtures
175
+ * without going through the eviction counter).
176
+ */
177
+ export async function writeCachedRaw(key: string, map: FileMap): Promise<void> {
178
+ await tryWriteCachedRaw(key, map);
179
+ }
180
+
181
+ const DEFAULT_EVICTION_INTERVAL = 20;
182
+ const DEFAULT_EVICTION_CAP = 5000;
183
+ let writeCounter = 0;
184
+ let evictionInterval = DEFAULT_EVICTION_INTERVAL;
185
+ let evictionCap = DEFAULT_EVICTION_CAP;
186
+
187
+ export function __setEvictionHooksForTest(
188
+ hooks: { interval?: number; cap?: number } | null,
189
+ ): void {
190
+ if (hooks === null) {
191
+ evictionInterval = DEFAULT_EVICTION_INTERVAL;
192
+ evictionCap = DEFAULT_EVICTION_CAP;
193
+ return;
194
+ }
195
+
196
+ if (typeof hooks.interval === "number") evictionInterval = hooks.interval;
197
+ if (typeof hooks.cap === "number") evictionCap = hooks.cap;
198
+ }
199
+
200
+ const CACHE_KEY_FILE = /^[0-9a-f]{32}\.json$/;
201
+ async function maybeEvict(): Promise<void> {
202
+ try {
203
+ const dir = resolveCacheDir();
204
+ const names = (await readdir(dir)).filter((n) => CACHE_KEY_FILE.test(n));
205
+ if (names.length <= evictionCap) return;
206
+
207
+ const entries: Array<{ path: string; age: number }> = [];
208
+ for (const name of names) {
209
+ const path = join(dir, name);
210
+ try {
211
+ const s = await stat(path);
212
+ const age = (s.atimeMs ?? s.mtimeMs) || 0;
213
+ entries.push({ path, age });
214
+ } catch {
215
+ // ignore individual stat failures
216
+ }
217
+ }
218
+
219
+ entries.sort((a, b) => a.age - b.age);
220
+ const excess = entries.length - evictionCap;
221
+ for (let i = 0; i < excess; i += 1) {
222
+ try {
223
+ await unlink(entries[i].path);
224
+ } catch {
225
+ // ignore individual unlink failures
226
+ }
227
+ }
228
+ } catch {
229
+ // Swallow eviction errors — never propagate.
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Public atomic write. Counts successful invocations so eviction can trigger lazily.
235
+ */
236
+ export async function writeCached(key: string, map: FileMap): Promise<void> {
237
+ if (!(await tryWriteCachedRaw(key, map))) return;
238
+ writeCounter += 1;
239
+ if (writeCounter % evictionInterval === 0) {
240
+ try {
241
+ await maybeEvict();
242
+ } catch {
243
+ // Swallow eviction errors at the call site too.
244
+ }
245
+ }
246
+ }
247
+
248
+ // Test-only: reset the write counter between suites.
249
+ export function __resetWriteCounter(): void {
250
+ writeCounter = 0;
251
+ }
@@ -0,0 +1,87 @@
1
+ import { SymbolKind } from "./readseek/enums.js";
2
+ import type { SymbolMatch } from "./readseek/symbol-lookup.js";
3
+ import type { FileMap, FileSymbol } from "./readseek/types.js";
4
+
5
+ export interface LocalBundleSupport {
6
+ symbol: SymbolMatch;
7
+ lines: string[];
8
+ }
9
+
10
+ export interface LocalBundlePlan {
11
+ requested: SymbolMatch;
12
+ support: LocalBundleSupport[];
13
+ }
14
+
15
+ interface FlatSymbol {
16
+ symbol: FileSymbol;
17
+ parentName?: string;
18
+ }
19
+
20
+ const CONTAINER_KINDS = new Set<SymbolKind>([
21
+ SymbolKind.Class,
22
+ SymbolKind.Module,
23
+ SymbolKind.Namespace,
24
+ ]);
25
+
26
+ function flattenSymbols(symbols: FileSymbol[], parentName?: string): FlatSymbol[] {
27
+ const flattened: FlatSymbol[] = [];
28
+
29
+ for (const symbol of symbols) {
30
+ flattened.push({ symbol, parentName });
31
+ if (symbol.children?.length) {
32
+ flattened.push(...flattenSymbols(symbol.children, symbol.name));
33
+ }
34
+ }
35
+
36
+ return flattened;
37
+ }
38
+
39
+ export function buildLocalBundle(
40
+ fileMap: FileMap,
41
+ requested: SymbolMatch,
42
+ allLines: string[],
43
+ ): LocalBundlePlan | null {
44
+ const requestedText = allLines.slice(requested.startLine - 1, requested.endLine).join("\n");
45
+ const identifiers = new Set(requestedText.match(/\b[A-Za-z_][A-Za-z0-9_]*\b/g) ?? []);
46
+
47
+ const candidates = flattenSymbols(fileMap.symbols).filter(({ symbol }) => {
48
+ if (CONTAINER_KINDS.has(symbol.kind as SymbolKind)) return false;
49
+ return !(symbol.name === requested.name && symbol.startLine === requested.startLine && symbol.endLine === requested.endLine);
50
+ });
51
+
52
+ const symbolsByName = new Map<string, FlatSymbol[]>();
53
+ for (const candidate of candidates) {
54
+ const bucket = symbolsByName.get(candidate.symbol.name) ?? [];
55
+ bucket.push(candidate);
56
+ symbolsByName.set(candidate.symbol.name, bucket);
57
+ }
58
+
59
+ const support: LocalBundleSupport[] = [];
60
+ for (const identifier of identifiers) {
61
+ const matches = symbolsByName.get(identifier) ?? [];
62
+ if (matches.length === 0) continue;
63
+ if (matches.length > 1) return null;
64
+
65
+ const match = matches[0];
66
+ support.push({
67
+ symbol: {
68
+ name: match.symbol.name,
69
+ kind: match.symbol.kind,
70
+ startLine: match.symbol.startLine,
71
+ endLine: match.symbol.endLine,
72
+ ...(match.parentName ? { parentName: match.parentName } : {}),
73
+ },
74
+ lines: allLines.slice(match.symbol.startLine - 1, match.symbol.endLine),
75
+ });
76
+ }
77
+
78
+ support.sort((a, b) => {
79
+ if (a.symbol.startLine !== b.symbol.startLine) return a.symbol.startLine - b.symbol.startLine;
80
+ return a.symbol.name.localeCompare(b.symbol.name);
81
+ });
82
+
83
+ return {
84
+ requested,
85
+ support,
86
+ };
87
+ }