mira-harness 0.2.0 → 0.2.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/README.md +24 -2
- package/dist/assert.d.ts +41 -0
- package/dist/catalog.d.ts +3 -0
- package/dist/cli.js +147 -26
- package/dist/commands/report.d.ts +2 -0
- package/dist/index.js +108 -20
- package/dist/log.d.ts +3 -0
- package/dist/mcp.js +123 -35
- package/llms.txt +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,7 +101,7 @@ a "typing…" fallback for a slow bot — replies run 5–62s) and capture, per
|
|
|
101
101
|
| `login` | One-time interactive login → prints `TG_SESSION` |
|
|
102
102
|
| `doctor` | Check `.env` / session / connectivity / @mira resolution (read-only) |
|
|
103
103
|
| `send [message...]` | One probe → full reply as JSON (message via arg or stdin). `--quiet --settle --timeout --no-log` |
|
|
104
|
-
| `loop` | Run the catalog paced. `--category --max --confirm --peer --gap --settle --timeout --list --catalog --quiet` |
|
|
104
|
+
| `loop` | Run the catalog paced; grades `expect` probes (exit 1 on failure). `--category --max --confirm --peer --gap --settle --timeout --list --catalog --no-fail --quiet` |
|
|
105
105
|
| `catalog` | List the catalog (no sends). `--category --catalog --json` |
|
|
106
106
|
| `watch` | Live-tail @mira's messages (observe-only). `--peer` |
|
|
107
107
|
| `report` | Distill the run log into Markdown. `--in --out --category` |
|
|
@@ -130,13 +130,35 @@ per-feature flags — `MIRA_NO_BANNER=1` (mascot + tip), `MIRA_NO_NOTIFY=1` (com
|
|
|
130
130
|
The built-in catalog (27 probes: `core` / `skills` / `generation` / `wallet`) is just a
|
|
131
131
|
default. Point `--catalog <file.json>` (CLI) or `catalogFile` (MCP) at your own probe set
|
|
132
132
|
to probe any bot — each entry needs `id` + `send` (`category` / `hypothesis` / `slow` /
|
|
133
|
-
`confirm` / `note` optional). See [`examples/catalog.sample.json`](examples/catalog.sample.json):
|
|
133
|
+
`confirm` / `note` / `expect` optional). See [`examples/catalog.sample.json`](examples/catalog.sample.json):
|
|
134
134
|
|
|
135
135
|
```bash
|
|
136
136
|
mira-harness loop --catalog ./examples/catalog.sample.json
|
|
137
137
|
mira-harness catalog --catalog ./examples/catalog.sample.json --json
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
+
### Assertions (PASS/FAIL)
|
|
141
|
+
|
|
142
|
+
Give a probe an optional `expect` block and `loop` grades it ✓/✗. The checks are **structural**
|
|
143
|
+
— @mira is an LLM (non-deterministic), so exact-text matches would flake:
|
|
144
|
+
|
|
145
|
+
| Check | Means |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `replies: true` | a reply arrived (no timeout) |
|
|
148
|
+
| `textMatches: "<regex>"` | some message text matches (case-insensitive) |
|
|
149
|
+
| `minButtons` / `minLinks` | at least N inline buttons / links across messages |
|
|
150
|
+
| `hasWebApp: true` | a Mini App (`web_app` / startapp) "Launch" button is present |
|
|
151
|
+
| `media: "photo"` | a message carries media of that kind (`photo`/`video`/`audio`/…) |
|
|
152
|
+
| `maxFirstReplyMs` | first-reply latency within the bound |
|
|
153
|
+
| `json: true` | the first message text parses as JSON |
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{ "id": "json-strict", "send": "Reply with ONLY {\"ok\":true}", "expect": { "json": true } }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Probes without `expect` stay observe-only (informational). `loop` **exits non-zero** if any
|
|
160
|
+
graded probe fails — so it drops straight into CI. Add `--no-fail` to report without failing.
|
|
161
|
+
|
|
140
162
|
## Use as a library
|
|
141
163
|
|
|
142
164
|
The CLI is a thin frontend over an exported core:
|
package/dist/assert.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe assertions — turn an observed reply into a PASS/FAIL verdict.
|
|
3
|
+
*
|
|
4
|
+
* A probe's optional `expect` declares machine-checkable expectations; `evaluate`
|
|
5
|
+
* runs them against the captured ProbeResult. @mira is an LLM (non-deterministic),
|
|
6
|
+
* so the checks are deliberately STRUCTURAL and loose — a reply arrived, >= N
|
|
7
|
+
* links, valid JSON, a latency bound — never exact text, which would flake.
|
|
8
|
+
*
|
|
9
|
+
* Probes WITHOUT `expect` are not graded: they stay observe-only / informational.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import type { ProbeResult } from "./capture.js";
|
|
13
|
+
export declare const ExpectSchema: z.ZodObject<{
|
|
14
|
+
replies: z.ZodOptional<z.ZodBoolean>;
|
|
15
|
+
textMatches: z.ZodOptional<z.ZodString>;
|
|
16
|
+
minButtons: z.ZodOptional<z.ZodNumber>;
|
|
17
|
+
minLinks: z.ZodOptional<z.ZodNumber>;
|
|
18
|
+
hasWebApp: z.ZodOptional<z.ZodBoolean>;
|
|
19
|
+
media: z.ZodOptional<z.ZodEnum<{
|
|
20
|
+
photo: "photo";
|
|
21
|
+
video: "video";
|
|
22
|
+
audio: "audio";
|
|
23
|
+
document: "document";
|
|
24
|
+
webpage: "webpage";
|
|
25
|
+
other: "other";
|
|
26
|
+
}>>;
|
|
27
|
+
maxFirstReplyMs: z.ZodOptional<z.ZodNumber>;
|
|
28
|
+
json: z.ZodOptional<z.ZodBoolean>;
|
|
29
|
+
}, z.core.$strip>;
|
|
30
|
+
export type Expect = z.infer<typeof ExpectSchema>;
|
|
31
|
+
export interface Check {
|
|
32
|
+
name: string;
|
|
33
|
+
ok: boolean;
|
|
34
|
+
detail: string;
|
|
35
|
+
}
|
|
36
|
+
export interface Verdict {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
checks: Check[];
|
|
39
|
+
}
|
|
40
|
+
/** Run a probe's expectations against its captured result. Pure — no network. */
|
|
41
|
+
export declare function evaluate(expect: Expect, result: ProbeResult): Verdict;
|
package/dist/catalog.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Expect } from "./assert.js";
|
|
1
2
|
/** Built-in categories. Custom catalogs may use any category string. */
|
|
2
3
|
export type ProbeCategory = "core" | "skills" | "generation" | "wallet";
|
|
3
4
|
export interface Probe {
|
|
@@ -15,6 +16,8 @@ export interface Probe {
|
|
|
15
16
|
*/
|
|
16
17
|
confirm?: boolean;
|
|
17
18
|
note?: string;
|
|
19
|
+
/** Optional machine-checkable expectations — graded by `loop` (PASS/FAIL). */
|
|
20
|
+
expect?: Expect;
|
|
18
21
|
}
|
|
19
22
|
export declare const CATEGORIES: ProbeCategory[];
|
|
20
23
|
/**
|
package/dist/cli.js
CHANGED
|
@@ -6,16 +6,87 @@ import { Command } from "commander";
|
|
|
6
6
|
// src/catalog.ts
|
|
7
7
|
import { readFileSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
|
+
import { z as z2 } from "zod";
|
|
10
|
+
|
|
11
|
+
// src/assert.ts
|
|
9
12
|
import { z } from "zod";
|
|
13
|
+
var ExpectSchema = z.object({
|
|
14
|
+
replies: z.boolean().optional(),
|
|
15
|
+
textMatches: z.string().optional(),
|
|
16
|
+
minButtons: z.number().int().nonnegative().optional(),
|
|
17
|
+
minLinks: z.number().int().nonnegative().optional(),
|
|
18
|
+
hasWebApp: z.boolean().optional(),
|
|
19
|
+
media: z.enum(["photo", "video", "audio", "document", "webpage", "other"]).optional(),
|
|
20
|
+
maxFirstReplyMs: z.number().positive().optional(),
|
|
21
|
+
json: z.boolean().optional()
|
|
22
|
+
});
|
|
23
|
+
function evaluate(expect, result) {
|
|
24
|
+
const checks = [];
|
|
25
|
+
const texts = result.messages.map((m) => m.text).filter((t) => t.length > 0);
|
|
26
|
+
const firstText = texts[0] ?? "";
|
|
27
|
+
const buttons = result.messages.reduce((n, m) => n + m.buttons.length, 0);
|
|
28
|
+
const links = result.messages.reduce((n, m) => n + m.links.length, 0);
|
|
29
|
+
const webApp = result.messages.some((m) => m.buttons.some((b) => b.webAppUrl !== undefined));
|
|
30
|
+
const mediaKinds = result.messages.map((m) => m.media?.kind).filter((k) => k !== undefined);
|
|
31
|
+
const add = (name, ok, detail) => {
|
|
32
|
+
checks.push({ name, ok, detail });
|
|
33
|
+
};
|
|
34
|
+
if (expect.replies !== undefined) {
|
|
35
|
+
const replied = !result.timedOut && result.messages.length > 0;
|
|
36
|
+
add("replies", replied === expect.replies, result.timedOut ? "timed out" : `${result.messages.length} message(s)`);
|
|
37
|
+
}
|
|
38
|
+
if (expect.textMatches !== undefined) {
|
|
39
|
+
const pattern = expect.textMatches;
|
|
40
|
+
let compiled;
|
|
41
|
+
try {
|
|
42
|
+
compiled = new RegExp(pattern, "i");
|
|
43
|
+
} catch {
|
|
44
|
+
compiled = undefined;
|
|
45
|
+
}
|
|
46
|
+
const rx = compiled;
|
|
47
|
+
const ok = rx ? texts.some((t) => rx.test(t)) : false;
|
|
48
|
+
add("textMatches", ok, rx ? `/${pattern}/i` : `invalid regex: ${pattern}`);
|
|
49
|
+
}
|
|
50
|
+
if (expect.minButtons !== undefined) {
|
|
51
|
+
add("minButtons", buttons >= expect.minButtons, `${buttons} >= ${expect.minButtons}`);
|
|
52
|
+
}
|
|
53
|
+
if (expect.minLinks !== undefined) {
|
|
54
|
+
add("minLinks", links >= expect.minLinks, `${links} >= ${expect.minLinks}`);
|
|
55
|
+
}
|
|
56
|
+
if (expect.hasWebApp !== undefined) {
|
|
57
|
+
add("hasWebApp", webApp === expect.hasWebApp, `webApp=${webApp}`);
|
|
58
|
+
}
|
|
59
|
+
if (expect.media !== undefined) {
|
|
60
|
+
add("media", mediaKinds.includes(expect.media), `kinds=[${mediaKinds.join(",")}] want ${expect.media}`);
|
|
61
|
+
}
|
|
62
|
+
if (expect.maxFirstReplyMs !== undefined) {
|
|
63
|
+
const ms = result.firstReplyMs;
|
|
64
|
+
add("maxFirstReplyMs", ms !== null && ms <= expect.maxFirstReplyMs, ms === null ? "no reply" : `${ms}ms <= ${expect.maxFirstReplyMs}ms`);
|
|
65
|
+
}
|
|
66
|
+
if (expect.json === true) {
|
|
67
|
+
let ok = false;
|
|
68
|
+
try {
|
|
69
|
+
JSON.parse(firstText);
|
|
70
|
+
ok = firstText.length > 0;
|
|
71
|
+
} catch {
|
|
72
|
+
ok = false;
|
|
73
|
+
}
|
|
74
|
+
add("json", ok, ok ? "valid JSON" : "not valid JSON");
|
|
75
|
+
}
|
|
76
|
+
return { ok: checks.every((ch) => ch.ok), checks };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/catalog.ts
|
|
10
80
|
var CATEGORIES = ["core", "skills", "generation", "wallet"];
|
|
11
|
-
var ProbeSchema =
|
|
12
|
-
id:
|
|
13
|
-
category:
|
|
14
|
-
hypothesis:
|
|
15
|
-
send:
|
|
16
|
-
slow:
|
|
17
|
-
confirm:
|
|
18
|
-
note:
|
|
81
|
+
var ProbeSchema = z2.object({
|
|
82
|
+
id: z2.string().min(1),
|
|
83
|
+
category: z2.string().default("custom"),
|
|
84
|
+
hypothesis: z2.string().default(""),
|
|
85
|
+
send: z2.string().min(1),
|
|
86
|
+
slow: z2.boolean().optional(),
|
|
87
|
+
confirm: z2.boolean().optional(),
|
|
88
|
+
note: z2.string().optional(),
|
|
89
|
+
expect: ExpectSchema.optional()
|
|
19
90
|
});
|
|
20
91
|
function loadCatalog(file) {
|
|
21
92
|
const path = resolve(process.cwd(), file);
|
|
@@ -26,7 +97,7 @@ function loadCatalog(file) {
|
|
|
26
97
|
} catch {
|
|
27
98
|
throw new Error(`catalog ${file} is not valid JSON`);
|
|
28
99
|
}
|
|
29
|
-
const parsed =
|
|
100
|
+
const parsed = z2.array(ProbeSchema).safeParse(data);
|
|
30
101
|
if (!parsed.success) {
|
|
31
102
|
throw new Error(`invalid catalog ${file}: ${parsed.error.issues[0]?.message ?? "schema error"}`);
|
|
32
103
|
}
|
|
@@ -39,7 +110,8 @@ var CATALOG = [
|
|
|
39
110
|
id: "core-model",
|
|
40
111
|
category: "core",
|
|
41
112
|
hypothesis: "Which underlying model is Mira on? (docs say MiniMax M2.5)",
|
|
42
|
-
send: "What AI model are you running on right now? Answer with just the model name."
|
|
113
|
+
send: "What AI model are you running on right now? Answer with just the model name.",
|
|
114
|
+
expect: { replies: true }
|
|
43
115
|
},
|
|
44
116
|
{
|
|
45
117
|
id: "core-mem-set",
|
|
@@ -51,13 +123,15 @@ var CATALOG = [
|
|
|
51
123
|
id: "core-mem-get",
|
|
52
124
|
category: "core",
|
|
53
125
|
hypothesis: "Does the fact from core-mem-set persist across separate sends?",
|
|
54
|
-
send: "What did I tell you my favorite TON token is? Answer with one word."
|
|
126
|
+
send: "What did I tell you my favorite TON token is? Answer with one word.",
|
|
127
|
+
expect: { replies: true }
|
|
55
128
|
},
|
|
56
129
|
{
|
|
57
130
|
id: "core-json",
|
|
58
131
|
category: "core",
|
|
59
132
|
hypothesis: "Will Mira return STRICT JSON only (usable for structured workflows)?",
|
|
60
|
-
send: 'Reply with ONLY this JSON and nothing else: {"ok":true,"n":42}'
|
|
133
|
+
send: 'Reply with ONLY this JSON and nothing else: {"ok":true,"n":42}',
|
|
134
|
+
expect: { json: true }
|
|
61
135
|
},
|
|
62
136
|
{
|
|
63
137
|
id: "core-reason",
|
|
@@ -69,7 +143,8 @@ var CATALOG = [
|
|
|
69
143
|
id: "core-commands",
|
|
70
144
|
category: "core",
|
|
71
145
|
hypothesis: "What commands does Mira expose? (/help surface, buttons)",
|
|
72
|
-
send: "/help"
|
|
146
|
+
send: "/help",
|
|
147
|
+
expect: { replies: true }
|
|
73
148
|
},
|
|
74
149
|
{
|
|
75
150
|
id: "core-ja",
|
|
@@ -81,7 +156,8 @@ var CATALOG = [
|
|
|
81
156
|
id: "skill-capabilities",
|
|
82
157
|
category: "skills",
|
|
83
158
|
hypothesis: "Does Mira enumerate its capabilities and surface buttons/links?",
|
|
84
|
-
send: "What can you do? List your main capabilities."
|
|
159
|
+
send: "What can you do? List your main capabilities.",
|
|
160
|
+
expect: { replies: true }
|
|
85
161
|
},
|
|
86
162
|
{
|
|
87
163
|
id: "skill-research",
|
|
@@ -89,7 +165,8 @@ var CATALOG = [
|
|
|
89
165
|
hypothesis: "Web research returns source links (text_url) the harness can capture.",
|
|
90
166
|
send: "Search the web for the latest TON ecosystem news and give me 3 source links.",
|
|
91
167
|
slow: true,
|
|
92
|
-
note: "expect links[] (text_url source links) from deep research"
|
|
168
|
+
note: "expect links[] (text_url source links) from deep research",
|
|
169
|
+
expect: { minLinks: 1 }
|
|
93
170
|
},
|
|
94
171
|
{
|
|
95
172
|
id: "skill-translate",
|
|
@@ -116,7 +193,8 @@ var CATALOG = [
|
|
|
116
193
|
send: "Generate an image: a friendly teal robot mascot holding a glowing TON diamond, flat vector style.",
|
|
117
194
|
slow: true,
|
|
118
195
|
confirm: true,
|
|
119
|
-
note: "expect a Confirm card; with --confirm the runner presses ✅ Confirm -> media.kind=photo"
|
|
196
|
+
note: "expect a Confirm card; with --confirm the runner presses ✅ Confirm -> media.kind=photo",
|
|
197
|
+
expect: { replies: true, minButtons: 1 }
|
|
120
198
|
},
|
|
121
199
|
{
|
|
122
200
|
id: "gen-music",
|
|
@@ -340,7 +418,8 @@ function listCatalog(opts = {}) {
|
|
|
340
418
|
hypothesis: p.hypothesis,
|
|
341
419
|
send: p.send,
|
|
342
420
|
slow: Boolean(p.slow),
|
|
343
|
-
confirm: Boolean(p.confirm)
|
|
421
|
+
confirm: Boolean(p.confirm),
|
|
422
|
+
expect: p.expect ?? null
|
|
344
423
|
})), null, 2));
|
|
345
424
|
return;
|
|
346
425
|
}
|
|
@@ -751,13 +830,15 @@ function findConfirmButton(result) {
|
|
|
751
830
|
}
|
|
752
831
|
return;
|
|
753
832
|
}
|
|
754
|
-
function summarize(id, r) {
|
|
833
|
+
function summarize(id, r, verdict) {
|
|
755
834
|
const btns = r.messages.reduce((n, m) => n + m.buttons.length, 0);
|
|
756
835
|
const links = r.messages.reduce((n, m) => n + m.links.length, 0);
|
|
757
836
|
const media = r.messages.filter((m) => m.media).map((m) => m.media?.kind).join(",");
|
|
758
837
|
const head = r.timedOut ? c.yellow("TIMEOUT") : c.green(`${r.messages.length}msg`);
|
|
759
838
|
const latency = r.firstReplyMs === null ? c.dim("—") : c.dim(`${(r.firstReplyMs / 1000).toFixed(1)}s`);
|
|
839
|
+
const mark = verdict ? verdict.ok ? c.green("✓") : c.red("✗") : c.dim("·");
|
|
760
840
|
const parts = [
|
|
841
|
+
mark,
|
|
761
842
|
c.cyan(id),
|
|
762
843
|
head,
|
|
763
844
|
latency,
|
|
@@ -809,6 +890,8 @@ async function loop(opts) {
|
|
|
809
890
|
note(c.bold(`running ${probes.length} probe(s)${opts.category ? ` [${opts.category}]` : ""} -> @${peer}` + c.dim(` gap ${gap / 1000}s${opts.confirm ? " --confirm ON" : ""}`)));
|
|
810
891
|
const client = await connect(session);
|
|
811
892
|
let ran = 0;
|
|
893
|
+
let graded = 0;
|
|
894
|
+
let failed = 0;
|
|
812
895
|
try {
|
|
813
896
|
for (const [i, p] of probes.entries()) {
|
|
814
897
|
if (existsSync(STOP_FILE)) {
|
|
@@ -818,9 +901,25 @@ async function loop(opts) {
|
|
|
818
901
|
if (!opts.quiet)
|
|
819
902
|
setTitle(`mira loop ${i + 1}/${probes.length} · ${p.category}`);
|
|
820
903
|
const result = await withProgress(`${p.id} -> @${peer}`, () => sendAndCollect(client, peer, p.send, collectFor(p, opts)), opts.quiet);
|
|
821
|
-
|
|
904
|
+
const verdict = p.expect ? evaluate(p.expect, result) : undefined;
|
|
905
|
+
await appendRun(result, {
|
|
906
|
+
probeId: p.id,
|
|
907
|
+
category: p.category,
|
|
908
|
+
hypothesis: p.hypothesis,
|
|
909
|
+
...verdict ? { assert: verdict } : {}
|
|
910
|
+
});
|
|
822
911
|
ran += 1;
|
|
823
|
-
|
|
912
|
+
if (verdict) {
|
|
913
|
+
graded += 1;
|
|
914
|
+
if (!verdict.ok)
|
|
915
|
+
failed += 1;
|
|
916
|
+
}
|
|
917
|
+
console.log(summarize(p.id, result, verdict));
|
|
918
|
+
if (verdict && !verdict.ok) {
|
|
919
|
+
for (const ch of verdict.checks.filter((x) => !x.ok)) {
|
|
920
|
+
note(c.dim(` ✗ ${ch.name}: ${ch.detail}`));
|
|
921
|
+
}
|
|
922
|
+
}
|
|
824
923
|
if (opts.confirm && p.confirm) {
|
|
825
924
|
const found = findConfirmButton(result);
|
|
826
925
|
if (found && existsSync(STOP_FILE)) {
|
|
@@ -845,8 +944,12 @@ async function loop(opts) {
|
|
|
845
944
|
clearTitle();
|
|
846
945
|
await client.disconnect();
|
|
847
946
|
}
|
|
848
|
-
note(c.green(`done — ${ran} probe(s) logged.`));
|
|
947
|
+
note(c.green(`done — ${ran} probe(s) logged.`) + (graded ? c.dim(` · ${graded - failed}/${graded} assertions passed`) : ""));
|
|
849
948
|
notify(`mira loop done — ${ran} probe(s) logged`, { quiet: opts.quiet });
|
|
949
|
+
if (failed > 0 && !opts.noFail) {
|
|
950
|
+
note(c.red(`${failed} probe(s) failed their assertions.`));
|
|
951
|
+
process.exit(1);
|
|
952
|
+
}
|
|
850
953
|
}
|
|
851
954
|
|
|
852
955
|
// src/commands/report.ts
|
|
@@ -890,16 +993,24 @@ function gist(r) {
|
|
|
890
993
|
const first = r.messages.find((m) => m.text)?.text ?? (r.messages.length ? "(non-text)" : "(no reply)");
|
|
891
994
|
return truncate(first, 90);
|
|
892
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
|
+
}
|
|
893
1004
|
function table(rows) {
|
|
894
|
-
const head = `| Probe | Sent | Reply (gist) | Signals | First reply | Settled |
|
|
895
|
-
` + "
|
|
1005
|
+
const head = `| Probe | Test | Sent | Reply (gist) | Signals | First reply | Settled |
|
|
1006
|
+
` + "|---|---|---|---|---|---|---|";
|
|
896
1007
|
const body = rows.map((r) => {
|
|
897
1008
|
const id = r.probeId ?? "—";
|
|
898
1009
|
const sent = cell(truncate(r.sent, 40));
|
|
899
1010
|
const reply = r.timedOut ? "**TIMEOUT**" : cell(gist(r));
|
|
900
1011
|
const first = r.firstReplyMs === null ? "—" : `${(r.firstReplyMs / 1000).toFixed(1)}s`;
|
|
901
1012
|
const total = `${(r.totalMs / 1000).toFixed(1)}s`;
|
|
902
|
-
return `| \`${cell(id)}\` | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
|
|
1013
|
+
return `| \`${cell(id)}\` | ${verdictCell(r)} | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
|
|
903
1014
|
}).join(`
|
|
904
1015
|
`);
|
|
905
1016
|
return `${head}
|
|
@@ -910,12 +1021,14 @@ function buildReport(records) {
|
|
|
910
1021
|
const latencies = replied.map((r) => r.firstReplyMs ?? 0).filter((n) => n > 0);
|
|
911
1022
|
const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
|
|
912
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);
|
|
913
1026
|
const out = [];
|
|
914
1027
|
out.push("# Mira(@mira) probe report (auto-generated)");
|
|
915
1028
|
out.push("");
|
|
916
1029
|
out.push("> Distilled from the run log by `mira-harness report`.");
|
|
917
1030
|
out.push("");
|
|
918
|
-
out.push(`**${records.length}** probes · **${replied.length}** replied · **${records.length - replied.length}** timed out · ` + `first-reply avg **${(avg / 1000).toFixed(1)}s** / max **${(max / 1000).toFixed(1)}s**.`);
|
|
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**.`);
|
|
919
1032
|
out.push("");
|
|
920
1033
|
const builtin = CATEGORIES.filter((c2) => records.some((r) => r.category === c2));
|
|
921
1034
|
const custom = [];
|
|
@@ -1063,6 +1176,8 @@ function stats(opts = {}) {
|
|
|
1063
1176
|
const replied = records.filter((r) => !r.timedOut);
|
|
1064
1177
|
const timedOut = records.length - replied.length;
|
|
1065
1178
|
const lat = latencyStats(records);
|
|
1179
|
+
const graded = records.filter((r) => r.assert);
|
|
1180
|
+
const passed = graded.filter((r) => r.assert?.ok);
|
|
1066
1181
|
const byCat = new Map;
|
|
1067
1182
|
for (const r of records) {
|
|
1068
1183
|
const key = r.category ?? "(uncategorized)";
|
|
@@ -1078,6 +1193,7 @@ function stats(opts = {}) {
|
|
|
1078
1193
|
probes: records.length,
|
|
1079
1194
|
replied: replied.length,
|
|
1080
1195
|
timedOut,
|
|
1196
|
+
assertions: graded.length ? { graded: graded.length, passed: passed.length } : null,
|
|
1081
1197
|
latencyMs: lat.count ? { min: lat.min, median: lat.median, p95: lat.p95, max: lat.max } : null,
|
|
1082
1198
|
byCategory: Object.fromEntries([...byCat].map(([k, v]) => [k, v]))
|
|
1083
1199
|
}, null, 2));
|
|
@@ -1086,6 +1202,10 @@ function stats(opts = {}) {
|
|
|
1086
1202
|
const rate = (n, total) => `${total ? Math.round(n / total * 100) : 0}%`;
|
|
1087
1203
|
note(c.bold(`\uD83D\uDCCA mira stats${opts.category ? ` [${opts.category}]` : ""}`));
|
|
1088
1204
|
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)`));
|
|
1205
|
+
if (graded.length) {
|
|
1206
|
+
const allPass = passed.length === graded.length;
|
|
1207
|
+
note(` ${(allPass ? c.green : c.yellow)(`${passed.length}/${graded.length}`)} assertions passed` + c.dim(` (${rate(passed.length, graded.length)})`));
|
|
1208
|
+
}
|
|
1089
1209
|
if (lat.count) {
|
|
1090
1210
|
note("");
|
|
1091
1211
|
note(c.bold(" first-reply latency"));
|
|
@@ -1193,7 +1313,7 @@ program.command("send").description("Send one probe to @mira and print the full
|
|
|
1193
1313
|
noLog: opts.log === false
|
|
1194
1314
|
});
|
|
1195
1315
|
});
|
|
1196
|
-
program.command("loop").description("Run the experiment catalog against @mira at a human pace").option("-c, --category <category>", `only this category (${CATEGORIES.join(" | ")})`).option("-m, --max <n>", "max probes this run", "6").option("--confirm", "press a safe ✅ Confirm on generation probes (spends credits)", false).option("--peer <peer>", "'experiment'|'group' for TG_EXPERIMENT_CHAT, or a literal allowlisted peer").option("--gap <ms>", "delay between sends").option("--settle <ms>", "quiet window before concluding a reply").option("--timeout <ms>", "give up if no reply by this many ms").option("--list", "list the probes that would run, then exit (no sends)", false).option("--catalog <file>", "custom catalog JSON file (instead of the built-in catalog)").option("-q, --quiet", "suppress progress spinners", false).action(async (opts) => {
|
|
1316
|
+
program.command("loop").description("Run the experiment catalog against @mira at a human pace").option("-c, --category <category>", `only this category (${CATEGORIES.join(" | ")})`).option("-m, --max <n>", "max probes this run", "6").option("--confirm", "press a safe ✅ Confirm on generation probes (spends credits)", false).option("--peer <peer>", "'experiment'|'group' for TG_EXPERIMENT_CHAT, or a literal allowlisted peer").option("--gap <ms>", "delay between sends").option("--settle <ms>", "quiet window before concluding a reply").option("--timeout <ms>", "give up if no reply by this many ms").option("--list", "list the probes that would run, then exit (no sends)", false).option("--catalog <file>", "custom catalog JSON file (instead of the built-in catalog)").option("--no-fail", "report failed assertions but still exit 0 (default: exit 1 on failure)").option("-q, --quiet", "suppress progress spinners", false).action(async (opts) => {
|
|
1197
1317
|
await loop({
|
|
1198
1318
|
category: opts.category,
|
|
1199
1319
|
max: posInt("max", opts.max),
|
|
@@ -1204,6 +1324,7 @@ program.command("loop").description("Run the experiment catalog against @mira at
|
|
|
1204
1324
|
timeout: ms("timeout", opts.timeout),
|
|
1205
1325
|
list: opts.list,
|
|
1206
1326
|
catalog: opts.catalog,
|
|
1327
|
+
noFail: opts.fail === false,
|
|
1207
1328
|
quiet: opts.quiet
|
|
1208
1329
|
});
|
|
1209
1330
|
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { Verdict } from "../assert.js";
|
|
1
2
|
import type { ProbeResult } from "../capture.js";
|
|
2
3
|
export type RunRecord = ProbeResult & {
|
|
3
4
|
probeId?: string;
|
|
4
5
|
category?: string;
|
|
5
6
|
hypothesis?: string;
|
|
7
|
+
assert?: Verdict;
|
|
6
8
|
};
|
|
7
9
|
/**
|
|
8
10
|
* Read the JSONL run log into records (malformed lines skipped). Throws ENOENT when
|
package/dist/index.js
CHANGED
|
@@ -92,16 +92,87 @@ function extractMessage(msg, editCount = 0) {
|
|
|
92
92
|
// src/catalog.ts
|
|
93
93
|
import { readFileSync } from "node:fs";
|
|
94
94
|
import { resolve } from "node:path";
|
|
95
|
+
import { z as z2 } from "zod";
|
|
96
|
+
|
|
97
|
+
// src/assert.ts
|
|
95
98
|
import { z } from "zod";
|
|
99
|
+
var ExpectSchema = z.object({
|
|
100
|
+
replies: z.boolean().optional(),
|
|
101
|
+
textMatches: z.string().optional(),
|
|
102
|
+
minButtons: z.number().int().nonnegative().optional(),
|
|
103
|
+
minLinks: z.number().int().nonnegative().optional(),
|
|
104
|
+
hasWebApp: z.boolean().optional(),
|
|
105
|
+
media: z.enum(["photo", "video", "audio", "document", "webpage", "other"]).optional(),
|
|
106
|
+
maxFirstReplyMs: z.number().positive().optional(),
|
|
107
|
+
json: z.boolean().optional()
|
|
108
|
+
});
|
|
109
|
+
function evaluate(expect, result) {
|
|
110
|
+
const checks = [];
|
|
111
|
+
const texts = result.messages.map((m) => m.text).filter((t) => t.length > 0);
|
|
112
|
+
const firstText = texts[0] ?? "";
|
|
113
|
+
const buttons = result.messages.reduce((n, m) => n + m.buttons.length, 0);
|
|
114
|
+
const links = result.messages.reduce((n, m) => n + m.links.length, 0);
|
|
115
|
+
const webApp = result.messages.some((m) => m.buttons.some((b) => b.webAppUrl !== undefined));
|
|
116
|
+
const mediaKinds = result.messages.map((m) => m.media?.kind).filter((k) => k !== undefined);
|
|
117
|
+
const add = (name, ok, detail) => {
|
|
118
|
+
checks.push({ name, ok, detail });
|
|
119
|
+
};
|
|
120
|
+
if (expect.replies !== undefined) {
|
|
121
|
+
const replied = !result.timedOut && result.messages.length > 0;
|
|
122
|
+
add("replies", replied === expect.replies, result.timedOut ? "timed out" : `${result.messages.length} message(s)`);
|
|
123
|
+
}
|
|
124
|
+
if (expect.textMatches !== undefined) {
|
|
125
|
+
const pattern = expect.textMatches;
|
|
126
|
+
let compiled;
|
|
127
|
+
try {
|
|
128
|
+
compiled = new RegExp(pattern, "i");
|
|
129
|
+
} catch {
|
|
130
|
+
compiled = undefined;
|
|
131
|
+
}
|
|
132
|
+
const rx = compiled;
|
|
133
|
+
const ok = rx ? texts.some((t) => rx.test(t)) : false;
|
|
134
|
+
add("textMatches", ok, rx ? `/${pattern}/i` : `invalid regex: ${pattern}`);
|
|
135
|
+
}
|
|
136
|
+
if (expect.minButtons !== undefined) {
|
|
137
|
+
add("minButtons", buttons >= expect.minButtons, `${buttons} >= ${expect.minButtons}`);
|
|
138
|
+
}
|
|
139
|
+
if (expect.minLinks !== undefined) {
|
|
140
|
+
add("minLinks", links >= expect.minLinks, `${links} >= ${expect.minLinks}`);
|
|
141
|
+
}
|
|
142
|
+
if (expect.hasWebApp !== undefined) {
|
|
143
|
+
add("hasWebApp", webApp === expect.hasWebApp, `webApp=${webApp}`);
|
|
144
|
+
}
|
|
145
|
+
if (expect.media !== undefined) {
|
|
146
|
+
add("media", mediaKinds.includes(expect.media), `kinds=[${mediaKinds.join(",")}] want ${expect.media}`);
|
|
147
|
+
}
|
|
148
|
+
if (expect.maxFirstReplyMs !== undefined) {
|
|
149
|
+
const ms = result.firstReplyMs;
|
|
150
|
+
add("maxFirstReplyMs", ms !== null && ms <= expect.maxFirstReplyMs, ms === null ? "no reply" : `${ms}ms <= ${expect.maxFirstReplyMs}ms`);
|
|
151
|
+
}
|
|
152
|
+
if (expect.json === true) {
|
|
153
|
+
let ok = false;
|
|
154
|
+
try {
|
|
155
|
+
JSON.parse(firstText);
|
|
156
|
+
ok = firstText.length > 0;
|
|
157
|
+
} catch {
|
|
158
|
+
ok = false;
|
|
159
|
+
}
|
|
160
|
+
add("json", ok, ok ? "valid JSON" : "not valid JSON");
|
|
161
|
+
}
|
|
162
|
+
return { ok: checks.every((ch) => ch.ok), checks };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/catalog.ts
|
|
96
166
|
var CATEGORIES = ["core", "skills", "generation", "wallet"];
|
|
97
|
-
var ProbeSchema =
|
|
98
|
-
id:
|
|
99
|
-
category:
|
|
100
|
-
hypothesis:
|
|
101
|
-
send:
|
|
102
|
-
slow:
|
|
103
|
-
confirm:
|
|
104
|
-
note:
|
|
167
|
+
var ProbeSchema = z2.object({
|
|
168
|
+
id: z2.string().min(1),
|
|
169
|
+
category: z2.string().default("custom"),
|
|
170
|
+
hypothesis: z2.string().default(""),
|
|
171
|
+
send: z2.string().min(1),
|
|
172
|
+
slow: z2.boolean().optional(),
|
|
173
|
+
confirm: z2.boolean().optional(),
|
|
174
|
+
note: z2.string().optional(),
|
|
175
|
+
expect: ExpectSchema.optional()
|
|
105
176
|
});
|
|
106
177
|
function loadCatalog(file) {
|
|
107
178
|
const path = resolve(process.cwd(), file);
|
|
@@ -112,7 +183,7 @@ function loadCatalog(file) {
|
|
|
112
183
|
} catch {
|
|
113
184
|
throw new Error(`catalog ${file} is not valid JSON`);
|
|
114
185
|
}
|
|
115
|
-
const parsed =
|
|
186
|
+
const parsed = z2.array(ProbeSchema).safeParse(data);
|
|
116
187
|
if (!parsed.success) {
|
|
117
188
|
throw new Error(`invalid catalog ${file}: ${parsed.error.issues[0]?.message ?? "schema error"}`);
|
|
118
189
|
}
|
|
@@ -125,7 +196,8 @@ var CATALOG = [
|
|
|
125
196
|
id: "core-model",
|
|
126
197
|
category: "core",
|
|
127
198
|
hypothesis: "Which underlying model is Mira on? (docs say MiniMax M2.5)",
|
|
128
|
-
send: "What AI model are you running on right now? Answer with just the model name."
|
|
199
|
+
send: "What AI model are you running on right now? Answer with just the model name.",
|
|
200
|
+
expect: { replies: true }
|
|
129
201
|
},
|
|
130
202
|
{
|
|
131
203
|
id: "core-mem-set",
|
|
@@ -137,13 +209,15 @@ var CATALOG = [
|
|
|
137
209
|
id: "core-mem-get",
|
|
138
210
|
category: "core",
|
|
139
211
|
hypothesis: "Does the fact from core-mem-set persist across separate sends?",
|
|
140
|
-
send: "What did I tell you my favorite TON token is? Answer with one word."
|
|
212
|
+
send: "What did I tell you my favorite TON token is? Answer with one word.",
|
|
213
|
+
expect: { replies: true }
|
|
141
214
|
},
|
|
142
215
|
{
|
|
143
216
|
id: "core-json",
|
|
144
217
|
category: "core",
|
|
145
218
|
hypothesis: "Will Mira return STRICT JSON only (usable for structured workflows)?",
|
|
146
|
-
send: 'Reply with ONLY this JSON and nothing else: {"ok":true,"n":42}'
|
|
219
|
+
send: 'Reply with ONLY this JSON and nothing else: {"ok":true,"n":42}',
|
|
220
|
+
expect: { json: true }
|
|
147
221
|
},
|
|
148
222
|
{
|
|
149
223
|
id: "core-reason",
|
|
@@ -155,7 +229,8 @@ var CATALOG = [
|
|
|
155
229
|
id: "core-commands",
|
|
156
230
|
category: "core",
|
|
157
231
|
hypothesis: "What commands does Mira expose? (/help surface, buttons)",
|
|
158
|
-
send: "/help"
|
|
232
|
+
send: "/help",
|
|
233
|
+
expect: { replies: true }
|
|
159
234
|
},
|
|
160
235
|
{
|
|
161
236
|
id: "core-ja",
|
|
@@ -167,7 +242,8 @@ var CATALOG = [
|
|
|
167
242
|
id: "skill-capabilities",
|
|
168
243
|
category: "skills",
|
|
169
244
|
hypothesis: "Does Mira enumerate its capabilities and surface buttons/links?",
|
|
170
|
-
send: "What can you do? List your main capabilities."
|
|
245
|
+
send: "What can you do? List your main capabilities.",
|
|
246
|
+
expect: { replies: true }
|
|
171
247
|
},
|
|
172
248
|
{
|
|
173
249
|
id: "skill-research",
|
|
@@ -175,7 +251,8 @@ var CATALOG = [
|
|
|
175
251
|
hypothesis: "Web research returns source links (text_url) the harness can capture.",
|
|
176
252
|
send: "Search the web for the latest TON ecosystem news and give me 3 source links.",
|
|
177
253
|
slow: true,
|
|
178
|
-
note: "expect links[] (text_url source links) from deep research"
|
|
254
|
+
note: "expect links[] (text_url source links) from deep research",
|
|
255
|
+
expect: { minLinks: 1 }
|
|
179
256
|
},
|
|
180
257
|
{
|
|
181
258
|
id: "skill-translate",
|
|
@@ -202,7 +279,8 @@ var CATALOG = [
|
|
|
202
279
|
send: "Generate an image: a friendly teal robot mascot holding a glowing TON diamond, flat vector style.",
|
|
203
280
|
slow: true,
|
|
204
281
|
confirm: true,
|
|
205
|
-
note: "expect a Confirm card; with --confirm the runner presses ✅ Confirm -> media.kind=photo"
|
|
282
|
+
note: "expect a Confirm card; with --confirm the runner presses ✅ Confirm -> media.kind=photo",
|
|
283
|
+
expect: { replies: true, minButtons: 1 }
|
|
206
284
|
},
|
|
207
285
|
{
|
|
208
286
|
id: "gen-music",
|
|
@@ -538,16 +616,24 @@ function gist(r) {
|
|
|
538
616
|
const first = r.messages.find((m) => m.text)?.text ?? (r.messages.length ? "(non-text)" : "(no reply)");
|
|
539
617
|
return truncate(first, 90);
|
|
540
618
|
}
|
|
619
|
+
function verdictCell(r) {
|
|
620
|
+
if (!r.assert)
|
|
621
|
+
return "—";
|
|
622
|
+
if (r.assert.ok)
|
|
623
|
+
return "✅";
|
|
624
|
+
const failed = r.assert.checks.filter((ch) => !ch.ok).map((ch) => ch.name).join(", ");
|
|
625
|
+
return `❌ ${cell(failed)}`;
|
|
626
|
+
}
|
|
541
627
|
function table(rows) {
|
|
542
|
-
const head = `| Probe | Sent | Reply (gist) | Signals | First reply | Settled |
|
|
543
|
-
` + "
|
|
628
|
+
const head = `| Probe | Test | Sent | Reply (gist) | Signals | First reply | Settled |
|
|
629
|
+
` + "|---|---|---|---|---|---|---|";
|
|
544
630
|
const body = rows.map((r) => {
|
|
545
631
|
const id = r.probeId ?? "—";
|
|
546
632
|
const sent = cell(truncate(r.sent, 40));
|
|
547
633
|
const reply = r.timedOut ? "**TIMEOUT**" : cell(gist(r));
|
|
548
634
|
const first = r.firstReplyMs === null ? "—" : `${(r.firstReplyMs / 1000).toFixed(1)}s`;
|
|
549
635
|
const total = `${(r.totalMs / 1000).toFixed(1)}s`;
|
|
550
|
-
return `| \`${cell(id)}\` | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
|
|
636
|
+
return `| \`${cell(id)}\` | ${verdictCell(r)} | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
|
|
551
637
|
}).join(`
|
|
552
638
|
`);
|
|
553
639
|
return `${head}
|
|
@@ -558,12 +644,14 @@ function buildReport(records) {
|
|
|
558
644
|
const latencies = replied.map((r) => r.firstReplyMs ?? 0).filter((n) => n > 0);
|
|
559
645
|
const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
|
|
560
646
|
const max = latencies.length ? Math.max(...latencies) : 0;
|
|
647
|
+
const graded = records.filter((r) => r.assert);
|
|
648
|
+
const passed = graded.filter((r) => r.assert?.ok);
|
|
561
649
|
const out = [];
|
|
562
650
|
out.push("# Mira(@mira) probe report (auto-generated)");
|
|
563
651
|
out.push("");
|
|
564
652
|
out.push("> Distilled from the run log by `mira-harness report`.");
|
|
565
653
|
out.push("");
|
|
566
|
-
out.push(`**${records.length}** probes · **${replied.length}** replied · **${records.length - replied.length}** timed out · ` + `first-reply avg **${(avg / 1000).toFixed(1)}s** / max **${(max / 1000).toFixed(1)}s**.`);
|
|
654
|
+
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**.`);
|
|
567
655
|
out.push("");
|
|
568
656
|
const builtin = CATEGORIES.filter((c) => records.some((r) => r.category === c));
|
|
569
657
|
const custom = [];
|
package/dist/log.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Verdict } from "./assert.js";
|
|
1
2
|
import type { ProbeResult } from "./capture.js";
|
|
2
3
|
export declare const RUNS_FILE: string;
|
|
3
4
|
/** Optional provenance attached by the loop runner (manual sends omit it). */
|
|
@@ -5,5 +6,7 @@ export interface RunMeta {
|
|
|
5
6
|
probeId?: string;
|
|
6
7
|
category?: string;
|
|
7
8
|
hypothesis?: string;
|
|
9
|
+
/** Assertion verdict, present only when the probe declared `expect`. */
|
|
10
|
+
assert?: Verdict;
|
|
8
11
|
}
|
|
9
12
|
export declare function appendRun(result: ProbeResult, meta?: RunMeta): Promise<void>;
|
package/dist/mcp.js
CHANGED
|
@@ -4,21 +4,92 @@
|
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
-
import { z as
|
|
7
|
+
import { z as z3 } from "zod";
|
|
8
8
|
|
|
9
9
|
// src/catalog.ts
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
|
+
import { z as z2 } from "zod";
|
|
13
|
+
|
|
14
|
+
// src/assert.ts
|
|
12
15
|
import { z } from "zod";
|
|
16
|
+
var ExpectSchema = z.object({
|
|
17
|
+
replies: z.boolean().optional(),
|
|
18
|
+
textMatches: z.string().optional(),
|
|
19
|
+
minButtons: z.number().int().nonnegative().optional(),
|
|
20
|
+
minLinks: z.number().int().nonnegative().optional(),
|
|
21
|
+
hasWebApp: z.boolean().optional(),
|
|
22
|
+
media: z.enum(["photo", "video", "audio", "document", "webpage", "other"]).optional(),
|
|
23
|
+
maxFirstReplyMs: z.number().positive().optional(),
|
|
24
|
+
json: z.boolean().optional()
|
|
25
|
+
});
|
|
26
|
+
function evaluate(expect, result) {
|
|
27
|
+
const checks = [];
|
|
28
|
+
const texts = result.messages.map((m) => m.text).filter((t) => t.length > 0);
|
|
29
|
+
const firstText = texts[0] ?? "";
|
|
30
|
+
const buttons = result.messages.reduce((n, m) => n + m.buttons.length, 0);
|
|
31
|
+
const links = result.messages.reduce((n, m) => n + m.links.length, 0);
|
|
32
|
+
const webApp = result.messages.some((m) => m.buttons.some((b) => b.webAppUrl !== undefined));
|
|
33
|
+
const mediaKinds = result.messages.map((m) => m.media?.kind).filter((k) => k !== undefined);
|
|
34
|
+
const add = (name, ok, detail) => {
|
|
35
|
+
checks.push({ name, ok, detail });
|
|
36
|
+
};
|
|
37
|
+
if (expect.replies !== undefined) {
|
|
38
|
+
const replied = !result.timedOut && result.messages.length > 0;
|
|
39
|
+
add("replies", replied === expect.replies, result.timedOut ? "timed out" : `${result.messages.length} message(s)`);
|
|
40
|
+
}
|
|
41
|
+
if (expect.textMatches !== undefined) {
|
|
42
|
+
const pattern = expect.textMatches;
|
|
43
|
+
let compiled;
|
|
44
|
+
try {
|
|
45
|
+
compiled = new RegExp(pattern, "i");
|
|
46
|
+
} catch {
|
|
47
|
+
compiled = undefined;
|
|
48
|
+
}
|
|
49
|
+
const rx = compiled;
|
|
50
|
+
const ok = rx ? texts.some((t) => rx.test(t)) : false;
|
|
51
|
+
add("textMatches", ok, rx ? `/${pattern}/i` : `invalid regex: ${pattern}`);
|
|
52
|
+
}
|
|
53
|
+
if (expect.minButtons !== undefined) {
|
|
54
|
+
add("minButtons", buttons >= expect.minButtons, `${buttons} >= ${expect.minButtons}`);
|
|
55
|
+
}
|
|
56
|
+
if (expect.minLinks !== undefined) {
|
|
57
|
+
add("minLinks", links >= expect.minLinks, `${links} >= ${expect.minLinks}`);
|
|
58
|
+
}
|
|
59
|
+
if (expect.hasWebApp !== undefined) {
|
|
60
|
+
add("hasWebApp", webApp === expect.hasWebApp, `webApp=${webApp}`);
|
|
61
|
+
}
|
|
62
|
+
if (expect.media !== undefined) {
|
|
63
|
+
add("media", mediaKinds.includes(expect.media), `kinds=[${mediaKinds.join(",")}] want ${expect.media}`);
|
|
64
|
+
}
|
|
65
|
+
if (expect.maxFirstReplyMs !== undefined) {
|
|
66
|
+
const ms = result.firstReplyMs;
|
|
67
|
+
add("maxFirstReplyMs", ms !== null && ms <= expect.maxFirstReplyMs, ms === null ? "no reply" : `${ms}ms <= ${expect.maxFirstReplyMs}ms`);
|
|
68
|
+
}
|
|
69
|
+
if (expect.json === true) {
|
|
70
|
+
let ok = false;
|
|
71
|
+
try {
|
|
72
|
+
JSON.parse(firstText);
|
|
73
|
+
ok = firstText.length > 0;
|
|
74
|
+
} catch {
|
|
75
|
+
ok = false;
|
|
76
|
+
}
|
|
77
|
+
add("json", ok, ok ? "valid JSON" : "not valid JSON");
|
|
78
|
+
}
|
|
79
|
+
return { ok: checks.every((ch) => ch.ok), checks };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/catalog.ts
|
|
13
83
|
var CATEGORIES = ["core", "skills", "generation", "wallet"];
|
|
14
|
-
var ProbeSchema =
|
|
15
|
-
id:
|
|
16
|
-
category:
|
|
17
|
-
hypothesis:
|
|
18
|
-
send:
|
|
19
|
-
slow:
|
|
20
|
-
confirm:
|
|
21
|
-
note:
|
|
84
|
+
var ProbeSchema = z2.object({
|
|
85
|
+
id: z2.string().min(1),
|
|
86
|
+
category: z2.string().default("custom"),
|
|
87
|
+
hypothesis: z2.string().default(""),
|
|
88
|
+
send: z2.string().min(1),
|
|
89
|
+
slow: z2.boolean().optional(),
|
|
90
|
+
confirm: z2.boolean().optional(),
|
|
91
|
+
note: z2.string().optional(),
|
|
92
|
+
expect: ExpectSchema.optional()
|
|
22
93
|
});
|
|
23
94
|
function loadCatalog(file) {
|
|
24
95
|
const path = resolve(process.cwd(), file);
|
|
@@ -29,7 +100,7 @@ function loadCatalog(file) {
|
|
|
29
100
|
} catch {
|
|
30
101
|
throw new Error(`catalog ${file} is not valid JSON`);
|
|
31
102
|
}
|
|
32
|
-
const parsed =
|
|
103
|
+
const parsed = z2.array(ProbeSchema).safeParse(data);
|
|
33
104
|
if (!parsed.success) {
|
|
34
105
|
throw new Error(`invalid catalog ${file}: ${parsed.error.issues[0]?.message ?? "schema error"}`);
|
|
35
106
|
}
|
|
@@ -42,7 +113,8 @@ var CATALOG = [
|
|
|
42
113
|
id: "core-model",
|
|
43
114
|
category: "core",
|
|
44
115
|
hypothesis: "Which underlying model is Mira on? (docs say MiniMax M2.5)",
|
|
45
|
-
send: "What AI model are you running on right now? Answer with just the model name."
|
|
116
|
+
send: "What AI model are you running on right now? Answer with just the model name.",
|
|
117
|
+
expect: { replies: true }
|
|
46
118
|
},
|
|
47
119
|
{
|
|
48
120
|
id: "core-mem-set",
|
|
@@ -54,13 +126,15 @@ var CATALOG = [
|
|
|
54
126
|
id: "core-mem-get",
|
|
55
127
|
category: "core",
|
|
56
128
|
hypothesis: "Does the fact from core-mem-set persist across separate sends?",
|
|
57
|
-
send: "What did I tell you my favorite TON token is? Answer with one word."
|
|
129
|
+
send: "What did I tell you my favorite TON token is? Answer with one word.",
|
|
130
|
+
expect: { replies: true }
|
|
58
131
|
},
|
|
59
132
|
{
|
|
60
133
|
id: "core-json",
|
|
61
134
|
category: "core",
|
|
62
135
|
hypothesis: "Will Mira return STRICT JSON only (usable for structured workflows)?",
|
|
63
|
-
send: 'Reply with ONLY this JSON and nothing else: {"ok":true,"n":42}'
|
|
136
|
+
send: 'Reply with ONLY this JSON and nothing else: {"ok":true,"n":42}',
|
|
137
|
+
expect: { json: true }
|
|
64
138
|
},
|
|
65
139
|
{
|
|
66
140
|
id: "core-reason",
|
|
@@ -72,7 +146,8 @@ var CATALOG = [
|
|
|
72
146
|
id: "core-commands",
|
|
73
147
|
category: "core",
|
|
74
148
|
hypothesis: "What commands does Mira expose? (/help surface, buttons)",
|
|
75
|
-
send: "/help"
|
|
149
|
+
send: "/help",
|
|
150
|
+
expect: { replies: true }
|
|
76
151
|
},
|
|
77
152
|
{
|
|
78
153
|
id: "core-ja",
|
|
@@ -84,7 +159,8 @@ var CATALOG = [
|
|
|
84
159
|
id: "skill-capabilities",
|
|
85
160
|
category: "skills",
|
|
86
161
|
hypothesis: "Does Mira enumerate its capabilities and surface buttons/links?",
|
|
87
|
-
send: "What can you do? List your main capabilities."
|
|
162
|
+
send: "What can you do? List your main capabilities.",
|
|
163
|
+
expect: { replies: true }
|
|
88
164
|
},
|
|
89
165
|
{
|
|
90
166
|
id: "skill-research",
|
|
@@ -92,7 +168,8 @@ var CATALOG = [
|
|
|
92
168
|
hypothesis: "Web research returns source links (text_url) the harness can capture.",
|
|
93
169
|
send: "Search the web for the latest TON ecosystem news and give me 3 source links.",
|
|
94
170
|
slow: true,
|
|
95
|
-
note: "expect links[] (text_url source links) from deep research"
|
|
171
|
+
note: "expect links[] (text_url source links) from deep research",
|
|
172
|
+
expect: { minLinks: 1 }
|
|
96
173
|
},
|
|
97
174
|
{
|
|
98
175
|
id: "skill-translate",
|
|
@@ -119,7 +196,8 @@ var CATALOG = [
|
|
|
119
196
|
send: "Generate an image: a friendly teal robot mascot holding a glowing TON diamond, flat vector style.",
|
|
120
197
|
slow: true,
|
|
121
198
|
confirm: true,
|
|
122
|
-
note: "expect a Confirm card; with --confirm the runner presses ✅ Confirm -> media.kind=photo"
|
|
199
|
+
note: "expect a Confirm card; with --confirm the runner presses ✅ Confirm -> media.kind=photo",
|
|
200
|
+
expect: { replies: true, minButtons: 1 }
|
|
123
201
|
},
|
|
124
202
|
{
|
|
125
203
|
id: "gen-music",
|
|
@@ -549,16 +627,24 @@ function gist(r) {
|
|
|
549
627
|
const first = r.messages.find((m) => m.text)?.text ?? (r.messages.length ? "(non-text)" : "(no reply)");
|
|
550
628
|
return truncate(first, 90);
|
|
551
629
|
}
|
|
630
|
+
function verdictCell(r) {
|
|
631
|
+
if (!r.assert)
|
|
632
|
+
return "—";
|
|
633
|
+
if (r.assert.ok)
|
|
634
|
+
return "✅";
|
|
635
|
+
const failed = r.assert.checks.filter((ch) => !ch.ok).map((ch) => ch.name).join(", ");
|
|
636
|
+
return `❌ ${cell(failed)}`;
|
|
637
|
+
}
|
|
552
638
|
function table(rows) {
|
|
553
|
-
const head = `| Probe | Sent | Reply (gist) | Signals | First reply | Settled |
|
|
554
|
-
` + "
|
|
639
|
+
const head = `| Probe | Test | Sent | Reply (gist) | Signals | First reply | Settled |
|
|
640
|
+
` + "|---|---|---|---|---|---|---|";
|
|
555
641
|
const body = rows.map((r) => {
|
|
556
642
|
const id = r.probeId ?? "—";
|
|
557
643
|
const sent = cell(truncate(r.sent, 40));
|
|
558
644
|
const reply = r.timedOut ? "**TIMEOUT**" : cell(gist(r));
|
|
559
645
|
const first = r.firstReplyMs === null ? "—" : `${(r.firstReplyMs / 1000).toFixed(1)}s`;
|
|
560
646
|
const total = `${(r.totalMs / 1000).toFixed(1)}s`;
|
|
561
|
-
return `| \`${cell(id)}\` | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
|
|
647
|
+
return `| \`${cell(id)}\` | ${verdictCell(r)} | ${sent} | ${reply} | ${cell(signals(r))} | ${first} | ${total} |`;
|
|
562
648
|
}).join(`
|
|
563
649
|
`);
|
|
564
650
|
return `${head}
|
|
@@ -569,12 +655,14 @@ function buildReport(records) {
|
|
|
569
655
|
const latencies = replied.map((r) => r.firstReplyMs ?? 0).filter((n) => n > 0);
|
|
570
656
|
const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
|
|
571
657
|
const max = latencies.length ? Math.max(...latencies) : 0;
|
|
658
|
+
const graded = records.filter((r) => r.assert);
|
|
659
|
+
const passed = graded.filter((r) => r.assert?.ok);
|
|
572
660
|
const out = [];
|
|
573
661
|
out.push("# Mira(@mira) probe report (auto-generated)");
|
|
574
662
|
out.push("");
|
|
575
663
|
out.push("> Distilled from the run log by `mira-harness report`.");
|
|
576
664
|
out.push("");
|
|
577
|
-
out.push(`**${records.length}** probes · **${replied.length}** replied · **${records.length - replied.length}** timed out · ` + `first-reply avg **${(avg / 1000).toFixed(1)}s** / max **${(max / 1000).toFixed(1)}s**.`);
|
|
665
|
+
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**.`);
|
|
578
666
|
out.push("");
|
|
579
667
|
const builtin = CATEGORIES.filter((c) => records.some((r) => r.category === c));
|
|
580
668
|
const custom = [];
|
|
@@ -672,9 +760,9 @@ server.registerTool("mira_send", {
|
|
|
672
760
|
title: "Send one probe to @mira",
|
|
673
761
|
description: "Send a message to @mira and capture the FULL settled reply (messages, buttons incl. web_app/startapp, links, media, edits, latency).",
|
|
674
762
|
inputSchema: {
|
|
675
|
-
message:
|
|
676
|
-
settleMs:
|
|
677
|
-
timeoutMs:
|
|
763
|
+
message: z3.string().describe("the message to send to @mira"),
|
|
764
|
+
settleMs: z3.number().int().positive().optional().describe("quiet window before concluding"),
|
|
765
|
+
timeoutMs: z3.number().int().positive().optional().describe("give up if no reply by then")
|
|
678
766
|
}
|
|
679
767
|
}, async ({ message, settleMs, timeoutMs }) => {
|
|
680
768
|
if (existsSync(STOP_FILE))
|
|
@@ -696,13 +784,13 @@ server.registerTool("mira_loop", {
|
|
|
696
784
|
title: "Run the experiment catalog (observe-only)",
|
|
697
785
|
description: "Send catalog probes to @mira at a human pace and capture replies. OBSERVE-ONLY: never presses buttons or spends credits (use the CLI `loop --confirm` for that).",
|
|
698
786
|
inputSchema: {
|
|
699
|
-
category:
|
|
700
|
-
max:
|
|
701
|
-
peer:
|
|
702
|
-
gapMs:
|
|
703
|
-
settleMs:
|
|
704
|
-
timeoutMs:
|
|
705
|
-
catalogFile:
|
|
787
|
+
category: z3.string().optional().describe(`one of: ${CATEGORIES.join(", ")} (or any category in a custom catalog)`),
|
|
788
|
+
max: z3.number().int().positive().max(50).optional().describe("max probes (default 6)"),
|
|
789
|
+
peer: z3.string().optional().describe("'experiment'|'group' for TG_EXPERIMENT_CHAT, or a literal allowlisted peer"),
|
|
790
|
+
gapMs: z3.number().int().nonnegative().optional().describe("delay between sends (default 15000)"),
|
|
791
|
+
settleMs: z3.number().int().positive().optional(),
|
|
792
|
+
timeoutMs: z3.number().int().positive().optional(),
|
|
793
|
+
catalogFile: z3.string().optional().describe("custom catalog JSON file (instead of the built-in catalog)")
|
|
706
794
|
}
|
|
707
795
|
}, async ({ category, max, peer, gapMs, settleMs, timeoutMs, catalogFile }) => {
|
|
708
796
|
let source;
|
|
@@ -757,8 +845,8 @@ server.registerTool("mira_catalog", {
|
|
|
757
845
|
title: "List the experiment catalog",
|
|
758
846
|
description: "List probes (id, category, hypothesis, flags) without sending anything.",
|
|
759
847
|
inputSchema: {
|
|
760
|
-
category:
|
|
761
|
-
catalogFile:
|
|
848
|
+
category: z3.string().optional().describe(`one of: ${CATEGORIES.join(", ")} (or any category in a custom catalog)`),
|
|
849
|
+
catalogFile: z3.string().optional().describe("custom catalog JSON file (instead of the built-in catalog)")
|
|
762
850
|
}
|
|
763
851
|
}, async ({ category, catalogFile }) => {
|
|
764
852
|
let source;
|
|
@@ -784,8 +872,8 @@ server.registerTool("mira_report", {
|
|
|
784
872
|
title: "Render the run log as Markdown",
|
|
785
873
|
description: "Distill the JSONL run log into a Markdown report (per-probe gist / signals / latency).",
|
|
786
874
|
inputSchema: {
|
|
787
|
-
inFile:
|
|
788
|
-
category:
|
|
875
|
+
inFile: z3.string().optional().describe("input JSONL path (default: the run log)"),
|
|
876
|
+
category: z3.string().optional().describe("only include probes from this category")
|
|
789
877
|
}
|
|
790
878
|
}, async ({ inFile, category }) => {
|
|
791
879
|
try {
|
package/llms.txt
CHANGED
|
@@ -19,7 +19,7 @@ so the only programmatic path is a userbot (a real user account over MTProto / G
|
|
|
19
19
|
## CLI commands
|
|
20
20
|
- `doctor` — env / session / connectivity / @mira resolution check (read-only). Run this first.
|
|
21
21
|
- `send [message...]` — one probe → full reply as JSON. Message via arg or stdin. Flags: `--settle <ms> --timeout <ms> --quiet --no-log`.
|
|
22
|
-
- `loop` — run the catalog, paced. Flags: `--category <core|skills|generation|wallet> --max <n> --peer <experiment> --gap <ms> --settle <ms> --timeout <ms> --list --catalog <file.json> --quiet`. Observe-only unless `--confirm`.
|
|
22
|
+
- `loop` — run the catalog, paced. Flags: `--category <core|skills|generation|wallet> --max <n> --peer <experiment> --gap <ms> --settle <ms> --timeout <ms> --list --catalog <file.json> --no-fail --quiet`. Observe-only unless `--confirm`. Grades probes that declare `expect` (PASS/FAIL) and **exits non-zero** if any graded probe fails (`--no-fail` to report without failing) — drops straight into CI.
|
|
23
23
|
- `catalog` — list the catalog, no network. Flags: `--category --catalog <file.json> --json`.
|
|
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`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mira-harness",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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": {
|