@keepsnappy/mcp 0.1.0-alpha.15 → 0.1.0-alpha.16
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 +10 -3
- package/dist/keepsnappy-mcp.js +353 -172
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ writes or recomputes anything (plan 48 §4).
|
|
|
7
7
|
It gives the same answers as the `keepsnappy` CLI (`keepsnappy findings | finding | compare | history | commits | layers`):
|
|
8
8
|
both use this package's API client and answer layout, so a person and an agent read the same words.
|
|
9
9
|
|
|
10
|
+
Which question needs which tool, and how to read the answers: [docs/agents.md](../../docs/agents.md) ("KeepSnappy for coding agents").
|
|
11
|
+
|
|
10
12
|
## Set it up in an app repo (TapIn)
|
|
11
13
|
|
|
12
14
|
1. Ask the KeepSnappy owner for a project key with **read** scope (`bun run create-key -- <org> <app> read` in
|
|
@@ -20,7 +22,7 @@ both use this package's API client and answer layout, so a person and an agent r
|
|
|
20
22
|
"mcpServers": {
|
|
21
23
|
"keepsnappy": {
|
|
22
24
|
"command": "npx",
|
|
23
|
-
"args": ["-y", "@keepsnappy/mcp"],
|
|
25
|
+
"args": ["-y", "@keepsnappy/mcp@0.1.0-alpha.16"],
|
|
24
26
|
"env": {
|
|
25
27
|
"KEEPSNAPPY_KEY": "${KEEPSNAPPY_KEY}",
|
|
26
28
|
"KEEPSNAPPY_API": "https://keepsnappy-api-922918702810.europe-west1.run.app"
|
|
@@ -31,12 +33,13 @@ both use this package's API client and answer layout, so a person and an agent r
|
|
|
31
33
|
```
|
|
32
34
|
|
|
33
35
|
Claude Code expands `${KEEPSNAPPY_KEY}` from your environment. `KEEPSNAPPY_API` is optional (the hosted API is the default).
|
|
34
|
-
From a KeepSnappy checkout instead of npm: `"command": "bun", "args": ["<keepsnappy>/packages/mcp/src/bin.ts"]`.
|
|
36
|
+
Use 0.1.0-alpha.16 or later (all eleven tools, with `pr_status` and `form`; alpha.15 has ten, alpha.13 six). From a KeepSnappy checkout instead of npm: `"command": "bun", "args": ["<keepsnappy>/packages/mcp/src/bin.ts"]`.
|
|
35
37
|
|
|
36
38
|
## Tools
|
|
37
39
|
|
|
38
40
|
| Tool | Answers | API |
|
|
39
41
|
|---|---|---|
|
|
42
|
+
| `pr_status` | Is this branch ok, in one call: blocked, nothing blocks with results to look at, or ok; per journey × phone the deciding word, whether it blocks, the worst present findings (ids for `get_finding`) and the dashboard link. A branch never measured, whose newest run set was not judged, or that could not be compared yet is not ok: the answer says which, with its reason. | `GET /v1/branches/status` |
|
|
40
43
|
| `list_findings` | Whether and when the branch was measured (`measured`: run sets, the newest set of each journey × phone with its commit, word and runs), then what got worse (Worse / Warning) or better, worst first. Filters: `branch` or `main`, `journey`, `phone`, `state` (`present` default, `not-judged`, `resolved`, `all`), `limit` (≤ 200), `offset` (the answer gives `next_offset`). | `GET /v1/findings` |
|
|
41
44
|
| `get_finding` | One finding by id: what changed, where (journey, phone, branch, commit title and author, PR), how sure (runs per side, session spread, measured differently / not blocking, setup notes), why (components that rendered longer with their cause, counts that moved), what to do (a prompt for a coding agent), lifecycle (first and last seen; present, resolved, or not judged when the newest run set did not judge the measure; seen in N PRs). The prompt ends with the finding's id and page. | `GET /v1/findings/:id` |
|
|
42
45
|
| `compare_runs` | Two runs side by side, window by window, and what differs in how they were measured. No statistics. | `GET /v1/compare?a=&b=` |
|
|
@@ -53,7 +56,11 @@ An empty list is a clean result only when `measured.newest` exists and was judge
|
|
|
53
56
|
KeepSnappy measurement on branch X" or "the newest run set was not judged" otherwise. Query parameters the API does not know
|
|
54
57
|
are dropped, not refused (there is no `?commit=` filter).
|
|
55
58
|
|
|
56
|
-
Every tool returns the text the CLI prints and the API's JSON as structured content.
|
|
59
|
+
Every tool returns the text the CLI prints and the API's JSON as structured content. The answer tools (`pr_status`, `list_findings`, `get_finding`, `session_places`, `main_sessions`, `main_vs_previous`,
|
|
60
|
+
`place_trend`, `window_history`) take `form`: `full` (the default, the whole text) or `short` (the CLI's `--short`). Short is
|
|
61
|
+
the full text with lines left out, in the same order, and ends with one line of its own that says what it left out (with
|
|
62
|
+
counts); nothing left out, no line. Each tool's description says what its short form leaves out. The JSON is the same in
|
|
63
|
+
both forms. Every finding names its layer
|
|
57
64
|
(4 = PR vs main, 5 = main over time), its certificate (null until plan 51 certifies the layer) and its limits.
|
|
58
65
|
|
|
59
66
|
## Words
|
package/dist/keepsnappy-mcp.js
CHANGED
|
@@ -14086,6 +14086,7 @@ async function get(t, path, query = {}) {
|
|
|
14086
14086
|
return body;
|
|
14087
14087
|
}
|
|
14088
14088
|
var listFindings = (t, f = {}) => get(t, "v1/findings", { branch: f.branch, main: f.main ? "true" : undefined, journey: f.journey, phone: f.phone, state: f.state, limit: f.limit, offset: f.offset || undefined });
|
|
14089
|
+
var branchStatus = (t, branch) => get(t, "v1/branches/status", { branch });
|
|
14089
14090
|
var getFinding = (t, id) => get(t, `v1/findings/${encodeURIComponent(id)}`);
|
|
14090
14091
|
var compareRuns = (t, a, b) => get(t, "v1/compare", { a, b });
|
|
14091
14092
|
var windowHistory = (t, q) => get(t, "v1/history", q);
|
|
@@ -23002,6 +23003,7 @@ var DIMENSIONS = {
|
|
|
23002
23003
|
};
|
|
23003
23004
|
var BREAKDOWN_MEASURES = {
|
|
23004
23005
|
self_ms: { unit: "ms", worse: "higher", label: "render time", decimals: 1 },
|
|
23006
|
+
rolled_up_ms: { unit: "ms", worse: "none", label: "render time from libraries", decimals: 1 },
|
|
23005
23007
|
total_ms: { unit: "ms", worse: "higher", label: "total render time", decimals: 1 },
|
|
23006
23008
|
renders: { unit: "count", worse: "higher", label: "renders", decimals: 0 },
|
|
23007
23009
|
mounts: { unit: "count", worse: "higher", label: "mounts", decimals: 0 },
|
|
@@ -23012,27 +23014,12 @@ var HEAT_METRIC = "sampler.thermal.heats_up";
|
|
|
23012
23014
|
var HEAT_LABEL = MEASURES[HEAT_METRIC].label;
|
|
23013
23015
|
// packages/facts/dist/src/frame.js
|
|
23014
23016
|
var LIMIT_FRAME_MS = 1000 / 60;
|
|
23015
|
-
// packages/present/dist/src/
|
|
23016
|
-
var
|
|
23017
|
-
|
|
23018
|
-
|
|
23019
|
-
tap: "Tap",
|
|
23020
|
-
scroll: "Scroll",
|
|
23021
|
-
step: "Test step",
|
|
23022
|
-
journey: "Whole journey",
|
|
23023
|
-
background: "Background",
|
|
23024
|
-
after3s: "3 s after"
|
|
23025
|
-
};
|
|
23026
|
-
var KIND_WORD = {
|
|
23027
|
-
journey: PLACE_KIND_WORDS.journey,
|
|
23028
|
-
screen: PLACE_KIND_WORDS.screen,
|
|
23029
|
-
launch: PLACE_KIND_WORDS.launch,
|
|
23030
|
-
press: PLACE_KIND_WORDS.tap,
|
|
23031
|
-
drag: PLACE_KIND_WORDS.scroll,
|
|
23032
|
-
step: PLACE_KIND_WORDS.step,
|
|
23033
|
-
background: PLACE_KIND_WORDS.background
|
|
23034
|
-
};
|
|
23017
|
+
// packages/present/dist/src/capital.js
|
|
23018
|
+
var upperFirst = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
|
23019
|
+
|
|
23020
|
+
// packages/present/dist/src/placeWords.js
|
|
23035
23021
|
var GENERIC_GROUPS = new Set(["app", "tabs", "root"]);
|
|
23022
|
+
|
|
23036
23023
|
// packages/present/dist/src/states.js
|
|
23037
23024
|
var STATE_WORDS = {
|
|
23038
23025
|
worse: "Worse",
|
|
@@ -23042,6 +23029,9 @@ var STATE_WORDS = {
|
|
|
23042
23029
|
"cant-tell": "Can’t tell"
|
|
23043
23030
|
};
|
|
23044
23031
|
var INFO_WORDS = { heat: "Phone heated up" };
|
|
23032
|
+
var isWorseState = (state) => state === "worse";
|
|
23033
|
+
var NOT_BLOCKING = "not blocking";
|
|
23034
|
+
|
|
23045
23035
|
// packages/present/dist/src/values.js
|
|
23046
23036
|
var NO_VALUE = "–";
|
|
23047
23037
|
var FALLBACK_DECIMALS = 1;
|
|
@@ -23097,6 +23087,43 @@ function fieldValueText(field, v) {
|
|
|
23097
23087
|
const def = fieldDef(field.split(KEY_SEPARATOR)[0]);
|
|
23098
23088
|
return format(def?.unit, def?.decimals ?? FALLBACK_DECIMALS, v);
|
|
23099
23089
|
}
|
|
23090
|
+
|
|
23091
|
+
// packages/present/dist/src/rowWords.js
|
|
23092
|
+
var GONE_TAILS = {
|
|
23093
|
+
branch: { notInRun: "on main, not in this run", removed: "on main, not rendered in this run" },
|
|
23094
|
+
journey: { notInRun: "in the sessions before, not in the newest session", removed: "in the sessions before, not rendered in the newest session" }
|
|
23095
|
+
};
|
|
23096
|
+
var ROW_STATE_WORDS = {
|
|
23097
|
+
new: "New in this PR: not compared with main",
|
|
23098
|
+
notInRun: upperFirst(GONE_TAILS.branch.notInRun),
|
|
23099
|
+
removed: upperFirst(GONE_TAILS.branch.removed)
|
|
23100
|
+
};
|
|
23101
|
+
var JOURNEY_ROW_WORDS = {
|
|
23102
|
+
new: "New in the newest session: not compared with the sessions before",
|
|
23103
|
+
notInRun: upperFirst(GONE_TAILS.journey.notInRun),
|
|
23104
|
+
removed: upperFirst(GONE_TAILS.journey.removed)
|
|
23105
|
+
};
|
|
23106
|
+
|
|
23107
|
+
// packages/present/dist/src/places.js
|
|
23108
|
+
var PLACE_KIND_WORDS = {
|
|
23109
|
+
screen: "Screen",
|
|
23110
|
+
launch: "Launch screen",
|
|
23111
|
+
tap: "Tap",
|
|
23112
|
+
scroll: "Scroll",
|
|
23113
|
+
step: "Test step",
|
|
23114
|
+
journey: "Whole journey",
|
|
23115
|
+
background: "Background",
|
|
23116
|
+
after3s: "3 s after"
|
|
23117
|
+
};
|
|
23118
|
+
var KIND_WORD = {
|
|
23119
|
+
journey: PLACE_KIND_WORDS.journey,
|
|
23120
|
+
screen: PLACE_KIND_WORDS.screen,
|
|
23121
|
+
launch: PLACE_KIND_WORDS.launch,
|
|
23122
|
+
press: PLACE_KIND_WORDS.tap,
|
|
23123
|
+
drag: PLACE_KIND_WORDS.scroll,
|
|
23124
|
+
step: PLACE_KIND_WORDS.step,
|
|
23125
|
+
background: PLACE_KIND_WORDS.background
|
|
23126
|
+
};
|
|
23100
23127
|
// packages/present/dist/src/words.js
|
|
23101
23128
|
var REASON_WORDS = {
|
|
23102
23129
|
"no-baseline": "there are no main runs on this phone and journey yet",
|
|
@@ -23109,6 +23136,7 @@ var REASON_WORDS = {
|
|
|
23109
23136
|
"platforms-differ": "the sides ran on different platforms (iOS and Android are never compared)",
|
|
23110
23137
|
"not-enough-main": "main does not have enough sessions on this phone and journey yet"
|
|
23111
23138
|
};
|
|
23139
|
+
var waitingWords = (runs) => `${runs} main ${runs === 1 ? "run waits" : "runs wait"} for a reprocess`;
|
|
23112
23140
|
// packages/present/dist/src/stability.js
|
|
23113
23141
|
var UNSTABLE_WORDS = {
|
|
23114
23142
|
short: "main varies too much here",
|
|
@@ -23147,6 +23175,7 @@ function mainCantWords(reason, config) {
|
|
|
23147
23175
|
return "";
|
|
23148
23176
|
return reason.kind === "noisy_main" ? "main’s runs vary too much there" : `main has ${reason.earlier_sessions}${config.baseline.sessions === undefined ? "" : ` of ${config.baseline.sessions}`} earlier sessions${reason.since_label ? ` on ${reason.since_label}` : ""}`;
|
|
23149
23177
|
}
|
|
23178
|
+
var waitingSessionsWords = (sessions) => `${sessions} older main ${sessions === 1 ? "session waits" : "sessions wait"} for a reprocess`;
|
|
23150
23179
|
function mainPlaceReasonWords(reason, config) {
|
|
23151
23180
|
switch (reason.kind) {
|
|
23152
23181
|
case "no_baseline":
|
|
@@ -23176,37 +23205,113 @@ var SHORT_ASPECT_WORDS = {
|
|
|
23176
23205
|
driverVersion: "Driver",
|
|
23177
23206
|
journeyVersion: "Journey"
|
|
23178
23207
|
};
|
|
23208
|
+
function mainSessionCantWords(places, reason, config) {
|
|
23209
|
+
return `${STATE_WORDS["cant-tell"]} at ${places} place${places === 1 ? "" : "s"}: ${mainCantWords(reason, config)}`;
|
|
23210
|
+
}
|
|
23211
|
+
var KIND_PLURAL = {
|
|
23212
|
+
screen: "screens",
|
|
23213
|
+
press: "taps",
|
|
23214
|
+
drag: "scrolls",
|
|
23215
|
+
launch: "launch screens",
|
|
23216
|
+
step: "test steps",
|
|
23217
|
+
background: "background work",
|
|
23218
|
+
other: "other places"
|
|
23219
|
+
};
|
|
23220
|
+
var listWords = (kinds) => {
|
|
23221
|
+
const words = kinds.map((k) => KIND_PLURAL[k] ?? "other places");
|
|
23222
|
+
return words.length < 2 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
|
|
23223
|
+
};
|
|
23224
|
+
function mainCutWords(v) {
|
|
23225
|
+
const parts = [];
|
|
23226
|
+
if (v.cuts.length)
|
|
23227
|
+
parts.push(`Starts a new ${v.cuts.join(" and ")} baseline.`);
|
|
23228
|
+
if (v.partial_render) {
|
|
23229
|
+
const cut = listWords(v.partial_render.cut);
|
|
23230
|
+
parts.push(`${cut[0].toUpperCase()}${cut.slice(1)} start a new render baseline; ${listWords(v.partial_render.kept)} keep theirs.`);
|
|
23231
|
+
}
|
|
23232
|
+
return parts.length ? parts.join(" ") : "Measures alike; no baseline cut.";
|
|
23233
|
+
}
|
|
23179
23234
|
// packages/mcp/src/answer.ts
|
|
23235
|
+
var ANSWER_FORMS = ["short", "full"];
|
|
23236
|
+
var SHORT_SESSIONS = 3;
|
|
23237
|
+
var SHORT_POINTS = 5;
|
|
23238
|
+
var SHORT_PLACES = 5;
|
|
23239
|
+
var SHORT_FORM_NOTES = {
|
|
23240
|
+
list_findings: "the how-sure lines, the place lines of findings that are not Worse and the lanes that were judged",
|
|
23241
|
+
get_finding: "the why, seen and layer lines, the component lines and the prompt",
|
|
23242
|
+
session_places: "the component lines",
|
|
23243
|
+
main_sessions: `the sessions older than the newest ${SHORT_SESSIONS}, the main changes of all but the newest session and, for one run_set, the places in norm`,
|
|
23244
|
+
main_vs_previous: `the movers after the first ${SHORT_PLACES} (every new, gone or multi-device place stays)`,
|
|
23245
|
+
place_trend: `the sessions older than the newest ${SHORT_SESSIONS} and the history restarts among them (unknown restarts always stay)`,
|
|
23246
|
+
window_history: `the runs older than the newest ${SHORT_POINTS}`,
|
|
23247
|
+
pr_status: "the per-journey dashboard links and the heat note"
|
|
23248
|
+
};
|
|
23249
|
+
var andList = (xs) => xs.length < 2 ? xs[0] ?? "" : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
|
|
23250
|
+
|
|
23251
|
+
class Rows {
|
|
23252
|
+
rows = [];
|
|
23253
|
+
add(short, ...texts) {
|
|
23254
|
+
for (const t of texts)
|
|
23255
|
+
for (const l of t.split(`
|
|
23256
|
+
`))
|
|
23257
|
+
this.rows.push({ text: l, short });
|
|
23258
|
+
return this;
|
|
23259
|
+
}
|
|
23260
|
+
both(...texts) {
|
|
23261
|
+
return this.add(true, ...texts);
|
|
23262
|
+
}
|
|
23263
|
+
full(...texts) {
|
|
23264
|
+
return this.add(false, ...texts);
|
|
23265
|
+
}
|
|
23266
|
+
render(form, left = []) {
|
|
23267
|
+
if (form !== "short")
|
|
23268
|
+
return this.rows.map((r) => r.text).join(`
|
|
23269
|
+
`);
|
|
23270
|
+
const kept = [];
|
|
23271
|
+
for (const r of this.rows) {
|
|
23272
|
+
if (!r.short)
|
|
23273
|
+
continue;
|
|
23274
|
+
if (r.text === "" && (!kept.length || kept[kept.length - 1] === ""))
|
|
23275
|
+
continue;
|
|
23276
|
+
kept.push(r.text);
|
|
23277
|
+
}
|
|
23278
|
+
if (kept[kept.length - 1] === "")
|
|
23279
|
+
kept.pop();
|
|
23280
|
+
if (this.rows.some((r) => !r.short && r.text.trim() !== ""))
|
|
23281
|
+
kept.push(`Short form: ${andList(left.length ? left : ["other lines"])} left out; the full form shows them.`);
|
|
23282
|
+
return kept.join(`
|
|
23283
|
+
`);
|
|
23284
|
+
}
|
|
23285
|
+
}
|
|
23180
23286
|
var LABEL_W = 14;
|
|
23181
23287
|
var line = (label, text) => ` ${label.padEnd(LABEL_W)}${text}`;
|
|
23182
23288
|
var plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
23289
|
+
var promptText = (f) => f.what_to_do.prompt ?? "No prompt: this finding is an improvement.";
|
|
23183
23290
|
function formatFinding(f, opts = {}) {
|
|
23184
|
-
const
|
|
23185
|
-
|
|
23186
|
-
|
|
23187
|
-
|
|
23188
|
-
|
|
23189
|
-
|
|
23190
|
-
line("What to do", f.answer.what_to_do),
|
|
23191
|
-
line("Seen", f.answer.lifecycle),
|
|
23192
|
-
line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`),
|
|
23193
|
-
line("Id", f.id)
|
|
23194
|
-
];
|
|
23291
|
+
const r = new Rows;
|
|
23292
|
+
r.both(f.answer.headline, line("What changed", f.answer.what_changed), line("Where", f.answer.where), line("How sure", f.answer.how_sure));
|
|
23293
|
+
r.full(line("Why", f.answer.why));
|
|
23294
|
+
r.both(line("What to do", f.answer.what_to_do));
|
|
23295
|
+
r.full(line("Seen", f.answer.lifecycle), line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`));
|
|
23296
|
+
r.both(line("Id", f.id));
|
|
23195
23297
|
if (f.why.components.length) {
|
|
23196
|
-
|
|
23298
|
+
r.full(line("Components", ""));
|
|
23197
23299
|
for (const c of f.why.components) {
|
|
23198
|
-
|
|
23300
|
+
r.full(` ${c.component}: ${c.self_ms_before ?? "–"} → ${c.self_ms_after} ms own time, renders ${c.renders_before ?? "–"} → ${c.renders_after ?? "–"}${c.cause ? `; ${c.cause}` : ""}${c.flagged ? "" : " (not certain)"}`);
|
|
23199
23301
|
}
|
|
23200
23302
|
}
|
|
23201
23303
|
if (opts.prompt)
|
|
23202
|
-
|
|
23203
|
-
return
|
|
23204
|
-
|
|
23304
|
+
r.full("", promptText(f));
|
|
23305
|
+
return r.render(opts.form, [
|
|
23306
|
+
"the why, seen and layer lines",
|
|
23307
|
+
...f.why.components.length ? [plural(f.why.components.length, "component line")] : [],
|
|
23308
|
+
...opts.prompt ? ["the prompt"] : []
|
|
23309
|
+
]);
|
|
23205
23310
|
}
|
|
23206
23311
|
var when = (iso) => iso.slice(0, 16).replace("T", " ");
|
|
23207
23312
|
var LANES_SHOWN = 8;
|
|
23208
23313
|
var STATE_WORDS2 = { present: "still present", resolved: "resolved", "not-judged": "not judged in the newest run set" };
|
|
23209
|
-
function
|
|
23314
|
+
function measuredLines(l, r) {
|
|
23210
23315
|
const f = l.filters;
|
|
23211
23316
|
const scope = [f.branch ?? "all branches", f.journey, f.phone].filter(Boolean).join(" · ");
|
|
23212
23317
|
const m = l.measured;
|
|
@@ -23214,21 +23319,25 @@ function measuredLine(l) {
|
|
|
23214
23319
|
if (!n) {
|
|
23215
23320
|
const on = f.branch ? f.branch === l.baseline_branch ? f.branch : `branch ${f.branch}` : "any branch";
|
|
23216
23321
|
const narrow = [f.journey, f.phone ? `phone “${f.phone}”` : null].filter(Boolean).join(", ");
|
|
23217
|
-
|
|
23322
|
+
r.both(m.run_sets ? `${scope}: ${plural(m.run_sets, "run set")} measured, but no verdict yet: nothing is known about its performance.` : `No KeepSnappy measurement on ${on}${narrow ? ` for ${narrow}` : ""}: nothing is known about its performance.`);
|
|
23323
|
+
return [];
|
|
23218
23324
|
}
|
|
23219
23325
|
const lane = (x) => {
|
|
23220
23326
|
const runs = x.runs.after !== null && x.runs.before !== null ? x.branch === l.baseline_branch ? `, ${x.runs.after} latest runs vs ${x.runs.before} earlier` : `, ${x.runs.after} branch runs vs ${x.runs.before} on ${l.baseline_branch}` : "";
|
|
23221
|
-
return `${x.commit?.sha ? `commit ${x.commit.sha.slice(0, 7)}` : "commit not recorded"} (${when(x.at)}): ${x.word}${runs}`;
|
|
23327
|
+
return `${x.commit?.sha ? `commit ${x.commit.sha.slice(0, 7)}` : "commit not recorded"} (${when(x.at)}): ${x.word}${runs}${x.waiting_for_reprocess ? `; ${waitingWords(x.waiting_for_reprocess)}` : ""}`;
|
|
23222
23328
|
};
|
|
23223
|
-
if (m.lanes.length === 1)
|
|
23224
|
-
|
|
23329
|
+
if (m.lanes.length === 1) {
|
|
23330
|
+
r.both(`${scope}: newest measured ${f.branch ? "" : `on ${n.branch}, `}${lane(n)}; ${plural(m.run_sets, "run set")} measured.`);
|
|
23331
|
+
return [];
|
|
23332
|
+
}
|
|
23225
23333
|
const shown = m.lanes.slice(0, LANES_SHOWN);
|
|
23226
|
-
|
|
23227
|
-
|
|
23228
|
-
|
|
23229
|
-
|
|
23230
|
-
|
|
23231
|
-
`);
|
|
23334
|
+
const unjudged = shown.filter((x) => !x.judged);
|
|
23335
|
+
r.add(unjudged.length > 0, `${scope}: ${plural(m.run_sets, "run set")} measured; the newest of each journey × phone:`);
|
|
23336
|
+
for (const x of shown)
|
|
23337
|
+
r.add(!x.judged, ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`);
|
|
23338
|
+
if (m.lanes.length > shown.length)
|
|
23339
|
+
r.full(` … ${m.lanes.length - shown.length} more (--journey / --phone narrow it)`);
|
|
23340
|
+
return [...shown.length > unjudged.length ? [plural(shown.length - unjudged.length, "judged lane")] : [], ...m.lanes.length > shown.length ? [plural(m.lanes.length - shown.length, "lane") + " beyond the eighth"] : []];
|
|
23232
23341
|
}
|
|
23233
23342
|
function countsLine(l) {
|
|
23234
23343
|
const c = l.counts;
|
|
@@ -23248,22 +23357,69 @@ function countsLine(l) {
|
|
|
23248
23357
|
const line = `${head}${more.length ? `; ${more.join("; ")}` : ""}.`;
|
|
23249
23358
|
return line.charAt(0).toUpperCase() + line.slice(1);
|
|
23250
23359
|
}
|
|
23251
|
-
function formatFindingList(l) {
|
|
23252
|
-
const
|
|
23360
|
+
function formatFindingList(l, opts = {}) {
|
|
23361
|
+
const r = new Rows;
|
|
23362
|
+
const lanesLeft = measuredLines(l, r);
|
|
23253
23363
|
if (l.measured.newest || l.total)
|
|
23254
|
-
|
|
23364
|
+
r.both(countsLine(l));
|
|
23255
23365
|
l.findings.forEach((f, i) => {
|
|
23256
|
-
|
|
23366
|
+
r.both("", `${l.offset + i + 1}. ${f.answer.headline}`);
|
|
23367
|
+
r.add(isWorseState(f.state), ` ${f.answer.where}`);
|
|
23368
|
+
r.full(` ${f.answer.how_sure}`);
|
|
23369
|
+
r.both(` ${f.answer.lifecycle} Id ${f.id}`);
|
|
23257
23370
|
});
|
|
23258
23371
|
const shown = l.findings.length ? `Showing ${l.offset + 1}–${l.offset + l.findings.length} of ${l.total}` : "";
|
|
23259
23372
|
if (l.next_offset !== null)
|
|
23260
|
-
|
|
23373
|
+
r.both("", `${shown || `Showing none of ${l.total}`}; the rest from offset ${l.next_offset} (--offset ${l.next_offset}).`);
|
|
23261
23374
|
else if (l.offset > 0)
|
|
23262
|
-
|
|
23375
|
+
r.both("", shown ? `${shown}.` : `Offset ${l.offset} is past the last of ${l.total}.`);
|
|
23263
23376
|
if (l.findings.length)
|
|
23264
|
-
|
|
23265
|
-
|
|
23266
|
-
|
|
23377
|
+
r.both("", "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt");
|
|
23378
|
+
const notWorse = l.findings.filter((f) => !isWorseState(f.state)).length;
|
|
23379
|
+
return r.render(opts.form, [
|
|
23380
|
+
...l.findings.length ? [plural(l.findings.length, "how-sure line")] : [],
|
|
23381
|
+
...notWorse ? [plural(notWorse, "place line")] : [],
|
|
23382
|
+
...lanesLeft
|
|
23383
|
+
]);
|
|
23384
|
+
}
|
|
23385
|
+
var STATUS_LEAD = {
|
|
23386
|
+
ok: "ok",
|
|
23387
|
+
warns: "nothing blocks, results to look at",
|
|
23388
|
+
blocks: "blocked",
|
|
23389
|
+
"cant-tell": "not ok yet, nothing was compared",
|
|
23390
|
+
"not-judged": "not ok yet, the newest run set was not judged",
|
|
23391
|
+
"never-measured": "not ok yet, never measured"
|
|
23392
|
+
};
|
|
23393
|
+
function formatBranchStatus(s, opts = {}) {
|
|
23394
|
+
const r = new Rows;
|
|
23395
|
+
r.both(`${s.branch}: ${STATUS_LEAD[s.result]}`, s.reason);
|
|
23396
|
+
if (s.lanes.length)
|
|
23397
|
+
r.both("");
|
|
23398
|
+
for (const l of s.lanes) {
|
|
23399
|
+
const badge = l.result === "blocks" ? " (blocks)" : l.word === STATE_WORDS.worse ? ` (${NOT_BLOCKING})` : "";
|
|
23400
|
+
const sha = l.commit?.sha?.slice(0, 7);
|
|
23401
|
+
r.both(` ${l.journey_title ?? l.journey} on ${l.phone_name}: ${l.word}${badge} · ${sha ? `commit ${sha}` : "commit not recorded"} (${when(l.at)})${l.in_newest_run_set ? "" : " · older run set"}${l.waiting_for_reprocess ? `; ${waitingWords(l.waiting_for_reprocess)}` : ""}`);
|
|
23402
|
+
for (const f of l.worst)
|
|
23403
|
+
r.both(` ${f.id} ${f.headline}`);
|
|
23404
|
+
if (l.present_findings > l.worst.length)
|
|
23405
|
+
r.both(` … ${l.present_findings - l.worst.length} more: keepsnappy findings --branch ${s.branch}`);
|
|
23406
|
+
if (l.link)
|
|
23407
|
+
r.full(` ${l.link}`);
|
|
23408
|
+
}
|
|
23409
|
+
const links = s.lanes.filter((l) => l.link).length;
|
|
23410
|
+
const heated = s.counts.heat_runs ?? 0;
|
|
23411
|
+
const more = [
|
|
23412
|
+
[false, heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural(heated, "run")} (information, never blocks)` : ""],
|
|
23413
|
+
[true, s.not_judged_findings ? `${plural(s.not_judged_findings, "finding")} not judged in the newest run set; they may still be there: keepsnappy findings --branch ${s.branch} --state not-judged` : ""],
|
|
23414
|
+
[true, s.links.overview ? `Dashboard: ${s.links.overview}` : ""],
|
|
23415
|
+
[true, s.worst.length ? "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt" : ""]
|
|
23416
|
+
];
|
|
23417
|
+
const shownMore = more.filter(([, text]) => text);
|
|
23418
|
+
if (shownMore.length)
|
|
23419
|
+
r.both("");
|
|
23420
|
+
for (const [short, text] of shownMore)
|
|
23421
|
+
r.add(short, text);
|
|
23422
|
+
return r.render(opts.form, [...links ? [plural(links, "dashboard link")] : [], ...heated ? ["the heat note"] : []]);
|
|
23267
23423
|
}
|
|
23268
23424
|
var commitText = (c) => !c ? "commit not recorded" : c.title ? `“${c.title}”${c.author ? ` by ${c.author}` : ""}${c.sha ? ` (${c.sha.slice(0, 7)})` : ""}` : c.sha ? c.sha.slice(0, 7) : "commit not recorded";
|
|
23269
23425
|
function formatCompare(r) {
|
|
@@ -23285,19 +23441,20 @@ function formatCompare(r) {
|
|
|
23285
23441
|
return out.join(`
|
|
23286
23442
|
`);
|
|
23287
23443
|
}
|
|
23288
|
-
function formatHistory(h) {
|
|
23289
|
-
const
|
|
23444
|
+
function formatHistory(h, opts = {}) {
|
|
23445
|
+
const r = new Rows;
|
|
23446
|
+
r.both(`${h.window_name} in ${h.journey_title ?? h.journey} on ${h.branch}: ${h.points.length ? `${plural(h.points.length, "run")}, oldest first` : h.note ?? "no runs"}`);
|
|
23290
23447
|
const ms = h.measures.slice(0, 4);
|
|
23291
23448
|
if (h.points.length) {
|
|
23292
|
-
|
|
23293
|
-
|
|
23294
|
-
|
|
23295
|
-
}
|
|
23449
|
+
r.both(` ${"when".padEnd(17)}${ms.map((m) => (m.label.length > 22 ? `${m.label.slice(0, 21)}…` : m.label).padStart(24)).join("")} commit`);
|
|
23450
|
+
h.points.forEach((p, i) => {
|
|
23451
|
+
r.add(i >= h.points.length - SHORT_POINTS, ` ${(p.at ?? "").slice(0, 16).replace("T", " ").padEnd(17)}${ms.map((m) => fieldValueText(m.metric, p.values[m.metric]).padStart(24)).join("")} ${commitText(p.commit)}`);
|
|
23452
|
+
});
|
|
23296
23453
|
}
|
|
23297
23454
|
if (h.findings.length)
|
|
23298
|
-
|
|
23299
|
-
|
|
23300
|
-
|
|
23455
|
+
r.both("", "Findings on this window:", ...h.findings.map((f) => ` ${f.answer.headline} — ${f.where.branch} (${f.id})`));
|
|
23456
|
+
const older = Math.max(0, h.points.length - SHORT_POINTS);
|
|
23457
|
+
return r.render(opts.form, older ? [plural(older, "older run")] : []);
|
|
23301
23458
|
}
|
|
23302
23459
|
function formatCommits(c) {
|
|
23303
23460
|
return [
|
|
@@ -23317,50 +23474,44 @@ function versionText(label, counts) {
|
|
|
23317
23474
|
return `${label} ${name(values[0])}`;
|
|
23318
23475
|
return `${label} mixed: ${values.map((k) => `${name(k)} ×${counts[k]}`).join(", ")}`;
|
|
23319
23476
|
}
|
|
23320
|
-
function formatSessionPlaces(
|
|
23321
|
-
const s =
|
|
23477
|
+
function formatSessionPlaces(p, opts = {}) {
|
|
23478
|
+
const s = p.session;
|
|
23322
23479
|
const sha = s.commit?.sha?.slice(0, 7);
|
|
23323
23480
|
const commit = s.commit ? `${s.commit.title ? `“${s.commit.title}”${s.commit.author ? ` by ${s.commit.author}` : ""}` : sha ?? "commit not recorded"}${s.commit.title && sha ? ` (${sha})` : ""}` : "commit not recorded";
|
|
23324
23481
|
const v = s.versions;
|
|
23325
23482
|
const codes = Object.keys(s.runs.invalid_codes);
|
|
23326
|
-
const
|
|
23327
|
-
|
|
23328
|
-
|
|
23329
|
-
|
|
23330
|
-
|
|
23331
|
-
|
|
23332
|
-
|
|
23333
|
-
|
|
23334
|
-
|
|
23335
|
-
`stats ${v.stats}`
|
|
23336
|
-
].filter(Boolean).join(", ")}.`
|
|
23337
|
-
];
|
|
23483
|
+
const r = new Rows().both(`Session ${s.completed_at ? `${when(s.completed_at)} ` : ""}on ${s.branch}: ${commit}${s.pull_request?.number ? `, PR #${s.pull_request.number}` : ""}; ${s.journey.title ?? s.journey.key} on ${s.phone.model}${s.phone.os ? ` · ${s.phone.os}` : ""}${s.status === "processed" ? "" : ` (run set ${s.status})`}.`, `Runs: ${s.runs.valid} valid${s.runs.invalid ? `, ${s.runs.invalid} invalid${codes.length ? ` (${list(codes)})` : ""}` : ""}. ${[
|
|
23484
|
+
versionText("SDK", v.sdk),
|
|
23485
|
+
versionText("driver", v.driver),
|
|
23486
|
+
versionText("driver version", v.driver_version),
|
|
23487
|
+
versionText("app", v.app_build),
|
|
23488
|
+
versionText("journey version", v.journey_version),
|
|
23489
|
+
`derivation ${v.derivation}`,
|
|
23490
|
+
`stats ${v.stats}`
|
|
23491
|
+
].filter(Boolean).join(", ")}.`);
|
|
23338
23492
|
if (s.not_computed)
|
|
23339
|
-
return
|
|
23340
|
-
`);
|
|
23493
|
+
return r.both("Not computed: this session has no stored statistics yet; nothing is known about its places (not zero).").render(opts.form);
|
|
23341
23494
|
if (s.stale)
|
|
23342
|
-
return
|
|
23343
|
-
|
|
23344
|
-
const
|
|
23345
|
-
|
|
23346
|
-
|
|
23347
|
-
|
|
23495
|
+
return r.both("Stale: its statistics are of another derivation or stats version and are not served until the next recompute rewrites them; nothing is known about its places (not zero).").render(opts.form);
|
|
23496
|
+
const by = p.filters.measure;
|
|
23497
|
+
const byLabel = p.places.flatMap((x) => x.measures).find((m) => m.measure === by)?.label ?? by;
|
|
23498
|
+
r.both(`Stored per-session statistics: no verdict, no comparison. Places by ${byLabel} (${p.filters.sort}), highest first${p.filters.window ? `, matching “${p.filters.window}”` : ""}${p.without_measure ? `; ${p.without_measure} of ${p.total} places ${p.without_measure === 1 ? "has" : "have"} no value for it` : ""}.`, "");
|
|
23499
|
+
let componentLines = 0;
|
|
23500
|
+
p.places.forEach((place, i) => {
|
|
23501
|
+
const m = place.measures.find((x) => x.measure === by);
|
|
23348
23502
|
const value = m ? `${fieldValueText(by, m.median)} (p90 ${fieldValueText(by, m.p90)}, n ${m.n})` : `no ${by}`;
|
|
23349
|
-
const comps =
|
|
23350
|
-
|
|
23503
|
+
const comps = place.components.map((c) => `${c.name} ${valueText("self_ms", c.self_ms.median)} (p90 ${valueText("self_ms", c.self_ms.p90)}${c.renders_median !== null ? `, ${plural(c.renders_median, "render")}` : ""})`);
|
|
23504
|
+
r.both(`${p.offset + i + 1}. ${place.window_name}: ${value}`);
|
|
23505
|
+
if (comps.length) {
|
|
23506
|
+
r.full(` Components: ${list(comps)}`);
|
|
23507
|
+
componentLines++;
|
|
23508
|
+
}
|
|
23351
23509
|
});
|
|
23352
|
-
|
|
23353
|
-
|
|
23354
|
-
|
|
23355
|
-
"",
|
|
23356
|
-
|
|
23357
|
-
];
|
|
23358
|
-
if (!r.places.length)
|
|
23359
|
-
out.push(r.total ? `Offset ${r.offset} is past the last of ${r.total} places.` : "No place with stored statistics in this session matches.");
|
|
23360
|
-
else if (r.next_offset !== null)
|
|
23361
|
-
out.push("", `Showing ${r.offset + 1}–${r.offset + r.places.length} of ${r.total}; the rest from offset ${r.next_offset} (--offset ${r.next_offset}).`);
|
|
23362
|
-
return out.join(`
|
|
23363
|
-
`);
|
|
23510
|
+
if (!p.places.length)
|
|
23511
|
+
r.both(p.total ? `Offset ${p.offset} is past the last of ${p.total} places.` : "No place with stored statistics in this session matches.");
|
|
23512
|
+
else if (p.next_offset !== null)
|
|
23513
|
+
r.both("", `Showing ${p.offset + 1}–${p.offset + p.places.length} of ${p.total}; the rest from offset ${p.next_offset} (--offset ${p.next_offset}).`);
|
|
23514
|
+
return r.render(opts.form, componentLines ? [plural(componentLines, "component line")] : []);
|
|
23364
23515
|
}
|
|
23365
23516
|
function formatLayers(l) {
|
|
23366
23517
|
return [
|
|
@@ -23372,81 +23523,98 @@ function formatLayers(l) {
|
|
|
23372
23523
|
`);
|
|
23373
23524
|
}
|
|
23374
23525
|
var MAIN_TIERS_WORST_FIRST = Object.keys(MAIN_TIER_RANK).sort((a, b) => MAIN_TIER_RANK[a] - MAIN_TIER_RANK[b]);
|
|
23375
|
-
function formatMainSessions(h) {
|
|
23526
|
+
function formatMainSessions(h, opts = {}) {
|
|
23376
23527
|
const baseline = { baseline: { sessions: h.baseline_sessions } };
|
|
23377
|
-
const
|
|
23528
|
+
const r = new Rows().both(`Main sessions: tiers are verdicts vs ${h.baseline_sessions === undefined ? "earlier sessions" : `the ${h.baseline_sessions} sessions before`}.`);
|
|
23378
23529
|
if (!h.sessions.length)
|
|
23379
|
-
|
|
23530
|
+
r.both("No main sessions measured for this journey and phone.");
|
|
23531
|
+
const recent = new Set(h.sessions.slice(-SHORT_SESSIONS));
|
|
23532
|
+
const latest = h.sessions[h.sessions.length - 1];
|
|
23533
|
+
let otherChanges = 0;
|
|
23380
23534
|
for (const s of h.sessions) {
|
|
23381
|
-
|
|
23535
|
+
const k = recent.has(s);
|
|
23536
|
+
r.add(k, `${when(s.at)} ${s.run_set_id} ${commitText(s.commit ? { ...s.commit, author: null } : null)} (${plural(s.runs, "run")})`);
|
|
23382
23537
|
if (!s.places)
|
|
23383
|
-
|
|
23538
|
+
r.add(k, ` ${MAIN_SUMMARY_PENDING}${s.summary_reason ? ` (${s.summary_reason})` : ""}`);
|
|
23384
23539
|
else
|
|
23385
|
-
|
|
23540
|
+
r.add(k, ` ${MAIN_TIERS_WORST_FIRST.map((tier) => `${MAIN_TIER_WORDS[tier]}: ${s.places[tier]}`).join("; ")}${s.cant_reason ? `; ${mainSessionCantWords(s.places.cant, s.cant_reason, baseline)}` : ""}`);
|
|
23541
|
+
if (s.waiting_sessions)
|
|
23542
|
+
r.add(k, ` ${waitingSessionsWords(s.waiting_sessions)}`);
|
|
23386
23543
|
if (s.memory)
|
|
23387
|
-
|
|
23544
|
+
r.add(k, ` Memory: ${MAIN_TIER_WORDS[s.memory.tier]}, ${s.memory.median} MB (n ${s.memory.n})${s.memory.cant_reason ? `; ${mainCantWords(s.memory.cant_reason, baseline)}` : ""}`);
|
|
23388
23545
|
for (const v of h.versions.filter((v) => v.from_run_set_id === s.run_set_id))
|
|
23389
|
-
|
|
23390
|
-
for (const c of h.changes.filter((c) => c.from_run_set_id === s.run_set_id))
|
|
23391
|
-
|
|
23546
|
+
r.add(k, ` Version: ${v.label}; ${mainCutWords(v)}`);
|
|
23547
|
+
for (const c of h.changes.filter((c) => c.from_run_set_id === s.run_set_id)) {
|
|
23548
|
+
r.add(s === latest, ` Main change: ${c.place} ${c.ref}, ${c.before_median ?? "–"} → ${c.after_median ?? "–"}`);
|
|
23549
|
+
if (k && s !== latest)
|
|
23550
|
+
otherChanges++;
|
|
23551
|
+
}
|
|
23392
23552
|
}
|
|
23393
|
-
|
|
23394
|
-
|
|
23553
|
+
const older = h.sessions.length - recent.size;
|
|
23554
|
+
return r.render(opts.form, [...older ? [plural(older, "older session")] : [], ...otherChanges ? [plural(otherChanges, "main change") + " of the other recent sessions"] : []]);
|
|
23395
23555
|
}
|
|
23396
|
-
function formatMainSessionPlaces(p) {
|
|
23556
|
+
function formatMainSessionPlaces(p, opts = {}) {
|
|
23557
|
+
const r = new Rows;
|
|
23397
23558
|
if (p.status !== "ok")
|
|
23398
|
-
return `${MAIN_SUMMARY_PENDING}${p.summary_reason ? ` (${p.summary_reason})` : ""}
|
|
23559
|
+
return r.both(`${MAIN_SUMMARY_PENDING}${p.summary_reason ? ` (${p.summary_reason})` : ""}`).render(opts.form);
|
|
23399
23560
|
const baselineSessions = p.baseline_sessions;
|
|
23400
|
-
|
|
23401
|
-
|
|
23402
|
-
|
|
23403
|
-
|
|
23404
|
-
|
|
23561
|
+
r.both(`Main session places: tiers are verdicts vs ${p.baseline_sessions === undefined ? "earlier sessions" : `the ${p.baseline_sessions} sessions before`}.`);
|
|
23562
|
+
for (const x of p.places)
|
|
23563
|
+
r.add(x.tier !== "same", ` ${x.place} ${x.ref}: ${MAIN_TIER_WORDS[x.tier]}${x.reason ? `; ${mainPlaceReasonWords(x.reason, { baseline: { sessions: baselineSessions } })}` : ""}`);
|
|
23564
|
+
const inNorm = p.places.filter((x) => x.tier === "same").length;
|
|
23565
|
+
return r.render(opts.form, inNorm ? [plural(inNorm, "place") + " in norm"] : []);
|
|
23405
23566
|
}
|
|
23406
23567
|
var signed = (n) => `${n > 0 ? "+" : ""}${Number(n.toFixed(3))}`;
|
|
23407
23568
|
var previousLine = (p) => ` ${p.place} ${p.ref}${p.devices ? ` [devices ${p.devices.before.join(", ") || "–"} → ${p.devices.after.join(", ") || "–"}]` : ""}: ${p.status === "compared" ? "" : `${p.status}; `}${p.before ?? "–"} → ${p.after ?? "–"}; delta ${p.delta === null ? "–" : signed(p.delta)}; ${p.percent_change === null ? "change undefined" : `${signed(p.percent_change)}%`}; n ${p.n.before ?? "–"} → ${p.n.after ?? "–"}`;
|
|
23408
|
-
function formatMainVsPrevious(
|
|
23409
|
-
const
|
|
23410
|
-
if (
|
|
23411
|
-
|
|
23412
|
-
else if (
|
|
23413
|
-
|
|
23414
|
-
if (
|
|
23415
|
-
|
|
23416
|
-
if (
|
|
23417
|
-
|
|
23418
|
-
if (
|
|
23419
|
-
|
|
23420
|
-
|
|
23421
|
-
if (
|
|
23422
|
-
|
|
23423
|
-
|
|
23424
|
-
|
|
23425
|
-
}
|
|
23426
|
-
function formatPlaceTrend(t) {
|
|
23427
|
-
const
|
|
23569
|
+
function formatMainVsPrevious(m, opts = {}) {
|
|
23570
|
+
const r = new Rows().both(`Information only: main session ${m.run_set_id} vs ${m.previous_run_set_id ?? "no previous session"}.`, `Measure: ${m.measure}; ${m.status}.`);
|
|
23571
|
+
if (m.cut === "unknown")
|
|
23572
|
+
r.both("History restart: unknown; current summaries unavailable.");
|
|
23573
|
+
else if (m.cut)
|
|
23574
|
+
r.both(`History restart: ${m.cut.label}; ${m.cut.cuts.join(", ")}. Numbers cross this restart.`);
|
|
23575
|
+
if (m.not_computed.length)
|
|
23576
|
+
r.both(`not_computed: ${m.not_computed.join(", ")}`);
|
|
23577
|
+
if (m.stale.length)
|
|
23578
|
+
r.both(`stale: ${m.stale.join(", ")}`);
|
|
23579
|
+
if (m.journey)
|
|
23580
|
+
r.both("Whole journey:", previousLine(m.journey));
|
|
23581
|
+
m.places.forEach((p, i) => r.add(i < SHORT_PLACES || p.status !== "compared", previousLine(p)));
|
|
23582
|
+
if (m.next_offset !== null)
|
|
23583
|
+
r.both(`Showing ${m.offset + 1}–${m.offset + m.places.length} of ${m.total}; next offset ${m.next_offset}.`);
|
|
23584
|
+
const smaller = m.places.filter((p, i) => i >= SHORT_PLACES && p.status === "compared").length;
|
|
23585
|
+
return r.render(opts.form, smaller ? [plural(smaller, "smaller mover")] : []);
|
|
23586
|
+
}
|
|
23587
|
+
function formatPlaceTrend(t, opts = {}) {
|
|
23588
|
+
const r = new Rows().both(`${t.journey} · ${t.phone.model} · ${t.baseline_branch}: stored per-session statistics`);
|
|
23589
|
+
const newest = new Set(t.sessions.slice(-SHORT_SESSIONS).map((s) => s.run_set_id));
|
|
23590
|
+
const olderSessions = new Set;
|
|
23591
|
+
let olderRestarts = 0;
|
|
23428
23592
|
for (const session of t.sessions) {
|
|
23593
|
+
const k = newest.has(session.run_set_id);
|
|
23429
23594
|
if (session.restarts_known === false)
|
|
23430
|
-
|
|
23431
|
-
for (const restart of t.restarts.filter((
|
|
23432
|
-
|
|
23595
|
+
r.both(`History restarts unknown: ${session.run_set_id} (no current main summary in the history window)`);
|
|
23596
|
+
for (const restart of t.restarts.filter((x) => x.from_run_set_id === session.run_set_id)) {
|
|
23597
|
+
r.add(k, `History restarts: ${restart.label} (${restart.cuts.join(", ")})`);
|
|
23598
|
+
if (!k)
|
|
23599
|
+
olderRestarts++;
|
|
23433
23600
|
}
|
|
23434
23601
|
for (const place of t.places) {
|
|
23435
23602
|
const value = place.sessions.find((s) => s.run_set_id === session.run_set_id);
|
|
23436
23603
|
if (!value)
|
|
23437
23604
|
continue;
|
|
23438
23605
|
const unit = place.unit ? ` ${place.unit}` : "";
|
|
23439
|
-
|
|
23606
|
+
if (!k)
|
|
23607
|
+
olderSessions.add(session.run_set_id);
|
|
23608
|
+
r.add(k, `${session.completed_at?.slice(0, 10) ?? "date unknown"} ${session.commit.sha?.slice(0, 7) ?? "commit unknown"} · ${place.window} · ${place.label} (${place.ref}): median ${value.median}${unit}, MAD ${value.mad}${unit}, n=${value.n}`);
|
|
23440
23609
|
}
|
|
23441
23610
|
}
|
|
23442
23611
|
if (!t.sessions.length)
|
|
23443
|
-
|
|
23612
|
+
r.both("No main sessions.");
|
|
23444
23613
|
if (t.not_computed.length)
|
|
23445
|
-
|
|
23614
|
+
r.both(`Not computed: ${t.not_computed.join(", ")}`);
|
|
23446
23615
|
if (t.stale.length)
|
|
23447
|
-
|
|
23448
|
-
return
|
|
23449
|
-
`);
|
|
23616
|
+
r.both(`Stale: ${t.stale.join(", ")}`);
|
|
23617
|
+
return r.render(opts.form, [...olderSessions.size ? [plural(olderSessions.size, "older session")] : [], ...olderRestarts ? [plural(olderRestarts, "older restart marker")] : []]);
|
|
23450
23618
|
}
|
|
23451
23619
|
|
|
23452
23620
|
// packages/mcp/src/server.ts
|
|
@@ -23467,7 +23635,7 @@ function createServer(target) {
|
|
|
23467
23635
|
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
23468
23636
|
server.registerTool("list_findings", {
|
|
23469
23637
|
title: "List performance findings",
|
|
23470
|
-
description: "What got worse (Worse / Slightly worse) or better in the app, worst first, as KeepSnappy measured it on real phones. The answer starts with whether and when the branch was measured and how its newest run set read: an empty list on a branch that was never measured, or whose newest run set was not judged, is not a clean result. Filter by a PR branch or main, journey, phone, and state (present by default; not-judged = the newest run set did not judge it, so it may still be there). Each finding says what changed, where, how sure, and has an id for get_finding. Page with offset."
|
|
23638
|
+
description: "What got worse (Worse / Slightly worse) or better in the app, worst first, as KeepSnappy measured it on real phones. The answer starts with whether and when the branch was measured and how its newest run set read: an empty list on a branch that was never measured, or whose newest run set was not judged, is not a clean result. Filter by a PR branch or main, journey, phone, and state (present by default; not-judged = the newest run set did not judge it, so it may still be there). Each finding says what changed, where, how sure, and has an id for get_finding. Page with offset." + ` ${shortNote("list_findings")}`,
|
|
23471
23639
|
inputSchema: {
|
|
23472
23640
|
branch: string2().optional().describe("A PR branch, e.g. feat/keepsnappy-integration"),
|
|
23473
23641
|
main: boolean2().optional().describe("Findings of main over time instead of a branch"),
|
|
@@ -23475,16 +23643,17 @@ function createServer(target) {
|
|
|
23475
23643
|
phone: string2().optional().describe('Part of the phone model, e.g. "iPhone 15"'),
|
|
23476
23644
|
state: _enum(["present", "resolved", "not-judged", "all"]).optional(),
|
|
23477
23645
|
limit: number2().int().min(1).max(200).optional(),
|
|
23478
|
-
offset: number2().int().min(0).optional().describe("Skip this many findings; the answer gives next_offset")
|
|
23646
|
+
offset: number2().int().min(0).optional().describe("Skip this many findings; the answer gives next_offset"),
|
|
23647
|
+
form: formInput
|
|
23479
23648
|
},
|
|
23480
23649
|
annotations: READ_ONLY
|
|
23481
|
-
}, async (a) => answer(() => listFindings(target, a), formatFindingList));
|
|
23650
|
+
}, async ({ form, ...a }) => answer(() => listFindings(target, a), (l) => formatFindingList(l, { form })));
|
|
23482
23651
|
server.registerTool("get_finding", {
|
|
23483
23652
|
title: "One finding with evidence and a prompt",
|
|
23484
|
-
description:
|
|
23485
|
-
inputSchema: { id: string2().regex(/^f_[0-9a-f]{16}$/).describe("Finding id from list_findings") },
|
|
23653
|
+
description: `One finding by id: what changed, where (journey, phone, branch, commit, PR), how sure (runs, spread, measured differently / not blocking), why (components that rendered longer and their causes, counts that moved), what to do (a prompt for a coding agent), and since when it is seen. The evidence ranks likely causes; it does not prove them. why.status: ok = stored evidence is attached; none = no stored evidence for this place, or a memory/heat finding where React evidence does not explain the measure; no-profiles = the input runs explicitly recorded React export off; no-timings = profiles have no usable component timings; not-enough-runs = too few runs for component evidence. The last two values are reserved: the current stored-judgement reader does not infer them from missing evidence. Null component values were not stored; a new component has a null baseline. Counts are evidence, never a verdict. ${shortNote("get_finding")}`,
|
|
23654
|
+
inputSchema: { id: string2().regex(/^f_[0-9a-f]{16}$/).describe("Finding id from list_findings"), form: formInput },
|
|
23486
23655
|
annotations: READ_ONLY
|
|
23487
|
-
}, async ({ id }) => answer(() => getFinding(target, id), (f) => formatFinding(f, { prompt:
|
|
23656
|
+
}, async ({ id, form }) => answer(() => getFinding(target, id), (f) => formatFinding(f, { prompt: form !== "short", form })));
|
|
23488
23657
|
server.registerTool("compare_runs", {
|
|
23489
23658
|
title: "Compare two runs",
|
|
23490
23659
|
description: "Two runs side by side, window by window, with what differs in how they were measured. One run against one run: no statistics; use findings for verdicts.",
|
|
@@ -23493,15 +23662,16 @@ function createServer(target) {
|
|
|
23493
23662
|
}, async ({ a, b }) => answer(() => compareRuns(target, a, b), formatCompare));
|
|
23494
23663
|
server.registerTool("window_history", {
|
|
23495
23664
|
title: "One window on main over time",
|
|
23496
|
-
description:
|
|
23665
|
+
description: `Every recent main run of one window (a screen, tap or scroll) of a journey, oldest first, with commit titles, and the findings on that window. ${shortNote("window_history")}`,
|
|
23497
23666
|
inputSchema: {
|
|
23498
23667
|
journey: string2().describe("Journey key, e.g. live-ui"),
|
|
23499
|
-
window: string2().describe("
|
|
23668
|
+
window: string2().describe("Exact window id (press:live.chat.send); display names are not identifiers"),
|
|
23500
23669
|
phone: string2().optional(),
|
|
23501
|
-
limit: number2().int().min(1).max(200).optional()
|
|
23670
|
+
limit: number2().int().min(1).max(200).optional(),
|
|
23671
|
+
form: formInput
|
|
23502
23672
|
},
|
|
23503
23673
|
annotations: READ_ONLY
|
|
23504
|
-
}, async (a) => answer(() => windowHistory(target, a), formatHistory));
|
|
23674
|
+
}, async ({ form, ...a }) => answer(() => windowHistory(target, a), (h) => formatHistory(h, { form })));
|
|
23505
23675
|
server.registerTool("commits_between", {
|
|
23506
23676
|
title: "Measured commits between two points",
|
|
23507
23677
|
description: "The commits KeepSnappy measured between two points of a branch (run set ids or commit hashes), one entry per commit with its measuring sessions, title, author and time. Only measured commits; git log gives the rest.",
|
|
@@ -23510,7 +23680,7 @@ function createServer(target) {
|
|
|
23510
23680
|
}, async (a) => answer(() => commitsBetween(target, a), formatCommits));
|
|
23511
23681
|
server.registerTool("session_places", {
|
|
23512
23682
|
title: "Every place of one session",
|
|
23513
|
-
description:
|
|
23683
|
+
description: `The overall performance of one measuring session (a run set of one journey on one phone), place by place (a screen, tap or scroll), from the statistics stored for that session: per measure its median, spread (MAD), p10/p90 and range over the session's valid runs, and the components with the most own render time. Use it to find places worth optimising. It is not a verdict and not a comparison: for what got worse use list_findings, for one place over time window_history. Pick the session with run_set, or with journey (+ phone, + branch; default: the app's baseline branch (main)) for its newest closed run set that has a valid run. Places are ranked by measure (default render_ms) and sort (median, p90 or mad, highest first); window narrows by id or name; places without the measure are listed last. Page with offset (the answer gives next_offset). A session with no stored statistics says not_computed, one with statistics of another version says stale: both mean nothing is known about its places, not that they are fine. ${shortNote("session_places")}`,
|
|
23514
23684
|
inputSchema: {
|
|
23515
23685
|
run_set: string2().optional().describe("Run set id (the answer of other tools names it); otherwise journey picks the newest closed one"),
|
|
23516
23686
|
journey: string2().optional().describe("Journey key, e.g. live-ui"),
|
|
@@ -23521,10 +23691,11 @@ function createServer(target) {
|
|
|
23521
23691
|
window: string2().optional().describe('Only places whose id or name contains this, e.g. "chat send"'),
|
|
23522
23692
|
limit: number2().int().min(1).max(200).optional(),
|
|
23523
23693
|
offset: number2().int().min(0).optional().describe("Skip this many places; the answer gives next_offset"),
|
|
23524
|
-
top_components: number2().int().min(0).max(20).optional().describe("Components with the most own time listed per place (default 5)")
|
|
23694
|
+
top_components: number2().int().min(0).max(20).optional().describe("Components with the most own time listed per place (default 5)"),
|
|
23695
|
+
form: formInput
|
|
23525
23696
|
},
|
|
23526
23697
|
annotations: READ_ONLY
|
|
23527
|
-
}, async (a) => answer(() => sessionPlaces(target, a), formatSessionPlaces));
|
|
23698
|
+
}, async ({ form, ...a }) => answer(() => sessionPlaces(target, a), (r) => formatSessionPlaces(r, { form })));
|
|
23528
23699
|
server.registerTool("explain_layers", {
|
|
23529
23700
|
title: "Which layer answers which question",
|
|
23530
23701
|
description: "KeepSnappy’s decision layers (instrument, execution, single value, run health, PR vs main, main over time, access), the questions each answers, where to read it, and each layer’s certificate.",
|
|
@@ -23538,40 +23709,50 @@ function createServer(target) {
|
|
|
23538
23709
|
const mainDescription = "Main session tiers are verdicts vs the earlier sessions (baseline_sessions in the API answer); vs-previous is information, not a verdict.";
|
|
23539
23710
|
server.registerTool("main_sessions", {
|
|
23540
23711
|
title: "Main sessions and their tiers",
|
|
23541
|
-
description: `${mainDescription} List sessions with tier counts, memory and history restarts, or give run_set for that session’s places and reasons. sessions cannot be combined with run_set
|
|
23542
|
-
inputSchema: { ...mainLane, sessions: number2().int().min(1).max(20).optional(), run_set: string2().min(1).optional() },
|
|
23712
|
+
description: `${mainDescription} List sessions with tier counts, memory and history restarts, or give run_set for that session’s places and reasons. sessions cannot be combined with run_set. ${shortNote("main_sessions")}`,
|
|
23713
|
+
inputSchema: { ...mainLane, sessions: number2().int().min(1).max(20).optional(), run_set: string2().min(1).optional(), form: formInput },
|
|
23543
23714
|
annotations: READ_ONLY
|
|
23544
|
-
}, async ({ run_set, ...q }) => {
|
|
23715
|
+
}, async ({ run_set, form, ...q }) => {
|
|
23545
23716
|
if (run_set && q.sessions !== undefined)
|
|
23546
23717
|
return { isError: true, content: [{ type: "text", text: "400: sessions cannot be combined with run_set" }] };
|
|
23547
|
-
return run_set ? answer(() => mainSessionPlaces(target, run_set, { journey: q.journey, phone: q.phone }), formatMainSessionPlaces) : answer(() => mainSessions(target, q), formatMainSessions);
|
|
23718
|
+
return run_set ? answer(() => mainSessionPlaces(target, run_set, { journey: q.journey, phone: q.phone }), (p) => formatMainSessionPlaces(p, { form })) : answer(() => mainSessions(target, q), (h) => formatMainSessions(h, { form }));
|
|
23548
23719
|
});
|
|
23549
23720
|
server.registerTool("main_vs_previous", {
|
|
23550
23721
|
title: "Main session vs previous: stored medians",
|
|
23551
|
-
description: `${mainDescription} Before, after, delta, percent change and n from stored medians in one phone-model lane, with device ids per side; multiple devices on a side return devices_differ without pooling. new/gone are missing on one side, never zero. Flags history restarts. Latest session by default. Sorted by absolute percent change for measure
|
|
23722
|
+
description: `${mainDescription} Before, after, delta, percent change and n from stored medians in one phone-model lane, with device ids per side; multiple devices on a side return devices_differ without pooling. new/gone are missing on one side, never zero. Flags history restarts. Latest session by default. Sorted by absolute percent change for measure. ${shortNote("main_vs_previous")}`,
|
|
23552
23723
|
inputSchema: {
|
|
23553
23724
|
...mainLane,
|
|
23554
23725
|
run_set: string2().min(1).optional(),
|
|
23555
23726
|
measure: string2().min(1).max(300).optional(),
|
|
23556
23727
|
limit: number2().int().min(1).max(200).optional(),
|
|
23557
|
-
offset: number2().int().min(0).optional()
|
|
23728
|
+
offset: number2().int().min(0).optional(),
|
|
23729
|
+
form: formInput
|
|
23558
23730
|
},
|
|
23559
23731
|
annotations: READ_ONLY
|
|
23560
|
-
}, async (q) => answer(() => mainVsPrevious(target, q), formatMainVsPrevious));
|
|
23732
|
+
}, async ({ form, ...q }) => answer(() => mainVsPrevious(target, q), (m) => formatMainVsPrevious(m, { form })));
|
|
23561
23733
|
server.registerTool("place_trend", {
|
|
23562
23734
|
title: "One place over main sessions",
|
|
23563
|
-
description:
|
|
23735
|
+
description: `Stored per-session statistics of one place over main’s sessions, oldest first, with history restart markers. No verdict: use list_findings for a verdict, or session_places for every place in one session. Defaults to every stored measure at the place; request component refs explicitly. ${shortNote("place_trend")}`,
|
|
23564
23736
|
inputSchema: {
|
|
23565
23737
|
journey: string2().min(1).describe("Journey key or id"),
|
|
23566
23738
|
phone: string2().min(1).describe("Device id or part of the phone model; ambiguous matches are rejected"),
|
|
23567
23739
|
window: array(string2().min(1)).min(1).max(50).describe("Place ids or names"),
|
|
23568
23740
|
ref: array(string2().min(1)).min(1).max(200).optional().describe("Refs, e.g. m:render_ms or b:component:<key>:self_ms"),
|
|
23569
|
-
sessions: number2().int().min(1).max(30).optional().describe("Newest main sessions, default 10")
|
|
23741
|
+
sessions: number2().int().min(1).max(30).optional().describe("Newest main sessions, default 10"),
|
|
23742
|
+
form: formInput
|
|
23570
23743
|
},
|
|
23571
23744
|
annotations: READ_ONLY
|
|
23572
|
-
}, async (a) => answer(() => placeTrend(target, a), formatPlaceTrend));
|
|
23745
|
+
}, async ({ form, ...a }) => answer(() => placeTrend(target, a), (t) => formatPlaceTrend(t, { form })));
|
|
23746
|
+
server.registerTool("pr_status", {
|
|
23747
|
+
title: "Is my PR ok?",
|
|
23748
|
+
description: `Is this branch ok, in one call. The answer is blocked, nothing blocks with results to look at, or ok; per journey × phone it gives the deciding word and whether it blocks, the worst present findings with ids for get_finding, and dashboard links. A branch that was never measured, whose newest run set was not judged, or that could not be compared yet is not ok: the answer says which, with its reason. Use list_findings for every finding. ${shortNote("pr_status")}`,
|
|
23749
|
+
inputSchema: { branch: string2().min(1).describe("The PR branch, e.g. feat/keepsnappy-integration"), form: formInput },
|
|
23750
|
+
annotations: READ_ONLY
|
|
23751
|
+
}, async ({ branch, form }) => answer(() => branchStatus(target, branch), (s) => formatBranchStatus(s, { form })));
|
|
23573
23752
|
return server;
|
|
23574
23753
|
}
|
|
23754
|
+
var formInput = _enum(ANSWER_FORMS).optional().describe("full (default): the whole answer. short: what decides, without the evidence detail; its last line says what it left out. The same JSON either way");
|
|
23755
|
+
var shortNote = (tool) => `form short leaves out ${SHORT_FORM_NOTES[tool]}, and ends with a line saying so.`;
|
|
23575
23756
|
|
|
23576
23757
|
// packages/mcp/src/bin.ts
|
|
23577
23758
|
var key = process.env.KEEPSNAPPY_KEY;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keepsnappy/mcp",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.16",
|
|
4
4
|
"description": "KeepSnappy MCP server (stdio, read-only): performance findings, compare, window history and commits for coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|