@keepsnappy/mcp 0.1.0-alpha.13 → 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 +13 -6
- package/dist/keepsnappy-mcp.js +601 -85
- 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,16 +33,18 @@ 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=` |
|
|
43
46
|
| `window_history` | One window (screen, tap, scroll) of a journey on main over time, run by run, with commit titles, plus the findings on it. | `GET /v1/history` |
|
|
47
|
+
| `session_places` | Every place (screen, tap, scroll) of one session (a run set of one journey on one phone) from its stored statistics: per measure the median, MAD, p10/p90 and range, and the components with the most own time. Not a verdict, not a comparison. Pick with `run_set`, or `journey` (+ `phone`, `branch`; default: the app's baseline branch (main)) for the newest closed run set with a valid run; rank by `measure` (default `render_ms`) and `sort` (`median`, `p90`, `mad`), narrow with `window`, page with `limit` / `offset` (the answer gives `next_offset`), `top_components` (default 5). A session with no stored statistics says `not_computed`, one with another version's `stale`: never zero. | `GET /v1/sessions/places` |
|
|
44
48
|
| `commits_between` | The commits KeepSnappy measured between two points (run set ids or commit hashes), one entry per commit with its sessions. | `GET /v1/commits` |
|
|
45
49
|
| `explain_layers` | Which of KeepSnappy's layers answers which question, and each layer's certificate (none issued yet). | `GET /v1/layers` |
|
|
46
50
|
|
|
@@ -52,14 +56,17 @@ An empty list is a clean result only when `measured.newest` exists and was judge
|
|
|
52
56
|
KeepSnappy measurement on branch X" or "the newest run set was not judged" otherwise. Query parameters the API does not know
|
|
53
57
|
are dropped, not refused (there is no `?commit=` filter).
|
|
54
58
|
|
|
55
|
-
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
|
|
56
64
|
(4 = PR vs main, 5 = main over time), its certificate (null until plan 51 certifies the layer) and its limits.
|
|
57
65
|
|
|
58
66
|
## Words
|
|
59
67
|
|
|
60
|
-
The one word table of `packages/
|
|
61
|
-
Can
|
|
62
|
-
("Worse (not blocking)"). The evidence ranks likely causes; it does not prove them.
|
|
68
|
+
The one word table of `packages/present/src/states.ts` (REQ-VER-24): **Worse / Slightly worse / Better / No change /
|
|
69
|
+
Can’t tell**, and "not blocking" as a badge on a Worse measured differently from main ("Worse (not blocking)"). The evidence ranks likely causes; it does not prove them.
|
|
63
70
|
|
|
64
71
|
## Develop
|
|
65
72
|
|
package/dist/keepsnappy-mcp.js
CHANGED
|
@@ -14067,9 +14067,13 @@ class ApiError extends Error {
|
|
|
14067
14067
|
}
|
|
14068
14068
|
async function get(t, path, query = {}) {
|
|
14069
14069
|
const u = new URL(path, t.url.endsWith("/") ? t.url : `${t.url}/`);
|
|
14070
|
-
for (const [k, v] of Object.entries(query))
|
|
14071
|
-
if (v
|
|
14070
|
+
for (const [k, v] of Object.entries(query)) {
|
|
14071
|
+
if (Array.isArray(v))
|
|
14072
|
+
for (const value of v)
|
|
14073
|
+
u.searchParams.append(k, value);
|
|
14074
|
+
else if (v !== undefined && v !== "")
|
|
14072
14075
|
u.searchParams.set(k, String(v));
|
|
14076
|
+
}
|
|
14073
14077
|
const r = await (t.fetch ?? fetch)(u, { headers: { "x-keepsnappy-key": t.key, accept: "application/json" } });
|
|
14074
14078
|
const text = await r.text();
|
|
14075
14079
|
const body = text ? JSON.parse(text) : {};
|
|
@@ -14082,11 +14086,32 @@ async function get(t, path, query = {}) {
|
|
|
14082
14086
|
return body;
|
|
14083
14087
|
}
|
|
14084
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 });
|
|
14085
14090
|
var getFinding = (t, id) => get(t, `v1/findings/${encodeURIComponent(id)}`);
|
|
14086
14091
|
var compareRuns = (t, a, b) => get(t, "v1/compare", { a, b });
|
|
14087
14092
|
var windowHistory = (t, q) => get(t, "v1/history", q);
|
|
14088
14093
|
var commitsBetween = (t, q) => get(t, "v1/commits", q);
|
|
14089
14094
|
var layers = (t) => get(t, "v1/layers");
|
|
14095
|
+
var sessionPlaces = (t, f) => get(t, "v1/sessions/places", {
|
|
14096
|
+
run_set: f.run_set,
|
|
14097
|
+
journey: f.journey,
|
|
14098
|
+
phone: f.phone,
|
|
14099
|
+
branch: f.branch,
|
|
14100
|
+
measure: f.measure,
|
|
14101
|
+
sort: f.sort,
|
|
14102
|
+
window: f.window,
|
|
14103
|
+
limit: f.limit,
|
|
14104
|
+
offset: f.offset || undefined,
|
|
14105
|
+
top_components: f.top_components
|
|
14106
|
+
});
|
|
14107
|
+
var SESSION_SORTS = ["median", "p90", "mad"];
|
|
14108
|
+
var mainSessions = (t, q) => get(t, "v1/main/sessions", { ...q });
|
|
14109
|
+
var mainSessionPlaces = (t, runSetId, q) => get(t, `v1/main/sessions/${encodeURIComponent(runSetId)}/places`, { ...q });
|
|
14110
|
+
var mainVsPrevious = (t, q) => {
|
|
14111
|
+
const { run_set, ...query } = q;
|
|
14112
|
+
return get(t, `v1/main/sessions/${encodeURIComponent(run_set ?? "latest")}/vs-previous`, query);
|
|
14113
|
+
};
|
|
14114
|
+
var placeTrend = (t, q) => get(t, "v1/places/trend", { ...q });
|
|
14090
14115
|
|
|
14091
14116
|
// node_modules/.bun/zod@4.6.7/node_modules/zod/v3/helpers/util.js
|
|
14092
14117
|
var util;
|
|
@@ -22924,6 +22949,13 @@ var NOT_FACTS = {
|
|
|
22924
22949
|
decimals: 0,
|
|
22925
22950
|
what: "Whether the test cleaned up the app’s memory once at the end of the journey, outside any measured window, before the end reading"
|
|
22926
22951
|
},
|
|
22952
|
+
"sdk.end_read": {
|
|
22953
|
+
unit: "flag",
|
|
22954
|
+
label: "End memory read taken on request after the clean-up (1 = yes)",
|
|
22955
|
+
short: "end memory read on request",
|
|
22956
|
+
decimals: 0,
|
|
22957
|
+
what: "Whether the end memory reading is the one the app took when the test asked for it right after the clean-up (1), or the clean-up’s own reading (0, an app or run without the request)"
|
|
22958
|
+
},
|
|
22927
22959
|
"sdk.appstate_changes": {
|
|
22928
22960
|
unit: "count",
|
|
22929
22961
|
label: "App went to the background or came back",
|
|
@@ -22963,29 +22995,43 @@ var NOT_FACTS = {
|
|
|
22963
22995
|
};
|
|
22964
22996
|
var isNotFactId = (id) => Object.hasOwn(NOT_FACTS, id);
|
|
22965
22997
|
var fieldDef = (id) => isMeasureId(id) ? MEASURES[id] : isNotFactId(id) ? NOT_FACTS[id] : undefined;
|
|
22966
|
-
|
|
22967
|
-
|
|
22968
|
-
|
|
22969
|
-
|
|
22970
|
-
|
|
22971
|
-
launch: "Launch screen",
|
|
22972
|
-
tap: "Tap",
|
|
22973
|
-
scroll: "Scroll",
|
|
22974
|
-
step: "Test step",
|
|
22975
|
-
journey: "Whole journey",
|
|
22976
|
-
background: "Background",
|
|
22977
|
-
after3s: "3 s after"
|
|
22998
|
+
var DIMENSIONS = {
|
|
22999
|
+
component: { label: "Component", rankBy: "self_ms" },
|
|
23000
|
+
updater: { label: "Update scheduled by", rankBy: "share_of_commits" },
|
|
23001
|
+
slow_commit_component: { label: "Component in the slowest commit", rankBy: "self_ms" },
|
|
23002
|
+
slow_commit_updater: { label: "Update of the slowest commit scheduled by", rankBy: "scheduled" }
|
|
22978
23003
|
};
|
|
22979
|
-
var
|
|
22980
|
-
|
|
22981
|
-
|
|
22982
|
-
|
|
22983
|
-
|
|
22984
|
-
|
|
22985
|
-
|
|
22986
|
-
|
|
23004
|
+
var BREAKDOWN_MEASURES = {
|
|
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 },
|
|
23007
|
+
total_ms: { unit: "ms", worse: "higher", label: "total render time", decimals: 1 },
|
|
23008
|
+
renders: { unit: "count", worse: "higher", label: "renders", decimals: 0 },
|
|
23009
|
+
mounts: { unit: "count", worse: "higher", label: "mounts", decimals: 0 },
|
|
23010
|
+
share_of_commits: { unit: "share", worse: "none", label: "share of commits", decimals: 0 },
|
|
23011
|
+
scheduled: { unit: "count", worse: "none", label: "updates scheduled", decimals: 0 }
|
|
22987
23012
|
};
|
|
23013
|
+
var HEAT_METRIC = "sampler.thermal.heats_up";
|
|
23014
|
+
var HEAT_LABEL = MEASURES[HEAT_METRIC].label;
|
|
23015
|
+
// packages/facts/dist/src/frame.js
|
|
23016
|
+
var LIMIT_FRAME_MS = 1000 / 60;
|
|
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
|
|
22988
23021
|
var GENERIC_GROUPS = new Set(["app", "tabs", "root"]);
|
|
23022
|
+
|
|
23023
|
+
// packages/present/dist/src/states.js
|
|
23024
|
+
var STATE_WORDS = {
|
|
23025
|
+
worse: "Worse",
|
|
23026
|
+
warning: "Slightly worse",
|
|
23027
|
+
better: "Better",
|
|
23028
|
+
"no-change": "No change",
|
|
23029
|
+
"cant-tell": "Can’t tell"
|
|
23030
|
+
};
|
|
23031
|
+
var INFO_WORDS = { heat: "Phone heated up" };
|
|
23032
|
+
var isWorseState = (state) => state === "worse";
|
|
23033
|
+
var NOT_BLOCKING = "not blocking";
|
|
23034
|
+
|
|
22989
23035
|
// packages/present/dist/src/values.js
|
|
22990
23036
|
var NO_VALUE = "–";
|
|
22991
23037
|
var FALLBACK_DECIMALS = 1;
|
|
@@ -23022,42 +23068,250 @@ function format(unit, decimals, v) {
|
|
|
23022
23068
|
const text = String(roundTo(v * shown.scale, decimals) + 0);
|
|
23023
23069
|
return shown.unit ? `${text} ${shown.unit}` : text;
|
|
23024
23070
|
}
|
|
23071
|
+
function valueText(measure, v) {
|
|
23072
|
+
const def = isMeasureId(measure) ? MEASURES[measure] : BREAKDOWN_MEASURES[measure];
|
|
23073
|
+
return format(def.unit, def.decimals, v);
|
|
23074
|
+
}
|
|
23025
23075
|
var KEY_SEPARATOR = ":";
|
|
23076
|
+
function breakdownMeasure(field) {
|
|
23077
|
+
const at = field.indexOf(KEY_SEPARATOR);
|
|
23078
|
+
if (at < 0 || !Object.hasOwn(DIMENSIONS, field.slice(0, at)))
|
|
23079
|
+
return null;
|
|
23080
|
+
const measure = field.slice(field.lastIndexOf(".") + 1);
|
|
23081
|
+
return Object.hasOwn(BREAKDOWN_MEASURES, measure) ? measure : null;
|
|
23082
|
+
}
|
|
23026
23083
|
function fieldValueText(field, v) {
|
|
23084
|
+
const breakdown = breakdownMeasure(field);
|
|
23085
|
+
if (breakdown)
|
|
23086
|
+
return valueText(breakdown, v);
|
|
23027
23087
|
const def = fieldDef(field.split(KEY_SEPARATOR)[0]);
|
|
23028
23088
|
return format(def?.unit, def?.decimals ?? FALLBACK_DECIMALS, v);
|
|
23029
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
|
+
};
|
|
23127
|
+
// packages/present/dist/src/words.js
|
|
23128
|
+
var REASON_WORDS = {
|
|
23129
|
+
"no-baseline": "there are no main runs on this phone and journey yet",
|
|
23130
|
+
"too-few-runs": "there are too few valid runs on a side",
|
|
23131
|
+
"judged-measures-missing": "the judged measures are missing on a side",
|
|
23132
|
+
"measured-differently": "the sides were measured differently",
|
|
23133
|
+
"runs-unknown": "a change that would count has no value in some runs, so it cannot be confirmed in every run",
|
|
23134
|
+
"facts-version-mismatch": "the sides were derived with different versions",
|
|
23135
|
+
"not-judged-on-simulators": "timing is not judged on simulators",
|
|
23136
|
+
"platforms-differ": "the sides ran on different platforms (iOS and Android are never compared)",
|
|
23137
|
+
"not-enough-main": "main does not have enough sessions on this phone and journey yet"
|
|
23138
|
+
};
|
|
23139
|
+
var waitingWords = (runs) => `${runs} main ${runs === 1 ? "run waits" : "runs wait"} for a reprocess`;
|
|
23140
|
+
// packages/present/dist/src/stability.js
|
|
23141
|
+
var UNSTABLE_WORDS = {
|
|
23142
|
+
short: "main varies too much here",
|
|
23143
|
+
label: `${STATE_WORDS["cant-tell"]} · main varies too much here`,
|
|
23144
|
+
line: "Main varies too much here to tell",
|
|
23145
|
+
ratioTip: "1.0 = main’s run-to-run noise alone would pass the warning step about once in 100 comparisons.",
|
|
23146
|
+
what: "Main’s own runs vary too much here to tell, so a comparison there would mostly measure noise. It is shown with its values, never judged, never blocks and is never counted. It is judged again once main settles."
|
|
23147
|
+
};
|
|
23148
|
+
// packages/present/dist/src/present.js
|
|
23149
|
+
var ASPECT_WORDS = {
|
|
23150
|
+
renderer: "React renderer",
|
|
23151
|
+
reactExport: "React export",
|
|
23152
|
+
causeSplit: "work split by its cause",
|
|
23153
|
+
windowModel: "window model",
|
|
23154
|
+
screenWindowModel: "screen window model",
|
|
23155
|
+
cpuSource: "CPU source",
|
|
23156
|
+
sdk: "SDK version",
|
|
23157
|
+
driver: "driver",
|
|
23158
|
+
driverVersion: "driver version",
|
|
23159
|
+
journeyVersion: "journey version",
|
|
23160
|
+
actionGc: "memory clean-up before each step",
|
|
23161
|
+
endGc: "memory clean-up at the journey end",
|
|
23162
|
+
cpuProfiling: "CPU profiling"
|
|
23163
|
+
};
|
|
23164
|
+
// packages/present/dist/src/main-history.js
|
|
23165
|
+
var MAIN_TIER_WORDS = {
|
|
23166
|
+
same: "In norm",
|
|
23167
|
+
better: STATE_WORDS.better,
|
|
23168
|
+
cant: STATE_WORDS["cant-tell"],
|
|
23169
|
+
slight: STATE_WORDS.warning,
|
|
23170
|
+
worse: STATE_WORDS.worse
|
|
23171
|
+
};
|
|
23172
|
+
var MAIN_TIER_RANK = { worse: 0, slight: 1, cant: 2, better: 3, same: 4 };
|
|
23173
|
+
function mainCantWords(reason, config) {
|
|
23174
|
+
if (!reason)
|
|
23175
|
+
return "";
|
|
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}` : ""}`;
|
|
23177
|
+
}
|
|
23178
|
+
var waitingSessionsWords = (sessions) => `${sessions} older main ${sessions === 1 ? "session waits" : "sessions wait"} for a reprocess`;
|
|
23179
|
+
function mainPlaceReasonWords(reason, config) {
|
|
23180
|
+
switch (reason.kind) {
|
|
23181
|
+
case "no_baseline":
|
|
23182
|
+
case "noisy_main":
|
|
23183
|
+
return mainCantWords(reason, config);
|
|
23184
|
+
case "higher":
|
|
23185
|
+
return "Higher than main’s earlier sessions";
|
|
23186
|
+
case "lower":
|
|
23187
|
+
return "Lower than main’s earlier sessions";
|
|
23188
|
+
case "missing_value":
|
|
23189
|
+
return REASON_WORDS["runs-unknown"];
|
|
23190
|
+
case "measured_differently":
|
|
23191
|
+
return REASON_WORDS["measured-differently"];
|
|
23192
|
+
case "core":
|
|
23193
|
+
return reason.codes.map((code) => REASON_WORDS[code]).join("; ");
|
|
23194
|
+
}
|
|
23195
|
+
}
|
|
23196
|
+
var MAIN_SUMMARY_PENDING = "Runs received; the summary is not computed yet.";
|
|
23197
|
+
var MAIN_NEWEST_SUMMARY_PENDING = `Newest: ${MAIN_SUMMARY_PENDING}`;
|
|
23198
|
+
var SHORT_ASPECT_WORDS = {
|
|
23199
|
+
...ASPECT_WORDS,
|
|
23200
|
+
sdk: "SDK",
|
|
23201
|
+
renderer: "Renderer",
|
|
23202
|
+
endGc: "End GC",
|
|
23203
|
+
causeSplit: "Cause split",
|
|
23204
|
+
driver: "Driver",
|
|
23205
|
+
driverVersion: "Driver",
|
|
23206
|
+
journeyVersion: "Journey"
|
|
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
|
+
}
|
|
23030
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
|
+
}
|
|
23031
23286
|
var LABEL_W = 14;
|
|
23032
23287
|
var line = (label, text) => ` ${label.padEnd(LABEL_W)}${text}`;
|
|
23033
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.";
|
|
23034
23290
|
function formatFinding(f, opts = {}) {
|
|
23035
|
-
const
|
|
23036
|
-
|
|
23037
|
-
|
|
23038
|
-
|
|
23039
|
-
|
|
23040
|
-
|
|
23041
|
-
|
|
23042
|
-
line("
|
|
23043
|
-
line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`),
|
|
23044
|
-
line("Id", f.id)
|
|
23045
|
-
];
|
|
23046
|
-
if (f.why.components.length > 1) {
|
|
23047
|
-
out.push(line("Components", ""));
|
|
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));
|
|
23297
|
+
if (f.why.components.length) {
|
|
23298
|
+
r.full(line("Components", ""));
|
|
23048
23299
|
for (const c of f.why.components) {
|
|
23049
|
-
|
|
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)"}`);
|
|
23050
23301
|
}
|
|
23051
23302
|
}
|
|
23052
23303
|
if (opts.prompt)
|
|
23053
|
-
|
|
23054
|
-
return
|
|
23055
|
-
|
|
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
|
+
]);
|
|
23056
23310
|
}
|
|
23057
23311
|
var when = (iso) => iso.slice(0, 16).replace("T", " ");
|
|
23058
23312
|
var LANES_SHOWN = 8;
|
|
23059
23313
|
var STATE_WORDS2 = { present: "still present", resolved: "resolved", "not-judged": "not judged in the newest run set" };
|
|
23060
|
-
function
|
|
23314
|
+
function measuredLines(l, r) {
|
|
23061
23315
|
const f = l.filters;
|
|
23062
23316
|
const scope = [f.branch ?? "all branches", f.journey, f.phone].filter(Boolean).join(" · ");
|
|
23063
23317
|
const m = l.measured;
|
|
@@ -23065,58 +23319,107 @@ function measuredLine(l) {
|
|
|
23065
23319
|
if (!n) {
|
|
23066
23320
|
const on = f.branch ? f.branch === l.baseline_branch ? f.branch : `branch ${f.branch}` : "any branch";
|
|
23067
23321
|
const narrow = [f.journey, f.phone ? `phone “${f.phone}”` : null].filter(Boolean).join(", ");
|
|
23068
|
-
|
|
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 [];
|
|
23069
23324
|
}
|
|
23070
23325
|
const lane = (x) => {
|
|
23071
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}` : "";
|
|
23072
|
-
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)}` : ""}`;
|
|
23073
23328
|
};
|
|
23074
|
-
if (m.lanes.length === 1)
|
|
23075
|
-
|
|
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
|
+
}
|
|
23076
23333
|
const shown = m.lanes.slice(0, LANES_SHOWN);
|
|
23077
|
-
|
|
23078
|
-
|
|
23079
|
-
|
|
23080
|
-
|
|
23081
|
-
|
|
23082
|
-
`);
|
|
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"] : []];
|
|
23083
23341
|
}
|
|
23084
23342
|
function countsLine(l) {
|
|
23085
23343
|
const c = l.counts;
|
|
23086
23344
|
const parts = [
|
|
23087
|
-
c.worse ? `${c.worse} worse${c.not_blocking ? ` (${c.not_blocking} not blocking)` : ""}` : "",
|
|
23088
|
-
c.warn ?
|
|
23089
|
-
c.better ? `${c.better} better` : ""
|
|
23345
|
+
c.worse ? `${c.worse} ${STATE_WORDS.worse.toLowerCase()}${c.not_blocking ? ` (${c.not_blocking} not blocking)` : ""}` : "",
|
|
23346
|
+
c.warn ? `${c.warn} ${STATE_WORDS.warning.toLowerCase()}` : "",
|
|
23347
|
+
c.better ? `${c.better} ${STATE_WORDS.better.toLowerCase()}` : ""
|
|
23090
23348
|
].filter(Boolean);
|
|
23091
23349
|
const state = l.filters.state;
|
|
23092
23350
|
const unjudged = l.measured.lanes.filter((x) => !x.judged);
|
|
23093
|
-
const head = parts.length ? `${parts.join(", ")}${state === "all" ? "" : `, ${STATE_WORDS2[state] ?? state}`}` : state !== "present" && state !== "all" ? `No finding ${STATE_WORDS2[state] ?? state}` : unjudged.length === l.measured.lanes.length ? `No finding, but nothing was compared yet (${[...new Set(unjudged.map((x) => x.word))].join(", ")}): measure again before calling it clean` :
|
|
23351
|
+
const head = parts.length ? `${parts.join(", ")}${state === "all" ? "" : `, ${STATE_WORDS2[state] ?? state}`}` : state !== "present" && state !== "all" ? `No finding ${STATE_WORDS2[state] ?? state}` : unjudged.length === l.measured.lanes.length ? `No finding, but nothing was compared yet (${[...new Set(unjudged.map((x) => x.word))].join(", ")}): measure again before calling it clean` : `Nothing ${STATE_WORDS.worse.toLowerCase()}, nothing ${STATE_WORDS.warning.toLowerCase()}, nothing ${STATE_WORDS.better.toLowerCase()}`;
|
|
23094
23352
|
const heated = c.heat_runs ?? 0;
|
|
23095
|
-
const never = c.unsettled ?? 0;
|
|
23096
23353
|
const more = [
|
|
23097
|
-
|
|
23098
|
-
heated ? `${plural(heated, "run")} heated up the phone (run health, layer 3)` : "",
|
|
23354
|
+
heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural(heated, "run")} (run health, layer 3)` : "",
|
|
23099
23355
|
state === "present" && l.states.not_judged ? `${l.states.not_judged} more not judged in the newest run set (state not-judged)` : ""
|
|
23100
23356
|
].filter(Boolean);
|
|
23101
23357
|
const line = `${head}${more.length ? `; ${more.join("; ")}` : ""}.`;
|
|
23102
23358
|
return line.charAt(0).toUpperCase() + line.slice(1);
|
|
23103
23359
|
}
|
|
23104
|
-
function formatFindingList(l) {
|
|
23105
|
-
const
|
|
23360
|
+
function formatFindingList(l, opts = {}) {
|
|
23361
|
+
const r = new Rows;
|
|
23362
|
+
const lanesLeft = measuredLines(l, r);
|
|
23106
23363
|
if (l.measured.newest || l.total)
|
|
23107
|
-
|
|
23364
|
+
r.both(countsLine(l));
|
|
23108
23365
|
l.findings.forEach((f, i) => {
|
|
23109
|
-
|
|
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}`);
|
|
23110
23370
|
});
|
|
23111
23371
|
const shown = l.findings.length ? `Showing ${l.offset + 1}–${l.offset + l.findings.length} of ${l.total}` : "";
|
|
23112
23372
|
if (l.next_offset !== null)
|
|
23113
|
-
|
|
23373
|
+
r.both("", `${shown || `Showing none of ${l.total}`}; the rest from offset ${l.next_offset} (--offset ${l.next_offset}).`);
|
|
23114
23374
|
else if (l.offset > 0)
|
|
23115
|
-
|
|
23375
|
+
r.both("", shown ? `${shown}.` : `Offset ${l.offset} is past the last of ${l.total}.`);
|
|
23116
23376
|
if (l.findings.length)
|
|
23117
|
-
|
|
23118
|
-
|
|
23119
|
-
|
|
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"] : []]);
|
|
23120
23423
|
}
|
|
23121
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";
|
|
23122
23425
|
function formatCompare(r) {
|
|
@@ -23138,19 +23441,20 @@ function formatCompare(r) {
|
|
|
23138
23441
|
return out.join(`
|
|
23139
23442
|
`);
|
|
23140
23443
|
}
|
|
23141
|
-
function formatHistory(h) {
|
|
23142
|
-
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"}`);
|
|
23143
23447
|
const ms = h.measures.slice(0, 4);
|
|
23144
23448
|
if (h.points.length) {
|
|
23145
|
-
|
|
23146
|
-
|
|
23147
|
-
|
|
23148
|
-
}
|
|
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
|
+
});
|
|
23149
23453
|
}
|
|
23150
23454
|
if (h.findings.length)
|
|
23151
|
-
|
|
23152
|
-
|
|
23153
|
-
|
|
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")] : []);
|
|
23154
23458
|
}
|
|
23155
23459
|
function formatCommits(c) {
|
|
23156
23460
|
return [
|
|
@@ -23160,6 +23464,55 @@ function formatCommits(c) {
|
|
|
23160
23464
|
].join(`
|
|
23161
23465
|
`);
|
|
23162
23466
|
}
|
|
23467
|
+
var list = (xs) => xs.join(", ");
|
|
23468
|
+
function versionText(label, counts) {
|
|
23469
|
+
const values = Object.keys(counts).sort((a, b) => a === "unknown" ? -1 : b === "unknown" ? 1 : a < b ? -1 : a > b ? 1 : 0);
|
|
23470
|
+
const name = (k) => k === "unknown" ? "not recorded" : k;
|
|
23471
|
+
if (values.length === 0)
|
|
23472
|
+
return "";
|
|
23473
|
+
if (values.length === 1)
|
|
23474
|
+
return `${label} ${name(values[0])}`;
|
|
23475
|
+
return `${label} mixed: ${values.map((k) => `${name(k)} ×${counts[k]}`).join(", ")}`;
|
|
23476
|
+
}
|
|
23477
|
+
function formatSessionPlaces(p, opts = {}) {
|
|
23478
|
+
const s = p.session;
|
|
23479
|
+
const sha = s.commit?.sha?.slice(0, 7);
|
|
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";
|
|
23481
|
+
const v = s.versions;
|
|
23482
|
+
const codes = Object.keys(s.runs.invalid_codes);
|
|
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(", ")}.`);
|
|
23492
|
+
if (s.not_computed)
|
|
23493
|
+
return r.both("Not computed: this session has no stored statistics yet; nothing is known about its places (not zero).").render(opts.form);
|
|
23494
|
+
if (s.stale)
|
|
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);
|
|
23502
|
+
const value = m ? `${fieldValueText(by, m.median)} (p90 ${fieldValueText(by, m.p90)}, n ${m.n})` : `no ${by}`;
|
|
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
|
+
}
|
|
23509
|
+
});
|
|
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")] : []);
|
|
23515
|
+
}
|
|
23163
23516
|
function formatLayers(l) {
|
|
23164
23517
|
return [
|
|
23165
23518
|
l.note,
|
|
@@ -23169,6 +23522,100 @@ function formatLayers(l) {
|
|
|
23169
23522
|
].join(`
|
|
23170
23523
|
`);
|
|
23171
23524
|
}
|
|
23525
|
+
var MAIN_TIERS_WORST_FIRST = Object.keys(MAIN_TIER_RANK).sort((a, b) => MAIN_TIER_RANK[a] - MAIN_TIER_RANK[b]);
|
|
23526
|
+
function formatMainSessions(h, opts = {}) {
|
|
23527
|
+
const baseline = { baseline: { sessions: h.baseline_sessions } };
|
|
23528
|
+
const r = new Rows().both(`Main sessions: tiers are verdicts vs ${h.baseline_sessions === undefined ? "earlier sessions" : `the ${h.baseline_sessions} sessions before`}.`);
|
|
23529
|
+
if (!h.sessions.length)
|
|
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;
|
|
23534
|
+
for (const s of h.sessions) {
|
|
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")})`);
|
|
23537
|
+
if (!s.places)
|
|
23538
|
+
r.add(k, ` ${MAIN_SUMMARY_PENDING}${s.summary_reason ? ` (${s.summary_reason})` : ""}`);
|
|
23539
|
+
else
|
|
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)}`);
|
|
23543
|
+
if (s.memory)
|
|
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)}` : ""}`);
|
|
23545
|
+
for (const v of h.versions.filter((v) => v.from_run_set_id === s.run_set_id))
|
|
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
|
+
}
|
|
23552
|
+
}
|
|
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"] : []]);
|
|
23555
|
+
}
|
|
23556
|
+
function formatMainSessionPlaces(p, opts = {}) {
|
|
23557
|
+
const r = new Rows;
|
|
23558
|
+
if (p.status !== "ok")
|
|
23559
|
+
return r.both(`${MAIN_SUMMARY_PENDING}${p.summary_reason ? ` (${p.summary_reason})` : ""}`).render(opts.form);
|
|
23560
|
+
const baselineSessions = p.baseline_sessions;
|
|
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"] : []);
|
|
23566
|
+
}
|
|
23567
|
+
var signed = (n) => `${n > 0 ? "+" : ""}${Number(n.toFixed(3))}`;
|
|
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 ?? "–"}`;
|
|
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;
|
|
23592
|
+
for (const session of t.sessions) {
|
|
23593
|
+
const k = newest.has(session.run_set_id);
|
|
23594
|
+
if (session.restarts_known === false)
|
|
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++;
|
|
23600
|
+
}
|
|
23601
|
+
for (const place of t.places) {
|
|
23602
|
+
const value = place.sessions.find((s) => s.run_set_id === session.run_set_id);
|
|
23603
|
+
if (!value)
|
|
23604
|
+
continue;
|
|
23605
|
+
const unit = place.unit ? ` ${place.unit}` : "";
|
|
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}`);
|
|
23609
|
+
}
|
|
23610
|
+
}
|
|
23611
|
+
if (!t.sessions.length)
|
|
23612
|
+
r.both("No main sessions.");
|
|
23613
|
+
if (t.not_computed.length)
|
|
23614
|
+
r.both(`Not computed: ${t.not_computed.join(", ")}`);
|
|
23615
|
+
if (t.stale.length)
|
|
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")] : []]);
|
|
23618
|
+
}
|
|
23172
23619
|
|
|
23173
23620
|
// packages/mcp/src/server.ts
|
|
23174
23621
|
var SERVER_NAME = "keepsnappy";
|
|
@@ -23188,7 +23635,7 @@ function createServer(target) {
|
|
|
23188
23635
|
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
23189
23636
|
server.registerTool("list_findings", {
|
|
23190
23637
|
title: "List performance findings",
|
|
23191
|
-
description: "What got worse (Worse /
|
|
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")}`,
|
|
23192
23639
|
inputSchema: {
|
|
23193
23640
|
branch: string2().optional().describe("A PR branch, e.g. feat/keepsnappy-integration"),
|
|
23194
23641
|
main: boolean2().optional().describe("Findings of main over time instead of a branch"),
|
|
@@ -23196,16 +23643,17 @@ function createServer(target) {
|
|
|
23196
23643
|
phone: string2().optional().describe('Part of the phone model, e.g. "iPhone 15"'),
|
|
23197
23644
|
state: _enum(["present", "resolved", "not-judged", "all"]).optional(),
|
|
23198
23645
|
limit: number2().int().min(1).max(200).optional(),
|
|
23199
|
-
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
|
|
23200
23648
|
},
|
|
23201
23649
|
annotations: READ_ONLY
|
|
23202
|
-
}, async (a) => answer(() => listFindings(target, a), formatFindingList));
|
|
23650
|
+
}, async ({ form, ...a }) => answer(() => listFindings(target, a), (l) => formatFindingList(l, { form })));
|
|
23203
23651
|
server.registerTool("get_finding", {
|
|
23204
23652
|
title: "One finding with evidence and a prompt",
|
|
23205
|
-
description:
|
|
23206
|
-
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 },
|
|
23207
23655
|
annotations: READ_ONLY
|
|
23208
|
-
}, 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 })));
|
|
23209
23657
|
server.registerTool("compare_runs", {
|
|
23210
23658
|
title: "Compare two runs",
|
|
23211
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.",
|
|
@@ -23214,29 +23662,97 @@ function createServer(target) {
|
|
|
23214
23662
|
}, async ({ a, b }) => answer(() => compareRuns(target, a, b), formatCompare));
|
|
23215
23663
|
server.registerTool("window_history", {
|
|
23216
23664
|
title: "One window on main over time",
|
|
23217
|
-
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")}`,
|
|
23218
23666
|
inputSchema: {
|
|
23219
23667
|
journey: string2().describe("Journey key, e.g. live-ui"),
|
|
23220
|
-
window: string2().describe("
|
|
23668
|
+
window: string2().describe("Exact window id (press:live.chat.send); display names are not identifiers"),
|
|
23221
23669
|
phone: string2().optional(),
|
|
23222
|
-
limit: number2().int().min(1).max(200).optional()
|
|
23670
|
+
limit: number2().int().min(1).max(200).optional(),
|
|
23671
|
+
form: formInput
|
|
23223
23672
|
},
|
|
23224
23673
|
annotations: READ_ONLY
|
|
23225
|
-
}, async (a) => answer(() => windowHistory(target, a), formatHistory));
|
|
23674
|
+
}, async ({ form, ...a }) => answer(() => windowHistory(target, a), (h) => formatHistory(h, { form })));
|
|
23226
23675
|
server.registerTool("commits_between", {
|
|
23227
23676
|
title: "Measured commits between two points",
|
|
23228
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.",
|
|
23229
23678
|
inputSchema: { from: string2().min(7), to: string2().min(7).optional(), branch: string2().optional().describe("Default: main") },
|
|
23230
23679
|
annotations: READ_ONLY
|
|
23231
23680
|
}, async (a) => answer(() => commitsBetween(target, a), formatCommits));
|
|
23681
|
+
server.registerTool("session_places", {
|
|
23682
|
+
title: "Every place of one session",
|
|
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")}`,
|
|
23684
|
+
inputSchema: {
|
|
23685
|
+
run_set: string2().optional().describe("Run set id (the answer of other tools names it); otherwise journey picks the newest closed one"),
|
|
23686
|
+
journey: string2().optional().describe("Journey key, e.g. live-ui"),
|
|
23687
|
+
phone: string2().optional().describe('Part of the phone model, e.g. "iPhone 15", or a device id'),
|
|
23688
|
+
branch: string2().optional().describe("With journey: the branch of the run set (default: the app's baseline branch); not with run_set"),
|
|
23689
|
+
measure: string2().optional().describe("The measure places are ranked by (default render_ms), e.g. commit_max_ms, renders, dur_ms"),
|
|
23690
|
+
sort: _enum(SESSION_SORTS).optional().describe("The statistic of the measure to rank by (default median)"),
|
|
23691
|
+
window: string2().optional().describe('Only places whose id or name contains this, e.g. "chat send"'),
|
|
23692
|
+
limit: number2().int().min(1).max(200).optional(),
|
|
23693
|
+
offset: number2().int().min(0).optional().describe("Skip this many places; the answer gives next_offset"),
|
|
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
|
|
23696
|
+
},
|
|
23697
|
+
annotations: READ_ONLY
|
|
23698
|
+
}, async ({ form, ...a }) => answer(() => sessionPlaces(target, a), (r) => formatSessionPlaces(r, { form })));
|
|
23232
23699
|
server.registerTool("explain_layers", {
|
|
23233
23700
|
title: "Which layer answers which question",
|
|
23234
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.",
|
|
23235
23702
|
inputSchema: {},
|
|
23236
23703
|
annotations: READ_ONLY
|
|
23237
23704
|
}, async () => answer(() => layers(target), formatLayers));
|
|
23705
|
+
const mainLane = {
|
|
23706
|
+
journey: string2().min(1).describe("Journey key or id"),
|
|
23707
|
+
phone: string2().min(1).describe("Exact device model, as in the dashboard, e.g. iPhone 15")
|
|
23708
|
+
};
|
|
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.";
|
|
23710
|
+
server.registerTool("main_sessions", {
|
|
23711
|
+
title: "Main sessions and their tiers",
|
|
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 },
|
|
23714
|
+
annotations: READ_ONLY
|
|
23715
|
+
}, async ({ run_set, form, ...q }) => {
|
|
23716
|
+
if (run_set && q.sessions !== undefined)
|
|
23717
|
+
return { isError: true, content: [{ type: "text", text: "400: sessions cannot be combined with run_set" }] };
|
|
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 }));
|
|
23719
|
+
});
|
|
23720
|
+
server.registerTool("main_vs_previous", {
|
|
23721
|
+
title: "Main session vs previous: stored medians",
|
|
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")}`,
|
|
23723
|
+
inputSchema: {
|
|
23724
|
+
...mainLane,
|
|
23725
|
+
run_set: string2().min(1).optional(),
|
|
23726
|
+
measure: string2().min(1).max(300).optional(),
|
|
23727
|
+
limit: number2().int().min(1).max(200).optional(),
|
|
23728
|
+
offset: number2().int().min(0).optional(),
|
|
23729
|
+
form: formInput
|
|
23730
|
+
},
|
|
23731
|
+
annotations: READ_ONLY
|
|
23732
|
+
}, async ({ form, ...q }) => answer(() => mainVsPrevious(target, q), (m) => formatMainVsPrevious(m, { form })));
|
|
23733
|
+
server.registerTool("place_trend", {
|
|
23734
|
+
title: "One place over main sessions",
|
|
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")}`,
|
|
23736
|
+
inputSchema: {
|
|
23737
|
+
journey: string2().min(1).describe("Journey key or id"),
|
|
23738
|
+
phone: string2().min(1).describe("Device id or part of the phone model; ambiguous matches are rejected"),
|
|
23739
|
+
window: array(string2().min(1)).min(1).max(50).describe("Place ids or names"),
|
|
23740
|
+
ref: array(string2().min(1)).min(1).max(200).optional().describe("Refs, e.g. m:render_ms or b:component:<key>:self_ms"),
|
|
23741
|
+
sessions: number2().int().min(1).max(30).optional().describe("Newest main sessions, default 10"),
|
|
23742
|
+
form: formInput
|
|
23743
|
+
},
|
|
23744
|
+
annotations: READ_ONLY
|
|
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 })));
|
|
23238
23752
|
return server;
|
|
23239
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.`;
|
|
23240
23756
|
|
|
23241
23757
|
// packages/mcp/src/bin.ts
|
|
23242
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",
|