pi-ast-sgrep 2.0.2 → 2.2.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.
- package/README.md +16 -19
- package/dist/code-mode.d.ts +1 -1
- package/dist/code-mode.js +1 -1
- package/dist/codemode/connector.d.ts +18 -3
- package/dist/codemode/connector.js +85 -31
- package/dist/codemode/dispatch.d.ts +13 -1
- package/dist/codemode/dispatch.js +87 -24
- package/dist/codemode/guest-api.d.ts +16 -0
- package/dist/codemode/guest-api.js +194 -0
- package/dist/codemode/guest-worker.mjs +287 -0
- package/dist/codemode/index.d.ts +4 -3
- package/dist/codemode/index.js +4 -3
- package/dist/codemode/native.d.ts +1 -1
- package/dist/codemode/native.js +1 -1
- package/dist/codemode/runner.d.ts +13 -9
- package/dist/codemode/runner.js +411 -213
- package/dist/codemode/session-pool.d.ts +6 -1
- package/dist/codemode/session-pool.js +125 -32
- package/dist/codemode/types.d.ts +42 -2
- package/dist/codemode/types.js +40 -15
- package/dist/codemode/worker.d.ts +1 -1
- package/dist/codemode/worker.js +25 -2
- package/dist/host/commands.d.ts +6 -0
- package/dist/host/commands.js +49 -0
- package/dist/host/results.d.ts +123 -0
- package/dist/host/results.js +126 -0
- package/dist/host/tools.d.ts +28 -0
- package/dist/host/tools.js +802 -0
- package/dist/index.d.ts +7 -34
- package/dist/index.js +5 -543
- package/dist/runtime/config.d.ts +36 -0
- package/dist/runtime/config.js +98 -0
- package/dist/runtime/freshness.d.ts +43 -0
- package/dist/runtime/freshness.js +446 -0
- package/dist/runtime/index-health.d.ts +16 -0
- package/dist/runtime/index-health.js +111 -0
- package/dist/runtime/runtime.d.ts +48 -0
- package/dist/runtime/runtime.js +265 -0
- package/dist/runtime/sqlite.d.ts +15 -0
- package/dist/runtime/sqlite.js +63 -0
- package/dist/runtime/types.d.ts +55 -0
- package/dist/runtime/types.js +25 -0
- package/dist/ui/card.d.ts +66 -0
- package/dist/ui/card.js +375 -0
- package/dist/ui/present.d.ts +89 -0
- package/dist/ui/present.js +391 -0
- package/package.json +8 -7
- package/dist/codemode/sandbox-worker.d.ts +0 -1
- package/dist/codemode/sandbox-worker.js +0 -204
- package/dist/present.d.ts +0 -70
- package/dist/present.js +0 -260
- package/dist/runtime.d.ts +0 -137
- package/dist/runtime.js +0 -799
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ No Rust toolchain or separate MCP server is required. The npm package selects th
|
|
|
44
44
|
| `asgrep_search` | Run one natural, structural, symbol, graph, semantic, word, literal, or regex lookup. |
|
|
45
45
|
| `asgrep_index` | Create, refresh, or explicitly rebuild the current project index. |
|
|
46
46
|
| `asgrep_status` | Inspect the selected root, index, backend, counts, and capabilities. |
|
|
47
|
+
| `asgrep_read`, `asgrep_edit` | One-shot file window / exact-string edit. Registered on every host, but **kept out of the active tool set when the host already provides built-in `read` and `edit`** (pi does), because Pi sends only active tools to the model. Sessions started with `--no-builtin-tools`, MCP-style hosts, and any host without tool-set control keep them active; `ASGREP_KEEP_FILE_TOOLS=1` pins them active anywhere. |
|
|
47
48
|
|
|
48
49
|
The package also registers `/asgrep-doctor`, `/asgrep-status`, `/asgrep-index`, and `/asgrep-reindex`.
|
|
49
50
|
|
|
@@ -57,7 +58,7 @@ Pi can make one `asgrep` call like this:
|
|
|
57
58
|
|
|
58
59
|
```json
|
|
59
60
|
{
|
|
60
|
-
"code": "async () => {\n const seed = await asgrep.search(
|
|
61
|
+
"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
62
|
}
|
|
62
63
|
```
|
|
63
64
|
|
|
@@ -65,32 +66,28 @@ This workflow narrows the first result, runs independent follow-up searches toge
|
|
|
65
66
|
|
|
66
67
|
### Code Mode API
|
|
67
68
|
|
|
68
|
-
The Code Mode program receives these asynchronous methods on `asgrep
|
|
69
|
+
The Code Mode program receives these asynchronous methods on `asgrep`. Positional arguments work; object forms remain valid.
|
|
69
70
|
|
|
70
71
|
| Method | Use |
|
|
71
72
|
|---|---|
|
|
72
|
-
| `asgrep.search(
|
|
73
|
-
| `asgrep.
|
|
74
|
-
| `asgrep.defs(
|
|
75
|
-
| `asgrep.callers(
|
|
76
|
-
| `asgrep.
|
|
77
|
-
| `asgrep.
|
|
78
|
-
| `asgrep.indexStatus()` |
|
|
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. |
|
|
73
|
+
| `asgrep.search("query", { limit?, in?, lang? })` | Hybrid search: intent, symbol, or prefixed `defs:` / `callers:` / `pattern:` query. Bound a tree with `in`. |
|
|
74
|
+
| `asgrep.find("token")` | Lexical / identifier lookup (`word:`). Prefixed queries pass through. |
|
|
75
|
+
| `asgrep.defs("Symbol")` | Jump to definitions. |
|
|
76
|
+
| `asgrep.callers("Symbol")` | Reverse-walk callers. |
|
|
77
|
+
| `asgrep.read({ path, start, end }` or `{ refs }`) | Batched line windows from the index. Prefer one call with `refs`. |
|
|
78
|
+
| `asgrep.edit(path, oldText, newText)` | Unique string replace jailed to the project root, then targeted reindex. |
|
|
79
|
+
| `asgrep.indexStatus()` / `asgrep.doctor()` | Index health without a fifth outer Pi tool. |
|
|
82
80
|
|
|
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.
|
|
81
|
+
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
82
|
|
|
85
|
-
Code Mode runs in
|
|
83
|
+
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
84
|
|
|
87
85
|
The bridge rejects oversized call arguments and serialized results, allows at
|
|
88
86
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
sandbox.
|
|
87
|
+
reaches the extension host. Raw-memory and WebAssembly globals are unavailable.
|
|
88
|
+
Native tool values are capped at 1 MiB each and complete batch responses at 4 MiB
|
|
89
|
+
before Node-API converts them into extension-host objects. These bounds do not
|
|
90
|
+
turn `node:vm` into an OS sandbox.
|
|
94
91
|
|
|
95
92
|
## Direct one-shot search
|
|
96
93
|
|
package/dist/code-mode.d.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -89,4 +103,5 @@ export declare function createAsgrepConnector(host: BatchCapableHost, context: {
|
|
|
89
103
|
cwd: string;
|
|
90
104
|
}, options?: {
|
|
91
105
|
signal?: AbortSignal;
|
|
106
|
+
scope?: string;
|
|
92
107
|
}): ConnectorBundle;
|
|
@@ -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) {
|
|
@@ -18,6 +20,31 @@ function clampExcerpt(excerptLines) {
|
|
|
18
20
|
*/
|
|
19
21
|
export function createAsgrepConnector(host, context, options = {}) {
|
|
20
22
|
const dispatcher = createCodemodeDispatcher(host);
|
|
23
|
+
// One index per checkout: when the caller's cwd is a subdirectory of the
|
|
24
|
+
// checkout that owns the index, path arguments are rebased onto that checkout
|
|
25
|
+
// root and searches are scoped to the subdirectory. Callers never see a
|
|
26
|
+
// second `.asgrep` grow inside the tree.
|
|
27
|
+
const scope = options.scope && options.scope !== "."
|
|
28
|
+
? options.scope.replace(/^(?:\.\/)+/u, "").replace(/\/+$/u, "")
|
|
29
|
+
: undefined;
|
|
30
|
+
const rebasePath = (path) => {
|
|
31
|
+
if (!scope || path.startsWith("/") || path.startsWith("~"))
|
|
32
|
+
return path;
|
|
33
|
+
const clean = path.replace(/^(?:\.\/)+/u, "");
|
|
34
|
+
return clean === "" || clean === "." ? scope : `${scope}/${clean}`;
|
|
35
|
+
};
|
|
36
|
+
const rebaseRef = (ref) => {
|
|
37
|
+
const hash = ref.indexOf("#");
|
|
38
|
+
return hash === -1 ? rebasePath(ref) : rebasePath(ref.slice(0, hash)) + ref.slice(hash);
|
|
39
|
+
};
|
|
40
|
+
/** Directories below the checkout combine with the anchor scope, never replace it. */
|
|
41
|
+
const withScope = (input) => {
|
|
42
|
+
if (!scope)
|
|
43
|
+
return input;
|
|
44
|
+
const nested = input.in ?? input.fileFilter ?? input.file_filter;
|
|
45
|
+
const combined = typeof nested === "string" && nested.trim() ? `${scope}/${nested.replace(/^(?:\.\/)+/u, "")}` : scope;
|
|
46
|
+
return { ...input, in: combined };
|
|
47
|
+
};
|
|
21
48
|
const combinedSignals = new WeakMap();
|
|
22
49
|
const callOptions = (signal) => {
|
|
23
50
|
if (!options.signal)
|
|
@@ -32,48 +59,75 @@ export function createAsgrepConnector(host, context, options = {}) {
|
|
|
32
59
|
return { signal: combined };
|
|
33
60
|
};
|
|
34
61
|
const call = (tool, args, signal) => dispatcher.host.call(tool, args, context, callOptions(signal));
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
query:
|
|
62
|
+
const searchPayload = (method, input) => {
|
|
63
|
+
const scoped = coerceHostArgs(method, withScope({ ...input }));
|
|
64
|
+
const payload = {
|
|
65
|
+
query: scoped.query,
|
|
39
66
|
limit: clampLimit(input.limit),
|
|
40
67
|
excerpt_lines: clampExcerpt(input.excerptLines),
|
|
41
68
|
format: input.format === "agent" ? "agent" : "capsule",
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
};
|
|
70
|
+
if (typeof scoped.lang === "string" && scoped.lang.trim())
|
|
71
|
+
payload.lang = scoped.lang.trim();
|
|
72
|
+
return payload;
|
|
73
|
+
};
|
|
74
|
+
// Bound function properties (not methods) so vm call sites cannot lose `this`.
|
|
75
|
+
const asgrep = {
|
|
76
|
+
search: (input, callOptions) => call("search", searchPayload("search", input), callOptions?.signal),
|
|
77
|
+
find: (input, callOptions) => call("find", searchPayload("find", input), callOptions?.signal),
|
|
78
|
+
read: (input, callOptions) => call("read", defined({
|
|
79
|
+
path: typeof input.path === "string" ? rebasePath(input.path) : input.path,
|
|
80
|
+
start: input.start,
|
|
81
|
+
end: input.end,
|
|
82
|
+
ref: typeof input.ref === "string" ? rebaseRef(input.ref) : input.ref,
|
|
83
|
+
refs: Array.isArray(input.refs) ? input.refs.map((ref) => (typeof ref === "string" ? rebaseRef(ref) : ref)) : input.refs,
|
|
84
|
+
context_lines: input.contextLines,
|
|
85
|
+
max_chars: input.maxChars,
|
|
86
|
+
}), callOptions?.signal),
|
|
87
|
+
edit: (input, callOptions) => {
|
|
88
|
+
// Multi-edit wire contract: every entry carries its own path; the
|
|
89
|
+
// top-level path is the default for entries that omit it.
|
|
90
|
+
const edits = input.edits?.map((entry) => ({
|
|
91
|
+
...(typeof input.path === "string" ? { path: rebasePath(input.path) } : {}),
|
|
92
|
+
...entry,
|
|
93
|
+
...(typeof entry.path === "string" ? { path: rebasePath(entry.path) } : {}),
|
|
94
|
+
}));
|
|
95
|
+
return call("edit", defined({
|
|
96
|
+
path: typeof input.path === "string" ? rebasePath(input.path) : input.path,
|
|
97
|
+
oldText: input.oldText,
|
|
98
|
+
newText: input.newText,
|
|
99
|
+
edits,
|
|
100
|
+
}), callOptions?.signal);
|
|
101
|
+
},
|
|
102
|
+
semantic: (input, callOptions) => call("semantic", searchPayload("semantic", input), callOptions?.signal),
|
|
103
|
+
chain: (input, callOptions) => call("chain", { query: input.query, limit: clampLimit(input.limit), top_n: 20 }, callOptions?.signal),
|
|
104
|
+
defs: (input, callOptions) => {
|
|
105
|
+
const scoped = coerceHostArgs("defs", { ...input });
|
|
106
|
+
return call("defs", {
|
|
107
|
+
symbol: scoped.symbol,
|
|
108
|
+
limit: clampLimit(input.limit),
|
|
109
|
+
excerpt_lines: clampExcerpt(input.excerptLines),
|
|
110
|
+
}, callOptions?.signal);
|
|
111
|
+
},
|
|
112
|
+
callers: (input, callOptions) => {
|
|
113
|
+
const scoped = coerceHostArgs("callers", { ...input });
|
|
114
|
+
return call("callers", {
|
|
115
|
+
symbol: scoped.symbol,
|
|
116
|
+
limit: clampLimit(input.limit),
|
|
117
|
+
excerpt_lines: clampExcerpt(input.excerptLines),
|
|
118
|
+
}, callOptions?.signal);
|
|
119
|
+
},
|
|
120
|
+
imports: (input, callOptions) => call("imports", defined({ module: input.module, limit: clampLimit(input.limit), excerpt_lines: clampExcerpt(input.excerptLines) }), callOptions?.signal),
|
|
69
121
|
indexStatus: (callOptions) => call("index_status", {}, callOptions?.signal),
|
|
70
122
|
indexRepo: (input = {}, callOptions) => call("index_repo", { force: input.force === true }, callOptions?.signal),
|
|
71
123
|
catalogSearch: (input, callOptions) => call("catalog_search", { query: input.query }, callOptions?.signal),
|
|
72
124
|
catalogDescribe: (input, callOptions) => call("catalog_describe", { name: input.name }, callOptions?.signal),
|
|
125
|
+
doctor: (callOptions) => host.run(["doctor", ".", "--json"], context, callOptions),
|
|
73
126
|
};
|
|
74
127
|
return {
|
|
75
128
|
asgrep,
|
|
76
129
|
stats: dispatcher.stats,
|
|
130
|
+
trace: dispatcher.trace,
|
|
77
131
|
resetStats: dispatcher.resetStats,
|
|
78
132
|
};
|
|
79
133
|
}
|
|
@@ -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 (
|
|
44
|
-
await settleOne(host,
|
|
45
|
-
return;
|
|
78
|
+
if (reads.length === 1) {
|
|
79
|
+
await settleOne(host, reads[0], stats);
|
|
46
80
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
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" },
|
|
@@ -243,22 +287,41 @@ export function argvFor(tool, args) {
|
|
|
243
287
|
const paths = Array.isArray(args.paths)
|
|
244
288
|
? args.paths.filter((path) => typeof path === "string")
|
|
245
289
|
: [];
|
|
246
|
-
|
|
290
|
+
// Callers that index on the hot path (freshness) pass use_embed:false so a
|
|
291
|
+
// cold repo answers with lexical/AST rows instead of waiting on vectors.
|
|
292
|
+
const embed = args.use_embed === false ? ["--no-embed"] : [];
|
|
293
|
+
return [command, ".", "--json", ...embed, ...paths.flatMap((path) => ["--path", path])];
|
|
247
294
|
}
|
|
248
295
|
const limit = num(args.limit, 8);
|
|
249
296
|
if (spec.form === "chain") {
|
|
250
297
|
return ["chain", argStr(args, "query"), ".", "--json", "--limit", String(limit)];
|
|
251
298
|
}
|
|
252
299
|
const excerpt = num(args.excerpt_lines ?? args.excerptLines, 0);
|
|
253
|
-
const capsule = ["--json", "--format", "agent-capsule", "--limit", String(limit), "--excerpt-lines", String(excerpt)];
|
|
300
|
+
const capsule = withLang(["--json", "--format", "agent-capsule", "--limit", String(limit), "--excerpt-lines", String(excerpt)], args);
|
|
254
301
|
if (spec.form === "semantic") {
|
|
255
302
|
return ["semantic", argStr(args, "query"), ".", ...capsule];
|
|
256
303
|
}
|
|
304
|
+
if (spec.form === "find") {
|
|
305
|
+
const raw = argStr(args, "query").trim();
|
|
306
|
+
let token = raw;
|
|
307
|
+
if (/^blast:/i.test(raw)) {
|
|
308
|
+
const target = raw.slice(raw.indexOf(":") + 1).trim();
|
|
309
|
+
token = /[\\/.]/.test(target) ? `imports:${target}` : `callers:${target}`;
|
|
310
|
+
}
|
|
311
|
+
else if (!/^(defs|callers|imports|literal|regex|word|pattern):/i.test(raw)) {
|
|
312
|
+
token = `word:${raw}`;
|
|
313
|
+
}
|
|
314
|
+
return [...capsule, token, "."];
|
|
315
|
+
}
|
|
257
316
|
// capsule (+ optional prefix for defs/callers/imports)
|
|
258
317
|
const raw = argStr(args, spec.key);
|
|
259
318
|
const token = spec.prefix ? `${spec.prefix}:${raw}` : raw;
|
|
260
319
|
return [...capsule, token, "."];
|
|
261
320
|
}
|
|
321
|
+
function withLang(argv, args) {
|
|
322
|
+
const lang = typeof args.lang === "string" ? args.lang.trim() : "";
|
|
323
|
+
return lang ? ["--lang", lang, ...argv] : argv;
|
|
324
|
+
}
|
|
262
325
|
function num(value, fallback) {
|
|
263
326
|
if (typeof value === "number" && Number.isFinite(value))
|
|
264
327
|
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;
|