mira-harness 0.2.6 → 0.2.8

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 CHANGED
@@ -209,6 +209,9 @@ Claude/agent can probe @mira directly via tools.
209
209
  | `mira_loop` | `category?`, `max?`, `peer?`, `gapMs?`, `settleMs?`, `timeoutMs?`, `catalogFile?` | run the catalog, **observe-only** (never clicks / spends credits) |
210
210
  | `mira_catalog` | `category?`, `catalogFile?` | list the catalog (no network) |
211
211
  | `mira_report` | `inFile?`, `category?` | run log → Markdown |
212
+ | `mira_stats` | `inFile?`, `category?` | run-log summary: totals, reply rate, latency, by-category (JSON) |
213
+ | `mira_diff` | `baseline`, `current?` | drift between two run logs (regressions / changes) |
214
+ | `mira_assert` | `inFile?`, `catalogFile?`, `category?` | re-grade a saved run log offline (no network) |
212
215
  | `mira_doctor` | — | env / session / connectivity check |
213
216
 
214
217
  Register it (local build — run `bun run build` first):
package/dist/cli.js CHANGED
@@ -434,6 +434,9 @@ function clearTitle() {
434
434
  // src/commands/report.ts
435
435
  import { readFileSync as readFileSync2, writeFileSync } from "node:fs";
436
436
  import { resolve as resolve3 } from "node:path";
437
+ function isRunRecord(x) {
438
+ return typeof x === "object" && x !== null && Array.isArray(x.messages) && typeof x.sent === "string";
439
+ }
437
440
  function loadRunRecords(inFile, category) {
438
441
  const path = inFile ? resolve3(process.cwd(), inFile) : RUNS_FILE;
439
442
  const raw = readFileSync2(path, "utf8");
@@ -444,7 +447,9 @@ function loadRunRecords(inFile, category) {
444
447
  if (!t)
445
448
  continue;
446
449
  try {
447
- records.push(JSON.parse(t));
450
+ const rec = JSON.parse(t);
451
+ if (isRunRecord(rec))
452
+ records.push(rec);
448
453
  } catch {}
449
454
  }
450
455
  return category ? records.filter((r) => r.category === category) : records;
@@ -556,6 +561,19 @@ function report(opts) {
556
561
  }
557
562
 
558
563
  // src/commands/assert.ts
564
+ function assertSummary(records, source) {
565
+ const byId = new Map(source.map((p) => [p.id, p]));
566
+ const results = [];
567
+ for (const r of records) {
568
+ if (!r.probeId)
569
+ continue;
570
+ const p = byId.get(r.probeId);
571
+ if (!p?.expect)
572
+ continue;
573
+ results.push({ id: r.probeId, verdict: evaluate(p.expect, r) });
574
+ }
575
+ return { graded: results.length, passed: results.filter((g) => g.verdict.ok).length, results };
576
+ }
559
577
  function assertLog(opts = {}) {
560
578
  let records;
561
579
  try {
@@ -570,29 +588,20 @@ function assertLog(opts = {}) {
570
588
  process.exit(1);
571
589
  }
572
590
  const source = opts.catalog ? loadCatalog(opts.catalog) : CATALOG;
573
- const byId = new Map(source.map((p) => [p.id, p]));
574
- const graded = [];
575
- for (const r of records) {
576
- if (!r.probeId)
577
- continue;
578
- const p = byId.get(r.probeId);
579
- if (!p?.expect)
580
- continue;
581
- graded.push({ id: r.probeId, verdict: evaluate(p.expect, r) });
582
- }
583
- const failed = graded.filter((g) => !g.verdict.ok);
591
+ const { graded, passed, results } = assertSummary(records, source);
592
+ const failed = results.filter((g) => !g.verdict.ok);
584
593
  if (opts.json) {
585
- console.log(JSON.stringify({ graded: graded.length, passed: graded.length - failed.length, results: graded }, null, 2));
594
+ console.log(JSON.stringify({ graded, passed, results }, null, 2));
586
595
  if (failed.length && !opts.noFail)
587
596
  process.exit(1);
588
597
  return;
589
598
  }
590
599
  note(c.bold(`assert ${opts.catalog ?? "(built-in catalog)"} × ${opts.in ?? "(run log)"}`));
591
- if (!graded.length) {
600
+ if (!graded) {
592
601
  note(c.dim(" no records matched a probe with `expect` — nothing to grade."));
593
602
  return;
594
603
  }
595
- for (const g of graded) {
604
+ for (const g of results) {
596
605
  note(` ${g.verdict.ok ? c.green("✓") : c.red("✗")} ${c.cyan(g.id)}`);
597
606
  if (!g.verdict.ok) {
598
607
  for (const ch of g.verdict.checks.filter((x) => !x.ok))
@@ -600,7 +609,7 @@ function assertLog(opts = {}) {
600
609
  }
601
610
  }
602
611
  note((failed.length ? c.red : c.green)(`
603
- ${graded.length - failed.length}/${graded.length} assertions passed.`));
612
+ ${passed}/${graded} assertions passed.`));
604
613
  if (failed.length && !opts.noFail)
605
614
  process.exit(1);
606
615
  }
@@ -867,7 +876,14 @@ function req(name) {
867
876
  return v;
868
877
  }
869
878
  var tgEnv = {
870
- apiId: () => Number(req("TG_API_ID")),
879
+ apiId: () => {
880
+ const raw = req("TG_API_ID");
881
+ const n = Number(raw);
882
+ if (!Number.isInteger(n) || n <= 0) {
883
+ throw new Error(`TG_API_ID must be a positive integer (got "${raw}") — see .env.example`);
884
+ }
885
+ return n;
886
+ },
871
887
  apiHash: () => req("TG_API_HASH"),
872
888
  session: () => process.env.TG_SESSION ?? "",
873
889
  miraPeer: process.env.MIRA_PEER ?? "mira",
@@ -1325,14 +1341,14 @@ async function send(rawMessage, opts = {}) {
1325
1341
  let verdict;
1326
1342
  try {
1327
1343
  const result = await withProgress(`@${peer}`, () => sendAndCollect(client, peer, message, collect), opts.quiet);
1344
+ if (opts.expect)
1345
+ verdict = evaluate(opts.expect, result);
1328
1346
  if (!opts.noLog)
1329
- await appendRun(result);
1347
+ await appendRun(result, verdict ? { assert: verdict } : {});
1330
1348
  if (!opts.quiet) {
1331
1349
  note(result.timedOut ? c.yellow("no reply (timed out)") : c.green(`${result.messages.length} message(s) · first reply ${((result.firstReplyMs ?? 0) / 1000).toFixed(1)}s`));
1332
1350
  }
1333
1351
  console.log(JSON.stringify(result, null, 2));
1334
- if (opts.expect)
1335
- verdict = evaluate(opts.expect, result);
1336
1352
  } finally {
1337
1353
  await client.disconnect();
1338
1354
  }
@@ -1382,6 +1398,29 @@ function latencyStats(records) {
1382
1398
  max: latencies[latencies.length - 1] ?? 0
1383
1399
  };
1384
1400
  }
1401
+ function statsSummary(records) {
1402
+ const replied = records.filter((r) => !r.timedOut);
1403
+ const lat = latencyStats(records);
1404
+ const graded = records.filter((r) => r.assert);
1405
+ const passed = graded.filter((r) => r.assert?.ok);
1406
+ const byCat = new Map;
1407
+ for (const r of records) {
1408
+ const key = r.category ?? "(uncategorized)";
1409
+ const e = byCat.get(key) ?? { total: 0, replied: 0 };
1410
+ e.total += 1;
1411
+ if (!r.timedOut)
1412
+ e.replied += 1;
1413
+ byCat.set(key, e);
1414
+ }
1415
+ return {
1416
+ probes: records.length,
1417
+ replied: replied.length,
1418
+ timedOut: records.length - replied.length,
1419
+ assertions: graded.length ? { graded: graded.length, passed: passed.length } : null,
1420
+ latencyMs: lat.count ? { min: lat.min, median: lat.median, p95: lat.p95, max: lat.max } : null,
1421
+ byCategory: Object.fromEntries([...byCat].map(([k, v]) => [k, v]))
1422
+ };
1423
+ }
1385
1424
  function stats(opts = {}) {
1386
1425
  let records;
1387
1426
  try {
@@ -1399,6 +1438,10 @@ function stats(opts = {}) {
1399
1438
  console.error(opts.category ? `no probes in category "${opts.category}" in the run log.` : "run log is empty — run `mira-harness send` or `mira-harness loop` first.");
1400
1439
  process.exit(1);
1401
1440
  }
1441
+ if (opts.json) {
1442
+ console.log(JSON.stringify(statsSummary(records), null, 2));
1443
+ return;
1444
+ }
1402
1445
  const replied = records.filter((r) => !r.timedOut);
1403
1446
  const timedOut = records.length - replied.length;
1404
1447
  const lat = latencyStats(records);
@@ -1414,17 +1457,6 @@ function stats(opts = {}) {
1414
1457
  byCat.set(key, e);
1415
1458
  }
1416
1459
  const series = [...records].sort((a, b) => a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0).filter((r) => !r.timedOut && r.firstReplyMs !== null && r.firstReplyMs > 0).map((r) => r.firstReplyMs);
1417
- if (opts.json) {
1418
- console.log(JSON.stringify({
1419
- probes: records.length,
1420
- replied: replied.length,
1421
- timedOut,
1422
- assertions: graded.length ? { graded: graded.length, passed: passed.length } : null,
1423
- latencyMs: lat.count ? { min: lat.min, median: lat.median, p95: lat.p95, max: lat.max } : null,
1424
- byCategory: Object.fromEntries([...byCat].map(([k, v]) => [k, v]))
1425
- }, null, 2));
1426
- return;
1427
- }
1428
1460
  const rate = (n, total) => `${total ? Math.round(n / total * 100) : 0}%`;
1429
1461
  note(c.bold(`\uD83D\uDCCA mira stats${opts.category ? ` [${opts.category}]` : ""}`));
1430
1462
  note(` ${c.cyan(String(records.length))} probes · ${c.green(`${replied.length} replied`)} · ` + (timedOut ? c.yellow(`${timedOut} timed out`) : c.dim("0 timed out")) + c.dim(` (${rate(replied.length, records.length)} reply rate)`));
@@ -1634,10 +1666,19 @@ if (process.argv.length <= 2) {
1634
1666
  banner(version);
1635
1667
  console.log(` Drive @mira from your terminal — QA, learn, and assert on its behavior.
1636
1668
  `);
1637
- console.log(` Commands ${c.cyan("login · doctor · send · loop · catalog · watch")}`);
1638
- console.log(` ${c.cyan("report · stats · diff · assert · schema")}
1639
- `);
1640
- console.log(` Run ${c.bold("mira-harness --help")} for options + examples, or ${c.bold("mira-harness <command> --help")}.`);
1669
+ console.log(` ${c.bold("Commands")}`);
1670
+ const cmds = program.commands.filter((cmd) => cmd.name() !== "help");
1671
+ const pad = Math.max(...cmds.map((cmd) => cmd.name().length));
1672
+ for (const cmd of cmds) {
1673
+ console.log(` ${c.cyan(cmd.name().padEnd(pad))} ${c.dim(cmd.description())}`);
1674
+ }
1675
+ console.log(`
1676
+ ${c.bold("Quick start")}`);
1677
+ for (const ex of ["login", "doctor", 'send "What can you do?"', "loop --category core"]) {
1678
+ console.log(c.dim(` $ mira-harness ${ex}`));
1679
+ }
1680
+ console.log(`
1681
+ Run ${c.bold("mira-harness --help")} for all options + examples, or ${c.bold("mira-harness <command> --help")}.`);
1641
1682
  process.exit(0);
1642
1683
  }
1643
1684
  program.parseAsync(process.argv).then(() => process.exit(0)).catch((e) => {
@@ -7,9 +7,9 @@ export type RunRecord = ProbeResult & {
7
7
  assert?: Verdict;
8
8
  };
9
9
  /**
10
- * Read the JSONL run log into records (malformed lines skipped). Throws ENOENT when
11
- * the file is absent — callers decide how to surface that. Shared by `report` and
12
- * `stats` so the two stay in lockstep on the log format.
10
+ * Read the JSONL run log into records (malformed/wrong-shape lines skipped). Throws
11
+ * ENOENT when the file is absent — callers decide how to surface that. Shared by
12
+ * `report` and `stats` so the two stay in lockstep on the log format.
13
13
  */
14
14
  export declare function loadRunRecords(inFile?: string, category?: string): RunRecord[];
15
15
  export declare function buildReport(records: RunRecord[]): string;
package/dist/env.d.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import "dotenv/config";
9
9
  export declare const tgEnv: {
10
+ /** Telegram api_id — a positive integer. Reject non-numeric/zero up front so
11
+ * `doctor` flags a bad value instead of passing NaN into GramJS (cryptic later). */
10
12
  apiId: () => number;
11
13
  apiHash: () => string;
12
14
  /** Empty until `mira-harness login` mints it; required by send/loop. */
package/dist/index.js CHANGED
@@ -414,7 +414,14 @@ function req(name) {
414
414
  return v;
415
415
  }
416
416
  var tgEnv = {
417
- apiId: () => Number(req("TG_API_ID")),
417
+ apiId: () => {
418
+ const raw = req("TG_API_ID");
419
+ const n = Number(raw);
420
+ if (!Number.isInteger(n) || n <= 0) {
421
+ throw new Error(`TG_API_ID must be a positive integer (got "${raw}") — see .env.example`);
422
+ }
423
+ return n;
424
+ },
418
425
  apiHash: () => req("TG_API_HASH"),
419
426
  session: () => process.env.TG_SESSION ?? "",
420
427
  miraPeer: process.env.MIRA_PEER ?? "mira",
@@ -596,6 +603,9 @@ async function appendRun(result, meta = {}) {
596
603
  }
597
604
 
598
605
  // src/commands/report.ts
606
+ function isRunRecord(x) {
607
+ return typeof x === "object" && x !== null && Array.isArray(x.messages) && typeof x.sent === "string";
608
+ }
599
609
  function loadRunRecords(inFile, category) {
600
610
  const path = inFile ? resolve3(process.cwd(), inFile) : RUNS_FILE;
601
611
  const raw = readFileSync2(path, "utf8");
@@ -606,7 +616,9 @@ function loadRunRecords(inFile, category) {
606
616
  if (!t)
607
617
  continue;
608
618
  try {
609
- records.push(JSON.parse(t));
619
+ const rec = JSON.parse(t);
620
+ if (isRunRecord(rec))
621
+ records.push(rec);
610
622
  } catch {}
611
623
  }
612
624
  return category ? records.filter((r) => r.category === category) : records;
package/dist/mcp.js CHANGED
@@ -424,7 +424,14 @@ function req(name) {
424
424
  return v;
425
425
  }
426
426
  var tgEnv = {
427
- apiId: () => Number(req("TG_API_ID")),
427
+ apiId: () => {
428
+ const raw = req("TG_API_ID");
429
+ const n = Number(raw);
430
+ if (!Number.isInteger(n) || n <= 0) {
431
+ throw new Error(`TG_API_ID must be a positive integer (got "${raw}") — see .env.example`);
432
+ }
433
+ return n;
434
+ },
428
435
  apiHash: () => req("TG_API_HASH"),
429
436
  session: () => process.env.TG_SESSION ?? "",
430
437
  miraPeer: process.env.MIRA_PEER ?? "mira",
@@ -591,9 +598,8 @@ async function subscribe(client, peer, onMessage) {
591
598
  };
592
599
  }
593
600
 
594
- // src/commands/report.ts
595
- import { readFileSync as readFileSync2, writeFileSync } from "node:fs";
596
- import { resolve as resolve3 } from "node:path";
601
+ // src/commands/assert.ts
602
+ import { resolve as resolve4 } from "node:path";
597
603
 
598
604
  // src/log.ts
599
605
  import { appendFile, mkdir } from "node:fs/promises";
@@ -606,7 +612,119 @@ async function appendRun(result, meta = {}) {
606
612
  `, "utf8");
607
613
  }
608
614
 
615
+ // src/ui.ts
616
+ import pc from "picocolors";
617
+ var c = pc;
618
+ function note(msg) {
619
+ process.stderr.write(`${msg}
620
+ `);
621
+ }
622
+ function center(s, width) {
623
+ const pad = Math.max(0, width - s.length);
624
+ const left = Math.floor(pad / 2);
625
+ return " ".repeat(left) + s + " ".repeat(pad - left);
626
+ }
627
+ function mascot(mood = "neutral") {
628
+ const faces = {
629
+ neutral: { top: "* . *", eyes: "o o", mouth: "~" },
630
+ happy: { top: "* . *", eyes: "^ ^", mouth: "\\_/" },
631
+ sleepy: { top: "z . z", eyes: "- -", mouth: "~" },
632
+ sad: { top: " ", eyes: "x x", mouth: "_" }
633
+ };
634
+ const f = faces[mood];
635
+ return [` ${f.top}`, " .-------.", ` | ${f.eyes} |`, ` | ${center(f.mouth, 5)} |`, " '-------'"];
636
+ }
637
+ var WORDMARK = [
638
+ " __ __ ___ ____ _",
639
+ "| \\/ |_ _| _ \\ / \\",
640
+ "| |\\/| || || |_) | / _ \\",
641
+ "| | | || || _ < / ___ \\",
642
+ "|_| |_|___|_| \\_\\/_/ \\_\\"
643
+ ];
644
+ var TIPS = [
645
+ 'echo "What can you do?" | mira-harness send — pipe a probe from stdin',
646
+ "mira-harness loop --list — preview the probes without sending anything",
647
+ "touch STOP_MIRA — instant kill switch; blocks every send until removed",
648
+ "mira-harness loop --catalog ./my.json — run your own probe catalog",
649
+ "mira-harness report --out report.md — distill the run log into Markdown",
650
+ "mira-harness stats — latency records + an ASCII sparkline of your runs",
651
+ "mira-harness watch — live-tail @mira while you poke it by hand",
652
+ "MIRA_NO_BANNER=1 hides this banner · --quiet hides all decoration"
653
+ ];
654
+ function banner(version, opts = {}) {
655
+ if (opts.quiet || process.env.MIRA_NO_BANNER || !process.stderr.isTTY || !process.stdout.isTTY)
656
+ return;
657
+ const tip = TIPS[new Date().getDate() % TIPS.length];
658
+ const lines = [
659
+ ...mascot("neutral").map((l) => c.magenta(l)),
660
+ ...WORDMARK.map((l) => c.cyan(l)),
661
+ c.dim(`mira-harness v${version} · drive @mira from your terminal`),
662
+ c.dim(`tip: ${tip}`)
663
+ ];
664
+ process.stderr.write(`
665
+ ${lines.map((l) => ` ${l}`).join(`
666
+ `)}
667
+
668
+ `);
669
+ }
670
+ var VERBS = [
671
+ "Summoning",
672
+ "Consulting the chain",
673
+ "Poking the bot",
674
+ "Nudging the oracle",
675
+ "Pondering",
676
+ "Channeling TON",
677
+ "Crunching tokens",
678
+ "Waiting on a reply"
679
+ ];
680
+ var VERBS_SLOW = ["Still pondering", "Deep in thought", "Brewing a big one", "Hang tight", "Almost there"];
681
+ var SLOW_AFTER_MS = 30000;
682
+ var VERB_EVERY_MS = 3500;
683
+ async function withProgress(label, fn, quiet = false) {
684
+ if (quiet || !process.stderr.isTTY)
685
+ return fn();
686
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
687
+ const start = Date.now();
688
+ let i = 0;
689
+ const timer = setInterval(() => {
690
+ const elapsed = Date.now() - start;
691
+ const pool = elapsed >= SLOW_AFTER_MS ? VERBS_SLOW : VERBS;
692
+ const verb = pool[Math.floor(elapsed / VERB_EVERY_MS) % pool.length];
693
+ const s = (elapsed / 1000).toFixed(0);
694
+ process.stderr.write(`\r${pc.cyan(frames[i++ % frames.length])} ${verb} ${pc.dim(`· ${label} · ${s}s`)} `);
695
+ }, 100);
696
+ try {
697
+ return await fn();
698
+ } finally {
699
+ clearInterval(timer);
700
+ process.stderr.write("\r\x1B[K");
701
+ }
702
+ }
703
+ function oscSafe(s) {
704
+ return s.replace(/[\x00-\x1f\x7f]/g, " ").trim();
705
+ }
706
+ function notify(message, opts = {}) {
707
+ if (opts.quiet || process.env.MIRA_NO_NOTIFY || !process.stderr.isTTY)
708
+ return;
709
+ process.stderr.write(`\x1B]9;${oscSafe(message)}\x07`);
710
+ }
711
+ function setTitle(title) {
712
+ if (process.env.MIRA_NO_TITLE || !process.stderr.isTTY)
713
+ return;
714
+ process.stderr.write(`\x1B]0;${oscSafe(title)}\x07`);
715
+ }
716
+ function clearTitle() {
717
+ if (!process.stderr.isTTY)
718
+ return;
719
+ process.stderr.write("\x1B]0;\x07");
720
+ }
721
+
609
722
  // src/commands/report.ts
723
+ import { readFileSync as readFileSync2, writeFileSync } from "node:fs";
724
+ import { resolve as resolve3 } from "node:path";
725
+ function isRunRecord(x) {
726
+ return typeof x === "object" && x !== null && Array.isArray(x.messages) && typeof x.sent === "string";
727
+ }
610
728
  function loadRunRecords(inFile, category) {
611
729
  const path = inFile ? resolve3(process.cwd(), inFile) : RUNS_FILE;
612
730
  const raw = readFileSync2(path, "utf8");
@@ -617,7 +735,9 @@ function loadRunRecords(inFile, category) {
617
735
  if (!t)
618
736
  continue;
619
737
  try {
620
- records.push(JSON.parse(t));
738
+ const rec = JSON.parse(t);
739
+ if (isRunRecord(rec))
740
+ records.push(rec);
621
741
  } catch {}
622
742
  }
623
743
  return category ? records.filter((r) => r.category === category) : records;
@@ -682,7 +802,7 @@ function buildReport(records) {
682
802
  out.push("");
683
803
  out.push(`**${records.length}** probes · **${replied.length}** replied · **${records.length - replied.length}** timed out · ` + (graded.length ? `**${passed.length}/${graded.length}** assertions passed · ` : "") + `first-reply avg **${(avg / 1000).toFixed(1)}s** / max **${(max / 1000).toFixed(1)}s**.`);
684
804
  out.push("");
685
- const builtin = CATEGORIES.filter((c) => records.some((r) => r.category === c));
805
+ const builtin = CATEGORIES.filter((c2) => records.some((r) => r.category === c2));
686
806
  const custom = [];
687
807
  for (const r of records) {
688
808
  if (r.category && !CATEGORIES.includes(r.category) && !custom.includes(r.category)) {
@@ -728,6 +848,296 @@ function report(opts) {
728
848
  }
729
849
  }
730
850
 
851
+ // src/commands/assert.ts
852
+ function assertSummary(records, source) {
853
+ const byId = new Map(source.map((p) => [p.id, p]));
854
+ const results = [];
855
+ for (const r of records) {
856
+ if (!r.probeId)
857
+ continue;
858
+ const p = byId.get(r.probeId);
859
+ if (!p?.expect)
860
+ continue;
861
+ results.push({ id: r.probeId, verdict: evaluate(p.expect, r) });
862
+ }
863
+ return { graded: results.length, passed: results.filter((g) => g.verdict.ok).length, results };
864
+ }
865
+ function assertLog(opts = {}) {
866
+ let records;
867
+ try {
868
+ records = loadRunRecords(opts.in, opts.category);
869
+ } catch (e) {
870
+ if (e?.code === "ENOENT") {
871
+ const path = opts.in ? resolve4(process.cwd(), opts.in) : RUNS_FILE;
872
+ console.error(`no run log at ${path} — run \`mira-harness send\` or \`mira-harness loop\` first.`);
873
+ } else {
874
+ console.error(e instanceof Error ? e.message : String(e));
875
+ }
876
+ process.exit(1);
877
+ }
878
+ const source = opts.catalog ? loadCatalog(opts.catalog) : CATALOG;
879
+ const { graded, passed, results } = assertSummary(records, source);
880
+ const failed = results.filter((g) => !g.verdict.ok);
881
+ if (opts.json) {
882
+ console.log(JSON.stringify({ graded, passed, results }, null, 2));
883
+ if (failed.length && !opts.noFail)
884
+ process.exit(1);
885
+ return;
886
+ }
887
+ note(c.bold(`assert ${opts.catalog ?? "(built-in catalog)"} × ${opts.in ?? "(run log)"}`));
888
+ if (!graded) {
889
+ note(c.dim(" no records matched a probe with `expect` — nothing to grade."));
890
+ return;
891
+ }
892
+ for (const g of results) {
893
+ note(` ${g.verdict.ok ? c.green("✓") : c.red("✗")} ${c.cyan(g.id)}`);
894
+ if (!g.verdict.ok) {
895
+ for (const ch of g.verdict.checks.filter((x) => !x.ok))
896
+ note(c.dim(` ✗ ${ch.name}: ${ch.detail}`));
897
+ }
898
+ }
899
+ note((failed.length ? c.red : c.green)(`
900
+ ${passed}/${graded} assertions passed.`));
901
+ if (failed.length && !opts.noFail)
902
+ process.exit(1);
903
+ }
904
+
905
+ // src/commands/diff.ts
906
+ import { resolve as resolve5 } from "node:path";
907
+ function lastByProbe(records) {
908
+ const m = new Map;
909
+ for (const r of records) {
910
+ if (r.probeId)
911
+ m.set(r.probeId, r);
912
+ }
913
+ return m;
914
+ }
915
+ function signals2(r) {
916
+ const buttons = r.messages.reduce((n, m) => n + m.buttons.length, 0);
917
+ const links = r.messages.reduce((n, m) => n + m.links.length, 0);
918
+ const media = r.messages.map((m) => m.media?.kind).filter((k) => k !== undefined).join(",");
919
+ return { buttons, links, media };
920
+ }
921
+ function diffRuns(baseline, current) {
922
+ const base = lastByProbe(baseline);
923
+ const cur = lastByProbe(current);
924
+ const items = [];
925
+ for (const id of [...new Set([...base.keys(), ...cur.keys()])].sort()) {
926
+ const b = base.get(id);
927
+ const c2 = cur.get(id);
928
+ if (b && !c2) {
929
+ items.push({ probeId: id, kind: "removed", what: "in baseline, not in current" });
930
+ continue;
931
+ }
932
+ if (c2 && !b) {
933
+ items.push({ probeId: id, kind: "added", what: "new in current (not in baseline)" });
934
+ continue;
935
+ }
936
+ if (!b || !c2)
937
+ continue;
938
+ if (!b.timedOut && c2.timedOut)
939
+ items.push({ probeId: id, kind: "regression", what: "now times out (was replying)" });
940
+ else if (b.timedOut && !c2.timedOut)
941
+ items.push({ probeId: id, kind: "improvement", what: "now replies (was timing out)" });
942
+ const ba = b.assert;
943
+ const ca = c2.assert;
944
+ if (ba && ca && ba.ok !== ca.ok) {
945
+ if (ba.ok && !ca.ok) {
946
+ const failed = ca.checks.filter((x) => !x.ok).map((x) => x.name).join(", ");
947
+ items.push({ probeId: id, kind: "regression", what: `assertion now FAILS (${failed})` });
948
+ } else {
949
+ items.push({ probeId: id, kind: "improvement", what: "assertion now passes" });
950
+ }
951
+ }
952
+ if (b.firstReplyMs !== null && c2.firstReplyMs !== null) {
953
+ const bms = b.firstReplyMs;
954
+ const cms = c2.firstReplyMs;
955
+ if (cms > bms * 2 && cms - bms > 5000) {
956
+ items.push({
957
+ probeId: id,
958
+ kind: "regression",
959
+ what: `first reply ${(bms / 1000).toFixed(1)}s -> ${(cms / 1000).toFixed(1)}s (>2x slower)`
960
+ });
961
+ }
962
+ }
963
+ const bs = signals2(b);
964
+ const cs = signals2(c2);
965
+ if (bs.buttons !== cs.buttons)
966
+ items.push({ probeId: id, kind: "change", what: `buttons ${bs.buttons} -> ${cs.buttons}` });
967
+ if (bs.links !== cs.links)
968
+ items.push({ probeId: id, kind: "change", what: `links ${bs.links} -> ${cs.links}` });
969
+ if (bs.media !== cs.media)
970
+ items.push({ probeId: id, kind: "change", what: `media [${bs.media}] -> [${cs.media}]` });
971
+ }
972
+ return items;
973
+ }
974
+ function load(path, label) {
975
+ try {
976
+ return loadRunRecords(path);
977
+ } catch (e) {
978
+ const shown = path ? resolve5(process.cwd(), path) : RUNS_FILE;
979
+ if (e?.code === "ENOENT")
980
+ console.error(`${label}: no run log at ${shown}`);
981
+ else
982
+ console.error(`${label}: ${e instanceof Error ? e.message : String(e)}`);
983
+ return null;
984
+ }
985
+ }
986
+ function diff(opts) {
987
+ const base = load(opts.baseline, "baseline");
988
+ const cur = load(opts.current, "current");
989
+ if (!base || !cur)
990
+ process.exit(1);
991
+ if (!base.length)
992
+ note(c.yellow(`baseline ${opts.baseline} has no records.`));
993
+ if (!cur.length)
994
+ note(c.yellow(`current ${opts.current ?? "(run log)"} has no records.`));
995
+ const items = diffRuns(base, cur);
996
+ const regressions = items.filter((i) => i.kind === "regression");
997
+ if (opts.json) {
998
+ console.log(JSON.stringify({ regressions: regressions.length, drift: items.length, items }, null, 2));
999
+ if (regressions.length && !opts.noFail)
1000
+ process.exit(1);
1001
+ return;
1002
+ }
1003
+ note(c.bold(`drift ${opts.baseline} -> ${opts.current ?? "(current run log)"}`));
1004
+ if (!items.length) {
1005
+ note(c.green(" no drift detected."));
1006
+ return;
1007
+ }
1008
+ const paint = {
1009
+ regression: (s) => c.red(s),
1010
+ improvement: (s) => c.green(s),
1011
+ change: (s) => c.dim(s),
1012
+ added: (s) => c.dim(s),
1013
+ removed: (s) => c.dim(s)
1014
+ };
1015
+ for (const i of items) {
1016
+ note(` ${paint[i.kind](i.kind.padEnd(11))} ${c.cyan(i.probeId)} — ${i.what}`);
1017
+ }
1018
+ note(regressions.length ? c.red(`
1019
+ ${regressions.length} regression(s).`) : c.green(`
1020
+ no regressions.`));
1021
+ if (regressions.length && !opts.noFail)
1022
+ process.exit(1);
1023
+ }
1024
+
1025
+ // src/commands/stats.ts
1026
+ import { resolve as resolve6 } from "node:path";
1027
+ var SPARK = "▁▂▃▄▅▆▇█";
1028
+ function sparkline(values) {
1029
+ if (!values.length)
1030
+ return "";
1031
+ let min = Number.POSITIVE_INFINITY;
1032
+ let max = Number.NEGATIVE_INFINITY;
1033
+ for (const v of values) {
1034
+ if (v < min)
1035
+ min = v;
1036
+ if (v > max)
1037
+ max = v;
1038
+ }
1039
+ const range = max - min || 1;
1040
+ return values.map((v) => SPARK[Math.min(SPARK.length - 1, Math.floor((v - min) / range * SPARK.length))]).join("");
1041
+ }
1042
+ function percentile(sortedAsc, p) {
1043
+ if (!sortedAsc.length)
1044
+ return 0;
1045
+ const idx = Math.min(sortedAsc.length - 1, Math.floor(p / 100 * sortedAsc.length));
1046
+ return sortedAsc[idx] ?? 0;
1047
+ }
1048
+ var secs = (ms) => `${(ms / 1000).toFixed(1)}s`;
1049
+ function latencyStats(records) {
1050
+ const latencies = records.filter((r) => !r.timedOut && r.firstReplyMs !== null && r.firstReplyMs > 0).map((r) => r.firstReplyMs).sort((a, b) => a - b);
1051
+ return {
1052
+ count: latencies.length,
1053
+ min: latencies[0] ?? 0,
1054
+ median: percentile(latencies, 50),
1055
+ p95: percentile(latencies, 95),
1056
+ max: latencies[latencies.length - 1] ?? 0
1057
+ };
1058
+ }
1059
+ function statsSummary(records) {
1060
+ const replied = records.filter((r) => !r.timedOut);
1061
+ const lat = latencyStats(records);
1062
+ const graded = records.filter((r) => r.assert);
1063
+ const passed = graded.filter((r) => r.assert?.ok);
1064
+ const byCat = new Map;
1065
+ for (const r of records) {
1066
+ const key = r.category ?? "(uncategorized)";
1067
+ const e = byCat.get(key) ?? { total: 0, replied: 0 };
1068
+ e.total += 1;
1069
+ if (!r.timedOut)
1070
+ e.replied += 1;
1071
+ byCat.set(key, e);
1072
+ }
1073
+ return {
1074
+ probes: records.length,
1075
+ replied: replied.length,
1076
+ timedOut: records.length - replied.length,
1077
+ assertions: graded.length ? { graded: graded.length, passed: passed.length } : null,
1078
+ latencyMs: lat.count ? { min: lat.min, median: lat.median, p95: lat.p95, max: lat.max } : null,
1079
+ byCategory: Object.fromEntries([...byCat].map(([k, v]) => [k, v]))
1080
+ };
1081
+ }
1082
+ function stats(opts = {}) {
1083
+ let records;
1084
+ try {
1085
+ records = loadRunRecords(opts.in, opts.category);
1086
+ } catch (e) {
1087
+ if (e?.code === "ENOENT") {
1088
+ const path = opts.in ? resolve6(process.cwd(), opts.in) : RUNS_FILE;
1089
+ console.error(`no run log at ${path} — run \`mira-harness send\` or \`mira-harness loop\` first.`);
1090
+ } else {
1091
+ console.error(e instanceof Error ? e.message : String(e));
1092
+ }
1093
+ process.exit(1);
1094
+ }
1095
+ if (!records.length) {
1096
+ console.error(opts.category ? `no probes in category "${opts.category}" in the run log.` : "run log is empty — run `mira-harness send` or `mira-harness loop` first.");
1097
+ process.exit(1);
1098
+ }
1099
+ if (opts.json) {
1100
+ console.log(JSON.stringify(statsSummary(records), null, 2));
1101
+ return;
1102
+ }
1103
+ const replied = records.filter((r) => !r.timedOut);
1104
+ const timedOut = records.length - replied.length;
1105
+ const lat = latencyStats(records);
1106
+ const graded = records.filter((r) => r.assert);
1107
+ const passed = graded.filter((r) => r.assert?.ok);
1108
+ const byCat = new Map;
1109
+ for (const r of records) {
1110
+ const key = r.category ?? "(uncategorized)";
1111
+ const e = byCat.get(key) ?? { total: 0, replied: 0 };
1112
+ e.total += 1;
1113
+ if (!r.timedOut)
1114
+ e.replied += 1;
1115
+ byCat.set(key, e);
1116
+ }
1117
+ const series = [...records].sort((a, b) => a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0).filter((r) => !r.timedOut && r.firstReplyMs !== null && r.firstReplyMs > 0).map((r) => r.firstReplyMs);
1118
+ const rate = (n, total) => `${total ? Math.round(n / total * 100) : 0}%`;
1119
+ note(c.bold(`\uD83D\uDCCA mira stats${opts.category ? ` [${opts.category}]` : ""}`));
1120
+ note(` ${c.cyan(String(records.length))} probes · ${c.green(`${replied.length} replied`)} · ` + (timedOut ? c.yellow(`${timedOut} timed out`) : c.dim("0 timed out")) + c.dim(` (${rate(replied.length, records.length)} reply rate)`));
1121
+ if (graded.length) {
1122
+ const allPass = passed.length === graded.length;
1123
+ note(` ${(allPass ? c.green : c.yellow)(`${passed.length}/${graded.length}`)} assertions passed` + c.dim(` (${rate(passed.length, graded.length)})`));
1124
+ }
1125
+ if (lat.count) {
1126
+ note("");
1127
+ note(c.bold(" first-reply latency"));
1128
+ note(` \uD83C\uDFC6 fastest ${c.green(secs(lat.min))} median ${c.cyan(secs(lat.median))} ` + `p95 ${c.cyan(secs(lat.p95))} slowest ${c.yellow(secs(lat.max))}`);
1129
+ if (series.length > 1)
1130
+ note(` ${c.magenta(sparkline(series))} ${c.dim(`(${series.length} replies, oldest→newest)`)}`);
1131
+ } else {
1132
+ note(c.dim(" no first-reply times recorded yet."));
1133
+ }
1134
+ note("");
1135
+ note(c.bold(" by category"));
1136
+ for (const [cat, v] of byCat) {
1137
+ note(` ${c.cyan(cat.padEnd(16))} ${String(v.total).padStart(3)} probes ${c.dim(`${rate(v.replied, v.total)} replied`)}`);
1138
+ }
1139
+ }
1140
+
731
1141
  // src/version.ts
732
1142
  import { readFileSync as readFileSync3 } from "node:fs";
733
1143
  import { dirname as dirname2, join } from "node:path";
@@ -791,7 +1201,7 @@ server.registerTool("mira_send", {
791
1201
  if (timeoutMs !== undefined)
792
1202
  collect.firstReplyTimeoutMs = timeoutMs;
793
1203
  try {
794
- const result = await withClient((c) => sendAndCollect(c, tgEnv.miraPeer, message, collect));
1204
+ const result = await withClient((c2) => sendAndCollect(c2, tgEnv.miraPeer, message, collect));
795
1205
  await appendRun(result);
796
1206
  return json(result);
797
1207
  } catch (e) {
@@ -900,6 +1310,64 @@ server.registerTool("mira_report", {
900
1310
  return errorText(msg(e));
901
1311
  }
902
1312
  });
1313
+ server.registerTool("mira_stats", {
1314
+ title: "Summarize the run log",
1315
+ description: "Roll the run log up into totals, reply rate, assertion pass count, first-reply latency (min/median/p95/max) and a per-category breakdown. Read-only — sends nothing.",
1316
+ inputSchema: {
1317
+ inFile: z3.string().optional().describe("input JSONL path (default: the run log)"),
1318
+ category: z3.string().optional().describe("only include probes from this category")
1319
+ }
1320
+ }, async ({ inFile, category }) => {
1321
+ let records;
1322
+ try {
1323
+ records = loadRunRecords(inFile, category);
1324
+ } catch (e) {
1325
+ return errorText(msg(e));
1326
+ }
1327
+ if (!records.length) {
1328
+ return errorText(category ? `no probes in category "${category}" in the run log.` : "run log is empty — run mira_send or mira_loop first.");
1329
+ }
1330
+ return json(statsSummary(records));
1331
+ });
1332
+ server.registerTool("mira_diff", {
1333
+ title: "Diff two run logs for @mira drift",
1334
+ description: "Compare two run logs probe-by-probe and report behavioral drift (regressions, improvements, surface changes). Read-only — sends nothing.",
1335
+ inputSchema: {
1336
+ baseline: z3.string().describe("baseline run-log path to compare against"),
1337
+ current: z3.string().optional().describe("current run-log path (default: the run log)")
1338
+ }
1339
+ }, async ({ baseline, current }) => {
1340
+ let base;
1341
+ let cur;
1342
+ try {
1343
+ base = loadRunRecords(baseline);
1344
+ cur = loadRunRecords(current);
1345
+ } catch (e) {
1346
+ return errorText(msg(e));
1347
+ }
1348
+ const items = diffRuns(base, cur);
1349
+ const regressions = items.filter((i) => i.kind === "regression").length;
1350
+ return json({ regressions, drift: items.length, items });
1351
+ });
1352
+ server.registerTool("mira_assert", {
1353
+ title: "Re-grade a run log offline",
1354
+ description: "Re-apply a catalog's `expect` assertions to a SAVED run log (no network) and return per-probe verdicts plus a pass count. Read-only — sends nothing.",
1355
+ inputSchema: {
1356
+ inFile: z3.string().optional().describe("input JSONL path (default: the run log)"),
1357
+ catalogFile: z3.string().optional().describe("custom catalog JSON file (default: the built-in catalog)"),
1358
+ category: z3.string().optional().describe("only include probes from this category")
1359
+ }
1360
+ }, async ({ inFile, catalogFile, category }) => {
1361
+ let records;
1362
+ let source;
1363
+ try {
1364
+ records = loadRunRecords(inFile, category);
1365
+ source = catalogFile ? loadCatalog(catalogFile) : CATALOG;
1366
+ } catch (e) {
1367
+ return errorText(msg(e));
1368
+ }
1369
+ return json(assertSummary(records, source));
1370
+ });
903
1371
  server.registerTool("mira_doctor", {
904
1372
  title: "Preflight checks",
905
1373
  description: "Check env, session, connectivity and @mira resolution (read-only — sends nothing)."
package/llms.txt CHANGED
@@ -35,6 +35,9 @@ A second frontend over the same core, for agents. Tools:
35
35
  - `mira_loop` { category?, max?, peer?, gapMs?, settleMs?, timeoutMs?, catalogFile? } — run the catalog, **observe-only** (never clicks / spends credits).
36
36
  - `mira_catalog` { category?, catalogFile? } — list the catalog (no network).
37
37
  - `mira_report` { inFile?, category? } — run log → Markdown.
38
+ - `mira_stats` { inFile?, category? } — run-log summary: totals, reply rate, latency, by-category (JSON, no network).
39
+ - `mira_diff` { baseline, current? } — drift between two run logs: regressions / changes (JSON, no network).
40
+ - `mira_assert` { inFile?, catalogFile?, category? } — re-grade a saved run log against the catalog's `expect` (JSON, no network).
38
41
  - `mira_doctor` — env / session / connectivity check (no arguments).
39
42
  Credentials come from the MCP `env` block or a `.env` in the server's cwd.
40
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mira-harness",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "A CLI + MCP dev-tool for communicating with the @mira Telegram bot — capture its full reply (buttons/links/media) and run a self-driving experiment catalog.",
5
5
  "type": "module",
6
6
  "bin": {