pi-ast-sgrep 2.1.1 → 2.2.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 +1 -0
- package/dist/codemode/connector.d.ts +2 -1
- package/dist/codemode/connector.js +70 -8
- package/dist/codemode/dispatch.js +4 -1
- package/dist/codemode/fallback.d.ts +43 -0
- package/dist/codemode/fallback.js +303 -0
- package/dist/codemode/native.d.ts +5 -1
- package/dist/codemode/native.js +5 -1
- package/dist/codemode/types.d.ts +9 -0
- package/dist/codemode/types.js +12 -13
- package/dist/host/results.d.ts +8 -0
- package/dist/host/results.js +47 -1
- package/dist/host/tools.d.ts +19 -0
- package/dist/host/tools.js +319 -127
- package/dist/runtime/freshness.js +40 -7
- package/dist/runtime/index-health.d.ts +1 -1
- package/dist/runtime/index-health.js +4 -2
- package/dist/runtime/runtime.js +18 -2
- package/dist/runtime/types.d.ts +1 -1
- package/dist/runtime/types.js +1 -1
- package/dist/ui/card.d.ts +4 -1
- package/dist/ui/card.js +125 -66
- package/dist/ui/present.d.ts +37 -36
- package/dist/ui/present.js +226 -154
- package/package.json +4 -3
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { MachineEnvelope } from "../runtime/runtime.js";
|
|
2
|
-
import type { ChainArgs, EditArgs, FindArgs, ReadArgs, SearchArgs } from "./types.js";
|
|
3
2
|
import { type BatchCapableHost, type DispatchCall, type DispatchStats } from "./dispatch.js";
|
|
3
|
+
import type { ChainArgs, EditArgs, FindArgs, ReadArgs, SearchArgs } from "./types.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).
|
|
@@ -103,4 +103,5 @@ export declare function createAsgrepConnector(host: BatchCapableHost, context: {
|
|
|
103
103
|
cwd: string;
|
|
104
104
|
}, options?: {
|
|
105
105
|
signal?: AbortSignal;
|
|
106
|
+
scope?: string;
|
|
106
107
|
}): ConnectorBundle;
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import { argvFor, asEnvelope, createCodemodeDispatcher, } from "./dispatch.js";
|
|
2
|
+
import { editFilesFallback, readWindowsFallback } from "./fallback.js";
|
|
1
3
|
import { coerceHostArgs } from "./guest-api.js";
|
|
2
4
|
import { defined } from "./types.js";
|
|
3
|
-
import { createCodemodeDispatcher, } from "./dispatch.js";
|
|
4
5
|
const DEFAULT_LIMIT = 8;
|
|
6
|
+
/** Native tools the extension serves itself when the launcher predates them. */
|
|
7
|
+
const NATIVE_FALLBACK_TOOLS = new Set(["read", "edit", "find"]);
|
|
8
|
+
/** Stable unknown-tool prefix, byte-identical in every native generation. */
|
|
9
|
+
function isUnknownToolError(cause) {
|
|
10
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
11
|
+
return message.includes("unknown tool:");
|
|
12
|
+
}
|
|
5
13
|
function clampLimit(limit) {
|
|
6
14
|
if (limit === undefined)
|
|
7
15
|
return DEFAULT_LIMIT;
|
|
@@ -20,6 +28,31 @@ function clampExcerpt(excerptLines) {
|
|
|
20
28
|
*/
|
|
21
29
|
export function createAsgrepConnector(host, context, options = {}) {
|
|
22
30
|
const dispatcher = createCodemodeDispatcher(host);
|
|
31
|
+
// One index per checkout: when the caller's cwd is a subdirectory of the
|
|
32
|
+
// checkout that owns the index, path arguments are rebased onto that checkout
|
|
33
|
+
// root and searches are scoped to the subdirectory. Callers never see a
|
|
34
|
+
// second `.asgrep` grow inside the tree.
|
|
35
|
+
const scope = options.scope && options.scope !== "."
|
|
36
|
+
? options.scope.replace(/^(?:\.\/)+/u, "").replace(/\/+$/u, "")
|
|
37
|
+
: undefined;
|
|
38
|
+
const rebasePath = (path) => {
|
|
39
|
+
if (!scope || path.startsWith("/") || path.startsWith("~"))
|
|
40
|
+
return path;
|
|
41
|
+
const clean = path.replace(/^(?:\.\/)+/u, "");
|
|
42
|
+
return clean === "" || clean === "." ? scope : `${scope}/${clean}`;
|
|
43
|
+
};
|
|
44
|
+
const rebaseRef = (ref) => {
|
|
45
|
+
const hash = ref.indexOf("#");
|
|
46
|
+
return hash === -1 ? rebasePath(ref) : rebasePath(ref.slice(0, hash)) + ref.slice(hash);
|
|
47
|
+
};
|
|
48
|
+
/** Directories below the checkout combine with the anchor scope, never replace it. */
|
|
49
|
+
const withScope = (input) => {
|
|
50
|
+
if (!scope)
|
|
51
|
+
return input;
|
|
52
|
+
const nested = input.in ?? input.fileFilter ?? input.file_filter;
|
|
53
|
+
const combined = typeof nested === "string" && nested.trim() ? `${scope}/${nested.replace(/^(?:\.\/)+/u, "")}` : scope;
|
|
54
|
+
return { ...input, in: combined };
|
|
55
|
+
};
|
|
23
56
|
const combinedSignals = new WeakMap();
|
|
24
57
|
const callOptions = (signal) => {
|
|
25
58
|
if (!options.signal)
|
|
@@ -33,9 +66,37 @@ export function createAsgrepConnector(host, context, options = {}) {
|
|
|
33
66
|
}
|
|
34
67
|
return { signal: combined };
|
|
35
68
|
};
|
|
36
|
-
|
|
69
|
+
// Unknown-tool fallback engages once per tool: after the first miss the
|
|
70
|
+
// native attempt is skipped for this bundle (a backend cannot gain tools
|
|
71
|
+
// mid-session). Any other error still propagates untouched, unmemoized.
|
|
72
|
+
const nativeMissing = new Set();
|
|
73
|
+
const fallbackCall = async (tool, args, signal) => {
|
|
74
|
+
if (tool === "find")
|
|
75
|
+
return host.run(argvFor("find", args), context, signal === undefined ? {} : { signal });
|
|
76
|
+
const value = tool === "read"
|
|
77
|
+
? await readWindowsFallback(context.cwd, args, signal)
|
|
78
|
+
: await editFilesFallback(context.cwd, args, signal);
|
|
79
|
+
return asEnvelope(value, tool);
|
|
80
|
+
};
|
|
81
|
+
const call = async (tool, args, signal) => {
|
|
82
|
+
const opts = callOptions(signal);
|
|
83
|
+
if (NATIVE_FALLBACK_TOOLS.has(tool) && nativeMissing.has(tool))
|
|
84
|
+
return fallbackCall(tool, args, opts.signal);
|
|
85
|
+
try {
|
|
86
|
+
return await dispatcher.host.call(tool, args, context, opts);
|
|
87
|
+
}
|
|
88
|
+
catch (cause) {
|
|
89
|
+
// No memo check here: concurrent same-tool misses must all fall back —
|
|
90
|
+
// the memo only skips future native attempts, never a fresh miss.
|
|
91
|
+
if (NATIVE_FALLBACK_TOOLS.has(tool) && isUnknownToolError(cause)) {
|
|
92
|
+
nativeMissing.add(tool);
|
|
93
|
+
return fallbackCall(tool, args, opts.signal);
|
|
94
|
+
}
|
|
95
|
+
throw cause;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
37
98
|
const searchPayload = (method, input) => {
|
|
38
|
-
const scoped = coerceHostArgs(method, { ...input });
|
|
99
|
+
const scoped = coerceHostArgs(method, withScope({ ...input }));
|
|
39
100
|
const payload = {
|
|
40
101
|
query: scoped.query,
|
|
41
102
|
limit: clampLimit(input.limit),
|
|
@@ -51,11 +112,11 @@ export function createAsgrepConnector(host, context, options = {}) {
|
|
|
51
112
|
search: (input, callOptions) => call("search", searchPayload("search", input), callOptions?.signal),
|
|
52
113
|
find: (input, callOptions) => call("find", searchPayload("find", input), callOptions?.signal),
|
|
53
114
|
read: (input, callOptions) => call("read", defined({
|
|
54
|
-
path: input.path,
|
|
115
|
+
path: typeof input.path === "string" ? rebasePath(input.path) : input.path,
|
|
55
116
|
start: input.start,
|
|
56
117
|
end: input.end,
|
|
57
|
-
ref: input.ref,
|
|
58
|
-
refs: input.refs,
|
|
118
|
+
ref: typeof input.ref === "string" ? rebaseRef(input.ref) : input.ref,
|
|
119
|
+
refs: Array.isArray(input.refs) ? input.refs.map((ref) => (typeof ref === "string" ? rebaseRef(ref) : ref)) : input.refs,
|
|
59
120
|
context_lines: input.contextLines,
|
|
60
121
|
max_chars: input.maxChars,
|
|
61
122
|
}), callOptions?.signal),
|
|
@@ -63,11 +124,12 @@ export function createAsgrepConnector(host, context, options = {}) {
|
|
|
63
124
|
// Multi-edit wire contract: every entry carries its own path; the
|
|
64
125
|
// top-level path is the default for entries that omit it.
|
|
65
126
|
const edits = input.edits?.map((entry) => ({
|
|
66
|
-
...(typeof input.path === "string" ? { path: input.path } : {}),
|
|
127
|
+
...(typeof input.path === "string" ? { path: rebasePath(input.path) } : {}),
|
|
67
128
|
...entry,
|
|
129
|
+
...(typeof entry.path === "string" ? { path: rebasePath(entry.path) } : {}),
|
|
68
130
|
}));
|
|
69
131
|
return call("edit", defined({
|
|
70
|
-
path: input.path,
|
|
132
|
+
path: typeof input.path === "string" ? rebasePath(input.path) : input.path,
|
|
71
133
|
oldText: input.oldText,
|
|
72
134
|
newText: input.newText,
|
|
73
135
|
edits,
|
|
@@ -287,7 +287,10 @@ export function argvFor(tool, args) {
|
|
|
287
287
|
const paths = Array.isArray(args.paths)
|
|
288
288
|
? args.paths.filter((path) => typeof path === "string")
|
|
289
289
|
: [];
|
|
290
|
-
|
|
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])];
|
|
291
294
|
}
|
|
292
295
|
const limit = num(args.limit, 8);
|
|
293
296
|
if (spec.form === "chain") {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension-side read/edit for launchers that predate the native tools.
|
|
3
|
+
*
|
|
4
|
+
* Severed-lane contract: when native answers `unknown tool: <name>` for
|
|
5
|
+
* read/edit/find, the connector serves the call here instead of failing, so
|
|
6
|
+
* an extension tracking main keeps working on an official launcher. Every
|
|
7
|
+
* shape, default, cap, jail rule, and error string mirrors
|
|
8
|
+
* crates/ast-sgrep-codemode/src/io.rs exactly — callers cannot distinguish
|
|
9
|
+
* this lane from a native one. Errors are plain Errors carrying the native
|
|
10
|
+
* text (the native lane surfaces them the same way); no new error codes.
|
|
11
|
+
*
|
|
12
|
+
* Deliberate divergences from native, both strictly safer:
|
|
13
|
+
* - Reads come from disk, never the index (the extension has no row reader
|
|
14
|
+
* here). Disk is fresher than a possibly stale index row.
|
|
15
|
+
* - Edits skip the native targeted reindex. A fallback edit is exactly like
|
|
16
|
+
* an external editor write, which the freshness layer already reconciles.
|
|
17
|
+
*/
|
|
18
|
+
/** Mirror read_windows (disk-backed; see module note). */
|
|
19
|
+
export declare function readWindowsFallback(configuredCwd: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<{
|
|
20
|
+
ok: true;
|
|
21
|
+
count: number;
|
|
22
|
+
windows: Array<{
|
|
23
|
+
path: string;
|
|
24
|
+
ref: string;
|
|
25
|
+
start: number;
|
|
26
|
+
end: number;
|
|
27
|
+
truncated: boolean;
|
|
28
|
+
text: string;
|
|
29
|
+
}>;
|
|
30
|
+
}>;
|
|
31
|
+
/** Mirror edit_files (validate-all-then-write; no targeted reindex — freshness covers). */
|
|
32
|
+
export declare function editFilesFallback(configuredCwd: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<{
|
|
33
|
+
ok: true;
|
|
34
|
+
changed: number;
|
|
35
|
+
edits: Array<{
|
|
36
|
+
path: string;
|
|
37
|
+
changed: boolean;
|
|
38
|
+
line: number;
|
|
39
|
+
removed: string[];
|
|
40
|
+
added: string[];
|
|
41
|
+
truncated?: true;
|
|
42
|
+
}>;
|
|
43
|
+
}>;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension-side read/edit for launchers that predate the native tools.
|
|
3
|
+
*
|
|
4
|
+
* Severed-lane contract: when native answers `unknown tool: <name>` for
|
|
5
|
+
* read/edit/find, the connector serves the call here instead of failing, so
|
|
6
|
+
* an extension tracking main keeps working on an official launcher. Every
|
|
7
|
+
* shape, default, cap, jail rule, and error string mirrors
|
|
8
|
+
* crates/ast-sgrep-codemode/src/io.rs exactly — callers cannot distinguish
|
|
9
|
+
* this lane from a native one. Errors are plain Errors carrying the native
|
|
10
|
+
* text (the native lane surfaces them the same way); no new error codes.
|
|
11
|
+
*
|
|
12
|
+
* Deliberate divergences from native, both strictly safer:
|
|
13
|
+
* - Reads come from disk, never the index (the extension has no row reader
|
|
14
|
+
* here). Disk is fresher than a possibly stale index row.
|
|
15
|
+
* - Edits skip the native targeted reindex. A fallback edit is exactly like
|
|
16
|
+
* an external editor write, which the freshness layer already reconciles.
|
|
17
|
+
*/
|
|
18
|
+
import { readFile, realpath, writeFile } from "node:fs/promises";
|
|
19
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
20
|
+
import { pathContained } from "../runtime/index-health.js";
|
|
21
|
+
// Native caps (crates/ast-sgrep-codemode/src/io.rs + core limits).
|
|
22
|
+
const MAX_READ_REFS = 32;
|
|
23
|
+
const MAX_READ_CHARS = 100_000;
|
|
24
|
+
const MAX_EDITS = 16;
|
|
25
|
+
const MAX_LINE_CHARS = 2_000;
|
|
26
|
+
const MAX_INDEX_FILE_BYTES = 64 * 1024 * 1024;
|
|
27
|
+
const MAX_EXCERPT_LINES = 100;
|
|
28
|
+
const U32_MAX = 4_294_967_295;
|
|
29
|
+
/** Mirror serde as_u64 (integers only, then wrapping `as u32`). */
|
|
30
|
+
function u32(value) {
|
|
31
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER)
|
|
32
|
+
return undefined;
|
|
33
|
+
return value % (U32_MAX + 1);
|
|
34
|
+
}
|
|
35
|
+
/** Mirror serde as_u64 without narrowing (context/max budgets). */
|
|
36
|
+
function u64(value) {
|
|
37
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER)
|
|
38
|
+
return undefined;
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
/** First present key wins even when its value is unusable (or_else chain). */
|
|
42
|
+
function present(args, ...keys) {
|
|
43
|
+
for (const key of keys)
|
|
44
|
+
if (key in args)
|
|
45
|
+
return args[key];
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
/** Mirror str::lines: \n or \r\n separators, no phantom trailing line. */
|
|
49
|
+
function rustLines(text) {
|
|
50
|
+
if (text === "")
|
|
51
|
+
return [];
|
|
52
|
+
const lines = text.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
|
|
53
|
+
if (text.endsWith("\n"))
|
|
54
|
+
lines.pop();
|
|
55
|
+
return lines;
|
|
56
|
+
}
|
|
57
|
+
function charCount(text) {
|
|
58
|
+
return [...text].length;
|
|
59
|
+
}
|
|
60
|
+
function clampLine(line) {
|
|
61
|
+
if (charCount(line) <= MAX_LINE_CHARS)
|
|
62
|
+
return { line, clamped: false };
|
|
63
|
+
return { line: [...line].slice(0, MAX_LINE_CHARS).join(""), clamped: true };
|
|
64
|
+
}
|
|
65
|
+
/** Mirror session root_arg: configured root, optional contained override. */
|
|
66
|
+
async function jailRoot(configuredCwd, args) {
|
|
67
|
+
const configured = await realpath(configuredCwd).catch(() => {
|
|
68
|
+
throw new Error(`cannot resolve session root: ${configuredCwd}`);
|
|
69
|
+
});
|
|
70
|
+
const raw = args.root;
|
|
71
|
+
if (typeof raw !== "string")
|
|
72
|
+
return configured;
|
|
73
|
+
const candidate = isAbsolute(raw) ? raw : join(configured, raw);
|
|
74
|
+
const resolved = await realpath(candidate).catch(() => {
|
|
75
|
+
throw new Error(`cannot resolve requested root: ${candidate}`);
|
|
76
|
+
});
|
|
77
|
+
if (!pathContained(configured, resolved)) {
|
|
78
|
+
throw new Error(`requested root is outside the configured session root: ${resolved}`);
|
|
79
|
+
}
|
|
80
|
+
return resolved;
|
|
81
|
+
}
|
|
82
|
+
/** Mirror jail_rel_path: explicit '..' rejection, canonicalize, containment. */
|
|
83
|
+
async function jailPath(root, raw) {
|
|
84
|
+
if (raw.split(/[\\/]/u).includes(".."))
|
|
85
|
+
throw new Error("path must not contain '..'");
|
|
86
|
+
const candidate = isAbsolute(raw) ? raw : join(root, raw);
|
|
87
|
+
const abs = await realpath(candidate).catch(() => {
|
|
88
|
+
throw new Error(`cannot resolve path ${raw}`);
|
|
89
|
+
});
|
|
90
|
+
if (!pathContained(root, abs))
|
|
91
|
+
throw new Error(`path escapes session root: ${raw}`);
|
|
92
|
+
return { abs, display: relative(root, abs).replace(/\\/g, "/") };
|
|
93
|
+
}
|
|
94
|
+
function parseU32Strict(text) {
|
|
95
|
+
const digits = /^(?:\+)?(\d+)$/.exec(text)?.[1];
|
|
96
|
+
if (digits === undefined)
|
|
97
|
+
return undefined;
|
|
98
|
+
const value = Number(digits);
|
|
99
|
+
return Number.isSafeInteger(value) && value <= U32_MAX ? value : undefined;
|
|
100
|
+
}
|
|
101
|
+
function parseRefString(raw) {
|
|
102
|
+
const hash = raw.lastIndexOf("#L");
|
|
103
|
+
if (hash === -1)
|
|
104
|
+
return { path: raw, start: 1, end: 40 };
|
|
105
|
+
const rest = raw.slice(hash + 2).trim();
|
|
106
|
+
const dash = rest.indexOf("-L");
|
|
107
|
+
const startText = dash === -1 ? rest : rest.slice(0, dash);
|
|
108
|
+
const endText = dash === -1 ? rest : rest.slice(dash + 2);
|
|
109
|
+
const start = parseU32Strict(startText);
|
|
110
|
+
if (start === undefined)
|
|
111
|
+
throw new Error(`invalid ref start in ${raw}`);
|
|
112
|
+
const end = parseU32Strict(endText);
|
|
113
|
+
if (end === undefined)
|
|
114
|
+
throw new Error(`invalid ref end in ${raw}`);
|
|
115
|
+
if (start === 0 || end < start)
|
|
116
|
+
throw new Error(`invalid ref range in ${raw}`);
|
|
117
|
+
return { path: raw.slice(0, hash), start, end };
|
|
118
|
+
}
|
|
119
|
+
function parseRefValue(value) {
|
|
120
|
+
if (typeof value === "string")
|
|
121
|
+
return parseRefString(value);
|
|
122
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
123
|
+
throw new Error("ref must be a string or object");
|
|
124
|
+
const obj = value;
|
|
125
|
+
if (typeof obj.ref === "string")
|
|
126
|
+
return parseRefString(obj.ref);
|
|
127
|
+
const path = present(obj, "path", "file");
|
|
128
|
+
if (typeof path !== "string")
|
|
129
|
+
throw new Error("ref.path is required");
|
|
130
|
+
const start = u32(present(obj, "start", "line_start")) ?? 1;
|
|
131
|
+
const end = u32(present(obj, "end", "line_end")) ?? start;
|
|
132
|
+
return { path, start: Math.max(1, start), end: Math.max(start, end) };
|
|
133
|
+
}
|
|
134
|
+
function collectRefs(args) {
|
|
135
|
+
if (Array.isArray(args.refs))
|
|
136
|
+
return args.refs.map(parseRefValue);
|
|
137
|
+
if (args.ref !== undefined)
|
|
138
|
+
return [parseRefValue(args.ref)];
|
|
139
|
+
const path = present(args, "path", "file");
|
|
140
|
+
if (typeof path !== "string")
|
|
141
|
+
throw new Error("path is required");
|
|
142
|
+
const start = u32(present(args, "start", "line_start")) ?? 1;
|
|
143
|
+
const end = u32(present(args, "end", "line_end")) ?? start;
|
|
144
|
+
return [{ path, start: Math.max(1, start), end: Math.max(start, end) }];
|
|
145
|
+
}
|
|
146
|
+
function sliceWindow(numbered, start, end, maxChars) {
|
|
147
|
+
let out = "";
|
|
148
|
+
let actualStart = start;
|
|
149
|
+
let actualEnd = start;
|
|
150
|
+
let first = true;
|
|
151
|
+
let truncated = false;
|
|
152
|
+
let chars = 0;
|
|
153
|
+
for (const [no, content] of numbered) {
|
|
154
|
+
if (no < start)
|
|
155
|
+
continue;
|
|
156
|
+
if (no > end)
|
|
157
|
+
break;
|
|
158
|
+
const { line, clamped } = clampLine(content);
|
|
159
|
+
if (clamped)
|
|
160
|
+
truncated = true;
|
|
161
|
+
const add = (first ? 0 : 1) + charCount(line);
|
|
162
|
+
if (chars + add > maxChars) {
|
|
163
|
+
truncated = true;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
if (first) {
|
|
167
|
+
actualStart = no;
|
|
168
|
+
first = false;
|
|
169
|
+
}
|
|
170
|
+
if (out !== "")
|
|
171
|
+
out += "\n";
|
|
172
|
+
out += line;
|
|
173
|
+
actualEnd = no;
|
|
174
|
+
chars += add;
|
|
175
|
+
}
|
|
176
|
+
if (first)
|
|
177
|
+
return { text: "", actualStart: start, actualEnd: start, truncated: false };
|
|
178
|
+
return { text: out, actualStart, actualEnd, truncated };
|
|
179
|
+
}
|
|
180
|
+
async function readFileCapped(abs, display, signal) {
|
|
181
|
+
const text = await readFile(abs, "utf8").catch(() => {
|
|
182
|
+
throw new Error(`cannot read ${display}`);
|
|
183
|
+
});
|
|
184
|
+
if (Buffer.byteLength(text) > MAX_INDEX_FILE_BYTES)
|
|
185
|
+
throw new Error(`${display} exceeds max ${MAX_INDEX_FILE_BYTES} bytes`);
|
|
186
|
+
signal?.throwIfAborted();
|
|
187
|
+
return text;
|
|
188
|
+
}
|
|
189
|
+
/** Mirror read_windows (disk-backed; see module note). */
|
|
190
|
+
export async function readWindowsFallback(configuredCwd, args, signal) {
|
|
191
|
+
signal?.throwIfAborted();
|
|
192
|
+
const root = await jailRoot(configuredCwd, args);
|
|
193
|
+
const contextLines = Math.min(u64(present(args, "context_lines", "contextLines")) ?? 0, MAX_EXCERPT_LINES);
|
|
194
|
+
const maxChars = Math.min(Math.max(u64(present(args, "max_chars", "maxChars")) ?? MAX_READ_CHARS, 1), MAX_READ_CHARS);
|
|
195
|
+
const refs = collectRefs(args);
|
|
196
|
+
if (refs.length === 0)
|
|
197
|
+
throw new Error("read requires path, ref, or refs");
|
|
198
|
+
if (refs.length > MAX_READ_REFS)
|
|
199
|
+
throw new Error(`read exceeds max ${MAX_READ_REFS} windows`);
|
|
200
|
+
const windows = [];
|
|
201
|
+
for (const spec of refs) {
|
|
202
|
+
signal?.throwIfAborted();
|
|
203
|
+
const { abs, display } = await jailPath(root, spec.path);
|
|
204
|
+
const text = await readFileCapped(abs, display, signal);
|
|
205
|
+
const numbered = rustLines(text).map((line, index) => [index + 1, line]);
|
|
206
|
+
const start = Math.max(1, spec.start - contextLines);
|
|
207
|
+
const end = Math.min(spec.end + contextLines, U32_MAX);
|
|
208
|
+
const slice = sliceWindow(numbered, start, end, maxChars);
|
|
209
|
+
windows.push({
|
|
210
|
+
path: display,
|
|
211
|
+
ref: `${display}#L${slice.actualStart}-L${slice.actualEnd}`,
|
|
212
|
+
start: slice.actualStart,
|
|
213
|
+
end: slice.actualEnd,
|
|
214
|
+
truncated: slice.truncated,
|
|
215
|
+
text: slice.text,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return { ok: true, count: windows.length, windows };
|
|
219
|
+
}
|
|
220
|
+
function parseEditValue(value) {
|
|
221
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
222
|
+
throw new Error("edit must be an object");
|
|
223
|
+
const obj = value;
|
|
224
|
+
const path = present(obj, "path", "file");
|
|
225
|
+
if (typeof path !== "string")
|
|
226
|
+
throw new Error("path is required");
|
|
227
|
+
const oldText = present(obj, "oldText", "old_string", "old");
|
|
228
|
+
if (typeof oldText !== "string")
|
|
229
|
+
throw new Error("oldText is required");
|
|
230
|
+
const newText = present(obj, "newText", "new_string", "new");
|
|
231
|
+
if (typeof newText !== "string")
|
|
232
|
+
throw new Error("newText is required");
|
|
233
|
+
if (oldText === "")
|
|
234
|
+
throw new Error("oldText must not be empty");
|
|
235
|
+
return { path, oldText, newText };
|
|
236
|
+
}
|
|
237
|
+
function collectEdits(args) {
|
|
238
|
+
if (Array.isArray(args.edits))
|
|
239
|
+
return args.edits.map(parseEditValue);
|
|
240
|
+
if (typeof args.path === "string")
|
|
241
|
+
return [parseEditValue(args)];
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
function diffLines(text) {
|
|
245
|
+
const all = rustLines(text);
|
|
246
|
+
return { lines: all.slice(0, 24), more: all.length > 24 };
|
|
247
|
+
}
|
|
248
|
+
function matchLine(buffer, offset) {
|
|
249
|
+
return buffer.slice(0, offset).split("\n").length;
|
|
250
|
+
}
|
|
251
|
+
/** Mirror edit_files (validate-all-then-write; no targeted reindex — freshness covers). */
|
|
252
|
+
export async function editFilesFallback(configuredCwd, args, signal) {
|
|
253
|
+
signal?.throwIfAborted();
|
|
254
|
+
const root = await jailRoot(configuredCwd, args);
|
|
255
|
+
const edits = collectEdits(args);
|
|
256
|
+
if (edits.length === 0)
|
|
257
|
+
throw new Error("edit requires path+oldText+newText or edits[]");
|
|
258
|
+
if (edits.length > MAX_EDITS)
|
|
259
|
+
throw new Error(`edit exceeds max ${MAX_EDITS} replacements`);
|
|
260
|
+
// Phase 1: resolve, read, and compute every rewrite before any write.
|
|
261
|
+
const plan = new Map();
|
|
262
|
+
const applied = [];
|
|
263
|
+
for (const edit of edits) {
|
|
264
|
+
signal?.throwIfAborted();
|
|
265
|
+
const { abs, display } = await jailPath(root, edit.path);
|
|
266
|
+
let entry = plan.get(display);
|
|
267
|
+
if (!entry) {
|
|
268
|
+
const original = await readFileCapped(abs, display, signal);
|
|
269
|
+
entry = { abs, display, original, rewritten: original };
|
|
270
|
+
plan.set(display, entry);
|
|
271
|
+
}
|
|
272
|
+
const first = entry.rewritten.indexOf(edit.oldText);
|
|
273
|
+
if (first === -1)
|
|
274
|
+
throw new Error("oldText must match exactly once (found 0)");
|
|
275
|
+
if (entry.rewritten.indexOf(edit.oldText, first + edit.oldText.length) !== -1) {
|
|
276
|
+
throw new Error("oldText must match exactly once (found 2+)");
|
|
277
|
+
}
|
|
278
|
+
const line = matchLine(entry.rewritten, first);
|
|
279
|
+
const next = entry.rewritten.slice(0, first) + edit.newText + entry.rewritten.slice(first + edit.oldText.length);
|
|
280
|
+
const changed = next !== entry.rewritten;
|
|
281
|
+
entry.rewritten = next;
|
|
282
|
+
const removed = diffLines(edit.oldText);
|
|
283
|
+
const added = diffLines(edit.newText);
|
|
284
|
+
applied.push({
|
|
285
|
+
path: display,
|
|
286
|
+
changed,
|
|
287
|
+
line,
|
|
288
|
+
removed: removed.lines,
|
|
289
|
+
added: added.lines,
|
|
290
|
+
...(removed.more || added.more ? { truncated: true } : {}),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
// Phase 2: every edit validated — write each touched file once.
|
|
294
|
+
for (const entry of plan.values()) {
|
|
295
|
+
if (entry.rewritten === entry.original)
|
|
296
|
+
continue;
|
|
297
|
+
signal?.throwIfAborted();
|
|
298
|
+
await writeFile(entry.abs, entry.rewritten).catch(() => {
|
|
299
|
+
throw new Error(`cannot write ${entry.display}`);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return { ok: true, changed: applied.filter((row) => row.changed).length, edits: applied };
|
|
303
|
+
}
|
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
* 1. `ASGREP_CODEMODE_NAPI_PATH` (dev override)
|
|
9
9
|
* 2. `@ast-sgrep/<platform>/ast-sgrep-codemode.node` via launcher (release install)
|
|
10
10
|
* 3. Local `extension/native/` / cargo `target/release` (dev builds)
|
|
11
|
+
*
|
|
12
|
+
* Severed-lane policy: the in-process binding requires an exact native
|
|
13
|
+
* generation (an ABI mismatch risks crashes, not errors). Older launchers
|
|
14
|
+
* serve Code Mode over CLI sticky/batch/spawn instead — same tools, slower.
|
|
11
15
|
*/
|
|
12
|
-
export declare const CODEMODE_BINDING_VERSION = "2.
|
|
16
|
+
export declare const CODEMODE_BINDING_VERSION = "2.5.2";
|
|
13
17
|
export type NativeSessionConfig = {
|
|
14
18
|
root?: string;
|
|
15
19
|
indexPath?: string;
|
package/dist/codemode/native.js
CHANGED
|
@@ -8,12 +8,16 @@
|
|
|
8
8
|
* 1. `ASGREP_CODEMODE_NAPI_PATH` (dev override)
|
|
9
9
|
* 2. `@ast-sgrep/<platform>/ast-sgrep-codemode.node` via launcher (release install)
|
|
10
10
|
* 3. Local `extension/native/` / cargo `target/release` (dev builds)
|
|
11
|
+
*
|
|
12
|
+
* Severed-lane policy: the in-process binding requires an exact native
|
|
13
|
+
* generation (an ABI mismatch risks crashes, not errors). Older launchers
|
|
14
|
+
* serve Code Mode over CLI sticky/batch/spawn instead — same tools, slower.
|
|
11
15
|
*/
|
|
12
16
|
import { createRequire } from "node:module";
|
|
13
17
|
import { existsSync } from "node:fs";
|
|
14
18
|
import { dirname, join } from "node:path";
|
|
15
19
|
import { fileURLToPath } from "node:url";
|
|
16
|
-
export const CODEMODE_BINDING_VERSION = "2.
|
|
20
|
+
export const CODEMODE_BINDING_VERSION = "2.5.2";
|
|
17
21
|
let cached;
|
|
18
22
|
function platformTriple() {
|
|
19
23
|
const { platform, arch } = process;
|
package/dist/codemode/types.d.ts
CHANGED
|
@@ -46,4 +46,13 @@ export type CodemodeHostMethod = (typeof CODEMODE_HOST_METHODS)[number];
|
|
|
46
46
|
* Return shapes are muscle memory (Blacksmith): field names, never values.
|
|
47
47
|
* defs:/callers:/imports:/pattern:/blast: go through find or search prefixes.
|
|
48
48
|
*/
|
|
49
|
+
/**
|
|
50
|
+
* Always-on API cheat sheet for the Code Mode tool description.
|
|
51
|
+
*
|
|
52
|
+
* Deliberately minimal: every token here rides in the system prompt of every
|
|
53
|
+
* request. The full per-method schema is one call away through
|
|
54
|
+
* `asgrep.catalogSearch(query)` / `asgrep.catalogDescribe(name)`, which returns
|
|
55
|
+
* the same shapes from the native catalog, so the model pays for the reference
|
|
56
|
+
* only when it needs it.
|
|
57
|
+
*/
|
|
49
58
|
export declare const CODEMODE_TYPES_FOR_MODEL: string;
|
package/dist/codemode/types.js
CHANGED
|
@@ -30,18 +30,17 @@ export const CODEMODE_HOST_METHODS = [
|
|
|
30
30
|
* Return shapes are muscle memory (Blacksmith): field names, never values.
|
|
31
31
|
* defs:/callers:/imports:/pattern:/blast: go through find or search prefixes.
|
|
32
32
|
*/
|
|
33
|
+
/**
|
|
34
|
+
* Always-on API cheat sheet for the Code Mode tool description.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately minimal: every token here rides in the system prompt of every
|
|
37
|
+
* request. The full per-method schema is one call away through
|
|
38
|
+
* `asgrep.catalogSearch(query)` / `asgrep.catalogDescribe(name)`, which returns
|
|
39
|
+
* the same shapes from the native catalog, so the model pays for the reference
|
|
40
|
+
* only when it needs it.
|
|
41
|
+
*/
|
|
33
42
|
export const CODEMODE_TYPES_FOR_MODEL = `
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
declare const asgrep: {
|
|
38
|
-
search(query: string | { query: string; limit?: number; in?: string; lang?: string; excerptLines?: number }): Promise<Hits>;
|
|
39
|
-
find(query: string | { query: string; limit?: number }): Promise<Hits>;
|
|
40
|
-
defs(symbol: string | { symbol: string; limit?: number }): Promise<Hits>;
|
|
41
|
-
callers(symbol: string | { symbol: string; limit?: number }): Promise<Hits>;
|
|
42
|
-
read(input: { path?: string; start?: number; end?: number; ref?: string; refs?: unknown[]; contextLines?: number }): Promise<{ ok: boolean; count: number; windows: Window[] }>;
|
|
43
|
-
edit(path: string | { path?: string; oldText?: string; newText?: string; edits?: Array<{ path: string; oldText: string; newText: string }> }, oldText?: string, newText?: string): Promise<{ ok: boolean; changed: number }>;
|
|
44
|
-
indexStatus(): Promise<{ ok: boolean }>;
|
|
45
|
-
};
|
|
46
|
-
/** Positional args work: search("auth"), defs("Foo"). Promise.all independent calls. 0 hits include suggested_next. Return a small value. */
|
|
43
|
+
asgrep.search(query|{query,in,lang,limit,excerptLines}) | find(q) | semantic(q) | defs(sym) | callers(sym)
|
|
44
|
+
| imports(mod) | chain(q) | read({path|ref|refs,start,end}) | edit({path,oldText,newText}|{edits}) | indexStatus()
|
|
45
|
+
hits[{file,ref,symbol,kind,preview}] | windows[{path,start,end,text}] | catalogDescribe("name") for schemas
|
|
47
46
|
`.trim();
|
package/dist/host/results.d.ts
CHANGED
|
@@ -67,6 +67,10 @@ export declare function success(command: string, response: MachineEnvelope, extr
|
|
|
67
67
|
mode?: string;
|
|
68
68
|
activationMs?: number;
|
|
69
69
|
backend?: string;
|
|
70
|
+
freshness?: "stale";
|
|
71
|
+
indexState?: "empty" | "ready";
|
|
72
|
+
excerptLines?: number;
|
|
73
|
+
notes?: string[];
|
|
70
74
|
}): {
|
|
71
75
|
content: {
|
|
72
76
|
type: "text";
|
|
@@ -77,6 +81,10 @@ export declare function success(command: string, response: MachineEnvelope, extr
|
|
|
77
81
|
mode?: string;
|
|
78
82
|
activationMs?: number;
|
|
79
83
|
backend?: string;
|
|
84
|
+
freshness?: "stale";
|
|
85
|
+
indexState?: "empty" | "ready";
|
|
86
|
+
excerptLines?: number;
|
|
87
|
+
notes?: string[];
|
|
80
88
|
ok: boolean;
|
|
81
89
|
command: string;
|
|
82
90
|
response: {
|