register.mjs
566 lines 20.6 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Issue #1 Phase B — MCP prompts (registerKnowtationPrompts).
3 * Prompt argument values are strings per MCP; coerce inside handlers.
4 */
5
6 import fs from 'fs';
7 import path from 'path';
8 import { z } from 'zod';
9 import { loadConfig } from '../../lib/config.mjs';
10 import { runListNotes } from '../../lib/list-notes.mjs';
11 import { runSearch } from '../../lib/search.mjs';
12 import { readNote, normalizeSlug } from '../../lib/vault.mjs';
13 import { listNotesForCausalChainId } from '../resources/graph.mjs';
14 import {
15 textContent,
16 embeddedNoteFromPath,
17 embeddedMarkdownResource,
18 snippet,
19 parseIntSafe,
20 formatMemoryEventsAsync,
21 maybeAppendSamplingPrefill,
22 MAX_EMBEDDED_NOTES,
23 MAX_ENTITY_NOTES,
24 PROJECT_SUMMARY_NOTES,
25 CONTENT_PLAN_NOTES,
26 } from './helpers.mjs';
27
28 /** @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server */
29 export function registerKnowtationPrompts(server) {
30 server.registerPrompt(
31 'daily-brief',
32 {
33 title: 'Daily brief',
34 description: 'Notes since a date (default today UTC) with snippets; assistant prefill for summarizing.',
35 argsSchema: {
36 date: z.string().optional().describe('YYYY-MM-DD; default today (UTC)'),
37 project: z.string().optional().describe('Project slug'),
38 },
39 },
40 async (args) => {
41 const config = loadConfig();
42 const since = (args.date && String(args.date).trim()) || new Date().toISOString().slice(0, 10);
43 const out = runListNotes(config, {
44 since,
45 project: args.project || undefined,
46 limit: 80,
47 offset: 0,
48 order: 'date',
49 fields: 'full',
50 });
51 const notes = out.notes || [];
52 const lines = notes.length
53 ? notes.map((n, i) => {
54 const title = n.frontmatter?.title || n.path;
55 const d = n.frontmatter?.date || '';
56 return `${i + 1}. **${title}** (${n.path}, ${d})\n ${snippet(n.body, 240)}`;
57 })
58 : ['(No notes in range.)'];
59 return {
60 description: `Daily brief for notes since ${since}`,
61 messages: [
62 {
63 role: 'user',
64 content: textContent(
65 'You are a personal knowledge assistant. Below are notes captured in the selected range. Summarize themes, decisions, and open threads.'
66 ),
67 },
68 { role: 'user', content: textContent(lines.join('\n\n')) },
69 { role: 'assistant', content: textContent('Here is your daily brief:') },
70 ],
71 };
72 }
73 );
74
75 server.registerPrompt(
76 'search-and-synthesize',
77 {
78 title: 'Search and synthesize',
79 description: 'Semantic search then embed top notes for synthesis.',
80 argsSchema: {
81 query: z.string().describe('Search query'),
82 project: z.string().optional().describe('Project slug'),
83 limit: z.string().optional().describe('Max notes (default 10)'),
84 },
85 },
86 async (args) => {
87 const config = loadConfig();
88 const limit = Math.min(20, Math.max(1, parseIntSafe(args.limit, 10)));
89 const searchOut = await runSearch(String(args.query || ''), {
90 limit,
91 project: args.project || undefined,
92 fields: 'path',
93 });
94 const paths = (searchOut.results || []).map((r) => r.path).filter(Boolean).slice(0, MAX_EMBEDDED_NOTES);
95 const messages = [
96 {
97 role: 'user',
98 content: textContent(
99 `You have ${paths.length} top-matching vault notes below (semantic search for: "${String(args.query)}"). Synthesize key themes, agreements, and gaps. Cite paths when specific.`
100 ),
101 },
102 ];
103 for (const p of paths) {
104 try {
105 messages.push({ role: 'user', content: embeddedNoteFromPath(config, p) });
106 } catch (_) {}
107 }
108 return maybeAppendSamplingPrefill(server, { description: 'Search results embedded as resources', messages });
109 }
110 );
111
112 server.registerPrompt(
113 'project-summary',
114 {
115 title: 'Project summary',
116 description: 'Recent project notes embedded for executive-style summary.',
117 argsSchema: {
118 project: z.string().describe('Project slug'),
119 since: z.string().optional().describe('YYYY-MM-DD'),
120 format: z.enum(['brief', 'detailed', 'stakeholder']).optional().describe('Summary style'),
121 },
122 },
123 async (args) => {
124 const config = loadConfig();
125 const project = normalizeSlug(String(args.project || ''));
126 if (!project) {
127 return {
128 messages: [{ role: 'user', content: textContent('Error: project argument is required.') }],
129 };
130 }
131 const fmt = args.format || 'brief';
132 const out = runListNotes(config, {
133 project,
134 since: args.since || undefined,
135 limit: PROJECT_SUMMARY_NOTES,
136 offset: 0,
137 order: 'date',
138 fields: 'full',
139 });
140 const notes = out.notes || [];
141 const messages = [
142 {
143 role: 'user',
144 content: textContent(
145 `Produce a ${fmt} executive summary for project "${project}" using the embedded notes. Note count (sample): ${notes.length} of ${out.total} total matching filters.`
146 ),
147 },
148 ];
149 for (const n of notes.slice(0, MAX_EMBEDDED_NOTES)) {
150 try {
151 messages.push({ role: 'user', content: embeddedNoteFromPath(config, n.path) });
152 } catch (_) {}
153 }
154 return maybeAppendSamplingPrefill(server, { description: `Project summary (${project})`, messages });
155 }
156 );
157
158 server.registerPrompt(
159 'write-from-capture',
160 {
161 title: 'Write from capture',
162 description: 'Format raw capture text into a proper vault note (optionally with capture template).',
163 argsSchema: {
164 raw_text: z.string().describe('Raw pasted text'),
165 source: z.string().describe('e.g. telegram, whatsapp, email'),
166 project: z.string().optional().describe('Project slug'),
167 },
168 },
169 async (args) => {
170 const config = loadConfig();
171 const raw = String(args.raw_text ?? '');
172 const source = String(args.source ?? 'unknown');
173 const project = args.project ? normalizeSlug(String(args.project)) : null;
174 const tryRel = 'templates/capture.md';
175 const full = path.join(config.vault_path, tryRel);
176 let templateHint = '';
177 const messages = [
178 {
179 role: 'user',
180 content: textContent(
181 `Format the following raw capture into a Knowtation markdown note with YAML frontmatter: title, date (today if missing), source: "${source}", inbox-friendly tags if appropriate${project ? `, project: "${project}"` : ''}. Use clean body markdown.${templateHint}`
182 ),
183 },
184 ];
185 if (fs.existsSync(full) && fs.statSync(full).isFile()) {
186 try {
187 const t = fs.readFileSync(full, 'utf8');
188 templateHint = `\n\nA capture template is attached as an embedded resource (knowtation://vault/${tryRel}).`;
189 messages[0].content = textContent(
190 `Format the following raw capture into a Knowtation markdown note with YAML frontmatter: title, date (today if missing), source: "${source}", inbox-friendly tags if appropriate${project ? `, project: "${project}"` : ''}. Use clean body markdown.${templateHint}`
191 );
192 messages.push({
193 role: 'user',
194 content: embeddedMarkdownResource(`knowtation://vault/${tryRel}`, t),
195 });
196 } catch (_) {}
197 }
198 messages.push({ role: 'user', content: textContent(`--- Raw capture ---\n${raw.slice(0, 50000)}`) });
199 return { description: 'Capture → vault note', messages };
200 }
201 );
202
203 server.registerPrompt(
204 'temporal-summary',
205 {
206 title: 'Temporal summary',
207 description: 'Notes between two dates; optional semantic topic filter.',
208 argsSchema: {
209 since: z.string().describe('YYYY-MM-DD start'),
210 until: z.string().describe('YYYY-MM-DD end'),
211 topic: z.string().optional().describe('Optional semantic filter; runs search then intersects dates'),
212 project: z.string().optional().describe('Project slug'),
213 },
214 },
215 async (args) => {
216 const config = loadConfig();
217 const since = String(args.since || '').slice(0, 10);
218 const until = String(args.until || '').slice(0, 10);
219 let pathSet = null;
220 if (args.topic && String(args.topic).trim()) {
221 const so = await runSearch(String(args.topic), {
222 limit: 80,
223 project: args.project || undefined,
224 fields: 'path',
225 });
226 pathSet = new Set((so.results || []).map((r) => r.path).filter(Boolean));
227 }
228 const out = runListNotes(config, {
229 since,
230 until,
231 project: args.project || undefined,
232 limit: 100,
233 offset: 0,
234 order: 'date-asc',
235 fields: 'path+metadata',
236 });
237 let notes = out.notes || [];
238 if (pathSet) {
239 notes = notes.filter((n) => pathSet.has(n.path));
240 }
241 const lines = notes.map(
242 (n, i) =>
243 `${i + 1}. ${n.title || n.path} (${n.path}, ${n.date || ''})${n.tags?.length ? ` tags: ${n.tags.join(',')}` : ''}`
244 );
245 return {
246 description: `Temporal view ${since} … ${until}`,
247 messages: [
248 {
249 role: 'user',
250 content: textContent(
251 `What happened between ${since} and ${until}? What decisions were made? What changed? Use the note list below${args.topic ? ` (filtered by topic search)` : ''}.\n\n${lines.join('\n') || '(No notes in range.)'}`
252 ),
253 },
254 ],
255 };
256 }
257 );
258
259 server.registerPrompt(
260 'extract-entities',
261 {
262 title: 'Extract entities',
263 description: 'Structured JSON extraction prompt over vault notes in scope.',
264 argsSchema: {
265 folder: z.string().optional(),
266 project: z.string().optional(),
267 entity_types: z.enum(['people', 'places', 'decisions', 'goals', 'all']).optional(),
268 },
269 },
270 async (args) => {
271 const config = loadConfig();
272 const types = args.entity_types || 'all';
273 const out = runListNotes(config, {
274 folder: args.folder || undefined,
275 project: args.project || undefined,
276 limit: MAX_ENTITY_NOTES,
277 offset: 0,
278 fields: 'full',
279 });
280 const notes = out.notes || [];
281 const messages = [
282 {
283 role: 'user',
284 content: textContent(
285 `Extract entities from the embedded notes. Output a single JSON object: { "people": [], "places": [], "decisions": [], "goals": [] } with short strings. Entity focus: ${types}. If a category is empty, use [].`
286 ),
287 },
288 ];
289 for (const n of notes.slice(0, MAX_EMBEDDED_NOTES)) {
290 try {
291 messages.push({ role: 'user', content: embeddedNoteFromPath(config, n.path) });
292 } catch (_) {}
293 }
294 return { description: 'Entity extraction', messages };
295 }
296 );
297
298 server.registerPrompt(
299 'meeting-notes',
300 {
301 title: 'Meeting notes',
302 description: 'Transcript → structured meeting note instructions.',
303 argsSchema: {
304 transcript: z.string().describe('Raw transcript'),
305 attendees: z.string().optional().describe('Comma-separated names'),
306 project: z.string().optional(),
307 date: z.string().optional().describe('YYYY-MM-DD'),
308 },
309 },
310 async (args) => {
311 const attendees = String(args.attendees || '')
312 .split(',')
313 .map((s) => s.trim())
314 .filter(Boolean);
315 const project = args.project ? normalizeSlug(String(args.project)) : null;
316 const date = args.date || new Date().toISOString().slice(0, 10);
317 const t = String(args.transcript || '').slice(0, 100_000);
318 const suggestedPath = project
319 ? `projects/${project}/inbox/meeting-${date}.md`
320 : `inbox/meeting-${date}.md`;
321 return {
322 description: 'Meeting note draft prompt',
323 messages: [
324 {
325 role: 'user',
326 content: textContent(
327 `Convert the transcript into a vault meeting note with YAML frontmatter: title, date: ${date}, attendees: [${attendees.map((a) => `"${a}"`).join(', ')}]${project ? `, project: "${project}"` : ''}, tags. Body: agenda summary, decisions, action items (owners), follow-ups. Suggested path for write tool: ${suggestedPath}`
328 ),
329 },
330 { role: 'user', content: textContent(`--- Transcript ---\n${t}`) },
331 ],
332 };
333 }
334 );
335
336 server.registerPrompt(
337 'knowledge-gap',
338 {
339 title: 'Knowledge gap',
340 description: 'Given search hits, ask what is missing and what to capture next.',
341 argsSchema: {
342 query: z.string().describe('Topic / question'),
343 project: z.string().optional(),
344 },
345 },
346 async (args) => {
347 const config = loadConfig();
348 const so = await runSearch(String(args.query || ''), {
349 limit: 15,
350 project: args.project || undefined,
351 fields: 'path+snippet',
352 });
353 const lines = (so.results || []).map(
354 (r, i) => `${i + 1}. ${r.path}${r.snippet ? `\n ${snippet(r.snippet, 200)}` : ''}`
355 );
356 return maybeAppendSamplingPrefill(server, {
357 description: 'Knowledge gap analysis',
358 messages: [
359 {
360 role: 'user',
361 content: textContent(
362 `Given these vault search results for "${String(args.query)}", what is missing? What questions remain unanswered? What should I capture next?\n\n${lines.join('\n\n') || '(No results.)'}`
363 ),
364 },
365 ],
366 });
367 }
368 );
369
370 server.registerPrompt(
371 'causal-chain',
372 {
373 title: 'Causal chain',
374 description: 'Notes sharing causal_chain_id, embedded in chronological order.',
375 argsSchema: {
376 chain_id: z.string().describe('Causal chain id / slug'),
377 include_summaries: z.string().optional().describe('true to emphasize summaries edges'),
378 },
379 },
380 async (args) => {
381 const config = loadConfig();
382 const notes = listNotesForCausalChainId(config, String(args.chain_id || ''));
383 const inc = String(args.include_summaries || '').toLowerCase() === 'true';
384 const messages = [
385 {
386 role: 'user',
387 content: textContent(
388 `Narrate the causal sequence for chain "${String(args.chain_id)}". Use follows / summarizes in frontmatter where present.${inc ? ' Pay special attention to summarization relationships.' : ''}`
389 ),
390 },
391 ];
392 for (const n of notes.slice(0, MAX_EMBEDDED_NOTES)) {
393 try {
394 messages.push({ role: 'user', content: embeddedNoteFromPath(config, n.path) });
395 } catch (_) {}
396 }
397 if (notes.length === 0) {
398 messages.push({
399 role: 'user',
400 content: textContent('(No notes found for this causal_chain_id.)'),
401 });
402 }
403 return { description: `Causal chain ${args.chain_id}`, messages };
404 }
405 );
406
407 server.registerPrompt(
408 'content-plan',
409 {
410 title: 'Content plan',
411 description: 'Content calendar / plan from recent project notes.',
412 argsSchema: {
413 project: z.string().describe('Project slug'),
414 format: z.enum(['blog', 'podcast', 'newsletter', 'thread']).optional(),
415 tone: z.string().optional(),
416 },
417 },
418 async (args) => {
419 const config = loadConfig();
420 const project = normalizeSlug(String(args.project || ''));
421 const fmt = args.format || 'blog';
422 const tone = args.tone || 'clear, authoritative';
423 const out = runListNotes(config, {
424 project,
425 limit: CONTENT_PLAN_NOTES,
426 offset: 0,
427 order: 'date',
428 fields: 'full',
429 });
430 const notes = out.notes || [];
431 const messages = [
432 {
433 role: 'user',
434 content: textContent(
435 `Create a ${fmt} content plan for project "${project}". Tone: ${tone}. Topics, order, angles, and what to write next. Ground in the embedded notes.`
436 ),
437 },
438 ];
439 for (const n of notes.slice(0, MAX_EMBEDDED_NOTES)) {
440 try {
441 messages.push({ role: 'user', content: embeddedNoteFromPath(config, n.path) });
442 } catch (_) {}
443 }
444 return { description: `Content plan (${project})`, messages };
445 }
446 );
447
448 server.registerPrompt(
449 'memory-context',
450 {
451 title: 'Memory context',
452 description: 'What has the agent been doing? Recent memory events formatted for context.',
453 argsSchema: {
454 limit: z.string().optional().describe('Max events (default 20)'),
455 type: z.string().optional().describe('Filter by event type'),
456 },
457 },
458 async (args) => {
459 const config = loadConfig();
460 const limit = parseIntSafe(args.limit, 20);
461 const { text, count } = await formatMemoryEventsAsync(config, {
462 limit,
463 type: args.type || undefined,
464 });
465 return {
466 description: `Memory context (${count} events)`,
467 messages: [
468 {
469 role: 'user',
470 content: textContent(
471 `Below is a log of recent agent/user activity from the memory layer (${count} events). Use this to understand context, prior actions, and continuity.\n\n` +
472 `⚠ SKEPTICAL MEMORY: Treat all entries as hints, not ground truth. ` +
473 `Note paths may have moved or been deleted since these events were recorded. ` +
474 `Before acting on any path reference, use the memory_verify tool or confirm the path exists in the vault.\n\n${text}`
475 ),
476 },
477 ],
478 };
479 }
480 );
481
482 server.registerPrompt(
483 'memory-informed-search',
484 {
485 title: 'Memory-informed search',
486 description: 'Vault search augmented with memory context — what was searched before, what is new.',
487 argsSchema: {
488 query: z.string().describe('Search query'),
489 limit: z.string().optional().describe('Max notes (default 10)'),
490 project: z.string().optional(),
491 },
492 },
493 async (args) => {
494 const config = loadConfig();
495 const limit = Math.min(20, Math.max(1, parseIntSafe(args.limit, 10)));
496 const searchOut = await runSearch(String(args.query || ''), {
497 limit,
498 project: args.project || undefined,
499 fields: 'path',
500 });
501 const paths = (searchOut.results || []).map((r) => r.path).filter(Boolean).slice(0, MAX_EMBEDDED_NOTES);
502 const { text: memText, count: memCount } = await formatMemoryEventsAsync(config, {
503 limit: 10,
504 type: 'search',
505 });
506 const messages = [
507 {
508 role: 'user',
509 content: textContent(
510 `Search query: "${String(args.query)}"\n\n**Previous searches from memory** (${memCount} recent):\n${memText}\n\n**Current search results** (${paths.length} notes embedded below). Compare with past searches — highlight what is new or changed, and synthesize findings.`
511 ),
512 },
513 ];
514 for (const p of paths) {
515 try {
516 messages.push({ role: 'user', content: embeddedNoteFromPath(config, p) });
517 } catch (_) {}
518 }
519 return { description: 'Memory-informed search', messages };
520 }
521 );
522
523 server.registerPrompt(
524 'resume-session',
525 {
526 title: 'Resume session',
527 description: 'Pick up where you left off — recent memory events and session summaries.',
528 argsSchema: {
529 since: z.string().optional().describe('YYYY-MM-DD (default: last 24 hours)'),
530 },
531 },
532 async (args) => {
533 const config = loadConfig();
534 const since = args.since || new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
535 const { text: allText, count: allCount } = await formatMemoryEventsAsync(config, {
536 limit: 30,
537 since,
538 });
539 const { text: summaryText, count: summaryCount } = await formatMemoryEventsAsync(config, {
540 limit: 5,
541 type: 'session_summary',
542 since,
543 });
544 const parts = [];
545 if (summaryCount > 0) {
546 parts.push(`**Session summaries** (${summaryCount}):\n${summaryText}`);
547 }
548 parts.push(`**Recent activity** (${allCount} events since ${since}):\n${allText}`);
549 return {
550 description: `Resume session (since ${since})`,
551 messages: [
552 {
553 role: 'user',
554 content: textContent(
555 `Help me pick up where I left off. Below is my recent activity log and any session summaries. Summarize what was happening, what was accomplished, and suggest next steps.\n\n` +
556 `⚠ SKEPTICAL MEMORY: Treat all memory entries as hints, not ground truth. ` +
557 `Vault paths referenced in past events may have moved or been deleted. ` +
558 `Use memory_verify to confirm path references before acting, and check the vault directly for current state.\n\n` +
559 `${parts.join('\n\n')}`
560 ),
561 },
562 ],
563 };
564 }
565 );
566 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago