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.
- package/LICENSE +661 -0
- package/README.md +42 -2
- package/THIRD_PARTY_NOTICES.md +62 -0
- package/dist/server/cli-args.js +85 -0
- package/dist/server/cli.js +186 -0
- package/dist/server/code/anchor-resolver.js +108 -0
- package/dist/server/code/parse-cache.js +69 -0
- package/dist/server/code/repo-map.js +227 -0
- package/dist/server/code/symbols.js +149 -0
- package/dist/server/files/file-filter.js +171 -0
- package/dist/server/files/path-guard.js +71 -0
- package/dist/server/files/redact.js +46 -0
- package/dist/server/http/ask-route.js +85 -0
- package/dist/server/http/file-route.js +14 -0
- package/dist/server/http/serve.js +101 -0
- package/dist/server/http/speech-route.js +53 -0
- package/dist/server/http/transcribe-route.js +53 -0
- package/dist/server/llm/ask.js +102 -0
- package/dist/server/llm/claude-headless.js +16 -0
- package/dist/server/llm/claude-process.js +206 -0
- package/dist/server/llm/claude-stream.js +161 -0
- package/dist/server/llm/conversation.js +21 -0
- package/dist/server/llm/fake-provider.js +73 -0
- package/dist/server/llm/prompt.js +84 -0
- package/dist/server/llm/provider.js +1 -0
- package/dist/server/llm/selection.js +21 -0
- package/dist/server/log.js +10 -0
- package/dist/server/mcp/server.js +70 -0
- package/dist/server/source/git-clone.js +80 -0
- package/dist/server/source/github-url.js +18 -0
- package/dist/server/source/local-folder.js +19 -0
- package/dist/server/source/project-name.js +6 -0
- package/dist/server/tools/find-symbol.js +31 -0
- package/dist/server/tools/grep.js +134 -0
- package/dist/server/tools/read-file.js +54 -0
- package/dist/server/tools/run-tool.js +18 -0
- package/dist/shared/anchor.js +14 -0
- package/dist/shared/events.js +26 -0
- package/dist/shared/file.js +3 -0
- package/dist/shared/llm-request.js +10 -0
- package/dist/shared/parse.js +7 -0
- package/dist/shared/phrases.js +1 -0
- package/dist/shared/result.js +7 -0
- package/dist/shared/tool-inputs.js +30 -0
- package/dist/shared/tool-result.js +2 -0
- package/dist/shared/tour.js +25 -0
- package/dist/shared/transcript.js +2 -0
- package/dist/shared/untrusted.js +4 -0
- package/dist/shared/usage.js +9 -0
- package/dist/web/assets/index-Baler-BH.css +1 -0
- package/dist/web/assets/index-NFl7WrKD.js +112 -0
- package/dist/web/index.html +13 -0
- package/package.json +63 -4
package/README.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Ask Miyagi
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|