phase-c.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
2 days ago
| 1 | /** |
| 2 | * Issue #1 Phase C — enhanced MCP tools. |
| 3 | */ |
| 4 | |
| 5 | import { z } from 'zod'; |
| 6 | import path from 'path'; |
| 7 | import { loadConfig } from '../../lib/config.mjs'; |
| 8 | import { readNote, resolveVaultRelativePath, normalizeSlug } from '../../lib/vault.mjs'; |
| 9 | import { runRelate } from '../../lib/relate.mjs'; |
| 10 | import { runBacklinks } from '../../lib/backlinks.mjs'; |
| 11 | import { runCaptureInbox } from '../../lib/capture-inbox.mjs'; |
| 12 | import { transcribe } from '../../lib/transcribe.mjs'; |
| 13 | import { writeNote } from '../../lib/write.mjs'; |
| 14 | import { runVaultSync } from '../../lib/vault-git-sync.mjs'; |
| 15 | import { completeChat } from '../../lib/llm-complete.mjs'; |
| 16 | import { runExtractTasks } from '../../lib/extract-tasks.mjs'; |
| 17 | import { runCluster } from '../../lib/cluster-semantic.mjs'; |
| 18 | import { runTagSuggest } from '../../lib/tag-suggest.mjs'; |
| 19 | import { trySampling } from '../sampling.mjs'; |
| 20 | |
| 21 | function jsonResponse(obj) { |
| 22 | return { content: [{ type: 'text', text: JSON.stringify(obj) }] }; |
| 23 | } |
| 24 | |
| 25 | function jsonError(msg, code = 'ERROR') { |
| 26 | return { content: [{ type: 'text', text: JSON.stringify({ error: msg, code }) }], isError: true }; |
| 27 | } |
| 28 | |
| 29 | /** |
| 30 | * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server |
| 31 | */ |
| 32 | export function registerPhaseCTools(server) { |
| 33 | server.registerTool( |
| 34 | 'relate', |
| 35 | { |
| 36 | description: 'Find semantically related notes to a given note path (vector nearest neighbors, excluding self).', |
| 37 | inputSchema: { |
| 38 | path: z.string().describe('Vault-relative path to the source note (.md)'), |
| 39 | limit: z.number().optional().describe('Max related notes (default 5, max 20)'), |
| 40 | project: z.string().optional().describe('Filter neighbors by project slug'), |
| 41 | }, |
| 42 | }, |
| 43 | async (args) => { |
| 44 | try { |
| 45 | const out = await runRelate(args.path, { limit: args.limit, project: args.project }); |
| 46 | return jsonResponse(out); |
| 47 | } catch (e) { |
| 48 | return jsonError(e.message || String(e), 'RUNTIME_ERROR'); |
| 49 | } |
| 50 | } |
| 51 | ); |
| 52 | |
| 53 | server.registerTool( |
| 54 | 'backlinks', |
| 55 | { |
| 56 | description: 'List notes that wikilink to the target note ([[target]]).', |
| 57 | inputSchema: { |
| 58 | path: z.string().describe('Vault-relative path of the target note'), |
| 59 | }, |
| 60 | }, |
| 61 | async (args) => { |
| 62 | try { |
| 63 | const config = loadConfig(); |
| 64 | return jsonResponse(runBacklinks(config, args.path)); |
| 65 | } catch (e) { |
| 66 | return jsonError(e.message || String(e), 'RUNTIME_ERROR'); |
| 67 | } |
| 68 | } |
| 69 | ); |
| 70 | |
| 71 | server.registerTool( |
| 72 | 'capture', |
| 73 | { |
| 74 | description: 'Fast inbox capture: writes a new note under inbox/ (or projects/{project}/inbox/) with inbox frontmatter. No AIR check.', |
| 75 | inputSchema: { |
| 76 | text: z.string().describe('Note body text'), |
| 77 | source: z.string().optional().describe('Source label (default mcp-capture)'), |
| 78 | project: z.string().optional().describe('Optional project slug for project inbox'), |
| 79 | tags: z.array(z.string()).optional().describe('Optional tags'), |
| 80 | }, |
| 81 | }, |
| 82 | async (args) => { |
| 83 | try { |
| 84 | const out = await runCaptureInbox(args.text, { |
| 85 | source: args.source, |
| 86 | project: args.project, |
| 87 | tags: args.tags, |
| 88 | }); |
| 89 | return jsonResponse(out); |
| 90 | } catch (e) { |
| 91 | return jsonError(e.message || String(e), 'RUNTIME_ERROR'); |
| 92 | } |
| 93 | } |
| 94 | ); |
| 95 | |
| 96 | server.registerTool( |
| 97 | 'transcribe', |
| 98 | { |
| 99 | description: 'Transcribe an audio/video file (Whisper) and write the transcript to the vault.', |
| 100 | inputSchema: { |
| 101 | path: z.string().describe('Absolute path to audio/video file on disk'), |
| 102 | project: z.string().optional(), |
| 103 | tags: z.array(z.string()).optional(), |
| 104 | output_path: z |
| 105 | .string() |
| 106 | .optional() |
| 107 | .describe('Vault-relative .md path; default inbox auto-named from timestamp'), |
| 108 | }, |
| 109 | }, |
| 110 | async (args) => { |
| 111 | try { |
| 112 | const config = loadConfig(); |
| 113 | const { text, transcoded } = await transcribe(args.path, { |
| 114 | model: config.transcription?.model, |
| 115 | transcodeOversized: config.transcription?.transcode_oversized !== false, |
| 116 | }); |
| 117 | const base = path.basename(args.path, path.extname(args.path)).replace(/[^a-z0-9-_]+/gi, '-').slice(0, 60) || 'transcript'; |
| 118 | const dateStr = new Date().toISOString().slice(0, 10); |
| 119 | let rel = args.output_path; |
| 120 | if (!rel) { |
| 121 | const proj = args.project ? `projects/${normalizeSlug(String(args.project))}/inbox` : 'inbox'; |
| 122 | rel = `${proj}/${dateStr}-transcribe-${base}.md`; |
| 123 | } |
| 124 | const tagLine = args.tags?.length ? args.tags.join(', ') : undefined; |
| 125 | const fm = { source: 'transcribe', date: dateStr, inbox: true }; |
| 126 | if (tagLine) fm.tags = tagLine; |
| 127 | if (args.project) fm.project = args.project; |
| 128 | const body = |
| 129 | transcoded |
| 130 | ? '> *Transcoded for Whisper (ffmpeg) before upload.*\n\n' + (text || '') |
| 131 | : text || ''; |
| 132 | const out = writeNote(config.vault_path, rel, { body, frontmatter: fm }); |
| 133 | return jsonResponse({ ...out, transcript_length: text.length, written: true, transcoded: transcoded === true }); |
| 134 | } catch (e) { |
| 135 | return jsonError(e.message || String(e), 'RUNTIME_ERROR'); |
| 136 | } |
| 137 | } |
| 138 | ); |
| 139 | |
| 140 | server.registerTool( |
| 141 | 'vault_sync', |
| 142 | { |
| 143 | description: 'Git add, commit, and push the vault when vault.git.enabled and remote are configured.', |
| 144 | inputSchema: { |
| 145 | message: z.string().optional().describe('Ignored; commit message is auto-generated'), |
| 146 | }, |
| 147 | }, |
| 148 | async () => { |
| 149 | try { |
| 150 | const config = loadConfig(); |
| 151 | const out = runVaultSync(config); |
| 152 | return jsonResponse(out); |
| 153 | } catch (e) { |
| 154 | return jsonError(e.message || String(e), 'RUNTIME_ERROR'); |
| 155 | } |
| 156 | } |
| 157 | ); |
| 158 | |
| 159 | server.registerTool( |
| 160 | 'summarize', |
| 161 | { |
| 162 | description: |
| 163 | 'Summarize one or more notes. When the MCP host supports sampling, uses the client LLM; otherwise OpenAI or Ollama on the server (OPENAI_API_KEY or Ollama + OLLAMA_CHAT_MODEL).', |
| 164 | inputSchema: { |
| 165 | path: z.string().optional().describe('Single vault-relative note path'), |
| 166 | paths: z.array(z.string()).optional().describe('Multiple note paths'), |
| 167 | style: z.enum(['brief', 'detailed', 'bullets']).optional(), |
| 168 | max_words: z.number().optional(), |
| 169 | }, |
| 170 | }, |
| 171 | async (args) => { |
| 172 | try { |
| 173 | const config = loadConfig(); |
| 174 | const paths = []; |
| 175 | if (args.path) paths.push(args.path); |
| 176 | if (args.paths?.length) paths.push(...args.paths); |
| 177 | if (!paths.length) return jsonError('Provide path or paths', 'INVALID'); |
| 178 | const chunks = []; |
| 179 | for (const p of paths) { |
| 180 | resolveVaultRelativePath(config.vault_path, p); |
| 181 | const n = readNote(config.vault_path, p); |
| 182 | chunks.push(`## ${p}\n${n.body || ''}`); |
| 183 | } |
| 184 | const combined = chunks.join('\n\n').slice(0, 48000); |
| 185 | const style = args.style || 'brief'; |
| 186 | const mw = args.max_words ?? (style === 'detailed' ? 400 : style === 'bullets' ? 300 : 150); |
| 187 | const system = `You summarize vault notes faithfully. Output style: ${style}. Max approximately ${mw} words.`; |
| 188 | const user = `Summarize the following markdown note(s):\n\n${combined}`; |
| 189 | const maxTokens = Math.min(1024, Math.floor(mw * 2)); |
| 190 | let summary = await trySampling(server, { system, user, maxTokens }); |
| 191 | if (summary == null) { |
| 192 | summary = await completeChat(config, { system, user, maxTokens }); |
| 193 | } |
| 194 | return jsonResponse({ summary, source_paths: paths.map((p) => p.replace(/\\/g, '/')) }); |
| 195 | } catch (e) { |
| 196 | return jsonError(e.message || String(e), 'RUNTIME_ERROR'); |
| 197 | } |
| 198 | } |
| 199 | ); |
| 200 | |
| 201 | server.registerTool( |
| 202 | 'extract_tasks', |
| 203 | { |
| 204 | description: 'Extract Markdown checkbox tasks (- [ ] / - [x]) from notes with optional filters.', |
| 205 | inputSchema: { |
| 206 | folder: z.string().optional(), |
| 207 | project: z.string().optional(), |
| 208 | tag: z.string().optional(), |
| 209 | since: z.string().optional().describe('YYYY-MM-DD'), |
| 210 | status: z.enum(['open', 'done', 'all']).optional(), |
| 211 | }, |
| 212 | }, |
| 213 | async (args) => { |
| 214 | try { |
| 215 | const config = loadConfig(); |
| 216 | return jsonResponse(runExtractTasks(config, args)); |
| 217 | } catch (e) { |
| 218 | return jsonError(e.message || String(e), 'RUNTIME_ERROR'); |
| 219 | } |
| 220 | } |
| 221 | ); |
| 222 | |
| 223 | server.registerTool( |
| 224 | 'cluster', |
| 225 | { |
| 226 | description: 'Cluster notes by embedding truncated content (k-means). Sample size capped at 200 notes.', |
| 227 | inputSchema: { |
| 228 | project: z.string().optional(), |
| 229 | folder: z.string().optional(), |
| 230 | n_clusters: z.number().optional().describe('Number of clusters (default 5, max 15)'), |
| 231 | }, |
| 232 | }, |
| 233 | async (args) => { |
| 234 | try { |
| 235 | const out = await runCluster(args); |
| 236 | return jsonResponse(out); |
| 237 | } catch (e) { |
| 238 | return jsonError(e.message || String(e), 'RUNTIME_ERROR'); |
| 239 | } |
| 240 | } |
| 241 | ); |
| 242 | |
| 243 | server.registerTool( |
| 244 | 'tag_suggest', |
| 245 | { |
| 246 | description: 'Suggest tags from semantically similar notes (requires indexed vault).', |
| 247 | inputSchema: { |
| 248 | path: z.string().optional().describe('Vault-relative note path'), |
| 249 | body: z.string().optional().describe('Raw markdown/text if no path'), |
| 250 | }, |
| 251 | }, |
| 252 | async (args) => { |
| 253 | try { |
| 254 | if (!args.path && !args.body) return jsonError('Provide path or body', 'INVALID'); |
| 255 | const out = await runTagSuggest({ path: args.path, body: args.body }); |
| 256 | return jsonResponse(out); |
| 257 | } catch (e) { |
| 258 | return jsonError(e.message || String(e), 'RUNTIME_ERROR'); |
| 259 | } |
| 260 | } |
| 261 | ); |
| 262 | } |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
2 days ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
2 days ago