feat: enable full shell access for AI agent + simplify MCP tools

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 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-05 12:12:12 +01:00
parent 6a57e83c99
commit 89da970455
12 changed files with 311 additions and 420 deletions

View File

@@ -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: {} } },
)

View File

@@ -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<string>}
* @returns {Promise<{path: string, frontmatter: Record<string, unknown>, 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<string, string>} [frontmatter]
* @returns {Promise<string>} 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<void>}
*/
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<string, unknown>} patch
* @returns {Promise<Record<string, unknown>>} 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<void>}
*/
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<string[]>} 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<Array<{path: string, title: string, type: string|null}>>}
*/
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 ---

View File

@@ -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<String> {
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<F>(req: AgentStreamRequest, mut emit: F) -> Result<String, String>
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<Vec<String>, String> {
let mcp_config = build_mcp_config(&req.vault_path)?;
@@ -181,8 +182,6 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, 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<F>(bin: &PathBuf, args: &[String], emit: &mut F) -> Result<String, String>
/// When `cwd` is `Some`, the subprocess starts with that working directory.
fn run_claude_subprocess<F>(
bin: &PathBuf,
args: &[String],
cwd: Option<&str>,
emit: &mut F,
) -> Result<String, String>
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"));
}

View File

@@ -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}
/>
</div>
</div>

View File

@@ -66,8 +66,8 @@ describe('AiActionCard', () => {
expect(header.getAttribute('tabindex')).toBe('0')
})
it('uses lighter background for ui_ tools', () => {
render(<AiActionCard {...defaults} tool="ui_open_tab" label="Opened tab" />)
it('uses lighter background for open_note tool', () => {
render(<AiActionCard {...defaults} tool="open_note" label="Opening note" />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.06')
})

View File

@@ -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<string, IconRenderer> = {
create_note: (s) => <PencilSimple size={s} />,
edit_note_frontmatter: (s) => <PencilSimple size={s} />,
append_to_note: (s) => <PencilSimple size={s} />,
// Native Claude Code tools
Bash: (s) => <Terminal size={s} />,
Write: (s) => <PencilSimple size={s} />,
Edit: (s) => <NotePencil size={s} />,
Read: (s) => <File size={s} />,
Glob: (s) => <FolderOpen size={s} />,
Grep: (s) => <MagnifyingGlass size={s} />,
// Laputa MCP tools
search_notes: (s) => <MagnifyingGlass size={s} />,
list_notes: (s) => <MagnifyingGlass size={s} />,
link_notes: (s) => <Link size={s} />,
get_vault_context: (s) => <ChartBar size={s} />,
get_note: (s) => <File size={s} />,
open_note: (s) => <Eye size={s} />,
// Legacy tools (for backward compatibility with existing messages)
create_note: (s) => <PencilSimple size={s} />,
delete_note: (s) => <Trash size={s} />,
vault_context: (s) => <ChartBar size={s} />,
ui_open_note: (s) => <Eye size={s} />,
ui_open_tab: (s) => <Eye size={s} />,
ui_highlight: (s) => <Sparkle size={s} />,
ui_set_filter: (s) => <Sparkle size={s} />,
}
const DEFAULT_ICON: IconRenderer = (s) => <PencilSimple size={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)',
}}
>
<div

View File

@@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { WikilinkChatInput } from './WikilinkChatInput'
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
@@ -11,6 +11,8 @@ export type { AiAgentMessage } from '../hooks/useAiAgent'
interface AiPanelProps {
onClose: () => 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<NoteReference[]>([])
const inputRef = useRef<HTMLInputElement>(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<AgentFileCallbacks>(() => ({
onFileCreated,
onFileModified,
}), [onFileCreated, onFileModified])
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
const hasContext = !!activeEntry
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'

View File

@@ -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}
/>
</div>
</div>

View File

@@ -24,6 +24,8 @@ interface EditorRightPanelProps {
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
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({
<AiPanel
onClose={() => onToggleAIChat?.()}
onOpenNote={onOpenNote}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
vaultPath={vaultPath}
activeEntry={inspectorEntry}
entries={entries}

View File

@@ -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<AiAgentMessage[]>([])
const [status, setStatus] = useState<AgentStatus>('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<Map<string, { tool: string; input?: string }>>(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<string, string> = {
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<string, string> = {
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
}

View File

@@ -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')
})

View File

@@ -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(