mira-harness 0.2.1 → 0.2.3
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 +14 -0
- package/dist/cli.js +267 -143
- package/llms.txt +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -106,6 +106,7 @@ a "typing…" fallback for a slow bot — replies run 5–62s) and capture, per
|
|
|
106
106
|
| `watch` | Live-tail @mira's messages (observe-only). `--peer` |
|
|
107
107
|
| `report` | Distill the run log into Markdown. `--in --out --category` |
|
|
108
108
|
| `stats` | At-a-glance dashboard: totals, latency records, sparkline. `--in --category --json` |
|
|
109
|
+
| `diff` | Compare two run logs for @mira behavioral drift (exit 1 on a regression). `--json --no-fail` |
|
|
109
110
|
|
|
110
111
|
Run `mira-harness --help` (or `<command> --help`) for full options.
|
|
111
112
|
|
|
@@ -159,6 +160,19 @@ Give a probe an optional `expect` block and `loop` grades it ✓/✗. The checks
|
|
|
159
160
|
Probes without `expect` stay observe-only (informational). `loop` **exits non-zero** if any
|
|
160
161
|
graded probe fails — so it drops straight into CI. Add `--no-fail` to report without failing.
|
|
161
162
|
|
|
163
|
+
### Drift detection
|
|
164
|
+
|
|
165
|
+
`diff` compares two run logs and flags how @mira's behavior **changed** (structural, not
|
|
166
|
+
exact text). **Regressions** — an assertion that flipped ✓→✗, a probe that now times out, a
|
|
167
|
+
>2× latency blow-up — exit non-zero; surface changes (buttons / links / media) and
|
|
168
|
+
improvements are reported but pass. Snapshot a baseline, re-run later, diff:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
MIRA_RUNS_FILE=baseline.jsonl mira-harness loop --category core # snapshot a baseline
|
|
172
|
+
mira-harness loop --category core # a later run -> mira-runs.jsonl
|
|
173
|
+
mira-harness diff baseline.jsonl # vs the current run log
|
|
174
|
+
```
|
|
175
|
+
|
|
162
176
|
## Use as a library
|
|
163
177
|
|
|
164
178
|
The CLI is a thin frontend over an exported core:
|
package/dist/cli.js
CHANGED
|
@@ -430,6 +430,263 @@ function listCatalog(opts = {}) {
|
|
|
430
430
|
}
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
// src/commands/diff.ts
|
|
434
|
+
import { resolve as resolve4 } from "node:path";
|
|
435
|
+
|
|
436
|
+
// src/log.ts
|
|
437
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
438
|
+
import { dirname, resolve as resolve2 } from "node:path";
|
|
439
|
+
var RUNS_FILE = resolve2(process.cwd(), process.env.MIRA_RUNS_FILE ?? "mira-runs.jsonl");
|
|
440
|
+
async function appendRun(result, meta = {}) {
|
|
441
|
+
await mkdir(dirname(RUNS_FILE), { recursive: true });
|
|
442
|
+
const record = { ...meta, ...result };
|
|
443
|
+
await appendFile(RUNS_FILE, `${JSON.stringify(record)}
|
|
444
|
+
`, "utf8");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/commands/report.ts
|
|
448
|
+
import { readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
449
|
+
import { resolve as resolve3 } from "node:path";
|
|
450
|
+
function loadRunRecords(inFile, category) {
|
|
451
|
+
const path = inFile ? resolve3(process.cwd(), inFile) : RUNS_FILE;
|
|
452
|
+
const raw = readFileSync2(path, "utf8");
|
|
453
|
+
const records = [];
|
|
454
|
+
for (const line of raw.split(`
|
|
455
|
+
`)) {
|
|
456
|
+
const t = line.trim();
|
|
457
|
+
if (!t)
|
|
458
|
+
continue;
|
|
459
|
+
try {
|
|
460
|
+
records.push(JSON.parse(t));
|
|
461
|
+
} catch {}
|
|
462
|
+
}
|
|
463
|
+
return category ? records.filter((r) => r.category === category) : records;
|
|
464
|
+
}
|
|
465
|
+
function truncate(s, n) {
|
|
466
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
467
|
+
return flat.length > n ? `${flat.slice(0, n - 1)}…` : flat;
|
|
468
|
+
}
|
|
469
|
+
function cell(s) {
|
|
470
|
+
return s.replace(/\|/g, "\\|");
|
|
471
|
+
}
|
|
472
|
+
function signals(r) {
|
|
473
|
+
const btns = r.messages.reduce((n, m) => n + m.buttons.length, 0);
|
|
474
|
+
const links = r.messages.reduce((n, m) => n + m.links.length, 0);
|
|
475
|
+
const media = r.messages.filter((m) => m.media).map((m) => m.media?.kind);
|
|
476
|
+
const edits = r.messages.reduce((n, m) => n + m.editCount, 0);
|
|
477
|
+
return [
|
|
478
|
+
btns ? `${btns}btn` : "",
|
|
479
|
+
links ? `${links}link` : "",
|
|
480
|
+
media.length ? `media=${media.join(",")}` : "",
|
|
481
|
+
edits ? `${edits}edit` : ""
|
|
482
|
+
].filter(Boolean).join(" ") || "—";
|
|
483
|
+
}
|
|
484
|
+
function gist(r) {
|
|
485
|
+
const first = r.messages.find((m) => m.text)?.text ?? (r.messages.length ? "(non-text)" : "(no reply)");
|
|
486
|
+
return truncate(first, 90);
|
|
487
|
+
}
|
|
488
|
+
function verdictCell(r) {
|
|
489
|
+
if (!r.assert)
|
|
490
|
+
return "—";
|
|
491
|
+
if (r.assert.ok)
|
|
492
|
+
return "✅";
|
|
493
|
+
const failed = r.assert.checks.filter((ch) => !ch.ok).map((ch) => ch.name).join(", ");
|
|
494
|
+
return `❌ ${cell(failed)}`;
|
|
495
|
+
}
|
|
496
|
+
function table(rows) {
|
|
497
|
+
const head = `| Probe | Test | Sent | Reply (gist) | Signals | First reply | Settled |
|
|
498
|
+
` + "|---|---|---|---|---|---|---|";
|
|
499
|
+
const body = rows.map((r) => {
|
|
500
|
+
const id = r.probeId ?? "—";
|
|
501
|
+
const sent = cell(truncate(r.sent, 40));
|
|
502
|
+
const reply = r.timedOut ? "**TIMEOUT**" : cell(gist(r));
|
|
503
|
+
const first = r.firstReplyMs === null ? "—" : `${(r.firstReplyMs / 1000).toFixed(1)}s`;
|
|
504
|
+
const total = `${(r.totalMs / 1000).toFixed(1)}s`;
|
|
505
|
+
return `| \`${cell(id)}\` | ${verdictCell(r)} | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
|
|
506
|
+
}).join(`
|
|
507
|
+
`);
|
|
508
|
+
return `${head}
|
|
509
|
+
${body}`;
|
|
510
|
+
}
|
|
511
|
+
function buildReport(records) {
|
|
512
|
+
const replied = records.filter((r) => !r.timedOut);
|
|
513
|
+
const latencies = replied.map((r) => r.firstReplyMs ?? 0).filter((n) => n > 0);
|
|
514
|
+
const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
|
|
515
|
+
const max = latencies.length ? Math.max(...latencies) : 0;
|
|
516
|
+
const graded = records.filter((r) => r.assert);
|
|
517
|
+
const passed = graded.filter((r) => r.assert?.ok);
|
|
518
|
+
const out = [];
|
|
519
|
+
out.push("# Mira(@mira) probe report (auto-generated)");
|
|
520
|
+
out.push("");
|
|
521
|
+
out.push("> Distilled from the run log by `mira-harness report`.");
|
|
522
|
+
out.push("");
|
|
523
|
+
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**.`);
|
|
524
|
+
out.push("");
|
|
525
|
+
const builtin = CATEGORIES.filter((c2) => records.some((r) => r.category === c2));
|
|
526
|
+
const custom = [];
|
|
527
|
+
for (const r of records) {
|
|
528
|
+
if (r.category && !CATEGORIES.includes(r.category) && !custom.includes(r.category)) {
|
|
529
|
+
custom.push(r.category);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
for (const cat of [...builtin, ...custom]) {
|
|
533
|
+
out.push(`## ${cat}`, "", table(records.filter((r) => r.category === cat)), "");
|
|
534
|
+
}
|
|
535
|
+
const uncategorized = records.filter((r) => !r.category);
|
|
536
|
+
if (uncategorized.length)
|
|
537
|
+
out.push("## (uncategorized)", "", table(uncategorized), "");
|
|
538
|
+
return out.join(`
|
|
539
|
+
`);
|
|
540
|
+
}
|
|
541
|
+
function renderReport(inFile, category) {
|
|
542
|
+
const records = loadRunRecords(inFile, category);
|
|
543
|
+
if (!records.length) {
|
|
544
|
+
throw new Error(category ? `no probes in category "${category}" in the run log.` : "run log is empty.");
|
|
545
|
+
}
|
|
546
|
+
return buildReport(records);
|
|
547
|
+
}
|
|
548
|
+
function report(opts) {
|
|
549
|
+
let md;
|
|
550
|
+
try {
|
|
551
|
+
md = renderReport(opts.in, opts.category);
|
|
552
|
+
} catch (e) {
|
|
553
|
+
if (e?.code === "ENOENT") {
|
|
554
|
+
const path = opts.in ? resolve3(process.cwd(), opts.in) : RUNS_FILE;
|
|
555
|
+
console.error(`no run log at ${path} — run \`mira-harness send\` or \`mira-harness loop\` first.`);
|
|
556
|
+
} else {
|
|
557
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
558
|
+
}
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
if (opts.out) {
|
|
562
|
+
const dest = resolve3(process.cwd(), opts.out);
|
|
563
|
+
writeFileSync(dest, `${md}
|
|
564
|
+
`, "utf8");
|
|
565
|
+
console.error(`wrote report to ${opts.out}`);
|
|
566
|
+
} else {
|
|
567
|
+
console.log(md);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// src/commands/diff.ts
|
|
572
|
+
function lastByProbe(records) {
|
|
573
|
+
const m = new Map;
|
|
574
|
+
for (const r of records) {
|
|
575
|
+
if (r.probeId)
|
|
576
|
+
m.set(r.probeId, r);
|
|
577
|
+
}
|
|
578
|
+
return m;
|
|
579
|
+
}
|
|
580
|
+
function signals2(r) {
|
|
581
|
+
const buttons = r.messages.reduce((n, m) => n + m.buttons.length, 0);
|
|
582
|
+
const links = r.messages.reduce((n, m) => n + m.links.length, 0);
|
|
583
|
+
const media = r.messages.map((m) => m.media?.kind).filter((k) => k !== undefined).join(",");
|
|
584
|
+
return { buttons, links, media };
|
|
585
|
+
}
|
|
586
|
+
function diffRuns(baseline, current) {
|
|
587
|
+
const base = lastByProbe(baseline);
|
|
588
|
+
const cur = lastByProbe(current);
|
|
589
|
+
const items = [];
|
|
590
|
+
for (const id of [...new Set([...base.keys(), ...cur.keys()])].sort()) {
|
|
591
|
+
const b = base.get(id);
|
|
592
|
+
const c2 = cur.get(id);
|
|
593
|
+
if (b && !c2) {
|
|
594
|
+
items.push({ probeId: id, kind: "removed", what: "in baseline, not in current" });
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (c2 && !b) {
|
|
598
|
+
items.push({ probeId: id, kind: "added", what: "new in current (not in baseline)" });
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
if (!b || !c2)
|
|
602
|
+
continue;
|
|
603
|
+
if (!b.timedOut && c2.timedOut)
|
|
604
|
+
items.push({ probeId: id, kind: "regression", what: "now times out (was replying)" });
|
|
605
|
+
else if (b.timedOut && !c2.timedOut)
|
|
606
|
+
items.push({ probeId: id, kind: "improvement", what: "now replies (was timing out)" });
|
|
607
|
+
const ba = b.assert;
|
|
608
|
+
const ca = c2.assert;
|
|
609
|
+
if (ba && ca && ba.ok !== ca.ok) {
|
|
610
|
+
if (ba.ok && !ca.ok) {
|
|
611
|
+
const failed = ca.checks.filter((x) => !x.ok).map((x) => x.name).join(", ");
|
|
612
|
+
items.push({ probeId: id, kind: "regression", what: `assertion now FAILS (${failed})` });
|
|
613
|
+
} else {
|
|
614
|
+
items.push({ probeId: id, kind: "improvement", what: "assertion now passes" });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (b.firstReplyMs !== null && c2.firstReplyMs !== null) {
|
|
618
|
+
const bms = b.firstReplyMs;
|
|
619
|
+
const cms = c2.firstReplyMs;
|
|
620
|
+
if (cms > bms * 2 && cms - bms > 5000) {
|
|
621
|
+
items.push({
|
|
622
|
+
probeId: id,
|
|
623
|
+
kind: "regression",
|
|
624
|
+
what: `first reply ${(bms / 1000).toFixed(1)}s -> ${(cms / 1000).toFixed(1)}s (>2x slower)`
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const bs = signals2(b);
|
|
629
|
+
const cs = signals2(c2);
|
|
630
|
+
if (bs.buttons !== cs.buttons)
|
|
631
|
+
items.push({ probeId: id, kind: "change", what: `buttons ${bs.buttons} -> ${cs.buttons}` });
|
|
632
|
+
if (bs.links !== cs.links)
|
|
633
|
+
items.push({ probeId: id, kind: "change", what: `links ${bs.links} -> ${cs.links}` });
|
|
634
|
+
if (bs.media !== cs.media)
|
|
635
|
+
items.push({ probeId: id, kind: "change", what: `media [${bs.media}] -> [${cs.media}]` });
|
|
636
|
+
}
|
|
637
|
+
return items;
|
|
638
|
+
}
|
|
639
|
+
function load(path, label) {
|
|
640
|
+
try {
|
|
641
|
+
return loadRunRecords(path);
|
|
642
|
+
} catch (e) {
|
|
643
|
+
const shown = path ? resolve4(process.cwd(), path) : RUNS_FILE;
|
|
644
|
+
if (e?.code === "ENOENT")
|
|
645
|
+
console.error(`${label}: no run log at ${shown}`);
|
|
646
|
+
else
|
|
647
|
+
console.error(`${label}: ${e instanceof Error ? e.message : String(e)}`);
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function diff(opts) {
|
|
652
|
+
const base = load(opts.baseline, "baseline");
|
|
653
|
+
const cur = load(opts.current, "current");
|
|
654
|
+
if (!base || !cur)
|
|
655
|
+
process.exit(1);
|
|
656
|
+
if (!base.length)
|
|
657
|
+
note(c.yellow(`baseline ${opts.baseline} has no records.`));
|
|
658
|
+
if (!cur.length)
|
|
659
|
+
note(c.yellow(`current ${opts.current ?? "(run log)"} has no records.`));
|
|
660
|
+
const items = diffRuns(base, cur);
|
|
661
|
+
const regressions = items.filter((i) => i.kind === "regression");
|
|
662
|
+
if (opts.json) {
|
|
663
|
+
console.log(JSON.stringify({ regressions: regressions.length, drift: items.length, items }, null, 2));
|
|
664
|
+
if (regressions.length && !opts.noFail)
|
|
665
|
+
process.exit(1);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
note(c.bold(`drift ${opts.baseline} -> ${opts.current ?? "(current run log)"}`));
|
|
669
|
+
if (!items.length) {
|
|
670
|
+
note(c.green(" no drift detected."));
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const paint = {
|
|
674
|
+
regression: (s) => c.red(s),
|
|
675
|
+
improvement: (s) => c.green(s),
|
|
676
|
+
change: (s) => c.dim(s),
|
|
677
|
+
added: (s) => c.dim(s),
|
|
678
|
+
removed: (s) => c.dim(s)
|
|
679
|
+
};
|
|
680
|
+
for (const i of items) {
|
|
681
|
+
note(` ${paint[i.kind](i.kind.padEnd(11))} ${c.cyan(i.probeId)} — ${i.what}`);
|
|
682
|
+
}
|
|
683
|
+
note(regressions.length ? c.red(`
|
|
684
|
+
${regressions.length} regression(s).`) : c.green(`
|
|
685
|
+
no regressions.`));
|
|
686
|
+
if (regressions.length && !opts.noFail)
|
|
687
|
+
process.exit(1);
|
|
688
|
+
}
|
|
689
|
+
|
|
433
690
|
// src/client.ts
|
|
434
691
|
import { Api, TelegramClient } from "telegram";
|
|
435
692
|
import { EditedMessage } from "telegram/events/EditedMessage.js";
|
|
@@ -602,7 +859,7 @@ function buildCollector(peer, client, t, sent, opts) {
|
|
|
602
859
|
const collected = new Map;
|
|
603
860
|
const start = Date.now();
|
|
604
861
|
let firstReplyMs = null;
|
|
605
|
-
return new Promise((
|
|
862
|
+
return new Promise((resolve5) => {
|
|
606
863
|
let settleTimer;
|
|
607
864
|
let firstTimer;
|
|
608
865
|
let hardTimer;
|
|
@@ -632,7 +889,7 @@ function buildCollector(peer, client, t, sent, opts) {
|
|
|
632
889
|
const finish = () => {
|
|
633
890
|
cleanup();
|
|
634
891
|
const messages = [...collected.values()].sort((a, b) => a.msg.id - b.msg.id).map(({ msg, editCount }) => extractMessage(msg, editCount));
|
|
635
|
-
|
|
892
|
+
resolve5({
|
|
636
893
|
peer,
|
|
637
894
|
sent,
|
|
638
895
|
messages,
|
|
@@ -797,19 +1054,6 @@ This string grants full access to your account. Never commit it.
|
|
|
797
1054
|
|
|
798
1055
|
// src/commands/loop.ts
|
|
799
1056
|
import { existsSync } from "node:fs";
|
|
800
|
-
|
|
801
|
-
// src/log.ts
|
|
802
|
-
import { appendFile, mkdir } from "node:fs/promises";
|
|
803
|
-
import { dirname, resolve as resolve2 } from "node:path";
|
|
804
|
-
var RUNS_FILE = resolve2(process.cwd(), process.env.MIRA_RUNS_FILE ?? "mira-runs.jsonl");
|
|
805
|
-
async function appendRun(result, meta = {}) {
|
|
806
|
-
await mkdir(dirname(RUNS_FILE), { recursive: true });
|
|
807
|
-
const record = { ...meta, ...result };
|
|
808
|
-
await appendFile(RUNS_FILE, `${JSON.stringify(record)}
|
|
809
|
-
`, "utf8");
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
// src/commands/loop.ts
|
|
813
1057
|
var STOP_FILE = "STOP_MIRA";
|
|
814
1058
|
var DEFAULT_GAP_MS = 15000;
|
|
815
1059
|
var SLOW = { firstReplyTimeoutMs: 90000, maxMs: 240000, typingGraceMs: 90000 };
|
|
@@ -952,130 +1196,6 @@ async function loop(opts) {
|
|
|
952
1196
|
}
|
|
953
1197
|
}
|
|
954
1198
|
|
|
955
|
-
// src/commands/report.ts
|
|
956
|
-
import { readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
957
|
-
import { resolve as resolve3 } from "node:path";
|
|
958
|
-
function loadRunRecords(inFile, category) {
|
|
959
|
-
const path = inFile ? resolve3(process.cwd(), inFile) : RUNS_FILE;
|
|
960
|
-
const raw = readFileSync2(path, "utf8");
|
|
961
|
-
const records = [];
|
|
962
|
-
for (const line of raw.split(`
|
|
963
|
-
`)) {
|
|
964
|
-
const t = line.trim();
|
|
965
|
-
if (!t)
|
|
966
|
-
continue;
|
|
967
|
-
try {
|
|
968
|
-
records.push(JSON.parse(t));
|
|
969
|
-
} catch {}
|
|
970
|
-
}
|
|
971
|
-
return category ? records.filter((r) => r.category === category) : records;
|
|
972
|
-
}
|
|
973
|
-
function truncate(s, n) {
|
|
974
|
-
const flat = s.replace(/\s+/g, " ").trim();
|
|
975
|
-
return flat.length > n ? `${flat.slice(0, n - 1)}…` : flat;
|
|
976
|
-
}
|
|
977
|
-
function cell(s) {
|
|
978
|
-
return s.replace(/\|/g, "\\|");
|
|
979
|
-
}
|
|
980
|
-
function signals(r) {
|
|
981
|
-
const btns = r.messages.reduce((n, m) => n + m.buttons.length, 0);
|
|
982
|
-
const links = r.messages.reduce((n, m) => n + m.links.length, 0);
|
|
983
|
-
const media = r.messages.filter((m) => m.media).map((m) => m.media?.kind);
|
|
984
|
-
const edits = r.messages.reduce((n, m) => n + m.editCount, 0);
|
|
985
|
-
return [
|
|
986
|
-
btns ? `${btns}btn` : "",
|
|
987
|
-
links ? `${links}link` : "",
|
|
988
|
-
media.length ? `media=${media.join(",")}` : "",
|
|
989
|
-
edits ? `${edits}edit` : ""
|
|
990
|
-
].filter(Boolean).join(" ") || "—";
|
|
991
|
-
}
|
|
992
|
-
function gist(r) {
|
|
993
|
-
const first = r.messages.find((m) => m.text)?.text ?? (r.messages.length ? "(non-text)" : "(no reply)");
|
|
994
|
-
return truncate(first, 90);
|
|
995
|
-
}
|
|
996
|
-
function verdictCell(r) {
|
|
997
|
-
if (!r.assert)
|
|
998
|
-
return "—";
|
|
999
|
-
if (r.assert.ok)
|
|
1000
|
-
return "✅";
|
|
1001
|
-
const failed = r.assert.checks.filter((ch) => !ch.ok).map((ch) => ch.name).join(", ");
|
|
1002
|
-
return `❌ ${cell(failed)}`;
|
|
1003
|
-
}
|
|
1004
|
-
function table(rows) {
|
|
1005
|
-
const head = `| Probe | Test | Sent | Reply (gist) | Signals | First reply | Settled |
|
|
1006
|
-
` + "|---|---|---|---|---|---|---|";
|
|
1007
|
-
const body = rows.map((r) => {
|
|
1008
|
-
const id = r.probeId ?? "—";
|
|
1009
|
-
const sent = cell(truncate(r.sent, 40));
|
|
1010
|
-
const reply = r.timedOut ? "**TIMEOUT**" : cell(gist(r));
|
|
1011
|
-
const first = r.firstReplyMs === null ? "—" : `${(r.firstReplyMs / 1000).toFixed(1)}s`;
|
|
1012
|
-
const total = `${(r.totalMs / 1000).toFixed(1)}s`;
|
|
1013
|
-
return `| \`${cell(id)}\` | ${verdictCell(r)} | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
|
|
1014
|
-
}).join(`
|
|
1015
|
-
`);
|
|
1016
|
-
return `${head}
|
|
1017
|
-
${body}`;
|
|
1018
|
-
}
|
|
1019
|
-
function buildReport(records) {
|
|
1020
|
-
const replied = records.filter((r) => !r.timedOut);
|
|
1021
|
-
const latencies = replied.map((r) => r.firstReplyMs ?? 0).filter((n) => n > 0);
|
|
1022
|
-
const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
|
|
1023
|
-
const max = latencies.length ? Math.max(...latencies) : 0;
|
|
1024
|
-
const graded = records.filter((r) => r.assert);
|
|
1025
|
-
const passed = graded.filter((r) => r.assert?.ok);
|
|
1026
|
-
const out = [];
|
|
1027
|
-
out.push("# Mira(@mira) probe report (auto-generated)");
|
|
1028
|
-
out.push("");
|
|
1029
|
-
out.push("> Distilled from the run log by `mira-harness report`.");
|
|
1030
|
-
out.push("");
|
|
1031
|
-
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**.`);
|
|
1032
|
-
out.push("");
|
|
1033
|
-
const builtin = CATEGORIES.filter((c2) => records.some((r) => r.category === c2));
|
|
1034
|
-
const custom = [];
|
|
1035
|
-
for (const r of records) {
|
|
1036
|
-
if (r.category && !CATEGORIES.includes(r.category) && !custom.includes(r.category)) {
|
|
1037
|
-
custom.push(r.category);
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
for (const cat of [...builtin, ...custom]) {
|
|
1041
|
-
out.push(`## ${cat}`, "", table(records.filter((r) => r.category === cat)), "");
|
|
1042
|
-
}
|
|
1043
|
-
const uncategorized = records.filter((r) => !r.category);
|
|
1044
|
-
if (uncategorized.length)
|
|
1045
|
-
out.push("## (uncategorized)", "", table(uncategorized), "");
|
|
1046
|
-
return out.join(`
|
|
1047
|
-
`);
|
|
1048
|
-
}
|
|
1049
|
-
function renderReport(inFile, category) {
|
|
1050
|
-
const records = loadRunRecords(inFile, category);
|
|
1051
|
-
if (!records.length) {
|
|
1052
|
-
throw new Error(category ? `no probes in category "${category}" in the run log.` : "run log is empty.");
|
|
1053
|
-
}
|
|
1054
|
-
return buildReport(records);
|
|
1055
|
-
}
|
|
1056
|
-
function report(opts) {
|
|
1057
|
-
let md;
|
|
1058
|
-
try {
|
|
1059
|
-
md = renderReport(opts.in, opts.category);
|
|
1060
|
-
} catch (e) {
|
|
1061
|
-
if (e?.code === "ENOENT") {
|
|
1062
|
-
const path = opts.in ? resolve3(process.cwd(), opts.in) : RUNS_FILE;
|
|
1063
|
-
console.error(`no run log at ${path} — run \`mira-harness send\` or \`mira-harness loop\` first.`);
|
|
1064
|
-
} else {
|
|
1065
|
-
console.error(e instanceof Error ? e.message : String(e));
|
|
1066
|
-
}
|
|
1067
|
-
process.exit(1);
|
|
1068
|
-
}
|
|
1069
|
-
if (opts.out) {
|
|
1070
|
-
const dest = resolve3(process.cwd(), opts.out);
|
|
1071
|
-
writeFileSync(dest, `${md}
|
|
1072
|
-
`, "utf8");
|
|
1073
|
-
console.error(`wrote report to ${opts.out}`);
|
|
1074
|
-
} else {
|
|
1075
|
-
console.log(md);
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
1199
|
// src/commands/send.ts
|
|
1080
1200
|
import { existsSync as existsSync2 } from "node:fs";
|
|
1081
1201
|
var STOP_FILE2 = "STOP_MIRA";
|
|
@@ -1123,7 +1243,7 @@ async function send(rawMessage, opts = {}) {
|
|
|
1123
1243
|
}
|
|
1124
1244
|
|
|
1125
1245
|
// src/commands/stats.ts
|
|
1126
|
-
import { resolve as
|
|
1246
|
+
import { resolve as resolve5 } from "node:path";
|
|
1127
1247
|
var SPARK = "▁▂▃▄▅▆▇█";
|
|
1128
1248
|
function sparkline(values) {
|
|
1129
1249
|
if (!values.length)
|
|
@@ -1162,7 +1282,7 @@ function stats(opts = {}) {
|
|
|
1162
1282
|
records = loadRunRecords(opts.in, opts.category);
|
|
1163
1283
|
} catch (e) {
|
|
1164
1284
|
if (e?.code === "ENOENT") {
|
|
1165
|
-
const path = opts.in ?
|
|
1285
|
+
const path = opts.in ? resolve5(process.cwd(), opts.in) : RUNS_FILE;
|
|
1166
1286
|
console.error(`no run log at ${path} — run \`mira-harness send\` or \`mira-harness loop\` first.`);
|
|
1167
1287
|
} else {
|
|
1168
1288
|
console.error(e instanceof Error ? e.message : String(e));
|
|
@@ -1253,11 +1373,11 @@ async function watch(opts = {}) {
|
|
|
1253
1373
|
const client = await connect(session);
|
|
1254
1374
|
const unsubscribe = await subscribe(client, peer, (m, kind) => note(line(peer, m, kind)));
|
|
1255
1375
|
note(c.bold(`watching @${peer} — Ctrl-C to stop`));
|
|
1256
|
-
await new Promise((
|
|
1376
|
+
await new Promise((resolve6) => {
|
|
1257
1377
|
process.once("SIGINT", () => {
|
|
1258
1378
|
note(c.dim(`
|
|
1259
1379
|
stopping…`));
|
|
1260
|
-
|
|
1380
|
+
resolve6();
|
|
1261
1381
|
});
|
|
1262
1382
|
});
|
|
1263
1383
|
unsubscribe();
|
|
@@ -1338,6 +1458,9 @@ program.command("report").description("Distill the run log (JSONL) into a Markdo
|
|
|
1338
1458
|
program.command("stats").description("At-a-glance run-log dashboard: totals, latency records, sparkline").option("--in <file>", "input JSONL (default: the run log)").option("-c, --category <category>", "only include probes from this category").option("--json", "output a JSON summary instead of the colored dashboard", false).action((opts) => {
|
|
1339
1459
|
stats({ in: opts.in, category: opts.category, json: opts.json });
|
|
1340
1460
|
});
|
|
1461
|
+
program.command("diff").description("Compare two run logs for @mira behavioral drift (exit 1 on a regression)").argument("<baseline>", "baseline run log (JSONL)").argument("[current]", "current run log (JSONL); defaults to the run log").option("--json", "output the drift as JSON", false).option("--no-fail", "report regressions but still exit 0").action((baseline, current, opts) => {
|
|
1462
|
+
diff({ baseline, current, json: opts.json, noFail: !opts.fail });
|
|
1463
|
+
});
|
|
1341
1464
|
program.addHelpText("after", `
|
|
1342
1465
|
Examples:
|
|
1343
1466
|
$ mira-harness login
|
|
@@ -1352,6 +1475,7 @@ Examples:
|
|
|
1352
1475
|
$ mira-harness watch
|
|
1353
1476
|
$ mira-harness report --category core --out report.md
|
|
1354
1477
|
$ mira-harness stats
|
|
1478
|
+
$ mira-harness diff baseline.jsonl
|
|
1355
1479
|
`);
|
|
1356
1480
|
if (process.argv.length <= 2) {
|
|
1357
1481
|
banner(version);
|
package/llms.txt
CHANGED
|
@@ -24,6 +24,7 @@ so the only programmatic path is a userbot (a real user account over MTProto / G
|
|
|
24
24
|
- `watch` — live-tail @mira's messages, observe-only. Flag: `--peer`.
|
|
25
25
|
- `report` — distill the JSONL run log into Markdown. Flags: `--in <file> --out <file> --category`.
|
|
26
26
|
- `stats` — at-a-glance run-log dashboard: totals, first-reply latency records (fastest / median / p95 / slowest), an ASCII sparkline, and a per-category breakdown. Flags: `--in <file> --category --json`.
|
|
27
|
+
- `diff <baseline> [current]` — compare two run logs for @mira behavioral drift (matches probes by id; structural, not exact-text). Regressions (assertion ✓→✗, new timeout, >2x latency) **exit non-zero**; surface changes / improvements are reported. `current` defaults to the run log. Flags: `--json --no-fail`.
|
|
27
28
|
- Help: `mira-harness --help` or `<command> --help`.
|
|
28
29
|
|
|
29
30
|
## MCP server (bin: mira-harness-mcp, entry dist/mcp.js)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mira-harness",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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": {
|