diff --git a/.changeset/acp-subagent-tool-permission.md b/.changeset/acp-subagent-tool-permission.md new file mode 100644 index 000000000..a3eb4396d --- /dev/null +++ b/.changeset/acp-subagent-tool-permission.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Fixed sub-agent tool calls being denied silently under ACP. A tool call made inside a dispatched sub-agent went through the global approval slot, which no ACP code path installed a handler for, so its safe fallback denied every one without the client seeing a `session/request_permission`. Delegated work could only write by bypassing approval entirely, which was invisible to a client that gates writes. Sub-agent calls now use the same permission channel as top-level ones, announced first so the request names a known tool call, prefixed so their cards cannot collide with a top-level id, and titled with the sub-agent so the client can tell them apart. The handler is scoped to the turn that installs it, so it no longer outlives that turn holding a finished session. Two known limits remain. The approval slot is still a process-wide singleton, so while two sessions have turns in flight at once the later one's handler answers the earlier one's approvals, against the wrong session id and abort controller; closing that needs the slot keyed by session or an approval channel threaded through the sub-agent executor. And an approved sub-agent call is marked `completed` as soon as it is approved rather than when it runs, because the sub-agent layer does not report results back, so a client sees `completed` for a tool that may still fail. Note also that sub-agent approvals ignore the ACP session's mode and the configured `alwaysAllow` list, so a `yolo` or `auto-accept` session is prompted inside a sub-agent for a tool it would not be prompted for at top level. Closes #1019. diff --git a/.changeset/calm-queues-compact.md b/.changeset/calm-queues-compact.md new file mode 100644 index 000000000..e0f8b5575 --- /dev/null +++ b/.changeset/calm-queues-compact.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Resume queued prompts after slash commands and manual context compaction complete. Closes #1060. diff --git a/.changeset/coverage-drop-vs-base.md b/.changeset/coverage-drop-vs-base.md new file mode 100644 index 000000000..29e6ecfa7 --- /dev/null +++ b/.changeset/coverage-drop-vs-base.md @@ -0,0 +1,4 @@ +--- +--- + +CI: pass fail-on-coverage-drop to the shared PR checks workflow. diff --git a/.changeset/curvy-tools-smile.md b/.changeset/curvy-tools-smile.md new file mode 100644 index 000000000..6c8e88f19 --- /dev/null +++ b/.changeset/curvy-tools-smile.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Fixed subagent tool results that return structured data without `llmContent` so the complete output is preserved for the model instead of being passed as `undefined`. Closes #1033. diff --git a/.changeset/custom-tool-windows-cmd.md b/.changeset/custom-tool-windows-cmd.md new file mode 100644 index 000000000..3cfc71c28 --- /dev/null +++ b/.changeset/custom-tool-windows-cmd.md @@ -0,0 +1,5 @@ +--- +'@nanocollective/nanocoder': patch +--- + +Custom tools on Windows now spawn `cmd.exe /d /s /c` instead of `-c`, which cmd does not accept. `/d` skips AutoRun; `/s` makes quote stripping deterministic. `{{ }}` substitution stays POSIX-quoted and is not shell-safe under cmd. Closes #1028. diff --git a/.changeset/fiery-hats-pick.md b/.changeset/fiery-hats-pick.md new file mode 100644 index 000000000..376339f4d --- /dev/null +++ b/.changeset/fiery-hats-pick.md @@ -0,0 +1,7 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Auto-generate descriptive filenames for /export instead of generic timestamps. Closes #934 + +Exports are now contained to the project directory, matching read_file / write_file / string_replace: `~` is not expanded and absolute paths outside the project root are refused rather than written. Rejections name the specific cause (null byte, `~`, `..` segment, outside the root) instead of failing generically. diff --git a/.changeset/fix-literal-replacement-dollar-tokens.md b/.changeset/fix-literal-replacement-dollar-tokens.md new file mode 100644 index 000000000..54ac6c1f1 --- /dev/null +++ b/.changeset/fix-literal-replacement-dollar-tokens.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Fixed `string_replace` and `diff_edit` corrupting edits whose replacement text contains `$`. Both tools passed the model's replacement straight to `String.prototype.replace`, which treats that argument as a substitution template rather than a literal: `$$` collapsed to a single `$`, `$&` expanded to the matched text, and ``$` ``/`$'` spliced a whole half of the file into the middle of the edit. Those are ordinary characters in shell scripts, Makefiles, CI YAML and anything that builds a regex, so the bytes on disk silently diverged from the diff the user approved. Replacements are now spliced by index, so the approved preview - in the terminal and over ACP - is what lands. Closes #1057. diff --git a/.changeset/fix-tailwind-v4-vscode.md b/.changeset/fix-tailwind-v4-vscode.md new file mode 100644 index 000000000..aa6b464c7 --- /dev/null +++ b/.changeset/fix-tailwind-v4-vscode.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Fixed a regression where the VS Code extension webview rendered without theme colors after the Tailwind v4 upgrade by migrating custom color variables to an `@theme` block in the CSS. diff --git a/.changeset/fresh-pandas-initialize.md b/.changeset/fresh-pandas-initialize.md new file mode 100644 index 000000000..f2916cbd0 --- /dev/null +++ b/.changeset/fresh-pandas-initialize.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Added bundled React, Next.js, and Rust project presets for `nanocoder init --preset ` and `/init --preset `. Presets seed analyzed `AGENTS.md` guidance, stack-specific context ignores, and a `/check` command skill while preserving existing files. Closes #1008. diff --git a/.changeset/pr-path-labels.md b/.changeset/pr-path-labels.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/pr-path-labels.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/ripgrep-file-search.md b/.changeset/ripgrep-file-search.md new file mode 100644 index 000000000..11b47104b --- /dev/null +++ b/.changeset/ripgrep-file-search.md @@ -0,0 +1,11 @@ +--- +"@nanocollective/nanocoder": minor +--- + +File search (path matching and content search) is now backed by `ripgrep` instead of a hand-rolled JS walker. + +Search also respects `.nanocoderignore` and binary files again, matching `list_directory` and file autocomplete. + +A failed search now reports the failure instead of returning an empty result set. + +`.nanocoderignore` directories are skipped during the walk rather than filtered afterwards, so a large ignored directory can no longer crowd real files out of the results. diff --git a/.changeset/semantic-memory-remaining.md b/.changeset/semantic-memory-remaining.md new file mode 100644 index 000000000..d12ec78ee --- /dev/null +++ b/.changeset/semantic-memory-remaining.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Wire semantic memory recall into subagent and daemon runs, serialize writes across manager instances and processes, and cap each repo memory file at 500 entries. diff --git a/.changeset/semantic-memory-retrieval.md b/.changeset/semantic-memory-retrieval.md new file mode 100644 index 000000000..eaba199cd --- /dev/null +++ b/.changeset/semantic-memory-retrieval.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Rank recalled memories by how much of the query they cover, skip tool-call narration in reversal detection, and drop noisy `/memory propose` candidates. diff --git a/.changeset/semantic-memory-setting.md b/.changeset/semantic-memory-setting.md new file mode 100644 index 000000000..01489cb5b --- /dev/null +++ b/.changeset/semantic-memory-setting.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Added a dedicated Semantic Memory toggle to `/settings` under Advanced, backed by the `semanticMemoryEnabled` preference. The setting defaults on to preserve existing behavior, and can be turned off to keep agents from persisting reusable context across sessions. diff --git a/.changeset/session-autosave-indicator.md b/.changeset/session-autosave-indicator.md new file mode 100644 index 000000000..8bd03ceaf --- /dev/null +++ b/.changeset/session-autosave-indicator.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Added a visual `saving` indicator in the CLI status line that briefly displays whenever session state is autosaved to disk. Thanks to @rishu685. Closes #932. diff --git a/.changeset/theme-aware-syntax-highlighting.md b/.changeset/theme-aware-syntax-highlighting.md new file mode 100644 index 000000000..f695230d2 --- /dev/null +++ b/.changeset/theme-aware-syntax-highlighting.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Syntax highlighting now follows your theme, and takes a `syntaxTheme` preference when you want code to keep a palette of its own. All five highlighting call sites - markdown code blocks, `string_replace` diff context, the `write_file` preview, and the file explorer preview - passed `theme: 'default'`, a string where `cli-highlight` expects a token-to-formatter map, so the option was silently dropped and every theme rendered code identically in the library's own colours. Each one now derives its token map from a theme's palette: keywords take `primary`, built-ins and declarations `tool`, strings `success`, numbers `warning`, comments `secondary`, attributes and variables `info`, and everything else the theme's body `text`. Code follows `selectedTheme` by default; setting `syntaxTheme` in `nanocoder-preferences.json` (e.g. `"syntaxTheme": "dracula"`) points code at any other theme's palette while the rest of the UI stays put, and an unknown name falls back to `selectedTheme` rather than dropping the styling. Closes #935. diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..d16d096b7 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,28 @@ +area:tools: + - changed-files: + - any-glob-to-any-file: + - source/tools/** + - source/custom-tools/** + +area:tui: + - changed-files: + - any-glob-to-any-file: + - source/hooks/** + - source/components/** + - source/app/** + +area:ci: + - changed-files: + - any-glob-to-any-file: + - .github/** + +area:docs: + - changed-files: + - any-glob-to-any-file: + - docs/** + +area:vscode: + - changed-files: + - any-glob-to-any-file: + - plugins/vscode/** + - source/vscode/** diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 13d28f2d2..47e1265d1 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -1,8 +1,6 @@ name: Pull Request Automated Checks -# The shared org workflow covers the checks every repo runs. Everything -# genuinely nanocoder-specific — the VS Code extension — stays in this file -# rather than becoming an input on the shared workflow. +# Nanocoder-specific checks (VS Code extension) that sit outside the shared org workflow. on: pull_request: @@ -17,26 +15,18 @@ permissions: jobs: pr-checks: uses: Nano-Collective/.github/.github/workflows/pr-checks.yml@main + with: + fail-on-coverage-drop: true - # `changeset-check.yml` only asserts that a changeset file was added, never - # that the package name inside it resolves. A wrong name is accepted on the - # PR and then breaks `release-prepare` on every subsequent push to main, - # which is what #1065 did. - # - # This runs scripts/validate-changesets.js rather than `changeset status`: - # status also exits 1 when packages changed but no changeset was added, which - # would turn changeset-check.yml's deliberately non-blocking nudge into a hard - # requirement, and it needs a local `main` ref that this detached checkout - # does not have. + # Validates changeset package names to prevent main branch breakages. + # Uses a custom script instead of `changeset status` to keep the check non-blocking. changeset-validation: name: Changeset Validation runs-on: ubuntu-latest - # The automated "Version Packages" PR consumes changesets rather than - # adding them, matching the skip in changeset-check.yml. + # Skip for the automated "Version Packages" PR. if: github.head_ref != 'changeset-release/main' steps: - # The validator is dependency-free and reads only the working tree, so - # this job needs no pnpm install and no history beyond the head commit. + # Dependency-free check, no pnpm install needed. - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: @@ -50,9 +40,7 @@ jobs: - name: Validate every changeset resolves to a workspace package run: node scripts/validate-changesets.js - # nanocoder ships a VS Code extension alongside the CLI. It has its own - # tsconfig, so the root `test:types` does not cover it, and it produces the - # .vsix that the release consumes. + # Tests and builds the VS Code extension. vscode-extension: name: VS Code Extension runs-on: ubuntu-latest @@ -75,16 +63,19 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # The root tsconfig only includes source/**, so the extension's own - # sources and specs are checked by their own project or not at all. + # Type check extension separately from the root tsconfig. - name: Type check the extension run: pnpm test:types:vscode - name: Build the extension run: pnpm run build:vscode + # Verify .vsix existence. The next step will verify its CSS theme tokens. - name: Verify the .vsix was produced run: | set -euo pipefail test -f assets/nanocoder-vscode.vsix echo "VS Code extension packaged" + + - name: Verify the theme tokens compiled into the CSS + run: node plugins/vscode/scripts/verify-theme-css.js diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 000000000..b75fec903 --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,70 @@ +name: PR path labels + +on: + pull_request_target: + types: [opened, synchronize, reopened] + branches: [main] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + # Skip the automated "Version Packages" PR. + if: github.head_ref != 'changeset-release/main' + steps: + - name: Color area labels + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const colors = { + 'area:tools': ['1d76db', 'Tool implementations and tool-calling'], + 'area:tui': ['fbca04', 'Terminal UI'], + 'area:ci': ['e99695', 'GitHub Actions and CI'], + 'area:docs': ['0e8a16', 'Documentation'], + 'area:vscode': ['5319e7', 'VS Code extension and host integration'], + }; + const {owner, repo} = context.repo; + const {data} = await github.rest.repos.getContent({ + owner, + repo, + path: '.github/labeler.yml', + ref: context.payload.pull_request.head.sha, + }); + if (data.type !== 'file' || typeof data.content !== 'string') { + throw new Error('labeler.yml is not a file'); + } + const yaml = Buffer.from(data.content, 'base64').toString('utf8'); + const names = [...yaml.matchAll(/^(\S+):\s*$/gm)].map(m => m[1]); + if (names.length === 0) { + throw new Error('labeler.yml had no top-level label keys'); + } + for (const name of names) { + const meta = colors[name]; + if (!meta) { + throw new Error( + `labeler.yml has ${name} but no colour map entry`, + ); + } + try { + await github.rest.issues.getLabel({owner, repo, name}); + } catch (error) { + if (error.status !== 404) throw error; + const [color, description] = meta; + await github.rest.issues.createLabel({ + owner, + repo, + name, + color, + description, + }); + } + } + + - name: Label by path + uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 + with: + sync-labels: false diff --git a/.gitignore b/.gitignore index 762da40b8..d7a2a3d59 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,4 @@ benchmarks/.module-* # Auto-generated CSS plugins/vscode/media/chat-panel.css +assets/*.vsix diff --git a/assets/nanocoder-vscode.vsix b/assets/nanocoder-vscode.vsix deleted file mode 100644 index 90b14e6f6..000000000 Binary files a/assets/nanocoder-vscode.vsix and /dev/null differ diff --git a/badges/coverage.svg b/badges/coverage.svg index 204c17353..99b5986cd 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -COVERAGE: 91.71%COVERAGE91.71% \ No newline at end of file +COVERAGE: 92.17%COVERAGE92.17% \ No newline at end of file diff --git a/badges/forks.svg b/badges/forks.svg index 6891326a2..0da1a63fb 100644 --- a/badges/forks.svg +++ b/badges/forks.svg @@ -1 +1 @@ -FORKS293 \ No newline at end of file +FORKS300 \ No newline at end of file diff --git a/badges/repo-size.svg b/badges/repo-size.svg index ad857e85c..d94a4047d 100644 --- a/badges/repo-size.svg +++ b/badges/repo-size.svg @@ -1 +1 @@ -REPO SIZE: 34.8 MIBREPO SIZE34.8 MIB \ No newline at end of file +REPO SIZE: 35.3 MIBREPO SIZE35.3 MIB \ No newline at end of file diff --git a/docs/configuration/preferences.md b/docs/configuration/preferences.md index 8a72b7411..460fca658 100644 --- a/docs/configuration/preferences.md +++ b/docs/configuration/preferences.md @@ -40,11 +40,15 @@ Preferences follow the same location hierarchy as configuration files: | `lastProvider` | The AI provider you last selected | | `lastModel` | The model you last used | | `providerModels` | Your preferred model for each provider (remembered per-provider) | -| `selectedTheme` | The theme you last selected via `/settings` | +| `selectedTheme` | The theme you last selected via `/settings`. Also colours syntax highlighting in code blocks, diffs, and file previews | +| `syntaxTheme` | Optional. Name of the theme whose palette colours syntax highlighting, when you want code to keep a palette of its own (e.g. `"dracula"`) instead of following `selectedTheme`. Any theme name from `/settings` → **Theme** works; an unknown name falls back to `selectedTheme` | | `titleShape` | The title shape style (e.g., box, rounded) | | `nanocoderShape` | The nanocoder ASCII art shape | | `trustedDirectories` | Directories you've approved through the first-run security disclaimer | | `lastUpdateCheck` | Timestamp of the last update check (used to avoid checking too frequently) | +| `semanticMemoryEnabled` | Enables semantic memory across sessions. Set to `false` or use `/settings` → **Advanced** → **Semantic Memory** to keep agents stateless. | +| `semanticMemoryTokenBudget` | Approximate token ceiling for the recalled `## Project Context` block. Default `240`, clamped to 40-4000. Adjustable from `/settings` → **Advanced**. | +| `semanticMemoryLimit` | Maximum memories considered for a single prompt. Default `8`, clamped to 1-50. Adjustable from `/settings` → **Advanced**. | | `alternateScreen` | When `true`, starts in fullscreen mode (alternate screen buffer with in-app scrolling) by default. The `--alt-screen`/`--no-alt-screen` CLI flags override this for a single run. See [CLI Options](../getting-started/index.md#cli-options). | ### Paste Configuration diff --git a/docs/features/commands.md b/docs/features/commands.md index 26d6a066e..290e53adc 100644 --- a/docs/features/commands.md +++ b/docs/features/commands.md @@ -13,7 +13,7 @@ Type `/` in the chat input to see available commands. All commands start with `/ | Command | Description | |---------|-------------| | `/help` | Show available commands | -| `/init` | Initialize project with intelligent analysis, create AGENTS.md and configuration files. Use `/init --force` to regenerate AGENTS.md if it already exists, or `/init --lean` to skip merging `CLAUDE.md` content into the generated AGENTS.md | +| `/init` | Initialize the project with intelligent analysis and create `AGENTS.md`. Use `/init --preset ` for bundled stack guidance, ignore patterns, and a `/check` command skill; `/init --force` regenerates `AGENTS.md`, and `/init --lean` skips merging `CLAUDE.md` | | `/setup-config` | Open a configuration file in your `$EDITOR` (lists project and global config files) | | `/clear` | Clear chat history | | `/model` | Switch between available models from any configured provider | @@ -46,6 +46,8 @@ Type `/` in the chat input to see available commands. All commands start with `/ | `/explorer` | Interactive file browser to navigate, preview, and select files for context | | `/tune` | Configure runtime model behaviour — tool profiles, compaction, native tools, model parameters (see [Tune](tune.md)) | | `/ide` | Connect to an IDE for live integration (e.g., VS Code diff previews) | +| `/remember` | Save a durable project memory (see [Semantic Memory](semantic-memory.md)) | +| `/memory` | List, delete, propose, and accept project memories (see [Semantic Memory](semantic-memory.md)) | | `/privacy` | Inspect what the prompt scrubber will remove from your prompts | | `/credits` | Show project contributors and dependencies | | `/copilot-login` | Log in to GitHub Copilot via device flow. Saves credentials for the "GitHub Copilot" provider | diff --git a/docs/features/custom-tools.md b/docs/features/custom-tools.md index 12b3a06b6..5b0321ad8 100644 --- a/docs/features/custom-tools.md +++ b/docs/features/custom-tools.md @@ -90,7 +90,7 @@ timeout_ms: 30000 # default 30000, max 300000 cwd: ./scripts # default: project root; supports ${VAR}; must stay in the project env: FOO: bar # extra env vars; values support ${VAR} -shell: bash | sh # default: bash if available, else sh +shell: bash | sh # default: bash if available, else sh; Windows: ComSpec/cmd.exe --- # Body is a shell script. See "Template Syntax" below. @@ -122,11 +122,13 @@ This is containment against misconfiguration, not a sandbox. The script body is The body is a shell script with two placeholder forms: -- **`{{ name }}`** — substitutes `args[name]`, shell-quoted. Arrays expand to space-separated quoted tokens. +- **`{{ name }}`** — substitutes `args[name]`, POSIX-quoted. Arrays expand to space-separated quoted tokens. Not cmd-safe; see below. - **`{{# name }}…{{/ name }}`** — section: included only when `args[name]` is truthy (non-empty string, non-empty array, non-zero number, `true`, etc.). Nested sections are supported. - **`{{^ name }}…{{/ name }}`** — inverted section: included only when `args[name]` is falsy/empty (the complement of `{{# name }}`). -All substituted values are wrapped in POSIX single quotes and any embedded single quotes are escaped. This blocks shell injection through parameter values: +All substituted values are wrapped in POSIX single quotes and any embedded single quotes are escaped. That is shell-safe under bash/sh. It is **not** shell-safe under cmd.exe: cmd does not treat `'` as a quote, so `type {{ file }}` becomes `type 'notes.txt'` (file not found) and `&`, `|`, `>`, `^`, `%VAR%` in a value can break out. Per-shell quoting in `renderValue` is a follow-up (#1084); until then the default Windows shell is cmd. + +This blocks shell injection through parameter values on POSIX: ```markdown echo {{ name }} @@ -145,7 +147,7 @@ echo '; rm -rf /; #' When the tool runs: 1. Parameters are validated against the declared schema. Validation errors (missing required params, wrong types, pattern mismatch, etc.) come back as `⚒ Missing required parameter: foo`-style messages without invoking the script. -2. The body is rendered, then handed to the chosen shell via `-c`. +2. The body is rendered, then handed to the chosen shell (`-c` for bash/sh, `/d /s /c` for cmd.exe). `shell: bash` / `shell: sh` still spawn `/bin/bash` or `/bin/sh` even on Windows, which typically fails with "Custom tool failed to start" if those binaries are missing. 3. `cwd` and `env` are resolved (with `${VAR}` and `${VAR:-default}` substitution against `process.env`). See [Working directory](#working-directory) for the containment rules. 4. The script runs with `timeout_ms` enforcement. 5. On exit code 0, stdout (and any stderr) is returned to the model, truncated at the standard output limit. @@ -168,7 +170,7 @@ When the tool runs: ## Security Model -A custom tool runs with your full shell privileges. The trust boundary is "you wrote this file or you trust the repo it came from" — the same model as `.nanocoder/commands/`, `.envrc`, or `package.json` scripts. Parameter values are shell-escaped, but the script body itself is whatever you wrote: if you put `rm -rf /` in there, it will run. +A custom tool runs with your full shell privileges. The trust boundary is "you wrote this file or you trust the repo it came from" — the same model as `.nanocoder/commands/`, `.envrc`, or `package.json` scripts. Parameter values are POSIX-quoted, which is not a cmd.exe injection barrier. The script body itself is whatever you wrote: if you put `rm -rf /` in there, it will run. Project tools sit in `.nanocoder/tools/` and travel with the repo; personal tools sit in `~/.config/nanocoder/tools/` and don't. Treat custom tools from an unfamiliar repo with the same skepticism you'd apply to running its install script. diff --git a/docs/features/index.md b/docs/features/index.md index 3973e1a14..71d49c9e8 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -168,7 +168,7 @@ The AI also has a task tool and will proactively create and update tasks when wo ### Project Setup with `/init` -Run `/init` to analyze your project and generate an `AGENTS.md` file — a project-specific prompt that gives the AI context about your codebase, conventions, and tooling. Use `/init --force` to regenerate it. +Run `/init` or `nanocoder init` to analyze your project and generate an `AGENTS.md` file — a project-specific prompt that gives the AI context about your codebase, conventions, and tooling. Use `--preset react`, `--preset nextjs`, or `--preset rust` to add bundled stack guidance, a `.nanocoderignore`, and a `/check` command skill. Use `/init --force` to regenerate `AGENTS.md`; existing preset files are preserved. The `AGENTS.md` file is automatically loaded every session, so the AI always knows how your project works. @@ -247,6 +247,7 @@ Extend Nanocoder's capabilities by connecting [MCP (Model Context Protocol) serv | [Checkpointing](checkpointing.md) | Saving and restoring conversation snapshots | | [Session Management](session-management.md) | Automatic session saving and resumption | | [Task Management](task-management.md) | Tracking multi-step work | +| [Semantic Memory](semantic-memory.md) | Save durable project facts and recall them automatically across sessions | | [File Explorer](file-explorer.md) | Interactive file browser for context selection | | [Image Attachments](image-attachments.md) | Send screenshots and images to vision-capable models | | [VS Code Extension](vscode-extension.md) | Editor integration with live diff previews | diff --git a/docs/features/semantic-memory.md b/docs/features/semantic-memory.md new file mode 100644 index 000000000..19ebe70da --- /dev/null +++ b/docs/features/semantic-memory.md @@ -0,0 +1,114 @@ +--- +title: "Semantic Memory" +description: "Save durable project facts and recall them automatically across sessions" +sidebar_order: 13 +--- + +# Semantic Memory + +Semantic memory lets you save durable facts about a project - architectural decisions, conventions, known issues, rejected approaches - so you don't have to re-explain them every session. Relevant memories are automatically recalled and injected into the system prompt as project context. + +Memory creation is always manual and explicit. Nothing is ever saved automatically after a session; you decide what's worth remembering. + +## Commands + +- `/remember [--category ] ` - Save a memory directly. `-c` is a short form of `--category`. +- `/memory list` - List all saved memories with their short IDs and categories. `/memory ls` is an alias, and a bare `/memory` with no subcommand does the same thing. +- `/memory delete ` - Delete a specific memory. `/memory rm` is an alias. +- `/memory clear` - Delete all memories for the current project. +- `/memory propose` - Scan the recent conversation for durable-sounding facts and print them as numbered proposals for review. +- `/memory accept ` - Save proposal `n` from the most recent `/memory propose` output. + +### Example + +``` +/remember The auth module uses Clerk and avoids middleware in the edge runtime. +/remember -c codingStyle Use camelCase for all variable names. + +/memory list +/memory delete 18d51c0d +/memory propose +/memory accept 2 +``` + +### Memory IDs + +`/memory list` prints an 8-character short ID for each memory, which is what you pass to `/memory delete`. The full UUID still works, as does any unambiguous prefix of either. If a prefix matches more than one memory, the command reports the ambiguity and deletes nothing rather than guessing. + +## Categories + +Memories are grouped into: `architecture`, `bugFix`, `refactor`, `todo`, `codingStyle`, or `project` (the default, for anything that doesn't match a more specific category). `/remember` infers a category automatically from the content unless you pass `--category`. + +## Recall + +When you send a message, Nanocoder ranks saved memories by relevance to that message (keyword overlap, with common words filtered out) and injects the most relevant ones into the system prompt under a `## Project Context` heading, up to a token budget. Low-relevance memories are dropped rather than injected as noise. + +A memory is kept when it covers at least 10% of the query's keywords, and either the category matched, at least two keywords overlapped, or a single overlapping keyword is at least half the query. That last rule is why `auth` and `fix auth` both recall a Clerk/auth memory, while a one-word hit in a long prompt still does not. Memories that do not fit the remaining token budget are skipped so a later, shorter memory can still be injected. + +Retrieval is keyword-based, not a true embeddings/vector search. The "semantic" in the name refers to the kind of facts stored (durable project knowledge), not the matching technique. + +### Where recall is active + +Recall runs on: + +- the interactive TUI +- `nanocoder run` / `--plain` +- `--acp` +- subagent runs (the `agent` tool) +- daemon-triggered skill runs (they use the same subagent executor) + +The TUI, plain shell, and ACP print `Recalling N project memories...` when memories are injected. Subagent and daemon runs inject the same block silently, since there is no chat UI to attach that notice to. + +### Tuning the budget + +Two settings bound how much of the context window project context may consume. Both are adjustable from `/settings` -> **Advanced**, which cycles through common presets, or by editing `nanocoder-preferences.json` directly for any value in range. + +| Preference key | Default | Range | Meaning | +|---|---|---|---| +| `semanticMemoryEnabled` | `true` | boolean | Master switch for recall and writes | +| `semanticMemoryTokenBudget` | `240` | 40 - 4000 | Approximate token ceiling for the injected block | +| `semanticMemoryLimit` | `8` | 1 - 50 | Maximum memories considered for one prompt | + +Values outside the supported range are clamped rather than rejected. On a small local model the 240-token default is a meaningful slice of the window, so lowering it is often the right call. + +## Proposals + +`/memory propose` looks back through the recent conversation for lines that read like durable facts (matched against the category keywords above) and prints them with their source (`explicit-user` or `conversation-inferred`) and a short evidence snippet. Nothing is saved until you run `/memory accept `. + +The scan covers the last 40 messages and prints at most 20 proposals, so a long session doesn't produce a list too large to review. Proposals without warnings are listed first. The printed numbering is fixed for as long as that list stands: accepting one proposal does not renumber the others, and accepting the same number twice is refused rather than repeated. Running `/clear` discards the list, since its evidence refers to a conversation you can no longer see. + +### Warnings + +Proposals inferred purely from assistant text carry an `Inferred from conversation, no explicit user statement.` warning. + +A proposal is additionally flagged `Possible assistant position reversal.` when the assistant turn looks like a concession to pressure rather than to evidence. That means the turn was preceded by a user message carrying no code, file path, or error output, and the turn either contradicts an earlier assistant turn on the same subject or opens with an agreement phrase. Tool-call turns (including ones that also have narration) and short "let me look at the file" turns are stepped over, so the check still works in a normal agentic session where the assistant reads files between turns. CamelCase words and the bare word "error" in ordinary prose do not count as technical evidence. + +This catches the case where a model agreeing with a user's stylistic preference gets summarized into a "project convention" that was never actually decided. If you explicitly restate the same line yourself, the reversal warning is cleared, since your own statement is what actually resolves the ambiguity. + +The check is a heuristic tuned to over-flag rather than miss: it only adds a warning to a proposal you are already reviewing by hand, so a spurious warning costs you a moment's attention while a missed one costs you a false project convention. + +## Turning It Off + +Semantic memory is on by default. Toggle it from `/settings` -> **Advanced** -> **Semantic Memory**. Turning it off disables both recall (memories are no longer injected into prompts) and writes (`/remember` and `/memory accept` are refused while it's off). + +## Storage and Scope + +Memories are stored per-repository in a local JSON file under the Nanocoder data directory: + +| Platform | Path | +|---|---| +| macOS | `~/Library/Application Support/nanocoder/memory/` | +| Linux | `~/.local/share/nanocoder/memory/` (or `$XDG_DATA_HOME/nanocoder/memory/`) | +| Windows | `%APPDATA%\nanocoder\memory\` | + +Setting `NANOCODER_DATA_DIR` overrides all of these. + +The filename is a hash of the repository's `git remote origin.url`, or of its absolute path for non-git directories. This means: + +- All branches, worktrees, and local clones that share the same `origin` remote share one memory pool. +- Forks with a different `origin` get their own, separate pool. +- This scope isn't currently configurable. If you work across branches with genuinely divergent conventions in the same repository, they'll share memories. + +Each repository file is capped at 500 memories. Saving past that drops the oldest entries (by timestamp) so the file cannot grow without bound. Writes to the same file are serialized across manager instances in one process, and locked across processes (TUI and daemon). + +Files are written atomically (temp file + rename) with restrictive permissions (`0600` on the file, `0700` on the directory), and nothing ever leaves your machine. Memory content is fenced when injected into the system prompt, with the fence widened as needed so content containing backticks cannot break out of it. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 256d1d5b5..b848a61fa 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -60,6 +60,7 @@ nanocoder -h | `--no-alt-screen` | | Force inline mode (the default), even if `alternateScreen: true` is set in your preferences file. | | `--continue` | `-c` | Resume the most recent [saved session](../features/session-management.md) for the current directory; starts a fresh session if none exists. Interactive only — errors with `run`. Mutually exclusive with `--resume`. | | `--resume [id]` | `-r` | Resume a [saved session](../features/session-management.md) by session ID, 1-based list index, or `last`. With no ID, opens the session picker at startup. Errors if the session is not found. Interactive only — errors with `run`. | +| `init [--preset ]` | | Initialize the current project. Bundled presets: `react`, `nextjs`, and `rust` | | `run` | | Run in non-interactive mode | **Provider/Model Flags:** @@ -85,6 +86,34 @@ nanocoder --mode normal run "refactor db module" If `--mode` is omitted, interactive mode starts in `normal` and `run` mode starts in `auto-accept` (the previous defaults). +## Project Initialization Presets + +Initialize a project from the terminal with automatic project analysis: + +```bash +nanocoder init +``` + +Add `--preset` to seed stack-specific guidance, context ignore patterns, and a +`/check` command skill: + +```bash +nanocoder init --preset react +nanocoder init --preset nextjs +nanocoder init --preset rust +``` + +Every preset creates an analyzed `AGENTS.md`, a `.nanocoderignore`, and +`.nanocoder/commands/check.md`. The selected preset supplies the project type +and fills in stack defaults while detected languages, package scripts, and +commands remain authoritative where applicable. Existing files are never +silently replaced: an already initialized project is refused unless `--force` +is passed, `--force` only regenerates `AGENTS.md`, and existing preset files are +preserved. + +The interactive `/init` command accepts the same options, including +`/init --preset nextjs`, `/init --force`, and `/init --lean`. + ## Interactive Mode To start Nanocoder in interactive mode (the default), simply run: diff --git a/package.json b/package.json index 5fe0eaaa3..9d1e5b851 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "changeset:version": "changeset version && node scripts/normalize-changelog.js", "format": "biome check --write .", "format:check": "biome check .", - "build:vscode": "cd plugins/vscode && pnpm run build && pnpm exec vsce package --allow-missing-repository --skip-license --no-dependencies -o ../../assets/nanocoder-vscode.vsix", + "build:vscode": "cd plugins/vscode && pnpm run build && mkdir -p ../../assets && pnpm exec vsce package --allow-missing-repository --skip-license --no-dependencies -o ../../assets/nanocoder-vscode.vsix", "prepublishOnly": "pnpm run build && pnpm run build:vscode", "generate:system-prompts": "tsx scripts/generate-system-prompts.ts", "prepare": "husky" @@ -78,6 +78,7 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@nanocollective/get-md": "^1.6.0", "@nanocollective/prompt-scrub": "^1.0.1", + "@vscode/ripgrep": "^1.18.0", "ai": "6.0.193", "chalk": "^6.0.0", "chokidar": "^5.0.0", @@ -95,6 +96,7 @@ "ink-spinner": "^5.0.0", "ink-tab": "^5.2.0", "llama-tokenizer-js": "^1.2.2", + "lru-cache": "^11.5.1", "pino": "^10.1.0", "pino-pretty": "^13.1.3", "pino-roll": "^4.0.0", diff --git a/plugins/vscode/.vscodeignore b/plugins/vscode/.vscodeignore index 1605242de..77177bf79 100644 --- a/plugins/vscode/.vscodeignore +++ b/plugins/vscode/.vscodeignore @@ -5,5 +5,5 @@ test-stubs/** node_modules/** .gitignore tsconfig.json -tailwind.config.js +scripts/** **/*.map diff --git a/plugins/vscode/media/chat-panel.css b/plugins/vscode/media/chat-panel.css deleted file mode 100644 index c94888bfd..000000000 --- a/plugins/vscode/media/chat-panel.css +++ /dev/null @@ -1 +0,0 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-top-10{top:-2.5rem}.bottom-24{bottom:6rem}.bottom-\[calc\(100\%\+8px\)\]{bottom:calc(100% + 8px)}.left-0{left:0}.left-1\/2{left:50%}.right-0{right:0}.top-0{top:0}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.mx-1{margin-left:.25rem;margin-right:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-3{margin-top:.75rem;margin-bottom:.75rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.ml-1{margin-left:.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mr-\[2px\]{margin-right:2px}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-\[1px\]{margin-top:1px}.\!block{display:block!important}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.hidden{display:none}.h-12{height:3rem}.h-24{height:6rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-\[1\.2em\]{height:1.2em}.h-full{height:100%}.max-h-64{max-height:16rem}.max-h-\[250px\]{max-height:250px}.max-h-\[calc\(100vh-100px\)\]{max-height:calc(100vh - 100px)}.max-h-full{max-height:100%}.min-h-\[28px\]{min-height:28px}.min-h-\[44px\]{min-height:44px}.w-12{width:3rem}.w-24{width:6rem}.w-5{width:1.25rem}.w-8{width:2rem}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[15\%\]{max-width:15%}.max-w-\[30\%\]{max-width:30%}.max-w-\[40\%\]{max-width:40%}.max-w-\[85\%\]{max-width:85%}.max-w-full{max-width:100%}.flex-1{flex:1 1 0%}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-hidden{overflow-y:hidden}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l-\[3px\]{border-left-width:3px}.border-t{border-top-width:1px}.border-none{border-style:none}.border-vscode-border{border-color:var(--vscode-panel-border,hsla(0,0%,50%,.2))}.border-vscode-button-secondary{border-color:var(--vscode-button-secondaryBackground)}.border-vscode-focusBorder{border-color:var(--vscode-focusBorder)}.border-vscode-input-border{border-color:var(--vscode-input-border,transparent)}.border-vscode-input-focus{border-color:var(--vscode-focusBorder)}.border-vscode-widget-border{border-color:var(--vscode-widget-border)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-transparent{background-color:transparent}.bg-vscode-bg{background-color:var(--vscode-editor-background)}.bg-vscode-button-bg{background-color:var(--vscode-button-background)}.bg-vscode-button-secondary{background-color:var(--vscode-button-secondaryBackground)}.bg-vscode-dropdown-bg{background-color:var(--vscode-dropdown-background)}.bg-vscode-input-bg{background-color:var(--vscode-input-background)}.bg-vscode-list-active{background-color:var(--vscode-list-activeSelectionBackground)}.bg-vscode-widget-bg{background-color:var(--vscode-editorWidget-background)}.bg-vscode-widget-header{background-color:var(--vscode-editorWidget-border)}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.pb-1{padding-bottom:.25rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pl-3{padding-left:.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-2\.5{padding-top:.625rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.font-vscode{font-family:var(--vscode-font-family)}.text-\[0\.65em\]{font-size:.65em}.text-\[0\.75em\]{font-size:.75em}.text-\[0\.78em\]{font-size:.78em}.text-\[0\.7em\]{font-size:.7em}.text-\[0\.82em\]{font-size:.82em}.text-\[0\.85em\]{font-size:.85em}.text-\[0\.8em\]{font-size:.8em}.text-\[0\.95em\]{font-size:.95em}.text-\[0\.9em\]{font-size:.9em}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-\[0\.04em\]{letter-spacing:.04em}.tracking-\[0\.06em\]{letter-spacing:.06em}.text-\[\#3178C6\]{--tw-text-opacity:1;color:rgb(49 120 198/var(--tw-text-opacity,1))}.text-\[\#563D7C\]{--tw-text-opacity:1;color:rgb(86 61 124/var(--tw-text-opacity,1))}.text-\[\#89d185\]{--tw-text-opacity:1;color:rgb(137 209 133/var(--tw-text-opacity,1))}.text-\[\#CB3837\]{--tw-text-opacity:1;color:rgb(203 56 55/var(--tw-text-opacity,1))}.text-\[\#E34F26\]{--tw-text-opacity:1;color:rgb(227 79 38/var(--tw-text-opacity,1))}.text-\[\#F1E05A\]{--tw-text-opacity:1;color:rgb(241 224 90/var(--tw-text-opacity,1))}.text-\[\#cccccc\]{--tw-text-opacity:1;color:rgb(204 204 204/var(--tw-text-opacity,1))}.text-\[\#f14c4c\]{--tw-text-opacity:1;color:rgb(241 76 76/var(--tw-text-opacity,1))}.text-vscode-button-fg{color:var(--vscode-button-foreground)}.text-vscode-dropdown-fg{color:var(--vscode-dropdown-foreground)}.text-vscode-fg{color:var(--vscode-editor-foreground)}.text-vscode-input-fg{color:var(--vscode-input-foreground)}.text-vscode-list-activeFg{color:var(--vscode-list-activeSelectionForeground)}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.line-through{text-decoration-line:line-through}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-65{opacity:.65}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}body,html{height:100%;width:100%;margin:0;padding:0;overflow:hidden;font-family:var(--vscode-font-family);font-size:var(--vscode-font-size)}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--vscode-scrollbarSlider-background);border:3px solid transparent;background-clip:padding-box;border-radius:5px}::-webkit-scrollbar-thumb:hover{background:var(--vscode-scrollbarSlider-hoverBackground);border:3px solid transparent;background-clip:padding-box}::-webkit-scrollbar-thumb:active{background:var(--vscode-scrollbarSlider-activeBackground);border:3px solid transparent;background-clip:padding-box}select{border:1px solid var(--vscode-dropdown-border,transparent)}select,select option{background-color:var(--vscode-dropdown-background);color:var(--vscode-dropdown-foreground)}.markdown-body p{margin-bottom:.5em;margin-top:0}.markdown-body p:last-child{margin-bottom:0}.markdown-body strong{font-weight:600}.markdown-body ul{list-style-type:disc}.markdown-body ol,.markdown-body ul{padding-left:1.5em;margin-bottom:.75em}.markdown-body ol{list-style-type:decimal}.markdown-body li{margin-bottom:.25em}.markdown-body code{font-family:var(--vscode-editor-font-family,monospace);padding:.1em .3em;border-radius:3px;font-size:.9em}.markdown-body code,.markdown-body pre{background-color:var(--vscode-textCodeBlock-background,rgba(0,0,0,.1))}.markdown-body pre{padding:.75em;border-radius:4px;overflow-x:auto;max-width:100%;margin-bottom:.75em}.markdown-body pre code{background-color:transparent;padding:0;font-size:.85em}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4{font-weight:600;margin-top:1em;margin-bottom:.5em}.markdown-body h1{font-size:1.5em}.markdown-body h2{font-size:1.3em}.markdown-body h3{font-size:1.1em}.context-chip{display:inline-flex;align-items:center;gap:6px;max-width:200px;padding:4px 8px;border-radius:12px;border:1px solid var(--vscode-dropdown-border,hsla(0,0%,59%,.2));background-color:var(--vscode-editorWidget-background);font-size:.85em;font-weight:500;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:nowrap;transition:all .15s ease}.context-chip:hover{border-color:var(--vscode-focusBorder);opacity:.95}.context-chip .chip-name{overflow:hidden;text-overflow:ellipsis}.context-chip .chip-remove{margin-left:2px;opacity:.5;font-size:1.1em;line-height:1;cursor:pointer;transition:opacity .15s ease}.context-chip .chip-remove:hover{opacity:1}#composer-box.drag-over:after{content:"Drop files or folders";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;border:2px dashed var(--vscode-focusBorder);border-radius:inherit;background:var(--vscode-input-background);opacity:.92;font-size:.85em;font-weight:600;pointer-events:none;z-index:10}#timeline-strip.timeline-disabled{opacity:.45;pointer-events:none}#timeline-nodes{position:relative}.timeline-line{position:absolute;left:10px;right:10px;top:50%;height:2px;background:var(--vscode-panel-border,hsla(0,0%,50%,.35));transform:translateY(-50%);pointer-events:none}.timeline-node{position:relative;z-index:1;width:28px;height:28px;display:flex;align-items:center;justify-content:center;flex-shrink:0;background:transparent;border:none;padding:0;cursor:pointer}.timeline-dot{width:10px;height:10px;border-radius:50%;border:2px solid var(--vscode-focusBorder);background:var(--vscode-editor-background);transition:transform .12s ease,background .12s ease}.timeline-node[data-kind=edit] .timeline-dot{border-color:var(--vscode-focusBorder)}.timeline-node[data-kind=execute] .timeline-dot{border-color:var(--vscode-editorWarning-foreground,#cca700)}.timeline-node[data-kind=other] .timeline-dot{border-color:var(--vscode-descriptionForeground,hsla(0,0%,50%,.8))}.timeline-node[data-kind=now] .timeline-dot{width:8px;height:8px;background:var(--vscode-button-background);border-color:var(--vscode-button-background)}.timeline-node.is-selected .timeline-dot,.timeline-node:hover .timeline-dot{transform:scale(1.35);background:var(--vscode-focusBorder)}.timeline-node[data-kind=now].is-selected .timeline-dot,.timeline-node[data-kind=now]:hover .timeline-dot{background:var(--vscode-button-background)}.timeline-node:focus-visible .timeline-dot{outline:1px solid var(--vscode-focusBorder);outline-offset:2px}#timeline-hint{white-space:nowrap}.timeline-confirm-actions{display:flex;gap:8px;margin-top:8px}.timeline-confirm-actions button{border:none;border-radius:4px;padding:4px 10px}.settings-tab,.timeline-confirm-actions button{font-size:.85em;cursor:pointer;font-family:var(--vscode-font-family)}.settings-tab{background:transparent;border:none;border-bottom:2px solid transparent;color:var(--vscode-editor-foreground);opacity:.6;padding:.5rem .75rem;transition:opacity .15s,border-color .15s}.settings-tab:hover{opacity:.9}.settings-tab.active{opacity:1;border-bottom-color:var(--vscode-focusBorder);font-weight:600}.settings-section{background:var(--vscode-editorWidget-background);border:1px solid var(--vscode-widget-border);border-radius:6px;padding:.75rem}.settings-section-title{font-size:.8em;font-weight:600;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-bottom:.5rem}.settings-list{display:flex;flex-direction:column;gap:0}.settings-list-item{display:flex;align-items:center;gap:.5rem;padding:.375rem 0;font-size:.9em;border-bottom:1px solid var(--vscode-widget-border)}.settings-list-item:last-child{border-bottom:none}.settings-list-item-name{font-weight:500;flex-shrink:0}.settings-list-item-detail{opacity:.6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.settings-list-empty{font-size:.85em;opacity:.5;padding:.25rem 0;font-style:italic}.settings-row{display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding:.375rem 0;border-bottom:1px solid var(--vscode-widget-border)}.settings-row:last-child{border-bottom:none}.settings-row-info{display:flex;flex-direction:column;flex:1;min-width:0}.settings-row-label{font-size:.9em;font-weight:500}.settings-row-desc{font-size:.78em;opacity:.55;margin-top:.1em}.settings-toggle{position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0;cursor:pointer}.settings-toggle input{opacity:0;width:0;height:0}.settings-toggle-slider{position:absolute;top:0;left:0;right:0;bottom:0;background-color:var(--vscode-input-background);border:1px solid var(--vscode-input-border,transparent);border-radius:20px;transition:background-color .2s}.settings-toggle-slider:before{content:"";position:absolute;height:14px;width:14px;left:2px;bottom:2px;background-color:var(--vscode-editor-foreground);opacity:.6;border-radius:50%;transition:transform .2s,opacity .2s}.settings-toggle input:checked+.settings-toggle-slider{background-color:var(--vscode-button-background);border-color:var(--vscode-button-background)}.settings-toggle input:checked+.settings-toggle-slider:before{transform:translateX(16px);opacity:1;background-color:var(--vscode-button-foreground)}.settings-select{background-color:var(--vscode-dropdown-background);color:var(--vscode-dropdown-foreground);border:1px solid var(--vscode-dropdown-border,transparent);border-radius:4px;padding:.25rem .5rem;font-family:var(--vscode-font-family);font-size:.85em;cursor:pointer;outline:none;min-width:100px}.settings-select:focus{border-color:var(--vscode-focusBorder)}.settings-number-input{background-color:var(--vscode-input-background);color:var(--vscode-input-foreground);border:1px solid var(--vscode-input-border,transparent);border-radius:4px;padding:.25rem .5rem;font-family:var(--vscode-font-family);font-size:.85em;width:70px;outline:none;text-align:center}.settings-number-input:focus{border-color:var(--vscode-focusBorder)}.settings-action-btn{display:flex;align-items:center;gap:.5rem;background:transparent;border:1px solid var(--vscode-button-secondaryBackground);color:var(--vscode-editor-foreground);border-radius:4px;padding:.375rem .625rem;font-family:var(--vscode-font-family);font-size:.85em;cursor:pointer;transition:background-color .15s}.settings-action-btn:hover{background-color:var(--vscode-button-secondaryHoverBackground)}.settings-action-btn-danger{border-color:rgba(241,76,76,.4);color:#f14c4c}.settings-action-btn-danger:hover{background-color:rgba(241,76,76,.1)}.settings-badge{display:inline-flex;align-items:center;gap:.25rem;font-size:.78em;padding:.125rem .375rem;border-radius:3px;font-weight:500}.settings-badge-ok{background-color:rgba(137,209,133,.15);color:#89d185}.settings-badge-off{background-color:hsla(0,0%,80%,.1);color:#999}.first\:border-t-0:first-child{border-top-width:0}.empty\:hidden:empty{display:none}.focus-within\:border-vscode-input-focus:focus-within{border-color:var(--vscode-focusBorder)}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-vscode-focusBorder:hover{border-color:var(--vscode-focusBorder)}.hover\:bg-black\/80:hover{background-color:rgba(0,0,0,.8)}.hover\:bg-vscode-button-hover:hover{background-color:var(--vscode-button-hoverBackground)}.hover\:bg-vscode-button-secondaryHover:hover{background-color:var(--vscode-button-secondaryHoverBackground)}.hover\:bg-vscode-list-hover:hover{background-color:var(--vscode-list-hoverBackground)}.hover\:bg-vscode-toolbarHover:hover{background-color:var(--vscode-toolbar-hoverBackground,hsla(0,0%,50%,.15))}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:opacity-100{opacity:1}.group.is-processing .group-\[\.is-processing\]\:block{display:block}.group.is-processing .group-\[\.is-processing\]\:hidden{display:none}.\[\&\.is-processing\]\:bg-vscode-button-secondary.is-processing{background-color:var(--vscode-button-secondaryBackground)}.\[\&\.is-processing\]\:hover\:bg-vscode-button-secondaryHover:hover.is-processing{background-color:var(--vscode-button-secondaryHoverBackground)}.\[\&_svg\]\:mr-0 svg{margin-right:0}.\[\&_svg\]\:h-6 svg{height:1.5rem}.\[\&_svg\]\:w-6 svg{width:1.5rem} \ No newline at end of file diff --git a/plugins/vscode/package.json b/plugins/vscode/package.json index 0c5944c36..68c1d3961 100644 --- a/plugins/vscode/package.json +++ b/plugins/vscode/package.json @@ -215,11 +215,9 @@ "@types/vscode": "^1.125.0", "@types/ws": "^8.5.10", "@vscode/vsce": "^3.9.1", - "autoprefixer": "^10.4.19", "concurrently": "^8.2.2", "esbuild": "^0.28.1", "eslint": "^10.1.0", - "postcss": "^8.4.38", "tailwindcss": "^4.3.3", "typescript": "^7.0.2" }, diff --git a/plugins/vscode/scripts/verify-theme-css.js b/plugins/vscode/scripts/verify-theme-css.js new file mode 100644 index 000000000..c523bf2f2 --- /dev/null +++ b/plugins/vscode/scripts/verify-theme-css.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +// Asserts that utility classes used in the chat-panel HTML/JS are present in the compiled CSS. +// Dependency-free script to prevent silent failures where the .vsix builds but lacks theme colors. + +import {readFileSync, existsSync} from 'node:fs'; +import {join, dirname} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const pkgRoot = join(here, '..'); +const cssPath = join(pkgRoot, 'media', 'chat-panel.css'); +const htmlPath = join(pkgRoot, 'media', 'chat-panel.html'); +const jsPath = join(pkgRoot, 'media', 'chat-panel.js'); + +if (!existsSync(cssPath)) { + console.error( + `verify-theme-css: ${cssPath} is missing. Run \`pnpm run build:vscode\` first.`, + ); + process.exit(1); +} + +// Extract vscode-* Tailwind classes (including variant prefixes like hover:). +// 'vscode-…' must precede 'vscode' to avoid partial matches. +const tailwindClass = /(?:[a-zA-Z-]+:)*[a-zA-Z][\w-]*-(?:vscode-[\w-]+|vscode)\b/g; + +const referenced = new Set(); +for (const path of [htmlPath, jsPath]) { + if (!existsSync(path)) continue; + const text = readFileSync(path, 'utf8'); + for (const match of text.match(tailwindClass) ?? []) { + referenced.add(match); + } +} + +// Spot-check critical tokens whose absence would be visually obvious. +const requiredTokens = [ + 'bg-vscode-bg', + 'text-vscode-fg', + 'bg-vscode-input-bg', + 'border-vscode-input-border', + 'bg-vscode-button-bg', + 'text-vscode-button-fg', + 'border-vscode-border', +]; + +const css = readFileSync(cssPath, 'utf8'); + +// Escape colons for variant selectors (e.g. .hover\:bg-vscode-foo). +function compiledSelectorFor(token) { + return token.replaceAll(':', '\\\\:'); +} + +function compiledAsClass(token) { + const selector = compiledSelectorFor(token); + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp + return new RegExp(`\\.${selector}(?![\\w-])`).test(css); +} + +const missing = [...referenced].filter((cls) => !compiledAsClass(cls)); + +const missingRequired = requiredTokens.filter( + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp + (token) => !new RegExp(`\\.${token}(?![\\w-])`).test(css), +); + +if (missing.length === 0 && missingRequired.length === 0) { + const scanned = referenced.size; + console.log( + `verify-theme-css: ok — ${scanned} theme class(es) found in ${cssPath}`, + ); + process.exit(0); +} + +console.error( + 'verify-theme-css: the compiled CSS is missing theme utilities referenced', +); +console.error(' by the webview templates. The webview would render unthemed.'); +console.error(''); + +if (missingRequired.length > 0) { + console.error('Required tokens that did not compile:'); + for (const token of missingRequired) { + console.error(` - .${token}`); + } + console.error(''); +} + +if (missing.length > 0) { + const preview = missing.slice(0, 20); + console.error(`Other missing classes (${missing.length} total, showing first ${preview.length}):`); + for (const cls of preview) { + console.error(` - .${cls}`); + } + if (missing.length > preview.length) { + console.error(` ... and ${missing.length - preview.length} more`); + } + console.error(''); +} + +console.error( + 'This usually means the Tailwind theme moved (e.g. tailwind.config.js no', +); +console.error( + 'longer being read by Tailwind v4) or @theme tokens were deleted. Check', +); +console.error('that src/styles.css declares every `--color-vscode-*` used by'); +console.error('media/chat-panel.{html,js}.'); + +process.exit(1); diff --git a/plugins/vscode/src/styles.css b/plugins/vscode/src/styles.css index bfa0aac2c..868d72657 100644 --- a/plugins/vscode/src/styles.css +++ b/plugins/vscode/src/styles.css @@ -1,290 +1,538 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; -/* Custom base styles for webviews that Tailwind doesn't reset out of the box for our specific use-case */ -html, body { - height: 100%; - width: 100%; - margin: 0; - padding: 0; - overflow: hidden; - font-family: var(--vscode-font-family); - font-size: var(--vscode-font-size); +/* ── VS Code theme tokens ────────────────────────────── + * Tailwind v4 reads theme from CSS, not tailwind.config.js. + * Each `--color-vscode-*` token below becomes a `bg-`, + * `text-`, `border-` (etc.) utility that resolves to the + * matching `var(--vscode-*)` CSS variable VS Code exposes + * to webviews. Adding a token here is enough to make a + * utility class available in `media/chat-panel.{html,js}`. + */ +@theme { + --color-vscode-bg: var(--vscode-editor-background); + --color-vscode-fg: var(--vscode-editor-foreground); + + --color-vscode-input-bg: var(--vscode-input-background); + --color-vscode-input-fg: var(--vscode-input-foreground); + --color-vscode-input-border: var(--vscode-input-border, transparent); + --color-vscode-input-focus: var(--vscode-focusBorder); + + --color-vscode-button-bg: var(--vscode-button-background); + --color-vscode-button-fg: var(--vscode-button-foreground); + --color-vscode-button-hover: var(--vscode-button-hoverBackground); + --color-vscode-button-secondary: var(--vscode-button-secondaryBackground); + --color-vscode-button-secondaryHover: var( + --vscode-button-secondaryHoverBackground + ); + + --color-vscode-border: var(--vscode-panel-border, rgba(128, 128, 128, 0.2)); + + --color-vscode-list-hover: var(--vscode-list-hoverBackground); + --color-vscode-list-active: var(--vscode-list-activeSelectionBackground); + --color-vscode-list-activeFg: var(--vscode-list-activeSelectionForeground); + --color-vscode-list-error: var(--vscode-list-errorForeground, #f44747); + + --color-vscode-error: var(--vscode-editorError-foreground, #f48771); + --color-vscode-focusBorder: var(--vscode-focusBorder); + + --color-vscode-dropdown-bg: var(--vscode-dropdown-background); + --color-vscode-dropdown-fg: var(--vscode-dropdown-foreground); + --color-vscode-dropdown-border: var(--vscode-dropdown-border, transparent); + --color-vscode-dropdown-foreground: var(--vscode-dropdown-foreground); + + --color-vscode-symbolIcon-fileForeground: var( + --vscode-symbolIcon-fileForeground + ); + + --color-vscode-toolbarHover: var( + --vscode-toolbar-hoverBackground, + rgba(128, 128, 128, 0.15) + ); + + --color-vscode-widget-bg: var(--vscode-editorWidget-background); + --color-vscode-widget-border: var(--vscode-widget-border); + --color-vscode-widget-header: var(--vscode-editorWidget-border); + + --color-vscode-editor-bg: var(--vscode-editor-background); + + --font-vscode: var(--vscode-font-family); +} + +/* ── Webview shell ───────────────────────────────────── + * Tailwind v4 doesn't ship a Preflight reset for raw HTML + * the way v3 did, so keep the body/html sizing we rely on. + */ +html, +body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; + overflow: hidden; + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); } /* Custom VS Code-native Scrollbar Styling */ ::-webkit-scrollbar { - width: 10px; - height: 10px; + width: 10px; + height: 10px; } ::-webkit-scrollbar-track { - background: transparent; + background: transparent; } ::-webkit-scrollbar-thumb { - background: var(--vscode-scrollbarSlider-background); - border: 3px solid transparent; - background-clip: padding-box; - border-radius: 5px; + background: var(--vscode-scrollbarSlider-background); + border: 3px solid transparent; + background-clip: padding-box; + border-radius: 5px; } ::-webkit-scrollbar-thumb:hover { - background: var(--vscode-scrollbarSlider-hoverBackground); - border: 3px solid transparent; - background-clip: padding-box; + background: var(--vscode-scrollbarSlider-hoverBackground); + border: 3px solid transparent; + background-clip: padding-box; } ::-webkit-scrollbar-thumb:active { - background: var(--vscode-scrollbarSlider-activeBackground); - border: 3px solid transparent; - background-clip: padding-box; + background: var(--vscode-scrollbarSlider-activeBackground); + border: 3px solid transparent; + background-clip: padding-box; } /* Style native selects to blend with VS Code themes */ select { - background-color: var(--vscode-dropdown-background); - color: var(--vscode-dropdown-foreground); - border: 1px solid var(--vscode-dropdown-border, transparent); + background-color: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); + border: 1px solid var(--vscode-dropdown-border, transparent); } select option { - background-color: var(--vscode-dropdown-background); - color: var(--vscode-dropdown-foreground); + background-color: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); } /* Basic Markdown styling since Tailwind resets all headings, lists, and bold text */ .markdown-body p { - margin-bottom: 0.5em; - margin-top: 0; + margin-bottom: 0.5em; + margin-top: 0; } .markdown-body p:last-child { - margin-bottom: 0; + margin-bottom: 0; } .markdown-body strong { - font-weight: 600; + font-weight: 600; } .markdown-body ul { - list-style-type: disc; - padding-left: 1.5em; - margin-bottom: 0.75em; + list-style-type: disc; + padding-left: 1.5em; + margin-bottom: 0.75em; } .markdown-body ol { - list-style-type: decimal; - padding-left: 1.5em; - margin-bottom: 0.75em; + list-style-type: decimal; + padding-left: 1.5em; + margin-bottom: 0.75em; } .markdown-body li { - margin-bottom: 0.25em; + margin-bottom: 0.25em; } .markdown-body code { - font-family: var(--vscode-editor-font-family, monospace); - background-color: var(--vscode-textCodeBlock-background, rgba(0,0,0,0.1)); - padding: 0.1em 0.3em; - border-radius: 3px; - font-size: 0.9em; + font-family: var(--vscode-editor-font-family, monospace); + background-color: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.1)); + padding: 0.1em 0.3em; + border-radius: 3px; + font-size: 0.9em; } .markdown-body pre { - background-color: var(--vscode-textCodeBlock-background, rgba(0,0,0,0.1)); - padding: 0.75em; - border-radius: 4px; - overflow-x: auto; - max-width: 100%; - margin-bottom: 0.75em; + background-color: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.1)); + padding: 0.75em; + border-radius: 4px; + overflow-x: auto; + max-width: 100%; + margin-bottom: 0.75em; } .markdown-body pre code { - background-color: transparent; - padding: 0; - font-size: 0.85em; + background-color: transparent; + padding: 0; + font-size: 0.85em; +} +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4 { + font-weight: 600; + margin-top: 1em; + margin-bottom: 0.5em; } -.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { - font-weight: 600; - margin-top: 1em; - margin-bottom: 0.5em; +.markdown-body h1 { + font-size: 1.5em; +} +.markdown-body h2 { + font-size: 1.3em; +} +.markdown-body h3 { + font-size: 1.1em; } -.markdown-body h1 { font-size: 1.5em; } -.markdown-body h2 { font-size: 1.3em; } -.markdown-body h3 { font-size: 1.1em; } /* ── Context Chip ───────────────────────────────────── */ .context-chip { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: 200px; - padding: 4px 8px; - border-radius: 12px; - border: 1px solid var(--vscode-dropdown-border, rgba(150, 150, 150, 0.2)); - background-color: var(--vscode-editorWidget-background); - font-size: 0.85em; - font-weight: 500; - cursor: pointer; - user-select: none; - white-space: nowrap; - transition: all 150ms ease; + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 200px; + padding: 4px 8px; + border-radius: 12px; + border: 1px solid var(--vscode-dropdown-border, rgba(150, 150, 150, 0.2)); + background-color: var(--vscode-editorWidget-background); + font-size: 0.85em; + font-weight: 500; + cursor: pointer; + user-select: none; + white-space: nowrap; + transition: all 150ms ease; } .context-chip:hover { - border-color: var(--vscode-focusBorder); - opacity: 0.95; + border-color: var(--vscode-focusBorder); + opacity: 0.95; } .context-chip .chip-name { - overflow: hidden; - text-overflow: ellipsis; + overflow: hidden; + text-overflow: ellipsis; } .context-chip .chip-remove { - margin-left: 2px; - opacity: 0.5; - font-size: 1.1em; - line-height: 1; - cursor: pointer; - transition: opacity 150ms ease; + margin-left: 2px; + opacity: 0.5; + font-size: 1.1em; + line-height: 1; + cursor: pointer; + transition: opacity 150ms ease; +} +.context-chip .chip-remove:hover { + opacity: 1; } -.context-chip .chip-remove:hover { opacity: 1; } /* ── Drop Overlay ───────────────────────────────────── */ #composer-box.drag-over::after { - content: 'Drop files or folders'; - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - border: 2px dashed var(--vscode-focusBorder); - border-radius: inherit; - background: var(--vscode-input-background); - opacity: 0.92; - font-size: 0.85em; - font-weight: 600; - pointer-events: none; - z-index: 10; + content: 'Drop files or folders'; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border: 2px dashed var(--vscode-focusBorder); + border-radius: inherit; + background: var(--vscode-input-background); + opacity: 0.92; + font-size: 0.85em; + font-weight: 600; + pointer-events: none; + z-index: 10; } /* ── Action Timeline ────────────────────────────────── */ #timeline-strip.timeline-disabled { - opacity: 0.45; - pointer-events: none; + opacity: 0.45; + pointer-events: none; } #timeline-nodes { - position: relative; + position: relative; } .timeline-line { - position: absolute; - left: 10px; - right: 10px; - top: 50%; - height: 2px; - background: var(--vscode-panel-border, rgba(128,128,128,0.35)); - transform: translateY(-50%); - pointer-events: none; + position: absolute; + left: 10px; + right: 10px; + top: 50%; + height: 2px; + background: var(--vscode-panel-border, rgba(128, 128, 128, 0.35)); + transform: translateY(-50%); + pointer-events: none; } .timeline-node { - position: relative; - z-index: 1; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - background: transparent; - border: none; - padding: 0; - cursor: pointer; + position: relative; + z-index: 1; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: transparent; + border: none; + padding: 0; + cursor: pointer; } .timeline-dot { - width: 10px; - height: 10px; - border-radius: 50%; - border: 2px solid var(--vscode-focusBorder); - background: var(--vscode-editor-background); - transition: transform 120ms ease, background 120ms ease; + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid var(--vscode-focusBorder); + background: var(--vscode-editor-background); + transition: transform 120ms ease, background 120ms ease; } .timeline-node[data-kind="edit"] .timeline-dot { - border-color: var(--vscode-focusBorder); + border-color: var(--vscode-focusBorder); } .timeline-node[data-kind="execute"] .timeline-dot { - border-color: var(--vscode-editorWarning-foreground, #cca700); + border-color: var(--vscode-editorWarning-foreground, #cca700); } .timeline-node[data-kind="other"] .timeline-dot { - border-color: var(--vscode-descriptionForeground, rgba(128,128,128,0.8)); + border-color: var(--vscode-descriptionForeground, rgba(128, 128, 128, 0.8)); } .timeline-node[data-kind="now"] .timeline-dot { - width: 8px; - height: 8px; - background: var(--vscode-button-background); - border-color: var(--vscode-button-background); + width: 8px; + height: 8px; + background: var(--vscode-button-background); + border-color: var(--vscode-button-background); } .timeline-node.is-selected .timeline-dot, .timeline-node:hover .timeline-dot { - transform: scale(1.35); - background: var(--vscode-focusBorder); + transform: scale(1.35); + background: var(--vscode-focusBorder); } .timeline-node[data-kind="now"].is-selected .timeline-dot, .timeline-node[data-kind="now"]:hover .timeline-dot { - background: var(--vscode-button-background); + background: var(--vscode-button-background); } .timeline-node:focus-visible .timeline-dot { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 2px; + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; } #timeline-hint { - white-space: nowrap; + white-space: nowrap; } .timeline-confirm-actions { - display: flex; - gap: 8px; - margin-top: 8px; + display: flex; + gap: 8px; + margin-top: 8px; } .timeline-confirm-actions button { - border: none; - border-radius: 4px; - padding: 4px 10px; - font-size: 0.85em; - cursor: pointer; - font-family: var(--vscode-font-family); + border: none; + border-radius: 4px; + padding: 4px 10px; + font-size: 0.85em; + cursor: pointer; + font-family: var(--vscode-font-family); } /* ── Settings UI ────────────────────────────────────── */ -.settings-tab { background: transparent; border: none; border-bottom: 2px solid transparent; color: var(--vscode-editor-foreground); opacity: 0.6; padding: 0.5rem 0.75rem; font-family: var(--vscode-font-family); font-size: 0.85em; cursor: pointer; transition: opacity 0.15s, border-color 0.15s; } -.settings-tab:hover { opacity: 0.9; } -.settings-tab.active { opacity: 1; border-bottom-color: var(--vscode-focusBorder); font-weight: 600; } -.settings-section { background: var(--vscode-editorWidget-background); border: 1px solid var(--vscode-widget-border); border-radius: 6px; padding: 0.75rem; } -.settings-section-title { font-size: 0.8em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.6; margin-bottom: 0.5rem; } -.settings-list { display: flex; flex-direction: column; gap: 0; } -.settings-list-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.9em; border-bottom: 1px solid var(--vscode-widget-border); } -.settings-list-item:last-child { border-bottom: none; } -.settings-list-item-name { font-weight: 500; flex-shrink: 0; } -.settings-list-item-detail { opacity: 0.6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } -.settings-list-empty { font-size: 0.85em; opacity: 0.5; padding: 0.25rem 0; font-style: italic; } -.settings-row { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.375rem 0; border-bottom: 1px solid var(--vscode-widget-border); } -.settings-row:last-child { border-bottom: none; } -.settings-row-info { display: flex; flex-direction: column; flex: 1; min-width: 0; } -.settings-row-label { font-size: 0.9em; font-weight: 500; } -.settings-row-desc { font-size: 0.78em; opacity: 0.55; margin-top: 0.1em; } -.settings-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; cursor: pointer; } -.settings-toggle input { opacity: 0; width: 0; height: 0; } -.settings-toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, transparent); border-radius: 20px; transition: background-color 0.2s; } -.settings-toggle-slider:before { content: ""; position: absolute; height: 14px; width: 14px; left: 2px; bottom: 2px; background-color: var(--vscode-editor-foreground); opacity: 0.6; border-radius: 50%; transition: transform 0.2s, opacity 0.2s; } -.settings-toggle input:checked + .settings-toggle-slider { background-color: var(--vscode-button-background); border-color: var(--vscode-button-background); } -.settings-toggle input:checked + .settings-toggle-slider:before { transform: translateX(16px); opacity: 1; background-color: var(--vscode-button-foreground); } -.settings-select { background-color: var(--vscode-dropdown-background); color: var(--vscode-dropdown-foreground); border: 1px solid var(--vscode-dropdown-border, transparent); border-radius: 4px; padding: 0.25rem 0.5rem; font-family: var(--vscode-font-family); font-size: 0.85em; cursor: pointer; outline: none; min-width: 100px; } -.settings-select:focus { border-color: var(--vscode-focusBorder); } -.settings-number-input { background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border, transparent); border-radius: 4px; padding: 0.25rem 0.5rem; font-family: var(--vscode-font-family); font-size: 0.85em; width: 70px; outline: none; text-align: center; } -.settings-number-input:focus { border-color: var(--vscode-focusBorder); } -.settings-action-btn { display: flex; align-items: center; gap: 0.5rem; background: transparent; border: 1px solid var(--vscode-button-secondaryBackground); color: var(--vscode-editor-foreground); border-radius: 4px; padding: 0.375rem 0.625rem; font-family: var(--vscode-font-family); font-size: 0.85em; cursor: pointer; transition: background-color 0.15s; } -.settings-action-btn:hover { background-color: var(--vscode-button-secondaryHoverBackground); } -.settings-action-btn-danger { border-color: rgba(241, 76, 76, 0.4); color: #f14c4c; } -.settings-action-btn-danger:hover { background-color: rgba(241, 76, 76, 0.1); } -.settings-badge { display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.78em; padding: 0.125rem 0.375rem; border-radius: 3px; font-weight: 500; } -.settings-badge-ok { background-color: rgba(137, 209, 133, 0.15); color: #89d185; } -.settings-badge-off { background-color: hsla(0, 0%, 80%, 0.1); color: #999; } +.settings-tab { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--vscode-editor-foreground); + opacity: 0.6; + padding: 0.5rem 0.75rem; + font-family: var(--vscode-font-family); + font-size: 0.85em; + cursor: pointer; + transition: opacity 0.15s, border-color 0.15s; +} +.settings-tab:hover { + opacity: 0.9; +} +.settings-tab.active { + opacity: 1; + border-bottom-color: var(--vscode-focusBorder); + font-weight: 600; +} +.settings-section { + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-widget-border); + border-radius: 6px; + padding: 0.75rem; +} +.settings-section-title { + font-size: 0.8em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.6; + margin-bottom: 0.5rem; +} +.settings-list { + display: flex; + flex-direction: column; + gap: 0; +} +.settings-list-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0; + font-size: 0.9em; + border-bottom: 1px solid var(--vscode-widget-border); +} +.settings-list-item:last-child { + border-bottom: none; +} +.settings-list-item-name { + font-weight: 500; + flex-shrink: 0; +} +.settings-list-item-detail { + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} +.settings-list-empty { + font-size: 0.85em; + opacity: 0.5; + padding: 0.25rem 0; + font-style: italic; +} +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.375rem 0; + border-bottom: 1px solid var(--vscode-widget-border); +} +.settings-row:last-child { + border-bottom: none; +} +.settings-row-info { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} +.settings-row-label { + font-size: 0.9em; + font-weight: 500; +} +.settings-row-desc { + font-size: 0.78em; + opacity: 0.55; + margin-top: 0.1em; +} +.settings-toggle { + position: relative; + display: inline-block; + width: 36px; + height: 20px; + flex-shrink: 0; + cursor: pointer; +} +.settings-toggle input { + opacity: 0; + width: 0; + height: 0; +} +.settings-toggle-slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 20px; + transition: background-color 0.2s; +} +.settings-toggle-slider:before { + content: ""; + position: absolute; + height: 14px; + width: 14px; + left: 2px; + bottom: 2px; + background-color: var(--vscode-editor-foreground); + opacity: 0.6; + border-radius: 50%; + transition: transform 0.2s, opacity 0.2s; +} +.settings-toggle input:checked + .settings-toggle-slider { + background-color: var(--vscode-button-background); + border-color: var(--vscode-button-background); +} +.settings-toggle input:checked + .settings-toggle-slider:before { + transform: translateX(16px); + opacity: 1; + background-color: var(--vscode-button-foreground); +} +.settings-select { + background-color: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); + border: 1px solid var(--vscode-dropdown-border, transparent); + border-radius: 4px; + padding: 0.25rem 0.5rem; + font-family: var(--vscode-font-family); + font-size: 0.85em; + cursor: pointer; + outline: none; + min-width: 100px; +} +.settings-select:focus { + border-color: var(--vscode-focusBorder); +} +.settings-number-input { + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 4px; + padding: 0.25rem 0.5rem; + font-family: var(--vscode-font-family); + font-size: 0.85em; + width: 70px; + outline: none; + text-align: center; +} +.settings-number-input:focus { + border-color: var(--vscode-focusBorder); +} +.settings-action-btn { + display: flex; + align-items: center; + gap: 0.5rem; + background: transparent; + border: 1px solid var(--vscode-button-secondaryBackground); + color: var(--vscode-editor-foreground); + border-radius: 4px; + padding: 0.375rem 0.625rem; + font-family: var(--vscode-font-family); + font-size: 0.85em; + cursor: pointer; + transition: background-color 0.15s; +} +.settings-action-btn:hover { + background-color: var(--vscode-button-secondaryHoverBackground); +} +.settings-action-btn-danger { + border-color: rgba(241, 76, 76, 0.4); + color: #f14c4c; +} +.settings-action-btn-danger:hover { + background-color: rgba(241, 76, 76, 0.1); +} +.settings-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.78em; + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-weight: 500; +} +.settings-badge-ok { + background-color: rgba(137, 209, 133, 0.15); + color: #89d185; +} +.settings-badge-off { + background-color: hsla(0, 0%, 80%, 0.1); + color: #999; +} diff --git a/plugins/vscode/tailwind.config.js b/plugins/vscode/tailwind.config.js deleted file mode 100644 index 4fefe4678..000000000 --- a/plugins/vscode/tailwind.config.js +++ /dev/null @@ -1,51 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: ["./media/**/*.{html,js}"], - theme: { - extend: { - colors: { - vscode: { - bg: 'var(--vscode-editor-background)', - fg: 'var(--vscode-editor-foreground)', - input: { - bg: 'var(--vscode-input-background)', - fg: 'var(--vscode-input-foreground)', - border: 'var(--vscode-input-border, transparent)', - focus: 'var(--vscode-focusBorder)' - }, - button: { - bg: 'var(--vscode-button-background)', - fg: 'var(--vscode-button-foreground)', - hover: 'var(--vscode-button-hoverBackground)', - secondary: 'var(--vscode-button-secondaryBackground)', - secondaryHover: 'var(--vscode-button-secondaryHoverBackground)' - }, - border: 'var(--vscode-panel-border, rgba(128,128,128,0.2))', - list: { - hover: 'var(--vscode-list-hoverBackground)', - active: 'var(--vscode-list-activeSelectionBackground)', - activeFg: 'var(--vscode-list-activeSelectionForeground)', - error: 'var(--vscode-list-errorForeground, #f44747)' - }, - error: 'var(--vscode-editorError-foreground, #f48771)', - focusBorder: 'var(--vscode-focusBorder)', - dropdown: { - bg: 'var(--vscode-dropdown-background)', - fg: 'var(--vscode-dropdown-foreground)', - border: 'var(--vscode-dropdown-border, transparent)' - }, - toolbarHover: 'var(--vscode-toolbar-hoverBackground, rgba(128,128,128,0.15))', - widget: { - bg: 'var(--vscode-editorWidget-background)', - border: 'var(--vscode-widget-border)', - header: 'var(--vscode-editorWidget-border)' - } - } - }, - fontFamily: { - vscode: 'var(--vscode-font-family)' - } - }, - }, - plugins: [], -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e9cc4bf2..f2cff4ef8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@nanocollective/prompt-scrub': specifier: ^1.0.1 version: 1.0.1 + '@vscode/ripgrep': + specifier: ^1.18.0 + version: 1.18.0 ai: specifier: 6.0.193 version: 6.0.193(zod@4.4.3) @@ -93,6 +96,9 @@ importers: llama-tokenizer-js: specifier: ^1.2.2 version: 1.2.2 + lru-cache: + specifier: ^11.5.1 + version: 11.5.1 pino: specifier: ^10.1.0 version: 10.3.1 @@ -200,9 +206,6 @@ importers: '@vscode/vsce': specifier: ^3.9.1 version: 3.9.2 - autoprefixer: - specifier: ^10.4.19 - version: 10.5.4(postcss@8.5.25) concurrently: specifier: ^8.2.2 version: 8.2.2 @@ -212,9 +215,6 @@ importers: eslint: specifier: ^10.1.0 version: 10.4.1(jiti@2.7.0) - postcss: - specifier: ^8.4.38 - version: 8.5.25 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -1752,6 +1752,69 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} + '@vscode/ripgrep-darwin-arm64@1.18.0': + resolution: {integrity: sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==} + cpu: [arm64] + os: [darwin] + + '@vscode/ripgrep-darwin-x64@1.18.0': + resolution: {integrity: sha512-25b4gWbL138dGuQU244ebCKKc0q05ULBMoFSz9oAEUHNeqK/lOJViDS7DRvbDazzAzSEdan391Znks/R5mkaTQ==} + cpu: [x64] + os: [darwin] + + '@vscode/ripgrep-linux-arm64@1.18.0': + resolution: {integrity: sha512-lQ/5zTG++U0E3IhVgS4EPTTn/U4okncaRMM5GOFfOYZywS4nuD31GhkHbNYlDk5CuDC68+hYJ0/eQeyCKJDA+g==} + cpu: [arm64] + os: [linux] + + '@vscode/ripgrep-linux-arm@1.18.0': + resolution: {integrity: sha512-GDAvufNDHu8zqLEmXstalQF0Wh6wQvdsBi/Vg3Yi3CK4a8XoFXqqXVEHEZ9xQz3t0NfoSEc9JbvK9DDS6FxyxQ==} + cpu: [arm] + os: [linux] + + '@vscode/ripgrep-linux-ia32@1.18.0': + resolution: {integrity: sha512-YWLkSUtFd4Jh5EepIhA9RJSfv3uMAVMo+2rBIGHPBnvgLrZciIs2cDKei1/p6Wc/aCzUoHyMAg2R6tw4ZCBKGg==} + cpu: [ia32] + os: [linux] + + '@vscode/ripgrep-linux-ppc64@1.18.0': + resolution: {integrity: sha512-quXVY8fwQ8O/lvU1yrSqSl3jlUzysRSb+AfUfCL/tRtphxsKlFvPAejryZ6vg4Bgvn8XL74xb4qMCDmWgYrT5w==} + cpu: [ppc64] + os: [linux] + + '@vscode/ripgrep-linux-riscv64@1.18.0': + resolution: {integrity: sha512-f5kBQBrWfQt8Q7OhSORuNDei5dkYagBj3y4jImSUXGMy8B/Ke7SltSRcUtjPv166FAFfHCAmWuZp3+cWnX2/Vw==} + cpu: [riscv64] + os: [linux] + + '@vscode/ripgrep-linux-s390x@1.18.0': + resolution: {integrity: sha512-rTOcJFGGcl2c07RUOWUo4U1ndnemKhY6A9hnMB18uk7jSgJc0d/QLBGWMWpumdtoJtpizn/wIv5mXIisJukusQ==} + cpu: [s390x] + os: [linux] + + '@vscode/ripgrep-linux-x64@1.18.0': + resolution: {integrity: sha512-mQ3bVrUpnD2vs7QT0vX90Lt0cnUq467uFtEktIdsJJmW296RoSULRGqWgzG1AKxyBpNDD6l4ZO4qKf6SgyC23Q==} + cpu: [x64] + os: [linux] + + '@vscode/ripgrep-win32-arm64@1.18.0': + resolution: {integrity: sha512-vfTIjq1OHnzUjxZcHVQAMbnggp8dpGf+0QKFOZHwWPqFwXxQC8eCWM+5NUdoJ6yrElCeMzoUTXoK/LdZaniB+Q==} + cpu: [arm64] + os: [win32] + + '@vscode/ripgrep-win32-ia32@1.18.0': + resolution: {integrity: sha512-//rfAE+BOw5AC2EMmepmiE36jUuevtQYNQqqlw1s3m9FlRxjxEut97RkRPHAu9BG4mSojatZx+kXZXNdyI9caQ==} + cpu: [ia32] + os: [win32] + + '@vscode/ripgrep-win32-x64@1.18.0': + resolution: {integrity: sha512-KNPvtElldqILHdnAetujPaowkNbpqJy3ssIGGN6F6Kve9Qi+nNLI2DN01O83JjCEVQbCzl8Ov3QZ9Eov3BR8Dg==} + cpu: [x64] + os: [win32] + + '@vscode/ripgrep@1.18.0': + resolution: {integrity: sha512-ns5lWe44tSfbTMbVUsyB+I1819PVSw4AdpgK0RNkzfWfwy6+3IUNSxwSrfTno1/oWaS/hERNz+XLWVyga2aJBQ==} + '@vscode/vsce-sign-alpine-arm64@2.0.6': resolution: {integrity: sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==} cpu: [arm64] @@ -1923,13 +1986,6 @@ packages: resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - autoprefixer@10.5.4: - resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - ava@8.0.1: resolution: {integrity: sha512-YlwwL5HX2EJRE75e2LR8nio8lKAJ702sFbv0QYnhzngAjyo9wB+Xg4JZh5f432khlWfVD5OQ93rrRopEXJptpg==} engines: {node: ^22.20 || ^24.12 || >=26} @@ -1950,11 +2006,6 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.43: - resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} - engines: {node: '>=6.0.0'} - hasBin: true - binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1994,11 +2045,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.6: - resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -2042,9 +2088,6 @@ packages: resolution: {integrity: sha512-kfzR4zzQtAE9PC7CzZsjl3aBNbXWuXiSeOCdLcPpBfGW8YuCqQHcRPFDbr/BPVmd3EEPVpuFzLyuT/cUhPr4OQ==} engines: {node: '>=12.20'} - caniuse-lite@1.0.30001806: - resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} - cbor2@2.3.0: resolution: {integrity: sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==} engines: {node: '>=20'} @@ -2368,9 +2411,6 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.393: - resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} - emittery@2.0.0: resolution: {integrity: sha512-FLtgn/CGBXiX3ZtPAm5q4LWWepHChOt55J9u01WFu3dyap2U7IwptlrqoE1COR/kxwdy/DOxIBALSxIW449I1g==} engines: {node: '>=22'} @@ -2643,9 +2683,6 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fraction.js@5.3.4: - resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3397,11 +3434,6 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -3435,10 +3467,6 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true - node-releases@2.0.51: - resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} - engines: {node: '>=18'} - node-sarif-builder@3.4.0: resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} engines: {node: '>=20'} @@ -3651,13 +3679,6 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@8.5.25: - resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} - engines: {node: ^10 || ^12 || >=14} - powershell-utils@0.2.0: resolution: {integrity: sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==} engines: {node: '>=20'} @@ -4269,12 +4290,6 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -5729,6 +5744,57 @@ snapshots: '@vercel/oidc@3.2.0': {} + '@vscode/ripgrep-darwin-arm64@1.18.0': + optional: true + + '@vscode/ripgrep-darwin-x64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-arm64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-arm@1.18.0': + optional: true + + '@vscode/ripgrep-linux-ia32@1.18.0': + optional: true + + '@vscode/ripgrep-linux-ppc64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-riscv64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-s390x@1.18.0': + optional: true + + '@vscode/ripgrep-linux-x64@1.18.0': + optional: true + + '@vscode/ripgrep-win32-arm64@1.18.0': + optional: true + + '@vscode/ripgrep-win32-ia32@1.18.0': + optional: true + + '@vscode/ripgrep-win32-x64@1.18.0': + optional: true + + '@vscode/ripgrep@1.18.0': + optionalDependencies: + '@vscode/ripgrep-darwin-arm64': 1.18.0 + '@vscode/ripgrep-darwin-x64': 1.18.0 + '@vscode/ripgrep-linux-arm': 1.18.0 + '@vscode/ripgrep-linux-arm64': 1.18.0 + '@vscode/ripgrep-linux-ia32': 1.18.0 + '@vscode/ripgrep-linux-ppc64': 1.18.0 + '@vscode/ripgrep-linux-riscv64': 1.18.0 + '@vscode/ripgrep-linux-s390x': 1.18.0 + '@vscode/ripgrep-linux-x64': 1.18.0 + '@vscode/ripgrep-win32-arm64': 1.18.0 + '@vscode/ripgrep-win32-ia32': 1.18.0 + '@vscode/ripgrep-win32-x64': 1.18.0 + '@vscode/vsce-sign-alpine-arm64@2.0.6': optional: true @@ -5898,15 +5964,6 @@ snapshots: auto-bind@5.0.1: {} - autoprefixer@10.5.4(postcss@8.5.25): - dependencies: - browserslist: 4.28.6 - caniuse-lite: 1.0.30001806 - fraction.js: 5.3.4 - picocolors: 1.1.1 - postcss: 8.5.25 - postcss-value-parser: 4.2.0 - ava@8.0.1(@ava/typescript@7.0.0): dependencies: '@vercel/nft': 1.10.2 @@ -5966,8 +6023,6 @@ snapshots: base64-js@1.5.1: optional: true - baseline-browser-mapping@2.10.43: {} - binary-extensions@2.3.0: {} binaryextensions@6.11.0: @@ -6017,14 +6072,6 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.6: - dependencies: - baseline-browser-mapping: 2.10.43 - caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.393 - node-releases: 2.0.51 - update-browserslist-db: 1.2.3(browserslist@4.28.6) - buffer-crc32@0.2.13: {} buffer-equal-constant-time@1.0.1: {} @@ -6069,8 +6116,6 @@ snapshots: callsites@4.2.0: {} - caniuse-lite@1.0.30001806: {} - cbor2@2.3.0: dependencies: '@cto.af/wtf8': 0.0.5 @@ -6396,8 +6441,6 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.393: {} - emittery@2.0.0: {} emoji-regex@10.6.0: {} @@ -6761,8 +6804,6 @@ snapshots: forwarded@0.2.0: {} - fraction.js@5.3.4: {} - fresh@2.0.0: {} fs-constants@1.0.0: @@ -7454,8 +7495,6 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.16: {} - napi-build-utils@2.0.0: optional: true @@ -7479,8 +7518,6 @@ snapshots: node-gyp-build@4.8.4: {} - node-releases@2.0.51: {} - node-sarif-builder@3.4.0: dependencies: '@types/sarif': 2.1.7 @@ -7737,14 +7774,6 @@ snapshots: pluralize@8.0.0: {} - postcss-value-parser@4.2.0: {} - - postcss@8.5.25: - dependencies: - nanoid: 3.3.16 - picocolors: 1.1.1 - source-map-js: 1.2.1 - powershell-utils@0.2.0: {} prebuild-install@7.1.3: @@ -8379,12 +8408,6 @@ snapshots: unpipe@1.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.6): - dependencies: - browserslist: 4.28.6 - escalade: 3.2.0 - picocolors: 1.1.1 - uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/source/acp/acp-agent.spec.ts b/source/acp/acp-agent.spec.ts index a3e7fa13c..11c8adbb2 100644 --- a/source/acp/acp-agent.spec.ts +++ b/source/acp/acp-agent.spec.ts @@ -12,6 +12,7 @@ import { } from '@/message-handler'; import {convertToModelMessages} from '@/ai-sdk-client/converters/message-converter'; import {sessionManager} from '@/session/session-manager'; +import {SemanticMemoryManager} from '@/memory/semantic-memory-manager'; console.log('\nacp-agent.spec.ts'); @@ -946,3 +947,34 @@ test('AcpAgent.extMethod - timeline/list throws on missing session', async t => ); }); +test('AcpAgent.prompt - recalls relevant project memories scoped to the session cwd, without accumulating across turns', async t => { + await new SemanticMemoryManager({cwd: '/tmp'}).addMemory({ + content: 'Auth uses Clerk and avoids middleware.', + }); + + const capturedSystemPrompts: string[] = []; + const conn = createMockConn(); + const initContext = createMockInitContext(); + initContext.client = { + ...initContext.client, + chat: async (messages: Array<{content: string}>) => { + capturedSystemPrompts.push(messages[0]?.content ?? ''); + return {choices: [{message: {content: 'Test response'}}]}; + }, + } as any; + const agent = new AcpAgent(initContext, conn); + const session = await agent.newSession({cwd: '/tmp'}); + + await agent.prompt({ + sessionId: session.sessionId, + prompt: [{type: 'text', text: 'refactor auth middleware handling'}], + }); + await agent.prompt({ + sessionId: session.sessionId, + prompt: [{type: 'text', text: 'unrelated question about docs'}], + }); + + t.true(capturedSystemPrompts[0]?.includes('## Project Context')); + t.true(capturedSystemPrompts[0]?.includes('Auth uses Clerk')); + t.false(capturedSystemPrompts[1]?.includes('## Project Context')); +}); diff --git a/source/acp/acp-agent.ts b/source/acp/acp-agent.ts index 2bfdd710b..383e22ce0 100644 --- a/source/acp/acp-agent.ts +++ b/source/acp/acp-agent.ts @@ -46,8 +46,13 @@ import {artifactManager} from '@/artifacts/artifact-manager'; import {isInternalWalkthroughMessage} from '@/artifacts/walkthrough-lifecycle'; import {createLLMClient} from '@/client-factory'; import {getAppConfig} from '@/config/index'; -import {loadPreferences, updateLastUsed} from '@/config/preferences'; +import { + getProjectContextPreferences, + loadPreferences, + updateLastUsed, +} from '@/config/preferences'; import {resolveTune} from '@/config/tune'; +import {appendRelevantProjectContextWithCount} from '@/memory/project-context'; import {TimelineManager} from '@/services/timeline-manager'; import {sessionManager} from '@/session/session-manager'; import {getTuneToolMode} from '@/types/config'; @@ -334,6 +339,25 @@ export class AcpAgent implements Agent { }, ]; + if (session.baseSystemMessage) { + const projectContext = await appendRelevantProjectContextWithCount( + session.baseSystemMessage.content, + userText, + session.getMemoryFinder(), + getProjectContextPreferences(), + ); + session.systemMessage = { + role: 'system', + content: projectContext.systemPrompt, + }; + setLastBuiltPrompt(projectContext.systemPrompt); + if (projectContext.memoryCount > 0) { + logger.info( + `ACP recall: session=${params.sessionId} count=${projectContext.memoryCount}`, + ); + } + } + const config = getAppConfig(); const nonInteractiveAlwaysAllow = config.alwaysAllow ?? []; @@ -840,7 +864,8 @@ export class AcpAgent implements Agent { ); setLastBuiltPrompt(systemContent); - session.systemMessage = {role: 'system', content: systemContent}; + session.baseSystemMessage = {role: 'system', content: systemContent}; + session.systemMessage = session.baseSystemMessage; } private async saveAcpSessionToDisk(session: AcpSession): Promise { diff --git a/source/acp/acp-conversation.spec.ts b/source/acp/acp-conversation.spec.ts index e12bccf1a..a18508bda 100644 --- a/source/acp/acp-conversation.spec.ts +++ b/source/acp/acp-conversation.spec.ts @@ -6,6 +6,7 @@ import test from 'ava'; import type {AgentSideConnection} from '@agentclientprotocol/sdk'; import {AcpSession} from '@/acp/acp-session'; import {runAcpConversation} from '@/acp/acp-conversation'; +import {signalToolApproval} from '@/utils/tool-approval-queue'; import { setToolRegistryGetter, setToolManagerGetter, @@ -2183,3 +2184,158 @@ test.serial( }, ); + +// ============================================================================ +// Sub-agent tool approval (#1019) +// ============================================================================ + +/** + * Drives signalToolApproval from inside the turn, which is the only way the + * sub-agent executor ever reaches it. Firing it after runAcpConversation + * returned would only pass while the handler leaked past the turn. + */ +const approveFromInsideTurn = async ( + requestPermission: (p: any) => Promise, + subagentName = 'docs', +) => { + const updates: any[] = []; + const permissionRequests: any[] = []; + let approved: boolean | undefined; + let signalled = false; + + const conn = { + sessionUpdate: async (u: any) => { + updates.push(u.update); + }, + requestPermission: async (p: any) => { + permissionRequests.push(p); + return requestPermission(p); + }, + } as unknown as AgentSideConnection; + + const session = createMockSession(conn); + // chat() is awaited by the turn, so this is a point where the handler is + // installed and the turn has not returned - the state the sub-agent + // executor signals from. + const client = { + chat: async () => { + if (!signalled) { + signalled = true; + approved = await signalToolApproval({ + toolCall: createMockToolCall('write_file', {path: 'a.txt'}, 'call-1'), + subagentName, + }); + } + return { + choices: [{message: {content: 'done', tool_calls: []}}], + toolsDisabled: false, + }; + }, + } as unknown as LLMClient; + const toolManager = { + getAvailableToolNames: () => [], + getFilteredTools: () => ({}), + hasTool: () => false, + getToolEntry: () => undefined, + isReadOnly: () => true, + }; + + await runAcpConversation({ + session, + client, + toolManager: toolManager as any, + conn, + nonInteractiveAlwaysAllow: [], + }); + + return {approved, updates, permissionRequests, session}; +}; + +test('runAcpConversation - a sub-agent tool call reaches the client for permission', async t => { + const {approved, updates, permissionRequests} = await approveFromInsideTurn( + async () => ({outcome: {outcome: 'selected', optionId: 'allow'}}), + ); + + t.true(approved); + t.is(permissionRequests.length, 1); + + // Prefixed: sub-agent ids come from the sub-agent's own model and share an + // id space with top-level calls, so an unprefixed id could merge two cards. + const announcedId = permissionRequests[0].toolCall.toolCallId; + t.is(announcedId, 'subagent:call-1'); + t.true(String(permissionRequests[0].toolCall.title).includes('docs')); + + // Announced before the request, or the client rejects the id. + const announced = updates.filter( + (u: any) => u.sessionUpdate === 'tool_call' && u.toolCallId === announcedId, + ); + t.is(announced.length, 1); + + // Settled rather than left spinning: the sub-agent layer reports no result. + const terminal = updates.filter( + (u: any) => u.toolCallId === announcedId && u.status === 'completed', + ); + t.is(terminal.length, 1); +}); + +test('runAcpConversation - a denied sub-agent tool call is reported as failed', async t => { + const {approved, updates, permissionRequests} = await approveFromInsideTurn( + async () => ({outcome: {outcome: 'selected', optionId: 'deny'}}), + ); + + t.false(approved); + t.is(permissionRequests.length, 1); + const failed = updates.filter( + (u: any) => u.toolCallId === 'subagent:call-1' && u.status === 'failed', + ); + t.is(failed.length, 1); + t.is(failed[0].rawOutput, 'Denied by user'); +}); + +test('runAcpConversation - a cancelled sub-agent permission denies the call', async t => { + const {approved, updates, permissionRequests} = await approveFromInsideTurn( + async () => ({outcome: {outcome: 'cancelled'}}), + ); + + // t.false(approved) alone would pass with no handler installed at all, + // since the slot's fallback already denies. The request reaching the + // connection and the call being settled are the parts that need one. + t.false(approved); + t.is(permissionRequests.length, 1); + const failed = updates.filter( + (u: any) => u.toolCallId === 'subagent:call-1' && u.status === 'failed', + ); + t.is(failed.length, 1); + // Distinct from the deny path, so a client can tell a user's refusal from + // a turn that was torn down under it. + t.is(failed[0].rawOutput, 'Cancelled by user'); +}); + +test('runAcpConversation - a transport failure denies the tool rather than aborting the sub-agent', async t => { + const {approved, permissionRequests} = await approveFromInsideTurn(async () => { + throw new Error('connection closed'); + }); + + // signalToolApproval is awaited outside the sub-agent executor's own try, so + // a throw here would abort the whole run instead of denying one tool. + t.false(approved); + t.is(permissionRequests.length, 1); +}); + +test('runAcpConversation - the approval handler does not outlive the turn', async t => { + const {permissionRequests} = await approveFromInsideTurn(async () => ({ + outcome: {outcome: 'selected', optionId: 'allow'}, + })); + t.is(permissionRequests.length, 1); + + // The slot is a module singleton shared across sessions, so a handler left + // installed would answer a later session's approvals with this turn's + // session id and abort controller. + const afterTurn = await signalToolApproval({ + toolCall: createMockToolCall('write_file', {path: 'b.txt'}, 'call-2'), + subagentName: 'docs', + }); + + t.false(afterTurn); + t.is(permissionRequests.length, 1); +}); diff --git a/source/acp/acp-conversation.ts b/source/acp/acp-conversation.ts index a7034faa9..b95652053 100644 --- a/source/acp/acp-conversation.ts +++ b/source/acp/acp-conversation.ts @@ -44,6 +44,10 @@ import type { import {buildResponseUsage} from '@/usage/response-usage'; import {maybeAutoCompact} from '@/utils/auto-compact'; import {capMessagesForModel} from '@/utils/message-capping'; +import { + type PendingToolApproval, + setGlobalToolApprovalHandler, +} from '@/utils/tool-approval-queue'; import {createCancellationResults} from '@/utils/tool-cancellation'; import {toOptionString} from '@/utils/type-helpers'; @@ -74,8 +78,107 @@ export interface RunAcpConversationOptions { nonInteractiveAlwaysAllow: string[]; } +/** + * Sub-agent tool calls reach the approval slot in tool-approval-queue, which + * only the Ink UI installs a handler for. Under ACP the slot fell back to + * denying, so a dispatched sub-agent was refused without the client seeing a + * request. Route those to the same session/request_permission channel the + * top-level calls use. + */ +function createSubagentApprovalHandler( + session: AcpSession, + conn: AgentSideConnection, +): (approval: PendingToolApproval) => Promise { + return async ({toolCall, subagentName}) => { + // The sub-agent's own model names its tool calls and shares an id space + // with the top-level ones, so prefix to keep the two cards distinct. + const announced: ToolCall = { + ...toolCall, + id: `${SUBAGENT_TOOL_CALL_PREFIX}${toolCall.id}`, + }; + + try { + const meta = await buildToolCallMeta(announced); + const subagentMeta: AcpToolCallMeta = { + ...meta, + title: `${meta.title} (${subagentName})`, + }; + + // Announced first: a permission request naming a tool call the + // client has not seen is rejected as invalid params. + await emitToolCall(session, conn, announced, 'pending', subagentMeta); + + const permission = await requestToolPermission( + session, + announced, + conn, + subagentMeta, + session.abortController.signal, + ); + + if (permission === 'approved') { + // The sub-agent layer does not report its results back here, so + // settle the card on approval rather than leave it spinning + // forever. This does claim success before the tool has run: a + // call that then fails still shows completed. Reporting the + // real outcome means threading results out of the executor. + await emitToolCall( + session, + conn, + announced, + 'completed', + subagentMeta, + 'tool_call_update', + ); + return true; + } + + await emitToolCallUpdate( + session, + conn, + announced, + 'failed', + permission === 'cancelled' ? 'Cancelled by user' : 'Denied by user', + ); + return false; + } catch { + // signalToolApproval is awaited outside the sub-agent executor's own + // try, so a transport failure here would abort the whole sub-agent + // run. Deny the one tool instead and keep the safe-fallback posture. + return false; + } + }; +} + export async function runAcpConversation( options: RunAcpConversationOptions, +): Promise { + // The slot is a module singleton with last-writer-wins semantics, so the + // handler is scoped to this turn: without the teardown a finished turn's + // session, connection and aborted controller stay reachable, and the last + // turn to run would keep answering approvals for every later one. + // + // This does not make overlapping turns safe. turnActive is per session + // (acp-session.ts), so two sessions can be mid-turn at once, and for that + // window the later installer answers the earlier session's approvals + // against the wrong session id and abort controller. The restore is + // LIFO-correct, so routing rights hand back once the later turn ends. + // Closing the window itself needs the slot keyed by session id, or an + // approval channel threaded through SubagentExecutor. + const restoreApprovalHandler = setGlobalToolApprovalHandler( + createSubagentApprovalHandler(options.session, options.conn), + ); + try { + return await runTurn(options); + } finally { + restoreApprovalHandler(); + } +} + +const SUBAGENT_TOOL_CALL_PREFIX = 'subagent:'; + +async function runTurn( + options: RunAcpConversationOptions, ): Promise { const {session, client, toolManager, conn, nonInteractiveAlwaysAllow} = options; diff --git a/source/acp/acp-session.ts b/source/acp/acp-session.ts index ccd12f5ae..559e6d092 100644 --- a/source/acp/acp-session.ts +++ b/source/acp/acp-session.ts @@ -2,6 +2,7 @@ import type { AgentSideConnection, ClientCapabilities, } from '@agentclientprotocol/sdk'; +import {SemanticMemoryManager} from '@/memory/semantic-memory-manager'; import {TimelineManager} from '@/services/timeline-manager'; import type {DevelopmentMode, Message} from '@/types/core'; @@ -14,12 +15,14 @@ export class AcpSession { messages: Message[] = []; systemMessage?: Message; + baseSystemMessage?: Message; abortController = new AbortController(); developmentMode: DevelopmentMode; /** True while a prompt turn is being processed, to reject overlapping prompts. */ turnActive = false; /** URI of the file currently focused in the editor client (e.g. VS Code). */ activeFile?: string; + private memoryFinder?: SemanticMemoryManager; constructor(options: { sessionId: string; @@ -36,6 +39,10 @@ export class AcpSession { this.timeline = new TimelineManager(options.cwd, options.sessionId); } + getMemoryFinder(): SemanticMemoryManager { + return (this.memoryFinder ??= new SemanticMemoryManager({cwd: this.cwd})); + } + cancel(): void { this.abortController.abort(); } diff --git a/source/acp/acp-tool-call.spec.ts b/source/acp/acp-tool-call.spec.ts index ea4565030..eb95aee94 100644 --- a/source/acp/acp-tool-call.spec.ts +++ b/source/acp/acp-tool-call.spec.ts @@ -222,3 +222,24 @@ test('buildToolCallMeta - withDiff true is the default', async t => { rmSync(dir, {recursive: true, force: true}); } }); + +test('buildToolCallMeta - string_replace diff shows $ tokens literally', async t => { + const dir = mkdtempSync(join(tmpdir(), 'acp-tc-')); + const file = join(dir, 'run.sh'); + writeFileSync(file, '#!/bin/sh\necho "old"\nexit 0\n'); + const replacement = 'echo "pid=$$ match=$& pre=$` post=$\'"'; + try { + const meta = await buildToolCallMeta( + makeCall('string_replace', { + path: file, + old_str: 'echo "old"', + new_str: replacement, + }), + ); + // The previewed diff has to be the diff that lands on disk. + const diff = meta.content[0] as any; + t.is(diff.newText, `#!/bin/sh\n${replacement}\nexit 0\n`); + } finally { + rmSync(dir, {recursive: true, force: true}); + } +}); diff --git a/source/acp/acp-tool-call.ts b/source/acp/acp-tool-call.ts index 9c1db320a..25e958fe5 100644 --- a/source/acp/acp-tool-call.ts +++ b/source/acp/acp-tool-call.ts @@ -6,6 +6,7 @@ import type { ToolKind, } from '@agentclientprotocol/sdk'; import type {ToolCall} from '@/types/core'; +import {replaceFirstLiteral} from '@/utils/literal-replace'; export interface AcpToolCallMeta { title: string; @@ -169,7 +170,7 @@ async function buildStringReplaceDiff( type: 'diff', path: absPath, oldText: current, - newText: current.replace(oldStr, newStr), + newText: replaceFirstLiteral(current, oldStr, newStr), }; } diff --git a/source/app/App.tsx b/source/app/App.tsx index 84ed4ed3f..977cdd3b9 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -44,7 +44,6 @@ import {useUserMessageQueue} from '@/hooks/useUserMessageQueue'; import {useVSCodeServer} from '@/hooks/useVSCodeServer'; import {getAllSubagentProgress} from '@/services/subagent-events'; import {generateKey} from '@/session/key-generator'; -import type {ImageAttachment} from '@/types/core'; import type {ThemePreset} from '@/types/ui'; import {createPinoLogger} from '@/utils/logging/pino-logger'; import {setGlobalMessageQueue} from '@/utils/message-queue'; @@ -84,14 +83,6 @@ export default function App({ // Use extracted hooks const appState = useAppState(initialDevelopmentMode); const userMessageQueue = useUserMessageQueue(); - const queuedUserSubmitRef = React.useRef< - | (( - message: string, - displayValue: string, - images?: ImageAttachment[], - ) => Promise) - | null - >(null); const {exit} = useApp(); const {isTrusted, handleConfirmTrust, isTrustLoading, isTrustedError} = useDirectoryTrust(); @@ -249,35 +240,6 @@ export default function App({ } }, []); - const drainQueuedUserMessage = React.useCallback(() => { - // Defer to a macrotask, not a microtask. `onConversationComplete` fires - // deep inside the finishing turn's await chain, so a microtask drain would - // start the next turn BEFORE that turn's `resetStreamingState()` finally - // runs — and the stale reset would then wipe the new turn's abortController - // and isGenerating, leaving the busy indicator (and Escape-to-cancel) dead. - // A timeout runs after those continuations, so the drained turn keeps its - // busy state. - setTimeout(() => { - void userMessageQueue.drainNextMessage(async message => { - const submitQueuedMessage = queuedUserSubmitRef.current; - if (!submitQueuedMessage || !appState.client || !appState.toolManager) { - return false; - } - - await submitQueuedMessage( - message.message, - message.displayValue, - message.images, - ); - return true; - }); - }, 0); - }, [ - appState.client, - appState.toolManager, - userMessageQueue.drainNextMessage, - ]); - // Setup chat handler const chatHandler = useChatHandler({ client: appState.client, @@ -299,7 +261,6 @@ export default function App({ appState.setCompactToolCounts(null); appState.compactToolCountsRef.current = {}; appState.setLiveTaskList(null); - drainQueuedUserMessage(); }, // A turn that started in plan mode finished uninterrupted — a plan was // produced. Flag it so the interactive UI can show the plan review bar. @@ -570,10 +531,6 @@ export default function App({ activeEditor: vscodeServer.activeEditor, }); - React.useEffect(() => { - queuedUserSubmitRef.current = handleUserSubmit; - }, [handleUserSubmit]); - // Setup non-interactive mode const {nonInteractiveLoadingMessage} = useNonInteractiveMode({ nonInteractivePrompt, @@ -592,7 +549,7 @@ export default function App({ }); // Setup session autosave - useSessionAutosave({ + const {isSaving} = useSessionAutosave({ messages: appState.messages, currentProvider: appState.currentProvider, currentModel: appState.currentModel, @@ -784,6 +741,7 @@ export default function App({ handleUserSubmit={handleUserSubmit} userMessageQueue={userMessageQueue} handleIdeSelect={handleIdeSelect} + isSaving={isSaving} /> )} diff --git a/source/app/components/chat-input.tsx b/source/app/components/chat-input.tsx index 2995b824c..388a719db 100644 --- a/source/app/components/chat-input.tsx +++ b/source/app/components/chat-input.tsx @@ -101,6 +101,7 @@ export interface ChatInputProps { * transcript and isn't clipped by the scroll viewport's overflow="hidden". */ fullscreen?: boolean; + isSaving?: boolean; } /** @@ -155,6 +156,7 @@ export function ChatInput({ activeEditor, onDismissActiveEditor, fullscreen = false, + isSaving, }: ChatInputProps): React.ReactElement { const {colors} = useTheme(); const activeToolCall = pendingToolCalls[currentToolIndex]; @@ -253,6 +255,7 @@ export function ChatInput({ currentModel={currentModel} activeEditor={activeEditor} onDismissActiveEditor={onDismissActiveEditor} + isSaving={isSaving} /> ) : /* Client Missing */ mcpInitialized && !client ? ( diff --git a/source/app/components/settings-selector.tsx b/source/app/components/settings-selector.tsx index 1a3606017..d76f8c632 100644 --- a/source/app/components/settings-selector.tsx +++ b/source/app/components/settings-selector.tsx @@ -11,6 +11,7 @@ import { getNotificationsPreference, getPasteThreshold, getPrivacyPreference, + getProjectContextPreferences, getReasoningExpanded, getShowUsageFooter, updateCompactToolDisplay, @@ -20,6 +21,9 @@ import { updatePrivacyPreference, updateReasoningExpanded, updateSelectedTheme, + updateSemanticMemoryEnabled, + updateSemanticMemoryLimit, + updateSemanticMemoryTokenBudget, updateShowUsageFooter, } from '@/config/preferences'; import {getThemeColors, themes} from '@/config/themes'; @@ -46,6 +50,7 @@ export type ManagedSettingsPanel = | 'notifications' | 'display-settings' | 'privacy' + | 'semantic-memory' | 'json-config' | 'web-search' | 'providers-config' @@ -984,3 +989,124 @@ export function SettingsPrivacyPanel({ ); } + +/** Presets cycled by the Advanced panel. Any value in range can still be set + * directly in nanocoder-preferences.json; these are just the common choices. */ +const TOKEN_BUDGET_PRESETS = [120, 240, 480, 960]; +const MEMORY_LIMIT_PRESETS = [3, 5, 8, 12]; + +/** Next preset after `current`, wrapping. Falls to the first when `current` + * is a hand-edited value that isn't in the list. */ +function cyclePreset(presets: number[], current: number): number { + const index = presets.indexOf(current); + return presets[(index + 1) % presets.length] ?? presets[0] ?? current; +} + +// Semantic memory settings panel +export function SettingsSemanticMemoryPanel({ + onBack, + onCancel, +}: { + onBack: () => void; + onCancel: () => void; +}) { + const {boxWidth, isNarrow} = useResponsiveTerminal(); + const {colors} = useTheme(); + + const initialContextPreferences = getProjectContextPreferences(); + const [semanticMemoryEnabled, setSemanticMemoryEnabled] = useState( + initialContextPreferences.semanticMemoryEnabled, + ); + const [tokenBudget, setTokenBudget] = useState( + initialContextPreferences.tokenBudget, + ); + const [memoryLimit, setMemoryLimit] = useState( + initialContextPreferences.memoryLimit, + ); + + useInput((_, key) => { + if (key.escape) { + onCancel(); + } + if (key.shift && key.tab) { + onBack(); + } + }); + + const items = useMemo(() => { + return [ + { + label: `Semantic Memory: ${semanticMemoryEnabled ? 'ON' : 'OFF'}`, + value: 'semantic-memory', + }, + { + label: `Memory Token Budget: ${tokenBudget}`, + value: 'semantic-memory-token-budget', + }, + { + label: `Memories Per Prompt: ${memoryLimit}`, + value: 'semantic-memory-limit', + }, + ]; + }, [semanticMemoryEnabled, tokenBudget, memoryLimit]); + + const handleSelect = (item: {value: string}) => { + switch (item.value) { + case 'semantic-memory': { + const next = !semanticMemoryEnabled; + setSemanticMemoryEnabled(next); + updateSemanticMemoryEnabled(next); + break; + } + case 'semantic-memory-token-budget': { + const next = cyclePreset(TOKEN_BUDGET_PRESETS, tokenBudget); + setTokenBudget(next); + updateSemanticMemoryTokenBudget(next); + break; + } + case 'semantic-memory-limit': { + const next = cyclePreset(MEMORY_LIMIT_PRESETS, memoryLimit); + setMemoryLimit(next); + updateSemanticMemoryLimit(next); + break; + } + } + }; + + const title = isNarrow ? 'Memory' : 'Semantic Memory'; + + return ( + + {!isNarrow && ( + + + Toggle settings with Enter. Shift+Tab to go back, Esc to exit + + + )} + + + + Semantic Memory recalls saved project context and injects it into + future prompts. Turn it off for stateless agent behavior. The budget + and per-prompt count bound how much of the context window it may + consume - lower them on small local models. + + + + + + + Enter/Esc + + + ); +} diff --git a/source/app/components/settings-tabs.tsx b/source/app/components/settings-tabs.tsx index f97a7818b..46a0b1d53 100644 --- a/source/app/components/settings-tabs.tsx +++ b/source/app/components/settings-tabs.tsx @@ -11,6 +11,7 @@ import { getPasteThreshold, getPrivacyPreference, getProfessionalTone, + getProjectContextPreferences, getReasoningExpanded, updateAlternateScreen, updateProfessionalTone, @@ -38,6 +39,7 @@ import { SettingsNotificationsPanel, SettingsPasteThresholdPanel, SettingsPrivacyPanel, + SettingsSemanticMemoryPanel, SettingsThemePanel, SettingsTitleShapePanel, } from './settings-selector'; @@ -259,6 +261,15 @@ function buildRowsForTab( ]; case 'advanced': { const rows: SettingRow[] = [ + { + kind: 'managed', + id: 'semantic-memory', + label: 'Semantic Memory', + value: getProjectContextPreferences().semanticMemoryEnabled + ? 'on' + : 'off', + panel: 'semantic-memory', + }, { kind: 'managed', id: 'privacy', @@ -408,6 +419,8 @@ function renderManagedPanel( return ; case 'display-settings': return ; + case 'semantic-memory': + return ; case 'privacy': return ; case 'json-config': diff --git a/source/app/sections/interactive-app.spec.tsx b/source/app/sections/interactive-app.spec.tsx index d8e8cea95..7e930bce6 100644 --- a/source/app/sections/interactive-app.spec.tsx +++ b/source/app/sections/interactive-app.spec.tsx @@ -1,6 +1,8 @@ import test from 'ava'; import {Text} from 'ink'; import React from 'react'; +import {DELAY_COMMAND_COMPLETE_MS} from '@/constants'; +import {useUserMessageQueue} from '@/hooks/useUserMessageQueue'; import type {Message} from '@/types'; import {renderWithTheme} from '../../test-utils/render-with-theme.js'; import {InteractiveApp} from './interactive-app.js'; @@ -41,6 +43,13 @@ interface Overrides { setPendingPlanProceed?: (v: string | null) => void; handleMessageSubmit?: (message: string) => Promise; currentSessionId?: string | null; + toolManager?: unknown; + queuedMessages?: Array<{id: string; message: string; displayValue: string}>; + handleUserSubmit?: (message: string) => Promise; + drainNextMessage?: ( + dispatch: (message: {id: string; message: string; displayValue: string}) => + boolean | Promise, + ) => boolean | Promise; } function makeProps(o: Overrides = {}) { @@ -49,6 +58,7 @@ function makeProps(o: Overrides = {}) { const appState = { client: o.client ?? null, + toolManager: o.toolManager ?? null, messages: o.messages ?? [], currentModel: 'mock-model', currentProvider: 'mock', @@ -140,16 +150,16 @@ function makeProps(o: Overrides = {}) { pendingToolConfirmation: null, handleToolConfirmation: noop, handleQuestionAnswer: noop, - handleUserSubmit: noopAsync, + handleUserSubmit: o.handleUserSubmit ?? noopAsync, userMessageQueue: { - queuedMessages: [], + queuedMessages: o.queuedMessages ?? [], enqueueMessage: () => ({ id: 'queued-test', message: '', displayValue: '', }), removeMessage: noop, - drainNextMessage: () => false, + drainNextMessage: o.drainNextMessage ?? (() => false), }, handleIdeSelect: noop, } as never; @@ -160,6 +170,141 @@ test('renders without crashing in default state', t => { t.truthy(lastFrame()); }); +test('does not drain queued prompts while a turn is generating', async t => { + let submitted = false; + const {unmount} = renderWithTheme( + { + submitted = true; + }, + })} + />, + ); + + await new Promise(resolve => setTimeout(resolve, 25)); + t.false(submitted); + unmount(); +}); + +test('does not drain queued prompts while a modal mode is active', async t => { + let submitted = false; + const {unmount} = renderWithTheme( + { + submitted = true; + }, + })} + />, + ); + + await new Promise(resolve => setTimeout(resolve, 25)); + t.false(submitted); + unmount(); +}); + +test('drains every queued prompt after each dispatched turn returns to idle', async t => { + const submitted: string[] = []; + + const QueueDrainHarness = () => { + const userMessageQueue = useUserMessageQueue(); + const [isConversationComplete, setIsConversationComplete] = + React.useState(true); + + React.useEffect(() => { + userMessageQueue.enqueueMessage({message: 'first', displayValue: 'first'}); + userMessageQueue.enqueueMessage({message: 'second', displayValue: 'second'}); + }, [userMessageQueue.enqueueMessage]); + + return ( + { + submitted.push(message); + setIsConversationComplete(false); + await new Promise(resolve => setTimeout(resolve, 10)); + setIsConversationComplete(true); + }, + })} + userMessageQueue={userMessageQueue} + /> + ); + }; + + const {unmount} = renderWithTheme(); + await new Promise(resolve => setTimeout(resolve, 100)); + t.deepEqual(submitted, ['first', 'second']); + unmount(); +}); + +test('drains a prompt after delayed command completion when the app is idle', async t => { + const submitted: string[] = []; + + const DelayedCommandHarness = () => { + const userMessageQueue = useUserMessageQueue(); + const [isToolExecuting, setIsToolExecuting] = React.useState(true); + const [isConversationComplete, setIsConversationComplete] = + React.useState(false); + + React.useEffect(() => { + userMessageQueue.enqueueMessage({ + message: 'after compact', + displayValue: 'after compact', + }); + const timeout = setTimeout(() => { + setIsToolExecuting(false); + setIsConversationComplete(true); + }, DELAY_COMMAND_COMPLETE_MS); + + return () => clearTimeout(timeout); + }, [userMessageQueue.enqueueMessage]); + + return ( + { + submitted.push(message); + }, + })} + userMessageQueue={userMessageQueue} + /> + ); + }; + + const {unmount} = renderWithTheme(); + await new Promise(resolve => + setTimeout(resolve, DELAY_COMMAND_COMPLETE_MS + 40), + ); + t.deepEqual(submitted, ['after compact']); + unmount(); +}); + test('renders the static-component marker through ChatHistory', t => { const {lastFrame} = renderWithTheme( , diff --git a/source/app/sections/interactive-app.tsx b/source/app/sections/interactive-app.tsx index b2c1b135e..4e509a6ae 100644 --- a/source/app/sections/interactive-app.tsx +++ b/source/app/sections/interactive-app.tsx @@ -50,6 +50,7 @@ interface InteractiveAppProps { * the inline Static-based flow with native scrollback. */ altScreenActive?: boolean; + isSaving?: boolean; } /** @@ -76,6 +77,7 @@ export function InteractiveApp({ handleIdeSelect, clearKey, altScreenActive = false, + isSaving, }: InteractiveAppProps): React.ReactElement { const nextRestoredDraftIdRef = React.useRef(1); // Tune / IDE are launched by closing settings first, so their exit has no way @@ -99,6 +101,8 @@ export function InteractiveApp({ React.useState(null); const [restoredDraft, setRestoredDraft] = React.useState(null); + const drainInProgressRef = React.useRef(false); + const [drainAttempt, setDrainAttempt] = React.useState(0); const handleToggleCompactDisplay = () => { const expanding = appState.compactToolDisplay; @@ -180,6 +184,82 @@ export function InteractiveApp({ appState.isToolExecuting || appState.abortController !== null); + // Drain queued prompts only after the previous turn is fully idle and all + // modal modes have closed. Command handlers and conversation completion can + // both signal completion, so keeping the drain here makes it idempotent and + // prevents nested or duplicate turns. + const queueDrainBlocked = + appState.isCancelling || + chatHandler.isGenerating || + appState.isToolExecuting || + appState.abortController !== null || + appState.isToolConfirmationMode || + appState.isQuestionMode || + pendingSubagentApproval !== null || + pendingToolConfirmation !== null; + + React.useEffect(() => { + // Re-run after a successful dispatch settles, once its queue update has + // rendered and the next item can be considered. + void drainAttempt; + if ( + queueDrainBlocked || + appState.activeMode !== null || + appState.isSettingsMode || + !appState.isConversationComplete || + userMessageQueue.queuedMessages.length === 0 || + drainInProgressRef.current + ) { + return; + } + + drainInProgressRef.current = true; + let started = false; + const timeout = setTimeout(() => { + started = true; + void userMessageQueue + .drainNextMessage(async message => { + if (!appState.client || !appState.toolManager) return false; + await handleUserSubmit( + message.message, + message.displayValue, + message.images, + ); + return true; + }) + .then( + dispatched => { + drainInProgressRef.current = false; + // The queue state update happens before the dispatch resolves. A + // separate render is needed to notice and drain the next item after + // the dispatched turn returns to idle. + if (dispatched) { + setDrainAttempt(attempt => attempt + 1); + } + }, + () => { + drainInProgressRef.current = false; + }, + ); + }, 0); + + return () => { + clearTimeout(timeout); + if (!started) drainInProgressRef.current = false; + }; + }, [ + appState.activeMode, + appState.client, + appState.isConversationComplete, + appState.isSettingsMode, + appState.toolManager, + queueDrainBlocked, + handleUserSubmit, + userMessageQueue.drainNextMessage, + userMessageQueue.queuedMessages.length, + drainAttempt, + ]); + const recallableSubmittedDraft = cancellable && chatHandler.isGenerating && @@ -445,6 +525,7 @@ export function InteractiveApp({ tune={appState.tune} currentModel={appState.currentModel} fullscreen={fullscreen} + isSaving={isSaving} /> )} diff --git a/source/app/utils/app-util.spec.ts b/source/app/utils/app-util.spec.ts index 5b608a247..0eb06749c 100644 --- a/source/app/utils/app-util.spec.ts +++ b/source/app/utils/app-util.spec.ts @@ -330,6 +330,22 @@ test.serial('chat message - displayValue is optional (callers without a placehol t.is(received.displayValue, undefined); }); +test.serial('delayed slash-command completion is delivered after the handler returns', async t => { + let completed = false; + const options = createResumeTestOptions({ + onCommandComplete: () => { + completed = true; + }, + }); + options.onShowStatus = () => {}; + + await handleMessageSubmission('/status', options); + + t.false(completed); + await new Promise(resolve => setTimeout(resolve, 125)); + t.true(completed); +}); + test.serial('retry command - /retry without a prior user turn shows an error', async t => { let queued: React.ReactNode = null; let submitted = false; diff --git a/source/app/utils/app-util.ts b/source/app/utils/app-util.ts index ab9af0568..b9e0ea689 100644 --- a/source/app/utils/app-util.ts +++ b/source/app/utils/app-util.ts @@ -10,6 +10,7 @@ import {CopilotLogin} from '@/commands/copilot-login'; import BashProgress from '@/components/bash-progress'; import CommandProgress from '@/components/command-progress'; import {DELAY_COMMAND_COMPLETE_MS, MAX_SESSION_NAME_LENGTH} from '@/constants'; +import {sharedProposalStore} from '@/memory/proposal-store'; import {CheckpointManager} from '@/services/checkpoint-manager'; import {generateKey} from '@/session/key-generator'; import {executeBashCommand, formatBashResultForLLM} from '@/tools/execute-bash'; @@ -312,7 +313,7 @@ async function handleSpecialCommand( } case SPECIAL_COMMANDS.CLEAR: await onClearMessages(); - // Increment clear counter to force re-render of static components + sharedProposalStore.clear(); options.onClearCounterIncrement?.(); setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; @@ -562,6 +563,7 @@ async function handleBuiltInCommand( developmentMode: options.developmentMode, lastApiUsage, apiCallHistory, + sessionId: options.sessionId, }); } finally { if (progressLabel) { diff --git a/source/app/utils/handlers/retry-handler.spec.ts b/source/app/utils/handlers/retry-handler.spec.ts new file mode 100644 index 000000000..66895dda2 --- /dev/null +++ b/source/app/utils/handlers/retry-handler.spec.ts @@ -0,0 +1,24 @@ +import test from 'ava'; +import type {MessageSubmissionOptions} from '@/types'; +import {handleRetryCommand} from './retry-handler.js'; + +test('does not signal command completion after the retried turn returns', async t => { + let chatCalls = 0; + let completionCalls = 0; + + const options = { + messages: [{role: 'user', content: 'retry me'}], + provider: 'mock', + onAddToChatQueue: () => {}, + onHandleChatMessage: async () => { + chatCalls++; + }, + onCommandComplete: () => { + completionCalls++; + }, + } as unknown as MessageSubmissionOptions; + + t.true(await handleRetryCommand(['retry'], options)); + t.is(chatCalls, 1); + t.is(completionCalls, 0); +}); diff --git a/source/app/utils/handlers/retry-handler.ts b/source/app/utils/handlers/retry-handler.ts index 6bf654f33..328d75607 100644 --- a/source/app/utils/handlers/retry-handler.ts +++ b/source/app/utils/handlers/retry-handler.ts @@ -86,6 +86,7 @@ export async function handleRetryCommand( lastUserMessage.content, lastUserMessage.content, ); - options.onCommandComplete?.(); + // The retried chat turn owns its completion signal. Emitting another one + // here can start the next queued prompt while that turn is still unwinding. return true; } diff --git a/source/cli-integration.spec.ts b/source/cli-integration.spec.ts index 559be75b1..6b0b2f347 100644 --- a/source/cli-integration.spec.ts +++ b/source/cli-integration.spec.ts @@ -1,5 +1,7 @@ import test from 'ava'; -import {execSync, execFileSync} from 'child_process'; +import {execFileSync, spawnSync} from 'child_process'; +import {mkdtempSync, rmSync, writeFileSync} from 'fs'; +import {tmpdir} from 'os'; import {join} from 'path'; import {fileURLToPath} from 'url'; @@ -96,4 +98,57 @@ test('CLI integration: help flag takes precedence over other arguments', t => { // Should return help text, not start the app t.true(output.includes('Usage:')); t.true(output.includes('--version')); -}); \ No newline at end of file +}); + +test('CLI integration: init help exits successfully with preset guidance', t => { + const result = spawnSync(process.execPath, [cliPath, 'init', '--help'], { + encoding: 'utf8', + }); + + t.is(result.status, 0); + t.true(result.stdout.includes('Usage: nanocoder init [options]')); + t.true(result.stdout.includes('--preset ')); + t.true(result.stdout.includes('react, nextjs, rust')); +}); + +test.serial('CLI integration: init preset succeeds and creates files', t => { + const projectPath = mkdtempSync(join(tmpdir(), 'nanocoder-cli-init-')); + try { + writeFileSync( + join(projectPath, 'package.json'), + JSON.stringify({scripts: {build: 'vite build'}}), + ); + const result = spawnSync( + process.execPath, + [cliPath, 'init', '--preset', 'React'], + {cwd: projectPath, encoding: 'utf8'}, + ); + + t.is(result.status, 0); + t.true(result.stdout.includes('Preset: react')); + t.true(result.stdout.includes('Created: AGENTS.md')); + t.true(result.stdout.includes('Created: .nanocoderignore')); + } finally { + rmSync(projectPath, {recursive: true, force: true}); + } +}); + +test.serial('CLI integration: init invalid preset exits with an error', t => { + const projectPath = mkdtempSync(join(tmpdir(), 'nanocoder-cli-init-')); + try { + const result = spawnSync( + process.execPath, + [cliPath, 'init', '--preset', 'constructor'], + {cwd: projectPath, encoding: 'utf8'}, + ); + + t.is(result.status, 1); + t.true( + result.stderr.includes( + 'Unknown preset "constructor". Supported presets: react, nextjs, rust.', + ), + ); + } finally { + rmSync(projectPath, {recursive: true, force: true}); + } +}); diff --git a/source/cli.tsx b/source/cli.tsx index 8bd5c58c3..027f0417f 100644 --- a/source/cli.tsx +++ b/source/cli.tsx @@ -72,12 +72,67 @@ if (args[0] === 'daemon') { process.exit(result.exitCode); } +// Handle `nanocoder init` without booting the interactive app. The shared +// initializer is also used by /init, so both entry points keep identical file +// generation and overwrite behavior. +if (args[0] === 'init') { + if (args.includes('--help') || args.includes('-h')) { + console.log(` +Usage: nanocoder init [options] + +Options: + --preset Apply a bundled project preset (react, nextjs, rust) + -f, --force Regenerate AGENTS.md if it already exists + --lean Skip CLAUDE.md when merging existing project guidance + -h, --help Show help for the init command + +Examples: + nanocoder init + nanocoder init --preset react + nanocoder init --preset nextjs + nanocoder init --preset rust + `); + process.exit(0); + } + + const [{parseInitArguments}, initializer] = await Promise.all([ + import('@/init/init-args'), + import('@/init/initializer'), + ]); + try { + const options = parseInitArguments(args.slice(1)); + const result = initializer.initializeProject({ + projectPath: process.cwd(), + ...options, + }); + + console.log('Nanocoder project initialized successfully.'); + if (result.preset) console.log(`Preset: ${result.preset}`); + for (const file of result.created) console.log(`Created: ${file}`); + for (const file of result.preserved) { + console.log(`Preserved existing file: ${file}`); + } + process.exit(0); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown initialization error'; + const suffix = + error instanceof initializer.ProjectAlreadyInitializedError + ? ' Use nanocoder init --force to regenerate.' + : ''; + console.error(`${message}${suffix}`); + process.exit(1); + } +} + // Handle --help/-h flag — fast path, no heavy imports if (args.includes('--help') || args.includes('-h')) { console.log(` Usage: nanocoder [options] [command] Commands: + init [options] Analyze the project and create AGENTS.md. + Use --preset for bundled defaults. copilot login [provider-name] Log in to GitHub Copilot (device flow). Saves credentials for the "GitHub Copilot" provider. daemon Manage the per-project skill daemon. Subcommands: start, stop, status, logs, install, uninstall. @@ -115,6 +170,7 @@ Options: run Run in non-interactive mode Examples: + nanocoder init --preset nextjs nanocoder --provider openrouter --model google/gemini-3.1-flash run "analyze src/app.ts" nanocoder --provider ollama --model llama3.1 --context-max 128k nanocoder --mode yolo run "refactor database module" diff --git a/source/commands.ts b/source/commands.ts index a9b76d388..3671fffcb 100644 --- a/source/commands.ts +++ b/source/commands.ts @@ -105,6 +105,7 @@ class CommandRegistry { developmentMode?: import('@/types/core').DevelopmentMode; lastApiUsage?: import('@/types/core').ApiUsageSnapshot | null; apiCallHistory?: import('@/types/core').ApiCallRecord[]; + sessionId?: string; }, ): Promise { const parts = input.trim().split(/\s+/); diff --git a/source/commands/export.spec.tsx b/source/commands/export.spec.tsx index 623fa9d60..bfb808ea3 100644 --- a/source/commands/export.spec.tsx +++ b/source/commands/export.spec.tsx @@ -2,10 +2,16 @@ import test from 'ava'; import type {Message} from '@/types/index'; import {exportCommand} from './export'; import {promises as fs} from 'fs'; +import path from 'path'; import React from 'react'; import {render} from 'ink-testing-library'; import {themes} from '../config/themes'; import {ThemeContext} from '../hooks/useTheme'; +import { + resetSessionCwd, + setProjectRoot, + setSessionCwd, +} from '../services/session-cwd'; // Mock fs module const originalWriteFile = fs.writeFile; @@ -17,10 +23,13 @@ test.beforeEach(() => { mockWriteFileCalls.push({path: filepath, content}); return Promise.resolve(void 0); }; + // Isolate each test from session-cwd state set by others. + resetSessionCwd(); }); test.afterEach(() => { fs.writeFile = originalWriteFile; + resetSessionCwd(); }); // Mock ThemeProvider for testing @@ -66,9 +75,59 @@ test('exportCommand uses provided filename', async t => { t.true(mockWriteFileCalls[0].path.includes('custom-export.md')); }); -test('exportCommand generates default filename when none provided', async t => { +test('exportCommand keeps overwrite semantics for a user-provided filename', async t => { + // A user-typed name must always write to that exact path (overwrite), never + // be auto-suffixed, no matter what already exists on disk. + await exportCommand.handler(['fixed-name.md'], testMessages, testMetadata); + + t.is(mockWriteFileCalls.length, 1); + t.true(mockWriteFileCalls[0].path.endsWith('fixed-name.md')); + t.false(mockWriteFileCalls[0].path.includes('fixed-name-2')); +}); + +test('exportCommand writes a generated filename that is free', async t => { await exportCommand.handler([], testMessages, testMetadata); + t.is(mockWriteFileCalls.length, 1); + // No auto-suffix (matches -2, -3 etc.) when the target does not exist. + t.regex(mockWriteFileCalls[0].path, /hello-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('exportCommand surfaces a write failure instead of a false success', async t => { + const originalWriteFile = fs.writeFile; + fs.writeFile = async () => { + throw new Error('ENOSPC: no space left on device'); + }; + + const result = (await exportCommand.handler( + ['big.md'], + testMessages, + testMetadata, + )) as React.ReactElement; + + const {lastFrame} = render({result}); + const output = lastFrame(); + t.truthy(output); + t.regex(output!, /Failed to export chat/); + t.regex(output!, /ENOSPC/); + t.false(output!.includes('Chat exported')); + fs.writeFile = originalWriteFile; +}); + +test('exportCommand generates default filename from first user message', async t => { + await exportCommand.handler([], testMessages, testMetadata); + + t.is(mockWriteFileCalls.length, 1); + t.true(mockWriteFileCalls[0].path.includes('hello-')); + t.true(mockWriteFileCalls[0].path.endsWith('.md')); +}); + +test('exportCommand falls back to nanocoder-chat when no user messages', async t => { + const noUserMessages: Message[] = [ + {role: 'assistant', content: 'Hi there', tool_calls: undefined}, + ]; + await exportCommand.handler([], noUserMessages, testMetadata); + t.is(mockWriteFileCalls.length, 1); t.true(mockWriteFileCalls[0].path.includes('nanocoder-chat-')); t.true(mockWriteFileCalls[0].path.endsWith('.md')); @@ -185,3 +244,183 @@ test('exportCommand renders Export component with correct filename', async t => t.regex(output!, /my-export\.md/); } }); + +test('exportCommand rejects path traversal in filename', async t => { + const result = (await exportCommand.handler( + ['../../../etc/passwd'], + testMessages, + testMetadata, + )) as React.ReactElement; + + t.is(mockWriteFileCalls.length, 0); + + const {lastFrame} = render({result}); + const output = lastFrame(); + t.truthy(output); + t.regex(output!, /'\.\.' segments are not allowed/); + t.false(output!.includes('Chat exported')); +}); + +test('exportCommand allows exporting into a subdirectory', async t => { + // isValidFilePath is segment-aware, so a subdirectory export must work while + // traversal is still blocked. + await exportCommand.handler( + ['reports/chat.md'], + testMessages, + testMetadata, + ); + + t.is(mockWriteFileCalls.length, 1); + t.true(mockWriteFileCalls[0].path.endsWith('chat.md')); + t.true( + mockWriteFileCalls[0].path + .split(/[\\/]/) + .slice(-2) + .join('/') === 'reports/chat.md', + ); +}); + +test('exportCommand rejects a filename with a null byte', async t => { + const result = (await exportCommand.handler( + ['evil\u0000.md'], + testMessages, + testMetadata, + )) as React.ReactElement; + + t.is(mockWriteFileCalls.length, 0); + + const {lastFrame} = render({result}); + const output = lastFrame(); + t.truthy(output); + // The message must name the actual cause, not a generic "invalid path". + t.regex(output!, /Invalid export path: the filename contains a null byte/); +}); + +test('exportCommand rejects a home-directory shorthand path', async t => { + const result = (await exportCommand.handler( + ['~/notes.md'], + testMessages, + testMetadata, + )) as React.ReactElement; + + t.is(mockWriteFileCalls.length, 0); + + const {lastFrame} = render({result}); + const output = lastFrame(); + t.truthy(output); + // `~` is not expanded, so say so and point at what does work. + t.regex(output!, /'~' is not expanded/); + t.regex(output!, /absolute path inside it/); +}); + +test('exportCommand rejects a path escaping the project directory', async t => { + const result = (await exportCommand.handler( + ['../../outside.md'], + testMessages, + testMetadata, + )) as React.ReactElement; + + t.is(mockWriteFileCalls.length, 0); + + const {lastFrame} = render({result}); + const output = lastFrame(); + t.truthy(output); + t.regex(output!, /'\.\.' segments are not allowed/); + t.false(output!.includes('Chat exported')); +}); + +test('exportCommand rejects an absolute path outside the project', async t => { + const outside = path.resolve(process.cwd(), '..', 'outside.md'); + const result = (await exportCommand.handler( + [outside], + testMessages, + testMetadata, + )) as React.ReactElement; + + t.is(mockWriteFileCalls.length, 0); + + const {lastFrame} = render({result}); + const output = lastFrame(); + t.truthy(output); + // Containment is deliberate: name the boundary that was crossed. + t.regex(output!, /outside the project directory/); +}); + +test('exportCommand resolves a relative path against the session cwd (honours cd)', async t => { + // Pin the session cwd to a subdirectory, as a bash `cd` would, and confirm a + // bare relative export lands there -- not in the launch dir (process.cwd()). + // Must live under the project root for the containment check to pass, so + // register cleanup up front — a mid-test failure would otherwise leave the + // directory behind in the working tree. + const subdir = path.join(process.cwd(), 'tmp-session-cwd-test'); + await fs.mkdir(subdir, {recursive: true}); + t.teardown(() => fs.rm(subdir, {recursive: true, force: true})); + setSessionCwd(subdir); + + await exportCommand.handler(['chat.md'], testMessages, testMetadata); + + t.is(mockWriteFileCalls.length, 1); + const expected = path.join(subdir, 'chat.md'); + t.is(mockWriteFileCalls[0].path, expected); +}); + +test('exportCommand contains writes to the project root even when the session cwd is deeper', async t => { + // A pinned project root is the non-shrinking containment boundary. With the + // session cwd inside a worktree, an absolute path inside the project root + // (but above the cwd) is still allowed, while one above the project root is + // rejected. Relative '..' is blocked outright by isValidFilePath. + const root = path.join(process.cwd(), 'tmp-prjroot-test'); + const subdir = path.join(root, 'worktree'); + await fs.mkdir(subdir, {recursive: true}); + t.teardown(() => fs.rm(root, {recursive: true, force: true})); + setProjectRoot(root); + setSessionCwd(subdir); + + // Absolute path inside the project root but above the session cwd is allowed. + const insideRoot = path.join(root, 'chat.md'); + await exportCommand.handler([insideRoot], testMessages, testMetadata); + t.is(mockWriteFileCalls.length, 1); + t.is(mockWriteFileCalls[0].path, insideRoot); + + // Absolute path escaping above the project root is rejected. + const outside = path.join(root, '..', 'outside.md'); + const escape = (await exportCommand.handler( + [outside], + testMessages, + testMetadata, + )) as React.ReactElement; + t.is(mockWriteFileCalls.length, 1); + const {lastFrame} = render({escape}); + t.regex(lastFrame()!, /outside the project directory/); +}); + +test('exportCommand reports a missing parent directory clearly', async t => { + fs.writeFile = originalWriteFile; + + const result = (await exportCommand.handler( + ['no-such-folder/chat.md'], + testMessages, + testMetadata, + )) as React.ReactElement; + fs.writeFile = originalWriteFile; + + const {lastFrame} = render({result}); + t.regex(lastFrame()!, /Failed to export chat/); + t.regex(lastFrame()!, /Parent directory does not exist/); +}); + +test('exportCommand renders a subdirectory export relative to the project root', async t => { + const result = (await exportCommand.handler( + ['reports/chat.md'], + testMessages, + testMetadata, + )) as React.ReactElement; + + const {lastFrame} = render({result}); + const output = lastFrame()!; + // Full relative path is shown (with the platform separator), not a bare + // basename. + t.true(output.includes(`Chat exported to reports${path.sep}chat.md`)); + t.false(output.includes(`Chat exported to chat${path.sep}`)); + t.false(output.includes('Chat exported to chat.md')); +}); diff --git a/source/commands/export.tsx b/source/commands/export.tsx index 0ea605bb9..5b341b5e7 100644 --- a/source/commands/export.tsx +++ b/source/commands/export.tsx @@ -1,9 +1,14 @@ import fs from 'fs/promises'; import path from 'path'; import React from 'react'; -import {SuccessMessage} from '@/components/message-box'; +import {ErrorMessage, SuccessMessage} from '@/components/message-box'; +import {getProjectRoot, getSafeSessionCwd} from '@/services/session-cwd'; import {generateKey} from '@/session/key-generator'; import {Command, Message} from '@/types/index'; +import {formatError} from '@/utils/error-formatter'; +import {generateExportFilename} from '@/utils/generate-export-filename'; +import {resolveFilePath} from '@/utils/path-validation'; +import {writeUniqueFile} from '@/utils/write-unique-file'; const formatMessageContent = (message: Message) => { let content = ''; @@ -46,6 +51,43 @@ function Export({filename}: {filename: string}) { ); } +function ExportError({message}: {message: string}) { + return ( + + ); +} + +/** + * `resolveFilePath` throws a single generic "Invalid file path" for several + * distinct causes, which leaves the user guessing (a null byte and a `~` are + * very different mistakes). Re-derive the specific reason so the message names + * what was actually wrong and how to fix it. + * + * Exports are deliberately contained to the project directory, the same as + * `read_file` / `write_file` / `string_replace`. `~` is not expanded and paths + * outside the root are refused rather than silently redirected. + */ +function explainInvalidPath(filename: string, root: string): string { + if (!filename.trim()) { + return 'the filename is empty'; + } + if (filename.includes('\0')) { + return 'the filename contains a null byte'; + } + if (filename.startsWith('~')) { + return "'~' is not expanded; use a path relative to the project, or an absolute path inside it"; + } + if (filename.split(/[/\\]/).some(segment => segment === '..')) { + return "'..' segments are not allowed; exports stay inside the project"; + } + return `it is outside the project directory (${root})`; +} + export const exportCommand: Command = { name: 'export', description: 'Export the chat history to a markdown file', @@ -54,10 +96,29 @@ export const exportCommand: Command = { messages: Message[], {provider, model, tokens}, ) => { - const filename = - args[0] || - `nanocoder-chat-${new Date().toISOString().replace(/:/g, '-')}.md`; - const filepath = path.resolve(process.cwd(), filename); // nosemgrep + const userProvided = args.length > 0; + const requestedFilename = args[0] || generateExportFilename(messages); + + // Resolve against the session cwd (which honours bash `cd`) and enforce + // containment within the project root (which does not shrink as `cd` + // descends) -- the same convention as read_file / write_file / string_replace. + const projectRoot = getProjectRoot(); + let filepath: string; + try { + filepath = resolveFilePath( + requestedFilename, + getSafeSessionCwd(), + projectRoot, + ); + } catch { + return React.createElement(ExportError, { + key: generateKey('export'), + message: `Invalid export path: ${explainInvalidPath( + requestedFilename, + projectRoot, + )}`, + }); + } const frontmatter = `--- session_date: ${new Date().toISOString()} @@ -70,13 +131,42 @@ total_tokens: ${tokens} `; - const markdownContent = messages.map(formatMessageContent).join(''); + const markdownContent = + frontmatter + messages.map(formatMessageContent).join(''); + + // A name the user typed keeps overwrite semantics (least surprise). Only + // generated names get auto-suffixed so repeated exports never clobber -- + // and the write is atomic ('wx') so concurrent exports can't race. + let writtenFilepath: string; + try { + writtenFilepath = userProvided + ? await fs.writeFile(filepath, markdownContent).then(() => filepath) + : await writeUniqueFile(filepath, markdownContent); + } catch (error) { + // writeUniqueFile already translates a missing parent dir for + // generated names; mirror it for user-typed names that write directly. + const message = + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ? `Parent directory does not exist: ${path.dirname(filepath)}` + : formatError(error); + return React.createElement(ExportError, { + key: generateKey('export'), + message: `Failed to export chat: ${message}`, + }); + } - await fs.writeFile(filepath, frontmatter + markdownContent); + // Show the exported file relative to the project root so subdirectory + // exports (e.g. reports/chat.md) are recognisable rather than a bare basename. + const displayPath = writtenFilepath.startsWith(projectRoot + path.sep) + ? writtenFilepath.slice(projectRoot.length + 1) + : writtenFilepath; return React.createElement(Export, { key: generateKey('export'), - filename, + filename: displayPath, }); }, }; diff --git a/source/commands/init.spec.tsx b/source/commands/init.spec.tsx new file mode 100644 index 000000000..34881bc0c --- /dev/null +++ b/source/commands/init.spec.tsx @@ -0,0 +1,64 @@ +import test from 'ava'; +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {render} from 'ink-testing-library'; +import React from 'react'; +import {themes} from '@/config/themes'; +import {ThemeContext} from '@/hooks/useTheme'; +import {TitleShapeContext} from '@/hooks/useTitleShape'; +import {initCommand} from './init.js'; + +function Providers({children}: {children: React.ReactNode}) { + return ( + {}, + }} + > + {}}} + > + {children} + + + ); +} + +test.serial('init success renders the selected preset and preserved files', async t => { + const originalCwd = process.cwd(); + const projectPath = mkdtempSync(join(tmpdir(), 'nanocoder-init-render-')); + try { + writeFileSync(join(projectPath, '.nanocoderignore'), 'keep-me\n'); + writeFileSync( + join(projectPath, 'package.json'), + JSON.stringify({scripts: {build: 'vite build'}}), + ); + process.chdir(projectPath); + + const result = await initCommand.handler(['--preset', 'react'], [], { + provider: 'test', + model: 'test', + tokens: 0, + getMessageTokens: () => 0, + }); + if (!React.isValidElement(result)) { + t.fail('Expected InitSuccess to return a React element'); + return; + } + + const {lastFrame, unmount} = render({result}); + const output = lastFrame(); + unmount(); + + t.truthy(output); + t.true(output?.includes('Preset: react')); + t.true(output?.includes('Existing Files Preserved:')); + t.true(output?.includes('.nanocoderignore')); + } finally { + process.chdir(originalCwd); + rmSync(projectPath, {recursive: true, force: true}); + } +}); diff --git a/source/commands/init.tsx b/source/commands/init.tsx index f84d15f4a..810c4977b 100644 --- a/source/commands/init.tsx +++ b/source/commands/init.tsx @@ -1,23 +1,27 @@ -import {existsSync, mkdirSync, writeFileSync} from 'fs'; import {Box, Text} from 'ink'; -import {join} from 'path'; import React from 'react'; import {ErrorMessage} from '@/components/message-box'; import {TitledBoxWithPreferences} from '@/components/ui/titled-box'; import {getColors} from '@/config/index'; import {useTerminalWidth} from '@/hooks/useTerminalWidth'; -import {AgentsTemplateGenerator} from '@/init/agents-template-generator'; -import {ExistingRulesExtractor} from '@/init/existing-rules-extractor'; -import {ProjectAnalyzer} from '@/init/project-analyzer'; +import {parseInitArguments} from '@/init/init-args'; +import { + initializeProject, + ProjectAlreadyInitializedError, +} from '@/init/initializer'; import {generateKey} from '@/session/key-generator'; import {Command} from '@/types/index'; import {formatError} from '@/utils/error-formatter'; function InitSuccess({ created, + preserved, + preset, analysis, }: { created: string[]; + preserved?: string[]; + preset?: string; analysis?: { projectType: string; primaryLanguage: string; @@ -42,6 +46,7 @@ function InitSuccess({ ✓ Nanocoder project initialized successfully! + {preset && • Preset: {preset}} {analysis && ( <> @@ -78,6 +83,21 @@ function InitSuccess({ ))} + {preserved && preserved.length > 0 && ( + <> + + + Existing Files Preserved: + + + {preserved.map(item => ( + + • {item} + + ))} + + )} + @@ -99,72 +119,14 @@ function InitError({message}: {message: string}) { export const initCommand: Command = { name: 'init', description: - 'Initialize nanocoder configuration and analyze project structure. Use --force to regenerate AGENTS.md, --lean to skip CLAUDE.md when generating AGENTS.md.', + 'Initialize nanocoder configuration and analyze project structure. Use --preset , --force to regenerate AGENTS.md, or --lean to skip CLAUDE.md.', handler: (args: string[], _messages, _metadata) => { const cwd = process.cwd(); - const created: string[] = []; - const forceRegenerate = args.includes('--force') || args.includes('-f'); - // --lean: skip Claude-Code-specific source files (CLAUDE.md) when - // generating AGENTS.md. Keeps the generated AGENTS.md smaller and - // reduces duplication for users who already have CLAUDE.md. - const lean = args.includes('--lean'); try { - // Check if already initialized - const agentsPath = join(cwd, 'AGENTS.md'); - const nanocoderDir = join(cwd, '.nanocoder'); - - // Check for existing initialization - const hasAgents = existsSync(agentsPath); - const hasNanocoder = existsSync(nanocoderDir); - - if (hasAgents && hasNanocoder && !forceRegenerate) { - return Promise.resolve( - React.createElement(InitError, { - key: generateKey('init-error'), - message: - 'Project already initialized. Found AGENTS.md and .nanocoder/ directory. Use /init --force to regenerate.', - }), - ); - } - - // Show progress indicator for analysis - // Note: In a real implementation, we'd want to show this as a loading state - // For now, we'll do the analysis synchronously - - // Analyze the project - const analyzer = new ProjectAnalyzer(cwd); - const analysis = analyzer.analyze(); - - // Extract existing AI configuration files (skip AGENTS.md when force - // regenerating; skip CLAUDE.md in lean mode). - const rulesExtractor = new ExistingRulesExtractor( - cwd, - forceRegenerate, - lean ? ['CLAUDE.md'] : [], - ); - const existingRules = rulesExtractor.extractExistingRules(); - - // Create AGENTS.md based on analysis and existing rules - if (!hasAgents || forceRegenerate) { - const agentsContent = AgentsTemplateGenerator.generateAgentsMd( - analysis, - existingRules, - ); - writeFileSync(agentsPath, agentsContent); - created.push(hasAgents ? 'AGENTS.md (regenerated)' : 'AGENTS.md'); - - // Report found existing rules - if (existingRules.length > 0) { - const sourceFiles = existingRules.map(r => r.source).join(', '); - created.push(`↳ Merged content from: ${sourceFiles}`); - } - } - - if (!hasNanocoder) { - mkdirSync(nanocoderDir, {recursive: true}); - created.push('.nanocoder/'); - } + const options = parseInitArguments(args); + const result = initializeProject({projectPath: cwd, ...options}); + const {analysis} = result; // Prepare analysis summary for display const analysisSummary = { @@ -179,16 +141,21 @@ export const initCommand: Command = { return Promise.resolve( React.createElement(InitSuccess, { key: generateKey('init-success'), - created, + created: result.created, + preserved: result.preserved, + preset: result.preset, analysis: analysisSummary, }), ); } catch (error: unknown) { - const errorMessage = formatError(error); + const errorMessage = + error instanceof ProjectAlreadyInitializedError + ? `${error.message} Use /init --force to regenerate.` + : `Failed to initialize project: ${formatError(error)}`; return Promise.resolve( React.createElement(InitError, { key: generateKey('init-error'), - message: `Failed to initialize project: ${errorMessage}`, + message: errorMessage, }), ); } diff --git a/source/commands/lazy-registry.ts b/source/commands/lazy-registry.ts index 9f4dfa6d9..e701874b9 100644 --- a/source/commands/lazy-registry.ts +++ b/source/commands/lazy-registry.ts @@ -104,7 +104,7 @@ export const lazyCommands: LazyCommand[] = [ { name: 'init', description: - 'Initialize nanocoder configuration and analyze project structure. Use --force to regenerate AGENTS.md.', + 'Initialize nanocoder configuration and analyze project structure. Use --preset , --force to regenerate AGENTS.md, or --lean to skip CLAUDE.md.', load: () => import('@/commands/init').then(m => m.initCommand), }, { @@ -177,6 +177,16 @@ export const lazyCommands: LazyCommand[] = [ 'Re-run the last user turn (use --model to switch models first)', load: () => import('@/commands/retry').then(m => m.retryCommand), }, + { + name: 'remember', + description: 'Save a durable project memory', + load: () => import('@/commands/remember').then(m => m.rememberCommand), + }, + { + name: 'memory', + description: 'Manage project memories', + load: () => import('@/commands/memory').then(m => m.memoryCommand), + }, { name: 'tasks', description: 'Manage your task list', diff --git a/source/commands/memory.spec.tsx b/source/commands/memory.spec.tsx new file mode 100644 index 000000000..5b4a27646 --- /dev/null +++ b/source/commands/memory.spec.tsx @@ -0,0 +1,473 @@ +import test from 'ava'; +import React from 'react'; +import {ProposalStore} from '@/memory/proposal-store'; +import type {SemanticMemory} from '@/memory/semantic-memory-manager'; +import type {MemoryProposal} from '@/memory/summarizer-service'; +import {renderWithTheme} from '@/test-utils/render-with-theme'; +import type {Message} from '@/types/core'; +import {lazyCommands} from './lazy-registry.js'; +import {createMemoryCommand, memoryCommand} from './memory.js'; + +const testMetadata = { + provider: 'test-provider', + model: 'test-model', + tokens: 0, + getMessageTokens: (message: Message) => message.content.length, +}; + +class FakeMemoryManager { + memories: SemanticMemory[] = []; + cleared = false; + + async listMemories(): Promise { + return this.memories; + } + + async deleteMemory(id: string): Promise { + const before = this.memories.length; + this.memories = this.memories.filter(memory => memory.id !== id); + return this.memories.length !== before; + } + + async clearMemories(): Promise { + this.cleared = true; + this.memories = []; + } +} + +class FakeSummarizerService { + accepted: Array> = []; + sessionIds: Array = []; + + constructor(private readonly proposals: MemoryProposal[]) {} + + proposeMemoriesFromMessages(messages: Message[]): MemoryProposal[] { + return messages.length === 0 ? [] : this.proposals; + } + + async acceptProposal( + proposal: Pick, + sourceSessionId?: string, + ): Promise { + this.accepted.push({content: proposal.content, category: proposal.category}); + this.sessionIds.push(sourceSessionId); + return { + id: `accepted-${this.accepted.length}`, + content: proposal.content, + category: proposal.category, + timestamp: '2026-08-05T00:00:00.000Z', + }; + } +} + +test('memoryCommand has correct name and description', t => { + t.is(memoryCommand.name, 'memory'); + t.is(memoryCommand.description, 'Manage project memories'); +}); + +test('memory command lists empty state', async t => { + const manager = new FakeMemoryManager(); + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['list'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('No project memories saved.')); +}); + +test('memory command lists saved memories', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [ + { + id: 'memory-1', + content: 'Auth uses Clerk.', + category: 'architecture', + timestamp: '2026-07-21T00:00:00.000Z', + }, + ]; + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['list'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + const output = lastFrame() ?? ''; + + // Listed by short id rather than the raw UUID. + t.true(output.includes('memory1')); + t.true(output.includes('architecture')); + t.true(output.includes('Auth uses Clerk.')); +}); + +test('memory command deletes a memory', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [ + { + id: 'memory-1', + content: 'Auth uses Clerk.', + category: 'architecture', + timestamp: '2026-07-21T00:00:00.000Z', + }, + ]; + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['delete', 'memory-1'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Deleted memory: memory1')); + t.deepEqual(manager.memories, []); +}); + +test('memory command reports missing memory delete', async t => { + const manager = new FakeMemoryManager(); + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['delete', 'missing'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Memory not found: missing')); +}); + +test('memory command clears memories', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [ + { + id: 'memory-1', + content: 'Auth uses Clerk.', + category: 'architecture', + timestamp: '2026-07-21T00:00:00.000Z', + }, + ]; + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['clear'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true(manager.cleared); + t.true((lastFrame() ?? '').includes('Cleared project memories.')); +}); + +test('memory command shows usage for unknown subcommand', async t => { + const manager = new FakeMemoryManager(); + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['unknown'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Usage: /memory')); +}); + +test('memory command proposes durable memories from current messages', async t => { + const manager = new FakeMemoryManager(); + const command = createMemoryCommand({ + memoryManager: manager, + summarizerService: new FakeSummarizerService([ + { + content: 'Auth uses Clerk.', + category: 'architecture', + sourceType: 'explicit-user', + evidence: { + userMessages: ['Refactor auth.'], + assistantMessages: [], + }, + warnings: [], + }, + ]), + }); + + const result = await command.handler( + ['propose'], + [ + { + role: 'user', + content: 'Refactor auth.', + }, + ], + testMetadata, + ); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + const output = lastFrame() ?? ''; + + t.true(output.includes('[architecture]')); + t.true(output.includes('Auth uses Clerk.')); +}); + +test('memory command reports when no proposals are found', async t => { + const manager = new FakeMemoryManager(); + const command = createMemoryCommand({ + memoryManager: manager, + summarizerService: new FakeSummarizerService([]), + }); + + const result = await command.handler(['propose'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('No durable memory proposals found.')); +}); + +test('memory command accepts a proposal by index after propose', async t => { + const manager = new FakeMemoryManager(); + const summarizerService = new FakeSummarizerService([ + { + content: 'Auth uses Clerk.', + category: 'architecture', + sourceType: 'explicit-user', + evidence: {userMessages: ['Refactor auth.'], assistantMessages: []}, + warnings: [], + }, + ]); + const command = createMemoryCommand({memoryManager: manager, summarizerService}); + + await command.handler(['propose'], [{role: 'user', content: 'Refactor auth.'}], testMetadata); + const result = await command.handler(['accept', '1'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Saved architecture memory: Auth uses Clerk.')); + t.deepEqual(summarizerService.accepted, [ + {content: 'Auth uses Clerk.', category: 'architecture'}, + ]); + t.deepEqual(summarizerService.sessionIds, [undefined]); +}); + +test('memory command passes the current session id when accepting a proposal', async t => { + const manager = new FakeMemoryManager(); + const summarizerService = new FakeSummarizerService([ + { + content: 'Auth uses Clerk.', + category: 'architecture', + sourceType: 'explicit-user', + evidence: {userMessages: ['Refactor auth.'], assistantMessages: []}, + warnings: [], + }, + ]); + const command = createMemoryCommand({memoryManager: manager, summarizerService}); + + await command.handler(['propose'], [{role: 'user', content: 'Refactor auth.'}], testMetadata); + await command.handler(['accept', '1'], [], { + ...testMetadata, + sessionId: 'session-1', + }); + + t.deepEqual(summarizerService.sessionIds, ['session-1']); +}); + +test('memory command rejects accept with no prior proposals', async t => { + const manager = new FakeMemoryManager(); + const command = createMemoryCommand({ + memoryManager: manager, + summarizerService: new FakeSummarizerService([]), + }); + + const result = await command.handler(['accept', '1'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true( + (lastFrame() ?? '').includes('No proposals to accept. Run /memory propose first.'), + ); +}); + +test('memory command rejects accept with an out-of-range index', async t => { + const manager = new FakeMemoryManager(); + const summarizerService = new FakeSummarizerService([ + { + content: 'Auth uses Clerk.', + category: 'architecture', + sourceType: 'explicit-user', + evidence: {userMessages: ['Refactor auth.'], assistantMessages: []}, + warnings: [], + }, + ]); + const command = createMemoryCommand({memoryManager: manager, summarizerService}); + + await command.handler(['propose'], [{role: 'user', content: 'Refactor auth.'}], testMetadata); + const result = await command.handler(['accept', '5'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Usage: /memory accept <1-1>')); + t.deepEqual(summarizerService.accepted, []); +}); + +test('lazy registry exposes /memory', t => { + const memory = lazyCommands.find(command => command.name === 'memory'); + + t.truthy(memory); + t.is(memory?.description, 'Manage project memories'); +}); + +// --- Accept indexing: the round-3 review's merge blocker. Accepting a proposal +// must not renumber the list the user is still reading off screen. --- + +function proposal(content: string, category = 'architecture'): MemoryProposal { + return { + content, + category, + sourceType: 'explicit-user', + evidence: {userMessages: [content], assistantMessages: []}, + warnings: [], + }; +} + +const FOUR_PROPOSALS = [ + proposal('Proposal one.'), + proposal('Proposal two.'), + proposal('Proposal three.'), + proposal('Proposal four.'), +]; + +test('memory accept keeps indices stable across successive accepts', async t => { + const summarizerService = new FakeSummarizerService(FOUR_PROPOSALS); + const command = createMemoryCommand({ + memoryManager: new FakeMemoryManager(), + summarizerService, + }); + + await command.handler( + ['propose'], + [{role: 'user', content: 'seed'}], + testMetadata, + ); + await command.handler(['accept', '2'], [], testMetadata); + await command.handler(['accept', '3'], [], testMetadata); + + // Before the fix the second accept saved "Proposal four." because the list + // was re-indexed after the first accept. + t.deepEqual(summarizerService.accepted, [ + {content: 'Proposal two.', category: 'architecture'}, + {content: 'Proposal three.', category: 'architecture'}, + ]); +}); + +test('memory accept refuses to save the same proposal twice', async t => { + const summarizerService = new FakeSummarizerService(FOUR_PROPOSALS); + const command = createMemoryCommand({ + memoryManager: new FakeMemoryManager(), + summarizerService, + }); + + await command.handler( + ['propose'], + [{role: 'user', content: 'seed'}], + testMetadata, + ); + await command.handler(['accept', '2'], [], testMetadata); + const result = await command.handler(['accept', '2'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Proposal 2 was already saved.')); + t.is(summarizerService.accepted.length, 1); +}); + +test('memory accept is reset when the proposal store is cleared', async t => { + const store = new ProposalStore(); + const summarizerService = new FakeSummarizerService(FOUR_PROPOSALS); + const command = createMemoryCommand({ + memoryManager: new FakeMemoryManager(), + summarizerService, + proposalStore: store, + }); + + await command.handler( + ['propose'], + [{role: 'user', content: 'seed'}], + testMetadata, + ); + // What /clear does. + store.clear(); + + const result = await command.handler(['accept', '1'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true( + (lastFrame() ?? '').includes('No proposals to accept. Run /memory propose first.'), + ); + t.deepEqual(summarizerService.accepted, []); +}); + +// --- Short ids --- + +const UUID_A = '18d51c0d-becb-4efc-8d0d-b8c1f3b61802'; +const UUID_B = '18d51c0d-0000-4efc-8d0d-b8c1f3b61802'; +const UUID_C = 'ff000000-1111-4efc-8d0d-b8c1f3b61802'; + +function storedMemory(id: string, content: string): SemanticMemory { + return { + id, + content, + category: 'architecture', + timestamp: '2026-07-21T00:00:00.000Z', + }; +} + +test('memory list shows a short id instead of the raw UUID', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [storedMemory(UUID_A, 'Auth uses Clerk.')]; + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['list'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + const output = lastFrame() ?? ''; + + t.true(output.includes('18d51c0d')); + t.false(output.includes(UUID_A)); +}); + +test('memory delete accepts a short id', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [storedMemory(UUID_C, 'Auth uses Clerk.')]; + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['delete', 'ff000000'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Deleted memory: ff000000')); + t.deepEqual(manager.memories, []); +}); + +test('memory delete still accepts a full UUID', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [storedMemory(UUID_C, 'Auth uses Clerk.')]; + const command = createMemoryCommand({memoryManager: manager}); + + await command.handler(['delete', UUID_C], [], testMetadata); + + t.deepEqual(manager.memories, []); +}); + +test('memory delete reports an ambiguous short id instead of guessing', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [ + storedMemory(UUID_A, 'Auth uses Clerk.'), + storedMemory(UUID_B, 'Storage uses SQLite.'), + ]; + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['delete', '18d51c0d'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Ambiguous memory id')); + t.is(manager.memories.length, 2); +}); + +test('bare /memory defaults to list', async t => { + const manager = new FakeMemoryManager(); + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler([], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('No project memories saved.')); +}); + +test('memory ls and rm aliases behave like list and delete', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [storedMemory(UUID_C, 'Auth uses Clerk.')]; + const command = createMemoryCommand({memoryManager: manager}); + + const listed = await command.handler(['ls'], [], testMetadata); + t.true( + (renderWithTheme(listed as React.ReactElement).lastFrame() ?? '').includes( + 'Auth uses Clerk.', + ), + ); + + await command.handler(['rm', 'ff000000'], [], testMetadata); + t.deepEqual(manager.memories, []); +}); diff --git a/source/commands/memory.tsx b/source/commands/memory.tsx new file mode 100644 index 000000000..528cb7e8a --- /dev/null +++ b/source/commands/memory.tsx @@ -0,0 +1,327 @@ +import {Box, Text} from 'ink'; +import {TitledBoxWithPreferences} from '@/components/ui/titled-box'; +import {useTerminalWidth} from '@/hooks/useTerminalWidth'; +import {useTheme} from '@/hooks/useTheme'; +import {ProposalStore, sharedProposalStore} from '@/memory/proposal-store'; +import type {SemanticMemory} from '@/memory/semantic-memory-manager'; +import {SemanticMemoryManager} from '@/memory/semantic-memory-manager'; +import type {MemoryProposal} from '@/memory/summarizer-service'; +import {SummarizerService} from '@/memory/summarizer-service'; +import type {Command} from '@/types/commands'; +import {formatError} from '@/utils/error-formatter'; +import {errorMsg, infoMsg, successMsg} from '@/utils/message-factory'; + +interface MemoryCommandOptions { + memoryManager?: Pick< + SemanticMemoryManager, + 'listMemories' | 'deleteMemory' | 'clearMemories' + >; + summarizerService?: Pick< + SummarizerService, + 'proposeMemoriesFromMessages' | 'acceptProposal' + >; + proposalStore?: ProposalStore; +} + +const USAGE = + 'Usage: /memory list | /memory delete | /memory clear | /memory propose | /memory accept '; + +/** Length of the display id. Long enough to stay unique in a realistic pool, + * short enough to retype without copying out of wrapped terminal output. */ +const SHORT_ID_LENGTH = 8; + +export function shortMemoryId(id: string): string { + return id.replaceAll('-', '').slice(0, SHORT_ID_LENGTH); +} + +type IdLookup = + | {kind: 'found'; memory: SemanticMemory} + | {kind: 'missing'} + | {kind: 'ambiguous'; matches: SemanticMemory[]}; + +/** Accepts a short id, a full UUID, or any unambiguous prefix of either. */ +export function resolveMemoryId( + memories: SemanticMemory[], + input: string, +): IdLookup { + const needle = input.trim().toLowerCase(); + if (!needle) return {kind: 'missing'}; + + const exact = memories.find(memory => memory.id.toLowerCase() === needle); + if (exact) return {kind: 'found', memory: exact}; + + const matches = memories.filter(memory => { + const compact = memory.id.replaceAll('-', '').toLowerCase(); + return ( + compact.startsWith(needle.replaceAll('-', '')) || + memory.id.toLowerCase().startsWith(needle) + ); + }); + + if (matches.length === 0) return {kind: 'missing'}; + if (matches.length > 1) return {kind: 'ambiguous', matches}; + return {kind: 'found', memory: matches[0] as SemanticMemory}; +} + +function MemoryList({memories}: {memories: SemanticMemory[]}) { + const {colors} = useTheme(); + const width = useTerminalWidth(); + + return ( + + {memories.map((memory, index) => ( + + + + {shortMemoryId(memory.id)} + + · {memory.category} + + + {memory.content} + + + ))} + + + + {memories.length} memor{memories.length === 1 ? 'y' : 'ies'} · delete + one with /memory delete <id> + + + + ); +} + +function ProposalEvidence({proposal}: {proposal: MemoryProposal}) { + const {colors} = useTheme(); + const rows = [ + ...proposal.evidence.userMessages.map(text => ({label: 'User', text})), + ...proposal.evidence.assistantMessages.map(text => ({ + label: 'Assistant', + text, + })), + ]; + + if (rows.length === 0) return null; + + return ( + + {rows.map(row => ( + + {row.label}: "{row.text}" + + ))} + + ); +} + +function MemoryProposals({proposals}: {proposals: readonly MemoryProposal[]}) { + const {colors} = useTheme(); + const width = useTerminalWidth(); + + return ( + + {proposals.map((proposal, index) => { + const hasWarnings = proposal.warnings.length > 0; + return ( + + + + {index + 1}. + + [{proposal.category}] + {proposal.sourceType} + {hasWarnings && ( + · review carefully + )} + + + {proposal.content} + + + {proposal.warnings.map(warning => ( + + ⚠ {warning} + + ))} + + ); + })} + + + + Save one with /memory accept <1-{proposals.length}> + + + + ); +} + +export function createMemoryCommand( + options: MemoryCommandOptions = {}, +): Command { + let memoryManager = options.memoryManager; + let summarizerService = options.summarizerService; + if (!memoryManager) { + const manager = new SemanticMemoryManager(); + memoryManager = manager; + summarizerService ??= new SummarizerService(manager); + } else { + summarizerService ??= new SummarizerService(); + } + // Defaults to a private store; only the exported singleton binds the shared + // one, so tests and any ad-hoc instance can't clobber each other's state. + const proposalStore = options.proposalStore ?? new ProposalStore(); + + return { + name: 'memory', + description: 'Manage project memories', + handler: async (args, messages, metadata) => { + const subcommand = args[0]?.toLowerCase() ?? 'list'; + + try { + if (subcommand === 'list' || subcommand === 'ls') { + const memories = await memoryManager.listMemories(); + if (memories.length === 0) { + return infoMsg('No project memories saved.', 'memory-list'); + } + + return ; + } + + if (subcommand === 'delete' || subcommand === 'rm') { + const id = args[1]?.trim(); + if (!id) return errorMsg(USAGE, 'memory-error'); + + const lookup = resolveMemoryId( + await memoryManager.listMemories(), + id, + ); + if (lookup.kind === 'missing') { + return errorMsg(`Memory not found: ${id}`, 'memory-error'); + } + if (lookup.kind === 'ambiguous') { + const ids = lookup.matches + .map(memory => shortMemoryId(memory.id)) + .join(', '); + return errorMsg( + `Ambiguous memory id "${id}" matches: ${ids}`, + 'memory-error', + ); + } + + const deleted = await memoryManager.deleteMemory(lookup.memory.id); + if (!deleted) { + return errorMsg(`Memory not found: ${id}`, 'memory-error'); + } + + return successMsg( + `Deleted memory: ${shortMemoryId(lookup.memory.id)}`, + 'memory-deleted', + ); + } + + if (subcommand === 'clear') { + await memoryManager.clearMemories(); + proposalStore.clear(); + return successMsg('Cleared project memories.', 'memory-cleared'); + } + + if (subcommand === 'propose') { + const proposals = + summarizerService.proposeMemoriesFromMessages(messages); + if (proposals.length === 0) { + proposalStore.clear(); + return infoMsg( + 'No durable memory proposals found.', + 'memory-propose', + ); + } + + // Warning-free proposals first, so the safest choices carry the + // lowest numbers. Order is fixed here and never changes again - + // `/memory accept` indexes into exactly this list. + proposals.sort( + (a, b) => + (a.warnings.length === 0 ? 0 : 1) - + (b.warnings.length === 0 ? 0 : 1), + ); + proposalStore.set(proposals); + + return ; + } + + if (subcommand === 'accept') { + if (proposalStore.size === 0) { + return errorMsg( + 'No proposals to accept. Run /memory propose first.', + 'memory-error', + ); + } + + const index = Number.parseInt(args[1] ?? '', 10); + const proposal = proposalStore.at(index); + if (!proposal) { + return errorMsg( + `Usage: /memory accept <1-${proposalStore.size}>`, + 'memory-error', + ); + } + if (proposalStore.isAccepted(index)) { + return errorMsg( + `Proposal ${index} was already saved.`, + 'memory-error', + ); + } + + const memory = await summarizerService.acceptProposal( + proposal, + metadata.sessionId, + ); + proposalStore.markAccepted(index); + + return successMsg( + `Saved ${memory.category} memory: ${memory.content}`, + 'memory-accept', + ); + } + + return errorMsg(USAGE, 'memory-error'); + } catch (error) { + return errorMsg( + `Failed to manage memory: ${formatError(error)}`, + 'memory-error', + ); + } + }, + }; +} + +export const memoryCommand: Command = createMemoryCommand({ + proposalStore: sharedProposalStore, +}); diff --git a/source/commands/remember.spec.tsx b/source/commands/remember.spec.tsx new file mode 100644 index 000000000..4f07106e9 --- /dev/null +++ b/source/commands/remember.spec.tsx @@ -0,0 +1,137 @@ +import test from 'ava'; +import React from 'react'; +import type {SemanticMemory} from '@/memory/semantic-memory-manager'; +import {SummarizerService} from '@/memory/summarizer-service'; +import {renderWithTheme} from '@/test-utils/render-with-theme'; +import type {Message} from '@/types/core'; +import {lazyCommands} from './lazy-registry.js'; +import {createRememberCommand, rememberCommand} from './remember.js'; + +const testMetadata = { + provider: 'test-provider', + model: 'test-model', + tokens: 0, + getMessageTokens: (message: Message) => message.content.length, +}; + +class FakeSummarizerService extends SummarizerService { + rememberedInput?: { + content: string; + category?: string; + sourceSessionId?: string; + }; + + constructor( + private readonly memory: SemanticMemory, + private readonly error?: Error, + ) { + super(); + } + + override async remember(input: { + content: string; + category?: string; + sourceSessionId?: string; + }): Promise { + this.rememberedInput = input; + if (this.error) throw this.error; + return this.memory; + } +} + +test('rememberCommand has correct name and description', t => { + t.is(rememberCommand.name, 'remember'); + t.is(rememberCommand.description, 'Save a durable project memory'); +}); + +test('remember command returns usage when content is missing', async t => { + const result = await rememberCommand.handler([], [], testMetadata); + t.truthy(React.isValidElement(result)); + + const {lastFrame} = renderWithTheme(result as React.ReactElement); + const output = lastFrame() ?? ''; + + t.true(output.includes('Usage: /remember')); +}); + +test('remember command saves a manual memory', async t => { + const service = new FakeSummarizerService({ + id: 'memory-1', + content: 'Use the existing auth adapter.', + category: 'architecture', + timestamp: '2026-07-15T00:00:00.000Z', + }); + const command = createRememberCommand({summarizerService: service}); + + const result = await command.handler( + ['Use', 'the', 'existing', 'auth', 'adapter.'], + [], + testMetadata, + ); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.deepEqual(service.rememberedInput, { + content: 'Use the existing auth adapter.', + category: undefined, + }); + t.true((lastFrame() ?? '').includes('Remembered architecture memory.')); +}); + +test('remember command forwards explicit category', async t => { + const service = new FakeSummarizerService({ + id: 'memory-1', + content: 'Keep generated files out of review.', + category: 'codingStyle', + timestamp: '2026-07-15T00:00:00.000Z', + }); + const command = createRememberCommand({summarizerService: service}); + + await command.handler( + [ + '--category', + 'coding-style', + 'Keep', + 'generated', + 'files', + 'out', + 'of', + 'review.', + ], + [], + testMetadata, + ); + + t.deepEqual(service.rememberedInput, { + content: 'Keep generated files out of review.', + category: 'coding-style', + }); +}); + +test('remember command reports save failures', async t => { + const service = new FakeSummarizerService( + { + id: 'memory-1', + content: 'Use the existing auth adapter.', + category: 'architecture', + timestamp: '2026-07-15T00:00:00.000Z', + }, + new Error('disk full'), + ); + const command = createRememberCommand({summarizerService: service}); + + const result = await command.handler( + ['Use', 'the', 'existing', 'auth', 'adapter.'], + [], + testMetadata, + ); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Failed to save memory: disk full')); +}); + +test('lazy registry exposes /remember', t => { + const remember = lazyCommands.find(command => command.name === 'remember'); + + t.truthy(remember); + t.is(remember?.description, 'Save a durable project memory'); +}); diff --git a/source/commands/remember.ts b/source/commands/remember.ts new file mode 100644 index 000000000..0d7cef6c2 --- /dev/null +++ b/source/commands/remember.ts @@ -0,0 +1,79 @@ +import {SummarizerService} from '@/memory/summarizer-service'; +import type {Command} from '@/types/commands'; +import {formatError} from '@/utils/error-formatter'; +import {errorMsg, successMsg} from '@/utils/message-factory'; + +interface RememberCommandOptions { + summarizerService?: SummarizerService; +} + +interface ParsedRememberArgs { + content: string; + category?: string; + error?: string; +} + +const USAGE = 'Usage: /remember [--category ] '; + +export function createRememberCommand( + options: RememberCommandOptions = {}, +): Command { + const summarizerService = + options.summarizerService ?? new SummarizerService(); + + return { + name: 'remember', + description: 'Save a durable project memory', + handler: async (args: string[]) => { + const parsed = parseRememberArgs(args); + if (parsed.error) { + return errorMsg(parsed.error, 'remember-error'); + } + + try { + const memory = await summarizerService.remember({ + content: parsed.content, + category: parsed.category, + }); + + return successMsg( + `Remembered ${memory.category} memory.`, + 'remember-success', + ); + } catch (error) { + return errorMsg( + `Failed to save memory: ${formatError(error)}`, + 'remember-error', + ); + } + }, + }; +} + +export const rememberCommand: Command = createRememberCommand(); + +function parseRememberArgs(args: string[]): ParsedRememberArgs { + let category: string | undefined; + const contentParts: string[] = []; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === '--category' || arg === '-c') { + const value = args[index + 1]; + if (!value) { + return {content: '', error: USAGE}; + } + + category = value; + index++; + continue; + } + + contentParts.push(arg); + } + + const content = contentParts.join(' ').trim(); + if (!content) return {content: '', error: USAGE}; + + return {content, category}; +} diff --git a/source/components/development-mode-indicator.spec.tsx b/source/components/development-mode-indicator.spec.tsx index 293467e1b..6d80f5176 100644 --- a/source/components/development-mode-indicator.spec.tsx +++ b/source/components/development-mode-indicator.spec.tsx @@ -601,3 +601,79 @@ test('collapsed task badge keeps the key hint when there is room for it', t => { ); t.regex(output, /Tasks \(2\/5 Ctrl-t\)/); }); + +// ============================================================================ +// Auto-save indicator tests (Issue #932) +// ============================================================================ + +test('DevelopmentModeIndicator renders saving indicator when isSaving is true', t => { + const output = renderWithWidth( + , + ); + t.regex(output, /saving/); +}); + +test('DevelopmentModeIndicator omits saving indicator when isSaving is false or undefined', t => { + const falseOutput = renderWithWidth( + , + ); + t.notRegex(falseOutput, /saving/); + + const undefOutput = renderWithWidth( + , + ); + t.notRegex(undefOutput, /saving/); +}); + +test('saving indicator drops under narrow width pressure without shrinking session name', t => { + const fullSession = 'feature-authentication-token'; + // Render at a tight width of 40 columns + const output = renderWithWidth( + , + 42, + ); + + // saving indicator must drop when width is tight + t.notRegex(output, /saving/); + // Session name must still have room and not be shrunk away + t.regex(output, /feature/); +}); + +test('saving indicator renders when there is sufficient width', t => { + const output = renderWithWidth( + , + 100, + ); + + t.regex(output, /my-session/); + t.regex(output, /saving/); + t.regex(output, /ctx: 40%/); +}); diff --git a/source/components/development-mode-indicator.tsx b/source/components/development-mode-indicator.tsx index 325f17a44..72566a0cb 100644 --- a/source/components/development-mode-indicator.tsx +++ b/source/components/development-mode-indicator.tsx @@ -31,6 +31,7 @@ interface DevelopmentModeIndicatorProps { currentModel?: string; activeEditor?: ActiveEditorState | null; taskInfo?: TaskIndicatorInfo | null; + isSaving?: boolean; } function getContextColor( @@ -58,6 +59,7 @@ export const DevelopmentModeIndicator = React.memo( currentModel, activeEditor, taskInfo, + isSaving, }: DevelopmentModeIndicatorProps) => { const {isNarrow, actualWidth, truncate} = useResponsiveTerminal(); const modeLabel = isNarrow @@ -110,147 +112,164 @@ export const DevelopmentModeIndicator = React.memo( // share whatever room is left, each truncating with an ellipsis; if both // fit fully neither truncates; if both overflow they split the remaining // space evenly. - // The Ctrl-t hint, the line-range suffix and the (Shift+Tab to cycle) - // hint are optional — drop them when otherwise the row would wrap. The - // Ctrl-t hint drops first (the collapsed badge still reports progress - // without it, and an expanded list needs no badge at all), then the - // line-range suffix, then the shift hint. - const {sessionLabel, editorLabel, showShiftHint, taskLabel} = (() => { - const editorFileName = activeEditor?.fileName; - const hasSelection = - !!activeEditor?.selection && - !!activeEditor.startLine && - !!activeEditor.endLine; - const editorPrefix = editorFileName - ? hasSelection - ? '⊡ ' - : '⊡ In ' - : ''; - const editorSuffixFull = - editorFileName && hasSelection - ? ` (L${activeEditor.startLine}-${activeEditor.endLine})` + // The Ctrl-t hint, the saving indicator, the line-range suffix and + // the (Shift+Tab to cycle) hint are optional — drop them when otherwise + // the row would wrap. The Ctrl-t hint drops first, then the saving + // indicator, then the line-range suffix, then the shift hint. + const {sessionLabel, editorLabel, showShiftHint, taskLabel, showSaving} = + (() => { + const editorFileName = activeEditor?.fileName; + const hasSelection = + !!activeEditor?.selection && + !!activeEditor.startLine && + !!activeEditor.endLine; + const editorPrefix = editorFileName + ? hasSelection + ? '⊡ ' + : '⊡ In ' : ''; + const editorSuffixFull = + editorFileName && hasSelection + ? ` (L${activeEditor.startLine}-${activeEditor.endLine})` + : ''; - const shiftHintFull = - isNarrow && developmentMode !== 'headless' - ? ' (Shift+Tab to cycle)' + const shiftHintFull = + isNarrow && developmentMode !== 'headless' + ? ' (Shift+Tab to cycle)' + : ''; + const tuneSegment = tuneLabel ? ` · ${tuneLabel}` : ''; + const taskBaseSegment = taskLabelBase ? ` · ${taskLabelBase}` : ''; + const taskHintSegment = taskLabelWithHint + ? ` · ${taskLabelWithHint}` : ''; - const tuneSegment = tuneLabel ? ` · ${tuneLabel}` : ''; - const taskBaseSegment = taskLabelBase ? ` · ${taskLabelBase}` : ''; - const taskHintSegment = taskLabelWithHint - ? ` · ${taskLabelWithHint}` - : ''; - // Cost of upgrading the badge from its base form to the key-hint - // form. With the list expanded there is no base form, so this is the - // price of the whole segment. - const taskHintExtraFull = taskHintSegment.length - taskBaseSegment.length; - const ctxSegment = - contextPercentUsed !== null - ? ` · ctx: ${ctxPrefix}${contextPercentUsed}%` - : ''; - const sessionSeparator = sessionName ? ' · ' : ''; - const editorSeparator = editorFileName ? ' · ' : ''; + // Cost of upgrading the badge from its base form to the key-hint + // form. With the list expanded there is no base form, so this is the + // price of the whole segment. + const taskHintExtraFull = + taskHintSegment.length - taskBaseSegment.length; + const savingSegment = isSaving ? ' · saving' : ''; + const savingExtraFull = savingSegment.length; + const ctxSegment = + contextPercentUsed !== null + ? ` · ctx: ${ctxPrefix}${contextPercentUsed}%` + : ''; + const sessionSeparator = sessionName ? ' · ' : ''; + const editorSeparator = editorFileName ? ' · ' : ''; - const minLen = 6; - const minSessionLen = sessionName ? minLen : 0; - const minEditorLen = editorFileName ? minLen : 0; + const minLen = 6; + const minSessionLen = sessionName ? minLen : 0; + const minEditorLen = editorFileName ? minLen : 0; - // Width consumed by parts that always render. - const requiredWidth = - modeLabel.length + - tuneSegment.length + - taskBaseSegment.length + - ctxSegment.length + - sessionSeparator.length + - editorSeparator.length + - editorPrefix.length + - minSessionLen + - minEditorLen; + // Width consumed by parts that always render. + const requiredWidth = + modeLabel.length + + tuneSegment.length + + taskBaseSegment.length + + ctxSegment.length + + sessionSeparator.length + + editorSeparator.length + + editorPrefix.length + + minSessionLen + + minEditorLen; - // Decide which optional segments fit. Drop the Ctrl-t hint first, - // then the suffix, then the shift hint, until the row fits within - // actualWidth. - let editorSuffix = editorSuffixFull; - let shiftHint = shiftHintFull; - let taskHintExtra = taskHintExtraFull; - if ( - requiredWidth + - taskHintExtra + - editorSuffix.length + - shiftHint.length + - 1 > - actualWidth - ) { - taskHintExtra = 0; + // Decide which optional segments fit. Drop the Ctrl-t hint first, + // then the saving indicator, then the suffix, then the shift hint, + // until the row fits within actualWidth. + let editorSuffix = editorSuffixFull; + let shiftHint = shiftHintFull; + let taskHintExtra = taskHintExtraFull; + let savingExtra = savingExtraFull; if ( - requiredWidth + editorSuffix.length + shiftHint.length + 1 > + requiredWidth + + taskHintExtra + + savingExtra + + editorSuffix.length + + shiftHint.length + + 1 > actualWidth ) { - editorSuffix = ''; - if (requiredWidth + shiftHint.length + 1 > actualWidth) { - shiftHint = ''; + taskHintExtra = 0; + if ( + requiredWidth + + savingExtra + + editorSuffix.length + + shiftHint.length + + 1 > + actualWidth + ) { + savingExtra = 0; + if ( + requiredWidth + editorSuffix.length + shiftHint.length + 1 > + actualWidth + ) { + editorSuffix = ''; + if (requiredWidth + shiftHint.length + 1 > actualWidth) { + shiftHint = ''; + } + } } } - } - const fixedWidth = - modeLabel.length + - shiftHint.length + - tuneSegment.length + - taskBaseSegment.length + - taskHintExtra + - ctxSegment.length + - sessionSeparator.length + - editorSeparator.length + - editorPrefix.length + - editorSuffix.length; + const fixedWidth = + modeLabel.length + + shiftHint.length + + tuneSegment.length + + taskBaseSegment.length + + taskHintExtra + + savingExtra + + ctxSegment.length + + sessionSeparator.length + + editorSeparator.length + + editorPrefix.length + + editorSuffix.length; - const remaining = Math.max(0, actualWidth - fixedWidth - 1); + const remaining = Math.max(0, actualWidth - fixedWidth - 1); - let sessionMax = 0; - let filenameMax = 0; - if (sessionName && editorFileName) { - const sessionNeed = sessionName.length; - const filenameNeed = editorFileName.length; - if (sessionNeed + filenameNeed <= remaining) { - sessionMax = sessionNeed; - filenameMax = filenameNeed; - } else { - const half = Math.floor(remaining / 2); - if (sessionNeed <= half) { + let sessionMax = 0; + let filenameMax = 0; + if (sessionName && editorFileName) { + const sessionNeed = sessionName.length; + const filenameNeed = editorFileName.length; + if (sessionNeed + filenameNeed <= remaining) { sessionMax = sessionNeed; - filenameMax = remaining - sessionMax; - } else if (filenameNeed <= half) { filenameMax = filenameNeed; - sessionMax = remaining - filenameMax; } else { - sessionMax = half; - filenameMax = remaining - half; + const half = Math.floor(remaining / 2); + if (sessionNeed <= half) { + sessionMax = sessionNeed; + filenameMax = remaining - sessionMax; + } else if (filenameNeed <= half) { + filenameMax = filenameNeed; + sessionMax = remaining - filenameMax; + } else { + sessionMax = half; + filenameMax = remaining - half; + } } + } else if (sessionName) { + sessionMax = remaining; + } else if (editorFileName) { + filenameMax = remaining; } - } else if (sessionName) { - sessionMax = remaining; - } else if (editorFileName) { - filenameMax = remaining; - } - const session = sessionName - ? truncate(sessionName, Math.max(minLen, sessionMax)) - : null; - const editor = editorFileName - ? `${editorPrefix}${truncate( - editorFileName, - Math.max(minLen, filenameMax), - )}${editorSuffix}` - : null; + const session = sessionName + ? truncate(sessionName, Math.max(minLen, sessionMax)) + : null; + const editor = editorFileName + ? `${editorPrefix}${truncate( + editorFileName, + Math.max(minLen, filenameMax), + )}${editorSuffix}` + : null; - return { - sessionLabel: session, - editorLabel: editor, - showShiftHint: shiftHint.length > 0, - taskLabel: taskHintExtra > 0 ? taskLabelWithHint : taskLabelBase, - }; - })(); + return { + sessionLabel: session, + editorLabel: editor, + showShiftHint: shiftHint.length > 0, + taskLabel: taskHintExtra > 0 ? taskLabelWithHint : taskLabelBase, + showSaving: savingExtra > 0, + }; + })(); return ( @@ -291,6 +310,14 @@ export const DevelopmentModeIndicator = React.memo( )} + {showSaving && ( + <> + · + + saving + + + )} {contextPercentUsed !== null && ( <> · diff --git a/source/components/file-explorer/index.tsx b/source/components/file-explorer/index.tsx index e1b5b210e..d7ca852d9 100644 --- a/source/components/file-explorer/index.tsx +++ b/source/components/file-explorer/index.tsx @@ -3,6 +3,7 @@ import {highlight} from 'cli-highlight'; import {Box, Text, useFocus, useInput} from 'ink'; import {useEffect, useMemo, useState} from 'react'; import {StyledTitle} from '@/components/ui/styled-title'; +import {getSyntaxTheme} from '@/config/themes'; import { CHARS_PER_TOKEN_ESTIMATE, FILE_EXPLORER_TOKEN_WARNING_THRESHOLD, @@ -39,7 +40,11 @@ export function FileExplorer({onClose}: FileExplorerProps) { const [selectedFiles, setSelectedFiles] = useState>(new Set()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [preview, setPreview] = useState(null); + // The plain, indentation-compressed source. Highlighting is derived from it + // below rather than stored, so switching theme with the preview open + // re-colours it instead of leaving the old palette until reselect. + const [previewSource, setPreviewSource] = useState(null); + const [previewLanguage, setPreviewLanguage] = useState('plaintext'); const [previewError, setPreviewError] = useState(null); const [previewPath, setPreviewPath] = useState(null); const [viewMode, setViewMode] = useState('tree'); @@ -113,10 +118,24 @@ export function FileExplorer({onClose}: FileExplorerProps) { return Math.ceil(totalSize / CHARS_PER_TOKEN_ESTIMATE); }, [selectedFiles, allNodes]); + // Re-runs when the palette changes, so a theme switch recolours an open + // preview. Highlighting failures fall back to the plain source. + const preview = useMemo(() => { + if (previewSource === null) return null; + try { + return highlight(previewSource, { + language: previewLanguage, + theme: getSyntaxTheme(colors), + }); + } catch { + return previewSource; + } + }, [previewSource, previewLanguage, colors]); + // Load preview when entering preview mode const loadPreviewForNode = async (node: FileNode) => { if (node.isDirectory) { - setPreview(null); + setPreviewSource(null); setPreviewError('Cannot preview directory'); return; } @@ -136,24 +155,13 @@ export function FileExplorer({onClose}: FileExplorerProps) { const compressedLines = compressIndentation(lines); const compressedContent = compressedLines.join('\n'); - // Apply syntax highlighting - let highlighted: string; - try { - highlighted = highlight(compressedContent, { - language: lang, - theme: 'default', - }); - } catch { - // Fallback to plain text if highlighting fails - highlighted = compressedContent; - } - - setPreview(highlighted); + setPreviewLanguage(lang); + setPreviewSource(compressedContent); setPreviewPath(node.path); setPreviewError(null); setPreviewScroll(0); } catch { - setPreview(null); + setPreviewSource(null); setPreviewError('Cannot preview (binary or unreadable)'); } }; diff --git a/source/components/user-input.spec.tsx b/source/components/user-input.spec.tsx index c3b553d63..8fdd8cc75 100644 --- a/source/components/user-input.spec.tsx +++ b/source/components/user-input.spec.tsx @@ -420,14 +420,13 @@ test('UserInput navigates queued messages while busy with empty input', async t unmount(); }); -test('UserInput loads selected queued message for editing', async t => { +test('UserInput loads selected queued message for editing while idle', async t => { let removedId = ''; const {stdin, lastFrame, unmount} = render( { t.notRegex(output, /Available commands:/); unmount(); }); - // pasteEvents is a module singleton, so these run serially: a concurrently // mounted UserInput would also receive the payload and corrupt its frame. @@ -1163,4 +1161,3 @@ test.serial('UserInput ignores terminal pastes while disabled', async t => { t.notRegex(lastFrame()!, /should not appear/); unmount(); }); - diff --git a/source/components/user-input.tsx b/source/components/user-input.tsx index 52cecd97b..66fc96ba9 100644 --- a/source/components/user-input.tsx +++ b/source/components/user-input.tsx @@ -74,6 +74,7 @@ interface ChatProps { forceFocus?: boolean; // Force focus for testing (bypasses useFocus) onSubmittedDraft?: (draft: SubmittedInputDraft) => void; restoreSubmittedDraft?: RestoredInputDraft | null; + isSaving?: boolean; } export default function UserInput({ @@ -102,6 +103,7 @@ export default function UserInput({ forceFocus = false, onSubmittedDraft, restoreSubmittedDraft = null, + isSaving, }: ChatProps) { const {isFocused, focus} = useFocus({autoFocus: !disabled, id: 'user-input'}); const effectiveFocus = forceFocus || isFocused; @@ -631,7 +633,7 @@ export default function UserInput({ const handleQueueNavigation = useCallback( (direction: 'up' | 'down') => { - if (!isBusy || input.length > 0 || queuedMessages.length === 0) { + if (input.length > 0 || queuedMessages.length === 0) { return false; } @@ -654,12 +656,11 @@ export default function UserInput({ setSelectedQueuedIndex(selectedQueuedIndex + 1); return true; }, - [isBusy, input.length, queuedMessages.length, selectedQueuedIndex], + [input.length, queuedMessages.length, selectedQueuedIndex], ); const loadSelectedQueuedMessage = useCallback(() => { if ( - !isBusy || input.length > 0 || selectedQueuedIndex < 0 || selectedQueuedIndex >= queuedMessages.length @@ -680,7 +681,6 @@ export default function UserInput({ setTextInputKey(prev => prev + 1); return true; }, [ - isBusy, input.length, selectedQueuedIndex, queuedMessages, @@ -690,7 +690,6 @@ export default function UserInput({ const removeSelectedQueuedMessage = useCallback(() => { if ( - !isBusy || input.length > 0 || selectedQueuedIndex < 0 || selectedQueuedIndex >= queuedMessages.length @@ -704,7 +703,6 @@ export default function UserInput({ ); return true; }, [ - isBusy, input.length, selectedQueuedIndex, queuedMessages, @@ -1007,6 +1005,7 @@ export default function UserInput({ tune={tune} currentModel={currentModel} taskInfo={taskInfo} + isSaving={isSaving} /> ); @@ -1177,6 +1176,7 @@ export default function UserInput({ currentModel={currentModel} activeEditor={activeEditor} taskInfo={taskInfo} + isSaving={isSaving} /> ); diff --git a/source/config/preferences.spec.ts b/source/config/preferences.spec.ts index 1e7c0da4e..2bf98753c 100644 --- a/source/config/preferences.spec.ts +++ b/source/config/preferences.spec.ts @@ -2,6 +2,14 @@ import {existsSync, mkdirSync, readFileSync, rmSync, writeFileSync} from 'node:f import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test from 'ava'; +import { + DEFAULT_MEMORY_LIMIT, + DEFAULT_TOKEN_BUDGET, + MAX_MEMORY_LIMIT, + MAX_TOKEN_BUDGET, + MIN_MEMORY_LIMIT, + MIN_TOKEN_BUDGET, +} from '@/memory/project-context'; import { getCompactToolDisplay, getLastUsedModel, @@ -9,8 +17,11 @@ import { getNotificationsPreference, getPasteThreshold, getProfessionalTone, + getProjectContextPreferences, getReasoningExpanded, + getSemanticMemoryEnabled, loadPreferences, + resolveProjectContextPreferences, resetPreferencesCache, savePreferences, getShowUsageFooter, @@ -21,6 +32,9 @@ import { updatePasteThreshold, updateProfessionalTone, updateReasoningExpanded, + updateSemanticMemoryEnabled, + updateSemanticMemoryLimit, + updateSemanticMemoryTokenBudget, getPrivacyPreference, updatePrivacyPreference, updateShowUsageFooter, @@ -1711,3 +1725,177 @@ test.serial('updateProfessionalTone preserves other preferences', t => { } } }); + +test.serial('getSemanticMemoryEnabled returns true when not set', t => { + const preferencesPath = getTestPreferencesPath(); + const preferences: UserPreferences = {}; + writeFileSync(preferencesPath, JSON.stringify(preferences), 'utf-8'); + + try { + const result = getSemanticMemoryEnabled(); + t.is(result, true); + } finally { + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + } +}); + +test.serial('getSemanticMemoryEnabled returns false when disabled', t => { + const preferencesPath = getTestPreferencesPath(); + const preferences: UserPreferences = {semanticMemoryEnabled: false}; + writeFileSync(preferencesPath, JSON.stringify(preferences), 'utf-8'); + + try { + const result = getSemanticMemoryEnabled(); + t.is(result, false); + } finally { + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + } +}); + +test.serial('updateSemanticMemoryEnabled saves the preference correctly', t => { + const preferencesPath = getTestPreferencesPath(); + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + + try { + updateSemanticMemoryEnabled(false); + + t.true(existsSync(preferencesPath)); + const content = readFileSync(preferencesPath, 'utf-8'); + const parsed = JSON.parse(content) as UserPreferences; + + t.is(parsed.semanticMemoryEnabled, false); + } finally { + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + } +}); + +// ============================================================================ +// Project Context Preferences Tests (token budget + memory limit, round-4 review) +// ============================================================================ + +test('resolveProjectContextPreferences falls back to the shipped defaults', t => { + t.deepEqual(resolveProjectContextPreferences({} as UserPreferences), { + semanticMemoryEnabled: true, + memoryLimit: DEFAULT_MEMORY_LIMIT, + tokenBudget: DEFAULT_TOKEN_BUDGET, + }); +}); + +test('resolveProjectContextPreferences honours configured values', t => { + t.deepEqual( + resolveProjectContextPreferences({ + semanticMemoryEnabled: false, + semanticMemoryLimit: 3, + semanticMemoryTokenBudget: 120, + } as UserPreferences), + {semanticMemoryEnabled: false, memoryLimit: 3, tokenBudget: 120}, + ); +}); + +test('resolveProjectContextPreferences clamps out-of-range values', t => { + const tooLow = resolveProjectContextPreferences({ + semanticMemoryLimit: 0, + semanticMemoryTokenBudget: 1, + } as UserPreferences); + t.is(tooLow.memoryLimit, MIN_MEMORY_LIMIT); + t.is(tooLow.tokenBudget, MIN_TOKEN_BUDGET); + + const tooHigh = resolveProjectContextPreferences({ + semanticMemoryLimit: 10_000, + semanticMemoryTokenBudget: 10_000, + } as UserPreferences); + t.is(tooHigh.memoryLimit, MAX_MEMORY_LIMIT); + t.is(tooHigh.tokenBudget, MAX_TOKEN_BUDGET); +}); + +test.serial('getProjectContextPreferences reads token budget and memory limit from disk', t => { + const preferencesPath = getTestPreferencesPath(); + const data: UserPreferences = { + semanticMemoryEnabled: true, + semanticMemoryLimit: 12, + semanticMemoryTokenBudget: 480, + }; + writeFileSync(preferencesPath, JSON.stringify(data, null, 2), 'utf-8'); + + try { + t.deepEqual(getProjectContextPreferences(), { + semanticMemoryEnabled: true, + memoryLimit: 12, + tokenBudget: 480, + }); + } finally { + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + } +}); + +test.serial('updateSemanticMemoryLimit saves a clamped value', t => { + const preferencesPath = getTestPreferencesPath(); + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + + try { + updateSemanticMemoryLimit(500); + + const content = readFileSync(preferencesPath, 'utf-8'); + const parsed = JSON.parse(content) as UserPreferences; + + t.is(parsed.semanticMemoryLimit, MAX_MEMORY_LIMIT); + } finally { + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + } +}); + +test.serial('updateSemanticMemoryTokenBudget saves a clamped value', t => { + const preferencesPath = getTestPreferencesPath(); + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + + try { + updateSemanticMemoryTokenBudget(1); + + const content = readFileSync(preferencesPath, 'utf-8'); + const parsed = JSON.parse(content) as UserPreferences; + + t.is(parsed.semanticMemoryTokenBudget, MIN_TOKEN_BUDGET); + } finally { + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + } +}); + +test.serial('full workflow: update and retrieve project context preferences', t => { + const preferencesPath = getTestPreferencesPath(); + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + + try { + updateSemanticMemoryLimit(5); + updateSemanticMemoryTokenBudget(960); + + t.deepEqual(getProjectContextPreferences(), { + semanticMemoryEnabled: true, + memoryLimit: 5, + tokenBudget: 960, + }); + } finally { + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + } +}); diff --git a/source/config/preferences.ts b/source/config/preferences.ts index 6c134bc75..d7b944c21 100644 --- a/source/config/preferences.ts +++ b/source/config/preferences.ts @@ -1,6 +1,15 @@ import {readFileSync, writeFileSync} from 'fs'; import type {TitleShape} from '@/components/ui/styled-title'; import {getClosestConfigFile} from '@/config/index'; +import { + DEFAULT_MEMORY_LIMIT, + DEFAULT_TOKEN_BUDGET, + MAX_MEMORY_LIMIT, + MAX_TOKEN_BUDGET, + MIN_MEMORY_LIMIT, + MIN_TOKEN_BUDGET, + type ProjectContextOptions, +} from '@/memory/project-context'; import type {TuneConfig} from '@/types/config'; import type {UserPreferences} from '@/types/index'; import type {NanocoderShape, ThemePreset} from '@/types/ui'; @@ -242,6 +251,89 @@ export function updatePrivacyPreference(value: boolean): void { savePreferences(preferences); } +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.round(value))); +} + +/** + * Resolve the project-context knobs from an already-loaded preferences object. + * + * The single place the semantic-memory defaults live. Callers that inject + * `loadPreferences` (the plain shell) pass their own object in; everything else + * goes through {@link getProjectContextPreferences}. + */ +export function resolveProjectContextPreferences( + preferences: UserPreferences, +): Required< + Pick< + ProjectContextOptions, + 'semanticMemoryEnabled' | 'memoryLimit' | 'tokenBudget' + > +> { + return { + semanticMemoryEnabled: preferences.semanticMemoryEnabled ?? true, + memoryLimit: clamp( + preferences.semanticMemoryLimit ?? DEFAULT_MEMORY_LIMIT, + MIN_MEMORY_LIMIT, + MAX_MEMORY_LIMIT, + ), + tokenBudget: clamp( + preferences.semanticMemoryTokenBudget ?? DEFAULT_TOKEN_BUDGET, + MIN_TOKEN_BUDGET, + MAX_TOKEN_BUDGET, + ), + }; +} + +/** Project-context knobs for the current user. */ +export function getProjectContextPreferences(): ReturnType< + typeof resolveProjectContextPreferences +> { + return resolveProjectContextPreferences(loadPreferences()); +} + +/** + * Get the semantic memory preference from preferences + */ +export function getSemanticMemoryEnabled(): boolean { + return getProjectContextPreferences().semanticMemoryEnabled; +} + +/** + * Save the semantic memory preference + */ +export function updateSemanticMemoryEnabled(value: boolean): void { + const preferences = loadPreferences(); + preferences.semanticMemoryEnabled = value; + savePreferences(preferences); +} + +/** + * Save how many memories may be recalled into a single prompt. + */ +export function updateSemanticMemoryLimit(value: number): void { + const preferences = loadPreferences(); + preferences.semanticMemoryLimit = clamp( + value, + MIN_MEMORY_LIMIT, + MAX_MEMORY_LIMIT, + ); + savePreferences(preferences); +} + +/** + * Save the token budget project context may consume in the system prompt. + */ +export function updateSemanticMemoryTokenBudget(value: number): void { + const preferences = loadPreferences(); + preferences.semanticMemoryTokenBudget = clamp( + value, + MIN_TOKEN_BUDGET, + MAX_TOKEN_BUDGET, + ); + savePreferences(preferences); +} + /** * Get the alternate-screen (fullscreen) preference. Also settable via * --alt-screen/--no-alt-screen at launch; this is the persisted default. diff --git a/source/config/themes.spec.ts b/source/config/themes.spec.ts index aa678b26a..6b6d48ce4 100644 --- a/source/config/themes.spec.ts +++ b/source/config/themes.spec.ts @@ -1,5 +1,40 @@ +import {mkdirSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import test from 'ava'; -import {themes} from '@/config/themes'; +import chalk from 'chalk'; +import {DEFAULT_THEME, highlight} from 'cli-highlight'; +import {getSyntaxTheme, themes} from '@/config/themes'; +import {resetPreferencesCache, savePreferences} from '@/config/preferences'; + +// AVA runs each spec in its own non-TTY process, where chalk disables colour and +// every formatter becomes a no-op. Force truecolor so the escapes are assertable. +chalk.level = 3; + +// getSyntaxTheme reads the `syntaxTheme` preference, so point every test at a +// config directory of its own — a contributor who sets that preference must not +// change what this spec sees. +const configRoot = join(tmpdir(), `nanocoder-themes-spec-${process.pid}`); + +/** Point the config lookup at a directory holding exactly `preferences`. */ +function useConfigDir(name: string, preferences: Record): void { + const dir = join(configRoot, name); + mkdirSync(dir, {recursive: true}); + writeFileSync( + join(dir, 'nanocoder-preferences.json'), + JSON.stringify(preferences), + ); + process.env.NANOCODER_CONFIG_DIR = dir; +} + +test.before(() => { + useConfigDir('no-preference', {}); +}); + +test.after.always(() => { + rmSync(configRoot, {recursive: true, force: true}); + delete process.env.NANOCODER_CONFIG_DIR; +}); /** Relative luminance per WCAG 2.1. */ function luminance(hex: string): number { @@ -66,3 +101,167 @@ test('themeType matches whether base is actually light or dark', t => { ); } }); + +/** The opening escape chalk emits for a hex colour. */ +function ansiFor(hex: string): string { + return chalk.hex(hex)('x').split('x')[0] ?? ''; +} + +const snippet = `// greet +const greeting = 'hi'; +const answer = 42;`; + +// Every call site used to pass `theme: 'default'`, a string where cli-highlight +// expects a token -> formatter map, so the option was dropped and code always +// rendered in the library's palette. Any token left unmapped reintroduces that +// clash for the constructs it covers. +test('getSyntaxTheme maps every token cli-highlight styles by default', t => { + for (const [name, theme] of entries) { + const syntax = getSyntaxTheme(theme.colors); + const unmapped = Object.keys(DEFAULT_THEME).filter( + token => !(token in syntax), + ); + t.deepEqual(unmapped, [], `${name} leaves tokens on the library default`); + } +}); + +test('getSyntaxTheme colours tokens with the palette it was given', t => { + const colors = themes['tokyo-night'].colors; + const output = highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(colors), + }); + + t.true(output.includes(ansiFor(colors.primary)), 'keyword uses primary'); + t.true(output.includes(ansiFor(colors.success)), 'string uses success'); + t.true(output.includes(ansiFor(colors.warning)), 'number uses warning'); + t.true(output.includes(ansiFor(colors.secondary)), 'comment uses secondary'); + t.true(output.includes(ansiFor(colors.text)), 'unmatched code uses text'); +}); + +test('getSyntaxTheme renders the same code differently per theme', t => { + const rendered = new Set( + entries.map(([, theme]) => + highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(theme.colors), + }), + ), + ); + + // The snippet exercises exactly these five roles, so two themes may only + // share a rendering when they share all five. Collapsing further is what the + // ignored `theme: 'default'` option did — every theme rendered identically. + const palettes = new Set( + entries.map(([, theme]) => + [ + theme.colors.primary, + theme.colors.success, + theme.colors.warning, + theme.colors.secondary, + theme.colors.text, + ].join('/'), + ), + ); + + t.is(rendered.size, palettes.size); + t.true(palettes.size > 1); +}); + +test('getSyntaxTheme reuses the theme built for a palette', t => { + const colors = themes['gruvbox-dark'].colors; + t.is(getSyntaxTheme(colors), getSyntaxTheme(colors)); + t.not(getSyntaxTheme(colors), getSyntaxTheme(themes['one-light'].colors)); +}); + +// These run last: each repoints NANOCODER_CONFIG_DIR, which is what re-resolves +// the cached `syntaxTheme` lookup. +test('syntaxTheme gives code its own palette without moving the UI theme', t => { + useConfigDir('dracula-code', { + selectedTheme: 'tokyo-night', + syntaxTheme: 'dracula', + }); + + const ui = themes['tokyo-night'].colors; + const code = themes['dracula'].colors; + const output = highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(ui), + }); + + t.true(output.includes(ansiFor(code.primary)), 'keyword uses dracula primary'); + t.true(output.includes(ansiFor(code.warning)), 'number uses dracula warning'); + t.false( + output.includes(ansiFor(ui.primary)), + 'the UI theme must not colour code once syntaxTheme is set', + ); +}); + +test('an unknown syntaxTheme falls back to the UI palette', t => { + useConfigDir('misspelt', {syntaxTheme: 'draclua'}); + + const ui = themes['nord-frost'].colors; + const output = highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(ui), + }); + + t.true(output.includes(ansiFor(ui.primary))); +}); + +test('code follows the UI palette when syntaxTheme is unset', t => { + useConfigDir('ui-only', {selectedTheme: 'gruvbox-light'}); + + const ui = themes['gruvbox-light'].colors; + const output = highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(ui), + }); + + t.true(output.includes(ansiFor(ui.primary))); +}); + +// The cache used to key on NANOCODER_CONFIG_DIR alone, which never moves in a +// real session - so syntaxTheme was read once per process and a later write was +// ignored until restart. Keying on the preferences version as well fixes that, +// and this pins it without touching the env var at all. +test('a syntaxTheme written mid-session takes effect without a restart', t => { + useConfigDir('live-write', {selectedTheme: 'nord-frost'}); + + const ui = themes['nord-frost'].colors; + const before = highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(ui), + }); + t.true(before.includes(ansiFor(ui.primary)), 'starts on the UI palette'); + + // Same config dir, new preferences: only the version counter moves. + resetPreferencesCache(); + savePreferences({selectedTheme: 'nord-frost', syntaxTheme: 'dracula'}); + + const after = highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(ui), + }); + t.true( + after.includes(ansiFor(themes['dracula'].colors.primary)), + 'the write is picked up on the next highlight', + ); +}); + +// `themes` comes from JSON.parse, so it carries Object.prototype: a `preset in +// themes` check would accept these and resolve to a non-theme whose `.colors` is +// undefined, leaving the fallback to rescue it by accident. +for (const inherited of ['constructor', 'toString', 'valueOf', '__proto__']) { + test(`a syntaxTheme of '${inherited}' falls back to the UI palette`, t => { + useConfigDir(`inherited-${inherited}`, {syntaxTheme: inherited}); + + const ui = themes['one-light'].colors; + const output = highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(ui), + }); + + t.true(output.includes(ansiFor(ui.primary))); + }); +} diff --git a/source/config/themes.ts b/source/config/themes.ts index 9dce4c52f..10dbd81c1 100644 --- a/source/config/themes.ts +++ b/source/config/themes.ts @@ -1,7 +1,14 @@ import {readFileSync} from 'node:fs'; import {dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; +import chalk from 'chalk'; +import type {Theme as SyntaxTheme} from 'cli-highlight'; +import {getPreferencesVersion, loadPreferences} from '@/config/preferences'; +// The palette a syntax theme needs is exactly the subset the markdown parser +// already declares, so reuse it rather than declaring a second Pick. +import type {RenderPalette as SyntaxPalette} from '@/types/markdown-parser'; import type {Theme, ThemePreset} from '@/types/ui'; +import {getLogger} from '@/utils/logging'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -18,3 +25,131 @@ export function getThemeColors(themePreset: ThemePreset) { } export const defaultTheme: ThemePreset = 'tokyo-night'; + +// `syntaxTheme` lets code blocks keep a palette of their own while the rest of +// the UI follows `selectedTheme` — for a terminal already dressed in Dracula or +// Nord, say. The diff and file previews highlight line by line, so this cannot +// read preferences off disk each time. (`@/config/preferences` imports +// `@/config/index`, which imports this module; neither touches the other at +// module scope, so the cycle resolves. Keep it that way.) +// +// Keyed on the preferences version as well as the config dir: the version is a +// monotonic counter bumped on every write and free to read, so a `/settings` +// change lands on the next highlight instead of waiting for a restart. The dir +// is what moves under tests, which point NANOCODER_CONFIG_DIR at a fixture. +let overrideCache: { + dir?: string; + version: number; + palette: SyntaxPalette | null; +} | null = null; + +// Naming an unknown theme is silent otherwise: the render simply looks like it +// did before the preference was set, with nothing to say why. +// +// Structured logging rather than `logWarning`, on two counts. `@/utils/message- +// queue` reaches `@/components/message-box` -> `useTheme` -> back here, which is +// a cycle this module is deliberately kept out of; and getSyntaxTheme is called +// from render (the diff, write_file and file-explorer previews), where pushing +// onto the chat queue is a state update during another component's render. +let warnedSyntaxTheme: string | null = null; + +function resolveSyntaxPalette(colors: SyntaxPalette): SyntaxPalette { + const dir = process.env.NANOCODER_CONFIG_DIR; + const version = getPreferencesVersion(); + if ( + !overrideCache || + overrideCache.dir !== dir || + overrideCache.version !== version + ) { + const preset = loadPreferences().syntaxTheme; + // Own properties only: `themes` comes from JSON.parse, so it carries + // Object.prototype and a `syntaxTheme` of "constructor" or "toString" + // would otherwise pass an `in` check and resolve to a non-theme. + const known = Boolean(preset) && Object.hasOwn(themes, preset as string); + + if (preset && !known && warnedSyntaxTheme !== preset) { + warnedSyntaxTheme = preset; + getLogger().warn( + `Unknown syntaxTheme '${preset}', falling back to the selected theme`, + {syntaxTheme: preset, source: 'syntax-theme'}, + ); + } + + overrideCache = { + dir, + version, + // An unknown or misspelt name falls back to the UI theme rather than + // throwing the user into an unstyled render. + palette: known ? themes[preset as ThemePreset].colors : null, + }; + } + return overrideCache.palette ?? colors; +} + +// cli-highlight's `theme` option takes a map of token -> formatter function, so +// the string 'default' every call site used to pass was silently ignored and code +// always rendered in the library's own palette. Deriving the map from a theme's +// colours keeps syntax highlighting in step with whichever preset is in play. +const syntaxThemes = new WeakMap(); + +export function getSyntaxTheme(uiColors: SyntaxPalette): SyntaxTheme { + const colors = resolveSyntaxPalette(uiColors); + const cached = syntaxThemes.get(colors); + if (cached) return cached; + + const keyword = chalk.hex(colors.primary); + const accent = chalk.hex(colors.tool); + const quoted = chalk.hex(colors.success); + const numeric = chalk.hex(colors.warning); + const muted = chalk.hex(colors.secondary); + const detail = chalk.hex(colors.info); + const body = chalk.hex(colors.text); + + const theme: SyntaxTheme = { + keyword, + literal: keyword, + type: keyword, + tag: keyword, + 'meta-keyword': keyword, + 'template-tag': keyword, + built_in: accent, + 'builtin-name': accent, + class: accent, + function: accent, + title: accent, + name: accent, + section: accent, + 'selector-tag': accent, + string: quoted, + regexp: quoted, + symbol: quoted, + 'meta-string': quoted, + quote: quoted, + link: quoted, + addition: quoted, + number: numeric, + bullet: numeric, + comment: muted, + doctag: muted, + meta: muted, + attr: detail, + attribute: detail, + variable: detail, + 'template-variable': detail, + 'selector-attr': detail, + 'selector-class': detail, + 'selector-id': detail, + 'selector-pseudo': detail, + formula: detail, + deletion: chalk.hex(colors.error), + params: body, + subst: body, + code: body, + default: body, + emphasis: chalk.italic, + strong: chalk.bold, + }; + + syntaxThemes.set(colors, theme); + return theme; +} diff --git a/source/custom-tools/handler.spec.ts b/source/custom-tools/handler.spec.ts index 6994cf27d..729cf8604 100644 --- a/source/custom-tools/handler.spec.ts +++ b/source/custom-tools/handler.spec.ts @@ -1,8 +1,15 @@ -import {mkdirSync, rmSync, symlinkSync} from 'node:fs'; +import {chmodSync, mkdirSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join, resolve} from 'node:path'; import test, {type ExecutionContext} from 'ava'; -import {buildHandler, expandVars, mergeEnv, resolveCwd, runScript} from './handler'; +import { + buildHandler, + expandVars, + mergeEnv, + resolveCwd, + runScript, + shellArgs, +} from './handler'; import type {CustomToolMetadata} from '@/types/custom-tools'; console.log('\ncustom-tools/handler.spec.ts'); @@ -54,6 +61,35 @@ test('expandVars replaces $VAR and ${VAR}', t => { else process.env.NCT_FOO = prev; }); +test('shellArgs uses /d /s /c for cmd.exe and -c for posix shells', t => { + t.deepEqual(shellArgs('cmd.exe', 'echo hi'), ['/d', '/s', '/c', 'echo hi']); + t.deepEqual(shellArgs('cmd', 'echo hi'), ['/d', '/s', '/c', 'echo hi']); + t.deepEqual(shellArgs('C:\\Windows\\System32\\cmd.exe', 'echo hi'), [ + '/d', + '/s', + '/c', + 'echo hi', + ]); + t.deepEqual(shellArgs('/bin/sh', 'echo hi'), ['-c', 'echo hi']); + t.deepEqual(shellArgs('/bin/bash', 'echo hi'), ['-c', 'echo hi']); +}); + +// Prove runScript forwards shellArgs, not a hardcoded -c. A POSIX script +// named cmd.exe is enough: isWindowsCmd keys off the basename. +const spawnArgTest = process.platform === 'win32' ? test.skip : test; +spawnArgTest('runScript passes shellArgs argv into spawn', async t => { + const bin = join(testDir, 'cmd.exe'); + writeFileSync(bin, '#!/bin/sh\nprintf "%s\\n" "$@"\n'); + chmodSync(bin, 0o755); + const result = await runScript('echo hi', { + cwd: testDir, + env: process.env, + shell: bin, + timeoutMs: 5_000, + }); + t.is(result, 'EXIT_CODE: 0\n/d\n/s\n/c\necho hi'); +}); + test('mergeEnv overlays configured vars onto process.env', t => { const env = mergeEnv({CUSTOM_VAR: 'value'}); t.is(env.CUSTOM_VAR, 'value'); diff --git a/source/custom-tools/handler.ts b/source/custom-tools/handler.ts index 5bdcdd2ba..6cc9292a3 100644 --- a/source/custom-tools/handler.ts +++ b/source/custom-tools/handler.ts @@ -54,7 +54,7 @@ export function runScript( options: RunOptions, ): Promise { return new Promise((resolvePromise, rejectPromise) => { - const child = spawn(options.shell, ['-c', script], { + const child = spawn(options.shell, shellArgs(options.shell, script), { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], @@ -190,6 +190,16 @@ export function expandVars(value: string): string { }); } +/** cmd.exe: /d (skip AutoRun), /s (deterministic quotes), /c. POSIX: -c. */ +export function shellArgs(shell: string, script: string): string[] { + return isWindowsCmd(shell) ? ['/d', '/s', '/c', script] : ['-c', script]; +} + +function isWindowsCmd(shell: string): boolean { + const name = shell.replaceAll('\\', '/').split('/').pop() ?? ''; + return /^cmd(\.exe)?$/i.test(name); +} + function pickShell(configured: string | undefined): string { if (configured === 'bash') return '/bin/bash'; if (configured === 'sh') return '/bin/sh'; diff --git a/source/custom-tools/template.ts b/source/custom-tools/template.ts index f73c063aa..977b87c72 100644 --- a/source/custom-tools/template.ts +++ b/source/custom-tools/template.ts @@ -14,8 +14,9 @@ * in single quotes and escapes embedded single quotes. Arrays are joined into * a single space-separated string with each element individually quoted. * - * Substitution happens *before* the body is handed to the shell, so the - * shell sees a complete, safe command line. + * Substitution happens *before* the body is handed to the shell. Under + * bash/sh that yields a POSIX-quoted command line. Under cmd.exe the + * same quotes are not quoting, so this is not an injection barrier. */ import {expandSections} from '@/utils/template-sections'; diff --git a/source/hooks/chat-handler/types.ts b/source/hooks/chat-handler/types.ts index 05c84bf7f..4143d5df4 100644 --- a/source/hooks/chat-handler/types.ts +++ b/source/hooks/chat-handler/types.ts @@ -1,5 +1,9 @@ import type React from 'react'; import type {CustomCommandLoader} from '@/custom-commands/loader'; +import type { + MemoryFinder, + ProjectContextOptions, +} from '@/memory/project-context'; import type {Task} from '@/tools/tasks/types'; import type {ToolManager} from '@/tools/tool-manager'; import type {TuneConfig} from '@/types/config'; @@ -55,6 +59,8 @@ export interface UseChatHandlerProps { subagentsReady?: boolean; privacySessionMapRef?: React.MutableRefObject>; privacyEnabled?: boolean; + memoryFinder?: MemoryFinder; + projectContextOptions?: ProjectContextOptions; /** Ensure tool calls in this turn share the persisted conversation ID. */ ensureCurrentSessionId?: () => string; } diff --git a/source/hooks/chat-handler/useChatHandler.spec.tsx b/source/hooks/chat-handler/useChatHandler.spec.tsx index b14e03269..c20a74e3d 100644 --- a/source/hooks/chat-handler/useChatHandler.spec.tsx +++ b/source/hooks/chat-handler/useChatHandler.spec.tsx @@ -771,3 +771,134 @@ test.serial( } }, ); + +test('useChatHandler - injects project context from memory finder', async t => { + let hookResult: ChatHandlerReturn | null = null; + let sentMessages: Message[] = []; + const queuedComponents: React.ReactNode[] = []; + const client: LLMClient = { + ...createMockClient(), + chat: async (messages, _tools, callbacks) => { + sentMessages = messages; + callbacks.onFinish?.(); + return { + choices: [ + { + message: { + role: 'assistant', + content: 'ok', + }, + }, + ], + }; + }, + }; + + const props = createMockProps({ + client, + toolManager: createMockToolManager(), + addToChatQueue: component => { + queuedComponents.push(component); + }, + memoryFinder: { + findRelevantMemories: async (query, limit) => { + t.is(query, 'refactor auth'); + t.is(limit, 8); + return [ + { + id: 'memory-1', + content: 'Auth uses Clerk and avoids middleware.', + category: 'architecture', + timestamp: '2026-07-17T00:00:00.000Z', + }, + ]; + }, + }, + }); + + const rendered = render( + { + hookResult = result; + }} + />, + ); + + await waitForCondition(() => hookResult !== null); + await hookResult!.handleChatMessage('refactor auth'); + + t.true(sentMessages[0].content.includes('## Project Context')); + t.true( + sentMessages[0].content.includes( + '- Auth uses Clerk and avoids middleware.', + ), + ); + t.true( + queuedComponents.some( + component => + React.isValidElement(component) && + component.props.message === 'Recalling 1 project memory...', + ), + ); + rendered.unmount(); +}); + +test('useChatHandler - does not accumulate project context across turns', async t => { + let hookResult: ChatHandlerReturn | null = null; + const sentSystemPrompts: string[] = []; + const client: LLMClient = { + ...createMockClient(), + chat: async (messages, _tools, callbacks) => { + sentSystemPrompts.push(String(messages[0]?.content ?? '')); + callbacks.onFinish?.(); + return { + choices: [ + { + message: { + role: 'assistant', + content: 'ok', + }, + }, + ], + }; + }, + }; + + const props = createMockProps({ + client, + toolManager: createMockToolManager(), + memoryFinder: { + findRelevantMemories: async query => { + if (query === 'refactor auth') { + return [ + { + id: 'memory-1', + content: 'Auth uses Clerk and avoids middleware.', + category: 'architecture', + timestamp: '2026-07-17T00:00:00.000Z', + }, + ]; + } + return []; + }, + }, + }); + + const rendered = render( + { + hookResult = result; + }} + />, + ); + + await waitForCondition(() => hookResult !== null); + await hookResult!.handleChatMessage('refactor auth'); + await hookResult!.handleChatMessage('unrelated question about docs'); + + t.true(sentSystemPrompts[0]?.includes('## Project Context')); + t.false(sentSystemPrompts[1]?.includes('## Project Context')); + rendered.unmount(); +}); diff --git a/source/hooks/chat-handler/useChatHandler.tsx b/source/hooks/chat-handler/useChatHandler.tsx index 1a9010a26..2a295d45a 100644 --- a/source/hooks/chat-handler/useChatHandler.tsx +++ b/source/hooks/chat-handler/useChatHandler.tsx @@ -6,9 +6,12 @@ import {getAppConfig} from '@/config/index'; import { getPreferencesVersion, getProfessionalTone, + getProjectContextPreferences, subscribeToPreferences, } from '@/config/preferences'; import {CommandIntegration} from '@/custom-commands/command-integration'; +import {appendRelevantProjectContextWithCount} from '@/memory/project-context'; +import {SemanticMemoryManager} from '@/memory/semantic-memory-manager'; import {processToolUse} from '@/message-handler'; import {generateKey} from '@/session/key-generator'; import {getTuneToolMode} from '@/types/config'; @@ -97,11 +100,18 @@ export function useChatHandler({ subagentsReady, privacySessionMapRef, privacyEnabled, + memoryFinder, + projectContextOptions, ensureCurrentSessionId, }: UseChatHandlerProps): ChatHandlerReturn { // Conversation state manager for enhanced context const conversationStateManager = React.useRef(new ConversationStateManager()); + const projectMemoryFinder = React.useMemo( + () => memoryFinder ?? new SemanticMemoryManager(), + [memoryFinder], + ); + // Resolve the active fallback format when native tools are disabled. When // native is on, this value is unused. The tune override takes priority over // provider-level disables so users can pick the JSON path explicitly even @@ -391,6 +401,25 @@ export function useChatHandler({ ); } + const projectContext = await appendRelevantProjectContextWithCount( + systemPrompt, + message, + projectMemoryFinder, + // Preferences supply the defaults; an explicit prop still wins so + // callers (and tests) can override per session. + {...getProjectContextPreferences(), ...projectContextOptions}, + ); + systemPrompt = projectContext.systemPrompt; + setLastBuiltPrompt(systemPrompt); + if (projectContext.memoryCount > 0) { + addToChatQueue( + infoMsg( + `Recalling ${projectContext.memoryCount} project memor${projectContext.memoryCount === 1 ? 'y' : 'ies'}...`, + 'memory-recall', + ), + ); + } + // Create stream request const systemMessage: Message = { role: 'system', diff --git a/source/hooks/useAppHandlers.spec.tsx b/source/hooks/useAppHandlers.spec.tsx index a73a59891..09ee334c2 100644 --- a/source/hooks/useAppHandlers.spec.tsx +++ b/source/hooks/useAppHandlers.spec.tsx @@ -190,6 +190,14 @@ test('returns the expected handler surface', t => { t.is(typeof handlers.handleMessageSubmit, 'function'); }); +test('signals slash-command completion so queued work can resume', async t => { + const {handlers, spies} = setup(); + + await handlers.handleMessageSubmit('/compact'); + + t.deepEqual(spies.setIsConversationComplete.calls, [[false], [true]]); +}); + test('handleCancel without an abort controller is a no-op', t => { const { handlers, spies } = setup({ abortController: null }); diff --git a/source/hooks/useAppHandlers.tsx b/source/hooks/useAppHandlers.tsx index eef7c9a46..60ba1b487 100644 --- a/source/hooks/useAppHandlers.tsx +++ b/source/hooks/useAppHandlers.tsx @@ -710,6 +710,7 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { developmentMode: props.developmentMode, lastApiUsage: props.lastApiUsage, apiCallHistory: props.apiCallHistory, + sessionId: props.ensureCurrentSessionId(), }, displayValue, images, @@ -742,6 +743,7 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { props.developmentMode, props.lastApiUsage, props.apiCallHistory, + props.ensureCurrentSessionId, clearMessages, enterCheckpointLoadMode, handleShowStatus, diff --git a/source/hooks/useSessionAutosave.spec.ts b/source/hooks/useSessionAutosave.spec.ts index 7b882ae90..4b2913e6d 100644 --- a/source/hooks/useSessionAutosave.spec.ts +++ b/source/hooks/useSessionAutosave.spec.ts @@ -394,3 +394,116 @@ test.serial( ); }, ); + +// --------------------------------------------------------------------------- +// Issue #932 — Auto-save indicator & unblocked flush +// --------------------------------------------------------------------------- + +test.serial( + 'Issue 932: indicator hide timer does not block save chain or flush resolution', + async t => { + let isSaving = false; + let hideTimer: NodeJS.Timeout | null = null; + const minDuration = 500; + + const runSave = async () => { + let startTime: number | null = null; + try { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + startTime = Date.now(); + isSaving = true; + + // Fast mock disk write (~5ms) + await new Promise(r => setTimeout(r, 5)); + } finally { + if (startTime !== null) { + const elapsed = Date.now() - startTime; + const remaining = Math.max(0, minDuration - elapsed); + hideTimer = setTimeout(() => { + isSaving = false; + hideTimer = null; + }, remaining); + } + } + }; + + const flushStart = Date.now(); + await runSave(); + const flushElapsed = Date.now() - flushStart; + + // flush/save resolution must finish immediately on I/O completion (< 100ms), + // NOT blocked by the 500ms UI indicator timer + t.true( + flushElapsed < 100, + `flush() took ${flushElapsed}ms; must not wait for the 500ms UI timer`, + ); + t.true(isSaving, 'isSaving must be true immediately after save finishes'); + + // Wait 250ms: isSaving must still be true (within the 500ms floor) + await new Promise(r => setTimeout(r, 250)); + t.true(isSaving, 'isSaving must stay true at 250ms (within 500ms floor)'); + + // Wait another 300ms (total > 550ms): isSaving must transition to false + await new Promise(r => setTimeout(r, 300)); + t.false( + isSaving, + 'isSaving must transition to false after 500ms minimum display floor', + ); + t.is(hideTimer, null); + }, +); + +test.serial( + 'Issue 932: subsequent save cancels earlier pending hide timer', + async t => { + let isSaving = false; + let hideTimer: NodeJS.Timeout | null = null; + let timerClearCount = 0; + const minDuration = 500; + + const runSave = async () => { + let startTime: number | null = null; + try { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + timerClearCount++; + } + startTime = Date.now(); + isSaving = true; + + await new Promise(r => setTimeout(r, 5)); + } finally { + if (startTime !== null) { + const elapsed = Date.now() - startTime; + const remaining = Math.max(0, minDuration - elapsed); + hideTimer = setTimeout(() => { + isSaving = false; + hideTimer = null; + }, remaining); + } + } + }; + + // First save schedules a hide timer for 500ms + await runSave(); + t.true(isSaving); + t.truthy(hideTimer); + + // Second save starts 100ms later (while hide timer is still pending) + await new Promise(r => setTimeout(r, 100)); + await runSave(); + + t.is(timerClearCount, 1, 'Previous hide timer must be cancelled'); + t.true(isSaving); + + // Clean up + if (hideTimer) { + clearTimeout(hideTimer); + } + }, +); + diff --git a/source/hooks/useSessionAutosave.ts b/source/hooks/useSessionAutosave.ts index d6876a558..443775ba3 100644 --- a/source/hooks/useSessionAutosave.ts +++ b/source/hooks/useSessionAutosave.ts @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import {isApprovedPlanMessage} from '@/artifacts/approved-plan'; import {isInternalWalkthroughMessage} from '@/artifacts/walkthrough-lifecycle'; import {getAppConfig} from '@/config/index'; @@ -74,8 +74,10 @@ export function useSessionAutosave({ currentSessionId, setCurrentSessionId, }: UseSessionAutosaveProps) { + const [isSaving, setIsSaving] = useState(false); const initPromiseRef = useRef | null>(null); const timeoutRef = useRef(null); + const hideTimerRef = useRef(null); const lastSaveRef = useRef(0); // Serialises saves: each new save is chained onto the tail of this promise. @@ -146,6 +148,9 @@ export function useSessionAutosave({ if (timeoutRef.current) { clearTimeout(timeoutRef.current); } + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + } }; }, []); @@ -156,6 +161,7 @@ export function useSessionAutosave({ capturedProvider: string, capturedModel: string, ) => { + let startTime: number | null = null; try { // Wait for initialization to complete before saving const initialized = await initPromiseRef.current; @@ -170,6 +176,15 @@ export function useSessionAutosave({ ); if (persistedMessages.length === 0) return; + // Cancel any pending delayed-hide from an earlier save before showing + // the indicator for this save. + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + startTime = Date.now(); + setIsSaving(true); + // Read the live session ID AFTER the await above. Any prior save // in this chain has already called setCurrentSessionId (and updated // currentSessionIdRef.current) by this point, so we correctly take @@ -238,6 +253,16 @@ export function useSessionAutosave({ lastSaveRef.current = Date.now(); } catch (error) { console.warn('Failed to auto-save session:', error); + } finally { + if (startTime !== null) { + const elapsed = Date.now() - startTime; + const minDuration = 500; + const remaining = Math.max(0, minDuration - elapsed); + hideTimerRef.current = setTimeout(() => { + setIsSaving(false); + hideTimerRef.current = null; + }, remaining); + } } }, [setCurrentSessionId], @@ -318,4 +343,6 @@ export function useSessionAutosave({ }); return () => manager.unregister(SHUTDOWN_HANDLER_NAME); }, [flush]); + + return {isSaving}; } diff --git a/source/init/init-args.spec.ts b/source/init/init-args.spec.ts new file mode 100644 index 000000000..2a519517f --- /dev/null +++ b/source/init/init-args.spec.ts @@ -0,0 +1,32 @@ +import test from 'ava'; +import {InitArgumentError, parseInitArguments} from '@/init/init-args'; + +test('parseInitArguments parses --preset for the init command', t => { + t.deepEqual(parseInitArguments(['--preset', 'react']), { + forceRegenerate: false, + lean: false, + preset: 'react', + }); +}); + +test('parseInitArguments parses fused --preset syntax and existing flags', t => { + t.deepEqual(parseInitArguments(['--force', '--lean', '--preset=nextjs']), { + forceRegenerate: true, + lean: true, + preset: 'nextjs', + }); +}); + +test('parseInitArguments preserves init behavior without --preset', t => { + t.deepEqual(parseInitArguments([]), { + forceRegenerate: false, + lean: false, + preset: undefined, + }); +}); + +test('parseInitArguments rejects --preset without a value', t => { + const error = t.throws(() => parseInitArguments(['--preset'])); + t.true(error instanceof InitArgumentError); + t.regex(error.message, /Supported presets: react, nextjs, rust/); +}); diff --git a/source/init/init-args.ts b/source/init/init-args.ts new file mode 100644 index 000000000..cb873a692 --- /dev/null +++ b/source/init/init-args.ts @@ -0,0 +1,48 @@ +import {supportedPresetNames} from '@/init/preset-registry'; + +export interface ParsedInitArguments { + forceRegenerate: boolean; + lean: boolean; + preset?: string; +} + +export class InitArgumentError extends Error { + constructor(message: string) { + super(message); + this.name = 'InitArgumentError'; + } +} + +export function parseInitArguments( + args: readonly string[], +): ParsedInitArguments { + let preset: string | undefined; + + for (let index = 0; index < args.length; index++) { + const argument = args[index]; + if (argument === '--preset') { + const value = args[index + 1]; + if (!value || value.startsWith('-')) { + throw new InitArgumentError( + `Missing value for --preset. Supported presets: ${supportedPresetNames.join(', ')}.`, + ); + } + preset = value; + index++; + } else if (argument.startsWith('--preset=')) { + const value = argument.slice('--preset='.length); + if (!value) { + throw new InitArgumentError( + `Missing value for --preset. Supported presets: ${supportedPresetNames.join(', ')}.`, + ); + } + preset = value; + } + } + + return { + forceRegenerate: args.includes('--force') || args.includes('-f'), + lean: args.includes('--lean'), + preset, + }; +} diff --git a/source/init/initializer.spec.ts b/source/init/initializer.spec.ts new file mode 100644 index 000000000..1ee739c4f --- /dev/null +++ b/source/init/initializer.spec.ts @@ -0,0 +1,237 @@ +import test from 'ava'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {parseCommandFile} from '@/custom-commands/parser'; +import { + initializeProject, + ProjectAlreadyInitializedError, +} from '@/init/initializer'; +import {UnknownPresetError} from '@/init/preset-registry'; + +function createTestProject(): string { + return mkdtempSync(join(tmpdir(), 'nanocoder-preset-')); +} + +function removeTestProject(projectPath: string): void { + rmSync(projectPath, {recursive: true, force: true}); +} + +const presetExpectations = { + react: { + projectType: 'React Web Application', + ignorePattern: '*.tsbuildinfo', + commandText: 'React project quality checks', + }, + nextjs: { + projectType: 'Next.js Web Application', + ignorePattern: 'next-env.d.ts', + commandText: 'Next.js project quality checks', + }, + rust: { + projectType: 'Rust Application', + ignorePattern: '*.profraw', + commandText: 'Rust project quality checks', + }, +} as const; + +for (const [preset, expectation] of Object.entries(presetExpectations)) { + test.serial(`initializeProject generates the ${preset} preset`, t => { + const projectPath = createTestProject(); + try { + const result = initializeProject({projectPath, preset}); + const agents = readFileSync(join(projectPath, 'AGENTS.md'), 'utf-8'); + const ignore = readFileSync( + join(projectPath, '.nanocoderignore'), + 'utf-8', + ); + const command = readFileSync( + join(projectPath, '.nanocoder', 'commands', 'check.md'), + 'utf-8', + ); + const parsedCommand = parseCommandFile( + join(projectPath, '.nanocoder', 'commands', 'check.md'), + ); + + t.is(result.preset, preset); + t.true(agents.includes(`**Project Type:** ${expectation.projectType}`)); + t.true(ignore.includes(expectation.ignorePattern)); + t.true(command.includes(expectation.commandText)); + t.true(command.includes('description:')); + t.truthy(parsedCommand.metadata.description); + t.true(parsedCommand.content.length > 0); + t.deepEqual(result.preserved, []); + } finally { + removeTestProject(projectPath); + } + }); +} + +test.serial('initializeProject keeps existing behavior without a preset', t => { + const projectPath = createTestProject(); + try { + const result = initializeProject({projectPath}); + + t.true(existsSync(join(projectPath, 'AGENTS.md'))); + t.true(existsSync(join(projectPath, '.nanocoder'))); + t.false(existsSync(join(projectPath, '.nanocoderignore'))); + t.false( + existsSync(join(projectPath, '.nanocoder', 'commands', 'check.md')), + ); + t.is(result.preset, undefined); + t.deepEqual(result.created, ['AGENTS.md', '.nanocoder/']); + } finally { + removeTestProject(projectPath); + } +}); + +test.serial('initializeProject rejects an invalid preset before writing files', t => { + const projectPath = createTestProject(); + try { + const error = t.throws(() => + initializeProject({projectPath, preset: 'unknown'}), + ); + t.true(error instanceof UnknownPresetError); + t.regex(error.message, /Supported presets: react, nextjs, rust/); + t.false(existsSync(join(projectPath, 'AGENTS.md'))); + t.false(existsSync(join(projectPath, '.nanocoder'))); + } finally { + removeTestProject(projectPath); + } +}); + +test.serial( + 'initializeProject only includes React preset commands backed by package scripts', + t => { + const projectPath = createTestProject(); + try { + writeFileSync( + join(projectPath, 'package.json'), + JSON.stringify({ + dependencies: {react: '^19.0.0'}, + scripts: { + build: 'vite build', + test: 'vitest run', + }, + }), + ); + + const result = initializeProject({projectPath, preset: 'react'}); + const agents = readFileSync(join(projectPath, 'AGENTS.md'), 'utf-8'); + + t.deepEqual(result.analysis.buildCommands, { + Build: 'npm run build', + Test: 'npm run test', + }); + t.true(agents.includes('npm run build')); + t.true(agents.includes('npm run test')); + t.false(agents.includes('npm run dev')); + t.false(agents.includes('npm run lint')); + } finally { + removeTestProject(projectPath); + } + }, +); + +test.serial( + 'initializeProject includes React preset commands whose package scripts exist', + t => { + const projectPath = createTestProject(); + try { + writeFileSync( + join(projectPath, 'package.json'), + JSON.stringify({ + dependencies: {react: '^19.0.0'}, + scripts: { + dev: 'vite', + build: 'vite build', + test: 'vitest run', + lint: 'eslint .', + }, + }), + ); + + const result = initializeProject({projectPath, preset: 'React'}); + const agents = readFileSync(join(projectPath, 'AGENTS.md'), 'utf-8'); + + t.deepEqual(result.analysis.buildCommands, { + Development: 'npm run dev', + Build: 'npm run build', + Test: 'npm run test', + Lint: 'npm run lint', + }); + t.is(result.preset, 'react'); + for (const command of Object.values(result.analysis.buildCommands)) { + t.true(agents.includes(command)); + } + } finally { + removeTestProject(projectPath); + } + }, +); + +test.serial('initializeProject preserves existing preset files with --force', t => { + const projectPath = createTestProject(); + const existingAgents = '# Existing instructions'; + const existingIgnore = 'keep-this-ignore\n'; + const existingCommand = 'keep this command\n'; + try { + mkdirSync(join(projectPath, '.nanocoder', 'commands'), {recursive: true}); + writeFileSync(join(projectPath, 'AGENTS.md'), existingAgents); + writeFileSync(join(projectPath, '.nanocoderignore'), existingIgnore); + writeFileSync( + join(projectPath, '.nanocoder', 'commands', 'check.md'), + existingCommand, + ); + + const result = initializeProject({ + projectPath, + preset: 'react', + forceRegenerate: true, + }); + + t.not(readFileSync(join(projectPath, 'AGENTS.md'), 'utf-8'), existingAgents); + t.is( + readFileSync(join(projectPath, '.nanocoderignore'), 'utf-8'), + existingIgnore, + ); + t.is( + readFileSync( + join(projectPath, '.nanocoder', 'commands', 'check.md'), + 'utf-8', + ), + existingCommand, + ); + t.deepEqual(result.preserved, [ + '.nanocoderignore', + '.nanocoder/commands/check.md', + ]); + t.true(result.created.includes('AGENTS.md (regenerated)')); + } finally { + removeTestProject(projectPath); + } +}); + +test.serial('initializeProject refuses an initialized project without --force', t => { + const projectPath = createTestProject(); + try { + mkdirSync(join(projectPath, '.nanocoder')); + writeFileSync(join(projectPath, 'AGENTS.md'), '# Existing instructions'); + + const error = t.throws(() => initializeProject({projectPath})); + t.true(error instanceof ProjectAlreadyInitializedError); + t.is( + readFileSync(join(projectPath, 'AGENTS.md'), 'utf-8'), + '# Existing instructions', + ); + } finally { + removeTestProject(projectPath); + } +}); diff --git a/source/init/initializer.ts b/source/init/initializer.ts new file mode 100644 index 000000000..ae857894b --- /dev/null +++ b/source/init/initializer.ts @@ -0,0 +1,108 @@ +import {existsSync, mkdirSync, writeFileSync} from 'node:fs'; +import {dirname, isAbsolute, join, relative, resolve} from 'node:path'; +import {AgentsTemplateGenerator} from '@/init/agents-template-generator'; +import {ExistingRulesExtractor} from '@/init/existing-rules-extractor'; +import {applyPresetToAnalysis, resolvePreset} from '@/init/preset-registry'; +import {type ProjectAnalysis, ProjectAnalyzer} from '@/init/project-analyzer'; + +export interface InitializeProjectOptions { + projectPath: string; + forceRegenerate?: boolean; + lean?: boolean; + preset?: string; +} + +export interface InitializeProjectResult { + created: string[]; + preserved: string[]; + analysis: ProjectAnalysis; + preset?: string; +} + +export class ProjectAlreadyInitializedError extends Error { + constructor() { + super( + 'Project already initialized. Found AGENTS.md and .nanocoder/ directory.', + ); + this.name = 'ProjectAlreadyInitializedError'; + } +} + +function resolvePresetPath(projectPath: string, relativePath: string): string { + const projectRoot = resolve(projectPath); + const destination = resolve(projectRoot, relativePath); + const relativeDestination = relative(projectRoot, destination); + if (relativeDestination.startsWith('..') || isAbsolute(relativeDestination)) { + throw new Error(`Invalid preset file path: ${relativePath}`); + } + return destination; +} + +export function initializeProject( + options: InitializeProjectOptions, +): InitializeProjectResult { + const { + projectPath, + forceRegenerate = false, + lean = false, + preset: presetName, + } = options; + const preset = presetName ? resolvePreset(presetName) : undefined; + const created: string[] = []; + const preserved: string[] = []; + const agentsPath = join(projectPath, 'AGENTS.md'); + const nanocoderDir = join(projectPath, '.nanocoder'); + const hasAgents = existsSync(agentsPath); + const hasNanocoder = existsSync(nanocoderDir); + + if (hasAgents && hasNanocoder && !forceRegenerate) { + throw new ProjectAlreadyInitializedError(); + } + + const detectedAnalysis = new ProjectAnalyzer(projectPath).analyze(); + const analysis = preset + ? applyPresetToAnalysis(detectedAnalysis, preset) + : detectedAnalysis; + const existingRules = new ExistingRulesExtractor( + projectPath, + forceRegenerate, + lean ? ['CLAUDE.md'] : [], + ).extractExistingRules(); + + if (!hasAgents || forceRegenerate) { + const agentsContent = AgentsTemplateGenerator.generateAgentsMd( + analysis, + existingRules, + ); + writeFileSync(agentsPath, agentsContent); + created.push(hasAgents ? 'AGENTS.md (regenerated)' : 'AGENTS.md'); + + if (existingRules.length > 0) { + const sourceFiles = existingRules.map(rule => rule.source).join(', '); + created.push(`↳ Merged content from: ${sourceFiles}`); + } + } + + if (!hasNanocoder) { + mkdirSync(nanocoderDir, {recursive: true}); + created.push('.nanocoder/'); + } + + for (const file of preset?.files ?? []) { + const destination = resolvePresetPath(projectPath, file.path); + if (existsSync(destination)) { + preserved.push(file.path); + continue; + } + mkdirSync(dirname(destination), {recursive: true}); + writeFileSync(destination, file.content); + created.push(file.path); + } + + return { + created, + preserved, + analysis, + preset: preset?.name, + }; +} diff --git a/source/init/preset-registry.spec.ts b/source/init/preset-registry.spec.ts new file mode 100644 index 000000000..c20faed7e --- /dev/null +++ b/source/init/preset-registry.spec.ts @@ -0,0 +1,100 @@ +import test from 'ava'; +import { + applyPresetToAnalysis, + resolvePreset, + supportedPresetNames, + UnknownPresetError, +} from '@/init/preset-registry'; +import type {ProjectAnalysis} from '@/init/project-analyzer'; + +test('preset registry exposes the supported preset names', t => { + t.deepEqual(supportedPresetNames, ['react', 'nextjs', 'rust']); +}); + +for (const name of supportedPresetNames) { + test(`preset registry resolves ${name}`, t => { + const preset = resolvePreset(name); + t.is(preset.name, name); + t.true(preset.files.some(file => file.path === '.nanocoderignore')); + t.true( + preset.files.some(file => file.path === '.nanocoder/commands/check.md'), + ); + }); +} + +test('preset registry reports unknown presets and all supported names', t => { + const error = t.throws(() => resolvePreset('vue')); + t.true(error instanceof UnknownPresetError); + t.is( + error.message, + 'Unknown preset "vue". Supported presets: react, nextjs, rust.', + ); +}); + +for (const inheritedName of ['constructor', 'toString', '__proto__']) { + test(`preset registry rejects inherited property ${inheritedName}`, t => { + const error = t.throws(() => resolvePreset(inheritedName)); + t.true(error instanceof UnknownPresetError); + t.is( + error.message, + `Unknown preset "${inheritedName}". Supported presets: react, nextjs, rust.`, + ); + }); +} + +test('preset registry normalizes case and whitespace', t => { + t.is(resolvePreset(' React ').name, 'react'); +}); + +test('preset registry preserves the user-provided value in errors', t => { + const error = t.throws(() => resolvePreset(' Vue ')); + t.true(error instanceof UnknownPresetError); + t.is( + error.message, + 'Unknown preset " Vue ". Supported presets: react, nextjs, rust.', + ); +}); + +test('preset commands require package scripts and detected commands win', t => { + const analysis: ProjectAnalysis = { + projectPath: '/project', + projectName: 'example', + languages: {primary: null, secondary: [], all: []}, + dependencies: { + frameworks: [], + buildTools: [], + testingFrameworks: [], + buildInfo: {scripts: {build: 'custom-build'}}, + }, + projectType: 'Unknown', + keyFiles: {config: [], documentation: [], build: [], test: []}, + structure: { + totalFiles: 0, + scannedFiles: 0, + directories: [], + importantDirectories: [], + }, + buildCommands: {Build: 'pnpm run custom-build'}, + }; + + const result = applyPresetToAnalysis(analysis, resolvePreset('react')); + t.deepEqual(result.buildCommands, {Build: 'pnpm run custom-build'}); +}); + +test('Rust preset keeps Cargo.lock available as project context', t => { + const ignoreFile = resolvePreset('rust').files.find( + file => file.path === '.nanocoderignore', + ); + t.truthy(ignoreFile); + t.false(ignoreFile?.content.includes('Cargo.lock')); +}); + +for (const presetName of supportedPresetNames) { + test(`${presetName} preset check command declares no aliases`, t => { + const checkFile = resolvePreset(presetName).files.find( + file => file.path === '.nanocoder/commands/check.md', + ); + t.truthy(checkFile); + t.false(checkFile?.content.includes('aliases:')); + }); +} diff --git a/source/init/preset-registry.ts b/source/init/preset-registry.ts new file mode 100644 index 000000000..822cbc795 --- /dev/null +++ b/source/init/preset-registry.ts @@ -0,0 +1,80 @@ +import type {PresetDefinition, PresetName} from '@/init/presets'; +import type {ProjectAnalysis} from '@/init/project-analyzer'; +import {nextjsPreset} from '@/init/templates/preset-nextjs'; +import {reactPreset} from '@/init/templates/preset-react'; +import {rustPreset} from '@/init/templates/preset-rust'; + +const presets: Record = { + react: reactPreset, + nextjs: nextjsPreset, + rust: rustPreset, +}; + +export const supportedPresetNames = Object.freeze( + Object.keys(presets) as PresetName[], +); + +export class UnknownPresetError extends Error { + constructor(name: string) { + super( + `Unknown preset "${name}". Supported presets: ${supportedPresetNames.join(', ')}.`, + ); + this.name = 'UnknownPresetError'; + } +} + +export function resolvePreset(name: string): PresetDefinition { + const normalizedName = name.trim().toLowerCase(); + if (!Object.hasOwn(presets, normalizedName)) { + throw new UnknownPresetError(name); + } + return presets[normalizedName as PresetName]; +} + +export function applyPresetToAnalysis( + analysis: ProjectAnalysis, + preset: PresetDefinition, +): ProjectAnalysis { + const existingFrameworkNames = new Set( + analysis.dependencies.frameworks.map(framework => framework.name), + ); + const presetFrameworks = preset.frameworks.filter( + framework => !existingFrameworkNames.has(framework.name), + ); + + const primary = analysis.languages.primary ?? { + name: preset.primaryLanguage, + extensions: [], + percentage: 100, + files: [], + }; + const detectedPackageScripts = analysis.dependencies.buildInfo.scripts ?? {}; + const presetBuildCommands = Object.fromEntries( + Object.entries(preset.buildCommands).filter(([action]) => { + const packageScript = preset.packageScripts?.[action]; + return ( + packageScript === undefined || + Object.hasOwn(detectedPackageScripts, packageScript) + ); + }), + ); + + return { + ...analysis, + projectType: preset.projectType, + languages: { + ...analysis.languages, + primary, + all: + analysis.languages.all.length > 0 ? analysis.languages.all : [primary], + }, + dependencies: { + ...analysis.dependencies, + frameworks: [...presetFrameworks, ...analysis.dependencies.frameworks], + }, + buildCommands: { + ...presetBuildCommands, + ...analysis.buildCommands, + }, + }; +} diff --git a/source/init/presets.ts b/source/init/presets.ts new file mode 100644 index 000000000..ade638ce1 --- /dev/null +++ b/source/init/presets.ts @@ -0,0 +1,20 @@ +import type {ProjectAnalysis} from '@/init/project-analyzer'; + +export type PresetName = 'react' | 'nextjs' | 'rust'; + +export interface PresetFile { + path: string; + content: string; +} + +export interface PresetDefinition { + name: PresetName; + description: string; + projectType: string; + primaryLanguage: string; + frameworks: ProjectAnalysis['dependencies']['frameworks']; + buildCommands: Record; + /** Build-command label to the package.json script required for that command. */ + packageScripts?: Record; + files: PresetFile[]; +} diff --git a/source/init/templates/preset-nextjs.ts b/source/init/templates/preset-nextjs.ts new file mode 100644 index 000000000..d3c30af2d --- /dev/null +++ b/source/init/templates/preset-nextjs.ts @@ -0,0 +1,54 @@ +import type {PresetDefinition} from '@/init/presets'; + +export const nextjsPreset = { + name: 'nextjs', + description: 'Next.js application defaults and quality checks', + projectType: 'Next.js Web Application', + primaryLanguage: 'TypeScript', + frameworks: [ + {name: 'Next.js', category: 'web', confidence: 'high'}, + {name: 'React', category: 'web', confidence: 'high'}, + ], + buildCommands: { + Development: 'npm run dev', + Build: 'npm run build', + Test: 'npm run test', + Lint: 'npm run lint', + }, + packageScripts: { + Development: 'dev', + Build: 'build', + Test: 'test', + Lint: 'lint', + }, + files: [ + { + path: '.nanocoderignore', + content: `# Dependency lockfiles and generated framework metadata +package-lock.json +pnpm-lock.yaml +yarn.lock +next-env.d.ts +*.tsbuildinfo + +# Next.js and test output +.next/ +out/ +coverage/ +`, + }, + { + path: '.nanocoder/commands/check.md', + content: `--- +description: Run the available Next.js project quality checks +category: quality +--- + +Inspect package.json and the lockfiles to determine the package manager. Run +the available type-check, lint, test, and production build scripts in that +order. Do not invent missing scripts. Pay attention to server/client component +boundaries and report each failure with the relevant file and line information. +`, + }, + ], +} satisfies PresetDefinition; diff --git a/source/init/templates/preset-react.ts b/source/init/templates/preset-react.ts new file mode 100644 index 000000000..ecbfb70b4 --- /dev/null +++ b/source/init/templates/preset-react.ts @@ -0,0 +1,49 @@ +import type {PresetDefinition} from '@/init/presets'; + +export const reactPreset = { + name: 'react', + description: 'React application defaults and quality checks', + projectType: 'React Web Application', + primaryLanguage: 'TypeScript', + frameworks: [{name: 'React', category: 'web', confidence: 'high'}], + buildCommands: { + Development: 'npm run dev', + Build: 'npm run build', + Test: 'npm run test', + Lint: 'npm run lint', + }, + packageScripts: { + Development: 'dev', + Build: 'build', + Test: 'test', + Lint: 'lint', + }, + files: [ + { + path: '.nanocoderignore', + content: `# Dependency lockfiles and generated TypeScript metadata +package-lock.json +pnpm-lock.yaml +yarn.lock +*.tsbuildinfo + +# Generated test and build artifacts +coverage/ +dist/ +`, + }, + { + path: '.nanocoder/commands/check.md', + content: `--- +description: Run the available React project quality checks +category: quality +--- + +Inspect package.json and the lockfiles to determine the package manager. Run +the available type-check, lint, test, and build scripts in that order. Do not +invent missing scripts. Report each command and summarize any failures with +the relevant file and line information. +`, + }, + ], +} satisfies PresetDefinition; diff --git a/source/init/templates/preset-rust.ts b/source/init/templates/preset-rust.ts new file mode 100644 index 000000000..ba602c699 --- /dev/null +++ b/source/init/templates/preset-rust.ts @@ -0,0 +1,42 @@ +import type {PresetDefinition} from '@/init/presets'; + +export const rustPreset = { + name: 'rust', + description: 'Rust project defaults and Cargo quality checks', + projectType: 'Rust Application', + primaryLanguage: 'Rust', + frameworks: [], + buildCommands: { + Build: 'cargo build', + Test: 'cargo test', + Lint: 'cargo clippy --all-targets --all-features', + Format: 'cargo fmt --check', + Run: 'cargo run', + }, + files: [ + { + path: '.nanocoderignore', + content: `# Generated Cargo build output +target/ + +# Generated coverage and profiling data +coverage/ +*.profraw +*.profdata +`, + }, + { + path: '.nanocoder/commands/check.md', + content: `--- +description: Run the standard Rust project quality checks +category: quality +--- + +Inspect Cargo.toml and repository instructions, then run cargo fmt --check, +cargo clippy --all-targets --all-features, and cargo test. Add --workspace when +the manifest defines a workspace. Report each command and summarize failures +with the relevant crate, file, and line information. +`, + }, + ], +} satisfies PresetDefinition; diff --git a/source/lsp/server-discovery.ts b/source/lsp/server-discovery.ts index b3a8d8616..46cb9cd77 100644 --- a/source/lsp/server-discovery.ts +++ b/source/lsp/server-discovery.ts @@ -328,6 +328,7 @@ function verifyLSPServerWithCommunication( // A clean exit can also indicate success for some servers // However, for LSP servers waiting for input, an immediate exit is often a failure // The 'spawn' event is a more reliable indicator for our purpose + resolve(false); }); }); } diff --git a/source/markdown-parser/index.spec.ts b/source/markdown-parser/index.spec.ts index 248978655..07da3ee7c 100644 --- a/source/markdown-parser/index.spec.ts +++ b/source/markdown-parser/index.spec.ts @@ -1,10 +1,29 @@ +import {mkdirSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import test from 'ava'; +import chalk from 'chalk'; import stripAnsi from 'strip-ansi'; import type {Colors} from '../types/markdown-parser.js'; import {parseMarkdown} from './index.js'; console.log(`\nindex.spec.ts`); +// Highlighting consults the `syntaxTheme` preference, so run against an empty +// config directory — a contributor who sets that preference must not change +// which colours these assertions see. +const testConfigDir = join(tmpdir(), `nanocoder-md-spec-${process.pid}`); + +test.before(() => { + mkdirSync(testConfigDir, {recursive: true}); + process.env.NANOCODER_CONFIG_DIR = testConfigDir; +}); + +test.after.always(() => { + rmSync(testConfigDir, {recursive: true, force: true}); + delete process.env.NANOCODER_CONFIG_DIR; +}); + const mockColors: Colors = { primary: '#3b82f6', secondary: '#6b7280', @@ -165,6 +184,33 @@ test('parseMarkdown handles code blocks without language', t => { t.true(result.includes('Plain code')); }); +// Code blocks used to render in cli-highlight's own palette: the parser passed +// `theme: 'default'`, a string where the library expects a token -> formatter +// map, so the option was dropped. Colour must come from the caller's palette. +test('parseMarkdown highlights code blocks with the supplied colors', t => { + // Two separate things are going on here, both load-bearing. + // + // chalk.level = 3 because the runner reports no colour support, so the + // assertion would otherwise compare two unstyled strings. + // + // A distinct palette object because chalk bakes the colour MODEL into a + // builder when the builder is created: chalk.hex() picks ansi16, ansi256 + // or truecolor from chalk.level at that moment. Whether to emit codes is + // re-checked per call, but which codes is not. getSyntaxTheme memoises + // per palette identity, so reusing mockColors here would reuse builders + // frozen at the runner's default level - emitting  where the + // assertion builds [38;2;... and the two would not match. + const previousLevel = chalk.level; + chalk.level = 3; + try { + const colors: Colors = {...mockColors, primary: '#ff0000'}; + const result = parseMarkdown('```javascript\nconst x = 5;\n```', colors); + t.true(result.includes(chalk.hex(colors.primary)('const'))); + } finally { + chalk.level = previousLevel; + } +}); + // Edge case tests test('parseMarkdown does not create bullet list from hyphen in middle of line', t => { const text = 'This is not - a list'; diff --git a/source/markdown-parser/index.ts b/source/markdown-parser/index.ts index 3af3042b9..34cc0b92f 100644 --- a/source/markdown-parser/index.ts +++ b/source/markdown-parser/index.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; import {highlight} from 'cli-highlight'; +import {getSyntaxTheme} from '@/config/themes'; import type {Colors} from '../types/markdown-parser.js'; import {decodeHtmlEntities} from './html-entities.js'; import {parseMarkdownTable} from './table-parser.js'; @@ -63,7 +64,7 @@ function _parseMarkdownCore( // Apply syntax highlighting with detected language const highlighted = highlight(codeStr, { language: lang || 'plaintext', - theme: 'default', + theme: getSyntaxTheme(themeColors), }); const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`; codeBlocks.push(highlighted); diff --git a/source/memory/project-context.spec.ts b/source/memory/project-context.spec.ts new file mode 100644 index 000000000..c3fe2477c --- /dev/null +++ b/source/memory/project-context.spec.ts @@ -0,0 +1,178 @@ +import test from 'ava'; +import { + appendRelevantProjectContextWithCount, + type ProjectContextOptions, +} from './project-context.js'; +import type {SemanticMemory} from './semantic-memory-manager.js'; + +const memory = (content: string): SemanticMemory => ({ + id: content, + content, + category: 'project', + timestamp: '2026-07-17T00:00:00.000Z', +}); + +async function inject( + memories: SemanticMemory[], + options: ProjectContextOptions = {}, + query = 'auth', +) { + return appendRelevantProjectContextWithCount( + 'base prompt', + query, + {findRelevantMemories: async () => memories}, + options, + ); +} + +test('appendRelevantProjectContextWithCount returns original prompt for no memories', async t => { + const result = await inject([]); + t.is(result.systemPrompt, 'base prompt'); + t.is(result.memoryCount, 0); +}); + +test('appendRelevantProjectContextWithCount formats memories as project context', async t => { + const result = await inject([ + memory('Auth uses Clerk.'), + memory('Avoid middleware.\nUse adapters.'), + ]); + t.is( + result.systemPrompt, + 'base prompt\n\n## Project Context\n\n```\n- Auth uses Clerk.\n- Avoid middleware. Use adapters.\n```', + ); + t.is(result.memoryCount, 2); +}); + +test('appendRelevantProjectContextWithCount strips a leading list marker so bullets are not doubled', async t => { + const result = await inject([ + memory('- Added a regression test for the 40-column case.'), + ]); + t.is( + result.systemPrompt, + 'base prompt\n\n## Project Context\n\n```\n- Added a regression test for the 40-column case.\n```', + ); +}); + +test('appendRelevantProjectContextWithCount respects token budget', async t => { + const result = await inject( + [ + memory('Use existing hooks.'), + memory( + 'This second memory is intentionally long enough to exceed the tiny test budget.', + ), + ], + {tokenBudget: 14}, + ); + t.is( + result.systemPrompt, + 'base prompt\n\n## Project Context\n\n```\n- Use existing hooks.\n```', + ); +}); + +test('appendRelevantProjectContextWithCount returns original prompt when budget is too small', async t => { + const result = await inject([memory('Use existing hooks.')], { + tokenBudget: 1, + }); + t.is(result.systemPrompt, 'base prompt'); + t.is(result.memoryCount, 0); +}); + +test('appendRelevantProjectContextWithCount skips an oversized memory and still injects later ones', async t => { + const result = await inject( + [ + memory('This first memory is intentionally too long for the small budget.'), + memory('Use adapters.'), + ], + {tokenBudget: 12}, + ); + t.is( + result.systemPrompt, + 'base prompt\n\n## Project Context\n\n```\n- Use adapters.\n```', + ); + t.is(result.memoryCount, 1); +}); + +test('appendRelevantProjectContextWithCount reports injected memory count', async t => { + const result = await appendRelevantProjectContextWithCount( + 'base prompt', + 'auth', + { + findRelevantMemories: async () => [ + memory('Auth uses Clerk.'), + memory('Use adapters.'), + ], + }, + ); + + t.is(result.memoryCount, 2); + t.true(result.systemPrompt.includes('## Project Context')); +}); + +test('appendRelevantProjectContextWithCount skips memory lookup when disabled', async t => { + const result = await appendRelevantProjectContextWithCount( + 'base prompt', + 'auth', + { + findRelevantMemories: async () => { + throw new Error('should not look up memories when disabled'); + }, + }, + {semanticMemoryEnabled: false}, + ); + + t.is(result.memoryCount, 0); + t.is(result.systemPrompt, 'base prompt'); +}); + +test('appendRelevantProjectContextWithCount passes configured memory limit', async t => { + const result = await appendRelevantProjectContextWithCount( + 'base prompt', + 'auth', + { + findRelevantMemories: async (query, limit) => { + t.is(query, 'auth'); + t.is(limit, 2); + return [memory('Auth uses Clerk.')]; + }, + }, + {memoryLimit: 2}, + ); + + t.true(result.systemPrompt.includes('Auth uses Clerk.')); +}); + +test('appendRelevantProjectContextWithCount returns original prompt when lookup fails', async t => { + const result = await appendRelevantProjectContextWithCount( + 'base prompt', + 'auth', + { + findRelevantMemories: async () => { + throw new Error('memory unavailable'); + }, + }, + ); + + t.is(result.systemPrompt, 'base prompt'); + t.is(result.memoryCount, 0); +}); + +test('appendRelevantProjectContextWithCount widens the fence so memory content cannot escape it', async t => { + const result = await inject([ + memory('Use ``` fenced blocks ``` carefully.'), + ]); + + t.is( + result.systemPrompt, + 'base prompt\n\n## Project Context\n\n````\n- Use ``` fenced blocks ``` carefully.\n````', + ); + const [, body] = result.systemPrompt.split('````'); + t.true(body?.includes('fenced blocks') ?? false); +}); + +test('appendRelevantProjectContextWithCount keeps the standard fence when content has no backticks', async t => { + const result = await inject([memory('Auth uses Clerk.')]); + t.is( + result.systemPrompt, + 'base prompt\n\n## Project Context\n\n```\n- Auth uses Clerk.\n```', + ); +}); diff --git a/source/memory/project-context.ts b/source/memory/project-context.ts new file mode 100644 index 000000000..db4854e02 --- /dev/null +++ b/source/memory/project-context.ts @@ -0,0 +1,108 @@ +import {getLogger} from '@/utils/logging'; +import type {SemanticMemory} from './semantic-memory-manager'; +import {SemanticMemoryManager} from './semantic-memory-manager'; + +export type MemoryFinder = Pick; + +export interface ProjectContextOptions { + memoryLimit?: number; + tokenBudget?: number; + semanticMemoryEnabled?: boolean; +} + +export interface ProjectContextResult { + systemPrompt: string; + memoryCount: number; +} + +export const DEFAULT_MEMORY_LIMIT = 8; +export const DEFAULT_TOKEN_BUDGET = 240; + +/** Bounds for the user-configurable values, applied when preferences are read. */ +export const MIN_MEMORY_LIMIT = 1; +export const MAX_MEMORY_LIMIT = 50; +export const MIN_TOKEN_BUDGET = 40; +export const MAX_TOKEN_BUDGET = 4000; + +function estimateTokens(value: string): number { + return Math.ceil(value.length / 4); +} + +/** + * Picks a fence longer than the longest backtick run in the body, the way + * Markdown itself does. Memory content is interpolated verbatim, so a fixed + * three-backtick fence could be escaped by a memory containing backticks. + */ +function fenceFor(body: string): string { + let longest = 0; + for (const match of body.matchAll(/`+/gu)) { + longest = Math.max(longest, match[0].length); + } + return '`'.repeat(Math.max(3, longest + 1)); +} + +function formatProjectContextWithCount( + memories: SemanticMemory[], + options: ProjectContextOptions = {}, +): {content: string; memoryCount: number} { + if (memories.length === 0) return {content: '', memoryCount: 0}; + + const tokenBudget = options.tokenBudget ?? DEFAULT_TOKEN_BUDGET; + const bullets: string[] = []; + let usedTokens = + estimateTokens('## Project Context\n\n') + estimateTokens('```\n\n```'); + + for (const memory of memories) { + const text = memory.content + .replaceAll(/\s+/gu, ' ') + .trim() + .replace(/^[-*]\s+/u, ''); + const bullet = `- ${text}`; + const bulletTokens = estimateTokens(`${bullet}\n`); + if (usedTokens + bulletTokens > tokenBudget) continue; + + bullets.push(bullet); + usedTokens += bulletTokens; + } + + if (bullets.length === 0) return {content: '', memoryCount: 0}; + + const body = bullets.join('\n'); + const fence = fenceFor(body); + + return { + content: `## Project Context\n\n${fence}\n${body}\n${fence}`, + memoryCount: bullets.length, + }; +} + +export async function appendRelevantProjectContextWithCount( + systemPrompt: string, + query: string, + memoryFinder: MemoryFinder = new SemanticMemoryManager(), + options: ProjectContextOptions = {}, +): Promise { + if (options.semanticMemoryEnabled === false) { + return {systemPrompt, memoryCount: 0}; + } + + try { + const projectContext = formatProjectContextWithCount( + await memoryFinder.findRelevantMemories( + query, + options.memoryLimit ?? DEFAULT_MEMORY_LIMIT, + ), + options, + ); + + if (!projectContext.content) return {systemPrompt, memoryCount: 0}; + + return { + systemPrompt: `${systemPrompt}\n\n${projectContext.content}`, + memoryCount: projectContext.memoryCount, + }; + } catch (error) { + getLogger().warn({error}, 'Failed to recall project memories'); + return {systemPrompt, memoryCount: 0}; + } +} diff --git a/source/memory/proposal-store.ts b/source/memory/proposal-store.ts new file mode 100644 index 000000000..819983045 --- /dev/null +++ b/source/memory/proposal-store.ts @@ -0,0 +1,58 @@ +import type {MemoryProposal} from './summarizer-service'; + +/** + * Holds the proposal list printed by the last `/memory propose`. + * + * The list is never mutated once printed. `/memory accept ` addresses it by + * the same 1-based index the user is reading off screen, so accepted entries are + * tracked in a separate set rather than removed - dropping an entry would shift + * every later number against the printout and silently save the wrong memory. + */ +export class ProposalStore { + private proposals: MemoryProposal[] = []; + private readonly accepted = new Set(); + + set(proposals: MemoryProposal[]): void { + this.proposals = proposals; + this.accepted.clear(); + } + + list(): readonly MemoryProposal[] { + return this.proposals; + } + + get size(): number { + return this.proposals.length; + } + + /** `index` is 1-based, matching the printed list. */ + at(index: number): MemoryProposal | undefined { + if ( + !Number.isInteger(index) || + index < 1 || + index > this.proposals.length + ) { + return undefined; + } + return this.proposals[index - 1]; + } + + isAccepted(index: number): boolean { + return this.accepted.has(index); + } + + markAccepted(index: number): void { + this.accepted.add(index); + } + + clear(): void { + this.proposals = []; + this.accepted.clear(); + } +} + +/** + * Shared store backing the lazily-loaded `/memory` command. Cleared by `/clear` + * so a proposal derived from a discarded conversation can't still be accepted. + */ +export const sharedProposalStore = new ProposalStore(); diff --git a/source/memory/semantic-memory-manager.spec.ts b/source/memory/semantic-memory-manager.spec.ts new file mode 100644 index 000000000..1345a9c5a --- /dev/null +++ b/source/memory/semantic-memory-manager.spec.ts @@ -0,0 +1,274 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'ava'; +import {SemanticMemoryManager} from './semantic-memory-manager.js'; + +async function createTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'nanocoder-memory-')); +} + +test('SemanticMemoryManager stores and reloads repo-scoped memories', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + const memory = await manager.addMemory({ + content: ' Use the existing auth adapter pattern for Clerk changes. ', + sourceSessionId: 'session-1', + }); + + t.is(memory.content, 'Use the existing auth adapter pattern for Clerk changes.'); + t.is(memory.category, 'project'); + t.regex(memory.timestamp, /^\d{4}-\d{2}-\d{2}T/); + t.is(memory.sourceSessionId, 'session-1'); + + const reloaded = new SemanticMemoryManager({memoryDir: dir, cwd}); + t.deepEqual(await reloaded.listMemories(), [memory]); +}); + +test('SemanticMemoryManager keeps different repositories isolated', async t => { + const dir = await createTempDir(); + const repoA = path.join(dir, 'repo-a'); + const repoB = path.join(dir, 'repo-b'); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await new SemanticMemoryManager({memoryDir: dir, cwd: repoA}).addMemory({ + content: 'Repo A uses route handlers.', + }); + + const repoBManager = new SemanticMemoryManager({memoryDir: dir, cwd: repoB}); + t.deepEqual(await repoBManager.listMemories(), []); +}); + +test('SemanticMemoryManager stores memory category', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + const memory = await manager.addMemory({ + content: 'Follow the existing provider abstraction.', + category: 'architecture', + }); + + t.is(memory.category, 'architecture'); + t.deepEqual(await manager.listMemories(), [memory]); +}); + +test('SemanticMemoryManager deletes and clears memories', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + const first = await manager.addMemory({content: 'Keep components small.'}); + const second = await manager.addMemory({content: 'Prefer existing hooks.'}); + + t.true(await manager.deleteMemory(first.id)); + t.false(await manager.deleteMemory(first.id)); + t.deepEqual(await manager.listMemories(), [second]); + + await manager.clearMemories(); + t.deepEqual(await manager.listMemories(), []); +}); + +test('SemanticMemoryManager returns relevant memories before unrelated ones', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + const auth = await manager.addMemory({ + content: 'Auth flow uses Clerk and avoids middleware.', + }); + await manager.addMemory({ + content: 'Release notes are generated from contributor history.', + }); + + t.deepEqual(await manager.findRelevantMemories('refactor clerk auth', 3), [ + auth, + ]); +}); + +test('SemanticMemoryManager includes category matches in relevance ranking', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + const architecture = await manager.addMemory({ + content: 'Use the service layer for persistence changes.', + category: 'architecture', + }); + await manager.addMemory({ + content: 'Release notes are generated from contributor history.', + category: 'workflow', + }); + + t.deepEqual(await manager.findRelevantMemories('architecture', 3), [ + architecture, + ]); +}); + +test('SemanticMemoryManager filters out stopword-only matches on an unrelated query', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + await manager.addMemory({ + content: 'The auth module uses Clerk and we avoid middleware in the edge runtime.', + }); + await manager.addMemory({ + content: + 'The flaky test in the payments suite is a known failure and we should fix it later.', + }); + const style = await manager.addMemory({ + content: 'Use tabs not spaces in the settings form styling.', + }); + + const results = await manager.findRelevantMemories( + 'can you add a new field to the user profile page in the settings form', + 5, + ); + + t.deepEqual(results, [style]); +}); + +test('SemanticMemoryManager ranks by query coverage, not memory length', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + await manager.addMemory({ + content: 'Always add tests.', + }); + const worker = await manager.addMemory({ + content: + 'We decided against introducing a separate background worker process for indexing, because the daemon already owns scheduling and a second long-lived process would complicate the lockfile story.', + }); + + t.deepEqual( + await manager.findRelevantMemories( + 'should I add a background worker for this', + 5, + ), + [worker], + ); +}); + +test('SemanticMemoryManager recalls a memory on a single keyword when it covers half the query', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + const auth = await manager.addMemory({ + content: + 'The auth module uses Clerk and avoids middleware in the edge runtime.', + }); + + t.deepEqual(await manager.findRelevantMemories('auth', 3), [auth]); + t.deepEqual(await manager.findRelevantMemories('fix auth', 3), [auth]); + t.deepEqual( + await manager.findRelevantMemories('refactor the auth middleware', 3), + [auth], + ); + t.deepEqual(await manager.findRelevantMemories('update clerk auth flow', 3), [ + auth, + ]); +}); + +test('SemanticMemoryManager serializes concurrent writes so none are lost', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + await Promise.all( + Array.from({length: 10}, (_, i) => + manager.addMemory({content: `Memory number ${i}.`}), + ), + ); + + const memories = await manager.listMemories(); + t.is(memories.length, 10); +}); + +test('SemanticMemoryManager serializes concurrent writes across manager instances', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const first = new SemanticMemoryManager({memoryDir: dir, cwd}); + const second = new SemanticMemoryManager({memoryDir: dir, cwd}); + + await Promise.all([ + ...Array.from({length: 10}, (_, i) => + first.addMemory({content: `First instance memory ${i}.`}), + ), + ...Array.from({length: 10}, (_, i) => + second.addMemory({content: `Second instance memory ${i}.`}), + ), + ]); + + t.is((await first.listMemories()).length, 20); +}); + +test('SemanticMemoryManager drops oldest memories when the store cap is exceeded', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({ + memoryDir: dir, + cwd, + maxStoredMemories: 3, + }); + + for (const index of [1, 2, 3, 4, 5]) { + await manager.addMemory({ + content: `Auth adapter numbered convention ${index}.`, + }); + await new Promise(resolve => setTimeout(resolve, 5)); + } + + const memories = await manager.listMemories(); + t.deepEqual( + memories.map(memory => memory.content), + [ + 'Auth adapter numbered convention 3.', + 'Auth adapter numbered convention 4.', + 'Auth adapter numbered convention 5.', + ], + ); +}); + +test('SemanticMemoryManager rejects empty memory content', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + await t.throwsAsync(manager.addMemory({content: ' '}), { + message: 'Memory content cannot be empty', + }); +}); + +test('SemanticMemoryManager rewrites a corrupt store on the next write', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + await manager.addMemory({content: 'Auth uses Clerk.'}); + const files = await fs.readdir(dir); + const store = files.find(name => name.endsWith('.json')); + t.truthy(store); + await fs.writeFile(path.join(dir, store!), '{not json', 'utf8'); + + const repaired = await manager.addMemory({content: 'Use adapters.'}); + t.deepEqual(await manager.listMemories(), [repaired]); +}); diff --git a/source/memory/semantic-memory-manager.ts b/source/memory/semantic-memory-manager.ts new file mode 100644 index 000000000..10e4667f7 --- /dev/null +++ b/source/memory/semantic-memory-manager.ts @@ -0,0 +1,433 @@ +import {execFile} from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {promisify} from 'node:util'; +import {getAppDataPath} from '@/config/paths'; + +const execFileAsync = promisify(execFile); + +export interface SemanticMemory { + id: string; + content: string; + category: string; + timestamp: string; + sourceSessionId?: string; +} + +export interface CreateMemoryInput { + content: string; + category?: string; + sourceSessionId?: string; +} + +export interface SemanticMemoryManagerOptions { + memoryDir?: string; + cwd?: string; + maxStoredMemories?: number; +} + +const DEFAULT_MAX_STORED_MEMORIES = 500; + +const writeQueues = new Map>(); + +function enqueueByKey(key: string, operation: () => Promise): Promise { + const previous = writeQueues.get(key) ?? Promise.resolve(); + const result = previous.then(operation, operation); + writeQueues.set( + key, + result.then( + () => undefined, + () => undefined, + ), + ); + return result; +} + +const LOCK_STALE_MS = 10_000; +const LOCK_WAIT_MS = 15_000; + +async function withExclusiveLock( + lockPath: string, + operation: () => Promise, +): Promise { + const deadline = Date.now() + LOCK_WAIT_MS; + while (true) { + try { + const handle = await fs.open(lockPath, 'wx', 0o600); + try { + await handle.writeFile(String(process.pid), 'utf8'); + return await operation(); + } finally { + await handle.close(); + try { + const owner = (await fs.readFile(lockPath, 'utf8')).trim(); + if (owner === String(process.pid)) { + await fs.unlink(lockPath); + } + } catch { + // Lock already gone or stolen. + } + } + } catch (error) { + const code = + error instanceof Error && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + if (code !== 'EEXIST') throw error; + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for memory file lock: ${lockPath}`); + } + try { + const stat = await fs.stat(lockPath); + if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) { + await fs.unlink(lockPath); + continue; + } + } catch { + // Lock gone; retry create. + } + await new Promise(resolve => setTimeout(resolve, 20)); + } + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isSemanticMemory(value: unknown): value is SemanticMemory { + if (!isRecord(value)) return false; + return ( + typeof value.id === 'string' && + typeof value.content === 'string' && + typeof value.category === 'string' && + typeof value.timestamp === 'string' && + (value.sourceSessionId === undefined || + typeof value.sourceSessionId === 'string') + ); +} + +async function atomicWriteFile(filePath: string, data: string): Promise { + const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`; + try { + await fs.writeFile(tmpPath, data, {mode: 0o600}); + await fs.rename(tmpPath, filePath); + } catch (error) { + try { + await fs.unlink(tmpPath); + } catch (_cleanupError) { + // Ignore cleanup errors. + } + throw error; + } +} + +function hashScope(scope: string): string { + return crypto.createHash('sha256').update(scope).digest('hex').slice(0, 32); +} + +const STOPWORDS = new Set([ + 'a', + 'about', + 'after', + 'again', + 'all', + 'am', + 'an', + 'and', + 'any', + 'are', + 'as', + 'at', + 'be', + 'been', + 'being', + 'but', + 'by', + 'can', + 'could', + 'did', + 'do', + 'does', + 'doing', + 'down', + 'during', + 'each', + 'few', + 'for', + 'from', + 'further', + 'had', + 'has', + 'have', + 'having', + 'he', + 'her', + 'here', + 'hers', + 'herself', + 'him', + 'himself', + 'his', + 'how', + 'if', + 'in', + 'into', + 'is', + 'it', + 'its', + 'itself', + 'just', + 'me', + 'more', + 'most', + 'my', + 'myself', + 'no', + 'nor', + 'not', + 'now', + 'of', + 'off', + 'on', + 'once', + 'only', + 'or', + 'other', + 'our', + 'ours', + 'ourselves', + 'out', + 'over', + 'own', + 'same', + 'she', + 'should', + 'so', + 'some', + 'such', + 'than', + 'that', + 'the', + 'their', + 'theirs', + 'them', + 'themselves', + 'then', + 'there', + 'these', + 'they', + 'this', + 'those', + 'through', + 'to', + 'too', + 'under', + 'until', + 'up', + 'very', + 'was', + 'we', + 'were', + 'what', + 'when', + 'where', + 'which', + 'while', + 'who', + 'whom', + 'why', + 'will', + 'with', + 'would', + 'you', + 'your', + 'yours', + 'yourself', + 'yourselves', +]); + +const MIN_RELEVANCE_RATIO = 0.1; +const SINGLE_HIT_MIN_RATIO = 0.5; + +function tokenize(value: string): Set { + return new Set( + value + .toLowerCase() + .split(/[^a-z0-9]+/u) + .filter(part => part.length > 1 && !STOPWORDS.has(part)), + ); +} + +export class SemanticMemoryManager { + private readonly memoryDir: string; + private readonly cwd: string; + private readonly maxStoredMemories: number; + private memoryFilePath?: string; + + constructor(options: SemanticMemoryManagerOptions = {}) { + this.memoryDir = options.memoryDir ?? path.join(getAppDataPath(), 'memory'); + this.cwd = options.cwd ?? process.cwd(); + this.maxStoredMemories = Math.max( + 1, + options.maxStoredMemories ?? DEFAULT_MAX_STORED_MEMORIES, + ); + } + + private mutate(operation: () => Promise): Promise { + return this.getMemoryFilePath().then(filePath => + enqueueByKey(filePath, () => + withExclusiveLock(`${filePath}.lock`, operation), + ), + ); + } + + async addMemory(input: CreateMemoryInput): Promise { + const content = input.content.trim(); + if (!content) { + throw new Error('Memory content cannot be empty'); + } + + const category = input.category?.trim() || 'project'; + const memory: SemanticMemory = { + id: crypto.randomUUID(), + content, + category, + timestamp: new Date().toISOString(), + ...(input.sourceSessionId + ? {sourceSessionId: input.sourceSessionId} + : {}), + }; + + return this.mutate(async () => { + const memories = await this.listMemories(); + memories.push(memory); + await this.writeMemories(memories); + return memory; + }); + } + + async listMemories(): Promise { + const filePath = await this.getMemoryFilePath(); + try { + const data = await fs.readFile(filePath, 'utf-8'); + const parsed: unknown = JSON.parse(data); + if (!Array.isArray(parsed)) return []; + return parsed.filter(isSemanticMemory); + } catch (error) { + if ( + error instanceof SyntaxError || + (error instanceof Error && 'code' in error && error.code === 'ENOENT') + ) { + return []; + } + throw error; + } + } + + async deleteMemory(id: string): Promise { + return this.mutate(async () => { + const memories = await this.listMemories(); + const filtered = memories.filter(memory => memory.id !== id); + if (filtered.length === memories.length) { + return false; + } + + await this.writeMemories(filtered); + return true; + }); + } + + async clearMemories(): Promise { + await this.mutate(() => this.writeMemories([])); + } + + async findRelevantMemories( + query: string, + limit = 5, + ): Promise { + const queryTerms = tokenize(query); + if (queryTerms.size === 0 || limit <= 0) return []; + + return (await this.listMemories()) + .map(memory => { + const memoryTerms = tokenize(memory.content); + const categoryTerms = tokenize(memory.category); + let matchedQueryTerms = 0; + let categoryHit = false; + for (const term of queryTerms) { + if (categoryTerms.has(term)) categoryHit = true; + if (memoryTerms.has(term) || categoryTerms.has(term)) { + matchedQueryTerms++; + } + } + const relevanceRatio = matchedQueryTerms / queryTerms.size; + return {memory, matchedQueryTerms, categoryHit, relevanceRatio}; + }) + .filter( + result => + result.relevanceRatio >= MIN_RELEVANCE_RATIO && + (result.categoryHit || + result.matchedQueryTerms >= 2 || + result.relevanceRatio >= SINGLE_HIT_MIN_RATIO), + ) + .sort((a, b) => { + if (a.matchedQueryTerms !== b.matchedQueryTerms) { + return b.matchedQueryTerms - a.matchedQueryTerms; + } + return b.memory.timestamp.localeCompare(a.memory.timestamp); + }) + .slice(0, limit) + .map(result => result.memory); + } + + private async getMemoryFilePath(): Promise { + if (this.memoryFilePath) return this.memoryFilePath; + + await fs.mkdir(this.memoryDir, {recursive: true, mode: 0o700}); + const scope = await this.getRepositoryScope(); + this.memoryFilePath = path.join(this.memoryDir, `${hashScope(scope)}.json`); + return this.memoryFilePath; + } + + private async getRepositoryScope(): Promise { + try { + const {stdout} = await execFileAsync( + 'git', + ['config', '--get', 'remote.origin.url'], + {cwd: this.cwd}, + ); + const remote = stdout.trim(); + if (remote) return remote; + } catch { + // Non-git directories fall back to their absolute path. + } + + return path.resolve(this.cwd); + } + + private capMemories(memories: SemanticMemory[]): SemanticMemory[] { + if (memories.length <= this.maxStoredMemories) return memories; + + const keep = new Set( + [...memories] + .sort((a, b) => { + const byTime = b.timestamp.localeCompare(a.timestamp); + return byTime !== 0 ? byTime : a.id.localeCompare(b.id); + }) + .slice(0, this.maxStoredMemories) + .map(memory => memory.id), + ); + + return memories.filter(memory => keep.has(memory.id)); + } + + private async writeMemories(memories: SemanticMemory[]): Promise { + const filePath = await this.getMemoryFilePath(); + await atomicWriteFile( + filePath, + JSON.stringify(this.capMemories(memories), null, 2), + ); + } +} diff --git a/source/memory/summarizer-service.spec.ts b/source/memory/summarizer-service.spec.ts new file mode 100644 index 000000000..5d5279311 --- /dev/null +++ b/source/memory/summarizer-service.spec.ts @@ -0,0 +1,619 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'ava'; +import type {Message} from '@/types/core'; +import {SemanticMemoryManager} from './semantic-memory-manager.js'; +import { + inferMemoryCategory, + MAX_PROPOSALS, + MAX_SCANNED_MESSAGES, + type MemoryProposal, + REVERSAL_WARNING, + SummarizerService, + toCamelCaseCategory, +} from './summarizer-service.js'; + +async function createTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'nanocoder-memory-')); +} + +test('SummarizerService stores a manual memory', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + const service = new SummarizerService(manager, () => true); + + const memory = await service.remember({ + content: ' Use the existing provider abstraction for model changes. ', + sourceSessionId: 'session-1', + }); + + t.is(memory.content, 'Use the existing provider abstraction for model changes.'); + t.is(memory.category, 'architecture'); + t.is(memory.sourceSessionId, 'session-1'); + t.deepEqual(await manager.listMemories(), [memory]); +}); + +test('SummarizerService rejects empty manual memory content', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const service = new SummarizerService( + new SemanticMemoryManager({memoryDir: dir, cwd}), + () => true, + ); + + await t.throwsAsync(service.remember({content: ' '}), { + message: 'Memory content cannot be empty', + }); +}); + +test('SummarizerService uses explicit camelCase category', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const service = new SummarizerService( + new SemanticMemoryManager({memoryDir: dir, cwd}), + () => true, + ); + + const memory = await service.remember({ + content: 'Keep generated files out of review unless needed.', + category: 'coding style', + }); + + t.is(memory.category, 'codingStyle'); +}); + +test('inferMemoryCategory maps durable facts to stable categories', t => { + t.is( + inferMemoryCategory('Avoid middleware in the auth architecture.'), + 'architecture', + ); + t.is( + inferMemoryCategory('Use camel case for new command variables.'), + 'codingStyle', + ); + t.is( + inferMemoryCategory('Use camelCase for all variable names.'), + 'codingStyle', + ); + t.is(inferMemoryCategory('This fixes the queued input regression.'), 'bugFix'); + t.is(inferMemoryCategory('Refactor the old storage path later.'), 'refactor'); + t.is(inferMemoryCategory('TODO delete obsolete project memory.'), 'todo'); + t.is(inferMemoryCategory('The project name is Nanocoder.'), 'project'); +}); + +test('toCamelCaseCategory normalizes category names', t => { + t.is(toCamelCaseCategory('coding style'), 'codingStyle'); + t.is(toCamelCaseCategory('BUG-FIX'), 'bugFix'); + t.is(toCamelCaseCategory(''), 'project'); +}); + +test('SummarizerService proposes durable memories from messages', t => { + const service = new SummarizerService(); + + t.deepEqual( + service.proposeMemoriesFromMessages([ + { + role: 'system', + content: 'You are Nanocoder.', + }, + { + role: 'user', + content: 'Use the existing provider abstraction for model changes.', + }, + { + role: 'assistant', + content: 'Fixed the queued input regression by restoring drafts.', + }, + { + role: 'tool', + content: 'command output', + tool_call_id: 'tool-1', + name: 'execute_bash', + }, + ]), + [ + { + content: 'Use the existing provider abstraction for model changes.', + category: 'architecture', + sourceType: 'explicit-user', + evidence: { + userMessages: [ + 'Use the existing provider abstraction for model changes.', + ], + assistantMessages: [], + }, + warnings: [], + }, + { + content: 'Fixed the queued input regression by restoring drafts.', + category: 'bugFix', + sourceType: 'conversation-inferred', + evidence: { + userMessages: [], + assistantMessages: [ + 'Fixed the queued input regression by restoring drafts.', + ], + }, + warnings: ['Inferred from conversation, no explicit user statement.'], + }, + ] satisfies MemoryProposal[], + ); +}); + +test('SummarizerService dedupes proposed memories and user takes precedence', t => { + const service = new SummarizerService(); + + t.deepEqual( + service.proposeMemoriesFromMessages([ + { + role: 'user', + content: 'Refactor the storage path later.', + }, + { + role: 'assistant', + content: 'Refactor the storage path later.', + }, + ]), + [ + { + content: 'Refactor the storage path later.', + category: 'refactor', + sourceType: 'explicit-user', + evidence: { + userMessages: ['Refactor the storage path later.'], + assistantMessages: ['Refactor the storage path later.'], + }, + warnings: [], + }, + ], + ); +}); + +test('SummarizerService detects assistant position reversal', t => { + const service = new SummarizerService(); + + t.deepEqual( + service.proposeMemoriesFromMessages([ + { + role: 'user', + content: "Actually, generic exceptions look cleaner, you'd agree right?", + }, + { + role: 'assistant', + content: "You're right. I will use generic exceptions formatting.", + }, + ]), + [ + { + content: "You're right. I will use generic exceptions formatting.", + category: 'codingStyle', + sourceType: 'conversation-inferred', + evidence: { + userMessages: [], + assistantMessages: ["You're right. I will use generic exceptions formatting."], + }, + warnings: [ + 'Possible assistant position reversal.', + 'Inferred from conversation, no explicit user statement.', + ], + }, + ], + ); +}); + +test('SummarizerService clears the reversal warning once the user later explicitly restates the same line', t => { + const service = new SummarizerService(); + + const proposals = service.proposeMemoriesFromMessages([ + { + role: 'user', + content: 'Actually, generic exceptions look cleaner, do not you think.', + }, + { + role: 'assistant', + content: "You're right. I will use generic exceptions formatting.", + }, + { + role: 'user', + content: "You're right. I will use generic exceptions formatting.", + }, + ]); + + const restated = proposals.find( + p => p.content === "You're right. I will use generic exceptions formatting.", + ); + t.truthy(restated); + t.is(restated?.sourceType, 'explicit-user'); + t.deepEqual(restated?.warnings, []); +}); + +test('SummarizerService guards false positive on reversal when user provides path/code/error', t => { + const service = new SummarizerService(); + + t.deepEqual( + service.proposeMemoriesFromMessages([ + { + role: 'user', + content: 'That path is wrong, it should be /src/auth/style.ts.', + }, + { + role: 'assistant', + content: "You're right. The style convention is updated.", + }, + ]), + [ + { + content: 'That path is wrong, it should be /src/auth/style.ts.', + category: 'codingStyle', + sourceType: 'explicit-user', + evidence: { + userMessages: ['That path is wrong, it should be /src/auth/style.ts.'], + assistantMessages: [], + }, + warnings: [], + }, + { + content: "You're right. The style convention is updated.", + category: 'codingStyle', + sourceType: 'conversation-inferred', + evidence: { + userMessages: [], + assistantMessages: ["You're right. The style convention is updated."], + }, + warnings: [ + 'Inferred from conversation, no explicit user statement.', + ], + }, + ], + ); +}); + +test('SummarizerService drops uncategorized lines from both user and assistant', t => { + const service = new SummarizerService(); + + t.deepEqual( + service.proposeMemoriesFromMessages([ + { + role: 'assistant', + content: 'The project name shows up in the welcome banner.', + }, + { + role: 'user', + content: 'The project name is Nanocoder, not nano-coder.', + }, + { + role: 'user', + content: 'run the tests', + }, + { + role: 'user', + content: + 'One thing I noticed while in there: the provider list component does the same', + }, + ]), + [], + ); +}); + +test('SummarizerService caps candidates per message and truncates evidence snippets', t => { + const service = new SummarizerService(); + const longSuffix = 'x'.repeat(200); + const longMessage = [ + `Fix the provider retry storage schema bug one. ${longSuffix}.`, + 'Fix the provider retry storage schema bug two.', + 'Fix the provider retry storage schema bug three.', + 'Fix the provider retry storage schema bug four.', + 'Fix the provider retry storage schema bug five.', + ].join('\n'); + + const proposals = service.proposeMemoriesFromMessages([ + {role: 'assistant', content: longMessage}, + ]); + + t.is(proposals.length, 3); + for (const proposal of proposals) { + for (const evidence of proposal.evidence.assistantMessages) { + t.true(evidence.length <= 161); + } + } + t.true(proposals[0]!.evidence.assistantMessages[0]!.endsWith('…')); +}); + +test('SummarizerService proposals do not save memories automatically', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + const service = new SummarizerService(manager); + + const proposals = service.proposeMemoriesFromMessages([ + { + role: 'user', + content: 'TODO delete obsolete project memory later.', + }, + ]); + + t.deepEqual(proposals, [ + { + content: 'TODO delete obsolete project memory later.', + category: 'todo', + sourceType: 'explicit-user', + evidence: { + userMessages: ['TODO delete obsolete project memory later.'], + assistantMessages: [], + }, + warnings: [], + }, + ]); + t.deepEqual(await manager.listMemories(), []); +}); + +test('SummarizerService blocks writes when semantic memory is disabled', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + const service = new SummarizerService(manager, () => false); + + await t.throwsAsync(service.remember({content: 'Use tabs not spaces.'}), { + message: 'Semantic memory is turned off. Enable it in /settings to save memories.', + }); + await t.throwsAsync( + service.acceptProposal({content: 'Use tabs not spaces.', category: 'codingStyle'}), + { + message: 'Semantic memory is turned off. Enable it in /settings to save memories.', + }, + ); + t.deepEqual(await manager.listMemories(), []); +}); + +test('SummarizerService acceptProposal saves a proposal without re-deriving its category', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + const service = new SummarizerService(manager, () => true); + + const memory = await service.acceptProposal( + {content: 'Fixed the queued input regression by restoring drafts.', category: 'bugFix'}, + 'session-1', + ); + + t.is(memory.category, 'bugFix'); + t.is(memory.sourceSessionId, 'session-1'); + t.deepEqual(await manager.listMemories(), [memory]); +}); + +// --- Reversal detector: the four variants the round-3 review found defeated, +// plus the contradiction case the original report actually asked for. --- + +const CONCESSION = + "You're right. The provider config should load lazily, not eagerly."; +const USER_PREFERENCE_ONLY = + 'Honestly, lazy provider config just feels cleaner to me.'; + +function reversalWarnings(messages: Message[], content: string): string[] { + const proposal = new SummarizerService() + .proposeMemoriesFromMessages(messages) + .find(p => p.content === content); + if (!proposal) throw new Error(`no proposal produced for: ${content}`); + return proposal.warnings; +} + +test('reversal is flagged on a clean two-turn concession', t => { + t.true( + reversalWarnings( + [ + {role: 'user', content: USER_PREFERENCE_ONLY}, + {role: 'assistant', content: CONCESSION}, + ], + CONCESSION, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is still flagged when the user message contains a bare slash', t => { + // "auth/login" is prose, not a file path; it must not count as evidence. + t.true( + reversalWarnings( + [ + { + role: 'user', + content: + 'Honestly, for auth/login lazy provider config just feels cleaner to me.', + }, + {role: 'assistant', content: CONCESSION}, + ], + CONCESSION, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is still flagged across intervening tool-call turns', t => { + t.true( + reversalWarnings( + [ + {role: 'user', content: USER_PREFERENCE_ONLY}, + { + role: 'assistant', + content: '', + tool_calls: [ + {id: 't1', function: {name: 'read_file', arguments: {}}}, + ], + }, + { + role: 'tool', + content: 'file contents', + tool_call_id: 't1', + name: 'read_file', + }, + {role: 'assistant', content: CONCESSION}, + ], + CONCESSION, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is flagged for agreement openers outside the original six phrases', t => { + const conceded = + 'Agreed on reflection. The provider config should load lazily, not eagerly.'; + t.true( + reversalWarnings( + [ + {role: 'user', content: USER_PREFERENCE_ONLY}, + {role: 'assistant', content: conceded}, + ], + conceded, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is flagged when a turn contradicts an earlier assistant turn without any opener', t => { + const reversed = + 'The provider config should load lazily, not eagerly, for this project.'; + t.true( + reversalWarnings( + [ + {role: 'user', content: 'How should provider config load?'}, + { + role: 'assistant', + content: 'The provider config should load eagerly, not lazily.', + }, + {role: 'user', content: USER_PREFERENCE_ONLY}, + {role: 'assistant', content: reversed}, + ], + reversed, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is not flagged when a substantive assistant reply sits in between', t => { + const conceded = "You're right. Use eager provider config."; + t.false( + reversalWarnings( + [ + {role: 'user', content: 'Honestly lazy just feels cleaner.'}, + {role: 'assistant', content: 'Here is a summary of the current setup.'}, + {role: 'assistant', content: conceded}, + ], + conceded, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is not flagged for a routine factual assistant turn', t => { + const fact = 'The storage schema keeps one provider row per workspace.'; + t.false( + reversalWarnings( + [ + {role: 'user', content: 'How is the storage laid out here?'}, + {role: 'assistant', content: fact}, + ], + fact, + ).includes(REVERSAL_WARNING), + ); +}); + +test('SummarizerService bounds the scan window and total proposal count', t => { + const service = new SummarizerService(); + const messages: Message[] = []; + // Well past both limits, and old enough that the earliest fall outside the window. + const total = MAX_SCANNED_MESSAGES + 20; + for (let i = 0; i < total; i++) { + messages.push({ + role: 'user', + content: `Fix the provider retry storage schema bug number ${i}.`, + }); + } + + const proposals = service.proposeMemoriesFromMessages(messages); + + t.is(proposals.length, MAX_PROPOSALS); + // The window keeps the newest turns, so the oldest message is not proposed. + t.false(proposals.some(p => p.content.endsWith('number 0.'))); + t.true(proposals.some(p => p.content.endsWith(`number ${total - 1}.`))); +}); + +test('reversal is still flagged when an intervening tool call has narration', t => { + t.true( + reversalWarnings( + [ + {role: 'user', content: USER_PREFERENCE_ONLY}, + { + role: 'assistant', + content: 'Let me re-read the config loader.', + tool_calls: [ + {id: 't1', function: {name: 'read_file', arguments: {}}}, + ], + }, + { + role: 'tool', + content: 'file contents', + tool_call_id: 't1', + name: 'read_file', + }, + {role: 'assistant', content: CONCESSION}, + ], + CONCESSION, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is still flagged across a look-ahead narration turn with no tool call', t => { + t.true( + reversalWarnings( + [ + {role: 'user', content: USER_PREFERENCE_ONLY}, + {role: 'assistant', content: 'Let me look at the file first.'}, + {role: 'assistant', content: CONCESSION}, + ], + CONCESSION, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is still flagged when the user message contains camelCase or the word error', t => { + t.true( + reversalWarnings( + [ + { + role: 'user', + content: + 'the two exceptClauses are over-engineered, simplify it.', + }, + {role: 'assistant', content: CONCESSION}, + ], + CONCESSION, + ).includes(REVERSAL_WARNING), + ); + t.true( + reversalWarnings( + [ + { + role: 'user', + content: 'is that not over-engineered? one error handler reads better.', + }, + {role: 'assistant', content: CONCESSION}, + ], + CONCESSION, + ).includes(REVERSAL_WARNING), + ); +}); + +test('SummarizerService strips leading list markers from proposed content', t => { + const service = new SummarizerService(); + const proposals = service.proposeMemoriesFromMessages([ + { + role: 'user', + content: '- Added a regression test for the 40-column case.', + }, + ]); + + t.is(proposals.length, 1); + t.is(proposals[0]?.content, 'Added a regression test for the 40-column case.'); +}); diff --git a/source/memory/summarizer-service.ts b/source/memory/summarizer-service.ts new file mode 100644 index 000000000..8b9edd558 --- /dev/null +++ b/source/memory/summarizer-service.ts @@ -0,0 +1,510 @@ +import {getSemanticMemoryEnabled} from '@/config/preferences'; +import type {Message} from '@/types/core'; +import { + type SemanticMemory, + SemanticMemoryManager, +} from './semantic-memory-manager'; + +export interface RememberMemoryInput { + content: string; + category?: string; + sourceSessionId?: string; +} + +export type MemorySourceType = 'explicit-user' | 'conversation-inferred'; + +export interface MemoryProposal { + content: string; + category: string; + sourceType: MemorySourceType; + evidence: { + userMessages: string[]; + assistantMessages: string[]; + }; + warnings: string[]; +} + +const MAX_CANDIDATES_PER_MESSAGE = 3; +const MAX_EVIDENCE_LENGTH = 160; + +/** + * How far back `/memory propose` scans. Proposals are reviewed by eye against a + * printed, numbered list, so an unbounded scan over a long session produces a + * list nobody reads. Both limits keep the newest turns. + */ +export const MAX_SCANNED_MESSAGES = 40; +export const MAX_PROPOSALS = 20; + +export const REVERSAL_WARNING = 'Possible assistant position reversal.'; +const INFERRED_WARNING = + 'Inferred from conversation, no explicit user statement.'; + +function truncateEvidence(content: string): string { + const collapsed = content.replaceAll(/\s+/gu, ' ').trim(); + if (collapsed.length <= MAX_EVIDENCE_LENGTH) return collapsed; + return `${collapsed.slice(0, MAX_EVIDENCE_LENGTH)}…`; +} + +const CATEGORY_RULES: Array<{category: string; pattern: RegExp}> = [ + { + category: 'bugFix', + pattern: /\b(bug|fix|fixed|regression|failure|failed|failing|flake)\b/i, + }, + { + category: 'refactor', + pattern: /\b(refactor|migration|migrate|migrated|rewrite)\b/i, + }, + { + category: 'todo', + pattern: /\b(todo|follow up|later|defer|deferred|unresolved)\b/i, + }, + { + category: 'architecture', + pattern: + /\b(architecture|architectural|adapter|middleware|provider|database|storage|schema|abstraction)\b/i, + }, + { + category: 'codingStyle', + pattern: + /\b(style|convention|format|formatting|naming|camel ?case|lint)\b/i, + }, +]; + +/** + * Openers a model reaches for when conceding. Deliberately broader than a + * handful of stock phrases: a concession phrased "Agreed on reflection" is the + * same event as one phrased "You're right", and only one of them was previously + * detectable. + */ +const AGREEMENT_OPENER_PATTERN = + /^\s*[^a-z0-9]*(you(?:'|’)?re\s+(?:right|correct)|you\s+are\s+(?:right|correct)|good\s+point|fair\s+(?:enough|point)|that\s+makes\s+sense|agreed|i\s+agree|on\s+reflection|point\s+taken|my\s+mistake|i\s+was\s+wrong|apologies|sorry,\s+you)/i; + +/** + * Signals that a user turn carried real evidence rather than bare preference. + * + * A path needs a genuine path shape - a leading `/`, `./` or `~/`, two or more + * segments, or a known file extension. A single bare slash does not count, so + * ordinary prose like "auth/login" no longer suppresses detection. + */ +const CODE_FENCE_PATTERN = /```|~~~/; +const INLINE_CODE_PATTERN = /`[^`]+`/; +const PATH_PATTERN = + /(?:^|[\s('"])(?:~\/|\.{1,2}\/|\/)[\w.-]+|[\w.-]+\/[\w.-]+\/[\w.-]+|\b[\w-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|ya?ml|md|py|rb|go|rs|java|html|css|scss|toml|sh|sql)\b/; +const ERROR_OUTPUT_PATTERN = + /\b(error:|exception|traceback|stack\s?trace|failed\s+with|exit\s+code|ENOENT|undefined is not|cannot read)\b/i; +const CODE_IDENTIFIER_PATTERN = + /\b[A-Za-z_$][\w$]*\([^)]*\)|\b[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]+\b/; + +function hasTechnicalEvidence(content: string): boolean { + return ( + CODE_FENCE_PATTERN.test(content) || + INLINE_CODE_PATTERN.test(content) || + PATH_PATTERN.test(content) || + ERROR_OUTPUT_PATTERN.test(content) || + CODE_IDENTIFIER_PATTERN.test(content) + ); +} + +const LOOKAHEAD_NARRATION = + /^(?:let me |i(?:'m going to |'ll | will )?)(?:re-)?(?:read|look at|check|inspect|open|examine)\b/i; + +/** Tool calls (with or without narration) and "I'll look at the file" turns. */ +function isPlumbingTurn(message: Message): boolean { + if ((message.tool_calls?.length ?? 0) > 0) return true; + return LOOKAHEAD_NARRATION.test(message.content.trim()); +} + +const NEGATION_PATTERN = + /\b(not|never|no|avoid|isn'?t|aren'?t|won'?t|shouldn'?t|doesn'?t|don'?t|instead\s+of|rather\s+than|no\s+longer)\b/i; + +/** + * Opposed term pairs used to spot a stance flip between two assistant turns. + * Each entry is one axis; which side a turn *asserts* is decided by whether the + * term is negated, so "lazily, not eagerly" asserts lazy rather than both. + */ +const OPPOSED_TERM_PAIRS: Array<[RegExp, RegExp]> = [ + [/\blazil?y?\b|\blazy\b/i, /\beager(?:ly)?\b/i], + [/\bsynchronous(?:ly)?\b|\bsync\b/i, /\basynchronous(?:ly)?\b|\basync\b/i], + [/\benabled?\b/i, /\bdisabled?\b/i], + [/\bincluded?\b/i, /\bexcluded?\b/i], + [/\badded?\b/i, /\bremoved?\b/i], + [/\bmutable\b/i, /\bimmutable\b/i], + [/\bexplicit(?:ly)?\b/i, /\bimplicit(?:ly)?\b/i], + [/\bstatic(?:ally)?\b/i, /\bdynamic(?:ally)?\b/i], + [/\bbefore\b/i, /\bafter\b/i], + [/\bsingle\b/i, /\bmultiple\b/i], + [/\bshould\b/i, /\bshould\s?n[o']?t\b/i], +]; + +const NEGATION_LOOKBEHIND = 24; + +/** True when `pattern` matches `text` at a position not preceded by a negation. */ +function assertsTerm(text: string, pattern: RegExp): boolean { + let from = 0; + while (from < text.length) { + const match = text.slice(from).match(pattern); + if (!match || match.index === undefined) return false; + const start = from + match.index; + const preceding = text.slice( + Math.max(0, start - NEGATION_LOOKBEHIND), + start, + ); + if (!NEGATION_PATTERN.test(preceding)) return true; + from = start + Math.max(match[0].length, 1); + } + return false; +} + +/** Whole-word match for a token already split out of the text (no regex). */ +function assertsTermWord(text: string, term: string): boolean { + const haystack = text.toLowerCase(); + let from = 0; + while (from <= haystack.length - term.length) { + const start = haystack.indexOf(term, from); + if (start < 0) return false; + const before = start === 0 ? '' : haystack[start - 1]; + const after = haystack[start + term.length] ?? ''; + const bounded = + (start === 0 || /[^a-z0-9]/i.test(before ?? '')) && + (after === '' || /[^a-z0-9]/i.test(after)); + if (bounded) { + const preceding = text.slice( + Math.max(0, start - NEGATION_LOOKBEHIND), + start, + ); + if (!NEGATION_PATTERN.test(preceding)) return true; + } + from = start + 1; + } + return false; +} + +function contentTerms(value: string): Set { + return new Set( + value + .toLowerCase() + .split(/[^a-z0-9]+/u) + .filter(part => part.length > 2 && !TOPIC_STOPWORDS.has(part)), + ); +} + +const TOPIC_STOPWORDS = new Set([ + 'the', + 'and', + 'but', + 'for', + 'not', + 'you', + 'are', + 'was', + 'were', + 'this', + 'that', + 'with', + 'have', + 'has', + 'had', + 'will', + 'would', + 'should', + 'could', + 'can', + 'its', + 'your', + 'our', + 'their', + 'them', + 'they', + 'from', + 'into', + 'been', + 'right', + 'correct', + 'agreed', + 'agree', + 'point', + 'sense', + 'makes', + 'reflection', +]); + +const MIN_SHARED_TOPIC_TERMS = 2; + +/** + * True when `later` reverses a stance `earlier` took on the same subject. + * + * Requires topical overlap first, then either a flip along one of the opposed + * term axes or a negation asymmetry on a shared term. This is a heuristic + * feeding a *warning* on a proposal the user is already reviewing by hand, so it + * is tuned to tolerate false positives rather than miss real concessions. + */ +function isContradiction(earlier: string, later: string): boolean { + const earlierTerms = contentTerms(earlier); + const laterTerms = contentTerms(later); + const shared = [...laterTerms].filter(term => earlierTerms.has(term)); + if (shared.length < MIN_SHARED_TOPIC_TERMS) return false; + + for (const [sideA, sideB] of OPPOSED_TERM_PAIRS) { + const earlierA = assertsTerm(earlier, sideA); + const earlierB = assertsTerm(earlier, sideB); + const laterA = assertsTerm(later, sideA); + const laterB = assertsTerm(later, sideB); + if ( + (earlierA && !earlierB && laterB && !laterA) || + (earlierB && !earlierA && laterA && !laterB) + ) { + return true; + } + } + + return shared.some( + term => assertsTermWord(earlier, term) !== assertsTermWord(later, term), + ); +} + +export class SummarizerService { + constructor( + private readonly memoryManager = new SemanticMemoryManager(), + private readonly isMemoryEnabled: () => boolean = getSemanticMemoryEnabled, + ) {} + + async remember(input: RememberMemoryInput): Promise { + this.assertMemoryWritesEnabled(); + + const content = input.content.trim(); + if (!content) { + throw new Error('Memory content cannot be empty'); + } + + return this.memoryManager.addMemory({ + content, + category: input.category + ? toCamelCaseCategory(input.category) + : inferMemoryCategory(content), + sourceSessionId: input.sourceSessionId, + }); + } + + async acceptProposal( + proposal: Pick, + sourceSessionId?: string, + ): Promise { + this.assertMemoryWritesEnabled(); + + return this.memoryManager.addMemory({ + content: proposal.content, + category: proposal.category, + sourceSessionId, + }); + } + + private assertMemoryWritesEnabled(): void { + if (!this.isMemoryEnabled()) { + throw new Error( + 'Semantic memory is turned off. Enable it in /settings to save memories.', + ); + } + } + + proposeMemoriesFromMessages(messages: Message[]): MemoryProposal[] { + const proposals = new Map< + string, + { + content: string; + category: string; + sourceRole: 'user' | 'assistant'; + userTurns: string[]; + assistantTurns: string[]; + warnings: string[]; + } + >(); + + // Only the most recent turns are scanned; older ones would swell the + // printed list past what anyone reviews by eye. + const firstScanned = Math.max(0, messages.length - MAX_SCANNED_MESSAGES); + + for (let i = firstScanned; i < messages.length; i++) { + const message = messages[i]; + if (!message || (message.role !== 'user' && message.role !== 'assistant')) + continue; + + const candidates = splitMemoryCandidates(message.content).slice( + 0, + MAX_CANDIDATES_PER_MESSAGE, + ); + + for (const candidate of candidates) { + const category = inferMemoryCategory(candidate); + if (category === 'project') continue; + + const key = candidate.toLowerCase(); + let entry = proposals.get(key); + if (!entry) { + entry = { + content: candidate, + category, + sourceRole: message.role, + userTurns: [], + assistantTurns: [], + warnings: [], + }; + proposals.set(key, entry); + } + + const snippet = truncateEvidence(message.content); + if (message.role === 'user') { + entry.userTurns.push(snippet); + entry.sourceRole = 'user'; + entry.warnings = entry.warnings.filter( + warning => warning !== REVERSAL_WARNING, + ); + } else { + entry.assistantTurns.push(snippet); + } + + if ( + message.role === 'assistant' && + entry.sourceRole !== 'user' && + !entry.warnings.includes(REVERSAL_WARNING) && + this.isAssistantReversal(messages, i) + ) { + entry.warnings.push(REVERSAL_WARNING); + } + } + } + + return [...proposals.values()].slice(-MAX_PROPOSALS).map(entry => { + const sourceType: MemorySourceType = + entry.sourceRole === 'user' ? 'explicit-user' : 'conversation-inferred'; + const warnings = [...entry.warnings]; + + if ( + sourceType === 'conversation-inferred' && + !warnings.includes(INFERRED_WARNING) + ) { + warnings.push(INFERRED_WARNING); + } + + return { + content: entry.content, + category: entry.category, + sourceType, + evidence: { + userMessages: entry.userTurns, + assistantMessages: entry.assistantTurns, + }, + warnings, + }; + }); + } + + /** + * Flags an assistant turn that reads as a concession to social pressure + * rather than to evidence. + * + * Shape, following the original report: + * 1. the turn is preceded by a user turn carrying no code, path or error + * output - i.e. pushback with no new information, and + * 2. the turn either contradicts an earlier assistant turn on the same + * subject, or opens with an agreement phrase. + * + * Tool-call turns and tool results are stepped over in (1); in a real + * agentic session the assistant reads files between almost every pair of + * user turns, and bailing on those made the check near-unreachable. + */ + private isAssistantReversal( + messages: Message[], + assistantIndex: number, + ): boolean { + const assistantMsg = messages[assistantIndex]; + if (!assistantMsg) return false; + + let userIndex = -1; + for (let i = assistantIndex - 1; i >= 0; i--) { + const m = messages[i]; + if (!m) continue; + if (m.role === 'tool') continue; + if (m.role === 'assistant') { + if (isPlumbingTurn(m)) continue; + return false; + } + if (m.role === 'user') { + userIndex = i; + break; + } + } + + const userMsg = userIndex >= 0 ? messages[userIndex] : undefined; + if (!userMsg) return false; + if (hasTechnicalEvidence(userMsg.content)) return false; + + if (AGREEMENT_OPENER_PATTERN.test(assistantMsg.content)) return true; + + return this.contradictsEarlierAssistantTurn( + messages, + assistantIndex, + userIndex, + ); + } + + /** True when any assistant turn before `userIndex` took the opposite stance. */ + private contradictsEarlierAssistantTurn( + messages: Message[], + assistantIndex: number, + userIndex: number, + ): boolean { + const later = messages[assistantIndex]; + if (!later) return false; + + for (let i = userIndex - 1; i >= 0; i--) { + const earlier = messages[i]; + if (!earlier || earlier.role !== 'assistant') continue; + if (isPlumbingTurn(earlier)) continue; + if (isContradiction(earlier.content, later.content)) return true; + } + + return false; + } +} + +export function inferMemoryCategory(content: string): string { + for (const rule of CATEGORY_RULES) { + if (rule.pattern.test(content)) return rule.category; + } + + return 'project'; +} + +export function toCamelCaseCategory(value: string): string { + const parts = value + .trim() + .toLowerCase() + .split(/[^a-z0-9]+/u) + .filter(Boolean); + + if (parts.length === 0) return 'project'; + + return parts + .map((part, index) => + index === 0 ? part : `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`, + ) + .join(''); +} + +function splitMemoryCandidates(content: string): string[] { + return content + .split(/\n+/u) + .map(part => + part + .trim() + .replace(/^[-*]\s+/u, '') + .replace(/^\d+\.\s+/u, ''), + ) + .filter( + part => + part.length >= 12 && + part.length <= 300 && + !part.endsWith('?') && + /[.!:]$/u.test(part), + ); +} diff --git a/source/plain/shell.spec.ts b/source/plain/shell.spec.ts index df10dfcf2..16d0699c8 100644 --- a/source/plain/shell.spec.ts +++ b/source/plain/shell.spec.ts @@ -652,6 +652,86 @@ test.serial( }, ); +test.serial( + "text mode recalls relevant project memories and surfaces the count on stderr", + async (t) => { + const shutdown: CapturedShutdown = { code: null }; + const stdout = capturingStdout(); + const stderr = capturingStderr(); + const calls: Array<{ systemPrompt: string; query: string }> = []; + try { + await runPlainShell({ + prompt: "refactor the auth module", + developmentMode: "auto-accept", + trustDirectory: true, + outputFormat: "text", + deps: baseDeps({ + initializePlain: makeFakeInitializePlain(), + runPlainConversation: makeFakeRunPlainConversation({ + kind: "success", + finalText: "all done", + reasoning: null, + toolCalls: [], + }), + getShutdownManager: makeFakeShutdownManager(shutdown), + appendRelevantProjectContextWithCount: async (systemPrompt, query) => { + calls.push({ systemPrompt, query }); + return { + systemPrompt: `${systemPrompt}\n\n## Project Context\n\n- Auth uses Clerk.`, + memoryCount: 2, + }; + }, + }), + }); + } finally { + stdout.restore(); + stderr.restore(); + } + + t.is(calls.length, 1); + t.is(calls[0]?.query, "refactor the auth module"); + t.regex(stderr.get(), /Recalling 2 project memories\.\.\./); + t.is(shutdown.code, 0); + }, +); + +test.serial( + "text mode stays silent when no relevant memories are recalled", + async (t) => { + const shutdown: CapturedShutdown = { code: null }; + const stdout = capturingStdout(); + const stderr = capturingStderr(); + try { + await runPlainShell({ + prompt: "do the thing", + developmentMode: "auto-accept", + trustDirectory: true, + outputFormat: "text", + deps: baseDeps({ + initializePlain: makeFakeInitializePlain(), + runPlainConversation: makeFakeRunPlainConversation({ + kind: "success", + finalText: "all done", + reasoning: null, + toolCalls: [], + }), + getShutdownManager: makeFakeShutdownManager(shutdown), + appendRelevantProjectContextWithCount: async (systemPrompt) => ({ + systemPrompt, + memoryCount: 0, + }), + }), + }); + } finally { + stdout.restore(); + stderr.restore(); + } + + t.false(stderr.get().includes("Recalling")); + t.is(shutdown.code, 0); + }, +); + test.serial( "text error outcome writes the error message to stderr with exit code 1", async (t) => { diff --git a/source/plain/shell.ts b/source/plain/shell.ts index 188cbce21..6c57281d0 100644 --- a/source/plain/shell.ts +++ b/source/plain/shell.ts @@ -6,12 +6,18 @@ import { artifactManager, } from '@/artifacts/artifact-manager'; import {getAppConfig} from '@/config/index'; -import {loadPreferences, savePreferences} from '@/config/preferences'; +import { + loadPreferences, + resolveProjectContextPreferences, + savePreferences, +} from '@/config/preferences'; import {resolveTune} from '@/config/tune'; import { TOOL_APPROVAL_REQUIRED_KIND, TOOL_APPROVAL_REQUIRED_PREFIX, } from '@/constants'; +import {appendRelevantProjectContextWithCount} from '@/memory/project-context'; +import {SemanticMemoryManager} from '@/memory/semantic-memory-manager'; import {runPlainConversation} from '@/plain/conversation'; import {initializePlain} from '@/plain/initialize'; import { @@ -51,6 +57,7 @@ export interface RunPlainShellDeps { getShutdownManager: typeof getShutdownManager; loadPreferences: typeof loadPreferences; savePreferences: typeof savePreferences; + appendRelevantProjectContextWithCount: typeof appendRelevantProjectContextWithCount; artifacts: Pick< ArtifactManager, | 'cleanupStaleEphemeralSessions' @@ -65,6 +72,7 @@ const defaultDeps: RunPlainShellDeps = { getShutdownManager, loadPreferences, savePreferences, + appendRelevantProjectContextWithCount, artifacts: artifactManager, }; @@ -168,12 +176,25 @@ export async function runPlainShell( const toolsForPrompt = toolsDisabled ? toolManager.getFilteredTools(availableNames) : {}; - const systemContent = appendToolDefinitionsToPrompt( + const toolPrompt = appendToolDefinitionsToPrompt( basePrompt, toolsDisabled, fallbackToolFormat, toolsForPrompt, ); + + const projectContext = await deps.appendRelevantProjectContextWithCount( + toolPrompt, + prompt, + new SemanticMemoryManager(), + resolveProjectContextPreferences(deps.loadPreferences()), + ); + const systemContent = projectContext.systemPrompt; + if (projectContext.memoryCount > 0) { + writeStatus( + `Recalling ${projectContext.memoryCount} project memor${projectContext.memoryCount === 1 ? 'y' : 'ies'}...`, + ); + } setLastBuiltPrompt(systemContent); const systemMessage: Message = {role: 'system', content: systemContent}; diff --git a/source/repo-map/index.ts b/source/repo-map/index.ts index 429d46d54..081cd5e75 100644 --- a/source/repo-map/index.ts +++ b/source/repo-map/index.ts @@ -312,40 +312,42 @@ async function scanFiles( const files: ScannedFile[] = []; let truncated = false; - await walkProjectEntries(cwd, undefined, async entry => { - if (entry.isDirectory) { - return false; - } - const language = languageFor(entry.relativePath); - if (!language) { - return false; - } - // Checked before the push so a repo holding exactly `maxFiles` indexable - // files is not reported as truncated. - if (files.length >= maxFiles) { - truncated = true; - return true; - } + const walkResult = await walkProjectEntries( + cwd, + undefined, + async entry => { + const language = languageFor(entry.relativePath); + if (!language) { + return false; + } + // Checked before the push so exactly `maxFiles` files isn't reported as truncated. + if (files.length >= maxFiles) { + truncated = true; + return true; + } - let source: string; - try { - source = await readFile(entry.absolutePath, 'utf-8'); - } catch { - return false; - } - if (source.length > maxFileBytes) { - return false; - } + let source: string; + try { + source = await readFile(entry.absolutePath, 'utf-8'); + } catch { + return false; + } + if (source.length > maxFileBytes) { + return false; + } - const stripped = stripNoise(source, language); - files.push({ - path: entry.relativePath.replace(/\\/g, '/'), - definitions: extractDefinitions(stripped, language), - references: countReferences(stripped, language), - }); + const stripped = stripNoise(source, language); + files.push({ + path: entry.relativePath.replace(/\\/g, '/'), + definitions: extractDefinitions(stripped, language), + references: countReferences(stripped, language), + }); - return false; - }); + return false; + }, + {includeDirectories: false}, + ); + truncated = truncated || walkResult.truncated; return {files, truncated}; } diff --git a/source/subagents/subagent-executor.spec.ts b/source/subagents/subagent-executor.spec.ts index d6cc76bc3..53ebb2491 100644 --- a/source/subagents/subagent-executor.spec.ts +++ b/source/subagents/subagent-executor.spec.ts @@ -7,6 +7,7 @@ import { setSessionContextLimit, } from '@/models/index'; import {SubagentLoader, getSubagentLoader} from './subagent-loader.js'; +import type {MemoryFinder} from '@/memory/project-context'; import type {ToolManager} from '@/tools/tool-manager'; import type { LLMClient, @@ -33,7 +34,7 @@ function createMockToolManager( handler: ( args: unknown, options?: ToolExecutionContext, - ) => Promise; + ) => Promise; readOnly: boolean; needsApproval?: boolean; } @@ -185,6 +186,42 @@ test.serial('executes tool calls and returns final response', async t => { t.is(result.output, 'Found the file with 100 lines'); }); +test.serial('stringifies structured tool output without llmContent', async t => { + const toolManager = createMockToolManager({ + read_file: { + handler: async () => ({someField: 'value'}), + readOnly: true, + }, + }); + const toolResults: Message[] = []; + const client = createMockClient( + [ + { + content: '', + tool_calls: [{ + id: 'tc-structured', + function: {name: 'read_file', arguments: '{}'}, + }], + }, + {content: 'The tool returned structured data.'}, + ], + messages => { + const toolMessage = messages.find(message => message.role === 'tool'); + if (toolMessage) toolResults.push(toolMessage); + }, + ); + + const executor = new SubagentExecutor(toolManager, client); + const result = await executor.execute({ + subagent_type: 'explore', + description: 'Read structured data', + }); + + t.true(result.success); + t.is(result.output, 'The tool returned structured data.'); + t.is(toolResults[0]?.content, '{"someField":"value"}'); +}); + test.serial('forwards the parent execution context to subagent tools', async t => { let receivedContext: ToolExecutionContext | undefined; const toolManager = createMockToolManager({ @@ -1137,6 +1174,82 @@ test.serial('a subagent cannot execute a tool outside its allow-list', async t = t.regex(toolResult, /not available to this subagent/); }); +test.serial('injects relevant project memories into the subagent system prompt', async t => { + const toolManager = createMockToolManager(); + const client = createMockClient([{content: 'Here are the results'}]); + let systemPrompt = ''; + const originalChat = client.chat.bind(client); + client.chat = async (messages: Message[], tools, callbacks, signal, modeOverrides) => { + systemPrompt = String(messages[0]?.content ?? ''); + return originalChat(messages, tools, callbacks, signal, modeOverrides); + }; + + const memoryFinder: MemoryFinder = { + findRelevantMemories: async () => [ + { + id: 'mem-1', + content: 'Auth flow uses Clerk and avoids middleware.', + category: 'architecture', + timestamp: '2026-01-01T00:00:00.000Z', + }, + ], + }; + + const executor = new SubagentExecutor(toolManager, client, process.cwd(), 'normal', { + memoryFinder, + projectContextOptions: {semanticMemoryEnabled: true}, + }); + + const result = await executor.execute({ + subagent_type: 'explore', + description: 'Refactor Clerk auth', + }); + + t.true(result.success); + t.true(systemPrompt.includes('## Project Context')); + t.true(systemPrompt.includes('Auth flow uses Clerk and avoids middleware.')); +}); + +test.serial('skips subagent memory recall when semantic memory is disabled', async t => { + const toolManager = createMockToolManager(); + const client = createMockClient([{content: 'Here are the results'}]); + let systemPrompt = ''; + const originalChat = client.chat.bind(client); + client.chat = async (messages: Message[], tools, callbacks, signal, modeOverrides) => { + systemPrompt = String(messages[0]?.content ?? ''); + return originalChat(messages, tools, callbacks, signal, modeOverrides); + }; + + let finderCalls = 0; + const memoryFinder: MemoryFinder = { + findRelevantMemories: async () => { + finderCalls++; + return [ + { + id: 'mem-1', + content: 'Auth flow uses Clerk and avoids middleware.', + category: 'architecture', + timestamp: '2026-01-01T00:00:00.000Z', + }, + ]; + }, + }; + + const executor = new SubagentExecutor(toolManager, client, process.cwd(), 'normal', { + memoryFinder, + projectContextOptions: {semanticMemoryEnabled: false}, + }); + + const result = await executor.execute({ + subagent_type: 'explore', + description: 'Refactor Clerk auth', + }); + + t.true(result.success); + t.is(finderCalls, 0); + t.false(systemPrompt.includes('## Project Context')); +}); + test.serial( 'caps subagent history before client.chat without starting on a tool row', async t => { @@ -1283,4 +1396,3 @@ test.serial('compacts subagent history after a tool turn', async t => { resetSessionContextLimit(); } }); - diff --git a/source/subagents/subagent-executor.ts b/source/subagents/subagent-executor.ts index 775a2b977..2d867d10d 100644 --- a/source/subagents/subagent-executor.ts +++ b/source/subagents/subagent-executor.ts @@ -7,7 +7,14 @@ import {createLLMClient} from '@/client-factory'; import {getAppConfig, getRetryLimits} from '@/config/index'; +import {getProjectContextPreferences} from '@/config/preferences'; import {computeToolCallSignature} from '@/hooks/chat-handler/utils/tool-signature'; +import { + appendRelevantProjectContextWithCount, + type MemoryFinder, + type ProjectContextOptions, +} from '@/memory/project-context'; +import {SemanticMemoryManager} from '@/memory/semantic-memory-manager'; import { appendSubagentTool, getSubagentProgress, @@ -85,17 +92,27 @@ export class SubagentExecutor { * that don't supply a resolver (plain shell, tests). */ private modeResolver?: () => DevelopmentMode; + private memoryFinder: MemoryFinder; + private projectContextOptions?: ProjectContextOptions; constructor( toolManager: ToolManager, parentClient: LLMClient, projectRoot: string = process.cwd(), parentMode: DevelopmentMode = 'normal', + options: { + memoryFinder?: MemoryFinder; + projectContextOptions?: ProjectContextOptions; + } = {}, ) { this.toolManager = toolManager; this.parentClient = parentClient; this.projectRoot = projectRoot; this.parentMode = parentMode; + this.memoryFinder = + options.memoryFinder ?? + new SemanticMemoryManager({cwd: this.projectRoot}); + this.projectContextOptions = options.projectContextOptions; } /** @@ -164,9 +181,18 @@ export class SubagentExecutor { const context = this.createSubagentContext(config, task); const filteredTools = this.filterTools(config); + const recalled = await appendRelevantProjectContextWithCount( + context.systemMessage, + this.buildTaskPrompt(task), + this.memoryFinder, + { + ...getProjectContextPreferences(), + ...this.projectContextOptions, + }, + ); const messages: Message[] = [ - {role: 'system', content: context.systemMessage}, + {role: 'system', content: recalled.systemPrompt}, ...context.initialMessages, ]; @@ -739,7 +765,10 @@ export class SubagentExecutor { }); // Subagents converse in text, so collapse structured output to its // text representation. - const content = typeof result === 'string' ? result : result.llmContent; + const content = + typeof result === 'string' + ? result + : (result.llmContent ?? JSON.stringify(result)); return truncateToolResult(content); } catch (error) { // Handler validation failures surface here too (the handler is diff --git a/source/tools/file-ops/diff-edit.spec.tsx b/source/tools/file-ops/diff-edit.spec.tsx index 54f57aa81..b4562ebbb 100644 --- a/source/tools/file-ops/diff-edit.spec.tsx +++ b/source/tools/file-ops/diff-edit.spec.tsx @@ -365,3 +365,43 @@ test('diff_edit description tells models not to wrap diff in code fences', t => /do not wrap.*code fence|code fence.*do not wrap/i, ); }); + +// `$$`, `$&`, "$`" and `$'` are substitution tokens to String.prototype.replace +// but ordinary characters in the shell scripts and CI YAML models edit. +test('diff_edit writes $ substitution tokens literally', async t => { + const filePath = await createTestFile( + 'dollars.sh', + '#!/bin/sh\necho "old"\nexit 0\n', + ); + const replacement = 'echo "pid=$$ match=$& pre=$` post=$\'"'; + + await executeDiffEdit({ + path: filePath, + diff: diffBlock('echo "old"', replacement), + }); + + t.is( + await readFile(filePath, 'utf-8'), + `#!/bin/sh\n${replacement}\nexit 0\n`, + ); +}); + +test('diff_edit keeps $ tokens literal across multiple blocks', async t => { + const filePath = await createTestFile( + 'multi.yml', + 'first: OLD_A\nsecond: OLD_B\n', + ); + + await executeDiffEdit({ + path: filePath, + diff: [ + diffBlock('first: OLD_A', 'first: "$&"'), + diffBlock('second: OLD_B', "second: \"$`$'\""), + ].join('\n\n'), + }); + + t.is( + await readFile(filePath, 'utf-8'), + 'first: "$&"\nsecond: "$`$\'"\n', + ); +}); diff --git a/source/tools/file-ops/diff-edit.tsx b/source/tools/file-ops/diff-edit.tsx index d31b08c39..c1bdc311c 100644 --- a/source/tools/file-ops/diff-edit.tsx +++ b/source/tools/file-ops/diff-edit.tsx @@ -10,6 +10,7 @@ import type {NanocoderToolExport} from '@/types/core'; import {jsonSchema, tool} from '@/types/core'; import {formatError} from '@/utils/error-formatter'; import {getCachedFileContent, invalidateCache} from '@/utils/file-cache'; +import {replaceFirstLiteral} from '@/utils/literal-replace'; import {validatePath} from '@/utils/path-validators'; import {hasSeenFile, markFileSeen} from '@/utils/read-tracker'; import {createFileToolApproval} from '@/utils/tool-approval'; @@ -162,7 +163,7 @@ function applyBlocks(fileContent: string, blocks: DiffEditBlock[]): string { ); } - newContent = newContent.replace(block.search, block.replace); + newContent = replaceFirstLiteral(newContent, block.search, block.replace); }); return newContent; diff --git a/source/tools/file-ops/string-replace-preview.tsx b/source/tools/file-ops/string-replace-preview.tsx index 04fc87af4..5f696ff34 100644 --- a/source/tools/file-ops/string-replace-preview.tsx +++ b/source/tools/file-ops/string-replace-preview.tsx @@ -4,6 +4,7 @@ import {Box, Text} from 'ink'; import React from 'react'; import ToolMessage from '@/components/tool-message'; import {getColors} from '@/config/index'; +import {getSyntaxTheme} from '@/config/themes'; import {DEFAULT_TERMINAL_COLUMNS} from '@/constants'; import type {Colors} from '@/types/index'; import {truncateAnsi} from '@/utils/ansi-truncate'; @@ -174,7 +175,7 @@ export async function formatStringReplacePreview( let displayLine: string; try { displayLine = truncateAnsi( - highlight(line, {language, theme: 'default'}), + highlight(line, {language, theme: getSyntaxTheme(themeColors)}), availableWidth, ); } catch { @@ -338,7 +339,7 @@ export async function formatStringReplacePreview( let displayLine: string; try { displayLine = truncateAnsi( - highlight(line, {language, theme: 'default'}), + highlight(line, {language, theme: getSyntaxTheme(themeColors)}), availableWidth, ); } catch { diff --git a/source/tools/file-ops/string-replace.spec.tsx b/source/tools/file-ops/string-replace.spec.tsx index 2cd78f99f..d1eaab1b6 100644 --- a/source/tools/file-ops/string-replace.spec.tsx +++ b/source/tools/file-ops/string-replace.spec.tsx @@ -1029,3 +1029,77 @@ test('string_replace formatter: normalizes tabs to 2 spaces', async t => { t.regex(output!, /string_replace/); t.regex(output!, /Path:/); }); + +// ============================================================================ +// Literal Replacement Tests +// ============================================================================ + +// `$$`, `$&`, "$`" and `$'` are ordinary characters in shell scripts, +// Makefiles and CI YAML, but they are substitution tokens to +// String.prototype.replace. The replacement must land byte for byte. +const DOLLAR_TOKENS = 'echo "pid=$$ match=$& pre=$` post=$\'"'; + +test('string_replace: writes $ substitution tokens literally', async t => { + const filePath = await createTestFile( + 'dollars.sh', + '#!/bin/sh\necho "old"\nexit 0\n', + ); + + await executeStringReplace({ + path: filePath, + old_str: 'echo "old"', + new_str: DOLLAR_TOKENS, + }); + + t.is( + await readFile(filePath, 'utf-8'), + `#!/bin/sh\n${DOLLAR_TOKENS}\nexit 0\n`, + ); +}); + +test('string_replace: $` and $\' do not splice the rest of the file in', async t => { + const filePath = await createTestFile( + 'halves.txt', + 'BEFORE\nTARGET\nAFTER\n', + ); + + await executeStringReplace({ + path: filePath, + old_str: 'TARGET', + new_str: "$`$'", + }); + + const newContent = await readFile(filePath, 'utf-8'); + t.is(newContent, "BEFORE\n$`$'\nAFTER\n"); + t.false(newContent.includes('BEFORE\nBEFORE')); +}); + +test('string_replace: $ tokens in old_str still match and are removable', async t => { + const filePath = await createTestFile( + 'makefile', + 'all:\n\t@echo $$HOME $(shell pwd)\n', + ); + + await executeStringReplace({ + path: filePath, + old_str: '@echo $$HOME $(shell pwd)', + new_str: '@echo $$PWD', + }); + + t.is(await readFile(filePath, 'utf-8'), 'all:\n\t@echo $$PWD\n'); +}); + +test('string_replace: numbered group tokens stay literal', async t => { + const filePath = await createTestFile('groups.sh', 'run "old"\n'); + + await executeStringReplace({ + path: filePath, + old_str: 'run "old"', + new_str: 'printf "%s\n" "$1" "$2" "$<" "$@"', + }); + + t.is( + await readFile(filePath, 'utf-8'), + 'printf "%s\n" "$1" "$2" "$<" "$@"\n', + ); +}); diff --git a/source/tools/file-ops/string-replace.tsx b/source/tools/file-ops/string-replace.tsx index 1a0027e04..f08da0dd3 100644 --- a/source/tools/file-ops/string-replace.tsx +++ b/source/tools/file-ops/string-replace.tsx @@ -8,6 +8,7 @@ import type {NanocoderToolExport} from '@/types/core'; import {jsonSchema, tool} from '@/types/core'; import {formatError} from '@/utils/error-formatter'; import {getCachedFileContent, invalidateCache} from '@/utils/file-cache'; +import {replaceFirstLiteral} from '@/utils/literal-replace'; import {validatePath} from '@/utils/path-validators'; import {hasSeenFile, markFileSeen} from '@/utils/read-tracker'; import {createFileToolApproval} from '@/utils/tool-approval'; @@ -88,7 +89,7 @@ const executeStringReplace = async ( ); } - const newContent = fileContent.replace(old_str, new_str); + const newContent = replaceFirstLiteral(fileContent, old_str, new_str); await writeFile(absPath, newContent, 'utf-8'); invalidateCache(absPath); // The model now knows the file's current contents, so a follow-up edit is @@ -162,7 +163,7 @@ const stringReplaceFormatter = async ( const occurrences = fileContent.split(old_str).length - 1; if (occurrences === 1) { - const newContent = fileContent.replace(old_str, new_str); + const newContent = replaceFirstLiteral(fileContent, old_str, new_str); const changeId = sendFileChangeToVSCode( absPath, diff --git a/source/tools/file-ops/write-file.tsx b/source/tools/file-ops/write-file.tsx index 59de933d3..90aba99da 100644 --- a/source/tools/file-ops/write-file.tsx +++ b/source/tools/file-ops/write-file.tsx @@ -5,6 +5,7 @@ import {highlight} from 'cli-highlight'; import {Box, Text} from 'ink'; import React from 'react'; import ToolMessage from '@/components/tool-message'; +import {getSyntaxTheme} from '@/config/themes'; import {DEFAULT_TERMINAL_COLUMNS} from '@/constants'; import {ThemeContext} from '@/hooks/useTheme'; import {getSafeSessionCwd} from '@/services/session-cwd'; @@ -136,7 +137,10 @@ const WriteFileFormatter = React.memo(({args}: {args: WriteFileArgs}) => { const language = getLanguageFromExtension(ext); try { - const highlighted = highlight(line, {language, theme: 'default'}); + const highlighted = highlight(line, { + language, + theme: getSyntaxTheme(colors), + }); const truncated = truncateAnsi(highlighted, availableWidth); return ( diff --git a/source/types/app.ts b/source/types/app.ts index 20a81c687..6c3086acf 100644 --- a/source/types/app.ts +++ b/source/types/app.ts @@ -59,4 +59,5 @@ export interface MessageSubmissionOptions { developmentMode?: DevelopmentMode; lastApiUsage?: ApiUsageSnapshot | null; apiCallHistory?: ApiCallRecord[]; + sessionId?: string; } diff --git a/source/types/commands.ts b/source/types/commands.ts index 6fecf234e..8b4e6988a 100644 --- a/source/types/commands.ts +++ b/source/types/commands.ts @@ -22,6 +22,7 @@ export interface Command { developmentMode?: import('@/types/core').DevelopmentMode; lastApiUsage?: ApiUsageSnapshot | null; apiCallHistory?: ApiCallRecord[]; + sessionId?: string; }, ) => Promise; } diff --git a/source/types/config.ts b/source/types/config.ts index 7eec7b3eb..cda914987 100644 --- a/source/types/config.ts +++ b/source/types/config.ts @@ -443,6 +443,12 @@ export interface UserPreferences { }; lastUpdateCheck?: number; selectedTheme?: ThemePreset; + /** + * Theme whose palette colours syntax highlighting in code blocks, diffs, and + * file previews. Defaults to `selectedTheme`; set it only to give code a + * palette of its own. An unknown name falls back to `selectedTheme`. + */ + syntaxTheme?: ThemePreset; trustedDirectories?: string[]; titleShape?: TitleShape; nanocoderShape?: NanocoderShape; @@ -458,6 +464,12 @@ export interface UserPreferences { */ showUsageFooter?: boolean; enablePromptScrubbing?: boolean; + /** Whether semantic memory is active. Default true to preserve existing behavior. */ + semanticMemoryEnabled?: boolean; + /** Max memories recalled into one prompt. Defaults and bounds live in project-context.ts. */ + semanticMemoryLimit?: number; + /** Approximate token ceiling for the injected Project Context block. */ + semanticMemoryTokenBudget?: number; /** * Interactive TUI screen mode. true (default): fullscreen on the * alternate screen buffer with in-app scrolling (wheel / PgUp / PgDn). diff --git a/source/types/markdown-parser.ts b/source/types/markdown-parser.ts index 41b15b242..b526dff75 100644 --- a/source/types/markdown-parser.ts +++ b/source/types/markdown-parser.ts @@ -1,7 +1,11 @@ import type {Colors as FullColors} from '@/types/ui'; -// Subset of Colors used by the markdown parser -export type Colors = Pick< +/** + * The palette subset used for rendering: the markdown parser, and the + * cli-highlight theme derived from it in `@/config/themes`. Named for the job + * rather than the source now that it is shared by both. + */ +export type RenderPalette = Pick< FullColors, | 'primary' | 'secondary' @@ -12,3 +16,9 @@ export type Colors = Pick< | 'text' | 'tool' >; + +/** + * @deprecated Prefer {@link RenderPalette}. Kept so the markdown-parser call + * sites and its re-export keep working without a rename sweep. + */ +export type Colors = RenderPalette; diff --git a/source/utils/file-autocomplete.ts b/source/utils/file-autocomplete.ts index 7e165b49a..b668e8c3c 100644 --- a/source/utils/file-autocomplete.ts +++ b/source/utils/file-autocomplete.ts @@ -31,12 +31,15 @@ async function getAllFiles(cwd: string): Promise { try { const allFiles: string[] = []; - await walkProjectEntries(cwd, undefined, entry => { - if (!entry.isDirectory) { + await walkProjectEntries( + cwd, + undefined, + entry => { allFiles.push(entry.relativePath.replace(/\\/g, '/')); - } - return false; - }); + return false; + }, + {includeDirectories: false}, + ); fileListCache = { files: allFiles, diff --git a/source/utils/file-search.spec.ts b/source/utils/file-search.spec.ts index 3038d1efc..3d11fdf56 100644 --- a/source/utils/file-search.spec.ts +++ b/source/utils/file-search.spec.ts @@ -1,13 +1,17 @@ -import {mkdirSync, rmSync, writeFileSync} from 'node:fs'; +import {chmodSync, mkdirSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {writeFile} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test from 'ava'; import { findMatchingPaths, + GLOB_TOKEN_CACHE_MAX_TOKENS, + globTokenCache, matchesGlob, searchProjectContents, SearchTimeoutError, + walkProjectEntries, } from './file-search'; function createTempDir(name: string): string { @@ -29,6 +33,98 @@ test('matchesGlob normalizes Windows-style separators in path and pattern', t => t.true(matchesGlob('src\\components\\Button.tsx', 'src\\**\\*.tsx')); }); +test('matchesGlob handles glob edge cases found during the regex-to-DP rewrite', t => { + // Leading '**/' can vanish entirely (zero directories). + t.true(matchesGlob('index.ts', '**/*.ts')); + t.true(matchesGlob('a/b/c/index.ts', '**/*.ts')); + // Embedded '**/' can also vanish entirely. + t.true(matchesGlob('a/index.ts', 'a/**/index.ts')); + t.true(matchesGlob('a/b/c/index.ts', 'a/**/index.ts')); + // Trailing '/**' requires a literal '/' to be present. + t.false(matchesGlob('a', 'a/**')); + t.true(matchesGlob('a/', 'a/**')); + t.true(matchesGlob('a/b', 'a/**')); + // '**' with no adjacent '/' still has slash-crossing power. + t.true(matchesGlob('x/xx/xxx', '*x**x')); + t.false(matchesGlob('a', '*a**a')); // needs two 'a's, only has one + t.true(matchesGlob('a/a', '*a**a')); + // Single '*' never crosses '/', even adjacent to '**'. + t.false(matchesGlob('a', 'a*/**')); + t.true(matchesGlob('ab/', 'a*/**')); +}); + +test('matchesGlob stays fast on a pattern shape that hangs a naive regex engine', t => { + // This shape hung for 20+ seconds against the old regex-based implementation. + const pathologicalPattern = `${'*a'.repeat(25)}b`; + const start = Date.now(); + const result = matchesGlob('a'.repeat(2000), pathologicalPattern); + t.true(Date.now() - start < 100); + t.false(result); +}); + +test('matchesGlob rejects a pattern longer than the sanity length cap', t => { + const error = t.throws(() => matchesGlob('a.ts', 'a'.repeat(1001))); + t.true(error instanceof Error); + t.regex(error?.message ?? '', /too long/); +}); + +test('matchesGlob rejects a pattern with too many brace-expansion combinations', t => { + // 200 sequential {a,b} groups hung for 30+ seconds before this cap existed. + const pattern = '{a,b}'.repeat(200); + const start = Date.now(); + const error = t.throws(() => matchesGlob('a'.repeat(400), pattern)); + t.true(Date.now() - start < 100); + t.true(error instanceof Error); + t.regex(error?.message ?? '', /too many brace-expansion combinations/); +}); + +test.serial( + 'globTokenCache stays bounded by total token count across many worst-case-sized entries', + t => { + // 6 sequential {aaaa,bbbb} groups is exactly MAX_BRACE_EXPANSIONS (2^6 = 64), padded near MAX_GLOB_PATTERN_LENGTH so each branch tokenizes to ~900+ tokens - a worst-case-sized entry. + const filler = 'a'.repeat(900); + const groups = '{aaaa,bbbb}'.repeat(6); + + for (let i = 0; i < 30; i++) { + const pattern = `p${i}_${filler}${groups}`; + matchesGlob('irrelevant/path.ts', pattern); + } + + t.true(globTokenCache.calculatedSize <= GLOB_TOKEN_CACHE_MAX_TOKENS); + }, +); + +test.serial( + 'matchesGlob reuses the cached tokenization for a repeated pattern', + t => { + const pattern = 'src/**/*.repeated-pattern-test.ts'; + matchesGlob('src/foo/repeated-pattern-test.ts', pattern); + t.true(globTokenCache.has(pattern)); + + const sizeBefore = globTokenCache.size; + matchesGlob('src/bar/repeated-pattern-test.ts', pattern); + t.is(globTokenCache.size, sizeBefore); + }, +); + +test.serial( + 'globTokenCache silently drops an entry whose own size exceeds the cache budget, and matchesGlob still works', + t => { + // Not reachable through matchesGlob itself (the two caps keep real patterns well under budget) - exercise the cache directly instead. + const oversized = [ + Array.from({length: GLOB_TOKEN_CACHE_MAX_TOKENS + 1}, () => ({ + type: 'literal' as const, + char: 'a', + })), + ]; + + globTokenCache.set('oversized-entry-test-key', oversized); + t.false(globTokenCache.has('oversized-entry-test-key')); + + t.true(matchesGlob('a.ts', '*.ts')); + }, +); + test.serial('findMatchingPaths returns files and directories cross-platform', async t => { const testDir = createTempDir('test-file-search-find-temp'); @@ -54,6 +150,486 @@ test.serial('findMatchingPaths returns files and directories cross-platform', as } }); +test.serial('findMatchingPaths finds an empty, nested directory', async t => { + const testDir = createTempDir('test-file-search-empty-dir-temp'); + + try { + mkdirSync(join(testDir, 'src', 'emptydir', 'nested', 'deeper'), { + recursive: true, + }); + writeFileSync(join(testDir, 'src', 'placeholder.ts'), 'export {};'); + + const shallow = await findMatchingPaths('emptydir', testDir, 50); + t.true(shallow.files.includes('src/emptydir')); + + const deep = await findMatchingPaths('deeper', testDir, 50); + t.true(deep.files.includes('src/emptydir/nested/deeper')); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } +}); + +test.serial( + 'walkProjectEntries with includeDirectories=false never reports a directory, even an empty one', + async t => { + const testDir = createTempDir('test-file-search-no-dirs-temp'); + + try { + mkdirSync(join(testDir, 'src', 'emptydir'), {recursive: true}); + writeFileSync(join(testDir, 'src', 'placeholder.ts'), 'export {};'); + + const entries: {relativePath: string; isDirectory: boolean}[] = []; + await walkProjectEntries( + testDir, + undefined, + entry => { + entries.push(entry); + return false; + }, + {includeDirectories: false}, + ); + + t.false(entries.some(e => e.isDirectory)); + t.true(entries.some(e => e.relativePath === 'src/placeholder.ts')); + t.false(entries.some(e => e.relativePath === 'src/emptydir')); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'walkProjectEntries reports truncated when the raw file-scan cap is hit', + async t => { + const testDir = createTempDir('test-file-search-raw-cap-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + for (let i = 0; i < 30; i++) { + writeFileSync(join(testDir, `f${i}.txt`), 'x'); + } + + const result = await walkProjectEntries( + testDir, + undefined, + () => false, + {maxRawFilesScanned: 5}, + ); + t.true(result.truncated); + + const untruncated = await walkProjectEntries( + testDir, + undefined, + () => false, + {maxRawFilesScanned: 1000}, + ); + t.false(untruncated.truncated); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'walkProjectEntries does not let stdout overshoot the raw file-scan cap within a single oversized chunk', + async t => { + const testDir = createTempDir('test-file-search-chunk-overshoot-temp'); + try { + mkdirSync(testDir, {recursive: true}); + // 200 files is enough for one stdout chunk to hold far more lines than the cap; batched async writes keep setup fast. + await Promise.all( + Array.from({length: 200}, (_, i) => + writeFile(join(testDir, `f${i}.txt`), 'x'), + ), + ); + let fileCount = 0; + const result = await walkProjectEntries( + testDir, + undefined, + entry => { + if (!entry.isDirectory) fileCount++; + return false; + }, + {includeDirectories: false, maxRawFilesScanned: 10}, + ); + t.is(fileCount, 10); + t.true(result.truncated); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'walkProjectEntries reports truncated when the empty-directory walk cap is hit, even with zero files', + async t => { + const testDir = createTempDir('test-file-search-dir-cap-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + // No files at all, so only the JS-side empty-directory recursion can trip a cap here. + for (let i = 0; i < 30; i++) { + mkdirSync(join(testDir, `d${i}`), {recursive: true}); + } + + const result = await walkProjectEntries( + testDir, + undefined, + () => false, + {maxRawFilesScanned: 5}, + ); + t.true(result.truncated); + + const untruncated = await walkProjectEntries( + testDir, + undefined, + () => false, + {maxRawFilesScanned: 1000}, + ); + t.false(untruncated.truncated); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'walkProjectEntries skips the empty-directory walk once the raw file-scan cap already fired', + async t => { + const testDir = createTempDir('test-file-search-skip-dir-walk-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + for (let i = 0; i < 30; i++) { + writeFileSync(join(testDir, `f${i}.txt`), 'x'); + } + mkdirSync(join(testDir, 'empty-dir'), {recursive: true}); + + const seenDirectories: string[] = []; + const result = await walkProjectEntries( + testDir, + undefined, + entry => { + if (entry.isDirectory) { + seenDirectories.push(entry.relativePath); + } + return false; + }, + {maxRawFilesScanned: 5}, + ); + + t.true(result.truncated); + // The empty-dir walk should have been skipped once the raw scan cap made the result incomplete. + t.false(seenDirectories.includes('empty-dir')); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'findMatchingPaths hides an empty directory ignored by a nested .gitignore', + async t => { + const testDir = createTempDir('test-file-search-nested-gitignore-empty-temp'); + + try { + mkdirSync(join(testDir, 'pkg'), {recursive: true}); + writeFileSync(join(testDir, 'pkg', '.gitignore'), 'should-be-hidden\n'); + mkdirSync(join(testDir, 'pkg', 'should-be-hidden'), {recursive: true}); + mkdirSync(join(testDir, 'pkg', 'sub', 'should-be-hidden'), { + recursive: true, + }); + mkdirSync(join(testDir, 'pkg', 'still-visible'), {recursive: true}); + writeFileSync(join(testDir, 'pkg', 'kept.ts'), 'export {};'); + + const hidden = await findMatchingPaths('should-be-hidden', testDir, 50); + t.deepEqual(hidden.files, []); + + const visible = await findMatchingPaths('still-visible', testDir, 50); + t.true(visible.files.includes('pkg/still-visible')); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'findMatchingPaths hides an empty directory ignored by a trailing-slash .gitignore pattern', + async t => { + // Uses a name outside DEFAULT_IGNORE_DIRS (not "dist") so the hardcoded exclusion list can't mask a regression in the trailing-slash check. + const testDir = createTempDir( + 'test-file-search-trailing-slash-gitignore-temp', + ); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, '.gitignore'), 'ignoredslash/\n'); + mkdirSync(join(testDir, 'ignoredslash'), {recursive: true}); + mkdirSync(join(testDir, 'empty'), {recursive: true}); + + const hidden = await findMatchingPaths('ignoredslash', testDir, 50); + t.deepEqual(hidden.files, []); + + const visible = await findMatchingPaths('empty', testDir, 50); + t.true(visible.files.includes('empty')); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'findMatchingPaths and searchProjectContents respect .nanocoderignore', + async t => { + const testDir = createTempDir('test-file-search-nanocoderignore-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, '.nanocoderignore'), 'secret.txt\n'); + writeFileSync(testDir + '/secret.txt', 'findme_secret'); + writeFileSync(join(testDir, 'visible.txt'), 'findme_visible'); + + const pathResult = await findMatchingPaths('*.txt', testDir, 50); + t.deepEqual(pathResult.files, ['visible.txt']); + + const contentResult = await searchProjectContents( + 'findme_', + testDir, + 50, + false, + ); + t.deepEqual( + contentResult.matches.map(m => m.file), + ['visible.txt'], + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'a .nanocoderignore directory is pruned during traversal, not just filtered from results', + async t => { + const testDir = createTempDir('test-file-search-nanocoderignore-prune-temp'); + + try { + mkdirSync(join(testDir, 'fixtures'), {recursive: true}); + mkdirSync(join(testDir, 'src'), {recursive: true}); + writeFileSync(join(testDir, '.nanocoderignore'), 'fixtures/\n'); + // Sorts before src/, so a JS-side filter alone would let these 20 spend + // the whole scan budget and crowd keep.ts out of the results entirely. + for (let index = 0; index < 20; index++) { + writeFileSync( + join(testDir, 'fixtures', `f${String(index).padStart(2, '0')}.txt`), + 'noise', + ); + } + writeFileSync(join(testDir, 'src', 'keep.ts'), 'export {};'); + + const seen: string[] = []; + const result = await walkProjectEntries( + testDir, + undefined, + entry => { + seen.push(entry.relativePath); + return false; + }, + {includeDirectories: false, maxRawFilesScanned: 5}, + ); + + t.true(seen.includes('src/keep.ts')); + t.false(seen.some(entry => entry.startsWith('fixtures/'))); + t.false(result.truncated); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'walkProjectEntries rejects an async onEntry under sorted: false rather than racing it', + async t => { + const testDir = createTempDir('test-file-search-async-visitor-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'a.txt'), 'hello\n'); + + // The overloads make this a compile error; this guards the runtime + // backstop for callers without type checking. + const asyncVisitor = async () => false; + await t.throwsAsync( + () => + walkProjectEntries( + testDir, + undefined, + asyncVisitor as unknown as () => boolean, + {sorted: false}, + ), + {message: /onEntry must be synchronous/}, + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'findMatchingPaths lets .nanocoderignore un-ignore a DEFAULT_IGNORE_DIRS entry', + async t => { + const testDir = createTempDir('test-file-search-nanocoderignore-unignore-temp'); + + try { + mkdirSync(join(testDir, 'dist'), {recursive: true}); + writeFileSync(join(testDir, 'dist', 'bundle.js'), 'kept'); + writeFileSync(join(testDir, '.nanocoderignore'), '!dist\n!dist/**\n'); + + const result = await findMatchingPaths('bundle.js', testDir, 50); + t.deepEqual(result.files, ['dist/bundle.js']); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'findMatchingPaths lets .nanocoderignore un-ignore an empty dir hidden by root .gitignore', + async t => { + const testDir = createTempDir( + 'test-file-search-nanocoderignore-empty-dir-temp', + ); + + try { + mkdirSync(join(testDir, 'build-cache'), {recursive: true}); + writeFileSync(join(testDir, '.gitignore'), 'build-cache\n'); + writeFileSync(join(testDir, '.nanocoderignore'), '!build-cache\n'); + + const result = await findMatchingPaths('build-cache', testDir, 50); + t.deepEqual(result.files, ['build-cache']); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +// Symlinks are deliberately never followed - one could point anywhere outside the project. + +test.serial( + 'findMatchingPaths does not descend into a symlinked directory', + async t => { + const testDir = createTempDir('test-file-search-symlink-dir-temp'); + + try { + mkdirSync(join(testDir, 'real-target'), {recursive: true}); + writeFileSync(join(testDir, 'real-target', 'inner.ts'), 'export {};'); + symlinkSync( + join(testDir, 'real-target'), + join(testDir, 'linked-dir'), + 'junction', + ); + + const result = await findMatchingPaths('linked-dir', testDir, 50); + t.deepEqual(result.files, []); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'findMatchingPaths tolerates a symlink near an ancestor without hanging', + async t => { + const testDir = createTempDir('test-file-search-symlink-cycle-temp'); + + try { + mkdirSync(join(testDir, 'a', 'b'), {recursive: true}); + writeFileSync(join(testDir, 'normal.ts'), 'export {};'); + symlinkSync(testDir, join(testDir, 'a', 'b', 'loop'), 'junction'); + + const result = await findMatchingPaths('normal.ts', testDir, 50); + t.true(result.files.includes('normal.ts')); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents does not read content behind a symlinked file', + async t => { + const testDir = createTempDir('test-file-search-symlink-file-temp'); + const targetDir = createTempDir('test-file-search-symlink-target-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + mkdirSync(targetDir, {recursive: true}); + writeFileSync(join(targetDir, 'real.ts'), 'searchTarget here'); + symlinkSync(join(targetDir, 'real.ts'), join(testDir, 'linked.ts'), 'file'); + + const result = await searchProjectContents('searchTarget', testDir, 10, false); + t.deepEqual(result.matches, []); + } finally { + rmSync(testDir, {recursive: true, force: true}); + rmSync(targetDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'a symlink pointing outside the project directory cannot leak content or paths', + async t => { + const projectDir = createTempDir('test-file-search-sandbox-project-temp'); + const outsideDir = createTempDir('test-file-search-sandbox-outside-temp'); + + try { + mkdirSync(join(projectDir, 'src'), {recursive: true}); + mkdirSync(outsideDir, {recursive: true}); + writeFileSync( + join(outsideDir, 'secret.txt'), + 'SECRET_OUTSIDE_CONTENT findme_outside', + ); + symlinkSync(outsideDir, join(projectDir, 'src', 'escape'), 'junction'); + + const contentResult = await searchProjectContents( + 'SECRET_OUTSIDE_CONTENT', + projectDir, + 10, + false, + ); + t.deepEqual(contentResult.matches, []); + + const fileResult = await findMatchingPaths('secret.txt', projectDir, 50); + t.deepEqual(fileResult.files, []); + } finally { + rmSync(projectDir, {recursive: true, force: true}); + rmSync(outsideDir, {recursive: true, force: true}); + } + }, +); + +test.serial('findMatchingPaths finds binary-extension files', async t => { + const testDir = createTempDir('test-file-search-binary-ext-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'icon.svg'), ''); + writeFileSync(join(testDir, 'photo.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(join(testDir, 'module.wasm'), Buffer.from([0x00, 0x61, 0x73, 0x6d])); + + const svgResult = await findMatchingPaths('*.svg', testDir, 50); + t.deepEqual(svgResult.files, ['icon.svg']); + + const pngResult = await findMatchingPaths('*.png', testDir, 50); + t.deepEqual(pngResult.files, ['photo.png']); + + const wasmResult = await findMatchingPaths('*.wasm', testDir, 50); + t.deepEqual(wasmResult.files, ['module.wasm']); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } +}); + test.serial('findMatchingPaths enforces maxResults and truncation', async t => { const testDir = createTempDir('test-file-search-max-temp'); @@ -71,6 +647,150 @@ test.serial('findMatchingPaths enforces maxResults and truncation', async t => { } }); +test.serial( + 'findMatchingPaths and searchProjectContents return nothing for a non-positive maxResults', + async t => { + const testDir = createTempDir('test-file-search-nonpositive-max-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'file.ts'), 'searchTarget'); + + for (const maxResults of [0, -1, -5]) { + const findResult = await findMatchingPaths('*.ts', testDir, maxResults); + t.deepEqual( + findResult, + {files: [], truncated: false}, + `findMatchingPaths maxResults=${maxResults}`, + ); + + const searchResult = await searchProjectContents( + 'searchTarget', + testDir, + maxResults, + false, + ); + t.deepEqual( + searchResult, + {matches: [], truncated: false}, + `searchProjectContents maxResults=${maxResults}`, + ); + } + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial('searchProjectContents rejects an empty or whitespace-only query', async t => { + const testDir = createTempDir('test-file-search-empty-query-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'a.ts'), 'content'); + + await t.throwsAsync(() => searchProjectContents('', testDir, 10, false)); + await t.throwsAsync(() => searchProjectContents(' ', testDir, 10, false)); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } +}); + +test.serial( + 'searchProjectContents stops early instead of buffering every match before truncating', + async t => { + const testDir = createTempDir('test-file-search-maxcount-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + // rg should be killed once enough matches have streamed in, not left running. + const lines = Array.from({length: 500}, (_, i) => `searchTarget line ${i}`); + writeFileSync(join(testDir, 'big.ts'), lines.join('\n')); + + const result = await searchProjectContents('searchTarget', testDir, 5, false); + t.is(result.matches.length, 5); + t.true(result.truncated); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents stays bounded when context lines themselves also match (rg --max-count overshoot case)', + async t => { + const testDir = createTempDir('test-file-search-dense-context-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + // Every line matches, so rg's own --max-count overshoots with --context (ripgrep#2843). + const lines = Array.from({length: 5000}, (_, i) => `searchTarget line ${i}`); + writeFileSync(join(testDir, 'dense.ts'), lines.join('\n')); + + const start = Date.now(); + const result = await searchProjectContents( + 'searchTarget', + testDir, + 5, + false, + undefined, + undefined, + undefined, + 3, + ); + const elapsed = Date.now() - start; + + t.is(result.matches.length, 5); + t.true(result.truncated); + t.true(elapsed < 5000, `expected a fast bounded search, took ${elapsed}ms`); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents keeps the full context window on the last match even when every context line is itself a match', + async t => { + const testDir = createTempDir('test-file-search-dense-context-headroom-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + // Every line matches, so the Nth match's trailing context lines stream in as type:"match" entries too, not type:"context". + const lines = Array.from({length: 5000}, (_, i) => `searchTarget line ${i}`); + writeFileSync(join(testDir, 'dense.ts'), lines.join('\n')); + + const contextLines = 3; + const result = await searchProjectContents( + 'searchTarget', + testDir, + 5, + false, + undefined, + undefined, + undefined, + contextLines, + ); + + t.is(result.matches.length, 5); + const lastMatch = result.matches[result.matches.length - 1]; + t.truthy(lastMatch); + for ( + let line = lastMatch.line - contextLines; + line <= lastMatch.line + contextLines; + line++ + ) { + t.true( + lastMatch.content.includes(`${line}: `), + `expected line ${line} in last match's context, got:\n${lastMatch.content}`, + ); + } + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + test.serial('searchProjectContents respects include, path, wholeWord and context', async t => { const testDir = createTempDir('test-file-search-search-temp'); @@ -113,6 +833,43 @@ test.serial('searchProjectContents respects include, path, wholeWord and context } }); +test.serial( + 'searchProjectContents gives each nearby match its own context block even when windows overlap', + async t => { + const testDir = createTempDir('test-file-search-context-overlap-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + const lines = Array.from({length: 12}, (_, i) => `line${i}`); + lines[4] = 'TARGET one'; + lines[6] = 'TARGET two'; + writeFileSync(join(testDir, 'a.txt'), lines.join('\n')); + + const result = await searchProjectContents( + 'TARGET', + testDir, + 10, + false, + undefined, + undefined, + undefined, + 2, + ); + + t.is(result.matches.length, 2); + t.is(result.matches[0]?.line, 5); + t.is(result.matches[1]?.line, 7); + // Both blocks share lines 5-7, which rg streams only once - each still gets its own block. + t.true(result.matches[0]?.content.includes('5: TARGET one')); + t.true(result.matches[0]?.content.includes('7: TARGET two')); + t.true(result.matches[1]?.content.includes('5: TARGET one')); + t.true(result.matches[1]?.content.includes('7: TARGET two')); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + test.serial('searchProjectContents skips ignored and binary files', async t => { const testDir = createTempDir('test-file-search-ignore-temp'); @@ -141,13 +898,103 @@ test.serial('searchProjectContents skips ignored and binary files', async t => { } }); +test.serial( + 'searchProjectContents respects a .gitignore nested in a subdirectory', + async t => { + const testDir = createTempDir('test-file-search-nested-gitignore-temp'); + + try { + mkdirSync(join(testDir, 'pkg'), {recursive: true}); + writeFileSync(join(testDir, 'pkg', '.gitignore'), 'ignored.ts\n'); + writeFileSync(join(testDir, 'pkg', 'ignored.ts'), 'searchTarget'); + writeFileSync(join(testDir, 'pkg', 'kept.ts'), 'searchTarget'); + + const result = await searchProjectContents('searchTarget', testDir, 10, false); + + t.deepEqual( + result.matches.map(match => match.file), + ['pkg/kept.ts'], + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents keeps binary excludes even under a broad include pattern', + async t => { + const testDir = createTempDir('test-file-search-include-order-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'code.ts'), 'searchTarget'); + writeFileSync(join(testDir, 'image.png'), 'searchTarget'); + + const result = await searchProjectContents( + 'searchTarget', + testDir, + 10, + false, + '**/*', + ); + + t.deepEqual( + result.matches.map(match => match.file), + ['code.ts'], + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents skips a file with NUL bytes even without a matching extension', + async t => { + const testDir = createTempDir('test-file-search-nul-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync( + join(testDir, 'weird.log'), + Buffer.from('searchTarget\0garbage\0bytes'), + ); + writeFileSync(join(testDir, 'clean.log'), 'searchTarget in a clean file'); + + const result = await searchProjectContents('searchTarget', testDir, 10, false); + + t.deepEqual( + result.matches.map(match => match.file), + ['clean.log'], + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'findMatchingPaths rejects quickly on a spawn failure instead of waiting out the timeout', + async t => { + // A nonexistent cwd makes spawn fail (ENOENT); this must not wait out the 30s timeout. + const bogusDir = join( + createTempDir('nonexistent-cwd'), + 'definitely', + 'does-not-exist', + ); + const start = Date.now(); + await t.throwsAsync(() => findMatchingPaths('a.ts', bogusDir, 50)); + t.true(Date.now() - start < 5000); + }, +); + test.serial('searchProjectContents throws SearchTimeoutError when timeout elapses', async t => { const testDir = createTempDir('test-file-search-timeout-temp'); try { mkdirSync(testDir, {recursive: true}); - // Many files with a query that never matches — walker keeps going, - // giving the abort timer a chance to fire between async I/O yields. + // Many files, query never matches - gives the timeout a chance to fire mid-walk. for (let i = 0; i < 500; i++) { writeFileSync(join(testDir, `file${i}.ts`), 'line a\nline b\nline c\n'); } @@ -171,3 +1018,232 @@ test.serial('searchProjectContents throws SearchTimeoutError when timeout elapse rmSync(testDir, {recursive: true, force: true}); } }); + +test.serial( + 'searchProjectContents rejects with the caller-supplied abort reason, not a generic AbortError', + async t => { + const testDir = createTempDir('test-file-search-abort-reason-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + for (let i = 0; i < 2000; i++) { + writeFileSync( + join(testDir, `file${i}.ts`), + 'line a\nline b\nline c\n'.repeat(20), + ); + } + + const controller = new AbortController(); + const customReason = new Error('custom-abort-reason'); + setTimeout(() => controller.abort(customReason), 5); + + const error = await t.throwsAsync(() => + searchProjectContents( + 'no-such-thing-anywhere', + testDir, + 100000, + false, + undefined, + undefined, + undefined, + undefined, + 30000, + controller.signal, + ), + ); + t.is(error, customReason); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents still throws on a genuinely invalid regex', + async t => { + const testDir = createTempDir('test-file-search-badregex-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'f.ts'), 'content'); + + // rg exits 2 for an invalid regex too; must still reject, not return no matches. + await t.throwsAsync(() => + searchProjectContents('[invalid(regex', testDir, 10, false), + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents supports lookahead and backreferences (rg auto-selects pcre2)', + async t => { + const testDir = createTempDir('test-file-search-pcre2-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'a.txt'), 'foobar\nfoofoo\n'); + + const lookahead = await searchProjectContents( + 'foo(?=bar)', + testDir, + 10, + false, + ); + t.deepEqual( + lookahead.matches.map(m => m.content), + ['foobar'], + ); + + const backreference = await searchProjectContents( + `(foo)${String.fromCharCode(92)}1`, + testDir, + 10, + false, + ); + t.deepEqual( + backreference.matches.map(m => m.content), + ['foofoo'], + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents supports Python-style and Perl-style named backreferences (rg auto-selects pcre2)', + async t => { + const testDir = createTempDir('test-file-search-named-backref-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'a.txt'), 'foofoo\nfoobar\n'); + + const pythonStyle = await searchProjectContents( + '(?Pfoo)(?P=n)', + testDir, + 10, + false, + ); + t.deepEqual( + pythonStyle.matches.map(m => m.content), + ['foofoo'], + ); + + const perlQuoteStyle = await searchProjectContents( + `(?foo)${String.fromCharCode(92)}k'n'`, + testDir, + 10, + false, + ); + t.deepEqual( + perlQuoteStyle.matches.map(m => m.content), + ['foofoo'], + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents does not misjudge a possessive quantifier as an ordinary greedy one', + async t => { + const testDir = createTempDir('test-file-search-possessive-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'a.txt'), 'aaa\n'); + + // a++a is possessive - a++ leaves nothing for the trailing 'a' to match. + const result = await searchProjectContents('a++a', testDir, 10, false); + t.deepEqual(result.matches, []); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +// chmod 0o000 does not stop root, and Windows ignores the mode entirely. +const unreadableDirTest = + process.platform === 'win32' || process.getuid?.() === 0 + ? test.serial.skip + : test.serial; + +unreadableDirTest( + 'walkProjectEntries surfaces an unreadable search root instead of reporting no files', + async t => { + const testDir = createTempDir('test-file-search-unreadable-temp'); + const lockedDir = join(testDir, 'locked'); + + try { + mkdirSync(lockedDir, {recursive: true}); + writeFileSync(join(lockedDir, 'a.txt'), 'hello\n'); + // rg exits 2 with "Permission denied (os error 13)" on an empty stdout. That + // message is in no allowlist, so gating rejection on recognized stderr would + // report this as an ordinary empty result. + chmodSync(lockedDir, 0o000); + + await t.throwsAsync( + () => walkProjectEntries(testDir, lockedDir, () => false), + {message: /ripgrep exited with code 2/}, + ); + } finally { + chmodSync(lockedDir, 0o755); + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents still matches an ordinary named capture group', + async t => { + const testDir = createTempDir('test-file-search-named-group-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'a.txt'), 'foobar\n'); + + // Looks like lookbehind syntax at a glance but isn't. + const result = await searchProjectContents( + '(?foo)bar', + testDir, + 10, + false, + ); + t.deepEqual( + result.matches.map(m => m.content), + ['foobar'], + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents throws on a nonexistent searchPath instead of returning no matches', + async t => { + const testDir = createTempDir('test-file-search-bad-searchpath-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + + await t.throwsAsync(() => + searchProjectContents( + 'foo', + testDir, + 10, + false, + undefined, + join(testDir, 'does-not-exist'), + ), + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); diff --git a/source/utils/file-search.ts b/source/utils/file-search.ts index 150dba61f..44f7b0c98 100644 --- a/source/utils/file-search.ts +++ b/source/utils/file-search.ts @@ -1,12 +1,24 @@ +import {spawn} from 'node:child_process'; +import type {Dirent} from 'node:fs'; import {lstat, readdir, readFile} from 'node:fs/promises'; import path from 'node:path'; +import ignore from 'ignore'; +import {LRUCache} from 'lru-cache'; import {BINARY_FILE_EXTENSIONS} from '@/constants'; -import {loadGitignore} from '@/utils/gitignore-loader'; +import { + DEFAULT_IGNORE_DIRS, + findNanocoderIgnoreFile, + loadGitignore, +} from '@/utils/gitignore-loader'; +import {getLogger} from '@/utils/logging'; +import {resolveRipgrepPath} from '@/utils/ripgrep-path'; const MAX_CONTEXT_CONTENT_LENGTH = 1500; const MAX_MATCH_CONTENT_LENGTH = 300; const DEFAULT_SEARCH_TIMEOUT_MS = 30_000; +const MAX_RAW_FILES_SCANNED = 50_000; +const MAX_GLOB_PATTERN_LENGTH = 1000; export class SearchTimeoutError extends Error { constructor(timeoutMs: number) { @@ -35,70 +47,169 @@ function normalizePathForMatch(filePath: string): string { return filePath.replace(/\\/g, '/'); } -function escapeRegexChar(char: string): string { - return /[|\\{}()[\]^$+?.]/.test(char) ? `\\${char}` : char; -} +const MAX_BRACE_EXPANSIONS = 64; function expandBraces(pattern: string): string[] { - const match = pattern.match(/\{([^{}]+)\}/); - if (!match || match.index === undefined) { - return [pattern]; - } + let combinationCount = 0; - const before = pattern.slice(0, match.index); - const after = pattern.slice(match.index + match[0].length); + const expand = (current: string): string[] => { + const match = current.match(/\{([^{}]+)\}/); + if (!match || match.index === undefined) { + combinationCount++; + if (combinationCount > MAX_BRACE_EXPANSIONS) { + throw new Error( + `Glob pattern has too many brace-expansion combinations (max ${MAX_BRACE_EXPANSIONS}).`, + ); + } + return [current]; + } - return match[1] - .split(',') - .flatMap(part => expandBraces(`${before}${part.trim()}${after}`)); -} + const before = current.slice(0, match.index); + const after = current.slice(match.index + match[0].length); -function globToRegExpSource(pattern: string): string { - let source = ''; + return match[1] + .split(',') + .flatMap(part => expand(`${before}${part.trim()}${after}`)); + }; - for (let index = 0; index < pattern.length; index++) { - const current = pattern[index]; - const next = pattern[index + 1]; + return expand(pattern); +} - if (current === '*') { - if (next === '*') { - const afterNext = pattern[index + 2]; - if (afterNext === '/') { - source += '(?:.*/)?'; - index += 2; +type GlobToken = + | {type: 'literal'; char: string} + | {type: 'slash'} + | {type: 'qmark'} + | {type: 'star'} + | {type: 'globstar'} + | {type: 'globstarSlash'}; + +function tokenizeGlob(pattern: string): GlobToken[] { + const tokens: GlobToken[] = []; + let index = 0; + while (index < pattern.length) { + const char = pattern[index]; + if (char === '*') { + if (pattern[index + 1] === '*') { + if (pattern[index + 2] === '/') { + tokens.push({type: 'globstarSlash'}); + index += 3; } else { - source += '.*'; - index += 1; + tokens.push({type: 'globstar'}); + index += 2; } } else { - source += '[^/]*'; + tokens.push({type: 'star'}); + index += 1; } continue; } - - if (current === '?') { - source += '[^/]'; + if (char === '?') { + tokens.push({type: 'qmark'}); + index += 1; continue; } - - if (current === '/') { - source += '/'; + if (char === '/') { + tokens.push({type: 'slash'}); + index += 1; continue; } + tokens.push({type: 'literal', char}); + index += 1; + } + return tokens; +} + +// DP table, not a compiled regex - no backtracking, so no ReDoS. +function matchTokens(text: string, tokens: GlobToken[]): boolean { + const textLength = text.length; + const tokenCount = tokens.length; - source += escapeRegexChar(current); + let previousRow = new Array(tokenCount + 1).fill(false); + previousRow[0] = true; + for (let tokenIndex = 1; tokenIndex <= tokenCount; tokenIndex++) { + const token = tokens[tokenIndex - 1]; + previousRow[tokenIndex] = + (token.type === 'star' || + token.type === 'globstar' || + token.type === 'globstarSlash') && + previousRow[tokenIndex - 1]; } - return source; + // True once any row hits this column - globstarSlash can start from any earlier row. + const columnEverTrue = [...previousRow]; + + for (let textIndex = 1; textIndex <= textLength; textIndex++) { + const currentRow = new Array(tokenCount + 1).fill(false); + const textChar = text[textIndex - 1]; + + for (let tokenIndex = 1; tokenIndex <= tokenCount; tokenIndex++) { + const token = tokens[tokenIndex - 1]; + let matched: boolean; + switch (token.type) { + case 'literal': + matched = previousRow[tokenIndex - 1] && textChar === token.char; + break; + case 'slash': + matched = previousRow[tokenIndex - 1] && textChar === '/'; + break; + case 'qmark': + matched = previousRow[tokenIndex - 1] && textChar !== '/'; + break; + case 'star': + matched = + currentRow[tokenIndex - 1] || + (previousRow[tokenIndex] && textChar !== '/'); + break; + case 'globstar': + matched = currentRow[tokenIndex - 1] || previousRow[tokenIndex]; + break; + case 'globstarSlash': + matched = + currentRow[tokenIndex - 1] || + (textChar === '/' && columnEverTrue[tokenIndex - 1]); + break; + } + currentRow[tokenIndex] = matched; + } + + previousRow = currentRow; + for (let tokenIndex = 0; tokenIndex <= tokenCount; tokenIndex++) { + columnEverTrue[tokenIndex] = + columnEverTrue[tokenIndex] || currentRow[tokenIndex]; + } + } + + return previousRow[tokenCount]; } -function buildGlobRegexes(pattern: string): RegExp[] { +// Bounds total tokens, not entry count - one entry can hold up to MAX_BRACE_EXPANSIONS arrays. +/** @internal Exported for direct unit testing only. */ +export const GLOB_TOKEN_CACHE_MAX_TOKENS = 1_000_000; + +/** @internal Exported for direct unit testing only. */ +export const globTokenCache = new LRUCache({ + maxSize: GLOB_TOKEN_CACHE_MAX_TOKENS, + sizeCalculation: tokenized => + tokenized.reduce((sum, tokens) => sum + tokens.length, 0), +}); + +function tokenizeExpandedPattern(pattern: string): GlobToken[][] { + const cached = globTokenCache.get(pattern); + if (cached) { + return cached; + } + + if (pattern.length > MAX_GLOB_PATTERN_LENGTH) { + throw new Error( + `Glob pattern is too long (${pattern.length} chars, max ${MAX_GLOB_PATTERN_LENGTH}).`, + ); + } + const normalizedPattern = normalizePathForMatch(pattern); - return expandBraces(normalizedPattern).map( - expanded => - // nosemgrep: detect-non-literal-regexp - new RegExp(`^${globToRegExpSource(expanded)}$`), - ); + const tokenized = expandBraces(normalizedPattern).map(tokenizeGlob); + + globTokenCache.set(pattern, tokenized); + return tokenized; } export function matchesGlob( @@ -110,93 +221,343 @@ export function matchesGlob( const target = matchBasename ? path.posix.basename(normalizedPath) : normalizedPath; - return buildGlobRegexes(pattern).some(regex => regex.test(target)); + return tokenizeExpandedPattern(pattern).some(tokens => + matchTokens(target, tokens), + ); } -function isIgnoredByBinaryHeuristics( - filePath: string, - content: string, -): boolean { - const ext = path.extname(filePath).toLowerCase(); - if (BINARY_FILE_EXTENSIONS.has(ext)) { - return true; +function defaultIgnoreGlobs( + projectIgnore: ReturnType, +): string[] { + const globs: string[] = []; + for (const dir of DEFAULT_IGNORE_DIRS) { + if (projectIgnore.ignores(dir)) { + globs.push('-g', `!${dir}`); + } } + return globs; +} - return content.includes('\0'); +/** + * Hands .nanocoderignore to rg so it prunes during traversal. + * + * The JS-side `projectIgnore.ignores()` filter downstream would drop these + * paths anyway, but only after rg had walked them and after they had already + * spent budget against `maxRawFilesScanned` - a large ignored fixtures + * directory could crowd real files out of the results entirely. + * + * rg applies `--ignore-file` rules after .gitignore and after `.ignore`, which + * is the layering {@link loadGitignore} documents. Omitted when the file does + * not exist: rg warns on a missing path, and every search would carry it. + */ +function nanocoderIgnoreFileArgs(cwd: string): string[] { + const ignoreFile = findNanocoderIgnoreFile(cwd); + return ignoreFile ? ['--ignore-file', ignoreFile] : []; } -function formatMatchContent(content: string, maxLength: number): string { - if (content.length <= maxLength) { - return content; +async function assertPathExists(candidatePath: string): Promise { + await lstat(candidatePath); +} + +// Possessive quantifiers parse under rg's default engine with different (wrong) semantics - the one case --engine auto can't self-detect. +const POSSESSIVE_QUANTIFIER_PATTERN = /[*+?]\+|\}\+/; + +function binaryExcludeGlobs(): string[] { + const globs: string[] = []; + for (const ext of BINARY_FILE_EXTENSIONS) { + globs.push('-g', `!*${ext}`); } - return `${content.slice(0, maxLength)}…`; + return globs; } -function buildSearchRegex( - query: string, - caseSensitive: boolean, - wholeWord: boolean, -): RegExp { - const flags = caseSensitive ? 'g' : 'gi'; - const source = wholeWord ? `\\b(?:${query})\\b` : query; - // nosemgrep: detect-non-literal-regexp - return new RegExp(source, flags); +interface RunRipgrepResult { + stdout: string; + hitMaxLines: boolean; } -export async function walkProjectEntries( +async function runRipgrep( + args: string[], cwd: string, - startPath: string | undefined, - onEntry: (entry: ProjectEntry) => boolean | Promise, + timeoutMs: number, signal?: AbortSignal, -): Promise { - const ig = loadGitignore(cwd); - const rootPath = startPath ?? cwd; - await lstat(rootPath); + maxMatches?: number, + maxLines?: number, + onLine?: (line: string) => boolean, +): Promise { + const rgPath = await resolveRipgrepPath(); - const checkAborted = () => { - if (signal?.aborted) { - throw signal.reason ?? new Error('Walk aborted'); + return new Promise((resolve, reject) => { + // No `signal`/`timeout` in spawn options - Node's own handling leaks state. Own both. + const child = spawn(rgPath, args, {cwd}); + let stdout = ''; + let stderr = ''; + let killedForLimit = false; + let hitMaxLines = false; + let timedOut = false; + let matchCount = 0; + let lineCount = 0; + + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, timeoutMs); + let lineRemainder = ''; + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + // This runs on the stream's event loop turn, not inside the promise + // executor, so a throw from onLine would escape as an uncaught + // exception and take the process down instead of failing the search. + try { + consumeChunk(chunk); + } catch (err) { + // Reusing killedForLimit to ignore any chunks still in flight. The + // promise is already rejected, so the close handler's resolve is a no-op. + killedForLimit = true; + child.kill(); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + + function consumeChunk(chunk: string): void { + if (killedForLimit) { + return; + } + + if ( + maxMatches === undefined && + maxLines === undefined && + onLine === undefined + ) { + stdout += chunk; + return; + } + + // Chunks aren't line-aligned - build stdout here so it can't overshoot the cap. + lineRemainder += chunk; + let newlineIndex = lineRemainder.indexOf('\n'); + while (newlineIndex >= 0) { + const line = lineRemainder.slice(0, newlineIndex); + lineRemainder = lineRemainder.slice(newlineIndex + 1); + stdout += line + '\n'; + + if (onLine?.(line)) { + killedForLimit = true; + child.kill(); + return; + } + + if (line) { + if (maxLines !== undefined) { + lineCount++; + } else { + // rg's --max-count overshoots with --context, so count matches ourselves. + try { + if ((JSON.parse(line) as {type?: string}).type === 'match') { + matchCount++; + } + } catch { + // no-op + } + } + } + + if (maxLines !== undefined && lineCount >= maxLines) { + killedForLimit = true; + hitMaxLines = true; + child.kill(); + return; + } + if (maxMatches !== undefined && matchCount >= maxMatches) { + killedForLimit = true; + child.kill(); + return; + } + + newlineIndex = lineRemainder.indexOf('\n'); + } } - }; + + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + + const onAbort = () => { + child.kill(); + }; + signal?.addEventListener('abort', onAbort); + + child.on('error', err => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + reject(err); + }); + + child.on('close', (code, closeSignal) => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + + if (signal?.aborted) { + reject(signal.reason ?? new Error('Search aborted')); + return; + } + if (killedForLimit) { + resolve({stdout, hitMaxLines}); + return; + } + if (timedOut) { + reject(new SearchTimeoutError(timeoutMs)); + return; + } + // code is null when rg was killed by a signal we didn't send (e.g. OOM killer). + if (closeSignal) { + reject(new Error(`ripgrep terminated by signal ${closeSignal}`)); + return; + } + // Exit 1 = no matches. Exit 2 with output is a recoverable mid-scan warning + // (one unreadable subdirectory, say) - rg scanned the rest, so keep it. + // + // Exit 2 with nothing on stdout means the search never produced anything: + // an unreadable root, a rejected argument, a build without PCRE2 (real on + // Linux ARM). Deliberately no stderr allowlist here - anything rg says that + // nobody enumerated would otherwise fall through to `resolve('')`, and every + // caller reads that as a genuine "no results" rather than a failure. + if (code !== null && code > 1 && stdout.length === 0) { + reject( + new Error( + `ripgrep exited with code ${code}: ${ + stderr.trim() || 'no error output' + }`, + ), + ); + return; + } + resolve({stdout, hitMaxLines: false}); + }); + }); +} + +const MAX_WALK_DEPTH = 200; + +// Unanchored `foo` matches any depth (dirPrefix/**/foo); anchored patterns stay scoped (dirPrefix/foo). +function prefixGitignoreLine( + line: string, + dirPrefix: string, +): string | undefined { + const trimmed = line.trimEnd(); + if (!trimmed || trimmed.startsWith('#')) { + return undefined; + } + if (!dirPrefix) { + return trimmed; + } + + const negated = trimmed.startsWith('!'); + const pattern = negated ? trimmed.slice(1) : trimmed; + const isAnchoredOrNested = + pattern.startsWith('/') || pattern.replace(/\/$/, '').includes('/'); + const prefixed = isAnchoredOrNested + ? `${dirPrefix}/${pattern.replace(/^\//, '')}` + : `${dirPrefix}/**/${pattern}`; + return negated ? `!${prefixed}` : prefixed; +} + +async function walkEmptyDirectories( + cwd: string, + rootPath: string, + seenDirs: Set, + onEntry: (entry: ProjectEntry) => boolean | Promise, + projectIgnore: ReturnType, + signal?: AbortSignal, + maxDirsWalked: number = MAX_RAW_FILES_SCANNED, +): Promise<{truncated: boolean}> { + // Seeded from projectIgnore for correct rule order; nested .gitignore merges in with higher precedence, same as git. + const ig = ignore(); + ig.add(projectIgnore); + let loggedDepthCap = false; + let dirsWalked = 0; + let hitDirCap = false; const visit = async ( absolutePath: string, - relativePath: string, + depth: number, ): Promise => { - checkAborted(); - if (relativePath && ig.ignores(normalizePathForMatch(relativePath))) { + if (signal?.aborted) { + throw signal.reason ?? new Error('Walk aborted'); + } + + if (depth > MAX_WALK_DEPTH) { + if (!loggedDepthCap) { + loggedDepthCap = true; + getLogger().warn( + {cwd, maxDepth: MAX_WALK_DEPTH}, + 'walkEmptyDirectories: hit max depth, some directories were not walked', + ); + } return false; } - const stats = await lstat(absolutePath); - const isDirectory = stats.isDirectory(); - const isSymlink = stats.isSymbolicLink(); + // readdir itself is the expensive part; cap on that, not on discovered entries. + dirsWalked++; + if (dirsWalked > maxDirsWalked) { + hitDirCap = true; + return true; + } - if (relativePath) { - const shouldStop = await onEntry({ - absolutePath, - relativePath, - isDirectory, - }); - if (shouldStop) { - return true; + const dirPrefix = normalizePathForMatch(path.relative(cwd, absolutePath)); + // cwd's .gitignore is already in projectIgnore - re-reading it would duplicate and invert precedence. + if (dirPrefix !== '') { + const gitignoreContent = await readFile( + path.join(absolutePath, '.gitignore'), + 'utf-8', + ).catch(() => undefined); + if (gitignoreContent !== undefined) { + const patterns = gitignoreContent + .split('\n') + .map(line => prefixGitignoreLine(line, dirPrefix)) + .filter((line): line is string => line !== undefined); + if (patterns.length > 0) { + ig.add(patterns); + } } } - if (!isDirectory || isSymlink) { + let children: Dirent[]; + try { + children = await readdir(absolutePath, {withFileTypes: true}); + } catch { return false; } - let children = await readdir(absolutePath, {withFileTypes: true}); - children = children.sort((a, b) => a.name.localeCompare(b.name)); - for (const child of children) { + if (!child.isDirectory()) { + continue; + } + const childAbsolutePath = path.join(absolutePath, child.name); - const childRelativePath = relativePath - ? path.join(relativePath, child.name) - : child.name; + const childRelativePath = normalizePathForMatch( + path.relative(cwd, childAbsolutePath), + ); + + // child is a directory; ignore needs a trailing slash to match directory-only patterns like "dist/". + if (ig.ignores(`${childRelativePath}/`)) { + continue; + } + + if (!seenDirs.has(childRelativePath)) { + seenDirs.add(childRelativePath); + const stop = await onEntry({ + absolutePath: childAbsolutePath, + relativePath: childRelativePath, + isDirectory: true, + }); + if (stop) { + return true; + } + } - if (await visit(childAbsolutePath, childRelativePath)) { + if (await visit(childAbsolutePath, depth + 1)) { return true; } } @@ -204,8 +565,272 @@ export async function walkProjectEntries( return false; }; - const rootRelativePath = path.relative(cwd, rootPath); - await visit(rootPath, rootRelativePath === '' ? '' : rootRelativePath); + await visit(rootPath, 0); + return {truncated: hitDirCap}; +} + +export interface WalkProjectEntriesOptions { + /** Emit directory entries alongside files. Defaults to true. */ + includeDirectories?: boolean; + signal?: AbortSignal; + maxRawFilesScanned?: number; + /** + * Sort entries by path. Defaults to true. + * + * Unsorted streams results early but isn't guaranteed faster - rg's + * discovery order is non-deterministic. + * + * `sorted: false` reads entries off rg's stdout as it arrives, so `onEntry` + * MUST be synchronous there: returning a promise would let the stream run + * ahead of the callback. The overloads below make that a compile error, and + * {@link emitEntrySync} throws if a JS caller slips one through anyway. + */ + sorted?: boolean; +} + +/** `onEntry` shape accepted when entries stream in unsorted - no promises. */ +export type SyncProjectEntryVisitor = (entry: ProjectEntry) => boolean; + +/** `onEntry` shape accepted when entries are sorted and emitted one at a time. */ +export type ProjectEntryVisitor = ( + entry: ProjectEntry, +) => boolean | Promise; + +function emitEntrySync( + onEntry: ProjectEntryVisitor, + entry: ProjectEntry, +): boolean { + const stop = onEntry(entry); + if (stop instanceof Promise) { + throw new Error( + 'walkProjectEntries: onEntry must be synchronous when sorted: false', + ); + } + return stop; +} + +async function walkUnsortedFileStream( + cwd: string, + rootPath: string, + args: string[], + onEntry: ProjectEntryVisitor, + includeDirectories: boolean, + projectIgnore: ReturnType, + signal: AbortSignal | undefined, + maxRawFilesScanned: number, +): Promise<{truncated: boolean}> { + const seenDirs = new Set(); + let stoppedEarly = false; + + const onLine = (line: string): boolean => { + const file = normalizePathForMatch(line); + if (!file) { + return false; + } + + const relativeFile = normalizePathForMatch(path.relative(cwd, file)); + // rg already pruned these via --ignore-file; kept as a backstop because + // its matcher and the `ignore` package are separate implementations. + if (projectIgnore.ignores(relativeFile)) { + return false; + } + + if (includeDirectories) { + const parts = relativeFile.split('/'); + let dirRelative = ''; + for (let index = 0; index < parts.length - 1; index++) { + dirRelative = index === 0 ? parts[0] : `${dirRelative}/${parts[index]}`; + if (seenDirs.has(dirRelative)) { + continue; + } + seenDirs.add(dirRelative); + if ( + emitEntrySync(onEntry, { + absolutePath: path.join(cwd, dirRelative), + relativePath: dirRelative, + isDirectory: true, + }) + ) { + stoppedEarly = true; + return true; + } + } + } + + if ( + emitEntrySync(onEntry, { + absolutePath: path.join(cwd, relativeFile), + relativePath: relativeFile, + isDirectory: false, + }) + ) { + stoppedEarly = true; + return true; + } + + return false; + }; + + const {hitMaxLines} = await runRipgrep( + args, + cwd, + DEFAULT_SEARCH_TIMEOUT_MS, + signal, + undefined, + maxRawFilesScanned, + onLine, + ); + + if (stoppedEarly) { + return {truncated: hitMaxLines}; + } + + let hitDirCap = false; + if (includeDirectories && !hitMaxLines) { + ({truncated: hitDirCap} = await walkEmptyDirectories( + cwd, + rootPath, + seenDirs, + onEntry, + projectIgnore, + signal, + maxRawFilesScanned, + )); + } + + return {truncated: hitMaxLines || hitDirCap}; +} + +/** + * Walk every non-ignored file (and, by default, directory) under `startPath`, + * calling `onEntry` for each. Return true from `onEntry` to stop the walk. + * + * With `sorted: false`, entries stream straight off rg's stdout and `onEntry` + * must be synchronous - see {@link WalkProjectEntriesOptions.sorted}. + */ +export async function walkProjectEntries( + cwd: string, + startPath: string | undefined, + onEntry: SyncProjectEntryVisitor, + options: WalkProjectEntriesOptions & {sorted: false}, +): Promise<{truncated: boolean}>; +export async function walkProjectEntries( + cwd: string, + startPath: string | undefined, + onEntry: ProjectEntryVisitor, + options?: WalkProjectEntriesOptions & {sorted?: true}, +): Promise<{truncated: boolean}>; +export async function walkProjectEntries( + cwd: string, + startPath: string | undefined, + onEntry: ProjectEntryVisitor, + options: WalkProjectEntriesOptions = {}, +): Promise<{truncated: boolean}> { + const { + includeDirectories = true, + signal, + maxRawFilesScanned = MAX_RAW_FILES_SCANNED, + sorted = true, + } = options; + const rootPath = startPath ?? cwd; + await assertPathExists(rootPath); + const projectIgnore = loadGitignore(cwd); + const args = [ + '--files', + '--hidden', + // No --follow (symlinks could escape cwd); --no-require-git works without a repo. + '--no-ignore-parent', + '--no-require-git', + '--no-config', + ...(sorted ? ['--sort', 'path'] : []), + ...nanocoderIgnoreFileArgs(cwd), + ...defaultIgnoreGlobs(projectIgnore), + '--', + rootPath, + ]; + + if (!sorted) { + return walkUnsortedFileStream( + cwd, + rootPath, + args, + onEntry, + includeDirectories, + projectIgnore, + signal, + maxRawFilesScanned, + ); + } + + const {stdout, hitMaxLines} = await runRipgrep( + args, + cwd, + DEFAULT_SEARCH_TIMEOUT_MS, + signal, + undefined, + maxRawFilesScanned, + ); + const files = stdout + .split(/\r?\n/) + .filter(Boolean) + .map(normalizePathForMatch); + + const seenDirs = new Set(); + for (const file of files) { + if (signal?.aborted) { + throw signal.reason ?? new Error('Walk aborted'); + } + + const relativeFile = normalizePathForMatch(path.relative(cwd, file)); + if (projectIgnore.ignores(relativeFile)) { + continue; + } + + if (includeDirectories) { + const parts = relativeFile.split('/'); + + let dirRelative = ''; + for (let index = 0; index < parts.length - 1; index++) { + dirRelative = index === 0 ? parts[0] : `${dirRelative}/${parts[index]}`; + if (seenDirs.has(dirRelative)) { + continue; + } + seenDirs.add(dirRelative); + const stop = await onEntry({ + absolutePath: path.join(cwd, dirRelative), + relativePath: dirRelative, + isDirectory: true, + }); + if (stop) { + return {truncated: hitMaxLines}; + } + } + } + + const stop = await onEntry({ + absolutePath: path.join(cwd, relativeFile), + relativePath: relativeFile, + isDirectory: false, + }); + if (stop) { + return {truncated: hitMaxLines}; + } + } + + let hitDirCap = false; + if (includeDirectories && !hitMaxLines) { + ({truncated: hitDirCap} = await walkEmptyDirectories( + cwd, + rootPath, + seenDirs, + onEntry, + projectIgnore, + signal, + maxRawFilesScanned, + )); + } + + return {truncated: hitMaxLines || hitDirCap}; } export async function findMatchingPaths( @@ -213,129 +838,265 @@ export async function findMatchingPaths( cwd: string, maxResults: number, ): Promise<{files: string[]; truncated: boolean}> { + if (maxResults <= 0) { + // The push-then-check loop below always lets one entry through first. + return {files: [], truncated: false}; + } + const hasSlash = normalizePathForMatch(pattern).includes('/'); const files: string[] = []; let truncated = false; - await walkProjectEntries(cwd, undefined, entry => { - if (matchesGlob(entry.relativePath, pattern, !hasSlash)) { - files.push(normalizePathForMatch(entry.relativePath)); - if (files.length >= maxResults) { - truncated = true; - return true; + const walkResult = await walkProjectEntries( + cwd, + undefined, + entry => { + if (matchesGlob(entry.relativePath, pattern, !hasSlash)) { + files.push(normalizePathForMatch(entry.relativePath)); + if (files.length >= maxResults) { + truncated = true; + return true; + } } - } - return false; - }); + return false; + }, + {sorted: false}, + ); + truncated = truncated || walkResult.truncated; return {files, truncated}; } -export async function searchProjectContents( - query: string, +function formatMatchContent(content: string, maxLength: number): string { + if (content.length <= maxLength) { + return content; + } + return `${content.slice(0, maxLength)}…`; +} + +interface RgJsonMatch { + type: string; + data: { + path?: {text?: string}; + line_number?: number; + lines?: {text?: string}; + }; +} + +function parseRgJsonLines(stdout: string): Array<{ + type: 'match' | 'context'; + file: string; + lineNumber: number; + text?: string; +}> { + const results: Array<{ + type: 'match' | 'context'; + file: string; + lineNumber: number; + text?: string; + }> = []; + for (const line of stdout.split('\n')) { + if (!line) { + continue; + } + let parsed: RgJsonMatch; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (parsed.type !== 'match' && parsed.type !== 'context') { + continue; + } + const file = parsed.data.path?.text; + const lineNumber = parsed.data.line_number; + if (file === undefined || lineNumber === undefined) { + continue; + } + results.push({ + type: parsed.type, + file: normalizePathForMatch(file), + lineNumber, + text: parsed.data.lines?.text, + }); + } + return results; +} + +type RgLine = ReturnType[number]; + +function toRelativeFile(cwd: string, file: string): string { + const absolutePath = path.isAbsolute(file) ? file : path.join(cwd, file); + return normalizePathForMatch(path.relative(cwd, absolutePath)); +} + +function buildMatchesWithoutContext( + rgLines: RgLine[], cwd: string, maxResults: number, - caseSensitive: boolean, - include?: string, - searchPath?: string, - wholeWord?: boolean, - contextLines?: number, - timeoutMs: number = DEFAULT_SEARCH_TIMEOUT_MS, -): Promise<{matches: SearchMatch[]; truncated: boolean}> { +): {matches: SearchMatch[]; truncated: boolean} { const matches: SearchMatch[] = []; let truncated = false; - const regex = buildSearchRegex(query, caseSensitive, wholeWord ?? false); - const hasContext = contextLines !== undefined && contextLines > 0; - const normalizedContextLines = Math.max(0, contextLines ?? 0); - const includeHasSlash = include - ? normalizePathForMatch(include).includes('/') - : false; - const controller = new AbortController(); - const timeoutError = new SearchTimeoutError(timeoutMs); - const timer = setTimeout(() => controller.abort(timeoutError), timeoutMs); + for (const {file, lineNumber, text} of rgLines) { + if (text === undefined) { + continue; + } - try { - await walkProjectEntries( - cwd, - searchPath, - async entry => { - if (entry.isDirectory) { - return false; - } + matches.push({ + file: toRelativeFile(cwd, file), + line: lineNumber, + content: formatMatchContent( + text.replace(/\r?\n$/, '').trim(), + MAX_MATCH_CONTENT_LENGTH, + ), + }); - if ( - include && - !matchesGlob(entry.relativePath, include, !includeHasSlash) - ) { - return false; - } + if (matches.length >= maxResults) { + truncated = true; + break; + } + } - let content: string; - try { - content = await readFile(entry.absolutePath, 'utf-8'); - } catch { - return false; - } + return {matches, truncated}; +} - if (isIgnoredByBinaryHeuristics(entry.relativePath, content)) { - return false; - } +function buildMatchesWithContext( + rgLines: RgLine[], + cwd: string, + maxResults: number, + contextLines: number, +): {matches: SearchMatch[]; truncated: boolean} { + const textByFileAndLine = new Map>(); + const matchLinesByFile = new Map(); - const lines = content.split(/\r?\n/); + for (const {type, file, lineNumber, text} of rgLines) { + if (text !== undefined) { + let byLine = textByFileAndLine.get(file); + if (!byLine) { + byLine = new Map(); + textByFileAndLine.set(file, byLine); + } + byLine.set(lineNumber, text.replace(/\r?\n$/, '')); + } - for (let index = 0; index < lines.length; index++) { - const currentLine = lines[index] ?? ''; + if (type === 'match') { + const existing = matchLinesByFile.get(file); + if (existing) { + existing.push(lineNumber); + } else { + matchLinesByFile.set(file, [lineNumber]); + } + } + } - regex.lastIndex = 0; - if (!regex.test(currentLine)) { - continue; - } + const matches: SearchMatch[] = []; + let truncated = false; - const lineNumber = index + 1; - let matchContent = currentLine.trim(); - - if (hasContext) { - const start = Math.max(0, index - normalizedContextLines); - const end = Math.min( - lines.length - 1, - index + normalizedContextLines, - ); - const contextContent = lines - .slice(start, end + 1) - .map((line, offset) => `${start + offset + 1}: ${line}`) - .join('\n'); - matchContent = formatMatchContent( - contextContent, - MAX_CONTEXT_CONTENT_LENGTH, - ); - } else { - matchContent = formatMatchContent( - matchContent, - MAX_MATCH_CONTENT_LENGTH, - ); - } + outer: for (const [file, matchLines] of matchLinesByFile) { + const byLine = textByFileAndLine.get(file); + const relativeFile = toRelativeFile(cwd, file); - matches.push({ - file: normalizePathForMatch(entry.relativePath), - line: lineNumber, - content: matchContent, - }); + for (const lineNumber of matchLines) { + if (byLine?.get(lineNumber) === undefined) { + continue; + } - if (matches.length >= maxResults) { - truncated = true; - return true; - } + const blockLines: string[] = []; + for ( + let line = lineNumber - contextLines; + line <= lineNumber + contextLines; + line++ + ) { + const lineText = byLine?.get(line); + if (lineText !== undefined) { + blockLines.push(`${line}: ${lineText}`); } + } - return false; - }, - controller.signal, - ); - } finally { - clearTimeout(timer); + matches.push({ + file: relativeFile, + line: lineNumber, + content: formatMatchContent( + blockLines.join('\n'), + MAX_CONTEXT_CONTENT_LENGTH, + ), + }); + + if (matches.length >= maxResults) { + truncated = true; + break outer; + } + } } return {matches, truncated}; } + +export async function searchProjectContents( + query: string, + cwd: string, + maxResults: number, + caseSensitive: boolean, + include?: string, + searchPath?: string, + wholeWord?: boolean, + contextLines?: number, + timeoutMs: number = DEFAULT_SEARCH_TIMEOUT_MS, + signal?: AbortSignal, +): Promise<{matches: SearchMatch[]; truncated: boolean}> { + if (maxResults <= 0) { + return {matches: [], truncated: false}; + } + if (!query.trim()) { + throw new Error('Search query cannot be empty'); + } + await assertPathExists(searchPath ?? cwd); + + const projectIgnore = loadGitignore(cwd); + + const args = [ + '--json', + '--hidden', + '--no-ignore-parent', + '--no-require-git', + '--no-config', + '--sort', + 'path', + caseSensitive ? '--case-sensitive' : '--ignore-case', + ]; + if (wholeWord) { + args.push('--word-regexp'); + } + args.push( + '--engine', + POSSESSIVE_QUANTIFIER_PATTERN.test(query) ? 'pcre2' : 'auto', + ); + // Must precede the exclude globs: rg's `-g` is last-wins, so an include after would re-include them. + if (include) { + args.push('-g', include); + } + args.push( + ...nanocoderIgnoreFileArgs(cwd), + ...defaultIgnoreGlobs(projectIgnore), + ...binaryExcludeGlobs(), + ); + const normalizedContextLines = Math.max(0, contextLines ?? 0); + if (normalizedContextLines > 0) { + args.push('--context', String(normalizedContextLines)); + } + args.push('--regexp', query, '--', searchPath ?? cwd); + + // No --max-count (overshoots with --context) - headroom of contextLines covers each match's own trailing context. + const rgMaxCount = Math.max(0, maxResults) + normalizedContextLines; + + const {stdout} = await runRipgrep(args, cwd, timeoutMs, signal, rgMaxCount); + const rgLines = parseRgJsonLines(stdout).filter( + line => !projectIgnore.ignores(toRelativeFile(cwd, line.file)), + ); + + return normalizedContextLines > 0 + ? buildMatchesWithContext(rgLines, cwd, maxResults, normalizedContextLines) + : buildMatchesWithoutContext(rgLines, cwd, maxResults); +} diff --git a/source/utils/generate-export-filename.spec.ts b/source/utils/generate-export-filename.spec.ts new file mode 100644 index 000000000..82ba98a47 --- /dev/null +++ b/source/utils/generate-export-filename.spec.ts @@ -0,0 +1,113 @@ +import test from 'ava'; +import type {Message} from '@/types/core'; +import {generateExportFilename} from './generate-export-filename'; + +const user = (content: string): Message => ({role: 'user', content}); +const assistant = (content: string): Message => ({role: 'assistant', content}); + +test('generates slug from first user message', t => { + const messages = [user('fix the login bug')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^fix-the-login-bug-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('truncates to 4 words', t => { + const messages = [user('add dark mode toggle to the navbar')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^add-dark-mode-toggle-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('handles single word message', t => { + const messages = [user('hello')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^hello-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('strips special characters', t => { + const messages = [user('fix: the auth login')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^fix-the-auth-login-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('trims leading and trailing whitespace', t => { + const messages = [user(' setup react router ')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^setup-react-router-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('handles newlines in first line', t => { + const messages = [user('fix the bug\nin the auth module')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^fix-the-bug-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('falls back when no user messages', t => { + const messages = [assistant('hello')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^nanocoder-chat-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('falls back on empty messages array', t => { + const filename = generateExportFilename([]); + t.regex(filename, /^nanocoder-chat-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('falls back on empty content', t => { + const messages = [user('')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^nanocoder-chat-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('skips empty first line and uses second', t => { + const messages = [user('\nfix the login bug')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^fix-the-login-bug-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('truncates long slug at word boundary', t => { + const messages = [user('a'.repeat(100))]; + const filename = generateExportFilename(messages); + const slug = filename.replace(/-\d{4}-\d{2}-\d{2}\.md$/, ''); + t.true(slug.length <= 40); + t.false(slug.endsWith('-')); +}); + +test('preserves CJK characters in slug', t => { + const messages = [user('修复登录问题')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^修复登录问题-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('preserves Cyrillic characters in slug', t => { + const messages = [user('исправить ошибку входа')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^исправить-ошибку-входа-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('strips emoji while keeping adjacent words', t => { + const messages = [user('fix the 🐛 bug')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^fix-the-bug-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('truncates a long CJK slug at the 40-character limit', t => { + const messages = [user('修'.repeat(100))]; + const filename = generateExportFilename(messages); + const slug = filename.replace(/-\d{4}-\d{2}-\d{2}\.md$/, ''); + // 40 CJK characters still keep the whole filename well under 255 bytes, so + // no byte budget is needed -- the char limit alone suffices. + t.is(slug.length, 40); + t.false(slug.endsWith('-')); + t.true(Buffer.byteLength(filename, 'utf-8') < 255); +}); + +test('truncates a long hyphenated slug at the last whole word', t => { + const messages = [user('fix-the-login-logout-registration-authentication')]; + const filename = generateExportFilename(messages); + const slug = filename.replace(/-\d{4}-\d{2}-\d{2}\.md$/, ''); + t.true(slug.length <= 40); + // Must not split a word: trimming stops at a word boundary, so it must not + // end mid-word with a break inside a hyphenated token. + t.true(/^fix(?:-[a-z]+)*$/.test(slug)); + t.true(slug.length >= 20); +}); diff --git a/source/utils/generate-export-filename.ts b/source/utils/generate-export-filename.ts new file mode 100644 index 000000000..ddd84fd3b --- /dev/null +++ b/source/utils/generate-export-filename.ts @@ -0,0 +1,64 @@ +import type {Message} from '@/types/core'; + +const MAX_WORDS = 4; +const MAX_SLUG_LENGTH = 40; + +function sanitizeSlug(input: string): string { + return input + .toLowerCase() + .replace(/[^\p{L}\p{N}\s-]/gu, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +function truncateAtWordBoundary(slug: string, maxLength: number): string { + if (slug.length <= maxLength) { + return slug; + } + + const truncated = slug.substring(0, maxLength); + const lastHyphen = truncated.lastIndexOf('-'); + return lastHyphen > 0 ? truncated.substring(0, lastHyphen) : truncated; +} + +function generateSlugFromMessages(messages: Message[]): string { + const firstUserMessage = messages.find(m => m.role === 'user'); + if (!firstUserMessage?.content) { + return ''; + } + + const lines = firstUserMessage.content.split('\n'); + let firstLine = ''; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) { + firstLine = trimmed; + break; + } + } + + if (!firstLine) { + return ''; + } + + const words = firstLine.split(/\s+/).filter(Boolean); + const truncated = words.slice(0, MAX_WORDS).join(' '); + return truncateAtWordBoundary(sanitizeSlug(truncated), MAX_SLUG_LENGTH); +} + +export function generateExportFilename(messages: Message[]): string { + const slug = generateSlugFromMessages(messages); + // Deliberately UTC, not local: the date is only there to disambiguate + // exports, and a UTC stamp keeps a session that crosses local midnight (or + // is exported from a different timezone than it was recorded in) ordering + // consistently. Collisions within the same day are handled by + // writeUniqueFile, so a local date would buy nothing. + const date = new Date().toISOString().split('T')[0]; + + if (!slug) { + return `nanocoder-chat-${date}.md`; + } + + return `${slug}-${date}.md`; +} diff --git a/source/utils/gitignore-loader.ts b/source/utils/gitignore-loader.ts index 2b048aa19..ed30962cd 100644 --- a/source/utils/gitignore-loader.ts +++ b/source/utils/gitignore-loader.ts @@ -37,6 +37,23 @@ const DEFAULT_IGNORE_DIRS = [ '.hg', // Mercurial ]; +const NANOCODER_IGNORE_FILENAME = '.nanocoderignore'; + +/** + * Absolute path to the workspace's .nanocoderignore, or undefined when there + * isn't one. + * + * Exists so callers that hand the file to an external tool - ripgrep's + * `--ignore-file`, say - resolve it the same way {@link loadGitignore} does + * rather than re-deriving the filename. + */ +export function findNanocoderIgnoreFile( + workspaceRoot: string, +): string | undefined { + const candidate = join(workspaceRoot, NANOCODER_IGNORE_FILENAME); + return existsSync(candidate) ? candidate : undefined; +} + export interface LoadGitignoreOptions { /** * Whether to layer .nanocoderignore on top of .gitignore. Defaults to true. @@ -76,7 +93,7 @@ export function loadGitignore( const {nanocoderIgnore = true} = options; const ig = ignore(); const gitignorePath = join(workspaceRoot, '.gitignore'); - const nanocoderignorePath = join(workspaceRoot, '.nanocoderignore'); + const nanocoderignorePath = join(workspaceRoot, NANOCODER_IGNORE_FILENAME); // Always ignore common directories ig.add(DEFAULT_IGNORE_DIRS); diff --git a/source/utils/global-handler-slot.ts b/source/utils/global-handler-slot.ts index 4970c47f8..d1ea37bd6 100644 --- a/source/utils/global-handler-slot.ts +++ b/source/utils/global-handler-slot.ts @@ -5,8 +5,13 @@ * handler is registered, `signal()` resolves to a caller-supplied fallback. */ export interface GlobalHandlerSlot { - /** Called once from App.tsx to wire up the UI handler. */ - set(handler: (input: TInput) => Promise): void; + /** + * Wire up the handler. Returns a disposer that restores whatever handler + * was installed before, for callers whose handler is only valid for a + * bounded scope. Callers that own the slot for the process lifetime, such + * as the Ink UI, can ignore it. + */ + set(handler: (input: TInput) => Promise): () => void; /** Called from the tool/executor; resolves with the user's response. */ signal(input: TInput): Promise; } @@ -18,7 +23,15 @@ export function createGlobalHandlerSlot( return { set(next) { + const previous = handler; handler = next; + return () => { + // Only step back if nobody replaced us in the meantime, so a + // later owner is not clobbered by an earlier one's teardown. + if (handler === next) { + handler = previous; + } + }; }, async signal(input) { if (!handler) { diff --git a/source/utils/literal-replace.spec.ts b/source/utils/literal-replace.spec.ts new file mode 100644 index 000000000..46ccffe9f --- /dev/null +++ b/source/utils/literal-replace.spec.ts @@ -0,0 +1,54 @@ +import test from 'ava'; +import {replaceFirstLiteral} from '@/utils/literal-replace'; + +console.log('\nliteral-replace.spec.ts'); + +test('replaceFirstLiteral replaces the first occurrence only', t => { + t.is(replaceFirstLiteral('a b a b', 'a', 'X'), 'X b a b'); +}); + +test('replaceFirstLiteral returns the content unchanged when absent', t => { + t.is(replaceFirstLiteral('hello', 'nope', 'X'), 'hello'); +}); + +test('replaceFirstLiteral does not collapse $$', t => { + t.is(replaceFirstLiteral('pid=X', 'X', '$$'), 'pid=$$'); +}); + +test('replaceFirstLiteral does not expand $& into the match', t => { + t.is(replaceFirstLiteral('a MATCH b', 'MATCH', '$&'), 'a $& b'); +}); + +test('replaceFirstLiteral does not expand $` into the prefix', t => { + t.is(replaceFirstLiteral('BEFORE|X|AFTER', 'X', '$`'), 'BEFORE|$`|AFTER'); +}); + +test("replaceFirstLiteral does not expand $' into the suffix", t => { + t.is(replaceFirstLiteral('BEFORE|X|AFTER', 'X', "$'"), "BEFORE|$'|AFTER"); +}); + +test('replaceFirstLiteral keeps group tokens literal', t => { + t.is(replaceFirstLiteral('X', 'X', '$1 $ $99'), '$1 $ $99'); +}); + +test('replaceFirstLiteral carries every token through in one pass', t => { + const replacement = 'echo "pid=$$ match=$& pre=$` post=$\'"'; + + t.is( + replaceFirstLiteral('#!/bin/sh\necho "old"\nexit 0\n', 'echo "old"', replacement), + `#!/bin/sh\n${replacement}\nexit 0\n`, + ); +}); + +test('replaceFirstLiteral handles an empty replacement (deletion)', t => { + t.is(replaceFirstLiteral('keep DROP keep', 'DROP ', ''), 'keep keep'); +}); + +test('replaceFirstLiteral matches String.replace for $-free input', t => { + const content = 'alpha\nbeta\ngamma\n'; + + t.is( + replaceFirstLiteral(content, 'beta', 'BETA'), + content.replace('beta', 'BETA'), + ); +}); diff --git a/source/utils/literal-replace.ts b/source/utils/literal-replace.ts new file mode 100644 index 000000000..343fc918b --- /dev/null +++ b/source/utils/literal-replace.ts @@ -0,0 +1,28 @@ +/** + * Replace the first occurrence of `search` with `replacement`, treating the + * replacement as literal text. + * + * `String.prototype.replace` runs GetSubstitution over its second argument, so + * `$$`, `$&`, "$`" and `$'` are rewritten before the result is produced. Those + * are ordinary characters in shell scripts, Makefiles, CI YAML and anything + * that builds a regex, so an edit tool that passes model-supplied text straight + * to `replace` silently writes bytes nobody approved — and "$`" / `$'` splice + * a whole half of the file into the middle of the edit. + * + * Splicing by index sidesteps substitution parsing entirely and avoids + * re-scanning the string for a second pass. + */ +export function replaceFirstLiteral( + content: string, + search: string, + replacement: string, +): string { + const index = content.indexOf(search); + if (index === -1) { + return content; + } + + return ( + content.slice(0, index) + replacement + content.slice(index + search.length) + ); +} diff --git a/source/utils/ripgrep-path.spec.ts b/source/utils/ripgrep-path.spec.ts new file mode 100644 index 000000000..9fcc0a4f8 --- /dev/null +++ b/source/utils/ripgrep-path.spec.ts @@ -0,0 +1,33 @@ +import {execFileSync} from 'node:child_process'; +import test from 'ava'; +import {resetRipgrepPathCache, resolveRipgrepPath} from './ripgrep-path.js'; + +console.log(`\nripgrep-path.spec.ts`); + +test.beforeEach(() => { + resetRipgrepPathCache(); +}); + +test.afterEach(() => { + resetRipgrepPathCache(); +}); + +test('resolveRipgrepPath resolves a real, runnable rg binary', async t => { + const rgPath = await resolveRipgrepPath(); + t.truthy(rgPath); + + const output = execFileSync(rgPath, ['--version'], {encoding: 'utf8'}); + t.regex(output, /^ripgrep \d+\.\d+\.\d+/); +}); + +test('resolveRipgrepPath caches the result across calls', async t => { + // beforeEach guarantees a cold cache, so this exercises both the uncached and cached branches instead of two already-warm calls that would be equal either way. + // A timing assertion was tried and dropped - Node's own dynamic import() memoizes the module, so a deliberately broken cache still measured "fast" on the second call. + const first = await resolveRipgrepPath(); + const second = await resolveRipgrepPath(); + t.is(first, second); +}); + +test('resetRipgrepPathCache is safe to call before any resolution', t => { + t.notThrows(() => resetRipgrepPathCache()); +}); diff --git a/source/utils/ripgrep-path.ts b/source/utils/ripgrep-path.ts new file mode 100644 index 000000000..088eb809c --- /dev/null +++ b/source/utils/ripgrep-path.ts @@ -0,0 +1,15 @@ +let cachedPath: string | undefined; + +export async function resolveRipgrepPath(): Promise { + if (cachedPath) { + return cachedPath; + } + + const {rgPath} = await import('@vscode/ripgrep'); + cachedPath = rgPath; + return cachedPath; +} + +export function resetRipgrepPathCache(): void { + cachedPath = undefined; +} diff --git a/source/utils/write-unique-file.spec.ts b/source/utils/write-unique-file.spec.ts new file mode 100644 index 000000000..07ef58a2e --- /dev/null +++ b/source/utils/write-unique-file.spec.ts @@ -0,0 +1,99 @@ +import test from 'ava'; +import type {ExecutionContext} from 'ava'; +import {promises as fs} from 'fs'; +import os from 'os'; +import path from 'path'; +import {writeUniqueFile} from './write-unique-file'; + +// Registers cleanup up front so a mid-test failure still removes the directory +// instead of leaving it behind. +async function tmpDir(t: ExecutionContext): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'write-unique-test-')); + t.teardown(() => fs.rm(dir, {recursive: true, force: true})); + return dir; +} + +test('writes to the given path when it is free', async t => { + const dir = await tmpDir(t); + const filepath = path.join(dir, 'test.md'); + const result = await writeUniqueFile(filepath, 'content'); + t.is(result, filepath); + t.is(await fs.readFile(filepath, 'utf-8'), 'content'); +}); + +test('appends a counter when the path is taken', async t => { + const dir = await tmpDir(t); + const filepath = path.join(dir, 'test.md'); + await fs.writeFile(filepath, 'existing'); + const result = await writeUniqueFile(filepath, 'content'); + t.is(result, path.join(dir, 'test-2.md')); + t.is(await fs.readFile(path.join(dir, 'test-2.md'), 'utf-8'), 'content'); +}); + +test('never overwrites an existing file', async t => { + const dir = await tmpDir(t); + const filepath = path.join(dir, 'test.md'); + const originals = [ + 'test.md', + 'test-2.md', + 'test-3.md', + 'test-4.md', + 'test-5.md', + 'test-6.md', + ]; + for (const name of originals) { + await fs.writeFile(path.join(dir, name), 'existing'); + } + + const result = await writeUniqueFile(filepath, 'content'); + + // Every pre-existing file keeps its content — none may be clobbered. + for (const name of originals) { + t.is(await fs.readFile(path.join(dir, name), 'utf-8'), 'existing'); + } + // The writer must have landed in a fresh, distinct file (timestamp suffix). + t.not(result, filepath); + t.true(result.startsWith(path.join(dir, 'test-new-'))); + t.true(result.endsWith('.md')); + t.is(await fs.readFile(result, 'utf-8'), 'content'); +}); + +test('is atomic: the original is never clobbered by a race', async t => { + const dir = await tmpDir(t); + const filepath = path.join(dir, 'test.md'); + await fs.writeFile(filepath, 'original'); + + // Simulate TWO concurrent exclusive-flag writers for the same target. Only + // one may win the base name; the other must fall to a suffix, and the + // original file's contents must be preserved. + const [a, b] = await Promise.all([ + writeUniqueFile(filepath, 'first'), + writeUniqueFile(filepath, 'second'), + ]); + + t.not(a, filepath); + t.not(b, filepath); + t.not(a, b); + t.is(await fs.readFile(filepath, 'utf-8'), 'original'); +}); + +test('reports a missing parent directory clearly', async t => { + const dir = await tmpDir(t); + const filepath = path.join(dir, 'does-not-exist', 'chat.md'); + + await t.throwsAsync(() => writeUniqueFile(filepath, 'content'), { + message: /Parent directory does not exist/, + }); +}); + +test('keeps every candidate in the target directory', async t => { + const dir = await tmpDir(t); + const filepath = path.join(dir, 'test.md'); + await fs.writeFile(filepath, 'existing'); + + const result = await writeUniqueFile(filepath, 'content'); + + // Suffixes go on the basename only — a collision must never walk the write + // out of the directory the caller validated. + t.is(path.dirname(result), dir); +}); diff --git a/source/utils/write-unique-file.ts b/source/utils/write-unique-file.ts new file mode 100644 index 000000000..3616a444c --- /dev/null +++ b/source/utils/write-unique-file.ts @@ -0,0 +1,69 @@ +import fs from 'fs/promises'; +import path from 'path'; + +const MAX_COLLISION_ATTEMPTS = 5; + +/** + * Finds a free filename next to `filepath` and writes `content` to it + * atomically, returning the path actually written. + * + * The write uses the exclusive flag 'wx' so the free-check and the create are + * a single atomic step: two concurrent writers for the same target can never + * both succeed on the same path (no TOCTOU race, no clobbering). On EEXIST we + * try the next collision suffix (`-2`, `-3`, ...); once the bounded attempts + * are exhausted we fall back to a timestamp suffix, which is cheaper than + * walking a long sequential run. This function never falls through to + * overwriting an existing file — if it cannot find a free name it throws. + * + * Callers must pass an already-validated, containment-checked absolute path + * (see `resolveFilePath`). The suffixes are appended to the basename only, so + * every candidate stays in the same directory as `filepath`. + */ +export async function writeUniqueFile( + filepath: string, + content: string, +): Promise { + const dir = path.dirname(filepath); + const ext = path.extname(filepath); + const base = path.basename(filepath, ext); + + const tryWrite = async (candidate: string): Promise => { + try { + await fs.writeFile(candidate, content, {flag: 'wx'}); + return candidate; + } catch (error) { + if (error && typeof error === 'object' && 'code' in error) { + // Collision: try the next candidate. + if (error.code === 'EEXIST') return null; + // Missing parent directory: report it plainly so the caller knows + // the write failed because a directory doesn't exist. + if (error.code === 'ENOENT') { + throw new Error(`Parent directory does not exist: ${dir}`); + } + } + throw error; + } + }; + + for (let i = 1; i < MAX_COLLISION_ATTEMPTS + 1; i++) { + const suffix = i === 1 ? '' : `-${i}`; + // `filepath` was already validated and containment-checked by the caller, + // so `dir`/`base`/`ext` cannot contain a separator or `..` and this join + // can never leave `dir`. + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + const candidate = path.join(dir, `${base}${suffix}${ext}`); + const written = await tryWrite(candidate); + if (written) return written; + } + + // Bounded attempts all collided, drop a timestamp and try once more. If the + // astronomically-unlikely timestamp collision happens, surface the error + // rather than clobber anything. + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + const timestamped = path.join(dir, `${base}-new-${Date.now()}${ext}`); + const written = await tryWrite(timestamped); + if (!written) { + throw new Error(`Unable to allocate a unique filename for: ${filepath}`); + } + return written; +}