enrich.mjs
130 lines 5.2 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Issue #1 Phase F2 — enrich tool: auto-tag, categorize, and title via sampling (or server LLM).
3 */
4
5 import { z } from 'zod';
6 import { loadConfig } from '../../lib/config.mjs';
7 import { readNote, resolveVaultRelativePath } from '../../lib/vault.mjs';
8 import { writeNote } from '../../lib/write.mjs';
9 import { completeChat } from '../../lib/llm-complete.mjs';
10 import { trySamplingJson } from '../sampling.mjs';
11
12 function jsonResponse(obj) {
13 return { content: [{ type: 'text', text: JSON.stringify(obj) }] };
14 }
15
16 function jsonError(msg, code = 'ERROR') {
17 return { content: [{ type: 'text', text: JSON.stringify({ error: msg, code }) }], isError: true };
18 }
19
20 const ENRICH_SYSTEM = `You are a knowledge management assistant. Given a note's content, suggest metadata.
21 Return ONLY a JSON object with these fields:
22 - "title": a concise descriptive title (string)
23 - "project": a lowercase-kebab-case project slug, or null if unclear (string|null)
24 - "tags": up to 5 relevant tags as an array of lowercase strings (string[])
25
26 Base suggestions on the actual content. Do not invent information not present in the note.`;
27
28 /**
29 * Parse the LLM response (JSON) into a normalized suggestions object.
30 * @param {string} raw
31 * @returns {{ title: string|null, project: string|null, tags: string[] }}
32 */
33 function parseEnrichResponse(raw) {
34 const fallback = { title: null, project: null, tags: [] };
35 if (!raw) return fallback;
36 try {
37 const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
38 const obj = JSON.parse(cleaned);
39 return {
40 title: typeof obj.title === 'string' && obj.title.trim() ? obj.title.trim() : null,
41 project: typeof obj.project === 'string' && obj.project.trim() ? obj.project.trim().toLowerCase().replace(/\s+/g, '-') : null,
42 tags: Array.isArray(obj.tags)
43 ? obj.tags.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim().toLowerCase()).slice(0, 10)
44 : [],
45 };
46 } catch (_) {
47 return fallback;
48 }
49 }
50
51 /**
52 * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
53 */
54 export function registerEnrichTool(server) {
55 server.registerTool(
56 'enrich',
57 {
58 description:
59 'Auto-categorize a note: suggest project, tags, and title via sampling (client LLM) or server LLM. Optionally apply suggestions to frontmatter.',
60 inputSchema: {
61 path: z.string().describe('Vault-relative note path'),
62 apply: z.boolean().optional().describe('Write suggestions to frontmatter (default false, dry-run)'),
63 },
64 },
65 async (args) => {
66 try {
67 const config = loadConfig();
68 resolveVaultRelativePath(config.vault_path, args.path);
69 const note = readNote(config.vault_path, args.path);
70 const body = (note.body || '').slice(0, 32000);
71 const existingFm = note.frontmatter || {};
72 const userPrompt = `Enrich the following note. Existing frontmatter: ${JSON.stringify(existingFm)}\n\n---\n${body}`;
73
74 const samplingResult = await trySamplingJson(server, {
75 system: ENRICH_SYSTEM,
76 user: userPrompt,
77 maxTokens: 512,
78 });
79
80 let suggestions;
81 if (samplingResult) {
82 suggestions = {
83 title: typeof samplingResult.title === 'string' && samplingResult.title.trim() ? samplingResult.title.trim() : null,
84 project: typeof samplingResult.project === 'string' && samplingResult.project.trim()
85 ? samplingResult.project.trim().toLowerCase().replace(/\s+/g, '-') : null,
86 tags: Array.isArray(samplingResult.tags)
87 ? samplingResult.tags.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim().toLowerCase()).slice(0, 10)
88 : [],
89 };
90 } else {
91 const raw = await completeChat(config, {
92 system: ENRICH_SYSTEM + '\n\nRespond ONLY with valid JSON. No markdown fences, no explanation.',
93 user: userPrompt,
94 maxTokens: 512,
95 });
96 suggestions = parseEnrichResponse(raw);
97 }
98
99 let applied = false;
100 if (args.apply) {
101 const fm = {};
102 if (suggestions.title && !existingFm.title) fm.title = suggestions.title;
103 if (suggestions.project && !existingFm.project) fm.project = suggestions.project;
104 if (suggestions.tags.length > 0) {
105 const existingTags = typeof existingFm.tags === 'string'
106 ? existingFm.tags.split(',').map((t) => t.trim().toLowerCase())
107 : Array.isArray(existingFm.tags) ? existingFm.tags.map((t) => String(t).trim().toLowerCase()) : [];
108 const merged = [...new Set([...existingTags, ...suggestions.tags])];
109 fm.tags = merged.join(', ');
110 }
111 if (Object.keys(fm).length > 0) {
112 writeNote(config.vault_path, args.path, { frontmatter: fm });
113 applied = true;
114 }
115 }
116
117 return jsonResponse({
118 path: args.path.replace(/\\/g, '/'),
119 suggestions,
120 applied,
121 source: samplingResult ? 'sampling' : 'server-llm',
122 });
123 } catch (e) {
124 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
125 }
126 }
127 );
128 }
129
130 export { parseEnrichResponse, ENRICH_SYSTEM };
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