memory.mjs
463 lines 17.5 KB
Raw
sha256:fd47ab66017e55331b88ba3a59c34c23e4e05c5aec424251d3a404c5a7998c8e feat(hub): restore integration tile detail modals; add Herm… Human minor ⚠ breaking 15 days ago
1 /**
2 * MCP memory tools: query, store, list, search, clear, summarize.
3 * Phase 8 Memory Augmentation.
4 */
5
6 import { z } from 'zod';
7 import fs from 'fs';
8 import path from 'path';
9 import yaml from 'js-yaml';
10 import { loadConfig } from '../../lib/config.mjs';
11 import { createMemoryManager, verifyMemoryEvent } from '../../lib/memory.mjs';
12 import { MEMORY_EVENT_TYPES } from '../../lib/memory-event.mjs';
13
14 function jsonResponse(obj) {
15 return { content: [{ type: 'text', text: JSON.stringify(obj) }] };
16 }
17
18 function jsonError(msg, code = 'ERROR') {
19 return { content: [{ type: 'text', text: JSON.stringify({ error: msg, code }) }], isError: true };
20 }
21
22 const SHELL_META_RE = /[/\\;|&$`(){}<>!#]/;
23 const INTERVAL_BOUNDS = { min: 1, max: 43200 };
24
25 function resolveConfigPath() {
26 return process.env.KNOWTATION_CONFIG ?? path.join(process.cwd(), 'config', 'local.yaml');
27 }
28
29 /**
30 * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
31 * @param {object} [opts] — injectable dependencies for testing
32 */
33 export function registerMemoryTools(server, opts = {}) {
34 server.registerTool(
35 'memory_query',
36 {
37 description: 'Read the latest value for a memory event type (e.g. search, export, write, import, index, propose, user).',
38 inputSchema: {
39 key: z.string().describe('Memory event type to query (e.g. search, export, write, user)'),
40 },
41 },
42 async (args) => {
43 try {
44 const config = loadConfig();
45 if (!config.memory?.enabled) {
46 return jsonResponse({ key: args.key, value: null, enabled: false });
47 }
48 const mm = createMemoryManager(config);
49 const event = mm.getLatest(args.key);
50 if (!event) return jsonResponse({ key: args.key, value: null, updated_at: null });
51 return jsonResponse({ key: args.key, value: event.data, updated_at: event.ts, id: event.id });
52 } catch (e) {
53 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
54 }
55 }
56 );
57
58 server.registerTool(
59 'memory_store',
60 {
61 description: 'Store a value in memory for agent write-back or user-defined context. Type defaults to "user".',
62 inputSchema: {
63 key: z.string().describe('Descriptive key for this memory entry'),
64 value: z.record(z.unknown()).describe('JSON object to store'),
65 ttl: z.string().optional().describe('Optional TTL (ISO 8601 duration, e.g. P7D for 7 days)'),
66 },
67 },
68 async (args) => {
69 try {
70 const config = loadConfig();
71 if (!config.memory?.enabled) {
72 return jsonError('Memory layer not enabled. Set memory.enabled in config.', 'DISABLED');
73 }
74 const mm = createMemoryManager(config);
75 const data = { key: args.key, ...args.value };
76 const result = mm.store('user', data, { ttl: args.ttl });
77 return jsonResponse(result);
78 } catch (e) {
79 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
80 }
81 }
82 );
83
84 server.registerTool(
85 'memory_list',
86 {
87 description: 'List recent memory events with optional filters. Use topic to filter by topic slug (e.g. "blockchain", "vault").',
88 inputSchema: {
89 type: z.string().optional().describe('Filter by event type'),
90 topic: z.string().optional().describe('Filter by topic slug (derived from event data)'),
91 since: z.string().optional().describe('ISO date lower bound'),
92 until: z.string().optional().describe('ISO date upper bound'),
93 limit: z.number().optional().describe('Max events (default 20, max 100)'),
94 },
95 },
96 async (args) => {
97 try {
98 const config = loadConfig();
99 if (!config.memory?.enabled) {
100 return jsonResponse({ events: [], count: 0, enabled: false });
101 }
102 const mm = createMemoryManager(config);
103 const events = mm.list({
104 type: args.type,
105 topic: args.topic,
106 since: args.since,
107 until: args.until,
108 limit: Math.min(args.limit ?? 20, 100),
109 });
110 return jsonResponse({ events, count: events.length });
111 } catch (e) {
112 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
113 }
114 }
115 );
116
117 server.registerTool(
118 'memory_search',
119 {
120 description: 'Semantic search over memory entries. Requires memory.provider: vector or mem0.',
121 inputSchema: {
122 query: z.string().describe('Search query'),
123 limit: z.number().optional().describe('Max results (default 10)'),
124 },
125 },
126 async (args) => {
127 try {
128 const config = loadConfig();
129 if (!config.memory?.enabled) {
130 return jsonError('Memory layer not enabled.', 'DISABLED');
131 }
132 const mm = createMemoryManager(config);
133 if (!mm.supportsSearch()) {
134 return jsonError('Semantic memory search requires memory.provider: vector or mem0.', 'UNSUPPORTED');
135 }
136 const results = mm.search(args.query, { limit: args.limit ?? 10 });
137 return jsonResponse({ results, count: results.length });
138 } catch (e) {
139 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
140 }
141 }
142 );
143
144 server.registerTool(
145 'memory_clear',
146 {
147 description: 'Clear memory events. Requires confirm: true.',
148 inputSchema: {
149 type: z.string().optional().describe('Only clear events of this type'),
150 before: z.string().optional().describe('Only clear events before this ISO date'),
151 confirm: z.boolean().describe('Must be true to proceed'),
152 },
153 },
154 async (args) => {
155 try {
156 if (!args.confirm) {
157 return jsonError('Set confirm: true to clear memory.', 'CONFIRMATION_REQUIRED');
158 }
159 const config = loadConfig();
160 if (!config.memory?.enabled) {
161 return jsonError('Memory layer not enabled.', 'DISABLED');
162 }
163 const mm = createMemoryManager(config);
164 const result = mm.clear({ type: args.type, before: args.before });
165 return jsonResponse(result);
166 } catch (e) {
167 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
168 }
169 }
170 );
171
172 server.registerTool(
173 'memory_verify',
174 {
175 description:
176 'Verify one or more memory events against the current vault state. Returns a confidence level for each: ' +
177 '"verified" (path exists, unchanged), "stale" (path gone or modified after event), or ' +
178 '"hint" (no verifiable reference — treat as context only). ' +
179 'ALWAYS call this before acting on memory that references vault paths.',
180 inputSchema: {
181 event_ids: z
182 .array(z.string())
183 .optional()
184 .describe('List of memory event IDs (mem_*) to verify. Omit to verify all recent events.'),
185 type: z.string().optional().describe('Verify only events of this type (e.g. write, export)'),
186 limit: z.number().optional().describe('Max events to verify when no event_ids given (default 20)'),
187 },
188 },
189 async (args) => {
190 try {
191 const config = loadConfig();
192 if (!config.memory?.enabled) {
193 return jsonError('Memory layer not enabled.', 'DISABLED');
194 }
195 const mm = createMemoryManager(config);
196
197 let events;
198 if (args.event_ids && args.event_ids.length > 0) {
199 const allRecent = mm.list({ limit: 500 });
200 const idSet = new Set(args.event_ids);
201 events = allRecent.filter((e) => idSet.has(e.id));
202 } else {
203 events = mm.list({ type: args.type, limit: Math.min(args.limit ?? 20, 100) });
204 }
205
206 const results = events.map((event) => {
207 const { confidence, reason } = verifyMemoryEvent(config, event);
208 return {
209 id: event.id,
210 type: event.type,
211 ts: event.ts,
212 confidence,
213 reason,
214 data_summary: JSON.stringify(event.data).slice(0, 120),
215 };
216 });
217
218 const counts = { verified: 0, hint: 0, stale: 0 };
219 for (const r of results) counts[r.confidence] = (counts[r.confidence] || 0) + 1;
220
221 return jsonResponse({
222 results,
223 summary: counts,
224 total: results.length,
225 note: 'Treat memory as hints. Stale entries may reference moved or deleted notes. Verify against the vault before taking action.',
226 });
227 } catch (e) {
228 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
229 }
230 }
231 );
232
233 server.registerTool(
234 'memory_consolidate',
235 {
236 description:
237 'Trigger LLM-powered memory consolidation: group recent events by topic, merge/deduplicate via LLM, ' +
238 'and store concise fact summaries as consolidation events. Optionally runs the stale reference ' +
239 'detection pass (verify). Rebuilds the pointer index afterward. ' +
240 'Routes to the Hub when KNOWTATION_HUB_URL is set.',
241 inputSchema: {
242 dry_run: z.boolean().optional().describe('If true, preview what would happen without writing events (default false)'),
243 passes: z
244 .array(z.string())
245 .optional()
246 .describe('Pass names to run, e.g. ["consolidate", "verify"]. Default: all enabled passes from daemon config.'),
247 lookback_hours: z.number().optional().describe('How far back to read events (default: daemon config or 24h)'),
248 },
249 },
250 async (args) => {
251 try {
252 const config = (opts.loadConfig ?? loadConfig)();
253 if (!config.memory?.enabled) {
254 return jsonError('Memory layer not enabled. Set memory.enabled in config.', 'DISABLED');
255 }
256
257 const hubUrl =
258 (process.env.KNOWTATION_HUB_URL || '').trim().replace(/\/+$/, '') ||
259 (typeof config.hub_url === 'string' && config.hub_url.trim()
260 ? config.hub_url.trim().replace(/\/+$/, '')
261 : '');
262
263 if (hubUrl) {
264 const token = (process.env.KNOWTATION_HUB_TOKEN || '').trim();
265 if (!token) {
266 return jsonError(
267 'KNOWTATION_HUB_TOKEN is not set. Set this environment variable to authenticate with the Hub.',
268 'HUB_TOKEN_REQUIRED',
269 );
270 }
271 const fetchImpl = opts.fetchFn ?? globalThis.fetch;
272 const body = {};
273 if (args.dry_run != null) body.dry_run = args.dry_run;
274 if (args.passes != null) body.passes = args.passes;
275 if (args.lookback_hours != null) body.lookback_hours = args.lookback_hours;
276
277 const res = await fetchImpl(`${hubUrl}/api/v1/memory/consolidate`, {
278 method: 'POST',
279 headers: {
280 'Content-Type': 'application/json',
281 Authorization: `Bearer ${token}`,
282 },
283 body: JSON.stringify(body),
284 });
285
286 const text = await res.text();
287 let data;
288 try { data = text ? JSON.parse(text) : null; } catch { data = { error: text.slice(0, 200) }; }
289
290 if (!res.ok) {
291 const msg = data?.error || res.statusText || 'Hub request failed';
292 return jsonError(msg, data?.code || 'HUB_ERROR');
293 }
294 return jsonResponse(data);
295 }
296
297 const _consolidateMemory = opts.consolidateMemory
298 ?? (await import('../../lib/memory-consolidate.mjs')).consolidateMemory;
299 const result = await _consolidateMemory(config, {
300 dryRun: args.dry_run,
301 passes: args.passes,
302 lookbackHours: args.lookback_hours,
303 });
304 return jsonResponse(result);
305 } catch (e) {
306 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
307 }
308 }
309 );
310
311 server.registerTool(
312 'daemon_status',
313 {
314 description:
315 'Return the background consolidation daemon status: running state, PID, ' +
316 'last pass time and statistics, next scheduled pass time, and events processed count. ' +
317 'Use before calling daemon start/stop to check current state.',
318 inputSchema: {},
319 },
320 async () => {
321 try {
322 const config = loadConfig();
323 const { getDaemonStatus } = await import('../../lib/daemon.mjs');
324 const status = getDaemonStatus(config);
325 return jsonResponse(status);
326 } catch (e) {
327 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
328 }
329 }
330 );
331
332 server.registerTool(
333 'memory_summarize',
334 {
335 description: 'Generate an LLM-powered summary of recent session activity and store it as a session_summary event.',
336 inputSchema: {
337 since: z.string().optional().describe('ISO date lower bound (default: last 24 hours)'),
338 max_tokens: z.number().optional().describe('Max LLM output tokens (default 512)'),
339 dry_run: z.boolean().optional().describe('If true, returns summary without storing'),
340 },
341 },
342 async (args) => {
343 try {
344 const config = loadConfig();
345 if (!config.memory?.enabled) {
346 return jsonError('Memory layer not enabled.', 'DISABLED');
347 }
348 const { generateSessionSummary } = await import('../../lib/memory-session-summary.mjs');
349 const result = await generateSessionSummary(config, {
350 since: args.since,
351 maxTokens: args.max_tokens,
352 dryRun: args.dry_run,
353 });
354 return jsonResponse(result);
355 } catch (e) {
356 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
357 }
358 }
359 );
360
361 server.registerTool(
362 'consolidation_history',
363 {
364 description:
365 'Return the last N consolidation pass records stored in memory. Each record contains ' +
366 'topics processed, events merged, cost, and pass timestamp.',
367 inputSchema: {
368 limit: z.number().optional().describe('Max records to return (default 20)'),
369 },
370 },
371 async (args) => {
372 try {
373 const config = (opts.loadConfig ?? loadConfig)();
374 if (!config.memory?.enabled) {
375 return jsonResponse({ history: [], count: 0, enabled: false });
376 }
377 const mm = (opts.createMemoryManager ?? createMemoryManager)(config);
378 const history = mm.list({ type: 'consolidation', limit: args.limit ?? 20 });
379 return jsonResponse({ history, count: history.length });
380 } catch (e) {
381 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
382 }
383 }
384 );
385
386 server.registerTool(
387 'consolidation_settings',
388 {
389 description:
390 'Read or write the daemon consolidation settings in config/local.yaml. ' +
391 'When called with no arguments, returns the current daemon.* settings. ' +
392 'When called with update fields, writes them back.',
393 inputSchema: {
394 enabled: z.boolean().optional().describe('daemon.enabled'),
395 interval_minutes: z.number().optional().describe('daemon.interval_minutes'),
396 idle_only: z.boolean().optional().describe('daemon.idle_only'),
397 idle_threshold_minutes: z.number().optional().describe('daemon.idle_threshold_minutes'),
398 run_on_start: z.boolean().optional().describe('daemon.run_on_start'),
399 max_cost_per_day_usd: z.number().optional().describe('daemon.max_cost_per_day_usd'),
400 llm_model: z.string().optional().describe('daemon.llm.model'),
401 },
402 },
403 async (args) => {
404 try {
405 const hasUpdate = [
406 args.enabled, args.interval_minutes, args.idle_only,
407 args.idle_threshold_minutes, args.run_on_start,
408 args.max_cost_per_day_usd, args.llm_model,
409 ].some((v) => v !== undefined);
410
411 if (!hasUpdate) {
412 const config = (opts.loadConfig ?? loadConfig)();
413 return jsonResponse({ daemon: config.daemon ?? {} });
414 }
415
416 if (args.interval_minutes != null && (args.interval_minutes < INTERVAL_BOUNDS.min || args.interval_minutes > INTERVAL_BOUNDS.max)) {
417 return jsonError(
418 `interval_minutes must be between ${INTERVAL_BOUNDS.min} and ${INTERVAL_BOUNDS.max}.`,
419 'VALIDATION_ERROR',
420 );
421 }
422 if (args.llm_model != null && SHELL_META_RE.test(args.llm_model)) {
423 return jsonError(
424 'llm_model contains invalid characters (path separators or shell metacharacters).',
425 'VALIDATION_ERROR',
426 );
427 }
428
429 const _existsSync = opts.fs?.existsSync ?? fs.existsSync;
430 const _readFileSync = opts.fs?.readFileSync ?? fs.readFileSync;
431 const _writeFileSync = opts.fs?.writeFileSync ?? fs.writeFileSync;
432 const _mkdirSync = opts.fs?.mkdirSync ?? fs.mkdirSync;
433 const configFilePath = (opts.resolveConfigPath ?? resolveConfigPath)();
434
435 let doc = {};
436 if (_existsSync(configFilePath)) {
437 const raw = _readFileSync(configFilePath, 'utf8');
438 doc = yaml.load(raw) || {};
439 }
440
441 if (!doc.daemon) doc.daemon = {};
442 if (args.enabled !== undefined) doc.daemon.enabled = args.enabled;
443 if (args.interval_minutes !== undefined) doc.daemon.interval_minutes = args.interval_minutes;
444 if (args.idle_only !== undefined) doc.daemon.idle_only = args.idle_only;
445 if (args.idle_threshold_minutes !== undefined) doc.daemon.idle_threshold_minutes = args.idle_threshold_minutes;
446 if (args.run_on_start !== undefined) doc.daemon.run_on_start = args.run_on_start;
447 if (args.max_cost_per_day_usd !== undefined) doc.daemon.max_cost_per_day_usd = args.max_cost_per_day_usd;
448 if (args.llm_model !== undefined) {
449 if (!doc.daemon.llm) doc.daemon.llm = {};
450 doc.daemon.llm.model = args.llm_model;
451 }
452
453 const dir = path.dirname(configFilePath);
454 if (!_existsSync(dir)) _mkdirSync(dir, { recursive: true });
455 _writeFileSync(configFilePath, yaml.dump(doc), 'utf8');
456
457 return jsonResponse({ ok: true, daemon: doc.daemon });
458 } catch (e) {
459 return jsonError(e.message || String(e), 'RUNTIME_ERROR');
460 }
461 }
462 );
463 }
File History 3 commits
sha256:fd47ab66017e55331b88ba3a59c34c23e4e05c5aec424251d3a404c5a7998c8e feat(hub): restore integration tile detail modals; add Herm… Human minor 15 days ago
sha256:2827ba9e7632a4b141c50caf1e8f7d77abbc3515be20e7465f2bccb0ac4edf91 fix: repair endpoint now sets has_active_subscription when … Human minor 16 days ago