@echomem/mcp 1.4.7 → 1.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +35 -9
  2. package/assets/canonical-scorer/README.md +18 -0
  3. package/assets/canonical-scorer/analyze-10-problems.mjs +857 -0
  4. package/assets/canonical-scorer/build-session-waste-dashboard.mjs +1628 -0
  5. package/assets/canonical-scorer/golden_anchors.mjs +83 -0
  6. package/assets/canonical-scorer/optimizable_detail.mjs +633 -0
  7. package/assets/hud/claude.svg +1 -0
  8. package/assets/hud/codex.svg +1 -0
  9. package/assets/hud/session-viewer.html +35 -0
  10. package/dist/city/chaos-to-clarity-pencil.html +582 -0
  11. package/dist/city/echo-ai-city-only.html +1126 -109
  12. package/dist/city/echo-ai-city-only.template.html +1126 -109
  13. package/dist/city/echo-face-cutout.png +0 -0
  14. package/dist/city/pencil-pie-generator.html +883 -0
  15. package/dist/city/pencil-webgl-landscape.html +1239 -0
  16. package/dist/city/spatial-fan-story.html +479 -0
  17. package/dist/codex-session-files.js +283 -0
  18. package/dist/codex-sync.js +7 -2
  19. package/dist/context-analysis/canonical-golden.js +47 -0
  20. package/dist/context-analysis/claude-native-canonical.js +1193 -0
  21. package/dist/context-analysis/vendored-canonical.js +793 -0
  22. package/dist/context-analysis/workspace-report.js +1838 -0
  23. package/dist/context-metrics/calculate.js +56 -0
  24. package/dist/context-metrics/model-limits.js +26 -0
  25. package/dist/context-metrics/types.js +1 -0
  26. package/dist/forensics-10-problems.js +7 -6
  27. package/dist/forensics.js +863 -132
  28. package/dist/hud/adapters.js +8 -4
  29. package/dist/hud/autostart.js +66 -0
  30. package/dist/hud/cli.js +31 -0
  31. package/dist/hud/electron-main.js +182 -19
  32. package/dist/hud/metric.js +13 -4
  33. package/dist/hud/monitor.js +171 -84
  34. package/dist/hud/preload.cjs +3 -0
  35. package/dist/hud/server.js +321 -4
  36. package/dist/hud/web.js +880 -270
  37. package/dist/index.js +122 -24
  38. package/dist/local-data-paths.js +87 -0
  39. package/dist/migrate.js +55 -29
  40. package/dist/report.js +101 -40
  41. package/dist/setup-page.js +4257 -245
  42. package/dist/setup-preview.js +245 -0
  43. package/dist/setup.js +786 -75
  44. package/dist/v1-contract.js +20 -2
  45. package/package.json +6 -4
  46. package/templates/echomem-recall.md +2 -2
package/dist/setup.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `echomem-mcp setup | login | unlock | status | logout` — onboarding for the local bridge (spec §8).
2
+ * `echomem-mcp init | setup | login | unlock | status | logout` — onboarding for the local bridge (spec §8).
3
3
  *
4
4
  * Design goals from the spec:
5
5
  * - One command → one browser approval → one reload.
@@ -20,7 +20,7 @@ import fs from "node:fs";
20
20
  import os from "node:os";
21
21
  import path from "node:path";
22
22
  import readline from "node:readline";
23
- import { fileURLToPath } from "node:url";
23
+ import { fileURLToPath, pathToFileURL } from "node:url";
24
24
  import axios from "axios";
25
25
  import { KeyStore } from "./keystore.js";
26
26
  import { fetchEncryptionConfig, deriveAndVerifyKey, verifyKeyB64 } from "./encryption.js";
@@ -28,7 +28,8 @@ import { collect, runReport, buildStatsPayload } from "./report.js";
28
28
  import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableFastDiscovery, discoverMigratableSessions, discoverPendingSessionsTargeted, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, MIGRATE_CONCURRENCY, } from "./migrate.js";
29
29
  import { syncCodexUsage } from "./codex-sync.js";
30
30
  import { renderSetupPage } from "./setup-page.js";
31
- import { repoLabel } from "./forensics.js";
31
+ import { parseSetupPreviewState } from "./setup-preview.js";
32
+ import { repoLabel, validateForensicReportForSetup } from "./forensics.js";
32
33
  import { installHooks } from "./hud/hooks.js";
33
34
  import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
34
35
  import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
@@ -40,6 +41,50 @@ const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.
40
41
  function home(...p) {
41
42
  return path.join(os.homedir(), ...p);
42
43
  }
44
+ /** Map a source entry to its compiled sibling without ever guessing outside this package. */
45
+ export function compiledDistPathForSource(entry) {
46
+ if (!entry.endsWith(".ts") || !path.isAbsolute(entry))
47
+ return null;
48
+ const normalized = path.normalize(entry);
49
+ const srcSegment = `${path.sep}src${path.sep}`;
50
+ const srcIndex = normalized.lastIndexOf(srcSegment);
51
+ if (srcIndex < 0)
52
+ return null;
53
+ const packageRoot = normalized.slice(0, srcIndex) || path.parse(normalized).root;
54
+ const relativeSource = normalized.slice(srcIndex + srcSegment.length);
55
+ const candidate = path.join(packageRoot, "dist", relativeSource.replace(/\.ts$/, ".js"));
56
+ try {
57
+ const real = fs.realpathSync(candidate);
58
+ if (!fs.statSync(real).isFile())
59
+ return null;
60
+ fs.accessSync(real, fs.constants.R_OK);
61
+ return real;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ function runtimeModuleUrl(name) {
68
+ const jsUrl = new URL(`./${name}.js`, import.meta.url);
69
+ if (jsUrl.protocol !== "file:")
70
+ return jsUrl.href;
71
+ const jsPath = fileURLToPath(jsUrl);
72
+ try {
73
+ if (fs.statSync(jsPath).isFile())
74
+ return jsUrl.href;
75
+ }
76
+ catch {
77
+ /* Source-mode runs do not have an adjacent .js file. */
78
+ }
79
+ const tsPath = fileURLToPath(new URL(`./${name}.ts`, import.meta.url));
80
+ const compiled = compiledDistPathForSource(tsPath);
81
+ if (compiled)
82
+ return pathToFileURL(compiled).href;
83
+ if (fs.existsSync(tsPath)) {
84
+ throw new Error(`The local ${name} worker is not built. Run npm --prefix packages/mcp-server run build and retry.`);
85
+ }
86
+ return jsUrl.href;
87
+ }
43
88
  /** Known clients and where their MCP server map lives. */
44
89
  export function knownClients() {
45
90
  const appSupport = process.platform === "darwin"
@@ -81,9 +126,15 @@ export function buildServerEntry(opts = {}) {
81
126
  // Trade-off: the node path is version-specific under nvm — re-run `setup` after a Node upgrade.
82
127
  try {
83
128
  const entry = fs.realpathSync(process.argv[1] || "");
84
- if (entry && fs.existsSync(entry)) {
85
- return { command: process.execPath, args: [entry] };
129
+ if (entry && fs.existsSync(entry) && !isEphemeralNpxPath(entry)) {
130
+ return { command: process.execPath, args: [compiledDistPathForSource(entry) ?? entry] };
86
131
  }
132
+ // We're running from an EPHEMERAL npx cache (`npx @echomem/mcp …` with no global install). npx GCs
133
+ // those `_npx/<hash>` dirs, so pinning a client to this path works until the next cleanup, then the
134
+ // MCP server silently vanishes. Never write it — prefer a durable global install of the same package.
135
+ const globalEntry = resolveGlobalEntry();
136
+ if (globalEntry)
137
+ return { command: process.execPath, args: [globalEntry] };
87
138
  }
88
139
  catch {
89
140
  /* couldn't resolve a local install — fall through to npx */
@@ -91,6 +142,33 @@ export function buildServerEntry(opts = {}) {
91
142
  // Fallback (unresolved local install): at least drop `-y` so npx doesn't auto-INSTALL on every start.
92
143
  return { command: "npx", args: ["@echomem/mcp"] };
93
144
  }
145
+ /** True when a resolved entry lives inside npx's throwaway cache (`…/_npx/<hash>/…`). */
146
+ function isEphemeralNpxPath(entry) {
147
+ return entry.split(path.sep).includes("_npx");
148
+ }
149
+ /**
150
+ * Locate a DURABLE global install of the bridge (the one `npm i -g @echomem/mcp` creates). Global
151
+ * modules sit next to the running node — `<node>/../lib/node_modules` (nvm/unix) or `<node>/node_modules`
152
+ * (Windows). Returns the realpath'd dist entry, or null when the package isn't globally installed.
153
+ */
154
+ function resolveGlobalEntry() {
155
+ const nodeDir = path.dirname(process.execPath);
156
+ const pkgParts = MCP_PACKAGE_NAME.split("/"); // ["@echomem", "mcp"]
157
+ const candidates = [
158
+ path.join(nodeDir, "..", "lib", "node_modules", ...pkgParts, "dist", "index.js"),
159
+ path.join(nodeDir, "node_modules", ...pkgParts, "dist", "index.js"),
160
+ ];
161
+ for (const candidate of candidates) {
162
+ try {
163
+ if (fs.existsSync(candidate))
164
+ return fs.realpathSync(candidate);
165
+ }
166
+ catch {
167
+ /* keep trying */
168
+ }
169
+ }
170
+ return null;
171
+ }
94
172
  /** The TOML block EchoMem adds to ~/.codex/config.toml. No secret — the bridge reads the keystore. */
95
173
  export function codexTomlBlock(entry) {
96
174
  const command = JSON.stringify(String(entry.command));
@@ -129,6 +207,51 @@ export function writeCodexConfig(configPath, entry) {
129
207
  fs.appendFileSync(configPath, sep + block + "\n");
130
208
  return "wrote";
131
209
  }
210
+ /**
211
+ * Write the EchoMem guidance block into the agent's GLOBAL memory file (~/.codex/AGENTS.md,
212
+ * ~/.claude/CLAUDE.md) so the agent treats EchoMem as its core memory tool without the tool
213
+ * descriptions having to over-explain "when to call me". Marker-fenced + idempotent: only our
214
+ * block is ever replaced; the user's own content is never touched. Project-level files are
215
+ * deliberately NOT written — those belong to the user.
216
+ */
217
+ const AGENTS_MD_BEGIN = "<!-- echomem:begin -->";
218
+ const AGENTS_MD_END = "<!-- echomem:end -->";
219
+ function echomemGuidanceBlock() {
220
+ return [
221
+ AGENTS_MD_BEGIN,
222
+ "## Memory (EchoMem)",
223
+ "EchoMem is your long-term memory across all coding sessions and tools.",
224
+ "- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
225
+ "- When meaningful work wraps up (a decision, a fix, a milestone) or the user asks to remember something: call `save_conversation`.",
226
+ "- 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.",
227
+ "- If the user asks to show, reopen, restart, or bring back the EchoMem HUD (the context-health overlay), run the shell command `echomem-hud app --client auto`.",
228
+ "- If the user wants the HUD to come back after a computer restart, run the shell command `echomem-hud autostart on --client auto`.",
229
+ AGENTS_MD_END,
230
+ ].join("\n");
231
+ }
232
+ export function writeAgentsMemoryGuidance(filePath) {
233
+ let content = "";
234
+ try {
235
+ content = fs.readFileSync(filePath, "utf8");
236
+ }
237
+ catch {
238
+ /* fresh file */
239
+ }
240
+ const block = echomemGuidanceBlock();
241
+ const start = content.indexOf(AGENTS_MD_BEGIN);
242
+ const end = content.indexOf(AGENTS_MD_END);
243
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
244
+ if (start >= 0 && end > start) {
245
+ const current = content.slice(start, end + AGENTS_MD_END.length);
246
+ if (current === block)
247
+ return "exists";
248
+ fs.writeFileSync(filePath, content.slice(0, start) + block + content.slice(end + AGENTS_MD_END.length));
249
+ return "updated";
250
+ }
251
+ const sep = content ? (content.endsWith("\n") ? "\n" : "\n\n") : "";
252
+ fs.appendFileSync(filePath, sep + block + "\n");
253
+ return "wrote";
254
+ }
132
255
  /** Merge the EchoMem entry into a JSON client's `mcpServers` map without clobbering siblings. */
133
256
  export function writeJsonClientConfig(configPath, entry) {
134
257
  let config = {};
@@ -275,8 +398,8 @@ export function resolveServerEntryVersion(entry) {
275
398
  }
276
399
  const candidates = [...args, command].filter((value) => typeof value === "string");
277
400
  for (const candidate of candidates) {
278
- if (!candidate.includes(MCP_PACKAGE_NAME) && !candidate.includes(`${path.sep}echomem-mcp`))
279
- continue;
401
+ // Direct local/dev entries often look like ".../packages/mcp-server/dist/index.js";
402
+ // they do not contain the published package name, but walking upward still finds package.json.
280
403
  const resolved = packageVersionFromPath(candidate);
281
404
  if (resolved)
282
405
  return resolved;
@@ -408,11 +531,111 @@ function openBrowser(url) {
408
531
  /* headless — caller prints the URL */
409
532
  }
410
533
  }
534
+ function openClaudeDesktop() {
535
+ if (process.platform !== "darwin") {
536
+ return { ok: false, message: "Could not auto-open Claude on this system. The prompt is copied - open Claude Desktop and paste it." };
537
+ }
538
+ const installedPath = firstExisting([
539
+ "/Applications/Claude.app",
540
+ path.join(os.homedir(), "Applications", "Claude.app"),
541
+ ]);
542
+ const attempts = [
543
+ ["-b", "com.anthropic.claudefordesktop"],
544
+ ["-a", "Claude"],
545
+ ...(installedPath ? [[installedPath]] : []),
546
+ ];
547
+ const failures = [];
548
+ for (const args of attempts) {
549
+ try {
550
+ execFileSync("open", args, { stdio: "pipe" });
551
+ return { ok: true, message: "Opened Claude Desktop. The prompt is copied - paste it into Claude." };
552
+ }
553
+ catch (error) {
554
+ failures.push(`${args.join(" ")}: ${commandFailureMessage(error)}`);
555
+ }
556
+ }
557
+ return {
558
+ ok: false,
559
+ message: installedPath
560
+ ? "Could not auto-open Claude Desktop. The prompt is copied - open Claude Desktop and paste it."
561
+ : "Claude Desktop was not found by macOS. The prompt is copied - open Claude manually and paste it.",
562
+ detail: failures.join(" | "),
563
+ };
564
+ }
565
+ 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;
566
+ // `claude --resume <id>` only finds sessions that belong to the current project directory, so
567
+ // recover the session's original cwd from its transcript before resuming.
568
+ function claudeSessionCwd(sessionId) {
569
+ try {
570
+ const projectsDir = path.join(os.homedir(), ".claude", "projects");
571
+ for (const dir of fs.readdirSync(projectsDir)) {
572
+ const file = path.join(projectsDir, dir, `${sessionId}.jsonl`);
573
+ if (!fs.existsSync(file))
574
+ continue;
575
+ const fd = fs.openSync(file, "r");
576
+ try {
577
+ const head = Buffer.alloc(65536);
578
+ const read = fs.readSync(fd, head, 0, head.length, 0);
579
+ const match = head.toString("utf8", 0, read).match(/"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/);
580
+ return match ? JSON.parse(`"${match[1]}"`) : null;
581
+ }
582
+ finally {
583
+ fs.closeSync(fd);
584
+ }
585
+ }
586
+ }
587
+ catch {
588
+ /* fall through to a plain resume */
589
+ }
590
+ return null;
591
+ }
592
+ function shellQuote(value) {
593
+ return `'${value.replace(/'/g, `'\\''`)}'`;
594
+ }
595
+ function openExistingAgentSession(source, sessionId) {
596
+ if (process.platform !== "darwin")
597
+ return { ok: false, message: "Opening local agent sessions is currently available on macOS." };
598
+ if (!LOCAL_SESSION_ID_RE.test(sessionId))
599
+ return { ok: false, message: "The local session identifier is invalid." };
600
+ try {
601
+ if (source === "codex") {
602
+ execFileSync("open", [`codex://threads/${sessionId}`], { stdio: "pipe" });
603
+ return { ok: true, message: "Opened the original session in Codex." };
604
+ }
605
+ if (source === "claude-code") {
606
+ const claude = firstExisting(["/opt/homebrew/bin/claude", "/usr/local/bin/claude"]) || "claude";
607
+ const cwd = claudeSessionCwd(sessionId);
608
+ const resume = `${claude} --resume ${sessionId}`;
609
+ const command = cwd && fs.existsSync(cwd) ? `cd ${shellQuote(cwd)} && ${resume}` : resume;
610
+ execFileSync("osascript", [
611
+ "-e", "tell application \"Terminal\" to activate",
612
+ "-e", `tell application \"Terminal\" to do script ${JSON.stringify(command)}`,
613
+ ], { stdio: "pipe" });
614
+ return { ok: true, message: "Opened the original Claude Code session in Terminal." };
615
+ }
616
+ return { ok: false, message: "Unsupported agent session source." };
617
+ }
618
+ catch (error) {
619
+ return { ok: false, message: "Could not open the original local session.", detail: commandFailureMessage(error) };
620
+ }
621
+ }
622
+ function commandFailureMessage(error) {
623
+ const maybe = error;
624
+ if (Buffer.isBuffer(maybe.stderr)) {
625
+ const stderr = maybe.stderr.toString("utf8").trim();
626
+ if (stderr)
627
+ return stderr;
628
+ }
629
+ return error instanceof Error ? error.message : String(error);
630
+ }
411
631
  function migratableFromDiscovery(disc) {
412
632
  const eta = estimateMigrationEta(disc.pending, disc.skippedActive);
633
+ const pendingCodex = disc.pendingCodex ?? disc.pending.filter((s) => s.source === "codex").length;
413
634
  return {
414
635
  pending: disc.pending.length,
415
636
  pendingTotal: disc.pendingTotal,
637
+ pendingCodex,
638
+ pendingClaudeCode: disc.pendingClaudeCode ?? disc.pending.length - pendingCodex,
416
639
  alreadyMigrated: disc.alreadyMigrated,
417
640
  skippedActive: disc.skippedActive,
418
641
  limited: disc.limited,
@@ -438,6 +661,8 @@ function migratableFromFastSummary(summary) {
438
661
  return {
439
662
  pending: summary.pending,
440
663
  pendingTotal: summary.pendingTotal,
664
+ pendingCodex: summary.pendingCodex,
665
+ pendingClaudeCode: summary.pendingClaudeCode,
441
666
  alreadyMigrated: summary.alreadyMigrated,
442
667
  skippedActive: summary.skippedActive,
443
668
  eta: summary.eta,
@@ -557,8 +782,109 @@ function serveRepoCityAsset(reqPath, res) {
557
782
  fs.createReadStream(filePath).pipe(res);
558
783
  return true;
559
784
  }
785
+ function hudAssetsRoot() {
786
+ return fileURLToPath(new URL("../assets/hud/", import.meta.url));
787
+ }
788
+ function serveHudAsset(reqPath, res) {
789
+ const root = hudAssetsRoot();
790
+ const rel = decodeURIComponent(reqPath.slice("/hud-assets/".length));
791
+ const filePath = path.resolve(root, rel);
792
+ const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
793
+ if (!filePath.startsWith(rootWithSep)) {
794
+ res.writeHead(403).end("forbidden");
795
+ return true;
796
+ }
797
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
798
+ res.writeHead(404).end("not found");
799
+ return true;
800
+ }
801
+ res.writeHead(200, {
802
+ "Content-Type": CITY_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
803
+ "Cache-Control": "no-store",
804
+ });
805
+ fs.createReadStream(filePath).pipe(res);
806
+ return true;
807
+ }
808
+ function firstExisting(paths) {
809
+ for (const candidate of paths) {
810
+ if (fs.existsSync(candidate))
811
+ return candidate;
812
+ }
813
+ return null;
814
+ }
815
+ function appResourcesPath(appName) {
816
+ const app = firstExisting([
817
+ `/Applications/${appName}.app`,
818
+ path.join(os.homedir(), "Applications", `${appName}.app`),
819
+ ]);
820
+ return app ? path.join(app, "Contents", "Resources") : null;
821
+ }
822
+ function agentIconSource(id) {
823
+ const resources = appResourcesPath(id === "codex" ? "Codex" : "Claude");
824
+ if (!resources)
825
+ return null;
826
+ if (id === "codex") {
827
+ return firstExisting([
828
+ path.join(resources, "icon.png"),
829
+ path.join(resources, "icon-codex-dark-color.png"),
830
+ path.join(resources, "icon-codex-light.png"),
831
+ path.join(resources, "icon.icns"),
832
+ path.join(resources, "app.icns"),
833
+ path.join(resources, "electron.icns"),
834
+ ]);
835
+ }
836
+ return firstExisting([
837
+ path.join(resources, "icon.png"),
838
+ path.join(resources, "app.png"),
839
+ path.join(resources, "icon.icns"),
840
+ path.join(resources, "electron.icns"),
841
+ ]);
842
+ }
843
+ function convertedAgentIconPath(id, sourcePath) {
844
+ if (path.extname(sourcePath).toLowerCase() === ".png")
845
+ return sourcePath;
846
+ if (process.platform !== "darwin" || path.extname(sourcePath).toLowerCase() !== ".icns")
847
+ return null;
848
+ const stat = fs.statSync(sourcePath);
849
+ const cacheDir = path.join(os.homedir(), ".echomem", "cache", "agent-icons");
850
+ fs.mkdirSync(cacheDir, { recursive: true });
851
+ const out = path.join(cacheDir, `${id}-${Math.round(stat.mtimeMs)}-${stat.size}.png`);
852
+ if (fs.existsSync(out))
853
+ return out;
854
+ try {
855
+ execFileSync("sips", ["-s", "format", "png", sourcePath, "--out", out], { stdio: "ignore" });
856
+ return fs.existsSync(out) ? out : null;
857
+ }
858
+ catch {
859
+ try {
860
+ fs.rmSync(out, { force: true });
861
+ }
862
+ catch { }
863
+ return null;
864
+ }
865
+ }
866
+ function serveAgentIcon(reqPath, res) {
867
+ const raw = decodeURIComponent(reqPath.slice("/agent-icons/".length)).replace(/\.png$/i, "");
868
+ const id = raw === "codex" || raw === "claude-desktop" ? raw : null;
869
+ if (!id) {
870
+ res.writeHead(404).end("not found");
871
+ return true;
872
+ }
873
+ const source = agentIconSource(id);
874
+ const filePath = source ? convertedAgentIconPath(id, source) : null;
875
+ if (!filePath) {
876
+ res.writeHead(404).end("not found");
877
+ return true;
878
+ }
879
+ res.writeHead(200, {
880
+ "Content-Type": "image/png",
881
+ "Cache-Control": "no-store",
882
+ });
883
+ fs.createReadStream(filePath).pipe(res);
884
+ return true;
885
+ }
560
886
  function discoverMigratableSessionsOffThread() {
561
- const migrateUrl = new URL("./migrate.js", import.meta.url).href;
887
+ const migrateUrl = runtimeModuleUrl("migrate");
562
888
  const code = `
563
889
  import { parentPort } from "node:worker_threads";
564
890
  import { discoverMigratableSessions } from ${JSON.stringify(migrateUrl)};
@@ -604,15 +930,29 @@ function discoverMigratableSessionsOffThread() {
604
930
  });
605
931
  });
606
932
  }
933
+ function forensicStageLabel(stage) {
934
+ if (stage === "reading-transcripts")
935
+ return "Reading transcript files";
936
+ if (stage === "building-summary")
937
+ return "Building scan summary";
938
+ if (stage === "classifying-repeated-context")
939
+ return "Classifying repeated context";
940
+ if (stage === "finalizing-report")
941
+ return "Finalizing report";
942
+ return "Starting local scan";
943
+ }
607
944
  /** Build the local forensic "Context Doctor" report on a worker thread so the multi-file scan never
608
945
  * blocks the bridge's event loop (the freeze postmortem: any unbounded sync work on this path is risky). */
609
- function buildForensicReportOffThread(onProgress) {
610
- const forensicsUrl = new URL("./forensics.js", import.meta.url).href;
946
+ export function buildForensicReportOffThread(onProgress, options = {}) {
947
+ const forensicsUrl = runtimeModuleUrl("forensics");
611
948
  const code = `
612
949
  import { parentPort } from "node:worker_threads";
613
950
  import { buildForensicReport } from ${JSON.stringify(forensicsUrl)};
614
951
  try {
615
- const report = buildForensicReport({ onProgress: (done, total) => parentPort?.postMessage({ progress: { done, total } }) });
952
+ const report = await buildForensicReport({
953
+ includeLegacyGoldenStandard: false,
954
+ onProgress: (done, total, stage, detail) => parentPort?.postMessage({ progress: { done, total, stage, detail } }),
955
+ });
616
956
  parentPort?.postMessage({ ok: true, report });
617
957
  } catch (error) {
618
958
  parentPort?.postMessage({ ok: false, message: error instanceof Error ? error.message : String(error) });
@@ -621,30 +961,50 @@ function buildForensicReportOffThread(onProgress) {
621
961
  const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
622
962
  return new Promise((resolve, reject) => {
623
963
  let settled = false;
964
+ const requestedTimeoutMs = options.timeoutMs ?? 15 * 60_000;
965
+ const timeoutMs = Number.isFinite(requestedTimeoutMs) ? Math.max(1, requestedTimeoutMs) : 15 * 60_000;
966
+ const timeout = setTimeout(() => {
967
+ if (settled)
968
+ return;
969
+ settled = true;
970
+ void worker.terminate();
971
+ const error = new Error(`Local forensic report timed out after ${timeoutMs}ms`);
972
+ error.code = "REPORT_SCAN_TIMEOUT";
973
+ reject(error);
974
+ }, timeoutMs);
975
+ timeout.unref?.();
976
+ const finish = (result) => {
977
+ if (settled)
978
+ return;
979
+ settled = true;
980
+ clearTimeout(timeout);
981
+ void worker.terminate();
982
+ if (result.ok)
983
+ resolve(result.report);
984
+ else
985
+ reject(result.error);
986
+ };
624
987
  worker.on("message", (message) => {
988
+ if (settled)
989
+ return;
625
990
  const msg = message;
626
991
  if (msg.progress) {
627
- onProgress?.(msg.progress.done, msg.progress.total);
992
+ onProgress?.(msg.progress);
628
993
  return;
629
994
  }
630
- settled = true;
631
- if (msg.ok === true && msg.report && typeof msg.report === "object")
632
- resolve(msg.report);
633
- else
634
- reject(new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed"));
635
- void worker.terminate();
995
+ if (msg.ok === true && msg.report && typeof msg.report === "object") {
996
+ finish({ ok: true, report: msg.report });
997
+ return;
998
+ }
999
+ finish({ ok: false, error: new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed") });
636
1000
  });
637
1001
  worker.once("error", (error) => {
638
- if (settled)
639
- return;
640
- settled = true;
641
- reject(error);
1002
+ finish({ ok: false, error });
642
1003
  });
643
1004
  worker.once("exit", (code) => {
644
1005
  if (settled)
645
1006
  return;
646
- settled = true;
647
- reject(new Error(`Forensic report worker exited (code ${code}) without a result`));
1007
+ finish({ ok: false, error: new Error(`Forensic report worker exited (code ${code}) without a result`) });
648
1008
  });
649
1009
  });
650
1010
  }
@@ -653,6 +1013,49 @@ export function respondMigrate(res, body, status = 200) {
653
1013
  return;
654
1014
  res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
655
1015
  }
1016
+ function safeForensicError(error) {
1017
+ const code = error && typeof error === "object" && "code" in error
1018
+ ? String(error.code || "")
1019
+ : "";
1020
+ if (code === "REPORT_SCAN_TIMEOUT") {
1021
+ return {
1022
+ code,
1023
+ message: "The local workspace scan took too long and was stopped. No backup data was substituted. Rerun setup to retry.",
1024
+ };
1025
+ }
1026
+ return {
1027
+ code: "REPORT_BUILD_FAILED",
1028
+ message: "EchoMem could not finish the local workspace scan. No backup data was substituted. Rerun setup to retry.",
1029
+ };
1030
+ }
1031
+ function publicRunningForensicProgress(value) {
1032
+ if (!value || typeof value !== "object")
1033
+ return null;
1034
+ const progress = value;
1035
+ if (progress.status !== "running")
1036
+ return null;
1037
+ const safeCount = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
1038
+ ? Math.floor(candidate)
1039
+ : 0);
1040
+ const safeDuration = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
1041
+ ? candidate
1042
+ : 0);
1043
+ const total = safeCount(progress.total);
1044
+ const rawStage = typeof progress.stage === "string" ? progress.stage : "starting";
1045
+ const stage = ["starting", "reading-transcripts", "building-summary", "classifying-repeated-context", "finalizing-report"].includes(rawStage)
1046
+ ? rawStage
1047
+ : "starting";
1048
+ return {
1049
+ status: "running",
1050
+ scanned: total > 0 ? Math.min(safeCount(progress.scanned), total) : 0,
1051
+ total,
1052
+ stage,
1053
+ label: forensicStageLabel(stage),
1054
+ elapsedMs: safeDuration(progress.elapsedMs),
1055
+ stageElapsedMs: safeDuration(progress.stageElapsedMs),
1056
+ updatedAt: safeDuration(progress.updatedAt) || Date.now(),
1057
+ };
1058
+ }
656
1059
  /**
657
1060
  * Start the persistent localhost bridge used by the connect-device page. It accepts the token,
658
1061
  * serves local Wrapped stats, and holds the /migrate response until cmdLogin has created a cloud
@@ -662,6 +1065,7 @@ export function startCallbackServer(opts = {}) {
662
1065
  const timeoutMs = opts.timeoutMs ?? 300_000;
663
1066
  const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
664
1067
  const expectedNonce = opts.nonce;
1068
+ const scanId = opts.scanId ?? randomUUID();
665
1069
  return new Promise((resolveOuter, rejectOuter) => {
666
1070
  const onToken = deferred();
667
1071
  const decision = deferred();
@@ -677,6 +1081,10 @@ export function startCallbackServer(opts = {}) {
677
1081
  let timer;
678
1082
  let closed = false;
679
1083
  let server;
1084
+ // The browser setup page holds a keep-alive socket (and polls /progress). server.close() only stops
1085
+ // accepting NEW connections and waits for existing ones to end — so without destroying these the
1086
+ // handle never releases and the CLI hangs after migration. Track live sockets and kill them on close.
1087
+ const sockets = new Set();
680
1088
  const checkNonce = (nonce) => !expectedNonce || nonce === expectedNonce;
681
1089
  const text = (res, status, body = "") => res.writeHead(status, { "Content-Type": "text/plain" }).end(body);
682
1090
  const json = (res, status, body) => res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
@@ -688,6 +1096,9 @@ export function startCallbackServer(opts = {}) {
688
1096
  return;
689
1097
  closed = true;
690
1098
  server.close();
1099
+ for (const socket of sockets)
1100
+ socket.destroy();
1101
+ sockets.clear();
691
1102
  };
692
1103
  const armTimeout = () => {
693
1104
  if (timer)
@@ -739,17 +1150,69 @@ export function startCallbackServer(opts = {}) {
739
1150
  serveRepoCityAsset(route, res);
740
1151
  return;
741
1152
  }
1153
+ if (route.startsWith("/hud-assets/") && req.method === "GET") {
1154
+ serveHudAsset(route, res);
1155
+ return;
1156
+ }
1157
+ if (route.startsWith("/agent-icons/") && req.method === "GET") {
1158
+ serveAgentIcon(route, res);
1159
+ return;
1160
+ }
742
1161
  if (route === "/setup" && req.method === "GET") {
1162
+ res.setHeader("Cache-Control", "no-store");
1163
+ if (!checkNonce(url.searchParams.get("nonce") || undefined))
1164
+ return void text(res, 403, "bad nonce");
1165
+ const hasPreview = url.searchParams.has("preview");
1166
+ if (hasPreview && opts.allowPreview !== true) {
1167
+ return void text(res, 403, "Design preview is disabled. No sample data was shown.");
1168
+ }
1169
+ const previewState = hasPreview ? parseSetupPreviewState(url.searchParams.get("preview")) : null;
1170
+ if (hasPreview && !previewState) {
1171
+ return void text(res, 400, "Unknown setup preview state.");
1172
+ }
743
1173
  res.writeHead(200, {
744
1174
  "Content-Type": "text/html; charset=utf-8",
745
1175
  "Cache-Control": "no-store",
746
- }).end(renderSetupPage());
1176
+ }).end(renderSetupPage(previewState ? { previewState } : undefined));
747
1177
  return;
748
1178
  }
749
1179
  if (route === "/config" && req.method === "GET") {
750
1180
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
751
1181
  return void text(res, 403, "bad nonce");
752
- json(res, 200, { connected, authUrl, switchAccountUrl: switchAccountUrl || authUrl, localOnly: true });
1182
+ json(res, 200, { connected, authUrl, switchAccountUrl: switchAccountUrl || authUrl, localOnly: true, workspacePath: process.cwd() });
1183
+ return;
1184
+ }
1185
+ if (route === "/launch-agent" && req.method === "POST") {
1186
+ let body;
1187
+ try {
1188
+ body = await readJsonBody(req);
1189
+ }
1190
+ catch {
1191
+ text(res, 400, "bad json");
1192
+ return;
1193
+ }
1194
+ if (!checkNonce(asString(body.nonce)))
1195
+ return void text(res, 403, "bad nonce");
1196
+ const agent = asString(body.agent);
1197
+ if (agent !== "claude-desktop")
1198
+ return void json(res, 400, { ok: false, message: "Unsupported agent." });
1199
+ const result = openClaudeDesktop();
1200
+ json(res, result.ok ? 200 : 501, result);
1201
+ return;
1202
+ }
1203
+ if (route === "/open-session" && req.method === "POST") {
1204
+ let body;
1205
+ try {
1206
+ body = await readJsonBody(req);
1207
+ }
1208
+ catch {
1209
+ text(res, 400, "bad json");
1210
+ return;
1211
+ }
1212
+ if (!checkNonce(asString(body.nonce)))
1213
+ return void text(res, 403, "bad nonce");
1214
+ const result = openExistingAgentSession(asString(body.source) || "", asString(body.sessionId) || "");
1215
+ json(res, result.ok ? 200 : 400, result);
753
1216
  return;
754
1217
  }
755
1218
  if (route === "/callback" && req.method === "GET") {
@@ -779,15 +1242,99 @@ export function startCallbackServer(opts = {}) {
779
1242
  }
780
1243
  if (route === "/report" && req.method === "GET") {
781
1244
  // Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
1245
+ res.setHeader("Cache-Control", "no-store");
782
1246
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
783
1247
  return void text(res, 403, "bad nonce");
784
- const payload = opts.getReport ? opts.getReport() : null;
1248
+ let payload;
1249
+ try {
1250
+ payload = opts.getReport ? opts.getReport() : null;
1251
+ }
1252
+ catch {
1253
+ return void json(res, 500, {
1254
+ schemaVersion: 1,
1255
+ kind: "failed",
1256
+ mode: "production",
1257
+ scanId,
1258
+ error: {
1259
+ code: "REPORT_STATE_UNAVAILABLE",
1260
+ message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
1261
+ },
1262
+ });
1263
+ }
785
1264
  if (payload == null) {
786
1265
  // 202 carries scan progress so the page can show a live "scanned N/total" indicator.
787
- const prog = opts.getReportProgress ? opts.getReportProgress() : { scanned: 0, total: 0 };
788
- return void res.writeHead(202, { "Content-Type": "application/json" }).end(JSON.stringify(prog));
1266
+ let prog;
1267
+ try {
1268
+ prog = opts.getReportProgress ? opts.getReportProgress() : {
1269
+ status: "running",
1270
+ scanned: 0,
1271
+ total: 0,
1272
+ stage: "starting",
1273
+ label: "Starting local scan",
1274
+ elapsedMs: 0,
1275
+ stageElapsedMs: 0,
1276
+ updatedAt: Date.now(),
1277
+ };
1278
+ }
1279
+ catch {
1280
+ return void json(res, 500, {
1281
+ schemaVersion: 1,
1282
+ kind: "failed",
1283
+ mode: "production",
1284
+ scanId,
1285
+ error: {
1286
+ code: "REPORT_STATE_UNAVAILABLE",
1287
+ message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
1288
+ },
1289
+ });
1290
+ }
1291
+ if (prog && typeof prog === "object" && prog.status === "failed") {
1292
+ return void json(res, 500, {
1293
+ schemaVersion: 1,
1294
+ kind: "failed",
1295
+ mode: "production",
1296
+ scanId,
1297
+ error: safeForensicError(prog.error),
1298
+ });
1299
+ }
1300
+ const publicProgress = publicRunningForensicProgress(prog);
1301
+ if (!publicProgress) {
1302
+ return void json(res, 500, {
1303
+ schemaVersion: 1,
1304
+ kind: "failed",
1305
+ mode: "production",
1306
+ scanId,
1307
+ error: {
1308
+ code: "REPORT_STATE_INVALID",
1309
+ message: "EchoMem received an invalid local scan state. No backup data was substituted. Rerun setup to retry.",
1310
+ },
1311
+ });
1312
+ }
1313
+ return void json(res, 202, {
1314
+ schemaVersion: 1,
1315
+ kind: "scanning",
1316
+ mode: "production",
1317
+ scanId,
1318
+ progress: publicProgress,
1319
+ });
789
1320
  }
790
- json(res, 200, payload);
1321
+ const validation = validateForensicReportForSetup(payload);
1322
+ if (!validation.ok) {
1323
+ return void json(res, 500, {
1324
+ schemaVersion: 1,
1325
+ kind: "failed",
1326
+ mode: "production",
1327
+ scanId,
1328
+ error: { code: validation.code, message: validation.message },
1329
+ });
1330
+ }
1331
+ json(res, 200, {
1332
+ schemaVersion: 1,
1333
+ kind: validation.kind,
1334
+ mode: "production",
1335
+ scanId,
1336
+ report: validation.report,
1337
+ });
791
1338
  return;
792
1339
  }
793
1340
  if (route === "/progress" && req.method === "GET") {
@@ -874,9 +1421,13 @@ export function startCallbackServer(opts = {}) {
874
1421
  text(res, 500, e instanceof Error ? e.message : String(e));
875
1422
  });
876
1423
  });
1424
+ server.on("connection", (socket) => {
1425
+ sockets.add(socket);
1426
+ socket.on("close", () => sockets.delete(socket));
1427
+ });
877
1428
  armTimeout();
878
1429
  server.on("error", (e) => rejectOuter(e));
879
- server.listen(0, "127.0.0.1", () => {
1430
+ server.listen(opts.port ?? 0, "127.0.0.1", () => {
880
1431
  const addr = server.address();
881
1432
  const port = typeof addr === "object" && addr ? addr.port : 0;
882
1433
  resolveOuter({
@@ -1052,9 +1603,14 @@ async function cmdSetup(flags) {
1052
1603
  }
1053
1604
  }
1054
1605
  }
1606
+ if (!flags["no-agents-md"]) {
1607
+ writeMemoryGuidanceForTargets(targets);
1608
+ }
1055
1609
  console.log("");
1056
1610
  if (flags["skip-login"] || flags["no-login"]) {
1057
- console.log(`Skipped login; existing EchoMem credentials are unchanged. Current bridge: ${MCP_PACKAGE_LABEL}`);
1611
+ // init drives login itself right after, so the "skipped" note would be misleading there.
1612
+ if (!flags["init-quiet"])
1613
+ console.log(`Skipped login; existing EchoMem credentials are unchanged. Current bridge: ${MCP_PACKAGE_LABEL}`);
1058
1614
  }
1059
1615
  else {
1060
1616
  await cmdLogin(flags);
@@ -1062,6 +1618,63 @@ async function cmdSetup(flags) {
1062
1618
  if (flags["with-hud"])
1063
1619
  await cmdSetupHud(flags);
1064
1620
  }
1621
+ /**
1622
+ * `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
1623
+ * machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), writes the AGENTS.md
1624
+ * memory guidance, logs in via the browser, and launches the context HUD — the whole product in a
1625
+ * single command. `setup`/`update` remain the granular primitives; init just picks the "do everything"
1626
+ * defaults and frames the result.
1627
+ */
1628
+ async function cmdInit(flags) {
1629
+ console.log("Setting up EchoMem — shared memory for all your coding agents, plus the live context HUD.\n");
1630
+ // 1. Configure every installed agent + write AGENTS.md. Hold login + HUD so we control ordering.
1631
+ await cmdSetup({ ...flags, all: true, "skip-login": true, "with-hud": false, "init-quiet": true });
1632
+ // 2. Bring the HUD up NOW (non-blocking) so everything is already running while onboarding proceeds.
1633
+ if (!flags["no-hud"])
1634
+ await cmdSetupHud(flags);
1635
+ // 3. Start onboarding — opens the browser dashboard (scan → connect → extraction) and waits there.
1636
+ console.log("");
1637
+ if (!flags["skip-login"] && !flags["no-login"])
1638
+ await cmdLogin(flags);
1639
+ console.log("");
1640
+ console.log("🎉 EchoMem is ready.");
1641
+ console.log(" • MCP memory is configured for every coding agent installed on this machine.");
1642
+ if (!flags["no-hud"]) {
1643
+ console.log(' • The context HUD is running (top-right). Right-click it → "Show after restart" to keep it,');
1644
+ console.log(' or just tell your agent "open the EchoMem HUD" anytime (it runs: echomem-hud app).');
1645
+ }
1646
+ else {
1647
+ console.log(' • Start the context HUD anytime with: echomem-hud app');
1648
+ }
1649
+ console.log(' • Try it now: ask your agent — "search my EchoMem for what I\'ve been working on and recap it."');
1650
+ }
1651
+ /**
1652
+ * For each configured client, add the EchoMem guidance block to its GLOBAL memory file so the
1653
+ * agent knows to lean on EchoMem for recall/save. Global only (~/.codex/AGENTS.md,
1654
+ * ~/.claude/CLAUDE.md) — project files are the user's. Skip with --no-agents-md.
1655
+ */
1656
+ function writeMemoryGuidanceForTargets(targets) {
1657
+ const files = new Map(); // path → label
1658
+ for (const t of targets) {
1659
+ if (t.id === "codex")
1660
+ files.set(home(".codex", "AGENTS.md"), "Codex");
1661
+ if (t.id === "claude-code" || t.id === "claude-desktop")
1662
+ files.set(home(".claude", "CLAUDE.md"), "Claude");
1663
+ }
1664
+ for (const [file, label] of files) {
1665
+ try {
1666
+ const result = writeAgentsMemoryGuidance(file);
1667
+ if (result === "wrote")
1668
+ console.log(`✅ Added EchoMem memory guidance to ${label}'s global memory file: ${file}`);
1669
+ else if (result === "updated")
1670
+ console.log(`✅ Refreshed EchoMem memory guidance in ${file}`);
1671
+ // "exists" → silent; nothing changed.
1672
+ }
1673
+ catch (error) {
1674
+ console.log(`ℹ️ Could not write memory guidance to ${file}: ${error instanceof Error ? error.message : String(error)}`);
1675
+ }
1676
+ }
1677
+ }
1065
1678
  async function cmdUpdate(flags) {
1066
1679
  await cmdSetup({ ...flags, "skip-login": true });
1067
1680
  console.log(`Update config complete. Start a new MCP session to load ${MCP_PACKAGE_LABEL}.`);
@@ -1106,9 +1719,21 @@ async function cmdSetupHud(flags) {
1106
1719
  }
1107
1720
  function resolveHudCliPath() {
1108
1721
  const entry = fs.realpathSync(process.argv[1] || "");
1722
+ const compiledEntry = compiledDistPathForSource(entry);
1723
+ if (compiledEntry) {
1724
+ const compiledHud = path.join(path.dirname(compiledEntry), "hud", "cli.js");
1725
+ if (fs.existsSync(compiledHud))
1726
+ return compiledHud;
1727
+ }
1109
1728
  const base = path.dirname(entry);
1110
1729
  const candidate = path.join(base, "hud", "cli.js");
1111
- return fs.existsSync(candidate) ? candidate : path.join(base, "hud", "cli.ts");
1730
+ if (fs.existsSync(candidate))
1731
+ return candidate;
1732
+ const sourceCandidate = path.join(base, "hud", "cli.ts");
1733
+ if (fs.existsSync(sourceCandidate)) {
1734
+ throw new Error("The local HUD CLI is not built. Run npm --prefix packages/mcp-server run build and retry.");
1735
+ }
1736
+ return candidate;
1112
1737
  }
1113
1738
  function parseHudClient(value) {
1114
1739
  return value === "codex" || value === "claude-code" || value === "claude-desktop" || value === "both" || value === "auto"
@@ -1130,11 +1755,34 @@ async function cmdLogin(flags) {
1130
1755
  // Browser path: open a localhost dashboard. It briefly leaves for hosted auth, then returns here
1131
1756
  // after the web page has delivered the token+key to the callback. The nonce gates every local route.
1132
1757
  console.log("Opening your browser to approve this device…");
1133
- const nonce = randomUUID();
1758
+ const devPortRaw = typeof flags["dev-port"] === "string" ? Number(flags["dev-port"]) : undefined;
1759
+ if (devPortRaw !== undefined && (!Number.isInteger(devPortRaw) || devPortRaw < 1024 || devPortRaw > 65535)) {
1760
+ throw new Error("--dev-port must be an integer between 1024 and 65535");
1761
+ }
1762
+ const devNonce = typeof flags["dev-nonce"] === "string" ? flags["dev-nonce"].trim() : undefined;
1763
+ if (devNonce && devPortRaw === undefined)
1764
+ throw new Error("--dev-nonce requires --dev-port");
1765
+ if (devNonce && !/^[A-Za-z0-9-]{16,128}$/.test(devNonce)) {
1766
+ throw new Error("--dev-nonce must contain 16-128 letters, numbers, or hyphens");
1767
+ }
1768
+ const nonce = devNonce || randomUUID();
1134
1769
  let stats = null;
1135
1770
  let forensicReport = null;
1136
- let forensicProgress = { scanned: 0, total: 0 };
1771
+ const forensicStartedAt = Date.now();
1772
+ let forensicStageStartedAt = forensicStartedAt;
1773
+ let forensicStage = "starting";
1774
+ let forensicProgress = {
1775
+ status: "running",
1776
+ scanned: 0,
1777
+ total: 0,
1778
+ stage: forensicStage,
1779
+ label: forensicStageLabel(forensicStage),
1780
+ elapsedMs: 0,
1781
+ stageElapsedMs: 0,
1782
+ updatedAt: forensicStartedAt,
1783
+ };
1137
1784
  const srv = await startCallbackServer({
1785
+ port: devPortRaw,
1138
1786
  nonce,
1139
1787
  getStats: () => stats,
1140
1788
  getReport: () => forensicReport,
@@ -1153,13 +1801,42 @@ async function cmdLogin(flags) {
1153
1801
  console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
1154
1802
  // Scan-first: build the local forensic "Context Doctor" report off-thread so the page shows it
1155
1803
  // BEFORE the user connects an account (the scan is local-only; nothing leaves the machine).
1156
- buildForensicReportOffThread((done, total) => {
1157
- forensicProgress = { scanned: done, total };
1804
+ buildForensicReportOffThread((progress) => {
1805
+ const now = Date.now();
1806
+ const nextStage = progress.stage || forensicStage;
1807
+ if (nextStage !== forensicStage) {
1808
+ forensicStage = nextStage;
1809
+ forensicStageStartedAt = now;
1810
+ console.log(`Local scan: ${forensicStageLabel(forensicStage)}…`);
1811
+ }
1812
+ forensicProgress = {
1813
+ status: "running",
1814
+ scanned: progress.done,
1815
+ total: progress.total,
1816
+ stage: forensicStage,
1817
+ label: forensicStageLabel(forensicStage),
1818
+ detail: progress.detail,
1819
+ elapsedMs: now - forensicStartedAt,
1820
+ stageElapsedMs: now - forensicStageStartedAt,
1821
+ updatedAt: now,
1822
+ };
1158
1823
  })
1159
1824
  .then((r) => {
1160
1825
  forensicReport = r;
1161
1826
  })
1162
1827
  .catch((e) => {
1828
+ const now = Date.now();
1829
+ forensicProgress = {
1830
+ status: "failed",
1831
+ scanned: forensicProgress.scanned,
1832
+ total: forensicProgress.total,
1833
+ stage: "failed",
1834
+ label: "Local scan failed",
1835
+ elapsedMs: now - forensicStartedAt,
1836
+ stageElapsedMs: now - forensicStageStartedAt,
1837
+ updatedAt: now,
1838
+ error: safeForensicError(e),
1839
+ };
1163
1840
  console.error(`Could not build the local report: ${e instanceof Error ? e.message : String(e)}`);
1164
1841
  });
1165
1842
  let token;
@@ -1544,49 +2221,79 @@ async function cmdLogin(flags) {
1544
2221
  return;
1545
2222
  }
1546
2223
  updateProgress({ status: "starting", running: 0, queued: exact.pending.length, latest: "Creating import session." });
1547
- const controller = new AbortController();
1548
- const h = await withTimeout(startMigration({
1549
- pending: exact.pending,
1550
- signal: controller.signal,
1551
- onProgress: (ev) => {
1552
- let latestRepo;
1553
- if (ev.error) {
1554
- progressFailed += 1;
1555
- }
1556
- else {
1557
- progressDone += 1;
1558
- progressExtracted += ev.memories ?? 0;
1559
- latestRepo = repoLabel(ev.session.cwd); // building this completed conversation belongs to
1560
- }
1561
- updateProgress({
1562
- latest: `${ev.session.source} ${(ev.session.firstTs || "").slice(0, 10)}${ev.error ? ` failed: ${ev.error}` : ""}`,
1563
- ...(latestRepo ? { latestRepo } : {}),
1564
- });
1565
- },
1566
- }), 30_000, "IMPORT_START_TIMEOUT", () => controller.abort());
1567
- activeSessionId = h.sessionId;
1568
- activeJobCount = h.jobCount;
1569
- updateProgress({ status: "running", sessionId: h.sessionId, jobCount: h.jobCount, total: h.jobCount, capped: h.capped, latest: "Import session created." });
1570
- sendMigrate({ sessionId: h.sessionId, jobCount: h.jobCount, ...(h.capped ? { capped: h.capped } : {}) });
2224
+ // Plan caps limit how many conversations one import session accepts (IMPORT_LIMIT_EXCEEDED →
2225
+ // startMigration slices to the cap). Instead of making the user re-run setup per batch (3000
2226
+ // sessions used to mean 3 clicks), loop batches automatically until everything pending is done.
2227
+ const onBatchProgress = (ev) => {
2228
+ let latestRepo;
2229
+ if (ev.error) {
2230
+ progressFailed += 1;
2231
+ }
2232
+ else {
2233
+ progressDone += 1;
2234
+ progressExtracted += ev.memories ?? 0;
2235
+ latestRepo = repoLabel(ev.session.cwd); // building this completed conversation belongs to
2236
+ }
2237
+ updateProgress({
2238
+ latest: `${ev.session.source} ${(ev.session.firstTs || "").slice(0, 10)}${ev.error ? ` failed: ${ev.error}` : ""}`,
2239
+ ...(latestRepo ? { latestRepo } : {}),
2240
+ });
2241
+ };
2242
+ let remaining = exact.pending;
2243
+ let stoppedReason;
2244
+ let planLimitNote;
2245
+ let batchIndex = 0;
1571
2246
  console.log("Migrating your history… keep this terminal open until it completes.");
1572
- console.log(`Migration metrics: ${h.metricsFile}`);
1573
- const r = await h.done;
2247
+ for (;;) {
2248
+ batchIndex += 1;
2249
+ let h;
2250
+ try {
2251
+ const controller = new AbortController();
2252
+ h = await withTimeout(startMigration({ pending: remaining, signal: controller.signal, onProgress: onBatchProgress }), 30_000, "IMPORT_START_TIMEOUT", () => controller.abort());
2253
+ }
2254
+ catch (batchError) {
2255
+ if (batchIndex === 1)
2256
+ throw batchError; // first batch failing = the whole import failed
2257
+ // A later batch could not start (e.g. plan headroom exhausted). Finish gracefully with a note.
2258
+ planLimitNote = `Imported ${progressDone} so far — the rest hit your plan's import limit. Re-run extraction later for the remaining ${remaining.length}.`;
2259
+ break;
2260
+ }
2261
+ activeSessionId = h.sessionId;
2262
+ if (batchIndex === 1) {
2263
+ updateProgress({ status: "running", sessionId: h.sessionId, jobCount: activeJobCount, total: activeJobCount, latest: "Import session created." });
2264
+ sendMigrate({ sessionId: h.sessionId, jobCount: activeJobCount });
2265
+ }
2266
+ else {
2267
+ updateProgress({ latest: `Continuing automatically — batch ${batchIndex} (${remaining.length} left).` });
2268
+ }
2269
+ console.log(`Migration metrics (batch ${batchIndex}): ${h.metricsFile}`);
2270
+ const r = await h.done;
2271
+ if (r.stoppedReason) {
2272
+ stoppedReason = r.stoppedReason;
2273
+ break;
2274
+ }
2275
+ // startMigration sliced to the cap ⇒ more remain: continue with the next batch automatically.
2276
+ if (h.capped && remaining.length > h.jobCount) {
2277
+ remaining = remaining.slice(h.jobCount);
2278
+ continue;
2279
+ }
2280
+ break;
2281
+ }
1574
2282
  srv.setProgress({
1575
- status: r.failed || r.stoppedReason ? "failed" : "completed",
1576
- sessionId: h.sessionId,
1577
- jobCount: h.jobCount,
1578
- capped: h.capped,
1579
- total: h.jobCount,
1580
- completed: r.migrated,
2283
+ status: stoppedReason ? "failed" : "completed",
2284
+ sessionId: activeSessionId || undefined,
2285
+ jobCount: activeJobCount,
2286
+ total: activeJobCount,
2287
+ completed: progressDone,
1581
2288
  running: 0,
1582
2289
  queued: 0,
1583
- failed: r.failed,
1584
- extracted: r.extracted,
1585
- latest: r.stoppedReason ? `Stopped: ${r.stoppedReason}` : "Import complete.",
1586
- ...(r.stoppedReason ? { error: r.stoppedReason } : {}),
2290
+ failed: progressFailed,
2291
+ extracted: progressExtracted,
2292
+ latest: stoppedReason ? `Stopped: ${stoppedReason}` : planLimitNote || "Import complete.",
2293
+ ...(stoppedReason ? { error: stoppedReason } : {}),
1587
2294
  });
1588
- console.log(`Import finished: ${r.migrated} imported, ${r.extracted} memories, ${r.failed} failed.`);
1589
- if (r.stoppedReason || r.failed)
2295
+ console.log(`Import finished: ${progressDone} imported, ${progressExtracted} memories, ${progressFailed} failed.`);
2296
+ if (stoppedReason || progressFailed)
1590
2297
  process.exitCode = 1;
1591
2298
  await new Promise((resolve) => setTimeout(resolve, 2000));
1592
2299
  srv.close();
@@ -1713,6 +2420,7 @@ function cmdLogout() {
1713
2420
  const HELP = `EchoMem MCP — local memory bridge
1714
2421
 
1715
2422
  Usage:
2423
+ echomem-mcp init One command: configure every installed agent + HUD + log in
1716
2424
  echomem-mcp Run the MCP server (stdio; default — used by your editor)
1717
2425
  echomem-mcp setup [--client X] Detect editor, write its MCP config, then log in
1718
2426
  echomem-mcp setup --skip-login Write MCP config without opening login/browser
@@ -1749,6 +2457,9 @@ export async function runCli(argv) {
1749
2457
  const cmd = argv[0];
1750
2458
  const flags = parseFlags(argv.slice(1));
1751
2459
  switch (cmd) {
2460
+ case "init":
2461
+ await cmdInit(flags);
2462
+ return true;
1752
2463
  case "setup":
1753
2464
  await cmdSetup(flags);
1754
2465
  return true;