@onenomad/engram-mcp 1.1.0 → 2.0.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 +32 -32
- package/dist/auth/login.d.ts +107 -68
- package/dist/auth/login.js +227 -216
- package/dist/auth/login.js.map +1 -1
- package/dist/consolidator.js +519 -519
- package/dist/context-pressure.js +91 -91
- package/dist/handoff.d.ts +53 -53
- package/dist/handoff.js +156 -156
- package/dist/server.js +204 -49
- package/dist/server.js.map +1 -1
- package/dist/source-dedup.d.ts +86 -86
- package/dist/source-dedup.js +147 -147
- package/dist/update-metadata.d.ts +29 -29
- package/dist/update-metadata.js +51 -51
- package/dist/wal.d.ts +95 -95
- package/dist/wal.js +295 -295
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -23,6 +23,9 @@ import { syncBridge, loadBridgeFile } from './procedural-bridge.js';
|
|
|
23
23
|
import { writeHandoff, readHandoff, listHandoffs } from './handoff.js';
|
|
24
24
|
import { assessPressure } from './context-pressure.js';
|
|
25
25
|
import { listRecentTraces, gcOldTraces } from './retrieval-trace.js';
|
|
26
|
+
import { hostname } from 'node:os';
|
|
27
|
+
import { startDeviceCode, pollDeviceCode, credentialsFromApproval, } from './auth/login.js';
|
|
28
|
+
import { readCredentials, writeCredentials, deleteCredentials, credentialsPath, credentialsStat, } from './auth/credentials.js';
|
|
26
29
|
// ── Config & Storage ────────────────────────────────────────────────
|
|
27
30
|
const config = loadConfig();
|
|
28
31
|
let _storage = null;
|
|
@@ -42,17 +45,17 @@ const server = new McpServer({ name: 'engram', version: '2.4.0' }, {
|
|
|
42
45
|
instructions: [
|
|
43
46
|
'Engram is your long-term memory.',
|
|
44
47
|
'',
|
|
45
|
-
'Save what matters:
|
|
46
|
-
'Before answering about prior conversations:
|
|
48
|
+
'Save what matters: engram-ingest for facts/preferences/decisions, engram-kg-add for relationships, engram-diary-write at session end.',
|
|
49
|
+
'Before answering about prior conversations: engram-search first.',
|
|
47
50
|
'',
|
|
48
51
|
'## Handoff protocol (MANDATORY)',
|
|
49
52
|
'Context compaction can fail if the window fills completely. When that happens, the user has to abandon the chat. Never let this happen.',
|
|
50
53
|
'',
|
|
51
|
-
'1. Save memories continuously with
|
|
52
|
-
'2. At session start, call
|
|
53
|
-
'3. When context feels heavy (long tool outputs, many file reads, extended work) call
|
|
54
|
-
'4. At NATURAL PHASE BOUNDARIES (task done, pivoting focus, finishing a subsystem, user says "ok next let\'s…") call
|
|
55
|
-
'5. BEFORE invoking /compact — or before session end, or when the user asks to "save this session" / "checkpoint this" — call
|
|
54
|
+
'1. Save memories continuously with engram-ingest — never batch.',
|
|
55
|
+
'2. At session start, call engram-handoff-read to resume where the prior session left off. If the user references a specific past session (by name or topic), call engram-handoff-list first and load the matching named checkpoint with engram-handoff-read({ name }).',
|
|
56
|
+
'3. When context feels heavy (long tool outputs, many file reads, extended work) call engram-context-pressure with your honest level assessment. Follow the returned actionPlan.',
|
|
57
|
+
'4. At NATURAL PHASE BOUNDARIES (task done, pivoting focus, finishing a subsystem, user says "ok next let\'s…") call engram-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.',
|
|
58
|
+
'5. BEFORE invoking /compact — or before session end, or when the user asks to "save this session" / "checkpoint this" — call engram-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
59
|
'6. Do not wait for the system to auto-compact. Compact early, while there is still headroom for the handoff.',
|
|
57
60
|
'',
|
|
58
61
|
'If persona MCP available: call persona_signal on user reactions (correction, approval, frustration, praise, etc).',
|
|
@@ -61,7 +64,7 @@ const server = new McpServer({ name: 'engram', version: '2.4.0' }, {
|
|
|
61
64
|
// ─────────────────────────────────────────────────────────────────────
|
|
62
65
|
// CORE MEMORY TOOLS
|
|
63
66
|
// ─────────────────────────────────────────────────────────────────────
|
|
64
|
-
server.registerTool('
|
|
67
|
+
server.registerTool('engram-search', {
|
|
65
68
|
title: 'Search Memories',
|
|
66
69
|
description: 'Search long-term memories. Returns relevant facts, preferences, decisions, and rules. Set format=true to get pre-formatted output for prompt injection.',
|
|
67
70
|
inputSchema: z.object({
|
|
@@ -110,7 +113,7 @@ server.registerTool('memory_search', {
|
|
|
110
113
|
.sort((a, b) => b.chunk.importance - a.chunk.importance)
|
|
111
114
|
.slice(0, 3);
|
|
112
115
|
}
|
|
113
|
-
// Formatted output mode (replaces old
|
|
116
|
+
// Formatted output mode (replaces old engram-format tool)
|
|
114
117
|
if (formatOutput) {
|
|
115
118
|
const memText = formatRecalledMemories(selected);
|
|
116
119
|
const rules = await formatRulesForPrompt(storage);
|
|
@@ -135,13 +138,13 @@ server.registerTool('memory_search', {
|
|
|
135
138
|
})),
|
|
136
139
|
});
|
|
137
140
|
});
|
|
138
|
-
server.registerTool('
|
|
141
|
+
server.registerTool('engram-budget', {
|
|
139
142
|
title: 'Search Memories Within a Token Budget',
|
|
140
143
|
description: [
|
|
141
|
-
'Like
|
|
144
|
+
'Like engram-search, but returns memories that fit within a TOKEN BUDGET instead of a count limit.',
|
|
142
145
|
'Greedy fill from highest-relevance memories: candidates ranked by score × importance, included until the next entry would exceed the budget.',
|
|
143
146
|
'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
|
|
147
|
+
'Returns the same memory shape as engram-search plus { budgetTokens, usedTokens, includedCount, candidateCount } so callers can see how the budget got spent.',
|
|
145
148
|
].join(' '),
|
|
146
149
|
inputSchema: z.object({
|
|
147
150
|
query: z.string().describe('Natural language search query.'),
|
|
@@ -207,7 +210,7 @@ server.registerTool('memory_budget', {
|
|
|
207
210
|
})),
|
|
208
211
|
});
|
|
209
212
|
});
|
|
210
|
-
server.registerTool('
|
|
213
|
+
server.registerTool('engram-ingest', {
|
|
211
214
|
title: 'Save Memory',
|
|
212
215
|
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
216
|
inputSchema: z.object({
|
|
@@ -225,13 +228,13 @@ server.registerTool('memory_ingest', {
|
|
|
225
228
|
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
229
|
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
230
|
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
|
|
229
|
-
skipDailyEntry: z.boolean().optional().describe('Skip the post-batch daily-entry append. Production users should leave this off — daily entries power
|
|
230
|
-
awaitSideEffects: z.boolean().optional().describe('When false, KG extraction + daily-entry append run in the BACKGROUND after the chunks land on disk;
|
|
231
|
+
skipKgExtraction: z.boolean().optional().describe('Skip the per-chunk knowledge-graph triple extraction. Production users should leave this off — KG extraction powers engram-dossier, engram-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.'),
|
|
232
|
+
skipDailyEntry: z.boolean().optional().describe('Skip the post-batch daily-entry append. Production users should leave this off — daily entries power engram-diary-read and cross-session summaries. Benchmark harnesses set this true alongside skipKgExtraction to match the standalone bench setup.'),
|
|
233
|
+
awaitSideEffects: z.boolean().optional().describe('When false, KG extraction + daily-entry append run in the BACKGROUND after the chunks land on disk; engram-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
234
|
}),
|
|
232
235
|
}, async ({ content, type, importance, tags, source, domain, topic, sentiment, emotionalValence, emotionalArousal, skipDedupe, origin, tier, createdAt, skipKgExtraction, skipDailyEntry, awaitSideEffects }) => {
|
|
233
236
|
const storage = await ensureStorage();
|
|
234
|
-
// Auto duplicate check (replaces old
|
|
237
|
+
// Auto duplicate check (replaces old engram-check-duplicate tool). Callers
|
|
235
238
|
// writing intentional refinements can bypass via skipDedupe=true.
|
|
236
239
|
if (!skipDedupe) {
|
|
237
240
|
const dupeResults = await search(config, storage, content, 5);
|
|
@@ -278,7 +281,7 @@ server.registerTool('memory_ingest', {
|
|
|
278
281
|
} : null,
|
|
279
282
|
});
|
|
280
283
|
});
|
|
281
|
-
//
|
|
284
|
+
// engram-update-metadata — patch metadata-shape fields on an existing
|
|
282
285
|
// memory by id. Closes a gap that callers (e.g. cortex's workspace
|
|
283
286
|
// backfill) hit when they need to correct stamps without re-ingesting
|
|
284
287
|
// (which either dupes or relies on similarity dedupe to overwrite —
|
|
@@ -295,7 +298,7 @@ server.registerTool('memory_ingest', {
|
|
|
295
298
|
// (`embedding`, `embeddingVersion`) are rejected — those are either
|
|
296
299
|
// immutable identity or computed from content. Callers wanting to
|
|
297
300
|
// re-embed should re-ingest with skipDedupe.
|
|
298
|
-
server.registerTool('
|
|
301
|
+
server.registerTool('engram-update-metadata', {
|
|
299
302
|
title: 'Update Memory Metadata',
|
|
300
303
|
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
304
|
inputSchema: z.object({
|
|
@@ -325,7 +328,7 @@ server.registerTool('memory_update_metadata', {
|
|
|
325
328
|
// immutable fields (id, createdAt, embedding) stay locked.
|
|
326
329
|
const patch = buildUpdateMetadataPatch(metadata, effectiveMode);
|
|
327
330
|
if (effectiveMode === 'replace') {
|
|
328
|
-
process.stderr.write(`[engram]
|
|
331
|
+
process.stderr.write(`[engram] engram-update-metadata mode=replace id=${id} — caller wiped unset metadata fields to defaults\n`);
|
|
329
332
|
}
|
|
330
333
|
// Compute a lightweight diff for the audit line (existing vs patch),
|
|
331
334
|
// limited to the keys the patch actually touches so we don't log the
|
|
@@ -335,7 +338,7 @@ server.registerTool('memory_update_metadata', {
|
|
|
335
338
|
const before = existing[key];
|
|
336
339
|
diff[key] = { from: before, to: value };
|
|
337
340
|
}
|
|
338
|
-
process.stderr.write(`[engram]
|
|
341
|
+
process.stderr.write(`[engram] engram-update-metadata id=${id} mode=${effectiveMode} diff=${JSON.stringify(diff)}\n`);
|
|
339
342
|
await storage.updateChunk(id, patch);
|
|
340
343
|
const updated = await storage.getChunk(id);
|
|
341
344
|
if (!updated) {
|
|
@@ -356,7 +359,7 @@ server.registerTool('memory_update_metadata', {
|
|
|
356
359
|
},
|
|
357
360
|
});
|
|
358
361
|
});
|
|
359
|
-
server.registerTool('
|
|
362
|
+
server.registerTool('engram-scratch-promote', {
|
|
360
363
|
title: 'Promote Scratch Memory',
|
|
361
364
|
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
365
|
inputSchema: z.object({
|
|
@@ -373,7 +376,7 @@ server.registerTool('memory_scratch_promote', {
|
|
|
373
376
|
await storage.updateChunk(id, { tier: 'short-term' });
|
|
374
377
|
return json({ promoted: true, id, from: 'scratch', to: 'short-term' });
|
|
375
378
|
});
|
|
376
|
-
server.registerTool('
|
|
379
|
+
server.registerTool('engram-extract', {
|
|
377
380
|
title: 'Extract Memories',
|
|
378
381
|
description: 'Extract memories from a conversation. Uses LLM or heuristic fallback. Set rulesOnly=true to extract procedural rules only.',
|
|
379
382
|
inputSchema: z.object({
|
|
@@ -385,7 +388,7 @@ server.registerTool('memory_extract', {
|
|
|
385
388
|
const storage = await ensureStorage();
|
|
386
389
|
const parsed = JSON.parse(messages);
|
|
387
390
|
const convId = conversationId ?? `mcp-${Date.now()}`;
|
|
388
|
-
// Rules-only mode (replaces old
|
|
391
|
+
// Rules-only mode (replaces old engram-extract-rules tool)
|
|
389
392
|
if (rulesOnly) {
|
|
390
393
|
await extractRules(config, storage, parsed);
|
|
391
394
|
const rules = await formatRulesForPrompt(storage);
|
|
@@ -409,7 +412,7 @@ server.registerTool('memory_extract', {
|
|
|
409
412
|
}
|
|
410
413
|
return json({ extracted: allChunks.length, memories: allChunks });
|
|
411
414
|
});
|
|
412
|
-
server.registerTool('
|
|
415
|
+
server.registerTool('engram-maintain', {
|
|
413
416
|
title: 'Consolidate',
|
|
414
417
|
description: 'Run memory consolidation: decay, promote/demote tiers, link related, merge duplicates, self-organize, and sync Persona bridge.',
|
|
415
418
|
inputSchema: z.object({}),
|
|
@@ -426,7 +429,7 @@ server.registerTool('memory_maintain', {
|
|
|
426
429
|
}
|
|
427
430
|
return json({ action: 'consolidation', ...stats, bridge: bridgeSync });
|
|
428
431
|
});
|
|
429
|
-
server.registerTool('
|
|
432
|
+
server.registerTool('engram-rules', {
|
|
430
433
|
title: 'Procedural Rules',
|
|
431
434
|
description: 'Show active procedural rules learned from corrections and preferences.',
|
|
432
435
|
inputSchema: z.object({}),
|
|
@@ -435,7 +438,7 @@ server.registerTool('memory_rules', {
|
|
|
435
438
|
const t = await formatRulesForPrompt(storage);
|
|
436
439
|
return text(t || 'No active procedural rules.');
|
|
437
440
|
});
|
|
438
|
-
server.registerTool('
|
|
441
|
+
server.registerTool('engram-outcome', {
|
|
439
442
|
title: 'Recall Outcome',
|
|
440
443
|
description: 'Record whether recalled memories were helpful, corrected, or irrelevant. Adjusts importance.',
|
|
441
444
|
inputSchema: z.object({
|
|
@@ -448,7 +451,7 @@ server.registerTool('memory_outcome', {
|
|
|
448
451
|
await recordRecallOutcome(config, storage, ids, outcome, `mcp-${Date.now()}`);
|
|
449
452
|
return text(`Recorded ${outcome} outcome for ${ids.length} chunk(s).`);
|
|
450
453
|
});
|
|
451
|
-
server.registerTool('
|
|
454
|
+
server.registerTool('engram-session', {
|
|
452
455
|
title: 'Session State',
|
|
453
456
|
description: 'Manage session state (hot RAM). Actions: show, task, context, decision, action, clear.',
|
|
454
457
|
inputSchema: z.object({
|
|
@@ -478,7 +481,7 @@ server.registerTool('memory_session', {
|
|
|
478
481
|
return text(`Unknown action: ${action}`);
|
|
479
482
|
}
|
|
480
483
|
});
|
|
481
|
-
server.registerTool('
|
|
484
|
+
server.registerTool('engram-stats', {
|
|
482
485
|
title: 'Stats',
|
|
483
486
|
description: 'Memory system stats: chunks by tier/layer/type, rules, knowledge graph, bridge status, and taxonomy.',
|
|
484
487
|
inputSchema: z.object({}),
|
|
@@ -497,7 +500,7 @@ server.registerTool('memory_stats', {
|
|
|
497
500
|
const kgStats = await getGraphStats(storage);
|
|
498
501
|
const state = readSessionState(config.dataDir);
|
|
499
502
|
const diaryDates = listDiaryDates(config.dataDir);
|
|
500
|
-
// Taxonomy (folded in from old
|
|
503
|
+
// Taxonomy (folded in from old engram-taxonomy tool)
|
|
501
504
|
const tree = await storage.getTaxonomy();
|
|
502
505
|
// Bridge status (new observability)
|
|
503
506
|
let bridge = { status: 'no bridge file' };
|
|
@@ -527,7 +530,7 @@ server.registerTool('memory_stats', {
|
|
|
527
530
|
sessionTask: state.currentTask || null,
|
|
528
531
|
});
|
|
529
532
|
});
|
|
530
|
-
server.registerTool('
|
|
533
|
+
server.registerTool('engram-govern', {
|
|
531
534
|
title: 'Governance Check',
|
|
532
535
|
description: 'Advisory checks: "check" (contradictions), "drift" (semantic drift), "poison" (injection scan), "full" (all).',
|
|
533
536
|
inputSchema: z.object({
|
|
@@ -562,7 +565,7 @@ server.registerTool('memory_govern', {
|
|
|
562
565
|
// ─────────────────────────────────────────────────────────────────────
|
|
563
566
|
// KNOWLEDGE GRAPH TOOLS
|
|
564
567
|
// ─────────────────────────────────────────────────────────────────────
|
|
565
|
-
server.registerTool('
|
|
568
|
+
server.registerTool('engram-kg-add', {
|
|
566
569
|
title: 'KG Add',
|
|
567
570
|
description: 'Add a subject-predicate-object triple. Use replace=true to auto-invalidate conflicting facts.',
|
|
568
571
|
inputSchema: z.object({
|
|
@@ -578,7 +581,7 @@ server.registerTool('memory_kg_add', {
|
|
|
578
581
|
const triple = await fn(storage, subject, predicate, object, `mcp-${Date.now()}`, confidence);
|
|
579
582
|
return json({ added: true, triple: { id: triple.id, subject: triple.subject, predicate: triple.predicate, object: triple.object } });
|
|
580
583
|
});
|
|
581
|
-
server.registerTool('
|
|
584
|
+
server.registerTool('engram-kg-query', {
|
|
582
585
|
title: 'KG Query',
|
|
583
586
|
description: 'Query knowledge graph triples. Filter by subject, predicate, and/or object.',
|
|
584
587
|
inputSchema: z.object({
|
|
@@ -601,7 +604,7 @@ server.registerTool('memory_kg_query', {
|
|
|
601
604
|
})),
|
|
602
605
|
});
|
|
603
606
|
});
|
|
604
|
-
server.registerTool('
|
|
607
|
+
server.registerTool('engram-kg-invalidate', {
|
|
605
608
|
title: 'KG Invalidate',
|
|
606
609
|
description: 'Mark a fact as no longer valid. Stays in history.',
|
|
607
610
|
inputSchema: z.object({
|
|
@@ -612,7 +615,7 @@ server.registerTool('memory_kg_invalidate', {
|
|
|
612
615
|
await invalidateTriple(storage, tripleId);
|
|
613
616
|
return text(`Triple ${tripleId} invalidated.`);
|
|
614
617
|
});
|
|
615
|
-
server.registerTool('
|
|
618
|
+
server.registerTool('engram-kg-timeline', {
|
|
616
619
|
title: 'KG Timeline',
|
|
617
620
|
description: 'Chronological history of all facts about an entity.',
|
|
618
621
|
inputSchema: z.object({
|
|
@@ -629,7 +632,7 @@ server.registerTool('memory_kg_timeline', {
|
|
|
629
632
|
})),
|
|
630
633
|
});
|
|
631
634
|
});
|
|
632
|
-
server.registerTool('
|
|
635
|
+
server.registerTool('engram-dossier', {
|
|
633
636
|
title: 'Entity Dossier',
|
|
634
637
|
description: [
|
|
635
638
|
'Aggregate everything Engram knows about an entity (person, project, concept) into a structured snapshot.',
|
|
@@ -713,7 +716,7 @@ server.registerTool('memory_dossier', {
|
|
|
713
716
|
// Optional token-budget enforcement. Splits budget evenly across
|
|
714
717
|
// the 5 categories (facts / preferences / decisions / corrections
|
|
715
718
|
// / recent) and greedy-fills each within its share. Same 4
|
|
716
|
-
// chars/token + 30 wrapper estimate as
|
|
719
|
+
// chars/token + 30 wrapper estimate as engram-budget.
|
|
717
720
|
let usedTokens = 0;
|
|
718
721
|
if (typeof budgetTokens === 'number' && budgetTokens > 0) {
|
|
719
722
|
const perCategoryBudget = Math.floor(budgetTokens / 5);
|
|
@@ -772,7 +775,7 @@ server.registerTool('memory_dossier', {
|
|
|
772
775
|
// ─────────────────────────────────────────────────────────────────────
|
|
773
776
|
// DIARY TOOLS
|
|
774
777
|
// ─────────────────────────────────────────────────────────────────────
|
|
775
|
-
server.registerTool('
|
|
778
|
+
server.registerTool('engram-diary-write', {
|
|
776
779
|
title: 'Write Diary',
|
|
777
780
|
description: 'Write a session diary entry. Record what happened, what was decided, what matters next.',
|
|
778
781
|
inputSchema: z.object({
|
|
@@ -783,7 +786,7 @@ server.registerTool('memory_diary_write', {
|
|
|
783
786
|
const entry = writeDiaryEntry(config.dataDir, content, agent);
|
|
784
787
|
return json({ written: true, date: entry.date, time: entry.time, agent: entry.agent });
|
|
785
788
|
});
|
|
786
|
-
server.registerTool('
|
|
789
|
+
server.registerTool('engram-diary-read', {
|
|
787
790
|
title: 'Read Diary',
|
|
788
791
|
description: 'Read diary entries from recent days or a specific date.',
|
|
789
792
|
inputSchema: z.object({
|
|
@@ -798,7 +801,7 @@ server.registerTool('memory_diary_read', {
|
|
|
798
801
|
// ─────────────────────────────────────────────────────────────────────
|
|
799
802
|
// HANDOFF TOOLS — cross-session "where we left off" lifeline
|
|
800
803
|
// ─────────────────────────────────────────────────────────────────────
|
|
801
|
-
server.registerTool('
|
|
804
|
+
server.registerTool('engram-handoff-write', {
|
|
802
805
|
title: 'Write Handoff Note',
|
|
803
806
|
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
807
|
inputSchema: z.object({
|
|
@@ -835,13 +838,13 @@ server.registerTool('memory_handoff_write', {
|
|
|
835
838
|
summary: note.currentTask,
|
|
836
839
|
});
|
|
837
840
|
});
|
|
838
|
-
server.registerTool('
|
|
841
|
+
server.registerTool('engram-handoff-read', {
|
|
839
842
|
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
|
|
843
|
+
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 engram-handoff-list).',
|
|
841
844
|
inputSchema: z.object({
|
|
842
845
|
name: z.string().optional().describe('Named checkpoint to load (e.g. "engram-named-checkpoints"). Takes precedence over stamp if both are provided.'),
|
|
843
846
|
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
|
|
847
|
+
list: z.boolean().optional().describe('Deprecated — use engram-handoff-list. If true, lists recent checkpoints.'),
|
|
845
848
|
limit: z.number().min(1).max(50).optional().describe('For list mode: max entries to return (default 10).'),
|
|
846
849
|
}),
|
|
847
850
|
}, async ({ name, stamp, list, limit }) => {
|
|
@@ -854,22 +857,22 @@ server.registerTool('memory_handoff_read', {
|
|
|
854
857
|
return json({
|
|
855
858
|
found: false,
|
|
856
859
|
message: identifier
|
|
857
|
-
? `No handoff found matching "${identifier}". Use
|
|
860
|
+
? `No handoff found matching "${identifier}". Use engram-handoff-list to see saved checkpoints.`
|
|
858
861
|
: 'No handoff note available.',
|
|
859
862
|
});
|
|
860
863
|
}
|
|
861
864
|
return json({ found: true, ...note });
|
|
862
865
|
});
|
|
863
|
-
server.registerTool('
|
|
866
|
+
server.registerTool('engram-handoff-list', {
|
|
864
867
|
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
|
|
868
|
+
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 engram-handoff-read.',
|
|
866
869
|
inputSchema: z.object({
|
|
867
870
|
limit: z.number().min(1).max(50).optional().describe('Max checkpoints to return (default 10, max 50).'),
|
|
868
871
|
}),
|
|
869
872
|
}, async ({ limit }) => {
|
|
870
873
|
return json({ handoffs: listHandoffs(config.dataDir, limit ?? 10) });
|
|
871
874
|
});
|
|
872
|
-
server.registerTool('
|
|
875
|
+
server.registerTool('engram-context-pressure', {
|
|
873
876
|
title: 'Context Pressure Check',
|
|
874
877
|
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
878
|
inputSchema: z.object({
|
|
@@ -881,9 +884,161 @@ server.registerTool('memory_context_pressure', {
|
|
|
881
884
|
return json(assessPressure(level, reason ?? '', phaseBoundary ?? false));
|
|
882
885
|
});
|
|
883
886
|
// ─────────────────────────────────────────────────────────────────────
|
|
887
|
+
// CLOUD AUTH — device-code login + credentials file management
|
|
888
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
889
|
+
// The MCP login flow is two-step because device-code pairing needs the
|
|
890
|
+
// user to approve in a browser, which usually takes longer than a single
|
|
891
|
+
// MCP tool call can wait. Tool 1 starts the pairing and returns the URL +
|
|
892
|
+
// user code. Tool 2 polls for approval in chunks short enough to stay
|
|
893
|
+
// under the MCP tool timeout. The caller re-invokes tool 2 if the user
|
|
894
|
+
// is still finishing the browser flow.
|
|
895
|
+
function sleepMs(ms) {
|
|
896
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
897
|
+
}
|
|
898
|
+
// Cap a single resume call well under the MCP tool timeout. Leaves
|
|
899
|
+
// headroom for the final pollDeviceCode round-trip after the loop's
|
|
900
|
+
// "still pending" exit check.
|
|
901
|
+
const RESUME_MAX_DURATION_MS = 45_000;
|
|
902
|
+
server.registerTool('engram-login', {
|
|
903
|
+
title: 'Cloud Login (start device-code pairing)',
|
|
904
|
+
description: [
|
|
905
|
+
'Start a device-code login against a Pyre Cloud server (the same flow as the `engram-memory login` CLI command).',
|
|
906
|
+
'Returns the URL and user code the human must visit + enter in a browser. AFTER showing those to the user, call `engram-login-resume` with the returned `deviceCode` to poll for approval — it may need to be called more than once if the user is slow.',
|
|
907
|
+
'On approval the credentials file at `~/.pyre/credentials.json` (or $PYRE_CREDENTIALS_FILE) is written and Engram\'s cloud storage adapter starts using it on next server start.',
|
|
908
|
+
].join(' '),
|
|
909
|
+
inputSchema: z.object({
|
|
910
|
+
serverUrl: z.string().describe('Pyre Cloud base URL (e.g. https://pyre.sh). No trailing slash needed.'),
|
|
911
|
+
label: z.string().optional().describe('Friendly device label to attach to the issued credential. Defaults to this machine\'s hostname.'),
|
|
912
|
+
}),
|
|
913
|
+
}, async ({ serverUrl, label }) => {
|
|
914
|
+
const apiUrl = serverUrl.trim().replace(/\/+$/, '');
|
|
915
|
+
if (!apiUrl) {
|
|
916
|
+
return json({ ok: false, error: 'serverUrl is required (e.g. https://pyre.sh).' });
|
|
917
|
+
}
|
|
918
|
+
try {
|
|
919
|
+
const start = await startDeviceCode(fetch, apiUrl, label?.trim() || hostname(), sleepMs);
|
|
920
|
+
const expiresAt = Date.now() + start.expires_in * 1000;
|
|
921
|
+
return json({
|
|
922
|
+
ok: true,
|
|
923
|
+
serverUrl: apiUrl,
|
|
924
|
+
verificationUrl: start.verification_url,
|
|
925
|
+
userCode: start.user_code,
|
|
926
|
+
deviceCode: start.device_code,
|
|
927
|
+
intervalSeconds: start.interval,
|
|
928
|
+
expiresInSeconds: start.expires_in,
|
|
929
|
+
expiresAt,
|
|
930
|
+
instructions: `Show the user this URL and code, then call engram-login-resume({ serverUrl: "${apiUrl}", deviceCode: "${start.device_code}", intervalSeconds: ${start.interval}, expiresAt: ${expiresAt} }) to poll for approval. If it returns "pending", call it again.`,
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
catch (err) {
|
|
934
|
+
return json({ ok: false, error: `Could not reach ${apiUrl}: ${err.message}` });
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
server.registerTool('engram-login-resume', {
|
|
938
|
+
title: 'Cloud Login (resume / poll device-code)',
|
|
939
|
+
description: [
|
|
940
|
+
'Poll a device-code pairing started by `engram-login`. Polls for ~45s, then returns one of: approved, pending, denied, expired, error.',
|
|
941
|
+
'If "pending" is returned and `expiresAt` has not passed, call this tool again with the same arguments to keep waiting.',
|
|
942
|
+
'On "approved" the credentials file is written and the response includes the storage api_url assigned by the server.',
|
|
943
|
+
].join(' '),
|
|
944
|
+
inputSchema: z.object({
|
|
945
|
+
serverUrl: z.string().describe('Pyre Cloud base URL — must match the one passed to engram-login.'),
|
|
946
|
+
deviceCode: z.string().describe('device_code returned by engram-login.'),
|
|
947
|
+
intervalSeconds: z.number().min(1).max(60).describe('Polling interval suggested by the server (returned by engram-login).'),
|
|
948
|
+
expiresAt: z.number().describe('Epoch ms after which the device code is expired (returned by engram-login).'),
|
|
949
|
+
}),
|
|
950
|
+
}, async ({ serverUrl, deviceCode, intervalSeconds, expiresAt }) => {
|
|
951
|
+
const apiUrl = serverUrl.trim().replace(/\/+$/, '');
|
|
952
|
+
const intervalMs = Math.max(1, intervalSeconds) * 1000;
|
|
953
|
+
const stopAt = Math.min(Date.now() + RESUME_MAX_DURATION_MS, expiresAt);
|
|
954
|
+
while (Date.now() < stopAt) {
|
|
955
|
+
await sleepMs(intervalMs);
|
|
956
|
+
if (Date.now() >= stopAt)
|
|
957
|
+
break;
|
|
958
|
+
let body;
|
|
959
|
+
try {
|
|
960
|
+
body = await pollDeviceCode(fetch, apiUrl, deviceCode);
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
// Transient — keep polling until our window closes.
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (body.status === 'pending')
|
|
967
|
+
continue;
|
|
968
|
+
if (body.status === 'denied') {
|
|
969
|
+
return json({ ok: false, status: 'denied', error: 'Authorization denied.' });
|
|
970
|
+
}
|
|
971
|
+
if (body.status === 'expired') {
|
|
972
|
+
return json({ ok: false, status: 'expired', error: 'Pairing code expired. Call engram-login again.' });
|
|
973
|
+
}
|
|
974
|
+
if (body.status === 'approved') {
|
|
975
|
+
try {
|
|
976
|
+
const creds = credentialsFromApproval(body);
|
|
977
|
+
writeCredentials(creds);
|
|
978
|
+
return json({
|
|
979
|
+
ok: true,
|
|
980
|
+
status: 'approved',
|
|
981
|
+
apiUrl: creds.api_url,
|
|
982
|
+
label: creds.label,
|
|
983
|
+
scopes: creds.scopes,
|
|
984
|
+
credentialsPath: credentialsPath(),
|
|
985
|
+
note: 'Credentials written. Restart the Engram MCP server (or your MCP client) for cloud storage to take effect.',
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
catch (err) {
|
|
989
|
+
return json({ ok: false, status: 'error', error: `Could not write credentials: ${err.message}` });
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
if (Date.now() >= expiresAt) {
|
|
994
|
+
return json({ ok: false, status: 'expired', error: 'Pairing code expired. Call engram-login again.' });
|
|
995
|
+
}
|
|
996
|
+
return json({
|
|
997
|
+
ok: true,
|
|
998
|
+
status: 'pending',
|
|
999
|
+
secondsUntilExpiry: Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)),
|
|
1000
|
+
note: 'Still waiting on browser approval. Call engram-login-resume again with the same arguments.',
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
server.registerTool('engram-login-status', {
|
|
1004
|
+
title: 'Cloud Login Status',
|
|
1005
|
+
description: 'Inspect the local Pyre Cloud credentials file. Returns whether the user is logged in, the api_url and label of the active credential, and the credentials file path. No network calls.',
|
|
1006
|
+
inputSchema: z.object({}),
|
|
1007
|
+
}, async () => {
|
|
1008
|
+
const path = credentialsPath();
|
|
1009
|
+
const stat = credentialsStat();
|
|
1010
|
+
const creds = readCredentials();
|
|
1011
|
+
if (!creds) {
|
|
1012
|
+
return json({ loggedIn: false, credentialsPath: path, fileExists: stat !== null });
|
|
1013
|
+
}
|
|
1014
|
+
return json({
|
|
1015
|
+
loggedIn: true,
|
|
1016
|
+
credentialsPath: path,
|
|
1017
|
+
apiUrl: creds.api_url,
|
|
1018
|
+
label: creds.label,
|
|
1019
|
+
scopes: creds.scopes,
|
|
1020
|
+
issuedAt: creds.issued_at,
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
server.registerTool('engram-logout', {
|
|
1024
|
+
title: 'Cloud Logout',
|
|
1025
|
+
description: 'Delete the local Pyre Cloud credentials file. Idempotent — succeeds whether or not the file existed. Engram falls back to local LanceDB on next server start.',
|
|
1026
|
+
inputSchema: z.object({}),
|
|
1027
|
+
}, async () => {
|
|
1028
|
+
const path = credentialsPath();
|
|
1029
|
+
const removed = deleteCredentials();
|
|
1030
|
+
return json({
|
|
1031
|
+
ok: true,
|
|
1032
|
+
loggedOut: removed,
|
|
1033
|
+
alreadyLoggedOut: !removed,
|
|
1034
|
+
credentialsPath: path,
|
|
1035
|
+
note: removed ? 'Restart the Engram MCP server to fall back to local storage.' : undefined,
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
1038
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
884
1039
|
// DIAGNOSTIC RETRIEVAL TRACES
|
|
885
1040
|
// ─────────────────────────────────────────────────────────────────────
|
|
886
|
-
server.registerTool('
|
|
1041
|
+
server.registerTool('engram-trace-recent', {
|
|
887
1042
|
title: 'Recent Retrieval Traces',
|
|
888
1043
|
description: [
|
|
889
1044
|
'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.',
|
|
@@ -912,7 +1067,7 @@ server.registerTool('memory_trace_recent', {
|
|
|
912
1067
|
// ─────────────────────────────────────────────────────────────────────
|
|
913
1068
|
// IMPORT
|
|
914
1069
|
// ─────────────────────────────────────────────────────────────────────
|
|
915
|
-
server.registerTool('
|
|
1070
|
+
server.registerTool('engram-import', {
|
|
916
1071
|
title: 'Import',
|
|
917
1072
|
description: 'Bulk import from chat exports: claude-jsonl, chatgpt-json, or plain-text.',
|
|
918
1073
|
inputSchema: z.object({
|