@echomem/mcp 1.4.44 → 1.4.45

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 (50) hide show
  1. package/README.md +28 -25
  2. package/dist/city/README.md +9 -0
  3. package/dist/city/echo-ai-city-only.html +2232 -0
  4. package/dist/city/echo-extraction-plate.html +330 -0
  5. package/dist/city/echo-face-cutout.png +0 -0
  6. package/dist/city/personality_stickers/bossy.png +0 -0
  7. package/dist/city/personality_stickers/ghosty.png +0 -0
  8. package/dist/city/personality_stickers/loopy.png +0 -0
  9. package/dist/city/personality_stickers/lusty.png +0 -0
  10. package/dist/city/personality_stickers/maxxy.png +0 -0
  11. package/dist/city/personality_stickers/tabby.png +0 -0
  12. package/dist/city/vendor/OrbitControls.js +1417 -0
  13. package/dist/city/vendor/RoundedBoxGeometry.js +155 -0
  14. package/dist/city/vendor/echo_general-file-21.riv +0 -0
  15. package/dist/city/vendor/rive.js +8139 -0
  16. package/dist/city/vendor/rive.wasm +0 -0
  17. package/dist/city/vendor/three.module.min.js +6 -0
  18. package/dist/context-analysis/claude-native-canonical.js +2 -2
  19. package/dist/context-analysis/vendored-canonical.js +2 -2
  20. package/dist/context-analysis/workspace-report.js +3 -3
  21. package/dist/forensics.js +1531 -0
  22. package/dist/hud/hooks.js +31 -43
  23. package/dist/index.js +79 -15
  24. package/dist/local-data-paths.js +38 -0
  25. package/dist/migrate.js +140 -70
  26. package/dist/report.js +721 -0
  27. package/dist/save-checkpoint-hook.js +1 -1
  28. package/dist/setup-page/client-core.js +372 -18
  29. package/dist/setup-page/client-extraction.js +204 -37
  30. package/dist/setup-page/client-lifecycle.js +101 -31
  31. package/dist/setup-page/client-report-audit.js +819 -0
  32. package/dist/setup-page/client-report-city.js +356 -0
  33. package/dist/setup-page/client-report.js +6 -0
  34. package/dist/setup-page/client.js +2 -0
  35. package/dist/setup-page/styles-city-report.js +880 -0
  36. package/dist/setup-page/styles-context-audit.js +470 -0
  37. package/dist/setup-page/styles-extraction.js +31 -1
  38. package/dist/setup-page/styles-foundation.js +89 -0
  39. package/dist/setup-page/styles-mvp.js +155 -10
  40. package/dist/setup-page/styles-website-alignment.js +204 -0
  41. package/dist/setup-page/styles.js +4 -0
  42. package/dist/setup-page.js +4 -4
  43. package/dist/setup-preview.js +212 -4
  44. package/dist/setup.js +702 -321
  45. package/dist/source-session.js +3 -9
  46. package/dist/v1-contract.js +8 -0
  47. package/package.json +7 -9
  48. package/dist/config-files.js +0 -63
  49. package/dist/local-jsonl.js +0 -87
  50. package/dist/onboarding-stats.js +0 -16
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, login, plan if needed, then extraction.
6
+ * - `init` runs one ordered flow: local-history permission/report, 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, spawnSync, } from "node:child_process";
18
+ import { execFileSync, spawn } 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 { buildOnboardingStatsPayload } from "./onboarding-stats.js";
28
+ import { runReport, buildStatsPayload } from "./report.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
34
  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,25 +70,15 @@ const CODEX_SKILL_NAMES = [
70
70
  function home(...p) {
71
71
  return path.join(os.homedir(), ...p);
72
72
  }
73
- function configuredProfileDirectory(envKey, fallbackName) {
74
- const configured = process.env[envKey]?.trim();
73
+ function codexHome() {
74
+ const configured = process.env.CODEX_HOME?.trim();
75
75
  if (!configured)
76
- return home(fallbackName);
76
+ return home(".codex");
77
77
  if (configured === "~")
78
78
  return os.homedir();
79
- if (configured.startsWith("~/") || configured.startsWith("~\\")) {
79
+ if (configured.startsWith(`~${path.sep}`))
80
80
  return path.join(os.homedir(), configured.slice(2));
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");
81
+ return path.resolve(configured);
92
82
  }
93
83
  function filesEqual(left, right) {
94
84
  try {
@@ -237,7 +227,7 @@ export function detectClients() {
237
227
  if (c.kind === "command")
238
228
  return fs.existsSync(c.detectDir);
239
229
  if (c.id === "claude-code")
240
- return claudeCodeCliAvailable();
230
+ return fs.existsSync(home(".claude"));
241
231
  return false;
242
232
  });
243
233
  }
@@ -386,11 +376,11 @@ export function writeCodexConfig(configPath, entry, options = {}) {
386
376
  if (lines.slice(start, end).join("\n").trimEnd() === block)
387
377
  return "exists"; // already correct
388
378
  const next = [...lines.slice(0, start), ...block.split("\n"), ...lines.slice(end)];
389
- atomicWriteTextFile(configPath, next.join("\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, ""));
379
+ fs.writeFileSync(configPath, next.join("\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, ""));
390
380
  return "wrote"; // replaced a stale entry → caller tells the user to restart Codex
391
381
  }
392
382
  const sep = content ? (content.endsWith("\n") ? "\n" : "\n\n") : "";
393
- atomicWriteTextFile(configPath, content + sep + block + "\n");
383
+ fs.appendFileSync(configPath, sep + block + "\n");
394
384
  return "wrote";
395
385
  }
396
386
  /**
@@ -411,7 +401,7 @@ function echomemGuidanceBlock() {
411
401
  "- 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.",
412
402
  "- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
413
403
  '- 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.',
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.",
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.",
415
405
  '- 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.',
416
406
  "- 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.",
417
407
  "- 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.",
@@ -422,7 +412,7 @@ function echomemGuidanceBlock() {
422
412
  "- 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.",
423
413
  "- 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.",
424
414
  "- 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.",
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.",
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.",
426
416
  "- 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.",
427
417
  "- 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.",
428
418
  "- 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.",
@@ -447,56 +437,41 @@ export function writeAgentsMemoryGuidance(filePath) {
447
437
  const current = content.slice(start, end + AGENTS_MD_END.length);
448
438
  if (current === block)
449
439
  return "exists";
450
- atomicWriteTextFile(filePath, content.slice(0, start) + block + content.slice(end + AGENTS_MD_END.length));
440
+ fs.writeFileSync(filePath, content.slice(0, start) + block + content.slice(end + AGENTS_MD_END.length));
451
441
  return "updated";
452
442
  }
453
443
  const sep = content ? (content.endsWith("\n") ? "\n" : "\n\n") : "";
454
- atomicWriteTextFile(filePath, content + sep + block + "\n");
444
+ fs.appendFileSync(filePath, sep + block + "\n");
455
445
  return "wrote";
456
446
  }
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;
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 = {};
464
450
  try {
465
- candidates = [
466
- path.join(codexHome(), "AGENTS.md"),
467
- path.join(claudeConfigHome(), "CLAUDE.md"),
468
- ];
451
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
469
452
  }
470
453
  catch {
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
- }
454
+ /* fresh config */
484
455
  }
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)) {
456
+ config.mcpServers = config.mcpServers || {};
457
+ if (!options.forceHeadless && validDesktopManagedEntry(config.mcpServers.echomem)) {
492
458
  return "desktop-managed";
493
459
  }
494
- servers.echomem = entry;
495
- atomicWriteJsonObject(configPath, config);
460
+ config.mcpServers.echomem = entry;
461
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
462
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
496
463
  return "wrote";
497
464
  }
498
465
  function readClaudeCodeConfigFile(configPath) {
499
- return readJsonObjectFile(configPath, "Claude Code user configuration");
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
+ }
500
475
  }
501
476
  function echoMemEntryFromServers(value) {
502
477
  return objectRecord(objectRecord(value)?.echomem);
@@ -530,78 +505,11 @@ function claudeCodeLocalEchoMemProjects(configPath) {
530
505
  .map(([projectPath]) => projectPath)
531
506
  .sort();
532
507
  }
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
- }
599
508
  export function writeClaudeCodeConfig(entry, options = {}) {
600
509
  // EchoMem belongs at user scope so every Claude Code project resolves the same durable runtime.
601
510
  // Older CLI versions wrote local/project entries, which take precedence over user scope and can
602
511
  // keep launching a deleted npm cache or stale runtime. Migrate those only after user scope is safe.
603
512
  const configPath = options.configPath ?? home(".claude.json");
604
- let failureReason;
605
513
  const emptyResult = () => ({
606
514
  state: "unavailable",
607
515
  removedLocalProjects: [],
@@ -609,21 +517,18 @@ export function writeClaudeCodeConfig(entry, options = {}) {
609
517
  failedLocalProjects: [],
610
518
  restoredPreviousUserEntry: false,
611
519
  preservedDesktopManaged: false,
612
- failureReason,
613
520
  });
614
521
  const runClaude = (args, cwd) => {
615
522
  try {
616
- execClaudeCodeSync(args, {
523
+ execFileSync("claude", args, {
617
524
  cwd,
618
525
  encoding: "utf8",
619
526
  stdio: ["ignore", "pipe", "pipe"],
620
527
  timeout: 10000,
621
528
  });
622
- failureReason = undefined;
623
529
  return true;
624
530
  }
625
- catch (error) {
626
- failureReason = commandFailureMessage(error);
531
+ catch {
627
532
  return false;
628
533
  }
629
534
  };
@@ -642,10 +547,8 @@ export function writeClaudeCodeConfig(entry, options = {}) {
642
547
  if (previousUserEntry && !removeUser())
643
548
  return emptyResult();
644
549
  if (!addUser(desiredUserEntry)) {
645
- const addFailureReason = failureReason;
646
550
  if (previousUserEntry)
647
551
  restoredPreviousUserEntry = addUser(previousUserEntry);
648
- failureReason = addFailureReason;
649
552
  return { ...emptyResult(), restoredPreviousUserEntry };
650
553
  }
651
554
  }
@@ -859,10 +762,10 @@ function inspectClientConfig(client, desiredVersion) {
859
762
  };
860
763
  }
861
764
  function inspectClaudeCodeConfig(client, desiredVersion) {
862
- if (!fs.existsSync(claudeConfigHome()) && !claudeCodeCliAvailable())
765
+ if (!fs.existsSync(home(".claude")))
863
766
  return null;
864
767
  try {
865
- const output = execClaudeCodeSync(["mcp", "list"], {
768
+ const output = execFileSync("claude", ["mcp", "list"], {
866
769
  encoding: "utf8",
867
770
  stdio: ["ignore", "pipe", "ignore"],
868
771
  timeout: 3000,
@@ -942,23 +845,6 @@ function openBrowser(url) {
942
845
  }
943
846
  }
944
847
  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
- }
962
848
  if (process.platform !== "darwin") {
963
849
  return { ok: false, message: "Could not auto-open Claude on this system. The prompt is copied - open Claude Desktop and paste it." };
964
850
  }
@@ -990,11 +876,12 @@ function openClaudeDesktop() {
990
876
  };
991
877
  }
992
878
  const LOCAL_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
879
+ const LOCAL_COWORK_SESSION_ID_RE = /^local_[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
993
880
  // `claude --resume <id>` only finds sessions that belong to the current project directory, so
994
881
  // recover the session's original cwd from its transcript before resuming.
995
882
  function claudeSessionCwd(sessionId) {
996
883
  try {
997
- const projectsDir = path.join(claudeConfigHome(), "projects");
884
+ const projectsDir = path.join(os.homedir(), ".claude", "projects");
998
885
  for (const dir of fs.readdirSync(projectsDir)) {
999
886
  const file = path.join(projectsDir, dir, `${sessionId}.jsonl`);
1000
887
  if (!fs.existsSync(file))
@@ -1020,40 +907,20 @@ function shellQuote(value) {
1020
907
  return `'${value.replace(/'/g, `'\\''`)}'`;
1021
908
  }
1022
909
  function openExistingAgentSession(source, sessionId) {
1023
- if (!LOCAL_SESSION_ID_RE.test(sessionId))
910
+ const validSessionId = source === "claude-desktop"
911
+ ? LOCAL_COWORK_SESSION_ID_RE.test(sessionId)
912
+ : LOCAL_SESSION_ID_RE.test(sessionId);
913
+ if (!validSessionId)
1024
914
  return { ok: false, message: "The local session identifier is invalid." };
915
+ if (source !== "claude-desktop" && process.platform !== "darwin") {
916
+ return { ok: false, message: "Opening local Codex and Claude Code sessions is currently available on macOS." };
917
+ }
1025
918
  try {
1026
919
  if (source === "codex") {
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
- }
920
+ execFileSync("open", [`codex://threads/${sessionId}`], { stdio: "pipe" });
1041
921
  return { ok: true, message: "Opened the original session in Codex." };
1042
922
  }
1043
923
  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
- }
1057
924
  const claude = firstExisting(["/opt/homebrew/bin/claude", "/usr/local/bin/claude"]) || "claude";
1058
925
  const cwd = claudeSessionCwd(sessionId);
1059
926
  const resume = `${claude} --resume ${sessionId}`;
@@ -1064,6 +931,23 @@ function openExistingAgentSession(source, sessionId) {
1064
931
  ], { stdio: "pipe" });
1065
932
  return { ok: true, message: "Opened the original Claude Code session in Terminal." };
1066
933
  }
934
+ if (source === "claude-desktop") {
935
+ const url = `claude://claude.ai/claude-code-desktop/${sessionId}`;
936
+ if (process.platform === "win32") {
937
+ spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `start "" "${url}"`], {
938
+ detached: true,
939
+ stdio: "ignore",
940
+ windowsHide: true,
941
+ }).unref();
942
+ }
943
+ else if (process.platform === "darwin") {
944
+ execFileSync("open", [url], { stdio: "pipe" });
945
+ }
946
+ else {
947
+ return { ok: false, message: "Opening local Cowork sessions is not supported on this system yet." };
948
+ }
949
+ return { ok: true, message: "Opened the original Cowork session in Claude Desktop." };
950
+ }
1067
951
  return { ok: false, message: "Unsupported agent session source." };
1068
952
  }
1069
953
  catch (error) {
@@ -1104,7 +988,8 @@ function migratableFromDiscovery(disc) {
1104
988
  pending: disc.pending.length,
1105
989
  pendingTotal: disc.pendingTotal,
1106
990
  pendingCodex,
1107
- pendingClaudeCode: disc.pendingClaudeCode ?? disc.pending.length - pendingCodex,
991
+ pendingClaudeCode: disc.pendingClaudeCode ?? disc.pending.filter((s) => s.source === "claude-code").length,
992
+ pendingCowork: disc.pendingCowork ?? disc.pending.filter((s) => s.source === "claude-desktop").length,
1108
993
  alreadyMigrated: disc.alreadyMigrated,
1109
994
  skippedActive: disc.skippedActive,
1110
995
  limited: disc.limited,
@@ -1123,7 +1008,8 @@ function sessionsFromDiscovery(disc) {
1123
1008
  return {
1124
1009
  total: disc.sessions.length,
1125
1010
  codex: disc.codexCount,
1126
- claudeCode: disc.claudeCount,
1011
+ claudeCode: disc.claudeCodeCount,
1012
+ cowork: disc.coworkCount,
1127
1013
  };
1128
1014
  }
1129
1015
  function migratableFromFastSummary(summary) {
@@ -1132,6 +1018,7 @@ function migratableFromFastSummary(summary) {
1132
1018
  pendingTotal: summary.pendingTotal,
1133
1019
  pendingCodex: summary.pendingCodex,
1134
1020
  pendingClaudeCode: summary.pendingClaudeCode,
1021
+ pendingCowork: summary.pendingCowork,
1135
1022
  alreadyMigrated: summary.alreadyMigrated,
1136
1023
  skippedActive: summary.skippedActive,
1137
1024
  eta: summary.eta,
@@ -1224,7 +1111,7 @@ function withTimeout(promise, ms, code, onTimeout) {
1224
1111
  function delay(ms) {
1225
1112
  return new Promise((resolve) => setTimeout(resolve, ms));
1226
1113
  }
1227
- const LOCAL_ASSET_TYPES = {
1114
+ const CITY_ASSET_TYPES = {
1228
1115
  ".html": "text/html; charset=utf-8",
1229
1116
  ".js": "text/javascript; charset=utf-8",
1230
1117
  ".json": "application/json; charset=utf-8",
@@ -1233,11 +1120,39 @@ const LOCAL_ASSET_TYPES = {
1233
1120
  ".png": "image/png",
1234
1121
  ".svg": "image/svg+xml",
1235
1122
  };
1236
- function repoLabel(cwd) {
1237
- if (!cwd)
1238
- return "";
1239
- const normalized = cwd.replace(/[\\/]+$/, "");
1240
- return path.basename(normalized) || normalized;
1123
+ function repoCityArtifactsRoot() {
1124
+ // Monorepo/dev: read the city assets live from repo-root /artifacts.
1125
+ const repo = fileURLToPath(new URL("../../../artifacts/", import.meta.url));
1126
+ if (fs.existsSync(repo))
1127
+ return repo;
1128
+ // Published install: fall back to the copy bundled into dist/city by prepack (bundle-city.mjs).
1129
+ return fileURLToPath(new URL("./city/", import.meta.url));
1130
+ }
1131
+ function serveRepoCityAsset(reqPath, res) {
1132
+ const root = repoCityArtifactsRoot();
1133
+ const rel = reqPath === "/city" || reqPath === "/city/" ? "echo-ai-city-only.html" : decodeURIComponent(reqPath.slice("/city/".length));
1134
+ // Archives stay in the checkout for recovery, but must never become a localhost UI surface.
1135
+ const normalizedRel = rel.replace(/\\/g, "/");
1136
+ if (normalizedRel === "archive" || normalizedRel.startsWith("archive/")) {
1137
+ res.writeHead(404).end("not found");
1138
+ return true;
1139
+ }
1140
+ const filePath = path.resolve(root, rel);
1141
+ const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
1142
+ if (!filePath.startsWith(rootWithSep)) {
1143
+ res.writeHead(403).end("forbidden");
1144
+ return true;
1145
+ }
1146
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
1147
+ res.writeHead(404).end("not found");
1148
+ return true;
1149
+ }
1150
+ res.writeHead(200, {
1151
+ "Content-Type": CITY_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
1152
+ "Cache-Control": "no-store",
1153
+ });
1154
+ fs.createReadStream(filePath).pipe(res);
1155
+ return true;
1241
1156
  }
1242
1157
  function hudAssetsRoot() {
1243
1158
  return fileURLToPath(new URL("../assets/hud/", import.meta.url));
@@ -1256,7 +1171,7 @@ function serveHudAsset(reqPath, res) {
1256
1171
  return true;
1257
1172
  }
1258
1173
  res.writeHead(200, {
1259
- "Content-Type": LOCAL_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
1174
+ "Content-Type": CITY_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
1260
1175
  "Cache-Control": "no-store",
1261
1176
  });
1262
1177
  fs.createReadStream(filePath).pipe(res);
@@ -1433,6 +1348,61 @@ export function discoverMigratableFastOffThread(opts = {}) {
1433
1348
  });
1434
1349
  });
1435
1350
  }
1351
+ /** Build the full local-history dashboard payload away from the callback server's event loop.
1352
+ * `collect()` can synchronously parse hundreds of JSONL files for tens of seconds; doing that on
1353
+ * the bridge thread prevents even localhost actions such as account switch from receiving a reply. */
1354
+ export function buildCollectedStatsPayloadOffThread(inject) {
1355
+ const reportUrl = runtimeModuleUrl("report");
1356
+ const serializedInject = JSON.stringify(inject);
1357
+ const code = `
1358
+ import { parentPort } from "node:worker_threads";
1359
+ import { collect, buildStatsPayload } from ${JSON.stringify(reportUrl)};
1360
+
1361
+ try {
1362
+ const payload = await buildStatsPayload(collect(), ${serializedInject});
1363
+ parentPort?.postMessage({ ok: true, payload });
1364
+ } catch (error) {
1365
+ parentPort?.postMessage({
1366
+ ok: false,
1367
+ message: error instanceof Error ? error.message : String(error),
1368
+ stack: error instanceof Error ? error.stack : undefined,
1369
+ });
1370
+ }
1371
+ `;
1372
+ const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
1373
+ return new Promise((resolve, reject) => {
1374
+ let settled = false;
1375
+ const finish = (result) => {
1376
+ if (settled)
1377
+ return;
1378
+ settled = true;
1379
+ void worker.terminate();
1380
+ if (result.ok)
1381
+ resolve(result.payload);
1382
+ else
1383
+ reject(result.error);
1384
+ };
1385
+ worker.once("message", (message) => {
1386
+ const msg = message;
1387
+ if (msg.ok === true) {
1388
+ finish({ ok: true, payload: msg.payload });
1389
+ return;
1390
+ }
1391
+ const error = new Error(typeof msg.message === "string" ? msg.message : "Full local stats worker failed");
1392
+ if (typeof msg.stack === "string")
1393
+ error.stack = msg.stack;
1394
+ finish({ ok: false, error });
1395
+ });
1396
+ worker.once("error", (error) => {
1397
+ finish({ ok: false, error });
1398
+ });
1399
+ worker.once("exit", (code) => {
1400
+ if (settled)
1401
+ return;
1402
+ finish({ ok: false, error: new Error(`Full local stats worker exited (code ${code}) without a result`) });
1403
+ });
1404
+ });
1405
+ }
1436
1406
  export function createLocalDiscoveryCache(loaders = {}) {
1437
1407
  const loadQuick = loaders.loadQuick ?? (() => discoverMigratableFastOffThread());
1438
1408
  const loadExact = loaders.loadExact ?? (() => discoverMigratableSessionsOffThread());
@@ -1481,6 +1451,45 @@ export function createLocalDiscoveryCache(loaders = {}) {
1481
1451
  },
1482
1452
  };
1483
1453
  }
1454
+ function forensicStageLabel(stage) {
1455
+ if (stage === "reading-transcripts")
1456
+ return "Reading transcript files";
1457
+ if (stage === "building-summary")
1458
+ return "Building scan summary";
1459
+ if (stage === "classifying-repeated-context")
1460
+ return "Classifying repeated context";
1461
+ if (stage === "finalizing-report")
1462
+ return "Finalizing report";
1463
+ return "Starting local scan";
1464
+ }
1465
+ /** Build the local forensic "Context Doctor" report on a worker thread so the multi-file scan never
1466
+ * blocks the bridge's event loop (the freeze postmortem: any unbounded sync work on this path is risky). */
1467
+ export function buildForensicReportOffThread(onProgress, options = {}) {
1468
+ let lastProgress = null;
1469
+ const recordProgress = (progress) => {
1470
+ lastProgress = progress;
1471
+ onProgress?.(progress);
1472
+ };
1473
+ return runForensicReportWorker(recordProgress, options).catch(async (primaryError) => {
1474
+ if (options.failOpen === false)
1475
+ throw primaryError;
1476
+ const failureCode = errorCode(primaryError) || "REPORT_BUILD_FAILED";
1477
+ console.error(`[echomem] local scan degraded after ${failureCode}; continuing without local-history analysis`);
1478
+ onProgress?.({
1479
+ done: lastProgress?.done || 0,
1480
+ total: lastProgress?.total || 0,
1481
+ stage: "finalizing-report",
1482
+ detail: "finishing setup without optional local-history analysis",
1483
+ overall: 0.99,
1484
+ stageDone: 0,
1485
+ stageTotal: 0,
1486
+ });
1487
+ return runForensicReportWorker(undefined, {
1488
+ timeoutMs: 30_000,
1489
+ maxOldGenerationSizeMb: Math.max(64, options.maxOldGenerationSizeMb || 0),
1490
+ }, [], failureCode);
1491
+ });
1492
+ }
1484
1493
  function errorCode(error) {
1485
1494
  return error && typeof error === "object" && "code" in error
1486
1495
  ? String(error.code || "")
@@ -1498,8 +1507,9 @@ export function completeOptionalStatsPayload(payload, reason, countsTrusted) {
1498
1507
  generatedFrom: ["~/.codex/sessions", "~/.claude/projects"],
1499
1508
  llmCallsUsed: 0,
1500
1509
  transcriptsUploaded: false,
1501
- sessions: { total: 0, codex: 0, claudeCode: 0 },
1510
+ sessions: { total: 0, codex: 0, claudeCode: 0, cowork: 0 },
1502
1511
  migratable: { pending: 0, alreadyMigrated: 0 },
1512
+ memoriesCaptured: null,
1503
1513
  };
1504
1514
  const completed = payload && typeof payload === "object" && !Array.isArray(payload)
1505
1515
  ? { ...payload }
@@ -1512,11 +1522,155 @@ export function completeOptionalStatsPayload(payload, reason, countsTrusted) {
1512
1522
  };
1513
1523
  return completed;
1514
1524
  }
1525
+ function runForensicReportWorker(onProgress, options, sources, degradedReason) {
1526
+ const forensicsUrl = runtimeModuleUrl("forensics");
1527
+ const serializedSources = sources === undefined ? "undefined" : JSON.stringify(sources);
1528
+ const serializedDegradedReason = JSON.stringify(degradedReason || "");
1529
+ const code = `
1530
+ import { parentPort } from "node:worker_threads";
1531
+ import { buildForensicReport, validateForensicReportForSetup } from ${JSON.stringify(forensicsUrl)};
1532
+ try {
1533
+ const report = await buildForensicReport({
1534
+ sources: ${serializedSources},
1535
+ includeLegacyGoldenStandard: false,
1536
+ onProgress: (done, total, stage, detail, overall, stageDone, stageTotal) => parentPort?.postMessage({
1537
+ progress: { done, total, stage, detail, overall, stageDone, stageTotal },
1538
+ }),
1539
+ });
1540
+ const degradedReason = ${serializedDegradedReason};
1541
+ if (degradedReason) {
1542
+ report.scanDiagnostics = {
1543
+ degraded: true,
1544
+ reason: degradedReason,
1545
+ skippedSources: ["codex", "claude"],
1546
+ };
1547
+ }
1548
+ const validation = validateForensicReportForSetup(report);
1549
+ if (!validation.ok) {
1550
+ const error = new Error(validation.message);
1551
+ error.code = validation.code;
1552
+ throw error;
1553
+ }
1554
+ parentPort?.postMessage({ ok: true, report });
1555
+ } catch (error) {
1556
+ parentPort?.postMessage({
1557
+ ok: false,
1558
+ message: error instanceof Error ? error.message : String(error),
1559
+ code: error && typeof error === "object" && "code" in error ? String(error.code || "") : "",
1560
+ });
1561
+ }
1562
+ `;
1563
+ const requestedHeapMb = options.maxOldGenerationSizeMb;
1564
+ const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`), Number.isFinite(requestedHeapMb)
1565
+ ? { resourceLimits: { maxOldGenerationSizeMb: Math.max(16, Math.floor(requestedHeapMb)) } }
1566
+ : undefined);
1567
+ return new Promise((resolve, reject) => {
1568
+ let settled = false;
1569
+ const requestedTimeoutMs = options.timeoutMs ?? 15 * 60_000;
1570
+ const timeoutMs = Number.isFinite(requestedTimeoutMs) ? Math.max(1, requestedTimeoutMs) : 15 * 60_000;
1571
+ const timeout = setTimeout(() => {
1572
+ if (settled)
1573
+ return;
1574
+ settled = true;
1575
+ void worker.terminate();
1576
+ const error = new Error(`Local forensic report timed out after ${timeoutMs}ms`);
1577
+ error.code = "REPORT_SCAN_TIMEOUT";
1578
+ reject(error);
1579
+ }, timeoutMs);
1580
+ timeout.unref?.();
1581
+ const finish = (result) => {
1582
+ if (settled)
1583
+ return;
1584
+ settled = true;
1585
+ clearTimeout(timeout);
1586
+ void worker.terminate();
1587
+ if (result.ok)
1588
+ resolve(result.report);
1589
+ else
1590
+ reject(result.error);
1591
+ };
1592
+ worker.on("message", (message) => {
1593
+ if (settled)
1594
+ return;
1595
+ const msg = message;
1596
+ if (msg.progress) {
1597
+ onProgress?.(msg.progress);
1598
+ return;
1599
+ }
1600
+ if (msg.ok === true && msg.report && typeof msg.report === "object") {
1601
+ finish({ ok: true, report: msg.report });
1602
+ return;
1603
+ }
1604
+ const error = new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed");
1605
+ if (typeof msg.code === "string" && msg.code)
1606
+ error.code = msg.code;
1607
+ finish({ ok: false, error });
1608
+ });
1609
+ worker.once("error", (error) => {
1610
+ finish({ ok: false, error });
1611
+ });
1612
+ worker.once("exit", (code) => {
1613
+ if (settled)
1614
+ return;
1615
+ finish({ ok: false, error: new Error(`Forensic report worker exited (code ${code}) without a result`) });
1616
+ });
1617
+ });
1618
+ }
1515
1619
  export function respondMigrate(res, body, status = 200) {
1516
1620
  if (res.writableEnded)
1517
1621
  return;
1518
1622
  res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
1519
1623
  }
1624
+ function safeForensicError(error) {
1625
+ const code = error && typeof error === "object" && "code" in error
1626
+ ? String(error.code || "")
1627
+ : "";
1628
+ if (code === "REPORT_SCAN_TIMEOUT") {
1629
+ return {
1630
+ code,
1631
+ message: "The local workspace scan took too long and was stopped. No backup data was substituted. Rerun setup to retry.",
1632
+ };
1633
+ }
1634
+ return {
1635
+ code: "REPORT_BUILD_FAILED",
1636
+ message: "EchoMem could not finish the local workspace scan. No backup data was substituted. Rerun setup to retry.",
1637
+ };
1638
+ }
1639
+ function publicRunningForensicProgress(value) {
1640
+ if (!value || typeof value !== "object")
1641
+ return null;
1642
+ const progress = value;
1643
+ if (progress.status !== "running")
1644
+ return null;
1645
+ const safeCount = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
1646
+ ? Math.floor(candidate)
1647
+ : 0);
1648
+ const safeDuration = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
1649
+ ? candidate
1650
+ : 0);
1651
+ const safeFraction = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate)
1652
+ ? Math.min(1, Math.max(0, candidate))
1653
+ : 0);
1654
+ const total = safeCount(progress.total);
1655
+ const stageTotal = safeCount(progress.stageTotal);
1656
+ const rawStage = typeof progress.stage === "string" ? progress.stage : "starting";
1657
+ const stage = ["starting", "reading-transcripts", "building-summary", "classifying-repeated-context", "finalizing-report"].includes(rawStage)
1658
+ ? rawStage
1659
+ : "starting";
1660
+ return {
1661
+ status: "running",
1662
+ scanned: total > 0 ? Math.min(safeCount(progress.scanned), total) : 0,
1663
+ total,
1664
+ stage,
1665
+ label: forensicStageLabel(stage),
1666
+ stageDone: stageTotal > 0 ? Math.min(safeCount(progress.stageDone), stageTotal) : 0,
1667
+ stageTotal,
1668
+ overall: safeFraction(progress.overall),
1669
+ elapsedMs: safeDuration(progress.elapsedMs),
1670
+ stageElapsedMs: safeDuration(progress.stageElapsedMs),
1671
+ updatedAt: safeDuration(progress.updatedAt) || Date.now(),
1672
+ };
1673
+ }
1520
1674
  /**
1521
1675
  * Start the persistent localhost bridge used by the setup page. It sends/verifies OTP through the
1522
1676
  * hosted API, accepts the local passphrase, serves local Wrapped stats, and holds the /migrate
@@ -1529,11 +1683,12 @@ export function startCallbackServer(opts = {}) {
1529
1683
  : `${Math.ceil(timeoutMs / 1000)} seconds`;
1530
1684
  const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
1531
1685
  const expectedNonce = opts.nonce;
1686
+ const scanId = opts.scanId ?? randomUUID();
1532
1687
  const flow = opts.flow ?? "onboarding";
1533
1688
  const isLoginFlow = flow === "login";
1534
1689
  // A login screen must not be blocked by a local-history permission. That permission belongs to
1535
1690
  // onboarding and is intentionally enforced separately below.
1536
- const requiresLocalHistoryConsent = opts.requireLocalHistoryConsent === true && !isLoginFlow;
1691
+ const requiresReportConsent = opts.requireReportConsent === true && !isLoginFlow;
1537
1692
  return new Promise((resolveOuter, rejectOuter) => {
1538
1693
  const onToken = deferred();
1539
1694
  const setupExit = deferred();
@@ -1546,7 +1701,7 @@ export function startCallbackServer(opts = {}) {
1546
1701
  let activeDeviceToken = opts.initialToken?.token || "";
1547
1702
  let activeAccountEmail = "";
1548
1703
  let pendingLocalAuth = null;
1549
- let localHistoryConsentGranted = !requiresLocalHistoryConsent;
1704
+ let reportConsentGranted = !requiresReportConsent;
1550
1705
  let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
1551
1706
  let migrateStarted = false;
1552
1707
  let tokenRefreshHandler = null;
@@ -1613,7 +1768,7 @@ export function startCallbackServer(opts = {}) {
1613
1768
  const handleCallback = (res, token, key, nonce) => {
1614
1769
  if (!checkNonce(nonce))
1615
1770
  return void text(res, 403, "bad nonce");
1616
- if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
1771
+ if (requiresReportConsent && !reportConsentGranted) {
1617
1772
  return void json(res, 403, {
1618
1773
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1619
1774
  message: "Allow local history access in the setup page before connecting EchoMem.",
@@ -1643,7 +1798,7 @@ export function startCallbackServer(opts = {}) {
1643
1798
  text(res, 403, "bad nonce");
1644
1799
  return true;
1645
1800
  }
1646
- if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
1801
+ if (requiresReportConsent && !reportConsentGranted) {
1647
1802
  json(res, 403, {
1648
1803
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1649
1804
  message: "Allow local history access in the setup page before connecting EchoMem.",
@@ -1670,7 +1825,8 @@ export function startCallbackServer(opts = {}) {
1670
1825
  armTimeout();
1671
1826
  };
1672
1827
  const isOnboardingOnlyRoute = (route) => [
1673
- "/local-history-consent",
1828
+ "/report-consent",
1829
+ "/report",
1674
1830
  "/stats",
1675
1831
  "/billing-status",
1676
1832
  "/billing-checkout",
@@ -1863,6 +2019,10 @@ export function startCallbackServer(opts = {}) {
1863
2019
  message: "Run `echomem-mcp init` to access local-history onboarding.",
1864
2020
  });
1865
2021
  }
2022
+ if ((route === "/city" || route.startsWith("/city/")) && req.method === "GET") {
2023
+ serveRepoCityAsset(route, res);
2024
+ return;
2025
+ }
1866
2026
  if (route.startsWith("/hud-assets/") && req.method === "GET") {
1867
2027
  serveHudAsset(route, res);
1868
2028
  return;
@@ -1900,13 +2060,8 @@ export function startCallbackServer(opts = {}) {
1900
2060
  localOnly: true,
1901
2061
  localAuth: true,
1902
2062
  workspacePath: process.cwd(),
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
- },
2063
+ consentRequired: requiresReportConsent,
2064
+ consentGranted: reportConsentGranted,
1910
2065
  });
1911
2066
  return;
1912
2067
  }
@@ -2010,7 +2165,7 @@ export function startCallbackServer(opts = {}) {
2010
2165
  if (route === "/stats" && req.method === "GET") {
2011
2166
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
2012
2167
  return void text(res, 403, "bad nonce");
2013
- if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2168
+ if (requiresReportConsent && !reportConsentGranted) {
2014
2169
  return void json(res, 403, {
2015
2170
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2016
2171
  message: "Allow local history access before continuing setup.",
@@ -2026,7 +2181,7 @@ export function startCallbackServer(opts = {}) {
2026
2181
  res.setHeader("Cache-Control", "no-store, max-age=0");
2027
2182
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
2028
2183
  return void text(res, 403, "bad nonce");
2029
- if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2184
+ if (requiresReportConsent && !reportConsentGranted) {
2030
2185
  return void json(res, 403, {
2031
2186
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2032
2187
  message: "Allow local history access before continuing setup.",
@@ -2150,7 +2305,7 @@ export function startCallbackServer(opts = {}) {
2150
2305
  }
2151
2306
  if (!checkNonce(asString(body.nonce)))
2152
2307
  return void text(res, 403, "bad nonce");
2153
- if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2308
+ if (requiresReportConsent && !reportConsentGranted) {
2154
2309
  return void json(res, 403, {
2155
2310
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2156
2311
  message: "Allow local history access before managing an onboarding plan.",
@@ -2216,10 +2371,114 @@ export function startCallbackServer(opts = {}) {
2216
2371
  }
2217
2372
  return;
2218
2373
  }
2374
+ if (route === "/report" && req.method === "GET") {
2375
+ // Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
2376
+ res.setHeader("Cache-Control", "no-store");
2377
+ if (!checkNonce(url.searchParams.get("nonce") || undefined))
2378
+ return void text(res, 403, "bad nonce");
2379
+ if (requiresReportConsent && !reportConsentGranted) {
2380
+ return void json(res, 403, {
2381
+ error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2382
+ message: "Allow local history access before starting the local scan.",
2383
+ });
2384
+ }
2385
+ let payload;
2386
+ try {
2387
+ payload = opts.getReport ? opts.getReport() : null;
2388
+ }
2389
+ catch {
2390
+ return void json(res, 500, {
2391
+ schemaVersion: 1,
2392
+ kind: "failed",
2393
+ mode: "production",
2394
+ scanId,
2395
+ error: {
2396
+ code: "REPORT_STATE_UNAVAILABLE",
2397
+ message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
2398
+ },
2399
+ });
2400
+ }
2401
+ if (payload == null) {
2402
+ // 202 carries scan progress so the page can show a live "scanned N/total" indicator.
2403
+ let prog;
2404
+ try {
2405
+ prog = opts.getReportProgress ? opts.getReportProgress() : {
2406
+ status: "running",
2407
+ scanned: 0,
2408
+ total: 0,
2409
+ stage: "starting",
2410
+ label: "Starting local scan",
2411
+ elapsedMs: 0,
2412
+ stageElapsedMs: 0,
2413
+ updatedAt: Date.now(),
2414
+ };
2415
+ }
2416
+ catch {
2417
+ return void json(res, 500, {
2418
+ schemaVersion: 1,
2419
+ kind: "failed",
2420
+ mode: "production",
2421
+ scanId,
2422
+ error: {
2423
+ code: "REPORT_STATE_UNAVAILABLE",
2424
+ message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
2425
+ },
2426
+ });
2427
+ }
2428
+ if (prog && typeof prog === "object" && prog.status === "failed") {
2429
+ return void json(res, 500, {
2430
+ schemaVersion: 1,
2431
+ kind: "failed",
2432
+ mode: "production",
2433
+ scanId,
2434
+ error: safeForensicError(prog.error),
2435
+ });
2436
+ }
2437
+ const publicProgress = publicRunningForensicProgress(prog);
2438
+ if (!publicProgress) {
2439
+ return void json(res, 500, {
2440
+ schemaVersion: 1,
2441
+ kind: "failed",
2442
+ mode: "production",
2443
+ scanId,
2444
+ error: {
2445
+ code: "REPORT_STATE_INVALID",
2446
+ message: "EchoMem received an invalid local scan state. No backup data was substituted. Rerun setup to retry.",
2447
+ },
2448
+ });
2449
+ }
2450
+ return void json(res, 202, {
2451
+ schemaVersion: 1,
2452
+ kind: "scanning",
2453
+ mode: "production",
2454
+ scanId,
2455
+ progress: publicProgress,
2456
+ });
2457
+ }
2458
+ const validation = validateForensicReportForSetup(payload);
2459
+ if (!validation.ok) {
2460
+ console.error(`[echomem] local report validation failed: ${validation.code} — ${validation.message}`);
2461
+ return void json(res, 500, {
2462
+ schemaVersion: 1,
2463
+ kind: "failed",
2464
+ mode: "production",
2465
+ scanId,
2466
+ error: { code: validation.code, message: validation.message },
2467
+ });
2468
+ }
2469
+ json(res, 200, {
2470
+ schemaVersion: 1,
2471
+ kind: validation.kind,
2472
+ mode: "production",
2473
+ scanId,
2474
+ report: validation.report,
2475
+ });
2476
+ return;
2477
+ }
2219
2478
  if (route === "/progress" && req.method === "GET") {
2220
2479
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
2221
2480
  return void text(res, 403, "bad nonce");
2222
- if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2481
+ if (requiresReportConsent && !reportConsentGranted) {
2223
2482
  return void json(res, 403, {
2224
2483
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2225
2484
  message: "Allow local history access before continuing setup.",
@@ -2228,7 +2487,7 @@ export function startCallbackServer(opts = {}) {
2228
2487
  json(res, 200, progress);
2229
2488
  return;
2230
2489
  }
2231
- if (route === "/local-history-consent" && req.method === "POST") {
2490
+ if (route === "/report-consent" && req.method === "POST") {
2232
2491
  let body;
2233
2492
  try {
2234
2493
  body = await readJsonBody(req);
@@ -2240,7 +2499,8 @@ export function startCallbackServer(opts = {}) {
2240
2499
  if (!checkNonce(asString(body.nonce)))
2241
2500
  return void text(res, 403, "bad nonce");
2242
2501
  const allowed = body.allowed === true;
2243
- localHistoryConsentGranted = allowed;
2502
+ reportConsentGranted = allowed;
2503
+ opts.onReportConsent?.(allowed);
2244
2504
  json(res, 200, { ok: true, allowed });
2245
2505
  return;
2246
2506
  }
@@ -2299,7 +2559,7 @@ export function startCallbackServer(opts = {}) {
2299
2559
  }
2300
2560
  if (!checkNonce(asString(body.nonce)))
2301
2561
  return void text(res, 403, "bad nonce");
2302
- if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
2562
+ if (requiresReportConsent && !reportConsentGranted) {
2303
2563
  return void json(res, 403, {
2304
2564
  error: "LOCAL_HISTORY_CONSENT_REQUIRED",
2305
2565
  message: "Allow local history access before starting extraction.",
@@ -2561,7 +2821,6 @@ async function cmdSetup(flags) {
2561
2821
  // --dev is already an explicit request to replace the managed runtime with a checkout.
2562
2822
  const forceHeadless = flags["force-headless"] === true || typeof flags.dev === "string";
2563
2823
  const configurationFailures = [];
2564
- const configuredTargets = [];
2565
2824
  if (targets.length === 0) {
2566
2825
  console.log("No client auto-detected. Add this MCP server entry manually:\n");
2567
2826
  console.log(JSON.stringify({ echomem: entry }, null, 2));
@@ -2569,91 +2828,66 @@ async function cmdSetup(flags) {
2569
2828
  }
2570
2829
  else {
2571
2830
  for (const c of targets) {
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}`);
2577
- }
2578
- else {
2579
- console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath}`);
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);
2831
+ if (c.kind === "json") {
2832
+ const result = writeJsonClientConfig(c.configPath, entry, { forceHeadless });
2833
+ if (result === "desktop-managed") {
2834
+ console.log(`✅ Kept the valid Echo Desktop-managed EchoMem entry for ${c.label}: ${c.configPath}`);
2592
2835
  }
2593
2836
  else {
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);
2837
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath}`);
2838
+ }
2839
+ }
2840
+ else if (c.kind === "command") {
2841
+ const result = writeCodexConfig(c.configPath, entry, { forceHeadless });
2842
+ if (result === "wrote")
2843
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath} — start a new Codex session to load it.`);
2844
+ else if (result === "desktop-managed")
2845
+ console.log(`✅ Kept the valid Echo Desktop-managed EchoMem entry for ${c.label}: ${c.configPath}`);
2846
+ else
2847
+ console.log(`✅ ${c.label} already has the EchoMem MCP entry: ${c.configPath}`);
2848
+ }
2849
+ else {
2850
+ const result = c.id === "claude-code"
2851
+ ? writeClaudeCodeConfig(entry, { forceHeadless })
2852
+ : "unavailable";
2853
+ if (result !== "unavailable" && result.state === "wrote") {
2854
+ if (result.preservedDesktopManaged) {
2855
+ console.log(`✅ Kept the valid Echo Desktop-managed EchoMem user entry for ${c.label}.`);
2611
2856
  }
2612
2857
  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
- });
2858
+ console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
2859
+ }
2860
+ if (result.removedLocalProjects.length > 0) {
2861
+ console.log(`✅ Removed ${result.removedLocalProjects.length} stale Claude Code project-local EchoMem ${result.removedLocalProjects.length === 1 ? "entry" : "entries"}.`);
2862
+ }
2863
+ if (result.skippedLocalProjects.length > 0) {
2864
+ console.log(`ℹ️ Ignored ${result.skippedLocalProjects.length} EchoMem local ${result.skippedLocalProjects.length === 1 ? "entry" : "entries"} for deleted project directories; they cannot shadow the user entry.`);
2621
2865
  }
2622
2866
  }
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
- });
2867
+ else {
2868
+ const failedProjects = result === "unavailable" ? [] : result.failedLocalProjects;
2869
+ configurationFailures.push(failedProjects.length > 0
2870
+ ? `${c.label} still has project-local EchoMem overrides in: ${failedProjects.join(", ")}`
2871
+ : `${c.label} user-scoped EchoMem entry could not be verified`);
2872
+ }
2629
2873
  }
2630
2874
  }
2631
2875
  }
2632
2876
  if (configurationFailures.length > 0) {
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
- }
2877
+ throw new Error([
2878
+ "EchoMem MCP configuration is incomplete; onboarding was stopped before login/import.",
2879
+ ...configurationFailures.map((failure) => `- ${failure}`),
2880
+ `Retry with: ${MCP_UPDATE_COMMAND} --client claude-code`,
2881
+ ].join("\n"));
2648
2882
  }
2649
2883
  if (!flags["no-agents-md"]) {
2650
- writeMemoryGuidanceForTargets(configuredTargets);
2884
+ writeMemoryGuidanceForTargets(targets);
2651
2885
  }
2652
2886
  if (!flags["no-codex-skills"]) {
2653
- writeCodexSkillsForTargets(configuredTargets);
2887
+ writeCodexSkillsForTargets(targets);
2654
2888
  }
2655
2889
  if (flags["no-save-hooks"] !== true) {
2656
- writeLifecycleHooksForTargets(configuredTargets);
2890
+ writeLifecycleHooksForTargets(targets);
2657
2891
  }
2658
2892
  console.log("");
2659
2893
  if (flags["skip-login"] || flags["no-login"]) {
@@ -2665,14 +2899,14 @@ async function cmdSetup(flags) {
2665
2899
  await cmdLogin(flags);
2666
2900
  }
2667
2901
  if (flags["with-hud"]) {
2668
- console.log("ℹ️ The standalone EchoMem HUD has been retired. Use `echomem-mcp status` to inspect this installation.");
2902
+ console.log("ℹ️ The standalone EchoMem HUD has been retired. Echo Desktop now owns setup and status.");
2669
2903
  }
2670
2904
  }
2671
2905
  /**
2672
2906
  * `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
2673
2907
  * machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), installs EchoMem's
2674
2908
  * Codex skills and writes the AGENTS.md memory guidance. One browser
2675
- * bridge then runs permission → login → plan if needed → extraction in that order.
2909
+ * bridge then runs permission → report → login → plan if needed → extraction in that order.
2676
2910
  * `setup`/`login`/`update` remain granular primitives; init picks the full product defaults.
2677
2911
  */
2678
2912
  async function cmdInit(flags) {
@@ -2681,13 +2915,12 @@ async function cmdInit(flags) {
2681
2915
  await cmdSetup({
2682
2916
  ...flags,
2683
2917
  all: true,
2684
- "continue-on-client-error": true,
2685
2918
  "skip-login": true,
2686
2919
  "with-hud": false,
2687
2920
  "init-quiet": true,
2688
2921
  "install-save-hooks": flags["no-save-hooks"] !== true,
2689
2922
  });
2690
- // 2. Start one ordered onboarding bridge. A fresh device logs in only after consent.
2923
+ // 2. Start one ordered onboarding bridge. A fresh device logs in only after consent + report.
2691
2924
  console.log("");
2692
2925
  if (!flags["skip-login"] && !flags["no-login"] && !await cmdOnboarding(flags)) {
2693
2926
  console.log("\nEchoMem is configured, but onboarding did not finish. Run `echomem-mcp init` again when you are ready.");
@@ -2695,8 +2928,8 @@ async function cmdInit(flags) {
2695
2928
  }
2696
2929
  console.log("");
2697
2930
  console.log("🎉 EchoMem is ready.");
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.");
2931
+ console.log(" • MCP memory is configured for every coding agent installed on this machine.");
2932
+ console.log(" • Echo Desktop shows connection status and manages this device credential.");
2700
2933
  console.log(' • Try it now: ask your agent — "search my EchoMem for what I\'ve been working on and recap it."');
2701
2934
  }
2702
2935
  /**
@@ -2710,7 +2943,7 @@ function writeMemoryGuidanceForTargets(targets) {
2710
2943
  if (t.id === "codex" && t.kind === "command")
2711
2944
  files.set(path.join(t.detectDir, "AGENTS.md"), "Codex");
2712
2945
  if (t.id === "claude-code" || t.id === "claude-desktop")
2713
- files.set(path.join(claudeConfigHome(), "CLAUDE.md"), "Claude");
2946
+ files.set(home(".claude", "CLAUDE.md"), "Claude");
2714
2947
  }
2715
2948
  for (const [file, label] of files) {
2716
2949
  try {
@@ -2745,28 +2978,22 @@ function writeCodexSkillsForTargets(targets) {
2745
2978
  }
2746
2979
  }
2747
2980
  function writeLifecycleHooksForTargets(targets) {
2748
- const clients = [];
2981
+ const clients = new Set();
2749
2982
  if (targets.some((target) => target.id === "codex"))
2750
- clients.push("codex");
2983
+ clients.add("codex");
2751
2984
  if (targets.some((target) => target.id === "claude-code"))
2752
- clients.push("claude-code");
2753
- if (clients.length === 0) {
2985
+ clients.add("claude-code");
2986
+ if (clients.size === 0) {
2754
2987
  console.log("ℹ️ No hook-capable Codex or Claude Code client was detected; private-save checkpoint hooks were not installed.");
2755
2988
  return;
2756
2989
  }
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
- }
2990
+ const mode = clients.size === 2 ? "both" : [...clients][0];
2991
+ const sourcePaths = installSourceSessionHooks(mode);
2992
+ const savePaths = installSaveCheckpointHooks(mode);
2993
+ const paths = [...new Set([...sourcePaths, ...savePaths])];
2994
+ console.log(`✅ Installed EchoMem source-session and private-save hooks:\n${paths.map((p) => ` - ${p}`).join("\n")}`);
2995
+ if (clients.has("codex")) {
2996
+ console.log(" Codex: start a new session and run /hooks once to review and trust the hook.");
2770
2997
  }
2771
2998
  }
2772
2999
  async function cmdUpdate(flags) {
@@ -2782,7 +3009,7 @@ function selectSetupTargets(requested, all) {
2782
3009
  return fs.existsSync(path.dirname(client.configPath));
2783
3010
  if (client.kind === "command")
2784
3011
  return fs.existsSync(client.detectDir);
2785
- return client.id === "claude-code" && claudeCodeCliAvailable();
3012
+ return client.id === "claude-code" && fs.existsSync(home(".claude"));
2786
3013
  });
2787
3014
  }
2788
3015
  return requested ? knownClients().filter((client) => client.id === requested) : detectClients();
@@ -2818,7 +3045,7 @@ async function cmdLogin(flags) {
2818
3045
  return true;
2819
3046
  }
2820
3047
  // Browser path: this bridge does only account/device authentication. It intentionally exposes
2821
- // no local-history routes; `init` owns local-history consent and optional extraction.
3048
+ // no local-history routes; `init` owns scan consent, reporting, and optional extraction.
2822
3049
  console.log("Opening your browser to connect this device locally…");
2823
3050
  const { port, nonce } = localBridgeOptions(flags);
2824
3051
  const srv = await startCallbackServer({ port, nonce, flow: "login" });
@@ -2846,7 +3073,7 @@ async function cmdLogin(flags) {
2846
3073
  }
2847
3074
  /**
2848
3075
  * The local-history onboarding flow. Existing device credentials are reused when available; a
2849
- * fresh device stays in this same bridge and asks for login only after permission.
3076
+ * fresh device stays in this same bridge and asks for login only after permission and report.
2850
3077
  */
2851
3078
  async function cmdOnboarding(flags) {
2852
3079
  const store = new KeyStore();
@@ -2858,17 +3085,149 @@ async function cmdOnboarding(flags) {
2858
3085
  console.log("Opening your browser for EchoMem onboarding…");
2859
3086
  const { port, nonce } = localBridgeOptions(flags);
2860
3087
  let stats = null;
3088
+ let forensicReport = null;
3089
+ let forensicConsent = "pending";
3090
+ let forensicScanStarted = false;
3091
+ const forensicStartedAt = Date.now();
3092
+ let forensicStageStartedAt = forensicStartedAt;
3093
+ let forensicStage = "starting";
3094
+ let forensicOverall = 0;
3095
+ let forensicProgress = {
3096
+ status: "running",
3097
+ scanned: 0,
3098
+ total: 0,
3099
+ stage: forensicStage,
3100
+ label: forensicStageLabel(forensicStage),
3101
+ stageDone: 0,
3102
+ stageTotal: 0,
3103
+ overall: 0,
3104
+ elapsedMs: 0,
3105
+ stageElapsedMs: 0,
3106
+ updatedAt: forensicStartedAt,
3107
+ };
2861
3108
  const srv = await startCallbackServer({
2862
3109
  port,
2863
3110
  nonce,
2864
3111
  flow: "onboarding",
2865
3112
  initialToken,
2866
- requireLocalHistoryConsent: true,
3113
+ requireReportConsent: true,
3114
+ getStats: () => stats,
3115
+ getReport: () => forensicReport,
3116
+ getReportProgress: () => {
3117
+ if (forensicProgress.status !== "running")
3118
+ return forensicProgress;
3119
+ const now = Date.now();
3120
+ const sinceWorkerUpdate = Math.max(0, now - forensicProgress.updatedAt);
3121
+ return {
3122
+ ...forensicProgress,
3123
+ elapsedMs: forensicProgress.elapsedMs + sinceWorkerUpdate,
3124
+ stageElapsedMs: forensicProgress.stageElapsedMs + sinceWorkerUpdate,
3125
+ updatedAt: now,
3126
+ };
3127
+ },
3128
+ onReportConsent: (allowed) => {
3129
+ if (!allowed) {
3130
+ forensicConsent = "declined";
3131
+ forensicProgress = {
3132
+ status: "failed",
3133
+ scanned: 0,
3134
+ total: 0,
3135
+ stage: "failed",
3136
+ label: "Local scan skipped",
3137
+ stageDone: 0,
3138
+ stageTotal: 0,
3139
+ overall: 0,
3140
+ elapsedMs: Date.now() - forensicStartedAt,
3141
+ stageElapsedMs: Date.now() - forensicStageStartedAt,
3142
+ updatedAt: Date.now(),
3143
+ error: {
3144
+ code: "REPORT_SCAN_DECLINED",
3145
+ message: "Local file analysis was skipped. EchoMem can still connect, save conversations, and import memories.",
3146
+ },
3147
+ };
3148
+ return;
3149
+ }
3150
+ forensicConsent = "allowed";
3151
+ const now = Date.now();
3152
+ forensicStage = "starting";
3153
+ forensicStageStartedAt = now;
3154
+ forensicOverall = 0;
3155
+ forensicProgress = {
3156
+ status: "running",
3157
+ scanned: 0,
3158
+ total: 0,
3159
+ stage: forensicStage,
3160
+ label: forensicStageLabel(forensicStage),
3161
+ stageDone: 0,
3162
+ stageTotal: 0,
3163
+ overall: 0,
3164
+ elapsedMs: now - forensicStartedAt,
3165
+ stageElapsedMs: 0,
3166
+ updatedAt: now,
3167
+ };
3168
+ startForensicScan();
3169
+ },
2867
3170
  });
2868
3171
  const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
2869
3172
  openBrowser(localSetupUrl);
2870
3173
  console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
2871
3174
  console.log("Waiting for local-history onboarding for up to 15 minutes…");
3175
+ const startForensicScan = () => {
3176
+ if (forensicScanStarted || forensicConsent !== "allowed")
3177
+ return;
3178
+ forensicScanStarted = true;
3179
+ // Build the local forensic "Context Doctor" report off-thread only after explicit consent.
3180
+ buildForensicReportOffThread((progress) => {
3181
+ const now = Date.now();
3182
+ const nextStage = progress.stage || forensicStage;
3183
+ if (nextStage !== forensicStage) {
3184
+ forensicStage = nextStage;
3185
+ forensicStageStartedAt = now;
3186
+ console.log(`Local scan: ${forensicStageLabel(forensicStage)}…`);
3187
+ }
3188
+ // Latched, so a caller that ever reports a smaller fraction cannot walk the bar backwards.
3189
+ forensicOverall = Math.max(forensicOverall, typeof progress.overall === "number" && Number.isFinite(progress.overall) ? progress.overall : 0);
3190
+ forensicProgress = {
3191
+ status: "running",
3192
+ scanned: progress.done,
3193
+ total: progress.total,
3194
+ stage: forensicStage,
3195
+ label: forensicStageLabel(forensicStage),
3196
+ detail: progress.detail,
3197
+ stageDone: typeof progress.stageDone === "number" && Number.isFinite(progress.stageDone)
3198
+ ? Math.max(0, Math.floor(progress.stageDone))
3199
+ : 0,
3200
+ stageTotal: typeof progress.stageTotal === "number" && Number.isFinite(progress.stageTotal)
3201
+ ? Math.max(0, Math.floor(progress.stageTotal))
3202
+ : 0,
3203
+ overall: forensicOverall,
3204
+ elapsedMs: now - forensicStartedAt,
3205
+ stageElapsedMs: now - forensicStageStartedAt,
3206
+ updatedAt: now,
3207
+ };
3208
+ })
3209
+ .then((r) => {
3210
+ forensicReport = r;
3211
+ })
3212
+ .catch((e) => {
3213
+ const now = Date.now();
3214
+ forensicProgress = {
3215
+ status: "failed",
3216
+ scanned: forensicProgress.scanned,
3217
+ total: forensicProgress.total,
3218
+ stage: "failed",
3219
+ label: "Local scan failed",
3220
+ stageDone: forensicProgress.stageDone,
3221
+ stageTotal: forensicProgress.stageTotal,
3222
+ overall: forensicOverall,
3223
+ elapsedMs: now - forensicStartedAt,
3224
+ stageElapsedMs: now - forensicStageStartedAt,
3225
+ updatedAt: now,
3226
+ error: safeForensicError(e),
3227
+ };
3228
+ console.error(`Could not build the local report: ${e instanceof Error ? e.message : String(e)}`);
3229
+ });
3230
+ };
2872
3231
  let token;
2873
3232
  let key;
2874
3233
  try {
@@ -2956,10 +3315,12 @@ async function cmdOnboarding(flags) {
2956
3315
  let sessionSummary = {
2957
3316
  total: quick.sessions,
2958
3317
  codex: quick.codexCount,
2959
- claudeCode: quick.claudeCount,
3318
+ claudeCode: quick.claudeCodeCount,
3319
+ cowork: quick.coworkCount,
2960
3320
  };
2961
- stats = buildOnboardingStatsPayload({
3321
+ stats = await buildStatsPayload([], {
2962
3322
  partial: true,
3323
+ skipMemoryCount: true,
2963
3324
  sessions: sessionSummary,
2964
3325
  migratable,
2965
3326
  discovery: { phase: "quick", exact: false },
@@ -2991,10 +3352,12 @@ async function cmdOnboarding(flags) {
2991
3352
  sessionSummary = {
2992
3353
  total: cloudSummary.sessions,
2993
3354
  codex: cloudSummary.codexCount,
2994
- claudeCode: cloudSummary.claudeCount,
3355
+ claudeCode: cloudSummary.claudeCodeCount,
3356
+ cowork: cloudSummary.coworkCount,
2995
3357
  };
2996
- const cloudPayload = buildOnboardingStatsPayload({
3358
+ const cloudPayload = await buildStatsPayload([], {
2997
3359
  partial: true,
3360
+ skipMemoryCount: true,
2998
3361
  sessions: sessionSummary,
2999
3362
  migratable,
3000
3363
  discovery: { phase: "account", exact: false },
@@ -3027,10 +3390,12 @@ async function cmdOnboarding(flags) {
3027
3390
  sessionSummary = {
3028
3391
  total: unavailableSummary.sessions,
3029
3392
  codex: unavailableSummary.codexCount,
3030
- claudeCode: unavailableSummary.claudeCount,
3393
+ claudeCode: unavailableSummary.claudeCodeCount,
3394
+ cowork: unavailableSummary.coworkCount,
3031
3395
  };
3032
- const unavailablePayload = buildOnboardingStatsPayload({
3396
+ const unavailablePayload = await buildStatsPayload([], {
3033
3397
  partial: true,
3398
+ skipMemoryCount: true,
3034
3399
  sessions: sessionSummary,
3035
3400
  migratable,
3036
3401
  discovery: { phase: "account", exact: false },
@@ -3066,8 +3431,9 @@ async function cmdOnboarding(flags) {
3066
3431
  migratable = migratableFromDiscovery(initialExact);
3067
3432
  latestPendingEstimate = migratable.pending;
3068
3433
  sessionSummary = sessionsFromDiscovery(initialExact);
3069
- const partialPayload = withCandidateSessions(buildOnboardingStatsPayload({
3434
+ const partialPayload = withCandidateSessions(await buildStatsPayload([], {
3070
3435
  partial: true,
3436
+ skipMemoryCount: true,
3071
3437
  sessions: sessionSummary,
3072
3438
  migratable,
3073
3439
  discovery: { phase: "exact", exact: true },
@@ -3115,10 +3481,12 @@ async function cmdOnboarding(flags) {
3115
3481
  migratable = migratableFromDiscovery(reconciled);
3116
3482
  latestPendingEstimate = migratable.pending;
3117
3483
  sessionSummary = sessionsFromDiscovery(reconciled);
3118
- const reconciledPayload = withCandidateSessions(buildOnboardingStatsPayload({
3484
+ const reconciledPayload = withCandidateSessions(await buildStatsPayload([], {
3485
+ partial: true,
3486
+ skipMemoryCount: true,
3119
3487
  sessions: sessionSummary,
3120
3488
  migratable,
3121
- discovery: { phase: "full", exact: true },
3489
+ discovery: { phase: "exact", exact: true },
3122
3490
  }), reconciled);
3123
3491
  if (generation !== refreshGeneration)
3124
3492
  return;
@@ -3133,11 +3501,20 @@ async function cmdOnboarding(flags) {
3133
3501
  failed: 0,
3134
3502
  extracted: 0,
3135
3503
  });
3504
+ const fullPayload = withCandidateSessions(await buildCollectedStatsPayloadOffThread({
3505
+ sessions: sessionSummary,
3506
+ migratable,
3507
+ discovery: { phase: "full", exact: true },
3508
+ }), reconciled);
3509
+ if (generation !== refreshGeneration)
3510
+ return;
3511
+ stats = fullPayload;
3512
+ srv.setStats(fullPayload);
3136
3513
  })().catch((e) => {
3137
3514
  if (generation !== refreshGeneration)
3138
3515
  return;
3139
- console.error(`[echomem] optional account reconciliation unavailable; continuing (${errorCode(e) || "ACCOUNT_RECONCILIATION_FAILED"})`);
3140
- publishOptionalStatsFallback(generation, "ACCOUNT_RECONCILIATION_FAILED", true);
3516
+ console.error(`[echomem] optional full local-history stats unavailable; continuing (${errorCode(e) || "FULL_STATS_FAILED"})`);
3517
+ publishOptionalStatsFallback(generation, "FULL_STATS_FAILED", true);
3141
3518
  });
3142
3519
  return initialExact;
3143
3520
  }).catch((e) => {
@@ -3597,7 +3974,7 @@ Usage:
3597
3974
  echomem-mcp Run the MCP server (stdio; default — used by your editor)
3598
3975
  echomem-mcp setup [--client X] Detect editor, write its MCP config, then connect this device
3599
3976
  echomem-mcp setup --skip-login Write MCP config without opening login/browser
3600
- echomem-mcp setup --force-headless Explicitly replace a valid externally managed entry
3977
+ echomem-mcp setup --force-headless Explicitly replace valid Echo Desktop-managed entries
3601
3978
  echomem-mcp setup --no-codex-skills Skip installing the bundled EchoMem Codex skills
3602
3979
  echomem-mcp update --all Install this bridge durably + repoint detected clients; no login/browser
3603
3980
  echomem-mcp update --client X Repoint one MCP client; no login/browser
@@ -3608,6 +3985,7 @@ Usage:
3608
3985
  echomem-mcp status Show token/key/clients
3609
3986
  echomem-mcp doctor [--no-network] Diagnose configured client bridge versions
3610
3987
  echomem-mcp logout Remove stored credentials
3988
+ echomem-mcp report [--json] Your AI coding memory audit (local, no login, $0)
3611
3989
  echomem-mcp migrate [--since D] Import your existing Codex/Claude history into your memory
3612
3990
  echomem-mcp migrate --estimate Estimate migration size/time metadata without uploading transcripts
3613
3991
  echomem-mcp migrate --max-chars N Import only sessions up to N assembled text chars
@@ -3659,6 +4037,9 @@ export async function runCli(argv) {
3659
4037
  case "logout":
3660
4038
  cmdLogout();
3661
4039
  return true;
4040
+ case "report":
4041
+ await runReport(flags);
4042
+ return true;
3662
4043
  case "migrate":
3663
4044
  await cmdMigrate(flags);
3664
4045
  return true;