@echomem/mcp 1.4.26 → 1.4.28

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
@@ -25,13 +25,13 @@ import { fileURLToPath, pathToFileURL } from "node:url";
25
25
  import axios from "axios";
26
26
  import { KeyStore } from "./keystore.js";
27
27
  import { fetchEncryptionConfig, deriveAndVerifyKey, setupNewEncryptionKey, verifyKeyB64 } from "./encryption.js";
28
- import { collect, runReport, buildStatsPayload } from "./report.js";
29
- import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableFastDiscovery, discoverMigratableSessions, discoverPendingSessionsTargeted, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, MIGRATE_CONCURRENCY, } from "./migrate.js";
28
+ import { runReport, buildStatsPayload } from "./report.js";
29
+ import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableSessions, discoverPendingSessionsTargeted, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, MIGRATE_CONCURRENCY, } from "./migrate.js";
30
30
  import { syncCodexUsage } from "./codex-sync.js";
31
31
  import { renderSetupPage } from "./setup-page.js";
32
32
  import { parseSetupPreviewState } from "./setup-preview.js";
33
33
  import { repoLabel, validateForensicReportForSetup } from "./forensics.js";
34
- import { installHooks } from "./hud/hooks.js";
34
+ import { installHooks, installSaveCheckpointHooks } from "./hud/hooks.js";
35
35
  import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
36
36
  import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
37
37
  // The setup dashboard, account login, and encryption passphrase entry are all served by this
@@ -389,17 +389,19 @@ function echomemGuidanceBlock() {
389
389
  "EchoMem is your long-term memory across all coding sessions and tools.",
390
390
  "- 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.",
391
391
  "- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
392
- "- When meaningful work wraps up (a decision, a fix, a milestone) or the user asks to remember something: call `save_conversation`.",
392
+ "- Before the final response for a task that produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone: call `save_conversation`. This private checkpoint does not require a separate user request. Do not save secrets, credentials, incidental chatter, or trivial read-only work. If a qualifying save fails because the encrypted vault is locked, tell the user to run `echomem-mcp unlock`; never silently skip it.",
393
+ "- For a user with a company group, call `get_group_session_sharing` near session start or after the first qualifying private save. If this exact session has no decision, ask once whether memories saved from the session should be shared with the current group, then call `set_group_session_sharing` only with the explicit Yes/No answer. A Yes decision syncs eligible memories after each private save; a No decision keeps later saves private.",
393
394
  "- 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.",
394
395
  "",
395
396
  "### Company group memory",
396
397
  "- Use `get_group_context` when the user asks who is in their company group, what teammates are responsible for, or what work is already covered. Treat declared participant fields as facts and published-memory conclusions as evidence or inference.",
397
398
  "- A group publication is separate from a globally public memory: publishing to a group creates a group-scoped snapshot and must not change the encrypted original or its global `is_public` setting.",
399
+ "- Group session sharing is scoped to the exact current MCP session and current group. Membership is rechecked for each sync. Flagged memories are withheld from automatic session sync and remain private.",
398
400
  "- If a user asks to create a group, call `create_memory_group`; if they ask for a code to share, call `create_group_invite` and return the secret invite code only to that user. Never save the invite code to memory or include it in logs, analytics, summaries, or unrelated output.",
399
401
  "- If a user supplies an `echo_grp_...` code and explicitly asks to join, call `join_memory_group`. Joining never authorizes publishing by itself and must not move a user out of another group. After joining, continue into the profile-and-publication preview instead of leaving title or responsibility blank.",
400
402
  "- For requests such as “prepare my recent work memories,” “upload work from this ticket,” or “publish work since my last sync,” call `prepare_group_publication` first. This is a no-publication preview. For encrypted accounts, tell the user to run `echomem-mcp unlock` locally if the tool reports that the key is required.",
401
403
  "- After preparing, select only exact candidate memory IDs that match the user's stated scope and exclude already-published or exact-content duplicates. Use the candidate evidence to draft a concise title and responsibility summary for the current member, but label both as proposals rather than facts.",
402
- "- Use two distinct evidence links. For the user's own source memories during search, flag review, or publication preview, preserve the Memory ID and use `https://echoknows.com/memories/timeline?memoryId=...`; this is an owner-only personal link and may require Vault unlock. For already-published results from `search_others_memories`, use `https://echoknows.com/company/memories?memoryId=...`, which requires current group access.",
404
+ "- Use one canonical evidence link for every memory: preserve the Memory ID and link to `https://echoknows.com/memory/<memory-id>`. The site resolves the authorized representation: an owner is sent to their private timeline, while current group/friend access opens an authorized publication snapshot or public memory. The visible Markdown label should use the memory key, not the raw URL or UUID.",
403
405
  "- If the user asks to flag memories about a sensitive topic, search their own memories first, show the exact matches with owner-only personal links, and ask them to confirm. Only then call `flag_memories_for_publication_attention`; flagging does not publish, decrypt, change visibility, or retract an existing group snapshot.",
404
406
  "- Present the proposed title/responsibility and the memory publication preview together and ask for explicit confirmation. Never save an inferred profile or publish memories before confirmation. If an unflagged candidate appears sensitive, proactively ask whether the user wants to mark its exact ID for publication attention first. Explain naturally: marking does not publish or change encryption; it means you will call it out and ask for detailed confirmation whenever a later publication includes it. Never auto-flag based on agent inference. Show already-flagged candidates in a separate warning, state that nothing has been published yet, and offer three choices: exclude them, review them separately, or first search for and mark similar sensitive owned memories for publication attention.",
405
407
  "- On confirmation, call `update_group_profile` with the confirmed title, responsibility summary, and `confirmed: true`, then call `complete_group_publication` with the exact `scanId`, selected memory IDs, and `confirmed: true`. If a flagged memory is selected, require separate explicit acknowledgement and pass its exact ID in `acknowledgedFlaggedMemoryIds`. If the user edits either proposal, use their wording. An explicit request to join and upload still requires this preview and confirmation.",
@@ -1143,6 +1145,155 @@ function discoverMigratableSessionsOffThread() {
1143
1145
  });
1144
1146
  });
1145
1147
  }
1148
+ export function discoverMigratableFastOffThread(opts = {}) {
1149
+ const migrateUrl = runtimeModuleUrl("migrate");
1150
+ const serializedOpts = JSON.stringify(opts);
1151
+ const code = `
1152
+ import { parentPort } from "node:worker_threads";
1153
+ import { discoverMigratableFastDiscovery } from ${JSON.stringify(migrateUrl)};
1154
+
1155
+ try {
1156
+ parentPort?.postMessage({ ok: true, discovery: discoverMigratableFastDiscovery(${serializedOpts}) });
1157
+ } catch (error) {
1158
+ parentPort?.postMessage({
1159
+ ok: false,
1160
+ message: error instanceof Error ? error.message : String(error),
1161
+ stack: error instanceof Error ? error.stack : undefined,
1162
+ });
1163
+ }
1164
+ `;
1165
+ const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
1166
+ return new Promise((resolve, reject) => {
1167
+ let settled = false;
1168
+ worker.once("message", (message) => {
1169
+ settled = true;
1170
+ const msg = message;
1171
+ if (msg.ok === true && msg.discovery && typeof msg.discovery === "object") {
1172
+ resolve(msg.discovery);
1173
+ return;
1174
+ }
1175
+ const err = new Error(typeof msg.message === "string" ? msg.message : "Quick local discovery failed");
1176
+ if (typeof msg.stack === "string")
1177
+ err.stack = msg.stack;
1178
+ reject(err);
1179
+ });
1180
+ worker.once("error", (error) => {
1181
+ if (settled)
1182
+ return;
1183
+ settled = true;
1184
+ reject(error);
1185
+ });
1186
+ worker.once("exit", (code) => {
1187
+ if (settled)
1188
+ return;
1189
+ settled = true;
1190
+ reject(new Error(`Quick local discovery worker exited (code ${code}) without a result`));
1191
+ });
1192
+ });
1193
+ }
1194
+ /** Build the full local-history dashboard payload away from the callback server's event loop.
1195
+ * `collect()` can synchronously parse hundreds of JSONL files for tens of seconds; doing that on
1196
+ * the bridge thread prevents even localhost actions such as account switch from receiving a reply. */
1197
+ export function buildCollectedStatsPayloadOffThread(inject) {
1198
+ const reportUrl = runtimeModuleUrl("report");
1199
+ const serializedInject = JSON.stringify(inject);
1200
+ const code = `
1201
+ import { parentPort } from "node:worker_threads";
1202
+ import { collect, buildStatsPayload } from ${JSON.stringify(reportUrl)};
1203
+
1204
+ try {
1205
+ const payload = await buildStatsPayload(collect(), ${serializedInject});
1206
+ parentPort?.postMessage({ ok: true, payload });
1207
+ } catch (error) {
1208
+ parentPort?.postMessage({
1209
+ ok: false,
1210
+ message: error instanceof Error ? error.message : String(error),
1211
+ stack: error instanceof Error ? error.stack : undefined,
1212
+ });
1213
+ }
1214
+ `;
1215
+ const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
1216
+ return new Promise((resolve, reject) => {
1217
+ let settled = false;
1218
+ const finish = (result) => {
1219
+ if (settled)
1220
+ return;
1221
+ settled = true;
1222
+ void worker.terminate();
1223
+ if (result.ok)
1224
+ resolve(result.payload);
1225
+ else
1226
+ reject(result.error);
1227
+ };
1228
+ worker.once("message", (message) => {
1229
+ const msg = message;
1230
+ if (msg.ok === true) {
1231
+ finish({ ok: true, payload: msg.payload });
1232
+ return;
1233
+ }
1234
+ const error = new Error(typeof msg.message === "string" ? msg.message : "Full local stats worker failed");
1235
+ if (typeof msg.stack === "string")
1236
+ error.stack = msg.stack;
1237
+ finish({ ok: false, error });
1238
+ });
1239
+ worker.once("error", (error) => {
1240
+ finish({ ok: false, error });
1241
+ });
1242
+ worker.once("exit", (code) => {
1243
+ if (settled)
1244
+ return;
1245
+ finish({ ok: false, error: new Error(`Full local stats worker exited (code ${code}) without a result`) });
1246
+ });
1247
+ });
1248
+ }
1249
+ export function createLocalDiscoveryCache(loaders = {}) {
1250
+ const loadQuick = loaders.loadQuick ?? (() => discoverMigratableFastOffThread());
1251
+ const loadExact = loaders.loadExact ?? (() => discoverMigratableSessionsOffThread());
1252
+ let quick = null;
1253
+ let quickPromise = null;
1254
+ let exact = null;
1255
+ let exactPromise = null;
1256
+ return {
1257
+ getQuick() {
1258
+ if (quick)
1259
+ return Promise.resolve(quick);
1260
+ if (quickPromise)
1261
+ return quickPromise;
1262
+ quickPromise = loadQuick()
1263
+ .then((discovery) => {
1264
+ quick = discovery;
1265
+ return discovery;
1266
+ })
1267
+ .catch((error) => {
1268
+ quickPromise = null;
1269
+ throw error;
1270
+ });
1271
+ return quickPromise;
1272
+ },
1273
+ getExact() {
1274
+ if (exact)
1275
+ return Promise.resolve(exact);
1276
+ if (exactPromise)
1277
+ return exactPromise;
1278
+ exactPromise = loadExact()
1279
+ .then((discovery) => {
1280
+ exact = discovery;
1281
+ return discovery;
1282
+ })
1283
+ .catch((error) => {
1284
+ exactPromise = null;
1285
+ throw error;
1286
+ });
1287
+ return exactPromise;
1288
+ },
1289
+ peekExact() {
1290
+ return exact;
1291
+ },
1292
+ pendingExact() {
1293
+ return exactPromise;
1294
+ },
1295
+ };
1296
+ }
1146
1297
  function forensicStageLabel(stage) {
1147
1298
  if (stage === "reading-transcripts")
1148
1299
  return "Reading transcript files";
@@ -1164,7 +1315,9 @@ export function buildForensicReportOffThread(onProgress, options = {}) {
1164
1315
  try {
1165
1316
  const report = await buildForensicReport({
1166
1317
  includeLegacyGoldenStandard: false,
1167
- onProgress: (done, total, stage, detail) => parentPort?.postMessage({ progress: { done, total, stage, detail } }),
1318
+ onProgress: (done, total, stage, detail, overall, stageDone, stageTotal) => parentPort?.postMessage({
1319
+ progress: { done, total, stage, detail, overall, stageDone, stageTotal },
1320
+ }),
1168
1321
  });
1169
1322
  parentPort?.postMessage({ ok: true, report });
1170
1323
  } catch (error) {
@@ -1253,7 +1406,11 @@ function publicRunningForensicProgress(value) {
1253
1406
  const safeDuration = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
1254
1407
  ? candidate
1255
1408
  : 0);
1409
+ const safeFraction = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate)
1410
+ ? Math.min(1, Math.max(0, candidate))
1411
+ : 0);
1256
1412
  const total = safeCount(progress.total);
1413
+ const stageTotal = safeCount(progress.stageTotal);
1257
1414
  const rawStage = typeof progress.stage === "string" ? progress.stage : "starting";
1258
1415
  const stage = ["starting", "reading-transcripts", "building-summary", "classifying-repeated-context", "finalizing-report"].includes(rawStage)
1259
1416
  ? rawStage
@@ -1264,6 +1421,9 @@ function publicRunningForensicProgress(value) {
1264
1421
  total,
1265
1422
  stage,
1266
1423
  label: forensicStageLabel(stage),
1424
+ stageDone: stageTotal > 0 ? Math.min(safeCount(progress.stageDone), stageTotal) : 0,
1425
+ stageTotal,
1426
+ overall: safeFraction(progress.overall),
1267
1427
  elapsedMs: safeDuration(progress.elapsedMs),
1268
1428
  stageElapsedMs: safeDuration(progress.stageElapsedMs),
1269
1429
  updatedAt: safeDuration(progress.updatedAt) || Date.now(),
@@ -1289,6 +1449,7 @@ export function startCallbackServer(opts = {}) {
1289
1449
  const requiresReportConsent = opts.requireReportConsent === true && !isLoginFlow;
1290
1450
  return new Promise((resolveOuter, rejectOuter) => {
1291
1451
  const onToken = deferred();
1452
+ const setupExit = deferred();
1292
1453
  const decision = deferred();
1293
1454
  const migrateRequest = deferred();
1294
1455
  let stats = null;
@@ -1296,6 +1457,7 @@ export function startCallbackServer(opts = {}) {
1296
1457
  let switchAccountUrl = "";
1297
1458
  let connected = Boolean(opts.initialToken?.token);
1298
1459
  let activeDeviceToken = opts.initialToken?.token || "";
1460
+ let activeAccountEmail = "";
1299
1461
  let pendingLocalAuth = null;
1300
1462
  let reportConsentGranted = !requiresReportConsent;
1301
1463
  let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
@@ -1330,8 +1492,8 @@ export function startCallbackServer(opts = {}) {
1330
1492
  return false;
1331
1493
  return revokeDeviceToken(pending.token);
1332
1494
  };
1333
- const completeDeviceLogin = async (token) => {
1334
- await authedAxios(token).post("/api/extension/mcp/local-auth/complete-device", {}, { timeout: 10_000 });
1495
+ const completeDeviceLogin = async (token, encryptionPreference = "maximum") => {
1496
+ await authedAxios(token).post("/api/extension/mcp/local-auth/complete-device", { encryptionPreference }, { timeout: 10_000 });
1335
1497
  };
1336
1498
  const close = () => {
1337
1499
  if (timer)
@@ -1407,6 +1569,7 @@ export function startCallbackServer(opts = {}) {
1407
1569
  const firstToken = !onToken.settled();
1408
1570
  connected = true;
1409
1571
  activeDeviceToken = token;
1572
+ activeAccountEmail = pendingLocalAuth?.email || activeAccountEmail;
1410
1573
  pendingLocalAuth = null;
1411
1574
  const callbackToken = { token, key };
1412
1575
  if (firstToken) {
@@ -1425,6 +1588,7 @@ export function startCallbackServer(opts = {}) {
1425
1588
  "/stats",
1426
1589
  "/billing-status",
1427
1590
  "/billing-checkout",
1591
+ "/billing-checkout-sync",
1428
1592
  "/billing-portal",
1429
1593
  "/progress",
1430
1594
  "/migrate",
@@ -1563,6 +1727,38 @@ export function startCallbackServer(opts = {}) {
1563
1727
  json(res, detail.status, { ok: false, error: detail.message });
1564
1728
  }
1565
1729
  };
1730
+ const handleLocalSkipEncryption = async (res, body) => {
1731
+ if (rejectIfLocalAuthBlocked(res, asString(body.nonce)))
1732
+ return;
1733
+ if (!pendingLocalAuth || pendingLocalAuth.expiresAtMs <= Date.now()) {
1734
+ await revokePendingLocalAuth();
1735
+ return void json(res, 409, {
1736
+ ok: false,
1737
+ error: "This local login expired. Send a new verification code.",
1738
+ reset: true,
1739
+ });
1740
+ }
1741
+ if (pendingLocalAuth.mode !== "setup") {
1742
+ return void json(res, 409, {
1743
+ ok: false,
1744
+ error: "This account already uses encrypted memory. Enter the vault passphrase to continue.",
1745
+ });
1746
+ }
1747
+ try {
1748
+ await completeDeviceLogin(pendingLocalAuth.token, "standard");
1749
+ resolveLocalToken(pendingLocalAuth.token, undefined);
1750
+ json(res, 200, {
1751
+ ok: true,
1752
+ connected: true,
1753
+ encryptionEnabled: false,
1754
+ encryptionPreference: "standard",
1755
+ });
1756
+ }
1757
+ catch (error) {
1758
+ const detail = publicAxiosError(error, "Failed to continue without encryption");
1759
+ json(res, detail.status, { ok: false, error: detail.message });
1760
+ }
1761
+ };
1566
1762
  server = http.createServer((req, res) => {
1567
1763
  res.setHeader("Access-Control-Allow-Origin", "*");
1568
1764
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
@@ -1627,6 +1823,18 @@ export function startCallbackServer(opts = {}) {
1627
1823
  });
1628
1824
  return;
1629
1825
  }
1826
+ if (route === "/local-auth/skip-encryption" && req.method === "POST") {
1827
+ let body;
1828
+ try {
1829
+ body = await readJsonBody(req);
1830
+ }
1831
+ catch {
1832
+ text(res, 400, "bad json");
1833
+ return;
1834
+ }
1835
+ await handleLocalSkipEncryption(res, body);
1836
+ return;
1837
+ }
1630
1838
  if (route === "/launch-agent" && req.method === "POST") {
1631
1839
  let body;
1632
1840
  try {
@@ -1740,7 +1948,18 @@ export function startCallbackServer(opts = {}) {
1740
1948
  const token = activeDeviceToken || new KeyStore().getToken();
1741
1949
  const pricingUrl = `${PRICING_URL}?source=mcp_onboarding`;
1742
1950
  if (!token) {
1743
- json(res, 200, { plan: "free", paid: false, trialAvailable: true, trialUsed: false, pricingUrl });
1951
+ json(res, 200, {
1952
+ plan: "free",
1953
+ paid: false,
1954
+ trialAvailable: true,
1955
+ trialUsed: false,
1956
+ subscriptionStatus: null,
1957
+ trialStartedAt: null,
1958
+ trialEndedAt: null,
1959
+ currentPeriodEnd: null,
1960
+ cancelAtPeriodEnd: false,
1961
+ pricingUrl,
1962
+ });
1744
1963
  return;
1745
1964
  }
1746
1965
  try {
@@ -1752,24 +1971,84 @@ export function startCallbackServer(opts = {}) {
1752
1971
  const plan = (asString(response.data?.plan) || "free").toLowerCase();
1753
1972
  const trialUsed = response.data?.billing?.trialUsed === true;
1754
1973
  const trialAvailable = response.data?.billing?.trialAvailable !== false;
1974
+ const accountEmail = asString(profileResponse?.data?.email) || activeAccountEmail;
1975
+ if (accountEmail)
1976
+ activeAccountEmail = accountEmail;
1755
1977
  json(res, 200, {
1756
1978
  plan,
1757
1979
  paid: ["pro", "power", "team", "enterprise"].includes(plan),
1758
1980
  trialAvailable,
1759
1981
  trialUsed,
1982
+ subscriptionStatus: asString(response.data?.billing?.subscriptionStatus) || null,
1983
+ trialStartedAt: asString(response.data?.billing?.trialStartedAt) || null,
1984
+ trialEndedAt: asString(response.data?.billing?.trialEndedAt) || null,
1985
+ currentPeriodEnd: asString(response.data?.billing?.currentPeriodEnd) || null,
1986
+ cancelAtPeriodEnd: response.data?.billing?.cancelAtPeriodEnd === true,
1760
1987
  historicalConversationQuota: response.data?.historicalConversationQuota ?? null,
1761
1988
  memoryProcessingQuota: response.data?.memoryProcessingQuota ?? null,
1762
1989
  memorySearchQuota: response.data?.memorySearchQuota ?? null,
1763
- account: profileResponse ? {
1764
- displayName: asString(profileResponse.data?.displayName) || "EchoMem user",
1765
- email: asString(profileResponse.data?.email) || "",
1766
- avatarUrl: asString(profileResponse.data?.avatarUrl) || "",
1990
+ activation: response.data?.activation ?? null,
1991
+ account: accountEmail ? {
1992
+ email: accountEmail,
1993
+ avatarUrl: asString(profileResponse?.data?.avatarUrl) || "",
1767
1994
  } : null,
1768
1995
  pricingUrl,
1769
1996
  });
1770
1997
  }
1771
1998
  catch {
1772
- json(res, 200, { plan: "unknown", paid: false, trialAvailable: true, trialUsed: false, pricingUrl });
1999
+ json(res, 200, {
2000
+ plan: "unknown",
2001
+ paid: false,
2002
+ trialAvailable: false,
2003
+ trialUsed: false,
2004
+ subscriptionStatus: null,
2005
+ trialStartedAt: null,
2006
+ trialEndedAt: null,
2007
+ currentPeriodEnd: null,
2008
+ cancelAtPeriodEnd: false,
2009
+ pricingUrl,
2010
+ });
2011
+ }
2012
+ return;
2013
+ }
2014
+ if (route === "/billing-checkout-sync" && req.method === "POST") {
2015
+ let body;
2016
+ try {
2017
+ body = await readJsonBody(req);
2018
+ }
2019
+ catch {
2020
+ text(res, 400, "bad json");
2021
+ return;
2022
+ }
2023
+ if (!checkNonce(asString(body.nonce)))
2024
+ return void text(res, 403, "bad nonce");
2025
+ const sessionId = asString(body.sessionId);
2026
+ if (!sessionId || !/^cs_(?:test_|live_)?[A-Za-z0-9_]+$/.test(sessionId)) {
2027
+ return void json(res, 400, {
2028
+ error: "INVALID_CHECKOUT_SESSION",
2029
+ message: "Checkout session is missing or invalid.",
2030
+ });
2031
+ }
2032
+ const token = activeDeviceToken || new KeyStore().getToken();
2033
+ if (!connected || !token) {
2034
+ return void json(res, 401, {
2035
+ error: "ECHOMEM_LOGIN_REQUIRED",
2036
+ message: "Connect your EchoMem account before syncing checkout.",
2037
+ });
2038
+ }
2039
+ try {
2040
+ const response = await axios.post(hostedBillingEndpoint("/api/billing/checkout/sync"), { sessionId }, {
2041
+ timeout: 15_000,
2042
+ headers: {
2043
+ "Content-Type": "application/json",
2044
+ Authorization: `Bearer ${token}`,
2045
+ },
2046
+ });
2047
+ json(res, 200, { ok: response.data?.ok === true });
2048
+ }
2049
+ catch (error) {
2050
+ const detail = publicAxiosError(error, "Checkout is still syncing.");
2051
+ json(res, detail.status, { error: "BILLING_SYNC_PENDING", message: detail.message });
1773
2052
  }
1774
2053
  return;
1775
2054
  }
@@ -1837,7 +2116,12 @@ export function startCallbackServer(opts = {}) {
1837
2116
  message: "Echo billing returned an unexpected destination.",
1838
2117
  });
1839
2118
  }
1840
- json(res, 200, { url: hostedUrl });
2119
+ json(res, 200, {
2120
+ url: hostedUrl,
2121
+ ...(isCheckout && typeof response.data?.sessionId === "string"
2122
+ ? { sessionId: response.data.sessionId }
2123
+ : {}),
2124
+ });
1841
2125
  }
1842
2126
  catch (error) {
1843
2127
  const detail = publicAxiosError(error, isCheckout ? "Could not start secure checkout." : "Could not open secure plan management.");
@@ -1931,6 +2215,7 @@ export function startCallbackServer(opts = {}) {
1931
2215
  }
1932
2216
  const validation = validateForensicReportForSetup(payload);
1933
2217
  if (!validation.ok) {
2218
+ console.error(`[echomem] local report validation failed: ${validation.code} — ${validation.message}`);
1934
2219
  return void json(res, 500, {
1935
2220
  schemaVersion: 1,
1936
2221
  kind: "failed",
@@ -2000,22 +2285,25 @@ export function startCallbackServer(opts = {}) {
2000
2285
  }
2001
2286
  connected = false;
2002
2287
  activeDeviceToken = "";
2288
+ activeAccountEmail = "";
2003
2289
  const revokedPendingCredential = await revokePendingLocalAuth();
2004
2290
  stats = null;
2005
2291
  migrateStarted = false;
2006
2292
  progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
2293
+ try {
2294
+ await logoutHandler?.();
2295
+ }
2296
+ catch (e) {
2297
+ console.error(`Could not reset local login state: ${e instanceof Error ? e.message : String(e)}`);
2298
+ }
2007
2299
  json(res, 200, {
2008
2300
  ok: true,
2301
+ connected: false,
2009
2302
  authUrl,
2010
2303
  switchAccountUrl: switchAccountUrl || authUrl,
2011
2304
  localOnly: true,
2012
2305
  revokedPendingCredential,
2013
2306
  });
2014
- Promise.resolve()
2015
- .then(() => logoutHandler?.())
2016
- .catch((e) => {
2017
- console.error(`Could not reset local login state: ${e instanceof Error ? e.message : String(e)}`);
2018
- });
2019
2307
  return;
2020
2308
  }
2021
2309
  if (route === "/migrate" && req.method === "POST") {
@@ -2075,6 +2363,9 @@ export function startCallbackServer(opts = {}) {
2075
2363
  }
2076
2364
  }
2077
2365
  json(res, 200, { ok: true });
2366
+ if (!onToken.settled()) {
2367
+ setupExit.resolve("skip");
2368
+ }
2078
2369
  decision.resolve("skip");
2079
2370
  close();
2080
2371
  return;
@@ -2101,6 +2392,7 @@ export function startCallbackServer(opts = {}) {
2101
2392
  port,
2102
2393
  onToken: onToken.promise,
2103
2394
  wait: onToken.promise,
2395
+ setupExit: setupExit.promise,
2104
2396
  decision: decision.promise,
2105
2397
  migrateRequest: migrateRequest.promise,
2106
2398
  setTokenRefreshHandler: (handler) => {
@@ -2311,6 +2603,9 @@ async function cmdSetup(flags) {
2311
2603
  if (!flags["no-codex-skills"]) {
2312
2604
  writeCodexSkillsForTargets(targets);
2313
2605
  }
2606
+ if (flags["install-save-hooks"]) {
2607
+ writeSaveCheckpointHooksForTargets(targets);
2608
+ }
2314
2609
  console.log("");
2315
2610
  if (flags["skip-login"] || flags["no-login"]) {
2316
2611
  // init drives login itself right after, so the "skipped" note would be misleading there.
@@ -2333,7 +2628,14 @@ async function cmdSetup(flags) {
2333
2628
  async function cmdInit(flags) {
2334
2629
  console.log("Setting up EchoMem — shared memory for all your coding agents, plus the live context HUD.\n");
2335
2630
  // 1. Configure every installed agent + write AGENTS.md. Hold login + HUD so we control ordering.
2336
- await cmdSetup({ ...flags, all: true, "skip-login": true, "with-hud": false, "init-quiet": true });
2631
+ await cmdSetup({
2632
+ ...flags,
2633
+ all: true,
2634
+ "skip-login": true,
2635
+ "with-hud": false,
2636
+ "init-quiet": true,
2637
+ "install-save-hooks": flags["no-save-hooks"] !== true,
2638
+ });
2337
2639
  // 2. Bring the HUD up NOW (non-blocking) so everything is already running while onboarding proceeds.
2338
2640
  if (!flags["no-hud"])
2339
2641
  await cmdSetupHud(flags);
@@ -2400,6 +2702,23 @@ function writeCodexSkillsForTargets(targets) {
2400
2702
  console.log(`ℹ️ Could not install EchoMem Codex skills: ${error instanceof Error ? error.message : String(error)}`);
2401
2703
  }
2402
2704
  }
2705
+ function writeSaveCheckpointHooksForTargets(targets) {
2706
+ const clients = new Set();
2707
+ if (targets.some((target) => target.id === "codex"))
2708
+ clients.add("codex");
2709
+ if (targets.some((target) => target.id === "claude-code"))
2710
+ clients.add("claude-code");
2711
+ if (clients.size === 0) {
2712
+ console.log("ℹ️ No hook-capable Codex or Claude Code client was detected; private-save checkpoint hooks were not installed.");
2713
+ return;
2714
+ }
2715
+ const mode = clients.size === 2 ? "both" : [...clients][0];
2716
+ const paths = installSaveCheckpointHooks(mode);
2717
+ console.log(`✅ Installed EchoMem private-save checkpoint hooks:\n${paths.map((p) => ` - ${p}`).join("\n")}`);
2718
+ if (clients.has("codex")) {
2719
+ console.log(" Codex: start a new session and run /hooks once to review and trust the hook.");
2720
+ }
2721
+ }
2403
2722
  async function cmdUpdate(flags) {
2404
2723
  if (!flags.dev)
2405
2724
  installDurableGlobalUpdate();
@@ -2544,12 +2863,16 @@ async function cmdOnboarding(flags) {
2544
2863
  const forensicStartedAt = Date.now();
2545
2864
  let forensicStageStartedAt = forensicStartedAt;
2546
2865
  let forensicStage = "starting";
2866
+ let forensicOverall = 0;
2547
2867
  let forensicProgress = {
2548
2868
  status: "running",
2549
2869
  scanned: 0,
2550
2870
  total: 0,
2551
2871
  stage: forensicStage,
2552
2872
  label: forensicStageLabel(forensicStage),
2873
+ stageDone: 0,
2874
+ stageTotal: 0,
2875
+ overall: 0,
2553
2876
  elapsedMs: 0,
2554
2877
  stageElapsedMs: 0,
2555
2878
  updatedAt: forensicStartedAt,
@@ -2562,7 +2885,18 @@ async function cmdOnboarding(flags) {
2562
2885
  requireReportConsent: true,
2563
2886
  getStats: () => stats,
2564
2887
  getReport: () => forensicReport,
2565
- getReportProgress: () => forensicProgress,
2888
+ getReportProgress: () => {
2889
+ if (forensicProgress.status !== "running")
2890
+ return forensicProgress;
2891
+ const now = Date.now();
2892
+ const sinceWorkerUpdate = Math.max(0, now - forensicProgress.updatedAt);
2893
+ return {
2894
+ ...forensicProgress,
2895
+ elapsedMs: forensicProgress.elapsedMs + sinceWorkerUpdate,
2896
+ stageElapsedMs: forensicProgress.stageElapsedMs + sinceWorkerUpdate,
2897
+ updatedAt: now,
2898
+ };
2899
+ },
2566
2900
  onReportConsent: (allowed) => {
2567
2901
  if (!allowed) {
2568
2902
  forensicConsent = "declined";
@@ -2572,6 +2906,9 @@ async function cmdOnboarding(flags) {
2572
2906
  total: 0,
2573
2907
  stage: "failed",
2574
2908
  label: "Local scan skipped",
2909
+ stageDone: 0,
2910
+ stageTotal: 0,
2911
+ overall: 0,
2575
2912
  elapsedMs: Date.now() - forensicStartedAt,
2576
2913
  stageElapsedMs: Date.now() - forensicStageStartedAt,
2577
2914
  updatedAt: Date.now(),
@@ -2586,12 +2923,16 @@ async function cmdOnboarding(flags) {
2586
2923
  const now = Date.now();
2587
2924
  forensicStage = "starting";
2588
2925
  forensicStageStartedAt = now;
2926
+ forensicOverall = 0;
2589
2927
  forensicProgress = {
2590
2928
  status: "running",
2591
2929
  scanned: 0,
2592
2930
  total: 0,
2593
2931
  stage: forensicStage,
2594
2932
  label: forensicStageLabel(forensicStage),
2933
+ stageDone: 0,
2934
+ stageTotal: 0,
2935
+ overall: 0,
2595
2936
  elapsedMs: now - forensicStartedAt,
2596
2937
  stageElapsedMs: 0,
2597
2938
  updatedAt: now,
@@ -2616,6 +2957,8 @@ async function cmdOnboarding(flags) {
2616
2957
  forensicStageStartedAt = now;
2617
2958
  console.log(`Local scan: ${forensicStageLabel(forensicStage)}…`);
2618
2959
  }
2960
+ // Latched, so a caller that ever reports a smaller fraction cannot walk the bar backwards.
2961
+ forensicOverall = Math.max(forensicOverall, typeof progress.overall === "number" && Number.isFinite(progress.overall) ? progress.overall : 0);
2619
2962
  forensicProgress = {
2620
2963
  status: "running",
2621
2964
  scanned: progress.done,
@@ -2623,6 +2966,13 @@ async function cmdOnboarding(flags) {
2623
2966
  stage: forensicStage,
2624
2967
  label: forensicStageLabel(forensicStage),
2625
2968
  detail: progress.detail,
2969
+ stageDone: typeof progress.stageDone === "number" && Number.isFinite(progress.stageDone)
2970
+ ? Math.max(0, Math.floor(progress.stageDone))
2971
+ : 0,
2972
+ stageTotal: typeof progress.stageTotal === "number" && Number.isFinite(progress.stageTotal)
2973
+ ? Math.max(0, Math.floor(progress.stageTotal))
2974
+ : 0,
2975
+ overall: forensicOverall,
2626
2976
  elapsedMs: now - forensicStartedAt,
2627
2977
  stageElapsedMs: now - forensicStageStartedAt,
2628
2978
  updatedAt: now,
@@ -2639,6 +2989,9 @@ async function cmdOnboarding(flags) {
2639
2989
  total: forensicProgress.total,
2640
2990
  stage: "failed",
2641
2991
  label: "Local scan failed",
2992
+ stageDone: forensicProgress.stageDone,
2993
+ stageTotal: forensicProgress.stageTotal,
2994
+ overall: forensicOverall,
2642
2995
  elapsedMs: now - forensicStartedAt,
2643
2996
  stageElapsedMs: now - forensicStageStartedAt,
2644
2997
  updatedAt: now,
@@ -2650,7 +3003,16 @@ async function cmdOnboarding(flags) {
2650
3003
  let token;
2651
3004
  let key;
2652
3005
  try {
2653
- ({ token, key } = await srv.onToken);
3006
+ const authResult = await Promise.race([
3007
+ srv.onToken.then((credentials) => ({ kind: "token", credentials })),
3008
+ srv.setupExit.then(() => ({ kind: "skip" })),
3009
+ ]);
3010
+ if (authResult.kind === "skip") {
3011
+ srv.close();
3012
+ console.log("Vault setup deferred. Your local coding history is unchanged; run `echomem-mcp init` when you are ready to continue.");
3013
+ return false;
3014
+ }
3015
+ ({ token, key } = authResult.credentials);
2654
3016
  }
2655
3017
  catch (e) {
2656
3018
  srv.close();
@@ -2667,13 +3029,22 @@ async function cmdOnboarding(flags) {
2667
3029
  let exactDiscovery = Promise.resolve(null);
2668
3030
  let refreshGeneration = 0;
2669
3031
  let latestPendingEstimate = 0;
3032
+ // Local discovery is account-independent. Keep one snapshot for this onboarding process and
3033
+ // overlay each connected account's processed keys instead of rereading every JSONL on switch.
3034
+ const localDiscovery = createLocalDiscoveryCache({
3035
+ loadExact: async () => {
3036
+ // Publish the quick metadata result before starting the heavier exact worker.
3037
+ await delay(250);
3038
+ return discoverMigratableSessionsOffThread();
3039
+ },
3040
+ });
2670
3041
  // The account's already-imported keys, shared so the /migrate sizing can assemble ONLY pending sessions.
2671
3042
  let lastProcessedImportKeys = null;
2672
3043
  const resetLocalLoginState = () => {
2673
3044
  refreshGeneration++;
2674
3045
  stats = null;
2675
3046
  disc = null;
2676
- exactDiscovery = Promise.resolve(null);
3047
+ exactDiscovery = localDiscovery.pendingExact() ?? Promise.resolve(localDiscovery.peekExact());
2677
3048
  latestPendingEstimate = 0;
2678
3049
  lastProcessedImportKeys = null;
2679
3050
  srv.setStats(null);
@@ -2691,7 +3062,9 @@ async function cmdOnboarding(flags) {
2691
3062
  const generation = ++refreshGeneration;
2692
3063
  lastProcessedImportKeys = null; // shared with /migrate so it can assemble only the pending sessions
2693
3064
  let importStatusUnavailable = false;
2694
- const quickDiscovery = discoverMigratableFastDiscovery();
3065
+ const quickDiscovery = await localDiscovery.getQuick();
3066
+ if (generation !== refreshGeneration)
3067
+ return;
2695
3068
  const quick = summarizeFastMigratableDiscovery(quickDiscovery);
2696
3069
  let migratable = migratableFromFastSummary(quick);
2697
3070
  latestPendingEstimate = migratable.pending;
@@ -2799,23 +3172,7 @@ async function cmdOnboarding(flags) {
2799
3172
  console.error(`Could not check this EchoMem account's import status quickly: ${e instanceof Error ? e.message : String(e)}`);
2800
3173
  }
2801
3174
  });
2802
- exactDiscovery = new Promise((resolve, reject) => {
2803
- const timer = setTimeout(() => {
2804
- void (async () => {
2805
- try {
2806
- // Start the local sizing pass without waiting on cloud/account status. The
2807
- // account check is useful for tighter counts, but extraction can safely start
2808
- // from local candidates because the import path skips true duplicates.
2809
- await delay(250);
2810
- resolve(await discoverMigratableSessionsOffThread());
2811
- }
2812
- catch (e) {
2813
- reject(e instanceof Error ? e : new Error(String(e)));
2814
- }
2815
- })();
2816
- }, 250);
2817
- timer.unref?.();
2818
- }).then(async (exact) => {
3175
+ exactDiscovery = localDiscovery.getExact().then(async (exact) => {
2819
3176
  if (generation !== refreshGeneration)
2820
3177
  return disc;
2821
3178
  const initialExact = lastProcessedImportKeys
@@ -2897,7 +3254,7 @@ async function cmdOnboarding(flags) {
2897
3254
  failed: 0,
2898
3255
  extracted: 0,
2899
3256
  });
2900
- const fullPayload = withCandidateSessions(await buildStatsPayload(collect(), {
3257
+ const fullPayload = withCandidateSessions(await buildCollectedStatsPayloadOffThread({
2901
3258
  sessions: sessionSummary,
2902
3259
  migratable,
2903
3260
  discovery: { phase: "full", exact: true },
@@ -3380,7 +3737,8 @@ Manual / headless:
3380
3737
  ${MCP_UPDATE_ALL_COMMAND} # one-shot latest update for detected clients, no browser login
3381
3738
  ${MCP_UPDATE_COMMAND} --client codex # update one client only
3382
3739
  echomem-mcp setup --dev /abs/path/dist/index.js # point clients at a local checkout
3383
- echomem-mcp setup --with-hud --install-hud-hooks --client codex [--hud-client auto]
3740
+ echomem-mcp setup --with-hud --install-hud-hooks --install-save-hooks --client codex [--hud-client auto]
3741
+ echomem-mcp setup --install-save-hooks --all Install proactive private-save completion checks
3384
3742
  echomem-mcp sync-usage --days 7 --limit 50 --dry-run
3385
3743
 
3386
3744
  Current bridge version: ${MCP_PACKAGE_VERSION}