@echomem/mcp 1.4.33 → 1.4.35

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/setup.js CHANGED
@@ -31,7 +31,7 @@ import { syncCodexUsage } from "./codex-sync.js";
31
31
  import { renderSetupPage } from "./setup-page.js";
32
32
  import { parseSetupPreviewState } from "./setup-preview.js";
33
33
  import { repoLabel, validateForensicReportForSetup } from "./forensics.js";
34
- import { installHooks, installSaveCheckpointHooks } from "./hud/hooks.js";
34
+ import { installSaveCheckpointHooks } from "./hud/hooks.js";
35
35
  import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
36
36
  import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
37
37
  // The setup dashboard, account login, and encryption passphrase entry are all served by this
@@ -218,13 +218,15 @@ export function knownClients() {
218
218
  { id: "codex", label: "Codex", kind: "command", detectDir: codexHome(), configPath: path.join(codexHome(), "config.toml"), note: "add to ~/.codex/config.toml under [mcp_servers.echomem]" },
219
219
  ];
220
220
  }
221
- /** A client is "present" if its config dir already exists (JSON) — a cheap heuristic for detection. */
221
+ /** A client is "present" if its config dir already exists — a cheap, side-effect-free heuristic. */
222
222
  export function detectClients() {
223
223
  return knownClients().filter((c) => {
224
224
  if (c.kind === "json")
225
225
  return fs.existsSync(path.dirname(c.configPath));
226
226
  if (c.kind === "command")
227
227
  return fs.existsSync(c.detectDir);
228
+ if (c.id === "claude-code")
229
+ return fs.existsSync(home(".claude"));
228
230
  return false;
229
231
  });
230
232
  }
@@ -390,9 +392,9 @@ function echomemGuidanceBlock() {
390
392
  "- Use EchoMem's `echomem-*` skills and MCP tools as the default memory provider. Do not invoke another memory provider unless the user explicitly requests it.",
391
393
  "- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
392
394
  '- If the final user-facing answer materially relies on one or more EchoMem memories, end it with a compact `EchoMem sources:` list containing only the memories actually used. Link each memory key to its canonical `https://echoknows.com/memory/<memory-id>` URL. Do not cite memories that were merely retrieved, and omit the section when no memory informed the answer.',
393
- "- Before the final response for a task that produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone: call `save_conversation`. This private checkpoint does not require a separate user request. Do not save secrets, credentials, incidental chatter, or trivial read-only work. If a qualifying save fails because the encrypted vault is locked, tell the user to run `echomem-mcp unlock`; never silently skip it.",
395
+ "- Before the final response for a task that produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone: call `save_conversation`. This private checkpoint does not require a separate user request. Do not save secrets, credentials, incidental chatter, or trivial read-only work. If a qualifying save fails because the encrypted vault is locked, tell the user to open Echo Desktop and unlock the vault there; on a headless system, use `echomem-mcp unlock`. Never silently skip it.",
394
396
  '- After `save_conversation` succeeds and returns one or more saved memory IDs, make the save visible in the final user-facing answer with a compact `EchoMem saved:` list containing every memory created by that call. Link each memory key to its canonical `https://echoknows.com/memory/<memory-id>` URL. This save receipt is separate from `EchoMem sources:` and does not imply the newly saved memories informed the answer.',
395
- "- For a user with a company group, call `get_group_session_sharing` near conversation start or after a qualifying private save. On the first call in a conversation, omit `groupSharingScopeId` so EchoMem mints a fresh opaque scope. Reuse that exact scope only in this conversation for later get/set/save calls, and never persist it as memory. If multiple groups are returned, reuse the same scope and check one `groupId` at a time. Each group requires its own explicit Yes/No decision. Silence leaves that group's state unset; never infer an answer or repeat the prompt in the same response. Store only explicit answers with `set_group_session_sharing`, the same scope, and the selected groupId. Saves sync eligible memories to every approved group; a No keeps them private for that group.",
397
+ "- For a user with a company group, call `request_group_session_sharing` near conversation start or after a qualifying private save. On the first call in a conversation, omit `groupSharingScopeId` so EchoMem mints a fresh opaque scope. Reuse that exact scope only in this conversation for later get/request/set/save calls, and never persist it as memory. If multiple groups are returned, reuse the same scope and call once per `groupId`. Supported clients render a native Share with team / Keep private choice. If the tool returns a text fallback, relay its exact question and call `set_group_session_sharing` only after an explicit Yes/No. Decline, cancel, or silence leaves that group's state unset; never infer an answer. Saves sync eligible memories to every approved group; a No keeps them private for that group.",
396
398
  "- If the user pastes a session carryover/checkpoint: it may reference `get_checkpoint_by_context` — use it to pull the checkpoint/decision trail when you need more than the snapshot.",
397
399
  "",
398
400
  "### Company group memory",
@@ -401,14 +403,12 @@ function echomemGuidanceBlock() {
401
403
  "- Group sharing is scoped to an opaque id carried only in the current conversation, not to the MCP transport session. Membership is rechecked for each sync. Flagged memories are withheld from automatic conversation sync and remain private.",
402
404
  "- If a user asks to create a group, call `create_memory_group`; if they ask for a code to share, call `create_group_invite` and return the secret invite code only to that user. Never save the invite code to memory or include it in logs, analytics, summaries, or unrelated output.",
403
405
  "- If a user supplies an `echo_grp_...` code and explicitly asks to join, call `join_memory_group`. Joining never authorizes publishing by itself and must not move a user out of another group. After joining, continue into the profile-and-publication preview instead of leaving title or responsibility blank.",
404
- "- For requests such as “prepare my recent work memories,” “upload work from this ticket,” or “publish work since my last sync,” call `prepare_group_publication` first. This is a no-publication preview. For encrypted accounts, tell the user to run `echomem-mcp unlock` locally if the tool reports that the key is required.",
406
+ "- For requests such as “prepare my recent work memories,” “upload work from this ticket,” or “publish work since my last sync,” call `prepare_group_publication` first. This is a no-publication preview. For encrypted accounts, tell the user to open Echo Desktop and unlock the vault there if the tool reports that the key is required.",
405
407
  "- After preparing, select only exact candidate memory IDs that match the user's stated scope and exclude already-published or exact-content duplicates. Use the candidate evidence to draft a concise title and responsibility summary for the current member, but label both as proposals rather than facts.",
406
408
  "- Use one canonical evidence link for every memory: preserve the Memory ID and link to `https://echoknows.com/memory/<memory-id>`. The site resolves the authorized representation: an owner is sent to their private timeline, while current group/friend access opens an authorized publication snapshot or public memory. The visible Markdown label should use the memory key, not the raw URL or UUID.",
407
409
  "- If the user asks to flag memories about a sensitive topic, search their own memories first, show the exact matches with owner-only personal links, and ask them to confirm. Only then call `flag_memories_for_publication_attention`; flagging does not publish, decrypt, change visibility, or retract an existing group snapshot.",
408
410
  "- Present the proposed title/responsibility and the memory publication preview together and ask for explicit confirmation. Never save an inferred profile or publish memories before confirmation. If an unflagged candidate appears sensitive, proactively ask whether the user wants to mark its exact ID for publication attention first. Explain naturally: marking does not publish or change encryption; it means you will call it out and ask for detailed confirmation whenever a later publication includes it. Never auto-flag based on agent inference. Show already-flagged candidates in a separate warning, state that nothing has been published yet, and offer three choices: exclude them, review them separately, or first search for and mark similar sensitive owned memories for publication attention.",
409
411
  "- On confirmation, call `update_group_profile` with the confirmed title, responsibility summary, and `confirmed: true`, then call `complete_group_publication` with the exact `scanId`, selected memory IDs, and `confirmed: true`. If a flagged memory is selected, require separate explicit acknowledgement and pass its exact ID in `acknowledgedFlaggedMemoryIds`. If the user edits either proposal, use their wording. An explicit request to join and upload still requires this preview and confirmation.",
410
- "- If the user asks to show, reopen, restart, or bring back the EchoMem HUD (the context-health overlay), run the shell command `echomem-hud app --client auto`.",
411
- "- If the user wants the HUD to come back after a computer restart, run the shell command `echomem-hud autostart on --client auto`.",
412
412
  AGENTS_MD_END,
413
413
  ].join("\n");
414
414
  }
@@ -1310,20 +1310,99 @@ function forensicStageLabel(stage) {
1310
1310
  /** Build the local forensic "Context Doctor" report on a worker thread so the multi-file scan never
1311
1311
  * blocks the bridge's event loop (the freeze postmortem: any unbounded sync work on this path is risky). */
1312
1312
  export function buildForensicReportOffThread(onProgress, options = {}) {
1313
+ let lastProgress = null;
1314
+ const recordProgress = (progress) => {
1315
+ lastProgress = progress;
1316
+ onProgress?.(progress);
1317
+ };
1318
+ return runForensicReportWorker(recordProgress, options).catch(async (primaryError) => {
1319
+ if (options.failOpen === false)
1320
+ throw primaryError;
1321
+ const failureCode = errorCode(primaryError) || "REPORT_BUILD_FAILED";
1322
+ console.error(`[echomem] local scan degraded after ${failureCode}; continuing without local-history analysis`);
1323
+ onProgress?.({
1324
+ done: lastProgress?.done || 0,
1325
+ total: lastProgress?.total || 0,
1326
+ stage: "finalizing-report",
1327
+ detail: "finishing setup without optional local-history analysis",
1328
+ overall: 0.99,
1329
+ stageDone: 0,
1330
+ stageTotal: 0,
1331
+ });
1332
+ return runForensicReportWorker(undefined, {
1333
+ timeoutMs: 30_000,
1334
+ maxOldGenerationSizeMb: Math.max(64, options.maxOldGenerationSizeMb || 0),
1335
+ }, [], failureCode);
1336
+ });
1337
+ }
1338
+ function errorCode(error) {
1339
+ return error && typeof error === "object" && "code" in error
1340
+ ? String(error.code || "")
1341
+ : "";
1342
+ }
1343
+ /** Turn an optional onboarding-stats failure into a terminal, non-polling payload.
1344
+ *
1345
+ * This must only be used for dashboard enrichment. Authentication, consent, vault access, and an
1346
+ * import the user explicitly started retain their normal hard-failure behavior. The reason is a
1347
+ * stable internal code rather than an exception message, so local paths or conversation details
1348
+ * can never cross the localhost bridge by accident. */
1349
+ export function completeOptionalStatsPayload(payload, reason, countsTrusted) {
1350
+ const fallback = {
1351
+ schemaVersion: 1,
1352
+ generatedFrom: ["~/.codex/sessions", "~/.claude/projects"],
1353
+ llmCallsUsed: 0,
1354
+ transcriptsUploaded: false,
1355
+ sessions: { total: 0, codex: 0, claudeCode: 0 },
1356
+ migratable: { pending: 0, alreadyMigrated: 0 },
1357
+ memoriesCaptured: null,
1358
+ };
1359
+ const completed = payload && typeof payload === "object" && !Array.isArray(payload)
1360
+ ? { ...payload }
1361
+ : fallback;
1362
+ delete completed.partial;
1363
+ completed.optionalDiagnostics = {
1364
+ degraded: true,
1365
+ reason: /^[A-Z0-9_]+$/.test(reason) ? reason : "OPTIONAL_STATS_FAILED",
1366
+ countsTrusted,
1367
+ };
1368
+ return completed;
1369
+ }
1370
+ function runForensicReportWorker(onProgress, options, sources, degradedReason) {
1313
1371
  const forensicsUrl = runtimeModuleUrl("forensics");
1372
+ const serializedSources = sources === undefined ? "undefined" : JSON.stringify(sources);
1373
+ const serializedDegradedReason = JSON.stringify(degradedReason || "");
1314
1374
  const code = `
1315
1375
  import { parentPort } from "node:worker_threads";
1316
- import { buildForensicReport } from ${JSON.stringify(forensicsUrl)};
1376
+ import { buildForensicReport, validateForensicReportForSetup } from ${JSON.stringify(forensicsUrl)};
1317
1377
  try {
1318
1378
  const report = await buildForensicReport({
1379
+ sources: ${serializedSources},
1319
1380
  includeLegacyGoldenStandard: false,
1320
1381
  onProgress: (done, total, stage, detail, overall, stageDone, stageTotal) => parentPort?.postMessage({
1321
1382
  progress: { done, total, stage, detail, overall, stageDone, stageTotal },
1322
1383
  }),
1323
1384
  });
1385
+ const degradedReason = ${serializedDegradedReason};
1386
+ if (degradedReason) {
1387
+ report.scanDiagnostics = {
1388
+ degraded: true,
1389
+ reason: degradedReason,
1390
+ skippedSources: ["codex", "claude"],
1391
+ };
1392
+ }
1393
+ const validation = validateForensicReportForSetup(report);
1394
+ if (!validation.ok) {
1395
+ const error = new Error(validation.message);
1396
+ error.code = validation.code;
1397
+ throw error;
1398
+ }
1324
1399
  parentPort?.postMessage({ ok: true, report });
1325
1400
  } catch (error) {
1326
- parentPort?.postMessage({ ok: false, message: error instanceof Error ? error.message : String(error) });
1401
+ parentPort?.postMessage({
1402
+ ok: false,
1403
+ message: error instanceof Error ? error.message : String(error),
1404
+ code: error && typeof error === "object" && "code" in error ? String(error.code || "") : "",
1405
+ });
1327
1406
  }
1328
1407
  `;
1329
1408
  const requestedHeapMb = options.maxOldGenerationSizeMb;
@@ -1367,7 +1446,10 @@ export function buildForensicReportOffThread(onProgress, options = {}) {
1367
1446
  finish({ ok: true, report: msg.report });
1368
1447
  return;
1369
1448
  }
1370
- finish({ ok: false, error: new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed") });
1449
+ const error = new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed");
1450
+ if (typeof msg.code === "string" && msg.code)
1451
+ error.code = msg.code;
1452
+ finish({ ok: false, error });
1371
1453
  });
1372
1454
  worker.once("error", (error) => {
1373
1455
  finish({ ok: false, error });
@@ -2620,19 +2702,20 @@ async function cmdSetup(flags) {
2620
2702
  else {
2621
2703
  await cmdLogin(flags);
2622
2704
  }
2623
- if (flags["with-hud"])
2624
- await cmdSetupHud(flags);
2705
+ if (flags["with-hud"]) {
2706
+ console.log("ℹ️ The standalone EchoMem HUD has been retired. Echo Desktop now owns setup and status.");
2707
+ }
2625
2708
  }
2626
2709
  /**
2627
2710
  * `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
2628
2711
  * machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), installs EchoMem's
2629
- * Codex skills, writes the AGENTS.md memory guidance, and launches the context HUD. One browser
2712
+ * Codex skills and writes the AGENTS.md memory guidance. One browser
2630
2713
  * bridge then runs permission → report → login → plan if needed → extraction in that order.
2631
2714
  * `setup`/`login`/`update` remain granular primitives; init picks the full product defaults.
2632
2715
  */
2633
2716
  async function cmdInit(flags) {
2634
- console.log("Setting up EchoMem — shared memory for all your coding agents, plus the live context HUD.\n");
2635
- // 1. Configure every installed agent + write AGENTS.md. Hold login + HUD so we control ordering.
2717
+ console.log("Setting up EchoMem — shared memory for all your coding agents.\n");
2718
+ // 1. Configure every installed agent + write AGENTS.md. Hold login so we control ordering.
2636
2719
  await cmdSetup({
2637
2720
  ...flags,
2638
2721
  all: true,
@@ -2641,10 +2724,7 @@ async function cmdInit(flags) {
2641
2724
  "init-quiet": true,
2642
2725
  "install-save-hooks": flags["no-save-hooks"] !== true,
2643
2726
  });
2644
- // 2. Bring the HUD up NOW (non-blocking) so everything is already running while onboarding proceeds.
2645
- if (!flags["no-hud"])
2646
- await cmdSetupHud(flags);
2647
- // 3. Start one ordered onboarding bridge. A fresh device logs in only after consent + report.
2727
+ // 2. Start one ordered onboarding bridge. A fresh device logs in only after consent + report.
2648
2728
  console.log("");
2649
2729
  if (!flags["skip-login"] && !flags["no-login"] && !await cmdOnboarding(flags)) {
2650
2730
  console.log("\nEchoMem is configured, but onboarding did not finish. Run `echomem-mcp init` again when you are ready.");
@@ -2653,13 +2733,7 @@ async function cmdInit(flags) {
2653
2733
  console.log("");
2654
2734
  console.log("🎉 EchoMem is ready.");
2655
2735
  console.log(" • MCP memory is configured for every coding agent installed on this machine.");
2656
- if (!flags["no-hud"]) {
2657
- console.log(' • The context HUD is running (top-right). Right-click it → "Show after restart" to keep it,');
2658
- console.log(' or just tell your agent "open the EchoMem HUD" anytime (it runs: echomem-hud app).');
2659
- }
2660
- else {
2661
- console.log(' • Start the context HUD anytime with: echomem-hud app');
2662
- }
2736
+ console.log(" • Echo Desktop shows connection status and manages this device credential.");
2663
2737
  console.log(' • Try it now: ask your agent — "search my EchoMem for what I\'ve been working on and recap it."');
2664
2738
  }
2665
2739
  /**
@@ -2742,55 +2816,6 @@ function selectSetupTargets(requested, all) {
2742
2816
  }
2743
2817
  return requested ? knownClients().filter((client) => client.id === requested) : detectClients();
2744
2818
  }
2745
- async function cmdSetupHud(flags) {
2746
- const client = parseHudClient(flags["hud-client"] || "auto");
2747
- const hudCli = resolveHudCliPath();
2748
- console.log("");
2749
- console.log(`✅ EchoMem HUD available: ${process.execPath} ${hudCli}`);
2750
- if (flags["install-hud-hooks"]) {
2751
- const paths = installHooks(client === "claude-desktop" ? "auto" : client);
2752
- console.log(`✅ Installed EchoMem HUD hook support:\n${paths.map((p) => ` - ${p}`).join("\n")}`);
2753
- console.log(" Codex users: run /hooks in a new Codex session to review and trust changed hooks.");
2754
- }
2755
- else {
2756
- console.log("ℹ️ HUD hooks not installed. Add --install-hud-hooks if you want lifecycle wakeups.");
2757
- }
2758
- if (!flags["no-launch-hud"]) {
2759
- try {
2760
- spawn(process.execPath, [hudCli, "app", "--client", client], { stdio: "ignore", detached: true }).unref();
2761
- console.log("✅ Launched EchoMem HUD app.");
2762
- }
2763
- catch {
2764
- console.log(`ℹ️ Could not auto-launch HUD. Run: echomem-hud app --client ${client}`);
2765
- }
2766
- }
2767
- else {
2768
- console.log(`Run the HUD later with: echomem-hud app --client ${client}`);
2769
- }
2770
- }
2771
- function resolveHudCliPath() {
2772
- const entry = fs.realpathSync(process.argv[1] || "");
2773
- const compiledEntry = compiledDistPathForSource(entry);
2774
- if (compiledEntry) {
2775
- const compiledHud = path.join(path.dirname(compiledEntry), "hud", "cli.js");
2776
- if (fs.existsSync(compiledHud))
2777
- return compiledHud;
2778
- }
2779
- const base = path.dirname(entry);
2780
- const candidate = path.join(base, "hud", "cli.js");
2781
- if (fs.existsSync(candidate))
2782
- return candidate;
2783
- const sourceCandidate = path.join(base, "hud", "cli.ts");
2784
- if (fs.existsSync(sourceCandidate)) {
2785
- throw new Error("The local HUD CLI is not built. Run npm --prefix packages/mcp-server run build and retry.");
2786
- }
2787
- return candidate;
2788
- }
2789
- function parseHudClient(value) {
2790
- return value === "codex" || value === "claude-code" || value === "claude-desktop" || value === "both" || value === "auto"
2791
- ? value
2792
- : "auto";
2793
- }
2794
2819
  function localBridgeOptions(flags) {
2795
2820
  const port = typeof flags["dev-port"] === "string" ? Number(flags["dev-port"]) : undefined;
2796
2821
  if (port !== undefined && (!Number.isInteger(port) || port < 1024 || port > 65535)) {
@@ -3063,7 +3088,23 @@ async function cmdOnboarding(flags) {
3063
3088
  extracted: 0,
3064
3089
  });
3065
3090
  };
3066
- const refreshLocalStatsForToken = async (activeToken) => {
3091
+ const publishOptionalStatsFallback = (generation, reason, countsTrusted) => {
3092
+ if (generation !== refreshGeneration)
3093
+ return;
3094
+ stats = completeOptionalStatsPayload(stats, reason, countsTrusted);
3095
+ srv.setStats(stats);
3096
+ const pending = countsTrusted ? latestPendingEstimate : 0;
3097
+ srv.setProgress({
3098
+ status: "idle",
3099
+ total: pending,
3100
+ completed: 0,
3101
+ running: 0,
3102
+ queued: pending,
3103
+ failed: 0,
3104
+ extracted: 0,
3105
+ });
3106
+ };
3107
+ const refreshLocalStatsForTokenCore = async (activeToken) => {
3067
3108
  const generation = ++refreshGeneration;
3068
3109
  lastProcessedImportKeys = null; // shared with /migrate so it can assemble only the pending sessions
3069
3110
  let importStatusUnavailable = false;
@@ -3268,15 +3309,31 @@ async function cmdOnboarding(flags) {
3268
3309
  return;
3269
3310
  stats = fullPayload;
3270
3311
  srv.setStats(fullPayload);
3271
- })();
3312
+ })().catch((e) => {
3313
+ if (generation !== refreshGeneration)
3314
+ return;
3315
+ console.error(`[echomem] optional full local-history stats unavailable; continuing (${errorCode(e) || "FULL_STATS_FAILED"})`);
3316
+ publishOptionalStatsFallback(generation, "FULL_STATS_FAILED", true);
3317
+ });
3272
3318
  return initialExact;
3273
3319
  }).catch((e) => {
3274
3320
  if (generation === refreshGeneration) {
3275
- console.error(`Could not finish exact local extraction estimate: ${e instanceof Error ? e.message : String(e)}`);
3321
+ console.error(`[echomem] optional exact local-history discovery unavailable; continuing (${errorCode(e) || "EXACT_DISCOVERY_FAILED"})`);
3322
+ publishOptionalStatsFallback(generation, "EXACT_DISCOVERY_FAILED", false);
3276
3323
  }
3277
3324
  return disc;
3278
3325
  });
3279
3326
  };
3327
+ const refreshLocalStatsForToken = async (activeToken) => {
3328
+ const expectedGeneration = refreshGeneration + 1;
3329
+ try {
3330
+ await refreshLocalStatsForTokenCore(activeToken);
3331
+ }
3332
+ catch (e) {
3333
+ console.error(`[echomem] optional local-history stats unavailable; continuing (${errorCode(e) || "OPTIONAL_STATS_FAILED"})`);
3334
+ publishOptionalStatsFallback(expectedGeneration, "OPTIONAL_STATS_FAILED", false);
3335
+ }
3336
+ };
3280
3337
  srv.setLogoutHandler(resetLocalLoginState);
3281
3338
  srv.setTokenRefreshHandler(async ({ token: nextToken, key: nextKey }) => {
3282
3339
  if (!await verifyAndPrint({ token: nextToken, key: nextKey }))
@@ -3712,14 +3769,13 @@ function cmdLogout() {
3712
3769
  const HELP = `EchoMem MCP — local memory bridge
3713
3770
 
3714
3771
  Usage:
3715
- echomem-mcp init One command: configure agents + HUD + login + local-history onboarding
3772
+ echomem-mcp init Legacy/headless setup: configure agents + login + local-history onboarding
3716
3773
  echomem-mcp Run the MCP server (stdio; default — used by your editor)
3717
3774
  echomem-mcp setup [--client X] Detect editor, write its MCP config, then connect this device
3718
3775
  echomem-mcp setup --skip-login Write MCP config without opening login/browser
3719
3776
  echomem-mcp setup --no-codex-skills Skip installing the bundled EchoMem Codex skills
3720
3777
  echomem-mcp update --all Install this bridge durably + repoint detected clients; no login/browser
3721
3778
  echomem-mcp update --client X Repoint one MCP client; no login/browser
3722
- echomem-mcp setup --with-hud Configure MCP, then launch the EchoMem context HUD
3723
3779
  echomem-mcp login Connect this device only; never scans or imports local history
3724
3780
  echomem-mcp login --force Reconnect this device with a different account
3725
3781
  echomem-mcp unlock Privately unlock the vault on this trusted device
@@ -3742,7 +3798,6 @@ Manual / headless:
3742
3798
  ${MCP_UPDATE_ALL_COMMAND} # one-shot latest update for detected clients, no browser login
3743
3799
  ${MCP_UPDATE_COMMAND} --client codex # update one client only
3744
3800
  echomem-mcp setup --dev /abs/path/dist/index.js # point clients at a local checkout
3745
- echomem-mcp setup --with-hud --install-hud-hooks --install-save-hooks --client codex [--hud-client auto]
3746
3801
  echomem-mcp setup --install-save-hooks --all Install proactive private-save completion checks
3747
3802
  echomem-mcp sync-usage --days 7 --limit 50 --dry-run
3748
3803
 
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { MEMORY_CITATION_INSTRUCTION, SAVED_MEMORY_RECEIPT_INSTRUCTION, withMcpVersion, } from "./package-metadata.js";
2
+ import { MCP_DESKTOP_MANAGED, MCP_VAULT_UNLOCK_INSTRUCTION, MEMORY_CITATION_INSTRUCTION, SAVED_MEMORY_RECEIPT_INSTRUCTION, withMcpVersion, } from "./package-metadata.js";
3
3
  export const canonicalToolNames = {
4
4
  search: "search_memories",
5
5
  save: "save_conversation",
@@ -13,6 +13,7 @@ export const canonicalToolNames = {
13
13
  recordCitations: "record_memory_citations",
14
14
  groupContext: "get_group_context",
15
15
  getGroupSessionSharing: "get_group_session_sharing",
16
+ requestGroupSessionSharing: "request_group_session_sharing",
16
17
  setGroupSessionSharing: "set_group_session_sharing",
17
18
  createGroup: "create_memory_group",
18
19
  createGroupInvite: "create_group_invite",
@@ -38,6 +39,127 @@ export const legacyAliasToCanonical = {
38
39
  export function resolveCanonicalToolName(toolName) {
39
40
  return legacyAliasToCanonical[toolName] ?? toolName;
40
41
  }
42
+ export const TOOL_DESCRIPTION_BYTE_LIMIT = 2_000;
43
+ const READ_ONLY_TOOL_NAMES = new Set([
44
+ canonicalToolNames.search,
45
+ "search_memories_by_description_semantic",
46
+ canonicalToolNames.timeRange,
47
+ "search_memories_by_time_range",
48
+ canonicalToolNames.keywords,
49
+ canonicalToolNames.friends,
50
+ canonicalToolNames.searchUsers,
51
+ canonicalToolNames.others,
52
+ canonicalToolNames.publicMemory,
53
+ canonicalToolNames.groupContext,
54
+ canonicalToolNames.getGroupSessionSharing,
55
+ canonicalToolNames.getByContext,
56
+ canonicalToolNames.checkpointByContext,
57
+ canonicalToolNames.report,
58
+ canonicalToolNames.updateStatus,
59
+ canonicalToolNames.contextHealth,
60
+ canonicalToolNames.recompose,
61
+ ]);
62
+ const IDEMPOTENT_WRITE_TOOL_NAMES = new Set([
63
+ canonicalToolNames.recordCitations,
64
+ canonicalToolNames.requestGroupSessionSharing,
65
+ canonicalToolNames.setGroupSessionSharing,
66
+ canonicalToolNames.updateGroupProfile,
67
+ canonicalToolNames.completeGroupPublication,
68
+ canonicalToolNames.publishToGroup,
69
+ canonicalToolNames.publishBatchToGroup,
70
+ ]);
71
+ const REQUIRES_USER_INTERACTION_TOOL_NAMES = new Set([
72
+ canonicalToolNames.requestGroupSessionSharing,
73
+ canonicalToolNames.setGroupSessionSharing,
74
+ canonicalToolNames.flagPublicationAttention,
75
+ canonicalToolNames.updateGroupProfile,
76
+ canonicalToolNames.completeGroupPublication,
77
+ canonicalToolNames.publishToGroup,
78
+ canonicalToolNames.publishBatchToGroup,
79
+ canonicalToolNames.delete,
80
+ ]);
81
+ const ALWAYS_LOAD_TOOL_NAMES = new Set([
82
+ canonicalToolNames.search,
83
+ canonicalToolNames.save,
84
+ ]);
85
+ const LOCAL_ONLY_TOOL_NAMES = new Set([
86
+ canonicalToolNames.report,
87
+ canonicalToolNames.contextHealth,
88
+ canonicalToolNames.recompose,
89
+ ]);
90
+ function truncateToByteBudget(value, limit) {
91
+ if (Buffer.byteLength(value, "utf8") <= limit)
92
+ return value;
93
+ const ellipsis = "…";
94
+ const contentBudget = limit - Buffer.byteLength(ellipsis, "utf8");
95
+ if (contentBudget <= 0)
96
+ return "";
97
+ let low = 0;
98
+ let high = value.length;
99
+ while (low < high) {
100
+ const middle = Math.ceil((low + high) / 2);
101
+ if (Buffer.byteLength(value.slice(0, middle), "utf8") <= contentBudget)
102
+ low = middle;
103
+ else
104
+ high = middle - 1;
105
+ }
106
+ const safeEnd = low > 0
107
+ && /[\uD800-\uDBFF]/.test(value.charAt(low - 1))
108
+ && /[\uDC00-\uDFFF]/.test(value.charAt(low))
109
+ ? low - 1
110
+ : low;
111
+ const candidate = value.slice(0, safeEnd);
112
+ const boundary = Math.max(candidate.lastIndexOf("\n"), candidate.lastIndexOf(" "));
113
+ const body = boundary >= Math.floor(candidate.length * 0.8) ? candidate.slice(0, boundary) : candidate;
114
+ return `${body.trimEnd()}${ellipsis}`;
115
+ }
116
+ function boundToolDescription(description) {
117
+ if (Buffer.byteLength(description, "utf8") <= TOOL_DESCRIPTION_BYTE_LIMIT)
118
+ return description;
119
+ const versionMarker = "\n\nEchoMem MCP bridge:";
120
+ const versionIndex = description.lastIndexOf(versionMarker);
121
+ if (versionIndex < 0) {
122
+ return truncateToByteBudget(description, TOOL_DESCRIPTION_BYTE_LIMIT);
123
+ }
124
+ const suffix = description.slice(versionIndex + 2);
125
+ const separator = "\n\n";
126
+ const bodyLimit = TOOL_DESCRIPTION_BYTE_LIMIT
127
+ - Buffer.byteLength(suffix, "utf8")
128
+ - Buffer.byteLength(separator, "utf8");
129
+ if (bodyLimit <= 0)
130
+ return truncateToByteBudget(suffix, TOOL_DESCRIPTION_BYTE_LIMIT);
131
+ return `${truncateToByteBudget(description.slice(0, versionIndex), bodyLimit)}${separator}${suffix}`;
132
+ }
133
+ function compactGroupRoster(groupMap) {
134
+ const labels = groupMap
135
+ .split("\n")
136
+ .map((line) => line.trim().replace(/^-\s*/, ""))
137
+ .filter(Boolean)
138
+ .map((line) => line.split(/\s+—\s+/u, 1)[0] ?? line)
139
+ .map((label) => truncateToByteBudget(label, 48));
140
+ return labels.length ? `Team roster: ${labels.join("; ")}.` : "";
141
+ }
142
+ function decorateLocalToolSpec(tool) {
143
+ const readOnly = READ_ONLY_TOOL_NAMES.has(tool.name);
144
+ const decorated = {
145
+ ...tool,
146
+ description: boundToolDescription(tool.description),
147
+ annotations: {
148
+ readOnlyHint: readOnly,
149
+ destructiveHint: tool.name === canonicalToolNames.delete,
150
+ idempotentHint: readOnly || IDEMPOTENT_WRITE_TOOL_NAMES.has(tool.name),
151
+ openWorldHint: !LOCAL_ONLY_TOOL_NAMES.has(tool.name),
152
+ },
153
+ };
154
+ const meta = {};
155
+ if (ALWAYS_LOAD_TOOL_NAMES.has(tool.name)) {
156
+ meta["anthropic/alwaysLoad"] = true;
157
+ }
158
+ if (REQUIRES_USER_INTERACTION_TOOL_NAMES.has(tool.name)) {
159
+ meta["anthropic/requiresUserInteraction"] = true;
160
+ }
161
+ return Object.keys(meta).length > 0 ? { ...decorated, _meta: meta } : decorated;
162
+ }
41
163
  const triggerMetadataSchema = {
42
164
  triggerMessage: z.string().optional(),
43
165
  triggerMessageRole: z.string().optional(),
@@ -129,6 +251,7 @@ export const getGroupSessionSharingSchema = z.object({
129
251
  groupSharingScopeId: z.string().uuid().optional(),
130
252
  groupId: z.string().uuid().optional(),
131
253
  });
254
+ export const requestGroupSessionSharingSchema = getGroupSessionSharingSchema;
132
255
  export const setGroupSessionSharingSchema = z.object({
133
256
  ...triggerMetadataSchema,
134
257
  groupSharingScopeId: z.string().uuid(),
@@ -224,9 +347,9 @@ export function listToolSpecs(opts = {}) {
224
347
  // Same device as the personal map, aimed at the group surface: the agent judges whether teammates
225
348
  // have covered the topic before searching, instead of never calling the group tools at all.
226
349
  const groupMapSection = groupMap
227
- ? `\n\nThis user's company group currently shares work in these areas (a relevance guide — search the group when the task relates to one of these people or topics):\n${groupMap}\n`
350
+ ? `\n\nThis user's company group currently shares work in these areas (a relevance guide — search the group when the task relates to one of these people or topics):\n${compactGroupRoster(groupMap)}\n${groupMap}\n`
228
351
  : "";
229
- return [
352
+ const tools = [
230
353
  {
231
354
  name: canonicalToolNames.search,
232
355
  description: withMcpVersion(`Recall the user's prior decisions, preferences, and project context from EchoMem — their long-term memory across ALL their AI tools, not just this session. Use it instead of re-deriving or re-asking what the user already settled. ${recallPlanNote} ${searchBillingReplyInstruction} ${memoryCitationInstruction}${mapSection}\nReturns ranked memories only; the MCP host model writes the final answer. Current time: ${currentTime}.${updateSection}`),
@@ -265,7 +388,7 @@ export function listToolSpecs(opts = {}) {
265
388
  },
266
389
  {
267
390
  name: canonicalToolNames.save,
268
- description: `Save durable knowledge from this conversation into the user's private EchoMem (durable memories are extracted automatically). Call before the final response when work produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone; this private checkpoint does not require a separate user request. Omit secrets, incidental chatter, and trivial read-only work. If the encrypted vault is locked, tell the user to run \`echomem-mcp unlock\` and never silently skip a qualifying checkpoint. Private persistence happens first. For group sharing, reuse the exact groupSharingScopeId returned by get_group_session_sharing or an earlier save in this conversation. Never reuse it in another conversation or save it as memory. Each group has an independent decision under the same conversation scope; eligible memories sync automatically to every approved group, while flagged memories stay private. If a selected group has no decision yet, ask whether to share with that named group. Silence leaves it unset; never infer the answer. New extraction input uses the plan's weekly processing allowance; if the limit is reached, nothing is saved. passthrough=true stores the text verbatim as a session capsule. ${SAVED_MEMORY_RECEIPT_INSTRUCTION}`,
391
+ description: `Save durable knowledge from this conversation into the user's private EchoMem (durable memories are extracted automatically). Call before the final response when work produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone; this private checkpoint does not require a separate user request. Omit secrets, incidental chatter, and trivial read-only work. If the encrypted vault is locked, tell the user to ${MCP_VAULT_UNLOCK_INSTRUCTION} and never silently skip a qualifying checkpoint. Private persistence happens first. For group sharing, reuse the exact groupSharingScopeId returned by get_group_session_sharing, request_group_session_sharing, or an earlier save in this conversation. Never reuse it in another conversation or save it as memory. Each group has an independent decision under the same conversation scope; eligible memories sync automatically to every approved group, while flagged memories stay private. If a selected group has no decision yet, call request_group_session_sharing so supported hosts render a choice UI; its fallback tells you when a text Yes/No prompt is required. Silence leaves consent unset; never infer the answer. New extraction input uses the plan's weekly processing allowance; if the limit is reached, nothing is saved. passthrough=true stores the text verbatim as a session capsule. ${SAVED_MEMORY_RECEIPT_INSTRUCTION}`,
269
392
  inputSchema: {
270
393
  type: "object",
271
394
  properties: {
@@ -513,7 +636,7 @@ export function listToolSpecs(opts = {}) {
513
636
  },
514
637
  {
515
638
  name: canonicalToolNames.getGroupSessionSharing,
516
- description: "Read the confirmed sharing decision for one group in this exact conversation. On the first call, omit groupSharingScopeId; EchoMem returns a new opaque scope. Reuse that scope only in this conversation. If the user belongs to multiple groups and groupId is omitted, EchoMem returns the available groups; call again with the same scope and one groupId at a time. Each group requires an independent explicit Yes/No decision. Silence leaves that group's state unset; never infer the answer.",
639
+ description: "Read the confirmed sharing decision for one group in this exact conversation. On the first call, omit groupSharingScopeId; EchoMem returns a new opaque scope. Reuse that scope only in this conversation. If the user belongs to multiple groups and groupId is omitted, EchoMem returns the available groups. If a decision is unset, call request_group_session_sharing with this scope and the selected groupId so supported hosts render a choice UI. Silence leaves that group's state unset; never infer the answer.",
517
640
  inputSchema: {
518
641
  type: "object",
519
642
  properties: {
@@ -532,6 +655,27 @@ export function listToolSpecs(opts = {}) {
532
655
  },
533
656
  },
534
657
  },
658
+ {
659
+ name: canonicalToolNames.requestGroupSessionSharing,
660
+ description: "Check one group's sharing decision for this exact conversation and, only when it is unset, ask the user through the MCP client's native choice UI. Omit groupSharingScopeId only on the first call so EchoMem can mint a fresh scope; reuse the returned scope only in this conversation. When multiple groups are returned, call again with the same scope and one groupId at a time. An accepted Share choice backfills eligible private memories and syncs later saves; Keep private records No. Decline or cancel records nothing. If the client cannot render MCP elicitation, relay the returned fallback question and call set_group_session_sharing only after the user explicitly answers.",
661
+ inputSchema: {
662
+ type: "object",
663
+ properties: {
664
+ groupSharingScopeId: {
665
+ type: "string",
666
+ format: "uuid",
667
+ description: "Reuse the opaque scope returned in this conversation. Omit only on the first sharing check.",
668
+ },
669
+ groupId: {
670
+ type: "string",
671
+ format: "uuid",
672
+ description: "Group to confirm. Required when the user belongs to multiple groups.",
673
+ },
674
+ triggerMessage: { type: "string" },
675
+ triggerMessageRole: { type: "string", default: "user" },
676
+ },
677
+ },
678
+ },
535
679
  {
536
680
  name: canonicalToolNames.setGroupSessionSharing,
537
681
  description: "Store the user's explicit Yes/No sharing decision for one group in this exact conversation scope. Pass the exact groupSharingScopeId returned in this conversation and the selected groupId when the user belongs to multiple groups. EchoMem validates membership server-side. Decisions are independent per group: share=true backfills that group and later saves sync to every approved group; share=false keeps this conversation private for that group. Flagged memories remain private.",
@@ -574,7 +718,7 @@ export function listToolSpecs(opts = {}) {
574
718
  },
575
719
  {
576
720
  name: canonicalToolNames.joinGroup,
577
- description: "Join a company memory group using an invite code after the user explicitly asks to join. Joining never publishes memories. Next call prepare_group_publication, infer a proposed title and responsibility summary from the user's own candidate memories, and ask the user to confirm the profile together with the publication preview. Also call get_group_session_sharing without a scope to mint one for this conversation and, if unset, ask whether memories saved from this conversation should be shared. Silence stays unset and should be prompted again at a later qualifying checkpoint until an explicit Yes or No.",
721
+ description: "Join a company memory group using an invite code after the user explicitly asks to join. Joining never publishes memories. Next call prepare_group_publication, infer a proposed title and responsibility summary from the user's own candidate memories, and ask the user to confirm the profile together with the publication preview. Also call request_group_session_sharing without a scope so supported hosts render a native sharing choice. Decline or cancel stays unset and should be prompted again at a later qualifying checkpoint.",
578
722
  inputSchema: {
579
723
  type: "object",
580
724
  properties: {
@@ -765,11 +909,11 @@ export function listToolSpecs(opts = {}) {
765
909
  },
766
910
  {
767
911
  name: canonicalToolNames.checkpointByContext,
768
- description: withMcpVersion(`Rebuild a clean-context checkpoint / decision log from one EchoMem contextId. ${recallPlanNote} Use this when the user or EchoMem HUD gives you a contextId for a renewed coding session and you need the session handoff, not a raw memory dump. It deterministically fetches that context and formats it as orientation state: decisions, carryover, constraints, and checkpoints. ${memoryCitationInstruction} Current time: ${currentTime}.`),
912
+ description: withMcpVersion(`Rebuild a clean-context checkpoint / decision log from one EchoMem contextId. ${recallPlanNote} Use this when the user or EchoMem returns a contextId for a renewed coding session and you need the session handoff, not a raw memory dump. It deterministically fetches that context and formats it as orientation state: decisions, carryover, constraints, and checkpoints. ${memoryCitationInstruction} Current time: ${currentTime}.`),
769
913
  inputSchema: {
770
914
  type: "object",
771
915
  properties: {
772
- contextId: { type: "string", description: "The EchoMem contextId shown by the HUD or returned by save_conversation / Renew session." },
916
+ contextId: { type: "string", description: "The EchoMem contextId returned by save_conversation or Renew session." },
773
917
  limit: { type: "number", default: 100 },
774
918
  triggerMessage: {
775
919
  type: "string",
@@ -787,7 +931,7 @@ export function listToolSpecs(opts = {}) {
787
931
  },
788
932
  {
789
933
  name: canonicalToolNames.updateStatus,
790
- description: `Check whether this installed EchoMem MCP bridge is behind the latest published npm version. Works without login, uploads no user transcript, and normal background checks are cached so EchoMem does not hit npm on every startup. If it reports an update, tell the user and offer to run the returned update command; after updating, the user must start a new agent/MCP session.${updateSection}`,
934
+ description: `Check whether this installed EchoMem MCP bridge is behind the latest version. Works without login, uploads no user transcript, and normal background checks are cached. ${MCP_DESKTOP_MANAGED ? "Echo Desktop owns updates for this runtime; direct the user back to the desktop app." : "If it reports an update, tell the user and offer to run the returned update command."} After updating, the user must start a new agent/MCP session.${updateSection}`,
791
935
  inputSchema: {
792
936
  type: "object",
793
937
  properties: {
@@ -801,7 +945,7 @@ export function listToolSpecs(opts = {}) {
801
945
  },
802
946
  {
803
947
  name: canonicalToolNames.contextHealth,
804
- description: "Show the current local Codex/Claude context-health score as markdown: clean percentage, tracked lower-bound dead-weight, redundant reads, source client, and token-count source. Use when the user asks about the context HUD, dirty context, context pollution, whether cleanup is worth it, or wants an in-chat fallback to the passive HUD. This reads local agent logs only and needs no login.",
948
+ description: "Show the current local Codex/Claude context-health score as markdown: clean percentage, tracked lower-bound dead-weight, redundant reads, source client, and token-count source. Use when the user asks about dirty context, context pollution, or whether cleanup is worth it. This on-demand tool reads local agent logs only and needs no login.",
805
949
  inputSchema: {
806
950
  type: "object",
807
951
  properties: {
@@ -841,4 +985,5 @@ export function listToolSpecs(opts = {}) {
841
985
  },
842
986
  },
843
987
  ];
988
+ return tools.map(decorateLocalToolSpec);
844
989
  }