@echomem/mcp 1.4.47 → 1.4.49

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 (47) 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-data-paths.js +68 -29
  7. package/dist/local-jsonl.js +87 -0
  8. package/dist/migrate.js +8 -9
  9. package/dist/onboarding-stats.js +16 -0
  10. package/dist/save-checkpoint-hook.js +1 -1
  11. package/dist/setup-page/client-core.js +18 -372
  12. package/dist/setup-page/client-extraction.js +14 -129
  13. package/dist/setup-page/client-lifecycle.js +28 -101
  14. package/dist/setup-page/client.js +0 -2
  15. package/dist/setup-page/styles-extraction.js +1 -31
  16. package/dist/setup-page/styles-foundation.js +0 -89
  17. package/dist/setup-page/styles-mvp.js +10 -155
  18. package/dist/setup-page/styles-website-alignment.js +0 -204
  19. package/dist/setup-page/styles.js +0 -4
  20. package/dist/setup-page.js +4 -4
  21. package/dist/setup-preview.js +4 -212
  22. package/dist/setup.js +119 -583
  23. package/dist/v1-contract.js +0 -8
  24. package/package.json +5 -7
  25. package/dist/city/README.md +0 -9
  26. package/dist/city/echo-ai-city-only.html +0 -2232
  27. package/dist/city/echo-extraction-plate.html +0 -330
  28. package/dist/city/echo-face-cutout.png +0 -0
  29. package/dist/city/personality_stickers/bossy.png +0 -0
  30. package/dist/city/personality_stickers/ghosty.png +0 -0
  31. package/dist/city/personality_stickers/loopy.png +0 -0
  32. package/dist/city/personality_stickers/lusty.png +0 -0
  33. package/dist/city/personality_stickers/maxxy.png +0 -0
  34. package/dist/city/personality_stickers/tabby.png +0 -0
  35. package/dist/city/vendor/OrbitControls.js +0 -1417
  36. package/dist/city/vendor/RoundedBoxGeometry.js +0 -155
  37. package/dist/city/vendor/echo_general-file-21.riv +0 -0
  38. package/dist/city/vendor/rive.js +0 -8139
  39. package/dist/city/vendor/rive.wasm +0 -0
  40. package/dist/city/vendor/three.module.min.js +0 -6
  41. package/dist/forensics.js +0 -1531
  42. package/dist/report.js +0 -721
  43. package/dist/setup-page/client-report-audit.js +0 -819
  44. package/dist/setup-page/client-report-city.js +0 -356
  45. package/dist/setup-page/client-report.js +0 -6
  46. package/dist/setup-page/styles-city-report.js +0 -880
  47. 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,12 @@ 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";
33
+ import { resolveClaudeDesktopSupportRoots } from "./local-data-paths.js";
34
34
  import { installSaveCheckpointHooks, installSourceSessionHooks } 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";
@@ -70,15 +70,25 @@ const CODEX_SKILL_NAMES = [
70
70
  function home(...p) {
71
71
  return path.join(os.homedir(), ...p);
72
72
  }
73
- function codexHome() {
74
- const configured = process.env.CODEX_HOME?.trim();
73
+ function configuredProfileDirectory(envKey, fallbackName) {
74
+ const configured = process.env[envKey]?.trim();
75
75
  if (!configured)
76
- return home(".codex");
76
+ return home(fallbackName);
77
77
  if (configured === "~")
78
78
  return os.homedir();
79
- if (configured.startsWith(`~${path.sep}`))
79
+ if (configured.startsWith("~/") || configured.startsWith("~\\")) {
80
80
  return path.join(os.homedir(), configured.slice(2));
81
- return path.resolve(configured);
81
+ }
82
+ if (!path.isAbsolute(configured)) {
83
+ throw new Error(`${envKey} must be an absolute path or start with ~/`);
84
+ }
85
+ return path.normalize(configured);
86
+ }
87
+ function codexHome() {
88
+ return configuredProfileDirectory("CODEX_HOME", ".codex");
89
+ }
90
+ function claudeConfigHome() {
91
+ return configuredProfileDirectory("CLAUDE_CONFIG_DIR", ".claude");
82
92
  }
83
93
  function filesEqual(left, right) {
84
94
  try {
@@ -211,10 +221,18 @@ export function knownClients() {
211
221
  const appSupport = process.platform === "darwin"
212
222
  ? home("Library", "Application Support")
213
223
  : process.env.APPDATA || home(".config");
224
+ const defaultClaudeDesktopRoot = path.join(appSupport, "Claude");
225
+ const claudeDesktopRoots = resolveClaudeDesktopSupportRoots();
226
+ const claudeDesktopClients = (claudeDesktopRoots.length > 0 ? claudeDesktopRoots : [defaultClaudeDesktopRoot]).map((supportRoot) => ({
227
+ id: "claude-desktop",
228
+ label: "Claude Desktop",
229
+ kind: "json",
230
+ configPath: path.join(supportRoot, "claude_desktop_config.json"),
231
+ }));
214
232
  return [
215
233
  { id: "cursor", label: "Cursor", kind: "json", configPath: home(".cursor", "mcp.json") },
216
234
  { id: "windsurf", label: "Windsurf", kind: "json", configPath: home(".codeium", "windsurf", "mcp_config.json") },
217
- { id: "claude-desktop", label: "Claude Desktop", kind: "json", configPath: path.join(appSupport, "Claude", "claude_desktop_config.json") },
235
+ ...claudeDesktopClients,
218
236
  { id: "claude-code", label: "Claude Code", kind: "snippet", note: "run: claude mcp add-json -s user echomem '<entry>' (or add to .mcp.json)" },
219
237
  { id: "codex", label: "Codex", kind: "command", detectDir: codexHome(), configPath: path.join(codexHome(), "config.toml"), note: "add to ~/.codex/config.toml under [mcp_servers.echomem]" },
220
238
  ];
@@ -227,7 +245,7 @@ export function detectClients() {
227
245
  if (c.kind === "command")
228
246
  return fs.existsSync(c.detectDir);
229
247
  if (c.id === "claude-code")
230
- return fs.existsSync(home(".claude"));
248
+ return claudeCodeCliAvailable();
231
249
  return false;
232
250
  });
233
251
  }
@@ -401,7 +419,7 @@ function echomemGuidanceBlock() {
401
419
  "- 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
420
  "- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
403
421
  '- 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.",
422
+ "- 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
423
  '- 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
424
  "- 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
425
  "- 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 +430,7 @@ function echomemGuidanceBlock() {
412
430
  "- 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
431
  "- 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
432
  "- 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.",
433
+ "- 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
434
  "- 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
435
  "- 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
436
  "- 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 +462,35 @@ export function writeAgentsMemoryGuidance(filePath) {
444
462
  fs.appendFileSync(filePath, sep + block + "\n");
445
463
  return "wrote";
446
464
  }
465
+ /**
466
+ * Refresh marker-owned guidance for users upgrading an existing standalone MCP install.
467
+ * This intentionally does not create global memory files: setup owns first installation,
468
+ * while server startup only migrates guidance that EchoMem already installed.
469
+ */
470
+ export function refreshInstalledMemoryGuidance() {
471
+ let candidates;
472
+ try {
473
+ candidates = [
474
+ path.join(codexHome(), "AGENTS.md"),
475
+ path.join(claudeConfigHome(), "CLAUDE.md"),
476
+ ];
477
+ }
478
+ catch {
479
+ return;
480
+ }
481
+ for (const filePath of candidates) {
482
+ try {
483
+ const content = fs.readFileSync(filePath, "utf8");
484
+ const start = content.indexOf(AGENTS_MD_BEGIN);
485
+ const end = content.indexOf(AGENTS_MD_END);
486
+ if (start >= 0 && end > start)
487
+ writeAgentsMemoryGuidance(filePath);
488
+ }
489
+ catch {
490
+ // Missing, unreadable, or externally managed files are left untouched.
491
+ }
492
+ }
493
+ }
447
494
  /** Merge the EchoMem entry into a JSON client's `mcpServers` map without clobbering siblings. */
448
495
  export function writeJsonClientConfig(configPath, entry, options = {}) {
449
496
  let config = {};
@@ -617,6 +664,19 @@ function execClaudeCodeSync(args, options, commandCandidates) {
617
664
  }
618
665
  throw lastError;
619
666
  }
667
+ export function claudeCodeCliAvailable() {
668
+ try {
669
+ execClaudeCodeSync(["--version"], {
670
+ encoding: "utf8",
671
+ stdio: ["ignore", "pipe", "ignore"],
672
+ timeout: 3000,
673
+ });
674
+ return true;
675
+ }
676
+ catch {
677
+ return false;
678
+ }
679
+ }
620
680
  export function writeClaudeCodeConfig(entry, options = {}) {
621
681
  // EchoMem belongs at user scope so every Claude Code project resolves the same durable runtime.
622
682
  // Older CLI versions wrote local/project entries, which take precedence over user scope and can
@@ -1229,7 +1289,7 @@ function withTimeout(promise, ms, code, onTimeout) {
1229
1289
  function delay(ms) {
1230
1290
  return new Promise((resolve) => setTimeout(resolve, ms));
1231
1291
  }
1232
- const CITY_ASSET_TYPES = {
1292
+ const LOCAL_ASSET_TYPES = {
1233
1293
  ".html": "text/html; charset=utf-8",
1234
1294
  ".js": "text/javascript; charset=utf-8",
1235
1295
  ".json": "application/json; charset=utf-8",
@@ -1238,39 +1298,11 @@ const CITY_ASSET_TYPES = {
1238
1298
  ".png": "image/png",
1239
1299
  ".svg": "image/svg+xml",
1240
1300
  };
1241
- function repoCityArtifactsRoot() {
1242
- // Monorepo/dev: read the city assets live from repo-root /artifacts.
1243
- const repo = fileURLToPath(new URL("../../../artifacts/", import.meta.url));
1244
- if (fs.existsSync(repo))
1245
- return repo;
1246
- // Published install: fall back to the copy bundled into dist/city by prepack (bundle-city.mjs).
1247
- return fileURLToPath(new URL("./city/", import.meta.url));
1248
- }
1249
- function serveRepoCityAsset(reqPath, res) {
1250
- const root = repoCityArtifactsRoot();
1251
- const rel = reqPath === "/city" || reqPath === "/city/" ? "echo-ai-city-only.html" : decodeURIComponent(reqPath.slice("/city/".length));
1252
- // Archives stay in the checkout for recovery, but must never become a localhost UI surface.
1253
- const normalizedRel = rel.replace(/\\/g, "/");
1254
- if (normalizedRel === "archive" || normalizedRel.startsWith("archive/")) {
1255
- res.writeHead(404).end("not found");
1256
- return true;
1257
- }
1258
- const filePath = path.resolve(root, rel);
1259
- const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
1260
- if (!filePath.startsWith(rootWithSep)) {
1261
- res.writeHead(403).end("forbidden");
1262
- return true;
1263
- }
1264
- if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
1265
- res.writeHead(404).end("not found");
1266
- return true;
1267
- }
1268
- res.writeHead(200, {
1269
- "Content-Type": CITY_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
1270
- "Cache-Control": "no-store",
1271
- });
1272
- fs.createReadStream(filePath).pipe(res);
1273
- return true;
1301
+ function repoLabel(cwd) {
1302
+ if (!cwd)
1303
+ return "";
1304
+ const normalized = cwd.replace(/[\\/]+$/, "");
1305
+ return path.basename(normalized) || normalized;
1274
1306
  }
1275
1307
  function hudAssetsRoot() {
1276
1308
  return fileURLToPath(new URL("../assets/hud/", import.meta.url));
@@ -1289,7 +1321,7 @@ function serveHudAsset(reqPath, res) {
1289
1321
  return true;
1290
1322
  }
1291
1323
  res.writeHead(200, {
1292
- "Content-Type": CITY_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
1324
+ "Content-Type": LOCAL_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
1293
1325
  "Cache-Control": "no-store",
1294
1326
  });
1295
1327
  fs.createReadStream(filePath).pipe(res);
@@ -1466,61 +1498,6 @@ export function discoverMigratableFastOffThread(opts = {}) {
1466
1498
  });
1467
1499
  });
1468
1500
  }
1469
- /** Build the full local-history dashboard payload away from the callback server's event loop.
1470
- * `collect()` can synchronously parse hundreds of JSONL files for tens of seconds; doing that on
1471
- * the bridge thread prevents even localhost actions such as account switch from receiving a reply. */
1472
- export function buildCollectedStatsPayloadOffThread(inject) {
1473
- const reportUrl = runtimeModuleUrl("report");
1474
- const serializedInject = JSON.stringify(inject);
1475
- const code = `
1476
- import { parentPort } from "node:worker_threads";
1477
- import { collect, buildStatsPayload } from ${JSON.stringify(reportUrl)};
1478
-
1479
- try {
1480
- const payload = await buildStatsPayload(collect(), ${serializedInject});
1481
- parentPort?.postMessage({ ok: true, payload });
1482
- } catch (error) {
1483
- parentPort?.postMessage({
1484
- ok: false,
1485
- message: error instanceof Error ? error.message : String(error),
1486
- stack: error instanceof Error ? error.stack : undefined,
1487
- });
1488
- }
1489
- `;
1490
- const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
1491
- return new Promise((resolve, reject) => {
1492
- let settled = false;
1493
- const finish = (result) => {
1494
- if (settled)
1495
- return;
1496
- settled = true;
1497
- void worker.terminate();
1498
- if (result.ok)
1499
- resolve(result.payload);
1500
- else
1501
- reject(result.error);
1502
- };
1503
- worker.once("message", (message) => {
1504
- const msg = message;
1505
- if (msg.ok === true) {
1506
- finish({ ok: true, payload: msg.payload });
1507
- return;
1508
- }
1509
- const error = new Error(typeof msg.message === "string" ? msg.message : "Full local stats worker failed");
1510
- if (typeof msg.stack === "string")
1511
- error.stack = msg.stack;
1512
- finish({ ok: false, error });
1513
- });
1514
- worker.once("error", (error) => {
1515
- finish({ ok: false, error });
1516
- });
1517
- worker.once("exit", (code) => {
1518
- if (settled)
1519
- return;
1520
- finish({ ok: false, error: new Error(`Full local stats worker exited (code ${code}) without a result`) });
1521
- });
1522
- });
1523
- }
1524
1501
  export function createLocalDiscoveryCache(loaders = {}) {
1525
1502
  const loadQuick = loaders.loadQuick ?? (() => discoverMigratableFastOffThread());
1526
1503
  const loadExact = loaders.loadExact ?? (() => discoverMigratableSessionsOffThread());
@@ -1569,45 +1546,6 @@ export function createLocalDiscoveryCache(loaders = {}) {
1569
1546
  },
1570
1547
  };
1571
1548
  }
1572
- function forensicStageLabel(stage) {
1573
- if (stage === "reading-transcripts")
1574
- return "Reading transcript files";
1575
- if (stage === "building-summary")
1576
- return "Building scan summary";
1577
- if (stage === "classifying-repeated-context")
1578
- return "Classifying repeated context";
1579
- if (stage === "finalizing-report")
1580
- return "Finalizing report";
1581
- return "Starting local scan";
1582
- }
1583
- /** Build the local forensic "Context Doctor" report on a worker thread so the multi-file scan never
1584
- * blocks the bridge's event loop (the freeze postmortem: any unbounded sync work on this path is risky). */
1585
- export function buildForensicReportOffThread(onProgress, options = {}) {
1586
- let lastProgress = null;
1587
- const recordProgress = (progress) => {
1588
- lastProgress = progress;
1589
- onProgress?.(progress);
1590
- };
1591
- return runForensicReportWorker(recordProgress, options).catch(async (primaryError) => {
1592
- if (options.failOpen === false)
1593
- throw primaryError;
1594
- const failureCode = errorCode(primaryError) || "REPORT_BUILD_FAILED";
1595
- console.error(`[echomem] local scan degraded after ${failureCode}; continuing without local-history analysis`);
1596
- onProgress?.({
1597
- done: lastProgress?.done || 0,
1598
- total: lastProgress?.total || 0,
1599
- stage: "finalizing-report",
1600
- detail: "finishing setup without optional local-history analysis",
1601
- overall: 0.99,
1602
- stageDone: 0,
1603
- stageTotal: 0,
1604
- });
1605
- return runForensicReportWorker(undefined, {
1606
- timeoutMs: 30_000,
1607
- maxOldGenerationSizeMb: Math.max(64, options.maxOldGenerationSizeMb || 0),
1608
- }, [], failureCode);
1609
- });
1610
- }
1611
1549
  function errorCode(error) {
1612
1550
  return error && typeof error === "object" && "code" in error
1613
1551
  ? String(error.code || "")
@@ -1627,7 +1565,6 @@ export function completeOptionalStatsPayload(payload, reason, countsTrusted) {
1627
1565
  transcriptsUploaded: false,
1628
1566
  sessions: { total: 0, codex: 0, claudeCode: 0, cowork: 0 },
1629
1567
  migratable: { pending: 0, alreadyMigrated: 0 },
1630
- memoriesCaptured: null,
1631
1568
  };
1632
1569
  const completed = payload && typeof payload === "object" && !Array.isArray(payload)
1633
1570
  ? { ...payload }
@@ -1640,155 +1577,11 @@ export function completeOptionalStatsPayload(payload, reason, countsTrusted) {
1640
1577
  };
1641
1578
  return completed;
1642
1579
  }
1643
- function runForensicReportWorker(onProgress, options, sources, degradedReason) {
1644
- const forensicsUrl = runtimeModuleUrl("forensics");
1645
- const serializedSources = sources === undefined ? "undefined" : JSON.stringify(sources);
1646
- const serializedDegradedReason = JSON.stringify(degradedReason || "");
1647
- const code = `
1648
- import { parentPort } from "node:worker_threads";
1649
- import { buildForensicReport, validateForensicReportForSetup } from ${JSON.stringify(forensicsUrl)};
1650
- try {
1651
- const report = await buildForensicReport({
1652
- sources: ${serializedSources},
1653
- includeLegacyGoldenStandard: false,
1654
- onProgress: (done, total, stage, detail, overall, stageDone, stageTotal) => parentPort?.postMessage({
1655
- progress: { done, total, stage, detail, overall, stageDone, stageTotal },
1656
- }),
1657
- });
1658
- const degradedReason = ${serializedDegradedReason};
1659
- if (degradedReason) {
1660
- report.scanDiagnostics = {
1661
- degraded: true,
1662
- reason: degradedReason,
1663
- skippedSources: ["codex", "claude"],
1664
- };
1665
- }
1666
- const validation = validateForensicReportForSetup(report);
1667
- if (!validation.ok) {
1668
- const error = new Error(validation.message);
1669
- error.code = validation.code;
1670
- throw error;
1671
- }
1672
- parentPort?.postMessage({ ok: true, report });
1673
- } catch (error) {
1674
- parentPort?.postMessage({
1675
- ok: false,
1676
- message: error instanceof Error ? error.message : String(error),
1677
- code: error && typeof error === "object" && "code" in error ? String(error.code || "") : "",
1678
- });
1679
- }
1680
- `;
1681
- const requestedHeapMb = options.maxOldGenerationSizeMb;
1682
- const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`), Number.isFinite(requestedHeapMb)
1683
- ? { resourceLimits: { maxOldGenerationSizeMb: Math.max(16, Math.floor(requestedHeapMb)) } }
1684
- : undefined);
1685
- return new Promise((resolve, reject) => {
1686
- let settled = false;
1687
- const requestedTimeoutMs = options.timeoutMs ?? 15 * 60_000;
1688
- const timeoutMs = Number.isFinite(requestedTimeoutMs) ? Math.max(1, requestedTimeoutMs) : 15 * 60_000;
1689
- const timeout = setTimeout(() => {
1690
- if (settled)
1691
- return;
1692
- settled = true;
1693
- void worker.terminate();
1694
- const error = new Error(`Local forensic report timed out after ${timeoutMs}ms`);
1695
- error.code = "REPORT_SCAN_TIMEOUT";
1696
- reject(error);
1697
- }, timeoutMs);
1698
- timeout.unref?.();
1699
- const finish = (result) => {
1700
- if (settled)
1701
- return;
1702
- settled = true;
1703
- clearTimeout(timeout);
1704
- void worker.terminate();
1705
- if (result.ok)
1706
- resolve(result.report);
1707
- else
1708
- reject(result.error);
1709
- };
1710
- worker.on("message", (message) => {
1711
- if (settled)
1712
- return;
1713
- const msg = message;
1714
- if (msg.progress) {
1715
- onProgress?.(msg.progress);
1716
- return;
1717
- }
1718
- if (msg.ok === true && msg.report && typeof msg.report === "object") {
1719
- finish({ ok: true, report: msg.report });
1720
- return;
1721
- }
1722
- const error = new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed");
1723
- if (typeof msg.code === "string" && msg.code)
1724
- error.code = msg.code;
1725
- finish({ ok: false, error });
1726
- });
1727
- worker.once("error", (error) => {
1728
- finish({ ok: false, error });
1729
- });
1730
- worker.once("exit", (code) => {
1731
- if (settled)
1732
- return;
1733
- finish({ ok: false, error: new Error(`Forensic report worker exited (code ${code}) without a result`) });
1734
- });
1735
- });
1736
- }
1737
1580
  export function respondMigrate(res, body, status = 200) {
1738
1581
  if (res.writableEnded)
1739
1582
  return;
1740
1583
  res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
1741
1584
  }
1742
- function safeForensicError(error) {
1743
- const code = error && typeof error === "object" && "code" in error
1744
- ? String(error.code || "")
1745
- : "";
1746
- if (code === "REPORT_SCAN_TIMEOUT") {
1747
- return {
1748
- code,
1749
- message: "The local workspace scan took too long and was stopped. No backup data was substituted. Rerun setup to retry.",
1750
- };
1751
- }
1752
- return {
1753
- code: "REPORT_BUILD_FAILED",
1754
- message: "EchoMem could not finish the local workspace scan. No backup data was substituted. Rerun setup to retry.",
1755
- };
1756
- }
1757
- function publicRunningForensicProgress(value) {
1758
- if (!value || typeof value !== "object")
1759
- return null;
1760
- const progress = value;
1761
- if (progress.status !== "running")
1762
- return null;
1763
- const safeCount = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
1764
- ? Math.floor(candidate)
1765
- : 0);
1766
- const safeDuration = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
1767
- ? candidate
1768
- : 0);
1769
- const safeFraction = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate)
1770
- ? Math.min(1, Math.max(0, candidate))
1771
- : 0);
1772
- const total = safeCount(progress.total);
1773
- const stageTotal = safeCount(progress.stageTotal);
1774
- const rawStage = typeof progress.stage === "string" ? progress.stage : "starting";
1775
- const stage = ["starting", "reading-transcripts", "building-summary", "classifying-repeated-context", "finalizing-report"].includes(rawStage)
1776
- ? rawStage
1777
- : "starting";
1778
- return {
1779
- status: "running",
1780
- scanned: total > 0 ? Math.min(safeCount(progress.scanned), total) : 0,
1781
- total,
1782
- stage,
1783
- label: forensicStageLabel(stage),
1784
- stageDone: stageTotal > 0 ? Math.min(safeCount(progress.stageDone), stageTotal) : 0,
1785
- stageTotal,
1786
- overall: safeFraction(progress.overall),
1787
- elapsedMs: safeDuration(progress.elapsedMs),
1788
- stageElapsedMs: safeDuration(progress.stageElapsedMs),
1789
- updatedAt: safeDuration(progress.updatedAt) || Date.now(),
1790
- };
1791
- }
1792
1585
  /**
1793
1586
  * Start the persistent localhost bridge used by the setup page. It sends/verifies OTP through the
1794
1587
  * hosted API, accepts the local passphrase, serves local Wrapped stats, and holds the /migrate
@@ -1801,12 +1594,11 @@ export function startCallbackServer(opts = {}) {
1801
1594
  : `${Math.ceil(timeoutMs / 1000)} seconds`;
1802
1595
  const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
1803
1596
  const expectedNonce = opts.nonce;
1804
- const scanId = opts.scanId ?? randomUUID();
1805
1597
  const flow = opts.flow ?? "onboarding";
1806
1598
  const isLoginFlow = flow === "login";
1807
1599
  // A login screen must not be blocked by a local-history permission. That permission belongs to
1808
1600
  // onboarding and is intentionally enforced separately below.
1809
- const requiresReportConsent = opts.requireReportConsent === true && !isLoginFlow;
1601
+ const requiresLocalHistoryConsent = opts.requireLocalHistoryConsent === true && !isLoginFlow;
1810
1602
  return new Promise((resolveOuter, rejectOuter) => {
1811
1603
  const onToken = deferred();
1812
1604
  const setupExit = deferred();
@@ -1819,7 +1611,7 @@ export function startCallbackServer(opts = {}) {
1819
1611
  let activeDeviceToken = opts.initialToken?.token || "";
1820
1612
  let activeAccountEmail = "";
1821
1613
  let pendingLocalAuth = null;
1822
- let reportConsentGranted = !requiresReportConsent;
1614
+ let localHistoryConsentGranted = !requiresLocalHistoryConsent;
1823
1615
  let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
1824
1616
  let migrateStarted = false;
1825
1617
  let tokenRefreshHandler = null;
@@ -1886,7 +1678,7 @@ export function startCallbackServer(opts = {}) {
1886
1678
  const handleCallback = (res, token, key, nonce) => {
1887
1679
  if (!checkNonce(nonce))
1888
1680
  return void text(res, 403, "bad nonce");
1889
- if (requiresReportConsent && !reportConsentGranted) {
1681
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
1890
1682
  return void json(res, 403, {
1891
1683
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1892
1684
  message: "Allow local history access in the setup page before connecting EchoMem.",
@@ -1916,7 +1708,7 @@ export function startCallbackServer(opts = {}) {
1916
1708
  text(res, 403, "bad nonce");
1917
1709
  return true;
1918
1710
  }
1919
- if (requiresReportConsent && !reportConsentGranted) {
1711
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
1920
1712
  json(res, 403, {
1921
1713
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1922
1714
  message: "Allow local history access in the setup page before connecting EchoMem.",
@@ -1943,8 +1735,7 @@ export function startCallbackServer(opts = {}) {
1943
1735
  armTimeout();
1944
1736
  };
1945
1737
  const isOnboardingOnlyRoute = (route) => [
1946
- "/report-consent",
1947
- "/report",
1738
+ "/local-history-consent",
1948
1739
  "/stats",
1949
1740
  "/billing-status",
1950
1741
  "/billing-checkout",
@@ -2137,10 +1928,6 @@ export function startCallbackServer(opts = {}) {
2137
1928
  message: "Run `echomem-mcp init` to access local-history onboarding.",
2138
1929
  });
2139
1930
  }
2140
- if ((route === "/city" || route.startsWith("/city/")) && req.method === "GET") {
2141
- serveRepoCityAsset(route, res);
2142
- return;
2143
- }
2144
1931
  if (route.startsWith("/hud-assets/") && req.method === "GET") {
2145
1932
  serveHudAsset(route, res);
2146
1933
  return;
@@ -2178,8 +1965,13 @@ export function startCallbackServer(opts = {}) {
2178
1965
  localOnly: true,
2179
1966
  localAuth: true,
2180
1967
  workspacePath: process.cwd(),
2181
- consentRequired: requiresReportConsent,
2182
- consentGranted: reportConsentGranted,
1968
+ consentRequired: requiresLocalHistoryConsent,
1969
+ consentGranted: localHistoryConsentGranted,
1970
+ platform: process.platform,
1971
+ capabilities: {
1972
+ openClaudeDesktop: process.platform === "darwin" || process.platform === "win32",
1973
+ openAgentSessions: process.platform === "darwin" || process.platform === "win32",
1974
+ },
2183
1975
  });
2184
1976
  return;
2185
1977
  }
@@ -2283,7 +2075,7 @@ export function startCallbackServer(opts = {}) {
2283
2075
  if (route === "/stats" && req.method === "GET") {
2284
2076
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
2285
2077
  return void text(res, 403, "bad nonce");
2286
- if (requiresReportConsent && !reportConsentGranted) {
2078
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2287
2079
  return void json(res, 403, {
2288
2080
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2289
2081
  message: "Allow local history access before continuing setup.",
@@ -2299,7 +2091,7 @@ export function startCallbackServer(opts = {}) {
2299
2091
  res.setHeader("Cache-Control", "no-store, max-age=0");
2300
2092
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
2301
2093
  return void text(res, 403, "bad nonce");
2302
- if (requiresReportConsent && !reportConsentGranted) {
2094
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2303
2095
  return void json(res, 403, {
2304
2096
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2305
2097
  message: "Allow local history access before continuing setup.",
@@ -2423,7 +2215,7 @@ export function startCallbackServer(opts = {}) {
2423
2215
  }
2424
2216
  if (!checkNonce(asString(body.nonce)))
2425
2217
  return void text(res, 403, "bad nonce");
2426
- if (requiresReportConsent && !reportConsentGranted) {
2218
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2427
2219
  return void json(res, 403, {
2428
2220
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2429
2221
  message: "Allow local history access before managing an onboarding plan.",
@@ -2489,114 +2281,10 @@ export function startCallbackServer(opts = {}) {
2489
2281
  }
2490
2282
  return;
2491
2283
  }
2492
- if (route === "/report" && req.method === "GET") {
2493
- // Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
2494
- res.setHeader("Cache-Control", "no-store");
2495
- if (!checkNonce(url.searchParams.get("nonce") || undefined))
2496
- return void text(res, 403, "bad nonce");
2497
- if (requiresReportConsent && !reportConsentGranted) {
2498
- return void json(res, 403, {
2499
- error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2500
- message: "Allow local history access before starting the local scan.",
2501
- });
2502
- }
2503
- let payload;
2504
- try {
2505
- payload = opts.getReport ? opts.getReport() : null;
2506
- }
2507
- catch {
2508
- return void json(res, 500, {
2509
- schemaVersion: 1,
2510
- kind: "failed",
2511
- mode: "production",
2512
- scanId,
2513
- error: {
2514
- code: "REPORT_STATE_UNAVAILABLE",
2515
- message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
2516
- },
2517
- });
2518
- }
2519
- if (payload == null) {
2520
- // 202 carries scan progress so the page can show a live "scanned N/total" indicator.
2521
- let prog;
2522
- try {
2523
- prog = opts.getReportProgress ? opts.getReportProgress() : {
2524
- status: "running",
2525
- scanned: 0,
2526
- total: 0,
2527
- stage: "starting",
2528
- label: "Starting local scan",
2529
- elapsedMs: 0,
2530
- stageElapsedMs: 0,
2531
- updatedAt: Date.now(),
2532
- };
2533
- }
2534
- catch {
2535
- return void json(res, 500, {
2536
- schemaVersion: 1,
2537
- kind: "failed",
2538
- mode: "production",
2539
- scanId,
2540
- error: {
2541
- code: "REPORT_STATE_UNAVAILABLE",
2542
- message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
2543
- },
2544
- });
2545
- }
2546
- if (prog && typeof prog === "object" && prog.status === "failed") {
2547
- return void json(res, 500, {
2548
- schemaVersion: 1,
2549
- kind: "failed",
2550
- mode: "production",
2551
- scanId,
2552
- error: safeForensicError(prog.error),
2553
- });
2554
- }
2555
- const publicProgress = publicRunningForensicProgress(prog);
2556
- if (!publicProgress) {
2557
- return void json(res, 500, {
2558
- schemaVersion: 1,
2559
- kind: "failed",
2560
- mode: "production",
2561
- scanId,
2562
- error: {
2563
- code: "REPORT_STATE_INVALID",
2564
- message: "EchoMem received an invalid local scan state. No backup data was substituted. Rerun setup to retry.",
2565
- },
2566
- });
2567
- }
2568
- return void json(res, 202, {
2569
- schemaVersion: 1,
2570
- kind: "scanning",
2571
- mode: "production",
2572
- scanId,
2573
- progress: publicProgress,
2574
- });
2575
- }
2576
- const validation = validateForensicReportForSetup(payload);
2577
- if (!validation.ok) {
2578
- console.error(`[echomem] local report validation failed: ${validation.code} — ${validation.message}`);
2579
- return void json(res, 500, {
2580
- schemaVersion: 1,
2581
- kind: "failed",
2582
- mode: "production",
2583
- scanId,
2584
- error: { code: validation.code, message: validation.message },
2585
- });
2586
- }
2587
- json(res, 200, {
2588
- schemaVersion: 1,
2589
- kind: validation.kind,
2590
- mode: "production",
2591
- scanId,
2592
- report: validation.report,
2593
- });
2594
- return;
2595
- }
2596
2284
  if (route === "/progress" && req.method === "GET") {
2597
2285
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
2598
2286
  return void text(res, 403, "bad nonce");
2599
- if (requiresReportConsent && !reportConsentGranted) {
2287
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2600
2288
  return void json(res, 403, {
2601
2289
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2602
2290
  message: "Allow local history access before continuing setup.",
@@ -2605,7 +2293,7 @@ export function startCallbackServer(opts = {}) {
2605
2293
  json(res, 200, progress);
2606
2294
  return;
2607
2295
  }
2608
- if (route === "/report-consent" && req.method === "POST") {
2296
+ if (route === "/local-history-consent" && req.method === "POST") {
2609
2297
  let body;
2610
2298
  try {
2611
2299
  body = await readJsonBody(req);
@@ -2617,8 +2305,7 @@ export function startCallbackServer(opts = {}) {
2617
2305
  if (!checkNonce(asString(body.nonce)))
2618
2306
  return void text(res, 403, "bad nonce");
2619
2307
  const allowed = body.allowed === true;
2620
- reportConsentGranted = allowed;
2621
- opts.onReportConsent?.(allowed);
2308
+ localHistoryConsentGranted = allowed;
2622
2309
  json(res, 200, { ok: true, allowed });
2623
2310
  return;
2624
2311
  }
@@ -2677,7 +2364,7 @@ export function startCallbackServer(opts = {}) {
2677
2364
  }
2678
2365
  if (!checkNonce(asString(body.nonce)))
2679
2366
  return void text(res, 403, "bad nonce");
2680
- if (requiresReportConsent && !reportConsentGranted) {
2367
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2681
2368
  return void json(res, 403, {
2682
2369
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2683
2370
  message: "Allow local history access before starting extraction.",
@@ -2951,7 +2638,7 @@ async function cmdSetup(flags) {
2951
2638
  if (c.kind === "json") {
2952
2639
  const result = writeJsonClientConfig(c.configPath, entry, { forceHeadless });
2953
2640
  if (result === "desktop-managed") {
2954
- console.log(`✅ Kept the valid Echo Desktop-managed EchoMem entry for ${c.label}: ${c.configPath}`);
2641
+ console.log(`✅ Kept the valid externally managed EchoMem entry for ${c.label}: ${c.configPath}`);
2955
2642
  }
2956
2643
  else {
2957
2644
  console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath}`);
@@ -2963,7 +2650,7 @@ async function cmdSetup(flags) {
2963
2650
  if (result === "wrote")
2964
2651
  console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath} — start a new Codex session to load it.`);
2965
2652
  else if (result === "desktop-managed")
2966
- console.log(`✅ Kept the valid Echo Desktop-managed EchoMem entry for ${c.label}: ${c.configPath}`);
2653
+ console.log(`✅ Kept the valid externally managed EchoMem entry for ${c.label}: ${c.configPath}`);
2967
2654
  else
2968
2655
  console.log(`✅ ${c.label} already has the EchoMem MCP entry: ${c.configPath}`);
2969
2656
  configuredTargets.push(c);
@@ -2974,7 +2661,7 @@ async function cmdSetup(flags) {
2974
2661
  : "unavailable";
2975
2662
  if (result !== "unavailable" && result.state === "wrote") {
2976
2663
  if (result.preservedDesktopManaged) {
2977
- console.log(`✅ Kept the valid Echo Desktop-managed EchoMem user entry for ${c.label}.`);
2664
+ console.log(`✅ Kept the valid externally managed EchoMem user entry for ${c.label}.`);
2978
2665
  }
2979
2666
  else {
2980
2667
  console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
@@ -3042,14 +2729,14 @@ async function cmdSetup(flags) {
3042
2729
  await cmdLogin(flags);
3043
2730
  }
3044
2731
  if (flags["with-hud"]) {
3045
- console.log("ℹ️ The standalone EchoMem HUD has been retired. Echo Desktop now owns setup and status.");
2732
+ console.log("ℹ️ The standalone EchoMem HUD has been retired. Use `echomem-mcp status` to inspect this installation.");
3046
2733
  }
3047
2734
  }
3048
2735
  /**
3049
2736
  * `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
3050
2737
  * machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), installs EchoMem's
3051
2738
  * Codex skills and writes the AGENTS.md memory guidance. One browser
3052
- * bridge then runs permission → report → login → plan if needed → extraction in that order.
2739
+ * bridge then runs permission → login → plan if needed → extraction in that order.
3053
2740
  * `setup`/`login`/`update` remain granular primitives; init picks the full product defaults.
3054
2741
  */
3055
2742
  async function cmdInit(flags) {
@@ -3063,7 +2750,7 @@ async function cmdInit(flags) {
3063
2750
  "init-quiet": true,
3064
2751
  "install-save-hooks": flags["no-save-hooks"] !== true,
3065
2752
  });
3066
- // 2. Start one ordered onboarding bridge. A fresh device logs in only after consent + report.
2753
+ // 2. Start one ordered onboarding bridge. A fresh device logs in only after consent.
3067
2754
  console.log("");
3068
2755
  if (!flags["skip-login"] && !flags["no-login"] && !await cmdOnboarding(flags)) {
3069
2756
  console.log("\nEchoMem is configured, but onboarding did not finish. Run `echomem-mcp init` again when you are ready.");
@@ -3071,8 +2758,8 @@ async function cmdInit(flags) {
3071
2758
  }
3072
2759
  console.log("");
3073
2760
  console.log("🎉 EchoMem is ready.");
3074
- console.log(" • MCP memory is configured for every available coding agent EchoMem could connect to on this machine.");
3075
- console.log(" • Echo Desktop shows connection status and manages this device credential.");
2761
+ console.log(" • MCP memory is configured for the coding agents that connected successfully.");
2762
+ console.log(" • Use `echomem-mcp status` to inspect this device, and `login`, `unlock`, or `update` to manage it.");
3076
2763
  console.log(' • Try it now: ask your agent — "search my EchoMem for what I\'ve been working on and recap it."');
3077
2764
  }
3078
2765
  /**
@@ -3152,7 +2839,7 @@ function selectSetupTargets(requested, all) {
3152
2839
  return fs.existsSync(path.dirname(client.configPath));
3153
2840
  if (client.kind === "command")
3154
2841
  return fs.existsSync(client.detectDir);
3155
- return client.id === "claude-code" && fs.existsSync(home(".claude"));
2842
+ return client.id === "claude-code" && claudeCodeCliAvailable();
3156
2843
  });
3157
2844
  }
3158
2845
  return requested ? knownClients().filter((client) => client.id === requested) : detectClients();
@@ -3188,7 +2875,7 @@ async function cmdLogin(flags) {
3188
2875
  return true;
3189
2876
  }
3190
2877
  // Browser path: this bridge does only account/device authentication. It intentionally exposes
3191
- // no local-history routes; `init` owns scan consent, reporting, and optional extraction.
2878
+ // no local-history routes; `init` owns local-history consent and optional extraction.
3192
2879
  console.log("Opening your browser to connect this device locally…");
3193
2880
  const { port, nonce } = localBridgeOptions(flags);
3194
2881
  const srv = await startCallbackServer({ port, nonce, flow: "login" });
@@ -3216,7 +2903,7 @@ async function cmdLogin(flags) {
3216
2903
  }
3217
2904
  /**
3218
2905
  * The local-history onboarding flow. Existing device credentials are reused when available; a
3219
- * fresh device stays in this same bridge and asks for login only after permission and report.
2906
+ * fresh device stays in this same bridge and asks for login only after permission.
3220
2907
  */
3221
2908
  async function cmdOnboarding(flags) {
3222
2909
  const store = new KeyStore();
@@ -3228,149 +2915,17 @@ async function cmdOnboarding(flags) {
3228
2915
  console.log("Opening your browser for EchoMem onboarding…");
3229
2916
  const { port, nonce } = localBridgeOptions(flags);
3230
2917
  let stats = null;
3231
- let forensicReport = null;
3232
- let forensicConsent = "pending";
3233
- let forensicScanStarted = false;
3234
- const forensicStartedAt = Date.now();
3235
- let forensicStageStartedAt = forensicStartedAt;
3236
- let forensicStage = "starting";
3237
- let forensicOverall = 0;
3238
- let forensicProgress = {
3239
- status: "running",
3240
- scanned: 0,
3241
- total: 0,
3242
- stage: forensicStage,
3243
- label: forensicStageLabel(forensicStage),
3244
- stageDone: 0,
3245
- stageTotal: 0,
3246
- overall: 0,
3247
- elapsedMs: 0,
3248
- stageElapsedMs: 0,
3249
- updatedAt: forensicStartedAt,
3250
- };
3251
2918
  const srv = await startCallbackServer({
3252
2919
  port,
3253
2920
  nonce,
3254
2921
  flow: "onboarding",
3255
2922
  initialToken,
3256
- requireReportConsent: true,
3257
- getStats: () => stats,
3258
- getReport: () => forensicReport,
3259
- getReportProgress: () => {
3260
- if (forensicProgress.status !== "running")
3261
- return forensicProgress;
3262
- const now = Date.now();
3263
- const sinceWorkerUpdate = Math.max(0, now - forensicProgress.updatedAt);
3264
- return {
3265
- ...forensicProgress,
3266
- elapsedMs: forensicProgress.elapsedMs + sinceWorkerUpdate,
3267
- stageElapsedMs: forensicProgress.stageElapsedMs + sinceWorkerUpdate,
3268
- updatedAt: now,
3269
- };
3270
- },
3271
- onReportConsent: (allowed) => {
3272
- if (!allowed) {
3273
- forensicConsent = "declined";
3274
- forensicProgress = {
3275
- status: "failed",
3276
- scanned: 0,
3277
- total: 0,
3278
- stage: "failed",
3279
- label: "Local scan skipped",
3280
- stageDone: 0,
3281
- stageTotal: 0,
3282
- overall: 0,
3283
- elapsedMs: Date.now() - forensicStartedAt,
3284
- stageElapsedMs: Date.now() - forensicStageStartedAt,
3285
- updatedAt: Date.now(),
3286
- error: {
3287
- code: "REPORT_SCAN_DECLINED",
3288
- message: "Local file analysis was skipped. EchoMem can still connect, save conversations, and import memories.",
3289
- },
3290
- };
3291
- return;
3292
- }
3293
- forensicConsent = "allowed";
3294
- const now = Date.now();
3295
- forensicStage = "starting";
3296
- forensicStageStartedAt = now;
3297
- forensicOverall = 0;
3298
- forensicProgress = {
3299
- status: "running",
3300
- scanned: 0,
3301
- total: 0,
3302
- stage: forensicStage,
3303
- label: forensicStageLabel(forensicStage),
3304
- stageDone: 0,
3305
- stageTotal: 0,
3306
- overall: 0,
3307
- elapsedMs: now - forensicStartedAt,
3308
- stageElapsedMs: 0,
3309
- updatedAt: now,
3310
- };
3311
- startForensicScan();
3312
- },
2923
+ requireLocalHistoryConsent: true,
3313
2924
  });
3314
2925
  const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
3315
2926
  openBrowser(localSetupUrl);
3316
2927
  console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
3317
2928
  console.log("Waiting for local-history onboarding for up to 15 minutes…");
3318
- const startForensicScan = () => {
3319
- if (forensicScanStarted || forensicConsent !== "allowed")
3320
- return;
3321
- forensicScanStarted = true;
3322
- // Build the local forensic "Context Doctor" report off-thread only after explicit consent.
3323
- buildForensicReportOffThread((progress) => {
3324
- const now = Date.now();
3325
- const nextStage = progress.stage || forensicStage;
3326
- if (nextStage !== forensicStage) {
3327
- forensicStage = nextStage;
3328
- forensicStageStartedAt = now;
3329
- console.log(`Local scan: ${forensicStageLabel(forensicStage)}…`);
3330
- }
3331
- // Latched, so a caller that ever reports a smaller fraction cannot walk the bar backwards.
3332
- forensicOverall = Math.max(forensicOverall, typeof progress.overall === "number" && Number.isFinite(progress.overall) ? progress.overall : 0);
3333
- forensicProgress = {
3334
- status: "running",
3335
- scanned: progress.done,
3336
- total: progress.total,
3337
- stage: forensicStage,
3338
- label: forensicStageLabel(forensicStage),
3339
- detail: progress.detail,
3340
- stageDone: typeof progress.stageDone === "number" && Number.isFinite(progress.stageDone)
3341
- ? Math.max(0, Math.floor(progress.stageDone))
3342
- : 0,
3343
- stageTotal: typeof progress.stageTotal === "number" && Number.isFinite(progress.stageTotal)
3344
- ? Math.max(0, Math.floor(progress.stageTotal))
3345
- : 0,
3346
- overall: forensicOverall,
3347
- elapsedMs: now - forensicStartedAt,
3348
- stageElapsedMs: now - forensicStageStartedAt,
3349
- updatedAt: now,
3350
- };
3351
- })
3352
- .then((r) => {
3353
- forensicReport = r;
3354
- })
3355
- .catch((e) => {
3356
- const now = Date.now();
3357
- forensicProgress = {
3358
- status: "failed",
3359
- scanned: forensicProgress.scanned,
3360
- total: forensicProgress.total,
3361
- stage: "failed",
3362
- label: "Local scan failed",
3363
- stageDone: forensicProgress.stageDone,
3364
- stageTotal: forensicProgress.stageTotal,
3365
- overall: forensicOverall,
3366
- elapsedMs: now - forensicStartedAt,
3367
- stageElapsedMs: now - forensicStageStartedAt,
3368
- updatedAt: now,
3369
- error: safeForensicError(e),
3370
- };
3371
- console.error(`Could not build the local report: ${e instanceof Error ? e.message : String(e)}`);
3372
- });
3373
- };
3374
2929
  let token;
3375
2930
  let key;
3376
2931
  try {
@@ -3461,9 +3016,8 @@ async function cmdOnboarding(flags) {
3461
3016
  claudeCode: quick.claudeCodeCount,
3462
3017
  cowork: quick.coworkCount,
3463
3018
  };
3464
- stats = await buildStatsPayload([], {
3019
+ stats = buildOnboardingStatsPayload({
3465
3020
  partial: true,
3466
- skipMemoryCount: true,
3467
3021
  sessions: sessionSummary,
3468
3022
  migratable,
3469
3023
  discovery: { phase: "quick", exact: false },
@@ -3498,9 +3052,8 @@ async function cmdOnboarding(flags) {
3498
3052
  claudeCode: cloudSummary.claudeCodeCount,
3499
3053
  cowork: cloudSummary.coworkCount,
3500
3054
  };
3501
- const cloudPayload = await buildStatsPayload([], {
3055
+ const cloudPayload = buildOnboardingStatsPayload({
3502
3056
  partial: true,
3503
- skipMemoryCount: true,
3504
3057
  sessions: sessionSummary,
3505
3058
  migratable,
3506
3059
  discovery: { phase: "account", exact: false },
@@ -3536,9 +3089,8 @@ async function cmdOnboarding(flags) {
3536
3089
  claudeCode: unavailableSummary.claudeCodeCount,
3537
3090
  cowork: unavailableSummary.coworkCount,
3538
3091
  };
3539
- const unavailablePayload = await buildStatsPayload([], {
3092
+ const unavailablePayload = buildOnboardingStatsPayload({
3540
3093
  partial: true,
3541
- skipMemoryCount: true,
3542
3094
  sessions: sessionSummary,
3543
3095
  migratable,
3544
3096
  discovery: { phase: "account", exact: false },
@@ -3574,9 +3126,8 @@ async function cmdOnboarding(flags) {
3574
3126
  migratable = migratableFromDiscovery(initialExact);
3575
3127
  latestPendingEstimate = migratable.pending;
3576
3128
  sessionSummary = sessionsFromDiscovery(initialExact);
3577
- const partialPayload = withCandidateSessions(await buildStatsPayload([], {
3129
+ const partialPayload = withCandidateSessions(buildOnboardingStatsPayload({
3578
3130
  partial: true,
3579
- skipMemoryCount: true,
3580
3131
  sessions: sessionSummary,
3581
3132
  migratable,
3582
3133
  discovery: { phase: "exact", exact: true },
@@ -3624,12 +3175,10 @@ async function cmdOnboarding(flags) {
3624
3175
  migratable = migratableFromDiscovery(reconciled);
3625
3176
  latestPendingEstimate = migratable.pending;
3626
3177
  sessionSummary = sessionsFromDiscovery(reconciled);
3627
- const reconciledPayload = withCandidateSessions(await buildStatsPayload([], {
3628
- partial: true,
3629
- skipMemoryCount: true,
3178
+ const reconciledPayload = withCandidateSessions(buildOnboardingStatsPayload({
3630
3179
  sessions: sessionSummary,
3631
3180
  migratable,
3632
- discovery: { phase: "exact", exact: true },
3181
+ discovery: { phase: "full", exact: true },
3633
3182
  }), reconciled);
3634
3183
  if (generation !== refreshGeneration)
3635
3184
  return;
@@ -3644,20 +3193,11 @@ async function cmdOnboarding(flags) {
3644
3193
  failed: 0,
3645
3194
  extracted: 0,
3646
3195
  });
3647
- const fullPayload = withCandidateSessions(await buildCollectedStatsPayloadOffThread({
3648
- sessions: sessionSummary,
3649
- migratable,
3650
- discovery: { phase: "full", exact: true },
3651
- }), reconciled);
3652
- if (generation !== refreshGeneration)
3653
- return;
3654
- stats = fullPayload;
3655
- srv.setStats(fullPayload);
3656
3196
  })().catch((e) => {
3657
3197
  if (generation !== refreshGeneration)
3658
3198
  return;
3659
- console.error(`[echomem] optional full local-history stats unavailable; continuing (${errorCode(e) || "FULL_STATS_FAILED"})`);
3660
- publishOptionalStatsFallback(generation, "FULL_STATS_FAILED", true);
3199
+ console.error(`[echomem] optional account reconciliation unavailable; continuing (${errorCode(e) || "ACCOUNT_RECONCILIATION_FAILED"})`);
3200
+ publishOptionalStatsFallback(generation, "ACCOUNT_RECONCILIATION_FAILED", true);
3661
3201
  });
3662
3202
  return initialExact;
3663
3203
  }).catch((e) => {
@@ -4117,7 +3657,7 @@ Usage:
4117
3657
  echomem-mcp Run the MCP server (stdio; default — used by your editor)
4118
3658
  echomem-mcp setup [--client X] Detect editor, write its MCP config, then connect this device
4119
3659
  echomem-mcp setup --skip-login Write MCP config without opening login/browser
4120
- echomem-mcp setup --force-headless Explicitly replace valid Echo Desktop-managed entries
3660
+ echomem-mcp setup --force-headless Explicitly replace a valid externally managed entry
4121
3661
  echomem-mcp setup --no-codex-skills Skip installing the bundled EchoMem Codex skills
4122
3662
  echomem-mcp update --all Install this bridge durably + repoint detected clients; no login/browser
4123
3663
  echomem-mcp update --client X Repoint one MCP client; no login/browser
@@ -4128,7 +3668,6 @@ Usage:
4128
3668
  echomem-mcp status Show token/key/clients
4129
3669
  echomem-mcp doctor [--no-network] Diagnose configured client bridge versions
4130
3670
  echomem-mcp logout Remove stored credentials
4131
- echomem-mcp report [--json] Your AI coding memory audit (local, no login, $0)
4132
3671
  echomem-mcp migrate [--since D] Import your existing Codex/Claude history into your memory
4133
3672
  echomem-mcp migrate --estimate Estimate migration size/time metadata without uploading transcripts
4134
3673
  echomem-mcp migrate --max-chars N Import only sessions up to N assembled text chars
@@ -4180,9 +3719,6 @@ export async function runCli(argv) {
4180
3719
  case "logout":
4181
3720
  cmdLogout();
4182
3721
  return true;
4183
- case "report":
4184
- await runReport(flags);
4185
- return true;
4186
3722
  case "migrate":
4187
3723
  await cmdMigrate(flags);
4188
3724
  return true;