@echomem/mcp 1.4.44 → 1.4.46
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 +28 -25
- package/dist/city/README.md +9 -0
- package/dist/city/echo-ai-city-only.html +2232 -0
- package/dist/city/echo-extraction-plate.html +330 -0
- 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 +1417 -0
- package/dist/city/vendor/RoundedBoxGeometry.js +155 -0
- package/dist/city/vendor/echo_general-file-21.riv +0 -0
- package/dist/city/vendor/rive.js +8139 -0
- package/dist/city/vendor/rive.wasm +0 -0
- package/dist/city/vendor/three.module.min.js +6 -0
- 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/forensics.js +1531 -0
- package/dist/hud/hooks.js +31 -43
- package/dist/index.js +79 -15
- package/dist/local-data-paths.js +38 -0
- package/dist/migrate.js +140 -70
- package/dist/report.js +721 -0
- package/dist/save-checkpoint-hook.js +1 -1
- package/dist/setup-page/client-core.js +372 -18
- package/dist/setup-page/client-extraction.js +204 -37
- package/dist/setup-page/client-lifecycle.js +101 -31
- package/dist/setup-page/client-report-audit.js +819 -0
- package/dist/setup-page/client-report-city.js +356 -0
- package/dist/setup-page/client-report.js +6 -0
- package/dist/setup-page/client.js +2 -0
- package/dist/setup-page/styles-city-report.js +880 -0
- package/dist/setup-page/styles-context-audit.js +470 -0
- package/dist/setup-page/styles-extraction.js +31 -1
- package/dist/setup-page/styles-foundation.js +89 -0
- package/dist/setup-page/styles-mvp.js +155 -10
- package/dist/setup-page/styles-website-alignment.js +204 -0
- package/dist/setup-page/styles.js +4 -0
- package/dist/setup-page.js +4 -4
- package/dist/setup-preview.js +212 -4
- package/dist/setup.js +793 -300
- package/dist/source-session.js +3 -9
- package/dist/v1-contract.js +8 -0
- package/package.json +7 -9
- package/dist/config-files.js +0 -63
- package/dist/local-jsonl.js +0 -87
- package/dist/onboarding-stats.js +0 -16
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, login, plan if needed, then extraction.
|
|
6
|
+
* - `init` runs one ordered flow: local-history permission/report, 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,13 +25,13 @@ 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 { runReport, buildStatsPayload } from "./report.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";
|
|
33
34
|
import { installSaveCheckpointHooks, installSourceSessionHooks } from "./hud/hooks.js";
|
|
34
|
-
import { atomicWriteJsonObject, atomicWriteTextFile, readJsonObjectFile } from "./config-files.js";
|
|
35
35
|
import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
|
|
36
36
|
import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
|
|
37
37
|
import { installHeadlessRuntimeSync, readHeadlessRuntimeInstallation, } from "./headless-runtime.js";
|
|
@@ -70,25 +70,15 @@ const CODEX_SKILL_NAMES = [
|
|
|
70
70
|
function home(...p) {
|
|
71
71
|
return path.join(os.homedir(), ...p);
|
|
72
72
|
}
|
|
73
|
-
function
|
|
74
|
-
const configured = process.env
|
|
73
|
+
function codexHome() {
|
|
74
|
+
const configured = process.env.CODEX_HOME?.trim();
|
|
75
75
|
if (!configured)
|
|
76
|
-
return home(
|
|
76
|
+
return home(".codex");
|
|
77
77
|
if (configured === "~")
|
|
78
78
|
return os.homedir();
|
|
79
|
-
if (configured.startsWith(
|
|
79
|
+
if (configured.startsWith(`~${path.sep}`))
|
|
80
80
|
return path.join(os.homedir(), configured.slice(2));
|
|
81
|
-
|
|
82
|
-
if (!path.isAbsolute(configured)) {
|
|
83
|
-
throw new Error(`${envKey} must be an absolute path or start with ~/`);
|
|
84
|
-
}
|
|
85
|
-
return path.normalize(configured);
|
|
86
|
-
}
|
|
87
|
-
function codexHome() {
|
|
88
|
-
return configuredProfileDirectory("CODEX_HOME", ".codex");
|
|
89
|
-
}
|
|
90
|
-
function claudeConfigHome() {
|
|
91
|
-
return configuredProfileDirectory("CLAUDE_CONFIG_DIR", ".claude");
|
|
81
|
+
return path.resolve(configured);
|
|
92
82
|
}
|
|
93
83
|
function filesEqual(left, right) {
|
|
94
84
|
try {
|
|
@@ -237,7 +227,7 @@ export function detectClients() {
|
|
|
237
227
|
if (c.kind === "command")
|
|
238
228
|
return fs.existsSync(c.detectDir);
|
|
239
229
|
if (c.id === "claude-code")
|
|
240
|
-
return
|
|
230
|
+
return fs.existsSync(home(".claude"));
|
|
241
231
|
return false;
|
|
242
232
|
});
|
|
243
233
|
}
|
|
@@ -386,11 +376,11 @@ export function writeCodexConfig(configPath, entry, options = {}) {
|
|
|
386
376
|
if (lines.slice(start, end).join("\n").trimEnd() === block)
|
|
387
377
|
return "exists"; // already correct
|
|
388
378
|
const next = [...lines.slice(0, start), ...block.split("\n"), ...lines.slice(end)];
|
|
389
|
-
|
|
379
|
+
fs.writeFileSync(configPath, next.join("\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, ""));
|
|
390
380
|
return "wrote"; // replaced a stale entry → caller tells the user to restart Codex
|
|
391
381
|
}
|
|
392
382
|
const sep = content ? (content.endsWith("\n") ? "\n" : "\n\n") : "";
|
|
393
|
-
|
|
383
|
+
fs.appendFileSync(configPath, sep + block + "\n");
|
|
394
384
|
return "wrote";
|
|
395
385
|
}
|
|
396
386
|
/**
|
|
@@ -411,7 +401,7 @@ function echomemGuidanceBlock() {
|
|
|
411
401
|
"- 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.",
|
|
412
402
|
"- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
|
|
413
403
|
'- 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.',
|
|
414
|
-
"- 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,
|
|
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, tell the user to open Echo Desktop and unlock the vault there; on a headless system, use `echomem-mcp unlock`. Never silently skip it.",
|
|
415
405
|
'- 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.',
|
|
416
406
|
"- 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.",
|
|
417
407
|
"- 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.",
|
|
@@ -422,7 +412,7 @@ function echomemGuidanceBlock() {
|
|
|
422
412
|
"- 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.",
|
|
423
413
|
"- 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.",
|
|
424
414
|
"- 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.",
|
|
425
|
-
"- 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,
|
|
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, tell the user to open Echo Desktop and unlock the vault there if the tool reports that the key is required.",
|
|
426
416
|
"- 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.",
|
|
427
417
|
"- 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.",
|
|
428
418
|
"- 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.",
|
|
@@ -447,56 +437,41 @@ export function writeAgentsMemoryGuidance(filePath) {
|
|
|
447
437
|
const current = content.slice(start, end + AGENTS_MD_END.length);
|
|
448
438
|
if (current === block)
|
|
449
439
|
return "exists";
|
|
450
|
-
|
|
440
|
+
fs.writeFileSync(filePath, content.slice(0, start) + block + content.slice(end + AGENTS_MD_END.length));
|
|
451
441
|
return "updated";
|
|
452
442
|
}
|
|
453
443
|
const sep = content ? (content.endsWith("\n") ? "\n" : "\n\n") : "";
|
|
454
|
-
|
|
444
|
+
fs.appendFileSync(filePath, sep + block + "\n");
|
|
455
445
|
return "wrote";
|
|
456
446
|
}
|
|
457
|
-
/**
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
* while server startup only migrates guidance that EchoMem already installed.
|
|
461
|
-
*/
|
|
462
|
-
export function refreshInstalledMemoryGuidance() {
|
|
463
|
-
let candidates;
|
|
447
|
+
/** Merge the EchoMem entry into a JSON client's `mcpServers` map without clobbering siblings. */
|
|
448
|
+
export function writeJsonClientConfig(configPath, entry, options = {}) {
|
|
449
|
+
let config = {};
|
|
464
450
|
try {
|
|
465
|
-
|
|
466
|
-
path.join(codexHome(), "AGENTS.md"),
|
|
467
|
-
path.join(claudeConfigHome(), "CLAUDE.md"),
|
|
468
|
-
];
|
|
451
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
469
452
|
}
|
|
470
453
|
catch {
|
|
471
|
-
|
|
472
|
-
}
|
|
473
|
-
for (const filePath of candidates) {
|
|
474
|
-
try {
|
|
475
|
-
const content = fs.readFileSync(filePath, "utf8");
|
|
476
|
-
const start = content.indexOf(AGENTS_MD_BEGIN);
|
|
477
|
-
const end = content.indexOf(AGENTS_MD_END);
|
|
478
|
-
if (start >= 0 && end > start)
|
|
479
|
-
writeAgentsMemoryGuidance(filePath);
|
|
480
|
-
}
|
|
481
|
-
catch {
|
|
482
|
-
// Missing, unreadable, or externally managed files are left untouched.
|
|
483
|
-
}
|
|
454
|
+
/* fresh config */
|
|
484
455
|
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
export function writeJsonClientConfig(configPath, entry, options = {}) {
|
|
488
|
-
const config = readJsonObjectFile(configPath, "MCP client configuration");
|
|
489
|
-
const servers = objectRecord(config.mcpServers) ?? {};
|
|
490
|
-
config.mcpServers = servers;
|
|
491
|
-
if (!options.forceHeadless && validDesktopManagedEntry(servers.echomem)) {
|
|
456
|
+
config.mcpServers = config.mcpServers || {};
|
|
457
|
+
if (!options.forceHeadless && validDesktopManagedEntry(config.mcpServers.echomem)) {
|
|
492
458
|
return "desktop-managed";
|
|
493
459
|
}
|
|
494
|
-
|
|
495
|
-
|
|
460
|
+
config.mcpServers.echomem = entry;
|
|
461
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
462
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
496
463
|
return "wrote";
|
|
497
464
|
}
|
|
498
465
|
function readClaudeCodeConfigFile(configPath) {
|
|
499
|
-
|
|
466
|
+
try {
|
|
467
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
468
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
469
|
+
? parsed
|
|
470
|
+
: {};
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return {};
|
|
474
|
+
}
|
|
500
475
|
}
|
|
501
476
|
function echoMemEntryFromServers(value) {
|
|
502
477
|
return objectRecord(objectRecord(value)?.echomem);
|
|
@@ -530,48 +505,65 @@ function claudeCodeLocalEchoMemProjects(configPath) {
|
|
|
530
505
|
.map(([projectPath]) => projectPath)
|
|
531
506
|
.sort();
|
|
532
507
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
* `claude.cmd` on Windows, and Node cannot execute .cmd/.bat launchers through execFileSync
|
|
536
|
-
* directly. Resolve the command with `where.exe` and route command files through ComSpec while
|
|
537
|
-
* keeping native .exe installations on the direct exec path.
|
|
538
|
-
*/
|
|
539
|
-
function execClaudeCodeSync(args, options) {
|
|
540
|
-
if (process.platform !== "win32")
|
|
541
|
-
return execFileSync("claude", args, options);
|
|
542
|
-
let command = "claude";
|
|
508
|
+
function versionedClaudeCodeExecutables(root, executable) {
|
|
509
|
+
let versions;
|
|
543
510
|
try {
|
|
544
|
-
|
|
545
|
-
encoding: "utf8",
|
|
546
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
547
|
-
timeout: 3000,
|
|
548
|
-
windowsHide: true,
|
|
549
|
-
})
|
|
550
|
-
.split(/\r?\n/)
|
|
551
|
-
.map((candidate) => candidate.trim())
|
|
552
|
-
.find(Boolean);
|
|
553
|
-
if (resolved)
|
|
554
|
-
command = resolved;
|
|
511
|
+
versions = fs.readdirSync(root, { withFileTypes: true });
|
|
555
512
|
}
|
|
556
513
|
catch {
|
|
557
|
-
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
514
|
+
return [];
|
|
515
|
+
}
|
|
516
|
+
return versions
|
|
517
|
+
.filter((entry) => entry.isDirectory())
|
|
518
|
+
.sort((left, right) => right.name.localeCompare(left.name, undefined, { numeric: true }))
|
|
519
|
+
.map((entry) => path.join(root, entry.name, executable))
|
|
520
|
+
.filter((candidate) => fs.existsSync(candidate));
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Return every safe Claude Code launcher location worth trying. Claude Desktop bundles the CLI,
|
|
524
|
+
* but Windows Store/MSIX installs expose its real files below the package LocalCache instead of
|
|
525
|
+
* the caller's ordinary APPDATA/PATH view.
|
|
526
|
+
*/
|
|
527
|
+
export function claudeCodeCommandCandidates(options = {}) {
|
|
528
|
+
const platform = options.platform ?? process.platform;
|
|
529
|
+
const env = options.env ?? process.env;
|
|
530
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
531
|
+
const candidates = [];
|
|
532
|
+
const configured = env.CLAUDE_CODE_EXECUTABLE?.trim();
|
|
533
|
+
if (configured)
|
|
534
|
+
candidates.push(configured);
|
|
535
|
+
candidates.push("claude");
|
|
536
|
+
if (platform === "win32") {
|
|
537
|
+
const appData = env.APPDATA?.trim() || path.join(homeDir, "AppData", "Roaming");
|
|
538
|
+
const localAppData = env.LOCALAPPDATA?.trim() || path.join(homeDir, "AppData", "Local");
|
|
539
|
+
candidates.push(...versionedClaudeCodeExecutables(path.join(appData, "Claude", "claude-code"), "claude.exe"), ...versionedClaudeCodeExecutables(path.join(localAppData, "Claude", "claude-code"), "claude.exe"));
|
|
540
|
+
const packagesRoot = path.join(localAppData, "Packages");
|
|
541
|
+
try {
|
|
542
|
+
for (const entry of fs.readdirSync(packagesRoot, { withFileTypes: true })) {
|
|
543
|
+
if (!entry.isDirectory() || !/^Claude_/i.test(entry.name))
|
|
544
|
+
continue;
|
|
545
|
+
candidates.push(...versionedClaudeCodeExecutables(path.join(packagesRoot, entry.name, "LocalCache", "Roaming", "Claude", "claude-code"), "claude.exe"));
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
/* A non-Store install has no Packages directory. */
|
|
550
|
+
}
|
|
551
|
+
const executionAlias = path.join(localAppData, "Microsoft", "WindowsApps", "claude.exe");
|
|
552
|
+
if (fs.existsSync(executionAlias))
|
|
553
|
+
candidates.push(executionAlias);
|
|
554
|
+
}
|
|
555
|
+
else if (platform === "darwin") {
|
|
556
|
+
for (const candidate of [
|
|
557
|
+
"/Applications/Claude.app/Contents/Resources/claude",
|
|
558
|
+
path.join(homeDir, "Applications", "Claude.app", "Contents", "Resources", "claude"),
|
|
559
|
+
"/opt/homebrew/bin/claude",
|
|
560
|
+
"/usr/local/bin/claude",
|
|
561
|
+
]) {
|
|
562
|
+
if (fs.existsSync(candidate))
|
|
563
|
+
candidates.push(candidate);
|
|
571
564
|
}
|
|
572
|
-
return result.stdout || "";
|
|
573
565
|
}
|
|
574
|
-
return
|
|
566
|
+
return [...new Set(candidates)];
|
|
575
567
|
}
|
|
576
568
|
function escapeWindowsCmdCommand(value) {
|
|
577
569
|
return value.replace(/([()\][%!^"`<>&|;, *?])/g, "^$1");
|
|
@@ -583,25 +575,53 @@ function escapeWindowsCmdArgument(value) {
|
|
|
583
575
|
escaped = `"${escaped}"`;
|
|
584
576
|
return escaped.replace(/([()\][%!^"`<>&|;, *?])/g, "^$1");
|
|
585
577
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
578
|
+
function execClaudeCodeSync(args, options, commandCandidates) {
|
|
579
|
+
let candidates = commandCandidates ? [...commandCandidates] : claudeCodeCommandCandidates();
|
|
580
|
+
if (process.platform === "win32" && !commandCandidates) {
|
|
581
|
+
try {
|
|
582
|
+
const pathMatches = execFileSync("where.exe", ["claude"], {
|
|
583
|
+
encoding: "utf8",
|
|
584
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
585
|
+
timeout: 3000,
|
|
586
|
+
windowsHide: true,
|
|
587
|
+
}).split(/\r?\n/).map((candidate) => candidate.trim()).filter(Boolean);
|
|
588
|
+
candidates = [...new Set([...pathMatches, ...candidates])];
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
/* The bundled Desktop candidates below remain available. */
|
|
592
|
+
}
|
|
594
593
|
}
|
|
595
|
-
|
|
596
|
-
|
|
594
|
+
let lastError = new Error("Claude Code CLI was not found.");
|
|
595
|
+
for (const command of candidates) {
|
|
596
|
+
try {
|
|
597
|
+
if (process.platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
|
|
598
|
+
const shellCommand = [escapeWindowsCmdCommand(command), ...args.map(escapeWindowsCmdArgument)].join(" ");
|
|
599
|
+
const spawnOptions = {
|
|
600
|
+
...options,
|
|
601
|
+
windowsHide: true,
|
|
602
|
+
windowsVerbatimArguments: true,
|
|
603
|
+
};
|
|
604
|
+
const result = spawnSync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `"${shellCommand}"`], spawnOptions);
|
|
605
|
+
if (result.error)
|
|
606
|
+
throw result.error;
|
|
607
|
+
if (result.status !== 0) {
|
|
608
|
+
throw new Error(result.stderr?.trim() || `Claude Code exited with status ${result.status ?? "unknown"}.`);
|
|
609
|
+
}
|
|
610
|
+
return result.stdout || "";
|
|
611
|
+
}
|
|
612
|
+
return execFileSync(command, args, process.platform === "win32" ? { ...options, windowsHide: true } : options);
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
lastError = error;
|
|
616
|
+
}
|
|
597
617
|
}
|
|
618
|
+
throw lastError;
|
|
598
619
|
}
|
|
599
620
|
export function writeClaudeCodeConfig(entry, options = {}) {
|
|
600
621
|
// EchoMem belongs at user scope so every Claude Code project resolves the same durable runtime.
|
|
601
622
|
// Older CLI versions wrote local/project entries, which take precedence over user scope and can
|
|
602
623
|
// keep launching a deleted npm cache or stale runtime. Migrate those only after user scope is safe.
|
|
603
624
|
const configPath = options.configPath ?? home(".claude.json");
|
|
604
|
-
let failureReason;
|
|
605
625
|
const emptyResult = () => ({
|
|
606
626
|
state: "unavailable",
|
|
607
627
|
removedLocalProjects: [],
|
|
@@ -609,7 +629,6 @@ export function writeClaudeCodeConfig(entry, options = {}) {
|
|
|
609
629
|
failedLocalProjects: [],
|
|
610
630
|
restoredPreviousUserEntry: false,
|
|
611
631
|
preservedDesktopManaged: false,
|
|
612
|
-
failureReason,
|
|
613
632
|
});
|
|
614
633
|
const runClaude = (args, cwd) => {
|
|
615
634
|
try {
|
|
@@ -618,12 +637,10 @@ export function writeClaudeCodeConfig(entry, options = {}) {
|
|
|
618
637
|
encoding: "utf8",
|
|
619
638
|
stdio: ["ignore", "pipe", "pipe"],
|
|
620
639
|
timeout: 10000,
|
|
621
|
-
});
|
|
622
|
-
failureReason = undefined;
|
|
640
|
+
}, options.claudeCommands);
|
|
623
641
|
return true;
|
|
624
642
|
}
|
|
625
|
-
catch
|
|
626
|
-
failureReason = commandFailureMessage(error);
|
|
643
|
+
catch {
|
|
627
644
|
return false;
|
|
628
645
|
}
|
|
629
646
|
};
|
|
@@ -642,10 +659,8 @@ export function writeClaudeCodeConfig(entry, options = {}) {
|
|
|
642
659
|
if (previousUserEntry && !removeUser())
|
|
643
660
|
return emptyResult();
|
|
644
661
|
if (!addUser(desiredUserEntry)) {
|
|
645
|
-
const addFailureReason = failureReason;
|
|
646
662
|
if (previousUserEntry)
|
|
647
663
|
restoredPreviousUserEntry = addUser(previousUserEntry);
|
|
648
|
-
failureReason = addFailureReason;
|
|
649
664
|
return { ...emptyResult(), restoredPreviousUserEntry };
|
|
650
665
|
}
|
|
651
666
|
}
|
|
@@ -859,7 +874,7 @@ function inspectClientConfig(client, desiredVersion) {
|
|
|
859
874
|
};
|
|
860
875
|
}
|
|
861
876
|
function inspectClaudeCodeConfig(client, desiredVersion) {
|
|
862
|
-
if (!fs.existsSync(
|
|
877
|
+
if (!fs.existsSync(home(".claude")))
|
|
863
878
|
return null;
|
|
864
879
|
try {
|
|
865
880
|
const output = execClaudeCodeSync(["mcp", "list"], {
|
|
@@ -942,23 +957,6 @@ function openBrowser(url) {
|
|
|
942
957
|
}
|
|
943
958
|
}
|
|
944
959
|
function openClaudeDesktop() {
|
|
945
|
-
if (process.platform === "win32") {
|
|
946
|
-
try {
|
|
947
|
-
spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "start \"\" \"claude://\""], {
|
|
948
|
-
detached: true,
|
|
949
|
-
stdio: "ignore",
|
|
950
|
-
windowsHide: true,
|
|
951
|
-
}).unref();
|
|
952
|
-
return { ok: true, message: "Opened Claude Desktop. The prompt is copied - paste it into Claude." };
|
|
953
|
-
}
|
|
954
|
-
catch (error) {
|
|
955
|
-
return {
|
|
956
|
-
ok: false,
|
|
957
|
-
message: "Could not auto-open Claude Desktop. The prompt is copied - open Claude Desktop and paste it.",
|
|
958
|
-
detail: commandFailureMessage(error),
|
|
959
|
-
};
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
960
|
if (process.platform !== "darwin") {
|
|
963
961
|
return { ok: false, message: "Could not auto-open Claude on this system. The prompt is copied - open Claude Desktop and paste it." };
|
|
964
962
|
}
|
|
@@ -990,11 +988,12 @@ function openClaudeDesktop() {
|
|
|
990
988
|
};
|
|
991
989
|
}
|
|
992
990
|
const LOCAL_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
991
|
+
const LOCAL_COWORK_SESSION_ID_RE = /^local_[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
993
992
|
// `claude --resume <id>` only finds sessions that belong to the current project directory, so
|
|
994
993
|
// recover the session's original cwd from its transcript before resuming.
|
|
995
994
|
function claudeSessionCwd(sessionId) {
|
|
996
995
|
try {
|
|
997
|
-
const projectsDir = path.join(
|
|
996
|
+
const projectsDir = path.join(os.homedir(), ".claude", "projects");
|
|
998
997
|
for (const dir of fs.readdirSync(projectsDir)) {
|
|
999
998
|
const file = path.join(projectsDir, dir, `${sessionId}.jsonl`);
|
|
1000
999
|
if (!fs.existsSync(file))
|
|
@@ -1020,40 +1019,20 @@ function shellQuote(value) {
|
|
|
1020
1019
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
1021
1020
|
}
|
|
1022
1021
|
function openExistingAgentSession(source, sessionId) {
|
|
1023
|
-
|
|
1022
|
+
const validSessionId = source === "claude-desktop"
|
|
1023
|
+
? LOCAL_COWORK_SESSION_ID_RE.test(sessionId)
|
|
1024
|
+
: LOCAL_SESSION_ID_RE.test(sessionId);
|
|
1025
|
+
if (!validSessionId)
|
|
1024
1026
|
return { ok: false, message: "The local session identifier is invalid." };
|
|
1027
|
+
if (source !== "claude-desktop" && process.platform !== "darwin") {
|
|
1028
|
+
return { ok: false, message: "Opening local Codex and Claude Code sessions is currently available on macOS." };
|
|
1029
|
+
}
|
|
1025
1030
|
try {
|
|
1026
1031
|
if (source === "codex") {
|
|
1027
|
-
|
|
1028
|
-
if (process.platform === "win32") {
|
|
1029
|
-
spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `start "" "${url}"`], {
|
|
1030
|
-
detached: true,
|
|
1031
|
-
stdio: "ignore",
|
|
1032
|
-
windowsHide: true,
|
|
1033
|
-
}).unref();
|
|
1034
|
-
}
|
|
1035
|
-
else if (process.platform === "darwin") {
|
|
1036
|
-
execFileSync("open", [url], { stdio: "pipe" });
|
|
1037
|
-
}
|
|
1038
|
-
else {
|
|
1039
|
-
return { ok: false, message: "Opening local Codex sessions is not supported on this system yet." };
|
|
1040
|
-
}
|
|
1032
|
+
execFileSync("open", [`codex://threads/${sessionId}`], { stdio: "pipe" });
|
|
1041
1033
|
return { ok: true, message: "Opened the original session in Codex." };
|
|
1042
1034
|
}
|
|
1043
1035
|
if (source === "claude-code") {
|
|
1044
|
-
if (process.platform === "win32") {
|
|
1045
|
-
const cwd = claudeSessionCwd(sessionId);
|
|
1046
|
-
spawn(process.env.ComSpec || "cmd.exe", ["/d", "/k", `claude --resume ${sessionId}`], {
|
|
1047
|
-
cwd: cwd && fs.existsSync(cwd) ? cwd : process.cwd(),
|
|
1048
|
-
detached: true,
|
|
1049
|
-
stdio: "ignore",
|
|
1050
|
-
windowsHide: false,
|
|
1051
|
-
}).unref();
|
|
1052
|
-
return { ok: true, message: "Opened the original Claude Code session in a terminal." };
|
|
1053
|
-
}
|
|
1054
|
-
if (process.platform !== "darwin") {
|
|
1055
|
-
return { ok: false, message: "Opening local Claude Code sessions is not supported on this system yet." };
|
|
1056
|
-
}
|
|
1057
1036
|
const claude = firstExisting(["/opt/homebrew/bin/claude", "/usr/local/bin/claude"]) || "claude";
|
|
1058
1037
|
const cwd = claudeSessionCwd(sessionId);
|
|
1059
1038
|
const resume = `${claude} --resume ${sessionId}`;
|
|
@@ -1064,6 +1043,23 @@ function openExistingAgentSession(source, sessionId) {
|
|
|
1064
1043
|
], { stdio: "pipe" });
|
|
1065
1044
|
return { ok: true, message: "Opened the original Claude Code session in Terminal." };
|
|
1066
1045
|
}
|
|
1046
|
+
if (source === "claude-desktop") {
|
|
1047
|
+
const url = `claude://claude.ai/claude-code-desktop/${sessionId}`;
|
|
1048
|
+
if (process.platform === "win32") {
|
|
1049
|
+
spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `start "" "${url}"`], {
|
|
1050
|
+
detached: true,
|
|
1051
|
+
stdio: "ignore",
|
|
1052
|
+
windowsHide: true,
|
|
1053
|
+
}).unref();
|
|
1054
|
+
}
|
|
1055
|
+
else if (process.platform === "darwin") {
|
|
1056
|
+
execFileSync("open", [url], { stdio: "pipe" });
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
return { ok: false, message: "Opening local Cowork sessions is not supported on this system yet." };
|
|
1060
|
+
}
|
|
1061
|
+
return { ok: true, message: "Opened the original Cowork session in Claude Desktop." };
|
|
1062
|
+
}
|
|
1067
1063
|
return { ok: false, message: "Unsupported agent session source." };
|
|
1068
1064
|
}
|
|
1069
1065
|
catch (error) {
|
|
@@ -1104,7 +1100,8 @@ function migratableFromDiscovery(disc) {
|
|
|
1104
1100
|
pending: disc.pending.length,
|
|
1105
1101
|
pendingTotal: disc.pendingTotal,
|
|
1106
1102
|
pendingCodex,
|
|
1107
|
-
pendingClaudeCode: disc.pendingClaudeCode ?? disc.pending.
|
|
1103
|
+
pendingClaudeCode: disc.pendingClaudeCode ?? disc.pending.filter((s) => s.source === "claude-code").length,
|
|
1104
|
+
pendingCowork: disc.pendingCowork ?? disc.pending.filter((s) => s.source === "claude-desktop").length,
|
|
1108
1105
|
alreadyMigrated: disc.alreadyMigrated,
|
|
1109
1106
|
skippedActive: disc.skippedActive,
|
|
1110
1107
|
limited: disc.limited,
|
|
@@ -1123,7 +1120,8 @@ function sessionsFromDiscovery(disc) {
|
|
|
1123
1120
|
return {
|
|
1124
1121
|
total: disc.sessions.length,
|
|
1125
1122
|
codex: disc.codexCount,
|
|
1126
|
-
claudeCode: disc.
|
|
1123
|
+
claudeCode: disc.claudeCodeCount,
|
|
1124
|
+
cowork: disc.coworkCount,
|
|
1127
1125
|
};
|
|
1128
1126
|
}
|
|
1129
1127
|
function migratableFromFastSummary(summary) {
|
|
@@ -1132,6 +1130,7 @@ function migratableFromFastSummary(summary) {
|
|
|
1132
1130
|
pendingTotal: summary.pendingTotal,
|
|
1133
1131
|
pendingCodex: summary.pendingCodex,
|
|
1134
1132
|
pendingClaudeCode: summary.pendingClaudeCode,
|
|
1133
|
+
pendingCowork: summary.pendingCowork,
|
|
1135
1134
|
alreadyMigrated: summary.alreadyMigrated,
|
|
1136
1135
|
skippedActive: summary.skippedActive,
|
|
1137
1136
|
eta: summary.eta,
|
|
@@ -1224,7 +1223,7 @@ function withTimeout(promise, ms, code, onTimeout) {
|
|
|
1224
1223
|
function delay(ms) {
|
|
1225
1224
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1226
1225
|
}
|
|
1227
|
-
const
|
|
1226
|
+
const CITY_ASSET_TYPES = {
|
|
1228
1227
|
".html": "text/html; charset=utf-8",
|
|
1229
1228
|
".js": "text/javascript; charset=utf-8",
|
|
1230
1229
|
".json": "application/json; charset=utf-8",
|
|
@@ -1233,11 +1232,39 @@ const LOCAL_ASSET_TYPES = {
|
|
|
1233
1232
|
".png": "image/png",
|
|
1234
1233
|
".svg": "image/svg+xml",
|
|
1235
1234
|
};
|
|
1236
|
-
function
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1235
|
+
function repoCityArtifactsRoot() {
|
|
1236
|
+
// Monorepo/dev: read the city assets live from repo-root /artifacts.
|
|
1237
|
+
const repo = fileURLToPath(new URL("../../../artifacts/", import.meta.url));
|
|
1238
|
+
if (fs.existsSync(repo))
|
|
1239
|
+
return repo;
|
|
1240
|
+
// Published install: fall back to the copy bundled into dist/city by prepack (bundle-city.mjs).
|
|
1241
|
+
return fileURLToPath(new URL("./city/", import.meta.url));
|
|
1242
|
+
}
|
|
1243
|
+
function serveRepoCityAsset(reqPath, res) {
|
|
1244
|
+
const root = repoCityArtifactsRoot();
|
|
1245
|
+
const rel = reqPath === "/city" || reqPath === "/city/" ? "echo-ai-city-only.html" : decodeURIComponent(reqPath.slice("/city/".length));
|
|
1246
|
+
// Archives stay in the checkout for recovery, but must never become a localhost UI surface.
|
|
1247
|
+
const normalizedRel = rel.replace(/\\/g, "/");
|
|
1248
|
+
if (normalizedRel === "archive" || normalizedRel.startsWith("archive/")) {
|
|
1249
|
+
res.writeHead(404).end("not found");
|
|
1250
|
+
return true;
|
|
1251
|
+
}
|
|
1252
|
+
const filePath = path.resolve(root, rel);
|
|
1253
|
+
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
|
|
1254
|
+
if (!filePath.startsWith(rootWithSep)) {
|
|
1255
|
+
res.writeHead(403).end("forbidden");
|
|
1256
|
+
return true;
|
|
1257
|
+
}
|
|
1258
|
+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
1259
|
+
res.writeHead(404).end("not found");
|
|
1260
|
+
return true;
|
|
1261
|
+
}
|
|
1262
|
+
res.writeHead(200, {
|
|
1263
|
+
"Content-Type": CITY_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
|
|
1264
|
+
"Cache-Control": "no-store",
|
|
1265
|
+
});
|
|
1266
|
+
fs.createReadStream(filePath).pipe(res);
|
|
1267
|
+
return true;
|
|
1241
1268
|
}
|
|
1242
1269
|
function hudAssetsRoot() {
|
|
1243
1270
|
return fileURLToPath(new URL("../assets/hud/", import.meta.url));
|
|
@@ -1256,7 +1283,7 @@ function serveHudAsset(reqPath, res) {
|
|
|
1256
1283
|
return true;
|
|
1257
1284
|
}
|
|
1258
1285
|
res.writeHead(200, {
|
|
1259
|
-
"Content-Type":
|
|
1286
|
+
"Content-Type": CITY_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
|
|
1260
1287
|
"Cache-Control": "no-store",
|
|
1261
1288
|
});
|
|
1262
1289
|
fs.createReadStream(filePath).pipe(res);
|
|
@@ -1433,6 +1460,61 @@ export function discoverMigratableFastOffThread(opts = {}) {
|
|
|
1433
1460
|
});
|
|
1434
1461
|
});
|
|
1435
1462
|
}
|
|
1463
|
+
/** Build the full local-history dashboard payload away from the callback server's event loop.
|
|
1464
|
+
* `collect()` can synchronously parse hundreds of JSONL files for tens of seconds; doing that on
|
|
1465
|
+
* the bridge thread prevents even localhost actions such as account switch from receiving a reply. */
|
|
1466
|
+
export function buildCollectedStatsPayloadOffThread(inject) {
|
|
1467
|
+
const reportUrl = runtimeModuleUrl("report");
|
|
1468
|
+
const serializedInject = JSON.stringify(inject);
|
|
1469
|
+
const code = `
|
|
1470
|
+
import { parentPort } from "node:worker_threads";
|
|
1471
|
+
import { collect, buildStatsPayload } from ${JSON.stringify(reportUrl)};
|
|
1472
|
+
|
|
1473
|
+
try {
|
|
1474
|
+
const payload = await buildStatsPayload(collect(), ${serializedInject});
|
|
1475
|
+
parentPort?.postMessage({ ok: true, payload });
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
parentPort?.postMessage({
|
|
1478
|
+
ok: false,
|
|
1479
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1480
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
`;
|
|
1484
|
+
const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
|
|
1485
|
+
return new Promise((resolve, reject) => {
|
|
1486
|
+
let settled = false;
|
|
1487
|
+
const finish = (result) => {
|
|
1488
|
+
if (settled)
|
|
1489
|
+
return;
|
|
1490
|
+
settled = true;
|
|
1491
|
+
void worker.terminate();
|
|
1492
|
+
if (result.ok)
|
|
1493
|
+
resolve(result.payload);
|
|
1494
|
+
else
|
|
1495
|
+
reject(result.error);
|
|
1496
|
+
};
|
|
1497
|
+
worker.once("message", (message) => {
|
|
1498
|
+
const msg = message;
|
|
1499
|
+
if (msg.ok === true) {
|
|
1500
|
+
finish({ ok: true, payload: msg.payload });
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
const error = new Error(typeof msg.message === "string" ? msg.message : "Full local stats worker failed");
|
|
1504
|
+
if (typeof msg.stack === "string")
|
|
1505
|
+
error.stack = msg.stack;
|
|
1506
|
+
finish({ ok: false, error });
|
|
1507
|
+
});
|
|
1508
|
+
worker.once("error", (error) => {
|
|
1509
|
+
finish({ ok: false, error });
|
|
1510
|
+
});
|
|
1511
|
+
worker.once("exit", (code) => {
|
|
1512
|
+
if (settled)
|
|
1513
|
+
return;
|
|
1514
|
+
finish({ ok: false, error: new Error(`Full local stats worker exited (code ${code}) without a result`) });
|
|
1515
|
+
});
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1436
1518
|
export function createLocalDiscoveryCache(loaders = {}) {
|
|
1437
1519
|
const loadQuick = loaders.loadQuick ?? (() => discoverMigratableFastOffThread());
|
|
1438
1520
|
const loadExact = loaders.loadExact ?? (() => discoverMigratableSessionsOffThread());
|
|
@@ -1481,6 +1563,45 @@ export function createLocalDiscoveryCache(loaders = {}) {
|
|
|
1481
1563
|
},
|
|
1482
1564
|
};
|
|
1483
1565
|
}
|
|
1566
|
+
function forensicStageLabel(stage) {
|
|
1567
|
+
if (stage === "reading-transcripts")
|
|
1568
|
+
return "Reading transcript files";
|
|
1569
|
+
if (stage === "building-summary")
|
|
1570
|
+
return "Building scan summary";
|
|
1571
|
+
if (stage === "classifying-repeated-context")
|
|
1572
|
+
return "Classifying repeated context";
|
|
1573
|
+
if (stage === "finalizing-report")
|
|
1574
|
+
return "Finalizing report";
|
|
1575
|
+
return "Starting local scan";
|
|
1576
|
+
}
|
|
1577
|
+
/** Build the local forensic "Context Doctor" report on a worker thread so the multi-file scan never
|
|
1578
|
+
* blocks the bridge's event loop (the freeze postmortem: any unbounded sync work on this path is risky). */
|
|
1579
|
+
export function buildForensicReportOffThread(onProgress, options = {}) {
|
|
1580
|
+
let lastProgress = null;
|
|
1581
|
+
const recordProgress = (progress) => {
|
|
1582
|
+
lastProgress = progress;
|
|
1583
|
+
onProgress?.(progress);
|
|
1584
|
+
};
|
|
1585
|
+
return runForensicReportWorker(recordProgress, options).catch(async (primaryError) => {
|
|
1586
|
+
if (options.failOpen === false)
|
|
1587
|
+
throw primaryError;
|
|
1588
|
+
const failureCode = errorCode(primaryError) || "REPORT_BUILD_FAILED";
|
|
1589
|
+
console.error(`[echomem] local scan degraded after ${failureCode}; continuing without local-history analysis`);
|
|
1590
|
+
onProgress?.({
|
|
1591
|
+
done: lastProgress?.done || 0,
|
|
1592
|
+
total: lastProgress?.total || 0,
|
|
1593
|
+
stage: "finalizing-report",
|
|
1594
|
+
detail: "finishing setup without optional local-history analysis",
|
|
1595
|
+
overall: 0.99,
|
|
1596
|
+
stageDone: 0,
|
|
1597
|
+
stageTotal: 0,
|
|
1598
|
+
});
|
|
1599
|
+
return runForensicReportWorker(undefined, {
|
|
1600
|
+
timeoutMs: 30_000,
|
|
1601
|
+
maxOldGenerationSizeMb: Math.max(64, options.maxOldGenerationSizeMb || 0),
|
|
1602
|
+
}, [], failureCode);
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1484
1605
|
function errorCode(error) {
|
|
1485
1606
|
return error && typeof error === "object" && "code" in error
|
|
1486
1607
|
? String(error.code || "")
|
|
@@ -1498,8 +1619,9 @@ export function completeOptionalStatsPayload(payload, reason, countsTrusted) {
|
|
|
1498
1619
|
generatedFrom: ["~/.codex/sessions", "~/.claude/projects"],
|
|
1499
1620
|
llmCallsUsed: 0,
|
|
1500
1621
|
transcriptsUploaded: false,
|
|
1501
|
-
sessions: { total: 0, codex: 0, claudeCode: 0 },
|
|
1622
|
+
sessions: { total: 0, codex: 0, claudeCode: 0, cowork: 0 },
|
|
1502
1623
|
migratable: { pending: 0, alreadyMigrated: 0 },
|
|
1624
|
+
memoriesCaptured: null,
|
|
1503
1625
|
};
|
|
1504
1626
|
const completed = payload && typeof payload === "object" && !Array.isArray(payload)
|
|
1505
1627
|
? { ...payload }
|
|
@@ -1512,11 +1634,155 @@ export function completeOptionalStatsPayload(payload, reason, countsTrusted) {
|
|
|
1512
1634
|
};
|
|
1513
1635
|
return completed;
|
|
1514
1636
|
}
|
|
1637
|
+
function runForensicReportWorker(onProgress, options, sources, degradedReason) {
|
|
1638
|
+
const forensicsUrl = runtimeModuleUrl("forensics");
|
|
1639
|
+
const serializedSources = sources === undefined ? "undefined" : JSON.stringify(sources);
|
|
1640
|
+
const serializedDegradedReason = JSON.stringify(degradedReason || "");
|
|
1641
|
+
const code = `
|
|
1642
|
+
import { parentPort } from "node:worker_threads";
|
|
1643
|
+
import { buildForensicReport, validateForensicReportForSetup } from ${JSON.stringify(forensicsUrl)};
|
|
1644
|
+
try {
|
|
1645
|
+
const report = await buildForensicReport({
|
|
1646
|
+
sources: ${serializedSources},
|
|
1647
|
+
includeLegacyGoldenStandard: false,
|
|
1648
|
+
onProgress: (done, total, stage, detail, overall, stageDone, stageTotal) => parentPort?.postMessage({
|
|
1649
|
+
progress: { done, total, stage, detail, overall, stageDone, stageTotal },
|
|
1650
|
+
}),
|
|
1651
|
+
});
|
|
1652
|
+
const degradedReason = ${serializedDegradedReason};
|
|
1653
|
+
if (degradedReason) {
|
|
1654
|
+
report.scanDiagnostics = {
|
|
1655
|
+
degraded: true,
|
|
1656
|
+
reason: degradedReason,
|
|
1657
|
+
skippedSources: ["codex", "claude"],
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
const validation = validateForensicReportForSetup(report);
|
|
1661
|
+
if (!validation.ok) {
|
|
1662
|
+
const error = new Error(validation.message);
|
|
1663
|
+
error.code = validation.code;
|
|
1664
|
+
throw error;
|
|
1665
|
+
}
|
|
1666
|
+
parentPort?.postMessage({ ok: true, report });
|
|
1667
|
+
} catch (error) {
|
|
1668
|
+
parentPort?.postMessage({
|
|
1669
|
+
ok: false,
|
|
1670
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1671
|
+
code: error && typeof error === "object" && "code" in error ? String(error.code || "") : "",
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
`;
|
|
1675
|
+
const requestedHeapMb = options.maxOldGenerationSizeMb;
|
|
1676
|
+
const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`), Number.isFinite(requestedHeapMb)
|
|
1677
|
+
? { resourceLimits: { maxOldGenerationSizeMb: Math.max(16, Math.floor(requestedHeapMb)) } }
|
|
1678
|
+
: undefined);
|
|
1679
|
+
return new Promise((resolve, reject) => {
|
|
1680
|
+
let settled = false;
|
|
1681
|
+
const requestedTimeoutMs = options.timeoutMs ?? 15 * 60_000;
|
|
1682
|
+
const timeoutMs = Number.isFinite(requestedTimeoutMs) ? Math.max(1, requestedTimeoutMs) : 15 * 60_000;
|
|
1683
|
+
const timeout = setTimeout(() => {
|
|
1684
|
+
if (settled)
|
|
1685
|
+
return;
|
|
1686
|
+
settled = true;
|
|
1687
|
+
void worker.terminate();
|
|
1688
|
+
const error = new Error(`Local forensic report timed out after ${timeoutMs}ms`);
|
|
1689
|
+
error.code = "REPORT_SCAN_TIMEOUT";
|
|
1690
|
+
reject(error);
|
|
1691
|
+
}, timeoutMs);
|
|
1692
|
+
timeout.unref?.();
|
|
1693
|
+
const finish = (result) => {
|
|
1694
|
+
if (settled)
|
|
1695
|
+
return;
|
|
1696
|
+
settled = true;
|
|
1697
|
+
clearTimeout(timeout);
|
|
1698
|
+
void worker.terminate();
|
|
1699
|
+
if (result.ok)
|
|
1700
|
+
resolve(result.report);
|
|
1701
|
+
else
|
|
1702
|
+
reject(result.error);
|
|
1703
|
+
};
|
|
1704
|
+
worker.on("message", (message) => {
|
|
1705
|
+
if (settled)
|
|
1706
|
+
return;
|
|
1707
|
+
const msg = message;
|
|
1708
|
+
if (msg.progress) {
|
|
1709
|
+
onProgress?.(msg.progress);
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
if (msg.ok === true && msg.report && typeof msg.report === "object") {
|
|
1713
|
+
finish({ ok: true, report: msg.report });
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
const error = new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed");
|
|
1717
|
+
if (typeof msg.code === "string" && msg.code)
|
|
1718
|
+
error.code = msg.code;
|
|
1719
|
+
finish({ ok: false, error });
|
|
1720
|
+
});
|
|
1721
|
+
worker.once("error", (error) => {
|
|
1722
|
+
finish({ ok: false, error });
|
|
1723
|
+
});
|
|
1724
|
+
worker.once("exit", (code) => {
|
|
1725
|
+
if (settled)
|
|
1726
|
+
return;
|
|
1727
|
+
finish({ ok: false, error: new Error(`Forensic report worker exited (code ${code}) without a result`) });
|
|
1728
|
+
});
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1515
1731
|
export function respondMigrate(res, body, status = 200) {
|
|
1516
1732
|
if (res.writableEnded)
|
|
1517
1733
|
return;
|
|
1518
1734
|
res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
|
|
1519
1735
|
}
|
|
1736
|
+
function safeForensicError(error) {
|
|
1737
|
+
const code = error && typeof error === "object" && "code" in error
|
|
1738
|
+
? String(error.code || "")
|
|
1739
|
+
: "";
|
|
1740
|
+
if (code === "REPORT_SCAN_TIMEOUT") {
|
|
1741
|
+
return {
|
|
1742
|
+
code,
|
|
1743
|
+
message: "The local workspace scan took too long and was stopped. No backup data was substituted. Rerun setup to retry.",
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
return {
|
|
1747
|
+
code: "REPORT_BUILD_FAILED",
|
|
1748
|
+
message: "EchoMem could not finish the local workspace scan. No backup data was substituted. Rerun setup to retry.",
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1751
|
+
function publicRunningForensicProgress(value) {
|
|
1752
|
+
if (!value || typeof value !== "object")
|
|
1753
|
+
return null;
|
|
1754
|
+
const progress = value;
|
|
1755
|
+
if (progress.status !== "running")
|
|
1756
|
+
return null;
|
|
1757
|
+
const safeCount = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
|
|
1758
|
+
? Math.floor(candidate)
|
|
1759
|
+
: 0);
|
|
1760
|
+
const safeDuration = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
|
|
1761
|
+
? candidate
|
|
1762
|
+
: 0);
|
|
1763
|
+
const safeFraction = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate)
|
|
1764
|
+
? Math.min(1, Math.max(0, candidate))
|
|
1765
|
+
: 0);
|
|
1766
|
+
const total = safeCount(progress.total);
|
|
1767
|
+
const stageTotal = safeCount(progress.stageTotal);
|
|
1768
|
+
const rawStage = typeof progress.stage === "string" ? progress.stage : "starting";
|
|
1769
|
+
const stage = ["starting", "reading-transcripts", "building-summary", "classifying-repeated-context", "finalizing-report"].includes(rawStage)
|
|
1770
|
+
? rawStage
|
|
1771
|
+
: "starting";
|
|
1772
|
+
return {
|
|
1773
|
+
status: "running",
|
|
1774
|
+
scanned: total > 0 ? Math.min(safeCount(progress.scanned), total) : 0,
|
|
1775
|
+
total,
|
|
1776
|
+
stage,
|
|
1777
|
+
label: forensicStageLabel(stage),
|
|
1778
|
+
stageDone: stageTotal > 0 ? Math.min(safeCount(progress.stageDone), stageTotal) : 0,
|
|
1779
|
+
stageTotal,
|
|
1780
|
+
overall: safeFraction(progress.overall),
|
|
1781
|
+
elapsedMs: safeDuration(progress.elapsedMs),
|
|
1782
|
+
stageElapsedMs: safeDuration(progress.stageElapsedMs),
|
|
1783
|
+
updatedAt: safeDuration(progress.updatedAt) || Date.now(),
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1520
1786
|
/**
|
|
1521
1787
|
* Start the persistent localhost bridge used by the setup page. It sends/verifies OTP through the
|
|
1522
1788
|
* hosted API, accepts the local passphrase, serves local Wrapped stats, and holds the /migrate
|
|
@@ -1529,11 +1795,12 @@ export function startCallbackServer(opts = {}) {
|
|
|
1529
1795
|
: `${Math.ceil(timeoutMs / 1000)} seconds`;
|
|
1530
1796
|
const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
|
|
1531
1797
|
const expectedNonce = opts.nonce;
|
|
1798
|
+
const scanId = opts.scanId ?? randomUUID();
|
|
1532
1799
|
const flow = opts.flow ?? "onboarding";
|
|
1533
1800
|
const isLoginFlow = flow === "login";
|
|
1534
1801
|
// A login screen must not be blocked by a local-history permission. That permission belongs to
|
|
1535
1802
|
// onboarding and is intentionally enforced separately below.
|
|
1536
|
-
const
|
|
1803
|
+
const requiresReportConsent = opts.requireReportConsent === true && !isLoginFlow;
|
|
1537
1804
|
return new Promise((resolveOuter, rejectOuter) => {
|
|
1538
1805
|
const onToken = deferred();
|
|
1539
1806
|
const setupExit = deferred();
|
|
@@ -1546,7 +1813,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1546
1813
|
let activeDeviceToken = opts.initialToken?.token || "";
|
|
1547
1814
|
let activeAccountEmail = "";
|
|
1548
1815
|
let pendingLocalAuth = null;
|
|
1549
|
-
let
|
|
1816
|
+
let reportConsentGranted = !requiresReportConsent;
|
|
1550
1817
|
let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
|
|
1551
1818
|
let migrateStarted = false;
|
|
1552
1819
|
let tokenRefreshHandler = null;
|
|
@@ -1613,7 +1880,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1613
1880
|
const handleCallback = (res, token, key, nonce) => {
|
|
1614
1881
|
if (!checkNonce(nonce))
|
|
1615
1882
|
return void text(res, 403, "bad nonce");
|
|
1616
|
-
if (
|
|
1883
|
+
if (requiresReportConsent && !reportConsentGranted) {
|
|
1617
1884
|
return void json(res, 403, {
|
|
1618
1885
|
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
1619
1886
|
message: "Allow local history access in the setup page before connecting EchoMem.",
|
|
@@ -1643,7 +1910,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
1643
1910
|
text(res, 403, "bad nonce");
|
|
1644
1911
|
return true;
|
|
1645
1912
|
}
|
|
1646
|
-
if (
|
|
1913
|
+
if (requiresReportConsent && !reportConsentGranted) {
|
|
1647
1914
|
json(res, 403, {
|
|
1648
1915
|
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
1649
1916
|
message: "Allow local history access in the setup page before connecting EchoMem.",
|
|
@@ -1670,7 +1937,8 @@ export function startCallbackServer(opts = {}) {
|
|
|
1670
1937
|
armTimeout();
|
|
1671
1938
|
};
|
|
1672
1939
|
const isOnboardingOnlyRoute = (route) => [
|
|
1673
|
-
"/
|
|
1940
|
+
"/report-consent",
|
|
1941
|
+
"/report",
|
|
1674
1942
|
"/stats",
|
|
1675
1943
|
"/billing-status",
|
|
1676
1944
|
"/billing-checkout",
|
|
@@ -1863,6 +2131,10 @@ export function startCallbackServer(opts = {}) {
|
|
|
1863
2131
|
message: "Run `echomem-mcp init` to access local-history onboarding.",
|
|
1864
2132
|
});
|
|
1865
2133
|
}
|
|
2134
|
+
if ((route === "/city" || route.startsWith("/city/")) && req.method === "GET") {
|
|
2135
|
+
serveRepoCityAsset(route, res);
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
1866
2138
|
if (route.startsWith("/hud-assets/") && req.method === "GET") {
|
|
1867
2139
|
serveHudAsset(route, res);
|
|
1868
2140
|
return;
|
|
@@ -1900,13 +2172,8 @@ export function startCallbackServer(opts = {}) {
|
|
|
1900
2172
|
localOnly: true,
|
|
1901
2173
|
localAuth: true,
|
|
1902
2174
|
workspacePath: process.cwd(),
|
|
1903
|
-
consentRequired:
|
|
1904
|
-
consentGranted:
|
|
1905
|
-
platform: process.platform,
|
|
1906
|
-
capabilities: {
|
|
1907
|
-
openClaudeDesktop: process.platform === "darwin" || process.platform === "win32",
|
|
1908
|
-
openAgentSessions: process.platform === "darwin" || process.platform === "win32",
|
|
1909
|
-
},
|
|
2175
|
+
consentRequired: requiresReportConsent,
|
|
2176
|
+
consentGranted: reportConsentGranted,
|
|
1910
2177
|
});
|
|
1911
2178
|
return;
|
|
1912
2179
|
}
|
|
@@ -2010,7 +2277,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
2010
2277
|
if (route === "/stats" && req.method === "GET") {
|
|
2011
2278
|
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
2012
2279
|
return void text(res, 403, "bad nonce");
|
|
2013
|
-
if (
|
|
2280
|
+
if (requiresReportConsent && !reportConsentGranted) {
|
|
2014
2281
|
return void json(res, 403, {
|
|
2015
2282
|
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
2016
2283
|
message: "Allow local history access before continuing setup.",
|
|
@@ -2026,7 +2293,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
2026
2293
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
2027
2294
|
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
2028
2295
|
return void text(res, 403, "bad nonce");
|
|
2029
|
-
if (
|
|
2296
|
+
if (requiresReportConsent && !reportConsentGranted) {
|
|
2030
2297
|
return void json(res, 403, {
|
|
2031
2298
|
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
2032
2299
|
message: "Allow local history access before continuing setup.",
|
|
@@ -2150,7 +2417,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
2150
2417
|
}
|
|
2151
2418
|
if (!checkNonce(asString(body.nonce)))
|
|
2152
2419
|
return void text(res, 403, "bad nonce");
|
|
2153
|
-
if (
|
|
2420
|
+
if (requiresReportConsent && !reportConsentGranted) {
|
|
2154
2421
|
return void json(res, 403, {
|
|
2155
2422
|
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
2156
2423
|
message: "Allow local history access before managing an onboarding plan.",
|
|
@@ -2216,10 +2483,114 @@ export function startCallbackServer(opts = {}) {
|
|
|
2216
2483
|
}
|
|
2217
2484
|
return;
|
|
2218
2485
|
}
|
|
2486
|
+
if (route === "/report" && req.method === "GET") {
|
|
2487
|
+
// Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
|
|
2488
|
+
res.setHeader("Cache-Control", "no-store");
|
|
2489
|
+
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
2490
|
+
return void text(res, 403, "bad nonce");
|
|
2491
|
+
if (requiresReportConsent && !reportConsentGranted) {
|
|
2492
|
+
return void json(res, 403, {
|
|
2493
|
+
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
2494
|
+
message: "Allow local history access before starting the local scan.",
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
let payload;
|
|
2498
|
+
try {
|
|
2499
|
+
payload = opts.getReport ? opts.getReport() : null;
|
|
2500
|
+
}
|
|
2501
|
+
catch {
|
|
2502
|
+
return void json(res, 500, {
|
|
2503
|
+
schemaVersion: 1,
|
|
2504
|
+
kind: "failed",
|
|
2505
|
+
mode: "production",
|
|
2506
|
+
scanId,
|
|
2507
|
+
error: {
|
|
2508
|
+
code: "REPORT_STATE_UNAVAILABLE",
|
|
2509
|
+
message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
|
|
2510
|
+
},
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
if (payload == null) {
|
|
2514
|
+
// 202 carries scan progress so the page can show a live "scanned N/total" indicator.
|
|
2515
|
+
let prog;
|
|
2516
|
+
try {
|
|
2517
|
+
prog = opts.getReportProgress ? opts.getReportProgress() : {
|
|
2518
|
+
status: "running",
|
|
2519
|
+
scanned: 0,
|
|
2520
|
+
total: 0,
|
|
2521
|
+
stage: "starting",
|
|
2522
|
+
label: "Starting local scan",
|
|
2523
|
+
elapsedMs: 0,
|
|
2524
|
+
stageElapsedMs: 0,
|
|
2525
|
+
updatedAt: Date.now(),
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
catch {
|
|
2529
|
+
return void json(res, 500, {
|
|
2530
|
+
schemaVersion: 1,
|
|
2531
|
+
kind: "failed",
|
|
2532
|
+
mode: "production",
|
|
2533
|
+
scanId,
|
|
2534
|
+
error: {
|
|
2535
|
+
code: "REPORT_STATE_UNAVAILABLE",
|
|
2536
|
+
message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
|
|
2537
|
+
},
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
if (prog && typeof prog === "object" && prog.status === "failed") {
|
|
2541
|
+
return void json(res, 500, {
|
|
2542
|
+
schemaVersion: 1,
|
|
2543
|
+
kind: "failed",
|
|
2544
|
+
mode: "production",
|
|
2545
|
+
scanId,
|
|
2546
|
+
error: safeForensicError(prog.error),
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
const publicProgress = publicRunningForensicProgress(prog);
|
|
2550
|
+
if (!publicProgress) {
|
|
2551
|
+
return void json(res, 500, {
|
|
2552
|
+
schemaVersion: 1,
|
|
2553
|
+
kind: "failed",
|
|
2554
|
+
mode: "production",
|
|
2555
|
+
scanId,
|
|
2556
|
+
error: {
|
|
2557
|
+
code: "REPORT_STATE_INVALID",
|
|
2558
|
+
message: "EchoMem received an invalid local scan state. No backup data was substituted. Rerun setup to retry.",
|
|
2559
|
+
},
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
return void json(res, 202, {
|
|
2563
|
+
schemaVersion: 1,
|
|
2564
|
+
kind: "scanning",
|
|
2565
|
+
mode: "production",
|
|
2566
|
+
scanId,
|
|
2567
|
+
progress: publicProgress,
|
|
2568
|
+
});
|
|
2569
|
+
}
|
|
2570
|
+
const validation = validateForensicReportForSetup(payload);
|
|
2571
|
+
if (!validation.ok) {
|
|
2572
|
+
console.error(`[echomem] local report validation failed: ${validation.code} — ${validation.message}`);
|
|
2573
|
+
return void json(res, 500, {
|
|
2574
|
+
schemaVersion: 1,
|
|
2575
|
+
kind: "failed",
|
|
2576
|
+
mode: "production",
|
|
2577
|
+
scanId,
|
|
2578
|
+
error: { code: validation.code, message: validation.message },
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
json(res, 200, {
|
|
2582
|
+
schemaVersion: 1,
|
|
2583
|
+
kind: validation.kind,
|
|
2584
|
+
mode: "production",
|
|
2585
|
+
scanId,
|
|
2586
|
+
report: validation.report,
|
|
2587
|
+
});
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2219
2590
|
if (route === "/progress" && req.method === "GET") {
|
|
2220
2591
|
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
2221
2592
|
return void text(res, 403, "bad nonce");
|
|
2222
|
-
if (
|
|
2593
|
+
if (requiresReportConsent && !reportConsentGranted) {
|
|
2223
2594
|
return void json(res, 403, {
|
|
2224
2595
|
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
2225
2596
|
message: "Allow local history access before continuing setup.",
|
|
@@ -2228,7 +2599,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
2228
2599
|
json(res, 200, progress);
|
|
2229
2600
|
return;
|
|
2230
2601
|
}
|
|
2231
|
-
if (route === "/
|
|
2602
|
+
if (route === "/report-consent" && req.method === "POST") {
|
|
2232
2603
|
let body;
|
|
2233
2604
|
try {
|
|
2234
2605
|
body = await readJsonBody(req);
|
|
@@ -2240,7 +2611,8 @@ export function startCallbackServer(opts = {}) {
|
|
|
2240
2611
|
if (!checkNonce(asString(body.nonce)))
|
|
2241
2612
|
return void text(res, 403, "bad nonce");
|
|
2242
2613
|
const allowed = body.allowed === true;
|
|
2243
|
-
|
|
2614
|
+
reportConsentGranted = allowed;
|
|
2615
|
+
opts.onReportConsent?.(allowed);
|
|
2244
2616
|
json(res, 200, { ok: true, allowed });
|
|
2245
2617
|
return;
|
|
2246
2618
|
}
|
|
@@ -2299,7 +2671,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
2299
2671
|
}
|
|
2300
2672
|
if (!checkNonce(asString(body.nonce)))
|
|
2301
2673
|
return void text(res, 403, "bad nonce");
|
|
2302
|
-
if (
|
|
2674
|
+
if (requiresReportConsent && !reportConsentGranted) {
|
|
2303
2675
|
return void json(res, 403, {
|
|
2304
2676
|
error: "LOCAL_HISTORY_CONSENT_REQUIRED",
|
|
2305
2677
|
message: "Allow local history access before starting extraction.",
|
|
@@ -2561,7 +2933,6 @@ async function cmdSetup(flags) {
|
|
|
2561
2933
|
// --dev is already an explicit request to replace the managed runtime with a checkout.
|
|
2562
2934
|
const forceHeadless = flags["force-headless"] === true || typeof flags.dev === "string";
|
|
2563
2935
|
const configurationFailures = [];
|
|
2564
|
-
const configuredTargets = [];
|
|
2565
2936
|
if (targets.length === 0) {
|
|
2566
2937
|
console.log("No client auto-detected. Add this MCP server entry manually:\n");
|
|
2567
2938
|
console.log(JSON.stringify({ echomem: entry }, null, 2));
|
|
@@ -2569,91 +2940,66 @@ async function cmdSetup(flags) {
|
|
|
2569
2940
|
}
|
|
2570
2941
|
else {
|
|
2571
2942
|
for (const c of targets) {
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
console.log(`✅ Kept the valid externally managed EchoMem entry for ${c.label}: ${c.configPath}`);
|
|
2577
|
-
}
|
|
2578
|
-
else {
|
|
2579
|
-
console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath}`);
|
|
2580
|
-
}
|
|
2581
|
-
configuredTargets.push(c);
|
|
2582
|
-
}
|
|
2583
|
-
else if (c.kind === "command") {
|
|
2584
|
-
const result = writeCodexConfig(c.configPath, entry, { forceHeadless });
|
|
2585
|
-
if (result === "wrote")
|
|
2586
|
-
console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath} — start a new Codex session to load it.`);
|
|
2587
|
-
else if (result === "desktop-managed")
|
|
2588
|
-
console.log(`✅ Kept the valid externally managed EchoMem entry for ${c.label}: ${c.configPath}`);
|
|
2589
|
-
else
|
|
2590
|
-
console.log(`✅ ${c.label} already has the EchoMem MCP entry: ${c.configPath}`);
|
|
2591
|
-
configuredTargets.push(c);
|
|
2943
|
+
if (c.kind === "json") {
|
|
2944
|
+
const result = writeJsonClientConfig(c.configPath, entry, { forceHeadless });
|
|
2945
|
+
if (result === "desktop-managed") {
|
|
2946
|
+
console.log(`✅ Kept the valid Echo Desktop-managed EchoMem entry for ${c.label}: ${c.configPath}`);
|
|
2592
2947
|
}
|
|
2593
2948
|
else {
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2949
|
+
console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath}`);
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
else if (c.kind === "command") {
|
|
2953
|
+
const result = writeCodexConfig(c.configPath, entry, { forceHeadless });
|
|
2954
|
+
if (result === "wrote")
|
|
2955
|
+
console.log(`✅ Wrote EchoMem MCP entry to ${c.label}: ${c.configPath} — start a new Codex session to load it.`);
|
|
2956
|
+
else if (result === "desktop-managed")
|
|
2957
|
+
console.log(`✅ Kept the valid Echo Desktop-managed EchoMem entry for ${c.label}: ${c.configPath}`);
|
|
2958
|
+
else
|
|
2959
|
+
console.log(`✅ ${c.label} already has the EchoMem MCP entry: ${c.configPath}`);
|
|
2960
|
+
}
|
|
2961
|
+
else {
|
|
2962
|
+
const result = c.id === "claude-code"
|
|
2963
|
+
? writeClaudeCodeConfig(entry, { forceHeadless })
|
|
2964
|
+
: "unavailable";
|
|
2965
|
+
if (result !== "unavailable" && result.state === "wrote") {
|
|
2966
|
+
if (result.preservedDesktopManaged) {
|
|
2967
|
+
console.log(`✅ Kept the valid Echo Desktop-managed EchoMem user entry for ${c.label}.`);
|
|
2611
2968
|
}
|
|
2612
2969
|
else {
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
});
|
|
2970
|
+
console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
|
|
2971
|
+
}
|
|
2972
|
+
if (result.removedLocalProjects.length > 0) {
|
|
2973
|
+
console.log(`✅ Removed ${result.removedLocalProjects.length} stale Claude Code project-local EchoMem ${result.removedLocalProjects.length === 1 ? "entry" : "entries"}.`);
|
|
2974
|
+
}
|
|
2975
|
+
if (result.skippedLocalProjects.length > 0) {
|
|
2976
|
+
console.log(`ℹ️ Ignored ${result.skippedLocalProjects.length} EchoMem local ${result.skippedLocalProjects.length === 1 ? "entry" : "entries"} for deleted project directories; they cannot shadow the user entry.`);
|
|
2621
2977
|
}
|
|
2622
2978
|
}
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
}
|
|
2979
|
+
else {
|
|
2980
|
+
const failedProjects = result === "unavailable" ? [] : result.failedLocalProjects;
|
|
2981
|
+
configurationFailures.push(failedProjects.length > 0
|
|
2982
|
+
? `${c.label} still has project-local EchoMem overrides in: ${failedProjects.join(", ")}`
|
|
2983
|
+
: `${c.label} user-scoped EchoMem entry could not be verified`);
|
|
2984
|
+
}
|
|
2629
2985
|
}
|
|
2630
2986
|
}
|
|
2631
2987
|
}
|
|
2632
2988
|
if (configurationFailures.length > 0) {
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
...configurationFailures.map((failure) => `- ${failure.reason}`),
|
|
2639
|
-
...configurationFailures.map((failure) => `Retry ${failure.client.label} with: ${MCP_UPDATE_COMMAND} --client ${failure.client.id}`),
|
|
2640
|
-
].join("\n");
|
|
2641
|
-
if (continueOnClientError) {
|
|
2642
|
-
console.log(`⚠️ ${failureMessage}`);
|
|
2643
|
-
console.log("Continuing with the clients that connected successfully. You can repair the remaining client later.");
|
|
2644
|
-
}
|
|
2645
|
-
else {
|
|
2646
|
-
throw new Error(failureMessage);
|
|
2647
|
-
}
|
|
2989
|
+
throw new Error([
|
|
2990
|
+
"EchoMem MCP configuration is incomplete; onboarding was stopped before login/import.",
|
|
2991
|
+
...configurationFailures.map((failure) => `- ${failure}`),
|
|
2992
|
+
`Retry with: ${MCP_UPDATE_COMMAND} --client claude-code`,
|
|
2993
|
+
].join("\n"));
|
|
2648
2994
|
}
|
|
2649
2995
|
if (!flags["no-agents-md"]) {
|
|
2650
|
-
writeMemoryGuidanceForTargets(
|
|
2996
|
+
writeMemoryGuidanceForTargets(targets);
|
|
2651
2997
|
}
|
|
2652
2998
|
if (!flags["no-codex-skills"]) {
|
|
2653
|
-
writeCodexSkillsForTargets(
|
|
2999
|
+
writeCodexSkillsForTargets(targets);
|
|
2654
3000
|
}
|
|
2655
3001
|
if (flags["no-save-hooks"] !== true) {
|
|
2656
|
-
writeLifecycleHooksForTargets(
|
|
3002
|
+
writeLifecycleHooksForTargets(targets);
|
|
2657
3003
|
}
|
|
2658
3004
|
console.log("");
|
|
2659
3005
|
if (flags["skip-login"] || flags["no-login"]) {
|
|
@@ -2665,14 +3011,14 @@ async function cmdSetup(flags) {
|
|
|
2665
3011
|
await cmdLogin(flags);
|
|
2666
3012
|
}
|
|
2667
3013
|
if (flags["with-hud"]) {
|
|
2668
|
-
console.log("ℹ️ The standalone EchoMem HUD has been retired.
|
|
3014
|
+
console.log("ℹ️ The standalone EchoMem HUD has been retired. Echo Desktop now owns setup and status.");
|
|
2669
3015
|
}
|
|
2670
3016
|
}
|
|
2671
3017
|
/**
|
|
2672
3018
|
* `echomem-mcp init` — the one-command install. Configures EVERY coding agent installed on this
|
|
2673
3019
|
* machine (Codex + Claude Code + Claude Desktop, not just auto-detected ones), installs EchoMem's
|
|
2674
3020
|
* Codex skills and writes the AGENTS.md memory guidance. One browser
|
|
2675
|
-
* bridge then runs permission → login → plan if needed → extraction in that order.
|
|
3021
|
+
* bridge then runs permission → report → login → plan if needed → extraction in that order.
|
|
2676
3022
|
* `setup`/`login`/`update` remain granular primitives; init picks the full product defaults.
|
|
2677
3023
|
*/
|
|
2678
3024
|
async function cmdInit(flags) {
|
|
@@ -2681,13 +3027,12 @@ async function cmdInit(flags) {
|
|
|
2681
3027
|
await cmdSetup({
|
|
2682
3028
|
...flags,
|
|
2683
3029
|
all: true,
|
|
2684
|
-
"continue-on-client-error": true,
|
|
2685
3030
|
"skip-login": true,
|
|
2686
3031
|
"with-hud": false,
|
|
2687
3032
|
"init-quiet": true,
|
|
2688
3033
|
"install-save-hooks": flags["no-save-hooks"] !== true,
|
|
2689
3034
|
});
|
|
2690
|
-
// 2. Start one ordered onboarding bridge. A fresh device logs in only after consent.
|
|
3035
|
+
// 2. Start one ordered onboarding bridge. A fresh device logs in only after consent + report.
|
|
2691
3036
|
console.log("");
|
|
2692
3037
|
if (!flags["skip-login"] && !flags["no-login"] && !await cmdOnboarding(flags)) {
|
|
2693
3038
|
console.log("\nEchoMem is configured, but onboarding did not finish. Run `echomem-mcp init` again when you are ready.");
|
|
@@ -2695,8 +3040,8 @@ async function cmdInit(flags) {
|
|
|
2695
3040
|
}
|
|
2696
3041
|
console.log("");
|
|
2697
3042
|
console.log("🎉 EchoMem is ready.");
|
|
2698
|
-
console.log(" • MCP memory is configured for
|
|
2699
|
-
console.log(" •
|
|
3043
|
+
console.log(" • MCP memory is configured for every coding agent installed on this machine.");
|
|
3044
|
+
console.log(" • Echo Desktop shows connection status and manages this device credential.");
|
|
2700
3045
|
console.log(' • Try it now: ask your agent — "search my EchoMem for what I\'ve been working on and recap it."');
|
|
2701
3046
|
}
|
|
2702
3047
|
/**
|
|
@@ -2710,7 +3055,7 @@ function writeMemoryGuidanceForTargets(targets) {
|
|
|
2710
3055
|
if (t.id === "codex" && t.kind === "command")
|
|
2711
3056
|
files.set(path.join(t.detectDir, "AGENTS.md"), "Codex");
|
|
2712
3057
|
if (t.id === "claude-code" || t.id === "claude-desktop")
|
|
2713
|
-
files.set(
|
|
3058
|
+
files.set(home(".claude", "CLAUDE.md"), "Claude");
|
|
2714
3059
|
}
|
|
2715
3060
|
for (const [file, label] of files) {
|
|
2716
3061
|
try {
|
|
@@ -2745,28 +3090,22 @@ function writeCodexSkillsForTargets(targets) {
|
|
|
2745
3090
|
}
|
|
2746
3091
|
}
|
|
2747
3092
|
function writeLifecycleHooksForTargets(targets) {
|
|
2748
|
-
const clients =
|
|
3093
|
+
const clients = new Set();
|
|
2749
3094
|
if (targets.some((target) => target.id === "codex"))
|
|
2750
|
-
clients.
|
|
3095
|
+
clients.add("codex");
|
|
2751
3096
|
if (targets.some((target) => target.id === "claude-code"))
|
|
2752
|
-
clients.
|
|
2753
|
-
if (clients.
|
|
3097
|
+
clients.add("claude-code");
|
|
3098
|
+
if (clients.size === 0) {
|
|
2754
3099
|
console.log("ℹ️ No hook-capable Codex or Claude Code client was detected; private-save checkpoint hooks were not installed.");
|
|
2755
3100
|
return;
|
|
2756
3101
|
}
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
console.log(" Codex: start a new session and run /hooks once to review and trust the hook.");
|
|
2765
|
-
}
|
|
2766
|
-
}
|
|
2767
|
-
catch (error) {
|
|
2768
|
-
console.log(`ℹ️ Could not install EchoMem hooks for ${client}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2769
|
-
}
|
|
3102
|
+
const mode = clients.size === 2 ? "both" : [...clients][0];
|
|
3103
|
+
const sourcePaths = installSourceSessionHooks(mode);
|
|
3104
|
+
const savePaths = installSaveCheckpointHooks(mode);
|
|
3105
|
+
const paths = [...new Set([...sourcePaths, ...savePaths])];
|
|
3106
|
+
console.log(`✅ Installed EchoMem source-session and private-save hooks:\n${paths.map((p) => ` - ${p}`).join("\n")}`);
|
|
3107
|
+
if (clients.has("codex")) {
|
|
3108
|
+
console.log(" Codex: start a new session and run /hooks once to review and trust the hook.");
|
|
2770
3109
|
}
|
|
2771
3110
|
}
|
|
2772
3111
|
async function cmdUpdate(flags) {
|
|
@@ -2782,7 +3121,7 @@ function selectSetupTargets(requested, all) {
|
|
|
2782
3121
|
return fs.existsSync(path.dirname(client.configPath));
|
|
2783
3122
|
if (client.kind === "command")
|
|
2784
3123
|
return fs.existsSync(client.detectDir);
|
|
2785
|
-
return client.id === "claude-code" &&
|
|
3124
|
+
return client.id === "claude-code" && fs.existsSync(home(".claude"));
|
|
2786
3125
|
});
|
|
2787
3126
|
}
|
|
2788
3127
|
return requested ? knownClients().filter((client) => client.id === requested) : detectClients();
|
|
@@ -2818,7 +3157,7 @@ async function cmdLogin(flags) {
|
|
|
2818
3157
|
return true;
|
|
2819
3158
|
}
|
|
2820
3159
|
// Browser path: this bridge does only account/device authentication. It intentionally exposes
|
|
2821
|
-
// no local-history routes; `init` owns
|
|
3160
|
+
// no local-history routes; `init` owns scan consent, reporting, and optional extraction.
|
|
2822
3161
|
console.log("Opening your browser to connect this device locally…");
|
|
2823
3162
|
const { port, nonce } = localBridgeOptions(flags);
|
|
2824
3163
|
const srv = await startCallbackServer({ port, nonce, flow: "login" });
|
|
@@ -2846,7 +3185,7 @@ async function cmdLogin(flags) {
|
|
|
2846
3185
|
}
|
|
2847
3186
|
/**
|
|
2848
3187
|
* The local-history onboarding flow. Existing device credentials are reused when available; a
|
|
2849
|
-
* fresh device stays in this same bridge and asks for login only after permission.
|
|
3188
|
+
* fresh device stays in this same bridge and asks for login only after permission and report.
|
|
2850
3189
|
*/
|
|
2851
3190
|
async function cmdOnboarding(flags) {
|
|
2852
3191
|
const store = new KeyStore();
|
|
@@ -2858,17 +3197,149 @@ async function cmdOnboarding(flags) {
|
|
|
2858
3197
|
console.log("Opening your browser for EchoMem onboarding…");
|
|
2859
3198
|
const { port, nonce } = localBridgeOptions(flags);
|
|
2860
3199
|
let stats = null;
|
|
3200
|
+
let forensicReport = null;
|
|
3201
|
+
let forensicConsent = "pending";
|
|
3202
|
+
let forensicScanStarted = false;
|
|
3203
|
+
const forensicStartedAt = Date.now();
|
|
3204
|
+
let forensicStageStartedAt = forensicStartedAt;
|
|
3205
|
+
let forensicStage = "starting";
|
|
3206
|
+
let forensicOverall = 0;
|
|
3207
|
+
let forensicProgress = {
|
|
3208
|
+
status: "running",
|
|
3209
|
+
scanned: 0,
|
|
3210
|
+
total: 0,
|
|
3211
|
+
stage: forensicStage,
|
|
3212
|
+
label: forensicStageLabel(forensicStage),
|
|
3213
|
+
stageDone: 0,
|
|
3214
|
+
stageTotal: 0,
|
|
3215
|
+
overall: 0,
|
|
3216
|
+
elapsedMs: 0,
|
|
3217
|
+
stageElapsedMs: 0,
|
|
3218
|
+
updatedAt: forensicStartedAt,
|
|
3219
|
+
};
|
|
2861
3220
|
const srv = await startCallbackServer({
|
|
2862
3221
|
port,
|
|
2863
3222
|
nonce,
|
|
2864
3223
|
flow: "onboarding",
|
|
2865
3224
|
initialToken,
|
|
2866
|
-
|
|
3225
|
+
requireReportConsent: true,
|
|
3226
|
+
getStats: () => stats,
|
|
3227
|
+
getReport: () => forensicReport,
|
|
3228
|
+
getReportProgress: () => {
|
|
3229
|
+
if (forensicProgress.status !== "running")
|
|
3230
|
+
return forensicProgress;
|
|
3231
|
+
const now = Date.now();
|
|
3232
|
+
const sinceWorkerUpdate = Math.max(0, now - forensicProgress.updatedAt);
|
|
3233
|
+
return {
|
|
3234
|
+
...forensicProgress,
|
|
3235
|
+
elapsedMs: forensicProgress.elapsedMs + sinceWorkerUpdate,
|
|
3236
|
+
stageElapsedMs: forensicProgress.stageElapsedMs + sinceWorkerUpdate,
|
|
3237
|
+
updatedAt: now,
|
|
3238
|
+
};
|
|
3239
|
+
},
|
|
3240
|
+
onReportConsent: (allowed) => {
|
|
3241
|
+
if (!allowed) {
|
|
3242
|
+
forensicConsent = "declined";
|
|
3243
|
+
forensicProgress = {
|
|
3244
|
+
status: "failed",
|
|
3245
|
+
scanned: 0,
|
|
3246
|
+
total: 0,
|
|
3247
|
+
stage: "failed",
|
|
3248
|
+
label: "Local scan skipped",
|
|
3249
|
+
stageDone: 0,
|
|
3250
|
+
stageTotal: 0,
|
|
3251
|
+
overall: 0,
|
|
3252
|
+
elapsedMs: Date.now() - forensicStartedAt,
|
|
3253
|
+
stageElapsedMs: Date.now() - forensicStageStartedAt,
|
|
3254
|
+
updatedAt: Date.now(),
|
|
3255
|
+
error: {
|
|
3256
|
+
code: "REPORT_SCAN_DECLINED",
|
|
3257
|
+
message: "Local file analysis was skipped. EchoMem can still connect, save conversations, and import memories.",
|
|
3258
|
+
},
|
|
3259
|
+
};
|
|
3260
|
+
return;
|
|
3261
|
+
}
|
|
3262
|
+
forensicConsent = "allowed";
|
|
3263
|
+
const now = Date.now();
|
|
3264
|
+
forensicStage = "starting";
|
|
3265
|
+
forensicStageStartedAt = now;
|
|
3266
|
+
forensicOverall = 0;
|
|
3267
|
+
forensicProgress = {
|
|
3268
|
+
status: "running",
|
|
3269
|
+
scanned: 0,
|
|
3270
|
+
total: 0,
|
|
3271
|
+
stage: forensicStage,
|
|
3272
|
+
label: forensicStageLabel(forensicStage),
|
|
3273
|
+
stageDone: 0,
|
|
3274
|
+
stageTotal: 0,
|
|
3275
|
+
overall: 0,
|
|
3276
|
+
elapsedMs: now - forensicStartedAt,
|
|
3277
|
+
stageElapsedMs: 0,
|
|
3278
|
+
updatedAt: now,
|
|
3279
|
+
};
|
|
3280
|
+
startForensicScan();
|
|
3281
|
+
},
|
|
2867
3282
|
});
|
|
2868
3283
|
const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
|
|
2869
3284
|
openBrowser(localSetupUrl);
|
|
2870
3285
|
console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
|
|
2871
3286
|
console.log("Waiting for local-history onboarding for up to 15 minutes…");
|
|
3287
|
+
const startForensicScan = () => {
|
|
3288
|
+
if (forensicScanStarted || forensicConsent !== "allowed")
|
|
3289
|
+
return;
|
|
3290
|
+
forensicScanStarted = true;
|
|
3291
|
+
// Build the local forensic "Context Doctor" report off-thread only after explicit consent.
|
|
3292
|
+
buildForensicReportOffThread((progress) => {
|
|
3293
|
+
const now = Date.now();
|
|
3294
|
+
const nextStage = progress.stage || forensicStage;
|
|
3295
|
+
if (nextStage !== forensicStage) {
|
|
3296
|
+
forensicStage = nextStage;
|
|
3297
|
+
forensicStageStartedAt = now;
|
|
3298
|
+
console.log(`Local scan: ${forensicStageLabel(forensicStage)}…`);
|
|
3299
|
+
}
|
|
3300
|
+
// Latched, so a caller that ever reports a smaller fraction cannot walk the bar backwards.
|
|
3301
|
+
forensicOverall = Math.max(forensicOverall, typeof progress.overall === "number" && Number.isFinite(progress.overall) ? progress.overall : 0);
|
|
3302
|
+
forensicProgress = {
|
|
3303
|
+
status: "running",
|
|
3304
|
+
scanned: progress.done,
|
|
3305
|
+
total: progress.total,
|
|
3306
|
+
stage: forensicStage,
|
|
3307
|
+
label: forensicStageLabel(forensicStage),
|
|
3308
|
+
detail: progress.detail,
|
|
3309
|
+
stageDone: typeof progress.stageDone === "number" && Number.isFinite(progress.stageDone)
|
|
3310
|
+
? Math.max(0, Math.floor(progress.stageDone))
|
|
3311
|
+
: 0,
|
|
3312
|
+
stageTotal: typeof progress.stageTotal === "number" && Number.isFinite(progress.stageTotal)
|
|
3313
|
+
? Math.max(0, Math.floor(progress.stageTotal))
|
|
3314
|
+
: 0,
|
|
3315
|
+
overall: forensicOverall,
|
|
3316
|
+
elapsedMs: now - forensicStartedAt,
|
|
3317
|
+
stageElapsedMs: now - forensicStageStartedAt,
|
|
3318
|
+
updatedAt: now,
|
|
3319
|
+
};
|
|
3320
|
+
})
|
|
3321
|
+
.then((r) => {
|
|
3322
|
+
forensicReport = r;
|
|
3323
|
+
})
|
|
3324
|
+
.catch((e) => {
|
|
3325
|
+
const now = Date.now();
|
|
3326
|
+
forensicProgress = {
|
|
3327
|
+
status: "failed",
|
|
3328
|
+
scanned: forensicProgress.scanned,
|
|
3329
|
+
total: forensicProgress.total,
|
|
3330
|
+
stage: "failed",
|
|
3331
|
+
label: "Local scan failed",
|
|
3332
|
+
stageDone: forensicProgress.stageDone,
|
|
3333
|
+
stageTotal: forensicProgress.stageTotal,
|
|
3334
|
+
overall: forensicOverall,
|
|
3335
|
+
elapsedMs: now - forensicStartedAt,
|
|
3336
|
+
stageElapsedMs: now - forensicStageStartedAt,
|
|
3337
|
+
updatedAt: now,
|
|
3338
|
+
error: safeForensicError(e),
|
|
3339
|
+
};
|
|
3340
|
+
console.error(`Could not build the local report: ${e instanceof Error ? e.message : String(e)}`);
|
|
3341
|
+
});
|
|
3342
|
+
};
|
|
2872
3343
|
let token;
|
|
2873
3344
|
let key;
|
|
2874
3345
|
try {
|
|
@@ -2956,10 +3427,12 @@ async function cmdOnboarding(flags) {
|
|
|
2956
3427
|
let sessionSummary = {
|
|
2957
3428
|
total: quick.sessions,
|
|
2958
3429
|
codex: quick.codexCount,
|
|
2959
|
-
claudeCode: quick.
|
|
3430
|
+
claudeCode: quick.claudeCodeCount,
|
|
3431
|
+
cowork: quick.coworkCount,
|
|
2960
3432
|
};
|
|
2961
|
-
stats =
|
|
3433
|
+
stats = await buildStatsPayload([], {
|
|
2962
3434
|
partial: true,
|
|
3435
|
+
skipMemoryCount: true,
|
|
2963
3436
|
sessions: sessionSummary,
|
|
2964
3437
|
migratable,
|
|
2965
3438
|
discovery: { phase: "quick", exact: false },
|
|
@@ -2991,10 +3464,12 @@ async function cmdOnboarding(flags) {
|
|
|
2991
3464
|
sessionSummary = {
|
|
2992
3465
|
total: cloudSummary.sessions,
|
|
2993
3466
|
codex: cloudSummary.codexCount,
|
|
2994
|
-
claudeCode: cloudSummary.
|
|
3467
|
+
claudeCode: cloudSummary.claudeCodeCount,
|
|
3468
|
+
cowork: cloudSummary.coworkCount,
|
|
2995
3469
|
};
|
|
2996
|
-
const cloudPayload =
|
|
3470
|
+
const cloudPayload = await buildStatsPayload([], {
|
|
2997
3471
|
partial: true,
|
|
3472
|
+
skipMemoryCount: true,
|
|
2998
3473
|
sessions: sessionSummary,
|
|
2999
3474
|
migratable,
|
|
3000
3475
|
discovery: { phase: "account", exact: false },
|
|
@@ -3027,10 +3502,12 @@ async function cmdOnboarding(flags) {
|
|
|
3027
3502
|
sessionSummary = {
|
|
3028
3503
|
total: unavailableSummary.sessions,
|
|
3029
3504
|
codex: unavailableSummary.codexCount,
|
|
3030
|
-
claudeCode: unavailableSummary.
|
|
3505
|
+
claudeCode: unavailableSummary.claudeCodeCount,
|
|
3506
|
+
cowork: unavailableSummary.coworkCount,
|
|
3031
3507
|
};
|
|
3032
|
-
const unavailablePayload =
|
|
3508
|
+
const unavailablePayload = await buildStatsPayload([], {
|
|
3033
3509
|
partial: true,
|
|
3510
|
+
skipMemoryCount: true,
|
|
3034
3511
|
sessions: sessionSummary,
|
|
3035
3512
|
migratable,
|
|
3036
3513
|
discovery: { phase: "account", exact: false },
|
|
@@ -3066,8 +3543,9 @@ async function cmdOnboarding(flags) {
|
|
|
3066
3543
|
migratable = migratableFromDiscovery(initialExact);
|
|
3067
3544
|
latestPendingEstimate = migratable.pending;
|
|
3068
3545
|
sessionSummary = sessionsFromDiscovery(initialExact);
|
|
3069
|
-
const partialPayload = withCandidateSessions(
|
|
3546
|
+
const partialPayload = withCandidateSessions(await buildStatsPayload([], {
|
|
3070
3547
|
partial: true,
|
|
3548
|
+
skipMemoryCount: true,
|
|
3071
3549
|
sessions: sessionSummary,
|
|
3072
3550
|
migratable,
|
|
3073
3551
|
discovery: { phase: "exact", exact: true },
|
|
@@ -3115,10 +3593,12 @@ async function cmdOnboarding(flags) {
|
|
|
3115
3593
|
migratable = migratableFromDiscovery(reconciled);
|
|
3116
3594
|
latestPendingEstimate = migratable.pending;
|
|
3117
3595
|
sessionSummary = sessionsFromDiscovery(reconciled);
|
|
3118
|
-
const reconciledPayload = withCandidateSessions(
|
|
3596
|
+
const reconciledPayload = withCandidateSessions(await buildStatsPayload([], {
|
|
3597
|
+
partial: true,
|
|
3598
|
+
skipMemoryCount: true,
|
|
3119
3599
|
sessions: sessionSummary,
|
|
3120
3600
|
migratable,
|
|
3121
|
-
discovery: { phase: "
|
|
3601
|
+
discovery: { phase: "exact", exact: true },
|
|
3122
3602
|
}), reconciled);
|
|
3123
3603
|
if (generation !== refreshGeneration)
|
|
3124
3604
|
return;
|
|
@@ -3133,11 +3613,20 @@ async function cmdOnboarding(flags) {
|
|
|
3133
3613
|
failed: 0,
|
|
3134
3614
|
extracted: 0,
|
|
3135
3615
|
});
|
|
3616
|
+
const fullPayload = withCandidateSessions(await buildCollectedStatsPayloadOffThread({
|
|
3617
|
+
sessions: sessionSummary,
|
|
3618
|
+
migratable,
|
|
3619
|
+
discovery: { phase: "full", exact: true },
|
|
3620
|
+
}), reconciled);
|
|
3621
|
+
if (generation !== refreshGeneration)
|
|
3622
|
+
return;
|
|
3623
|
+
stats = fullPayload;
|
|
3624
|
+
srv.setStats(fullPayload);
|
|
3136
3625
|
})().catch((e) => {
|
|
3137
3626
|
if (generation !== refreshGeneration)
|
|
3138
3627
|
return;
|
|
3139
|
-
console.error(`[echomem] optional
|
|
3140
|
-
publishOptionalStatsFallback(generation, "
|
|
3628
|
+
console.error(`[echomem] optional full local-history stats unavailable; continuing (${errorCode(e) || "FULL_STATS_FAILED"})`);
|
|
3629
|
+
publishOptionalStatsFallback(generation, "FULL_STATS_FAILED", true);
|
|
3141
3630
|
});
|
|
3142
3631
|
return initialExact;
|
|
3143
3632
|
}).catch((e) => {
|
|
@@ -3597,7 +4086,7 @@ Usage:
|
|
|
3597
4086
|
echomem-mcp Run the MCP server (stdio; default — used by your editor)
|
|
3598
4087
|
echomem-mcp setup [--client X] Detect editor, write its MCP config, then connect this device
|
|
3599
4088
|
echomem-mcp setup --skip-login Write MCP config without opening login/browser
|
|
3600
|
-
echomem-mcp setup --force-headless Explicitly replace
|
|
4089
|
+
echomem-mcp setup --force-headless Explicitly replace valid Echo Desktop-managed entries
|
|
3601
4090
|
echomem-mcp setup --no-codex-skills Skip installing the bundled EchoMem Codex skills
|
|
3602
4091
|
echomem-mcp update --all Install this bridge durably + repoint detected clients; no login/browser
|
|
3603
4092
|
echomem-mcp update --client X Repoint one MCP client; no login/browser
|
|
@@ -3608,6 +4097,7 @@ Usage:
|
|
|
3608
4097
|
echomem-mcp status Show token/key/clients
|
|
3609
4098
|
echomem-mcp doctor [--no-network] Diagnose configured client bridge versions
|
|
3610
4099
|
echomem-mcp logout Remove stored credentials
|
|
4100
|
+
echomem-mcp report [--json] Your AI coding memory audit (local, no login, $0)
|
|
3611
4101
|
echomem-mcp migrate [--since D] Import your existing Codex/Claude history into your memory
|
|
3612
4102
|
echomem-mcp migrate --estimate Estimate migration size/time metadata without uploading transcripts
|
|
3613
4103
|
echomem-mcp migrate --max-chars N Import only sessions up to N assembled text chars
|
|
@@ -3659,6 +4149,9 @@ export async function runCli(argv) {
|
|
|
3659
4149
|
case "logout":
|
|
3660
4150
|
cmdLogout();
|
|
3661
4151
|
return true;
|
|
4152
|
+
case "report":
|
|
4153
|
+
await runReport(flags);
|
|
4154
|
+
return true;
|
|
3662
4155
|
case "migrate":
|
|
3663
4156
|
await cmdMigrate(flags);
|
|
3664
4157
|
return true;
|