From 5076e0ddba31b829679d9160da54f44c10771a0c Mon Sep 17 00:00:00 2001 From: Maik Roland Damm Date: Tue, 26 May 2026 22:20:44 +0200 Subject: [PATCH 1/8] feat: enhance terminal and browser functionality - Updated terminal selection background color for improved visibility. - Added right-click context menu functionality in terminal for copy/paste actions. - Increased terminal scrollback buffer to 5000 lines. - Refactored browser navigation commands to run on the main thread for better performance. - Improved error handling for empty URLs in browser tabs. - Enhanced PTY manager to handle null-terminated strings in working directory paths. - Added support for file paths in tool activity metrics for better tracking. - Introduced a new sidebar graph height constant for layout adjustments. - Improved sidebar resizer logic to account for dynamic height adjustments. - Added a settings button in the right panel for easier access to application settings. - Updated CSS for better styling of tool paths and scrollbar visibility. --- ...244t_eb\342\206\222tauri_4c9226e7.plan.md" | 286 ++++++++++++++++++ .blxcode/tasks/index.json | 16 +- .cargo/config.toml | 6 + public/terminal_bootstrap.mjs | 32 +- src-tauri/src/browser_host.rs | 18 +- src-tauri/src/commands.rs | 52 +++- src-tauri/src/pty_host.rs | 22 +- src/config/app.config.rs | 3 + src/workbench/agent_panel/mod.rs | 21 +- src/workbench/agent_panel/timeline.rs | 233 ++++++++++++-- .../turn_metrics_bar/turn_metrics_bar.css | 40 ++- src/workbench/agent_timeline.rs | 38 +++ src/workbench/browser_tab.rs | 10 +- .../file_diff_section/file-diff-section.css | 4 - src/workbench/mod.rs | 5 +- src/workbench/right_panel.rs | 34 ++- src/workbench/sidebar.rs | 42 ++- src/workbench/sidebar_resizer/mod.rs | 22 +- src/workbench/state.rs | 6 +- styles.css | 107 ++++++- 20 files changed, 883 insertions(+), 114 deletions(-) create mode 100644 ".agents/.cursor/plans/memory_parit\303\244t_eb\342\206\222tauri_4c9226e7.plan.md" diff --git "a/.agents/.cursor/plans/memory_parit\303\244t_eb\342\206\222tauri_4c9226e7.plan.md" "b/.agents/.cursor/plans/memory_parit\303\244t_eb\342\206\222tauri_4c9226e7.plan.md" new file mode 100644 index 0000000..f580d05 --- /dev/null +++ "b/.agents/.cursor/plans/memory_parit\303\244t_eb\342\206\222tauri_4c9226e7.plan.md" @@ -0,0 +1,286 @@ +--- +name: Memory Parität EB→Tauri +overview: "BLXCode-eb definiert das Zielbild: Dual-Scope (Workspace + Global unter `~/.blxcode/`), Unterordner als Kategorien, Category-Hubs im Graph, Frontmatter und scope-präfixierte Wikilinks. BLXCode hat bereits workspace-scoped Kategorien und einen ähnlichen Memory-Tab, fehlt aber fast die gesamte Global-/Scope-Schicht — die soll 1:1 nach dem EB-Referenzcode portiert werden." +todos: + - id: backend-scope-types + content: memory.rs in Module splitten; MemoryScope, Global-Roots, erweiterte NoteMeta/MemoryListResponse, memory_status/bootstrap + status: pending + - id: backend-crud-graph + content: Alle memory_* Commands mit scope; frontmatter + wikilinks + graph Hubs/crossScope portieren + status: pending + - id: bridge-ipc + content: tauri_bridge + invoke-Args für scope; note_key/parse_note_key; memory_list Response anpassen + status: pending + - id: ui-files-sidebar + content: "workbench/memory/ EB-Struktur; Dual-Scope-Sidebar, useMemory-äquivalent, scope:path, Bootstrap Global" + status: pending + - id: remove-legacy-migration + content: migrate_legacy_memory aus agents_layout entfernen; Tests/Doku anpassen + status: pending + - id: ui-graph-search + content: "memory_graph: Hub-Nodes, scope-aware Klicks; Search-Filter workspace:cat / global:cat" + status: pending + - id: agent-export + content: Agent-Tools/Prompt/Context mit scope; Export/Import/Pointer-UI; i18n + Tests + Doku + status: pending +isProject: false +--- + +# Memory-System: Parität BLXCode-eb → BLXCode (Tauri) + +## Referenz (Source of Truth) + +BLXCode-eb implementiert das vollständige Modell. Diese Dateien sind die Spezifikation zum Portieren: + +| Bereich | EB-Referenz | +|---------|-------------| +| Typen/Konstanten | [`src/shared/memory.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/shared/memory.ts) | +| Pfade & Scopes | [`src/bun/memory/paths.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/bun/memory/paths.ts) | +| CRUD/List/Graph | [`src/bun/memory/store.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/bun/memory/store.ts) | +| Graph-Builder | [`src/bun/memory/graph.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/bun/memory/graph.ts) | +| Wikilinks | [`src/bun/memory/wikilinks.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/bun/memory/wikilinks.ts) | +| Frontmatter | [`src/bun/memory/frontmatter.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/bun/memory/frontmatter.ts) | +| UI-Sidebar/Kategorien | [`src/views/.../Memory/files/MemorySidebar.tsx`](c:/Users/quork/Entwicklung/BLXCode-eb/src/views/app/layout/SidePanel/Memory/files/MemorySidebar.tsx), [`memoryScopeCategories.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/views/app/layout/SidePanel/Memory/memoryScopeCategories.ts) | +| Graph-UI | [`MemoryGraphView.tsx`](c:/Users/quork/Entwicklung/BLXCode-eb/src/views/app/layout/SidePanel/Memory/graph/MemoryGraphView.tsx) | + +## Ist-Zustand BLXCode (Gap-Analyse) + +```mermaid +flowchart LR + subgraph eb [BLXCode-eb] + WS["workspace\n.agents/memory"] + GL["global\n~/.blxcode/memory"] + EBList["memoryList\nnotes + subcategories x2"] + EBGraph["Graph + CategoryHubs\ncrossScope edges"] + end + subgraph blx [BLXCode heute] + WSonly["nur workspace"] + CatOK["Unterordner = Kategorien\nmemory_list_categories"] + GraphSimple["Graph ohne Hubs\nkein scope auf Nodes"] + end +``` + +**Bereits vorhanden (behalten/erweitern):** +- Workspace-Roots: `.agents/memory/`, `.agents/learnings/` ([`memory.rs`](c:/Users/quork/Entwicklung/BLXCode/src-tauri/src/memory.rs)) +- Dynamische Kategorien via Unterordner + `.gitkeep` (`memory_list_categories`, `memory_create_category`) +- Memory-Tab mit Files / Graph / Search ([`memory_panel.rs`](c:/Users/quork/Entwicklung/BLXCode/src/workbench/memory_panel.rs)) +- `graph_category_for` identisch zu EB (erster Pfadsegment = Kategorie) + +**Fehlt für 1:1-Parität:** + +| Feature | EB | BLXCode | +|---------|----|---------| +| `MemoryScope` (`workspace` \| `global`) | überall | fehlt | +| Global-Roots `~/.blxcode/{memory,learnings}` | ja | nein | +| `memoryList` → `{ notes, memorySubcategories }` pro Scope | ja | getrennte Calls, nur workspace | +| `NoteMeta`: `scope`, `title`, `enabled`, `tags`, `category`, `isLearning`, `isOverview` | ja | nur `path`, `name`, `size`, `modified`, `isTemplate` | +| Frontmatter (`title`, `enabled`, `tags`) | ja | nein | +| Wikilinks `[[global:…]]` / `[[workspace:…]]` | ja | nur basename/pfad | +| Graph: Category-Hub-Knoten (`hub:cat`), `crossScope`, `hubScopes` | ja | nein | +| Sidebar: zwei Sektionen (Projekt / Global) | ja | eine flache Kategorie-Liste | +| Note-Keys `scope:path` | ja | nur `path` | +| `memory_bootstrap` / `memory_status` (global) | ja | nur `workspace_ensure_agents` | +| Export/Import/Pointers mit Scope-Auswahl | UI + RPC | Backend teils da, UI fehlt | + +--- + +## Entscheidungen (abgestimmt) + +| Thema | Entscheidung | +|-------|----------------| +| Workspace-Legacy `/.blxcode/memory/` | **Entfernen** — keine automatische Migration mehr nach `.agents/memory/` | +| Global Memory | **1:1 EB:** `~/.blxcode/memory` + `~/.blxcode/learnings` (User-Home, nicht im Projektordner) | +| `memory_list` API | **Breaking Change** — nur noch `{ notes, memorySubcategories }`, alle Aufrufer anpassen | +| UI-Struktur | **Volle EB-Parität** — `src/workbench/memory/` mit Untermodulen (panel, files/, graph/, search/, dialogs) statt monolithischem `memory_panel.rs` | +| Kategorie-Einstellungen | Alte Keys `decisions` → **`workspace:decisions`** beim Laden; Global-Keys `global:…` | + +--- + +## Ziel-Architektur + +```mermaid +flowchart TB + subgraph storage [Dateisystem] + WSM["workspace/.agents/memory"] + WSL["workspace/.agents/learnings"] + GLM["~/.blxcode/memory"] + GLL["~/.blxcode/learnings"] + end + subgraph tauri [src-tauri/src/memory/] + paths[paths.rs] + store[store.rs] + graph[graph.rs] + wikilinks[wikilinks.rs] + frontmatter[frontmatter.rs] + end + subgraph ui [src/workbench/memory/] + panel[memory_panel / useMemory-äquivalent] + sidebar[Scope-Sektionen + Kategorien] + graphUi[memory_graph mit Hubs] + end + WSM --> paths + WSL --> paths + GLM --> paths + GLL --> paths + paths --> store + store --> graph + wikilinks --> graph + store --> panel + graph --> graphUi +``` + +**Pfad-Regeln (wie EB):** +- API-Pfad `learnings/foo.md` → Kategorie `learnings`, Scope je nach Root +- `decisions/note.md` unter memory → Kategorie `decisions` +- Root-`.md` → Kategorie `memory` +- Unterordner von `.agents/memory` (ohne `_templates`, ohne `.`) → `memorySubcategories` +- Node-IDs: `{scope}:{apiPath}`; Hub-IDs: `hub:{category}` + +--- + +## Implementierung (Phasen) + +### Phase 1 — Backend: Scope & Typen + +**Refactor** [`src-tauri/src/memory.rs`](c:/Users/quork/Entwicklung/BLXCode/src-tauri/src/memory.rs) in Module (analog EB): + +- `memory/paths.rs` — `get_global_roots()`, `get_workspace_roots()`, `list_memory_subcategories()`, `graph_category_for()`, `node_id`/`parse_node_id`, `validate_category_name` +- `memory/types.rs` — Serde-Structs spiegeln [`shared/memory.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/shared/memory.ts): `MemoryScope`, `NoteMeta`, `MemoryListResponse`, `GraphNode` (+ `isCategoryHub`, `hubScopes`), `GraphEdge` (+ `crossScope`), `SearchHit` (+ `scope`, `category`), `MemoryStatusResponse` + +**Neue/erweiterte Commands** in [`lib.rs`](c:/Users/quork/Entwicklung/BLXCode/src-tauri/src/lib.rs): + +- `memory_status(workspace_cwd?)` → workspace + global Ordner-Status +- `memory_bootstrap(target, workspace_cwd?)` — `workspace` | `global` | `all` (Seed README in memory/learnings wie EB `store.ts`) +- `memory_list` → `{ notes, memorySubcategories: { workspace, global } }` (ersetzt flaches `Vec`) +- Alle bestehenden Commands um Parameter `scope: MemoryScope` erweitern (`memory_read`, `write`, `create`, `delete`, `rename`, `create_category`, `graph`, `search`, `backlinks`, `export`, `import`) + +**`collect_notes`:** Notizen aus beiden Roots sammeln, `scope` setzen, Frontmatter parsen, `category`/`isLearning`/`isOverview`/`enabled`/`title`/`tags` befüllen (Logik aus EB `collectNotes` in `store.ts`). + +**Kompatibilität:** Bestehende Workspace-Dateien unter `.agents/` bleiben unverändert; Global wird bei erstem Bootstrap unter `~/.blxcode/` angelegt. + +**Legacy entfernen:** `migrate_legacy_memory` und `LEGACY_MEMORY_REL` aus [`agents_layout.rs`](c:/Users/quork/Entwicklung/BLXCode/src-tauri/src/agents_layout.rs) streichen; Test `memory_list_includes_learnings_and_legacy_migrates` ersetzen. Nutzer mit altem `/.blxcode/memory/` müssen Inhalt **manuell** nach `.agents/memory/` verschieben (einmalig, in Doku erwähnen). + +### Phase 2 — Graph, Wikilinks, Suche + +Port aus EB: + +- [`graph.rs`](c:/Users/quork/Entwicklung/BLXCode-eb/src/bun/memory/graph.ts): Hub-Knoten, Note→Hub-Kanten, Wikilink-Auflösung, `orphan`, `crossScope` +- [`wikilinks.rs`](c:/Users/quork/Entwicklung/BLXCode-eb/src/bun/memory/wikilinks.ts): `global:`/`workspace:`-Prefix, Auflösung über `scopePaths`-Map beider Scopes +- `memory_graph(workspace_cwd)` nutzt dieselbe Hub-Map-Logik wie `memoryGraph` in `store.ts` (Zeilen 434–496) + +`memory_backlinks` → `Vec<{ scope, path }>` statt nur Pfad-Strings. + +`memory_search` → Treffer mit `scope` + `category` für Filter `workspace:decisions` etc. + +### Phase 3 — Tauri-Bridge & Frontend-Typen + +[`src/tauri_bridge.rs`](c:/Users/quork/Entwicklung/BLXCode/src/tauri_bridge.rs): + +- Alle `memory_*`-Wrapper um `scope` ergänzen +- `memory_list` → neues Response-Struct +- Hilfsfunktionen wie EB [`memoryRpc.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/views/app/lib/memoryRpc.ts): `note_key(scope, path)`, `parse_note_key` + +Optional kleines Rust→WASM-geteiltes Modul oder Duplikat der Konstanten in [`src/config/app.config.rs`](c:/Users/quork/Entwicklung/BLXCode/src/config/app.config.rs): `CATEGORY_HUB_PREFIX`, `MEMORY_GRAPH_MODE_KEY` (bereits vorhanden prüfen). + +### Phase 4 — Memory-UI (Files-Tab) + +**Neue Modulstruktur** (analog EB `SidePanel/Memory/`), Leptos statt React: + +``` +src/workbench/memory/ + mod.rs — MemoryPanel, TabBar (files|graph|search) + use_memory.rs — State, load/save, bootstrap (≈ useMemory.ts) + scope_categories.rs — build_scope_categories (≈ memoryScopeCategories.ts) + files/ + sidebar.rs — Dual-Scope-Sektionen + Kategorie-Gruppen + editor.rs + new_category_dialog.rs + graph/ — ggf. memory_graph/ hierher ziehen oder re-export + search/ + dialogs/ — export, import, pointers, no-folder +``` + +[`memory_panel.rs`](c:/Users/quork/Entwicklung/BLXCode/src/workbench/memory_panel.rs) wird auf dünnen Re-Export reduziert oder entfernt. + +1. **State:** `memorySubcategories: Record>`, `activeKey: Option` (`scope:path`), Bootstrap beim Panel-Mount (`memory_bootstrap("all")`) +2. **`build_scope_categories`** — Port von [`memoryScopeCategories.ts`](c:/Users/quork/Entwicklung/BLXCode-eb/src/views/app/layout/SidePanel/Memory/memoryScopeCategories.ts): learnings immer, + Unterordner + Kategorien aus Notizen +3. **Sidebar:** Zwei collapsible Sektionen „Projekt“ / „Global“ (i18n-Keys neu in [`src/i18n/`](c:/Users/quork/Entwicklung/BLXCode/src/i18n/)), pro Scope: + - Root-Notizen (`category == "memory"`) + - Collapsible Gruppen pro Kategorie (`groups_open` Key: `scope:category`) + - „Neue Kategorie“ pro Scope + - Global-Sektion: „Global Memory anlegen“ wenn noch nicht gebootstrappt (wie `MemoryNoFolderState` / `showCreateGlobal` in EB) +4. **Editor:** Frontmatter-aware Titel, `enabled`-Toggle, Wikilink-Klick mit Scope-Auflösung (`memory_resolve_link` neu, analog EB) +5. **`memory_note_groups`:** um `scope` erweitern oder durch EB-äquivalente Gruppierung ersetzen + +### Phase 5 — Graph-Tab + +[`src/workbench/memory_graph/mod.rs`](c:/Users/quork/Entwicklung/BLXCode/src/workbench/memory_graph/mod.rs): + +- Node-IDs als `scope:path`; Hub-Klicks → `overviewPathForCategory` + `preferredScopeForHub` (Port aus `shared/memory.ts`) +- `GraphNode.category` + Hub-Styling (Farben aus bestehenden `memory_category_settings`, Key ggf. `global:decisions`) +- `graph_selected_node` / Preview / Handoff: Scope mitgeben +- 2D/3D: Hub-Knoten visuell unterscheiden (EB trennt Hub vs. Note) + +### Phase 6 — Search-Tab + +- Scope-/Kategorie-Filter-Chips (`all` | `workspace:cat` | `global:cat`) wie [`MemorySearchView.tsx`](c:/Users/quork/Entwicklung/BLXCode-eb/src/views/app/layout/SidePanel/Memory/search/MemorySearchView.tsx) +- Treffer-Öffnen mit korrektem Scope + +### Phase 7 — Agent-Integration + +- **Tools** [`src-tauri/src/agent/tools.rs`](c:/Users/quork/Entwicklung/BLXCode/src-tauri/src/agent/tools.rs): `scope`-Parameter bei allen Memory-Tools; Pfade als `global:learnings/…` oder absoluter Workspace-Pfad dokumentieren +- **Client tools** [`client_tools.rs`](c:/Users/quork/Entwicklung/BLXCode/src-tauri/src/agent/client_tools.rs): Kontext-Items mit Scope-Prefix in `paths`/`source` +- **System prompt** + [`harness_skills/memory.md`](c:/Users/quork/Entwicklung/BLXCode/src-tauri/src/agent/harness_skills/memory.md): Global vs. Workspace, Kategorien, Hub-Graph +- **Deep links** [`state.rs`](c:/Users/quork/Entwicklung/BLXCode/src/workbench/state.rs): `pending_memory_note` → `(scope, path)` + +### Phase 8 — Export/Import/Pointer-UI + +Backend existiert teils; ergänzen: + +- Bridge-Wrapper für `memory_export` / `memory_import` (falls noch `dead_code`) +- Dialoge analog EB [`MemoryExportDialog.tsx`](c:/Users/quork/Entwicklung/BLXCode-eb/src/views/app/layout/SidePanel/Memory/MemoryExportDialog.tsx) / Import + Pointer-Installer im Memory-Panel-Menü + +### Phase 9 — Tests & Doku + +- Rust-Tests in `memory/`: Scope-Trennung, `list_memory_subcategories`, Hub-Graph, cross-scope Wikilink, bootstrap global +- [`docs/user/memory-and-tasks.md`](c:/Users/quork/Entwicklung/BLXCode/docs/user/memory-and-tasks.md) + [`docs/developer/tauri-ipc.md`](c:/Users/quork/Entwicklung/BLXCode/docs/developer/tauri-ipc.md) aktualisieren + +--- + +## Wichtige Portierungs-Details (nicht vergessen) + +1. **Learnings** bleiben separater Root (nicht Unterordner von memory), API-Prefix `learnings/` — in beiden Repos gleich. +2. **Reservierte Kategorien:** `_templates`, `memory`, `learnings` — keine neuen Ordner mit diesen Namen. +3. **Overview-Notizen:** `README.md` in Kategorie-Ordner → `isOverview`, Hub-Label im Graph. +4. **Learning-Dateinamen:** `learning-.md` (EB validiert in `validateLearningName`). +5. **Kategorie-Einstellungen:** Beim Deserialisieren/Lesen alte Keys ohne `:` → `workspace:`; neue Keys immer `workspace:…` oder `global:…`. + +--- + +## Zwei verschiedene `.blxcode`-Pfade (häufige Verwechslung) + +| Pfad | Bedeutung | Nach Umsetzung | +|------|-----------|----------------| +| `/.blxcode/memory/` | **Altes** projekt-lokales Memory (frühere BLXCode-Generation) | **Nicht mehr** automatisch gelesen/migriert — manuell nach `.agents/memory/` verschieben | +| `~/.blxcode/memory/` + `~/.blxcode/learnings/` | **Global Memory** (EB-Modell, app-weit) | Neuer Scope `global` in UI/Graph/Agent | + +Global Memory liegt absichtlich **nicht** im Workspace, sondern im Home-Verzeichnis — wie in EB. + +--- + +## Nicht im Scope (bewusst) + +- Semantische Suche / Embeddings +- Rules/Skills/Plans in Memory-Graph (EB schließt das ebenfalls aus) +- Automatische Migration von `/.blxcode/memory/` (bewusst entfernt, siehe Entscheidungen) + +--- + +## Empfohlene Reihenfolge & Risiko + +1. Backend + Legacy-Entfernung + `memory_list` Breaking Change +2. Bridge +3. Neues `workbench/memory/`-Modul (Files → Graph → Search) +4. Agent, Export/Import, Doku + +Größter Aufwand: UI-Neustrukturierung + Scope durchgängig; dafür kein paralleles Pflegen von Alt-`memory_panel.rs`. diff --git a/.blxcode/tasks/index.json b/.blxcode/tasks/index.json index 5c83a3e..f73b1d6 100644 --- a/.blxcode/tasks/index.json +++ b/.blxcode/tasks/index.json @@ -1,17 +1,5 @@ { "version": 1, - "tasks": [ - { - "id": "task-1779295993-1", - "title": "Demo task", - "description": "A placeholder demo task created by BLXCode Agent. You can edit the title, description, or status anytime.", - "status": "pending", - "position": 0, - "createdAt": 1779295993, - "updatedAt": 1779295993, - "completedAt": null, - "parentId": null, - "notes": null - } - ] + "tasks": [], + "activePlanPath": null } \ No newline at end of file diff --git a/.cargo/config.toml b/.cargo/config.toml index 6576a4e..5d5a432 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -4,3 +4,9 @@ linker = "aarch64-linux-gnu-gcc" [target.x86_64-unknown-linux-gnu] linker = "x86_64-linux-gnu-gcc" + +# Faster linking on Windows — rust-lld is bundled with rustup and significantly +# faster than the default MSVC link.exe for the native Tauri backend. +[target.x86_64-pc-windows-msvc] +linker = "rust-lld.exe" +rustflags = ["-C", "linker=rust-lld.exe"] diff --git a/public/terminal_bootstrap.mjs b/public/terminal_bootstrap.mjs index b59a207..9e9c322 100644 --- a/public/terminal_bootstrap.mjs +++ b/public/terminal_bootstrap.mjs @@ -20,7 +20,7 @@ function xtermThemeFromDom() { foreground: readCssVar("--term-fg", "#f1f2f5"), cursor: readCssVar("--term-cursor", "#58a6ff"), cursorAccent: readCssVar("--term-cursor", "#58a6ff"), - selectionBackground: readCssVar("--accent-soft", "rgba(88, 166, 255, 0.16)"), + selectionBackground: readCssVar("--accent-soft", "rgba(88, 166, 255, 0.30)"), black: readCssVar("--bg-app", "#090a0d"), red: readCssVar("--danger", "#f85149"), green: readCssVar("--success", "#3fb950"), @@ -226,6 +226,8 @@ window.__blxcodeTerminal = { allowTransparency: true, theme: xtermThemeFromDom(), disableStdin: false, + rightClickSelectsWord: false, + scrollback: 5000, }); const fit = new FitAddon(); term.loadAddon(fit); @@ -241,6 +243,34 @@ window.__blxcodeTerminal = { }); term.loadAddon(webLinks); term.open(container); + + // Windows CMD-style right-click: copy selection if text is selected, + // otherwise paste from clipboard. xterm.js swallows contextmenu by + // default so we need our own handler on the rendered element. + const attachContextMenu = () => { + const el = term.element; + if (!el) return; + el.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const sel = term.getSelection(); + if (sel && sel.length > 0) { + navigator.clipboard.writeText(sel).catch(() => {}); + term.clearSelection(); + } else { + navigator.clipboard + .readText() + .then((text) => { if (text) term.paste(text); }) + .catch(() => {}); + } + }); + }; + // term.element is available after open() but guard with rAF to be safe + if (term.element) { + attachContextMenu(); + } else { + requestAnimationFrame(attachContextMenu); + } + const rec = { term, fit, diff --git a/src-tauri/src/browser_host.rs b/src-tauri/src/browser_host.rs index 457c159..6105a97 100644 --- a/src-tauri/src/browser_host.rs +++ b/src-tauri/src/browser_host.rs @@ -9,7 +9,6 @@ use tauri::webview::WebviewBuilder; use tauri::{AppHandle, LogicalPosition, LogicalSize, Manager, WebviewUrl}; use url::Url; -pub const DEFAULT_HOME_URL: &str = "https://blxcode.com"; /// Child-WebViews mit SPA-gestützten Bounds funktionieren zuverlässig nur dort, /// wo das Tauri-/wry-Backend eine echte Unter-WebView einpasst (Windows: HWND-Child, @@ -84,11 +83,13 @@ impl BrowserHost { let label = label_for(tab_id); // Webview für aktiven Tab anlegen falls noch nicht vorhanden. + // Kein nativer Child für leere URLs (neuer Tab) — die Leptos-Komponente + // zeigt in dem Fall die New-Tab-Seite ohne überlagernde Webview. if !state.tabs.contains_key(&tab_id) { - let start = navigate_to - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or(DEFAULT_HOME_URL); + let start = match navigate_to.map(str::trim).filter(|s| !s.is_empty()) { + Some(url) => url, + None => return Ok(()), + }; let u = Url::parse(start).map_err(|e| format!("URL: {e}"))?; let builder = WebviewBuilder::new(&label, WebviewUrl::External(u)); let window = app @@ -104,9 +105,10 @@ impl BrowserHost { state.tabs.insert(tab_id, Some(start.to_string())); } - let wv = app - .get_webview(&label) - .ok_or_else(|| format!("webview {label}"))?; + // Tab exists but still has no webview yet (URL was empty at creation time). + let Some(wv) = app.get_webview(&label) else { + return Ok(()); + }; wv.set_position(LogicalPosition::new(rect.x, rect.y)) .map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6b4cdd9..787091f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -467,44 +467,70 @@ pub struct BrowserBoundsPayload { pub visible: bool, } +/// Dispatches `f` to the Tauri main thread and awaits the result. +/// Required for any WebView2 / wry API call on Windows (add_child, navigate, +/// show, hide, eval, …) — those must run on the UI message-pump thread. +async fn dispatch_on_main(app: tauri::AppHandle, f: F) -> Result +where + F: FnOnce(tauri::AppHandle) -> Result + Send + 'static, + T: Send + 'static, +{ + let (tx, rx) = tokio::sync::oneshot::channel::>(); + app.clone() + .run_on_main_thread(move || { + let _ = tx.send(f(app)); + }) + .map_err(|e| e.to_string())?; + rx.await.map_err(|_| "main thread channel dropped".to_string())? +} + #[tauri::command] -pub fn browser_sync_bounds( +pub async fn browser_sync_bounds( app: tauri::AppHandle, - host: State<'_, BrowserHost>, active_tab_id: Option, rect: BrowserBoundsPayload, url_optional: Option, ) -> Result<(), String> { - host.sync_bounds(&app, active_tab_id, rect, url_optional.as_deref()) + dispatch_on_main(app, move |app| { + app.state::() + .sync_bounds(&app, active_tab_id, rect, url_optional.as_deref()) + }) + .await } #[tauri::command] -pub fn browser_run_js( - app: AppHandle, - host: State<'_, BrowserHost>, +pub async fn browser_run_js( + app: tauri::AppHandle, tab_id: u64, script: String, ) -> Result<(), String> { - host.eval_embedded(&app, tab_id, script) + dispatch_on_main(app, move |app| { + app.state::().eval_embedded(&app, tab_id, script) + }) + .await } #[tauri::command] -pub fn browser_navigate( +pub async fn browser_navigate( app: tauri::AppHandle, - host: State<'_, BrowserHost>, tab_id: u64, url: String, ) -> Result<(), String> { - host.navigate(&app, tab_id, &url) + dispatch_on_main(app, move |app| { + app.state::().navigate(&app, tab_id, &url) + }) + .await } #[tauri::command] -pub fn browser_close_tab( +pub async fn browser_close_tab( app: tauri::AppHandle, - host: State<'_, BrowserHost>, tab_id: u64, ) -> Result<(), String> { - host.close_tab(&app, tab_id) + dispatch_on_main(app, move |app| { + app.state::().close_tab(&app, tab_id) + }) + .await } #[tauri::command] diff --git a/src-tauri/src/pty_host.rs b/src-tauri/src/pty_host.rs index 6baf345..6babc27 100644 --- a/src-tauri/src/pty_host.rs +++ b/src-tauri/src/pty_host.rs @@ -56,7 +56,7 @@ impl PtyManager { cwd: String, extra_env: Vec<(String, String)>, ) -> Result { - let cwd = PathBuf::from(cwd.trim()); + let cwd = PathBuf::from(cwd.trim().trim_matches('\0')); if cwd.as_os_str().is_empty() { return Err("cwd empty".into()); } @@ -73,9 +73,18 @@ impl PtyManager { }; let pair = pty_system.openpty(size).map_err(|e| e.to_string())?; - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + #[cfg(windows)] + let (shell, login_args): (String, &[&str]) = ( + std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".into()), + &[], + ); + #[cfg(not(windows))] + let (shell, login_args): (String, &[&str]) = ( + std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()), + &["-l"], + ); let mut cmd = CommandBuilder::new(&shell); - cmd.args(["-l"]); + cmd.args(login_args); cmd.cwd(&cwd); cmd.env("TERM", "xterm-256color"); cmd.env("COLORTERM", "truecolor"); @@ -282,7 +291,10 @@ fn drain_queue(q: &mut VecDeque>, cap: usize) -> Vec { } fn home_dir_string() -> Option { - std::env::var("HOME").ok().filter(|s| !s.is_empty()) + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .ok() + .filter(|s| !s.is_empty()) } /// Safe `cd`-style navigation: only `cd` with one argument (or `cd` / `cd ~`), plus empty line = pwd. @@ -326,7 +338,7 @@ fn resolve_cd_path(base: &Path, arg: &str) -> Result { let joined = if let Some(stripped) = arg.strip_prefix("~/") { let h = home_dir_string().ok_or_else(|| "HOME not set".to_string())?; Path::new(&h).join(stripped) - } else if arg.starts_with('/') { + } else if Path::new(arg).is_absolute() { PathBuf::from(arg) } else if arg == ".." { base.join("..") diff --git a/src/config/app.config.rs b/src/config/app.config.rs index e9917bd..30740eb 100644 --- a/src/config/app.config.rs +++ b/src/config/app.config.rs @@ -88,6 +88,9 @@ pub const SIDEBAR_DIFF_HEIGHT_PCT_DEFAULT: f64 = 30.0; pub const SIDEBAR_DIFF_HEIGHT_PCT_MIN: f64 = 12.0; pub const SIDEBAR_DIFF_HEIGHT_PCT_MAX: f64 = 76.0; +/// Minimum height reserved for the Git Graph (commits) slot when stacked with other sections. +pub const SIDEBAR_GRAPH_HEIGHT_PCT_MIN: f64 = 12.0; + /// `localStorage` key for sidebar width in pixels. pub const SIDEBAR_WIDTH_PX_KEY: &str = "blxcode_sidebar_width_px_v1"; diff --git a/src/workbench/agent_panel/mod.rs b/src/workbench/agent_panel/mod.rs index 0782b91..fa12a05 100644 --- a/src/workbench/agent_panel/mod.rs +++ b/src/workbench/agent_panel/mod.rs @@ -59,6 +59,15 @@ pub fn AgentPanelDock() -> impl IntoView { let image_mode = RwSignal::new(false); let chat_maximized = RwSignal::new(false); let chat_scroll_ref = NodeRef::::new(); + let compose_input_ref = NodeRef::::new(); + // Refocus the compose input whenever the agent finishes (busy → false). + Effect::new(move |_| { + if !busy.get() { + if let Some(el) = compose_input_ref.get() { + let _ = el.focus(); + } + } + }); let voice_handle = VoiceOrbHandle::new(); let task_snapshot = RwSignal::new(TaskSnapshot { tasks: Vec::new(), @@ -290,13 +299,6 @@ pub fn AgentPanelDock() -> impl IntoView {

{move || i18n.tr(I18nKey::AgChatHeading)()}

- {move || { - if timeline.get().is_empty() { - i18n.tr(I18nKey::AgBadgeReady)().to_string() - } else { - i18n.tr(I18nKey::AgBadgeLive)().to_string() - } - }} + {move || { + if !has_detail || !detail_open.get() { + return view! { <> }.into_any(); + } + if has_paths { + view! { +
    + {paths_sv.get_value().into_iter().map(|p| { + let display = path_tail(&p); + let p_open = p.clone(); + view! { +
  • + +
  • + } + }).collect_view()} +
+ }.into_any() + } else { + view! { +
{detail_text.clone()}
+ }.into_any() + } + }} +
+ + } + }).collect_view()} + diff --git a/src/workbench/agent_panel/turn_metrics_bar/turn_metrics_bar.css b/src/workbench/agent_panel/turn_metrics_bar/turn_metrics_bar.css index 46224ba..c276ff6 100644 --- a/src/workbench/agent_panel/turn_metrics_bar/turn_metrics_bar.css +++ b/src/workbench/agent_panel/turn_metrics_bar/turn_metrics_bar.css @@ -3,22 +3,23 @@ not compete with the row's primary content. */ .turn-metrics-bar { - display: inline-flex; + display: flex; flex-wrap: wrap; - align-items: center; + align-items: baseline; gap: 0.4rem; margin-top: 0.25rem; padding: 0.1rem 0; font-family: ui-monospace, "SF Mono", "Menlo", "Consolas", monospace; font-size: 0.7rem; - line-height: 1.2; + line-height: 1; color: var(--text-muted); } .turn-metrics-bar__cell { display: inline-flex; - align-items: center; + align-items: baseline; gap: 0.2rem; + line-height: 1; } .turn-metrics-bar__cell strong { @@ -43,21 +44,32 @@ font-size: 0.66rem; } -/* The new synthetic "decision" row for tool-only model rounds — - essentially just the metrics bar with a tiny prefix. */ -.agent-chat-line--decision { - padding-left: 0; -} - -.agent-chat-line--decision .agent-chat-body { - padding: 0.05rem 0 0.05rem 0.1rem; +/* Model-round row: inference metrics + tool calls grouped in one block. */ +.agent-chat-line--model-round .agent-chat-body { + padding-top: 0.15rem; + padding-bottom: 0.2rem; } .agent-chat-decision-label { - display: inline-block; + display: block; font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); - margin-right: 0.4rem; + margin-bottom: 0.25rem; +} + +/* Tool list nested inside the model-round block. */ +.model-round-tools { + list-style: none; + margin: 0.3rem 0 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.model-round-tool-item { + display: flex; + flex-direction: column; } diff --git a/src/workbench/agent_timeline.rs b/src/workbench/agent_timeline.rs index 40e2113..8ee742a 100644 --- a/src/workbench/agent_timeline.rs +++ b/src/workbench/agent_timeline.rs @@ -29,10 +29,23 @@ pub struct ToolActivity { /// Per-row metrics — populated by a matching `TurnUsage` event. #[serde(default)] pub metrics: TurnMetrics, + /// Workspace-relative paths accessed by this tool call (file-reading tools + /// only). Populated from tool args at call time; accumulates entries when + /// consecutive same-tool calls are grouped in a ModelRound. + #[serde(default)] + pub paths: Vec, + /// Number of individual tool calls merged into this row (1 = ungrouped). + #[serde(default = "default_merged_count")] + pub merged_count: usize, +} + +fn default_merged_count() -> usize { + 1 } impl ToolActivity { pub fn from_call(tool: &str, args: Option<&Value>, loc: Locale) -> Self { + let paths = file_arg_path(tool, args).into_iter().collect(); Self { tool: tool.to_owned(), label: tool_label(tool, loc), @@ -41,6 +54,8 @@ impl ToolActivity { detail: None, call_id: None, metrics: TurnMetrics::default(), + paths, + merged_count: 1, } } @@ -288,6 +303,7 @@ fn summarize_args(tool: &str, args: Option<&Value>) -> String { "git_show" => Some("rev"), "git_commit" => Some("message"), "web_fetch" => Some("url"), + "rules_read" | "skills_read" => Some("name"), _ => None, }; if let Some(key) = pick { @@ -302,3 +318,25 @@ fn summarize_args(tool: &str, args: Option<&Value>) -> String { } String::new() } + +/// Returns the workspace-relative path that a file-reading tool accesses, +/// constructed from its arguments. Returns `None` for non-file tools. +fn file_arg_path(tool: &str, args: Option<&Value>) -> Option { + let args = args?; + match tool { + "rules_read" => { + let name = args.get("name")?.as_str()?; + Some(format!(".agents/rules/{name}")) + } + "skills_read" => { + let name = args.get("name")?.as_str()?; + Some(format!(".agents/skills/{name}/SKILL.md")) + } + "read_workspace_file" | "memory_read" | "memory_create" | "memory_write" + | "memory_delete" | "memory_backlinks" | "list_workspace_files" => { + args.get("path")?.as_str().map(|s| s.to_owned()) + } + "memory_rename" => args.get("newPath")?.as_str().map(|s| s.to_owned()), + _ => None, + } +} diff --git a/src/workbench/browser_tab.rs b/src/workbench/browser_tab.rs index 0b9924b..f92c6ab 100644 --- a/src/workbench/browser_tab.rs +++ b/src/workbench/browser_tab.rs @@ -534,7 +534,15 @@ pub fn BrowserTabDock() -> impl IntoView { aria-label=move || i18n.tr(I18nKey::BrNativeAria)() > diff --git a/src/workbench/file_diff_section/file-diff-section.css b/src/workbench/file_diff_section/file-diff-section.css index b320c95..45b6725 100644 --- a/src/workbench/file_diff_section/file-diff-section.css +++ b/src/workbench/file_diff_section/file-diff-section.css @@ -1,8 +1,4 @@ .sidebar-view-section--sb-diff { - flex: 0 0 auto; -} - -.sidebar-view-section--sb-diff.sidebar-view-section--open { flex: 1 1 auto; min-height: 0; } diff --git a/src/workbench/mod.rs b/src/workbench/mod.rs index 4e21805..f38ac01 100644 --- a/src/workbench/mod.rs +++ b/src/workbench/mod.rs @@ -56,8 +56,9 @@ pub use right_panel::RightPanel; pub use sidebar::Sidebar; pub use skills_rules_panel::SkillsRulesService; pub use state::{ - AgentImageContextStatus, BrowserEmbedSurface, HarnessUiService, LegacyStorageMigration, - RightPanelTab, WorkbenchService, WorkbenchSnapshot, WorkspaceAgentImage, + AgentImageContextStatus, BrowserEmbedSurface, HarnessSettingsCategory, HarnessUiService, + LegacyStorageMigration, RightPanelTab, WorkbenchService, WorkbenchSnapshot, + WorkspaceAgentImage, }; pub use workspace_panel::WorkspacePanel; diff --git a/src/workbench/right_panel.rs b/src/workbench/right_panel.rs index 8ef9618..09e7dd6 100644 --- a/src/workbench/right_panel.rs +++ b/src/workbench/right_panel.rs @@ -3,7 +3,8 @@ use crate::i18n::I18nKey; use crate::service::I18nService; use crate::workbench::skills_rules_panel::{RulesTabDock, SkillsTabDock}; use crate::workbench::{ - AgentPanelDock, BrowserTabDock, MemoryPanel, PlansPanel, RightPanelTab, WorkbenchService, + AgentPanelDock, BrowserTabDock, HarnessSettingsCategory, MemoryPanel, PlansPanel, + RightPanelTab, WorkbenchService, }; use leptos::leptos_dom::helpers::window_event_listener_untyped; use leptos::prelude::*; @@ -54,6 +55,32 @@ fn PlansTabDock() -> impl IntoView { } } +#[component] +fn RightPanelSettingsButton(#[prop(default = "")] extra_class: &'static str) -> impl IntoView { + let wb = expect_context::(); + let i18n = expect_context::(); + view! { + + } +} + #[component] pub fn RightPanel() -> impl IntoView { let wb = expect_context::(); @@ -264,6 +291,9 @@ pub fn RightPanel() -> impl IntoView { +
+ +
impl IntoView { {move || i18n.tr(I18nKey::TabSkills)()} +
+
diff --git a/src/workbench/sidebar.rs b/src/workbench/sidebar.rs index a2ade64..01e2635 100644 --- a/src/workbench/sidebar.rs +++ b/src/workbench/sidebar.rs @@ -2,9 +2,10 @@ use crate::config::{ SIDEBAR_DIFF_HEIGHT_PCT_DEFAULT, SIDEBAR_DIFF_HEIGHT_PCT_KEY, SIDEBAR_DIFF_HEIGHT_PCT_MAX, SIDEBAR_DIFF_HEIGHT_PCT_MIN, SIDEBAR_EXPLORER_HEIGHT_PCT_DEFAULT, SIDEBAR_EXPLORER_HEIGHT_PCT_KEY, SIDEBAR_EXPLORER_HEIGHT_PCT_MAX, - SIDEBAR_EXPLORER_HEIGHT_PCT_MIN, SIDEBAR_PANELS_HEIGHT_PCT_DEFAULT, - SIDEBAR_PANELS_HEIGHT_PCT_KEY, SIDEBAR_PANELS_HEIGHT_PCT_MAX, SIDEBAR_PANELS_HEIGHT_PCT_MIN, - SIDEBAR_WIDTH_PX_DEFAULT, SIDEBAR_WIDTH_PX_MIN, + SIDEBAR_EXPLORER_HEIGHT_PCT_MIN, SIDEBAR_GRAPH_HEIGHT_PCT_MIN, + SIDEBAR_PANELS_HEIGHT_PCT_DEFAULT, SIDEBAR_PANELS_HEIGHT_PCT_KEY, + SIDEBAR_PANELS_HEIGHT_PCT_MAX, SIDEBAR_PANELS_HEIGHT_PCT_MIN, SIDEBAR_WIDTH_PX_DEFAULT, + SIDEBAR_WIDTH_PX_MIN, }; use crate::i18n::I18nKey; use crate::service::I18nService; @@ -454,6 +455,8 @@ pub fn Sidebar() -> impl IntoView {
impl IntoView { height_pct=explorer_height_pct container_selector=".workbench-sidebar__panels" aria_key=I18nKey::SbExplorerResizeAria + clamp_max=Some(Signal::derive(move || { + let diff_visible = wb.active_sidebar_diff_open() + && git_repo_available.get_untracked() == Some(true); + let graph_visible = wb.active_sidebar_graph_open(); + let reserved = if diff_visible && graph_visible { + diff_height_pct.get() + SIDEBAR_GRAPH_HEIGHT_PCT_MIN + } else if diff_visible { + diff_height_pct.get() + } else if graph_visible { + SIDEBAR_GRAPH_HEIGHT_PCT_MIN + } else { + 0.0 + }; + (100.0 - reserved).max(SIDEBAR_EXPLORER_HEIGHT_PCT_MIN) + })) />
impl IntoView {
impl IntoView { container_selector=".workbench-sidebar__panels" clamp=SidebarResizerClamp::DiffInPanels aria_key=I18nKey::SbDiffResizeAria + subtract_pct=Some(Signal::derive(move || { + if wb.active_sidebar_explorer_open() { + explorer_height_pct.get() + } else { + 0.0 + } + })) + clamp_max=Some(Signal::derive(move || { + let explorer_pct = if wb.active_sidebar_explorer_open() { + explorer_height_pct.get() + } else { + 0.0 + }; + (100.0 - explorer_pct - SIDEBAR_GRAPH_HEIGHT_PCT_MIN) + .max(SIDEBAR_DIFF_HEIGHT_PCT_MIN) + })) />
>, + /// Dynamic ceiling that overrides the static clamp max — used to reserve space + /// for sections below (e.g. ensure the graph slot never collapses to zero). + #[prop(default = None)] + clamp_max: Option>, ) -> impl IntoView { let i18n = expect_context::(); let dragging = RwSignal::new(false); @@ -82,12 +92,20 @@ pub fn SidebarResizer( return; } let offset = f64::from(pe.client_y()) - rect.top(); - let pct = if measure_from_bottom { + let raw_pct = if measure_from_bottom { ((height - offset) / height) * 100.0 } else { (offset / height) * 100.0 }; - let (min_pct, max_pct) = clamp.min_max(); + let pct = match subtract_pct { + Some(sub) => raw_pct - sub.get_untracked(), + None => raw_pct, + }; + let (min_pct, static_max) = clamp.min_max(); + let max_pct = match clamp_max { + Some(s) => s.get_untracked().min(static_max), + None => static_max, + }; height_pct.set(pct.max(min_pct).min(max_pct)); }); diff --git a/src/workbench/state.rs b/src/workbench/state.rs index 77dd865..44cd974 100644 --- a/src/workbench/state.rs +++ b/src/workbench/state.rs @@ -195,7 +195,11 @@ pub struct ChatUsageStats { /// belong to a turn that was cancelled by `agent_clear_conversation`. /// Bumped locally on chat reset as well so we don't credit anything /// emitted before the reset to the fresh chat. - #[serde(default)] + /// + /// Not persisted: the backend resets its own generation to 0 on every + /// app launch, so persisting this value would cause all first-session + /// events to be dropped after any prior-session reset. + #[serde(skip)] pub current_turn_generation: u64, } diff --git a/styles.css b/styles.css index 4f86673..e154e65 100644 --- a/styles.css +++ b/styles.css @@ -23,6 +23,34 @@ box-sizing: border-box; } +/* ── Global thin scrollbars ──────────────────────────────────────────────── */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +*::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 2px; +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +*::-webkit-scrollbar-corner { + background: transparent; +} + html { height: 100%; overflow: hidden; @@ -620,6 +648,18 @@ body { min-height: 0; } +.workbench-sidebar__diff-slot { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.workbench-sidebar__diff-slot > .sidebar-view-section { + flex: 1 1 auto; + min-height: 0; +} + .workbench-sidebar__graph-slot { flex: 1 1 auto; min-height: 0; @@ -1718,6 +1758,27 @@ button.workbench-shortcut-row--action:focus-visible { display: block; } +.workbench-right-rail__footer { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0.35rem 0 0.5rem; + border-top: 1px solid var(--border); +} + +.workbench-right-rail-settings-btn { + width: 2rem; + height: 2rem; + flex-shrink: 0; +} + +.workbench-right-settings-btn__icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + .workbench-right-slot--resizing, .workbench-right-slot--resizing * { cursor: col-resize !important; @@ -3239,6 +3300,36 @@ button.workbench-shortcut-row--action:focus-visible { word-break: break-word; } +.tool-row-paths { + list-style: none; + margin: 0; + padding: 0.1rem 0.52rem 0.48rem 1.9rem; + display: flex; + flex-direction: column; + gap: 0.18rem; +} + +.tool-row-path-btn { + display: inline-flex; + align-items: center; + padding: 0; + border: none; + background: transparent; + font-family: var(--font-mono); + font-size: 0.66rem; + color: var(--accent); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: transparent; + transition: color 120ms ease, text-decoration-color 120ms ease; +} + +.tool-row-path-btn:hover { + color: var(--accent); + text-decoration-color: var(--accent); +} + .agent-tool-row__count { display: inline-flex; align-items: center; @@ -4794,8 +4885,6 @@ button.workbench-shortcut-row--action:focus-visible { flex: 1; overflow: auto; padding: 0.875rem 1.25rem 1rem; - scrollbar-width: thin; - scrollbar-color: var(--border-strong) transparent; } .eula-scroll.eula-md { @@ -4896,18 +4985,6 @@ button.workbench-shortcut-row--action:focus-visible { color: inherit; } -.eula-scroll::-webkit-scrollbar { - width: 6px; -} - -.eula-scroll::-webkit-scrollbar-thumb { - background: var(--border-strong); - border-radius: 3px; -} - -.eula-scroll::-webkit-scrollbar-track { - background: transparent; -} .eula-actions { position: relative; @@ -5203,7 +5280,6 @@ button { gap: 0.18rem; min-width: 0; overflow-x: auto; - scrollbar-width: thin; } .workspace-center-tab { @@ -6347,6 +6423,7 @@ button { background: transparent !important; } + .harness-sheet--wizard { margin-top: 3vh; width: min(96vw, 52rem); From 19b5b004ba748d21087d5dba243d8ee30cf20022 Mon Sep 17 00:00:00 2001 From: Maik Roland Damm Date: Tue, 26 May 2026 23:43:35 +0200 Subject: [PATCH 2/8] Refactor workspace module imports and clean up code structure - Rearranged module imports in `mod.rs` for better organization. - Consolidated and simplified function signatures in `plans_panel/mod.rs`. - Removed unnecessary whitespace in `right_panel.rs` and `sidebar_resizer/mod.rs`. - Improved readability by adjusting formatting in `skills_rules_panel/install_dialog.rs`. - Enhanced clarity in `state.rs` by restructuring enum definitions. - Streamlined terminal cell imports in `terminal_cell.rs`. - Optimized theme service function in `theme_service.rs`. - Adjusted function signatures for consistency in `voice_app_controls/mod.rs`. - Cleaned up layout logic in `workspace_panel.rs`. - Refined workspace settings pane structure in `workspace_settings_pane/mod.rs`. - Added new styles for memory files section in `styles.css`. --- .../plans/phase5-6-handoff-for-codex.md | 327 +++++ .agents/learnings/LEARNINGS.md | 12 - .agents/learnings/README.md | 28 + .agents/memory/README.md | 28 + .agents/memory/Test.md | 2 + frontend-js/graph3d_entry.mjs | 38 +- src-tauri/Cargo.toml | 1 + src-tauri/src/agent/anthropic.rs | 39 +- src-tauri/src/agent/git_agent.rs | 14 +- src-tauri/src/agent/mod.rs | 4 +- src-tauri/src/agent/openrouter.rs | 14 +- src-tauri/src/agent/pricing.rs | 26 +- src-tauri/src/agent/session_orchestrator.rs | 6 +- src-tauri/src/agent/shell_exec.rs | 21 +- src-tauri/src/agent/subagent_prompts.rs | 68 +- src-tauri/src/agent/subagent_runner.rs | 105 +- src-tauri/src/agent/subagents.rs | 10 +- src-tauri/src/agent/system_prompt.rs | 7 +- src-tauri/src/agent/tool_groups.rs | 28 +- src-tauri/src/agent/tools.rs | 34 +- src-tauri/src/agent/web_settings.rs | 23 +- src-tauri/src/agent/web_tools.rs | 6 +- src-tauri/src/agent/workspace_agent.rs | 10 +- src-tauri/src/agent_settings.rs | 9 +- src-tauri/src/agents_layout.rs | 65 +- src-tauri/src/api_keys.rs | 7 +- src-tauri/src/browser_host.rs | 1 - src-tauri/src/commands.rs | 24 +- src-tauri/src/fs_entries.rs | 64 +- src-tauri/src/git_graph.rs | 9 +- src-tauri/src/gitignore.rs | 4 +- src-tauri/src/image/generate.rs | 39 +- src-tauri/src/image/settings.rs | 7 +- src-tauri/src/lib.rs | 7 +- src-tauri/src/memory.rs | 1276 ----------------- src-tauri/src/memory/frontmatter.rs | 87 ++ src-tauri/src/memory/graph.rs | 153 ++ src-tauri/src/memory/mod.rs | 178 +++ src-tauri/src/memory/paths.rs | 153 ++ src-tauri/src/memory/store.rs | 1092 ++++++++++++++ src-tauri/src/memory/types.rs | 128 ++ src-tauri/src/memory/wikilinks.rs | 253 ++++ src-tauri/src/plans.rs | 21 +- src-tauri/src/skills_rules/commands.rs | 4 +- src-tauri/src/skills_rules/install.rs | 9 +- src-tauri/src/skills_rules/store.rs | 43 +- src-tauri/src/skills_rules/types.rs | 10 +- src-tauri/src/voice/settings.rs | 9 +- src-tauri/src/voice/stt.rs | 3 +- src/i18n/keys.rs | 3 + src/i18n/locales/de_de.rs | 3 + src/i18n/locales/en_us.rs | 3 + src/i18n/locales/es_es.rs | 3 + src/i18n/locales/fr_fr.rs | 3 + src/i18n/locales/hu_hu.rs | 3 + src/i18n/locales/it_it.rs | 3 + src/i18n/locales/ja_jp.rs | 3 + src/i18n/locales/ko_kr.rs | 3 + src/i18n/locales/pl_pl.rs | 3 + src/i18n/locales/pt_br.rs | 3 + src/i18n/locales/ru_ru.rs | 3 + src/i18n/locales/zh_cn.rs | 3 + src/i18n/locales/zh_tw.rs | 3 + src/main.rs | 2 +- src/tauri_bridge.rs | 180 ++- src/theme/catalog.rs | 6 +- src/theme/mod.rs | 4 +- src/workbench/agent_context_handoff.rs | 34 +- src/workbench/agent_model_picker/mod.rs | 14 +- .../agent_panel/ask_user_card/mod.rs | 7 +- src/workbench/agent_panel/client_tools.rs | 5 +- src/workbench/agent_panel/mod.rs | 9 +- src/workbench/agent_panel/timeline.rs | 125 +- .../agent_panel/turn_metrics_bar/mod.rs | 5 +- src/workbench/agent_panel/voice_orb/mod.rs | 9 +- src/workbench/agent_provider_pane/mod.rs | 5 +- src/workbench/agent_timeline.rs | 27 +- src/workbench/api_keys_pane/mod.rs | 12 +- src/workbench/chat_markdown.rs | 46 +- .../file_preview/code_context_menu.rs | 4 +- src/workbench/file_preview/code_view.rs | 83 +- src/workbench/file_preview/hljs_glue.rs | 12 +- src/workbench/file_preview/markdown_view.rs | 6 +- src/workbench/file_preview/mermaid_glue.rs | 39 +- src/workbench/file_preview/util.rs | 36 +- src/workbench/git_graph/mod.rs | 4 +- src/workbench/harness_chords.rs | 11 +- src/workbench/harness_image_pane/mod.rs | 5 +- src/workbench/harness_ui.rs | 2 +- src/workbench/harness_voice_pane/mod.rs | 17 +- src/workbench/memory_graph/mod.rs | 213 ++- src/workbench/memory_panel.rs | 666 ++++++--- src/workbench/mod.rs | 18 +- src/workbench/plans_panel/mod.rs | 5 +- src/workbench/right_panel.rs | 2 +- src/workbench/sidebar_resizer/mod.rs | 20 +- .../skills_rules_panel/install_dialog.rs | 9 +- src/workbench/state.rs | 28 +- src/workbench/terminal_cell.rs | 6 +- src/workbench/theme_service.rs | 5 +- src/workbench/voice_app_controls/mod.rs | 7 +- src/workbench/workspace_panel.rs | 7 +- src/workbench/workspace_settings_pane/mod.rs | 13 +- styles.css | 48 +- 104 files changed, 4040 insertions(+), 2264 deletions(-) create mode 100644 .agents/.cursor/plans/phase5-6-handoff-for-codex.md delete mode 100644 .agents/learnings/LEARNINGS.md create mode 100644 .agents/learnings/README.md create mode 100644 .agents/memory/README.md create mode 100644 .agents/memory/Test.md delete mode 100644 src-tauri/src/memory.rs create mode 100644 src-tauri/src/memory/frontmatter.rs create mode 100644 src-tauri/src/memory/graph.rs create mode 100644 src-tauri/src/memory/mod.rs create mode 100644 src-tauri/src/memory/paths.rs create mode 100644 src-tauri/src/memory/store.rs create mode 100644 src-tauri/src/memory/types.rs create mode 100644 src-tauri/src/memory/wikilinks.rs diff --git a/.agents/.cursor/plans/phase5-6-handoff-for-codex.md b/.agents/.cursor/plans/phase5-6-handoff-for-codex.md new file mode 100644 index 0000000..c1022c1 --- /dev/null +++ b/.agents/.cursor/plans/phase5-6-handoff-for-codex.md @@ -0,0 +1,327 @@ +# Handoff: Memory System — Phases 5 & 6 + +## Context + +This is BLXCode (Tauri 2 + Leptos 0.8 CSR), a desktop IDE. +Phases 1–4 of the memory-parity plan are **complete and compiling**. +This document tells you exactly what is left for Phases 5 and 6 and where every relevant piece of code lives. + +--- + +## What Was Done (Phases 1–4) + +### Backend (`src-tauri/src/memory/`) +- `memory.rs` was replaced by a full module: `types.rs`, `paths.rs`, `frontmatter.rs`, `wikilinks.rs`, `graph.rs`, `store.rs`, `mod.rs` +- `MemoryScope { Workspace, Global }` (serde: `"workspace"` / `"global"`) runs everywhere +- Global roots: `~/.blxcode/memory/` and `~/.blxcode/learnings/` (via `dirs::home_dir()`) +- New commands registered in `src-tauri/src/lib.rs`: `memory_status`, `memory_bootstrap` +- All CRUD commands have `scope: MemoryScope` parameter +- `memory_list` returns `MemoryListResponse { notes: Vec, memory_subcategories: MemorySubcategories { workspace, global } }` +- `memory_backlinks` returns `Vec` +- Graph builder creates hub nodes (`id: "hub:{category}"`, `is_category_hub: true`) and cross-scope edges +- Legacy migration (`migrate_legacy_memory`) removed from `agents_layout.rs` + +### Frontend (`src/`) +- `src/tauri_bridge.rs`: all memory types updated, `MemoryScope` enum, `note_key(scope, path)`, `parse_note_key(key)`, `memory_bootstrap`, `memory_status` +- `src/workbench/memory_panel.rs`: dual-scope sidebar implemented + - `MemoryState` has `active_scope`, `global_subcategories`, `global_bootstrapped`, `backlinks: Vec` + - Files view has two sections: "Projekt" (workspace) and "Global" + - Global section shows "Create Global Memory" button when not bootstrapped + - All CRUD ops (create note/category, rename, delete, backlink click, context menu) use `note.scope` + - Workspace bootstrap called on workspace change +- `src/workbench/memory_graph/mod.rs`: still uses **hardcoded `MemoryScope::Workspace`** in two places (intentional Phase 4 deferral) + +--- + +## Current State of `memory_graph/mod.rs` + +File: `src/workbench/memory_graph/mod.rs` + +Two hardcoded workspace fallbacks that need to be fixed in Phase 5: + +**Line ~810** (in the "Open in Files" button click handler): +```rust +expand_files_group_for_path(state.clone(), &MemoryScope::Workspace, &path); +load_note(state.clone(), ws, MemoryScope::Workspace, path); +``` + +**Line ~858** (inside `open_graph_preview`): +```rust +match tauri_bridge::memory_read(&ws, &MemoryScope::Workspace, &path).await { +``` + +The `GraphPreviewState.path` signal currently holds just the bare API path (`"decisions/note.md"`), without scope. The graph node ID is `"{scope}:{path}"` (see `GraphNode.id` in `tauri_bridge.rs`). The scope must be recovered from the node ID when a node is clicked, so it can be threaded through to `load_note`, `memory_read`, etc. + +--- + +## Phase 5 — Graph-Tab: Scope-Aware Clicks + Hub-Node Styling + +### Goal +1. Node clicks carry the correct scope (workspace or global) +2. Hub nodes are visually distinct from regular note nodes +3. Cross-scope edges are optionally styled differently + +### Key Types (already defined in `src/tauri_bridge.rs`) + +```rust +pub struct GraphNode { + pub id: String, // "workspace:decisions/note.md" or "hub:decisions" + pub scope: MemoryScope, + pub path: String, // bare API path, e.g. "decisions/note.md" + pub label: String, + pub tags: Vec, + pub orphan: bool, + pub category: String, + pub is_category_hub: Option, // true for hub nodes + pub hub_scopes: Option>, + pub color: Option, +} + +pub struct GraphEdge { + pub source: String, + pub target: String, + pub cross_scope: bool, +} +``` + +### Changes Required in `src/workbench/memory_graph/mod.rs` + +#### 1. `GraphPreviewState` — add scope field + +```rust +struct GraphPreviewState { + open: RwSignal, + scope: RwSignal, // ADD THIS + path: RwSignal>, + label: RwSignal, + content: RwSignal, + loading: RwSignal, +} +``` + +Initialize `scope: RwSignal::new(MemoryScope::Workspace)` in `GraphPreviewState::new()`. + +#### 2. `open_graph_preview` — derive scope from node ID + +The function signature is `fn open_graph_preview(state: MemoryState, preview: GraphPreviewState, path: String)`. +The `path` argument is currently the bare API path. Change the call site to pass the node **ID** (which includes the scope prefix), then parse it: + +```rust +fn open_graph_preview(state: MemoryState, preview: GraphPreviewState, node_id: String) { + // node_id is either "workspace:some/path.md" or "hub:category" or "global:some/path.md" + let (scope, path) = tauri_bridge::parse_note_key(&node_id) + .unwrap_or((MemoryScope::Workspace, node_id.clone())); + + state.graph_selected_node.set(Some(node_id)); + preview.open.set(true); + preview.scope.set(scope.clone()); + preview.path.set(Some(path.clone())); + // ... rest unchanged, but use `scope` instead of MemoryScope::Workspace for memory_read + match tauri_bridge::memory_read(&ws, &scope, &path).await { ... } +} +``` + +For hub nodes (`parse_note_key` returns `None` because the ID is `"hub:category"`, not `"scope:path"`): show a hub summary panel (list of notes in this category) instead of reading a file. Check: `if node_id.starts_with("hub:")`. + +#### 3. "Open in Files" button — pass scope correctly + +```rust +expand_files_group_for_path(state.clone(), &preview.scope.get_untracked(), &path); +load_note(state.clone(), ws, preview.scope.get_untracked(), path); +``` + +#### 4. `navigate_to_graph_node` in `src/workbench/memory_graph/mod.rs` + +This public function is called from `memory_panel.rs` when the user clicks "show in graph" for the active note. It currently takes a plain path. Update to also accept scope so it can set the correct selected node: + +Current signature (approx.): +```rust +pub(crate) fn navigate_to_graph_node(state: MemoryState, path: String) +``` + +Change to: +```rust +pub(crate) fn navigate_to_graph_node(state: MemoryState, scope: MemoryScope, path: String) +``` + +And form the node ID: `let node_id = tauri_bridge::note_key(&scope, &path);` +Then set `state.graph_selected_node.set(Some(node_id))`. + +Update all call sites in `memory_panel.rs` — search for `navigate_to_graph_node` to find them. + +#### 5. Hub Node Visual Styling + +In the graph renderer (JavaScript side in `public/` or wherever the 3D/2D graph is rendered), hub nodes can be detected by their ID prefix `"hub:"`. + +On the Leptos side, the node list passed to the JS graph should tag hub nodes. `GraphNode.is_category_hub` is already `Some(true)` for hubs. The existing graph bridge in `memory_graph/mod.rs` serializes the graph data to JS — check how nodes are passed and add a `hub` CSS class or color override. + +Look for the JS graph integration in `public/` (likely `public/memory_graph.js` or similar) and check if there is already a hub-specific rendering path. The `GraphNode.color` field can be set by the backend but may also be overridden in JS. Hub nodes should be rendered larger and with a folder/category icon. + +If the graph rendering is done entirely in WASM/Leptos without JS: add a `class:memory-graph-node--hub=move || node.is_category_hub.unwrap_or(false)` to the node element and style in `styles.css`. + +#### 6. Cross-scope Edge Styling + +`GraphEdge.cross_scope: bool` is already in the data. Style cross-scope edges with a dashed line or different color. Pass this through to the graph JS as an edge attribute. + +--- + +## Phase 6 — Search-Tab: Scope-Aware Filter Chips + +### Current State + +File: `src/workbench/memory_panel.rs`, component `MemorySearchView` (around line 1830+). + +Current filter chips are hardcoded to `"memory"` and `"learnings"` strings, which only cover workspace. The filter functions `memory_hit_count`, `learnings_hit_count`, `search_hit_category`, `filter_search_hits` all use string-comparison against these two categories. + +`SearchHit` (in `src/tauri_bridge.rs`) already has: +```rust +pub struct SearchHit { + pub scope: MemoryScope, + pub path: String, + pub line: u32, + pub snippet: String, + pub category: String, // e.g. "decisions", "learnings", "memory" +} +``` + +### Changes Required + +#### 1. Replace hardcoded filter chips with dynamic scope+category chips + +Instead of a static "Memory" / "Learnings" split, build dynamic chips from the actual search results: + +``` +[All (N)] [Workspace (N)] [Global (N)] [workspace:decisions (N)] [global:memory (N)] ... +``` + +The chip key format is: +- `None` → All results +- `Some("workspace")` → all workspace hits +- `Some("global")` → all global hits +- `Some("workspace:decisions")` → workspace hits in category "decisions" +- `Some("global:learnings")` → global learnings + +Build chips dynamically from `SearchHit.scope` + `SearchHit.category`. + +#### 2. Update `filter_search_hits` + +```rust +fn filter_search_hits(hits: Vec, filter: Option) -> Vec { + match filter.as_deref() { + None => hits, + Some("workspace") => hits.into_iter().filter(|h| h.scope == MemoryScope::Workspace).collect(), + Some("global") => hits.into_iter().filter(|h| h.scope == MemoryScope::Global).collect(), + Some(key) => { + // "workspace:decisions" or "global:learnings" + if let Some((scope_str, cat)) = key.split_once(':') { + let target_scope = match scope_str { + "workspace" => MemoryScope::Workspace, + "global" => MemoryScope::Global, + _ => return hits, + }; + hits.into_iter() + .filter(|h| h.scope == target_scope && h.category == cat) + .collect() + } else { + hits + } + } + } +} +``` + +#### 3. Update `load_note` call in search results + +The search hit click currently calls: +```rust +load_note(s.clone(), ws, sc.clone(), p.clone()); +``` +This is already correct (Phase 4 set `h.scope` into `sc`). Verify it's still wired up correctly. + +#### 4. Build dynamic filter chip list + +Add a helper: +```rust +fn search_scope_categories(hits: &[SearchHit]) -> Vec<(String, usize)> { + // Returns (chip_key, count) pairs for All + scope-level + scope:category level + // Only include chips where count > 0 +} +``` + +Render them in the `MemorySearchView` filter bar instead of the current hardcoded buttons. + +#### 5. i18n + +Add keys for "Workspace" and "Global" scope labels used in filter chips: +- `MemSearchFilterWorkspace` +- `MemSearchFilterGlobal` + +Add to `src/i18n/keys.rs` and all locale files in `src/i18n/locales/`. See how `MemGlobalCreate` was added in Phase 4 for the pattern. + +--- + +## Reference: EB Implementation + +The BLXCode-eb reference lives at `c:\Users\quork\Entwicklung\BLXCode-eb\`. + +For Phase 5: +- `src/views/app/layout/SidePanel/Memory/graph/MemoryGraphView.tsx` — hub node rendering, scope-aware click handlers +- `src/shared/memory.ts` — `overviewPathForCategory`, `preferredScopeForHub`, `CATEGORY_HUB_PREFIX = "hub:"` + +For Phase 6: +- `src/views/app/layout/SidePanel/Memory/search/MemorySearchView.tsx` — scope+category filter chips +- `src/shared/memory.ts` — filter chip logic + +--- + +## Compile Instructions + +```powershell +# Check frontend only (fast) +cargo check -p blxcode-ui --target wasm32-unknown-unknown + +# Check backend only +cargo check -p blxcode + +# Run all tests +cargo test --workspace + +# Full dev build +cargo tauri dev +``` + +There are currently no compile errors. There are some `dead_code` / `unused_import` warnings in `src-tauri/src/memory/store.rs` and `tauri_bridge.rs` (`parse_note_key`) — these are harmless leftovers. + +--- + +## Files to Touch + +| File | Why | +|------|-----| +| `src/workbench/memory_graph/mod.rs` | Phase 5: scope-aware node clicks, hub styling, `navigate_to_graph_node` signature | +| `src/workbench/memory_panel.rs` | Phase 5: update `navigate_to_graph_node` call sites; Phase 6: `MemorySearchView` filter chips | +| `src/i18n/keys.rs` | Phase 6: add `MemSearchFilterWorkspace`, `MemSearchFilterGlobal` | +| `src/i18n/locales/*.rs` | Phase 6: add translations for all 13 locales | +| `styles.css` | Phase 5: hub node CSS classes (if rendered in Leptos) | +| `public/` (JS graph) | Phase 5: hub node visual distinction in graph renderer | + +Do **not** touch: +- `src-tauri/src/memory/` — backend is complete +- `src/tauri_bridge.rs` — bridge is complete (`parse_note_key` is already there) +- `src-tauri/src/lib.rs` — all commands registered + +--- + +## Acceptance Criteria + +**Phase 5 done when:** +- Clicking a global graph node opens it in the editor with `MemoryScope::Global` +- Clicking a hub node shows a summary (or a list of notes in that category) — not a 404 +- Hub nodes are visually distinct from note nodes (size, shape, or color) +- The "Open in Files" button in the graph preview navigates to the correct scope section + +**Phase 6 done when:** +- Search results from both workspace and global memory appear +- Filter chips allow narrowing by scope (`workspace` / `global`) and by scope+category +- Clicking a search result opens the note with the correct scope +- `cargo check -p blxcode-ui --target wasm32-unknown-unknown` has no errors diff --git a/.agents/learnings/LEARNINGS.md b/.agents/learnings/LEARNINGS.md deleted file mode 100644 index e6cecf9..0000000 --- a/.agents/learnings/LEARNINGS.md +++ /dev/null @@ -1,12 +0,0 @@ -# Learnings - -This directory is the persistent knowledge base for AI coding agents working on -this repository. Use it to capture facts, decisions, conventions, pitfalls, and -resolved mistakes that are useful beyond a single task. - -Keep this file as the overview and index. Store individual learnings in separate -Markdown files inside `.agents/learnings/`. - -## Index - -_(Add learnings here as `[[learnings/topic-filename|Short title]]` — one line per topic.)_ diff --git a/.agents/learnings/README.md b/.agents/learnings/README.md new file mode 100644 index 0000000..238c001 --- /dev/null +++ b/.agents/learnings/README.md @@ -0,0 +1,28 @@ +--- +title: Learnings +enabled: true +tags: [] +--- +# Learnings + +This folder is a **growing knowledge base of resolved problems** - concrete things you discovered the hard way and want future agents (human or AI) to find. + +Each learning is a separate markdown file named `learning-.md`. Together they form a searchable log of "the thing we now know that we didn't know before." + +Keep this file as the overview and index. Store individual learnings in separate Markdown files inside `.agents/learnings/`. + +## When to record a learning + +- A bug you fixed that wasn't obvious from the symptom +- A workaround for a quirk in a dependency, runtime, or environment +- A migration step that required out-of-band knowledge + +## Tips for AI agents + +- Search here before debugging - someone may have hit this already. +- When you fix a non-trivial bug, propose a new learning entry. +- Keep one learning per file; cross-link related ones via `[[wikilinks]]`. + +## Index + +_(Add learnings here as `[[learnings/topic-filename|Short title]]` - one line per topic.)_ diff --git a/.agents/memory/README.md b/.agents/memory/README.md new file mode 100644 index 0000000..813c477 --- /dev/null +++ b/.agents/memory/README.md @@ -0,0 +1,28 @@ +--- +title: Memory +enabled: true +tags: [] +--- +# Memory + +This folder stores **durable, project-wide notes** that should survive across coding sessions and be available to every AI agent or teammate working in this repo. + +## What belongs here + +- Architectural decisions and the reasoning behind them +- Conventions, patterns and "house style" that aren't obvious from the code +- Reference material: external systems, where to find logs / dashboards +- Known-good workflows ("how we deploy", "how we cut a release") +- User-specific preferences for collaboration + +## What does **not** belong here + +- Ephemeral todos for the current task (use a Plan or task list instead) +- Things already documented in code, `CLAUDE.md`, or commit messages +- Time-sensitive snapshots (`git log` is authoritative for history) + +## Tips for AI agents + +- Read existing notes before answering questions about this project. +- When you learn something non-obvious, propose adding it here. +- Prefer updating an existing note over creating a near-duplicate. diff --git a/.agents/memory/Test.md b/.agents/memory/Test.md new file mode 100644 index 0000000..88b97b5 --- /dev/null +++ b/.agents/memory/Test.md @@ -0,0 +1,2 @@ +# Test + diff --git a/frontend-js/graph3d_entry.mjs b/frontend-js/graph3d_entry.mjs index 5afaaed..76ed55c 100644 --- a/frontend-js/graph3d_entry.mjs +++ b/frontend-js/graph3d_entry.mjs @@ -17,7 +17,11 @@ function readCssVar(name, fallback = "") { function applyGraph3dTheme(rec) { if (!rec?.graph) return; rec.graph.backgroundColor("rgba(0,0,0,0)"); - rec.graph.linkColor(() => readCssVar("--overlay-4", "rgba(255,255,255,0.14)")); + rec.graph.linkColor((link) => + link.crossScope + ? readCssVar("--accent-cool", "#7dd3fc") + : readCssVar("--overlay-4", "rgba(255,255,255,0.14)"), + ); rec.graph.nodeThreeObject(makeNodeObject); rec.graph.refresh(); } @@ -74,6 +78,7 @@ function normalizeGraphData(graphData) { ? graphData.edges.map((edge) => ({ source: edge.source, target: edge.target, + crossScope: Boolean(edge.crossScope), })) : []; return { @@ -84,6 +89,7 @@ function normalizeGraphData(graphData) { orphan: Boolean(node.orphan), color: typeof node.color === "string" && node.color.trim() ? node.color : null, category: typeof node.category === "string" && node.category.trim() ? node.category : null, + isCategoryHub: Boolean(node.isCategoryHub || String(node.id).startsWith("hub:")), })), links, }; @@ -91,6 +97,7 @@ function normalizeGraphData(graphData) { function colorForNode(node) { if (node.color) return node.color; + if (node.isCategoryHub) return readCssVar("--accent-warm", "#f59e0b"); if (node.orphan) return "#9aa3b8"; const tag = node.tags?.[0] || ""; let hash = 0; @@ -104,23 +111,30 @@ function colorForNode(node) { function makeNodeObject(node) { const color = colorForNode(node); const group = new THREE.Group(); - const geometry = new THREE.SphereGeometry(5.8, 24, 24); + const isHub = Boolean(node.isCategoryHub); + const geometry = isHub + ? new THREE.BoxGeometry(12.6, 8.8, 5.8) + : new THREE.SphereGeometry(5.8, 24, 24); const material = new THREE.MeshPhongMaterial({ color, emissive: color, - emissiveIntensity: 0.22, + emissiveIntensity: isHub ? 0.34 : 0.22, shininess: 70, }); - const sphere = new THREE.Mesh(geometry, material); + const body = new THREE.Mesh(geometry, material); const halo = new THREE.Mesh( - new THREE.SphereGeometry(8.5, 24, 24), + isHub ? new THREE.BoxGeometry(17.5, 12, 8) : new THREE.SphereGeometry(8.5, 24, 24), new THREE.MeshBasicMaterial({ color, transparent: true, - opacity: 0.12, + opacity: isHub ? 0.18 : 0.12, depthWrite: false, }), ); + if (isHub) { + halo.rotation.z = -0.08; + body.rotation.z = -0.08; + } const label = new SpriteText(wrapLabel(node.label || node.id)); label.color = readCssVar("--text", "rgba(238,239,245,0.92)"); label.backgroundColor = readCssVar("--scrim-bg", "rgba(8,10,16,0.58)"); @@ -128,14 +142,14 @@ function makeNodeObject(node) { label.borderWidth = 0.25; label.borderRadius = 2; label.padding = 2.6; - label.textHeight = 4.2; + label.textHeight = isHub ? 4.8 : 4.2; label.center.set(0, 0.5); label.position.set(12.5, 0, 0); label.material.depthWrite = false; label.material.depthTest = false; label.renderOrder = 10; group.add(halo); - group.add(sphere); + group.add(body); group.add(label); return group; } @@ -368,9 +382,13 @@ window.__blxcodeGraph3d = { .nodeId("id") .nodeLabel("label") .nodeThreeObject(makeNodeObject) - .linkColor(() => readCssVar("--overlay-4", "rgba(255,255,255,0.14)")) + .linkColor((link) => + link.crossScope + ? readCssVar("--accent-cool", "#7dd3fc") + : readCssVar("--overlay-4", "rgba(255,255,255,0.14)"), + ) .linkOpacity(0.34) - .linkWidth(1) + .linkWidth((link) => (link.crossScope ? 1.45 : 1)) .linkCurvature((link) => linkWobble(link)) .linkCurveRotation((link) => linkRotation(link)) .onLinkHover((link) => { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6488c21..d290542 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,3 +39,4 @@ uuid = { version = "1", features = ["v4"] } tauri-plugin-updater = "2" tauri-plugin-process = "2" notify = "6" +dirs = "5" diff --git a/src-tauri/src/agent/anthropic.rs b/src-tauri/src/agent/anthropic.rs index 30810b4..2927401 100644 --- a/src-tauri/src/agent/anthropic.rs +++ b/src-tauri/src/agent/anthropic.rs @@ -10,11 +10,11 @@ //! contracts as the OpenAI-compatible path. use super::system_prompt::system_prompt; +use crate::agent::pricing; use crate::agent::protocol::{AgentEvent, AgentImageContextItem}; use crate::agent::state::AgentEngineState; use crate::agent::tool_dispatch::{dispatch_tool, DispatchContext}; use crate::agent::tools::{self, WorkspaceRootGuard}; -use crate::agent::pricing; use crate::agent_settings::{AgentProviderKind, AgentProviderSettings, ThinkingLevel}; use crate::tasks; use serde::Deserialize; @@ -192,8 +192,7 @@ pub async fn run_chat_turn( return; } }; - let round_elapsed_ms = - round_start.elapsed().as_millis().min(u64::MAX as u128) as u64; + let round_elapsed_ms = round_start.elapsed().as_millis().min(u64::MAX as u128) as u64; // Anthropic's `/v1/messages` doesn't surface a cost field — always // fall back to local token×price math via the static id-mapping @@ -273,18 +272,16 @@ pub async fn run_chat_turn( let args_val: Value = serde_json::from_str(&call.arguments).unwrap_or_else(|_| json!({})); let tool_start = Instant::now(); - let outcome = - dispatch_tool( - &state, - &call.id, - &call.name, - &args_val, - root_guard.as_ref(), - Some(&dispatch_ctx), - ) - .await; - let tool_elapsed_ms = - tool_start.elapsed().as_millis().min(u64::MAX as u128) as u64; + let outcome = dispatch_tool( + &state, + &call.id, + &call.name, + &args_val, + root_guard.as_ref(), + Some(&dispatch_ctx), + ) + .await; + let tool_elapsed_ms = tool_start.elapsed().as_millis().min(u64::MAX as u128) as u64; if outcome.ok && call.name.starts_with("task_") { maybe_emit_task_snapshot(&state, root_guard.as_ref()); @@ -610,9 +607,9 @@ async fn run_one_round( BlockDelta::TextDelta { text } => { if !text.is_empty() { if acc.ttft_ms.is_none() { - acc.ttft_ms = Some( - request_sent.elapsed().as_millis().min(u64::MAX as u128) as u64, - ); + acc.ttft_ms = + Some(request_sent.elapsed().as_millis().min(u64::MAX as u128) + as u64); } if thinking_active && !thinking_closed { thinking_closed = true; @@ -627,9 +624,9 @@ async fn run_one_round( BlockDelta::ThinkingDelta { thinking } => { if !thinking.is_empty() { if acc.ttft_ms.is_none() { - acc.ttft_ms = Some( - request_sent.elapsed().as_millis().min(u64::MAX as u128) as u64, - ); + acc.ttft_ms = + Some(request_sent.elapsed().as_millis().min(u64::MAX as u128) + as u64); } state.push(AgentEvent::ThinkingDelta { delta: thinking.clone(), diff --git a/src-tauri/src/agent/git_agent.rs b/src-tauri/src/agent/git_agent.rs index 9d8d865..9c0c5a2 100644 --- a/src-tauri/src/agent/git_agent.rs +++ b/src-tauri/src/agent/git_agent.rs @@ -43,10 +43,15 @@ fn require_env(root: &WorkspaceRootGuard) -> Result<(), ToolOutcome> { } fn optional_path<'a>(args: &'a Value, key: &str) -> Option<&'a str> { - args.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty()) + args.get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) } -fn resolve_cwd(root: &WorkspaceRootGuard, rel: Option<&str>) -> Result { +fn resolve_cwd( + root: &WorkspaceRootGuard, + rel: Option<&str>, +) -> Result { let base = root.as_str(); let base_path = Path::new(&base); match rel { @@ -118,7 +123,10 @@ pub fn tool_git_diff(args: &Value, root: Option<&WorkspaceRootGuard>) -> ToolOut if args.get("staged").and_then(|v| v.as_bool()) == Some(true) { cmd_args.push("--staged"); } - let output = Command::new("git").args(&cmd_args).current_dir(&cwd).output(); + let output = Command::new("git") + .args(&cmd_args) + .current_dir(&cwd) + .output(); match output { Ok(o) => ToolOutcome { ok: true, diff --git a/src-tauri/src/agent/mod.rs b/src-tauri/src/agent/mod.rs index 29f88bc..ea33fe7 100644 --- a/src-tauri/src/agent/mod.rs +++ b/src-tauri/src/agent/mod.rs @@ -13,12 +13,12 @@ mod shell_exec; mod subagent_prompts; mod subagent_runner; mod subagents; -mod web_commands; -pub(crate) mod web_settings; mod system_prompt; mod tool_dispatch; mod tool_groups; mod tools_extra; +mod web_commands; +pub(crate) mod web_settings; mod web_tools; mod workspace_agent; diff --git a/src-tauri/src/agent/openrouter.rs b/src-tauri/src/agent/openrouter.rs index 4d9d0c4..2e4214d 100644 --- a/src-tauri/src/agent/openrouter.rs +++ b/src-tauri/src/agent/openrouter.rs @@ -11,13 +11,13 @@ //! Cancellation (`state.cancelled()`) is polled between SSE lines and //! between rounds; pending oneshots are dropped on cancel. +use crate::agent::pricing; use crate::agent::protocol::{AgentEvent, AgentImageContextItem}; use crate::agent::state::AgentEngineState; use crate::agent::system_prompt::system_prompt; use crate::agent::tool_dispatch::{dispatch_tool, DispatchContext}; use crate::agent::tool_groups::openai_tool_name_to_internal; use crate::agent::tools::WorkspaceRootGuard; -use crate::agent::pricing; use crate::agent_settings::{AgentProviderKind, AgentProviderSettings, ThinkingLevel}; use crate::tasks; use serde::Deserialize; @@ -298,8 +298,7 @@ pub async fn run_chat_turn( return; } }; - let round_elapsed_ms = - round_start.elapsed().as_millis().min(u64::MAX as u128) as u64; + let round_elapsed_ms = round_start.elapsed().as_millis().min(u64::MAX as u128) as u64; // Resolve USD cost: prefer OpenRouter's native number, fall back // to local token×price math via the cached pricing table. @@ -379,8 +378,7 @@ pub async fn run_chat_turn( Some(&dispatch_ctx), ) .await; - let tool_elapsed_ms = - tool_start.elapsed().as_millis().min(u64::MAX as u128) as u64; + let tool_elapsed_ms = tool_start.elapsed().as_millis().min(u64::MAX as u128) as u64; if outcome.ok && internal_name.starts_with("task_") { maybe_emit_task_snapshot(&state, root_guard.as_ref()); @@ -553,7 +551,8 @@ async fn run_one_round( if let Some(reasoning) = reasoning_chunk { if !reasoning.is_empty() { if acc.ttft_ms.is_none() { - acc.ttft_ms = Some(request_sent.elapsed().as_millis().min(u64::MAX as u128) as u64); + acc.ttft_ms = + Some(request_sent.elapsed().as_millis().min(u64::MAX as u128) as u64); } thinking_active = true; state.push(AgentEvent::ThinkingDelta { delta: reasoning }); @@ -562,7 +561,8 @@ async fn run_one_round( if let Some(text) = choice.delta.content { if !text.is_empty() { if acc.ttft_ms.is_none() { - acc.ttft_ms = Some(request_sent.elapsed().as_millis().min(u64::MAX as u128) as u64); + acc.ttft_ms = + Some(request_sent.elapsed().as_millis().min(u64::MAX as u128) as u64); } if thinking_active && !thinking_closed { thinking_closed = true; diff --git a/src-tauri/src/agent/pricing.rs b/src-tauri/src/agent/pricing.rs index 1946b49..6d9aa9b 100644 --- a/src-tauri/src/agent/pricing.rs +++ b/src-tauri/src/agent/pricing.rs @@ -70,15 +70,14 @@ fn find_in_cache( /// OpenRouter id (whose pricing we reuse). Add entries as new direct /// models are exposed in the UI — the test below guards against typos. #[must_use] -pub fn map_direct_to_openrouter(provider: AgentProviderKind, model_id: &str) -> Option<&'static str> { +pub fn map_direct_to_openrouter( + provider: AgentProviderKind, + model_id: &str, +) -> Option<&'static str> { match (provider, model_id) { // Anthropic Direct → OpenRouter - (AgentProviderKind::Anthropic, "claude-sonnet-4-5") => { - Some("anthropic/claude-sonnet-4.5") - } - (AgentProviderKind::Anthropic, "claude-sonnet-4-6") => { - Some("anthropic/claude-sonnet-4.6") - } + (AgentProviderKind::Anthropic, "claude-sonnet-4-5") => Some("anthropic/claude-sonnet-4.5"), + (AgentProviderKind::Anthropic, "claude-sonnet-4-6") => Some("anthropic/claude-sonnet-4.6"), (AgentProviderKind::Anthropic, "claude-opus-4-1") => Some("anthropic/claude-opus-4.1"), (AgentProviderKind::Anthropic, "claude-opus-4-7") => Some("anthropic/claude-opus-4.7"), (AgentProviderKind::Anthropic, "claude-haiku-4-5-20251001") => { @@ -96,7 +95,9 @@ pub fn map_direct_to_openrouter(provider: AgentProviderKind, model_id: &str) -> #[cfg(test)] mod tests { use super::*; - use crate::agent_settings::{AgentProviderKind, ModelPricing, ProviderModelEntry, ThinkingLevel}; + use crate::agent_settings::{ + AgentProviderKind, ModelPricing, ProviderModelEntry, ThinkingLevel, + }; fn settings_with_openrouter(entries: Vec) -> AgentProviderSettings { AgentProviderSettings { @@ -120,8 +121,7 @@ mod tests { #[test] fn openrouter_hit_sums_prompt_and_completion() { - let settings = - settings_with_openrouter(vec![entry("openai/gpt-5", 0.000_002, 0.000_010)]); + let settings = settings_with_openrouter(vec![entry("openai/gpt-5", 0.000_002, 0.000_010)]); let cost = resolve_cost( &settings, AgentProviderKind::Openrouter, @@ -185,8 +185,7 @@ mod tests { #[test] fn missing_tokens_still_prices_known_side() { - let settings = - settings_with_openrouter(vec![entry("openai/gpt-5", 0.000_002, 0.000_010)]); + let settings = settings_with_openrouter(vec![entry("openai/gpt-5", 0.000_002, 0.000_010)]); let cost = resolve_cost( &settings, AgentProviderKind::Openrouter, @@ -200,8 +199,7 @@ mod tests { #[test] fn both_tokens_none_returns_none_even_with_pricing() { - let settings = - settings_with_openrouter(vec![entry("openai/gpt-5", 0.000_002, 0.000_010)]); + let settings = settings_with_openrouter(vec![entry("openai/gpt-5", 0.000_002, 0.000_010)]); assert!(resolve_cost( &settings, AgentProviderKind::Openrouter, diff --git a/src-tauri/src/agent/session_orchestrator.rs b/src-tauri/src/agent/session_orchestrator.rs index a0f20ad..8541c9b 100644 --- a/src-tauri/src/agent/session_orchestrator.rs +++ b/src-tauri/src/agent/session_orchestrator.rs @@ -278,10 +278,8 @@ fn render_context_prompt( .first() .cloned() .unwrap_or_else(|| item.source.clone()); - let mut line = format!( - "- plan `{plan_path}`: {label}", - label = item.label.trim() - ); + let mut line = + format!("- plan `{plan_path}`: {label}", label = item.label.trim()); if let Some(ws) = workspace_root { if let Some(meta) = plans::plan_meta_for(ws, &plan_path) { line.push_str(&format!( diff --git a/src-tauri/src/agent/shell_exec.rs b/src-tauri/src/agent/shell_exec.rs index 5231d5c..baf9016 100644 --- a/src-tauri/src/agent/shell_exec.rs +++ b/src-tauri/src/agent/shell_exec.rs @@ -41,8 +41,25 @@ pub fn kill_all_children() { fn read_only_allowlist(program: &str) -> bool { matches!( program, - "ls" | "pwd" | "cat" | "head" | "tail" | "wc" | "file" | "which" | "env" | "rg" - | "fd" | "find" | "tree" | "stat" | "du" | "df" | "node" | "npm" | "cargo" | "git" + "ls" | "pwd" + | "cat" + | "head" + | "tail" + | "wc" + | "file" + | "which" + | "env" + | "rg" + | "fd" + | "find" + | "tree" + | "stat" + | "du" + | "df" + | "node" + | "npm" + | "cargo" + | "git" ) } diff --git a/src-tauri/src/agent/subagent_prompts.rs b/src-tauri/src/agent/subagent_prompts.rs index f32258b..060f093 100644 --- a/src-tauri/src/agent/subagent_prompts.rs +++ b/src-tauri/src/agent/subagent_prompts.rs @@ -74,38 +74,48 @@ fn render_tool_inventory(groups: &[ToolGroup]) -> String { .collect(); let buckets: &[(&str, &[&str])] = &[ - ("Workspace (read repo files)", &[ - "list_workspace_files", - "read_workspace_file", - "workspace_search", - ]), + ( + "Workspace (read repo files)", + &[ + "list_workspace_files", + "read_workspace_file", + "workspace_search", + ], + ), ("Diffs", &["workspace_git_status", "workspace_diff"]), - ("Git", &[ - "git_status", - "git_diff", - "git_log", - "git_show", - "git_branch_info", - "git_ls_files", - ]), + ( + "Git", + &[ + "git_status", + "git_diff", + "git_log", + "git_show", + "git_branch_info", + "git_ls_files", + ], + ), ("Environment / shell", &["environment_detect", "shell_exec"]), - ("Memory", &[ - "memory_list", - "memory_read", - "memory_search", - "memory_graph", - "memory_backlinks", - "memory_category_list", - "memory_context_list", - ]), - ("Plans", &["plan_list", "plan_read", "plan_load", "plan_context_list"]), + ( + "Memory", + &[ + "memory_list", + "memory_read", + "memory_search", + "memory_graph", + "memory_backlinks", + "memory_category_list", + "memory_context_list", + ], + ), + ( + "Plans", + &["plan_list", "plan_read", "plan_load", "plan_context_list"], + ), ("Tasks", &["task_list", "task_get"]), - ("Rules & Skills", &[ - "rules_list", - "rules_read", - "skills_list", - "skills_read", - ]), + ( + "Rules & Skills", + &["rules_list", "rules_read", "skills_list", "skills_read"], + ), ("Web", &["web_search", "web_fetch"]), ]; diff --git a/src-tauri/src/agent/subagent_runner.rs b/src-tauri/src/agent/subagent_runner.rs index 3cd8833..f18bb61 100644 --- a/src-tauri/src/agent/subagent_runner.rs +++ b/src-tauri/src/agent/subagent_runner.rs @@ -4,10 +4,10 @@ use crate::agent::anthropic::{from_anthropic_name, to_anthropic_name}; use crate::agent::openrouter::Endpoint; use crate::agent::protocol::AgentEvent; use crate::agent::state::AgentEngineState; -use crate::agent::subagent_prompts::{self, SubagentRole, truncate_submit_result}; +use crate::agent::subagent_prompts::{self, truncate_submit_result, SubagentRole}; +use crate::agent::tool_dispatch::DispatchContext; use crate::agent::tool_groups::{openai_tool_name_to_internal, ToolGroup}; use crate::agent::tools::{self, WorkspaceRootGuard}; -use crate::agent::tool_dispatch::DispatchContext; use crate::agent_settings::AgentProviderKind; use futures_util::TryStreamExt; use serde::Deserialize; @@ -142,11 +142,10 @@ pub async fn run_one_subagent( // with as the first step. When models claim "tools not in schema", the // operator can immediately compare against this list and tell whether // the model is hallucinating or the provisioning really is empty. - let provisioned: Vec<&'static str> = - crate::agent::tool_groups::registry_filtered(groups, true) - .into_iter() - .map(|t| t.name) - .collect(); + let provisioned: Vec<&'static str> = crate::agent::tool_groups::registry_filtered(groups, true) + .into_iter() + .map(|t| t.name) + .collect(); state.push(AgentEvent::SubagentStep { agent_id: agent_id.to_owned(), step_id: "provisioned-tools".into(), @@ -226,7 +225,12 @@ pub async fn run_one_subagent( } let round_start = Instant::now(); let round = match stream_openai_subagent_round( - state, &client, endpoint, &ctx.api_key, &body, agent_id, + state, + &client, + endpoint, + &ctx.api_key, + &body, + agent_id, ) .await { @@ -276,20 +280,24 @@ pub async fn run_one_subagent( } if !round.tool_calls.is_empty() { assistant["tool_calls"] = Value::Array( - round.tool_calls.iter().map(|tc| { - json!({ - "id": tc.id, - "type": "function", - "function": { - "name": tc.name, - "arguments": if tc.arguments.is_empty() { - "{}".to_string() - } else { - tc.arguments.clone() - }, - } + round + .tool_calls + .iter() + .map(|tc| { + json!({ + "id": tc.id, + "type": "function", + "function": { + "name": tc.name, + "arguments": if tc.arguments.is_empty() { + "{}".to_string() + } else { + tc.arguments.clone() + }, + } + }) }) - }).collect(), + .collect(), ); } messages.push(assistant); @@ -340,11 +348,8 @@ pub async fn run_one_subagent( groups, root_guard.as_ref(), ); - let tool_elapsed_ms = tool_start - .elapsed() - .as_millis() - .min(u64::MAX as u128) - as u64; + let tool_elapsed_ms = + tool_start.elapsed().as_millis().min(u64::MAX as u128) as u64; state.push(AgentEvent::TurnUsage { kind: crate::agent::protocol::TurnUsageKind::ToolExec, agent_id: Some(agent_id.to_owned()), @@ -393,7 +398,11 @@ pub async fn run_one_subagent( }); let round_start = Instant::now(); let round = match stream_anthropic_subagent_round( - state, &client, &ctx.api_key, &body, agent_id, + state, + &client, + &ctx.api_key, + &body, + agent_id, ) .await { @@ -436,7 +445,8 @@ pub async fn run_one_subagent( return result; } if !round.assistant_blocks.is_empty() { - messages.push(json!({ "role": "assistant", "content": round.assistant_blocks })); + messages + .push(json!({ "role": "assistant", "content": round.assistant_blocks })); } if round.tool_uses.is_empty() { break; @@ -479,11 +489,8 @@ pub async fn run_one_subagent( let tool_start = Instant::now(); let outcome = execute_subagent_tool(&name, &args, groups, root_guard.as_ref()); - let tool_elapsed_ms = tool_start - .elapsed() - .as_millis() - .min(u64::MAX as u128) - as u64; + let tool_elapsed_ms = + tool_start.elapsed().as_millis().min(u64::MAX as u128) as u64; state.push(AgentEvent::TurnUsage { kind: crate::agent::protocol::TurnUsageKind::ToolExec, agent_id: Some(agent_id.to_owned()), @@ -936,9 +943,9 @@ async fn stream_anthropic_subagent_round( }); } if acc.ttft_ms.is_none() { - acc.ttft_ms = Some( - req_start.elapsed().as_millis().min(u64::MAX as u128) as u64, - ); + acc.ttft_ms = + Some(req_start.elapsed().as_millis().min(u64::MAX as u128) + as u64); } state.push(AgentEvent::SubagentAssistantDelta { agent_id: agent_id.to_owned(), @@ -951,9 +958,9 @@ async fn stream_anthropic_subagent_round( AnthroBlockDelta::ThinkingDelta { thinking } => { if !thinking.is_empty() { if acc.ttft_ms.is_none() { - acc.ttft_ms = Some( - req_start.elapsed().as_millis().min(u64::MAX as u128) as u64, - ); + acc.ttft_ms = + Some(req_start.elapsed().as_millis().min(u64::MAX as u128) + as u64); } state.push(AgentEvent::SubagentThinkingDelta { agent_id: agent_id.to_owned(), @@ -1121,9 +1128,8 @@ async fn stream_openai_subagent_round( if !reasoning.is_empty() { thinking_active = true; if acc.ttft_ms.is_none() { - acc.ttft_ms = Some( - req_start.elapsed().as_millis().min(u64::MAX as u128) as u64, - ); + acc.ttft_ms = + Some(req_start.elapsed().as_millis().min(u64::MAX as u128) as u64); } state.push(AgentEvent::SubagentThinkingDelta { agent_id: agent_id.to_owned(), @@ -1140,9 +1146,8 @@ async fn stream_openai_subagent_round( }); } if acc.ttft_ms.is_none() { - acc.ttft_ms = Some( - req_start.elapsed().as_millis().min(u64::MAX as u128) as u64, - ); + acc.ttft_ms = + Some(req_start.elapsed().as_millis().min(u64::MAX as u128) as u64); } state.push(AgentEvent::SubagentAssistantDelta { agent_id: agent_id.to_owned(), @@ -1208,20 +1213,14 @@ mod tests { let args = json!({ "status": "blocked", "summary": "denied" }); let mut called: HashSet = HashSet::new(); called.insert("list_workspace_files".into()); - assert_eq!( - validate_submit(&args, &called, true), - SubmitVerdict::Accept - ); + assert_eq!(validate_submit(&args, &called, true), SubmitVerdict::Accept); } #[test] fn validate_submit_accepts_completed_regardless() { let args = json!({ "status": "completed", "summary": "done" }); let called: HashSet = HashSet::new(); - assert_eq!( - validate_submit(&args, &called, true), - SubmitVerdict::Accept - ); + assert_eq!(validate_submit(&args, &called, true), SubmitVerdict::Accept); } #[test] diff --git a/src-tauri/src/agent/subagents.rs b/src-tauri/src/agent/subagents.rs index a449b90..46a0f5e 100644 --- a/src-tauri/src/agent/subagents.rs +++ b/src-tauri/src/agent/subagents.rs @@ -1,11 +1,11 @@ //! Coordinated subagent runs via `subagents.run`. +use crate::agent::protocol::AgentEvent; +use crate::agent::state::AgentEngineState; use crate::agent::subagent_prompts::{self, SubagentRole}; use crate::agent::subagent_runner::{run_one_subagent, SubagentProvider}; -use crate::agent::state::AgentEngineState; use crate::agent::tool_dispatch::DispatchContext; use crate::agent::tool_groups::{self, parse_allowed_groups_strict, ToolGroup}; -use crate::agent::protocol::AgentEvent; use crate::agent::tools::{ToolOutcome, WorkspaceRootGuard}; use serde::Deserialize; use serde_json::{json, Value}; @@ -147,10 +147,8 @@ pub async fn run( groups.push(ToolGroup::SubagentSubmit); groups.retain(|g| !matches!(g, ToolGroup::SubagentsRun | ToolGroup::ShellWrite)); - let tools_openai = - tool_groups::render_for_openai_filtered(&groups, web_enabled); - let mut tools_anthropic = - tool_groups::render_for_anthropic_filtered(&groups, web_enabled); + let tools_openai = tool_groups::render_for_openai_filtered(&groups, web_enabled); + let mut tools_anthropic = tool_groups::render_for_anthropic_filtered(&groups, web_enabled); if let Some(arr) = tools_anthropic.as_array_mut() { for entry in arr { if let Some(name) = entry.get("name").and_then(|v| v.as_str()) { diff --git a/src-tauri/src/agent/system_prompt.rs b/src-tauri/src/agent/system_prompt.rs index 5a07eeb..688d4d5 100644 --- a/src-tauri/src/agent/system_prompt.rs +++ b/src-tauri/src/agent/system_prompt.rs @@ -252,7 +252,12 @@ mod tests { // Resume step covers EN+DE continuation directives assert!(p.contains("**Resume check.**")); for kw in [ - "continue", "keep going", "resume", "weiter", "fortsetzen", "weitermachen", + "continue", + "keep going", + "resume", + "weiter", + "fortsetzen", + "weitermachen", ] { assert!(p.contains(kw), "missing resume keyword: {kw}"); } diff --git a/src-tauri/src/agent/tool_groups.rs b/src-tauri/src/agent/tool_groups.rs index d28c8da..20cab99 100644 --- a/src-tauri/src/agent/tool_groups.rs +++ b/src-tauri/src/agent/tool_groups.rs @@ -98,12 +98,7 @@ impl ToolGroup { "memory_context_attach", "memory_context_detach", ], - Self::PlansRead => &[ - "plan_list", - "plan_read", - "plan_load", - "plan_context_list", - ], + Self::PlansRead => &["plan_list", "plan_read", "plan_load", "plan_context_list"], Self::PlansWrite => &[ "plan_create", "plan_write", @@ -114,12 +109,7 @@ impl ToolGroup { "plan_context_detach", ], Self::TasksRead => &["task_list", "task_get"], - Self::TasksWrite => &[ - "task_create", - "task_update", - "task_delete", - "task_reorder", - ], + Self::TasksWrite => &["task_create", "task_update", "task_delete", "task_reorder"], Self::RulesSkillsRead => &["rules_list", "rules_read", "skills_list", "skills_read"], Self::RulesSkillsWrite => &[ "rules_write", @@ -175,10 +165,7 @@ pub fn coordinator_groups(web_enabled: bool) -> Vec { } pub fn parse_allowed_groups(names: &[String]) -> Vec { - names - .iter() - .filter_map(|s| ToolGroup::parse(s)) - .collect() + names.iter().filter_map(|s| ToolGroup::parse(s)).collect() } /// Like [`parse_allowed_groups`] but also returns the strings that failed to @@ -303,9 +290,9 @@ mod tests { fn parse_allowed_groups_strict_separates_known_from_unknown() { let input = vec![ "workspace_read".to_string(), - "file_access".to_string(), // bogus + "file_access".to_string(), // bogus "git_read".to_string(), - "shell".to_string(), // bogus (correct names are shell_read/shell_write) + "shell".to_string(), // bogus (correct names are shell_read/shell_write) ]; let (ok, bad) = parse_allowed_groups_strict(&input); assert_eq!(ok, vec![ToolGroup::WorkspaceRead, ToolGroup::GitRead]); @@ -349,7 +336,10 @@ mod tests { } // SubagentSubmit is force-pushed onto every subagent's group list. for tool in ToolGroup::SubagentSubmit.tool_names() { - assert!(!tool.contains('.'), "SubagentSubmit tool {tool:?} must be dotless"); + assert!( + !tool.contains('.'), + "SubagentSubmit tool {tool:?} must be dotless" + ); } } diff --git a/src-tauri/src/agent/tools.rs b/src-tauri/src/agent/tools.rs index 6d2601e..c20055d 100644 --- a/src-tauri/src/agent/tools.rs +++ b/src-tauri/src/agent/tools.rs @@ -1412,10 +1412,10 @@ fn tool_memory_list(root: Option<&WorkspaceRootGuard>) -> ToolOutcome { Err(out) => return out, }; match memory::memory_list(ws) { - Ok(mut notes) => { - notes.truncate(200); + Ok(mut resp) => { + resp.notes.truncate(200); let body = - serde_json::to_string(¬es).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}")); + serde_json::to_string(&resp).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}")); ToolOutcome { ok: true, content: body, @@ -1439,7 +1439,7 @@ fn tool_memory_read(args: &Value, root: Option<&WorkspaceRootGuard>) -> ToolOutc Ok(s) => s, Err(out) => return out, }; - match memory::memory_read(ws, path.to_owned()) { + match memory::memory_read(ws, memory::MemoryScope::Workspace, path.to_owned()) { Ok(note) => { let body = truncate_chars(¬e.content, 4000); ToolOutcome { @@ -1509,7 +1509,12 @@ fn tool_memory_create(args: &Value, root: Option<&WorkspaceRootGuard>) -> ToolOu Ok(s) => s, Err(out) => return out, }; - match memory::memory_create(ws, path.to_owned(), Some(content)) { + match memory::memory_create( + ws, + memory::MemoryScope::Workspace, + path.to_owned(), + Some(content), + ) { Ok(meta) => { let body = serde_json::to_string(&meta).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}")); @@ -1551,7 +1556,12 @@ fn tool_memory_write(args: &Value, root: Option<&WorkspaceRootGuard>) -> ToolOut Ok(s) => s, Err(out) => return out, }; - match memory::memory_write(ws, path.to_owned(), content.to_owned()) { + match memory::memory_write( + ws, + memory::MemoryScope::Workspace, + path.to_owned(), + content.to_owned(), + ) { Ok(note) => ToolOutcome { ok: true, content: format!("wrote {} ({} bytes)", note.path, note.content.len()), @@ -1574,7 +1584,7 @@ fn tool_memory_delete(args: &Value, root: Option<&WorkspaceRootGuard>) -> ToolOu Ok(s) => s, Err(out) => return out, }; - match memory::memory_delete(ws, path.to_owned()) { + match memory::memory_delete(ws, memory::MemoryScope::Workspace, path.to_owned()) { Ok(()) => ToolOutcome { ok: true, content: format!("deleted {path}"), @@ -1607,7 +1617,13 @@ fn tool_memory_rename(args: &Value, root: Option<&WorkspaceRootGuard>) -> ToolOu Ok(s) => s, Err(out) => return out, }; - match memory::memory_rename(ws, old_path.to_owned(), new_path.to_owned(), rewrite_links) { + match memory::memory_rename( + ws, + memory::MemoryScope::Workspace, + old_path.to_owned(), + new_path.to_owned(), + rewrite_links, + ) { Ok(report) => { let body = serde_json::to_string(&report).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}")); @@ -1655,7 +1671,7 @@ fn tool_memory_backlinks(args: &Value, root: Option<&WorkspaceRootGuard>) -> Too Ok(s) => s, Err(out) => return out, }; - match memory::memory_backlinks(ws, path.to_owned()) { + match memory::memory_backlinks(ws, memory::MemoryScope::Workspace, path.to_owned()) { Ok(links) => { let body = serde_json::to_string(&links).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}")); diff --git a/src-tauri/src/agent/web_settings.rs b/src-tauri/src/agent/web_settings.rs index 7b5ef90..8e8a787 100644 --- a/src-tauri/src/agent/web_settings.rs +++ b/src-tauri/src/agent/web_settings.rs @@ -77,10 +77,7 @@ pub fn refresh_runtime_from_app(app: &AppHandle) { } pub fn runtime_provider() -> WebProviderKind { - runtime() - .lock() - .map(|g| g.provider) - .unwrap_or_default() + runtime().lock().map(|g| g.provider).unwrap_or_default() } pub fn load(app: &AppHandle) -> Result { @@ -136,7 +133,9 @@ fn key_masked(kind: WebKeyKind) -> Result, String> { let entry = keyring_entry(kind)?; match entry.get_password() { Ok(secret) => Ok(agent_settings::mask_secret_pub(&secret)), - Err(keyring_core::Error::NoEntry) => Ok(env_key(kind).and_then(|s| agent_settings::mask_secret_pub(&s))), + Err(keyring_core::Error::NoEntry) => { + Ok(env_key(kind).and_then(|s| agent_settings::mask_secret_pub(&s))) + } Err(_) if cfg!(target_os = "linux") => { Ok(env_key(kind).and_then(|s| agent_settings::mask_secret_pub(&s))) } @@ -189,8 +188,12 @@ pub fn resolve_key(kind: WebKeyKind) -> Option { pub fn resolve_active_key() -> Option<(WebProviderKind, String)> { match runtime_provider() { - WebProviderKind::Tavily => resolve_key(WebKeyKind::Tavily).map(|k| (WebProviderKind::Tavily, k)), - WebProviderKind::Brave => resolve_key(WebKeyKind::Brave).map(|k| (WebProviderKind::Brave, k)), + WebProviderKind::Tavily => { + resolve_key(WebKeyKind::Tavily).map(|k| (WebProviderKind::Tavily, k)) + } + WebProviderKind::Brave => { + resolve_key(WebKeyKind::Brave).map(|k| (WebProviderKind::Brave, k)) + } WebProviderKind::None => env_key(WebKeyKind::Tavily) .map(|k| (WebProviderKind::Tavily, k)) .or_else(|| env_key(WebKeyKind::Brave).map(|k| (WebProviderKind::Brave, k))), @@ -201,7 +204,11 @@ pub fn web_tools_enabled() -> bool { resolve_active_key().is_some() } -pub fn set_key(app: &AppHandle, kind: WebKeyKind, api_key: String) -> Result { +pub fn set_key( + app: &AppHandle, + kind: WebKeyKind, + api_key: String, +) -> Result { let secret = api_key.trim(); if secret.is_empty() { return Err("API key is empty".into()); diff --git a/src-tauri/src/agent/web_tools.rs b/src-tauri/src/agent/web_tools.rs index f05af17..49aba5f 100644 --- a/src-tauri/src/agent/web_tools.rs +++ b/src-tauri/src/agent/web_tools.rs @@ -21,14 +21,16 @@ pub fn tool_web_search(args: &Value, _root: Option<&WorkspaceRootGuard>) -> Tool let Some((provider, key)) = web_settings::resolve_active_key() else { return ToolOutcome { ok: false, - content: "web tools disabled: configure API keys in Settings → Agent → Web Tools".into(), + content: "web tools disabled: configure API keys in Settings → Agent → Web Tools" + .into(), }; }; match provider { WebProviderKind::Tavily => tavily_search(query, &key), WebProviderKind::Brave => ToolOutcome { ok: false, - content: "Brave search not implemented in v1; select Tavily in Web Tools settings".into(), + content: "Brave search not implemented in v1; select Tavily in Web Tools settings" + .into(), }, WebProviderKind::None => ToolOutcome { ok: false, diff --git a/src-tauri/src/agent/workspace_agent.rs b/src-tauri/src/agent/workspace_agent.rs index c6482bc..a0f1521 100644 --- a/src-tauri/src/agent/workspace_agent.rs +++ b/src-tauri/src/agent/workspace_agent.rs @@ -51,11 +51,9 @@ pub fn tool_workspace_search(args: &Value, root: Option<&WorkspaceRootGuard>) -> }, } } - Err(_) => { - ToolOutcome { - ok: false, - content: "rg not available; install ripgrep for workspace_search".into(), - } - } + Err(_) => ToolOutcome { + ok: false, + content: "rg not available; install ripgrep for workspace_search".into(), + }, } } diff --git a/src-tauri/src/agent_settings.rs b/src-tauri/src/agent_settings.rs index 22e2af2..6e32924 100644 --- a/src-tauri/src/agent_settings.rs +++ b/src-tauri/src/agent_settings.rs @@ -215,9 +215,7 @@ pub(crate) fn provider_key_with_source( ) -> Result<(Option, KeySource), String> { let entry = keyring_entry(provider)?; match entry.get_password() { - Ok(secret) if !secret.trim().is_empty() => { - Ok((mask_secret(&secret), KeySource::Keyring)) - } + Ok(secret) if !secret.trim().is_empty() => Ok((mask_secret(&secret), KeySource::Keyring)), Ok(_) | Err(keyring_core::Error::NoEntry) => match read_fallback_secret(app, provider)? { Some(secret) => Ok((mask_secret(&secret), KeySource::File)), None => match provider_env_secret(provider) { @@ -362,8 +360,8 @@ fn save_settings(app: &AppHandle, settings: &AgentProviderSettings) -> Result<() // Merge the agent settings into the existing envelope so sibling keys // (voice/image) are preserved. let mut envelope = read_envelope(app)?; - let value = serde_json::to_value(settings) - .map_err(|e| format!("serialize agent settings: {e}"))?; + let value = + serde_json::to_value(settings).map_err(|e| format!("serialize agent settings: {e}"))?; let merged = match value { serde_json::Value::Object(map) => map, _ => return Err("agent settings did not serialize to a JSON object".into()), @@ -580,7 +578,6 @@ fn settings_view( }) } - #[derive(Deserialize)] struct OpenrouterModelsEnvelope { data: Vec, diff --git a/src-tauri/src/agents_layout.rs b/src-tauri/src/agents_layout.rs index 6f6385b..c05f1a0 100644 --- a/src-tauri/src/agents_layout.rs +++ b/src-tauri/src/agents_layout.rs @@ -1,4 +1,4 @@ -//! Workspace `.agents/` bootstrap, legacy memory migration, and learnings wikilink upgrades. +//! Workspace `.agents/` bootstrap and learnings wikilink upgrades. use std::fs; use std::path::{Path, PathBuf}; @@ -7,7 +7,6 @@ pub const AGENTS_REL: &str = ".agents"; pub const MEMORY_REL: &str = ".agents/memory"; pub const LEARNINGS_REL: &str = ".agents/learnings"; pub const PLANS_REL: &str = ".agents/plans"; -pub const LEGACY_MEMORY_REL: &str = ".blxcode/memory"; pub const LEARNINGS_API_PREFIX: &str = "learnings/"; pub const PLANS_INDEX: &str = "PLANS.md"; const TEMPLATES_DIRNAME: &str = "_templates"; @@ -67,7 +66,7 @@ pub fn validate_workspace_cwd(ws: &str) -> Result { Ok(p) } -/// Creates `.agents/memory`, `.agents/learnings`, migrates legacy memory, seeds index, upgrades wikilinks. +/// Creates `.agents/memory`, `.agents/learnings`, seeds index, upgrades wikilinks. pub fn ensure_agents_layout(ws: &str) -> Result { let ws_path = validate_workspace_cwd(ws)?; let agents = ws_path.join(AGENTS_REL); @@ -82,7 +81,6 @@ pub fn ensure_agents_layout(ws: &str) -> Result { fs::create_dir_all(&learnings).map_err(|e| format!("create {LEARNINGS_REL}: {e}"))?; fs::create_dir_all(&plans).map_err(|e| format!("create {PLANS_REL}: {e}"))?; - migrate_legacy_memory(&ws_path, &memory)?; seed_learnings_index_if_empty(&learnings)?; fix_learnings_index_typo(&learnings)?; upgrade_learnings_graph_links(&learnings)?; @@ -126,38 +124,6 @@ fn dir_has_any_md(dir: &Path) -> bool { false } -fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), String> { - if !src.is_dir() { - return Ok(()); - } - fs::create_dir_all(dest).map_err(|e| format!("mkdir {}: {e}", dest.display()))?; - for entry in fs::read_dir(src).map_err(|e| format!("read {}: {e}", src.display()))? { - let entry = entry.map_err(|e| e.to_string())?; - let from = entry.path(); - let to = dest.join(entry.file_name()); - if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { - copy_dir_recursive(&from, &to)?; - } else { - fs::copy(&from, &to).map_err(|e| format!("copy {}: {e}", from.display()))?; - } - } - Ok(()) -} - -fn migrate_legacy_memory(ws: &Path, memory: &Path) -> Result<(), String> { - let legacy = ws.join(LEGACY_MEMORY_REL); - if !legacy.is_dir() { - return Ok(()); - } - if dir_has_any_md(memory) { - return Ok(()); - } - if !dir_has_any_md(&legacy) { - return Ok(()); - } - copy_dir_recursive(&legacy, memory) -} - fn seed_learnings_index_if_empty(learnings: &Path) -> Result<(), String> { if dir_has_any_md(learnings) { return Ok(()); @@ -246,9 +212,7 @@ fn md_link_target_to_wikilink(target: &str) -> Option { if !path_part.to_ascii_lowercase().ends_with(".md") { return None; } - let stem = path_part - .trim_end_matches(".md") - .trim_end_matches(".MD"); + let stem = path_part.trim_end_matches(".md").trim_end_matches(".MD"); if stem.is_empty() || stem.contains("..") || stem.starts_with('/') { return None; } @@ -378,29 +342,6 @@ mod tests { assert_eq!(out, line); } - #[test] - fn migrate_legacy_only_when_new_empty() { - let ws = std::env::temp_dir().join(format!( - "blxcode_agents_migrate_{}_{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let _ = fs::remove_dir_all(&ws); - fs::create_dir_all(ws.join(LEGACY_MEMORY_REL)).unwrap(); - fs::write(ws.join(LEGACY_MEMORY_REL).join("note.md"), "legacy").unwrap(); - - let roots = ensure_agents_layout(&ws.to_string_lossy()).unwrap(); - assert_eq!( - fs::read_to_string(roots.memory.join("note.md")).unwrap(), - "legacy" - ); - - let _ = fs::remove_dir_all(&ws); - } - #[test] fn wikilink_upgrade_creates_graph_edge_fixture() { let ws = std::env::temp_dir().join(format!( diff --git a/src-tauri/src/api_keys.rs b/src-tauri/src/api_keys.rs index 2bccc34..2bd7c4d 100644 --- a/src-tauri/src/api_keys.rs +++ b/src-tauri/src/api_keys.rs @@ -11,11 +11,11 @@ use serde::{Deserialize, Serialize}; use tauri::AppHandle; use crate::agent::web_settings::{self, WebKeyKind}; -use crate::media_keys::{self, MediaKeyKind}; use crate::agent_settings::{ self, delete_provider_key_secret, provider_env_var, provider_key_with_source, set_provider_key_secret, AgentProviderKind, KeySource, }; +use crate::media_keys::{self, MediaKeyKind}; #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -98,7 +98,10 @@ fn media_kind_from_kind(kind: &str) -> Option { fn build_status(app: &AppHandle) -> Result { let mut entries = Vec::with_capacity( - LLM_KINDS.len() + SEARCH_KINDS.len() + COMING_SOON_LLM.len() + media_keys::MEDIA_KEY_KINDS.len(), + LLM_KINDS.len() + + SEARCH_KINDS.len() + + COMING_SOON_LLM.len() + + media_keys::MEDIA_KEY_KINDS.len(), ); for (provider, kind, label) in LLM_KINDS { diff --git a/src-tauri/src/browser_host.rs b/src-tauri/src/browser_host.rs index 6105a97..e134a33 100644 --- a/src-tauri/src/browser_host.rs +++ b/src-tauri/src/browser_host.rs @@ -9,7 +9,6 @@ use tauri::webview::WebviewBuilder; use tauri::{AppHandle, LogicalPosition, LogicalSize, Manager, WebviewUrl}; use url::Url; - /// Child-WebViews mit SPA-gestützten Bounds funktionieren zuverlässig nur dort, /// wo das Tauri-/wry-Backend eine echte Unter-WebView einpasst (Windows: HWND-Child, /// macOS: NSView addSubview). Auf Linux/GTK fügt `add_child` die Webview als diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 787091f..6b2f4e7 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -193,8 +193,7 @@ pub fn agent_export_context_images( } let base_dir = ws_root.join(AGENT_CONTEXT_REL_DIR); let images_dir = base_dir.join(AGENT_CONTEXT_IMAGES_DIRNAME); - std::fs::create_dir_all(&images_dir) - .map_err(|e| format!("create context image dir: {e}"))?; + std::fs::create_dir_all(&images_dir).map_err(|e| format!("create context image dir: {e}"))?; let mut exports: Vec = Vec::with_capacity(items.len()); for item in items { @@ -357,8 +356,8 @@ mod tests { bytes_b64: b64, size_bytes: png.len() as u64, }]; - let report = agent_export_context_images(ws.to_string_lossy().into(), items) - .expect("export ok"); + let report = + agent_export_context_images(ws.to_string_lossy().into(), items).expect("export ok"); let dir = std::path::PathBuf::from(&report.dir); assert!(dir.ends_with(AGENT_CONTEXT_REL_DIR)); let manifest = std::path::PathBuf::from(&report.manifest_path); @@ -366,7 +365,11 @@ mod tests { assert_eq!(report.images.len(), 1); let exported = std::path::PathBuf::from(&report.images[0].path); assert!(exported.is_file()); - assert!(exported.file_name().unwrap().to_string_lossy().ends_with(".png")); + assert!(exported + .file_name() + .unwrap() + .to_string_lossy() + .ends_with(".png")); let manifest_body = std::fs::read_to_string(&manifest).unwrap(); assert!(manifest_body.contains("\"images\"")); assert!(manifest_body.contains("img:1")); @@ -481,7 +484,8 @@ where let _ = tx.send(f(app)); }) .map_err(|e| e.to_string())?; - rx.await.map_err(|_| "main thread channel dropped".to_string())? + rx.await + .map_err(|_| "main thread channel dropped".to_string())? } #[tauri::command] @@ -505,7 +509,8 @@ pub async fn browser_run_js( script: String, ) -> Result<(), String> { dispatch_on_main(app, move |app| { - app.state::().eval_embedded(&app, tab_id, script) + app.state::() + .eval_embedded(&app, tab_id, script) }) .await } @@ -523,10 +528,7 @@ pub async fn browser_navigate( } #[tauri::command] -pub async fn browser_close_tab( - app: tauri::AppHandle, - tab_id: u64, -) -> Result<(), String> { +pub async fn browser_close_tab(app: tauri::AppHandle, tab_id: u64) -> Result<(), String> { dispatch_on_main(app, move |app| { app.state::().close_tab(&app, tab_id) }) diff --git a/src-tauri/src/fs_entries.rs b/src-tauri/src/fs_entries.rs index a00c9a1..49f0534 100644 --- a/src-tauri/src/fs_entries.rs +++ b/src-tauri/src/fs_entries.rs @@ -103,17 +103,14 @@ fn stem_lower(path: &Path) -> String { /// variant (`LICENSE.md`) since the caller always passes the stem. fn classify_policy(stem: &str) -> Option { match stem { - "license" | "licence" | "copying" | "copyright" | "unlicense" => { - Some(PolicyKind::License) - } + "license" | "licence" | "copying" | "copyright" | "unlicense" => Some(PolicyKind::License), "contributing" | "contribution" | "contributions" => Some(PolicyKind::Contributing), "contributors" | "contributer" | "contributers" => Some(PolicyKind::Contributors), "code_of_conduct" | "code-of-conduct" | "codeofconduct" => Some(PolicyKind::CodeOfConduct), "security" | "security-policy" | "security_policy" => Some(PolicyKind::Security), "authors" | "maintainers" | "owners" | "codeowners" => Some(PolicyKind::Authors), - "changelog" | "changes" | "history" | "release_notes" | "release-notes" | "releasenotes" => { - Some(PolicyKind::Changelog) - } + "changelog" | "changes" | "history" | "release_notes" | "release-notes" + | "releasenotes" => Some(PolicyKind::Changelog), "readme" => Some(PolicyKind::Readme), _ => None, } @@ -127,24 +124,20 @@ fn classify_kind(ext: &str) -> FileKind { "md" | "markdown" => FileKind::Markdown, "mmd" | "mermaid" => FileKind::Mermaid, // Source code with syntax highlighting. - "rs" | "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" - | "py" | "pyw" | "pyi" - | "go" | "java" | "kt" | "kts" | "scala" | "groovy" | "gradle" - | "swift" | "m" | "mm" | "c" | "h" | "cpp" | "cc" | "cxx" | "hpp" | "hxx" - | "cs" | "fs" | "fsx" | "vb" | "rb" | "erb" | "php" | "phtml" - | "lua" | "pl" | "pm" | "dart" | "r" | "jl" - | "clj" | "cljs" | "cljc" | "edn" | "ex" | "exs" | "eex" | "erl" | "hrl" - | "hs" | "lhs" | "purs" | "elm" | "nim" | "zig" | "ml" | "mli" | "ocaml" - | "html" | "htm" | "xhtml" | "vue" | "svelte" | "css" | "scss" | "sass" | "less" | "styl" - | "json" | "json5" | "jsonc" | "toml" | "yaml" | "yml" | "xml" | "plist" - | "sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "cmd" - | "sql" | "graphql" | "gql" | "proto" | "thrift" | "tf" | "tfvars" | "hcl" | "nix" - | "dockerfile" | "containerfile" | "makefile" | "mk" | "cmake" - | "diff" | "patch" => FileKind::Code, + "rs" | "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" | "py" | "pyw" | "pyi" | "go" + | "java" | "kt" | "kts" | "scala" | "groovy" | "gradle" | "swift" | "m" | "mm" | "c" + | "h" | "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "cs" | "fs" | "fsx" | "vb" | "rb" + | "erb" | "php" | "phtml" | "lua" | "pl" | "pm" | "dart" | "r" | "jl" | "clj" | "cljs" + | "cljc" | "edn" | "ex" | "exs" | "eex" | "erl" | "hrl" | "hs" | "lhs" | "purs" | "elm" + | "nim" | "zig" | "ml" | "mli" | "ocaml" | "html" | "htm" | "xhtml" | "vue" | "svelte" + | "css" | "scss" | "sass" | "less" | "styl" | "json" | "json5" | "jsonc" | "toml" + | "yaml" | "yml" | "xml" | "plist" | "sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" + | "cmd" | "sql" | "graphql" | "gql" | "proto" | "thrift" | "tf" | "tfvars" | "hcl" + | "nix" | "dockerfile" | "containerfile" | "makefile" | "mk" | "cmake" | "diff" + | "patch" => FileKind::Code, // Plain text without highlighting (still gets line numbers in the preview). - "txt" | "log" | "ini" | "conf" | "cfg" | "env" | "properties" - | "lock" | "gitignore" | "gitattributes" | "editorconfig" - | "csv" | "tsv" => FileKind::Text, + "txt" | "log" | "ini" | "conf" | "cfg" | "env" | "properties" | "lock" | "gitignore" + | "gitattributes" | "editorconfig" | "csv" | "tsv" => FileKind::Text, _ => FileKind::Binary, } } @@ -447,9 +440,18 @@ mod tests { #[test] fn classify_policy_matches_known_stems() { - assert!(matches!(classify_policy("license"), Some(PolicyKind::License))); - assert!(matches!(classify_policy("licence"), Some(PolicyKind::License))); - assert!(matches!(classify_policy("copying"), Some(PolicyKind::License))); + assert!(matches!( + classify_policy("license"), + Some(PolicyKind::License) + )); + assert!(matches!( + classify_policy("licence"), + Some(PolicyKind::License) + )); + assert!(matches!( + classify_policy("copying"), + Some(PolicyKind::License) + )); assert!(matches!( classify_policy("contributing"), Some(PolicyKind::Contributing) @@ -474,7 +476,10 @@ mod tests { classify_policy("changelog"), Some(PolicyKind::Changelog) )); - assert!(matches!(classify_policy("readme"), Some(PolicyKind::Readme))); + assert!(matches!( + classify_policy("readme"), + Some(PolicyKind::Readme) + )); assert!(classify_policy("random").is_none()); } @@ -498,10 +503,7 @@ mod tests { let root = tmp.to_string_lossy().into_owned(); let meta = stat_workspace_file(root, "CONTRIBUTING.md".into()).unwrap(); assert!(matches!(meta.kind, FileKind::Markdown)); - assert!(matches!( - meta.policy_kind, - Some(PolicyKind::Contributing) - )); + assert!(matches!(meta.policy_kind, Some(PolicyKind::Contributing))); let _ = fs::remove_dir_all(tmp); } diff --git a/src-tauri/src/git_graph.rs b/src-tauri/src/git_graph.rs index e8cc610..0b1e41a 100644 --- a/src-tauri/src/git_graph.rs +++ b/src-tauri/src/git_graph.rs @@ -77,9 +77,8 @@ pub fn git_commit_graph(cwd: String, limit: Option) -> Result Result { - let pretty = format!( - "%x1e%H{FIELD_SEP}%P{FIELD_SEP}%s{FIELD_SEP}%an{FIELD_SEP}%ar{FIELD_SEP}%D%x02" - ); + let pretty = + format!("%x1e%H{FIELD_SEP}%P{FIELD_SEP}%s{FIELD_SEP}%an{FIELD_SEP}%ar{FIELD_SEP}%D%x02"); let out = Command::new("git") .arg("-C") .arg(work_tree) @@ -235,7 +234,9 @@ mod tests { #[test] fn parse_commit_record_fields() { - let rec = format!("deadbeef{FIELD_SEP}{FIELD_SEP}subject{FIELD_SEP}author{FIELD_SEP}1 day ago{FIELD_SEP}"); + let rec = format!( + "deadbeef{FIELD_SEP}{FIELD_SEP}subject{FIELD_SEP}author{FIELD_SEP}1 day ago{FIELD_SEP}" + ); let c = parse_commit_record(&rec).expect("parse"); assert_eq!(c.oid, "deadbeef"); assert_eq!(c.subject, "subject"); diff --git a/src-tauri/src/gitignore.rs b/src-tauri/src/gitignore.rs index f055535..1230f17 100644 --- a/src-tauri/src/gitignore.rs +++ b/src-tauri/src/gitignore.rs @@ -43,8 +43,8 @@ pub fn gitignore_append_blxcode(workspace_cwd: String) -> Result Result { - let env: FalImageEnvelope = serde_json::from_str(body) - .map_err(|e| GenerateError::Parse(format!("fal json: {e}")))?; - let item = env - .images - .into_iter() - .next() - .or(env.image) - .ok_or_else(|| { - GenerateError::Provider( - env.detail - .unwrap_or_else(|| "fal returned no images".into()), - ) - })?; + let env: FalImageEnvelope = + serde_json::from_str(body).map_err(|e| GenerateError::Parse(format!("fal json: {e}")))?; + let item = env.images.into_iter().next().or(env.image).ok_or_else(|| { + GenerateError::Provider( + env.detail + .unwrap_or_else(|| "fal returned no images".into()), + ) + })?; let url = item .url .ok_or_else(|| GenerateError::Provider("fal image had no url".into()))?; @@ -594,13 +587,19 @@ fn parse_openrouter_envelope(body: &str) -> Result) -> Option { let value = content?; if let Some(s) = value.as_str() { - return Some(format!("openrouter returned no image: {}", truncate(s, 240))); + return Some(format!( + "openrouter returned no image: {}", + truncate(s, 240) + )); } if let Some(arr) = value.as_array() { for block in arr { if block.get("type").and_then(|t| t.as_str()) == Some("text") { if let Some(t) = block.get("text").and_then(|t| t.as_str()) { - return Some(format!("openrouter returned no image: {}", truncate(t, 240))); + return Some(format!( + "openrouter returned no image: {}", + truncate(t, 240) + )); } } } @@ -628,9 +627,7 @@ fn decode_data_url(url: &str) -> Result<(String, Vec), GenerateError> { } } if !is_b64 { - return Err(GenerateError::Parse( - "data URL not base64-encoded".into(), - )); + return Err(GenerateError::Parse("data URL not base64-encoded".into())); } let bytes = BASE64 .decode(payload.as_bytes()) diff --git a/src-tauri/src/image/settings.rs b/src-tauri/src/image/settings.rs index 4f78d0e..e0de2df 100644 --- a/src-tauri/src/image/settings.rs +++ b/src-tauri/src/image/settings.rs @@ -141,13 +141,14 @@ pub fn provider_key(app: &AppHandle, provider: ImageProviderKind) -> Result { agent_settings::provider_key_pub(app, agent_settings::AgentProviderKind::Openrouter) } - ImageProviderKind::Fal => crate::media_keys::resolve_key(crate::media_keys::MediaKeyKind::Fal) - .ok_or_else(|| { + ImageProviderKind::Fal => { + crate::media_keys::resolve_key(crate::media_keys::MediaKeyKind::Fal).ok_or_else(|| { format!( "fal.ai API key missing — set it in Settings → API Keys (env: {})", crate::media_keys::MediaKeyKind::Fal.env_var() ) - }), + }) + } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1719e55..457a8ed 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -37,8 +37,8 @@ use updater::{ BlxUpdaterState, }; use voice::{ - voice_cancel_recording, voice_settings_get, voice_settings_save, - voice_start_recording, voice_stop_and_transcribe, voice_tts_preview, VoiceRecorderState, + voice_cancel_recording, voice_settings_get, voice_settings_save, voice_start_recording, + voice_stop_and_transcribe, voice_tts_preview, VoiceRecorderState, }; use workbench_state::{ agent_latest_session_id, agent_session_exists, workbench_clear_terminal_notifications, @@ -183,11 +183,12 @@ pub fn run() { gitignore::gitignore_append_blxcode, memory::workspace_ensure_agents, memory::memory_root, + memory::memory_status, + memory::memory_bootstrap, memory::memory_list, memory::memory_read, memory::memory_write, memory::memory_create, - memory::memory_list_categories, memory::memory_create_category, memory::memory_delete, memory::memory_rename, diff --git a/src-tauri/src/memory.rs b/src-tauri/src/memory.rs deleted file mode 100644 index 5c755a5..0000000 --- a/src-tauri/src/memory.rs +++ /dev/null @@ -1,1276 +0,0 @@ -//! Workspace-scoped Obsidian-style memory. -//! -//! Layout per workspace: -//! -//! ```text -//! /.agents/memory/ — user notes (+ _templates/) -//! /.agents/learnings/ — durable repo learnings (API: `learnings/…`) -//! ``` -//! -//! Legacy `.blxcode/memory/` is migrated once into `.agents/memory/` when empty. -//! All Tauri commands take the workspace cwd; API paths are sandboxed. -//! -//! Link syntax (Obsidian-compatible subset): -//! `[[Note Name]]` — link to `Note Name.md` (by basename, case-insensitive) -//! `[[folder/Note]]` — explicit relative path -//! `[[Note Name|alias]]` — display alias (ignored for graph) -//! `#tag` — tag (graph metadata) - -use crate::agents_layout::{ - ensure_agents_layout, validate_workspace_cwd, WorkspaceRoots, LEARNINGS_API_PREFIX, - LEARNINGS_REL, MEMORY_REL, -}; -use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::fs; -use std::io::Write; -use std::path::{Component, Path, PathBuf}; - -const TEMPLATES_DIRNAME: &str = "_templates"; - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct NoteMeta { - /// Relative path within the memory root, forward slashes, includes `.md`. - pub path: String, - /// Basename without extension. - pub name: String, - /// File size in bytes. - pub size: u64, - /// Last-modified time as seconds since UNIX epoch. - pub modified: i64, - /// True if under the `_templates/` subdir. - pub is_template: bool, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct NoteContent { - pub path: String, - pub content: String, - pub modified: i64, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GraphNode { - pub id: String, - pub label: String, - pub tags: Vec, - pub orphan: bool, - /// Category key derived from the API path (first segment under - /// `.agents/memory/`, or `learnings` for the learnings root, or - /// `memory` for top-level memory notes). - pub category: String, -} - -fn graph_category_for(api_path: &str) -> String { - if api_path.starts_with(LEARNINGS_API_PREFIX) { - return "learnings".to_string(); - } - if let Some((head, _)) = api_path.split_once('/') { - if !head.is_empty() { - return head.to_string(); - } - } - "memory".to_string() -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GraphEdge { - pub source: String, - pub target: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct GraphData { - pub nodes: Vec, - pub edges: Vec, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SearchHit { - pub path: String, - pub line: u32, - pub snippet: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct PointerResult { - pub agent: String, - pub path: String, - pub installed: bool, - pub note: Option, -} - -fn err(s: impl Into) -> Result { - Err(s.into()) -} - -fn ensure_workspace_memory(ws: &str) -> Result { - ensure_agents_layout(ws) -} - -fn resolve_api_path(api_path: &str) -> Result<(bool, String), String> { - let p = api_path.trim().replace('\\', "/"); - if p.is_empty() { - return err("empty path"); - } - if p.starts_with(LEARNINGS_API_PREFIX) { - let rel = p - .strip_prefix(LEARNINGS_API_PREFIX) - .unwrap_or_default() - .trim(); - if rel.is_empty() || rel.contains("..") { - return err("invalid learnings path"); - } - return Ok((true, rel.to_owned())); - } - if p.contains("..") { - return err("invalid path"); - } - Ok((false, p)) -} - -fn note_root<'a>(roots: &'a WorkspaceRoots, is_learnings: bool) -> &'a Path { - if is_learnings { - roots.learnings.as_path() - } else { - roots.memory.as_path() - } -} - -fn resolve_note_abs(roots: &WorkspaceRoots, api_path: &str) -> Result { - let (is_learnings, rel) = resolve_api_path(api_path)?; - safe_join(note_root(roots, is_learnings), &rel, true) -} - -fn api_path_for(roots: &WorkspaceRoots, file_root: &Path, rel: &str) -> String { - if file_root == roots.learnings.as_path() { - format!("{LEARNINGS_API_PREFIX}{rel}") - } else { - rel.to_owned() - } -} - -fn collect_all_md(roots: &WorkspaceRoots, out: &mut Vec<(String, PathBuf, PathBuf)>) { - for root in [roots.memory.as_path(), roots.learnings.as_path()] { - let mut files = Vec::new(); - walk_md(root, &mut files); - for abs in files { - let Some(rel) = rel_from_root(root, &abs) else { - continue; - }; - let api = api_path_for(roots, root, &rel); - out.push((api, abs, root.to_path_buf())); - } - } -} - -fn meta_from_abs(_roots: &WorkspaceRoots, api_path: &str, abs: &Path, file_root: &Path) -> NoteMeta { - let rel = rel_from_root(file_root, abs).unwrap_or_default(); - let meta = fs::metadata(abs).ok(); - let name = abs - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_owned(); - let is_template = !api_path.starts_with(LEARNINGS_API_PREFIX) - && rel.starts_with(&format!("{TEMPLATES_DIRNAME}/")); - NoteMeta { - path: api_path.to_owned(), - name, - size: meta.as_ref().map(|m| m.len()).unwrap_or(0), - modified: mtime_secs(abs), - is_template, - } -} - -/// Validates `rel` is a clean relative path with no `..` or absolute -/// segments, normalises slashes to forward, and returns the absolute -/// path inside `root`. Also enforces `.md` extension when `enforce_md`. -fn safe_join(root: &Path, rel: &str, enforce_md: bool) -> Result { - let rel = rel.trim().trim_start_matches('/').trim_start_matches('\\'); - if rel.is_empty() { - return err("empty relative path"); - } - let normalized = rel.replace('\\', "/"); - let candidate = PathBuf::from(&normalized); - for c in candidate.components() { - match c { - Component::Normal(_) => {} - _ => return err(format!("disallowed path component in {rel}")), - } - } - let abs = root.join(&candidate); - // Cheap defense-in-depth: re-check the joined path lies under root. - let canon_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); - let canon_abs = abs - .parent() - .and_then(|p| fs::canonicalize(p).ok()) - .map(|p| p.join(abs.file_name().unwrap_or_default())) - .unwrap_or(abs.clone()); - if !canon_abs.starts_with(&canon_root) { - return err("path escapes memory root"); - } - if enforce_md { - let is_md = canon_abs - .extension() - .and_then(|e| e.to_str()) - .map(|e| e.eq_ignore_ascii_case("md")) - .unwrap_or(false); - if !is_md { - return err("only .md files allowed"); - } - } - Ok(abs) -} - -fn rel_from_root(root: &Path, p: &Path) -> Option { - p.strip_prefix(root) - .ok() - .map(|rel| rel.to_string_lossy().replace('\\', "/")) -} - -fn mtime_secs(p: &Path) -> i64 { - fs::metadata(p) - .and_then(|m| m.modified()) - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -fn walk_md(root: &Path, out: &mut Vec) { - let Ok(read) = fs::read_dir(root) else { return }; - for entry in read.flatten() { - let path = entry.path(); - let Ok(ft) = entry.file_type() else { continue }; - if ft.is_dir() { - walk_md(&path, out); - continue; - } - if ft.is_file() { - let is_md = path - .extension() - .and_then(|e| e.to_str()) - .map(|e| e.eq_ignore_ascii_case("md")) - .unwrap_or(false); - if is_md { - out.push(path); - } - } - } -} - -#[tauri::command] -pub fn workspace_ensure_agents(workspace_cwd: String) -> Result<(), String> { - ensure_agents_layout(&workspace_cwd)?; - Ok(()) -} - -#[tauri::command] -pub fn memory_root(workspace_cwd: String) -> Result { - let roots = ensure_workspace_memory(&workspace_cwd)?; - Ok(roots.memory.to_string_lossy().into_owned()) -} - -#[tauri::command] -pub fn memory_list(workspace_cwd: String) -> Result, String> { - let roots = ensure_workspace_memory(&workspace_cwd)?; - let mut collected = Vec::new(); - collect_all_md(&roots, &mut collected); - let mut out: Vec = collected - .into_iter() - .map(|(api, abs, file_root)| meta_from_abs(&roots, &api, &abs, &file_root)) - .collect(); - out.sort_by(|a, b| a.path.to_lowercase().cmp(&b.path.to_lowercase())); - Ok(out) -} - -#[tauri::command] -pub fn memory_read(workspace_cwd: String, path: String) -> Result { - let roots = ensure_workspace_memory(&workspace_cwd)?; - let abs = resolve_note_abs(&roots, &path)?; - let content = fs::read_to_string(&abs).map_err(|e| format!("read {path}: {e}"))?; - Ok(NoteContent { - path, - content, - modified: mtime_secs(&abs), - }) -} - -#[tauri::command] -pub fn memory_write( - workspace_cwd: String, - path: String, - content: String, -) -> Result { - let roots = ensure_workspace_memory(&workspace_cwd)?; - let abs = resolve_note_abs(&roots, &path)?; - if let Some(parent) = abs.parent() { - fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?; - } - let mut file = fs::File::create(&abs).map_err(|e| format!("create {path}: {e}"))?; - file.write_all(content.as_bytes()) - .map_err(|e| format!("write {path}: {e}"))?; - Ok(NoteContent { - path, - content, - modified: mtime_secs(&abs), - }) -} - -#[tauri::command] -pub fn memory_create( - workspace_cwd: String, - path: String, - content: Option, -) -> Result { - let roots = ensure_workspace_memory(&workspace_cwd)?; - let abs = resolve_note_abs(&roots, &path)?; - if abs.exists() { - return err(format!("already exists: {path}")); - } - if let Some(parent) = abs.parent() { - fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?; - } - let body = content.unwrap_or_default(); - fs::write(&abs, body.as_bytes()).map_err(|e| format!("write {path}: {e}"))?; - let (is_learnings, _) = resolve_api_path(&path)?; - let file_root = note_root(&roots, is_learnings); - Ok(meta_from_abs(&roots, &path, &abs, file_root)) -} - -/// Marker file dropped into a freshly created empty category so it survives -/// `git`/cleanup without any notes. `memory_list` ignores it because it's not `.md`. -const CATEGORY_PLACEHOLDER: &str = ".gitkeep"; - -fn is_reserved_category(name: &str) -> bool { - name.eq_ignore_ascii_case(TEMPLATES_DIRNAME) - || name.eq_ignore_ascii_case("memory") - || name.eq_ignore_ascii_case("learnings") -} - -fn validate_category_name(name: &str) -> Result { - let trimmed = name.trim(); - if trimmed.is_empty() { - return err("empty category name"); - } - if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") { - return err("invalid category name"); - } - if is_reserved_category(trimmed) { - return err(format!("reserved category name: {trimmed}")); - } - Ok(trimmed.to_owned()) -} - -#[tauri::command] -pub fn memory_list_categories(workspace_cwd: String) -> Result, String> { - let roots = ensure_workspace_memory(&workspace_cwd)?; - let mut out: Vec = Vec::new(); - let Ok(read) = fs::read_dir(roots.memory.as_path()) else { - return Ok(out); - }; - for entry in read.flatten() { - let Ok(ft) = entry.file_type() else { continue }; - if !ft.is_dir() { - continue; - } - let Some(name) = entry.file_name().to_str().map(str::to_owned) else { - continue; - }; - if name.starts_with('.') || name.eq_ignore_ascii_case(TEMPLATES_DIRNAME) { - continue; - } - out.push(name); - } - out.sort_unstable_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); - Ok(out) -} - -#[tauri::command] -pub fn memory_create_category(workspace_cwd: String, name: String) -> Result { - let roots = ensure_workspace_memory(&workspace_cwd)?; - let clean = validate_category_name(&name)?; - let dir = roots.memory.join(&clean); - if dir.exists() { - return err(format!("already exists: {clean}")); - } - fs::create_dir_all(&dir).map_err(|e| format!("mkdir: {e}"))?; - let placeholder = dir.join(CATEGORY_PLACEHOLDER); - let _ = fs::write(&placeholder, b""); - Ok(clean) -} - -#[tauri::command] -pub fn memory_delete(workspace_cwd: String, path: String) -> Result<(), String> { - let roots = ensure_workspace_memory(&workspace_cwd)?; - let (is_learnings, _) = resolve_api_path(&path)?; - let root = note_root(&roots, is_learnings); - let abs = resolve_note_abs(&roots, &path)?; - if !abs.exists() { - return err(format!("not found: {path}")); - } - fs::remove_file(&abs).map_err(|e| format!("delete {path}: {e}"))?; - // best-effort: remove empty parent dirs up to root - if let Some(mut parent) = abs.parent() { - while parent != root { - if fs::read_dir(parent) - .map(|mut r| r.next().is_none()) - .unwrap_or(false) - { - let _ = fs::remove_dir(parent); - if let Some(grand) = parent.parent() { - parent = grand; - } else { - break; - } - } else { - break; - } - } - } - Ok(()) -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct RenameReport { - pub old_path: String, - pub new_path: String, - pub link_rewrites: u32, - pub files_changed: u32, -} - -#[tauri::command] -pub fn memory_rename( - workspace_cwd: String, - old_path: String, - new_path: String, - rewrite_links: bool, -) -> Result { - let roots = ensure_workspace_memory(&workspace_cwd)?; - let (old_learn, _) = resolve_api_path(&old_path)?; - let (new_learn, _) = resolve_api_path(&new_path)?; - if old_learn != new_learn { - return err("cannot rename across memory and learnings roots"); - } - let abs_old = resolve_note_abs(&roots, &old_path)?; - let abs_new = resolve_note_abs(&roots, &new_path)?; - if !abs_old.exists() { - return err(format!("not found: {old_path}")); - } - if abs_new.exists() { - return err(format!("already exists: {new_path}")); - } - if let Some(parent) = abs_new.parent() { - fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?; - } - fs::rename(&abs_old, &abs_new).map_err(|e| format!("rename: {e}"))?; - - let old_basename = Path::new(&old_path) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_owned(); - let new_basename = Path::new(&new_path) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_owned(); - - let mut total_rewrites = 0u32; - let mut files_changed = 0u32; - - if rewrite_links && !old_basename.is_empty() && !new_basename.is_empty() { - let mut collected = Vec::new(); - collect_all_md(&roots, &mut collected); - for (_, f, _) in collected { - let Ok(content) = fs::read_to_string(&f) else { - continue; - }; - let (updated, n) = - rewrite_wikilinks(&content, &old_basename, &new_basename, &old_path, &new_path); - if n > 0 { - if fs::write(&f, updated.as_bytes()).is_ok() { - total_rewrites += n; - files_changed += 1; - } - } - } - } - Ok(RenameReport { - old_path, - new_path, - link_rewrites: total_rewrites, - files_changed, - }) -} - -/// Rewrites `[[old_basename|...]]` -> `[[new_basename|...]]` and -/// `[[old_path_without_ext|...]]` -> `[[new_path_without_ext|...]]`. -/// Returns (new_content, count). -fn rewrite_wikilinks( - content: &str, - old_basename: &str, - new_basename: &str, - old_path: &str, - new_path: &str, -) -> (String, u32) { - let old_pwx = strip_md_ext(old_path); - let new_pwx = strip_md_ext(new_path); - let mut out = String::with_capacity(content.len()); - let mut i = 0usize; - let bytes = content.as_bytes(); - let mut count = 0u32; - while i < bytes.len() { - if i + 1 < bytes.len() && bytes[i] == b'[' && bytes[i + 1] == b'[' { - if let Some(end) = find_close_link(&content[i + 2..]) { - let inner = &content[i + 2..i + 2 + end]; - // split target|alias - let (target, alias) = match inner.find('|') { - Some(j) => (&inner[..j], Some(&inner[j + 1..])), - None => (inner, None), - }; - let target_t = target.trim(); - let new_target = if target_t.eq_ignore_ascii_case(old_basename) - || target_t.eq_ignore_ascii_case(&old_pwx) - { - if target_t.contains('/') { - Some(new_pwx.clone()) - } else { - Some(new_basename.to_owned()) - } - } else { - None - }; - if let Some(t) = new_target { - out.push_str("[["); - out.push_str(&t); - if let Some(a) = alias { - out.push('|'); - out.push_str(a); - } - out.push_str("]]"); - count += 1; - } else { - out.push_str(&content[i..i + 2 + end + 2]); - } - i += 2 + end + 2; - continue; - } - } - out.push(content[i..].chars().next().unwrap()); - i += content[i..].chars().next().unwrap().len_utf8(); - } - (out, count) -} - -fn strip_md_ext(p: &str) -> String { - if let Some(idx) = p.rfind('.') { - if p[idx + 1..].eq_ignore_ascii_case("md") { - return p[..idx].to_owned(); - } - } - p.to_owned() -} - -fn find_close_link(s: &str) -> Option { - let bytes = s.as_bytes(); - let mut i = 0; - while i + 1 < bytes.len() { - if bytes[i] == b']' && bytes[i + 1] == b']' { - return Some(i); - } - if bytes[i] == b'\n' { - return None; - } - i += 1; - } - None -} - -/// Extracts `[[link]]` targets (without alias) and `#tag`s from a body. -fn parse_links_and_tags(body: &str) -> (Vec, Vec) { - let mut links: Vec = Vec::new(); - let mut tags: BTreeSet = BTreeSet::new(); - let mut i = 0; - let bytes = body.as_bytes(); - while i < bytes.len() { - if i + 1 < bytes.len() && bytes[i] == b'[' && bytes[i + 1] == b'[' { - if let Some(end) = find_close_link(&body[i + 2..]) { - let inner = &body[i + 2..i + 2 + end]; - let target = match inner.find('|') { - Some(j) => &inner[..j], - None => inner, - }; - let t = target.trim(); - if !t.is_empty() { - links.push(t.to_owned()); - } - i += 2 + end + 2; - continue; - } - } - if bytes[i] == b'#' { - let prev = if i == 0 { b'\n' } else { bytes[i - 1] }; - // tag must follow whitespace or start of line; skip markdown headings - if prev == b'\n' || prev == b' ' || prev == b'\t' { - let mut j = i + 1; - while j < bytes.len() { - let c = bytes[j]; - let ok = c.is_ascii_alphanumeric() || c == b'-' || c == b'_' || c == b'/'; - if !ok { - break; - } - j += 1; - } - // Heading `# Foo` => j == i+1 with space after; require at least 1 char - // and that the char after '#' is not space (so headings are excluded). - if j > i + 1 { - let after_hash = bytes[i + 1]; - if after_hash != b' ' { - let tag = &body[i + 1..j]; - if !tag.is_empty() && !tag.chars().all(|c| c.is_ascii_digit()) { - tags.insert(tag.to_owned()); - } - } - } - i = j.max(i + 1); - continue; - } - } - i += 1; - } - (links, tags.into_iter().collect()) -} - -#[tauri::command] -pub fn memory_graph(workspace_cwd: String) -> Result { - let roots = ensure_workspace_memory(&workspace_cwd)?; - let mut collected = Vec::new(); - collect_all_md(&roots, &mut collected); - collected.retain(|(api, _, _)| !api.starts_with(&format!("{TEMPLATES_DIRNAME}/"))); - - let mut by_basename: HashMap> = HashMap::new(); - let mut by_pwx: HashMap = HashMap::new(); - let mut bodies: BTreeMap = BTreeMap::new(); - - for (api, f, _) in &collected { - if let Some(stem) = Path::new(api) - .file_stem() - .and_then(|s| s.to_str()) - { - by_basename - .entry(stem.to_ascii_lowercase()) - .or_default() - .push(api.clone()); - } - by_pwx.insert(strip_md_ext(api).to_ascii_lowercase(), api.clone()); - if let Ok(body) = fs::read_to_string(f) { - bodies.insert(api.clone(), body); - } - } - - let mut nodes: Vec = Vec::new(); - let mut edges: Vec = Vec::new(); - let mut has_edge: BTreeSet = BTreeSet::new(); - - for (rel, body) in &bodies { - let (links, tags) = parse_links_and_tags(body); - let mut node_tags = tags; - node_tags.sort(); - node_tags.dedup(); - let stem = Path::new(rel) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_owned(); - let category = graph_category_for(rel); - nodes.push(GraphNode { - id: rel.clone(), - label: stem, - tags: node_tags, - orphan: false, - category, - }); - for raw in links { - let lower = raw.to_ascii_lowercase(); - let target = if let Some(t) = by_pwx.get(&lower) { - Some(t.clone()) - } else if let Some(list) = by_basename.get(&lower) { - list.first().cloned() - } else { - None - }; - if let Some(t) = target { - if t != *rel { - has_edge.insert(rel.clone()); - has_edge.insert(t.clone()); - edges.push(GraphEdge { - source: rel.clone(), - target: t, - }); - } - } - } - } - - for n in nodes.iter_mut() { - n.orphan = !has_edge.contains(&n.id); - } - - Ok(GraphData { nodes, edges }) -} - -#[tauri::command] -pub fn memory_backlinks(workspace_cwd: String, path: String) -> Result, String> { - let g = memory_graph(workspace_cwd)?; - let mut out: Vec = g - .edges - .into_iter() - .filter_map(|e| { - if e.target == path { - Some(e.source) - } else { - None - } - }) - .collect(); - out.sort(); - out.dedup(); - Ok(out) -} - -#[tauri::command] -pub fn memory_search(workspace_cwd: String, query: String) -> Result, String> { - let roots = ensure_workspace_memory(&workspace_cwd)?; - let needle = query.trim(); - if needle.is_empty() { - return Ok(Vec::new()); - } - let needle_l = needle.to_ascii_lowercase(); - let mut collected = Vec::new(); - collect_all_md(&roots, &mut collected); - let mut hits: Vec = Vec::new(); - for (api, f, _) in collected { - let Ok(body) = fs::read_to_string(&f) else { - continue; - }; - for (idx, line) in body.lines().enumerate() { - if line.to_ascii_lowercase().contains(&needle_l) { - let snip = if line.len() > 200 { &line[..200] } else { line }; - hits.push(SearchHit { - path: api.clone(), - line: (idx + 1) as u32, - snippet: snip.to_owned(), - }); - if hits.len() >= 500 { - return Ok(hits); - } - } - } - } - Ok(hits) -} - -#[tauri::command] -pub fn memory_export(workspace_cwd: String, dest_dir: String) -> Result { - let roots = ensure_workspace_memory(&workspace_cwd)?; - let dest = PathBuf::from(dest_dir.trim()); - if !dest.is_absolute() { - return err("dest_dir must be absolute"); - } - fs::create_dir_all(&dest).map_err(|e| format!("mkdir dest: {e}"))?; - let mut n = 0u32; - for (root, sub) in [ - (roots.memory.as_path(), "memory"), - (roots.learnings.as_path(), "learnings"), - ] { - let mut files = Vec::new(); - walk_md(root, &mut files); - for f in files { - let Some(rel) = rel_from_root(root, &f) else { - continue; - }; - let target = dest.join(sub).join(&rel); - if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?; - } - fs::copy(&f, &target).map_err(|e| format!("copy {rel}: {e}"))?; - n += 1; - } - } - Ok(n) -} - -#[tauri::command] -pub fn memory_import(workspace_cwd: String, src_dir: String) -> Result { - let roots = ensure_workspace_memory(&workspace_cwd)?; - let src = PathBuf::from(src_dir.trim()); - if !src.is_absolute() { - return err("src_dir must be absolute"); - } - if !src.exists() { - return err("src_dir does not exist"); - } - let mut n = 0u32; - let learnings_src = src.join("learnings"); - let memory_src = src.join("memory"); - if learnings_src.is_dir() { - n += import_tree_into_root(&learnings_src, &roots.learnings)?; - } - if memory_src.is_dir() { - n += import_tree_into_root(&memory_src, &roots.memory)?; - } else if !learnings_src.is_dir() { - n += import_tree_into_root(&src, &roots.memory)?; - } - Ok(n) -} - -fn import_tree_into_root(src: &Path, dest_root: &Path) -> Result { - let mut files = Vec::new(); - walk_md(src, &mut files); - let mut n = 0u32; - for f in files { - let Ok(rel_pb) = f.strip_prefix(src) else { - continue; - }; - let rel = rel_pb.to_string_lossy().replace('\\', "/"); - let abs = match safe_join(dest_root, &rel, true) { - Ok(p) => p, - Err(_) => continue, - }; - if abs.exists() { - continue; - } - if let Some(parent) = abs.parent() { - fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?; - } - fs::copy(&f, &abs).map_err(|e| format!("copy {rel}: {e}"))?; - n += 1; - } - Ok(n) -} - -// --------------------------------------------------------------------- -// Phase 4: agent pointer files. -// -// Each supported agent CLI has its own "memory" convention. blxcode can -// manage a short pointer block inside those files, but must never create -// them implicitly or modify unrelated user-owned content. -// -// claude -> CLAUDE.md -// codex -> AGENTS.md -// gemini -> GEMINI.md -// cursor -> .cursorrules -// opencode -> AGENTS.md (shares with codex; we write once) -// -// The block is delimited by a marker so re-installs replace cleanly -// instead of duplicating content. Anything outside the markers is -// preserved. - -const POINTER_BEGIN: &str = ""; -const POINTER_END: &str = ""; -const POINTER_BEGIN_CURSOR: &str = "# blxcode-memory:begin"; -const POINTER_END_CURSOR: &str = "# blxcode-memory:end"; - -fn pointer_filename(agent: &str) -> Option<&'static str> { - match agent { - "claude" => Some("CLAUDE.md"), - "codex" => Some("AGENTS.md"), - "gemini" => Some("GEMINI.md"), - "cursor" => Some(".cursorrules"), - "opencode" => Some("AGENTS.md"), - _ => None, - } -} - -fn pointer_body(workspace_cwd: &Path, notes: &[NoteMeta], cursor_style: bool) -> String { - let memory_dir = workspace_cwd.join(MEMORY_REL); - let learnings_dir = workspace_cwd.join(LEARNINGS_REL); - let mut s = String::new(); - if cursor_style { - s.push_str("blxcode tracks per-workspace memory and learnings at the paths below.\n"); - } else { - s.push_str("## blxcode workspace memory\n\n"); - s.push_str( - "This workspace uses **blxcode** to maintain Markdown notes and learnings \ -shared across all agent sessions. Treat the directories below as authoritative \ -context: read notes that are relevant to the task, and propose new notes \ -or edits when you learn something the team should remember.\n\n", - ); - } - s.push_str(&format!("Memory root: `{}`\n", memory_dir.display())); - s.push_str(&format!( - "Learnings root: `{}` (API paths: `learnings/…`)\n\n", - learnings_dir.display() - )); - if notes.is_empty() { - s.push_str("(no notes yet)\n"); - } else { - s.push_str("Current notes:\n"); - let mut shown = 0; - for n in notes { - if n.is_template { - continue; - } - s.push_str(&format!("- `{}`\n", n.path)); - shown += 1; - if shown >= 80 { - s.push_str(&format!("- … and {} more\n", notes.len() - shown)); - break; - } - } - } - s.push('\n'); - s -} - -fn splice_block(existing: &str, begin: &str, end: &str, new_body: &str) -> String { - let block = format!("{begin}\n{new_body}{end}\n"); - if let (Some(bi), Some(ei)) = (existing.find(begin), existing.find(end)) { - let ei_end = ei + end.len(); - let tail_start = if ei_end < existing.len() && existing.as_bytes()[ei_end] == b'\n' { - ei_end + 1 - } else { - ei_end - }; - let head = &existing[..bi]; - let tail = &existing[tail_start..]; - let mut out = String::with_capacity(head.len() + block.len() + tail.len()); - out.push_str(head); - out.push_str(&block); - out.push_str(tail); - return out; - } - let mut out = String::new(); - out.push_str(existing); - if !existing.is_empty() && !existing.ends_with('\n') { - out.push('\n'); - } - if !existing.is_empty() { - out.push('\n'); - } - out.push_str(&block); - out -} - -fn strip_block(existing: &str, begin: &str, end: &str) -> String { - if let (Some(bi), Some(ei)) = (existing.find(begin), existing.find(end)) { - let ei_end = ei + end.len(); - let tail_start = if ei_end < existing.len() && existing.as_bytes()[ei_end] == b'\n' { - ei_end + 1 - } else { - ei_end - }; - let mut out = String::new(); - out.push_str(&existing[..bi]); - out.push_str(&existing[tail_start..]); - return out.trim_end_matches('\n').to_owned() + "\n"; - } - existing.to_owned() -} - -#[tauri::command] -pub fn memory_install_pointers( - workspace_cwd: String, - agents: Vec, -) -> Result, String> { - let ws = validate_workspace_cwd(&workspace_cwd)?; - let _ = ensure_workspace_memory(&workspace_cwd)?; - let notes = memory_list(workspace_cwd.clone()).unwrap_or_default(); - let mut results: Vec = Vec::new(); - let mut written_files: BTreeSet = BTreeSet::new(); - for agent in agents { - let Some(fname) = pointer_filename(&agent) else { - results.push(PointerResult { - agent, - path: String::new(), - installed: false, - note: Some("unknown agent".into()), - }); - continue; - }; - let path = ws.join(fname); - if written_files.contains(fname) { - // codex+opencode share AGENTS.md; report second one as skipped - results.push(PointerResult { - agent, - path: path.to_string_lossy().into_owned(), - installed: false, - note: Some("shared file already handled".into()), - }); - continue; - } - if !path.exists() { - results.push(PointerResult { - agent, - path: path.to_string_lossy().into_owned(), - installed: false, - note: Some( - "skipped: file absent; blxcode does not auto-create root pointer files".into(), - ), - }); - written_files.insert(fname.to_owned()); - continue; - } - let cursor_style = agent == "cursor"; - let body = pointer_body(&ws, ¬es, cursor_style); - let (begin, end) = if cursor_style { - (POINTER_BEGIN_CURSOR, POINTER_END_CURSOR) - } else { - (POINTER_BEGIN, POINTER_END) - }; - let existing = fs::read_to_string(&path).unwrap_or_default(); - if !existing.contains(begin) { - results.push(PointerResult { - agent, - path: path.to_string_lossy().into_owned(), - installed: false, - note: Some( - "skipped: existing file is user-owned and has no blxcode managed block".into(), - ), - }); - written_files.insert(fname.to_owned()); - continue; - } - let updated = splice_block(&existing, begin, end, &body); - match fs::write(&path, updated.as_bytes()) { - Ok(()) => { - written_files.insert(fname.to_owned()); - results.push(PointerResult { - agent, - path: path.to_string_lossy().into_owned(), - installed: true, - note: None, - }); - } - Err(e) => results.push(PointerResult { - agent, - path: path.to_string_lossy().into_owned(), - installed: false, - note: Some(format!("write failed: {e}")), - }), - } - } - Ok(results) -} - -#[tauri::command] -pub fn memory_uninstall_pointers( - workspace_cwd: String, - agents: Vec, -) -> Result, String> { - let ws = validate_workspace_cwd(&workspace_cwd)?; - let mut results = Vec::new(); - let mut handled: BTreeSet = BTreeSet::new(); - for agent in agents { - let Some(fname) = pointer_filename(&agent) else { - results.push(PointerResult { - agent, - path: String::new(), - installed: false, - note: Some("unknown agent".into()), - }); - continue; - }; - if handled.contains(fname) { - results.push(PointerResult { - agent, - path: ws.join(fname).to_string_lossy().into_owned(), - installed: false, - note: Some("shared file already cleaned".into()), - }); - continue; - } - let path = ws.join(fname); - let cursor_style = agent == "cursor"; - let (begin, end) = if cursor_style { - (POINTER_BEGIN_CURSOR, POINTER_END_CURSOR) - } else { - (POINTER_BEGIN, POINTER_END) - }; - let existing = fs::read_to_string(&path).unwrap_or_default(); - if existing.is_empty() { - results.push(PointerResult { - agent, - path: path.to_string_lossy().into_owned(), - installed: false, - note: Some("no file".into()), - }); - continue; - } - let stripped = strip_block(&existing, begin, end); - if stripped.trim().is_empty() { - let _ = fs::remove_file(&path); - } else if let Err(e) = fs::write(&path, stripped.as_bytes()) { - results.push(PointerResult { - agent, - path: path.to_string_lossy().into_owned(), - installed: false, - note: Some(format!("write failed: {e}")), - }); - continue; - } - handled.insert(fname.to_owned()); - results.push(PointerResult { - agent, - path: path.to_string_lossy().into_owned(), - installed: false, - note: Some("removed".into()), - }); - } - Ok(results) -} - -#[tauri::command] -pub fn memory_pointer_status(workspace_cwd: String) -> Result, String> { - let ws = validate_workspace_cwd(&workspace_cwd)?; - let agents = ["claude", "codex", "gemini", "cursor", "opencode"]; - let mut out = Vec::new(); - for a in agents { - let Some(fname) = pointer_filename(a) else { - continue; - }; - let path = ws.join(fname); - let body = fs::read_to_string(&path).unwrap_or_default(); - let cursor_style = a == "cursor"; - let begin = if cursor_style { - POINTER_BEGIN_CURSOR - } else { - POINTER_BEGIN - }; - let installed = body.contains(begin); - out.push(PointerResult { - agent: a.to_owned(), - path: path.to_string_lossy().into_owned(), - installed, - note: None, - }); - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::agents_layout::{LEGACY_MEMORY_REL, LEARNINGS_REL}; - - #[test] - fn safe_join_rejects_traversal() { - let tmp = std::env::temp_dir().join("blxcode_memtest_safe"); - let _ = fs::create_dir_all(&tmp); - assert!(safe_join(&tmp, "../etc/passwd", false).is_err()); - // Leading slash is stripped and treated as relative — the safety - // invariant is "result stays under root", not "reject the input". - assert!(safe_join(&tmp, "/abs/note.md", true).is_ok()); - assert!(safe_join(&tmp, "ok/file.md", true).is_ok()); - assert!(safe_join(&tmp, "ok/file.txt", true).is_err()); - assert!(safe_join(&tmp, "ok/../../../etc/passwd", true).is_err()); - } - - #[test] - fn rewrite_wikilinks_basic() { - let body = "see [[Old]] and [[Old|label]] but not [[Other]]"; - let (out, n) = rewrite_wikilinks(body, "Old", "New", "Old.md", "New.md"); - assert_eq!(n, 2); - assert!(out.contains("[[New]]")); - assert!(out.contains("[[New|label]]")); - assert!(out.contains("[[Other]]")); - } - - #[test] - fn parse_tags_skips_headings() { - let (_, tags) = parse_links_and_tags("# Heading\n\nText with #foo and #bar-baz here"); - assert!(tags.contains(&"foo".to_owned())); - assert!(tags.contains(&"bar-baz".to_owned())); - assert!(!tags.iter().any(|t| t == "Heading")); - } - - #[test] - fn memory_list_includes_learnings_and_legacy_migrates() { - let ws = std::env::temp_dir().join(format!( - "blxcode_mem_multi_{}_{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let _ = fs::remove_dir_all(&ws); - fs::create_dir_all(&ws).unwrap(); - fs::create_dir_all(ws.join(LEARNINGS_REL)).unwrap(); - fs::write( - ws.join(LEARNINGS_REL).join("LEARNINGS.md"), - "## Index\n\n- [Topic](topic.md)\n", - ) - .unwrap(); - fs::write(ws.join(LEARNINGS_REL).join("topic.md"), "# Topic").unwrap(); - fs::create_dir_all(ws.join(LEGACY_MEMORY_REL)).unwrap(); - fs::write(ws.join(LEGACY_MEMORY_REL).join("legacy.md"), "old").unwrap(); - - let cwd = ws.to_string_lossy().into_owned(); - let list = memory_list(cwd.clone()).unwrap(); - assert!(list.iter().any(|n| n.path == "learnings/topic.md")); - assert!(list.iter().any(|n| n.path == "legacy.md")); - - let g = memory_graph(cwd).unwrap(); - assert!(g.edges.iter().any(|e| e.source.contains("LEARNINGS") || e.target.contains("topic"))); - - let _ = fs::remove_dir_all(&ws); - } - - #[test] - fn install_pointers_does_not_create_missing_root_files() { - let ws = std::env::temp_dir().join(format!( - "blxcode_memtest_no_create_{}_{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - fs::create_dir_all(&ws).unwrap(); - - let out = memory_install_pointers( - ws.to_string_lossy().into_owned(), - vec!["claude".into(), "codex".into()], - ) - .unwrap(); - - assert!(!ws.join("CLAUDE.md").exists()); - assert!(!ws.join("AGENTS.md").exists()); - assert!(out.iter().all(|r| !r.installed)); - } - - #[test] - fn install_pointers_does_not_touch_unmanaged_existing_files() { - let ws = std::env::temp_dir().join(format!( - "blxcode_memtest_unmanaged_{}_{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - fs::create_dir_all(&ws).unwrap(); - let agents = ws.join("AGENTS.md"); - fs::write(&agents, "# user content\n").unwrap(); - - let before = fs::read_to_string(&agents).unwrap(); - let out = memory_install_pointers(ws.to_string_lossy().into_owned(), vec!["codex".into()]) - .unwrap(); - let after = fs::read_to_string(&agents).unwrap(); - - assert_eq!(before, after); - assert_eq!(out.len(), 1); - assert!(!out[0].installed); - } -} diff --git a/src-tauri/src/memory/frontmatter.rs b/src-tauri/src/memory/frontmatter.rs new file mode 100644 index 0000000..a101406 --- /dev/null +++ b/src-tauri/src/memory/frontmatter.rs @@ -0,0 +1,87 @@ +/// Minimal YAML-frontmatter subset used in memory notes. +/// +/// Only `title`, `enabled`, and `tags` are parsed; everything else is ignored. +/// The delimiter is `---` on its own line. + +#[derive(Debug, Default, Clone)] +pub struct MemoryFrontmatter { + pub title: Option, + pub enabled: Option, + pub tags: Option>, +} + +pub fn parse_frontmatter(body: &str) -> (MemoryFrontmatter, String) { + if !body.starts_with("---") { + return (MemoryFrontmatter::default(), body.to_owned()); + } + let rest = &body[3..]; + let end = match rest.find("\n---") { + Some(i) => i, + None => return (MemoryFrontmatter::default(), body.to_owned()), + }; + let raw = &rest[..end]; + let body_rest = &rest[end + 4..]; + let body_rest = body_rest.strip_prefix('\n').unwrap_or(body_rest); + + let mut fm = MemoryFrontmatter::default(); + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let Some(colon) = trimmed.find(':') else { + continue; + }; + if colon == 0 { + continue; + } + let key = trimmed[..colon].trim(); + let value = trimmed[colon + 1..].trim(); + match key { + "title" => { + fm.title = Some(value.trim_matches(|c| c == '"' || c == '\'').to_owned()); + } + "enabled" => { + fm.enabled = Some(value == "true"); + } + "tags" => { + if value.starts_with('[') && value.ends_with(']') { + let inner = &value[1..value.len() - 1]; + fm.tags = Some( + inner + .split(',') + .map(|t| t.trim().trim_matches(|c| c == '"' || c == '\'').to_owned()) + .filter(|t| !t.is_empty()) + .collect(), + ); + } + } + _ => {} + } + } + (fm, body_rest.to_owned()) +} + +pub fn serialize_frontmatter(fm: &MemoryFrontmatter, body: &str) -> String { + let mut lines = vec!["---".to_owned()]; + if let Some(t) = &fm.title { + lines.push(format!("title: {t}")); + } + if let Some(e) = fm.enabled { + lines.push(format!("enabled: {e}")); + } + if let Some(tags) = &fm.tags { + let rendered = tags + .iter() + .map(|t| format!("\"{t}\"")) + .collect::>() + .join(", "); + lines.push(format!("tags: [{rendered}]")); + } + lines.push("---".to_owned()); + format!("{}\n{body}", lines.join("\n")) +} + +pub fn strip_frontmatter(body: &str) -> String { + parse_frontmatter(body).1 +} diff --git a/src-tauri/src/memory/graph.rs b/src-tauri/src/memory/graph.rs new file mode 100644 index 0000000..7a7d194 --- /dev/null +++ b/src-tauri/src/memory/graph.rs @@ -0,0 +1,153 @@ +use std::collections::{BTreeSet, HashMap}; + +use super::frontmatter::strip_frontmatter; +use super::paths::{category_hub_node_id, category_hub_path, graph_category_for, node_id}; +use super::types::{GraphData, GraphEdge, GraphNode, MemoryScope}; +use super::wikilinks::{parse_links_and_tags, resolve_link_target}; + +pub struct ScopeNote { + pub scope: MemoryScope, + pub path: String, + pub body: String, + pub label: String, +} + +pub struct CategoryHubInput { + pub category: String, + pub label: String, + pub scopes: Vec, +} + +pub fn build_graph(notes: Vec, category_hubs: Vec) -> GraphData { + let mut scope_paths: HashMap> = HashMap::new(); + for n in ¬es { + scope_paths + .entry(n.scope.clone()) + .or_default() + .push(n.path.clone()); + } + + let mut nodes: Vec = notes + .iter() + .map(|n| { + let body = strip_frontmatter(&n.body); + let pr = parse_links_and_tags(&body); + GraphNode { + id: node_id(&n.scope, &n.path), + scope: n.scope.clone(), + path: n.path.clone(), + label: n.label.clone(), + tags: pr.tags, + orphan: true, + category: graph_category_for(&n.path), + is_category_hub: None, + hub_scopes: None, + } + }) + .collect(); + + let mut hub_ids: BTreeSet = BTreeSet::new(); + for hub in &category_hubs { + let id = category_hub_node_id(&hub.category); + if hub_ids.contains(&id) { + continue; + } + hub_ids.insert(id.clone()); + let scope = if hub.scopes.contains(&MemoryScope::Workspace) { + MemoryScope::Workspace + } else { + hub.scopes + .first() + .cloned() + .unwrap_or(MemoryScope::Workspace) + }; + nodes.push(GraphNode { + id, + scope, + path: category_hub_path(&hub.category), + label: hub.label.clone(), + tags: Vec::new(), + orphan: false, + category: hub.category.clone(), + is_category_hub: Some(true), + hub_scopes: Some(hub.scopes.clone()), + }); + } + + let node_set: BTreeSet = nodes.iter().map(|n| n.id.clone()).collect(); + let hub_id_set: BTreeSet = nodes + .iter() + .filter(|n| n.is_category_hub.unwrap_or(false)) + .map(|n| n.id.clone()) + .collect(); + + let mut edges: Vec = Vec::new(); + let mut edge_keys: BTreeSet = BTreeSet::new(); + + for note in ¬es { + let body = strip_frontmatter(¬e.body); + let pr = parse_links_and_tags(&body); + let source_id = node_id(¬e.scope, ¬e.path); + for link in &pr.links { + let Some((target_scope, target_path)) = + resolve_link_target(¬e.scope, link, &scope_paths) + else { + continue; + }; + let target_id = node_id(&target_scope, &target_path); + if !node_set.contains(&target_id) || hub_id_set.contains(&target_id) { + continue; + } + let key = format!("{source_id}->{target_id}"); + if edge_keys.contains(&key) { + continue; + } + edge_keys.insert(key); + let cross_scope = note.scope != target_scope; + edges.push(GraphEdge { + source: source_id.clone(), + target: target_id, + cross_scope, + }); + } + } + + // Note → hub edges + for note in ¬es { + let note_id = node_id(¬e.scope, ¬e.path); + let cat = graph_category_for(¬e.path); + if cat == "memory" { + continue; + } + let hub_id = category_hub_node_id(&cat); + if !hub_id_set.contains(&hub_id) { + continue; + } + let key = format!("{note_id}->{hub_id}"); + if edge_keys.contains(&key) { + continue; + } + edge_keys.insert(key); + edges.push(GraphEdge { + source: note_id, + target: hub_id, + cross_scope: false, + }); + } + + // Mark orphan nodes + let mut connected: BTreeSet = BTreeSet::new(); + for e in &edges { + connected.insert(e.source.clone()); + connected.insert(e.target.clone()); + } + for n in nodes.iter_mut() { + if n.is_category_hub.unwrap_or(false) { + n.orphan = false; + continue; + } + n.orphan = !connected.contains(&n.id); + } + + GraphData { nodes, edges } +} diff --git a/src-tauri/src/memory/mod.rs b/src-tauri/src/memory/mod.rs new file mode 100644 index 0000000..7056324 --- /dev/null +++ b/src-tauri/src/memory/mod.rs @@ -0,0 +1,178 @@ +//! Obsidian-style memory system with workspace + global scope. +//! +//! Layout: +//! ```text +//! /.agents/memory/ workspace notes +//! /.agents/learnings/ workspace learnings (API: `learnings/…`) +//! ~/.blxcode/memory/ global notes +//! ~/.blxcode/learnings/ global learnings +//! ``` +//! +//! Node IDs: `{scope}:{api_path}` (e.g. `workspace:decisions/note.md`) + +mod frontmatter; +mod graph; +pub mod paths; +mod store; +mod types; +pub mod wikilinks; + +pub use types::*; + +use crate::agents_layout::ensure_agents_layout; +use store::{ + collect_notes, memory_backlinks_impl, memory_bootstrap_impl, memory_create_category_impl, + memory_create_impl, memory_delete_impl, memory_export_impl, memory_graph_impl, + memory_import_impl, memory_install_pointers_impl, memory_list_impl, memory_pointer_status_impl, + memory_read_impl, memory_rename_impl, memory_search_impl, memory_status_impl, + memory_uninstall_pointers_impl, memory_write_impl, +}; + +// ── Bootstrap / status ──────────────────────────────────────────────────────── + +#[tauri::command] +pub fn workspace_ensure_agents(workspace_cwd: String) -> Result<(), String> { + ensure_agents_layout(&workspace_cwd)?; + Ok(()) +} + +#[tauri::command] +pub fn memory_status(workspace_cwd: String) -> Result { + Ok(memory_status_impl(&workspace_cwd)) +} + +#[tauri::command] +pub fn memory_bootstrap(workspace_cwd: String, target: String) -> Result<(), String> { + memory_bootstrap_impl(&target, &workspace_cwd) +} + +// ── List ────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub fn memory_list(workspace_cwd: String) -> Result { + Ok(memory_list_impl(&workspace_cwd)) +} + +// ── CRUD ────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub fn memory_read( + workspace_cwd: String, + scope: MemoryScope, + path: String, +) -> Result { + memory_read_impl(&scope, &workspace_cwd, &path) +} + +#[tauri::command] +pub fn memory_write( + workspace_cwd: String, + scope: MemoryScope, + path: String, + content: String, +) -> Result { + memory_write_impl(&scope, &workspace_cwd, &path, &content) +} + +#[tauri::command] +pub fn memory_create( + workspace_cwd: String, + scope: MemoryScope, + path: String, + content: Option, +) -> Result { + memory_create_impl(&scope, &workspace_cwd, &path, content) +} + +#[tauri::command] +pub fn memory_delete( + workspace_cwd: String, + scope: MemoryScope, + path: String, +) -> Result<(), String> { + memory_delete_impl(&scope, &workspace_cwd, &path) +} + +#[tauri::command] +pub fn memory_rename( + workspace_cwd: String, + scope: MemoryScope, + old_path: String, + new_path: String, + rewrite_links: bool, +) -> Result { + memory_rename_impl(&scope, &workspace_cwd, &old_path, &new_path, rewrite_links) +} + +#[tauri::command] +pub fn memory_create_category( + workspace_cwd: String, + scope: MemoryScope, + name: String, +) -> Result { + memory_create_category_impl(&scope, &workspace_cwd, &name) +} + +// ── Graph, backlinks, search ────────────────────────────────────────────────── + +#[tauri::command] +pub fn memory_graph(workspace_cwd: String) -> Result { + memory_graph_impl(&workspace_cwd) +} + +#[tauri::command] +pub fn memory_backlinks( + workspace_cwd: String, + scope: MemoryScope, + path: String, +) -> Result, String> { + memory_backlinks_impl(&scope, &workspace_cwd, &path) +} + +#[tauri::command] +pub fn memory_search(workspace_cwd: String, query: String) -> Result, String> { + memory_search_impl(&workspace_cwd, &query) +} + +// ── Export / Import ─────────────────────────────────────────────────────────── + +#[tauri::command] +pub fn memory_export(workspace_cwd: String, dest_dir: String) -> Result { + memory_export_impl(&workspace_cwd, &dest_dir) +} + +#[tauri::command] +pub fn memory_import(workspace_cwd: String, src_dir: String) -> Result { + memory_import_impl(&workspace_cwd, &src_dir) +} + +// ── Pointer files ───────────────────────────────────────────────────────────── + +#[tauri::command] +pub fn memory_install_pointers( + workspace_cwd: String, + agents: Vec, +) -> Result, String> { + memory_install_pointers_impl(&workspace_cwd, agents) +} + +#[tauri::command] +pub fn memory_uninstall_pointers( + workspace_cwd: String, + agents: Vec, +) -> Result, String> { + memory_uninstall_pointers_impl(&workspace_cwd, agents) +} + +#[tauri::command] +pub fn memory_pointer_status(workspace_cwd: String) -> Result, String> { + memory_pointer_status_impl(&workspace_cwd) +} + +// ── Legacy stub (kept for binary compatibility) ─────────────────────────────── + +#[tauri::command] +pub fn memory_root(workspace_cwd: String) -> Result { + let roots = paths::get_workspace_roots(&workspace_cwd)?; + Ok(roots.memory.to_string_lossy().into_owned()) +} diff --git a/src-tauri/src/memory/paths.rs b/src-tauri/src/memory/paths.rs new file mode 100644 index 0000000..0b0d97f --- /dev/null +++ b/src-tauri/src/memory/paths.rs @@ -0,0 +1,153 @@ +use crate::agents_layout::{ + validate_workspace_cwd, LEARNINGS_API_PREFIX, LEARNINGS_REL, MEMORY_REL, +}; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::types::MemoryScope; + +pub const TEMPLATES_DIRNAME: &str = "_templates"; +pub const CATEGORY_PLACEHOLDER: &str = ".gitkeep"; +pub const CATEGORY_HUB_ID_PREFIX: &str = "hub:"; +pub const CATEGORY_HUB_PATH_PREFIX: &str = "@category/"; + +#[derive(Debug, Clone)] +pub struct MemoryRoots { + pub memory: PathBuf, + pub learnings: PathBuf, +} + +pub fn home_dir() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")) +} + +pub fn get_global_roots() -> MemoryRoots { + let base = home_dir().join(".blxcode"); + MemoryRoots { + memory: base.join("memory"), + learnings: base.join("learnings"), + } +} + +pub fn get_workspace_roots(ws: &str) -> Result { + let path = validate_workspace_cwd(ws)?; + Ok(MemoryRoots { + memory: path.join(MEMORY_REL), + learnings: path.join(LEARNINGS_REL), + }) +} + +pub fn get_roots_for_scope( + scope: &MemoryScope, + workspace_cwd: &str, +) -> Result { + match scope { + MemoryScope::Global => Ok(get_global_roots()), + MemoryScope::Workspace => get_workspace_roots(workspace_cwd), + } +} + +pub fn folder_exists(dir: &Path) -> bool { + dir.is_dir() +} + +pub fn node_id(scope: &MemoryScope, api_path: &str) -> String { + let s = scope_str(scope); + format!("{s}:{api_path}") +} + +pub fn scope_str(scope: &MemoryScope) -> &'static str { + match scope { + MemoryScope::Workspace => "workspace", + MemoryScope::Global => "global", + } +} + +pub fn parse_node_id(id: &str) -> Option<(MemoryScope, String)> { + let idx = id.find(':')?; + if idx == 0 { + return None; + } + let scope = match &id[..idx] { + "workspace" => MemoryScope::Workspace, + "global" => MemoryScope::Global, + _ => return None, + }; + Some((scope, id[idx + 1..].to_owned())) +} + +pub fn graph_category_for(api_path: &str) -> String { + if api_path.starts_with(LEARNINGS_API_PREFIX) { + return "learnings".to_string(); + } + if let Some((head, _)) = api_path.split_once('/') { + if !head.is_empty() && !head.ends_with(".md") { + return head.to_string(); + } + } + "memory".to_string() +} + +pub fn list_memory_subcategories(memory_root: &Path) -> Vec { + if !memory_root.is_dir() { + return Vec::new(); + } + let mut out = Vec::new(); + let Ok(read) = fs::read_dir(memory_root) else { + return out; + }; + for entry in read.flatten() { + let Ok(ft) = entry.file_type() else { continue }; + if !ft.is_dir() { + continue; + } + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if name.starts_with('.') || name.eq_ignore_ascii_case(TEMPLATES_DIRNAME) { + continue; + } + out.push(name); + } + out.sort_unstable_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + out +} + +pub fn validate_category_name(name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("empty category name".into()); + } + if trimmed.contains('/') + || trimmed.contains('\\') + || trimmed.contains("..") + || trimmed.starts_with('.') + { + return Err("invalid category name".into()); + } + if is_reserved_category(trimmed) { + return Err(format!("reserved category name: {trimmed}")); + } + Ok(trimmed.to_owned()) +} + +fn is_reserved_category(name: &str) -> bool { + name.eq_ignore_ascii_case(TEMPLATES_DIRNAME) + || name.eq_ignore_ascii_case("memory") + || name.eq_ignore_ascii_case("learnings") +} + +pub fn category_hub_node_id(category: &str) -> String { + format!("{CATEGORY_HUB_ID_PREFIX}{category}") +} + +pub fn category_hub_path(category: &str) -> String { + format!("{CATEGORY_HUB_PATH_PREFIX}{category}") +} + +pub fn overview_path_for_category(category: &str) -> String { + if category == "learnings" { + return format!("{LEARNINGS_API_PREFIX}README.md"); + } + format!("{category}/README.md") +} diff --git a/src-tauri/src/memory/store.rs b/src-tauri/src/memory/store.rs new file mode 100644 index 0000000..59caafe --- /dev/null +++ b/src-tauri/src/memory/store.rs @@ -0,0 +1,1092 @@ +//! Core CRUD helpers — called from #[tauri::command] wrappers in mod.rs. + +use crate::agents_layout::{ensure_agents_layout, LEARNINGS_API_PREFIX, LEARNINGS_REL, MEMORY_REL}; +use std::collections::HashMap; +use std::fs; +use std::io::Write as IoWrite; +use std::path::{Component, Path, PathBuf}; + +use super::frontmatter::{ + parse_frontmatter, serialize_frontmatter, strip_frontmatter, MemoryFrontmatter, +}; +use super::graph::{build_graph, CategoryHubInput, ScopeNote}; +use super::paths::{ + folder_exists, get_global_roots, get_roots_for_scope, graph_category_for, + list_memory_subcategories, node_id, parse_node_id, validate_category_name, MemoryRoots, + CATEGORY_PLACEHOLDER, TEMPLATES_DIRNAME, +}; +use super::types::{ + BacklinkRef, GraphData, MemoryFolderStatus, MemoryListResponse, MemoryScope, + MemoryStatusResponse, MemorySubcategories, NoteContent, NoteMeta, PointerResult, RenameReport, + SearchHit, +}; +use super::wikilinks::{parse_links_and_tags, rewrite_wikilinks}; + +// ── Bootstrap seed text ──────────────────────────────────────────────────────── + +const MEMORY_OVERVIEW_BODY: &str = r#"# Memory + +This folder stores **durable, project-wide notes** that should survive across coding sessions and be available to every AI agent or teammate working in this repo. + +## What belongs here + +- Architectural decisions and the reasoning behind them +- Conventions, patterns and "house style" that aren't obvious from the code +- Reference material: external systems, where to find logs / dashboards +- Known-good workflows ("how we deploy", "how we cut a release") +- User-specific preferences for collaboration + +## What does **not** belong here + +- Ephemeral todos for the current task (use a Plan or task list instead) +- Things already documented in code, `CLAUDE.md`, or commit messages +- Time-sensitive snapshots (`git log` is authoritative for history) + +## Tips for AI agents + +- Read existing notes before answering questions about this project. +- When you learn something non-obvious, propose adding it here. +- Prefer updating an existing note over creating a near-duplicate. +"#; + +const LEARNINGS_OVERVIEW_BODY: &str = r#"# Learnings + +This folder is a **growing knowledge base of resolved problems** — concrete things you discovered the hard way and want future agents (human or AI) to find. + +Each learning is a separate markdown file named `learning-.md`. Together they form a searchable log of "the thing we now know that we didn't know before." + +## When to record a learning + +- A bug you fixed that wasn't obvious from the symptom +- A workaround for a quirk in a dependency, runtime, or environment +- A migration step that required out-of-band knowledge + +## Tips for AI agents + +- Search here before debugging — someone may have hit this already. +- When you fix a non-trivial bug, propose a new learning entry. +- Keep one learning per file; cross-link related ones via `[[wikilinks]]`. +"#; + +// ── Path helpers ─────────────────────────────────────────────────────────────── + +fn err(s: impl Into) -> Result { + Err(s.into()) +} + +fn mtime_secs(p: &Path) -> i64 { + fs::metadata(p) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn walk_md(root: &Path, out: &mut Vec) { + let Ok(read) = fs::read_dir(root) else { return }; + for entry in read.flatten() { + let path = entry.path(); + let Ok(ft) = entry.file_type() else { continue }; + if ft.is_dir() { + walk_md(&path, out); + continue; + } + if ft.is_file() { + let is_md = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("md")) + .unwrap_or(false); + if is_md { + out.push(path); + } + } + } +} + +fn rel_from_root(root: &Path, p: &Path) -> Option { + p.strip_prefix(root) + .ok() + .map(|rel| rel.to_string_lossy().replace('\\', "/")) +} + +/// Returns (is_learnings, rel_within_root). +fn resolve_api_path(api_path: &str) -> Result<(bool, String), String> { + let p = api_path.trim().replace('\\', "/"); + if p.is_empty() { + return err("empty path"); + } + if p.starts_with(LEARNINGS_API_PREFIX) { + let rel = p + .strip_prefix(LEARNINGS_API_PREFIX) + .unwrap_or_default() + .trim(); + if rel.is_empty() || rel.contains("..") { + return err("invalid learnings path"); + } + return Ok((true, rel.to_owned())); + } + if p.contains("..") { + return err("invalid path"); + } + Ok((false, p)) +} + +fn note_abs(roots: &MemoryRoots, api_path: &str) -> Result { + let (is_learnings, rel) = resolve_api_path(api_path)?; + let root = if is_learnings { + roots.learnings.as_path() + } else { + roots.memory.as_path() + }; + safe_join(root, &rel, true) +} + +fn safe_join(root: &Path, rel: &str, enforce_md: bool) -> Result { + let rel = rel.trim().trim_start_matches('/').trim_start_matches('\\'); + if rel.is_empty() { + return err("empty relative path"); + } + let normalized = rel.replace('\\', "/"); + let candidate = PathBuf::from(&normalized); + for c in candidate.components() { + match c { + Component::Normal(_) => {} + _ => return err(format!("disallowed path component in {rel}")), + } + } + let abs = root.join(&candidate); + let canon_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let canon_abs = abs + .parent() + .and_then(|p| fs::canonicalize(p).ok()) + .map(|p| p.join(abs.file_name().unwrap_or_default())) + .unwrap_or(abs.clone()); + if !canon_abs.starts_with(&canon_root) { + return err("path escapes memory root"); + } + if enforce_md { + let is_md = canon_abs + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("md")) + .unwrap_or(false); + if !is_md { + return err("only .md files allowed"); + } + } + Ok(abs) +} + +// ── Note metadata collection ─────────────────────────────────────────────────── + +fn meta_from_file(scope: &MemoryScope, api_path: &str, abs: &Path) -> Option { + let content = fs::read_to_string(abs).ok()?; + let (fm, body_rest) = parse_frontmatter(&content); + let (is_learnings, _) = resolve_api_path(api_path).ok()?; + let file_name = Path::new(api_path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_owned(); + let basename = Path::new(api_path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_owned(); + let is_overview = basename.eq_ignore_ascii_case("README.md"); + let is_template = !is_learnings && api_path.starts_with(&format!("{TEMPLATES_DIRNAME}/")); + let title = fm + .title + .clone() + .unwrap_or_else(|| extract_h1_title(&body_rest).unwrap_or_else(|| file_name.clone())); + let meta = fs::metadata(abs).ok(); + Some(NoteMeta { + scope: scope.clone(), + path: api_path.to_owned(), + name: file_name, + title, + enabled: fm.enabled.unwrap_or(true), + tags: fm.tags.unwrap_or_default(), + size: meta.as_ref().map(|m| m.len()).unwrap_or(0), + modified: mtime_secs(abs), + is_template, + is_learning: is_learnings, + is_overview, + category: graph_category_for(api_path), + }) +} + +fn extract_h1_title(body: &str) -> Option { + for line in body.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("# ") { + let t = rest.trim(); + if !t.is_empty() { + return Some(t.to_owned()); + } + } + } + None +} + +pub fn collect_notes(scope: &MemoryScope, workspace_cwd: &str) -> Vec { + let Ok(roots) = get_roots_for_scope(scope, workspace_cwd) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (is_learnings, root) in [ + (false, roots.memory.as_path()), + (true, roots.learnings.as_path()), + ] { + let mut files = Vec::new(); + walk_md(root, &mut files); + for abs in files { + let Some(rel) = rel_from_root(root, &abs) else { + continue; + }; + let api = if is_learnings { + format!("{LEARNINGS_API_PREFIX}{rel}") + } else { + rel + }; + if let Some(meta) = meta_from_file(scope, &api, &abs) { + out.push(meta); + } + } + } + out.sort_by(|a, b| a.path.to_lowercase().cmp(&b.path.to_lowercase())); + out +} + +// ── Status & Bootstrap ──────────────────────────────────────────────────────── + +pub fn memory_status_impl(workspace_cwd: &str) -> MemoryStatusResponse { + let global = get_global_roots(); + let ws = get_roots_for_scope(&MemoryScope::Workspace, workspace_cwd).ok(); + MemoryStatusResponse { + workspace: MemoryFolderStatus { + memory: ws + .as_ref() + .map(|r| folder_exists(&r.memory)) + .unwrap_or(false), + learnings: ws + .as_ref() + .map(|r| folder_exists(&r.learnings)) + .unwrap_or(false), + }, + global: MemoryFolderStatus { + memory: folder_exists(&global.memory), + learnings: folder_exists(&global.learnings), + }, + } +} + +fn seed_memory(memory_dir: &Path) -> Result<(), String> { + fs::create_dir_all(memory_dir).map_err(|e| format!("mkdir: {e}"))?; + let readme = memory_dir.join("README.md"); + if !readme.exists() { + let fm = MemoryFrontmatter { + title: Some("Memory".into()), + enabled: Some(true), + tags: Some(Vec::new()), + }; + let content = serialize_frontmatter(&fm, MEMORY_OVERVIEW_BODY); + fs::write(&readme, content.as_bytes()).map_err(|e| format!("write README: {e}"))?; + } + Ok(()) +} + +fn seed_learnings(learnings_dir: &Path) -> Result<(), String> { + fs::create_dir_all(learnings_dir).map_err(|e| format!("mkdir: {e}"))?; + let readme = learnings_dir.join("README.md"); + if !readme.exists() { + let fm = MemoryFrontmatter { + title: Some("Learnings".into()), + enabled: Some(true), + tags: Some(Vec::new()), + }; + let content = serialize_frontmatter(&fm, LEARNINGS_OVERVIEW_BODY); + fs::write(&readme, content.as_bytes()).map_err(|e| format!("write README: {e}"))?; + } + Ok(()) +} + +pub fn memory_bootstrap_impl(target: &str, workspace_cwd: &str) -> Result<(), String> { + match target { + "workspace" | "all" => { + let roots = get_roots_for_scope(&MemoryScope::Workspace, workspace_cwd)?; + seed_memory(&roots.memory)?; + seed_learnings(&roots.learnings)?; + if target == "workspace" { + return Ok(()); + } + // fall through for "all" + let global = get_global_roots(); + if let Some(parent) = global.memory.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir global base: {e}"))?; + } + seed_memory(&global.memory)?; + seed_learnings(&global.learnings)?; + } + "global" => { + let global = get_global_roots(); + if let Some(parent) = global.memory.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir global base: {e}"))?; + } + seed_memory(&global.memory)?; + seed_learnings(&global.learnings)?; + } + _ => return err(format!("unknown target: {target}")), + } + Ok(()) +} + +// ── memory_list ──────────────────────────────────────────────────────────────── + +pub fn memory_list_impl(workspace_cwd: &str) -> MemoryListResponse { + // Workspace scope + let ws_notes = collect_notes(&MemoryScope::Workspace, workspace_cwd); + let ws_cats = get_roots_for_scope(&MemoryScope::Workspace, workspace_cwd) + .map(|r| list_memory_subcategories(&r.memory)) + .unwrap_or_default(); + + // Global scope + let global_roots = get_global_roots(); + let global_notes = + if folder_exists(&global_roots.memory) || folder_exists(&global_roots.learnings) { + // collect_notes for global doesn't need workspace_cwd but the function signature takes it; + // for global we can pass an empty string and use get_global_roots directly + collect_notes_global() + } else { + Vec::new() + }; + let global_cats = list_memory_subcategories(&global_roots.memory); + + let mut all_notes = ws_notes; + all_notes.extend(global_notes); + + MemoryListResponse { + notes: all_notes, + memory_subcategories: MemorySubcategories { + workspace: ws_cats, + global: global_cats, + }, + } +} + +fn collect_notes_global() -> Vec { + let roots = get_global_roots(); + let mut out = Vec::new(); + for (is_learnings, root) in [ + (false, roots.memory.as_path()), + (true, roots.learnings.as_path()), + ] { + let mut files = Vec::new(); + walk_md(root, &mut files); + for abs in files { + let Some(rel) = rel_from_root(root, &abs) else { + continue; + }; + let api = if is_learnings { + format!("{LEARNINGS_API_PREFIX}{rel}") + } else { + rel + }; + if let Some(meta) = meta_from_file(&MemoryScope::Global, &api, &abs) { + out.push(meta); + } + } + } + out.sort_by(|a, b| a.path.to_lowercase().cmp(&b.path.to_lowercase())); + out +} + +// ── CRUD ────────────────────────────────────────────────────────────────────── + +pub fn memory_read_impl( + scope: &MemoryScope, + workspace_cwd: &str, + path: &str, +) -> Result { + let roots = get_roots_for_scope(scope, workspace_cwd)?; + let abs = note_abs(&roots, path)?; + let content = fs::read_to_string(&abs).map_err(|e| format!("read {path}: {e}"))?; + Ok(NoteContent { + scope: scope.clone(), + path: path.to_owned(), + content, + modified: mtime_secs(&abs), + }) +} + +pub fn memory_write_impl( + scope: &MemoryScope, + workspace_cwd: &str, + path: &str, + content: &str, +) -> Result { + let roots = get_roots_for_scope(scope, workspace_cwd)?; + let abs = note_abs(&roots, path)?; + if let Some(parent) = abs.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?; + } + let mut file = fs::File::create(&abs).map_err(|e| format!("create {path}: {e}"))?; + file.write_all(content.as_bytes()) + .map_err(|e| format!("write {path}: {e}"))?; + Ok(NoteContent { + scope: scope.clone(), + path: path.to_owned(), + content: content.to_owned(), + modified: mtime_secs(&abs), + }) +} + +pub fn memory_create_impl( + scope: &MemoryScope, + workspace_cwd: &str, + path: &str, + content: Option, +) -> Result { + let roots = get_roots_for_scope(scope, workspace_cwd)?; + let abs = note_abs(&roots, path)?; + if abs.exists() { + return err(format!("already exists: {path}")); + } + if let Some(parent) = abs.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?; + } + let body = content.unwrap_or_default(); + fs::write(&abs, body.as_bytes()).map_err(|e| format!("write {path}: {e}"))?; + meta_from_file(scope, path, &abs).ok_or_else(|| format!("failed to read meta for {path}")) +} + +pub fn memory_delete_impl( + scope: &MemoryScope, + workspace_cwd: &str, + path: &str, +) -> Result<(), String> { + let roots = get_roots_for_scope(scope, workspace_cwd)?; + let (is_learnings, _) = resolve_api_path(path)?; + let root = if is_learnings { + &roots.learnings + } else { + &roots.memory + }; + let abs = note_abs(&roots, path)?; + if !abs.exists() { + return err(format!("not found: {path}")); + } + fs::remove_file(&abs).map_err(|e| format!("delete {path}: {e}"))?; + // Remove empty parent dirs up to root + if let Some(mut parent) = abs.parent() { + while parent != root.as_path() { + if fs::read_dir(parent) + .map(|mut r| r.next().is_none()) + .unwrap_or(false) + { + let _ = fs::remove_dir(parent); + if let Some(grand) = parent.parent() { + parent = grand; + } else { + break; + } + } else { + break; + } + } + } + Ok(()) +} + +pub fn memory_rename_impl( + scope: &MemoryScope, + workspace_cwd: &str, + old_path: &str, + new_path: &str, + rewrite_links: bool, +) -> Result { + let roots = get_roots_for_scope(scope, workspace_cwd)?; + let (old_learn, _) = resolve_api_path(old_path)?; + let (new_learn, _) = resolve_api_path(new_path)?; + if old_learn != new_learn { + return err("cannot rename across memory and learnings roots"); + } + let abs_old = note_abs(&roots, old_path)?; + let abs_new = note_abs(&roots, new_path)?; + if !abs_old.exists() { + return err(format!("not found: {old_path}")); + } + if abs_new.exists() { + return err(format!("already exists: {new_path}")); + } + if let Some(parent) = abs_new.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?; + } + fs::rename(&abs_old, &abs_new).map_err(|e| format!("rename: {e}"))?; + + let old_basename = Path::new(old_path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_owned(); + let new_basename = Path::new(new_path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_owned(); + + let mut total_rewrites = 0u32; + let mut files_changed = 0u32; + + if rewrite_links && !old_basename.is_empty() && !new_basename.is_empty() { + let mut all_files = Vec::new(); + walk_md(&roots.memory, &mut all_files); + walk_md(&roots.learnings, &mut all_files); + for f in all_files { + let Ok(content) = fs::read_to_string(&f) else { + continue; + }; + let (updated, n) = + rewrite_wikilinks(&content, &old_basename, &new_basename, old_path, new_path); + if n > 0 { + if fs::write(&f, updated.as_bytes()).is_ok() { + total_rewrites += n; + files_changed += 1; + } + } + } + } + Ok(RenameReport { + old_path: old_path.to_owned(), + new_path: new_path.to_owned(), + link_rewrites: total_rewrites, + files_changed, + }) +} + +pub fn memory_create_category_impl( + scope: &MemoryScope, + workspace_cwd: &str, + name: &str, +) -> Result { + let roots = get_roots_for_scope(scope, workspace_cwd)?; + let clean = validate_category_name(name)?; + let dir = roots.memory.join(&clean); + if dir.exists() { + return err(format!("already exists: {clean}")); + } + fs::create_dir_all(&dir).map_err(|e| format!("mkdir: {e}"))?; + let _ = fs::write(dir.join(CATEGORY_PLACEHOLDER), b""); + Ok(clean) +} + +// ── Graph ───────────────────────────────────────────────────────────────────── + +pub fn memory_graph_impl(workspace_cwd: &str) -> Result { + let ws_roots = get_roots_for_scope(&MemoryScope::Workspace, workspace_cwd).ok(); + let global_roots = get_global_roots(); + + let ws_notes = collect_notes(&MemoryScope::Workspace, workspace_cwd); + let global_notes = collect_notes_global(); + + let mut scope_notes: Vec = Vec::new(); + + for meta in &ws_notes { + if meta.is_template || meta.is_overview { + continue; + } + let body = ws_roots + .as_ref() + .and_then(|roots| note_abs(roots, &meta.path).ok()) + .and_then(|abs| fs::read_to_string(&abs).ok()) + .unwrap_or_default(); + scope_notes.push(ScopeNote { + scope: MemoryScope::Workspace, + path: meta.path.clone(), + body, + label: meta.name.clone(), + }); + } + + for meta in &global_notes { + if meta.is_template || meta.is_overview { + continue; + } + let body = note_abs(&global_roots, &meta.path) + .ok() + .and_then(|abs| fs::read_to_string(&abs).ok()) + .unwrap_or_default(); + scope_notes.push(ScopeNote { + scope: MemoryScope::Global, + path: meta.path.clone(), + body, + label: meta.name.clone(), + }); + } + + // Build category hubs from unique categories across both scopes + let mut cat_scopes: HashMap> = HashMap::new(); + for sn in &scope_notes { + let cat = graph_category_for(&sn.path); + if cat != "memory" { + let entry = cat_scopes.entry(cat).or_default(); + if !entry.contains(&sn.scope) { + entry.push(sn.scope.clone()); + } + } + } + let category_hubs: Vec = cat_scopes + .into_iter() + .map(|(cat, scopes)| CategoryHubInput { + label: capitalize_first(&cat), + category: cat, + scopes, + }) + .collect(); + + Ok(build_graph(scope_notes, category_hubs)) +} + +fn capitalize_first(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + } +} + +// ── Backlinks & Search ──────────────────────────────────────────────────────── + +pub fn memory_backlinks_impl( + scope: &MemoryScope, + workspace_cwd: &str, + path: &str, +) -> Result, String> { + let graph = memory_graph_impl(workspace_cwd)?; + let target_id = node_id(scope, path); + let mut out: Vec = graph + .edges + .into_iter() + .filter_map(|e| { + if e.target == target_id { + super::paths::parse_node_id(&e.source) + .map(|(s, p)| BacklinkRef { scope: s, path: p }) + } else { + None + } + }) + .collect(); + out.sort_by(|a, b| a.path.cmp(&b.path)); + out.dedup_by(|a, b| a.path == b.path && a.scope == b.scope); + Ok(out) +} + +pub fn memory_search_impl(workspace_cwd: &str, query: &str) -> Result, String> { + let needle = query.trim(); + if needle.is_empty() { + return Ok(Vec::new()); + } + let needle_l = needle.to_ascii_lowercase(); + let mut hits: Vec = Vec::new(); + + for scope in [MemoryScope::Workspace, MemoryScope::Global] { + let roots = match get_roots_for_scope(&scope, workspace_cwd) { + Ok(r) => r, + Err(_) => continue, + }; + for (is_learnings, root) in [ + (false, roots.memory.as_path()), + (true, roots.learnings.as_path()), + ] { + let mut files = Vec::new(); + walk_md(root, &mut files); + for abs in files { + let Some(rel) = rel_from_root(root, &abs) else { + continue; + }; + let api = if is_learnings { + format!("{LEARNINGS_API_PREFIX}{rel}") + } else { + rel + }; + let Ok(body) = fs::read_to_string(&abs) else { + continue; + }; + for (idx, line) in body.lines().enumerate() { + if line.to_ascii_lowercase().contains(&needle_l) { + let snip = if line.len() > 200 { &line[..200] } else { line }; + hits.push(SearchHit { + scope: scope.clone(), + path: api.clone(), + line: (idx + 1) as u32, + snippet: snip.to_owned(), + category: graph_category_for(&api), + }); + if hits.len() >= 500 { + return Ok(hits); + } + } + } + } + } + } + Ok(hits) +} + +// ── Export / Import ─────────────────────────────────────────────────────────── + +pub fn memory_export_impl(workspace_cwd: &str, dest_dir: &str) -> Result { + let roots = get_roots_for_scope(&MemoryScope::Workspace, workspace_cwd)?; + let dest = PathBuf::from(dest_dir.trim()); + if !dest.is_absolute() { + return err("dest_dir must be absolute"); + } + fs::create_dir_all(&dest).map_err(|e| format!("mkdir dest: {e}"))?; + let mut n = 0u32; + for (root, sub) in [ + (roots.memory.as_path(), "memory"), + (roots.learnings.as_path(), "learnings"), + ] { + let mut files = Vec::new(); + walk_md(root, &mut files); + for f in files { + let Some(rel) = rel_from_root(root, &f) else { + continue; + }; + let target = dest.join(sub).join(&rel); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?; + } + fs::copy(&f, &target).map_err(|e| format!("copy {rel}: {e}"))?; + n += 1; + } + } + Ok(n) +} + +pub fn memory_import_impl(workspace_cwd: &str, src_dir: &str) -> Result { + let roots = get_roots_for_scope(&MemoryScope::Workspace, workspace_cwd)?; + let src = PathBuf::from(src_dir.trim()); + if !src.is_absolute() { + return err("src_dir must be absolute"); + } + if !src.exists() { + return err("src_dir does not exist"); + } + let mut n = 0u32; + let learnings_src = src.join("learnings"); + let memory_src = src.join("memory"); + if learnings_src.is_dir() { + n += import_tree(&learnings_src, &roots.learnings)?; + } + if memory_src.is_dir() { + n += import_tree(&memory_src, &roots.memory)?; + } else if !learnings_src.is_dir() { + n += import_tree(&src, &roots.memory)?; + } + Ok(n) +} + +fn import_tree(src: &Path, dest_root: &Path) -> Result { + let mut files = Vec::new(); + walk_md(src, &mut files); + let mut n = 0u32; + for f in files { + let Ok(rel_pb) = f.strip_prefix(src) else { + continue; + }; + let rel = rel_pb.to_string_lossy().replace('\\', "/"); + let abs = match safe_join(dest_root, &rel, true) { + Ok(p) => p, + Err(_) => continue, + }; + if abs.exists() { + continue; + } + if let Some(parent) = abs.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?; + } + fs::copy(&f, &abs).map_err(|e| format!("copy {rel}: {e}"))?; + n += 1; + } + Ok(n) +} + +// ── Pointer files ───────────────────────────────────────────────────────────── + +const POINTER_BEGIN: &str = ""; +const POINTER_END: &str = ""; +const POINTER_BEGIN_CURSOR: &str = "# blxcode-memory:begin"; +const POINTER_END_CURSOR: &str = "# blxcode-memory:end"; + +fn pointer_filename(agent: &str) -> Option<&'static str> { + match agent { + "claude" => Some("CLAUDE.md"), + "codex" => Some("AGENTS.md"), + "gemini" => Some("GEMINI.md"), + "cursor" => Some(".cursorrules"), + "opencode" => Some("AGENTS.md"), + _ => None, + } +} + +fn pointer_body(workspace_cwd: &Path, notes: &[NoteMeta], cursor_style: bool) -> String { + let memory_dir = workspace_cwd.join(MEMORY_REL); + let learnings_dir = workspace_cwd.join(LEARNINGS_REL); + let mut s = String::new(); + if cursor_style { + s.push_str("blxcode tracks per-workspace memory and learnings at the paths below.\n"); + } else { + s.push_str("## blxcode workspace memory\n\n"); + s.push_str( + "This workspace uses **blxcode** to maintain Markdown notes and learnings \ +shared across all agent sessions. Treat the directories below as authoritative \ +context: read notes that are relevant to the task, and propose new notes \ +or edits when you learn something the team should remember.\n\n", + ); + } + s.push_str(&format!("Memory root: `{}`\n", memory_dir.display())); + s.push_str(&format!( + "Learnings root: `{}` (API paths: `learnings/…`)\n\n", + learnings_dir.display() + )); + let non_template: Vec<_> = notes.iter().filter(|n| !n.is_template).collect(); + if non_template.is_empty() { + s.push_str("(no notes yet)\n"); + } else { + s.push_str("Current notes:\n"); + let mut shown = 0; + for n in &non_template { + s.push_str(&format!("- `{}`\n", n.path)); + shown += 1; + if shown >= 80 { + s.push_str(&format!("- … and {} more\n", non_template.len() - shown)); + break; + } + } + } + s.push('\n'); + s +} + +fn splice_block(existing: &str, begin: &str, end: &str, new_body: &str) -> String { + let block = format!("{begin}\n{new_body}{end}\n"); + if let (Some(bi), Some(ei)) = (existing.find(begin), existing.find(end)) { + let ei_end = ei + end.len(); + let tail_start = if ei_end < existing.len() && existing.as_bytes()[ei_end] == b'\n' { + ei_end + 1 + } else { + ei_end + }; + let mut out = String::with_capacity(bi + block.len() + existing.len() - tail_start); + out.push_str(&existing[..bi]); + out.push_str(&block); + out.push_str(&existing[tail_start..]); + return out; + } + let mut out = String::new(); + out.push_str(existing); + if !existing.is_empty() && !existing.ends_with('\n') { + out.push('\n'); + } + if !existing.is_empty() { + out.push('\n'); + } + out.push_str(&block); + out +} + +fn strip_block(existing: &str, begin: &str, end: &str) -> String { + if let (Some(bi), Some(ei)) = (existing.find(begin), existing.find(end)) { + let ei_end = ei + end.len(); + let tail_start = if ei_end < existing.len() && existing.as_bytes()[ei_end] == b'\n' { + ei_end + 1 + } else { + ei_end + }; + let mut out = String::new(); + out.push_str(&existing[..bi]); + out.push_str(&existing[tail_start..]); + return out.trim_end_matches('\n').to_owned() + "\n"; + } + existing.to_owned() +} + +pub fn memory_install_pointers_impl( + workspace_cwd: &str, + agents: Vec, +) -> Result, String> { + use crate::agents_layout::validate_workspace_cwd; + let ws = validate_workspace_cwd(workspace_cwd)?; + let notes = collect_notes(&MemoryScope::Workspace, workspace_cwd); + let mut results: Vec = Vec::new(); + let mut written: std::collections::BTreeSet = Default::default(); + for agent in agents { + let Some(fname) = pointer_filename(&agent) else { + results.push(PointerResult { + agent, + path: String::new(), + installed: false, + note: Some("unknown agent".into()), + }); + continue; + }; + let path = ws.join(fname); + if written.contains(fname) { + results.push(PointerResult { + agent, + path: path.to_string_lossy().into_owned(), + installed: false, + note: Some("shared file already handled".into()), + }); + continue; + } + if !path.exists() { + results.push(PointerResult { + agent, + path: path.to_string_lossy().into_owned(), + installed: false, + note: Some( + "skipped: file absent; blxcode does not auto-create root pointer files".into(), + ), + }); + written.insert(fname.to_owned()); + continue; + } + let cursor_style = agent == "cursor"; + let body = pointer_body(&ws, ¬es, cursor_style); + let (begin, end) = if cursor_style { + (POINTER_BEGIN_CURSOR, POINTER_END_CURSOR) + } else { + (POINTER_BEGIN, POINTER_END) + }; + let existing = fs::read_to_string(&path).unwrap_or_default(); + if !existing.contains(begin) { + results.push(PointerResult { + agent, + path: path.to_string_lossy().into_owned(), + installed: false, + note: Some( + "skipped: existing file is user-owned and has no blxcode managed block".into(), + ), + }); + written.insert(fname.to_owned()); + continue; + } + let updated = splice_block(&existing, begin, end, &body); + match fs::write(&path, updated.as_bytes()) { + Ok(()) => { + written.insert(fname.to_owned()); + results.push(PointerResult { + agent, + path: path.to_string_lossy().into_owned(), + installed: true, + note: None, + }); + } + Err(e) => results.push(PointerResult { + agent, + path: path.to_string_lossy().into_owned(), + installed: false, + note: Some(format!("write failed: {e}")), + }), + } + } + Ok(results) +} + +pub fn memory_uninstall_pointers_impl( + workspace_cwd: &str, + agents: Vec, +) -> Result, String> { + use crate::agents_layout::validate_workspace_cwd; + let ws = validate_workspace_cwd(workspace_cwd)?; + let mut results = Vec::new(); + let mut handled: std::collections::BTreeSet = Default::default(); + for agent in agents { + let Some(fname) = pointer_filename(&agent) else { + results.push(PointerResult { + agent, + path: String::new(), + installed: false, + note: Some("unknown agent".into()), + }); + continue; + }; + if handled.contains(fname) { + results.push(PointerResult { + agent, + path: ws.join(fname).to_string_lossy().into_owned(), + installed: false, + note: Some("shared file already cleaned".into()), + }); + continue; + } + let path = ws.join(fname); + let cursor_style = agent == "cursor"; + let (begin, end) = if cursor_style { + (POINTER_BEGIN_CURSOR, POINTER_END_CURSOR) + } else { + (POINTER_BEGIN, POINTER_END) + }; + let existing = fs::read_to_string(&path).unwrap_or_default(); + if existing.is_empty() { + results.push(PointerResult { + agent, + path: path.to_string_lossy().into_owned(), + installed: false, + note: Some("no file".into()), + }); + continue; + } + let stripped = strip_block(&existing, begin, end); + if stripped.trim().is_empty() { + let _ = fs::remove_file(&path); + } else if let Err(e) = fs::write(&path, stripped.as_bytes()) { + results.push(PointerResult { + agent, + path: path.to_string_lossy().into_owned(), + installed: false, + note: Some(format!("write failed: {e}")), + }); + continue; + } + handled.insert(fname.to_owned()); + results.push(PointerResult { + agent, + path: path.to_string_lossy().into_owned(), + installed: false, + note: Some("removed".into()), + }); + } + Ok(results) +} + +pub fn memory_pointer_status_impl(workspace_cwd: &str) -> Result, String> { + use crate::agents_layout::validate_workspace_cwd; + let ws = validate_workspace_cwd(workspace_cwd)?; + let agents = ["claude", "codex", "gemini", "cursor", "opencode"]; + let mut out = Vec::new(); + for a in agents { + let Some(fname) = pointer_filename(a) else { + continue; + }; + let path = ws.join(fname); + let body = fs::read_to_string(&path).unwrap_or_default(); + let cursor_style = a == "cursor"; + let begin = if cursor_style { + POINTER_BEGIN_CURSOR + } else { + POINTER_BEGIN + }; + out.push(PointerResult { + agent: a.to_owned(), + path: path.to_string_lossy().into_owned(), + installed: body.contains(begin), + note: None, + }); + } + Ok(out) +} diff --git a/src-tauri/src/memory/types.rs b/src-tauri/src/memory/types.rs new file mode 100644 index 0000000..699547f --- /dev/null +++ b/src-tauri/src/memory/types.rs @@ -0,0 +1,128 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum MemoryScope { + Workspace, + Global, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct NoteMeta { + pub scope: MemoryScope, + pub path: String, + pub name: String, + pub title: String, + pub enabled: bool, + pub tags: Vec, + pub size: u64, + pub modified: i64, + pub is_template: bool, + pub is_learning: bool, + pub is_overview: bool, + pub category: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct NoteContent { + pub scope: MemoryScope, + pub path: String, + pub content: String, + pub modified: i64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GraphNode { + pub id: String, + pub scope: MemoryScope, + pub path: String, + pub label: String, + pub tags: Vec, + pub orphan: bool, + pub category: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_category_hub: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hub_scopes: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GraphEdge { + pub source: String, + pub target: String, + pub cross_scope: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GraphData { + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SearchHit { + pub scope: MemoryScope, + pub path: String, + pub line: u32, + pub snippet: String, + pub category: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct BacklinkRef { + pub scope: MemoryScope, + pub path: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MemoryFolderStatus { + pub memory: bool, + pub learnings: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MemoryStatusResponse { + pub workspace: MemoryFolderStatus, + pub global: MemoryFolderStatus, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MemorySubcategories { + pub workspace: Vec, + pub global: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MemoryListResponse { + pub notes: Vec, + pub memory_subcategories: MemorySubcategories, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PointerResult { + pub agent: String, + pub path: String, + pub installed: bool, + pub note: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RenameReport { + pub old_path: String, + pub new_path: String, + pub link_rewrites: u32, + pub files_changed: u32, +} diff --git a/src-tauri/src/memory/wikilinks.rs b/src-tauri/src/memory/wikilinks.rs new file mode 100644 index 0000000..a3187d3 --- /dev/null +++ b/src-tauri/src/memory/wikilinks.rs @@ -0,0 +1,253 @@ +use std::collections::{BTreeSet, HashMap}; + +use super::types::MemoryScope; + +#[derive(Debug, Clone)] +pub struct ParsedWikilink { + pub target: String, + pub alias: Option, + pub scope: Option, +} + +pub fn parse_wikilink_inner(inner: &str) -> Option { + let (target_part, alias) = match inner.find('|') { + Some(i) => (&inner[..i], Some(inner[i + 1..].trim().to_owned())), + None => (inner, None), + }; + let target_part = target_part.trim(); + if target_part.is_empty() { + return None; + } + let (scope, target) = if let Some(colon) = target_part.find(':') { + if colon > 0 { + let prefix = &target_part[..colon]; + match prefix { + "global" => ( + Some(MemoryScope::Global), + target_part[colon + 1..].trim().to_owned(), + ), + "workspace" => ( + Some(MemoryScope::Workspace), + target_part[colon + 1..].trim().to_owned(), + ), + _ => (None, target_part.to_owned()), + } + } else { + (None, target_part.to_owned()) + } + } else { + (None, target_part.to_owned()) + }; + if target.is_empty() { + return None; + } + Some(ParsedWikilink { + target, + alias, + scope, + }) +} + +pub struct ParseResult { + pub links: Vec, + pub tags: Vec, +} + +pub fn parse_links_and_tags(body: &str) -> ParseResult { + let mut links: Vec = Vec::new(); + let mut tags: BTreeSet = BTreeSet::new(); + let bytes = body.as_bytes(); + let mut i = 0usize; + + while i < bytes.len() { + if i + 1 < bytes.len() && bytes[i] == b'[' && bytes[i + 1] == b'[' { + let start = i + 2; + let mut j = start; + let mut found = false; + while j + 1 < bytes.len() { + if bytes[j] == b']' && bytes[j + 1] == b']' { + let inner = &body[start..j]; + if let Some(link) = parse_wikilink_inner(inner) { + links.push(link); + } + i = j + 2; + found = true; + break; + } + j += 1; + } + if !found { + i += 1; + } + continue; + } + if bytes[i] == b'#' { + let prev = if i == 0 { b'\n' } else { bytes[i - 1] }; + if prev == b'\n' || prev == b' ' || prev == b'\t' { + let mut j = i + 1; + while j < bytes.len() { + let c = bytes[j]; + let ok = c.is_ascii_alphanumeric() || c == b'-' || c == b'_' || c == b'/'; + if !ok { + break; + } + j += 1; + } + if j > i + 1 && bytes[i + 1] != b' ' { + let tag = &body[i + 1..j]; + if !tag.is_empty() && !tag.chars().all(|c| c.is_ascii_digit()) { + tags.insert(tag.to_owned()); + } + } + i = j.max(i + 1); + continue; + } + } + i += 1; + } + + ParseResult { + links, + tags: tags.into_iter().collect(), + } +} + +pub struct NoteLookup { + pub by_path: HashMap, + pub by_basename: HashMap>, +} + +pub fn build_note_lookup(api_paths: &[String]) -> NoteLookup { + let mut by_path: HashMap = HashMap::new(); + let mut by_basename: HashMap> = HashMap::new(); + for api in api_paths { + by_path.insert(api.to_lowercase(), api.clone()); + let no_ext = strip_md_ext(api).to_ascii_lowercase(); + by_path.insert(no_ext, api.clone()); + if let Some(base) = api.split('/').last() { + let stem = strip_md_ext(base).to_ascii_lowercase(); + by_basename.entry(stem).or_default().push(api.clone()); + } + } + NoteLookup { + by_path, + by_basename, + } +} + +pub fn resolve_link_target( + source_scope: &MemoryScope, + link: &ParsedWikilink, + scope_paths: &HashMap>, +) -> Option<(MemoryScope, String)> { + let target_scope = link.scope.as_ref().unwrap_or(source_scope); + let empty = Vec::new(); + let paths = scope_paths.get(target_scope).unwrap_or(&empty); + let lookup = build_note_lookup(paths); + let raw = link.target.replace('\\', "/"); + if raw.is_empty() { + return None; + } + let candidate = if raw.ends_with(".md") { + raw.clone() + } else { + format!("{raw}.md") + }; + if let Some(p) = lookup.by_path.get(&candidate.to_ascii_lowercase()) { + return Some((target_scope.clone(), p.clone())); + } + let no_ext = strip_md_ext(&raw).to_ascii_lowercase(); + if let Some(p) = lookup.by_path.get(&no_ext) { + return Some((target_scope.clone(), p.clone())); + } + if let Some(base) = raw.split('/').last() { + let stem = strip_md_ext(base).to_ascii_lowercase(); + if let Some(matches) = lookup.by_basename.get(&stem) { + if matches.len() == 1 { + return Some((target_scope.clone(), matches[0].clone())); + } + } + } + None +} + +pub fn rewrite_wikilinks( + content: &str, + old_basename: &str, + new_basename: &str, + old_path: &str, + new_path: &str, +) -> (String, u32) { + let old_pwx = strip_md_ext(old_path); + let new_pwx = strip_md_ext(new_path); + let mut out = String::with_capacity(content.len()); + let bytes = content.as_bytes(); + let mut i = 0usize; + let mut count = 0u32; + + while i < bytes.len() { + if i + 1 < bytes.len() && bytes[i] == b'[' && bytes[i + 1] == b'[' { + if let Some(end) = find_close_link(&content[i + 2..]) { + let inner = &content[i + 2..i + 2 + end]; + let (target, alias) = match inner.find('|') { + Some(j) => (&inner[..j], Some(&inner[j + 1..])), + None => (inner, None), + }; + let target_t = target.trim(); + let new_target = if target_t.eq_ignore_ascii_case(old_basename) + || target_t.eq_ignore_ascii_case(&old_pwx) + { + if target_t.contains('/') { + Some(new_pwx.clone()) + } else { + Some(new_basename.to_owned()) + } + } else { + None + }; + if let Some(t) = new_target { + out.push_str("[["); + out.push_str(&t); + if let Some(a) = alias { + out.push('|'); + out.push_str(a); + } + out.push_str("]]"); + count += 1; + } else { + out.push_str(&content[i..i + 2 + end + 2]); + } + i += 2 + end + 2; + continue; + } + } + let ch = content[i..].chars().next().unwrap(); + out.push(ch); + i += ch.len_utf8(); + } + (out, count) +} + +pub fn strip_md_ext(p: &str) -> String { + if let Some(idx) = p.rfind('.') { + if p[idx + 1..].eq_ignore_ascii_case("md") { + return p[..idx].to_owned(); + } + } + p.to_owned() +} + +fn find_close_link(s: &str) -> Option { + let bytes = s.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b']' && bytes[i + 1] == b']' { + return Some(i); + } + if bytes[i] == b'\n' { + return None; + } + i += 1; + } + None +} diff --git a/src-tauri/src/plans.rs b/src-tauri/src/plans.rs index 4452929..92471ed 100644 --- a/src-tauri/src/plans.rs +++ b/src-tauri/src/plans.rs @@ -439,10 +439,7 @@ pub fn plan_create_inner( } let body = content.unwrap_or("").to_owned(); let body = if body.is_empty() { - let stem = abs - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("Plan"); + let stem = abs.file_stem().and_then(|s| s.to_str()).unwrap_or("Plan"); format!("# {stem}\n\n## Tasks\n\n") } else { body @@ -528,8 +525,11 @@ pub fn plan_rename_inner( } fs::rename(&abs_old, &abs_new).map_err(|e| format!("rename: {e}"))?; // Plan task records that referenced the old path get rewritten too. - tasks::tasks_rewrite_plan_path(workspace_cwd, &rel_from_root(&root, &abs_old).unwrap_or_default(), - &rel_from_root(&root, &abs_new).unwrap_or_default())?; + tasks::tasks_rewrite_plan_path( + workspace_cwd, + &rel_from_root(&root, &abs_old).unwrap_or_default(), + &rel_from_root(&root, &abs_new).unwrap_or_default(), + )?; meta_from_abs(&root, &abs_new).ok_or_else(|| "failed to read back renamed plan".to_owned()) } @@ -675,10 +675,7 @@ pub fn plan_load(workspace_cwd: String, path: String) -> Result Result { +pub fn plan_sync_from_tasks(workspace_cwd: String, path: String) -> Result { plan_sync_from_tasks_inner(&workspace_cwd, &path) } @@ -796,9 +793,7 @@ mod tests { #[test] fn load_replaces_only_plan_tasks_and_keeps_free_tasks() { - use crate::tasks::{ - tasks_create_inner, tasks_snapshot, TaskCreateInput, TaskStatus, - }; + use crate::tasks::{tasks_create_inner, tasks_snapshot, TaskCreateInput, TaskStatus}; let ws = temp_ws("load_keep_free"); let cwd = ws.to_string_lossy().into_owned(); diff --git a/src-tauri/src/skills_rules/commands.rs b/src-tauri/src/skills_rules/commands.rs index e6d2806..f34954b 100644 --- a/src-tauri/src/skills_rules/commands.rs +++ b/src-tauri/src/skills_rules/commands.rs @@ -22,9 +22,7 @@ use crate::skills_rules::install; use crate::skills_rules::store; -use crate::skills_rules::types::{ - RuleEntry, SkillEntry, SkillSourceInput, -}; +use crate::skills_rules::types::{RuleEntry, SkillEntry, SkillSourceInput}; /// Idempotently create `.agents/{rules,skills}/` plus their `index.json` /// manifests. Safe to call on every workspace open. diff --git a/src-tauri/src/skills_rules/install.rs b/src-tauri/src/skills_rules/install.rs index 2a7dbd5..b592016 100644 --- a/src-tauri/src/skills_rules/install.rs +++ b/src-tauri/src/skills_rules/install.rs @@ -42,9 +42,7 @@ pub fn install_skill(ws: &str, name: &str, source: SkillSourceInput) -> Result { Err("agent-created skills must use skills_write, not skills_install".into()) } - SkillSourceKind::Core => { - Err("core skills are built-in and cannot be installed".into()) - } + SkillSourceKind::Core => Err("core skills are built-in and cannot be installed".into()), } })(); @@ -241,8 +239,9 @@ fn is_safe_npm_version(v: &str) -> bool { let v = v.trim(); !v.is_empty() && !v.starts_with('-') - && v.chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-' | '+' | '~' | '^')) + && v.chars().all(|c| { + c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-' | '+' | '~' | '^') + }) } /// Extracts a `*.tgz` produced by `npm pack` into `dest` by delegating to the diff --git a/src-tauri/src/skills_rules/store.rs b/src-tauri/src/skills_rules/store.rs index 2645011..c60231d 100644 --- a/src-tauri/src/skills_rules/store.rs +++ b/src-tauri/src/skills_rules/store.rs @@ -26,17 +26,32 @@ pub const SKILLS_REL: &str = ".agents/skills"; /// Built-in harness skills embedded in the binary. /// Each entry is `(name, markdown_content)`. pub const CORE_SKILLS: &[(&str, &str)] = &[ - ("file-access", include_str!("../agent/harness_skills/file-access.md")), + ( + "file-access", + include_str!("../agent/harness_skills/file-access.md"), + ), ("memory", include_str!("../agent/harness_skills/memory.md")), ("plans", include_str!("../agent/harness_skills/plans.md")), ("tasks", include_str!("../agent/harness_skills/tasks.md")), - ("rules-skills", include_str!("../agent/harness_skills/rules-skills.md")), - ("harness", include_str!("../agent/harness_skills/harness.md")), - ("environment", include_str!("../agent/harness_skills/environment.md")), + ( + "rules-skills", + include_str!("../agent/harness_skills/rules-skills.md"), + ), + ( + "harness", + include_str!("../agent/harness_skills/harness.md"), + ), + ( + "environment", + include_str!("../agent/harness_skills/environment.md"), + ), ("shell", include_str!("../agent/harness_skills/shell.md")), ("git", include_str!("../agent/harness_skills/git.md")), ("web", include_str!("../agent/harness_skills/web.md")), - ("subagents", include_str!("../agent/harness_skills/subagents.md")), + ( + "subagents", + include_str!("../agent/harness_skills/subagents.md"), + ), ]; const CORE_INSTALLED_AT: &str = "2026-01-01T00:00:00Z"; @@ -250,9 +265,7 @@ fn atomic_write_json(path: &Path, value: &T) -> Result<(), fs::create_dir_all(parent).map_err(|e| format!("mkdir parent: {e}"))?; let tmp = parent.join(format!( ".{}.{}.tmp", - path.file_name() - .and_then(|s| s.to_str()) - .unwrap_or("index"), + path.file_name().and_then(|s| s.to_str()).unwrap_or("index"), std::process::id() )); { @@ -367,8 +380,7 @@ pub fn list_rules(ws: &str) -> Result, String> { let path = roots.rules.join(&name); let body = fs::read_to_string(&path).unwrap_or_default(); let meta = fs::metadata(&path).ok(); - let (title, summary) = - extract_title_and_summary(&body, name.trim_end_matches(".md")); + let (title, summary) = extract_title_and_summary(&body, name.trim_end_matches(".md")); let enabled = idx.rules.get(&name).map(|e| e.enabled).unwrap_or(true); entries.push(RuleEntry { name, @@ -519,11 +531,7 @@ pub fn list_skills(ws: &str) -> Result, String> { .iter() .map(|(name, content)| { let (title, summary) = extract_title_and_summary(content, name); - let enabled = idx - .skills - .get(*name) - .map(|e| e.enabled) - .unwrap_or(true); + let enabled = idx.skills.get(*name).map(|e| e.enabled).unwrap_or(true); SkillEntry { name: name.to_string(), title, @@ -940,7 +948,10 @@ mod tests { set_rule_enabled(&ws_str(&ws), "rule-beta.md", false).unwrap(); let _ = ensure_skills_rules_roots(&ws_str(&ws)).unwrap(); let rules_after = list_rules(&ws_str(&ws)).unwrap(); - let beta = rules_after.iter().find(|r| r.name == "rule-beta.md").unwrap(); + let beta = rules_after + .iter() + .find(|r| r.name == "rule-beta.md") + .unwrap(); assert!(!beta.enabled, "bootstrap must be a no-op when index exists"); let _ = fs::remove_dir_all(&ws); } diff --git a/src-tauri/src/skills_rules/types.rs b/src-tauri/src/skills_rules/types.rs index 5747fb3..8ec1c98 100644 --- a/src-tauri/src/skills_rules/types.rs +++ b/src-tauri/src/skills_rules/types.rs @@ -138,13 +138,19 @@ pub struct SkillIndexEntry { impl Default for RulesIndex { fn default() -> Self { - Self { version: INDEX_VERSION, rules: BTreeMap::new() } + Self { + version: INDEX_VERSION, + rules: BTreeMap::new(), + } } } impl Default for SkillsIndex { fn default() -> Self { - Self { version: INDEX_VERSION, skills: BTreeMap::new() } + Self { + version: INDEX_VERSION, + skills: BTreeMap::new(), + } } } diff --git a/src-tauri/src/voice/settings.rs b/src-tauri/src/voice/settings.rs index 8030232..9613c7e 100644 --- a/src-tauri/src/voice/settings.rs +++ b/src-tauri/src/voice/settings.rs @@ -160,10 +160,11 @@ pub fn save(app: &AppHandle, settings: &VoiceSettings) -> Result Result { match provider { - VoiceProviderKind::Aws => crate::media_keys::resolve_key(crate::media_keys::MediaKeyKind::AwsPolly) - .ok_or_else(|| { - "AWS API key missing. Add it under Settings → API Keys (Amazon Polly).".into() - }), + VoiceProviderKind::Aws => { + crate::media_keys::resolve_key(crate::media_keys::MediaKeyKind::AwsPolly).ok_or_else( + || "AWS API key missing. Add it under Settings → API Keys (Amazon Polly).".into(), + ) + } VoiceProviderKind::Openai => { agent_settings::provider_key_pub(app, agent_settings::AgentProviderKind::Openai) } diff --git a/src-tauri/src/voice/stt.rs b/src-tauri/src/voice/stt.rs index 6430532..607dbe8 100644 --- a/src-tauri/src/voice/stt.rs +++ b/src-tauri/src/voice/stt.rs @@ -35,7 +35,8 @@ pub async fn transcribe_wav( if provider == VoiceProviderKind::Aws { let _ = (model, api_key, wav_path, language); return Err( - "AWS Transcribe STT ist in den Einstellungen wählbar; die Laufzeit-Anbindung folgt.".into(), + "AWS Transcribe STT ist in den Einstellungen wählbar; die Laufzeit-Anbindung folgt." + .into(), ); } let bytes = tokio::fs::read(wav_path) diff --git a/src/i18n/keys.rs b/src/i18n/keys.rs index e31cbcc..b7c8f97 100644 --- a/src/i18n/keys.rs +++ b/src/i18n/keys.rs @@ -243,12 +243,15 @@ pub enum I18nKey { MemGraph3dLoadFailed, MemSearchPh, MemSearchFilterAll, + MemSearchFilterWorkspace, + MemSearchFilterGlobal, MemFilesExpand, MemFilesCollapse, MemFilesGroupMemory, MemFilesGroupLearnings, MemFilesGroupExpand, MemFilesGroupCollapse, + MemGlobalCreate, AgAriaPane, AgSandbox, diff --git a/src/i18n/locales/de_de.rs b/src/i18n/locales/de_de.rs index 2487a03..1feec6c 100644 --- a/src/i18n/locales/de_de.rs +++ b/src/i18n/locales/de_de.rs @@ -241,12 +241,15 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::MemGraph3dLoadFailed => "3D-Grafik konnte nicht geladen werden. 2D-Grafik wird angezeigt.", I18nKey::MemSearchPh => "Notizen durchsuchen…", I18nKey::MemSearchFilterAll => "Alle", + I18nKey::MemSearchFilterWorkspace => "Arbeitsbereich", + I18nKey::MemSearchFilterGlobal => "Global", I18nKey::MemFilesExpand => "Dateiliste erweitern", I18nKey::MemFilesCollapse => "Dateiliste einklappen", I18nKey::MemFilesGroupMemory => "Erinnerung", I18nKey::MemFilesGroupLearnings => "Erkenntnisse", I18nKey::MemFilesGroupExpand => "Gruppe aufklappen", I18nKey::MemFilesGroupCollapse => "Gruppe einklappen", + I18nKey::MemGlobalCreate => "Globalen Speicher anlegen", I18nKey::AgAriaPane => "Agent Harness", I18nKey::AgSandbox => "Tool-Sandbox ", I18nKey::AgNoPath => "(kein Pfad)", diff --git a/src/i18n/locales/en_us.rs b/src/i18n/locales/en_us.rs index d9472ee..8cd5b74 100644 --- a/src/i18n/locales/en_us.rs +++ b/src/i18n/locales/en_us.rs @@ -266,12 +266,15 @@ Classic: Ctrl+O quick open, Ctrl+` new terminal, Ctrl+Shift+P palette." I18nKey::MemGraph3dLoadFailed => "3D graph could not load. Showing 2D graph.", I18nKey::MemSearchPh => "Search notes…", I18nKey::MemSearchFilterAll => "All", + I18nKey::MemSearchFilterWorkspace => "Workspace", + I18nKey::MemSearchFilterGlobal => "Global", I18nKey::MemFilesExpand => "Expand file list", I18nKey::MemFilesCollapse => "Collapse file list", I18nKey::MemFilesGroupMemory => "Memory", I18nKey::MemFilesGroupLearnings => "Learnings", I18nKey::MemFilesGroupExpand => "Expand group", I18nKey::MemFilesGroupCollapse => "Collapse group", + I18nKey::MemGlobalCreate => "Create Global Memory", I18nKey::AgAriaPane => "Agent harness", I18nKey::AgSandbox => "Tool sandbox ", diff --git a/src/i18n/locales/es_es.rs b/src/i18n/locales/es_es.rs index 364716d..84c7e2b 100644 --- a/src/i18n/locales/es_es.rs +++ b/src/i18n/locales/es_es.rs @@ -241,12 +241,15 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::MemGraph3dLoadFailed => "No se pudo cargar el gráfico 3D. Mostrando gráfico 2D.", I18nKey::MemSearchPh => "Notas de búsqueda...", I18nKey::MemSearchFilterAll => "Todo", + I18nKey::MemSearchFilterWorkspace => "Espacio de trabajo", + I18nKey::MemSearchFilterGlobal => "Global", I18nKey::MemFilesExpand => "Expandir lista de archivos", I18nKey::MemFilesCollapse => "Contraer lista de archivos", I18nKey::MemFilesGroupMemory => "Memoria", I18nKey::MemFilesGroupLearnings => "Aprendizajes", I18nKey::MemFilesGroupExpand => "Expandir grupo", I18nKey::MemFilesGroupCollapse => "Colapsar grupo", + I18nKey::MemGlobalCreate => "Crear memoria global", I18nKey::AgAriaPane => "Arnés de agente", I18nKey::AgSandbox => "Caja de arena para herramientas", I18nKey::AgNoPath => "(sin camino)", diff --git a/src/i18n/locales/fr_fr.rs b/src/i18n/locales/fr_fr.rs index 3cc3400..e0069fb 100644 --- a/src/i18n/locales/fr_fr.rs +++ b/src/i18n/locales/fr_fr.rs @@ -243,12 +243,15 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::MemGraph3dLoadFailed => "Le graphique 3D n'a pas pu se charger. Affichage d'un graphique 2D.", I18nKey::MemSearchPh => "Notes de recherche…", I18nKey::MemSearchFilterAll => "Tous", + I18nKey::MemSearchFilterWorkspace => "Espace de travail", + I18nKey::MemSearchFilterGlobal => "Global", I18nKey::MemFilesExpand => "Développer la liste des fichiers", I18nKey::MemFilesCollapse => "Réduire la liste des fichiers", I18nKey::MemFilesGroupMemory => "Mémoire", I18nKey::MemFilesGroupLearnings => "Apprentissages", I18nKey::MemFilesGroupExpand => "Agrandir le groupe", I18nKey::MemFilesGroupCollapse => "Réduire le groupe", + I18nKey::MemGlobalCreate => "Créer la mémoire globale", I18nKey::AgAriaPane => "Harnais d'agent", I18nKey::AgSandbox => "Bac à sable à outils", I18nKey::AgNoPath => "(pas de chemin)", diff --git a/src/i18n/locales/hu_hu.rs b/src/i18n/locales/hu_hu.rs index fe9c523..bddd285 100644 --- a/src/i18n/locales/hu_hu.rs +++ b/src/i18n/locales/hu_hu.rs @@ -241,12 +241,15 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::MemGraph3dLoadFailed => "A 3D-s grafikont nem sikerült betölteni. 2D grafikon megjelenítése.", I18nKey::MemSearchPh => "Jegyzetek keresése…", I18nKey::MemSearchFilterAll => "Minden", + I18nKey::MemSearchFilterWorkspace => "Munkaterület", + I18nKey::MemSearchFilterGlobal => "Globális", I18nKey::MemFilesExpand => "A fájllista kibontása", I18nKey::MemFilesCollapse => "Fájllista összecsukása", I18nKey::MemFilesGroupMemory => "Memória", I18nKey::MemFilesGroupLearnings => "Tanulmányok", I18nKey::MemFilesGroupExpand => "Csoport bővítése", I18nKey::MemFilesGroupCollapse => "Csoport összecsukása", + I18nKey::MemGlobalCreate => "Globális memória létrehozása", I18nKey::AgAriaPane => "Ügynök hám", I18nKey::AgSandbox => "Szerszám homokozó", I18nKey::AgNoPath => "(nincs út)", diff --git a/src/i18n/locales/it_it.rs b/src/i18n/locales/it_it.rs index d01545f..164ca78 100644 --- a/src/i18n/locales/it_it.rs +++ b/src/i18n/locales/it_it.rs @@ -241,12 +241,15 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::MemGraph3dLoadFailed => "Impossibile caricare il grafico 3D. Mostra il grafico 2D.", I18nKey::MemSearchPh => "Cerca note...", I18nKey::MemSearchFilterAll => "Tutto", + I18nKey::MemSearchFilterWorkspace => "Area di lavoro", + I18nKey::MemSearchFilterGlobal => "Globale", I18nKey::MemFilesExpand => "Espandi l'elenco dei file", I18nKey::MemFilesCollapse => "Comprimi l'elenco dei file", I18nKey::MemFilesGroupMemory => "Memoria", I18nKey::MemFilesGroupLearnings => "Apprendimenti", I18nKey::MemFilesGroupExpand => "Espandi gruppo", I18nKey::MemFilesGroupCollapse => "Comprimi gruppo", + I18nKey::MemGlobalCreate => "Crea memoria globale", I18nKey::AgAriaPane => "Imbracatura dell'agente", I18nKey::AgSandbox => "Sabbiera per strumenti", I18nKey::AgNoPath => "(nessun percorso)", diff --git a/src/i18n/locales/ja_jp.rs b/src/i18n/locales/ja_jp.rs index 2e49293..432a880 100644 --- a/src/i18n/locales/ja_jp.rs +++ b/src/i18n/locales/ja_jp.rs @@ -235,12 +235,15 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::MemGraph3dLoadFailed => "3D グラフを読み込めませんでした。 2D グラフを表示します。", I18nKey::MemSearchPh => "メモを検索…", I18nKey::MemSearchFilterAll => "全て", + I18nKey::MemSearchFilterWorkspace => "ワークスペース", + I18nKey::MemSearchFilterGlobal => "グローバル", I18nKey::MemFilesExpand => "ファイルリストを展開する", I18nKey::MemFilesCollapse => "ファイルリストを折りたたむ", I18nKey::MemFilesGroupMemory => "メモリ", I18nKey::MemFilesGroupLearnings => "学び", I18nKey::MemFilesGroupExpand => "グループを展開する", I18nKey::MemFilesGroupCollapse => "グループを折りたたむ", + I18nKey::MemGlobalCreate => "グローバルメモリを作成", I18nKey::AgAriaPane => "エージェントハーネス", I18nKey::AgSandbox => "ツールサンドボックス", I18nKey::AgNoPath => "(パスがありません)", diff --git a/src/i18n/locales/ko_kr.rs b/src/i18n/locales/ko_kr.rs index 0ec9908..77574b6 100644 --- a/src/i18n/locales/ko_kr.rs +++ b/src/i18n/locales/ko_kr.rs @@ -235,12 +235,15 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::MemGraph3dLoadFailed => "3D 그래프를 로드할 수 없습니다. 2D 그래프를 표시합니다.", I18nKey::MemSearchPh => "메모 검색…", I18nKey::MemSearchFilterAll => "모두", + I18nKey::MemSearchFilterWorkspace => "작업 영역", + I18nKey::MemSearchFilterGlobal => "전역", I18nKey::MemFilesExpand => "파일 목록 펼치기", I18nKey::MemFilesCollapse => "파일 목록 축소", I18nKey::MemFilesGroupMemory => "메모리", I18nKey::MemFilesGroupLearnings => "학습", I18nKey::MemFilesGroupExpand => "그룹 펼치기", I18nKey::MemFilesGroupCollapse => "그룹 축소", + I18nKey::MemGlobalCreate => "전역 메모리 만들기", I18nKey::AgAriaPane => "에이전트 하네스", I18nKey::AgSandbox => "도구 샌드박스", I18nKey::AgNoPath => "(경로 없음)", diff --git a/src/i18n/locales/pl_pl.rs b/src/i18n/locales/pl_pl.rs index 593983d..6600247 100644 --- a/src/i18n/locales/pl_pl.rs +++ b/src/i18n/locales/pl_pl.rs @@ -239,12 +239,15 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::MemGraph3dLoadFailed => "Nie można wczytać wykresu 3D. Wyświetlanie wykresu 2D.", I18nKey::MemSearchPh => "Wyszukaj notatki…", I18nKey::MemSearchFilterAll => "Wszystko", + I18nKey::MemSearchFilterWorkspace => "Obszar roboczy", + I18nKey::MemSearchFilterGlobal => "Globalne", I18nKey::MemFilesExpand => "Rozwiń listę plików", I18nKey::MemFilesCollapse => "Zwiń listę plików", I18nKey::MemFilesGroupMemory => "Pamięć", I18nKey::MemFilesGroupLearnings => "Nauka", I18nKey::MemFilesGroupExpand => "Rozwiń grupę", I18nKey::MemFilesGroupCollapse => "Zwiń grupę", + I18nKey::MemGlobalCreate => "Utwórz pamięć globalną", I18nKey::AgAriaPane => "Uprząż agenta", I18nKey::AgSandbox => "Piaskownica narzędziowa", I18nKey::AgNoPath => "(bez ścieżki)", diff --git a/src/i18n/locales/pt_br.rs b/src/i18n/locales/pt_br.rs index 20c3356..91c1023 100644 --- a/src/i18n/locales/pt_br.rs +++ b/src/i18n/locales/pt_br.rs @@ -241,12 +241,15 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::MemGraph3dLoadFailed => "O gráfico 3D não pôde ser carregado. Mostrando gráfico 2D.", I18nKey::MemSearchPh => "Notas de pesquisa…", I18nKey::MemSearchFilterAll => "Todos", + I18nKey::MemSearchFilterWorkspace => "Workspace", + I18nKey::MemSearchFilterGlobal => "Global", I18nKey::MemFilesExpand => "Expandir lista de arquivos", I18nKey::MemFilesCollapse => "Recolher lista de arquivos", I18nKey::MemFilesGroupMemory => "Memória", I18nKey::MemFilesGroupLearnings => "Aprendizados", I18nKey::MemFilesGroupExpand => "Expandir grupo", I18nKey::MemFilesGroupCollapse => "Recolher grupo", + I18nKey::MemGlobalCreate => "Criar memória global", I18nKey::AgAriaPane => "Arnês do agente", I18nKey::AgSandbox => "Caixa de areia de ferramentas", I18nKey::AgNoPath => "(sem caminho)", diff --git a/src/i18n/locales/ru_ru.rs b/src/i18n/locales/ru_ru.rs index d140b01..1264a69 100644 --- a/src/i18n/locales/ru_ru.rs +++ b/src/i18n/locales/ru_ru.rs @@ -239,12 +239,15 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::MemGraph3dLoadFailed => "Не удалось загрузить 3D-график. Показан 2D-график.", I18nKey::MemSearchPh => "Поиск заметок…", I18nKey::MemSearchFilterAll => "Все", + I18nKey::MemSearchFilterWorkspace => "Рабочая область", + I18nKey::MemSearchFilterGlobal => "Глобально", I18nKey::MemFilesExpand => "Развернуть список файлов", I18nKey::MemFilesCollapse => "Свернуть список файлов", I18nKey::MemFilesGroupMemory => "Память", I18nKey::MemFilesGroupLearnings => "Обучение", I18nKey::MemFilesGroupExpand => "Развернуть группу", I18nKey::MemFilesGroupCollapse => "Свернуть группу", + I18nKey::MemGlobalCreate => "Создать глобальную память", I18nKey::AgAriaPane => "Агентская обвязка", I18nKey::AgSandbox => "Песочница для инструментов", I18nKey::AgNoPath => "(нет пути)", diff --git a/src/i18n/locales/zh_cn.rs b/src/i18n/locales/zh_cn.rs index 5f9a65d..b1ba3fc 100644 --- a/src/i18n/locales/zh_cn.rs +++ b/src/i18n/locales/zh_cn.rs @@ -235,12 +235,15 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::MemGraph3dLoadFailed => "3D 图表无法加载。显示 2D 图表。", I18nKey::MemSearchPh => "搜索笔记...", I18nKey::MemSearchFilterAll => "全部", + I18nKey::MemSearchFilterWorkspace => "工作区", + I18nKey::MemSearchFilterGlobal => "全局", I18nKey::MemFilesExpand => "展开文件列表", I18nKey::MemFilesCollapse => "折叠文件列表", I18nKey::MemFilesGroupMemory => "记忆", I18nKey::MemFilesGroupLearnings => "学习内容", I18nKey::MemFilesGroupExpand => "展开组", I18nKey::MemFilesGroupCollapse => "折叠组", + I18nKey::MemGlobalCreate => "创建全局记忆", I18nKey::AgAriaPane => "代理线束", I18nKey::AgSandbox => "工具沙箱", I18nKey::AgNoPath => "(没有路径)", diff --git a/src/i18n/locales/zh_tw.rs b/src/i18n/locales/zh_tw.rs index 11217f8..9dff9a3 100644 --- a/src/i18n/locales/zh_tw.rs +++ b/src/i18n/locales/zh_tw.rs @@ -235,12 +235,15 @@ pub fn msg(key: I18nKey) -> &'static str { I18nKey::MemGraph3dLoadFailed => "3D 圖表無法載入。顯示 2D 圖表。", I18nKey::MemSearchPh => "搜尋筆記...", I18nKey::MemSearchFilterAll => "全部", + I18nKey::MemSearchFilterWorkspace => "工作區", + I18nKey::MemSearchFilterGlobal => "全域", I18nKey::MemFilesExpand => "展開文件列表", I18nKey::MemFilesCollapse => "折疊文件列表", I18nKey::MemFilesGroupMemory => "記憶", I18nKey::MemFilesGroupLearnings => "學習內容", I18nKey::MemFilesGroupExpand => "展開組", I18nKey::MemFilesGroupCollapse => "折疊組", + I18nKey::MemGlobalCreate => "建立全域記憶", I18nKey::AgAriaPane => "代理線束", I18nKey::AgSandbox => "工具沙箱", I18nKey::AgNoPath => "(沒有路徑)", diff --git a/src/main.rs b/src/main.rs index 918957e..e572462 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,9 +7,9 @@ mod memory_paths; mod open_http; mod quit; mod service; -mod theme; mod skills_rules_wire; mod tauri_bridge; +mod theme; mod workbench; use app::*; diff --git a/src/tauri_bridge.rs b/src/tauri_bridge.rs index 549c373..0703805 100644 --- a/src/tauri_bridge.rs +++ b/src/tauri_bridge.rs @@ -553,7 +553,6 @@ pub async fn agent_web_settings_save( .await } - pub async fn agent_environment_invalidate() -> Result<(), String> { invoke_unit_js("agent_environment_invalidate", JsValue::NULL).await } @@ -717,10 +716,7 @@ pub struct BinaryFilePreview { pub truncated: bool, } -pub async fn stat_workspace_file( - workspace_root: String, - path: String, -) -> Result { +pub async fn stat_workspace_file(workspace_root: String, path: String) -> Result { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct A { @@ -1146,27 +1142,68 @@ pub async fn agent_latest_session_id(agent: String, cwd: String) -> Result Result<(), String> { invoke_typed("workspace_ensure_agents", WsArg { workspace_cwd: ws }).await } +// ── Scope ────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum MemoryScope { + Workspace, + Global, +} + +/// Composite key used as node ID and for addressing notes across scopes. +pub fn note_key(scope: &MemoryScope, path: &str) -> String { + let s = match scope { + MemoryScope::Workspace => "workspace", + MemoryScope::Global => "global", + }; + format!("{s}:{path}") +} + +pub fn parse_note_key(key: &str) -> Option<(MemoryScope, String)> { + let idx = key.find(':')?; + if idx == 0 { + return None; + } + let scope = match &key[..idx] { + "workspace" => MemoryScope::Workspace, + "global" => MemoryScope::Global, + _ => return None, + }; + Some((scope, key[idx + 1..].to_owned())) +} + +// ── Types ───────────────────────────────────────────────────────────────────── + #[allow(dead_code)] #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct NoteMeta { + pub scope: MemoryScope, pub path: String, pub name: String, + pub title: String, + pub enabled: bool, + pub tags: Vec, pub size: u64, pub modified: i64, pub is_template: bool, + pub is_learning: bool, + pub is_overview: bool, + pub category: String, } #[allow(dead_code)] #[derive(Clone, Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct NoteContent { + pub scope: MemoryScope, pub path: String, pub content: String, pub modified: i64, @@ -1177,15 +1214,18 @@ pub struct NoteContent { #[serde(rename_all = "camelCase")] pub struct GraphNode { pub id: String, + pub scope: MemoryScope, + pub path: String, pub label: String, pub tags: Vec, pub orphan: bool, + pub category: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub color: Option, - /// Category key (derived from path prefix). Filled in by the frontend - /// after fetching, used by the renderer to cluster nodes per category. + pub is_category_hub: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub category: Option, + pub hub_scopes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, } #[allow(dead_code)] @@ -1194,6 +1234,7 @@ pub struct GraphNode { pub struct GraphEdge { pub source: String, pub target: String, + pub cross_scope: bool, } #[allow(dead_code)] @@ -1208,9 +1249,51 @@ pub struct GraphData { #[derive(Clone, Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchHit { + pub scope: MemoryScope, pub path: String, pub line: u32, pub snippet: String, + pub category: String, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BacklinkRef { + pub scope: MemoryScope, + pub path: String, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryFolderStatus { + pub memory: bool, + pub learnings: bool, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryStatusResponse { + pub workspace: MemoryFolderStatus, + pub global: MemoryFolderStatus, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemorySubcategories { + pub workspace: Vec, + pub global: Vec, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryListResponse { + pub notes: Vec, + pub memory_subcategories: MemorySubcategories, } #[allow(dead_code)] @@ -1239,32 +1322,63 @@ struct WsArg<'a> { workspace_cwd: &'a str, } -pub async fn memory_list(ws: &str) -> Result, String> { +// ── Commands ────────────────────────────────────────────────────────────────── + +pub async fn memory_status(ws: &str) -> Result { + invoke_typed("memory_status", WsArg { workspace_cwd: ws }).await +} + +pub async fn memory_bootstrap(ws: &str, target: &str) -> Result<(), String> { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct A<'a> { + workspace_cwd: &'a str, + target: &'a str, + } + invoke_unit_js( + "memory_bootstrap", + args_value(A { + workspace_cwd: ws, + target, + })?, + ) + .await +} + +pub async fn memory_list(ws: &str) -> Result { invoke_typed("memory_list", WsArg { workspace_cwd: ws }).await } -pub async fn memory_read(ws: &str, path: &str) -> Result { +pub async fn memory_read(ws: &str, scope: &MemoryScope, path: &str) -> Result { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct A<'a> { workspace_cwd: &'a str, + scope: &'a MemoryScope, path: &'a str, } invoke_typed( "memory_read", A { workspace_cwd: ws, + scope, path, }, ) .await } -pub async fn memory_write(ws: &str, path: &str, content: &str) -> Result { +pub async fn memory_write( + ws: &str, + scope: &MemoryScope, + path: &str, + content: &str, +) -> Result { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct A<'a> { workspace_cwd: &'a str, + scope: &'a MemoryScope, path: &'a str, content: &'a str, } @@ -1272,6 +1386,7 @@ pub async fn memory_write(ws: &str, path: &str, content: &str) -> Result Result, ) -> Result { @@ -1288,6 +1404,7 @@ pub async fn memory_create( #[serde(rename_all = "camelCase")] struct A<'a> { workspace_cwd: &'a str, + scope: &'a MemoryScope, path: &'a str, #[serde(skip_serializing_if = "Option::is_none")] content: Option<&'a str>, @@ -1296,6 +1413,7 @@ pub async fn memory_create( "memory_create", A { workspace_cwd: ws, + scope, path, content, }, @@ -1303,38 +1421,42 @@ pub async fn memory_create( .await } -pub async fn memory_list_categories(ws: &str) -> Result, String> { - invoke_typed("memory_list_categories", WsArg { workspace_cwd: ws }).await -} - -pub async fn memory_create_category(ws: &str, name: &str) -> Result { +pub async fn memory_create_category( + ws: &str, + scope: &MemoryScope, + name: &str, +) -> Result { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct A<'a> { workspace_cwd: &'a str, + scope: &'a MemoryScope, name: &'a str, } invoke_typed( "memory_create_category", A { workspace_cwd: ws, + scope, name, }, ) .await } -pub async fn memory_delete(ws: &str, path: &str) -> Result<(), String> { +pub async fn memory_delete(ws: &str, scope: &MemoryScope, path: &str) -> Result<(), String> { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct A<'a> { workspace_cwd: &'a str, + scope: &'a MemoryScope, path: &'a str, } invoke_unit_js( "memory_delete", args_value(A { workspace_cwd: ws, + scope, path, })?, ) @@ -1343,6 +1465,7 @@ pub async fn memory_delete(ws: &str, path: &str) -> Result<(), String> { pub async fn memory_rename( ws: &str, + scope: &MemoryScope, old_path: &str, new_path: &str, rewrite_links: bool, @@ -1351,6 +1474,7 @@ pub async fn memory_rename( #[serde(rename_all = "camelCase")] struct A<'a> { workspace_cwd: &'a str, + scope: &'a MemoryScope, old_path: &'a str, new_path: &'a str, rewrite_links: bool, @@ -1359,6 +1483,7 @@ pub async fn memory_rename( "memory_rename", A { workspace_cwd: ws, + scope, old_path, new_path, rewrite_links, @@ -1371,17 +1496,23 @@ pub async fn memory_graph(ws: &str) -> Result { invoke_typed("memory_graph", WsArg { workspace_cwd: ws }).await } -pub async fn memory_backlinks(ws: &str, path: &str) -> Result, String> { +pub async fn memory_backlinks( + ws: &str, + scope: &MemoryScope, + path: &str, +) -> Result, String> { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct A<'a> { workspace_cwd: &'a str, + scope: &'a MemoryScope, path: &'a str, } invoke_typed( "memory_backlinks", A { workspace_cwd: ws, + scope, path, }, ) @@ -1824,11 +1955,7 @@ pub async fn git_status_changes(cwd: String) -> Result, String> invoke_typed("git_status_changes", Args { cwd }).await } -pub async fn git_file_diff( - cwd: String, - rel_path: String, - staged: bool, -) -> Result { +pub async fn git_file_diff(cwd: String, rel_path: String, staged: bool) -> Result { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct Args { @@ -1905,7 +2032,8 @@ pub fn listen_git_status_dirty( #[wasm_bindgen] extern "C" { #[wasm_bindgen(catch, js_namespace = ["window", "__TAURI__", "event"], js_name = listen)] - async fn tauri_listen_raw(event: &str, callback: &js_sys::Function) -> Result; + async fn tauri_listen_raw(event: &str, callback: &js_sys::Function) + -> Result; } /// RAII handle returned by [`listen_tauri_event`]. Drop calls the diff --git a/src/theme/catalog.rs b/src/theme/catalog.rs index c0502a0..4c67d4c 100644 --- a/src/theme/catalog.rs +++ b/src/theme/catalog.rs @@ -240,10 +240,6 @@ pub fn is_valid_theme_id(id: &str) -> bool { pub fn themes_for_mode(mode: Option) -> Vec { match mode { None => THEMES.iter().copied().collect(), - Some(m) => THEMES - .iter() - .copied() - .filter(|t| t.mode == m) - .collect(), + Some(m) => THEMES.iter().copied().filter(|t| t.mode == m).collect(), } } diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 106ebec..620958d 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -1,7 +1,5 @@ mod catalog; mod i18n; -pub use catalog::{ - is_valid_theme_id, theme_by_id, AppTheme, ThemeMode, DEFAULT_THEME_ID, THEMES, -}; +pub use catalog::{is_valid_theme_id, theme_by_id, AppTheme, ThemeMode, DEFAULT_THEME_ID, THEMES}; pub use i18n::{theme_desc_key, theme_name_key}; diff --git a/src/workbench/agent_context_handoff.rs b/src/workbench/agent_context_handoff.rs index 6b0e673..1583a43 100644 --- a/src/workbench/agent_context_handoff.rs +++ b/src/workbench/agent_context_handoff.rs @@ -133,7 +133,10 @@ pub fn render_agent_context_block(input: &RenderInputs) -> String { out.push_str(&format!(" - `{p}`\n")); } } else if item.paths.len() > 12 { - out.push_str(&format!(" - ({} paths — see manifest)\n", item.paths.len())); + out.push_str(&format!( + " - ({} paths — see manifest)\n", + item.paths.len() + )); } } } @@ -599,8 +602,8 @@ pub async fn perform_handoff( // Pull workspace-attached context once. We keep plan items separate from // memory-style items so the kind filters can be respected. - let attached = context_items - .unwrap_or_else(|| wb.agent_context_for_workspace_untracked(workspace_id)); + let attached = + context_items.unwrap_or_else(|| wb.agent_context_for_workspace_untracked(workspace_id)); let mut effective_items: Vec = Vec::new(); for item in attached { let is_plan = matches!( @@ -1008,7 +1011,10 @@ fn build_plan_task_snapshot( for task in &snapshot.tasks { if let Some(path) = task.plan_path.as_deref() { if attached_plans.is_empty() || attached_plans.contains(path) { - buckets.entry(path.to_owned()).or_default().push(task.clone()); + buckets + .entry(path.to_owned()) + .or_default() + .push(task.clone()); } } } @@ -1120,10 +1126,7 @@ pub fn file_snippet_context_item( Some(ws) if !ws.is_empty() => format!("file-snippet:{ws}:{rel_path}:{start}-{end}"), _ => format!("file-snippet:{rel_path}:{start}-{end}"), }; - let source = format!( - "{} · lines {start}-{end}", - language.unwrap_or("text") - ); + let source = format!("{} · lines {start}-{end}", language.unwrap_or("text")); AgentContextItem { id, kind: AgentContextKind::FileSnippet, @@ -1204,10 +1207,7 @@ mod tests { workspace_root: Some("/repo".into()), include_memory: true, include_images: true, - context_items: vec![note_context_item( - "learnings/index.md", - "Learnings", - )], + context_items: vec![note_context_item("learnings/index.md", "Learnings")], images: vec![RenderImageMeta { id: "img".into(), label: "Shot".into(), @@ -1352,15 +1352,7 @@ mod tests { #[test] fn file_snippet_item_id_includes_workspace_when_provided() { - let item = file_snippet_context_item( - "src/foo.rs", - 1, - 1, - None, - "S", - "x", - Some("Demo"), - ); + let item = file_snippet_context_item("src/foo.rs", 1, 1, None, "S", "x", Some("Demo")); assert_eq!(item.id, "file-snippet:Demo:src/foo.rs:1-1"); } diff --git a/src/workbench/agent_model_picker/mod.rs b/src/workbench/agent_model_picker/mod.rs index 159dbc1..e6f518b 100644 --- a/src/workbench/agent_model_picker/mod.rs +++ b/src/workbench/agent_model_picker/mod.rs @@ -78,13 +78,7 @@ fn focus_by_id(id: &str) { fn model_option_dom_id(prefix: &str, model_id: &str) -> String { let slug: String = model_id .chars() - .map(|c| { - if c.is_ascii_alphanumeric() { - c - } else { - '-' - } - }) + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect(); format!("{prefix}-option-{slug}") } @@ -94,10 +88,8 @@ pub fn AgentModelPicker( model_id: RwSignal, model_entries: RwSignal>, loading_models: RwSignal, - #[prop(default = "agent-model")] - option_id_prefix: &'static str, - #[prop(default = true)] - show_custom_field: bool, + #[prop(default = "agent-model")] option_id_prefix: &'static str, + #[prop(default = true)] show_custom_field: bool, #[prop(optional)] on_change: Option>, ) -> impl IntoView { let i18n = expect_context::(); diff --git a/src/workbench/agent_panel/ask_user_card/mod.rs b/src/workbench/agent_panel/ask_user_card/mod.rs index 52bf0bc..b13ed60 100644 --- a/src/workbench/agent_panel/ask_user_card/mod.rs +++ b/src/workbench/agent_panel/ask_user_card/mod.rs @@ -300,12 +300,7 @@ fn mark_cancelled(timeline: RwSignal>, call_id: &str) { }); } -fn submit_async( - call_id: String, - ok: bool, - message: String, - data: Option, -) { +fn submit_async(call_id: String, ok: bool, message: String, data: Option) { leptos::task::spawn_local(async move { let _ = agent_submit_tool_result(call_id, ok, Some(message), data).await; }); diff --git a/src/workbench/agent_panel/client_tools.rs b/src/workbench/agent_panel/client_tools.rs index 15b082f..2fb8942 100644 --- a/src/workbench/agent_panel/client_tools.rs +++ b/src/workbench/agent_panel/client_tools.rs @@ -273,8 +273,9 @@ fn handle_memory_context_attach( }; leptos::task::spawn_local(async move { match memory_list(&cwd).await { - Ok(notes) => { - let paths: Vec = notes + Ok(resp) => { + let paths: Vec = resp + .notes .into_iter() .filter(|n| { if category == "learnings" { diff --git a/src/workbench/agent_panel/mod.rs b/src/workbench/agent_panel/mod.rs index fa12a05..f563a7d 100644 --- a/src/workbench/agent_panel/mod.rs +++ b/src/workbench/agent_panel/mod.rs @@ -22,14 +22,13 @@ use crate::workbench::agent_panel::image_context::{ clear_drop_state, handle_dom_drag_event, handle_dom_drop, install_agent_image_intake, DropZoneState, }; -use crate::workbench::agent_panel::task_list::TaskSection; use crate::workbench::agent_panel::subagent_debounce::{ is_subagent_timeline_event, SubagentEventDebounce, }; +use crate::workbench::agent_panel::task_list::TaskSection; use crate::workbench::agent_panel::timeline::{ apply_agent_event, compact_timeline, ChatLineIndexColumn, TimelineItem, TimelineRow, }; -use std::rc::Rc; use crate::workbench::agent_panel::voice_orb::{ handle_voice_event, install_ptt_hotkey, VoiceOrb, VoiceOrbHandle, }; @@ -38,6 +37,7 @@ use leptos::html; use leptos::prelude::*; use leptos_icons::Icon as LxIcon; use std::collections::HashMap; +use std::rc::Rc; use wasm_bindgen::JsCast; #[component] @@ -146,10 +146,7 @@ pub fn AgentPanelDock() -> impl IntoView { // Prime the handoff renderer cache so terminal handoffs after // a workspace reload see the restored plan-task state. if let Some(ws_id) = active { - crate::workbench::agent_context_handoff::store_task_snapshot( - ws_id, - next.clone(), - ); + crate::workbench::agent_context_handoff::store_task_snapshot(ws_id, next.clone()); } task_snapshot_sig.set(next); }); diff --git a/src/workbench/agent_panel/timeline.rs b/src/workbench/agent_panel/timeline.rs index 83e77ea..64a56b1 100644 --- a/src/workbench/agent_panel/timeline.rs +++ b/src/workbench/agent_panel/timeline.rs @@ -2,6 +2,8 @@ use crate::agent_wire::{AgentEvent, TaskSnapshot, TurnMetrics, TurnUsageKind}; use crate::i18n::{lookup, I18nKey, Locale}; use crate::service::I18nService; use crate::tauri_bridge::{is_tauri_shell, voice_settings_get}; +use crate::workbench::agent_panel::ask_user_card::AskUserCard; +use crate::workbench::agent_panel::turn_metrics_bar::{BarContext, TurnMetricsBar}; use crate::workbench::agent_panel::voice_orb::{ play_line_tts, tts_line_playback_available, VoiceOrbHandle, }; @@ -10,8 +12,6 @@ use crate::workbench::agent_timeline::{ subagent_role_label, subagent_status_label, ActivityStatus, AskUserOption, AskUserState, SubagentCard, SubagentGroup, SubagentStepRow, ToolActivity, }; -use crate::workbench::agent_panel::ask_user_card::AskUserCard; -use crate::workbench::agent_panel::turn_metrics_bar::{BarContext, TurnMetricsBar}; use crate::workbench::chat_markdown::render_markdown_to_html; use crate::workbench::WorkbenchService; use leptos::prelude::*; @@ -20,7 +20,9 @@ use std::collections::HashMap; #[derive(Clone, Debug, PartialEq)] pub enum DisplayTimelineItem { - User { text: String }, + User { + text: String, + }, Assistant { text: String, /// Latest user-message text preceding this assistant block — used as @@ -37,7 +39,10 @@ pub enum DisplayTimelineItem { tools: Vec, }, SubagentGroup(SubagentGroup), - Thinking { text: String, done: bool }, + Thinking { + text: String, + done: bool, + }, GeneratedImage { prompt: String, mime: String, @@ -75,7 +80,10 @@ fn persist_agent_timeline( .filter(|item| { !matches!( item, - TimelineItem::AskUser { state: AskUserState::Open, .. } + TimelineItem::AskUser { + state: AskUserState::Open, + .. + } ) }) .map(|item| match item { @@ -86,13 +94,15 @@ fn persist_agent_timeline( saved_path, filename, } => { - let drop_preview = saved_path - .as_deref() - .is_some_and(|p| !p.trim().is_empty()); + let drop_preview = saved_path.as_deref().is_some_and(|p| !p.trim().is_empty()); TimelineItem::GeneratedImage { prompt, mime, - preview_src: if drop_preview { String::new() } else { preview_src }, + preview_src: if drop_preview { + String::new() + } else { + preview_src + }, saved_path, filename, } @@ -163,10 +173,9 @@ fn find_subagent_card_mut<'a>( agent_id: &str, ) -> Option<&'a mut SubagentCard> { rows.iter_mut().rev().find_map(|entry| match entry { - TimelineItem::SubagentGroup(group) => group - .agents - .iter_mut() - .find(|c| c.agent_id == agent_id), + TimelineItem::SubagentGroup(group) => { + group.agents.iter_mut().find(|c| c.agent_id == agent_id) + } _ => None, }) } @@ -282,7 +291,11 @@ pub fn apply_agent_event( }); persist_agent_timeline(persist, timeline); } - AgentEvent::ToolCall { tool, args, call_id } => { + AgentEvent::ToolCall { + tool, + args, + call_id, + } => { if tool == "harness.ask_user" { if let Some((call_id, ask)) = call_id .clone() @@ -306,8 +319,7 @@ pub fn apply_agent_event( // user at least sees something landed. The client_tools.rs // dispatcher will short-circuit the result with ok=false. } - let entry = - ToolActivity::from_call_with_id(tool, args.as_ref(), loc, call_id.clone()); + let entry = ToolActivity::from_call_with_id(tool, args.as_ref(), loc, call_id.clone()); timeline.update(|rows| rows.push(TimelineItem::Tool(entry))); persist_agent_timeline(persist, timeline); } @@ -393,11 +405,7 @@ pub fn apply_agent_event( } => { timeline.update(|rows| { if let Some(card) = find_subagent_card_mut(rows, agent_id) { - if let Some(step) = card - .steps - .iter_mut() - .find(|s| s.id == *step_id) - { + if let Some(step) = card.steps.iter_mut().find(|s| s.id == *step_id) { step.title = title.clone(); step.status = status.clone(); step.note = note.clone(); @@ -413,7 +421,12 @@ pub fn apply_agent_event( }); persist_agent_timeline(persist, timeline); } - AgentEvent::SubagentToolCall { agent_id, tool, args, .. } => { + AgentEvent::SubagentToolCall { + agent_id, + tool, + args, + .. + } => { let entry = ToolActivity::from_call(tool, args.as_ref(), loc); timeline.update(|rows| { if let Some(card) = find_subagent_card_mut(rows, agent_id) { @@ -462,25 +475,27 @@ pub fn apply_agent_event( }; // 2) Per-row routing — the 4 cases from the plan. - timeline.update(|rows| match (*kind, agent_id.as_deref(), call_id.as_deref()) { - (TurnUsageKind::ToolExec, None, Some(call_id)) => { - attach_main_tool_exec(rows, call_id, metrics); - } - (TurnUsageKind::ToolExec, Some(agent_id), Some(call_id)) => { - attach_subagent_tool_exec(rows, agent_id, call_id, metrics); - } - (TurnUsageKind::ModelRound, None, _) => { - attach_main_model_round(rows, metrics); - } - (TurnUsageKind::ModelRound, Some(agent_id), _) => { - if let Some(card) = find_subagent_card_mut(rows, agent_id) { - card.metrics.merge(&metrics); + timeline.update( + |rows| match (*kind, agent_id.as_deref(), call_id.as_deref()) { + (TurnUsageKind::ToolExec, None, Some(call_id)) => { + attach_main_tool_exec(rows, call_id, metrics); } - } - // ToolExec without a call_id is malformed — nothing to - // route to, the session aggregate above still counted it. - (TurnUsageKind::ToolExec, _, None) => {} - }); + (TurnUsageKind::ToolExec, Some(agent_id), Some(call_id)) => { + attach_subagent_tool_exec(rows, agent_id, call_id, metrics); + } + (TurnUsageKind::ModelRound, None, _) => { + attach_main_model_round(rows, metrics); + } + (TurnUsageKind::ModelRound, Some(agent_id), _) => { + if let Some(card) = find_subagent_card_mut(rows, agent_id) { + card.metrics.merge(&metrics); + } + } + // ToolExec without a call_id is malformed — nothing to + // route to, the session aggregate above still counted it. + (TurnUsageKind::ToolExec, _, None) => {} + }, + ); persist_agent_timeline(persist, timeline); } AgentEvent::SubagentAssistantDelta { agent_id, delta } => { @@ -607,9 +622,9 @@ fn synthesize_completion_message(rows: &[TimelineItem]) -> Option { .iter() .rposition(|entry| matches!(entry, TimelineItem::User { .. }))?; - let has_assistant_after_user = rows[last_user_idx + 1..] - .iter() - .any(|entry| matches!(entry, TimelineItem::Assistant { text, .. } if !text.trim().is_empty())); + let has_assistant_after_user = rows[last_user_idx + 1..].iter().any( + |entry| matches!(entry, TimelineItem::Assistant { text, .. } if !text.trim().is_empty()), + ); if has_assistant_after_user { return None; } @@ -661,11 +676,7 @@ fn synthesize_completion_message(rows: &[TimelineItem]) -> Option { fn merge_consecutive_model_rounds(items: Vec) -> Vec { let mut out: Vec = Vec::new(); for item in items { - if let DisplayTimelineItem::ModelRound { - metrics, - mut tools, - } = item - { + if let DisplayTimelineItem::ModelRound { metrics, mut tools } = item { // Only collapse single-tool rounds with the same tool name as the // previous round. if tools.len() == 1 { @@ -675,9 +686,7 @@ fn merge_consecutive_model_rounds(items: Vec) -> Vec) -> Vec { last.merged_count += t.merged_count; if t.status == ActivityStatus::Fail { last.status = ActivityStatus::Fail; - } else if last.status == ActivityStatus::Ok - && t.status == ActivityStatus::Pending - { + } else if last.status == ActivityStatus::Ok && t.status == ActivityStatus::Pending { last.status = ActivityStatus::Pending; } continue; @@ -751,7 +758,10 @@ pub fn compact_timeline(items: Vec) -> Vec { // Collect the tool calls that follow this model round into a // single grouped block instead of emitting separate Tool rows. let mut tools = Vec::new(); - while iter.peek().is_some_and(|x| matches!(x, TimelineItem::Tool(_))) { + while iter + .peek() + .is_some_and(|x| matches!(x, TimelineItem::Tool(_))) + { if let Some(TimelineItem::Tool(t)) = iter.next() { tools.push(t); } @@ -1255,9 +1265,7 @@ fn GeneratedImageRow( return; } leptos::task::spawn_local(async move { - if let Ok(resp) = - crate::tauri_bridge::generated_image_preview(path).await - { + if let Ok(resp) = crate::tauri_bridge::generated_image_preview(path).await { preview.set(format!("data:{};base64,{}", resp.mime, resp.bytes_b64)); } }); @@ -1446,4 +1454,3 @@ fn ToolActivityRow( } } - diff --git a/src/workbench/agent_panel/turn_metrics_bar/mod.rs b/src/workbench/agent_panel/turn_metrics_bar/mod.rs index 58b7e7a..65d26fe 100644 --- a/src/workbench/agent_panel/turn_metrics_bar/mod.rs +++ b/src/workbench/agent_panel/turn_metrics_bar/mod.rs @@ -47,7 +47,10 @@ pub fn TurnMetricsBar(metrics: TurnMetrics, context: BarContext) -> impl IntoVie _ => dash.clone(), }; let ttft = metrics.ttft_ms.map(fmt_ms).unwrap_or_else(|| dash.clone()); - let cost = metrics.cost_usd.map(fmt_cost).unwrap_or_else(|| dash.clone()); + let cost = metrics + .cost_usd + .map(fmt_cost) + .unwrap_or_else(|| dash.clone()); let label_in = lookup(loc, I18nKey::AgMetricsIn); let label_out = lookup(loc, I18nKey::AgMetricsOut); diff --git a/src/workbench/agent_panel/voice_orb/mod.rs b/src/workbench/agent_panel/voice_orb/mod.rs index 15d22d4..424e2b9 100644 --- a/src/workbench/agent_panel/voice_orb/mod.rs +++ b/src/workbench/agent_panel/voice_orb/mod.rs @@ -248,16 +248,11 @@ pub fn handle_voice_event(audio_ref: NodeRef, ev: &AgentEvent) { /// True when App voice settings have a non-empty TTS model (provider is always set). #[must_use] pub fn tts_line_playback_available(settings: Option<&VoiceSettings>) -> bool { - settings - .is_some_and(|s| !s.tts.model_id.trim().is_empty()) + settings.is_some_and(|s| !s.tts.model_id.trim().is_empty()) } /// Synthesize and play one chat line via configured TTS (same API as voice settings preview). -pub fn play_line_tts( - audio_ref: NodeRef, - settings: VoiceSettings, - text: String, -) { +pub fn play_line_tts(audio_ref: NodeRef, settings: VoiceSettings, text: String) { let text = text.trim().to_owned(); if text.is_empty() { return; diff --git a/src/workbench/agent_provider_pane/mod.rs b/src/workbench/agent_provider_pane/mod.rs index c4da278..09bfd6a 100644 --- a/src/workbench/agent_provider_pane/mod.rs +++ b/src/workbench/agent_provider_pane/mod.rs @@ -97,7 +97,10 @@ fn provider_key_status_text( } } -fn provider_cache(view: &AgentProviderSettingsView, provider: AgentProviderKind) -> Vec { +fn provider_cache( + view: &AgentProviderSettingsView, + provider: AgentProviderKind, +) -> Vec { match provider { AgentProviderKind::Openrouter => view.model_cache_openrouter.clone(), AgentProviderKind::Anthropic => view.model_cache_anthropic.clone(), diff --git a/src/workbench/agent_timeline.rs b/src/workbench/agent_timeline.rs index 8ee742a..9edeeff 100644 --- a/src/workbench/agent_timeline.rs +++ b/src/workbench/agent_timeline.rs @@ -228,7 +228,9 @@ pub enum AskUserState { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum TimelineItem { - User { text: String }, + User { + text: String, + }, Assistant { text: String, /// Per-row metrics aggregated from `ModelRound` events whose visible @@ -238,7 +240,10 @@ pub enum TimelineItem { metrics: TurnMetrics, }, Tool(ToolActivity), - Thinking { text: String, done: bool }, + Thinking { + text: String, + done: bool, + }, SubagentGroup(SubagentGroup), /// Synthetic row inserted for tool-only model rounds (no assistant /// text was emitted). Carries the round's metrics so cost / tokens @@ -287,7 +292,12 @@ fn summarize_args(tool: &str, args: Option<&Value>) -> String { let pick = match tool { "harness.create_workspace" => Some("title"), "list_workspace_files" => Some("path"), - "read_workspace_file" | "memory_read" | "memory_create" | "memory_write" | "memory_delete" | "memory_backlinks" => Some("path"), + "read_workspace_file" + | "memory_read" + | "memory_create" + | "memory_write" + | "memory_delete" + | "memory_backlinks" => Some("path"), "memory_rename" => Some("newPath"), "memory_search" => Some("query"), "memory_category_update" => Some("category"), @@ -332,10 +342,13 @@ fn file_arg_path(tool: &str, args: Option<&Value>) -> Option { let name = args.get("name")?.as_str()?; Some(format!(".agents/skills/{name}/SKILL.md")) } - "read_workspace_file" | "memory_read" | "memory_create" | "memory_write" - | "memory_delete" | "memory_backlinks" | "list_workspace_files" => { - args.get("path")?.as_str().map(|s| s.to_owned()) - } + "read_workspace_file" + | "memory_read" + | "memory_create" + | "memory_write" + | "memory_delete" + | "memory_backlinks" + | "list_workspace_files" => args.get("path")?.as_str().map(|s| s.to_owned()), "memory_rename" => args.get("newPath")?.as_str().map(|s| s.to_owned()), _ => None, } diff --git a/src/workbench/api_keys_pane/mod.rs b/src/workbench/api_keys_pane/mod.rs index 0b2ea80..bc91566 100644 --- a/src/workbench/api_keys_pane/mod.rs +++ b/src/workbench/api_keys_pane/mod.rs @@ -294,13 +294,17 @@ fn ApiKeyRow(entry: ApiKeyEntry, drafts: RwSignal) -> impl IntoView { let kind_for_delete_check = kind.clone(); let marked_delete = Signal::derive(move || { - drafts.with(|m| matches!(m.get(kind_for_delete_check.as_ref()), Some(DraftAction::Delete))) + drafts.with(|m| { + matches!( + m.get(kind_for_delete_check.as_ref()), + Some(DraftAction::Delete) + ) + }) }); let kind_for_dirty = kind.clone(); - let row_dirty = Signal::derive(move || { - drafts.with(|m| m.contains_key(kind_for_dirty.as_ref())) - }); + let row_dirty = + Signal::derive(move || drafts.with(|m| m.contains_key(kind_for_dirty.as_ref()))); let kind_for_input = kind.clone(); let on_input = move |ev: web_sys::Event| { diff --git a/src/workbench/chat_markdown.rs b/src/workbench/chat_markdown.rs index 5df9e12..e799556 100644 --- a/src/workbench/chat_markdown.rs +++ b/src/workbench/chat_markdown.rs @@ -29,11 +29,8 @@ fn strip_known_prefixes(path: &str) -> Cow<'_, str> { Cow::Borrowed(t) } -const MEMORY_PATH_PREFIXES: &[&str] = &[ - ".agents/learnings/", - ".agents/memory/", - ".blxcode/memory/", -]; +const MEMORY_PATH_PREFIXES: &[&str] = + &[".agents/learnings/", ".agents/memory/", ".blxcode/memory/"]; fn memory_rel_from_display_path(display_path: &str) -> Option { let t = display_path.trim(); @@ -174,6 +171,22 @@ pub fn preprocess_agent_chat_markdown(src: &str) -> String { out } +fn strip_yaml_frontmatter(src: &str) -> &str { + let rest = src + .strip_prefix("---\r\n") + .or_else(|| src.strip_prefix("---\n")); + let Some(rest) = rest else { + return src; + }; + + for marker in ["\n---\r\n", "\n---\n", "\n...\r\n", "\n...\n"] { + if let Some(pos) = rest.find(marker) { + return &rest[pos + marker.len()..]; + } + } + src +} + /// Strips `blx-open` from fenced-block info lines (first token only) and records whether each /// fence should start expanded. fn normalize_blx_open_fenced_markers(md: &str) -> (String, Vec) { @@ -487,9 +500,7 @@ fn postprocess_html_inline_code_memory_paths(html: &mut String) { rest = &rest[en + 7..]; let trimmed = inner.trim(); if !inner.contains('\n') - && MEMORY_PATH_PREFIXES - .iter() - .any(|p| trimmed.starts_with(p)) + && MEMORY_PATH_PREFIXES.iter().any(|p| trimmed.starts_with(p)) && trimmed.to_ascii_lowercase().ends_with(".md") { if let Some(rel) = memory_rel_from_display_path(trimmed) { @@ -510,7 +521,7 @@ fn postprocess_html_inline_code_memory_paths(html: &mut String) { #[must_use] pub fn render_markdown_to_html(src: &str) -> String { - let prepped = preprocess_agent_chat_markdown(src); + let prepped = preprocess_agent_chat_markdown(strip_yaml_frontmatter(src)); let (md_for_cmark, fence_expand) = normalize_blx_open_fenced_markers(&prepped); let mut opts = Options::empty(); opts.insert(Options::ENABLE_TABLES); @@ -583,6 +594,23 @@ fn main() {} assert!(!html.contains("```")); } + #[test] + fn render_strips_yaml_frontmatter() { + let md = "---\ntitle: Learnings\nenabled: true\n---\n# Learnings\n"; + let html = render_markdown_to_html(md); + assert!(!html.contains("title:")); + assert!(!html.contains("enabled:")); + assert!(html.contains("

Learnings

")); + } + + #[test] + fn render_keeps_horizontal_rule_without_frontmatter_close() { + let md = "---\n# Title\n"; + let html = render_markdown_to_html(md); + assert!(html.contains("Title")); + } + #[test] fn vague_markdown_fence_shows_inferred_rust_in_summary() { let html = r#"
fn main() {
diff --git a/src/workbench/file_preview/code_context_menu.rs b/src/workbench/file_preview/code_context_menu.rs
index 6725fee..f7fe34a 100644
--- a/src/workbench/file_preview/code_context_menu.rs
+++ b/src/workbench/file_preview/code_context_menu.rs
@@ -13,9 +13,7 @@
 
 use crate::i18n::I18nKey;
 use crate::service::I18nService;
-use crate::workbench::agent_context_handoff::{
-    WorkspaceTerminalGroup, WorkspaceTerminalTarget,
-};
+use crate::workbench::agent_context_handoff::{WorkspaceTerminalGroup, WorkspaceTerminalTarget};
 use leptos::prelude::*;
 
 /// Action emitted from a menu item. The parent component holds the
diff --git a/src/workbench/file_preview/code_view.rs b/src/workbench/file_preview/code_view.rs
index f4e6f42..4bac378 100644
--- a/src/workbench/file_preview/code_view.rs
+++ b/src/workbench/file_preview/code_view.rs
@@ -9,8 +9,7 @@ use crate::i18n::I18nKey;
 use crate::service::I18nService;
 use crate::tauri_bridge::{is_tauri_shell, pty_write, read_workspace_text_file};
 use crate::workbench::agent_context_handoff::{
-    file_snippet_context_item, list_terminal_targets_all_workspaces,
-    render_file_snippet_envelope,
+    file_snippet_context_item, list_terminal_targets_all_workspaces, render_file_snippet_envelope,
 };
 use crate::workbench::file_preview::code_context_menu::{
     CodeContextMenu, CodeContextMenuState, CodeMenuAction,
@@ -169,42 +168,36 @@ pub fn CodeView(
 
     // Window-level mouseup ends any in-progress drag, even if the pointer
     // left the code area. We register once and clean up with on_cleanup.
-    let mouseup_handle = leptos::leptos_dom::helpers::window_event_listener_untyped(
-        "mouseup",
-        move |_| {
+    let mouseup_handle =
+        leptos::leptos_dom::helpers::window_event_listener_untyped("mouseup", move |_| {
             if drag_anchor.get_untracked().is_some() {
                 drag_anchor.set(None);
                 drag_moved.set(false);
             }
-        },
-    );
+        });
     on_cleanup(move || drop(mouseup_handle));
 
     // Click anywhere closes the menu. We listen at window level instead of
     // installing per-element listeners so the menu closes consistently for
     // every dismissal path (clicking another row, the page background, etc.).
-    let click_close_handle = leptos::leptos_dom::helpers::window_event_listener_untyped(
-        "mousedown",
-        move |_| {
+    let click_close_handle =
+        leptos::leptos_dom::helpers::window_event_listener_untyped("mousedown", move |_| {
             if menu_state.get_untracked().is_some() {
                 menu_state.set(None);
             }
-        },
-    );
+        });
     on_cleanup(move || drop(click_close_handle));
 
     // Escape key also closes the menu.
-    let escape_handle = leptos::leptos_dom::helpers::window_event_listener_untyped(
-        "keydown",
-        move |ev| {
+    let escape_handle =
+        leptos::leptos_dom::helpers::window_event_listener_untyped("keydown", move |ev| {
             let Some(kev) = ev.dyn_ref::() else {
                 return;
             };
             if kev.key() == "Escape" && menu_state.get_untracked().is_some() {
                 menu_state.set(None);
             }
-        },
-    );
+        });
     on_cleanup(move || drop(escape_handle));
 
     let rel_path_for_actions = rel_path.clone();
@@ -442,7 +435,11 @@ fn handle_menu_action(
                 language,
                 &plain_lines,
                 range,
-                if cross { Some(&preview_workspace_label) } else { None },
+                if cross {
+                    Some(&preview_workspace_label)
+                } else {
+                    None
+                },
             );
             let payload = if snippet.ends_with('\n') {
                 snippet
@@ -458,15 +455,13 @@ fn handle_menu_action(
                 let b64 = base64::engine::general_purpose::STANDARD.encode(payload.as_bytes());
                 match pty_write(session_id, b64).await {
                     Ok(()) => {
-                        let msg = i18n_for_msg
-                            .tr(I18nKey::CodeViewToastSnippetInsertedTerminal)()
+                        let msg = i18n_for_msg.tr(I18nKey::CodeViewToastSnippetInsertedTerminal)()
                             .replace("{terminal}", &target_label)
                             .replace("{workspace}", &ws_label);
                         toast.success(msg);
                     }
                     Err(e) => {
-                        let msg = i18n_for_msg
-                            .tr(I18nKey::CodeViewToastInsertFailed)()
+                        let msg = i18n_for_msg.tr(I18nKey::CodeViewToastInsertFailed)()
                             .replace("{error}", &e);
                         toast.error(msg);
                     }
@@ -484,7 +479,11 @@ fn handle_menu_action(
                 language,
                 &plain_lines,
                 range,
-                if cross { Some(&preview_workspace_label) } else { None },
+                if cross {
+                    Some(&preview_workspace_label)
+                } else {
+                    None
+                },
             );
             let target_workspace_root = wb.workspaces().with_untracked(|all| {
                 all.iter()
@@ -505,7 +504,11 @@ fn handle_menu_action(
                 range,
                 language,
                 &snippet,
-                if cross { Some(&preview_workspace_label) } else { None },
+                if cross {
+                    Some(&preview_workspace_label)
+                } else {
+                    None
+                },
             );
             let toast = toast;
             let i18n_for_msg = i18n;
@@ -516,15 +519,13 @@ fn handle_menu_action(
                 let b64 = base64::engine::general_purpose::STANDARD.encode(envelope.as_bytes());
                 match pty_write(session_id, b64).await {
                     Ok(()) => {
-                        let msg = i18n_for_msg
-                            .tr(I18nKey::CodeViewToastEnvelopeInsertedTerminal)()
+                        let msg = i18n_for_msg.tr(I18nKey::CodeViewToastEnvelopeInsertedTerminal)()
                             .replace("{terminal}", &target_label)
                             .replace("{workspace}", &ws_label);
                         toast.success(msg);
                     }
                     Err(e) => {
-                        let msg = i18n_for_msg
-                            .tr(I18nKey::CodeViewToastInsertFailed)()
+                        let msg = i18n_for_msg.tr(I18nKey::CodeViewToastInsertFailed)()
                             .replace("{error}", &e);
                         toast.error(msg);
                     }
@@ -541,7 +542,11 @@ fn handle_menu_action(
                 language,
                 &plain_lines,
                 range,
-                if cross { Some(&preview_workspace_label) } else { None },
+                if cross {
+                    Some(&preview_workspace_label)
+                } else {
+                    None
+                },
             );
             let item_label = if range.0 == range.1 {
                 format!("Snippet · {}:{}", rel_path, range.0)
@@ -555,24 +560,21 @@ fn handle_menu_action(
                 language,
                 &item_label,
                 &snippet,
-                if cross { Some(&preview_workspace_label) } else { None },
+                if cross {
+                    Some(&preview_workspace_label)
+                } else {
+                    None
+                },
             );
             wb.upsert_workspace_agent_context(workspace_id, item);
-            let msg = i18n
-                .tr(I18nKey::CodeViewToastAgentAttached)()
+            let msg = i18n.tr(I18nKey::CodeViewToastAgentAttached)()
                 .replace("{workspace}", &workspace_label);
             toast.success(msg);
         }
         CodeMenuAction::CopySnippet => {
             let cross_label_unused = (); // always preview workspace for clipboard
             let _ = cross_label_unused;
-            let snippet = build_file_snippet_block(
-                rel_path,
-                language,
-                &plain_lines,
-                range,
-                None,
-            );
+            let snippet = build_file_snippet_block(rel_path, language, &plain_lines, range, None);
             copy_to_clipboard(snippet, i18n, toast, I18nKey::CodeViewToastCopiedSnippet);
         }
         CodeMenuAction::CopyRange => {
@@ -612,8 +614,7 @@ fn copy_to_clipboard(text: String, i18n: I18nService, toast: ToastService, succe
                             .and_then(|v| v.as_string())
                     })
                     .unwrap_or_else(|| "unknown".to_string());
-                let msg = i18n_for_msg
-                    .tr(I18nKey::CodeViewToastClipboardFailed)()
+                let msg = i18n_for_msg.tr(I18nKey::CodeViewToastClipboardFailed)()
                     .replace("{error}", &err);
                 toast.error(msg);
             }
diff --git a/src/workbench/file_preview/hljs_glue.rs b/src/workbench/file_preview/hljs_glue.rs
index 4068830..6377762 100644
--- a/src/workbench/file_preview/hljs_glue.rs
+++ b/src/workbench/file_preview/hljs_glue.rs
@@ -62,8 +62,8 @@ pub async fn ensure_hljs_loaded() -> Result<(), String> {
 pub async fn highlight(code: &str, language: &str) -> Result {
     ensure_hljs_loaded().await?;
     let hljs = hljs_global().ok_or("hljs not available")?;
-    let highlight_fn = Reflect::get(&hljs, &JsValue::from_str("highlight"))
-        .map_err(|_| "no hljs.highlight")?;
+    let highlight_fn =
+        Reflect::get(&hljs, &JsValue::from_str("highlight")).map_err(|_| "no hljs.highlight")?;
     let highlight_fn: Function = highlight_fn
         .dyn_into()
         .map_err(|_| "hljs.highlight not callable")?;
@@ -75,12 +75,8 @@ pub async fn highlight(code: &str, language: &str) -> Result {
         &JsValue::from_str(language),
     )
     .map_err(|_| "set language")?;
-    Reflect::set(
-        &opts,
-        &JsValue::from_str("ignoreIllegals"),
-        &JsValue::TRUE,
-    )
-    .map_err(|_| "set ignoreIllegals")?;
+    Reflect::set(&opts, &JsValue::from_str("ignoreIllegals"), &JsValue::TRUE)
+        .map_err(|_| "set ignoreIllegals")?;
 
     let result = highlight_fn
         .call2(&hljs, &JsValue::from_str(code), &opts)
diff --git a/src/workbench/file_preview/markdown_view.rs b/src/workbench/file_preview/markdown_view.rs
index c361547..92f68f4 100644
--- a/src/workbench/file_preview/markdown_view.rs
+++ b/src/workbench/file_preview/markdown_view.rs
@@ -43,10 +43,8 @@ fn render_markdown(src: &str) -> String {
             Event::End(TagEnd::CodeBlock) if in_mermaid => {
                 let escaped = html_escape(&mermaid_buf);
                 buffered.push(Event::Html(
-                    format!(
-                        r#"
{escaped}
"# - ) - .into(), + format!(r#"
{escaped}
"#) + .into(), )); in_mermaid = false; mermaid_buf.clear(); diff --git a/src/workbench/file_preview/mermaid_glue.rs b/src/workbench/file_preview/mermaid_glue.rs index 6dc1d6d..b3fd4df 100644 --- a/src/workbench/file_preview/mermaid_glue.rs +++ b/src/workbench/file_preview/mermaid_glue.rs @@ -13,13 +13,15 @@ const MERMAID_SRC: &str = "/public/vendor/mermaid/mermaid.min.js"; fn mermaid_global() -> Option { let w = web_sys::window()?; - Reflect::get(&w, &JsValue::from_str("mermaid")).ok().and_then(|v| { - if v.is_undefined() || v.is_null() { - None - } else { - Some(v) - } - }) + Reflect::get(&w, &JsValue::from_str("mermaid")) + .ok() + .and_then(|v| { + if v.is_undefined() || v.is_null() { + None + } else { + Some(v) + } + }) } /// Returns `Ok(())` once `globalThis.mermaid` exists. Inserts a `