From 4e81ab8aa38c62d9452b6a9381fe08da0718cf5b Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 5 Jun 2026 16:12:31 +0200 Subject: [PATCH] fix: add MCP create note tool --- docs/ARCHITECTURE.md | 2 +- docs/GETTING-STARTED.md | 2 +- mcp-server/index.js | 87 ++++++++++++++++++++- mcp-server/test.js | 104 +++++++++++++++++++++++++- mcp-server/vault.js | 87 ++++++++++++++++++++- mcp-server/ws-bridge.js | 58 +++++++++++++- src/lib/aiAgentFileOperations.test.ts | 19 +++++ src/lib/aiAgentFileOperations.ts | 59 ++++++++++++++- src/utils/ai-agent.ts | 1 + 9 files changed, 406 insertions(+), 13 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 013956e5..ce9f8568 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -360,7 +360,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan |------|--------|-------------| | `open_note` | `path` | Open and read a note by relative path | | `read_note` | `path` | Read note content (alias for `open_note`) | -| `create_note` | `path, title, [type]` | Create new note with title and optional type frontmatter | +| `create_note` | `path, content, [title], [type], [vaultPath]` | Create a new markdown note inside an active vault without overwriting existing files | | `search_notes` | `query, [limit]` | Search notes by title or content substring | | `list_vaults` | — | List active mounted vaults and whether each has root `AGENTS.md` instructions | | `append_to_note` | `path, text` | Append text to end of existing note | diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 4e9a8866..1c12adc1 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -249,7 +249,7 @@ tolaria/ │ └── icons/ # App icons │ ├── mcp-server/ # MCP bridge (Node.js or Bun) -│ ├── index.js # MCP server entry (stdio, 14 tools) +│ ├── index.js # MCP server entry (stdio tools) │ ├── vault.js # Vault file operations │ ├── ws-bridge.js # WebSocket bridge (ports 9710, 9711) │ ├── test.js # MCP server tests diff --git a/mcp-server/index.js b/mcp-server/index.js index d8c989b0..1803b6d3 100644 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -8,6 +8,7 @@ * - 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) + * - create_note: create a new markdown note without overwriting existing files * - open_note: signal Tolaria UI to open a note as a tab * - highlight_editor: visually highlight a UI element (editor, tab, etc.) * - refresh_vault: trigger vault rescan so new/modified files appear @@ -19,7 +20,7 @@ import { ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js' import WebSocket from 'ws' -import { searchNotes, getNote } from './vault.js' +import { createNote, searchNotes, getNote } from './vault.js' import { requireVaultPaths } from './vault-path.js' import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js' import path from 'node:path' @@ -32,6 +33,12 @@ const LOCAL_READ_ONLY_TOOL_ANNOTATIONS = Object.freeze({ idempotentHint: true, openWorldHint: false, }) +const LOCAL_CREATE_TOOL_ANNOTATIONS = Object.freeze({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, +}) // Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js). // The bridge relays messages to all other clients (the React frontend). @@ -156,6 +163,23 @@ const TOOLS = [ required: ['path'], }, }, + { + name: 'create_note', + description: 'Create a new markdown note inside an active Tolaria vault. Does not overwrite existing files. Use content for the full markdown including YAML frontmatter and H1.', + annotations: LOCAL_CREATE_TOOL_ANNOTATIONS, + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Relative path inside the vault, or an absolute path inside an active vault. Must end in .md.' }, + content: { type: 'string', description: 'Full markdown note content, including YAML frontmatter when needed.' }, + title: { type: 'string', description: 'Optional title used only when content is omitted.' }, + type: { type: 'string', description: 'Optional note type used only when content is omitted.' }, + is_a: { type: 'string', description: 'Legacy alias for type, used only when content is omitted.' }, + vaultPath: { type: 'string', description: 'Optional target vault root when multiple vaults are active.' }, + }, + required: ['path'], + }, + }, { name: 'open_note', description: 'Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.', @@ -268,6 +292,51 @@ function uiPath(args = {}) { return vaultPath ? path.join(vaultPath, notePath) : notePath } +function isInsideVaultRoot(vaultPath, notePath) { + const relative = path.relative(vaultPath, notePath) + return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative) +} + +function notePathArg(args = {}) { + const notePath = typeof args.path === 'string' ? args.path.trim() : '' + if (!notePath) throw new Error('Note path is required') + return notePath +} + +function writableVaultPath(args = {}) { + const requested = requestedVaultPath(args) + if (requested) return requested + + const roots = activeVaultPaths() + const notePath = notePathArg(args) + if (path.isAbsolute(notePath)) { + const root = roots.find(vaultPath => isInsideVaultRoot(vaultPath, notePath)) + if (root) return root + } + if (roots.length === 1) return roots[0] + throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`) +} + +function yamlScalar(value) { + return JSON.stringify(value) +} + +function fallbackCreateNoteContent(args = {}) { + const title = typeof args.title === 'string' && args.title.trim() ? args.title.trim() : path.basename(notePathArg(args), '.md') + const type = typeof args.type === 'string' && args.type.trim() + ? args.type.trim() + : typeof args.is_a === 'string' && args.is_a.trim() + ? args.is_a.trim() + : 'Note' + return `---\ntype: ${yamlScalar(type)}\n---\n\n# ${title}\n` +} + +function createNoteContent(args = {}) { + return typeof args.content === 'string' && args.content.trim() + ? args.content + : fallbackCreateNoteContent(args) +} + async function handleSearchNotes(args) { const results = await searchActiveVaults(args.query, args.limit) const text = results.length === 0 @@ -300,6 +369,21 @@ async function handleGetNote(args) { return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] } } +async function handleCreateNote(args = {}) { + const notePath = notePathArg(args) + const vaultPath = writableVaultPath(args) + const note = await createNote(vaultPath, notePath, createNoteContent(args)) + const targetPath = uiPath({ ...args, path: note.path, vaultPath }) + broadcastUiAction('vault_changed', { path: targetPath }) + broadcastUiAction('open_tab', { path: targetPath }) + return { + content: [{ + type: 'text', + text: JSON.stringify({ path: note.path, absolutePath: note.absolutePath, vaultPath }, null, 2), + }], + } +} + function handleOpenNote(args) { // Refresh vault first so the new/modified note appears in the note list, // then signal the UI to open it in a tab. @@ -324,6 +408,7 @@ const TOOL_HANDLERS = new Map([ ['get_vault_context', handleVaultContext], ['list_vaults', handleListVaults], ['get_note', handleGetNote], + ['create_note', handleCreateNote], ['open_note', handleOpenNote], ['highlight_editor', handleHighlightEditor], ['refresh_vault', handleRefreshVault], diff --git a/mcp-server/test.js b/mcp-server/test.js index 820ba6b8..d9f35234 100644 --- a/mcp-server/test.js +++ b/mcp-server/test.js @@ -2,7 +2,7 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' import { spawn } from 'node:child_process' import { - mkdtemp, mkdir, open, rm, writeFile, + access, mkdtemp, mkdir, open, readFile, rm, writeFile, } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' @@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { - findMarkdownFiles, getNote, searchNotes, vaultContext, + createNote, findMarkdownFiles, getNote, searchNotes, vaultContext, } from './vault.js' import { requireVaultPath, requireVaultPaths } from './vault-path.js' import { vaultContextWithInstructions } from './agent-instructions.js' @@ -126,6 +126,74 @@ describe('getNote', () => { }) }) +describe('createNote', () => { + it('creates a new markdown note inside the vault', async () => { + const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-')) + const content = `--- +type: Note +--- + +# MCP Created +` + + try { + const note = await createNote(vaultDir, 'note/mcp-created.md', content) + assert.equal(note.path, 'note/mcp-created.md') + assert.equal(await readFile(path.join(vaultDir, note.path), 'utf-8'), content) + } finally { + await rm(vaultDir, { recursive: true, force: true }) + } + }) + + it('does not overwrite an existing note', async () => { + const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-existing-')) + const notePath = path.join(vaultDir, 'existing.md') + await writeFile(notePath, '# Existing\n', 'utf-8') + + try { + await assert.rejects( + () => createNote(vaultDir, 'existing.md', '# Replacement\n'), + { code: 'EEXIST' }, + ) + assert.equal(await readFile(notePath, 'utf-8'), '# Existing\n') + } finally { + await rm(vaultDir, { recursive: true, force: true }) + } + }) + + it('rejects absolute paths outside the vault', async () => { + const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-vault-')) + const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-outside-')) + + try { + await assert.rejects( + () => createNote(vaultDir, path.join(outsideDir, 'outside.md'), '# Outside\n'), + { message: ACTIVE_VAULT_ERROR }, + ) + } finally { + await rm(vaultDir, { recursive: true, force: true }) + await rm(outsideDir, { recursive: true, force: true }) + } + }) + + it('rejects outside paths before creating missing parent folders', async () => { + const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-vault-')) + const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-outside-')) + const outsideParent = path.join(outsideDir, 'missing-parent') + + try { + await assert.rejects( + () => createNote(vaultDir, path.join(outsideParent, 'outside.md'), '# Outside\n'), + { message: ACTIVE_VAULT_ERROR }, + ) + await assert.rejects(() => access(outsideParent), { code: 'ENOENT' }) + } finally { + await rm(vaultDir, { recursive: true, force: true }) + await rm(outsideDir, { recursive: true, force: true }) + } + }) +}) + describe('searchNotes', () => { it('should find notes matching title', async () => { const results = await searchNotes(tmpDir, 'Test Project') @@ -330,11 +398,43 @@ describe('stdio process lifecycle', () => { assert.equal(tool.annotations?.destructiveHint, false, `${name} should not be treated as destructive`) assert.equal(tool.annotations?.openWorldHint, false, `${name} should stay scoped to local active vaults`) } + + const createTool = toolsByName.get('create_note') + assert.ok(createTool, 'Missing MCP tool: create_note') + assert.equal(createTool.annotations?.readOnlyHint, false) + assert.equal(createTool.annotations?.destructiveHint, false) + assert.equal(createTool.annotations?.openWorldHint, false) } finally { await closeMcpClient(client, stderr) } }) + it('creates a note through the MCP create_note tool', async () => { + const { client, stderr } = await connectMcpClient() + const relativePath = 'note/mcp-tool-created.md' + const absolutePath = path.join(tmpDir, relativePath) + const content = `--- +type: Note +--- + +# MCP Tool Created +` + + try { + await rm(absolutePath, { force: true }) + const result = await client.callTool({ + name: 'create_note', + arguments: { path: relativePath, content }, + }) + + assert.equal(await readFile(absolutePath, 'utf-8'), content) + assert.match(JSON.stringify(result.content), /mcp-tool-created\.md/) + } finally { + await rm(absolutePath, { force: true }) + await closeMcpClient(client, stderr) + } + }) + it('exits when the MCP client closes stdin', async () => { const child = spawn(process.execPath, ['index.js'], { cwd: MCP_SERVER_DIR, diff --git a/mcp-server/vault.js b/mcp-server/vault.js index c77b27df..0c9ac3f9 100644 --- a/mcp-server/vault.js +++ b/mcp-server/vault.js @@ -1,9 +1,10 @@ /** * Vault operations — read-only helpers for Tolaria markdown vault. - * Write operations are handled by the app-managed agent's active permission - * profile and native file-edit tools when available. + * Most write operations are handled by the app-managed agent's active + * permission profile and native file-edit tools; createNote is intentionally + * narrow so read-only agents can create a new Markdown file without overwrite. */ -import { open, opendir, realpath } from 'node:fs/promises' +import { mkdir, open, opendir, realpath } from 'node:fs/promises' import path from 'node:path' import matter from 'gray-matter' @@ -60,6 +61,22 @@ export async function getNote(vaultPath, notePath) { } } +/** + * Create a new markdown note inside the vault without overwriting an existing file. + * @param {string} vaultPath + * @param {string} notePath + * @param {string} content + * @returns {Promise<{path: string, absolutePath: string}>} + */ +export async function createNote(vaultPath, notePath, content) { + const { requestedPath, relativePath } = await resolveNewVaultNotePath(vaultPath, notePath) + await writeNewUtf8File(requestedPath, content) + return { + path: relativePath, + absolutePath: requestedPath, + } +} + /** * Search notes by title or content substring. * @param {string} vaultPath @@ -145,6 +162,61 @@ function resolveRequestedNotePath(vaultRoot, notePath) { return resolved } +async function resolveNewVaultNotePath(vaultPath, notePath) { + const requestedNotePath = validateNewNotePath(notePath) + const vaultRoot = await realpath(vaultPath) + const requestedPath = resolveRequestedNotePath(vaultRoot, requestedNotePath) + const relativePath = relativeNotePathInsideVault(vaultRoot, requestedPath) + await ensureWritableParentInsideVault(vaultRoot, requestedPath) + return { requestedPath, relativePath } +} + +function validateNewNotePath(notePath) { + const trimmedPath = typeof notePath === 'string' ? notePath.trim() : '' + if (!trimmedPath) { + throw new Error('Note path is required') + } + if (!trimmedPath.endsWith('.md')) { + throw new Error('New notes must be markdown files ending in .md') + } + return trimmedPath +} + +async function ensureWritableParentInsideVault(vaultRoot, requestedPath) { + const parentPath = path.dirname(requestedPath) + const existingAncestor = await nearestExistingAncestor(parentPath) + assertInsideVault(vaultRoot, existingAncestor) + await mkdir(parentPath, { recursive: true }) + assertInsideVault(vaultRoot, await realpath(parentPath)) +} + +async function nearestExistingAncestor(targetPath) { + let currentPath = targetPath + while (currentPath && currentPath !== path.dirname(currentPath)) { + try { + return await realpath(currentPath) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + currentPath = path.dirname(currentPath) + } + } + return realpath(currentPath) +} + +function assertInsideVault(vaultRoot, targetPath) { + if (!isVaultRelativePath(path.relative(vaultRoot, targetPath))) { + throw new Error(ACTIVE_VAULT_ERROR) + } +} + +function relativeNotePathInsideVault(vaultRoot, requestedPath) { + const relativePath = path.relative(vaultRoot, requestedPath) + if (!isVaultRelativePath(relativePath) || !relativePath) { + throw new Error(ACTIVE_VAULT_ERROR) + } + return relativePath +} + function resolveInside(root, target) { const resolved = path.resolve(root, target) const relative = path.relative(root, resolved) @@ -338,6 +410,15 @@ async function readUtf8File(filePath) { } } +async function writeNewUtf8File(filePath, content) { + const handle = await open(filePath, 'wx') + try { + await handle.writeFile(content, 'utf-8') + } finally { + await handle.close() + } +} + async function statFile(filePath) { const handle = await open(filePath, 'r') try { diff --git a/mcp-server/ws-bridge.js b/mcp-server/ws-bridge.js index 4ccf56da..9d1b76ed 100644 --- a/mcp-server/ws-bridge.js +++ b/mcp-server/ws-bridge.js @@ -22,7 +22,7 @@ import { createServer } from 'node:http' import { WebSocketServer } from 'ws' import { - getNote, searchNotes, + createNote, getNote, searchNotes, } from './vault.js' import { requireVaultPaths } from './vault-path.js' import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js' @@ -62,6 +62,51 @@ function uiPath(args = {}) { return vaultPath ? path.join(vaultPath, notePath) : notePath } +function isInsideVaultRoot(vaultPath, notePath) { + const relative = path.relative(vaultPath, notePath) + return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative) +} + +function notePathArg(args = {}) { + const notePath = typeof args.path === 'string' ? args.path.trim() : '' + if (!notePath) throw new Error('Note path is required') + return notePath +} + +function writableVaultPath(args = {}) { + const requested = requestedVaultPath(args) + if (requested) return requested + + const roots = activeVaultPaths() + const notePath = notePathArg(args) + if (path.isAbsolute(notePath)) { + const root = roots.find(vaultPath => isInsideVaultRoot(vaultPath, notePath)) + if (root) return root + } + if (roots.length === 1) return roots[0] + throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`) +} + +function yamlScalar(value) { + return JSON.stringify(value) +} + +function fallbackCreateNoteContent(args = {}) { + const title = typeof args.title === 'string' && args.title.trim() ? args.title.trim() : path.basename(notePathArg(args), '.md') + const type = typeof args.type === 'string' && args.type.trim() + ? args.type.trim() + : typeof args.is_a === 'string' && args.is_a.trim() + ? args.is_a.trim() + : 'Note' + return `---\ntype: ${yamlScalar(type)}\n---\n\n# ${title}\n` +} + +function createNoteContent(args = {}) { + return typeof args.content === 'string' && args.content.trim() + ? args.content + : fallbackCreateNoteContent(args) +} + async function getNoteFromActiveVaults(notePath, vaultPath = null) { const candidates = vaultPath ? [vaultPath] : activeVaultPaths() const matches = [] @@ -129,6 +174,16 @@ function uiOpenTabTool(args) { return { ok: true } } +async function createNoteTool(args = {}) { + const notePath = notePathArg(args) + const vaultPath = writableVaultPath(args) + const note = await createNote(vaultPath, notePath, createNoteContent(args)) + const targetPath = uiPath({ ...args, path: note.path, vaultPath }) + broadcastUiAction('vault_changed', { path: targetPath }) + broadcastUiAction('open_tab', { path: targetPath }) + return { ok: true, path: note.path, absolutePath: note.absolutePath, vaultPath } +} + function highlightTool(args) { broadcastUiAction('highlight', { element: args.element, path: args.path }) return { ok: true } @@ -161,6 +216,7 @@ async function listVaultsTool() { const TOOL_EXECUTORS = [ ['open_note', readNoteTool], ['read_note', readNoteTool], + ['create_note', createNoteTool], ['search_notes', (args) => searchActiveVaults(args.query, args.limit)], ['vault_context', () => activeVaultContext()], ['list_vaults', () => listVaultsTool()], diff --git a/src/lib/aiAgentFileOperations.test.ts b/src/lib/aiAgentFileOperations.test.ts index 03742223..0199f7b7 100644 --- a/src/lib/aiAgentFileOperations.test.ts +++ b/src/lib/aiAgentFileOperations.test.ts @@ -23,6 +23,25 @@ describe('detectFileOperation', () => { expect(cb.onFileModified).not.toHaveBeenCalled() }) + it('calls onFileCreated for create_note tool with relative markdown path', () => { + const cb = makeCallbacks() + detectFileOperation({ toolName: 'create_note', input: JSON.stringify({ path: 'note/generated.md' }), vaultPath: VAULT, callbacks: cb }) + expect(cb.onFileCreated).toHaveBeenCalledWith('note/generated.md') + expect(cb.onVaultChanged).not.toHaveBeenCalled() + }) + + it('calls onFileCreated for create_note tool with Windows absolute path', () => { + const cb = makeCallbacks() + detectFileOperation({ + toolName: 'create_note', + input: JSON.stringify({ path: String.raw`D:\Notes\Notas\nota-longa-teste-gerada-2.md` }), + vaultPath: String.raw`D:\Notes\Notas`, + callbacks: cb, + }) + expect(cb.onFileCreated).toHaveBeenCalledWith('nota-longa-teste-gerada-2.md') + expect(cb.onVaultChanged).not.toHaveBeenCalled() + }) + it('calls onFileModified for Edit tool with .md in vault', () => { const cb = makeCallbacks() detectFileOperation({ toolName: 'Edit', input: JSON.stringify({ file_path: `${VAULT}/note/test.md` }), vaultPath: VAULT, callbacks: cb }) diff --git a/src/lib/aiAgentFileOperations.ts b/src/lib/aiAgentFileOperations.ts index ad381e7d..3d10f26e 100644 --- a/src/lib/aiAgentFileOperations.ts +++ b/src/lib/aiAgentFileOperations.ts @@ -1,3 +1,5 @@ +import { normalizeNotePathSeparators, normalizeVaultRelativePath } from '../utils/notePathIdentity' + export interface AgentFileCallbacks { onFileCreated?: (relativePath: string) => void onFileModified?: (relativePath: string) => void @@ -38,6 +40,11 @@ interface VaultRelativePathRequest { vaultPath: string } +interface NormalizedToolPath { + value: string + windowsStyle: boolean +} + export function detectFileOperation(operation: AgentFileOperation): void { if (!operation.callbacks) return const context = { @@ -53,6 +60,9 @@ export function detectFileOperation(operation: AgentFileOperation): void { case 'Write': notifyWriteOperation(context) return + case 'create_note': + notifyCreateNoteOperation(context) + return case 'Edit': notifyEditOperation(context) } @@ -72,6 +82,13 @@ function notifyWriteOperation(context: OperationContext): void { }) } +function notifyCreateNoteOperation(context: OperationContext): void { + notifyCreatedPath({ + relativePath: markdownCreationPathFromToolInput(context), + callbacks: context.callbacks, + }) +} + function notifyEditOperation(context: OperationContext): void { notifyModifiedPath({ relativePath: markdownPathFromToolInput(context), @@ -102,6 +119,15 @@ function markdownPathFromToolInput(context: ToolInputContext): string | null { }) } +function markdownCreationPathFromToolInput(context: ToolInputContext): string | null { + const filePath = parseFilePath(context) + if (!filePath?.endsWith('.md')) return null + const notePath = normalizeToolPath(filePath) + return isRelativePath(notePath) + ? safeRelativeMarkdownPath(notePath) + : markdownVaultRelativePath({ filePath, vaultPath: context.vaultPath }) +} + function parseFilePath(source: ToolInputSource): string | null { const parsed = parseToolInput(source) if (!parsed) return null @@ -142,10 +168,35 @@ function markdownVaultRelativePath(request: { } function toVaultRelative({ filePath, vaultPath }: VaultRelativePathRequest): string | null { - const vaultRoot = vaultPath.replace(/\/+$/, '') - const prefix = `${vaultRoot}/` - if (!filePath.startsWith(prefix)) return null - return filePath.slice(prefix.length) || null + const vaultRoot = normalizeToolPath(vaultPath) + const notePath = normalizeToolPath(filePath) + return childPathInsideVault(vaultRoot, notePath) +} + +function childPathInsideVault(vaultRoot: NormalizedToolPath, notePath: NormalizedToolPath): string | null { + const prefix = `${vaultRoot.value}/` + const caseInsensitive = vaultRoot.windowsStyle || notePath.windowsStyle + const normalizedPrefix = caseInsensitive ? prefix.toLowerCase() : prefix + const normalizedNotePath = caseInsensitive ? notePath.value.toLowerCase() : notePath.value + if (!normalizedNotePath.startsWith(normalizedPrefix)) return null + return notePath.value.slice(prefix.length) || null +} + +function normalizeToolPath(value: string): NormalizedToolPath { + return { + value: normalizeNotePathSeparators(value).replace(/\/+$/u, ''), + windowsStyle: /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('\\\\'), + } +} + +function isRelativePath(path: NormalizedToolPath): boolean { + return !path.value.startsWith('/') && !path.windowsStyle +} + +function safeRelativeMarkdownPath(path: NormalizedToolPath): string | null { + const relativePath = normalizeVaultRelativePath(path.value) + if (!relativePath || relativePath.startsWith('../') || relativePath.includes('/../')) return null + return relativePath } export function parseBashFileCreation(request: BashFileCreationRequest): string | null { diff --git a/src/utils/ai-agent.ts b/src/utils/ai-agent.ts index 2e4d1990..f7d6396c 100644 --- a/src/utils/ai-agent.ts +++ b/src/utils/ai-agent.ts @@ -79,6 +79,7 @@ const AGENT_SYSTEM_PREAMBLE = `You are working inside Tolaria, a local-first Mar Notes are Markdown files with YAML frontmatter. Organization is primarily expressed through H1 titles, types, properties, wikilinks, and relationships, not folder structure. Prefer file edit tools for note changes. 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). +Use create_note(path, content, vaultPath?) for new Markdown notes when shell writes are unavailable. When you create or edit a note, call open_note(path) so the user sees it in Tolaria. When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.