pi-ast-sgrep 2.1.1 → 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 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
 
@@ -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;
@@ -20,6 +20,31 @@ function clampExcerpt(excerptLines) {
20
20
  */
21
21
  export function createAsgrepConnector(host, context, options = {}) {
22
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
+ };
23
48
  const combinedSignals = new WeakMap();
24
49
  const callOptions = (signal) => {
25
50
  if (!options.signal)
@@ -35,7 +60,7 @@ export function createAsgrepConnector(host, context, options = {}) {
35
60
  };
36
61
  const call = (tool, args, signal) => dispatcher.host.call(tool, args, context, callOptions(signal));
37
62
  const searchPayload = (method, input) => {
38
- const scoped = coerceHostArgs(method, { ...input });
63
+ const scoped = coerceHostArgs(method, withScope({ ...input }));
39
64
  const payload = {
40
65
  query: scoped.query,
41
66
  limit: clampLimit(input.limit),
@@ -51,11 +76,11 @@ export function createAsgrepConnector(host, context, options = {}) {
51
76
  search: (input, callOptions) => call("search", searchPayload("search", input), callOptions?.signal),
52
77
  find: (input, callOptions) => call("find", searchPayload("find", input), callOptions?.signal),
53
78
  read: (input, callOptions) => call("read", defined({
54
- path: input.path,
79
+ path: typeof input.path === "string" ? rebasePath(input.path) : input.path,
55
80
  start: input.start,
56
81
  end: input.end,
57
- ref: input.ref,
58
- refs: input.refs,
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,
59
84
  context_lines: input.contextLines,
60
85
  max_chars: input.maxChars,
61
86
  }), callOptions?.signal),
@@ -63,11 +88,12 @@ export function createAsgrepConnector(host, context, options = {}) {
63
88
  // Multi-edit wire contract: every entry carries its own path; the
64
89
  // top-level path is the default for entries that omit it.
65
90
  const edits = input.edits?.map((entry) => ({
66
- ...(typeof input.path === "string" ? { path: input.path } : {}),
91
+ ...(typeof input.path === "string" ? { path: rebasePath(input.path) } : {}),
67
92
  ...entry,
93
+ ...(typeof entry.path === "string" ? { path: rebasePath(entry.path) } : {}),
68
94
  }));
69
95
  return call("edit", defined({
70
- path: input.path,
96
+ path: typeof input.path === "string" ? rebasePath(input.path) : input.path,
71
97
  oldText: input.oldText,
72
98
  newText: input.newText,
73
99
  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
- return [command, ".", "--json", ...paths.flatMap((path) => ["--path", path])];
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") {
@@ -9,7 +9,7 @@
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
11
  */
12
- export declare const CODEMODE_BINDING_VERSION = "2.0.0";
12
+ export declare const CODEMODE_BINDING_VERSION = "2.1.0";
13
13
  export type NativeSessionConfig = {
14
14
  root?: string;
15
15
  indexPath?: string;
@@ -13,7 +13,7 @@ import { createRequire } from "node:module";
13
13
  import { existsSync } from "node:fs";
14
14
  import { dirname, join } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
- export const CODEMODE_BINDING_VERSION = "2.0.0";
16
+ export const CODEMODE_BINDING_VERSION = "2.1.0";
17
17
  let cached;
18
18
  function platformTriple() {
19
19
  const { platform, arch } = process;
@@ -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;
@@ -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
- type Hit = { file: string; symbol?: string; kind?: string; score?: number; line?: number; ref?: string };
35
- type Hits = { ok: boolean; hits: Hit[]; suggested_next?: string[] };
36
- type Window = { path: string; ref: string; start: number; end: number; truncated: boolean; text: string };
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();
@@ -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: {
@@ -19,14 +19,46 @@ export function success(command, response, extra = {}) {
19
19
  : command === "read"
20
20
  ? formatReadResult(response)
21
21
  : formatSearchResult(response, { command, ...extra });
22
+ // Notes qualify the answer the agent is about to trust (stale index, empty
23
+ // index): they stay in the model-visible text, not only in the details bag.
24
+ const body = (extra.notes ?? []).length > 0
25
+ ? `${text}\n${(extra.notes ?? []).map((note) => ` ! ${note}`).join("\n")}`
26
+ : text;
22
27
  return {
23
- content: [{ type: "text", text: bounded(text) }],
28
+ content: [{ type: "text", text: bounded(body) }],
24
29
  // The tool execute owns its machine command: normalize the envelope's
25
30
  // command (native catalog names like index_status/index_repo must surface
26
31
  // as the machine commands status/index/reindex).
27
32
  details: { ok: true, command, response: { ...response, command }, ...extra },
28
33
  };
29
34
  }
35
+ /**
36
+ * Failure families the native session raises as *text*: the NAPI boundary
37
+ * carries `err.to_string()`, so the code has to be reconstructed from the
38
+ * message instead of being read off a struct. Everything here maps to
39
+ * OPERATIONAL_ERROR — a real answer about the index, not a mystery failure.
40
+ *
41
+ * Keep this list bounded and message-precise: an unrecognised failure stays
42
+ * UNEXPECTED_ERROR, which is honest, while a false positive would hide one.
43
+ */
44
+ const OPERATIONAL_FAILURES = [
45
+ {
46
+ pattern: /database is locked|database table is locked/i,
47
+ hint: "another process holds the index write lock (a concurrent asgrep index build); retry in a few seconds, or scope the search with in:/fileFilter",
48
+ },
49
+ {
50
+ pattern: /index changed while preparing|retry the rebuild/i,
51
+ hint: "the index changed while this query was preparing; retry once the running index build finishes",
52
+ },
53
+ {
54
+ pattern: /index is empty|index does not exist|failed to open index|failed to resolve index path|index schema version|unsupported schema|newer than supported/i,
55
+ },
56
+ {
57
+ pattern: /database disk image is malformed|file is not a database|not a database/i,
58
+ hint: "the index file is damaged; run /asgrep-reindex to rebuild it",
59
+ },
60
+ { pattern: /unable to open database file|no such table/i },
61
+ ];
30
62
  export function errorDetails(cause, signal) {
31
63
  if (signal?.aborted) {
32
64
  return { code: "CANCELLED", message: "cancelled", details: {} };
@@ -45,6 +77,20 @@ export function errorDetails(cause, signal) {
45
77
  details: {},
46
78
  };
47
79
  }
80
+ // An aborted call is not a mystery failure: the caller's deadline or cancel
81
+ // fired. (Checked after timeout so a timed-out abort still reads as TIMEOUT.)
82
+ if ((cause instanceof Error && cause.name === "AbortError") || /aborted|was cancelled|operation cancelled/i.test(message)) {
83
+ return { code: "CANCELLED", message: "cancelled", details: {} };
84
+ }
85
+ for (const family of OPERATIONAL_FAILURES) {
86
+ if (!family.pattern.test(message))
87
+ continue;
88
+ return {
89
+ code: "OPERATIONAL_ERROR",
90
+ message,
91
+ details: family.hint ? { hint: family.hint } : {},
92
+ };
93
+ }
48
94
  return { code: "UNEXPECTED_ERROR", message, details: {} };
49
95
  }
50
96
  export function isFreshnessTimeout(cause, userSignal) {
@@ -6,4 +6,23 @@
6
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
7
  import { type FreshnessLike, type RuntimeLike } from "./results.js";
8
8
  export declare const DEFAULT_LIMIT = 8;
9
+ /**
10
+ * pi ships `read`, `edit`, `write`, `bash`, `grep`, `find`, `ls` built in, so on
11
+ * a normal Pi host our one-shot file tools would be paid for twice and never
12
+ * needed. They stay REGISTERED — an MCP-style host, a `--no-builtin-tools`
13
+ * session, or a host that drops the built-ins still gets them — but they are
14
+ * left out of the active set when the host already provides read+edit. Pi only
15
+ * sends ACTIVE tools (schema, snippet, guidelines) to the model, so this is the
16
+ * difference between ~296 tokens per request and nothing.
17
+ *
18
+ * ASGREP_KEEP_FILE_TOOLS=1 pins them active regardless.
19
+ */
20
+ /**
21
+ * Tools that MUTATE the index never ride the warm session: its calls are
22
+ * serialized, so a write there blocks every read queued behind it.
23
+ *
24
+ * Exported so the routing contract is testable without a live session.
25
+ */
26
+ export declare function writesOffSession(tool: string): boolean;
27
+ export declare function hostProvidesFileTools(pi: ExtensionAPI, env?: NodeJS.ProcessEnv): boolean;
9
28
  export declare function registerAstSgrepTools(pi: ExtensionAPI, runtime?: RuntimeLike, freshness?: FreshnessLike): void;