@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 +12 -5
- package/dist/codex-session-files.js +7 -1
- package/dist/crypto.js +4 -0
- package/dist/encryption.js +14 -2
- package/dist/hud/cli.js +0 -0
- package/dist/index.js +59 -125
- package/dist/keystore.js +9 -19
- package/dist/migrate.js +88 -33
- package/dist/setup-page/client-core.js +194 -27
- package/dist/setup-page/client-extraction.js +129 -39
- package/dist/setup-page/client-lifecycle.js +1 -5
- package/dist/setup-page/client-report-city.js +14 -5
- package/dist/setup-page/styles-extraction.js +200 -6
- package/dist/setup-page/styles-website-alignment.js +183 -0
- package/dist/setup-page.js +1 -0
- package/dist/setup-preview.js +140 -37
- package/dist/setup.js +441 -45
- package/dist/v1-contract.js +1 -11
- package/package.json +6 -2
- package/templates/codex-skills/echomem-forget/SKILL.md +16 -0
- package/templates/codex-skills/echomem-forget/agents/openai.yaml +6 -0
- package/templates/codex-skills/echomem-login/SKILL.md +27 -0
- package/templates/codex-skills/echomem-login/agents/openai.yaml +6 -0
- package/templates/codex-skills/echomem-save/SKILL.md +14 -0
- package/templates/codex-skills/echomem-save/agents/openai.yaml +6 -0
- package/templates/codex-skills/echomem-search/SKILL.md +14 -0
- package/templates/codex-skills/echomem-search/agents/openai.yaml +6 -0
- package/dist/context-analysis/claude-canonical-adapter.js +0 -315
- package/dist/hud/efficiency.js +0 -447
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
|
-
- **
|
|
29
|
-
|
|
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`,
|
|
51
|
-
|
|
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` |
|
|
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 {
|
package/dist/encryption.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { deriveKey, exportKeyToBase64, importKeyFromBase64, decrypt, verifyKey,
|
|
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 ??
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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)} ā
|
|
573
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
//
|
|
812
|
-
|
|
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:
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
|
14
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
80
|
-
|
|
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 {
|
|
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
|
-
|
|
188
|
-
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
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) ===
|
|
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 = "
|
|
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 === "
|
|
1261
|
-
console.error(c.yellow(`\nā Vault locked mid-run.
|
|
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
|
-
|
|
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`.");
|