@echomem/mcp 1.3.0 → 1.3.2

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/report.js CHANGED
@@ -102,6 +102,31 @@ function classifyClaudeTool(name) {
102
102
  const base = String(name || "").toLowerCase().replace(/^mcp__.+?__/, "");
103
103
  return CLAUDE_READ.has(base) ? "read" : "act";
104
104
  }
105
+ function normalizeCwd(cwd) {
106
+ if (!cwd)
107
+ return null;
108
+ const m = cwd.match(/worktrees\/[^/]+\/(.+)$/);
109
+ return m ? m[1] : cwd;
110
+ }
111
+ function basenameProject(cwd) {
112
+ if (!cwd)
113
+ return null;
114
+ const name = path.basename(cwd);
115
+ return name && name !== "." && name !== "/" ? name : null;
116
+ }
117
+ function epochMs(ts) {
118
+ if (!ts)
119
+ return null;
120
+ const n = Date.parse(ts);
121
+ return Number.isFinite(n) ? n : null;
122
+ }
123
+ function durationBetween(startTs, lastTs) {
124
+ const start = epochMs(startTs);
125
+ const last = epochMs(lastTs);
126
+ if (start == null || last == null)
127
+ return 0;
128
+ return Math.max(0, Math.round((last - start) / 1000));
129
+ }
105
130
  // ---------------------------------------------------------------------------
106
131
  // Parsing
107
132
  // ---------------------------------------------------------------------------
@@ -163,13 +188,20 @@ export function eachLine(file, fn) {
163
188
  /** Codex rollout: token_count is CUMULATIVE (take the last); function_call is per tool invocation. */
164
189
  export function parseCodex(file) {
165
190
  let tot = null;
166
- const meta = { first: null };
191
+ const meta = { first: null, last: null, cwd: null };
167
192
  let echo = 0;
168
193
  let reads = 0;
169
194
  let acts = 0;
170
195
  eachLine(file, (o) => {
171
- if (meta.first === null && typeof o.timestamp === "string")
172
- meta.first = o.timestamp;
196
+ if (typeof o.timestamp === "string") {
197
+ if (meta.first === null)
198
+ meta.first = o.timestamp;
199
+ meta.last = o.timestamp;
200
+ }
201
+ if (o && o.type === "session_meta" && o.payload && typeof o.payload.cwd === "string") {
202
+ meta.cwd = o.payload.cwd;
203
+ return;
204
+ }
173
205
  const p = o && typeof o.payload === "object" && o.payload ? o.payload : o;
174
206
  if (!p || typeof p !== "object")
175
207
  return;
@@ -194,6 +226,10 @@ export function parseCodex(file) {
194
226
  source: "codex",
195
227
  date: meta.first ? meta.first.slice(0, 10) : null,
196
228
  month: meta.first ? meta.first.slice(0, 7) : null,
229
+ startTs: meta.first,
230
+ lastTs: meta.last,
231
+ durationSec: durationBetween(meta.first, meta.last),
232
+ cwd: normalizeCwd(meta.cwd),
197
233
  total: tot.total_tokens || 0,
198
234
  cached: tot.cached_input_tokens || 0,
199
235
  output: tot.output_tokens || 0,
@@ -210,11 +246,29 @@ export function parseClaude(file) {
210
246
  let echo = 0;
211
247
  let reads = 0;
212
248
  let acts = 0;
213
- const meta = { first: null };
249
+ const meta = {
250
+ first: null,
251
+ last: null,
252
+ cwd: null,
253
+ durationSec: 0,
254
+ prevMs: null,
255
+ };
214
256
  let sawUsage = false;
215
257
  eachLine(file, (o) => {
216
- if (meta.first === null && typeof o.timestamp === "string")
217
- meta.first = o.timestamp;
258
+ if (!meta.cwd && typeof o.cwd === "string")
259
+ meta.cwd = o.cwd;
260
+ if (typeof o.timestamp === "string") {
261
+ if (meta.first === null)
262
+ meta.first = o.timestamp;
263
+ meta.last = o.timestamp;
264
+ const ms = epochMs(o.timestamp);
265
+ if (ms != null) {
266
+ if (meta.prevMs != null) {
267
+ meta.durationSec += Math.min(300, Math.max(0, Math.round((ms - meta.prevMs) / 1000)));
268
+ }
269
+ meta.prevMs = ms;
270
+ }
271
+ }
218
272
  if (o.type !== "assistant" || !o.message)
219
273
  return;
220
274
  const u = o.message.usage;
@@ -248,6 +302,10 @@ export function parseClaude(file) {
248
302
  source: "claude-code",
249
303
  date: meta.first ? meta.first.slice(0, 10) : null,
250
304
  month: meta.first ? meta.first.slice(0, 7) : null,
305
+ startTs: meta.first,
306
+ lastTs: meta.last,
307
+ durationSec: meta.durationSec,
308
+ cwd: normalizeCwd(meta.cwd),
251
309
  total,
252
310
  cached,
253
311
  output,
@@ -326,6 +384,34 @@ export function aggregate(stats) {
326
384
  const months = [...byMonth.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([month, d]) => ({
327
385
  month, tokens: d.t, sessions: d.n, rereadPct: d.t ? Math.round((d.c / d.t) * 100) : 0,
328
386
  }));
387
+ const byDay = new Map();
388
+ for (const d of dates)
389
+ byDay.set(d, (byDay.get(d) || 0) + 1);
390
+ const busiestDayEntry = [...byDay.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
391
+ const hourHistogram = Array.from({ length: 24 }, () => 0);
392
+ for (const s of stats) {
393
+ const ms = epochMs(s.startTs);
394
+ if (ms == null)
395
+ continue;
396
+ const hour = new Date(ms).getHours();
397
+ if (hour >= 0 && hour < 24)
398
+ hourHistogram[hour] += 1;
399
+ }
400
+ const hourMax = Math.max(...hourHistogram);
401
+ const hourLocal = hourMax > 0 ? hourHistogram.findIndex((n) => n === hourMax) : null;
402
+ const approxHours = stats.reduce((n, s) => n + s.durationSec, 0) / 3600;
403
+ const longestSessionMin = Math.max(0, ...stats.map((s) => s.durationSec)) / 60;
404
+ const byProject = new Map();
405
+ for (const s of stats) {
406
+ const name = basenameProject(s.cwd);
407
+ if (!name)
408
+ continue;
409
+ byProject.set(name, (byProject.get(name) || 0) + 1);
410
+ }
411
+ const topProjects = [...byProject.entries()]
412
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
413
+ .slice(0, 10)
414
+ .map(([name, sessions]) => ({ name, sessions }));
329
415
  const totals = stats.map((s) => s.total).sort((a, b) => b - a);
330
416
  const topN = Math.max(1, Math.floor(totals.length * 0.1));
331
417
  const topShare = total ? Math.round((totals.slice(0, topN).reduce((n, x) => n + x, 0) / total) * 100) : 0;
@@ -342,7 +428,15 @@ export function aggregate(stats) {
342
428
  first: dates[0] || null,
343
429
  last: dates[dates.length - 1] || null,
344
430
  days: new Set(dates).size,
345
- months, topShare, medianSession: median(stats.map((s) => s.total)),
431
+ months,
432
+ busiestDay: { date: busiestDayEntry?.[0] || null, sessions: busiestDayEntry?.[1] || 0 },
433
+ hourHistogram,
434
+ hourLocal,
435
+ approxHours,
436
+ longestSessionMin,
437
+ topProjects,
438
+ topShare,
439
+ medianSession: median(stats.map((s) => s.total)),
346
440
  costLow, costHigh,
347
441
  };
348
442
  }
@@ -400,6 +494,59 @@ function renderJson(a, memCount) {
400
494
  estSpendUsdRough: { low: Math.round(a.costLow), high: Math.round(a.costHigh), note: "cache priced at ~0.1-0.5x; rough" },
401
495
  }, null, 2);
402
496
  }
497
+ function round1(n) {
498
+ return Math.round(n * 10) / 10;
499
+ }
500
+ /** Build the localhost onboarding dashboard payload. This is intentionally separate from report --json. */
501
+ export async function buildStatsPayload(stats, inject) {
502
+ const a = aggregate(stats);
503
+ const memCount = inject?.skipMemoryCount
504
+ ? null
505
+ : Object.prototype.hasOwnProperty.call(inject || {}, "memoriesCaptured")
506
+ ? inject?.memoriesCaptured ?? null
507
+ : await fetchMemoryCount();
508
+ const busiestMonth = [...a.months].sort((x, y) => y.sessions - x.sessions || x.month.localeCompare(y.month))[0];
509
+ const sessionCounts = inject?.sessions ?? { total: a.sessions, codex: a.codexN, claudeCode: a.claudeN };
510
+ return {
511
+ schemaVersion: 1,
512
+ ...(inject?.partial ? { partial: true } : {}),
513
+ ...(inject?.discovery ? { discovery: inject.discovery } : {}),
514
+ generatedFrom: ["~/.codex/sessions", "~/.claude/projects"],
515
+ llmCallsUsed: 0,
516
+ transcriptsUploaded: false,
517
+ window: { first: a.first, last: a.last, activeDays: a.days },
518
+ sessions: sessionCounts,
519
+ migratable: {
520
+ pending: inject?.migratable?.pending ?? 0,
521
+ alreadyMigrated: inject?.migratable?.alreadyMigrated ?? 0,
522
+ ...inject?.migratable,
523
+ },
524
+ busiest: {
525
+ month: { month: busiestMonth?.month || null, sessions: busiestMonth?.sessions || 0 },
526
+ day: a.busiestDay,
527
+ hourLocal: a.hourLocal,
528
+ hourHistogram: a.hourHistogram,
529
+ },
530
+ time: { approxHours: round1(a.approxHours), approx: true, longestSessionMin: Math.round(a.longestSessionMin) },
531
+ contextGathering: {
532
+ reads: a.reads,
533
+ memoryRecalls: a.recalls,
534
+ readsPerRecall: a.ratio,
535
+ otherActions: a.acts,
536
+ note: "reads = file reads + searches only; edits/writes/builds/tests excluded (memory doesn't replace them)",
537
+ },
538
+ tokens: {
539
+ totalCumulative: a.total,
540
+ rereadPctOfTokens: a.rereadPct,
541
+ fresh: a.fresh,
542
+ output: a.output,
543
+ note: "cumulative across turns; re-read % is a TOKEN share (context re-sent each turn), NOT time",
544
+ },
545
+ concentration: { topTenPctShare: a.topShare, medianSession: a.medianSession },
546
+ topProjects: a.topProjects,
547
+ memoriesCaptured: memCount,
548
+ };
549
+ }
403
550
  /** Render the report to a string. `useColor=false` for the MCP tool (clean text); true for the terminal. */
404
551
  function renderText(a, memCount, useColor) {
405
552
  const c = makeColor(useColor);