diff --git a/AGENTS.md b/AGENTS.md index c8d71286..e3168609 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,11 +133,14 @@ src/host/** VS Code adapters (views, conversation, backend managers, when that backend first starts) and the search worker, commands, auth, settings, mentions, editor tracking, usage trace logs, voice, the diagnostics - MCP server, the MCP servers' spawner, the network posture) + MCP server and its code intelligence tools, VS Code's + language services, the MCP servers' spawner, the network + posture) src/core/** backend-agnostic logic; must not import `vscode` (MSP host, Model API client and tools, the MCP client, context, Muse Code's memory, export, worktrees, usage, - dictation, Muse Voice, the paid gate, network failures) + dictation, Muse Voice, the paid gate, network failures, + code intelligence and the repo map) src/acp/** the ACP agent (D62): the ACP side of a session and the translation of the engine's events; must not import `vscode` @@ -162,7 +165,9 @@ test/unit/** vitest (node + jsdom via docblock); `vscode` is mocked test/e2e/** the fake Muse Code CLI driven through the real backend; the opt-in live drills (the Muse Code CLI; the Model API sweep, which bills the owner's key) -test/integration/** @vscode/test-cli, runs inside VS Code +test/integration/** @vscode/test-cli, runs inside VS Code, over the workspace + test/fixtures/workspace (code-intel/ is a TypeScript + project its language service reads) test/harness/ the webview behind a fake host, for screenshots and the accessibility gate; themes/ holds VS Code's four themes test/hosts/ the extension and the ACP agent in other editors diff --git a/CHANGELOG.md b/CHANGELOG.md index b5b8e292..691125a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,52 @@ happened, not what was planned; superseded entries are kept. ### Added +- **Code intelligence** (M67, PLAN.md D49): the agent finds definitions, + references and symbols the way the editor does, from VS Code's own + language services, instead of searching text. `find_definition`, + `find_references`, `workspace_symbols`, `document_symbols`, `hover`, + `call_hierarchy` and `repo_map` are reads in every mode on the Model API + backend, Plan and Restricted Mode included; a symbol is named by path, + line and column, by its name on a line or in a file, or by name alone. + Answers are workspace-relative, sorted and capped, and say how many + results outside the workspace (a library's declarations, another folder) + they left out; a hover for a symbol defined only outside the workspace + (and outside the languages' own libraries) is held back. A file whose + language has no service, or that declares nothing, says so instead of + answering nothing, and an empty answer says that not every language + provides every kind. In a file with unsaved changes a line number is + refused and a name is found in the editor's text, said so, the editor + matched by the file's real path (a workspace opened through a link). A + call hierarchy names the file of each outgoing call site, and counts the + other functions a position names (overloads) that it did not ask. On the Muse + Code backend the same tools are served to Muse Code as + `mcp__ide__findDefinition` and the rest, each marked read-only; Muse Code + 1.4.0 still shows its own card for them in its on-request mode. +- **`rename_symbol`** renames a symbol everywhere it is used. On the Model + API backend it is an edit: the card names its files, a protected one + first (and is a protected write when one of them is); every file is + checked again after the card and once more right before its own write, + and Stop before the first write writes nothing; the row carries one + patch, so Revert and rewind undo it, a rename stopped partway included. + It refuses an edit that also creates, moves or deletes files, and one + whose ranges no longer cover the old name (made from an older version of + a file). A hook matching `Edit` runs for it, with the files it would + write, and the rename writes the plan the hook was shown. On Muse Code it changes nothing and hands Muse Code the diff to + apply with its own edit tool. +- **A repo map in the Model API's prompt** (`museSpark.modelApiRepoMap`, + off by default, machine-scoped, trusted workspaces only): the files other + files use most, with their most used definitions, within about 1,000 + tokens. It is kept once made, tried again on a later turn when a try finds + nothing (three tries at most), and shared with child tasks and forks. It + adds those tokens to every request, its heading and notes counted in + the budget. `repo_map` gives the same map on request either way, all of + its text within `max_tokens`; a budget too small for its own lead and + notes is refused with the size that would do. - **Groundwork for editors other than VS Code** (M60, M61, PLAN.md D60). The owner's IDE compatibility plan is filed in `docs/ide-compatibility.md`. A new gate, `npm run check:host-api`, keeps a record of what the extension asks of its host (`docs/ide-compatibility/host-api.md`): the - 201 VS Code APIs it uses and where, the 13 files that import `vscode`, + initial 201 VS Code APIs and where, the 13 files that imported `vscode`, the Node built-ins, and what the webview needs (`acquireVsCodeApi` and 57 theme variables); it fails when the record goes stale, and when the engine, the protocol, the webview, the conversation controller, either @@ -158,6 +199,9 @@ happened, not what was planned; superseded entries are kept. ### Fixed +- **Stop still prevents a rename's first write during its final file check.** + A cancellation after approval is checked again after the awaited recheck. + Once a write starts, the remaining rename keeps its existing completion path. - **ACP paid-grant race tests use canonical temporary paths.** Filesystem aliases on macOS and Windows no longer leave the test waiting for a rename under a different name. Native path resolution also expands Windows 8.3 diff --git a/PLAN.md b/PLAN.md index 026be111..75cfde86 100644 --- a/PLAN.md +++ b/PLAN.md @@ -3077,6 +3077,8 @@ modelApi` (the key of D61). There is no "auto", so the bill is never a | Q7 | **Resolved 2026-09-22:** owner pressed F5 and confirmed the Muse Spark chat shell renders in the Extension Development Host (verbal confirmation; no screenshot filed). | Closed. | | Q8 | **Resolved 2026-09-22:** owner signed in; publisher is `RandyNorthrup`. Publishing ran by hand from the CI artifact with a clipboard PAT for 0.1.0–0.5.0; since 2026-09-23 the `VSCE_PAT` repository secret lets `release.yml` publish every `v*` tag. | Closed. | | Q9 | The Muse Code user rules file: `/rules import` writes one into the config root and the model is told "if user and project rules conflict, project rules win", but its file name is not printed by `muse --help`, `muse skills`, the settings skill or the binary's strings. The Model API backend cannot mirror what it cannot name. | Not loaded on the Model API backend; the CLI backend loads it itself. | +| Q10 | M67's repo map on Muse Code: the plan asks for it "as an opt-in section of the system prompt", but Muse Code's instructions are its own (D13: nothing installed into its folders). It could ride as a hidden note on the first turn of a conversation (as the question-card hint does), billed to the subscription as prompt tokens. Wanted? | The `repoMap` tool only; no note in Muse Code turns. | +| Q11 | M67's prompt repo map setting: its name (`museSpark.modelApiRepoMap`), its default (off, since every request pays its tokens) and its fixed ~1,000-token budget, and whether the model should see the map by default once the M75 evaluation measures it. | Off by default, machine-scoped, 1,024 tokens, no budget setting. | | Q60 | **Answered 2026-09-26:** the owner set up the Open VSX account: the Eclipse Publisher Agreement signed, the namespace `RandyNorthrup` created, the token in `OVSX_PAT`. The release workflow publishes there from the next tag (M62). | | Q61 | **Resolved 2026-09-26:** the owner approved the ACP SDK. `@agentclientprotocol/sdk` 1.4.0 is pinned: 1.5.0 (2026-09-21) is inside `.npmrc`'s 7-day `min-release-age`, and 1.4.0 speaks the same ACP v1 (D62). | | Q62 | **Resolved 2026-09-26:** "you can install whatever you need". What this container's network lets in is recorded per editor (D62); the rest is qualified in CI or on the owner's machines. | @@ -6878,6 +6880,12 @@ harness scenario, which is what the accessibility gate checks (D32). ### M67 — Code intelligence tools (D49) +**Integration review 2026-09-29:** PR #57's candidate is being joined with +PR #32 before final gates. A Stop during the last awaited check of the first +rename file could still write; the first-write boundary now rechecks cancellation. +The two held-check regression cases, independent review and final local rig +gates are tracked in `docs/certification/m67.md`; no merged status is claimed. + - **Goal.** The model finds definitions, references and symbols the way an IDE does, instead of grepping. - **Scope.** @@ -6913,6 +6921,152 @@ harness scenario, which is what the accessibility gate checks (D32). - **Tests.** A fake language-service host in unit tests. An integration test in VS Code over a TypeScript fixture. - **Size.** M. +- **As built (2026-09-28).** Decisions taken while building, each open to + the owner's review: + - **One core, two surfaces.** `src/core/codeIntel/` holds the tools + (confinement, placing, capping, the repo map, the rename plan) over a + `LanguageServiceHost` interface; `src/host/codeIntel/languageServices.ts` + implements it with VS Code's `vscode.execute…Provider`, + `vscode.prepareCallHierarchy`/`provide…Calls`, `vscode.prepareRename` + and `vscode.executeDocumentRenameProvider` commands. The Model API + offers `find_definition`, `find_references`, `workspace_symbols`, + `document_symbols`, `hover`, `call_hierarchy`, `repo_map`, + `rename_symbol`; the `ide` server offers the same as `findDefinition`, + …, `renameSymbol` (the camel case of `getDiagnostics`). The core stays + outside `src/core/backends/modelapi/**`, since the activation bundle + carries it for the `ide` tools. + - **Naming a symbol.** Path, line and column (1-based); path, line and + name (its first whole-word use on the line); path and name (its first + use in the file); or name alone, looked up among the workspace symbols + (exact name, TypeScript's `greet()` read as `greet`; the first in place + order is used and the others listed). + - **"No language service".** VS Code exposes no way to ask whether a + provider exists, and its commands answer an empty list either way. An + empty answer is therefore checked against the file's document symbols: + none means "no language service answered for (language )", + worded to allow for a file that declares nothing; some means "No + at ". Workspace symbols have no file to check, so an + empty answer says that they come from the languages' services and that + TypeScript's needs a project file open. + - **Placing results.** A result is in the workspace when its path is + (textual and canonical confinement, D24) or when its real path is under + the root's real path (a workspace opened through a link, whose files a + server reports by real path). Everything else, including virtual + documents, is left out and counted. Lines come from the disk. + - **Caps.** 100 locations, 200 symbols, 50 callers or callees with five + call sites each, 4,000 characters of hover, three outline levels; a + 20-second deadline per language-service call. + - **Rename.** Planned before the card from VS Code's rename edit: every + file must be placed in the workspace (any outside refuses the whole + rename), at most 200 files, no file operations, no unsaved changes, and + the document's text equal to the disk's (BOM aside), so the edit lands + where the service meant it. On the Model API the card is a `fileWrite` + naming up to five files and counting the rest, protected when any file + is (D24); after approval every file is confined and read again and + nothing is written unless each is unchanged; files are written with the + tools' atomic write, one patch across them (per-line hunks, which Edit + Review's revert undoes), and the fingerprints `write_file` checks are + updated. A failed write stops the rest and names what was written. + Plan refuses a rename before the language service is asked. On `ide` + the tool returns a unified diff and writes nothing (read-only). The + file tools' one-hunk patch (D27's `hunkBetween`) moved beside the + rename's hunks as `changeHunk` in `codeText.ts`, unchanged, so the two + share one implementation (the duplication gate). + - **Repo map.** Aider's idea over VS Code's services: names of three + characters or more are counted in the text of up to 1,000 listed files + (128 KiB each, read confined); the 300 names used by the most files are + looked up as workspace symbols, eight at a time, within 10 seconds (5 for + the prompt); each file scores the uses of its names by other files, + shared among a name's definers. Document symbols per file were rejected: + opening every file would make VS Code open each document for every + extension (a linter lints them all). The prompt section is opt in + (`museSpark.modelApiRepoMap`, machine-scoped since it bills prompt + tokens), made on the first turn that has it on and kept for the session + so the prompt's prefix stays cached (a Stop ends it at once, and a map + cut short that way is not kept); Muse Code's instructions are its own, + so there it is the `repoMap` tool only. + - **Annotations.** `McpTool` gained `annotations` (as M69 adds it); + `getDiagnostics` and every code intelligence tool declare + `readOnlyHint: true`, and all are listed in Restricted Mode. + - **Tests.** `codeIntelTools`, `codeText`, `renamePlan`, `repoMap`, + `modelApiCodeIntel`, `ideCodeIntelTools`, `languageServices` (the + adapter over the `vscode` mock) and `toolPresentation`; + `test/integration/codeIntel.test.ts` over `test/fixtures/workspace/ +code-intel` on VS Code stable and 1.125.0; live case19 of the Model + API sweep; the `code-intel` harness scenario. + - **Review round (after `c5c4045b`).** Three class reviews; every + finding fixed in one commit on a merge of `origin/main` (PR #50): + - A rename's edit ranges must each cover exactly the old name (the + text the edit at the position replaces, read from the text the + position came from), and that file must still be that text: an edit + the service computed on an older version is refused, never applied. + - File operations: VS Code's `WorkspaceEdit.entries()` lists only text + edits and `size` counts them (read from the extension hosts of 1.99.0, + 1.125.0 and 1.139.0), so the old check never fired. The adapter reads the + internal `_allEntries()` through zod (`_type` 2 is a text edit, 1 a + file operation); a missing or changed list is `unknown`, refused + (§8 records the undocumented member). + - Writing: every file is checked again after the card, then each once + more right before its own write; a change found partway stops with a + revertable patch of what was written. Stop before the first write + writes nothing; once writing starts the rest follow. A failed + `rename_symbol` row with a patch keeps review and rewind + (`PARTIAL_EDIT_TOOLS`, `hasLandedEdits`). + - The prompt's repo map: trusted workspaces only; only a map with text + is kept, a try that fails or comes out empty counts, three at most + (`REPO_MAP_PROMPT_TRIES`), and a Stop does not count; child tasks and + forks use the conversation's. `Limits` takes its Stop listener off, + begins no work past the budget, and holds the file listing to it; the + map says how many files it read. + - Unsaved files: a line number in one is refused; lines shown are the + editor's, and the answer says so. + - Hover: held back when every definition is outside the workspace and + outside the languages' libraries (VS Code's `appRoot` and each + extension's folder, `LanguageServiceHost.libraryRoots`). Types that + flow from outside files into workspace symbols remain (§9). + - Hooks: `rename_symbol` matches `Edit`, and a matching `PreToolUse` + hook gets the planned `files` (planned for it alone, only when one + would run and the mode allows edits). + - Wording: "no language service" allows a file that declares nothing; + an empty answer says the language may lack that provider; the same + name, a change after the plan, the header's file name. A rename named + by symbol alone has no file button. The card names protected files + first. + - Captures: live case19 again with a stand-in that skips string + literals (4 references, 4 edits, the import asserted); and Muse Code + 1.4.0-R4302.1 calling `mcp__ide__findReferences` in on-request mode + (4 model attempts): it listed our tools with annotations, asked its + own card for a `readOnlyHint` tool, and showed our text verbatim. + The README's "reads in every mode" is the Model API's alone. + - **Second review (Grok Build on `585af100`).** One P1, five P2; all + held on inspection and are fixed in one commit: + - Unsaved changes by real path: `ToolIo.unsavedFiles()` lists the + editors' files, and `unsavedDocumentPath` matches one to a file by its + own path, the service's, or its real path (a workspace opened through + a link names files by the link in the editor and by the real path in + the language service). The plan, the recheck before each write, the + lines shown and the target all use it; a target is asked and read at + the editor's own path. + - Repo map: a batch the time or a Stop cuts off is dropped whole, so + nothing it finds later reaches the map or its counts; "no language + service" only when every lookup ran and none found anything, a cut + map being partial. + - A rename planned for its `PreToolUse` hooks is the plan written; a + hook's new arguments plan afresh. + - Call hierarchy: outgoing call sites name the file of the function + asked about; of several items at a position the one declared there is + asked, and the answer counts the others. + - **Codex on PR #57.** Two P2s, both held and fixed in one commit: + - The repo map counts every line it renders against its budget (the + lead, the count of files left out, the notes, and the prompt + section's heading); `repo_map` refuses a `max_tokens` too small for + its own fixed text and names the size that would do. + - `document_symbols` opens and outlines the file at the path of an + editor holding unsaved changes to it (`openAsEdited`), as the other + tools do; no other tool opened a named file directly. +- **Status.** Built on `feature/m67-code-intel` (2026-09-28); reviewed and + fixed the same day. Drills and the live checks in + `docs/certification/m67.md`. ### M68 — Verify loop (D49) @@ -7961,24 +8115,34 @@ remain available. Every lint or scanner suppression (`eslint-disable`, `@ts-expect-error`, `nosemgrep`), every cast the compiler cannot verify, and every error swallowed inside generated shell, C# or Swift must be listed here with its reason. A TypeScript `catch {}` needs only an inline comment saying why the error is dropped. -| File | Construct | Reason | Added | -| ------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -| `src/host/backend/toolIo.ts` | `nosemgrep` on `spawn` (`detect-child-process`) | The command line is the tool's payload by design: the user approved it on a card, and it runs through PowerShell / bash as an argument array, never a shell string. | 2026-09-22 | -| `src/host/backend/searchWorker.ts` | `nosemgrep` on `new RegExp(pattern)` (`detect-non-literal-regexp`) | The model's search pattern is evaluated on a worker thread that `toolIo.searchOnWorker` terminates at `SEARCH_TIMEOUT_MS`, and the pattern is capped at `SEARCH_PATTERN_MAX_LENGTH`; a runaway match cannot hang the host. | 2026-09-22 | -| `src/host/voice/dictationHost.ts` | `nosemgrep` on two `spawn` calls (`detect-child-process`) | The dictation and capture helpers' command lines are fixed by `helperLocation.ts` (Windows PowerShell under `%SystemRoot%` with a bundled script, or the bundled macOS binary with VS Code's own app name (`--app-name`)); M35's Linux recorder is `arecord` or `parec` found by absolute path on PATH, with fixed arguments. Argument arrays; no user, model or workspace input reaches them. | 2026-09-25 | -| `native/darwin/Dictation.swift` | `unsafeBitCast(symbol, to: SetDisclaim.self)` | `responsibility_spawnattrs_setdisclaim` is a private libsystem call with no header, so it is resolved with `dlsym` and cast to its C signature, `int (posix_spawnattr_t *, int)`, the one Chromium and Qt declare (M28). A missing symbol is handled before the cast (the helper then asks as before); the signature has been stable since macOS 10.14. | 2026-09-23 | -| `src/host/backend/shellJob.ts` | `catch { }` in the join statement each Windows command starts with | A command whose job cannot be joined (the assembly removed since the self-test, a policy change) must still run as it would without one; its kill then finds no job, logs that, and falls back to taskkill and the sweep (M27), so the failure is reported where it matters. | 2026-09-23 | -| `test/unit/App.test.tsx` | `as unknown as Selection` (four stubs) | jsdom offers no usable `Selection`; the quote-menu tests stub the two members the code reads (`toString`, `anchorNode`) and nothing else, so a structural cast is the honest shape. Test-only. | 2026-09-23 | -| `scripts/capture-themes.mjs` | `nosemgrep` on `spawn` (`detect-child-process`) | A developer script (M37): it starts the VS Code build `@vscode/test-electron` downloaded, with its own fixed arguments, as an argument array with no shell. Nothing from a user, the model or a workspace reaches it, and it never ships. | 2026-09-24 | -| `scripts/sast.mjs` | `nosemgrep` on two `spawnSync` calls (`detect-child-process`) | The SAST gate's own launcher (M40): it runs `semgrep` or the semgrep executable found in a Python's user Scripts folder, and asks the interpreters in a fixed list (`python`, `python3`, `py`) where that folder is. Every command and argument is the script's own, passed as an argument array with no shell; nothing from a user, the model or a workspace reaches them, and the script never ships. | 2026-09-25 | -| `src/host/backend/mcpProcess.ts` | `nosemgrep` on `spawn` (`detect-child-process`) | A stdio MCP server the user configured in Muse Code's own settings file (M50, D42), started only in a trusted workspace: its command found by absolute path (D24), its arguments passed as an array. A `.cmd`/`.bat` launcher goes through `cmd.exe /d /v:off /s /c` with every part quoted and `"`, `%` and line breaks refused. Nothing the model writes reaches the command line. | 2026-09-25 | -| `src/host/backend/mcpJobLaunch.ts` | `nosemgrep` on `spawn` (`detect-child-process`) | On Windows M50 starts only its compiled C# executable in extension storage, with no arguments. The configured command, arguments and allowlisted environment are in a private encoded environment value; C# removes it and builds the server's exact environment before `CreateProcessW`. The server is assigned to its job before its first instruction. Since M56 the launcher's C# ships as `native/windows/MuseSparkMcpLauncher.cs` and the shared `MuseSparkMcpJob.cs`, is read by `jobSourceReader`, and compiles to an executable named by its source's digest (`jobBuild.ts`). | 2026-09-26 | -| `src/runtime/main.ts` | `nosemgrep` on `spawn` (`detect-child-process`) | The ACP agent's `login` (M63, D62) runs `muse login` in the user's terminal the way the agent starts `muse serve`: the command is the CLI `MuseCodeBackendManager.resolveLaunch` found (the install layout, `PATH`, or an absolute `--muse-binary` that must exist, D1a, D4), the arguments its launcher's fixed prefix and `MUSE_LOGIN_ARGS`, passed as an array with no shell. Nothing from an editor, the model or a workspace reaches it. Found by the first local SAST run on PR #32's code (2026-09-27). | 2026-09-27 | -| `test/unit/helpers/fakeMcpOrphan.mjs` | `nosemgrep` on `spawn` (`detect-child-process`) | The M50 Windows regression fixture starts only this Node with its own fixed file to test an MCP server whose child outlives it. The child self-exits after 12 seconds; no model or workspace input reaches its command line, and the fixture never ships. | 2026-09-25 | -| `src/host/backend/modelApiBundle.ts` | `value is ModelApiBundle` (`isModelApiBundle`, a type predicate) | `require` of `dist/modelApi.js` returns `unknown`; the guard checks that `createModelApiHost` is a function, but not its parameter and result types, which no run-time check can see. Both bundles come from one source tree in one `npm run build` and ship in one package, this module types the factory on both sides, and `modelApiBundle.test.ts` builds the real bundle and runs a turn through it (M57). | 2026-09-27 | +| File | Construct | Reason | Added | +| ---------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `src/host/backend/toolIo.ts` | `nosemgrep` on `spawn` (`detect-child-process`) | The command line is the tool's payload by design: the user approved it on a card, and it runs through PowerShell / bash as an argument array, never a shell string. | 2026-09-22 | +| `src/host/backend/searchWorker.ts` | `nosemgrep` on `new RegExp(pattern)` (`detect-non-literal-regexp`) | The model's search pattern is evaluated on a worker thread that `toolIo.searchOnWorker` terminates at `SEARCH_TIMEOUT_MS`, and the pattern is capped at `SEARCH_PATTERN_MAX_LENGTH`; a runaway match cannot hang the host. | 2026-09-22 | +| `src/host/voice/dictationHost.ts` | `nosemgrep` on two `spawn` calls (`detect-child-process`) | The dictation and capture helpers' command lines are fixed by `helperLocation.ts` (Windows PowerShell under `%SystemRoot%` with a bundled script, or the bundled macOS binary with VS Code's own app name (`--app-name`)); M35's Linux recorder is `arecord` or `parec` found by absolute path on PATH, with fixed arguments. Argument arrays; no user, model or workspace input reaches them. | 2026-09-25 | +| `native/darwin/Dictation.swift` | `unsafeBitCast(symbol, to: SetDisclaim.self)` | `responsibility_spawnattrs_setdisclaim` is a private libsystem call with no header, so it is resolved with `dlsym` and cast to its C signature, `int (posix_spawnattr_t *, int)`, the one Chromium and Qt declare (M28). A missing symbol is handled before the cast (the helper then asks as before); the signature has been stable since macOS 10.14. | 2026-09-23 | +| `src/host/backend/shellJob.ts` | `catch { }` in the join statement each Windows command starts with | A command whose job cannot be joined (the assembly removed since the self-test, a policy change) must still run as it would without one; its kill then finds no job, logs that, and falls back to taskkill and the sweep (M27), so the failure is reported where it matters. | 2026-09-23 | +| `test/unit/App.test.tsx` | `as unknown as Selection` (four stubs) | jsdom offers no usable `Selection`; the quote-menu tests stub the two members the code reads (`toString`, `anchorNode`) and nothing else, so a structural cast is the honest shape. Test-only. | 2026-09-23 | +| `scripts/capture-themes.mjs` | `nosemgrep` on `spawn` (`detect-child-process`) | A developer script (M37): it starts the VS Code build `@vscode/test-electron` downloaded, with its own fixed arguments, as an argument array with no shell. Nothing from a user, the model or a workspace reaches it, and it never ships. | 2026-09-24 | +| `scripts/sast.mjs` | `nosemgrep` on two `spawnSync` calls (`detect-child-process`) | The SAST gate's own launcher (M40): it runs `semgrep` or the semgrep executable found in a Python's user Scripts folder, and asks the interpreters in a fixed list (`python`, `python3`, `py`) where that folder is. Every command and argument is the script's own, passed as an argument array with no shell; nothing from a user, the model or a workspace reaches them, and the script never ships. | 2026-09-25 | +| `src/host/backend/mcpProcess.ts` | `nosemgrep` on `spawn` (`detect-child-process`) | A stdio MCP server the user configured in Muse Code's own settings file (M50, D42), started only in a trusted workspace: its command found by absolute path (D24), its arguments passed as an array. A `.cmd`/`.bat` launcher goes through `cmd.exe /d /v:off /s /c` with every part quoted and `"`, `%` and line breaks refused. Nothing the model writes reaches the command line. | 2026-09-25 | +| `src/host/backend/mcpJobLaunch.ts` | `nosemgrep` on `spawn` (`detect-child-process`) | On Windows M50 starts only its compiled C# executable in extension storage, with no arguments. The configured command, arguments and allowlisted environment are in a private encoded environment value; C# removes it and builds the server's exact environment before `CreateProcessW`. The server is assigned to its job before its first instruction. Since M56 the launcher's C# ships as `native/windows/MuseSparkMcpLauncher.cs` and the shared `MuseSparkMcpJob.cs`, is read by `jobSourceReader`, and compiles to an executable named by its source's digest (`jobBuild.ts`). | 2026-09-26 | +| `test/unit/helpers/fakeMcpOrphan.mjs` | `nosemgrep` on `spawn` (`detect-child-process`) | The M50 Windows regression fixture starts only this Node with its own fixed file to test an MCP server whose child outlives it. The child self-exits after 12 seconds; no model or workspace input reaches its command line, and the fixture never ships. | 2026-09-25 | +| `src/host/backend/modelApiBundle.ts` | `value is ModelApiBundle` (`isModelApiBundle`, a type predicate) | `require` of `dist/modelApi.js` returns `unknown`; the guard checks that `createModelApiHost` is a function, but not its parameter and result types, which no run-time check can see. Both bundles come from one source tree in one `npm run build` and ship in one package, this module types the factory on both sides, and `modelApiBundle.test.ts` builds the real bundle and runs a turn through it (M57). | 2026-09-27 | +| `src/host/codeIntel/languageServices.ts` | `Reflect.get(edit, '_allEntries')`, an undocumented member | VS Code's `WorkspaceEdit` API lists only text edits (`entries()`, and `size` counts them), so a rename that also moves or creates files looks plain. The internal `_allEntries()` (1.99.0 to 1.139.0) lists every entry with its `_type`; it is read as `unknown` and parsed with zod, and a missing member or a changed shape answers `unknown`, which refuses the rename rather than applying half of it (M67). `languageServices.test.ts` and the integration suite cover both. | 2026-09-28 | +| `src/runtime/main.ts` | `nosemgrep` on `spawn` (`detect-child-process`) | The ACP agent's `login` (M63, D62) runs `muse login` in the user's terminal the way the agent starts `muse serve`: the command is the CLI `MuseCodeBackendManager.resolveLaunch` found (the install layout, `PATH`, or an absolute `--muse-binary` that must exist, D1a, D4), the arguments its launcher's fixed prefix and `MUSE_LOGIN_ARGS`, passed as an array with no shell. Nothing from an editor, the model or a workspace reaches it. Found by the first local SAST run on PR #32's code (2026-09-27). | 2026-09-27 | ## 9. Security assumptions and accepted residual risk +- Code intelligence (M67) shows what VS Code's language services say. A + result located outside the workspace is left out and a hover defined only + there is held back, but a language service also infers: a workspace + symbol whose type comes from an imported file outside the workspace (a + `../` import, a `tsconfig` path) carries that type, a literal type + included, into its hover and into diagnostics, as the editor shows it. + Accepted: it is the language's own view of the workspace the user opened, + the same text the Problems panel (`getDiagnostics`) already sends. + - The `muse` CLI is closed source; we trust its stdio protocol as documented by Meta's SDK and validate every message shape at our boundary. Residual risk: a compromised or malicious CLI binary on the user's PATH; mitigated diff --git a/README.md b/README.md index e89abd95..2bd47904 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ Every change is in the [CHANGELOG](CHANGELOG.md). diff, the path opens the file with the changed lines selected, **Click to expand** opens VS Code's diff editor, and any output opens in an editor tab with a click. +- **Code intelligence from your editor.** The agent finds definitions, + references, symbols and callers, reads hovers, maps the repository and + renames symbols through VS Code's own language services instead of + searching text, on both backends ([more](#code-intelligence)). - **Permission modes, like Claude Code.** Manual, Edit automatically, Plan and Auto (Bypass behind a setting), switched from the mode button or `Shift+Tab`. Gated commands arrive as approval cards with the CLI's own @@ -566,6 +570,78 @@ worktree with uncommitted changes is removed only after a second confirmation that says the changes will be lost. Both commands need a trusted workspace, since git does not run in Restricted Mode. +## Code intelligence + +The agent finds its way around code the way the editor does: from VS Code's +own language services (the ones behind Go to Definition, Find All +References, the outline and Rename Symbol), not by searching text. Whatever +language extensions you have installed answer; TypeScript and JavaScript +work out of the box. + +| Tool | What the agent gets | +| ------------------- | --------------------------------------------------------------------------------------- | +| `find_definition` | Where a symbol is defined, with the line | +| `find_references` | Every use of a symbol, its declaration included | +| `workspace_symbols` | The workspace's classes, functions and variables matching a name | +| `document_symbols` | A file's outline | +| `hover` | A symbol's type and documentation, as the editor's hover shows them | +| `call_hierarchy` | Who calls a function, or what it calls, where the language supports it | +| `repo_map` | The files other files use most, with their most used definitions, within a token budget | +| `rename_symbol` | A rename everywhere the symbol is used | + +- **Naming a symbol.** By path, line and column; by its name on a line or in + a file; or by name alone, looked up among the workspace's symbols. +- **What comes back.** Workspace-relative `path:line:column` with the line's + text, sorted, and capped with the rest counted. A result outside the + workspace (a library's declarations, another folder) is left out and + counted. A hover for a symbol defined only outside the workspace is held + back, unless it is defined in a language's own library (TypeScript's + `lib.*.d.ts` in VS Code, an extension's bundled stubs). A file whose + language has no service in VS Code, or that declares no symbols, says + **No language service**; a service that answers for a file but finds + nothing at the place says so, and that not every language provides every + kind of answer (JSON and YAML have outlines but no references), so an + empty answer is not proof that nothing uses a symbol. +- **Unsaved files.** The language services read the editor's text, while + `read_file` reads the disk. In a file with unsaved changes a line number + is refused; a name is found in the editor's text, and the answer says its + lines are the editor's. An editor is matched to a file by its real path, + so a workspace opened through a link (whose files the language service + names by their real paths) counts the same editor. +- **Reads in every mode (Model API).** On the Model API backend all but + `rename_symbol` run without a card in every permission mode, Plan and + Restricted Mode included. +- **Renames are edits (Model API).** `rename_symbol` asks like an edit: its + card names the files, a protected one first (a protected write when one of + them is). Every file is checked again after you approve, and each once more + right before it is written, so nothing a formatter or you saved meanwhile + is overwritten; Stop before the first atomic file write starts writes nothing, + including while its final file check waits. The row keeps + one patch across what was written, so Revert and rewind undo it, a rename + stopped partway included. It refuses a rename that would touch a file + outside the workspace, create, move or delete files (or one VS Code does + not say that of), change a file with unsaved changes, or whose edits no + longer match the file (the service answered from an older version). A hook + that matches `Edit` runs for a rename too, and its `PreToolUse` input + names the files it would write: the rename then writes that same plan. +- **On the Muse Code backend** the same tools reach Muse Code through the + extension's tool server as `mcp__ide__findDefinition` and the rest, each + marked read-only (`readOnlyHint`). Muse Code decides whether to ask: in + its on-request mode it showed its own card for `findReferences` (Muse + Code 1.4.0), as for any MCP tool. Its `renameSymbol` changes nothing: it + hands Muse Code the diff, and Muse Code's own edit tool applies it under + its approvals and rewind. +- **The repo map in the prompt** (`museSpark.modelApiRepoMap`, off by + default, machine-scoped): in a trusted workspace the Model API backend + puts the map in its instructions, within about 1,000 tokens, so the model + starts out knowing the workspace's layout. It is made once per + conversation; a try that finds nothing (TypeScript's service knows the + workspace's symbols only once one of the project's files is open) is + made again on the next turn, three times at most, and a child task and a + fork use the conversation's map. It adds those tokens to every request. + Muse Code's instructions are its own, so there the model asks for + `repoMap` when it wants one. + ## Session goals Give a conversation a goal and Muse keeps working toward it across turns, on @@ -1283,7 +1359,7 @@ All settings live under `museSpark.*`; changes apply to open panels immediately. The settings that choose what runs and what is billed (`initialPermissionMode`, `backend`, `shellSandbox`, `sandboxNetwork`, `allowDangerouslySkipPermissions`, `museBinaryPath`, `environmentVariables`, -`modelApiHooks`, `modelApiPromptCacheRetention` and the five paid features, +`modelApiHooks`, `modelApiRepoMap`, `modelApiPromptCacheRetention` and the five paid features, `modelApiWebSearch`, `modelApiImageGeneration`, `modelApiVoice`, `modelApiSubagents` and `modelApiScheduledPrompts`) are machine-scoped: they take effect from your user settings only, never from a repository's @@ -1320,6 +1396,7 @@ Bypass at once. | `modelApiSubagents` | `false` | [Paid](#paid-features): Model API child tasks, with a model-rate confirmation and a fresh four-request popup for every task | | `modelApiScheduledPrompts` | `false` | [Paid](#scheduled-prompts-model-api): a due prompt can run only after this machine-scoped gate and a separate confirmation of that occurrence's Model API token rates; never unattended | | `modelApiHooks` | `false` | Run Muse Code's hook commands on the Model API backend in a trusted workspace: your administrator's, yours and the project's. They run as you, outside the agent's sandbox, without the Model API key; review them with **Muse Spark: Hooks** first. Machine-scoped | +| `modelApiRepoMap` | `false` | Put a [repo map](#code-intelligence) in the Model API backend's instructions in a trusted workspace: the workspace's most used files and definitions, made once per conversation in about 1,000 tokens, which every request then carries (billed to your key). Machine-scoped | | `environmentVariables` | `[]` | `{ name, value }` pairs for the Muse Code process and the terminals that run the CLI (Open in Terminal, MCP sign-in, `muse logout`); an `XDG_CONFIG_HOME` here is where the extension looks for the CLI's sign-in and settings too. Never put API keys here; use Sign in. Changing it restarts the host | The Model API backend's shell tool applies `terminal.integrated.env.*` the @@ -1402,7 +1479,9 @@ stopped and the next message resumes the same session. only when you press Send. The exceptions are ones you set up: on the Model API backend an MCP server you configured receives its tool calls' arguments, and with `museSpark.modelApiHooks` on your hook commands - receive your prompt and bounded previews of tool and model calls. By + receive your prompt and bounded previews of tool and model calls. With + `museSpark.modelApiRepoMap` on, every request also carries the repo map + (file paths and definition names, no file contents). By default each message also carries the open file's path and any selected text (`attachOpenFile`); on the CLI backend each turn carries a short hidden note asking the model to offer choices through the question card. The extension has no telemetry diff --git a/SECURITY.md b/SECURITY.md index 12ab00df..6a71e0e2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -57,7 +57,7 @@ Only the latest release on the Visual Studio Marketplace receives fixes. a trusted workspace. The settings that choose what runs and what is billed (`museBinaryPath`, `environmentVariables`, `backend`, `shellSandbox`, `sandboxNetwork`, `initialPermissionMode`, - `allowDangerouslySkipPermissions`, `modelApiHooks`, + `allowDangerouslySkipPermissions`, `modelApiHooks`, `modelApiRepoMap`, `modelApiPromptCacheRetention` and the five paid `modelApi*` features) are machine-scoped in every workspace, trusted or not: a repository's `.vscode/settings.json` cannot point the extension at its own executable. In a remote window a dev container @@ -74,6 +74,33 @@ Only the latest release on the Visual Studio Marketplace receives fixes. rewind apply the same check before writing a file back. Windows names that would be reinterpreted are refused: alternate data streams (`a.txt:x`), device names (`NUL`, `COM1`), trailing dots or spaces. +- **Code intelligence (both backends).** The file a code intelligence tool + is asked about is confined the same way, and a result VS Code's language + service returns from outside the workspace (a library's declarations, + another folder, a file reached through a link that leaves it) is left out + and counted: its location and lines are never shown. A hover shows what a + declaration says, so a hover for a symbol defined only outside the + workspace is held back, unless the definition is in a language's library + inside VS Code's installation or an extension's folder. What a language + service infers still flows through: a symbol defined in the workspace + whose type comes from a file outside it (an import from `../`, a + `tsconfig` path) shows that type in its hover and its diagnostics, as it + does in the editor. `rename_symbol` refuses a rename that would touch any + file outside the workspace, create, move or delete files (seen through + the edit's internal entry list, since VS Code's API lists only text + edits; an edit that does not show its list is refused too), or change a + file with unsaved changes, one VS Code holds differently from the disk, + or one whose edit ranges no longer cover the old name (an edit the service + made from an older version of the file). On the Model API backend it asks + as an edit (a protected write when any of its files is one, named first on + the card), and every file is confined and read again after the card and + once more right before its own write, so nothing a formatter, a hook or + the user wrote meanwhile is overwritten; a Stop before the first write + writes nothing. On the Muse Code backend the `ide` server's tools change + nothing and declare themselves read-only; its rename returns the edits for + Muse Code's own edit tool, and Muse Code 1.4.0 asks its own card for these + tools in its on-request mode. The repo map reaches the Model API's + instructions only in a trusted workspace. - **Protected writes (Model API backend).** Writing `.git/**`, `.husky/**`, `.vscode/**`, `.idea/**`, `.devcontainer/**`, `.github/workflows/**`, `.agents/**`, `.muse/**`, `AGENTS.md`, `CLAUDE.md`, `.envrc` or diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 1693442a..9f145704 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -12,8 +12,10 @@ security notes for contributors are in `PLAN.md` §9. pick for attachment in a trusted, indexed workspace, the contents of files you `@`-mention, the open file or selection when the "attach open file" setting is on, and the outputs of the tools the agent runs (file contents, - command output, Problems-panel diagnostics) are sent to Meta so the model - can answer. Nothing is sent until you press Send. + command output, Problems-panel diagnostics, and what VS Code's language + services answer about your code: definitions, references, symbols, hover + text) are sent to Meta so the model can answer. Nothing is sent until you + press Send. - **Your own shell commands (`!`).** A message that starts with `!` runs on your machine, and the command with what it printed goes to Meta with the next request, so the model knows what you ran; Muse Code also keeps it in @@ -29,9 +31,10 @@ security notes for contributors are in `PLAN.md` §9. saves never passes through the extension. What the CLI sends beyond your messages (its system prompt, its own telemetry, if any) is governed by Meta's Muse Code terms, not by this extension. - For the Problems panel and, when turned on, paid images, the extension - serves Muse Code a tool server bound to `127.0.0.1` with a per-window - token; nothing else on the network can reach it. + For the Problems panel, the code intelligence tools and, when turned on, + paid images, the extension serves Muse Code a tool server bound to + `127.0.0.1` with a per-window token; nothing else on the network can + reach it. A picked text file is sent as named text. Muse Code retains a readable `[Muse Spark Code attached text files: …]` annotation in the message's display text so the extension can mark its file card after History resume; @@ -115,6 +118,15 @@ security notes for contributors are in `PLAN.md` §9. lists as changed, and the subjects of the last five commits (never file contents or diffs); in Restricted Mode git is not run and none of this is sent. The Muse Code CLI assembles its own context under Meta's terms. +- **The repo map (Model API backend, off by default).** With + `museSpark.modelApiRepoMap` on, in a trusted workspace, the instructions + sent with every request of a conversation (its child tasks' included) + carry a map of the workspace made on its first turns: file + paths, and the names, kinds and lines of the definitions other files use + most. To make it the extension counts names in your files on your machine + (their text is not sent) and asks VS Code's language services where each + is defined. The `repo_map` tool sends the same kind of map when the model + calls it, setting or not. - **Contributor-tier models.** Meta may use traffic to the models whose id ends in `-contributor` to train its models. The extension asks once per conversation before using one, and refuses them entirely when the diff --git a/docs/certification/README.md b/docs/certification/README.md index 16d8cd03..41502132 100644 --- a/docs/certification/README.md +++ b/docs/certification/README.md @@ -73,6 +73,7 @@ The PNGs beside the records are that day's harness renders. - [M57](m57.md): the Model API backend out of the activation bundle into `dist/modelApi.js`, the identity audit and the bundle-split gate (PLAN.md D6) - [M58](m58.md): a popup before every paid use: Allow once, Allow always in this workspace, or Deny (PLAN.md D48) - [Sign-in detection](sign-in-detection.md): the CLI's sign-in read from its credential file's structure and confirmed by the CLI, sign-out through `account/logout`, and every way a browser sign-in ends (PLAN.md D26 amendment) +- [M67](m67.md): code intelligence tools from VS Code's language services on both backends, `rename_symbol` through the edit path, and the opt-in repo map (PLAN.md D49) - [M60](m60.md): the host API record and the `vscode` boundary, with M61's host bridge and portable controller (PLAN.md D60) - [M62a, M62b](m62.md): the VS Code floor at 1.99, from an API and Node audit, tested in VSCodium and code-server; Eclipse Theia 1.75 (PLAN.md M62, A8) - [M63a–M63c](m63.md): the ACP agent for other editors, the Model API key in the OS credential store, and the agent's package; Zed, Emacs with agent-shell, Neovim with CodeCompanion, JupyterLab with Jupyter AI; the editors' MCP servers; the host checks in CI (PLAN.md D61, D62) diff --git a/docs/certification/m67-code-intel.png b/docs/certification/m67-code-intel.png new file mode 100644 index 00000000..b74339e9 Binary files /dev/null and b/docs/certification/m67-code-intel.png differ diff --git a/docs/certification/m67.md b/docs/certification/m67.md new file mode 100644 index 00000000..ff645657 --- /dev/null +++ b/docs/certification/m67.md @@ -0,0 +1,449 @@ +# M67: code intelligence tools + +Recorded 2026-09-28 on branch `feature/m67-code-intel`, from +`origin/plan/d49-competitive-program` `2407109c`. PLAN.md D49, M67. + +## The request + +D49's first wave ranks what most improves the code a user gets. M67 is the +model understanding the code it changes: finding definitions, references +and symbols the way an IDE does instead of grepping, from VS Code's own +language services, on both backends, with a compact repo map. + +## What was built + +- **The core** (`src/core/codeIntel/`, no `vscode`): `languageService.ts` + (the `LanguageServiceHost` interface, plain data), `codeIntelQuery.ts` + (one call's confinement, placing and name lookup, the deadline), + `codeIntelTools.ts` (the read tools), `rename.ts` (the rename's plan and + its diff), `repoMap.ts`, `codeText.ts` (positions, whole-word search, + edits, hunks) and `definitions.ts` (descriptions and argument schemas). + The file tools' one-hunk patch (`hunkBetween` in `tools.ts`, D27) moved + into `codeText.ts` as `changeHunk`, unchanged, so the file tools and the + rename share it; `modelApiHost.test.ts` and `modelApiTools.test.ts` pass + as they were. `codeIntelCalls.ts` is on the bundle-split gate's + load-with-the-backend list; the core in `src/core/codeIntel/` is in both + bundles, since the `ide` tools need it at activation (`dist/extension.js` + 460.2 KiB of 600, `dist/modelApi.js` 326.9 KiB of 400). +- **The adapter** (`src/host/codeIntel/languageServices.ts`): VS Code's + `vscode.executeDefinitionProvider`, `…ReferenceProvider`, + `…HoverProvider`, `…DocumentSymbolProvider`, `…WorkspaceSymbolProvider`, + `vscode.prepareCallHierarchy` / `provideIncomingCalls` / + `provideOutgoingCalls`, `vscode.prepareRename` and + `vscode.executeDocumentRenameProvider`, converted to plain data; a result + that is no `file:` resource has no path; hover parts parsed with zod. +- **Model API** (`src/core/backends/modelapi/codeIntelCalls.ts`, + `ModelApiHost.ts`, `tools.ts`, `instructions.ts`): `find_definition`, + `find_references`, `workspace_symbols`, `document_symbols`, `hover`, + `call_hierarchy` and `repo_map` are `read` tools (no card in any mode, + Plan and Restricted Mode included, stopped by Stop); `rename_symbol` is + planned before its card, judged as an edit (protected when any of its + files is), re-checked file by file after approval, written with the + tools' atomic write, and leaves one patch across its files. The prompt + names the tools, and carries the repo map while + `museSpark.modelApiRepoMap` is on (made once per session). +- **Muse Code** (`src/host/ide/codeIntelTools.ts`, `src/core/mcp.ts`, + `src/core/diagnostics.ts`): the same tools on the `ide` server as + `findDefinition` … `renameSymbol`, each with `readOnlyHint: true` in + `tools/list` (as `getDiagnostics` now is); `renameSymbol` returns a + unified diff and writes nothing. +- **The panel**: row labels for all sixteen names in 15 tables, the symbol + as a row's summary, `rename_symbol` rows as edits; the rename card names + up to five files and counts the rest (`renameCardMore`). Setting + `museSpark.modelApiRepoMap` (machine-scoped) in 15 manifest tables. + `check:l10n` reports 0 problems. + +## Tests + +| Suite | What it holds | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `test/unit/codeIntelTools.test.ts` | places with lines, outside results counted, lookups by position, line, file and workspace symbol (TypeScript's `greet()`), refusals, "no language service" vs "none here", caps and repeats, a linked root, the deadline, symbols, hover, call hierarchy | +| `test/unit/codeText.test.ts` | whole-word search, edits applied or refused, hunks per changed group and their revert by Edit Review's `revertHunks`, the unified diff | +| `test/unit/renamePlan.test.ts` | a plan keeping BOM and CRLF, a file listed twice, the refusals (outside, file operations, too many, unsaved, stale, dirty, broken edit), "nothing to rename", the provider's own refusal, the `ide` diff | +| `test/unit/repoMap.test.ts` | ranking, token budget, no service, empty map, time budget and a stuck service, unreadable and oversized files, the file cap, the prompt section and Stop | +| `test/unit/modelApiCodeIntel.test.ts` | tools and guidance offered only with language services, reads in Plan and Restricted Mode, the rename card and patch, Auto and `write_file` after it, protected paths, Plan, a file changed / unsaved / relinked under the card, a failed write, Stop, the repo map once per session | +| `test/unit/ideCodeIntelTools.test.ts` | `tools/list` with `readOnlyHint` for every tool, `getDiagnostics` included; answers and refusals as MCP results; the rename's diff with nothing written | +| `test/unit/languageServices.test.ts` | each command's result converted, links and virtual documents, hover shapes, both symbol shapes, call hierarchy both ways, rename entries and file operations | +| `test/unit/toolPresentation.test.ts` | the rows' labels and summaries | +| `test/integration/codeIntel.test.ts` | VS Code's own TypeScript service over `test/fixtures/workspace/code-intel`: definition across files, the library left out and counted, references in order, outline, hover, workspace symbols, callers, plain text's "no language service", a rename plan that writes nothing | + +The integration suite ran on VS Code stable (1.139.0) and on the manifest's +floor, 1.125.0: 15 passing on each. It found two things the fake had not: +TypeScript's workspace symbols name a function `greet()` and start their +range at `export`, so names are now matched without the parentheses and +placed at the name (`bareName`, `nameStart`). + +## Red drills + +Each guard was broken on disk, its suite run, and the file's exact bytes +restored and checked by SHA-256 (never git). Every run exited 1. + +| Guard broken | File | Suite | Failed | +| --------------------------------------------------------------- | ---------------------------------------------- | --------------------------- | ------ | +| input path confined (`confine`) | `src/core/codeIntel/codeIntelQuery.ts` | `codeIntelTools.test.ts` | 1 | +| results outside the workspace left out and counted (`place`) | `src/core/codeIntel/codeIntelQuery.ts` | `codeIntelTools.test.ts` | 5 | +| "no language service", never an empty answer (`nothingAt`) | `src/core/codeIntel/codeIntelQuery.ts` | `codeIntelTools.test.ts` | 1 | +| answers capped with the rest counted | `src/core/codeIntel/codeIntelTools.ts` | `codeIntelTools.test.ts` | 1 | +| a language service that does not answer ends the call (`ask`) | `src/core/codeIntel/codeIntelQuery.ts` | `codeIntelTools.test.ts` | 1 | +| TypeScript's `greet()` matched by name (`bareName`) | `src/core/codeIntel/codeIntelQuery.ts` | `codeIntelTools.test.ts` | 2 | +| a rename touching a file outside the workspace refused whole | `src/core/codeIntel/rename.ts` | `renamePlan.test.ts` | 1 | +| a rename refuses a file VS Code holds differently from the disk | `src/core/codeIntel/rename.ts` | `renamePlan.test.ts` | 1 | +| a rename merges the edits of a file listed twice | `src/core/codeIntel/rename.ts` | `renamePlan.test.ts` | 1 | +| an approved rename re-reads every file before writing any | `src/core/backends/modelapi/codeIntelCalls.ts` | `modelApiCodeIntel.test.ts` | 1 | +| a rename touching a protected path is a protected write (D24) | `src/core/backends/modelapi/ModelApiHost.ts` | `modelApiCodeIntel.test.ts` | 1 | +| the read tools are reads: no card in Plan or Restricted Mode | `src/core/backends/modelapi/tools.ts` | `modelApiCodeIntel.test.ts` | 1 | +| the repo map is made once per session | `src/core/backends/modelapi/ModelApiHost.ts` | `modelApiCodeIntel.test.ts` | 1 | +| the `ide` tools declare `readOnlyHint` (D49) | `src/host/ide/codeIntelTools.ts` | `ideCodeIntelTools.test.ts` | 1 | +| `tools/list` carries the annotations | `src/core/mcp.ts` | `ideCodeIntelTools.test.ts` | 1 | +| a rename's hunks group changed lines, which revert undoes | `src/core/codeIntel/codeText.ts` | `codeText.test.ts` | 1 | +| the repo map stops at its time budget and says it is partial | `src/core/codeIntel/repoMap.ts` | `repoMap.test.ts` | 1 | +| a repo map cut short by Stop is not kept for the session | `src/core/backends/modelapi/ModelApiHost.ts` | `modelApiCodeIntel.test.ts` | 1 | +| Stop ends the repo map at once, not at its time budget | `src/core/codeIntel/repoMap.ts` | `modelApiCodeIntel.test.ts` | 1 | +| Plan refuses a rename before the language service is asked | `src/core/backends/modelapi/ModelApiHost.ts` | `modelApiCodeIntel.test.ts` | 1 | + +The first run of `renamePlan.test.ts` also caught a real bug: an edit that +lists one file twice planned it twice, the second plan overwriting the +first. The plan now merges a file's edits (the drill above keeps it). + +## Live check + +The Model API sweep's new case19 (`test/e2e/modelApi.live.e2e.test.ts`), +through `run-live-modelapi.ps1 -Filter case19`, on +`muse-spark-1.3-contributor` in an empty temporary workspace with two +TypeScript files. VS Code's services exist only in the extension host, so a +stand-in that knows the one symbol answers from the files' text; the real +services' answers are the integration suite's. The prompt asked for +`find_references` on `greet`, then `rename_symbol` to `welcome`, in Manual +mode (the rename card allowed once by the sweep's answerer). + +- **3 requests**, all `POST /v1/responses 200`; 14,878 input tokens (9,442 + cached), 489 output; estimated $0.0007. No client error: Meta accepted the + eight new function definitions. +- Rows `find_references:completed rename_symbol:completed`; the card was + shown and allowed. Reply: "`find_references` listed **5** references, + renamed `greet` → `welcome` (5 edits in 2 files)." +- **That count was wrong** (found in review, C3-6): the stand-in matched + `greet` inside the module path `'./greet'` too, so the "rename" also + rewrote the import to `'./welcome'`, a broken import, and the case still + passed. TypeScript's own service gives 4. The corrected run is under the + review round below. +- No key in the log, events or files (the sweep's own check). + +No new wire shape is parsed (AGENTS rule 13): the tool calls are the Model +API's captured `function_call` items and MSP's captured tool items; the MCP +annotations are what the extension sends; VS Code's results are its typed +API, and the one surprise in them (`greet()`) was captured by the +integration run above. + +## Accessibility and screenshots + +The `code-intel` harness scenario (rows for references, an outline, a +failed definition, a Muse Code rename diff, and a rename card across eight +files, protected): `node scripts/a11y.mjs code-intel` reports 4 pages, 0 +rules violated. Screenshot: [m67-code-intel.png](m67-code-intel.png). + +## Gate + +The gate's earlier runs each stopped at a real problem, fixed before the +next: an unused exported type (knip); two clones (jscpd: the file tools' +hunk, now shared as `changeHunk`, and a repeated approval step in a test); +the new Model API file not yet on the bundle-split gate's lists. The fourth +run passed every gate in `quality:gates` and found no accessibility +violation, but five pages of other scenarios (`empty`, `paid-palette`, +`paid-image`, `resume`, `narrow`) had no result: Chrome crashed or timed +out while three agents' gates ran on the machine at once. Those scenarios +rerun alone passed (24 pages, 0 violations). Semgrep then blocked +`new RegExp` over the escaped symbol name in `findName`; the search is now +`indexOf` with the same whole-word check, and Semgrep reports 0 findings. + +`npm run quality` at the end of the milestone, run alone on the machine +through the coordinator's one-gate script, exit 0: + +``` +All matched files use Prettier code style! +l10n: 14 tables, 94 manifest strings, 242 source files; 0 problems +✅ Congratulations, no circular dependency was found in your project. +Found 0 clones. + Test Files 183 passed | 2 skipped (185) + Tests 2592 passed | 24 skipped (2616) +All files | 94.52 | 89.76 | 96.28 | 94.48 | +ok dist/extension.js: 460.1 KiB (budget 600 KiB) +ok dist/modelApi.js: 326.8 KiB (budget 400 KiB) +ok dist/extension.js: 8 of the backend's 29 files (16.3 KiB), all on the allowed list +ok dist/modelApi.js: carries the 21 files that load only with the backend +audit: 0 advisories, 0 exceptions (.github/audit-exceptions.json) +a11y: 340 pages (85 scenarios × 4 themes), 0 rules violated on 0 elements, 0 rules undecided on 0 elements, 8 exempt, 0 pages without a result +no leaks found +Ran 287 rules on 425 files: 0 findings. +exit=0 +``` + +The integration suite ran again after the last change: 15 passing on +VS Code stable. + +## Review round (after `c5c4045b`) + +Three class reviewers read `c5c4045b`. Every finding is fixed in one commit +on top of two merges of `origin/main` (`ba82f43`, PR #50; then `c42c4d5`, +PR #49). None was wrong on +inspection; the evidence for the one that rested on VS Code's internals is +in its row. + +### Fixed + +| Finding | Fix | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| C1-1 (P1): a rename applied to a newer version of a file | The old name is what the edit at the position replaces, read from the text the position came from; every edit range must cover exactly that name in the text written, and the position's file must still be that text, or the rename is refused (`renameStale`). The `ide` diff shares the plan. | +| C2-1 / C3-1: file operations never detected | Read from VS Code 1.139.0's `extensionHostProcess.js`: `entries()` keeps only entries with `_type===2` and `get size(){return this.entries().length}` (1.125.0 and 1.99.0 have `_allEntries()` too), so `size !== entries().length` was always false. The adapter now parses `_allEntries()` with zod: any `_type` other than 2 is `present`, a missing member or shape is `unknown`, and both are refused. The unit fake builds a `WorkspaceEdit` the way VS Code does. PLAN §8 records the undocumented member. | +| C1-2: a change between the recheck and the write | Every file is checked after the card, then each once more right before its own write; a change found partway stops the rest (`renameChangedPartway`) with a revertable patch of what was written. | +| C1-3: Stop ignored once approved | `applyRename` takes the turn's signal: after the checks and before the first write, a Stop writes nothing. Once writing starts the rest follow, so the button never leaves a rename half done. | +| C1-4 / C3-4: a failed or empty prompt map pinned | Only a map with text is kept; a try that fails or comes out empty counts, three at most (`REPO_MAP_PROMPT_TRIES`); a Stop does not count. The map says how many files it read when the reading runs out of time. | +| C1-5: every child and side chat built a map | A child task's prompt carries its parent's map and never builds one; a fork copies the conversation's. | +| C1-6: a listener per map; listing not bounded | `Limits` listens once and `close()` takes the listener off in a `finally`; work begins only within the limits, and the file listing is raced against the budget and the Stop. | +| C1-7: editor text and disk mixed | A line number in a file with unsaved changes is refused (`codeIntelUnsavedPosition`); lines shown for such a file are the editor's, and the answer says so (`codeIntelUnsavedNote`). | +| C2-2: repo map in Restricted Mode | Built and sent only in a trusted workspace. | +| C2-3: hover from outside the workspace | The hover is held back when every definition is outside the workspace and outside the languages' libraries (`LanguageServiceHost.libraryRoots`: VS Code's `appRoot` and each extension's folder). The integration suite's `toUpperCase` hover (TypeScript's bundled library) still shows. Types inferred through imports remain, recorded in PLAN §9 and SECURITY. | +| C2-4: hooks blind to renames | `rename_symbol` matches `Edit`; a matching `PreToolUse` hook gets the planned `files`, planned for it only when one would run and the mode allows edits. A Stop or a refusal while planning leaves the hook the plain arguments, so the call still ends with its row and output (D26). | +| C2-5: the card could hide the protected file | Protected files are named first. | +| C2-6 / C3-7: no Muse Code capture | Captured below. The README's "reads without a card in every mode" is now the Model API's alone. | +| C3-2: a partial rename not revertable | A failed `rename_symbol` row with a patch keeps review, revert and rewind (`hasLandedEdits`, `PARTIAL_EDIT_TOOLS`); other failed rows do not. | +| C3-3: "no language service" both ways | The row says "…, or it declares no symbols."; an empty answer from a file whose service answers says not every language provides that kind; README softened. | +| C3-5: a broken file button | An edit row's summary is only a path. | +| C3-6: the live check overstated | The stand-in skips names inside string literals; the case asserts `src/main.ts` exactly, the import included. | +| Minor | The same name: "the new name `greet` is already the name there"; `renameChanged` no longer says "waited for approval"; the header names `codeIntelCalls.ts`. | + +### Live checks + +- **Model API, case19 again** (`muse-spark-1.3-contributor`, an empty + temporary workspace): **3 requests**, all 200, 15,028 input tokens (9,442 + cached), 782 output, est. $0.0007. Rows `find_references:completed +rename_symbol:completed`; `src/main.ts` read exactly + `import { welcome } from './greet'` and `[welcome('Ada'), welcome('Grace')]`. + The reply: "`find_references` listed **4** references … `rename_symbol` + made 4 edits in 2 files." +- **Muse Code through the `ide` server** (AGENTS rule 13): Muse Code + 1.4.0-R4302.1, the owner's sign-in, `muse-spark-1.3-contributor`, a + temporary folder with the same two files, on-request mode, one turn, + session `01a0e8b6-01ae-7020-b876-a41418374bb2`: **4 model attempts** from + the CLI's trace log. The real `IdeMcpServer` served `getDiagnostics` and + the eight code intelligence tools with their annotations; Muse Code + listed them (the list was asked 4 times), showed its own approval card + for `mcp__ide__findReferences` (subject `tool`; Allow once / Allow for + this session / Always allow this MCP tool / Reject) although the tool + says `readOnlyHint: true`, and called it with `{"symbol":"greet"}`. The + row (`mcp__ide__findReferences`, completed) carried our text verbatim as + `visibleOutput` (the lead and four places), and the reply was "4". A + scratch test drove it (not committed); its stand-in answered from the + files' text, as in case19. + +### Red drills + +Each new guard was broken in place, its suite run, and the file's bytes +restored and checked by SHA-256 (`scratchpad/drills-m67-review.js`). Every +run exited 1. + +| Guard broken | Suite | Failed | +| ------------------------------------------------ | --------------------------- | ------ | +| C1-1: each edit range covers the old name | `renamePlan.test.ts` | 1 | +| C1-1: the position's file is still its text | `renamePlan.test.ts` | 1 | +| C2-1: file operations read from `_allEntries` | `languageServices.test.ts` | 1 | +| C2-1: a missing list is `unknown` | `languageServices.test.ts` | 1 | +| C2-1: the plan refuses `unknown` | `renamePlan.test.ts` | 1 | +| C1-2: each file rechecked right before its write | `modelApiCodeIntel.test.ts` | 1 | +| C1-3: Stop before the first write | `modelApiCodeIntel.test.ts` | 1 | +| C1-4: tries bounded | `modelApiCodeIntel.test.ts` | 1 | +| C1-4: an empty try is not pinned | `modelApiCodeIntel.test.ts` | 2 | +| C1-5: a child uses its parent's map | `modelApiCodeIntel.test.ts` | 1 | +| C2-2: no map in Restricted Mode | `modelApiCodeIntel.test.ts` | 1 | +| C1-6: the Stop listener is taken off | `repoMap.test.ts` | 1 | +| C1-6: the file listing is bounded | `repoMap.test.ts` | 1 | +| C3-4: the files-read note | `repoMap.test.ts` | 1 | +| C1-7: a position in an unsaved file refused | `codeIntelTools.test.ts` | 1 | +| C1-7: an unsaved file's lines are the editor's | `codeIntelTools.test.ts` | 1 | +| C2-3: a hover defined only outside is held back | `codeIntelTools.test.ts` | 1 | +| C2-3: a library keeps its hover | `codeIntelTools.test.ts` | 1 | +| C2-4: an `Edit` hook matches a rename | `modelApiCodeIntel.test.ts` | 1 | +| C2-4: the hook gets the files | `modelApiCodeIntel.test.ts` | 1 | +| C2-4: a Stop while planning keeps the call's row | `modelApiCodeIntel.test.ts` | 1 | +| C2-5: protected files first on the card | `modelApiCodeIntel.test.ts` | 1 | +| C3-2: a partial rename in rewind | `uiState.test.ts` | 1 | +| C3-2: its row offers the editor | `Transcript.test.tsx` | 1 | +| C3-5: only a path is a row's file | `toolPresentation.test.ts` | 1 | +| Minor: the same-name wording | `renamePlan.test.ts` | 1 | + +The C2-5 drill first passed: the old card test's protected file, +`.vscode/tasks.ts`, sorts first by path anyway. The test now uses +`src/zz/.husky/pre-commit.ts`, which sorts last, and the drill fails. + +The integration suite (15 tests, the `toUpperCase` hover added) passed on +VS Code stable (1.139.0) and on the floor, 1.125.0, after these changes; +the rename plan passing there also shows `_allEntries` present in both. + +### The VS Code 1.99 floor (PR #32) + +PR #32 lowers the floor to VS Code 1.99 (Node 20.18). Checked against +1.99.0's `extensionHostProcess.js`: the ten commands M67 calls +(`vscode.executeDefinitionProvider`, `…ReferenceProvider`, +`…HoverProvider`, `…DocumentSymbolProvider`, `…WorkspaceSymbolProvider`, +`vscode.prepareCallHierarchy`, `vscode.provideIncomingCalls`, +`vscode.provideOutgoingCalls`, `vscode.prepareRename`, +`vscode.executeDocumentRenameProvider`) are all registered, and +`WorkspaceEdit` there has `_allEntries()`, `_type: 1` for `renameFile`, +`createFile` and `deleteFile` and `_type: 2` for text, with `size` +counting `entries()` alone, as in 1.139. The one Node 22 API M67 used, +`Promise.withResolvers` in the repo map's `Limits`, is gone: the Stop +listener is registered with its own `AbortController`'s signal, which +`close()` aborts. Drilled again after the change: without the abort, +`repoMap.test.ts` fails (a listener left); without the resolve, the +Stop-while-mapping case in `modelApiCodeIntel.test.ts` fails. Other +`Promise.withResolvers` calls on `main` predate M67 and are PR #32's. + +### Gate (review round) + +The first run after the fixes stopped at jscpd: the new Stop-while-planning +test repeated the wait-Stop-wait steps of two others, now one helper +(`stopOnceAsked`). `npm run quality` then, alone on the machine through +the coordinator's one-gate script, exit 0: + +``` +All matched files use Prettier code style! +l10n: 14 tables, 94 manifest strings, 245 source files; 0 problems +✅ Congratulations, no circular dependency was found in your project. +Found 0 clones. + Test Files 187 passed | 2 skipped (189) + Tests 2796 passed | 24 skipped (2820) +All files | 94.78 | 90.02 | 96.42 | 94.75 | +ok dist/extension.js: 478.4 KiB (budget 600 KiB) +ok dist/modelApi.js: 331.7 KiB (budget 400 KiB) +ok dist/extension.js: 8 of the backend's 29 files (16.3 KiB), all on the allowed list +ok dist/modelApi.js: carries the 21 files that load only with the backend +audit: 0 advisories, 0 exceptions (.github/audit-exceptions.json) +a11y: 340 pages (85 scenarios × 4 themes), 0 rules violated on 0 elements, 0 rules undecided on 0 elements, 8 exempt, 0 pages without a result +no leaks found +Ran 287 rules on 429 files: 0 findings. +exit=0 +``` + +The same code (`dee57853`) through the rig gate on the Mac mini +(`quality:gates`, `security:secrets`, `security:sast`; no `test:a11y`, +whose headless Chrome hangs on the rigs; `lint:ps` skipped off Windows): +187 test files passed, 2 skipped; 2,787 tests passed, 33 skipped (macOS +skips the Windows-only ones); 0 clones; no leaks; 0 Semgrep findings; +exit=0. The `code-intel` scenario alone here after it: `node +scripts/a11y.mjs code-intel` reports 4 pages, 0 rules violated, 0 pages +without a result. + +## Second review (Grok Build on `585af100`) + +Grok Build reviewed `585af100` against `c42c4d5f` on the Kubuntu rig: one +P1 and five P2. Each held when checked in the code; all are fixed in one +commit. + +| Finding | Fix | +| ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| P1: unsaved changes matched by the path's text (`codeIntelQuery.ts` `readLines`, `rename.ts` `plannedFile`, `codeIntelCalls.ts` `recheck`) | `ToolIo.unsavedFiles()` lists the editors' files; `unsavedDocumentPath` matches one by the file's own path, the service's, or its real path. A workspace opened through a link has the editor name `/link/ws/src/b.ts` and the language service `/ws/src/b.ts`: the plan and the recheck before each write now refuse it, the lines shown are that editor's (read at its own path, since the real path would open another document from disk), and a target is asked and read at the editor's path. `hasUnsavedChanges` is derived from the same list. | +| P2: an abandoned repo map batch kept writing | `inBatches` returns each finished batch's results and drops a batch the time or a Stop cuts off whole; the counts and the map are built from those results only. | +| P2: "no language service" on a map cut short | Refused only when every lookup ran and none found anything; a cut map is partial. | +| P2: the hook saw one plan, the write used another | The plan made for the `PreToolUse` hooks is kept for the call and written; a hook's new arguments (a new call object) plan afresh. | +| P2: outgoing call sites printed after the callee's path | Each outgoing site names the file of the function asked about (`called at src/b.ts:2:1`), which is where VS Code's `fromRanges` are (the item passed to `provideOutgoingCalls`). That is the queried file when the position is on the function's declaration; when it is on a call of a function declared elsewhere, it is that function's file, so the review's "the queried file" is followed in its intent rather than its words. | +| P2: only `items[0]` of `prepareCallHierarchy` asked | The item declared at the position (same file, selection holding the position) is asked, else the first; the answer counts the others ("[1 more functions share this position … ask at each one's own declaration]"). | + +### Red drills (second review) + +Each guard broken in place, its suites run, the file restored and checked +by SHA-256 (`scratchpad/drills-m67-final.js`); every run exited 1. + +| Guard broken | Suites | Failed | +| -------------------------------------------------- | --------------------------------------------------- | ------ | +| An editor found by its real path | `codeIntelTools`, `renamePlan`, `modelApiCodeIntel` | 3 | +| Lines read at the editor's own path | `codeIntelTools` | 1 | +| A target opened at the editor's path | `codeIntelTools` | 1 | +| The plan refuses by real path | `renamePlan` | 2 | +| The recheck refuses by real path | `modelApiCodeIntel` | 1 | +| `ToolIo` matches an editor by the platform's rules | `toolIo` | 1 | +| A cut map is partial, not unserved | `repoMap` | 1 | +| The hook's plan is the one written | `modelApiCodeIntel` | 1 | +| Outgoing sites name the asked function's file | `codeIntelTools` | 1 | +| The other call items counted | `codeIntelTools` | 1 | +| The call item declared at the position | `languageServices` | 1 | + +The cut-batch test also ran against the committed `repoMap.ts` (its +batches writing as they went), swapped in and restored by SHA-256: it +failed there (`scratchpad/drill-repomap-old.js`). + +The integration suite passed locally after the change, on VS Code stable +and on 1.125.0 (15 each). + +The rig gate on the Mac mini for `be43dabb` (`quality:gates`, +`test:a11y`, `security:secrets`, `security:sast`; `lint:ps` skipped off +Windows): l10n 0 problems, no cycles, 0 clones, 187 test files passed and +2 skipped, 2,794 tests passed and 33 skipped, a11y 340 pages with 0 rules +violated, no leaks, 0 Semgrep findings, exit=0. Locally, `node +scripts/a11y.mjs code-intel`: 4 pages, 0 rules violated. + +## Codex on PR #57 + +Codex left two P2 threads on `db23f6a2`. Both held when checked in the +code; one commit fixes each with its siblings. + +| Thread | Fix and siblings | +| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `repoMap.ts`: the budget counted only the file blocks | `renderLines` counts every line it renders (the lead, the count of files left out, the notes) and adds a file only while the whole answer still fits. `repo_map` refuses a `max_tokens` too small for its own lead and notes and names the size that would do (`max_tokens: 0`, clamped to 1, now answers "ask again with max_tokens of at least 46" in the test workspace); an empty map's text is held to the budget the same way. Sibling: the prompt section, whose heading and lead are now counted in its 1,024 tokens. The tool's `max_tokens` description says the budget covers the whole answer. | +| `codeIntelTools.ts`: `document_symbols` opened the file by the workspace's path | It opens and outlines the file through `openAsEdited`: at the path of an editor holding unsaved changes to it (the real path, in a workspace opened through a link), with the unsaved note. Sibling sweep of every `service.open` and every service call on a named file: the rest already go through `openAsEdited` or a target made by it; `plannedFile` opens by the file's path only after refusing any file an editor holds unsaved, so it reads what the disk holds. | + +### Red drills (PR #57) + +Each guard broken in place, its suite run, the file restored and checked +by SHA-256 (`scratchpad/drills-m67-pr57.js`); every run exited 1. + +| Guard broken | Suite | Failed | +| -------------------------------------------------- | ---------------- | ------ | +| `document_symbols` asked at the editor's path | `codeIntelTools` | 1 | +| A budget too small for the fixed text refused | `repoMap` | 1 | +| Each file block counted with the map's other lines | `repoMap` | 1 | +| The prompt section's heading counted | `repoMap` | 1 | + +The complete `npm run quality` on the Mac mini rig for this code: l10n 0 +problems, no cycles, 0 clones, 187 test files passed and 2 skipped, 2,795 +tests passed and 33 skipped, audit 0, a11y 340 pages with 0 rules +violated, no leaks, 0 Semgrep findings, exit=0 (`lint:ps` skipped off +Windows). + +## Integration review (2026-09-29) + +The integrator merged the PR #32 candidate into the M67 candidate worktree and +preserved both branches' documentation, ACP entries, code intelligence fixture +and host-test entrypoints. Final integration waits for the verified PR #32 main +parent; historical Mac receipts above do not certify this combined tree. + +Review found a cancellation gap in `applyRename`: its bulk checks tested Stop, +then the first per-file recheck awaited I/O before writing without testing Stop +again. A cancellation during that last check could still write the rename. +The existing host test now holds each of the two checks of the first file after +approval, cancels while held, and verifies both files remain unchanged and the +turn ends cancelled. The writer checks cancellation immediately before its first +write; a rename already being written retains its existing completion behavior. + +Independent read-only review confirmed the gap through confinement, unsaved +editor and disk-read awaits; the added first-write check covers all of them. +In a disposable copy, both held-check cases passed, removing that check caused +the second case to fail on renamed-versus-original file content (exit 1), and +restoring the source passed all 20 host code intelligence tests (exit 0). +Production source SHA-256: +`29c48d13ec2d7bd9dc04e932145031467bb82241a6d6d97e26cf05ab56bc0923`. +Logs and receipt are outside the checkout in the system temporary evidence +directory `muse-goal-evidence-20260929/m67-stop-final-check`. Its initial missing +tsconfig setup failure is retained separately and is not a red proof. + +The unchanged host inventory gate failed on the integrated language adapter, +then regenerated the record: 234 VS Code APIs, 14 host files, 17 Node built-ins +and 57 theme variables, zero problems. Exact-tree quality, local Mac +mini/Kubuntu/Windows VM gates and current-parent hosted checks remain pending. + +The first combined candidate (`2a9d17fb`) passed formatting and lint but its +unchanged host type gate failed: ACP's `createToolIo` still supplied the old +`hasUnsavedChanges` adapter property after M67 introduced `unsavedFiles`. +The runtime adapter now supplies the empty editor-path list, preserving the +ACP limitation above without offering VS Code language services there. The +Windows host, Mac, Kubuntu and Windows VM failures are retained; they caught +the integration error before any M67 push. Final proof binds to the new tree. diff --git a/docs/ide-compatibility/host-api.md b/docs/ide-compatibility/host-api.md index e3754f94..ffec368f 100644 --- a/docs/ide-compatibility/host-api.md +++ b/docs/ide-compatibility/host-api.md @@ -29,23 +29,24 @@ differs from the source. Do not edit it by hand. ## Files that import `vscode` -The VS Code adapter: 13 files. Everything else reaches VS Code only through them. +The VS Code adapter: 14 files. Everything else reaches VS Code only through them. -| File | VS Code APIs used | -| -------------------------------------- | ----------------- | -| `src/extension.ts` | 144 | -| `src/host/cliFeatures.ts` | 26 | -| `src/host/memoryFeatures.ts` | 17 | -| `src/host/mention/mentionQuickPick.ts` | 10 | -| `src/host/paid/paidHost.ts` | 11 | -| `src/host/popups.ts` | 2 | -| `src/host/quickPick.ts` | 5 | -| `src/host/views/ChatViewProvider.ts` | 11 | -| `src/host/views/chatPanel.ts` | 11 | -| `src/host/views/surfaceRegistry.ts` | 1 | -| `src/host/views/webviewSetup.ts` | 11 | -| `src/host/voice/dictationHost.ts` | 5 | -| `src/host/worktreeFeatures.ts` | 16 | +| File | VS Code APIs used | +| ---------------------------------------- | ----------------- | +| `src/extension.ts` | 144 | +| `src/host/cliFeatures.ts` | 26 | +| `src/host/codeIntel/languageServices.ts` | 45 | +| `src/host/memoryFeatures.ts` | 17 | +| `src/host/mention/mentionQuickPick.ts` | 10 | +| `src/host/paid/paidHost.ts` | 11 | +| `src/host/popups.ts` | 2 | +| `src/host/quickPick.ts` | 5 | +| `src/host/views/ChatViewProvider.ts` | 11 | +| `src/host/views/chatPanel.ts` | 11 | +| `src/host/views/surfaceRegistry.ts` | 1 | +| `src/host/views/webviewSetup.ts` | 11 | +| `src/host/voice/dictationHost.ts` | 5 | +| `src/host/worktreeFeatures.ts` | 16 | ## Portable modules @@ -66,213 +67,246 @@ These never reach `vscode` through their imports, type-only ones included; the g - `src/host/conversation/conversationController.ts` - `src/host/ide/ideMcpServer.ts` -## VS Code API used at run time (201) +## VS Code API used at run time (234) Functions, variables, classes, enums and members declared in `@types/vscode`; the members of a VS Code interface the code implements (a provider, an options object); and the members of a VS Code object handed to code that takes it by shape. -| API | Files | -| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `Clipboard.writeText` | `src/extension.ts` | -| `ConfigurationChangeEvent.affectsConfiguration` | `src/extension.ts`, `src/host/paid/paidHost.ts` | -| `ConfigurationTarget.Global` | `src/extension.ts`, `src/host/paid/paidHost.ts` | -| `Diagnostic.message` | `src/extension.ts` | -| `Diagnostic.range` | `src/extension.ts` | -| `Diagnostic.severity` | `src/extension.ts` | -| `Diagnostic.source` | `src/extension.ts` | -| `Disposable.dispose` | `src/host/views/ChatViewProvider.ts`, `src/host/views/chatPanel.ts`, `src/host/views/surfaceRegistry.ts`, `src/host/views/webviewSetup.ts` | -| `Extension.extensionKind` | `src/host/voice/dictationHost.ts` | -| `Extension.packageJSON` | `src/extension.ts` | -| `ExtensionContext.extension` | `src/extension.ts` | -| `ExtensionContext.extensionPath` | `src/extension.ts` | -| `ExtensionContext.extensionUri` | `src/extension.ts` | -| `ExtensionContext.globalState` | `src/extension.ts` | -| `ExtensionContext.globalStorageUri` | `src/extension.ts` | -| `ExtensionContext.secrets` | `src/extension.ts` | -| `ExtensionContext.storageUri` | `src/extension.ts` | -| `ExtensionContext.subscriptions` | `src/extension.ts` | -| `ExtensionContext.subscriptions.dispose` | `src/extension.ts` | -| `ExtensionContext.workspaceState` | `src/extension.ts` | -| `ExtensionKind.UI` | `src/host/voice/dictationHost.ts` | -| `FileStat.size` | `src/extension.ts` | -| `FileSystem.delete` | `src/extension.ts`, `src/host/memoryFeatures.ts` | -| `FileSystem.delete(options.useTrash)` | `src/extension.ts`, `src/host/memoryFeatures.ts` | -| `FileSystem.readFile` | `src/extension.ts` | -| `FileSystem.stat` | `src/extension.ts` | -| `FileSystem.writeFile` | `src/extension.ts`, `src/host/cliFeatures.ts` | -| `FileSystemError` | `src/extension.ts` | -| `FileSystemError.code` | `src/extension.ts` | -| `FileSystemWatcher.onDidChange` | `src/extension.ts` | -| `FileSystemWatcher.onDidCreate` | `src/extension.ts` | -| `FileSystemWatcher.onDidDelete` | `src/extension.ts` | -| `InputBoxOptions.ignoreFocusOut` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | -| `InputBoxOptions.password` | `src/extension.ts` | -| `InputBoxOptions.placeHolder` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | -| `InputBoxOptions.title` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | -| `InputBoxOptions.validateInput` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | -| `LogOutputChannel.error` | `src/extension.ts` | -| `LogOutputChannel.info` | `src/extension.ts` | -| `LogOutputChannel.trace` | `src/extension.ts` | -| `LogOutputChannel.warn` | `src/extension.ts` | -| `Memento.get` | `src/extension.ts` | -| `Memento.update` | `src/extension.ts` | -| `MessageItem.isCloseAffordance` | `src/host/paid/paidHost.ts` | -| `MessageItem.title` | `src/host/paid/paidHost.ts` | -| `MessageOptions.detail` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts`, `src/host/paid/paidHost.ts`, `src/host/worktreeFeatures.ts` | -| `MessageOptions.modal` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts`, `src/host/paid/paidHost.ts`, `src/host/worktreeFeatures.ts` | -| `OpenDialogOptions.canSelectMany` | `src/extension.ts` | -| `OpenDialogOptions.openLabel` | `src/extension.ts` | -| `OutputChannel.show` | `src/extension.ts` | -| `Position` | `src/extension.ts` | -| `Position.character` | `src/extension.ts` | -| `Position.line` | `src/extension.ts` | -| `QuickInput.dispose` | `src/host/mention/mentionQuickPick.ts` | -| `QuickInput.hide` | `src/host/mention/mentionQuickPick.ts` | -| `QuickInput.onDidHide` | `src/host/mention/mentionQuickPick.ts` | -| `QuickInput.show` | `src/host/mention/mentionQuickPick.ts` | -| `QuickPick.items` | `src/host/mention/mentionQuickPick.ts` | -| `QuickPick.matchOnDescription` | `src/host/mention/mentionQuickPick.ts` | -| `QuickPick.onDidAccept` | `src/host/mention/mentionQuickPick.ts` | -| `QuickPick.onDidChangeValue` | `src/host/mention/mentionQuickPick.ts` | -| `QuickPick.placeholder` | `src/host/mention/mentionQuickPick.ts` | -| `QuickPick.selectedItems` | `src/host/mention/mentionQuickPick.ts` | -| `QuickPickItem.label` | `src/host/cliFeatures.ts` | -| `QuickPickOptions.canPickMany` | `src/host/cliFeatures.ts` | -| `QuickPickOptions.ignoreFocusOut` | `src/host/cliFeatures.ts` | -| `QuickPickOptions.matchOnDescription` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts`, `src/host/worktreeFeatures.ts` | -| `QuickPickOptions.matchOnDetail` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts` | -| `QuickPickOptions.placeHolder` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts`, `src/host/worktreeFeatures.ts` | -| `QuickPickOptions.title` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts`, `src/host/worktreeFeatures.ts` | -| `Range` | `src/extension.ts` | -| `Range.contains` | `src/extension.ts` | -| `Range.end` | `src/extension.ts` | -| `Range.intersection` | `src/extension.ts` | -| `Range.isEmpty` | `src/extension.ts` | -| `Range.isEqual` | `src/extension.ts` | -| `Range.isSingleLine` | `src/extension.ts` | -| `Range.start` | `src/extension.ts` | -| `Range.union` | `src/extension.ts` | -| `Range.with` | `src/extension.ts` | -| `RelativePattern` | `src/extension.ts` | -| `SaveDialogOptions.defaultUri` | `src/host/cliFeatures.ts` | -| `SaveDialogOptions.filters` | `src/host/cliFeatures.ts` | -| `SecretStorage.delete` | `src/extension.ts` | -| `SecretStorage.get` | `src/extension.ts` | -| `SecretStorage.store` | `src/extension.ts` | -| `Selection` | `src/extension.ts` | -| `Selection.active` | `src/extension.ts` | -| `Terminal.sendText` | `src/extension.ts` | -| `Terminal.show` | `src/extension.ts` | -| `TerminalOptions.cwd` | `src/extension.ts` | -| `TerminalOptions.env` | `src/extension.ts` | -| `TerminalOptions.name` | `src/extension.ts` | -| `TerminalOptions.shellPath` | `src/extension.ts` | -| `TextDocument.getText` | `src/extension.ts` | -| `TextDocument.isDirty` | `src/extension.ts` | -| `TextDocument.lineAt` | `src/extension.ts` | -| `TextDocument.lineCount` | `src/extension.ts` | -| `TextDocument.uri` | `src/extension.ts` | -| `TextDocumentContentProvider.provideTextDocumentContent` | `src/extension.ts` | -| `TextDocumentShowOptions.preview` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | -| `TextEditor.document` | `src/extension.ts` | -| `TextEditor.edit` | `src/extension.ts` | -| `TextEditor.revealRange` | `src/extension.ts` | -| `TextEditor.selection` | `src/extension.ts` | -| `TextEditorEdit.insert` | `src/extension.ts` | -| `TextEditorEdit.replace` | `src/extension.ts` | -| `TextEditorRevealType.InCenter` | `src/extension.ts` | -| `TextLine.range` | `src/extension.ts` | -| `Uri.authority` | `src/extension.ts` | -| `Uri.file` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | -| `Uri.fragment` | `src/extension.ts` | -| `Uri.from` | `src/extension.ts` | -| `Uri.from(components.path)` | `src/extension.ts` | -| `Uri.from(components.query)` | `src/extension.ts` | -| `Uri.from(components.scheme)` | `src/extension.ts` | -| `Uri.fsPath` | `src/extension.ts`, `src/host/cliFeatures.ts` | -| `Uri.joinPath` | `src/extension.ts`, `src/host/views/webviewSetup.ts` | -| `Uri.parse` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | -| `Uri.path` | `src/extension.ts` | -| `Uri.query` | `src/extension.ts` | -| `Uri.scheme` | `src/extension.ts`, `src/host/cliFeatures.ts` | -| `Uri.toString` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/views/webviewSetup.ts` | -| `ViewBadge.tooltip` | `src/host/views/ChatViewProvider.ts` | -| `ViewBadge.value` | `src/host/views/ChatViewProvider.ts` | -| `ViewColumn.Beside` | `src/host/views/chatPanel.ts` | -| `Webview.asWebviewUri` | `src/host/views/webviewSetup.ts` | -| `Webview.cspSource` | `src/host/views/webviewSetup.ts` | -| `Webview.html` | `src/host/views/webviewSetup.ts` | -| `Webview.onDidReceiveMessage` | `src/host/views/webviewSetup.ts` | -| `Webview.options` | `src/host/views/webviewSetup.ts` | -| `Webview.postMessage` | `src/host/views/webviewSetup.ts` | -| `WebviewOptions.enableScripts` | `src/host/views/webviewSetup.ts` | -| `WebviewOptions.localResourceRoots` | `src/host/views/webviewSetup.ts` | -| `WebviewPanel.active` | `src/host/views/chatPanel.ts` | -| `WebviewPanel.onDidChangeViewState` | `src/host/views/chatPanel.ts` | -| `WebviewPanel.onDidDispose` | `src/host/views/chatPanel.ts` | -| `WebviewPanel.reveal` | `src/host/views/chatPanel.ts` | -| `WebviewPanel.title` | `src/host/views/chatPanel.ts` | -| `WebviewPanel.webview` | `src/host/views/chatPanel.ts` | -| `WebviewPanelOnDidChangeViewStateEvent.webviewPanel` | `src/host/views/chatPanel.ts` | -| `WebviewPanelOptions.retainContextWhenHidden` | `src/host/views/chatPanel.ts` | -| `WebviewPanelSerializer.deserializeWebviewPanel` | `src/extension.ts` | -| `WebviewView.badge` | `src/host/views/ChatViewProvider.ts` | -| `WebviewView.description` | `src/host/views/ChatViewProvider.ts` | -| `WebviewView.onDidChangeVisibility` | `src/host/views/ChatViewProvider.ts` | -| `WebviewView.onDidDispose` | `src/host/views/ChatViewProvider.ts` | -| `WebviewView.show` | `src/host/views/ChatViewProvider.ts` | -| `WebviewView.visible` | `src/host/views/ChatViewProvider.ts` | -| `WebviewView.webview` | `src/host/views/ChatViewProvider.ts` | -| `WebviewViewProvider.resolveWebviewView` | `src/host/views/ChatViewProvider.ts` | -| `WindowState.focused` | `src/extension.ts`, `src/host/paid/paidHost.ts` | -| `WorkspaceConfiguration.get` | `src/extension.ts` | -| `WorkspaceConfiguration.update` | `src/extension.ts`, `src/host/paid/paidHost.ts` | -| `WorkspaceFolder.uri` | `src/extension.ts` | -| `commands.executeCommand` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/worktreeFeatures.ts` | -| `commands.registerCommand` | `src/extension.ts` | -| `env.appName` | `src/host/voice/dictationHost.ts` | -| `env.clipboard` | `src/extension.ts` | -| `env.language` | `src/extension.ts` | -| `env.openExternal` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | -| `env.remoteName` | `src/extension.ts`, `src/host/voice/dictationHost.ts` | -| `extensions.getExtension` | `src/host/voice/dictationHost.ts` | -| `languages.getDiagnostics` | `src/extension.ts` | -| `version` | `src/extension.ts` | -| `window.activeTextEditor` | `src/extension.ts` | -| `window.createOutputChannel` | `src/extension.ts` | -| `window.createOutputChannel(options.log)` | `src/extension.ts` | -| `window.createQuickPick` | `src/extension.ts` | -| `window.createTerminal` | `src/extension.ts` | -| `window.createWebviewPanel` | `src/host/views/chatPanel.ts` | -| `window.onDidChangeActiveTextEditor` | `src/extension.ts` | -| `window.onDidChangeTextEditorSelection` | `src/extension.ts` | -| `window.onDidChangeWindowState` | `src/extension.ts` | -| `window.registerWebviewPanelSerializer` | `src/extension.ts` | -| `window.registerWebviewViewProvider` | `src/extension.ts` | -| `window.registerWebviewViewProvider(options.webviewOptions)` | `src/extension.ts` | -| `window.registerWebviewViewProvider(options.webviewOptions.retainContextWhenHidden)` | `src/extension.ts` | -| `window.showErrorMessage` | `src/extension.ts`, `src/host/popups.ts` | -| `window.showInformationMessage` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | -| `window.showInputBox` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | -| `window.showOpenDialog` | `src/extension.ts` | -| `window.showQuickPick` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts`, `src/host/worktreeFeatures.ts` | -| `window.showSaveDialog` | `src/host/cliFeatures.ts` | -| `window.showTextDocument` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | -| `window.showWarningMessage` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/paid/paidHost.ts`, `src/host/popups.ts`, `src/host/worktreeFeatures.ts` | -| `window.state` | `src/host/paid/paidHost.ts` | -| `workspace.asRelativePath` | `src/extension.ts` | -| `workspace.createFileSystemWatcher` | `src/extension.ts` | -| `workspace.findFiles` | `src/extension.ts` | -| `workspace.fs` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | -| `workspace.getConfiguration` | `src/extension.ts`, `src/host/paid/paidHost.ts` | -| `workspace.getWorkspaceFolder` | `src/extension.ts` | -| `workspace.isTrusted` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/worktreeFeatures.ts` | -| `workspace.onDidChangeConfiguration` | `src/extension.ts` | -| `workspace.onDidGrantWorkspaceTrust` | `src/extension.ts` | -| `workspace.openTextDocument` | `src/extension.ts` | -| `workspace.registerTextDocumentContentProvider` | `src/extension.ts` | -| `workspace.saveAll` | `src/extension.ts` | -| `workspace.textDocuments` | `src/extension.ts` | -| `workspace.workspaceFolders` | `src/extension.ts` | +| API | Files | +| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CallHierarchyIncomingCall.from` | `src/host/codeIntel/languageServices.ts` | +| `CallHierarchyIncomingCall.fromRanges` | `src/host/codeIntel/languageServices.ts` | +| `CallHierarchyItem.detail` | `src/host/codeIntel/languageServices.ts` | +| `CallHierarchyItem.kind` | `src/host/codeIntel/languageServices.ts` | +| `CallHierarchyItem.name` | `src/host/codeIntel/languageServices.ts` | +| `CallHierarchyItem.range` | `src/host/codeIntel/languageServices.ts` | +| `CallHierarchyItem.selectionRange` | `src/host/codeIntel/languageServices.ts` | +| `CallHierarchyItem.uri` | `src/host/codeIntel/languageServices.ts` | +| `CallHierarchyOutgoingCall.fromRanges` | `src/host/codeIntel/languageServices.ts` | +| `CallHierarchyOutgoingCall.to` | `src/host/codeIntel/languageServices.ts` | +| `Clipboard.writeText` | `src/extension.ts` | +| `ConfigurationChangeEvent.affectsConfiguration` | `src/extension.ts`, `src/host/paid/paidHost.ts` | +| `ConfigurationTarget.Global` | `src/extension.ts`, `src/host/paid/paidHost.ts` | +| `Diagnostic.message` | `src/extension.ts` | +| `Diagnostic.range` | `src/extension.ts` | +| `Diagnostic.severity` | `src/extension.ts` | +| `Diagnostic.source` | `src/extension.ts` | +| `Disposable.dispose` | `src/host/views/ChatViewProvider.ts`, `src/host/views/chatPanel.ts`, `src/host/views/surfaceRegistry.ts`, `src/host/views/webviewSetup.ts` | +| `DocumentSymbol.children` | `src/host/codeIntel/languageServices.ts` | +| `DocumentSymbol.detail` | `src/host/codeIntel/languageServices.ts` | +| `DocumentSymbol.kind` | `src/host/codeIntel/languageServices.ts` | +| `DocumentSymbol.name` | `src/host/codeIntel/languageServices.ts` | +| `DocumentSymbol.range` | `src/host/codeIntel/languageServices.ts` | +| `DocumentSymbol.selectionRange` | `src/host/codeIntel/languageServices.ts` | +| `Extension.extensionKind` | `src/host/voice/dictationHost.ts` | +| `Extension.extensionPath` | `src/host/codeIntel/languageServices.ts` | +| `Extension.packageJSON` | `src/extension.ts` | +| `ExtensionContext.extension` | `src/extension.ts` | +| `ExtensionContext.extensionPath` | `src/extension.ts` | +| `ExtensionContext.extensionUri` | `src/extension.ts` | +| `ExtensionContext.globalState` | `src/extension.ts` | +| `ExtensionContext.globalStorageUri` | `src/extension.ts` | +| `ExtensionContext.secrets` | `src/extension.ts` | +| `ExtensionContext.storageUri` | `src/extension.ts` | +| `ExtensionContext.subscriptions` | `src/extension.ts` | +| `ExtensionContext.subscriptions.dispose` | `src/extension.ts` | +| `ExtensionContext.workspaceState` | `src/extension.ts` | +| `ExtensionKind.UI` | `src/host/voice/dictationHost.ts` | +| `FileStat.size` | `src/extension.ts` | +| `FileSystem.delete` | `src/extension.ts`, `src/host/memoryFeatures.ts` | +| `FileSystem.delete(options.useTrash)` | `src/extension.ts`, `src/host/memoryFeatures.ts` | +| `FileSystem.readFile` | `src/extension.ts` | +| `FileSystem.stat` | `src/extension.ts` | +| `FileSystem.writeFile` | `src/extension.ts`, `src/host/cliFeatures.ts` | +| `FileSystemError` | `src/extension.ts` | +| `FileSystemError.code` | `src/extension.ts` | +| `FileSystemWatcher.onDidChange` | `src/extension.ts` | +| `FileSystemWatcher.onDidCreate` | `src/extension.ts` | +| `FileSystemWatcher.onDidDelete` | `src/extension.ts` | +| `Hover.contents` | `src/host/codeIntel/languageServices.ts` | +| `InputBoxOptions.ignoreFocusOut` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `InputBoxOptions.password` | `src/extension.ts` | +| `InputBoxOptions.placeHolder` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `InputBoxOptions.title` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `InputBoxOptions.validateInput` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `Location.range` | `src/host/codeIntel/languageServices.ts` | +| `Location.uri` | `src/host/codeIntel/languageServices.ts` | +| `LocationLink.targetRange` | `src/host/codeIntel/languageServices.ts` | +| `LocationLink.targetSelectionRange` | `src/host/codeIntel/languageServices.ts` | +| `LocationLink.targetUri` | `src/host/codeIntel/languageServices.ts` | +| `LogOutputChannel.error` | `src/extension.ts` | +| `LogOutputChannel.info` | `src/extension.ts` | +| `LogOutputChannel.trace` | `src/extension.ts` | +| `LogOutputChannel.warn` | `src/extension.ts` | +| `Memento.get` | `src/extension.ts` | +| `Memento.update` | `src/extension.ts` | +| `MessageItem.isCloseAffordance` | `src/host/paid/paidHost.ts` | +| `MessageItem.title` | `src/host/paid/paidHost.ts` | +| `MessageOptions.detail` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts`, `src/host/paid/paidHost.ts`, `src/host/worktreeFeatures.ts` | +| `MessageOptions.modal` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts`, `src/host/paid/paidHost.ts`, `src/host/worktreeFeatures.ts` | +| `OpenDialogOptions.canSelectMany` | `src/extension.ts` | +| `OpenDialogOptions.openLabel` | `src/extension.ts` | +| `OutputChannel.show` | `src/extension.ts` | +| `Position` | `src/extension.ts`, `src/host/codeIntel/languageServices.ts` | +| `Position.character` | `src/extension.ts`, `src/host/codeIntel/languageServices.ts` | +| `Position.line` | `src/extension.ts`, `src/host/codeIntel/languageServices.ts` | +| `QuickInput.dispose` | `src/host/mention/mentionQuickPick.ts` | +| `QuickInput.hide` | `src/host/mention/mentionQuickPick.ts` | +| `QuickInput.onDidHide` | `src/host/mention/mentionQuickPick.ts` | +| `QuickInput.show` | `src/host/mention/mentionQuickPick.ts` | +| `QuickPick.items` | `src/host/mention/mentionQuickPick.ts` | +| `QuickPick.matchOnDescription` | `src/host/mention/mentionQuickPick.ts` | +| `QuickPick.onDidAccept` | `src/host/mention/mentionQuickPick.ts` | +| `QuickPick.onDidChangeValue` | `src/host/mention/mentionQuickPick.ts` | +| `QuickPick.placeholder` | `src/host/mention/mentionQuickPick.ts` | +| `QuickPick.selectedItems` | `src/host/mention/mentionQuickPick.ts` | +| `QuickPickItem.label` | `src/host/cliFeatures.ts` | +| `QuickPickOptions.canPickMany` | `src/host/cliFeatures.ts` | +| `QuickPickOptions.ignoreFocusOut` | `src/host/cliFeatures.ts` | +| `QuickPickOptions.matchOnDescription` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts`, `src/host/worktreeFeatures.ts` | +| `QuickPickOptions.matchOnDetail` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts` | +| `QuickPickOptions.placeHolder` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts`, `src/host/worktreeFeatures.ts` | +| `QuickPickOptions.title` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts`, `src/host/worktreeFeatures.ts` | +| `Range` | `src/extension.ts` | +| `Range.contains` | `src/extension.ts` | +| `Range.end` | `src/extension.ts`, `src/host/codeIntel/languageServices.ts` | +| `Range.intersection` | `src/extension.ts` | +| `Range.isEmpty` | `src/extension.ts` | +| `Range.isEqual` | `src/extension.ts` | +| `Range.isSingleLine` | `src/extension.ts` | +| `Range.start` | `src/extension.ts`, `src/host/codeIntel/languageServices.ts` | +| `Range.union` | `src/extension.ts` | +| `Range.with` | `src/extension.ts` | +| `RelativePattern` | `src/extension.ts` | +| `SaveDialogOptions.defaultUri` | `src/host/cliFeatures.ts` | +| `SaveDialogOptions.filters` | `src/host/cliFeatures.ts` | +| `SecretStorage.delete` | `src/extension.ts` | +| `SecretStorage.get` | `src/extension.ts` | +| `SecretStorage.store` | `src/extension.ts` | +| `Selection` | `src/extension.ts` | +| `Selection.active` | `src/extension.ts` | +| `SymbolInformation.containerName` | `src/host/codeIntel/languageServices.ts` | +| `SymbolInformation.kind` | `src/host/codeIntel/languageServices.ts` | +| `SymbolInformation.location` | `src/host/codeIntel/languageServices.ts` | +| `SymbolInformation.name` | `src/host/codeIntel/languageServices.ts` | +| `Terminal.sendText` | `src/extension.ts` | +| `Terminal.show` | `src/extension.ts` | +| `TerminalOptions.cwd` | `src/extension.ts` | +| `TerminalOptions.env` | `src/extension.ts` | +| `TerminalOptions.name` | `src/extension.ts` | +| `TerminalOptions.shellPath` | `src/extension.ts` | +| `TextDocument.getText` | `src/extension.ts`, `src/host/codeIntel/languageServices.ts` | +| `TextDocument.isDirty` | `src/extension.ts`, `src/host/codeIntel/languageServices.ts` | +| `TextDocument.languageId` | `src/host/codeIntel/languageServices.ts` | +| `TextDocument.lineAt` | `src/extension.ts` | +| `TextDocument.lineCount` | `src/extension.ts` | +| `TextDocument.uri` | `src/extension.ts` | +| `TextDocumentContentProvider.provideTextDocumentContent` | `src/extension.ts` | +| `TextDocumentShowOptions.preview` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | +| `TextEdit.newText` | `src/host/codeIntel/languageServices.ts` | +| `TextEdit.range` | `src/host/codeIntel/languageServices.ts` | +| `TextEditor.document` | `src/extension.ts` | +| `TextEditor.edit` | `src/extension.ts` | +| `TextEditor.revealRange` | `src/extension.ts` | +| `TextEditor.selection` | `src/extension.ts` | +| `TextEditorEdit.insert` | `src/extension.ts` | +| `TextEditorEdit.replace` | `src/extension.ts` | +| `TextEditorRevealType.InCenter` | `src/extension.ts` | +| `TextLine.range` | `src/extension.ts` | +| `Uri.authority` | `src/extension.ts` | +| `Uri.file` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/codeIntel/languageServices.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `Uri.fragment` | `src/extension.ts` | +| `Uri.from` | `src/extension.ts` | +| `Uri.from(components.path)` | `src/extension.ts` | +| `Uri.from(components.query)` | `src/extension.ts` | +| `Uri.from(components.scheme)` | `src/extension.ts` | +| `Uri.fsPath` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/codeIntel/languageServices.ts` | +| `Uri.joinPath` | `src/extension.ts`, `src/host/views/webviewSetup.ts` | +| `Uri.parse` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | +| `Uri.path` | `src/extension.ts` | +| `Uri.query` | `src/extension.ts` | +| `Uri.scheme` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/codeIntel/languageServices.ts` | +| `Uri.toString` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/views/webviewSetup.ts` | +| `ViewBadge.tooltip` | `src/host/views/ChatViewProvider.ts` | +| `ViewBadge.value` | `src/host/views/ChatViewProvider.ts` | +| `ViewColumn.Beside` | `src/host/views/chatPanel.ts` | +| `Webview.asWebviewUri` | `src/host/views/webviewSetup.ts` | +| `Webview.cspSource` | `src/host/views/webviewSetup.ts` | +| `Webview.html` | `src/host/views/webviewSetup.ts` | +| `Webview.onDidReceiveMessage` | `src/host/views/webviewSetup.ts` | +| `Webview.options` | `src/host/views/webviewSetup.ts` | +| `Webview.postMessage` | `src/host/views/webviewSetup.ts` | +| `WebviewOptions.enableScripts` | `src/host/views/webviewSetup.ts` | +| `WebviewOptions.localResourceRoots` | `src/host/views/webviewSetup.ts` | +| `WebviewPanel.active` | `src/host/views/chatPanel.ts` | +| `WebviewPanel.onDidChangeViewState` | `src/host/views/chatPanel.ts` | +| `WebviewPanel.onDidDispose` | `src/host/views/chatPanel.ts` | +| `WebviewPanel.reveal` | `src/host/views/chatPanel.ts` | +| `WebviewPanel.title` | `src/host/views/chatPanel.ts` | +| `WebviewPanel.webview` | `src/host/views/chatPanel.ts` | +| `WebviewPanelOnDidChangeViewStateEvent.webviewPanel` | `src/host/views/chatPanel.ts` | +| `WebviewPanelOptions.retainContextWhenHidden` | `src/host/views/chatPanel.ts` | +| `WebviewPanelSerializer.deserializeWebviewPanel` | `src/extension.ts` | +| `WebviewView.badge` | `src/host/views/ChatViewProvider.ts` | +| `WebviewView.description` | `src/host/views/ChatViewProvider.ts` | +| `WebviewView.onDidChangeVisibility` | `src/host/views/ChatViewProvider.ts` | +| `WebviewView.onDidDispose` | `src/host/views/ChatViewProvider.ts` | +| `WebviewView.show` | `src/host/views/ChatViewProvider.ts` | +| `WebviewView.visible` | `src/host/views/ChatViewProvider.ts` | +| `WebviewView.webview` | `src/host/views/ChatViewProvider.ts` | +| `WebviewViewProvider.resolveWebviewView` | `src/host/views/ChatViewProvider.ts` | +| `WindowState.focused` | `src/extension.ts`, `src/host/paid/paidHost.ts` | +| `WorkspaceConfiguration.get` | `src/extension.ts` | +| `WorkspaceConfiguration.update` | `src/extension.ts`, `src/host/paid/paidHost.ts` | +| `WorkspaceEdit.entries` | `src/host/codeIntel/languageServices.ts` | +| `WorkspaceFolder.uri` | `src/extension.ts` | +| `commands.executeCommand` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/codeIntel/languageServices.ts`, `src/host/worktreeFeatures.ts` | +| `commands.registerCommand` | `src/extension.ts` | +| `env.appName` | `src/host/voice/dictationHost.ts` | +| `env.appRoot` | `src/host/codeIntel/languageServices.ts` | +| `env.clipboard` | `src/extension.ts` | +| `env.language` | `src/extension.ts` | +| `env.openExternal` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | +| `env.remoteName` | `src/extension.ts`, `src/host/voice/dictationHost.ts` | +| `extensions.all` | `src/host/codeIntel/languageServices.ts` | +| `extensions.getExtension` | `src/host/voice/dictationHost.ts` | +| `languages.getDiagnostics` | `src/extension.ts` | +| `version` | `src/extension.ts` | +| `window.activeTextEditor` | `src/extension.ts` | +| `window.createOutputChannel` | `src/extension.ts` | +| `window.createOutputChannel(options.log)` | `src/extension.ts` | +| `window.createQuickPick` | `src/extension.ts` | +| `window.createTerminal` | `src/extension.ts` | +| `window.createWebviewPanel` | `src/host/views/chatPanel.ts` | +| `window.onDidChangeActiveTextEditor` | `src/extension.ts` | +| `window.onDidChangeTextEditorSelection` | `src/extension.ts` | +| `window.onDidChangeWindowState` | `src/extension.ts` | +| `window.registerWebviewPanelSerializer` | `src/extension.ts` | +| `window.registerWebviewViewProvider` | `src/extension.ts` | +| `window.registerWebviewViewProvider(options.webviewOptions)` | `src/extension.ts` | +| `window.registerWebviewViewProvider(options.webviewOptions.retainContextWhenHidden)` | `src/extension.ts` | +| `window.showErrorMessage` | `src/extension.ts`, `src/host/popups.ts` | +| `window.showInformationMessage` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `window.showInputBox` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `window.showOpenDialog` | `src/extension.ts` | +| `window.showQuickPick` | `src/host/cliFeatures.ts`, `src/host/quickPick.ts`, `src/host/worktreeFeatures.ts` | +| `window.showSaveDialog` | `src/host/cliFeatures.ts` | +| `window.showTextDocument` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | +| `window.showWarningMessage` | `src/extension.ts`, `src/host/memoryFeatures.ts`, `src/host/paid/paidHost.ts`, `src/host/popups.ts`, `src/host/worktreeFeatures.ts` | +| `window.state` | `src/host/paid/paidHost.ts` | +| `workspace.asRelativePath` | `src/extension.ts` | +| `workspace.createFileSystemWatcher` | `src/extension.ts` | +| `workspace.findFiles` | `src/extension.ts` | +| `workspace.fs` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/memoryFeatures.ts` | +| `workspace.getConfiguration` | `src/extension.ts`, `src/host/paid/paidHost.ts` | +| `workspace.getWorkspaceFolder` | `src/extension.ts` | +| `workspace.isTrusted` | `src/extension.ts`, `src/host/cliFeatures.ts`, `src/host/worktreeFeatures.ts` | +| `workspace.onDidChangeConfiguration` | `src/extension.ts` | +| `workspace.onDidGrantWorkspaceTrust` | `src/extension.ts` | +| `workspace.openTextDocument` | `src/extension.ts`, `src/host/codeIntel/languageServices.ts` | +| `workspace.registerTextDocumentContentProvider` | `src/extension.ts` | +| `workspace.saveAll` | `src/extension.ts` | +| `workspace.textDocuments` | `src/extension.ts` | +| `workspace.workspaceFolders` | `src/extension.ts` | ## Node built-ins the host imports (17) diff --git a/knip.jsonc b/knip.jsonc index 56baea3e..98a7f1cb 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -34,6 +34,9 @@ "test/unit/helpers/fakeMcpPrebindParent.mjs", "test/unit/helpers/fakeMcpStartMarker.mjs", "test/integration/**/*.test.ts", + // The TypeScript fixture VS Code's language service reads in the code + // intelligence integration test (M67); main.ts imports the rest. + "test/fixtures/workspace/code-intel/main.ts", "test/hosts/*.mjs", "scripts/**/*.mjs", ".vscode-test.mjs" diff --git a/l10n/ui.cs.json b/l10n/ui.cs.json index bbbc95cf..f50a528e 100644 --- a/l10n/ui.cs.json +++ b/l10n/ui.cs.json @@ -816,7 +816,23 @@ "TodoWrite": "Úkoly", "snooze_reminder": "Odložení připomenutí", "submit_reminder_decision": "Připomenutí", - "submit_result": "Výsledek" + "submit_result": "Výsledek", + "find_definition": "Definice", + "find_references": "Odkazy", + "workspace_symbols": "Symboly", + "document_symbols": "Osnova", + "hover": "Popisek", + "call_hierarchy": "Volání", + "repo_map": "Mapa repozitáře", + "rename_symbol": "Přejmenování", + "mcp__ide__findDefinition": "Definice", + "mcp__ide__findReferences": "Odkazy", + "mcp__ide__workspaceSymbols": "Symboly", + "mcp__ide__documentSymbols": "Osnova", + "mcp__ide__hover": "Popisek", + "mcp__ide__callHierarchy": "Volání", + "mcp__ide__repoMap": "Mapa repozitáře", + "mcp__ide__renameSymbol": "Přejmenování" }, "mcpToolLabel": "{tool} ({server})", "memoryScopes": { @@ -1085,6 +1101,15 @@ "museStoppedBySignal": "Muse Code byl zastaven (signál: {signal})", "museUnknownSignal": "neznámý", "approvalImageSources": "Na základě {paths}", + "renameCardMore": { + "one": "{files} a {count} další soubor", + "few": "{files} a {count} další soubory", + "many": "{files} a {count} dalšího souboru", + "other": "{files} a {count} dalších souborů" + }, + "codeIntelNoService": "Pro {path} neodpověděla žádná jazyková služba, nebo soubor nedeklaruje žádné symboly.", + "codeIntelTimedOut": "Jazyková služba neodpověděla do {seconds} s.", + "repoMapNoService": "Pro symboly pracovního prostoru neodpověděla žádná jazyková služba.", "imageBuyTitle": "Muse chce vytvořit obrázek {path}", "imageBuyEditTitle": "Muse chce vytvořit upravený obrázek {path}", "imageBuyPrompt": "Výzva: {prompt}", diff --git a/l10n/ui.de.json b/l10n/ui.de.json index 191757f0..6ca62a7b 100644 --- a/l10n/ui.de.json +++ b/l10n/ui.de.json @@ -782,7 +782,23 @@ "TodoWrite": "Aufgaben", "snooze_reminder": "Erinnerung zurückstellen", "submit_reminder_decision": "Erinnerung", - "submit_result": "Ergebnis" + "submit_result": "Ergebnis", + "find_definition": "Definition", + "find_references": "Verweise", + "workspace_symbols": "Symbole", + "document_symbols": "Gliederung", + "hover": "Hover-Info", + "call_hierarchy": "Aufrufe", + "repo_map": "Repo-Karte", + "rename_symbol": "Umbenennen", + "mcp__ide__findDefinition": "Definition", + "mcp__ide__findReferences": "Verweise", + "mcp__ide__workspaceSymbols": "Symbole", + "mcp__ide__documentSymbols": "Gliederung", + "mcp__ide__hover": "Hover-Info", + "mcp__ide__callHierarchy": "Aufrufe", + "mcp__ide__repoMap": "Repo-Karte", + "mcp__ide__renameSymbol": "Umbenennen" }, "mcpToolLabel": "{tool} ({server})", "memoryScopes": { @@ -1031,6 +1047,13 @@ "museStoppedBySignal": "Muse Code wurde durch {signal} angehalten", "museUnknownSignal": "ein unbekanntes Signal", "approvalImageSources": "Ausgehend von {paths}", + "renameCardMore": { + "one": "{files} und {count} weitere Datei", + "other": "{files} und {count} weitere Dateien" + }, + "codeIntelNoService": "Für {path} hat kein Sprachdienst geantwortet, oder die Datei deklariert keine Symbole.", + "codeIntelTimedOut": "Der Sprachdienst hat nicht innerhalb von {seconds} Sekunden geantwortet.", + "repoMapNoService": "Für die Symbole des Arbeitsbereichs hat kein Sprachdienst geantwortet.", "imageBuyTitle": "Muse möchte das Bild {path} erstellen", "imageBuyEditTitle": "Muse möchte das bearbeitete Bild {path} erstellen", "imageBuyPrompt": "Prompt: {prompt}", diff --git a/l10n/ui.es.json b/l10n/ui.es.json index c07b699b..b4f76292 100644 --- a/l10n/ui.es.json +++ b/l10n/ui.es.json @@ -799,7 +799,23 @@ "TodoWrite": "Tareas", "snooze_reminder": "Posponer recordatorio", "submit_reminder_decision": "Recordatorio", - "submit_result": "Resultado" + "submit_result": "Resultado", + "find_definition": "Definición", + "find_references": "Referencias", + "workspace_symbols": "Símbolos", + "document_symbols": "Esquema", + "hover": "Información emergente", + "call_hierarchy": "Llamadas", + "repo_map": "Mapa del repositorio", + "rename_symbol": "Cambiar nombre", + "mcp__ide__findDefinition": "Definición", + "mcp__ide__findReferences": "Referencias", + "mcp__ide__workspaceSymbols": "Símbolos", + "mcp__ide__documentSymbols": "Esquema", + "mcp__ide__hover": "Información emergente", + "mcp__ide__callHierarchy": "Llamadas", + "mcp__ide__repoMap": "Mapa del repositorio", + "mcp__ide__renameSymbol": "Cambiar nombre" }, "mcpToolLabel": "{tool} ({server})", "memoryScopes": { @@ -1058,6 +1074,14 @@ "museStoppedBySignal": "Muse Code se detuvo por {signal}", "museUnknownSignal": "una señal desconocida", "approvalImageSources": "A partir de {paths}", + "renameCardMore": { + "one": "{files} y {count} archivo más", + "many": "{files} y {count} de archivos más", + "other": "{files} y {count} archivos más" + }, + "codeIntelNoService": "Ningún servicio de lenguaje respondió para {path}, o el archivo no declara símbolos.", + "codeIntelTimedOut": "El servicio de lenguaje no respondió en {seconds} segundos.", + "repoMapNoService": "Ningún servicio de lenguaje respondió con los símbolos del área de trabajo.", "imageBuyTitle": "Muse quiere crear la imagen {path}", "imageBuyEditTitle": "Muse quiere hacer la imagen editada {path}", "imageBuyPrompt": "Indicación: {prompt}", diff --git a/l10n/ui.fr.json b/l10n/ui.fr.json index 4cd60a3e..04af9750 100644 --- a/l10n/ui.fr.json +++ b/l10n/ui.fr.json @@ -799,7 +799,23 @@ "TodoWrite": "Tâches", "snooze_reminder": "Reporter le rappel", "submit_reminder_decision": "Rappel", - "submit_result": "Résultat" + "submit_result": "Résultat", + "find_definition": "Définition", + "find_references": "Références", + "workspace_symbols": "Symboles", + "document_symbols": "Structure", + "hover": "Info-bulle", + "call_hierarchy": "Appels", + "repo_map": "Carte du dépôt", + "rename_symbol": "Renommer", + "mcp__ide__findDefinition": "Définition", + "mcp__ide__findReferences": "Références", + "mcp__ide__workspaceSymbols": "Symboles", + "mcp__ide__documentSymbols": "Structure", + "mcp__ide__hover": "Info-bulle", + "mcp__ide__callHierarchy": "Appels", + "mcp__ide__repoMap": "Carte du dépôt", + "mcp__ide__renameSymbol": "Renommer" }, "mcpToolLabel": "{tool} ({server})", "memoryScopes": { @@ -1058,6 +1074,14 @@ "museStoppedBySignal": "Muse Code a été arrêté par {signal}", "museUnknownSignal": "un signal inconnu", "approvalImageSources": "À partir de {paths}", + "renameCardMore": { + "one": "{files} et {count} autre fichier", + "many": "{files} et {count} de fichiers en plus", + "other": "{files} et {count} autres fichiers" + }, + "codeIntelNoService": "Aucun service de langage n’a répondu pour {path}, ou le fichier ne déclare aucun symbole.", + "codeIntelTimedOut": "Le service de langage n’a pas répondu dans les {seconds} secondes.", + "repoMapNoService": "Aucun service de langage n’a répondu pour les symboles de l’espace de travail.", "imageBuyTitle": "Muse souhaite créer l’image {path}", "imageBuyEditTitle": "Muse souhaite créer l’image modifiée {path}", "imageBuyPrompt": "Invite : {prompt}", diff --git a/l10n/ui.hu.json b/l10n/ui.hu.json index 2314f8b5..5289ab60 100644 --- a/l10n/ui.hu.json +++ b/l10n/ui.hu.json @@ -782,7 +782,23 @@ "TodoWrite": "Feladatok", "snooze_reminder": "Emlékeztető elhalasztása", "submit_reminder_decision": "Emlékeztető", - "submit_result": "Eredmény" + "submit_result": "Eredmény", + "find_definition": "Definíció", + "find_references": "Hivatkozások", + "workspace_symbols": "Szimbólumok", + "document_symbols": "Vázlat", + "hover": "Súgó", + "call_hierarchy": "Hívások", + "repo_map": "Tárolótérkép", + "rename_symbol": "Átnevezés", + "mcp__ide__findDefinition": "Definíció", + "mcp__ide__findReferences": "Hivatkozások", + "mcp__ide__workspaceSymbols": "Szimbólumok", + "mcp__ide__documentSymbols": "Vázlat", + "mcp__ide__hover": "Súgó", + "mcp__ide__callHierarchy": "Hívások", + "mcp__ide__repoMap": "Tárolótérkép", + "mcp__ide__renameSymbol": "Átnevezés" }, "mcpToolLabel": "{tool} ({server})", "memoryScopes": { @@ -1031,6 +1047,13 @@ "museStoppedBySignal": "A Muse Code-ot a következő jel állította le: {signal}", "museUnknownSignal": "egy ismeretlen jel", "approvalImageSources": "Kiindulópont: {paths}", + "renameCardMore": { + "one": "{files} és még {count} fájl", + "other": "{files} és még {count} fájl" + }, + "codeIntelNoService": "Egyetlen nyelvi szolgáltatás sem válaszolt erre: {path}, vagy a fájl nem deklarál szimbólumokat.", + "codeIntelTimedOut": "A nyelvi szolgáltatás {seconds} másodpercen belül nem válaszolt.", + "repoMapNoService": "A munkaterület szimbólumaira egyetlen nyelvi szolgáltatás sem válaszolt.", "imageBuyTitle": "A Muse létre szeretné hozni ezt a képet: {path}", "imageBuyEditTitle": "A Muse létre szeretné hozni ezt a szerkesztett képet: {path}", "imageBuyPrompt": "Prompt: {prompt}", diff --git a/l10n/ui.it.json b/l10n/ui.it.json index 2a565c9d..30760c30 100644 --- a/l10n/ui.it.json +++ b/l10n/ui.it.json @@ -799,7 +799,23 @@ "TodoWrite": "Attività", "snooze_reminder": "Posticipa promemoria", "submit_reminder_decision": "Promemoria", - "submit_result": "Risultato" + "submit_result": "Risultato", + "find_definition": "Definizione", + "find_references": "Riferimenti", + "workspace_symbols": "Simboli", + "document_symbols": "Struttura", + "hover": "Informazioni al passaggio", + "call_hierarchy": "Chiamate", + "repo_map": "Mappa del repository", + "rename_symbol": "Rinomina", + "mcp__ide__findDefinition": "Definizione", + "mcp__ide__findReferences": "Riferimenti", + "mcp__ide__workspaceSymbols": "Simboli", + "mcp__ide__documentSymbols": "Struttura", + "mcp__ide__hover": "Informazioni al passaggio", + "mcp__ide__callHierarchy": "Chiamate", + "mcp__ide__repoMap": "Mappa del repository", + "mcp__ide__renameSymbol": "Rinomina" }, "mcpToolLabel": "{tool} ({server})", "memoryScopes": { @@ -1058,6 +1074,14 @@ "museStoppedBySignal": "Muse Code è stato arrestato da {signal}", "museUnknownSignal": "un segnale sconosciuto", "approvalImageSources": "A partire da {paths}", + "renameCardMore": { + "one": "{files} e {count} altro file", + "many": "{files} e altri {count} di file", + "other": "{files} e altri {count} file" + }, + "codeIntelNoService": "Nessun servizio di linguaggio ha risposto per {path}, oppure il file non dichiara simboli.", + "codeIntelTimedOut": "Il servizio di linguaggio non ha risposto entro {seconds} secondi.", + "repoMapNoService": "Nessun servizio di linguaggio ha risposto per i simboli dell’area di lavoro.", "imageBuyTitle": "Muse vuole creare l’immagine {path}", "imageBuyEditTitle": "Muse vuole creare l’immagine modificata {path}", "imageBuyPrompt": "Prompt: {prompt}", diff --git a/l10n/ui.ja.json b/l10n/ui.ja.json index cc3bc69f..2aed8fa9 100644 --- a/l10n/ui.ja.json +++ b/l10n/ui.ja.json @@ -765,7 +765,23 @@ "TodoWrite": "タスク", "snooze_reminder": "リマインダーをスヌーズ", "submit_reminder_decision": "リマインダー", - "submit_result": "結果" + "submit_result": "結果", + "find_definition": "定義", + "find_references": "参照", + "workspace_symbols": "シンボル", + "document_symbols": "アウトライン", + "hover": "ホバー", + "call_hierarchy": "呼び出し", + "repo_map": "リポジトリマップ", + "rename_symbol": "名前の変更", + "mcp__ide__findDefinition": "定義", + "mcp__ide__findReferences": "参照", + "mcp__ide__workspaceSymbols": "シンボル", + "mcp__ide__documentSymbols": "アウトライン", + "mcp__ide__hover": "ホバー", + "mcp__ide__callHierarchy": "呼び出し", + "mcp__ide__repoMap": "リポジトリマップ", + "mcp__ide__renameSymbol": "名前の変更" }, "mcpToolLabel": "{tool} ({server})", "memoryScopes": { @@ -1004,6 +1020,12 @@ "museStoppedBySignal": "Muse Code は {signal} によって停止されました", "museUnknownSignal": "不明なシグナル", "approvalImageSources": "{paths} から", + "renameCardMore": { + "other": "{files} ほか {count} 個のファイル" + }, + "codeIntelNoService": "{path} に応答した言語サービスがないか、このファイルがシンボルを宣言していません。", + "codeIntelTimedOut": "言語サービスが {seconds} 秒以内に応答しませんでした。", + "repoMapNoService": "ワークスペースのシンボルに応答した言語サービスはありません。", "imageBuyTitle": "Muse が画像 {path} の作成を求めています", "imageBuyEditTitle": "Muse が編集後の画像 {path} の作成を求めています", "imageBuyPrompt": "プロンプト: {prompt}", diff --git a/l10n/ui.ko.json b/l10n/ui.ko.json index e680ddd3..35e5fef3 100644 --- a/l10n/ui.ko.json +++ b/l10n/ui.ko.json @@ -765,7 +765,23 @@ "TodoWrite": "할 일", "snooze_reminder": "알림 미루기", "submit_reminder_decision": "알림", - "submit_result": "결과" + "submit_result": "결과", + "find_definition": "정의", + "find_references": "참조", + "workspace_symbols": "기호", + "document_symbols": "개요", + "hover": "호버", + "call_hierarchy": "호출", + "repo_map": "저장소 맵", + "rename_symbol": "이름 바꾸기", + "mcp__ide__findDefinition": "정의", + "mcp__ide__findReferences": "참조", + "mcp__ide__workspaceSymbols": "기호", + "mcp__ide__documentSymbols": "개요", + "mcp__ide__hover": "호버", + "mcp__ide__callHierarchy": "호출", + "mcp__ide__repoMap": "저장소 맵", + "mcp__ide__renameSymbol": "이름 바꾸기" }, "mcpToolLabel": "{tool} ({server})", "memoryScopes": { @@ -1004,6 +1020,12 @@ "museStoppedBySignal": "Muse Code가 {signal}에 의해 중지되었습니다", "museUnknownSignal": "알 수 없는 신호", "approvalImageSources": "{paths}에서 시작", + "renameCardMore": { + "other": "{files} 외 파일 {count}개" + }, + "codeIntelNoService": "{path}에 응답한 언어 서비스가 없거나 파일에 선언된 기호가 없습니다.", + "codeIntelTimedOut": "언어 서비스가 {seconds}초 안에 응답하지 않았습니다.", + "repoMapNoService": "작업 영역 기호에 응답한 언어 서비스가 없습니다.", "imageBuyTitle": "Muse가 이미지 {path} 생성을 요청합니다", "imageBuyEditTitle": "Muse가 편집된 이미지 {path} 생성을 요청합니다", "imageBuyPrompt": "프롬프트: {prompt}", diff --git a/l10n/ui.pl.json b/l10n/ui.pl.json index 77437d82..68748d6d 100644 --- a/l10n/ui.pl.json +++ b/l10n/ui.pl.json @@ -816,7 +816,23 @@ "TodoWrite": "Zadania", "snooze_reminder": "Odłóż przypomnienie", "submit_reminder_decision": "Przypomnienie", - "submit_result": "Wynik" + "submit_result": "Wynik", + "find_definition": "Definicja", + "find_references": "Odwołania", + "workspace_symbols": "Symbole", + "document_symbols": "Konspekt", + "hover": "Etykietka", + "call_hierarchy": "Wywołania", + "repo_map": "Mapa repozytorium", + "rename_symbol": "Zmiana nazwy", + "mcp__ide__findDefinition": "Definicja", + "mcp__ide__findReferences": "Odwołania", + "mcp__ide__workspaceSymbols": "Symbole", + "mcp__ide__documentSymbols": "Konspekt", + "mcp__ide__hover": "Etykietka", + "mcp__ide__callHierarchy": "Wywołania", + "mcp__ide__repoMap": "Mapa repozytorium", + "mcp__ide__renameSymbol": "Zmiana nazwy" }, "mcpToolLabel": "{tool} ({server})", "memoryScopes": { @@ -1085,6 +1101,15 @@ "museStoppedBySignal": "Muse Code został zatrzymany przez {signal}", "museUnknownSignal": "nieznany sygnał", "approvalImageSources": "Na podstawie {paths}", + "renameCardMore": { + "one": "{files} i {count} inny plik", + "few": "{files} i {count} inne pliki", + "many": "{files} i {count} innych plików", + "other": "{files} i {count} innego pliku" + }, + "codeIntelNoService": "Żadna usługa językowa nie odpowiedziała dla {path} albo plik nie deklaruje żadnych symboli.", + "codeIntelTimedOut": "Usługa językowa nie odpowiedziała w ciągu {seconds} s.", + "repoMapNoService": "Żadna usługa językowa nie odpowiedziała dla symboli obszaru roboczego.", "imageBuyTitle": "Muse chce utworzyć obraz {path}", "imageBuyEditTitle": "Muse chce utworzyć edytowany obraz {path}", "imageBuyPrompt": "Prompt: {prompt}", diff --git a/l10n/ui.pt-br.json b/l10n/ui.pt-br.json index 46a567d5..2ad852ab 100644 --- a/l10n/ui.pt-br.json +++ b/l10n/ui.pt-br.json @@ -799,7 +799,23 @@ "TodoWrite": "Tarefas", "snooze_reminder": "Adiar lembrete", "submit_reminder_decision": "Lembrete", - "submit_result": "Resultado" + "submit_result": "Resultado", + "find_definition": "Definição", + "find_references": "Referências", + "workspace_symbols": "Símbolos", + "document_symbols": "Estrutura", + "hover": "Dica de foco", + "call_hierarchy": "Chamadas", + "repo_map": "Mapa do repositório", + "rename_symbol": "Renomear", + "mcp__ide__findDefinition": "Definição", + "mcp__ide__findReferences": "Referências", + "mcp__ide__workspaceSymbols": "Símbolos", + "mcp__ide__documentSymbols": "Estrutura", + "mcp__ide__hover": "Dica de foco", + "mcp__ide__callHierarchy": "Chamadas", + "mcp__ide__repoMap": "Mapa do repositório", + "mcp__ide__renameSymbol": "Renomear" }, "mcpToolLabel": "{tool} ({server})", "memoryScopes": { @@ -1058,6 +1074,14 @@ "museStoppedBySignal": "o Muse Code foi parado por {signal}", "museUnknownSignal": "um sinal desconhecido", "approvalImageSources": "A partir de {paths}", + "renameCardMore": { + "one": "{files} e mais {count} arquivo", + "many": "{files} e mais {count} de arquivos", + "other": "{files} e mais {count} arquivos" + }, + "codeIntelNoService": "Nenhum serviço de linguagem respondeu para {path}, ou o arquivo não declara símbolos.", + "codeIntelTimedOut": "O serviço de linguagem não respondeu em {seconds} segundos.", + "repoMapNoService": "Nenhum serviço de linguagem respondeu com os símbolos do espaço de trabalho.", "imageBuyTitle": "O Muse quer criar a imagem {path}", "imageBuyEditTitle": "O Muse quer fazer a imagem editada {path}", "imageBuyPrompt": "Prompt: {prompt}", diff --git a/l10n/ui.ru.json b/l10n/ui.ru.json index 2bd553b9..e3f127b0 100644 --- a/l10n/ui.ru.json +++ b/l10n/ui.ru.json @@ -816,7 +816,23 @@ "TodoWrite": "Задачи", "snooze_reminder": "Отсрочка напоминания", "submit_reminder_decision": "Напоминание", - "submit_result": "Результат" + "submit_result": "Результат", + "find_definition": "Определение", + "find_references": "Ссылки", + "workspace_symbols": "Символы", + "document_symbols": "Структура", + "hover": "Подсказка", + "call_hierarchy": "Вызовы", + "repo_map": "Карта репозитория", + "rename_symbol": "Переименование", + "mcp__ide__findDefinition": "Определение", + "mcp__ide__findReferences": "Ссылки", + "mcp__ide__workspaceSymbols": "Символы", + "mcp__ide__documentSymbols": "Структура", + "mcp__ide__hover": "Подсказка", + "mcp__ide__callHierarchy": "Вызовы", + "mcp__ide__repoMap": "Карта репозитория", + "mcp__ide__renameSymbol": "Переименование" }, "mcpToolLabel": "{tool} ({server})", "memoryScopes": { @@ -1085,6 +1101,15 @@ "museStoppedBySignal": "Muse Code остановлен: {signal}", "museUnknownSignal": "неизвестный сигнал", "approvalImageSources": "На основе {paths}", + "renameCardMore": { + "one": "{files} и ещё {count} файл", + "few": "{files} и ещё {count} файла", + "many": "{files} и ещё {count} файлов", + "other": "{files} и ещё {count} файла" + }, + "codeIntelNoService": "Ни одна языковая служба не ответила для {path}, или файл не объявляет символов.", + "codeIntelTimedOut": "Языковая служба не ответила в течение {seconds} с.", + "repoMapNoService": "Ни одна языковая служба не ответила для символов рабочей области.", "imageBuyTitle": "Muse хочет создать изображение {path}", "imageBuyEditTitle": "Muse хочет создать отредактированное изображение {path}", "imageBuyPrompt": "Промпт: {prompt}", diff --git a/l10n/ui.tr.json b/l10n/ui.tr.json index 5370ce9a..85aa985a 100644 --- a/l10n/ui.tr.json +++ b/l10n/ui.tr.json @@ -782,7 +782,23 @@ "TodoWrite": "Görevler", "snooze_reminder": "Hatırlatıcıyı ertele", "submit_reminder_decision": "Hatırlatıcı", - "submit_result": "Sonuç" + "submit_result": "Sonuç", + "find_definition": "Tanım", + "find_references": "Başvurular", + "workspace_symbols": "Semboller", + "document_symbols": "Ana hat", + "hover": "Bilgi balonu", + "call_hierarchy": "Çağrılar", + "repo_map": "Depo haritası", + "rename_symbol": "Yeniden adlandır", + "mcp__ide__findDefinition": "Tanım", + "mcp__ide__findReferences": "Başvurular", + "mcp__ide__workspaceSymbols": "Semboller", + "mcp__ide__documentSymbols": "Ana hat", + "mcp__ide__hover": "Bilgi balonu", + "mcp__ide__callHierarchy": "Çağrılar", + "mcp__ide__repoMap": "Depo haritası", + "mcp__ide__renameSymbol": "Yeniden adlandır" }, "mcpToolLabel": "{tool} ({server})", "memoryScopes": { @@ -1031,6 +1047,13 @@ "museStoppedBySignal": "Muse Code durduruldu (sinyal: {signal})", "museUnknownSignal": "bilinmiyor", "approvalImageSources": "Başlangıç: {paths}", + "renameCardMore": { + "one": "{files} ve {count} dosya daha", + "other": "{files} ve {count} dosya daha" + }, + "codeIntelNoService": "{path} için hiçbir dil hizmeti yanıt vermedi ya da dosya hiç sembol bildirmiyor.", + "codeIntelTimedOut": "Dil hizmeti {seconds} saniye içinde yanıt vermedi.", + "repoMapNoService": "Çalışma alanı sembolleri için hiçbir dil hizmeti yanıt vermedi.", "imageBuyTitle": "Muse şu görüntüyü oluşturmak istiyor: {path}", "imageBuyEditTitle": "Muse şu düzenlenmiş görüntüyü oluşturmak istiyor: {path}", "imageBuyPrompt": "İstem: {prompt}", diff --git a/l10n/ui.zh-cn.json b/l10n/ui.zh-cn.json index fcc7b250..98bd056e 100644 --- a/l10n/ui.zh-cn.json +++ b/l10n/ui.zh-cn.json @@ -765,7 +765,23 @@ "TodoWrite": "任务", "snooze_reminder": "推迟提醒", "submit_reminder_decision": "提醒", - "submit_result": "结果" + "submit_result": "结果", + "find_definition": "定义", + "find_references": "引用", + "workspace_symbols": "符号", + "document_symbols": "大纲", + "hover": "悬停", + "call_hierarchy": "调用", + "repo_map": "仓库地图", + "rename_symbol": "重命名", + "mcp__ide__findDefinition": "定义", + "mcp__ide__findReferences": "引用", + "mcp__ide__workspaceSymbols": "符号", + "mcp__ide__documentSymbols": "大纲", + "mcp__ide__hover": "悬停", + "mcp__ide__callHierarchy": "调用", + "mcp__ide__repoMap": "仓库地图", + "mcp__ide__renameSymbol": "重命名" }, "mcpToolLabel": "{tool}({server})", "memoryScopes": { @@ -1004,6 +1020,12 @@ "museStoppedBySignal": "Muse Code 被 {signal} 停止", "museUnknownSignal": "未知信号", "approvalImageSources": "从 {paths} 开始", + "renameCardMore": { + "other": "{files} 及另外 {count} 个文件" + }, + "codeIntelNoService": "没有语言服务对 {path} 作出响应,或者该文件未声明任何符号。", + "codeIntelTimedOut": "语言服务未在 {seconds} 秒内响应。", + "repoMapNoService": "没有语言服务对工作区符号作出响应。", "imageBuyTitle": "Muse 想要创建图片 {path}", "imageBuyEditTitle": "Muse 想要制作编辑后的图片 {path}", "imageBuyPrompt": "提示词:{prompt}", diff --git a/l10n/ui.zh-tw.json b/l10n/ui.zh-tw.json index 2f2c21d6..4e346063 100644 --- a/l10n/ui.zh-tw.json +++ b/l10n/ui.zh-tw.json @@ -765,7 +765,23 @@ "TodoWrite": "工作", "snooze_reminder": "延後提醒", "submit_reminder_decision": "提醒", - "submit_result": "結果" + "submit_result": "結果", + "find_definition": "定義", + "find_references": "參考", + "workspace_symbols": "符號", + "document_symbols": "大綱", + "hover": "暫留", + "call_hierarchy": "呼叫", + "repo_map": "儲存庫地圖", + "rename_symbol": "重新命名", + "mcp__ide__findDefinition": "定義", + "mcp__ide__findReferences": "參考", + "mcp__ide__workspaceSymbols": "符號", + "mcp__ide__documentSymbols": "大綱", + "mcp__ide__hover": "暫留", + "mcp__ide__callHierarchy": "呼叫", + "mcp__ide__repoMap": "儲存庫地圖", + "mcp__ide__renameSymbol": "重新命名" }, "mcpToolLabel": "{tool}({server})", "memoryScopes": { @@ -1004,6 +1020,12 @@ "museStoppedBySignal": "Muse Code 已被 {signal} 停止", "museUnknownSignal": "未知的訊號", "approvalImageSources": "從 {paths} 開始", + "renameCardMore": { + "other": "{files} 及另外 {count} 個檔案" + }, + "codeIntelNoService": "沒有語言服務對 {path} 作出回應,或者該檔案未宣告任何符號。", + "codeIntelTimedOut": "語言服務未在 {seconds} 秒內回應。", + "repoMapNoService": "沒有語言服務對工作區符號作出回應。", "imageBuyTitle": "Muse 想要建立圖片 {path}", "imageBuyEditTitle": "Muse 想要製作編輯後的圖片 {path}", "imageBuyPrompt": "提示詞:{prompt}", diff --git a/l10n/untranslated.json b/l10n/untranslated.json index 99653c45..998cd8f0 100644 --- a/l10n/untranslated.json +++ b/l10n/untranslated.json @@ -85,7 +85,9 @@ "imageBuyPrompt", "workflowRowLabel", "workflowChildUntitled", - "workflowsLabel" + "workflowsLabel", + "toolLabels.find_definition", + "toolLabels.mcp__ide__findDefinition" ], "es": ["usagePlan", "agentTokens", "permissionModes.manual", "toolLabels.shell", "goalTokens"], "pt-br": [ diff --git a/package.json b/package.json index 98251f0c..96c7a1e9 100644 --- a/package.json +++ b/package.json @@ -531,6 +531,12 @@ "default": false, "description": "%config.modelApiHooks.description%", "scope": "machine" + }, + "museSpark.modelApiRepoMap": { + "type": "boolean", + "default": false, + "description": "%config.modelApiRepoMap.description%", + "scope": "machine" } } }, diff --git a/package.nls.cs.json b/package.nls.cs.json index 85378da4..dde4a6c8 100644 --- a/package.nls.cs.json +++ b/package.nls.cs.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "Placené, ve výchozím stavu vypnuté. Mikrofon posílá to, co nahrajete, do služby Muse Voice Transcribe od Meta místo do vlastního rozpoznávače řeči vašeho počítače, v ceně 0,18 US$ za hodinu zvuku, účtováno na váš klíč Model API: na back-endu Model API a na back-endu Muse Code, dokud je uložen klíč. Zapnutí vyžaduje potvrzení ceny a před každou nahrávkou budete nejprve dotázáni, ledaže Muse Voice vždy povolíte v tomto pracovním prostoru; mikrofon dává najevo, když je placený.", "config.modelApiSubagents.description": "Placené, ve výchozím nastavení vypnuté. Na backendu Model API používají podagenti váš klíč Model API. Zapnutí vyžaduje přijetí cen za tokeny. Každý nový úkol podagenta vyžaduje schválení, a to i v režimu Bypass, ledaže podagenty vždy povolíte v tomto pracovním prostoru, nejvýše pro čtyři požadavky včetně opakování. Cena tokenů podagentů je zahrnuta v odhadu konverzace.", "config.modelApiHooks.description": "Spouštět příkazy háčků Muse Code na backendu Model API. Ve výchozím nastavení vypnuto. Příkazy běží s vašimi oprávněními mimo sandbox agenta, a to jen v důvěryhodném pracovním prostoru. Před zapnutím je zkontrolujte přes Muse Spark: Hooks. Klíč Model API se procesům háčků nepředává.", + "config.modelApiRepoMap.description": "Přidat do systémového promptu backendu Model API mapu repozitáře: nejpoužívanější soubory a definice pracovního prostoru seřazené pomocí jazykových služeb VS Code, vytvořenou jednou za konverzaci v rozsahu asi 1 000 tokenů. Ve výchozím nastavení vypnuto: tyto tokeny přidává ke každému požadavku a účtují se na váš klíč Model API. Nástroj repo_map poskytne stejnou mapu na požádání v každém případě.", "config.modelApiScheduledPrompts.description": "Placená funkce, ve výchozím nastavení vypnutá. Prompt /loop, jehož čas nastal, lze na backendu Model API spustit teprve po přijetí ceny tokenů a povolení konkrétního spuštění, nebo pokud naplánovaná spuštění vždy povolíte v tomto pracovním prostoru. Naplánované prompty se nikdy nespouštějí bez vašeho dohledu; každé spuštění se účtuje vašemu klíči Model API podle zveřejněných cen tokenů daného modelu.", "config.sandboxNetwork.description": "Síť, kterou sandbox shellu Muse Code poskytuje příkazům. Platí jen tehdy, když je sandbox zapnutý (museSpark.shellSandbox); bez sandboxu mají příkazy vaši síť. Změna restartuje hostitele Muse Code.", "config.sandboxNetwork.enumDescriptions.default": "Nepředávat nic: platí výchozí nastavení Muse Code (proxy-only) nebo to, co nastavuje spravovaná konfigurace vašeho správce.", diff --git a/package.nls.de.json b/package.nls.de.json index 425adaf0..523cb8de 100644 --- a/package.nls.de.json +++ b/package.nls.de.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "Kostenpflichtig, standardmäßig aus. Das Mikrofon sendet Ihre Aufnahmen an Muse Voice Transcribe von Meta statt an die eigene Spracherkennung Ihres Computers, zum Preis von 0,18 $ pro Stunde Audio, die Ihrem Model-API-Schlüssel berechnet werden: auf dem Model-API-Backend, sowie auf dem Muse Code-Backend, solange ein Schlüssel gespeichert ist. Beim Einschalten werden Sie gebeten, den Preis zu bestätigen, und vor jeder Aufnahme wird zuerst gefragt, es sei denn, Sie lassen Muse Voice in diesem Arbeitsbereich immer zu; das Mikrofon zeigt an, wenn es die kostenpflichtige Erkennung verwendet.", "config.modelApiSubagents.description": "Kostenpflichtig, standardmäßig aus. Auf dem Model-API-Backend verwenden Unteragenten Ihren Model-API-Schlüssel. Beim Einschalten müssen Sie die Tokenpreise akzeptieren. Jede neue Kindaufgabe benötigt eine Genehmigung, auch in Bypass, es sei denn, Sie lassen Unteragenten in diesem Arbeitsbereich immer zu, für höchstens vier Anfragen einschließlich Wiederholungen. Die Tokenkosten der Unteragenten sind in der Schätzung der Unterhaltung enthalten.", "config.modelApiHooks.description": "Muse-Code-Hook-Befehle im Model-API-Backend ausführen. Standardmäßig aus. Die Befehle laufen nur in einem vertrauenswürdigen Arbeitsbereich, mit Ihren Rechten außerhalb der Agent-Sandbox. Prüfen Sie sie vor dem Einschalten unter Muse Spark: Hooks. Der Model-API-Schlüssel wird Hook-Prozessen nicht übergeben.", + "config.modelApiRepoMap.description": "Dem Systemprompt des Model-API-Backends eine Repo-Karte hinzufügen: die meistgenutzten Dateien und Definitionen des Arbeitsbereichs, gereiht mit den Sprachdiensten von VS Code, einmal pro Unterhaltung mit etwa 1.000 Token erstellt. Standardmäßig aus: Sie fügt jeder Anfrage diese Token hinzu, die über Ihren Model-API-Schlüssel abgerechnet werden. Das Tool repo_map liefert dieselbe Karte in jedem Fall auf Anfrage.", "config.modelApiScheduledPrompts.description": "Kostenpflichtig, standardmäßig aus. Ein fälliger /loop-Prompt kann auf dem Model-API-Backend erst ausgeführt werden, nachdem Sie den Tokenpreis akzeptiert und diese Ausführung zugelassen haben oder wenn Sie geplante Ausführungen in diesem Arbeitsbereich immer zulassen. Geplante Prompts laufen nie unbeaufsichtigt; jede Ausführung wird Ihrem Model-API-Schlüssel zu den veröffentlichten Tokenpreisen des Modells berechnet.", "config.sandboxNetwork.description": "Das Netzwerk, das die Shell-Sandbox von Muse Code Befehlen gewährt. Gilt nur, solange die Sandbox eingeschaltet ist (museSpark.shellSandbox); ohne Sandbox haben Befehle Ihr Netzwerk. Eine Änderung startet den Muse Code-Host neu.", "config.sandboxNetwork.enumDescriptions.default": "Nichts übergeben: Es gilt die Standardeinstellung von Muse Code (proxy-only) oder die verwaltete Konfiguration Ihres Administrators.", diff --git a/package.nls.es.json b/package.nls.es.json index 767df57a..e9a95d61 100644 --- a/package.nls.es.json +++ b/package.nls.es.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "De pago, desactivado de forma predeterminada. El micrófono envía lo que grabe a Muse Voice Transcribe de Meta en lugar de al reconocedor de voz de su propio equipo, a 0,18 US$ por hora de audio facturados a su clave de Model API: en el back-end de Model API, y en el back-end de Muse Code mientras haya una clave guardada. Al activarlo se le pide que confirme el precio, y cada grabación pregunta primero salvo que permita siempre Muse Voice en esta área de trabajo; el micrófono indica cuándo es el de pago.", "config.modelApiSubagents.description": "De pago y desactivado de forma predeterminada. En el back-end de Model API, los agentes secundarios usan su clave de Model API. Al activar la función se le pide que acepte los precios por token. Cada tarea secundaria nueva necesita aprobación, incluso en Bypass, salvo que permita siempre los subagentes en esta área de trabajo, para un máximo de cuatro solicitudes, incluidos los reintentos. El coste de sus tokens está incluido en la estimación de la conversación.", "config.modelApiHooks.description": "Ejecutar comandos de hooks de Muse Code en el backend de Model API. Desactivado de forma predeterminada. Los comandos se ejecutan con sus permisos fuera del entorno aislado del agente, y solo en un área de trabajo de confianza. Revíselos en Muse Spark: Hooks antes de activarlos. La clave de Model API no se entrega a los procesos de hooks.", + "config.modelApiRepoMap.description": "Añadir un mapa del repositorio a la instrucción de sistema del backend de Model API: los archivos y definiciones más usados del área de trabajo, ordenados con los servicios de lenguaje de VS Code, creado una vez por conversación en unos 1.000 tokens. Desactivado de forma predeterminada: añade esos tokens a cada solicitud, que se facturan a su clave de Model API. En cualquier caso, la herramienta repo_map ofrece el mismo mapa a petición.", "config.modelApiScheduledPrompts.description": "De pago y desactivado de forma predeterminada. Permite ejecutar un prompt /loop vencido en el backend Model API solo después de aceptar el precio por token y permitir esa ejecución, o si permites siempre las ejecuciones programadas en esta área de trabajo. Los prompts programados nunca se ejecutan sin supervisión; cada ejecución se factura a tu clave de Model API según las tarifas por token publicadas para el modelo.", "config.sandboxNetwork.description": "La red que el espacio aislado de shell de Muse Code da a los comandos. Solo se aplica mientras el espacio aislado está activado (museSpark.shellSandbox); sin él, los comandos tienen su red. Al cambiarlo se reinicia el host de Muse Code.", "config.sandboxNetwork.enumDescriptions.default": "No pasar nada: se aplica el valor predeterminado de Muse Code (proxy-only) o lo que establezca la configuración administrada de su administrador.", diff --git a/package.nls.fr.json b/package.nls.fr.json index 0e58741c..a4a8f856 100644 --- a/package.nls.fr.json +++ b/package.nls.fr.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "Payant, désactivé par défaut. Le microphone envoie vos enregistrements à Muse Voice Transcribe de Meta au lieu du moteur de reconnaissance vocale de votre ordinateur, au tarif de 0,18 $US par heure d’audio facturé sur votre clé Model API : sur le back-end Model API, ainsi que sur le back-end Muse Code tant qu’une clé est stockée. L’activation vous demande de confirmer le prix, et chaque enregistrement demande d’abord votre accord, sauf si vous autorisez toujours Muse Voice dans cet espace de travail ; le microphone indique quand il utilise le service payant.", "config.modelApiSubagents.description": "Fonction payante, désactivée par défaut. Sur le back-end Model API, les agents enfants utilisent votre clé Model API. Son activation vous demande d’accepter les tarifs des jetons. Chaque nouvelle tâche enfant nécessite une approbation, même en mode Bypass, sauf si vous autorisez toujours les sous-agents dans cet espace de travail, pour un maximum de quatre requêtes, nouvelles tentatives comprises. Le coût des jetons des agents enfants est inclus dans l’estimation de la conversation.", "config.modelApiHooks.description": "Exécuter les commandes de hooks Muse Code avec le backend Model API. Désactivé par défaut. Ces commandes s’exécutent avec vos droits hors du bac à sable de l’agent, et uniquement dans un espace de travail approuvé. Vérifiez-les dans Muse Spark: Hooks avant l’activation. La clé Model API n’est pas transmise aux processus de hooks.", + "config.modelApiRepoMap.description": "Ajouter une carte du dépôt à l’invite système du backend Model API : les fichiers et définitions les plus utilisés de l’espace de travail, classés avec les services de langage de VS Code, établie une fois par conversation en environ 1 000 jetons. Désactivé par défaut : elle ajoute ces jetons à chaque requête, facturés sur votre clé Model API. Dans tous les cas, l’outil repo_map fournit la même carte à la demande.", "config.modelApiScheduledPrompts.description": "Payant, désactivé par défaut. Permet d’exécuter un prompt /loop arrivé à échéance sur le back-end Model API uniquement après que vous avez accepté son tarif par jeton et autorisé cette exécution, ou si vous autorisez toujours les exécutions planifiées dans cet espace de travail. Les prompts planifiés ne s’exécutent jamais sans surveillance ; chaque exécution est facturée sur votre clé Model API aux tarifs par jeton publiés pour le modèle.", "config.sandboxNetwork.description": "Le réseau que le bac à sable de Muse Code accorde aux commandes shell. Ne s’applique que lorsque le bac à sable est activé (museSpark.shellSandbox) ; sans lui, les commandes disposent de votre réseau. Le modifier redémarre l’hôte Muse Code.", "config.sandboxNetwork.enumDescriptions.default": "Ne rien transmettre : la valeur par défaut de Muse Code (proxy-only) s’applique, ou celle que définit la configuration gérée de votre administrateur.", diff --git a/package.nls.hu.json b/package.nls.hu.json index a6863c01..4e02415d 100644 --- a/package.nls.hu.json +++ b/package.nls.hu.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "Fizetős, alapértelmezés szerint kikapcsolva. A mikrofon a felvételeit a számítógép saját beszédfelismerője helyett a Meta Muse Voice Transcribe szolgáltatásának küldi; díja $0.18 / óra hanganyag, amelyet a Model API-kulcsára számláznak: a Model API háttérrendszeren, és a Muse Code háttérrendszeren is, amíg egy kulcs tárolva van. Bekapcsoláskor meg kell erősítenie az árat, és minden felvétel előtt rákérdez, hacsak nem engedélyezi mindig a Muse Voice szolgáltatást ezen a munkaterületen; a mikrofon jelzi, ha a fizetős felismerést használja.", "config.modelApiSubagents.description": "Fizetős, alapértelmezés szerint kikapcsolva. A Model API háttérrendszeren az alügynökök az Ön Model API-kulcsát használják. Bekapcsoláskor el kell fogadnia a tokenárakat. Minden új alfeladat jóváhagyást igényel, Bypass módban is, hacsak nem engedélyezi mindig az alügynököket ezen a munkaterületen, legfeljebb négy kérésre, az újrapróbálkozásokat is beleértve. Az alügynökök tokenköltsége szerepel a beszélgetés becslésében.", "config.modelApiHooks.description": "A Muse Code hook-parancsainak futtatása a Model API háttérrendszeren. Alapértelmezés szerint ki van kapcsolva. A parancsok csak megbízható munkaterületen futnak, az Ön jogaival, az ügynök homokozóján kívül. Bekapcsolás előtt ellenőrizze őket a Muse Spark: Hooks nézetben. A Model API-kulcs nem kerül a hook-folyamatokhoz.", + "config.modelApiRepoMap.description": "Tárolótérkép hozzáadása a Model API háttérrendszer rendszerpromptjához: a munkaterület leggyakrabban használt fájljai és definíciói a VS Code nyelvi szolgáltatásaival rangsorolva, beszélgetésenként egyszer, körülbelül 1000 tokenben elkészítve. Alapértelmezés szerint ki van kapcsolva: minden kéréshez hozzáadja ezeket a tokeneket, amelyeket a Model API-kulcsára számláznak. A repo_map eszköz kérésre mindenképpen ugyanezt a térképet adja.", "config.modelApiScheduledPrompts.description": "Fizetős, alapértelmezés szerint kikapcsolva. Az esedékes /loop prompt a Model API háttérrendszerén csak akkor futtatható, ha elfogadta a tokenek árát és engedélyezte az adott futtatást, vagy ha mindig engedélyezi az ütemezett futtatásokat ezen a munkaterületen. Az ütemezett promptok soha nem futnak felügyelet nélkül; minden futtatás a modell közzétett tokenárai szerint a Model API-kulcsára terhelődik.", "config.sandboxNetwork.description": "A hálózat, amelyet a Muse Code shell-homokozója a parancsoknak ad. Csak akkor érvényes, ha a homokozó be van kapcsolva (museSpark.shellSandbox); homokozó nélkül a parancsok az Ön hálózatát használják. A módosítás újraindítja a Muse Code gazdafolyamatát.", "config.sandboxNetwork.enumDescriptions.default": "Semmit nem ad át: a Muse Code saját alapértelmezése (proxy-only) vagy a rendszergazda által felügyelt konfiguráció beállítása érvényes.", diff --git a/package.nls.it.json b/package.nls.it.json index da333102..a75fb551 100644 --- a/package.nls.it.json +++ b/package.nls.it.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "A pagamento, disattivata per impostazione predefinita. Il microfono invia ciò che registri a Muse Voice Transcribe di Meta anziché al sistema di riconoscimento vocale del tuo computer, al prezzo di 0,18 USD per ora di audio addebitato alla tua chiave Model API: sul back-end Model API, e sul back-end Muse Code mentre è archiviata una chiave. Quando la attivi ti viene chiesto di confermare il prezzo, e ogni registrazione chiede prima, a meno che tu non consenta sempre Muse Voice in questa area di lavoro; il microfono indica quando usa il servizio a pagamento.", "config.modelApiSubagents.description": "Funzione a pagamento, disattivata per impostazione predefinita. Sul back-end Model API, gli agenti secondari usano la tua chiave Model API. L’attivazione richiede l’accettazione dei prezzi dei token. Ogni nuova attività secondaria richiede l’approvazione, anche in modalità Bypass, a meno che tu non consenta sempre gli agenti secondari in questa area di lavoro, per un massimo di quattro richieste, inclusi i tentativi ripetuti. Il costo dei token degli agenti secondari è incluso nella stima della conversazione.", "config.modelApiHooks.description": "Esegui i comandi degli hook di Muse Code nel backend Model API. Disattivato per impostazione predefinita. I comandi vengono eseguiti solo in un’area di lavoro attendibile, con i tuoi privilegi e fuori dalla sandbox dell’agente. Controllali in Muse Spark: Hooks prima di attivare l’opzione. La chiave Model API non viene passata ai processi degli hook.", + "config.modelApiRepoMap.description": "Aggiungi una mappa del repository al prompt di sistema del backend Model API: i file e le definizioni più usati dell’area di lavoro, ordinati con i servizi di linguaggio di VS Code, creata una volta per conversazione in circa 1.000 token. Disattivata per impostazione predefinita: aggiunge quei token a ogni richiesta, addebitati sulla tua chiave Model API. In ogni caso lo strumento repo_map fornisce la stessa mappa su richiesta.", "config.modelApiScheduledPrompts.description": "A pagamento, disattivato per impostazione predefinita. Consente di eseguire un prompt /loop scaduto sul backend Model API solo dopo aver accettato il prezzo dei token e consentito quella esecuzione, oppure se consenti sempre le esecuzioni pianificate in questa area di lavoro. I prompt pianificati non vengono mai eseguiti senza supervisione; ogni esecuzione viene addebitata alla tua chiave Model API alle tariffe per token pubblicate per il modello.", "config.sandboxNetwork.description": "La rete che la sandbox della shell di Muse Code concede ai comandi. Si applica solo mentre la sandbox è attiva (museSpark.shellSandbox); senza sandbox i comandi hanno la tua rete. Se viene modificata, l’host di Muse Code si riavvia.", "config.sandboxNetwork.enumDescriptions.default": "Non passare nulla: vale l’impostazione predefinita di Muse Code (proxy-only) o quella stabilita dalla configurazione gestita del tuo amministratore.", diff --git a/package.nls.ja.json b/package.nls.ja.json index f1a5a98c..68b93d3f 100644 --- a/package.nls.ja.json +++ b/package.nls.ja.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "有料、既定ではオフ。マイクで録音した音声を、コンピューター自体の音声認識エンジンではなく Meta の Muse Voice Transcribe に送信します。料金は音声 1 時間あたり $0.18 で Model API キーに請求されます。Model API バックエンドで使用されるほか、Model API キーが保存されている間は Muse Code バックエンドでも使用されます。オンにするときに価格の確認を求め、このワークスペースで Muse Voice を常に許可しない限り、録音ごとに事前に確認します。有料のエンジンを使用しているときは、マイクにそのことが表示されます。", "config.modelApiSubagents.description": "有料、初期状態ではオフ。Model APIバックエンドでは子エージェントがModel APIキーを使います。有効化するとトークン料金の承認を求めます。このワークスペースでサブエージェントを常に許可しない限り、権限バイパスを含め、新しい子タスクごとに承認が必要です。再試行を含め最大4リクエストまで。子エージェントのトークン費用は会話の見積もりに含まれます。", "config.modelApiHooks.description": "Model API バックエンドで Muse Code のフックコマンドを実行します。既定ではオフです。コマンドは信頼されたワークスペースでのみ、エージェントのサンドボックス外でユーザーの権限で実行されます。有効にする前に Muse Spark: Hooks で確認してください。Model API キーはフックプロセスに渡されません。", + "config.modelApiRepoMap.description": "Model API バックエンドのシステムプロンプトにリポジトリマップを追加します。VS Code の言語サービスで順位付けした、ワークスペースで最もよく使われるファイルと定義を、会話ごとに一度、約 1,000 トークンで作成します。既定ではオフです。すべてのリクエストにそのトークンが加わり、Model API キーに課金されます。どちらの場合も、repo_map ツールで同じマップを必要なときに取得できます。", "config.modelApiScheduledPrompts.description": "有料、既定ではオフです。期限が来た /loop プロンプトは、トークン料金に同意し、その回の実行を許可した後、またはこのワークスペースで予定された実行を常に許可している場合にのみ、Model API バックエンドで実行できます。予定されたプロンプトが無人で実行されることはありません。実行ごとに、モデルの公開トークン料金に従って Model API キーに請求されます。", "config.sandboxNetwork.description": "Muse Code のシェル サンドボックスがコマンドに与えるネットワーク。サンドボックスがオンのとき (museSpark.shellSandbox) にのみ適用されます。サンドボックスがない場合、コマンドはユーザーのネットワークをそのまま使用します。変更すると Muse Code ホストが再起動します。", "config.sandboxNetwork.enumDescriptions.default": "何も渡しません: Muse Code 自体の既定値 (proxy-only)、または管理者の管理構成で設定された値が使用されます。", diff --git a/package.nls.json b/package.nls.json index 5485932b..80452f81 100644 --- a/package.nls.json +++ b/package.nls.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "Paid, off by default. The microphone sends what you record to Meta's Muse Voice Transcribe instead of your computer's own recogniser, at $0.18 per hour of audio billed to your Model API key: on the Model API backend, and on the Muse Code backend while a key is stored. Turning it on asks you to confirm the price, and each recording asks first unless you allow Muse Voice always in this workspace; the microphone says when it is the paid one.", "config.modelApiSubagents.description": "Paid, off by default. On the Model API backend, child agents use your Model API key. Enabling this asks you to accept token prices. Every new child task needs approval, including in Bypass, unless you allow subagents always in this workspace, for up to four requests including retries. Child token cost is included in the conversation estimate.", "config.modelApiHooks.description": "Run Muse Code hook commands on the Model API backend. Off by default. Hook commands run as you outside the agent sandbox, and only in a trusted workspace. Review the commands in Muse Spark: Hooks before enabling. The Model API key is withheld from hook processes.", + "config.modelApiRepoMap.description": "Add a repo map to the Model API backend's system prompt: the workspace's most used files and definitions, ranked with VS Code's language services, made once per conversation in about 1,000 tokens. Off by default: it adds those tokens to every request, billed to your Model API key. Either way, the repo_map tool gives the same map on request.", "config.modelApiScheduledPrompts.description": "Paid, off by default. Lets a due /loop prompt run on the Model API backend only after you accept its token price and allow that run, or allow scheduled runs always in this workspace. Scheduled prompts never run unattended; every run is billed to your Model API key at the model's published token rates.", "config.sandboxNetwork.description": "The network Muse Code's shell sandbox gives commands. It applies only while the sandbox is on (museSpark.shellSandbox); without the sandbox, commands have your network. Changing it restarts the Muse Code host.", "config.sandboxNetwork.enumDescriptions.default": "Pass nothing: Muse Code's own default (proxy-only), or what your administrator's managed configuration sets.", diff --git a/package.nls.ko.json b/package.nls.ko.json index 30b2ee7e..d44b2241 100644 --- a/package.nls.ko.json +++ b/package.nls.ko.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "유료이며 기본적으로 꺼져 있습니다. 마이크로 녹음한 내용을 컴퓨터 자체의 음성 인식기 대신 Meta의 Muse Voice Transcribe로 보냅니다. 요금은 오디오 1시간당 US$0.18이며 Model API 키에 청구됩니다. Model API 백엔드에서 사용되며, 키가 저장되어 있는 동안에는 Muse Code 백엔드에서도 사용됩니다. 켜면 가격을 확인하라는 메시지가 표시되고, 이 작업 영역에서 Muse Voice를 항상 허용하지 않는 한 녹음할 때마다 먼저 묻습니다. 유료 엔진을 사용할 때는 마이크에 그렇게 표시됩니다.", "config.modelApiSubagents.description": "유료 기능이며 기본적으로 꺼져 있습니다. Model API 백엔드의 하위 에이전트는 Model API 키를 사용합니다. 활성화할 때 토큰 가격 승인을 요청합니다. 이 작업 영역에서 하위 에이전트를 항상 허용하지 않는 한 권한 우회를 포함해 새 하위 작업마다 승인이 필요하며 재시도를 포함해 최대 4회 요청할 수 있습니다. 하위 에이전트 토큰 비용은 대화 예상 비용에 포함됩니다.", "config.modelApiHooks.description": "Model API 백엔드에서 Muse Code 훅 명령을 실행합니다. 기본적으로 꺼져 있습니다. 명령은 신뢰할 수 있는 작업 영역에서만 에이전트 샌드박스 밖에서 사용자 권한으로 실행됩니다. 켜기 전에 Muse Spark: Hooks에서 검토하세요. Model API 키는 훅 프로세스에 전달되지 않습니다.", + "config.modelApiRepoMap.description": "Model API 백엔드의 시스템 프롬프트에 저장소 맵을 추가합니다. VS Code 언어 서비스로 순위를 매긴 작업 영역에서 가장 많이 쓰이는 파일과 정의를 대화마다 한 번, 약 1,000토큰으로 만듭니다. 기본적으로 꺼져 있습니다. 모든 요청에 해당 토큰이 추가되어 Model API 키로 청구됩니다. 어느 경우든 repo_map 도구로 같은 맵을 요청할 수 있습니다.", "config.modelApiScheduledPrompts.description": "유료이며 기본적으로 꺼져 있습니다. 실행 시점이 된 /loop 프롬프트는 토큰 가격에 동의하고 해당 실행을 허용한 후에만, 또는 이 작업 영역에서 예약된 실행을 항상 허용한 경우에만 Model API 백엔드에서 실행됩니다. 예약된 프롬프트는 사용자 없이 자동 실행되지 않습니다. 각 실행 비용은 모델의 공개 토큰 요금에 따라 Model API 키로 청구됩니다.", "config.sandboxNetwork.description": "Muse Code의 셸 샌드박스가 명령에 허용하는 네트워크입니다. 샌드박스가 켜져 있을 때만 적용되며(museSpark.shellSandbox), 샌드박스가 없으면 명령이 사용자의 네트워크를 그대로 사용합니다. 변경하면 Muse Code 호스트가 다시 시작됩니다.", "config.sandboxNetwork.enumDescriptions.default": "아무것도 전달하지 않습니다. Muse Code 자체 기본값(proxy-only) 또는 관리자의 관리형 구성에서 설정한 값이 적용됩니다.", diff --git a/package.nls.pl.json b/package.nls.pl.json index 7395b7bd..9d0f10fc 100644 --- a/package.nls.pl.json +++ b/package.nls.pl.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "Płatne, domyślnie wyłączone. Mikrofon wysyła Twoje nagrania do usługi Muse Voice Transcribe firmy Meta zamiast do własnego modułu rozpoznawania mowy Twojego komputera, według stawki 0,18 USD za godzinę dźwięku rozliczanej z Twojego klucza Model API: w backendzie Model API oraz w backendzie Muse Code, gdy zapisany jest klucz. Włączenie wymaga potwierdzenia ceny, a przed każdym nagraniem pojawia się pytanie, chyba że zawsze zezwolisz na Muse Voice w tym obszarze roboczym; mikrofon pokazuje, kiedy jest płatny.", "config.modelApiSubagents.description": "Funkcja płatna, domyślnie wyłączona. W backendzie Model API agenci podrzędni używają Twojego klucza Model API. Włączenie wymaga zaakceptowania cen tokenów. Każde nowe zadanie podrzędne wymaga zatwierdzenia, także w trybie Bypass, chyba że zawsze zezwolisz na agentów podrzędnych w tym obszarze roboczym, dla maksymalnie czterech żądań, łącznie z ponowieniami. Koszt tokenów agentów podrzędnych jest uwzględniony w oszacowaniu rozmowy.", "config.modelApiHooks.description": "Uruchamiaj polecenia hooków Muse Code w zapleczu Model API. Domyślnie wyłączone. Polecenia działają tylko w zaufanym obszarze roboczym, z Twoimi uprawnieniami, poza piaskownicą agenta. Przed włączeniem sprawdź je w Muse Spark: Hooks. Klucz Model API nie jest przekazywany procesom hooków.", + "config.modelApiRepoMap.description": "Dodaj mapę repozytorium do promptu systemowego zaplecza Model API: najczęściej używane pliki i definicje obszaru roboczego, uszeregowane za pomocą usług językowych VS Code, tworzoną raz na rozmowę w około 1000 tokenów. Domyślnie wyłączone: dodaje te tokeny do każdego żądania, a opłata trafia na Twój klucz Model API. Narzędzie repo_map i tak podaje tę samą mapę na żądanie.", "config.modelApiScheduledPrompts.description": "Płatne, domyślnie wyłączone. Monit /loop, którego termin nadszedł, może zostać uruchomiony w zapleczu Model API dopiero po zaakceptowaniu ceny tokenów i zezwoleniu na to uruchomienie albo gdy zawsze zezwalasz na zaplanowane uruchomienia w tym obszarze roboczym. Zaplanowane monity nigdy nie są uruchamiane bez nadzoru; każde uruchomienie jest rozliczane przez Twój klucz Model API według opublikowanych stawek za tokeny modelu.", "config.sandboxNetwork.description": "Sieć, którą piaskownica powłoki Muse Code udostępnia poleceniom. Obowiązuje tylko wtedy, gdy piaskownica jest włączona (museSpark.shellSandbox); bez piaskownicy polecenia mają dostęp do Twojej sieci. Zmiana powoduje ponowne uruchomienie hosta Muse Code.", "config.sandboxNetwork.enumDescriptions.default": "Nie przekazuj niczego: obowiązuje domyślne ustawienie Muse Code (proxy-only) lub to, co ustala zarządzana konfiguracja Twojego administratora.", diff --git a/package.nls.pt-br.json b/package.nls.pt-br.json index 86f9e200..996aaca1 100644 --- a/package.nls.pt-br.json +++ b/package.nls.pt-br.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "Pago, desativado por padrão. O microfone envia o que você gravar para o Muse Voice Transcribe da Meta em vez do reconhecedor de fala do seu próprio computador, a US$ 0,18 por hora de áudio cobrados da sua chave da Model API: no back-end da Model API, e no back-end do Muse Code enquanto uma chave estiver armazenada. Ativá-lo pede que você confirme o preço, e cada gravação pergunta antes, a menos que você sempre permita o Muse Voice neste espaço de trabalho; o microfone indica quando é o pago.", "config.modelApiSubagents.description": "Recurso pago e desativado por padrão. No backend da Model API, subagentes usam sua chave da Model API. Ativar solicita a aceitação dos preços dos tokens. Toda nova tarefa de subagente exige aprovação, inclusive no modo Ignorar permissões, a menos que você sempre permita subagentes neste espaço de trabalho, para até quatro solicitações incluindo novas tentativas. O custo dos tokens dos subagentes está incluído na estimativa da conversa.", "config.modelApiHooks.description": "Executar comandos de hooks do Muse Code no backend da Model API. Desativado por padrão. Os comandos são executados somente em um espaço de trabalho confiável, com suas permissões, fora da sandbox do agente. Revise-os em Muse Spark: Hooks antes de ativar. A chave da Model API não é passada aos processos de hooks.", + "config.modelApiRepoMap.description": "Adicionar um mapa do repositório ao prompt de sistema do backend da Model API: os arquivos e definições mais usados do espaço de trabalho, classificados com os serviços de linguagem do VS Code, criado uma vez por conversa em cerca de 1.000 tokens. Desativado por padrão: ele adiciona esses tokens a cada solicitação, cobrados na sua chave da Model API. Em qualquer caso, a ferramenta repo_map fornece o mesmo mapa sob demanda.", "config.modelApiScheduledPrompts.description": "Pago, desativado por padrão. Permite executar um prompt /loop vencido no backend Model API somente depois que você aceitar o preço dos tokens e permitir essa execução, ou se você sempre permitir execuções agendadas neste espaço de trabalho. Os prompts agendados nunca são executados sem supervisão; cada execução é cobrada na sua chave Model API conforme as tarifas por token publicadas para o modelo.", "config.sandboxNetwork.description": "A rede que a área restrita do shell do Muse Code concede aos comandos. Só se aplica enquanto a área restrita está ativada (museSpark.shellSandbox); sem ela, os comandos têm a sua rede. Alterar isso reinicia o host do Muse Code.", "config.sandboxNetwork.enumDescriptions.default": "Não passar nada: vale o padrão do próprio Muse Code (proxy-only) ou o que a configuração gerenciada do seu administrador definir.", diff --git a/package.nls.ru.json b/package.nls.ru.json index 8fdedf0b..4246ef29 100644 --- a/package.nls.ru.json +++ b/package.nls.ru.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "Платно, по умолчанию выключено. Микрофон отправляет вашу запись в Muse Voice Transcribe от Meta вместо собственного распознавателя речи вашего компьютера по цене 0,18 $ за час аудио, оплачиваемой с вашего ключа Model API: на бэкенде Model API, а также на бэкенде Muse Code, пока сохранен ключ. При включении нужно подтвердить цену, а перед каждой записью спрашивается разрешение, если только вы не разрешите Muse Voice всегда в этой рабочей области; микрофон показывает, когда он платный.", "config.modelApiSubagents.description": "Платная функция, по умолчанию выключена. На бэкенде Model API субагенты используют ваш ключ Model API. При включении требуется принять цены за токены. Каждая новая дочерняя задача требует одобрения, в том числе в режиме «Обход разрешений», если только вы не разрешите субагентов всегда в этой рабочей области, и допускает до четырёх запросов, включая повторы. Стоимость токенов субагентов включена в оценку расходов разговора.", "config.modelApiHooks.description": "Запускать команды хуков Muse Code в серверной части Model API. По умолчанию выключено. Команды выполняются только в доверенной рабочей области, с вашими правами, вне песочницы агента. Перед включением проверьте их в Muse Spark: Hooks. Ключ Model API не передаётся процессам хуков.", + "config.modelApiRepoMap.description": "Добавлять карту репозитория в системный промпт серверной части Model API: самые используемые файлы и определения рабочей области, упорядоченные с помощью языковых служб VS Code, — один раз за беседу, примерно в 1000 токенов. По умолчанию выключено: эти токены добавляются к каждому запросу и оплачиваются вашим ключом Model API. Инструмент repo_map в любом случае выдаёт ту же карту по запросу.", "config.modelApiScheduledPrompts.description": "Платная функция, по умолчанию отключена. Запрос /loop, срок которого наступил, можно выполнить через серверную часть Model API только после принятия стоимости токенов и разрешения данного запуска или если вы всегда разрешаете запланированные запуски в этой рабочей области. Запланированные запросы никогда не выполняются без вашего участия; каждый запуск оплачивается через ваш ключ Model API по опубликованным тарифам модели за токены.", "config.sandboxNetwork.description": "Сеть, которую песочница оболочки Muse Code предоставляет командам. Действует, только пока песочница включена (museSpark.shellSandbox); без песочницы команды используют вашу сеть. Изменение перезапускает хост Muse Code.", "config.sandboxNetwork.enumDescriptions.default": "Ничего не передавать: действует собственное значение Muse Code по умолчанию (proxy-only) или то, что задает управляемая конфигурация вашего администратора.", diff --git a/package.nls.tr.json b/package.nls.tr.json index 47891445..8ae2b904 100644 --- a/package.nls.tr.json +++ b/package.nls.tr.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "Ücretli, varsayılan olarak kapalı. Mikrofon, kaydettiklerinizi bilgisayarınızın kendi konuşma tanıyıcısı yerine Meta'nın Muse Voice Transcribe hizmetine gönderir; Model API anahtarınıza ses saati başına $0.18 faturalandırılır: Model API arka ucunda ve bir anahtar depolandığı sürece Muse Code arka ucunda. Açmak için fiyatı onaylamanız istenir ve bu çalışma alanında Muse Voice'a her zaman izin vermediğiniz sürece her kayıttan önce sorulur; mikrofon, ücretli olan kullanıldığında bunu belirtir.", "config.modelApiSubagents.description": "Ücretli ve varsayılan olarak kapalı. Model API arka ucunda alt ajanlar Model API anahtarınızı kullanır. Açarken token fiyatlarını kabul etmeniz istenir. Bu çalışma alanında alt ajanlara her zaman izin vermediğiniz sürece, İzinleri atla dahil her yeni alt görev için onay gerekir; yeniden denemeler dahil en fazla dört istek yapılabilir. Alt ajan token maliyeti konuşma tahminine dahildir.", "config.modelApiHooks.description": "Muse Code hook komutlarını Model API arka ucunda çalıştır. Varsayılan olarak kapalıdır. Komutlar yalnızca güvenilen bir çalışma alanında, ajan korumalı alanı dışında sizin yetkilerinizle çalışır. Açmadan önce Muse Spark: Hooks bölümünde inceleyin. Model API anahtarı hook işlemlerine verilmez.", + "config.modelApiRepoMap.description": "Model API arka ucunun sistem istemine bir depo haritası ekle: çalışma alanının en çok kullanılan dosyaları ve tanımları, VS Code dil hizmetleriyle sıralanmış olarak, her konuşmada bir kez yaklaşık 1.000 belirteçle oluşturulur. Varsayılan olarak kapalıdır: bu belirteçleri her isteğe ekler ve Model API anahtarınıza faturalandırılır. repo_map aracı her durumda aynı haritayı istek üzerine verir.", "config.modelApiScheduledPrompts.description": "Ücretli, varsayılan olarak kapalı. Zamanı gelen bir /loop istemi, ancak token fiyatını kabul edip bu çalıştırmaya izin verdikten sonra ya da bu çalışma alanında zamanlanmış çalıştırmalara her zaman izin veriyorsanız Model API arka ucunda çalıştırılabilir. Zamanlanmış istemler hiçbir zaman gözetimsiz çalışmaz; her çalıştırma, modelin yayımlanmış token ücretlerine göre Model API anahtarınıza faturalandırılır.", "config.sandboxNetwork.description": "Muse Code'un kabuk korumalı alanının komutlara verdiği ağ. Yalnızca korumalı alan açıkken geçerlidir (museSpark.shellSandbox); korumalı alan olmadan komutlar sizin ağınızı kullanır. Değiştirmek Muse Code konağını yeniden başlatır.", "config.sandboxNetwork.enumDescriptions.default": "Hiçbir şey geçirme: Muse Code'un kendi varsayılanı (proxy-only) veya yöneticinizin yönetilen yapılandırmasının belirlediği değer geçerli olur.", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 6911374a..c65578db 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "付费,默认关闭。麦克风会将你录制的内容发送到 Meta 的 Muse Voice Transcribe,而不是使用你计算机自带的识别器,每小时音频 US$0.18,计费到你的 Model API 密钥:用于 Model API 后端,以及存储了密钥时的 Muse Code 后端。打开时会请你确认价格,并且除非你在此工作区中始终允许 Muse Voice,否则每次录音前都会先询问你;使用付费引擎时,麦克风会注明这一点。", "config.modelApiSubagents.description": "付费功能,默认关闭。在 Model API 后端,子代理使用您的 Model API 密钥。启用时会请您接受令牌价格。除非您在此工作区中始终允许子代理,否则每项新子任务均需批准,包括 绕过权限 模式;每项任务最多四次请求,包含重试。子代理令牌费用已包含在会话估算中。", "config.modelApiHooks.description": "在 Model API 后端运行 Muse Code 钩子命令。默认关闭。命令仅在受信任的工作区中以您的权限在代理沙盒之外运行。启用前请在 Muse Spark: Hooks 中检查命令。Model API 密钥不会传给钩子进程。", + "config.modelApiRepoMap.description": "在 Model API 后端的系统提示词中加入仓库地图:用 VS Code 语言服务排序的工作区中最常用的文件和定义,每个对话生成一次,约 1,000 个令牌。默认关闭:它会在每个请求中加入这些令牌,并向您的 Model API 密钥计费。无论是否开启,repo_map 工具都可按需提供同一张地图。", "config.modelApiScheduledPrompts.description": "付费功能,默认关闭。到期的 /loop 提示词只有在您接受令牌价格并允许本次运行后,或者您在此工作区中始终允许计划运行时,才能通过 Model API 后端运行。已计划的提示词绝不会在无人确认时运行;每次运行均按模型公布的令牌费率向您的 Model API 密钥计费。", "config.sandboxNetwork.description": "Muse Code 的 shell 沙盒给予命令的网络。仅在沙盒开启时适用(museSpark.shellSandbox);没有沙盒时,命令使用你的网络。更改此设置会重启 Muse Code 宿主。", "config.sandboxNetwork.enumDescriptions.default": "不传递任何内容:使用 Muse Code 自身的默认值(proxy-only),或管理员的托管配置所设置的值。", diff --git a/package.nls.zh-tw.json b/package.nls.zh-tw.json index fcded0dc..5cff093f 100644 --- a/package.nls.zh-tw.json +++ b/package.nls.zh-tw.json @@ -83,6 +83,7 @@ "config.modelApiVoice.description": "付費,預設為關閉。麥克風會將您錄製的內容傳送至 Meta 的 Muse Voice Transcribe,而不是使用您電腦本身的辨識器,每小時音訊 US$0.18,向您的 Model API 金鑰計費:用於 Model API 後端,以及儲存了金鑰時的 Muse Code 後端。開啟時會請您確認價格,且除非您在此工作區中一律允許 Muse Voice,否則每次錄音前都會先詢問您;使用付費引擎時,麥克風會加以標示。", "config.modelApiSubagents.description": "付費功能,預設關閉。在 Model API 後端,子代理程式使用您的 Model API 金鑰。啟用時會要求您接受權杖價格。除非您在此工作區中一律允許子代理程式,否則每項新子工作都須核准,包括 略過權限 模式;每項工作最多四次請求,包含重試。子代理程式權杖費用已包含在對話估算中。", "config.modelApiHooks.description": "在 Model API 後端執行 Muse Code 鉤子命令。預設關閉。命令僅在受信任的工作區中以您的權限在代理沙箱之外執行。啟用前請在 Muse Spark: Hooks 中檢查命令。Model API 金鑰不會傳給鉤子程序。", + "config.modelApiRepoMap.description": "在 Model API 後端的系統提示詞中加入儲存庫地圖:用 VS Code 語言服務排序的工作區中最常用的檔案與定義,每次對話產生一次,約 1,000 個權杖。預設關閉:它會在每個請求中加入這些權杖,並向您的 Model API 金鑰計費。無論是否開啟,repo_map 工具都可依需求提供同一張地圖。", "config.modelApiScheduledPrompts.description": "付費功能,預設關閉。到期的 /loop 提示詞只有在您接受權杖價格並允許本次執行後,或您在此工作區中一律允許排程執行時,才能透過 Model API 後端執行。已排程的提示詞絕不會在無人確認時執行;每次執行均依模型公布的權杖費率向您的 Model API 金鑰計費。", "config.sandboxNetwork.description": "Muse Code 的 shell 沙箱給予命令的網路。僅在沙箱開啟時適用(museSpark.shellSandbox);沒有沙箱時,命令會使用您的網路。變更此設定會重新啟動 Muse Code 主機。", "config.sandboxNetwork.enumDescriptions.default": "不傳遞任何內容:使用 Muse Code 本身的預設值(proxy-only),或系統管理員的受控設定所設定的值。", diff --git a/scripts/check-bundle-split.mjs b/scripts/check-bundle-split.mjs index 4236b509..106a9871 100644 --- a/scripts/check-bundle-split.mjs +++ b/scripts/check-bundle-split.mjs @@ -44,6 +44,8 @@ const ACTIVATION_ALLOWED = new Map([ // goals, subagents, memory tools, permission engine and MCP client. const LAZY_ONLY = [ 'ModelApiHost.ts', + // M67: the code intelligence tools' Model API side (reads and the rename's write). + 'codeIntelCalls.ts', 'glob.ts', 'goals.ts', 'hooks.ts', diff --git a/scripts/lib/harnessServer.mjs b/scripts/lib/harnessServer.mjs index f0423a65..90eb1c0d 100644 --- a/scripts/lib/harnessServer.mjs +++ b/scripts/lib/harnessServer.mjs @@ -94,6 +94,7 @@ export const SCENARIOS = [ 'muse-workflow-map', 'schedules', 'schedules-narrow', + 'code-intel', ] const CONTENT_TYPES = { '.html': 'text/html; charset=utf-8', diff --git a/src/core/backends/modelapi/ModelApiHost.ts b/src/core/backends/modelapi/ModelApiHost.ts index 82087048..7098e442 100644 --- a/src/core/backends/modelapi/ModelApiHost.ts +++ b/src/core/backends/modelapi/ModelApiHost.ts @@ -35,6 +35,8 @@ import { MODEL_API_EFFORT_OFF, ISO_DATE_LENGTH, MODEL_API_MAX_OUTPUT_TOKENS, + CODE_INTEL_TOOLS, + type CodeIntelTool, MODEL_API_MAX_RETRIES, MODEL_API_MAX_TOOL_ROUNDS, MAX_ENCODED_MEDIA_CHARS, @@ -53,6 +55,7 @@ import { MODEL_API_VERSION, MODEL_API_WEB_SEARCH_TOOL, MODEL_TEXT, + REPO_MAP_PROMPT_TRIES, OUTPUT_REF_PREFIX, SCHEDULE_LIFETIME_MS, SCHEDULE_MAX_INTERVAL_MS, @@ -137,6 +140,20 @@ import { isProtectedPath } from '../../protectedPaths' import { confineWorkspacePath } from '../../workspacePath' import type { McpTool } from '../../mcp' import type { MemoryStore } from '../../memory/memoryStore' +import type { CodeIntelDeps } from '../../codeIntel/codeIntelQuery' +import { codeIntelToolOf } from '../../codeIntel/definitions' +import type { LanguageServiceHost } from '../../codeIntel/languageService' +import type { RenamePlanResult } from '../../codeIntel/rename' +import { repoMapSection } from '../../codeIntel/repoMap' +import { + applyRename, + isProtectedRename, + planRenameCall, + renameCardPath, + renameHookFiles, + renameRefused, + runCodeIntelRead, +} from './codeIntelCalls' import { type ConfirmedModelRequest, MissingApiKeyError, @@ -160,6 +177,7 @@ import type { GoalRecord } from './goalRecord' import { type EnvironmentFacts, instructionsFor } from './instructions' import { dispatchHooks, + matchingHooks, type HookDefinition, type HookDispatch, type HookEvent, @@ -311,6 +329,13 @@ export interface ModelApiHostDeps extends ModelApiPaidHooks { * session-start snapshot; undefined leaves them out. */ readonly memory: MemoryStore | undefined + /** + * VS Code's language services (M67, PLAN.md D49): the code intelligence + * tools; undefined leaves them out. + */ + readonly codeIntel?: LanguageServiceHost | undefined + /** `museSpark.modelApiRepoMap`, read per turn: the repo map in the system prompt (M67). */ + readonly isRepoMapInPrompt?: (() => boolean) | undefined } const NO_ENVIRONMENT: EnvironmentFacts = { git: undefined } @@ -1156,6 +1181,14 @@ export class ModelApiSession implements AgentSession { private readonly context: WorkspaceContext /** The git facts of the prompt's environment section (D15), read on the first turn. */ private environment: EnvironmentFacts | undefined + /** + * The repo map of the prompt (M67), made on a turn that has it on and kept + * for the session once it has text, so the prompt's prefix stays the same. + * A try that failed or came out empty is not kept; a few are made, on + * later turns, before the session goes without. + */ + private repoMapText: string | undefined + private repoMapTries = 0 private readonly pendingApprovals = new Map>() /** Live cards for a second surface joining while a decision is still pending. */ private readonly pendingApprovalEvents = new Map< @@ -1211,6 +1244,8 @@ export class ModelApiSession implements AgentSession { private active: ActiveTurn | undefined /** Each file as the model last read or wrote it, for `write_file`'s check (D27). */ private readonly seenFiles = new Map() + /** Rename plans made for a call's PreToolUse hooks, which the call then writes (M67). */ + private readonly hookRenamePlans = new WeakMap>() /** Keeps each request within the page and encoded-media budgets (M54, PLAN.md D47). */ private readonly budget: MediaBudget private mediaNoticeSent = false @@ -1343,6 +1378,46 @@ export class ModelApiSession implements AgentSession { } } + /** The hooks that run for this session: none in a side chat or with the opt-in off (M51). */ + private enabledHooks(): readonly HookDefinition[] { + return this.isSideChat || this.deps.isHooksEnabled?.() === false ? [] : this.hooks + } + + /** + * A call's arguments as its PreToolUse hooks see them. A rename also names + * the files it would write (M67), planned only when a hook would run and + * the mode allows the edit at all; the call then writes that same plan + * (`decideAndRunRename`), so a hook never allows one set of files while + * another is written. + */ + private async preToolInput( + call: FunctionCallItem, + signal: AbortSignal, + ): Promise>> { + const args = argumentsOf(call) + const deps = this.codeIntelDeps() + if ( + deps === undefined || + signal.aborted || + call.name !== CODE_INTEL_TOOLS.renameSymbol || + matchingHooks(this.enabledHooks(), 'PreToolUse', toolMatcherNames(call.name)).length === 0 || + this.verdictWithHook({ toolName: call.name, toolClass: 'edit' }, false) === 'deny' + ) { + return toolHookInput(args) + } + const planning = planRenameCall(call.arguments, deps) + this.hookRenamePlans.set(call, planning) + try { + const planned = await unlessStopped(planning, signal) + return toolHookInput(planned.ok ? { ...args, files: renameHookFiles(planned.plan) } : args) + } catch { + // A Stop, or the provider's own refusal: the call itself then ends with + // its row and its output (a Stop's included), which a throw from here, + // before the row exists, would leave without one (D26). + return toolHookInput(args) + } + } + private async runHooks( event: HookEvent, turnId: string | undefined, @@ -1352,9 +1427,8 @@ export class ModelApiSession implements AgentSession { shouldReplayContext = true, shouldShowMessages = true, ): Promise { - const enabledHooks = this.isSideChat || this.deps.isHooksEnabled?.() === false ? [] : this.hooks const result = await dispatchHooks( - enabledHooks, + this.enabledHooks(), event, this.hookPayload(event, turnId, fields), matcher, @@ -1424,6 +1498,73 @@ export class ModelApiSession implements AgentSession { } } + /** What the code intelligence tools work with (M67); undefined without language services. */ + private codeIntelDeps(): CodeIntelDeps | undefined { + const { codeIntel } = this.deps + return codeIntel === undefined + ? undefined + : { + service: codeIntel, + workspaceRoot: this.deps.workspaceRoot, + platform: this.deps.platform, + io: this.deps.io, + now: this.deps.now, + } + } + + /** + * Whether this request's prompt carries the repo map (M67): the setting + * on, now, in a trusted workspace only, since the map repeats the + * workspace's file paths and names in every request's instructions. + */ + private isRepoMapOn(): boolean { + return ( + this.deps.codeIntel !== undefined && + this.deps.isWorkspaceTrusted() && + this.deps.isRepoMapInPrompt?.() === true + ) + } + + /** The map this session's prompt carries: a child's is its parent's, never one of its own. */ + private promptRepoMap(): string | undefined { + if (!this.isRepoMapOn()) { + return undefined + } + return this.isSubagent ? this.parentSession?.promptRepoMap() : this.repoMapText + } + + /** + * The repo map for the prompt (M67) while the setting is on. Never throws: + * a map that cannot be made is logged and the prompt goes without it. Only + * a map with text is kept; a try that failed or came out empty counts, and + * after `REPO_MAP_PROMPT_TRIES` the session stops trying. A Stop ends the + * lookups at once and does not count. A child task never builds one. + */ + private async loadRepoMap(signal: AbortSignal): Promise { + const deps = this.codeIntelDeps() + if ( + deps === undefined || + this.isSubagent || + this.repoMapText !== undefined || + this.repoMapTries >= REPO_MAP_PROMPT_TRIES || + !this.isRepoMapOn() + ) { + return + } + try { + const text = await repoMapSection(deps, signal) + if (!signal.aborted) { + this.repoMapTries += 1 + this.repoMapText = text + } + } catch (error: unknown) { + if (!signal.aborted) { + this.repoMapTries += 1 + } + this.deps.log.warn(`The repo map for the prompt could not be made: ${describe(error)}`) + } + } + /** Never throws: a describer that fails leaves the section at "no git". */ private async loadEnvironment(): Promise { try { @@ -1464,6 +1605,7 @@ export class ModelApiSession implements AgentSession { const hasMemory = hasShell && this.deps.memory !== undefined const context = this.context.sections() const goalSection = goalInstructions(this.goal, this.goalSteps) + const repoMap = this.promptRepoMap() const input = this.budget.fit(this.replay.map((entry) => entry.item)) if (this.budget.omitted && !this.mediaNoticeSent) { this.emit({ type: 'backendNotice', level: 'warning', text: UI_TEXT.olderMediaOmitted }) @@ -1479,9 +1621,11 @@ export class ModelApiSession implements AgentSession { shellName: shell.shellName, hasShell, hasMemory, + hasCodeIntel: this.deps.codeIntel !== undefined, today: new Date(this.deps.now()).toISOString().slice(0, ISO_DATE_LENGTH), environment: this.environment ?? NO_ENVIRONMENT, context, + ...(repoMap !== undefined && { repoMap }), // Pinned while the goal is active (M45, PLAN.md D38). ...(goalSection !== undefined && { goalSection }), }), @@ -1539,6 +1683,7 @@ export class ModelApiSession implements AgentSession { hasSubagents: !this.isSubagent && this.deps.isPaidFeatureOn('subagents'), isSubagent: this.isSubagent, hasMemory, + hasCodeIntel: this.deps.codeIntel !== undefined, }) const ide = (this.deps.ideTools ?? []).map( (tool) => mcpFunctionDefinition(ideFunctionName(tool), tool).definition, @@ -3510,6 +3655,10 @@ export class ModelApiSession implements AgentSession { return await this.runShellCall(itemId, call, signal) } default: { + const intelTool = codeIntelToolOf(call.name) + if (intelTool !== undefined && intelTool !== 'renameSymbol') { + return { outcome: await this.readCode(intelTool, call, signal) } + } return { outcome: await executeTool(call.name, call.arguments, { workspaceRoot: this.deps.workspaceRoot, @@ -3624,6 +3773,69 @@ export class ModelApiSession implements AgentSession { return { outcome: await runMemoryCall(memory, placed.value), isRejected: false } } + /** A read-only code intelligence call (M67): a read in every mode, stopped by Stop. */ + private async readCode( + tool: Exclude, + call: FunctionCallItem, + signal: AbortSignal, + ): Promise { + const deps = this.codeIntelDeps() + return deps === undefined + ? toolFailure(`unknown tool ${call.name}`) + : await unlessStopped(runCodeIntelRead(tool, call.arguments, deps, signal), signal) + } + + /** + * `rename_symbol` (M67, PLAN.md D49): the edit planned and checked before + * any card (a refused rename asks nothing), judged as an edit whose card + * names its files (protected if any file is, D24), then written file by + * file after every file is checked again. + */ + private async decideAndRunRename( + itemId: string, + call: FunctionCallItem, + signal: AbortSignal, + shouldForceApproval: boolean, + ): Promise { + const deps = this.codeIntelDeps() + if (deps === undefined) { + return { outcome: toolFailure(`unknown tool ${call.name}`), isRejected: false } + } + // Plan refuses every edit: the language service is not even asked then. + if (this.verdictWithHook({ toolName: call.name, toolClass: 'edit' }, false) === 'deny') { + return this.refusedByMode(call) + } + // The plan the PreToolUse hooks were shown, if they were: it is the one + // written, each file checked again for its content after the card. A + // hook's new arguments are a new call object, planned afresh. + const planning = this.hookRenamePlans.get(call) ?? planRenameCall(call.arguments, deps) + this.hookRenamePlans.delete(call) + const planned = await unlessStopped(planning, signal) + if (!planned.ok) { + return { outcome: renameRefused(planned), isRejected: false } + } + const { plan } = planned + const refusal = await this.judge( + itemId, + call, + signal, + { toolName: call.name, toolClass: 'edit', isProtected: isProtectedRename(plan) }, + { kind: 'fileWrite', path: renameCardPath(plan), toolName: call.name }, + shouldForceApproval, + ) + if (refusal !== undefined) { + return refusal + } + const outcome = await applyRename(plan, { + workspaceRoot: this.deps.workspaceRoot, + platform: this.deps.platform, + io: this.deps.io, + seen: this.seenFiles, + signal, + }) + return { outcome, isRejected: false } + } + /** The permission check and, when it allows, the tool itself. May throw (an abort, an I/O error). */ private async decideAndRun( turnId: string, @@ -3651,6 +3863,9 @@ export class ModelApiSession implements AgentSession { if (isMemoryTool(call.name)) { return await this.decideAndRunMemory(itemId, call, signal, toolClass, shouldForceApproval) } + if (call.name === CODE_INTEL_TOOLS.renameSymbol) { + return await this.decideAndRunRename(itemId, call, signal, shouldForceApproval) + } if ( this.isSubagent && (isSubagentTool(call.name) || @@ -3827,7 +4042,11 @@ export class ModelApiSession implements AgentSession { const pre = await this.runHooks( 'PreToolUse', turnId, - { tool_name: call.name, tool_input: toolHookInput(argumentsOf(call)), tool_use_id: itemId }, + { + tool_name: call.name, + tool_input: await this.preToolInput(call, signal), + tool_use_id: itemId, + }, toolMatcherNames(call.name), signal, false, @@ -4367,6 +4586,7 @@ export class ModelApiSession implements AgentSession { // it never throws, so the user message always follows. await this.context.load() this.environment ??= await this.loadEnvironment() + await this.loadRepoMap(turn.abort.signal) // Pending background output and user shell commands precede this turn. this.settleNotes(turn.turnId) this.touch() @@ -5688,6 +5908,9 @@ export class ModelApiSession implements AgentSession { // The goal as it stands goes with the fork (M45): a goal has no history // to cut, so a fork from an earlier turn gets today's goal too. target.goal = target.isSideChat ? undefined : this.goal + // The prompt's repo map goes with the fork (M67), so it is not made again. + target.repoMapText = this.repoMapText + target.repoMapTries = this.repoMapTries for (const child of this.children.values()) { if (!kept.has(child.parentTurnId)) { continue diff --git a/src/core/backends/modelapi/codeIntelCalls.ts b/src/core/backends/modelapi/codeIntelCalls.ts new file mode 100644 index 00000000..428c2c19 --- /dev/null +++ b/src/core/backends/modelapi/codeIntelCalls.ts @@ -0,0 +1,233 @@ +// The code intelligence tools on the Model API backend (M67, PLAN.md D49): +// the read tools' answers as tool outcomes, and `rename_symbol` written +// through the edit path. A rename's plan (src/core/codeIntel/rename.ts) is +// made before its card. Once approved, every file is confined, checked for +// unsaved changes and read again, and nothing is written unless each is +// still exactly as planned; a Stop until then writes nothing. The files are +// then written one by one with the tools' atomic write, each checked once +// more right before its own write, and the row carries one patch across +// what was written, so Edit Review and rewind cover the rename as they cover +// `edit_file`, a rename stopped partway included. + +import { MODEL_TEXT, RENAME_CARD_FILES_SHOWN, UI_TEXT } from '../../../shared/constants' +import { fill, plural } from '../../../shared/l10n/text' +import { ADD_MARKER, type PatchFile, REMOVE_MARKER } from '../../../shared/patchDocument' +import { type CodeIntelDeps, unsavedDocumentPath } from '../../codeIntel/codeIntelQuery' +import { answerCodeIntel, type CodeIntelReadTool } from '../../codeIntel/codeIntelTools' +import { + planRename, + type RenameFile, + type RenamePlan, + type RenamePlanResult, +} from '../../codeIntel/rename' +import { isProtectedPath } from '../../protectedPaths' +import { confineWorkspacePath } from '../../workspacePath' +import { fingerprint, type ToolIo, type ToolOutcome } from './tools' + +const NOT_JSON = 'arguments are not valid JSON' + +function failed(reason: string, visibleReason: string = reason): ToolOutcome { + return { output: `Error: ${reason}`, visibleOutput: visibleReason, failureReason: visibleReason } +} + +/** The model's arguments as a value; undefined when they are not JSON. */ +function parsedArguments(argsJson: string): { readonly value: unknown } | undefined { + try { + return { value: JSON.parse(argsJson) } + } catch { + return undefined + } +} + +/** A read tool's answer as the model and the row get it. */ +export async function runCodeIntelRead( + tool: CodeIntelReadTool, + argsJson: string, + deps: CodeIntelDeps, + signal: AbortSignal, +): Promise { + const args = parsedArguments(argsJson) + if (args === undefined) { + return failed(NOT_JSON) + } + const answer = await answerCodeIntel(tool, args.value, deps, signal) + return answer.ok + ? { output: answer.text, visibleOutput: answer.text } + : failed(answer.reason, answer.visibleReason) +} + +/** A rename call's plan, made before its card: nothing is written here. */ +export async function planRenameCall( + argsJson: string, + deps: CodeIntelDeps, +): Promise { + const args = parsedArguments(argsJson) + return args === undefined + ? { ok: false, reason: NOT_JSON, visibleReason: NOT_JSON } + : await planRename(args.value, deps) +} + +/** The outcome of a rename that could not be planned. */ +export function renameRefused(result: Extract): ToolOutcome { + return failed(result.reason, result.visibleReason) +} + +/** Whether any file the rename writes is a protected write (D24). */ +export function isProtectedRename(plan: RenamePlan): boolean { + return plan.files.some((file) => isProtectedPath(file.canonical)) +} + +/** + * What the rename's card names: a few of the files, protected ones first so + * the card never hides the one that makes it ask, and how many more. + */ +export function renameCardPath(plan: RenamePlan): string { + const ordered = plan.files.toSorted( + (a, b) => Number(isProtectedPath(b.canonical)) - Number(isProtectedPath(a.canonical)), + ) + const shown = ordered.slice(0, RENAME_CARD_FILES_SHOWN).map((file) => file.relative) + const hidden = plan.files.length - shown.length + const files = shown.join(', ') + return hidden > 0 ? plural(UI_TEXT.renameCardMore, hidden, { files }) : files +} + +/** The rename's files for a hook's payload: workspace-relative, in plan order. */ +export function renameHookFiles(plan: RenamePlan): readonly string[] { + return plan.files.map((file) => file.relative) +} + +export interface RenameWriteContext { + readonly workspaceRoot: string + readonly platform: NodeJS.Platform + readonly io: ToolIo + /** The session's fingerprints of what the model last read or wrote (D27). */ + readonly seen: Map + /** The turn's: a Stop before the first write writes nothing. */ + readonly signal: AbortSignal +} + +type Recheck = + | { readonly ok: true; readonly key: string } + | { readonly ok: false; readonly outcome: ToolOutcome; readonly changedPath?: string } + +/** + * Whether the file is still exactly as the plan read it: confined to the + * same real path, with no unsaved changes, the same text on disk. The key + * is the path its fingerprint is kept under (as the file tools resolve it). + */ +async function recheck(file: RenameFile, context: RenameWriteContext): Promise { + const { io } = context + const resolved = await confineWorkspacePath( + context.workspaceRoot, + file.relative, + context.platform, + io, + ) + if (!resolved.ok || resolved.checkedAbsolute !== file.checkedAbsolute) { + return { ok: false, outcome: failed(MODEL_TEXT.pathChangedAfterApproval) } + } + // By the real path: an editor may hold the file under a link's path. + if ((await unsavedDocumentPath(io, file, context.platform)) !== undefined) { + return { ok: false, outcome: failed(`${file.relative} ${MODEL_TEXT.fileHasUnsavedChanges}`) } + } + const current = await io.readFile(file.checkedAbsolute, file.checkedAbsolute) + return current === file.before + ? { ok: true, key: resolved.absolute } + : { + ok: false, + outcome: failed(fill(MODEL_TEXT.renameChanged, { path: file.relative })), + changedPath: file.relative, + } +} + +/** The patch across the files written, for the row, Edit Review and rewind. */ +function renamePatch(files: readonly RenameFile[]): NonNullable { + const patchFiles: PatchFile[] = files.map((file) => ({ + path: file.relative, + hunks: [...file.hunks], + created: false, + })) + const lines = files.flatMap((file) => file.hunks.flatMap((hunk) => hunk.lines)) + return { + document: JSON.stringify({ files: patchFiles }), + summary: { + files: files.length, + added: lines.filter((line) => line.startsWith(ADD_MARKER)).length, + removed: lines.filter((line) => line.startsWith(REMOVE_MARKER)).length, + }, + } +} + +/** A rename stopped partway: what stopped it, what was written, and its revertable patch. */ +function partial( + template: string, + values: Readonly>, + plan: RenamePlan, + written: readonly RenameFile[], +): ToolOutcome { + const reason = fill(template, { + ...values, + written: String(written.length), + total: String(plan.files.length), + paths: written.map((done) => done.relative).join(', '), + }) + return { ...failed(reason), ...(written.length > 0 && { patch: renamePatch(written) }) } +} + +/** + * Writes an approved rename. Every file is checked again first: nothing is + * written if one moved, changed or gained unsaved changes after the plan, + * or if the turn was stopped meanwhile. Each file is then checked once more + * right before its own write, so a change made while the others were being + * written is never overwritten; that, or a write that fails, stops the rest + * and says which files were written, and the row can revert them. Once the + * first file is written the rest follow even if Stop comes, so the rename is + * not left half done by the button. + */ +export async function applyRename( + plan: RenamePlan, + context: RenameWriteContext, +): Promise { + for (const file of plan.files) { + const result = await recheck(file, context) + if (!result.ok) { + return result.outcome + } + } + context.signal.throwIfAborted() + const written: RenameFile[] = [] + for (const file of plan.files) { + const result = await recheck(file, context) + if (!result.ok) { + return written.length === 0 + ? result.outcome + : partial( + MODEL_TEXT.renameChangedPartway, + { path: result.changedPath ?? file.relative }, + plan, + written, + ) + } + // Stop may arrive while the final per-file recheck awaits I/O. Once a + // write starts, finish the rename so the button cannot leave it half done. + if (written.length === 0) { + context.signal.throwIfAborted() + } + try { + await context.io.writeFile(file.checkedAbsolute, file.after, file.checkedAbsolute) + } catch (error: unknown) { + const reason = error instanceof Error ? error.message : String(error) + return partial(MODEL_TEXT.renamePartial, { path: file.relative, reason }, plan, written) + } + written.push(file) + context.seen.set(result.key, fingerprint(file.after)) + } + const output = fill(MODEL_TEXT.renameDone, { + from: plan.from, + to: plan.to, + edits: String(plan.edits), + files: String(plan.files.length), + paths: plan.files.map((file) => file.relative).join(', '), + }) + return { output, visibleOutput: output, patch: renamePatch(written) } +} diff --git a/src/core/backends/modelapi/hooks.ts b/src/core/backends/modelapi/hooks.ts index f45751d1..393ec8f2 100644 --- a/src/core/backends/modelapi/hooks.ts +++ b/src/core/backends/modelapi/hooks.ts @@ -27,6 +27,7 @@ import { HOOK_CONTROL_CODE_LIMIT, HOOK_NEWLINE_CODE, HOOK_DELETE_CODE, + CODE_INTEL_TOOLS, MILLISECONDS_PER_SECOND, MODEL_API_TOOLS, PROJECT_HOOKS_SEGMENTS, @@ -529,7 +530,9 @@ export function toolMatcherNames(name: string): readonly string[] { case MODEL_API_TOOLS.writeFile: { return [name, 'Write'] } - case MODEL_API_TOOLS.editFile: { + // A rename (M67) edits files as edit_file does: an `Edit` hook sees it too. + case MODEL_API_TOOLS.editFile: + case CODE_INTEL_TOOLS.renameSymbol: { return [name, 'Edit'] } case MODEL_API_TOOLS.search: { diff --git a/src/core/backends/modelapi/instructions.ts b/src/core/backends/modelapi/instructions.ts index 8d735af9..cfa69a66 100644 --- a/src/core/backends/modelapi/instructions.ts +++ b/src/core/backends/modelapi/instructions.ts @@ -11,6 +11,7 @@ import { MEMORY_DIR, MEMORY_INDEX_FILE, MODEL_API_TOOLS, + MODEL_TEXT, type MemoryScope, } from '../../../shared/constants' import type { ContextSections } from '../../context/workspaceContext' @@ -38,6 +39,10 @@ export interface InstructionFacts { readonly hasShell: boolean /** True while the memory tools are offered (M49): trusted, with a memory store. */ readonly hasMemory: boolean + /** True while the code intelligence tools are offered (M67): VS Code's language services. */ + readonly hasCodeIntel: boolean + /** The repo map section, while `museSpark.modelApiRepoMap` is on (M67): fixed for the session. */ + readonly repoMap?: string /** `YYYY-MM-DD` in the host's clock. */ readonly today: string readonly environment: EnvironmentFacts @@ -61,6 +66,7 @@ function baseText(facts: InstructionFacts): string[] { 'You are Muse Spark, a coding agent working inside Visual Studio Code through the Muse Spark Code extension.', `The workspace root is ${facts.workspaceRoot} on ${facts.platform}. Every path you give a tool is relative to it (or absolute inside it); paths outside the workspace are refused.`, `Use the tools for everything that touches the workspace: read_file before editing a file, edit_file for changes inside a file (find must match exactly once), write_file to create or replace a file, search and list_files to look around${facts.hasShell ? ', and the shell tool to run commands' : ''}.`, + ...(facts.hasCodeIntel ? [MODEL_TEXT.codeIntelInstructions] : []), shell, 'Use ask_user when you need a decision from the user; when you offer the user a choice between options, ask through ask_user instead of listing the options in prose. Use todo_write to keep a short task list while working on several steps.', 'Never invent file contents or command output; report what the tools returned. Answer in GitHub-flavoured Markdown, briefly, with code in fenced blocks.', @@ -164,6 +170,7 @@ export function instructionsFor(facts: InstructionFacts): string { : `# Workspace rules${PARAGRAPH}${facts.context.rules}`, skillsText(facts.context), memoryText(facts), + facts.repoMap, facts.goalSection, ] return sections.filter((section) => section !== undefined).join(PARAGRAPH) diff --git a/src/core/backends/modelapi/tools.ts b/src/core/backends/modelapi/tools.ts index 501eb564..6ec18eb0 100644 --- a/src/core/backends/modelapi/tools.ts +++ b/src/core/backends/modelapi/tools.ts @@ -16,6 +16,7 @@ import { todoItemSchema, } from '../../../shared/agentEvents' import { + CODE_INTEL_TOOLS, IMAGE_EXTENSIONS, LIST_FILES_DEFAULT_LIMIT, MAX_DOCUMENT_BYTES, @@ -33,7 +34,6 @@ import { SEARCH_MAX_RESULTS, SEARCH_PATTERN_MAX_LENGTH, SEARCH_TIMEOUT_MS, - PATCH_CONTEXT_LINES, SHELL_DEFAULT_TIMEOUT_MS, SHELL_MAX_TIMEOUT_MS, TOOL_OUTPUT_CLIP_MARKER, @@ -41,15 +41,11 @@ import { TOOL_OUTPUT_MAX_CHARS, UI_TEXT, } from '../../../shared/constants' -import { - ADD_MARKER, - CONTEXT_MARKER, - type PatchFile, - type PatchHunk, - REMOVE_MARKER, -} from '../../../shared/patchDocument' +import { ADD_MARKER, type PatchFile, REMOVE_MARKER } from '../../../shared/patchDocument' import { fill, formatNumber, plural } from '../../../shared/l10n/text' import type { DocumentPart, ImagePart } from '../../agent/agentBackend' +import { changeHunk } from '../../codeIntel/codeText' +import { MODEL_API_CODE_INTEL_DEFINITIONS } from '../../codeIntel/definitions' import { readImageInfo } from '../../imageDimensions' import { isPdf, pdfPageCount } from '../../pdf' import { confineWorkspacePath } from '../../workspacePath' @@ -166,6 +162,8 @@ export interface ToolIo { reserveFile(absolutePath: string, expectedCanonicalPath?: string): Promise /** Whether an editor holds unsaved changes to the file (D27). */ hasUnsavedChanges(absolutePath: string): boolean + /** Absolute paths of the files open in an editor with unsaved changes, as the editor names them. */ + unsavedFiles(): readonly string[] /** Workspace-relative, forward-slash paths of every listed file. */ listFiles(): Promise /** Evaluates the pattern off the host thread with a time budget (ReDoS containment). */ @@ -278,6 +276,15 @@ const TOOL_CLASSES: Readonly> = { [MODEL_API_TOOLS.getGoal]: 'interactive', [MODEL_API_TOOLS.updateGoal]: 'interactive', [MODEL_API_TOOLS.reportProgress]: 'interactive', + // M67 (PLAN.md D49): the language services read, in every mode; a rename is an edit. + [CODE_INTEL_TOOLS.findDefinition]: 'read', + [CODE_INTEL_TOOLS.findReferences]: 'read', + [CODE_INTEL_TOOLS.workspaceSymbols]: 'read', + [CODE_INTEL_TOOLS.documentSymbols]: 'read', + [CODE_INTEL_TOOLS.hover]: 'read', + [CODE_INTEL_TOOLS.callHierarchy]: 'read', + [CODE_INTEL_TOOLS.repoMap]: 'read', + [CODE_INTEL_TOOLS.renameSymbol]: 'edit', } export function classifyTool(name: string): ToolClass | undefined { @@ -333,6 +340,8 @@ export interface ToolDefinitionOptions { readonly isSubagent?: boolean /** Muse Code's memory tools, trusted workspaces only (M49, PLAN.md D41). */ readonly hasMemory?: boolean + /** The code intelligence tools, while VS Code's language services are at hand (M67). */ + readonly hasCodeIntel?: boolean } const DEFAULT_TOOL_OPTIONS: ToolDefinitionOptions = { hasShell: true, hasSkills: false } @@ -524,6 +533,11 @@ export function toolDefinitions( define(tool.name, tool.description, tool.properties, tool.required), ) : []), + ...(options.hasCodeIntel === true + ? MODEL_API_CODE_INTEL_DEFINITIONS.map((tool) => + define(CODE_INTEL_TOOLS[tool.tool], tool.description, tool.properties, tool.required), + ) + : []), ] } @@ -621,55 +635,17 @@ function fileText(text: string, shape: TextShape): string { } /** What the model last saw of a file, to know it is not overwriting an unseen change. */ -function fingerprint(raw: string): string { +export function fingerprint(raw: string): string { return createHash(FINGERPRINT_HASH).update(raw).digest('hex') } /** - * The hunk between two texts: the changed lines (common prefix and suffix - * trimmed) with up to PATCH_CONTEXT_LINES unchanged lines on each side, in - * unified-diff numbering (PLAN.md D27). An insertion's `oldStart` is the - * line it follows (0 at the top), never a marker of a created file; a - * Revert checks the context, so it refuses a file that has moved on. + * An edit's row and patch. The hunk is the changed lines (common prefix and + * suffix trimmed) with their context, in unified-diff numbering (PLAN.md + * D27, `changeHunk`, shared with M67's rename): an insertion's `oldStart` is + * the line it follows, never a marker of a created file, and a Revert checks + * the context, so it refuses a file that has moved on. */ -function hunkBetween(before: string, after: string): PatchHunk | undefined { - const old = splitLines(before) - const updated = splitLines(after) - let start = 0 - while (start < old.length && start < updated.length && old[start] === updated[start]) { - start += 1 - } - let oldEnd = old.length - let newEnd = updated.length - while (oldEnd > start && newEnd > start && old[oldEnd - 1] === updated[newEnd - 1]) { - oldEnd -= 1 - newEnd -= 1 - } - const removed = old.slice(start, oldEnd) - const added = updated.slice(start, newEnd) - if (removed.length === 0 && added.length === 0) { - return undefined - } - const contextStart = Math.max(start - PATCH_CONTEXT_LINES, 0) - const leading = old.slice(contextStart, start) - const trailing = old.slice(oldEnd, oldEnd + PATCH_CONTEXT_LINES) - const oldLines = leading.length + removed.length + trailing.length - const newLines = leading.length + added.length + trailing.length - return { - // Unified numbering: a side with no lines starts at the line before it. - oldStart: oldLines === 0 ? contextStart : contextStart + 1, - oldLines, - newStart: newLines === 0 ? contextStart : contextStart + 1, - newLines, - lines: [ - ...leading.map((line) => `${CONTEXT_MARKER}${line}`), - ...removed.map((line) => `${REMOVE_MARKER}${line}`), - ...added.map((line) => `${ADD_MARKER}${line}`), - ...trailing.map((line) => `${CONTEXT_MARKER}${line}`), - ], - } -} - function patchOutcome( relativePath: string, before: string | undefined, @@ -677,7 +653,7 @@ function patchOutcome( visibleOutput: string, output: string, ): ToolOutcome { - const hunk = hunkBetween(before ?? '', after) + const hunk = changeHunk(before ?? '', after) const hunks = hunk === undefined ? [] : [hunk] // Said outright (D27): a Revert trashes only a file this edit created. const file: PatchFile = { path: relativePath, hunks, created: before === undefined } diff --git a/src/core/codeIntel/codeIntelQuery.ts b/src/core/codeIntel/codeIntelQuery.ts new file mode 100644 index 00000000..cc1b17ed --- /dev/null +++ b/src/core/codeIntel/codeIntelQuery.ts @@ -0,0 +1,576 @@ +// One code intelligence call (M67, PLAN.md D49): the language service +// asked under a deadline, the file the model named confined like the file +// tools' (D24), each result placed in the workspace or left out and +// counted, and the symbol the model meant found from what it gave (a +// position, a name on a line or in a file, or a workspace symbol). + +import * as z from 'zod/mini' +import { + CODE_INTEL_MAX_NAME_MATCHES, + CODE_INTEL_NAME_MAX_CHARS, + CODE_INTEL_PREVIEW_MAX_CHARS, + CODE_INTEL_TIMEOUT_MS, + MILLISECONDS_PER_SECOND, + MODEL_TEXT, + SYMBOL_KIND_NAMES, + UI_TEXT, +} from '../../shared/constants' +import { fill } from '../../shared/l10n/text' +import { isSamePath } from '../paths' +import { withDeadline } from '../timeouts' +import { confineWorkspacePath, isBelow, type RealPathIo } from '../workspacePath' +import { pathModule } from '../workspaceRoot' +import { findName, withoutBom } from './codeText' +import type { LocateArgs } from './definitions' +import type { + CodePosition, + CodeSymbol, + LanguageServiceHost, + OpenedDocument, +} from './languageService' + +/** The file access the tools need: `ToolIo` satisfies it. */ +export interface CodeIntelIo extends RealPathIo { + /** A UTF-8 file's text; undefined when it does not exist; rejects for other content. */ + readFile(absolutePath: string, expectedCanonicalPath?: string): Promise + /** Workspace-relative, forward-slash paths of the workspace's files. */ + listFiles(): Promise + /** Absolute paths of the files open in an editor with unsaved changes, as the editor names them. */ + unsavedFiles(): readonly string[] +} + +/** + * The path of the editor holding unsaved changes to the file, or undefined: + * one naming it as the tools or the language service do, or naming another + * path whose real path is the file's. A workspace opened through a link + * has the editor name a file by the link and the language service by its + * real path (D27 by the file, not by its name). + */ +export async function unsavedDocumentPath( + io: Pick, + file: Pick, + platform: NodeJS.Platform, +): Promise { + const open = io.unsavedFiles() + const named = open.find( + (path) => + isSamePath(path, file.absolute, platform) || isSamePath(path, file.checkedAbsolute, platform), + ) + if (named !== undefined) { + return named + } + for (const path of open) { + try { + if (isSamePath(await io.realPath(path), file.checkedAbsolute, platform)) { + return path + } + } catch { + // An editor's path the file system cannot resolve is not this file. + } + } + return undefined +} + +export interface CodeIntelDeps { + readonly service: LanguageServiceHost + readonly workspaceRoot: string + readonly platform: NodeJS.Platform + readonly io: CodeIntelIo + /** Epoch milliseconds, for the repo map's time budget. */ + readonly now: () => number +} + +export type CodeIntelAnswer = + | { readonly ok: true; readonly text: string } + | { readonly ok: false; readonly reason: string; readonly visibleReason: string } + +/** Why a call cannot be answered: what the model reads, and what the row shows. */ +export class CodeIntelRefusal extends Error { + public constructor( + reason: string, + public readonly visibleReason: string = reason, + ) { + super(reason) + this.name = 'CodeIntelRefusal' + } +} + +const TIMEOUT_SECONDS = CODE_INTEL_TIMEOUT_MS / MILLISECONDS_PER_SECOND +const LINE_BREAK = /[\r\n]/ +const ELLIPSIS = '…' +const CALL_PARENTHESES = '()' +const HIGH_SURROGATE = /[\uD800-\uDBFF]$/ +const DOCUMENT_LINE_BREAK = /\r\n|\r|\n/ + +/** The language service's answer, or a refusal once it has taken too long. */ +export async function ask(work: Promise): Promise { + try { + return await withDeadline( + work, + CODE_INTEL_TIMEOUT_MS, + fill(MODEL_TEXT.codeIntelTimedOut, { seconds: String(TIMEOUT_SECONDS) }), + ) + } catch (error: unknown) { + if (error instanceof Error && error.name === 'DeadlineError') { + throw new CodeIntelRefusal( + error.message, + fill(UI_TEXT.codeIntelTimedOut, { seconds: TIMEOUT_SECONDS }), + ) + } + throw error + } +} + +/** A value's arguments, or the refusal that names what is wrong with them. */ +export function parseArgs(schema: z.ZodMiniType, raw: unknown): T { + const parsed = schema.safeParse(raw) + if (!parsed.success) { + throw new CodeIntelRefusal(`invalid arguments: ${z.prettifyError(parsed.error)}`) + } + return parsed.data +} + +/** A name or query the model gave: one line, not empty, not too long. */ +export function checkName(field: string, value: string): string { + if (value === '' || value.length > CODE_INTEL_NAME_MAX_CHARS || LINE_BREAK.test(value)) { + throw new CodeIntelRefusal( + fill(MODEL_TEXT.codeIntelBadName, { field, max: String(CODE_INTEL_NAME_MAX_CHARS) }), + ) + } + return value +} + +/** + * A symbol's name as code spells it: TypeScript's workspace symbols name a + * function or a method with its call parentheses (`greet()`), which the + * code never spells that way. Shown as it came; matched without them. + */ +export function bareName(symbol: CodeSymbol): string { + return symbol.name.endsWith(CALL_PARENTHESES) + ? symbol.name.slice(0, -CALL_PARENTHESES.length) + : symbol.name +} + +/** A symbol kind in words; one VS Code adds later shows as its number (AGENTS rule 13). */ +export function kindName(kind: number): string { + return SYMBOL_KIND_NAMES[kind] ?? `kind ${String(kind)}` +} + +/** `path:line:column`, 1-based, as the model and the user read positions. */ +export function placeText(relative: string, at: CodePosition): string { + return `${relative}:${String(at.line + 1)}:${String(at.character + 1)}` +} + +/** At most `max` characters, cut on a code point, marked when cut. */ +export function clipText(text: string, max: number): string { + if (text.length <= max) { + return text + } + const kept = text.slice(0, max - 1) + // Never half a surrogate pair. + return `${HIGH_SURROGATE.test(kept) ? kept.slice(0, -1) : kept}${ELLIPSIS}` +} + +/** A file in the workspace: how the model names it, and where it really is. */ +export interface PlacedFile { + /** Absolute, as VS Code names it (what the language services are asked with). */ + readonly absolute: string + /** Workspace-relative, forward slashes. */ + readonly relative: string + /** Workspace-relative after links are resolved: what the permission rules judge (D24). */ + readonly canonical: string + /** The real path, which the disk is read and written at. */ + readonly checkedAbsolute: string +} + +/** The symbol a call is about. */ +export interface Target { + readonly file: PlacedFile + readonly at: CodePosition + readonly document: OpenedDocument + /** Says how a name was resolved, and the other symbols of that name. */ + readonly lead: string | undefined +} + +function isWholeNumberFromOne(value: number): boolean { + return Number.isSafeInteger(value) && value >= 1 +} + +/** Ordinal order of two strings: the same on every machine, whatever its locale. */ +export function compareText(a: string, b: string): number { + if (a === b) { + return 0 + } + return a < b ? -1 : 1 +} + +function comparePlaces( + a: { readonly relative: string; readonly at: CodePosition }, + b: { readonly relative: string; readonly at: CodePosition }, +): number { + return ( + compareText(a.relative, b.relative) || a.at.line - b.at.line || a.at.character - b.at.character + ) +} + +/** Sorts by path, line and column: the same answer every time (M67's acceptance). */ +export function byPlace( + items: readonly T[], +): readonly T[] { + return items.toSorted((a, b) => comparePlaces(a, b)) +} + +/** One call's view of the workspace: confinement and file reads, each done once. */ +export class CodeIntelQuery { + /** Files whose lines this call took from an editor with unsaved changes. */ + private readonly unsaved = new Set() + private readonly realPaths = new Map>() + private readonly placed = new Map>() + private readonly texts = new Map>() + private readonly io: RealPathIo = { + realPath: (absolutePath) => { + let known = this.realPaths.get(absolutePath) + if (known === undefined) { + known = this.deps.io.realPath(absolutePath) + this.realPaths.set(absolutePath, known) + } + return known + }, + } + + public constructor(public readonly deps: CodeIntelDeps) {} + + private async placeOnce(path: string): Promise { + const { workspaceRoot, platform } = this.deps + const textual = await confineWorkspacePath(workspaceRoot, path, platform, this.io) + if (textual.ok) { + return textual + } + let realRoot: string + let realTarget: string + try { + ;[realRoot, realTarget] = await Promise.all([ + this.io.realPath(workspaceRoot), + this.io.realPath(path), + ]) + } catch { + // A path the file system will not resolve is not shown as a workspace file. + return undefined + } + const p = pathModule(platform) + const relative = p.relative(realRoot, realTarget) + if (!isBelow(relative, p)) { + return undefined + } + const forward = relative.split(p.sep).join('/') + return { absolute: path, relative: forward, canonical: forward, checkedAbsolute: realTarget } + } + + /** + * The file's lines as the language service read them: the editor's when + * it holds unsaved changes (the answer then says so), else the disk's; + * undefined when it is missing or not UTF-8 text. + */ + private async readLines(file: PlacedFile): Promise { + let text: string | undefined + try { + const edited = await this.unsavedPath(file) + if (edited === undefined) { + text = await this.deps.io.readFile(file.checkedAbsolute, file.checkedAbsolute) + } else { + // The editor's document, by the editor's own path: the same file + // opened by its real path would be another document, read from disk. + const document = await ask(this.service.open(edited)) + this.noteDocument(file, document) + text = document.text + } + } catch { + // A file that cannot be read as text keeps its locations, without their lines. + return + } + return text === undefined ? undefined : withoutBom(text).split(DOCUMENT_LINE_BREAK) + } + + /** Whether a path is under one of the languages' library folders (`LanguageServiceHost.libraryRoots`). */ + private async isInLibrary(path: string): Promise { + const roots = this.service.libraryRoots() + const p = pathModule(this.deps.platform) + const isUnder = (root: string, target: string) => isBelow(p.relative(root, target), p) + if (roots.some((root) => isUnder(root, path))) { + return true + } + try { + const real = await this.io.realPath(path) + const realRoots = await Promise.all(roots.map(async (root) => await this.io.realPath(root))) + return realRoots.some((root) => isUnder(root, real)) + } catch { + // A path the file system will not resolve is not taken as a library's. + return false + } + } + + /** The file's lines, read once per call. */ + private async linesOf(file: PlacedFile): Promise { + let lines = this.texts.get(file.checkedAbsolute) + if (lines === undefined) { + lines = this.readLines(file) + this.texts.set(file.checkedAbsolute, lines) + } + return await lines + } + + private inFile( + file: PlacedFile, + document: OpenedDocument, + args: LocateArgs, + symbol: string | undefined, + ): Target { + const { line, column } = args + if (line !== undefined && !isWholeNumberFromOne(line)) { + throw new CodeIntelRefusal(MODEL_TEXT.codeIntelBadPosition) + } + if (column !== undefined) { + if (line === undefined || !isWholeNumberFromOne(column)) { + throw new CodeIntelRefusal(MODEL_TEXT.codeIntelBadPosition) + } + return { file, document, at: { line: line - 1, character: column - 1 }, lead: undefined } + } + if (symbol === undefined) { + throw new CodeIntelRefusal(MODEL_TEXT.codeIntelNoTarget) + } + const text = withoutBom(document.text) + const at = + line === undefined + ? findName(text, symbol, { line: 0, character: 0 }) + : findName(text, symbol, { line: line - 1, character: 0 }, line - 1) + const where = line === undefined ? file.relative : `${file.relative}:${String(line)}` + if (at === undefined) { + throw new CodeIntelRefusal(fill(MODEL_TEXT.codeIntelNotInFile, { symbol, place: where })) + } + return { + file, + document, + at, + lead: fill(MODEL_TEXT.codeIntelUsing, { symbol, place: placeText(file.relative, at) }), + } + } + + /** The workspace symbols named exactly `name`, inside the workspace, in place order. */ + private async symbolsNamed( + name: string, + ): Promise< + readonly { symbol: CodeSymbol; file: PlacedFile; relative: string; at: CodePosition }[] + > { + const found = await ask(this.service.workspaceSymbols(name)) + const placed = await Promise.all( + found + .filter((symbol) => bareName(symbol) === name) + .map(async (symbol) => { + const file = await this.place(symbol.location.path) + return file === undefined + ? undefined + : { symbol, file, relative: file.relative, at: symbol.selection.start } + }), + ) + return byPlace(placed.filter((entry) => entry !== undefined)) + } + + private async byWorkspaceSymbol(symbol: string): Promise { + const [first, ...others] = await this.symbolsNamed(symbol) + if (first === undefined) { + throw new CodeIntelRefusal(fill(MODEL_TEXT.codeIntelNoSymbolNamed, { symbol })) + } + const { file, document } = await this.openAsEdited(first.file) + this.noteDocument(file, document) + const { selection } = first.symbol + // The provider's range may start before the name (`export class Foo`). + const at = + findName(withoutBom(document.text), symbol, selection.start, selection.end.line) ?? + selection.start + const listed = others + .slice(0, CODE_INTEL_MAX_NAME_MATCHES) + .map((other) => placeText(other.relative, other.at)) + const hidden = others.length - listed.length + const places = hidden > 0 ? [...listed, `+${String(hidden)}`] : listed + return { + file, + document, + at, + lead: joinLines([ + fill(MODEL_TEXT.codeIntelUsing, { symbol, place: placeText(first.relative, at) }), + places.length === 0 + ? undefined + : fill(MODEL_TEXT.codeIntelOtherMatches, { symbol, places: places.join(', ') }), + ]), + } + } + + public get service(): LanguageServiceHost { + return this.deps.service + } + + /** A path the model gave, confined like the file tools' (D24), or the refusal. */ + public async confine(given: string): Promise { + const { workspaceRoot, platform } = this.deps + const resolved = await confineWorkspacePath(workspaceRoot, given, platform, this.io) + if (!resolved.ok) { + throw new CodeIntelRefusal(resolved.reason) + } + return resolved + } + + /** + * Where a result is in the workspace, or undefined when it is outside (a + * library's declarations, another folder, a virtual document). A file is + * inside when its path is, or when its real path is inside the real root + * (a workspace opened through a link, whose files the server reports by + * their real paths). + */ + public place(path: string | undefined): Promise { + if (path === undefined) { + return Promise.resolve(undefined) + } + let known = this.placed.get(path) + if (known === undefined) { + known = this.placeOnce(path) + this.placed.set(path, known) + } + return known + } + + /** A line of a file on disk (0-based), trimmed and clipped; undefined when it cannot be read. */ + public async preview(file: PlacedFile, line: number): Promise { + const lines = await this.linesOf(file) + const text = lines?.[line]?.trim() + return text === undefined || text === '' + ? undefined + : clipText(text, CODE_INTEL_PREVIEW_MAX_CHARS) + } + + /** + * Where a symbol's name starts, found in its range on disk: a provider's + * range may start before the name (TypeScript's workspace symbols start at + * `export`). The range's own start when the name is not found there. + */ + public async nameStart(file: PlacedFile, symbol: CodeSymbol): Promise { + const { start, end } = symbol.selection + const lines = (await this.linesOf(file)) ?? [] + for (let line = start.line; line <= end.line; line += 1) { + const from = { line: 0, character: line === start.line ? start.character : 0 } + const found = findName(lines[line] ?? '', bareName(symbol), from) + if (found !== undefined) { + return { line, character: found.character } + } + } + return start + } + + /** The path of an editor with unsaved changes to the file (`unsavedDocumentPath`), or undefined. */ + public async unsavedPath( + file: Pick, + ): Promise { + return await unsavedDocumentPath( + { + unsavedFiles: () => this.deps.io.unsavedFiles(), + realPath: async (path) => await this.io.realPath(path), + }, + file, + this.deps.platform, + ) + } + + /** + * The file's document as the language service reads it: an editor's with + * unsaved changes, by the editor's path, when one holds the file (the file + * is then asked about at that path), else the file's own. + */ + public async openAsEdited( + file: PlacedFile, + ): Promise<{ readonly file: PlacedFile; readonly document: OpenedDocument }> { + const edited = await this.unsavedPath(file) + const asked = edited === undefined ? file : { ...file, absolute: edited } + return { file: asked, document: await ask(this.service.open(asked.absolute)) } + } + + /** Remembers a document the answer read with unsaved changes, for `unsavedNotes`. */ + public noteDocument(file: PlacedFile, document: OpenedDocument): void { + if (document.isDirty) { + this.unsaved.add(file.relative) + } + } + + /** What the answer adds when its lines came from editors with unsaved changes. */ + public unsavedNotes(): readonly string[] { + return this.unsaved.size === 0 + ? [] + : [ + fill(MODEL_TEXT.codeIntelUnsavedNote, { + paths: [...this.unsaved].toSorted((a, b) => compareText(a, b)).join(', '), + }), + ] + } + + /** + * Whether an answer may show what a result says about its file: a file in + * the workspace, or one under a language's library folder. + */ + public async isDescribable(path: string | undefined): Promise { + return ( + path !== undefined && + ((await this.place(path)) !== undefined || (await this.isInLibrary(path))) + ) + } + + /** The refusal for a file no language service answers for (M67: never an empty answer). */ + public noService(file: PlacedFile, document: OpenedDocument): CodeIntelRefusal { + return new CodeIntelRefusal( + fill(MODEL_TEXT.codeIntelNoService, { path: file.relative, language: document.languageId }), + fill(UI_TEXT.codeIntelNoService, { path: file.relative }), + ) + } + + /** + * What an empty answer means: nothing of the kind at the position when + * the file's language service answers for it (it lists the file's + * symbols), else no language service at all. + */ + public async nothingAt(what: string, target: Target): Promise { + const symbols = await ask(this.service.documentSymbols(target.file.absolute)) + if (symbols.length === 0) { + throw this.noService(target.file, target.document) + } + return joinLines([ + target.lead, + fill(MODEL_TEXT.codeIntelNothingAt, { + what, + place: placeText(target.file.relative, target.at), + }), + ]) + } + + /** The symbol the arguments name (see `definitions.ts`), or the refusal. */ + public async target(args: LocateArgs): Promise { + const symbol = args.symbol === undefined ? undefined : checkName('symbol', args.symbol) + if (args.path !== undefined) { + const { file, document } = await this.openAsEdited(await this.confine(args.path)) + // The model's lines come from read_file, which reads the disk; the + // service reads the editor's text, and the two differ here. + if (document.isDirty && args.line !== undefined) { + throw new CodeIntelRefusal( + fill(MODEL_TEXT.codeIntelUnsavedPosition, { path: file.relative }), + ) + } + this.noteDocument(file, document) + return this.inFile(file, document, args, symbol) + } + if (symbol !== undefined) { + return await this.byWorkspaceSymbol(symbol) + } + throw new CodeIntelRefusal(MODEL_TEXT.codeIntelNoTarget) + } +} + +/** The lines that are present, one per line. */ +export function joinLines(lines: readonly (string | undefined)[]): string { + return lines.filter((line) => line !== undefined && line !== '').join('\n') +} diff --git a/src/core/codeIntel/codeIntelTools.ts b/src/core/codeIntel/codeIntelTools.ts new file mode 100644 index 00000000..2a120aae --- /dev/null +++ b/src/core/codeIntel/codeIntelTools.ts @@ -0,0 +1,411 @@ +// The read-only code intelligence tools (M67, PLAN.md D49): definitions, +// references, workspace and document symbols, hover and the call +// hierarchy, answered from VS Code's language services for both backends. +// Every answer is workspace-relative, sorted, capped with the rest counted, +// and says how many results outside the workspace it left out; a file whose +// language has no service answers "no language service", never nothing. + +import { + CODE_INTEL_HOVER_MAX_CHARS, + CODE_INTEL_MAX_CALL_SITES, + CODE_INTEL_MAX_CALLS, + CODE_INTEL_MAX_LOCATIONS, + CODE_INTEL_MAX_SYMBOLS, + CODE_INTEL_SYMBOL_DEPTH, + type CodeIntelTool, + MODEL_TEXT, + REPO_MAP_DEFAULT_TOKENS, + REPO_MAP_MAX_TOKENS, + REPO_MAP_TIME_BUDGET_MS, +} from '../../shared/constants' +import { fill } from '../../shared/l10n/text' +import { + ask, + bareName, + byPlace, + checkName, + clipText, + compareText, + type CodeIntelAnswer, + type CodeIntelDeps, + CodeIntelQuery, + CodeIntelRefusal, + joinLines, + kindName, + parseArgs, + type PlacedFile, + placeText, +} from './codeIntelQuery' +import { + callHierarchyArgs, + documentSymbolsArgs, + locateArgs, + repoMapArgs, + workspaceSymbolsArgs, +} from './definitions' +import type { CodeLocation, CodePosition, CodeRange, CodeSymbol } from './languageService' +import { repoMap } from './repoMap' + +export type CodeIntelReadTool = Exclude + +const INDENT = ' ' + +interface PlacedResult { + readonly file: PlacedFile + readonly relative: string + readonly at: CodePosition +} + +/** The notes under a listing: what was not shown, and what was outside. */ +function listingNotes(hidden: number, outside: number): readonly string[] { + return [ + ...(hidden > 0 ? [fill(MODEL_TEXT.codeIntelMore, { count: String(hidden) })] : []), + ...(outside > 0 ? [fill(MODEL_TEXT.codeIntelOutside, { count: String(outside) })] : []), + ] +} + +/** A symbol found twice (a provider's repeat) is one; two symbols at one place are two. */ +function symbolKey(symbol: CodeSymbol): string { + return `${String(symbol.kind)} ${symbol.name}` +} + +/** + * Each result placed in the workspace (sorted, a repeat of the same place + * and `sameness` left out), and the count left outside. + */ +async function placeAll( + query: CodeIntelQuery, + items: readonly T[], + where: (item: T) => { readonly path: string | undefined; readonly at: CodePosition }, + sameness: (item: T) => string = () => '', +): Promise<{ + readonly inside: readonly (PlacedResult & { readonly item: T })[] + readonly outside: number +}> { + const placed = await Promise.all( + items.map(async (item) => { + const { path, at } = where(item) + const file = await query.place(path) + return file === undefined ? undefined : { item, file, relative: file.relative, at } + }), + ) + const seen = new Set() + const inside = byPlace(placed.filter((entry) => entry !== undefined)).filter((entry) => { + const key = `${placeText(entry.relative, entry.at)} ${sameness(entry.item)}` + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) + return { inside, outside: placed.filter((entry) => entry === undefined).length } +} + +/** Locations as `path:line:column: the line`, capped, with the notes. */ +async function locationListing( + query: CodeIntelQuery, + locations: readonly CodeLocation[], +): Promise { + const { inside, outside } = await placeAll(query, locations, (location) => ({ + path: location.path, + at: location.range.start, + })) + const shown = inside.slice(0, CODE_INTEL_MAX_LOCATIONS) + const lines = await Promise.all( + shown.map(async (entry) => { + const place = placeText(entry.relative, entry.at) + const text = await query.preview(entry.file, entry.at.line) + return text === undefined ? place : `${place}: ${text}` + }), + ) + return [...lines, ...listingNotes(inside.length - shown.length, outside)] +} + +async function findLocations( + query: CodeIntelQuery, + raw: unknown, + what: string, + find: (path: string, at: CodePosition) => Promise, +): Promise { + const target = await query.target(parseArgs(locateArgs, raw)) + const locations = await ask(find(target.file.absolute, target.at)) + return locations.length === 0 + ? await query.nothingAt(what, target) + : joinLines([target.lead, ...(await locationListing(query, locations))]) +} + +/** + * The hover, unless the symbol is defined only in files outside the + * workspace that are no language's library: a hover shows what its + * declaration says (its documentation, a constant's literal type), and the + * tools show nothing of such files. A symbol with no definition to judge + * (a keyword, a literal) keeps its hover. + */ +async function hover(query: CodeIntelQuery, raw: unknown): Promise { + const target = await query.target(parseArgs(locateArgs, raw)) + const parts = await ask(query.service.hover(target.file.absolute, target.at)) + const text = parts + .map((part) => part.trim()) + .filter((part) => part !== '') + .join('\n\n') + if (text === '') { + return await query.nothingAt('hover information', target) + } + const definitions = await ask(query.service.definitions(target.file.absolute, target.at)) + const describable = await Promise.all( + definitions.map(async (definition) => await query.isDescribable(definition.path)), + ) + const isHeldBack = definitions.length > 0 && describable.every((isShown) => !isShown) + return joinLines([ + target.lead, + isHeldBack + ? fill(MODEL_TEXT.codeIntelHoverHeldBack, { count: String(definitions.length) }) + : clipText(text, CODE_INTEL_HOVER_MAX_CHARS), + ]) +} + +function symbolLabel(symbol: CodeSymbol): string { + const detail = symbol.detail === undefined || symbol.detail === '' ? '' : ` ${symbol.detail}` + return `${kindName(symbol.kind)} ${symbol.name}${detail}` +} + +function symbolStart(symbol: CodeSymbol): CodePosition { + return symbol.selection.start +} + +function compareSymbols(a: CodeSymbol, b: CodeSymbol): number { + const left = symbolStart(a) + const right = symbolStart(b) + return left.line - right.line || left.character - right.character || compareText(a.name, b.name) +} + +/** How many symbols a tree holds, its root included. */ +function countSymbols(symbols: readonly CodeSymbol[]): number { + return symbols.reduce((total, symbol) => total + 1 + countSymbols(symbol.children), 0) +} + +/** The outline, depth-first in document order, within the depth and the cap. */ +function outlineLines(symbols: readonly CodeSymbol[]): readonly string[] { + const lines: string[] = [] + const walk = (level: readonly CodeSymbol[], depth: number) => { + const ordered = level.toSorted((a, b) => compareSymbols(a, b)) + for (const symbol of ordered) { + if (lines.length >= CODE_INTEL_MAX_SYMBOLS) { + return + } + const at = symbolStart(symbol) + lines.push( + `${INDENT.repeat(depth)}${String(at.line + 1)}:${String(at.character + 1)} ${symbolLabel(symbol)}`, + ) + if (depth + 1 < CODE_INTEL_SYMBOL_DEPTH) { + walk(symbol.children, depth + 1) + } + } + } + walk(symbols, 0) + return lines +} + +async function documentSymbols(query: CodeIntelQuery, raw: unknown): Promise { + const { path } = parseArgs(documentSymbolsArgs, raw) + // The outline of the editor's text when one holds unsaved changes to the + // file, asked at that editor's path (it may name the file another way). + const { file, document } = await query.openAsEdited(await query.confine(path)) + query.noteDocument(file, document) + const symbols = await ask(query.service.documentSymbols(file.absolute)) + if (symbols.length === 0) { + throw query.noService(file, document) + } + const lines = outlineLines(symbols) + return joinLines([ + `${file.relative} (${document.languageId}):`, + ...lines, + ...listingNotes(countSymbols(symbols) - lines.length, 0), + ]) +} + +/** Exact names first (then the same name in another case), then by place. */ +function symbolRank(symbol: CodeSymbol, query: string): number { + const name = bareName(symbol) + if (name === query) { + return 0 + } + return name.toLowerCase() === query.toLowerCase() ? 1 : 2 +} + +async function workspaceSymbols(query: CodeIntelQuery, raw: unknown): Promise { + const text = checkName('query', parseArgs(workspaceSymbolsArgs, raw).query) + const found = await ask(query.service.workspaceSymbols(text)) + const { inside, outside } = await placeAll( + query, + found, + (symbol) => ({ path: symbol.location.path, at: symbol.selection.start }), + symbolKey, + ) + if (inside.length === 0) { + return joinLines([ + fill(MODEL_TEXT.codeIntelNoSymbolsMatch, { query: text }), + ...listingNotes(0, outside), + ]) + } + // Each at its name, which a provider's range may start before. + const named = await Promise.all( + inside.map(async (entry) => ({ ...entry, at: await query.nameStart(entry.file, entry.item) })), + ) + const ranked = byPlace(named).toSorted( + (a, b) => symbolRank(a.item, text) - symbolRank(b.item, text), + ) + const shown = ranked.slice(0, CODE_INTEL_MAX_SYMBOLS) + return joinLines([ + ...shown.map((entry) => { + const { container } = entry.item + const within = container === undefined || container === '' ? '' : ` (in ${container})` + return `${placeText(entry.relative, entry.at)}: ${kindName(entry.item.kind)} ${entry.item.name}${within}` + }), + ...listingNotes(inside.length - shown.length, outside), + ]) +} + +/** + * Each call site, a few listed and the rest counted: `line:column`, or + * `path:line:column` when the sites are in a file the line does not name. + */ +function callSites(ranges: readonly CodeRange[], relative?: string): string { + const sorted = ranges + .map((range) => range.start) + .toSorted((a, b) => a.line - b.line || a.character - b.character) + const listed = sorted + .slice(0, CODE_INTEL_MAX_CALL_SITES) + .map((at) => + relative === undefined + ? `${String(at.line + 1)}:${String(at.character + 1)}` + : placeText(relative, at), + ) + const hidden = sorted.length - listed.length + return (hidden > 0 ? [...listed, `+${String(hidden)}`] : listed).join(', ') +} + +async function callHierarchy(query: CodeIntelQuery, raw: unknown): Promise { + const args = parseArgs(callHierarchyArgs, raw) + const target = await query.target(args) + const direction = args.direction ?? 'incoming' + const answer = await ask(query.service.callHierarchy(target.file.absolute, target.at, direction)) + if (answer === undefined) { + const symbols = await ask(query.service.documentSymbols(target.file.absolute)) + throw symbols.length === 0 + ? query.noService(target.file, target.document) + : new CodeIntelRefusal( + fill(MODEL_TEXT.codeIntelNoCallHierarchy, { + place: placeText(target.file.relative, target.at), + }), + ) + } + const itemFile = await query.place(answer.item.location.path) + const header = fill( + direction === 'incoming' ? MODEL_TEXT.codeIntelCallsTo : MODEL_TEXT.codeIntelCallsFrom, + { + symbol: `${kindName(answer.item.kind)} ${answer.item.name}`, + place: + itemFile === undefined + ? MODEL_TEXT.codeIntelOutsideWorkspace + : placeText(itemFile.relative, answer.item.selection.start), + }, + ) + const { inside, outside } = await placeAll( + query, + answer.calls, + (call) => ({ path: call.symbol.location.path, at: call.symbol.selection.start }), + (call) => symbolKey(call.symbol), + ) + const shown = inside.slice(0, CODE_INTEL_MAX_CALLS) + // Incoming calls are in the caller's file, the line's own; outgoing ones + // are in the file of the function asked about, named with each site. + const sitesOf = (ranges: readonly CodeRange[]): string => { + if (direction === 'incoming') { + return fill(MODEL_TEXT.codeIntelCallSites, { sites: callSites(ranges) }) + } + return itemFile === undefined + ? fill(MODEL_TEXT.codeIntelCalledOutside, { sites: callSites(ranges) }) + : fill(MODEL_TEXT.codeIntelCalledAt, { sites: callSites(ranges, itemFile.relative) }) + } + return joinLines([ + target.lead, + header, + ...(outside === 0 && inside.length === 0 ? [MODEL_TEXT.codeIntelNoCalls] : []), + ...shown.map( + (entry) => + `${placeText(entry.relative, entry.at)}: ${kindName(entry.item.symbol.kind)} ${entry.item.symbol.name} (${sitesOf(entry.item.ranges)})`, + ), + ...listingNotes(inside.length - shown.length, outside), + ...(answer.otherItems > 0 + ? [fill(MODEL_TEXT.codeIntelOtherCallItems, { count: String(answer.otherItems) })] + : []), + ]) +} + +async function repoMapTool(query: CodeIntelQuery, raw: unknown, signal: AbortSignal | undefined) { + const { max_tokens: requested } = parseArgs(repoMapArgs, raw) + const maxTokens = Math.min( + Math.max(Math.floor(requested ?? REPO_MAP_DEFAULT_TOKENS), 1), + REPO_MAP_MAX_TOKENS, + ) + return await repoMap(query, { maxTokens, timeBudgetMs: REPO_MAP_TIME_BUDGET_MS, signal }) +} + +async function answerText( + tool: CodeIntelReadTool, + query: CodeIntelQuery, + raw: unknown, + signal: AbortSignal | undefined, +): Promise { + switch (tool) { + case 'findDefinition': { + return await findLocations(query, raw, 'definition', (path, at) => + query.service.definitions(path, at), + ) + } + case 'findReferences': { + return await findLocations(query, raw, 'references', (path, at) => + query.service.references(path, at), + ) + } + case 'hover': { + return await hover(query, raw) + } + case 'documentSymbols': { + return await documentSymbols(query, raw) + } + case 'workspaceSymbols': { + return await workspaceSymbols(query, raw) + } + case 'callHierarchy': { + return await callHierarchy(query, raw) + } + case 'repoMap': { + return await repoMapTool(query, raw, signal) + } + } +} + +/** + * One read-only call: the answer, or the refusal with its reason. Anything + * else (the language service's own failure) is thrown for the caller to + * report as a failed call. + */ +export async function answerCodeIntel( + tool: CodeIntelReadTool, + raw: unknown, + deps: CodeIntelDeps, + signal?: AbortSignal, +): Promise { + const query = new CodeIntelQuery(deps) + try { + const text = await answerText(tool, query, raw, signal) + return { ok: true, text: joinLines([text, ...query.unsavedNotes()]) } + } catch (error: unknown) { + if (error instanceof CodeIntelRefusal) { + return { ok: false, reason: error.message, visibleReason: error.visibleReason } + } + throw error + } +} diff --git a/src/core/codeIntel/codeText.ts b/src/core/codeIntel/codeText.ts new file mode 100644 index 00000000..35adff39 --- /dev/null +++ b/src/core/codeIntel/codeText.ts @@ -0,0 +1,283 @@ +// Text work for the code intelligence tools (M67): positions to offsets as +// VS Code counts them, a whole-word search for a symbol's name, a +// language service's edits applied to a file's text, and the hunks of the +// patch a rename leaves (the Model API's row, Edit Review and rewind) or +// hands back as a diff (the `ide` tool). Pure. + +import { PATCH_CONTEXT_LINES } from '../../shared/constants' +import { + ADD_MARKER, + CONTEXT_MARKER, + type PatchHunk, + REMOVE_MARKER, +} from '../../shared/patchDocument' +import type { CodePosition, TextEdit } from './languageService' + +export const BOM = '\u{FEFF}' +const CR = '\r' +const LF = '\n' +const CRLF = '\r\n' +// Edit Review and rewind split a file this way (src/core/patchApply.ts), so +// the hunks must too. +const PATCH_LINE_BREAK = /\r?\n/ +// What continues a name: a letter, a digit, `_` or `$` (JavaScript's, and +// most languages' identifiers). +const NAME_CHARACTER = /[\p{L}\p{N}_$]/u + +/** The text without its UTF-8 byte-order mark, as VS Code's documents hold it. */ +export function withoutBom(text: string): string { + return text.startsWith(BOM) ? text.slice(BOM.length) : text +} + +/** Where each line starts, VS Code's line breaks counted. */ +function lineStarts(text: string): readonly number[] { + const starts = [0] + for (let index = 0; index < text.length; index += 1) { + const character = text[index] + if (character === CR && text[index + 1] === LF) { + index += 1 + starts.push(index + 1) + } else if (character === CR || character === LF) { + starts.push(index + 1) + } + } + return starts +} + +/** Where a line's text ends, its line break left out. */ +function lineEnd(text: string, starts: readonly number[], line: number): number { + const next = starts[line + 1] + if (next === undefined) { + return text.length + } + return next - (text.slice(next - CRLF.length, next) === CRLF ? CRLF.length : 1) +} + +/** The offset of a position; undefined past the end of its line or of the text. */ +function offsetOf(text: string, starts: readonly number[], at: CodePosition): number | undefined { + const start = starts[at.line] + if (start === undefined || at.character < 0) { + return undefined + } + const offset = start + at.character + return offset <= lineEnd(text, starts, at.line) ? offset : undefined +} + +/** The position of an offset. */ +function positionOf(starts: readonly number[], offset: number): CodePosition { + let line = 0 + while (line + 1 < starts.length && (starts[line + 1] ?? 0) <= offset) { + line += 1 + } + return { line, character: offset - (starts[line] ?? 0) } +} + +/** + * The first whole-word occurrence of `name` at or after `from`, and no + * later than line `lastLine` when given, as a position; undefined when there + * is none (or `from` is not in the text). The name is matched literally, + * never as a pattern. + */ +export function findName( + text: string, + name: string, + from: CodePosition, + lastLine?: number, +): CodePosition | undefined { + const starts = lineStarts(text) + const begin = offsetOf(text, starts, from) + if (begin === undefined || name === '') { + return undefined + } + const end = lastLine === undefined ? text.length : lineEnd(text, starts, lastLine) + for (let at = text.indexOf(name, begin); at !== -1; at = text.indexOf(name, at + 1)) { + if (at + name.length > end) { + return undefined + } + const before = text.slice(Math.max(at - 1, 0), at) + const after = text.slice(at + name.length, at + name.length + 1) + if (!NAME_CHARACTER.test(before) && !NAME_CHARACTER.test(after)) { + return positionOf(starts, at) + } + } + return undefined +} + +/** The text a range covers; undefined when the range is not inside the text. */ +export function textIn(text: string, start: CodePosition, end: CodePosition): string | undefined { + const starts = lineStarts(text) + const from = offsetOf(text, starts, start) + const to = offsetOf(text, starts, end) + return from === undefined || to === undefined || to < from ? undefined : text.slice(from, to) +} + +/** + * The text with the edits applied, positions read against the text as it + * was; undefined when an edit falls outside it or two edits overlap. + */ +export function applyTextEdits(text: string, edits: readonly TextEdit[]): string | undefined { + const starts = lineStarts(text) + const spans: { readonly from: number; readonly to: number; readonly newText: string }[] = [] + for (const edit of edits) { + const from = offsetOf(text, starts, edit.range.start) + const to = offsetOf(text, starts, edit.range.end) + if (from === undefined || to === undefined || to < from) { + return undefined + } + spans.push({ from, to, newText: edit.newText }) + } + spans.sort((a, b) => a.from - b.from || a.to - b.to) + let result = '' + let cursor = 0 + for (const span of spans) { + if (span.from < cursor) { + return undefined + } + result += `${text.slice(cursor, span.from)}${span.newText}` + cursor = span.to + } + return `${result}${text.slice(cursor)}` +} + +function patchLines(text: string): readonly string[] { + const lines = text.split(PATCH_LINE_BREAK) + if (lines.at(-1) === '') { + lines.pop() + } + return lines +} + +/** A hunk over `old[oldFrom, oldTo)` and `updated[newFrom, newTo)`, unified numbering. */ +function hunkOf( + lines: readonly string[], + oldFrom: number, + oldLines: number, + newFrom: number, + newLines: number, +): PatchHunk { + return { + // A side with no lines starts at the line before it (unified numbering). + oldStart: oldLines === 0 ? oldFrom : oldFrom + 1, + oldLines, + newStart: newLines === 0 ? newFrom : newFrom + 1, + newLines, + lines: [...lines], + } +} + +/** One hunk around everything that changed; undefined when no line did. */ +function spanningHunk(old: readonly string[], updated: readonly string[]): PatchHunk | undefined { + let start = 0 + while (start < old.length && start < updated.length && old[start] === updated[start]) { + start += 1 + } + let oldEnd = old.length + let newEnd = updated.length + while (oldEnd > start && newEnd > start && old[oldEnd - 1] === updated[newEnd - 1]) { + oldEnd -= 1 + newEnd -= 1 + } + if (oldEnd === start && newEnd === start) { + return undefined + } + const from = Math.max(start - PATCH_CONTEXT_LINES, 0) + const trailing = old.slice(oldEnd, oldEnd + PATCH_CONTEXT_LINES) + const leading = old.slice(from, start) + const lines = [ + ...leading.map((line) => `${CONTEXT_MARKER}${line}`), + ...old.slice(start, oldEnd).map((line) => `${REMOVE_MARKER}${line}`), + ...updated.slice(start, newEnd).map((line) => `${ADD_MARKER}${line}`), + ...trailing.map((line) => `${CONTEXT_MARKER}${line}`), + ] + const context = leading.length + trailing.length + return hunkOf(lines, from, context + oldEnd - start, from, context + newEnd - start) +} + +/** + * The one hunk around everything that changed between two texts, with + * three lines of context each side, in unified numbering (PLAN.md D27: an + * insertion's `oldStart` is the line it follows); undefined when no line + * changed. The file tools' patch (`write_file`, `edit_file`). + */ +export function changeHunk(before: string, after: string): PatchHunk | undefined { + return spanningHunk(patchLines(before), patchLines(after)) +} + +/** The hunk for lines `[from, to]` of two texts of equal length: runs of changes, context between. */ +function alignedHunk( + old: readonly string[], + updated: readonly string[], + from: number, + to: number, +): PatchHunk { + const lines: string[] = [] + let index = from + while (index <= to) { + if (old[index] === updated[index]) { + lines.push(`${CONTEXT_MARKER}${old[index] ?? ''}`) + index += 1 + continue + } + let runEnd = index + while (runEnd <= to && old[runEnd] !== updated[runEnd]) { + runEnd += 1 + } + lines.push( + ...old.slice(index, runEnd).map((line) => `${REMOVE_MARKER}${line}`), + ...updated.slice(index, runEnd).map((line) => `${ADD_MARKER}${line}`), + ) + index = runEnd + } + const count = to - from + 1 + return hunkOf(lines, from, count, from, count) +} + +/** + * The hunks between two texts (BOMs left out, as Edit Review matches them): + * one per group of changed lines when the line count is unchanged (a rename + * changes names within lines), else one around everything that changed. + */ +export function patchHunks(before: string, after: string): readonly PatchHunk[] { + const old = patchLines(withoutBom(before)) + const updated = patchLines(withoutBom(after)) + if (old.length !== updated.length) { + const hunk = spanningHunk(old, updated) + return hunk === undefined ? [] : [hunk] + } + const changed = old.flatMap((line, index) => (line === updated[index] ? [] : [index])) + const hunks: PatchHunk[] = [] + let group: { first: number; last: number } | undefined + const close = (closing: { first: number; last: number }) => { + hunks.push( + alignedHunk( + old, + updated, + Math.max(closing.first - PATCH_CONTEXT_LINES, 0), + Math.min(closing.last + PATCH_CONTEXT_LINES, old.length - 1), + ), + ) + } + for (const index of changed) { + if (group !== undefined && index - group.last <= PATCH_CONTEXT_LINES * 2) { + group.last = index + continue + } + if (group !== undefined) { + close(group) + } + group = { first: index, last: index } + } + if (group !== undefined) { + close(group) + } + return hunks +} + +/** A file's hunks as a unified diff, for a model to apply with its own edit tool. */ +export function unifiedDiff(path: string, hunks: readonly PatchHunk[]): string { + const body = hunks.flatMap((hunk) => [ + `@@ -${String(hunk.oldStart)},${String(hunk.oldLines ?? 0)} +${String(hunk.newStart)},${String(hunk.newLines ?? 0)} @@`, + ...hunk.lines, + ]) + return [`--- a/${path}`, `+++ b/${path}`, ...body].join(LF) +} diff --git a/src/core/codeIntel/definitions.ts b/src/core/codeIntel/definitions.ts new file mode 100644 index 00000000..620408ac --- /dev/null +++ b/src/core/codeIntel/definitions.ts @@ -0,0 +1,143 @@ +// The code intelligence tools as the model sees them (M67, PLAN.md D49): +// one description and one argument schema per tool, shared by the Model +// API's native tools (`find_definition`, …) and the `ide` server's +// (`mcp__ide__findDefinition`, …). Only `rename_symbol` differs: on the +// Model API it writes the files through the edit path; on `ide` it returns +// the edits for Muse Code's own edit tool. + +import * as z from 'zod/mini' +import { CODE_INTEL_TOOLS, type CodeIntelTool } from '../../shared/constants' + +export const CALL_DIRECTIONS = ['incoming', 'outgoing'] as const + +export const locateArgs = z.object({ + path: z.optional(z.string()), + line: z.optional(z.number()), + column: z.optional(z.number()), + symbol: z.optional(z.string()), +}) +export type LocateArgs = z.infer +export const workspaceSymbolsArgs = z.object({ query: z.string() }) +export const documentSymbolsArgs = z.object({ path: z.string() }) +export const callHierarchyArgs = z.extend(locateArgs, { + direction: z.optional(z.enum(CALL_DIRECTIONS)), +}) +export const repoMapArgs = z.object({ max_tokens: z.optional(z.number()) }) +export const renameArgs = z.extend(locateArgs, { new_name: z.string() }) + +const HOW_TO_NAME = + 'Name the symbol by path, line and column (1-based); by path and line plus its name in symbol; by path and symbol (its first use in that file); or by symbol alone (looked up among the workspace symbols).' + +const LOCATE_PROPERTIES = { + path: { type: 'string', description: 'Workspace-relative path of the file' }, + line: { type: 'integer', description: '1-based line' }, + column: { type: 'integer', description: '1-based column: the character in the line' }, + symbol: { + type: 'string', + description: + "The symbol's name: found on line in path, its first use in path, or a workspace symbol when no path is given", + }, +} as const + +export interface CodeIntelDefinition { + readonly tool: CodeIntelTool + readonly description: string + readonly properties: Readonly> + readonly required: readonly string[] +} + +const RENAME_PROPERTIES = { + ...LOCATE_PROPERTIES, + new_name: { type: 'string', description: 'The new name' }, +} as const + +const READ_DEFINITIONS: readonly CodeIntelDefinition[] = [ + { + tool: 'findDefinition', + description: `Find where a symbol is defined, from VS Code's language service (Go to Definition). ${HOW_TO_NAME} Answers workspace-relative path:line:column with the line's text.`, + properties: LOCATE_PROPERTIES, + required: [], + }, + { + tool: 'findReferences', + description: `Find every use of a symbol, its declaration included, from VS Code's language service (Find All References). ${HOW_TO_NAME} Answers workspace-relative path:line:column with each line's text.`, + properties: LOCATE_PROPERTIES, + required: [], + }, + { + tool: 'workspaceSymbols', + description: + "Search the workspace's symbols (classes, functions, methods, variables…) by name, from VS Code's language services (Go to Symbol in Workspace).", + properties: { query: { type: 'string', description: 'The name or part of it' } }, + required: ['query'], + }, + { + tool: 'documentSymbols', + description: + "A file's symbols as an outline (classes, their members, functions…) with line:column, from VS Code's language service.", + properties: { path: LOCATE_PROPERTIES.path }, + required: ['path'], + }, + { + tool: 'hover', + description: `A symbol's type and documentation, as VS Code's hover shows them. ${HOW_TO_NAME}`, + properties: LOCATE_PROPERTIES, + required: [], + }, + { + tool: 'callHierarchy', + description: `Who calls a function or method (incoming, the default) or what it calls (outgoing), from VS Code's language service where the language supports it. ${HOW_TO_NAME}`, + properties: { + ...LOCATE_PROPERTIES, + direction: { type: 'string', enum: [...CALL_DIRECTIONS] }, + }, + required: [], + }, + { + tool: 'repoMap', + description: + 'A compact map of the workspace: the files other files use most, ranked by how often the names they define are used elsewhere, each with its most used definitions, within a token budget. Use it to get your bearings in an unfamiliar codebase.', + properties: { + max_tokens: { + type: 'integer', + description: + 'The budget in tokens for the whole answer, notes included (default 1024, at most 8192)', + }, + }, + required: [], + }, +] + +/** The Model API's tools: `rename_symbol` writes through the edit path and asks like an edit. */ +export const MODEL_API_CODE_INTEL_DEFINITIONS: readonly CodeIntelDefinition[] = [ + ...READ_DEFINITIONS, + { + tool: 'renameSymbol', + description: `Rename a symbol everywhere it is used, through VS Code's language service (Rename Symbol), and write the changed files; it asks like an edit. ${HOW_TO_NAME}`, + properties: RENAME_PROPERTIES, + required: ['new_name'], + }, +] + +/** The `ide` server's tools: `renameSymbol` changes nothing and returns the edits. */ +export const IDE_CODE_INTEL_DEFINITIONS: readonly CodeIntelDefinition[] = [ + ...READ_DEFINITIONS, + { + tool: 'renameSymbol', + description: `Compute the edits that rename a symbol everywhere it is used, through VS Code's language service (Rename Symbol). It changes nothing: it returns a unified diff per file for you to apply with your own edit tool. ${HOW_TO_NAME}`, + properties: RENAME_PROPERTIES, + required: ['new_name'], + }, +] + +const TOOL_BY_NAME: ReadonlyMap = new Map( + MODEL_API_CODE_INTEL_DEFINITIONS.map((definition) => [ + CODE_INTEL_TOOLS[definition.tool], + definition.tool, + ]), +) + +/** Which code intelligence tool a Model API function name is; undefined for any other. */ +export function codeIntelToolOf(name: string): CodeIntelTool | undefined { + return TOOL_BY_NAME.get(name) +} diff --git a/src/core/codeIntel/languageService.ts b/src/core/codeIntel/languageService.ts new file mode 100644 index 00000000..d48a7394 --- /dev/null +++ b/src/core/codeIntel/languageService.ts @@ -0,0 +1,112 @@ +// What the code intelligence tools ask of VS Code's language services (M67, +// PLAN.md D49), as plain data: the host adapter +// (src/host/codeIntel/languageServices.ts) runs VS Code's `vscode.execute…` +// commands and converts their results; the unit tests hand in a fake. Paths +// are absolute file-system paths; a result that is not a file on disk (a +// virtual document) has no path and is counted as outside the workspace. + +/** A 0-based position, counted as VS Code counts: UTF-16 code units in the line. */ +export interface CodePosition { + readonly line: number + readonly character: number +} + +export interface CodeRange { + readonly start: CodePosition + readonly end: CodePosition +} + +export interface CodeLocation { + /** Absolute; undefined for a resource that is no file on disk. */ + readonly path: string | undefined + readonly range: CodeRange +} + +export interface CodeSymbol { + readonly name: string + /** VS Code's `SymbolKind` value. */ + readonly kind: number + readonly detail: string | undefined + /** The symbol it belongs to, as the provider names it (a workspace symbol's). */ + readonly container: string | undefined + /** The whole declaration. */ + readonly location: CodeLocation + /** The name itself where the provider says (a document symbol's), else the location's range. */ + readonly selection: CodeRange + readonly children: readonly CodeSymbol[] +} + +/** A caller (incoming) or a callee (outgoing), and where the calls are. */ +export interface CallSite { + readonly symbol: CodeSymbol + /** + * Incoming: the calls, in the caller's file. Outgoing: the calls, in the + * file of the function asked about. + */ + readonly ranges: readonly CodeRange[] +} + +export interface CallHierarchyAnswer { + /** The function or method the position names: the one declared there, when several are. */ + readonly item: CodeSymbol + readonly calls: readonly CallSite[] + /** The others the position also names (overloads, merged declarations), not asked. */ + readonly otherItems: number +} + +export type CallDirection = 'incoming' | 'outgoing' + +export interface TextEdit { + readonly range: CodeRange + readonly newText: string +} + +export interface FileEdits { + /** Absolute; undefined for a resource that is no file on disk. */ + readonly path: string | undefined + readonly edits: readonly TextEdit[] +} + +/** + * Whether the edit also creates, renames or deletes files, which the tool + * refuses; `unknown` when VS Code does not say, which it refuses too. + */ +export type FileOperations = 'none' | 'present' | 'unknown' + +export interface RenameEdits { + readonly files: readonly FileEdits[] + readonly fileOperations: FileOperations +} + +/** A document as the language services see it (an open editor's text, unsaved changes included). */ +export interface OpenedDocument { + readonly languageId: string + readonly text: string + readonly isDirty: boolean +} + +export interface LanguageServiceHost { + /** Loads the document without showing it; rejects when it cannot be read as text. */ + open(path: string): Promise + definitions(path: string, at: CodePosition): Promise + /** The references, the declaration included (VS Code's `executeReferenceProvider`). */ + references(path: string, at: CodePosition): Promise + /** The hover's parts as Markdown. */ + hover(path: string, at: CodePosition): Promise + documentSymbols(path: string): Promise + workspaceSymbols(query: string): Promise + /** Undefined when nothing at the position has a call hierarchy. */ + callHierarchy( + path: string, + at: CodePosition, + direction: CallDirection, + ): Promise + /** Rejects with the provider's own reason when the position cannot be renamed. */ + rename(path: string, at: CodePosition, newName: string): Promise + /** + * Folders outside the workspace whose declarations a hover may still + * describe: the editor's own installation and its extensions, where the + * languages' bundled libraries live (`lib.dom.d.ts`, typeshed). + */ + libraryRoots(): readonly string[] +} diff --git a/src/core/codeIntel/rename.ts b/src/core/codeIntel/rename.ts new file mode 100644 index 00000000..6d176a71 --- /dev/null +++ b/src/core/codeIntel/rename.ts @@ -0,0 +1,236 @@ +// `rename_symbol`'s plan (M67, PLAN.md D49): VS Code's rename provider asked +// for the edit, which is checked before anything asks or writes. Every file +// it touches must be in the workspace (confined as the file tools confine a +// path, D24), have no unsaved changes, and read on disk exactly as VS Code +// holds it; and every range of the edit must cover exactly the old name in +// that text, so an edit the service computed on an older version of a file +// is refused, never applied to the newer one. An edit that also creates, +// moves or deletes files (or one VS Code does not say that of) is refused. +// The Model API then writes the files through its edit path +// (src/core/backends/modelapi/codeIntelCalls.ts); the `ide` tool returns the +// diff for Muse Code's own edit tool and writes nothing. + +import { CODE_INTEL_NAME_MAX_CHARS, MODEL_TEXT, RENAME_MAX_FILES } from '../../shared/constants' +import { fill } from '../../shared/l10n/text' +import type { PatchHunk } from '../../shared/patchDocument' +import { applyTextEdits, BOM, patchHunks, textIn, unifiedDiff, withoutBom } from './codeText' +import { + ask, + checkName, + compareText, + type CodeIntelDeps, + CodeIntelQuery, + CodeIntelRefusal, + joinLines, + parseArgs, + type PlacedFile, + placeText, + type Target, +} from './codeIntelQuery' +import { renameArgs } from './definitions' +import type { FileEdits, RenameEdits, TextEdit } from './languageService' + +export interface RenameFile extends PlacedFile { + /** The file on disk as the plan read it, BOM included. */ + readonly before: string + /** What the rename writes, BOM kept. */ + readonly after: string + readonly edits: number + readonly hunks: readonly PatchHunk[] +} + +export interface RenamePlan { + readonly from: string + readonly to: string + /** Sorted by path. */ + readonly files: readonly RenameFile[] + readonly edits: number +} + +export type RenamePlanResult = + | { readonly ok: true; readonly plan: RenamePlan } + | { readonly ok: false; readonly reason: string; readonly visibleReason: string } + +function staleRefusal(file: PlacedFile): CodeIntelRefusal { + return new CodeIntelRefusal(fill(MODEL_TEXT.renameStale, { path: file.relative })) +} + +/** + * The file's text with the edits applied, or why the plan cannot use it. + * `expected` is the text the rename's position was read from, for the file + * that holds it: the document must still be that text. + */ +async function plannedFile( + query: CodeIntelQuery, + planned: { readonly file: PlacedFile; readonly edits: readonly TextEdit[] }, + from: string, + expected: string | undefined, +): Promise { + const { file, edits } = planned + // By the real path: an editor may hold the file under a link's path. + if ((await query.unsavedPath(file)) !== undefined) { + throw new CodeIntelRefusal(`${file.relative} ${MODEL_TEXT.fileHasUnsavedChanges}`) + } + const before = await query.deps.io.readFile(file.checkedAbsolute, file.checkedAbsolute) + const document = await ask(query.service.open(file.absolute)) + const { text } = document + if ( + before === undefined || + document.isDirty || + text !== withoutBom(before) || + (expected !== undefined && text !== expected) || + // An edit made on another version of the file lands on other text. + edits.some((edit) => textIn(text, edit.range.start, edit.range.end) !== from) + ) { + throw staleRefusal(file) + } + const changed = applyTextEdits(text, edits) + if (changed === undefined) { + throw staleRefusal(file) + } + return { + ...file, + before, + after: before.startsWith(BOM) ? `${BOM}${changed}` : changed, + edits: edits.length, + hunks: patchHunks(text, changed), + } +} + +/** The edit's files placed in the workspace; any outside refuses the whole rename. */ +async function placedFiles( + query: CodeIntelQuery, + files: readonly FileEdits[], +): Promise { + const placed = await Promise.all( + files + .filter((entry) => entry.edits.length > 0) + .map(async (entry) => ({ file: await query.place(entry.path), edits: entry.edits })), + ) + const outside = placed.filter((entry) => entry.file === undefined).length + if (outside > 0) { + throw new CodeIntelRefusal(fill(MODEL_TEXT.renameOutside, { count: String(outside) })) + } + // One entry per file, however the edit groups its changes. + const byFile = new Map() + const merged: { file: PlacedFile; edits: TextEdit[] }[] = [] + for (const { file, edits } of placed) { + if (file === undefined) { + continue + } + let entry = byFile.get(file.checkedAbsolute) + if (entry === undefined) { + entry = { file, edits: [] } + byFile.set(file.checkedAbsolute, entry) + merged.push(entry) + } + entry.edits.push(...edits) + } + if (merged.length > RENAME_MAX_FILES) { + throw new CodeIntelRefusal( + fill(MODEL_TEXT.renameTooMany, { + count: String(merged.length), + max: String(RENAME_MAX_FILES), + }), + ) + } + return merged +} + +/** Refuses an edit that also changes files, or one VS Code does not say that of. */ +function checkFileOperations(edit: RenameEdits): void { + if (edit.fileOperations === 'present') { + throw new CodeIntelRefusal(MODEL_TEXT.renameFileOperations) + } + if (edit.fileOperations === 'unknown') { + throw new CodeIntelRefusal(MODEL_TEXT.renameFileOperationsUnknown) + } +} + +/** + * The old name: what the edit at the position replaces, in the text the + * position was read from. Every other range of the edit must cover it too. + */ +function oldName( + target: Target, + placed: readonly { file: PlacedFile; edits: readonly TextEdit[] }[], +): string { + const own = placed + .find((entry) => entry.file.checkedAbsolute === target.file.checkedAbsolute) + ?.edits.find( + (candidate) => + candidate.range.start.line === target.at.line && + candidate.range.start.character <= target.at.character && + candidate.range.end.character >= target.at.character, + ) + const from = + own === undefined ? undefined : textIn(target.document.text, own.range.start, own.range.end) + if (from === undefined || from === '') { + throw staleRefusal(target.file) + } + return from +} + +async function plan(query: CodeIntelQuery, raw: unknown): Promise { + const args = parseArgs(renameArgs, raw) + const to = checkName('new_name', args.new_name) + const target = await query.target(args) + const place = placeText(target.file.relative, target.at) + const edit = await ask(query.service.rename(target.file.absolute, target.at, to)) + checkFileOperations(edit) + if (edit.files.every((entry) => entry.edits.length === 0)) { + const symbols = await ask(query.service.documentSymbols(target.file.absolute)) + throw symbols.length === 0 + ? query.noService(target.file, target.document) + : new CodeIntelRefusal(fill(MODEL_TEXT.renameNothing, { place })) + } + const placed = await placedFiles(query, edit.files) + const from = oldName(target, placed) + if (from === to) { + throw new CodeIntelRefusal(fill(MODEL_TEXT.renameSameName, { name: to })) + } + const files: RenameFile[] = [] + for (const entry of placed) { + const isTargetFile = entry.file.checkedAbsolute === target.file.checkedAbsolute + files.push( + await plannedFile(query, entry, from, isTargetFile ? target.document.text : undefined), + ) + } + const changed = files + .filter((file) => file.after !== file.before) + .toSorted((a, b) => compareText(a.relative, b.relative)) + if (changed.length === 0) { + throw new CodeIntelRefusal(fill(MODEL_TEXT.renameNothing, { place })) + } + return { + from: from.slice(0, CODE_INTEL_NAME_MAX_CHARS), + to, + files: changed, + edits: changed.reduce((total, file) => total + file.edits, 0), + } +} + +/** The rename's edit, checked file by file; nothing is written here. */ +export async function planRename(raw: unknown, deps: CodeIntelDeps): Promise { + try { + return { ok: true, plan: await plan(new CodeIntelQuery(deps), raw) } + } catch (error: unknown) { + if (error instanceof CodeIntelRefusal) { + return { ok: false, reason: error.message, visibleReason: error.visibleReason } + } + throw error + } +} + +/** The plan as the `ide` tool hands it to Muse Code: a lead and a unified diff per file. */ +export function renameDiff(plan: RenamePlan): string { + return joinLines([ + fill(MODEL_TEXT.renameEditsLead, { + from: plan.from, + to: plan.to, + edits: String(plan.edits), + files: String(plan.files.length), + }), + ...plan.files.map((file) => unifiedDiff(file.relative, file.hunks)), + ]) +} diff --git a/src/core/codeIntel/repoMap.ts b/src/core/codeIntel/repoMap.ts new file mode 100644 index 00000000..3f0a17af --- /dev/null +++ b/src/core/codeIntel/repoMap.ts @@ -0,0 +1,429 @@ +// The repo map (M67, PLAN.md D49; Aider's idea over VS Code's services): +// the workspace's files ranked by how often other files use the names they +// define, each with its most used definitions, within a token budget. The +// names are counted in the files' text (read confined, like the search +// tool's); where each widely used name is defined comes from the workspace +// symbols of VS Code's language services. A name defined in several files +// shares its weight among them. The lookups stop at the time budget or a +// Stop, and the map then says it is partial. + +import { + MODEL_TEXT, + REPO_MAP_CHARS_PER_TOKEN, + REPO_MAP_CONCURRENCY, + REPO_MAP_MAX_FILE_CHARS, + REPO_MAP_MAX_FILES, + REPO_MAP_MAX_LOOKUPS, + REPO_MAP_MIN_NAME_CHARS, + REPO_MAP_PROMPT_TIME_BUDGET_MS, + REPO_MAP_PROMPT_TOKENS, + REPO_MAP_SYMBOLS_PER_FILE, + UI_TEXT, +} from '../../shared/constants' +import { fill } from '../../shared/l10n/text' +import { withDeadline } from '../timeouts' +import { + ask, + bareName, + type CodeIntelDeps, + CodeIntelQuery, + CodeIntelRefusal, + compareText, + joinLines, + kindName, +} from './codeIntelQuery' +import type { CodePosition, CodeSymbol } from './languageService' + +export interface RepoMapOptions { + readonly maxTokens: number + readonly timeBudgetMs: number + readonly signal?: AbortSignal | undefined +} + +// A name as most languages spell one: a letter, `_` or `$`, then those or digits. +const NAME = /[\p{L}_$][\p{L}\p{N}_$]*/gu +const INDENT = ' ' +// Between the prompt section's heading, its lead and the map. +const SECTION_BREAK = '\n\n' +const BUDGET_SPENT = 'the repo map time budget is spent' +// What `Limits.within` settles with when the turn is stopped. +const STOPPED = Symbol('stopped') + +interface Definition { + readonly name: string + readonly kind: number + readonly at: CodePosition + weight: number +} + +interface RankedFile { + readonly relative: string + score: number + readonly definitions: Definition[] +} + +/** Each name of `min` characters or more in the text, with how often it occurs. */ +/** A file left out of the counts: unreadable, not text, or too long. */ +const NO_NAMES: ReadonlyMap = new Map() + +function countNames(text: string): ReadonlyMap { + const counts = new Map() + for (const [name] of text.matchAll(NAME)) { + if (name.length >= REPO_MAP_MIN_NAME_CHARS) { + counts.set(name, (counts.get(name) ?? 0) + 1) + } + } + return counts +} + +/** + * The time budget and the Stop that end the map's work early. It listens + * for the Stop once, and `close` takes the listener off again, so a turn + * that asks for many maps collects none. + */ +class Limits { + private readonly stopped: Promise + /** Takes the Stop listener off when the map is done. */ + private readonly listening = new AbortController() + + public constructor( + private readonly now: () => number, + private readonly deadline: number, + private readonly signal: AbortSignal | undefined, + ) { + // Not `Promise.withResolvers`, which Node 20 (VS Code 1.99's host) lacks. + this.stopped = new Promise((resolve) => { + signal?.addEventListener( + 'abort', + () => { + resolve(STOPPED) + }, + { once: true, signal: this.listening.signal }, + ) + }) + } + + public isOver(): boolean { + return this.signal?.aborted === true || this.now() >= this.deadline + } + + /** + * The value of the work `start` begins, or `undefined` once the time is up + * or the turn is stopped: when that is so already the work is not begun, + * and otherwise it is left to finish on its own. + */ + public async within(start: () => Promise): Promise { + if (this.isOver()) { + return undefined + } + const remainingMs = Math.max(this.deadline - this.now(), 1) + const boxed = async () => ({ value: await start() }) + try { + const settled = await Promise.race([ + withDeadline(boxed(), remainingMs, BUDGET_SPENT), + this.stopped, + ]) + return settled === STOPPED ? undefined : settled.value + } catch (error: unknown) { + if (error instanceof Error && error.name === 'DeadlineError') { + return undefined + } + throw error + } + } + + public close(): void { + this.listening.abort() + } +} + +/** + * Runs `work` over `items`, a few at a time, until the limits say stop; + * returns the results of the batches that finished, in order. A batch the + * limits cut off is left to run, and its results are dropped: nothing it + * finds later reaches the map or its counts. + */ +async function inBatches( + items: readonly T[], + limits: Limits, + work: (item: T) => Promise, +): Promise { + const results: R[] = [] + while (results.length < items.length) { + const batch = items.slice(results.length, results.length + REPO_MAP_CONCURRENCY) + const finished = await limits.within( + async () => await Promise.all(batch.map(async (item) => await work(item))), + ) + if (finished === undefined) { + break + } + for (const result of finished) { + results.push(result) + } + } + return results +} + +/** Name → file → how often the file uses it, over the files that could be read. */ +async function readUses( + query: CodeIntelQuery, + files: readonly string[], + limits: Limits, +): Promise<{ + readonly uses: ReadonlyMap> + readonly read: number +}> { + const counted = await inBatches(files, limits, async (relative) => { + let text: string | undefined + try { + const file = await query.confine(relative) + text = await query.deps.io.readFile(file.checkedAbsolute, file.checkedAbsolute) + } catch { + // A file that is not confined UTF-8 text is left out of the counts, as + // the search tool leaves it out. + return { relative, names: NO_NAMES } + } + return { + relative, + names: + text === undefined || text.length > REPO_MAP_MAX_FILE_CHARS ? NO_NAMES : countNames(text), + } + }) + const uses = new Map>() + for (const { relative, names } of counted) { + for (const [name, count] of names) { + const perFile = uses.get(name) ?? new Map() + perFile.set(relative, count) + uses.set(name, perFile) + } + } + return { uses, read: counted.length } +} + +/** The names used by two files or more, the most widely used first. */ +function candidates(uses: ReadonlyMap>): readonly string[] { + return [...uses] + .filter(([, perFile]) => perFile.size > 1) + .toSorted(([a, left], [b, right]) => right.size - left.size || compareText(a, b)) + .slice(0, REPO_MAP_MAX_LOOKUPS) + .map(([name]) => name) +} + +function rank( + definitions: ReadonlyMap, + uses: ReadonlyMap>, +): readonly RankedFile[] { + const files = new Map() + const ranked: RankedFile[] = [] + for (const [name, found] of definitions) { + const definers = new Set(found.map((entry) => entry.relative)) + const users = uses.get(name) ?? new Map() + let usedElsewhere = 0 + for (const [relative, count] of users) { + if (!definers.has(relative)) { + usedElsewhere += count + } + } + if (usedElsewhere === 0) { + continue + } + const weight = usedElsewhere / definers.size + const credited = new Set() + for (const { relative, symbol } of found) { + // Overloads and redeclarations in one file count once. + if (credited.has(relative)) { + continue + } + credited.add(relative) + let file = files.get(relative) + if (file === undefined) { + file = { relative, score: 0, definitions: [] } + files.set(relative, file) + ranked.push(file) + } + file.score += weight + file.definitions.push({ name, kind: symbol.kind, at: symbol.selection.start, weight }) + } + } + return ranked.toSorted((a, b) => b.score - a.score || compareText(a.relative, b.relative)) +} + +function fileBlock(file: RankedFile): string { + const shown = file.definitions + .toSorted((a, b) => b.weight - a.weight || a.at.line - b.at.line || compareText(a.name, b.name)) + .slice(0, REPO_MAP_SYMBOLS_PER_FILE) + .toSorted((a, b) => a.at.line - b.at.line || a.at.character - b.at.character) + .map( + (definition) => + `${INDENT}${String(definition.at.line + 1)}: ${kindName(definition.kind)} ${definition.name}`, + ) + return [file.relative, ...shown].join('\n') +} + +/** The length of lines joined one per line (`joinLines`). */ +function joinedLength(lines: readonly string[]): number { + return lines.reduce((total, line) => total + line.length, Math.max(lines.length - 1, 0)) +} + +/** + * The map as lines within `budget` characters, every line counted: the + * lead, as many ranked files as fit, the line counting the rest, and the + * notes. When the lead, that line and the notes alone do not fit, those + * lines, and `fits` false. + */ +function renderLines( + ranked: readonly RankedFile[], + budget: number, + notes: readonly string[], +): { readonly lines: readonly string[]; readonly fits: boolean } { + const blocks: string[] = [] + const compose = (): readonly string[] => { + const hidden = ranked.length - blocks.length + return [ + MODEL_TEXT.repoMapLead, + ...blocks, + ...(hidden > 0 ? [fill(MODEL_TEXT.codeIntelMore, { count: String(hidden) })] : []), + ...notes, + ] + } + if (joinedLength(compose()) > budget) { + return { lines: compose(), fits: false } + } + for (const file of ranked) { + blocks.push(fileBlock(file)) + if (joinedLength(compose()) > budget) { + blocks.pop() + break + } + } + return { lines: compose(), fits: true } +} + +/** The refusal for a budget too small for the answer's own fixed text. */ +function tooSmall(maxTokens: number, lines: readonly string[]): CodeIntelRefusal { + return new CodeIntelRefusal( + fill(MODEL_TEXT.repoMapBudgetTooSmall, { + tokens: String(maxTokens), + needed: String(Math.ceil(joinedLength(lines) / REPO_MAP_CHARS_PER_TOKEN)), + }), + ) +} + +interface BuiltMap { + readonly ranked: readonly RankedFile[] + /** What the map could not cover: lookups cut short, files past the cap. */ + readonly notes: readonly string[] +} + +/** + * The ranked files, or the refusal when no language service answers + * workspace symbols (the map would be empty for want of one, not for want + * of code). + */ +async function buildMap(query: CodeIntelQuery, options: RepoMapOptions): Promise { + const { now } = query.deps + const limits = new Limits(now, now() + options.timeBudgetMs, options.signal) + try { + return await buildWithin(query, limits) + } finally { + limits.close() + } +} + +async function buildWithin(query: CodeIntelQuery, limits: Limits): Promise { + const found = await limits.within(async () => await query.deps.io.listFiles()) + if (found === undefined) { + return { ranked: [], notes: [MODEL_TEXT.repoMapNoFiles] } + } + const listed = found.toSorted((a, b) => compareText(a, b)) + const files = listed.slice(0, REPO_MAP_MAX_FILES) + const { uses, read } = await readUses(query, files, limits) + const names = candidates(uses) + const lookups = await inBatches(names, limits, async (name) => { + const found = await ask(query.service.workspaceSymbols(name)) + const exact = await Promise.all( + found + .filter((symbol) => bareName(symbol) === name) + .map(async (symbol) => ({ symbol, file: await query.place(symbol.location.path) })), + ) + const inside = exact.flatMap(({ symbol, file }) => + file === undefined ? [] : [{ relative: file.relative, symbol }], + ) + return { name, answered: found.length, inside } + }) + const definitions = new Map() + for (const { name, inside } of lookups) { + if (inside.length > 0) { + definitions.set(name, inside) + } + } + const looked = lookups.length + // No service only when every lookup ran and none found anything: a map + // cut short by the time or a Stop is a partial map, not a missing service. + if (looked > 0 && looked === names.length && lookups.every((lookup) => lookup.answered === 0)) { + throw new CodeIntelRefusal(MODEL_TEXT.repoMapNoService, UI_TEXT.repoMapNoService) + } + const notes = [ + ...(read < files.length + ? [fill(MODEL_TEXT.repoMapFilesRead, { done: String(read), total: String(files.length) })] + : []), + ...(looked < names.length + ? [fill(MODEL_TEXT.repoMapPartial, { done: String(looked), total: String(names.length) })] + : []), + ...(files.length < listed.length + ? [ + fill(MODEL_TEXT.repoMapFilesCapped, { + count: String(files.length), + total: String(listed.length), + }), + ] + : []), + ] + return { ranked: rank(definitions, uses), notes } +} + +/** + * The `repo_map` tool's answer within `maxTokens`, all of its text counted; + * the refusal names the budget needed when its fixed text does not fit. + */ +export async function repoMap(query: CodeIntelQuery, options: RepoMapOptions): Promise { + const { ranked, notes } = await buildMap(query, options) + const budget = options.maxTokens * REPO_MAP_CHARS_PER_TOKEN + if (ranked.length === 0) { + const empty = [MODEL_TEXT.repoMapEmpty, ...notes] + if (joinedLength(empty) > budget) { + throw tooSmall(options.maxTokens, empty) + } + return joinLines(empty) + } + const { lines, fits } = renderLines(ranked, budget, notes) + if (!fits) { + throw tooSmall(options.maxTokens, lines) + } + return joinLines(lines) +} + +/** + * The system prompt's section (opt in, M67): the map within its own budget + * and time, its heading counted, undefined when no file ranks (or, never + * with the fixed budget, when its fixed text would not fit). Rejects with + * the refusal when no language service answers. + */ +export async function repoMapSection( + deps: CodeIntelDeps, + signal: AbortSignal, +): Promise { + const options = { + maxTokens: REPO_MAP_PROMPT_TOKENS, + timeBudgetMs: REPO_MAP_PROMPT_TIME_BUDGET_MS, + signal, + } + const { ranked, notes } = await buildMap(new CodeIntelQuery(deps), options) + const heading = `${MODEL_TEXT.repoMapSection}${SECTION_BREAK}${MODEL_TEXT.repoMapSectionLead}${SECTION_BREAK}` + const { lines, fits } = renderLines( + ranked, + options.maxTokens * REPO_MAP_CHARS_PER_TOKEN - heading.length, + notes, + ) + return !fits || ranked.length === 0 ? undefined : `${heading}${joinLines(lines)}` +} diff --git a/src/core/diagnostics.ts b/src/core/diagnostics.ts index d797c8b7..e9d63219 100644 --- a/src/core/diagnostics.ts +++ b/src/core/diagnostics.ts @@ -13,6 +13,7 @@ import { DIAGNOSTIC_MESSAGE_MAX_CHARS, DIAGNOSTICS_MAX_ENTRIES, IDE_MCP_TOOL_DIAGNOSTICS, + MCP_ANNOTATIONS_READ_ONLY, } from '../shared/constants' import type { McpTool } from './mcp' import { resolveAgainstRoot } from './workspaceRoot' @@ -148,6 +149,8 @@ export function diagnosticsTool(deps: DiagnosticsToolDeps): McpTool { }, }, }, + // It only reads the Problems panel (D49's rule for tools on `ide`, M67). + annotations: MCP_ANNOTATIONS_READ_ONLY, // A request that names no workspace file rejects, so the server answers // with an error result the model can read. call: (args) => { diff --git a/src/core/mcp.ts b/src/core/mcp.ts index 9bf732dd..525a87c8 100644 --- a/src/core/mcp.ts +++ b/src/core/mcp.ts @@ -14,6 +14,8 @@ export interface McpTool { readonly description: string /** JSON Schema for the arguments. */ readonly inputSchema: Readonly> + /** MCP's behaviour hints (`readOnlyHint`, `openWorldHint`, …), listed when present (M69). */ + readonly annotations?: Readonly> /** The tool's text result; throw to report a tool error. */ readonly call: (args: Readonly>) => Promise } @@ -108,10 +110,11 @@ export async function handleMcpMessage( } case 'tools/list': { return resultResponse(message.id, { - tools: tools.map(({ name, description, inputSchema }) => ({ + tools: tools.map(({ name, description, inputSchema, annotations }) => ({ name, description, inputSchema, + ...(annotations !== undefined && { annotations }), })), }) } diff --git a/src/core/workspacePath.ts b/src/core/workspacePath.ts index 0831c272..b8c9b666 100644 --- a/src/core/workspacePath.ts +++ b/src/core/workspacePath.ts @@ -43,7 +43,7 @@ const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|conin\$|conout\$|com\d|lpt\d) const WINDOWS_TRAILING_DOT_OR_SPACE = /[. ]$/ /** Whether a `path.relative` result stays below its base. */ -function isBelow(relative: string, p: ReturnType): boolean { +export function isBelow(relative: string, p: ReturnType): boolean { return ( relative !== '' && relative !== PARENT_SEGMENT && diff --git a/src/extension.ts b/src/extension.ts index 388c4809..e191ccdb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -14,7 +14,6 @@ import { readBackendChoice } from './core/backendSelection' import { personalSkillsRoot } from './core/context/skills' import { memoryDataRoot } from './core/memory/memoryLocation' import { MemoryStore } from './core/memory/memoryStore' -import { isSamePath } from './core/paths' import { terminalArgument } from './core/shellQuote' import { renderSupportReport } from './core/support/report' import { type CliInvocation, isSandboxNetworkApplied } from './core/backends/musecode/sandbox' @@ -77,6 +76,8 @@ import { canonicalPath } from './host/canonicalPath' import { loadToolImage } from './core/toolImages' import { ModelApiClient } from './core/backends/modelapi/client' import { ideImageTools } from './host/ide/imageTools' +import { ideCodeIntelTools } from './host/ide/codeIntelTools' +import { vscodeLanguageServices } from './host/codeIntel/languageServices' import { usablePaidFeatures } from './shared/paid' import { createCliFeatures } from './host/cliFeatures' import { createWorktreeFeatures } from './host/worktreeFeatures' @@ -833,14 +834,11 @@ export async function activate(context: vscode.ExtensionContext): Promise // Each Windows command in a job object of its own, so a Stop ends // everything it started (PLAN.md M27). shellJobAssembly: windowsJobAssembly, - // An open editor with unsaved changes to the file (PLAN.md D27). - hasUnsavedChanges: (absolutePath) => - vscode.workspace.textDocuments.some( - (document) => - document.isDirty && - document.uri.scheme === FILE_SCHEME && - isSamePath(document.uri.fsPath, absolutePath, process.platform), - ), + // The open editors with unsaved changes to a file (PLAN.md D27). + unsavedFiles: () => + vscode.workspace.textDocuments + .filter((document) => document.isDirty && document.uri.scheme === FILE_SCHEME) + .map((document) => document.uri.fsPath), }) const diagnostics = diagnosticsTool({ getDiagnostics: collectDiagnostics, @@ -863,9 +861,24 @@ export async function activate(context: vscode.ExtensionContext): Promise log, }) const ideTools = [diagnostics] + // Code intelligence over VS Code's language services (M67, PLAN.md D49): + // native tools on the Model API backend, `ide` tools for Muse Code. Only + // with a folder open, since every path is the workspace's. + const languageServices = vscodeLanguageServices() + const codeIntel = + workspaceRoot === undefined + ? undefined + : { + service: languageServices, + workspaceRoot, + platform: process.platform, + io: toolIo, + now: () => Date.now(), + } const ideServer = new IdeMcpServer( () => [ diagnostics, + ...ideCodeIntelTools(codeIntel), ...ideImageTools({ isOffered: () => isKeyStored && paid.gate.isOn('imageGeneration'), keyGeneration: () => auth.admissionGeneration, @@ -1071,6 +1084,8 @@ export async function activate(context: vscode.ExtensionContext): Promise }), ), ideTools, + codeIntel: languageServices, + isRepoMapInPrompt: () => currentSettings().modelApiRepoMap, allowsPaidUse: async (request, requiresAsking) => await paid.consent.allows(request, requiresAsking), isPaidUseRemembered: (feature) => paid.consent.isRemembered(feature), diff --git a/src/host/backend/modelApiBackendManager.ts b/src/host/backend/modelApiBackendManager.ts index 9c6fbf5a..a6021ff9 100644 --- a/src/host/backend/modelApiBackendManager.ts +++ b/src/host/backend/modelApiBackendManager.ts @@ -20,6 +20,7 @@ import type { SessionStore } from '../../core/backends/modelapi/sessionStore' import type { ScheduleStore } from '../../shared/schedule' import type { ToolIo } from '../../core/backends/modelapi/tools' import type { ContextIo } from '../../core/context/contextFiles' +import type { LanguageServiceHost } from '../../core/codeIntel/languageService' import type { McpTool } from '../../core/mcp' import type { MemoryStore } from '../../core/memory/memoryStore' import { MODEL_API_BASE_URL, type PromptCacheRetention, UI_TEXT } from '../../shared/constants' @@ -60,6 +61,10 @@ export interface ModelApiBackendManagerDeps extends ModelApiPaidHooks { | undefined /** The extension's own IDE tools, offered in process (M50). */ readonly ideTools?: readonly McpTool[] | undefined + /** VS Code's language services, for the code intelligence tools (M67). */ + readonly codeIntel?: LanguageServiceHost | undefined + /** `museSpark.modelApiRepoMap`, read per turn (M67). */ + readonly isRepoMapInPrompt?: (() => boolean) | undefined /** Muse Code's memory, shared with the Memory view (M49, PLAN.md D41). */ readonly memory: MemoryStore | undefined /** The Model API bundle, dist/modelApi.js beside the running bundle (M57, PLAN.md D6). */ @@ -192,6 +197,8 @@ export class ModelApiBackendManager { notePaidUse: this.deps.notePaidUse, promptCacheRetention: this.deps.promptCacheRetention, ideTools: this.deps.ideTools, + codeIntel: this.deps.codeIntel, + isRepoMapInPrompt: this.deps.isRepoMapInPrompt, allowsPaidUse: this.deps.allowsPaidUse, isPaidUseRemembered: this.deps.isPaidUseRemembered, noteSubagentUsage: this.deps.noteSubagentUsage, diff --git a/src/host/backend/toolIo.ts b/src/host/backend/toolIo.ts index 1c73fb4c..90c161b5 100644 --- a/src/host/backend/toolIo.ts +++ b/src/host/backend/toolIo.ts @@ -66,8 +66,11 @@ export interface ToolIoDeps { readonly searchWorkerPath: string /** Where a failed tree kill is reported. */ readonly log: (message: string) => void - /** Whether an editor holds unsaved changes to the file (VS Code's documents, D27). */ - readonly hasUnsavedChanges: (absolutePath: string) => boolean + /** + * The files open in an editor with unsaved changes, by the paths VS + * Code's documents give (D27). + */ + readonly unsavedFiles: () => readonly string[] /** Windows: the job helper's assembly, undefined where jobs are unavailable (M27). */ readonly shellJobAssembly?: (() => Promise) | undefined } @@ -611,7 +614,9 @@ export function createToolIo(deps: ToolIoDeps): ToolIo { release, } }, - hasUnsavedChanges: deps.hasUnsavedChanges, + hasUnsavedChanges: (absolutePath) => + deps.unsavedFiles().some((open) => isSamePath(open, absolutePath, deps.platform)), + unsavedFiles: deps.unsavedFiles, listFiles: deps.listFiles, searchFiles: (job) => searchOnWorker(deps.searchWorkerPath, job, SEARCH_TIMEOUT_MS), realPath: canonicalPath, diff --git a/src/host/codeIntel/languageServices.ts b/src/host/codeIntel/languageServices.ts new file mode 100644 index 00000000..a2116d5d --- /dev/null +++ b/src/host/codeIntel/languageServices.ts @@ -0,0 +1,279 @@ +// VS Code's language services for the code intelligence tools (M67, +// PLAN.md D49): the `vscode.execute…` commands the editor's own Go to +// Definition, Find All References, outline, hover, call hierarchy and +// Rename Symbol use, their results converted to the plain data +// src/core/codeIntel works with. A document is loaded without being shown; +// a result that is no file on disk (a virtual document) has no path. + +import * as vscode from 'vscode' +import * as z from 'zod/mini' +import { VSCODE_COMMANDS } from '../../shared/constants' +import type { + CallDirection, + CallHierarchyAnswer, + CallSite, + CodeLocation, + CodePosition, + CodeRange, + CodeSymbol, + FileOperations, + LanguageServiceHost, + RenameEdits, +} from '../../core/codeIntel/languageService' + +const FILE_SCHEME = 'file' +const FENCE = '```' + +function pathOf(uri: vscode.Uri): string | undefined { + return uri.scheme === FILE_SCHEME ? uri.fsPath : undefined +} + +function toPosition(at: CodePosition): vscode.Position { + return new vscode.Position(at.line, at.character) +} + +function fromRange(range: vscode.Range): CodeRange { + return { + start: { line: range.start.line, character: range.start.character }, + end: { line: range.end.line, character: range.end.character }, + } +} + +/** A `Location` or a `LocationLink` (definition providers return either). */ +function fromLocation(location: vscode.Location | vscode.LocationLink): CodeLocation { + return 'targetUri' in location + ? { + path: pathOf(location.targetUri), + range: fromRange(location.targetSelectionRange ?? location.targetRange), + } + : { path: pathOf(location.uri), range: fromRange(location.range) } +} + +function fromDocumentSymbol(symbol: vscode.DocumentSymbol, path: string): CodeSymbol { + return { + name: symbol.name, + kind: symbol.kind, + detail: symbol.detail, + container: undefined, + location: { path, range: fromRange(symbol.range) }, + selection: fromRange(symbol.selectionRange), + children: symbol.children.map((child) => fromDocumentSymbol(child, path)), + } +} + +function fromSymbolInformation(symbol: vscode.SymbolInformation): CodeSymbol { + const location = fromLocation(symbol.location) + return { + name: symbol.name, + kind: symbol.kind, + detail: undefined, + container: symbol.containerName, + location, + selection: location.range, + children: [], + } +} + +function fromCallItem(item: vscode.CallHierarchyItem): CodeSymbol { + return { + name: item.name, + kind: item.kind, + detail: item.detail, + container: undefined, + location: { path: pathOf(item.uri), range: fromRange(item.range) }, + selection: fromRange(item.selectionRange), + children: [], + } +} + +// A hover part: Markdown text, or code in a language (the older marked-string form). +const markedCodeSchema = z.object({ language: z.string(), value: z.string() }) +const markdownSchema = z.object({ value: z.string() }) + +/** + * A hover part as Markdown, code fenced. A shape VS Code does not document + * is shown as it came, never dropped (AGENTS rule 13). + */ +function hoverText(part: unknown): string { + if (typeof part === 'string') { + return part + } + const code = markedCodeSchema.safeParse(part) + if (code.success) { + return `${FENCE}${code.data.language}\n${code.data.value}\n${FENCE}` + } + const markdown = markdownSchema.safeParse(part) + return markdown.success ? markdown.data.value : JSON.stringify(part) +} + +async function run(command: string, ...args: unknown[]): Promise { + return await vscode.commands.executeCommand(command, ...args) +} + +/** Whether the range holds the position, its ends included. */ +function isWithin(range: CodeRange, at: CodePosition): boolean { + const { start, end } = range + return ( + (at.line > start.line || (at.line === start.line && at.character >= start.character)) && + (at.line < end.line || (at.line === end.line && at.character <= end.character)) + ) +} + +async function callHierarchy( + uri: vscode.Uri, + at: CodePosition, + direction: CallDirection, +): Promise { + const items = + (await run( + VSCODE_COMMANDS.prepareCallHierarchy, + uri, + toPosition(at), + )) ?? [] + // Several items (overloads, merged declarations): the one declared at the + // position is asked, else the first; the answer counts the others. + const item = + items.find( + (candidate) => + pathOf(candidate.uri) === pathOf(uri) && isWithin(fromRange(candidate.selectionRange), at), + ) ?? items[0] + if (item === undefined) { + return undefined + } + let calls: readonly CallSite[] + if (direction === 'incoming') { + const incoming = await run( + VSCODE_COMMANDS.provideIncomingCalls, + item, + ) + calls = (incoming ?? []).map((call) => ({ + symbol: fromCallItem(call.from), + ranges: call.fromRanges.map((range) => fromRange(range)), + })) + } else { + const outgoing = await run( + VSCODE_COMMANDS.provideOutgoingCalls, + item, + ) + calls = (outgoing ?? []).map((call) => ({ + symbol: fromCallItem(call.to), + ranges: call.fromRanges.map((range) => fromRange(range)), + })) + } + return { item: fromCallItem(item), calls, otherItems: items.length - 1 } +} + +async function rename(uri: vscode.Uri, at: CodePosition, newName: string): Promise { + const position = toPosition(at) + // Rejects with the provider's reason ("You cannot rename this element."). + await run(VSCODE_COMMANDS.prepareRename, uri, position) + const edit = await run( + VSCODE_COMMANDS.executeDocumentRenameProvider, + uri, + position, + newName, + ) + if (edit === undefined) { + return { files: [], fileOperations: 'none' } + } + return { + files: edit.entries().map(([target, edits]) => ({ + path: pathOf(target), + edits: edits.map((textEdit) => ({ + range: fromRange(textEdit.range), + newText: textEdit.newText, + })), + })), + fileOperations: fileOperationsOf(edit), + } +} + +// What `WorkspaceEdit` holds beyond its text edits. Its API shows only +// those: `entries()` lists text edits and `size` counts them (read from +// VS Code 1.125.0's and 1.139.0's extensionHostProcess.js), so a rename that +// also creates, moves or deletes files looks like a plain one. Only the +// internal `_allEntries()` lists everything, each entry with a `_type` (1 a +// file operation, 2 a text edit, others cells and snippets). It is read as +// untyped data: when it is missing or its shape changes, the answer is +// `unknown`, and the rename is refused rather than half applied. +const ALL_ENTRIES = '_allEntries' +const TEXT_EDIT_TYPE = 2 +const allEntriesSchema = z.array(z.object({ _type: z.number() })) + +function fileOperationsOf(edit: vscode.WorkspaceEdit): FileOperations { + const allEntries: unknown = Reflect.get(edit, ALL_ENTRIES) + if (typeof allEntries !== 'function') { + return 'unknown' + } + const parsed = allEntriesSchema.safeParse(Reflect.apply(allEntries, edit, [])) + if (!parsed.success) { + return 'unknown' + } + return parsed.data.every((entry) => entry._type === TEXT_EDIT_TYPE) ? 'none' : 'present' +} + +/** The language services behind the code intelligence tools. */ +export function vscodeLanguageServices(): LanguageServiceHost { + return { + open: async (path) => { + const document = await vscode.workspace.openTextDocument(vscode.Uri.file(path)) + return { + languageId: document.languageId, + text: document.getText(), + isDirty: document.isDirty, + } + }, + definitions: async (path, at) => { + const found = await run<(vscode.Location | vscode.LocationLink)[]>( + VSCODE_COMMANDS.executeDefinitionProvider, + vscode.Uri.file(path), + toPosition(at), + ) + return (found ?? []).map((location) => fromLocation(location)) + }, + references: async (path, at) => { + const found = await run( + VSCODE_COMMANDS.executeReferenceProvider, + vscode.Uri.file(path), + toPosition(at), + ) + return (found ?? []).map((location) => fromLocation(location)) + }, + hover: async (path, at) => { + const hovers = await run( + VSCODE_COMMANDS.executeHoverProvider, + vscode.Uri.file(path), + toPosition(at), + ) + return (hovers ?? []).flatMap((hover) => hover.contents.map((part) => hoverText(part))) + }, + documentSymbols: async (path) => { + const found = await run<(vscode.DocumentSymbol | vscode.SymbolInformation)[]>( + VSCODE_COMMANDS.executeDocumentSymbolProvider, + vscode.Uri.file(path), + ) + return (found ?? []).map((symbol) => + 'selectionRange' in symbol + ? fromDocumentSymbol(symbol, path) + : fromSymbolInformation(symbol), + ) + }, + workspaceSymbols: async (query) => { + const found = await run( + VSCODE_COMMANDS.executeWorkspaceSymbolProvider, + query, + ) + return (found ?? []).map((symbol) => fromSymbolInformation(symbol)) + }, + callHierarchy: async (path, at, direction) => + await callHierarchy(vscode.Uri.file(path), at, direction), + rename: async (path, at, newName) => await rename(vscode.Uri.file(path), at, newName), + // VS Code's installation holds the built-in languages' libraries (the + // TypeScript extension's `lib.*.d.ts`); an installed extension holds + // its own (Python's typeshed). Read at each call: extensions come and go. + libraryRoots: () => [ + vscode.env.appRoot, + ...vscode.extensions.all.map((extension) => extension.extensionPath), + ], + } +} diff --git a/src/host/ide/codeIntelTools.ts b/src/host/ide/codeIntelTools.ts new file mode 100644 index 00000000..f0cdbf1b --- /dev/null +++ b/src/host/ide/codeIntelTools.ts @@ -0,0 +1,65 @@ +// The code intelligence tools the `ide` server offers Muse Code (M67, +// PLAN.md D49): the same answers as the Model API's native tools, as +// `mcp__ide__findDefinition` and the rest. Every one changes nothing, so +// each declares `readOnlyHint` (D49's rule for tools on `ide`) and all are +// listed in Restricted Mode too. `renameSymbol` never writes: it returns the +// edits as a diff, and Muse Code's own edit tool applies them under its +// approvals and rewind. Listed only with a folder open. + +import { IDE_CODE_INTEL_TOOLS, MCP_ANNOTATIONS_READ_ONLY } from '../../shared/constants' +import type { CodeIntelDeps } from '../../core/codeIntel/codeIntelQuery' +import { answerCodeIntel, type CodeIntelReadTool } from '../../core/codeIntel/codeIntelTools' +import { + type CodeIntelDefinition, + IDE_CODE_INTEL_DEFINITIONS, +} from '../../core/codeIntel/definitions' +import { planRename, renameDiff } from '../../core/codeIntel/rename' +import type { McpTool } from '../../core/mcp' + +async function read( + tool: CodeIntelReadTool, + args: Readonly>, + intel: CodeIntelDeps, +): Promise { + const answer = await answerCodeIntel(tool, args, intel) + if (!answer.ok) { + throw new Error(answer.reason) + } + return answer.text +} + +async function renameEdits( + args: Readonly>, + intel: CodeIntelDeps, +): Promise { + const planned = await planRename(args, intel) + if (!planned.ok) { + throw new Error(planned.reason) + } + return renameDiff(planned.plan) +} + +function toolFor(definition: CodeIntelDefinition, intel: CodeIntelDeps): McpTool { + const { tool } = definition + return { + name: IDE_CODE_INTEL_TOOLS[tool], + description: definition.description, + inputSchema: { + type: 'object', + properties: definition.properties, + required: [...definition.required], + additionalProperties: false, + }, + annotations: MCP_ANNOTATIONS_READ_ONLY, + // A refusal rejects, so the server answers with an error result the model reads. + call: async (args) => + tool === 'renameSymbol' ? await renameEdits(args, intel) : await read(tool, args, intel), + } +} + +/** The tools as they stand: none with no folder open. */ +export function ideCodeIntelTools(intel: CodeIntelDeps | undefined): readonly McpTool[] { + return intel === undefined + ? [] + : IDE_CODE_INTEL_DEFINITIONS.map((definition) => toolFor(definition, intel)) +} diff --git a/src/host/settings.ts b/src/host/settings.ts index 619c82d9..0ea3b8a8 100644 --- a/src/host/settings.ts +++ b/src/host/settings.ts @@ -44,6 +44,8 @@ export interface ExtensionSettings extends SettingsSnapshot { readonly modelApiSubagents: boolean /** Explicit machine opt-in for external hook commands (M51). */ readonly modelApiHooks: boolean + /** The repo map in the Model API's system prompt (M67). */ + readonly modelApiRepoMap: boolean } /** @@ -75,6 +77,7 @@ const settingSchemas = { modelApiScheduledPrompts: z.boolean(), modelApiSubagents: z.boolean(), modelApiHooks: z.boolean(), + modelApiRepoMap: z.boolean(), } as const type SettingKey = keyof typeof settingSchemas @@ -142,6 +145,7 @@ export function readSettings(config: SettingsSource, log: Logger): ExtensionSett modelApiScheduledPrompts: readSetting(config, 'modelApiScheduledPrompts', log), modelApiSubagents: readSetting(config, 'modelApiSubagents', log), modelApiHooks: readSetting(config, 'modelApiHooks', log), + modelApiRepoMap: readSetting(config, 'modelApiRepoMap', log), } } diff --git a/src/runtime/backends.ts b/src/runtime/backends.ts index e74fdd4e..b185f27e 100644 --- a/src/runtime/backends.ts +++ b/src/runtime/backends.ts @@ -155,7 +155,7 @@ function modelApiManager( searchWorkerPath: path.join(deps.distDir, SEARCH_WORKER_FILE), log: warn, // The agent cannot see the editor's buffers (D62); the client's `fs/*` will (M63c). - hasUnsavedChanges: () => false, + unsavedFiles: () => [], shellJobAssembly: platform === 'win32' && systemRoot !== undefined ? shellJobAssembly({ diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 4fd46ffd..7d6b3a15 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -87,6 +87,17 @@ export const VSCODE_COMMANDS = { openWalkthrough: 'workbench.action.openWalkthrough', // A folder in a window of its own (M32's new worktree). openFolder: 'vscode.openFolder', + // VS Code's language services (M67): the code intelligence tools. + executeDefinitionProvider: 'vscode.executeDefinitionProvider', + executeReferenceProvider: 'vscode.executeReferenceProvider', + executeHoverProvider: 'vscode.executeHoverProvider', + executeDocumentSymbolProvider: 'vscode.executeDocumentSymbolProvider', + executeWorkspaceSymbolProvider: 'vscode.executeWorkspaceSymbolProvider', + prepareCallHierarchy: 'vscode.prepareCallHierarchy', + provideIncomingCalls: 'vscode.provideIncomingCalls', + provideOutgoingCalls: 'vscode.provideOutgoingCalls', + prepareRename: 'vscode.prepareRename', + executeDocumentRenameProvider: 'vscode.executeDocumentRenameProvider', } as const // Settings (package.json `contributes.configuration`). Keys are relative to @@ -263,6 +274,9 @@ export const SETTING_DEFAULTS = { // Hook commands are user code outside the agent sandbox (M51). A machine // setting must explicitly enable them on the Model API backend. modelApiHooks: false, + // M67 (PLAN.md D49): the repo map in the Model API's system prompt. It + // spends tokens on every request, so it is off until the user turns it on. + modelApiRepoMap: false, } as const export const ARCHIVE_DAY_CHOICES = [1, 2, 7, 14, 0] as const // Settings a repository's `.vscode/settings.json` must never set (PLAN.md @@ -285,6 +299,8 @@ export const MACHINE_SCOPED_SETTINGS = [ 'modelApiScheduledPrompts', 'modelApiSubagents', 'modelApiHooks', + // The repo map is billed as prompt tokens on the key (M67): the user's choice. + 'modelApiRepoMap', ] as const // Muse Code SDK 1.3.0 hook process limits (PLAN.md M51). @@ -580,6 +596,8 @@ export const FILE_EDIT_TOOLS: ReadonlySet = new Set([ 'write_file', 'edit_file', 'apply_patch', + // M67: the Model API's rename, one patch across the files it changed. + 'rename_symbol', ]) export const FILE_READ_TOOLS: ReadonlySet = new Set(['read_file']) // Muse Code's own tool families whose rows read their JSON results (M43, @@ -645,6 +663,115 @@ export const IMAGE_PREVIEW_TOOLS: ReadonlySet = new Set([ 'mcp__ide__generateImage', 'mcp__ide__editImage', ]) + +// --- Code intelligence (M67, PLAN.md D49) --- +// +// The tools over VS Code's language services: native on the Model API +// backend, and on the `ide` server for Muse Code by the camel-case names +// its other tools use (`getDiagnostics`). +export const CODE_INTEL_TOOLS = { + findDefinition: 'find_definition', + findReferences: 'find_references', + workspaceSymbols: 'workspace_symbols', + documentSymbols: 'document_symbols', + hover: 'hover', + callHierarchy: 'call_hierarchy', + repoMap: 'repo_map', + renameSymbol: 'rename_symbol', +} as const +export type CodeIntelTool = keyof typeof CODE_INTEL_TOOLS +export const IDE_CODE_INTEL_TOOLS = { + findDefinition: 'findDefinition', + findReferences: 'findReferences', + workspaceSymbols: 'workspaceSymbols', + documentSymbols: 'documentSymbols', + hover: 'hover', + callHierarchy: 'callHierarchy', + repoMap: 'repoMap', + renameSymbol: 'renameSymbol', +} as const satisfies Readonly> +// MCP tool annotations (2025-06-18 schema): a tool that changes nothing +// says so, and Muse Code may run it as a read (D49's rule for `ide`). +export const MCP_ANNOTATIONS_READ_ONLY = { readOnlyHint: true } as const +// What one answer lists at most; the rest are counted, never silently cut. +export const CODE_INTEL_MAX_LOCATIONS = 100 +export const CODE_INTEL_MAX_SYMBOLS = 200 +export const CODE_INTEL_MAX_CALLS = 50 +// Call sites listed per caller or callee; the rest are counted. +export const CODE_INTEL_MAX_CALL_SITES = 5 +// Other symbols of the same name a lookup by name lists, so the model can pick one. +export const CODE_INTEL_MAX_NAME_MATCHES = 10 +// Nesting `document_symbols` shows (a class, its members, their locals). +export const CODE_INTEL_SYMBOL_DEPTH = 3 +export const CODE_INTEL_HOVER_MAX_CHARS = 4000 +// A result's source line, as `search` shows one. +export const CODE_INTEL_PREVIEW_MAX_CHARS = 200 +export const CODE_INTEL_NAME_MAX_CHARS = 200 +// A language server that does not answer (a stuck one, a project still +// loading) ends the call with that reason instead of holding it. +export const CODE_INTEL_TIMEOUT_MS = 20_000 +// A rename touching more files than this is refused (a rename that large is +// a refactor for the user); its card names a few and counts the rest. +export const RENAME_MAX_FILES = 200 +export const RENAME_CARD_FILES_SHOWN = 5 +// VS Code's `SymbolKind`, by value (vscode.d.ts): the words the model reads. +export const SYMBOL_KIND_NAMES = [ + 'file', + 'module', + 'namespace', + 'package', + 'class', + 'method', + 'property', + 'field', + 'constructor', + 'enum', + 'interface', + 'function', + 'variable', + 'constant', + 'string', + 'number', + 'boolean', + 'array', + 'object', + 'key', + 'null', + 'enum member', + 'struct', + 'event', + 'operator', + 'type parameter', +] as const +// The repo map (Aider's idea, over VS Code's services): files ranked by how +// often other files use the names they define. The names are counted in +// the files' text; where each is defined comes from workspace symbols. +export const REPO_MAP_MAX_FILES = 1000 +export const REPO_MAP_MAX_FILE_CHARS = 131_072 +// The names looked up (the most widely used first), and how many at once. +export const REPO_MAP_MAX_LOOKUPS = 300 +export const REPO_MAP_CONCURRENCY = 8 +// Shorter names (`i`, `id`) are too common to rank by. +export const REPO_MAP_MIN_NAME_CHARS = 3 +export const REPO_MAP_SYMBOLS_PER_FILE = 8 +// The tool's default budget and its ceiling; the system prompt's (opt in). +export const REPO_MAP_DEFAULT_TOKENS = 1024 +export const REPO_MAP_MAX_TOKENS = 8192 +export const REPO_MAP_PROMPT_TOKENS = 1024 +// The rough characters-per-token figure OpenAI and Meta both quote. +export const REPO_MAP_CHARS_PER_TOKEN = 4 +// The lookups stop here, and the map says it is partial. +export const REPO_MAP_TIME_BUDGET_MS = 10_000 +export const REPO_MAP_PROMPT_TIME_BUDGET_MS = 5000 +// The prompt's map is tried on this many turns of a session at most: a try +// that fails or comes out empty (TypeScript's workspace symbols stay empty +// until one of the project's files is open) is not kept, and the next turn +// tries again. +export const REPO_MAP_PROMPT_TRIES = 3 +// The edit tools whose failed row may still carry the patch of what they +// wrote (M67: a rename stopped partway), so its review and rewind stay on. +export const PARTIAL_EDIT_TOOLS: ReadonlySet = new Set(['rename_symbol']) + // --- Meta Model API backend (M7, PLAN.md D1 / D2 / §5.1) --- export const MODEL_API_BASE_URL = 'https://api.meta.ai/v1' @@ -1818,6 +1945,81 @@ export const MODEL_TEXT = { imagePathTaken: 'something already exists at that path; choose a new file name', imageAccountChanged: 'the Model API key changed; ask again before buying an image', pathChangedAfterApproval: 'path changed after approval; request a new approval', + // M67 (PLAN.md D49): the code intelligence tools' answers and refusals. + codeIntelInstructions: + "For code, find_definition, find_references, workspace_symbols, document_symbols, hover, call_hierarchy and repo_map answer from VS Code's language services, as an IDE does: prefer them to search when you look for where a symbol is defined or used. rename_symbol renames a symbol everywhere it is used.", + codeIntelNoService: + 'no language service answered for {path} (language {language}): VS Code has no provider of this kind for it here, or the file declares no symbols; use search and read_file instead', + codeIntelNothingAt: + "No {what} at {place}: the file's language service found none there. Not every language's service provides {what}, so use search to be sure.", + codeIntelUnsavedPosition: + '{path} has unsaved changes in an editor, so its lines differ from what read_file shows; name the symbol without a line, or ask the user to save the file', + codeIntelUnsavedNote: + "[unsaved changes in an editor: {paths}; their lines here are the editor's, not what read_file shows]", + codeIntelHoverHeldBack: + 'The hover is held back: this symbol is defined only outside the workspace ({count} definitions), in files the tools do not show.', + codeIntelTimedOut: + 'the language service did not answer within {seconds} seconds; it may still be loading the project, so try again shortly or use search', + codeIntelOutside: + '[left out {count} outside the workspace: library declarations or other folders]', + codeIntelMore: '[{count} more not shown]', + codeIntelNoTarget: + 'name the symbol by path, line and column; by path, line and symbol; by path and symbol; or by symbol alone', + codeIntelBadPosition: 'line and column must be whole numbers from 1', + codeIntelBadName: '{field} must be a single line of 1 to {max} characters', + codeIntelNotInFile: '`{symbol}` does not occur in {place}', + codeIntelNoSymbolNamed: + "no workspace symbol is named `{symbol}`: workspace symbols come from the languages' services (TypeScript's needs one of the project's files open), so give a path, or use search", + codeIntelUsing: 'Using `{symbol}` at {place}.', + codeIntelOtherMatches: 'Also named `{symbol}`: {places}.', + codeIntelNoSymbolsMatch: + "No workspace symbols match `{query}` in the workspace. Workspace symbols come from the languages' services: TypeScript's needs one of the project's files open, and a language without a service has none.", + codeIntelNoCallHierarchy: + 'nothing at {place} has a call hierarchy here; place the position on a function or method name, or the language has no call hierarchy in VS Code', + codeIntelCallsTo: 'Calls to {symbol} at {place}:', + codeIntelCallsFrom: 'Calls from {symbol} at {place}:', + codeIntelCallSites: 'calls at {sites}', + codeIntelCalledAt: 'called at {sites}', + codeIntelCalledOutside: 'called at {sites} of its file outside the workspace', + codeIntelOtherCallItems: + "[{count} more functions share this position (overloads or merged declarations) and were not asked; ask at each one's own declaration for its calls]", + codeIntelOutsideWorkspace: 'outside the workspace', + codeIntelNoCalls: 'No calls found.', + renameFileOperations: + 'this rename would also create, move or delete files, which rename_symbol does not do; nothing was changed', + renameFileOperationsUnknown: + 'VS Code did not say whether this rename also creates, moves or deletes files, so rename_symbol does not apply it; nothing was changed', + renameSameName: 'the new name `{name}` is already the name there; nothing to rename', + renameOutside: + 'this rename would also change {count} files outside the workspace; nothing was changed', + renameTooMany: 'this rename would change {count} files, more than {max}; nothing was changed', + renameStale: + "the language service's rename does not match {path} as it is now (it differs between VS Code and the disk, has unsaved changes, or changed after the service last read it); nothing was changed, so call rename_symbol again shortly", + renameNothing: 'nothing to rename at {place}', + renameChanged: + '{path} changed after the rename was planned; nothing was changed, so call rename_symbol again', + renameChangedPartway: + '{path} changed after the rename was planned, so it was not written. The rename was written to {written} of {total} files ({paths}); the rest are unchanged, and the row can revert what was written', + renameDone: + 'Renamed `{from}` to `{to}`: {edits} edits in {files} files ({paths}). Read a file again before replacing it with write_file.', + renamePartial: + 'writing {path} failed: {reason}. The rename was written to {written} of {total} files ({paths}); the rest are unchanged, and the row can revert what was written', + renameEditsLead: + 'The rename of `{from}` to `{to}`: {edits} edits in {files} files. This tool changed nothing: apply the diff below with your own edit tool.', + repoMapLead: + 'Files ranked by how often other files use the names they define (names counted in the text, definitions from workspace symbols), each with its most used definitions:', + repoMapPartial: '[partial: looked up {done} of {total} names within the time budget]', + repoMapFilesCapped: '[ranked the first {count} of {total} files]', + repoMapFilesRead: '[partial: read {done} of {total} files within the time budget]', + repoMapNoFiles: "[partial: the workspace's files were not listed within the time budget]", + repoMapNoService: + "no language service answered workspace symbols here (TypeScript's needs one of the project's files open); use list_files and search instead", + repoMapEmpty: + 'No workspace file defines a name that other files use, as far as the workspace symbols show.', + repoMapBudgetTooSmall: + "max_tokens {tokens} cannot hold the map's own lead and notes; ask again with max_tokens of at least {needed}", + repoMapSection: '# Repo map', + repoMapSectionLead: 'The workspace as this session began (repo_map gives a fresh one):', // The user said no in the price confirmation (M44): nothing was bought. imageDeclined: 'the user declined to buy this image; nothing was bought or written', // M45 (PLAN.md D38): the goal loop on the Model API backend, in Muse Code's diff --git a/src/shared/l10n/en.ts b/src/shared/l10n/en.ts index 7ebff227..8e9af827 100644 --- a/src/shared/l10n/en.ts +++ b/src/shared/l10n/en.ts @@ -443,6 +443,15 @@ export const EN = { approvalUseTool: 'Muse wants to use {action}', // {paths} is the list of images an edit starts from, shown as code. approvalImageSources: 'Starting from {paths}', + // M67: a rename's card names a few of its files ({files}) and counts the rest. + renameCardMore: forms({ + one: '{files} and {count} more file', + other: '{files} and {count} more files', + }), + // M67: the code intelligence rows when VS Code's language services cannot answer. + codeIntelNoService: 'No language service answered for {path}, or it declares no symbols.', + codeIntelTimedOut: 'The language service did not answer within {seconds} seconds.', + repoMapNoService: 'No language service answered for the workspace’s symbols.', // The paid-use popup before an image, on either backend (M34, M44, M58). imageBuyTitle: 'Muse wants to create the image {path}', imageBuyEditTitle: 'Muse wants to make the edited image {path}', @@ -911,6 +920,23 @@ export const EN = { snooze_reminder: 'Snooze reminder', submit_reminder_decision: 'Reminder', submit_result: 'Result', + // M67: code intelligence, native on the Model API and on the ide server. + find_definition: 'Definition', + find_references: 'References', + workspace_symbols: 'Symbols', + document_symbols: 'Outline', + hover: 'Hover', + call_hierarchy: 'Calls', + repo_map: 'Repo map', + rename_symbol: 'Rename', + mcp__ide__findDefinition: 'Definition', + mcp__ide__findReferences: 'References', + mcp__ide__workspaceSymbols: 'Symbols', + mcp__ide__documentSymbols: 'Outline', + mcp__ide__hover: 'Hover', + mcp__ide__callHierarchy: 'Calls', + mcp__ide__repoMap: 'Repo map', + mcp__ide__renameSymbol: 'Rename', }, // A tool from an MCP server the table does not name (`mcp____`). mcpToolLabel: '{tool} ({server})', diff --git a/src/webview/components/ToolRow.tsx b/src/webview/components/ToolRow.tsx index 64690441..316626d7 100644 --- a/src/webview/components/ToolRow.tsx +++ b/src/webview/components/ToolRow.tsx @@ -12,6 +12,7 @@ import type { LineRange } from '../../shared/protocol' import { type DiffRow, type FileDiff, parsePatchDocument, parseUnifiedText } from '../diff' import { failedOutcomeText, + hasLandedEdits, isFailedStatus, type OutputPage, type ToolImageState, @@ -322,9 +323,10 @@ function ToolRowView({ ) const change = changeSummary(entry.patchSummary) const isFailed = isFailedStatus(entry.status) || entry.status === TOOL_STATUS_INTERRUPTED - // A finished edit with a stored patch can be reviewed in the editor. + // An edit whose changes are on disk (a finished one, or a rename stopped + // partway, M67) can be reviewed and reverted in the editor. const reviewRef = - presentation.body === 'edit' && entry.status === 'completed' ? entry.patchRef : undefined + presentation.body === 'edit' && hasLandedEdits(entry) ? entry.patchRef : undefined usePatchPages(entry, isOpen, patchPage, onReadOutput) // Parsed once per page, not per render (M25); a partial document parses to nothing. const patchContent = patchPage?.isEof === true ? patchPage.content : undefined diff --git a/src/webview/state/uiState.ts b/src/webview/state/uiState.ts index 363baf2e..488ec9fa 100644 --- a/src/webview/state/uiState.ts +++ b/src/webview/state/uiState.ts @@ -22,6 +22,7 @@ import { HIDDEN_ITEM_KINDS, MAX_ATTACHMENTS_PER_MESSAGE, MILLISECONDS_PER_SECOND, + PARTIAL_EDIT_TOOLS, type PermissionMode, type TaskRequest, TOOL_STATUS_INTERRUPTED, @@ -378,6 +379,7 @@ const OUTPUT_FIELD = 'output' const TEXT_FIELD = 'text' const IN_PROGRESS = 'inProgress' const COMPLETED = 'completed' +const FAILED = 'failed' const REJECTED = 'rejected' const CANCELLED = 'cancelled' const USER_MESSAGE_KIND = 'userMessage' @@ -490,6 +492,22 @@ function dictationAnnouncement( return next === 'idle' && previous === 'listening' ? UI_TEXT.announceStoppedListening : undefined } +/** + * Whether a tool row's edits are on disk: a completed row with a patch, or + * a rename stopped partway (M67), whose failed row carries the patch of the + * files it did write. Review, revert and rewind cover both. + */ +export function hasLandedEdits(entry: { + readonly status: string + readonly tool: string + readonly patchRef?: OutputRef | undefined +}): boolean { + return ( + entry.patchRef !== undefined && + (entry.status === COMPLETED || (entry.status === FAILED && PARTIAL_EDIT_TOOLS.has(entry.tool))) + ) +} + /** Whether a tool status is an outcome the row shows as a failure (not running, done or cut off). */ export function isFailedStatus(status: string): boolean { return status !== IN_PROGRESS && status !== COMPLETED && status !== TOOL_STATUS_INTERRUPTED @@ -1094,7 +1112,9 @@ function replayChild( * of the same row keeps it. */ function stampCompletion(entry: TranscriptEntry, seq: number): TranscriptEntry { - return entry.kind === 'tool' && entry.status === COMPLETED && entry.completedSeq === undefined + return entry.kind === 'tool' && + (entry.status === COMPLETED || hasLandedEdits(entry)) && + entry.completedSeq === undefined ? { ...entry, completedSeq: seq } : entry } @@ -2368,7 +2388,7 @@ export function editsAfter(state: UiState, entryId: string): readonly EditRef[] readonly patchRef: OutputRef } => entry.kind === 'tool' && - entry.status === COMPLETED && + hasLandedEdits(entry) && entry.patchRef !== undefined && entry.completedSeq !== undefined && entry.completedSeq > message.seq, diff --git a/src/webview/toolPresentation.ts b/src/webview/toolPresentation.ts index 204c1bfa..dc9a749d 100644 --- a/src/webview/toolPresentation.ts +++ b/src/webview/toolPresentation.ts @@ -64,6 +64,8 @@ interface ParsedArgs { readonly currentWork: string | undefined /** `cron_delete`'s job id (M43). */ readonly id: string | undefined + /** A code intelligence tool's symbol name (M67). */ + readonly symbol: string | undefined } const NO_ARGS: ParsedArgs = { @@ -79,6 +81,7 @@ const NO_ARGS: ParsedArgs = { status: undefined, currentWork: undefined, id: undefined, + symbol: undefined, } // `mcp____`: the name Muse Code gives an MCP server's tool. @@ -107,6 +110,7 @@ function parseArgs(args: string): ParsedArgs { status: pick('status'), currentWork: pick('current_work'), id: pick('id'), + symbol: pick('symbol'), } } catch { return NO_ARGS @@ -189,6 +193,7 @@ function otherPresentation( return { summary: parsed.path ?? + parsed.symbol ?? parsed.pattern ?? parsed.query ?? parsed.url ?? @@ -217,6 +222,7 @@ export function describeTool(tool: string, args: string): ToolPresentation { } } if (FILE_EDIT_TOOLS.has(tool)) { + // Only a path: an edit row's summary is the file it opens. return { label, summary: parsed.path ?? '', body: 'edit', command: undefined, imagePath } } return FILE_READ_TOOLS.has(tool) diff --git a/test/e2e/modelApi.live.e2e.test.ts b/test/e2e/modelApi.live.e2e.test.ts index 36f1f499..cfa8b755 100644 --- a/test/e2e/modelApi.live.e2e.test.ts +++ b/test/e2e/modelApi.live.e2e.test.ts @@ -44,6 +44,11 @@ import { crc32, deflateSync } from 'node:zlib' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import * as z from 'zod/mini' import type { AgentSession, TurnPart } from '../../src/core/agent/agentBackend' +import type { + CodeLocation, + CodeSymbol, + LanguageServiceHost, +} from '../../src/core/codeIntel/languageService' import { APPROVAL_CHOICE_IDS } from '../../src/core/backends/modelapi/permissions' import { parseLoopPrompt } from '../../src/core/backends/modelapi/schedules' import { type Usage, usageSchema } from '../../src/core/backends/modelapi/schemas' @@ -561,6 +566,8 @@ interface RigOptions { readonly mcpJobPath?: string | undefined /** `museSpark.modelApiPromptCacheRetention`; the setting's default when absent. */ readonly promptCacheRetention?: PromptCacheRetention + /** Language services for the code intelligence tools (M67), over the rig's workspace. */ + readonly codeIntel?: (workspace: string) => LanguageServiceHost } interface Rig { @@ -652,7 +659,7 @@ async function openRig(options: RigOptions): Promise { env: () => process.env, // Built by `npm run build:dev`; no case here needs the search tool. searchWorkerPath: path.join(process.cwd(), 'dist', SEARCH_WORKER_FILE), - hasUnsavedChanges: () => false, + unsavedFiles: () => [], log: (message) => { log.warn(message) }, @@ -756,6 +763,7 @@ async function openRig(options: RigOptions): Promise { ), // Built by `npm run build:dev`, as in activate (M57). bundlePath: path.join(process.cwd(), 'dist', MODEL_API_BUNDLE_FILE), + codeIntel: options.codeIntel?.(workspace), ideTools: [ diagnosticsTool({ getDiagnostics: () => [], @@ -954,6 +962,93 @@ function wordPdf(word: string): Uint8Array { return new TextEncoder().encode(document) } +// The code intelligence case's workspace (M67): one function, used twice. +const CODE_FILES = { + 'src/greet.ts': 'export function greet(name: string): string {\n return `Hello, ${name}`\n}\n', + 'src/main.ts': + "import { greet } from './greet'\n\nexport const pair = [greet('Ada'), greet('Grace')]\n", +} +const CODE_SYMBOL = 'greet' +const CODE_SYMBOL_USE = /\bgreet\b/g +// VS Code's `SymbolKind.Function`. +const FUNCTION_KIND = 11 +// A quote that opens or closes a string literal in the case's files. +const QUOTE = /['"`]/g + +/** Whether a column of a line is inside a string literal (the module path `'./greet'`). */ +function isInString(line: string, column: number): boolean { + return (line.slice(0, column).match(QUOTE)?.length ?? 0) % 2 === 1 +} + +/** + * VS Code's language services live only in the extension host. A stand-in + * that knows the case's one symbol answers from the files' text, so the + * model uses the code intelligence tools against Meta's real API; the real + * services' answers are the integration test's (test/integration). + */ +function textLanguageService(workspace: string): LanguageServiceHost { + const fileOf = (name: string) => path.join(workspace, ...name.split('/')) + const uses = (): CodeLocation[] => + Object.keys(CODE_FILES).flatMap((name) => + readFileSync(fileOf(name), 'utf8') + .split('\n') + .flatMap((line, index) => { + const found: CodeLocation[] = [] + for (const match of line.matchAll(CODE_SYMBOL_USE)) { + // As TypeScript's service does, a name inside a string is no use of it. + if (!isInString(line, match.index)) { + found.push({ + path: fileOf(name), + range: { + start: { line: index, character: match.index }, + end: { line: index, character: match.index + CODE_SYMBOL.length }, + }, + }) + } + } + return found + }), + ) + const declaration = (): readonly CodeSymbol[] => + uses() + .slice(0, 1) + .map((location) => ({ + name: CODE_SYMBOL, + kind: FUNCTION_KIND, + detail: undefined, + container: undefined, + location, + selection: location.range, + children: [], + })) + return { + open: (file) => + Promise.resolve({ + languageId: 'typescript', + text: readFileSync(file, 'utf8'), + isDirty: false, + }), + definitions: () => Promise.resolve(declaration().map((symbol) => symbol.location)), + references: () => Promise.resolve(uses()), + hover: () => Promise.resolve(['function greet(name: string): string']), + documentSymbols: (file) => + Promise.resolve(file === fileOf('src/greet.ts') ? declaration() : []), + workspaceSymbols: (query) => Promise.resolve(CODE_SYMBOL.includes(query) ? declaration() : []), + callHierarchy: () => Promise.resolve(undefined), + libraryRoots: () => [], + rename: (_file, _at, newName) => + Promise.resolve({ + files: Object.keys(CODE_FILES).map((name) => ({ + path: fileOf(name), + edits: uses() + .filter((use) => use.path === fileOf(name)) + .map((use) => ({ range: use.range, newText: newName })), + })), + fileOperations: 'none', + }), + } +} + function isPngFile(file: string): boolean { try { return readFileSync(file).subarray(0, PNG_SIGNATURE.length).equals(Buffer.from(PNG_SIGNATURE)) @@ -1772,4 +1867,29 @@ describe.skipIf(!IS_ENABLED)('live Model API sweep (MUSE_LIVE_MODEL_API=1)', () }, CASE_MS, ) + + it( + 'case19 code intelligence: references, then a rename through its card (M67, D49)', + async () => { + const options = { files: CODE_FILES, codeIntel: textLanguageService } + await runCase('case19 code intelligence', options, async (rig) => { + const driver = await startSession(rig, 'promptUnmatched') + const finished = await send( + driver, + 'Use the find_references tool on the symbol greet to see where it is used, then use the rename_symbol tool to rename greet to welcome. Reply with how many references find_references listed.', + ) + expectCompleted(finished) + const rows = toolsRun(finished) + expect(rows).toContain('find_references:completed') + expect(rows).toContain('rename_symbol:completed') + expect(driver.watch.approved).toContain('rename_symbol') + // The import names the new function; its module path is unchanged. + expect(readFileSync(path.join(rig.workspace, 'src', 'main.ts'), 'utf8')).toBe( + "import { welcome } from './greet'\n\nexport const pair = [welcome('Ada'), welcome('Grace')]\n", + ) + rig.notes.push(`rows ${rows.join(' ')}`, `reply ${JSON.stringify(finished.reply)}`) + }) + }, + CASE_MS, + ) }) diff --git a/test/fixtures/workspace/code-intel/greet.ts b/test/fixtures/workspace/code-intel/greet.ts new file mode 100644 index 00000000..68ba432a --- /dev/null +++ b/test/fixtures/workspace/code-intel/greet.ts @@ -0,0 +1,7 @@ +// A TypeScript fixture for the code intelligence integration test (M67): +// VS Code's TypeScript service answers over this file and main.ts. + +/** Says hello to someone. */ +export function greet(name: string): string { + return `Hello, ${name}` +} diff --git a/test/fixtures/workspace/code-intel/main.ts b/test/fixtures/workspace/code-intel/main.ts new file mode 100644 index 00000000..0ac43a15 --- /dev/null +++ b/test/fixtures/workspace/code-intel/main.ts @@ -0,0 +1,8 @@ +// The code intelligence fixture's caller (M67): two calls to greet, and one +// to a library method, whose declaration is outside the workspace. +import { greet } from './greet' + +/** Greets two people, loudly. */ +export function main(): string { + return `${greet('Ada')} ${greet('Grace')}`.toUpperCase() +} diff --git a/test/fixtures/workspace/code-intel/notes.txt b/test/fixtures/workspace/code-intel/notes.txt new file mode 100644 index 00000000..4b29d9ae --- /dev/null +++ b/test/fixtures/workspace/code-intel/notes.txt @@ -0,0 +1 @@ +Plain text, which no language service answers for. diff --git a/test/fixtures/workspace/code-intel/tsconfig.json b/test/fixtures/workspace/code-intel/tsconfig.json new file mode 100644 index 00000000..35567350 --- /dev/null +++ b/test/fixtures/workspace/code-intel/tsconfig.json @@ -0,0 +1,14 @@ +{ + // The code intelligence fixture's own project (M67): VS Code's TypeScript + // service loads it for the integration test, and ESLint's project service + // lints the two files with it. + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "noEmit": true + }, + "include": ["./*.ts"] +} diff --git a/test/harness/index.html b/test/harness/index.html index 926434fa..f0ce0c17 100644 --- a/test/harness/index.html +++ b/test/harness/index.html @@ -2038,6 +2038,109 @@ } }) }, + // --- M67: code intelligence rows, and a rename's card across its files --- + 'code-intel': () => { + window.harnessSilent = true + setDraft('Where is greet used? Then rename it to welcome') + key({ key: 'Enter' }) + const row = (itemId, name, args, visibleOutput, extra = {}) => + event({ + type: 'itemCompleted', + item: { + itemId, + kind: 'toolCall', + status: 'completed', + tool: name, + args, + visibleOutput, + ...extra, + }, + }) + row( + 'ci1', + 'find_references', + '{"path":"src/greet.ts","symbol":"greet"}', + [ + 'Using `greet` at src/greet.ts:5:17.', + 'src/greet.ts:5:17: export function greet(name: string): string {', + "src/main.ts:3:10: import { greet } from './greet'", + "src/main.ts:7:13: return `${greet('Ada')} ${greet('Grace')}`", + '[left out 1 outside the workspace: library declarations or other folders]', + ].join('\n'), + ) + row( + 'ci2', + 'document_symbols', + '{"path":"src/greet.ts"}', + 'src/greet.ts (typescript):\n5:17 function greet (name: string): string', + ) + row( + 'ci3', + 'find_definition', + '{"path":"notes.txt","line":1,"column":1}', + 'No language service answered for notes.txt.', + { status: 'failed', failureReason: 'No language service answered for notes.txt.' }, + ) + row( + 'ci4', + 'mcp__ide__renameSymbol', + '{"path":"src/greet.ts","symbol":"greet","new_name":"welcome"}', + [ + 'The rename of `greet` to `welcome`: 3 edits in 2 files. This tool changed nothing: apply the diff below with your own edit tool.', + '--- a/src/greet.ts', + '+++ b/src/greet.ts', + '@@ -5,1 +5,1 @@', + '-export function greet(name: string): string {', + '+export function welcome(name: string): string {', + ].join('\n'), + ) + event({ + type: 'itemStarted', + item: { + itemId: 'ci5', + kind: 'toolCall', + status: 'inProgress', + tool: 'rename_symbol', + args: '{"path":"src/greet.ts","symbol":"greet","new_name":"welcome"}', + }, + }) + event({ + type: 'approvalRequested', + approvalId: 'ci5a', + itemId: 'ci5', + toolName: 'rename_symbol', + rawArgs: '{"path":"src/greet.ts","symbol":"greet","new_name":"welcome"}', + requirementId: { approvalId: 'ci5a', sourceIndex: 0 }, + subject: { + kind: 'fileWrite', + path: '.vscode/tasks.ts, src/a.ts, src/b.ts, src/greet.ts, src/main.ts and 3 more files', + toolName: 'rename_symbol', + }, + availableChoices: [ + { choiceId: 'allow_once', label: 'Allow once', decision: 'approved', scope: 'once' }, + { + choiceId: 'allow_session', + label: 'Always allow rename_symbol', + decision: 'approvedPolicyAmendment', + scope: 'session', + }, + { + choiceId: 'abort', + label: 'Reject', + decision: 'abort', + scope: 'once', + acceptsFeedback: true, + }, + ], + isJudgeEscalated: false, + isProtectedWrite: true, + }) + later(150, () => { + for (const toggle of document.querySelectorAll('.tool-toggle[aria-expanded="false"]')) { + toggle.click() + } + }) + }, // --- M43: a search's results, background work, and a picture a tool read --- 'muse-web': () => { window.harnessSilent = true diff --git a/test/integration/codeIntel.test.ts b/test/integration/codeIntel.test.ts new file mode 100644 index 00000000..f9ad366e --- /dev/null +++ b/test/integration/codeIntel.test.ts @@ -0,0 +1,190 @@ +// Code intelligence over a real TypeScript service (M67, PLAN.md D49): the +// tools' core and the host's adapter inside the Extension Development Host, +// over the fixture test/fixtures/workspace/code-intel (VS Code's built-in +// TypeScript extension answers). Nothing here writes: the rename is planned +// and its diff read. + +import * as assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import * as vscode from 'vscode' +import type { CodeIntelDeps } from '../../src/core/codeIntel/codeIntelQuery' +import { answerCodeIntel, type CodeIntelReadTool } from '../../src/core/codeIntel/codeIntelTools' +import { planRename, renameDiff } from '../../src/core/codeIntel/rename' +import { canonicalPath, isMissingPath } from '../../src/host/canonicalPath' +import { vscodeLanguageServices } from '../../src/host/codeIntel/languageServices' + +const FIXTURE = 'code-intel' +// TypeScript answers once it has loaded the fixture's project. +const READY_TIMEOUT_MS = 60_000 +const POLL_INTERVAL_MS = 250 +// Each test may wait that long for the project, and ask several times. +const TEST_TIMEOUT_MS = 120_000 + +async function readText(file: string): Promise { + try { + return await readFile(file, 'utf8') + } catch (error: unknown) { + if (isMissingPath(error)) { + return undefined + } + throw error + } +} + +function depsFor(root: string): CodeIntelDeps { + return { + service: vscodeLanguageServices(), + workspaceRoot: root, + platform: process.platform, + io: { + realPath: canonicalPath, + readFile: readText, + listFiles: async () => { + const found = await vscode.workspace.findFiles(`${FIXTURE}/**`) + return found.map((uri) => vscode.workspace.asRelativePath(uri, false)) + }, + unsavedFiles: () => [], + }, + now: () => Date.now(), + } +} + +/** The tool's answer once it passes `isReady` (the project may still be loading). */ +async function answerOnce( + deps: CodeIntelDeps, + tool: CodeIntelReadTool, + args: Record, + isReady: (text: string) => boolean, +): Promise { + const deadline = Date.now() + READY_TIMEOUT_MS + let last = '' + while (Date.now() < deadline) { + const answer = await answerCodeIntel(tool, args, deps) + last = answer.ok ? answer.text : `refused: ${answer.reason}` + if (isReady(last)) { + return last + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + } + throw new Error(`${tool} never answered as expected; last answer: ${last}`) +} + +suite('code intelligence over TypeScript (M67)', () => { + let deps: CodeIntelDeps + + suiteSetup(async () => { + const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + assert.ok(root, 'the fixture workspace is open') + deps = depsFor(root) + // TypeScript loads a project for an open file; workspace symbols need one. + const main = vscode.Uri.file(path.join(root, FIXTURE, 'main.ts')) + await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(main)) + }) + + test('finds a definition across files, and leaves the library out, counted', async () => { + const own = await answerOnce( + deps, + 'findDefinition', + { path: `${FIXTURE}/main.ts`, line: 7, symbol: 'greet' }, + (text) => text.includes('greet.ts'), + ) + assert.match( + own, + /code-intel\/greet\.ts:5:17: export function greet\(name: string\): string \{/, + ) + const library = await answerOnce( + deps, + 'findDefinition', + { path: `${FIXTURE}/main.ts`, symbol: 'toUpperCase' }, + (text) => text.includes('left out'), + ) + assert.match(library, /\[left out \d+ outside the workspace/) + }).timeout(TEST_TIMEOUT_MS) + + test('finds every reference, the declaration included, in place order', async () => { + const text = await answerOnce( + deps, + 'findReferences', + { path: `${FIXTURE}/greet.ts`, symbol: 'greet' }, + (answer) => answer.includes('main.ts:7'), + ) + const places = text + .split('\n') + .filter((line) => line.startsWith(FIXTURE)) + .map((line) => line.split(': ', 1)[0]) + assert.deepEqual(places, [ + 'code-intel/greet.ts:5:17', + 'code-intel/main.ts:3:10', + 'code-intel/main.ts:7:13', + 'code-intel/main.ts:7:29', + ]) + }).timeout(TEST_TIMEOUT_MS) + + test('outlines, hovers, finds symbols and callers', async () => { + assert.match( + await answerOnce(deps, 'documentSymbols', { path: `${FIXTURE}/greet.ts` }, (text) => + text.includes('greet'), + ), + /5:17 function greet/, + ) + assert.match( + await answerOnce(deps, 'hover', { path: `${FIXTURE}/greet.ts`, symbol: 'greet' }, (text) => + text.includes('function greet'), + ), + /Says hello to someone\./, + ) + // Defined in TypeScript's bundled library, under VS Code's installation: + // a library root, so the hover is shown, not held back. + assert.match( + await answerOnce( + deps, + 'hover', + { path: `${FIXTURE}/main.ts`, symbol: 'toUpperCase' }, + (text) => text.includes('toUpperCase'), + ), + /toUpperCase\(\): string/, + ) + assert.match( + await answerOnce(deps, 'workspaceSymbols', { query: 'greet' }, (text) => + text.includes('greet.ts'), + ), + /code-intel\/greet\.ts:5:17: function greet/, + ) + assert.match( + await answerOnce( + deps, + 'callHierarchy', + { path: `${FIXTURE}/greet.ts`, symbol: 'greet' }, + (text) => text.includes('main.ts'), + ), + /code-intel\/main\.ts:6:17: function main \(calls at 7:13, 7:29\)/, + ) + }).timeout(TEST_TIMEOUT_MS) + + test('says "no language service" for plain text', async () => { + const answer = await answerCodeIntel('documentSymbols', { path: `${FIXTURE}/notes.txt` }, deps) + assert.equal(answer.ok, false) + assert.match( + answer.reason, + /no language service answered for code-intel\/notes\.txt \(language plaintext\)/, + ) + }) + + test('plans a rename across both files and writes nothing', async () => { + const before = await readText(path.join(deps.workspaceRoot, FIXTURE, 'main.ts')) + const planned = await planRename( + { path: `${FIXTURE}/greet.ts`, symbol: 'greet', new_name: 'welcome' }, + deps, + ) + assert.ok(planned.ok, planned.ok ? '' : planned.reason) + assert.deepEqual( + planned.plan.files.map((file) => file.relative), + ['code-intel/greet.ts', 'code-intel/main.ts'], + ) + const diff = renameDiff(planned.plan) + assert.match(diff, /\+export function welcome\(name: string\): string \{/) + assert.match(diff, /\+import \{ welcome \} from '\.\/greet'/) + assert.equal(await readText(path.join(deps.workspaceRoot, FIXTURE, 'main.ts')), before) + }).timeout(TEST_TIMEOUT_MS) +}) diff --git a/test/unit/Transcript.test.tsx b/test/unit/Transcript.test.tsx index 66e63cc5..4c732b4d 100644 --- a/test/unit/Transcript.test.tsx +++ b/test/unit/Transcript.test.tsx @@ -481,6 +481,32 @@ describe('Transcript rows (M15, M16)', () => { expect(document.querySelectorAll('.diff tr')).toHaveLength(2 + 15 + 2) expect(screen.getAllByText('Click to expand')).toHaveLength(1) }) + + it('offers the editor for a rename stopped partway, and for no other failed edit (M67)', () => { + const diff = '--- a/x.ts\n+++ b/x.ts\n@@ -1,1 +1,1 @@\n-old\n+new' + const props = renderTranscript([ + tool({ + id: 'r1', + tool: 'rename_symbol', + args: '{"path":"x.ts"}', + output: diff, + status: 'failed', + patchRef: { id: 'p1', byteLen: 10 }, + }), + tool({ + id: 'e1', + tool: 'edit_file', + args: '{"path":"y.ts"}', + output: diff, + status: 'failed', + patchRef: { id: 'p2', byteLen: 10 }, + }), + ]) + const offered = screen.getAllByText('Click to expand') + expect(offered).toHaveLength(1) + fireEvent.click(offered[0]!) + expect(props.onOpenEditDiff).toHaveBeenCalledWith('r1', 'p1') + }) }) describe('Transcript chat references (M17)', () => { diff --git a/test/unit/codeIntelTools.test.ts b/test/unit/codeIntelTools.test.ts new file mode 100644 index 00000000..351fb713 --- /dev/null +++ b/test/unit/codeIntelTools.test.ts @@ -0,0 +1,487 @@ +// The read-only code intelligence tools (M67, PLAN.md D49) over a fake of +// VS Code's language services: confined inputs, workspace-relative, +// sorted and capped answers that count what they leave out, lookups by +// name, and "no language service" instead of an empty answer. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CodeIntelDeps } from '../../src/core/codeIntel/codeIntelQuery' +import { answerCodeIntel, type CodeIntelReadTool } from '../../src/core/codeIntel/codeIntelTools' +import type { CodeLocation } from '../../src/core/codeIntel/languageService' +import { + CODE_INTEL_HOVER_MAX_CHARS, + CODE_INTEL_MAX_LOCATIONS, + UI_TEXT, +} from '../../src/shared/constants' +import { fill } from '../../src/shared/l10n/text' +import { + type FakeServiceOptions, + fakeLanguageService, + KIND, + loc, + sym, +} from './helpers/fakeLanguageService' +import { memoryToolIo, type MemoryToolIo, realPathThrough } from './helpers/fakeToolIo' + +const ROOT = '/ws' +const A = `${ROOT}/src/a.ts` +const B = `${ROOT}/src/b.ts` +const Z = `${ROOT}/src/z.ts` +const LIB = '/lib/lib.d.ts' +const OTHER = '/other/c.ts' +const FILES = { + 'src/a.ts': 'export function greet(name: string): string {\n return `hi ${name}`\n}\n', + 'src/b.ts': "import { greet } from './a'\ngreet('x')\ngreet('y')\n", + 'src/z.ts': 'greeting\n greet()\n', + 'notes.txt': 'plain words greet\n', +} +const GREET = sym('greet', KIND.function, A, 0, 16) +const START = { line: 0, character: 0 } + +type Options = Omit & { + readonly io?: MemoryToolIo + readonly realPath?: (path: string) => Promise + /** The folder the workspace is opened as, when not the files' own (a link to it). */ + readonly workspaceRoot?: string +} + +function setup(options: Options = {}) { + const io = options.io ?? memoryToolIo(FILES, ROOT) + const service = fakeLanguageService({ files: io.files, ...options }) + const deps: CodeIntelDeps = { + service, + workspaceRoot: options.workspaceRoot ?? ROOT, + platform: 'linux', + io: options.realPath === undefined ? io : { ...io, realPath: options.realPath }, + now: () => 0, + } + return { + io, + service, + ask: async (tool: CodeIntelReadTool, args: unknown) => await answerCodeIntel(tool, args, deps), + } +} + +/** The answer's text; fails the test when the call was refused. */ +async function textOf(answer: Promise>>) { + const settled = await answer + if (!settled.ok) { + throw new Error(`refused: ${settled.reason}`) + } + return settled.text +} + +/** The refusal's reason for the model; fails the test when the call was answered. */ +async function reasonOf(answer: Promise>>) { + const refused = await refusalOf(answer) + return refused.reason +} + +/** The refusal's reasons; fails the test when the call was answered. */ +async function refusalOf(answer: Promise>>) { + const settled = await answer + if (settled.ok) { + throw new Error(`answered: ${settled.text}`) + } + return settled +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('find_definition and find_references', () => { + it('answers workspace-relative places with their lines, and counts what is outside', async () => { + const t = setup({ + definitions: () => [loc(A, 0, 16), loc(LIB, 5, 0), loc(undefined, 1, 1), loc(OTHER, 0, 0)], + }) + const text = await textOf(t.ask('findDefinition', { path: 'src/b.ts', line: 2, column: 1 })) + expect(text).toBe( + [ + 'src/a.ts:1:17: export function greet(name: string): string {', + '[left out 3 outside the workspace: library declarations or other folders]', + ].join('\n'), + ) + expect(t.service.asked).toContain(`definitions ${B} 1:0`) + }) + + it('finds a name on a line, in a file, or among the workspace symbols', async () => { + const t = setup({ + definitions: () => [loc(A, 0, 16)], + workspace: [ + sym('greet', KIND.function, Z, 1, 0), + // As TypeScript gives one: the name with its call parentheses, the + // range from the start of the declaration. + { ...GREET, name: 'greet()', selection: { start: START, end: GREET.selection.end } }, + sym('greet', KIND.function, LIB, 0, 0), + sym('greeter', KIND.class, A, 0, 0), + ], + }) + expect( + await textOf(t.ask('findDefinition', { path: 'src/b.ts', line: 3, symbol: 'greet' })), + ).toContain('Using `greet` at src/b.ts:3:1.') + // The first whole-word use in the file: `greeting` is another name. + expect(await textOf(t.ask('findDefinition', { path: 'src/z.ts', symbol: 'greet' }))).toContain( + 'Using `greet` at src/z.ts:2:3.', + ) + // By name alone: the first in place order, the provider's range moved to + // the name, the others listed, and the library's left out. + const byName = await textOf(t.ask('findDefinition', { symbol: 'greet' })) + expect(byName).toContain('Using `greet` at src/a.ts:1:17.') + expect(byName).toContain('Also named `greet`: src/z.ts:2:1.') + expect(byName).not.toContain('lib') + expect(t.service.asked).toContain(`definitions ${A} 0:16`) + }) + + it('refuses arguments that name nothing, or a place outside the workspace', async () => { + const io = memoryToolIo(FILES, ROOT, undefined, { linked: '/elsewhere' }) + const t = setup({ io }) + const reasons = await Promise.all( + [ + {}, + { path: 'src/b.ts' }, + { path: 'src/b.ts', column: 2 }, + { path: 'src/b.ts', line: 0, column: 1 }, + { path: 'src/b.ts', line: 1.5, column: 1 }, + { path: 'src/b.ts', symbol: 'absent' }, + { path: 'src/b.ts', line: 1, symbol: 'nowhere' }, + { symbol: 'unknown' }, + { symbol: 'two\nlines' }, + { path: '../etc/passwd', line: 1, column: 1 }, + { path: 'linked/x.ts', line: 1, column: 1 }, + { path: 7 }, + ].map(async (args) => await reasonOf(t.ask('findReferences', args))), + ) + expect(reasons).toEqual([ + expect.stringContaining('name the symbol by path, line and column'), + expect.stringContaining('name the symbol by path, line and column'), + 'line and column must be whole numbers from 1', + 'line and column must be whole numbers from 1', + 'line and column must be whole numbers from 1', + '`absent` does not occur in src/b.ts', + '`nowhere` does not occur in src/b.ts:1', + expect.stringContaining('no workspace symbol is named `unknown`'), + 'symbol must be a single line of 1 to 200 characters', + 'path ../etc/passwd is outside the workspace', + 'path linked/x.ts leads outside the workspace through a link', + expect.stringContaining('invalid arguments'), + ]) + }) + + it('says "no language service" for a file none answers, and "none here" for one that does', async () => { + const t = setup({ symbols: { [B]: [GREET] } }) + const refused = await refusalOf( + t.ask('findDefinition', { path: 'notes.txt', line: 1, column: 1 }), + ) + expect(refused.reason).toContain( + 'no language service answered for notes.txt (language plaintext)', + ) + expect(refused.visibleReason).toBe(fill(UI_TEXT.codeIntelNoService, { path: 'notes.txt' })) + // The file's service answers (it lists symbols), but may not provide this. + expect(await textOf(t.ask('findReferences', { path: 'src/b.ts', line: 1, column: 1 }))).toBe( + "No references at src/b.ts:1:1: the file's language service found none there. Not every language's service provides references, so use search to be sure.", + ) + }) + + it('sorts, removes repeats and caps a long answer, counting the rest', async () => { + const many: CodeLocation[] = Array.from({ length: CODE_INTEL_MAX_LOCATIONS + 50 }, (_, index) => + loc(index % 2 === 0 ? B : A, index, 0), + ) + const t = setup({ references: () => [...many, many[0]!, many[1]!] }) + const text = await textOf(t.ask('findReferences', { path: 'src/a.ts', symbol: 'greet' })) + const lines = text.split('\n').slice(1) + expect(lines).toHaveLength(CODE_INTEL_MAX_LOCATIONS + 1) + expect(lines[0]).toBe('src/a.ts:2:1: return `hi ${name}`') + expect(lines.at(-1)).toBe('[50 more not shown]') + expect(lines.filter((line) => line.startsWith('src/b.ts'))).toHaveLength(25) + }) + + it('places a result by its real path when the workspace is opened through a link', async () => { + const real = '/real/ws' + const t = setup({ + realPath: (path) => + path === '/broken' + ? Promise.reject(new Error('EACCES')) + : Promise.resolve(path.startsWith(ROOT) ? `${real}${path.slice(ROOT.length)}` : path), + definitions: () => [loc(`${real}/src/a.ts`, 0, 16), loc('/broken', 0, 0)], + }) + const text = await textOf(t.ask('findDefinition', { path: 'src/b.ts', line: 2, column: 1 })) + expect(text).toBe( + [ + 'src/a.ts:1:17', + '[left out 1 outside the workspace: library declarations or other folders]', + ].join('\n'), + ) + }) + + it('ends a call the language service does not answer in time', async () => { + vi.useFakeTimers() + const t = setup({ definitions: () => new Promise(() => undefined) }) + const pending = t.ask('findDefinition', { path: 'src/b.ts', line: 2, column: 1 }) + await vi.advanceTimersByTimeAsync(20_000) + const refused = await refusalOf(pending) + expect(refused.reason).toContain('did not answer within 20 seconds') + expect(refused.visibleReason).toBe(fill(UI_TEXT.codeIntelTimedOut, { seconds: 20 })) + }) + + it("passes the language service's own failure on", async () => { + const t = setup({ definitions: () => Promise.reject(new Error('the server crashed')) }) + await expect(t.ask('findDefinition', { path: 'src/b.ts', line: 2, column: 1 })).rejects.toThrow( + 'the server crashed', + ) + }) +}) + +describe('workspace_symbols and document_symbols', () => { + it('lists exact names first, then by place, with containers and the count outside', async () => { + const t = setup({ + workspace: [ + sym('greeter', KIND.class, A, 0, 0), + sym('Greet', KIND.function, A, 2, 0), + sym('greet', KIND.method, B, 4, 2, { container: 'Host' }), + sym('greet', KIND.function, LIB, 0, 0), + { ...GREET, name: 'greet()', selection: { start: START, end: GREET.selection.end } }, + ], + }) + expect(await textOf(t.ask('workspaceSymbols', { query: 'greet' }))).toBe( + [ + 'src/a.ts:1:17: function greet()', + 'src/b.ts:5:3: method greet (in Host)', + 'src/a.ts:3:1: function Greet', + 'src/a.ts:1:1: class greeter', + '[left out 1 outside the workspace: library declarations or other folders]', + ].join('\n'), + ) + expect(await textOf(t.ask('workspaceSymbols', { query: 'nothing' }))).toContain( + 'No workspace symbols match `nothing` in the workspace.', + ) + expect(await reasonOf(t.ask('workspaceSymbols', { query: '' }))).toBe( + 'query must be a single line of 1 to 200 characters', + ) + }) + + it("outlines a file's symbols to their depth, counting the deeper ones", async () => { + const deepest = sym('inner', KIND.variable, A, 3, 8) + const local = sym('count', KIND.variable, A, 2, 6, { children: [deepest] }) + const method = sym('run', KIND.method, A, 1, 2, { detail: '(x: number)', children: [local] }) + const t = setup({ + symbols: { + [A]: [ + sym('LATER', KIND.constant, A, 9, 0), + sym('Host', KIND.class, A, 0, 0, { children: [method] }), + ], + }, + }) + expect(await textOf(t.ask('documentSymbols', { path: 'src/a.ts' }))).toBe( + [ + 'src/a.ts (typescript):', + '1:1 class Host', + ' 2:3 method run (x: number)', + ' 3:7 variable count', + '10:1 constant LATER', + '[1 more not shown]', + ].join('\n'), + ) + const refused = await refusalOf(t.ask('documentSymbols', { path: 'notes.txt' })) + expect(refused.reason).toContain('no language service answered for notes.txt') + }) +}) + +describe('hover', () => { + it("joins the hover's parts, clips a long one, and says when there is none", async () => { + const t = setup({ + symbols: { [B]: [GREET] }, + hover: (path) => + path === A + ? ['', '```ts\nfunction greet(name: string): string\n```', ' Says hi. '] + : ['x'.repeat(CODE_INTEL_HOVER_MAX_CHARS + 10)], + }) + expect(await textOf(t.ask('hover', { path: 'src/a.ts', line: 1, column: 17 }))).toBe( + '```ts\nfunction greet(name: string): string\n```\n\nSays hi.', + ) + const long = await textOf(t.ask('hover', { path: 'src/z.ts', line: 1, column: 1 })) + expect(long).toHaveLength(CODE_INTEL_HOVER_MAX_CHARS) + expect(long.endsWith('…')).toBe(true) + const none = setup({ symbols: { [B]: [GREET] }, hover: () => [' '] }) + expect(await textOf(none.ask('hover', { path: 'src/b.ts', line: 1, column: 1 }))).toContain( + 'No hover information at src/b.ts:1:1:', + ) + }) + + it('holds back the hover of a symbol defined only outside the workspace and its libraries', async () => { + const secret = ['```ts\nconst TOKEN: "sk-live-123"\n```'] + const outside = setup({ hover: () => secret, definitions: () => [loc(OTHER, 0, 13)] }) + const heldBack = await textOf(outside.ask('hover', { path: 'src/b.ts', line: 2, column: 1 })) + expect(heldBack).toBe( + 'The hover is held back: this symbol is defined only outside the workspace (1 definitions), in files the tools do not show.', + ) + expect(heldBack).not.toContain('sk-live') + // A language's bundled library, a workspace file among the definitions, + // or no definition at all keep the hover. + const library = setup({ + hover: () => secret, + definitions: () => [loc(LIB, 0, 0)], + libraryRoots: ['/lib'], + }) + const mixed = setup({ + hover: () => secret, + definitions: () => [loc(OTHER, 0, 0), loc(A, 0, 16)], + }) + const bare = setup({ hover: () => secret }) + for (const t of [library, mixed, bare]) { + expect(await textOf(t.ask('hover', { path: 'src/b.ts', line: 2, column: 1 }))).toContain( + 'sk-live', + ) + } + const unresolvable = setup({ + hover: () => secret, + definitions: () => [loc('/elsewhere/x.ts', 0, 0)], + libraryRoots: ['/lib'], + realPath: (path) => + path === '/elsewhere/x.ts' ? Promise.reject(new Error('EACCES')) : Promise.resolve(path), + }) + expect( + await textOf(unresolvable.ask('hover', { path: 'src/b.ts', line: 2, column: 1 })), + ).toContain('held back') + }) +}) + +describe('files with unsaved changes', () => { + it("refuses a position there, and answers from the editor's lines with a note", async () => { + const edited = "import { greet } from './a'\n// a new line\ngreet('x')\n" + const t = setup({ + dirty: new Set([B]), + buffers: { [B]: edited }, + references: () => [loc(B, 2, 0), loc(A, 0, 16)], + }) + t.io.unsaved.add(B) + expect(await reasonOf(t.ask('findReferences', { path: 'src/b.ts', line: 2, column: 1 }))).toBe( + 'src/b.ts has unsaved changes in an editor, so its lines differ from what read_file shows; name the symbol without a line, or ask the user to save the file', + ) + // By name, the editor's text is where the name is found, and the lines + // shown are the editor's, said so. + expect(await textOf(t.ask('findReferences', { path: 'src/b.ts', symbol: 'greet' }))).toBe( + [ + 'Using `greet` at src/b.ts:1:10.', + 'src/a.ts:1:17: export function greet(name: string): string {', + "src/b.ts:3:1: greet('x')", + "[unsaved changes in an editor: src/b.ts; their lines here are the editor's, not what read_file shows]", + ].join('\n'), + ) + }) + + it('finds them by the real path when the workspace is opened through a link', async () => { + // The editor names b.ts by the link; the language service by its real path. + const link = '/link/ws' + const edited = "import { greet } from './a'\n// a new line\ngreet('x')\n" + const t = setup({ + workspaceRoot: link, + links: { [link]: ROOT }, + realPath: realPathThrough(link, ROOT), + dirty: new Set([`${link}/src/b.ts`]), + buffers: { [`${link}/src/b.ts`]: edited }, + references: () => [loc(B, 2, 0), loc(A, 0, 16)], + }) + t.io.unsaved.add(`${link}/src/b.ts`) + expect(await textOf(t.ask('findReferences', { path: 'src/a.ts', symbol: 'greet' }))).toBe( + [ + 'Using `greet` at src/a.ts:1:17.', + 'src/a.ts:1:17: export function greet(name: string): string {', + "src/b.ts:3:1: greet('x')", + "[unsaved changes in an editor: src/b.ts; their lines here are the editor's, not what read_file shows]", + ].join('\n'), + ) + // An editor holding the real path: a position named through the link is refused. + const real = setup({ + workspaceRoot: link, + links: { [link]: ROOT }, + realPath: realPathThrough(link, ROOT), + dirty: new Set([B]), + buffers: { [B]: edited }, + // The service outlines the editor's document, the one it has open. + symbols: { [B]: [sym('main', KIND.function, B, 2, 0)] }, + }) + real.io.unsaved.add(B) + expect( + await reasonOf(real.ask('findReferences', { path: 'src/b.ts', line: 3, column: 1 })), + ).toContain('src/b.ts has unsaved changes in an editor') + // Its outline is the editor's, asked at the editor's path, and said so. + expect(await textOf(real.ask('documentSymbols', { path: 'src/b.ts' }))).toBe( + [ + 'src/b.ts (typescript):', + '3:1 function main', + "[unsaved changes in an editor: src/b.ts; their lines here are the editor's, not what read_file shows]", + ].join('\n'), + ) + }) +}) + +describe('call_hierarchy', () => { + const caller = sym('main', KIND.function, B, 0, 9) + const ranges = Array.from({ length: 7 }, (_, index) => loc(B, index + 1, 2).range) + + it('lists the callers with their call sites, and the callees the other way', async () => { + const log = sym('log', KIND.method, LIB, 0, 0) + const t = setup({ + symbols: { [B]: [caller] }, + calls: (path, _at, direction) => { + if (direction === 'incoming') { + return { + item: GREET, + calls: [ + { symbol: caller, ranges }, + { symbol: sym('lib', KIND.function, LIB, 0, 0), ranges: [] }, + ], + otherItems: 0, + } + } + // Outgoing: the sites are in the file of the function asked about + // (main in b.ts; log outside), not in the callee's. + return path === B + ? { + item: caller, + calls: [{ symbol: GREET, ranges: [loc(B, 1, 0).range] }], + otherItems: 1, + } + : { item: log, calls: [{ symbol: GREET, ranges: [loc(LIB, 4, 0).range] }], otherItems: 0 } + }, + }) + expect(await textOf(t.ask('callHierarchy', { path: 'src/a.ts', line: 1, column: 17 }))).toBe( + [ + 'Calls to function greet at src/a.ts:1:17:', + 'src/b.ts:1:10: function main (calls at 2:3, 3:3, 4:3, 5:3, 6:3, +2)', + '[left out 1 outside the workspace: library declarations or other folders]', + ].join('\n'), + ) + expect( + await textOf( + t.ask('callHierarchy', { path: 'src/b.ts', line: 1, column: 10, direction: 'outgoing' }), + ), + ).toBe( + [ + 'Calls from function main at src/b.ts:1:10:', + 'src/a.ts:1:17: function greet (called at src/b.ts:2:1)', + "[1 more functions share this position (overloads or merged declarations) and were not asked; ask at each one's own declaration for its calls]", + ].join('\n'), + ) + expect( + await textOf( + t.ask('callHierarchy', { path: 'src/a.ts', line: 1, column: 17, direction: 'outgoing' }), + ), + ).toBe( + [ + 'Calls from method log at outside the workspace:', + 'src/a.ts:1:17: function greet (called at 5:1 of its file outside the workspace)', + ].join('\n'), + ) + }) + + it('says why there is no hierarchy: nothing callable here, or no language service', async () => { + const t = setup({ symbols: { [B]: [caller] } }) + expect( + await reasonOf(t.ask('callHierarchy', { path: 'src/b.ts', line: 1, column: 1 })), + ).toContain('nothing at src/b.ts:1:1 has a call hierarchy here') + expect( + await reasonOf(t.ask('callHierarchy', { path: 'notes.txt', line: 1, column: 1 })), + ).toContain('no language service answered for notes.txt') + }) +}) diff --git a/test/unit/codeText.test.ts b/test/unit/codeText.test.ts new file mode 100644 index 00000000..cd6b43b4 --- /dev/null +++ b/test/unit/codeText.test.ts @@ -0,0 +1,115 @@ +// The code intelligence tools' text work (M67): names found as whole words +// at VS Code's positions, a language service's edits applied, and the +// hunks a rename leaves, which Edit Review and rewind must be able to undo. + +import { describe, expect, it } from 'vitest' +import { + applyTextEdits, + BOM, + findName, + patchHunks, + textIn, + unifiedDiff, +} from '../../src/core/codeIntel/codeText' +import { revertHunks } from '../../src/core/patchApply' + +const START = { line: 0, character: 0 } + +function at(line: number, character: number) { + return { line, character } +} + +function edit(line: number, from: number, to: number, newText: string) { + return { range: { start: at(line, from), end: at(line, to) }, newText } +} + +describe('findName', () => { + it('finds whole words only, literally, from a position and within a line', () => { + const text = 'greeting greet\r\nx.greet(a$greet)\rgreet' + expect(findName(text, 'greet', START)).toEqual(at(0, 9)) + expect(findName(text, 'greet', at(1, 0))).toEqual(at(1, 2)) + expect(findName(text, 'greet', at(1, 3))).toEqual(at(2, 0)) + expect(findName(text, 'greet', at(1, 3), 1)).toBeUndefined() + expect(findName('a.b(c) a.b', 'a.b', at(0, 1))).toEqual(at(0, 7)) + expect(findName(text, '', START)).toBeUndefined() + expect(findName(text, 'greet', at(9, 0))).toBeUndefined() + expect(findName(text, 'greet', at(0, 99))).toBeUndefined() + }) +}) + +describe('applyTextEdits and textIn', () => { + it('applies edits against the original positions, whatever their order', () => { + const text = 'let old = 1\r\nold + old\n' + const edits = [edit(1, 6, 9, 'fresh'), edit(0, 4, 7, 'fresh'), edit(1, 0, 3, 'fresh')] + expect(applyTextEdits(text, edits)).toBe('let fresh = 1\r\nfresh + fresh\n') + expect(textIn(text, at(1, 6), at(1, 9))).toBe('old') + }) + + it('refuses edits outside the text or overlapping each other', () => { + const text = 'abc\ndef' + expect(applyTextEdits(text, [edit(5, 0, 1, 'x')])).toBeUndefined() + expect(applyTextEdits(text, [edit(0, 0, 9, 'x')])).toBeUndefined() + expect(applyTextEdits(text, [edit(0, 2, 1, 'x')])).toBeUndefined() + expect(applyTextEdits(text, [edit(0, 0, 2, 'x'), edit(0, 1, 3, 'y')])).toBeUndefined() + expect(textIn(text, at(0, 2), at(0, 1))).toBeUndefined() + }) +}) + +describe('patchHunks', () => { + const before = Array.from({ length: 20 }, (_, index) => `line ${String(index)} old`).join('\n') + + it('makes one hunk per group of changed lines, which revert undoes', () => { + const after = before.replace('line 1 old', 'line 1 new').replace('line 3 old', 'line 3 new') + const changed = after.replace('line 15 old', 'line 15 new') + const hunks = patchHunks(`${BOM}${before}\n`, `${BOM}${changed}\n`) + expect(hunks).toHaveLength(2) + expect(hunks[0]).toMatchObject({ oldStart: 1, oldLines: 7, newStart: 1, newLines: 7 }) + expect(hunks[0]?.lines.slice(0, 4)).toEqual([ + ' line 0 old', + '-line 1 old', + '+line 1 new', + ' line 2 old', + ]) + expect(hunks[1]).toMatchObject({ oldStart: 13, oldLines: 7 }) + expect(revertHunks(`${changed}\n`, hunks)).toEqual({ + ok: true, + content: `${before}\n`, + isCreatedFile: false, + }) + }) + + it('makes one hunk around the change when the line count changes', () => { + const after = before.replace('line 10 old', 'line 10 new\nline 10b') + const hunks = patchHunks(before, after) + expect(hunks).toEqual([ + { + oldStart: 8, + oldLines: 7, + newStart: 8, + newLines: 8, + lines: [ + ' line 7 old', + ' line 8 old', + ' line 9 old', + '-line 10 old', + '+line 10 new', + '+line 10b', + ' line 11 old', + ' line 12 old', + ' line 13 old', + ], + }, + ]) + expect(revertHunks(after, hunks)).toMatchObject({ ok: true, content: before }) + expect(patchHunks('', 'one\n')).toEqual([ + { oldStart: 0, oldLines: 0, newStart: 1, newLines: 1, lines: ['+one'] }, + ]) + }) + + it('writes the hunks as a unified diff', () => { + const hunks = patchHunks('a\nb\n', 'a\nc\n') + expect(unifiedDiff('src/x.ts', hunks)).toBe( + ['--- a/src/x.ts', '+++ b/src/x.ts', '@@ -1,2 +1,2 @@', ' a', '-b', '+c'].join('\n'), + ) + }) +}) diff --git a/test/unit/helpers/fakeLanguageService.ts b/test/unit/helpers/fakeLanguageService.ts new file mode 100644 index 00000000..106b7c9c --- /dev/null +++ b/test/unit/helpers/fakeLanguageService.ts @@ -0,0 +1,171 @@ +// A fake of VS Code's language services for the code intelligence tests +// (M67): documents read from the in-memory tool files (as VS Code holds +// them, BOM left out), and whatever answers a test gives each provider. A +// provider a test does not give answers as a language with no service does: +// nothing. + +import type { + CallDirection, + CallHierarchyAnswer, + CodeLocation, + CodePosition, + CodeSymbol, + LanguageServiceHost, + RenameEdits, +} from '../../../src/core/codeIntel/languageService' + +const BOM = '\u{FEFF}' +// VS Code's `SymbolKind` values the tests use. +export const KIND = { class: 4, method: 5, function: 11, variable: 12, constant: 13 } as const + +type Answer = (path: string, at: CodePosition) => T | Promise + +export interface FakeServiceOptions { + /** The documents' text by absolute path, read when a document is opened. */ + readonly files: ReadonlyMap + /** Absolute paths an editor holds unsaved changes to. */ + readonly dirty?: ReadonlySet + /** A document's text in VS Code when it differs from the disk. */ + readonly buffers?: Readonly> + readonly symbols?: Readonly> + readonly workspace?: readonly CodeSymbol[] | ((query: string) => Promise) + readonly definitions?: Answer + readonly references?: Answer + readonly hover?: Answer + readonly calls?: ( + path: string, + at: CodePosition, + direction: CallDirection, + ) => CallHierarchyAnswer | undefined + readonly rename?: (path: string, at: CodePosition, newName: string) => Promise + /** Folders a hover may describe outside the workspace; none unless given. */ + readonly libraryRoots?: readonly string[] + /** + * A folder an editor names files by (a link, such as the workspace opened + * through one) → the real folder the files are kept under. + */ + readonly links?: Readonly> +} + +/** The path with a linked folder replaced by the real one. */ +function throughLinks(path: string, links: Readonly> = {}): string { + for (const [link, real] of Object.entries(links)) { + if (path.startsWith(`${link}/`)) { + return `${real}${path.slice(link.length)}` + } + } + return path +} + +export interface FakeLanguageService extends LanguageServiceHost { + /** Each provider asked, in order: `definitions a.ts 2:5` and so on. */ + readonly asked: string[] +} + +const LANGUAGES: Readonly> = { + ts: 'typescript', + md: 'markdown', + txt: 'plaintext', +} + +function languageOf(path: string): string { + const extension = path.slice(path.lastIndexOf('.') + 1) + return LANGUAGES[extension] ?? extension +} + +function describeAt(path: string, at: CodePosition): string { + return `${path} ${String(at.line)}:${String(at.character)}` +} + +/** A location in `path` at a 0-based line and column, `length` characters long. */ +export function loc( + path: string | undefined, + line: number, + character: number, + length = 1, +): CodeLocation { + return { + path, + range: { start: { line, character }, end: { line, character: character + length } }, + } +} + +/** A symbol named `name` whose name starts at the 0-based line and column. */ +export function sym( + name: string, + kind: number, + path: string | undefined, + line: number, + character: number, + extra: Partial> = {}, +): CodeSymbol { + const location = loc(path, line, character, name.length) + return { + name, + kind, + detail: extra.detail, + container: extra.container, + location, + selection: location.range, + children: extra.children ?? [], + } +} + +/** What a provider a test does not give answers: nothing, as with no language service. */ +function none(): readonly never[] { + return [] +} + +export function fakeLanguageService(options: FakeServiceOptions): FakeLanguageService { + const asked: string[] = [] + return { + asked, + open: (path) => { + asked.push(`open ${path}`) + const text = options.buffers?.[path] ?? options.files.get(throughLinks(path, options.links)) + if (text === undefined) { + return Promise.reject(new Error(`cannot open ${path}`)) + } + return Promise.resolve({ + languageId: languageOf(path), + text: text.startsWith(BOM) ? text.slice(BOM.length) : text, + isDirty: options.dirty?.has(path) === true, + }) + }, + definitions: async (path, at) => { + asked.push(`definitions ${describeAt(path, at)}`) + return await (options.definitions ?? none)(path, at) + }, + references: async (path, at) => { + asked.push(`references ${describeAt(path, at)}`) + return await (options.references ?? none)(path, at) + }, + hover: async (path, at) => { + asked.push(`hover ${describeAt(path, at)}`) + return await (options.hover ?? none)(path, at) + }, + documentSymbols: (path) => { + asked.push(`symbols ${path}`) + return Promise.resolve(options.symbols?.[path] ?? []) + }, + workspaceSymbols: async (query) => { + asked.push(`workspace ${query}`) + const { workspace } = options + return typeof workspace === 'function' + ? await workspace(query) + : (workspace ?? []).filter((symbol) => + symbol.name.toLowerCase().includes(query.toLowerCase()), + ) + }, + callHierarchy: (path, at, direction) => { + asked.push(`calls ${direction} ${describeAt(path, at)}`) + return Promise.resolve(options.calls?.(path, at, direction)) + }, + rename: async (path, at, newName) => { + asked.push(`rename ${describeAt(path, at)} ${newName}`) + return await (options.rename?.(path, at, newName) ?? + Promise.resolve({ files: [], fileOperations: 'none' })) + }, + libraryRoots: () => options.libraryRoots ?? [], + } +} diff --git a/test/unit/helpers/fakeToolIo.ts b/test/unit/helpers/fakeToolIo.ts index afcd260d..31198fa1 100644 --- a/test/unit/helpers/fakeToolIo.ts +++ b/test/unit/helpers/fakeToolIo.ts @@ -23,6 +23,14 @@ function keyOf(absolutePath: string): string { return absolutePath.replaceAll('\\', '/') } +/** A `realPath` for a workspace opened through `link`, a link to `real`. */ +export function realPathThrough(link: string, real: string): (path: string) => Promise { + return (path) => + Promise.resolve( + path === link || path.startsWith(`${link}/`) ? real + path.slice(link.length) : path, + ) +} + export function memoryToolIo( initial: Record, root: string, @@ -67,6 +75,7 @@ export function memoryToolIo( shellCalls, unsaved, hasUnsavedChanges: (absolutePath) => unsaved.has(absolutePath.replaceAll('\\', '/')), + unsavedFiles: () => [...unsaved], realPath: (absolutePath) => { const forward = absolutePath.replaceAll('\\', '/') for (const [relative, target] of Object.entries(links)) { @@ -126,6 +135,7 @@ export const noopToolIo: ToolIo = { reserveFile: () => Promise.resolve({ fill: () => Promise.resolve(), release: () => Promise.resolve() }), hasUnsavedChanges: () => false, + unsavedFiles: () => [], listFiles: () => Promise.resolve([]), searchFiles: () => Promise.resolve({ ok: true, hits: [] }), runShell: () => diff --git a/test/unit/ideCodeIntelTools.test.ts b/test/unit/ideCodeIntelTools.test.ts new file mode 100644 index 00000000..672d060b --- /dev/null +++ b/test/unit/ideCodeIntelTools.test.ts @@ -0,0 +1,139 @@ +// The code intelligence tools on the `ide` server for Muse Code (M67, +// PLAN.md D49): listed with a folder open, each declaring itself read-only +// (MCP annotations), answering as the Model API's tools do, and a rename +// that returns its edits and writes nothing. + +import { describe, expect, it } from 'vitest' +import type { CodeIntelDeps } from '../../src/core/codeIntel/codeIntelQuery' +import { diagnosticsTool } from '../../src/core/diagnostics' +import { handleMcpMessage } from '../../src/core/mcp' +import { ideCodeIntelTools } from '../../src/host/ide/codeIntelTools' +import { IDE_MCP_SERVER_INFO } from '../../src/shared/constants' +import { fakeLanguageService, loc } from './helpers/fakeLanguageService' +import { memoryToolIo } from './helpers/fakeToolIo' + +const ROOT = '/ws' +const A = `${ROOT}/a.ts` +const FILES = { 'a.ts': 'export const answer = 42\n', 'b.ts': 'answer\n' } + +function tools() { + const io = memoryToolIo(FILES, ROOT) + const deps: CodeIntelDeps = { + service: fakeLanguageService({ + files: io.files, + definitions: () => [loc(A, 0, 13)], + rename: () => + Promise.resolve({ + files: [ + { + path: A, + edits: [ + { + range: { start: { line: 0, character: 13 }, end: { line: 0, character: 19 } }, + newText: 'result', + }, + ], + }, + ], + fileOperations: 'none' as const, + }), + }), + workspaceRoot: ROOT, + platform: 'linux', + io, + now: () => 0, + } + return { io, list: ideCodeIntelTools(deps) } +} + +/** A call's JSON-RPC result, or undefined when the server did not answer. */ +async function resultOf(name: string, args: Record) { + const answered = await call(name, args) + return answered.body?.['result'] +} + +async function call(name: string, args: Record) { + const { io, list } = tools() + const outcome = await handleMcpMessage( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name, arguments: args }, + }), + list, + IDE_MCP_SERVER_INFO, + ) + return { io, body: outcome.kind === 'response' ? outcome.body : undefined } +} + +describe('ide code intelligence tools', () => { + it('lists every tool as read-only, getDiagnostics too, and none without a folder', async () => { + expect(ideCodeIntelTools(undefined)).toEqual([]) + const diagnostics = diagnosticsTool({ + getDiagnostics: () => [], + workspaceRoot: ROOT, + platform: 'linux', + relativeInRoot: () => undefined, + }) + const outcome = await handleMcpMessage( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + [diagnostics, ...tools().list], + IDE_MCP_SERVER_INFO, + ) + const listed = outcome.kind === 'response' ? outcome.body['result'] : undefined + expect(listed).toMatchObject({ + tools: [ + 'getDiagnostics', + 'findDefinition', + 'findReferences', + 'workspaceSymbols', + 'documentSymbols', + 'hover', + 'callHierarchy', + 'repoMap', + 'renameSymbol', + ].map((name) => ({ name, annotations: { readOnlyHint: true } })), + }) + }) + + it('answers as the Model API tools do, and a refusal as an error result', async () => { + expect(await resultOf('findDefinition', { path: 'b.ts', symbol: 'answer' })).toEqual({ + content: [ + { type: 'text', text: 'Using `answer` at b.ts:1:1.\na.ts:1:14: export const answer = 42' }, + ], + }) + expect(await resultOf('hover', { path: '../x.ts', line: 1, column: 1 })).toMatchObject({ + content: [{ text: 'path ../x.ts is outside the workspace' }], + isError: true, + }) + }) + + it('returns a rename as a diff for Muse Code and writes nothing', async () => { + const { io, body } = await call('renameSymbol', { + path: 'a.ts', + symbol: 'answer', + new_name: 'result', + }) + expect(body).toMatchObject({ + result: { + content: [ + { + text: [ + 'The rename of `answer` to `result`: 1 edits in 1 files. This tool changed nothing: apply the diff below with your own edit tool.', + '--- a/a.ts', + '+++ b/a.ts', + '@@ -1,1 +1,1 @@', + '-export const answer = 42', + '+export const result = 42', + ].join('\n'), + }, + ], + }, + }) + expect(io.files.get(A)).toBe(FILES['a.ts']) + expect( + await resultOf('renameSymbol', { path: 'a.ts', symbol: 'nowhere', new_name: 'x' }), + ).toMatchObject({ isError: true }) + }) +}) diff --git a/test/unit/instructions.test.ts b/test/unit/instructions.test.ts index 39b0a3bb..b6b120bd 100644 --- a/test/unit/instructions.test.ts +++ b/test/unit/instructions.test.ts @@ -10,6 +10,7 @@ const base = { today: '2026-09-22', environment: { git: undefined }, hasMemory: false, + hasCodeIntel: false, } const noContext = { rules: undefined, skills: [], memory: [] } @@ -137,4 +138,20 @@ describe('instructionsFor', () => { expect(clean).toContain('- Git branch: main\n- Working tree at session start: clean.') expect(clean).not.toContain('Recent commits') }) + + it('names the code intelligence tools and pins the repo map before the goal (M67)', () => { + const without = instructionsFor({ ...base, hasShell: true, context: noContext }) + expect(without).not.toContain('find_definition') + const text = instructionsFor({ + ...base, + hasShell: true, + hasCodeIntel: true, + context: noContext, + repoMap: '# Repo map\n\nsrc/a.ts', + goalSection: '# Session goal', + }) + expect(text).toContain('prefer them to search when you look for where a symbol is defined') + expect(text.indexOf('# Repo map')).toBeGreaterThan(text.indexOf('# How to work')) + expect(text.indexOf('# Repo map')).toBeLessThan(text.indexOf('# Session goal')) + }) }) diff --git a/test/unit/languageServices.test.ts b/test/unit/languageServices.test.ts new file mode 100644 index 00000000..500c5cbc --- /dev/null +++ b/test/unit/languageServices.test.ts @@ -0,0 +1,261 @@ +// VS Code's language services as the code intelligence tools read them +// (M67): each `vscode.execute…` command's result converted to plain data, +// a result that is no file left without a path, and a rename's file +// operations noticed. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as vscode from 'vscode' +import { vscodeLanguageServices } from '../../src/host/codeIntel/languageServices' +import { VSCODE_COMMANDS } from '../../src/shared/constants' +import { extensions as mockExtensions } from './mocks/vscode' + +const FILE = '/ws/a.ts' +const AT = { line: 1, character: 2 } +const answers = new Map unknown>() + +function range(line: number, from: number, to: number) { + return { start: { line, character: from }, end: { line, character: to } } +} + +const fileUri = vscode.Uri.file(FILE) + +/** + * A `WorkspaceEdit` as VS Code's extension host builds one (1.125.0 and + * 1.139.0): `entries()` lists only the text edits and `size` counts them; + * `_allEntries()` lists everything, file operations (`_type` 1) included. + */ +function vsCodeWorkspaceEdit(allEntries: readonly Record[]) { + const entries = () => { + const byUri = new Map() + const listed: [unknown, unknown[]][] = [] + for (const entry of allEntries) { + if (entry['_type'] !== 2) { + continue + } + let known = byUri.get(entry['uri']) + if (known === undefined) { + known = [entry['uri'], []] + byUri.set(entry['uri'], known) + listed.push(known) + } + known[1].push(entry['edit']) + } + return listed + } + return { + entries, + get size() { + return entries().length + }, + _allEntries: () => allEntries, + } +} +const virtualUri = { scheme: 'untitled', fsPath: 'Untitled-1' } + +beforeEach(() => { + answers.clear() + vi.mocked(vscode.commands.executeCommand).mockReset() + vi.mocked(vscode.commands.executeCommand).mockImplementation((( + command: string, + ...args: unknown[] + ) => Promise.resolve(answers.get(command)?.(...args))) as typeof vscode.commands.executeCommand) +}) + +describe('vscodeLanguageServices', () => { + const services = vscodeLanguageServices() + + it('opens a document without showing it', async () => { + vi.mocked(vscode.workspace.openTextDocument).mockResolvedValue({ + languageId: 'typescript', + getText: () => 'text', + isDirty: true, + } as unknown as vscode.TextDocument) + expect(await services.open(FILE)).toEqual({ + languageId: 'typescript', + text: 'text', + isDirty: true, + }) + }) + + it('reads locations and links, asking at the position given', async () => { + answers.set(VSCODE_COMMANDS.executeDefinitionProvider, (uri, position) => { + expect(uri).toMatchObject({ fsPath: FILE }) + expect(position).toMatchObject(AT) + return [ + { uri: fileUri, range: range(0, 1, 2) }, + { targetUri: fileUri, targetRange: range(3, 0, 9), targetSelectionRange: range(3, 4, 5) }, + { targetUri: virtualUri, targetRange: range(4, 0, 1) }, + ] + }) + answers.set(VSCODE_COMMANDS.executeReferenceProvider, () => [ + { uri: fileUri, range: range(7, 0, 3) }, + ]) + expect(await services.definitions(FILE, AT)).toEqual([ + { path: FILE, range: range(0, 1, 2) }, + { path: FILE, range: range(3, 4, 5) }, + { path: undefined, range: range(4, 0, 1) }, + ]) + expect(await services.references(FILE, AT)).toEqual([{ path: FILE, range: range(7, 0, 3) }]) + answers.clear() + expect(await services.definitions(FILE, AT)).toEqual([]) + expect(await services.references(FILE, AT)).toEqual([]) + expect(await services.hover(FILE, AT)).toEqual([]) + expect(await services.documentSymbols(FILE)).toEqual([]) + expect(await services.workspaceSymbols('x')).toEqual([]) + }) + + it('reads hover parts as Markdown, code fenced, an unknown shape as it came', async () => { + answers.set(VSCODE_COMMANDS.executeHoverProvider, () => [ + { contents: ['plain', { language: 'ts', value: 'let x' }, { value: '**md**' }, 42] }, + ]) + expect(await services.hover(FILE, AT)).toEqual(['plain', '```ts\nlet x\n```', '**md**', '42']) + }) + + it('reads document symbols in both of their shapes, and workspace symbols', async () => { + const child = { + name: 'run', + kind: 5, + detail: '()', + range: range(2, 0, 9), + selectionRange: range(2, 2, 5), + children: [], + } + answers.set(VSCODE_COMMANDS.executeDocumentSymbolProvider, () => [ + { + name: 'Host', + kind: 4, + detail: '', + range: range(1, 0, 20), + selectionRange: range(1, 6, 10), + children: [child], + }, + { + name: 'flat', + kind: 12, + containerName: 'Host', + location: { uri: fileUri, range: range(9, 0, 4) }, + }, + ]) + answers.set(VSCODE_COMMANDS.executeWorkspaceSymbolProvider, (query) => [ + { + name: String(query), + kind: 11, + containerName: '', + location: { uri: virtualUri, range: range(0, 0, 1) }, + }, + ]) + const [host, flat] = await services.documentSymbols(FILE) + expect(host).toMatchObject({ + name: 'Host', + selection: range(1, 6, 10), + location: { path: FILE, range: range(1, 0, 20) }, + children: [{ name: 'run', detail: '()', selection: range(2, 2, 5) }], + }) + expect(flat).toMatchObject({ + name: 'flat', + container: 'Host', + selection: range(9, 0, 4), + children: [], + }) + expect(await services.workspaceSymbols('greet')).toMatchObject([ + { name: 'greet', kind: 11, location: { path: undefined } }, + ]) + }) + + it("names VS Code's installation and every extension's folder as library roots", () => { + mockExtensions.all = [{ extensionPath: '/ext/ms-python.python' }] + expect(services.libraryRoots()).toEqual(['/vscode/resources/app', '/ext/ms-python.python']) + }) + + it('prepares a call hierarchy and reads its calls either way', async () => { + const item = { + name: 'greet', + kind: 11, + uri: fileUri, + range: range(0, 0, 30), + selectionRange: range(0, 16, 21), + } + const caller = { ...item, name: 'main', detail: 'main.ts' } + answers.set(VSCODE_COMMANDS.prepareCallHierarchy, () => [item]) + answers.set(VSCODE_COMMANDS.provideIncomingCalls, () => [ + { from: caller, fromRanges: [range(5, 2, 7)] }, + ]) + answers.set(VSCODE_COMMANDS.provideOutgoingCalls, () => [ + { to: caller, fromRanges: [range(1, 2, 7)] }, + ]) + expect(await services.callHierarchy(FILE, AT, 'incoming')).toMatchObject({ + item: { name: 'greet', selection: range(0, 16, 21) }, + calls: [{ symbol: { name: 'main', detail: 'main.ts' }, ranges: [range(5, 2, 7)] }], + }) + expect(await services.callHierarchy(FILE, AT, 'outgoing')).toMatchObject({ + calls: [{ symbol: { name: 'main' }, ranges: [range(1, 2, 7)] }], + }) + answers.set(VSCODE_COMMANDS.provideIncomingCalls, () => undefined) + answers.set(VSCODE_COMMANDS.provideOutgoingCalls, () => undefined) + expect(await services.callHierarchy(FILE, AT, 'incoming')).toMatchObject({ calls: [] }) + expect(await services.callHierarchy(FILE, AT, 'outgoing')).toMatchObject({ calls: [] }) + answers.set(VSCODE_COMMANDS.prepareCallHierarchy, () => []) + expect(await services.callHierarchy(FILE, AT, 'incoming')).toBeUndefined() + }) + + it('asks the one of several call items declared at the position, and counts the others', async () => { + const at = { line: 3, character: 18 } + const elsewhere = { name: 'greet', kind: 11, uri: vscode.Uri.file('/ws/b.ts') } + const items = [ + { ...elsewhere, range: range(3, 0, 30), selectionRange: range(3, 16, 21) }, + { ...elsewhere, uri: fileUri, range: range(0, 0, 30), selectionRange: range(0, 16, 21) }, + { ...elsewhere, uri: fileUri, range: range(3, 0, 30), selectionRange: range(3, 16, 21) }, + ] + answers.set(VSCODE_COMMANDS.prepareCallHierarchy, () => items) + answers.set(VSCODE_COMMANDS.provideIncomingCalls, (item) => [ + { from: item, fromRanges: [range(9, 0, 5)] }, + ]) + expect(await services.callHierarchy(FILE, at, 'incoming')).toMatchObject({ + item: { location: { path: FILE }, selection: range(3, 16, 21) }, + otherItems: 2, + }) + // None declared at the position: the first. + expect(await services.callHierarchy(FILE, { line: 7, character: 0 }, 'incoming')).toMatchObject( + { item: { location: { path: '/ws/b.ts' } }, otherItems: 2 }, + ) + }) + + it("reads a rename's edits, notices file operations, and passes a refusal on", async () => { + const edits = [{ range: range(0, 16, 21), newText: 'welcome' }] + let workspaceEdit: unknown = vsCodeWorkspaceEdit([{ _type: 2, uri: fileUri, edit: edits[0] }]) + answers.set(VSCODE_COMMANDS.executeDocumentRenameProvider, (_uri, _position, newName) => { + expect(newName).toBe('welcome') + return workspaceEdit + }) + expect(await services.rename(FILE, AT, 'welcome')).toEqual({ + files: [{ path: FILE, edits: [{ range: range(0, 16, 21), newText: 'welcome' }] }], + fileOperations: 'none', + }) + // A file moved by the rename: VS Code's `entries()` and `size` do not show it. + workspaceEdit = vsCodeWorkspaceEdit([ + { _type: 2, uri: fileUri, edit: edits[0] }, + { _type: 1, from: fileUri, to: virtualUri }, + ]) + expect(await services.rename(FILE, AT, 'welcome')).toMatchObject({ + files: [{ path: FILE }], + fileOperations: 'present', + }) + // A VS Code whose edit no longer says: the rename is refused as unknown. + workspaceEdit = { size: 1, entries: () => [[fileUri, edits]] } + expect(await services.rename(FILE, AT, 'welcome')).toMatchObject({ fileOperations: 'unknown' }) + workspaceEdit = { entries: () => [[fileUri, edits]], _allEntries: () => [{ kind: 'text' }] } + expect(await services.rename(FILE, AT, 'welcome')).toMatchObject({ fileOperations: 'unknown' }) + answers.delete(VSCODE_COMMANDS.executeDocumentRenameProvider) + expect(await services.rename(FILE, AT, 'welcome')).toEqual({ + files: [], + fileOperations: 'none', + }) + vi.mocked(vscode.commands.executeCommand).mockImplementation(((command: string) => + command === VSCODE_COMMANDS.prepareRename + ? Promise.reject(new Error('You cannot rename this element.')) + : Promise.resolve(undefined)) as typeof vscode.commands.executeCommand) + await expect(services.rename(FILE, AT, 'welcome')).rejects.toThrow( + 'You cannot rename this element.', + ) + }) +}) diff --git a/test/unit/mocks/vscode.ts b/test/unit/mocks/vscode.ts index a89925e4..521de7f0 100644 --- a/test/unit/mocks/vscode.ts +++ b/test/unit/mocks/vscode.ts @@ -101,10 +101,27 @@ export const workspace = { // The Memory view's delete, to the trash (M49). delete: vi.fn(), }, + // The code intelligence tools' documents (M67). + openTextDocument: vi.fn<(uri: vscode.Uri) => Promise>(), +} + +/** A position, as the language-service commands take one (M67). */ +export class Position { + public constructor( + public readonly line: number, + public readonly character: number, + ) {} } export const env = { openExternal: vi.fn(), + // Where VS Code is installed (M67: the built-in languages' libraries). + appRoot: '/vscode/resources/app', +} + +/** The installed extensions, by folder (M67): a test sets the list it needs. */ +export const extensions: { all: readonly Pick, 'extensionPath'>[] } = { + all: [], } export const commands = { diff --git a/test/unit/modelApiCodeIntel.test.ts b/test/unit/modelApiCodeIntel.test.ts new file mode 100644 index 00000000..3b8a7fd1 --- /dev/null +++ b/test/unit/modelApiCodeIntel.test.ts @@ -0,0 +1,674 @@ +// The code intelligence tools on the Model API backend (M67, PLAN.md D49), +// through the host on the fake Model API: reads that run in every mode +// (Restricted Mode included), a rename that asks and writes like an edit +// (protected paths, Plan, a file changed while the card was open), and the +// opt-in repo map in the system prompt. + +import { describe, expect, it, vi } from 'vitest' +import { ModelApiHost } from '../../src/core/backends/modelapi/ModelApiHost' +import type { AgentEvent } from '../../src/shared/agentEvents' +import { type HookDefinition, parseHookConfig } from '../../src/core/backends/modelapi/hooks' +import { MODEL_TEXT, type PaidFeature } from '../../src/shared/constants' +import { memoryContextIo } from './helpers/fakeContextIo' +import { + type FakeServiceOptions, + fakeLanguageService, + KIND, + loc, + sym, +} from './helpers/fakeLanguageService' +import { FakeLogOutputChannel } from './helpers/fakes' +import { + FAKE_MODEL_API_ACCOUNT_ID, + fakeModelApi, + fakeModelApiClient, + type ScriptedCall, +} from './helpers/fakeModelApi' +import { disabledPaidFeatures } from './helpers/fakePaidFeatures' +import { memoryToolIo, realPathThrough } from './helpers/fakeToolIo' + +const ROOT = '/ws' +const A = `${ROOT}/src/a.ts` +const B = `${ROOT}/src/b.ts` +const TASKS = `${ROOT}/.vscode/tasks.ts` +const FILES = { + 'src/a.ts': 'export function greet() {}\n', + 'src/b.ts': "import { greet } from './a'\ngreet()\n", + '.vscode/tasks.ts': 'greet()\n', +} +const TOOLS = [ + 'find_definition', + 'find_references', + 'workspace_symbols', + 'document_symbols', + 'hover', + 'call_hierarchy', + 'repo_map', + 'rename_symbol', +] +const RENAME: ScriptedCall = { + name: 'rename_symbol', + arguments: JSON.stringify({ path: 'src/a.ts', symbol: 'greet', new_name: 'welcome' }), +} + +/** A rename of `greet` at the start of a file's first line. */ +function renamedFile(path: string) { + return renamed(path, 0, 0) +} + +function renamed(path: string, line: number, character: number) { + return { + path, + edits: [ + { + range: { start: { line, character }, end: { line, character: character + 5 } }, + newText: 'welcome', + }, + ], + } +} + +interface StartOptions { + readonly approvalMode?: string + readonly isTrusted?: boolean + /** The language services' answers; null runs the host without them. */ + readonly service?: Omit | null + readonly isRepoMapOn?: () => boolean + /** Paid child tasks on, their popup allowing each (M48), for the child's prompt. */ + readonly hasSubagents?: boolean + /** Hooks from Muse Code's settings (M51), and what each one received on its stdin. */ + readonly hooks?: readonly HookDefinition[] + readonly hookPayloads?: unknown[] + /** The folder the workspace is opened as: a link to the files' own (/ws). */ + readonly linkedAs?: string +} + +async function start(options: StartOptions = {}) { + const api = fakeModelApi() + const io = memoryToolIo(FILES, ROOT) + const root = options.linkedAs ?? ROOT + if (options.linkedAs !== undefined) { + io.realPath = realPathThrough(options.linkedAs, ROOT) + } + const payloads = options.hookPayloads + if (payloads !== undefined) { + io.runHook = (_command, payload) => { + payloads.push(JSON.parse(payload)) + return Promise.resolve({ + stdout: '{}', + stderr: '', + exitCode: 0, + isTimedOut: false, + isCancelled: false, + }) + } + } + const service = + options.service === null + ? undefined + : fakeLanguageService({ + files: io.files, + ...(options.linkedAs !== undefined && { links: { [options.linkedAs]: ROOT } }), + ...options.service, + }) + let ids = 0 + const log = new FakeLogOutputChannel() + // Built directly on POSIX paths: the manager would take this machine's platform. + const host = new ModelApiHost({ + ...disabledPaidFeatures, + ...(options.hasSubagents === true && { + isPaidFeatureOn: (feature: PaidFeature) => feature === 'subagents', + allowsPaidUse: () => Promise.resolve(true), + }), + client: fakeModelApiClient(api, log), + log, + workspaceRoot: root, + platform: 'linux', + io, + contextIo: memoryContextIo(io.files), + newId: () => { + ids += 1 + return `n${String(ids)}` + }, + now: () => 0, + personalSkillsRoot: undefined, + isWorkspaceTrusted: () => options.isTrusted ?? true, + describeEnvironment: () => Promise.resolve({ git: undefined }), + promptCacheRetention: () => 'in_memory', + getAccountId: () => Promise.resolve(FAKE_MODEL_API_ACCOUNT_ID), + memory: undefined, + codeIntel: service, + isRepoMapInPrompt: options.isRepoMapOn, + loadHooks: () => Promise.resolve(options.hooks ?? []), + }) + const session = await host.startSession({ + workspaceRoot: root, + modelId: 'muse-spark-1.3', + approvalMode: options.approvalMode ?? 'promptUnmatched', + }) + const events: AgentEvent[] = [] + session.onEvent((event) => { + events.push(event) + }) + let turns = 0 + /** Sends a message whose reply makes these calls and then answers; waits for the turn's end. */ + const turn = async (calls: readonly ScriptedCall[], isCardExpected = false) => { + turns += 1 + const expected = turns + api.script({ calls }, { text: 'done' }) + await session.sendTurn([{ type: 'text', text: 'go' }]) + if (!isCardExpected) { + await vi.waitFor(() => { + expect(events.filter((event) => event.type === 'turnCompleted')).toHaveLength(expected) + }) + } + } + return { api, io, service, session, events, turn, host } +} + +type Started = Awaited> + +/** The tool rows' final states, in order. */ +function finished(events: readonly AgentEvent[]) { + return events.flatMap((event) => + event.type === 'itemCompleted' && event.item.kind === 'toolCall' ? [event.item] : [], + ) +} + +/** What the model was given back for its calls in the request after them. */ +function outputs(api: ReturnType): readonly string[] { + const input = api.responseBodies().at(-1)?.['input'] + return (Array.isArray(input) ? input : []).flatMap((item: unknown) => + typeof item === 'object' && + item !== null && + 'type' in item && + item.type === 'function_call_output' && + 'output' in item && + typeof item.output === 'string' + ? [item.output] + : [], + ) +} + +async function cardFor(events: readonly AgentEvent[]) { + await vi.waitFor(() => { + expect(events.some((event) => event.type === 'approvalRequested')).toBe(true) + }) + const card = events.find((event) => event.type === 'approvalRequested') + if (card?.type !== 'approvalRequested') { + throw new Error('no card') + } + return card +} + +/** Allows the card once, as the user would, and waits for the turn to end. */ +async function allowAndFinish(t: Started, card: Awaited>) { + await t.session.decideApproval({ + approvalId: card.approvalId, + choiceId: 'allow_once', + requirementId: card.requirementId, + }) + await vi.waitFor(() => { + expect(t.events.some((event) => event.type === 'turnCompleted')).toBe(true) + }) +} + +/** Presses Stop once `provider` has been asked, and waits for the turn to end as the `turns`th. */ +async function stopOnceAsked(t: Started, provider: string, turns = 1) { + await vi.waitFor(() => { + expect(t.service?.asked.some((call) => call.startsWith(provider))).toBe(true) + }) + await t.session.cancel() + await vi.waitFor(() => { + expect(t.events.filter((event) => event.type === 'turnCompleted')).toHaveLength(turns) + }) +} + +/** `greet` renamed where it is: its definition in a.ts, its import and call in b.ts. */ +const GREET_FILES = [renamed(A, 0, 16), renamed(B, 0, 9), renamed(B, 1, 0)] + +const GREET_EVERYWHERE = { + rename: () => Promise.resolve({ files: GREET_FILES, fileOperations: 'none' as const }), +} + +/** A project `PreToolUse` hook on `Edit`, which a rename matches. */ +const EDIT_HOOK = parseHookConfig( + JSON.stringify({ + hooks: { PreToolUse: [{ matcher: 'Edit', hooks: [{ type: 'command', command: 'guard' }] }] }, + }), + 'project', + 'linux', +).hooks + +describe('code intelligence on the Model API backend', () => { + it('offers the tools with their guidance only while language services are there', async () => { + const t = await start() + await t.turn([]) + const body = t.api.responseBodies()[0] ?? {} + const names = (body['tools'] as { name?: string }[]).map((tool) => tool.name) + expect(names).toEqual(expect.arrayContaining(TOOLS)) + expect(String(body['instructions'])).toContain(MODEL_TEXT.codeIntelInstructions) + const without = await start({ service: null }) + await without.turn([]) + const plain = without.api.responseBodies()[0] ?? {} + expect((plain['tools'] as { name?: string }[]).map((tool) => tool.name)).not.toContain('hover') + expect(String(plain['instructions'])).not.toContain(MODEL_TEXT.codeIntelInstructions) + }) + + it('reads without a card in Plan and in Restricted Mode, and says when no service answers', async () => { + const t = await start({ + approvalMode: 'denyUnmatched', + isTrusted: false, + service: { references: () => [loc(A, 0, 16), loc(B, 1, 0)] }, + }) + await t.turn([ + { name: 'find_references', arguments: '{"path":"src/b.ts","line":2,"column":1}' }, + { name: 'find_definition', arguments: '{"path":"src/a.ts","line":1,"column":1}' }, + { name: 'hover', arguments: 'not json' }, + ]) + expect(t.events.some((event) => event.type === 'approvalRequested')).toBe(false) + const [references, definition, hover] = outputs(t.api) + expect(references).toBe('src/a.ts:1:17: export function greet() {}\nsrc/b.ts:2:1: greet()') + expect(definition).toContain('Error: no language service answered for src/a.ts') + expect(hover).toBe('Error: arguments are not valid JSON') + expect(finished(t.events).map((item) => item.status)).toEqual(['completed', 'failed', 'failed']) + expect(finished(t.events)[1]?.failureReason).toBe( + 'No language service answered for src/a.ts, or it declares no symbols.', + ) + }) + + it('renames through the edit path: a card naming the files, then one patch across them', async () => { + const t = await start({ service: GREET_EVERYWHERE }) + await t.turn([RENAME], true) + const card = await cardFor(t.events) + expect(card.subject).toEqual({ + kind: 'fileWrite', + path: 'src/a.ts, src/b.ts', + toolName: 'rename_symbol', + }) + expect(card.isProtectedWrite).toBe(false) + await allowAndFinish(t, card) + expect(t.io.files.get(A)).toBe('export function welcome() {}\n') + expect(t.io.files.get(B)).toBe("import { welcome } from './a'\nwelcome()\n") + expect(finished(t.events)[0]).toMatchObject({ + status: 'completed', + patchSummary: { files: 2, added: 3, removed: 3 }, + }) + expect(outputs(t.api)[0]).toContain('Renamed `greet` to `welcome`: 3 edits in 2 files') + }) + + it('writes without a card in Auto, and write_file may then replace a renamed file', async () => { + const t = await start({ approvalMode: 'onRequest', service: GREET_EVERYWHERE }) + await t.turn([ + RENAME, + { name: 'write_file', arguments: JSON.stringify({ path: 'src/b.ts', content: 'gone\n' }) }, + ]) + expect(t.events.some((event) => event.type === 'approvalRequested')).toBe(false) + expect(t.io.files.get(A)).toBe('export function welcome() {}\n') + expect(t.io.files.get(B)).toBe('gone\n') + }) + + it('asks for a protected file in every mode but Bypass, and Plan refuses a rename', async () => { + const protectedRename = { + rename: () => + Promise.resolve({ + files: [renamed(A, 0, 16), renamed(TASKS, 0, 0)], + fileOperations: 'none' as const, + }), + } + const auto = await start({ approvalMode: 'onRequest', service: protectedRename }) + await auto.turn([RENAME], true) + expect(await cardFor(auto.events)).toMatchObject({ isProtectedWrite: true }) + const plan = await start({ approvalMode: 'denyUnmatched', service: GREET_EVERYWHERE }) + await plan.turn([RENAME]) + expect(outputs(plan.api)[0]).toBe(`Error: rename_symbol ${MODEL_TEXT.toolRefusedByMode}`) + expect(plan.io.files.get(A)).toBe(FILES['src/a.ts']) + // Refused before the language service is asked for anything. + expect(plan.service?.asked.some((call) => call.startsWith('rename'))).toBe(false) + }) + + it.each([ + [ + 'changed', + (t: Started) => { + t.io.files.set(B, 'someone else wrote this\n') + }, + 'src/b.ts changed after the rename was planned; nothing was changed', + ], + [ + 'gained unsaved changes', + (t: Started) => { + t.io.unsaved.add(B) + }, + `src/b.ts ${MODEL_TEXT.fileHasUnsavedChanges}`, + ], + [ + 'became a link elsewhere', + (t: Started) => { + t.io.realPath = (path) => Promise.resolve(path === B ? `${ROOT}/src/elsewhere.ts` : path) + }, + MODEL_TEXT.pathChangedAfterApproval, + ], + ])('writes nothing when a file %s while the card was open', async (_what, change, reason) => { + const t = await start({ service: GREET_EVERYWHERE }) + await t.turn([RENAME], true) + const card = await cardFor(t.events) + change(t) + await allowAndFinish(t, card) + expect(outputs(t.api)[0]).toMatch(/^Error: /) + expect(outputs(t.api)[0]).toContain(reason) + expect(t.io.files.get(A)).toBe(FILES['src/a.ts']) + }) + + it("writes nothing when an editor gains unsaved changes under a link's path", async () => { + // The workspace is opened through a link; the service names the files by + // their real paths (/ws), the editor by the link's. + const t = await start({ linkedAs: '/link/ws', service: GREET_EVERYWHERE }) + await t.turn([RENAME], true) + const card = await cardFor(t.events) + t.io.unsaved.add('/link/ws/src/b.ts') + await allowAndFinish(t, card) + expect(outputs(t.api)[0]).toBe(`Error: src/b.ts ${MODEL_TEXT.fileHasUnsavedChanges}`) + expect(t.io.files.get(A)).toBe(FILES['src/a.ts']) + expect(t.io.files.get(B)).toBe(FILES['src/b.ts']) + }) + + it('stops writing, and says so, when a file changes while the others are written', async () => { + const t = await start({ approvalMode: 'onRequest', service: GREET_EVERYWHERE }) + const write = t.io.writeFile + t.io.writeFile = async (path, content, expected) => { + await write(path, content, expected) + if (path === A) { + // A formatter or the user saves b.ts between the two writes. + t.io.files.set(B, 'someone else wrote this\n') + } + } + await t.turn([RENAME]) + expect(outputs(t.api)[0]).toBe( + 'Error: src/b.ts changed after the rename was planned, so it was not written. The rename was written to 1 of 2 files (src/a.ts); the rest are unchanged, and the row can revert what was written', + ) + expect(t.io.files.get(B)).toBe('someone else wrote this\n') + expect(finished(t.events)[0]).toMatchObject({ + status: 'failed', + patchSummary: { files: 1, added: 1, removed: 1 }, + }) + }) + + it.each([1, 2])( + 'writes nothing when Stop comes during file check %d after approval', + async (heldRead) => { + const t = await start({ service: GREET_EVERYWHERE }) + await t.turn([RENAME], true) + const card = await cardFor(t.events) + const read = t.io.readFile + const held = Promise.withResolvers() + const entered = Promise.withResolvers() + let reads = 0 + t.io.readFile = async (path, expected) => { + if (path === A) { + reads += 1 + if (reads === heldRead) { + entered.resolve(undefined) + await held.promise + } + } + return await read(path, expected) + } + await t.session.decideApproval({ + approvalId: card.approvalId, + choiceId: 'allow_once', + requirementId: card.requirementId, + }) + await entered.promise + await t.session.cancel() + held.resolve(undefined) + await vi.waitFor(() => { + expect(t.events.some((event) => event.type === 'turnCompleted')).toBe(true) + }) + expect(t.events.find((event) => event.type === 'turnCompleted')).toMatchObject({ + terminal: 'cancelled', + }) + expect(t.io.files.get(A)).toBe(FILES['src/a.ts']) + expect(t.io.files.get(B)).toBe(FILES['src/b.ts']) + }, + ) + + it('names the protected file first on the card', async () => { + // A git hook that sorts last among the files: without the order, the + // card would name five others and count it among the rest. + const hook = `${ROOT}/src/zz/.husky/pre-commit.ts` + const many = { + rename: () => + Promise.resolve({ + files: [ + renamed(A, 0, 16), + renamed(B, 0, 9), + ...['c', 'd', 'e', 'f'].map((name) => renamedFile(`${ROOT}/src/${name}.ts`)), + renamedFile(hook), + ], + fileOperations: 'none' as const, + }), + } + const t = await start({ approvalMode: 'onRequest', service: many }) + for (const file of [...['c', 'd', 'e', 'f'].map((name) => `${ROOT}/src/${name}.ts`), hook]) { + t.io.files.set(file, 'greet\n') + } + await t.turn([RENAME], true) + const card = await cardFor(t.events) + expect(card).toMatchObject({ isProtectedWrite: true }) + expect(card.subject.path).toBe( + 'src/zz/.husky/pre-commit.ts, src/a.ts, src/b.ts, src/c.ts, src/d.ts and 2 more files', + ) + }) + + it('runs an Edit hook for a rename, with the files it would write', async () => { + const payloads: unknown[] = [] + const hooks = EDIT_HOOK + const t = await start({ + approvalMode: 'onRequest', + service: GREET_EVERYWHERE, + hooks, + hookPayloads: payloads, + }) + await t.turn([RENAME]) + expect(payloads).toMatchObject([ + { + hook_event_name: 'PreToolUse', + tool_name: 'rename_symbol', + tool_input: { + path: 'src/a.ts', + symbol: 'greet', + new_name: 'welcome', + files: ['src/a.ts', 'src/b.ts'], + }, + }, + ]) + expect(t.io.files.get(A)).toBe('export function welcome() {}\n') + // A Stop while the hook's files are planned still ends the call with its row. + const stopped = await start({ + approvalMode: 'onRequest', + service: { rename: () => new Promise(() => undefined) }, + hooks, + hookPayloads: [], + }) + await stopped.turn([RENAME], true) + await stopOnceAsked(stopped, 'rename') + expect(finished(stopped.events).map((item) => item.tool)).toEqual(['rename_symbol']) + }) + + it('writes the plan its hook was shown, however the code changes while the hook runs', async () => { + const C = `${ROOT}/src/c.ts` + let isGrown = false + const payloads: unknown[] = [] + const t = await start({ + approvalMode: 'onRequest', + service: { + rename: () => + Promise.resolve({ + files: [ + ...GREET_FILES, + // A reference added while the hook runs (by it, or by the user). + ...(isGrown ? [renamedFile(C)] : []), + ], + fileOperations: 'none' as const, + }), + }, + hooks: EDIT_HOOK, + hookPayloads: payloads, + }) + t.io.files.set(C, 'greet()\n') + const runHook = t.io.runHook?.bind(t.io) + t.io.runHook = async (...args) => { + isGrown = true + if (runHook === undefined) { + throw new Error('no hook runner') + } + return await runHook(...args) + } + await t.turn([RENAME]) + expect(payloads).toMatchObject([{ tool_input: { files: ['src/a.ts', 'src/b.ts'] } }]) + expect(t.service?.asked.filter((call) => call.startsWith('rename'))).toHaveLength(1) + expect(t.io.files.get(A)).toBe('export function welcome() {}\n') + expect(t.io.files.get(C)).toBe('greet()\n') + }) + + it('says which files a failed write left renamed, and the row can revert them', async () => { + const t = await start({ approvalMode: 'onRequest', service: GREET_EVERYWHERE }) + const write = t.io.writeFile + t.io.writeFile = (path, content, expected) => + path === B ? Promise.reject(new Error('disk full')) : write(path, content, expected) + await t.turn([RENAME]) + expect(outputs(t.api)[0]).toBe( + 'Error: writing src/b.ts failed: disk full. The rename was written to 1 of 2 files (src/a.ts); the rest are unchanged, and the row can revert what was written', + ) + expect(finished(t.events)[0]).toMatchObject({ + status: 'failed', + patchSummary: { files: 1, added: 1, removed: 1 }, + }) + }) + + it('refuses a rename it cannot plan before any card, and Stop ends a call that hangs', async () => { + const t = await start({ service: { definitions: () => new Promise(() => undefined) } }) + await t.turn([RENAME]) + expect(t.events.some((event) => event.type === 'approvalRequested')).toBe(false) + expect(outputs(t.api)[0]).toContain('no language service answered for src/a.ts') + t.api.script({ + calls: [{ name: 'find_definition', arguments: '{"symbol":"greet","path":"src/b.ts"}' }], + }) + await t.session.sendTurn([{ type: 'text', text: 'hang' }]) + await stopOnceAsked(t, 'definitions', 2) + expect(t.events.findLast((event) => event.type === 'turnCompleted')).toMatchObject({ + terminal: 'cancelled', + }) + }) + + it('pins the repo map in the prompt once per session while the setting is on', async () => { + let isOn = true + const t = await start({ + isRepoMapOn: () => isOn, + service: { + workspace: (query) => + Promise.resolve(query === 'greet' ? [sym('greet', KIND.function, A, 0, 16)] : []), + }, + }) + await t.turn([]) + await t.turn([]) + const [first, second] = t.api.responseBodies().map((body) => String(body['instructions'])) + expect(first).toContain('# Repo map') + expect(first).toContain('src/a.ts\n 1: function greet') + expect(second).toBe(first) + expect(t.service?.asked.filter((call) => call === 'workspace greet')).toHaveLength(1) + isOn = false + await t.turn([]) + expect(String(t.api.responseBodies().at(-1)?.['instructions'])).not.toContain('# Repo map') + // A Stop while the map is being made ends the turn at once; the next makes it again. + let isStuck = true + const stopped = await start({ + isRepoMapOn: () => true, + service: { + workspace: (query) => + isStuck + ? new Promise(() => undefined) + : Promise.resolve(query === 'greet' ? [sym('greet', KIND.function, A, 0, 16)] : []), + }, + }) + await stopped.session.sendTurn([{ type: 'text', text: 'go' }]) + await stopOnceAsked(stopped, 'workspace') + isStuck = false + stopped.api.script({ text: 'done' }) + await stopped.session.sendTurn([{ type: 'text', text: 'again' }]) + await vi.waitFor(() => { + expect(stopped.events.filter((event) => event.type === 'turnCompleted')).toHaveLength(2) + }) + expect(stopped.events.find((event) => event.type === 'turnCompleted')).toMatchObject({ + terminal: 'cancelled', + }) + expect(String(stopped.api.responseBodies().at(-1)?.['instructions'])).toContain('# Repo map') + // No language service: tried on three turns at most, never pinned empty. + const quiet = await start({ + isRepoMapOn: () => true, + service: { workspace: () => Promise.resolve([]) }, + }) + for (let index = 0; index < 4; index += 1) { + await quiet.turn([]) + } + expect(String(quiet.api.responseBodies()[0]?.['instructions'])).not.toContain('# Repo map') + expect(quiet.service?.asked.filter((call) => call.startsWith('workspace'))).toHaveLength(3) + }) + + it('makes the map again after a try that found nothing, and never in Restricted Mode', async () => { + let isReady = false + const t = await start({ + isRepoMapOn: () => true, + service: { + workspace: (query) => + Promise.resolve( + isReady && query === 'greet' ? [sym('greet', KIND.function, A, 0, 16)] : [], + ), + }, + }) + // The first turn finds no workspace symbols (TypeScript's project not loaded yet). + await t.turn([]) + isReady = true + await t.turn([]) + const [first, second] = t.api.responseBodies().map((body) => String(body['instructions'])) + expect(first).not.toContain('# Repo map') + expect(second).toContain('# Repo map') + const restricted = await start({ isRepoMapOn: () => true, isTrusted: false }) + await restricted.turn([]) + expect(String(restricted.api.responseBodies()[0]?.['instructions'])).not.toContain('# Repo map') + expect(restricted.service?.asked.some((call) => call.startsWith('workspace'))).toBe(false) + }) + + it("gives a child task its parent's map and never makes one of its own", async () => { + const t = await start({ + isRepoMapOn: () => true, + hasSubagents: true, + service: { + workspace: (query) => + Promise.resolve(query === 'greet' ? [sym('greet', KIND.function, A, 0, 16)] : []), + }, + }) + t.api.script( + { + calls: [ + { name: 'subagent_spawn', arguments: '{"role":"explorer","objective":"Map files"}' }, + ], + }, + { text: 'done' }, + ) + await t.session.sendTurn([{ type: 'text', text: 'delegate' }]) + await vi.waitFor(() => { + expect( + t.api + .responseBodies() + .some((body) => JSON.stringify(body['input']).includes(MODEL_TEXT.subagentObjective)), + ).toBe(true) + }) + const child = t.api + .responseBodies() + .find((body) => JSON.stringify(body['input']).includes(MODEL_TEXT.subagentObjective)) + expect(String(child?.['instructions'])).toContain('src/a.ts\n 1: function greet') + expect(t.service?.asked.filter((call) => call === 'workspace greet')).toHaveLength(1) + }) +}) diff --git a/test/unit/renamePlan.test.ts b/test/unit/renamePlan.test.ts new file mode 100644 index 00000000..2ef17549 --- /dev/null +++ b/test/unit/renamePlan.test.ts @@ -0,0 +1,233 @@ +// `rename_symbol`'s plan (M67, PLAN.md D49): the language service's edit +// checked before anything asks or writes (every file in the workspace, +// saved, and on disk as VS Code holds it), and the diff the `ide` tool +// hands Muse Code. + +import { describe, expect, it } from 'vitest' +import type { CodeIntelDeps } from '../../src/core/codeIntel/codeIntelQuery' +import type { FileEdits, RenameEdits } from '../../src/core/codeIntel/languageService' +import { planRename, renameDiff } from '../../src/core/codeIntel/rename' +import { MODEL_TEXT, RENAME_MAX_FILES } from '../../src/shared/constants' +import { + type FakeServiceOptions, + fakeLanguageService, + KIND, + sym, +} from './helpers/fakeLanguageService' +import { memoryToolIo, realPathThrough } from './helpers/fakeToolIo' + +const ROOT = '/ws' +const A = `${ROOT}/src/a.ts` +const B = `${ROOT}/src/b.ts` +const BOM = '\u{FEFF}' +const FILES = { + 'src/a.ts': `${BOM}export function greet() {}\r\n`, + 'src/b.ts': "import { greet } from './a'\ngreet()\n", +} +const ARGS = { path: 'src/a.ts', line: 1, column: 17, new_name: 'welcome' } + +function renameIn(path: string | undefined, line: number, character: number): FileEdits { + return { + path, + edits: [ + { + range: { start: { line, character }, end: { line, character: character + 5 } }, + newText: 'welcome', + }, + ], + } +} + +const EDITS: RenameEdits = { + files: [renameIn(B, 0, 9), renameIn(A, 0, 16), { path: B, edits: [renameIn(B, 1, 0).edits[0]!] }], + fileOperations: 'none' as const, +} + +function setup(options: Omit = {}) { + const io = memoryToolIo(FILES, ROOT) + const service = fakeLanguageService({ + files: io.files, + rename: () => Promise.resolve(EDITS), + ...options, + }) + const deps: CodeIntelDeps = { service, workspaceRoot: ROOT, platform: 'linux', io, now: () => 0 } + return { io, service, plan: async (args: unknown) => await planRename(args, deps) } +} + +async function reasonOf(result: ReturnType['plan']>): Promise { + const settled = await result + if (settled.ok) { + throw new Error('the rename was planned') + } + return settled.reason +} + +describe('planRename', () => { + it("plans every file's text, keeping its BOM and line breaks, and writes nothing", async () => { + const t = setup() + const result = await t.plan(ARGS) + if (!result.ok) { + throw new Error(result.reason) + } + const { plan } = result + expect(plan).toMatchObject({ from: 'greet', to: 'welcome', edits: 3 }) + expect(plan.files.map((file) => [file.relative, file.after])).toEqual([ + ['src/a.ts', `${BOM}export function welcome() {}\r\n`], + ['src/b.ts', "import { welcome } from './a'\nwelcome()\n"], + ]) + expect(t.io.files.get(A)).toBe(FILES['src/a.ts']) + expect(renameDiff(plan)).toBe( + [ + 'The rename of `greet` to `welcome`: 3 edits in 2 files. This tool changed nothing: apply the diff below with your own edit tool.', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1,1 +1,1 @@', + '-export function greet() {}', + '+export function welcome() {}', + '--- a/src/b.ts', + '+++ b/src/b.ts', + '@@ -1,2 +1,2 @@', + "-import { greet } from './a'", + '-greet()', + "+import { welcome } from './a'", + '+welcome()', + ].join('\n'), + ) + }) + + it('refuses a rename it cannot do whole, before anything asks', async () => { + const outside = setup({ + rename: () => + Promise.resolve({ + ...EDITS, + files: [...EDITS.files, renameIn('/lib/x.d.ts', 0, 0), renameIn(undefined, 0, 0)], + }), + }) + const moves = setup({ + rename: () => Promise.resolve({ ...EDITS, fileOperations: 'present' as const }), + }) + const unknown = setup({ + rename: () => Promise.resolve({ ...EDITS, fileOperations: 'unknown' as const }), + }) + const many = setup({ + rename: () => + Promise.resolve({ + files: Array.from({ length: RENAME_MAX_FILES + 1 }, (_, index) => + renameIn(`${ROOT}/f${String(index)}.ts`, 0, 0), + ), + fileOperations: 'none' as const, + }), + }) + const unsaved = setup() + unsaved.io.unsaved.add(B) + const stale = setup({ buffers: { [B]: 'an older text\n' } }) + const dirty = setup({ dirty: new Set([B]) }) + const broken = setup({ + rename: () => + Promise.resolve({ + files: [renameIn(A, 0, 16), renameIn(B, 9, 0)], + fileOperations: 'none' as const, + }), + }) + expect(await reasonOf(outside.plan(ARGS))).toBe( + 'this rename would also change 2 files outside the workspace; nothing was changed', + ) + expect(await reasonOf(moves.plan(ARGS))).toContain('would also create, move or delete files') + expect(await reasonOf(unknown.plan(ARGS))).toBe( + 'VS Code did not say whether this rename also creates, moves or deletes files, so rename_symbol does not apply it; nothing was changed', + ) + expect(await reasonOf(many.plan(ARGS))).toBe( + `this rename would change ${String(RENAME_MAX_FILES + 1)} files, more than ${String(RENAME_MAX_FILES)}; nothing was changed`, + ) + expect(await reasonOf(unsaved.plan(ARGS))).toContain('src/b.ts has unsaved changes') + for (const t of [stale, dirty, broken]) { + expect(await reasonOf(t.plan(ARGS))).toContain( + "the language service's rename does not match src/b.ts as it is now", + ) + } + expect(await reasonOf(setup().plan({ ...ARGS, new_name: '' }))).toContain('new_name must be') + expect(await reasonOf(setup().plan('not an object'))).toContain('invalid arguments') + }) + + it('says whether nothing can be renamed there, or no language service answers', async () => { + const none = { rename: () => Promise.resolve({ files: [], fileOperations: 'none' as const }) } + const withSymbols = setup({ + ...none, + symbols: { [A]: [sym('greet', KIND.function, A, 0, 16)] }, + }) + expect(await reasonOf(withSymbols.plan(ARGS))).toBe('nothing to rename at src/a.ts:1:17') + expect(await reasonOf(setup(none).plan(ARGS))).toContain( + 'no language service answered for src/a.ts', + ) + const same = setup({ + rename: () => + Promise.resolve({ + files: [{ path: A, edits: [{ ...renameIn(A, 0, 16).edits[0]!, newText: 'greet' }] }], + fileOperations: 'none' as const, + }), + }) + expect(await reasonOf(same.plan({ ...ARGS, new_name: 'greet' }))).toBe( + 'the new name `greet` is already the name there; nothing to rename', + ) + }) + + it('refuses edits the service computed on another version of a file (C1-1)', async () => { + // b.ts gained a first line; the service's ranges are for the old text. + const newer = setup() + newer.io.files.set(B, "// a new first line\nimport { greet } from './a'\ngreet()\n") + expect(await reasonOf(newer.plan(ARGS))).toContain( + "the language service's rename does not match src/b.ts as it is now", + ) + expect(newer.io.files.get(B)).toContain('import { greet }') + // The file the position was read from changed while the service + // answered (both on disk and in VS Code), though its ranges still land + // on the name: its text is not the one the position came from. + const moved = setup({ + rename: () => { + moved.io.files.set(A, `${BOM}export function greet() {}\r\n// appended\r\n`) + return Promise.resolve(EDITS) + }, + }) + expect(await reasonOf(moved.plan(ARGS))).toContain( + "the language service's rename does not match src/a.ts as it is now", + ) + // An edit that does not cover the position asked about has no old name. + const elsewhere = setup({ + rename: () => + Promise.resolve({ files: [renameIn(B, 1, 0)], fileOperations: 'none' as const }), + }) + expect(await reasonOf(elsewhere.plan(ARGS))).toContain('does not match src/a.ts') + }) + + it("passes the provider's refusal on", async () => { + const t = setup({ rename: () => Promise.reject(new Error('You cannot rename this element.')) }) + await expect(t.plan(ARGS)).rejects.toThrow('You cannot rename this element.') + }) + + it('refuses a file an editor holds unsaved by the path of a link to the workspace', async () => { + // The workspace is opened as /link/ws; the language service names the + // files by their real paths under /ws, the editor by the link. + const link = '/link/ws' + const io = memoryToolIo(FILES, ROOT) + io.realPath = realPathThrough(link, ROOT) + io.unsaved.add(`${link}/src/b.ts`) + const service = fakeLanguageService({ + files: io.files, + links: { [link]: ROOT }, + dirty: new Set([`${link}/src/b.ts`]), + buffers: { [`${link}/src/b.ts`]: "import { greet } from './a'\ngreet()\n// edited\n" }, + rename: () => Promise.resolve(EDITS), + }) + const result = await planRename(ARGS, { + service, + workspaceRoot: link, + platform: 'linux', + io, + now: () => 0, + }) + expect(result).toMatchObject({ + ok: false, + reason: `src/b.ts ${MODEL_TEXT.fileHasUnsavedChanges}`, + }) + }) +}) diff --git a/test/unit/repoMap.test.ts b/test/unit/repoMap.test.ts new file mode 100644 index 00000000..d15a6a56 --- /dev/null +++ b/test/unit/repoMap.test.ts @@ -0,0 +1,259 @@ +// The repo map (M67, PLAN.md D49): files ranked by how often other files +// use the names they define, within a token budget and a time budget, as +// a tool and as the opt-in section of the Model API's system prompt. + +import { getEventListeners } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CodeIntelDeps } from '../../src/core/codeIntel/codeIntelQuery' +import { answerCodeIntel } from '../../src/core/codeIntel/codeIntelTools' +import type { CodeSymbol } from '../../src/core/codeIntel/languageService' +import { repoMapSection } from '../../src/core/codeIntel/repoMap' +import { + REPO_MAP_CHARS_PER_TOKEN, + REPO_MAP_MAX_FILE_CHARS, + REPO_MAP_MAX_FILES, + REPO_MAP_PROMPT_TOKENS, + UI_TEXT, +} from '../../src/shared/constants' +import { fakeLanguageService, KIND, sym } from './helpers/fakeLanguageService' +import { memoryToolIo, type MemoryToolIo } from './helpers/fakeToolIo' + +const ROOT = '/ws' +const CORE = `${ROOT}/src/core.ts` +const TWO = `${ROOT}/src/two.ts` +const FILES = { + 'src/core.ts': 'export function helper() {}\nexport const TABLE = 1\n', + 'src/one.ts': "import { helper, TABLE } from './core'\nhelper(TABLE)\nhelper()\n", + 'src/two.ts': "import { helper } from './core'\nhelper()\nexport function oneThing() {}\n", + 'src/three.ts': 'oneThing()\n', +} +const DEFINED: Readonly> = { + helper: [ + sym('helper', KIND.function, CORE, 0, 16), + sym('helper', KIND.function, '/lib/x.d.ts', 0, 0), + ], + TABLE: [sym('TABLE', KIND.constant, CORE, 1, 13)], + oneThing: [sym('oneThing', KIND.function, TWO, 2, 16)], +} + +/** Workspace symbols as a server answers them: the definitions, and near misses for the rest. */ +function answers(query: string): Promise { + return Promise.resolve(DEFINED[query] ?? [sym(`${query}Like`, KIND.variable, CORE, 0, 0)]) +} + +function setup( + options: { + readonly io?: MemoryToolIo + readonly workspace?: (query: string) => Promise + readonly now?: () => number + } = {}, +) { + const io = options.io ?? memoryToolIo(FILES, ROOT) + const service = fakeLanguageService({ files: io.files, workspace: options.workspace ?? answers }) + const deps: CodeIntelDeps = { + service, + workspaceRoot: ROOT, + platform: 'linux', + io, + now: options.now ?? (() => 0), + } + return { + service, + deps, + map: async (args: unknown = {}) => { + const answer = await answerCodeIntel('repoMap', args, deps) + return answer.ok ? answer.text : `refused: ${answer.reason} / ${answer.visibleReason}` + }, + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('repo map', () => { + it('ranks the files other files use most, with their most used definitions', async () => { + const t = setup() + const text = await t.map() + expect(text.split('\n').slice(1)).toEqual([ + 'src/core.ts', + ' 1: function helper', + ' 2: constant TABLE', + 'src/two.ts', + ' 3: function oneThing', + ]) + expect(text).not.toContain('x.d.ts') + }) + + it('keeps all its text within the token budget and counts the files left out', async () => { + const t = setup() + const text = await t.map({ max_tokens: 60 }) + expect(text).toContain('src/core.ts') + expect(text).not.toContain('src/two.ts') + expect(text).toContain('[1 more not shown]') + // The lead and the count of the files left out are counted too. + for (const tokens of [46, 59, 60, 62, 63]) { + const answer = await t.map({ max_tokens: tokens }) + expect(answer).not.toContain('refused') + expect(answer.length).toBeLessThanOrEqual(tokens * 4) + } + expect(await t.map({ max_tokens: 46 })).toContain('[2 more not shown]') + // A budget that cannot hold even those says what would. + const refusal = + "max_tokens 1 cannot hold the map's own lead and notes; ask again with max_tokens of at least 46" + expect(await t.map({ max_tokens: 0 })).toBe(`refused: ${refusal} / ${refusal}`) + const alone = setup({ io: memoryToolIo({ 'solo.ts': 'const lonely = 1\n' }, ROOT) }) + expect(await alone.map({ max_tokens: 5 })).toContain('refused: max_tokens 5 cannot hold') + }) + + it('says when no language service answers, and when no file ranks', async () => { + const none = setup({ workspace: () => Promise.resolve([]) }) + expect(await none.map()).toContain(`refused: no language service answered workspace symbols`) + expect(await none.map()).toContain(UI_TEXT.repoMapNoService) + const alone = setup({ io: memoryToolIo({ 'solo.ts': 'const lonely = 1\n' }, ROOT) }) + expect(await alone.map()).toBe( + 'No workspace file defines a name that other files use, as far as the workspace symbols show.', + ) + expect(await repoMapSection(alone.deps, new AbortController().signal)).toBeUndefined() + }) + + it('stops its lookups at the time budget and says the map is partial', async () => { + const names = 'alpha bravo charlie delta echo foxtrot golf hotel india juliet' + const files = Object.fromEntries( + Array.from({ length: 3 }, (_, index) => [`f${String(index)}.ts`, names]), + ) + let clock = 0 + const slow = setup({ + io: memoryToolIo(files, ROOT), + now: () => clock, + workspace: (query) => { + clock += 6000 + return answers(query) + }, + }) + expect(await slow.map()).toContain('[partial: looked up 8 of 10 names within the time budget]') + vi.useFakeTimers() + const stuck = setup({ workspace: () => new Promise(() => undefined) }) + const pending = stuck.map() + await vi.advanceTimersByTimeAsync(10_000) + expect(await pending).toContain('[partial: looked up 0 of 8 names within the time budget]') + }) + + it('keeps nothing from a batch the time cut off, and calls a cut map partial, not unserved', async () => { + // One lookup of the batch never answers: the others' definitions, found + // before the cut, stay out of the map the note says was not looked up. + vi.useFakeTimers() + const cut = setup({ + workspace: (query) => (query === 'import' ? new Promise(() => undefined) : answers(query)), + }) + const pending = cut.map() + await vi.advanceTimersByTimeAsync(10_000) + const text = await pending + expect(text).not.toContain('src/core.ts') + expect(text).toContain('[partial: looked up 0 of') + vi.useRealTimers() + // Lookups that found nothing before the time ran out: a partial map. + const names = 'alpha bravo charlie delta echo foxtrot golf hotel india juliet' + let clock = 0 + const empty = setup({ + io: memoryToolIo({ 'f0.ts': names, 'f1.ts': names }, ROOT), + now: () => clock, + workspace: () => { + clock += 6000 + return Promise.resolve([]) + }, + }) + const partial = await empty.map() + expect(partial).not.toContain('refused') + expect(partial).toContain('[partial: looked up 8 of 10 names within the time budget]') + }) + + it('says how many files it read when the reading runs out of time, or the listing does', async () => { + const files = Object.fromEntries( + Array.from({ length: 20 }, (_, index) => [`f${String(index).padStart(2, '0')}.ts`, 'shared']), + ) + const io = memoryToolIo(files, ROOT) + let clock = 0 + const readFile = io.readFile + io.readFile = async (path, expected) => { + clock += 1000 + return await readFile(path, expected) + } + const slow = setup({ io, now: () => clock }) + expect(await slow.map()).toContain('[partial: read 16 of 20 files within the time budget]') + vi.useFakeTimers() + const unlisted = memoryToolIo(FILES, ROOT) + unlisted.listFiles = () => new Promise(() => undefined) + const pending = setup({ io: unlisted }).map() + await vi.advanceTimersByTimeAsync(10_000) + expect(await pending).toContain( + "[partial: the workspace's files were not listed within the time budget]", + ) + }) + + it('leaves no listener on the turn signal, however many maps it makes', async () => { + const turn = new AbortController() + const t = setup() + for (let index = 0; index < 12; index += 1) { + await repoMapSection(t.deps, turn.signal) + } + expect(getEventListeners(turn.signal, 'abort')).toHaveLength(0) + }) + + it('reads confined UTF-8 files only, up to its caps', async () => { + const files: Record = { + ...FILES, + 'big.ts': 'helper '.repeat(REPO_MAP_MAX_FILE_CHARS), + } + for (let index = 0; index < REPO_MAP_MAX_FILES; index += 1) { + files[`z/${String(index).padStart(4, '0')}.ts`] = 'x' + } + const io = memoryToolIo(files, ROOT) + const unreadable = `${ROOT}/src/one.ts` + const readFile = io.readFile + io.readFile = (path, expected) => + path === unreadable ? Promise.reject(new Error('not UTF-8')) : readFile(path, expected) + const text = await setup({ io }).map() + expect(text).toContain( + `[ranked the first ${String(REPO_MAP_MAX_FILES)} of ${String(REPO_MAP_MAX_FILES + 5)} files]`, + ) + // Without one.ts, and with big.ts left out, only two.ts uses helper. + expect(text).toContain('src/core.ts\n 1: function helper') + expect(text).not.toContain('TABLE') + }) + + it('makes the system prompt section, or nothing once a Stop has come', async () => { + const t = setup() + const section = await repoMapSection(t.deps, new AbortController().signal) + expect(section?.startsWith('# Repo map\n\nThe workspace as this session began')).toBe(true) + expect(section).toContain('src/core.ts') + const stopped = new AbortController() + stopped.abort() + const quiet = setup() + expect(await repoMapSection(quiet.deps, stopped.signal)).toBeUndefined() + expect(quiet.service.asked.some((call) => call.startsWith('workspace'))).toBe(false) + }) + + it('keeps the whole prompt section, its heading included, within its budget', async () => { + // 150 files each defining a name another file uses: far more than fits. + const names = Array.from({ length: 150 }, (_, index) => `definedInFile${String(index)}`) + const files: Record = { 'src/user.ts': names.join('\n') } + for (const [index, name] of names.entries()) { + files[`src/d${String(index)}.ts`] = `export function ${name}() {}\n` + } + const many = setup({ + io: memoryToolIo(files, ROOT), + workspace: (query) => { + const index = names.indexOf(query) + return Promise.resolve( + index === -1 + ? [] + : [sym(query, KIND.function, `${ROOT}/src/d${String(index)}.ts`, 0, 16)], + ) + }, + }) + const section = await repoMapSection(many.deps, new AbortController().signal) + expect(section).toContain('more not shown]') + expect(section?.length).toBeLessThanOrEqual(REPO_MAP_PROMPT_TOKENS * REPO_MAP_CHARS_PER_TOKEN) + }) +}) diff --git a/test/unit/toolIo.test.ts b/test/unit/toolIo.test.ts index cf27cc71..18552ff9 100644 --- a/test/unit/toolIo.test.ts +++ b/test/unit/toolIo.test.ts @@ -240,10 +240,28 @@ const io = () => env: () => process.env, searchWorkerPath: 'unused-here', log: () => undefined, - hasUnsavedChanges: () => false, + unsavedFiles: () => [], shellJobAssembly: jobAssembly, }) +describe('createToolIo: files with unsaved changes', () => { + it("lists the editors' files, and matches one by the platform's rules", () => { + const open = [String.raw`C:\Ws\A.ts`] + const windows = createToolIo({ + platform: 'win32', + listFiles: () => Promise.resolve([]), + systemRoot: undefined, + env: () => ({}), + searchWorkerPath: 'unused-here', + log: () => undefined, + unsavedFiles: () => open, + }) + expect(windows.unsavedFiles()).toEqual(open) + expect(windows.hasUnsavedChanges('c:/ws/a.ts')).toBe(true) + expect(windows.hasUnsavedChanges(String.raw`C:\Ws\B.ts`)).toBe(false) + }) +}) + describe('createToolIo (real file system and shell)', () => { let root = '' beforeAll(async () => { @@ -594,7 +612,7 @@ describe('createToolIo (real file system and shell)', () => { env: () => process.env, searchWorkerPath: 'unused-here', log: () => undefined, - hasUnsavedChanges: () => false, + unsavedFiles: () => [], }) const result = await broken.runShell('echo hi', root, SHELL_BUDGET_MS) expect(result.exitCode).toBeNull() diff --git a/test/unit/toolIoBounded.test.ts b/test/unit/toolIoBounded.test.ts index 25e8c081..17cf4619 100644 --- a/test/unit/toolIoBounded.test.ts +++ b/test/unit/toolIoBounded.test.ts @@ -34,7 +34,7 @@ const io = createToolIo({ env: () => process.env, searchWorkerPath: 'unused-here', log: () => undefined, - hasUnsavedChanges: () => false, + unsavedFiles: () => [], }) async function textLength(target: string): Promise { diff --git a/test/unit/toolPresentation.test.ts b/test/unit/toolPresentation.test.ts index 86393fcb..f5deaafd 100644 --- a/test/unit/toolPresentation.test.ts +++ b/test/unit/toolPresentation.test.ts @@ -265,3 +265,31 @@ describe('image edits and the ide server’s images (M44)', () => { }) }) }) + +describe('code intelligence rows (M67)', () => { + it('label both backends alike, name the symbol, and show a rename as an edit', () => { + expect(describeTool('find_references', '{"symbol":"greet"}')).toMatchObject({ + label: 'References', + summary: 'greet', + body: 'generic', + }) + expect(describeTool('mcp__ide__findDefinition', '{"path":"src/a.ts","line":2}')).toMatchObject({ + label: 'Definition', + summary: 'src/a.ts', + }) + expect(describeTool('workspace_symbols', '{"query":"Parser"}').summary).toBe('Parser') + // An edit row's summary is the file it opens: a rename named by symbol alone has none. + expect(describeTool('rename_symbol', '{"symbol":"greet","new_name":"welcome"}')).toMatchObject({ + label: 'Rename', + summary: '', + body: 'edit', + }) + expect(describeTool('rename_symbol', '{"path":"src/a.ts","symbol":"greet"}').summary).toBe( + 'src/a.ts', + ) + expect(describeTool('mcp__ide__renameSymbol', '{}')).toMatchObject({ + label: 'Rename', + body: 'generic', + }) + }) +}) diff --git a/test/unit/uiState.test.ts b/test/unit/uiState.test.ts index c1fea61b..3bca9a48 100644 --- a/test/unit/uiState.test.ts +++ b/test/unit/uiState.test.ts @@ -9,6 +9,7 @@ import { agentsOf, backgroundTasksOf, editsAfter, + hasLandedEdits, forkCutBefore, hasPendingRequest, initialUiState, @@ -74,6 +75,24 @@ function expectBoundReplayCard(state: UiState) { const NOW = 1_000_000 +/** A tool row that finished, with the stored patch of its edit when `patch` names one. */ +function toolFinished(itemId: string, tool: string, status: string, patch?: string): UiAction { + return host({ + type: 'agentEvent', + event: { + type: 'itemCompleted', + item: { + itemId, + kind: 'toolCall', + status, + tool, + args: '{}', + ...(patch !== undefined && { patchRef: { id: patch, byteLen: 10 } }), + }, + }, + }) +} + function host(message: HostToWebviewMessage, at = NOW): UiAction { return { type: 'hostMessage', message, at } } @@ -1371,26 +1390,11 @@ describe('uiReducer: session history (M6)', () => { }) it('lists the completed edits after a message newest first for a rewind (M13)', () => { - const edit = (itemId: string, status: string, patch: string) => - host({ - type: 'agentEvent', - event: { - type: 'itemCompleted', - item: { - itemId, - kind: 'toolCall', - status, - tool: 'edit_file', - args: '{}', - patchRef: { id: patch, byteLen: 10 }, - }, - }, - }) const state = reduceAll([ { type: 'submitted', localId: 'l1', text: 'one', attachments: [], contextLabel: undefined }, host({ type: 'turnAccepted', localId: 'l1', turnId: 't1' }), - edit('e1', 'completed', 'p1'), - edit('e2', 'completed', 'p2'), + toolFinished('e1', 'edit_file', 'completed', 'p1'), + toolFinished('e2', 'edit_file', 'completed', 'p2'), host({ type: 'agentEvent', event: { @@ -1406,7 +1410,7 @@ describe('uiReducer: session history (M6)', () => { }), { type: 'submitted', localId: 'l2', text: 'two', attachments: [], contextLabel: undefined }, host({ type: 'turnAccepted', localId: 'l2', turnId: 't2' }), - edit('e3', 'failed', 'p3'), + toolFinished('e3', 'edit_file', 'failed', 'p3'), ]) expect(editsAfter(state, 'l1')).toEqual([ { itemId: 'e2', outputRef: 'p2' }, @@ -1416,6 +1420,28 @@ describe('uiReducer: session history (M6)', () => { expect(editsAfter(state, 'ghost')).toEqual([]) }) + it('counts a rename stopped partway among the edits on disk, and no other failed row (M67)', () => { + const state = reduceAll([ + { type: 'submitted', localId: 'l1', text: 'one', attachments: [], contextLabel: undefined }, + host({ type: 'turnAccepted', localId: 'l1', turnId: 't1' }), + toolFinished('n1', 'rename_symbol', 'failed', 'p2'), + toolFinished('n2', 'rename_symbol', 'failed'), + toolFinished('x1', 'apply_patch', 'failed', 'p3'), + toolFinished('e1', 'edit_file', 'completed', 'p1'), + ]) + expect(editsAfter(state, 'l1')).toEqual([ + { itemId: 'e1', outputRef: 'p1' }, + { itemId: 'n1', outputRef: 'p2' }, + ]) + const patch = { id: 'p', byteLen: 1 } + expect(hasLandedEdits({ status: 'failed', tool: 'rename_symbol', patchRef: patch })).toBe(true) + expect(hasLandedEdits({ status: 'failed', tool: 'edit_file', patchRef: patch })).toBe(false) + expect(hasLandedEdits({ status: 'rejected', tool: 'rename_symbol', patchRef: patch })).toBe( + false, + ) + expect(hasLandedEdits({ status: 'completed', tool: 'rename_symbol' })).toBe(false) + }) + // M20: a subagent's edits live in its own transcript (M18), and before this // the rewind never saw them; edits from the conversation and its agents // now unwind in the reverse of the order they completed, wherever they