@shanepadgett/tau-agent 0.27.0 → 0.28.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.
Files changed (113) hide show
  1. package/extensions/context/sync.ts +1 -9
  2. package/extensions/context-pruning/prune.ts +2 -2
  3. package/extensions/explore/README.md +13 -21
  4. package/extensions/explore/ast/adapter.ts +97 -0
  5. package/extensions/explore/ast/engine.ts +470 -0
  6. package/extensions/explore/ast/format/ast-search.ts +97 -0
  7. package/extensions/explore/ast/format/composite.ts +26 -0
  8. package/extensions/explore/ast/format/context.ts +66 -0
  9. package/extensions/explore/ast/format/deps.ts +86 -0
  10. package/extensions/explore/ast/format/discover.ts +85 -0
  11. package/extensions/explore/ast/format/impact.ts +105 -0
  12. package/extensions/explore/ast/format/outline.ts +66 -0
  13. package/extensions/explore/ast/format/relationships.ts +81 -0
  14. package/extensions/explore/ast/format/show.ts +38 -0
  15. package/extensions/explore/ast/grammars/kotlin.wasm +0 -0
  16. package/extensions/explore/ast/grammars/manifest.json +41 -0
  17. package/extensions/explore/ast/grammars/manifest.ts +99 -0
  18. package/extensions/explore/ast/grammars/odin.wasm +0 -0
  19. package/extensions/explore/ast/grammars/swift.wasm +0 -0
  20. package/extensions/explore/ast/graph/file-graph.ts +360 -0
  21. package/extensions/explore/ast/graph/relationships.ts +597 -0
  22. package/extensions/explore/ast/identity.ts +119 -0
  23. package/extensions/explore/ast/ir.ts +130 -0
  24. package/extensions/explore/ast/languages/csharp-file-deps.ts +189 -0
  25. package/extensions/explore/ast/languages/csharp.ts +493 -0
  26. package/extensions/explore/ast/languages/file-dep-util.ts +120 -0
  27. package/extensions/explore/ast/languages/fixtures/sample.cs +17 -0
  28. package/extensions/explore/ast/languages/fixtures/sample.java +21 -0
  29. package/extensions/explore/ast/languages/fixtures/sample.kt +15 -0
  30. package/extensions/explore/ast/languages/fixtures/sample.odin +28 -0
  31. package/extensions/explore/ast/languages/fixtures/sample.rs +31 -0
  32. package/extensions/explore/ast/languages/fixtures/sample.swift +25 -0
  33. package/extensions/explore/ast/languages/go-file-deps.ts +139 -0
  34. package/extensions/explore/ast/languages/go.ts +545 -0
  35. package/extensions/explore/ast/languages/java.ts +412 -0
  36. package/extensions/explore/ast/languages/jvm-file-deps.ts +217 -0
  37. package/extensions/explore/ast/languages/kotlin.ts +611 -0
  38. package/extensions/explore/ast/languages/odin-file-deps.ts +175 -0
  39. package/extensions/explore/ast/languages/odin.ts +407 -0
  40. package/extensions/explore/ast/languages/rust-file-deps.ts +159 -0
  41. package/extensions/explore/ast/languages/rust.ts +506 -0
  42. package/extensions/explore/ast/languages/swift-file-deps.ts +59 -0
  43. package/extensions/explore/ast/languages/swift.ts +466 -0
  44. package/extensions/explore/ast/languages/tree.ts +220 -0
  45. package/extensions/explore/ast/languages/typescript-file-deps.ts +259 -0
  46. package/extensions/explore/ast/languages/typescript-package-surface.ts +440 -0
  47. package/extensions/explore/ast/languages/typescript.ts +716 -0
  48. package/extensions/explore/ast/markdown.ts +145 -0
  49. package/extensions/explore/ast/package-surface.ts +42 -0
  50. package/extensions/explore/ast/queries/ast-search.ts +306 -0
  51. package/extensions/explore/ast/queries/composite-target.ts +77 -0
  52. package/extensions/explore/ast/queries/context.ts +406 -0
  53. package/extensions/explore/ast/queries/discover.ts +427 -0
  54. package/extensions/explore/ast/queries/impact.ts +250 -0
  55. package/extensions/explore/ast/queries/outline.ts +203 -0
  56. package/extensions/explore/ast/queries/show.ts +262 -0
  57. package/extensions/explore/ast/query.ts +48 -0
  58. package/extensions/explore/ast/read/hook.ts +125 -0
  59. package/extensions/explore/ast/read/policy.ts +55 -0
  60. package/extensions/explore/ast/registry.ts +120 -0
  61. package/extensions/explore/ast/scan.ts +76 -0
  62. package/extensions/explore/ast/slice.ts +31 -0
  63. package/extensions/explore/ast/tools/ast-search.ts +138 -0
  64. package/extensions/explore/ast/tools/context.ts +126 -0
  65. package/extensions/explore/ast/tools/deps.ts +72 -0
  66. package/extensions/explore/ast/tools/discover.ts +208 -0
  67. package/extensions/explore/ast/tools/impact.ts +135 -0
  68. package/extensions/explore/ast/tools/outline.ts +200 -0
  69. package/extensions/explore/ast/tools/relationships.ts +160 -0
  70. package/extensions/explore/ast/tools/render.ts +121 -0
  71. package/extensions/explore/ast/tools/reverse-deps.ts +72 -0
  72. package/extensions/explore/ast/tools/show.ts +116 -0
  73. package/extensions/explore/guidance.ts +52 -0
  74. package/extensions/explore/index.ts +82 -92
  75. package/extensions/explore/read/autoread.ts +118 -0
  76. package/extensions/explore/settings.ts +49 -31
  77. package/extensions/explore/traverse.ts +254 -91
  78. package/extensions/handoff/index.ts +1 -1
  79. package/extensions/publish/index.ts +18 -85
  80. package/extensions/publish/publish-ui.ts +0 -4
  81. package/extensions/soul/README.md +7 -4
  82. package/extensions/soul/index.ts +11 -5
  83. package/extensions/soul/prompt.ts +31 -13
  84. package/extensions/soul/settings.ts +7 -2
  85. package/extensions/subagent/agents/context-sync.md +6 -10
  86. package/extensions/subagent/agents/review.md +24 -55
  87. package/extensions/subagent/agents/scout.md +38 -115
  88. package/extensions/subagent/run.ts +1 -1
  89. package/extensions/tau-help/help.md +2 -4
  90. package/package.json +6 -4
  91. package/schemas/tau.schema.json +35 -30
  92. package/{extensions/explore → shared}/autoread.ts +21 -7
  93. package/shared/events.ts +1 -1
  94. package/shared/full-file-knowledge.ts +49 -0
  95. package/extensions/explore/ast-guidance.ts +0 -67
  96. package/extensions/explore/ast-languages.ts +0 -61
  97. package/extensions/explore/ast-tools.ts +0 -1994
  98. package/extensions/explore/ast-worker.ts +0 -1025
  99. package/extensions/explore/find.ts +0 -147
  100. package/extensions/explore/full-file-knowledge.ts +0 -234
  101. package/extensions/explore/grep.ts +0 -749
  102. package/extensions/explore/limits.ts +0 -22
  103. package/extensions/explore/ls.ts +0 -113
  104. package/extensions/explore/orientation-state.ts +0 -280
  105. package/extensions/explore/path-display.ts +0 -41
  106. package/extensions/explore/path-tree.ts +0 -150
  107. package/extensions/explore/read-cache.ts +0 -273
  108. package/extensions/explore/read-snapshots.ts +0 -57
  109. package/extensions/explore/read-stats-panel.ts +0 -176
  110. package/extensions/explore/read-stats.ts +0 -138
  111. package/extensions/explore/read.ts +0 -372
  112. package/extensions/explore/result.ts +0 -29
  113. package/native-bin/darwin-arm64/tau-ast +0 -0
@@ -223,12 +223,4 @@ function changedCatalogPaths(before: Map<string, string>, after: Map<string, str
223
223
  }
224
224
 
225
225
  // Ensure evidence tool name stays aligned with agent definition tooling.
226
- export const CONTEXT_SYNC_REQUIRED_TOOLS = [
227
- "read",
228
- "ls",
229
- "find",
230
- "grep",
231
- "bash",
232
- "patch",
233
- CONTEXT_SYNC_EVIDENCE_TOOL,
234
- ] as const;
226
+ export const CONTEXT_SYNC_REQUIRED_TOOLS = ["read", "bash", "patch", CONTEXT_SYNC_EVIDENCE_TOOL] as const;
@@ -2,8 +2,8 @@ import { resolve } from "node:path";
2
2
  import { type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
3
3
  import { type Static, Type } from "typebox";
4
4
  import type { ContextPruneDeferredFileV2, ContextPruneDetailsV2 } from "../../shared/context-pruning-state.ts";
5
- import { prepareAutoreadMessage, type PreparedAutoreadMessage } from "../explore/autoread.ts";
6
- import { MAX_COMPLETE_FILE_SNAPSHOT_BYTES } from "../explore/full-file-knowledge.ts";
5
+ import { prepareAutoreadMessage, type PreparedAutoreadMessage } from "../../shared/autoread.ts";
6
+ import { MAX_COMPLETE_FILE_SNAPSHOT_BYTES } from "../../shared/full-file-knowledge.ts";
7
7
 
8
8
  export const contextPruneParameters = Type.Object(
9
9
  {
@@ -1,28 +1,20 @@
1
1
  # Explore
2
2
 
3
- Locator edit tools (`replace_declaration`, `replace_body`, `insert_declaration`, and `rename_declaration`) consume numeric declaration locators and reject stale or parser-recovered targets. They validate candidate syntax before writing, invalidate old locators, and return fresh locators after a clean reparse. For Markdown headings, `replace_declaration` replaces the complete section and `replace_body` preserves the heading while replacing its contents. Nested replacement headings must stay below the selected heading depth. Markdown insertion and rename remain unavailable. Code rename requires an explicit file or repository scope. Inferred references change only with approval; ambiguous references stay unchanged and are reported.
3
+ Explore gives Tau 12 structural source tools: outlines, declaration slices, discovery, structural search, graph and relationship queries, impact, and context packs. Targets use path + declaration name, with an optional line to disambiguate.
4
4
 
5
- Explore is Tau's first-party filesystem exploration extension.
5
+ Pi keeps ordinary filesystem tools (`ls`, `find`, `grep`, `read`). Explore adds structure on top of supported source languages via in-process tree-sitter (WASM) on every platform supported by the Node runtime. Registered languages share the same tools and exploration workflow.
6
6
 
7
- It exists so agents can inspect paths, discover files, search text, inspect declarations in supported source languages, and read exact source with compact model payloads and readable tool rows. For configured supported source, the official `read` tool requires a current structural attempt for that exact file fingerprint. Direct file outlines and searches qualify, along with files returned by `symbol`, `api_discover`, structural search, and relationship tools. Markdown is ungated by default. Unsupported files and unavailable workers keep the ordinary read path. Autoread establishes complete-file knowledge while that source remains in active context. Later reads can return an unchanged marker or a smaller diff instead of resending the file. A successful Tau patch can return a trusted cached complete-file diff without another structural attempt when the current branch still contains the prior complete-file baseline and the resulting fingerprint matches.
7
+ ## Large `read` / autoread
8
8
 
9
- When the source baseline is no longer available, such as after compaction or a cold subagent resume, `read` safely returns the current full source.
9
+ When `explore.read.enabled` is on (default), a full Pi `read` or autoread of a registered source file (including Markdown) above `explore.read.structureThresholdLines` (default 200) returns an **outline** — declarations or headings — not the full body. Use ranged `read` (`offset`/`limit`, capped by `explore.read.maxRangeLines`) or `show` for bodies and sections. Small files and unregistered paths stay ordinary Pi full text. Set `explore.read.enabled` to `false` to turn the overlay off.
10
10
 
11
- Agents invoke it with `ls`, `find`, `grep`, `api_discover`, `ast_search`, `outline`, `symbol`, `references`, `callers`, `callees`, `implementations`, `tests`, and `read`. The five relationship tools expand a declaration locator across a repository or subtree. Every result reports exact source location, syntactic relationship, exact/inferred/ambiguous certainty, production/test/generated/re-export classification, declaration candidates, and a numeric locator for the nearest complete editable scope. Ambiguous results are marked non-actionable. Scans stay bounded, deterministic, ignore-aware, and cancellable. `ast_search` finds code shapes with ast-grep patterns across one supported source file, repository, package, or subtree. Directory searches require an explicit language; supported files can infer it. Results include exact previews, metavariable bindings, parser uncertainty, numeric locators for matches and enclosing scopes, complete scan counts, and explicit result or traversal limits. Output stays bounded and complete overflow is saved to the active session temporary store. `api_discover` searches declarations across a repository, package, or subtree by exact, prefix, substring, bounded fuzzy, declaration-kind, or documentation query. It filters public, private, source-exported, or package-surface declarations and reports defining files separately from caller access for TypeScript, TSX, Odin, Go, Rust, C#, Java, Kotlin, and Swift. Caller access includes the canonical module path, exact import statement, usable access expression, and resolution uncertainty; TypeScript results also include re-export chains. Candidates contain signatures without implementation bodies and numeric locators accepted by `symbol`. `outline` returns public declaration signatures and parenthesized numeric locators for TypeScript, TSX, Odin, Go, Rust, C#, Java, Kotlin, Swift, and Markdown files. Markdown headings locate their complete sections. It accepts a package directory for one-level inspection, or `recursive: true` for ignore-aware mixed-language orientation of a repository or subtree. Recursive output stays bounded; when the complete outline is larger, Tau saves it to a temporary path for targeted `grep` and ranged `read` during the active session. Exact-name filters narrow the result, `includePrivate` exposes internal declarations, and `includeDocs` adds attached documentation comments when they are needed. Annotations and attributes remain in normal outlines. `symbol` accepts those numbers in four views: `signature` omits documentation and bodies, `signatureWithDocs` adds attached documentation without the body, `declaration` returns exact declaration source, and `declarationWithImports` adds required imports. It retrieves several locators in one call, can add bounded surrounding lines to exact declarations, and rejects the whole batch when any locator is stale. Users run `/read-stats` to see estimated token and cost savings for the session.
11
+ ## Tools
12
12
 
13
- Explore scans a bounded, ignore-aware slice of the working root before each agent run. When the repository contains supported source and the selected native worker is usable, Explore adds language-specific AST-first guidance to the agent prompt. Unsupported repositories and hosts get the ordinary filesystem workflow. `api_discover`, `outline`, and `symbol` stay registered either way.
14
-
15
- Installed packages support `api_discover`, `ast_search`, `outline`, `symbol`, and the relationship tools on Apple Silicon Macs. They include the worker, so users do not need Rust or Cargo. On other platforms, the rest of Explore remains available and AST tools report the platform limit when invoked.
16
-
17
- For supported source, use the cheapest useful step:
18
-
19
- 1. Identify the current job: locate, reuse, edit, explain, or debug. Avoid loading implementation, callers, tests, or documentation needed only later.
20
- 2. Use a recursive `outline` to orient an unfamiliar repository or subtree. Outline a known package or file directly. Outline large Markdown files before retrieving a relevant heading section.
21
- 3. Use `api_discover` when reuse intent is known but the declaration path or exact name is not. Prefer package surfaces and public exports before private declarations.
22
- 4. Narrow paths, exact names, query work, and result limits once the likely target is known. Enable `includePrivate` only for targeted implementation work.
23
- 5. Stop at a clear outline or API candidate signature. Use `symbol(signature)` for closer contract inspection, `signatureWithDocs` only when attached documentation matters, and declaration views only for exact source.
24
- 6. Use `references`, `callers`, `callees`, `implementations`, or `tests` after selecting a change target and when the result can affect the plan. Inspect tests earlier only when the task begins with test behavior or a failure.
25
- 7. Use `ast_search` for code shapes and `grep` for literal text. Use targeted `read` for exact formatting, comments, parser gaps, unsupported files, or source outside declaration boundaries.
26
- 8. Prefer locator edits when a complete declaration or body is the natural edit boundary. Use textual patching when the change crosses those boundaries or depends on surrounding text.
27
-
28
- Stop when the current question is answered. Expand exploration only for a specific unresolved question.
13
+ - `outline` — declarations and structure for a file, one-level directory, or recursive subtree (no bodies).
14
+ - `show` — exact signature / docs / declaration / declaration+imports for path+name targets.
15
+ - `discover` — find reusable declarations across a repo/package/subtree by name, kind, or docs (signatures only).
16
+ - `deps` / `reverse_deps` — file import graph forward and reverse.
17
+ - `callers` / `callees` / `references` / `implementations` — symbol relationship sites.
18
+ - `impact` — blast radius: symbol callees/callers plus file imports/importers/transitive dependents.
19
+ - `context` — budgeted pack of bodies/signatures around one symbol.
20
+ - `ast_search` — structural pattern search (ast-grep) over supported source.
@@ -0,0 +1,97 @@
1
+ import type { Tree } from "web-tree-sitter";
2
+ import type { Decl, ImportRef } from "./ir.ts";
3
+ import type { PackageSurfaceResolver } from "./package-surface.ts";
4
+
5
+ export type LanguageCapabilities = {
6
+ shape: boolean;
7
+ search: boolean;
8
+ fileDeps: boolean;
9
+ callEdges: boolean;
10
+ packageSurface: boolean;
11
+ };
12
+
13
+ /**
14
+ * Contextual pattern wrap for languages where a bare snippet parses as the wrong kind
15
+ * (e.g. Go `fmt.Println($A)` → type_conversion). Shared search substitutes the user
16
+ * pattern for `$PATTERN` and tries each wrap after the bare pattern.
17
+ */
18
+ export type AstSearchPatternContext = {
19
+ /** Source template containing the literal placeholder `$PATTERN`. */
20
+ readonly context: string;
21
+ /** tree-sitter kind name selected from the parsed context. */
22
+ readonly selector: string;
23
+ };
24
+
25
+ export type ExtractResult = {
26
+ decls: Decl[];
27
+ imports: ImportRef[];
28
+ };
29
+
30
+ /** Result of adapter-owned import specifier → file resolution. */
31
+ export type FileDepResolution =
32
+ | { kind: "internal"; paths: string[] }
33
+ | { kind: "external"; id: string }
34
+ | { kind: "unresolved" };
35
+
36
+ /**
37
+ * Host services for file-dep resolution.
38
+ * Language-agnostic: resolvers never touch the engine type.
39
+ */
40
+ export type FileDepHost = {
41
+ readonly cwd: string;
42
+ /** Scope root for this query. Resolvers must not walk above it for internal hits. */
43
+ readonly scopeRoot: string;
44
+ pathExists(path: string): Promise<boolean>;
45
+ isFile(path: string): Promise<boolean>;
46
+ /** Basenames; empty if missing or not a directory. */
47
+ readDir(path: string): Promise<string[]>;
48
+ /** True when path is a registered source file this graph should consider. */
49
+ ownsPath(path: string): boolean;
50
+ /** Session-scoped memo bag; cleared with graph invalidation. */
51
+ readonly memo: Map<string, unknown>;
52
+ };
53
+
54
+ export type FileDepResolver = (
55
+ fromPath: string,
56
+ specifier: string,
57
+ host: FileDepHost,
58
+ signal: AbortSignal,
59
+ ) => Promise<FileDepResolution>;
60
+
61
+ /**
62
+ * Grammar-backed language. `id` must match a pin in `ast/grammars/manifest`.
63
+ * Engine owns wasm load/parse; adapter only walks the tree.
64
+ * Language-owned hooks (`importNoiseIdentifiers`, `resolveFileDep`, optional `resolvePackageSurface`, callEdges)
65
+ * stay on the adapter — tools/queries never branch on language id.
66
+ */
67
+ export type GrammarAdapter = {
68
+ readonly mode: "grammar";
69
+ readonly id: string;
70
+ readonly extensions: readonly string[];
71
+ readonly capabilities: LanguageCapabilities;
72
+ /** Keywords/types ignored when intersecting import text with a declaration slice (`show`). */
73
+ readonly importNoiseIdentifiers: ReadonlySet<string>;
74
+ extract(tree: Tree, source: string): ExtractResult;
75
+ /** Required when `capabilities.fileDeps` is true. */
76
+ readonly resolveFileDep?: FileDepResolver;
77
+ readonly resolvePackageSurface?: PackageSurfaceResolver;
78
+ /** Optional ast_search contextual wraps; shared matcher stays language-blind. */
79
+ readonly patternContexts?: readonly AstSearchPatternContext[];
80
+ };
81
+
82
+ /**
83
+ * Source-only language (Markdown). No tree-sitter grammar.
84
+ * Discriminant keeps engine free of `language ===` branches.
85
+ */
86
+ export type SourceAdapter = {
87
+ readonly mode: "source";
88
+ readonly id: string;
89
+ readonly extensions: readonly string[];
90
+ readonly capabilities: LanguageCapabilities;
91
+ readonly importNoiseIdentifiers: ReadonlySet<string>;
92
+ extract(source: string): ExtractResult;
93
+ readonly resolveFileDep?: FileDepResolver;
94
+ readonly resolvePackageSurface?: PackageSurfaceResolver;
95
+ };
96
+
97
+ export type LanguageAdapter = GrammarAdapter | SourceAdapter;
@@ -0,0 +1,470 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import {
5
+ initializeTreeSitter,
6
+ parse as astGrepParse,
7
+ registerDynamicLanguage,
8
+ type SgNode,
9
+ type SgRoot,
10
+ } from "@ast-grep/wasm";
11
+ import { Language, Parser } from "web-tree-sitter";
12
+ import type { AdapterRegistry } from "./registry.ts";
13
+ import { createDefaultRegistry } from "./registry.ts";
14
+ import type { FileIr } from "./ir.ts";
15
+ import { grammarWasmPath, loadGrammarManifest, runtimeWasmPath, type GrammarPin } from "./grammars/manifest.ts";
16
+ import { formatPathForDisplay, pathResolutionError, resolveExplorePath } from "../traverse.ts";
17
+
18
+ export type FileSource = {
19
+ ir: FileIr;
20
+ /** Decoded source text this IR's offsets index into (UTF-16 code units). */
21
+ source: string;
22
+ };
23
+
24
+ export type AstSearchBinding = {
25
+ name: string;
26
+ /** Exact matched text (single node or multi-node join of exact pieces). */
27
+ text: string;
28
+ };
29
+
30
+ export type AstSearchHit = {
31
+ /** 1-indexed inclusive. */
32
+ startLine: number;
33
+ endLine: number;
34
+ /** Exact match preview (edit-grade). */
35
+ text: string;
36
+ bindings: AstSearchBinding[];
37
+ };
38
+
39
+ export type ExploreEngine = {
40
+ /** Absolute session cwd — path resolution and directory scans use this. */
41
+ readonly cwd: string;
42
+ readonly registry: AdapterRegistry;
43
+ /** Resolve path, read bytes, return cached or freshly extracted FileIr. */
44
+ irForFile(path: string): Promise<FileIr>;
45
+ /** One read: IR (cached when hash matches) plus the decoded source for slicing. */
46
+ sourceForFile(path: string): Promise<FileSource>;
47
+ /**
48
+ * Structural pattern search over one source string.
49
+ * Engine owns @ast-grep/wasm init + grammar registration from pinned wasm paths.
50
+ * Does not use the IR cache; does not cache match results.
51
+ */
52
+ searchInSource(languageId: string, source: string, pattern: string): Promise<AstSearchHit[]>;
53
+ invalidate(paths: readonly string[]): void;
54
+ clear(): void;
55
+ /**
56
+ * Drop IR cache and delete the retained Parser.
57
+ * Loaded Language modules are dropped from JS maps; web-tree-sitter 0.26 has no Language.delete().
58
+ */
59
+ shutdown(): void;
60
+ };
61
+
62
+ export type ExploreEngineOptions = {
63
+ cwd: string;
64
+ registry?: AdapterRegistry;
65
+ };
66
+
67
+ type CacheEntry = {
68
+ contentHash: string;
69
+ ir: FileIr;
70
+ };
71
+
72
+ function contentHashOf(bytes: Uint8Array): string {
73
+ return createHash("sha256").update(bytes).digest("hex");
74
+ }
75
+
76
+ function lineCountOf(source: string): number {
77
+ if (source.length === 0) return 0;
78
+ let count = 1;
79
+ for (let i = 0; i < source.length; i += 1) {
80
+ const code = source.charCodeAt(i);
81
+ if (code === 10) count += 1;
82
+ else if (code === 13) {
83
+ count += 1;
84
+ if (source.charCodeAt(i + 1) === 10) i += 1;
85
+ }
86
+ }
87
+ return count;
88
+ }
89
+
90
+ function pinById(id: string): GrammarPin {
91
+ const pin = loadGrammarManifest().grammars.find((entry) => entry.id === id);
92
+ if (pin === undefined) {
93
+ throw new Error(`No grammar artifact for language: ${id}`);
94
+ }
95
+ return pin;
96
+ }
97
+
98
+ /** ast-grep metavars: `$NAME` one node, `$$$NAME` sibling sequence. Skip `$_`. */
99
+ function metaVarNames(pattern: string): { singles: string[]; multis: string[] } {
100
+ const singles: string[] = [];
101
+ const multis: string[] = [];
102
+ let i = 0;
103
+ while (i < pattern.length) {
104
+ if (pattern[i] !== "$") {
105
+ i += 1;
106
+ continue;
107
+ }
108
+ let dollars = 0;
109
+ while (i < pattern.length && pattern[i] === "$") {
110
+ dollars += 1;
111
+ i += 1;
112
+ }
113
+ let name = "";
114
+ while (i < pattern.length) {
115
+ const c = pattern[i];
116
+ if (c === undefined) break;
117
+ const isAlpha = (c >= "A" && c <= "Z") || c === "_";
118
+ const isAlnum = isAlpha || (c >= "0" && c <= "9");
119
+ if ((name.length === 0 && isAlpha) || (name.length > 0 && isAlnum)) {
120
+ name += c;
121
+ i += 1;
122
+ continue;
123
+ }
124
+ break;
125
+ }
126
+ if (name.length === 0 || name === "_") continue;
127
+ if (dollars >= 3) {
128
+ if (!multis.includes(name)) multis.push(name);
129
+ } else if (dollars === 1) {
130
+ if (!singles.includes(name)) singles.push(name);
131
+ }
132
+ }
133
+ return { singles, multis };
134
+ }
135
+
136
+ function hitKey(hit: AstSearchHit): string {
137
+ return `${hit.startLine}:${hit.endLine}:${hit.text}`;
138
+ }
139
+
140
+ /** @ast-grep/wasm Pos.index is Unicode scalar offset, not UTF-16. */
141
+ function sliceByCharOffset(source: string, startChar: number, endChar: number): string {
142
+ let start = 0;
143
+ let end = source.length;
144
+ let charIndex = 0;
145
+ for (let i = 0; i < source.length;) {
146
+ if (charIndex === startChar) start = i;
147
+ const code = source.charCodeAt(i);
148
+ const step = code >= 0xd800 && code <= 0xdbff ? 2 : 1;
149
+ i += step;
150
+ charIndex += 1;
151
+ if (charIndex === endChar) {
152
+ end = i;
153
+ break;
154
+ }
155
+ }
156
+ if (charIndex < endChar) end = source.length;
157
+ return source.slice(start, end);
158
+ }
159
+
160
+ function bindingsFromNode(node: SgNode, pattern: string, source: string): AstSearchBinding[] {
161
+ const { singles, multis } = metaVarNames(pattern);
162
+ const out: AstSearchBinding[] = [];
163
+ for (const name of singles) {
164
+ const match = node.getMatch(name);
165
+ if (match !== undefined) out.push({ name, text: match.text() });
166
+ }
167
+ for (const name of multis) {
168
+ const parts = node.getMultipleMatches(name);
169
+ if (parts.length === 0) continue;
170
+ const first = parts[0];
171
+ const last = parts[parts.length - 1];
172
+ if (first === undefined || last === undefined) continue;
173
+ const start = first.range().start.index;
174
+ const end = last.range().end.index;
175
+ out.push({ name, text: sliceByCharOffset(source, start, end) });
176
+ }
177
+ return out;
178
+ }
179
+
180
+ function nodeToHit(node: SgNode, pattern: string, source: string): AstSearchHit {
181
+ const range = node.range();
182
+ return {
183
+ startLine: range.start.line + 1,
184
+ endLine: range.end.line + 1,
185
+ text: node.text(),
186
+ bindings: bindingsFromNode(node, pattern, source),
187
+ };
188
+ }
189
+
190
+ function collectHits(
191
+ root: SgRoot,
192
+ matcher: unknown,
193
+ pattern: string,
194
+ source: string,
195
+ into: Map<string, AstSearchHit>,
196
+ ): void {
197
+ const nodes = root.root().findAll(matcher);
198
+ for (const node of nodes) {
199
+ const hit = nodeToHit(node, pattern, source);
200
+ const key = hitKey(hit);
201
+ if (!into.has(key)) into.set(key, hit);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * In-process web-tree-sitter host.
207
+ * Parse → extract plain FileIr → tree.delete() immediately. Cache IR only.
208
+ * All IR offsets are UTF-16 code units into the decoded source string.
209
+ * Only this module resolves wasm paths via the grammar manifest.
210
+ * ast_search uses @ast-grep/wasm on the same pinned grammar paths (decision 11-A).
211
+ */
212
+ export function createExploreEngine(options: ExploreEngineOptions): ExploreEngine {
213
+ const cwd = resolve(options.cwd);
214
+ const registry = options.registry ?? createDefaultRegistry();
215
+ const irCache = new Map<string, CacheEntry>();
216
+ const languages = new Map<string, Language>();
217
+ const languageLoads = new Map<string, Promise<Language>>();
218
+ const searchLangReady = new Set<string>();
219
+
220
+ let initPromise: Promise<void> | undefined;
221
+ let searchInitPromise: Promise<void> | undefined;
222
+ let parser: Parser | undefined;
223
+ let shutDown = false;
224
+
225
+ const assertLive = (): void => {
226
+ if (shutDown) {
227
+ throw new Error("Explore engine has been shut down");
228
+ }
229
+ };
230
+
231
+ const ensureReady = async (): Promise<Parser> => {
232
+ assertLive();
233
+ if (parser !== undefined) return parser;
234
+ if (initPromise === undefined) {
235
+ initPromise = Parser.init({ locateFile: () => runtimeWasmPath() }).then(() => {
236
+ if (shutDown) return;
237
+ parser = new Parser();
238
+ });
239
+ }
240
+ await initPromise;
241
+ assertLive();
242
+ if (parser === undefined) throw new Error("Explore engine failed to initialize parser");
243
+ return parser;
244
+ };
245
+
246
+ const ensureSearchReady = async (): Promise<void> => {
247
+ assertLive();
248
+ // IR path init first so locateFile pins web-tree-sitter.wasm for the process.
249
+ await ensureReady();
250
+ if (searchInitPromise === undefined) {
251
+ searchInitPromise = initializeTreeSitter();
252
+ }
253
+ await searchInitPromise;
254
+ assertLive();
255
+ };
256
+
257
+ const ensureSearchLanguage = async (languageId: string): Promise<void> => {
258
+ assertLive();
259
+ if (searchLangReady.has(languageId)) return;
260
+ const adapter = registry.adapterForId(languageId);
261
+ if (adapter === undefined) {
262
+ throw new Error(`Unregistered language: ${languageId}`);
263
+ }
264
+ if (adapter.mode !== "grammar" || !adapter.capabilities.search) {
265
+ throw new Error(`Language does not support structural search: ${languageId}`);
266
+ }
267
+ await ensureSearchReady();
268
+ assertLive();
269
+ await registerDynamicLanguage({
270
+ [languageId]: {
271
+ libraryPath: grammarWasmPath(pinById(languageId)),
272
+ expandoChar: "µ",
273
+ },
274
+ });
275
+ assertLive();
276
+ searchLangReady.add(languageId);
277
+ };
278
+
279
+ const loadLanguage = async (languageId: string): Promise<Language> => {
280
+ assertLive();
281
+ const cached = languages.get(languageId);
282
+ if (cached !== undefined) return cached;
283
+
284
+ let pending = languageLoads.get(languageId);
285
+ if (pending === undefined) {
286
+ pending = Language.load(grammarWasmPath(pinById(languageId))).then(
287
+ (language) => {
288
+ languageLoads.delete(languageId);
289
+ if (!shutDown) {
290
+ languages.set(languageId, language);
291
+ }
292
+ return language;
293
+ },
294
+ (error: unknown) => {
295
+ languageLoads.delete(languageId);
296
+ throw error;
297
+ },
298
+ );
299
+ languageLoads.set(languageId, pending);
300
+ }
301
+
302
+ const language = await pending;
303
+ assertLive();
304
+ return language;
305
+ };
306
+
307
+ const buildIr = async (absolutePath: string, source: string, hash: string): Promise<FileIr> => {
308
+ const adapter = registry.adapterForPath(absolutePath);
309
+ if (adapter === undefined) {
310
+ throw new Error(`Unsupported language for path: ${formatPathForDisplay(absolutePath, cwd)}`);
311
+ }
312
+ const lineCount = lineCountOf(source);
313
+
314
+ if (adapter.mode === "source") {
315
+ const extracted = adapter.extract(source);
316
+ return {
317
+ path: absolutePath,
318
+ contentHash: hash,
319
+ languageId: adapter.id,
320
+ lineCount,
321
+ decls: extracted.decls,
322
+ imports: extracted.imports,
323
+ parseDegraded: false,
324
+ };
325
+ }
326
+
327
+ const activeParser = await ensureReady();
328
+ const language = await loadLanguage(adapter.id);
329
+ assertLive();
330
+ activeParser.setLanguage(language);
331
+ activeParser.reset();
332
+ const tree = activeParser.parse(source);
333
+ if (tree === null) {
334
+ throw new Error(`Parse failed for ${formatPathForDisplay(absolutePath, cwd)}`);
335
+ }
336
+ try {
337
+ const extracted = adapter.extract(tree, source);
338
+ return {
339
+ path: absolutePath,
340
+ contentHash: hash,
341
+ languageId: adapter.id,
342
+ lineCount,
343
+ decls: extracted.decls,
344
+ imports: extracted.imports,
345
+ parseDegraded: tree.rootNode.hasError,
346
+ };
347
+ } finally {
348
+ tree.delete();
349
+ }
350
+ };
351
+
352
+ const loadSource = async (path: string): Promise<FileSource> => {
353
+ assertLive();
354
+ const absolutePath = resolveExplorePath(cwd, path);
355
+ let bytes: Buffer;
356
+ try {
357
+ bytes = await readFile(absolutePath);
358
+ } catch (error) {
359
+ throw pathResolutionError(error, path);
360
+ }
361
+ assertLive();
362
+ const hash = contentHashOf(bytes);
363
+ const source = bytes.toString("utf8");
364
+ const cached = irCache.get(absolutePath);
365
+ if (cached !== undefined && cached.contentHash === hash) {
366
+ return { ir: cached.ir, source };
367
+ }
368
+ const ir = await buildIr(absolutePath, source, hash);
369
+ assertLive();
370
+ irCache.set(absolutePath, { contentHash: hash, ir });
371
+ return { ir, source };
372
+ };
373
+
374
+ return {
375
+ cwd,
376
+ registry,
377
+
378
+ async irForFile(path: string): Promise<FileIr> {
379
+ const source = await loadSource(path);
380
+ return source.ir;
381
+ },
382
+
383
+ async sourceForFile(path: string): Promise<FileSource> {
384
+ return loadSource(path);
385
+ },
386
+
387
+ async searchInSource(languageId: string, source: string, pattern: string): Promise<AstSearchHit[]> {
388
+ assertLive();
389
+ const adapter = registry.adapterForId(languageId);
390
+ if (adapter === undefined) {
391
+ throw new Error(`Unregistered language: ${languageId}`);
392
+ }
393
+ if (adapter.mode !== "grammar" || !adapter.capabilities.search) {
394
+ throw new Error(`Language does not support structural search: ${languageId}`);
395
+ }
396
+ await ensureSearchLanguage(languageId);
397
+ assertLive();
398
+
399
+ let root: SgRoot;
400
+ try {
401
+ root = astGrepParse(languageId, source);
402
+ } catch (error) {
403
+ const message = error instanceof Error ? error.message : String(error);
404
+ throw new Error(`Parse failed for structural search (${languageId}): ${message}`);
405
+ }
406
+
407
+ try {
408
+ const byKey = new Map<string, AstSearchHit>();
409
+ const matchers: unknown[] = [pattern];
410
+ const contexts = adapter.patternContexts ?? [];
411
+ for (const wrap of contexts) {
412
+ if (!wrap.context.includes("$PATTERN")) continue;
413
+ matchers.push({
414
+ rule: {
415
+ pattern: {
416
+ context: wrap.context.split("$PATTERN").join(pattern),
417
+ selector: wrap.selector,
418
+ },
419
+ },
420
+ });
421
+ }
422
+
423
+ let anyMatcherOk = false;
424
+ let lastError: string | undefined;
425
+ for (const matcher of matchers) {
426
+ try {
427
+ collectHits(root, matcher, pattern, source, byKey);
428
+ anyMatcherOk = true;
429
+ } catch (error) {
430
+ lastError = error instanceof Error ? error.message : String(error);
431
+ }
432
+ }
433
+ if (!anyMatcherOk) {
434
+ throw new Error(`Invalid pattern: ${lastError ?? "unknown pattern error"}`);
435
+ }
436
+
437
+ return [...byKey.values()].sort((a, b) => {
438
+ if (a.startLine !== b.startLine) return a.startLine - b.startLine;
439
+ if (a.endLine !== b.endLine) return a.endLine - b.endLine;
440
+ return a.text.localeCompare(b.text);
441
+ });
442
+ } finally {
443
+ root.free();
444
+ }
445
+ },
446
+
447
+ invalidate(paths: readonly string[]): void {
448
+ for (const path of paths) {
449
+ irCache.delete(resolveExplorePath(cwd, path));
450
+ }
451
+ },
452
+
453
+ clear(): void {
454
+ irCache.clear();
455
+ },
456
+
457
+ shutdown(): void {
458
+ if (shutDown) return;
459
+ shutDown = true;
460
+ irCache.clear();
461
+ languages.clear();
462
+ languageLoads.clear();
463
+ searchLangReady.clear();
464
+ parser?.delete();
465
+ parser = undefined;
466
+ initPromise = undefined;
467
+ searchInitPromise = undefined;
468
+ },
469
+ };
470
+ }