pi-readseek 0.4.27 → 0.4.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readseek",
3
- "version": "0.4.27",
3
+ "version": "0.4.29",
4
4
  "description": "Pi extension for readseek-backed hash-anchored read/edit/grep, structural code maps, structural search, and file exploration",
5
5
  "type": "module",
6
6
  "exports": {
package/src/read.ts CHANGED
@@ -29,7 +29,7 @@ import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
29
29
  import { readseekRead, readseekDetect, type ReadSeekDetection } from "./readseek-client.js";
30
30
  import { formatReadCallText, formatReadResultText } from "./read-render-helpers.js";
31
31
  import { resolveReadSeekOcrMode } from "./readseek-settings.js";
32
- import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
32
+ import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidthCached } from "./tui-render-utils.js";
33
33
  import type { FileAnchoredCallback } from "./tool-types.js";
34
34
  import { filePathParam, mapParam, optionalIntOrString, registerReadSeekTool } from "./register-tool.js";
35
35
 
@@ -543,7 +543,7 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
543
543
  for (const badge of info.badges) summaryParts.push(badge);
544
544
  const summary = summaryParts.join(" • ");
545
545
  let text = summaryLine(summary, { hidden: !!textContent && !expanded });
546
- if (expanded && textContent) text += "\n" + wrapReadHashlinesForWidth(textContent, width);
546
+ if (expanded && textContent) text += "\n" + wrapReadHashlinesForWidthCached(content, textContent, width);
547
547
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
548
548
  },
549
549
  });
@@ -286,23 +286,36 @@ function directoryExists(dirPath: string): boolean {
286
286
  }
287
287
  }
288
288
 
289
+ const ownRepositoryByDir = new Map<string, boolean>();
290
+
289
291
  function isOwnReadSeekRepository(cwd = process.cwd()): boolean {
290
- let dir = path.resolve(cwd);
292
+ const start = path.resolve(cwd);
293
+ const cached = ownRepositoryByDir.get(start);
294
+ if (cached !== undefined) return cached;
295
+
296
+ let dir = start;
297
+ let result = false;
291
298
  while (true) {
292
299
  const packageJsonPath = path.join(dir, "package.json");
293
300
  if (existsSync(packageJsonPath)) {
294
301
  try {
295
302
  const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown };
296
- if (typeof packageJson.name === "string" && READSEEK_REPO_PACKAGE_NAMES.has(packageJson.name)) return true;
303
+ if (typeof packageJson.name === "string" && READSEEK_REPO_PACKAGE_NAMES.has(packageJson.name)) {
304
+ result = true;
305
+ break;
306
+ }
297
307
  } catch {
298
308
  // Ignore unreadable or invalid package manifests while walking up.
299
309
  }
300
310
  }
301
311
 
302
312
  const parent = path.dirname(dir);
303
- if (parent === dir) return false;
313
+ if (parent === dir) break;
304
314
  dir = parent;
305
315
  }
316
+
317
+ ownRepositoryByDir.set(start, result);
318
+ return result;
306
319
  }
307
320
 
308
321
  function defaultReadSeekDir(): string | null {
@@ -310,23 +323,43 @@ function defaultReadSeekDir(): string | null {
310
323
  return home ? path.join(home, ".pi", "readseek") : null;
311
324
  }
312
325
 
326
+ const DEFAULT_READSEEK_TIMEOUT_MS = 120_000;
327
+
328
+ function readseekTimeoutMs(): number {
329
+ const raw = process.env.READSEEK_TIMEOUT_MS;
330
+ if (raw !== undefined && raw !== "") {
331
+ const parsed = Number(raw);
332
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
333
+ }
334
+ return DEFAULT_READSEEK_TIMEOUT_MS;
335
+ }
336
+
313
337
  async function spawnReadSeekRaw(args: string[], options: RunReadSeekOptions = {}): Promise<string> {
314
338
  return new Promise<string>((resolve, reject) => {
315
339
  let settled = false;
340
+ let timeout: ReturnType<typeof setTimeout> | undefined;
316
341
  const fail = (error: Error): void => {
317
342
  if (settled) return;
318
343
  settled = true;
344
+ if (timeout !== undefined) clearTimeout(timeout);
319
345
  reject(error);
320
346
  };
321
347
  const succeed = (value: string): void => {
322
348
  if (settled) return;
323
349
  settled = true;
350
+ if (timeout !== undefined) clearTimeout(timeout);
324
351
  resolve(value);
325
352
  };
326
353
 
327
354
  const stdin = options.stdin;
328
355
  const stdio: StdioOptions = [stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"];
329
356
  const child = spawn(readseekBinaryPath(), args, { stdio, signal: options.signal });
357
+ const timeoutMs = options.timeoutMs ?? readseekTimeoutMs();
358
+ timeout = setTimeout(() => {
359
+ child.kill("SIGKILL");
360
+ fail(new Error(`readseek timed out after ${timeoutMs} ms`));
361
+ }, timeoutMs);
362
+ timeout.unref?.();
330
363
  const childStdout = child.stdout;
331
364
  const childStderr = child.stderr;
332
365
  const childStdin = child.stdin;
@@ -396,6 +429,7 @@ async function readseekInvocationArgs(args: string[]): Promise<string[]> {
396
429
  interface RunReadSeekOptions {
397
430
  signal?: AbortSignal;
398
431
  stdin?: string;
432
+ timeoutMs?: number;
399
433
  }
400
434
 
401
435
  async function runReadSeekRaw(args: string[], options: RunReadSeekOptions = {}): Promise<string> {
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { readFileSync, statSync, type Stats } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
@@ -89,16 +89,42 @@ function validateSettings(raw: unknown, source: string): ReadSeekSettingsResult
89
89
  return { settings, warnings };
90
90
  }
91
91
 
92
+ interface SettingsFileCacheEntry {
93
+ mtimeMs: number;
94
+ size: number;
95
+ result: ReadSeekSettingsResult;
96
+ }
97
+
98
+ const settingsFileCache = new Map<string, SettingsFileCacheEntry>();
99
+
100
+ function statFileOrNull(path: string): Stats | null {
101
+ try {
102
+ return statSync(path);
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
92
108
  function readSettingsFile(path: string): ReadSeekSettingsResult {
93
- if (!existsSync(path)) return { settings: {}, warnings: [] };
109
+ const stats = statFileOrNull(path);
110
+ if (!stats) {
111
+ settingsFileCache.delete(path);
112
+ return { settings: {}, warnings: [] };
113
+ }
114
+
115
+ const cached = settingsFileCache.get(path);
116
+ if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) return cached.result;
94
117
 
118
+ let result: ReadSeekSettingsResult;
95
119
  try {
96
120
  const text = readFileSync(path, "utf8");
97
- return validateSettings(JSON.parse(text) as unknown, path);
121
+ result = validateSettings(JSON.parse(text) as unknown, path);
98
122
  } catch (error) {
99
123
  const message = error instanceof Error ? error.message : String(error);
100
- return { settings: {}, warnings: [{ source: path, message: `Invalid JSON: ${message}` }] };
124
+ result = { settings: {}, warnings: [{ source: path, message: `Invalid JSON: ${message}` }] };
101
125
  }
126
+ settingsFileCache.set(path, { mtimeMs: stats.mtimeMs, size: stats.size, result });
127
+ return result;
102
128
  }
103
129
 
104
130
  function mergeSettings(base: ReadSeekJsonSettings, override: ReadSeekJsonSettings): ReadSeekJsonSettings {
@@ -149,6 +149,26 @@ export function wrapReadHashlinesForWidth(text: string, width: number | undefine
149
149
  return output.join("\n");
150
150
  }
151
151
 
152
+ const wrappedHashlinesCache = new WeakMap<object, { text: string; width: number | undefined; wrapped: string }>();
153
+
154
+ /**
155
+ * Memoized {@link wrapReadHashlinesForWidth} for render paths that re-run on
156
+ * every TUI frame: the wrapped output is cached per result content object so
157
+ * an expanded read result is only re-wrapped when its text or the terminal
158
+ * width changes.
159
+ */
160
+ export function wrapReadHashlinesForWidthCached(
161
+ cacheKey: object,
162
+ text: string,
163
+ width: number | undefined,
164
+ ): string {
165
+ const cached = wrappedHashlinesCache.get(cacheKey);
166
+ if (cached && cached.text === text && cached.width === width) return cached.wrapped;
167
+ const wrapped = wrapReadHashlinesForWidth(text, width);
168
+ wrappedHashlinesCache.set(cacheKey, { text, width, wrapped });
169
+ return wrapped;
170
+ }
171
+
152
172
  /**
153
173
  * Render the `isPartial` placeholder line shared by every tool's `renderResult`.
154
174
  */