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:
@@ -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: {} } },
|
||||
)
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
43
src/App.tsx
43
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user