@echomem/mcp 1.4.18 → 1.4.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/migrate.js CHANGED
@@ -25,7 +25,8 @@ import readline from "node:readline";
25
25
  import axios from "axios";
26
26
  import { KeyStore, echoConfigDir } from "./keystore.js";
27
27
  import { fetchEncryptionConfig } from "./encryption.js";
28
- import { resolveClaudeProjectsDir, resolveCodexSessionsDir } from "./local-data-paths.js";
28
+ import { discoverCodexSessionFiles } from "./codex-session-files.js";
29
+ import { resolveClaudeProjectsDir } from "./local-data-paths.js";
29
30
  import { walk, eachLine } from "./report.js";
30
31
  const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
31
32
  const RATE_MAX = 28; // stay under the import-jobs /run limit of 30 / 60s
@@ -184,20 +185,29 @@ export function normalizeCwd(cwd) {
184
185
  const m = cwd.match(/worktrees\/[^/]+\/(.+)$/);
185
186
  return m ? m[1] : cwd;
186
187
  }
187
- /** Discover every local session, newest first (by first-turn timestamp). */
188
- export function discoverSessions() {
188
+ function userCreatedCodexFiles(codexRoot) {
189
+ return discoverCodexSessionFiles({
190
+ ...(codexRoot
191
+ ? { roots: [{ kind: "active", path: codexRoot, priority: 0 }] }
192
+ : {}),
193
+ includeArchived: false,
194
+ userInitiatedOnly: true,
195
+ }).files.map((file) => file.path);
196
+ }
197
+ function userCreatedClaudeFiles(claudeRoot) {
198
+ return walk(claudeRoot, (candidate) => candidate.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows").filter(isUserCreatedClaudeSessionFile);
199
+ }
200
+ /** Discover every user-created local session, newest first (by first-turn timestamp). */
201
+ export function discoverSessions(opts = {}) {
189
202
  const out = [];
190
- const codexRoot = resolveCodexSessionsDir();
191
- if (codexRoot) {
192
- for (const f of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
193
- const s = assembleCodex(f);
194
- if (s)
195
- out.push(s);
196
- }
203
+ for (const f of userCreatedCodexFiles(opts.codexRoot)) {
204
+ const s = assembleCodex(f);
205
+ if (s)
206
+ out.push(s);
197
207
  }
198
- const claudeRoot = resolveClaudeProjectsDir();
208
+ const claudeRoot = opts.claudeRoot ?? resolveClaudeProjectsDir();
199
209
  if (claudeRoot) {
200
- for (const f of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
210
+ for (const f of userCreatedClaudeFiles(claudeRoot)) {
201
211
  const s = assembleClaude(f);
202
212
  if (s)
203
213
  out.push(s);
@@ -250,6 +260,20 @@ function hasClaudeText(content) {
250
260
  return false;
251
261
  return content.some((block) => isRecord(block) && block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0);
252
262
  }
263
+ function isUserCreatedClaudeSessionFile(file) {
264
+ return initialJsonObjects(file).some((obj) => {
265
+ if (!isRecord(obj) || obj.type !== "user")
266
+ return false;
267
+ if (obj.userType !== "external" || obj.isSidechain !== false || obj.parentUuid !== null)
268
+ return false;
269
+ if (obj.isMeta === true || obj.isCompactSummary === true)
270
+ return false;
271
+ if (typeof obj.agentId === "string" && obj.agentId.trim())
272
+ return false;
273
+ const message = isRecord(obj.message) ? obj.message : {};
274
+ return hasClaudeText(message.content);
275
+ });
276
+ }
253
277
  function fastSessionInfo(file, source) {
254
278
  let conversationKey = `${source === "codex" ? "codex" : "claude-code"}:${sha16(file)}`;
255
279
  let hasTextTurn = false;
@@ -287,21 +311,18 @@ function fastSessionInfo(file, source) {
287
311
  }
288
312
  function fastSessionEntries(opts = {}) {
289
313
  const out = [];
290
- const codexRoot = opts.codexRoot ?? resolveCodexSessionsDir();
291
- if (codexRoot) {
292
- for (const filePath of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
293
- const stat = statSafe(filePath);
294
- const info = fastSessionInfo(filePath, "codex");
295
- // Include if we found text OR a real session id (big sessions can have their first text turn beyond
296
- // the 1MB probe window — gating only on text dropped them entirely; exact discovery refines later).
297
- // We require a real key so the fast/exact conversationKey match (no sha16 fallback mismatch).
298
- if (info.hasTextTurn || info.hasRealKey)
299
- out.push({ filePath, source: "codex", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
300
- }
314
+ for (const filePath of userCreatedCodexFiles(opts.codexRoot)) {
315
+ const stat = statSafe(filePath);
316
+ const info = fastSessionInfo(filePath, "codex");
317
+ // Include if we found text OR a real session id (big sessions can have their first text turn beyond
318
+ // the 1MB probe window — gating only on text dropped them entirely; exact discovery refines later).
319
+ // We require a real key so the fast/exact conversationKey match (no sha16 fallback mismatch).
320
+ if (info.hasTextTurn || info.hasRealKey)
321
+ out.push({ filePath, source: "codex", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
301
322
  }
302
323
  const claudeRoot = opts.claudeRoot ?? resolveClaudeProjectsDir();
303
324
  if (claudeRoot) {
304
- for (const filePath of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
325
+ for (const filePath of userCreatedClaudeFiles(claudeRoot)) {
305
326
  const stat = statSafe(filePath);
306
327
  const info = fastSessionInfo(filePath, "claude-code");
307
328
  // Same rule as codex: include on text OR a real session id so large sessions aren't undercounted,
@@ -499,6 +520,11 @@ function parsePositiveIntFlag(value, name) {
499
520
  throw new Error(`${name} must be a positive integer.`);
500
521
  return n;
501
522
  }
523
+ export async function cancelQueuedImportSession(client, sessionId) {
524
+ if (!sessionId)
525
+ throw new Error("IMPORT_SESSION_ID_REQUIRED");
526
+ await client.patch(`/api/extension/import-sessions/${encodeURIComponent(sessionId)}`, { action: "cancel" });
527
+ }
502
528
  function isRecord(value) {
503
529
  return typeof value === "object" && value !== null && !Array.isArray(value);
504
530
  }
@@ -756,7 +782,7 @@ function authedClient(token) {
756
782
  });
757
783
  }
758
784
  export function discoverMigratableSessions(opts = {}) {
759
- let sessions = discoverSessions();
785
+ let sessions = discoverSessions(opts);
760
786
  const since = opts.since;
761
787
  if (since)
762
788
  sessions = sessions.filter((s) => (s.firstTs || "").slice(0, 10) >= since);
@@ -989,7 +1015,18 @@ export async function startMigration(opts) {
989
1015
  metricsFile,
990
1016
  selection: opts.selection,
991
1017
  });
992
- return { sessionId: session.id, runId, jobCount: session.jobs.length, metricsFile, ...(capped ? { capped } : {}), done };
1018
+ const cancelQueued = async () => {
1019
+ await cancelQueuedImportSession(client, session.id);
1020
+ };
1021
+ return {
1022
+ sessionId: session.id,
1023
+ runId,
1024
+ jobCount: session.jobs.length,
1025
+ metricsFile,
1026
+ ...(capped ? { capped } : {}),
1027
+ done,
1028
+ cancelQueued,
1029
+ };
993
1030
  }
994
1031
  async function runJobs(args) {
995
1032
  const ledger = loadLedger();
@@ -1060,7 +1097,7 @@ async function runJobs(args) {
1060
1097
  const message = responseMessage(e);
1061
1098
  const durationMs = Date.now() - jobStartedAt;
1062
1099
  if (status === 422 && errCode === "ENCRYPTION_KEY_REQUIRED") {
1063
- stoppedReason = "key-expired"; // the vault key expired mid-run — signal workers to stop pulling
1100
+ stoppedReason = "vault-locked"; // no usable local vault key — signal workers to stop pulling
1064
1101
  done++;
1065
1102
  recordMetric(buildMigrationMetric({
1066
1103
  runId: args.runId, importSessionId: args.session.id, jobId: job.id, index: done, total,
@@ -1096,7 +1133,8 @@ async function runJobs(args) {
1096
1133
  }
1097
1134
  };
1098
1135
  try {
1099
- await Promise.all(Array.from({ length: Math.min(MIGRATE_CONCURRENCY, Math.max(1, total)) }, () => worker()));
1136
+ const workerCount = Math.min(MIGRATE_CONCURRENCY, Math.max(1, args.session.concurrency), Math.max(1, total));
1137
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
1100
1138
  }
1101
1139
  catch {
1102
1140
  failed++;
@@ -1236,12 +1274,12 @@ export async function cmdMigrate(flags) {
1236
1274
  },
1237
1275
  });
1238
1276
  if (h.capped)
1239
- console.log(c.yellow(`Your plan imports up to ${h.capped} at a time — importing the newest ${h.capped}; re-run migrate for older sessions.`));
1277
+ console.log(c.yellow(`Your plan has ${h.capped} historical slots remaining — importing the newest ${h.capped}. Upgrade to import more.`));
1240
1278
  console.log(c.dim(`Import session ${h.sessionId} — ${h.jobCount} jobs queued (the web dashboard can watch this live).`));
1241
1279
  console.log(c.dim(`Metrics: ${h.metricsFile}`));
1242
1280
  const r = await h.done;
1243
- if (r.stoppedReason === "key-expired") {
1244
- console.error(c.yellow(`\n⚠ Vault locked mid-run. Run \`echomem-mcp unlock\` and re-run migrate to resume (${r.migrated} done so far).`));
1281
+ if (r.stoppedReason === "vault-locked") {
1282
+ console.error(c.yellow(`\n⚠ Vault locked mid-run. Open Terminal, run \`echomem-mcp unlock\`, and re-run migrate to resume (${r.migrated} done so far).`));
1245
1283
  process.exitCode = 1;
1246
1284
  }
1247
1285
  console.log("");
@@ -1258,10 +1296,7 @@ export async function cmdMigrate(flags) {
1258
1296
  if (code === "NOT_LOGGED_IN")
1259
1297
  console.error("Not logged in. Run `echomem-mcp login` first, then re-run migrate.");
1260
1298
  else if (code === "VAULT_LOCKED") {
1261
- const store = new KeyStore();
1262
- console.error(store.isKeyExpired()
1263
- ? "Vault key expired. Run `echomem-mcp unlock`, then re-run migrate."
1264
- : "This account is ENCRYPTED but the vault is locked. Run `echomem-mcp unlock`, then re-run migrate.");
1299
+ console.error("This account is encrypted but the local vault is locked. Open Terminal, run `echomem-mcp unlock`, then re-run migrate.");
1265
1300
  }
1266
1301
  else if (code === "FORBIDDEN_SCOPE") {
1267
1302
  console.error("This device token cannot import history. Re-connect this device with `echomem-mcp login`.");
@@ -1283,7 +1318,14 @@ async function createImportSession(client, sessions, bareId, userTz, signal) {
1283
1318
  }));
1284
1319
  const res = await client.post("/api/extension/import-sessions", { items }, signal ? { signal } : undefined);
1285
1320
  const data = res.data || {};
1286
- return { id: String(data.session?.id || ""), jobs: Array.isArray(data.jobs) ? data.jobs : [] };
1321
+ const serverConcurrency = Number(data.session?.concurrency);
1322
+ return {
1323
+ id: String(data.session?.id || ""),
1324
+ concurrency: Number.isFinite(serverConcurrency) && serverConcurrency > 0
1325
+ ? Math.min(MIGRATE_CONCURRENCY, Math.floor(serverConcurrency))
1326
+ : MIGRATE_CONCURRENCY,
1327
+ jobs: Array.isArray(data.jobs) ? data.jobs : [],
1328
+ };
1287
1329
  }
1288
1330
  /** Run one queued job: stream its transcript to the server, which extracts it. Retries transient locks/rate limits. */
1289
1331
  async function runImportJob(client, jobId, s, userTz, encKey) {
@@ -13,16 +13,27 @@ export const SETUP_PAGE_CLIENT_CORE = String.raw ` var params = new URLSear
13
13
  var statsSlow = false;
14
14
  var decisionMade = false;
15
15
  var connected = false;
16
+ var localHistoryConsentGranted = false;
16
17
  var extractMounted = false; // WebGL extraction-plate iframe mounted once; persists across /progress polls
18
+ var lastExtractionProgress = null; // restores the live extraction view if ending fails
19
+ var extractionRecoveryMessage = ""; // persists pause failures across resumed progress updates
17
20
  var exStartAt = 0; // extraction start (ms) — drives the countdown
18
21
  var exDeadline = 0; // first honest ETA deadline; passing it while running → "taking longer" banner
19
22
  var extractionEnded = false; // user chose to leave the extraction flow before natural completion
23
+ var progressPollRunId = 0; // invalidates older progress loops when extraction resumes
20
24
  var dashMounted = false; // dashboard shell (incl. plate iframe) mounted once; persists across /stats polls
21
25
  var reportMounted = false; // report shell (city iframe) mounted once; loading is an overlay on it, not a separate page
22
26
  var authUrl = "";
27
+ var switchAccountUrl = "";
23
28
  var workspacePath = "";
24
29
  var billingStatus = null;
25
- var recallGateSkipped = false;
30
+ var billingStatusLoading = false;
31
+ var setupPlanChoice = "";
32
+ var selectedSessionKeys = Object.create(null);
33
+ var sessionSelectionTouched = false;
34
+ var sessionPickerQuery = "";
35
+ var sessionPickerSource = "all";
36
+ var billingPollTimer = null;
26
37
  var authWindow = null;
27
38
  var connectionPollStarted = false;
28
39
  var statsPollStarted = false;
@@ -34,6 +45,20 @@ export const SETUP_PAGE_CLIENT_CORE = String.raw ` var params = new URLSear
34
45
  return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch];
35
46
  });
36
47
  }
48
+ function setupIcon(name) {
49
+ var paths = {
50
+ "shield-check": '<path d="M12 3 5 6v5c0 4.6 2.9 8.1 7 10 4.1-1.9 7-5.4 7-10V6l-7-3Z"/><path d="m8.8 12.1 2 2 4.4-4.5"/>',
51
+ "folder-read": '<path d="M3 7.5h6l2-2h4.5A2.5 2.5 0 0 1 18 8v1"/><path d="M3 7.5V18a2 2 0 0 0 2 2h7"/><circle cx="17" cy="15" r="3.5"/><path d="m19.5 17.5 2 2"/>',
52
+ "report": '<path d="M5 20V10"/><path d="M12 20V4"/><path d="M19 20v-7"/><path d="M3 20h18"/>',
53
+ "cloud-off": '<path d="m3 3 18 18"/><path d="M6.7 6.7A5.5 5.5 0 0 0 6.5 17H17"/><path d="M10.7 5.2A7 7 0 0 1 19 12.1 4 4 0 0 1 19.5 19"/>',
54
+ "list-check": '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="m7.5 9 1.4 1.4L11.5 8"/><path d="M13.5 9h3"/><path d="m7.5 14 1.4 1.4 2.6-2.4"/><path d="M13.5 14h3"/>',
55
+ "pause": '<path d="M9 7v10"/><path d="M15 7v10"/>',
56
+ "check": '<path d="m6.5 12.2 3.5 3.5 7.5-7.5"/>',
57
+ "copy": '<rect x="8" y="8" width="11" height="11" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
58
+ "arrow-right": '<path d="M5 12h14"/><path d="m14 7 5 5-5 5"/>'
59
+ };
60
+ return '<svg class="setupGlyph" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><g stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' + (paths[name] || "") + '</g></svg>';
61
+ }
37
62
  function setHead(nextTitle, nextStatus) {
38
63
  title.textContent = nextTitle;
39
64
  status.textContent = nextStatus;
@@ -151,6 +176,11 @@ export const SETUP_PAGE_CLIENT_CORE = String.raw ` var params = new URLSear
151
176
  });
152
177
  }
153
178
  function onConnectClick() {
179
+ if (!localHistoryConsentGranted) {
180
+ renderLocalScanConsent();
181
+ setConsentStatus("Local history access is required before you can connect EchoMem or continue setup.", "required");
182
+ return;
183
+ }
154
184
  if (connected) { void waitForStats(); return; }
155
185
  if (!openAuthWindow(authUrl)) showPopupFallback("", authUrl);
156
186
  startConnectionPoll();