@echomem/mcp 1.1.0 → 1.3.0
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 +7 -5
- package/dist/index.js +176 -17
- package/dist/migrate.js +438 -0
- package/dist/report.js +2 -2
- package/dist/setup.js +5 -0
- package/dist/v1-contract.js +12 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,11 +6,11 @@ This package implements a stateless, cloud-first [Model Context Protocol (MCP)](
|
|
|
6
6
|
|
|
7
7
|
This MCP Server bridges local tools and your EchoMem Cloud API entirely via authenticated REST `fetch()` calls.
|
|
8
8
|
|
|
9
|
-
- `search_memories`:
|
|
9
|
+
- `search_memories`: Calls the deep-search candidate phase, resolves the retrieved memory rows, and returns those memories only by default
|
|
10
10
|
- `save_conversation`: Connects to `POST /api/extension/memories/ingest`
|
|
11
11
|
- `get_memories_by_time_range`: Connects to `POST /api/extension/memories/time-range`
|
|
12
12
|
- `search_memories_by_keywords`: Connects to `POST /api/extension/memories/keywords`
|
|
13
|
-
- `search_others_memories`: Connects to MemoryFeed search
|
|
13
|
+
- `search_others_memories`: Connects to MemoryFeed public search without sending the authenticated Echo user id
|
|
14
14
|
|
|
15
15
|
No direct access to the `IndexedDB` or local files is required.
|
|
16
16
|
|
|
@@ -27,7 +27,9 @@ For **zero-knowledge accounts**, the key never leaves your machine. The bridge h
|
|
|
27
27
|
- **locked state:** if the key is absent or past its TTL (7 days, matching the extension), tools
|
|
28
28
|
return a `🔒 locked` nudge to run `echomem-mcp unlock` — **ciphertext is never handed to the model**.
|
|
29
29
|
|
|
30
|
-
Unencrypted accounts are unaffected
|
|
30
|
+
Unencrypted accounts are unaffected. `search_memories` returns retrieved memories by default so
|
|
31
|
+
your MCP client does the final answer generation; pass `includeAnswer: true` only if you need the
|
|
32
|
+
legacy EchoMem-synthesized recall answer.
|
|
31
33
|
|
|
32
34
|
## Quick start (recommended)
|
|
33
35
|
|
|
@@ -142,11 +144,11 @@ ECHO_API_TOKEN="your_token" ECHO_API_BASE_URL="http://localhost:3000" npm run st
|
|
|
142
144
|
|
|
143
145
|
## Available Tools
|
|
144
146
|
|
|
145
|
-
* **`search_memories`**:
|
|
147
|
+
* **`search_memories`**: Retrieve ranked personal memories for a query. Defaults to memory-only output and skips EchoMem answer generation; set `includeAnswer: true` for the legacy synthesized recall.
|
|
146
148
|
* **`save_conversation`**: Ingest and structure a conversation directly into your EchoMem timeline.
|
|
147
149
|
* **`get_memories_by_time_range`**: Retrieve memories between explicit start/end timestamps.
|
|
148
150
|
* **`search_memories_by_keywords`**: Retrieve memories by matching the `keys` field.
|
|
149
|
-
* **`search_others_memories`**: Search other users' public memories through MemoryFeed.
|
|
151
|
+
* **`search_others_memories`**: Search other users' public memories through MemoryFeed public search.
|
|
150
152
|
|
|
151
153
|
Legacy aliases are preserved for compatibility:
|
|
152
154
|
|
package/dist/index.js
CHANGED
|
@@ -88,6 +88,46 @@ function classifyError(error) {
|
|
|
88
88
|
return "invalid_args";
|
|
89
89
|
return "api_error";
|
|
90
90
|
}
|
|
91
|
+
function isRecord(value) {
|
|
92
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
93
|
+
}
|
|
94
|
+
function readString(record, key) {
|
|
95
|
+
const value = record[key];
|
|
96
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
97
|
+
}
|
|
98
|
+
function readNumber(record, key) {
|
|
99
|
+
const value = record[key];
|
|
100
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
101
|
+
}
|
|
102
|
+
function normalizeRetrievalCandidate(value, fallbackRank) {
|
|
103
|
+
if (!isRecord(value))
|
|
104
|
+
return null;
|
|
105
|
+
const id = readString(value, "memory_id") ?? readString(value, "id");
|
|
106
|
+
if (!id)
|
|
107
|
+
return null;
|
|
108
|
+
return {
|
|
109
|
+
id,
|
|
110
|
+
bucket: readString(value, "bucket") ?? "primary",
|
|
111
|
+
rank: readNumber(value, "rank") ?? fallbackRank,
|
|
112
|
+
score: readNumber(value, "retrieval_similarity_score")
|
|
113
|
+
?? readNumber(value, "similarity_score")
|
|
114
|
+
?? readNumber(value, "similarity"),
|
|
115
|
+
key: readString(value, "key") ?? readString(value, "keys"),
|
|
116
|
+
time: readString(value, "time"),
|
|
117
|
+
isPublic: typeof value.is_public === "boolean" ? value.is_public : undefined,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function withinTimeFrame(memory, candidate, timeFrameDays) {
|
|
121
|
+
if (!timeFrameDays)
|
|
122
|
+
return true;
|
|
123
|
+
const timestamp = readString(memory, "time") ?? readString(memory, "created_at") ?? candidate.time;
|
|
124
|
+
if (!timestamp)
|
|
125
|
+
return true;
|
|
126
|
+
const ms = Date.parse(timestamp);
|
|
127
|
+
if (!Number.isFinite(ms))
|
|
128
|
+
return true;
|
|
129
|
+
return ms >= Date.now() - timeFrameDays * 24 * 60 * 60 * 1000;
|
|
130
|
+
}
|
|
91
131
|
class EchoMemApiClient {
|
|
92
132
|
store;
|
|
93
133
|
axios;
|
|
@@ -190,6 +230,89 @@ class EchoMemApiClient {
|
|
|
190
230
|
const idx = desc.indexOf("Content:");
|
|
191
231
|
return (idx >= 0 ? desc.slice(idx + "Content:".length) : desc).trim();
|
|
192
232
|
}
|
|
233
|
+
async fetchMemoryById(id, enc) {
|
|
234
|
+
try {
|
|
235
|
+
const response = await this.axios.get(`/api/extension/memories/${encodeURIComponent(id)}`, {
|
|
236
|
+
timeout: 10_000,
|
|
237
|
+
});
|
|
238
|
+
const row = isRecord(response.data) ? response.data : {};
|
|
239
|
+
return enc.enabled ? await decryptMemoryFields(row, enc.key) : row;
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async searchMemoriesRaw(query, opts, enc, retrievalOnly = false) {
|
|
249
|
+
const response = await this.axios.post("/api/extension/memories/search", {
|
|
250
|
+
query,
|
|
251
|
+
k: opts.limit,
|
|
252
|
+
similarityThreshold: opts.threshold,
|
|
253
|
+
timeFrameDays: opts.timeFrameDays,
|
|
254
|
+
});
|
|
255
|
+
const data = enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
|
|
256
|
+
return retrievalOnly && isRecord(data)
|
|
257
|
+
? { ...data, retrievalOnly: true, tuned: false }
|
|
258
|
+
: data;
|
|
259
|
+
}
|
|
260
|
+
async searchMemoriesRetrieved(query, opts, enc) {
|
|
261
|
+
const response = await this.axios.post("/api/extension/memories/deep-search/candidates", {
|
|
262
|
+
query,
|
|
263
|
+
userTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
264
|
+
});
|
|
265
|
+
const data = response.data ?? {};
|
|
266
|
+
if (data.success === false) {
|
|
267
|
+
throw new Error(`deep-search candidates proxy error: ${data.error || "unknown"}`);
|
|
268
|
+
}
|
|
269
|
+
const rawCandidates = Array.isArray(data.personalCandidates) ? data.personalCandidates : [];
|
|
270
|
+
const candidates = rawCandidates
|
|
271
|
+
.map((candidate, index) => normalizeRetrievalCandidate(candidate, index))
|
|
272
|
+
.filter((candidate) => candidate !== null);
|
|
273
|
+
const primaryIds = new Set(candidates.filter((c) => c.bucket === "primary").map((c) => c.id));
|
|
274
|
+
const seen = new Set();
|
|
275
|
+
const dedupedCandidates = candidates.filter((candidate) => {
|
|
276
|
+
if (candidate.bucket === "inference" && primaryIds.has(candidate.id))
|
|
277
|
+
return false;
|
|
278
|
+
if (candidate.score !== undefined && candidate.score < opts.threshold)
|
|
279
|
+
return false;
|
|
280
|
+
if (seen.has(candidate.id))
|
|
281
|
+
return false;
|
|
282
|
+
seen.add(candidate.id);
|
|
283
|
+
return true;
|
|
284
|
+
});
|
|
285
|
+
const memories = [];
|
|
286
|
+
for (const candidate of dedupedCandidates) {
|
|
287
|
+
const row = await this.fetchMemoryById(candidate.id, enc);
|
|
288
|
+
if (!row || !withinTimeFrame(row, candidate, opts.timeFrameDays))
|
|
289
|
+
continue;
|
|
290
|
+
const key = readString(row, "key") ?? readString(row, "keys") ?? candidate.key ?? "";
|
|
291
|
+
const memory = {
|
|
292
|
+
...row,
|
|
293
|
+
id: readString(row, "id") ?? candidate.id,
|
|
294
|
+
key,
|
|
295
|
+
keys: readString(row, "keys") ?? key,
|
|
296
|
+
retrieval_bucket: candidate.bucket,
|
|
297
|
+
retrieval_rank: candidate.rank,
|
|
298
|
+
is_public: typeof row.is_public === "boolean" ? row.is_public : candidate.isPublic,
|
|
299
|
+
};
|
|
300
|
+
if (candidate.score !== undefined) {
|
|
301
|
+
memory.similarity = candidate.score;
|
|
302
|
+
memory.similarity_score = candidate.score;
|
|
303
|
+
memory.retrieval_similarity_score = candidate.score;
|
|
304
|
+
}
|
|
305
|
+
memories.push(memory);
|
|
306
|
+
if (memories.length >= opts.limit)
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
success: true,
|
|
311
|
+
tuned: true,
|
|
312
|
+
retrievalOnly: true,
|
|
313
|
+
memories,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
193
316
|
// Tuned recall. Routes through echo-mem-chrome's AUTHENTICATED deep-search proxy
|
|
194
317
|
// (the same route the Chrome extension uses): it verifies our ec_ token, derives
|
|
195
318
|
// user_id server-side, and forwards to dg-web's two-phase retriever. The bridge
|
|
@@ -233,29 +356,26 @@ class EchoMemApiClient {
|
|
|
233
356
|
});
|
|
234
357
|
return enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
|
|
235
358
|
}
|
|
359
|
+
const searchOpts = { limit, threshold, timeFrameDays: parsed.timeFrameDays };
|
|
360
|
+
if (!parsed.includeAnswer) {
|
|
361
|
+
try {
|
|
362
|
+
return await this.searchMemoriesRetrieved(query, searchOpts, enc);
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return await this.searchMemoriesRaw(query, searchOpts, enc, true);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
236
368
|
// Encrypted account: the server can't synthesize over plaintext it doesn't hold, so use the raw
|
|
237
369
|
// retriever (returns ciphertext) and decrypt locally — zero-knowledge preserved end-to-end.
|
|
238
370
|
if (enc.enabled) {
|
|
239
|
-
|
|
240
|
-
query,
|
|
241
|
-
k: limit,
|
|
242
|
-
similarityThreshold: threshold,
|
|
243
|
-
timeFrameDays: parsed.timeFrameDays,
|
|
244
|
-
});
|
|
245
|
-
return await this.decryptResult(response.data, enc.key);
|
|
371
|
+
return await this.searchMemoriesRaw(query, searchOpts, enc);
|
|
246
372
|
}
|
|
247
373
|
// Unencrypted: prefer the tuned two-phase retriever; degrade gracefully to the untuned path.
|
|
248
374
|
try {
|
|
249
375
|
return await this.searchMemoriesTuned(query);
|
|
250
376
|
}
|
|
251
377
|
catch {
|
|
252
|
-
|
|
253
|
-
query,
|
|
254
|
-
k: limit,
|
|
255
|
-
similarityThreshold: threshold,
|
|
256
|
-
timeFrameDays: parsed.timeFrameDays,
|
|
257
|
-
});
|
|
258
|
-
return response.data;
|
|
378
|
+
return await this.searchMemoriesRaw(query, searchOpts, enc);
|
|
259
379
|
}
|
|
260
380
|
}
|
|
261
381
|
/** Decrypt the model-visible fields on a `{ memories: [...] }` response locally. */
|
|
@@ -313,9 +433,7 @@ class EchoMemApiClient {
|
|
|
313
433
|
async searchOthersMemories(args) {
|
|
314
434
|
const parsed = othersSchema.parse(args);
|
|
315
435
|
try {
|
|
316
|
-
const
|
|
317
|
-
const response = await axios.post(`${MEMORY_FEED_API_URL.replace(/\/$/, "")}/api/search`, {
|
|
318
|
-
userId: whoami.user_id,
|
|
436
|
+
const response = await axios.post(`${MEMORY_FEED_API_URL.replace(/\/$/, "")}/api/search/public`, {
|
|
319
437
|
query: parsed.query,
|
|
320
438
|
excludedMemoryIds: [],
|
|
321
439
|
}, {
|
|
@@ -459,6 +577,47 @@ class EchoMemMCPServer {
|
|
|
459
577
|
rec.results_chars = mems.reduce((n, m) => n + String(m?.description ?? "").length, 0);
|
|
460
578
|
rec.memory_keys = mems.map((m) => String(m?.key ?? m?.keys ?? "").trim()).filter(Boolean).slice(0, 40);
|
|
461
579
|
}
|
|
580
|
+
// Default MCP recall path: return only retrieved memories. The calling agent does the answer
|
|
581
|
+
// generation, so we avoid spending an extra EchoMem model call and avoid double synthesis.
|
|
582
|
+
if (result?.retrievalOnly) {
|
|
583
|
+
const memories = Array.isArray(result.memories) ? result.memories.filter(isRecord) : [];
|
|
584
|
+
if (!memories.length) {
|
|
585
|
+
return { content: [{ type: "text", text: "No relevant memories found." }] };
|
|
586
|
+
}
|
|
587
|
+
const formattedResults = memories
|
|
588
|
+
.map((m, idx) => {
|
|
589
|
+
const key = readString(m, "key") ?? readString(m, "keys") ?? "Saved memory";
|
|
590
|
+
const score = readNumber(m, "similarity")
|
|
591
|
+
?? readNumber(m, "similarity_score")
|
|
592
|
+
?? readNumber(m, "retrieval_similarity_score");
|
|
593
|
+
const meta = [
|
|
594
|
+
readString(m, "time") ? `Time: ${readString(m, "time")}` : "",
|
|
595
|
+
readString(m, "location") ? `Location: ${readString(m, "location")}` : "",
|
|
596
|
+
readString(m, "category") ? `Category: ${readString(m, "category")}` : "",
|
|
597
|
+
readString(m, "object") ? `Object: ${readString(m, "object")}` : "",
|
|
598
|
+
readString(m, "emotion") ? `Emotion: ${readString(m, "emotion")}` : "",
|
|
599
|
+
readString(m, "retrieval_bucket") ? `Bucket: ${readString(m, "retrieval_bucket")}` : "",
|
|
600
|
+
].filter(Boolean).join(" | ");
|
|
601
|
+
const description = typeof m.description === "string"
|
|
602
|
+
? m.description
|
|
603
|
+
: m.description == null
|
|
604
|
+
? ""
|
|
605
|
+
: String(m.description);
|
|
606
|
+
const details = typeof m.details === "string"
|
|
607
|
+
? m.details.trim()
|
|
608
|
+
: m.details == null
|
|
609
|
+
? ""
|
|
610
|
+
: String(m.details).trim();
|
|
611
|
+
return [
|
|
612
|
+
`[${idx + 1}] ${key}${typeof score === "number" ? ` (score ${score.toFixed(3)})` : ""}`,
|
|
613
|
+
meta,
|
|
614
|
+
`Description: ${description}`,
|
|
615
|
+
details ? `Details: ${details}` : "",
|
|
616
|
+
].filter(Boolean).join("\n");
|
|
617
|
+
})
|
|
618
|
+
.join("\n\n");
|
|
619
|
+
return { content: [{ type: "text", text: `Retrieved ${memories.length} memories:\n\n${formattedResults}` }] };
|
|
620
|
+
}
|
|
462
621
|
// Tuned two-phase path: synthesized brief + ranked source memories.
|
|
463
622
|
if (result?.tuned) {
|
|
464
623
|
const { answer, memories } = result;
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `echomem-mcp migrate` — one-shot bulk back-fill of the user's EXISTING local coding-agent history
|
|
3
|
+
* (Codex + Claude Code) into their Echo cloud. The onboarding "see it instantly know my work" moment.
|
|
4
|
+
*
|
|
5
|
+
* Why this lives in the bridge (client-side): the session logs exist ONLY on the user's machine
|
|
6
|
+
* (~/.codex/sessions, ~/.claude/projects). The bridge discovers each session, assembles it into a
|
|
7
|
+
* `## `-turn transcript, and feeds it to the EXISTING durable import queue (the same one the extension
|
|
8
|
+
* uses): POST /api/extension/import-sessions creates one import_jobs row per session, then the bridge
|
|
9
|
+
* POSTs each transcript to /api/extension/import-jobs/{id}/run. The web dashboard polls
|
|
10
|
+
* GET /import-sessions/{id} for live progress — the bridge owns local-file access, the server owns the
|
|
11
|
+
* queue + status. (Headless CLI also works; it just shows progress in the terminal.)
|
|
12
|
+
*
|
|
13
|
+
* Idempotency: the queue dedups by (platform, conversationId) → source_hash (a repeat run hits a
|
|
14
|
+
* non-fatal DUPLICATE_SOURCE), and a local ledger (~/.echomem/migrate-ledger.json) skips done+unchanged
|
|
15
|
+
* files WITHOUT a round-trip. Turns are joined with a SINGLE "\n" so the transcript stays byte-stable
|
|
16
|
+
* across runs (guarded by test/migrate.test.mjs). The run is sequential (throttled < 30/min to the /run
|
|
17
|
+
* limit) and stops cleanly if the encrypted vault's key expires mid-run.
|
|
18
|
+
* NOTE: client-pull — the queue advances only while the bridge runs; there is no server-side worker.
|
|
19
|
+
*/
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import crypto from "node:crypto";
|
|
24
|
+
import readline from "node:readline";
|
|
25
|
+
import axios from "axios";
|
|
26
|
+
import { KeyStore, echoConfigDir } from "./keystore.js";
|
|
27
|
+
import { fetchEncryptionConfig } from "./encryption.js";
|
|
28
|
+
import { walk, eachLine } from "./report.js";
|
|
29
|
+
const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
|
|
30
|
+
const RATE_MAX = 28; // stay under the import-jobs /run limit of 30 / 60s
|
|
31
|
+
const RATE_WINDOW_MS = 60_000;
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Assemblers — reconstruct a `## `-turn transcript from a raw session log.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const sha16 = (s) => crypto.createHash("sha256").update(s).digest("hex").slice(0, 16);
|
|
36
|
+
const turn = (role, ts, body) => `## ${role}${ts ? " — " + ts : ""}\n${body}`;
|
|
37
|
+
const firstLine = (s) => (s.split("\n").find((l) => l.trim()) || "").trim().slice(0, 120);
|
|
38
|
+
const truncate = (s, n) => (s.length > n ? s.slice(0, n) + "…" : s);
|
|
39
|
+
/** Strip Codex's synthetic scaffolding blocks from a user message; "" if nothing real remains. */
|
|
40
|
+
function cleanCodexUser(msg) {
|
|
41
|
+
let s = String(msg || "");
|
|
42
|
+
s = s.replace(/<(environment_context|user_instructions|permissions|app-context)>[\s\S]*?<\/\1>/g, "");
|
|
43
|
+
return s.trim();
|
|
44
|
+
}
|
|
45
|
+
/** Codex rollout-*.jsonl → one session. session_meta is TOP-LEVEL `o.type`; turns are payload events. */
|
|
46
|
+
export function assembleCodex(file) {
|
|
47
|
+
let sessionId = null;
|
|
48
|
+
let cwd = null;
|
|
49
|
+
let firstTs = null;
|
|
50
|
+
let title = "";
|
|
51
|
+
const turns = [];
|
|
52
|
+
eachLine(file, (o) => {
|
|
53
|
+
if (o && o.type === "session_meta") {
|
|
54
|
+
sessionId = (o.payload && typeof o.payload.id === "string" && o.payload.id) || sessionId;
|
|
55
|
+
cwd = (o.payload && typeof o.payload.cwd === "string" && o.payload.cwd) || cwd;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const p = o && typeof o.payload === "object" && o.payload ? o.payload : o;
|
|
59
|
+
if (!p || typeof p !== "object")
|
|
60
|
+
return;
|
|
61
|
+
const ts = typeof o.timestamp === "string" ? o.timestamp : "";
|
|
62
|
+
if (p.type === "user_message" && typeof p.message === "string") {
|
|
63
|
+
const body = cleanCodexUser(p.message);
|
|
64
|
+
if (!body)
|
|
65
|
+
return;
|
|
66
|
+
turns.push(turn("User", ts, body));
|
|
67
|
+
if (!firstTs)
|
|
68
|
+
firstTs = ts;
|
|
69
|
+
if (!title)
|
|
70
|
+
title = firstLine(body);
|
|
71
|
+
}
|
|
72
|
+
else if (p.type === "agent_message" && typeof p.message === "string") {
|
|
73
|
+
const body = p.message.trim();
|
|
74
|
+
if (!body)
|
|
75
|
+
return;
|
|
76
|
+
turns.push(turn("Assistant", ts, body));
|
|
77
|
+
if (!firstTs)
|
|
78
|
+
firstTs = ts;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
if (!turns.length)
|
|
82
|
+
return null;
|
|
83
|
+
const stat = statSafe(file);
|
|
84
|
+
return {
|
|
85
|
+
filePath: file,
|
|
86
|
+
source: "codex",
|
|
87
|
+
conversationKey: `codex:${sessionId || sha16(file)}`,
|
|
88
|
+
cwd: normalizeCwd(cwd),
|
|
89
|
+
firstTs,
|
|
90
|
+
title: title || "Codex session",
|
|
91
|
+
rawData: turns.join("\n"),
|
|
92
|
+
turnCount: turns.length,
|
|
93
|
+
size: stat.size,
|
|
94
|
+
mtimeMs: stat.mtimeMs,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Claude Code <uuid>.jsonl → one session. Only user/assistant lines are turns; thinking is dropped. */
|
|
98
|
+
export function assembleClaude(file) {
|
|
99
|
+
let sessionId = null;
|
|
100
|
+
let cwd = null;
|
|
101
|
+
let firstTs = null;
|
|
102
|
+
let title = "";
|
|
103
|
+
const turns = [];
|
|
104
|
+
eachLine(file, (o) => {
|
|
105
|
+
if (!cwd && typeof o.cwd === "string")
|
|
106
|
+
cwd = o.cwd;
|
|
107
|
+
if (!sessionId && typeof o.sessionId === "string")
|
|
108
|
+
sessionId = o.sessionId;
|
|
109
|
+
const ts = typeof o.timestamp === "string" ? o.timestamp : "";
|
|
110
|
+
if (o.type === "user") {
|
|
111
|
+
const c = o.message && o.message.content;
|
|
112
|
+
let body = "";
|
|
113
|
+
if (typeof c === "string")
|
|
114
|
+
body = c.trim();
|
|
115
|
+
else if (Array.isArray(c)) {
|
|
116
|
+
// tool_result-only carriers are NOT real user prompts → skip; keep any genuine text blocks.
|
|
117
|
+
body = c.filter((b) => b && b.type === "text" && b.text).map((b) => b.text).join("\n").trim();
|
|
118
|
+
}
|
|
119
|
+
if (!body)
|
|
120
|
+
return;
|
|
121
|
+
turns.push(turn("User", ts, body));
|
|
122
|
+
if (!firstTs)
|
|
123
|
+
firstTs = ts;
|
|
124
|
+
if (!title)
|
|
125
|
+
title = firstLine(body);
|
|
126
|
+
}
|
|
127
|
+
else if (o.type === "assistant") {
|
|
128
|
+
const c = o.message && o.message.content;
|
|
129
|
+
let body = "";
|
|
130
|
+
if (Array.isArray(c)) {
|
|
131
|
+
const parts = [];
|
|
132
|
+
for (const b of c) {
|
|
133
|
+
if (b && b.type === "text" && b.text)
|
|
134
|
+
parts.push(b.text);
|
|
135
|
+
else if (b && b.type === "tool_use")
|
|
136
|
+
parts.push(`[tool: ${b.name || "?"} ${truncate(JSON.stringify(b.input || {}), 120)}]`);
|
|
137
|
+
// thinking blocks dropped (bulk of tokens, low memory value)
|
|
138
|
+
}
|
|
139
|
+
body = parts.join("\n").trim();
|
|
140
|
+
}
|
|
141
|
+
else if (typeof c === "string") {
|
|
142
|
+
body = c.trim();
|
|
143
|
+
}
|
|
144
|
+
if (!body)
|
|
145
|
+
return; // thinking-only turn → no header
|
|
146
|
+
turns.push(turn("Assistant", ts, body));
|
|
147
|
+
if (!firstTs)
|
|
148
|
+
firstTs = ts;
|
|
149
|
+
}
|
|
150
|
+
// queue-operation / attachment / ai-title / last-prompt / mode / summary / unknown → ignored
|
|
151
|
+
});
|
|
152
|
+
if (!turns.length)
|
|
153
|
+
return null;
|
|
154
|
+
const stat = statSafe(file);
|
|
155
|
+
return {
|
|
156
|
+
filePath: file,
|
|
157
|
+
source: "claude-code",
|
|
158
|
+
conversationKey: `claude:${sessionId || sha16(file)}`,
|
|
159
|
+
cwd: normalizeCwd(cwd),
|
|
160
|
+
firstTs,
|
|
161
|
+
title: title || "Claude Code session",
|
|
162
|
+
rawData: turns.join("\n"),
|
|
163
|
+
turnCount: turns.length,
|
|
164
|
+
size: stat.size,
|
|
165
|
+
mtimeMs: stat.mtimeMs,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function statSafe(file) {
|
|
169
|
+
try {
|
|
170
|
+
const s = fs.statSync(file);
|
|
171
|
+
return { size: s.size, mtimeMs: Math.round(s.mtimeMs) };
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return { size: 0, mtimeMs: 0 };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Strip agent/git worktree wrappers so worktree sessions group under their real project root. */
|
|
178
|
+
export function normalizeCwd(cwd) {
|
|
179
|
+
if (!cwd)
|
|
180
|
+
return null;
|
|
181
|
+
const m = cwd.match(/worktrees\/[^/]+\/(.+)$/);
|
|
182
|
+
return m ? m[1] : cwd;
|
|
183
|
+
}
|
|
184
|
+
/** Discover every local session, newest first (by first-turn timestamp). */
|
|
185
|
+
export function discoverSessions() {
|
|
186
|
+
const out = [];
|
|
187
|
+
const codexRoot = path.join(os.homedir(), ".codex", "sessions");
|
|
188
|
+
for (const f of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
|
|
189
|
+
const s = assembleCodex(f);
|
|
190
|
+
if (s)
|
|
191
|
+
out.push(s);
|
|
192
|
+
}
|
|
193
|
+
const claudeRoot = path.join(os.homedir(), ".claude", "projects");
|
|
194
|
+
for (const f of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
|
|
195
|
+
const s = assembleClaude(f);
|
|
196
|
+
if (s)
|
|
197
|
+
out.push(s);
|
|
198
|
+
}
|
|
199
|
+
out.sort((a, b) => String(b.firstTs || "").localeCompare(String(a.firstTs || "")));
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
function ledgerPath() {
|
|
203
|
+
return path.join(echoConfigDir(), "migrate-ledger.json");
|
|
204
|
+
}
|
|
205
|
+
function loadLedger() {
|
|
206
|
+
try {
|
|
207
|
+
return JSON.parse(fs.readFileSync(ledgerPath(), "utf8"));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function saveLedger(l) {
|
|
214
|
+
try {
|
|
215
|
+
fs.mkdirSync(echoConfigDir(), { recursive: true, mode: 0o700 });
|
|
216
|
+
fs.writeFileSync(ledgerPath(), JSON.stringify(l, null, 2));
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
/* best effort */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// CLI
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
const color = (enabled) => {
|
|
226
|
+
const w = (code) => (s) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
227
|
+
return { bold: w("1"), dim: w("2"), red: w("31"), green: w("32"), cyan: w("36"), yellow: w("33") };
|
|
228
|
+
};
|
|
229
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
230
|
+
function promptYesNo(question) {
|
|
231
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
232
|
+
return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(!/^n/i.test(a.trim())); }));
|
|
233
|
+
}
|
|
234
|
+
export async function cmdMigrate(flags) {
|
|
235
|
+
const c = color(process.stdout.isTTY === true && !process.env.NO_COLOR && flags["no-color"] !== true);
|
|
236
|
+
const store = new KeyStore();
|
|
237
|
+
// Discover + filter (pure local — discovery and --dry-run need no login).
|
|
238
|
+
let sessions = discoverSessions();
|
|
239
|
+
const since = typeof flags.since === "string" ? flags.since : undefined;
|
|
240
|
+
if (since)
|
|
241
|
+
sessions = sessions.filter((s) => (s.firstTs || "").slice(0, 10) >= since);
|
|
242
|
+
const limit = typeof flags.limit === "string" ? parseInt(flags.limit, 10) : undefined;
|
|
243
|
+
if (limit && limit > 0)
|
|
244
|
+
sessions = sessions.slice(0, limit);
|
|
245
|
+
if (!sessions.length) {
|
|
246
|
+
console.log("No local Codex/Claude Code sessions found to migrate.");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const ledger = loadLedger();
|
|
250
|
+
const isNew = (s) => {
|
|
251
|
+
const e = ledger[s.conversationKey];
|
|
252
|
+
return !e || e.size !== s.size; // unseen, or the file grew/changed since last migrate
|
|
253
|
+
};
|
|
254
|
+
const pending = sessions.filter(isNew);
|
|
255
|
+
const codexN = sessions.filter((s) => s.source === "codex").length;
|
|
256
|
+
const claudeN = sessions.length - codexN;
|
|
257
|
+
console.log("");
|
|
258
|
+
console.log(c.bold(c.cyan("EchoMem migration")) + c.dim(` (${API_BASE})`));
|
|
259
|
+
console.log(`Found ${c.bold(String(sessions.length))} sessions (${codexN} Codex + ${claudeN} Claude Code) · ${c.bold(String(pending.length))} new/changed to import` +
|
|
260
|
+
(sessions.length - pending.length ? c.dim(`, ${sessions.length - pending.length} already migrated`) : ""));
|
|
261
|
+
if (flags["dry-run"] === true) {
|
|
262
|
+
console.log(c.dim("\n--dry-run: discovered + assembled only, nothing sent.\n"));
|
|
263
|
+
for (const s of pending.slice(0, 50)) {
|
|
264
|
+
console.log(` ${s.source === "codex" ? "codex " : "claude"} ${(s.firstTs || "").slice(0, 10)} ${s.turnCount} turns ${c.dim(truncate(s.title, 60))}`);
|
|
265
|
+
}
|
|
266
|
+
if (pending.length > 50)
|
|
267
|
+
console.log(c.dim(` … and ${pending.length - 50} more`));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (!pending.length) {
|
|
271
|
+
console.log(c.green("\n✓ Everything is already migrated — nothing to do.\n"));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// --- Below here we actually send → require a token + (if encrypted) an unlocked key. ---
|
|
275
|
+
const token = store.getToken();
|
|
276
|
+
if (!token) {
|
|
277
|
+
console.error("Not logged in. Run `echomem-mcp login` first, then re-run migrate.");
|
|
278
|
+
process.exitCode = 1;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const client = axios.create({
|
|
282
|
+
baseURL: API_BASE,
|
|
283
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
284
|
+
});
|
|
285
|
+
let encKey;
|
|
286
|
+
try {
|
|
287
|
+
const cfg = await fetchEncryptionConfig(client);
|
|
288
|
+
if (cfg.enabled) {
|
|
289
|
+
encKey = store.getKey();
|
|
290
|
+
if (!encKey) {
|
|
291
|
+
console.error(store.isKeyExpired()
|
|
292
|
+
? "Vault key expired. Run `echomem-mcp unlock`, then re-run migrate."
|
|
293
|
+
: "This account is ENCRYPTED but the vault is locked. Run `echomem-mcp unlock`, then re-run migrate.");
|
|
294
|
+
process.exitCode = 1;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
/* config fetch failed → proceed as unencrypted (server enforces 422 if it's actually encrypted) */
|
|
301
|
+
}
|
|
302
|
+
// Consent: one keypress (skipped with --yes or when non-interactive).
|
|
303
|
+
if (flags.yes !== true && process.stdin.isTTY) {
|
|
304
|
+
const ok = await promptYesNo(`Import ${pending.length} session(s) into your EchoMem memory? [Y/n] `);
|
|
305
|
+
if (!ok) {
|
|
306
|
+
console.log("Aborted. Nothing was sent.");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
311
|
+
const bareId = (s) => s.conversationKey.slice(s.conversationKey.indexOf(":") + 1);
|
|
312
|
+
const mapKey = (platform, conv) => `${platform}:${conv}`;
|
|
313
|
+
// 1) Create the import session — descriptors only, NO transcript. The server fans this out to one
|
|
314
|
+
// import_jobs row per session, which is what the web dashboard polls (GET /import-sessions/{id}).
|
|
315
|
+
let toImport = pending;
|
|
316
|
+
let session;
|
|
317
|
+
try {
|
|
318
|
+
session = await createImportSession(client, toImport, bareId, userTz);
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
// Plan caps the batch → import the newest N and tell the user to re-run for the rest.
|
|
322
|
+
const max = Number(e?.response?.data?.maxConversations);
|
|
323
|
+
if (e?.response?.status === 422 && e?.response?.data?.error === "IMPORT_LIMIT_EXCEEDED" && max > 0) {
|
|
324
|
+
console.log(c.yellow(`Your plan imports up to ${max} at a time — importing the newest ${max}; re-run migrate for older sessions.`));
|
|
325
|
+
toImport = pending.slice(0, max);
|
|
326
|
+
session = await createImportSession(client, toImport, bareId, userTz);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
console.error(c.red(`Could not start the import: ${e?.response?.data?.message || e?.response?.data?.error || e?.message || e}`));
|
|
330
|
+
process.exitCode = 1;
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const byConv = new Map(toImport.map((s) => [mapKey(s.source, bareId(s)), s]));
|
|
335
|
+
console.log(c.dim(`Import session ${session.id} — ${session.jobs.length} jobs queued (the web dashboard can watch this live).`));
|
|
336
|
+
// 2) Drive each job: the queue lives server-side, but only THIS machine can read the logs, so the
|
|
337
|
+
// bridge streams each transcript to /import-jobs/{id}/run. The web just polls. Throttle < 30/min.
|
|
338
|
+
const reqTimes = [];
|
|
339
|
+
const throttle = async () => {
|
|
340
|
+
for (;;) {
|
|
341
|
+
const now = Date.now();
|
|
342
|
+
while (reqTimes.length && now - reqTimes[0] > RATE_WINDOW_MS)
|
|
343
|
+
reqTimes.shift();
|
|
344
|
+
if (reqTimes.length < RATE_MAX) {
|
|
345
|
+
reqTimes.push(Date.now());
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
await sleep(RATE_WINDOW_MS - (now - reqTimes[0]) + 100);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
let migrated = 0, extracted = 0, failed = 0, i = 0;
|
|
352
|
+
for (const job of session.jobs) {
|
|
353
|
+
const s = byConv.get(mapKey(job.platform, job.conversation_id));
|
|
354
|
+
if (!s)
|
|
355
|
+
continue;
|
|
356
|
+
i++;
|
|
357
|
+
const tag = `${c.dim(`[${i}/${session.jobs.length}]`)} ${s.source === "codex" ? "codex " : "claude"} ${(s.firstTs || "").slice(0, 10)}`;
|
|
358
|
+
try {
|
|
359
|
+
await throttle();
|
|
360
|
+
const r = await runImportJob(client, job.id, s, userTz, encKey);
|
|
361
|
+
extracted += r.memories;
|
|
362
|
+
migrated++;
|
|
363
|
+
ledger[s.conversationKey] = { size: s.size, mtimeMs: s.mtimeMs, status: "done", memories: r.memories };
|
|
364
|
+
saveLedger(ledger);
|
|
365
|
+
const note = r.alreadyDone || r.duplicate ? c.dim("already imported") : c.green(`+${r.memories} ${r.memories === 1 ? "memory" : "memories"}`);
|
|
366
|
+
console.log(`${tag} ${note} ${c.dim(truncate(s.title, 48))}`);
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
const status = e?.response?.status;
|
|
370
|
+
const errCode = e?.response?.data?.error;
|
|
371
|
+
// Vault key expired mid-run (TTL crossed) → stop cleanly; the rest is resumable on re-run.
|
|
372
|
+
if (status === 422 && errCode === "ENCRYPTION_KEY_REQUIRED") {
|
|
373
|
+
console.error(c.yellow(`\n⚠ Vault locked mid-run. Run \`echomem-mcp unlock\` and re-run migrate to resume (${migrated} done so far).`));
|
|
374
|
+
process.exitCode = 1;
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
failed++;
|
|
378
|
+
console.log(`${tag} ${c.red("✗ failed")} ${c.dim(truncate(String(e?.response?.data?.message || errCode || e?.message || e), 60))}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
console.log("");
|
|
382
|
+
console.log(c.bold("Done.") + ` ${c.green(String(migrated) + " imported")} · ${c.bold(String(extracted))} memories` +
|
|
383
|
+
(failed ? ` · ${c.red(String(failed) + " failed")}` : "") + ".");
|
|
384
|
+
if (extracted > 0) {
|
|
385
|
+
console.log(c.dim("Now ask your agent about a past project — it can recall it from memory.\n"));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/** Create an import session (lightweight descriptors only — NO transcript). Returns the session id + jobs. */
|
|
389
|
+
async function createImportSession(client, sessions, bareId, userTz) {
|
|
390
|
+
const items = sessions.map((s) => ({
|
|
391
|
+
conversationId: bareId(s),
|
|
392
|
+
platform: s.source, // free-text label; also half of the (session, platform, conversation) key
|
|
393
|
+
title: s.title,
|
|
394
|
+
sourceDate: s.firstTs,
|
|
395
|
+
userTz,
|
|
396
|
+
}));
|
|
397
|
+
const res = await client.post("/api/extension/import-sessions", { items });
|
|
398
|
+
const data = res.data || {};
|
|
399
|
+
return { id: String(data.session?.id || ""), jobs: Array.isArray(data.jobs) ? data.jobs : [] };
|
|
400
|
+
}
|
|
401
|
+
/** Run one queued job: stream its transcript to the server, which extracts it. One retry on 429/5xx. */
|
|
402
|
+
async function runImportJob(client, jobId, s, userTz, encKey) {
|
|
403
|
+
const body = {
|
|
404
|
+
rawData: s.rawData,
|
|
405
|
+
source: s.source, // NOT containing "mcp" (avoids the route's MCP title truncation)
|
|
406
|
+
title: s.title,
|
|
407
|
+
sourceDate: s.firstTs, // back-fill memories on the session's real date
|
|
408
|
+
userTz,
|
|
409
|
+
sessionType: "coding", // routes to the coding-checkpoint extraction prompt (ignored if unsupported)
|
|
410
|
+
};
|
|
411
|
+
const cfg = encKey ? { headers: { "X-Encryption-Key": encKey } } : undefined;
|
|
412
|
+
for (let attempt = 0;; attempt++) {
|
|
413
|
+
try {
|
|
414
|
+
const res = await client.post(`/api/extension/import-jobs/${jobId}/run`, body, cfg);
|
|
415
|
+
const data = res.data || {};
|
|
416
|
+
if (data.claimed === false) {
|
|
417
|
+
// Already completed on a prior run → idempotent, not an error; otherwise it didn't start.
|
|
418
|
+
if (data.job?.status === "completed")
|
|
419
|
+
return { memories: Number(data.job?.saved_memory_count) || 0, alreadyDone: true, duplicate: false };
|
|
420
|
+
throw new Error(data.message || "job did not start");
|
|
421
|
+
}
|
|
422
|
+
const result = data.result || {};
|
|
423
|
+
return { memories: Number(result.memoriesExtracted) || 0, alreadyDone: false, duplicate: !!result.duplicate };
|
|
424
|
+
}
|
|
425
|
+
catch (e) {
|
|
426
|
+
const status = e?.response?.status;
|
|
427
|
+
if (status === 422 && e?.response?.data?.error === "ENCRYPTION_KEY_REQUIRED")
|
|
428
|
+
throw e; // not retryable
|
|
429
|
+
if (status === 409)
|
|
430
|
+
throw e; // claim conflict — don't hammer a job another worker holds
|
|
431
|
+
const retryable = status === 429 || (status >= 500 && status < 600) || e?.code === "ECONNABORTED" || !status;
|
|
432
|
+
if (!retryable || attempt >= 2)
|
|
433
|
+
throw e;
|
|
434
|
+
const retryAfter = Number(e?.response?.headers?.["retry-after"]) || Number(e?.response?.data?.retryAfterSeconds);
|
|
435
|
+
await sleep((status === 429 && retryAfter > 0 ? retryAfter : Math.pow(2, attempt) * 2) * 1000);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
package/dist/report.js
CHANGED
|
@@ -110,7 +110,7 @@ function classifyClaudeTool(name) {
|
|
|
110
110
|
* readFileSync().split()) avoids both loading a multi-hundred-MB session into one string and the
|
|
111
111
|
* V8 ~512 MB max-string cliff that would silently drop a large session. Never throws.
|
|
112
112
|
*/
|
|
113
|
-
function eachLine(file, fn) {
|
|
113
|
+
export function eachLine(file, fn) {
|
|
114
114
|
let fd;
|
|
115
115
|
try {
|
|
116
116
|
fd = fs.openSync(file, "r");
|
|
@@ -256,7 +256,7 @@ export function parseClaude(file) {
|
|
|
256
256
|
toolsEcho: echo,
|
|
257
257
|
};
|
|
258
258
|
}
|
|
259
|
-
function walk(dir, match, skipDir, out = []) {
|
|
259
|
+
export function walk(dir, match, skipDir, out = []) {
|
|
260
260
|
let entries;
|
|
261
261
|
try {
|
|
262
262
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
package/dist/setup.js
CHANGED
|
@@ -23,6 +23,7 @@ import axios from "axios";
|
|
|
23
23
|
import { KeyStore } from "./keystore.js";
|
|
24
24
|
import { fetchEncryptionConfig, deriveAndVerifyKey, verifyKeyB64 } from "./encryption.js";
|
|
25
25
|
import { runReport, buildReportText } from "./report.js";
|
|
26
|
+
import { cmdMigrate } from "./migrate.js";
|
|
26
27
|
// The connect-device page lives on the main site (WebPageReactVersion → yeahecho.com), where the
|
|
27
28
|
// user already has a session. It calls the EchoMem API cross-origin. Override with ECHO_WEB_URL.
|
|
28
29
|
const WEB_URL = (process.env.ECHO_WEB_URL || "https://yeahecho.com").replace(/\/$/, "");
|
|
@@ -387,6 +388,7 @@ Usage:
|
|
|
387
388
|
echomem-mcp status Show token/key/clients
|
|
388
389
|
echomem-mcp logout Remove stored credentials
|
|
389
390
|
echomem-mcp report [--json] Your AI coding memory audit (local, no login, $0)
|
|
391
|
+
echomem-mcp migrate [--since D] Import your existing Codex/Claude history into your memory
|
|
390
392
|
|
|
391
393
|
Manual / headless:
|
|
392
394
|
echomem-mcp login --token ec_xxx [--passphrase <vault pass> | --key <base64>]
|
|
@@ -415,6 +417,9 @@ export async function runCli(argv) {
|
|
|
415
417
|
case "report":
|
|
416
418
|
await runReport(flags);
|
|
417
419
|
return true;
|
|
420
|
+
case "migrate":
|
|
421
|
+
await cmdMigrate(flags);
|
|
422
|
+
return true;
|
|
418
423
|
case "help":
|
|
419
424
|
case "--help":
|
|
420
425
|
case "-h":
|
package/dist/v1-contract.js
CHANGED
|
@@ -20,6 +20,7 @@ export const searchMemoriesSchema = z.object({
|
|
|
20
20
|
limit: z.number().optional(),
|
|
21
21
|
threshold: z.number().optional().default(0.1),
|
|
22
22
|
timeFrameDays: z.number().optional(),
|
|
23
|
+
includeAnswer: z.boolean().optional().default(false),
|
|
23
24
|
});
|
|
24
25
|
export const saveConversationSchema = z.object({
|
|
25
26
|
conversation: z.string().optional(),
|
|
@@ -55,7 +56,7 @@ export function listToolSpecs(opts = {}) {
|
|
|
55
56
|
return [
|
|
56
57
|
{
|
|
57
58
|
name: canonicalToolNames.search,
|
|
58
|
-
description: `Recall the user's prior decisions, preferences, constraints, and project context from EchoMem — their long-term memory across ALL their AI tools (Claude.ai, ChatGPT, other agents), not just this session.${mapSection}\nCall this when the task plausibly relates to that remembered context — a topic above, or when the user refers to past work ("what did we decide", "like before", "the usual") — so you don't re-derive or re-ask what they already settled. Skip it for self-contained tasks with no link to their history (e.g. a generic algorithm question).
|
|
59
|
+
description: `Recall the user's prior decisions, preferences, constraints, and project context from EchoMem — their long-term memory across ALL their AI tools (Claude.ai, ChatGPT, other agents), not just this session.${mapSection}\nCall this when the task plausibly relates to that remembered context — a topic above, or when the user refers to past work ("what did we decide", "like before", "the usual") — so you don't re-derive or re-ask what they already settled. Skip it for self-contained tasks with no link to their history (e.g. a generic algorithm question). By default this returns only the ranked memories retrieved for recall and skips EchoMem answer generation; set includeAnswer=true only when you explicitly need EchoMem's legacy synthesized recall. Current time: ${currentTime}.`,
|
|
59
60
|
inputSchema: {
|
|
60
61
|
type: "object",
|
|
61
62
|
properties: {
|
|
@@ -63,6 +64,11 @@ export function listToolSpecs(opts = {}) {
|
|
|
63
64
|
limit: { type: "number", default: 10 },
|
|
64
65
|
threshold: { type: "number", default: 0.1 },
|
|
65
66
|
timeFrameDays: { type: "number" },
|
|
67
|
+
includeAnswer: {
|
|
68
|
+
type: "boolean",
|
|
69
|
+
default: false,
|
|
70
|
+
description: "Default false: return only retrieved memories and skip answer generation. Set true for the legacy synthesized recall answer.",
|
|
71
|
+
},
|
|
66
72
|
},
|
|
67
73
|
},
|
|
68
74
|
},
|
|
@@ -76,6 +82,11 @@ export function listToolSpecs(opts = {}) {
|
|
|
76
82
|
limit: { type: "number", default: 10 },
|
|
77
83
|
threshold: { type: "number", default: 0.1 },
|
|
78
84
|
timeFrameDays: { type: "number" },
|
|
85
|
+
includeAnswer: {
|
|
86
|
+
type: "boolean",
|
|
87
|
+
default: false,
|
|
88
|
+
description: "Default false: return only retrieved memories and skip answer generation. Set true for the legacy synthesized recall answer.",
|
|
89
|
+
},
|
|
79
90
|
},
|
|
80
91
|
},
|
|
81
92
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@echomem/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "EchoMem Cloud-First MCP Server",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"start": "node dist/index.js",
|
|
18
18
|
"dev": "tsx src/index.ts",
|
|
19
19
|
"smoke": "node smoke.mjs",
|
|
20
|
-
"test": "npm run build && node test/crypto.test.mjs && node test/integration.test.mjs && node test/no-restart.test.mjs && node test/report.test.mjs",
|
|
20
|
+
"test": "npm run build && node test/crypto.test.mjs && node test/integration.test.mjs && node test/no-restart.test.mjs && node test/report.test.mjs && node test/tools.test.mjs && node test/migrate.test.mjs",
|
|
21
21
|
"prepack": "npm run build"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|