@echomem/mcp 1.4.8 → 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 (36) hide show
  1. package/README.md +23 -3
  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/dist/city/chaos-to-clarity-pencil.html +582 -0
  8. package/dist/city/echo-ai-city-only.html +1104 -105
  9. package/dist/city/echo-ai-city-only.template.html +1104 -105
  10. package/dist/city/pencil-pie-generator.html +883 -0
  11. package/dist/city/pencil-webgl-landscape.html +1239 -0
  12. package/dist/city/spatial-fan-story.html +479 -0
  13. package/dist/codex-session-files.js +283 -0
  14. package/dist/codex-sync.js +7 -2
  15. package/dist/context-analysis/canonical-golden.js +47 -0
  16. package/dist/context-analysis/claude-native-canonical.js +1193 -0
  17. package/dist/context-analysis/vendored-canonical.js +793 -0
  18. package/dist/context-analysis/workspace-report.js +1838 -0
  19. package/dist/context-metrics/calculate.js +56 -0
  20. package/dist/context-metrics/model-limits.js +26 -0
  21. package/dist/context-metrics/types.js +1 -0
  22. package/dist/forensics-10-problems.js +7 -6
  23. package/dist/forensics.js +863 -132
  24. package/dist/hud/adapters.js +8 -4
  25. package/dist/hud/metric.js +13 -4
  26. package/dist/hud/monitor.js +135 -16
  27. package/dist/hud/web.js +344 -298
  28. package/dist/index.js +7 -3
  29. package/dist/local-data-paths.js +87 -0
  30. package/dist/migrate.js +37 -29
  31. package/dist/report.js +101 -40
  32. package/dist/setup-page.js +3290 -196
  33. package/dist/setup-preview.js +245 -0
  34. package/dist/setup.js +432 -34
  35. package/package.json +5 -4
  36. 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));
@@ -484,6 +562,63 @@ function openClaudeDesktop() {
484
562
  detail: failures.join(" | "),
485
563
  };
486
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
+ }
487
622
  function commandFailureMessage(error) {
488
623
  const maybe = error;
489
624
  if (Buffer.isBuffer(maybe.stderr)) {
@@ -749,7 +884,7 @@ function serveAgentIcon(reqPath, res) {
749
884
  return true;
750
885
  }
751
886
  function discoverMigratableSessionsOffThread() {
752
- const migrateUrl = new URL("./migrate.js", import.meta.url).href;
887
+ const migrateUrl = runtimeModuleUrl("migrate");
753
888
  const code = `
754
889
  import { parentPort } from "node:worker_threads";
755
890
  import { discoverMigratableSessions } from ${JSON.stringify(migrateUrl)};
@@ -795,15 +930,29 @@ function discoverMigratableSessionsOffThread() {
795
930
  });
796
931
  });
797
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
+ }
798
944
  /** Build the local forensic "Context Doctor" report on a worker thread so the multi-file scan never
799
945
  * blocks the bridge's event loop (the freeze postmortem: any unbounded sync work on this path is risky). */
800
- function buildForensicReportOffThread(onProgress) {
801
- const forensicsUrl = new URL("./forensics.js", import.meta.url).href;
946
+ export function buildForensicReportOffThread(onProgress, options = {}) {
947
+ const forensicsUrl = runtimeModuleUrl("forensics");
802
948
  const code = `
803
949
  import { parentPort } from "node:worker_threads";
804
950
  import { buildForensicReport } from ${JSON.stringify(forensicsUrl)};
805
951
  try {
806
- 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
+ });
807
956
  parentPort?.postMessage({ ok: true, report });
808
957
  } catch (error) {
809
958
  parentPort?.postMessage({ ok: false, message: error instanceof Error ? error.message : String(error) });
@@ -812,30 +961,50 @@ function buildForensicReportOffThread(onProgress) {
812
961
  const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
813
962
  return new Promise((resolve, reject) => {
814
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
+ };
815
987
  worker.on("message", (message) => {
988
+ if (settled)
989
+ return;
816
990
  const msg = message;
817
991
  if (msg.progress) {
818
- onProgress?.(msg.progress.done, msg.progress.total);
992
+ onProgress?.(msg.progress);
819
993
  return;
820
994
  }
821
- settled = true;
822
- if (msg.ok === true && msg.report && typeof msg.report === "object")
823
- resolve(msg.report);
824
- else
825
- reject(new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed"));
826
- 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") });
827
1000
  });
828
1001
  worker.once("error", (error) => {
829
- if (settled)
830
- return;
831
- settled = true;
832
- reject(error);
1002
+ finish({ ok: false, error });
833
1003
  });
834
1004
  worker.once("exit", (code) => {
835
1005
  if (settled)
836
1006
  return;
837
- settled = true;
838
- 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`) });
839
1008
  });
840
1009
  });
841
1010
  }
@@ -844,6 +1013,49 @@ export function respondMigrate(res, body, status = 200) {
844
1013
  return;
845
1014
  res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
846
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
+ }
847
1059
  /**
848
1060
  * Start the persistent localhost bridge used by the connect-device page. It accepts the token,
849
1061
  * serves local Wrapped stats, and holds the /migrate response until cmdLogin has created a cloud
@@ -853,6 +1065,7 @@ export function startCallbackServer(opts = {}) {
853
1065
  const timeoutMs = opts.timeoutMs ?? 300_000;
854
1066
  const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
855
1067
  const expectedNonce = opts.nonce;
1068
+ const scanId = opts.scanId ?? randomUUID();
856
1069
  return new Promise((resolveOuter, rejectOuter) => {
857
1070
  const onToken = deferred();
858
1071
  const decision = deferred();
@@ -868,6 +1081,10 @@ export function startCallbackServer(opts = {}) {
868
1081
  let timer;
869
1082
  let closed = false;
870
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();
871
1088
  const checkNonce = (nonce) => !expectedNonce || nonce === expectedNonce;
872
1089
  const text = (res, status, body = "") => res.writeHead(status, { "Content-Type": "text/plain" }).end(body);
873
1090
  const json = (res, status, body) => res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
@@ -879,6 +1096,9 @@ export function startCallbackServer(opts = {}) {
879
1096
  return;
880
1097
  closed = true;
881
1098
  server.close();
1099
+ for (const socket of sockets)
1100
+ socket.destroy();
1101
+ sockets.clear();
882
1102
  };
883
1103
  const armTimeout = () => {
884
1104
  if (timer)
@@ -939,10 +1159,21 @@ export function startCallbackServer(opts = {}) {
939
1159
  return;
940
1160
  }
941
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
+ }
942
1173
  res.writeHead(200, {
943
1174
  "Content-Type": "text/html; charset=utf-8",
944
1175
  "Cache-Control": "no-store",
945
- }).end(renderSetupPage());
1176
+ }).end(renderSetupPage(previewState ? { previewState } : undefined));
946
1177
  return;
947
1178
  }
948
1179
  if (route === "/config" && req.method === "GET") {
@@ -969,6 +1200,21 @@ export function startCallbackServer(opts = {}) {
969
1200
  json(res, result.ok ? 200 : 501, result);
970
1201
  return;
971
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);
1216
+ return;
1217
+ }
972
1218
  if (route === "/callback" && req.method === "GET") {
973
1219
  handleCallback(res, url.searchParams.get("token") || undefined, url.searchParams.get("key") || undefined, url.searchParams.get("nonce") || undefined);
974
1220
  return;
@@ -996,15 +1242,99 @@ export function startCallbackServer(opts = {}) {
996
1242
  }
997
1243
  if (route === "/report" && req.method === "GET") {
998
1244
  // Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
1245
+ res.setHeader("Cache-Control", "no-store");
999
1246
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1000
1247
  return void text(res, 403, "bad nonce");
1001
- 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
+ }
1002
1264
  if (payload == null) {
1003
1265
  // 202 carries scan progress so the page can show a live "scanned N/total" indicator.
1004
- const prog = opts.getReportProgress ? opts.getReportProgress() : { scanned: 0, total: 0 };
1005
- 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
+ });
1006
1320
  }
1007
- 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
+ });
1008
1338
  return;
1009
1339
  }
1010
1340
  if (route === "/progress" && req.method === "GET") {
@@ -1091,9 +1421,13 @@ export function startCallbackServer(opts = {}) {
1091
1421
  text(res, 500, e instanceof Error ? e.message : String(e));
1092
1422
  });
1093
1423
  });
1424
+ server.on("connection", (socket) => {
1425
+ sockets.add(socket);
1426
+ socket.on("close", () => sockets.delete(socket));
1427
+ });
1094
1428
  armTimeout();
1095
1429
  server.on("error", (e) => rejectOuter(e));
1096
- server.listen(0, "127.0.0.1", () => {
1430
+ server.listen(opts.port ?? 0, "127.0.0.1", () => {
1097
1431
  const addr = server.address();
1098
1432
  const port = typeof addr === "object" && addr ? addr.port : 0;
1099
1433
  resolveOuter({
@@ -1306,7 +1640,7 @@ async function cmdInit(flags) {
1306
1640
  console.log("🎉 EchoMem is ready.");
1307
1641
  console.log(" • MCP memory is configured for every coding agent installed on this machine.");
1308
1642
  if (!flags["no-hud"]) {
1309
- console.log(' • The context HUD is running (top-right). Right-click it → "Open at login" to keep it,');
1643
+ console.log(' • The context HUD is running (top-right). Right-click it → "Show after restart" to keep it,');
1310
1644
  console.log(' or just tell your agent "open the EchoMem HUD" anytime (it runs: echomem-hud app).');
1311
1645
  }
1312
1646
  else {
@@ -1385,9 +1719,21 @@ async function cmdSetupHud(flags) {
1385
1719
  }
1386
1720
  function resolveHudCliPath() {
1387
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
+ }
1388
1728
  const base = path.dirname(entry);
1389
1729
  const candidate = path.join(base, "hud", "cli.js");
1390
- 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;
1391
1737
  }
1392
1738
  function parseHudClient(value) {
1393
1739
  return value === "codex" || value === "claude-code" || value === "claude-desktop" || value === "both" || value === "auto"
@@ -1409,11 +1755,34 @@ async function cmdLogin(flags) {
1409
1755
  // Browser path: open a localhost dashboard. It briefly leaves for hosted auth, then returns here
1410
1756
  // after the web page has delivered the token+key to the callback. The nonce gates every local route.
1411
1757
  console.log("Opening your browser to approve this device…");
1412
- 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();
1413
1769
  let stats = null;
1414
1770
  let forensicReport = null;
1415
- 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
+ };
1416
1784
  const srv = await startCallbackServer({
1785
+ port: devPortRaw,
1417
1786
  nonce,
1418
1787
  getStats: () => stats,
1419
1788
  getReport: () => forensicReport,
@@ -1432,13 +1801,42 @@ async function cmdLogin(flags) {
1432
1801
  console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
1433
1802
  // Scan-first: build the local forensic "Context Doctor" report off-thread so the page shows it
1434
1803
  // BEFORE the user connects an account (the scan is local-only; nothing leaves the machine).
1435
- buildForensicReportOffThread((done, total) => {
1436
- 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
+ };
1437
1823
  })
1438
1824
  .then((r) => {
1439
1825
  forensicReport = r;
1440
1826
  })
1441
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
+ };
1442
1840
  console.error(`Could not build the local report: ${e instanceof Error ? e.message : String(e)}`);
1443
1841
  });
1444
1842
  let token;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.4.8",
4
- "description": "EchoMem Cloud-First MCP Server",
3
+ "version": "1.4.9",
4
+ "description": "EchoMem MCP bridge: cloud-first memory tools, local context HUD, and the Agent Doctor workspace forensics report (cost ledger + 3D repo city)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
7
7
  "bin": {
@@ -18,10 +18,11 @@
18
18
  "scripts": {
19
19
  "build": "tsc",
20
20
  "start": "node dist/index.js",
21
- "dev": "tsx src/index.ts",
21
+ "predev": "npm run build",
22
+ "dev": "node dist/index.js",
22
23
  "smoke": "node smoke.mjs",
23
24
  "preview:extraction": "npm run build && node scripts/preview-extraction.mjs",
24
- "test": "npm run build && node test/crypto.test.mjs && node test/integration.test.mjs && node test/no-restart.test.mjs && node test/report.test.mjs && node test/tools.test.mjs && node test/update-check.test.mjs && node test/delete.test.mjs && node test/low-touch-tools.test.mjs && node test/migrate.test.mjs && node test/hud.test.mjs",
25
+ "test": "npm run build && node test/local-data-paths.test.mjs && node test/crypto.test.mjs && node test/integration.test.mjs && node test/no-restart.test.mjs && node test/report.test.mjs && node test/forensics.test.mjs && node test/canonical-golden.test.mjs && node test/tools.test.mjs && node test/update-check.test.mjs && node test/delete.test.mjs && node test/low-touch-tools.test.mjs && node test/migrate.test.mjs && node test/hud.test.mjs",
25
26
  "prepack": "npm run build && node scripts/bundle-city.mjs"
26
27
  },
27
28
  "dependencies": {