@echomem/mcp 1.4.7 → 1.4.9
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 +35 -9
- package/assets/canonical-scorer/README.md +18 -0
- package/assets/canonical-scorer/analyze-10-problems.mjs +857 -0
- package/assets/canonical-scorer/build-session-waste-dashboard.mjs +1628 -0
- package/assets/canonical-scorer/golden_anchors.mjs +83 -0
- package/assets/canonical-scorer/optimizable_detail.mjs +633 -0
- package/assets/hud/claude.svg +1 -0
- package/assets/hud/codex.svg +1 -0
- package/assets/hud/session-viewer.html +35 -0
- package/dist/city/chaos-to-clarity-pencil.html +582 -0
- package/dist/city/echo-ai-city-only.html +1126 -109
- package/dist/city/echo-ai-city-only.template.html +1126 -109
- package/dist/city/echo-face-cutout.png +0 -0
- package/dist/city/pencil-pie-generator.html +883 -0
- package/dist/city/pencil-webgl-landscape.html +1239 -0
- package/dist/city/spatial-fan-story.html +479 -0
- package/dist/codex-session-files.js +283 -0
- package/dist/codex-sync.js +7 -2
- package/dist/context-analysis/canonical-golden.js +47 -0
- package/dist/context-analysis/claude-native-canonical.js +1193 -0
- package/dist/context-analysis/vendored-canonical.js +793 -0
- package/dist/context-analysis/workspace-report.js +1838 -0
- package/dist/context-metrics/calculate.js +56 -0
- package/dist/context-metrics/model-limits.js +26 -0
- package/dist/context-metrics/types.js +1 -0
- package/dist/forensics-10-problems.js +7 -6
- package/dist/forensics.js +863 -132
- package/dist/hud/adapters.js +8 -4
- package/dist/hud/autostart.js +66 -0
- package/dist/hud/cli.js +31 -0
- package/dist/hud/electron-main.js +182 -19
- package/dist/hud/metric.js +13 -4
- package/dist/hud/monitor.js +171 -84
- package/dist/hud/preload.cjs +3 -0
- package/dist/hud/server.js +321 -4
- package/dist/hud/web.js +880 -270
- package/dist/index.js +122 -24
- package/dist/local-data-paths.js +87 -0
- package/dist/migrate.js +55 -29
- package/dist/report.js +101 -40
- package/dist/setup-page.js +4257 -245
- package/dist/setup-preview.js +245 -0
- package/dist/setup.js +786 -75
- package/dist/v1-contract.js +20 -2
- package/package.json +6 -4
- package/templates/echomem-recall.md +2 -2
package/dist/index.js
CHANGED
|
@@ -273,6 +273,23 @@ function lastUserMessageFromConversationText(value) {
|
|
|
273
273
|
flush();
|
|
274
274
|
return lastUser || undefined;
|
|
275
275
|
}
|
|
276
|
+
function formatMessagesForIngest(messages) {
|
|
277
|
+
return messages
|
|
278
|
+
.map((message) => {
|
|
279
|
+
const content = message.content.trim();
|
|
280
|
+
if (!content)
|
|
281
|
+
return "";
|
|
282
|
+
const role = message.role.toLowerCase();
|
|
283
|
+
const label = role === "user" || role === "human"
|
|
284
|
+
? "User"
|
|
285
|
+
: role === "system"
|
|
286
|
+
? "System"
|
|
287
|
+
: "Assistant";
|
|
288
|
+
return `## ${label}\n\n${content}`;
|
|
289
|
+
})
|
|
290
|
+
.filter(Boolean)
|
|
291
|
+
.join("\n\n---\n\n");
|
|
292
|
+
}
|
|
276
293
|
function normalizeRetrievalCandidate(value, fallbackRank) {
|
|
277
294
|
if (!isRecord(value))
|
|
278
295
|
return null;
|
|
@@ -645,7 +662,7 @@ class EchoMemApiClient {
|
|
|
645
662
|
return { success: true, tuned: true, answer: String(data.answer || data.response || "").trim(), memories };
|
|
646
663
|
}
|
|
647
664
|
async searchMemories(args) {
|
|
648
|
-
const parsed = searchMemoriesSchema.parse(args);
|
|
665
|
+
const parsed = searchMemoriesSchema.parse(args ?? {});
|
|
649
666
|
const query = parsed.query?.trim();
|
|
650
667
|
const limit = parsed.limit ?? parsed.k ?? 10;
|
|
651
668
|
const threshold = parsed.threshold ?? 0.1;
|
|
@@ -693,10 +710,10 @@ class EchoMemApiClient {
|
|
|
693
710
|
return data;
|
|
694
711
|
}
|
|
695
712
|
async saveConversation(args) {
|
|
696
|
-
const parsed = saveConversationSchema.parse(args);
|
|
713
|
+
const parsed = saveConversationSchema.parse(args ?? {});
|
|
697
714
|
let rawData = parsed.conversation?.trim() || "";
|
|
698
715
|
if (!rawData && parsed.messages?.length) {
|
|
699
|
-
rawData = parsed.messages
|
|
716
|
+
rawData = formatMessagesForIngest(parsed.messages);
|
|
700
717
|
}
|
|
701
718
|
if (!rawData) {
|
|
702
719
|
throw new McpError(ErrorCode.InvalidParams, "Either conversation or messages is required.");
|
|
@@ -724,7 +741,7 @@ class EchoMemApiClient {
|
|
|
724
741
|
return response.data;
|
|
725
742
|
}
|
|
726
743
|
async deleteMemory(args) {
|
|
727
|
-
const parsed = deleteMemorySchema.parse(args);
|
|
744
|
+
const parsed = deleteMemorySchema.parse(args ?? {});
|
|
728
745
|
const enc = await this.encState();
|
|
729
746
|
const memory = await this.fetchMemoryById(parsed.memoryId, enc);
|
|
730
747
|
if (!memory) {
|
|
@@ -768,7 +785,7 @@ class EchoMemApiClient {
|
|
|
768
785
|
};
|
|
769
786
|
}
|
|
770
787
|
async getMemoriesByTimeRange(args) {
|
|
771
|
-
const parsed = timeRangeSchema.parse(args);
|
|
788
|
+
const parsed = timeRangeSchema.parse(args ?? {});
|
|
772
789
|
const enc = await this.encState();
|
|
773
790
|
const response = await this.axios.post("/api/extension/memories/time-range", {
|
|
774
791
|
startDate: parsed.startDate,
|
|
@@ -778,7 +795,7 @@ class EchoMemApiClient {
|
|
|
778
795
|
return enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
|
|
779
796
|
}
|
|
780
797
|
async getMemoriesByContext(args) {
|
|
781
|
-
const parsed = getByContextSchema.parse(args);
|
|
798
|
+
const parsed = getByContextSchema.parse(args ?? {});
|
|
782
799
|
const enc = await this.encState();
|
|
783
800
|
const response = await this.axios.post("/api/extension/memories/by-context", {
|
|
784
801
|
contextId: parsed.contextId,
|
|
@@ -787,7 +804,7 @@ class EchoMemApiClient {
|
|
|
787
804
|
return enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
|
|
788
805
|
}
|
|
789
806
|
async searchMemoriesByKeywords(args) {
|
|
790
|
-
const parsed = keywordsSchema.parse(args);
|
|
807
|
+
const parsed = keywordsSchema.parse(args ?? {});
|
|
791
808
|
const enc = await this.encState();
|
|
792
809
|
const response = await this.axios.post("/api/extension/memories/keywords", {
|
|
793
810
|
keywords: parsed.keywords,
|
|
@@ -796,7 +813,7 @@ class EchoMemApiClient {
|
|
|
796
813
|
return enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
|
|
797
814
|
}
|
|
798
815
|
async listFriends(args) {
|
|
799
|
-
listFriendsSchema.parse(args);
|
|
816
|
+
listFriendsSchema.parse(args ?? {});
|
|
800
817
|
try {
|
|
801
818
|
const response = await this.axios.get("/api/extension/social/friends");
|
|
802
819
|
return response.data;
|
|
@@ -806,7 +823,7 @@ class EchoMemApiClient {
|
|
|
806
823
|
}
|
|
807
824
|
}
|
|
808
825
|
async searchUsers(args) {
|
|
809
|
-
const parsed = searchUsersSchema.parse(args);
|
|
826
|
+
const parsed = searchUsersSchema.parse(args ?? {});
|
|
810
827
|
try {
|
|
811
828
|
const response = await this.axios.post("/api/extension/social/users/search", {
|
|
812
829
|
query: parsed.query,
|
|
@@ -819,7 +836,7 @@ class EchoMemApiClient {
|
|
|
819
836
|
}
|
|
820
837
|
}
|
|
821
838
|
async sendFriendRequest(args) {
|
|
822
|
-
const parsed = sendFriendRequestSchema.parse(args);
|
|
839
|
+
const parsed = sendFriendRequestSchema.parse(args ?? {});
|
|
823
840
|
try {
|
|
824
841
|
const response = await this.axios.post("/api/extension/social/friend-requests", {
|
|
825
842
|
receiverUserId: parsed.targetUserId,
|
|
@@ -831,7 +848,7 @@ class EchoMemApiClient {
|
|
|
831
848
|
}
|
|
832
849
|
}
|
|
833
850
|
async searchOthersMemories(args) {
|
|
834
|
-
const parsed = othersSchema.parse(args);
|
|
851
|
+
const parsed = othersSchema.parse(args ?? {});
|
|
835
852
|
try {
|
|
836
853
|
const response = await this.axios.post("/api/extension/social/public-memories/search", {
|
|
837
854
|
query: parsed.query,
|
|
@@ -855,7 +872,7 @@ class EchoMemApiClient {
|
|
|
855
872
|
}
|
|
856
873
|
}
|
|
857
874
|
async getPublicMemory(args) {
|
|
858
|
-
const parsed = publicMemorySchema.parse(args);
|
|
875
|
+
const parsed = publicMemorySchema.parse(args ?? {});
|
|
859
876
|
try {
|
|
860
877
|
const response = await this.axios.get(`/api/extension/social/public-memories/${encodeURIComponent(parsed.memoryId)}?requestId=${encodeURIComponent(this.sessionId)}&source=mcp_friend_public_memory_fetch`);
|
|
861
878
|
return response.data;
|
|
@@ -1026,6 +1043,8 @@ class EchoMemMCPServer {
|
|
|
1026
1043
|
return await this.handleTimeRange(request.params.arguments);
|
|
1027
1044
|
case canonicalToolNames.getByContext:
|
|
1028
1045
|
return await this.handleGetByContext(request.params.arguments);
|
|
1046
|
+
case canonicalToolNames.checkpointByContext:
|
|
1047
|
+
return await this.handleGetCheckpointByContext(request.params.arguments);
|
|
1029
1048
|
case canonicalToolNames.keywords:
|
|
1030
1049
|
return await this.handleKeywords(request.params.arguments);
|
|
1031
1050
|
case canonicalToolNames.friends:
|
|
@@ -1254,7 +1273,7 @@ Details: ${m.details || "N/A"}`)
|
|
|
1254
1273
|
return { content: [{ type: "text", text }] };
|
|
1255
1274
|
}
|
|
1256
1275
|
async handleTimeRange(args) {
|
|
1257
|
-
const parsed = timeRangeSchema.parse(args);
|
|
1276
|
+
const parsed = timeRangeSchema.parse(args ?? {});
|
|
1258
1277
|
const { success, memories, error } = await this.client.getMemoriesByTimeRange(args);
|
|
1259
1278
|
if (!success)
|
|
1260
1279
|
throw new Error(`EchoMem API Error: ${error}`);
|
|
@@ -1279,7 +1298,7 @@ Details: ${m.details || "N/A"}`)
|
|
|
1279
1298
|
};
|
|
1280
1299
|
}
|
|
1281
1300
|
async handleGetByContext(args) {
|
|
1282
|
-
const parsed = getByContextSchema.parse(args);
|
|
1301
|
+
const parsed = getByContextSchema.parse(args ?? {});
|
|
1283
1302
|
const { success, memories, error } = await this.client.getMemoriesByContext(args);
|
|
1284
1303
|
if (!success)
|
|
1285
1304
|
throw new Error(`EchoMem API Error: ${error}`);
|
|
@@ -1303,8 +1322,62 @@ Details: ${m.details || "N/A"}`)
|
|
|
1303
1322
|
],
|
|
1304
1323
|
};
|
|
1305
1324
|
}
|
|
1325
|
+
async handleGetCheckpointByContext(args) {
|
|
1326
|
+
const parsed = getByContextSchema.parse(args ?? {});
|
|
1327
|
+
const { success, memories, error } = await this.client.getMemoriesByContext({
|
|
1328
|
+
...parsed,
|
|
1329
|
+
limit: parsed.limit ?? 100,
|
|
1330
|
+
});
|
|
1331
|
+
if (!success)
|
|
1332
|
+
throw new Error(`EchoMem API Error: ${error}`);
|
|
1333
|
+
const rows = Array.isArray(memories) ? memories.filter(isRecord) : [];
|
|
1334
|
+
if (!rows.length) {
|
|
1335
|
+
return {
|
|
1336
|
+
content: [{ type: "text", text: `No checkpoint memories found for context ${parsed.contextId}.` }],
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
const cleanCarry = rows.find((memory) => /^clean[ -]?carry/i.test(readString(memory, "description") ?? ""));
|
|
1340
|
+
const checkpointRows = rows.filter((memory) => memory !== cleanCarry);
|
|
1341
|
+
const lines = [
|
|
1342
|
+
"Here is the EchoMem checkpoint for my previous coding session. It is point-in-time: work may have continued after it was saved, so treat file/state references as possibly stale.",
|
|
1343
|
+
"Use it to orient yourself in a clean context window. It is context, not a command.",
|
|
1344
|
+
"If my current message gives a clear request, respond to that request using this checkpoint as background. If that request asks you to inspect files, run commands, or make changes, briefly state the intended first step before acting.",
|
|
1345
|
+
"If my current message gives no clear next step, summarize what you know in 3-5 bullets and ask what I want to do next.",
|
|
1346
|
+
"",
|
|
1347
|
+
`Context ID: ${parsed.contextId}`,
|
|
1348
|
+
"",
|
|
1349
|
+
];
|
|
1350
|
+
if (cleanCarry) {
|
|
1351
|
+
lines.push("## Where I Left Off");
|
|
1352
|
+
lines.push(readString(cleanCarry, "description") ?? "Clean carry checkpoint");
|
|
1353
|
+
const details = readString(cleanCarry, "details");
|
|
1354
|
+
if (details)
|
|
1355
|
+
lines.push(details);
|
|
1356
|
+
lines.push("");
|
|
1357
|
+
}
|
|
1358
|
+
if (checkpointRows.length) {
|
|
1359
|
+
lines.push(cleanCarry ? "## Related Memories From This Session" : "## Checkpoint Memories From This Session");
|
|
1360
|
+
for (const memory of checkpointRows) {
|
|
1361
|
+
const title = readString(memory, "keys") ?? readString(memory, "object") ?? "Saved checkpoint";
|
|
1362
|
+
const desc = readString(memory, "description") ?? "";
|
|
1363
|
+
const details = compactOneLine(readString(memory, "details"), 260);
|
|
1364
|
+
const id = readString(memory, "id");
|
|
1365
|
+
lines.push(`- ${title}${id ? ` (${id})` : ""}: ${desc}${details ? ` — ${details}` : ""}`);
|
|
1366
|
+
}
|
|
1367
|
+
lines.push("");
|
|
1368
|
+
}
|
|
1369
|
+
lines.push(`EchoMem returned ${rows.length} ${rows.length === 1 ? "memory" : "memories"} for this checkpoint context.`);
|
|
1370
|
+
return {
|
|
1371
|
+
content: [
|
|
1372
|
+
{
|
|
1373
|
+
type: "text",
|
|
1374
|
+
text: lines.join("\n"),
|
|
1375
|
+
},
|
|
1376
|
+
],
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1306
1379
|
async handleKeywords(args) {
|
|
1307
|
-
const parsed = keywordsSchema.parse(args);
|
|
1380
|
+
const parsed = keywordsSchema.parse(args ?? {});
|
|
1308
1381
|
const { success, memories, error } = await this.client.searchMemoriesByKeywords(args);
|
|
1309
1382
|
if (!success)
|
|
1310
1383
|
throw new Error(`EchoMem API Error: ${error}`);
|
|
@@ -1329,7 +1402,7 @@ Details: ${m.details || "N/A"}`)
|
|
|
1329
1402
|
};
|
|
1330
1403
|
}
|
|
1331
1404
|
async handleOthers(args) {
|
|
1332
|
-
const parsed = othersSchema.parse(args);
|
|
1405
|
+
const parsed = othersSchema.parse(args ?? {});
|
|
1333
1406
|
const payload = await this.client.searchOthersMemories(args);
|
|
1334
1407
|
const memories = payload?.memories ?? [];
|
|
1335
1408
|
if (!memories.length) {
|
|
@@ -1380,7 +1453,7 @@ Details: ${m.details || "N/A"}`;
|
|
|
1380
1453
|
};
|
|
1381
1454
|
}
|
|
1382
1455
|
async handleFriends(args) {
|
|
1383
|
-
listFriendsSchema.parse(args);
|
|
1456
|
+
listFriendsSchema.parse(args ?? {});
|
|
1384
1457
|
const payload = await this.client.listFriends(args);
|
|
1385
1458
|
const friends = Array.isArray(payload?.friends) ? payload.friends : [];
|
|
1386
1459
|
if (!friends.length) {
|
|
@@ -1411,7 +1484,7 @@ Details: ${m.details || "N/A"}`;
|
|
|
1411
1484
|
};
|
|
1412
1485
|
}
|
|
1413
1486
|
async handleSearchUsers(args) {
|
|
1414
|
-
const parsed = searchUsersSchema.parse(args);
|
|
1487
|
+
const parsed = searchUsersSchema.parse(args ?? {});
|
|
1415
1488
|
const payload = await this.client.searchUsers(args);
|
|
1416
1489
|
const users = Array.isArray(payload?.users) ? payload.users : [];
|
|
1417
1490
|
if (!users.length) {
|
|
@@ -1444,7 +1517,7 @@ Details: ${m.details || "N/A"}`;
|
|
|
1444
1517
|
};
|
|
1445
1518
|
}
|
|
1446
1519
|
async handleSendFriendRequest(args) {
|
|
1447
|
-
sendFriendRequestSchema.parse(args);
|
|
1520
|
+
sendFriendRequestSchema.parse(args ?? {});
|
|
1448
1521
|
const payload = await this.client.sendFriendRequest(args);
|
|
1449
1522
|
const result = isRecord(payload) ? payload : {};
|
|
1450
1523
|
const targetUser = isRecord(result.targetUser) ? result.targetUser : {};
|
|
@@ -1477,7 +1550,7 @@ Details: ${m.details || "N/A"}`;
|
|
|
1477
1550
|
};
|
|
1478
1551
|
}
|
|
1479
1552
|
async handlePublicMemory(args) {
|
|
1480
|
-
const parsed = publicMemorySchema.parse(args);
|
|
1553
|
+
const parsed = publicMemorySchema.parse(args ?? {});
|
|
1481
1554
|
const payload = await this.client.getPublicMemory(args);
|
|
1482
1555
|
const memory = payload?.memory;
|
|
1483
1556
|
if (!memory) {
|
|
@@ -1505,7 +1578,7 @@ Details: ${m.details || "N/A"}`;
|
|
|
1505
1578
|
};
|
|
1506
1579
|
}
|
|
1507
1580
|
async handleDelete(args, rec) {
|
|
1508
|
-
const parsed = deleteMemorySchema.parse(args);
|
|
1581
|
+
const parsed = deleteMemorySchema.parse(args ?? {});
|
|
1509
1582
|
if (rec) {
|
|
1510
1583
|
rec.memory_id_hash = hashText(parsed.memoryId);
|
|
1511
1584
|
rec.delete_confirmed = parsed.confirmed;
|
|
@@ -1564,10 +1637,35 @@ Details: ${m.details || "N/A"}`;
|
|
|
1564
1637
|
async function main() {
|
|
1565
1638
|
// Subcommands (setup/login/unlock/status/logout/help) run and exit; no subcommand → serve.
|
|
1566
1639
|
const handled = await runCli(process.argv.slice(2));
|
|
1567
|
-
if (handled)
|
|
1568
|
-
|
|
1640
|
+
if (handled) {
|
|
1641
|
+
// These are one-shot commands: the work is finished here. Force exit so a stray open handle —
|
|
1642
|
+
// e.g. a not-yet-timed-out keep-alive socket from the local setup server, or a detached child's
|
|
1643
|
+
// inherited descriptor — can't leave the terminal hanging after setup/migrate completes.
|
|
1644
|
+
process.exit(process.exitCode ?? 0);
|
|
1645
|
+
}
|
|
1569
1646
|
const store = new KeyStore();
|
|
1570
|
-
|
|
1647
|
+
// Serve mode is meant to be SPAWNED by the MCP client (editor), which drives us over a piped stdin.
|
|
1648
|
+
// A human who runs the bare server in a terminal instead gets a process that blocks forever on stdio
|
|
1649
|
+
// with no output — indistinguishable from a hang ("wtf, it's stuck"). A TTY on stdin means no editor
|
|
1650
|
+
// is on the other end: say so loudly and point at the command they almost certainly meant.
|
|
1651
|
+
if (process.stdin.isTTY) {
|
|
1652
|
+
const connected = Boolean(store.getToken());
|
|
1653
|
+
console.error([
|
|
1654
|
+
"",
|
|
1655
|
+
"⚠️ You started the EchoMem MCP server directly in a terminal.",
|
|
1656
|
+
"",
|
|
1657
|
+
" This is a background server that waits for your editor to connect over",
|
|
1658
|
+
" stdio. It is NOT frozen — a blank, unresponsive terminal is exactly what",
|
|
1659
|
+
" a running stdio server looks like.",
|
|
1660
|
+
"",
|
|
1661
|
+
connected
|
|
1662
|
+
? " Your editor launches this for you; you don't need to run it by hand."
|
|
1663
|
+
: " To connect this device, press Ctrl+C and run:",
|
|
1664
|
+
connected ? " Press Ctrl+C to stop it." : " echomem-mcp init",
|
|
1665
|
+
"",
|
|
1666
|
+
].join("\n"));
|
|
1667
|
+
}
|
|
1668
|
+
else if (!store.getToken()) {
|
|
1571
1669
|
// No token yet — DON'T exit. Start the server so the editor keeps the bridge alive; tools return
|
|
1572
1670
|
// a "run login" nudge until `login` writes the token, then the next call picks it up (no restart).
|
|
1573
1671
|
console.error("EchoMem: not connected yet — run `echomem-mcp login` to connect this device.");
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function normalizedHomeDir(homeDir) {
|
|
5
|
+
const value = homeDir.trim();
|
|
6
|
+
if (!value || !path.isAbsolute(value))
|
|
7
|
+
return null;
|
|
8
|
+
return path.normalize(value);
|
|
9
|
+
}
|
|
10
|
+
function expandCurrentUserHome(value, homeDir) {
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
if (!trimmed)
|
|
13
|
+
return null;
|
|
14
|
+
if (trimmed === "~")
|
|
15
|
+
return homeDir;
|
|
16
|
+
if (trimmed.startsWith(`~${path.sep}`) || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
|
17
|
+
return path.join(homeDir, trimmed.slice(2));
|
|
18
|
+
}
|
|
19
|
+
// Do not guess another user's home (~alice) or resolve a relative path against the MCP process cwd.
|
|
20
|
+
if (trimmed.startsWith("~") || !path.isAbsolute(trimmed))
|
|
21
|
+
return null;
|
|
22
|
+
return path.normalize(trimmed);
|
|
23
|
+
}
|
|
24
|
+
function configuredRoot(envKey, defaultDirName, opts = {}) {
|
|
25
|
+
const homeDir = normalizedHomeDir(opts.homeDir ?? os.homedir());
|
|
26
|
+
if (!homeDir)
|
|
27
|
+
return null;
|
|
28
|
+
const env = opts.env ?? process.env;
|
|
29
|
+
const configured = env[envKey];
|
|
30
|
+
if (typeof configured === "string" && configured.trim()) {
|
|
31
|
+
// An explicit profile override is authoritative. If it is invalid, callers must not silently
|
|
32
|
+
// scan the default profile, which may belong to a different account or contain stale history.
|
|
33
|
+
return expandCurrentUserHome(configured, homeDir);
|
|
34
|
+
}
|
|
35
|
+
return path.join(homeDir, defaultDirName);
|
|
36
|
+
}
|
|
37
|
+
/** Resolve an existing directory only after confirming it is a readable/searchable directory. */
|
|
38
|
+
export function resolveReadableDirectory(candidate) {
|
|
39
|
+
if (!candidate)
|
|
40
|
+
return null;
|
|
41
|
+
try {
|
|
42
|
+
const real = fs.realpathSync(candidate);
|
|
43
|
+
if (!fs.statSync(real).isDirectory())
|
|
44
|
+
return null;
|
|
45
|
+
fs.accessSync(real, fs.constants.R_OK | fs.constants.X_OK);
|
|
46
|
+
return real;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve every readable Codex transcript root under one authoritative CODEX_HOME profile.
|
|
54
|
+
* Active sessions come first. archived_sessions is additive historical data, not a fallback to a
|
|
55
|
+
* different profile. If both names resolve to the same directory, keep it once as active.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveCodexSessionRoots(opts = {}) {
|
|
58
|
+
const root = configuredRoot("CODEX_HOME", ".codex", opts);
|
|
59
|
+
if (!root)
|
|
60
|
+
return [];
|
|
61
|
+
const candidates = [
|
|
62
|
+
{ kind: "active", child: "sessions", priority: 0 },
|
|
63
|
+
{ kind: "archived", child: "archived_sessions", priority: 1 },
|
|
64
|
+
];
|
|
65
|
+
const roots = [];
|
|
66
|
+
const seenRealpaths = new Set();
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
const resolved = resolveReadableDirectory(path.join(root, candidate.child));
|
|
69
|
+
if (!resolved || seenRealpaths.has(resolved))
|
|
70
|
+
continue;
|
|
71
|
+
seenRealpaths.add(resolved);
|
|
72
|
+
roots.push({ kind: candidate.kind, path: resolved, priority: candidate.priority });
|
|
73
|
+
}
|
|
74
|
+
return roots;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Backward-compatible active-only resolver. Historical scanners should use
|
|
78
|
+
* resolveCodexSessionRoots/discoverCodexSessionFiles so archived sessions are not omitted.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveCodexSessionsDir(opts = {}) {
|
|
81
|
+
return resolveCodexSessionRoots(opts).find((root) => root.kind === "active")?.path ?? null;
|
|
82
|
+
}
|
|
83
|
+
/** Claude Code state lives under CLAUDE_CONFIG_DIR (default ~/.claude); transcripts are in projects. */
|
|
84
|
+
export function resolveClaudeProjectsDir(opts = {}) {
|
|
85
|
+
const root = configuredRoot("CLAUDE_CONFIG_DIR", ".claude", opts);
|
|
86
|
+
return resolveReadableDirectory(root ? path.join(root, "projects") : null);
|
|
87
|
+
}
|
package/dist/migrate.js
CHANGED
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
* NOTE: client-pull — the queue advances only while the bridge runs; there is no server-side worker.
|
|
20
20
|
*/
|
|
21
21
|
import fs from "node:fs";
|
|
22
|
-
import os from "node:os";
|
|
23
22
|
import path from "node:path";
|
|
24
23
|
import crypto from "node:crypto";
|
|
25
24
|
import readline from "node:readline";
|
|
26
25
|
import axios from "axios";
|
|
27
26
|
import { KeyStore, echoConfigDir } from "./keystore.js";
|
|
28
27
|
import { fetchEncryptionConfig } from "./encryption.js";
|
|
28
|
+
import { resolveClaudeProjectsDir, resolveCodexSessionsDir } from "./local-data-paths.js";
|
|
29
29
|
import { walk, eachLine } from "./report.js";
|
|
30
30
|
const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
|
|
31
31
|
const RATE_MAX = 28; // stay under the import-jobs /run limit of 30 / 60s
|
|
@@ -187,17 +187,21 @@ export function normalizeCwd(cwd) {
|
|
|
187
187
|
/** Discover every local session, newest first (by first-turn timestamp). */
|
|
188
188
|
export function discoverSessions() {
|
|
189
189
|
const out = [];
|
|
190
|
-
const codexRoot =
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
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
|
+
}
|
|
195
197
|
}
|
|
196
|
-
const claudeRoot =
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
198
|
+
const claudeRoot = resolveClaudeProjectsDir();
|
|
199
|
+
if (claudeRoot) {
|
|
200
|
+
for (const f of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
|
|
201
|
+
const s = assembleClaude(f);
|
|
202
|
+
if (s)
|
|
203
|
+
out.push(s);
|
|
204
|
+
}
|
|
201
205
|
}
|
|
202
206
|
out.sort((a, b) => String(b.firstTs || "").localeCompare(String(a.firstTs || "")));
|
|
203
207
|
return out;
|
|
@@ -283,24 +287,28 @@ function fastSessionInfo(file, source) {
|
|
|
283
287
|
}
|
|
284
288
|
function fastSessionEntries(opts = {}) {
|
|
285
289
|
const out = [];
|
|
286
|
-
const codexRoot = opts.codexRoot ??
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
+
}
|
|
295
301
|
}
|
|
296
|
-
const claudeRoot = opts.claudeRoot ??
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
302
|
+
const claudeRoot = opts.claudeRoot ?? resolveClaudeProjectsDir();
|
|
303
|
+
if (claudeRoot) {
|
|
304
|
+
for (const filePath of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
|
|
305
|
+
const stat = statSafe(filePath);
|
|
306
|
+
const info = fastSessionInfo(filePath, "claude-code");
|
|
307
|
+
// Same rule as codex: include on text OR a real session id so large sessions aren't undercounted,
|
|
308
|
+
// while keeping the key stable (claude-code sessionId appears on every line, so hasRealKey is reliable).
|
|
309
|
+
if (info.hasTextTurn || info.hasRealKey)
|
|
310
|
+
out.push({ filePath, source: "claude-code", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
311
|
+
}
|
|
304
312
|
}
|
|
305
313
|
return out;
|
|
306
314
|
}
|
|
@@ -644,6 +652,7 @@ export function estimateMigrationEtaFromLengths(lengths, skippedActive = 0, opts
|
|
|
644
652
|
};
|
|
645
653
|
}
|
|
646
654
|
export function summarizeFastMigratableDiscovery(discovery) {
|
|
655
|
+
const pendingCodex = discovery.pendingCodex ?? discovery.pending.filter((s) => s.source === "codex").length;
|
|
647
656
|
return {
|
|
648
657
|
sessions: discovery.sessions.length,
|
|
649
658
|
pending: discovery.pending.length,
|
|
@@ -652,6 +661,8 @@ export function summarizeFastMigratableDiscovery(discovery) {
|
|
|
652
661
|
skippedActive: discovery.skippedActive,
|
|
653
662
|
codexCount: discovery.codexCount,
|
|
654
663
|
claudeCount: discovery.claudeCount,
|
|
664
|
+
pendingCodex,
|
|
665
|
+
pendingClaudeCode: discovery.pendingClaudeCode ?? discovery.pending.length - pendingCodex,
|
|
655
666
|
eta: estimateMigrationEtaFromLengths(discovery.pending.map((s) => s.size), discovery.skippedActive, {
|
|
656
667
|
secondsPerSession: measuredSecondsPerSession() ?? undefined,
|
|
657
668
|
}),
|
|
@@ -690,6 +701,7 @@ export function discoverMigratableFastDiscovery(opts = {}) {
|
|
|
690
701
|
});
|
|
691
702
|
const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
|
|
692
703
|
const codexCount = sessions.filter((s) => s.source === "codex").length;
|
|
704
|
+
const pendingCodex = pending.filter((s) => s.source === "codex").length;
|
|
693
705
|
return {
|
|
694
706
|
sessions,
|
|
695
707
|
pending,
|
|
@@ -699,6 +711,8 @@ export function discoverMigratableFastDiscovery(opts = {}) {
|
|
|
699
711
|
limited: pending.length < pendingAll.length,
|
|
700
712
|
codexCount,
|
|
701
713
|
claudeCount: sessions.length - codexCount,
|
|
714
|
+
pendingCodex,
|
|
715
|
+
pendingClaudeCode: pending.length - pendingCodex,
|
|
702
716
|
};
|
|
703
717
|
}
|
|
704
718
|
export function discoverMigratableSummaryFast(opts = {}) {
|
|
@@ -757,6 +771,7 @@ export function discoverMigratableSessions(opts = {}) {
|
|
|
757
771
|
});
|
|
758
772
|
const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
|
|
759
773
|
const codexCount = sessions.filter((s) => s.source === "codex").length;
|
|
774
|
+
const pendingCodex = pending.filter((s) => s.source === "codex").length;
|
|
760
775
|
return {
|
|
761
776
|
sessions,
|
|
762
777
|
pending,
|
|
@@ -766,6 +781,8 @@ export function discoverMigratableSessions(opts = {}) {
|
|
|
766
781
|
limited: pending.length < pendingAll.length,
|
|
767
782
|
codexCount,
|
|
768
783
|
claudeCount: sessions.length - codexCount,
|
|
784
|
+
pendingCodex,
|
|
785
|
+
pendingClaudeCode: pending.length - pendingCodex,
|
|
769
786
|
};
|
|
770
787
|
}
|
|
771
788
|
/**
|
|
@@ -787,6 +804,7 @@ export function discoverPendingSessionsTargeted(processedKeys, opts = {}) {
|
|
|
787
804
|
if (s)
|
|
788
805
|
pending.push(s);
|
|
789
806
|
}
|
|
807
|
+
const pendingCodex = pending.filter((s) => s.source === "codex").length;
|
|
790
808
|
return {
|
|
791
809
|
sessions: pending,
|
|
792
810
|
pending,
|
|
@@ -796,6 +814,8 @@ export function discoverPendingSessionsTargeted(processedKeys, opts = {}) {
|
|
|
796
814
|
limited: filtered.limited,
|
|
797
815
|
codexCount: filtered.codexCount,
|
|
798
816
|
claudeCount: filtered.claudeCount,
|
|
817
|
+
pendingCodex,
|
|
818
|
+
pendingClaudeCode: pending.length - pendingCodex,
|
|
799
819
|
accountChecked: filtered.accountChecked,
|
|
800
820
|
accountCheckFailed: filtered.accountCheckFailed,
|
|
801
821
|
accountCheckUnavailable: filtered.accountCheckUnavailable,
|
|
@@ -808,6 +828,7 @@ export function applyAccountImportStatus(discovery, processedKeys, opts = {}) {
|
|
|
808
828
|
const skippedActive = discovery.sessions.length - selectableSessions.length;
|
|
809
829
|
const pendingAll = selectableSessions.filter((s) => !processedKeys.has(s.conversationKey));
|
|
810
830
|
const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
|
|
831
|
+
const pendingCodex = pending.filter((s) => s.source === "codex").length;
|
|
811
832
|
return {
|
|
812
833
|
...discovery,
|
|
813
834
|
pending,
|
|
@@ -815,6 +836,8 @@ export function applyAccountImportStatus(discovery, processedKeys, opts = {}) {
|
|
|
815
836
|
alreadyMigrated: selectableSessions.length - pendingAll.length,
|
|
816
837
|
skippedActive,
|
|
817
838
|
limited: pending.length < pendingAll.length,
|
|
839
|
+
pendingCodex,
|
|
840
|
+
pendingClaudeCode: pending.length - pendingCodex,
|
|
818
841
|
accountChecked: true,
|
|
819
842
|
accountCheckFailed: false,
|
|
820
843
|
accountCheckUnavailable: false,
|
|
@@ -827,6 +850,7 @@ export function applyFastAccountImportStatus(discovery, processedKeys, opts = {}
|
|
|
827
850
|
const skippedActive = discovery.sessions.length - selectableSessions.length;
|
|
828
851
|
const pendingAll = selectableSessions.filter((s) => !processedKeys.has(s.conversationKey));
|
|
829
852
|
const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
|
|
853
|
+
const pendingCodex = pending.filter((s) => s.source === "codex").length;
|
|
830
854
|
return {
|
|
831
855
|
...discovery,
|
|
832
856
|
pending,
|
|
@@ -834,6 +858,8 @@ export function applyFastAccountImportStatus(discovery, processedKeys, opts = {}
|
|
|
834
858
|
alreadyMigrated: selectableSessions.length - pendingAll.length,
|
|
835
859
|
skippedActive,
|
|
836
860
|
limited: pending.length < pendingAll.length,
|
|
861
|
+
pendingCodex,
|
|
862
|
+
pendingClaudeCode: pending.length - pendingCodex,
|
|
837
863
|
accountChecked: true,
|
|
838
864
|
accountCheckFailed: false,
|
|
839
865
|
accountCheckUnavailable: false,
|
|
@@ -1226,7 +1252,7 @@ export async function cmdMigrate(flags) {
|
|
|
1226
1252
|
: "This account is ENCRYPTED but the vault is locked. Run `echomem-mcp unlock`, then re-run migrate.");
|
|
1227
1253
|
}
|
|
1228
1254
|
else if (code === "FORBIDDEN_SCOPE") {
|
|
1229
|
-
console.error("This device token cannot import history. Re-connect this device with `echomem-mcp
|
|
1255
|
+
console.error("This device token cannot import history. Re-connect this device with `echomem-mcp login`.");
|
|
1230
1256
|
}
|
|
1231
1257
|
else {
|
|
1232
1258
|
console.error(c.red(`Could not start the import: ${responseMessage(e)}`));
|