phase-c.mjs
262 lines 9.4 KB
Raw
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