@echomem/mcp 1.4.27 → 1.4.29

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";
@@ -389,6 +389,7 @@ 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
+ '- If the final user-facing answer materially relies on one or more EchoMem memories, end it with a compact `EchoMem sources:` list containing only the memories actually used. Link each memory key to its canonical `https://echoknows.com/memory/<memory-id>` URL. Do not cite memories that were merely retrieved, and omit the section when no memory informed the answer.',
392
393
  "- 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
394
  "- 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.",
394
395
  "- 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.",
@@ -1145,6 +1146,155 @@ function discoverMigratableSessionsOffThread() {
1145
1146
  });
1146
1147
  });
1147
1148
  }
1149
+ export function discoverMigratableFastOffThread(opts = {}) {
1150
+ const migrateUrl = runtimeModuleUrl("migrate");
1151
+ const serializedOpts = JSON.stringify(opts);
1152
+ const code = `
1153
+ import { parentPort } from "node:worker_threads";
1154
+ import { discoverMigratableFastDiscovery } from ${JSON.stringify(migrateUrl)};
1155
+
1156
+ try {
1157
+ parentPort?.postMessage({ ok: true, discovery: discoverMigratableFastDiscovery(${serializedOpts}) });
1158
+ } catch (error) {
1159
+ parentPort?.postMessage({
1160
+ ok: false,
1161
+ message: error instanceof Error ? error.message : String(error),
1162
+ stack: error instanceof Error ? error.stack : undefined,
1163
+ });
1164
+ }
1165
+ `;
1166
+ const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
1167
+ return new Promise((resolve, reject) => {
1168
+ let settled = false;
1169
+ worker.once("message", (message) => {
1170
+ settled = true;
1171
+ const msg = message;
1172
+ if (msg.ok === true && msg.discovery && typeof msg.discovery === "object") {
1173
+ resolve(msg.discovery);
1174
+ return;
1175
+ }
1176
+ const err = new Error(typeof msg.message === "string" ? msg.message : "Quick local discovery failed");
1177
+ if (typeof msg.stack === "string")
1178
+ err.stack = msg.stack;
1179
+ reject(err);
1180
+ });
1181
+ worker.once("error", (error) => {
1182
+ if (settled)
1183
+ return;
1184
+ settled = true;
1185
+ reject(error);
1186
+ });
1187
+ worker.once("exit", (code) => {
1188
+ if (settled)
1189
+ return;
1190
+ settled = true;
1191
+ reject(new Error(`Quick local discovery worker exited (code ${code}) without a result`));
1192
+ });
1193
+ });
1194
+ }
1195
+ /** Build the full local-history dashboard payload away from the callback server's event loop.
1196
+ * `collect()` can synchronously parse hundreds of JSONL files for tens of seconds; doing that on
1197
+ * the bridge thread prevents even localhost actions such as account switch from receiving a reply. */
1198
+ export function buildCollectedStatsPayloadOffThread(inject) {
1199
+ const reportUrl = runtimeModuleUrl("report");
1200
+ const serializedInject = JSON.stringify(inject);
1201
+ const code = `
1202
+ import { parentPort } from "node:worker_threads";
1203
+ import { collect, buildStatsPayload } from ${JSON.stringify(reportUrl)};
1204
+
1205
+ try {
1206
+ const payload = await buildStatsPayload(collect(), ${serializedInject});
1207
+ parentPort?.postMessage({ ok: true, payload });
1208
+ } catch (error) {
1209
+ parentPort?.postMessage({
1210
+ ok: false,
1211
+ message: error instanceof Error ? error.message : String(error),
1212
+ stack: error instanceof Error ? error.stack : undefined,
1213
+ });
1214
+ }
1215
+ `;
1216
+ const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
1217
+ return new Promise((resolve, reject) => {
1218
+ let settled = false;
1219
+ const finish = (result) => {
1220
+ if (settled)
1221
+ return;
1222
+ settled = true;
1223
+ void worker.terminate();
1224
+ if (result.ok)
1225
+ resolve(result.payload);
1226
+ else
1227
+ reject(result.error);
1228
+ };
1229
+ worker.once("message", (message) => {
1230
+ const msg = message;
1231
+ if (msg.ok === true) {
1232
+ finish({ ok: true, payload: msg.payload });
1233
+ return;
1234
+ }
1235
+ const error = new Error(typeof msg.message === "string" ? msg.message : "Full local stats worker failed");
1236
+ if (typeof msg.stack === "string")
1237
+ error.stack = msg.stack;
1238
+ finish({ ok: false, error });
1239
+ });
1240
+ worker.once("error", (error) => {
1241
+ finish({ ok: false, error });
1242
+ });
1243
+ worker.once("exit", (code) => {
1244
+ if (settled)
1245
+ return;
1246
+ finish({ ok: false, error: new Error(`Full local stats worker exited (code ${code}) without a result`) });
1247
+ });
1248
+ });
1249
+ }
1250
+ export function createLocalDiscoveryCache(loaders = {}) {
1251
+ const loadQuick = loaders.loadQuick ?? (() => discoverMigratableFastOffThread());
1252
+ const loadExact = loaders.loadExact ?? (() => discoverMigratableSessionsOffThread());
1253
+ let quick = null;
1254
+ let quickPromise = null;
1255
+ let exact = null;
1256
+ let exactPromise = null;
1257
+ return {
1258
+ getQuick() {
1259
+ if (quick)
1260
+ return Promise.resolve(quick);
1261
+ if (quickPromise)
1262
+ return quickPromise;
1263
+ quickPromise = loadQuick()
1264
+ .then((discovery) => {
1265
+ quick = discovery;
1266
+ return discovery;
1267
+ })
1268
+ .catch((error) => {
1269
+ quickPromise = null;
1270
+ throw error;
1271
+ });
1272
+ return quickPromise;
1273
+ },
1274
+ getExact() {
1275
+ if (exact)
1276
+ return Promise.resolve(exact);
1277
+ if (exactPromise)
1278
+ return exactPromise;
1279
+ exactPromise = loadExact()
1280
+ .then((discovery) => {
1281
+ exact = discovery;
1282
+ return discovery;
1283
+ })
1284
+ .catch((error) => {
1285
+ exactPromise = null;
1286
+ throw error;
1287
+ });
1288
+ return exactPromise;
1289
+ },
1290
+ peekExact() {
1291
+ return exact;
1292
+ },
1293
+ pendingExact() {
1294
+ return exactPromise;
1295
+ },
1296
+ };
1297
+ }
1148
1298
  function forensicStageLabel(stage) {
1149
1299
  if (stage === "reading-transcripts")
1150
1300
  return "Reading transcript files";
@@ -1166,7 +1316,9 @@ export function buildForensicReportOffThread(onProgress, options = {}) {
1166
1316
  try {
1167
1317
  const report = await buildForensicReport({
1168
1318
  includeLegacyGoldenStandard: false,
1169
- onProgress: (done, total, stage, detail, overall) => parentPort?.postMessage({ progress: { done, total, stage, detail, overall } }),
1319
+ onProgress: (done, total, stage, detail, overall, stageDone, stageTotal) => parentPort?.postMessage({
1320
+ progress: { done, total, stage, detail, overall, stageDone, stageTotal },
1321
+ }),
1170
1322
  });
1171
1323
  parentPort?.postMessage({ ok: true, report });
1172
1324
  } catch (error) {
@@ -1259,6 +1411,7 @@ function publicRunningForensicProgress(value) {
1259
1411
  ? Math.min(1, Math.max(0, candidate))
1260
1412
  : 0);
1261
1413
  const total = safeCount(progress.total);
1414
+ const stageTotal = safeCount(progress.stageTotal);
1262
1415
  const rawStage = typeof progress.stage === "string" ? progress.stage : "starting";
1263
1416
  const stage = ["starting", "reading-transcripts", "building-summary", "classifying-repeated-context", "finalizing-report"].includes(rawStage)
1264
1417
  ? rawStage
@@ -1269,6 +1422,8 @@ function publicRunningForensicProgress(value) {
1269
1422
  total,
1270
1423
  stage,
1271
1424
  label: forensicStageLabel(stage),
1425
+ stageDone: stageTotal > 0 ? Math.min(safeCount(progress.stageDone), stageTotal) : 0,
1426
+ stageTotal,
1272
1427
  overall: safeFraction(progress.overall),
1273
1428
  elapsedMs: safeDuration(progress.elapsedMs),
1274
1429
  stageElapsedMs: safeDuration(progress.stageElapsedMs),
@@ -1303,6 +1458,7 @@ export function startCallbackServer(opts = {}) {
1303
1458
  let switchAccountUrl = "";
1304
1459
  let connected = Boolean(opts.initialToken?.token);
1305
1460
  let activeDeviceToken = opts.initialToken?.token || "";
1461
+ let activeAccountEmail = "";
1306
1462
  let pendingLocalAuth = null;
1307
1463
  let reportConsentGranted = !requiresReportConsent;
1308
1464
  let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
@@ -1337,8 +1493,8 @@ export function startCallbackServer(opts = {}) {
1337
1493
  return false;
1338
1494
  return revokeDeviceToken(pending.token);
1339
1495
  };
1340
- const completeDeviceLogin = async (token) => {
1341
- await authedAxios(token).post("/api/extension/mcp/local-auth/complete-device", {}, { timeout: 10_000 });
1496
+ const completeDeviceLogin = async (token, encryptionPreference = "maximum") => {
1497
+ await authedAxios(token).post("/api/extension/mcp/local-auth/complete-device", { encryptionPreference }, { timeout: 10_000 });
1342
1498
  };
1343
1499
  const close = () => {
1344
1500
  if (timer)
@@ -1414,6 +1570,7 @@ export function startCallbackServer(opts = {}) {
1414
1570
  const firstToken = !onToken.settled();
1415
1571
  connected = true;
1416
1572
  activeDeviceToken = token;
1573
+ activeAccountEmail = pendingLocalAuth?.email || activeAccountEmail;
1417
1574
  pendingLocalAuth = null;
1418
1575
  const callbackToken = { token, key };
1419
1576
  if (firstToken) {
@@ -1571,6 +1728,38 @@ export function startCallbackServer(opts = {}) {
1571
1728
  json(res, detail.status, { ok: false, error: detail.message });
1572
1729
  }
1573
1730
  };
1731
+ const handleLocalSkipEncryption = async (res, body) => {
1732
+ if (rejectIfLocalAuthBlocked(res, asString(body.nonce)))
1733
+ return;
1734
+ if (!pendingLocalAuth || pendingLocalAuth.expiresAtMs <= Date.now()) {
1735
+ await revokePendingLocalAuth();
1736
+ return void json(res, 409, {
1737
+ ok: false,
1738
+ error: "This local login expired. Send a new verification code.",
1739
+ reset: true,
1740
+ });
1741
+ }
1742
+ if (pendingLocalAuth.mode !== "setup") {
1743
+ return void json(res, 409, {
1744
+ ok: false,
1745
+ error: "This account already uses encrypted memory. Enter the vault passphrase to continue.",
1746
+ });
1747
+ }
1748
+ try {
1749
+ await completeDeviceLogin(pendingLocalAuth.token, "standard");
1750
+ resolveLocalToken(pendingLocalAuth.token, undefined);
1751
+ json(res, 200, {
1752
+ ok: true,
1753
+ connected: true,
1754
+ encryptionEnabled: false,
1755
+ encryptionPreference: "standard",
1756
+ });
1757
+ }
1758
+ catch (error) {
1759
+ const detail = publicAxiosError(error, "Failed to continue without encryption");
1760
+ json(res, detail.status, { ok: false, error: detail.message });
1761
+ }
1762
+ };
1574
1763
  server = http.createServer((req, res) => {
1575
1764
  res.setHeader("Access-Control-Allow-Origin", "*");
1576
1765
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
@@ -1635,6 +1824,18 @@ export function startCallbackServer(opts = {}) {
1635
1824
  });
1636
1825
  return;
1637
1826
  }
1827
+ if (route === "/local-auth/skip-encryption" && req.method === "POST") {
1828
+ let body;
1829
+ try {
1830
+ body = await readJsonBody(req);
1831
+ }
1832
+ catch {
1833
+ text(res, 400, "bad json");
1834
+ return;
1835
+ }
1836
+ await handleLocalSkipEncryption(res, body);
1837
+ return;
1838
+ }
1638
1839
  if (route === "/launch-agent" && req.method === "POST") {
1639
1840
  let body;
1640
1841
  try {
@@ -1771,6 +1972,9 @@ export function startCallbackServer(opts = {}) {
1771
1972
  const plan = (asString(response.data?.plan) || "free").toLowerCase();
1772
1973
  const trialUsed = response.data?.billing?.trialUsed === true;
1773
1974
  const trialAvailable = response.data?.billing?.trialAvailable !== false;
1975
+ const accountEmail = asString(profileResponse?.data?.email) || activeAccountEmail;
1976
+ if (accountEmail)
1977
+ activeAccountEmail = accountEmail;
1774
1978
  json(res, 200, {
1775
1979
  plan,
1776
1980
  paid: ["pro", "power", "team", "enterprise"].includes(plan),
@@ -1785,10 +1989,9 @@ export function startCallbackServer(opts = {}) {
1785
1989
  memoryProcessingQuota: response.data?.memoryProcessingQuota ?? null,
1786
1990
  memorySearchQuota: response.data?.memorySearchQuota ?? null,
1787
1991
  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) || "",
1992
+ account: accountEmail ? {
1993
+ email: accountEmail,
1994
+ avatarUrl: asString(profileResponse?.data?.avatarUrl) || "",
1792
1995
  } : null,
1793
1996
  pricingUrl,
1794
1997
  });
@@ -2083,22 +2286,25 @@ export function startCallbackServer(opts = {}) {
2083
2286
  }
2084
2287
  connected = false;
2085
2288
  activeDeviceToken = "";
2289
+ activeAccountEmail = "";
2086
2290
  const revokedPendingCredential = await revokePendingLocalAuth();
2087
2291
  stats = null;
2088
2292
  migrateStarted = false;
2089
2293
  progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
2294
+ try {
2295
+ await logoutHandler?.();
2296
+ }
2297
+ catch (e) {
2298
+ console.error(`Could not reset local login state: ${e instanceof Error ? e.message : String(e)}`);
2299
+ }
2090
2300
  json(res, 200, {
2091
2301
  ok: true,
2302
+ connected: false,
2092
2303
  authUrl,
2093
2304
  switchAccountUrl: switchAccountUrl || authUrl,
2094
2305
  localOnly: true,
2095
2306
  revokedPendingCredential,
2096
2307
  });
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
2308
  return;
2103
2309
  }
2104
2310
  if (route === "/migrate" && req.method === "POST") {
@@ -2665,6 +2871,8 @@ async function cmdOnboarding(flags) {
2665
2871
  total: 0,
2666
2872
  stage: forensicStage,
2667
2873
  label: forensicStageLabel(forensicStage),
2874
+ stageDone: 0,
2875
+ stageTotal: 0,
2668
2876
  overall: 0,
2669
2877
  elapsedMs: 0,
2670
2878
  stageElapsedMs: 0,
@@ -2678,7 +2886,18 @@ async function cmdOnboarding(flags) {
2678
2886
  requireReportConsent: true,
2679
2887
  getStats: () => stats,
2680
2888
  getReport: () => forensicReport,
2681
- getReportProgress: () => forensicProgress,
2889
+ getReportProgress: () => {
2890
+ if (forensicProgress.status !== "running")
2891
+ return forensicProgress;
2892
+ const now = Date.now();
2893
+ const sinceWorkerUpdate = Math.max(0, now - forensicProgress.updatedAt);
2894
+ return {
2895
+ ...forensicProgress,
2896
+ elapsedMs: forensicProgress.elapsedMs + sinceWorkerUpdate,
2897
+ stageElapsedMs: forensicProgress.stageElapsedMs + sinceWorkerUpdate,
2898
+ updatedAt: now,
2899
+ };
2900
+ },
2682
2901
  onReportConsent: (allowed) => {
2683
2902
  if (!allowed) {
2684
2903
  forensicConsent = "declined";
@@ -2688,6 +2907,8 @@ async function cmdOnboarding(flags) {
2688
2907
  total: 0,
2689
2908
  stage: "failed",
2690
2909
  label: "Local scan skipped",
2910
+ stageDone: 0,
2911
+ stageTotal: 0,
2691
2912
  overall: 0,
2692
2913
  elapsedMs: Date.now() - forensicStartedAt,
2693
2914
  stageElapsedMs: Date.now() - forensicStageStartedAt,
@@ -2710,6 +2931,8 @@ async function cmdOnboarding(flags) {
2710
2931
  total: 0,
2711
2932
  stage: forensicStage,
2712
2933
  label: forensicStageLabel(forensicStage),
2934
+ stageDone: 0,
2935
+ stageTotal: 0,
2713
2936
  overall: 0,
2714
2937
  elapsedMs: now - forensicStartedAt,
2715
2938
  stageElapsedMs: 0,
@@ -2744,6 +2967,12 @@ async function cmdOnboarding(flags) {
2744
2967
  stage: forensicStage,
2745
2968
  label: forensicStageLabel(forensicStage),
2746
2969
  detail: progress.detail,
2970
+ stageDone: typeof progress.stageDone === "number" && Number.isFinite(progress.stageDone)
2971
+ ? Math.max(0, Math.floor(progress.stageDone))
2972
+ : 0,
2973
+ stageTotal: typeof progress.stageTotal === "number" && Number.isFinite(progress.stageTotal)
2974
+ ? Math.max(0, Math.floor(progress.stageTotal))
2975
+ : 0,
2747
2976
  overall: forensicOverall,
2748
2977
  elapsedMs: now - forensicStartedAt,
2749
2978
  stageElapsedMs: now - forensicStageStartedAt,
@@ -2761,6 +2990,8 @@ async function cmdOnboarding(flags) {
2761
2990
  total: forensicProgress.total,
2762
2991
  stage: "failed",
2763
2992
  label: "Local scan failed",
2993
+ stageDone: forensicProgress.stageDone,
2994
+ stageTotal: forensicProgress.stageTotal,
2764
2995
  overall: forensicOverall,
2765
2996
  elapsedMs: now - forensicStartedAt,
2766
2997
  stageElapsedMs: now - forensicStageStartedAt,
@@ -2799,13 +3030,22 @@ async function cmdOnboarding(flags) {
2799
3030
  let exactDiscovery = Promise.resolve(null);
2800
3031
  let refreshGeneration = 0;
2801
3032
  let latestPendingEstimate = 0;
3033
+ // Local discovery is account-independent. Keep one snapshot for this onboarding process and
3034
+ // overlay each connected account's processed keys instead of rereading every JSONL on switch.
3035
+ const localDiscovery = createLocalDiscoveryCache({
3036
+ loadExact: async () => {
3037
+ // Publish the quick metadata result before starting the heavier exact worker.
3038
+ await delay(250);
3039
+ return discoverMigratableSessionsOffThread();
3040
+ },
3041
+ });
2802
3042
  // The account's already-imported keys, shared so the /migrate sizing can assemble ONLY pending sessions.
2803
3043
  let lastProcessedImportKeys = null;
2804
3044
  const resetLocalLoginState = () => {
2805
3045
  refreshGeneration++;
2806
3046
  stats = null;
2807
3047
  disc = null;
2808
- exactDiscovery = Promise.resolve(null);
3048
+ exactDiscovery = localDiscovery.pendingExact() ?? Promise.resolve(localDiscovery.peekExact());
2809
3049
  latestPendingEstimate = 0;
2810
3050
  lastProcessedImportKeys = null;
2811
3051
  srv.setStats(null);
@@ -2823,7 +3063,9 @@ async function cmdOnboarding(flags) {
2823
3063
  const generation = ++refreshGeneration;
2824
3064
  lastProcessedImportKeys = null; // shared with /migrate so it can assemble only the pending sessions
2825
3065
  let importStatusUnavailable = false;
2826
- const quickDiscovery = discoverMigratableFastDiscovery();
3066
+ const quickDiscovery = await localDiscovery.getQuick();
3067
+ if (generation !== refreshGeneration)
3068
+ return;
2827
3069
  const quick = summarizeFastMigratableDiscovery(quickDiscovery);
2828
3070
  let migratable = migratableFromFastSummary(quick);
2829
3071
  latestPendingEstimate = migratable.pending;
@@ -2931,23 +3173,7 @@ async function cmdOnboarding(flags) {
2931
3173
  console.error(`Could not check this EchoMem account's import status quickly: ${e instanceof Error ? e.message : String(e)}`);
2932
3174
  }
2933
3175
  });
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) => {
3176
+ exactDiscovery = localDiscovery.getExact().then(async (exact) => {
2951
3177
  if (generation !== refreshGeneration)
2952
3178
  return disc;
2953
3179
  const initialExact = lastProcessedImportKeys
@@ -3029,7 +3255,7 @@ async function cmdOnboarding(flags) {
3029
3255
  failed: 0,
3030
3256
  extracted: 0,
3031
3257
  });
3032
- const fullPayload = withCandidateSessions(await buildStatsPayload(collect(), {
3258
+ const fullPayload = withCandidateSessions(await buildCollectedStatsPayloadOffThread({
3033
3259
  sessions: sessionSummary,
3034
3260
  migratable,
3035
3261
  discovery: { phase: "full", exact: true },
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { withMcpVersion } from "./package-metadata.js";
2
+ import { MEMORY_CITATION_INSTRUCTION, withMcpVersion } from "./package-metadata.js";
3
3
  export const canonicalToolNames = {
4
4
  search: "search_memories",
5
5
  save: "save_conversation",
@@ -204,6 +204,7 @@ export function listToolSpecs(opts = {}) {
204
204
  const updateNotice = opts.updateNotice?.trim();
205
205
  const recallPlanNote = "Available on every plan: Free includes 100 searches each week, Pro includes 500, and Power includes 2,000.";
206
206
  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\".";
207
+ const memoryCitationInstruction = MEMORY_CITATION_INSTRUCTION;
207
208
  const updateSection = updateNotice ? `\n\nUPDATE NOTICE: ${updateNotice}` : "";
208
209
  const mapSection = map
209
210
  ? `\n\nThis user's EchoMem currently covers these topics (a relevance guide — recall when the task relates to one of them):\n${map}\n`
@@ -211,7 +212,7 @@ export function listToolSpecs(opts = {}) {
211
212
  return [
212
213
  {
213
214
  name: canonicalToolNames.search,
214
- 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 ranked memories only; the MCP host model writes the final answer. Current time: ${currentTime}.${updateSection}`),
215
+ 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} ${memoryCitationInstruction}${mapSection}\nReturns ranked memories only; the MCP host model writes the final answer. Current time: ${currentTime}.${updateSection}`),
215
216
  inputSchema: {
216
217
  type: "object",
217
218
  properties: {
@@ -229,7 +230,7 @@ export function listToolSpecs(opts = {}) {
229
230
  },
230
231
  {
231
232
  name: "search_memories_by_description_semantic",
232
- description: `Legacy alias for search_memories. ${recallPlanNote} ${searchBillingReplyInstruction}`,
233
+ description: `Legacy alias for search_memories. ${recallPlanNote} ${searchBillingReplyInstruction} ${memoryCitationInstruction}`,
233
234
  inputSchema: {
234
235
  type: "object",
235
236
  properties: {
@@ -280,7 +281,7 @@ export function listToolSpecs(opts = {}) {
280
281
  },
281
282
  {
282
283
  name: canonicalToolNames.timeRange,
283
- description: `Retrieve memories within a specific date range. ${recallPlanNote} Current time: ${currentTime}.`,
284
+ description: `Retrieve memories within a specific date range. ${recallPlanNote} ${memoryCitationInstruction} Current time: ${currentTime}.`,
284
285
  inputSchema: {
285
286
  type: "object",
286
287
  properties: {
@@ -298,7 +299,7 @@ export function listToolSpecs(opts = {}) {
298
299
  },
299
300
  {
300
301
  name: canonicalToolNames.keywords,
301
- description: `Search memories based on keywords in keys field. Pass keywords as valid JSON: preferably an array of quoted strings, for example {"keywords":["flow-lab","flow.html","Rive"],"limit":8}. A comma-separated JSON string is also accepted as a compatibility fallback. Never emit bare comma-separated tokens. ${recallPlanNote}`,
302
+ description: `Search memories based on keywords in keys field. Pass keywords as valid JSON: preferably an array of quoted strings, for example {"keywords":["flow-lab","flow.html","Rive"],"limit":8}. A comma-separated JSON string is also accepted as a compatibility fallback. Never emit bare comma-separated tokens. ${recallPlanNote} ${memoryCitationInstruction}`,
302
303
  inputSchema: {
303
304
  type: "object",
304
305
  properties: {
@@ -384,7 +385,7 @@ export function listToolSpecs(opts = {}) {
384
385
  },
385
386
  {
386
387
  name: canonicalToolNames.others,
387
- description: "Search public memories from accepted friends or people who share your company group. For onboarding and division-of-work questions, call get_group_context first, then use this tool for current evidence. Returned memories are recorded in memory_views for the owners.",
388
+ description: `Search public memories from accepted friends or people who share your company group. For onboarding and division-of-work questions, call get_group_context first, then use this tool for current evidence. Returned memories are recorded in memory_views for the owners. ${memoryCitationInstruction}`,
388
389
  inputSchema: {
389
390
  type: "object",
390
391
  properties: {
@@ -429,7 +430,7 @@ export function listToolSpecs(opts = {}) {
429
430
  },
430
431
  {
431
432
  name: canonicalToolNames.publicMemory,
432
- description: "Fetch one public memory by id when its owner is an accepted friend or shares your company group. If the caller is not the owner, EchoMem records the access in memory_views.",
433
+ description: `Fetch one public memory by id when its owner is an accepted friend or shares your company group. If the caller is not the owner, EchoMem records the access in memory_views. ${memoryCitationInstruction}`,
433
434
  inputSchema: {
434
435
  type: "object",
435
436
  properties: {
@@ -685,7 +686,7 @@ export function listToolSpecs(opts = {}) {
685
686
  },
686
687
  {
687
688
  name: canonicalToolNames.getByContext,
688
- 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}.`),
689
+ 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. ${memoryCitationInstruction} Current time: ${currentTime}.`),
689
690
  inputSchema: {
690
691
  type: "object",
691
692
  properties: {
@@ -702,7 +703,7 @@ export function listToolSpecs(opts = {}) {
702
703
  },
703
704
  {
704
705
  name: canonicalToolNames.checkpointByContext,
705
- 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}.`),
706
+ 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. ${memoryCitationInstruction} Current time: ${currentTime}.`),
706
707
  inputSchema: {
707
708
  type: "object",
708
709
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.4.27",
3
+ "version": "1.4.29",
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",
@@ -10,5 +10,6 @@ Use the `echomem` MCP server as the source of long-term memory.
10
10
  1. Call `search_memories` with a concise query describing the context needed. Keep `includeAnswer` false unless an EchoMem-generated synthesis is specifically useful.
11
11
  2. Use `get_memories_by_time_range` for explicit dates, `search_memories_by_keywords` for exact terms, and `get_checkpoint_by_context` when a carryover references an EchoMem checkpoint.
12
12
  3. Distinguish recalled facts from inference and preserve dates or provenance returned by EchoMem.
13
- 4. If EchoMem returns an action-required URL, give the user that exact URL and explanation.
14
- 5. If EchoMem is unavailable, say so. Do not switch to another memory provider unless the user requests it.
13
+ 4. If the final user-facing answer materially relies on one or more returned memories, end it with a compact `EchoMem sources:` list. Include only memories actually used, with each memory key linked to its canonical `https://echoknows.com/memory/<memory-id>` URL. Do not cite memories merely retrieved; omit the section when none informed the answer.
14
+ 5. If EchoMem returns an action-required URL, give the user that exact URL and explanation.
15
+ 6. If EchoMem is unavailable, say so. Do not switch to another memory provider unless the user requests it.