pi-ast-sgrep 2.0.1 → 2.1.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.
Files changed (49) hide show
  1. package/README.md +18 -21
  2. package/dist/code-mode.d.ts +1 -1
  3. package/dist/code-mode.js +1 -1
  4. package/dist/codemode/connector.d.ts +17 -3
  5. package/dist/codemode/connector.js +59 -31
  6. package/dist/codemode/dispatch.d.ts +13 -1
  7. package/dist/codemode/dispatch.js +83 -23
  8. package/dist/codemode/guest-api.d.ts +16 -0
  9. package/dist/codemode/guest-api.js +194 -0
  10. package/dist/codemode/guest-worker.mjs +287 -0
  11. package/dist/codemode/index.d.ts +4 -3
  12. package/dist/codemode/index.js +4 -3
  13. package/dist/codemode/runner.d.ts +13 -9
  14. package/dist/codemode/runner.js +411 -213
  15. package/dist/codemode/session-pool.d.ts +6 -1
  16. package/dist/codemode/session-pool.js +125 -32
  17. package/dist/codemode/types.d.ts +33 -2
  18. package/dist/codemode/types.js +39 -13
  19. package/dist/codemode/worker.d.ts +1 -1
  20. package/dist/codemode/worker.js +25 -2
  21. package/dist/host/commands.d.ts +6 -0
  22. package/dist/host/commands.js +49 -0
  23. package/dist/host/results.d.ts +115 -0
  24. package/dist/host/results.js +80 -0
  25. package/dist/host/tools.d.ts +9 -0
  26. package/dist/host/tools.js +610 -0
  27. package/dist/index.d.ts +7 -34
  28. package/dist/index.js +5 -543
  29. package/dist/runtime/config.d.ts +36 -0
  30. package/dist/runtime/config.js +98 -0
  31. package/dist/runtime/freshness.d.ts +43 -0
  32. package/dist/runtime/freshness.js +413 -0
  33. package/dist/runtime/index-health.d.ts +16 -0
  34. package/dist/runtime/index-health.js +109 -0
  35. package/dist/runtime/runtime.d.ts +48 -0
  36. package/dist/runtime/runtime.js +265 -0
  37. package/dist/runtime/sqlite.d.ts +15 -0
  38. package/dist/runtime/sqlite.js +63 -0
  39. package/dist/runtime/types.d.ts +55 -0
  40. package/dist/runtime/types.js +25 -0
  41. package/dist/ui/card.d.ts +63 -0
  42. package/dist/ui/card.js +316 -0
  43. package/dist/{present.d.ts → ui/present.d.ts} +20 -2
  44. package/dist/{present.js → ui/present.js} +68 -9
  45. package/package.json +6 -6
  46. package/dist/codemode/sandbox-worker.d.ts +0 -1
  47. package/dist/codemode/sandbox-worker.js +0 -204
  48. package/dist/runtime.d.ts +0 -137
  49. package/dist/runtime.js +0 -769
package/README.md CHANGED
@@ -57,7 +57,7 @@ Pi can make one `asgrep` call like this:
57
57
 
58
58
  ```json
59
59
  {
60
- "code": "async () => {\n const seed = await asgrep.search({ query: 'where are access tokens refreshed?', limit: 5 });\n const symbol = seed.hits?.[0]?.symbol;\n if (!symbol) return { seed };\n const [defs, callers] = await Promise.all([\n asgrep.defs({ symbol, limit: 5 }),\n asgrep.callers({ symbol, limit: 10 }),\n ]);\n return { symbol, defs: defs.hits, callers: callers.hits };\n}"
60
+ "code": "async () => {\n const seed = await asgrep.search('where are access tokens refreshed?', { limit: 5 });\n const hit = seed.hits?.[0];\n if (!hit) return { seed, next: seed.suggested_next };\n const [defs, window] = await Promise.all([\n asgrep.defs(hit.symbol, { limit: 5 }),\n asgrep.read({ refs: [hit.ref] }),\n ]);\n return { symbol: hit.symbol, defs: defs.hits, window };\n}"
61
61
  }
62
62
  ```
63
63
 
@@ -65,32 +65,28 @@ This workflow narrows the first result, runs independent follow-up searches toge
65
65
 
66
66
  ### Code Mode API
67
67
 
68
- The Code Mode program receives these asynchronous methods on `asgrep`:
68
+ The Code Mode program receives these asynchronous methods on `asgrep`. Positional arguments work; object forms remain valid.
69
69
 
70
70
  | Method | Use |
71
71
  |---|---|
72
- | `asgrep.search({ query, limit?, excerptLines? })` | Search by intent, symbol, or a prefixed structural query. |
73
- | `asgrep.semantic({ query, limit?, excerptLines? })` | Search local semantic embeddings directly. |
74
- | `asgrep.defs({ symbol, limit? })` | Find definitions for one symbol. |
75
- | `asgrep.callers({ symbol, limit? })` | Find call sites for one symbol. |
76
- | `asgrep.imports({ module, limit? })` | Find imports of one module. |
77
- | `asgrep.chain({ query, limit? })` | Trace related symbols and graph edges. |
78
- | `asgrep.indexStatus()` | Read index and backend state. |
79
- | `asgrep.indexRepo({ force? })` | Create, refresh, or rebuild the index. |
80
- | `asgrep.catalogSearch({ query })` | Discover less common ast-sgrep operations. |
81
- | `asgrep.catalogDescribe({ name })` | Read the schema for a discovered operation. |
72
+ | `asgrep.search("query", { limit?, in?, lang? })` | Hybrid search: intent, symbol, or prefixed `defs:` / `callers:` / `pattern:` query. Bound a tree with `in`. |
73
+ | `asgrep.find("token")` | Lexical / identifier lookup (`word:`). Prefixed queries pass through. |
74
+ | `asgrep.defs("Symbol")` | Jump to definitions. |
75
+ | `asgrep.callers("Symbol")` | Reverse-walk callers. |
76
+ | `asgrep.read({ path, start, end }` or `{ refs }`) | Batched line windows from the index. Prefer one call with `refs`. |
77
+ | `asgrep.edit(path, oldText, newText)` | Unique string replace jailed to the project root, then targeted reindex. |
78
+ | `asgrep.indexStatus()` / `asgrep.doctor()` | Index health without a fifth outer Pi tool. |
82
79
 
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.
80
+ 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. A search with 0 hits includes `suggested_next`.
84
81
 
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.
82
+ Code Mode runs **in-process** in a restricted `node:vm` context (no Worker sandbox, no OS jail). `asgrep` and `console` are built inside the context; the host only exposes a JSON bridge and a log sink so host `Function` cannot leak. Return shapes are declared on `asgrep.*` (muscle memory). `find({ query: "blast:Symbol" })` reverse-walks callers; `blast:path/to/file.ts` uses imports. Same trust boundary as Pi `bash`. Prefer Code Mode **or** MCP for a client, never both.
86
83
 
87
84
  The bridge rejects oversized call arguments and serialized results, allows at
88
85
  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.
86
+ reaches the extension host. Raw-memory and WebAssembly globals are unavailable.
87
+ Native tool values are capped at 1 MiB each and complete batch responses at 4 MiB
88
+ before Node-API converts them into extension-host objects. These bounds do not
89
+ turn `node:vm` into an OS sandbox.
94
90
 
95
91
  ## Direct one-shot search
96
92
 
@@ -132,9 +128,9 @@ Code Mode and `ast-sgrep-mcp` are separate front ends over the same Rust search
132
128
 
133
129
  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.
134
130
 
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.
131
+ 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 on first use preserves correctness. A ready index is not walked on first search or on the refresh interval; the interval only re-checks index health. Concurrent searches for the same root share one in-flight refresh. The last cancelled waiter aborts that shared index so Pi cannot leave workers running after a search is cancelled. In-process indexing uses the host's available parallelism by default.
136
132
 
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.
133
+ The refresh interval does not force a full tree walk. Run `/asgrep-index` when you need freshness immediately after a large external operation the watcher did not see; use `/asgrep-reindex` only for an incompatible or corrupt index, or when you explicitly need a strict full rebuild.
138
134
 
139
135
  The package never edits `.gitignore`. Add this entry yourself if index data must stay untracked:
140
136
 
@@ -209,6 +205,7 @@ Defaults are a 30-second operation timeout, 4 MiB output limit, and 30-second fr
209
205
  | `ASGREP_TIMEOUT_MS` | Set the native operation timeout. |
210
206
  | `ASGREP_MAX_OUTPUT_BYTES` | Bound native output. |
211
207
  | `ASGREP_REFRESH_INTERVAL_MS` | Set the idle freshness-check interval. |
208
+ | `ASGREP_INDEX_THREADS` | Cap in-process Code Mode indexing threads (default: host parallelism). |
212
209
  | `ASGREP_BIN` | Override the packaged binary for development. |
213
210
 
214
211
  Explicit project configuration can opt into `allowOutsideProject`; global settings and environment variables cannot relax the default project boundary. See the [complete package guide](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/pi-package.md) for schema and precedence details.
@@ -1,4 +1,4 @@
1
- import { type AstSgrepRuntime, type MachineEnvelope, type RunOptions, type RuntimeContext } from "./runtime.js";
1
+ import { type AstSgrepRuntime, type MachineEnvelope, type RunOptions, type RuntimeContext } from "./runtime/runtime.js";
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}`;
package/dist/code-mode.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { constants } from "node:fs";
2
2
  import { lstat, open, realpath } from "node:fs/promises";
3
3
  import { isAbsolute, relative, resolve, sep } from "node:path";
4
- import { RuntimeError } from "./runtime.js";
4
+ import { RuntimeError } from "./runtime/runtime.js";
5
5
  const DEFAULT_LIMIT = 20;
6
6
  const MAX_LIMIT = 100;
7
7
  const MAX_EXCERPT_LINES = 100;
@@ -1,6 +1,6 @@
1
- import type { MachineEnvelope } from "../runtime.js";
2
- import type { ChainArgs, SearchArgs } from "./types.js";
3
- import { type BatchCapableHost, type DispatchStats } from "./dispatch.js";
1
+ import type { MachineEnvelope } from "../runtime/runtime.js";
2
+ import type { ChainArgs, EditArgs, FindArgs, ReadArgs, SearchArgs } from "./types.js";
3
+ import { type BatchCapableHost, type DispatchCall, type DispatchStats } from "./dispatch.js";
4
4
  /**
5
5
  * Spawn/CLI transport. Hosts provide argv `run` only — never a typed twin.
6
6
  * Typed entry lives solely on {@link DispatchSurface} (dispatcher output).
@@ -27,6 +27,15 @@ export type AsgrepConnector = {
27
27
  search(input: SearchArgs, options?: {
28
28
  signal?: AbortSignal;
29
29
  }): Promise<MachineEnvelope>;
30
+ find(input: FindArgs, options?: {
31
+ signal?: AbortSignal;
32
+ }): Promise<MachineEnvelope>;
33
+ read(input: ReadArgs, options?: {
34
+ signal?: AbortSignal;
35
+ }): Promise<MachineEnvelope>;
36
+ edit(input: EditArgs, options?: {
37
+ signal?: AbortSignal;
38
+ }): Promise<MachineEnvelope>;
30
39
  semantic(input: SearchArgs, options?: {
31
40
  signal?: AbortSignal;
32
41
  }): Promise<MachineEnvelope>;
@@ -73,10 +82,15 @@ export type AsgrepConnector = {
73
82
  }, options?: {
74
83
  signal?: AbortSignal;
75
84
  }): Promise<MachineEnvelope>;
85
+ doctor(options?: {
86
+ signal?: AbortSignal;
87
+ }): Promise<MachineEnvelope>;
76
88
  };
77
89
  export type ConnectorBundle = {
78
90
  asgrep: AsgrepConnector;
79
91
  stats: () => DispatchStats;
92
+ /** Per-call dispatch trace for the current run (lane + ms + ok, capped). */
93
+ trace: () => DispatchCall[];
80
94
  resetStats: () => void;
81
95
  };
82
96
  /**
@@ -1,3 +1,5 @@
1
+ import { coerceHostArgs } from "./guest-api.js";
2
+ import { defined } from "./types.js";
1
3
  import { createCodemodeDispatcher, } from "./dispatch.js";
2
4
  const DEFAULT_LIMIT = 8;
3
5
  function clampLimit(limit) {
@@ -32,48 +34,74 @@ export function createAsgrepConnector(host, context, options = {}) {
32
34
  return { signal: combined };
33
35
  };
34
36
  const call = (tool, args, signal) => dispatcher.host.call(tool, args, context, callOptions(signal));
35
- // Bound function properties (not methods) so vm call sites cannot lose `this`.
36
- const asgrep = {
37
- search: (input, callOptions) => call("search", {
38
- query: input.query,
39
- limit: clampLimit(input.limit),
40
- excerpt_lines: clampExcerpt(input.excerptLines),
41
- format: input.format === "agent" ? "agent" : "capsule",
42
- }, callOptions?.signal),
43
- semantic: (input, callOptions) => call("semantic", {
44
- query: input.query,
37
+ const searchPayload = (method, input) => {
38
+ const scoped = coerceHostArgs(method, { ...input });
39
+ const payload = {
40
+ query: scoped.query,
45
41
  limit: clampLimit(input.limit),
46
42
  excerpt_lines: clampExcerpt(input.excerptLines),
47
43
  format: input.format === "agent" ? "agent" : "capsule",
48
- }, callOptions?.signal),
49
- chain: (input, callOptions) => call("chain", {
50
- query: input.query,
51
- limit: clampLimit(input.limit),
52
- top_n: 20,
53
- }, callOptions?.signal),
54
- defs: (input, callOptions) => call("defs", {
55
- symbol: input.symbol,
56
- limit: clampLimit(input.limit),
57
- excerpt_lines: clampExcerpt(input.excerptLines),
58
- }, callOptions?.signal),
59
- callers: (input, callOptions) => call("callers", {
60
- symbol: input.symbol,
61
- limit: clampLimit(input.limit),
62
- excerpt_lines: clampExcerpt(input.excerptLines),
63
- }, callOptions?.signal),
64
- imports: (input, callOptions) => call("imports", {
65
- module: input.module,
66
- limit: clampLimit(input.limit),
67
- excerpt_lines: clampExcerpt(input.excerptLines),
68
- }, callOptions?.signal),
44
+ };
45
+ if (typeof scoped.lang === "string" && scoped.lang.trim())
46
+ payload.lang = scoped.lang.trim();
47
+ return payload;
48
+ };
49
+ // Bound function properties (not methods) so vm call sites cannot lose `this`.
50
+ const asgrep = {
51
+ search: (input, callOptions) => call("search", searchPayload("search", input), callOptions?.signal),
52
+ find: (input, callOptions) => call("find", searchPayload("find", input), callOptions?.signal),
53
+ read: (input, callOptions) => call("read", defined({
54
+ path: input.path,
55
+ start: input.start,
56
+ end: input.end,
57
+ ref: input.ref,
58
+ refs: input.refs,
59
+ context_lines: input.contextLines,
60
+ max_chars: input.maxChars,
61
+ }), callOptions?.signal),
62
+ edit: (input, callOptions) => {
63
+ // Multi-edit wire contract: every entry carries its own path; the
64
+ // top-level path is the default for entries that omit it.
65
+ const edits = input.edits?.map((entry) => ({
66
+ ...(typeof input.path === "string" ? { path: input.path } : {}),
67
+ ...entry,
68
+ }));
69
+ return call("edit", defined({
70
+ path: input.path,
71
+ oldText: input.oldText,
72
+ newText: input.newText,
73
+ edits,
74
+ }), callOptions?.signal);
75
+ },
76
+ semantic: (input, callOptions) => call("semantic", searchPayload("semantic", input), callOptions?.signal),
77
+ chain: (input, callOptions) => call("chain", { query: input.query, limit: clampLimit(input.limit), top_n: 20 }, callOptions?.signal),
78
+ defs: (input, callOptions) => {
79
+ const scoped = coerceHostArgs("defs", { ...input });
80
+ return call("defs", {
81
+ symbol: scoped.symbol,
82
+ limit: clampLimit(input.limit),
83
+ excerpt_lines: clampExcerpt(input.excerptLines),
84
+ }, callOptions?.signal);
85
+ },
86
+ callers: (input, callOptions) => {
87
+ const scoped = coerceHostArgs("callers", { ...input });
88
+ return call("callers", {
89
+ symbol: scoped.symbol,
90
+ limit: clampLimit(input.limit),
91
+ excerpt_lines: clampExcerpt(input.excerptLines),
92
+ }, callOptions?.signal);
93
+ },
94
+ imports: (input, callOptions) => call("imports", defined({ module: input.module, limit: clampLimit(input.limit), excerpt_lines: clampExcerpt(input.excerptLines) }), callOptions?.signal),
69
95
  indexStatus: (callOptions) => call("index_status", {}, callOptions?.signal),
70
96
  indexRepo: (input = {}, callOptions) => call("index_repo", { force: input.force === true }, callOptions?.signal),
71
97
  catalogSearch: (input, callOptions) => call("catalog_search", { query: input.query }, callOptions?.signal),
72
98
  catalogDescribe: (input, callOptions) => call("catalog_describe", { name: input.name }, callOptions?.signal),
99
+ doctor: (callOptions) => host.run(["doctor", ".", "--json"], context, callOptions),
73
100
  };
74
101
  return {
75
102
  asgrep,
76
103
  stats: dispatcher.stats,
104
+ trace: dispatcher.trace,
77
105
  resetStats: dispatcher.resetStats,
78
106
  };
79
107
  }
@@ -4,7 +4,7 @@
4
4
  * Amdahl: serial cost is process spawn + SQLite open. Sticky serve kills spawn
5
5
  * for the whole Code Mode program; batch coalescing kills it per Promise.all wave.
6
6
  */
7
- import type { MachineEnvelope } from "../runtime.js";
7
+ import type { MachineEnvelope } from "../runtime/runtime.js";
8
8
  import type { ConnectorHost, DispatchSurface } from "./connector.js";
9
9
  export type CodemodeToolCall = {
10
10
  tool: string;
@@ -18,6 +18,15 @@ export type DispatchStats = {
18
18
  stickyCalls: number;
19
19
  wallMs: number;
20
20
  };
21
+ /** One settled host call: which lane carried it and how long it took. */
22
+ export type DispatchCall = {
23
+ tool: string;
24
+ /** Short display target: query/symbol/module/path, whichever the call used. */
25
+ target: string;
26
+ lane: "sticky" | "batch" | "spawn" | "serial";
27
+ ok: boolean;
28
+ ms: number;
29
+ };
21
30
  export type BatchResult = {
22
31
  results: Array<{
23
32
  id: string;
@@ -41,6 +50,8 @@ export type StickyWorker = {
41
50
  signal?: AbortSignal;
42
51
  }): Promise<BatchResult>;
43
52
  end(): Promise<void>;
53
+ /** True after timeout/crash/end. Missing means "assume live". */
54
+ closed?: () => boolean;
44
55
  };
45
56
  export type BatchCapableHost = ConnectorHost & {
46
57
  /** One-shot warm batch (codemode-batch). */
@@ -63,6 +74,7 @@ export type BatchCapableHost = ConnectorHost & {
63
74
  export declare function createCodemodeDispatcher(host: BatchCapableHost): {
64
75
  host: DispatchSurface;
65
76
  stats: () => DispatchStats;
77
+ trace: () => DispatchCall[];
66
78
  resetStats: () => void;
67
79
  };
68
80
  export declare function argvFor(tool: string, args: Record<string, unknown>): string[];
@@ -7,8 +7,16 @@
7
7
  import { mkdtemp, writeFile, rm } from "node:fs/promises";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
+ function callTarget(args) {
11
+ const refs = args.refs ?? args.ref;
12
+ const value = args.query ?? args.symbol ?? args.module ?? args.path ?? args.name
13
+ ?? (Array.isArray(refs) ? refs.map((r) => typeof r === "string" ? r : r?.path ?? "").filter(Boolean).join(", ")
14
+ : typeof refs === "string" ? refs : "");
15
+ const text = String(value).replace(/\s+/g, " ").trim();
16
+ return text.length > 60 ? text.slice(0, 59) + "…" : text;
17
+ }
10
18
  const MAX_WAVE = 32;
11
- const MUTATING_TOOLS = new Set(["index_repo"]);
19
+ const MUTATING_TOOLS = new Set(["index_repo", "edit"]);
12
20
  const abortError = () => Object.assign(new Error("codemode aborted"), { name: "AbortError" });
13
21
  function rejectWave(wave, cause) {
14
22
  for (const item of wave)
@@ -30,6 +38,21 @@ export function createCodemodeDispatcher(host) {
30
38
  let pending = [];
31
39
  let scheduled = false;
32
40
  let stats = emptyStats();
41
+ // Per-run call log: which lane each call took and how long it took. Bounded;
42
+ // surfaced in codemode result details so callers can see the dispatch shape.
43
+ const calls = [];
44
+ const TRACE_CAP = 128;
45
+ const recordCall = (item, ok) => {
46
+ if (calls.length >= TRACE_CAP)
47
+ return;
48
+ calls.push({ tool: item.tool, target: callTarget(item.args), lane: item.lane, ok, ms: Date.now() - item.startedAt });
49
+ };
50
+ // Mutations never batch and never overlap each other: edit/index_repo run on
51
+ // a serial tail so Promise.all([edit, edit]) cannot interleave writes, and a
52
+ // later wave's mutation queues behind an earlier one. Reads keep wave
53
+ // batching; ordering vs a concurrent write is intentionally unspecified
54
+ // (same semantics as today's shared batch — the store stays transactional).
55
+ let mutationTail = Promise.resolve();
33
56
  const flush = async () => {
34
57
  const wave = pending.filter((item) => !item.settled);
35
58
  pending = [];
@@ -39,18 +62,33 @@ export function createCodemodeDispatcher(host) {
39
62
  stats.waves += 1;
40
63
  stats.calls += wave.length;
41
64
  const waveStarted = Date.now();
65
+ const mutations = wave.filter((item) => MUTATING_TOOLS.has(item.tool) && !item.settled);
66
+ const reads = wave.filter((item) => !MUTATING_TOOLS.has(item.tool) && !item.settled);
67
+ let writesDone = mutationTail;
68
+ if (mutations.length > 0) {
69
+ writesDone = mutationTail.then(async () => {
70
+ for (const item of mutations) {
71
+ if (!item.settled)
72
+ await settleOne(host, item, stats);
73
+ }
74
+ });
75
+ mutationTail = writesDone;
76
+ }
42
77
  try {
43
- if (wave.length === 1) {
44
- await settleOne(host, wave[0], stats);
45
- return;
78
+ if (reads.length === 1) {
79
+ await settleOne(host, reads[0], stats);
46
80
  }
47
- // Chunk oversized waves (batch max = 32).
48
- for (let offset = 0; offset < wave.length; offset += MAX_WAVE) {
49
- const chunk = wave.slice(offset, offset + MAX_WAVE).filter((item) => !item.settled);
50
- if (chunk.length === 0)
51
- continue;
52
- await settleWave(host, chunk, stats);
81
+ else if (reads.length > 1) {
82
+ // Chunk oversized waves (batch max = 32).
83
+ for (let offset = 0; offset < reads.length; offset += MAX_WAVE) {
84
+ const chunk = reads.slice(offset, offset + MAX_WAVE).filter((item) => !item.settled);
85
+ if (chunk.length === 0)
86
+ continue;
87
+ await settleWave(host, chunk, stats);
88
+ }
53
89
  }
90
+ // Reads resolve independently; the wave is not done until queued writes land.
91
+ await writesDone;
54
92
  }
55
93
  finally {
56
94
  stats.wallMs += Date.now() - waveStarted;
@@ -64,6 +102,7 @@ export function createCodemodeDispatcher(host) {
64
102
  return;
65
103
  item.settled = true;
66
104
  cleanup();
105
+ recordCall(item, true);
67
106
  resolve(value);
68
107
  };
69
108
  item.reject = (reason) => {
@@ -71,6 +110,7 @@ export function createCodemodeDispatcher(host) {
71
110
  return;
72
111
  item.settled = true;
73
112
  cleanup();
113
+ recordCall(item, false);
74
114
  reject(reason);
75
115
  };
76
116
  const onAbort = () => item.reject(abortError());
@@ -94,6 +134,8 @@ export function createCodemodeDispatcher(host) {
94
134
  args,
95
135
  context,
96
136
  settled: false,
137
+ startedAt: Date.now(),
138
+ lane: MUTATING_TOOLS.has(tool) ? "serial" : "spawn",
97
139
  resolve: () => undefined,
98
140
  reject: () => undefined,
99
141
  };
@@ -105,14 +147,17 @@ export function createCodemodeDispatcher(host) {
105
147
  return {
106
148
  host: dispatchHost,
107
149
  stats: () => ({ ...stats }),
150
+ trace: () => calls.slice(),
108
151
  resetStats: () => {
109
152
  stats = emptyStats();
153
+ calls.length = 0;
110
154
  },
111
155
  };
112
156
  }
113
157
  async function settleOne(host, item, stats) {
114
158
  try {
115
159
  if (host.sticky) {
160
+ item.lane = "sticky";
116
161
  stats.stickyCalls += 1;
117
162
  item.resolve(await host.sticky.call(item.tool, item.args, item.options));
118
163
  return;
@@ -125,17 +170,18 @@ async function settleOne(host, item, stats) {
125
170
  item.reject(err);
126
171
  }
127
172
  }
173
+ /** Pending[] -> wire calls ({id, tool, args}) shared by both batch transports. */
174
+ function packCalls(wave) {
175
+ return wave.map((item, index) => ({ id: String(index), tool: item.tool, args: item.args }));
176
+ }
128
177
  async function settleWave(host, wave, stats) {
129
178
  if (host.sticky) {
130
179
  const transportOptions = sharedBatchOptions(wave);
131
180
  try {
132
- const calls = wave.map((item, index) => ({
133
- id: String(index),
134
- tool: item.tool,
135
- args: item.args,
136
- }));
137
- const batch = await host.sticky.batch(calls, transportOptions);
181
+ const batch = await host.sticky.batch(packCalls(wave), transportOptions);
138
182
  stats.stickyCalls += wave.length;
183
+ for (const item of wave)
184
+ item.lane = "sticky";
139
185
  settleFromBatch(wave, batch);
140
186
  return;
141
187
  }
@@ -159,13 +205,10 @@ async function settleWave(host, wave, stats) {
159
205
  if (host.runBatch) {
160
206
  const transportOptions = sharedBatchOptions(batchWave);
161
207
  try {
162
- const calls = batchWave.map((item, index) => ({
163
- id: String(index),
164
- tool: item.tool,
165
- args: item.args,
166
- }));
167
- const batch = await host.runBatch(calls, batchWave[0].context, transportOptions);
208
+ const batch = await host.runBatch(packCalls(batchWave), batchWave[0].context, transportOptions);
168
209
  stats.batchedCalls += batchWave.length;
210
+ for (const item of batchWave)
211
+ item.lane = "batch";
169
212
  settleFromBatch(batchWave, batch);
170
213
  return;
171
214
  }
@@ -221,6 +264,7 @@ function emptyStats() {
221
264
  }
222
265
  const ARGV_SPEC = {
223
266
  search: { form: "capsule", key: "query" },
267
+ find: { form: "find" },
224
268
  semantic: { form: "semantic" },
225
269
  chain: { form: "chain" },
226
270
  defs: { form: "capsule", key: "symbol", prefix: "defs" },
@@ -250,15 +294,31 @@ export function argvFor(tool, args) {
250
294
  return ["chain", argStr(args, "query"), ".", "--json", "--limit", String(limit)];
251
295
  }
252
296
  const excerpt = num(args.excerpt_lines ?? args.excerptLines, 0);
253
- const capsule = ["--json", "--format", "agent-capsule", "--limit", String(limit), "--excerpt-lines", String(excerpt)];
297
+ const capsule = withLang(["--json", "--format", "agent-capsule", "--limit", String(limit), "--excerpt-lines", String(excerpt)], args);
254
298
  if (spec.form === "semantic") {
255
299
  return ["semantic", argStr(args, "query"), ".", ...capsule];
256
300
  }
301
+ if (spec.form === "find") {
302
+ const raw = argStr(args, "query").trim();
303
+ let token = raw;
304
+ if (/^blast:/i.test(raw)) {
305
+ const target = raw.slice(raw.indexOf(":") + 1).trim();
306
+ token = /[\\/.]/.test(target) ? `imports:${target}` : `callers:${target}`;
307
+ }
308
+ else if (!/^(defs|callers|imports|literal|regex|word|pattern):/i.test(raw)) {
309
+ token = `word:${raw}`;
310
+ }
311
+ return [...capsule, token, "."];
312
+ }
257
313
  // capsule (+ optional prefix for defs/callers/imports)
258
314
  const raw = argStr(args, spec.key);
259
315
  const token = spec.prefix ? `${spec.prefix}:${raw}` : raw;
260
316
  return [...capsule, token, "."];
261
317
  }
318
+ function withLang(argv, args) {
319
+ const lang = typeof args.lang === "string" ? args.lang.trim() : "";
320
+ return lang ? ["--lang", lang, ...argv] : argv;
321
+ }
262
322
  function num(value, fallback) {
263
323
  if (typeof value === "number" && Number.isFinite(value))
264
324
  return Math.trunc(value);
@@ -0,0 +1,16 @@
1
+ /** Guest-call packing so the first shape a model tries actually works. */
2
+ import { type CodemodeHostMethod } from "./types.js";
3
+ export declare function resolveHostMethod(method: string): CodemodeHostMethod | undefined;
4
+ /** Turn positional guest calls into the host object shape. */
5
+ export declare function packGuestCall(method: string, args: unknown[]): Record<string, unknown>;
6
+ /** Accept query as a symbol alias and fold in:/fileFilter into the query string. */
7
+ export declare function coerceHostArgs(method: string, input: Record<string, unknown>): Record<string, unknown>;
8
+ export declare function applyQueryScope(query: string, args: Record<string, unknown>): string | undefined;
9
+ export declare function unknownMethodError(method: string): string;
10
+ export declare function timeoutHint(message: string): string;
11
+ /**
12
+ * Strip fences and invoke a function expression, including non-async arrows.
13
+ * A single expression with no `return` is returned automatically.
14
+ * Bare statements still wrap in an async IIFE.
15
+ */
16
+ export declare function normalizeCode(raw: string): string;