@echomem/mcp 1.4.27 → 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,8 +25,8 @@ 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";
@@ -1145,6 +1145,155 @@ function discoverMigratableSessionsOffThread() {
1145
1145
  });
1146
1146
  });
1147
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
+ }
1148
1297
  function forensicStageLabel(stage) {
1149
1298
  if (stage === "reading-transcripts")
1150
1299
  return "Reading transcript files";
@@ -1166,7 +1315,9 @@ export function buildForensicReportOffThread(onProgress, options = {}) {
1166
1315
  try {
1167
1316
  const report = await buildForensicReport({
1168
1317
  includeLegacyGoldenStandard: false,
1169
- onProgress: (done, total, stage, detail, overall) => parentPort?.postMessage({ progress: { done, total, stage, detail, overall } }),
1318
+ onProgress: (done, total, stage, detail, overall, stageDone, stageTotal) => parentPort?.postMessage({
1319
+ progress: { done, total, stage, detail, overall, stageDone, stageTotal },
1320
+ }),
1170
1321
  });
1171
1322
  parentPort?.postMessage({ ok: true, report });
1172
1323
  } catch (error) {
@@ -1259,6 +1410,7 @@ function publicRunningForensicProgress(value) {
1259
1410
  ? Math.min(1, Math.max(0, candidate))
1260
1411
  : 0);
1261
1412
  const total = safeCount(progress.total);
1413
+ const stageTotal = safeCount(progress.stageTotal);
1262
1414
  const rawStage = typeof progress.stage === "string" ? progress.stage : "starting";
1263
1415
  const stage = ["starting", "reading-transcripts", "building-summary", "classifying-repeated-context", "finalizing-report"].includes(rawStage)
1264
1416
  ? rawStage
@@ -1269,6 +1421,8 @@ function publicRunningForensicProgress(value) {
1269
1421
  total,
1270
1422
  stage,
1271
1423
  label: forensicStageLabel(stage),
1424
+ stageDone: stageTotal > 0 ? Math.min(safeCount(progress.stageDone), stageTotal) : 0,
1425
+ stageTotal,
1272
1426
  overall: safeFraction(progress.overall),
1273
1427
  elapsedMs: safeDuration(progress.elapsedMs),
1274
1428
  stageElapsedMs: safeDuration(progress.stageElapsedMs),
@@ -1303,6 +1457,7 @@ export function startCallbackServer(opts = {}) {
1303
1457
  let switchAccountUrl = "";
1304
1458
  let connected = Boolean(opts.initialToken?.token);
1305
1459
  let activeDeviceToken = opts.initialToken?.token || "";
1460
+ let activeAccountEmail = "";
1306
1461
  let pendingLocalAuth = null;
1307
1462
  let reportConsentGranted = !requiresReportConsent;
1308
1463
  let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
@@ -1337,8 +1492,8 @@ export function startCallbackServer(opts = {}) {
1337
1492
  return false;
1338
1493
  return revokeDeviceToken(pending.token);
1339
1494
  };
1340
- const completeDeviceLogin = async (token) => {
1341
- 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 });
1342
1497
  };
1343
1498
  const close = () => {
1344
1499
  if (timer)
@@ -1414,6 +1569,7 @@ export function startCallbackServer(opts = {}) {
1414
1569
  const firstToken = !onToken.settled();
1415
1570
  connected = true;
1416
1571
  activeDeviceToken = token;
1572
+ activeAccountEmail = pendingLocalAuth?.email || activeAccountEmail;
1417
1573
  pendingLocalAuth = null;
1418
1574
  const callbackToken = { token, key };
1419
1575
  if (firstToken) {
@@ -1571,6 +1727,38 @@ export function startCallbackServer(opts = {}) {
1571
1727
  json(res, detail.status, { ok: false, error: detail.message });
1572
1728
  }
1573
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
+ };
1574
1762
  server = http.createServer((req, res) => {
1575
1763
  res.setHeader("Access-Control-Allow-Origin", "*");
1576
1764
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
@@ -1635,6 +1823,18 @@ export function startCallbackServer(opts = {}) {
1635
1823
  });
1636
1824
  return;
1637
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
+ }
1638
1838
  if (route === "/launch-agent" && req.method === "POST") {
1639
1839
  let body;
1640
1840
  try {
@@ -1771,6 +1971,9 @@ export function startCallbackServer(opts = {}) {
1771
1971
  const plan = (asString(response.data?.plan) || "free").toLowerCase();
1772
1972
  const trialUsed = response.data?.billing?.trialUsed === true;
1773
1973
  const trialAvailable = response.data?.billing?.trialAvailable !== false;
1974
+ const accountEmail = asString(profileResponse?.data?.email) || activeAccountEmail;
1975
+ if (accountEmail)
1976
+ activeAccountEmail = accountEmail;
1774
1977
  json(res, 200, {
1775
1978
  plan,
1776
1979
  paid: ["pro", "power", "team", "enterprise"].includes(plan),
@@ -1785,10 +1988,9 @@ export function startCallbackServer(opts = {}) {
1785
1988
  memoryProcessingQuota: response.data?.memoryProcessingQuota ?? null,
1786
1989
  memorySearchQuota: response.data?.memorySearchQuota ?? null,
1787
1990
  activation: response.data?.activation ?? null,
1788
- account: profileResponse ? {
1789
- displayName: asString(profileResponse.data?.displayName) || "EchoMem user",
1790
- email: asString(profileResponse.data?.email) || "",
1791
- avatarUrl: asString(profileResponse.data?.avatarUrl) || "",
1991
+ account: accountEmail ? {
1992
+ email: accountEmail,
1993
+ avatarUrl: asString(profileResponse?.data?.avatarUrl) || "",
1792
1994
  } : null,
1793
1995
  pricingUrl,
1794
1996
  });
@@ -2083,22 +2285,25 @@ export function startCallbackServer(opts = {}) {
2083
2285
  }
2084
2286
  connected = false;
2085
2287
  activeDeviceToken = "";
2288
+ activeAccountEmail = "";
2086
2289
  const revokedPendingCredential = await revokePendingLocalAuth();
2087
2290
  stats = null;
2088
2291
  migrateStarted = false;
2089
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
+ }
2090
2299
  json(res, 200, {
2091
2300
  ok: true,
2301
+ connected: false,
2092
2302
  authUrl,
2093
2303
  switchAccountUrl: switchAccountUrl || authUrl,
2094
2304
  localOnly: true,
2095
2305
  revokedPendingCredential,
2096
2306
  });
2097
- Promise.resolve()
2098
- .then(() => logoutHandler?.())
2099
- .catch((e) => {
2100
- console.error(`Could not reset local login state: ${e instanceof Error ? e.message : String(e)}`);
2101
- });
2102
2307
  return;
2103
2308
  }
2104
2309
  if (route === "/migrate" && req.method === "POST") {
@@ -2665,6 +2870,8 @@ async function cmdOnboarding(flags) {
2665
2870
  total: 0,
2666
2871
  stage: forensicStage,
2667
2872
  label: forensicStageLabel(forensicStage),
2873
+ stageDone: 0,
2874
+ stageTotal: 0,
2668
2875
  overall: 0,
2669
2876
  elapsedMs: 0,
2670
2877
  stageElapsedMs: 0,
@@ -2678,7 +2885,18 @@ async function cmdOnboarding(flags) {
2678
2885
  requireReportConsent: true,
2679
2886
  getStats: () => stats,
2680
2887
  getReport: () => forensicReport,
2681
- 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
+ },
2682
2900
  onReportConsent: (allowed) => {
2683
2901
  if (!allowed) {
2684
2902
  forensicConsent = "declined";
@@ -2688,6 +2906,8 @@ async function cmdOnboarding(flags) {
2688
2906
  total: 0,
2689
2907
  stage: "failed",
2690
2908
  label: "Local scan skipped",
2909
+ stageDone: 0,
2910
+ stageTotal: 0,
2691
2911
  overall: 0,
2692
2912
  elapsedMs: Date.now() - forensicStartedAt,
2693
2913
  stageElapsedMs: Date.now() - forensicStageStartedAt,
@@ -2710,6 +2930,8 @@ async function cmdOnboarding(flags) {
2710
2930
  total: 0,
2711
2931
  stage: forensicStage,
2712
2932
  label: forensicStageLabel(forensicStage),
2933
+ stageDone: 0,
2934
+ stageTotal: 0,
2713
2935
  overall: 0,
2714
2936
  elapsedMs: now - forensicStartedAt,
2715
2937
  stageElapsedMs: 0,
@@ -2744,6 +2966,12 @@ async function cmdOnboarding(flags) {
2744
2966
  stage: forensicStage,
2745
2967
  label: forensicStageLabel(forensicStage),
2746
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,
2747
2975
  overall: forensicOverall,
2748
2976
  elapsedMs: now - forensicStartedAt,
2749
2977
  stageElapsedMs: now - forensicStageStartedAt,
@@ -2761,6 +2989,8 @@ async function cmdOnboarding(flags) {
2761
2989
  total: forensicProgress.total,
2762
2990
  stage: "failed",
2763
2991
  label: "Local scan failed",
2992
+ stageDone: forensicProgress.stageDone,
2993
+ stageTotal: forensicProgress.stageTotal,
2764
2994
  overall: forensicOverall,
2765
2995
  elapsedMs: now - forensicStartedAt,
2766
2996
  stageElapsedMs: now - forensicStageStartedAt,
@@ -2799,13 +3029,22 @@ async function cmdOnboarding(flags) {
2799
3029
  let exactDiscovery = Promise.resolve(null);
2800
3030
  let refreshGeneration = 0;
2801
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
+ });
2802
3041
  // The account's already-imported keys, shared so the /migrate sizing can assemble ONLY pending sessions.
2803
3042
  let lastProcessedImportKeys = null;
2804
3043
  const resetLocalLoginState = () => {
2805
3044
  refreshGeneration++;
2806
3045
  stats = null;
2807
3046
  disc = null;
2808
- exactDiscovery = Promise.resolve(null);
3047
+ exactDiscovery = localDiscovery.pendingExact() ?? Promise.resolve(localDiscovery.peekExact());
2809
3048
  latestPendingEstimate = 0;
2810
3049
  lastProcessedImportKeys = null;
2811
3050
  srv.setStats(null);
@@ -2823,7 +3062,9 @@ async function cmdOnboarding(flags) {
2823
3062
  const generation = ++refreshGeneration;
2824
3063
  lastProcessedImportKeys = null; // shared with /migrate so it can assemble only the pending sessions
2825
3064
  let importStatusUnavailable = false;
2826
- const quickDiscovery = discoverMigratableFastDiscovery();
3065
+ const quickDiscovery = await localDiscovery.getQuick();
3066
+ if (generation !== refreshGeneration)
3067
+ return;
2827
3068
  const quick = summarizeFastMigratableDiscovery(quickDiscovery);
2828
3069
  let migratable = migratableFromFastSummary(quick);
2829
3070
  latestPendingEstimate = migratable.pending;
@@ -2931,23 +3172,7 @@ async function cmdOnboarding(flags) {
2931
3172
  console.error(`Could not check this EchoMem account's import status quickly: ${e instanceof Error ? e.message : String(e)}`);
2932
3173
  }
2933
3174
  });
2934
- exactDiscovery = new Promise((resolve, reject) => {
2935
- const timer = setTimeout(() => {
2936
- void (async () => {
2937
- try {
2938
- // Start the local sizing pass without waiting on cloud/account status. The
2939
- // account check is useful for tighter counts, but extraction can safely start
2940
- // from local candidates because the import path skips true duplicates.
2941
- await delay(250);
2942
- resolve(await discoverMigratableSessionsOffThread());
2943
- }
2944
- catch (e) {
2945
- reject(e instanceof Error ? e : new Error(String(e)));
2946
- }
2947
- })();
2948
- }, 250);
2949
- timer.unref?.();
2950
- }).then(async (exact) => {
3175
+ exactDiscovery = localDiscovery.getExact().then(async (exact) => {
2951
3176
  if (generation !== refreshGeneration)
2952
3177
  return disc;
2953
3178
  const initialExact = lastProcessedImportKeys
@@ -3029,7 +3254,7 @@ async function cmdOnboarding(flags) {
3029
3254
  failed: 0,
3030
3255
  extracted: 0,
3031
3256
  });
3032
- const fullPayload = withCandidateSessions(await buildStatsPayload(collect(), {
3257
+ const fullPayload = withCandidateSessions(await buildCollectedStatsPayloadOffThread({
3033
3258
  sessions: sessionSummary,
3034
3259
  migratable,
3035
3260
  discovery: { phase: "full", exact: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.4.27",
3
+ "version": "1.4.28",
4
4
  "description": "EchoMem MCP bridge: cloud-first memory tools, local context HUD, and the Agent Doctor workspace forensics report (cost ledger + 3D repo city)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",