@echomem/mcp 1.4.46 → 1.4.48

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.
Files changed (46) hide show
  1. package/README.md +22 -26
  2. package/dist/context-analysis/claude-native-canonical.js +2 -2
  3. package/dist/context-analysis/vendored-canonical.js +2 -2
  4. package/dist/context-analysis/workspace-report.js +3 -3
  5. package/dist/index.js +5 -8
  6. package/dist/local-jsonl.js +87 -0
  7. package/dist/migrate.js +6 -4
  8. package/dist/onboarding-stats.js +16 -0
  9. package/dist/save-checkpoint-hook.js +1 -1
  10. package/dist/setup-page/client-core.js +18 -372
  11. package/dist/setup-page/client-extraction.js +14 -129
  12. package/dist/setup-page/client-lifecycle.js +28 -101
  13. package/dist/setup-page/client.js +0 -2
  14. package/dist/setup-page/styles-extraction.js +1 -31
  15. package/dist/setup-page/styles-foundation.js +0 -89
  16. package/dist/setup-page/styles-mvp.js +10 -155
  17. package/dist/setup-page/styles-website-alignment.js +0 -204
  18. package/dist/setup-page/styles.js +0 -4
  19. package/dist/setup-page.js +4 -4
  20. package/dist/setup-preview.js +4 -212
  21. package/dist/setup.js +167 -622
  22. package/dist/v1-contract.js +0 -8
  23. package/package.json +5 -7
  24. package/dist/city/README.md +0 -9
  25. package/dist/city/echo-ai-city-only.html +0 -2232
  26. package/dist/city/echo-extraction-plate.html +0 -330
  27. package/dist/city/echo-face-cutout.png +0 -0
  28. package/dist/city/personality_stickers/bossy.png +0 -0
  29. package/dist/city/personality_stickers/ghosty.png +0 -0
  30. package/dist/city/personality_stickers/loopy.png +0 -0
  31. package/dist/city/personality_stickers/lusty.png +0 -0
  32. package/dist/city/personality_stickers/maxxy.png +0 -0
  33. package/dist/city/personality_stickers/tabby.png +0 -0
  34. package/dist/city/vendor/OrbitControls.js +0 -1417
  35. package/dist/city/vendor/RoundedBoxGeometry.js +0 -155
  36. package/dist/city/vendor/echo_general-file-21.riv +0 -0
  37. package/dist/city/vendor/rive.js +0 -8139
  38. package/dist/city/vendor/rive.wasm +0 -0
  39. package/dist/city/vendor/three.module.min.js +0 -6
  40. package/dist/forensics.js +0 -1531
  41. package/dist/report.js +0 -721
  42. package/dist/setup-page/client-report-audit.js +0 -819
  43. package/dist/setup-page/client-report-city.js +0 -356
  44. package/dist/setup-page/client-report.js +0 -6
  45. package/dist/setup-page/styles-city-report.js +0 -880
  46. package/dist/setup-page/styles-context-audit.js +0 -470
package/dist/setup.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Design goals from the spec:
5
5
  * - `login` establishes the account and trusted device only; it never reads local history.
6
- * - `init` runs one ordered flow: local-history permission/report, login, plan if needed, then extraction.
6
+ * - `init` runs one ordered flow: local-history permission, login, plan if needed, then extraction.
7
7
  * - Both secrets (API token + encryption key) ride a single browser flow and land in the local
8
8
  * keystore — never in the client's MCP config, never in the agent's chat context.
9
9
  * - Re-unlock after the key's TTL is one step, not a re-setup.
@@ -25,12 +25,11 @@ import { fileURLToPath, pathToFileURL } from "node:url";
25
25
  import axios from "axios";
26
26
  import { KeyStore } from "./keystore.js";
27
27
  import { fetchEncryptionConfig, deriveAndVerifyKey, setupNewEncryptionKey, verifyKeyB64 } from "./encryption.js";
28
- import { runReport, buildStatsPayload } from "./report.js";
28
+ import { buildOnboardingStatsPayload } from "./onboarding-stats.js";
29
29
  import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableSessions, discoverPendingSessionsTargeted, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, MIGRATE_CONCURRENCY, } from "./migrate.js";
30
30
  import { syncCodexUsage } from "./codex-sync.js";
31
31
  import { renderSetupPage } from "./setup-page.js";
32
32
  import { parseSetupPreviewState } from "./setup-preview.js";
33
- import { repoLabel, validateForensicReportForSetup } from "./forensics.js";
34
33
  import { installSaveCheckpointHooks, installSourceSessionHooks } from "./hud/hooks.js";
35
34
  import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
36
35
  import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
@@ -70,15 +69,25 @@ const CODEX_SKILL_NAMES = [
70
69
  function home(...p) {
71
70
  return path.join(os.homedir(), ...p);
72
71
  }
73
- function codexHome() {
74
- const configured = process.env.CODEX_HOME?.trim();
72
+ function configuredProfileDirectory(envKey, fallbackName) {
73
+ const configured = process.env[envKey]?.trim();
75
74
  if (!configured)
76
- return home(".codex");
75
+ return home(fallbackName);
77
76
  if (configured === "~")
78
77
  return os.homedir();
79
- if (configured.startsWith(`~${path.sep}`))
78
+ if (configured.startsWith("~/") || configured.startsWith("~\\")) {
80
79
  return path.join(os.homedir(), configured.slice(2));
81
- return path.resolve(configured);
80
+ }
81
+ if (!path.isAbsolute(configured)) {
82
+ throw new Error(`${envKey} must be an absolute path or start with ~/`);
83
+ }
84
+ return path.normalize(configured);
85
+ }
86
+ function codexHome() {
87
+ return configuredProfileDirectory("CODEX_HOME", ".codex");
88
+ }
89
+ function claudeConfigHome() {
90
+ return configuredProfileDirectory("CLAUDE_CONFIG_DIR", ".claude");
82
91
  }
83
92
  function filesEqual(left, right) {
84
93
  try {
@@ -401,7 +410,7 @@ function echomemGuidanceBlock() {
401
410
  "- EchoMem's local SessionStart hook automatically binds each new Codex or Claude Code conversation to its real source-session ID. Use `bind_source_session` with a fresh UUID only as a compatibility fallback when the hook is unavailable or reports that the session is unbound.",
402
411
  "- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
403
412
  '- 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.',
404
- "- 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.",
413
+ "- 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, relay the bridge's unlock instruction; for this standalone runtime, use `echomem-mcp unlock`. Never silently skip it.",
405
414
  '- 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.',
406
415
  "- 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`. Relay the tool's exact text question and call `set_group_session_sharing` only after an explicit Yes/No. 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.",
407
416
  "- 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.",
@@ -412,7 +421,7 @@ function echomemGuidanceBlock() {
412
421
  "- 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.",
413
422
  "- 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.",
414
423
  "- 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.",
415
- "- 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.",
424
+ "- 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, relay the bridge's local unlock instruction if the tool reports that the key is required.",
416
425
  "- 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.",
417
426
  "- 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.",
418
427
  "- 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.",
@@ -444,6 +453,35 @@ export function writeAgentsMemoryGuidance(filePath) {
444
453
  fs.appendFileSync(filePath, sep + block + "\n");
445
454
  return "wrote";
446
455
  }
456
+ /**
457
+ * Refresh marker-owned guidance for users upgrading an existing standalone MCP install.
458
+ * This intentionally does not create global memory files: setup owns first installation,
459
+ * while server startup only migrates guidance that EchoMem already installed.
460
+ */
461
+ export function refreshInstalledMemoryGuidance() {
462
+ let candidates;
463
+ try {
464
+ candidates = [
465
+ path.join(codexHome(), "AGENTS.md"),
466
+ path.join(claudeConfigHome(), "CLAUDE.md"),
467
+ ];
468
+ }
469
+ catch {
470
+ return;
471
+ }
472
+ for (const filePath of candidates) {
473
+ try {
474
+ const content = fs.readFileSync(filePath, "utf8");
475
+ const start = content.indexOf(AGENTS_MD_BEGIN);
476
+ const end = content.indexOf(AGENTS_MD_END);
477
+ if (start >= 0 && end > start)
478
+ writeAgentsMemoryGuidance(filePath);
479
+ }
480
+ catch {
481
+ // Missing, unreadable, or externally managed files are left untouched.
482
+ }
483
+ }
484
+ }
447
485
  /** Merge the EchoMem entry into a JSON client's `mcpServers` map without clobbering siblings. */
448
486
  export function writeJsonClientConfig(configPath, entry, options = {}) {
449
487
  let config = {};
@@ -622,6 +660,7 @@ export function writeClaudeCodeConfig(entry, options = {}) {
622
660
  // Older CLI versions wrote local/project entries, which take precedence over user scope and can
623
661
  // keep launching a deleted npm cache or stale runtime. Migrate those only after user scope is safe.
624
662
  const configPath = options.configPath ?? home(".claude.json");
663
+ let failureReason;
625
664
  const emptyResult = () => ({
626
665
  state: "unavailable",
627
666
  removedLocalProjects: [],
@@ -629,6 +668,7 @@ export function writeClaudeCodeConfig(entry, options = {}) {
629
668
  failedLocalProjects: [],
630
669
  restoredPreviousUserEntry: false,
631
670
  preservedDesktopManaged: false,
671
+ failureReason,
632
672
  });
633
673
  const runClaude = (args, cwd) => {
634
674
  try {
@@ -638,9 +678,11 @@ export function writeClaudeCodeConfig(entry, options = {}) {
638
678
  stdio: ["ignore", "pipe", "pipe"],
639
679
  timeout: 10000,
640
680
  }, options.claudeCommands);
681
+ failureReason = undefined;
641
682
  return true;
642
683
  }
643
- catch {
684
+ catch (error) {
685
+ failureReason = commandFailureMessage(error);
644
686
  return false;
645
687
  }
646
688
  };
@@ -659,8 +701,10 @@ export function writeClaudeCodeConfig(entry, options = {}) {
659
701
  if (previousUserEntry && !removeUser())
660
702
  return emptyResult();
661
703
  if (!addUser(desiredUserEntry)) {
704
+ const addFailureReason = failureReason;
662
705
  if (previousUserEntry)
663
706
  restoredPreviousUserEntry = addUser(previousUserEntry);
707
+ failureReason = addFailureReason;
664
708
  return { ...emptyResult(), restoredPreviousUserEntry };
665
709
  }
666
710
  }
@@ -1223,7 +1267,7 @@ function withTimeout(promise, ms, code, onTimeout) {
1223
1267
  function delay(ms) {
1224
1268
  return new Promise((resolve) => setTimeout(resolve, ms));
1225
1269
  }
1226
- const CITY_ASSET_TYPES = {
1270
+ const LOCAL_ASSET_TYPES = {
1227
1271
  ".html": "text/html; charset=utf-8",
1228
1272
  ".js": "text/javascript; charset=utf-8",
1229
1273
  ".json": "application/json; charset=utf-8",
@@ -1232,39 +1276,11 @@ const CITY_ASSET_TYPES = {
1232
1276
  ".png": "image/png",
1233
1277
  ".svg": "image/svg+xml",
1234
1278
  };
1235
- function repoCityArtifactsRoot() {
1236
- // Monorepo/dev: read the city assets live from repo-root /artifacts.
1237
- const repo = fileURLToPath(new URL("../../../artifacts/", import.meta.url));
1238
- if (fs.existsSync(repo))
1239
- return repo;
1240
- // Published install: fall back to the copy bundled into dist/city by prepack (bundle-city.mjs).
1241
- return fileURLToPath(new URL("./city/", import.meta.url));
1242
- }
1243
- function serveRepoCityAsset(reqPath, res) {
1244
- const root = repoCityArtifactsRoot();
1245
- const rel = reqPath === "/city" || reqPath === "/city/" ? "echo-ai-city-only.html" : decodeURIComponent(reqPath.slice("/city/".length));
1246
- // Archives stay in the checkout for recovery, but must never become a localhost UI surface.
1247
- const normalizedRel = rel.replace(/\\/g, "/");
1248
- if (normalizedRel === "archive" || normalizedRel.startsWith("archive/")) {
1249
- res.writeHead(404).end("not found");
1250
- return true;
1251
- }
1252
- const filePath = path.resolve(root, rel);
1253
- const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
1254
- if (!filePath.startsWith(rootWithSep)) {
1255
- res.writeHead(403).end("forbidden");
1256
- return true;
1257
- }
1258
- if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
1259
- res.writeHead(404).end("not found");
1260
- return true;
1261
- }
1262
- res.writeHead(200, {
1263
- "Content-Type": CITY_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
1264
- "Cache-Control": "no-store",
1265
- });
1266
- fs.createReadStream(filePath).pipe(res);
1267
- return true;
1279
+ function repoLabel(cwd) {
1280
+ if (!cwd)
1281
+ return "";
1282
+ const normalized = cwd.replace(/[\\/]+$/, "");
1283
+ return path.basename(normalized) || normalized;
1268
1284
  }
1269
1285
  function hudAssetsRoot() {
1270
1286
  return fileURLToPath(new URL("../assets/hud/", import.meta.url));
@@ -1283,7 +1299,7 @@ function serveHudAsset(reqPath, res) {
1283
1299
  return true;
1284
1300
  }
1285
1301
  res.writeHead(200, {
1286
- "Content-Type": CITY_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
1302
+ "Content-Type": LOCAL_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
1287
1303
  "Cache-Control": "no-store",
1288
1304
  });
1289
1305
  fs.createReadStream(filePath).pipe(res);
@@ -1460,61 +1476,6 @@ export function discoverMigratableFastOffThread(opts = {}) {
1460
1476
  });
1461
1477
  });
1462
1478
  }
1463
- /** Build the full local-history dashboard payload away from the callback server's event loop.
1464
- * `collect()` can synchronously parse hundreds of JSONL files for tens of seconds; doing that on
1465
- * the bridge thread prevents even localhost actions such as account switch from receiving a reply. */
1466
- export function buildCollectedStatsPayloadOffThread(inject) {
1467
- const reportUrl = runtimeModuleUrl("report");
1468
- const serializedInject = JSON.stringify(inject);
1469
- const code = `
1470
- import { parentPort } from "node:worker_threads";
1471
- import { collect, buildStatsPayload } from ${JSON.stringify(reportUrl)};
1472
-
1473
- try {
1474
- const payload = await buildStatsPayload(collect(), ${serializedInject});
1475
- parentPort?.postMessage({ ok: true, payload });
1476
- } catch (error) {
1477
- parentPort?.postMessage({
1478
- ok: false,
1479
- message: error instanceof Error ? error.message : String(error),
1480
- stack: error instanceof Error ? error.stack : undefined,
1481
- });
1482
- }
1483
- `;
1484
- const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
1485
- return new Promise((resolve, reject) => {
1486
- let settled = false;
1487
- const finish = (result) => {
1488
- if (settled)
1489
- return;
1490
- settled = true;
1491
- void worker.terminate();
1492
- if (result.ok)
1493
- resolve(result.payload);
1494
- else
1495
- reject(result.error);
1496
- };
1497
- worker.once("message", (message) => {
1498
- const msg = message;
1499
- if (msg.ok === true) {
1500
- finish({ ok: true, payload: msg.payload });
1501
- return;
1502
- }
1503
- const error = new Error(typeof msg.message === "string" ? msg.message : "Full local stats worker failed");
1504
- if (typeof msg.stack === "string")
1505
- error.stack = msg.stack;
1506
- finish({ ok: false, error });
1507
- });
1508
- worker.once("error", (error) => {
1509
- finish({ ok: false, error });
1510
- });
1511
- worker.once("exit", (code) => {
1512
- if (settled)
1513
- return;
1514
- finish({ ok: false, error: new Error(`Full local stats worker exited (code ${code}) without a result`) });
1515
- });
1516
- });
1517
- }
1518
1479
  export function createLocalDiscoveryCache(loaders = {}) {
1519
1480
  const loadQuick = loaders.loadQuick ?? (() => discoverMigratableFastOffThread());
1520
1481
  const loadExact = loaders.loadExact ?? (() => discoverMigratableSessionsOffThread());
@@ -1563,45 +1524,6 @@ export function createLocalDiscoveryCache(loaders = {}) {
1563
1524
  },
1564
1525
  };
1565
1526
  }
1566
- function forensicStageLabel(stage) {
1567
- if (stage === "reading-transcripts")
1568
- return "Reading transcript files";
1569
- if (stage === "building-summary")
1570
- return "Building scan summary";
1571
- if (stage === "classifying-repeated-context")
1572
- return "Classifying repeated context";
1573
- if (stage === "finalizing-report")
1574
- return "Finalizing report";
1575
- return "Starting local scan";
1576
- }
1577
- /** Build the local forensic "Context Doctor" report on a worker thread so the multi-file scan never
1578
- * blocks the bridge's event loop (the freeze postmortem: any unbounded sync work on this path is risky). */
1579
- export function buildForensicReportOffThread(onProgress, options = {}) {
1580
- let lastProgress = null;
1581
- const recordProgress = (progress) => {
1582
- lastProgress = progress;
1583
- onProgress?.(progress);
1584
- };
1585
- return runForensicReportWorker(recordProgress, options).catch(async (primaryError) => {
1586
- if (options.failOpen === false)
1587
- throw primaryError;
1588
- const failureCode = errorCode(primaryError) || "REPORT_BUILD_FAILED";
1589
- console.error(`[echomem] local scan degraded after ${failureCode}; continuing without local-history analysis`);
1590
- onProgress?.({
1591
- done: lastProgress?.done || 0,
1592
- total: lastProgress?.total || 0,
1593
- stage: "finalizing-report",
1594
- detail: "finishing setup without optional local-history analysis",
1595
- overall: 0.99,
1596
- stageDone: 0,
1597
- stageTotal: 0,
1598
- });
1599
- return runForensicReportWorker(undefined, {
1600
- timeoutMs: 30_000,
1601
- maxOldGenerationSizeMb: Math.max(64, options.maxOldGenerationSizeMb || 0),
1602
- }, [], failureCode);
1603
- });
1604
- }
1605
1527
  function errorCode(error) {
1606
1528
  return error && typeof error === "object" && "code" in error
1607
1529
  ? String(error.code || "")
@@ -1621,7 +1543,6 @@ export function completeOptionalStatsPayload(payload, reason, countsTrusted) {
1621
1543
  transcriptsUploaded: false,
1622
1544
  sessions: { total: 0, codex: 0, claudeCode: 0, cowork: 0 },
1623
1545
  migratable: { pending: 0, alreadyMigrated: 0 },
1624
- memoriesCaptured: null,
1625
1546
  };
1626
1547
  const completed = payload && typeof payload === "object" && !Array.isArray(payload)
1627
1548
  ? { ...payload }
@@ -1634,155 +1555,11 @@ export function completeOptionalStatsPayload(payload, reason, countsTrusted) {
1634
1555
  };
1635
1556
  return completed;
1636
1557
  }
1637
- function runForensicReportWorker(onProgress, options, sources, degradedReason) {
1638
- const forensicsUrl = runtimeModuleUrl("forensics");
1639
- const serializedSources = sources === undefined ? "undefined" : JSON.stringify(sources);
1640
- const serializedDegradedReason = JSON.stringify(degradedReason || "");
1641
- const code = `
1642
- import { parentPort } from "node:worker_threads";
1643
- import { buildForensicReport, validateForensicReportForSetup } from ${JSON.stringify(forensicsUrl)};
1644
- try {
1645
- const report = await buildForensicReport({
1646
- sources: ${serializedSources},
1647
- includeLegacyGoldenStandard: false,
1648
- onProgress: (done, total, stage, detail, overall, stageDone, stageTotal) => parentPort?.postMessage({
1649
- progress: { done, total, stage, detail, overall, stageDone, stageTotal },
1650
- }),
1651
- });
1652
- const degradedReason = ${serializedDegradedReason};
1653
- if (degradedReason) {
1654
- report.scanDiagnostics = {
1655
- degraded: true,
1656
- reason: degradedReason,
1657
- skippedSources: ["codex", "claude"],
1658
- };
1659
- }
1660
- const validation = validateForensicReportForSetup(report);
1661
- if (!validation.ok) {
1662
- const error = new Error(validation.message);
1663
- error.code = validation.code;
1664
- throw error;
1665
- }
1666
- parentPort?.postMessage({ ok: true, report });
1667
- } catch (error) {
1668
- parentPort?.postMessage({
1669
- ok: false,
1670
- message: error instanceof Error ? error.message : String(error),
1671
- code: error && typeof error === "object" && "code" in error ? String(error.code || "") : "",
1672
- });
1673
- }
1674
- `;
1675
- const requestedHeapMb = options.maxOldGenerationSizeMb;
1676
- const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`), Number.isFinite(requestedHeapMb)
1677
- ? { resourceLimits: { maxOldGenerationSizeMb: Math.max(16, Math.floor(requestedHeapMb)) } }
1678
- : undefined);
1679
- return new Promise((resolve, reject) => {
1680
- let settled = false;
1681
- const requestedTimeoutMs = options.timeoutMs ?? 15 * 60_000;
1682
- const timeoutMs = Number.isFinite(requestedTimeoutMs) ? Math.max(1, requestedTimeoutMs) : 15 * 60_000;
1683
- const timeout = setTimeout(() => {
1684
- if (settled)
1685
- return;
1686
- settled = true;
1687
- void worker.terminate();
1688
- const error = new Error(`Local forensic report timed out after ${timeoutMs}ms`);
1689
- error.code = "REPORT_SCAN_TIMEOUT";
1690
- reject(error);
1691
- }, timeoutMs);
1692
- timeout.unref?.();
1693
- const finish = (result) => {
1694
- if (settled)
1695
- return;
1696
- settled = true;
1697
- clearTimeout(timeout);
1698
- void worker.terminate();
1699
- if (result.ok)
1700
- resolve(result.report);
1701
- else
1702
- reject(result.error);
1703
- };
1704
- worker.on("message", (message) => {
1705
- if (settled)
1706
- return;
1707
- const msg = message;
1708
- if (msg.progress) {
1709
- onProgress?.(msg.progress);
1710
- return;
1711
- }
1712
- if (msg.ok === true && msg.report && typeof msg.report === "object") {
1713
- finish({ ok: true, report: msg.report });
1714
- return;
1715
- }
1716
- const error = new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed");
1717
- if (typeof msg.code === "string" && msg.code)
1718
- error.code = msg.code;
1719
- finish({ ok: false, error });
1720
- });
1721
- worker.once("error", (error) => {
1722
- finish({ ok: false, error });
1723
- });
1724
- worker.once("exit", (code) => {
1725
- if (settled)
1726
- return;
1727
- finish({ ok: false, error: new Error(`Forensic report worker exited (code ${code}) without a result`) });
1728
- });
1729
- });
1730
- }
1731
1558
  export function respondMigrate(res, body, status = 200) {
1732
1559
  if (res.writableEnded)
1733
1560
  return;
1734
1561
  res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
1735
1562
  }
1736
- function safeForensicError(error) {
1737
- const code = error && typeof error === "object" && "code" in error
1738
- ? String(error.code || "")
1739
- : "";
1740
- if (code === "REPORT_SCAN_TIMEOUT") {
1741
- return {
1742
- code,
1743
- message: "The local workspace scan took too long and was stopped. No backup data was substituted. Rerun setup to retry.",
1744
- };
1745
- }
1746
- return {
1747
- code: "REPORT_BUILD_FAILED",
1748
- message: "EchoMem could not finish the local workspace scan. No backup data was substituted. Rerun setup to retry.",
1749
- };
1750
- }
1751
- function publicRunningForensicProgress(value) {
1752
- if (!value || typeof value !== "object")
1753
- return null;
1754
- const progress = value;
1755
- if (progress.status !== "running")
1756
- return null;
1757
- const safeCount = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
1758
- ? Math.floor(candidate)
1759
- : 0);
1760
- const safeDuration = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
1761
- ? candidate
1762
- : 0);
1763
- const safeFraction = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate)
1764
- ? Math.min(1, Math.max(0, candidate))
1765
- : 0);
1766
- const total = safeCount(progress.total);
1767
- const stageTotal = safeCount(progress.stageTotal);
1768
- const rawStage = typeof progress.stage === "string" ? progress.stage : "starting";
1769
- const stage = ["starting", "reading-transcripts", "building-summary", "classifying-repeated-context", "finalizing-report"].includes(rawStage)
1770
- ? rawStage
1771
- : "starting";
1772
- return {
1773
- status: "running",
1774
- scanned: total > 0 ? Math.min(safeCount(progress.scanned), total) : 0,
1775
- total,
1776
- stage,
1777
- label: forensicStageLabel(stage),
1778
- stageDone: stageTotal > 0 ? Math.min(safeCount(progress.stageDone), stageTotal) : 0,
1779
- stageTotal,
1780
- overall: safeFraction(progress.overall),
1781
- elapsedMs: safeDuration(progress.elapsedMs),
1782
- stageElapsedMs: safeDuration(progress.stageElapsedMs),
1783
- updatedAt: safeDuration(progress.updatedAt) || Date.now(),
1784
- };
1785
- }
1786
1563
  /**
1787
1564
  * Start the persistent localhost bridge used by the setup page. It sends/verifies OTP through the
1788
1565
  * hosted API, accepts the local passphrase, serves local Wrapped stats, and holds the /migrate
@@ -1795,12 +1572,11 @@ export function startCallbackServer(opts = {}) {
1795
1572
  : `${Math.ceil(timeoutMs / 1000)} seconds`;
1796
1573
  const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
1797
1574
  const expectedNonce = opts.nonce;
1798
- const scanId = opts.scanId ?? randomUUID();
1799
1575
  const flow = opts.flow ?? "onboarding";
1800
1576
  const isLoginFlow = flow === "login";
1801
1577
  // A login screen must not be blocked by a local-history permission. That permission belongs to
1802
1578
  // onboarding and is intentionally enforced separately below.
1803
- const requiresReportConsent = opts.requireReportConsent === true && !isLoginFlow;
1579
+ const requiresLocalHistoryConsent = opts.requireLocalHistoryConsent === true && !isLoginFlow;
1804
1580
  return new Promise((resolveOuter, rejectOuter) => {
1805
1581
  const onToken = deferred();
1806
1582
  const setupExit = deferred();
@@ -1813,7 +1589,7 @@ export function startCallbackServer(opts = {}) {
1813
1589
  let activeDeviceToken = opts.initialToken?.token || "";
1814
1590
  let activeAccountEmail = "";
1815
1591
  let pendingLocalAuth = null;
1816
- let reportConsentGranted = !requiresReportConsent;
1592
+ let localHistoryConsentGranted = !requiresLocalHistoryConsent;
1817
1593
  let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
1818
1594
  let migrateStarted = false;
1819
1595
  let tokenRefreshHandler = null;
@@ -1880,7 +1656,7 @@ export function startCallbackServer(opts = {}) {
1880
1656
  const handleCallback = (res, token, key, nonce) => {
1881
1657
  if (!checkNonce(nonce))
1882
1658
  return void text(res, 403, "bad nonce");
1883
- if (requiresReportConsent && !reportConsentGranted) {
1659
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
1884
1660
  return void json(res, 403, {
1885
1661
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1886
1662
  message: "Allow local history access in the setup page before connecting EchoMem.",
@@ -1910,7 +1686,7 @@ export function startCallbackServer(opts = {}) {
1910
1686
  text(res, 403, "bad nonce");
1911
1687
  return true;
1912
1688
  }
1913
- if (requiresReportConsent && !reportConsentGranted) {
1689
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
1914
1690
  json(res, 403, {
1915
1691
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1916
1692
  message: "Allow local history access in the setup page before connecting EchoMem.",
@@ -1937,8 +1713,7 @@ export function startCallbackServer(opts = {}) {
1937
1713
  armTimeout();
1938
1714
  };
1939
1715
  const isOnboardingOnlyRoute = (route) => [
1940
- "/report-consent",
1941
- "/report",
1716
+ "/local-history-consent",
1942
1717
  "/stats",
1943
1718
  "/billing-status",
1944
1719
  "/billing-checkout",
@@ -2131,10 +1906,6 @@ export function startCallbackServer(opts = {}) {
2131
1906
  message: "Run `echomem-mcp init` to access local-history onboarding.",
2132
1907
  });
2133
1908
  }
2134
- if ((route === "/city" || route.startsWith("/city/")) && req.method === "GET") {
2135
- serveRepoCityAsset(route, res);
2136
- return;
2137
- }
2138
1909
  if (route.startsWith("/hud-assets/") && req.method === "GET") {
2139
1910
  serveHudAsset(route, res);
2140
1911
  return;
@@ -2172,8 +1943,13 @@ export function startCallbackServer(opts = {}) {
2172
1943
  localOnly: true,
2173
1944
  localAuth: true,
2174
1945
  workspacePath: process.cwd(),
2175
- consentRequired: requiresReportConsent,
2176
- consentGranted: reportConsentGranted,
1946
+ consentRequired: requiresLocalHistoryConsent,
1947
+ consentGranted: localHistoryConsentGranted,
1948
+ platform: process.platform,
1949
+ capabilities: {
1950
+ openClaudeDesktop: process.platform === "darwin" || process.platform === "win32",
1951
+ openAgentSessions: process.platform === "darwin" || process.platform === "win32",
1952
+ },
2177
1953
  });
2178
1954
  return;
2179
1955
  }
@@ -2277,7 +2053,7 @@ export function startCallbackServer(opts = {}) {
2277
2053
  if (route === "/stats" && req.method === "GET") {
2278
2054
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
2279
2055
  return void text(res, 403, "bad nonce");
2280
- if (requiresReportConsent && !reportConsentGranted) {
2056
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2281
2057
  return void json(res, 403, {
2282
2058
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2283
2059
  message: "Allow local history access before continuing setup.",
@@ -2293,7 +2069,7 @@ export function startCallbackServer(opts = {}) {
2293
2069
  res.setHeader("Cache-Control", "no-store, max-age=0");
2294
2070
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
2295
2071
  return void text(res, 403, "bad nonce");
2296
- if (requiresReportConsent && !reportConsentGranted) {
2072
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2297
2073
  return void json(res, 403, {
2298
2074
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2299
2075
  message: "Allow local history access before continuing setup.",
@@ -2417,7 +2193,7 @@ export function startCallbackServer(opts = {}) {
2417
2193
  }
2418
2194
  if (!checkNonce(asString(body.nonce)))
2419
2195
  return void text(res, 403, "bad nonce");
2420
- if (requiresReportConsent && !reportConsentGranted) {
2196
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2421
2197
  return void json(res, 403, {
2422
2198
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2423
2199
  message: "Allow local history access before managing an onboarding plan.",
@@ -2483,114 +2259,10 @@ export function startCallbackServer(opts = {}) {
2483
2259
  }
2484
2260
  return;
2485
2261
  }
2486
- if (route === "/report" && req.method === "GET") {
2487
- // Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
2488
- res.setHeader("Cache-Control", "no-store");
2489
- if (!checkNonce(url.searchParams.get("nonce") || undefined))
2490
- return void text(res, 403, "bad nonce");
2491
- if (requiresReportConsent && !reportConsentGranted) {
2492
- return void json(res, 403, {
2493
- error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2494
- message: "Allow local history access before starting the local scan.",
2495
- });
2496
- }
2497
- let payload;
2498
- try {
2499
- payload = opts.getReport ? opts.getReport() : null;
2500
- }
2501
- catch {
2502
- return void json(res, 500, {
2503
- schemaVersion: 1,
2504
- kind: "failed",
2505
- mode: "production",
2506
- scanId,
2507
- error: {
2508
- code: "REPORT_STATE_UNAVAILABLE",
2509
- message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
2510
- },
2511
- });
2512
- }
2513
- if (payload == null) {
2514
- // 202 carries scan progress so the page can show a live "scanned N/total" indicator.
2515
- let prog;
2516
- try {
2517
- prog = opts.getReportProgress ? opts.getReportProgress() : {
2518
- status: "running",
2519
- scanned: 0,
2520
- total: 0,
2521
- stage: "starting",
2522
- label: "Starting local scan",
2523
- elapsedMs: 0,
2524
- stageElapsedMs: 0,
2525
- updatedAt: Date.now(),
2526
- };
2527
- }
2528
- catch {
2529
- return void json(res, 500, {
2530
- schemaVersion: 1,
2531
- kind: "failed",
2532
- mode: "production",
2533
- scanId,
2534
- error: {
2535
- code: "REPORT_STATE_UNAVAILABLE",
2536
- message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
2537
- },
2538
- });
2539
- }
2540
- if (prog && typeof prog === "object" && prog.status === "failed") {
2541
- return void json(res, 500, {
2542
- schemaVersion: 1,
2543
- kind: "failed",
2544
- mode: "production",
2545
- scanId,
2546
- error: safeForensicError(prog.error),
2547
- });
2548
- }
2549
- const publicProgress = publicRunningForensicProgress(prog);
2550
- if (!publicProgress) {
2551
- return void json(res, 500, {
2552
- schemaVersion: 1,
2553
- kind: "failed",
2554
- mode: "production",
2555
- scanId,
2556
- error: {
2557
- code: "REPORT_STATE_INVALID",
2558
- message: "EchoMem received an invalid local scan state. No backup data was substituted. Rerun setup to retry.",
2559
- },
2560
- });
2561
- }
2562
- return void json(res, 202, {
2563
- schemaVersion: 1,
2564
- kind: "scanning",
2565
- mode: "production",
2566
- scanId,
2567
- progress: publicProgress,
2568
- });
2569
- }
2570
- const validation = validateForensicReportForSetup(payload);
2571
- if (!validation.ok) {
2572
- console.error(`[echomem] local report validation failed: ${validation.code} — ${validation.message}`);
2573
- return void json(res, 500, {
2574
- schemaVersion: 1,
2575
- kind: "failed",
2576
- mode: "production",
2577
- scanId,
2578
- error: { code: validation.code, message: validation.message },
2579
- });
2580
- }
2581
- json(res, 200, {
2582
- schemaVersion: 1,
2583
- kind: validation.kind,
2584
- mode: "production",
2585
- scanId,
2586
- report: validation.report,
2587
- });
2588
- return;
2589
- }
2590
2262
  if (route === "/progress" && req.method === "GET") {
2591
2263
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
2592
2264
  return void text(res, 403, "bad nonce");
2593
- if (requiresReportConsent && !reportConsentGranted) {
2265
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2594
2266
  return void json(res, 403, {
2595
2267
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2596
2268
  message: "Allow local history access before continuing setup.",
@@ -2599,7 +2271,7 @@ export function startCallbackServer(opts = {}) {
2599
2271
  json(res, 200, progress);
2600
2272
  return;
2601
2273
  }
2602
- if (route === "/report-consent" && req.method === "POST") {
2274
+ if (route === "/local-history-consent" && req.method === "POST") {
2603
2275
  let body;
2604
2276
  try {
2605
2277
  body = await readJsonBody(req);
@@ -2611,8 +2283,7 @@ export function startCallbackServer(opts = {}) {
2611
2283
  if (!checkNonce(asString(body.nonce)))
2612
2284
  return void text(res, 403, "bad nonce");
2613
2285
  const allowed = body.allowed === true;
2614
- reportConsentGranted = allowed;
2615
- opts.onReportConsent?.(allowed);
2286
+ localHistoryConsentGranted = allowed;
2616
2287
  json(res, 200, { ok: true, allowed });
2617
2288
  return;
2618
2289
  }
@@ -2671,7 +2342,7 @@ export function startCallbackServer(opts = {}) {
2671
2342
  }
2672
2343
  if (!checkNonce(asString(body.nonce)))
2673
2344
  return void text(res, 403, "bad nonce");
2674
- if (requiresReportConsent && !reportConsentGranted) {
2345
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2675
2346
  return void json(res, 403, {
2676
2347
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2677
2348
  message: "Allow local history access before starting extraction.",
@@ -2933,6 +2604,7 @@ async function cmdSetup(flags) {
2933
2604
  // --dev is already an explicit request to replace the managed runtime with a checkout.
2934
2605
  const forceHeadless = flags["force-headless"] === true || typeof flags.dev === "string";
2935
2606
  const configurationFailures = [];
2607
+ const configuredTargets = [];
2936
2608
  if (targets.length === 0) {
2937
2609
  console.log("No client auto-detected. Add this MCP server entry manually:\n");
2938
2610
  console.log(JSON.stringify({ echomem: entry }, null, 2));
@@ -2940,66 +2612,90 @@ async function cmdSetup(flags) {
2940
2612
  }
2941
2613
  else {
2942
2614
  for (const c of targets) {
2943
- if (c.kind === "json") {
2944
- const result = writeJsonClientConfig(c.configPath, entry, { forceHeadless });
2945
- if (result === "desktop-managed") {
2946
- console.log(`✅ Kept the valid Echo Desktop-managed EchoMem entry for ${c.label}: ${c.configPath}`);
2947
- }
2948
- else {
2949
- console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath}`);
2950
- }
2951
- }
2952
- else if (c.kind === "command") {
2953
- const result = writeCodexConfig(c.configPath, entry, { forceHeadless });
2954
- if (result === "wrote")
2955
- console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath} — start a new Codex session to load it.`);
2956
- else if (result === "desktop-managed")
2957
- console.log(`✅ Kept the valid Echo Desktop-managed EchoMem entry for ${c.label}: ${c.configPath}`);
2958
- else
2959
- console.log(`✅ ${c.label} already has the EchoMem MCP entry: ${c.configPath}`);
2960
- }
2961
- else {
2962
- const result = c.id === "claude-code"
2963
- ? writeClaudeCodeConfig(entry, { forceHeadless })
2964
- : "unavailable";
2965
- if (result !== "unavailable" && result.state === "wrote") {
2966
- if (result.preservedDesktopManaged) {
2967
- console.log(`✅ Kept the valid Echo Desktop-managed EchoMem user entry for ${c.label}.`);
2615
+ try {
2616
+ if (c.kind === "json") {
2617
+ const result = writeJsonClientConfig(c.configPath, entry, { forceHeadless });
2618
+ if (result === "desktop-managed") {
2619
+ console.log(`✅ Kept the valid externally managed EchoMem entry for ${c.label}: ${c.configPath}`);
2968
2620
  }
2969
2621
  else {
2970
- console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
2971
- }
2972
- if (result.removedLocalProjects.length > 0) {
2973
- console.log(`✅ Removed ${result.removedLocalProjects.length} stale Claude Code project-local EchoMem ${result.removedLocalProjects.length === 1 ? "entry" : "entries"}.`);
2974
- }
2975
- if (result.skippedLocalProjects.length > 0) {
2976
- console.log(`ℹ️ Ignored ${result.skippedLocalProjects.length} EchoMem local ${result.skippedLocalProjects.length === 1 ? "entry" : "entries"} for deleted project directories; they cannot shadow the user entry.`);
2622
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath}`);
2977
2623
  }
2624
+ configuredTargets.push(c);
2625
+ }
2626
+ else if (c.kind === "command") {
2627
+ const result = writeCodexConfig(c.configPath, entry, { forceHeadless });
2628
+ if (result === "wrote")
2629
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath} — start a new Codex session to load it.`);
2630
+ else if (result === "desktop-managed")
2631
+ console.log(`✅ Kept the valid externally managed EchoMem entry for ${c.label}: ${c.configPath}`);
2632
+ else
2633
+ console.log(`✅ ${c.label} already has the EchoMem MCP entry: ${c.configPath}`);
2634
+ configuredTargets.push(c);
2978
2635
  }
2979
2636
  else {
2980
- const failedProjects = result === "unavailable" ? [] : result.failedLocalProjects;
2981
- configurationFailures.push(failedProjects.length > 0
2982
- ? `${c.label} still has project-local EchoMem overrides in: ${failedProjects.join(", ")}`
2983
- : `${c.label} user-scoped EchoMem entry could not be verified`);
2637
+ const result = c.id === "claude-code"
2638
+ ? writeClaudeCodeConfig(entry, { forceHeadless })
2639
+ : "unavailable";
2640
+ if (result !== "unavailable" && result.state === "wrote") {
2641
+ if (result.preservedDesktopManaged) {
2642
+ console.log(`✅ Kept the valid externally managed EchoMem user entry for ${c.label}.`);
2643
+ }
2644
+ else {
2645
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
2646
+ }
2647
+ if (result.removedLocalProjects.length > 0) {
2648
+ console.log(`✅ Removed ${result.removedLocalProjects.length} stale Claude Code project-local EchoMem ${result.removedLocalProjects.length === 1 ? "entry" : "entries"}.`);
2649
+ }
2650
+ if (result.skippedLocalProjects.length > 0) {
2651
+ console.log(`ℹ️ Ignored ${result.skippedLocalProjects.length} EchoMem local ${result.skippedLocalProjects.length === 1 ? "entry" : "entries"} for deleted project directories; they cannot shadow the user entry.`);
2652
+ }
2653
+ configuredTargets.push(c);
2654
+ }
2655
+ else {
2656
+ const failedProjects = result === "unavailable" ? [] : result.failedLocalProjects;
2657
+ const reason = result === "unavailable" ? undefined : result.failureReason;
2658
+ if (result !== "unavailable" && result.state === "needs-repair")
2659
+ configuredTargets.push(c);
2660
+ configurationFailures.push({
2661
+ client: c,
2662
+ reason: failedProjects.length > 0
2663
+ ? `${c.label} still has project-local EchoMem overrides in: ${failedProjects.join(", ")}`
2664
+ : `${c.label} user-scoped EchoMem entry could not be verified${reason ? `: ${reason}` : ""}`,
2665
+ });
2666
+ }
2984
2667
  }
2985
2668
  }
2669
+ catch (error) {
2670
+ configurationFailures.push({
2671
+ client: c,
2672
+ reason: `${c.label} configuration was left unchanged: ${error instanceof Error ? error.message : String(error)}`,
2673
+ });
2674
+ }
2986
2675
  }
2987
2676
  }
2988
2677
  if (configurationFailures.length > 0) {
2989
- throw new Error([
2990
- "EchoMem MCP configuration is incomplete; onboarding was stopped before login/import.",
2991
- ...configurationFailures.map((failure) => `- ${failure}`),
2992
- `Retry with: ${MCP_UPDATE_COMMAND} --client claude-code`,
2993
- ].join("\n"));
2678
+ const explicitSingleClient = Boolean(requested && requested !== "all" && flags.all !== true);
2679
+ const failureMessage = [
2680
+ explicitSingleClient
2681
+ ? "EchoMem could not configure the requested MCP client."
2682
+ : "EchoMem skipped MCP clients that were unavailable or could not be configured.",
2683
+ ...configurationFailures.map((failure) => `- ${failure.reason}`),
2684
+ ...configurationFailures.map((failure) => `Retry ${failure.client.label} later with: ${MCP_UPDATE_COMMAND} --client ${failure.client.id}`),
2685
+ ].join("\n");
2686
+ if (explicitSingleClient)
2687
+ throw new Error(failureMessage);
2688
+ console.log(`⚠️ ${failureMessage}`);
2689
+ console.log("Continuing with the available clients; account connection and local-history import are not blocked.");
2994
2690
  }
2995
2691
  if (!flags["no-agents-md"]) {
2996
- writeMemoryGuidanceForTargets(targets);
2692
+ writeMemoryGuidanceForTargets(configuredTargets);
2997
2693
  }
2998
2694
  if (!flags["no-codex-skills"]) {
2999
- writeCodexSkillsForTargets(targets);
2695
+ writeCodexSkillsForTargets(configuredTargets);
3000
2696
  }
3001
2697
  if (flags["no-save-hooks"] !== true) {
3002
- writeLifecycleHooksForTargets(targets);
2698
+ writeLifecycleHooksForTargets(configuredTargets);
3003
2699
  }
3004
2700
  console.log("");
3005
2701
  if (flags["skip-login"] || flags["no-login"]) {
@@ -3011,14 +2707,14 @@ async function cmdSetup(flags) {
3011
2707
  await cmdLogin(flags);
3012
2708
  }
3013
2709
  if (flags["with-hud"]) {
3014
- console.log("ℹ️ The standalone EchoMem HUD has been retired. Echo Desktop now owns setup and status.");
2710
+ console.log("ℹ️ The standalone EchoMem HUD has been retired. Use `echomem-mcp status` to inspect this installation.");
3015
2711
  }
3016
2712
  }
3017
2713
  /**
3018
2714
  * `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
3019
2715
  * machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), installs EchoMem's
3020
2716
  * Codex skills and writes the AGENTS.md memory guidance. One browser
3021
- * bridge then runs permission → report → login → plan if needed → extraction in that order.
2717
+ * bridge then runs permission → login → plan if needed → extraction in that order.
3022
2718
  * `setup`/`login`/`update` remain granular primitives; init picks the full product defaults.
3023
2719
  */
3024
2720
  async function cmdInit(flags) {
@@ -3032,7 +2728,7 @@ async function cmdInit(flags) {
3032
2728
  "init-quiet": true,
3033
2729
  "install-save-hooks": flags["no-save-hooks"] !== true,
3034
2730
  });
3035
- // 2. Start one ordered onboarding bridge. A fresh device logs in only after consent + report.
2731
+ // 2. Start one ordered onboarding bridge. A fresh device logs in only after consent.
3036
2732
  console.log("");
3037
2733
  if (!flags["skip-login"] && !flags["no-login"] && !await cmdOnboarding(flags)) {
3038
2734
  console.log("\nEchoMem is configured, but onboarding did not finish. Run `echomem-mcp init` again when you are ready.");
@@ -3040,8 +2736,8 @@ async function cmdInit(flags) {
3040
2736
  }
3041
2737
  console.log("");
3042
2738
  console.log("🎉 EchoMem is ready.");
3043
- console.log(" • MCP memory is configured for every coding agent installed on this machine.");
3044
- console.log(" • Echo Desktop shows connection status and manages this device credential.");
2739
+ console.log(" • MCP memory is configured for the coding agents that connected successfully.");
2740
+ console.log(" • Use `echomem-mcp status` to inspect this device, and `login`, `unlock`, or `update` to manage it.");
3045
2741
  console.log(' • Try it now: ask your agent — "search my EchoMem for what I\'ve been working on and recap it."');
3046
2742
  }
3047
2743
  /**
@@ -3157,7 +2853,7 @@ async function cmdLogin(flags) {
3157
2853
  return true;
3158
2854
  }
3159
2855
  // Browser path: this bridge does only account/device authentication. It intentionally exposes
3160
- // no local-history routes; `init` owns scan consent, reporting, and optional extraction.
2856
+ // no local-history routes; `init` owns local-history consent and optional extraction.
3161
2857
  console.log("Opening your browser to connect this device locally…");
3162
2858
  const { port, nonce } = localBridgeOptions(flags);
3163
2859
  const srv = await startCallbackServer({ port, nonce, flow: "login" });
@@ -3185,7 +2881,7 @@ async function cmdLogin(flags) {
3185
2881
  }
3186
2882
  /**
3187
2883
  * The local-history onboarding flow. Existing device credentials are reused when available; a
3188
- * fresh device stays in this same bridge and asks for login only after permission and report.
2884
+ * fresh device stays in this same bridge and asks for login only after permission.
3189
2885
  */
3190
2886
  async function cmdOnboarding(flags) {
3191
2887
  const store = new KeyStore();
@@ -3197,149 +2893,17 @@ async function cmdOnboarding(flags) {
3197
2893
  console.log("Opening your browser for EchoMem onboarding…");
3198
2894
  const { port, nonce } = localBridgeOptions(flags);
3199
2895
  let stats = null;
3200
- let forensicReport = null;
3201
- let forensicConsent = "pending";
3202
- let forensicScanStarted = false;
3203
- const forensicStartedAt = Date.now();
3204
- let forensicStageStartedAt = forensicStartedAt;
3205
- let forensicStage = "starting";
3206
- let forensicOverall = 0;
3207
- let forensicProgress = {
3208
- status: "running",
3209
- scanned: 0,
3210
- total: 0,
3211
- stage: forensicStage,
3212
- label: forensicStageLabel(forensicStage),
3213
- stageDone: 0,
3214
- stageTotal: 0,
3215
- overall: 0,
3216
- elapsedMs: 0,
3217
- stageElapsedMs: 0,
3218
- updatedAt: forensicStartedAt,
3219
- };
3220
2896
  const srv = await startCallbackServer({
3221
2897
  port,
3222
2898
  nonce,
3223
2899
  flow: "onboarding",
3224
2900
  initialToken,
3225
- requireReportConsent: true,
3226
- getStats: () => stats,
3227
- getReport: () => forensicReport,
3228
- getReportProgress: () => {
3229
- if (forensicProgress.status !== "running")
3230
- return forensicProgress;
3231
- const now = Date.now();
3232
- const sinceWorkerUpdate = Math.max(0, now - forensicProgress.updatedAt);
3233
- return {
3234
- ...forensicProgress,
3235
- elapsedMs: forensicProgress.elapsedMs + sinceWorkerUpdate,
3236
- stageElapsedMs: forensicProgress.stageElapsedMs + sinceWorkerUpdate,
3237
- updatedAt: now,
3238
- };
3239
- },
3240
- onReportConsent: (allowed) => {
3241
- if (!allowed) {
3242
- forensicConsent = "declined";
3243
- forensicProgress = {
3244
- status: "failed",
3245
- scanned: 0,
3246
- total: 0,
3247
- stage: "failed",
3248
- label: "Local scan skipped",
3249
- stageDone: 0,
3250
- stageTotal: 0,
3251
- overall: 0,
3252
- elapsedMs: Date.now() - forensicStartedAt,
3253
- stageElapsedMs: Date.now() - forensicStageStartedAt,
3254
- updatedAt: Date.now(),
3255
- error: {
3256
- code: "REPORT_SCAN_DECLINED",
3257
- message: "Local file analysis was skipped. EchoMem can still connect, save conversations, and import memories.",
3258
- },
3259
- };
3260
- return;
3261
- }
3262
- forensicConsent = "allowed";
3263
- const now = Date.now();
3264
- forensicStage = "starting";
3265
- forensicStageStartedAt = now;
3266
- forensicOverall = 0;
3267
- forensicProgress = {
3268
- status: "running",
3269
- scanned: 0,
3270
- total: 0,
3271
- stage: forensicStage,
3272
- label: forensicStageLabel(forensicStage),
3273
- stageDone: 0,
3274
- stageTotal: 0,
3275
- overall: 0,
3276
- elapsedMs: now - forensicStartedAt,
3277
- stageElapsedMs: 0,
3278
- updatedAt: now,
3279
- };
3280
- startForensicScan();
3281
- },
2901
+ requireLocalHistoryConsent: true,
3282
2902
  });
3283
2903
  const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
3284
2904
  openBrowser(localSetupUrl);
3285
2905
  console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
3286
2906
  console.log("Waiting for local-history onboarding for up to 15 minutes…");
3287
- const startForensicScan = () => {
3288
- if (forensicScanStarted || forensicConsent !== "allowed")
3289
- return;
3290
- forensicScanStarted = true;
3291
- // Build the local forensic "Context Doctor" report off-thread only after explicit consent.
3292
- buildForensicReportOffThread((progress) => {
3293
- const now = Date.now();
3294
- const nextStage = progress.stage || forensicStage;
3295
- if (nextStage !== forensicStage) {
3296
- forensicStage = nextStage;
3297
- forensicStageStartedAt = now;
3298
- console.log(`Local scan: ${forensicStageLabel(forensicStage)}…`);
3299
- }
3300
- // Latched, so a caller that ever reports a smaller fraction cannot walk the bar backwards.
3301
- forensicOverall = Math.max(forensicOverall, typeof progress.overall === "number" && Number.isFinite(progress.overall) ? progress.overall : 0);
3302
- forensicProgress = {
3303
- status: "running",
3304
- scanned: progress.done,
3305
- total: progress.total,
3306
- stage: forensicStage,
3307
- label: forensicStageLabel(forensicStage),
3308
- detail: progress.detail,
3309
- stageDone: typeof progress.stageDone === "number" && Number.isFinite(progress.stageDone)
3310
- ? Math.max(0, Math.floor(progress.stageDone))
3311
- : 0,
3312
- stageTotal: typeof progress.stageTotal === "number" && Number.isFinite(progress.stageTotal)
3313
- ? Math.max(0, Math.floor(progress.stageTotal))
3314
- : 0,
3315
- overall: forensicOverall,
3316
- elapsedMs: now - forensicStartedAt,
3317
- stageElapsedMs: now - forensicStageStartedAt,
3318
- updatedAt: now,
3319
- };
3320
- })
3321
- .then((r) => {
3322
- forensicReport = r;
3323
- })
3324
- .catch((e) => {
3325
- const now = Date.now();
3326
- forensicProgress = {
3327
- status: "failed",
3328
- scanned: forensicProgress.scanned,
3329
- total: forensicProgress.total,
3330
- stage: "failed",
3331
- label: "Local scan failed",
3332
- stageDone: forensicProgress.stageDone,
3333
- stageTotal: forensicProgress.stageTotal,
3334
- overall: forensicOverall,
3335
- elapsedMs: now - forensicStartedAt,
3336
- stageElapsedMs: now - forensicStageStartedAt,
3337
- updatedAt: now,
3338
- error: safeForensicError(e),
3339
- };
3340
- console.error(`Could not build the local report: ${e instanceof Error ? e.message : String(e)}`);
3341
- });
3342
- };
3343
2907
  let token;
3344
2908
  let key;
3345
2909
  try {
@@ -3430,9 +2994,8 @@ async function cmdOnboarding(flags) {
3430
2994
  claudeCode: quick.claudeCodeCount,
3431
2995
  cowork: quick.coworkCount,
3432
2996
  };
3433
- stats = await buildStatsPayload([], {
2997
+ stats = buildOnboardingStatsPayload({
3434
2998
  partial: true,
3435
- skipMemoryCount: true,
3436
2999
  sessions: sessionSummary,
3437
3000
  migratable,
3438
3001
  discovery: { phase: "quick", exact: false },
@@ -3467,9 +3030,8 @@ async function cmdOnboarding(flags) {
3467
3030
  claudeCode: cloudSummary.claudeCodeCount,
3468
3031
  cowork: cloudSummary.coworkCount,
3469
3032
  };
3470
- const cloudPayload = await buildStatsPayload([], {
3033
+ const cloudPayload = buildOnboardingStatsPayload({
3471
3034
  partial: true,
3472
- skipMemoryCount: true,
3473
3035
  sessions: sessionSummary,
3474
3036
  migratable,
3475
3037
  discovery: { phase: "account", exact: false },
@@ -3505,9 +3067,8 @@ async function cmdOnboarding(flags) {
3505
3067
  claudeCode: unavailableSummary.claudeCodeCount,
3506
3068
  cowork: unavailableSummary.coworkCount,
3507
3069
  };
3508
- const unavailablePayload = await buildStatsPayload([], {
3070
+ const unavailablePayload = buildOnboardingStatsPayload({
3509
3071
  partial: true,
3510
- skipMemoryCount: true,
3511
3072
  sessions: sessionSummary,
3512
3073
  migratable,
3513
3074
  discovery: { phase: "account", exact: false },
@@ -3543,9 +3104,8 @@ async function cmdOnboarding(flags) {
3543
3104
  migratable = migratableFromDiscovery(initialExact);
3544
3105
  latestPendingEstimate = migratable.pending;
3545
3106
  sessionSummary = sessionsFromDiscovery(initialExact);
3546
- const partialPayload = withCandidateSessions(await buildStatsPayload([], {
3107
+ const partialPayload = withCandidateSessions(buildOnboardingStatsPayload({
3547
3108
  partial: true,
3548
- skipMemoryCount: true,
3549
3109
  sessions: sessionSummary,
3550
3110
  migratable,
3551
3111
  discovery: { phase: "exact", exact: true },
@@ -3593,12 +3153,10 @@ async function cmdOnboarding(flags) {
3593
3153
  migratable = migratableFromDiscovery(reconciled);
3594
3154
  latestPendingEstimate = migratable.pending;
3595
3155
  sessionSummary = sessionsFromDiscovery(reconciled);
3596
- const reconciledPayload = withCandidateSessions(await buildStatsPayload([], {
3597
- partial: true,
3598
- skipMemoryCount: true,
3156
+ const reconciledPayload = withCandidateSessions(buildOnboardingStatsPayload({
3599
3157
  sessions: sessionSummary,
3600
3158
  migratable,
3601
- discovery: { phase: "exact", exact: true },
3159
+ discovery: { phase: "full", exact: true },
3602
3160
  }), reconciled);
3603
3161
  if (generation !== refreshGeneration)
3604
3162
  return;
@@ -3613,20 +3171,11 @@ async function cmdOnboarding(flags) {
3613
3171
  failed: 0,
3614
3172
  extracted: 0,
3615
3173
  });
3616
- const fullPayload = withCandidateSessions(await buildCollectedStatsPayloadOffThread({
3617
- sessions: sessionSummary,
3618
- migratable,
3619
- discovery: { phase: "full", exact: true },
3620
- }), reconciled);
3621
- if (generation !== refreshGeneration)
3622
- return;
3623
- stats = fullPayload;
3624
- srv.setStats(fullPayload);
3625
3174
  })().catch((e) => {
3626
3175
  if (generation !== refreshGeneration)
3627
3176
  return;
3628
- console.error(`[echomem] optional full local-history stats unavailable; continuing (${errorCode(e) || "FULL_STATS_FAILED"})`);
3629
- publishOptionalStatsFallback(generation, "FULL_STATS_FAILED", true);
3177
+ console.error(`[echomem] optional account reconciliation unavailable; continuing (${errorCode(e) || "ACCOUNT_RECONCILIATION_FAILED"})`);
3178
+ publishOptionalStatsFallback(generation, "ACCOUNT_RECONCILIATION_FAILED", true);
3630
3179
  });
3631
3180
  return initialExact;
3632
3181
  }).catch((e) => {
@@ -4086,7 +3635,7 @@ Usage:
4086
3635
  echomem-mcp Run the MCP server (stdio; default — used by your editor)
4087
3636
  echomem-mcp setup [--client X] Detect editor, write its MCP config, then connect this device
4088
3637
  echomem-mcp setup --skip-login Write MCP config without opening login/browser
4089
- echomem-mcp setup --force-headless Explicitly replace valid Echo Desktop-managed entries
3638
+ echomem-mcp setup --force-headless Explicitly replace a valid externally managed entry
4090
3639
  echomem-mcp setup --no-codex-skills Skip installing the bundled EchoMem Codex skills
4091
3640
  echomem-mcp update --all Install this bridge durably + repoint detected clients; no login/browser
4092
3641
  echomem-mcp update --client X Repoint one MCP client; no login/browser
@@ -4097,7 +3646,6 @@ Usage:
4097
3646
  echomem-mcp status Show token/key/clients
4098
3647
  echomem-mcp doctor [--no-network] Diagnose configured client bridge versions
4099
3648
  echomem-mcp logout Remove stored credentials
4100
- echomem-mcp report [--json] Your AI coding memory audit (local, no login, $0)
4101
3649
  echomem-mcp migrate [--since D] Import your existing Codex/Claude history into your memory
4102
3650
  echomem-mcp migrate --estimate Estimate migration size/time metadata without uploading transcripts
4103
3651
  echomem-mcp migrate --max-chars N Import only sessions up to N assembled text chars
@@ -4149,9 +3697,6 @@ export async function runCli(argv) {
4149
3697
  case "logout":
4150
3698
  cmdLogout();
4151
3699
  return true;
4152
- case "report":
4153
- await runReport(flags);
4154
- return true;
4155
3700
  case "migrate":
4156
3701
  await cmdMigrate(flags);
4157
3702
  return true;