@echomem/mcp 1.4.19 → 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/README.md CHANGED
@@ -25,8 +25,11 @@ For **zero-knowledge accounts**, the key never leaves your machine. The bridge h
25
25
  - **writes:** the key is handed to your own backend transiently in the `X-Encryption-Key` header so
26
26
  memories are encrypted at rest (the server processes plaintext for the request only — never stores
27
27
  the key).
28
- - **locked state:** if the key is absent or past its TTL (7 days, matching the extension), tools
29
- return a `🔒 locked` nudge to run `echomem-mcp unlock` **ciphertext is never handed to the model**.
28
+ - **trusted-device lifecycle:** after a verified login/unlock, the MCP key remains available until
29
+ the user runs `echomem-mcp lock`, logs out, or removes the local credentials. If the key is absent,
30
+ tools tell the user to run `echomem-mcp unlock` in their own terminal. The passphrase prompt is
31
+ visible while typed characters stay hidden, and the current agent session can retry immediately
32
+ after unlock — **ciphertext is never handed to the model**.
30
33
 
31
34
  Unencrypted accounts are unaffected. `search_memories` returns retrieved memories by default so
32
35
  your MCP client does the final answer generation; pass `includeAnswer: true` only if you need the
@@ -47,8 +50,11 @@ to the global install, but installing globally avoids the issue entirely). The c
47
50
  launch-at-login also runs from the installed path. `init` is the flagship one-liner — it wraps `setup --all --with-hud`: it writes each
48
51
  installed agent's MCP config (with **no secret** in it — credentials live in
49
52
  `~/.echomem/credentials.json`, mode 0600), adds the EchoMem memory guidance to their global
50
- `AGENTS.md` / `CLAUDE.md`, opens the browser to approve the device (and unlock the vault for
51
- encrypted accounts), then launches the context HUD. Reload your editors and you're done.
53
+ `AGENTS.md` / `CLAUDE.md`, installs first-party `echomem-search`, `echomem-save`,
54
+ `echomem-forget`, and `echomem-login` skills for Codex, opens the browser to approve the device
55
+ (and unlock the vault for encrypted accounts), then launches the context HUD. EchoMem updates only
56
+ its own skill folders; other memory-provider skills are detected and reported but never modified.
57
+ Reload your editors and you're done.
52
58
 
53
59
  Prefer to keep it minimal? `echomem-mcp setup` configures only the auto-detected editor and skips the
54
60
  HUD; the granular commands below still work.
@@ -64,7 +70,8 @@ HUD; the granular commands below still work.
64
70
  | `npx -y @echomem/mcp@latest update --client codex` | Update one client only |
65
71
  | `echomem-mcp setup --with-hud [--client codex]` | Write client config + log in + launch the EchoMem context HUD |
66
72
  | `echomem-mcp login` | Approve device in browser (or use `--token` / `--passphrase`) |
67
- | `echomem-mcp unlock` | Re-derive the encryption key after its TTL (or `--passphrase`) |
73
+ | `echomem-mcp unlock` | Privately unlock the vault on this trusted device |
74
+ | `echomem-mcp lock` | Remove the local vault key while keeping the device login |
68
75
  | `echomem-mcp status` | Show token / key / detected clients, configured bridge versions, and update guidance |
69
76
  | `echomem-mcp doctor [--no-network]` | Diagnose configured client bridge versions |
70
77
  | `echomem-mcp logout` | Remove stored credentials |
@@ -35,6 +35,12 @@ function normalizedSessionId(value) {
35
35
  const trimmed = value.trim();
36
36
  return new RegExp(`^${UUID_RE.source}$`, "i").test(trimmed) ? trimmed.toLowerCase() : trimmed;
37
37
  }
38
+ function isAutomaticCodexSource(source) {
39
+ if (typeof source !== "string")
40
+ return false;
41
+ const normalized = source.trim().toLowerCase().replace(/-/g, "_");
42
+ return normalized === "auto_review" || normalized === "guardian";
43
+ }
38
44
  function metadataFromFile(file) {
39
45
  try {
40
46
  for (const line of initialJsonLines(file)) {
@@ -63,7 +69,7 @@ function metadataFromFile(file) {
63
69
  return {
64
70
  id: rawId || null,
65
71
  startedAtMs: Number.isFinite(timestamp) ? timestamp : null,
66
- userInitiated: !isSubagent && !hasParent,
72
+ userInitiated: !isSubagent && !hasParent && !isAutomaticCodexSource(source),
67
73
  };
68
74
  }
69
75
  return { id: null, startedAtMs: null, userInitiated: null };
package/dist/hud/cli.js CHANGED
File without changes
package/dist/index.js CHANGED
@@ -22,11 +22,6 @@ class NoTokenError extends Error {
22
22
  }
23
23
  /** Thrown when an encrypted account has no usable key — the model gets an unlock nudge, never ciphertext. */
24
24
  class LockedError extends Error {
25
- expired;
26
- constructor(expired) {
27
- super("EchoMem vault locked");
28
- this.expired = expired;
29
- }
30
25
  }
31
26
  function stringifyErrorValue(value) {
32
27
  if (value == null) {
@@ -126,7 +121,7 @@ function formatUpgradeRequiredResult(error) {
126
121
  pricingUrl,
127
122
  });
128
123
  return [
129
- "ACTION REQUIRED: tell the user to start a 14-day Pro trial or choose a subscription at this exact URL:",
124
+ "ACTION REQUIRED: tell the user to start a 7-day Pro trial or choose a subscription at this exact URL:",
130
125
  pricingUrl,
131
126
  "When replying, include the exact URL above. Do not reply only with \"connect\" or \"upgrade\".",
132
127
  "",
@@ -591,7 +586,7 @@ class EchoMemApiClient {
591
586
  // No usable key: consult the server to tell "unencrypted" apart from "encrypted but locked/expired".
592
587
  const cfg = await this.getEncryptionConfig();
593
588
  if (cfg.enabled)
594
- throw new LockedError(this.store.isKeyExpired());
589
+ throw new LockedError("EchoMem vault locked");
595
590
  return { enabled: false };
596
591
  }
597
592
  pruneDeleteConfirmations(now = Date.now()) {
@@ -1221,9 +1216,13 @@ class EchoMemMCPServer {
1221
1216
  content: [
1222
1217
  {
1223
1218
  type: "text",
1224
- text: error.expired
1225
- ? "🔒 EchoMem vault is locked — your encryption key expired. Run `echomem-mcp unlock` in a terminal, then retry."
1226
- : "🔒 EchoMem vault is locked. Run `echomem-mcp unlock` (or `echomem-mcp login`) to provide your encryption key, then retry.",
1219
+ text: [
1220
+ "🔒 EchoMem vault is locked.",
1221
+ "This encrypted account has no usable local decryption key. Once unlocked, this trusted device stays unlocked until you explicitly lock it or log out.",
1222
+ "Action required from the user: open Terminal and run `echomem-mcp unlock` yourself. Do not have the agent run this interactive command and do not send your passphrase in chat.",
1223
+ "At `Vault passphrase (typing is hidden):`, type the passphrase and press Return. No characters will appear while you type; that is expected.",
1224
+ "After the success message, retry this EchoMem action in the current session — no editor restart is needed.",
1225
+ ].join("\n"),
1227
1226
  },
1228
1227
  ],
1229
1228
  };
package/dist/keystore.js CHANGED
@@ -10,13 +10,13 @@
10
10
  * Storage posture: a 0600 file in the user's home — the same on-disk trust level as the extension's
11
11
  * `chrome.storage.local`. The OS keychain (spec §8's ideal) is a drop-in for `read`/`write` later;
12
12
  * it is deliberately deferred because `keytar` is a native build that complicates `npx` distribution.
13
- * The encryption key carries a TTL (mirrors the extension's 7-day expiry); on expiry the bridge
14
- * returns LOCKED and the user re-runs `unlock`.
13
+ * The MCP bridge uses an explicit trusted-device lifecycle: a verified encryption key remains
14
+ * available until the user runs `lock`/`logout` or removes the local credentials. Interactive app
15
+ * session TTLs are intentionally separate from this device-bound developer workflow.
15
16
  */
16
17
  import fs from "node:fs";
17
18
  import os from "node:os";
18
19
  import path from "node:path";
19
- export const DEFAULT_KEY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
20
  /** The bridge's local config dir (`~/.echomem` or `$ECHO_CONFIG_DIR`) — home for credentials + telemetry. */
21
21
  export function echoConfigDir() {
22
22
  return process.env.ECHO_CONFIG_DIR || path.join(os.homedir(), ".echomem");
@@ -55,29 +55,19 @@ export class KeyStore {
55
55
  getToken() {
56
56
  return process.env.ECHO_API_TOKEN || readFileCreds().token;
57
57
  }
58
- /** Encryption key (base64), or undefined if absent/expired. Env wins (and never expires). */
58
+ /** Encryption key (base64), or undefined if absent. Env wins. */
59
59
  getKey() {
60
60
  if (process.env.ECHO_ENCRYPTION_KEY)
61
61
  return process.env.ECHO_ENCRYPTION_KEY;
62
- const creds = readFileCreds();
63
- if (!creds.key)
64
- return undefined;
65
- if (creds.keyExpiresAt && Date.now() > creds.keyExpiresAt)
66
- return undefined;
67
- return creds.key;
68
- }
69
- /** True when a key is present but past its TTL — used to tell "locked (expired)" from "never set". */
70
- isKeyExpired() {
71
- if (process.env.ECHO_ENCRYPTION_KEY)
72
- return false;
73
- const creds = readFileCreds();
74
- return !!(creds.key && creds.keyExpiresAt && Date.now() > creds.keyExpiresAt);
62
+ return readFileCreds().key;
75
63
  }
76
64
  saveToken(token) {
77
65
  writeFileCreds({ ...readFileCreds(), token });
78
66
  }
79
- saveKey(keyBase64, ttlMs = DEFAULT_KEY_TTL_MS) {
80
- writeFileCreds({ ...readFileCreds(), key: keyBase64, keyExpiresAt: Date.now() + ttlMs });
67
+ saveKey(keyBase64) {
68
+ const creds = readFileCreds();
69
+ delete creds.keyExpiresAt;
70
+ writeFileCreds({ ...creds, key: keyBase64 });
81
71
  }
82
72
  clearKey() {
83
73
  const creds = readFileCreds();
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,
@@ -761,7 +782,7 @@ function authedClient(token) {
761
782
  });
762
783
  }
763
784
  export function discoverMigratableSessions(opts = {}) {
764
- let sessions = discoverSessions();
785
+ let sessions = discoverSessions(opts);
765
786
  const since = opts.since;
766
787
  if (since)
767
788
  sessions = sessions.filter((s) => (s.firstTs || "").slice(0, 10) >= since);
@@ -1076,7 +1097,7 @@ async function runJobs(args) {
1076
1097
  const message = responseMessage(e);
1077
1098
  const durationMs = Date.now() - jobStartedAt;
1078
1099
  if (status === 422 && errCode === "ENCRYPTION_KEY_REQUIRED") {
1079
- 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
1080
1101
  done++;
1081
1102
  recordMetric(buildMigrationMetric({
1082
1103
  runId: args.runId, importSessionId: args.session.id, jobId: job.id, index: done, total,
@@ -1257,8 +1278,8 @@ export async function cmdMigrate(flags) {
1257
1278
  console.log(c.dim(`Import session ${h.sessionId} — ${h.jobCount} jobs queued (the web dashboard can watch this live).`));
1258
1279
  console.log(c.dim(`Metrics: ${h.metricsFile}`));
1259
1280
  const r = await h.done;
1260
- if (r.stoppedReason === "key-expired") {
1261
- 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).`));
1262
1283
  process.exitCode = 1;
1263
1284
  }
1264
1285
  console.log("");
@@ -1275,10 +1296,7 @@ export async function cmdMigrate(flags) {
1275
1296
  if (code === "NOT_LOGGED_IN")
1276
1297
  console.error("Not logged in. Run `echomem-mcp login` first, then re-run migrate.");
1277
1298
  else if (code === "VAULT_LOCKED") {
1278
- const store = new KeyStore();
1279
- console.error(store.isKeyExpired()
1280
- ? "Vault key expired. Run `echomem-mcp unlock`, then re-run migrate."
1281
- : "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.");
1282
1300
  }
1283
1301
  else if (code === "FORBIDDEN_SCOPE") {
1284
1302
  console.error("This device token cannot import history. Re-connect this device with `echomem-mcp login`.");
@@ -214,6 +214,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
214
214
  '<div class="readyGo">' +
215
215
  '<div class="actions">' +
216
216
  '<button id="migrate" class="primary"></button>' +
217
+ '<button type="button" id="skipHistoryImport" class="secondary skipHistoryButton">Skip history import</button>' +
217
218
  '</div>' +
218
219
  '<p class="ctaMeta" id="exEta"></p>' +
219
220
  '</div>' +
@@ -267,13 +268,19 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
267
268
  // While still counting, hide the primary button entirely; the headline + source split
268
269
  // already say it is working, and the ready state should present one clear action.
269
270
  var migrateBtn = document.getElementById("migrate");
271
+ var skipHistoryBtn = document.getElementById("skipHistoryImport");
270
272
  if (pendingTrusted) {
271
273
  migrateBtn.style.display = "";
272
274
  migrateBtn.disabled = canExtract && (!setupPlanConfirmed() || selectedSessionKeyList().length === 0);
273
275
  migrateBtn.innerHTML = '<span>' + esc(canExtract ? extractLabel : "Finish setup") + '</span>' + (canExtract ? setupIcon("arrow-right") : "");
274
276
  migrateBtn.onclick = canExtract ? startMigrate : skip;
277
+ if (skipHistoryBtn) {
278
+ skipHistoryBtn.style.display = canExtract ? "" : "none";
279
+ skipHistoryBtn.onclick = skip;
280
+ }
275
281
  } else {
276
282
  migrateBtn.style.display = "none";
283
+ if (skipHistoryBtn) skipHistoryBtn.style.display = "none";
277
284
  }
278
285
  }
279
286
  async function startMigrate() {
@@ -352,7 +359,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
352
359
  var savedMemoryLabel = extracted === 1 ? "memory saved" : "memories saved";
353
360
  var conversationNoun = completed === 1 ? "conversation" : "conversations";
354
361
  var timelineUrl = "https://yeahecho.com/memories/timeline";
355
- var stopDetail = String(progress.error) === "key-expired"
362
+ var stopDetail = String(progress.error) === "vault-locked"
356
363
  ? "Your encryption key was not accepted. Re-run extraction; if it keeps failing, run <code>echomem-mcp unlock</code> first. Already-done conversations are skipped."
357
364
  : esc(progress.error || "Re-run extraction to finish the rest. Already-done conversations are skipped.");
358
365
  var completionNote = done && progress.latest && progress.latest !== "Import complete."
@@ -626,6 +633,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
626
633
  }
627
634
  function updateSessionPickerSelectionUi(dialog, input, limit) {
628
635
  var selectedCount = selectedSessionKeyList().length;
636
+ hideSessionPickerLimitNotice();
629
637
  var row = input && input.closest ? input.closest(".sessionPickerRow") : null;
630
638
  if (row) row.classList.toggle("is-selected", !!input.checked);
631
639
  var count = dialog.querySelector(".sessionPickerActions strong");
@@ -638,6 +646,48 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
638
646
  confirm.textContent = "Use " + number(selectedCount) + " selected";
639
647
  }
640
648
  }
649
+ function sessionPickerLimitNotice(canDismiss) {
650
+ var plan = String(billingStatus && billingStatus.plan || "").toLowerCase();
651
+ var linkLabel = plan === "power" || plan === "team" || plan === "enterprise"
652
+ ? "View plan options"
653
+ : "Upgrade to import more";
654
+ return '<div class="sessionPickerLimitCard">' +
655
+ '<span class="sessionPickerLimitEyebrow">Plan limit</span>' +
656
+ '<h3>Plan limit reached.</h3>' +
657
+ '<p>Upgrade your plan to select and import more conversations.</p>' +
658
+ '<div class="sessionPickerLimitActions"><a id="upgradeSessionPlan" class="button primary" href="' + esc(setupPricingUrl("pro", false)) + '" target="_blank" rel="noopener noreferrer">' + linkLabel + '</a>' +
659
+ (canDismiss ? '<button type="button" class="textButton" id="dismissSessionLimit">Keep editing</button>' : "") + '</div>' +
660
+ '</div>';
661
+ }
662
+ function wireSessionPickerUpgrade() {
663
+ var upgrade = document.getElementById("upgradeSessionPlan");
664
+ if (upgrade) upgrade.onclick = function () { startBillingPoll(); };
665
+ var dismiss = document.getElementById("dismissSessionLimit");
666
+ if (dismiss) dismiss.onclick = hideSessionPickerLimitNotice;
667
+ }
668
+ function showSessionPickerLimitNotice(canDismiss) {
669
+ var wrap = document.getElementById("sessionPickerListWrap");
670
+ var list = wrap && wrap.querySelector ? wrap.querySelector(".sessionPickerList") : null;
671
+ var overlay = document.getElementById("sessionPickerLimitOverlay");
672
+ var status = document.getElementById("sessionPickerStatus");
673
+ if (status) status.textContent = "Plan limit reached. Upgrade to import more.";
674
+ if (!wrap || !overlay) return;
675
+ overlay.innerHTML = sessionPickerLimitNotice(canDismiss);
676
+ overlay.hidden = false;
677
+ if (list) list.inert = true;
678
+ wrap.classList.add("is-limit-blocked");
679
+ wireSessionPickerUpgrade();
680
+ }
681
+ function hideSessionPickerLimitNotice() {
682
+ var wrap = document.getElementById("sessionPickerListWrap");
683
+ var list = wrap && wrap.querySelector ? wrap.querySelector(".sessionPickerList") : null;
684
+ var overlay = document.getElementById("sessionPickerLimitOverlay");
685
+ var status = document.getElementById("sessionPickerStatus");
686
+ if (status) status.textContent = "";
687
+ if (overlay) { overlay.hidden = true; overlay.innerHTML = ""; }
688
+ if (list) list.inert = false;
689
+ if (wrap) wrap.classList.remove("is-limit-blocked");
690
+ }
641
691
  function renderSessionPicker() {
642
692
  var dialog = document.getElementById("sessionPicker");
643
693
  if (!dialog) return;
@@ -674,14 +724,17 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
674
724
  '<div class="sessionPickerToolbar"><label class="sessionSearch"><span>Search</span><input type="search" id="sessionPickerSearch" placeholder="Title or project" value="' + esc(sessionPickerQuery) + '" /></label>' +
675
725
  '<label class="sessionSourceFilter"><span>Source</span><select id="sessionPickerSource"><option value="all"' + (sessionPickerSource === "all" ? " selected" : "") + '>All sources</option><option value="codex"' + (sessionPickerSource === "codex" ? " selected" : "") + '>Codex</option><option value="claude-code"' + (sessionPickerSource === "claude-code" ? " selected" : "") + '>Claude Code</option></select></label>' +
676
726
  '</div>' +
677
- '<div class="sessionPickerActions"><strong>' + esc(number(selected.length)) + ' / ' + esc(number(limit)) + ' selected</strong><span id="sessionPickerStatus" role="status"></span><button type="button" class="textButton" id="selectNewestSessions">Select newest</button><button type="button" class="textButton" id="clearSessions">Clear</button></div>' +
678
- '<div class="sessionPickerList">' + (rows || '<div class="sessionPickerEmpty">No conversations match these filters.</div>') + '</div>' + overflow +
679
- '<footer class="sessionPickerFoot"><p>Only selected conversations will be sent for extraction.</p><button type="button" class="primary" id="confirmSessions"' + (selected.length ? "" : " disabled") + '>Use ' + esc(number(selected.length)) + ' selected</button></footer>' +
727
+ '<div class="sessionPickerActions"><strong>' + esc(number(selected.length)) + ' / ' + esc(number(limit)) + ' selected</strong><span id="sessionPickerStatus" class="sessionPickerStatus" role="status" aria-live="polite">' + (limit === 0 ? "Plan limit reached. Upgrade to import more." : "") + '</span><button type="button" class="textButton" id="selectNewestSessions">Select newest</button><button type="button" class="textButton" id="clearSessions">Clear</button></div>' +
728
+ '<div class="sessionPickerListWrap' + (limit === 0 ? " is-limit-blocked" : "") + '" id="sessionPickerListWrap"><div class="sessionPickerList"' + (limit === 0 ? " inert" : "") + '>' + (rows || '<div class="sessionPickerEmpty">No conversations match these filters.</div>') + overflow + '</div><div class="sessionPickerLimitOverlay" id="sessionPickerLimitOverlay" role="alert"' + (limit === 0 ? "" : " hidden") + '>' + (limit === 0 ? sessionPickerLimitNotice(false) : "") + '</div></div>' +
729
+ '<footer class="sessionPickerFoot"><p>Only selected conversations will be sent for extraction.</p><div class="sessionPickerFootActions"><button type="button" class="secondary" id="skipSessionImport">Skip history import</button><button type="button" class="primary" id="confirmSessions"' + (selected.length ? "" : " disabled") + '>Use ' + esc(number(selected.length)) + ' selected</button></div></footer>' +
680
730
  '</div>';
681
731
  var close = document.getElementById("closeSessionPicker");
682
732
  var confirm = document.getElementById("confirmSessions");
733
+ var skipImport = document.getElementById("skipSessionImport");
683
734
  if (close) close.onclick = function () { dialog.close(); renderSessionSelection(true); };
684
735
  if (confirm) confirm.onclick = function () { dialog.close(); renderSessionSelection(true); };
736
+ if (skipImport) skipImport.onclick = skip;
737
+ wireSessionPickerUpgrade();
685
738
  var search = document.getElementById("sessionPickerSearch");
686
739
  if (search) search.oninput = function () {
687
740
  sessionPickerQuery = search.value || "";
@@ -697,8 +750,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
697
750
  var count = selectedSessionKeyList().length;
698
751
  if (input.checked && !selectedSessionKeys[key] && count >= limit) {
699
752
  input.checked = false;
700
- var status = document.getElementById("sessionPickerStatus");
701
- if (status) status.textContent = "Plan limit reached";
753
+ showSessionPickerLimitNotice(limit > 0);
702
754
  return;
703
755
  }
704
756
  sessionSelectionTouched = true;
@@ -832,7 +884,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
832
884
  '<div class="setupPlanHead"><strong>Pro</strong><span>$40 / month</span></div>' +
833
885
  '<p>More recall for serious work.</p>' +
834
886
  '<ul><li>' + setupIcon("check") + '<span>250 bulk history imports</span></li><li>' + setupIcon("check") + '<span>200K new-chat tokens each week</span></li><li>' + setupIcon("check") + '<span>100 memory searches each week</span></li><li>' + setupIcon("check") + '<span>No memory-count limit</span></li></ul>' +
835
- '<button type="button" class="primary" id="chooseProPlan">' + (trialAvailable ? "Start 14-day trial" : "Choose Pro") + '</button>' +
887
+ '<button type="button" class="primary" id="chooseProPlan">' + (trialAvailable ? "Start 7-day trial" : "Choose Pro") + '</button>' +
836
888
  '<small>' + (trialAvailable ? "Card required. Cancel anytime." : "Your trial was already used.") + '</small>' +
837
889
  '</article>' +
838
890
  '<article class="setupPlanCard">' +
@@ -840,7 +892,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
840
892
  '<p>Higher limits for people who use Echo every day.</p>' +
841
893
  // Future Power feature: API and automation access.
842
894
  '<ul><li>' + setupIcon("check") + '<span>500 bulk history imports</span></li><li>' + setupIcon("check") + '<span>750K new-chat tokens each week</span></li><li>' + setupIcon("check") + '<span>250 memory searches each week</span></li><li>' + setupIcon("check") + '<span>No memory-count limit</span></li></ul>' +
843
- '<button type="button" class="secondary" id="choosePowerPlan">' + (trialAvailable ? "Start 14-day trial" : "Choose Power") + '</button>' +
895
+ '<button type="button" class="secondary" id="choosePowerPlan">' + (trialAvailable ? "Start 7-day trial" : "Choose Power") + '</button>' +
844
896
  '<small>' + (trialAvailable ? "Card required. Cancel anytime." : "Your trial was already used.") + '</small>' +
845
897
  '</article>' +
846
898
  '</div>' +
@@ -192,12 +192,21 @@ export const SETUP_PAGE_CLIENT_REPORT_CITY = String.raw ` /* ---------- ful
192
192
  app.className = "scanLoading";
193
193
  app.innerHTML =
194
194
  '<div class="scanLoader" aria-live="polite">' +
195
- '<p class="scanEyebrow">Local scan</p>' +
196
- '<h2>Reading your local coding history</h2>' +
197
- '<div class="scanProgress" role="progressbar" aria-label="Local scan progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="' + pct + '">' +
198
- '<i id="scan-load-fill" class="scanProgressFill" style="width:' + pct + '%"></i>' +
195
+ '<div class="scanLoaderLead">' +
196
+ '<span class="scanMascot" aria-hidden="true"><img src="/hud-assets/echo-face-cutout.png" alt="" /></span>' +
197
+ '<div class="scanLoaderCopy">' +
198
+ '<p class="scanEyebrow">Local scan</p>' +
199
+ '<h2 class="siteHeadline">Reading your <strong>local coding history</strong></h2>' +
200
+ '<p class="scanIntro">Echo is organizing your Codex and Claude Code sessions on this Mac.</p>' +
201
+ '</div>' +
202
+ '</div>' +
203
+ '<div class="scanProgressCard">' +
204
+ '<div class="scanProgress" role="progressbar" aria-label="Local scan progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="' + pct + '">' +
205
+ '<i id="scan-load-fill" class="scanProgressFill" style="width:' + pct + '%"></i>' +
206
+ '</div>' +
207
+ '<p id="scan-load-caption" class="scanCaption">' + esc(caption) + '</p>' +
199
208
  '</div>' +
200
- '<p id="scan-load-caption" class="scanCaption">' + esc(caption) + '</p>' +
209
+ '<p class="scanPrivacy"><span aria-hidden="true"></span>Transcripts stay on this device during the scan.</p>' +
201
210
  '</div>';
202
211
  }
203
212
 
@@ -271,6 +271,11 @@ export const SETUP_PAGE_STYLES_EXTRACTION = String.raw ` /* ---- dashboard (p
271
271
  border-radius: 14px;
272
272
  box-shadow: 0 18px 34px -20px rgba(26,58,143,0.62);
273
273
  }
274
+ .readyDecision .actions .skipHistoryButton {
275
+ min-height: 58px;
276
+ padding: 0 22px;
277
+ border-radius: 14px;
278
+ }
274
279
  .readyDecision .actions .primary:not(:disabled):hover { box-shadow: 0 22px 38px -18px rgba(26,58,143,0.66); }
275
280
  .readyDecision .actions .primary .setupGlyph { width: 19px; height: 19px; }
276
281
  /* Proof strip: one quiet line of evidence (source split), not stat cards. Centered. */
@@ -1383,6 +1388,7 @@ export const SETUP_PAGE_STYLES_EXTRACTION = String.raw ` /* ---- dashboard (p
1383
1388
 
1384
1389
  .sessionPicker {
1385
1390
  width: min(860px, calc(100vw - 32px));
1391
+ height: min(780px, calc(100vh - 32px));
1386
1392
  max-height: min(780px, calc(100vh - 32px));
1387
1393
  margin: auto;
1388
1394
  border: 0;
@@ -1393,7 +1399,7 @@ export const SETUP_PAGE_STYLES_EXTRACTION = String.raw ` /* ---- dashboard (p
1393
1399
  box-shadow: 0 34px 90px -30px rgba(10,18,46,0.68);
1394
1400
  }
1395
1401
  .sessionPicker::backdrop { background: rgba(13,21,43,0.62); backdrop-filter: blur(4px); }
1396
- .sessionPickerShell { display: grid; grid-template-rows: auto auto auto minmax(180px, 1fr) auto; max-height: min(780px, calc(100vh - 32px)); background: var(--echo-paper-white); }
1402
+ .sessionPickerShell { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; height: 100%; min-height: 0; max-height: min(780px, calc(100vh - 32px)); overflow: hidden; background: var(--echo-paper-white); }
1397
1403
  .sessionPickerHead {
1398
1404
  display: flex;
1399
1405
  align-items: flex-start;
@@ -1465,9 +1471,60 @@ export const SETUP_PAGE_STYLES_EXTRACTION = String.raw ` /* ---- dashboard (p
1465
1471
  padding: 9px 24px;
1466
1472
  }
1467
1473
  .sessionPickerActions > strong { margin-right: auto; color: var(--echo-ink-primary); font-size: 13px; font-variant-numeric: tabular-nums; }
1468
- .sessionPickerActions [role="status"] { color: #9b6612; font-size: 11px; font-weight: 700; }
1474
+ .sessionPickerStatus { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
1469
1475
  .sessionPickerActions .textButton { min-height: 32px; color: var(--echo-ink-primary); }
1470
- .sessionPickerList { min-height: 0; overflow-y: auto; background: var(--echo-paper-white); padding: 6px 0; }
1476
+ .sessionPickerListWrap { position: relative; min-height: 0; overflow: hidden; background: var(--echo-paper-white); }
1477
+ .sessionPickerList { height: 100%; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; background: var(--echo-paper-white); padding: 6px 0; }
1478
+ .sessionPickerListWrap.is-limit-blocked .sessionPickerList {
1479
+ filter: blur(4px);
1480
+ opacity: 0.36;
1481
+ pointer-events: none;
1482
+ transform: scale(1.012);
1483
+ transition: filter 180ms ease, opacity 180ms ease, transform 180ms ease;
1484
+ user-select: none;
1485
+ }
1486
+ .sessionPickerLimitOverlay {
1487
+ position: absolute;
1488
+ inset: 0;
1489
+ z-index: 3;
1490
+ display: grid;
1491
+ place-items: center;
1492
+ overflow-y: auto;
1493
+ background: rgba(245,247,252,0.38);
1494
+ padding: 20px;
1495
+ backdrop-filter: blur(2px);
1496
+ }
1497
+ .sessionPickerLimitOverlay[hidden] { display: none; }
1498
+ .sessionPickerLimitCard {
1499
+ width: min(470px, 100%);
1500
+ border: 1px solid rgba(26,58,143,0.18);
1501
+ border-radius: 18px;
1502
+ background: rgba(255,255,255,0.92);
1503
+ padding: clamp(24px, 5vw, 36px);
1504
+ text-align: center;
1505
+ box-shadow: 0 24px 56px -28px rgba(20,36,84,0.46), 0 6px 18px rgba(20,24,34,0.10);
1506
+ }
1507
+ .sessionPickerLimitEyebrow {
1508
+ display: inline-block;
1509
+ color: #9b6612;
1510
+ font-family: var(--echo-font-mono);
1511
+ font-size: 10px;
1512
+ font-weight: 900;
1513
+ letter-spacing: 0.1em;
1514
+ text-transform: uppercase;
1515
+ }
1516
+ .sessionPickerLimitCard h3 {
1517
+ margin: 8px 0 0;
1518
+ color: var(--echo-ink-text);
1519
+ font-family: var(--echo-font-brand);
1520
+ font-size: clamp(28px, 5vw, 40px);
1521
+ font-weight: 900;
1522
+ line-height: 1;
1523
+ }
1524
+ .sessionPickerLimitCard p { margin: 13px auto 0; max-width: 360px; color: var(--echo-ink-mute); font-size: 14px; line-height: 1.5; }
1525
+ .sessionPickerLimitActions { display: flex; justify-content: center; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 22px; }
1526
+ .sessionPickerLimitActions .button { min-height: 46px; padding: 0 20px; }
1527
+ .sessionPickerLimitActions .textButton { color: var(--echo-ink-primary); }
1471
1528
  .sessionPickerRow {
1472
1529
  display: grid;
1473
1530
  grid-template-columns: auto auto minmax(0, 1fr) auto;
@@ -1521,6 +1578,7 @@ export const SETUP_PAGE_STYLES_EXTRACTION = String.raw ` /* ---- dashboard (p
1521
1578
  padding: 14px 24px;
1522
1579
  }
1523
1580
  .sessionPickerFoot p { margin: 0; color: var(--echo-ink-mute); font-size: 12px; }
1581
+ .sessionPickerFootActions { display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
1524
1582
  .sessionPickerFoot button { min-width: 170px; }
1525
1583
 
1526
1584
  .pauseStatus {
@@ -1740,6 +1798,10 @@ export const SETUP_PAGE_STYLES_EXTRACTION = String.raw ` /* ---- dashboard (p
1740
1798
  border-radius: 4px;
1741
1799
  box-shadow: none;
1742
1800
  }
1801
+ .readyDecision .actions .skipHistoryButton {
1802
+ min-height: 52px;
1803
+ border-radius: 4px;
1804
+ }
1743
1805
  .readyDecision .actions .primary:not(:disabled):hover { box-shadow: none; }
1744
1806
  .ctaMeta { justify-content: flex-start; }
1745
1807
  .readyFoot {
@@ -1948,7 +2010,8 @@ export const SETUP_PAGE_STYLES_EXTRACTION = String.raw ` /* ---- dashboard (p
1948
2010
  .setupPlanConfirmed { grid-template-columns: auto minmax(0, 1fr); }
1949
2011
  .setupPlanConfirmed .textButton { grid-column: 2; width: fit-content; }
1950
2012
  .readyDecision .actions,
1951
- .readyDecision .actions .primary { width: 100%; }
2013
+ .readyDecision .actions .primary,
2014
+ .readyDecision .actions .skipHistoryButton { width: 100%; }
1952
2015
  .readyDecision .actions .primary { min-width: 0; }
1953
2016
  .readyGo,
1954
2017
  .readyFoot,
@@ -1965,15 +2028,21 @@ export const SETUP_PAGE_STYLES_EXTRACTION = String.raw ` /* ---- dashboard (p
1965
2028
  .recallPromptActions { display: grid; grid-template-columns: 1fr; }
1966
2029
  .recallAgent { width: 100%; }
1967
2030
  .skipStage { width: calc(100vw - 28px); padding: 26px 20px; }
1968
- .sessionPicker { width: 100vw; max-height: 100vh; border-radius: 0; border-left-width: 4px; }
1969
- .sessionPickerShell { max-height: 100vh; min-height: 100vh; }
2031
+ .sessionPicker { width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; border-left-width: 4px; }
2032
+ .sessionPickerShell { height: 100%; max-height: 100vh; min-height: 0; }
1970
2033
  .sessionPickerHead { padding: 18px 16px 15px; }
1971
2034
  .sessionPickerToolbar { grid-template-columns: 1fr; padding: 12px 16px; }
1972
2035
  .sessionPickerActions { flex-wrap: wrap; padding: 8px 16px; }
1973
2036
  .sessionPickerActions > strong { width: 100%; }
1974
2037
  .sessionPickerRow { grid-template-columns: auto auto minmax(0, 1fr); margin: 0 4px; padding: 10px 8px; }
1975
2038
  .sessionPickerMeta { display: none; }
2039
+ .sessionPickerLimitOverlay { padding: 14px; }
2040
+ .sessionPickerLimitCard { border-radius: 15px; padding: 24px 18px; }
2041
+ .sessionPickerLimitActions { display: grid; }
2042
+ .sessionPickerLimitActions .button,
2043
+ .sessionPickerLimitActions button { width: 100%; }
1976
2044
  .sessionPickerFoot { align-items: stretch; flex-direction: column; padding: 12px 16px; }
2045
+ .sessionPickerFootActions { display: grid; }
1977
2046
  .sessionPickerFoot button { width: 100%; }
1978
2047
  }
1979
2048