@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.
- package/README.md +23 -3
- package/assets/canonical-scorer/README.md +18 -0
- package/assets/canonical-scorer/analyze-10-problems.mjs +857 -0
- package/assets/canonical-scorer/build-session-waste-dashboard.mjs +1628 -0
- package/assets/canonical-scorer/golden_anchors.mjs +83 -0
- package/assets/canonical-scorer/optimizable_detail.mjs +633 -0
- package/dist/city/chaos-to-clarity-pencil.html +582 -0
- package/dist/city/echo-ai-city-only.html +1104 -105
- package/dist/city/echo-ai-city-only.template.html +1104 -105
- package/dist/city/pencil-pie-generator.html +883 -0
- package/dist/city/pencil-webgl-landscape.html +1239 -0
- package/dist/city/spatial-fan-story.html +479 -0
- package/dist/codex-session-files.js +283 -0
- package/dist/codex-sync.js +7 -2
- package/dist/context-analysis/canonical-golden.js +47 -0
- package/dist/context-analysis/claude-native-canonical.js +1193 -0
- package/dist/context-analysis/vendored-canonical.js +793 -0
- package/dist/context-analysis/workspace-report.js +1838 -0
- package/dist/context-metrics/calculate.js +56 -0
- package/dist/context-metrics/model-limits.js +26 -0
- package/dist/context-metrics/types.js +1 -0
- package/dist/forensics-10-problems.js +7 -6
- package/dist/forensics.js +863 -132
- package/dist/hud/adapters.js +8 -4
- package/dist/hud/metric.js +13 -4
- package/dist/hud/monitor.js +135 -16
- package/dist/hud/web.js +344 -298
- package/dist/index.js +7 -3
- package/dist/local-data-paths.js +87 -0
- package/dist/migrate.js +37 -29
- package/dist/report.js +101 -40
- package/dist/setup-page.js +3290 -196
- package/dist/setup-preview.js +245 -0
- package/dist/setup.js +432 -34
- package/package.json +5 -4
- 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 {
|
|
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 =
|
|
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 =
|
|
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({
|
|
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
|
|
992
|
+
onProgress?.(msg.progress);
|
|
819
993
|
return;
|
|
820
994
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
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 → "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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((
|
|
1436
|
-
|
|
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.
|
|
4
|
-
"description": "EchoMem
|
|
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
|
-
"
|
|
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": {
|