@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/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: home(".codex"), configPath: home(".codex", "config.toml"), note: "add to ~/.codex/config.toml under [mcp_servers.echomem]" },
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, { connected, authUrl, switchAccountUrl: switchAccountUrl || authUrl, localOnly: true, workspacePath: process.cwd() });
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 response = await authedAxios(token).get("/api/extension/account/bootstrap", { timeout: 6000 });
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 and saved. Reload your MCP client.";
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
- rl._writeToOutput = () => out.write("");
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), writes the AGENTS.md
1699
- * memory guidance, logs in via the browser, and launches the context HUD — the whole product in a
1700
- * single command. `setup`/`update` remain the granular primitives; init just picks the "do everything"
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(home(".codex", "AGENTS.md"), "Codex");
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
- // A pause is deliberately transient. Completed conversations remain recorded in the normal
2224
- // import ledger; unfinished conversations are rediscovered and offered again by the next init.
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
- activeJobCount = exact.pending.length;
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 (exact.pending.length === 0) {
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: exact.pending.length, latest: "Creating import session." });
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 = exact.pending;
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} so far — the rest hit your plan's import limit. Re-run extraction later for the remaining ${remaining.length}.`;
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
- // startMigration sliced to the cap more remain: continue with the next batch automatically.
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
- remaining = remaining.slice(h.jobCount);
2410
- continue;
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: "Paused by user. Re-run setup to continue the remaining conversations.",
2681
+ latest: "Ended for now. Re-run setup to rebuild the remaining conversation list.",
2426
2682
  });
2427
- console.log(`Import paused: ${progressDone} imported, ${progressExtracted} memories, ${progressFailed} failed.`);
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: "Paused by user. Re-run setup to continue the remaining conversations.",
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
- sendMigrate({ error: "IMPORT_START_FAILED", message: String(e?.message || e) }, 500);
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
- passphrase = await prompt("Vault passphrase: ", { silent: true });
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. Reload your MCP client (or start a new session).");
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
- console.log(`Encryption key: ${store.getKey() ? "present" : store.isKeyExpired() ? "EXPIRED — run `echomem-mcp unlock`" : "not set"}`);
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 Re-derive the encryption key after its TTL (or --passphrase)
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;