@echomem/mcp 1.4.7 → 1.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +35 -9
  2. package/assets/canonical-scorer/README.md +18 -0
  3. package/assets/canonical-scorer/analyze-10-problems.mjs +857 -0
  4. package/assets/canonical-scorer/build-session-waste-dashboard.mjs +1628 -0
  5. package/assets/canonical-scorer/golden_anchors.mjs +83 -0
  6. package/assets/canonical-scorer/optimizable_detail.mjs +633 -0
  7. package/assets/hud/claude.svg +1 -0
  8. package/assets/hud/codex.svg +1 -0
  9. package/assets/hud/session-viewer.html +35 -0
  10. package/dist/city/chaos-to-clarity-pencil.html +582 -0
  11. package/dist/city/echo-ai-city-only.html +1126 -109
  12. package/dist/city/echo-ai-city-only.template.html +1126 -109
  13. package/dist/city/echo-face-cutout.png +0 -0
  14. package/dist/city/pencil-pie-generator.html +883 -0
  15. package/dist/city/pencil-webgl-landscape.html +1239 -0
  16. package/dist/city/spatial-fan-story.html +479 -0
  17. package/dist/codex-session-files.js +283 -0
  18. package/dist/codex-sync.js +7 -2
  19. package/dist/context-analysis/canonical-golden.js +47 -0
  20. package/dist/context-analysis/claude-native-canonical.js +1193 -0
  21. package/dist/context-analysis/vendored-canonical.js +793 -0
  22. package/dist/context-analysis/workspace-report.js +1838 -0
  23. package/dist/context-metrics/calculate.js +56 -0
  24. package/dist/context-metrics/model-limits.js +26 -0
  25. package/dist/context-metrics/types.js +1 -0
  26. package/dist/forensics-10-problems.js +7 -6
  27. package/dist/forensics.js +863 -132
  28. package/dist/hud/adapters.js +8 -4
  29. package/dist/hud/autostart.js +66 -0
  30. package/dist/hud/cli.js +31 -0
  31. package/dist/hud/electron-main.js +182 -19
  32. package/dist/hud/metric.js +13 -4
  33. package/dist/hud/monitor.js +171 -84
  34. package/dist/hud/preload.cjs +3 -0
  35. package/dist/hud/server.js +321 -4
  36. package/dist/hud/web.js +880 -270
  37. package/dist/index.js +122 -24
  38. package/dist/local-data-paths.js +87 -0
  39. package/dist/migrate.js +55 -29
  40. package/dist/report.js +101 -40
  41. package/dist/setup-page.js +4257 -245
  42. package/dist/setup-preview.js +245 -0
  43. package/dist/setup.js +786 -75
  44. package/dist/v1-contract.js +20 -2
  45. package/package.json +6 -4
  46. package/templates/echomem-recall.md +2 -2
@@ -0,0 +1,283 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveCodexSessionRoots, resolveReadableDirectory, } from "./local-data-paths.js";
4
+ const ROLLOUT_FILE_RE = /^rollout-.*\.jsonl$/;
5
+ const UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
6
+ const FIRST_LINE_CHUNK_BYTES = 64 * 1024;
7
+ const MAX_FIRST_LINE_BYTES = 2 * 1024 * 1024;
8
+ function initialJsonLines(file) {
9
+ const fd = fs.openSync(file, "r");
10
+ try {
11
+ const chunk = Buffer.allocUnsafe(FIRST_LINE_CHUNK_BYTES);
12
+ const buffers = [];
13
+ let total = 0;
14
+ while (total < MAX_FIRST_LINE_BYTES) {
15
+ const remaining = Math.min(chunk.length, MAX_FIRST_LINE_BYTES - total);
16
+ const bytesRead = fs.readSync(fd, chunk, 0, remaining, null);
17
+ if (bytesRead <= 0)
18
+ break;
19
+ const piece = Buffer.from(chunk.subarray(0, bytesRead));
20
+ buffers.push(piece);
21
+ total += bytesRead;
22
+ }
23
+ if (!buffers.length)
24
+ return [];
25
+ return Buffer.concat(buffers).toString("utf8").split(/\r?\n/).filter(Boolean);
26
+ }
27
+ finally {
28
+ fs.closeSync(fd);
29
+ }
30
+ }
31
+ function isRecord(value) {
32
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
33
+ }
34
+ function normalizedSessionId(value) {
35
+ const trimmed = value.trim();
36
+ return new RegExp(`^${UUID_RE.source}$`, "i").test(trimmed) ? trimmed.toLowerCase() : trimmed;
37
+ }
38
+ function metadataFromFile(file) {
39
+ try {
40
+ for (const line of initialJsonLines(file)) {
41
+ let row;
42
+ try {
43
+ row = JSON.parse(line);
44
+ }
45
+ catch {
46
+ continue;
47
+ }
48
+ if (!isRecord(row) || row.type !== "session_meta" || !isRecord(row.payload))
49
+ continue;
50
+ const payload = row.payload;
51
+ const rawId = typeof payload.id === "string" ? normalizedSessionId(payload.id) : "";
52
+ // payload.timestamp is the semantic session launch time. The row timestamp is only when the
53
+ // first metadata event was persisted and can trail launch by several minutes.
54
+ const rawTimestamp = typeof payload.timestamp === "string"
55
+ ? payload.timestamp
56
+ : typeof row.timestamp === "string"
57
+ ? row.timestamp
58
+ : "";
59
+ const timestamp = rawTimestamp ? Date.parse(rawTimestamp) : NaN;
60
+ const source = payload.source;
61
+ const isSubagent = isRecord(source) && Object.hasOwn(source, "subagent");
62
+ const hasParent = typeof payload.parent_thread_id === "string" && payload.parent_thread_id.length > 0;
63
+ return {
64
+ id: rawId || null,
65
+ startedAtMs: Number.isFinite(timestamp) ? timestamp : null,
66
+ userInitiated: !isSubagent && !hasParent,
67
+ };
68
+ }
69
+ return { id: null, startedAtMs: null, userInitiated: null };
70
+ }
71
+ catch {
72
+ return { id: null, startedAtMs: null, userInitiated: null };
73
+ }
74
+ }
75
+ function filenameUuid(file) {
76
+ return path.basename(file).match(UUID_RE)?.[1]?.toLowerCase() ?? null;
77
+ }
78
+ function filenameTimestamp(file) {
79
+ const match = path.basename(file).match(/rollout-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})/);
80
+ if (!match)
81
+ return null;
82
+ // Codex rollout filenames encode the originating machine's local wall-clock time, not UTC.
83
+ // This process is reading that same local profile, so parse without a trailing Z and let DST apply.
84
+ const parsed = Date.parse(`${match[1]}T${match[2]}:${match[3]}:${match[4]}`);
85
+ return Number.isFinite(parsed) ? parsed : null;
86
+ }
87
+ function lastEventTimestamp(file, size) {
88
+ const bytes = Math.min(size, MAX_FIRST_LINE_BYTES);
89
+ if (bytes <= 0)
90
+ return null;
91
+ let fd = null;
92
+ try {
93
+ fd = fs.openSync(file, "r");
94
+ const buffer = Buffer.allocUnsafe(bytes);
95
+ const offset = Math.max(0, size - bytes);
96
+ const read = fs.readSync(fd, buffer, 0, bytes, offset);
97
+ const lines = buffer.toString("utf8", 0, read).split(/\r?\n/);
98
+ // The first line can be a partial tail when offset > 0; never trust it in that case.
99
+ const firstComplete = offset > 0 ? 1 : 0;
100
+ for (let index = lines.length - 1; index >= firstComplete; index -= 1) {
101
+ const line = lines[index]?.trim();
102
+ if (!line)
103
+ continue;
104
+ try {
105
+ const row = JSON.parse(line);
106
+ if (!isRecord(row) || typeof row.timestamp !== "string")
107
+ continue;
108
+ const timestamp = Date.parse(row.timestamp);
109
+ if (Number.isFinite(timestamp))
110
+ return timestamp;
111
+ }
112
+ catch {
113
+ /* keep looking for the last complete timestamped JSONL event */
114
+ }
115
+ }
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ finally {
121
+ if (fd != null)
122
+ fs.closeSync(fd);
123
+ }
124
+ return null;
125
+ }
126
+ function regularRolloutFiles(root) {
127
+ const files = [];
128
+ const visit = (dir) => {
129
+ let entries;
130
+ try {
131
+ entries = fs.readdirSync(dir, { withFileTypes: true });
132
+ }
133
+ catch {
134
+ return;
135
+ }
136
+ for (const entry of entries) {
137
+ const candidate = path.join(dir, entry.name);
138
+ if (entry.isSymbolicLink())
139
+ continue;
140
+ if (entry.isDirectory()) {
141
+ visit(candidate);
142
+ continue;
143
+ }
144
+ if (entry.isFile() && ROLLOUT_FILE_RE.test(entry.name))
145
+ files.push(candidate);
146
+ }
147
+ };
148
+ visit(root);
149
+ return files.sort();
150
+ }
151
+ function normalizedRoots(opts) {
152
+ const requested = opts.roots ? [...opts.roots] : resolveCodexSessionRoots(opts);
153
+ const includeArchived = opts.includeArchived !== false;
154
+ const roots = [];
155
+ const seen = new Set();
156
+ for (const candidate of requested.sort((left, right) => ((left.kind === "active" ? 0 : 1) - (right.kind === "active" ? 0 : 1)
157
+ || left.priority - right.priority
158
+ || left.path.localeCompare(right.path)))) {
159
+ if (!includeArchived && candidate.kind === "archived")
160
+ continue;
161
+ const resolved = resolveReadableDirectory(candidate.path);
162
+ if (!resolved || seen.has(resolved))
163
+ continue;
164
+ seen.add(resolved);
165
+ roots.push({ ...candidate, path: resolved });
166
+ }
167
+ return roots;
168
+ }
169
+ function candidateFor(file, root) {
170
+ let real;
171
+ let stat;
172
+ try {
173
+ const lstat = fs.lstatSync(file);
174
+ if (!lstat.isFile() || lstat.isSymbolicLink())
175
+ return null;
176
+ fs.accessSync(file, fs.constants.R_OK);
177
+ real = fs.realpathSync(file);
178
+ stat = fs.statSync(real);
179
+ if (!stat.isFile())
180
+ return null;
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ const metadata = metadataFromFile(real);
186
+ const uuid = filenameUuid(real);
187
+ const identitySource = metadata.id ? "metadata" : uuid ? "filename" : "path";
188
+ const identity = metadata.id || uuid || real;
189
+ return {
190
+ path: real,
191
+ rootKind: root.kind,
192
+ sessionKey: identitySource === "path" ? `path:${identity}` : `session:${identity}`,
193
+ identitySource,
194
+ identityRank: identitySource === "metadata" ? 0 : identitySource === "filename" ? 1 : 2,
195
+ startedAtMs: metadata.startedAtMs ?? filenameTimestamp(real),
196
+ lastEventAtMs: lastEventTimestamp(real, stat.size),
197
+ userInitiated: metadata.userInitiated,
198
+ size: stat.size,
199
+ mtimeMs: stat.mtimeMs,
200
+ };
201
+ }
202
+ function candidateOrder(left, right) {
203
+ const leftRoot = left.rootKind === "active" ? 0 : 1;
204
+ const rightRoot = right.rootKind === "active" ? 0 : 1;
205
+ return left.identityRank - right.identityRank
206
+ || (right.lastEventAtMs ?? Number.NEGATIVE_INFINITY) - (left.lastEventAtMs ?? Number.NEGATIVE_INFINITY)
207
+ || right.size - left.size
208
+ || leftRoot - rightRoot
209
+ || right.mtimeMs - left.mtimeMs
210
+ || left.path.localeCompare(right.path);
211
+ }
212
+ function chronologicalOrder(left, right) {
213
+ if (left.startedAtMs != null && right.startedAtMs != null) {
214
+ if (left.startedAtMs !== right.startedAtMs)
215
+ return left.startedAtMs - right.startedAtMs;
216
+ return left.sessionKey.localeCompare(right.sessionKey) || left.path.localeCompare(right.path);
217
+ }
218
+ if (left.startedAtMs != null)
219
+ return -1;
220
+ if (right.startedAtMs != null)
221
+ return 1;
222
+ return left.sessionKey.localeCompare(right.sessionKey) || left.path.localeCompare(right.path);
223
+ }
224
+ /**
225
+ * Discover one deterministic, de-duplicated file per Codex session across active and archived roots.
226
+ * JSONL files are never merged. A duplicate keeps the strongest, latest, most complete candidate and
227
+ * is surfaced in diagnostics; active vs archived is only a tie-break after semantic completeness.
228
+ */
229
+ export function discoverCodexSessionFiles(opts = {}) {
230
+ const roots = normalizedRoots(opts);
231
+ const diagnostics = {
232
+ scannedFiles: 0,
233
+ skippedUnreadableFiles: 0,
234
+ filenameIdentityFallbacks: 0,
235
+ pathIdentityFallbacks: 0,
236
+ unclassifiedSessions: 0,
237
+ conflicts: [],
238
+ };
239
+ const grouped = new Map();
240
+ for (const root of roots) {
241
+ for (const file of regularRolloutFiles(root.path)) {
242
+ diagnostics.scannedFiles += 1;
243
+ const candidate = candidateFor(file, root);
244
+ if (!candidate) {
245
+ diagnostics.skippedUnreadableFiles += 1;
246
+ continue;
247
+ }
248
+ if (candidate.identitySource === "filename")
249
+ diagnostics.filenameIdentityFallbacks += 1;
250
+ if (candidate.identitySource === "path")
251
+ diagnostics.pathIdentityFallbacks += 1;
252
+ if (candidate.userInitiated == null)
253
+ diagnostics.unclassifiedSessions += 1;
254
+ const list = grouped.get(candidate.sessionKey) || [];
255
+ list.push(candidate);
256
+ grouped.set(candidate.sessionKey, list);
257
+ }
258
+ }
259
+ const files = [];
260
+ for (const [sessionKey, candidates] of grouped) {
261
+ candidates.sort(candidateOrder);
262
+ const [kept, ...discarded] = candidates;
263
+ if (!kept)
264
+ continue;
265
+ if (!opts.userInitiatedOnly || kept.userInitiated === true) {
266
+ const { identityRank: _identityRank, ...file } = kept;
267
+ files.push(file);
268
+ }
269
+ for (const duplicate of discarded) {
270
+ diagnostics.conflicts.push({
271
+ sessionKey,
272
+ keptPath: kept.path,
273
+ keptRootKind: kept.rootKind,
274
+ discardedPath: duplicate.path,
275
+ discardedRootKind: duplicate.rootKind,
276
+ });
277
+ }
278
+ }
279
+ files.sort(chronologicalOrder);
280
+ diagnostics.conflicts.sort((left, right) => left.sessionKey.localeCompare(right.sessionKey)
281
+ || left.discardedPath.localeCompare(right.discardedPath));
282
+ return { roots, files, diagnostics };
283
+ }
@@ -1,10 +1,10 @@
1
1
  import fs from "node:fs";
2
- import os from "node:os";
3
2
  import path from "node:path";
4
3
  import { StringDecoder } from "node:string_decoder";
5
4
  import { createHash } from "node:crypto";
6
5
  import axios from "axios";
7
6
  import { KeyStore } from "./keystore.js";
7
+ import { resolveCodexSessionsDir } from "./local-data-paths.js";
8
8
  const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
9
9
  const DEFAULT_DAYS = 7;
10
10
  const DEFAULT_LIMIT = 50;
@@ -427,7 +427,12 @@ export async function syncCodexUsage(argv) {
427
427
  const days = flags.all ? Number.POSITIVE_INFINITY : Number(flags.days ?? DEFAULT_DAYS);
428
428
  const limit = Number(flags.limit ?? DEFAULT_LIMIT);
429
429
  const dryRun = flags["dry-run"] === true;
430
- const root = typeof flags.root === "string" ? flags.root : path.join(os.homedir(), ".codex", "sessions");
430
+ const root = typeof flags.root === "string" ? flags.root : resolveCodexSessionsDir();
431
+ if (!root) {
432
+ console.error("No readable Codex sessions directory was found. Check CODEX_HOME or pass --root explicitly.");
433
+ process.exitCode = 1;
434
+ return;
435
+ }
431
436
  const token = new KeyStore().getToken();
432
437
  if (!dryRun && !token) {
433
438
  console.error("Not logged in. Run `echomem-mcp login` first, or re-run with --dry-run.");
@@ -0,0 +1,47 @@
1
+ import { buildVendoredCanonicalReport } from "./vendored-canonical.js";
2
+ export const CANONICAL_PROBLEM_COUNT = 13;
3
+ export const CANONICAL_PROBLEMS = [
4
+ { id: "P01", name: "Outdated Images & Screenshots" },
5
+ { id: "P02", name: "Ignored User Instructions" },
6
+ { id: "P03", name: "Old Files" },
7
+ { id: "P04", name: "Repeated Setup After Compaction" },
8
+ { id: "P05", name: "Premature Completion Fixes" },
9
+ { id: "P06", name: "Session Re-heat" },
10
+ { id: "P07", name: "Failed Turn Leftovers" },
11
+ { id: "P08", name: "Repeated Git Check Logs" },
12
+ { id: "P09", name: "Repeated Fix Attempts" },
13
+ { id: "P10", name: "Repeated Search" },
14
+ { id: "P11", name: "Visual Debug Logs" },
15
+ { id: "P12", name: "Tool Call Logs" },
16
+ { id: "P13", name: "Agent's Reasoning Notes" },
17
+ ];
18
+ export function analyzeCanonicalCodexSession(options) {
19
+ void options.workspacePath;
20
+ return buildCanonicalGoldenReport({ sources: ["codex"], codexSessionPaths: [options.sessionPath] });
21
+ }
22
+ export function analyzeCanonicalClaudeSession(options) {
23
+ void options.workspacePath;
24
+ return buildCanonicalGoldenReport({ sources: ["claude-code"], claudeSessionPaths: [options.sessionPath] });
25
+ }
26
+ export async function buildCanonicalGoldenReport(opts) {
27
+ const scoringMode = "episode-outcome";
28
+ const report = await buildVendoredCanonicalReport({
29
+ limitFiles: opts?.limitFiles,
30
+ sources: opts?.sources,
31
+ codexSessionPaths: opts?.codexSessionPaths,
32
+ claudeSessionPaths: opts?.claudeSessionPaths,
33
+ strictSessionErrors: opts?.strictSessionErrors,
34
+ sessionPaths: opts?.sessionPaths,
35
+ onProgress: opts?.onProgress,
36
+ });
37
+ return {
38
+ ...report,
39
+ canonical: {
40
+ scoringMode,
41
+ algorithm: "provider-native-context-golden-standard-v3",
42
+ source: "context-golden-standard",
43
+ bucketContract: ["keep_oh", "keep_prod", "opt_dup", "opt_refind", "opt_dead"],
44
+ wasteRule: "raw-residue-with-p-attribution",
45
+ },
46
+ };
47
+ }