@keepsnappy/mcp 0.1.0-alpha.13 → 0.1.0-alpha.15
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 +3 -3
- package/dist/keepsnappy-mcp.js +348 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ From a KeepSnappy checkout instead of npm: `"command": "bun", "args": ["<keepsna
|
|
|
41
41
|
| `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
42
|
| `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
43
|
| `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` |
|
|
44
|
+
| `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
45
|
| `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
46
|
| `explain_layers` | Which of KeepSnappy's layers answers which question, and each layer's certificate (none issued yet). | `GET /v1/layers` |
|
|
46
47
|
|
|
@@ -57,9 +58,8 @@ Every tool returns the text the CLI prints and the API's JSON as structured cont
|
|
|
57
58
|
|
|
58
59
|
## Words
|
|
59
60
|
|
|
60
|
-
The one word table of `packages/
|
|
61
|
-
Can
|
|
62
|
-
("Worse (not blocking)"). The evidence ranks likely causes; it does not prove them.
|
|
61
|
+
The one word table of `packages/present/src/states.ts` (REQ-VER-24): **Worse / Slightly worse / Better / No change /
|
|
62
|
+
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
63
|
|
|
64
64
|
## Develop
|
|
65
65
|
|
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) : {};
|
|
@@ -14087,6 +14091,26 @@ var compareRuns = (t, a, b) => get(t, "v1/compare", { a, b });
|
|
|
14087
14091
|
var windowHistory = (t, q) => get(t, "v1/history", q);
|
|
14088
14092
|
var commitsBetween = (t, q) => get(t, "v1/commits", q);
|
|
14089
14093
|
var layers = (t) => get(t, "v1/layers");
|
|
14094
|
+
var sessionPlaces = (t, f) => get(t, "v1/sessions/places", {
|
|
14095
|
+
run_set: f.run_set,
|
|
14096
|
+
journey: f.journey,
|
|
14097
|
+
phone: f.phone,
|
|
14098
|
+
branch: f.branch,
|
|
14099
|
+
measure: f.measure,
|
|
14100
|
+
sort: f.sort,
|
|
14101
|
+
window: f.window,
|
|
14102
|
+
limit: f.limit,
|
|
14103
|
+
offset: f.offset || undefined,
|
|
14104
|
+
top_components: f.top_components
|
|
14105
|
+
});
|
|
14106
|
+
var SESSION_SORTS = ["median", "p90", "mad"];
|
|
14107
|
+
var mainSessions = (t, q) => get(t, "v1/main/sessions", { ...q });
|
|
14108
|
+
var mainSessionPlaces = (t, runSetId, q) => get(t, `v1/main/sessions/${encodeURIComponent(runSetId)}/places`, { ...q });
|
|
14109
|
+
var mainVsPrevious = (t, q) => {
|
|
14110
|
+
const { run_set, ...query } = q;
|
|
14111
|
+
return get(t, `v1/main/sessions/${encodeURIComponent(run_set ?? "latest")}/vs-previous`, query);
|
|
14112
|
+
};
|
|
14113
|
+
var placeTrend = (t, q) => get(t, "v1/places/trend", { ...q });
|
|
14090
14114
|
|
|
14091
14115
|
// node_modules/.bun/zod@4.6.7/node_modules/zod/v3/helpers/util.js
|
|
14092
14116
|
var util;
|
|
@@ -22924,6 +22948,13 @@ var NOT_FACTS = {
|
|
|
22924
22948
|
decimals: 0,
|
|
22925
22949
|
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
22950
|
},
|
|
22951
|
+
"sdk.end_read": {
|
|
22952
|
+
unit: "flag",
|
|
22953
|
+
label: "End memory read taken on request after the clean-up (1 = yes)",
|
|
22954
|
+
short: "end memory read on request",
|
|
22955
|
+
decimals: 0,
|
|
22956
|
+
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)"
|
|
22957
|
+
},
|
|
22927
22958
|
"sdk.appstate_changes": {
|
|
22928
22959
|
unit: "count",
|
|
22929
22960
|
label: "App went to the background or came back",
|
|
@@ -22963,6 +22994,22 @@ var NOT_FACTS = {
|
|
|
22963
22994
|
};
|
|
22964
22995
|
var isNotFactId = (id) => Object.hasOwn(NOT_FACTS, id);
|
|
22965
22996
|
var fieldDef = (id) => isMeasureId(id) ? MEASURES[id] : isNotFactId(id) ? NOT_FACTS[id] : undefined;
|
|
22997
|
+
var DIMENSIONS = {
|
|
22998
|
+
component: { label: "Component", rankBy: "self_ms" },
|
|
22999
|
+
updater: { label: "Update scheduled by", rankBy: "share_of_commits" },
|
|
23000
|
+
slow_commit_component: { label: "Component in the slowest commit", rankBy: "self_ms" },
|
|
23001
|
+
slow_commit_updater: { label: "Update of the slowest commit scheduled by", rankBy: "scheduled" }
|
|
23002
|
+
};
|
|
23003
|
+
var BREAKDOWN_MEASURES = {
|
|
23004
|
+
self_ms: { unit: "ms", worse: "higher", label: "render time", decimals: 1 },
|
|
23005
|
+
total_ms: { unit: "ms", worse: "higher", label: "total render time", decimals: 1 },
|
|
23006
|
+
renders: { unit: "count", worse: "higher", label: "renders", decimals: 0 },
|
|
23007
|
+
mounts: { unit: "count", worse: "higher", label: "mounts", decimals: 0 },
|
|
23008
|
+
share_of_commits: { unit: "share", worse: "none", label: "share of commits", decimals: 0 },
|
|
23009
|
+
scheduled: { unit: "count", worse: "none", label: "updates scheduled", decimals: 0 }
|
|
23010
|
+
};
|
|
23011
|
+
var HEAT_METRIC = "sampler.thermal.heats_up";
|
|
23012
|
+
var HEAT_LABEL = MEASURES[HEAT_METRIC].label;
|
|
22966
23013
|
// packages/facts/dist/src/frame.js
|
|
22967
23014
|
var LIMIT_FRAME_MS = 1000 / 60;
|
|
22968
23015
|
// packages/present/dist/src/places.js
|
|
@@ -22986,6 +23033,15 @@ var KIND_WORD = {
|
|
|
22986
23033
|
background: PLACE_KIND_WORDS.background
|
|
22987
23034
|
};
|
|
22988
23035
|
var GENERIC_GROUPS = new Set(["app", "tabs", "root"]);
|
|
23036
|
+
// packages/present/dist/src/states.js
|
|
23037
|
+
var STATE_WORDS = {
|
|
23038
|
+
worse: "Worse",
|
|
23039
|
+
warning: "Slightly worse",
|
|
23040
|
+
better: "Better",
|
|
23041
|
+
"no-change": "No change",
|
|
23042
|
+
"cant-tell": "Can’t tell"
|
|
23043
|
+
};
|
|
23044
|
+
var INFO_WORDS = { heat: "Phone heated up" };
|
|
22989
23045
|
// packages/present/dist/src/values.js
|
|
22990
23046
|
var NO_VALUE = "–";
|
|
22991
23047
|
var FALLBACK_DECIMALS = 1;
|
|
@@ -23022,11 +23078,104 @@ function format(unit, decimals, v) {
|
|
|
23022
23078
|
const text = String(roundTo(v * shown.scale, decimals) + 0);
|
|
23023
23079
|
return shown.unit ? `${text} ${shown.unit}` : text;
|
|
23024
23080
|
}
|
|
23081
|
+
function valueText(measure, v) {
|
|
23082
|
+
const def = isMeasureId(measure) ? MEASURES[measure] : BREAKDOWN_MEASURES[measure];
|
|
23083
|
+
return format(def.unit, def.decimals, v);
|
|
23084
|
+
}
|
|
23025
23085
|
var KEY_SEPARATOR = ":";
|
|
23086
|
+
function breakdownMeasure(field) {
|
|
23087
|
+
const at = field.indexOf(KEY_SEPARATOR);
|
|
23088
|
+
if (at < 0 || !Object.hasOwn(DIMENSIONS, field.slice(0, at)))
|
|
23089
|
+
return null;
|
|
23090
|
+
const measure = field.slice(field.lastIndexOf(".") + 1);
|
|
23091
|
+
return Object.hasOwn(BREAKDOWN_MEASURES, measure) ? measure : null;
|
|
23092
|
+
}
|
|
23026
23093
|
function fieldValueText(field, v) {
|
|
23094
|
+
const breakdown = breakdownMeasure(field);
|
|
23095
|
+
if (breakdown)
|
|
23096
|
+
return valueText(breakdown, v);
|
|
23027
23097
|
const def = fieldDef(field.split(KEY_SEPARATOR)[0]);
|
|
23028
23098
|
return format(def?.unit, def?.decimals ?? FALLBACK_DECIMALS, v);
|
|
23029
23099
|
}
|
|
23100
|
+
// packages/present/dist/src/words.js
|
|
23101
|
+
var REASON_WORDS = {
|
|
23102
|
+
"no-baseline": "there are no main runs on this phone and journey yet",
|
|
23103
|
+
"too-few-runs": "there are too few valid runs on a side",
|
|
23104
|
+
"judged-measures-missing": "the judged measures are missing on a side",
|
|
23105
|
+
"measured-differently": "the sides were measured differently",
|
|
23106
|
+
"runs-unknown": "a change that would count has no value in some runs, so it cannot be confirmed in every run",
|
|
23107
|
+
"facts-version-mismatch": "the sides were derived with different versions",
|
|
23108
|
+
"not-judged-on-simulators": "timing is not judged on simulators",
|
|
23109
|
+
"platforms-differ": "the sides ran on different platforms (iOS and Android are never compared)",
|
|
23110
|
+
"not-enough-main": "main does not have enough sessions on this phone and journey yet"
|
|
23111
|
+
};
|
|
23112
|
+
// packages/present/dist/src/stability.js
|
|
23113
|
+
var UNSTABLE_WORDS = {
|
|
23114
|
+
short: "main varies too much here",
|
|
23115
|
+
label: `${STATE_WORDS["cant-tell"]} · main varies too much here`,
|
|
23116
|
+
line: "Main varies too much here to tell",
|
|
23117
|
+
ratioTip: "1.0 = main’s run-to-run noise alone would pass the warning step about once in 100 comparisons.",
|
|
23118
|
+
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."
|
|
23119
|
+
};
|
|
23120
|
+
// packages/present/dist/src/present.js
|
|
23121
|
+
var ASPECT_WORDS = {
|
|
23122
|
+
renderer: "React renderer",
|
|
23123
|
+
reactExport: "React export",
|
|
23124
|
+
causeSplit: "work split by its cause",
|
|
23125
|
+
windowModel: "window model",
|
|
23126
|
+
screenWindowModel: "screen window model",
|
|
23127
|
+
cpuSource: "CPU source",
|
|
23128
|
+
sdk: "SDK version",
|
|
23129
|
+
driver: "driver",
|
|
23130
|
+
driverVersion: "driver version",
|
|
23131
|
+
journeyVersion: "journey version",
|
|
23132
|
+
actionGc: "memory clean-up before each step",
|
|
23133
|
+
endGc: "memory clean-up at the journey end",
|
|
23134
|
+
cpuProfiling: "CPU profiling"
|
|
23135
|
+
};
|
|
23136
|
+
// packages/present/dist/src/main-history.js
|
|
23137
|
+
var MAIN_TIER_WORDS = {
|
|
23138
|
+
same: "In norm",
|
|
23139
|
+
better: STATE_WORDS.better,
|
|
23140
|
+
cant: STATE_WORDS["cant-tell"],
|
|
23141
|
+
slight: STATE_WORDS.warning,
|
|
23142
|
+
worse: STATE_WORDS.worse
|
|
23143
|
+
};
|
|
23144
|
+
var MAIN_TIER_RANK = { worse: 0, slight: 1, cant: 2, better: 3, same: 4 };
|
|
23145
|
+
function mainCantWords(reason, config) {
|
|
23146
|
+
if (!reason)
|
|
23147
|
+
return "";
|
|
23148
|
+
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
|
+
}
|
|
23150
|
+
function mainPlaceReasonWords(reason, config) {
|
|
23151
|
+
switch (reason.kind) {
|
|
23152
|
+
case "no_baseline":
|
|
23153
|
+
case "noisy_main":
|
|
23154
|
+
return mainCantWords(reason, config);
|
|
23155
|
+
case "higher":
|
|
23156
|
+
return "Higher than main’s earlier sessions";
|
|
23157
|
+
case "lower":
|
|
23158
|
+
return "Lower than main’s earlier sessions";
|
|
23159
|
+
case "missing_value":
|
|
23160
|
+
return REASON_WORDS["runs-unknown"];
|
|
23161
|
+
case "measured_differently":
|
|
23162
|
+
return REASON_WORDS["measured-differently"];
|
|
23163
|
+
case "core":
|
|
23164
|
+
return reason.codes.map((code) => REASON_WORDS[code]).join("; ");
|
|
23165
|
+
}
|
|
23166
|
+
}
|
|
23167
|
+
var MAIN_SUMMARY_PENDING = "Runs received; the summary is not computed yet.";
|
|
23168
|
+
var MAIN_NEWEST_SUMMARY_PENDING = `Newest: ${MAIN_SUMMARY_PENDING}`;
|
|
23169
|
+
var SHORT_ASPECT_WORDS = {
|
|
23170
|
+
...ASPECT_WORDS,
|
|
23171
|
+
sdk: "SDK",
|
|
23172
|
+
renderer: "Renderer",
|
|
23173
|
+
endGc: "End GC",
|
|
23174
|
+
causeSplit: "Cause split",
|
|
23175
|
+
driver: "Driver",
|
|
23176
|
+
driverVersion: "Driver",
|
|
23177
|
+
journeyVersion: "Journey"
|
|
23178
|
+
};
|
|
23030
23179
|
// packages/mcp/src/answer.ts
|
|
23031
23180
|
var LABEL_W = 14;
|
|
23032
23181
|
var line = (label, text) => ` ${label.padEnd(LABEL_W)}${text}`;
|
|
@@ -23043,10 +23192,10 @@ function formatFinding(f, opts = {}) {
|
|
|
23043
23192
|
line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`),
|
|
23044
23193
|
line("Id", f.id)
|
|
23045
23194
|
];
|
|
23046
|
-
if (f.why.components.length
|
|
23195
|
+
if (f.why.components.length) {
|
|
23047
23196
|
out.push(line("Components", ""));
|
|
23048
23197
|
for (const c of f.why.components) {
|
|
23049
|
-
out.push(` ${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)"}`);
|
|
23198
|
+
out.push(` ${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
23199
|
}
|
|
23051
23200
|
}
|
|
23052
23201
|
if (opts.prompt)
|
|
@@ -23084,18 +23233,16 @@ function measuredLine(l) {
|
|
|
23084
23233
|
function countsLine(l) {
|
|
23085
23234
|
const c = l.counts;
|
|
23086
23235
|
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` : ""
|
|
23236
|
+
c.worse ? `${c.worse} ${STATE_WORDS.worse.toLowerCase()}${c.not_blocking ? ` (${c.not_blocking} not blocking)` : ""}` : "",
|
|
23237
|
+
c.warn ? `${c.warn} ${STATE_WORDS.warning.toLowerCase()}` : "",
|
|
23238
|
+
c.better ? `${c.better} ${STATE_WORDS.better.toLowerCase()}` : ""
|
|
23090
23239
|
].filter(Boolean);
|
|
23091
23240
|
const state = l.filters.state;
|
|
23092
23241
|
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` :
|
|
23242
|
+
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
23243
|
const heated = c.heat_runs ?? 0;
|
|
23095
|
-
const never = c.unsettled ?? 0;
|
|
23096
23244
|
const more = [
|
|
23097
|
-
|
|
23098
|
-
heated ? `${plural(heated, "run")} heated up the phone (run health, layer 3)` : "",
|
|
23245
|
+
heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural(heated, "run")} (run health, layer 3)` : "",
|
|
23099
23246
|
state === "present" && l.states.not_judged ? `${l.states.not_judged} more not judged in the newest run set (state not-judged)` : ""
|
|
23100
23247
|
].filter(Boolean);
|
|
23101
23248
|
const line = `${head}${more.length ? `; ${more.join("; ")}` : ""}.`;
|
|
@@ -23160,6 +23307,61 @@ function formatCommits(c) {
|
|
|
23160
23307
|
].join(`
|
|
23161
23308
|
`);
|
|
23162
23309
|
}
|
|
23310
|
+
var list = (xs) => xs.join(", ");
|
|
23311
|
+
function versionText(label, counts) {
|
|
23312
|
+
const values = Object.keys(counts).sort((a, b) => a === "unknown" ? -1 : b === "unknown" ? 1 : a < b ? -1 : a > b ? 1 : 0);
|
|
23313
|
+
const name = (k) => k === "unknown" ? "not recorded" : k;
|
|
23314
|
+
if (values.length === 0)
|
|
23315
|
+
return "";
|
|
23316
|
+
if (values.length === 1)
|
|
23317
|
+
return `${label} ${name(values[0])}`;
|
|
23318
|
+
return `${label} mixed: ${values.map((k) => `${name(k)} ×${counts[k]}`).join(", ")}`;
|
|
23319
|
+
}
|
|
23320
|
+
function formatSessionPlaces(r) {
|
|
23321
|
+
const s = r.session;
|
|
23322
|
+
const sha = s.commit?.sha?.slice(0, 7);
|
|
23323
|
+
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
|
+
const v = s.versions;
|
|
23325
|
+
const codes = Object.keys(s.runs.invalid_codes);
|
|
23326
|
+
const head = [
|
|
23327
|
+
`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})`}.`,
|
|
23328
|
+
`Runs: ${s.runs.valid} valid${s.runs.invalid ? `, ${s.runs.invalid} invalid${codes.length ? ` (${list(codes)})` : ""}` : ""}. ${[
|
|
23329
|
+
versionText("SDK", v.sdk),
|
|
23330
|
+
versionText("driver", v.driver),
|
|
23331
|
+
versionText("driver version", v.driver_version),
|
|
23332
|
+
versionText("app", v.app_build),
|
|
23333
|
+
versionText("journey version", v.journey_version),
|
|
23334
|
+
`derivation ${v.derivation}`,
|
|
23335
|
+
`stats ${v.stats}`
|
|
23336
|
+
].filter(Boolean).join(", ")}.`
|
|
23337
|
+
];
|
|
23338
|
+
if (s.not_computed)
|
|
23339
|
+
return [...head, "Not computed: this session has no stored statistics yet; nothing is known about its places (not zero)."].join(`
|
|
23340
|
+
`);
|
|
23341
|
+
if (s.stale)
|
|
23342
|
+
return [...head, "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)."].join(`
|
|
23343
|
+
`);
|
|
23344
|
+
const by = r.filters.measure;
|
|
23345
|
+
const byLabel = r.places.flatMap((p) => p.measures).find((m) => m.measure === by)?.label ?? by;
|
|
23346
|
+
const ranked = r.places.map((p, i) => {
|
|
23347
|
+
const m = p.measures.find((x) => x.measure === by);
|
|
23348
|
+
const value = m ? `${fieldValueText(by, m.median)} (p90 ${fieldValueText(by, m.p90)}, n ${m.n})` : `no ${by}`;
|
|
23349
|
+
const comps = p.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")}` : ""})`);
|
|
23350
|
+
return [`${r.offset + i + 1}. ${p.window_name}: ${value}`, ...comps.length ? [` Components: ${list(comps)}`] : []];
|
|
23351
|
+
});
|
|
23352
|
+
const out = [
|
|
23353
|
+
...head,
|
|
23354
|
+
`Stored per-session statistics: no verdict, no comparison. Places by ${byLabel} (${r.filters.sort}), highest first${r.filters.window ? `, matching “${r.filters.window}”` : ""}${r.without_measure ? `; ${r.without_measure} of ${r.total} places ${r.without_measure === 1 ? "has" : "have"} no value for it` : ""}.`,
|
|
23355
|
+
"",
|
|
23356
|
+
...ranked.flat()
|
|
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
|
+
`);
|
|
23364
|
+
}
|
|
23163
23365
|
function formatLayers(l) {
|
|
23164
23366
|
return [
|
|
23165
23367
|
l.note,
|
|
@@ -23169,6 +23371,83 @@ function formatLayers(l) {
|
|
|
23169
23371
|
].join(`
|
|
23170
23372
|
`);
|
|
23171
23373
|
}
|
|
23374
|
+
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) {
|
|
23376
|
+
const baseline = { baseline: { sessions: h.baseline_sessions } };
|
|
23377
|
+
const out = [`Main sessions: tiers are verdicts vs ${h.baseline_sessions === undefined ? "earlier sessions" : `the ${h.baseline_sessions} sessions before`}.`];
|
|
23378
|
+
if (!h.sessions.length)
|
|
23379
|
+
out.push("No main sessions measured for this journey and phone.");
|
|
23380
|
+
for (const s of h.sessions) {
|
|
23381
|
+
out.push(`${when(s.at)} ${s.run_set_id} ${commitText(s.commit ? { ...s.commit, author: null } : null)} (${plural(s.runs, "run")})`);
|
|
23382
|
+
if (!s.places)
|
|
23383
|
+
out.push(` ${MAIN_SUMMARY_PENDING}${s.summary_reason ? ` (${s.summary_reason})` : ""}`);
|
|
23384
|
+
else
|
|
23385
|
+
out.push(` ${MAIN_TIERS_WORST_FIRST.map((tier) => `${MAIN_TIER_WORDS[tier]}: ${s.places[tier]}`).join("; ")}${s.cant_reason ? `; ${mainCantWords(s.cant_reason, baseline)}` : ""}`);
|
|
23386
|
+
if (s.memory)
|
|
23387
|
+
out.push(` 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
|
+
for (const v of h.versions.filter((v) => v.from_run_set_id === s.run_set_id))
|
|
23389
|
+
out.push(` Version: ${v.label}${v.cuts.length ? `; history restart: ${v.cuts.join(", ")}` : ""}`);
|
|
23390
|
+
for (const c of h.changes.filter((c) => c.from_run_set_id === s.run_set_id))
|
|
23391
|
+
out.push(` Main change: ${c.place} ${c.ref}, ${c.before_median ?? "–"} → ${c.after_median ?? "–"}`);
|
|
23392
|
+
}
|
|
23393
|
+
return out.join(`
|
|
23394
|
+
`);
|
|
23395
|
+
}
|
|
23396
|
+
function formatMainSessionPlaces(p) {
|
|
23397
|
+
if (p.status !== "ok")
|
|
23398
|
+
return `${MAIN_SUMMARY_PENDING}${p.summary_reason ? ` (${p.summary_reason})` : ""}`;
|
|
23399
|
+
const baselineSessions = p.baseline_sessions;
|
|
23400
|
+
return [
|
|
23401
|
+
`Main session places: tiers are verdicts vs ${p.baseline_sessions === undefined ? "earlier sessions" : `the ${p.baseline_sessions} sessions before`}.`,
|
|
23402
|
+
...p.places.map((p) => ` ${p.place} ${p.ref}: ${MAIN_TIER_WORDS[p.tier]}${p.reason ? `; ${mainPlaceReasonWords(p.reason, { baseline: { sessions: baselineSessions } })}` : ""}`)
|
|
23403
|
+
].join(`
|
|
23404
|
+
`);
|
|
23405
|
+
}
|
|
23406
|
+
var signed = (n) => `${n > 0 ? "+" : ""}${Number(n.toFixed(3))}`;
|
|
23407
|
+
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(r) {
|
|
23409
|
+
const out = [`Information only: main session ${r.run_set_id} vs ${r.previous_run_set_id ?? "no previous session"}.`, `Measure: ${r.measure}; ${r.status}.`];
|
|
23410
|
+
if (r.cut === "unknown")
|
|
23411
|
+
out.push("History restart: unknown; current summaries unavailable.");
|
|
23412
|
+
else if (r.cut)
|
|
23413
|
+
out.push(`History restart: ${r.cut.label}; ${r.cut.cuts.join(", ")}. Numbers cross this restart.`);
|
|
23414
|
+
if (r.not_computed.length)
|
|
23415
|
+
out.push(`not_computed: ${r.not_computed.join(", ")}`);
|
|
23416
|
+
if (r.stale.length)
|
|
23417
|
+
out.push(`stale: ${r.stale.join(", ")}`);
|
|
23418
|
+
if (r.journey)
|
|
23419
|
+
out.push("Whole journey:", previousLine(r.journey));
|
|
23420
|
+
out.push(...r.places.map(previousLine));
|
|
23421
|
+
if (r.next_offset !== null)
|
|
23422
|
+
out.push(`Showing ${r.offset + 1}–${r.offset + r.places.length} of ${r.total}; next offset ${r.next_offset}.`);
|
|
23423
|
+
return out.join(`
|
|
23424
|
+
`);
|
|
23425
|
+
}
|
|
23426
|
+
function formatPlaceTrend(t) {
|
|
23427
|
+
const out = [`${t.journey} · ${t.phone.model} · ${t.baseline_branch}: stored per-session statistics`];
|
|
23428
|
+
for (const session of t.sessions) {
|
|
23429
|
+
if (session.restarts_known === false)
|
|
23430
|
+
out.push(`History restarts unknown: ${session.run_set_id} (no current main summary in the history window)`);
|
|
23431
|
+
for (const restart of t.restarts.filter((r) => r.from_run_set_id === session.run_set_id)) {
|
|
23432
|
+
out.push(`History restarts: ${restart.label} (${restart.cuts.join(", ")})`);
|
|
23433
|
+
}
|
|
23434
|
+
for (const place of t.places) {
|
|
23435
|
+
const value = place.sessions.find((s) => s.run_set_id === session.run_set_id);
|
|
23436
|
+
if (!value)
|
|
23437
|
+
continue;
|
|
23438
|
+
const unit = place.unit ? ` ${place.unit}` : "";
|
|
23439
|
+
out.push(`${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
|
+
}
|
|
23441
|
+
}
|
|
23442
|
+
if (!t.sessions.length)
|
|
23443
|
+
out.push("No main sessions.");
|
|
23444
|
+
if (t.not_computed.length)
|
|
23445
|
+
out.push(`Not computed: ${t.not_computed.join(", ")}`);
|
|
23446
|
+
if (t.stale.length)
|
|
23447
|
+
out.push(`Stale: ${t.stale.join(", ")}`);
|
|
23448
|
+
return out.join(`
|
|
23449
|
+
`);
|
|
23450
|
+
}
|
|
23172
23451
|
|
|
23173
23452
|
// packages/mcp/src/server.ts
|
|
23174
23453
|
var SERVER_NAME = "keepsnappy";
|
|
@@ -23188,7 +23467,7 @@ function createServer(target) {
|
|
|
23188
23467
|
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
23189
23468
|
server.registerTool("list_findings", {
|
|
23190
23469
|
title: "List performance findings",
|
|
23191
|
-
description: "What got worse (Worse /
|
|
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.",
|
|
23192
23471
|
inputSchema: {
|
|
23193
23472
|
branch: string2().optional().describe("A PR branch, e.g. feat/keepsnappy-integration"),
|
|
23194
23473
|
main: boolean2().optional().describe("Findings of main over time instead of a branch"),
|
|
@@ -23202,7 +23481,7 @@ function createServer(target) {
|
|
|
23202
23481
|
}, async (a) => answer(() => listFindings(target, a), formatFindingList));
|
|
23203
23482
|
server.registerTool("get_finding", {
|
|
23204
23483
|
title: "One finding with evidence and a prompt",
|
|
23205
|
-
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.",
|
|
23484
|
+
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.",
|
|
23206
23485
|
inputSchema: { id: string2().regex(/^f_[0-9a-f]{16}$/).describe("Finding id from list_findings") },
|
|
23207
23486
|
annotations: READ_ONLY
|
|
23208
23487
|
}, async ({ id }) => answer(() => getFinding(target, id), (f) => formatFinding(f, { prompt: true })));
|
|
@@ -23229,12 +23508,68 @@ function createServer(target) {
|
|
|
23229
23508
|
inputSchema: { from: string2().min(7), to: string2().min(7).optional(), branch: string2().optional().describe("Default: main") },
|
|
23230
23509
|
annotations: READ_ONLY
|
|
23231
23510
|
}, async (a) => answer(() => commitsBetween(target, a), formatCommits));
|
|
23511
|
+
server.registerTool("session_places", {
|
|
23512
|
+
title: "Every place of one session",
|
|
23513
|
+
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.",
|
|
23514
|
+
inputSchema: {
|
|
23515
|
+
run_set: string2().optional().describe("Run set id (the answer of other tools names it); otherwise journey picks the newest closed one"),
|
|
23516
|
+
journey: string2().optional().describe("Journey key, e.g. live-ui"),
|
|
23517
|
+
phone: string2().optional().describe('Part of the phone model, e.g. "iPhone 15", or a device id'),
|
|
23518
|
+
branch: string2().optional().describe("With journey: the branch of the run set (default: the app's baseline branch); not with run_set"),
|
|
23519
|
+
measure: string2().optional().describe("The measure places are ranked by (default render_ms), e.g. commit_max_ms, renders, dur_ms"),
|
|
23520
|
+
sort: _enum(SESSION_SORTS).optional().describe("The statistic of the measure to rank by (default median)"),
|
|
23521
|
+
window: string2().optional().describe('Only places whose id or name contains this, e.g. "chat send"'),
|
|
23522
|
+
limit: number2().int().min(1).max(200).optional(),
|
|
23523
|
+
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)")
|
|
23525
|
+
},
|
|
23526
|
+
annotations: READ_ONLY
|
|
23527
|
+
}, async (a) => answer(() => sessionPlaces(target, a), formatSessionPlaces));
|
|
23232
23528
|
server.registerTool("explain_layers", {
|
|
23233
23529
|
title: "Which layer answers which question",
|
|
23234
23530
|
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
23531
|
inputSchema: {},
|
|
23236
23532
|
annotations: READ_ONLY
|
|
23237
23533
|
}, async () => answer(() => layers(target), formatLayers));
|
|
23534
|
+
const mainLane = {
|
|
23535
|
+
journey: string2().min(1).describe("Journey key or id"),
|
|
23536
|
+
phone: string2().min(1).describe("Exact device model, as in the dashboard, e.g. iPhone 15")
|
|
23537
|
+
};
|
|
23538
|
+
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
|
+
server.registerTool("main_sessions", {
|
|
23540
|
+
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() },
|
|
23543
|
+
annotations: READ_ONLY
|
|
23544
|
+
}, async ({ run_set, ...q }) => {
|
|
23545
|
+
if (run_set && q.sessions !== undefined)
|
|
23546
|
+
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);
|
|
23548
|
+
});
|
|
23549
|
+
server.registerTool("main_vs_previous", {
|
|
23550
|
+
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.`,
|
|
23552
|
+
inputSchema: {
|
|
23553
|
+
...mainLane,
|
|
23554
|
+
run_set: string2().min(1).optional(),
|
|
23555
|
+
measure: string2().min(1).max(300).optional(),
|
|
23556
|
+
limit: number2().int().min(1).max(200).optional(),
|
|
23557
|
+
offset: number2().int().min(0).optional()
|
|
23558
|
+
},
|
|
23559
|
+
annotations: READ_ONLY
|
|
23560
|
+
}, async (q) => answer(() => mainVsPrevious(target, q), formatMainVsPrevious));
|
|
23561
|
+
server.registerTool("place_trend", {
|
|
23562
|
+
title: "One place over main sessions",
|
|
23563
|
+
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.",
|
|
23564
|
+
inputSchema: {
|
|
23565
|
+
journey: string2().min(1).describe("Journey key or id"),
|
|
23566
|
+
phone: string2().min(1).describe("Device id or part of the phone model; ambiguous matches are rejected"),
|
|
23567
|
+
window: array(string2().min(1)).min(1).max(50).describe("Place ids or names"),
|
|
23568
|
+
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")
|
|
23570
|
+
},
|
|
23571
|
+
annotations: READ_ONLY
|
|
23572
|
+
}, async (a) => answer(() => placeTrend(target, a), formatPlaceTrend));
|
|
23238
23573
|
return server;
|
|
23239
23574
|
}
|
|
23240
23575
|
|
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.15",
|
|
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",
|