From 89da9704559febac8a377a92ea53819f084198c0 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 5 Mar 2026 12:12:12 +0100 Subject: [PATCH] feat: enable full shell access for AI agent + simplify MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove --tools "" restriction so the agent has native bash/read/write/edit access. Set vault path as working directory for the subprocess. Simplify MCP to 4 Laputa-specific tools (search_notes, get_vault_context, get_note, open_note) — everything else is handled by native tools. Add file operation detection from Write/Edit tool calls to auto-open created notes and refresh modified notes in the UI. Enhanced tool call labels show bash commands, file paths, and note names. Co-Authored-By: Claude Opus 4.6 --- mcp-server/index.js | 259 ++++----------------------- mcp-server/vault.js | 154 +++------------- src-tauri/src/claude_cli.rs | 33 ++-- src/App.tsx | 43 ++++- src/components/AiActionCard.test.tsx | 4 +- src/components/AiActionCard.tsx | 34 ++-- src/components/AiPanel.tsx | 13 +- src/components/Editor.tsx | 6 + src/components/EditorRightPanel.tsx | 5 + src/hooks/useAiAgent.ts | 156 +++++++++++++--- src/utils/ai-agent.test.ts | 5 +- src/utils/ai-agent.ts | 19 +- 12 files changed, 311 insertions(+), 420 deletions(-) diff --git a/mcp-server/index.js b/mcp-server/index.js index 189f11b2..059deb87 100644 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -1,21 +1,15 @@ #!/usr/bin/env node /** - * Laputa MCP Server — provides vault operation tools for AI assistants. + * Laputa MCP Server — lightweight vault tools for AI agents. * - * Usage: - * VAULT_PATH=/path/to/vault node index.js + * The agent has full shell access (bash, read, write, edit). + * These MCP tools provide Laputa-specific capabilities that + * native tools cannot replace: * - * Tools: - * - open_note / read_note: Read a note by path - * - create_note: Create a new note with title and optional frontmatter - * - search_notes: Search notes by title or content - * - append_to_note: Append text to an existing note - * - edit_note_frontmatter: Merge a patch into a note's YAML frontmatter - * - delete_note: Delete a note file - * - link_notes: Add a title to an array property in a note's frontmatter - * - list_notes: List all notes, optionally filtered by type - * - vault_context: Get vault types and recent notes - * - ui_open_note / ui_open_tab / ui_highlight / ui_set_filter: UI actions + * - search_notes: full-text search across vault notes + * - get_vault_context: vault structure overview (types, note count, folders) + * - get_note: parsed frontmatter + content (convenience over raw cat) + * - open_note: signal Laputa UI to open a note as a tab */ import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' @@ -24,10 +18,7 @@ import { ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js' import WebSocket from 'ws' -import { - readNote, createNote, searchNotes, appendToNote, - editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext, -} from './vault.js' +import { searchNotes, getNote, vaultContext } from './vault.js' const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa' const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10) @@ -64,44 +55,9 @@ function broadcastUiAction(action, payload) { } const TOOLS = [ - { - name: 'open_note', - description: 'Open and read a note from the vault by its relative path', - inputSchema: { - type: 'object', - properties: { - path: { type: 'string', description: 'Relative path to the note (e.g. "project/my-project.md")' }, - }, - required: ['path'], - }, - }, - { - name: 'read_note', - description: 'Read the full content of a note', - inputSchema: { - type: 'object', - properties: { - path: { type: 'string', description: 'Relative path to the note' }, - }, - required: ['path'], - }, - }, - { - name: 'create_note', - description: 'Create a new note in the vault with a title and optional frontmatter', - inputSchema: { - type: 'object', - properties: { - path: { type: 'string', description: 'Relative path for the new note (e.g. "note/my-idea.md")' }, - title: { type: 'string', description: 'Title of the note' }, - is_a: { type: 'string', description: 'Entity type (Project, Note, Experiment, etc.)' }, - }, - required: ['path', 'title'], - }, - }, { name: 'search_notes', - description: 'Search notes in the vault by title or content', + description: 'Full-text search across vault notes by title or content. Returns matching paths, titles, and snippets.', inputSchema: { type: 'object', properties: { @@ -112,72 +68,24 @@ const TOOLS = [ }, }, { - name: 'append_to_note', - description: 'Append text to the end of an existing note', - inputSchema: { - type: 'object', - properties: { - path: { type: 'string', description: 'Relative path to the note' }, - text: { type: 'string', description: 'Text to append' }, - }, - required: ['path', 'text'], - }, - }, - { - name: 'edit_note_frontmatter', - description: 'Merge a patch object into a note\'s YAML frontmatter', - inputSchema: { - type: 'object', - properties: { - path: { type: 'string', description: 'Relative path to the note' }, - patch: { type: 'object', description: 'Key-value pairs to merge into frontmatter' }, - }, - required: ['path', 'patch'], - }, - }, - { - name: 'delete_note', - description: 'Delete a note file from the vault', - inputSchema: { - type: 'object', - properties: { - path: { type: 'string', description: 'Relative path to the note to delete' }, - }, - required: ['path'], - }, - }, - { - name: 'link_notes', - description: 'Add a target title to an array property in a note\'s frontmatter (e.g. add "Marco" to people: [])', - inputSchema: { - type: 'object', - properties: { - source_path: { type: 'string', description: 'Relative path to the source note' }, - property: { type: 'string', description: 'Frontmatter property name (e.g. "people", "tags")' }, - target_title: { type: 'string', description: 'Title to add to the array' }, - }, - required: ['source_path', 'property', 'target_title'], - }, - }, - { - name: 'list_notes', - description: 'List all notes in the vault, optionally filtered by type frontmatter field', - inputSchema: { - type: 'object', - properties: { - type_filter: { type: 'string', description: 'Filter by type frontmatter value' }, - sort: { type: 'string', enum: ['title', 'mtime'], description: 'Sort order (default: title)' }, - }, - }, - }, - { - name: 'vault_context', - description: 'Get vault context: unique entity types and 20 most recently modified notes', + name: 'get_vault_context', + description: 'Get vault orientation: entity types, total note count, top-level folders, and 20 most recently modified notes.', inputSchema: { type: 'object', properties: {} }, }, { - name: 'ui_open_note', - description: 'Open a note in the Laputa UI editor', + name: 'get_note', + description: 'Read a note with parsed YAML frontmatter and markdown content. Returns {path, frontmatter, content}.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Relative path to the note (e.g. "project/my-project.md")' }, + }, + required: ['path'], + }, + }, + { + name: 'open_note', + description: 'Open a note in the Laputa UI as a new tab. Use after creating or editing a note so the user can see it.', inputSchema: { type: 'object', properties: { @@ -186,70 +94,13 @@ const TOOLS = [ required: ['path'], }, }, - { - name: 'ui_open_tab', - description: 'Open a note in a new tab in the Laputa UI', - inputSchema: { - type: 'object', - properties: { - path: { type: 'string', description: 'Relative path to the note' }, - }, - required: ['path'], - }, - }, - { - name: 'ui_highlight', - description: 'Highlight a UI element in the Laputa interface', - inputSchema: { - type: 'object', - properties: { - element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'UI element to highlight' }, - path: { type: 'string', description: 'Relative path to the note (optional)' }, - }, - required: ['element'], - }, - }, - { - name: 'ui_set_filter', - description: 'Set the sidebar filter to show notes of a specific type', - inputSchema: { - type: 'object', - properties: { - type: { type: 'string', description: 'Type to filter by' }, - }, - required: ['type'], - }, - }, ] const TOOL_HANDLERS = { - open_note: handleReadNote, - read_note: handleReadNote, - create_note: handleCreateNote, search_notes: handleSearchNotes, - append_to_note: handleAppendToNote, - edit_note_frontmatter: handleEditFrontmatter, - delete_note: handleDeleteNote, - link_notes: handleLinkNotes, - list_notes: handleListNotes, - vault_context: handleVaultContext, - ui_open_note: handleUiOpenNote, - ui_open_tab: handleUiOpenTab, - ui_highlight: handleUiHighlight, - ui_set_filter: handleUiSetFilter, -} - -async function handleReadNote(args) { - const content = await readNote(VAULT_PATH, args.path) - return { content: [{ type: 'text', text: content }] } -} - -async function handleCreateNote(args) { - const frontmatter = {} - if (args.is_a) frontmatter.is_a = args.is_a - const absPath = await createNote(VAULT_PATH, args.path, args.title, frontmatter) - broadcastUiAction('vault_changed', { path: args.path }) - return { content: [{ type: 'text', text: `Created note at ${absPath}` }] } + get_vault_context: handleVaultContext, + get_note: handleGetNote, + open_note: handleOpenNote, } async function handleSearchNotes(args) { @@ -260,67 +111,25 @@ async function handleSearchNotes(args) { return { content: [{ type: 'text', text }] } } -async function handleAppendToNote(args) { - await appendToNote(VAULT_PATH, args.path, args.text) - broadcastUiAction('vault_changed', { path: args.path }) - return { content: [{ type: 'text', text: `Appended text to ${args.path}` }] } -} - -async function handleEditFrontmatter(args) { - const updated = await editNoteFrontmatter(VAULT_PATH, args.path, args.patch) - broadcastUiAction('vault_changed', { path: args.path }) - return { content: [{ type: 'text', text: JSON.stringify(updated) }] } -} - -async function handleDeleteNote(args) { - await deleteNote(VAULT_PATH, args.path) - broadcastUiAction('vault_changed', { path: args.path }) - return { content: [{ type: 'text', text: `Deleted ${args.path}` }] } -} - -async function handleLinkNotes(args) { - const arr = await linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title) - broadcastUiAction('vault_changed', { path: args.source_path }) - return { content: [{ type: 'text', text: `${args.property}: [${arr.join(', ')}]` }] } -} - -async function handleListNotes(args) { - const notes = await listNotes(VAULT_PATH, args.type_filter, args.sort) - const text = notes.length === 0 - ? 'No notes found.' - : notes.map(n => `${n.title} (${n.path})${n.type ? ` [${n.type}]` : ''}`).join('\n') - return { content: [{ type: 'text', text }] } -} - async function handleVaultContext() { const ctx = await vaultContext(VAULT_PATH) return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] } } -function handleUiOpenNote(args) { - broadcastUiAction('open_note', { path: args.path }) - return { content: [{ type: 'text', text: `Opening ${args.path} in UI` }] } +async function handleGetNote(args) { + const note = await getNote(VAULT_PATH, args.path) + return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] } } -function handleUiOpenTab(args) { +function handleOpenNote(args) { broadcastUiAction('open_tab', { path: args.path }) - return { content: [{ type: 'text', text: `Opening tab for ${args.path}` }] } -} - -function handleUiHighlight(args) { - broadcastUiAction('highlight', { element: args.element, path: args.path }) - return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] } -} - -function handleUiSetFilter(args) { - broadcastUiAction('set_filter', { filterType: args.type }) - return { content: [{ type: 'text', text: `Filter set to ${args.type}` }] } + return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] } } // --- Server setup --- const server = new Server( - { name: 'laputa-mcp-server', version: '0.1.0' }, + { name: 'laputa-mcp-server', version: '0.2.0' }, { capabilities: { tools: {} } }, ) diff --git a/mcp-server/vault.js b/mcp-server/vault.js index ae191b2e..ae24b450 100644 --- a/mcp-server/vault.js +++ b/mcp-server/vault.js @@ -1,5 +1,6 @@ /** - * Vault operations — file I/O for Laputa markdown vault. + * Vault operations — read-only helpers for Laputa markdown vault. + * Write operations are handled by the agent's native bash/write/edit tools. */ import fs from 'node:fs/promises' import path from 'node:path' @@ -26,36 +27,20 @@ export async function findMarkdownFiles(dir) { } /** - * Read a note's content by path (absolute or relative to vault). + * Read a note with parsed frontmatter and content. * @param {string} vaultPath * @param {string} notePath - * @returns {Promise} + * @returns {Promise<{path: string, frontmatter: Record, content: string}>} */ -export async function readNote(vaultPath, notePath) { +export async function getNote(vaultPath, notePath) { const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath) - return fs.readFile(absPath, 'utf-8') -} - -/** - * Create a new note with optional frontmatter. - * @param {string} vaultPath - * @param {string} relativePath - * @param {string} title - * @param {Record} [frontmatter] - * @returns {Promise} The absolute path of the created file. - */ -export async function createNote(vaultPath, relativePath, title, frontmatter = {}) { - const absPath = path.join(vaultPath, relativePath) - await fs.mkdir(path.dirname(absPath), { recursive: true }) - - const fmEntries = { title, ...frontmatter } - const fmLines = Object.entries(fmEntries) - .map(([k, v]) => `${k}: ${v}`) - .join('\n') - - const content = `---\n${fmLines}\n---\n\n# ${title}\n\n` - await fs.writeFile(absPath, content, 'utf-8') - return absPath + const raw = await fs.readFile(absPath, 'utf-8') + const parsed = matter(raw) + return { + path: path.relative(vaultPath, absPath), + frontmatter: parsed.data, + content: parsed.content.trim(), + } } /** @@ -92,110 +77,14 @@ export async function searchNotes(vaultPath, query, limit = 10) { } /** - * Append text to the end of a note. + * Get vault context: unique types, note count, top-level folders, and 20 most recent notes. * @param {string} vaultPath - * @param {string} notePath - * @param {string} text - * @returns {Promise} - */ -export async function appendToNote(vaultPath, notePath, text) { - const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath) - const current = await fs.readFile(absPath, 'utf-8') - const separator = current.endsWith('\n') ? '\n' : '\n\n' - await fs.writeFile(absPath, current + separator + text + '\n', 'utf-8') -} - -/** - * Merge a patch object into a note's YAML frontmatter. - * @param {string} vaultPath - * @param {string} notePath - * @param {Record} patch - * @returns {Promise>} The updated frontmatter. - */ -export async function editNoteFrontmatter(vaultPath, notePath, patch) { - const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath) - const raw = await fs.readFile(absPath, 'utf-8') - const parsed = matter(raw) - Object.assign(parsed.data, patch) - const updated = matter.stringify(parsed.content, parsed.data) - await fs.writeFile(absPath, updated, 'utf-8') - return parsed.data -} - -/** - * Delete a note file. - * @param {string} vaultPath - * @param {string} notePath - * @returns {Promise} - */ -export async function deleteNote(vaultPath, notePath) { - const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath) - await fs.unlink(absPath) -} - -/** - * Add a target title to an array property in a note's frontmatter. - * Creates the property as an array if it doesn't exist. - * @param {string} vaultPath - * @param {string} sourcePath - * @param {string} property - * @param {string} targetTitle - * @returns {Promise} The updated array. - */ -export async function linkNotes(vaultPath, sourcePath, property, targetTitle) { - const absPath = path.isAbsolute(sourcePath) ? sourcePath : path.join(vaultPath, sourcePath) - const raw = await fs.readFile(absPath, 'utf-8') - const parsed = matter(raw) - const current = Array.isArray(parsed.data[property]) ? parsed.data[property] : [] - if (!current.includes(targetTitle)) { - current.push(targetTitle) - } - parsed.data[property] = current - const updated = matter.stringify(parsed.content, parsed.data) - await fs.writeFile(absPath, updated, 'utf-8') - return current -} - -/** - * List all notes in the vault, optionally filtered by type. - * @param {string} vaultPath - * @param {string} [typeFilter] - * @param {string} [sort] - 'title' or 'mtime' (default: 'title') - * @returns {Promise>} - */ -export async function listNotes(vaultPath, typeFilter, sort = 'title') { - const files = await findMarkdownFiles(vaultPath) - const notes = await Promise.all(files.map(async (filePath) => { - const raw = await fs.readFile(filePath, 'utf-8') - const parsed = matter(raw) - const relativePath = path.relative(vaultPath, filePath) - const title = parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')) - const type = parsed.data.type || parsed.data.is_a || null - const stat = sort === 'mtime' ? await fs.stat(filePath) : null - return { path: relativePath, title, type, mtime: stat?.mtimeMs ?? 0 } - })) - - const filtered = typeFilter - ? notes.filter(n => n.type === typeFilter) - : notes - - if (sort === 'mtime') { - filtered.sort((a, b) => b.mtime - a.mtime) - } else { - filtered.sort((a, b) => a.title.localeCompare(b.title)) - } - - return filtered.map(({ mtime: _mtime, ...rest }) => rest) -} - -/** - * Get vault context: unique types and 20 most recent notes. - * @param {string} vaultPath - * @returns {Promise<{types: string[], recentNotes: Array<{path: string, title: string, type: string|null}>, vaultPath: string}>} + * @returns {Promise<{types: string[], noteCount: number, folders: string[], recentNotes: Array<{path: string, title: string, type: string|null}>, vaultPath: string}>} */ export async function vaultContext(vaultPath) { const files = await findMarkdownFiles(vaultPath) const typesSet = new Set() + const foldersSet = new Set() const notesWithMtime = [] for (const filePath of files) { @@ -203,9 +92,12 @@ export async function vaultContext(vaultPath) { const parsed = matter(raw) const type = parsed.data.type || parsed.data.is_a || null if (type) typesSet.add(type) + const rel = path.relative(vaultPath, filePath) + const topFolder = rel.split(path.sep)[0] + if (topFolder !== rel) foldersSet.add(topFolder + '/') const stat = await fs.stat(filePath) notesWithMtime.push({ - path: path.relative(vaultPath, filePath), + path: rel, title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')), type, mtime: stat.mtimeMs, @@ -215,7 +107,13 @@ export async function vaultContext(vaultPath) { notesWithMtime.sort((a, b) => b.mtime - a.mtime) const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest) - return { types: [...typesSet].sort(), recentNotes, vaultPath } + return { + types: [...typesSet].sort(), + noteCount: files.length, + folders: [...foldersSet].sort(), + recentNotes, + vaultPath, + } } // --- Helpers --- diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index 726dfd5f..5b9cf410 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -129,7 +129,7 @@ where { let bin = find_claude_binary()?; let args = build_chat_args(&req); - run_claude_subprocess(&bin, &args, &mut emit) + run_claude_subprocess(&bin, &args, None, &mut emit) } /// Build CLI arguments for a chat stream request. @@ -160,17 +160,18 @@ fn build_chat_args(req: &ChatStreamRequest) -> Vec { args } -/// Spawn `claude -p` with MCP vault tools for an agent task and stream events. +/// Spawn `claude -p` with full tool access and MCP vault tools for an agent task. pub fn run_agent_stream(req: AgentStreamRequest, mut emit: F) -> Result where F: FnMut(ClaudeStreamEvent), { let bin = find_claude_binary()?; let args = build_agent_args(&req)?; - run_claude_subprocess(&bin, &args, &mut emit) + run_claude_subprocess(&bin, &args, Some(&req.vault_path), &mut emit) } /// Build CLI arguments for an agent stream request. +/// Native tools (bash, read, write, edit) are enabled by default — no `--tools ""`. fn build_agent_args(req: &AgentStreamRequest) -> Result, String> { let mcp_config = build_mcp_config(&req.vault_path)?; @@ -181,8 +182,6 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result, String> { "stream-json".into(), "--verbose".into(), "--include-partial-messages".into(), - "--tools".into(), - String::new(), // disable built-in tools; MCP tools remain "--mcp-config".into(), mcp_config, "--dangerously-skip-permissions".into(), @@ -229,15 +228,25 @@ struct StreamState { } /// Core subprocess runner shared by chat and agent modes. -fn run_claude_subprocess(bin: &PathBuf, args: &[String], emit: &mut F) -> Result +/// When `cwd` is `Some`, the subprocess starts with that working directory. +fn run_claude_subprocess( + bin: &PathBuf, + args: &[String], + cwd: Option<&str>, + emit: &mut F, +) -> Result where F: FnMut(ClaudeStreamEvent), { - let mut child = Command::new(bin) - .args(args) + let mut cmd = Command::new(bin); + cmd.args(args) .env_remove("CLAUDECODE") // prevent "nested session" guard .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + let mut child = cmd .spawn() .map_err(|e| format!("Failed to spawn claude: {e}"))?; @@ -813,7 +822,7 @@ mod tests { std::fs::write(&path, script).unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); let mut events = vec![]; - let result = run_claude_subprocess(&path, args, &mut |e| events.push(e)); + let result = run_claude_subprocess(&path, args, None, &mut |e| events.push(e)); (result, events) } @@ -964,6 +973,8 @@ mod tests { assert!(args.contains(&"--dangerously-skip-permissions".to_string())); assert!(args.contains(&"--no-session-persistence".to_string())); assert!(!args.contains(&"--append-system-prompt".to_string())); + // Native tools must NOT be disabled + assert!(!args.contains(&"--tools".to_string())); } } @@ -1035,7 +1046,7 @@ mod tests { fn run_subprocess_spawn_failure() { let fake_bin = PathBuf::from("/nonexistent/binary/path"); let mut events = vec![]; - let result = run_claude_subprocess(&fake_bin, &[], &mut |e| events.push(e)); + let result = run_claude_subprocess(&fake_bin, &[], None, &mut |e| events.push(e)); assert!(result.is_err()); assert!(result.unwrap_err().contains("Failed to spawn")); } diff --git a/src/App.tsx b/src/App.tsx index 62733fbd..40878a93 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,7 +40,7 @@ import { ConflictResolverModal } from './components/ConflictResolverModal' import { UpdateBanner } from './components/UpdateBanner' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from './mock-tauri' -import type { SidebarSelection } from './types' +import type { SidebarSelection, VaultEntry } from './types' import type { NoteListItem } from './utils/ai-context' import { filterEntries } from './utils/noteListHelpers' import './App.css' @@ -224,21 +224,44 @@ function App() { useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward }) // MCP UI bridge: react to AI-driven open/highlight/vault-change events + const openNoteByPath = useCallback((path: string) => { + const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`) + if (entry) { + notes.handleSelectNote(entry) + } else { + // Entry not yet in vault (just created) — reload then open + vault.reloadVault().then(freshEntries => { + const fresh = freshEntries.find((e: VaultEntry) => e.path === path || e.path === `${resolvedPath}/${path}`) + if (fresh) notes.handleSelectNote(fresh) + }) + } + }, [vault, notes, resolvedPath]) + const aiActivity = useAiActivity({ - onOpenNote: (path) => { - const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`) - if (entry) notes.handleSelectNote(entry) - }, - onOpenTab: (path) => { - const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`) - if (entry) notes.handleSelectNote(entry) - }, + onOpenNote: openNoteByPath, + onOpenTab: openNoteByPath, onSetFilter: (filterType) => { setSelection({ kind: 'sectionGroup', type: filterType }) }, onVaultChanged: () => { vault.reloadVault() }, }) + // Agent file operation handlers: auto-open created notes, live-refresh modified notes + const handleAgentFileCreated = useCallback((relativePath: string) => { + vault.reloadVault().then(freshEntries => { + const entry = freshEntries.find((e: VaultEntry) => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`) + if (entry) notes.handleSelectNote(entry) + }) + }, [vault, notes, resolvedPath]) + + const handleAgentFileModified = useCallback((relativePath: string) => { + const fullPath = `${resolvedPath}/${relativePath}` + const matchPath = notes.tabs.some(t => t.entry.path === relativePath) ? relativePath : fullPath + if (notes.tabs.some(t => t.entry.path === matchPath)) { + vault.reloadVault() + } + }, [vault, notes, resolvedPath]) + const { triggerIncrementalIndex } = indexing const onAfterSave = useCallback(() => { vault.loadModifiedFiles() @@ -525,6 +548,8 @@ function App() { onGoForward={handleGoForward} leftPanelsCollapsed={!sidebarVisible && !noteListVisible} isDarkTheme={themeManager.isDark} + onFileCreated={handleAgentFileCreated} + onFileModified={handleAgentFileModified} /> diff --git a/src/components/AiActionCard.test.tsx b/src/components/AiActionCard.test.tsx index 45f2a01e..21b2ca92 100644 --- a/src/components/AiActionCard.test.tsx +++ b/src/components/AiActionCard.test.tsx @@ -66,8 +66,8 @@ describe('AiActionCard', () => { expect(header.getAttribute('tabindex')).toBe('0') }) - it('uses lighter background for ui_ tools', () => { - render() + it('uses lighter background for open_note tool', () => { + render() const card = screen.getByTestId('ai-action-card') expect(card.style.background).toContain('0.06') }) diff --git a/src/components/AiActionCard.tsx b/src/components/AiActionCard.tsx index 3ca46f13..520499f5 100644 --- a/src/components/AiActionCard.tsx +++ b/src/components/AiActionCard.tsx @@ -1,7 +1,8 @@ import { type KeyboardEvent, type ReactNode, useCallback } from 'react' import { - PencilSimple, MagnifyingGlass, Link, Trash, ChartBar, Eye, Sparkle, + PencilSimple, MagnifyingGlass, Trash, ChartBar, Eye, CircleNotch, CheckCircle, XCircle, CaretRight, CaretDown, + Terminal, File, FolderOpen, NotePencil, } from '@phosphor-icons/react' export type AiActionStatus = 'pending' | 'done' | 'error' @@ -23,18 +24,21 @@ const MAX_DETAIL_LENGTH = 800 type IconRenderer = (size: number) => ReactNode const TOOL_ICON_MAP: Record = { - create_note: (s) => , - edit_note_frontmatter: (s) => , - append_to_note: (s) => , + // Native Claude Code tools + Bash: (s) => , + Write: (s) => , + Edit: (s) => , + Read: (s) => , + Glob: (s) => , + Grep: (s) => , + // Laputa MCP tools search_notes: (s) => , - list_notes: (s) => , - link_notes: (s) => , + get_vault_context: (s) => , + get_note: (s) => , + open_note: (s) => , + // Legacy tools (for backward compatibility with existing messages) + create_note: (s) => , delete_note: (s) => , - vault_context: (s) => , - ui_open_note: (s) => , - ui_open_tab: (s) => , - ui_highlight: (s) => , - ui_set_filter: (s) => , } const DEFAULT_ICON: IconRenderer = (s) => @@ -96,11 +100,15 @@ function DetailBlock({ label, content, isError }: { ) } +/** Whether this tool is a Laputa UI-only tool (lighter styling). */ +function isUiOnlyTool(tool: string): boolean { + return tool === 'open_note' +} + export function AiActionCard({ tool, label, path, status, input, output, expanded, onToggle, onOpenNote, }: AiActionCardProps) { const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON - const isUiTool = tool.startsWith('ui_') const hasDetails = !!(input || output) const handleKeyDown = useCallback((e: KeyboardEvent) => { @@ -129,7 +137,7 @@ export function AiActionCard({ className="rounded" style={{ fontSize: 12, - background: isUiTool ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)', + background: isUiOnlyTool(tool) ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)', }} >
void onOpenNote?: (path: string) => void + onFileCreated?: (relativePath: string) => void + onFileModified?: (relativePath: string) => void vaultPath: string activeEntry?: VaultEntry | null entries?: VaultEntry[] @@ -107,7 +109,7 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: { ) } -export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) { +export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) { const [input, setInput] = useState('') const [pendingRefs, setPendingRefs] = useState([]) const inputRef = useRef(null) @@ -131,7 +133,12 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, }) }, [activeEntry, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs]) - const agent = useAiAgent(vaultPath, contextPrompt) + const fileCallbacks = useMemo(() => ({ + onFileCreated, + onFileModified, + }), [onFileCreated, onFileModified]) + + const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks) const hasContext = !!activeEntry const isActive = agent.status === 'thinking' || agent.status === 'tool-executing' diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 203a37ec..7cf2e7f8 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -71,6 +71,8 @@ interface EditorProps { rawToggleRef?: React.MutableRefObject<() => void> /** Mutable ref that Editor registers its diff-mode toggle into, for command palette access. */ diffToggleRef?: React.MutableRefObject<() => void> + onFileCreated?: (relativePath: string) => void + onFileModified?: (relativePath: string) => void } function useEditorModeExclusion({ @@ -127,6 +129,8 @@ export const Editor = memo(function Editor({ isDarkTheme, rawToggleRef, diffToggleRef, + onFileCreated, + onFileModified, }: EditorProps) { const vaultPathRef = useRef(vaultPath) useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath]) @@ -242,6 +246,8 @@ export const Editor = memo(function Editor({ onDeleteProperty={onDeleteProperty} onAddProperty={onAddProperty} onOpenNote={onNavigateWikilink} + onFileCreated={onFileCreated} + onFileModified={onFileModified} />
diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx index 02bb535c..36cd0879 100644 --- a/src/components/EditorRightPanel.tsx +++ b/src/components/EditorRightPanel.tsx @@ -24,6 +24,8 @@ interface EditorRightPanelProps { onDeleteProperty?: (path: string, key: string) => Promise onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise onOpenNote?: (path: string) => void + onFileCreated?: (relativePath: string) => void + onFileModified?: (relativePath: string) => void } export function EditorRightPanel({ @@ -32,6 +34,7 @@ export function EditorRightPanel({ noteList, noteListFilter, onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote, + onFileCreated, onFileModified, }: EditorRightPanelProps) { if (showAIChat) { return ( @@ -42,6 +45,8 @@ export function EditorRightPanel({ onToggleAIChat?.()} onOpenNote={onOpenNote} + onFileCreated={onFileCreated} + onFileModified={onFileModified} vaultPath={vaultPath} activeEntry={inspectorEntry} entries={entries} diff --git a/src/hooks/useAiAgent.ts b/src/hooks/useAiAgent.ts index e9a274de..abdaca35 100644 --- a/src/hooks/useAiAgent.ts +++ b/src/hooks/useAiAgent.ts @@ -1,11 +1,14 @@ /** * Hook for the AI agent panel — manages agent state and streaming. - * Uses Claude CLI subprocess with MCP tools via Tauri. + * Uses Claude CLI subprocess with full tool access + MCP tools via Tauri. * * States: idle -> thinking -> tool-executing -> done/error * * Reasoning streams live while Claude thinks, then auto-collapses. * Response text accumulates internally and is revealed as a complete block on done. + * + * Detects file operations (Write/Edit/Bash) and notifies the parent via callbacks + * so the Laputa UI can auto-open new notes and live-refresh modified notes. */ import { useState, useCallback, useRef, useEffect } from 'react' import type { AiAction } from '../components/AiMessage' @@ -26,15 +29,31 @@ export interface AiAgentMessage { id?: string } -export function useAiAgent(vaultPath: string, contextPrompt?: string) { +export interface AgentFileCallbacks { + onFileCreated?: (relativePath: string) => void + onFileModified?: (relativePath: string) => void +} + +export function useAiAgent( + vaultPath: string, + contextPrompt?: string, + fileCallbacks?: AgentFileCallbacks, +) { const [messages, setMessages] = useState([]) const [status, setStatus] = useState('idle') const abortRef = useRef({ aborted: false }) const contextRef = useRef(contextPrompt) const responseAccRef = useRef('') + const fileCallbacksRef = useRef(fileCallbacks) + // Track tool inputs for file-operation detection on ToolDone + const toolInputMapRef = useRef>(new Map()) + useEffect(() => { contextRef.current = contextPrompt }, [contextPrompt]) + useEffect(() => { + fileCallbacksRef.current = fileCallbacks + }, [fileCallbacks]) const sendMessage = useCallback(async (text: string, references?: NoteReference[]) => { if (!text.trim() || status === 'thinking' || status === 'tool-executing') return @@ -52,6 +71,7 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) { abortRef.current = { aborted: false } responseAccRef.current = '' + toolInputMapRef.current = new Map() const messageId = nextMessageId() setMessages(prev => [...prev, { @@ -85,6 +105,7 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) { if (abortRef.current.aborted) return markReasoningDone() setStatus('tool-executing') + toolInputMapRef.current.set(toolId, { tool: toolName, input }) update(m => { const existing = m.actions.find(a => a.toolId === toolId) if (existing) { @@ -100,7 +121,7 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) { actions: [...m.actions, { tool: toolName, toolId, - label: formatToolLabel(toolName, toolId), + label: formatToolLabel(toolName, input), status: 'pending' as const, input, }], @@ -110,6 +131,10 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) { onToolDone: (toolId, output) => { if (abortRef.current.aborted) return + const info = toolInputMapRef.current.get(toolId) + if (info) { + detectFileOperation(info.tool, info.input, vaultPath, fileCallbacksRef.current) + } update(m => ({ ...m, actions: m.actions.map(a => @@ -151,6 +176,7 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) { const clearConversation = useCallback(() => { abortRef.current.aborted = true responseAccRef.current = '' + toolInputMapRef.current = new Map() setMessages([]) setStatus('idle') }, []) @@ -160,22 +186,112 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) { // --- Helpers --- -function formatToolLabel(toolName: string, toolId: string): string { - const suffix = toolId.slice(-6) - const labels: Record = { - read_note: 'Reading note', - create_note: 'Creating note', - search_notes: 'Searching notes', - append_to_note: 'Appending to note', - edit_note_frontmatter: 'Editing frontmatter', - delete_note: 'Deleting note', - link_notes: 'Linking notes', - list_notes: 'Listing notes', - vault_context: 'Loading vault context', - ui_open_note: 'Opening note', - ui_open_tab: 'Opening tab', - ui_highlight: 'Highlighting', - ui_set_filter: 'Setting filter', +/** Parse the file_path from a Write or Edit tool input JSON string. */ +function parseFilePath(input: string | undefined): string | null { + if (!input) return null + try { + const parsed = JSON.parse(input) + return parsed.file_path ?? parsed.path ?? null + } catch { + return null } - return `${labels[toolName] ?? toolName}... (${suffix})` +} + +/** Convert absolute path to vault-relative path, or null if outside vault. */ +function toVaultRelative(filePath: string, vaultPath: string): string | null { + if (!filePath.startsWith(vaultPath)) return null + const rel = filePath.slice(vaultPath.length).replace(/^\//, '') + return rel || null +} + +/** Detect file operations from completed tool calls and notify callbacks. */ +function detectFileOperation( + toolName: string, + input: string | undefined, + vaultPath: string, + callbacks: AgentFileCallbacks | undefined, +) { + if (!callbacks) return + if (toolName !== 'Write' && toolName !== 'Edit') return + const filePath = parseFilePath(input) + if (!filePath || !filePath.endsWith('.md')) return + const rel = toVaultRelative(filePath, vaultPath) + if (!rel) return + if (toolName === 'Write') { + callbacks.onFileCreated?.(rel) + } else { + callbacks.onFileModified?.(rel) + } +} + +/** Generate a human-readable label for a tool call. */ +function formatToolLabel(toolName: string, input?: string): string { + // Native Claude Code tools + switch (toolName) { + case 'Bash': { + const cmd = extractBashCommand(input) + return cmd ? `$ ${cmd}` : 'Running command...' + } + case 'Write': { + const fp = parseFilePath(input) + return fp ? `Writing ${basename(fp)}` : 'Writing file...' + } + case 'Edit': { + const fp = parseFilePath(input) + return fp ? `Editing ${basename(fp)}` : 'Editing file...' + } + case 'Read': { + const fp = parseFilePath(input) + return fp ? `Reading ${basename(fp)}` : 'Reading file...' + } + case 'Glob': + return 'Searching files...' + case 'Grep': + return 'Searching content...' + case 'TodoWrite': + return 'Updating plan...' + default: + break + } + + // Laputa MCP tools + const mcpLabels: Record = { + search_notes: 'Searching notes', + get_vault_context: 'Loading vault context', + get_note: 'Reading note', + open_note: 'Opening note', + } + if (mcpLabels[toolName]) { + const notePath = parseNotePath(input) + return notePath ? `${mcpLabels[toolName]}: ${basename(notePath)}` : `${mcpLabels[toolName]}...` + } + + return `${toolName}...` +} + +function extractBashCommand(input: string | undefined): string | null { + if (!input) return null + try { + const parsed = JSON.parse(input) + const cmd = parsed.command ?? parsed.cmd ?? null + if (typeof cmd !== 'string') return null + // Truncate long commands + return cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd + } catch { + return null + } +} + +function parseNotePath(input: string | undefined): string | null { + if (!input) return null + try { + const parsed = JSON.parse(input) + return parsed.path ?? parsed.query ?? null + } catch { + return null + } +} + +function basename(filePath: string): string { + return filePath.split('/').pop() ?? filePath } diff --git a/src/utils/ai-agent.test.ts b/src/utils/ai-agent.test.ts index b739b93c..4bbb4e93 100644 --- a/src/utils/ai-agent.test.ts +++ b/src/utils/ai-agent.test.ts @@ -12,13 +12,14 @@ import { buildAgentSystemPrompt, streamClaudeAgent } from './ai-agent' describe('buildAgentSystemPrompt', () => { it('returns preamble when no vault context', () => { const prompt = buildAgentSystemPrompt() - expect(prompt).toContain('AI assistant integrated into Laputa') + expect(prompt).toContain('working inside Laputa') + expect(prompt).toContain('full shell access') expect(prompt).not.toContain('Vault context') }) it('appends vault context when provided', () => { const prompt = buildAgentSystemPrompt('Recent notes: foo, bar') - expect(prompt).toContain('AI assistant integrated into Laputa') + expect(prompt).toContain('working inside Laputa') expect(prompt).toContain('Vault context:') expect(prompt).toContain('Recent notes: foo, bar') }) diff --git a/src/utils/ai-agent.ts b/src/utils/ai-agent.ts index 035a4075..298bd795 100644 --- a/src/utils/ai-agent.ts +++ b/src/utils/ai-agent.ts @@ -1,7 +1,8 @@ /** - * AI Agent utilities — Claude CLI agent mode with MCP vault tools. + * AI Agent utilities — Claude CLI agent mode with full shell access + MCP vault tools. * - * The Claude CLI handles the tool-use loop internally via MCP. + * The agent has full native tool access (bash, read, write, edit) plus + * Laputa-specific MCP tools (search_notes, get_vault_context, get_note, open_note). * The frontend receives streaming events for text, tool calls, and completion. */ @@ -9,10 +10,14 @@ import { isTauri } from '../mock-tauri' // --- Agent system prompt --- -const AGENT_SYSTEM_PREAMBLE = `You are an AI assistant integrated into Laputa, a personal knowledge management app. -You can perform actions on the user's vault using the provided tools. -Be concise and helpful. When creating notes, use appropriate entity types and folder conventions. -When you've completed a task, briefly summarize what you did.` +const AGENT_SYSTEM_PREAMBLE = `You are working inside Laputa, a personal knowledge management app. + +Notes are markdown files with YAML frontmatter. Standard fields: title, type (aliased is_a), date, tags. +You have full shell access. Use bash for file operations, search, bulk edits. +Use the provided MCP tools for: full-text search (search_notes), vault orientation (get_vault_context), parsed note reading (get_note), and opening notes in the UI (open_note). + +When you create or edit a note, call open_note(path) so the user sees it in Laputa. +Be concise and helpful. When you've completed a task, briefly summarize what you did.` export function buildAgentSystemPrompt(vaultContext?: string): string { if (!vaultContext) return AGENT_SYSTEM_PREAMBLE @@ -41,7 +46,7 @@ export interface AgentStreamCallbacks { } /** - * Stream an agent task through the Claude CLI subprocess with MCP tools. + * Stream an agent task through the Claude CLI subprocess with full tool access. * The CLI handles the tool-use loop; we receive events for UI updates. */ export async function streamClaudeAgent(