@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/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: 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.',
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 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.',
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('memory_search', {
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 memory_format tool)
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('memory_budget', {
141
+ server.registerTool('engram-budget', {
139
142
  title: 'Search Memories Within a Token Budget',
140
143
  description: [
141
- 'Like memory_search, but returns memories that fit within a TOKEN BUDGET instead of a count limit.',
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 memory_search plus { budgetTokens, usedTokens, includedCount, candidateCount } so callers can see how the budget got spent.',
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('memory_ingest', {
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 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
+ 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 memory_check_duplicate tool). Callers
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
- // memory_update_metadata — patch metadata-shape fields on an existing
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('memory_update_metadata', {
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] memory_update_metadata mode=replace id=${id} — caller wiped unset metadata fields to defaults\n`);
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] memory_update_metadata id=${id} mode=${effectiveMode} diff=${JSON.stringify(diff)}\n`);
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('memory_scratch_promote', {
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('memory_extract', {
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 memory_extract_rules tool)
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('memory_maintain', {
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('memory_rules', {
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('memory_outcome', {
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('memory_session', {
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('memory_stats', {
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 memory_taxonomy tool)
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('memory_govern', {
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('memory_kg_add', {
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('memory_kg_query', {
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('memory_kg_invalidate', {
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('memory_kg_timeline', {
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('memory_dossier', {
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 memory_budget.
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('memory_diary_write', {
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('memory_diary_read', {
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('memory_handoff_write', {
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('memory_handoff_read', {
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 memory_handoff_list).',
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 memory_handoff_list. If true, lists recent checkpoints.'),
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 memory_handoff_list to see saved checkpoints.`
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('memory_handoff_list', {
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 memory_handoff_read.',
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('memory_context_pressure', {
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('memory_trace_recent', {
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('memory_import', {
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({