askmiyagi 0.0.0-stage → 0.1.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 (53) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +42 -2
  3. package/THIRD_PARTY_NOTICES.md +62 -0
  4. package/dist/server/cli-args.js +85 -0
  5. package/dist/server/cli.js +186 -0
  6. package/dist/server/code/anchor-resolver.js +108 -0
  7. package/dist/server/code/parse-cache.js +69 -0
  8. package/dist/server/code/repo-map.js +227 -0
  9. package/dist/server/code/symbols.js +149 -0
  10. package/dist/server/files/file-filter.js +171 -0
  11. package/dist/server/files/path-guard.js +71 -0
  12. package/dist/server/files/redact.js +46 -0
  13. package/dist/server/http/ask-route.js +85 -0
  14. package/dist/server/http/file-route.js +14 -0
  15. package/dist/server/http/serve.js +101 -0
  16. package/dist/server/http/speech-route.js +53 -0
  17. package/dist/server/http/transcribe-route.js +53 -0
  18. package/dist/server/llm/ask.js +102 -0
  19. package/dist/server/llm/claude-headless.js +16 -0
  20. package/dist/server/llm/claude-process.js +206 -0
  21. package/dist/server/llm/claude-stream.js +161 -0
  22. package/dist/server/llm/conversation.js +21 -0
  23. package/dist/server/llm/fake-provider.js +73 -0
  24. package/dist/server/llm/prompt.js +84 -0
  25. package/dist/server/llm/provider.js +1 -0
  26. package/dist/server/llm/selection.js +21 -0
  27. package/dist/server/log.js +10 -0
  28. package/dist/server/mcp/server.js +70 -0
  29. package/dist/server/source/git-clone.js +80 -0
  30. package/dist/server/source/github-url.js +18 -0
  31. package/dist/server/source/local-folder.js +19 -0
  32. package/dist/server/source/project-name.js +6 -0
  33. package/dist/server/tools/find-symbol.js +31 -0
  34. package/dist/server/tools/grep.js +134 -0
  35. package/dist/server/tools/read-file.js +54 -0
  36. package/dist/server/tools/run-tool.js +18 -0
  37. package/dist/shared/anchor.js +14 -0
  38. package/dist/shared/events.js +26 -0
  39. package/dist/shared/file.js +3 -0
  40. package/dist/shared/llm-request.js +10 -0
  41. package/dist/shared/parse.js +7 -0
  42. package/dist/shared/phrases.js +1 -0
  43. package/dist/shared/result.js +7 -0
  44. package/dist/shared/tool-inputs.js +30 -0
  45. package/dist/shared/tool-result.js +2 -0
  46. package/dist/shared/tour.js +25 -0
  47. package/dist/shared/transcript.js +2 -0
  48. package/dist/shared/untrusted.js +4 -0
  49. package/dist/shared/usage.js +9 -0
  50. package/dist/web/assets/index-Baler-BH.css +1 -0
  51. package/dist/web/assets/index-NFl7WrKD.js +112 -0
  52. package/dist/web/index.html +13 -0
  53. package/package.json +63 -4
package/README.md CHANGED
@@ -1,3 +1,43 @@
1
- # Temporary Holding Version
1
+ # Ask Miyagi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A voice tutor for codebases. Ask a question out loud; Miyagi explains by voice while it opens files and highlights the exact code it is talking about, step by step, like a teacher sitting beside you.
4
+
5
+ Everything runs locally: one command starts a server on `127.0.0.1` and opens the browser. The voice is local by default (ElevenLabs is opt-in), and the model only gets read-only tools.
6
+
7
+ > Status: early development. See `docs/PLAN.md` for what is built and what is next.
8
+
9
+ ## Use
10
+
11
+ ```sh
12
+ npx askmiyagi path/to/your/repo
13
+ ```
14
+
15
+ Requires Node 24+ and a logged-in [Claude Code](https://claude.com/claude-code) CLI (other providers are planned).
16
+
17
+ ## Develop
18
+
19
+ ```sh
20
+ mise install
21
+ npm ci
22
+ mise exec -- lefthook install
23
+ ```
24
+
25
+ `mise install` sets up Node, gitleaks and lefthook at pinned versions. `lefthook install` adds the pre-commit hooks (secret scan, lint and types).
26
+
27
+ | Command | What it does |
28
+ |---|---|
29
+ | `npm run format` | Format and auto-fix everything |
30
+ | `npm run check` | Full gate: format check, lint, types, Svelte types, unused code, tests |
31
+ | `npm run check:fast` | Lint and types only |
32
+
33
+ Docs: `docs/PROJECT.md` (what and why), `docs/ARCHITECTURE.md` (how), `docs/PLAN.md` (build plan). Rules for people and coding agents: `AGENTS.md`.
34
+
35
+ ## Deploy
36
+
37
+ Ask Miyagi ships as the npm package [`askmiyagi`](https://www.npmjs.com/package/askmiyagi). To release, bump `version` in `package.json` and run `npm publish`; `prepublishOnly` runs `npm run check`, which builds `dist`, first. At release, rename `[Unreleased]` in `CHANGELOG.md` to the version and date.
38
+
39
+ ## License
40
+
41
+ Copyright (C) 2026 Sarthak Kumar.
42
+
43
+ Ask Miyagi is free software under the [GNU Affero General Public License v3.0](LICENSE) (`AGPL-3.0-only`). You may use, study, change and share it. If you share a changed version, or let other people use a changed version over a network, you must make its full source available under the same licence. Code adapted or bundled from other projects is credited in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
@@ -0,0 +1,62 @@
1
+ # Third-party notices
2
+
3
+ ## claude-loopback
4
+
5
+ `src/server/llm/claude-process.ts` and the Claude stream parser adapt spawn, argument, stream-parsing and process-tree kill code from claude-loopback (github.com/ssaarthakk/claude-loopback).
6
+
7
+ ```
8
+ MIT License
9
+
10
+ Copyright (c) 2026 Sarthak Kumar
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ ```
30
+
31
+ ## Browser app bundle
32
+
33
+ `dist/web` bundles code from these MIT-licensed packages:
34
+
35
+ - `svelte`: Copyright (c) 2016-2025 Svelte Contributors
36
+ - `codemirror`, `@codemirror/view`, `@codemirror/state`, `@codemirror/language`, `@codemirror/lang-javascript`, `@codemirror/commands`, `@codemirror/search`, `@codemirror/autocomplete`, `@codemirror/lint`: Copyright (C) 2018-2021 by Marijn Haverbeke and others
37
+ - `@lezer/common`, `@lezer/highlight`, `@lezer/lr`, `@lezer/javascript`, `style-mod`: Copyright (C) 2018 by Marijn Haverbeke and others
38
+ - `w3c-keyname`: Copyright (C) 2016 by Marijn Haverbeke and others
39
+ - `crelt`: Copyright (C) 2020 by Marijn Haverbeke
40
+ - `@marijn/find-cluster-break`: Copyright (C) 2024 by Marijn Haverbeke
41
+
42
+ Each is released under the MIT License:
43
+
44
+ ```
45
+ Permission is hereby granted, free of charge, to any person obtaining a copy
46
+ of this software and associated documentation files (the "Software"), to deal
47
+ in the Software without restriction, including without limitation the rights
48
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
49
+ copies of the Software, and to permit persons to whom the Software is
50
+ furnished to do so, subject to the following conditions:
51
+
52
+ The above copyright notice and this permission notice shall be included in all
53
+ copies or substantial portions of the Software.
54
+
55
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
56
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
57
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
58
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
59
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
60
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
61
+ SOFTWARE.
62
+ ```
@@ -0,0 +1,85 @@
1
+ import { parseArgs } from "node:util";
2
+ import { z } from "zod";
3
+ const defaultVoiceId = "JBFqnCBsd6RMkjVDRZzb";
4
+ const cliArgsSchema = z.object({
5
+ positionals: z.array(z.string()).max(1, "Only one project path or URL can be provided"),
6
+ port: z
7
+ .string()
8
+ .regex(/^\d+$/, "Port must be an integer between 0 and 65535")
9
+ .transform(Number)
10
+ .pipe(z.number().int().min(0).max(65535))
11
+ .optional(),
12
+ noOpen: z.boolean(),
13
+ provider: z
14
+ .enum(["fake"], { error: (issue) => `Unknown provider: ${String(issue.input)}` })
15
+ .optional(),
16
+ recording: z.string().min(1).optional(),
17
+ voice: z
18
+ .enum(["browser", "elevenlabs"], { error: (issue) => `Unknown voice: ${String(issue.input)}` })
19
+ .optional(),
20
+ voiceId: z.string().min(1, "--voice-id must not be empty").optional(),
21
+ model: z
22
+ .string()
23
+ .regex(/^\w[\w.-]*$/, "--model must be a model name such as sonnet")
24
+ .optional(),
25
+ bruteForceTokens: z
26
+ .string()
27
+ .regex(/^[1-9]\d*$/, "--brute-force-tokens must be a positive integer")
28
+ .transform(Number)
29
+ .optional(),
30
+ });
31
+ export function parseCliArgs(args) {
32
+ const parsed = parseArgs({
33
+ args,
34
+ options: {
35
+ port: { type: "string" },
36
+ "no-open": { type: "boolean" },
37
+ provider: { type: "string" },
38
+ recording: { type: "string" },
39
+ voice: { type: "string" },
40
+ "voice-id": { type: "string" },
41
+ "brute-force-tokens": { type: "string" },
42
+ model: { type: "string" },
43
+ },
44
+ allowPositionals: true,
45
+ strict: true,
46
+ });
47
+ const validated = cliArgsSchema.safeParse({
48
+ positionals: parsed.positionals,
49
+ port: parsed.values.port,
50
+ noOpen: parsed.values["no-open"] ?? false,
51
+ provider: parsed.values.provider,
52
+ recording: parsed.values.recording,
53
+ voice: parsed.values.voice,
54
+ voiceId: parsed.values["voice-id"],
55
+ bruteForceTokens: parsed.values["brute-force-tokens"],
56
+ model: parsed.values.model,
57
+ });
58
+ if (!validated.success) {
59
+ throw new Error(validated.error.issues.map((issue) => issue.message).join("\n"));
60
+ }
61
+ const { provider, recording } = validated.data;
62
+ if (provider === "fake" && !recording) {
63
+ throw new Error("--provider fake needs --recording <file>");
64
+ }
65
+ if (recording && provider !== "fake") {
66
+ throw new Error("--recording only works with --provider fake");
67
+ }
68
+ return {
69
+ path: validated.data.positionals[0] ?? ".",
70
+ port: validated.data.port ?? 0,
71
+ noOpen: validated.data.noOpen,
72
+ provider,
73
+ recording,
74
+ voice: validated.data.voice ?? "browser",
75
+ voiceId: validated.data.voiceId ?? defaultVoiceId,
76
+ bruteForceTokens: validated.data.bruteForceTokens,
77
+ model: validated.data.model,
78
+ };
79
+ }
80
+ export function speechOptions(options, env) {
81
+ return {
82
+ apiKey: options.voice === "elevenlabs" ? env.ELEVENLABS_API_KEY : undefined,
83
+ voiceId: options.voiceId,
84
+ };
85
+ }
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { randomBytes } from "node:crypto";
4
+ import { realpathSync } from "node:fs";
5
+ import { readFile } from "node:fs/promises";
6
+ import { createServer } from "node:http";
7
+ import { resolve } from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+ import { parseCliArgs, speechOptions } from "./cli-args.js";
10
+ import { createAskHandler } from "./http/ask-route.js";
11
+ import { createFileHandler } from "./http/file-route.js";
12
+ import { serveRequest } from "./http/serve.js";
13
+ import { createSpeechHandler } from "./http/speech-route.js";
14
+ import { createTranscribeHandler } from "./http/transcribe-route.js";
15
+ import { claudeHeadless } from "./llm/claude-headless.js";
16
+ import { loadFakeProvider } from "./llm/fake-provider.js";
17
+ import { runMcpCommand } from "./mcp/server.js";
18
+ import { openGitClone } from "./source/git-clone.js";
19
+ import { openLocalFolder } from "./source/local-folder.js";
20
+ import { projectName } from "./source/project-name.js";
21
+ export function closeServer(server, source) {
22
+ return new Promise((resolveClose) => {
23
+ server.close(() => resolveClose());
24
+ server.closeAllConnections();
25
+ }).then(() => source.dispose());
26
+ }
27
+ function openBrowser(url) {
28
+ const [command, args] = process.platform === "win32"
29
+ ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
30
+ : process.platform === "darwin"
31
+ ? ["open", [url]]
32
+ : ["xdg-open", [url]];
33
+ try {
34
+ const browser = spawn(command, args, { shell: false, detached: true, stdio: "ignore" });
35
+ let failed = false;
36
+ const reportFailure = () => {
37
+ if (failed)
38
+ return;
39
+ failed = true;
40
+ process.stderr.write(`Open ${url} in your browser\n`);
41
+ };
42
+ browser.once("error", reportFailure);
43
+ browser.once("exit", (code) => {
44
+ if (code !== 0)
45
+ reportFailure();
46
+ });
47
+ browser.unref();
48
+ }
49
+ catch {
50
+ process.stderr.write(`Open ${url} in your browser\n`);
51
+ }
52
+ }
53
+ async function runCli() {
54
+ const [subcommand, ...subcommandArgs] = process.argv.slice(2);
55
+ if (subcommand === "mcp") {
56
+ await runMcpCommand(subcommandArgs);
57
+ return;
58
+ }
59
+ let options;
60
+ try {
61
+ options = parseCliArgs(process.argv.slice(2));
62
+ }
63
+ catch (error) {
64
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
65
+ process.exitCode = 1;
66
+ return;
67
+ }
68
+ if (options.voice === "elevenlabs" && !process.env.ELEVENLABS_API_KEY) {
69
+ process.stderr.write("Set ELEVENLABS_API_KEY to use --voice elevenlabs\n");
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+ const interrupted = new AbortController();
74
+ process.on("SIGINT", () => interrupted.abort());
75
+ process.on("SIGTERM", () => interrupted.abort());
76
+ const source = /^[a-z][a-z0-9+.-]*:\/\//i.test(options.path)
77
+ ? await openGitClone(options.path, interrupted.signal)
78
+ : await openLocalFolder(options.path);
79
+ if (!source.ok) {
80
+ process.stderr.write(`${source.error}\n`);
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+ let provider = claudeHeadless(options.model);
85
+ if (options.provider === "fake" && options.recording) {
86
+ const fake = await loadFakeProvider(resolve(options.recording));
87
+ if (!fake.ok) {
88
+ process.stderr.write(`${fake.error}
89
+ `);
90
+ await source.value.dispose();
91
+ process.exitCode = 1;
92
+ return;
93
+ }
94
+ provider = fake.value;
95
+ }
96
+ const handleAsk = createAskHandler({
97
+ root: source.value.root,
98
+ provider,
99
+ bruteForceTokens: options.bruteForceTokens,
100
+ });
101
+ const handleFile = createFileHandler(source.value.root);
102
+ const speech = speechOptions(options, process.env);
103
+ const handleSpeech = createSpeechHandler(speech);
104
+ const handleTranscribe = createTranscribeHandler({ apiKey: speech.apiKey });
105
+ const webRoot = resolve(import.meta.dirname, "../../dist/web");
106
+ let page;
107
+ const sessionToken = randomBytes(32).toString("hex");
108
+ try {
109
+ const template = await readFile(resolve(webRoot, "index.html"), "utf8");
110
+ page = template.replace("</head>", `<meta name="miyagi-token" content="${sessionToken}"><meta name="miyagi-voice" content="${options.voice}"><meta name="miyagi-project" content="${encodeURIComponent(projectName(options.path, source.value.root))}"></head>`);
111
+ }
112
+ catch (error) {
113
+ if (typeof error === "object" &&
114
+ error !== null &&
115
+ "code" in error &&
116
+ (error.code === "ENOENT" || error.code === "ENOTDIR")) {
117
+ process.stderr.write("Run npm run build first\n");
118
+ }
119
+ else {
120
+ process.stderr.write(`Could not read built page: ${error instanceof Error ? error.message : String(error)}\n`);
121
+ }
122
+ await source.value.dispose();
123
+ process.exitCode = 1;
124
+ return;
125
+ }
126
+ let serverPort;
127
+ const server = createServer((request, response) => {
128
+ void serveRequest(request, response, webRoot, page, sessionToken, serverPort, handleAsk, handleFile, handleSpeech, handleTranscribe).catch((error) => {
129
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
130
+ if (!response.headersSent)
131
+ response.writeHead(500);
132
+ response.end();
133
+ });
134
+ });
135
+ try {
136
+ await new Promise((resolveListen, rejectListen) => {
137
+ server.once("error", rejectListen);
138
+ server.listen(options.port, "127.0.0.1", resolveListen);
139
+ });
140
+ }
141
+ catch (error) {
142
+ process.stderr.write(`Could not start server: ${error instanceof Error ? error.message : String(error)}\n`);
143
+ await source.value.dispose();
144
+ process.exitCode = 1;
145
+ return;
146
+ }
147
+ const address = server.address();
148
+ if (!address || typeof address === "string") {
149
+ process.stderr.write("Could not determine server address\n");
150
+ await source.value.dispose();
151
+ server.close();
152
+ process.exitCode = 1;
153
+ return;
154
+ }
155
+ serverPort = address.port;
156
+ const url = `http://127.0.0.1:${address.port}/`;
157
+ let closing = false;
158
+ const shutdown = () => {
159
+ if (closing)
160
+ return;
161
+ closing = true;
162
+ void closeServer(server, source.value).then(() => {
163
+ process.exitCode = 0;
164
+ }, (error) => {
165
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
166
+ process.exitCode = 1;
167
+ });
168
+ };
169
+ interrupted.signal.addEventListener("abort", shutdown, { once: true });
170
+ if (interrupted.signal.aborted)
171
+ shutdown();
172
+ process.stdout.write(`${url}\n`);
173
+ if (!options.noOpen)
174
+ openBrowser(url);
175
+ }
176
+ function startedAsCli() {
177
+ try {
178
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1] ?? "")).href;
179
+ }
180
+ catch {
181
+ return false;
182
+ }
183
+ }
184
+ if (startedAsCli()) {
185
+ void runCli();
186
+ }
@@ -0,0 +1,108 @@
1
+ import { readRedactedFile } from "../files/path-guard.js";
2
+ import { parseFile } from "./parse-cache.js";
3
+ import { uniqueDefinition } from "./symbols.js";
4
+ const maxBlockLines = 40;
5
+ const statementOrDeclaration = /(_statement|_declaration|_definition|_signature)$/;
6
+ function block(file, start, end, line = start) {
7
+ const shownStart = Math.max(start, line - maxBlockLines + 1);
8
+ return {
9
+ ok: true,
10
+ value: {
11
+ file,
12
+ start: shownStart,
13
+ end: Math.min(end, shownStart + maxBlockLines - 1),
14
+ capped: end - start + 1 > maxBlockLines,
15
+ },
16
+ };
17
+ }
18
+ function collapsed(text) {
19
+ return text.replace(/\s+/g, " ").trim();
20
+ }
21
+ function nearestLine(lines, line, matches) {
22
+ let nearest;
23
+ lines.forEach((candidate, index) => {
24
+ const number = index + 1;
25
+ if (matches(candidate) &&
26
+ (nearest === undefined || Math.abs(number - line) < Math.abs(nearest - line))) {
27
+ nearest = number;
28
+ }
29
+ });
30
+ return nearest;
31
+ }
32
+ function nearestMatchingLine(lines, line, text) {
33
+ const wanted = collapsed(text);
34
+ return (nearestLine(lines, line, (candidate) => collapsed(candidate) === wanted) ??
35
+ (wanted.length >= 8
36
+ ? nearestLine(lines, line, (candidate) => collapsed(candidate).includes(wanted))
37
+ : undefined));
38
+ }
39
+ function declarationPatterns(name) {
40
+ const word = `(?<![\\w$])${name.replaceAll("$", "\\$")}(?![\\w$])`;
41
+ const modifiers = "(export\\s+|async\\s+|static\\s+|public\\s+|private\\s+|protected\\s+)*";
42
+ return [
43
+ new RegExp(`\\b(function|class|interface|type|enum|const|let|var|def|fn|func)\\s+${word}`),
44
+ new RegExp(`^\\s*${modifiers}${word}\\s*[(<].*(\\{|=>)\\s*$`),
45
+ new RegExp(`${word}\\s*[:=]\\s*(async\\s*)?(\\(|function\\b)`),
46
+ new RegExp(`^(?!\\s*import\\b).*${word}`),
47
+ ];
48
+ }
49
+ function declarationLine(lines, symbol) {
50
+ const names = symbol.split(".");
51
+ if (!names.every((name) => /^[\w$]+$/.test(name)))
52
+ return undefined;
53
+ let from = 0;
54
+ if (names.length > 1) {
55
+ const [owner] = declarationPatterns(names.at(-2) ?? "");
56
+ from = lines.findIndex((candidate) => owner?.test(candidate));
57
+ if (from === -1)
58
+ return undefined;
59
+ }
60
+ for (const pattern of declarationPatterns(names.at(-1) ?? "")) {
61
+ const index = lines.findIndex((candidate, at) => at >= from && pattern.test(candidate));
62
+ if (index !== -1)
63
+ return index + 1;
64
+ }
65
+ return undefined;
66
+ }
67
+ function enclosingStatement(tree, text, line) {
68
+ const row = line - 1;
69
+ let node = tree.rootNode.descendantForPosition({ row, column: text.length - text.trimStart().length }, { row, column: text.trimEnd().length });
70
+ while (node && !statementOrDeclaration.test(node.type))
71
+ node = node.parent;
72
+ return node;
73
+ }
74
+ export async function resolveAnchor(root, anchor) {
75
+ const file = await readRedactedFile(root, anchor.file);
76
+ if (!file.ok)
77
+ return file;
78
+ const { relPath, source } = file.value;
79
+ const tree = await parseFile(relPath, source);
80
+ if ("symbol" in anchor) {
81
+ const symbol = anchor.symbol.trim();
82
+ const found = tree ? uniqueDefinition(tree.rootNode, relPath, symbol) : undefined;
83
+ if (found?.ok) {
84
+ const { startPosition, endPosition } = found.value.node;
85
+ return block(relPath, startPosition.row + 1, endPosition.row + 1);
86
+ }
87
+ const lines = source.split("\n");
88
+ const line = declarationLine(lines, symbol);
89
+ if (line === undefined) {
90
+ return found ?? { ok: false, error: `Symbol not found in ${relPath}: ${symbol}` };
91
+ }
92
+ const statement = tree && enclosingStatement(tree, lines[line - 1] ?? "", line);
93
+ return statement
94
+ ? block(relPath, statement.startPosition.row + 1, statement.endPosition.row + 1, line)
95
+ : block(relPath, line, line);
96
+ }
97
+ const text = anchor.text.trim();
98
+ if (!text)
99
+ return { ok: false, error: "Anchor text is empty" };
100
+ const lines = source.split("\n");
101
+ const line = nearestMatchingLine(lines, anchor.line, text);
102
+ if (line === undefined)
103
+ return { ok: false, error: `Text not found in ${relPath}: ${text}` };
104
+ const statement = tree && enclosingStatement(tree, lines[line - 1] ?? "", line);
105
+ return statement
106
+ ? block(relPath, statement.startPosition.row + 1, statement.endPosition.row + 1, line)
107
+ : block(relPath, line, line);
108
+ }
@@ -0,0 +1,69 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createRequire } from "node:module";
3
+ import { extname } from "node:path";
4
+ import { Language, Parser } from "web-tree-sitter";
5
+ const require = createRequire(import.meta.url);
6
+ const grammarPaths = {
7
+ typescript: require.resolve("tree-sitter-typescript/tree-sitter-typescript.wasm"),
8
+ tsx: require.resolve("tree-sitter-typescript/tree-sitter-tsx.wasm"),
9
+ javascript: require.resolve("tree-sitter-javascript/tree-sitter-javascript.wasm"),
10
+ };
11
+ const treeCache = new Map();
12
+ const languageCache = new Map();
13
+ let parserInitialization;
14
+ export const grammarsByExtension = {
15
+ ".ts": "typescript",
16
+ ".tsx": "tsx",
17
+ ".js": "javascript",
18
+ ".jsx": "javascript",
19
+ ".mts": "typescript",
20
+ ".cts": "typescript",
21
+ ".mjs": "javascript",
22
+ ".cjs": "javascript",
23
+ };
24
+ function grammarFor(path) {
25
+ return grammarsByExtension[extname(path)] ?? null;
26
+ }
27
+ export function hasParser(path) {
28
+ return grammarFor(path) !== null;
29
+ }
30
+ async function loadLanguage(grammar) {
31
+ let language = languageCache.get(grammar);
32
+ if (!language) {
33
+ language = Language.load(grammarPaths[grammar]);
34
+ languageCache.set(grammar, language);
35
+ }
36
+ return language;
37
+ }
38
+ async function parse(source, grammar) {
39
+ parserInitialization ??= Parser.init();
40
+ await parserInitialization;
41
+ const parser = new Parser();
42
+ try {
43
+ parser.setLanguage(await loadLanguage(grammar));
44
+ const tree = parser.parse(source);
45
+ if (tree === null)
46
+ throw new Error("Tree-sitter returned no parse tree");
47
+ return tree;
48
+ }
49
+ finally {
50
+ parser.delete();
51
+ }
52
+ }
53
+ export async function parseFile(path, source) {
54
+ const grammar = grammarFor(path);
55
+ if (grammar === null)
56
+ return null;
57
+ const hash = createHash("sha256").update(source).digest("hex");
58
+ const key = `${grammar}:${hash}`;
59
+ const cachedTree = treeCache.get(key);
60
+ if (cachedTree)
61
+ return cachedTree;
62
+ const pendingTree = parse(source, grammar).catch((error) => {
63
+ if (treeCache.get(key) === pendingTree)
64
+ treeCache.delete(key);
65
+ throw error;
66
+ });
67
+ treeCache.set(key, pendingTree);
68
+ return pendingTree;
69
+ }