@echomem/mcp 1.4.47 → 1.4.48
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 +22 -26
- package/dist/context-analysis/claude-native-canonical.js +2 -2
- package/dist/context-analysis/vendored-canonical.js +2 -2
- package/dist/context-analysis/workspace-report.js +3 -3
- package/dist/index.js +5 -8
- package/dist/local-jsonl.js +87 -0
- package/dist/migrate.js +6 -4
- package/dist/onboarding-stats.js +16 -0
- package/dist/save-checkpoint-hook.js +1 -1
- package/dist/setup-page/client-core.js +18 -372
- package/dist/setup-page/client-extraction.js +14 -129
- package/dist/setup-page/client-lifecycle.js +28 -101
- package/dist/setup-page/client.js +0 -2
- package/dist/setup-page/styles-extraction.js +1 -31
- package/dist/setup-page/styles-foundation.js +0 -89
- package/dist/setup-page/styles-mvp.js +10 -155
- package/dist/setup-page/styles-website-alignment.js +0 -204
- package/dist/setup-page/styles.js +0 -4
- package/dist/setup-page.js +4 -4
- package/dist/setup-preview.js +4 -212
- package/dist/setup.js +94 -580
- package/dist/v1-contract.js +0 -8
- package/package.json +5 -7
- package/dist/city/README.md +0 -9
- package/dist/city/echo-ai-city-only.html +0 -2232
- package/dist/city/echo-extraction-plate.html +0 -330
- package/dist/city/echo-face-cutout.png +0 -0
- package/dist/city/personality_stickers/bossy.png +0 -0
- package/dist/city/personality_stickers/ghosty.png +0 -0
- package/dist/city/personality_stickers/loopy.png +0 -0
- package/dist/city/personality_stickers/lusty.png +0 -0
- package/dist/city/personality_stickers/maxxy.png +0 -0
- package/dist/city/personality_stickers/tabby.png +0 -0
- package/dist/city/vendor/OrbitControls.js +0 -1417
- package/dist/city/vendor/RoundedBoxGeometry.js +0 -155
- package/dist/city/vendor/echo_general-file-21.riv +0 -0
- package/dist/city/vendor/rive.js +0 -8139
- package/dist/city/vendor/rive.wasm +0 -0
- package/dist/city/vendor/three.module.min.js +0 -6
- package/dist/forensics.js +0 -1531
- package/dist/report.js +0 -721
- package/dist/setup-page/client-report-audit.js +0 -819
- package/dist/setup-page/client-report-city.js +0 -356
- package/dist/setup-page/client-report.js +0 -6
- package/dist/setup-page/styles-city-report.js +0 -880
- package/dist/setup-page/styles-context-audit.js +0 -470
package/dist/setup.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Design goals from the spec:
|
|
5
5
|
* - `login` establishes the account and trusted device only; it never reads local history.
|
|
6
|
-
* - `init` runs one ordered flow: local-history permission
|
|
6
|
+
* - `init` runs one ordered flow: local-history permission, login, plan if needed, then extraction.
|
|
7
7
|
* - Both secrets (API token + encryption key) ride a single browser flow and land in the local
|
|
8
8
|
* keystore — never in the client's MCP config, never in the agent's chat context.
|
|
9
9
|
* - Re-unlock after the key's TTL is one step, not a re-setup.
|
|
@@ -25,12 +25,11 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
25
25
|
import axios from "axios";
|
|
26
26
|
import { KeyStore } from "./keystore.js";
|
|
27
27
|
import { fetchEncryptionConfig, deriveAndVerifyKey, setupNewEncryptionKey, verifyKeyB64 } from "./encryption.js";
|
|
28
|
-
import {
|
|
28
|
+
import { buildOnboardingStatsPayload } from "./onboarding-stats.js";
|
|
29
29
|
import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableSessions, discoverPendingSessionsTargeted, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, MIGRATE_CONCURRENCY, } from "./migrate.js";
|
|
30
30
|
import { syncCodexUsage } from "./codex-sync.js";
|
|
31
31
|
import { renderSetupPage } from "./setup-page.js";
|
|
32
32
|
import { parseSetupPreviewState } from "./setup-preview.js";
|
|
33
|
-
import { repoLabel, validateForensicReportForSetup } from "./forensics.js";
|
|
34
33
|
import { installSaveCheckpointHooks, installSourceSessionHooks } from "./hud/hooks.js";
|
|
35
34
|
import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
|
|
36
35
|
import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
|
|
@@ -70,15 +69,25 @@ const CODEX_SKILL_NAMES = [
|
|
|
70
69
|
function home(...p) {
|
|
71
70
|
return path.join(os.homedir(), ...p);
|
|
72
71
|
}
|
|
73
|
-
function
|
|
74
|
-
const configured = process.env
|
|
72
|
+
function configuredProfileDirectory(envKey, fallbackName) {
|
|
73
|
+
const configured = process.env[envKey]?.trim();
|
|
75
74
|
if (!configured)
|
|
76
|
-
return home(
|
|
75
|
+
return home(fallbackName);
|
|
77
76
|
if (configured === "~")
|
|
78
77
|
return os.homedir();
|
|
79
|
-
if (configured.startsWith(
|
|
78
|
+
if (configured.startsWith("~/") || configured.startsWith("~\\")) {
|
|
80
79
|
return path.join(os.homedir(), configured.slice(2));
|
|
81
|
-
|
|
80
|
+
}
|
|
81
|
+
if (!path.isAbsolute(configured)) {
|
|
82
|
+
throw new Error(`${envKey} must be an absolute path or start with ~/`);
|
|
83
|
+
}
|
|
84
|
+
return path.normalize(configured);
|
|
85
|
+
}
|
|
86
|
+
function codexHome() {
|
|
87
|
+
return configuredProfileDirectory("CODEX_HOME", ".codex");
|
|
88
|
+
}
|
|
89
|
+
function claudeConfigHome() {
|
|
90
|
+
return configuredProfileDirectory("CLAUDE_CONFIG_DIR", ".claude");
|
|
82
91
|
}
|
|
83
92
|
function filesEqual(left, right) {
|
|
84
93
|
try {
|
|
@@ -401,7 +410,7 @@ function echomemGuidanceBlock() {
|
|
|
401
410
|
"- EchoMem's local SessionStart hook automatically binds each new Codex or Claude Code conversation to its real source-session ID. Use `bind_source_session` with a fresh UUID only as a compatibility fallback when the hook is unavailable or reports that the session is unbound.",
|
|
402
411
|
"- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
|
|
403
412
|
'- If the final user-facing answer materially relies on one or more EchoMem memories, end it with a compact `EchoMem sources:` list containing only the memories actually used. Link each memory key to its canonical `https://echoknows.com/memory/<memory-id>` URL. Do not cite memories that were merely retrieved, and omit the section when no memory informed the answer.',
|
|
404
|
-
"- Before the final response for a task that produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone: call `save_conversation`. This private checkpoint does not require a separate user request. Do not save secrets, credentials, incidental chatter, or trivial read-only work. If a qualifying save fails because the encrypted vault is locked,
|
|
413
|
+
"- Before the final response for a task that produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone: call `save_conversation`. This private checkpoint does not require a separate user request. Do not save secrets, credentials, incidental chatter, or trivial read-only work. If a qualifying save fails because the encrypted vault is locked, relay the bridge's unlock instruction; for this standalone runtime, use `echomem-mcp unlock`. Never silently skip it.",
|
|
405
414
|
'- After `save_conversation` succeeds and returns one or more saved memory IDs, make the save visible in the final user-facing answer with a compact `EchoMem saved:` list containing every memory created by that call. Link each memory key to its canonical `https://echoknows.com/memory/<memory-id>` URL. This save receipt is separate from `EchoMem sources:` and does not imply the newly saved memories informed the answer.',
|
|
406
415
|
"- For a user with a company group, call `request_group_session_sharing` near conversation start or after a qualifying private save. On the first call in a conversation, omit `groupSharingScopeId` so EchoMem mints a fresh opaque scope. Reuse that exact scope only in this conversation for later get/request/set/save calls, and never persist it as memory. If multiple groups are returned, reuse the same scope and call once per `groupId`. Relay the tool's exact text question and call `set_group_session_sharing` only after an explicit Yes/No. Silence leaves that group's state unset; never infer an answer. Saves sync eligible memories to every approved group; a No keeps them private for that group.",
|
|
407
416
|
"- If the user pastes a session carryover/checkpoint: it may reference `get_checkpoint_by_context` — use it to pull the checkpoint/decision trail when you need more than the snapshot.",
|
|
@@ -412,7 +421,7 @@ function echomemGuidanceBlock() {
|
|
|
412
421
|
"- Group sharing is scoped to an opaque id carried only in the current conversation, not to the MCP transport session. Membership is rechecked for each sync. Flagged memories are withheld from automatic conversation sync and remain private.",
|
|
413
422
|
"- If a user asks to create a group, call `create_memory_group`; if they ask for a code to share, call `create_group_invite` and return the secret invite code only to that user. Never save the invite code to memory or include it in logs, analytics, summaries, or unrelated output.",
|
|
414
423
|
"- If a user supplies an `echo_grp_...` code and explicitly asks to join, call `join_memory_group`. Joining never authorizes publishing by itself and must not move a user out of another group. After joining, continue into the profile-and-publication preview instead of leaving title or responsibility blank.",
|
|
415
|
-
"- For requests such as “prepare my recent work memories,” “upload work from this ticket,” or “publish work since my last sync,” call `prepare_group_publication` first. This is a no-publication preview. For encrypted accounts,
|
|
424
|
+
"- For requests such as “prepare my recent work memories,” “upload work from this ticket,” or “publish work since my last sync,” call `prepare_group_publication` first. This is a no-publication preview. For encrypted accounts, relay the bridge's local unlock instruction if the tool reports that the key is required.",
|
|
416
425
|
"- After preparing, select only exact candidate memory IDs that match the user's stated scope and exclude already-published or exact-content duplicates. Use the candidate evidence to draft a concise title and responsibility summary for the current member, but label both as proposals rather than facts.",
|
|
417
426
|
"- Use one canonical evidence link for every memory: preserve the Memory ID and link to `https://echoknows.com/memory/<memory-id>`. The site resolves the authorized representation: an owner is sent to their private timeline, while current group/friend access opens an authorized publication snapshot or public memory. The visible Markdown label should use the memory key, not the raw URL or UUID.",
|
|
418
427
|
"- If the user asks to flag memories about a sensitive topic, search their own memories first, show the exact matches with owner-only personal links, and ask them to confirm. Only then call `flag_memories_for_publication_attention`; flagging does not publish, decrypt, change visibility, or retract an existing group snapshot.",
|
|
@@ -444,6 +453,35 @@ export function writeAgentsMemoryGuidance(filePath) {
|
|
|
444
453
|
fs.appendFileSync(filePath, sep + block + "\n");
|
|
445
454
|
return "wrote";
|
|
446
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* Refresh marker-owned guidance for users upgrading an existing standalone MCP install.
|
|
458
|
+
* This intentionally does not create global memory files: setup owns first installation,
|
|
459
|
+
* while server startup only migrates guidance that EchoMem already installed.
|
|
460
|
+
*/
|
|
461
|
+
export function refreshInstalledMemoryGuidance() {
|
|
462
|
+
let candidates;
|
|
463
|
+
try {
|
|
464
|
+
candidates = [
|
|
465
|
+
path.join(codexHome(), "AGENTS.md"),
|
|
466
|
+
path.join(claudeConfigHome(), "CLAUDE.md"),
|
|
467
|
+
];
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
for (const filePath of candidates) {
|
|
473
|
+
try {
|
|
474
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
475
|
+
const start = content.indexOf(AGENTS_MD_BEGIN);
|
|
476
|
+
const end = content.indexOf(AGENTS_MD_END);
|
|
477
|
+
if (start >= 0 && end > start)
|
|
478
|
+
writeAgentsMemoryGuidance(filePath);
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
// Missing, unreadable, or externally managed files are left untouched.
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
447
485
|
/** Merge the EchoMem entry into a JSON client's `mcpServers` map without clobbering siblings. */
|
|
448
486
|
export function writeJsonClientConfig(configPath, entry, options = {}) {
|
|
449
487
|
let config = {};
|
|
@@ -1229,7 +1267,7 @@ function withTimeout(promise, ms, code, onTimeout) {
|
|
|
1229
1267
|
function delay(ms) {
|
|
1230
1268
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1231
1269
|
}
|
|
1232
|
-
const
|
|
1270
|
+
const LOCAL_ASSET_TYPES = {
|
|
1233
1271
|
".html": "text/html; charset=utf-8",
|
|
1234
1272
|
".js": "text/javascript; charset=utf-8",
|
|
1235
1273
|
".json": "application/json; charset=utf-8",
|
|
@@ -1238,39 +1276,11 @@ const CITY_ASSET_TYPES = {
|
|
|
1238
1276
|
".png": "image/png",
|
|
1239
1277
|
".svg": "image/svg+xml",
|
|
1240
1278
|
};
|
|
1241
|
-
function
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
// Published install: fall back to the copy bundled into dist/city by prepack (bundle-city.mjs).
|
|
1247
|
-
return fileURLToPath(new URL("./city/", import.meta.url));
|
|
1248
|
-
}
|
|
1249
|
-
function serveRepoCityAsset(reqPath, res) {
|
|
1250
|
-
const root = repoCityArtifactsRoot();
|
|
1251
|
-
const rel = reqPath === "/city" || reqPath === "/city/" ? "echo-ai-city-only.html" : decodeURIComponent(reqPath.slice("/city/".length));
|
|
1252
|
-
// Archives stay in the checkout for recovery, but must never become a localhost UI surface.
|
|
1253
|
-
const normalizedRel = rel.replace(/\\/g, "/");
|
|
1254
|
-
if (normalizedRel === "archive" || normalizedRel.startsWith("archive/")) {
|
|
1255
|
-
res.writeHead(404).end("not found");
|
|
1256
|
-
return true;
|
|
1257
|
-
}
|
|
1258
|
-
const filePath = path.resolve(root, rel);
|
|
1259
|
-
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
|
|
1260
|
-
if (!filePath.startsWith(rootWithSep)) {
|
|
1261
|
-
res.writeHead(403).end("forbidden");
|
|
1262
|
-
return true;
|
|
1263
|
-
}
|
|
1264
|
-
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
1265
|
-
res.writeHead(404).end("not found");
|
|
1266
|
-
return true;
|
|
1267
|
-
}
|
|
1268
|
-
res.writeHead(200, {
|
|
1269
|
-
"Content-Type": CITY_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
|
|
1270
|
-
"Cache-Control": "no-store",
|
|
1271
|
-
});
|
|
1272
|
-
fs.createReadStream(filePath).pipe(res);
|
|
1273
|
-
return true;
|
|
1279
|
+
function repoLabel(cwd) {
|
|
1280
|
+
if (!cwd)
|
|
1281
|
+
return "";
|
|
1282
|
+
const normalized = cwd.replace(/[\\/]+$/, "");
|
|
1283
|
+
return path.basename(normalized) || normalized;
|
|
1274
1284
|
}
|
|
1275
1285
|
function hudAssetsRoot() {
|
|
1276
1286
|
return fileURLToPath(new URL("../assets/hud/", import.meta.url));
|
|
@@ -1289,7 +1299,7 @@ function serveHudAsset(reqPath, res) {
|
|
|
1289
1299
|
return true;
|
|
1290
1300
|
}
|
|
1291
1301
|
res.writeHead(200, {
|
|
1292
|
-
"Content-Type":
|
|
1302
|
+
"Content-Type": LOCAL_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
|
|
1293
1303
|
"Cache-Control": "no-store",
|
|
1294
1304
|
});
|
|
1295
1305
|
fs.createReadStream(filePath).pipe(res);
|
|
@@ -1466,61 +1476,6 @@ export function discoverMigratableFastOffThread(opts = {}) {
|
|
|
1466
1476
|
});
|
|
1467
1477
|
});
|
|
1468
1478
|
}
|
|
1469
|
-
/** Build the full local-history dashboard payload away from the callback server's event loop.
|
|
1470
|
-
* `collect()` can synchronously parse hundreds of JSONL files for tens of seconds; doing that on
|
|
1471
|
-
* the bridge thread prevents even localhost actions such as account switch from receiving a reply. */
|
|
1472
|
-
export function buildCollectedStatsPayloadOffThread(inject) {
|
|
1473
|
-
const reportUrl = runtimeModuleUrl("report");
|
|
1474
|
-
const serializedInject = JSON.stringify(inject);
|
|
1475
|
-
const code = `
|
|
1476
|
-
import { parentPort } from "node:worker_threads";
|
|
1477
|
-
import { collect, buildStatsPayload } from ${JSON.stringify(reportUrl)};
|
|
1478
|
-
|
|
1479
|
-
try {
|
|
1480
|
-
const payload = await buildStatsPayload(collect(), ${serializedInject});
|
|
1481
|
-
parentPort?.postMessage({ ok: true, payload });
|
|
1482
|
-
} catch (error) {
|
|
1483
|
-
parentPort?.postMessage({
|
|
1484
|
-
ok: false,
|
|
1485
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1486
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1487
|
-
});
|
|
1488
|
-
}
|
|
1489
|
-
`;
|
|
1490
|
-
const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
|
|
1491
|
-
return new Promise((resolve, reject) => {
|
|
1492
|
-
let settled = false;
|
|
1493
|
-
const finish = (result) => {
|
|
1494
|
-
if (settled)
|
|
1495
|
-
return;
|
|
1496
|
-
settled = true;
|
|
1497
|
-
void worker.terminate();
|
|
1498
|
-
if (result.ok)
|
|
1499
|
-
resolve(result.payload);
|
|
1500
|
-
else
|
|
1501
|
-
reject(result.error);
|
|
1502
|
-
};
|
|
1503
|
-
worker.once("message", (message) => {
|
|
1504
|
-
const msg = message;
|
|
1505
|
-
if (msg.ok === true) {
|
|
1506
|
-
finish({ ok: true, payload: msg.payload });
|
|
1507
|
-
return;
|
|
1508
|
-
}
|
|
1509
|
-
const error = new Error(typeof msg.message === "string" ? msg.message : "Full local stats worker failed");
|
|
1510
|
-
if (typeof msg.stack === "string")
|
|
1511
|
-
error.stack = msg.stack;
|
|
1512
|
-
finish({ ok: false, error });
|
|
1513
|
-
});
|
|
1514
|
-
worker.once("error", (error) => {
|
|
1515
|
-
finish({ ok: false, error });
|
|
1516
|
-
});
|
|
1517
|
-
worker.once("exit", (code) => {
|
|
1518
|
-
if (settled)
|
|
1519
|
-
return;
|
|
1520
|
-
finish({ ok: false, error: new Error(`Full local stats worker exited (code ${code}) without a result`) });
|
|
1521
|
-
});
|
|
1522
|
-
});
|
|
1523
|
-
}
|
|
1524
1479
|
export function createLocalDiscoveryCache(loaders = {}) {
|
|
1525
1480
|
const loadQuick = loaders.loadQuick ?? (() => discoverMigratableFastOffThread());
|
|
1526
1481
|
const loadExact = loaders.loadExact ?? (() => discoverMigratableSessionsOffThread());
|
|
@@ -1569,45 +1524,6 @@ export function createLocalDiscoveryCache(loaders = {}) {
|
|
|
1569
1524
|
},
|
|
1570
1525
|
};
|
|
1571
1526
|
}
|
|
1572
|
-
function forensicStageLabel(stage) {
|
|
1573
|
-
if (stage === "reading-transcripts")
|
|
1574
|
-
return "Reading transcript files";
|
|
1575
|
-
if (stage === "building-summary")
|
|
1576
|
-
return "Building scan summary";
|
|
1577
|
-
if (stage === "classifying-repeated-context")
|
|
1578
|
-
return "Classifying repeated context";
|
|
1579
|
-
if (stage === "finalizing-report")
|
|
1580
|
-
return "Finalizing report";
|
|
1581
|
-
return "Starting local scan";
|
|
1582
|
-
}
|
|
1583
|
-
/** Build the local forensic "Context Doctor" report on a worker thread so the multi-file scan never
|
|
1584
|
-
* blocks the bridge's event loop (the freeze postmortem: any unbounded sync work on this path is risky). */
|
|
1585
|
-
export function buildForensicReportOffThread(onProgress, options = {}) {
|
|
1586
|
-
let lastProgress = null;
|
|
1587
|
-
const recordProgress = (progress) => {
|
|
1588
|
-
lastProgress = progress;
|
|
1589
|
-
onProgress?.(progress);
|
|
1590
|
-
};
|
|
1591
|
-
return runForensicReportWorker(recordProgress, options).catch(async (primaryError) => {
|
|
1592
|
-
if (options.failOpen === false)
|
|
1593
|
-
throw primaryError;
|
|
1594
|
-
const failureCode = errorCode(primaryError) || "REPORT_BUILD_FAILED";
|
|
1595
|
-
console.error(`[echomem] local scan degraded after ${failureCode}; continuing without local-history analysis`);
|
|
1596
|
-
onProgress?.({
|
|
1597
|
-
done: lastProgress?.done || 0,
|
|
1598
|
-
total: lastProgress?.total || 0,
|
|
1599
|
-
stage: "finalizing-report",
|
|
1600
|
-
detail: "finishing setup without optional local-history analysis",
|
|
1601
|
-
overall: 0.99,
|
|
1602
|
-
stageDone: 0,
|
|
1603
|
-
stageTotal: 0,
|
|
1604
|
-
});
|
|
1605
|
-
return runForensicReportWorker(undefined, {
|
|
1606
|
-
timeoutMs: 30_000,
|
|
1607
|
-
maxOldGenerationSizeMb: Math.max(64, options.maxOldGenerationSizeMb || 0),
|
|
1608
|
-
}, [], failureCode);
|
|
1609
|
-
});
|
|
1610
|
-
}
|
|
1611
1527
|
function errorCode(error) {
|
|
1612
1528
|
return error && typeof error === "object" && "code" in error
|
|
1613
1529
|
? String(error.code || "")
|
|
@@ -1627,7 +1543,6 @@ export function completeOptionalStatsPayload(payload, reason, countsTrusted) {
|
|
|
1627
1543
|
transcriptsUploaded: false,
|
|
1628
1544
|
sessions: { total: 0, codex: 0, claudeCode: 0, cowork: 0 },
|
|
1629
1545
|
migratable: { pending: 0, alreadyMigrated: 0 },
|
|
1630
|
-
memoriesCaptured: null,
|
|
1631
1546
|
};
|
|
1632
1547
|
const completed = payload && typeof payload === "object" && !Array.isArray(payload)
|
|
1633
1548
|
? { ...payload }
|
|
@@ -1640,155 +1555,11 @@ export function completeOptionalStatsPayload(payload, reason, countsTrusted) {
|
|
|
1640
1555
|
};
|
|
1641
1556
|
return completed;
|
|
1642
1557
|
}
|
|
1643
|
-
function runForensicReportWorker(onProgress, options, sources, degradedReason) {
|
|
1644
|
-
const forensicsUrl = runtimeModuleUrl("forensics");
|
|
1645
|
-
const serializedSources = sources === undefined ? "undefined" : JSON.stringify(sources);
|
|
1646
|
-
const serializedDegradedReason = JSON.stringify(degradedReason || "");
|
|
1647
|
-
const code = `
|
|
1648
|
-
import { parentPort } from "node:worker_threads";
|
|
1649
|
-
import { buildForensicReport, validateForensicReportForSetup } from ${JSON.stringify(forensicsUrl)};
|
|
1650
|
-
try {
|
|
1651
|
-
const report = await buildForensicReport({
|
|
1652
|
-
sources: ${serializedSources},
|
|
1653
|
-
includeLegacyGoldenStandard: false,
|
|
1654
|
-
onProgress: (done, total, stage, detail, overall, stageDone, stageTotal) => parentPort?.postMessage({
|
|
1655
|
-
progress: { done, total, stage, detail, overall, stageDone, stageTotal },
|
|
1656
|
-
}),
|
|
1657
|
-
});
|
|
1658
|
-
const degradedReason = ${serializedDegradedReason};
|
|
1659
|
-
if (degradedReason) {
|
|
1660
|
-
report.scanDiagnostics = {
|
|
1661
|
-
degraded: true,
|
|
1662
|
-
reason: degradedReason,
|
|
1663
|
-
skippedSources: ["codex", "claude"],
|
|
1664
|
-
};
|
|
1665
|
-
}
|
|
1666
|
-
const validation = validateForensicReportForSetup(report);
|
|
1667
|
-
if (!validation.ok) {
|
|
1668
|
-
const error = new Error(validation.message);
|
|
1669
|
-
error.code = validation.code;
|
|
1670
|
-
throw error;
|
|
1671
|
-
}
|
|
1672
|
-
parentPort?.postMessage({ ok: true, report });
|
|
1673
|
-
} catch (error) {
|
|
1674
|
-
parentPort?.postMessage({
|
|
1675
|
-
ok: false,
|
|
1676
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1677
|
-
code: error && typeof error === "object" && "code" in error ? String(error.code || "") : "",
|
|
1678
|
-
});
|
|
1679
|
-
}
|
|
1680
|
-
`;
|
|
1681
|
-
const requestedHeapMb = options.maxOldGenerationSizeMb;
|
|
1682
|
-
const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`), Number.isFinite(requestedHeapMb)
|
|
1683
|
-
? { resourceLimits: { maxOldGenerationSizeMb: Math.max(16, Math.floor(requestedHeapMb)) } }
|
|
1684
|
-
: undefined);
|
|
1685
|
-
return new Promise((resolve, reject) => {
|
|
1686
|
-
let settled = false;
|
|
1687
|
-
const requestedTimeoutMs = options.timeoutMs ?? 15 * 60_000;
|
|
1688
|
-
const timeoutMs = Number.isFinite(requestedTimeoutMs) ? Math.max(1, requestedTimeoutMs) : 15 * 60_000;
|
|
1689
|
-
const timeout = setTimeout(() => {
|
|
1690
|
-
if (settled)
|
|
1691
|
-
return;
|
|
1692
|
-
settled = true;
|
|
1693
|
-
void worker.terminate();
|
|
1694
|
-
const error = new Error(`Local forensic report timed out after ${timeoutMs}ms`);
|
|
1695
|
-
error.code = "REPORT_SCAN_TIMEOUT";
|
|
1696
|
-
reject(error);
|
|
1697
|
-
}, timeoutMs);
|
|
1698
|
-
timeout.unref?.();
|
|
1699
|
-
const finish = (result) => {
|
|
1700
|
-
if (settled)
|
|
1701
|
-
return;
|
|
1702
|
-
settled = true;
|
|
1703
|
-
clearTimeout(timeout);
|
|
1704
|
-
void worker.terminate();
|
|
1705
|
-
if (result.ok)
|
|
1706
|
-
resolve(result.report);
|
|
1707
|
-
else
|
|
1708
|
-
reject(result.error);
|
|
1709
|
-
};
|
|
1710
|
-
worker.on("message", (message) => {
|
|
1711
|
-
if (settled)
|
|
1712
|
-
return;
|
|
1713
|
-
const msg = message;
|
|
1714
|
-
if (msg.progress) {
|
|
1715
|
-
onProgress?.(msg.progress);
|
|
1716
|
-
return;
|
|
1717
|
-
}
|
|
1718
|
-
if (msg.ok === true && msg.report && typeof msg.report === "object") {
|
|
1719
|
-
finish({ ok: true, report: msg.report });
|
|
1720
|
-
return;
|
|
1721
|
-
}
|
|
1722
|
-
const error = new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed");
|
|
1723
|
-
if (typeof msg.code === "string" && msg.code)
|
|
1724
|
-
error.code = msg.code;
|
|
1725
|
-
finish({ ok: false, error });
|
|
1726
|
-
});
|
|
1727
|
-
worker.once("error", (error) => {
|
|
1728
|
-
finish({ ok: false, error });
|
|
1729
|
-
});
|
|
1730
|
-
worker.once("exit", (code) => {
|
|
1731
|
-
if (settled)
|
|
1732
|
-
return;
|
|
1733
|
-
finish({ ok: false, error: new Error(`Forensic report worker exited (code ${code}) without a result`) });
|
|
1734
|
-
});
|
|
1735
|
-
});
|
|
1736
|
-
}
|
|
1737
1558
|
export function respondMigrate(res, body, status = 200) {
|
|
1738
1559
|
if (res.writableEnded)
|
|
1739
1560
|
return;
|
|
1740
1561
|
res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
|
|
1741
1562
|
}
|
|
1742
|
-
function safeForensicError(error) {
|
|
1743
|
-
const code = error && typeof error === "object" && "code" in error
|
|
1744
|
-
? String(error.code || "")
|
|
1745
|
-
: "";
|
|
1746
|
-
if (code === "REPORT_SCAN_TIMEOUT") {
|
|
1747
|
-
return {
|
|
1748
|
-
code,
|
|
1749
|
-
message: "The local workspace scan took too long and was stopped. No backup data was substituted. Rerun setup to retry.",
|
|
1750
|
-
};
|
|
1751
|
-
}
|
|
1752
|
-
return {
|
|
1753
|
-
code: "REPORT_BUILD_FAILED",
|
|
1754
|
-
message: "EchoMem could not finish the local workspace scan. No backup data was substituted. Rerun setup to retry.",
|
|
1755
|
-
};
|
|
1756
|
-
}
|
|
1757
|
-
function publicRunningForensicProgress(value) {
|
|
1758
|
-
if (!value || typeof value !== "object")
|
|
1759
|
-
return null;
|
|
1760
|
-
const progress = value;
|
|
1761
|
-
if (progress.status !== "running")
|
|
1762
|
-
return null;
|
|
1763
|
-
const safeCount = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
|
|
1764
|
-
? Math.floor(candidate)
|
|
1765
|
-
: 0);
|
|
1766
|
-
const safeDuration = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
|
|
1767
|
-
? candidate
|
|
1768
|
-
: 0);
|
|
1769
|
-
const safeFraction = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate)
|
|
1770
|
-
? Math.min(1, Math.max(0, candidate))
|
|
1771
|
-
: 0);
|
|
1772
|
-
const total = safeCount(progress.total);
|
|
1773
|
-
const stageTotal = safeCount(progress.stageTotal);
|
|
1774
|
-
const rawStage = typeof progress.stage === "string" ? progress.stage : "starting";
|
|
1775
|
-
const stage = ["starting", "reading-transcripts", "building-summary", "classifying-repeated-context", "finalizing-report"].includes(rawStage)
|
|
1776
|
-
? rawStage
|
|
1777
|
-
: "starting";
|
|
1778
|
-
return {
|
|
1779
|
-
status: "running",
|
|
1780
|
-
scanned: total > 0 ? Math.min(safeCount(progress.scanned), total) : 0,
|
|
1781
|
-
total,
|
|
1782
|
-
stage,
|
|
1783
|
-
label: forensicStageLabel(stage),
|
|
1784
|
-
stageDone: stageTotal > 0 ? Math.min(safeCount(progress.stageDone), stageTotal) : 0,
|
|
1785
|
-
stageTotal,
|
|
1786
|
-
overall: safeFraction(progress.overall),
|
|
1787
|
-
elapsedMs: safeDuration(progress.elapsedMs),
|
|
1788
|
-
stageElapsedMs: safeDuration(progress.stageElapsedMs),
|
|
1789
|
-
updatedAt: safeDuration(progress.updatedAt) || Date.now(),
|
|
1790
|
-
};
|
|
1791
|
-
}
|
|
1792
1563
|
/**
|
|
1793
1564
|
* Start the persistent localhost bridge used by the setup page. It sends/verifies OTP through the
|
|
1794
1565
|
* hosted API, accepts the local passphrase, serves local Wrapped stats, and holds the /migrate
|
|
@@ -1801,12 +1572,11 @@ export function startCallbackServer(opts = {}) {
|
|
|
1801
1572
|
: `${Math.ceil(timeoutMs / 1000)} seconds`;
|
|
1802
1573
|
const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
|
|
1803
1574
|
const expectedNonce = opts.nonce;
|
|
1804
|
-
const scanId = opts.scanId ?? randomUUID();
|
|
1805
1575
|
const flow = opts.flow ?? "onboarding";
|
|
1806
1576
|
const isLoginFlow = flow === "login";
|
|
1807
1577
|
// A login screen must not be blocked by a local-history permission. That permission belongs to
|
|
1808
1578
|
// onboarding and is intentionally enforced separately below.
|
|
1809
|
-
const
|
|
1579
|
+
const requiresLocalHistoryConsent = opts.requireLocalHistoryConsent === true && !isLoginFlow;
|
|
1810
1580
|
return new Promise((resolveOuter, rejectOuter) => {
|
|
1811
1581
|
const onToken = deferred();
|
|
1812
1582
|
const setupExit = deferred();
|
|
@@ -1819,7 +1589,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1819
1589
|
let activeDeviceToken = opts.initialToken?.token || "";
|
|
1820
1590
|
let activeAccountEmail = "";
|
|
1821
1591
|
let pendingLocalAuth = null;
|
|
1822
|
-
let
|
|
1592
|
+
let localHistoryConsentGranted = !requiresLocalHistoryConsent;
|
|
1823
1593
|
let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
|
|
1824
1594
|
let migrateStarted = false;
|
|
1825
1595
|
let tokenRefreshHandler = null;
|
|
@@ -1886,7 +1656,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1886
1656
|
const handleCallback = (res, token, key, nonce) => {
|
|
1887
1657
|
if (!checkNonce(nonce))
|
|
1888
1658
|
return void text(res, 403, "bad nonce");
|
|
1889
|
-
if (
|
|
1659
|
+
if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
|
|
1890
1660
|
return void json(res, 403, {
|
|
1891
1661
|
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
1892
1662
|
message: "Allow local history access in the setup page before connecting EchoMem.",
|
|
@@ -1916,7 +1686,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1916
1686
|
text(res, 403, "bad nonce");
|
|
1917
1687
|
return true;
|
|
1918
1688
|
}
|
|
1919
|
-
if (
|
|
1689
|
+
if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
|
|
1920
1690
|
json(res, 403, {
|
|
1921
1691
|
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
1922
1692
|
message: "Allow local history access in the setup page before connecting EchoMem.",
|
|
@@ -1943,8 +1713,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1943
1713
|
armTimeout();
|
|
1944
1714
|
};
|
|
1945
1715
|
const isOnboardingOnlyRoute = (route) => [
|
|
1946
|
-
"/
|
|
1947
|
-
"/report",
|
|
1716
|
+
"/local-history-consent",
|
|
1948
1717
|
"/stats",
|
|
1949
1718
|
"/billing-status",
|
|
1950
1719
|
"/billing-checkout",
|
|
@@ -2137,10 +1906,6 @@ export function startCallbackServer(opts = {}) {
|
|
|
2137
1906
|
message: "Run `echomem-mcp init` to access local-history onboarding.",
|
|
2138
1907
|
});
|
|
2139
1908
|
}
|
|
2140
|
-
if ((route === "/city" || route.startsWith("/city/")) && req.method === "GET") {
|
|
2141
|
-
serveRepoCityAsset(route, res);
|
|
2142
|
-
return;
|
|
2143
|
-
}
|
|
2144
1909
|
if (route.startsWith("/hud-assets/") && req.method === "GET") {
|
|
2145
1910
|
serveHudAsset(route, res);
|
|
2146
1911
|
return;
|
|
@@ -2178,8 +1943,13 @@ export function startCallbackServer(opts = {}) {
|
|
|
2178
1943
|
localOnly: true,
|
|
2179
1944
|
localAuth: true,
|
|
2180
1945
|
workspacePath: process.cwd(),
|
|
2181
|
-
consentRequired:
|
|
2182
|
-
consentGranted:
|
|
1946
|
+
consentRequired: requiresLocalHistoryConsent,
|
|
1947
|
+
consentGranted: localHistoryConsentGranted,
|
|
1948
|
+
platform: process.platform,
|
|
1949
|
+
capabilities: {
|
|
1950
|
+
openClaudeDesktop: process.platform === "darwin" || process.platform === "win32",
|
|
1951
|
+
openAgentSessions: process.platform === "darwin" || process.platform === "win32",
|
|
1952
|
+
},
|
|
2183
1953
|
});
|
|
2184
1954
|
return;
|
|
2185
1955
|
}
|
|
@@ -2283,7 +2053,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
2283
2053
|
if (route === "/stats" && req.method === "GET") {
|
|
2284
2054
|
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
2285
2055
|
return void text(res, 403, "bad nonce");
|
|
2286
|
-
if (
|
|
2056
|
+
if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
|
|
2287
2057
|
return void json(res, 403, {
|
|
2288
2058
|
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
2289
2059
|
message: "Allow local history access before continuing setup.",
|
|
@@ -2299,7 +2069,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
2299
2069
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
2300
2070
|
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
2301
2071
|
return void text(res, 403, "bad nonce");
|
|
2302
|
-
if (
|
|
2072
|
+
if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
|
|
2303
2073
|
return void json(res, 403, {
|
|
2304
2074
|
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
2305
2075
|
message: "Allow local history access before continuing setup.",
|
|
@@ -2423,7 +2193,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
2423
2193
|
}
|
|
2424
2194
|
if (!checkNonce(asString(body.nonce)))
|
|
2425
2195
|
return void text(res, 403, "bad nonce");
|
|
2426
|
-
if (
|
|
2196
|
+
if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
|
|
2427
2197
|
return void json(res, 403, {
|
|
2428
2198
|
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
2429
2199
|
message: "Allow local history access before managing an onboarding plan.",
|
|
@@ -2489,114 +2259,10 @@ export function startCallbackServer(opts = {}) {
|
|
|
2489
2259
|
}
|
|
2490
2260
|
return;
|
|
2491
2261
|
}
|
|
2492
|
-
if (route === "/report" && req.method === "GET") {
|
|
2493
|
-
// Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
|
|
2494
|
-
res.setHeader("Cache-Control", "no-store");
|
|
2495
|
-
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
2496
|
-
return void text(res, 403, "bad nonce");
|
|
2497
|
-
if (requiresReportConsent && !reportConsentGranted) {
|
|
2498
|
-
return void json(res, 403, {
|
|
2499
|
-
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
2500
|
-
message: "Allow local history access before starting the local scan.",
|
|
2501
|
-
});
|
|
2502
|
-
}
|
|
2503
|
-
let payload;
|
|
2504
|
-
try {
|
|
2505
|
-
payload = opts.getReport ? opts.getReport() : null;
|
|
2506
|
-
}
|
|
2507
|
-
catch {
|
|
2508
|
-
return void json(res, 500, {
|
|
2509
|
-
schemaVersion: 1,
|
|
2510
|
-
kind: "failed",
|
|
2511
|
-
mode: "production",
|
|
2512
|
-
scanId,
|
|
2513
|
-
error: {
|
|
2514
|
-
code: "REPORT_STATE_UNAVAILABLE",
|
|
2515
|
-
message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
|
|
2516
|
-
},
|
|
2517
|
-
});
|
|
2518
|
-
}
|
|
2519
|
-
if (payload == null) {
|
|
2520
|
-
// 202 carries scan progress so the page can show a live "scanned N/total" indicator.
|
|
2521
|
-
let prog;
|
|
2522
|
-
try {
|
|
2523
|
-
prog = opts.getReportProgress ? opts.getReportProgress() : {
|
|
2524
|
-
status: "running",
|
|
2525
|
-
scanned: 0,
|
|
2526
|
-
total: 0,
|
|
2527
|
-
stage: "starting",
|
|
2528
|
-
label: "Starting local scan",
|
|
2529
|
-
elapsedMs: 0,
|
|
2530
|
-
stageElapsedMs: 0,
|
|
2531
|
-
updatedAt: Date.now(),
|
|
2532
|
-
};
|
|
2533
|
-
}
|
|
2534
|
-
catch {
|
|
2535
|
-
return void json(res, 500, {
|
|
2536
|
-
schemaVersion: 1,
|
|
2537
|
-
kind: "failed",
|
|
2538
|
-
mode: "production",
|
|
2539
|
-
scanId,
|
|
2540
|
-
error: {
|
|
2541
|
-
code: "REPORT_STATE_UNAVAILABLE",
|
|
2542
|
-
message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
|
|
2543
|
-
},
|
|
2544
|
-
});
|
|
2545
|
-
}
|
|
2546
|
-
if (prog && typeof prog === "object" && prog.status === "failed") {
|
|
2547
|
-
return void json(res, 500, {
|
|
2548
|
-
schemaVersion: 1,
|
|
2549
|
-
kind: "failed",
|
|
2550
|
-
mode: "production",
|
|
2551
|
-
scanId,
|
|
2552
|
-
error: safeForensicError(prog.error),
|
|
2553
|
-
});
|
|
2554
|
-
}
|
|
2555
|
-
const publicProgress = publicRunningForensicProgress(prog);
|
|
2556
|
-
if (!publicProgress) {
|
|
2557
|
-
return void json(res, 500, {
|
|
2558
|
-
schemaVersion: 1,
|
|
2559
|
-
kind: "failed",
|
|
2560
|
-
mode: "production",
|
|
2561
|
-
scanId,
|
|
2562
|
-
error: {
|
|
2563
|
-
code: "REPORT_STATE_INVALID",
|
|
2564
|
-
message: "EchoMem received an invalid local scan state. No backup data was substituted. Rerun setup to retry.",
|
|
2565
|
-
},
|
|
2566
|
-
});
|
|
2567
|
-
}
|
|
2568
|
-
return void json(res, 202, {
|
|
2569
|
-
schemaVersion: 1,
|
|
2570
|
-
kind: "scanning",
|
|
2571
|
-
mode: "production",
|
|
2572
|
-
scanId,
|
|
2573
|
-
progress: publicProgress,
|
|
2574
|
-
});
|
|
2575
|
-
}
|
|
2576
|
-
const validation = validateForensicReportForSetup(payload);
|
|
2577
|
-
if (!validation.ok) {
|
|
2578
|
-
console.error(`[echomem] local report validation failed: ${validation.code} — ${validation.message}`);
|
|
2579
|
-
return void json(res, 500, {
|
|
2580
|
-
schemaVersion: 1,
|
|
2581
|
-
kind: "failed",
|
|
2582
|
-
mode: "production",
|
|
2583
|
-
scanId,
|
|
2584
|
-
error: { code: validation.code, message: validation.message },
|
|
2585
|
-
});
|
|
2586
|
-
}
|
|
2587
|
-
json(res, 200, {
|
|
2588
|
-
schemaVersion: 1,
|
|
2589
|
-
kind: validation.kind,
|
|
2590
|
-
mode: "production",
|
|
2591
|
-
scanId,
|
|
2592
|
-
report: validation.report,
|
|
2593
|
-
});
|
|
2594
|
-
return;
|
|
2595
|
-
}
|
|
2596
2262
|
if (route === "/progress" && req.method === "GET") {
|
|
2597
2263
|
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
2598
2264
|
return void text(res, 403, "bad nonce");
|
|
2599
|
-
if (
|
|
2265
|
+
if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
|
|
2600
2266
|
return void json(res, 403, {
|
|
2601
2267
|
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
2602
2268
|
message: "Allow local history access before continuing setup.",
|
|
@@ -2605,7 +2271,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
2605
2271
|
json(res, 200, progress);
|
|
2606
2272
|
return;
|
|
2607
2273
|
}
|
|
2608
|
-
if (route === "/
|
|
2274
|
+
if (route === "/local-history-consent" && req.method === "POST") {
|
|
2609
2275
|
let body;
|
|
2610
2276
|
try {
|
|
2611
2277
|
body = await readJsonBody(req);
|
|
@@ -2617,8 +2283,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
2617
2283
|
if (!checkNonce(asString(body.nonce)))
|
|
2618
2284
|
return void text(res, 403, "bad nonce");
|
|
2619
2285
|
const allowed = body.allowed === true;
|
|
2620
|
-
|
|
2621
|
-
opts.onReportConsent?.(allowed);
|
|
2286
|
+
localHistoryConsentGranted = allowed;
|
|
2622
2287
|
json(res, 200, { ok: true, allowed });
|
|
2623
2288
|
return;
|
|
2624
2289
|
}
|
|
@@ -2677,7 +2342,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
2677
2342
|
}
|
|
2678
2343
|
if (!checkNonce(asString(body.nonce)))
|
|
2679
2344
|
return void text(res, 403, "bad nonce");
|
|
2680
|
-
if (
|
|
2345
|
+
if (requiresLocalHistoryConsent && !localHistoryConsentGranted) {
|
|
2681
2346
|
return void json(res, 403, {
|
|
2682
2347
|
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
2683
2348
|
message: "Allow local history access before starting extraction.",
|
|
@@ -2951,7 +2616,7 @@ async function cmdSetup(flags) {
|
|
|
2951
2616
|
if (c.kind === "json") {
|
|
2952
2617
|
const result = writeJsonClientConfig(c.configPath, entry, { forceHeadless });
|
|
2953
2618
|
if (result === "desktop-managed") {
|
|
2954
|
-
console.log(`✅ Kept the valid
|
|
2619
|
+
console.log(`✅ Kept the valid externally managed EchoMem entry for ${c.label}: ${c.configPath}`);
|
|
2955
2620
|
}
|
|
2956
2621
|
else {
|
|
2957
2622
|
console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath}`);
|
|
@@ -2963,7 +2628,7 @@ async function cmdSetup(flags) {
|
|
|
2963
2628
|
if (result === "wrote")
|
|
2964
2629
|
console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath} — start a new Codex session to load it.`);
|
|
2965
2630
|
else if (result === "desktop-managed")
|
|
2966
|
-
console.log(`✅ Kept the valid
|
|
2631
|
+
console.log(`✅ Kept the valid externally managed EchoMem entry for ${c.label}: ${c.configPath}`);
|
|
2967
2632
|
else
|
|
2968
2633
|
console.log(`✅ ${c.label} already has the EchoMem MCP entry: ${c.configPath}`);
|
|
2969
2634
|
configuredTargets.push(c);
|
|
@@ -2974,7 +2639,7 @@ async function cmdSetup(flags) {
|
|
|
2974
2639
|
: "unavailable";
|
|
2975
2640
|
if (result !== "unavailable" && result.state === "wrote") {
|
|
2976
2641
|
if (result.preservedDesktopManaged) {
|
|
2977
|
-
console.log(`✅ Kept the valid
|
|
2642
|
+
console.log(`✅ Kept the valid externally managed EchoMem user entry for ${c.label}.`);
|
|
2978
2643
|
}
|
|
2979
2644
|
else {
|
|
2980
2645
|
console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
|
|
@@ -3042,14 +2707,14 @@ async function cmdSetup(flags) {
|
|
|
3042
2707
|
await cmdLogin(flags);
|
|
3043
2708
|
}
|
|
3044
2709
|
if (flags["with-hud"]) {
|
|
3045
|
-
console.log("ℹ️ The standalone EchoMem HUD has been retired.
|
|
2710
|
+
console.log("ℹ️ The standalone EchoMem HUD has been retired. Use `echomem-mcp status` to inspect this installation.");
|
|
3046
2711
|
}
|
|
3047
2712
|
}
|
|
3048
2713
|
/**
|
|
3049
2714
|
* `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
|
|
3050
2715
|
* machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), installs EchoMem's
|
|
3051
2716
|
* Codex skills and writes the AGENTS.md memory guidance. One browser
|
|
3052
|
-
* bridge then runs permission →
|
|
2717
|
+
* bridge then runs permission → login → plan if needed → extraction in that order.
|
|
3053
2718
|
* `setup`/`login`/`update` remain granular primitives; init picks the full product defaults.
|
|
3054
2719
|
*/
|
|
3055
2720
|
async function cmdInit(flags) {
|
|
@@ -3063,7 +2728,7 @@ async function cmdInit(flags) {
|
|
|
3063
2728
|
"init-quiet": true,
|
|
3064
2729
|
"install-save-hooks": flags["no-save-hooks"] !== true,
|
|
3065
2730
|
});
|
|
3066
|
-
// 2. Start one ordered onboarding bridge. A fresh device logs in only after consent
|
|
2731
|
+
// 2. Start one ordered onboarding bridge. A fresh device logs in only after consent.
|
|
3067
2732
|
console.log("");
|
|
3068
2733
|
if (!flags["skip-login"] && !flags["no-login"] && !await cmdOnboarding(flags)) {
|
|
3069
2734
|
console.log("\nEchoMem is configured, but onboarding did not finish. Run `echomem-mcp init` again when you are ready.");
|
|
@@ -3071,8 +2736,8 @@ async function cmdInit(flags) {
|
|
|
3071
2736
|
}
|
|
3072
2737
|
console.log("");
|
|
3073
2738
|
console.log("🎉 EchoMem is ready.");
|
|
3074
|
-
console.log(" • MCP memory is configured for
|
|
3075
|
-
console.log(" •
|
|
2739
|
+
console.log(" • MCP memory is configured for the coding agents that connected successfully.");
|
|
2740
|
+
console.log(" • Use `echomem-mcp status` to inspect this device, and `login`, `unlock`, or `update` to manage it.");
|
|
3076
2741
|
console.log(' • Try it now: ask your agent — "search my EchoMem for what I\'ve been working on and recap it."');
|
|
3077
2742
|
}
|
|
3078
2743
|
/**
|
|
@@ -3188,7 +2853,7 @@ async function cmdLogin(flags) {
|
|
|
3188
2853
|
return true;
|
|
3189
2854
|
}
|
|
3190
2855
|
// Browser path: this bridge does only account/device authentication. It intentionally exposes
|
|
3191
|
-
// no local-history routes; `init` owns
|
|
2856
|
+
// no local-history routes; `init` owns local-history consent and optional extraction.
|
|
3192
2857
|
console.log("Opening your browser to connect this device locally…");
|
|
3193
2858
|
const { port, nonce } = localBridgeOptions(flags);
|
|
3194
2859
|
const srv = await startCallbackServer({ port, nonce, flow: "login" });
|
|
@@ -3216,7 +2881,7 @@ async function cmdLogin(flags) {
|
|
|
3216
2881
|
}
|
|
3217
2882
|
/**
|
|
3218
2883
|
* The local-history onboarding flow. Existing device credentials are reused when available; a
|
|
3219
|
-
* fresh device stays in this same bridge and asks for login only after permission
|
|
2884
|
+
* fresh device stays in this same bridge and asks for login only after permission.
|
|
3220
2885
|
*/
|
|
3221
2886
|
async function cmdOnboarding(flags) {
|
|
3222
2887
|
const store = new KeyStore();
|
|
@@ -3228,149 +2893,17 @@ async function cmdOnboarding(flags) {
|
|
|
3228
2893
|
console.log("Opening your browser for EchoMem onboarding…");
|
|
3229
2894
|
const { port, nonce } = localBridgeOptions(flags);
|
|
3230
2895
|
let stats = null;
|
|
3231
|
-
let forensicReport = null;
|
|
3232
|
-
let forensicConsent = "pending";
|
|
3233
|
-
let forensicScanStarted = false;
|
|
3234
|
-
const forensicStartedAt = Date.now();
|
|
3235
|
-
let forensicStageStartedAt = forensicStartedAt;
|
|
3236
|
-
let forensicStage = "starting";
|
|
3237
|
-
let forensicOverall = 0;
|
|
3238
|
-
let forensicProgress = {
|
|
3239
|
-
status: "running",
|
|
3240
|
-
scanned: 0,
|
|
3241
|
-
total: 0,
|
|
3242
|
-
stage: forensicStage,
|
|
3243
|
-
label: forensicStageLabel(forensicStage),
|
|
3244
|
-
stageDone: 0,
|
|
3245
|
-
stageTotal: 0,
|
|
3246
|
-
overall: 0,
|
|
3247
|
-
elapsedMs: 0,
|
|
3248
|
-
stageElapsedMs: 0,
|
|
3249
|
-
updatedAt: forensicStartedAt,
|
|
3250
|
-
};
|
|
3251
2896
|
const srv = await startCallbackServer({
|
|
3252
2897
|
port,
|
|
3253
2898
|
nonce,
|
|
3254
2899
|
flow: "onboarding",
|
|
3255
2900
|
initialToken,
|
|
3256
|
-
|
|
3257
|
-
getStats: () => stats,
|
|
3258
|
-
getReport: () => forensicReport,
|
|
3259
|
-
getReportProgress: () => {
|
|
3260
|
-
if (forensicProgress.status !== "running")
|
|
3261
|
-
return forensicProgress;
|
|
3262
|
-
const now = Date.now();
|
|
3263
|
-
const sinceWorkerUpdate = Math.max(0, now - forensicProgress.updatedAt);
|
|
3264
|
-
return {
|
|
3265
|
-
...forensicProgress,
|
|
3266
|
-
elapsedMs: forensicProgress.elapsedMs + sinceWorkerUpdate,
|
|
3267
|
-
stageElapsedMs: forensicProgress.stageElapsedMs + sinceWorkerUpdate,
|
|
3268
|
-
updatedAt: now,
|
|
3269
|
-
};
|
|
3270
|
-
},
|
|
3271
|
-
onReportConsent: (allowed) => {
|
|
3272
|
-
if (!allowed) {
|
|
3273
|
-
forensicConsent = "declined";
|
|
3274
|
-
forensicProgress = {
|
|
3275
|
-
status: "failed",
|
|
3276
|
-
scanned: 0,
|
|
3277
|
-
total: 0,
|
|
3278
|
-
stage: "failed",
|
|
3279
|
-
label: "Local scan skipped",
|
|
3280
|
-
stageDone: 0,
|
|
3281
|
-
stageTotal: 0,
|
|
3282
|
-
overall: 0,
|
|
3283
|
-
elapsedMs: Date.now() - forensicStartedAt,
|
|
3284
|
-
stageElapsedMs: Date.now() - forensicStageStartedAt,
|
|
3285
|
-
updatedAt: Date.now(),
|
|
3286
|
-
error: {
|
|
3287
|
-
code: "REPORT_SCAN_DECLINED",
|
|
3288
|
-
message: "Local file analysis was skipped. EchoMem can still connect, save conversations, and import memories.",
|
|
3289
|
-
},
|
|
3290
|
-
};
|
|
3291
|
-
return;
|
|
3292
|
-
}
|
|
3293
|
-
forensicConsent = "allowed";
|
|
3294
|
-
const now = Date.now();
|
|
3295
|
-
forensicStage = "starting";
|
|
3296
|
-
forensicStageStartedAt = now;
|
|
3297
|
-
forensicOverall = 0;
|
|
3298
|
-
forensicProgress = {
|
|
3299
|
-
status: "running",
|
|
3300
|
-
scanned: 0,
|
|
3301
|
-
total: 0,
|
|
3302
|
-
stage: forensicStage,
|
|
3303
|
-
label: forensicStageLabel(forensicStage),
|
|
3304
|
-
stageDone: 0,
|
|
3305
|
-
stageTotal: 0,
|
|
3306
|
-
overall: 0,
|
|
3307
|
-
elapsedMs: now - forensicStartedAt,
|
|
3308
|
-
stageElapsedMs: 0,
|
|
3309
|
-
updatedAt: now,
|
|
3310
|
-
};
|
|
3311
|
-
startForensicScan();
|
|
3312
|
-
},
|
|
2901
|
+
requireLocalHistoryConsent: true,
|
|
3313
2902
|
});
|
|
3314
2903
|
const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
|
|
3315
2904
|
openBrowser(localSetupUrl);
|
|
3316
2905
|
console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
|
|
3317
2906
|
console.log("Waiting for local-history onboarding for up to 15 minutes…");
|
|
3318
|
-
const startForensicScan = () => {
|
|
3319
|
-
if (forensicScanStarted || forensicConsent !== "allowed")
|
|
3320
|
-
return;
|
|
3321
|
-
forensicScanStarted = true;
|
|
3322
|
-
// Build the local forensic "Context Doctor" report off-thread only after explicit consent.
|
|
3323
|
-
buildForensicReportOffThread((progress) => {
|
|
3324
|
-
const now = Date.now();
|
|
3325
|
-
const nextStage = progress.stage || forensicStage;
|
|
3326
|
-
if (nextStage !== forensicStage) {
|
|
3327
|
-
forensicStage = nextStage;
|
|
3328
|
-
forensicStageStartedAt = now;
|
|
3329
|
-
console.log(`Local scan: ${forensicStageLabel(forensicStage)}…`);
|
|
3330
|
-
}
|
|
3331
|
-
// Latched, so a caller that ever reports a smaller fraction cannot walk the bar backwards.
|
|
3332
|
-
forensicOverall = Math.max(forensicOverall, typeof progress.overall === "number" && Number.isFinite(progress.overall) ? progress.overall : 0);
|
|
3333
|
-
forensicProgress = {
|
|
3334
|
-
status: "running",
|
|
3335
|
-
scanned: progress.done,
|
|
3336
|
-
total: progress.total,
|
|
3337
|
-
stage: forensicStage,
|
|
3338
|
-
label: forensicStageLabel(forensicStage),
|
|
3339
|
-
detail: progress.detail,
|
|
3340
|
-
stageDone: typeof progress.stageDone === "number" && Number.isFinite(progress.stageDone)
|
|
3341
|
-
? Math.max(0, Math.floor(progress.stageDone))
|
|
3342
|
-
: 0,
|
|
3343
|
-
stageTotal: typeof progress.stageTotal === "number" && Number.isFinite(progress.stageTotal)
|
|
3344
|
-
? Math.max(0, Math.floor(progress.stageTotal))
|
|
3345
|
-
: 0,
|
|
3346
|
-
overall: forensicOverall,
|
|
3347
|
-
elapsedMs: now - forensicStartedAt,
|
|
3348
|
-
stageElapsedMs: now - forensicStageStartedAt,
|
|
3349
|
-
updatedAt: now,
|
|
3350
|
-
};
|
|
3351
|
-
})
|
|
3352
|
-
.then((r) => {
|
|
3353
|
-
forensicReport = r;
|
|
3354
|
-
})
|
|
3355
|
-
.catch((e) => {
|
|
3356
|
-
const now = Date.now();
|
|
3357
|
-
forensicProgress = {
|
|
3358
|
-
status: "failed",
|
|
3359
|
-
scanned: forensicProgress.scanned,
|
|
3360
|
-
total: forensicProgress.total,
|
|
3361
|
-
stage: "failed",
|
|
3362
|
-
label: "Local scan failed",
|
|
3363
|
-
stageDone: forensicProgress.stageDone,
|
|
3364
|
-
stageTotal: forensicProgress.stageTotal,
|
|
3365
|
-
overall: forensicOverall,
|
|
3366
|
-
elapsedMs: now - forensicStartedAt,
|
|
3367
|
-
stageElapsedMs: now - forensicStageStartedAt,
|
|
3368
|
-
updatedAt: now,
|
|
3369
|
-
error: safeForensicError(e),
|
|
3370
|
-
};
|
|
3371
|
-
console.error(`Could not build the local report: ${e instanceof Error ? e.message : String(e)}`);
|
|
3372
|
-
});
|
|
3373
|
-
};
|
|
3374
2907
|
let token;
|
|
3375
2908
|
let key;
|
|
3376
2909
|
try {
|
|
@@ -3461,9 +2994,8 @@ async function cmdOnboarding(flags) {
|
|
|
3461
2994
|
claudeCode: quick.claudeCodeCount,
|
|
3462
2995
|
cowork: quick.coworkCount,
|
|
3463
2996
|
};
|
|
3464
|
-
stats =
|
|
2997
|
+
stats = buildOnboardingStatsPayload({
|
|
3465
2998
|
partial: true,
|
|
3466
|
-
skipMemoryCount: true,
|
|
3467
2999
|
sessions: sessionSummary,
|
|
3468
3000
|
migratable,
|
|
3469
3001
|
discovery: { phase: "quick", exact: false },
|
|
@@ -3498,9 +3030,8 @@ async function cmdOnboarding(flags) {
|
|
|
3498
3030
|
claudeCode: cloudSummary.claudeCodeCount,
|
|
3499
3031
|
cowork: cloudSummary.coworkCount,
|
|
3500
3032
|
};
|
|
3501
|
-
const cloudPayload =
|
|
3033
|
+
const cloudPayload = buildOnboardingStatsPayload({
|
|
3502
3034
|
partial: true,
|
|
3503
|
-
skipMemoryCount: true,
|
|
3504
3035
|
sessions: sessionSummary,
|
|
3505
3036
|
migratable,
|
|
3506
3037
|
discovery: { phase: "account", exact: false },
|
|
@@ -3536,9 +3067,8 @@ async function cmdOnboarding(flags) {
|
|
|
3536
3067
|
claudeCode: unavailableSummary.claudeCodeCount,
|
|
3537
3068
|
cowork: unavailableSummary.coworkCount,
|
|
3538
3069
|
};
|
|
3539
|
-
const unavailablePayload =
|
|
3070
|
+
const unavailablePayload = buildOnboardingStatsPayload({
|
|
3540
3071
|
partial: true,
|
|
3541
|
-
skipMemoryCount: true,
|
|
3542
3072
|
sessions: sessionSummary,
|
|
3543
3073
|
migratable,
|
|
3544
3074
|
discovery: { phase: "account", exact: false },
|
|
@@ -3574,9 +3104,8 @@ async function cmdOnboarding(flags) {
|
|
|
3574
3104
|
migratable = migratableFromDiscovery(initialExact);
|
|
3575
3105
|
latestPendingEstimate = migratable.pending;
|
|
3576
3106
|
sessionSummary = sessionsFromDiscovery(initialExact);
|
|
3577
|
-
const partialPayload = withCandidateSessions(
|
|
3107
|
+
const partialPayload = withCandidateSessions(buildOnboardingStatsPayload({
|
|
3578
3108
|
partial: true,
|
|
3579
|
-
skipMemoryCount: true,
|
|
3580
3109
|
sessions: sessionSummary,
|
|
3581
3110
|
migratable,
|
|
3582
3111
|
discovery: { phase: "exact", exact: true },
|
|
@@ -3624,12 +3153,10 @@ async function cmdOnboarding(flags) {
|
|
|
3624
3153
|
migratable = migratableFromDiscovery(reconciled);
|
|
3625
3154
|
latestPendingEstimate = migratable.pending;
|
|
3626
3155
|
sessionSummary = sessionsFromDiscovery(reconciled);
|
|
3627
|
-
const reconciledPayload = withCandidateSessions(
|
|
3628
|
-
partial: true,
|
|
3629
|
-
skipMemoryCount: true,
|
|
3156
|
+
const reconciledPayload = withCandidateSessions(buildOnboardingStatsPayload({
|
|
3630
3157
|
sessions: sessionSummary,
|
|
3631
3158
|
migratable,
|
|
3632
|
-
discovery: { phase: "
|
|
3159
|
+
discovery: { phase: "full", exact: true },
|
|
3633
3160
|
}), reconciled);
|
|
3634
3161
|
if (generation !== refreshGeneration)
|
|
3635
3162
|
return;
|
|
@@ -3644,20 +3171,11 @@ async function cmdOnboarding(flags) {
|
|
|
3644
3171
|
failed: 0,
|
|
3645
3172
|
extracted: 0,
|
|
3646
3173
|
});
|
|
3647
|
-
const fullPayload = withCandidateSessions(await buildCollectedStatsPayloadOffThread({
|
|
3648
|
-
sessions: sessionSummary,
|
|
3649
|
-
migratable,
|
|
3650
|
-
discovery: { phase: "full", exact: true },
|
|
3651
|
-
}), reconciled);
|
|
3652
|
-
if (generation !== refreshGeneration)
|
|
3653
|
-
return;
|
|
3654
|
-
stats = fullPayload;
|
|
3655
|
-
srv.setStats(fullPayload);
|
|
3656
3174
|
})().catch((e) => {
|
|
3657
3175
|
if (generation !== refreshGeneration)
|
|
3658
3176
|
return;
|
|
3659
|
-
console.error(`[echomem] optional
|
|
3660
|
-
publishOptionalStatsFallback(generation, "
|
|
3177
|
+
console.error(`[echomem] optional account reconciliation unavailable; continuing (${errorCode(e) || "ACCOUNT_RECONCILIATION_FAILED"})`);
|
|
3178
|
+
publishOptionalStatsFallback(generation, "ACCOUNT_RECONCILIATION_FAILED", true);
|
|
3661
3179
|
});
|
|
3662
3180
|
return initialExact;
|
|
3663
3181
|
}).catch((e) => {
|
|
@@ -4117,7 +3635,7 @@ Usage:
|
|
|
4117
3635
|
echomem-mcp Run the MCP server (stdio; default — used by your editor)
|
|
4118
3636
|
echomem-mcp setup [--client X] Detect editor, write its MCP config, then connect this device
|
|
4119
3637
|
echomem-mcp setup --skip-login Write MCP config without opening login/browser
|
|
4120
|
-
echomem-mcp setup --force-headless Explicitly replace valid
|
|
3638
|
+
echomem-mcp setup --force-headless Explicitly replace a valid externally managed entry
|
|
4121
3639
|
echomem-mcp setup --no-codex-skills Skip installing the bundled EchoMem Codex skills
|
|
4122
3640
|
echomem-mcp update --all Install this bridge durably + repoint detected clients; no login/browser
|
|
4123
3641
|
echomem-mcp update --client X Repoint one MCP client; no login/browser
|
|
@@ -4128,7 +3646,6 @@ Usage:
|
|
|
4128
3646
|
echomem-mcp status Show token/key/clients
|
|
4129
3647
|
echomem-mcp doctor [--no-network] Diagnose configured client bridge versions
|
|
4130
3648
|
echomem-mcp logout Remove stored credentials
|
|
4131
|
-
echomem-mcp report [--json] Your AI coding memory audit (local, no login, $0)
|
|
4132
3649
|
echomem-mcp migrate [--since D] Import your existing Codex/Claude history into your memory
|
|
4133
3650
|
echomem-mcp migrate --estimate Estimate migration size/time metadata without uploading transcripts
|
|
4134
3651
|
echomem-mcp migrate --max-chars N Import only sessions up to N assembled text chars
|
|
@@ -4180,9 +3697,6 @@ export async function runCli(argv) {
|
|
|
4180
3697
|
case "logout":
|
|
4181
3698
|
cmdLogout();
|
|
4182
3699
|
return true;
|
|
4183
|
-
case "report":
|
|
4184
|
-
await runReport(flags);
|
|
4185
|
-
return true;
|
|
4186
3700
|
case "migrate":
|
|
4187
3701
|
await cmdMigrate(flags);
|
|
4188
3702
|
return true;
|