memory.mjs
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
sha256:6a102aafafdfe7e70a24f4e59740200f0ee713ce7915f1b53e9d4ba5ee8b4410
Initial Muse snapshot
Human
48 days ago