pi-ast-sgrep 1.4.0 → 2.0.1

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/README.md CHANGED
@@ -4,7 +4,23 @@ Native Code Mode, structural, graph, and semantic code search for [Pi](https://g
4
4
 
5
5
  [![pi-ast-sgrep: native code search inside Pi](https://cdn.jsdelivr.net/npm/pi-ast-sgrep/assets/preview.png)](https://pi.dev/packages/pi-ast-sgrep?name=pi-ast-sgrep)
6
6
 
7
- `pi-ast-sgrep` gives Pi a warm, project-aware search engine for understanding code. It finds behavior by intent, resolves definitions and callers, traces relationships, matches syntax-aware patterns, and searches local semantic embeddings. The primary `asgrep_codemode` tool lets Pi compose several searches in one JavaScript program instead of spending one model round trip per lookup.
7
+ `pi-ast-sgrep` gives Pi a warm, project-aware search engine for understanding code. It finds behavior by intent, resolves definitions and callers, traces relationships, matches syntax-aware patterns, joins two indexed channels, and searches local semantic embeddings. The primary `asgrep` tool lets Pi compose several searches in one JavaScript program instead of spending one model round trip per lookup.
8
+
9
+ **v2.0.0** · 13 languages · local-first semantic · critic + two-channel `AND` · **Code Mode** (on by default, no API key)
10
+
11
+ **Upgrading to 2.0:** this is a breaking semver release because the cloud/Ollama embedding backends were removed. Update the Pi package normally; local hashed semantic search remains the default, optional neural embeddings remain in-process, and indexes that still store `embed_backend=cloud|ollama` fail closed until `/asgrep-reindex`. One-shot tools and Code Mode now put bounded hits in `content` so the model sees them, not only display-only `details`.
12
+
13
+ ### What's new for Pi in 2.0
14
+
15
+ | Change | What you get |
16
+ |--------|----------------|
17
+ | Local-first embeddings | No `ASGREP_EMBED_API_KEY` / Ollama URL. Hashed semantic is default; optional ONNX stays in-process. |
18
+ | Results on the model path | `asgrep_search` and Code Mode serialize hits into `content`. |
19
+ | Two-channel queries | `asgrep.search({ query: 'callers:process_request AND pattern:fn $NAME($$$)' })` joins by span; `AND NOT` subtracts. Plain English `and` stays hybrid. |
20
+ | Critic + follow-ups | Agent envelopes include `why` (`critic:` notes) and causal `follow_up_queries` from the actual top hit. |
21
+ | Native work off the event loop | Index/search run as N-API worker tasks so Pi JS is not blocked on SQLite. |
22
+ | Auto-registered tools | `asgrep` lands without requiring a skill file. |
23
+ | Schema 12 | Older indexes rebuild through the normal compatibility path; `/asgrep-reindex` is the explicit full rebuild. |
8
24
 
9
25
  ## Install
10
26
 
@@ -24,11 +40,10 @@ No Rust toolchain or separate MCP server is required. The npm package selects th
24
40
 
25
41
  | Resource | Purpose |
26
42
  |---|---|
27
- | `asgrep_codemode` | Primary tool. Run a bounded JavaScript program that composes typed `asgrep.*` calls. |
43
+ | `asgrep` | Primary tool. Run a bounded JavaScript program that composes typed `asgrep.*` calls. Auto-registered with Pi (no skill file). |
28
44
  | `asgrep_search` | Run one natural, structural, symbol, graph, semantic, word, literal, or regex lookup. |
29
45
  | `asgrep_index` | Create, refresh, or explicitly rebuild the current project index. |
30
46
  | `asgrep_status` | Inspect the selected root, index, backend, counts, and capabilities. |
31
- | `ast-sgrep` skill | Teach Pi when and how to use Code Mode, direct search, or exact-text search. |
32
47
 
33
48
  The package also registers `/asgrep-doctor`, `/asgrep-status`, `/asgrep-index`, and `/asgrep-reindex`.
34
49
 
@@ -38,7 +53,7 @@ Ask Pi:
38
53
 
39
54
  > Use ast-sgrep Code Mode to find where access tokens are refreshed, trace the top result's callers, and return only the relevant files, symbols, and lines.
40
55
 
41
- Pi can make one `asgrep_codemode` call like this:
56
+ Pi can make one `asgrep` call like this:
42
57
 
43
58
  ```json
44
59
  {
@@ -50,7 +65,7 @@ This workflow narrows the first result, runs independent follow-up searches toge
50
65
 
51
66
  ### Code Mode API
52
67
 
53
- The sandbox exposes these asynchronous methods:
68
+ The Code Mode program receives these asynchronous methods on `asgrep`:
54
69
 
55
70
  | Method | Use |
56
71
  |---|---|
@@ -67,7 +82,15 @@ The sandbox exposes these asynchronous methods:
67
82
 
68
83
  Use `Promise.all` for independent calls. Filter, map, sort, and slice intermediate values in JavaScript. Return only the evidence needed for the next reasoning step.
69
84
 
70
- Code Mode includes `Promise`, `JSON`, arrays, objects, `Map`, `Set`, and `Math`. It does not expose `require`, `process`, `fetch`, or filesystem APIs. This is a capability boundary for generated code, not an OS sandbox for the installed Pi package.
85
+ Code Mode runs in a disposable worker with a restricted `node:vm` context that exposes only a serialized `asgrep.*` bridge and console. String and WebAssembly code generation are disabled, ambient Node globals such as `process` and `require` are not exposed, and terminating the worker contains synchronous and microtask CPU loops. Node does not consider `vm` an adversarial-code security boundary, however, and the installed Pi package has full OS-user access; do not treat Code Mode as an OS jail. Prefer Code Mode **or** MCP for a client, never both.
86
+
87
+ The bridge rejects oversized call arguments and serialized results, allows at
88
+ most 256 host calls per program, and caps collected console output before it
89
+ reaches the extension host. Raw-memory and WebAssembly globals are unavailable;
90
+ worker heap/stack limits contain the remaining accidental memory growth. Native
91
+ tool values are capped at 1 MiB each and complete batch responses at 4 MiB before
92
+ Node-API converts them into extension-host objects. These bounds do not turn `node:vm` into an OS
93
+ sandbox.
71
94
 
72
95
  ## Direct one-shot search
73
96
 
@@ -78,13 +101,15 @@ Use `asgrep_search` when one lookup is enough:
78
101
  {"query":"auth_refresh","mode":"callers","limit":8}
79
102
  {"query":"where are credentials renewed?","mode":"semantic","limit":8}
80
103
  {"query":"$CLIENT.post($URL)","mode":"pattern","limit":8}
104
+ {"query":"callers:process_request AND pattern:fn $NAME($$$)", "mode":"natural","limit":8}
105
+ {"query":"defs:handle AND NOT callers:test_","mode":"natural","limit":8}
81
106
  ```
82
107
 
83
108
  Available modes:
84
109
 
85
110
  | Mode | Best for |
86
111
  |---|---|
87
- | `natural` | Intent or mixed code-language queries when exact spelling is unknown. |
112
+ | `natural` | Intent or mixed code-language queries when exact spelling is unknown. Also the mode for two-channel `AND` / `AND NOT` query strings. |
88
113
  | `pattern` | Syntax-aware ast-sgrep patterns with metavariables. |
89
114
  | `defs`, `callers`, `imports` | Symbol and module navigation. |
90
115
  | `chain` | Multi-hop relationship tracing. |
@@ -97,6 +122,8 @@ Available modes:
97
122
 
98
123
  Official platform packages include `ast-sgrep-codemode.node`. The extension loads an in-process native `CodeModeSession` and keeps one warm Searcher per project root for Code Mode, direct tools, and freshness checks. Normal searches do not spawn a CLI process.
99
124
 
125
+ Native index and search calls run as Promise-returning N-API worker tasks rather than on Node's event-loop thread. Calls for one warm session are serialized before entering libuv so concurrent Pi work does not occupy worker threads waiting on the same SQLite session.
126
+
100
127
  Independent calls created in the same JavaScript turn are coalesced into a batch. `Promise.all` can therefore fan out several lookups while the model makes one tool call. If the native addon is unavailable, the bundled CLI service is a degraded fallback; `/asgrep-doctor` reports the active backend.
101
128
 
102
129
  Code Mode and `ast-sgrep-mcp` are separate front ends over the same Rust search core. Pi uses Code Mode directly and does not use an MCP adapter.
@@ -105,7 +132,9 @@ Code Mode and `ast-sgrep-mcp` are separate front ends over the same Rust search
105
132
 
106
133
  Start Pi in the repository you want to search. The first search validates the index and lazily creates `<project-root>/.asgrep/` when needed. Run `/asgrep-index` if you want to build it before searching.
107
134
 
108
- After a successful Pi `write` or `edit`, the extension marks the affected path dirty and refreshes it before the next search. Concurrent searches for the same root share one in-flight refresh. Use `/asgrep-reindex` only for an incompatible or corrupt index, or when you explicitly need a full rebuild.
135
+ After a successful Pi `write` or `edit`, the extension marks the affected path dirty and updates only known changed paths before the next search. It also watches the project for external filesystem changes: known file changes receive the same targeted update, while renames, directory changes, ignore-file edits, watcher errors, and ambiguous events trigger a correctness scan. `.asgrep` writes are excluded so indexing cannot dirty itself. If recursive watching is unavailable, an immediate scan plus the periodic full scan preserve correctness. Concurrent searches for the same root share one in-flight refresh.
136
+
137
+ The periodic interval forces a full incremental reconciliation even when the watcher reports nothing, covering dropped or coalesced filesystem events. Run `/asgrep-index` when you need freshness immediately after a large external operation; use `/asgrep-reindex` only for an incompatible or corrupt index, or when you explicitly need a strict full rebuild.
109
138
 
110
139
  The package never edits `.gitignore`. Add this entry yourself if index data must stay untracked:
111
140
 
@@ -120,7 +149,7 @@ The package never edits `.gitignore`. Add this entry yourself if index data must
120
149
  | `/asgrep-doctor` | Check package versions, native runtime, protocol, index, and project settings. |
121
150
  | `/asgrep-status` | Show the current root and index state. |
122
151
  | `/asgrep-index` | Create or incrementally refresh the index. |
123
- | `/asgrep-reindex` | Build and atomically replace the index from scratch. |
152
+ | `/asgrep-reindex` | Strictly rebuild the index in one transaction while preserving the prior usable rows on failure. |
124
153
 
125
154
  These commands take no arguments.
126
155
 
@@ -166,7 +195,7 @@ The agent chooses the retrieval granularity; these methods never auto-fuse chann
166
195
 
167
196
  The default semantic backend works offline. It needs no credential, sends no telemetry, and downloads no model on first use. Search data stays under the project's `.asgrep/` directory.
168
197
 
169
- External cloud, Ollama, and neural embedding providers are optional. If you enable one, source text and queries needed for embeddings can be sent to that provider. Its credential, retention, and privacy rules then apply.
198
+ In-process neural embeddings are optional (`--features neural-embed`). They never send source text to a remote embedding API. Hashed local search remains the default.
170
199
 
171
200
  Pi packages are trusted code. Installation grants this JavaScript extension and its native code the permissions of the OS user running Pi. Project-root confinement is a package policy, not an operating-system security boundary.
172
201
 
@@ -200,13 +229,14 @@ pi remove npm:pi-ast-sgrep
200
229
  pi install npm:pi-ast-sgrep@<previous-version>
201
230
  ```
202
231
 
203
- Then run `/asgrep-doctor`. Compatible updates reuse validated data. Incompatible formats rebuild atomically and preserve recoverable prior data when a rebuild fails.
232
+ Then run `/asgrep-doctor`. Compatible updates reuse validated data. Incompatible formats rebuild transactionally in place and preserve recoverable prior rows when a rebuild fails.
204
233
 
205
234
  ## More documentation
206
235
 
207
236
  - [Complete Pi package guide](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/pi-package.md)
208
237
  - [Code Mode architecture and performance](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/codemode.md)
209
- - [Query grammar](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/QUERY_GRAMMAR.md)
238
+ - [Query grammar](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/QUERY_GRAMMAR.md) (prefixes and two-channel `AND`)
239
+ - [Fusion ranking and critic](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/fusion-ranking.md)
210
240
  - [Release provenance](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/RELEASING.md)
211
241
 
212
242
  MIT
@@ -2,17 +2,16 @@ import { type AstSgrepRuntime, type MachineEnvelope, type RunOptions, type Runti
2
2
  export type SgrepKind = "asgrep" | "def" | "caller" | "graph" | "anchor" | "import" | "pattern" | "embed";
3
3
  export type SgrepSignal = "exact" | "structural" | "semantic";
4
4
  export type SgrepRef = `${string}#L${number}-L${number}`;
5
+ /**
6
+ * Trusted search hit. Location is solely `ref` (parsed once at the CLI/JSON boundary).
7
+ * Wire may still dual-encode file/lines; those are not live fields on this type.
8
+ */
5
9
  export interface SgrepHit {
6
10
  kind: SgrepKind;
7
11
  signal: SgrepSignal;
8
12
  contributors: SgrepKind[];
9
13
  score: number;
10
14
  margin: number;
11
- file: string;
12
- lines: {
13
- start: number;
14
- end: number;
15
- };
16
15
  ref: SgrepRef;
17
16
  preview: string;
18
17
  symbol?: string | null;
@@ -36,15 +35,18 @@ export interface SgrepReadOptions {
36
35
  maxChars?: number;
37
36
  signal?: AbortSignal;
38
37
  }
38
+ /**
39
+ * Trusted read window. Location is solely `ref` (actual lines returned; may expand the
40
+ * request via contextLines). Derive file/lines with `parseSgrepRef` -- no live twins.
41
+ */
39
42
  export interface SgrepReadResult {
40
43
  ref: SgrepRef;
41
- file: string;
42
- lines: {
43
- start: number;
44
- end: number;
45
- };
46
44
  content: string;
47
45
  truncated: boolean;
46
+ /** Present when truncated: 1-indexed line to resume from (on the last shown line). */
47
+ resumeOffset?: number;
48
+ /** Named recovery hint for the model (empty/past-EOF/truncation). */
49
+ note?: string;
48
50
  }
49
51
  export interface SgrepApi {
50
52
  keywordSearch(query: string, options?: SgrepSearchOptions): Promise<SgrepSearchResponse>;
@@ -62,6 +64,12 @@ export interface SgrepApi {
62
64
  }
63
65
  export type SgrepPlan<T> = (sgrep: Readonly<SgrepApi>) => T | Promise<T>;
64
66
  type RuntimeLike = Pick<AstSgrepRuntime, "run" | "resolveRoot">;
67
+ /** Derive file/lines from a branded ref (sole location encoding on SgrepHit). */
68
+ export declare function parseSgrepRef(ref: SgrepRef): {
69
+ file: string;
70
+ start: number;
71
+ end: number;
72
+ };
65
73
  export declare class SgrepCodeMode implements SgrepApi {
66
74
  #private;
67
75
  private readonly runtime;
package/dist/code-mode.js CHANGED
@@ -9,6 +9,17 @@ const DEFAULT_MAX_READ_CHARS = 100_000;
9
9
  const MAX_READ_CHARS = 1_000_000;
10
10
  const MAX_READ_REFS = 20;
11
11
  const MAX_SCAN_BYTES = 64 * 1024 * 1024;
12
+ const MAX_LINE_CHARS = 2_000;
13
+ const DEVICE_PATHS = new Set([
14
+ "/dev/zero", "/dev/urandom", "/dev/random", "/dev/stdin",
15
+ "/dev/stdout", "/dev/stderr", "/dev/null", "/dev/fd/0", "/dev/fd/1", "/dev/fd/2",
16
+ ]);
17
+ function assertSafeReadPath(absolutePath) {
18
+ const normalized = absolutePath.replace(/\\/g, "/");
19
+ if (DEVICE_PATHS.has(normalized) || /^\/proc\/\d+\/fd\//.test(normalized)) {
20
+ throw new RuntimeError("READ_FORBIDDEN_PATH", `${absolutePath} is a device or process fd path and cannot be read`, { path: absolutePath });
21
+ }
22
+ }
12
23
  const MAX_LINE_NUMBER = 0xffff_ffff;
13
24
  const REF_PATTERN = /^(.+?)#L([1-9]\d*)-L([1-9]\d*)$/;
14
25
  const KINDS = new Set(["asgrep", "def", "caller", "graph", "anchor", "import", "pattern", "embed"]);
@@ -38,6 +49,71 @@ function outputArgs(options) {
38
49
  String(boundedInteger(options.excerptLines, 0, 0, MAX_EXCERPT_LINES, "excerptLines")),
39
50
  ];
40
51
  }
52
+ function optionalTextField(field) {
53
+ return field === undefined || field === null || typeof field === "string";
54
+ }
55
+ function wireLinesValid(lines) {
56
+ return !!lines && typeof lines === "object"
57
+ && Number.isSafeInteger(lines.start)
58
+ && Number.isSafeInteger(lines.end)
59
+ && Number(lines.start) > 0
60
+ && Number(lines.end) >= Number(lines.start);
61
+ }
62
+ /** Parse wire location once: prefer branded `ref`; else derive from structured file/lines. */
63
+ function parseWireHitRef(hit) {
64
+ if (typeof hit.ref === "string") {
65
+ parseRef(hit.ref);
66
+ return hit.ref;
67
+ }
68
+ if (typeof hit.file === "string" && hit.file.length > 0 && !isAbsolute(hit.file) && wireLinesValid(hit.lines)) {
69
+ const start = Number(hit.lines.start);
70
+ const end = Number(hit.lines.end);
71
+ if (start > MAX_LINE_NUMBER || end > MAX_LINE_NUMBER) {
72
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
73
+ }
74
+ const ref = `${hit.file}#L${start}-L${end}`;
75
+ parseRef(ref);
76
+ return ref;
77
+ }
78
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
79
+ }
80
+ /** Wire hit shape gate: required protocol fields + optional text fields. Domain checks kept intact. */
81
+ function isValidHitShape(hit) {
82
+ return typeof hit.kind === "string" && KINDS.has(hit.kind)
83
+ && typeof hit.signal === "string" && SIGNALS.has(hit.signal)
84
+ && Array.isArray(hit.contributors) && hit.contributors.length > 0
85
+ && hit.contributors.every((kind) => typeof kind === "string" && KINDS.has(kind))
86
+ && typeof hit.score === "number" && Number.isFinite(hit.score)
87
+ && typeof hit.margin === "number" && Number.isFinite(hit.margin) && hit.margin >= 0
88
+ && typeof hit.preview === "string"
89
+ && optionalTextField(hit.symbol) && optionalTextField(hit.caller) && optionalTextField(hit.callee)
90
+ && optionalTextField(hit.language) && optionalTextField(hit.excerpt);
91
+ }
92
+ function parseSearchHit(candidate) {
93
+ if (!candidate || typeof candidate !== "object") {
94
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
95
+ }
96
+ const hit = candidate;
97
+ if (!isValidHitShape(hit)) {
98
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
99
+ }
100
+ const ref = parseWireHitRef(hit);
101
+ const parsed = {
102
+ kind: hit.kind,
103
+ signal: hit.signal,
104
+ contributors: hit.contributors,
105
+ score: hit.score,
106
+ margin: hit.margin,
107
+ ref,
108
+ preview: hit.preview,
109
+ ...(hit.symbol === undefined ? {} : { symbol: hit.symbol }),
110
+ ...(hit.caller === undefined ? {} : { caller: hit.caller }),
111
+ ...(hit.callee === undefined ? {} : { callee: hit.callee }),
112
+ ...(hit.language === undefined ? {} : { language: hit.language }),
113
+ ...(hit.excerpt === undefined ? {} : { excerpt: hit.excerpt }),
114
+ };
115
+ return parsed;
116
+ }
41
117
  function asSearchResponse(value) {
42
118
  if (value.ok !== true || !Array.isArray(value.hits)) {
43
119
  throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep search response is missing hits");
@@ -45,48 +121,23 @@ function asSearchResponse(value) {
45
121
  if (value.query !== undefined && typeof value.query !== "string") {
46
122
  throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep search response has an invalid query");
47
123
  }
48
- if (value.hit_count !== undefined
49
- && (typeof value.hit_count !== "number" || !Number.isSafeInteger(value.hit_count)
50
- || value.hit_count < 0 || value.hit_count !== value.hits.length)) {
51
- throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep search response has an invalid hit_count");
52
- }
53
- const optionalText = (field) => field === undefined || field === null || typeof field === "string";
54
- for (const candidate of value.hits) {
55
- if (!candidate || typeof candidate !== "object") {
56
- throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
57
- }
58
- const hit = candidate;
59
- const lines = hit.lines;
60
- const validLines = !!lines && typeof lines === "object"
61
- && Number.isSafeInteger(lines.start)
62
- && Number.isSafeInteger(lines.end)
63
- && Number(lines.start) > 0
64
- && Number(lines.end) >= Number(lines.start);
65
- const valid = typeof hit.kind === "string" && KINDS.has(hit.kind)
66
- && typeof hit.signal === "string" && SIGNALS.has(hit.signal)
67
- && Array.isArray(hit.contributors) && hit.contributors.length > 0
68
- && hit.contributors.every((kind) => typeof kind === "string" && KINDS.has(kind))
69
- && typeof hit.score === "number" && Number.isFinite(hit.score)
70
- && typeof hit.margin === "number" && Number.isFinite(hit.margin) && hit.margin >= 0
71
- && typeof hit.file === "string" && hit.file.length > 0 && !isAbsolute(hit.file)
72
- && validLines
73
- && typeof hit.ref === "string"
74
- && typeof hit.preview === "string"
75
- && optionalText(hit.symbol) && optionalText(hit.caller) && optionalText(hit.callee)
76
- && optionalText(hit.language) && optionalText(hit.excerpt);
77
- if (!valid)
78
- throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
79
- const parsed = parseRef(hit.ref);
80
- const hitLines = lines;
81
- if (parsed.file !== hit.file || parsed.start !== hitLines.start || parsed.end !== hitLines.end) {
82
- throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep hit ref does not match its file and lines");
124
+ // Guard form of compound hit_count validity (same checks, less && nesting).
125
+ if (value.hit_count !== undefined) {
126
+ if (typeof value.hit_count !== "number" || !Number.isSafeInteger(value.hit_count)
127
+ || value.hit_count < 0 || value.hit_count !== value.hits.length) {
128
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep search response has an invalid hit_count");
83
129
  }
84
130
  }
85
- return value;
131
+ const hits = value.hits.map(parseSearchHit);
132
+ return { ...value, hits };
86
133
  }
87
134
  function refValue(value) {
88
135
  return typeof value === "string" ? value : value.ref;
89
136
  }
137
+ /** Derive file/lines from a branded ref (sole location encoding on SgrepHit). */
138
+ export function parseSgrepRef(ref) {
139
+ return parseRef(ref);
140
+ }
90
141
  function parseRef(ref) {
91
142
  const match = REF_PATTERN.exec(ref);
92
143
  if (!match)
@@ -119,6 +170,9 @@ function boundedPrefix(value, maxChars) {
119
170
  }
120
171
  return { text: value, chars, truncated: false };
121
172
  }
173
+ function formatReadRef(file, start, end) {
174
+ return `${file}#L${start}-L${end}`;
175
+ }
122
176
  async function readLineWindow(handle, parsed, contextLines, maxChars, signal) {
123
177
  const stat = await handle.stat();
124
178
  if (!stat.isFile())
@@ -146,7 +200,9 @@ async function readLineWindow(handle, parsed, contextLines, maxChars, signal) {
146
200
  selectedStart ??= lineNumber;
147
201
  selectedEnd = lineNumber;
148
202
  if (!truncated) {
149
- const addition = `${selectedLines > 0 ? "\n" : ""}${line.endsWith("\r") ? line.slice(0, -1) : line}`;
203
+ const rawLine = line.endsWith("\r") ? line.slice(0, -1) : line;
204
+ const clamped = rawLine.length > MAX_LINE_CHARS ? `${rawLine.slice(0, MAX_LINE_CHARS)}…` : rawLine;
205
+ const addition = `${selectedLines > 0 ? "\n" : ""}${clamped}`;
150
206
  const bounded = boundedPrefix(addition, maxChars - contentChars);
151
207
  content += bounded.text;
152
208
  contentChars += bounded.chars;
@@ -209,14 +265,31 @@ async function readLineWindow(handle, parsed, contextLines, maxChars, signal) {
209
265
  stream.destroy();
210
266
  }
211
267
  checkAbort(signal);
212
- if (parsed.start >= lineNumber || parsed.end >= lineNumber) {
213
- throw new RuntimeError("RANGE_OUT_OF_BOUNDS", `${parsed.file} has fewer than ${parsed.end} lines`);
268
+ const totalLines = Math.max(0, lineNumber - 1);
269
+ if (totalLines === 0) {
270
+ return {
271
+ window: null,
272
+ content: "",
273
+ truncated: false,
274
+ note: `${parsed.file} is empty`,
275
+ };
214
276
  }
277
+ if (parsed.start > totalLines || parsed.end > totalLines) {
278
+ const resume = Math.max(1, totalLines);
279
+ throw new RuntimeError("RANGE_OUT_OF_BOUNDS", `Note: offset ${parsed.start} is beyond the end of ${parsed.file} (${totalLines} lines scanned). Retry with a smaller offset (e.g. start=${resume})`, { file: parsed.file, start: parsed.start, end: parsed.end, totalLines, resumeOffset: resume });
280
+ }
281
+ const endLine = selectedEnd ?? Math.max(wantedStart, totalLines);
282
+ const startLine = selectedStart ?? wantedStart;
215
283
  return {
216
- file: parsed.file,
217
- lines: { start: selectedStart ?? wantedStart, end: selectedEnd ?? Math.max(wantedStart, lineNumber - 1) },
284
+ window: { file: parsed.file, start: startLine, end: endLine },
218
285
  content,
219
286
  truncated,
287
+ ...(truncated
288
+ ? {
289
+ resumeOffset: endLine,
290
+ note: `truncated at line ${endLine}; resume with start=${endLine}`,
291
+ }
292
+ : {}),
220
293
  };
221
294
  }
222
295
  async function runSearch(runtime, context, command, query, options) {
@@ -225,6 +298,7 @@ async function runSearch(runtime, context, command, query, options) {
225
298
  }
226
299
  async function resolveReadableFile(root, ref, parsed) {
227
300
  const unresolved = resolve(root, parsed.file);
301
+ assertSafeReadPath(unresolved);
228
302
  if (!inside(root, unresolved))
229
303
  throw new RuntimeError("PATH_OUTSIDE_ROOT", `Ref escapes the project root: ${ref}`);
230
304
  let filePath;
@@ -331,7 +405,17 @@ export class SgrepCodeMode {
331
405
  const handle = await openStableHandle(root, ref, parsed.file, unresolved, filePath, expectedStat);
332
406
  try {
333
407
  const budget = perRefChars + (index < remainder ? 1 : 0);
334
- results.push({ ref, ...await readLineWindow(handle, parsed, contextLines, budget, options.signal) });
408
+ const payload = await readLineWindow(handle, parsed, contextLines, budget, options.signal);
409
+ const windowRef = payload.window
410
+ ? formatReadRef(payload.window.file, payload.window.start, payload.window.end)
411
+ : ref;
412
+ results.push({
413
+ ref: windowRef,
414
+ content: payload.content,
415
+ truncated: payload.truncated,
416
+ ...(payload.resumeOffset === undefined ? {} : { resumeOffset: payload.resumeOffset }),
417
+ ...(payload.note === undefined ? {} : { note: payload.note }),
418
+ });
335
419
  }
336
420
  finally {
337
421
  await handle.close();
@@ -1,49 +1,77 @@
1
1
  import type { MachineEnvelope } from "../runtime.js";
2
2
  import type { ChainArgs, SearchArgs } from "./types.js";
3
3
  import { type BatchCapableHost, type DispatchStats } from "./dispatch.js";
4
+ /**
5
+ * Spawn/CLI transport. Hosts provide argv `run` only — never a typed twin.
6
+ * Typed entry lives solely on {@link DispatchSurface} (dispatcher output).
7
+ */
4
8
  export type ConnectorHost = {
5
- /** Typed tool call (preferred no argv archaeology). */
6
- call?(tool: string, args: Record<string, unknown>, context: {
9
+ run(args: readonly string[], context: {
7
10
  cwd: string;
8
11
  }, options?: {
9
12
  signal?: AbortSignal;
10
13
  }): Promise<MachineEnvelope>;
11
- /** Legacy CLI argv (spawn fallback / direct tools). */
12
- run(args: readonly string[], context: {
14
+ };
15
+ /**
16
+ * Trusted typed dispatch after coalescing. `call` is required; no argv peer
17
+ * that can disagree with tool+args.
18
+ */
19
+ export type DispatchSurface = {
20
+ call(tool: string, args: Record<string, unknown>, context: {
13
21
  cwd: string;
14
22
  }, options?: {
15
23
  signal?: AbortSignal;
16
24
  }): Promise<MachineEnvelope>;
17
25
  };
18
26
  export type AsgrepConnector = {
19
- search(input: SearchArgs): Promise<MachineEnvelope>;
20
- semantic(input: SearchArgs): Promise<MachineEnvelope>;
21
- chain(input: ChainArgs): Promise<MachineEnvelope>;
27
+ search(input: SearchArgs, options?: {
28
+ signal?: AbortSignal;
29
+ }): Promise<MachineEnvelope>;
30
+ semantic(input: SearchArgs, options?: {
31
+ signal?: AbortSignal;
32
+ }): Promise<MachineEnvelope>;
33
+ chain(input: ChainArgs, options?: {
34
+ signal?: AbortSignal;
35
+ }): Promise<MachineEnvelope>;
22
36
  defs(input: {
23
37
  symbol: string;
24
38
  limit?: number;
25
39
  excerptLines?: number;
40
+ }, options?: {
41
+ signal?: AbortSignal;
26
42
  }): Promise<MachineEnvelope>;
27
43
  callers(input: {
28
44
  symbol: string;
29
45
  limit?: number;
30
46
  excerptLines?: number;
47
+ }, options?: {
48
+ signal?: AbortSignal;
31
49
  }): Promise<MachineEnvelope>;
32
50
  imports(input: {
33
51
  module: string;
34
52
  limit?: number;
35
53
  excerptLines?: number;
54
+ }, options?: {
55
+ signal?: AbortSignal;
56
+ }): Promise<MachineEnvelope>;
57
+ indexStatus(options?: {
58
+ signal?: AbortSignal;
36
59
  }): Promise<MachineEnvelope>;
37
- indexStatus(): Promise<MachineEnvelope>;
38
60
  indexRepo(input?: {
39
61
  force?: boolean;
62
+ }, options?: {
63
+ signal?: AbortSignal;
40
64
  }): Promise<MachineEnvelope>;
41
65
  /** Progressive discovery (like deferred tools) — list/filter available asgrep tools. */
42
66
  catalogSearch(input: {
43
67
  query: string;
68
+ }, options?: {
69
+ signal?: AbortSignal;
44
70
  }): Promise<MachineEnvelope>;
45
71
  catalogDescribe(input: {
46
72
  name: string;
73
+ }, options?: {
74
+ signal?: AbortSignal;
47
75
  }): Promise<MachineEnvelope>;
48
76
  };
49
77
  export type ConnectorBundle = {
@@ -52,7 +80,7 @@ export type ConnectorBundle = {
52
80
  resetStats: () => void;
53
81
  };
54
82
  /**
55
- * Host-side connector: typed methods the sandbox calls.
83
+ * Host-side connector: typed methods the Code Mode program calls.
56
84
  *
57
85
  * Same-tick calls (Promise.all) are coalesced by CodemodeDispatcher so N
58
86
  * lookups share sticky serve / one warm batch process when available.
@@ -11,59 +11,65 @@ function clampExcerpt(excerptLines) {
11
11
  return Math.min(100, Math.max(0, Math.trunc(excerptLines)));
12
12
  }
13
13
  /**
14
- * Host-side connector: typed methods the sandbox calls.
14
+ * Host-side connector: typed methods the Code Mode program calls.
15
15
  *
16
16
  * Same-tick calls (Promise.all) are coalesced by CodemodeDispatcher so N
17
17
  * lookups share sticky serve / one warm batch process when available.
18
18
  */
19
19
  export function createAsgrepConnector(host, context, options = {}) {
20
20
  const dispatcher = createCodemodeDispatcher(host);
21
- const runOptions = options.signal ? { signal: options.signal } : {};
22
- const call = (tool, args) => {
23
- if (dispatcher.host.call) {
24
- return dispatcher.host.call(tool, args, context, runOptions);
21
+ const combinedSignals = new WeakMap();
22
+ const callOptions = (signal) => {
23
+ if (!options.signal)
24
+ return signal ? { signal } : {};
25
+ if (!signal || signal === options.signal)
26
+ return { signal: options.signal };
27
+ let combined = combinedSignals.get(signal);
28
+ if (!combined) {
29
+ combined = AbortSignal.any([options.signal, signal]);
30
+ combinedSignals.set(signal, combined);
25
31
  }
26
- // Should not happen — dispatcher always exposes call.
27
- return dispatcher.host.run([], context, runOptions);
32
+ return { signal: combined };
28
33
  };
34
+ const call = (tool, args, signal) => dispatcher.host.call(tool, args, context, callOptions(signal));
29
35
  // Bound function properties (not methods) so vm call sites cannot lose `this`.
30
36
  const asgrep = {
31
- search: (input) => call("search", {
37
+ search: (input, callOptions) => call("search", {
32
38
  query: input.query,
33
39
  limit: clampLimit(input.limit),
34
40
  excerpt_lines: clampExcerpt(input.excerptLines),
35
41
  format: input.format === "agent" ? "agent" : "capsule",
36
- }),
37
- semantic: (input) => call("semantic", {
42
+ }, callOptions?.signal),
43
+ semantic: (input, callOptions) => call("semantic", {
38
44
  query: input.query,
39
45
  limit: clampLimit(input.limit),
40
46
  excerpt_lines: clampExcerpt(input.excerptLines),
41
47
  format: input.format === "agent" ? "agent" : "capsule",
42
- }),
43
- chain: (input) => call("chain", {
48
+ }, callOptions?.signal),
49
+ chain: (input, callOptions) => call("chain", {
44
50
  query: input.query,
45
51
  limit: clampLimit(input.limit),
46
52
  top_n: 20,
47
- }),
48
- defs: (input) => call("defs", {
53
+ }, callOptions?.signal),
54
+ defs: (input, callOptions) => call("defs", {
49
55
  symbol: input.symbol,
50
56
  limit: clampLimit(input.limit),
51
57
  excerpt_lines: clampExcerpt(input.excerptLines),
52
- }),
53
- callers: (input) => call("callers", {
58
+ }, callOptions?.signal),
59
+ callers: (input, callOptions) => call("callers", {
54
60
  symbol: input.symbol,
55
61
  limit: clampLimit(input.limit),
56
62
  excerpt_lines: clampExcerpt(input.excerptLines),
57
- }),
58
- imports: (input) => call("imports", {
63
+ }, callOptions?.signal),
64
+ imports: (input, callOptions) => call("imports", {
59
65
  module: input.module,
60
66
  limit: clampLimit(input.limit),
61
67
  excerpt_lines: clampExcerpt(input.excerptLines),
62
- }),
63
- indexStatus: () => call("index_status", {}),
64
- indexRepo: (input = {}) => call("index_repo", { force: input.force === true }),
65
- catalogSearch: (input) => call("catalog_search", { query: input.query }),
66
- catalogDescribe: (input) => call("catalog_describe", { name: input.name }),
68
+ }, callOptions?.signal),
69
+ indexStatus: (callOptions) => call("index_status", {}, callOptions?.signal),
70
+ indexRepo: (input = {}, callOptions) => call("index_repo", { force: input.force === true }, callOptions?.signal),
71
+ catalogSearch: (input, callOptions) => call("catalog_search", { query: input.query }, callOptions?.signal),
72
+ catalogDescribe: (input, callOptions) => call("catalog_describe", { name: input.name }, callOptions?.signal),
67
73
  };
68
74
  return {
69
75
  asgrep,
@@ -5,7 +5,7 @@
5
5
  * for the whole Code Mode program; batch coalescing kills it per Promise.all wave.
6
6
  */
7
7
  import type { MachineEnvelope } from "../runtime.js";
8
- import type { ConnectorHost } from "./connector.js";
8
+ import type { ConnectorHost, DispatchSurface } from "./connector.js";
9
9
  export type CodemodeToolCall = {
10
10
  tool: string;
11
11
  args: Record<string, unknown>;
@@ -61,11 +61,10 @@ export type BatchCapableHost = ConnectorHost & {
61
61
  * one microtask wave. Prefers sticky serve → one-shot batch → overlapped spawn.
62
62
  */
63
63
  export declare function createCodemodeDispatcher(host: BatchCapableHost): {
64
- host: ConnectorHost;
64
+ host: DispatchSurface;
65
65
  stats: () => DispatchStats;
66
66
  resetStats: () => void;
67
67
  };
68
- /** Build CLI argv for spawn fallback (typed path preferred). */
69
68
  export declare function argvFor(tool: string, args: Record<string, unknown>): string[];
70
69
  export declare function asEnvelope(value: unknown, command?: string): MachineEnvelope;
71
70
  /** One-shot batch via stdin (no tempfile) when spawn-with-stdin is available. */