@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.
- package/README.md +35 -9
- 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/assets/hud/claude.svg +1 -0
- package/assets/hud/codex.svg +1 -0
- package/assets/hud/session-viewer.html +35 -0
- package/dist/city/chaos-to-clarity-pencil.html +582 -0
- package/dist/city/echo-ai-city-only.html +1126 -109
- package/dist/city/echo-ai-city-only.template.html +1126 -109
- package/dist/city/echo-face-cutout.png +0 -0
- 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/autostart.js +66 -0
- package/dist/hud/cli.js +31 -0
- package/dist/hud/electron-main.js +182 -19
- package/dist/hud/metric.js +13 -4
- package/dist/hud/monitor.js +171 -84
- package/dist/hud/preload.cjs +3 -0
- package/dist/hud/server.js +321 -4
- package/dist/hud/web.js +880 -270
- package/dist/index.js +122 -24
- package/dist/local-data-paths.js +87 -0
- package/dist/migrate.js +55 -29
- package/dist/report.js +101 -40
- package/dist/setup-page.js +4257 -245
- package/dist/setup-preview.js +245 -0
- package/dist/setup.js +786 -75
- package/dist/v1-contract.js +20 -2
- package/package.json +6 -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));
|
|
@@ -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
|
-
|
|
279
|
-
|
|
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 =
|
|
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 =
|
|
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({
|
|
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
|
|
992
|
+
onProgress?.(msg.progress);
|
|
628
993
|
return;
|
|
629
994
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
788
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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((
|
|
1157
|
-
|
|
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
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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
|
-
|
|
1573
|
-
|
|
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:
|
|
1576
|
-
sessionId:
|
|
1577
|
-
jobCount:
|
|
1578
|
-
|
|
1579
|
-
|
|
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:
|
|
1584
|
-
extracted:
|
|
1585
|
-
latest:
|
|
1586
|
-
...(
|
|
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: ${
|
|
1589
|
-
if (
|
|
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;
|