@echomem/mcp 1.4.18 → 1.4.20
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 +12 -5
- package/dist/codex-session-files.js +7 -1
- package/dist/hud/server.js +17 -13
- package/dist/hud/web.js +204 -13
- package/dist/index.js +61 -30
- package/dist/keystore.js +9 -19
- package/dist/migrate.js +78 -36
- package/dist/setup-page/client-core.js +31 -1
- package/dist/setup-page/client-extraction.js +581 -157
- package/dist/setup-page/client-lifecycle.js +64 -43
- package/dist/setup-page/client-report-city.js +14 -5
- package/dist/setup-page/styles-extraction.js +1176 -148
- package/dist/setup-page/styles-foundation.js +20 -15
- package/dist/setup-page/styles-website-alignment.js +818 -0
- package/dist/setup-page/styles.js +2 -0
- package/dist/setup-preview.js +64 -1
- package/dist/setup.js +328 -39
- package/dist/v1-contract.js +8 -8
- package/package.json +1 -1
- package/templates/codex-skills/echomem-forget/SKILL.md +16 -0
- package/templates/codex-skills/echomem-forget/agents/openai.yaml +6 -0
- package/templates/codex-skills/echomem-login/SKILL.md +27 -0
- package/templates/codex-skills/echomem-login/agents/openai.yaml +6 -0
- package/templates/codex-skills/echomem-save/SKILL.md +14 -0
- package/templates/codex-skills/echomem-save/agents/openai.yaml +6 -0
- package/templates/codex-skills/echomem-search/SKILL.md +14 -0
- package/templates/codex-skills/echomem-search/agents/openai.yaml +6 -0
package/dist/setup.js
CHANGED
|
@@ -39,9 +39,107 @@ import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "
|
|
|
39
39
|
const WEB_URL = (process.env.ECHO_WEB_URL || "https://yeahecho.com").replace(/\/$/, "");
|
|
40
40
|
const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
|
|
41
41
|
const PRICING_URL = (process.env.ECHO_PRICING_URL || "https://echoknows.com/pricing").replace(/\/$/, "");
|
|
42
|
+
const CODEX_SKILL_NAMES = [
|
|
43
|
+
"echomem-search",
|
|
44
|
+
"echomem-save",
|
|
45
|
+
"echomem-forget",
|
|
46
|
+
"echomem-login",
|
|
47
|
+
];
|
|
42
48
|
function home(...p) {
|
|
43
49
|
return path.join(os.homedir(), ...p);
|
|
44
50
|
}
|
|
51
|
+
function codexHome() {
|
|
52
|
+
const configured = process.env.CODEX_HOME?.trim();
|
|
53
|
+
if (!configured)
|
|
54
|
+
return home(".codex");
|
|
55
|
+
if (configured === "~")
|
|
56
|
+
return os.homedir();
|
|
57
|
+
if (configured.startsWith(`~${path.sep}`))
|
|
58
|
+
return path.join(os.homedir(), configured.slice(2));
|
|
59
|
+
return path.resolve(configured);
|
|
60
|
+
}
|
|
61
|
+
function filesEqual(left, right) {
|
|
62
|
+
try {
|
|
63
|
+
return fs.readFileSync(left).equals(fs.readFileSync(right));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function packagedSkillMatches(source, destination) {
|
|
70
|
+
return filesEqual(path.join(source, "SKILL.md"), path.join(destination, "SKILL.md"))
|
|
71
|
+
&& filesEqual(path.join(source, "agents", "openai.yaml"), path.join(destination, "agents", "openai.yaml"));
|
|
72
|
+
}
|
|
73
|
+
function readSkillMetadata(skillFile) {
|
|
74
|
+
try {
|
|
75
|
+
const content = fs.readFileSync(skillFile, "utf8");
|
|
76
|
+
const frontmatter = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
77
|
+
if (!frontmatter)
|
|
78
|
+
return null;
|
|
79
|
+
const name = frontmatter[1].match(/^name:\s*(.+)$/m)?.[1]?.trim().replace(/^['"]|['"]$/g, "") ?? "";
|
|
80
|
+
const description = frontmatter[1].match(/^description:\s*(.+)$/m)?.[1]?.trim().replace(/^['"]|['"]$/g, "") ?? "";
|
|
81
|
+
return name ? { name, description } : null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function detectCompetingMemorySkills(skillsRoot) {
|
|
88
|
+
let entries = [];
|
|
89
|
+
try {
|
|
90
|
+
entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
const echoNames = new Set(CODEX_SKILL_NAMES);
|
|
96
|
+
return entries
|
|
97
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
98
|
+
.map((entry) => readSkillMetadata(path.join(skillsRoot, entry.name, "SKILL.md")))
|
|
99
|
+
.filter((metadata) => Boolean(metadata))
|
|
100
|
+
.filter(({ name, description }) => {
|
|
101
|
+
if (echoNames.has(name))
|
|
102
|
+
return false;
|
|
103
|
+
const text = `${name} ${description}`;
|
|
104
|
+
return /\b(memory|memories|remember|recall)\b/i.test(text)
|
|
105
|
+
&& /\b(search|save|forget|delete|login|connect|recall|remember)\b/i.test(text);
|
|
106
|
+
})
|
|
107
|
+
.map(({ name }) => name)
|
|
108
|
+
.sort();
|
|
109
|
+
}
|
|
110
|
+
/** Install the EchoMem-owned Codex skills bundled with this npm package. Other providers are never modified. */
|
|
111
|
+
export function installCodexSkills(targetCodexHome = codexHome(), templateRoot = fileURLToPath(new URL("../templates/codex-skills/", import.meta.url))) {
|
|
112
|
+
const skillsRoot = path.join(targetCodexHome, "skills");
|
|
113
|
+
const report = {
|
|
114
|
+
skillsRoot,
|
|
115
|
+
installed: [],
|
|
116
|
+
updated: [],
|
|
117
|
+
unchanged: [],
|
|
118
|
+
competingMemorySkills: [],
|
|
119
|
+
};
|
|
120
|
+
fs.mkdirSync(skillsRoot, { recursive: true });
|
|
121
|
+
for (const skillName of CODEX_SKILL_NAMES) {
|
|
122
|
+
const source = path.join(templateRoot, skillName);
|
|
123
|
+
const destination = path.join(skillsRoot, skillName);
|
|
124
|
+
const sourceSkillFile = path.join(source, "SKILL.md");
|
|
125
|
+
if (!fs.existsSync(sourceSkillFile) || !fs.statSync(sourceSkillFile).isFile()) {
|
|
126
|
+
throw new Error(`EchoMem package is missing the ${skillName} skill template.`);
|
|
127
|
+
}
|
|
128
|
+
if (!fs.existsSync(destination)) {
|
|
129
|
+
fs.cpSync(source, destination, { recursive: true, force: true });
|
|
130
|
+
report.installed.push(skillName);
|
|
131
|
+
}
|
|
132
|
+
else if (packagedSkillMatches(source, destination)) {
|
|
133
|
+
report.unchanged.push(skillName);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
fs.cpSync(source, destination, { recursive: true, force: true });
|
|
137
|
+
report.updated.push(skillName);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
report.competingMemorySkills = detectCompetingMemorySkills(skillsRoot);
|
|
141
|
+
return report;
|
|
142
|
+
}
|
|
45
143
|
/** Map a source entry to its compiled sibling without ever guessing outside this package. */
|
|
46
144
|
export function compiledDistPathForSource(entry) {
|
|
47
145
|
if (!entry.endsWith(".ts") || !path.isAbsolute(entry))
|
|
@@ -96,7 +194,7 @@ export function knownClients() {
|
|
|
96
194
|
{ id: "windsurf", label: "Windsurf", kind: "json", configPath: home(".codeium", "windsurf", "mcp_config.json") },
|
|
97
195
|
{ id: "claude-desktop", label: "Claude Desktop", kind: "json", configPath: path.join(appSupport, "Claude", "claude_desktop_config.json") },
|
|
98
196
|
{ id: "claude-code", label: "Claude Code", kind: "snippet", note: "run: claude mcp add-json echomem '<entry>' (or add to .mcp.json)" },
|
|
99
|
-
{ id: "codex", label: "Codex", kind: "command", detectDir:
|
|
197
|
+
{ id: "codex", label: "Codex", kind: "command", detectDir: codexHome(), configPath: path.join(codexHome(), "config.toml"), note: "add to ~/.codex/config.toml under [mcp_servers.echomem]" },
|
|
100
198
|
];
|
|
101
199
|
}
|
|
102
200
|
/** A client is "present" if its config dir already exists (JSON) — a cheap heuristic for detection. */
|
|
@@ -222,6 +320,7 @@ function echomemGuidanceBlock() {
|
|
|
222
320
|
AGENTS_MD_BEGIN,
|
|
223
321
|
"## Memory (EchoMem)",
|
|
224
322
|
"EchoMem is your long-term memory across all coding sessions and tools.",
|
|
323
|
+
"- Use EchoMem's `echomem-*` skills and MCP tools as the default memory provider. Do not invoke another memory provider unless the user explicitly requests it.",
|
|
225
324
|
"- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
|
|
226
325
|
"- When meaningful work wraps up (a decision, a fix, a milestone) or the user asks to remember something: call `save_conversation`.",
|
|
227
326
|
"- 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.",
|
|
@@ -629,6 +728,24 @@ function commandFailureMessage(error) {
|
|
|
629
728
|
}
|
|
630
729
|
return error instanceof Error ? error.message : String(error);
|
|
631
730
|
}
|
|
731
|
+
function candidateSessionsFromDiscovery(disc) {
|
|
732
|
+
return disc.pending.map((session) => ({
|
|
733
|
+
key: session.conversationKey,
|
|
734
|
+
source: session.source,
|
|
735
|
+
title: session.title || "Untitled coding session",
|
|
736
|
+
project: repoLabel(session.cwd) || "No project detected",
|
|
737
|
+
date: session.firstTs,
|
|
738
|
+
characters: session.rawData.length,
|
|
739
|
+
approxInputTokens: Math.ceil(session.rawData.length / 4),
|
|
740
|
+
turns: session.turnCount,
|
|
741
|
+
}));
|
|
742
|
+
}
|
|
743
|
+
function withCandidateSessions(payload, disc) {
|
|
744
|
+
const base = payload && typeof payload === "object" && !Array.isArray(payload)
|
|
745
|
+
? payload
|
|
746
|
+
: {};
|
|
747
|
+
return { ...base, candidateSessions: candidateSessionsFromDiscovery(disc) };
|
|
748
|
+
}
|
|
632
749
|
function migratableFromDiscovery(disc) {
|
|
633
750
|
const eta = estimateMigrationEta(disc.pending, disc.skippedActive);
|
|
634
751
|
const pendingCodex = disc.pendingCodex ?? disc.pending.filter((s) => s.source === "codex").length;
|
|
@@ -709,6 +826,16 @@ function isObjectRecord(value) {
|
|
|
709
826
|
function asString(value) {
|
|
710
827
|
return typeof value === "string" && value ? value : undefined;
|
|
711
828
|
}
|
|
829
|
+
function asConversationKeys(value) {
|
|
830
|
+
if (!Array.isArray(value))
|
|
831
|
+
return null;
|
|
832
|
+
const keys = value
|
|
833
|
+
.slice(0, 5000)
|
|
834
|
+
.filter((item) => typeof item === "string")
|
|
835
|
+
.map((item) => item.trim())
|
|
836
|
+
.filter((item) => /^(codex|claude-code):[^\s]{1,180}$/.test(item));
|
|
837
|
+
return Array.from(new Set(keys));
|
|
838
|
+
}
|
|
712
839
|
function readJsonBody(req) {
|
|
713
840
|
return new Promise((resolve, reject) => {
|
|
714
841
|
let body = "";
|
|
@@ -1084,6 +1211,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1084
1211
|
let authUrl = "";
|
|
1085
1212
|
let switchAccountUrl = "";
|
|
1086
1213
|
let connected = false;
|
|
1214
|
+
let reportConsentGranted = opts.requireReportConsent !== true;
|
|
1087
1215
|
let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
|
|
1088
1216
|
let migrateStarted = false;
|
|
1089
1217
|
let tokenRefreshHandler = null;
|
|
@@ -1129,6 +1257,12 @@ export function startCallbackServer(opts = {}) {
|
|
|
1129
1257
|
const handleCallback = (res, token, key, nonce) => {
|
|
1130
1258
|
if (!checkNonce(nonce))
|
|
1131
1259
|
return void text(res, 403, "bad nonce");
|
|
1260
|
+
if (opts.requireReportConsent === true && !reportConsentGranted) {
|
|
1261
|
+
return void json(res, 403, {
|
|
1262
|
+
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
1263
|
+
message: "Allow local history access in the setup page before connecting EchoMem.",
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1132
1266
|
if (!token)
|
|
1133
1267
|
return void text(res, 400, "missing token");
|
|
1134
1268
|
console.log(`[${new Date().toISOString()}] Browser approval callback received.`);
|
|
@@ -1191,7 +1325,15 @@ export function startCallbackServer(opts = {}) {
|
|
|
1191
1325
|
if (route === "/config" && req.method === "GET") {
|
|
1192
1326
|
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
1193
1327
|
return void text(res, 403, "bad nonce");
|
|
1194
|
-
json(res, 200, {
|
|
1328
|
+
json(res, 200, {
|
|
1329
|
+
connected,
|
|
1330
|
+
authUrl,
|
|
1331
|
+
switchAccountUrl: switchAccountUrl || authUrl,
|
|
1332
|
+
localOnly: true,
|
|
1333
|
+
workspacePath: process.cwd(),
|
|
1334
|
+
consentRequired: opts.requireReportConsent === true,
|
|
1335
|
+
consentGranted: reportConsentGranted,
|
|
1336
|
+
});
|
|
1195
1337
|
return;
|
|
1196
1338
|
}
|
|
1197
1339
|
if (route === "/launch-agent" && req.method === "POST") {
|
|
@@ -1246,6 +1388,12 @@ export function startCallbackServer(opts = {}) {
|
|
|
1246
1388
|
if (route === "/stats" && req.method === "GET") {
|
|
1247
1389
|
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
1248
1390
|
return void text(res, 403, "bad nonce");
|
|
1391
|
+
if (opts.requireReportConsent === true && !reportConsentGranted) {
|
|
1392
|
+
return void json(res, 403, {
|
|
1393
|
+
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
1394
|
+
message: "Allow local history access before continuing setup.",
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1249
1397
|
const payload = opts.getStats ? opts.getStats() : stats;
|
|
1250
1398
|
if (payload == null)
|
|
1251
1399
|
return void res.writeHead(202).end();
|
|
@@ -1255,6 +1403,12 @@ export function startCallbackServer(opts = {}) {
|
|
|
1255
1403
|
if (route === "/billing-status" && req.method === "GET") {
|
|
1256
1404
|
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
1257
1405
|
return void text(res, 403, "bad nonce");
|
|
1406
|
+
if (opts.requireReportConsent === true && !reportConsentGranted) {
|
|
1407
|
+
return void json(res, 403, {
|
|
1408
|
+
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
1409
|
+
message: "Allow local history access before continuing setup.",
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1258
1412
|
const token = new KeyStore().getToken();
|
|
1259
1413
|
const pricingUrl = `${PRICING_URL}?source=mcp_onboarding`;
|
|
1260
1414
|
if (!token) {
|
|
@@ -1262,7 +1416,11 @@ export function startCallbackServer(opts = {}) {
|
|
|
1262
1416
|
return;
|
|
1263
1417
|
}
|
|
1264
1418
|
try {
|
|
1265
|
-
const
|
|
1419
|
+
const client = authedAxios(token);
|
|
1420
|
+
const [response, profileResponse] = await Promise.all([
|
|
1421
|
+
client.get("/api/extension/account/bootstrap", { timeout: 6000 }),
|
|
1422
|
+
client.get("/api/extension/account/profile-summary", { timeout: 6000 }).catch(() => null),
|
|
1423
|
+
]);
|
|
1266
1424
|
const plan = (asString(response.data?.plan) || "free").toLowerCase();
|
|
1267
1425
|
const trialUsed = response.data?.billing?.trialUsed === true;
|
|
1268
1426
|
const trialAvailable = response.data?.billing?.trialAvailable !== false;
|
|
@@ -1271,6 +1429,14 @@ export function startCallbackServer(opts = {}) {
|
|
|
1271
1429
|
paid: ["pro", "power", "team", "enterprise"].includes(plan),
|
|
1272
1430
|
trialAvailable,
|
|
1273
1431
|
trialUsed,
|
|
1432
|
+
historicalConversationQuota: response.data?.historicalConversationQuota ?? null,
|
|
1433
|
+
memoryProcessingQuota: response.data?.memoryProcessingQuota ?? null,
|
|
1434
|
+
memorySearchQuota: response.data?.memorySearchQuota ?? null,
|
|
1435
|
+
account: profileResponse ? {
|
|
1436
|
+
displayName: asString(profileResponse.data?.displayName) || "EchoMem user",
|
|
1437
|
+
email: asString(profileResponse.data?.email) || "",
|
|
1438
|
+
avatarUrl: asString(profileResponse.data?.avatarUrl) || "",
|
|
1439
|
+
} : null,
|
|
1274
1440
|
pricingUrl,
|
|
1275
1441
|
});
|
|
1276
1442
|
}
|
|
@@ -1284,6 +1450,12 @@ export function startCallbackServer(opts = {}) {
|
|
|
1284
1450
|
res.setHeader("Cache-Control", "no-store");
|
|
1285
1451
|
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
1286
1452
|
return void text(res, 403, "bad nonce");
|
|
1453
|
+
if (opts.requireReportConsent === true && !reportConsentGranted) {
|
|
1454
|
+
return void json(res, 403, {
|
|
1455
|
+
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
1456
|
+
message: "Allow local history access before starting the local scan.",
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1287
1459
|
let payload;
|
|
1288
1460
|
try {
|
|
1289
1461
|
payload = opts.getReport ? opts.getReport() : null;
|
|
@@ -1379,6 +1551,12 @@ export function startCallbackServer(opts = {}) {
|
|
|
1379
1551
|
if (route === "/progress" && req.method === "GET") {
|
|
1380
1552
|
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
1381
1553
|
return void text(res, 403, "bad nonce");
|
|
1554
|
+
if (opts.requireReportConsent === true && !reportConsentGranted) {
|
|
1555
|
+
return void json(res, 403, {
|
|
1556
|
+
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
1557
|
+
message: "Allow local history access before continuing setup.",
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1382
1560
|
json(res, 200, progress);
|
|
1383
1561
|
return;
|
|
1384
1562
|
}
|
|
@@ -1394,6 +1572,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1394
1572
|
if (!checkNonce(asString(body.nonce)))
|
|
1395
1573
|
return void text(res, 403, "bad nonce");
|
|
1396
1574
|
const allowed = body.allowed === true;
|
|
1575
|
+
reportConsentGranted = allowed;
|
|
1397
1576
|
opts.onReportConsent?.(allowed);
|
|
1398
1577
|
json(res, 200, { ok: true, allowed });
|
|
1399
1578
|
return;
|
|
@@ -1442,6 +1621,12 @@ export function startCallbackServer(opts = {}) {
|
|
|
1442
1621
|
}
|
|
1443
1622
|
if (!checkNonce(asString(body.nonce)))
|
|
1444
1623
|
return void text(res, 403, "bad nonce");
|
|
1624
|
+
if (opts.requireReportConsent === true && !reportConsentGranted) {
|
|
1625
|
+
return void json(res, 403, {
|
|
1626
|
+
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
1627
|
+
message: "Allow local history access before starting extraction.",
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1445
1630
|
if (migrateStarted)
|
|
1446
1631
|
return void json(res, 409, { error: "MIGRATE_IN_PROGRESS" });
|
|
1447
1632
|
migrateStarted = true;
|
|
@@ -1449,7 +1634,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1449
1634
|
respondMigrate(res, { error: "IMPORT_START_TIMEOUT" }, 504);
|
|
1450
1635
|
}, 30_000);
|
|
1451
1636
|
safety.unref?.();
|
|
1452
|
-
migrateRequest.resolve({ res });
|
|
1637
|
+
migrateRequest.resolve({ res, conversationKeys: asConversationKeys(body.conversationKeys) });
|
|
1453
1638
|
decision.resolve("migrate");
|
|
1454
1639
|
return;
|
|
1455
1640
|
}
|
|
@@ -1607,16 +1792,20 @@ export async function verifyAndStore(input) {
|
|
|
1607
1792
|
return "Token saved, but this account is ENCRYPTED. Re-run with --passphrase to unlock the vault.";
|
|
1608
1793
|
}
|
|
1609
1794
|
store.saveKey(keyB64);
|
|
1610
|
-
return "✅ Token + encryption key verified
|
|
1795
|
+
return "✅ Token + encryption key verified. This device stays unlocked until you run `echomem-mcp lock` or log out. Retry EchoMem in this session — no editor restart needed.";
|
|
1611
1796
|
}
|
|
1612
1797
|
function prompt(question, { silent = false } = {}) {
|
|
1613
1798
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
1614
1799
|
return new Promise((resolve) => {
|
|
1615
1800
|
if (silent) {
|
|
1616
1801
|
const out = process.stdout;
|
|
1617
|
-
|
|
1802
|
+
// readline renders `question` through this private writer too. Print the label first, then
|
|
1803
|
+
// suppress only the user's characters; otherwise the CLI waits on a completely blank line.
|
|
1804
|
+
out.write(question);
|
|
1805
|
+
const silentRl = rl;
|
|
1806
|
+
silentRl._writeToOutput = () => undefined;
|
|
1618
1807
|
}
|
|
1619
|
-
rl.question(question, (answer) => {
|
|
1808
|
+
rl.question(silent ? "" : question, (answer) => {
|
|
1620
1809
|
rl.close();
|
|
1621
1810
|
if (silent)
|
|
1622
1811
|
process.stdout.write("\n");
|
|
@@ -1681,6 +1870,9 @@ async function cmdSetup(flags) {
|
|
|
1681
1870
|
if (!flags["no-agents-md"]) {
|
|
1682
1871
|
writeMemoryGuidanceForTargets(targets);
|
|
1683
1872
|
}
|
|
1873
|
+
if (!flags["no-codex-skills"]) {
|
|
1874
|
+
writeCodexSkillsForTargets(targets);
|
|
1875
|
+
}
|
|
1684
1876
|
console.log("");
|
|
1685
1877
|
if (flags["skip-login"] || flags["no-login"]) {
|
|
1686
1878
|
// init drives login itself right after, so the "skipped" note would be misleading there.
|
|
@@ -1695,10 +1887,10 @@ async function cmdSetup(flags) {
|
|
|
1695
1887
|
}
|
|
1696
1888
|
/**
|
|
1697
1889
|
* `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
|
|
1698
|
-
* machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones),
|
|
1699
|
-
* memory guidance, logs in via the browser, and launches the
|
|
1700
|
-
* single command. `setup`/`update` remain the granular
|
|
1701
|
-
* defaults and frames the result.
|
|
1890
|
+
* machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), installs EchoMem's
|
|
1891
|
+
* Codex skills, writes the AGENTS.md memory guidance, logs in via the browser, and launches the
|
|
1892
|
+
* context HUD — the whole product in a single command. `setup`/`update` remain the granular
|
|
1893
|
+
* primitives; init just picks the "do everything" defaults and frames the result.
|
|
1702
1894
|
*/
|
|
1703
1895
|
async function cmdInit(flags) {
|
|
1704
1896
|
console.log("Setting up EchoMem — shared memory for all your coding agents, plus the live context HUD.\n");
|
|
@@ -1733,8 +1925,8 @@ async function cmdInit(flags) {
|
|
|
1733
1925
|
function writeMemoryGuidanceForTargets(targets) {
|
|
1734
1926
|
const files = new Map(); // path → label
|
|
1735
1927
|
for (const t of targets) {
|
|
1736
|
-
if (t.id === "codex")
|
|
1737
|
-
files.set(
|
|
1928
|
+
if (t.id === "codex" && t.kind === "command")
|
|
1929
|
+
files.set(path.join(t.detectDir, "AGENTS.md"), "Codex");
|
|
1738
1930
|
if (t.id === "claude-code" || t.id === "claude-desktop")
|
|
1739
1931
|
files.set(home(".claude", "CLAUDE.md"), "Claude");
|
|
1740
1932
|
}
|
|
@@ -1752,6 +1944,24 @@ function writeMemoryGuidanceForTargets(targets) {
|
|
|
1752
1944
|
}
|
|
1753
1945
|
}
|
|
1754
1946
|
}
|
|
1947
|
+
function writeCodexSkillsForTargets(targets) {
|
|
1948
|
+
const target = targets.find((client) => client.id === "codex" && client.kind === "command");
|
|
1949
|
+
if (!target)
|
|
1950
|
+
return;
|
|
1951
|
+
try {
|
|
1952
|
+
const report = installCodexSkills(target.detectDir);
|
|
1953
|
+
const changed = [...report.installed, ...report.updated];
|
|
1954
|
+
if (changed.length > 0) {
|
|
1955
|
+
console.log(`✅ Installed EchoMem Codex skills: ${CODEX_SKILL_NAMES.join(", ")} — start a new Codex session to load them.`);
|
|
1956
|
+
}
|
|
1957
|
+
if (report.competingMemorySkills.length > 0) {
|
|
1958
|
+
console.log(`ℹ️ Other memory skills remain installed: ${report.competingMemorySkills.join(", ")}. EchoMem did not modify them; its global guidance now selects EchoMem by default.`);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
catch (error) {
|
|
1962
|
+
console.log(`ℹ️ Could not install EchoMem Codex skills: ${error instanceof Error ? error.message : String(error)}`);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1755
1965
|
async function cmdUpdate(flags) {
|
|
1756
1966
|
await cmdSetup({ ...flags, "skip-login": true });
|
|
1757
1967
|
console.log(`Update config complete. Start a new MCP session to load ${MCP_PACKAGE_LABEL}.`);
|
|
@@ -1863,6 +2073,7 @@ async function cmdLogin(flags) {
|
|
|
1863
2073
|
const srv = await startCallbackServer({
|
|
1864
2074
|
port: devPortRaw,
|
|
1865
2075
|
nonce,
|
|
2076
|
+
requireReportConsent: true,
|
|
1866
2077
|
getStats: () => stats,
|
|
1867
2078
|
getReport: () => forensicReport,
|
|
1868
2079
|
getReportProgress: () => forensicProgress,
|
|
@@ -1886,6 +2097,19 @@ async function cmdLogin(flags) {
|
|
|
1886
2097
|
return;
|
|
1887
2098
|
}
|
|
1888
2099
|
forensicConsent = "allowed";
|
|
2100
|
+
const now = Date.now();
|
|
2101
|
+
forensicStage = "starting";
|
|
2102
|
+
forensicStageStartedAt = now;
|
|
2103
|
+
forensicProgress = {
|
|
2104
|
+
status: "running",
|
|
2105
|
+
scanned: 0,
|
|
2106
|
+
total: 0,
|
|
2107
|
+
stage: forensicStage,
|
|
2108
|
+
label: forensicStageLabel(forensicStage),
|
|
2109
|
+
elapsedMs: now - forensicStartedAt,
|
|
2110
|
+
stageElapsedMs: 0,
|
|
2111
|
+
updatedAt: now,
|
|
2112
|
+
};
|
|
1889
2113
|
startForensicScan();
|
|
1890
2114
|
},
|
|
1891
2115
|
});
|
|
@@ -2125,13 +2349,13 @@ async function cmdLogin(flags) {
|
|
|
2125
2349
|
migratable = migratableFromDiscovery(initialExact);
|
|
2126
2350
|
latestPendingEstimate = migratable.pending;
|
|
2127
2351
|
sessionSummary = sessionsFromDiscovery(initialExact);
|
|
2128
|
-
const partialPayload = await buildStatsPayload([], {
|
|
2352
|
+
const partialPayload = withCandidateSessions(await buildStatsPayload([], {
|
|
2129
2353
|
partial: true,
|
|
2130
2354
|
skipMemoryCount: true,
|
|
2131
2355
|
sessions: sessionSummary,
|
|
2132
2356
|
migratable,
|
|
2133
2357
|
discovery: { phase: "exact", exact: true },
|
|
2134
|
-
});
|
|
2358
|
+
}), initialExact);
|
|
2135
2359
|
if (generation !== refreshGeneration)
|
|
2136
2360
|
return disc;
|
|
2137
2361
|
stats = partialPayload;
|
|
@@ -2175,13 +2399,13 @@ async function cmdLogin(flags) {
|
|
|
2175
2399
|
migratable = migratableFromDiscovery(reconciled);
|
|
2176
2400
|
latestPendingEstimate = migratable.pending;
|
|
2177
2401
|
sessionSummary = sessionsFromDiscovery(reconciled);
|
|
2178
|
-
const reconciledPayload = await buildStatsPayload([], {
|
|
2402
|
+
const reconciledPayload = withCandidateSessions(await buildStatsPayload([], {
|
|
2179
2403
|
partial: true,
|
|
2180
2404
|
skipMemoryCount: true,
|
|
2181
2405
|
sessions: sessionSummary,
|
|
2182
2406
|
migratable,
|
|
2183
2407
|
discovery: { phase: "exact", exact: true },
|
|
2184
|
-
});
|
|
2408
|
+
}), reconciled);
|
|
2185
2409
|
if (generation !== refreshGeneration)
|
|
2186
2410
|
return;
|
|
2187
2411
|
stats = reconciledPayload;
|
|
@@ -2195,11 +2419,11 @@ async function cmdLogin(flags) {
|
|
|
2195
2419
|
failed: 0,
|
|
2196
2420
|
extracted: 0,
|
|
2197
2421
|
});
|
|
2198
|
-
const fullPayload = await buildStatsPayload(collect(), {
|
|
2422
|
+
const fullPayload = withCandidateSessions(await buildStatsPayload(collect(), {
|
|
2199
2423
|
sessions: sessionSummary,
|
|
2200
2424
|
migratable,
|
|
2201
2425
|
discovery: { phase: "full", exact: true },
|
|
2202
|
-
});
|
|
2426
|
+
}), reconciled);
|
|
2203
2427
|
if (generation !== refreshGeneration)
|
|
2204
2428
|
return;
|
|
2205
2429
|
stats = fullPayload;
|
|
@@ -2220,18 +2444,42 @@ async function cmdLogin(flags) {
|
|
|
2220
2444
|
await refreshLocalStatsForToken(nextToken);
|
|
2221
2445
|
});
|
|
2222
2446
|
await refreshLocalStatsForToken(token);
|
|
2223
|
-
//
|
|
2224
|
-
// import ledger; unfinished conversations are
|
|
2447
|
+
// Ending is deliberately transient. Completed conversations remain recorded in the normal
|
|
2448
|
+
// import ledger; unfinished conversations are canceled and rediscovered by the next init.
|
|
2225
2449
|
let pauseRequested = false;
|
|
2226
2450
|
let pauseCompletion = null;
|
|
2451
|
+
let activeMigrationCleanup = null;
|
|
2452
|
+
let activeMigrationCleanupPromise = null;
|
|
2453
|
+
const cancelActiveMigration = async () => {
|
|
2454
|
+
if (!activeMigrationCleanup)
|
|
2455
|
+
return false;
|
|
2456
|
+
if (!activeMigrationCleanupPromise) {
|
|
2457
|
+
const cleanup = activeMigrationCleanup();
|
|
2458
|
+
activeMigrationCleanupPromise = cleanup;
|
|
2459
|
+
cleanup.catch(() => {
|
|
2460
|
+
// A transient backend failure must remain retryable from the restored extraction page.
|
|
2461
|
+
if (activeMigrationCleanupPromise === cleanup)
|
|
2462
|
+
activeMigrationCleanupPromise = null;
|
|
2463
|
+
});
|
|
2464
|
+
}
|
|
2465
|
+
await activeMigrationCleanupPromise;
|
|
2466
|
+
return true;
|
|
2467
|
+
};
|
|
2227
2468
|
srv.setMigrationPauseHandler(async () => {
|
|
2228
2469
|
pauseRequested = true;
|
|
2470
|
+
// Cancel the backend session first. This immediately releases every queued reservation and
|
|
2471
|
+
// prevents another job from being claimed while already-running requests finish safely.
|
|
2472
|
+
const canceledBeforePause = await cancelActiveMigration();
|
|
2229
2473
|
if (pauseCompletion)
|
|
2230
2474
|
await pauseCompletion;
|
|
2475
|
+
// If End for now raced with import-session creation, the cleanup handle becomes available only
|
|
2476
|
+
// after the local worker loop observes pauseRequested. Cancel it before acknowledging the UI.
|
|
2477
|
+
if (!canceledBeforePause)
|
|
2478
|
+
await cancelActiveMigration();
|
|
2231
2479
|
});
|
|
2232
2480
|
const choice = await srv.decision;
|
|
2233
2481
|
if (choice === "migrate") {
|
|
2234
|
-
const { res } = await srv.migrateRequest;
|
|
2482
|
+
const { res, conversationKeys } = await srv.migrateRequest;
|
|
2235
2483
|
let migrateResponded = false;
|
|
2236
2484
|
const sendMigrate = (body, status = 200) => {
|
|
2237
2485
|
if (migrateResponded)
|
|
@@ -2311,7 +2559,11 @@ async function cmdLogin(flags) {
|
|
|
2311
2559
|
process.exitCode = 1;
|
|
2312
2560
|
return true;
|
|
2313
2561
|
}
|
|
2314
|
-
|
|
2562
|
+
const requestedKeys = conversationKeys ? new Set(conversationKeys) : null;
|
|
2563
|
+
const selectedPending = requestedKeys
|
|
2564
|
+
? exact.pending.filter((session) => requestedKeys.has(session.conversationKey))
|
|
2565
|
+
: exact.pending;
|
|
2566
|
+
activeJobCount = selectedPending.length;
|
|
2315
2567
|
const updateProgress = (patch) => {
|
|
2316
2568
|
srv.setProgress({
|
|
2317
2569
|
status: "running",
|
|
@@ -2327,7 +2579,7 @@ async function cmdLogin(flags) {
|
|
|
2327
2579
|
});
|
|
2328
2580
|
};
|
|
2329
2581
|
try {
|
|
2330
|
-
if (
|
|
2582
|
+
if (selectedPending.length === 0) {
|
|
2331
2583
|
srv.setProgress({
|
|
2332
2584
|
status: "completed",
|
|
2333
2585
|
total: 0,
|
|
@@ -2343,7 +2595,7 @@ async function cmdLogin(flags) {
|
|
|
2343
2595
|
console.log("Setup complete — no unprocessed local conversations to extract.");
|
|
2344
2596
|
return true;
|
|
2345
2597
|
}
|
|
2346
|
-
updateProgress({ status: "starting", running: 0, queued:
|
|
2598
|
+
updateProgress({ status: "starting", running: 0, queued: selectedPending.length, latest: "Creating import session." });
|
|
2347
2599
|
// Plan caps limit how many conversations one import session accepts (IMPORT_LIMIT_EXCEEDED →
|
|
2348
2600
|
// startMigration slices to the cap). Instead of making the user re-run setup per batch (3000
|
|
2349
2601
|
// sessions used to mean 3 clicks), loop batches automatically until everything pending is done.
|
|
@@ -2362,7 +2614,7 @@ async function cmdLogin(flags) {
|
|
|
2362
2614
|
...(latestRepo ? { latestRepo } : {}),
|
|
2363
2615
|
});
|
|
2364
2616
|
};
|
|
2365
|
-
let remaining =
|
|
2617
|
+
let remaining = selectedPending;
|
|
2366
2618
|
let stoppedReason;
|
|
2367
2619
|
let planLimitNote;
|
|
2368
2620
|
let batchIndex = 0;
|
|
@@ -2383,10 +2635,12 @@ async function cmdLogin(flags) {
|
|
|
2383
2635
|
if (batchIndex === 1)
|
|
2384
2636
|
throw batchError; // first batch failing = the whole import failed
|
|
2385
2637
|
// A later batch could not start (e.g. plan headroom exhausted). Finish gracefully with a note.
|
|
2386
|
-
planLimitNote = `Imported ${progressDone}
|
|
2638
|
+
planLimitNote = `Imported ${progressDone}. Upgrade your plan to import the remaining ${remaining.length} conversations.`;
|
|
2387
2639
|
break;
|
|
2388
2640
|
}
|
|
2389
2641
|
activeSessionId = h.sessionId;
|
|
2642
|
+
activeMigrationCleanup = h.cancelQueued;
|
|
2643
|
+
activeMigrationCleanupPromise = null;
|
|
2390
2644
|
if (batchIndex === 1) {
|
|
2391
2645
|
updateProgress({ status: "running", sessionId: h.sessionId, jobCount: activeJobCount, total: activeJobCount, latest: "Import session created." });
|
|
2392
2646
|
sendMigrate({ sessionId: h.sessionId, jobCount: activeJobCount });
|
|
@@ -2404,10 +2658,12 @@ async function cmdLogin(flags) {
|
|
|
2404
2658
|
stoppedReason = r.stoppedReason;
|
|
2405
2659
|
break;
|
|
2406
2660
|
}
|
|
2407
|
-
//
|
|
2661
|
+
// A historical allowance is lifetime, not a per-batch cap. Stop cleanly
|
|
2662
|
+
// after the allowed slice instead of attempting another session.
|
|
2408
2663
|
if (h.capped && remaining.length > h.jobCount) {
|
|
2409
|
-
|
|
2410
|
-
|
|
2664
|
+
const left = remaining.length - h.jobCount;
|
|
2665
|
+
planLimitNote = `Import complete for this plan. Upgrade to import the remaining ${left} conversations.`;
|
|
2666
|
+
break;
|
|
2411
2667
|
}
|
|
2412
2668
|
break;
|
|
2413
2669
|
}
|
|
@@ -2422,9 +2678,9 @@ async function cmdLogin(flags) {
|
|
|
2422
2678
|
queued: Math.max(0, activeJobCount - progressDone - progressFailed),
|
|
2423
2679
|
failed: progressFailed,
|
|
2424
2680
|
extracted: progressExtracted,
|
|
2425
|
-
latest: "
|
|
2681
|
+
latest: "Ended for now. Re-run setup to rebuild the remaining conversation list.",
|
|
2426
2682
|
});
|
|
2427
|
-
console.log(`Import
|
|
2683
|
+
console.log(`Import ended for now: ${progressDone} imported, ${progressExtracted} memories, ${progressFailed} failed.`);
|
|
2428
2684
|
completePause();
|
|
2429
2685
|
// /skip sends the browser acknowledgement and closes the localhost bridge after this
|
|
2430
2686
|
// safe pause boundary. Do not close it here first or the page can claim success early.
|
|
@@ -2461,7 +2717,7 @@ async function cmdLogin(flags) {
|
|
|
2461
2717
|
queued: Math.max(0, activeJobCount - progressDone - progressFailed),
|
|
2462
2718
|
failed: progressFailed,
|
|
2463
2719
|
extracted: progressExtracted,
|
|
2464
|
-
latest: "
|
|
2720
|
+
latest: "Ended for now. Re-run setup to rebuild the remaining conversation list.",
|
|
2465
2721
|
});
|
|
2466
2722
|
completePause();
|
|
2467
2723
|
return true;
|
|
@@ -2488,8 +2744,13 @@ async function cmdLogin(flags) {
|
|
|
2488
2744
|
sendMigrate({ error: "NO_PENDING_SESSIONS" }, 409);
|
|
2489
2745
|
else if (e?.code === "IMPORT_START_TIMEOUT")
|
|
2490
2746
|
sendMigrate({ error: "IMPORT_START_TIMEOUT" }, 504);
|
|
2491
|
-
else
|
|
2492
|
-
|
|
2747
|
+
else {
|
|
2748
|
+
const responseData = isObjectRecord(e?.response?.data) ? e.response.data : {};
|
|
2749
|
+
const responseCode = asString(responseData.error) || "IMPORT_START_FAILED";
|
|
2750
|
+
const responseMessage = asString(responseData.message) || String(e?.message || e);
|
|
2751
|
+
const responseStatus = typeof e?.response?.status === "number" ? e.response.status : 500;
|
|
2752
|
+
sendMigrate({ error: responseCode, message: responseMessage }, responseStatus);
|
|
2753
|
+
}
|
|
2493
2754
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
2494
2755
|
srv.close();
|
|
2495
2756
|
process.exitCode = 1;
|
|
@@ -2517,7 +2778,9 @@ async function cmdUnlock(flags) {
|
|
|
2517
2778
|
let passphrase = typeof flags.passphrase === "string" ? flags.passphrase : undefined;
|
|
2518
2779
|
const key = typeof flags.key === "string" ? flags.key : undefined;
|
|
2519
2780
|
if (!passphrase && !key) {
|
|
2520
|
-
|
|
2781
|
+
console.log("This is a private, one-time unlock for this trusted device.");
|
|
2782
|
+
console.log("Enter your vault passphrase below. Your typing is hidden; press Return when finished.");
|
|
2783
|
+
passphrase = await prompt("Vault passphrase (typing is hidden): ", { silent: true });
|
|
2521
2784
|
}
|
|
2522
2785
|
const keyB64 = passphrase ? await deriveAndVerifyKey(passphrase, config) : key && (await verifyKeyB64(key, config)) ? key : null;
|
|
2523
2786
|
if (!keyB64) {
|
|
@@ -2526,7 +2789,22 @@ async function cmdUnlock(flags) {
|
|
|
2526
2789
|
return;
|
|
2527
2790
|
}
|
|
2528
2791
|
store.saveKey(keyB64);
|
|
2529
|
-
console.log("✅ Vault unlocked.
|
|
2792
|
+
console.log("✅ Vault unlocked. This device stays unlocked until you run `echomem-mcp lock` or log out.");
|
|
2793
|
+
console.log("Retry the EchoMem action in your current agent session — no restart needed.");
|
|
2794
|
+
}
|
|
2795
|
+
function cmdLock() {
|
|
2796
|
+
if (process.env.ECHO_ENCRYPTION_KEY) {
|
|
2797
|
+
console.error("The vault key comes from ECHO_ENCRYPTION_KEY. Unset that environment variable to lock this device.");
|
|
2798
|
+
process.exitCode = 1;
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2801
|
+
const store = new KeyStore();
|
|
2802
|
+
if (!store.getKey()) {
|
|
2803
|
+
console.log("EchoMem vault is already locked.");
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
store.clearKey();
|
|
2807
|
+
console.log("🔒 EchoMem vault locked on this device. Your login remains connected.");
|
|
2530
2808
|
}
|
|
2531
2809
|
async function cmdStatus(flags = {}) {
|
|
2532
2810
|
const store = new KeyStore();
|
|
@@ -2563,7 +2841,13 @@ async function cmdStatus(flags = {}) {
|
|
|
2563
2841
|
console.log(`Logged in as: (could not verify — ${formatVerificationError(error)})`);
|
|
2564
2842
|
}
|
|
2565
2843
|
}
|
|
2566
|
-
|
|
2844
|
+
const key = store.getKey();
|
|
2845
|
+
const keyStatus = key
|
|
2846
|
+
? process.env.ECHO_ENCRYPTION_KEY
|
|
2847
|
+
? "present — provided by environment"
|
|
2848
|
+
: "present — trusted on this device until `echomem-mcp lock` or logout"
|
|
2849
|
+
: "not set — open Terminal and run `echomem-mcp unlock` for encrypted accounts";
|
|
2850
|
+
console.log(`Encryption key: ${keyStatus}`);
|
|
2567
2851
|
const detected = detectClients();
|
|
2568
2852
|
console.log(`Detected clients: ${detected.length ? detected.map((c) => c.label).join(", ") : "none auto-detected"}`);
|
|
2569
2853
|
const reports = inspectClientConfigs(latest ?? MCP_PACKAGE_VERSION);
|
|
@@ -2592,11 +2876,13 @@ Usage:
|
|
|
2592
2876
|
echomem-mcp Run the MCP server (stdio; default — used by your editor)
|
|
2593
2877
|
echomem-mcp setup [--client X] Detect editor, write its MCP config, then log in
|
|
2594
2878
|
echomem-mcp setup --skip-login Write MCP config without opening login/browser
|
|
2879
|
+
echomem-mcp setup --no-codex-skills Skip installing the bundled EchoMem Codex skills
|
|
2595
2880
|
echomem-mcp update --all Repoint detected clients to this installed bridge; no login/browser
|
|
2596
2881
|
echomem-mcp update --client X Repoint one MCP client; no login/browser
|
|
2597
2882
|
echomem-mcp setup --with-hud Configure MCP, then launch the EchoMem context HUD
|
|
2598
2883
|
echomem-mcp login Approve this device in the browser (or --token/--passphrase)
|
|
2599
|
-
echomem-mcp unlock
|
|
2884
|
+
echomem-mcp unlock Privately unlock the vault on this trusted device
|
|
2885
|
+
echomem-mcp lock Remove the local vault key while keeping the device login
|
|
2600
2886
|
echomem-mcp status Show token/key/clients
|
|
2601
2887
|
echomem-mcp doctor [--no-network] Diagnose configured client bridge versions
|
|
2602
2888
|
echomem-mcp logout Remove stored credentials
|
|
@@ -2640,6 +2926,9 @@ export async function runCli(argv) {
|
|
|
2640
2926
|
case "unlock":
|
|
2641
2927
|
await cmdUnlock(flags);
|
|
2642
2928
|
return true;
|
|
2929
|
+
case "lock":
|
|
2930
|
+
cmdLock();
|
|
2931
|
+
return true;
|
|
2643
2932
|
case "status":
|
|
2644
2933
|
await cmdStatus(flags);
|
|
2645
2934
|
return true;
|