@echomem/mcp 1.3.2 → 1.4.1

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/dist/forensics.js CHANGED
@@ -17,7 +17,26 @@
17
17
  import fs from "node:fs";
18
18
  import os from "node:os";
19
19
  import path from "node:path";
20
+ import { execFileSync } from "node:child_process";
20
21
  import { eachLine, walk } from "./report.js";
22
+ /** Fast, safe per-repo commit count since a date. Metadata only (no diffs) so it stays cheap;
23
+ * `rev-list --count` is git-indexed. Any failure (not a repo, git missing, timeout) → 0. */
24
+ function gitCommitCount(cwd, sinceIso) {
25
+ if (!cwd)
26
+ return 0;
27
+ try {
28
+ const args = ["-C", cwd, "rev-list", "--count"];
29
+ if (sinceIso)
30
+ args.push(`--since=${sinceIso}`);
31
+ args.push("HEAD");
32
+ const out = execFileSync("git", args, { timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
33
+ const n = parseInt(out, 10);
34
+ return Number.isFinite(n) ? n : 0;
35
+ }
36
+ catch {
37
+ return 0;
38
+ }
39
+ }
21
40
  // ---------------------------------------------------------------------------
22
41
  // Constants (named on purpose — no magic numbers downstream)
23
42
  // ---------------------------------------------------------------------------
@@ -85,7 +104,7 @@ function round(value, digits = 1) {
85
104
  function tokensFromChars(chars = 0) {
86
105
  return Math.round((chars || 0) / CHARS_PER_TOKEN);
87
106
  }
88
- function repoLabel(cwd) {
107
+ export function repoLabel(cwd) {
89
108
  if (!cwd)
90
109
  return "home";
91
110
  const m = cwd.match(/worktrees\/[^/]+\/(.+)$/);
@@ -172,9 +191,11 @@ class Forensics {
172
191
  const name = repoLabel(cwd);
173
192
  let r = this.repos.get(name);
174
193
  if (!r) {
175
- r = { name, sessions: new Set(), assistantMessages: 0, cold: 0, cacheWrite: 0, cacheRead: 0, output: 0, reads: 0, rereads: 0, staleRereads: 0, userTimestamps: [] };
194
+ r = { name, cwd: null, sessions: new Set(), assistantMessages: 0, cold: 0, cacheWrite: 0, cacheRead: 0, output: 0, codexTokens: 0, claudeTokens: 0, reads: 0, rereads: 0, staleRereads: 0, userTimestamps: [], byDay: new Map() };
176
195
  this.repos.set(name, r);
177
196
  }
197
+ if (cwd && !r.cwd)
198
+ r.cwd = cwd; // remember a real working dir for this repo (for the git pass)
178
199
  return r;
179
200
  }
180
201
  fileRecord(p, repo) {
@@ -201,7 +222,7 @@ class Forensics {
201
222
  this.allSessionIds.add(id);
202
223
  }
203
224
  /** assistant-turn token usage (already split into cold/cacheWrite/cacheRead/output). */
204
- recordUsage(model, cwd, session, u) {
225
+ recordUsage(model, cwd, session, u, provider, ms) {
205
226
  const m = this.modelBucket(model);
206
227
  m.messages += 1;
207
228
  m.cold += u.cold;
@@ -217,6 +238,16 @@ class Forensics {
217
238
  r.cacheWrite += u.cacheWrite;
218
239
  r.cacheRead += u.cacheRead;
219
240
  r.output += u.output;
241
+ // per-repo provider split → drives the city's per-cube colour (Codex green / Claude orange)
242
+ const turnTokens = u.cold + u.cacheWrite + u.cacheRead + u.output;
243
+ if (provider === "codex")
244
+ r.codexTokens += turnTokens;
245
+ else
246
+ r.claudeTokens += turnTokens;
247
+ if (ms != null && turnTokens > 0) {
248
+ const d = localDay(ms);
249
+ r.byDay.set(d, (r.byDay.get(d) || 0) + turnTokens);
250
+ }
220
251
  }
221
252
  }
222
253
  recordEdit(target, cwd) {
@@ -369,9 +400,13 @@ class Forensics {
369
400
  const allUserTimestamps = [];
370
401
  const dayAttention = new Map();
371
402
  const repoOut = [];
403
+ const sinceIso = this.minTs != null ? new Date(this.minTs).toISOString() : null;
404
+ let totalCommits = 0;
372
405
  for (const repo of this.repos.values()) {
373
406
  allUserTimestamps.push(...repo.userTimestamps);
374
407
  const wb = this.workBlocks(repo.userTimestamps);
408
+ const commits = gitCommitCount(repo.cwd, sinceIso); // fast git pass: commits landed in this repo during the scan window
409
+ totalCommits += commits;
375
410
  repoOut.push({
376
411
  name: repo.name, sessions: repo.sessions.size, assistantMessages: repo.assistantMessages,
377
412
  attentionHours: round(wb.attentionHours, 1), activeAttentionDays: wb.activeDays,
@@ -380,6 +415,10 @@ class Forensics {
380
415
  tokens: repo.cold + repo.cacheWrite + repo.cacheRead + repo.output,
381
416
  cost: round(costOf({ cold: repo.cold, cacheWrite: repo.cacheWrite, cacheRead: repo.cacheRead, output: repo.output }, CLAUDE_DEFAULT), 2),
382
417
  reads: repo.reads, rereads: repo.rereads, staleRereads: repo.staleRereads,
418
+ dominantProvider: repo.codexTokens >= repo.claudeTokens ? "codex" : "claude",
419
+ codexTokens: repo.codexTokens, claudeTokens: repo.claudeTokens,
420
+ commits,
421
+ daily: [...repo.byDay.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)),
383
422
  });
384
423
  for (const b of wb.blocks) {
385
424
  const d = localDay(b.start);
@@ -445,6 +484,7 @@ class Forensics {
445
484
  sessionCount: this.allSessionIds.size,
446
485
  repoCount: repoOut.length,
447
486
  activeDays: activeAttentionDays,
487
+ totalCommits,
448
488
  totalTokens, totalInputTokens: totalInput, coldInputTokens: cold, cacheWriteTokens: cacheWrite,
449
489
  cacheReadTokens: cacheRead, outputTokens: output,
450
490
  inputPct: totalTokens ? Math.round((totalInput / totalTokens) * 100) : 0,
@@ -493,7 +533,7 @@ function feedClaude(file, eng) {
493
533
  eng.recordUsage(o.message.model || "unknown", cwd, session, {
494
534
  cold: u.input_tokens || 0, cacheWrite: u.cache_creation_input_tokens || 0,
495
535
  cacheRead: u.cache_read_input_tokens || 0, output: u.output_tokens || 0,
496
- });
536
+ }, "claude", Number.isFinite(ts) ? ts : undefined);
497
537
  const blocks = Array.isArray(o.message.content) ? o.message.content : [];
498
538
  for (const b of blocks) {
499
539
  if (b?.type !== "tool_use")
@@ -626,8 +666,9 @@ function replayFile(eng, fe) {
626
666
  if (fe.lastTs != null)
627
667
  eng.noteTs(fe.lastTs);
628
668
  eng.noteSession(fe.session);
669
+ const provider = fe.source === "codex" ? "codex" : "claude";
629
670
  for (const u of fe.usage)
630
- eng.recordUsage(u.model, fe.cwd, fe.session, { cold: u.cold, cacheWrite: 0, cacheRead: u.cacheRead, output: u.output });
671
+ eng.recordUsage(u.model, fe.cwd, fe.session, { cold: u.cold, cacheWrite: 0, cacheRead: u.cacheRead, output: u.output }, provider, fe.firstTs ?? undefined);
631
672
  for (const e of fe.ev) {
632
673
  if (e.t === "m")
633
674
  eng.recordUserMsg(fe.cwd, fe.session, e.ms);
package/dist/migrate.js CHANGED
@@ -249,13 +249,16 @@ function hasClaudeText(content) {
249
249
  function fastSessionInfo(file, source) {
250
250
  let conversationKey = `${source === "codex" ? "codex" : "claude-code"}:${sha16(file)}`;
251
251
  let hasTextTurn = false;
252
+ let hasRealKey = false;
252
253
  for (const obj of initialJsonObjects(file)) {
253
254
  if (!isRecord(obj))
254
255
  continue;
255
256
  if (source === "codex" && obj.type === "session_meta") {
256
257
  const payload = isRecord(obj.payload) ? obj.payload : {};
257
- if (typeof payload.id === "string" && payload.id)
258
+ if (typeof payload.id === "string" && payload.id) {
258
259
  conversationKey = `codex:${payload.id}`;
260
+ hasRealKey = true;
261
+ }
259
262
  continue;
260
263
  }
261
264
  if (source === "codex") {
@@ -268,6 +271,7 @@ function fastSessionInfo(file, source) {
268
271
  }
269
272
  if (source === "claude-code" && typeof obj.sessionId === "string" && obj.sessionId) {
270
273
  conversationKey = `claude-code:${obj.sessionId}`;
274
+ hasRealKey = true;
271
275
  }
272
276
  if (source === "claude-code" && (obj.type === "user" || obj.type === "assistant")) {
273
277
  const message = isRecord(obj.message) ? obj.message : {};
@@ -275,7 +279,7 @@ function fastSessionInfo(file, source) {
275
279
  hasTextTurn = true;
276
280
  }
277
281
  }
278
- return { conversationKey, hasTextTurn };
282
+ return { conversationKey, hasTextTurn, hasRealKey };
279
283
  }
280
284
  function fastSessionEntries(opts = {}) {
281
285
  const out = [];
@@ -283,14 +287,19 @@ function fastSessionEntries(opts = {}) {
283
287
  for (const filePath of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
284
288
  const stat = statSafe(filePath);
285
289
  const info = fastSessionInfo(filePath, "codex");
286
- if (info.hasTextTurn)
290
+ // Include if we found text OR a real session id (big sessions can have their first text turn beyond
291
+ // the 1MB probe window — gating only on text dropped them entirely; exact discovery refines later).
292
+ // We require a real key so the fast/exact conversationKey match (no sha16 fallback mismatch).
293
+ if (info.hasTextTurn || info.hasRealKey)
287
294
  out.push({ filePath, source: "codex", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
288
295
  }
289
296
  const claudeRoot = opts.claudeRoot ?? path.join(os.homedir(), ".claude", "projects");
290
297
  for (const filePath of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
291
298
  const stat = statSafe(filePath);
292
299
  const info = fastSessionInfo(filePath, "claude-code");
293
- if (info.hasTextTurn)
300
+ // Same rule as codex: include on text OR a real session id so large sessions aren't undercounted,
301
+ // while keeping the key stable (claude-code sessionId appears on every line, so hasRealKey is reliable).
302
+ if (info.hasTextTurn || info.hasRealKey)
294
303
  out.push({ filePath, source: "claude-code", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
295
304
  }
296
305
  return out;
@@ -759,6 +768,39 @@ export function discoverMigratableSessions(opts = {}) {
759
768
  claudeCount: sessions.length - codexCount,
760
769
  };
761
770
  }
771
+ /**
772
+ * Targeted sizing for extraction start. The fast (stat-only) scan already knows WHICH sessions are
773
+ * pending — by conversation key + file path — without reading any content. This assembles full content
774
+ * for ONLY those pending sessions, never the whole local history. That keeps "start extraction" fast even
775
+ * when a user has gigabytes of already-processed logs (reading all of them just to prepare a few new jobs
776
+ * is what made sizing hang for 30s+).
777
+ *
778
+ * `processedKeys` (from the account import-status check) yields the accurate pending set; without it we
779
+ * fall back to the local-ledger pending. Either way we read at most the pending files, not the archive.
780
+ */
781
+ export function discoverPendingSessionsTargeted(processedKeys, opts = {}) {
782
+ const fast = discoverMigratableFastDiscovery(opts);
783
+ const filtered = processedKeys ? applyFastAccountImportStatus(fast, processedKeys, opts) : fast;
784
+ const pending = [];
785
+ for (const entry of filtered.pending) {
786
+ const s = entry.source === "codex" ? assembleCodex(entry.filePath) : assembleClaude(entry.filePath);
787
+ if (s)
788
+ pending.push(s);
789
+ }
790
+ return {
791
+ sessions: pending,
792
+ pending,
793
+ pendingTotal: filtered.pendingTotal,
794
+ alreadyMigrated: filtered.alreadyMigrated,
795
+ skippedActive: filtered.skippedActive,
796
+ limited: filtered.limited,
797
+ codexCount: filtered.codexCount,
798
+ claudeCount: filtered.claudeCount,
799
+ accountChecked: filtered.accountChecked,
800
+ accountCheckFailed: filtered.accountCheckFailed,
801
+ accountCheckUnavailable: filtered.accountCheckUnavailable,
802
+ };
803
+ }
762
804
  export function applyAccountImportStatus(discovery, processedKeys, opts = {}) {
763
805
  const selectableSessions = opts.includeActive
764
806
  ? discovery.sessions