@echomem/mcp 1.4.17 → 1.4.19

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.
@@ -1,4 +1,6 @@
1
1
  export const SETUP_PREVIEW_STATES = [
2
+ "consent",
3
+ "consent-required",
2
4
  "report",
3
5
  "extract-counting",
4
6
  "extract-ready",
@@ -200,6 +202,15 @@ function previewWatermarkScript() {
200
202
  }
201
203
  export function renderSetupPreviewBootstrap(state) {
202
204
  const watermark = previewWatermarkScript();
205
+ if (state === "consent") {
206
+ return `${watermark}
207
+ renderLocalScanConsent();`;
208
+ }
209
+ if (state === "consent-required") {
210
+ return `${watermark}
211
+ renderLocalScanConsent();
212
+ setConsentStatus("Local history access is required to continue setup. Nothing will be scanned or uploaded unless you allow it.", "required");`;
213
+ }
203
214
  if (state === "report") {
204
215
  const reportJson = JSON.stringify(previewReport);
205
216
  return `${watermark}
@@ -214,6 +225,17 @@ export function renderSetupPreviewBootstrap(state) {
214
225
  }
215
226
  if (state === "extract-ready") {
216
227
  return `${watermark}
228
+ connected = true;
229
+ billingStatus = {
230
+ plan: "free", paid: false, trialAvailable: true,
231
+ pricingUrl: "https://echoknows.com/pricing?source=mcp_onboarding",
232
+ account: {
233
+ displayName: "Erik Hein",
234
+ email: "erik@example.com",
235
+ avatarUrl: "/hud-assets/echo-face-cutout.png"
236
+ },
237
+ historicalConversationQuota: { used: 72, limit: 100, remaining: 28 }
238
+ };
217
239
  stats = {
218
240
  sessions: { total: 136, codex: 82, claudeCode: 54 },
219
241
  migratable: {
@@ -221,6 +243,19 @@ export function renderSetupPreviewBootstrap(state) {
221
243
  alreadyMigrated: 108, skippedActive: 0,
222
244
  eta: { estimatedLabel: "under 2 minutes" }, estimatedLabel: "under 2 minutes"
223
245
  },
246
+ candidateSessions: Array.from({ length: 28 }, function (_, index) {
247
+ var codex = index < 19;
248
+ return {
249
+ key: (codex ? "codex:" : "claude-code:") + "preview-" + index,
250
+ source: codex ? "codex" : "claude-code",
251
+ title: ["Plan the onboarding flow", "Repair memory extraction", "Review pricing limits", "Polish the EchoMem HUD"][index % 4] + " · " + (index + 1),
252
+ project: ["EchoMem Chrome", "MCP Server", "Memory Platform"][index % 3],
253
+ date: new Date(Date.UTC(2026, 6, 18 - (index % 12))).toISOString(),
254
+ characters: 42000 + index * 2700,
255
+ approxInputTokens: 10500 + index * 675,
256
+ turns: 18 + index
257
+ };
258
+ }),
224
259
  discovery: { phase: "account", exact: false }
225
260
  };
226
261
  renderDashboard();`;
@@ -237,6 +272,20 @@ export function renderSetupPreviewBootstrap(state) {
237
272
  }
238
273
  if (state === "extract-done") {
239
274
  return `${watermark}
275
+ stats = {
276
+ candidateSessions: [
277
+ { key: "codex:done-1", project: "EchoMem Chrome", title: "Polish the onboarding flow" },
278
+ { key: "codex:done-2", project: "EchoMem Chrome", title: "Repair memory extraction" },
279
+ { key: "claude-code:done-3", project: "MCP Server", title: "Review recall limits" },
280
+ { key: "codex:done-4", project: "Memory Platform", title: "Plan the next release" }
281
+ ]
282
+ };
283
+ selectedSessionKeys = {
284
+ "codex:done-1": true,
285
+ "codex:done-2": true,
286
+ "claude-code:done-3": true,
287
+ "codex:done-4": true
288
+ };
240
289
  renderProgress({ status: "completed", total: 28, completed: 28, extracted: 128, failed: 0 });`;
241
290
  }
242
291
  if (state === "extract-ending")
package/dist/setup.js CHANGED
@@ -629,6 +629,24 @@ function commandFailureMessage(error) {
629
629
  }
630
630
  return error instanceof Error ? error.message : String(error);
631
631
  }
632
+ function candidateSessionsFromDiscovery(disc) {
633
+ return disc.pending.map((session) => ({
634
+ key: session.conversationKey,
635
+ source: session.source,
636
+ title: session.title || "Untitled coding session",
637
+ project: repoLabel(session.cwd) || "No project detected",
638
+ date: session.firstTs,
639
+ characters: session.rawData.length,
640
+ approxInputTokens: Math.ceil(session.rawData.length / 4),
641
+ turns: session.turnCount,
642
+ }));
643
+ }
644
+ function withCandidateSessions(payload, disc) {
645
+ const base = payload && typeof payload === "object" && !Array.isArray(payload)
646
+ ? payload
647
+ : {};
648
+ return { ...base, candidateSessions: candidateSessionsFromDiscovery(disc) };
649
+ }
632
650
  function migratableFromDiscovery(disc) {
633
651
  const eta = estimateMigrationEta(disc.pending, disc.skippedActive);
634
652
  const pendingCodex = disc.pendingCodex ?? disc.pending.filter((s) => s.source === "codex").length;
@@ -709,6 +727,16 @@ function isObjectRecord(value) {
709
727
  function asString(value) {
710
728
  return typeof value === "string" && value ? value : undefined;
711
729
  }
730
+ function asConversationKeys(value) {
731
+ if (!Array.isArray(value))
732
+ return null;
733
+ const keys = value
734
+ .slice(0, 5000)
735
+ .filter((item) => typeof item === "string")
736
+ .map((item) => item.trim())
737
+ .filter((item) => /^(codex|claude-code):[^\s]{1,180}$/.test(item));
738
+ return Array.from(new Set(keys));
739
+ }
712
740
  function readJsonBody(req) {
713
741
  return new Promise((resolve, reject) => {
714
742
  let body = "";
@@ -1084,10 +1112,12 @@ export function startCallbackServer(opts = {}) {
1084
1112
  let authUrl = "";
1085
1113
  let switchAccountUrl = "";
1086
1114
  let connected = false;
1115
+ let reportConsentGranted = opts.requireReportConsent !== true;
1087
1116
  let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
1088
1117
  let migrateStarted = false;
1089
1118
  let tokenRefreshHandler = null;
1090
1119
  let logoutHandler = null;
1120
+ let migrationPauseHandler = null;
1091
1121
  let timer;
1092
1122
  let closed = false;
1093
1123
  let server;
@@ -1128,6 +1158,12 @@ export function startCallbackServer(opts = {}) {
1128
1158
  const handleCallback = (res, token, key, nonce) => {
1129
1159
  if (!checkNonce(nonce))
1130
1160
  return void text(res, 403, "bad nonce");
1161
+ if (opts.requireReportConsent === true && !reportConsentGranted) {
1162
+ return void json(res, 403, {
1163
+ error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1164
+ message: "Allow local history access in the setup page before connecting EchoMem.",
1165
+ });
1166
+ }
1131
1167
  if (!token)
1132
1168
  return void text(res, 400, "missing token");
1133
1169
  console.log(`[${new Date().toISOString()}] Browser approval callback received.`);
@@ -1190,7 +1226,15 @@ export function startCallbackServer(opts = {}) {
1190
1226
  if (route === "/config" && req.method === "GET") {
1191
1227
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1192
1228
  return void text(res, 403, "bad nonce");
1193
- json(res, 200, { connected, authUrl, switchAccountUrl: switchAccountUrl || authUrl, localOnly: true, workspacePath: process.cwd() });
1229
+ json(res, 200, {
1230
+ connected,
1231
+ authUrl,
1232
+ switchAccountUrl: switchAccountUrl || authUrl,
1233
+ localOnly: true,
1234
+ workspacePath: process.cwd(),
1235
+ consentRequired: opts.requireReportConsent === true,
1236
+ consentGranted: reportConsentGranted,
1237
+ });
1194
1238
  return;
1195
1239
  }
1196
1240
  if (route === "/launch-agent" && req.method === "POST") {
@@ -1245,6 +1289,12 @@ export function startCallbackServer(opts = {}) {
1245
1289
  if (route === "/stats" && req.method === "GET") {
1246
1290
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1247
1291
  return void text(res, 403, "bad nonce");
1292
+ if (opts.requireReportConsent === true && !reportConsentGranted) {
1293
+ return void json(res, 403, {
1294
+ error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1295
+ message: "Allow local history access before continuing setup.",
1296
+ });
1297
+ }
1248
1298
  const payload = opts.getStats ? opts.getStats() : stats;
1249
1299
  if (payload == null)
1250
1300
  return void res.writeHead(202).end();
@@ -1254,6 +1304,12 @@ export function startCallbackServer(opts = {}) {
1254
1304
  if (route === "/billing-status" && req.method === "GET") {
1255
1305
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1256
1306
  return void text(res, 403, "bad nonce");
1307
+ if (opts.requireReportConsent === true && !reportConsentGranted) {
1308
+ return void json(res, 403, {
1309
+ error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1310
+ message: "Allow local history access before continuing setup.",
1311
+ });
1312
+ }
1257
1313
  const token = new KeyStore().getToken();
1258
1314
  const pricingUrl = `${PRICING_URL}?source=mcp_onboarding`;
1259
1315
  if (!token) {
@@ -1261,7 +1317,11 @@ export function startCallbackServer(opts = {}) {
1261
1317
  return;
1262
1318
  }
1263
1319
  try {
1264
- const response = await authedAxios(token).get("/api/extension/account/bootstrap", { timeout: 6000 });
1320
+ const client = authedAxios(token);
1321
+ const [response, profileResponse] = await Promise.all([
1322
+ client.get("/api/extension/account/bootstrap", { timeout: 6000 }),
1323
+ client.get("/api/extension/account/profile-summary", { timeout: 6000 }).catch(() => null),
1324
+ ]);
1265
1325
  const plan = (asString(response.data?.plan) || "free").toLowerCase();
1266
1326
  const trialUsed = response.data?.billing?.trialUsed === true;
1267
1327
  const trialAvailable = response.data?.billing?.trialAvailable !== false;
@@ -1270,6 +1330,14 @@ export function startCallbackServer(opts = {}) {
1270
1330
  paid: ["pro", "power", "team", "enterprise"].includes(plan),
1271
1331
  trialAvailable,
1272
1332
  trialUsed,
1333
+ historicalConversationQuota: response.data?.historicalConversationQuota ?? null,
1334
+ memoryProcessingQuota: response.data?.memoryProcessingQuota ?? null,
1335
+ memorySearchQuota: response.data?.memorySearchQuota ?? null,
1336
+ account: profileResponse ? {
1337
+ displayName: asString(profileResponse.data?.displayName) || "EchoMem user",
1338
+ email: asString(profileResponse.data?.email) || "",
1339
+ avatarUrl: asString(profileResponse.data?.avatarUrl) || "",
1340
+ } : null,
1273
1341
  pricingUrl,
1274
1342
  });
1275
1343
  }
@@ -1283,6 +1351,12 @@ export function startCallbackServer(opts = {}) {
1283
1351
  res.setHeader("Cache-Control", "no-store");
1284
1352
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1285
1353
  return void text(res, 403, "bad nonce");
1354
+ if (opts.requireReportConsent === true && !reportConsentGranted) {
1355
+ return void json(res, 403, {
1356
+ error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1357
+ message: "Allow local history access before starting the local scan.",
1358
+ });
1359
+ }
1286
1360
  let payload;
1287
1361
  try {
1288
1362
  payload = opts.getReport ? opts.getReport() : null;
@@ -1378,6 +1452,12 @@ export function startCallbackServer(opts = {}) {
1378
1452
  if (route === "/progress" && req.method === "GET") {
1379
1453
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1380
1454
  return void text(res, 403, "bad nonce");
1455
+ if (opts.requireReportConsent === true && !reportConsentGranted) {
1456
+ return void json(res, 403, {
1457
+ error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1458
+ message: "Allow local history access before continuing setup.",
1459
+ });
1460
+ }
1381
1461
  json(res, 200, progress);
1382
1462
  return;
1383
1463
  }
@@ -1393,6 +1473,7 @@ export function startCallbackServer(opts = {}) {
1393
1473
  if (!checkNonce(asString(body.nonce)))
1394
1474
  return void text(res, 403, "bad nonce");
1395
1475
  const allowed = body.allowed === true;
1476
+ reportConsentGranted = allowed;
1396
1477
  opts.onReportConsent?.(allowed);
1397
1478
  json(res, 200, { ok: true, allowed });
1398
1479
  return;
@@ -1441,6 +1522,12 @@ export function startCallbackServer(opts = {}) {
1441
1522
  }
1442
1523
  if (!checkNonce(asString(body.nonce)))
1443
1524
  return void text(res, 403, "bad nonce");
1525
+ if (opts.requireReportConsent === true && !reportConsentGranted) {
1526
+ return void json(res, 403, {
1527
+ error: "LOCAL_HISTORY_CONSENT_REQUIRED",
1528
+ message: "Allow local history access before starting extraction.",
1529
+ });
1530
+ }
1444
1531
  if (migrateStarted)
1445
1532
  return void json(res, 409, { error: "MIGRATE_IN_PROGRESS" });
1446
1533
  migrateStarted = true;
@@ -1448,7 +1535,7 @@ export function startCallbackServer(opts = {}) {
1448
1535
  respondMigrate(res, { error: "IMPORT_START_TIMEOUT" }, 504);
1449
1536
  }, 30_000);
1450
1537
  safety.unref?.();
1451
- migrateRequest.resolve({ res });
1538
+ migrateRequest.resolve({ res, conversationKeys: asConversationKeys(body.conversationKeys) });
1452
1539
  decision.resolve("migrate");
1453
1540
  return;
1454
1541
  }
@@ -1463,6 +1550,23 @@ export function startCallbackServer(opts = {}) {
1463
1550
  }
1464
1551
  if (!checkNonce(asString(body.nonce)))
1465
1552
  return void text(res, 403, "bad nonce");
1553
+ if (migrateStarted) {
1554
+ if (!migrationPauseHandler) {
1555
+ json(res, 409, { error: "MIGRATION_PAUSE_UNAVAILABLE", message: "Extraction is still starting. Try again in a moment." });
1556
+ return;
1557
+ }
1558
+ try {
1559
+ // Do not claim the work is paused until the import loop has stopped scheduling jobs.
1560
+ await migrationPauseHandler();
1561
+ }
1562
+ catch (error) {
1563
+ json(res, 500, {
1564
+ error: "MIGRATION_PAUSE_FAILED",
1565
+ message: error instanceof Error ? error.message : "Could not pause extraction.",
1566
+ });
1567
+ return;
1568
+ }
1569
+ }
1466
1570
  json(res, 200, { ok: true });
1467
1571
  decision.resolve("skip");
1468
1572
  close();
@@ -1496,6 +1600,9 @@ export function startCallbackServer(opts = {}) {
1496
1600
  setLogoutHandler: (handler) => {
1497
1601
  logoutHandler = handler;
1498
1602
  },
1603
+ setMigrationPauseHandler: (handler) => {
1604
+ migrationPauseHandler = handler;
1605
+ },
1499
1606
  setAuthUrl: (url, nextSwitchAccountUrl) => {
1500
1607
  authUrl = url;
1501
1608
  switchAccountUrl = nextSwitchAccountUrl || url;
@@ -1842,6 +1949,7 @@ async function cmdLogin(flags) {
1842
1949
  const srv = await startCallbackServer({
1843
1950
  port: devPortRaw,
1844
1951
  nonce,
1952
+ requireReportConsent: true,
1845
1953
  getStats: () => stats,
1846
1954
  getReport: () => forensicReport,
1847
1955
  getReportProgress: () => forensicProgress,
@@ -1865,6 +1973,19 @@ async function cmdLogin(flags) {
1865
1973
  return;
1866
1974
  }
1867
1975
  forensicConsent = "allowed";
1976
+ const now = Date.now();
1977
+ forensicStage = "starting";
1978
+ forensicStageStartedAt = now;
1979
+ forensicProgress = {
1980
+ status: "running",
1981
+ scanned: 0,
1982
+ total: 0,
1983
+ stage: forensicStage,
1984
+ label: forensicStageLabel(forensicStage),
1985
+ elapsedMs: now - forensicStartedAt,
1986
+ stageElapsedMs: 0,
1987
+ updatedAt: now,
1988
+ };
1868
1989
  startForensicScan();
1869
1990
  },
1870
1991
  });
@@ -2104,13 +2225,13 @@ async function cmdLogin(flags) {
2104
2225
  migratable = migratableFromDiscovery(initialExact);
2105
2226
  latestPendingEstimate = migratable.pending;
2106
2227
  sessionSummary = sessionsFromDiscovery(initialExact);
2107
- const partialPayload = await buildStatsPayload([], {
2228
+ const partialPayload = withCandidateSessions(await buildStatsPayload([], {
2108
2229
  partial: true,
2109
2230
  skipMemoryCount: true,
2110
2231
  sessions: sessionSummary,
2111
2232
  migratable,
2112
2233
  discovery: { phase: "exact", exact: true },
2113
- });
2234
+ }), initialExact);
2114
2235
  if (generation !== refreshGeneration)
2115
2236
  return disc;
2116
2237
  stats = partialPayload;
@@ -2154,13 +2275,13 @@ async function cmdLogin(flags) {
2154
2275
  migratable = migratableFromDiscovery(reconciled);
2155
2276
  latestPendingEstimate = migratable.pending;
2156
2277
  sessionSummary = sessionsFromDiscovery(reconciled);
2157
- const reconciledPayload = await buildStatsPayload([], {
2278
+ const reconciledPayload = withCandidateSessions(await buildStatsPayload([], {
2158
2279
  partial: true,
2159
2280
  skipMemoryCount: true,
2160
2281
  sessions: sessionSummary,
2161
2282
  migratable,
2162
2283
  discovery: { phase: "exact", exact: true },
2163
- });
2284
+ }), reconciled);
2164
2285
  if (generation !== refreshGeneration)
2165
2286
  return;
2166
2287
  stats = reconciledPayload;
@@ -2174,11 +2295,11 @@ async function cmdLogin(flags) {
2174
2295
  failed: 0,
2175
2296
  extracted: 0,
2176
2297
  });
2177
- const fullPayload = await buildStatsPayload(collect(), {
2298
+ const fullPayload = withCandidateSessions(await buildStatsPayload(collect(), {
2178
2299
  sessions: sessionSummary,
2179
2300
  migratable,
2180
2301
  discovery: { phase: "full", exact: true },
2181
- });
2302
+ }), reconciled);
2182
2303
  if (generation !== refreshGeneration)
2183
2304
  return;
2184
2305
  stats = fullPayload;
@@ -2199,9 +2320,42 @@ async function cmdLogin(flags) {
2199
2320
  await refreshLocalStatsForToken(nextToken);
2200
2321
  });
2201
2322
  await refreshLocalStatsForToken(token);
2323
+ // Ending is deliberately transient. Completed conversations remain recorded in the normal
2324
+ // import ledger; unfinished conversations are canceled and rediscovered by the next init.
2325
+ let pauseRequested = false;
2326
+ let pauseCompletion = null;
2327
+ let activeMigrationCleanup = null;
2328
+ let activeMigrationCleanupPromise = null;
2329
+ const cancelActiveMigration = async () => {
2330
+ if (!activeMigrationCleanup)
2331
+ return false;
2332
+ if (!activeMigrationCleanupPromise) {
2333
+ const cleanup = activeMigrationCleanup();
2334
+ activeMigrationCleanupPromise = cleanup;
2335
+ cleanup.catch(() => {
2336
+ // A transient backend failure must remain retryable from the restored extraction page.
2337
+ if (activeMigrationCleanupPromise === cleanup)
2338
+ activeMigrationCleanupPromise = null;
2339
+ });
2340
+ }
2341
+ await activeMigrationCleanupPromise;
2342
+ return true;
2343
+ };
2344
+ srv.setMigrationPauseHandler(async () => {
2345
+ pauseRequested = true;
2346
+ // Cancel the backend session first. This immediately releases every queued reservation and
2347
+ // prevents another job from being claimed while already-running requests finish safely.
2348
+ const canceledBeforePause = await cancelActiveMigration();
2349
+ if (pauseCompletion)
2350
+ await pauseCompletion;
2351
+ // If End for now raced with import-session creation, the cleanup handle becomes available only
2352
+ // after the local worker loop observes pauseRequested. Cancel it before acknowledging the UI.
2353
+ if (!canceledBeforePause)
2354
+ await cancelActiveMigration();
2355
+ });
2202
2356
  const choice = await srv.decision;
2203
2357
  if (choice === "migrate") {
2204
- const { res } = await srv.migrateRequest;
2358
+ const { res, conversationKeys } = await srv.migrateRequest;
2205
2359
  let migrateResponded = false;
2206
2360
  const sendMigrate = (body, status = 200) => {
2207
2361
  if (migrateResponded)
@@ -2214,6 +2368,14 @@ async function cmdLogin(flags) {
2214
2368
  let progressDone = 0;
2215
2369
  let progressFailed = 0;
2216
2370
  let progressExtracted = 0;
2371
+ let resolvePauseCompletion = null;
2372
+ pauseCompletion = new Promise((resolve) => {
2373
+ resolvePauseCompletion = resolve;
2374
+ });
2375
+ const completePause = () => {
2376
+ resolvePauseCompletion?.();
2377
+ resolvePauseCompletion = null;
2378
+ };
2217
2379
  if (!disc) {
2218
2380
  srv.setProgress({
2219
2381
  status: "starting",
@@ -2273,7 +2435,11 @@ async function cmdLogin(flags) {
2273
2435
  process.exitCode = 1;
2274
2436
  return true;
2275
2437
  }
2276
- activeJobCount = exact.pending.length;
2438
+ const requestedKeys = conversationKeys ? new Set(conversationKeys) : null;
2439
+ const selectedPending = requestedKeys
2440
+ ? exact.pending.filter((session) => requestedKeys.has(session.conversationKey))
2441
+ : exact.pending;
2442
+ activeJobCount = selectedPending.length;
2277
2443
  const updateProgress = (patch) => {
2278
2444
  srv.setProgress({
2279
2445
  status: "running",
@@ -2289,7 +2455,7 @@ async function cmdLogin(flags) {
2289
2455
  });
2290
2456
  };
2291
2457
  try {
2292
- if (exact.pending.length === 0) {
2458
+ if (selectedPending.length === 0) {
2293
2459
  srv.setProgress({
2294
2460
  status: "completed",
2295
2461
  total: 0,
@@ -2305,7 +2471,7 @@ async function cmdLogin(flags) {
2305
2471
  console.log("Setup complete — no unprocessed local conversations to extract.");
2306
2472
  return true;
2307
2473
  }
2308
- updateProgress({ status: "starting", running: 0, queued: exact.pending.length, latest: "Creating import session." });
2474
+ updateProgress({ status: "starting", running: 0, queued: selectedPending.length, latest: "Creating import session." });
2309
2475
  // Plan caps limit how many conversations one import session accepts (IMPORT_LIMIT_EXCEEDED →
2310
2476
  // startMigration slices to the cap). Instead of making the user re-run setup per batch (3000
2311
2477
  // sessions used to mean 3 clicks), loop batches automatically until everything pending is done.
@@ -2324,7 +2490,7 @@ async function cmdLogin(flags) {
2324
2490
  ...(latestRepo ? { latestRepo } : {}),
2325
2491
  });
2326
2492
  };
2327
- let remaining = exact.pending;
2493
+ let remaining = selectedPending;
2328
2494
  let stoppedReason;
2329
2495
  let planLimitNote;
2330
2496
  let batchIndex = 0;
@@ -2334,16 +2500,23 @@ async function cmdLogin(flags) {
2334
2500
  let h;
2335
2501
  try {
2336
2502
  const controller = new AbortController();
2337
- h = await withTimeout(startMigration({ pending: remaining, signal: controller.signal, onProgress: onBatchProgress }), 30_000, "IMPORT_START_TIMEOUT", () => controller.abort());
2503
+ h = await withTimeout(startMigration({
2504
+ pending: remaining,
2505
+ signal: controller.signal,
2506
+ onProgress: onBatchProgress,
2507
+ shouldStop: () => pauseRequested,
2508
+ }), 30_000, "IMPORT_START_TIMEOUT", () => controller.abort());
2338
2509
  }
2339
2510
  catch (batchError) {
2340
2511
  if (batchIndex === 1)
2341
2512
  throw batchError; // first batch failing = the whole import failed
2342
2513
  // A later batch could not start (e.g. plan headroom exhausted). Finish gracefully with a note.
2343
- planLimitNote = `Imported ${progressDone} so far — the rest hit your plan's import limit. Re-run extraction later for the remaining ${remaining.length}.`;
2514
+ planLimitNote = `Imported ${progressDone}. Upgrade your plan to import the remaining ${remaining.length} conversations.`;
2344
2515
  break;
2345
2516
  }
2346
2517
  activeSessionId = h.sessionId;
2518
+ activeMigrationCleanup = h.cancelQueued;
2519
+ activeMigrationCleanupPromise = null;
2347
2520
  if (batchIndex === 1) {
2348
2521
  updateProgress({ status: "running", sessionId: h.sessionId, jobCount: activeJobCount, total: activeJobCount, latest: "Import session created." });
2349
2522
  sendMigrate({ sessionId: h.sessionId, jobCount: activeJobCount });
@@ -2353,17 +2526,42 @@ async function cmdLogin(flags) {
2353
2526
  }
2354
2527
  console.log(`Migration metrics (batch ${batchIndex}): ${h.metricsFile}`);
2355
2528
  const r = await h.done;
2529
+ if (r.stoppedReason === "paused" || pauseRequested) {
2530
+ stoppedReason = "paused";
2531
+ break;
2532
+ }
2356
2533
  if (r.stoppedReason) {
2357
2534
  stoppedReason = r.stoppedReason;
2358
2535
  break;
2359
2536
  }
2360
- // startMigration sliced to the cap more remain: continue with the next batch automatically.
2537
+ // A historical allowance is lifetime, not a per-batch cap. Stop cleanly
2538
+ // after the allowed slice instead of attempting another session.
2361
2539
  if (h.capped && remaining.length > h.jobCount) {
2362
- remaining = remaining.slice(h.jobCount);
2363
- continue;
2540
+ const left = remaining.length - h.jobCount;
2541
+ planLimitNote = `Import complete for this plan. Upgrade to import the remaining ${left} conversations.`;
2542
+ break;
2364
2543
  }
2365
2544
  break;
2366
2545
  }
2546
+ if (stoppedReason === "paused") {
2547
+ srv.setProgress({
2548
+ status: "paused",
2549
+ sessionId: activeSessionId || undefined,
2550
+ jobCount: activeJobCount,
2551
+ total: activeJobCount,
2552
+ completed: progressDone,
2553
+ running: 0,
2554
+ queued: Math.max(0, activeJobCount - progressDone - progressFailed),
2555
+ failed: progressFailed,
2556
+ extracted: progressExtracted,
2557
+ latest: "Ended for now. Re-run setup to rebuild the remaining conversation list.",
2558
+ });
2559
+ console.log(`Import ended for now: ${progressDone} imported, ${progressExtracted} memories, ${progressFailed} failed.`);
2560
+ completePause();
2561
+ // /skip sends the browser acknowledgement and closes the localhost bridge after this
2562
+ // safe pause boundary. Do not close it here first or the page can claim success early.
2563
+ return true;
2564
+ }
2367
2565
  srv.setProgress({
2368
2566
  status: stoppedReason ? "failed" : "completed",
2369
2567
  sessionId: activeSessionId || undefined,
@@ -2384,6 +2582,22 @@ async function cmdLogin(flags) {
2384
2582
  srv.close();
2385
2583
  }
2386
2584
  catch (e) {
2585
+ if (pauseRequested) {
2586
+ srv.setProgress({
2587
+ status: "paused",
2588
+ sessionId: activeSessionId || undefined,
2589
+ jobCount: activeJobCount,
2590
+ total: activeJobCount,
2591
+ completed: progressDone,
2592
+ running: 0,
2593
+ queued: Math.max(0, activeJobCount - progressDone - progressFailed),
2594
+ failed: progressFailed,
2595
+ extracted: progressExtracted,
2596
+ latest: "Ended for now. Re-run setup to rebuild the remaining conversation list.",
2597
+ });
2598
+ completePause();
2599
+ return true;
2600
+ }
2387
2601
  srv.setProgress({
2388
2602
  status: "failed",
2389
2603
  sessionId: activeSessionId || undefined,
@@ -2406,8 +2620,13 @@ async function cmdLogin(flags) {
2406
2620
  sendMigrate({ error: "NO_PENDING_SESSIONS" }, 409);
2407
2621
  else if (e?.code === "IMPORT_START_TIMEOUT")
2408
2622
  sendMigrate({ error: "IMPORT_START_TIMEOUT" }, 504);
2409
- else
2410
- sendMigrate({ error: "IMPORT_START_FAILED", message: String(e?.message || e) }, 500);
2623
+ else {
2624
+ const responseData = isObjectRecord(e?.response?.data) ? e.response.data : {};
2625
+ const responseCode = asString(responseData.error) || "IMPORT_START_FAILED";
2626
+ const responseMessage = asString(responseData.message) || String(e?.message || e);
2627
+ const responseStatus = typeof e?.response?.status === "number" ? e.response.status : 500;
2628
+ sendMigrate({ error: responseCode, message: responseMessage }, responseStatus);
2629
+ }
2411
2630
  await new Promise((resolve) => setTimeout(resolve, 2000));
2412
2631
  srv.close();
2413
2632
  process.exitCode = 1;
@@ -108,7 +108,7 @@ export function listToolSpecs(opts = {}) {
108
108
  const currentTime = new Date().toISOString();
109
109
  const map = opts.map?.trim();
110
110
  const updateNotice = opts.updateNotice?.trim();
111
- const paidRecallNote = "Requires Echo Pro or Power; saving conversations and memory extraction remain included.";
111
+ const recallPlanNote = "Available on every plan: Free includes 10 searches each week, Pro includes 100, and Power includes 250.";
112
112
  const searchBillingReplyInstruction = "If search returns an ACTION REQUIRED subscription message, tell the user to start their trial or subscription and include the exact URL from that result verbatim. Do not respond only with \"connect\" or \"upgrade\".";
113
113
  const updateSection = updateNotice ? `\n\nUPDATE NOTICE: ${updateNotice}` : "";
114
114
  const mapSection = map
@@ -117,7 +117,7 @@ export function listToolSpecs(opts = {}) {
117
117
  return [
118
118
  {
119
119
  name: canonicalToolNames.search,
120
- description: withMcpVersion(`Recall the user's prior decisions, preferences, and project context from EchoMem — their long-term memory across ALL their AI tools, not just this session. Use it instead of re-deriving or re-asking what the user already settled. ${paidRecallNote} ${searchBillingReplyInstruction}${mapSection}\nReturns the ranked memories; set includeAnswer=true only if you need the legacy synthesized answer. Current time: ${currentTime}.${updateSection}`),
120
+ description: withMcpVersion(`Recall the user's prior decisions, preferences, and project context from EchoMem — their long-term memory across ALL their AI tools, not just this session. Use it instead of re-deriving or re-asking what the user already settled. ${recallPlanNote} ${searchBillingReplyInstruction}${mapSection}\nReturns the ranked memories; set includeAnswer=true only if you need the legacy synthesized answer. Current time: ${currentTime}.${updateSection}`),
121
121
  inputSchema: {
122
122
  type: "object",
123
123
  properties: {
@@ -140,7 +140,7 @@ export function listToolSpecs(opts = {}) {
140
140
  },
141
141
  {
142
142
  name: "search_memories_by_description_semantic",
143
- description: `Legacy alias for search_memories. ${paidRecallNote} ${searchBillingReplyInstruction}`,
143
+ description: `Legacy alias for search_memories. ${recallPlanNote} ${searchBillingReplyInstruction}`,
144
144
  inputSchema: {
145
145
  type: "object",
146
146
  properties: {
@@ -163,7 +163,7 @@ export function listToolSpecs(opts = {}) {
163
163
  },
164
164
  {
165
165
  name: canonicalToolNames.save,
166
- description: "Save this conversation into EchoMem as long-term memory (durable memories are extracted automatically). Saving is included. passthrough=true stores the text verbatim as a session capsule; reloading/searching saved memories requires Echo Pro or Power.",
166
+ description: "Save this conversation into EchoMem as long-term memory (durable memories are extracted automatically). New extraction input uses the plan's weekly processing allowance; if the limit is reached, nothing is saved. passthrough=true stores the text verbatim as a session capsule.",
167
167
  inputSchema: {
168
168
  type: "object",
169
169
  properties: {
@@ -196,7 +196,7 @@ export function listToolSpecs(opts = {}) {
196
196
  },
197
197
  {
198
198
  name: canonicalToolNames.timeRange,
199
- description: `Retrieve memories within a specific date range. ${paidRecallNote} Current time: ${currentTime}.`,
199
+ description: `Retrieve memories within a specific date range. ${recallPlanNote} Current time: ${currentTime}.`,
200
200
  inputSchema: {
201
201
  type: "object",
202
202
  properties: {
@@ -214,7 +214,7 @@ export function listToolSpecs(opts = {}) {
214
214
  },
215
215
  {
216
216
  name: canonicalToolNames.keywords,
217
- description: `Search memories based on keywords in keys field. ${paidRecallNote}`,
217
+ description: `Search memories based on keywords in keys field. ${recallPlanNote}`,
218
218
  inputSchema: {
219
219
  type: "object",
220
220
  properties: {
@@ -371,7 +371,7 @@ export function listToolSpecs(opts = {}) {
371
371
  },
372
372
  {
373
373
  name: canonicalToolNames.getByContext,
374
- description: withMcpVersion(`Deterministically re-fetch the exact batch of memories saved under one contextId — no semantic search, no ranking, just that session's saved capsule. ${paidRecallNote} save_conversation returns a contextId; pass it here to pull back precisely those memories, e.g. to warm up a fresh session with what a prior session saved, or to verify the saved facts are still present. Current time: ${currentTime}.`),
374
+ description: withMcpVersion(`Deterministically re-fetch the exact batch of memories saved under one contextId — no semantic search, no ranking, just that session's saved capsule. ${recallPlanNote} save_conversation returns a contextId; pass it here to pull back precisely those memories, e.g. to warm up a fresh session with what a prior session saved, or to verify the saved facts are still present. Current time: ${currentTime}.`),
375
375
  inputSchema: {
376
376
  type: "object",
377
377
  properties: {
@@ -388,7 +388,7 @@ export function listToolSpecs(opts = {}) {
388
388
  },
389
389
  {
390
390
  name: canonicalToolNames.checkpointByContext,
391
- description: withMcpVersion(`Rebuild a clean-context checkpoint / decision log from one EchoMem contextId. ${paidRecallNote} Use this when the user or EchoMem HUD gives you a contextId for a renewed coding session and you need the session handoff, not a raw memory dump. It deterministically fetches that context and formats it as orientation state: decisions, carryover, constraints, and checkpoints. Current time: ${currentTime}.`),
391
+ description: withMcpVersion(`Rebuild a clean-context checkpoint / decision log from one EchoMem contextId. ${recallPlanNote} Use this when the user or EchoMem HUD gives you a contextId for a renewed coding session and you need the session handoff, not a raw memory dump. It deterministically fetches that context and formats it as orientation state: decisions, carryover, constraints, and checkpoints. Current time: ${currentTime}.`),
392
392
  inputSchema: {
393
393
  type: "object",
394
394
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.4.17",
3
+ "version": "1.4.19",
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",