@onenomad/engram-mcp 1.0.0-beta.13 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +685 -691
- package/dist/cli.js +41 -41
- package/dist/governance.js +6 -6
- package/dist/handoff.d.ts +53 -48
- package/dist/handoff.js +156 -134
- package/dist/handoff.js.map +1 -1
- package/dist/migrate.js +5 -5
- package/dist/server.js +946 -927
- package/dist/server.js.map +1 -1
- package/dist/storage-postgres.js +61 -61
- package/migrations/postgres/001_init.sql +70 -70
- package/migrations/postgres/002_indexes.sql +45 -45
- package/package.json +69 -69
package/dist/server.js
CHANGED
|
@@ -1,928 +1,947 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
-
import { z } from 'zod';
|
|
5
|
-
import { Storage } from './storage.js';
|
|
6
|
-
import { buildUpdateMetadataPatch, } from './update-metadata.js';
|
|
7
|
-
import { loadConfig } from './config.js';
|
|
8
|
-
import { isLlmAvailable } from './llm.js';
|
|
9
|
-
import { search, selectRelevant, formatRecalledMemories } from './search.js';
|
|
10
|
-
import { graphAwareRerank, graphAwareRerankPPR } from './graph-rerank.js';
|
|
11
|
-
import { extractFromConversation } from './extractor.js';
|
|
12
|
-
import { consolidate } from './consolidator.js';
|
|
13
|
-
import { extractRules, formatRulesForPrompt } from './procedural.js';
|
|
14
|
-
import { recordRecallOutcome } from './outcome.js';
|
|
15
|
-
import { mem0Extract } from './mem0.js';
|
|
16
|
-
import { ingest } from './wal.js';
|
|
17
|
-
import { readSessionState, updateSessionState, appendToSessionState, clearSessionState, } from './session-state.js';
|
|
18
|
-
import { addTriple, replaceTriple, queryGraph, getTimeline, invalidateTriple, getGraphStats, } from './knowledge-graph.js';
|
|
19
|
-
import { writeDiaryEntry, readDiary, listDiaryDates } from './diary.js';
|
|
20
|
-
import { importConversation } from './importer.js';
|
|
21
|
-
import { runGovernanceCheck, detectContradictions } from './governance.js';
|
|
22
|
-
import { syncBridge, loadBridgeFile } from './procedural-bridge.js';
|
|
23
|
-
import { writeHandoff, readHandoff, listHandoffs } from './handoff.js';
|
|
24
|
-
import { assessPressure } from './context-pressure.js';
|
|
25
|
-
import { listRecentTraces, gcOldTraces } from './retrieval-trace.js';
|
|
26
|
-
// ── Config & Storage ────────────────────────────────────────────────
|
|
27
|
-
const config = loadConfig();
|
|
28
|
-
let _storage = null;
|
|
29
|
-
let _storageReady = null;
|
|
30
|
-
async function ensureStorage() {
|
|
31
|
-
if (!_storage) {
|
|
32
|
-
_storage = new Storage(config.dataDir);
|
|
33
|
-
_storageReady = _storage.ensureReady();
|
|
34
|
-
}
|
|
35
|
-
await _storageReady;
|
|
36
|
-
return _storage;
|
|
37
|
-
}
|
|
38
|
-
function text(t) { return { content: [{ type: 'text', text: t }] }; }
|
|
39
|
-
function json(data) { return text(JSON.stringify(data, null, 2)); }
|
|
40
|
-
// ── MCP Server ──────────────────────────────────────────────────────
|
|
41
|
-
const server = new McpServer({ name: 'engram', version: '2.4.0' }, {
|
|
42
|
-
instructions: [
|
|
43
|
-
'Engram is your long-term memory.',
|
|
44
|
-
'',
|
|
45
|
-
'Save what matters: memory_ingest for facts/preferences/decisions, memory_kg_add for relationships, memory_diary_write at session end.',
|
|
46
|
-
'Before answering about prior conversations: memory_search first.',
|
|
47
|
-
'',
|
|
48
|
-
'## Handoff protocol (MANDATORY)',
|
|
49
|
-
'Context compaction can fail if the window fills completely. When that happens, the user has to abandon the chat. Never let this happen.',
|
|
50
|
-
'',
|
|
51
|
-
'1. Save memories continuously with memory_ingest — never batch.',
|
|
52
|
-
'2. At session start, call memory_handoff_read to resume where the prior session left off.',
|
|
53
|
-
'3. When context feels heavy (long tool outputs, many file reads, extended work) call memory_context_pressure with your honest level assessment. Follow the returned actionPlan.',
|
|
54
|
-
'4. At NATURAL PHASE BOUNDARIES (task done, pivoting focus, finishing a subsystem, user says "ok next let\'s…") call memory_context_pressure with phaseBoundary=true and compact. Pivots thrash the cache anyway — compacting at the boundary is a free lunch, carrying verbose tool output from the old phase into the new one is not.',
|
|
55
|
-
'5. BEFORE invoking /compact — or before session end — call memory_handoff_write with a full "where we left off" snapshot: currentTask, completed, nextSteps, openQuestions, fileRefs (path:line), decisions, notes.
|
|
56
|
-
'6. Do not wait for the system to auto-compact. Compact early, while there is still headroom for the handoff.',
|
|
57
|
-
'',
|
|
58
|
-
'If persona MCP available: call persona_signal on user reactions (correction, approval, frustration, praise, etc).',
|
|
59
|
-
].join('\n'),
|
|
60
|
-
});
|
|
61
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
62
|
-
// CORE MEMORY TOOLS
|
|
63
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
64
|
-
server.registerTool('memory_search', {
|
|
65
|
-
title: 'Search Memories',
|
|
66
|
-
description: 'Search long-term memories. Returns relevant facts, preferences, decisions, and rules. Set format=true to get pre-formatted output for prompt injection.',
|
|
67
|
-
inputSchema: z.object({
|
|
68
|
-
query: z.string().describe('Natural language search query.'),
|
|
69
|
-
maxResults: z.number().min(1).max(500).optional().describe('Max results (default: 10, max: 500).'),
|
|
70
|
-
domain: z.string().optional().describe('Filter by domain/project.'),
|
|
71
|
-
topic: z.string().optional().describe('Filter by topic.'),
|
|
72
|
-
tag: z.string().optional().describe('Filter by exact tag match. Consumer-defined (e.g. "cortex_type:action_item").'),
|
|
73
|
-
cognitiveLoad: z.enum(['low', 'normal', 'high']).optional().describe('From Persona. "high" returns top 3 only.'),
|
|
74
|
-
format: z.boolean().optional().describe('If true, returns formatted text grouped by cognitive layer instead of JSON.'),
|
|
75
|
-
graphRerank: z.union([z.boolean(), z.enum(['lite', 'ppr'])]).optional().describe('Graph-aware rerank mode. `false` or omitted = pure similarity ranking. `true` or `"lite"` = 1-hop expansion + score boost (HippoRAG-lite, fast, no convergence). `"ppr"` = full Personalized PageRank walk from query-seed entities (Gutiérrez et al, NeurIPS 2024 — more accurate on multi-hop QA at modest extra cost). PPR falls back to lite when the graph has < 4 entities or > 500 nodes.'),
|
|
76
|
-
}),
|
|
77
|
-
}, async ({ query, maxResults, domain, topic, tag, cognitiveLoad, format: formatOutput, graphRerank }) => {
|
|
78
|
-
let effectiveMaxResults = maxResults;
|
|
79
|
-
if (cognitiveLoad === 'high') {
|
|
80
|
-
effectiveMaxResults = Math.min(effectiveMaxResults ?? 10, 3);
|
|
81
|
-
}
|
|
82
|
-
const storage = await ensureStorage();
|
|
83
|
-
const results = await search(config, storage, query, effectiveMaxResults, { domain, topic, tag });
|
|
84
|
-
let selected;
|
|
85
|
-
try {
|
|
86
|
-
selected = await selectRelevant(config, query, results);
|
|
87
|
-
}
|
|
88
|
-
catch {
|
|
89
|
-
selected = results.slice(0, cognitiveLoad === 'high' ? 3 : 5);
|
|
90
|
-
}
|
|
91
|
-
// Optional graph-aware rerank.
|
|
92
|
-
// - lite (or `true`): 1-hop expansion + boost. Fast.
|
|
93
|
-
// - ppr: full Personalized PageRank walk from seed entities.
|
|
94
|
-
// Better on multi-hop QA but pays the iteration cost.
|
|
95
|
-
// Both no-op on memory stores without graph data.
|
|
96
|
-
if (graphRerank) {
|
|
97
|
-
const mode = graphRerank === 'ppr' ? 'ppr' : 'lite';
|
|
98
|
-
try {
|
|
99
|
-
selected = mode === 'ppr'
|
|
100
|
-
? await graphAwareRerankPPR(storage, selected)
|
|
101
|
-
: await graphAwareRerank(storage, selected);
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
// graph rerank is opportunistic — fall through to similarity-
|
|
105
|
-
// only results on any error.
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
if (cognitiveLoad === 'high' && selected.length > 3) {
|
|
109
|
-
selected = selected
|
|
110
|
-
.sort((a, b) => b.chunk.importance - a.chunk.importance)
|
|
111
|
-
.slice(0, 3);
|
|
112
|
-
}
|
|
113
|
-
// Formatted output mode (replaces old memory_format tool)
|
|
114
|
-
if (formatOutput) {
|
|
115
|
-
const memText = formatRecalledMemories(selected);
|
|
116
|
-
const rules = await formatRulesForPrompt(storage);
|
|
117
|
-
return text(memText + rules || 'No relevant memories found.');
|
|
118
|
-
}
|
|
119
|
-
return json({
|
|
120
|
-
total: results.length,
|
|
121
|
-
selected: selected.length,
|
|
122
|
-
results: selected.map(r => ({
|
|
123
|
-
id: r.chunk.id,
|
|
124
|
-
content: r.chunk.content,
|
|
125
|
-
type: r.chunk.type,
|
|
126
|
-
layer: r.chunk.cognitiveLayer,
|
|
127
|
-
tier: r.chunk.tier,
|
|
128
|
-
domain: r.chunk.domain || undefined,
|
|
129
|
-
topic: r.chunk.topic || undefined,
|
|
130
|
-
tags: r.chunk.tags.length > 0 ? r.chunk.tags : undefined,
|
|
131
|
-
source: r.chunk.source || undefined,
|
|
132
|
-
createdAt: r.chunk.createdAt || undefined,
|
|
133
|
-
importance: r.chunk.importance,
|
|
134
|
-
score: Math.round(r.score * 1000) / 1000,
|
|
135
|
-
})),
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
server.registerTool('memory_budget', {
|
|
139
|
-
title: 'Search Memories Within a Token Budget',
|
|
140
|
-
description: [
|
|
141
|
-
'Like memory_search, but returns memories that fit within a TOKEN BUDGET instead of a count limit.',
|
|
142
|
-
'Greedy fill from highest-relevance memories: candidates ranked by score × importance, included until the next entry would exceed the budget.',
|
|
143
|
-
'Used by Pyre\'s Context Budget Engine: the persona/memories slot allocates N tokens, and Engram returns "the most useful subset that fits."',
|
|
144
|
-
'Returns the same memory shape as memory_search plus { budgetTokens, usedTokens, includedCount, candidateCount } so callers can see how the budget got spent.',
|
|
145
|
-
].join(' '),
|
|
146
|
-
inputSchema: z.object({
|
|
147
|
-
query: z.string().describe('Natural language search query.'),
|
|
148
|
-
budgetTokens: z.number().min(50).max(50000).describe('Token budget for the returned set. Greedy fill stops before exceeding this. Recommended range: 200 (tight slot) to 5000 (generous).'),
|
|
149
|
-
candidateLimit: z.number().min(1).max(500).optional().describe('Max candidates to consider before budget filtering (default: 50). Larger candidate pool = better quality picks but slower search.'),
|
|
150
|
-
domain: z.string().optional().describe('Filter by domain/project.'),
|
|
151
|
-
topic: z.string().optional().describe('Filter by topic.'),
|
|
152
|
-
tag: z.string().optional().describe('Filter by exact tag match.'),
|
|
153
|
-
format: z.boolean().optional().describe('If true, returns formatted text grouped by cognitive layer instead of JSON.'),
|
|
154
|
-
}),
|
|
155
|
-
}, async ({ query, budgetTokens, candidateLimit, domain, topic, tag, format: formatOutput }) => {
|
|
156
|
-
const storage = await ensureStorage();
|
|
157
|
-
const candidates = await search(config, storage, query, candidateLimit ?? 50, { domain, topic, tag });
|
|
158
|
-
// Greedy budget fill. Sort by relevance score × importance (the
|
|
159
|
-
// composite "useful here AND useful in general" signal). Token
|
|
160
|
-
// estimate is conservative: 4 chars/token for English-prose
|
|
161
|
-
// memory content + a 30-token wrapper overhead per entry for
|
|
162
|
-
// type/source/tags rendering. Slightly over-estimating beats
|
|
163
|
-
// under-estimating; the budget caller (Pyre's CBE) prefers a
|
|
164
|
-
// small remainder over a hard overflow.
|
|
165
|
-
const ranked = candidates
|
|
166
|
-
.map((r) => ({ r, weight: r.score * (r.chunk.importance + 0.1) }))
|
|
167
|
-
.sort((a, b) => b.weight - a.weight);
|
|
168
|
-
const selected = [];
|
|
169
|
-
let usedTokens = 0;
|
|
170
|
-
const WRAPPER_OVERHEAD = 30;
|
|
171
|
-
const CHARS_PER_TOKEN = 4;
|
|
172
|
-
for (const { r } of ranked) {
|
|
173
|
-
const contentTokens = Math.ceil(r.chunk.content.length / CHARS_PER_TOKEN);
|
|
174
|
-
const entryTokens = contentTokens + WRAPPER_OVERHEAD;
|
|
175
|
-
if (usedTokens + entryTokens > budgetTokens) {
|
|
176
|
-
// Hit the budget. The remaining candidates would push us over;
|
|
177
|
-
// greedy stop here. Could continue scanning for a smaller
|
|
178
|
-
// entry that still fits, but the marginal token win usually
|
|
179
|
-
// isn't worth losing the strict importance ordering.
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
selected.push(r);
|
|
183
|
-
usedTokens += entryTokens;
|
|
184
|
-
}
|
|
185
|
-
if (formatOutput) {
|
|
186
|
-
const memText = formatRecalledMemories(selected);
|
|
187
|
-
return text(memText || 'No relevant memories found within budget.');
|
|
188
|
-
}
|
|
189
|
-
return json({
|
|
190
|
-
budgetTokens,
|
|
191
|
-
usedTokens,
|
|
192
|
-
includedCount: selected.length,
|
|
193
|
-
candidateCount: candidates.length,
|
|
194
|
-
results: selected.map((r) => ({
|
|
195
|
-
id: r.chunk.id,
|
|
196
|
-
content: r.chunk.content,
|
|
197
|
-
type: r.chunk.type,
|
|
198
|
-
layer: r.chunk.cognitiveLayer,
|
|
199
|
-
tier: r.chunk.tier,
|
|
200
|
-
domain: r.chunk.domain || undefined,
|
|
201
|
-
topic: r.chunk.topic || undefined,
|
|
202
|
-
tags: r.chunk.tags.length > 0 ? r.chunk.tags : undefined,
|
|
203
|
-
source: r.chunk.source || undefined,
|
|
204
|
-
createdAt: r.chunk.createdAt || undefined,
|
|
205
|
-
importance: r.chunk.importance,
|
|
206
|
-
score: Math.round(r.score * 1000) / 1000,
|
|
207
|
-
})),
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
server.registerTool('memory_ingest', {
|
|
211
|
-
title: 'Save Memory',
|
|
212
|
-
description: 'Save a fact, preference, decision, correction, or context to long-term memory. Auto-classifies type/tags if omitted. Auto-checks for duplicates before saving unless skipDedupe=true.',
|
|
213
|
-
inputSchema: z.object({
|
|
214
|
-
content: z.string().describe('The memory to store.'),
|
|
215
|
-
type: z.enum(['fact', 'preference', 'decision', 'context', 'correction']).optional().describe('Memory type.'),
|
|
216
|
-
importance: z.number().min(0).max(1).optional().describe('Importance 0.0-1.0 (default: 0.5).'),
|
|
217
|
-
tags: z.string().optional().describe('Comma-separated tags.'),
|
|
218
|
-
source: z.string().optional().describe('Source identifier (e.g. stable sourceId from an upstream system). Stored on the chunk and returned on search.'),
|
|
219
|
-
domain: z.string().optional().describe('Domain/project namespace.'),
|
|
220
|
-
topic: z.string().optional().describe('Topic within the domain.'),
|
|
221
|
-
sentiment: z.enum(['frustrated', 'curious', 'satisfied', 'neutral', 'excited', 'confused']).optional().describe('Emotional sentiment from Persona.'),
|
|
222
|
-
emotionalValence: z.number().min(-1).max(1).optional().describe('Emotional valence from Persona. Boosts importance for charged memories.'),
|
|
223
|
-
emotionalArousal: z.number().min(0).max(1).optional().describe('Emotional arousal from Persona. High arousal = stronger encoding.'),
|
|
224
|
-
skipDedupe: z.boolean().optional().describe('If true, bypass the 0.75-similarity duplicate check. Use when the caller is writing structured refinements of prior memories (e.g. action items derived from a meeting note) and dedupe would swallow the write.'),
|
|
225
|
-
origin: z.enum(['user', 'derived', 'extracted', 'imported']).optional().describe('Provenance. Default "user" — explicit ingest is treated as user-asserted and protected from auto-merge / archive. Set "derived" when the caller is a downstream pipeline writing inferences.'),
|
|
226
|
-
tier: z.enum(['scratch', 'short-term']).optional().describe('Memory tier. "scratch" = session-only, never promoted by consolidation, auto-purged after 24h. Use for exploratory notes you may want to discard. Default short-term.'),
|
|
227
|
-
createdAt: z.string().optional().describe('ISO 8601 timestamp override. Default: ingest time (now). Use this when ingesting memories that ORIGINALLY happened at a different time — meeting notes from yesterday, chat history from last week, dated documents from years ago. The timestamp flows into the contextual prefix embedded with the content, giving the retrieval pipeline a temporal signal it would otherwise lose. Critical for benchmarks (LoCoMo) and real workloads that backfill historical context (Cortex ingest of dated docs, importing chat history from Slack/Discord).'),
|
|
228
|
-
skipKgExtraction: z.boolean().optional().describe('Skip the per-chunk knowledge-graph triple extraction. Production users should leave this off — KG extraction powers memory_dossier, memory_kg_query, and graph-aware reranking. Benchmark harnesses comparing apples-to-apples vs the standalone locomo bench (which bypasses wal.ts entirely) should set this to true so they measure the same code path.'),
|
|
229
|
-
skipDailyEntry: z.boolean().optional().describe('Skip the post-batch daily-entry append. Production users should leave this off — daily entries power memory_diary_read and cross-session summaries. Benchmark harnesses set this true alongside skipKgExtraction to match the standalone bench setup.'),
|
|
230
|
-
awaitSideEffects: z.boolean().optional().describe('When false, KG extraction + daily-entry append run in the BACKGROUND after the chunks land on disk; memory_ingest returns ~5-30x faster. Default true (caller awaits everything). Right for production paths where the agent doesn\'t immediately query the just-written content (chat WAL, vault → Engram bridge). Sync mode (true) is right when the caller WILL query within the same turn — bench harnesses, test fixtures, multi-step extraction pipelines.'),
|
|
231
|
-
}),
|
|
232
|
-
}, async ({ content, type, importance, tags, source, domain, topic, sentiment, emotionalValence, emotionalArousal, skipDedupe, origin, tier, createdAt, skipKgExtraction, skipDailyEntry, awaitSideEffects }) => {
|
|
233
|
-
const storage = await ensureStorage();
|
|
234
|
-
// Auto duplicate check (replaces old memory_check_duplicate tool). Callers
|
|
235
|
-
// writing intentional refinements can bypass via skipDedupe=true.
|
|
236
|
-
if (!skipDedupe) {
|
|
237
|
-
const dupeResults = await search(config, storage, content, 5);
|
|
238
|
-
const similar = dupeResults.filter(r => r.score >= 0.75);
|
|
239
|
-
if (similar.length > 0) {
|
|
240
|
-
return json({
|
|
241
|
-
ingested: 0,
|
|
242
|
-
duplicate: true,
|
|
243
|
-
similar: similar.map(r => ({
|
|
244
|
-
id: r.chunk.id,
|
|
245
|
-
content: r.chunk.content,
|
|
246
|
-
score: Math.round(r.score * 1000) / 1000,
|
|
247
|
-
})),
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
const chunks = await ingest(config, storage, [{
|
|
252
|
-
content,
|
|
253
|
-
type: type,
|
|
254
|
-
importance,
|
|
255
|
-
tags: tags?.split(',').map(t => t.trim()),
|
|
256
|
-
...(source ? { source } : {}),
|
|
257
|
-
domain,
|
|
258
|
-
topic,
|
|
259
|
-
sentiment: sentiment,
|
|
260
|
-
emotionalValence,
|
|
261
|
-
emotionalArousal,
|
|
262
|
-
origin: origin ?? 'user',
|
|
263
|
-
...(createdAt ? { createdAt } : {}),
|
|
264
|
-
...(skipKgExtraction ? { skipKgExtraction: true } : {}),
|
|
265
|
-
...(skipDailyEntry ? { skipDailyEntry: true } : {}),
|
|
266
|
-
...(awaitSideEffects === false ? { awaitSideEffects: false } : {}),
|
|
267
|
-
tier,
|
|
268
|
-
}]);
|
|
269
|
-
return json({
|
|
270
|
-
ingested: chunks.length,
|
|
271
|
-
memory: chunks[0] ? {
|
|
272
|
-
id: chunks[0].id,
|
|
273
|
-
content: chunks[0].content,
|
|
274
|
-
type: chunks[0].type,
|
|
275
|
-
layer: chunks[0].cognitiveLayer,
|
|
276
|
-
domain: chunks[0].domain || undefined,
|
|
277
|
-
topic: chunks[0].topic || undefined,
|
|
278
|
-
} : null,
|
|
279
|
-
});
|
|
280
|
-
});
|
|
281
|
-
// memory_update_metadata — patch metadata-shape fields on an existing
|
|
282
|
-
// memory by id. Closes a gap that callers (e.g. cortex's workspace
|
|
283
|
-
// backfill) hit when they need to correct stamps without re-ingesting
|
|
284
|
-
// (which either dupes or relies on similarity dedupe to overwrite —
|
|
285
|
-
// neither is correct semantics).
|
|
286
|
-
//
|
|
287
|
-
// The "metadata" surface here is engram-native: top-level
|
|
288
|
-
// MemoryChunk fields (tags, source, domain, topic, type, sentiment,
|
|
289
|
-
// importance, cognitiveLayer). Cortex translates its richer
|
|
290
|
-
// metadata.X shape into this surface client-side. Per the north-
|
|
291
|
-
// star: engram changes are generic non-breaking additives, not
|
|
292
|
-
// caller-specific hooks.
|
|
293
|
-
//
|
|
294
|
-
// Mutations of `id`, `createdAt`, and the embedding-related fields
|
|
295
|
-
// (`embedding`, `embeddingVersion`) are rejected — those are either
|
|
296
|
-
// immutable identity or computed from content. Callers wanting to
|
|
297
|
-
// re-embed should re-ingest with skipDedupe.
|
|
298
|
-
server.registerTool('memory_update_metadata', {
|
|
299
|
-
title: 'Update Memory Metadata',
|
|
300
|
-
description: 'Patch metadata-shape fields on an existing memory by id. Use to correct mis-stamped tags/source/domain/topic without re-ingesting (which would either duplicate or rely on similarity dedupe to overwrite). Mode "merge" (default) only updates specified fields; "replace" wipes unset fields to defaults — footgun-y, used sparingly. Rejects mutations of id, createdAt, embedding (re-embedding requires re-ingest with skipDedupe).',
|
|
301
|
-
inputSchema: z.object({
|
|
302
|
-
id: z.string().describe('Memory id to patch.'),
|
|
303
|
-
metadata: z.object({
|
|
304
|
-
tags: z.array(z.string()).optional().describe('Replacement tags array. To add/remove individual tags, callers fetch first, modify, write back.'),
|
|
305
|
-
source: z.string().optional(),
|
|
306
|
-
domain: z.string().optional(),
|
|
307
|
-
topic: z.string().optional(),
|
|
308
|
-
type: z.enum(['fact', 'preference', 'decision', 'context', 'correction']).optional(),
|
|
309
|
-
sentiment: z.enum(['frustrated', 'curious', 'satisfied', 'neutral', 'excited', 'confused']).optional(),
|
|
310
|
-
importance: z.number().min(0).max(1).optional(),
|
|
311
|
-
cognitiveLayer: z.string().optional(),
|
|
312
|
-
}).describe('Partial metadata to apply.'),
|
|
313
|
-
mode: z.enum(['merge', 'replace']).optional().describe('Default merge — patch only specified keys. Replace — clear unspecified metadata fields to defaults.'),
|
|
314
|
-
}),
|
|
315
|
-
}, async ({ id, metadata, mode }) => {
|
|
316
|
-
const storage = await ensureStorage();
|
|
317
|
-
const existing = await storage.getChunk(id);
|
|
318
|
-
if (!existing) {
|
|
319
|
-
return json({ error: 'not_found', id });
|
|
320
|
-
}
|
|
321
|
-
const effectiveMode = mode ?? 'merge';
|
|
322
|
-
// Build the patch. In merge mode, only carry fields the caller set.
|
|
323
|
-
// In replace mode, fields the caller didn't set get reset to engram
|
|
324
|
-
// defaults (matches what fresh ingest would produce). Either way,
|
|
325
|
-
// immutable fields (id, createdAt, embedding) stay locked.
|
|
326
|
-
const patch = buildUpdateMetadataPatch(metadata, effectiveMode);
|
|
327
|
-
if (effectiveMode === 'replace') {
|
|
328
|
-
process.stderr.write(`[engram] memory_update_metadata mode=replace id=${id} — caller wiped unset metadata fields to defaults\n`);
|
|
329
|
-
}
|
|
330
|
-
// Compute a lightweight diff for the audit line (existing vs patch),
|
|
331
|
-
// limited to the keys the patch actually touches so we don't log the
|
|
332
|
-
// whole memory blob.
|
|
333
|
-
const diff = {};
|
|
334
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
335
|
-
const before = existing[key];
|
|
336
|
-
diff[key] = { from: before, to: value };
|
|
337
|
-
}
|
|
338
|
-
process.stderr.write(`[engram] memory_update_metadata id=${id} mode=${effectiveMode} diff=${JSON.stringify(diff)}\n`);
|
|
339
|
-
await storage.updateChunk(id, patch);
|
|
340
|
-
const updated = await storage.getChunk(id);
|
|
341
|
-
if (!updated) {
|
|
342
|
-
// Shouldn't happen — getChunk just returned for the same id.
|
|
343
|
-
return json({ error: 'updated_not_found', id });
|
|
344
|
-
}
|
|
345
|
-
return json({
|
|
346
|
-
updated: {
|
|
347
|
-
id: updated.id,
|
|
348
|
-
content: updated.content,
|
|
349
|
-
type: updated.type,
|
|
350
|
-
tags: updated.tags,
|
|
351
|
-
source: updated.source,
|
|
352
|
-
domain: updated.domain,
|
|
353
|
-
topic: updated.topic,
|
|
354
|
-
sentiment: updated.sentiment,
|
|
355
|
-
importance: updated.importance,
|
|
356
|
-
},
|
|
357
|
-
});
|
|
358
|
-
});
|
|
359
|
-
server.registerTool('memory_scratch_promote', {
|
|
360
|
-
title: 'Promote Scratch Memory',
|
|
361
|
-
description: 'Graduate a scratch-tier memory to short-term so it survives the 24h auto-purge and enters the normal consolidation lifecycle. Use after deciding an exploratory note is worth keeping.',
|
|
362
|
-
inputSchema: z.object({
|
|
363
|
-
id: z.string().describe('Scratch chunk id to promote.'),
|
|
364
|
-
}),
|
|
365
|
-
}, async ({ id }) => {
|
|
366
|
-
const storage = await ensureStorage();
|
|
367
|
-
const existing = await storage.getChunk(id);
|
|
368
|
-
if (!existing)
|
|
369
|
-
return json({ error: 'not_found', id });
|
|
370
|
-
if (existing.tier !== 'scratch') {
|
|
371
|
-
return json({ error: 'not_scratch', id, currentTier: existing.tier });
|
|
372
|
-
}
|
|
373
|
-
await storage.updateChunk(id, { tier: 'short-term' });
|
|
374
|
-
return json({ promoted: true, id, from: 'scratch', to: 'short-term' });
|
|
375
|
-
});
|
|
376
|
-
server.registerTool('memory_extract', {
|
|
377
|
-
title: 'Extract Memories',
|
|
378
|
-
description: 'Extract memories from a conversation. Uses LLM or heuristic fallback. Set rulesOnly=true to extract procedural rules only.',
|
|
379
|
-
inputSchema: z.object({
|
|
380
|
-
messages: z.string().describe('JSON string of message array: [{role: "user", content: "..."}, ...]'),
|
|
381
|
-
conversationId: z.string().optional().describe('Session/conversation identifier.'),
|
|
382
|
-
rulesOnly: z.boolean().optional().describe('If true, only extract procedural rules.'),
|
|
383
|
-
}),
|
|
384
|
-
}, async ({ messages, conversationId, rulesOnly }) => {
|
|
385
|
-
const storage = await ensureStorage();
|
|
386
|
-
const parsed = JSON.parse(messages);
|
|
387
|
-
const convId = conversationId ?? `mcp-${Date.now()}`;
|
|
388
|
-
// Rules-only mode (replaces old memory_extract_rules tool)
|
|
389
|
-
if (rulesOnly) {
|
|
390
|
-
await extractRules(config, storage, parsed);
|
|
391
|
-
const rules = await formatRulesForPrompt(storage);
|
|
392
|
-
return text(rules || 'No procedural rules extracted.');
|
|
393
|
-
}
|
|
394
|
-
const allChunks = [];
|
|
395
|
-
if (config.extractionProvider === 'local' || config.extractionProvider === 'both') {
|
|
396
|
-
const chunks = await extractFromConversation(config, storage, parsed, convId);
|
|
397
|
-
allChunks.push(...chunks.map(c => ({
|
|
398
|
-
id: c.id, content: c.content, type: c.type,
|
|
399
|
-
layer: c.cognitiveLayer, importance: c.importance,
|
|
400
|
-
source: isLlmAvailable() ? 'llm' : 'heuristic',
|
|
401
|
-
})));
|
|
402
|
-
}
|
|
403
|
-
if (config.extractionProvider === 'mem0' || config.extractionProvider === 'both') {
|
|
404
|
-
const chunks = await mem0Extract(config, storage, parsed, convId);
|
|
405
|
-
allChunks.push(...chunks.map(c => ({
|
|
406
|
-
id: c.id, content: c.content, type: c.type,
|
|
407
|
-
layer: c.cognitiveLayer, importance: c.importance, source: 'mem0',
|
|
408
|
-
})));
|
|
409
|
-
}
|
|
410
|
-
return json({ extracted: allChunks.length, memories: allChunks });
|
|
411
|
-
});
|
|
412
|
-
server.registerTool('memory_maintain', {
|
|
413
|
-
title: 'Consolidate',
|
|
414
|
-
description: 'Run memory consolidation: decay, promote/demote tiers, link related, merge duplicates, self-organize, and sync Persona bridge.',
|
|
415
|
-
inputSchema: z.object({}),
|
|
416
|
-
}, async () => {
|
|
417
|
-
const storage = await ensureStorage();
|
|
418
|
-
const stats = await consolidate(storage, config);
|
|
419
|
-
// Auto-sync procedural bridge during maintenance
|
|
420
|
-
let bridgeSync = { exported: 0, imported: 0, reinforced: 0, conflicts: 0 };
|
|
421
|
-
try {
|
|
422
|
-
bridgeSync = await syncBridge(storage);
|
|
423
|
-
}
|
|
424
|
-
catch {
|
|
425
|
-
// Bridge sync is best-effort
|
|
426
|
-
}
|
|
427
|
-
return json({ action: 'consolidation', ...stats, bridge: bridgeSync });
|
|
428
|
-
});
|
|
429
|
-
server.registerTool('memory_rules', {
|
|
430
|
-
title: 'Procedural Rules',
|
|
431
|
-
description: 'Show active procedural rules learned from corrections and preferences.',
|
|
432
|
-
inputSchema: z.object({}),
|
|
433
|
-
}, async () => {
|
|
434
|
-
const storage = await ensureStorage();
|
|
435
|
-
const t = await formatRulesForPrompt(storage);
|
|
436
|
-
return text(t || 'No active procedural rules.');
|
|
437
|
-
});
|
|
438
|
-
server.registerTool('memory_outcome', {
|
|
439
|
-
title: 'Recall Outcome',
|
|
440
|
-
description: 'Record whether recalled memories were helpful, corrected, or irrelevant. Adjusts importance.',
|
|
441
|
-
inputSchema: z.object({
|
|
442
|
-
outcome: z.enum(['helpful', 'corrected', 'irrelevant']).describe('Outcome.'),
|
|
443
|
-
chunkIds: z.string().describe('Comma-separated memory chunk IDs.'),
|
|
444
|
-
}),
|
|
445
|
-
}, async ({ outcome, chunkIds }) => {
|
|
446
|
-
const storage = await ensureStorage();
|
|
447
|
-
const ids = chunkIds.split(',').map(id => id.trim());
|
|
448
|
-
await recordRecallOutcome(config, storage, ids, outcome, `mcp-${Date.now()}`);
|
|
449
|
-
return text(`Recorded ${outcome} outcome for ${ids.length} chunk(s).`);
|
|
450
|
-
});
|
|
451
|
-
server.registerTool('memory_session', {
|
|
452
|
-
title: 'Session State',
|
|
453
|
-
description: 'Manage session state (hot RAM). Actions: show, task, context, decision, action, clear.',
|
|
454
|
-
inputSchema: z.object({
|
|
455
|
-
action: z.enum(['show', 'task', 'context', 'decision', 'action', 'clear']).describe('Action.'),
|
|
456
|
-
value: z.string().optional().describe('Value (required for task/context/decision/action).'),
|
|
457
|
-
}),
|
|
458
|
-
}, async ({ action, value }) => {
|
|
459
|
-
switch (action) {
|
|
460
|
-
case 'show':
|
|
461
|
-
return json(readSessionState(config.dataDir));
|
|
462
|
-
case 'task':
|
|
463
|
-
updateSessionState(config.dataDir, { currentTask: value ?? '' });
|
|
464
|
-
return text(`Task set: ${value}`);
|
|
465
|
-
case 'context':
|
|
466
|
-
appendToSessionState(config.dataDir, 'keyContext', value ?? '');
|
|
467
|
-
return text(`Context added: ${value}`);
|
|
468
|
-
case 'decision':
|
|
469
|
-
appendToSessionState(config.dataDir, 'recentDecisions', value ?? '');
|
|
470
|
-
return text(`Decision recorded: ${value}`);
|
|
471
|
-
case 'action':
|
|
472
|
-
appendToSessionState(config.dataDir, 'pendingActions', { text: value ?? '', done: false });
|
|
473
|
-
return text(`Action added: ${value}`);
|
|
474
|
-
case 'clear':
|
|
475
|
-
clearSessionState(config.dataDir);
|
|
476
|
-
return text('Session state cleared.');
|
|
477
|
-
default:
|
|
478
|
-
return text(`Unknown action: ${action}`);
|
|
479
|
-
}
|
|
480
|
-
});
|
|
481
|
-
server.registerTool('memory_stats', {
|
|
482
|
-
title: 'Stats',
|
|
483
|
-
description: 'Memory system stats: chunks by tier/layer/type, rules, knowledge graph, bridge status, and taxonomy.',
|
|
484
|
-
inputSchema: z.object({}),
|
|
485
|
-
}, async () => {
|
|
486
|
-
const storage = await ensureStorage();
|
|
487
|
-
const all = await storage.listChunks();
|
|
488
|
-
const tiers = {};
|
|
489
|
-
const layers = {};
|
|
490
|
-
const types = {};
|
|
491
|
-
for (const c of all) {
|
|
492
|
-
tiers[c.tier] = (tiers[c.tier] ?? 0) + 1;
|
|
493
|
-
layers[c.cognitiveLayer] = (layers[c.cognitiveLayer] ?? 0) + 1;
|
|
494
|
-
types[c.type] = (types[c.type] ?? 0) + 1;
|
|
495
|
-
}
|
|
496
|
-
const rules = await storage.getRules();
|
|
497
|
-
const kgStats = await getGraphStats(storage);
|
|
498
|
-
const state = readSessionState(config.dataDir);
|
|
499
|
-
const diaryDates = listDiaryDates(config.dataDir);
|
|
500
|
-
// Taxonomy (folded in from old memory_taxonomy tool)
|
|
501
|
-
const tree = await storage.getTaxonomy();
|
|
502
|
-
// Bridge status (new observability)
|
|
503
|
-
let bridge = { status: 'no bridge file' };
|
|
504
|
-
try {
|
|
505
|
-
const bridgeFile = loadBridgeFile();
|
|
506
|
-
bridge = {
|
|
507
|
-
lastUpdated: bridgeFile.lastUpdated,
|
|
508
|
-
totalRules: bridgeFile.rules.length,
|
|
509
|
-
engramRules: bridgeFile.rules.filter(r => r.source === 'engram').length,
|
|
510
|
-
personaRules: bridgeFile.rules.filter(r => r.source === 'persona').length,
|
|
511
|
-
};
|
|
512
|
-
}
|
|
513
|
-
catch { /* no bridge file */ }
|
|
514
|
-
return json({
|
|
515
|
-
totalChunks: all.length,
|
|
516
|
-
byTier: tiers,
|
|
517
|
-
byLayer: layers,
|
|
518
|
-
byType: types,
|
|
519
|
-
proceduralRules: rules.length,
|
|
520
|
-
activeRules: rules.filter(r => r.confidence > 0.3).length,
|
|
521
|
-
knowledgeGraph: kgStats,
|
|
522
|
-
taxonomy: tree,
|
|
523
|
-
bridge,
|
|
524
|
-
diaryEntries: diaryDates.length,
|
|
525
|
-
llmAvailable: isLlmAvailable(),
|
|
526
|
-
embeddingModel: process.env.ENGRAM_EMBEDDING_MODEL ?? process.env.SMART_MEMORY_EMBEDDING_MODEL ?? 'Xenova/all-MiniLM-L6-v2',
|
|
527
|
-
sessionTask: state.currentTask || null,
|
|
528
|
-
});
|
|
529
|
-
});
|
|
530
|
-
server.registerTool('memory_govern', {
|
|
531
|
-
title: 'Governance Check',
|
|
532
|
-
description: 'Advisory checks: "check" (contradictions), "drift" (semantic drift), "poison" (injection scan), "full" (all).',
|
|
533
|
-
inputSchema: z.object({
|
|
534
|
-
action: z.enum(['check', 'drift', 'poison', 'full']).describe('Governance action.'),
|
|
535
|
-
content: z.string().optional().describe('Content to check (required for "check").'),
|
|
536
|
-
domain: z.string().optional().describe('Filter by domain.'),
|
|
537
|
-
}),
|
|
538
|
-
}, async ({ action, content, domain }) => {
|
|
539
|
-
const storage = await ensureStorage();
|
|
540
|
-
if (action === 'check') {
|
|
541
|
-
if (!content)
|
|
542
|
-
return json({ error: 'Content required for contradiction check.' });
|
|
543
|
-
const result = await detectContradictions(config, storage, content, { domain });
|
|
544
|
-
return json(result);
|
|
545
|
-
}
|
|
546
|
-
if (action === 'full') {
|
|
547
|
-
const report = await runGovernanceCheck(config, storage, { content, domain });
|
|
548
|
-
return json(report);
|
|
549
|
-
}
|
|
550
|
-
if (action === 'drift') {
|
|
551
|
-
const { measureSemanticDrift } = await import('./governance.js');
|
|
552
|
-
const drift = await measureSemanticDrift(config, storage, { domain });
|
|
553
|
-
return json(drift);
|
|
554
|
-
}
|
|
555
|
-
if (action === 'poison') {
|
|
556
|
-
const { checkMemoryPoisoning } = await import('./governance.js');
|
|
557
|
-
const poison = await checkMemoryPoisoning(storage);
|
|
558
|
-
return json(poison);
|
|
559
|
-
}
|
|
560
|
-
return json({ error: 'Unknown action.' });
|
|
561
|
-
});
|
|
562
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
563
|
-
// KNOWLEDGE GRAPH TOOLS
|
|
564
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
565
|
-
server.registerTool('memory_kg_add', {
|
|
566
|
-
title: 'KG Add',
|
|
567
|
-
description: 'Add a subject-predicate-object triple. Use replace=true to auto-invalidate conflicting facts.',
|
|
568
|
-
inputSchema: z.object({
|
|
569
|
-
subject: z.string().describe('Entity (e.g. "Matt").'),
|
|
570
|
-
predicate: z.string().describe('Relationship (e.g. "works-at").'),
|
|
571
|
-
object: z.string().describe('Target (e.g. "Acme Corp").'),
|
|
572
|
-
replace: z.boolean().optional().describe('Invalidate existing triples with same subject+predicate.'),
|
|
573
|
-
confidence: z.number().min(0).max(1).optional().describe('Confidence 0-1 (default: 0.5).'),
|
|
574
|
-
}),
|
|
575
|
-
}, async ({ subject, predicate, object, replace, confidence }) => {
|
|
576
|
-
const storage = await ensureStorage();
|
|
577
|
-
const fn = replace ? replaceTriple : addTriple;
|
|
578
|
-
const triple = await fn(storage, subject, predicate, object, `mcp-${Date.now()}`, confidence);
|
|
579
|
-
return json({ added: true, triple: { id: triple.id, subject: triple.subject, predicate: triple.predicate, object: triple.object } });
|
|
580
|
-
});
|
|
581
|
-
server.registerTool('memory_kg_query', {
|
|
582
|
-
title: 'KG Query',
|
|
583
|
-
description: 'Query knowledge graph triples. Filter by subject, predicate, and/or object.',
|
|
584
|
-
inputSchema: z.object({
|
|
585
|
-
subject: z.string().optional().describe('Filter by subject.'),
|
|
586
|
-
predicate: z.string().optional().describe('Filter by relationship.'),
|
|
587
|
-
object: z.string().optional().describe('Filter by target.'),
|
|
588
|
-
activeOnly: z.boolean().optional().describe('Only valid facts (default: true).'),
|
|
589
|
-
}),
|
|
590
|
-
}, async ({ subject, predicate, object, activeOnly }) => {
|
|
591
|
-
const storage = await ensureStorage();
|
|
592
|
-
const triples = await queryGraph(storage, {
|
|
593
|
-
subject, predicate, object,
|
|
594
|
-
activeOnly: activeOnly ?? true,
|
|
595
|
-
});
|
|
596
|
-
return json({
|
|
597
|
-
count: triples.length,
|
|
598
|
-
triples: triples.map(t => ({
|
|
599
|
-
id: t.id, subject: t.subject, predicate: t.predicate, object: t.object,
|
|
600
|
-
confidence: t.confidence, validFrom: t.validFrom, validTo: t.validTo,
|
|
601
|
-
})),
|
|
602
|
-
});
|
|
603
|
-
});
|
|
604
|
-
server.registerTool('memory_kg_invalidate', {
|
|
605
|
-
title: 'KG Invalidate',
|
|
606
|
-
description: 'Mark a fact as no longer valid. Stays in history.',
|
|
607
|
-
inputSchema: z.object({
|
|
608
|
-
tripleId: z.string().describe('Triple ID to invalidate.'),
|
|
609
|
-
}),
|
|
610
|
-
}, async ({ tripleId }) => {
|
|
611
|
-
const storage = await ensureStorage();
|
|
612
|
-
await invalidateTriple(storage, tripleId);
|
|
613
|
-
return text(`Triple ${tripleId} invalidated.`);
|
|
614
|
-
});
|
|
615
|
-
server.registerTool('memory_kg_timeline', {
|
|
616
|
-
title: 'KG Timeline',
|
|
617
|
-
description: 'Chronological history of all facts about an entity.',
|
|
618
|
-
inputSchema: z.object({
|
|
619
|
-
entity: z.string().describe('Entity name.'),
|
|
620
|
-
}),
|
|
621
|
-
}, async ({ entity }) => {
|
|
622
|
-
const storage = await ensureStorage();
|
|
623
|
-
const timeline = await getTimeline(storage, entity);
|
|
624
|
-
return json({
|
|
625
|
-
entity,
|
|
626
|
-
facts: timeline.map(t => ({
|
|
627
|
-
subject: t.subject, predicate: t.predicate, object: t.object,
|
|
628
|
-
validFrom: t.validFrom, validTo: t.validTo, active: !t.validTo,
|
|
629
|
-
})),
|
|
630
|
-
});
|
|
631
|
-
});
|
|
632
|
-
server.registerTool('memory_dossier', {
|
|
633
|
-
title: 'Entity Dossier',
|
|
634
|
-
description: [
|
|
635
|
-
'Aggregate everything Engram knows about an entity (person, project, concept) into a structured snapshot.',
|
|
636
|
-
'Pulls from FOUR sources: (1) KG triples where the entity is subject — definitive facts about the entity; (2) KG triples where the entity is object — facts where others reference the entity (e.g. "Alice reports-to Matt" appears in Matt\'s dossier as referencedBy); (3) memory chunks mentioning the entity in content/tags/topic — preferences, decisions, context; (4) recent activity ordered by createdAt — what came up lately.',
|
|
637
|
-
'Output is grouped by category (facts, preferences, decisions, corrections, recent) so the consumer doesn\'t have to bucket the chunks themselves.',
|
|
638
|
-
'Honors an optional budgetTokens cap; greedy fill within each category when set. Used by Pyre\'s Context Budget Engine to populate "what we know about <X>" slots without spending the entire memories budget on a search-by-relevance grab bag.',
|
|
639
|
-
].join(' '),
|
|
640
|
-
inputSchema: z.object({
|
|
641
|
-
entity: z.string().describe('Entity name. Matches against KG subject, chunk content (substring), tags (exact), and topic (exact). Case-insensitive.'),
|
|
642
|
-
budgetTokens: z.number().min(100).max(50000).optional().describe('Optional token cap for the returned set. When set, each category fills greedy by importance until the per-category share is exhausted (~25% of budget per category). Without budget, returns up to maxPerCategory entries per category.'),
|
|
643
|
-
maxPerCategory: z.number().min(1).max(50).optional().describe('Max entries per category when budgetTokens is omitted (default: 5).'),
|
|
644
|
-
domain: z.string().optional().describe('Optional domain filter (limits dossier to a single project/scope).'),
|
|
645
|
-
}),
|
|
646
|
-
}, async ({ entity, budgetTokens, maxPerCategory, domain }) => {
|
|
647
|
-
const storage = await ensureStorage();
|
|
648
|
-
const cap = maxPerCategory ?? 5;
|
|
649
|
-
const entityLower = entity.toLowerCase();
|
|
650
|
-
// 1a. KG triples where the entity is the subject (active facts).
|
|
651
|
-
// Filtered to active by default — invalidated triples shouldn't
|
|
652
|
-
// surface in a dossier.
|
|
653
|
-
const triples = await queryGraph(storage, {
|
|
654
|
-
subject: entity,
|
|
655
|
-
activeOnly: true,
|
|
656
|
-
});
|
|
657
|
-
// 1b. KG triples where the entity is the OBJECT — facts about the
|
|
658
|
-
// entity asserted from someone else's perspective (e.g.
|
|
659
|
-
// "Alice reports-to Matt" should appear in Matt's dossier as
|
|
660
|
-
// a referencedBy edge). Without this, the dossier only shows
|
|
661
|
-
// outbound relationships and misses inbound ones.
|
|
662
|
-
const referencedBy = await queryGraph(storage, {
|
|
663
|
-
object: entity,
|
|
664
|
-
activeOnly: true,
|
|
665
|
-
});
|
|
666
|
-
// 2. Memory chunks mentioning the entity. Use a generous candidate
|
|
667
|
-
// pool (entity-shaped queries are usually narrower than free-form
|
|
668
|
-
// search), then filter client-side for the substring/tag/topic
|
|
669
|
-
// match so we don't miss chunks the search ranker buried.
|
|
670
|
-
const candidates = await search(config, storage, entity, 100, { domain });
|
|
671
|
-
const matching = candidates.filter((r) => {
|
|
672
|
-
const c = r.chunk;
|
|
673
|
-
return c.content.toLowerCase().includes(entityLower)
|
|
674
|
-
|| c.tags.some((t) => t.toLowerCase() === entityLower)
|
|
675
|
-
|| c.topic.toLowerCase() === entityLower;
|
|
676
|
-
});
|
|
677
|
-
// Bucket by type. "context" maps into recent rather than its own
|
|
678
|
-
// category since context is usually time-sensitive — last week's
|
|
679
|
-
// context is less interesting than last week's preference.
|
|
680
|
-
const buckets = {
|
|
681
|
-
facts: [],
|
|
682
|
-
preferences: [],
|
|
683
|
-
decisions: [],
|
|
684
|
-
corrections: [],
|
|
685
|
-
recent: [],
|
|
686
|
-
};
|
|
687
|
-
for (const r of matching) {
|
|
688
|
-
const t = r.chunk.type;
|
|
689
|
-
if (t === 'fact')
|
|
690
|
-
buckets.facts.push(r);
|
|
691
|
-
else if (t === 'preference')
|
|
692
|
-
buckets.preferences.push(r);
|
|
693
|
-
else if (t === 'decision')
|
|
694
|
-
buckets.decisions.push(r);
|
|
695
|
-
else if (t === 'correction')
|
|
696
|
-
buckets.corrections.push(r);
|
|
697
|
-
// context is intentionally not its own bucket — falls into recent
|
|
698
|
-
}
|
|
699
|
-
// Recent = top-N most recently created chunks across ALL types,
|
|
700
|
-
// independent of category. Catches active context + new
|
|
701
|
-
// facts/preferences regardless of where they bucketed.
|
|
702
|
-
buckets.recent = [...matching]
|
|
703
|
-
.sort((a, b) => (b.chunk.createdAt ?? '').localeCompare(a.chunk.createdAt ?? ''))
|
|
704
|
-
.slice(0, cap);
|
|
705
|
-
// Per-category importance sort + cap.
|
|
706
|
-
for (const k of Object.keys(buckets)) {
|
|
707
|
-
if (k === 'recent')
|
|
708
|
-
continue;
|
|
709
|
-
buckets[k] = buckets[k]
|
|
710
|
-
.sort((a, b) => b.chunk.importance - a.chunk.importance)
|
|
711
|
-
.slice(0, cap);
|
|
712
|
-
}
|
|
713
|
-
// Optional token-budget enforcement. Splits budget evenly across
|
|
714
|
-
// the 5 categories (facts / preferences / decisions / corrections
|
|
715
|
-
// / recent) and greedy-fills each within its share. Same 4
|
|
716
|
-
// chars/token + 30 wrapper estimate as memory_budget.
|
|
717
|
-
let usedTokens = 0;
|
|
718
|
-
if (typeof budgetTokens === 'number' && budgetTokens > 0) {
|
|
719
|
-
const perCategoryBudget = Math.floor(budgetTokens / 5);
|
|
720
|
-
const CHARS_PER_TOKEN = 4;
|
|
721
|
-
const WRAPPER_OVERHEAD = 30;
|
|
722
|
-
for (const k of Object.keys(buckets)) {
|
|
723
|
-
let categoryUsed = 0;
|
|
724
|
-
const filtered = [];
|
|
725
|
-
for (const r of buckets[k]) {
|
|
726
|
-
const t = Math.ceil(r.chunk.content.length / CHARS_PER_TOKEN) + WRAPPER_OVERHEAD;
|
|
727
|
-
if (categoryUsed + t > perCategoryBudget)
|
|
728
|
-
continue;
|
|
729
|
-
filtered.push(r);
|
|
730
|
-
categoryUsed += t;
|
|
731
|
-
usedTokens += t;
|
|
732
|
-
}
|
|
733
|
-
buckets[k] = filtered;
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
const renderBucket = (entries) => entries.map((r) => ({
|
|
737
|
-
id: r.chunk.id,
|
|
738
|
-
content: r.chunk.content,
|
|
739
|
-
type: r.chunk.type,
|
|
740
|
-
importance: r.chunk.importance,
|
|
741
|
-
createdAt: r.chunk.createdAt || undefined,
|
|
742
|
-
domain: r.chunk.domain || undefined,
|
|
743
|
-
topic: r.chunk.topic || undefined,
|
|
744
|
-
tags: r.chunk.tags.length > 0 ? r.chunk.tags : undefined,
|
|
745
|
-
}));
|
|
746
|
-
return json({
|
|
747
|
-
entity,
|
|
748
|
-
budgetTokens: budgetTokens ?? null,
|
|
749
|
-
usedTokens: budgetTokens ? usedTokens : undefined,
|
|
750
|
-
kgFacts: triples.map((t) => ({
|
|
751
|
-
id: t.id,
|
|
752
|
-
predicate: t.predicate,
|
|
753
|
-
object: t.object,
|
|
754
|
-
confidence: t.confidence,
|
|
755
|
-
validFrom: t.validFrom,
|
|
756
|
-
})),
|
|
757
|
-
referencedBy: referencedBy.map((t) => ({
|
|
758
|
-
id: t.id,
|
|
759
|
-
subject: t.subject,
|
|
760
|
-
predicate: t.predicate,
|
|
761
|
-
confidence: t.confidence,
|
|
762
|
-
validFrom: t.validFrom,
|
|
763
|
-
})),
|
|
764
|
-
facts: renderBucket(buckets.facts),
|
|
765
|
-
preferences: renderBucket(buckets.preferences),
|
|
766
|
-
decisions: renderBucket(buckets.decisions),
|
|
767
|
-
corrections: renderBucket(buckets.corrections),
|
|
768
|
-
recent: renderBucket(buckets.recent),
|
|
769
|
-
candidateCount: matching.length,
|
|
770
|
-
});
|
|
771
|
-
});
|
|
772
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
773
|
-
// DIARY TOOLS
|
|
774
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
775
|
-
server.registerTool('memory_diary_write', {
|
|
776
|
-
title: 'Write Diary',
|
|
777
|
-
description: 'Write a session diary entry. Record what happened, what was decided, what matters next.',
|
|
778
|
-
inputSchema: z.object({
|
|
779
|
-
content: z.string().describe('Diary entry.'),
|
|
780
|
-
agent: z.string().optional().describe('Agent name (default: "claude").'),
|
|
781
|
-
}),
|
|
782
|
-
}, async ({ content, agent }) => {
|
|
783
|
-
const entry = writeDiaryEntry(config.dataDir, content, agent);
|
|
784
|
-
return json({ written: true, date: entry.date, time: entry.time, agent: entry.agent });
|
|
785
|
-
});
|
|
786
|
-
server.registerTool('memory_diary_read', {
|
|
787
|
-
title: 'Read Diary',
|
|
788
|
-
description: 'Read diary entries from recent days or a specific date.',
|
|
789
|
-
inputSchema: z.object({
|
|
790
|
-
date: z.string().optional().describe('YYYY-MM-DD. If omitted, returns recent.'),
|
|
791
|
-
daysBack: z.number().optional().describe('Days to look back (default: 7).'),
|
|
792
|
-
agent: z.string().optional().describe('Filter by agent.'),
|
|
793
|
-
}),
|
|
794
|
-
}, async ({ date, daysBack, agent }) => {
|
|
795
|
-
const entries = readDiary(config.dataDir, { date, daysBack, agent });
|
|
796
|
-
return json(entries);
|
|
797
|
-
});
|
|
798
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
799
|
-
// HANDOFF TOOLS — cross-session "where we left off" lifeline
|
|
800
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
801
|
-
server.registerTool('memory_handoff_write', {
|
|
802
|
-
title: 'Write Handoff Note',
|
|
803
|
-
description: 'Write a structured "where we left off" snapshot. Call BEFORE /compact, before session end,
|
|
804
|
-
inputSchema: z.object({
|
|
805
|
-
currentTask: z.string().describe('One-sentence description of what you are working on.'),
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
},
|
|
816
|
-
|
|
817
|
-
const
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
if (
|
|
849
|
-
return json({
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
}
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { Storage } from './storage.js';
|
|
6
|
+
import { buildUpdateMetadataPatch, } from './update-metadata.js';
|
|
7
|
+
import { loadConfig } from './config.js';
|
|
8
|
+
import { isLlmAvailable } from './llm.js';
|
|
9
|
+
import { search, selectRelevant, formatRecalledMemories } from './search.js';
|
|
10
|
+
import { graphAwareRerank, graphAwareRerankPPR } from './graph-rerank.js';
|
|
11
|
+
import { extractFromConversation } from './extractor.js';
|
|
12
|
+
import { consolidate } from './consolidator.js';
|
|
13
|
+
import { extractRules, formatRulesForPrompt } from './procedural.js';
|
|
14
|
+
import { recordRecallOutcome } from './outcome.js';
|
|
15
|
+
import { mem0Extract } from './mem0.js';
|
|
16
|
+
import { ingest } from './wal.js';
|
|
17
|
+
import { readSessionState, updateSessionState, appendToSessionState, clearSessionState, } from './session-state.js';
|
|
18
|
+
import { addTriple, replaceTriple, queryGraph, getTimeline, invalidateTriple, getGraphStats, } from './knowledge-graph.js';
|
|
19
|
+
import { writeDiaryEntry, readDiary, listDiaryDates } from './diary.js';
|
|
20
|
+
import { importConversation } from './importer.js';
|
|
21
|
+
import { runGovernanceCheck, detectContradictions } from './governance.js';
|
|
22
|
+
import { syncBridge, loadBridgeFile } from './procedural-bridge.js';
|
|
23
|
+
import { writeHandoff, readHandoff, listHandoffs } from './handoff.js';
|
|
24
|
+
import { assessPressure } from './context-pressure.js';
|
|
25
|
+
import { listRecentTraces, gcOldTraces } from './retrieval-trace.js';
|
|
26
|
+
// ── Config & Storage ────────────────────────────────────────────────
|
|
27
|
+
const config = loadConfig();
|
|
28
|
+
let _storage = null;
|
|
29
|
+
let _storageReady = null;
|
|
30
|
+
async function ensureStorage() {
|
|
31
|
+
if (!_storage) {
|
|
32
|
+
_storage = new Storage(config.dataDir);
|
|
33
|
+
_storageReady = _storage.ensureReady();
|
|
34
|
+
}
|
|
35
|
+
await _storageReady;
|
|
36
|
+
return _storage;
|
|
37
|
+
}
|
|
38
|
+
function text(t) { return { content: [{ type: 'text', text: t }] }; }
|
|
39
|
+
function json(data) { return text(JSON.stringify(data, null, 2)); }
|
|
40
|
+
// ── MCP Server ──────────────────────────────────────────────────────
|
|
41
|
+
const server = new McpServer({ name: 'engram', version: '2.4.0' }, {
|
|
42
|
+
instructions: [
|
|
43
|
+
'Engram is your long-term memory.',
|
|
44
|
+
'',
|
|
45
|
+
'Save what matters: memory_ingest for facts/preferences/decisions, memory_kg_add for relationships, memory_diary_write at session end.',
|
|
46
|
+
'Before answering about prior conversations: memory_search first.',
|
|
47
|
+
'',
|
|
48
|
+
'## Handoff protocol (MANDATORY)',
|
|
49
|
+
'Context compaction can fail if the window fills completely. When that happens, the user has to abandon the chat. Never let this happen.',
|
|
50
|
+
'',
|
|
51
|
+
'1. Save memories continuously with memory_ingest — never batch.',
|
|
52
|
+
'2. At session start, call memory_handoff_read to resume where the prior session left off. If the user references a specific past session (by name or topic), call memory_handoff_list first and load the matching named checkpoint with memory_handoff_read({ name }).',
|
|
53
|
+
'3. When context feels heavy (long tool outputs, many file reads, extended work) call memory_context_pressure with your honest level assessment. Follow the returned actionPlan.',
|
|
54
|
+
'4. At NATURAL PHASE BOUNDARIES (task done, pivoting focus, finishing a subsystem, user says "ok next let\'s…") call memory_context_pressure with phaseBoundary=true and compact. Pivots thrash the cache anyway — compacting at the boundary is a free lunch, carrying verbose tool output from the old phase into the new one is not.',
|
|
55
|
+
'5. BEFORE invoking /compact — or before session end, or when the user asks to "save this session" / "checkpoint this" — call memory_handoff_write with a full "where we left off" snapshot: currentTask, completed, nextSteps, openQuestions, fileRefs (path:line), decisions, notes. Pass `name` for a user-friendly checkpoint label so the user can resume it explicitly later.',
|
|
56
|
+
'6. Do not wait for the system to auto-compact. Compact early, while there is still headroom for the handoff.',
|
|
57
|
+
'',
|
|
58
|
+
'If persona MCP available: call persona_signal on user reactions (correction, approval, frustration, praise, etc).',
|
|
59
|
+
].join('\n'),
|
|
60
|
+
});
|
|
61
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
62
|
+
// CORE MEMORY TOOLS
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
64
|
+
server.registerTool('memory_search', {
|
|
65
|
+
title: 'Search Memories',
|
|
66
|
+
description: 'Search long-term memories. Returns relevant facts, preferences, decisions, and rules. Set format=true to get pre-formatted output for prompt injection.',
|
|
67
|
+
inputSchema: z.object({
|
|
68
|
+
query: z.string().describe('Natural language search query.'),
|
|
69
|
+
maxResults: z.number().min(1).max(500).optional().describe('Max results (default: 10, max: 500).'),
|
|
70
|
+
domain: z.string().optional().describe('Filter by domain/project.'),
|
|
71
|
+
topic: z.string().optional().describe('Filter by topic.'),
|
|
72
|
+
tag: z.string().optional().describe('Filter by exact tag match. Consumer-defined (e.g. "cortex_type:action_item").'),
|
|
73
|
+
cognitiveLoad: z.enum(['low', 'normal', 'high']).optional().describe('From Persona. "high" returns top 3 only.'),
|
|
74
|
+
format: z.boolean().optional().describe('If true, returns formatted text grouped by cognitive layer instead of JSON.'),
|
|
75
|
+
graphRerank: z.union([z.boolean(), z.enum(['lite', 'ppr'])]).optional().describe('Graph-aware rerank mode. `false` or omitted = pure similarity ranking. `true` or `"lite"` = 1-hop expansion + score boost (HippoRAG-lite, fast, no convergence). `"ppr"` = full Personalized PageRank walk from query-seed entities (Gutiérrez et al, NeurIPS 2024 — more accurate on multi-hop QA at modest extra cost). PPR falls back to lite when the graph has < 4 entities or > 500 nodes.'),
|
|
76
|
+
}),
|
|
77
|
+
}, async ({ query, maxResults, domain, topic, tag, cognitiveLoad, format: formatOutput, graphRerank }) => {
|
|
78
|
+
let effectiveMaxResults = maxResults;
|
|
79
|
+
if (cognitiveLoad === 'high') {
|
|
80
|
+
effectiveMaxResults = Math.min(effectiveMaxResults ?? 10, 3);
|
|
81
|
+
}
|
|
82
|
+
const storage = await ensureStorage();
|
|
83
|
+
const results = await search(config, storage, query, effectiveMaxResults, { domain, topic, tag });
|
|
84
|
+
let selected;
|
|
85
|
+
try {
|
|
86
|
+
selected = await selectRelevant(config, query, results);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
selected = results.slice(0, cognitiveLoad === 'high' ? 3 : 5);
|
|
90
|
+
}
|
|
91
|
+
// Optional graph-aware rerank.
|
|
92
|
+
// - lite (or `true`): 1-hop expansion + boost. Fast.
|
|
93
|
+
// - ppr: full Personalized PageRank walk from seed entities.
|
|
94
|
+
// Better on multi-hop QA but pays the iteration cost.
|
|
95
|
+
// Both no-op on memory stores without graph data.
|
|
96
|
+
if (graphRerank) {
|
|
97
|
+
const mode = graphRerank === 'ppr' ? 'ppr' : 'lite';
|
|
98
|
+
try {
|
|
99
|
+
selected = mode === 'ppr'
|
|
100
|
+
? await graphAwareRerankPPR(storage, selected)
|
|
101
|
+
: await graphAwareRerank(storage, selected);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// graph rerank is opportunistic — fall through to similarity-
|
|
105
|
+
// only results on any error.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (cognitiveLoad === 'high' && selected.length > 3) {
|
|
109
|
+
selected = selected
|
|
110
|
+
.sort((a, b) => b.chunk.importance - a.chunk.importance)
|
|
111
|
+
.slice(0, 3);
|
|
112
|
+
}
|
|
113
|
+
// Formatted output mode (replaces old memory_format tool)
|
|
114
|
+
if (formatOutput) {
|
|
115
|
+
const memText = formatRecalledMemories(selected);
|
|
116
|
+
const rules = await formatRulesForPrompt(storage);
|
|
117
|
+
return text(memText + rules || 'No relevant memories found.');
|
|
118
|
+
}
|
|
119
|
+
return json({
|
|
120
|
+
total: results.length,
|
|
121
|
+
selected: selected.length,
|
|
122
|
+
results: selected.map(r => ({
|
|
123
|
+
id: r.chunk.id,
|
|
124
|
+
content: r.chunk.content,
|
|
125
|
+
type: r.chunk.type,
|
|
126
|
+
layer: r.chunk.cognitiveLayer,
|
|
127
|
+
tier: r.chunk.tier,
|
|
128
|
+
domain: r.chunk.domain || undefined,
|
|
129
|
+
topic: r.chunk.topic || undefined,
|
|
130
|
+
tags: r.chunk.tags.length > 0 ? r.chunk.tags : undefined,
|
|
131
|
+
source: r.chunk.source || undefined,
|
|
132
|
+
createdAt: r.chunk.createdAt || undefined,
|
|
133
|
+
importance: r.chunk.importance,
|
|
134
|
+
score: Math.round(r.score * 1000) / 1000,
|
|
135
|
+
})),
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
server.registerTool('memory_budget', {
|
|
139
|
+
title: 'Search Memories Within a Token Budget',
|
|
140
|
+
description: [
|
|
141
|
+
'Like memory_search, but returns memories that fit within a TOKEN BUDGET instead of a count limit.',
|
|
142
|
+
'Greedy fill from highest-relevance memories: candidates ranked by score × importance, included until the next entry would exceed the budget.',
|
|
143
|
+
'Used by Pyre\'s Context Budget Engine: the persona/memories slot allocates N tokens, and Engram returns "the most useful subset that fits."',
|
|
144
|
+
'Returns the same memory shape as memory_search plus { budgetTokens, usedTokens, includedCount, candidateCount } so callers can see how the budget got spent.',
|
|
145
|
+
].join(' '),
|
|
146
|
+
inputSchema: z.object({
|
|
147
|
+
query: z.string().describe('Natural language search query.'),
|
|
148
|
+
budgetTokens: z.number().min(50).max(50000).describe('Token budget for the returned set. Greedy fill stops before exceeding this. Recommended range: 200 (tight slot) to 5000 (generous).'),
|
|
149
|
+
candidateLimit: z.number().min(1).max(500).optional().describe('Max candidates to consider before budget filtering (default: 50). Larger candidate pool = better quality picks but slower search.'),
|
|
150
|
+
domain: z.string().optional().describe('Filter by domain/project.'),
|
|
151
|
+
topic: z.string().optional().describe('Filter by topic.'),
|
|
152
|
+
tag: z.string().optional().describe('Filter by exact tag match.'),
|
|
153
|
+
format: z.boolean().optional().describe('If true, returns formatted text grouped by cognitive layer instead of JSON.'),
|
|
154
|
+
}),
|
|
155
|
+
}, async ({ query, budgetTokens, candidateLimit, domain, topic, tag, format: formatOutput }) => {
|
|
156
|
+
const storage = await ensureStorage();
|
|
157
|
+
const candidates = await search(config, storage, query, candidateLimit ?? 50, { domain, topic, tag });
|
|
158
|
+
// Greedy budget fill. Sort by relevance score × importance (the
|
|
159
|
+
// composite "useful here AND useful in general" signal). Token
|
|
160
|
+
// estimate is conservative: 4 chars/token for English-prose
|
|
161
|
+
// memory content + a 30-token wrapper overhead per entry for
|
|
162
|
+
// type/source/tags rendering. Slightly over-estimating beats
|
|
163
|
+
// under-estimating; the budget caller (Pyre's CBE) prefers a
|
|
164
|
+
// small remainder over a hard overflow.
|
|
165
|
+
const ranked = candidates
|
|
166
|
+
.map((r) => ({ r, weight: r.score * (r.chunk.importance + 0.1) }))
|
|
167
|
+
.sort((a, b) => b.weight - a.weight);
|
|
168
|
+
const selected = [];
|
|
169
|
+
let usedTokens = 0;
|
|
170
|
+
const WRAPPER_OVERHEAD = 30;
|
|
171
|
+
const CHARS_PER_TOKEN = 4;
|
|
172
|
+
for (const { r } of ranked) {
|
|
173
|
+
const contentTokens = Math.ceil(r.chunk.content.length / CHARS_PER_TOKEN);
|
|
174
|
+
const entryTokens = contentTokens + WRAPPER_OVERHEAD;
|
|
175
|
+
if (usedTokens + entryTokens > budgetTokens) {
|
|
176
|
+
// Hit the budget. The remaining candidates would push us over;
|
|
177
|
+
// greedy stop here. Could continue scanning for a smaller
|
|
178
|
+
// entry that still fits, but the marginal token win usually
|
|
179
|
+
// isn't worth losing the strict importance ordering.
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
selected.push(r);
|
|
183
|
+
usedTokens += entryTokens;
|
|
184
|
+
}
|
|
185
|
+
if (formatOutput) {
|
|
186
|
+
const memText = formatRecalledMemories(selected);
|
|
187
|
+
return text(memText || 'No relevant memories found within budget.');
|
|
188
|
+
}
|
|
189
|
+
return json({
|
|
190
|
+
budgetTokens,
|
|
191
|
+
usedTokens,
|
|
192
|
+
includedCount: selected.length,
|
|
193
|
+
candidateCount: candidates.length,
|
|
194
|
+
results: selected.map((r) => ({
|
|
195
|
+
id: r.chunk.id,
|
|
196
|
+
content: r.chunk.content,
|
|
197
|
+
type: r.chunk.type,
|
|
198
|
+
layer: r.chunk.cognitiveLayer,
|
|
199
|
+
tier: r.chunk.tier,
|
|
200
|
+
domain: r.chunk.domain || undefined,
|
|
201
|
+
topic: r.chunk.topic || undefined,
|
|
202
|
+
tags: r.chunk.tags.length > 0 ? r.chunk.tags : undefined,
|
|
203
|
+
source: r.chunk.source || undefined,
|
|
204
|
+
createdAt: r.chunk.createdAt || undefined,
|
|
205
|
+
importance: r.chunk.importance,
|
|
206
|
+
score: Math.round(r.score * 1000) / 1000,
|
|
207
|
+
})),
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
server.registerTool('memory_ingest', {
|
|
211
|
+
title: 'Save Memory',
|
|
212
|
+
description: 'Save a fact, preference, decision, correction, or context to long-term memory. Auto-classifies type/tags if omitted. Auto-checks for duplicates before saving unless skipDedupe=true.',
|
|
213
|
+
inputSchema: z.object({
|
|
214
|
+
content: z.string().describe('The memory to store.'),
|
|
215
|
+
type: z.enum(['fact', 'preference', 'decision', 'context', 'correction']).optional().describe('Memory type.'),
|
|
216
|
+
importance: z.number().min(0).max(1).optional().describe('Importance 0.0-1.0 (default: 0.5).'),
|
|
217
|
+
tags: z.string().optional().describe('Comma-separated tags.'),
|
|
218
|
+
source: z.string().optional().describe('Source identifier (e.g. stable sourceId from an upstream system). Stored on the chunk and returned on search.'),
|
|
219
|
+
domain: z.string().optional().describe('Domain/project namespace.'),
|
|
220
|
+
topic: z.string().optional().describe('Topic within the domain.'),
|
|
221
|
+
sentiment: z.enum(['frustrated', 'curious', 'satisfied', 'neutral', 'excited', 'confused']).optional().describe('Emotional sentiment from Persona.'),
|
|
222
|
+
emotionalValence: z.number().min(-1).max(1).optional().describe('Emotional valence from Persona. Boosts importance for charged memories.'),
|
|
223
|
+
emotionalArousal: z.number().min(0).max(1).optional().describe('Emotional arousal from Persona. High arousal = stronger encoding.'),
|
|
224
|
+
skipDedupe: z.boolean().optional().describe('If true, bypass the 0.75-similarity duplicate check. Use when the caller is writing structured refinements of prior memories (e.g. action items derived from a meeting note) and dedupe would swallow the write.'),
|
|
225
|
+
origin: z.enum(['user', 'derived', 'extracted', 'imported']).optional().describe('Provenance. Default "user" — explicit ingest is treated as user-asserted and protected from auto-merge / archive. Set "derived" when the caller is a downstream pipeline writing inferences.'),
|
|
226
|
+
tier: z.enum(['scratch', 'short-term']).optional().describe('Memory tier. "scratch" = session-only, never promoted by consolidation, auto-purged after 24h. Use for exploratory notes you may want to discard. Default short-term.'),
|
|
227
|
+
createdAt: z.string().optional().describe('ISO 8601 timestamp override. Default: ingest time (now). Use this when ingesting memories that ORIGINALLY happened at a different time — meeting notes from yesterday, chat history from last week, dated documents from years ago. The timestamp flows into the contextual prefix embedded with the content, giving the retrieval pipeline a temporal signal it would otherwise lose. Critical for benchmarks (LoCoMo) and real workloads that backfill historical context (Cortex ingest of dated docs, importing chat history from Slack/Discord).'),
|
|
228
|
+
skipKgExtraction: z.boolean().optional().describe('Skip the per-chunk knowledge-graph triple extraction. Production users should leave this off — KG extraction powers memory_dossier, memory_kg_query, and graph-aware reranking. Benchmark harnesses comparing apples-to-apples vs the standalone locomo bench (which bypasses wal.ts entirely) should set this to true so they measure the same code path.'),
|
|
229
|
+
skipDailyEntry: z.boolean().optional().describe('Skip the post-batch daily-entry append. Production users should leave this off — daily entries power memory_diary_read and cross-session summaries. Benchmark harnesses set this true alongside skipKgExtraction to match the standalone bench setup.'),
|
|
230
|
+
awaitSideEffects: z.boolean().optional().describe('When false, KG extraction + daily-entry append run in the BACKGROUND after the chunks land on disk; memory_ingest returns ~5-30x faster. Default true (caller awaits everything). Right for production paths where the agent doesn\'t immediately query the just-written content (chat WAL, vault → Engram bridge). Sync mode (true) is right when the caller WILL query within the same turn — bench harnesses, test fixtures, multi-step extraction pipelines.'),
|
|
231
|
+
}),
|
|
232
|
+
}, async ({ content, type, importance, tags, source, domain, topic, sentiment, emotionalValence, emotionalArousal, skipDedupe, origin, tier, createdAt, skipKgExtraction, skipDailyEntry, awaitSideEffects }) => {
|
|
233
|
+
const storage = await ensureStorage();
|
|
234
|
+
// Auto duplicate check (replaces old memory_check_duplicate tool). Callers
|
|
235
|
+
// writing intentional refinements can bypass via skipDedupe=true.
|
|
236
|
+
if (!skipDedupe) {
|
|
237
|
+
const dupeResults = await search(config, storage, content, 5);
|
|
238
|
+
const similar = dupeResults.filter(r => r.score >= 0.75);
|
|
239
|
+
if (similar.length > 0) {
|
|
240
|
+
return json({
|
|
241
|
+
ingested: 0,
|
|
242
|
+
duplicate: true,
|
|
243
|
+
similar: similar.map(r => ({
|
|
244
|
+
id: r.chunk.id,
|
|
245
|
+
content: r.chunk.content,
|
|
246
|
+
score: Math.round(r.score * 1000) / 1000,
|
|
247
|
+
})),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const chunks = await ingest(config, storage, [{
|
|
252
|
+
content,
|
|
253
|
+
type: type,
|
|
254
|
+
importance,
|
|
255
|
+
tags: tags?.split(',').map(t => t.trim()),
|
|
256
|
+
...(source ? { source } : {}),
|
|
257
|
+
domain,
|
|
258
|
+
topic,
|
|
259
|
+
sentiment: sentiment,
|
|
260
|
+
emotionalValence,
|
|
261
|
+
emotionalArousal,
|
|
262
|
+
origin: origin ?? 'user',
|
|
263
|
+
...(createdAt ? { createdAt } : {}),
|
|
264
|
+
...(skipKgExtraction ? { skipKgExtraction: true } : {}),
|
|
265
|
+
...(skipDailyEntry ? { skipDailyEntry: true } : {}),
|
|
266
|
+
...(awaitSideEffects === false ? { awaitSideEffects: false } : {}),
|
|
267
|
+
tier,
|
|
268
|
+
}]);
|
|
269
|
+
return json({
|
|
270
|
+
ingested: chunks.length,
|
|
271
|
+
memory: chunks[0] ? {
|
|
272
|
+
id: chunks[0].id,
|
|
273
|
+
content: chunks[0].content,
|
|
274
|
+
type: chunks[0].type,
|
|
275
|
+
layer: chunks[0].cognitiveLayer,
|
|
276
|
+
domain: chunks[0].domain || undefined,
|
|
277
|
+
topic: chunks[0].topic || undefined,
|
|
278
|
+
} : null,
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
// memory_update_metadata — patch metadata-shape fields on an existing
|
|
282
|
+
// memory by id. Closes a gap that callers (e.g. cortex's workspace
|
|
283
|
+
// backfill) hit when they need to correct stamps without re-ingesting
|
|
284
|
+
// (which either dupes or relies on similarity dedupe to overwrite —
|
|
285
|
+
// neither is correct semantics).
|
|
286
|
+
//
|
|
287
|
+
// The "metadata" surface here is engram-native: top-level
|
|
288
|
+
// MemoryChunk fields (tags, source, domain, topic, type, sentiment,
|
|
289
|
+
// importance, cognitiveLayer). Cortex translates its richer
|
|
290
|
+
// metadata.X shape into this surface client-side. Per the north-
|
|
291
|
+
// star: engram changes are generic non-breaking additives, not
|
|
292
|
+
// caller-specific hooks.
|
|
293
|
+
//
|
|
294
|
+
// Mutations of `id`, `createdAt`, and the embedding-related fields
|
|
295
|
+
// (`embedding`, `embeddingVersion`) are rejected — those are either
|
|
296
|
+
// immutable identity or computed from content. Callers wanting to
|
|
297
|
+
// re-embed should re-ingest with skipDedupe.
|
|
298
|
+
server.registerTool('memory_update_metadata', {
|
|
299
|
+
title: 'Update Memory Metadata',
|
|
300
|
+
description: 'Patch metadata-shape fields on an existing memory by id. Use to correct mis-stamped tags/source/domain/topic without re-ingesting (which would either duplicate or rely on similarity dedupe to overwrite). Mode "merge" (default) only updates specified fields; "replace" wipes unset fields to defaults — footgun-y, used sparingly. Rejects mutations of id, createdAt, embedding (re-embedding requires re-ingest with skipDedupe).',
|
|
301
|
+
inputSchema: z.object({
|
|
302
|
+
id: z.string().describe('Memory id to patch.'),
|
|
303
|
+
metadata: z.object({
|
|
304
|
+
tags: z.array(z.string()).optional().describe('Replacement tags array. To add/remove individual tags, callers fetch first, modify, write back.'),
|
|
305
|
+
source: z.string().optional(),
|
|
306
|
+
domain: z.string().optional(),
|
|
307
|
+
topic: z.string().optional(),
|
|
308
|
+
type: z.enum(['fact', 'preference', 'decision', 'context', 'correction']).optional(),
|
|
309
|
+
sentiment: z.enum(['frustrated', 'curious', 'satisfied', 'neutral', 'excited', 'confused']).optional(),
|
|
310
|
+
importance: z.number().min(0).max(1).optional(),
|
|
311
|
+
cognitiveLayer: z.string().optional(),
|
|
312
|
+
}).describe('Partial metadata to apply.'),
|
|
313
|
+
mode: z.enum(['merge', 'replace']).optional().describe('Default merge — patch only specified keys. Replace — clear unspecified metadata fields to defaults.'),
|
|
314
|
+
}),
|
|
315
|
+
}, async ({ id, metadata, mode }) => {
|
|
316
|
+
const storage = await ensureStorage();
|
|
317
|
+
const existing = await storage.getChunk(id);
|
|
318
|
+
if (!existing) {
|
|
319
|
+
return json({ error: 'not_found', id });
|
|
320
|
+
}
|
|
321
|
+
const effectiveMode = mode ?? 'merge';
|
|
322
|
+
// Build the patch. In merge mode, only carry fields the caller set.
|
|
323
|
+
// In replace mode, fields the caller didn't set get reset to engram
|
|
324
|
+
// defaults (matches what fresh ingest would produce). Either way,
|
|
325
|
+
// immutable fields (id, createdAt, embedding) stay locked.
|
|
326
|
+
const patch = buildUpdateMetadataPatch(metadata, effectiveMode);
|
|
327
|
+
if (effectiveMode === 'replace') {
|
|
328
|
+
process.stderr.write(`[engram] memory_update_metadata mode=replace id=${id} — caller wiped unset metadata fields to defaults\n`);
|
|
329
|
+
}
|
|
330
|
+
// Compute a lightweight diff for the audit line (existing vs patch),
|
|
331
|
+
// limited to the keys the patch actually touches so we don't log the
|
|
332
|
+
// whole memory blob.
|
|
333
|
+
const diff = {};
|
|
334
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
335
|
+
const before = existing[key];
|
|
336
|
+
diff[key] = { from: before, to: value };
|
|
337
|
+
}
|
|
338
|
+
process.stderr.write(`[engram] memory_update_metadata id=${id} mode=${effectiveMode} diff=${JSON.stringify(diff)}\n`);
|
|
339
|
+
await storage.updateChunk(id, patch);
|
|
340
|
+
const updated = await storage.getChunk(id);
|
|
341
|
+
if (!updated) {
|
|
342
|
+
// Shouldn't happen — getChunk just returned for the same id.
|
|
343
|
+
return json({ error: 'updated_not_found', id });
|
|
344
|
+
}
|
|
345
|
+
return json({
|
|
346
|
+
updated: {
|
|
347
|
+
id: updated.id,
|
|
348
|
+
content: updated.content,
|
|
349
|
+
type: updated.type,
|
|
350
|
+
tags: updated.tags,
|
|
351
|
+
source: updated.source,
|
|
352
|
+
domain: updated.domain,
|
|
353
|
+
topic: updated.topic,
|
|
354
|
+
sentiment: updated.sentiment,
|
|
355
|
+
importance: updated.importance,
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
server.registerTool('memory_scratch_promote', {
|
|
360
|
+
title: 'Promote Scratch Memory',
|
|
361
|
+
description: 'Graduate a scratch-tier memory to short-term so it survives the 24h auto-purge and enters the normal consolidation lifecycle. Use after deciding an exploratory note is worth keeping.',
|
|
362
|
+
inputSchema: z.object({
|
|
363
|
+
id: z.string().describe('Scratch chunk id to promote.'),
|
|
364
|
+
}),
|
|
365
|
+
}, async ({ id }) => {
|
|
366
|
+
const storage = await ensureStorage();
|
|
367
|
+
const existing = await storage.getChunk(id);
|
|
368
|
+
if (!existing)
|
|
369
|
+
return json({ error: 'not_found', id });
|
|
370
|
+
if (existing.tier !== 'scratch') {
|
|
371
|
+
return json({ error: 'not_scratch', id, currentTier: existing.tier });
|
|
372
|
+
}
|
|
373
|
+
await storage.updateChunk(id, { tier: 'short-term' });
|
|
374
|
+
return json({ promoted: true, id, from: 'scratch', to: 'short-term' });
|
|
375
|
+
});
|
|
376
|
+
server.registerTool('memory_extract', {
|
|
377
|
+
title: 'Extract Memories',
|
|
378
|
+
description: 'Extract memories from a conversation. Uses LLM or heuristic fallback. Set rulesOnly=true to extract procedural rules only.',
|
|
379
|
+
inputSchema: z.object({
|
|
380
|
+
messages: z.string().describe('JSON string of message array: [{role: "user", content: "..."}, ...]'),
|
|
381
|
+
conversationId: z.string().optional().describe('Session/conversation identifier.'),
|
|
382
|
+
rulesOnly: z.boolean().optional().describe('If true, only extract procedural rules.'),
|
|
383
|
+
}),
|
|
384
|
+
}, async ({ messages, conversationId, rulesOnly }) => {
|
|
385
|
+
const storage = await ensureStorage();
|
|
386
|
+
const parsed = JSON.parse(messages);
|
|
387
|
+
const convId = conversationId ?? `mcp-${Date.now()}`;
|
|
388
|
+
// Rules-only mode (replaces old memory_extract_rules tool)
|
|
389
|
+
if (rulesOnly) {
|
|
390
|
+
await extractRules(config, storage, parsed);
|
|
391
|
+
const rules = await formatRulesForPrompt(storage);
|
|
392
|
+
return text(rules || 'No procedural rules extracted.');
|
|
393
|
+
}
|
|
394
|
+
const allChunks = [];
|
|
395
|
+
if (config.extractionProvider === 'local' || config.extractionProvider === 'both') {
|
|
396
|
+
const chunks = await extractFromConversation(config, storage, parsed, convId);
|
|
397
|
+
allChunks.push(...chunks.map(c => ({
|
|
398
|
+
id: c.id, content: c.content, type: c.type,
|
|
399
|
+
layer: c.cognitiveLayer, importance: c.importance,
|
|
400
|
+
source: isLlmAvailable() ? 'llm' : 'heuristic',
|
|
401
|
+
})));
|
|
402
|
+
}
|
|
403
|
+
if (config.extractionProvider === 'mem0' || config.extractionProvider === 'both') {
|
|
404
|
+
const chunks = await mem0Extract(config, storage, parsed, convId);
|
|
405
|
+
allChunks.push(...chunks.map(c => ({
|
|
406
|
+
id: c.id, content: c.content, type: c.type,
|
|
407
|
+
layer: c.cognitiveLayer, importance: c.importance, source: 'mem0',
|
|
408
|
+
})));
|
|
409
|
+
}
|
|
410
|
+
return json({ extracted: allChunks.length, memories: allChunks });
|
|
411
|
+
});
|
|
412
|
+
server.registerTool('memory_maintain', {
|
|
413
|
+
title: 'Consolidate',
|
|
414
|
+
description: 'Run memory consolidation: decay, promote/demote tiers, link related, merge duplicates, self-organize, and sync Persona bridge.',
|
|
415
|
+
inputSchema: z.object({}),
|
|
416
|
+
}, async () => {
|
|
417
|
+
const storage = await ensureStorage();
|
|
418
|
+
const stats = await consolidate(storage, config);
|
|
419
|
+
// Auto-sync procedural bridge during maintenance
|
|
420
|
+
let bridgeSync = { exported: 0, imported: 0, reinforced: 0, conflicts: 0 };
|
|
421
|
+
try {
|
|
422
|
+
bridgeSync = await syncBridge(storage);
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
// Bridge sync is best-effort
|
|
426
|
+
}
|
|
427
|
+
return json({ action: 'consolidation', ...stats, bridge: bridgeSync });
|
|
428
|
+
});
|
|
429
|
+
server.registerTool('memory_rules', {
|
|
430
|
+
title: 'Procedural Rules',
|
|
431
|
+
description: 'Show active procedural rules learned from corrections and preferences.',
|
|
432
|
+
inputSchema: z.object({}),
|
|
433
|
+
}, async () => {
|
|
434
|
+
const storage = await ensureStorage();
|
|
435
|
+
const t = await formatRulesForPrompt(storage);
|
|
436
|
+
return text(t || 'No active procedural rules.');
|
|
437
|
+
});
|
|
438
|
+
server.registerTool('memory_outcome', {
|
|
439
|
+
title: 'Recall Outcome',
|
|
440
|
+
description: 'Record whether recalled memories were helpful, corrected, or irrelevant. Adjusts importance.',
|
|
441
|
+
inputSchema: z.object({
|
|
442
|
+
outcome: z.enum(['helpful', 'corrected', 'irrelevant']).describe('Outcome.'),
|
|
443
|
+
chunkIds: z.string().describe('Comma-separated memory chunk IDs.'),
|
|
444
|
+
}),
|
|
445
|
+
}, async ({ outcome, chunkIds }) => {
|
|
446
|
+
const storage = await ensureStorage();
|
|
447
|
+
const ids = chunkIds.split(',').map(id => id.trim());
|
|
448
|
+
await recordRecallOutcome(config, storage, ids, outcome, `mcp-${Date.now()}`);
|
|
449
|
+
return text(`Recorded ${outcome} outcome for ${ids.length} chunk(s).`);
|
|
450
|
+
});
|
|
451
|
+
server.registerTool('memory_session', {
|
|
452
|
+
title: 'Session State',
|
|
453
|
+
description: 'Manage session state (hot RAM). Actions: show, task, context, decision, action, clear.',
|
|
454
|
+
inputSchema: z.object({
|
|
455
|
+
action: z.enum(['show', 'task', 'context', 'decision', 'action', 'clear']).describe('Action.'),
|
|
456
|
+
value: z.string().optional().describe('Value (required for task/context/decision/action).'),
|
|
457
|
+
}),
|
|
458
|
+
}, async ({ action, value }) => {
|
|
459
|
+
switch (action) {
|
|
460
|
+
case 'show':
|
|
461
|
+
return json(readSessionState(config.dataDir));
|
|
462
|
+
case 'task':
|
|
463
|
+
updateSessionState(config.dataDir, { currentTask: value ?? '' });
|
|
464
|
+
return text(`Task set: ${value}`);
|
|
465
|
+
case 'context':
|
|
466
|
+
appendToSessionState(config.dataDir, 'keyContext', value ?? '');
|
|
467
|
+
return text(`Context added: ${value}`);
|
|
468
|
+
case 'decision':
|
|
469
|
+
appendToSessionState(config.dataDir, 'recentDecisions', value ?? '');
|
|
470
|
+
return text(`Decision recorded: ${value}`);
|
|
471
|
+
case 'action':
|
|
472
|
+
appendToSessionState(config.dataDir, 'pendingActions', { text: value ?? '', done: false });
|
|
473
|
+
return text(`Action added: ${value}`);
|
|
474
|
+
case 'clear':
|
|
475
|
+
clearSessionState(config.dataDir);
|
|
476
|
+
return text('Session state cleared.');
|
|
477
|
+
default:
|
|
478
|
+
return text(`Unknown action: ${action}`);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
server.registerTool('memory_stats', {
|
|
482
|
+
title: 'Stats',
|
|
483
|
+
description: 'Memory system stats: chunks by tier/layer/type, rules, knowledge graph, bridge status, and taxonomy.',
|
|
484
|
+
inputSchema: z.object({}),
|
|
485
|
+
}, async () => {
|
|
486
|
+
const storage = await ensureStorage();
|
|
487
|
+
const all = await storage.listChunks();
|
|
488
|
+
const tiers = {};
|
|
489
|
+
const layers = {};
|
|
490
|
+
const types = {};
|
|
491
|
+
for (const c of all) {
|
|
492
|
+
tiers[c.tier] = (tiers[c.tier] ?? 0) + 1;
|
|
493
|
+
layers[c.cognitiveLayer] = (layers[c.cognitiveLayer] ?? 0) + 1;
|
|
494
|
+
types[c.type] = (types[c.type] ?? 0) + 1;
|
|
495
|
+
}
|
|
496
|
+
const rules = await storage.getRules();
|
|
497
|
+
const kgStats = await getGraphStats(storage);
|
|
498
|
+
const state = readSessionState(config.dataDir);
|
|
499
|
+
const diaryDates = listDiaryDates(config.dataDir);
|
|
500
|
+
// Taxonomy (folded in from old memory_taxonomy tool)
|
|
501
|
+
const tree = await storage.getTaxonomy();
|
|
502
|
+
// Bridge status (new observability)
|
|
503
|
+
let bridge = { status: 'no bridge file' };
|
|
504
|
+
try {
|
|
505
|
+
const bridgeFile = loadBridgeFile();
|
|
506
|
+
bridge = {
|
|
507
|
+
lastUpdated: bridgeFile.lastUpdated,
|
|
508
|
+
totalRules: bridgeFile.rules.length,
|
|
509
|
+
engramRules: bridgeFile.rules.filter(r => r.source === 'engram').length,
|
|
510
|
+
personaRules: bridgeFile.rules.filter(r => r.source === 'persona').length,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
catch { /* no bridge file */ }
|
|
514
|
+
return json({
|
|
515
|
+
totalChunks: all.length,
|
|
516
|
+
byTier: tiers,
|
|
517
|
+
byLayer: layers,
|
|
518
|
+
byType: types,
|
|
519
|
+
proceduralRules: rules.length,
|
|
520
|
+
activeRules: rules.filter(r => r.confidence > 0.3).length,
|
|
521
|
+
knowledgeGraph: kgStats,
|
|
522
|
+
taxonomy: tree,
|
|
523
|
+
bridge,
|
|
524
|
+
diaryEntries: diaryDates.length,
|
|
525
|
+
llmAvailable: isLlmAvailable(),
|
|
526
|
+
embeddingModel: process.env.ENGRAM_EMBEDDING_MODEL ?? process.env.SMART_MEMORY_EMBEDDING_MODEL ?? 'Xenova/all-MiniLM-L6-v2',
|
|
527
|
+
sessionTask: state.currentTask || null,
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
server.registerTool('memory_govern', {
|
|
531
|
+
title: 'Governance Check',
|
|
532
|
+
description: 'Advisory checks: "check" (contradictions), "drift" (semantic drift), "poison" (injection scan), "full" (all).',
|
|
533
|
+
inputSchema: z.object({
|
|
534
|
+
action: z.enum(['check', 'drift', 'poison', 'full']).describe('Governance action.'),
|
|
535
|
+
content: z.string().optional().describe('Content to check (required for "check").'),
|
|
536
|
+
domain: z.string().optional().describe('Filter by domain.'),
|
|
537
|
+
}),
|
|
538
|
+
}, async ({ action, content, domain }) => {
|
|
539
|
+
const storage = await ensureStorage();
|
|
540
|
+
if (action === 'check') {
|
|
541
|
+
if (!content)
|
|
542
|
+
return json({ error: 'Content required for contradiction check.' });
|
|
543
|
+
const result = await detectContradictions(config, storage, content, { domain });
|
|
544
|
+
return json(result);
|
|
545
|
+
}
|
|
546
|
+
if (action === 'full') {
|
|
547
|
+
const report = await runGovernanceCheck(config, storage, { content, domain });
|
|
548
|
+
return json(report);
|
|
549
|
+
}
|
|
550
|
+
if (action === 'drift') {
|
|
551
|
+
const { measureSemanticDrift } = await import('./governance.js');
|
|
552
|
+
const drift = await measureSemanticDrift(config, storage, { domain });
|
|
553
|
+
return json(drift);
|
|
554
|
+
}
|
|
555
|
+
if (action === 'poison') {
|
|
556
|
+
const { checkMemoryPoisoning } = await import('./governance.js');
|
|
557
|
+
const poison = await checkMemoryPoisoning(storage);
|
|
558
|
+
return json(poison);
|
|
559
|
+
}
|
|
560
|
+
return json({ error: 'Unknown action.' });
|
|
561
|
+
});
|
|
562
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
563
|
+
// KNOWLEDGE GRAPH TOOLS
|
|
564
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
565
|
+
server.registerTool('memory_kg_add', {
|
|
566
|
+
title: 'KG Add',
|
|
567
|
+
description: 'Add a subject-predicate-object triple. Use replace=true to auto-invalidate conflicting facts.',
|
|
568
|
+
inputSchema: z.object({
|
|
569
|
+
subject: z.string().describe('Entity (e.g. "Matt").'),
|
|
570
|
+
predicate: z.string().describe('Relationship (e.g. "works-at").'),
|
|
571
|
+
object: z.string().describe('Target (e.g. "Acme Corp").'),
|
|
572
|
+
replace: z.boolean().optional().describe('Invalidate existing triples with same subject+predicate.'),
|
|
573
|
+
confidence: z.number().min(0).max(1).optional().describe('Confidence 0-1 (default: 0.5).'),
|
|
574
|
+
}),
|
|
575
|
+
}, async ({ subject, predicate, object, replace, confidence }) => {
|
|
576
|
+
const storage = await ensureStorage();
|
|
577
|
+
const fn = replace ? replaceTriple : addTriple;
|
|
578
|
+
const triple = await fn(storage, subject, predicate, object, `mcp-${Date.now()}`, confidence);
|
|
579
|
+
return json({ added: true, triple: { id: triple.id, subject: triple.subject, predicate: triple.predicate, object: triple.object } });
|
|
580
|
+
});
|
|
581
|
+
server.registerTool('memory_kg_query', {
|
|
582
|
+
title: 'KG Query',
|
|
583
|
+
description: 'Query knowledge graph triples. Filter by subject, predicate, and/or object.',
|
|
584
|
+
inputSchema: z.object({
|
|
585
|
+
subject: z.string().optional().describe('Filter by subject.'),
|
|
586
|
+
predicate: z.string().optional().describe('Filter by relationship.'),
|
|
587
|
+
object: z.string().optional().describe('Filter by target.'),
|
|
588
|
+
activeOnly: z.boolean().optional().describe('Only valid facts (default: true).'),
|
|
589
|
+
}),
|
|
590
|
+
}, async ({ subject, predicate, object, activeOnly }) => {
|
|
591
|
+
const storage = await ensureStorage();
|
|
592
|
+
const triples = await queryGraph(storage, {
|
|
593
|
+
subject, predicate, object,
|
|
594
|
+
activeOnly: activeOnly ?? true,
|
|
595
|
+
});
|
|
596
|
+
return json({
|
|
597
|
+
count: triples.length,
|
|
598
|
+
triples: triples.map(t => ({
|
|
599
|
+
id: t.id, subject: t.subject, predicate: t.predicate, object: t.object,
|
|
600
|
+
confidence: t.confidence, validFrom: t.validFrom, validTo: t.validTo,
|
|
601
|
+
})),
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
server.registerTool('memory_kg_invalidate', {
|
|
605
|
+
title: 'KG Invalidate',
|
|
606
|
+
description: 'Mark a fact as no longer valid. Stays in history.',
|
|
607
|
+
inputSchema: z.object({
|
|
608
|
+
tripleId: z.string().describe('Triple ID to invalidate.'),
|
|
609
|
+
}),
|
|
610
|
+
}, async ({ tripleId }) => {
|
|
611
|
+
const storage = await ensureStorage();
|
|
612
|
+
await invalidateTriple(storage, tripleId);
|
|
613
|
+
return text(`Triple ${tripleId} invalidated.`);
|
|
614
|
+
});
|
|
615
|
+
server.registerTool('memory_kg_timeline', {
|
|
616
|
+
title: 'KG Timeline',
|
|
617
|
+
description: 'Chronological history of all facts about an entity.',
|
|
618
|
+
inputSchema: z.object({
|
|
619
|
+
entity: z.string().describe('Entity name.'),
|
|
620
|
+
}),
|
|
621
|
+
}, async ({ entity }) => {
|
|
622
|
+
const storage = await ensureStorage();
|
|
623
|
+
const timeline = await getTimeline(storage, entity);
|
|
624
|
+
return json({
|
|
625
|
+
entity,
|
|
626
|
+
facts: timeline.map(t => ({
|
|
627
|
+
subject: t.subject, predicate: t.predicate, object: t.object,
|
|
628
|
+
validFrom: t.validFrom, validTo: t.validTo, active: !t.validTo,
|
|
629
|
+
})),
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
server.registerTool('memory_dossier', {
|
|
633
|
+
title: 'Entity Dossier',
|
|
634
|
+
description: [
|
|
635
|
+
'Aggregate everything Engram knows about an entity (person, project, concept) into a structured snapshot.',
|
|
636
|
+
'Pulls from FOUR sources: (1) KG triples where the entity is subject — definitive facts about the entity; (2) KG triples where the entity is object — facts where others reference the entity (e.g. "Alice reports-to Matt" appears in Matt\'s dossier as referencedBy); (3) memory chunks mentioning the entity in content/tags/topic — preferences, decisions, context; (4) recent activity ordered by createdAt — what came up lately.',
|
|
637
|
+
'Output is grouped by category (facts, preferences, decisions, corrections, recent) so the consumer doesn\'t have to bucket the chunks themselves.',
|
|
638
|
+
'Honors an optional budgetTokens cap; greedy fill within each category when set. Used by Pyre\'s Context Budget Engine to populate "what we know about <X>" slots without spending the entire memories budget on a search-by-relevance grab bag.',
|
|
639
|
+
].join(' '),
|
|
640
|
+
inputSchema: z.object({
|
|
641
|
+
entity: z.string().describe('Entity name. Matches against KG subject, chunk content (substring), tags (exact), and topic (exact). Case-insensitive.'),
|
|
642
|
+
budgetTokens: z.number().min(100).max(50000).optional().describe('Optional token cap for the returned set. When set, each category fills greedy by importance until the per-category share is exhausted (~25% of budget per category). Without budget, returns up to maxPerCategory entries per category.'),
|
|
643
|
+
maxPerCategory: z.number().min(1).max(50).optional().describe('Max entries per category when budgetTokens is omitted (default: 5).'),
|
|
644
|
+
domain: z.string().optional().describe('Optional domain filter (limits dossier to a single project/scope).'),
|
|
645
|
+
}),
|
|
646
|
+
}, async ({ entity, budgetTokens, maxPerCategory, domain }) => {
|
|
647
|
+
const storage = await ensureStorage();
|
|
648
|
+
const cap = maxPerCategory ?? 5;
|
|
649
|
+
const entityLower = entity.toLowerCase();
|
|
650
|
+
// 1a. KG triples where the entity is the subject (active facts).
|
|
651
|
+
// Filtered to active by default — invalidated triples shouldn't
|
|
652
|
+
// surface in a dossier.
|
|
653
|
+
const triples = await queryGraph(storage, {
|
|
654
|
+
subject: entity,
|
|
655
|
+
activeOnly: true,
|
|
656
|
+
});
|
|
657
|
+
// 1b. KG triples where the entity is the OBJECT — facts about the
|
|
658
|
+
// entity asserted from someone else's perspective (e.g.
|
|
659
|
+
// "Alice reports-to Matt" should appear in Matt's dossier as
|
|
660
|
+
// a referencedBy edge). Without this, the dossier only shows
|
|
661
|
+
// outbound relationships and misses inbound ones.
|
|
662
|
+
const referencedBy = await queryGraph(storage, {
|
|
663
|
+
object: entity,
|
|
664
|
+
activeOnly: true,
|
|
665
|
+
});
|
|
666
|
+
// 2. Memory chunks mentioning the entity. Use a generous candidate
|
|
667
|
+
// pool (entity-shaped queries are usually narrower than free-form
|
|
668
|
+
// search), then filter client-side for the substring/tag/topic
|
|
669
|
+
// match so we don't miss chunks the search ranker buried.
|
|
670
|
+
const candidates = await search(config, storage, entity, 100, { domain });
|
|
671
|
+
const matching = candidates.filter((r) => {
|
|
672
|
+
const c = r.chunk;
|
|
673
|
+
return c.content.toLowerCase().includes(entityLower)
|
|
674
|
+
|| c.tags.some((t) => t.toLowerCase() === entityLower)
|
|
675
|
+
|| c.topic.toLowerCase() === entityLower;
|
|
676
|
+
});
|
|
677
|
+
// Bucket by type. "context" maps into recent rather than its own
|
|
678
|
+
// category since context is usually time-sensitive — last week's
|
|
679
|
+
// context is less interesting than last week's preference.
|
|
680
|
+
const buckets = {
|
|
681
|
+
facts: [],
|
|
682
|
+
preferences: [],
|
|
683
|
+
decisions: [],
|
|
684
|
+
corrections: [],
|
|
685
|
+
recent: [],
|
|
686
|
+
};
|
|
687
|
+
for (const r of matching) {
|
|
688
|
+
const t = r.chunk.type;
|
|
689
|
+
if (t === 'fact')
|
|
690
|
+
buckets.facts.push(r);
|
|
691
|
+
else if (t === 'preference')
|
|
692
|
+
buckets.preferences.push(r);
|
|
693
|
+
else if (t === 'decision')
|
|
694
|
+
buckets.decisions.push(r);
|
|
695
|
+
else if (t === 'correction')
|
|
696
|
+
buckets.corrections.push(r);
|
|
697
|
+
// context is intentionally not its own bucket — falls into recent
|
|
698
|
+
}
|
|
699
|
+
// Recent = top-N most recently created chunks across ALL types,
|
|
700
|
+
// independent of category. Catches active context + new
|
|
701
|
+
// facts/preferences regardless of where they bucketed.
|
|
702
|
+
buckets.recent = [...matching]
|
|
703
|
+
.sort((a, b) => (b.chunk.createdAt ?? '').localeCompare(a.chunk.createdAt ?? ''))
|
|
704
|
+
.slice(0, cap);
|
|
705
|
+
// Per-category importance sort + cap.
|
|
706
|
+
for (const k of Object.keys(buckets)) {
|
|
707
|
+
if (k === 'recent')
|
|
708
|
+
continue;
|
|
709
|
+
buckets[k] = buckets[k]
|
|
710
|
+
.sort((a, b) => b.chunk.importance - a.chunk.importance)
|
|
711
|
+
.slice(0, cap);
|
|
712
|
+
}
|
|
713
|
+
// Optional token-budget enforcement. Splits budget evenly across
|
|
714
|
+
// the 5 categories (facts / preferences / decisions / corrections
|
|
715
|
+
// / recent) and greedy-fills each within its share. Same 4
|
|
716
|
+
// chars/token + 30 wrapper estimate as memory_budget.
|
|
717
|
+
let usedTokens = 0;
|
|
718
|
+
if (typeof budgetTokens === 'number' && budgetTokens > 0) {
|
|
719
|
+
const perCategoryBudget = Math.floor(budgetTokens / 5);
|
|
720
|
+
const CHARS_PER_TOKEN = 4;
|
|
721
|
+
const WRAPPER_OVERHEAD = 30;
|
|
722
|
+
for (const k of Object.keys(buckets)) {
|
|
723
|
+
let categoryUsed = 0;
|
|
724
|
+
const filtered = [];
|
|
725
|
+
for (const r of buckets[k]) {
|
|
726
|
+
const t = Math.ceil(r.chunk.content.length / CHARS_PER_TOKEN) + WRAPPER_OVERHEAD;
|
|
727
|
+
if (categoryUsed + t > perCategoryBudget)
|
|
728
|
+
continue;
|
|
729
|
+
filtered.push(r);
|
|
730
|
+
categoryUsed += t;
|
|
731
|
+
usedTokens += t;
|
|
732
|
+
}
|
|
733
|
+
buckets[k] = filtered;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const renderBucket = (entries) => entries.map((r) => ({
|
|
737
|
+
id: r.chunk.id,
|
|
738
|
+
content: r.chunk.content,
|
|
739
|
+
type: r.chunk.type,
|
|
740
|
+
importance: r.chunk.importance,
|
|
741
|
+
createdAt: r.chunk.createdAt || undefined,
|
|
742
|
+
domain: r.chunk.domain || undefined,
|
|
743
|
+
topic: r.chunk.topic || undefined,
|
|
744
|
+
tags: r.chunk.tags.length > 0 ? r.chunk.tags : undefined,
|
|
745
|
+
}));
|
|
746
|
+
return json({
|
|
747
|
+
entity,
|
|
748
|
+
budgetTokens: budgetTokens ?? null,
|
|
749
|
+
usedTokens: budgetTokens ? usedTokens : undefined,
|
|
750
|
+
kgFacts: triples.map((t) => ({
|
|
751
|
+
id: t.id,
|
|
752
|
+
predicate: t.predicate,
|
|
753
|
+
object: t.object,
|
|
754
|
+
confidence: t.confidence,
|
|
755
|
+
validFrom: t.validFrom,
|
|
756
|
+
})),
|
|
757
|
+
referencedBy: referencedBy.map((t) => ({
|
|
758
|
+
id: t.id,
|
|
759
|
+
subject: t.subject,
|
|
760
|
+
predicate: t.predicate,
|
|
761
|
+
confidence: t.confidence,
|
|
762
|
+
validFrom: t.validFrom,
|
|
763
|
+
})),
|
|
764
|
+
facts: renderBucket(buckets.facts),
|
|
765
|
+
preferences: renderBucket(buckets.preferences),
|
|
766
|
+
decisions: renderBucket(buckets.decisions),
|
|
767
|
+
corrections: renderBucket(buckets.corrections),
|
|
768
|
+
recent: renderBucket(buckets.recent),
|
|
769
|
+
candidateCount: matching.length,
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
773
|
+
// DIARY TOOLS
|
|
774
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
775
|
+
server.registerTool('memory_diary_write', {
|
|
776
|
+
title: 'Write Diary',
|
|
777
|
+
description: 'Write a session diary entry. Record what happened, what was decided, what matters next.',
|
|
778
|
+
inputSchema: z.object({
|
|
779
|
+
content: z.string().describe('Diary entry.'),
|
|
780
|
+
agent: z.string().optional().describe('Agent name (default: "claude").'),
|
|
781
|
+
}),
|
|
782
|
+
}, async ({ content, agent }) => {
|
|
783
|
+
const entry = writeDiaryEntry(config.dataDir, content, agent);
|
|
784
|
+
return json({ written: true, date: entry.date, time: entry.time, agent: entry.agent });
|
|
785
|
+
});
|
|
786
|
+
server.registerTool('memory_diary_read', {
|
|
787
|
+
title: 'Read Diary',
|
|
788
|
+
description: 'Read diary entries from recent days or a specific date.',
|
|
789
|
+
inputSchema: z.object({
|
|
790
|
+
date: z.string().optional().describe('YYYY-MM-DD. If omitted, returns recent.'),
|
|
791
|
+
daysBack: z.number().optional().describe('Days to look back (default: 7).'),
|
|
792
|
+
agent: z.string().optional().describe('Filter by agent.'),
|
|
793
|
+
}),
|
|
794
|
+
}, async ({ date, daysBack, agent }) => {
|
|
795
|
+
const entries = readDiary(config.dataDir, { date, daysBack, agent });
|
|
796
|
+
return json(entries);
|
|
797
|
+
});
|
|
798
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
799
|
+
// HANDOFF TOOLS — cross-session "where we left off" lifeline
|
|
800
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
801
|
+
server.registerTool('memory_handoff_write', {
|
|
802
|
+
title: 'Write Handoff Note',
|
|
803
|
+
description: 'Write a structured "where we left off" snapshot (a.k.a. session checkpoint). Call BEFORE /compact, before session end, when context_pressure returns hot/critical, or when the user asks to "save this session." Pass an optional `name` (e.g. "engram-named-checkpoints") so the user can later list-and-pick rather than scanning timestamps. This is the lifeline if the context window fills before compaction runs.',
|
|
804
|
+
inputSchema: z.object({
|
|
805
|
+
currentTask: z.string().describe('One-sentence description of what you are working on.'),
|
|
806
|
+
name: z.string().optional().describe('Human-friendly checkpoint name (kebab-case recommended) for list-and-pick resume. Optional — omit for an unnamed timestamped handoff.'),
|
|
807
|
+
reason: z.enum(['compact', 'session-end', 'manual', 'context-pressure']).optional().describe('Why this handoff is being written (default: manual).'),
|
|
808
|
+
sessionId: z.string().optional().describe('Session/conversation ID for cross-referencing.'),
|
|
809
|
+
completed: z.string().optional().describe('Comma-separated list of what has been completed this session.'),
|
|
810
|
+
nextSteps: z.string().optional().describe('Comma-separated concrete next actions to take on resume.'),
|
|
811
|
+
openQuestions: z.string().optional().describe('Comma-separated unresolved questions or blockers.'),
|
|
812
|
+
fileRefs: z.string().optional().describe('Comma-separated file paths (ideally path:line) the next agent needs.'),
|
|
813
|
+
decisions: z.string().optional().describe('Comma-separated key decisions made this session.'),
|
|
814
|
+
notes: z.string().optional().describe('Free-form additional context, quirks, gotchas.'),
|
|
815
|
+
}),
|
|
816
|
+
}, async ({ currentTask, name, reason, sessionId, completed, nextSteps, openQuestions, fileRefs, decisions, notes }) => {
|
|
817
|
+
const splitCsv = (s) => s ? s.split(',').map(x => x.trim()).filter(Boolean) : [];
|
|
818
|
+
const note = writeHandoff(config.dataDir, {
|
|
819
|
+
...(name ? { name } : {}),
|
|
820
|
+
sessionId: sessionId ?? null,
|
|
821
|
+
reason: reason ?? 'manual',
|
|
822
|
+
currentTask,
|
|
823
|
+
completed: splitCsv(completed),
|
|
824
|
+
nextSteps: splitCsv(nextSteps),
|
|
825
|
+
openQuestions: splitCsv(openQuestions),
|
|
826
|
+
fileRefs: splitCsv(fileRefs),
|
|
827
|
+
decisions: splitCsv(decisions),
|
|
828
|
+
notes: notes ?? '',
|
|
829
|
+
});
|
|
830
|
+
return json({
|
|
831
|
+
written: true,
|
|
832
|
+
timestamp: note.timestamp,
|
|
833
|
+
name: note.name,
|
|
834
|
+
reason: note.reason,
|
|
835
|
+
summary: note.currentTask,
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
server.registerTool('memory_handoff_read', {
|
|
839
|
+
title: 'Read Handoff Note',
|
|
840
|
+
description: 'Read a saved handoff/checkpoint. With no arg, returns the most recent. Pass `name` to load a named checkpoint, or `stamp` to load a specific timestamp. Set `list=true` to get recent checkpoints (deprecated — prefer memory_handoff_list).',
|
|
841
|
+
inputSchema: z.object({
|
|
842
|
+
name: z.string().optional().describe('Named checkpoint to load (e.g. "engram-named-checkpoints"). Takes precedence over stamp if both are provided.'),
|
|
843
|
+
stamp: z.string().optional().describe('Handoff stamp to load (e.g. "2026-04-20_14-32-05"). If omitted and no name, returns the latest.'),
|
|
844
|
+
list: z.boolean().optional().describe('Deprecated — use memory_handoff_list. If true, lists recent checkpoints.'),
|
|
845
|
+
limit: z.number().min(1).max(50).optional().describe('For list mode: max entries to return (default 10).'),
|
|
846
|
+
}),
|
|
847
|
+
}, async ({ name, stamp, list, limit }) => {
|
|
848
|
+
if (list) {
|
|
849
|
+
return json({ handoffs: listHandoffs(config.dataDir, limit ?? 10) });
|
|
850
|
+
}
|
|
851
|
+
const note = readHandoff(config.dataDir, name ?? stamp);
|
|
852
|
+
if (!note) {
|
|
853
|
+
const identifier = name ?? stamp;
|
|
854
|
+
return json({
|
|
855
|
+
found: false,
|
|
856
|
+
message: identifier
|
|
857
|
+
? `No handoff found matching "${identifier}". Use memory_handoff_list to see saved checkpoints.`
|
|
858
|
+
: 'No handoff note available.',
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
return json({ found: true, ...note });
|
|
862
|
+
});
|
|
863
|
+
server.registerTool('memory_handoff_list', {
|
|
864
|
+
title: 'List Handoff Checkpoints',
|
|
865
|
+
description: 'List recent saved handoffs/checkpoints, newest first. Each entry includes stamp, timestamp, reason, currentTask snippet, and (if set) the user-facing name. Call this when the user asks to "resume" or "pick up where we left off" so you can present options before loading one with memory_handoff_read.',
|
|
866
|
+
inputSchema: z.object({
|
|
867
|
+
limit: z.number().min(1).max(50).optional().describe('Max checkpoints to return (default 10, max 50).'),
|
|
868
|
+
}),
|
|
869
|
+
}, async ({ limit }) => {
|
|
870
|
+
return json({ handoffs: listHandoffs(config.dataDir, limit ?? 10) });
|
|
871
|
+
});
|
|
872
|
+
server.registerTool('memory_context_pressure', {
|
|
873
|
+
title: 'Context Pressure Check',
|
|
874
|
+
description: 'Self-assess context window pressure and get an action plan. Call periodically during long sessions — especially after big tool outputs, many file reads, or when responses feel sluggish. Levels: ok, warm, hot, critical. Also call with phaseBoundary=true at natural phase boundaries (task complete, pivoting focus, finishing a subsystem) — pivots thrash the cache anyway, so that is the RIGHT moment to compact. Returns an ordered actionPlan telling you exactly what to do (save memories, write handoff, compact).',
|
|
875
|
+
inputSchema: z.object({
|
|
876
|
+
level: z.enum(['ok', 'warm', 'hot', 'critical']).describe('Your honest assessment of current context pressure.'),
|
|
877
|
+
reason: z.string().optional().describe('What triggered this check (e.g. "long file reads", "extended session", "near token limit", "phase complete").'),
|
|
878
|
+
phaseBoundary: z.boolean().optional().describe('True when a task/phase just finished or you are about to pivot focus. Forces the action plan toward a proactive compact, even at ok/warm levels.'),
|
|
879
|
+
}),
|
|
880
|
+
}, async ({ level, reason, phaseBoundary }) => {
|
|
881
|
+
return json(assessPressure(level, reason ?? '', phaseBoundary ?? false));
|
|
882
|
+
});
|
|
883
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
884
|
+
// DIAGNOSTIC RETRIEVAL TRACES
|
|
885
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
886
|
+
server.registerTool('memory_trace_recent', {
|
|
887
|
+
title: 'Recent Retrieval Traces',
|
|
888
|
+
description: [
|
|
889
|
+
'List the most recent diagnostic retrieval traces. Each trace captures: query text, filters, per-stage candidate counts (corpus → vector above/below floor → keyword → final), result IDs, and total latency.',
|
|
890
|
+
'Use this when investigating "why didn\'t you find the obvious doc" complaints — the trace shows whether the result was retrieved at all, whether it survived the floor, and which stage dropped it.',
|
|
891
|
+
'Traces only persist when ENGRAM_ENABLE_RETRIEVAL_TRACES=true (default off). Returns an empty list when traces are disabled or no searches have run.',
|
|
892
|
+
].join(' '),
|
|
893
|
+
inputSchema: z.object({
|
|
894
|
+
limit: z.number().min(1).max(200).optional().describe('Max traces to return (default: 25, max: 200).'),
|
|
895
|
+
}),
|
|
896
|
+
}, async ({ limit }) => {
|
|
897
|
+
if (!config.enableRetrievalTraces) {
|
|
898
|
+
return json({
|
|
899
|
+
enabled: false,
|
|
900
|
+
traces: [],
|
|
901
|
+
note: 'Retrieval traces are disabled. Enable with ENGRAM_ENABLE_RETRIEVAL_TRACES=true (then restart Engram).',
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
const traces = await listRecentTraces({ dataDir: config.dataDir, retentionDays: config.retrievalTraceRetentionDays }, limit ?? 25);
|
|
905
|
+
return json({
|
|
906
|
+
enabled: true,
|
|
907
|
+
retentionDays: config.retrievalTraceRetentionDays,
|
|
908
|
+
count: traces.length,
|
|
909
|
+
traces,
|
|
910
|
+
});
|
|
911
|
+
});
|
|
912
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
913
|
+
// IMPORT
|
|
914
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
915
|
+
server.registerTool('memory_import', {
|
|
916
|
+
title: 'Import',
|
|
917
|
+
description: 'Bulk import from chat exports: claude-jsonl, chatgpt-json, or plain-text.',
|
|
918
|
+
inputSchema: z.object({
|
|
919
|
+
format: z.enum(['claude-jsonl', 'chatgpt-json', 'plain-text']).describe('Export format.'),
|
|
920
|
+
content: z.string().describe('Raw export content.'),
|
|
921
|
+
}),
|
|
922
|
+
}, async ({ format, content }) => {
|
|
923
|
+
const storage = await ensureStorage();
|
|
924
|
+
const result = await importConversation(config, storage, format, content);
|
|
925
|
+
return json(result);
|
|
926
|
+
});
|
|
927
|
+
// ── Start Server ────────────────────────────────────────────────────
|
|
928
|
+
async function main() {
|
|
929
|
+
const transport = new StdioServerTransport();
|
|
930
|
+
await server.connect(transport);
|
|
931
|
+
console.error('Engram MCP server running on stdio');
|
|
932
|
+
console.error(`Data dir: ${config.dataDir}`);
|
|
933
|
+
console.error(`LLM: ${isLlmAvailable() ? 'enabled' : 'disabled (heuristic mode)'}`);
|
|
934
|
+
console.error(`Embeddings: local (${process.env.ENGRAM_EMBEDDING_MODEL ?? process.env.SMART_MEMORY_EMBEDDING_MODEL ?? 'Xenova/all-MiniLM-L6-v2'})`);
|
|
935
|
+
console.error(`Mem0: ${config.mem0ApiKey ? 'enabled' : 'disabled'}`);
|
|
936
|
+
console.error(`Retrieval traces: ${config.enableRetrievalTraces ? `enabled (${config.retrievalTraceRetentionDays}d retention)` : 'disabled'}`);
|
|
937
|
+
// Best-effort trace GC on startup. Drops day-directories older than
|
|
938
|
+
// retentionDays. Cheap when the feature is off (no traces dir to scan).
|
|
939
|
+
if (config.enableRetrievalTraces) {
|
|
940
|
+
void gcOldTraces({ dataDir: config.dataDir, retentionDays: config.retrievalTraceRetentionDays });
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
main().catch(err => {
|
|
944
|
+
console.error('Fatal error:', err);
|
|
945
|
+
process.exit(1);
|
|
946
|
+
});
|
|
928
947
|
//# sourceMappingURL=server.js.map
|