pi-readseek 0.3.22 → 0.3.23

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.3.22",
3
+ "version": "0.3.23",
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": {
@@ -1,30 +1,41 @@
1
1
  const BASE10_INT_RE = /^-?\d+$/;
2
+ const MIN_SAFE_INTEGER = BigInt(Number.MIN_SAFE_INTEGER);
3
+ const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER);
2
4
 
3
5
  type CoercedIntResult =
4
6
  | { ok: true; value: number | undefined }
5
7
  | { ok: false; message: string };
6
8
 
9
+ function formatReceived(value: unknown): string {
10
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
11
+ return JSON.stringify(value) ?? String(value);
12
+ }
13
+
14
+ function invalidInteger(value: unknown, name: string): CoercedIntResult {
15
+ return {
16
+ ok: false,
17
+ message: `Invalid ${name}: expected a safe base-10 integer, received ${formatReceived(value)}.`,
18
+ };
19
+ }
20
+
7
21
  export function coerceObviousBase10Int(value: unknown, name: string): CoercedIntResult {
8
22
  if (value === undefined) {
9
23
  return { ok: true, value: undefined };
10
24
  }
11
25
 
12
26
  if (typeof value === "number") {
13
- if (Number.isInteger(value)) {
27
+ if (Number.isSafeInteger(value)) {
14
28
  return { ok: true, value };
15
29
  }
16
- return {
17
- ok: false,
18
- message: `Invalid ${name}: expected a base-10 integer, received ${value}.`,
19
- };
30
+ return invalidInteger(value, name);
20
31
  }
21
32
 
22
33
  if (typeof value === "string" && BASE10_INT_RE.test(value)) {
23
- return { ok: true, value: Number.parseInt(value, 10) };
34
+ const parsed = BigInt(value);
35
+ if (parsed >= MIN_SAFE_INTEGER && parsed <= MAX_SAFE_INTEGER) {
36
+ return { ok: true, value: Number(parsed) };
37
+ }
24
38
  }
25
39
 
26
- return {
27
- ok: false,
28
- message: `Invalid ${name}: expected a base-10 integer, received ${JSON.stringify(value)}.`,
29
- };
40
+ return invalidInteger(value, name);
30
41
  }
@@ -19,6 +19,19 @@ export interface EditOutputResult {
19
19
  patch: string;
20
20
  readseekValue: ReturnType<typeof buildReadseekEditResult>;
21
21
  }
22
+
23
+ const EDIT_OPERATION_NAMES = ["set_line", "replace_lines", "insert_after", "replace"] as const;
24
+
25
+ type EditOperationName = (typeof EDIT_OPERATION_NAMES)[number];
26
+
27
+ type EditOperationWithNewText = {
28
+ new_text: string;
29
+ };
30
+
31
+ function hasNewText(value: unknown): value is EditOperationWithNewText {
32
+ return typeof value === "object" && value !== null && typeof (value as { new_text?: unknown }).new_text === "string";
33
+ }
34
+
22
35
  function getVisibleDiffStats(diff: string): { added: number; removed: number } {
23
36
  const stats = parseDiffStats(diff);
24
37
  if (stats.added > 0 || stats.removed > 0) return stats;
@@ -49,10 +62,12 @@ function extractNewTextValues(edits: unknown[] | undefined): string[] {
49
62
  const values: string[] = [];
50
63
  for (const edit of edits ?? []) {
51
64
  if (!edit || typeof edit !== "object") continue;
52
- if ("set_line" in edit && typeof (edit as any).set_line?.new_text === "string") values.push((edit as any).set_line.new_text);
53
- if ("replace_lines" in edit && typeof (edit as any).replace_lines?.new_text === "string") values.push((edit as any).replace_lines.new_text);
54
- if ("insert_after" in edit && typeof (edit as any).insert_after?.new_text === "string") values.push((edit as any).insert_after.new_text);
55
- if ("replace" in edit && typeof (edit as any).replace?.new_text === "string") values.push((edit as any).replace.new_text);
65
+
66
+ const operations = edit as Partial<Record<EditOperationName, unknown>>;
67
+ for (const operationName of EDIT_OPERATION_NAMES) {
68
+ const operation = operations[operationName];
69
+ if (hasNewText(operation)) values.push(operation.new_text);
70
+ }
56
71
  }
57
72
  return values;
58
73
  }
@@ -62,10 +77,13 @@ function formatWhitespaceOnlyWarning(semanticSummary: SemanticSummary | undefine
62
77
  return "⚠ Edit classified as whitespace-only — if you intended a behavior change, re-read to verify.";
63
78
  }
64
79
  function formatSemanticSuffix(semanticSummary: SemanticSummary | undefined): string {
65
- const movedBlocks = semanticSummary?.movedBlocks ?? 0;
80
+ if (!semanticSummary) return "";
81
+
82
+ const movedBlocks = semanticSummary.movedBlocks ?? 0;
66
83
  if (movedBlocks <= 0) return "";
84
+
67
85
  const blockWord = movedBlocks === 1 ? "block" : "blocks";
68
- return ` [semantic: ${semanticSummary!.classification}, ${movedBlocks} ${blockWord} moved]`;
86
+ return ` [semantic: ${semanticSummary.classification}, ${movedBlocks} ${blockWord} moved]`;
69
87
  }
70
88
  function formatReplaceHint(edits: unknown[] | undefined, noopEdits: unknown[]): string | undefined {
71
89
  if ((noopEdits ?? []).length > 0) return undefined;
@@ -47,6 +47,14 @@ export interface GrepOutputGroup {
47
47
  };
48
48
  }
49
49
 
50
+ type ScopedGrepOutputGroup = GrepOutputGroup & {
51
+ scope: NonNullable<GrepOutputGroup["scope"]>;
52
+ };
53
+
54
+ function hasScope(group: GrepOutputGroup): group is ScopedGrepOutputGroup {
55
+ return group.scope !== undefined;
56
+ }
57
+
50
58
  export interface BuildGrepOutputInput {
51
59
  summary: boolean;
52
60
  totalMatches: number;
@@ -100,13 +108,13 @@ function buildScopeMetadata(groups: GrepOutputGroup[], warnings: GrepScopeWarnin
100
108
  return {
101
109
  mode: "symbol" as const,
102
110
  groups: groups
103
- .filter((group) => group.scope)
111
+ .filter(hasScope)
104
112
  .map((group) => ({
105
113
  path: group.absolutePath,
106
114
  displayPath: group.displayPath,
107
- symbol: group.scope!.symbol,
115
+ symbol: group.scope.symbol,
108
116
  matchCount: group.matchCount,
109
- matchLines: [...group.scope!.matchLines],
117
+ matchLines: [...group.scope.matchLines],
110
118
  lineAnchors: group.entries.flatMap((entry) => (entry.kind === "separator" ? [] : [entry.line.anchor])),
111
119
  })),
112
120
  warnings: [...warnings],
@@ -132,14 +140,16 @@ export function buildGrepOutput(input: BuildGrepOutputInput): GrepOutputResult {
132
140
  }
133
141
  text = blocks.join("\n");
134
142
  }
135
- if ((input.passthroughLines?.length ?? 0) > 0) {
136
- text += `\n\n${input.passthroughLines!.join("\n")}`;
143
+ const passthroughLines = input.passthroughLines ?? [];
144
+ if (passthroughLines.length > 0) {
145
+ text += `\n\n${passthroughLines.join("\n")}`;
137
146
  }
138
147
  if (input.limit !== undefined && input.totalMatches === input.limit) {
139
148
  text += `\n\n[Results truncated at ${input.limit} matches — refine pattern or increase limit]`;
140
149
  }
141
- if (!input.summary && input.scopeMode === "symbol" && (input.scopeWarnings?.length ?? 0) > 0) {
142
- text = `${input.scopeWarnings!.map((warning) => warning.message).join("\n\n")}\n\n${text}`;
150
+ const scopeWarnings = input.scopeWarnings ?? [];
151
+ if (!input.summary && input.scopeMode === "symbol" && scopeWarnings.length > 0) {
152
+ text = `${scopeWarnings.map((warning) => warning.message).join("\n\n")}\n\n${text}`;
143
153
  }
144
154
  const budget = resolveGrepOutputBudget();
145
155
  const truncated = truncateHead(text, {
@@ -161,7 +171,7 @@ export function buildGrepOutput(input: BuildGrepOutputInput): GrepOutputResult {
161
171
  })),
162
172
  };
163
173
  if (!input.summary && input.scopeMode === "symbol") {
164
- readseekValue.scopes = buildScopeMetadata(input.groups, input.scopeWarnings ?? []);
174
+ readseekValue.scopes = buildScopeMetadata(input.groups, scopeWarnings);
165
175
  }
166
176
 
167
177
  return {
package/src/grep.ts CHANGED
@@ -313,6 +313,16 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
313
313
  let candidateUnparsedCount = 0;
314
314
  const candidateLinePattern = /^.+(?::|-)\d+(?::|-)\s/;
315
315
 
316
+ const addSummaryMatch = (displayPath: string, absolutePath: string) => {
317
+ let group = groupsByPath.get(displayPath);
318
+ if (!group) {
319
+ group = { displayPath, absolutePath, matchCount: 0, entries: [] };
320
+ groupsByPath.set(displayPath, group);
321
+ }
322
+ group.matchCount++;
323
+ totalMatches++;
324
+ };
325
+
316
326
  const addEntry = (displayPath: string, absolutePath: string, kind: "match" | "context", line: ReadseekLine) => {
317
327
  let group = groupsByPath.get(displayPath);
318
328
  if (!group) {
@@ -341,6 +351,12 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
341
351
  }
342
352
  parsedCount++;
343
353
  const absolute = toAbsolutePath(parsed.displayPath);
354
+ if (p.summary) {
355
+ if (parsed.kind === "match") {
356
+ addSummaryMatch(parsed.displayPath, absolute);
357
+ }
358
+ continue;
359
+ }
344
360
  const fileLines = await getFileLines(absolute);
345
361
  if (fileLines === undefined) continue;
346
362
  // Bare-CR remapping: rg treats the entire bare-CR file as line 1, and the
@@ -379,6 +395,25 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
379
395
  });
380
396
  }
381
397
 
398
+ if (p.summary && parsedCount === 0 && candidateUnparsedCount > 0) {
399
+ const passthroughDetails =
400
+ typeof result.details === "object" && result.details !== null
401
+ ? (result.details as Record<string, unknown>)
402
+ : {};
403
+ return {
404
+ ...result,
405
+ details: {
406
+ ...passthroughDetails,
407
+ readseekValue: {
408
+ tool: "grep",
409
+ summary: true,
410
+ totalMatches: 0,
411
+ records: [],
412
+ },
413
+ },
414
+ };
415
+ }
416
+
382
417
  if (parsedCount === 0 && candidateUnparsedCount > 0) {
383
418
  const warning =
384
419
  "[hashline grep passthrough] Unparsed grep format; returned original output.";
@@ -405,7 +440,7 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
405
440
  };
406
441
  }
407
442
 
408
- const summary = p.summary;
443
+ const summary = !!p.summary;
409
444
  const effectiveLimit = typeof p.limit === "number" ? p.limit : 100;
410
445
  const groups = [...groupsByPath.values()];
411
446
  for (const group of groups) {
package/src/hashline.ts CHANGED
@@ -71,13 +71,16 @@ interface HashlineGlobalState {
71
71
 
72
72
  const HASHLINE_STATE_KEY = Symbol.for("pi-readseek.hashlineState.v1");
73
73
 
74
+ type HashlineGlobalSlots = typeof globalThis & Record<symbol, HashlineGlobalState | undefined>;
75
+
74
76
  function getHashlineState(): HashlineGlobalState {
75
- const globalObject = globalThis as any;
76
- globalObject[HASHLINE_STATE_KEY] ??= {
77
+ const globalObject = globalThis as HashlineGlobalSlots;
78
+ const state = globalObject[HASHLINE_STATE_KEY] ?? {
77
79
  h32Fn: null,
78
80
  initPromise: null,
79
- } satisfies HashlineGlobalState;
80
- return globalObject[HASHLINE_STATE_KEY] as HashlineGlobalState;
81
+ };
82
+ globalObject[HASHLINE_STATE_KEY] = state;
83
+ return state;
81
84
  }
82
85
 
83
86
  export async function ensureHashInit(): Promise<void> {
@@ -123,6 +126,7 @@ export function parseLineRef(ref: string): { line: number; hash: string; content
123
126
  const match = normalized.match(new RegExp(`^(\\d+):([0-9a-fA-F]{${HASH_LEN}})$`));
124
127
  if (!match) throw new Error(`Invalid line reference "${ref}". Expected "LINE:HASH" (e.g. "5:abc").`);
125
128
  const line = Number.parseInt(match[1], 10);
129
+ if (!Number.isSafeInteger(line)) throw new Error(`Line number must be a safe integer, got ${match[1]} in "${ref}".`);
126
130
  if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
127
131
  return { line, hash: match[2], content: contentAfterPipe };
128
132
  }
package/src/map-cache.ts CHANGED
@@ -17,14 +17,17 @@ interface MapCacheGlobalState {
17
17
 
18
18
  const MAP_CACHE_STATE_KEY = Symbol.for("pi-readseek.mapCacheState.v1");
19
19
 
20
+ type MapCacheGlobalSlots = typeof globalThis & Record<symbol, MapCacheGlobalState | undefined>;
21
+
20
22
  function getMapCacheState(): MapCacheGlobalState {
21
- const globalObject = globalThis as any;
22
- globalObject[MAP_CACHE_STATE_KEY] ??= {
23
+ const globalObject = globalThis as MapCacheGlobalSlots;
24
+ const state = globalObject[MAP_CACHE_STATE_KEY] ?? {
23
25
  cache: new Map<string, CacheEntry>(),
24
26
  inflight: new Map<string, Promise<FileMap | null>>(),
25
27
  maxSize: MAP_CACHE_MAX_SIZE,
26
- } satisfies MapCacheGlobalState;
27
- return globalObject[MAP_CACHE_STATE_KEY] as MapCacheGlobalState;
28
+ };
29
+ globalObject[MAP_CACHE_STATE_KEY] = state;
30
+ return state;
28
31
  }
29
32
 
30
33
  function rememberInMemory(absPath: string, entry: CacheEntry): void {
package/src/read.ts CHANGED
@@ -40,7 +40,7 @@ interface ReadParams {
40
40
  limit?: number | string;
41
41
  symbol?: string;
42
42
  map?: boolean;
43
- bundle?: "local";
43
+ bundle?: string;
44
44
  }
45
45
 
46
46
  interface ReadToolOptions {
@@ -56,6 +56,15 @@ export interface ExecuteReadOptions {
56
56
  onSuccessfulRead?: FileAnchoredCallback;
57
57
  }
58
58
 
59
+ function hasReadAnchors(result: AgentToolResult<any>): boolean {
60
+ const details = (result as { details?: unknown }).details;
61
+ if (!details || typeof details !== "object") return false;
62
+ const readseekValue = (details as { readseekValue?: unknown }).readseekValue;
63
+ if (!readseekValue || typeof readseekValue !== "object") return false;
64
+ const lines = (readseekValue as { lines?: unknown }).lines;
65
+ return Array.isArray(lines) && lines.length > 0;
66
+ }
67
+
59
68
  export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolResult<any>> {
60
69
  const { toolCallId, params, signal, onUpdate, cwd, onSuccessfulRead } = opts;
61
70
  await ensureHashInit();
@@ -76,10 +85,16 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
76
85
  const message = `Invalid offset: expected a positive integer, received ${offset.value}.`;
77
86
  return buildToolErrorResult("read", "invalid-offset", message, { path: rawParams.path });
78
87
  }
88
+ const rawBundle = typeof rawParams.bundle === "string" ? rawParams.bundle.trim() : undefined;
89
+ const requestedMapViaBundle =
90
+ rawBundle === "map" ||
91
+ (rawBundle === "local" && rawParams.symbol === undefined && rawParams.map !== false);
79
92
  const p = {
80
93
  ...rawParams,
81
94
  offset: offset.value,
82
95
  limit: limit.value,
96
+ map: rawParams.map === true || requestedMapViaBundle,
97
+ bundle: requestedMapViaBundle ? undefined : rawBundle,
83
98
  };
84
99
  if (rawParams.symbol !== undefined) {
85
100
  const trimmedSymbol = typeof rawParams.symbol === "string" ? rawParams.symbol.trim() : "";
@@ -93,13 +108,17 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
93
108
  const absolutePath = resolveToCwd(rawPath, cwd);
94
109
  const succeed = <T extends AgentToolResult<any>>(result: T): T => {
95
110
  const isError = (result as { isError?: boolean }).isError;
96
- if (!isError) {
111
+ if (!isError && hasReadAnchors(result)) {
97
112
  onSuccessfulRead?.(absolutePath);
98
113
  }
99
114
  return result;
100
115
  };
101
116
 
102
117
  throwIfAborted(signal);
118
+ if (p.bundle !== undefined && p.bundle !== "local") {
119
+ const message = `Invalid bundle: expected "local", received ${JSON.stringify(p.bundle)}.`;
120
+ return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
121
+ }
103
122
  if (p.symbol && (p.offset !== undefined || p.limit !== undefined)) {
104
123
  const message = "Cannot combine symbol with offset/limit. Use one or the other.";
105
124
  return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
@@ -332,7 +332,7 @@ async function runReadseek(args: string[], options: RunReadseekOptions = {}): Pr
332
332
  }
333
333
 
334
334
  function requireNumber(value: unknown, field: string): number {
335
- if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`invalid readseek ${field}`);
335
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new Error(`invalid readseek ${field}: expected safe integer`);
336
336
  return value;
337
337
  }
338
338