@echomem/mcp 1.4.43 → 1.4.44

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 (48) hide show
  1. package/README.md +25 -28
  2. package/dist/config-files.js +63 -0
  3. package/dist/context-analysis/claude-native-canonical.js +2 -2
  4. package/dist/context-analysis/vendored-canonical.js +2 -2
  5. package/dist/context-analysis/workspace-report.js +3 -3
  6. package/dist/hud/hooks.js +43 -31
  7. package/dist/index.js +15 -79
  8. package/dist/local-jsonl.js +87 -0
  9. package/dist/migrate.js +6 -4
  10. package/dist/onboarding-stats.js +16 -0
  11. package/dist/save-checkpoint-hook.js +1 -1
  12. package/dist/setup-page/client-core.js +18 -372
  13. package/dist/setup-page/client-extraction.js +33 -188
  14. package/dist/setup-page/client-lifecycle.js +31 -101
  15. package/dist/setup-page/client.js +0 -2
  16. package/dist/setup-page/styles-extraction.js +1 -31
  17. package/dist/setup-page/styles-foundation.js +0 -89
  18. package/dist/setup-page/styles-mvp.js +10 -155
  19. package/dist/setup-page/styles-website-alignment.js +0 -204
  20. package/dist/setup-page/styles.js +0 -4
  21. package/dist/setup-page.js +4 -4
  22. package/dist/setup-preview.js +4 -212
  23. package/dist/setup.js +314 -667
  24. package/dist/v1-contract.js +0 -8
  25. package/package.json +9 -7
  26. package/dist/city/README.md +0 -9
  27. package/dist/city/echo-ai-city-only.html +0 -2232
  28. package/dist/city/echo-extraction-plate.html +0 -330
  29. package/dist/city/echo-face-cutout.png +0 -0
  30. package/dist/city/personality_stickers/bossy.png +0 -0
  31. package/dist/city/personality_stickers/ghosty.png +0 -0
  32. package/dist/city/personality_stickers/loopy.png +0 -0
  33. package/dist/city/personality_stickers/lusty.png +0 -0
  34. package/dist/city/personality_stickers/maxxy.png +0 -0
  35. package/dist/city/personality_stickers/tabby.png +0 -0
  36. package/dist/city/vendor/OrbitControls.js +0 -1417
  37. package/dist/city/vendor/RoundedBoxGeometry.js +0 -155
  38. package/dist/city/vendor/echo_general-file-21.riv +0 -0
  39. package/dist/city/vendor/rive.js +0 -8139
  40. package/dist/city/vendor/rive.wasm +0 -0
  41. package/dist/city/vendor/three.module.min.js +0 -6
  42. package/dist/forensics.js +0 -1531
  43. package/dist/report.js +0 -721
  44. package/dist/setup-page/client-report-audit.js +0 -819
  45. package/dist/setup-page/client-report-city.js +0 -356
  46. package/dist/setup-page/client-report.js +0 -6
  47. package/dist/setup-page/styles-city-report.js +0 -880
  48. 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.
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import http from "node:http";
17
17
  import { randomUUID } from "node:crypto";
18
- import { execFileSync, spawn } from "node:child_process";
18
+ import { execFileSync, spawn, spawnSync, } from "node:child_process";
19
19
  import { Worker } from "node:worker_threads";
20
20
  import fs from "node:fs";
21
21
  import os from "node:os";
@@ -25,13 +25,13 @@ 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";
34
+ import { atomicWriteJsonObject, atomicWriteTextFile, readJsonObjectFile } from "./config-files.js";
35
35
  import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
36
36
  import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
37
37
  import { installHeadlessRuntimeSync, readHeadlessRuntimeInstallation, } from "./headless-runtime.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 {
@@ -227,7 +237,7 @@ export function detectClients() {
227
237
  if (c.kind === "command")
228
238
  return fs.existsSync(c.detectDir);
229
239
  if (c.id === "claude-code")
230
- return fs.existsSync(home(".claude"));
240
+ return claudeCodeCliAvailable();
231
241
  return false;
232
242
  });
233
243
  }
@@ -376,11 +386,11 @@ export function writeCodexConfig(configPath, entry, options = {}) {
376
386
  if (lines.slice(start, end).join("\n").trimEnd() === block)
377
387
  return "exists"; // already correct
378
388
  const next = [...lines.slice(0, start), ...block.split("\n"), ...lines.slice(end)];
379
- fs.writeFileSync(configPath, next.join("\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, ""));
389
+ atomicWriteTextFile(configPath, next.join("\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, ""));
380
390
  return "wrote"; // replaced a stale entry → caller tells the user to restart Codex
381
391
  }
382
392
  const sep = content ? (content.endsWith("\n") ? "\n" : "\n\n") : "";
383
- fs.appendFileSync(configPath, sep + block + "\n");
393
+ atomicWriteTextFile(configPath, content + sep + block + "\n");
384
394
  return "wrote";
385
395
  }
386
396
  /**
@@ -401,7 +411,7 @@ function echomemGuidanceBlock() {
401
411
  "- 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
412
  "- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
403
413
  '- 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.",
414
+ "- 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
415
  '- 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
416
  "- 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
417
  "- 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 +422,7 @@ function echomemGuidanceBlock() {
412
422
  "- 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
423
  "- 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
424
  "- 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.",
425
+ "- 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
426
  "- 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
427
  "- 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
428
  "- 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.",
@@ -437,41 +447,56 @@ export function writeAgentsMemoryGuidance(filePath) {
437
447
  const current = content.slice(start, end + AGENTS_MD_END.length);
438
448
  if (current === block)
439
449
  return "exists";
440
- fs.writeFileSync(filePath, content.slice(0, start) + block + content.slice(end + AGENTS_MD_END.length));
450
+ atomicWriteTextFile(filePath, content.slice(0, start) + block + content.slice(end + AGENTS_MD_END.length));
441
451
  return "updated";
442
452
  }
443
453
  const sep = content ? (content.endsWith("\n") ? "\n" : "\n\n") : "";
444
- fs.appendFileSync(filePath, sep + block + "\n");
454
+ atomicWriteTextFile(filePath, content + sep + block + "\n");
445
455
  return "wrote";
446
456
  }
447
- /** Merge the EchoMem entry into a JSON client's `mcpServers` map without clobbering siblings. */
448
- export function writeJsonClientConfig(configPath, entry, options = {}) {
449
- let config = {};
457
+ /**
458
+ * Refresh marker-owned guidance for users upgrading an existing standalone MCP install.
459
+ * This intentionally does not create global memory files: setup owns first installation,
460
+ * while server startup only migrates guidance that EchoMem already installed.
461
+ */
462
+ export function refreshInstalledMemoryGuidance() {
463
+ let candidates;
450
464
  try {
451
- config = JSON.parse(fs.readFileSync(configPath, "utf8"));
465
+ candidates = [
466
+ path.join(codexHome(), "AGENTS.md"),
467
+ path.join(claudeConfigHome(), "CLAUDE.md"),
468
+ ];
452
469
  }
453
470
  catch {
454
- /* fresh config */
471
+ return;
472
+ }
473
+ for (const filePath of candidates) {
474
+ try {
475
+ const content = fs.readFileSync(filePath, "utf8");
476
+ const start = content.indexOf(AGENTS_MD_BEGIN);
477
+ const end = content.indexOf(AGENTS_MD_END);
478
+ if (start >= 0 && end > start)
479
+ writeAgentsMemoryGuidance(filePath);
480
+ }
481
+ catch {
482
+ // Missing, unreadable, or externally managed files are left untouched.
483
+ }
455
484
  }
456
- config.mcpServers = config.mcpServers || {};
457
- if (!options.forceHeadless && validDesktopManagedEntry(config.mcpServers.echomem)) {
485
+ }
486
+ /** Merge the EchoMem entry into a JSON client's `mcpServers` map without clobbering siblings. */
487
+ export function writeJsonClientConfig(configPath, entry, options = {}) {
488
+ const config = readJsonObjectFile(configPath, "MCP client configuration");
489
+ const servers = objectRecord(config.mcpServers) ?? {};
490
+ config.mcpServers = servers;
491
+ if (!options.forceHeadless && validDesktopManagedEntry(servers.echomem)) {
458
492
  return "desktop-managed";
459
493
  }
460
- config.mcpServers.echomem = entry;
461
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
462
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
494
+ servers.echomem = entry;
495
+ atomicWriteJsonObject(configPath, config);
463
496
  return "wrote";
464
497
  }
465
498
  function readClaudeCodeConfigFile(configPath) {
466
- try {
467
- const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
468
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
469
- ? parsed
470
- : {};
471
- }
472
- catch {
473
- return {};
474
- }
499
+ return readJsonObjectFile(configPath, "Claude Code user configuration");
475
500
  }
476
501
  function echoMemEntryFromServers(value) {
477
502
  return objectRecord(objectRecord(value)?.echomem);
@@ -505,11 +530,78 @@ function claudeCodeLocalEchoMemProjects(configPath) {
505
530
  .map(([projectPath]) => projectPath)
506
531
  .sort();
507
532
  }
533
+ /**
534
+ * Run Claude Code without assuming its launcher is a native executable. npm installs expose
535
+ * `claude.cmd` on Windows, and Node cannot execute .cmd/.bat launchers through execFileSync
536
+ * directly. Resolve the command with `where.exe` and route command files through ComSpec while
537
+ * keeping native .exe installations on the direct exec path.
538
+ */
539
+ function execClaudeCodeSync(args, options) {
540
+ if (process.platform !== "win32")
541
+ return execFileSync("claude", args, options);
542
+ let command = "claude";
543
+ try {
544
+ const resolved = execFileSync("where.exe", ["claude"], {
545
+ encoding: "utf8",
546
+ stdio: ["ignore", "pipe", "ignore"],
547
+ timeout: 3000,
548
+ windowsHide: true,
549
+ })
550
+ .split(/\r?\n/)
551
+ .map((candidate) => candidate.trim())
552
+ .find(Boolean);
553
+ if (resolved)
554
+ command = resolved;
555
+ }
556
+ catch {
557
+ // Preserve the normal command-not-found failure below so callers can report it consistently.
558
+ }
559
+ if (/\.(cmd|bat)$/i.test(command)) {
560
+ const shellCommand = [escapeWindowsCmdCommand(command), ...args.map(escapeWindowsCmdArgument)].join(" ");
561
+ const spawnOptions = {
562
+ ...options,
563
+ windowsHide: true,
564
+ windowsVerbatimArguments: true,
565
+ };
566
+ const result = spawnSync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `"${shellCommand}"`], spawnOptions);
567
+ if (result.error)
568
+ throw result.error;
569
+ if (result.status !== 0) {
570
+ throw new Error(result.stderr?.trim() || `Claude Code exited with status ${result.status ?? "unknown"}.`);
571
+ }
572
+ return result.stdout || "";
573
+ }
574
+ return execFileSync(command, args, { ...options, windowsHide: true });
575
+ }
576
+ function escapeWindowsCmdCommand(value) {
577
+ return value.replace(/([()\][%!^"`<>&|;, *?])/g, "^$1");
578
+ }
579
+ function escapeWindowsCmdArgument(value) {
580
+ let escaped = value
581
+ .replace(/(?=(\\+?)?)\1"/g, "$1$1\\\"")
582
+ .replace(/(?=(\\+?)?)\1$/g, "$1$1");
583
+ escaped = `"${escaped}"`;
584
+ return escaped.replace(/([()\][%!^"`<>&|;, *?])/g, "^$1");
585
+ }
586
+ export function claudeCodeCliAvailable() {
587
+ try {
588
+ execClaudeCodeSync(["--version"], {
589
+ encoding: "utf8",
590
+ stdio: ["ignore", "pipe", "ignore"],
591
+ timeout: 3000,
592
+ });
593
+ return true;
594
+ }
595
+ catch {
596
+ return false;
597
+ }
598
+ }
508
599
  export function writeClaudeCodeConfig(entry, options = {}) {
509
600
  // EchoMem belongs at user scope so every Claude Code project resolves the same durable runtime.
510
601
  // Older CLI versions wrote local/project entries, which take precedence over user scope and can
511
602
  // keep launching a deleted npm cache or stale runtime. Migrate those only after user scope is safe.
512
603
  const configPath = options.configPath ?? home(".claude.json");
604
+ let failureReason;
513
605
  const emptyResult = () => ({
514
606
  state: "unavailable",
515
607
  removedLocalProjects: [],
@@ -517,18 +609,21 @@ export function writeClaudeCodeConfig(entry, options = {}) {
517
609
  failedLocalProjects: [],
518
610
  restoredPreviousUserEntry: false,
519
611
  preservedDesktopManaged: false,
612
+ failureReason,
520
613
  });
521
614
  const runClaude = (args, cwd) => {
522
615
  try {
523
- execFileSync("claude", args, {
616
+ execClaudeCodeSync(args, {
524
617
  cwd,
525
618
  encoding: "utf8",
526
619
  stdio: ["ignore", "pipe", "pipe"],
527
620
  timeout: 10000,
528
621
  });
622
+ failureReason = undefined;
529
623
  return true;
530
624
  }
531
- catch {
625
+ catch (error) {
626
+ failureReason = commandFailureMessage(error);
532
627
  return false;
533
628
  }
534
629
  };
@@ -547,8 +642,10 @@ export function writeClaudeCodeConfig(entry, options = {}) {
547
642
  if (previousUserEntry && !removeUser())
548
643
  return emptyResult();
549
644
  if (!addUser(desiredUserEntry)) {
645
+ const addFailureReason = failureReason;
550
646
  if (previousUserEntry)
551
647
  restoredPreviousUserEntry = addUser(previousUserEntry);
648
+ failureReason = addFailureReason;
552
649
  return { ...emptyResult(), restoredPreviousUserEntry };
553
650
  }
554
651
  }
@@ -762,10 +859,10 @@ function inspectClientConfig(client, desiredVersion) {
762
859
  };
763
860
  }
764
861
  function inspectClaudeCodeConfig(client, desiredVersion) {
765
- if (!fs.existsSync(home(".claude")))
862
+ if (!fs.existsSync(claudeConfigHome()) && !claudeCodeCliAvailable())
766
863
  return null;
767
864
  try {
768
- const output = execFileSync("claude", ["mcp", "list"], {
865
+ const output = execClaudeCodeSync(["mcp", "list"], {
769
866
  encoding: "utf8",
770
867
  stdio: ["ignore", "pipe", "ignore"],
771
868
  timeout: 3000,
@@ -845,6 +942,23 @@ function openBrowser(url) {
845
942
  }
846
943
  }
847
944
  function openClaudeDesktop() {
945
+ if (process.platform === "win32") {
946
+ try {
947
+ spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "start \"\" \"claude://\""], {
948
+ detached: true,
949
+ stdio: "ignore",
950
+ windowsHide: true,
951
+ }).unref();
952
+ return { ok: true, message: "Opened Claude Desktop. The prompt is copied - paste it into Claude." };
953
+ }
954
+ catch (error) {
955
+ return {
956
+ ok: false,
957
+ message: "Could not auto-open Claude Desktop. The prompt is copied - open Claude Desktop and paste it.",
958
+ detail: commandFailureMessage(error),
959
+ };
960
+ }
961
+ }
848
962
  if (process.platform !== "darwin") {
849
963
  return { ok: false, message: "Could not auto-open Claude on this system. The prompt is copied - open Claude Desktop and paste it." };
850
964
  }
@@ -880,7 +994,7 @@ const LOCAL_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-
880
994
  // recover the session's original cwd from its transcript before resuming.
881
995
  function claudeSessionCwd(sessionId) {
882
996
  try {
883
- const projectsDir = path.join(os.homedir(), ".claude", "projects");
997
+ const projectsDir = path.join(claudeConfigHome(), "projects");
884
998
  for (const dir of fs.readdirSync(projectsDir)) {
885
999
  const file = path.join(projectsDir, dir, `${sessionId}.jsonl`);
886
1000
  if (!fs.existsSync(file))
@@ -906,16 +1020,40 @@ function shellQuote(value) {
906
1020
  return `'${value.replace(/'/g, `'\\''`)}'`;
907
1021
  }
908
1022
  function openExistingAgentSession(source, sessionId) {
909
- if (process.platform !== "darwin")
910
- return { ok: false, message: "Opening local agent sessions is currently available on macOS." };
911
1023
  if (!LOCAL_SESSION_ID_RE.test(sessionId))
912
1024
  return { ok: false, message: "The local session identifier is invalid." };
913
1025
  try {
914
1026
  if (source === "codex") {
915
- execFileSync("open", [`codex://threads/${sessionId}`], { stdio: "pipe" });
1027
+ const url = `codex://threads/${sessionId}`;
1028
+ if (process.platform === "win32") {
1029
+ spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `start "" "${url}"`], {
1030
+ detached: true,
1031
+ stdio: "ignore",
1032
+ windowsHide: true,
1033
+ }).unref();
1034
+ }
1035
+ else if (process.platform === "darwin") {
1036
+ execFileSync("open", [url], { stdio: "pipe" });
1037
+ }
1038
+ else {
1039
+ return { ok: false, message: "Opening local Codex sessions is not supported on this system yet." };
1040
+ }
916
1041
  return { ok: true, message: "Opened the original session in Codex." };
917
1042
  }
918
1043
  if (source === "claude-code") {
1044
+ if (process.platform === "win32") {
1045
+ const cwd = claudeSessionCwd(sessionId);
1046
+ spawn(process.env.ComSpec || "cmd.exe", ["/d", "/k", `claude --resume ${sessionId}`], {
1047
+ cwd: cwd && fs.existsSync(cwd) ? cwd : process.cwd(),
1048
+ detached: true,
1049
+ stdio: "ignore",
1050
+ windowsHide: false,
1051
+ }).unref();
1052
+ return { ok: true, message: "Opened the original Claude Code session in a terminal." };
1053
+ }
1054
+ if (process.platform !== "darwin") {
1055
+ return { ok: false, message: "Opening local Claude Code sessions is not supported on this system yet." };
1056
+ }
919
1057
  const claude = firstExisting(["/opt/homebrew/bin/claude", "/usr/local/bin/claude"]) || "claude";
920
1058
  const cwd = claudeSessionCwd(sessionId);
921
1059
  const resume = `${claude} --resume ${sessionId}`;
@@ -1086,7 +1224,7 @@ function withTimeout(promise, ms, code, onTimeout) {
1086
1224
  function delay(ms) {
1087
1225
  return new Promise((resolve) => setTimeout(resolve, ms));
1088
1226
  }
1089
- const CITY_ASSET_TYPES = {
1227
+ const LOCAL_ASSET_TYPES = {
1090
1228
  ".html": "text/html; charset=utf-8",
1091
1229
  ".js": "text/javascript; charset=utf-8",
1092
1230
  ".json": "application/json; charset=utf-8",
@@ -1095,39 +1233,11 @@ const CITY_ASSET_TYPES = {
1095
1233
  ".png": "image/png",
1096
1234
  ".svg": "image/svg+xml",
1097
1235
  };
1098
- function repoCityArtifactsRoot() {
1099
- // Monorepo/dev: read the city assets live from repo-root /artifacts.
1100
- const repo = fileURLToPath(new URL("../../../artifacts/", import.meta.url));
1101
- if (fs.existsSync(repo))
1102
- return repo;
1103
- // Published install: fall back to the copy bundled into dist/city by prepack (bundle-city.mjs).
1104
- return fileURLToPath(new URL("./city/", import.meta.url));
1105
- }
1106
- function serveRepoCityAsset(reqPath, res) {
1107
- const root = repoCityArtifactsRoot();
1108
- const rel = reqPath === "/city" || reqPath === "/city/" ? "echo-ai-city-only.html" : decodeURIComponent(reqPath.slice("/city/".length));
1109
- // Archives stay in the checkout for recovery, but must never become a localhost UI surface.
1110
- const normalizedRel = rel.replace(/\\/g, "/");
1111
- if (normalizedRel === "archive" || normalizedRel.startsWith("archive/")) {
1112
- res.writeHead(404).end("not found");
1113
- return true;
1114
- }
1115
- const filePath = path.resolve(root, rel);
1116
- const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
1117
- if (!filePath.startsWith(rootWithSep)) {
1118
- res.writeHead(403).end("forbidden");
1119
- return true;
1120
- }
1121
- if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
1122
- res.writeHead(404).end("not found");
1123
- return true;
1124
- }
1125
- res.writeHead(200, {
1126
- "Content-Type": CITY_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
1127
- "Cache-Control": "no-store",
1128
- });
1129
- fs.createReadStream(filePath).pipe(res);
1130
- return true;
1236
+ function repoLabel(cwd) {
1237
+ if (!cwd)
1238
+ return "";
1239
+ const normalized = cwd.replace(/[\\/]+$/, "");
1240
+ return path.basename(normalized) || normalized;
1131
1241
  }
1132
1242
  function hudAssetsRoot() {
1133
1243
  return fileURLToPath(new URL("../assets/hud/", import.meta.url));
@@ -1146,7 +1256,7 @@ function serveHudAsset(reqPath, res) {
1146
1256
  return true;
1147
1257
  }
1148
1258
  res.writeHead(200, {
1149
- "Content-Type": CITY_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
1259
+ "Content-Type": LOCAL_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
1150
1260
  "Cache-Control": "no-store",
1151
1261
  });
1152
1262
  fs.createReadStream(filePath).pipe(res);
@@ -1323,61 +1433,6 @@ export function discoverMigratableFastOffThread(opts = {}) {
1323
1433
  });
1324
1434
  });
1325
1435
  }
1326
- /** Build the full local-history dashboard payload away from the callback server's event loop.
1327
- * `collect()` can synchronously parse hundreds of JSONL files for tens of seconds; doing that on
1328
- * the bridge thread prevents even localhost actions such as account switch from receiving a reply. */
1329
- export function buildCollectedStatsPayloadOffThread(inject) {
1330
- const reportUrl = runtimeModuleUrl("report");
1331
- const serializedInject = JSON.stringify(inject);
1332
- const code = `
1333
- import { parentPort } from "node:worker_threads";
1334
- import { collect, buildStatsPayload } from ${JSON.stringify(reportUrl)};
1335
-
1336
- try {
1337
- const payload = await buildStatsPayload(collect(), ${serializedInject});
1338
- parentPort?.postMessage({ ok: true, payload });
1339
- } catch (error) {
1340
- parentPort?.postMessage({
1341
- ok: false,
1342
- message: error instanceof Error ? error.message : String(error),
1343
- stack: error instanceof Error ? error.stack : undefined,
1344
- });
1345
- }
1346
- `;
1347
- const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
1348
- return new Promise((resolve, reject) => {
1349
- let settled = false;
1350
- const finish = (result) => {
1351
- if (settled)
1352
- return;
1353
- settled = true;
1354
- void worker.terminate();
1355
- if (result.ok)
1356
- resolve(result.payload);
1357
- else
1358
- reject(result.error);
1359
- };
1360
- worker.once("message", (message) => {
1361
- const msg = message;
1362
- if (msg.ok === true) {
1363
- finish({ ok: true, payload: msg.payload });
1364
- return;
1365
- }
1366
- const error = new Error(typeof msg.message === "string" ? msg.message : "Full local stats worker failed");
1367
- if (typeof msg.stack === "string")
1368
- error.stack = msg.stack;
1369
- finish({ ok: false, error });
1370
- });
1371
- worker.once("error", (error) => {
1372
- finish({ ok: false, error });
1373
- });
1374
- worker.once("exit", (code) => {
1375
- if (settled)
1376
- return;
1377
- finish({ ok: false, error: new Error(`Full local stats worker exited (code ${code}) without a result`) });
1378
- });
1379
- });
1380
- }
1381
1436
  export function createLocalDiscoveryCache(loaders = {}) {
1382
1437
  const loadQuick = loaders.loadQuick ?? (() => discoverMigratableFastOffThread());
1383
1438
  const loadExact = loaders.loadExact ?? (() => discoverMigratableSessionsOffThread());
@@ -1426,45 +1481,6 @@ export function createLocalDiscoveryCache(loaders = {}) {
1426
1481
  },
1427
1482
  };
1428
1483
  }
1429
- function forensicStageLabel(stage) {
1430
- if (stage === "reading-transcripts")
1431
- return "Reading transcript files";
1432
- if (stage === "building-summary")
1433
- return "Building scan summary";
1434
- if (stage === "classifying-repeated-context")
1435
- return "Classifying repeated context";
1436
- if (stage === "finalizing-report")
1437
- return "Finalizing report";
1438
- return "Starting local scan";
1439
- }
1440
- /** Build the local forensic "Context Doctor" report on a worker thread so the multi-file scan never
1441
- * blocks the bridge's event loop (the freeze postmortem: any unbounded sync work on this path is risky). */
1442
- export function buildForensicReportOffThread(onProgress, options = {}) {
1443
- let lastProgress = null;
1444
- const recordProgress = (progress) => {
1445
- lastProgress = progress;
1446
- onProgress?.(progress);
1447
- };
1448
- return runForensicReportWorker(recordProgress, options).catch(async (primaryError) => {
1449
- if (options.failOpen === false)
1450
- throw primaryError;
1451
- const failureCode = errorCode(primaryError) || "REPORT_BUILD_FAILED";
1452
- console.error(`[echomem] local scan degraded after ${failureCode}; continuing without local-history analysis`);
1453
- onProgress?.({
1454
- done: lastProgress?.done || 0,
1455
- total: lastProgress?.total || 0,
1456
- stage: "finalizing-report",
1457
- detail: "finishing setup without optional local-history analysis",
1458
- overall: 0.99,
1459
- stageDone: 0,
1460
- stageTotal: 0,
1461
- });
1462
- return runForensicReportWorker(undefined, {
1463
- timeoutMs: 30_000,
1464
- maxOldGenerationSizeMb: Math.max(64, options.maxOldGenerationSizeMb || 0),
1465
- }, [], failureCode);
1466
- });
1467
- }
1468
1484
  function errorCode(error) {
1469
1485
  return error && typeof error === "object" && "code" in error
1470
1486
  ? String(error.code || "")
@@ -1484,7 +1500,6 @@ export function completeOptionalStatsPayload(payload, reason, countsTrusted) {
1484
1500
  transcriptsUploaded: false,
1485
1501
  sessions: { total: 0, codex: 0, claudeCode: 0 },
1486
1502
  migratable: { pending: 0, alreadyMigrated: 0 },
1487
- memoriesCaptured: null,
1488
1503
  };
1489
1504
  const completed = payload && typeof payload === "object" && !Array.isArray(payload)
1490
1505
  ? { ...payload }
@@ -1497,155 +1512,11 @@ export function completeOptionalStatsPayload(payload, reason, countsTrusted) {
1497
1512
  };
1498
1513
  return completed;
1499
1514
  }
1500
- function runForensicReportWorker(onProgress, options, sources, degradedReason) {
1501
- const forensicsUrl = runtimeModuleUrl("forensics");
1502
- const serializedSources = sources === undefined ? "undefined" : JSON.stringify(sources);
1503
- const serializedDegradedReason = JSON.stringify(degradedReason || "");
1504
- const code = `
1505
- import { parentPort } from "node:worker_threads";
1506
- import { buildForensicReport, validateForensicReportForSetup } from ${JSON.stringify(forensicsUrl)};
1507
- try {
1508
- const report = await buildForensicReport({
1509
- sources: ${serializedSources},
1510
- includeLegacyGoldenStandard: false,
1511
- onProgress: (done, total, stage, detail, overall, stageDone, stageTotal) => parentPort?.postMessage({
1512
- progress: { done, total, stage, detail, overall, stageDone, stageTotal },
1513
- }),
1514
- });
1515
- const degradedReason = ${serializedDegradedReason};
1516
- if (degradedReason) {
1517
- report.scanDiagnostics = {
1518
- degraded: true,
1519
- reason: degradedReason,
1520
- skippedSources: ["codex", "claude"],
1521
- };
1522
- }
1523
- const validation = validateForensicReportForSetup(report);
1524
- if (!validation.ok) {
1525
- const error = new Error(validation.message);
1526
- error.code = validation.code;
1527
- throw error;
1528
- }
1529
- parentPort?.postMessage({ ok: true, report });
1530
- } catch (error) {
1531
- parentPort?.postMessage({
1532
- ok: false,
1533
- message: error instanceof Error ? error.message : String(error),
1534
- code: error && typeof error === "object" && "code" in error ? String(error.code || "") : "",
1535
- });
1536
- }
1537
- `;
1538
- const requestedHeapMb = options.maxOldGenerationSizeMb;
1539
- const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`), Number.isFinite(requestedHeapMb)
1540
- ? { resourceLimits: { maxOldGenerationSizeMb: Math.max(16, Math.floor(requestedHeapMb)) } }
1541
- : undefined);
1542
- return new Promise((resolve, reject) => {
1543
- let settled = false;
1544
- const requestedTimeoutMs = options.timeoutMs ?? 15 * 60_000;
1545
- const timeoutMs = Number.isFinite(requestedTimeoutMs) ? Math.max(1, requestedTimeoutMs) : 15 * 60_000;
1546
- const timeout = setTimeout(() => {
1547
- if (settled)
1548
- return;
1549
- settled = true;
1550
- void worker.terminate();
1551
- const error = new Error(`Local forensic report timed out after ${timeoutMs}ms`);
1552
- error.code = "REPORT_SCAN_TIMEOUT";
1553
- reject(error);
1554
- }, timeoutMs);
1555
- timeout.unref?.();
1556
- const finish = (result) => {
1557
- if (settled)
1558
- return;
1559
- settled = true;
1560
- clearTimeout(timeout);
1561
- void worker.terminate();
1562
- if (result.ok)
1563
- resolve(result.report);
1564
- else
1565
- reject(result.error);
1566
- };
1567
- worker.on("message", (message) => {
1568
- if (settled)
1569
- return;
1570
- const msg = message;
1571
- if (msg.progress) {
1572
- onProgress?.(msg.progress);
1573
- return;
1574
- }
1575
- if (msg.ok === true && msg.report && typeof msg.report === "object") {
1576
- finish({ ok: true, report: msg.report });
1577
- return;
1578
- }
1579
- const error = new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed");
1580
- if (typeof msg.code === "string" && msg.code)
1581
- error.code = msg.code;
1582
- finish({ ok: false, error });
1583
- });
1584
- worker.once("error", (error) => {
1585
- finish({ ok: false, error });
1586
- });
1587
- worker.once("exit", (code) => {
1588
- if (settled)
1589
- return;
1590
- finish({ ok: false, error: new Error(`Forensic report worker exited (code ${code}) without a result`) });
1591
- });
1592
- });
1593
- }
1594
1515
  export function respondMigrate(res, body, status = 200) {
1595
1516
  if (res.writableEnded)
1596
1517
  return;
1597
1518
  res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
1598
1519
  }
1599
- function safeForensicError(error) {
1600
- const code = error && typeof error === "object" && "code" in error
1601
- ? String(error.code || "")
1602
- : "";
1603
- if (code === "REPORT_SCAN_TIMEOUT") {
1604
- return {
1605
- code,
1606
- message: "The local workspace scan took too long and was stopped. No backup data was substituted. Rerun setup to retry.",
1607
- };
1608
- }
1609
- return {
1610
- code: "REPORT_BUILD_FAILED",
1611
- message: "EchoMem could not finish the local workspace scan. No backup data was substituted. Rerun setup to retry.",
1612
- };
1613
- }
1614
- function publicRunningForensicProgress(value) {
1615
- if (!value || typeof value !== "object")
1616
- return null;
1617
- const progress = value;
1618
- if (progress.status !== "running")
1619
- return null;
1620
- const safeCount = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
1621
- ? Math.floor(candidate)
1622
- : 0);
1623
- const safeDuration = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
1624
- ? candidate
1625
- : 0);
1626
- const safeFraction = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate)
1627
- ? Math.min(1, Math.max(0, candidate))
1628
- : 0);
1629
- const total = safeCount(progress.total);
1630
- const stageTotal = safeCount(progress.stageTotal);
1631
- const rawStage = typeof progress.stage === "string" ? progress.stage : "starting";
1632
- const stage = ["starting", "reading-transcripts", "building-summary", "classifying-repeated-context", "finalizing-report"].includes(rawStage)
1633
- ? rawStage
1634
- : "starting";
1635
- return {
1636
- status: "running",
1637
- scanned: total > 0 ? Math.min(safeCount(progress.scanned), total) : 0,
1638
- total,
1639
- stage,
1640
- label: forensicStageLabel(stage),
1641
- stageDone: stageTotal > 0 ? Math.min(safeCount(progress.stageDone), stageTotal) : 0,
1642
- stageTotal,
1643
- overall: safeFraction(progress.overall),
1644
- elapsedMs: safeDuration(progress.elapsedMs),
1645
- stageElapsedMs: safeDuration(progress.stageElapsedMs),
1646
- updatedAt: safeDuration(progress.updatedAt) || Date.now(),
1647
- };
1648
- }
1649
1520
  /**
1650
1521
  * Start the persistent localhost bridge used by the setup page. It sends/verifies OTP through the
1651
1522
  * hosted API, accepts the local passphrase, serves local Wrapped stats, and holds the /migrate
@@ -1658,12 +1529,11 @@ export function startCallbackServer(opts = {}) {
1658
1529
  : `${Math.ceil(timeoutMs / 1000)} seconds`;
1659
1530
  const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
1660
1531
  const expectedNonce = opts.nonce;
1661
- const scanId = opts.scanId ?? randomUUID();
1662
1532
  const flow = opts.flow ?? "onboarding";
1663
1533
  const isLoginFlow = flow === "login";
1664
1534
  // A login screen must not be blocked by a local-history permission. That permission belongs to
1665
1535
  // onboarding and is intentionally enforced separately below.
1666
- const requiresReportConsent = opts.requireReportConsent === true && !isLoginFlow;
1536
+ const requiresLocalHistoryConsent = opts.requireLocalHistoryConsent === true && !isLoginFlow;
1667
1537
  return new Promise((resolveOuter, rejectOuter) => {
1668
1538
  const onToken = deferred();
1669
1539
  const setupExit = deferred();
@@ -1676,7 +1546,7 @@ export function startCallbackServer(opts = {}) {
1676
1546
  let activeDeviceToken = opts.initialToken?.token || "";
1677
1547
  let activeAccountEmail = "";
1678
1548
  let pendingLocalAuth = null;
1679
- let reportConsentGranted = !requiresReportConsent;
1549
+ let localHistoryConsentGranted = !requiresLocalHistoryConsent;
1680
1550
  let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
1681
1551
  let migrateStarted = false;
1682
1552
  let tokenRefreshHandler = null;
@@ -1743,7 +1613,7 @@ export function startCallbackServer(opts = {}) {
1743
1613
  const handleCallback = (res, token, key, nonce) => {
1744
1614
  if (!checkNonce(nonce))
1745
1615
  return void text(res, 403, "bad nonce");
1746
- if (requiresReportConsent && !reportConsentGranted) {
1616
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
1747
1617
  return void json(res, 403, {
1748
1618
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1749
1619
  message: "Allow local history access in the setup page before connecting EchoMem.",
@@ -1773,7 +1643,7 @@ export function startCallbackServer(opts = {}) {
1773
1643
  text(res, 403, "bad nonce");
1774
1644
  return true;
1775
1645
  }
1776
- if (requiresReportConsent && !reportConsentGranted) {
1646
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
1777
1647
  json(res, 403, {
1778
1648
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1779
1649
  message: "Allow local history access in the setup page before connecting EchoMem.",
@@ -1800,8 +1670,7 @@ export function startCallbackServer(opts = {}) {
1800
1670
  armTimeout();
1801
1671
  };
1802
1672
  const isOnboardingOnlyRoute = (route) => [
1803
- "/report-consent",
1804
- "/report",
1673
+ "/local-history-consent",
1805
1674
  "/stats",
1806
1675
  "/billing-status",
1807
1676
  "/billing-checkout",
@@ -1994,10 +1863,6 @@ export function startCallbackServer(opts = {}) {
1994
1863
  message: "Run `echomem-mcp init` to access local-history onboarding.",
1995
1864
  });
1996
1865
  }
1997
- if ((route === "/city" || route.startsWith("/city/")) && req.method === "GET") {
1998
- serveRepoCityAsset(route, res);
1999
- return;
2000
- }
2001
1866
  if (route.startsWith("/hud-assets/") && req.method === "GET") {
2002
1867
  serveHudAsset(route, res);
2003
1868
  return;
@@ -2035,8 +1900,13 @@ export function startCallbackServer(opts = {}) {
2035
1900
  localOnly: true,
2036
1901
  localAuth: true,
2037
1902
  workspacePath: process.cwd(),
2038
- consentRequired: requiresReportConsent,
2039
- consentGranted: reportConsentGranted,
1903
+ consentRequired: requiresLocalHistoryConsent,
1904
+ consentGranted: localHistoryConsentGranted,
1905
+ platform: process.platform,
1906
+ capabilities: {
1907
+ openClaudeDesktop: process.platform === "darwin" || process.platform === "win32",
1908
+ openAgentSessions: process.platform === "darwin" || process.platform === "win32",
1909
+ },
2040
1910
  });
2041
1911
  return;
2042
1912
  }
@@ -2140,7 +2010,7 @@ export function startCallbackServer(opts = {}) {
2140
2010
  if (route === "/stats" && req.method === "GET") {
2141
2011
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
2142
2012
  return void text(res, 403, "bad nonce");
2143
- if (requiresReportConsent && !reportConsentGranted) {
2013
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2144
2014
  return void json(res, 403, {
2145
2015
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2146
2016
  message: "Allow local history access before continuing setup.",
@@ -2156,7 +2026,7 @@ export function startCallbackServer(opts = {}) {
2156
2026
  res.setHeader("Cache-Control", "no-store, max-age=0");
2157
2027
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
2158
2028
  return void text(res, 403, "bad nonce");
2159
- if (requiresReportConsent && !reportConsentGranted) {
2029
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2160
2030
  return void json(res, 403, {
2161
2031
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2162
2032
  message: "Allow local history access before continuing setup.",
@@ -2280,7 +2150,7 @@ export function startCallbackServer(opts = {}) {
2280
2150
  }
2281
2151
  if (!checkNonce(asString(body.nonce)))
2282
2152
  return void text(res, 403, "bad nonce");
2283
- if (requiresReportConsent && !reportConsentGranted) {
2153
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2284
2154
  return void json(res, 403, {
2285
2155
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2286
2156
  message: "Allow local history access before managing an onboarding plan.",
@@ -2346,114 +2216,10 @@ export function startCallbackServer(opts = {}) {
2346
2216
  }
2347
2217
  return;
2348
2218
  }
2349
- if (route === "/report" && req.method === "GET") {
2350
- // Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
2351
- res.setHeader("Cache-Control", "no-store");
2352
- if (!checkNonce(url.searchParams.get("nonce") || undefined))
2353
- return void text(res, 403, "bad nonce");
2354
- if (requiresReportConsent && !reportConsentGranted) {
2355
- return void json(res, 403, {
2356
- error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2357
- message: "Allow local history access before starting the local scan.",
2358
- });
2359
- }
2360
- let payload;
2361
- try {
2362
- payload = opts.getReport ? opts.getReport() : null;
2363
- }
2364
- catch {
2365
- return void json(res, 500, {
2366
- schemaVersion: 1,
2367
- kind: "failed",
2368
- mode: "production",
2369
- scanId,
2370
- error: {
2371
- code: "REPORT_STATE_UNAVAILABLE",
2372
- message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
2373
- },
2374
- });
2375
- }
2376
- if (payload == null) {
2377
- // 202 carries scan progress so the page can show a live "scanned N/total" indicator.
2378
- let prog;
2379
- try {
2380
- prog = opts.getReportProgress ? opts.getReportProgress() : {
2381
- status: "running",
2382
- scanned: 0,
2383
- total: 0,
2384
- stage: "starting",
2385
- label: "Starting local scan",
2386
- elapsedMs: 0,
2387
- stageElapsedMs: 0,
2388
- updatedAt: Date.now(),
2389
- };
2390
- }
2391
- catch {
2392
- return void json(res, 500, {
2393
- schemaVersion: 1,
2394
- kind: "failed",
2395
- mode: "production",
2396
- scanId,
2397
- error: {
2398
- code: "REPORT_STATE_UNAVAILABLE",
2399
- message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
2400
- },
2401
- });
2402
- }
2403
- if (prog && typeof prog === "object" && prog.status === "failed") {
2404
- return void json(res, 500, {
2405
- schemaVersion: 1,
2406
- kind: "failed",
2407
- mode: "production",
2408
- scanId,
2409
- error: safeForensicError(prog.error),
2410
- });
2411
- }
2412
- const publicProgress = publicRunningForensicProgress(prog);
2413
- if (!publicProgress) {
2414
- return void json(res, 500, {
2415
- schemaVersion: 1,
2416
- kind: "failed",
2417
- mode: "production",
2418
- scanId,
2419
- error: {
2420
- code: "REPORT_STATE_INVALID",
2421
- message: "EchoMem received an invalid local scan state. No backup data was substituted. Rerun setup to retry.",
2422
- },
2423
- });
2424
- }
2425
- return void json(res, 202, {
2426
- schemaVersion: 1,
2427
- kind: "scanning",
2428
- mode: "production",
2429
- scanId,
2430
- progress: publicProgress,
2431
- });
2432
- }
2433
- const validation = validateForensicReportForSetup(payload);
2434
- if (!validation.ok) {
2435
- console.error(`[echomem] local report validation failed: ${validation.code} — ${validation.message}`);
2436
- return void json(res, 500, {
2437
- schemaVersion: 1,
2438
- kind: "failed",
2439
- mode: "production",
2440
- scanId,
2441
- error: { code: validation.code, message: validation.message },
2442
- });
2443
- }
2444
- json(res, 200, {
2445
- schemaVersion: 1,
2446
- kind: validation.kind,
2447
- mode: "production",
2448
- scanId,
2449
- report: validation.report,
2450
- });
2451
- return;
2452
- }
2453
2219
  if (route === "/progress" && req.method === "GET") {
2454
2220
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
2455
2221
  return void text(res, 403, "bad nonce");
2456
- if (requiresReportConsent && !reportConsentGranted) {
2222
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2457
2223
  return void json(res, 403, {
2458
2224
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2459
2225
  message: "Allow local history access before continuing setup.",
@@ -2462,7 +2228,7 @@ export function startCallbackServer(opts = {}) {
2462
2228
  json(res, 200, progress);
2463
2229
  return;
2464
2230
  }
2465
- if (route === "/report-consent" && req.method === "POST") {
2231
+ if (route === "/local-history-consent" && req.method === "POST") {
2466
2232
  let body;
2467
2233
  try {
2468
2234
  body = await readJsonBody(req);
@@ -2474,8 +2240,7 @@ export function startCallbackServer(opts = {}) {
2474
2240
  if (!checkNonce(asString(body.nonce)))
2475
2241
  return void text(res, 403, "bad nonce");
2476
2242
  const allowed = body.allowed === true;
2477
- reportConsentGranted = allowed;
2478
- opts.onReportConsent?.(allowed);
2243
+ localHistoryConsentGranted = allowed;
2479
2244
  json(res, 200, { ok: true, allowed });
2480
2245
  return;
2481
2246
  }
@@ -2534,7 +2299,7 @@ export function startCallbackServer(opts = {}) {
2534
2299
  }
2535
2300
  if (!checkNonce(asString(body.nonce)))
2536
2301
  return void text(res, 403, "bad nonce");
2537
- if (requiresReportConsent && !reportConsentGranted) {
2302
+ if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2538
2303
  return void json(res, 403, {
2539
2304
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2540
2305
  message: "Allow local history access before starting extraction.",
@@ -2796,6 +2561,7 @@ async function cmdSetup(flags) {
2796
2561
  // --dev is already an explicit request to replace the managed runtime with a checkout.
2797
2562
  const forceHeadless = flags["force-headless"] === true || typeof flags.dev === "string";
2798
2563
  const configurationFailures = [];
2564
+ const configuredTargets = [];
2799
2565
  if (targets.length === 0) {
2800
2566
  console.log("No client auto-detected. Add this MCP server entry manually:\n");
2801
2567
  console.log(JSON.stringify({ echomem: entry }, null, 2));
@@ -2803,66 +2569,91 @@ async function cmdSetup(flags) {
2803
2569
  }
2804
2570
  else {
2805
2571
  for (const c of targets) {
2806
- if (c.kind === "json") {
2807
- const result = writeJsonClientConfig(c.configPath, entry, { forceHeadless });
2808
- if (result === "desktop-managed") {
2809
- console.log(`✅ Kept the valid Echo Desktop-managed EchoMem entry for ${c.label}: ${c.configPath}`);
2810
- }
2811
- else {
2812
- console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath}`);
2813
- }
2814
- }
2815
- else if (c.kind === "command") {
2816
- const result = writeCodexConfig(c.configPath, entry, { forceHeadless });
2817
- if (result === "wrote")
2818
- console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath} — start a new Codex session to load it.`);
2819
- else if (result === "desktop-managed")
2820
- console.log(`✅ Kept the valid Echo Desktop-managed EchoMem entry for ${c.label}: ${c.configPath}`);
2821
- else
2822
- console.log(`✅ ${c.label} already has the EchoMem MCP entry: ${c.configPath}`);
2823
- }
2824
- else {
2825
- const result = c.id === "claude-code"
2826
- ? writeClaudeCodeConfig(entry, { forceHeadless })
2827
- : "unavailable";
2828
- if (result !== "unavailable" && result.state === "wrote") {
2829
- if (result.preservedDesktopManaged) {
2830
- console.log(`✅ Kept the valid Echo Desktop-managed EchoMem user entry for ${c.label}.`);
2572
+ try {
2573
+ if (c.kind === "json") {
2574
+ const result = writeJsonClientConfig(c.configPath, entry, { forceHeadless });
2575
+ if (result === "desktop-managed") {
2576
+ console.log(`✅ Kept the valid externally managed EchoMem entry for ${c.label}: ${c.configPath}`);
2831
2577
  }
2832
2578
  else {
2833
- console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
2834
- }
2835
- if (result.removedLocalProjects.length > 0) {
2836
- console.log(`✅ Removed ${result.removedLocalProjects.length} stale Claude Code project-local EchoMem ${result.removedLocalProjects.length === 1 ? "entry" : "entries"}.`);
2837
- }
2838
- if (result.skippedLocalProjects.length > 0) {
2839
- console.log(`ℹ️ Ignored ${result.skippedLocalProjects.length} EchoMem local ${result.skippedLocalProjects.length === 1 ? "entry" : "entries"} for deleted project directories; they cannot shadow the user entry.`);
2579
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath}`);
2840
2580
  }
2581
+ configuredTargets.push(c);
2582
+ }
2583
+ else if (c.kind === "command") {
2584
+ const result = writeCodexConfig(c.configPath, entry, { forceHeadless });
2585
+ if (result === "wrote")
2586
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath} — start a new Codex session to load it.`);
2587
+ else if (result === "desktop-managed")
2588
+ console.log(`✅ Kept the valid externally managed EchoMem entry for ${c.label}: ${c.configPath}`);
2589
+ else
2590
+ console.log(`✅ ${c.label} already has the EchoMem MCP entry: ${c.configPath}`);
2591
+ configuredTargets.push(c);
2841
2592
  }
2842
2593
  else {
2843
- const failedProjects = result === "unavailable" ? [] : result.failedLocalProjects;
2844
- configurationFailures.push(failedProjects.length > 0
2845
- ? `${c.label} still has project-local EchoMem overrides in: ${failedProjects.join(", ")}`
2846
- : `${c.label} user-scoped EchoMem entry could not be verified`);
2594
+ const result = c.id === "claude-code"
2595
+ ? writeClaudeCodeConfig(entry, { forceHeadless })
2596
+ : "unavailable";
2597
+ if (result !== "unavailable" && result.state === "wrote") {
2598
+ if (result.preservedDesktopManaged) {
2599
+ console.log(`✅ Kept the valid externally managed EchoMem user entry for ${c.label}.`);
2600
+ }
2601
+ else {
2602
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
2603
+ }
2604
+ if (result.removedLocalProjects.length > 0) {
2605
+ console.log(`✅ Removed ${result.removedLocalProjects.length} stale Claude Code project-local EchoMem ${result.removedLocalProjects.length === 1 ? "entry" : "entries"}.`);
2606
+ }
2607
+ if (result.skippedLocalProjects.length > 0) {
2608
+ console.log(`ℹ️ Ignored ${result.skippedLocalProjects.length} EchoMem local ${result.skippedLocalProjects.length === 1 ? "entry" : "entries"} for deleted project directories; they cannot shadow the user entry.`);
2609
+ }
2610
+ configuredTargets.push(c);
2611
+ }
2612
+ else {
2613
+ const failedProjects = result === "unavailable" ? [] : result.failedLocalProjects;
2614
+ const reason = result === "unavailable" ? undefined : result.failureReason;
2615
+ configurationFailures.push({
2616
+ client: c,
2617
+ reason: failedProjects.length > 0
2618
+ ? `${c.label} still has project-local EchoMem overrides in: ${failedProjects.join(", ")}`
2619
+ : `${c.label} user-scoped EchoMem entry could not be verified${reason ? `: ${reason}` : ""}`,
2620
+ });
2621
+ }
2847
2622
  }
2848
2623
  }
2624
+ catch (error) {
2625
+ configurationFailures.push({
2626
+ client: c,
2627
+ reason: `${c.label} configuration was left unchanged: ${error instanceof Error ? error.message : String(error)}`,
2628
+ });
2629
+ }
2849
2630
  }
2850
2631
  }
2851
2632
  if (configurationFailures.length > 0) {
2852
- throw new Error([
2853
- "EchoMem MCP configuration is incomplete; onboarding was stopped before login/import.",
2854
- ...configurationFailures.map((failure) => `- ${failure}`),
2855
- `Retry with: ${MCP_UPDATE_COMMAND} --client claude-code`,
2856
- ].join("\n"));
2633
+ const continueOnClientError = flags["continue-on-client-error"] === true;
2634
+ const failureMessage = [
2635
+ continueOnClientError
2636
+ ? "EchoMem could not configure every detected client."
2637
+ : "EchoMem MCP configuration is incomplete; onboarding was stopped before login/import.",
2638
+ ...configurationFailures.map((failure) => `- ${failure.reason}`),
2639
+ ...configurationFailures.map((failure) => `Retry ${failure.client.label} with: ${MCP_UPDATE_COMMAND} --client ${failure.client.id}`),
2640
+ ].join("\n");
2641
+ if (continueOnClientError) {
2642
+ console.log(`⚠️ ${failureMessage}`);
2643
+ console.log("Continuing with the clients that connected successfully. You can repair the remaining client later.");
2644
+ }
2645
+ else {
2646
+ throw new Error(failureMessage);
2647
+ }
2857
2648
  }
2858
2649
  if (!flags["no-agents-md"]) {
2859
- writeMemoryGuidanceForTargets(targets);
2650
+ writeMemoryGuidanceForTargets(configuredTargets);
2860
2651
  }
2861
2652
  if (!flags["no-codex-skills"]) {
2862
- writeCodexSkillsForTargets(targets);
2653
+ writeCodexSkillsForTargets(configuredTargets);
2863
2654
  }
2864
2655
  if (flags["no-save-hooks"] !== true) {
2865
- writeLifecycleHooksForTargets(targets);
2656
+ writeLifecycleHooksForTargets(configuredTargets);
2866
2657
  }
2867
2658
  console.log("");
2868
2659
  if (flags["skip-login"] || flags["no-login"]) {
@@ -2874,14 +2665,14 @@ async function cmdSetup(flags) {
2874
2665
  await cmdLogin(flags);
2875
2666
  }
2876
2667
  if (flags["with-hud"]) {
2877
- console.log("ℹ️ The standalone EchoMem HUD has been retired. Echo Desktop now owns setup and status.");
2668
+ console.log("ℹ️ The standalone EchoMem HUD has been retired. Use `echomem-mcp status` to inspect this installation.");
2878
2669
  }
2879
2670
  }
2880
2671
  /**
2881
2672
  * `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
2882
2673
  * machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), installs EchoMem's
2883
2674
  * Codex skills and writes the AGENTS.md memory guidance. One browser
2884
- * bridge then runs permission → report → login → plan if needed → extraction in that order.
2675
+ * bridge then runs permission → login → plan if needed → extraction in that order.
2885
2676
  * `setup`/`login`/`update` remain granular primitives; init picks the full product defaults.
2886
2677
  */
2887
2678
  async function cmdInit(flags) {
@@ -2890,12 +2681,13 @@ async function cmdInit(flags) {
2890
2681
  await cmdSetup({
2891
2682
  ...flags,
2892
2683
  all: true,
2684
+ "continue-on-client-error": true,
2893
2685
  "skip-login": true,
2894
2686
  "with-hud": false,
2895
2687
  "init-quiet": true,
2896
2688
  "install-save-hooks": flags["no-save-hooks"] !== true,
2897
2689
  });
2898
- // 2. Start one ordered onboarding bridge. A fresh device logs in only after consent + report.
2690
+ // 2. Start one ordered onboarding bridge. A fresh device logs in only after consent.
2899
2691
  console.log("");
2900
2692
  if (!flags["skip-login"] && !flags["no-login"] && !await cmdOnboarding(flags)) {
2901
2693
  console.log("\nEchoMem is configured, but onboarding did not finish. Run `echomem-mcp init` again when you are ready.");
@@ -2903,8 +2695,8 @@ async function cmdInit(flags) {
2903
2695
  }
2904
2696
  console.log("");
2905
2697
  console.log("🎉 EchoMem is ready.");
2906
- console.log(" • MCP memory is configured for every coding agent installed on this machine.");
2907
- console.log(" • Echo Desktop shows connection status and manages this device credential.");
2698
+ console.log(" • MCP memory is configured for the coding agents that connected successfully.");
2699
+ console.log(" • Use `echomem-mcp status` to inspect this device, and `login`, `unlock`, or `update` to manage it.");
2908
2700
  console.log(' • Try it now: ask your agent — "search my EchoMem for what I\'ve been working on and recap it."');
2909
2701
  }
2910
2702
  /**
@@ -2918,7 +2710,7 @@ function writeMemoryGuidanceForTargets(targets) {
2918
2710
  if (t.id === "codex" && t.kind === "command")
2919
2711
  files.set(path.join(t.detectDir, "AGENTS.md"), "Codex");
2920
2712
  if (t.id === "claude-code" || t.id === "claude-desktop")
2921
- files.set(home(".claude", "CLAUDE.md"), "Claude");
2713
+ files.set(path.join(claudeConfigHome(), "CLAUDE.md"), "Claude");
2922
2714
  }
2923
2715
  for (const [file, label] of files) {
2924
2716
  try {
@@ -2953,22 +2745,28 @@ function writeCodexSkillsForTargets(targets) {
2953
2745
  }
2954
2746
  }
2955
2747
  function writeLifecycleHooksForTargets(targets) {
2956
- const clients = new Set();
2748
+ const clients = [];
2957
2749
  if (targets.some((target) => target.id === "codex"))
2958
- clients.add("codex");
2750
+ clients.push("codex");
2959
2751
  if (targets.some((target) => target.id === "claude-code"))
2960
- clients.add("claude-code");
2961
- if (clients.size === 0) {
2752
+ clients.push("claude-code");
2753
+ if (clients.length === 0) {
2962
2754
  console.log("ℹ️ No hook-capable Codex or Claude Code client was detected; private-save checkpoint hooks were not installed.");
2963
2755
  return;
2964
2756
  }
2965
- const mode = clients.size === 2 ? "both" : [...clients][0];
2966
- const sourcePaths = installSourceSessionHooks(mode);
2967
- const savePaths = installSaveCheckpointHooks(mode);
2968
- const paths = [...new Set([...sourcePaths, ...savePaths])];
2969
- console.log(`✅ Installed EchoMem source-session and private-save hooks:\n${paths.map((p) => ` - ${p}`).join("\n")}`);
2970
- if (clients.has("codex")) {
2971
- console.log(" Codex: start a new session and run /hooks once to review and trust the hook.");
2757
+ for (const client of clients) {
2758
+ try {
2759
+ const sourcePaths = installSourceSessionHooks(client);
2760
+ const savePaths = installSaveCheckpointHooks(client);
2761
+ const paths = [...new Set([...sourcePaths, ...savePaths])];
2762
+ console.log(`✅ Installed EchoMem source-session and private-save hooks for ${client}:\n${paths.map((p) => ` - ${p}`).join("\n")}`);
2763
+ if (client === "codex") {
2764
+ console.log(" Codex: start a new session and run /hooks once to review and trust the hook.");
2765
+ }
2766
+ }
2767
+ catch (error) {
2768
+ console.log(`ℹ️ Could not install EchoMem hooks for ${client}: ${error instanceof Error ? error.message : String(error)}`);
2769
+ }
2972
2770
  }
2973
2771
  }
2974
2772
  async function cmdUpdate(flags) {
@@ -2984,7 +2782,7 @@ function selectSetupTargets(requested, all) {
2984
2782
  return fs.existsSync(path.dirname(client.configPath));
2985
2783
  if (client.kind === "command")
2986
2784
  return fs.existsSync(client.detectDir);
2987
- return client.id === "claude-code" && fs.existsSync(home(".claude"));
2785
+ return client.id === "claude-code" && claudeCodeCliAvailable();
2988
2786
  });
2989
2787
  }
2990
2788
  return requested ? knownClients().filter((client) => client.id === requested) : detectClients();
@@ -3020,7 +2818,7 @@ async function cmdLogin(flags) {
3020
2818
  return true;
3021
2819
  }
3022
2820
  // Browser path: this bridge does only account/device authentication. It intentionally exposes
3023
- // no local-history routes; `init` owns scan consent, reporting, and optional extraction.
2821
+ // no local-history routes; `init` owns local-history consent and optional extraction.
3024
2822
  console.log("Opening your browser to connect this device locally…");
3025
2823
  const { port, nonce } = localBridgeOptions(flags);
3026
2824
  const srv = await startCallbackServer({ port, nonce, flow: "login" });
@@ -3048,7 +2846,7 @@ async function cmdLogin(flags) {
3048
2846
  }
3049
2847
  /**
3050
2848
  * The local-history onboarding flow. Existing device credentials are reused when available; a
3051
- * fresh device stays in this same bridge and asks for login only after permission and report.
2849
+ * fresh device stays in this same bridge and asks for login only after permission.
3052
2850
  */
3053
2851
  async function cmdOnboarding(flags) {
3054
2852
  const store = new KeyStore();
@@ -3060,149 +2858,17 @@ async function cmdOnboarding(flags) {
3060
2858
  console.log("Opening your browser for EchoMem onboarding…");
3061
2859
  const { port, nonce } = localBridgeOptions(flags);
3062
2860
  let stats = null;
3063
- let forensicReport = null;
3064
- let forensicConsent = "pending";
3065
- let forensicScanStarted = false;
3066
- const forensicStartedAt = Date.now();
3067
- let forensicStageStartedAt = forensicStartedAt;
3068
- let forensicStage = "starting";
3069
- let forensicOverall = 0;
3070
- let forensicProgress = {
3071
- status: "running",
3072
- scanned: 0,
3073
- total: 0,
3074
- stage: forensicStage,
3075
- label: forensicStageLabel(forensicStage),
3076
- stageDone: 0,
3077
- stageTotal: 0,
3078
- overall: 0,
3079
- elapsedMs: 0,
3080
- stageElapsedMs: 0,
3081
- updatedAt: forensicStartedAt,
3082
- };
3083
2861
  const srv = await startCallbackServer({
3084
2862
  port,
3085
2863
  nonce,
3086
2864
  flow: "onboarding",
3087
2865
  initialToken,
3088
- requireReportConsent: true,
3089
- getStats: () => stats,
3090
- getReport: () => forensicReport,
3091
- getReportProgress: () => {
3092
- if (forensicProgress.status !== "running")
3093
- return forensicProgress;
3094
- const now = Date.now();
3095
- const sinceWorkerUpdate = Math.max(0, now - forensicProgress.updatedAt);
3096
- return {
3097
- ...forensicProgress,
3098
- elapsedMs: forensicProgress.elapsedMs + sinceWorkerUpdate,
3099
- stageElapsedMs: forensicProgress.stageElapsedMs + sinceWorkerUpdate,
3100
- updatedAt: now,
3101
- };
3102
- },
3103
- onReportConsent: (allowed) => {
3104
- if (!allowed) {
3105
- forensicConsent = "declined";
3106
- forensicProgress = {
3107
- status: "failed",
3108
- scanned: 0,
3109
- total: 0,
3110
- stage: "failed",
3111
- label: "Local scan skipped",
3112
- stageDone: 0,
3113
- stageTotal: 0,
3114
- overall: 0,
3115
- elapsedMs: Date.now() - forensicStartedAt,
3116
- stageElapsedMs: Date.now() - forensicStageStartedAt,
3117
- updatedAt: Date.now(),
3118
- error: {
3119
- code: "REPORT_SCAN_DECLINED",
3120
- message: "Local file analysis was skipped. EchoMem can still connect, save conversations, and import memories.",
3121
- },
3122
- };
3123
- return;
3124
- }
3125
- forensicConsent = "allowed";
3126
- const now = Date.now();
3127
- forensicStage = "starting";
3128
- forensicStageStartedAt = now;
3129
- forensicOverall = 0;
3130
- forensicProgress = {
3131
- status: "running",
3132
- scanned: 0,
3133
- total: 0,
3134
- stage: forensicStage,
3135
- label: forensicStageLabel(forensicStage),
3136
- stageDone: 0,
3137
- stageTotal: 0,
3138
- overall: 0,
3139
- elapsedMs: now - forensicStartedAt,
3140
- stageElapsedMs: 0,
3141
- updatedAt: now,
3142
- };
3143
- startForensicScan();
3144
- },
2866
+ requireLocalHistoryConsent: true,
3145
2867
  });
3146
2868
  const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
3147
2869
  openBrowser(localSetupUrl);
3148
2870
  console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
3149
2871
  console.log("Waiting for local-history onboarding for up to 15 minutes…");
3150
- const startForensicScan = () => {
3151
- if (forensicScanStarted || forensicConsent !== "allowed")
3152
- return;
3153
- forensicScanStarted = true;
3154
- // Build the local forensic "Context Doctor" report off-thread only after explicit consent.
3155
- buildForensicReportOffThread((progress) => {
3156
- const now = Date.now();
3157
- const nextStage = progress.stage || forensicStage;
3158
- if (nextStage !== forensicStage) {
3159
- forensicStage = nextStage;
3160
- forensicStageStartedAt = now;
3161
- console.log(`Local scan: ${forensicStageLabel(forensicStage)}…`);
3162
- }
3163
- // Latched, so a caller that ever reports a smaller fraction cannot walk the bar backwards.
3164
- forensicOverall = Math.max(forensicOverall, typeof progress.overall === "number" && Number.isFinite(progress.overall) ? progress.overall : 0);
3165
- forensicProgress = {
3166
- status: "running",
3167
- scanned: progress.done,
3168
- total: progress.total,
3169
- stage: forensicStage,
3170
- label: forensicStageLabel(forensicStage),
3171
- detail: progress.detail,
3172
- stageDone: typeof progress.stageDone === "number" && Number.isFinite(progress.stageDone)
3173
- ? Math.max(0, Math.floor(progress.stageDone))
3174
- : 0,
3175
- stageTotal: typeof progress.stageTotal === "number" && Number.isFinite(progress.stageTotal)
3176
- ? Math.max(0, Math.floor(progress.stageTotal))
3177
- : 0,
3178
- overall: forensicOverall,
3179
- elapsedMs: now - forensicStartedAt,
3180
- stageElapsedMs: now - forensicStageStartedAt,
3181
- updatedAt: now,
3182
- };
3183
- })
3184
- .then((r) => {
3185
- forensicReport = r;
3186
- })
3187
- .catch((e) => {
3188
- const now = Date.now();
3189
- forensicProgress = {
3190
- status: "failed",
3191
- scanned: forensicProgress.scanned,
3192
- total: forensicProgress.total,
3193
- stage: "failed",
3194
- label: "Local scan failed",
3195
- stageDone: forensicProgress.stageDone,
3196
- stageTotal: forensicProgress.stageTotal,
3197
- overall: forensicOverall,
3198
- elapsedMs: now - forensicStartedAt,
3199
- stageElapsedMs: now - forensicStageStartedAt,
3200
- updatedAt: now,
3201
- error: safeForensicError(e),
3202
- };
3203
- console.error(`Could not build the local report: ${e instanceof Error ? e.message : String(e)}`);
3204
- });
3205
- };
3206
2872
  let token;
3207
2873
  let key;
3208
2874
  try {
@@ -3292,9 +2958,8 @@ async function cmdOnboarding(flags) {
3292
2958
  codex: quick.codexCount,
3293
2959
  claudeCode: quick.claudeCount,
3294
2960
  };
3295
- stats = await buildStatsPayload([], {
2961
+ stats = buildOnboardingStatsPayload({
3296
2962
  partial: true,
3297
- skipMemoryCount: true,
3298
2963
  sessions: sessionSummary,
3299
2964
  migratable,
3300
2965
  discovery: { phase: "quick", exact: false },
@@ -3328,9 +2993,8 @@ async function cmdOnboarding(flags) {
3328
2993
  codex: cloudSummary.codexCount,
3329
2994
  claudeCode: cloudSummary.claudeCount,
3330
2995
  };
3331
- const cloudPayload = await buildStatsPayload([], {
2996
+ const cloudPayload = buildOnboardingStatsPayload({
3332
2997
  partial: true,
3333
- skipMemoryCount: true,
3334
2998
  sessions: sessionSummary,
3335
2999
  migratable,
3336
3000
  discovery: { phase: "account", exact: false },
@@ -3365,9 +3029,8 @@ async function cmdOnboarding(flags) {
3365
3029
  codex: unavailableSummary.codexCount,
3366
3030
  claudeCode: unavailableSummary.claudeCount,
3367
3031
  };
3368
- const unavailablePayload = await buildStatsPayload([], {
3032
+ const unavailablePayload = buildOnboardingStatsPayload({
3369
3033
  partial: true,
3370
- skipMemoryCount: true,
3371
3034
  sessions: sessionSummary,
3372
3035
  migratable,
3373
3036
  discovery: { phase: "account", exact: false },
@@ -3403,9 +3066,8 @@ async function cmdOnboarding(flags) {
3403
3066
  migratable = migratableFromDiscovery(initialExact);
3404
3067
  latestPendingEstimate = migratable.pending;
3405
3068
  sessionSummary = sessionsFromDiscovery(initialExact);
3406
- const partialPayload = withCandidateSessions(await buildStatsPayload([], {
3069
+ const partialPayload = withCandidateSessions(buildOnboardingStatsPayload({
3407
3070
  partial: true,
3408
- skipMemoryCount: true,
3409
3071
  sessions: sessionSummary,
3410
3072
  migratable,
3411
3073
  discovery: { phase: "exact", exact: true },
@@ -3453,12 +3115,10 @@ async function cmdOnboarding(flags) {
3453
3115
  migratable = migratableFromDiscovery(reconciled);
3454
3116
  latestPendingEstimate = migratable.pending;
3455
3117
  sessionSummary = sessionsFromDiscovery(reconciled);
3456
- const reconciledPayload = withCandidateSessions(await buildStatsPayload([], {
3457
- partial: true,
3458
- skipMemoryCount: true,
3118
+ const reconciledPayload = withCandidateSessions(buildOnboardingStatsPayload({
3459
3119
  sessions: sessionSummary,
3460
3120
  migratable,
3461
- discovery: { phase: "exact", exact: true },
3121
+ discovery: { phase: "full", exact: true },
3462
3122
  }), reconciled);
3463
3123
  if (generation !== refreshGeneration)
3464
3124
  return;
@@ -3473,20 +3133,11 @@ async function cmdOnboarding(flags) {
3473
3133
  failed: 0,
3474
3134
  extracted: 0,
3475
3135
  });
3476
- const fullPayload = withCandidateSessions(await buildCollectedStatsPayloadOffThread({
3477
- sessions: sessionSummary,
3478
- migratable,
3479
- discovery: { phase: "full", exact: true },
3480
- }), reconciled);
3481
- if (generation !== refreshGeneration)
3482
- return;
3483
- stats = fullPayload;
3484
- srv.setStats(fullPayload);
3485
3136
  })().catch((e) => {
3486
3137
  if (generation !== refreshGeneration)
3487
3138
  return;
3488
- console.error(`[echomem] optional full local-history stats unavailable; continuing (${errorCode(e) || "FULL_STATS_FAILED"})`);
3489
- publishOptionalStatsFallback(generation, "FULL_STATS_FAILED", true);
3139
+ console.error(`[echomem] optional account reconciliation unavailable; continuing (${errorCode(e) || "ACCOUNT_RECONCILIATION_FAILED"})`);
3140
+ publishOptionalStatsFallback(generation, "ACCOUNT_RECONCILIATION_FAILED", true);
3490
3141
  });
3491
3142
  return initialExact;
3492
3143
  }).catch((e) => {
@@ -3946,7 +3597,7 @@ Usage:
3946
3597
  echomem-mcp Run the MCP server (stdio; default — used by your editor)
3947
3598
  echomem-mcp setup [--client X] Detect editor, write its MCP config, then connect this device
3948
3599
  echomem-mcp setup --skip-login Write MCP config without opening login/browser
3949
- echomem-mcp setup --force-headless Explicitly replace valid Echo Desktop-managed entries
3600
+ echomem-mcp setup --force-headless Explicitly replace a valid externally managed entry
3950
3601
  echomem-mcp setup --no-codex-skills Skip installing the bundled EchoMem Codex skills
3951
3602
  echomem-mcp update --all Install this bridge durably + repoint detected clients; no login/browser
3952
3603
  echomem-mcp update --client X Repoint one MCP client; no login/browser
@@ -3957,7 +3608,6 @@ Usage:
3957
3608
  echomem-mcp status Show token/key/clients
3958
3609
  echomem-mcp doctor [--no-network] Diagnose configured client bridge versions
3959
3610
  echomem-mcp logout Remove stored credentials
3960
- echomem-mcp report [--json] Your AI coding memory audit (local, no login, $0)
3961
3611
  echomem-mcp migrate [--since D] Import your existing Codex/Claude history into your memory
3962
3612
  echomem-mcp migrate --estimate Estimate migration size/time metadata without uploading transcripts
3963
3613
  echomem-mcp migrate --max-chars N Import only sessions up to N assembled text chars
@@ -4009,9 +3659,6 @@ export async function runCli(argv) {
4009
3659
  case "logout":
4010
3660
  cmdLogout();
4011
3661
  return true;
4012
- case "report":
4013
- await runReport(flags);
4014
- return true;
4015
3662
  case "migrate":
4016
3663
  await cmdMigrate(flags);
4017
3664
  return true;