@echomem/mcp 1.4.19 → 1.4.21

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/crypto.js CHANGED
@@ -95,6 +95,10 @@ export async function decrypt(ciphertext, key) {
95
95
  const plainBuffer = await subtle.decrypt({ name: ALGORITHM, iv }, key, data);
96
96
  return new TextDecoder().decode(plainBuffer);
97
97
  }
98
+ /** Create a server-verifiable token for a newly configured encryption key. */
99
+ export async function createVerification(key) {
100
+ return encrypt(VERIFICATION_PLAINTEXT, key);
101
+ }
98
102
  /** Verify a key against a stored verification token (current + legacy plaintexts). */
99
103
  export async function verifyKey(key, token) {
100
104
  try {
@@ -1,4 +1,5 @@
1
- import { deriveKey, exportKeyToBase64, importKeyFromBase64, decrypt, verifyKey, saltFromBase64 } from "./crypto.js";
1
+ import { createVerification, deriveKey, exportKeyToBase64, generateSalt, importKeyFromBase64, decrypt, saltFromBase64, saltToBase64, verifyKey, } from "./crypto.js";
2
+ const DEFAULT_KEY_ITERATIONS = 600_000;
2
3
  /** GET the account's encryption status from the EchoMem backend using the bridge's authed client. */
3
4
  export async function fetchEncryptionConfig(axios) {
4
5
  // Bounded: this is on the startup/tool-listing path; an unbounded call on a flaky network would hang
@@ -21,11 +22,22 @@ export async function deriveAndVerifyKey(passphrase, config) {
21
22
  if (!config.enabled || !config.salt || !config.verification)
22
23
  return null;
23
24
  const salt = saltFromBase64(config.salt);
24
- const key = await deriveKey(passphrase, salt, config.iterations ?? 600_000);
25
+ const key = await deriveKey(passphrase, salt, config.iterations ?? DEFAULT_KEY_ITERATIONS);
25
26
  if (!(await verifyKey(key, config.verification)))
26
27
  return null;
27
28
  return exportKeyToBase64(key);
28
29
  }
30
+ /** Create a fresh account encryption config locally. The passphrase/key never leaves the device. */
31
+ export async function setupNewEncryptionKey(passphrase, iterations = DEFAULT_KEY_ITERATIONS) {
32
+ const salt = generateSalt();
33
+ const key = await deriveKey(passphrase, salt, iterations);
34
+ return {
35
+ keyBase64: await exportKeyToBase64(key),
36
+ saltBase64: saltToBase64(salt),
37
+ verification: await createVerification(key),
38
+ iterations,
39
+ };
40
+ }
29
41
  /** Verify an already-exported base64 key against the server's verification token. */
30
42
  export async function verifyKeyB64(keyBase64, config) {
31
43
  if (!config.enabled || !config.verification)
package/dist/hud/cli.js CHANGED
File without changes
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { EventLogger, hashText } from "./events.js";
10
10
  import { buildReportText } from "./report.js";
11
11
  import { contextHealthMarkdown, recomposeCapsuleMarkdown } from "./hud/api.js";
12
12
  import { createHash, randomUUID } from "node:crypto";
13
- import { fetchEncryptionConfig, decryptMemoryFields } from "./encryption.js";
13
+ import { fetchEncryptionConfig, decryptMemoryFields, verifyKeyB64, } from "./encryption.js";
14
14
  import { runCli } from "./setup.js";
15
15
  import { MCP_PACKAGE_VERSION, MCP_SERVER_INSTRUCTIONS } from "./package-metadata.js";
16
16
  import { clearBillingAlert, writeBillingAlert } from "./billing-alert.js";
@@ -22,11 +22,9 @@ 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
- }
25
+ }
26
+ /** Thrown when the bridge cannot prove whether the account is encrypted. */
27
+ class EncryptionStatusUnavailableError extends Error {
30
28
  }
31
29
  function stringifyErrorValue(value) {
32
30
  if (value == null) {
@@ -135,6 +133,15 @@ function formatUpgradeRequiredResult(error) {
135
133
  code ? `Reason: ${code}.` : "",
136
134
  ].filter(Boolean).join("\n");
137
135
  }
136
+ function formatReconnectRequiredResult(error) {
137
+ if (!axios.isAxiosError(error) || error.response?.status !== 401)
138
+ return null;
139
+ return [
140
+ "šŸ”Œ EchoMem's saved login is no longer accepted (Unauthorized, HTTP 401).",
141
+ "Action required from the user: run `echomem-mcp login` in a terminal to reconnect this device, then retry this action.",
142
+ "No editor restart is needed. Keep all credentials out of chat.",
143
+ ].join("\n");
144
+ }
138
145
  /** Map a thrown error to the telemetry error_kind taxonomy. */
139
146
  function classifyError(error) {
140
147
  if (error instanceof NoTokenError)
@@ -569,8 +576,8 @@ class EchoMemApiClient {
569
576
  if (!this.encConfigPromise) {
570
577
  this.encConfigPromise = fetchEncryptionConfig(this.axios).catch((error) => {
571
578
  this.encConfigPromise = null; // allow retry next call
572
- console.error(`[encryption] config fetch failed: ${describeError(error)} — proceeding as unencrypted`);
573
- return { enabled: false };
579
+ console.error(`[encryption] config fetch failed: ${describeError(error)} — refusing memory access`);
580
+ throw new EncryptionStatusUnavailableError("EchoMem could not verify the account encryption state");
574
581
  });
575
582
  }
576
583
  return this.encConfigPromise;
@@ -581,18 +588,20 @@ class EchoMemApiClient {
581
588
  * handing the model ciphertext. For unencrypted accounts it returns `{ enabled: false }`.
582
589
  */
583
590
  async encState() {
584
- // A stored key ONLY exists because the user unlocked an ENCRYPTED vault, so treat it as
585
- // authoritative: a flaky/failed `/account/encryption` fetch (which defaults to enabled:false) must
586
- // NOT downgrade an unlocked encrypted account to plaintext — that causes a 422 on save and leaks
587
- // ciphertext on read. So if we hold a usable key, we're encrypted-and-unlocked, period.
588
- const key = this.store.getKey();
589
- if (key)
590
- return { enabled: true, key };
591
- // No usable key: consult the server to tell "unencrypted" apart from "encrypted but locked/expired".
592
591
  const cfg = await this.getEncryptionConfig();
593
- if (cfg.enabled)
594
- throw new LockedError(this.store.isKeyExpired());
595
- return { enabled: false };
592
+ if (!cfg.enabled)
593
+ return { enabled: false };
594
+ const key = this.store.getKey();
595
+ if (!key)
596
+ throw new LockedError("EchoMem vault locked");
597
+ if (!(await verifyKeyB64(key, cfg))) {
598
+ // A file-backed stale key can be removed safely; environment-provided
599
+ // keys remain under the caller's control and are ignored until corrected.
600
+ if (!process.env.ECHO_ENCRYPTION_KEY)
601
+ this.store.clearKey();
602
+ throw new LockedError("EchoMem vault key is invalid");
603
+ }
604
+ return { enabled: true, key };
596
605
  }
597
606
  pruneDeleteConfirmations(now = Date.now()) {
598
607
  for (const [memoryId, confirmation] of this.deleteConfirmations) {
@@ -613,13 +622,6 @@ class EchoMemApiClient {
613
622
  }
614
623
  return this.whoamiCache;
615
624
  }
616
- cleanMemoryDescription(desc) {
617
- if (!desc)
618
- return "";
619
- // The tuned snapshot wraps text as "Here is a memory: …Content: <text>".
620
- const idx = desc.indexOf("Content:");
621
- return (idx >= 0 ? desc.slice(idx + "Content:".length) : desc).trim();
622
- }
623
625
  async fetchMemoryById(id, enc) {
624
626
  try {
625
627
  const response = await this.axios.get(`/api/extension/memories/${encodeURIComponent(id)}`, {
@@ -635,34 +637,6 @@ class EchoMemApiClient {
635
637
  throw error;
636
638
  }
637
639
  }
638
- async searchMemoriesRaw(query, opts, enc, retrievalOnly = false, trace) {
639
- const endpoint = "/api/extension/memories/search";
640
- let response;
641
- try {
642
- response = await this.axios.post(endpoint, {
643
- query,
644
- k: opts.limit,
645
- similarityThreshold: opts.threshold,
646
- timeFrameDays: opts.timeFrameDays,
647
- requestId: opts.requestId,
648
- sessionId: this.sessionId,
649
- });
650
- trace?.({ requestId: opts.requestId, endpoint, httpStatus: response.status });
651
- }
652
- catch (error) {
653
- trace?.({
654
- requestId: opts.requestId,
655
- endpoint,
656
- httpStatus: axios.isAxiosError(error) ? error.response?.status : undefined,
657
- errorCode: axios.isAxiosError(error) ? errorCodeFrom(error.response?.data) : undefined,
658
- });
659
- throw error;
660
- }
661
- const data = enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
662
- return retrievalOnly && isRecord(data)
663
- ? { ...data, retrievalOnly: true, tuned: false }
664
- : data;
665
- }
666
640
  async searchMemoriesRetrieved(query, opts, enc, trace) {
667
641
  const endpoint = "/api/extension/memories/deep-search/candidates";
668
642
  let response;
@@ -737,48 +711,6 @@ class EchoMemApiClient {
737
711
  memories,
738
712
  };
739
713
  }
740
- // Tuned recall. Routes through echo-mem-chrome's AUTHENTICATED deep-search proxy
741
- // (the same route the Chrome extension uses): it verifies our ec_ token, derives
742
- // user_id server-side, and forwards to dg-web's two-phase retriever. The bridge
743
- // never hits dg-web directly, so no new auth is needed and no other caller is
744
- // disrupted. NOTE: encrypted users still get server-side plaintext only; the
745
- // phase-1 + bridge-local-decrypt + phase-2 flow (spec §11) is the remaining work.
746
- async searchMemoriesTuned(query, requestId, trace) {
747
- const endpoint = "/api/extension/memories/deep-search";
748
- let response;
749
- try {
750
- response = await this.axios.post(endpoint, {
751
- query,
752
- requestId,
753
- logicalRequestId: requestId,
754
- sessionId: this.sessionId,
755
- userTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
756
- });
757
- trace?.({ requestId, endpoint, httpStatus: response.status });
758
- }
759
- catch (error) {
760
- trace?.({
761
- requestId,
762
- endpoint,
763
- httpStatus: axios.isAxiosError(error) ? error.response?.status : undefined,
764
- errorCode: axios.isAxiosError(error) ? errorCodeFrom(error.response?.data) : undefined,
765
- });
766
- throw error;
767
- }
768
- const data = response.data ?? {};
769
- if (data.success === false) {
770
- trace?.({ requestId, endpoint, httpStatus: response.status, errorCode: errorCodeFrom(data) });
771
- throw new Error(`deep-search proxy error: ${data.error || "unknown"}`);
772
- }
773
- const primary = (data.retrievedMemorySnapshot?.primary ?? []);
774
- const memories = primary.map((m) => ({
775
- key: String(m.key ?? ""),
776
- description: this.cleanMemoryDescription(String(m.description ?? "")),
777
- time: m.time,
778
- similarity: typeof m.similarity_score === "number" ? m.similarity_score : undefined,
779
- }));
780
- return { success: true, tuned: true, answer: String(data.answer || data.response || "").trim(), memories };
781
- }
782
714
  async searchMemories(args, trace) {
783
715
  const parsed = searchMemoriesSchema.parse(args ?? {});
784
716
  const query = parsed.query?.trim();
@@ -805,18 +737,11 @@ class EchoMemApiClient {
805
737
  timeFrameDays: parsed.timeFrameDays,
806
738
  requestId: randomUUID(),
807
739
  };
808
- if (!parsed.includeAnswer) {
809
- return this.searchMemoriesRetrieved(query, searchOpts, enc, trace);
810
- }
811
- // Encrypted account: the server can't synthesize over plaintext it doesn't hold, so use the raw
812
- // retriever (returns ciphertext) and decrypt locally — zero-knowledge preserved end-to-end.
813
- if (enc.enabled) {
814
- return await this.searchMemoriesRaw(query, searchOpts, enc, false, trace);
815
- }
816
- // A synthesized answer is metered through the legacy route. Do not fall
817
- // back to raw retrieval after a ledger outage: that would return a result
818
- // without a durable usage fact.
819
- return this.searchMemoriesTuned(query, searchOpts.requestId, trace);
740
+ // MCP recall is retrieval-only. `includeAnswer` remains accepted by the
741
+ // parser for older clients, but it must never select an EchoMem/dg-web LLM
742
+ // path. The MCP host model receives the ranked memories and writes the
743
+ // final answer itself.
744
+ return this.searchMemoriesRetrieved(query, searchOpts, enc, trace);
820
745
  }
821
746
  /** Decrypt the model-visible fields on a `{ memories: [...] }` response locally. */
822
747
  async decryptResult(data, key) {
@@ -1221,19 +1146,42 @@ class EchoMemMCPServer {
1221
1146
  content: [
1222
1147
  {
1223
1148
  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.",
1149
+ text: [
1150
+ "šŸ”’ EchoMem vault is locked.",
1151
+ "This encrypted account has no usable local decryption key. Once unlocked, this trusted device stays unlocked until you explicitly lock it or log out.",
1152
+ "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.",
1153
+ "At `Vault passphrase (typing is hidden):`, type the passphrase and press Return. No characters will appear while you type; that is expected.",
1154
+ "After the success message, retry this EchoMem action in the current session — no editor restart is needed.",
1155
+ ].join("\n"),
1227
1156
  },
1228
1157
  ],
1229
1158
  };
1230
1159
  }
1160
+ if (error instanceof EncryptionStatusUnavailableError) {
1161
+ return {
1162
+ content: [{
1163
+ type: "text",
1164
+ text: [
1165
+ "āš ļø EchoMem could not verify whether this account's vault is encrypted.",
1166
+ "No memory data was returned. Retry this action after the service recovers.",
1167
+ ].join("\n"),
1168
+ }],
1169
+ isError: true,
1170
+ };
1171
+ }
1231
1172
  if (error instanceof ZodError) {
1232
1173
  throw new McpError(ErrorCode.InvalidParams, `Invalid arguments: ${error.message}`);
1233
1174
  }
1234
1175
  if (error instanceof McpError) {
1235
1176
  throw error;
1236
1177
  }
1178
+ const reconnectRequired = formatReconnectRequiredResult(error);
1179
+ if (reconnectRequired) {
1180
+ return {
1181
+ content: [{ type: "text", text: reconnectRequired }],
1182
+ isError: true,
1183
+ };
1184
+ }
1237
1185
  const upgradeRequired = formatUpgradeRequiredResult(error);
1238
1186
  if (upgradeRequired) {
1239
1187
  return {
@@ -1346,20 +1294,6 @@ class EchoMemMCPServer {
1346
1294
  .join("\n\n");
1347
1295
  return { content: [{ type: "text", text: `Retrieved ${memories.length} memories:\n\n${formattedResults}` }] };
1348
1296
  }
1349
- // Tuned two-phase path: synthesized brief + ranked source memories.
1350
- if (result?.tuned) {
1351
- const { answer, memories } = result;
1352
- if (!answer && !memories?.length) {
1353
- return { content: [{ type: "text", text: "No relevant memories found." }] };
1354
- }
1355
- const sources = (memories || [])
1356
- .map((m, idx) => `[${idx + 1}] ${m.key}${typeof m.similarity === "number" ? ` (sim ${m.similarity.toFixed(3)})` : ""}\n${m.description}`)
1357
- .join("\n\n");
1358
- const text = [answer && `Recall:\n${answer}`, sources && `---\nSource memories:\n\n${sources}`]
1359
- .filter(Boolean)
1360
- .join("\n\n");
1361
- return { content: [{ type: "text", text }] };
1362
- }
1363
1297
  // Fallback: untuned / time-range shape.
1364
1298
  const { success, memories, error } = result;
1365
1299
  if (!success)
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,
@@ -504,6 +525,37 @@ export async function cancelQueuedImportSession(client, sessionId) {
504
525
  throw new Error("IMPORT_SESSION_ID_REQUIRED");
505
526
  await client.patch(`/api/extension/import-sessions/${encodeURIComponent(sessionId)}`, { action: "cancel" });
506
527
  }
528
+ async function findRecoverableImportSession(client, sessions, bareConversationId, signal) {
529
+ const items = sessions.map((session) => ({
530
+ conversationId: bareConversationId(session),
531
+ platform: session.source,
532
+ sourceDate: session.firstTs,
533
+ }));
534
+ const response = await client.post("/api/extension/import-sessions/status", { items }, signal ? { signal } : undefined);
535
+ const data = response.data && typeof response.data === "object"
536
+ ? response.data
537
+ : {};
538
+ const required = new Set(sessions.map((session) => mapKey(session.source, bareConversationId(session))));
539
+ for (const candidate of data.recoverableSessions ?? []) {
540
+ const id = typeof candidate.id === "string" ? candidate.id : "";
541
+ const jobs = Array.isArray(candidate.jobs)
542
+ ? candidate.jobs
543
+ .filter((job) => typeof job.id === "string"
544
+ && typeof job.platform === "string"
545
+ && typeof job.conversation_id === "string")
546
+ .map((job) => ({
547
+ id: job.id,
548
+ platform: job.platform,
549
+ conversation_id: job.conversation_id,
550
+ }))
551
+ : [];
552
+ const covered = new Set(jobs.map((job) => mapKey(job.platform, job.conversation_id)));
553
+ if (id && required.size === jobs.length && [...required].every((key) => covered.has(key))) {
554
+ return { id, concurrency: MIGRATE_CONCURRENCY, jobs };
555
+ }
556
+ }
557
+ return null;
558
+ }
507
559
  function isRecord(value) {
508
560
  return typeof value === "object" && value !== null && !Array.isArray(value);
509
561
  }
@@ -761,7 +813,7 @@ function authedClient(token) {
761
813
  });
762
814
  }
763
815
  export function discoverMigratableSessions(opts = {}) {
764
- let sessions = discoverSessions();
816
+ let sessions = discoverSessions(opts);
765
817
  const since = opts.since;
766
818
  if (since)
767
819
  sessions = sessions.filter((s) => (s.firstTs || "").slice(0, 10) >= since);
@@ -963,7 +1015,13 @@ export async function startMigration(opts) {
963
1015
  throw codedError("FORBIDDEN_SCOPE");
964
1016
  const data = responseData(e);
965
1017
  const max = Number(data.maxConversations);
966
- if (responseStatus(e) === 422 && data.error === "IMPORT_LIMIT_EXCEEDED" && max > 0) {
1018
+ if (responseStatus(e) === 409 && data.error === "IMPORT_ALREADY_IN_PROGRESS") {
1019
+ const recovered = await findRecoverableImportSession(client, toImport, bareId, opts.signal);
1020
+ if (!recovered)
1021
+ throw e;
1022
+ session = recovered;
1023
+ }
1024
+ else if (responseStatus(e) === 422 && data.error === "IMPORT_LIMIT_EXCEEDED" && max > 0) {
967
1025
  capped = max;
968
1026
  toImport = opts.pending.slice(0, max);
969
1027
  try {
@@ -1076,7 +1134,7 @@ async function runJobs(args) {
1076
1134
  const message = responseMessage(e);
1077
1135
  const durationMs = Date.now() - jobStartedAt;
1078
1136
  if (status === 422 && errCode === "ENCRYPTION_KEY_REQUIRED") {
1079
- stoppedReason = "key-expired"; // the vault key expired mid-run — signal workers to stop pulling
1137
+ stoppedReason = "vault-locked"; // no usable local vault key — signal workers to stop pulling
1080
1138
  done++;
1081
1139
  recordMetric(buildMigrationMetric({
1082
1140
  runId: args.runId, importSessionId: args.session.id, jobId: job.id, index: done, total,
@@ -1257,8 +1315,8 @@ export async function cmdMigrate(flags) {
1257
1315
  console.log(c.dim(`Import session ${h.sessionId} — ${h.jobCount} jobs queued (the web dashboard can watch this live).`));
1258
1316
  console.log(c.dim(`Metrics: ${h.metricsFile}`));
1259
1317
  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).`));
1318
+ if (r.stoppedReason === "vault-locked") {
1319
+ 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
1320
  process.exitCode = 1;
1263
1321
  }
1264
1322
  console.log("");
@@ -1275,10 +1333,7 @@ export async function cmdMigrate(flags) {
1275
1333
  if (code === "NOT_LOGGED_IN")
1276
1334
  console.error("Not logged in. Run `echomem-mcp login` first, then re-run migrate.");
1277
1335
  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.");
1336
+ console.error("This account is encrypted but the local vault is locked. Open Terminal, run `echomem-mcp unlock`, then re-run migrate.");
1282
1337
  }
1283
1338
  else if (code === "FORBIDDEN_SCOPE") {
1284
1339
  console.error("This device token cannot import history. Re-connect this device with `echomem-mcp login`.");