Compare commits

..

57 Commits

Author SHA1 Message Date
lucaronin
dcd0d73848 fix: open vault files externally 2026-04-29 14:25:15 +02:00
lucaronin
0e8c8fb61a docs: add product analytics workflow 2026-04-29 14:24:12 +02:00
lucaronin
42bdd559ef fix: move mcp config copy to settings 2026-04-29 13:51:28 +02:00
lucaronin
3a6c97272d fix: allow pdf asset previews in csp 2026-04-29 13:32:29 +02:00
lucaronin
f53a3746d3 fix: localize recent UI copy 2026-04-29 13:25:22 +02:00
lucaronin
23145a05f5 feat: preview pdf files in app 2026-04-29 13:14:44 +02:00
lucaronin
5b86b0b50d fix: validate active note cache refresh 2026-04-29 12:09:58 +02:00
lucaronin
dc1b1783b6 docs: add localization developer workflow 2026-04-29 12:09:58 +02:00
lucaronin
3dc520abea feat: add safe note open cache 2026-04-29 12:09:58 +02:00
lucaronin
792b3009dd fix: keep saved view reorder UI native 2026-04-29 11:51:22 +02:00
lucaronin
6ee7f219f5 feat: add gemini cli agent support 2026-04-29 11:16:52 +02:00
lucaronin
3db1ed9dc5 fix: split ai permission tooltips 2026-04-29 10:44:20 +02:00
lucaronin
7a7ef5d9ca fix: improve ai permission toggle contrast 2026-04-29 10:35:19 +02:00
Luca Rossi
4c42cf4d52 Updated release download link 2026-04-29 10:30:53 +02:00
lucaronin
405b711e16 revert: restore editor side menu ordering 2026-04-29 10:06:03 +02:00
lucaronin
83705e26f5 docs: add ADRs for gitignored visibility and saved view ordering (guard — from commits d2c15b8, fd92ff5) 2026-04-29 08:03:17 +02:00
lucaronin
434927160c fix: stabilize radix overlay presence 2026-04-29 07:45:02 +02:00
lucaronin
ec2e04a464 test: wait for date picker smoke note load 2026-04-29 06:55:07 +02:00
lucaronin
a17ec689a3 fix: tolerate stale vault watcher listeners 2026-04-29 06:36:39 +02:00
lucaronin
eda4d380e0 fix: let disk decide type file conflicts 2026-04-29 06:09:31 +02:00
lucaronin
f9b155ba6e fix: repair malformed editor block entries 2026-04-29 05:27:39 +02:00
lucaronin
4a62008b1f fix: clamp stale raw editor selections 2026-04-29 05:05:15 +02:00
lucaronin
a08cee7965 refactor: share cli agent runtime scaffold 2026-04-29 04:47:16 +02:00
lucaronin
a7c480af62 fix: normalize stale vault entry metadata 2026-04-29 04:33:18 +02:00
lucaronin
f4790d5763 fix: detect Windows npm ai agent shims 2026-04-29 04:01:53 +02:00
lucaronin
ed52a9e800 fix: preserve frontmatter date picker days 2026-04-29 03:56:44 +02:00
lucaronin
2fcd808f09 fix: detect Windows npm ai agent shims 2026-04-29 03:53:54 +02:00
lucaronin
9d15286f7d fix: stop orphaned mcp server processes 2026-04-29 03:29:16 +02:00
lucaronin
537bd0f3ec fix: normalize Windows vault image paths 2026-04-29 03:02:57 +02:00
lucaronin
648241604f feat: add theme mode command actions 2026-04-29 02:35:52 +02:00
lucaronin
c6f3d9d945 fix: create type files in type folder 2026-04-29 02:24:01 +02:00
lucaronin
65e54f108a fix: render saved view colors 2026-04-29 01:40:16 +02:00
lucaronin
58f8a774b5 chore: ratchet codescene thresholds 2026-04-29 01:26:33 +02:00
lucaronin
0bf280d3ec fix: satisfy ai permission mode clippy 2026-04-29 01:26:33 +02:00
lucaronin
d2d4746d5c feat: add vault ai agent permission modes 2026-04-29 01:26:33 +02:00
lucaronin
91db7c0098 fix: pause saves without active vault 2026-04-29 00:19:47 +02:00
lucaronin
87908d982f fix: allow guidance open path 2026-04-28 23:56:08 +02:00
lucaronin
40544fc551 fix: detect nvm ai agent binaries 2026-04-28 23:39:48 +02:00
lucaronin
f34fdb0289 fix: guard stale suggestion menu actions 2026-04-28 23:29:09 +02:00
lucaronin
e601a42f41 fix: surface ai agent final replies 2026-04-28 23:00:06 +02:00
lucaronin
de627c2478 fix: normalize missing vault metadata 2026-04-28 22:52:07 +02:00
lucaronin
46d9aaed48 fix: dedupe editor toolbar state closes 2026-04-28 21:57:26 +02:00
lucaronin
6b96b5caf6 fix: reset editor selection before note swaps 2026-04-28 21:31:54 +02:00
lucaronin
595c48ca59 fix: apply settings theme changes immediately 2026-04-28 21:16:30 +02:00
lucaronin
fd92ff5e9b feat: allow manual saved view ordering 2026-04-28 20:41:07 +02:00
lucaronin
a39b346a75 fix: detect mise node for mcp setup 2026-04-28 20:28:56 +02:00
lucaronin
deac10caa2 fix: detect nvm pi installs 2026-04-28 19:54:39 +02:00
lucaronin
0e1e0ff47a fix: preserve ai composer line breaks 2026-04-28 19:32:08 +02:00
lucaronin
a645edbc19 fix: repair malformed editor block ids 2026-04-28 18:56:36 +02:00
lucaronin
48f1fb0b50 feat: add gemini cli external ai setup 2026-04-28 18:30:14 +02:00
lucaronin
1aede09834 fix: preserve wikilinks inside markdown tables 2026-04-28 18:00:15 +02:00
lucaronin
d51b76f992 fix: restrict sentry releases to stable builds 2026-04-28 17:17:23 +02:00
lucaronin
8e66fb4c19 fix: preserve table math markdown 2026-04-28 16:47:52 +02:00
lucaronin
a2843a0cf3 fix: persist immediate note creation before open 2026-04-28 15:01:32 +02:00
lucaronin
38fb8a7370 feat: copy mcp config from app 2026-04-28 14:03:10 +02:00
lucaronin
be5a8bb300 fix: make editor left handle draggable 2026-04-28 12:31:13 +02:00
lucaronin
ce040c75fd test: cover table hover stability 2026-04-28 10:46:06 +02:00
263 changed files with 11041 additions and 5453 deletions

View File

@@ -1,21 +0,0 @@
# /start
Start Tolaria in Tauri dev mode.
## Steps
1. Change to the Tolaria workspace:
```bash
cd /Users/luca/Workspace/tolaria
```
2. Start the native development app:
```bash
pnpm tauri dev
```
3. Keep the command running. Report the Vite local URL once it appears and leave the dev process alive for the user.
This is a local utility command, not a Laputa task. Do not run `/laputa-next-task`, CodeScene gates, Todoist updates, commits, or pushes for this command unless the user asks separately.

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=10.0
AVERAGE_THRESHOLD=9.89
AVERAGE_THRESHOLD=9.91

View File

@@ -65,9 +65,6 @@ jobs:
- name: Vite build check
run: pnpm build
- name: Docs build check
run: pnpm docs:build
# ── 1. Coverage-backed tests ──────────────────────────────────────────
# The coverage commands run the same frontend and Rust test suites, so keep
# them as the canonical test lane instead of running every suite twice.

View File

@@ -689,10 +689,10 @@ jobs:
stable-latest.json
# ─────────────────────────────────────────────────────────────
# Phase 4: Update GitHub Pages with docs, release history, and download assets
# Phase 4: Update GitHub Pages
# ─────────────────────────────────────────────────────────────
pages:
name: Update docs and release pages
name: Update release history page
needs: [version, release]
runs-on: ubuntu-latest
permissions:
@@ -703,39 +703,23 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build docs and release pages
- name: Build release history page
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VITEPRESS_BASE="/${GITHUB_REPOSITORY#*/}/" pnpm docs:build
mkdir -p _site/alpha _site/stable
cp -R site/.vitepress/dist/. _site/
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/releases/index.html
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
mkdir -p _site/download
cp _site/stable/download/index.html _site/download/index.html

View File

@@ -740,10 +740,10 @@ jobs:
alpha-latest.json
# ─────────────────────────────────────────────────────────────
# Phase 4: Update GitHub Pages with docs, release history, and download assets
# Phase 4: Update GitHub Pages with release history
# ─────────────────────────────────────────────────────────────
pages:
name: Update docs and release pages
name: Update release history page
needs: [version, release]
runs-on: ubuntu-latest
permissions:
@@ -754,39 +754,23 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build docs and release pages
- name: Build release history page
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VITEPRESS_BASE="/${GITHUB_REPOSITORY#*/}/" pnpm docs:build
mkdir -p _site/alpha _site/stable
cp -R site/.vitepress/dist/. _site/
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
curl -fsSL "${PAGES_URL}/stable/latest.json" -o _site/stable/latest.json || echo '{}' > _site/stable/latest.json
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/releases/index.html
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
mkdir -p _site/download
cp _site/stable/download/index.html _site/download/index.html

3
.gitignore vendored
View File

@@ -10,9 +10,6 @@ lerna-debug.log*
node_modules
dist
dist-ssr
site/.vitepress/cache/
site/.vitepress/dist/
_site/
*.local
# Editor directories and files

View File

@@ -22,7 +22,7 @@ Run `/laputa-next-task` — fetches next task (To Rework first, then Open), move
- Work on `main` branch — **no branches, no PRs, ever**. Pre-commit and pre-push block work from any other branch.
- Commit every 2030 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
- **⛔ NEVER use --no-verify**
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode. You don't need Pencil to use it you can open it as a JSON file.
### 1c. When done
@@ -47,16 +47,14 @@ bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
After both phases pass, add a **completion comment** to the Todoist task before running `/laputa-done`. The comment must include:
- What was implemented (12 lines)
After both phases pass, add a **completion comment** to the Todoist task. The comment must include:
- What was implemented (a few lines covering logic and UX/UI)
- QA: what was tested and how (Playwright / native screenshot / osascript)
- Refactoring: any files refactored to meet the CodeScene gate (or "none needed")
- ADRs: any new/updated ADRs (or "none")
- Docs: any updated docs (ARCHITECTURE.md, ABSTRACTIONS.md, etc.) (or "none")
- Code health: final Hotspot and Average scores after push
Then run `/laputa-done <task_id>` → moves to In Review, notifies Brian, self-dispatches next task.
---
## 2. Development Process
@@ -73,6 +71,22 @@ Red → Green → Refactor → Commit. One cycle per commit. For bugs: write fai
**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests first. Prefer E2E over unit tests for user flows.
### Localization (mandatory for UI copy)
All user-facing UI labels/copy must live in `src/lib/locales/en.json` and be translated into every target listed in `lara.yaml`. When adding or changing interface copy:
```bash
pnpm l10n:translate
```
Use `pnpm l10n:translate:force` only when intentionally regenerating existing translations. Commit `src/lib/locales/*.json`, `lara.yaml`/`lara.lock` changes if produced, and verify placeholders/product names stayed intact.
### Product analytics (mandatory for meaningful features)
New features should almost always emit a PostHog event so we can see whether users actually discover and use them. Skip instrumentation only for very small changes where a dedicated event would create noise. Use clear, stable event names, avoid PII or note content, and include only safe metadata that helps evaluate adoption and failures.
When adding or changing a meaningful user-facing feature, include the event name(s) in the Todoist completion comment alongside QA, docs, and code health. If intentionally not instrumenting a feature, explain why in the completion comment.
### Code health (mandatory)
Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Both gates block commit/push. Thresholds are a **ratchet** — only go up. When pre-push sees improved remote scores, it updates `.codescene-thresholds`, stages it, and stops so you can commit the new floor with normal verified hooks before pushing again. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`.
@@ -122,10 +136,6 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
- **Delete all test notes from disk** when done — do not leave untitled or temporary notes on the filesystem. Run `cd ~/Laputa && git checkout -- . && git clean -fd` to restore the vault to its last committed state.
- **Rationale:** test notes pollute the local vault over time, making it a collection of nonsensical untitled files. The vault must stay clean on disk, not just on the remote.
### UI design
Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task; on completion merge into `ui-design.pen` and delete it.
### UI components — mandatory rules
**Always use shadcn/ui components.** Never use raw HTML form elements (`<input>`, `<select>`, `<button>`, native `<input type="date">`, etc.) for user-facing UI. Every interactive element must use the shadcn/ui equivalent:
@@ -155,10 +165,11 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
- Tauri menu accelerators: `MenuItemBuilder::new(label).accelerator("CmdOrCtrl+1")`
- `app.set_menu()` replaces the ENTIRE menu bar — include all submenus
- `mock-tauri.ts` silently swallows Tauri calls — not a substitute for native testing
### QA scripts
```bash
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh Tolaria
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/out.png
bash ~/.openclaw/skills/tolaria-qa/scripts/shortcut.sh "command" "s"
```

View File

@@ -27,7 +27,7 @@ You can find some Loom walkthroughs below — they are short and to the point:
- 🔬 **Open source** — Tolaria is free and open source. I built this for [myself](https://x.com/lucaronin) and for sharing it with others.
- 📋 **Standards-based** — Notes are markdown files with YAML frontmatter. No proprietary formats, no locked-in data. Everything works with standard tools if you decide to move away from Tolaria.
- 🔍 **Types as lenses, not schemas** — Types in Tolaria are navigation aids, not enforcement mechanisms. There's no required fields, no validation, just helpful categories for finding notes.
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code and Codex CLI (for now), but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code, Codex CLI, and Gemini CLI setup paths, but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
- ⌨️ **Keyboard-first** — Tolaria is designed for power-users who want to use keyboard as much as possible. A lot of how we designed the Editor and the Command Palette is based on this.
- 💪 **Built from real use** — Tolaria was created for manage my personal vault of 10,000+ notes, and I use it every day. Every feature exists because it solved a real problem.
@@ -37,8 +37,6 @@ Download the [latest release here](https://refactoringhq.github.io/tolaria/downl
When you open Tolaria for the first time you get the chance of cloning the [getting started vault](https://github.com/refactoringhq/tolaria-getting-started) — which gives you a walkthrough of the whole app.
The public user docs live in [`site/`](site/) and are intended for GitHub Pages. Start with [Install Tolaria](site/start/install.md), then [First Launch](site/start/first-launch.md).
## Open source and local setup
Tolaria is open source and built with Tauri, React, and TypeScript. If you want to run or contribute to the app locally, here is [how to get started](https://github.com/refactoringhq/tolaria/blob/main/docs/GETTING-STARTED.md). You can also find the gist below 👇
@@ -73,6 +71,12 @@ Tauri 2 on Linux requires WebKit2GTK 4.1 and GTK 3:
The bundled MCP server still spawns the system `node` binary at runtime on Linux, so install Node from your distro package manager if you want the external AI tooling flow.
### Gemini CLI setup
Tolaria can register the active vault's MCP server in `~/.gemini/settings.json` from the status bar or command palette action "Set Up External AI Tools". The generated entry is scoped to the selected vault with `VAULT_PATH` and requires Node.js 18+ plus a separately installed and signed-in Gemini CLI.
Use "Restore Tolaria AI Guidance" if you also want Tolaria to create a non-destructive `GEMINI.md` compatibility shim in the vault root. The shim points Gemini back to the shared `AGENTS.md` instructions and is not written over custom `GEMINI.md` content.
### Quick start
```bash

View File

@@ -157,9 +157,15 @@ interface VaultEntry {
|---|---|---|
| `markdown` or absent | `.md`, `.markdown` | Full Tolaria note model: frontmatter, BlockNote, raw editor, relationships, title sync |
| `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics |
| `binary` | Images, PDFs, archives, other non-text files | Stays a normal vault file; previewable images open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
| `binary` | Images, PDFs, archives, other non-text files | Stays a normal vault file; previewable images and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
Image previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>` and supported PDFs render through the webview's PDF object renderer, both backed by Tauri asset URLs. The "open in default app" action re-enters the vault command boundary through `open_vault_file_external` before delegating to the native opener, so preview and external-open access stay scoped to the active vault. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
### Note Content Freshness
The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. Before showing cached markdown or editor-ready blocks, `useTabManagement` validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery.
Prepared BlockNote blocks in `useEditorTabSwap` are keyed by path plus source content. They can be built ahead of time from prefetched markdown, but they are reused only when the validated raw content for that path is identical to the source content that produced the blocks.
### Entity Types (isA / type)
@@ -175,6 +181,7 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
├── some-topic.md ← type: Topic
├── AGENTS.md ← canonical Tolaria AI guidance
├── CLAUDE.md ← compatibility shim pointing at AGENTS.md
├── GEMINI.md ← optional Gemini CLI shim pointing at AGENTS.md
├── ...
└── type/ ← type definition documents
```
@@ -303,6 +310,12 @@ type SidebarSelection =
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
- Folder deletion clears pending rename state, confirms destructive intent, drops affected folder-scoped tabs, reloads vault data, and resets folder selection if the deleted subtree owned the current selection.
### Saved Views
Saved Views live as YAML files under `views/`. Their definition includes user-visible fields (`name`, `icon`, `color`), note-list preferences (`sort`, `listPropertiesDisplay`), filters, and an optional top-level `order` number. The `order` value is stored directly in the YAML document, not in Markdown frontmatter, and lower values render earlier in every saved-View list. Views without an explicit order sort after ordered views by filename for stable fallback behavior.
The renderer uses `viewOrdering` helpers to convert drag or command-palette move intent into dense order updates before saving each affected view file through `save_view_cmd`. The sidebar keeps the saved View row visually unchanged: pointer users drag the row itself, and keyboard users use the command palette actions for the currently selected saved View.
### Neighborhood Mode
`SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing.
@@ -343,9 +356,9 @@ A `vault_health_check` command detects stray files in non-protected subfolders a
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands refresh the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary.
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. None of those actions mutate vault contents or bypass the backend write boundary.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
### Vault Caching
@@ -602,6 +615,7 @@ The app uses internal light and dark themes owned by Tolaria (see [ADR-0081](adr
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states via `:root` / `[data-theme]`, bridged to Tailwind v4
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
3. **Runtime theme bridge**: Applies `data-theme` and `.dark` for shadcn/ui, while CodeMirror and editor-specific consumers derive any non-CSS-variable values from the same semantic contract
4. **Theme mode commands**: Command-palette actions for light and dark mode call the same `saveSettings` path as the Settings panel and persist only `settings.theme_mode`
## Localization
@@ -669,7 +683,8 @@ No indexing step required — search runs directly against the filesystem.
Per-vault settings stored locally and scoped by vault path:
- Managed by `useVaultConfig` hook and `vaultConfigStore`
- Settings: zoom, view mode, editor mode, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle
- Settings: zoom, view mode, editor mode, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`)
- Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run
- One-time migration from localStorage (`configMigration.ts`)
### AI Guidance Files
@@ -677,8 +692,9 @@ Per-vault settings stored locally and scoped by vault path:
Tolaria tracks managed vault-level AI guidance separately from normal note content:
- `AGENTS.md` is the canonical managed guidance file for Tolaria-aware coding agents
- `CLAUDE.md` is a compatibility shim that points Claude Code back to `AGENTS.md`
- `GEMINI.md` is an optional Gemini CLI compatibility shim that points Gemini back to `AGENTS.md`
- `useVaultAiGuidanceStatus` reads `get_vault_ai_guidance_status` and normalizes the backend state into four UI cases: `managed`, `missing`, `broken`, and `custom`
- `restore_vault_ai_guidance` repairs only Tolaria-managed files; user-authored custom `AGENTS.md` / `CLAUDE.md` files are surfaced as custom and left untouched
- `restore_vault_ai_guidance` repairs only Tolaria-managed files and creates the optional Gemini shim on explicit request; user-authored custom `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` files are surfaced as custom and left untouched
- The status bar AI badge and command palette consume that abstraction to expose restore actions only when the managed guidance is missing or broken
### Getting Started / Onboarding
@@ -697,7 +713,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
`useAiAgentsOnboarding(enabled)` adds a separate first-launch agent step:
- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key)
- Only shows after vault onboarding has already resolved to a ready state
- Uses `get_ai_agents_status`, whose backend treats the app process path, login-shell path, and supported local/toolchain/app install locations, including Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
- Uses `get_ai_agents_status`, whose backend treats the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
- Persists dismissal locally once the user continues
### Remote Git Operations
@@ -726,12 +742,12 @@ interface Settings {
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
theme_mode: 'light' | 'dark' | null
ui_language: AppLocale | null
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | null
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null
hide_gitignored_files: boolean | null // null = default true
}
```
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette light/dark actions both update that same value. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
## Telemetry
@@ -743,9 +759,9 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`useTelemetry(settings, loaded)`** — Reactively initializes/tears down Sentry and PostHog based on settings. Called once in `App`.
### Libraries
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/release/key from `VITE_SENTRY_DSN`, `VITE_SENTRY_RELEASE`, and `VITE_POSTHOG_KEY` env vars.
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/key from `VITE_SENTRY_DSN` and `VITE_POSTHOG_KEY`; `VITE_SENTRY_RELEASE` is treated as the build version and only becomes Sentry's `release` for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds tag `tolaria.build_version` and `tolaria.release_kind` without creating normal Sentry Releases entries.
- **`src/main.tsx`** — React root error callbacks (`onCaughtError`, `onUncaughtError`, `onRecoverableError`) forward component-stack context to `Sentry.reactErrorHandler()` for debuggable production React errors.
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes. `reinit_sentry()` for runtime toggle.
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes; stable calendar `CARGO_PKG_VERSION` values become Sentry releases, while alpha/prerelease/internal versions are kept as diagnostic tags only. `reinit_sentry()` for runtime toggle.
### Tauri Commands
- **`reinit_telemetry`** — Re-reads settings and toggles Rust Sentry on/off. Called from frontend when user changes crash reporting setting.
@@ -771,6 +787,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
### CI/CD
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`.
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -89,6 +89,12 @@ flowchart LR
The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and the clean active editor all refresh under the ADR-0071 unsaved-edit rules. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
#### Note Opening Fast Path
Note opening uses a bounded in-memory fast path split across raw content and editor-ready blocks. `useTabManagement` owns the raw markdown prefetch cache and `useEditorTabSwap` owns the prepared BlockNote block cache. Cached or preloaded markdown is never rendered directly: before reusing it, the renderer calls the `validate_note_content` Tauri command, which compares the cached text with the current file bytes inside the validated vault boundary. If validation fails, Tolaria discards the cached entry and reads fresh disk content before swapping the editor.
The note list opportunistically preloads visible and adjacent markdown/text entries after a short idle delay. Once raw content resolves, the editor prepares BlockNote blocks in the background when it is mounted and not in raw mode. This targets the expensive markdown-to-editor conversion stage while keeping filesystem content authoritative and keeping preload memory bounded by the same cache limits as ordinary note switches.
## Tech Stack
| Layer | Technology | Version |
@@ -186,9 +192,9 @@ flowchart TD
└──────────────────────────────────────────────────────────────┘
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image binaries get an image indicator and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, while keyboard users can use command-palette move actions. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
Panels are separated by `ResizeHandle` components that support drag-to-resize.
@@ -224,12 +230,13 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration.
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters
3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, a file/search-only built-in tool list, hidden Windows subprocess launches, and closed stdin for print-mode subprocesses so Windows launches receive EOF; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json`; OpenCode runs through `opencode run --format json`; Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`. OpenCode and Pi both launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
4. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, and Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-agent selection, and the per-vault Safe / Power User permission mode shown in the panel header
2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents
4. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools, while Power User adds Bash without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs` and run through `codex --sandbox workspace-write --ask-for-approval never exec --json` in both modes. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config. Gemini runs through `gemini --output-format json --prompt` with Safe using `auto_edit` plus `tools.exclude=["run_shell_command"]` and Power User using `yolo` against a trusted transient Tolaria MCP entry. OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs.
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs.
#### Agent Event Flow
@@ -243,7 +250,7 @@ sequenceDiagram
U->>FE: sendMessage(text, references)
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath})
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath, permissionMode})
R->>R: pick adapter for claude_code, codex, opencode, or pi
R->>C: spawn agent with MCP-enabled config
@@ -294,7 +301,7 @@ Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existi
## MCP Server
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Gemini CLI, Cursor, or any MCP-compatible client).
### Tool Surface (14 tools)
@@ -326,10 +333,11 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
Tolaria can register itself as an MCP server in:
- `~/.claude.json` and `~/.claude/mcp.json` (Claude Code compatibility across current CLI and legacy MCP-file setups)
- `~/.gemini/settings.json` (Gemini CLI)
- `~/.cursor/mcp.json` (Cursor)
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`). The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`.
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
### Architecture
@@ -376,11 +384,12 @@ flowchart LR
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes |
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Cursor, and generic MCP configs on user request |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Cursor, and generic MCP configs |
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Gemini CLI, Cursor, and generic MCP configs on user request |
| `mcp_config_snippet(vault_path)` | Builds the exact `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, and generic MCP configs |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed on app exit via the `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed plus waited on app exit via the `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
## Search
@@ -425,7 +434,7 @@ The app uses internal app-owned light and dark themes (see [ADR-0081](adr/0081-i
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states. Bridged to Tailwind v4 via `@theme inline`.
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`; editor colors resolve through the same semantic app variables.
3. **Theme runtime**: Applies `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected.
3. **Theme runtime**: Applies `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected. Settings and command-palette theme actions both write the same installation-local `settings.theme_mode` value.
## Localization
@@ -472,11 +481,11 @@ When an opened folder is not yet a git repo, Tolaria shows a dismissible Git set
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, OpenCode, and Pi are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, OpenCode, Pi, and Gemini CLI are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. Optional `GEMINI.md` guidance is created only by the explicit AI guidance restore action. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
After the clone completes, Tolaria removes every configured git remote from the new starter vault. Getting Started vaults therefore open as local-only by default, and users opt into a remote later with the explicit Add Remote flow.
@@ -625,7 +634,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` shims), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
| `getting_started.rs` | Clones and normalizes the public Getting Started starter vault |
## Rust Backend Modules
@@ -636,8 +645,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
| `search.rs` | Keyword search — walkdir-based vault file scan with Gitignored-content visibility filtering |
| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch |
| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing |
| `ai_agents.rs` | CLI-agent request normalization and adapter dispatch |
| `cli_agent_runtime.rs` | Shared CLI-agent request, prompt, subprocess, version, and MCP path helpers |
| `claude_cli.rs`, `codex_cli.rs`, `opencode_cli.rs` | Claude Code, Codex, and OpenCode command/config/event adapters |
| `pi_cli.rs`, `pi_config.rs`, `pi_discovery.rs`, `pi_events.rs` | Pi subprocess launch, transient MCP adapter config, discovery, and JSON stream parsing |
| `mcp.rs` | MCP server spawning + explicit config registration/removal |
| `commands/` | Tauri command handlers (split into submodules) |
@@ -666,11 +676,12 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `batch_delete_notes` | Permanently delete notes from disk |
| `reload_vault` | Sync the active vault asset scope, invalidate cache, full rescan from filesystem, then apply Gitignored-content visibility → `Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `open_vault_file_external` | Validate an existing file against the active vault boundary, then open it with the system default app |
| `start_vault_watcher` / `stop_vault_watcher` | Start or stop native active-vault filesystem change events |
| `check_vault_exists` | Check if vault path exists |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.md` defaults |
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` guidance are managed, missing, broken, or custom |
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
### Frontmatter
@@ -724,9 +735,10 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `check_claude_cli` | Check if Claude CLI is available |
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi availability |
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, or Pi through the normalized agent event layer |
| `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor/generic config |
| `register_mcp_tools` | Register MCP in Claude/Gemini/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Gemini/Cursor/generic config |
| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault |
| `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected |
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
@@ -785,14 +797,14 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
| `useTheme` | Editor theme CSS vars and theme-mode bridge | Editor typography and app theme runtime |
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline |
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline and vault permission mode |
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints |
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
| `useVaultConfig` | Per-vault UI preferences, AI permission mode | Vault-specific config |
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
@@ -942,7 +954,7 @@ sequenceDiagram
**Architecture:**
- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()`
- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook; the React root also wires `onCaughtError`, `onUncaughtError`, and `onRecoverableError` through `Sentry.reactErrorHandler()` so production React invariants include component stack context when crash reporting is enabled.
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version so frontend Sentry events group by the shipped alpha or stable version.
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version, but the app only assigns Sentry's `release` field for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds omit `release` so they do not create normal Sentry Releases entries, while both frontend and Rust Sentry scopes tag `tolaria.build_version` and `tolaria.release_kind` for diagnostics.
- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct
- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null`

View File

@@ -79,7 +79,7 @@ tolaria/
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent)
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent + per-vault permission mode)
│ │ ├── AiMessage.tsx # Agent message display
│ │ ├── AiActionCard.tsx # Agent tool action cards
│ │ ├── AiAgentsOnboardingPrompt.tsx # First-launch AI agent installer prompt
@@ -114,7 +114,8 @@ tolaria/
│ │ ├── useNoteCreation.ts # Note/type creation
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi availability polling
│ │ ├── aiAgentPermissionMode.ts # Safe/Power User mode normalization + labels
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi/Gemini availability polling
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
│ │ ├── useAiActivity.ts # MCP UI bridge listener
│ │ ├── useAutoSync.ts # Auto git pull/push
@@ -188,9 +189,11 @@ tolaria/
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
│ │ ├── telemetry.rs # Sentry init + path scrubber
│ │ ├── search.rs # Keyword search (walkdir-based)
│ │ ├── ai_agents.rs # Shared CLI-agent detection + stream adapters
│ │ ├── claude_cli.rs # Claude CLI subprocess management
│ │ ├── pi_cli.rs # Pi CLI subprocess management
│ │ ├── ai_agents.rs # CLI-agent request normalization + adapter dispatch
│ │ ├── cli_agent_runtime.rs # Shared CLI-agent runtime process/prompt/MCP helpers
│ │ ├── claude_cli.rs # Claude CLI adapter
│ │ ├── codex_cli.rs # Codex CLI adapter
│ │ ├── pi_cli.rs # Pi CLI adapter
│ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal
│ │ ├── app_updater.rs # Alpha/stable updater endpoint selection
│ │ ├── settings.rs # App settings persistence
@@ -261,9 +264,9 @@ tolaria/
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). |
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, adapter dispatch, and stream normalization. |
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
| `src-tauri/src/pi_cli.rs` | Pi subprocess spawning through JSON mode and transient MCP adapter config. |
| `src-tauri/src/ai_agents.rs` | CLI-agent request normalization, availability aggregation, adapter dispatch, and Claude event mapping. |
| `src-tauri/src/cli_agent_runtime.rs` | Shared CLI-agent request shape, prompt wrapping, JSON subprocess lifecycle, version probing, and MCP path helpers. |
| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `src-tauri/src/gemini_cli.rs` | Per-agent command, config, discovery, and JSON event adapters. |
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
### Editor
@@ -282,9 +285,10 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, and actions. |
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, actions, and per-vault permission mode. |
| `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. |
| `src/lib/aiAgentSession.ts` | Single message/session lifecycle for prompt normalization, history, streaming, and reset behavior. |
| `src/lib/aiAgentPermissionMode.ts` | Safe/Power User mode normalization, display labels, and local transcript marker text. |
| `src/lib/aiAgentFileOperations.ts` | Detects agent-created or modified vault files from normalized tool inputs. |
| `src/lib/aiAgents.ts` | Supported agent definitions, status normalization, and default-agent helpers. |
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
@@ -304,7 +308,7 @@ tolaria/
| `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). |
| `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. |
| `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. |
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). |
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, AI permission mode). |
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, default AI agent, and the vault-level explicit organization toggle. |
| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
@@ -341,7 +345,7 @@ type SidebarSelection =
### Command Registry
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
@@ -420,11 +424,14 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string)
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`; keep app-managed launches on strict Tolaria MCP config, `acceptEdits`, and the scoped file/search tool list)
5. **Shared agent adapters / Codex/Pi args**: Edit `src-tauri/src/ai_agents.rs` plus the per-agent adapter modules (keep Codex sandboxed with active-vault `workspace-write`, keep Pi on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode)
4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts`
5. **Shared CLI runtime behavior**: Edit `src-tauri/src/cli_agent_runtime.rs` for process lifecycle, prompt wrapping, version probing, and common Tolaria MCP path handling.
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`). Keep Codex sandboxed with active-vault `workspace-write`, keep Pi and Gemini on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode.
### Work with external MCP setup
1. **Backend registration/status**: Edit `src-tauri/src/mcp.rs`; registration must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and write an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx`; users should see the Node.js prerequisite and the manual config shape before Tolaria writes third-party config files
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; app-managed Gemini sessions still require the user to install and sign in to Gemini CLI, but Tolaria supplies transient MCP settings when Gemini is selected as the default AI agent
5. **Process lifecycle**: Stdio MCP servers in `mcp-server/index.js` must exit when their external client closes stdin, and the desktop-owned `ws-bridge.js` child must be stopped on vault deselection, vault switch, and app exit

View File

@@ -1,34 +0,0 @@
# Public Docs Plan
This document records the phase 1 information architecture for public Tolaria documentation. The public docs source lives in `site/`; the existing `docs/` directory remains contributor, architecture, and agent context.
## Audiences
| Audience | Needs | Primary location |
|---|---|---|
| New users | Install, first launch, understand the app layout, clone the starter vault | `site/start/` |
| Active users | Learn concrete workflows such as organizing, Git sync, custom views, and AI | `site/guides/` |
| Power users | Understand file layout, frontmatter, filters, shortcuts, and platform support | `site/reference/` |
| Contributors and agents | Architecture, abstractions, ADRs, development workflow | `docs/`, `AGENTS.md` |
## Hosting Shape
The GitHub Pages output should reserve the root for public docs and mount release assets underneath it:
```text
/ public docs home
/releases/ release history
/download/ latest stable download redirect
/stable/latest.json
/alpha/latest.json
/latest.json compatibility alias for alpha latest
/latest-canary.json compatibility alias for alpha latest
```
Every user-visible app change should answer:
```text
Public docs impact:
- updated: <pages>
- not needed because: <reason>
```

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0091"
title: "Gemini CLI external AI setup"
status: active
date: 2026-04-28
---
## Context
Tolaria already supports explicit MCP setup for external desktop AI tools. Users asked for Gemini CLI support so the same active-vault MCP server can be registered where Gemini reads tool configuration, with optional Gemini-specific vault guidance.
Gemini CLI reads MCP server definitions from `~/.gemini/settings.json` and can load project guidance from `GEMINI.md`. Tolaria must support those conventions without silently rewriting global user settings or overwriting user-authored vault instructions.
## Decision
Tolaria adds `~/.gemini/settings.json` to the explicit external AI setup path list. The existing MCP entry shape is reused: `mcpServers.tolaria` runs the packaged stdio server through Node.js, pins `VAULT_PATH` to the selected vault, and sets `WS_UI_PORT=9711`.
Vault guidance keeps `AGENTS.md` as the canonical shared source. `restore_vault_ai_guidance` can create or repair a managed `GEMINI.md` shim that imports `AGENTS.md`, while bootstrap and repair flows continue to seed only required Tolaria guidance (`AGENTS.md` and `CLAUDE.md`) plus type scaffolding. Custom `GEMINI.md` files are classified as custom and are not overwritten.
The setup dialog documents that Gemini CLI still needs its own install and sign-in. Tolaria does not store Gemini credentials or model-provider API keys.
## Options Considered
- **Add Gemini to explicit MCP setup and optional guidance restore** (chosen): matches the existing consent boundary, preserves user settings, and gives Gemini the shared vault context.
- **Generate `GEMINI.md` automatically for every vault**: simpler discovery, but turns an optional third-party compatibility file into startup side effect and dirties vaults for users who do not use Gemini.
- **Document manual Gemini setup only**: avoids code changes, but leaves users to transpose paths and loses the active-vault safety already available for other MCP clients.
- **Create a Gemini-specific MCP entry shape**: unnecessary because Gemini accepts the same `mcpServers` entry structure used by the existing Tolaria MCP server.
## Consequences
- External AI setup now writes/removes Tolaria's MCP entry in Claude, Gemini, Cursor, and generic MCP config files.
- Gemini users can create a managed `GEMINI.md` shim without duplicating the canonical `AGENTS.md` guidance.
- Existing vault bootstrap and repair remain non-invasive for users who do not use Gemini.
- Native QA requires a local Gemini CLI install and authentication for an end-to-end Gemini prompt; otherwise the app can still verify the generated config and documentation paths.

View File

@@ -0,0 +1,39 @@
---
type: ADR
id: "0092"
title: "Vault-scoped AI agent permission modes"
status: active
date: 2026-04-28
---
## Context
ADR-0074 established explicit setup and least-privilege defaults for desktop AI tools. The in-app AI panel now supports multiple local CLI agents, and users need a clear per-vault way to choose whether an agent should stay in the narrow vault-safe profile or use broader local-work tools for that vault.
The mode must be visible at the point of use, must not mutate global CLI settings, and must not silently restore dangerous bypass flags. Existing transcripts should remain intact when the mode changes because a change applies to the next agent run, not to a process that is already streaming.
## Decision
**Tolaria stores an `ai_agent_permission_mode` per vault with values `safe` and `power_user`, defaulting missing or null values to `safe`, and passes that normalized mode through the AI panel stream request into each CLI adapter.**
The AI panel header displays the current mode and offers a compact Vault Safe / Power User control that is disabled while an agent run is active. Changing the mode preserves the transcript and inserts a local transcript marker.
Adapter mappings remain conservative:
- Claude Code Safe keeps `acceptEdits`, strict Tolaria MCP config, and file/search/edit tools only; Power User adds Bash to the allowed tool list without using `--dangerously-skip-permissions`.
- Codex keeps the active-vault `workspace-write` sandbox and `--ask-for-approval never` in both modes.
- OpenCode uses transient `OPENCODE_CONFIG_CONTENT`; Safe denies bash and external directories, while Power User allows bash but still denies external directories.
- Pi receives the mode on the adapter request path; both modes currently use the same transient MCP adapter config.
## Options Considered
- **Per-vault Safe / Power User modes** (chosen): makes the permission surface explicit where the agent is used and preserves least-privilege defaults for each vault.
- **Global app setting**: simpler storage, but a single toggle can over-apply a power-user profile to unrelated vaults.
- **Dangerous bypass mode**: maximizes CLI freedom, but violates ADR-0074's least-privilege boundary and needs a separate explicit security decision.
- **Adapter-specific UI switches**: exposes too much implementation detail and makes cross-agent behavior harder to reason about.
## Consequences
- Vault config normalization owns the safe default for old vaults and malformed values.
- Agent requests now carry a permission mode through frontend and Rust boundaries, so new adapters must choose an explicit mapping.
- Power User is intentionally not equivalent across agents; where an adapter lacks a safe broader local-work switch, both modes may map to the same conservative behavior and must document that with tests.
- Any future dangerous mode requires a new ADR and separate UI language.

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0093"
title: "Shared CLI agent runtime adapters"
status: active
date: 2026-04-29
---
## Context
Tolaria supports Claude Code, Codex, OpenCode, and Pi as local CLI agents in the AI panel. Each agent has different command-line arguments, configuration shape, and JSON event schema, but the Rust backend had grown repeated runtime plumbing around those differences: request shapes, prompt wrapping, subprocess launch, stdout JSON reading, stderr capture, exit handling, done events, version probing, and Tolaria MCP server path resolution.
That duplication made small runtime fixes expensive because they had to be repeated across several adapter files. It also kept Codex-specific command and event mapping inside `ai_agents.rs`, making the top-level module both an orchestrator and a bespoke adapter.
## Decision
**Tolaria uses `cli_agent_runtime.rs` as the shared runtime scaffold for app-managed CLI agents, while `ai_agents.rs` only normalizes and dispatches requests to per-agent adapter modules.**
The shared scaffold owns the common agent request shape, system/user prompt wrapping, JSON-line process lifecycle, normalized error/done handling for `AiAgentStreamEvent` adapters, version probing, and Tolaria MCP server path resolution. Per-agent modules keep the provider-specific pieces: binary discovery candidates, command arguments, transient config shape, authentication error wording, and JSON event mapping.
Codex now lives in `codex_cli.rs`, matching the Claude, OpenCode, and Pi adapter boundary. `ai_agents.rs` remains the Tauri-facing orchestrator that chooses an adapter and maps Claude's legacy event enum into the normalized event stream.
## Options Considered
- **Shared runtime scaffold with thin adapters** (chosen): reduces repeated process lifecycle code without hiding provider-specific command/config/event behavior.
- **One trait object per agent**: more uniform on paper, but adds indirection without removing much current complexity.
- **Leave each adapter self-contained**: keeps local readability for a single file, but new process, prompt, and MCP fixes continue to land in parallel.
- **Fully generic event mapping**: over-abstracts the JSON schemas and makes provider-specific edge cases harder to test.
## Consequences
- Runtime lifecycle fixes should usually start in `cli_agent_runtime.rs`.
- New agent adapters should use the shared request/prompt/process helpers and keep only command, config, discovery, and event mapping local.
- `ai_agents.rs` should not grow provider-specific runtime code again; it should normalize the frontend request, dispatch to an adapter, and map any legacy event shape.
- The shared scaffold deliberately does not erase provider differences. Authentication messages, permission semantics, and transient config formats remain adapter-owned and must stay covered by adapter tests.

View File

@@ -0,0 +1,37 @@
---
type: ADR
id: "0094"
title: "Gitignored content visibility as a command-boundary filter"
status: active
date: 2026-04-29
---
## Context
Tolaria's vault scanner now indexes more of the real filesystem so Folder views, search, and reload flows can reflect what is actually in the vault. In Git-backed vaults, that includes generated, local-only, or machine-specific content that users intentionally hide through `.gitignore`.
Always showing Gitignored files makes Folder lists and search noisy, especially in vaults that contain exports, build artifacts, or personal local scratch files. But removing those files during scanning would make visibility dependent on cache shape, complicate toggling, and blur the distinction between "what exists in the vault" and "what this installation chooses to surface."
## Decision
**Tolaria keeps the vault scan and cache complete, then applies Gitignored-content visibility at the command boundary before entries, folders, or search results reach React.**
- `hide_gitignored_files` is an installation-local app setting and defaults to `true`.
- Visibility checks use batched `git check-ignore --no-index --stdin` so Tolaria follows normal Git ignore and negation semantics as closely as practical.
- `list_vault`, `reload_vault`, `list_vault_folders`, and keyword search all apply the same filter when the setting is enabled.
- Toggling the setting reloads the current vault surfaces instead of rebuilding a different cache format.
- If a vault has no `.gitignore`, or Gitignored visibility is turned off, Tolaria shows the full scanned result.
## Options considered
- **Complete scan/cache + boundary filter** (chosen): keeps the filesystem model authoritative, makes toggling cheap and consistent, and avoids cache divergence.
- **Skip Gitignored content during scan/cache**: reduces later filtering work, but makes visibility part of the persisted cache shape and complicates instant toggling.
- **Always show Gitignored content**: simplest implementation, but too noisy for real Git-backed vaults and undermines users' existing ignore rules.
## Consequences
- Gitignored visibility is a per-installation comfort preference, not vault-authored shared metadata.
- Search, folder lists, and note reloads stay aligned because they all consult the same boundary filter.
- The cache can still support future visibility changes without a data migration.
- Users can reveal ignored content again immediately by disabling the setting.
- Future features that expose vault file lists should apply the same boundary filter unless they intentionally need raw filesystem output.

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0095"
title: "Saved views use an explicit YAML order field"
status: active
date: 2026-04-29
---
## Context
Saved Views already persist as user-editable YAML files in the vault and sync through Git. Filename ordering was stable, but it forced users to rename files just to change sidebar order and gave Tolaria no durable way to support drag reordering, move actions, or keyboard-first ordering controls.
The ordering choice also needs to travel with the view definition itself. Saved Views are part of the vault's shared information architecture, not a machine-local preference.
## Decision
**Each Saved View may store an optional top-level `order` number in its YAML definition, and Tolaria sorts views by that value before falling back to filename.**
- Lower `order` values render earlier in the sidebar and other Saved View lists.
- Views without `order` sort after ordered views and then fall back to filename ordering for stability.
- Reordering actions rewrite affected view files with a dense sequence of order values instead of encoding position in filenames.
- The same persisted order supports drag handles, explicit move buttons, and command-palette ordering actions.
## Options considered
- **Explicit `order` field in the view YAML** (chosen): portable, Git-syncable, easy to inspect by hand, and consistent with the existing file-first view model.
- **Filename-based ordering only**: no schema change, but makes reordering clumsy and couples user-visible structure to file naming.
- **App-local ordering state**: easy to prototype, but breaks cross-device consistency and separates ordering from the view artifact users already version.
## Consequences
- Saved View ordering becomes part of the vault and syncs naturally through Git.
- Existing views remain valid; unordered files keep a stable fallback sort until reordered.
- Reordering can touch multiple view files in one action because Tolaria normalizes the sequence.
- Future Saved View features should treat `order` as part of the shared YAML schema rather than introducing a parallel ordering store.

View File

@@ -0,0 +1,42 @@
---
type: ADR
id: "0097"
title: "Gemini CLI agent adapter"
status: active
date: 2026-04-29
---
## Context
ADR 0091 added Gemini CLI to explicit external MCP setup, but Gemini was still absent from Tolaria's selectable app-managed AI agents. That left the AI panel able to generate Gemini-compatible MCP configuration while the actual agent picker, availability checks, install links, and streaming dispatch did not treat Gemini like Claude Code, Codex, OpenCode, or Pi.
Gemini CLI supports headless `--prompt` execution with JSON output, configurable approval modes, tool exclusion, and settings-file overrides through `GEMINI_CLI_SYSTEM_SETTINGS_PATH`. Those features are enough to launch Gemini from Tolaria without mutating the user's durable `~/.gemini/settings.json` during app-managed sessions.
## Decision
Tolaria adds Gemini CLI as a first-class `AiAgentId`. The frontend agent definitions, onboarding prompt, install links, default-agent normalization, status badge, command registry, settings persistence, and mock Tauri status payloads include `gemini`.
The desktop backend adds a Gemini adapter that:
- discovers `gemini` through the process path, login shell, and common local/toolchain install locations
- runs `gemini --output-format json --approval-mode <mode> --prompt <prompt>` from the active vault
- supplies Tolaria MCP through a temporary settings file referenced by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
- uses Safe mode with `auto_edit`, an untrusted MCP entry, and `tools.exclude=["run_shell_command"]`
- uses Power User mode with `yolo` and a trusted Tolaria MCP entry
- maps Gemini JSON responses into Tolaria's existing AI panel stream events
The existing external MCP setup remains explicit and durable. The app-managed Gemini adapter uses transient settings so selecting Gemini in Tolaria does not rewrite the user's global Gemini config.
## Options Considered
- **Add Gemini as a first-class app-managed agent** (chosen): matches the existing agent picker and onboarding UI, uses Gemini's headless JSON mode, and keeps MCP setup vault-scoped.
- **Keep Gemini as external MCP setup only**: avoids another adapter, but keeps the interface inconsistent and requires users to leave Tolaria for a flow that other agents support in-panel.
- **Write app-managed Gemini config into `~/.gemini/settings.json`**: reuses the external setup path, but would blur the consent boundary and risk overwriting user preferences during normal AI panel usage.
- **Use interactive Gemini sessions**: could preserve richer CLI state, but does not fit Tolaria's current one-request stream lifecycle and would make cleanup/auth/error handling harder.
## Consequences
- Gemini appears anywhere users can choose, install, or switch local AI agents.
- End-to-end native Gemini QA requires the Gemini CLI to be installed and authenticated, but missing/auth failures now produce agent-specific guidance.
- Safe and Power User behavior is limited by Gemini's own approval/tool semantics; if Gemini changes those names, the adapter tests and docs need updating.
- The durable MCP setup path and optional `GEMINI.md` shim continue to serve external Gemini usage outside Tolaria's AI panel.

View File

@@ -0,0 +1,29 @@
---
type: ADR
id: "0098"
title: "In-app image and PDF previews for binary vault files"
status: active
date: 2026-04-29
supersedes: "0086"
---
## Context
ADR-0086 introduced the `FilePreview` path for image binaries while keeping binary files as ordinary `VaultEntry` records. The same file-first model should now cover PDFs, because asset-heavy vaults often mix screenshots, diagrams, and document exports that users need to inspect without leaving Tolaria.
## Decision
**Tolaria previews supported image and PDF files in the editor pane while keeping them as ordinary binary vault files.**
- The scanner keeps the coarse `fileKind: "binary"` representation. Previewability stays a renderer concern inferred from the file extension in `src/utils/filePreview.ts`.
- Supported images render with `<img>` and supported PDFs render with the webview PDF object renderer, both using Tauri asset URLs from `convertFileSrc`.
- The Tauri CSP permits scoped asset URLs in `object-src` so PDF objects can load vault-backed files without broadening script, connect, or image policy.
- PDF preview fallback content lives inside the PDF object so unsupported or failed renderers still expose an explicit "Open in default app" escape hatch.
- Note-list rows for previewable images and PDFs remain clickable and carry file-specific indicators; unsupported binary rows stay muted and non-clickable.
- `Escape` on the preview surface returns keyboard focus to the note list, matching the existing image-preview keyboard behavior.
## Consequences
- PDFs do not become notes and do not get Markdown editor semantics.
- The asset preview surface can keep growing to additional safe binary formats without changing the vault scanner or persisted cache shape.
- Broken PDFs may rely on the webview's own renderer failure state, but the surrounding Tolaria preview chrome still provides reveal, copy path, and default-app actions.

View File

@@ -142,3 +142,11 @@ proposed → active → superseded
| [0085](0085-non-git-vault-support.md) | Non-git vaults open with explicit later Git initialization | active |
| [0088](0088-markdown-durable-mermaid-diagrams.md) | Markdown-durable Mermaid diagrams in notes | active |
| [0089](0089-active-vault-filesystem-watcher.md) | Active vault filesystem watcher | active |
| [0090](0090-pi-cli-agent-adapter.md) | Pi CLI agent adapter | active |
| [0091](0091-gemini-cli-external-ai-setup.md) | Gemini CLI external AI setup | active |
| [0092](0092-vault-ai-agent-permission-modes.md) | Vault-scoped AI agent permission modes | active |
| [0093](0093-shared-cli-agent-runtime-adapters.md) | Shared CLI agent runtime adapters | active |
| [0094](0094-gitignored-content-visibility-boundary-filter.md) | Gitignored content visibility as a command-boundary filter | active |
| [0095](0095-saved-view-order-field.md) | Saved views use an explicit YAML order field | active |
| [0097](0097-gemini-cli-agent-adapter.md) | Gemini CLI agent adapter | active |
| [0098](0098-in-app-image-and-pdf-file-previews.md) | In-app image and PDF previews for binary vault files | active |

View File

@@ -14,6 +14,7 @@ files:
command.openLanguageSettings.keywords: 68efcb7e847b1a9d472baa609824be80
command.useSystemLanguage: b7f0315c47d4a59faa2a49571fcf1fca
command.openH1Setting: 05b4f6edc0e98b29670e8ee902cdb9bf
command.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
command.contribute: 9887a4451812854f0f1b6f669a874307
command.checkUpdates: f77f38f684c8b974dd4a56bb321cfd5f
command.group.navigation: 846495f9ceed11accf8879f555936a7d
@@ -70,6 +71,10 @@ files:
command.view.toggleAiPanel: 4279cbf31767465f25feff6e7bdd336e
command.view.newAiChat: 1b0a886d6e77f628ec96c2d71cdb0a9b
command.view.toggleBacklinks: b3c4be2d2c07bb8df181355a2c3658a7
command.view.moveViewUp: 76f92d3250e028609349d825c672f13e
command.view.moveViewDown: 9deea30162b8d3234f8d52795ad7393f
command.view.moveNamedViewUp: b4ac634d13a09eacb680ef12c69dd1f7
command.view.moveNamedViewDown: 15302c02df26fe52def77636e9141711
command.view.zoomIn: ae4a8e406b707636392b6673fca0e6a6
command.view.zoomOut: 97326f8cd9df886a6b19af180fb7dcc9
command.view.resetZoom: 193ee8c32391fc5cc303a51617cfd046
@@ -81,6 +86,9 @@ files:
command.settings.setupExternalAi: 24eea679eb699763daa2e3f2a67fcf9a
command.settings.reloadVault: f6e506dad6b6cf2be24d9438d458ae54
command.settings.repairVault: cee560b1fd5ad9d1ff1c19a0a2afe77a
command.settings.useLightMode: a76d5b1c076983bc113d247f0520c166
command.settings.useDarkMode: ba7873c42ae00542ba71db9be3b6761f
command.settings.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
command.ai.openAgents: 612abe804edd0f5ae228a534f77f3d23
command.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2
command.ai.switchToAgent: 5bb02187e653b360ab7ed661403271f2
@@ -115,6 +123,10 @@ files:
settings.titles.description: b94aeeca78dd376cc19b9aa019e53f6c
settings.titles.autoRename: 5383db8e790ebf5f956d9c59cb4c4f67
settings.titles.autoRenameDescription: cc28c28d8817736e658082a2261d9a6e
settings.vaultContent.title: 78b883ce9acb9c25e611158a518d9185
settings.vaultContent.description: a53487bf1c7898a1459dc1510e216f7d
settings.vaultContent.hideGitignored: 2c07ee6c8bfe746d356f44275d42f90e
settings.vaultContent.hideGitignoredDescription: e8b1ddfa416610f9d6eb299fa123a1d6
settings.aiAgents.title: 27f08387b26e1ceeb1b60bd9c97e7a47
settings.aiAgents.description: a13222e67eb121676ff6dfc7b085f02d
settings.aiAgents.default: 6af573559fd05d8c35bc57b41cc78e24
@@ -138,6 +150,68 @@ files:
settings.cancel: ea4788705e6873b424c65e91c2846b19
settings.save: c9cc8cce247e49bae79f15173ce97354
common.cancel: ea4788705e6873b424c65e91c2846b19
ai.permission.safe.short: c6eea0560cd6f377e78dff2c85cc9122
ai.permission.safe.control: 386cc66605d50e5bf1fb726f97e55b55
ai.permission.safe.tooltip: 3bd635e2b2223da302994d77bfd7edfd
ai.permission.powerUser.short: d4a47db271fc1ef3cd17f7396326b057
ai.permission.powerUser.control: d4a47db271fc1ef3cd17f7396326b057
ai.permission.powerUser.tooltip: 2585ecdb79ca02a3ee779bb61552de03
ai.permission.modeAria: 3ff6346566a4c44fd5c7395907125d3e
ai.permission.changed: b5d06ad47e6053fe5a73f4571a40e00c
ai.panel.title: 4412225fe5b326643b8c2e1d9b7b10ae
ai.panel.status.checking: 118740af1c4521b917e29791d661db82
ai.panel.status.missing: fe07f8eac233c229d81cbe9aa25668a0
ai.panel.status.ready: 296a0ac791eab2df130789f55fdc109b
ai.panel.copyMcpConfig: 6a8c9fe401557c870e3a90bbfab01b25
ai.panel.mcpConfig: 53bac93d0314941c8d2c1163026f3ad9
ai.panel.newChat: 1b0a886d6e77f628ec96c2d71cdb0a9b
ai.panel.close: d00ba22b83762f5a6e66db0be9e916d7
ai.panel.linkedCount: 5292fda189a357c9597b1cb0e1b0900b
ai.panel.empty.checkingTitle: 0dde2076cb53fe497d05d231296d50bb
ai.panel.empty.checkingDescription: 31e41510d851c366860377ba0c956f0b
ai.panel.empty.missingTitle: a2b764da52cb43b191bb4ecfd8ea4d26
ai.panel.empty.missingDescription: 91825c7d86b1d588d44a33e1d94af950
ai.panel.empty.withContextTitle: 66f02985937083e4ef62b98cc9f25aee
ai.panel.empty.noContextTitle: e919aeb0a49b58e494e43e5f6f6597c9
ai.panel.empty.withContextDescription: dd8faefcc8160a2db800a41d6628facb
ai.panel.empty.noContextDescription: 6a6e610835808016d1f2790c3f991a54
ai.panel.placeholder.checking: 08fec77719c37d3d6279b2641b37b4d3
ai.panel.placeholder.missing: 547c492026d115608067988b7cfbb9a9
ai.panel.placeholder.ready: 31f5265c2e0432a7ca10d08e5c6f3114
ai.panel.send: 747c6a3564774149c989884e0c581d53
mcp.setup.manageTitle: d786c7b9ab9b2406258648931bca0e01
mcp.setup.setupTitle: b4b373f690c8a0008885b31e78e93a5c
mcp.setup.connectedDescription: a2a3be70c0ac9fa7ef112df5252249fa
mcp.setup.setupDescription: 8bf199ffbcf303a99abac078c0c5e094
mcp.setup.reconnect: 813ba447b5e64c17ff9b68c693358ca3
mcp.setup.connect: fb95777dbc9f7b22014dc360a2957652
mcp.setup.disconnect: 42ae25231906c83927831e0ef7c317ac
mcp.setup.connecting: 182e7eda4e721ad00737460b88701dee
mcp.setup.disconnecting: 11de134aefe51cb4a5c545b5a057a871
mcp.setup.nodeRequirement: ac58a35cf21d2204fed548c227f77594
mcp.setup.writeEntryDescription: 370aa4403d2ffc37f574503bc3cd00e6
mcp.setup.clientPathsDescription: 94565852412051c799e78d2f4525ff53
mcp.setup.geminiGuidanceDescription: 182cf5b84b2f56cb1cf41dcdb5e093d8
mcp.setup.manual.title: 2188ca52346158cbd103ca7ce7c12dd9
mcp.setup.manual.copy: 6a8c9fe401557c870e3a90bbfab01b25
mcp.setup.manual.loading: 11ee201c121c8e2015c065952474a3ea
mcp.setup.manual.unavailable: 8b4330b839c41bad378c86c91ee0b5cd
mcp.toast.connected: 7716c0f9dad41a7fdbef592074459239
mcp.toast.refreshed: 227d8721ebec3077792b1223c1f30e54
mcp.toast.disconnected: 9b5254079561087d679e35aa10c80638
mcp.toast.alreadyDisconnected: 645bec2e33bd9de2b334b704d9fa84cf
mcp.toast.configCopied: 458b50444b9a1cb069b00475c0951b8a
mcp.toast.configCopyFailed: b451cfb95f87458d142d026a8aca74cf
mcp.toast.setupFailed: 51f290962c08d8ba76bfc6c19552eaed
mcp.toast.disconnectFailed: ddc3387ab6e996ce15bc4950a3548e1e
mcp.error.clipboardUnavailable: 3b9ed3fe7dba627edb74e1cdaa02b2ee
save.toast.saved: 248336101b461380a4b2391a7625493d
save.toast.nothingToSave: 86ae8cef7371e639f3c007449a2f7d1f
save.toast.missingActiveVault: 49cf5064a7dce757f895950d8beeed68
save.error.failed: b55b9fefe93fabae3c277e4e7956a534
save.error.autoFailed: 09b2b131f07baddb5ba8f5a3ee6234c5
save.error.invalidPath: c96fb669715e86508f4c5e73da9f6995
savedViews.reordered: 531081a566b826eed2e714ce70a0de08
locale.en: 78463a384a5aa4fad5fa73e2f506ecfc
sidebar.nav.inbox: 3882d32c66e7e768145ecd8f104b0c08
sidebar.nav.allNotes: cd76cf851b08a9d2feafaf255bc1f4d5
@@ -149,6 +223,9 @@ files:
sidebar.action.createView: ba019414ea8c7fcdb4a83a70ec1bb639
sidebar.action.editView: acdf34bd08b0692b906d446e590b1eb9
sidebar.action.deleteView: c8f4f9cd8ff820f955474e5c9f70ff39
sidebar.action.reorderView: ca2f42548fd57160d22fd0809bf8ed6e
sidebar.action.moveViewUp: e601b7f0901f12efb71f7821c0fdb79e
sidebar.action.moveViewDown: 6f86951085367864e3e4eb13f5c8688e
sidebar.action.customizeSections: 050a134cad1e9c6f04abe5d635592703
sidebar.action.renameSection: 8936914051df04e7550d0eeb4a31e0e0
sidebar.action.customizeIconColor: 9a2f685c74d261ab483ef00dee5314fe
@@ -318,6 +395,8 @@ files:
status.vault.cloneGit: a7d0aef7c6237a36e54fd5a26e9c23fe
status.vault.cloneGettingStarted: 9953a11a477b441976a626a9acf5d7df
status.vault.notFound: 3119b7fa94c96209bca571b7c7ed0b7e
status.vault.reloading: 893da50ef3a9e01d8a99ff5d3ceb55e9
status.vault.reloadingTooltip: 4a884bd7d113797ad16f905f70742da8
status.vault.remove: 7f3b4f76df23626170940dbc55c70728
status.remote.noneConfigured: 18a4edd4e8d862ccb343937f45585fb1
status.remote.inSync: a56f571b4df55d88fb06e61e343b3c91

View File

@@ -30,27 +30,69 @@ const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
// The bridge relays messages to all other clients (the React frontend).
let uiSocket = null
let reconnectTimer = null
let shutdownStarted = false
const RECONNECT_INTERVAL_MS = 3000
function connectUiBridge() {
if (shutdownStarted) return
try {
const ws = new WebSocket(WS_UI_URL)
uiSocket = ws
ws.on('open', () => {
uiSocket = ws
if (shutdownStarted) {
closeUiSocket()
return
}
console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`)
})
ws.on('close', () => {
uiSocket = null
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
if (uiSocket === ws) uiSocket = null
scheduleUiReconnect()
})
ws.on('error', () => {
// Silent — bridge may not be running yet, will retry
})
} catch {
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
scheduleUiReconnect()
}
}
connectUiBridge()
function scheduleUiReconnect() {
if (shutdownStarted) return
clearUiReconnectTimer()
reconnectTimer = setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
reconnectTimer.unref?.()
}
function clearUiReconnectTimer() {
if (!reconnectTimer) return
clearTimeout(reconnectTimer)
reconnectTimer = null
}
function closeUiSocket() {
const socket = uiSocket
uiSocket = null
if (!socket) return
socket.removeAllListeners()
socket.on('error', () => {})
if (socket.readyState === WebSocket.CONNECTING) {
socket.terminate?.()
return
}
try {
socket.close()
} catch {
// Ignore close races during process teardown.
}
socket.terminate?.()
}
function broadcastUiAction(action, payload) {
if (!uiSocket || uiSocket.readyState !== WebSocket.OPEN) return
@@ -193,10 +235,47 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}
})
async function shutdown(exitCode = 0) {
if (shutdownStarted) return
shutdownStarted = true
clearUiReconnectTimer()
closeUiSocket()
try {
await server.close()
} catch (error) {
console.error(`[mcp] Error while closing server: ${error.message}`)
}
process.exitCode = exitCode
setImmediate(() => process.exit(exitCode))
}
async function main() {
const transport = new StdioServerTransport()
server.onclose = () => {
void shutdown(0)
}
process.stdin.once('end', () => {
void shutdown(0)
})
process.stdin.once('close', () => {
void shutdown(0)
})
process.once('SIGINT', () => {
void shutdown(0)
})
process.once('SIGTERM', () => {
void shutdown(0)
})
connectUiBridge()
await server.connect(transport)
console.error(`Tolaria MCP server running (vault: ${VAULT_PATH})`)
}
main().catch(console.error)
main().catch((error) => {
console.error(error)
void shutdown(1)
})

View File

@@ -1,8 +1,10 @@
import { describe, it, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import fs from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
import { fileURLToPath } from 'node:url'
import {
findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
@@ -11,6 +13,7 @@ import { evaluateBridgeRequest } from './ws-bridge.js'
let tmpDir
const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
const MCP_SERVER_DIR = path.dirname(fileURLToPath(import.meta.url))
before(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
@@ -208,6 +211,34 @@ describe('requireVaultPath', () => {
})
})
describe('stdio process lifecycle', () => {
it('exits when the MCP client closes stdin', async () => {
const child = spawn(process.execPath, ['index.js'], {
cwd: MCP_SERVER_DIR,
env: { ...process.env, VAULT_PATH: tmpDir, WS_UI_PORT: '65534' },
stdio: ['pipe', 'ignore', 'pipe'],
})
let stderr = ''
child.stderr.setEncoding('utf8')
child.stderr.on('data', chunk => {
stderr += chunk
})
await sleep(200)
child.stdin.end()
const exit = await waitForExit(child, 1_500)
if (!exit) {
child.kill()
await waitForExit(child, 1_000)
assert.fail(`MCP server stayed alive after stdin closed.\n${stderr}`)
}
assert.equal(exit.signal, null)
assert.equal(exit.code, 0, stderr)
})
})
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
const outsideNote = path.join(outsideDir, 'outside.md')
@@ -222,3 +253,28 @@ async function assertRejectsOutsideVault(prefix, resolveNotePath) {
await fs.rm(outsideDir, { recursive: true, force: true })
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function waitForExit(child, timeoutMs) {
return new Promise((resolve) => {
const timer = setTimeout(() => {
cleanup()
resolve(null)
}, timeoutMs)
child.once('exit', onExit)
function onExit(code, signal) {
cleanup()
resolve({ code, signal })
}
function cleanup() {
clearTimeout(timer)
child.off('exit', onExit)
}
})
}

View File

@@ -8,9 +8,6 @@
"dev": "vite",
"build": "tsc -b && vite build",
"bundle-mcp": "node scripts/bundle-mcp-server.mjs",
"docs:dev": "vitepress dev site --host 127.0.0.1",
"docs:build": "vitepress build site",
"docs:preview": "vitepress preview site --host 127.0.0.1",
"lint": "eslint .",
"l10n:translate": "lara-cli translate",
"l10n:translate:force": "lara-cli translate --force",
@@ -20,7 +17,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "node scripts/run-vitest-coverage.mjs",
@@ -102,7 +99,6 @@
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
"vitepress": "^1.6.4",
"vitest": "^4.0.18",
"ws": "^8.19.0"
}

1014
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,101 +0,0 @@
import { defineConfig } from "vitepress";
const base = process.env.VITEPRESS_BASE ?? "/";
export default defineConfig({
title: "Tolaria",
description:
"Tolaria is a local-first Markdown knowledge base with native relationships, Git history, and AI workflows.",
base,
cleanUrls: true,
head: [
["link", { rel: "icon", type: "image/png", href: `${base}landing/favicon.png` }],
["meta", { property: "og:title", content: "Tolaria" }],
[
"meta",
{
property: "og:description",
content:
"A second brain for the AI era. Free forever, local-first, Markdown-based, Git-ready, and AI-friendly.",
},
],
],
themeConfig: {
logo: { src: "/landing/tolaria-icon.png", alt: "Tolaria" },
nav: [
{ text: "Features", link: "/#features" },
{ text: "Start", link: "/start/install" },
{ text: "Concepts", link: "/concepts/vaults" },
{ text: "Guides", link: "/guides/capture-a-note" },
{ text: "Reference", link: "/reference/supported-platforms" },
{ text: "Releases", link: "/releases/" },
],
search: {
provider: "local",
},
socialLinks: [{ icon: "github", link: "https://github.com/refactoringhq/tolaria" }],
sidebar: [
{
text: "Start Here",
items: [
{ text: "Install Tolaria", link: "/start/install" },
{ text: "First Launch", link: "/start/first-launch" },
{ text: "Getting Started Vault", link: "/start/getting-started-vault" },
{ text: "Open Or Create A Vault", link: "/start/open-or-create-vault" },
],
},
{
text: "Concepts",
items: [
{ text: "Vaults", link: "/concepts/vaults" },
{ text: "Notes", link: "/concepts/notes" },
{ text: "Properties", link: "/concepts/properties" },
{ text: "Types", link: "/concepts/types" },
{ text: "Relationships", link: "/concepts/relationships" },
{ text: "Inbox", link: "/concepts/inbox" },
{ text: "Git", link: "/concepts/git" },
{ text: "AI", link: "/concepts/ai" },
],
},
{
text: "Guides",
items: [
{ text: "Capture A Note", link: "/guides/capture-a-note" },
{ text: "Organize The Inbox", link: "/guides/organize-inbox" },
{ text: "Use Wikilinks", link: "/guides/use-wikilinks" },
{ text: "Create Types", link: "/guides/create-types" },
{ text: "Build Custom Views", link: "/guides/build-custom-views" },
{ text: "Connect A Git Remote", link: "/guides/connect-a-git-remote" },
{ text: "Commit And Push", link: "/guides/commit-and-push" },
{ text: "Use The AI Panel", link: "/guides/use-ai-panel" },
{ text: "Use The Command Palette", link: "/guides/use-command-palette" },
],
},
{
text: "Reference",
items: [
{ text: "Supported Platforms", link: "/reference/supported-platforms" },
{ text: "File Layout", link: "/reference/file-layout" },
{ text: "Frontmatter Fields", link: "/reference/frontmatter-fields" },
{ text: "View Filters", link: "/reference/view-filters" },
{ text: "Keyboard Shortcuts", link: "/reference/keyboard-shortcuts" },
{ text: "Docs Maintenance", link: "/reference/docs-maintenance" },
],
},
{
text: "Troubleshooting",
items: [
{ text: "Vault Not Loading", link: "/troubleshooting/vault-not-loading" },
{ text: "Git Authentication", link: "/troubleshooting/git-auth" },
{ text: "AI Agent Not Found", link: "/troubleshooting/ai-agent-not-found" },
{ text: "Sync Conflicts", link: "/troubleshooting/sync-conflicts" },
],
},
],
footer: {
message: "Free and open source. Local-first, Git-first, and Markdown-based.",
copyright:
"Tolaria is AGPL-3.0-or-later. The Tolaria name and logo remain covered by the project trademark policy.",
},
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
<script setup lang="ts">
import DefaultTheme from "vitepress/theme";
import { onBeforeUnmount, onMounted } from "vue";
import { useData } from "vitepress";
const { frontmatter } = useData();
const scrollClass = "tolaria-scrolled";
const updateScrollClass = () => {
document.documentElement.classList.toggle(scrollClass, window.scrollY > 8);
};
onMounted(() => {
updateScrollClass();
window.addEventListener("scroll", updateScrollClass, { passive: true });
});
onBeforeUnmount(() => {
window.removeEventListener("scroll", updateScrollClass);
document.documentElement.classList.remove(scrollClass);
});
</script>
<template>
<div :class="{ 'tolaria-landing-shell': frontmatter.landing }">
<DefaultTheme.Layout />
</div>
</template>

View File

@@ -1,12 +0,0 @@
import DefaultTheme from "vitepress/theme";
import LandingHome from "./LandingHome.vue";
import Layout from "./Layout.vue";
import "./styles.css";
export default {
extends: DefaultTheme,
Layout,
enhanceApp({ app }) {
app.component("LandingHome", LandingHome);
},
};

View File

@@ -1,171 +0,0 @@
@font-face {
font-family: "RefactoringSans";
src: url("./assets/RefactoringSans.otf") format("opentype");
font-display: swap;
font-style: normal;
font-weight: 400 900;
}
:root {
--vp-font-family-base:
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--vp-font-family-mono: "SF Mono", "Fira Code", ui-monospace, monospace;
--tolaria-font-brand:
"RefactoringSans", "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
--tolaria-bg: #faf9f5;
--tolaria-surface: #ffffff;
--tolaria-surface-muted: #f7f6f3;
--tolaria-text: #1a1a18;
--tolaria-text-secondary: #6b6b60;
--tolaria-text-muted: #9b9b90;
--tolaria-border: #e5e5e0;
--tolaria-blue: #155dff;
--tolaria-blue-hover: #4a5ad6;
--tolaria-blue-soft: #e8eeff;
--vp-c-bg: var(--tolaria-surface);
--vp-c-bg-alt: var(--tolaria-surface-muted);
--vp-c-bg-elv: var(--tolaria-surface);
--vp-c-bg-soft: var(--tolaria-surface-muted);
--vp-c-text-1: var(--tolaria-text);
--vp-c-text-2: var(--tolaria-text-secondary);
--vp-c-text-3: var(--tolaria-text-muted);
--vp-c-border: var(--tolaria-border);
--vp-c-divider: var(--tolaria-border);
--vp-c-brand-1: var(--tolaria-blue);
--vp-c-brand-2: var(--tolaria-blue-hover);
--vp-c-brand-3: var(--tolaria-blue);
--vp-c-brand-soft: var(--tolaria-blue-soft);
--vp-button-brand-bg: var(--tolaria-blue);
--vp-button-brand-hover-bg: var(--tolaria-blue-hover);
--vp-button-brand-border: var(--tolaria-blue);
--vp-code-bg: #eeeeea;
--vp-code-color: var(--tolaria-text-secondary);
}
.dark {
--vp-c-bg: #1f1e1b;
--vp-c-bg-alt: #191814;
--vp-c-bg-elv: #23221f;
--vp-c-bg-soft: #23221f;
--vp-c-text-1: #e6e1d8;
--vp-c-text-2: #b8b1a6;
--vp-c-text-3: #7f776d;
--vp-c-border: #34322d;
--vp-c-divider: #34322d;
--vp-c-brand-1: #78a4ff;
--vp-c-brand-2: #9bbeff;
--vp-c-brand-3: #78a4ff;
--vp-c-brand-soft: rgba(120, 164, 255, 0.16);
--vp-code-bg: #2d2b27;
--vp-code-color: #d8d1c6;
}
.VPNavBarTitle .logo {
width: auto;
height: 28px;
}
.VPNavBarTitle .title {
color: var(--vp-c-text-1);
font-family: var(--tolaria-font-brand);
font-size: 22px;
font-weight: 800;
letter-spacing: 0;
}
.tolaria-landing-shell {
--vp-c-bg: var(--tolaria-bg);
}
.tolaria-landing-shell .VPNav,
.tolaria-landing-shell .VPNavBar,
.tolaria-landing-shell .VPNavBar .content-body {
background: color-mix(in srgb, var(--tolaria-bg) 94%, transparent);
backdrop-filter: blur(14px);
}
.tolaria-landing-shell .VPNavBar .divider,
.tolaria-landing-shell .VPNavBar .divider-line {
background-color: transparent;
}
.tolaria-landing-shell .VPNavBar .divider-line {
opacity: 0;
transition: opacity 160ms ease;
}
.tolaria-landing-shell .VPLocalNav {
display: none;
}
.tolaria-scrolled .tolaria-landing-shell .VPNav {
box-shadow: 0 10px 24px rgba(26, 26, 24, 0.06);
}
.tolaria-scrolled .tolaria-landing-shell .VPNavBar .divider-line {
opacity: 1;
}
.DocSearch-Button {
border-radius: 999px;
}
.vp-doc h1,
.vp-doc h2,
.vp-doc h3 {
letter-spacing: 0;
}
.vp-doc a,
.VPNavBarMenuLink.active,
.VPLink.active {
color: var(--vp-c-brand-1);
}
.vp-doc table {
display: table;
width: 100%;
}
.vp-doc th {
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
}
.vp-doc td,
.vp-doc th {
border-color: var(--vp-c-divider);
}
.tolaria-landing-shell .VPContent,
.tolaria-landing-shell .VPPage,
.tolaria-landing-shell .VPDoc,
.tolaria-landing-shell .VPDoc .container,
.tolaria-landing-shell .VPDoc .content,
.tolaria-landing-shell .VPDoc .content-container,
.tolaria-landing-shell .VPDoc .main,
.tolaria-landing-shell .vp-doc {
max-width: none;
padding: 0;
margin: 0;
}
.tolaria-landing-shell .VPPage {
padding-top: var(--vp-nav-height);
}
.tolaria-landing-shell .VPDoc {
padding-top: var(--vp-nav-height);
}
.tolaria-landing-shell .vp-doc > div {
width: 100%;
}
.tolaria-landing-shell .vp-doc a {
text-decoration: none;
}
.tolaria-landing-shell .VPFooter {
display: none;
}

View File

@@ -1,20 +0,0 @@
# AI
Tolaria is designed for local AI agents that can work with files and tools rather than a hidden cloud-only notes API.
## Agent Panel
The AI panel streams messages from supported local CLI agents. It shows reasoning, tool activity, and file changes in the app.
## MCP Server
Tolaria exposes an MCP server so tools such as Claude Code and Codex can search, read, and edit vault notes through explicit tools.
## Why Git Matters For AI
AI-generated changes should be inspectable. Git gives you diffs, history, rollback, and a clear boundary between suggestions and committed work.
## Current Direction
Claude Code is the primary integration. Codex and other CLI agents are supported through the shared agent architecture as the app evolves.

View File

@@ -1,21 +0,0 @@
# Git
Git is Tolaria's history and sync layer. It keeps the model local-first while still supporting remote backup and multi-device workflows.
## What Tolaria Uses Git For
- Local commit history.
- Diff views.
- Per-note history.
- Pull and push.
- Conflict detection and resolution.
- Remote connection for local-only vaults.
## Local Commits
You can commit changes inside Tolaria without leaving the app. This gives you useful restore points even before a remote is configured.
## Remotes
Connect a compatible Git remote when you want sync or backup. Tolaria relies on your system Git authentication, so GitHub CLI, SSH keys, credential helpers, and existing Git configuration can continue to work.

View File

@@ -1,22 +0,0 @@
# Inbox
The Inbox is for notes that have been captured but not yet organized.
## Why It Exists
Fast capture should not require perfect structure. The Inbox gives you a place to put incomplete notes, then process them later.
## Organizing Inbox Notes
When reviewing the Inbox:
1. Give the note a clear H1.
2. Set its `type`.
3. Add status, dates, or URL if useful.
4. Add relationships with wikilinks or frontmatter fields.
5. Move it into a folder only if the folder adds value.
## Healthy Inbox Habit
Keep the Inbox small enough that it can be reviewed in one focused pass. Tolaria works best when capture is fast and organization is deliberate.

View File

@@ -1,31 +0,0 @@
# Notes
A note is a Markdown file with optional YAML frontmatter. Tolaria reads the first H1 as the primary title and keeps the file on disk as the durable representation.
## Anatomy
```md
---
type: Project
status: Active
belongs_to:
- "[[workspace]]"
---
# Launch Documentation
Draft the public Tolaria docs and keep them close to code changes.
```
## Titles
The first H1 is the main title. Older notes can still use a `title:` frontmatter fallback, but new notes should rely on the H1.
## Body Links
Use `[[wikilinks]]` to connect notes from the body. Tolaria can resolve links by filename, title, and aliases.
## Frontmatter
Use frontmatter for structured fields such as type, status, date, URL, and relationships. Keep free-form thinking in the body.

View File

@@ -1,25 +0,0 @@
# Properties
Properties are frontmatter fields that Tolaria can display, filter, and edit.
## Common Properties
| Field | Purpose |
| --- | --- |
| `type` | Groups the note into a type such as Project, Person, or Topic. |
| `status` | Tracks lifecycle state such as Active, Done, or Blocked. |
| `url` | Stores a canonical external link. |
| `date` | Represents a single date. |
| `start_date`, `end_date` | Represents a date range. |
| `aliases` | Gives a note alternative names for wikilink resolution. |
## System Properties
Fields that start with `_` are system properties. They remain in plain text but are hidden from normal property editing.
Examples include `_icon`, `_color`, `_order`, and `_pinned_properties` on type documents.
## Property Editing
The Inspector is the safest place to edit structured properties. Use raw Markdown mode when you need direct control over YAML.

View File

@@ -1,27 +0,0 @@
# Relationships
Relationships make a vault feel like a graph instead of a pile of documents.
## Relationship Fields
Any frontmatter field containing wikilinks can become a relationship.
```yaml
belongs_to:
- "[[product-work]]"
related_to:
- "[[documentation]]"
blocked_by:
- "[[release-process]]"
```
Tolaria does not need a hardcoded list of relationship names. It detects relationship fields dynamically.
## Body Links Versus Relationship Fields
Use body links when the relationship appears naturally in writing. Use frontmatter relationships when the connection is important enough to show in navigation, filters, or the Inspector.
## Backlinks
Tolaria can show incoming links and inverse relationships, making it easier to navigate from a note to the rest of its context.

View File

@@ -1,38 +0,0 @@
# Types
Types describe what kind of thing a note represents: Project, Person, Topic, Procedure, Event, or any category you create.
## Type Field
The `type:` field assigns a note to a type.
```yaml
type: Project
```
Tolaria does not infer type from folder location. Moving a file into another folder does not change its type.
## Type Documents
Type documents live in the `type/` folder and describe how a type should appear.
```yaml
---
type: Type
icon: folder
color: blue
sidebar_label: Projects
sort: modified:desc
---
# Project
```
## What Types Control
- Sidebar grouping.
- Type icon and color.
- Default sort.
- Pinned properties.
- New-note templates.

View File

@@ -1,29 +0,0 @@
# Vaults
A vault is the folder Tolaria reads and writes. The filesystem is the source of truth; the app state and cache are derived from files.
## Core Rules
- Notes are Markdown files.
- YAML frontmatter provides structure.
- Attachments are normal files inside the vault.
- Type definitions and saved views are also files.
- Git tracks history and supports remote sync.
## Why Local Files Matter
Local files keep your notes inspectable. You can open them in another editor, search with command-line tools, back them up with your own system, and version them with Git.
Tolaria should never become the only way to read your data.
## App State Versus Vault State
Vault-level information should travel with the vault. Machine-specific preferences stay with the app installation.
| Vault state | App state |
| --- | --- |
| Type icons and colors | Editor zoom |
| Saved views | Window size |
| Pinned properties | Recent vault list |
| Relationship conventions | Local cache |

View File

@@ -1,17 +0,0 @@
# Download
Download Tolaria from the latest stable release.
## macOS
[Download the latest stable build](https://refactoringhq.github.io/tolaria/download/)
macOS is the primary supported platform.
## Linux And Windows
Linux and Windows builds are best effort for now. Check the [latest GitHub release](https://github.com/refactoringhq/tolaria/releases/latest) for available artifacts.
## Verify The Version
After launching the app, check the version shown in Tolaria's status bar or release surface.

View File

@@ -1,20 +0,0 @@
# Build Custom Views
Custom views are saved filters for recurring questions.
## Good View Candidates
- Active projects.
- People without a recent follow-up.
- Drafts ready for review.
- Notes changed this week.
- Events in a date range.
## View Definition
Saved views live as files in the vault. They describe filters, sorting, and visible columns using structured data.
## Design The Question First
Before creating a view, write the question it answers. A good view is not "all fields with all filters"; it is a focused lens.

View File

@@ -1,20 +0,0 @@
# Capture A Note
Use capture when you need to get an idea into the vault before you know where it belongs.
## Steps
1. Open the command palette with `Cmd+K` or `Ctrl+K`.
2. Run `New Note`.
3. Write a clear H1.
4. Add the rough content.
5. Leave structure for later if you are still thinking.
## Capture Well
Prefer a useful title over a perfect taxonomy. You can add type, status, and relationships during inbox review.
## When To Add Structure Immediately
Add structure while capturing if the note is obviously a Project, Person, Event, or Procedure and the context is already known.

View File

@@ -1,19 +0,0 @@
# Commit And Push
Tolaria lets you commit and push vault changes from inside the app.
## Commit
1. Open the Git or changes surface.
2. Review changed files.
3. Write a short commit message.
4. Commit locally.
## Push
Push after committing when a remote is configured. If the remote has changed, pull first and resolve any conflicts.
## Use Small Commits
Small commits make it easier to understand what changed, roll back safely, and review AI-generated edits.

View File

@@ -1,23 +0,0 @@
# Connect A Git Remote
Connect a remote when you want backup or sync beyond the current machine.
## Before You Start
Make sure the remote repository exists and your system Git can authenticate to it. Tolaria uses system Git rather than storing provider-specific credentials.
## Steps
1. Open the bottom status bar remote chip, or run `Add Remote` from the command palette.
2. Paste the remote URL.
3. Confirm the remote name.
4. Fetch or push according to the app prompt.
## Recommended Auth
- SSH keys.
- GitHub CLI authentication.
- Existing Git credential helpers.
If authentication fails, see [Git Authentication](/troubleshooting/git-auth).

View File

@@ -1,27 +0,0 @@
# Create Types
Create a type when several notes share the same role in your system.
## Steps
1. Create a note in the `type/` folder.
2. Set `type: Type` in frontmatter.
3. Give the document a clear H1.
4. Add optional icon, color, sort, and sidebar label.
```yaml
---
type: Type
icon: briefcase
color: blue
sidebar_label: Projects
sort: modified:desc
---
# Project
```
## Use Types Sparingly
A type should represent a recurring category, not a one-off label. If you only need a temporary grouping, use a saved view or property instead.

View File

@@ -1,26 +0,0 @@
# Organize The Inbox
Inbox review turns quick captures into usable knowledge.
## Review Checklist
- Rename unclear notes.
- Add or correct the first H1.
- Set `type`.
- Add `status` for actionable notes.
- Add `belongs_to`, `related_to`, or other relationship fields when useful.
- Archive or delete notes that no longer matter.
## Make Notes Navigable
A note is organized when you can answer:
- What kind of thing is this?
- What is it connected to?
- What should happen next?
- Where would I expect to find it later?
## Avoid Over-Structuring
Do not add fields just because they exist. Add the structure that will help future navigation, review, or automation.

View File

@@ -1,19 +0,0 @@
# Use The AI Panel
The AI panel connects Tolaria to local CLI agents.
## Before Using It
Install a supported agent such as Claude Code and make sure it is available from your shell. Tolaria detects common install locations, but explicit shell availability is still the simplest setup.
## Good Requests
- "Find notes related to this project."
- "Summarize what changed in this note."
- "Draft a weekly review from these linked notes."
- "Update this checklist based on the current project status."
## Review Changes
AI edits are file edits. Review them with Tolaria's diff and Git history before committing.

View File

@@ -1,24 +0,0 @@
# Use The Command Palette
The command palette is the fastest way to move around Tolaria.
Open it with:
- `Cmd+K` on macOS.
- `Ctrl+K` on Linux and Windows.
## Common Commands
- New Note.
- Search.
- Open Settings.
- Reload Vault.
- Add Remote.
- Open Getting Started Vault.
- Toggle Raw Mode.
- Open in New Window.
## Keyboard-First Workflow
Use the palette when you know what you want to do but do not want to hunt through panels. It is also the best place to discover commands as the app grows.

View File

@@ -1,25 +0,0 @@
# Use Wikilinks
Wikilinks connect notes by name.
```md
This project belongs to [[content-systems]] and is related to [[git-workflows]].
```
## Link From The Body
Use body links when the connection is part of the sentence you are writing.
## Link From Frontmatter
Use frontmatter links when the relationship should become structured metadata.
```yaml
related_to:
- "[[git-workflows]]"
```
## Keep Links Stable
Prefer clear note titles and filenames. Use aliases when people or projects are known by multiple names.

View File

@@ -1,10 +0,0 @@
---
layout: page
sidebar: false
aside: false
landing: true
title: Tolaria
description: A second brain for the AI era. Free forever.
---
<LandingHome />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

View File

@@ -1,10 +0,0 @@
<svg width="266" height="363" viewBox="0 0 266 363" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_111_151)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M119.524 5.31219C126.607 -1.77073 138.117 -1.77073 145.2 5.31219C178.844 40.7268 265.61 147.856 265.61 230.195C265.61 303.68 206.29 363 132.805 363C59.3195 363 0 303.68 0 230.195C0 147.856 86.7659 40.7268 119.524 5.31219ZM59.5947 242.88C58.1619 234.744 50.3952 229.074 42.2195 230.195C34.0438 231.316 28.5932 238.799 30.0259 246.935C37.6847 290.425 72.2734 325.622 116.094 334.485C117.821 334.836 119.552 334.864 121.178 334.641C127.269 333.806 132.289 329.26 133.371 322.934C134.756 314.893 129.25 307.054 121.086 305.401C89.7767 299.057 65.0615 273.923 59.5947 242.88Z" fill="#155DFF"/>
</g>
<defs>
<clipPath id="clip0_111_151">
<rect width="266" height="363" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 889 B

View File

@@ -1,38 +0,0 @@
# Docs Maintenance
The public docs live in the app repo so documentation changes can ship with behavior changes.
## Update Docs When You Change
- A Tauri command.
- A new component or hook that changes user behavior.
- A data model or frontmatter convention.
- Git, AI, onboarding, or release behavior.
- Platform support.
- Keyboard shortcuts.
## Suggested Workflow
1. Make the code change.
2. Update the matching concept, guide, or reference page.
3. Add a troubleshooting page if the change creates a new failure mode.
4. Run `pnpm docs:build`.
5. Check the home page, search, and changed docs pages in a browser.
## Page Types
| Type | Purpose |
| --- | --- |
| Start | Helps a new user get into the app. |
| Concepts | Explains mental models. |
| Guides | Teaches workflows. |
| Reference | Gives stable facts and tables. |
| Troubleshooting | Starts from a symptom and ends with recovery. |
## Review Checklist
- Does the page describe current behavior?
- Does it mention macOS primary and Linux/Windows best effort when platform support matters?
- Are links relative and VitePress-compatible?
- Can a user discover the page with local search?

View File

@@ -1,35 +0,0 @@
# File Layout
A typical vault stays simple.
```txt
my-vault/
project-alpha.md
weekly-review.md
people/
ada-lovelace.md
attachments/
diagram.png
type/
project.md
person.md
views/
active-projects.yml
```
## Root Notes
Tolaria can work with flat vaults and nested folders. Type is not inferred from folder location; it comes from frontmatter.
## Special Folders
| Folder | Purpose |
| --- | --- |
| `type/` | Type definition documents. |
| `views/` | Saved custom views. |
| `attachments/` | Images and other attached files. |
## Git Files
If the vault is a Git repository, `.git/` belongs to Git. Tolaria reads Git state but does not treat `.git/` as notes.

View File

@@ -1,26 +0,0 @@
# Frontmatter Fields
Tolaria uses conventions instead of a required schema.
| Field | Meaning |
| --- | --- |
| `type` | The note's entity type. |
| `status` | Lifecycle state. |
| `icon` | Per-note icon. |
| `url` | External URL. |
| `date` | Single date. |
| `start_date` | Start of a date range. |
| `end_date` | End of a date range. |
| `aliases` | Alternative names for link resolution. |
| `belongs_to` | Parent relationship. |
| `related_to` | Lateral relationship. |
| `has` | Contained relationship. |
## Custom Fields
You can add your own fields. If a field contains wikilinks, Tolaria can treat it as a relationship.
## System Fields
Fields starting with `_` are reserved for system behavior and hidden from standard property editing.

View File

@@ -1,15 +0,0 @@
# Keyboard Shortcuts
| Shortcut | Action |
| --- | --- |
| `Cmd+K` / `Ctrl+K` | Open command palette. |
| `Cmd+N` / `Ctrl+N` | Create a new note. |
| `Cmd+S` / `Ctrl+S` | Save current note. |
| `Cmd+[` / `Alt+Left` | Navigate back when available. |
| `Cmd+]` / `Alt+Right` | Navigate forward when available. |
| `Cmd+Shift+O` / `Ctrl+Shift+O` | Open current note in a new window. |
Some shortcuts vary by platform because macOS, Linux, and Windows reserve different key combinations.
Use the command palette to discover the current command set.

View File

@@ -1,24 +0,0 @@
# Supported Platforms
Tolaria is a desktop app built with Tauri.
| Platform | Current support | Notes |
| --- | --- | --- |
| macOS | Primary | Main development and QA target. Apple Silicon is first-class. |
| Linux | Best effort | Depends on distribution WebKitGTK packaging and desktop integration behavior. |
| Windows | Best effort | Builds exist, but platform-specific behavior may lag macOS. |
## Support Policy
Primary support means the platform is part of normal development and release validation. Best-effort support means builds may be available, but bugs can take longer to diagnose and fix.
## Reporting Platform Bugs
Include:
- Tolaria version.
- Operating system and version.
- CPU architecture.
- Whether the vault is local-only or connected to a remote.
- Steps to reproduce.

View File

@@ -1,26 +0,0 @@
# View Filters
View filters define saved lists of notes.
## Common Filter Ideas
| Goal | Filter direction |
| --- | --- |
| Active projects | `type` is Project and `status` is Active |
| Drafts | `type` is Article and `status` is Draft |
| People follow-up | `type` is Person and date is before today |
| Recent work | modified date is within a recent range |
## Sorting
Useful sorts include:
- Recently modified first.
- Title ascending.
- Status ascending.
- A custom property ascending or descending.
## Keep Views Focused
A view should answer one recurring question. If it becomes too broad, split it into two views.

View File

@@ -1,16 +0,0 @@
# Releases
Tolaria releases are published on GitHub.
- [Latest release](https://github.com/refactoringhq/tolaria/releases/latest)
- [All releases](https://github.com/refactoringhq/tolaria/releases)
- [Download page](/download/)
## Release Channels
Stable builds are intended for normal use. Pre-release builds may contain newer features and rougher edges.
## Before Updating
Commit or push important vault changes before updating the app. Your notes are local files, but having a clean Git state makes recovery easier.

View File

@@ -1,32 +0,0 @@
# First Launch
The first launch flow is designed to get you into a real vault quickly without hiding the local-first model.
## What You Choose
Tolaria asks whether you want to:
- Create or clone the Getting Started vault.
- Open an existing local vault.
- Create a new empty vault.
The Getting Started vault is cloned locally and then disconnected from its remote. That keeps the sample safe to edit without accidentally pushing tutorial changes.
## What Tolaria Creates
Tolaria stores app-level settings on the local machine. Your notes stay in the vault folder you choose.
| Data | Stored in |
| --- | --- |
| Notes and attachments | Your vault folder |
| Type definitions and saved views | Your vault folder |
| Window size, zoom, recent vaults | Local app settings |
| Cache data | Rebuildable local cache |
## First Commands To Try
- `Cmd+K` / `Ctrl+K`: open the command palette.
- `New Note`: create a note in the current vault.
- `Open Getting Started Vault`: clone the public sample vault.
- `Reload Vault`: rescan files after external edits.

View File

@@ -1,24 +0,0 @@
# Getting Started Vault
The Getting Started vault is a small public sample vault hosted at [refactoringhq/tolaria-getting-started](https://github.com/refactoringhq/tolaria-getting-started).
It exists to show Tolaria's conventions without requiring you to restructure your own notes first.
## What It Demonstrates
- Markdown notes with YAML frontmatter.
- Types such as Project, Person, Topic, and Procedure.
- Wikilinks in note bodies.
- Relationship fields in frontmatter.
- A local Git repository that can be connected to a remote later.
## Local-Only By Default
When Tolaria clones the sample, it removes the remote from the local copy. This makes the sample vault disposable. You can edit it freely, commit locally, and delete it later.
To connect a vault to your own remote, use the bottom status bar remote chip or run `Add Remote` from the command palette.
## When To Move On
After you understand the sample, open your own vault. Tolaria does not require a special folder structure: a folder of Markdown files is enough to start.

View File

@@ -1,28 +0,0 @@
# Install Tolaria
Tolaria is developed and tested primarily on macOS. Linux and Windows builds are published on a best-effort basis while the app matures.
## Download
Use the latest stable release unless you are intentionally testing pre-release builds:
- [Download the latest stable build](https://refactoringhq.github.io/tolaria/download/)
- [Browse all GitHub releases](https://github.com/refactoringhq/tolaria/releases)
- [Read the release notes](/releases/)
## Platform Status
| Platform | Status | Notes |
| --- | --- | --- |
| macOS | Primary | Apple Silicon is the first-class desktop target. |
| Linux | Best effort | Builds exist, but desktop integration varies by distribution and WebKitGTK packaging. |
| Windows | Best effort | Builds exist, but behavior may lag macOS while the app is still early. |
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
## After Installing
1. Open Tolaria.
2. Choose the Getting Started vault if you want a guided sample.
3. Or open an existing folder of Markdown files as a vault.
4. Use the command palette with `Cmd+K` on macOS or `Ctrl+K` on Linux and Windows.

View File

@@ -1,25 +0,0 @@
# Open Or Create A Vault
A Tolaria vault is a folder on disk. The folder can contain Markdown notes, attachments, type definitions, saved views, and Git metadata.
## Open An Existing Folder
Choose an existing folder if you already have Markdown notes. Tolaria scans `.md` files and uses frontmatter when it exists.
Good starting points:
- A folder of plain Markdown files.
- An Obsidian-style vault.
- A Git repository containing notes.
- A copy of the Getting Started vault.
## Create A New Vault
Choose a new empty folder if you want Tolaria conventions from the start. New notes are created as Markdown files, and optional type definitions live in the `type/` folder.
## Git Repository Requirement
Tolaria's history and sync features expect the vault to be a Git repository. If a vault is not already a repository, Tolaria can initialize one for you.
Use Git because it gives Tolaria reliable local history, diff views, recovery, and remote sync without a proprietary backend.

View File

@@ -1,23 +0,0 @@
# AI Agent Not Found
Tolaria can only launch local CLI agents that are installed and discoverable.
## Symptoms
- The AI panel says no supported agent is available.
- Claude Code or another agent works in one shell but not in Tolaria.
## Checks
Open a terminal and run the agent command directly. For Claude Code:
```bash
claude --version
```
If the command fails, install or repair the agent first.
## Path Issues
Desktop apps can inherit a different `PATH` from your interactive shell. Tolaria checks common install locations, but shell setup can still vary. Prefer installing CLI tools in standard locations or making them available from your login shell.

View File

@@ -1,26 +0,0 @@
# Git Authentication
Tolaria uses system Git authentication. It does not manage provider passwords directly.
## Symptoms
- Push fails.
- Pull asks for credentials repeatedly.
- Remote fetch works in one terminal but not in Tolaria.
## Checks
1. Open a terminal.
2. `cd` into the vault.
3. Run `git remote -v`.
4. Run `git fetch`.
If `git fetch` fails in the terminal, fix system Git auth first.
## Common Fixes
- Sign in with GitHub CLI.
- Configure SSH keys.
- Update the remote URL.
- Check your credential helper.

View File

@@ -1,20 +0,0 @@
# Sync Conflicts
Sync conflicts happen when local and remote changes touch the same content.
## What To Do
1. Stop editing the conflicted note.
2. Open the conflict resolver if Tolaria presents it.
3. Review both sides.
4. Choose the correct content or merge manually.
5. Commit the resolved file.
6. Push again.
## Prevent Conflicts
- Pull before starting work on another device.
- Push after meaningful sessions.
- Keep AI-generated edits in small commits.
- Avoid editing the same note on multiple devices at the same time.

View File

@@ -1,25 +0,0 @@
# Vault Not Loading
Use this checklist when Tolaria cannot open or refresh a vault.
## Check The Folder
- Confirm the folder exists.
- Confirm the folder contains readable files.
- Confirm Tolaria has permission to access the folder.
- Try opening a smaller test vault to isolate the issue.
## Check Git
If the vault is a Git repository, verify it is not in a broken state:
```bash
git status
```
Resolve interrupted merges or corrupted repository state before retrying.
## Reload
Run `Reload Vault` from the command palette. This clears derived cache and rescans the filesystem.

View File

@@ -1,7 +1,4 @@
use serde::{Deserialize, Serialize};
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::Stdio;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
@@ -10,6 +7,15 @@ pub enum AiAgentId {
Codex,
Opencode,
Pi,
Gemini,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum AiAgentPermissionMode {
#[default]
Safe,
PowerUser,
}
#[derive(Debug, Clone, Serialize)]
@@ -24,6 +30,7 @@ pub struct AiAgentsStatus {
pub codex: AiAgentAvailability,
pub opencode: AiAgentAvailability,
pub pi: AiAgentAvailability,
pub gemini: AiAgentAvailability,
}
#[derive(Debug, Clone, Serialize)]
@@ -61,14 +68,22 @@ pub struct AiAgentStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: String,
pub permission_mode: Option<AiAgentPermissionMode>,
}
impl AiAgentStreamRequest {
fn permission_mode(&self) -> AiAgentPermissionMode {
self.permission_mode.unwrap_or_default()
}
}
pub fn get_ai_agents_status() -> AiAgentsStatus {
AiAgentsStatus {
claude_code: availability_from_claude(),
codex: availability_from_codex(),
opencode: availability_from_opencode(),
pi: availability_from_pi(),
codex: crate::codex_cli::check_cli(),
opencode: crate::opencode_cli::check_cli(),
pi: crate::pi_cli::check_cli(),
gemini: crate::gemini_cli::check_cli(),
}
}
@@ -76,12 +91,14 @@ pub fn run_ai_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Res
where
F: FnMut(AiAgentStreamEvent),
{
let permission_mode = request.permission_mode();
match request.agent {
AiAgentId::ClaudeCode => {
let mapped = crate::claude_cli::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::claude_cli::run_agent_stream(mapped, |event| {
if let Some(mapped_event) = map_claude_event(event) {
@@ -89,12 +106,21 @@ where
}
})
}
AiAgentId::Codex => run_codex_agent_stream(request, emit),
AiAgentId::Codex => {
let mapped = crate::codex_cli::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::codex_cli::run_agent_stream(mapped, emit)
}
AiAgentId::Opencode => {
let mapped = crate::opencode_cli::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::opencode_cli::run_agent_stream(mapped, emit)
}
@@ -103,9 +129,19 @@ where
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::pi_cli::run_agent_stream(mapped, emit)
}
AiAgentId::Gemini => {
let mapped = crate::gemini_cli::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::gemini_cli::run_agent_stream(mapped, emit)
}
}
}
@@ -117,351 +153,6 @@ fn availability_from_claude() -> AiAgentAvailability {
}
}
fn availability_from_codex() -> AiAgentAvailability {
let binary = match find_codex_binary() {
Ok(binary) => binary,
Err(_) => {
return AiAgentAvailability {
installed: false,
version: None,
}
}
};
AiAgentAvailability {
installed: true,
version: version_for_binary(&binary),
}
}
fn availability_from_opencode() -> AiAgentAvailability {
crate::opencode_cli::check_cli()
}
fn availability_from_pi() -> AiAgentAvailability {
crate::pi_cli::check_cli()
}
fn version_for_binary(binary: &PathBuf) -> Option<String> {
crate::hidden_command(binary)
.arg("--version")
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn find_codex_binary() -> Result<PathBuf, String> {
if let Some(binary) = find_codex_binary_on_path() {
return Ok(binary);
}
if let Some(binary) = find_codex_binary_in_user_shell() {
return Ok(binary);
}
if let Some(binary) = find_existing_binary(codex_binary_candidates()) {
return Ok(binary);
}
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
}
fn find_codex_binary_on_path() -> Option<PathBuf> {
crate::hidden_command("which")
.arg("codex")
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "codex"))
}
fn user_shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
})
}
fn codex_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| codex_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".local/bin/codex"),
home.join(".codex/bin/codex"),
home.join(".local/share/mise/shims/codex"),
home.join(".asdf/shims/codex"),
home.join(".npm-global/bin/codex"),
home.join(".npm/bin/codex"),
home.join(".bun/bin/codex"),
PathBuf::from("/usr/local/bin/codex"),
PathBuf::from("/opt/homebrew/bin/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
]
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn run_codex_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = find_codex_binary()?;
let args = build_codex_args(&request)?;
let prompt = build_codex_prompt(&request);
let mut command = build_codex_command(&binary, args, prompt, &request.vault_path);
let mut child = command
.spawn()
.map_err(|error| format!("Failed to spawn codex: {error}"))?;
let stdout = child.stdout.take().ok_or("No stdout handle")?;
let reader = std::io::BufReader::new(stdout);
let mut thread_id = String::new();
for line in reader.lines() {
let line = match line {
Ok(line) => line,
Err(error) => {
emit(AiAgentStreamEvent::Error {
message: format!("Read error: {error}"),
});
break;
}
};
if line.trim().is_empty() {
continue;
}
let json = match serde_json::from_str::<serde_json::Value>(&line) {
Ok(json) => json,
Err(_) => continue,
};
if let Some(id) = json["thread_id"].as_str() {
thread_id = id.to_string();
}
dispatch_codex_event(&json, &mut emit);
}
let stderr_output = child
.stderr
.take()
.and_then(|stderr| std::io::read_to_string(stderr).ok())
.unwrap_or_default();
let status = child
.wait()
.map_err(|error| format!("Wait failed: {error}"))?;
if !status.success() {
emit(AiAgentStreamEvent::Error {
message: format_codex_error(stderr_output, status.to_string()),
});
}
emit(AiAgentStreamEvent::Done);
Ok(thread_id)
}
fn build_codex_command(
binary: &Path,
args: Vec<String>,
prompt: String,
vault_path: &str,
) -> std::process::Command {
let mut command = crate::hidden_command(binary);
command
.args(args)
.arg(prompt)
.current_dir(vault_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
command
}
fn build_codex_args(request: &AiAgentStreamRequest) -> Result<Vec<String>, String> {
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
let mcp_server_path = mcp_server
.to_str()
.ok_or("Invalid MCP server path")?
.to_string();
Ok(vec![
"--sandbox".into(),
"workspace-write".into(),
"--ask-for-approval".into(),
"never".into(),
"exec".into(),
"--json".into(),
"-C".into(),
request.vault_path.clone(),
"-c".into(),
r#"mcp_servers.tolaria.command="node""#.into(),
"-c".into(),
format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path),
"-c".into(),
format!(
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#,
request.vault_path
),
])
}
fn build_codex_prompt(request: &AiAgentStreamRequest) -> String {
match request
.system_prompt
.as_ref()
.map(|prompt| prompt.trim())
.filter(|prompt| !prompt.is_empty())
{
Some(system_prompt) => format!(
"System instructions:\n{system_prompt}\n\nUser request:\n{}",
request.message
),
None => request.message.clone(),
}
}
fn dispatch_codex_event<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
match json["type"].as_str().unwrap_or_default() {
"thread.started" => {
if let Some(thread_id) = json["thread_id"].as_str() {
emit(AiAgentStreamEvent::Init {
session_id: thread_id.to_string(),
});
}
}
"item.started" => emit_codex_item_event(json, false, emit),
"item.completed" => emit_codex_item_event(json, true, emit),
_ => {}
}
}
fn emit_codex_item_event<F>(json: &serde_json::Value, completed: bool, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
let item = &json["item"];
let item_type = item["type"].as_str().unwrap_or_default();
let item_id = item["id"].as_str().unwrap_or_default();
match item_type {
"command_execution" => {
if completed {
emit(AiAgentStreamEvent::ToolDone {
tool_id: item_id.to_string(),
output: item["aggregated_output"]
.as_str()
.map(|output| output.to_string()),
});
} else {
emit(AiAgentStreamEvent::ToolStart {
tool_name: "Bash".into(),
tool_id: item_id.to_string(),
input: item["command"]
.as_str()
.map(|command| serde_json::json!({ "command": command }).to_string()),
});
}
}
"agent_message" if completed => {
if let Some(text) = item["text"].as_str() {
emit(AiAgentStreamEvent::TextDelta {
text: text.to_string(),
});
}
}
_ => {}
}
}
fn format_codex_error(stderr_output: String, status: String) -> String {
let lower = stderr_output.to_ascii_lowercase();
if is_codex_auth_error(&lower) {
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
}
if is_codex_write_permission_error(&lower) {
return "Codex could not write to the active vault. Tolaria starts Codex with a workspace-write sandbox, so verify the selected vault folder is writable and retry; writes outside the active vault remain blocked.".into();
}
if stderr_output.trim().is_empty() {
format!("codex exited with status {status}")
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
}
}
fn is_codex_auth_error(lower: &str) -> bool {
["auth", "login", "sign in"]
.iter()
.any(|pattern| lower.contains(pattern))
}
fn is_codex_write_permission_error(lower: &str) -> bool {
[
"read-only sandbox",
"writing is blocked",
"rejected by user approval",
"rejected by the environment",
]
.iter()
.any(|pattern| lower.contains(pattern))
}
fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAgentStreamEvent> {
match event {
crate::claude_cli::ClaudeStreamEvent::Init { session_id } => {
@@ -499,193 +190,21 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
#[test]
fn normalize_status_contains_all_agents() {
let status = get_ai_agents_status();
assert!(matches!(status.claude_code.installed, true | false));
assert!(matches!(status.codex.installed, true | false));
assert!(matches!(status.opencode.installed, true | false));
assert!(matches!(status.pi.installed, true | false));
}
#[test]
fn build_codex_prompt_keeps_system_prompt_first() {
let prompt = build_codex_prompt(&AiAgentStreamRequest {
agent: AiAgentId::Codex,
message: "Rename the note".into(),
system_prompt: Some("Be concise".into()),
vault_path: "/tmp/vault".into(),
});
assert!(prompt.starts_with("System instructions:\nBe concise"));
assert!(prompt.contains("User request:\nRename the note"));
}
#[test]
fn build_codex_args_uses_safe_default_permissions() {
if let Ok(args) = build_codex_args(&AiAgentStreamRequest {
agent: AiAgentId::Codex,
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
}) {
assert_eq!(args[0], "--sandbox");
assert_eq!(args[1], "workspace-write");
assert_eq!(args[2], "--ask-for-approval");
assert_eq!(args[3], "never");
assert_eq!(args[4], "exec");
assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
assert!(!args.contains(&"danger-full-access".to_string()));
assert!(args.contains(&"--json".to_string()));
assert!(args.contains(&"-C".to_string()));
}
}
#[test]
fn build_codex_command_keeps_agent_process_contract() {
let binary = PathBuf::from("codex");
let args = vec!["exec".to_string(), "--json".to_string()];
let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault");
let actual_args: Vec<&OsStr> = command.get_args().collect();
assert_eq!(command.get_program(), OsStr::new("codex"));
assert_eq!(
actual_args,
vec![
OsStr::new("exec"),
OsStr::new("--json"),
OsStr::new("Summarize")
]
);
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
}
#[test]
fn codex_binary_candidates_include_supported_macos_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = codex_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/codex"),
home.join(".codex/bin/codex"),
home.join(".local/share/mise/shims/codex"),
home.join(".asdf/shims/codex"),
home.join(".npm-global/bin/codex"),
home.join(".bun/bin/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
let install_flags = [
status.claude_code.installed,
status.codex.installed,
status.opencode.installed,
status.pi.installed,
status.gemini.installed,
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing-codex");
let codex = dir.path().join("codex");
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
let stdout = format!("\n{}\n{}\n", missing.display(), codex.display());
assert_eq!(first_existing_path(&stdout), Some(codex));
}
#[cfg(unix)]
#[test]
fn command_path_from_shell_finds_codex_from_login_shell() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let codex = dir.path().join("codex");
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap();
let shell = dir.path().join("shell");
std::fs::write(
&shell,
format!(
"#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n",
codex.display()
),
)
.unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex));
}
#[test]
fn dispatch_codex_command_events_maps_to_bash_events() {
let mut events = Vec::new();
let started = serde_json::json!({
"type": "item.started",
"item": {
"id": "item_1",
"type": "command_execution",
"command": "/bin/zsh -lc pwd"
}
});
let completed = serde_json::json!({
"type": "item.completed",
"item": {
"id": "item_1",
"type": "command_execution",
"aggregated_output": "/private/tmp\n"
}
});
dispatch_codex_event(&started, &mut |event| events.push(event));
dispatch_codex_event(&completed, &mut |event| events.push(event));
assert!(matches!(
&events[0],
AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. }
if tool_name == "Bash" && tool_id == "item_1"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ToolDone { tool_id, output }
if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n")
));
}
#[test]
fn dispatch_codex_agent_message_maps_to_text_delta() {
let mut events = Vec::new();
let completed = serde_json::json!({
"type": "item.completed",
"item": {
"id": "item_2",
"type": "agent_message",
"text": "All set"
}
});
dispatch_codex_event(&completed, &mut |event| events.push(event));
assert!(matches!(
&events[0],
AiAgentStreamEvent::TextDelta { text } if text == "All set"
));
}
#[test]
fn format_codex_error_explains_vault_write_permission_failures() {
let message = format_codex_error(
"The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
"exit status: 1".into(),
);
assert!(message.contains("active vault"));
assert!(message.contains("writable"));
assert!(message.contains("outside"));
assert!(install_flags
.iter()
.all(|installed| matches!(installed, true | false)));
}
#[test]

View File

@@ -1,6 +1,7 @@
use crate::ai_agents::AiAgentPermissionMode;
pub use crate::cli_agent_runtime::AgentStreamRequest;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Stdio};
@@ -50,14 +51,6 @@ pub struct ChatStreamRequest {
pub session_id: Option<String>,
}
/// Parameters accepted by Claude Code agent streams.
#[derive(Debug, Deserialize)]
pub struct AgentStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: String,
}
// ---------------------------------------------------------------------------
// Finding the `claude` binary
// ---------------------------------------------------------------------------
@@ -148,7 +141,7 @@ fn claude_binary_candidates() -> Vec<PathBuf> {
}
fn claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
let mut candidates = vec![
home.join(".local/bin/claude"),
home.join(".local/bin/claude.exe"),
home.join(".claude/local/claude"),
@@ -170,7 +163,24 @@ fn claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
home.join("scoop/shims/claude.exe"),
PathBuf::from("/opt/homebrew/bin/claude"),
PathBuf::from("/usr/local/bin/claude"),
]
];
candidates.extend(nvm_node_binary_candidates_for_home(home, "claude"));
candidates
}
fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
return Vec::new();
};
let mut candidates = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.map(|path| path.join("bin").join(binary_name))
.collect::<Vec<_>>();
candidates.sort();
candidates
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
@@ -193,16 +203,9 @@ pub fn check_cli() -> ClaudeCliStatus {
}
};
let version = crate::hidden_command(&bin)
.arg("--version")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
ClaudeCliStatus {
installed: true,
version,
version: crate::cli_agent_runtime::version_for_binary(&bin),
}
}
@@ -273,7 +276,7 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
"--permission-mode".into(),
"acceptEdits".into(),
"--tools".into(),
"Read,Edit,MultiEdit,Write,Glob,Grep,LS".into(),
agent_tools(req.permission_mode).into(),
"--no-session-persistence".into(),
];
@@ -287,19 +290,25 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
Ok(args)
}
fn agent_tools(permission_mode: AiAgentPermissionMode) -> &'static str {
match permission_mode {
AiAgentPermissionMode::Safe => "Read,Edit,MultiEdit,Write,Glob,Grep,LS",
AiAgentPermissionMode::PowerUser => "Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash",
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Build a temporary MCP config JSON string pointing to the vault MCP server.
fn build_mcp_config(vault_path: &str) -> Result<String, String> {
let server_dir = crate::mcp::mcp_server_dir()?;
let index_js = server_dir.join("index.js");
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
let config = serde_json::json!({
"mcpServers": {
"tolaria": {
"command": "node",
"args": [index_js.to_string_lossy()],
"args": [mcp_server_path],
"env": { "VAULT_PATH": vault_path }
}
}
@@ -314,6 +323,8 @@ struct StreamState {
tool_inputs: HashMap<String, String>,
/// The tool_use id of the block currently being streamed.
current_tool_id: Option<String>,
/// Tracks whether response text has already been emitted for this run.
emitted_text: bool,
}
/// Core subprocess runner shared by chat and agent modes.
@@ -327,55 +338,28 @@ fn run_claude_subprocess<F>(
where
F: FnMut(ClaudeStreamEvent),
{
let mut cmd = build_claude_command(bin, args, cwd);
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
let stdout = child.stdout.take().ok_or("No stdout handle")?;
let reader = std::io::BufReader::new(stdout);
let mut state = StreamState {
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
emitted_text: false,
};
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
emit(ClaudeStreamEvent::Error {
message: format!("Read error: {e}"),
});
break;
}
};
let cmd = build_claude_command(bin, args, cwd);
let run = crate::cli_agent_runtime::run_json_line_process(
cmd,
"claude",
emit,
|message| ClaudeStreamEvent::Error { message },
|json, emit, session_id| {
dispatch_event(json, &mut state, emit);
*session_id = state.session_id.clone();
},
)?;
if line.trim().is_empty() {
continue;
}
let json: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue, // skip non-JSON lines
};
dispatch_event(&json, &mut state, emit);
}
// Read stderr for potential error messages.
let stderr_output = child
.stderr
.take()
.and_then(|s| std::io::read_to_string(s).ok())
.unwrap_or_default();
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
if !status.success() && state.session_id.is_empty() {
if !run.status.success() && state.session_id.is_empty() {
emit(ClaudeStreamEvent::Error {
message: format_failed_claude_exit(&stderr_output, status),
message: format_failed_claude_exit(&run.stderr_output, run.status),
});
}
@@ -473,7 +457,15 @@ where
if !sid.is_empty() {
state.session_id = sid.clone();
}
let text = json["result"].as_str().unwrap_or("").to_string();
let text = if state.emitted_text {
String::new()
} else {
let text = json["result"].as_str().unwrap_or("").to_string();
if !text.is_empty() {
state.emitted_text = true;
}
text
};
emit(ClaudeStreamEvent::Result {
text,
session_id: sid,
@@ -483,19 +475,9 @@ where
// --- Complete assistant message (fallback for text when no partials) ---
"assistant" => {
if let Some(content) = json["message"]["content"].as_array() {
let emit_text = !state.emitted_text;
for block in content {
if block["type"].as_str() == Some("tool_use") {
if let (Some(id), Some(name)) =
(block["id"].as_str(), block["name"].as_str())
{
let input = format_tool_input(&block["input"], state, id);
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input,
});
}
}
dispatch_assistant_content_block(block, emit_text, state, emit);
}
}
}
@@ -518,9 +500,7 @@ where
match delta["type"].as_str() {
Some("text_delta") => {
if let Some(text) = delta["text"].as_str() {
emit(ClaudeStreamEvent::TextDelta {
text: text.to_string(),
});
emit_text_delta(text, state, emit);
}
}
Some("thinking_delta") => {
@@ -565,6 +545,46 @@ where
}
}
fn dispatch_assistant_content_block<F>(
block: &serde_json::Value,
emit_text: bool,
state: &mut StreamState,
emit: &mut F,
) where
F: FnMut(ClaudeStreamEvent),
{
match block["type"].as_str() {
Some("text") if emit_text => {
if let Some(text) = block["text"].as_str() {
emit_text_delta(text, state, emit);
}
}
Some("tool_use") => {
if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) {
let input = format_tool_input(&block["input"], state, id);
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input,
});
}
}
_ => {}
}
}
fn emit_text_delta<F>(text: &str, state: &mut StreamState, emit: &mut F)
where
F: FnMut(ClaudeStreamEvent),
{
if !text.is_empty() {
state.emitted_text = true;
}
emit(ClaudeStreamEvent::TextDelta {
text: text.to_string(),
});
}
/// Build the tool input string, preferring accumulated delta chunks over the
/// block's `input` field (which may be empty at stream start).
fn format_tool_input(
@@ -607,6 +627,82 @@ mod tests {
use std::ffi::OsString;
use std::process::Command;
macro_rules! chat_request {
($message:expr, None, None $(,)?) => {
ChatStreamRequest {
message: $message.into(),
system_prompt: None,
session_id: None,
}
};
($message:expr, Some($system_prompt:expr), None $(,)?) => {
ChatStreamRequest {
message: $message.into(),
system_prompt: Some($system_prompt.to_string()),
session_id: None,
}
};
($message:expr, None, Some($session_id:expr) $(,)?) => {
ChatStreamRequest {
message: $message.into(),
system_prompt: None,
session_id: Some($session_id.to_string()),
}
};
}
macro_rules! agent_request {
($message:expr, None, $permission_mode:expr $(,)?) => {
AgentStreamRequest {
message: $message.into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: $permission_mode,
}
};
($message:expr, Some($system_prompt:expr), $permission_mode:expr $(,)?) => {
AgentStreamRequest {
message: $message.into(),
system_prompt: Some($system_prompt.to_string()),
vault_path: "/tmp/vault".into(),
permission_mode: $permission_mode,
}
};
}
macro_rules! assert_args_contain {
($args:expr, [$($value:expr),+ $(,)?] $(,)?) => {
$(
assert!($args.contains(&$value.to_string()), "missing {}", $value);
)+
};
}
macro_rules! assert_args_lack {
($args:expr, [$($value:expr),+ $(,)?] $(,)?) => {
$(
assert!(!$args.contains(&$value.to_string()), "unexpected {}", $value);
)+
};
}
macro_rules! assert_no_arg_contains {
($args:expr, $fragment:expr $(,)?) => {
assert!(!$args.iter().any(|arg| arg.contains($fragment)));
};
}
fn assert_binary_candidates_include(home: &Path, expected: &[PathBuf]) {
let candidates = claude_binary_candidates_for_home(home);
for candidate in expected {
assert!(
candidates.contains(candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn check_cli_returns_status() {
let status = check_cli();
@@ -617,6 +713,50 @@ mod tests {
}
}
#[test]
fn claude_binary_candidates_include_nvm_managed_node_installs() {
let home = tempfile::tempdir().unwrap();
let claude = home.path().join(".nvm/versions/node/v22.12.0/bin/claude");
std::fs::create_dir_all(claude.parent().unwrap()).unwrap();
std::fs::write(&claude, "#!/bin/sh\n").unwrap();
let candidates = claude_binary_candidates_for_home(home.path());
assert!(candidates.contains(&claude), "missing {}", claude.display());
}
#[test]
fn agent_args_use_safe_mode_without_bash_by_default() {
let args = build_agent_args(&agent_request!(
"Rename the note",
None,
AiAgentPermissionMode::Safe,
))
.unwrap();
assert_args_contain!(
args,
["--strict-mcp-config", "--permission-mode", "acceptEdits"]
);
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]);
assert_no_arg_contains!(args, "Bash");
assert_args_lack!(args, ["--dangerously-skip-permissions"]);
}
#[test]
fn agent_args_allow_bash_in_power_user_mode_without_dangerous_bypass() {
let args = build_agent_args(&agent_request!(
"Rename the note",
None,
AiAgentPermissionMode::PowerUser,
))
.unwrap();
assert_args_contain!(args, ["--strict-mcp-config"]);
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash"]);
assert_args_lack!(args, ["--dangerously-skip-permissions"]);
}
#[test]
fn build_mcp_config_is_valid_json() {
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
@@ -636,6 +776,7 @@ mod tests {
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
emitted_text: false,
}
}
@@ -765,10 +906,35 @@ mod tests {
{ "type": "tool_use", "id": "tu_1", "name": "search_notes", "input": {} }
] }
}));
assert_eq!(events.len(), 1);
assert_eq!(events.len(), 2);
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1")
matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Let me search.")
);
assert!(
matches!(&events[1], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1")
);
}
#[test]
fn dispatch_event_result_after_text_delta_does_not_duplicate_response_text() {
let (state, events) = run_dispatch_sequence(vec![
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "Visible reply" } }
}),
serde_json::json!({
"type": "result",
"session_id": "session-1",
"result": "Visible reply"
}),
]);
assert_eq!(state.session_id, "session-1");
assert!(matches!(&events[..],
[
ClaudeStreamEvent::TextDelta { text },
ClaudeStreamEvent::Result { text: result_text, session_id },
] if text == "Visible reply" && result_text.is_empty() && session_id == "session-1"));
}
#[test]
@@ -1142,52 +1308,31 @@ mod tests {
#[test]
fn build_chat_args_basic() {
let req = ChatStreamRequest {
message: "hello".into(),
system_prompt: None,
session_id: None,
};
let args = build_chat_args(&req);
assert!(args.contains(&"-p".to_string()));
assert!(args.contains(&"hello".to_string()));
assert!(args.contains(&"stream-json".to_string()));
assert!(!args.contains(&"--system-prompt".to_string()));
assert!(!args.contains(&"--resume".to_string()));
let args = build_chat_args(&chat_request!("hello", None, None));
assert_args_contain!(args, ["-p", "hello", "stream-json"]);
assert_args_lack!(args, ["--system-prompt", "--resume"]);
}
#[test]
fn build_chat_args_with_system_prompt() {
let req = ChatStreamRequest {
message: "hi".into(),
system_prompt: Some("You are helpful.".into()),
session_id: None,
};
let args = build_chat_args(&req);
assert!(args.contains(&"--system-prompt".to_string()));
assert!(args.contains(&"You are helpful.".to_string()));
let args = build_chat_args(&chat_request!("hi", Some("You are helpful."), None));
assert_args_contain!(args, ["--system-prompt", "You are helpful."]);
}
#[test]
fn build_chat_args_empty_system_prompt_is_skipped() {
let req = ChatStreamRequest {
message: "hi".into(),
system_prompt: Some(String::new()),
session_id: None,
};
let args = build_chat_args(&req);
let args = build_chat_args(&chat_request!("hi", Some(""), None));
assert!(!args.contains(&"--system-prompt".to_string()));
}
#[test]
fn build_chat_args_with_session_id() {
let req = ChatStreamRequest {
message: "continue".into(),
system_prompt: None,
session_id: Some("sess-abc".into()),
};
let args = build_chat_args(&req);
assert!(args.contains(&"--resume".to_string()));
assert!(args.contains(&"sess-abc".to_string()));
let args = build_chat_args(&chat_request!("continue", None, Some("sess-abc")));
assert_args_contain!(args, ["--resume", "sess-abc"]);
}
// --- build_agent_args ---
@@ -1195,46 +1340,44 @@ mod tests {
#[test]
fn build_agent_args_basic() {
// build_agent_args calls build_mcp_config which needs mcp_server_dir
if let Ok(args) = build_agent_args(&AgentStreamRequest {
message: "create note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
}) {
assert!(args.contains(&"-p".to_string()));
assert!(args.contains(&"create note".to_string()));
assert!(args.contains(&"--mcp-config".to_string()));
assert!(args.contains(&"--strict-mcp-config".to_string()));
assert!(args.contains(&"--permission-mode".to_string()));
assert!(args.contains(&"acceptEdits".to_string()));
assert!(args.contains(&"--tools".to_string()));
assert!(args.contains(&"Read,Edit,MultiEdit,Write,Glob,Grep,LS".to_string()));
assert!(!args.contains(&"--dangerously-skip-permissions".to_string()));
assert!(!args.contains(&"bypassPermissions".to_string()));
assert!(!args.contains(&"Bash".to_string()));
assert!(args.contains(&"--no-session-persistence".to_string()));
assert!(!args.contains(&"--append-system-prompt".to_string()));
if let Ok(args) = build_agent_args(&agent_request!(
"create note",
None,
AiAgentPermissionMode::Safe,
)) {
assert_args_contain!(args, ["-p", "create note", "--mcp-config"]);
assert_args_contain!(args, ["--strict-mcp-config", "--permission-mode"]);
assert_args_contain!(args, ["acceptEdits", "--tools"]);
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]);
assert_args_contain!(args, ["--no-session-persistence"]);
assert_args_lack!(
args,
[
"--dangerously-skip-permissions",
"bypassPermissions",
"--append-system-prompt",
],
);
assert_no_arg_contains!(args, "Bash");
}
}
#[test]
fn build_agent_args_with_system_prompt() {
if let Ok(args) = build_agent_args(&AgentStreamRequest {
message: "do it".into(),
system_prompt: Some("Act as expert.".into()),
vault_path: "/tmp/v".into(),
}) {
assert!(args.contains(&"--append-system-prompt".to_string()));
assert!(args.contains(&"Act as expert.".to_string()));
if let Ok(args) = build_agent_args(&agent_request!(
"do it",
Some("Act as expert."),
AiAgentPermissionMode::Safe,
)) {
assert_args_contain!(args, ["--append-system-prompt", "Act as expert."]);
}
}
#[test]
fn build_agent_args_empty_system_prompt_is_skipped() {
if let Ok(args) = build_agent_args(&AgentStreamRequest {
message: "x".into(),
system_prompt: Some(String::new()),
vault_path: "/tmp/v".into(),
}) {
if let Ok(args) =
build_agent_args(&agent_request!("x", Some(""), AiAgentPermissionMode::Safe))
{
assert!(!args.contains(&"--append-system-prompt".to_string()));
}
}
@@ -1244,7 +1387,6 @@ mod tests {
#[test]
fn claude_binary_candidates_include_supported_local_and_toolchain_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = claude_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/claude"),
home.join(".claude/local/claude"),
@@ -1252,32 +1394,19 @@ mod tests {
home.join(".npm-global/bin/claude"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
assert_binary_candidates_include(&home, &expected);
}
#[test]
fn claude_binary_candidates_include_windows_exe_installs() {
let home = PathBuf::from(r"C:\Users\alex");
let candidates = claude_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/claude.exe"),
home.join(".claude/local/claude.exe"),
home.join("AppData/Roaming/npm/claude.cmd"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
assert_binary_candidates_include(&home, &expected);
}
#[test]
@@ -1333,6 +1462,7 @@ mod tests {
message: "test".into(),
system_prompt: Some("sys".into()),
vault_path: "/tmp/nonexistent".into(),
permission_mode: AiAgentPermissionMode::Safe,
};
let mut events = vec![];
let result = run_agent_stream(req, |e| events.push(e));

View File

@@ -0,0 +1,186 @@
use crate::ai_agents::{AiAgentPermissionMode, AiAgentStreamEvent};
use serde::Deserialize;
use std::io::BufRead;
use std::path::PathBuf;
use std::process::{Command, ExitStatus};
#[derive(Debug, Clone, Deserialize)]
pub struct AgentStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: String,
pub permission_mode: AiAgentPermissionMode,
}
pub(crate) struct JsonLineRun {
pub session_id: String,
pub stderr_output: String,
pub status: ExitStatus,
}
pub(crate) fn build_prompt(message: &str, system_prompt: Option<&str>) -> String {
match system_prompt
.map(str::trim)
.filter(|prompt| !prompt.is_empty())
{
Some(system_prompt) => {
format!("System instructions:\n{system_prompt}\n\nUser request:\n{message}")
}
None => message.to_string(),
}
}
pub(crate) fn mcp_server_path_string() -> Result<String, String> {
Ok(crate::mcp::mcp_server_dir()?
.join("index.js")
.to_str()
.ok_or("Invalid MCP server path")?
.to_string())
}
pub(crate) fn version_for_binary(binary: &PathBuf) -> Option<String> {
crate::hidden_command(binary)
.arg("--version")
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub(crate) fn parse_json_line(
line: Result<String, std::io::Error>,
) -> Result<Option<serde_json::Value>, String> {
let line = line.map_err(|error| format!("Read error: {error}"))?;
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(None);
}
Ok(serde_json::from_str::<serde_json::Value>(trimmed).ok())
}
#[cfg(test)]
pub(crate) fn parse_ai_agent_json_line<F>(
line: Result<String, std::io::Error>,
emit: &mut F,
) -> Option<serde_json::Value>
where
F: FnMut(AiAgentStreamEvent),
{
match parse_json_line(line) {
Ok(json) => json,
Err(message) => {
emit(AiAgentStreamEvent::Error { message });
None
}
}
}
pub(crate) fn run_json_line_process<Event, F, H>(
mut command: Command,
process_name: &'static str,
emit: &mut F,
error_event: impl Fn(String) -> Event,
mut handle_json: H,
) -> Result<JsonLineRun, String>
where
F: FnMut(Event),
H: FnMut(&serde_json::Value, &mut F, &mut String),
{
let mut child = command
.spawn()
.map_err(|error| format!("Failed to spawn {process_name}: {error}"))?;
let stdout = child.stdout.take().ok_or("No stdout handle")?;
let reader = std::io::BufReader::new(stdout);
let mut session_id = String::new();
for line in reader.lines() {
match parse_json_line(line) {
Ok(Some(json)) => handle_json(&json, emit, &mut session_id),
Ok(None) => {}
Err(message) => {
emit(error_event(message));
break;
}
}
}
let stderr_output = child
.stderr
.take()
.and_then(|stderr| std::io::read_to_string(stderr).ok())
.unwrap_or_default();
let status = child
.wait()
.map_err(|error| format!("Wait failed: {error}"))?;
Ok(JsonLineRun {
session_id,
stderr_output,
status,
})
}
pub(crate) fn run_ai_agent_json_stream<F>(
command: Command,
process_name: &'static str,
mut emit: F,
session_id: impl Fn(&serde_json::Value) -> Option<&str>,
dispatch_event: impl Fn(&serde_json::Value, &mut F),
format_error: impl Fn(String, String) -> String,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let run = run_json_line_process(
command,
process_name,
&mut emit,
|message| AiAgentStreamEvent::Error { message },
|json, emit, active_session_id| {
if let Some(id) = session_id(json) {
*active_session_id = id.to_string();
}
dispatch_event(json, emit);
},
)?;
if !run.status.success() {
emit(AiAgentStreamEvent::Error {
message: format_error(run.stderr_output, run.status.to_string()),
});
}
emit(AiAgentStreamEvent::Done);
Ok(run.session_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_prompt_keeps_system_prompt_first() {
let prompt = build_prompt("Rename the note", Some("Be concise"));
assert!(prompt.starts_with("System instructions:\nBe concise"));
assert!(prompt.contains("User request:\nRename the note"));
}
#[test]
fn build_prompt_skips_blank_system_prompt() {
assert_eq!(
build_prompt("Rename the note", Some(" ")),
"Rename the note"
);
}
#[test]
fn parse_json_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
assert!(parse_json_line(Ok(" ".into())).unwrap().is_none());
assert!(parse_json_line(Ok("not json".into())).unwrap().is_none());
let error = parse_json_line(Err(std::io::Error::other("broken pipe"))).unwrap_err();
assert!(error.contains("broken pipe"));
}
}

607
src-tauri/src/codex_cli.rs Normal file
View File

@@ -0,0 +1,607 @@
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
pub use crate::cli_agent_runtime::AgentStreamRequest;
use std::path::{Path, PathBuf};
use std::process::Stdio;
pub fn check_cli() -> AiAgentAvailability {
let binary = match find_codex_binary() {
Ok(binary) => binary,
Err(_) => {
return AiAgentAvailability {
installed: false,
version: None,
}
}
};
AiAgentAvailability {
installed: true,
version: crate::cli_agent_runtime::version_for_binary(&binary),
}
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = find_codex_binary()?;
run_agent_stream_with_binary(&binary, request, emit)
}
fn find_codex_binary() -> Result<PathBuf, String> {
if let Some(binary) = find_codex_binary_on_path() {
return Ok(binary);
}
if let Some(binary) = find_codex_binary_in_user_shell() {
return Ok(binary);
}
if let Some(binary) = find_existing_binary(codex_binary_candidates()) {
return Ok(binary);
}
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
}
fn find_codex_binary_on_path() -> Option<PathBuf> {
crate::hidden_command("which")
.arg("codex")
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "codex"))
}
fn user_shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
})
}
fn codex_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| codex_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
home.join(".local/bin/codex"),
home.join(".codex/bin/codex"),
home.join(".local/share/mise/shims/codex"),
home.join(".asdf/shims/codex"),
home.join(".npm-global/bin/codex"),
home.join(".npm/bin/codex"),
home.join(".bun/bin/codex"),
PathBuf::from("/usr/local/bin/codex"),
PathBuf::from("/opt/homebrew/bin/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
];
candidates.extend(nvm_node_binary_candidates_for_home(home, "codex"));
candidates
}
fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
return Vec::new();
};
let mut candidates = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.map(|path| path.join("bin").join(binary_name))
.collect::<Vec<_>>();
candidates.sort();
candidates
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn run_agent_stream_with_binary<F>(
binary: &Path,
request: AgentStreamRequest,
emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let args = build_codex_args(&request)?;
let prompt = build_codex_prompt(&request);
let command = build_codex_command(binary, args, prompt, &request.vault_path);
crate::cli_agent_runtime::run_ai_agent_json_stream(
command,
"codex",
emit,
codex_session_id,
dispatch_codex_event,
format_codex_error,
)
}
fn build_codex_command(
binary: &Path,
args: Vec<String>,
prompt: String,
vault_path: &str,
) -> std::process::Command {
let mut command = crate::hidden_command(binary);
command
.args(args)
.arg(prompt)
.current_dir(vault_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
command
}
fn build_codex_args(request: &AgentStreamRequest) -> Result<Vec<String>, String> {
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
Ok(vec![
"--sandbox".into(),
"workspace-write".into(),
"--ask-for-approval".into(),
"never".into(),
"exec".into(),
"--json".into(),
"-C".into(),
request.vault_path.clone(),
"-c".into(),
r#"mcp_servers.tolaria.command="node""#.into(),
"-c".into(),
format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path),
"-c".into(),
format!(
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#,
request.vault_path
),
])
}
fn build_codex_prompt(request: &AgentStreamRequest) -> String {
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
}
fn codex_session_id(json: &serde_json::Value) -> Option<&str> {
json["thread_id"].as_str()
}
fn dispatch_codex_event<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
match json["type"].as_str().unwrap_or_default() {
"thread.started" => {
if let Some(thread_id) = json["thread_id"].as_str() {
emit(AiAgentStreamEvent::Init {
session_id: thread_id.to_string(),
});
}
}
"item.started" => emit_codex_item_event(json, false, emit),
"item.completed" => emit_codex_item_event(json, true, emit),
_ => {}
}
}
fn emit_codex_item_event<F>(json: &serde_json::Value, completed: bool, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
let item = &json["item"];
let item_type = item["type"].as_str().unwrap_or_default();
let item_id = item["id"].as_str().unwrap_or_default();
match item_type {
"command_execution" => {
if completed {
emit(AiAgentStreamEvent::ToolDone {
tool_id: item_id.to_string(),
output: item["aggregated_output"]
.as_str()
.map(|output| output.to_string()),
});
} else {
emit(AiAgentStreamEvent::ToolStart {
tool_name: "Bash".into(),
tool_id: item_id.to_string(),
input: item["command"]
.as_str()
.map(|command| serde_json::json!({ "command": command }).to_string()),
});
}
}
"agent_message" if completed => {
if let Some(text) = item["text"].as_str() {
emit(AiAgentStreamEvent::TextDelta {
text: text.to_string(),
});
}
}
_ => {}
}
}
fn format_codex_error(stderr_output: String, status: String) -> String {
let lower = stderr_output.to_ascii_lowercase();
if is_codex_auth_error(&lower) {
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
}
if is_codex_write_permission_error(&lower) {
return "Codex could not write to the active vault. Tolaria starts Codex with a workspace-write sandbox, so verify the selected vault folder is writable and retry; writes outside the active vault remain blocked.".into();
}
if stderr_output.trim().is_empty() {
format!("codex exited with status {status}")
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
}
}
fn is_codex_auth_error(lower: &str) -> bool {
["auth", "login", "sign in"]
.iter()
.any(|pattern| lower.contains(pattern))
}
fn is_codex_write_permission_error(lower: &str) -> bool {
[
"read-only sandbox",
"writing is blocked",
"rejected by user approval",
"rejected by the environment",
]
.iter()
.any(|pattern| lower.contains(pattern))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai_agents::AiAgentPermissionMode;
use std::ffi::OsStr;
#[cfg(unix)]
fn executable_script(dir: &Path, name: &str, body: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let script = dir.join(name);
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
script
}
fn codex_request(
vault_path: &Path,
permission_mode: AiAgentPermissionMode,
) -> AgentStreamRequest {
AgentStreamRequest {
message: "Summarize".into(),
system_prompt: None,
vault_path: vault_path.to_string_lossy().into_owned(),
permission_mode,
}
}
fn assert_codex_workspace_write_contract(args: &[String]) {
let prefix = [
"--sandbox",
"workspace-write",
"--ask-for-approval",
"never",
];
assert_eq!(&args[..prefix.len()], prefix);
assert!(!args.iter().any(|arg| arg == "danger-full-access"));
assert!(!args
.iter()
.any(|arg| arg == "--dangerously-bypass-approvals-and-sandbox"));
}
#[cfg(unix)]
fn run_codex_script(body: &str) -> (String, Vec<AiAgentStreamEvent>) {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(dir.path(), "codex", body);
let mut events = Vec::new();
let thread_id = run_agent_stream_with_binary(
&binary,
codex_request(vault.path(), AiAgentPermissionMode::Safe),
|event| events.push(event),
)
.unwrap();
(thread_id, events)
}
fn assert_codex_text_flow(events: &[AiAgentStreamEvent], session: &str, text_delta: &str) {
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id == session
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::TextDelta { text } if text == text_delta
));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[test]
fn build_codex_prompt_keeps_system_prompt_first() {
let prompt = build_codex_prompt(&AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: Some("Be concise".into()),
vault_path: "/tmp/vault".into(),
permission_mode: AiAgentPermissionMode::Safe,
});
assert!(prompt.starts_with("System instructions:\nBe concise"));
assert!(prompt.contains("User request:\nRename the note"));
}
#[test]
fn build_codex_args_uses_safe_default_permissions() {
if let Ok(args) = build_codex_args(&AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: AiAgentPermissionMode::Safe,
}) {
assert_eq!(args[4], "exec");
assert_codex_workspace_write_contract(&args);
assert!(args.contains(&"--json".to_string()));
assert!(args.contains(&"-C".to_string()));
}
}
#[test]
fn codex_permission_modes_keep_workspace_write_without_dangerous_bypass() {
for permission_mode in [
AiAgentPermissionMode::Safe,
AiAgentPermissionMode::PowerUser,
] {
if let Ok(args) = build_codex_args(&AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode,
}) {
assert_codex_workspace_write_contract(&args);
}
}
}
#[test]
fn build_codex_command_keeps_agent_process_contract() {
let binary = PathBuf::from("codex");
let args = vec!["exec".to_string(), "--json".to_string()];
let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault");
let actual_args: Vec<&OsStr> = command.get_args().collect();
assert_eq!(command.get_program(), OsStr::new("codex"));
assert_eq!(
actual_args,
vec![
OsStr::new("exec"),
OsStr::new("--json"),
OsStr::new("Summarize")
]
);
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
}
#[cfg(unix)]
#[test]
fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() {
let (thread_id, events) = run_codex_script(
r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Done"}}'
"#,
);
assert_eq!(thread_id, "thread_1");
assert_codex_text_flow(&events, "thread_1", "Done");
}
#[cfg(unix)]
#[test]
fn run_codex_agent_stream_reports_nonzero_exit_errors() {
let (thread_id, events) = run_codex_script(
r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
printf '%s\n' 'login required' >&2
exit 2
"#,
);
assert_eq!(thread_id, "thread_1");
assert!(events.iter().any(|event| matches!(
event,
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
)));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[test]
fn codex_binary_candidates_include_supported_macos_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = codex_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/codex"),
home.join(".codex/bin/codex"),
home.join(".local/share/mise/shims/codex"),
home.join(".asdf/shims/codex"),
home.join(".npm-global/bin/codex"),
home.join(".bun/bin/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn codex_binary_candidates_include_nvm_managed_node_installs() {
let home = tempfile::tempdir().unwrap();
let codex = home.path().join(".nvm/versions/node/v22.12.0/bin/codex");
std::fs::create_dir_all(codex.parent().unwrap()).unwrap();
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
let candidates = codex_binary_candidates_for_home(home.path());
assert!(candidates.contains(&codex), "missing {}", codex.display());
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing-codex");
let codex = dir.path().join("codex");
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
let stdout = format!("\n{}\n{}\n", missing.display(), codex.display());
assert_eq!(first_existing_path(&stdout), Some(codex));
}
#[cfg(unix)]
#[test]
fn command_path_from_shell_finds_codex_from_login_shell() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let codex = dir.path().join("codex");
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap();
let shell = dir.path().join("shell");
std::fs::write(
&shell,
format!(
"#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n",
codex.display()
),
)
.unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex));
}
#[test]
fn dispatch_codex_command_events_maps_to_bash_events() {
let mut events = Vec::new();
let started = serde_json::json!({
"type": "item.started",
"item": {
"id": "item_1",
"type": "command_execution",
"command": "/bin/zsh -lc pwd"
}
});
let completed = serde_json::json!({
"type": "item.completed",
"item": {
"id": "item_1",
"type": "command_execution",
"aggregated_output": "/private/tmp\n"
}
});
dispatch_codex_event(&started, &mut |event| events.push(event));
dispatch_codex_event(&completed, &mut |event| events.push(event));
assert!(matches!(
&events[0],
AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. }
if tool_name == "Bash" && tool_id == "item_1"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ToolDone { tool_id, output }
if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n")
));
}
#[test]
fn dispatch_codex_agent_message_maps_to_text_delta() {
let mut events = Vec::new();
let completed = serde_json::json!({
"type": "item.completed",
"item": {
"id": "item_2",
"type": "agent_message",
"text": "All set"
}
});
dispatch_codex_event(&completed, &mut |event| events.push(event));
assert!(matches!(
&events[0],
AiAgentStreamEvent::TextDelta { text } if text == "All set"
));
}
#[test]
fn format_codex_error_explains_vault_write_permission_failures() {
let message = format_codex_error(
"The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
"exit status: 1".into(),
);
assert!(message.contains("active vault"));
assert!(message.contains("writable"));
assert!(message.contains("outside"));
}
}

View File

@@ -120,6 +120,10 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
installed: false,
version: None,
},
gemini: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
}
}
@@ -154,14 +158,17 @@ mod tests {
let initial = get_vault_ai_guidance_status(vault_path.clone()).unwrap();
assert_eq!(initial.agents_state, AiGuidanceFileState::Missing);
assert_eq!(initial.claude_state, AiGuidanceFileState::Missing);
assert_eq!(initial.gemini_state, AiGuidanceFileState::Missing);
assert!(initial.can_restore);
let restored = restore_vault_ai_guidance(vault_path.clone()).unwrap();
assert_eq!(restored.agents_state, AiGuidanceFileState::Managed);
assert_eq!(restored.claude_state, AiGuidanceFileState::Managed);
assert_eq!(restored.gemini_state, AiGuidanceFileState::Managed);
assert!(!restored.can_restore);
assert!(dir.path().join("AGENTS.md").exists());
assert!(dir.path().join("CLAUDE.md").exists());
assert!(dir.path().join("GEMINI.md").exists());
}
}

View File

@@ -131,6 +131,15 @@ pub async fn check_mcp_status(vault_path: String) -> Result<crate::mcp::McpStatu
.map_err(|e| format!("MCP status check failed: {e}"))
}
#[cfg(desktop)]
#[tauri::command]
pub async fn get_mcp_config_snippet(vault_path: String) -> Result<String, String> {
let vault_path = super::expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::mcp_config_snippet(&vault_path))
.await
.map_err(|e| format!("MCP config task failed: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(
@@ -167,6 +176,12 @@ pub async fn check_mcp_status(_vault_path: String) -> Result<crate::mcp::McpStat
Ok(crate::mcp::McpStatus::NotInstalled)
}
#[cfg(mobile)]
#[tauri::command]
pub async fn get_mcp_config_snippet(_vault_path: String) -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(_vault_path: Option<String>) -> Result<String, String> {

View File

@@ -53,6 +53,7 @@ mod tests {
name: "Inbox".to_string(),
icon: None,
color: None,
order: None,
sort: None,
list_properties_display: vec![],
filters: crate::vault::FilterGroup::All(vec![]),

View File

@@ -23,6 +23,14 @@ fn with_note_path<T>(
)
}
fn with_external_file_path<T>(
path: &Path,
vault_path: Option<&Path>,
action: impl FnOnce(&Path) -> Result<T, String>,
) -> Result<T, String> {
with_note_path(path, vault_path, ValidatedPathMode::Existing, action)
}
fn with_expanded_vault_root<T>(
path: &Path,
action: impl FnOnce(&Path) -> Result<T, String>,
@@ -75,6 +83,26 @@ pub fn sync_vault_asset_scope_for_window(
})
}
#[tauri::command]
pub fn open_vault_file_external(
app_handle: tauri::AppHandle,
path: PathBuf,
vault_path: Option<PathBuf>,
) -> Result<(), String> {
with_external_file_path(path.as_path(), vault_path.as_deref(), |validated_path| {
open_path_with_default_app(&app_handle, validated_path)
})
}
fn open_path_with_default_app(app_handle: &tauri::AppHandle, path: &Path) -> Result<(), String> {
use tauri_plugin_opener::OpenerExt;
app_handle
.opener()
.open_path(path.to_string_lossy().into_owned(), None::<String>)
.map_err(|error| error.to_string())
}
fn with_writable_note_path<T>(
path: PathBuf,
vault_path: Option<PathBuf>,
@@ -101,6 +129,20 @@ pub fn get_note_content(path: PathBuf, vault_path: Option<PathBuf>) -> Result<St
)
}
#[tauri::command]
pub fn validate_note_content(
path: PathBuf,
content: String,
vault_path: Option<PathBuf>,
) -> Result<bool, String> {
with_note_path(
path.as_path(),
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| vault::note_content_matches(validated_path, &content),
)
}
#[tauri::command]
pub fn save_note_content(
path: PathBuf,
@@ -341,4 +383,53 @@ mod tests {
.unwrap_err();
assert!(folder_error.contains("Path must stay inside the active vault"));
}
#[test]
fn external_file_paths_accept_files_inside_requested_vault() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
let attachment = note_path(&dir, "attachments/photo.png");
fs::create_dir_all(attachment.parent().unwrap()).unwrap();
fs::write(&attachment, "image-bytes").unwrap();
let validated = with_external_file_path(
attachment.as_path(),
Some(root.as_path()),
|validated_path| Ok(validated_path.to_path_buf()),
)
.unwrap();
assert_eq!(validated, attachment);
}
#[test]
fn external_file_paths_reject_files_outside_requested_vault() {
let vault = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("photo.png");
fs::write(&outside_file, "image-bytes").unwrap();
let error = with_external_file_path(
outside_file.as_path(),
Some(vault.path()),
|validated_path| Ok(validated_path.to_path_buf()),
)
.unwrap_err();
assert!(error.contains("Path must stay inside the active vault"));
}
#[test]
fn validate_note_content_compares_against_disk() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
let note = note_path(&dir, "note.md");
fs::write(&note, "# Fresh\n").unwrap();
assert!(
validate_note_content(note.clone(), "# Fresh\n".to_string(), Some(root.clone()),)
.unwrap()
);
assert!(!validate_note_content(note, "# Stale\n".to_string(), Some(root)).unwrap());
}
}

220
src-tauri/src/gemini_cli.rs Normal file
View File

@@ -0,0 +1,220 @@
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
pub use crate::cli_agent_runtime::AgentStreamRequest;
use std::path::Path;
use std::process::Output;
pub fn check_cli() -> AiAgentAvailability {
crate::gemini_discovery::check_cli()
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = crate::gemini_discovery::find_binary()?;
run_agent_stream_with_binary(&binary, request, emit)
}
fn run_agent_stream_with_binary<F>(
binary: &Path,
request: AgentStreamRequest,
mut emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let settings_dir = tempfile::Builder::new()
.prefix("tolaria-gemini-agent-")
.tempdir()
.map_err(|error| format!("Failed to create Gemini settings directory: {error}"))?;
let mut command = crate::gemini_config::build_command(binary, &request, settings_dir.path())?;
let output = command
.output()
.map_err(|error| format!("Failed to spawn gemini: {error}"))?;
emit(AiAgentStreamEvent::Init {
session_id: "gemini-headless".into(),
});
if output.status.success() {
emit_gemini_success(&output, &mut emit);
} else {
emit(AiAgentStreamEvent::Error {
message: format_gemini_error(output_stderr(&output), output.status.to_string()),
});
}
emit(AiAgentStreamEvent::Done);
Ok("gemini-headless".into())
}
fn emit_gemini_success<F>(output: &Output, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
match gemini_response_text(&output_stdout(output)) {
GeminiOutput::Response(text) => emit(AiAgentStreamEvent::TextDelta { text }),
GeminiOutput::Error(message) => emit(AiAgentStreamEvent::Error { message }),
GeminiOutput::Empty => {}
}
}
enum GeminiOutput {
Response(String),
Error(String),
Empty,
}
fn gemini_response_text(stdout: &str) -> GeminiOutput {
let trimmed = stdout.trim();
if trimmed.is_empty() {
return GeminiOutput::Empty;
}
match serde_json::from_str::<serde_json::Value>(trimmed) {
Ok(json) => response_from_json(&json),
Err(_) => GeminiOutput::Response(trimmed.to_string()),
}
}
fn response_from_json(json: &serde_json::Value) -> GeminiOutput {
if let Some(message) = json["error"]["message"].as_str() {
return GeminiOutput::Error(message.to_string());
}
if let Some(response) = json["response"].as_str() {
return GeminiOutput::Response(response.to_string());
}
GeminiOutput::Empty
}
fn output_stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).to_string()
}
fn output_stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).to_string()
}
fn format_gemini_error(stderr_output: String, status: String) -> String {
let lower = stderr_output.to_ascii_lowercase();
if is_auth_error(&lower) {
return "Gemini CLI is not authenticated. Run `gemini` in your terminal to sign in, or set GEMINI_API_KEY and retry.".into();
}
if stderr_output.trim().is_empty() {
format!("gemini exited with status {status}")
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
}
}
fn is_auth_error(lower: &str) -> bool {
[
"auth",
"login",
"sign in",
"api key",
"gemini_api_key",
"google_api_key",
"oauth",
"401",
]
.iter()
.any(|pattern| lower.contains(pattern))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai_agents::AiAgentPermissionMode;
#[cfg(unix)]
fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let script = dir.join("gemini");
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
script
}
fn request(vault_path: String) -> AgentStreamRequest {
AgentStreamRequest {
message: "Summarize".into(),
system_prompt: None,
vault_path,
permission_mode: AiAgentPermissionMode::Safe,
}
}
#[cfg(unix)]
#[test]
fn run_agent_stream_maps_gemini_json_response() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' '{"response":"Done","stats":{"tools":{"totalCalls":0}}}'"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_binary(
&binary,
request(vault.path().to_string_lossy().into_owned()),
|event| events.push(event),
)
.unwrap();
assert_eq!(session_id, "gemini-headless");
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id == "gemini-headless"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::TextDelta { text } if text == "Done"
));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[cfg(unix)]
#[test]
fn run_agent_stream_reports_gemini_auth_errors() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' 'oauth login required' >&2
exit 3
"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_binary(
&binary,
request(vault.path().to_string_lossy().into_owned()),
|event| events.push(event),
)
.unwrap();
assert_eq!(session_id, "gemini-headless");
assert!(events.iter().any(|event| matches!(
event,
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
)));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[test]
fn gemini_response_text_reads_json_response_or_plain_text() {
match gemini_response_text(r#"{"response":"Structured"}"#) {
GeminiOutput::Response(text) => assert_eq!(text, "Structured"),
_ => panic!("expected response"),
}
match gemini_response_text("Plain answer") {
GeminiOutput::Response(text) => assert_eq!(text, "Plain answer"),
_ => panic!("expected response"),
}
}
}

View File

@@ -0,0 +1,164 @@
use crate::ai_agents::AiAgentPermissionMode;
use crate::gemini_cli::AgentStreamRequest;
use std::path::{Path, PathBuf};
use std::process::Stdio;
pub(crate) fn build_command(
binary: &Path,
request: &AgentStreamRequest,
settings_dir: &Path,
) -> Result<std::process::Command, String> {
let settings_path = write_settings(settings_dir, &request.vault_path, request.permission_mode)?;
let mut command = crate::hidden_command(binary);
command
.args(build_args(request.permission_mode))
.arg("--prompt")
.arg(build_prompt(request))
.env("GEMINI_CLI_SYSTEM_SETTINGS_PATH", settings_path)
.env("NO_COLOR", "1")
.current_dir(&request.vault_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
Ok(command)
}
fn build_args(permission_mode: AiAgentPermissionMode) -> Vec<String> {
vec![
"--output-format".into(),
"json".into(),
"--approval-mode".into(),
approval_mode(permission_mode).into(),
]
}
fn approval_mode(permission_mode: AiAgentPermissionMode) -> &'static str {
match permission_mode {
AiAgentPermissionMode::Safe => "auto_edit",
AiAgentPermissionMode::PowerUser => "yolo",
}
}
fn build_prompt(request: &AgentStreamRequest) -> String {
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
}
fn write_settings(
settings_dir: &Path,
vault_path: &str,
permission_mode: AiAgentPermissionMode,
) -> Result<PathBuf, String> {
std::fs::create_dir_all(settings_dir)
.map_err(|error| format!("Failed to create Gemini settings directory: {error}"))?;
let settings_path = settings_dir.join("settings.json");
let settings = build_settings(vault_path, permission_mode)?;
std::fs::write(&settings_path, settings)
.map_err(|error| format!("Failed to write Gemini settings: {error}"))?;
Ok(settings_path)
}
fn build_settings(
vault_path: &str,
permission_mode: AiAgentPermissionMode,
) -> Result<String, String> {
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
let mut settings = serde_json::json!({
"mcpServers": {
"tolaria": {
"command": "node",
"args": [mcp_server_path],
"env": {
"VAULT_PATH": vault_path,
"WS_UI_PORT": "9711"
},
"description": "Tolaria active vault MCP server",
"trust": permission_mode == AiAgentPermissionMode::PowerUser
}
}
});
if permission_mode == AiAgentPermissionMode::Safe {
settings["tools"] = serde_json::json!({
"exclude": ["run_shell_command"]
});
}
serde_json::to_string(&settings)
.map_err(|error| format!("Failed to serialize Gemini settings: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
fn request() -> AgentStreamRequest {
AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: AiAgentPermissionMode::Safe,
}
}
#[test]
fn command_uses_headless_json_mode_and_temp_settings() {
let settings_dir = tempfile::tempdir().unwrap();
let command =
build_command(&PathBuf::from("gemini"), &request(), settings_dir.path()).unwrap();
let actual_args: Vec<&OsStr> = command.get_args().collect();
let settings_path = command
.get_envs()
.find(|(key, _)| *key == OsStr::new("GEMINI_CLI_SYSTEM_SETTINGS_PATH"))
.and_then(|(_, value)| value);
assert_eq!(command.get_program(), OsStr::new("gemini"));
assert_eq!(actual_args[0], OsStr::new("--output-format"));
assert_eq!(actual_args[1], OsStr::new("json"));
assert!(actual_args.contains(&OsStr::new("--prompt")));
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
assert!(settings_path.is_some());
assert!(settings_dir.path().join("settings.json").exists());
}
#[test]
fn safe_settings_include_tolaria_mcp_and_exclude_shell() {
let settings = build_settings("/tmp/vault", AiAgentPermissionMode::Safe).unwrap();
let json: serde_json::Value = serde_json::from_str(&settings).unwrap();
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
assert_eq!(
json["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
"/tmp/vault"
);
assert_eq!(json["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"], "9711");
assert_eq!(json["mcpServers"]["tolaria"]["trust"], false);
assert_eq!(json["tools"]["exclude"][0], "run_shell_command");
assert!(json["mcpServers"]["tolaria"]["args"][0]
.as_str()
.unwrap()
.ends_with("index.js"));
}
#[test]
fn power_user_settings_trust_tolaria_and_allow_shell_discovery() {
let settings = build_settings("/tmp/vault", AiAgentPermissionMode::PowerUser).unwrap();
let json: serde_json::Value = serde_json::from_str(&settings).unwrap();
assert_eq!(json["mcpServers"]["tolaria"]["trust"], true);
assert!(json.get("tools").is_none());
assert_eq!(approval_mode(AiAgentPermissionMode::PowerUser), "yolo");
}
#[test]
fn prompt_keeps_system_prompt_first() {
let prompt = build_prompt(&AgentStreamRequest {
system_prompt: Some("Be concise".into()),
..request()
});
assert!(prompt.starts_with("System instructions:\nBe concise"));
assert!(prompt.contains("User request:\nRename the note"));
}
}

View File

@@ -0,0 +1,187 @@
use crate::ai_agents::AiAgentAvailability;
use std::path::{Path, PathBuf};
pub(crate) fn check_cli() -> AiAgentAvailability {
match find_binary() {
Ok(binary) => AiAgentAvailability {
installed: true,
version: crate::cli_agent_runtime::version_for_binary(&binary),
},
Err(_) => AiAgentAvailability {
installed: false,
version: None,
},
}
}
pub(crate) fn find_binary() -> Result<PathBuf, String> {
if let Some(binary) = find_binary_on_path() {
return Ok(binary);
}
if let Some(binary) = find_binary_in_user_shell() {
return Ok(binary);
}
if let Some(binary) = find_existing_binary(gemini_binary_candidates()) {
return Ok(binary);
}
Err("Gemini CLI not found. Install it: https://google-gemini.github.io/gemini-cli/".into())
}
fn find_binary_on_path() -> Option<PathBuf> {
crate::hidden_command(path_lookup_command())
.arg("gemini")
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_lookup_command() -> &'static str {
if cfg!(windows) {
"where"
} else {
"which"
}
}
fn find_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "gemini"))
}
fn user_shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
})
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn gemini_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| gemini_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn gemini_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
home.join(".local/bin/gemini"),
home.join(".local/bin/gemini.exe"),
home.join(".gemini/bin/gemini"),
home.join(".gemini/bin/gemini.exe"),
home.join(".local/share/mise/shims/gemini"),
home.join(".local/share/mise/shims/gemini.exe"),
home.join(".asdf/shims/gemini"),
home.join(".asdf/shims/gemini.exe"),
home.join(".npm-global/bin/gemini"),
home.join(".npm-global/bin/gemini.cmd"),
home.join(".npm-global/bin/gemini.exe"),
home.join(".npm/bin/gemini"),
home.join(".npm/bin/gemini.cmd"),
home.join(".npm/bin/gemini.exe"),
home.join(".bun/bin/gemini"),
home.join(".bun/bin/gemini.exe"),
home.join("AppData/Roaming/npm/gemini.cmd"),
home.join("AppData/Roaming/npm/gemini.exe"),
home.join("AppData/Local/pnpm/gemini.cmd"),
home.join("AppData/Local/pnpm/gemini.exe"),
home.join("scoop/shims/gemini.exe"),
PathBuf::from("/usr/local/bin/gemini"),
PathBuf::from("/opt/homebrew/bin/gemini"),
];
candidates.extend(nvm_binary_candidates_for_home(home));
candidates
}
fn nvm_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
return Vec::new();
};
let mut candidates = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.map(|path| path.join("bin").join("gemini"))
.collect::<Vec<_>>();
candidates.sort();
candidates
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn binary_candidates_include_supported_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = gemini_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/gemini"),
home.join(".gemini/bin/gemini"),
home.join(".local/share/mise/shims/gemini"),
home.join(".asdf/shims/gemini"),
home.join(".npm-global/bin/gemini"),
home.join(".bun/bin/gemini"),
PathBuf::from("/opt/homebrew/bin/gemini"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing-gemini");
let gemini = dir.path().join("gemini");
std::fs::write(&gemini, "#!/bin/sh\n").unwrap();
let stdout = format!("\n{}\n{}\n", missing.display(), gemini.display());
assert_eq!(first_existing_path(&stdout), Some(gemini));
}
}

View File

@@ -1,8 +1,13 @@
pub mod ai_agents;
pub mod app_updater;
pub mod claude_cli;
mod cli_agent_runtime;
pub mod codex_cli;
mod commands;
pub mod frontmatter;
pub mod gemini_cli;
mod gemini_config;
mod gemini_discovery;
pub mod git;
pub mod mcp;
#[cfg(desktop)]
@@ -367,6 +372,7 @@ macro_rules! app_invoke_handler {
commands::list_vault,
commands::list_vault_folders,
commands::get_note_content,
commands::validate_note_content,
commands::create_note_content,
commands::save_note_content,
commands::update_frontmatter,
@@ -405,6 +411,7 @@ macro_rules! app_invoke_handler {
commands::reload_vault,
commands::reload_vault_entry,
commands::sync_vault_asset_scope_for_window,
commands::open_vault_file_external,
commands::sync_note_title,
commands::save_image,
commands::copy_image_to_vault,
@@ -435,6 +442,7 @@ macro_rules! app_invoke_handler {
commands::register_mcp_tools,
commands::remove_mcp_tools,
commands::check_mcp_status,
commands::get_mcp_config_snippet,
commands::sync_mcp_bridge_vault,
commands::repair_vault,
commands::reinit_telemetry,
@@ -460,10 +468,7 @@ fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
if let tauri::RunEvent::Exit = event {
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
let mut guard = state.0.lock().unwrap();
if let Some(ref mut child) = *guard {
let _ = child.kill();
log::info!("ws-bridge child process killed on exit");
}
stop_ws_bridge_child(&mut guard);
}
}

View File

@@ -17,30 +17,88 @@ pub enum McpStatus {
/// Find the `node` binary path at runtime.
pub(crate) fn find_node() -> Result<PathBuf, String> {
let output = node_lookup_command()
.output()
.map_err(|e| format!("Failed to locate node on PATH: {e}"))?;
if output.status.success() {
if let Some(path) = first_node_lookup_path(&output.stdout) {
verify_node_version(&path)?;
return Ok(path);
let mut last_error = None;
for path in node_binary_candidates() {
match verify_node_version(&path) {
Ok(()) => return Ok(path),
Err(error) => last_error = Some(error),
}
}
if let Some(path) = fallback_node_path() {
verify_node_version(&path)?;
return Ok(path);
}
Err("node not found in PATH or common install locations".into())
Err(last_error.unwrap_or_else(|| "node not found in PATH or common install locations".into()))
}
fn first_node_lookup_path(stdout: &[u8]) -> Option<PathBuf> {
fn node_binary_candidates() -> Vec<PathBuf> {
let mut candidates = find_node_on_path();
candidates.extend(find_node_in_user_shell());
candidates.extend(fallback_node_paths());
candidates
}
fn find_node_on_path() -> Vec<PathBuf> {
node_lookup_command()
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| node_lookup_paths(&output.stdout))
.unwrap_or_default()
}
fn find_node_in_user_shell() -> Vec<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.filter_map(|shell| command_path_from_shell(&shell, "node"))
.collect()
}
fn node_lookup_paths(stdout: &[u8]) -> Vec<PathBuf> {
String::from_utf8_lossy(stdout)
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect()
}
fn user_shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
})
}
fn verify_node_version(node: &Path) -> Result<(), String> {
@@ -91,7 +149,7 @@ fn node_lookup_command() -> Command {
command
}
fn fallback_node_path() -> Option<PathBuf> {
fn fallback_node_paths() -> Vec<PathBuf> {
let mut candidates = vec![
PathBuf::from("/opt/homebrew/bin/node"),
PathBuf::from("/usr/local/bin/node"),
@@ -120,24 +178,39 @@ fn fallback_node_path() -> Option<PathBuf> {
}
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".volta").join("bin").join(node_binary_name()));
let nvm_dir = home.join(".nvm").join("versions").join("node");
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
let mut versions = entries
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.collect::<Vec<_>>();
versions.sort();
versions.reverse();
candidates.extend(
versions
.into_iter()
.map(|version| version.join("bin").join("node")),
);
}
candidates.extend(node_binary_candidates_for_home(&home));
}
candidates.into_iter().find(|path| path.is_file())
candidates
.into_iter()
.filter(|path| path.is_file())
.collect()
}
fn node_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
home.join(".local/share/mise/shims")
.join(node_binary_name()),
home.join(".mise").join("shims").join(node_binary_name()),
home.join(".asdf").join("shims").join(node_binary_name()),
home.join(".volta").join("bin").join(node_binary_name()),
];
let nvm_dir = home.join(".nvm").join("versions").join("node");
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
let mut versions = entries
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.collect::<Vec<_>>();
versions.sort();
versions.reverse();
candidates.extend(
versions
.into_iter()
.map(|version| version.join("bin").join("node")),
);
}
candidates
}
fn node_binary_name() -> &'static str {
@@ -250,6 +323,7 @@ fn mcp_config_paths_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".claude.json"),
home.join(".claude").join("mcp.json"),
home.join(".gemini").join("settings.json"),
home.join(".cursor").join("mcp.json"),
home.join(".config").join("mcp").join("mcp.json"),
]
@@ -313,6 +387,28 @@ fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serd
})
}
fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result<String, String> {
let mut servers = serde_json::Map::new();
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
let config = serde_json::json!({ "mcpServers": servers });
serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize MCP config snippet: {e}"))
}
/// Build the exact MCP config JSON users can copy into compatible tools.
pub fn mcp_config_snippet(vault_path: &str) -> Result<String, String> {
let node = find_node().map_err(|e| {
format!("Node.js 18+ is required on PATH before Tolaria can build MCP config: {e}")
})?;
let server_dir = mcp_server_dir()?;
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
let node_command = node.to_string_lossy().into_owned();
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
build_mcp_config_snippet(&entry)
}
/// Write MCP registration to a list of config file paths.
/// Returns "registered" on first registration, "updated" if already present.
fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf]) -> String {
@@ -502,6 +598,20 @@ mod tests {
write_config_json(config_path, serde_json::json!({ "mcpServers": servers }));
}
struct ExpectedMcpServer<'a> {
index_js: &'a str,
vault_path: &'a str,
}
fn assert_registered_tolaria_server(
config: &serde_json::Value,
expected: ExpectedMcpServer<'_>,
) {
let server = &config["mcpServers"][MCP_SERVER_NAME];
assert_eq!(server["args"][0], expected.index_js);
assert_eq!(server["env"]["VAULT_PATH"], expected.vault_path);
}
fn write_index_js(dir: &Path) -> PathBuf {
let index_js = dir.join("index.js");
std::fs::write(&index_js, "console.log('ok');").unwrap();
@@ -519,14 +629,69 @@ mod tests {
}
#[test]
fn first_node_lookup_path_uses_first_non_empty_line() {
fn build_mcp_config_snippet_wraps_tolaria_server_entry() {
let entry = test_mcp_entry("/path/to/index.js", "/my/vault");
let snippet = build_mcp_config_snippet(&entry).unwrap();
let config: serde_json::Value = serde_json::from_str(&snippet).unwrap();
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/path/to/index.js"
);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/my/vault"
);
}
#[test]
fn node_lookup_paths_keep_non_empty_lines_in_order() {
let stdout = b"\nC:\\Program Files\\nodejs\\node.exe\r\nC:\\Other\\node.exe\r\n";
assert_eq!(
first_node_lookup_path(stdout).unwrap(),
PathBuf::from("C:\\Program Files\\nodejs\\node.exe")
node_lookup_paths(stdout),
vec![
PathBuf::from("C:\\Program Files\\nodejs\\node.exe"),
PathBuf::from("C:\\Other\\node.exe"),
]
);
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing-node");
let node = dir.path().join("node");
std::fs::write(&node, "#!/bin/sh\n").unwrap();
let stdout = format!("\n{}\n{}\n", missing.display(), node.display());
assert_eq!(first_existing_path(&stdout), Some(node));
}
#[cfg(unix)]
#[test]
fn command_path_from_shell_finds_node_from_login_shell() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let node = dir.path().join("node");
std::fs::write(&node, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o755)).unwrap();
let shell = dir.path().join("shell");
std::fs::write(
&shell,
format!(
"#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n",
node.display()
),
)
.unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(command_path_from_shell(&shell, "node"), Some(node));
}
#[test]
fn node_major_version_accepts_current_node_output() {
assert_eq!(node_major_version("v24.13.1\n"), Some(24));
@@ -534,6 +699,25 @@ mod tests {
assert_eq!(node_major_version("not-node"), None);
}
#[test]
fn node_binary_candidates_include_shell_managed_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = node_binary_candidates_for_home(&home);
let expected = [
home.join(".local/share/mise/shims/node"),
home.join(".asdf/shims/node"),
home.join(".volta/bin/node"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn mcp_server_dir_candidates_prefer_exe_dir_before_macos_resources() {
let dev_path = Path::new("/repo/mcp-server");
@@ -565,13 +749,12 @@ mod tests {
assert!(!was_update);
let config = read_config(&config_path);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/test/index.js"
);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/test/vault"
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/test/index.js",
vault_path: "/test/vault",
},
);
}
@@ -666,6 +849,35 @@ mod tests {
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
}
#[test]
fn upsert_preserves_gemini_settings_json_fields() {
let (_tmp, config_path) = temp_config_path("settings.json");
write_config_json(
&config_path,
serde_json::json!({
"theme": "GitHub",
"mcpServers": {
"other": { "command": "example" }
}
}),
);
let entry = test_mcp_entry("/gemini/index.js", "/gemini-vault");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
let config = read_config(&config_path);
assert!(!was_update);
assert_eq!(config["theme"], "GitHub");
assert_eq!(config["mcpServers"]["other"]["command"], "example");
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/gemini/index.js",
vault_path: "/gemini-vault",
},
);
}
#[test]
fn upsert_creates_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
@@ -736,6 +948,7 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let claude_user_cfg = tmp.path().join(".claude.json");
let claude_cfg = tmp.path().join("claude").join("mcp.json");
let gemini_cfg = tmp.path().join(".gemini").join("settings.json");
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json");
let entry = test_mcp_entry("/test/index.js", "/vault");
@@ -745,6 +958,7 @@ mod tests {
&[
claude_user_cfg.clone(),
claude_cfg.clone(),
gemini_cfg.clone(),
cursor_cfg.clone(),
generic_cfg.clone(),
],
@@ -752,14 +966,18 @@ mod tests {
assert!(claude_user_cfg.exists());
assert!(claude_cfg.exists());
assert!(gemini_cfg.exists());
assert!(cursor_cfg.exists());
assert!(generic_cfg.exists());
let raw = std::fs::read_to_string(&claude_user_cfg).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/test/index.js"
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/test/index.js",
vault_path: "/vault",
},
);
}
@@ -773,6 +991,7 @@ mod tests {
vec![
home.join(".claude.json"),
home.join(".claude").join("mcp.json"),
home.join(".gemini").join("settings.json"),
home.join(".cursor").join("mcp.json"),
home.join(".config").join("mcp").join("mcp.json"),
]

View File

@@ -1,58 +1,120 @@
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
use std::io::BufRead;
#[derive(Debug, Clone)]
pub struct AgentStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: String,
}
pub use crate::cli_agent_runtime::AgentStreamRequest;
use std::path::Path;
pub fn check_cli() -> AiAgentAvailability {
crate::opencode_discovery::check_cli()
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = crate::opencode_discovery::find_binary()?;
let mut command = crate::opencode_config::build_command(&binary, &request)?;
let mut child = command
.spawn()
.map_err(|error| format!("Failed to spawn opencode: {error}"))?;
let stdout = child.stdout.take().ok_or("No stdout handle")?;
let reader = std::io::BufReader::new(stdout);
let mut session_id = String::new();
for line in reader.lines() {
let json = match crate::opencode_events::parse_line(line, &mut emit) {
Some(json) => json,
None => continue,
};
if let Some(id) = crate::opencode_events::session_id(&json) {
session_id = id.to_string();
}
crate::opencode_events::dispatch_event(&json, &mut emit);
}
let stderr_output = child
.stderr
.take()
.and_then(|stderr| std::io::read_to_string(stderr).ok())
.unwrap_or_default();
let status = child
.wait()
.map_err(|error| format!("Wait failed: {error}"))?;
if !status.success() {
emit(AiAgentStreamEvent::Error {
message: crate::opencode_events::format_error(stderr_output, status.to_string()),
});
}
emit(AiAgentStreamEvent::Done);
Ok(session_id)
run_agent_stream_with_binary(&binary, request, emit)
}
fn run_agent_stream_with_binary<F>(
binary: &Path,
request: AgentStreamRequest,
emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let command = crate::opencode_config::build_command(binary, &request)?;
crate::cli_agent_runtime::run_ai_agent_json_stream(
command,
"opencode",
emit,
crate::opencode_events::session_id,
crate::opencode_events::dispatch_event,
crate::opencode_events::format_error,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai_agents::AiAgentPermissionMode;
#[cfg(unix)]
fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let script = dir.join("opencode");
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
script
}
fn request(vault_path: String) -> AgentStreamRequest {
AgentStreamRequest {
message: "Summarize".into(),
system_prompt: None,
vault_path,
permission_mode: AiAgentPermissionMode::Safe,
}
}
#[cfg(unix)]
#[test]
fn run_agent_stream_maps_opencode_json_events() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}'
printf '%s\n' '{"type":"message","text":"Done"}'
"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_binary(
&binary,
request(vault.path().to_string_lossy().into_owned()),
|event| events.push(event),
)
.unwrap();
assert_eq!(session_id, "open_1");
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id == "open_1"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::TextDelta { text } if text == "Done"
));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[cfg(unix)]
#[test]
fn run_agent_stream_reports_opencode_nonzero_exit_errors() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}'
printf '%s\n' 'provider login required' >&2
exit 3
"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_binary(
&binary,
request(vault.path().to_string_lossy().into_owned()),
|event| events.push(event),
)
.unwrap();
assert_eq!(session_id, "open_1");
assert!(events.iter().any(|event| matches!(
event,
AiAgentStreamEvent::Error { message } if message.contains("provider configured")
)));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
}

View File

@@ -1,3 +1,4 @@
use crate::ai_agents::AiAgentPermissionMode;
use crate::opencode_cli::AgentStreamRequest;
use std::path::Path;
use std::process::Stdio;
@@ -12,7 +13,7 @@ pub(crate) fn build_command(
.arg(build_prompt(request))
.env(
"OPENCODE_CONFIG_CONTENT",
build_config(&request.vault_path)?,
build_config(&request.vault_path, request.permission_mode)?,
)
.current_dir(&request.vault_path)
.stdin(Stdio::null())
@@ -26,30 +27,18 @@ fn build_args() -> Vec<String> {
}
fn build_prompt(request: &AgentStreamRequest) -> String {
match request
.system_prompt
.as_ref()
.map(|prompt| prompt.trim())
.filter(|prompt| !prompt.is_empty())
{
Some(system_prompt) => format!(
"System instructions:\n{system_prompt}\n\nUser request:\n{}",
request.message
),
None => request.message.clone(),
}
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
}
fn build_config(vault_path: &str) -> Result<String, String> {
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
let mcp_server_path = mcp_server
.to_str()
.ok_or("Invalid MCP server path")?
.to_string();
fn build_config(
vault_path: &str,
permission_mode: AiAgentPermissionMode,
) -> Result<String, String> {
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
serde_json::to_string(&serde_json::json!({
"$schema": "https://opencode.ai/config.json",
"permission": permission_config(),
"permission": permission_config(permission_mode),
"mcp": {
"tolaria": {
"type": "local",
@@ -62,7 +51,12 @@ fn build_config(vault_path: &str) -> Result<String, String> {
.map_err(|error| format!("Failed to serialize opencode config: {error}"))
}
fn permission_config() -> serde_json::Value {
fn permission_config(permission_mode: AiAgentPermissionMode) -> serde_json::Value {
let bash_permission = match permission_mode {
AiAgentPermissionMode::Safe => "deny",
AiAgentPermissionMode::PowerUser => "allow",
};
serde_json::json!({
"read": "allow",
"edit": "allow",
@@ -70,7 +64,7 @@ fn permission_config() -> serde_json::Value {
"grep": "allow",
"list": "allow",
"external_directory": "deny",
"bash": "deny"
"bash": bash_permission
})
}
@@ -85,6 +79,7 @@ mod tests {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe,
}
}
@@ -118,7 +113,9 @@ mod tests {
#[test]
fn config_includes_permissions_and_tolaria_mcp_server() {
if let Ok(config) = build_config("/tmp/vault") {
if let Ok(config) =
build_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe)
{
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
assert_eq!(json["permission"]["edit"], "allow");
assert_eq!(json["permission"]["external_directory"], "deny");
@@ -136,6 +133,18 @@ mod tests {
}
}
#[test]
fn power_user_config_allows_bash_but_keeps_external_directories_denied() {
if let Ok(config) = build_config(
"/tmp/vault",
crate::ai_agents::AiAgentPermissionMode::PowerUser,
) {
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
assert_eq!(json["permission"]["bash"], "allow");
assert_eq!(json["permission"]["external_directory"], "deny");
}
}
#[test]
fn prompt_keeps_system_prompt_first() {
let prompt = build_prompt(&AgentStreamRequest {

View File

@@ -5,7 +5,7 @@ pub(crate) fn check_cli() -> AiAgentAvailability {
match find_binary() {
Ok(binary) => AiAgentAvailability {
installed: true,
version: version_for_binary(&binary),
version: crate::cli_agent_runtime::version_for_binary(&binary),
},
Err(_) => AiAgentAvailability {
installed: false,
@@ -30,23 +30,22 @@ pub(crate) fn find_binary() -> Result<PathBuf, String> {
Err("OpenCode CLI not found. Install it: https://opencode.ai/docs/".into())
}
fn version_for_binary(binary: &PathBuf) -> Option<String> {
crate::hidden_command(binary)
.arg("--version")
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn find_binary_on_path() -> Option<PathBuf> {
crate::hidden_command("which")
crate::hidden_command(path_lookup_command())
.arg("opencode")
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_lookup_command() -> &'static str {
if cfg!(windows) {
"where"
} else {
"which"
}
}
fn find_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
@@ -107,12 +106,26 @@ fn opencode_binary_candidates() -> Vec<PathBuf> {
fn opencode_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".local/bin/opencode"),
home.join(".local/bin/opencode.exe"),
home.join(".opencode/bin/opencode"),
home.join(".opencode/bin/opencode.exe"),
home.join(".local/share/mise/shims/opencode"),
home.join(".local/share/mise/shims/opencode.exe"),
home.join(".asdf/shims/opencode"),
home.join(".asdf/shims/opencode.exe"),
home.join(".npm-global/bin/opencode"),
home.join(".npm-global/bin/opencode.cmd"),
home.join(".npm-global/bin/opencode.exe"),
home.join(".npm/bin/opencode"),
home.join(".npm/bin/opencode.cmd"),
home.join(".npm/bin/opencode.exe"),
home.join(".bun/bin/opencode"),
home.join(".bun/bin/opencode.exe"),
home.join("AppData/Roaming/npm/opencode.cmd"),
home.join("AppData/Roaming/npm/opencode.exe"),
home.join("AppData/Local/pnpm/opencode.cmd"),
home.join("AppData/Local/pnpm/opencode.exe"),
home.join("scoop/shims/opencode.exe"),
PathBuf::from("/usr/local/bin/opencode"),
PathBuf::from("/opt/homebrew/bin/opencode"),
]
@@ -145,6 +158,38 @@ mod tests {
}
}
#[test]
fn binary_candidates_include_windows_npm_and_toolchain_shims() {
let home = PathBuf::from(r"C:\Users\alex");
let candidates = opencode_binary_candidates_for_home(&home);
let expected = [
home.join(".npm-global/bin/opencode.cmd"),
home.join(".npm-global/bin/opencode.exe"),
home.join(".npm/bin/opencode.cmd"),
home.join(".npm/bin/opencode.exe"),
home.join("AppData/Roaming/npm/opencode.cmd"),
home.join("AppData/Roaming/npm/opencode.exe"),
home.join("AppData/Local/pnpm/opencode.cmd"),
home.join("AppData/Local/pnpm/opencode.exe"),
home.join("scoop/shims/opencode.exe"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn path_lookup_command_matches_current_platform() {
let expected = if cfg!(windows) { "where" } else { "which" };
assert_eq!(path_lookup_command(), expected);
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();

View File

@@ -1,5 +1,6 @@
use crate::ai_agents::AiAgentStreamEvent;
#[cfg(test)]
pub(crate) fn parse_line<F>(
line: Result<String, std::io::Error>,
emit: &mut F,
@@ -7,22 +8,7 @@ pub(crate) fn parse_line<F>(
where
F: FnMut(AiAgentStreamEvent),
{
let line = match line {
Ok(line) => line,
Err(error) => {
emit(AiAgentStreamEvent::Error {
message: format!("Read error: {error}"),
});
return None;
}
};
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
serde_json::from_str::<serde_json::Value>(trimmed).ok()
crate::cli_agent_runtime::parse_ai_agent_json_line(line, emit)
}
pub(crate) fn dispatch_event<F>(json: &serde_json::Value, emit: &mut F)
@@ -101,12 +87,8 @@ where
F: FnMut(AiAgentStreamEvent),
{
let tool_id = tool_id(json).unwrap_or("tool").to_string();
let tool_name = json["name"]
.as_str()
.or_else(|| json["tool"].as_str())
.unwrap_or("tool")
.to_string();
let input = json.get("input").map(|input| input.to_string());
let tool_name = tool_name(json).unwrap_or("tool").to_string();
let input = tool_input(json);
emit(AiAgentStreamEvent::ToolStart {
tool_name,
@@ -120,10 +102,7 @@ where
F: FnMut(AiAgentStreamEvent),
{
let tool_id = tool_id(json).unwrap_or("tool").to_string();
let output = json["output"]
.as_str()
.map(|output| output.to_string())
.or_else(|| json.get("result").map(|result| result.to_string()));
let output = tool_output(json);
emit(AiAgentStreamEvent::ToolDone { tool_id, output });
}
@@ -140,24 +119,50 @@ where
}
fn tool_id(json: &serde_json::Value) -> Option<&str> {
json["id"]
.as_str()
.or_else(|| json["toolID"].as_str())
.or_else(|| json["tool_id"].as_str())
first_string_field(
json,
&["id", "toolID", "tool_id", "toolCallID", "toolCallId"],
)
}
fn text_value(json: &serde_json::Value) -> Option<&str> {
json["text"]
.as_str()
.or_else(|| json["content"].as_str())
.or_else(|| json["message"].as_str())
first_string_field(json, &["text", "content", "message"])
}
fn message_value(json: &serde_json::Value) -> Option<&str> {
json["message"]
first_string_field(json, &["message", "error", "text"])
}
fn tool_name(json: &serde_json::Value) -> Option<&str> {
first_string_field(json, &["name", "tool", "toolName"])
}
fn tool_input(json: &serde_json::Value) -> Option<String> {
first_json_field(json, &["input", "args"]).map(|input| input.to_string())
}
fn tool_output(json: &serde_json::Value) -> Option<String> {
first_json_field(json, &["output", "result"]).map(display_json_value)
}
fn first_string_field<'a>(json: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> {
keys.iter()
.find_map(|key| json[*key].as_str().or_else(|| json["part"][*key].as_str()))
}
fn first_json_field<'a>(
json: &'a serde_json::Value,
keys: &[&str],
) -> Option<&'a serde_json::Value> {
keys.iter()
.find_map(|key| json.get(*key).or_else(|| json["part"].get(*key)))
}
fn display_json_value(value: &serde_json::Value) -> String {
value
.as_str()
.or_else(|| json["error"].as_str())
.or_else(|| json["text"].as_str())
.map(|output| output.to_string())
.unwrap_or_else(|| value.to_string())
}
fn is_auth_error(lower: &str) -> bool {

Some files were not shown because too many files have changed in this diff Show More