@ogygia/cli 0.0.0-stage → 0.11.0
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/LICENSE +21 -0
- package/README.md +27 -2
- package/ai/SKILL.md +363 -0
- package/dist/cli.mjs +40652 -0
- package/dist/mcp.js +1245 -0
- package/dist/perf/bisect.js +69 -0
- package/dist/perf/cli.js +221 -0
- package/dist/perf/compare.js +166 -0
- package/dist/perf/measure.js +102 -0
- package/dist/perf/report.js +55 -0
- package/package.json +59 -3
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { gzipSync } from "node:zlib";
|
|
4
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { parse } from "svelte/compiler";
|
|
8
|
+
import { CLIENT_BINDING_STUB, collect_flag_sites, flags_manifest, islandVirtualId, transformHost, wrapperVirtualId } from "@ogygia/core/internal/compiler";
|
|
9
|
+
// src/mcp.ts
|
|
10
|
+
/**
|
|
11
|
+
* `ogygia mcp` — a Model Context Protocol server that exposes the REAL ogygia compiler to an AI.
|
|
12
|
+
*
|
|
13
|
+
* The transform runs in plain Node (no browser, no WASM stub — the oxc parser auto-loads), so an AI
|
|
14
|
+
* client (Claude Desktop / Claude Code / any MCP host) can hand ogygia a `.svelte` source and get back
|
|
15
|
+
* the island map, the rewritten host, the wire shape, and any rule violations — the same currency the
|
|
16
|
+
* Observatory shows a human, in a form an AI can reason over.
|
|
17
|
+
*
|
|
18
|
+
* Transport is stdio, newline-delimited JSON-RPC 2.0 (the MCP stdio contract). Hand-rolled on purpose:
|
|
19
|
+
* zero runtime deps beyond ogygia's own compiler, so `npx ogygia mcp` just works wherever ogygia is
|
|
20
|
+
* installed. Everything human-facing goes to STDERR — stdout is the protocol channel and must stay clean.
|
|
21
|
+
*
|
|
22
|
+
* @packageDocumentation
|
|
23
|
+
*/
|
|
24
|
+
let profiler_reading;
|
|
25
|
+
async function load_profiler_reading() {
|
|
26
|
+
if (profiler_reading !== void 0) return profiler_reading;
|
|
27
|
+
try {
|
|
28
|
+
const from_app = createRequire(path.join(process.cwd(), "package.json"));
|
|
29
|
+
profiler_reading = await import(pathToFileURL(from_app.resolve("@ogygia/devtools/report")).href);
|
|
30
|
+
} catch {
|
|
31
|
+
profiler_reading = null;
|
|
32
|
+
}
|
|
33
|
+
return profiler_reading;
|
|
34
|
+
}
|
|
35
|
+
const TRAILING_SLASH_RE = /\/$/;
|
|
36
|
+
const HTTP_URL_RE = /^https?:\/\//i;
|
|
37
|
+
const LOGIN_FORM_RE = /id="og-login"/;
|
|
38
|
+
const HTML_TAG_G = /<[^>]+>/g;
|
|
39
|
+
const WS_G = /\s+/g;
|
|
40
|
+
const PLUS_G = /\+/g;
|
|
41
|
+
const SLASH_G = /\//g;
|
|
42
|
+
const TRAILING_EQUALS_RE = /=+$/;
|
|
43
|
+
const HASH_SUFFIX_RE = /#.*$/;
|
|
44
|
+
const BACKSLASH_G = /\\/g;
|
|
45
|
+
const UNKNOWN_PRESET_RE = /unknown preset/i;
|
|
46
|
+
const SVELTE_EXT_RE = /\.svelte$/;
|
|
47
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
48
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
49
|
+
const SERVER_NAME = "ogygia";
|
|
50
|
+
const MARK_KEYS = /* @__PURE__ */ new Set([
|
|
51
|
+
"wake",
|
|
52
|
+
"fetch",
|
|
53
|
+
"render",
|
|
54
|
+
"region",
|
|
55
|
+
"keep",
|
|
56
|
+
"preset",
|
|
57
|
+
"margin"
|
|
58
|
+
]);
|
|
59
|
+
/** A `render: 'deferred'` hole's timing (its `fetch`, default load); null for anything else. */
|
|
60
|
+
const hole_fetch = (attrs) => attrs.render === "deferred" ? String(attrs.fetch ?? "load") : null;
|
|
61
|
+
function build_ctx(ssr, route_csr = false) {
|
|
62
|
+
return {
|
|
63
|
+
root: "/repl",
|
|
64
|
+
libDir: "/repl/src/lib",
|
|
65
|
+
readFile: () => null,
|
|
66
|
+
pathModule: path,
|
|
67
|
+
dev: true,
|
|
68
|
+
virtualPathFor: (_host, iid) => islandVirtualId(iid),
|
|
69
|
+
wrapperPathFor: (_host, iid) => wrapperVirtualId(iid),
|
|
70
|
+
devUrlFor: (virtual) => "/@id/" + virtual,
|
|
71
|
+
visibleMargin: void 0,
|
|
72
|
+
presets: {},
|
|
73
|
+
importKeys: void 0,
|
|
74
|
+
idSalt: "",
|
|
75
|
+
linkVirtualIsland: true,
|
|
76
|
+
clientBindingStub: CLIENT_BINDING_STUB,
|
|
77
|
+
routeCsr: route_csr,
|
|
78
|
+
ssr
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Pull the marked imports (`import X from './X.svelte' with { … }`) out of a component's script. */
|
|
82
|
+
function parse_marks(source) {
|
|
83
|
+
let ast;
|
|
84
|
+
try {
|
|
85
|
+
ast = parse(source, { modern: true });
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
const bodies = [];
|
|
90
|
+
const inst = ast.instance;
|
|
91
|
+
const mod = ast.module;
|
|
92
|
+
if (inst?.content?.body) bodies.push(...inst.content.body);
|
|
93
|
+
if (mod?.content?.body) bodies.push(...mod.content.body);
|
|
94
|
+
const marks = [];
|
|
95
|
+
for (const node of bodies) {
|
|
96
|
+
if (node.type !== "ImportDeclaration") continue;
|
|
97
|
+
const attrs = {};
|
|
98
|
+
for (const a of node.attributes || []) {
|
|
99
|
+
const key = a.key?.name ?? a.key?.value;
|
|
100
|
+
if (key) attrs[key] = a.value?.value;
|
|
101
|
+
}
|
|
102
|
+
if (!Object.keys(attrs).some((k) => MARK_KEYS.has(k))) continue;
|
|
103
|
+
const local = node.specifiers?.[0]?.local?.name ?? "?";
|
|
104
|
+
const component = node.source.value;
|
|
105
|
+
marks.push({
|
|
106
|
+
local,
|
|
107
|
+
component,
|
|
108
|
+
attrs
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return marks;
|
|
112
|
+
}
|
|
113
|
+
const base = (p) => p ? p.split("?")[0].split("/").pop() || p : "";
|
|
114
|
+
/** A one-line, human/AI-legible label for what a mark's dials make it. */
|
|
115
|
+
function strategy_label(attrs) {
|
|
116
|
+
if (attrs.region === "raw") return "held region (raw — HTML only, ships no JS)";
|
|
117
|
+
const fetch = hole_fetch(attrs);
|
|
118
|
+
if (fetch === "interaction") return "on-demand server island (HTML fetched on the first hover/focus/touch inside, morphed in; ships no JS)";
|
|
119
|
+
if (fetch != null) return `server island (deferred, fetched on ${fetch})`;
|
|
120
|
+
if (attrs.render === "live") return "live region (baked, revalidates in background)";
|
|
121
|
+
if (attrs.wake === "none") return "lake (frozen server HTML, ships no JS)";
|
|
122
|
+
return `island (interactive, wakes on ${attrs.wake ?? "load"})${attrs.keep ? `, kept across nav as "${attrs.keep}"` : ""}`;
|
|
123
|
+
}
|
|
124
|
+
/** Run the real transform + merge in the marks → the island map an AI can read. Throws are the caller's. */
|
|
125
|
+
function compile(source, filename, ssr, route_csr) {
|
|
126
|
+
const id = `/repl/src/routes/${filename}`;
|
|
127
|
+
const result = transformHost(source, id, build_ctx(ssr, route_csr));
|
|
128
|
+
const marks = parse_marks(source);
|
|
129
|
+
const list = result?.islands ?? [];
|
|
130
|
+
const real_by_component = /* @__PURE__ */ new Map();
|
|
131
|
+
for (const isl of list) if (isl.componentPath) real_by_component.set(base(isl.componentPath), isl);
|
|
132
|
+
const islands = marks.map((m) => {
|
|
133
|
+
const hit = real_by_component.get(base(m.component));
|
|
134
|
+
return {
|
|
135
|
+
component: m.component,
|
|
136
|
+
local: m.local,
|
|
137
|
+
kind: hit?.kind ?? "(mark only)",
|
|
138
|
+
strategy: strategy_label(m.attrs),
|
|
139
|
+
attrs: m.attrs,
|
|
140
|
+
id: hit?.id ?? ""
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
return {
|
|
144
|
+
code: result?.code ?? source,
|
|
145
|
+
islands
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const text = (s) => ({ content: [{
|
|
149
|
+
type: "text",
|
|
150
|
+
text: s
|
|
151
|
+
}] });
|
|
152
|
+
const fail = (s) => ({
|
|
153
|
+
content: [{
|
|
154
|
+
type: "text",
|
|
155
|
+
text: s
|
|
156
|
+
}],
|
|
157
|
+
isError: true
|
|
158
|
+
});
|
|
159
|
+
const TOOLS = [
|
|
160
|
+
{
|
|
161
|
+
name: "ogygia_compile",
|
|
162
|
+
description: "Compile a Svelte component through the real ogygia transform (as it runs at build). Returns the island map (which imports became islands, with their render/wake dials and real ids) and the rewritten host module. Use this to see exactly what ogygia does to a component.",
|
|
163
|
+
inputSchema: {
|
|
164
|
+
type: "object",
|
|
165
|
+
properties: {
|
|
166
|
+
source: {
|
|
167
|
+
type: "string",
|
|
168
|
+
description: "The .svelte component source to compile."
|
|
169
|
+
},
|
|
170
|
+
filename: {
|
|
171
|
+
type: "string",
|
|
172
|
+
description: "File name for ids/errors (default App.svelte)."
|
|
173
|
+
},
|
|
174
|
+
csr: {
|
|
175
|
+
type: "boolean",
|
|
176
|
+
description: "true = compile the csr=true leg (ogygia steps aside, islands stripped to plain). Default false."
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
required: ["source"]
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: "ogygia_islands",
|
|
184
|
+
description: "The island map only (no rewritten code): each marked import → its primitive (island / lake / server island / live / held region), its render+wake dials, and its build id. The fast overview.",
|
|
185
|
+
inputSchema: {
|
|
186
|
+
type: "object",
|
|
187
|
+
properties: { source: {
|
|
188
|
+
type: "string",
|
|
189
|
+
description: "The .svelte component source."
|
|
190
|
+
} },
|
|
191
|
+
required: ["source"]
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: "ogygia_check",
|
|
196
|
+
description: "Check a component against ogygia rules. Runs the real transform and reports any [ogygia] build error it raises (writing to captured host state, illegal nesting, a non-literal macro argument, an island dynamic-import, …). Use this to validate a component an AI wrote before trusting it.",
|
|
197
|
+
inputSchema: {
|
|
198
|
+
type: "object",
|
|
199
|
+
properties: { source: {
|
|
200
|
+
type: "string",
|
|
201
|
+
description: "The .svelte component source to validate."
|
|
202
|
+
} },
|
|
203
|
+
required: ["source"]
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: "ogygia_explain",
|
|
208
|
+
description: "Explain, in prose, what happens to each marked component at runtime: where its HTML comes from, when (or whether) its JS runs, and how its props cross the island boundary. The teaching view.",
|
|
209
|
+
inputSchema: {
|
|
210
|
+
type: "object",
|
|
211
|
+
properties: { source: {
|
|
212
|
+
type: "string",
|
|
213
|
+
description: "The .svelte component source."
|
|
214
|
+
} },
|
|
215
|
+
required: ["source"]
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: "ogygia_debug",
|
|
220
|
+
description: "Debug a REAL running page. Loads a URL of your running ogygia app in a headless browser, lets its islands hydrate (scrolls to trigger `visible` islands; optionally clicks a selector to trigger an `interaction` one), then returns the ACTUAL runtime story per island from the devtools event bus: SSR → wire → connected → woke → hydrated (with timings), plus anomalies (SSR’d-but-never-connected, hydration failures), and for an app with a server transform any host the restore reached too late or could not put back exactly. Requires `devtools()` from `@ogygia/devtools/vite` listed beside `ogygia()` in the app’s vite.config (its dock on, the default) and Playwright installed. On the dev server the stream is always on; on a build, add `?og-devtools=on` to the URL so the server’s half of the story is in it too. Use this to see what really happened instead of reasoning from source.",
|
|
221
|
+
inputSchema: {
|
|
222
|
+
type: "object",
|
|
223
|
+
properties: {
|
|
224
|
+
url: {
|
|
225
|
+
type: "string",
|
|
226
|
+
description: "URL of a page on the running app (e.g. http://localhost:5173/blog)."
|
|
227
|
+
},
|
|
228
|
+
wait: {
|
|
229
|
+
type: "number",
|
|
230
|
+
description: "ms to wait for hydration to settle (default 2500, max 8000)."
|
|
231
|
+
},
|
|
232
|
+
scroll: {
|
|
233
|
+
type: "boolean",
|
|
234
|
+
description: "Scroll the page to trigger `visible` islands (default true)."
|
|
235
|
+
},
|
|
236
|
+
click: {
|
|
237
|
+
type: "string",
|
|
238
|
+
description: "Optional CSS selector to click, to wake an `interaction` island."
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
required: ["url"]
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: "ogygia_profile",
|
|
246
|
+
description: "Profile the SERVER-SIDE render of a route — runs the whole thing itself, nothing to download. ASK THE USER which URL to profile before calling (they prefer to name the host): a full URL like `http://localhost:5173/fr/fr` (local) or `https://app.com/fr/fr` (remote). Pass it as `url`. A bare path is accepted too, but then the tool just asks you for the URL (and lists any local server it can see). Records N renders and returns a digest: verdict (compute- vs io-bound), render p50, CPU findings, time-by-category, hottest functions + components (Svelte names each after its file), network, heap. Self-heals a stuck session (auto /reset + retry). Requires the profiler turned on: `devtools({ profiler: true })` from `@ogygia/devtools/vite` in the app’s vite.config (the tool tells you how if missing). Prod build = real numbers; dev is indicative (the digest says which). For a report you already downloaded, use ogygia_profile_open.",
|
|
247
|
+
inputSchema: {
|
|
248
|
+
type: "object",
|
|
249
|
+
properties: {
|
|
250
|
+
url: {
|
|
251
|
+
type: "string",
|
|
252
|
+
description: "Route to profile: a bare path (`/fr/fr`) = your local running server (auto-found), or a full URL (`https://app.com/fr/fr`) = that host."
|
|
253
|
+
},
|
|
254
|
+
runs: {
|
|
255
|
+
type: "number",
|
|
256
|
+
description: "Profiled renders to record (default 5, max 50). More = steadier median."
|
|
257
|
+
},
|
|
258
|
+
key: {
|
|
259
|
+
type: "string",
|
|
260
|
+
description: "Profiler secret (sent as the x-profiler-key header) — needed only when the app set one (prod/locked)."
|
|
261
|
+
},
|
|
262
|
+
origin: {
|
|
263
|
+
type: "string",
|
|
264
|
+
description: "Force a specific local origin (e.g. http://localhost:5173) when a bare path could match several running servers."
|
|
265
|
+
},
|
|
266
|
+
base: {
|
|
267
|
+
type: "string",
|
|
268
|
+
description: "Profiler mount path (default /__profiler)."
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
required: ["url"]
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: "ogygia_profile_open",
|
|
276
|
+
description: "Open a downloaded `.ogp` profile and return the same digest as ogygia_profile — verdict, render p50, CPU findings, time-by-category, hottest functions + components, network, heap. A `.ogp` is the encrypted trace you download when a live report can’t be kept (serverless/Amplify evict it, or the browser can’t render it). It decrypts entirely from the FILE — no running server, no profiler login. The file is AES-encrypted with the key it was exported with; pass that as `key`. If you don’t have it, ask the user for the export key and retry. That key is the ONLY thing needed — it is not the app’s profiler secret, and holding the file + its key already authorizes reading it.",
|
|
277
|
+
inputSchema: {
|
|
278
|
+
type: "object",
|
|
279
|
+
properties: {
|
|
280
|
+
file: {
|
|
281
|
+
type: "string",
|
|
282
|
+
description: "Path to the .ogp file (absolute, or relative to the MCP server cwd)."
|
|
283
|
+
},
|
|
284
|
+
key: {
|
|
285
|
+
type: "string",
|
|
286
|
+
description: "The export key the .ogp was made with. Omit only for a dev-key export; a wrong/absent key fails cleanly and asks for it."
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
required: ["file"]
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: "ogygia_scan",
|
|
294
|
+
description: "Scan a WHOLE ogygia project: walks a directory for .svelte files, runs the real transform on each, and returns the entire island architecture (every island / lake / server island / held region, by file, with its render+wake dials) PLUS a lint pass — hard [ogygia] errors and soft anti-patterns the transform allows (island-in-island, wake:none+deferred, interaction+deferred). Use it to understand or audit a real app, not one snippet.",
|
|
295
|
+
inputSchema: {
|
|
296
|
+
type: "object",
|
|
297
|
+
properties: { dir: {
|
|
298
|
+
type: "string",
|
|
299
|
+
description: "Directory to scan, relative to the server cwd or absolute (default \"src\"; try \"src/routes\")."
|
|
300
|
+
} }
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: "ogygia_observatory",
|
|
305
|
+
description: "Bundle one or more files into a shareable, CLIENT-ONLY ogygia Observatory link so the user can see their code compiled + running live in the browser (island map, byte ledger, wire, hydrating preview). The files are gzip-packed into the URL # fragment, which browsers never send to a server — nothing hits any server. Great for showing the user how one of their real pages becomes islands. Pass `files` (a map of filename → source; App.svelte is the entry) or a single `source`.",
|
|
306
|
+
inputSchema: {
|
|
307
|
+
type: "object",
|
|
308
|
+
properties: {
|
|
309
|
+
files: {
|
|
310
|
+
type: "object",
|
|
311
|
+
description: "Map of filename → source. App.svelte is the entry; include the components it imports.",
|
|
312
|
+
additionalProperties: { type: "string" }
|
|
313
|
+
},
|
|
314
|
+
source: {
|
|
315
|
+
type: "string",
|
|
316
|
+
description: "Single-file convenience: the component source (paired with `filename`)."
|
|
317
|
+
},
|
|
318
|
+
filename: {
|
|
319
|
+
type: "string",
|
|
320
|
+
description: "Name for `source` (default App.svelte)."
|
|
321
|
+
},
|
|
322
|
+
base: {
|
|
323
|
+
type: "string",
|
|
324
|
+
description: "Observatory base URL (default the public docs Observatory; use a localhost URL to target a dev build)."
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
name: "ogygia_flags",
|
|
331
|
+
description: "Inventory every flag() call site in a project — the same AST collector the build uses (real import bindings from ogygia, renames + namespaces included, literal names only). Live-scans src/, and diffs against the last build manifest (node_modules/.ogygia/flags-manifest.json) when present. Use it to audit rollouts, find dead flags, or list what ?og-exp can override.",
|
|
332
|
+
inputSchema: {
|
|
333
|
+
type: "object",
|
|
334
|
+
properties: { dir: {
|
|
335
|
+
type: "string",
|
|
336
|
+
description: "Project root (the directory containing src/ and node_modules/), relative to the server cwd or absolute. Default \".\"."
|
|
337
|
+
} }
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
name: "ogygia_fragment",
|
|
342
|
+
description: "Probe a federated ogygia app origin. Reports whether its fragment endpoint verifies signatures (an unsigned request should get 401 — a 200 means the endpoint is OPEN) and fetches the unsigned __catalog widget manifest (names + props, plus the typed-stub command). Use it BEFORE writing mount(peer) / peer.page() / peer.widget() code against a peer, or to sanity-check an exposed app in review.",
|
|
343
|
+
inputSchema: {
|
|
344
|
+
type: "object",
|
|
345
|
+
properties: { origin: {
|
|
346
|
+
type: "string",
|
|
347
|
+
description: "The MFE origin, e.g. https://cms.internal or http://localhost:5174."
|
|
348
|
+
} },
|
|
349
|
+
required: ["origin"]
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
];
|
|
353
|
+
function tool_compile(args) {
|
|
354
|
+
const source = String(args.source ?? "");
|
|
355
|
+
const filename = String(args.filename ?? "App.svelte");
|
|
356
|
+
const csr = args.csr === true;
|
|
357
|
+
if (!source.trim()) return fail("`source` is required.");
|
|
358
|
+
let out;
|
|
359
|
+
try {
|
|
360
|
+
out = compile(source, filename, true, csr);
|
|
361
|
+
} catch (e) {
|
|
362
|
+
return fail(`[ogygia] transform error:\n${e instanceof Error ? e.message : String(e)}`);
|
|
363
|
+
}
|
|
364
|
+
const map = out.islands.length ? out.islands.map((i) => `- ${i.component} (as ${i.local}) — ${i.strategy}${i.id ? ` · id ${i.id.slice(0, 8)}` : ""}`).join("\n") : "(no marked components — the whole file is free server HTML)";
|
|
365
|
+
const structured = JSON.stringify({
|
|
366
|
+
csr,
|
|
367
|
+
islands: out.islands.map((i) => ({
|
|
368
|
+
component: i.component,
|
|
369
|
+
kind: i.kind,
|
|
370
|
+
...i.attrs,
|
|
371
|
+
id: i.id
|
|
372
|
+
}))
|
|
373
|
+
}, null, 2);
|
|
374
|
+
return text(`# ogygia transform — ${filename}${csr ? " (csr=true leg)" : ""}\n\n## Island map (${out.islands.length})\n${map}\n\n## Structured\n\`\`\`json\n${structured}\n\`\`\`\n\n## Rewritten host module\n\`\`\`js\n${out.code}\n\`\`\``);
|
|
375
|
+
}
|
|
376
|
+
function tool_islands(args) {
|
|
377
|
+
const source = String(args.source ?? "");
|
|
378
|
+
if (!source.trim()) return fail("`source` is required.");
|
|
379
|
+
let out;
|
|
380
|
+
try {
|
|
381
|
+
out = compile(source, "App.svelte", true, false);
|
|
382
|
+
} catch (e) {
|
|
383
|
+
return fail(`[ogygia] transform error:\n${e instanceof Error ? e.message : String(e)}`);
|
|
384
|
+
}
|
|
385
|
+
const structured = out.islands.map((i) => ({
|
|
386
|
+
component: i.component,
|
|
387
|
+
local: i.local,
|
|
388
|
+
kind: i.kind,
|
|
389
|
+
...i.attrs,
|
|
390
|
+
id: i.id
|
|
391
|
+
}));
|
|
392
|
+
return text(`${out.islands.length} marked region(s)\n\n\`\`\`json\n${JSON.stringify(structured, null, 2)}\n\`\`\``);
|
|
393
|
+
}
|
|
394
|
+
function tool_check(args) {
|
|
395
|
+
const source = String(args.source ?? "");
|
|
396
|
+
if (!source.trim()) return fail("`source` is required.");
|
|
397
|
+
try {
|
|
398
|
+
compile(source, "App.svelte", true, false);
|
|
399
|
+
} catch (e) {
|
|
400
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
401
|
+
return text(`❌ FAILS ogygia rules\n\n${msg}`);
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
compile(source, "App.svelte", false, false);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
407
|
+
return text(`❌ FAILS ogygia rules (client leg)\n\n${msg}`);
|
|
408
|
+
}
|
|
409
|
+
return text("✅ passes ogygia rules — the transform accepts this component.");
|
|
410
|
+
}
|
|
411
|
+
function tool_explain(args) {
|
|
412
|
+
const source = String(args.source ?? "");
|
|
413
|
+
if (!source.trim()) return fail("`source` is required.");
|
|
414
|
+
let out;
|
|
415
|
+
try {
|
|
416
|
+
out = compile(source, "App.svelte", true, false);
|
|
417
|
+
} catch (e) {
|
|
418
|
+
return fail(`[ogygia] transform error:\n${e instanceof Error ? e.message : String(e)}`);
|
|
419
|
+
}
|
|
420
|
+
if (!out.islands.length) return text("This component marks nothing, so it ships as pure server HTML — zero JavaScript, no islands.");
|
|
421
|
+
const lines = out.islands.map((i) => {
|
|
422
|
+
const a = i.attrs;
|
|
423
|
+
if (a.region === "raw") return `• ${i.component}: a held region marked \`raw\` — server picks it, it renders as HTML and ships no JS.`;
|
|
424
|
+
if (hole_fetch(a) != null) return `• ${i.component}: a server island. Its HTML is fetched later from a signed endpoint (on ${hole_fetch(a)}); show \`ogygiaFallback\` while it loads. Not interactive unless it nests its own wake island.`;
|
|
425
|
+
if (a.render === "live") return `• ${i.component}: a live region — baked at request, then revalidated in the background and morphed in place.`;
|
|
426
|
+
if (a.wake === "none") return `• ${i.component}: a lake — heavy static subtree frozen to server HTML inside an island; ships no JS of its own.`;
|
|
427
|
+
const wake = a.wake ?? "load";
|
|
428
|
+
const keep = a.keep ? ` It is kept across SPA navigation as "${a.keep}" — its live \`$state\` survives the nav.` : "";
|
|
429
|
+
return `• ${i.component}: an island. Its own hydration root; JS wakes on ${wake}. Props cross by value (devalue) — functions never cross, captured host state is a snapshot.${keep}`;
|
|
430
|
+
});
|
|
431
|
+
return text(`How this page behaves at runtime:\n\n${lines.join("\n")}`);
|
|
432
|
+
}
|
|
433
|
+
const short = (fp) => fp.slice(0, 8);
|
|
434
|
+
/** Turn the raw event stream into a per-island runtime story + invariant warnings an AI can act on. */
|
|
435
|
+
function render_story(url, events) {
|
|
436
|
+
if (!events.length) return `Loaded ${url}, but the devtools bus emitted no events — no islands on this page, or nothing had happened yet.`;
|
|
437
|
+
const client_ts = events.filter((e) => e.realm === "client").map((e) => Number(e.t ?? 0));
|
|
438
|
+
const t0 = client_ts.length ? Math.min(...client_ts) : Math.min(...events.map((e) => Number(e.t ?? 0)));
|
|
439
|
+
const at = (e) => e.realm === "server" ? "" : `+${Math.round(Number(e.t ?? t0) - t0)}ms`;
|
|
440
|
+
const when = (e) => {
|
|
441
|
+
if (!e) return "";
|
|
442
|
+
const a = at(e);
|
|
443
|
+
return a ? ` (${a})` : "";
|
|
444
|
+
};
|
|
445
|
+
const has = (evs, n) => evs.find((e) => e.name === n);
|
|
446
|
+
const by_fp = /* @__PURE__ */ new Map();
|
|
447
|
+
const global = [];
|
|
448
|
+
for (const e of events) if (typeof e.fp === "string" && e.fp) {
|
|
449
|
+
const arr = by_fp.get(e.fp) ?? [];
|
|
450
|
+
arr.push(e);
|
|
451
|
+
by_fp.set(e.fp, arr);
|
|
452
|
+
} else global.push(e);
|
|
453
|
+
const warnings = [];
|
|
454
|
+
const blocks = [];
|
|
455
|
+
for (const [fp, evsRaw] of by_fp) {
|
|
456
|
+
const evs = evsRaw.slice().sort((a, b) => Number(a.seq ?? 0) - Number(b.seq ?? 0));
|
|
457
|
+
const ssr = has(evs, "server.region.rendered");
|
|
458
|
+
const connected = has(evs, "region.connected");
|
|
459
|
+
const woke = has(evs, "wake.fired");
|
|
460
|
+
const done = has(evs, "region.hydrate.done");
|
|
461
|
+
const failed = has(evs, "region.hydrate.failed");
|
|
462
|
+
const applied = has(evs, "region.server.applied");
|
|
463
|
+
const props = has(evs, "wire.props");
|
|
464
|
+
const scheduled = has(evs, "wake.scheduled");
|
|
465
|
+
const replay = has(evs, "interaction.replay");
|
|
466
|
+
const strategy = String(connected?.wake ?? scheduled?.when ?? "(unknown)");
|
|
467
|
+
const entry = connected?.entry ?? scheduled?.entry ?? evs.find((e) => e.entry)?.entry;
|
|
468
|
+
const label = entry ? base(String(entry)) : `#${short(fp)}`;
|
|
469
|
+
let status = "✅";
|
|
470
|
+
if (failed) status = "❌";
|
|
471
|
+
else if (ssr && !connected) status = "⚠️";
|
|
472
|
+
else if (connected && !done && !applied) status = "⏳";
|
|
473
|
+
const lines = [];
|
|
474
|
+
if (ssr) lines.push(` · SSR'd on the server${when(ssr)}`);
|
|
475
|
+
if (props) lines.push(` · props crossed the wire${props.bytes != null ? ` (${props.bytes} B)` : ""}`);
|
|
476
|
+
if (connected) lines.push(` · region connected — wake: ${strategy}${connected.nested ? ", nested (rides an awake ancestor)" : ""}${connected.deferred ? ", deferred hole" : ""}${when(connected)}`);
|
|
477
|
+
if (woke) lines.push(` · wake fired${when(woke)}`);
|
|
478
|
+
if (applied) lines.push(` · deferred HTML applied${applied.bytes != null ? ` (${applied.bytes} B)` : ""}${when(applied)}`);
|
|
479
|
+
if (done) lines.push(` · hydrated${done.ms != null ? ` in ${Math.round(Number(done.ms) * 10) / 10}ms` : ""} — interactive${when(done)}`);
|
|
480
|
+
if (replay) lines.push(` · replayed ${replay.clicks ?? "?"} queued click(s) after wake`);
|
|
481
|
+
if (failed) lines.push(` · HYDRATION FAILED${failed.error ? `: ${failed.error}` : ""}`);
|
|
482
|
+
blocks.push(`### ${status} ${label}${strategy !== "(unknown)" ? ` — wake:${strategy}` : ""}${entry ? ` (#${short(fp)})` : ""}\n${lines.join("\n")}`);
|
|
483
|
+
if (failed) warnings.push(`#${short(fp)}: hydration failed${failed.error ? ` — ${failed.error}` : ""}.`);
|
|
484
|
+
else if (ssr && !connected) warnings.push(`#${short(fp)}: SSR'd but the region never connected — its custom element never ran (island JS not shipped/loaded, or a csr=true/false mismatch).`);
|
|
485
|
+
else if (connected && strategy === "load" && !done) warnings.push(`#${short(fp)}: connected with wake:load but never finished hydrating — stuck or errored mid-wake.`);
|
|
486
|
+
}
|
|
487
|
+
const boot = global.find((e) => e.name === "runtime.boot");
|
|
488
|
+
const navs = global.filter((e) => e.name.startsWith("nav."));
|
|
489
|
+
return `${`# Runtime story — ${url}\n\n${by_fp.size} island(s) · ${events.length} events` + (boot ? ` · runtime booted (${boot.installers?.join(", ") || "installers ran"})` : "") + (navs.length ? ` · ${navs.filter((n) => n.name === "nav.finish").length} navigation(s)` : "")}\n\n${blocks.join("\n\n")}` + (warnings.length ? `\n\n## ⚠️ Warnings (${warnings.length})\n${warnings.map((w) => `- ${w}`).join("\n")}` : "\n\n## ✅ No lifecycle anomalies detected.");
|
|
490
|
+
}
|
|
491
|
+
/** What the page's restorer said (an app with `ogygia.handle({ transform })`), or '' when nothing. */
|
|
492
|
+
function restore_section(events) {
|
|
493
|
+
if (!events.length) return "";
|
|
494
|
+
const lines = events.map((e) => e.type === "ogygia:restore-late" ? `- a host (#${e.detail.host ?? "?"}) already had a shadow root when the restore reached it: something upgraded it first (a module script before the restorer, or an insertion ogygia did not make). It was left as served, so its island will heal or re-render.` : `- the transform broke hydration markup: ${e.detail.diff ?? `<${e.detail.host ?? "?"}>`}. Carry every og-* mark along, and render og-h hosts only by their plan (<slot> wrappers, og-shadow, og-keep).`);
|
|
495
|
+
return `\n\n## ⚠️ Server transform (${events.length})\n${lines.join("\n")}`;
|
|
496
|
+
}
|
|
497
|
+
/** Load a REAL page in a headless browser, let its islands hydrate, and read the devtools stream. */
|
|
498
|
+
async function tool_debug(args) {
|
|
499
|
+
const url = String(args.url ?? "");
|
|
500
|
+
if (!url) return fail("`url` is required — a page of a running ogygia app with `devtools()` (from `@ogygia/devtools/vite`) in its vite.config.");
|
|
501
|
+
const wait = typeof args.wait === "number" ? Math.min(Math.max(args.wait, 200), 8e3) : 2500;
|
|
502
|
+
const do_scroll = args.scroll !== false;
|
|
503
|
+
const click_sel = typeof args.click === "string" ? args.click : "";
|
|
504
|
+
let chromium;
|
|
505
|
+
try {
|
|
506
|
+
const spec = "playwright";
|
|
507
|
+
({chromium} = await import(spec));
|
|
508
|
+
} catch {
|
|
509
|
+
return fail("ogygia_debug needs Playwright: `npm i -D playwright && npx playwright install chromium`.");
|
|
510
|
+
}
|
|
511
|
+
let browser;
|
|
512
|
+
try {
|
|
513
|
+
browser = await chromium.launch();
|
|
514
|
+
} catch (e) {
|
|
515
|
+
return fail(`could not launch a browser (${e instanceof Error ? e.message : String(e)}). Try \`npx playwright install chromium\`.`);
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
const page = await browser.newPage();
|
|
519
|
+
await page.addInitScript(() => {
|
|
520
|
+
const w = window;
|
|
521
|
+
const list = w.__og_restore_events = [];
|
|
522
|
+
for (const type of ["ogygia:restore-late", "ogygia:restore-mismatch"]) document.addEventListener(type, (e) => list.push({
|
|
523
|
+
type,
|
|
524
|
+
detail: e.detail
|
|
525
|
+
}), true);
|
|
526
|
+
});
|
|
527
|
+
try {
|
|
528
|
+
await page.goto(url, {
|
|
529
|
+
waitUntil: "load",
|
|
530
|
+
timeout: 15e3
|
|
531
|
+
});
|
|
532
|
+
} catch (e) {
|
|
533
|
+
return fail(`could not load ${url} — is the dev server up? (${e instanceof Error ? e.message : String(e)})`);
|
|
534
|
+
}
|
|
535
|
+
if (!await page.evaluate(() => typeof window.__ogygia_devtools !== "undefined")) return fail(`${url} loaded, but window.__ogygia_devtools is absent — the app runs without the devtools. List them beside ogygia() in vite.config (\`import { devtools } from '@ogygia/devtools/vite'\`, then \`plugins: [ogygia(), devtools(), sveltekit()]\`, the dock left on), restart the dev server or rebuild, then retry.`);
|
|
536
|
+
await page.waitForTimeout(wait);
|
|
537
|
+
if (do_scroll) {
|
|
538
|
+
await page.evaluate(async () => {
|
|
539
|
+
for (let y = 0; y <= document.body.scrollHeight; y += 400) {
|
|
540
|
+
window.scrollTo(0, y);
|
|
541
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
542
|
+
}
|
|
543
|
+
window.scrollTo(0, 0);
|
|
544
|
+
});
|
|
545
|
+
await page.waitForTimeout(500);
|
|
546
|
+
}
|
|
547
|
+
if (click_sel) {
|
|
548
|
+
await page.click(click_sel, { timeout: 3e3 }).catch(() => {});
|
|
549
|
+
await page.waitForTimeout(400);
|
|
550
|
+
}
|
|
551
|
+
const trace = await page.evaluate(() => window.__ogygia_devtools.trace());
|
|
552
|
+
const restores = await page.evaluate(() => window.__og_restore_events ?? []);
|
|
553
|
+
return text(render_story(url, trace.events ?? []) + restore_section(restores));
|
|
554
|
+
} finally {
|
|
555
|
+
await browser.close();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const sev_icon = {
|
|
559
|
+
critical: "❌",
|
|
560
|
+
error: "❌",
|
|
561
|
+
warn: "⚠️",
|
|
562
|
+
warning: "⚠️",
|
|
563
|
+
info: "ℹ️",
|
|
564
|
+
good: "✅"
|
|
565
|
+
};
|
|
566
|
+
const median = (xs) => {
|
|
567
|
+
if (!xs.length) return 0;
|
|
568
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
569
|
+
const m = Math.floor(s.length / 2);
|
|
570
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
571
|
+
};
|
|
572
|
+
const kb_or_mb = (b) => b >= 1048576 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.round(b / 1024)} KB`;
|
|
573
|
+
/**
|
|
574
|
+
* THE PLAN: one ordered list of changes, each a place to edit and what it is worth per render —
|
|
575
|
+
* the slow patterns (memory and shipped-data ones first, then by ms saved), the work that builds
|
|
576
|
+
* data nothing reads (the drill's rows whose every key is unread: delete it), and a page load that
|
|
577
|
+
* waits on its layout for data it uses later. What an agent does first, second, third.
|
|
578
|
+
*/
|
|
579
|
+
function render_plan(r) {
|
|
580
|
+
const n = Math.max(1, r.target?.runs?.length ?? 1);
|
|
581
|
+
const steps = [];
|
|
582
|
+
const first_sentence = (s = "") => {
|
|
583
|
+
const dot = s.indexOf(". ");
|
|
584
|
+
return dot === -1 ? s : s.slice(0, dot + 1);
|
|
585
|
+
};
|
|
586
|
+
const gone_at = /* @__PURE__ */ new Set();
|
|
587
|
+
const place = (p) => p.startsWith("src/") ? p.slice(4) : p;
|
|
588
|
+
const all_lines = (d) => {
|
|
589
|
+
if (d.at) gone_at.add(place(d.at));
|
|
590
|
+
for (const c of d.children ?? []) all_lines(c);
|
|
591
|
+
};
|
|
592
|
+
const note = (d) => {
|
|
593
|
+
if (d.fills?.length && d.fills.every((f) => !f.read)) return all_lines(d);
|
|
594
|
+
for (const c of d.children ?? []) note(c);
|
|
595
|
+
};
|
|
596
|
+
if (r.drill) note(r.drill);
|
|
597
|
+
const gone = (x) => gone_at.has(place(`${x.file}:${x.line}`));
|
|
598
|
+
for (const p of r.patterns ?? []) {
|
|
599
|
+
if (p.sites?.length && p.sites.every((x) => gone(x) || !!x.via?.length && x.via.every(gone))) continue;
|
|
600
|
+
const s = p.sites?.[0];
|
|
601
|
+
const at = s?.via?.find((v) => v.in_loop) ?? (p.wait ? s?.via?.[0] : void 0) ?? s;
|
|
602
|
+
const where = at?.file ? at.module ? `${at.module} (built ${at.file}:${at.line})` : `${at.file}:${at.line}` : "";
|
|
603
|
+
const worth = p.kept_bytes ? `keeps ${kb_or_mb(p.kept_bytes)} alive per render` : p.seed_bytes ? `ships ${kb_or_mb(p.seed_bytes)} per page view` : `~${Math.round((p.save_ms ?? 0) / (p.wait ? 1 : n) * 10) / 10}ms per render`;
|
|
604
|
+
if (!p.kept_bytes && !p.seed_bytes && (p.save_ms ?? 0) <= 0) continue;
|
|
605
|
+
const value = (p.kept_bytes ?? 0) >= 8388608 || p.seed_bytes ? Infinity : p.kept_bytes ? .5 : (p.save_ms ?? 0) / (p.wait ? 1 : n);
|
|
606
|
+
steps.push({
|
|
607
|
+
value,
|
|
608
|
+
key: p.title ?? p.kind,
|
|
609
|
+
text: `**${p.title ?? p.kind}** — ${where ? `\`${where}\` · ` : ""}${worth}. ${first_sentence(p.fix)}`
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
const unread = [];
|
|
613
|
+
const walk = (d) => {
|
|
614
|
+
if (d.fills?.length && d.fills.every((f) => !f.read)) {
|
|
615
|
+
const first_line = (m) => {
|
|
616
|
+
for (const c of m.children ?? []) {
|
|
617
|
+
const at = c.kind === "line" && c.at ? c.at : first_line(c);
|
|
618
|
+
if (at) return at;
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
const at = d.at ?? first_line(d);
|
|
622
|
+
unread.push({
|
|
623
|
+
label: d.label,
|
|
624
|
+
ms: d.ms,
|
|
625
|
+
keys: d.fills.map((f) => f.key),
|
|
626
|
+
...at ? { at } : {},
|
|
627
|
+
...d.kind === "line" || !d.at && at ? { line: true } : {}
|
|
628
|
+
});
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
for (const c of d.children ?? []) walk(c);
|
|
632
|
+
};
|
|
633
|
+
if (r.drill) walk(r.drill);
|
|
634
|
+
const by_keys = /* @__PURE__ */ new Map();
|
|
635
|
+
for (const u of [...unread].sort((a, b) => b.ms - a.ms)) {
|
|
636
|
+
const k = u.keys.join(", ");
|
|
637
|
+
const had = by_keys.get(k);
|
|
638
|
+
if (!had) by_keys.set(k, { ...u });
|
|
639
|
+
else {
|
|
640
|
+
had.ms += u.ms;
|
|
641
|
+
if (u.line && u.at && !had.line) {
|
|
642
|
+
had.at = u.at;
|
|
643
|
+
had.line = true;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
for (const u of [...by_keys.values()].filter((x) => x.ms >= 1)) steps.push({
|
|
648
|
+
value: u.ms,
|
|
649
|
+
key: `unread:${u.keys.join(", ")}`,
|
|
650
|
+
text: `**Delete work for data nothing reads** — ${u.label}${u.at ? ` (\`${u.at}\`)` : ""} builds only ${u.keys.join(", ")}: ~${Math.round(u.ms * 10) / 10}ms per render. Drop it from the load, or the key with it.`
|
|
651
|
+
});
|
|
652
|
+
const chain = (r.findings ?? []).find((f) => f.code === "parent-chain" && (f.message ?? "").includes("never needed the layout"));
|
|
653
|
+
if (chain) {
|
|
654
|
+
const ms = Number((chain.message ?? "").match(/\(([\d.]+) ms later\)/)?.[1] ?? 0);
|
|
655
|
+
steps.push({
|
|
656
|
+
value: ms,
|
|
657
|
+
text: `**Move \`await parent()\` down** — ${chain.message}`
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
if (!steps.length) return "";
|
|
661
|
+
const parts = r.forecast?.parts ?? [];
|
|
662
|
+
if (parts.length) {
|
|
663
|
+
const key_of = (p) => p.kind === "unread-work" ? `unread:${p.title.slice(p.title.indexOf("(") + 1, -1)}` : p.title;
|
|
664
|
+
const rank = new Map(parts.map((p, i) => [key_of(p), i]));
|
|
665
|
+
const covered = new Set(parts.flatMap((p) => (p.with ?? []).map((w) => w.startsWith("Delete work for data nothing reads (") ? `unread:${w.slice(w.indexOf("(") + 1, -1)}` : w)));
|
|
666
|
+
const kept = [];
|
|
667
|
+
for (const s of steps) {
|
|
668
|
+
const i = s.key === void 0 ? void 0 : rank.get(s.key);
|
|
669
|
+
if (i === void 0) {
|
|
670
|
+
if (s.key !== void 0 && covered.has(s.key)) continue;
|
|
671
|
+
kept.push({
|
|
672
|
+
...s,
|
|
673
|
+
value: s.value === Infinity ? Infinity : -1 / (1 + s.value)
|
|
674
|
+
});
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const p = parts[i];
|
|
678
|
+
const also = p.with?.length ? ` Also covers: ${p.with.join("; ")}.` : "";
|
|
679
|
+
const after = p.after_ms !== void 0 ? ` → ~${Math.round(p.after_ms)}ms per render after this and the steps above.` : "";
|
|
680
|
+
kept.push({
|
|
681
|
+
...s,
|
|
682
|
+
value: 1e9 - i,
|
|
683
|
+
text: `${s.text}${also}${after}`
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
steps.length = 0;
|
|
687
|
+
steps.push(...kept);
|
|
688
|
+
}
|
|
689
|
+
return steps.sort((a, b) => b.value - a.value).slice(0, 8).map((s, i) => `${i + 1}. ${s.text}`).join("\n");
|
|
690
|
+
}
|
|
691
|
+
/** THE PATTERNS as an agent reads them: what, where (with the code and the caller lines), the fix */
|
|
692
|
+
function render_patterns(r) {
|
|
693
|
+
const n = Math.max(1, r.target?.runs?.length ?? 1);
|
|
694
|
+
const per = (p) => Math.round((p.save_ms ?? 0) / (p.wait ? 1 : n) * 100) / 100;
|
|
695
|
+
return (r.patterns ?? []).slice(0, 8).map((p, i) => {
|
|
696
|
+
const sites = (p.sites ?? []).slice(0, 4).map((s) => {
|
|
697
|
+
const via = (s.via ?? []).slice(0, 3).map((v) => `\n ← called from ${v.file}:${v.line}${v.in_loop ? " (in a loop)" : ""}: \`${v.code ?? ""}\``).join("");
|
|
698
|
+
const uc = s.upstream_cache;
|
|
699
|
+
const said = uc?.no_store ? " (the service says no-store: check before caching)" : uc?.no_cache ? " (the service says revalidate every reuse: cache only with a check, e.g. its ETag)" : uc?.max_age ? ` (the service allows caching ${uc.max_age}s${uc.private ? ", per user" : ""})` : uc?.private ? " (the service marks it private: per user only)" : "";
|
|
700
|
+
const rw = s.rewrite ? `\n Change at ${s.rewrite.file}:${s.rewrite.line}, replace:\n\`\`\`\n${s.rewrite.before}\n\`\`\`\n with:\n\`\`\`\n${s.rewrite.after}\n\`\`\`` : "";
|
|
701
|
+
return `\n - ${s.module ? `${s.module} (built ${s.file}:${s.line})` : `${s.file}:${s.line}`}: \`${s.code ?? ""}\`${said}${via}${rw}`;
|
|
702
|
+
}).join("");
|
|
703
|
+
const whole = p.kind === "same-document" || p.kind === "almost-same-document";
|
|
704
|
+
const save = p.save_ms ? ` — saves ~${per(p)}ms${whole ? " (the whole render, from a cache)" : p.wait ? " of waiting" : ""} per render` : "";
|
|
705
|
+
const example = p.example?.after ? `\n Example: \`${p.example.before ?? ""}\` → \`${(p.example.after ?? "").split("\n").join(" ")}\`` : "";
|
|
706
|
+
return `${i + 1}. **${p.title}**${save}\n ${p.evidence ?? ""}${sites}\n Fix: ${p.fix ?? ""}${example}`;
|
|
707
|
+
}).join("\n");
|
|
708
|
+
}
|
|
709
|
+
/** THE DRILL-DOWN as an agent reads it: an indented tree to the line (four levels below the
|
|
710
|
+
* render: phase, load file, who, line), each row with the patterns found on it */
|
|
711
|
+
function render_drill(root) {
|
|
712
|
+
const KIND = {
|
|
713
|
+
cpu: "CPU",
|
|
714
|
+
wait: "waiting",
|
|
715
|
+
gap: "nothing recorded",
|
|
716
|
+
lane: "load file"
|
|
717
|
+
};
|
|
718
|
+
const out = [];
|
|
719
|
+
const walk = (n, depth) => {
|
|
720
|
+
const alone = n.alone === void 0 ? "" : n.alone < .05 ? ", always beside other calls" : n.alone >= n.ms - .05 ? ", alone" : `, ${n.alone}ms alone`;
|
|
721
|
+
const spread = (n.runs ? `, ${n.kind === "wait" ? "calls took " : ""}${n.runs[0]}–${n.runs[1]}ms over renders` : "") + (n.cold !== void 0 ? `, ${n.cold}ms cold` : "");
|
|
722
|
+
const kind = KIND[n.kind] ? ` [${KIND[n.kind]}${n.calls && n.calls > 1 ? `, ${n.calls} ${n.kind === "cpu" ? "renders" : "calls"}` : ""}${alone}${spread}]` : "";
|
|
723
|
+
const at = n.at && n.kind !== "line" && n.kind !== "lane" ? ` @ ${n.at}` : "";
|
|
724
|
+
const why = n.why?.length ? ` — ${n.why.join(", ")}` : "";
|
|
725
|
+
const all_unread = !!n.fills?.length && n.fills.every((f) => !f.read);
|
|
726
|
+
const fills = n.fills?.length ? ` → ${n.fills.map((f) => f.read || all_unread ? f.key : `${f.key} (unread)`).join(", ")}${all_unread ? " (NOTHING READS IT)" : ""}` : "";
|
|
727
|
+
const sp = n.split;
|
|
728
|
+
const per = (ms) => Math.round(ms / (sp?.calls || 1) * 10) / 10;
|
|
729
|
+
const split = sp ? ` {${sp.calls > 1 ? "each call" : "the call"}: ${sp.theirs_ms !== void 0 ? `${per(sp.theirs_ms)}ms their side${sp.top ? ` (${sp.top.map((t) => `${t.name} ${per(t.ms)}`).join(", ")})` : ""}${sp.told && sp.told < sp.calls ? ` on ${sp.told} of ${sp.calls}` : ""}, ${per(sp.network_ms)}ms network & the rest` : `${per(sp.network_ms)}ms until headers`}, ${per(sp.body_ms)}ms body}` : "";
|
|
730
|
+
const from = n.kind === "line" && n.children?.length ? ` ← called from ${n.children.map((c) => `${c.at ?? c.label} (${c.ms}ms)`).join(", ")}` : "";
|
|
731
|
+
out.push(`${" ".repeat(depth)}- ${n.kind === "line" ? n.at ?? n.label : n.label}: ${n.ms}ms${kind}${at}${split}${from}${fills}${why}`);
|
|
732
|
+
if (depth < 3 && n.kind !== "line" && n.kind !== "more") for (const c of n.children ?? []) walk(c, depth + 1);
|
|
733
|
+
};
|
|
734
|
+
for (const c of root.children ?? []) walk(c, 0);
|
|
735
|
+
return out.join("\n");
|
|
736
|
+
}
|
|
737
|
+
function render_ledger(r) {
|
|
738
|
+
const n = Math.max(1, r.target?.runs?.length ?? 1);
|
|
739
|
+
const ms = (x) => Math.round(x / n * 100) / 100;
|
|
740
|
+
return (r.ledger ?? []).slice(0, 10).map((l) => {
|
|
741
|
+
const costs = [];
|
|
742
|
+
if (l.cpu_ms) costs.push(`${ms(l.cpu_ms)}ms CPU`);
|
|
743
|
+
if (l.lib_ms) costs.push(`${ms(l.lib_ms)}ms inside ${(l.libs ?? []).map((x) => x.name).slice(0, 2).join(", ") || "libraries"}`);
|
|
744
|
+
if (l.alloc_bytes) costs.push(`${kb_or_mb(l.alloc_bytes / n)} allocated`);
|
|
745
|
+
if (l.gc_ms) costs.push(`${ms(l.gc_ms)}ms GC`);
|
|
746
|
+
if (l.retained_bytes) costs.push(`${kb_or_mb(l.retained_bytes)} kept`);
|
|
747
|
+
if (l.merged) costs.push(`CPU moved here from line ${l.merged.from} (V8 had inlined ${l.merged.callee})`);
|
|
748
|
+
return `- ${l.file}:${l.line} — ${costs.join(", ")}${l.code ? `\n \`${l.code}\`` : ""}`;
|
|
749
|
+
}).join("\n");
|
|
750
|
+
}
|
|
751
|
+
/** exported for the tests only (mcp.ts is not a package entry). `reading`: the profiler's report
|
|
752
|
+
* reading, when the app has it (an older report's plain fix sum needs it). */
|
|
753
|
+
function render_profile(origin, r, reading = profiler_reading) {
|
|
754
|
+
const target = r.target?.page ?? r.target?.request ?? "(unknown)";
|
|
755
|
+
const runs = r.target?.runs ?? [];
|
|
756
|
+
const s = r.summary ?? {};
|
|
757
|
+
const n = Math.max(1, runs.length);
|
|
758
|
+
const per = (ms) => Math.round((ms ?? 0) / n * 100) / 100;
|
|
759
|
+
const head = `# SSR profile — ${target}${runs.length ? ` · ${runs.length} run(s)` : ""}\n\n**${s.verdict ?? "profiled"}** · render p50 ~${median(runs).toFixed(2)}ms` + (runs.length ? ` (runs: ${runs.join(", ")})` : "") + (s.busy_ms != null ? ` · CPU busy ${per(s.busy_ms)}ms per render (${s.busy_pct}% of the window)` : "") + (s.rss_mb != null ? ` · RSS ${s.rss_mb} MB` : "");
|
|
760
|
+
const prof_overhead = (r.budget ?? []).find((b) => b.category === "profiler");
|
|
761
|
+
const dev_note = r.dev ? `\n\n> ⚠️ DEV build${prof_overhead ? ` — ${prof_overhead.pct}% of the window is profiler/instrument overhead` : ""}. Timings are indicative; profile a PROD build (\`vite build && vite preview\`) for real cost.` : "";
|
|
762
|
+
const findings = (r.findings ?? []).map((f) => `- ${sev_icon[f.severity ?? "info"] ?? "•"} ${f.message ?? f.code ?? ""}`).join("\n");
|
|
763
|
+
const dev_noise = /* @__PURE__ */ new Set([
|
|
764
|
+
"vite",
|
|
765
|
+
".vite",
|
|
766
|
+
"rolldown",
|
|
767
|
+
"esbuild"
|
|
768
|
+
]);
|
|
769
|
+
const is_noise = (b) => b.category === "profiler" || r.dev === true && dev_noise.has((b.label ?? "").toLowerCase());
|
|
770
|
+
const kept = (r.budget ?? []).filter((b) => !is_noise(b));
|
|
771
|
+
const kept_total = kept.reduce((sum, b) => sum + (b.ms ?? 0), 0) || 1;
|
|
772
|
+
const budget = kept.slice(0, 8).map((b) => `- ${b.label}: ${per(b.ms)}ms (${((b.ms ?? 0) / kept_total * 100).toFixed(1)}%)`).join("\n");
|
|
773
|
+
const budget_title = r.dev ? "Where the time went (per render; Vite + profiler overhead excluded; % of remaining app time)" : "Where the time went (per render, profiler overhead excluded)";
|
|
774
|
+
const hot = (r.hot_functions ?? []).filter((h) => h.category !== "profiler").sort((a, b) => (b.self_ms ?? 0) - (a.self_ms ?? 0)).slice(0, 8).map((h, i) => {
|
|
775
|
+
const top = h.stacks?.[0];
|
|
776
|
+
const via = top?.frames?.length ? `\n ← ${top.frames.slice(0, 5).map((f) => f.replace(/ \(.*\)$/, "")).join(" ← ")}${top.frames.length > 5 ? " ← …" : ""}` : "";
|
|
777
|
+
return `${i + 1}. ${h.label ? `fn \`${h.label}\`` : h.name} — ${per(h.self_ms)}ms self${h.category ? ` [${h.category}]` : ""}${h.file ? ` · ${base(h.file)}${h.line ? `:${h.line}` : ""}` : ""}${via}`;
|
|
778
|
+
}).join("\n");
|
|
779
|
+
const comps = (r.components ?? []).sort((a, b) => (b.self_ms ?? 0) - (a.self_ms ?? 0)).slice(0, 10).map((c) => `- ${c.name} ×${c.instances ?? 1} — ${per(c.self_ms)}ms self${c.alloc_bytes ? `, ${Math.round(c.alloc_bytes / 1024)} KB alloc` : ""}`).join("\n");
|
|
780
|
+
const net = r.network;
|
|
781
|
+
const net_line = net ? `${net.count ?? 0} call(s)${net.total_ms ? `, ${net.total_ms}ms total` : ""}${net.errors ? `, ${net.errors} error(s)` : ""}` : "n/a";
|
|
782
|
+
const mem = r.memory;
|
|
783
|
+
const mem_line = mem ? `RSS ${mem.rss_start_mb}→${mem.rss_end_mb} MB (+${mem.growth_mb})` : "n/a";
|
|
784
|
+
const links = r.links ? `\n\nFull report: ${origin}${r.links.html}${r.links.json ? ` · JSON: ${origin}${r.links.json}` : ""}${r.links.cpuprofile ? ` · .cpuprofile: ${origin}${r.links.cpuprofile}` : ""}` : "";
|
|
785
|
+
const fc = r.forecast;
|
|
786
|
+
const impact = fc ? void 0 : reading?.fix_impact((r.patterns ?? []).map((p) => ({
|
|
787
|
+
save_ms: p.save_ms ?? 0,
|
|
788
|
+
wait: p.wait
|
|
789
|
+
})), median(runs), runs.length || 1, r.timeline?.wait_ms);
|
|
790
|
+
const covered = (fc?.parts ?? []).filter((p) => p.with?.length);
|
|
791
|
+
const forecast_line = fc && fc.now_ms > fc.after_ms ? `Doing all of it: ~${Math.round(fc.now_ms)}ms → ~${Math.round(fc.after_ms)}ms per render (−${Math.round((fc.now_ms - fc.after_ms) / fc.now_ms * 100)}%: ${[
|
|
792
|
+
fc.cpu_ms ? `${fc.cpu_ms}ms CPU` : "",
|
|
793
|
+
fc.wait_ms ? `${fc.wait_ms}ms waiting` : "",
|
|
794
|
+
fc.delete_ms ? `${fc.delete_ms}ms of work for unread data` : ""
|
|
795
|
+
].filter(Boolean).join(", ")}).${covered.length ? ` Counted once: ${covered.map((p) => `"${p.title}" covers ${p.with.map((w) => `"${w}"`).join(", ")}`).join("; ")}.` : ""}${fc.answers ? ` Keeping the services' answers between renders too (a freshness decision: only where a slightly stale answer is fine): ~${Math.round(fc.answers.after_ms)}ms${fc.answers.measured ? ` (measured: the page rendered in ${Math.round(fc.answers.measured.ms)}ms with those answers served from memory, then the other fixes taken off)` : ""}.` : ""}${fc.cpu_now_ms && fc.cpu_after_ms && fc.cpu_after_ms < fc.cpu_now_ms * .9 ? ` Capacity: main-thread CPU ${Math.round(fc.cpu_now_ms)}ms → ~${Math.round(fc.cpu_after_ms)}ms a render after the CPU fixes, so one core serves ~${Math.round(1e3 / fc.cpu_now_ms)} → ~${Math.round(1e3 / fc.cpu_after_ms)} renders a second (waiting fixes cut latency, not this).` : ""}${fc.cache ? " A cached copy of the whole document is a separate lever, not counted." : ""}${fc.clamped ? " (The savings claimed more waiting or CPU than the render had: held at what it used.)" : ""}${fc.partial ? " Built without sourcemaps: fewer lines seen, so fewer fixes counted; the real time after them is likely lower." : ""}\n` : impact ? `Fixing all of these takes ~${Math.round(impact.render_ms - impact.after_ms)}ms off the ${Math.round(impact.render_ms)}ms render (${impact.pct}%).\n` : "";
|
|
796
|
+
const patterns = forecast_line + render_patterns(r);
|
|
797
|
+
const ledger = render_ledger(r);
|
|
798
|
+
const plan = render_plan(r);
|
|
799
|
+
const sn = r.since;
|
|
800
|
+
const since = !sn ? "" : sn.order ? `Run order skews this: both ran on one server after renders that kept ${sn.order.kept_mb} MB alive each; restart between profiles to compare.` : sn.same ? `No real change since the last profile (${sn.a_ms} → ${sn.b_ms}ms, within the runs' own spread).` : `${sn.b_ms !== void 0 && sn.a_ms !== void 0 ? `${sn.b_ms - sn.a_ms > 0 ? "+" : ""}${Math.round((sn.b_ms - sn.a_ms) * 10) / 10}ms since the last profile (${sn.a_ms} → ${sn.b_ms}ms)` : "Since the last profile"}${sn.moved.length ? ", mostly:\n" + sn.moved.map((m) => `- ${m.status} ${m.path[m.path.length - 1]}${m.at && m.kind !== "line" ? ` @ ${m.at}` : ""}: ${m.d_ms > 0 ? "+" : ""}${m.d_ms}ms`).join("\n") : "."}${sn.fix_check ? `\nThe fixed patterns promised ~${sn.fix_check.predicted_ms}ms; the render moved ${sn.fix_check.measured_ms}ms (${sn.fix_check.verdict}).` : ""}`;
|
|
801
|
+
return head + dev_note + (since ? `\n\n## Since your last profile of this page\n${since}` + (sn?.score && sn.score.a !== sn.score.b ? `\nScore ${sn.score.a} → ${sn.score.b} (${sn.score.a_grade} → ${sn.score.b_grade})${sn.score.moved.length ? ": " + sn.score.moved.map((m) => `${m.label} ${m.a} → ${m.b}`).join(", ") : ""}.` : "") : "") + (plan ? `\n\n## Do this, in order (each: where to edit, and what it is worth)\n${fc ? forecast_line : ""}${plan}` : "") + (r.shared?.length ? `\n\n## These fixes make other pages faster too\n${r.shared.map((f) => `- \`${f.where}:${f.line}\` (${f.title}): ${f.pages.map((p) => `${p.page} ~${Math.round(p.ms * 10) / 10}ms`).join(", ")}; ~${Math.round(f.total_ms * 10) / 10}ms per render in all`).join("\n")}` : "") + (patterns ? `\n\n## Slow patterns (fix these first)\n${plan && fc ? render_patterns(r) : patterns}` : "") + (r.drill ? `\n\n## Where one render went (${r.drill.ms}ms, down to the line; every level adds up)\n${render_drill(r.drill)}` : "") + (ledger ? `\n\n## The exact lines (your code, costliest first, per render)\n${ledger}` : "") + (findings ? `\n\n## Findings\n${findings}` : "") + (budget ? `\n\n## ${budget_title}\n${budget}` : "") + (hot ? `\n\n## Hottest functions (self ms per render)\n${hot}` : "") + (comps ? `\n\n## Components (self ms per render; ×N = renders of it in one page render)\n${comps}` : "") + `\n\n## Network: ${net_line} · Memory: ${mem_line}` + links;
|
|
802
|
+
}
|
|
803
|
+
/** Record an SSR profile of a route on the running app + return the digested findings. */
|
|
804
|
+
const LOCAL_PORTS = [
|
|
805
|
+
4173,
|
|
806
|
+
5173,
|
|
807
|
+
3e3,
|
|
808
|
+
5174,
|
|
809
|
+
4174,
|
|
810
|
+
3001,
|
|
811
|
+
8080,
|
|
812
|
+
4321,
|
|
813
|
+
5175
|
|
814
|
+
];
|
|
815
|
+
/** fetch with a hard deadline — a hung server can't wedge the tool. */
|
|
816
|
+
async function fetch_timeout(url, opts, ms) {
|
|
817
|
+
const ac = new AbortController();
|
|
818
|
+
const t = setTimeout(() => ac.abort(), ms);
|
|
819
|
+
try {
|
|
820
|
+
return await fetch(url, {
|
|
821
|
+
...opts,
|
|
822
|
+
signal: ac.signal
|
|
823
|
+
});
|
|
824
|
+
} finally {
|
|
825
|
+
clearTimeout(t);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
/** Probe localhost ports in parallel for a MOUNTED profiler; returns hits in priority order. */
|
|
829
|
+
async function find_local_profiler(base, key) {
|
|
830
|
+
const key_h = key ? { "x-profiler-key": key } : {};
|
|
831
|
+
const probes = LOCAL_PORTS.map(async (port) => {
|
|
832
|
+
const origin = `http://localhost:${port}`;
|
|
833
|
+
try {
|
|
834
|
+
return (await fetch_timeout(origin + base, {
|
|
835
|
+
redirect: "manual",
|
|
836
|
+
headers: key_h
|
|
837
|
+
}, 700)).status !== 404 ? origin : null;
|
|
838
|
+
} catch {
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
return (await Promise.all(probes)).filter((o) => !!o);
|
|
843
|
+
}
|
|
844
|
+
const NOT_MOUNTED = (base, where) => `The profiler is not mounted at ${base} on ${where}. Install @ogygia/devtools and turn it on in vite.config.ts:\n\n import { devtools } from '@ogygia/devtools/vite';\n plugins: [ogygia(), devtools({ profiler: true /* or { secret, path, … } */ }), sveltekit()]\n\nogygia.handle() mounts it for you — no hooks wiring needed. Restart the server, then retry.`;
|
|
845
|
+
async function tool_profile(args) {
|
|
846
|
+
const raw = String(args.url ?? "").trim();
|
|
847
|
+
if (!raw) return fail("`url` is required — a route path like `/fr/fr` (profiles your local running server), or a full URL like `https://app.com/fr/fr` (profiles that host).");
|
|
848
|
+
const profiler_base = String(args.base ?? "/__profiler").replace(TRAILING_SLASH_RE, "");
|
|
849
|
+
const runs = typeof args.runs === "number" ? Math.min(Math.max(Math.round(args.runs), 1), 50) : 5;
|
|
850
|
+
const key = typeof args.key === "string" ? args.key : "";
|
|
851
|
+
const explicit_origin = typeof args.origin === "string" && args.origin ? args.origin.replace(TRAILING_SLASH_RE, "") : "";
|
|
852
|
+
let origin;
|
|
853
|
+
let route;
|
|
854
|
+
if (HTTP_URL_RE.test(raw)) {
|
|
855
|
+
const u = new URL(raw);
|
|
856
|
+
origin = u.origin;
|
|
857
|
+
route = u.pathname + u.search;
|
|
858
|
+
} else {
|
|
859
|
+
route = raw.startsWith("/") ? raw : "/" + raw;
|
|
860
|
+
if (explicit_origin) origin = explicit_origin;
|
|
861
|
+
else {
|
|
862
|
+
const hits = await find_local_profiler(profiler_base, key);
|
|
863
|
+
const suggest = hits.length ? `\n\nRunning locally right now with the profiler mounted — reply with one to use it:\n${hits.map((h) => ` • ${h}${route}`).join("\n")}` : `\n\n(No local server with the profiler is up — start \`vite dev\` / \`vite preview\`, or give a remote URL.)`;
|
|
864
|
+
return fail(`Which URL should I profile \`${route}\` on? Ask the user, then call ogygia_profile again with the full \`url\`.\n\nLocal looks like http://localhost:5173${route}; remote like https://your-app.com${route}.${suggest}`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
{
|
|
868
|
+
const key_h = key ? { "x-profiler-key": key } : {};
|
|
869
|
+
let pre;
|
|
870
|
+
try {
|
|
871
|
+
pre = await fetch_timeout(origin + profiler_base, {
|
|
872
|
+
redirect: "manual",
|
|
873
|
+
headers: key_h
|
|
874
|
+
}, 8e3);
|
|
875
|
+
} catch (e) {
|
|
876
|
+
return fail(`Could not reach ${origin} — is it running and reachable? (${e instanceof Error ? e.message : String(e)})`);
|
|
877
|
+
}
|
|
878
|
+
if (pre.status === 404) return fail(NOT_MOUNTED(profiler_base, origin));
|
|
879
|
+
const pre_body = await pre.text().catch(() => "");
|
|
880
|
+
if (LOGIN_FORM_RE.test(pre_body)) return fail(key ? `The profiler on ${origin} rejected that \`key\` (still locked). Confirm its secret with the user.` : `The profiler on ${origin} is locked. Ask the user for its secret and pass it as \`key\`.`);
|
|
881
|
+
}
|
|
882
|
+
const record_once = () => {
|
|
883
|
+
const rec = new URL(origin + profiler_base + "/page");
|
|
884
|
+
rec.searchParams.set("p", route);
|
|
885
|
+
rec.searchParams.set("format", "json");
|
|
886
|
+
rec.searchParams.set("runs", String(runs));
|
|
887
|
+
const headers = { accept: "application/json" };
|
|
888
|
+
if (key) headers["x-profiler-key"] = key;
|
|
889
|
+
return fetch_timeout(rec, { headers }, 9e4);
|
|
890
|
+
};
|
|
891
|
+
let res;
|
|
892
|
+
try {
|
|
893
|
+
res = await record_once();
|
|
894
|
+
} catch (e) {
|
|
895
|
+
const aborted = e instanceof Error && e.name === "AbortError";
|
|
896
|
+
return fail(aborted ? `Recording ${route} on ${origin} took over 90s and was aborted. Try fewer \`runs\`, or if this host times out (serverless), download the .ogp from ${origin}${profiler_base} and open it with ogygia_profile_open.` : `Recording failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
897
|
+
}
|
|
898
|
+
if (res.status === 409) try {
|
|
899
|
+
await fetch_timeout(new URL(origin + profiler_base + "/reset"), {
|
|
900
|
+
redirect: "manual",
|
|
901
|
+
headers: key ? { "x-profiler-key": key } : {}
|
|
902
|
+
}, 5e3);
|
|
903
|
+
res = await record_once();
|
|
904
|
+
} catch {}
|
|
905
|
+
if ((res.headers.get("content-type") ?? "").includes("application/json")) {
|
|
906
|
+
await load_profiler_reading();
|
|
907
|
+
return text(render_profile(origin, await res.json()));
|
|
908
|
+
}
|
|
909
|
+
const body = (await res.text()).replace(HTML_TAG_G, " ").replace(WS_G, " ").trim().slice(0, 300);
|
|
910
|
+
if (res.status === 409) return fail(`A profile is already running on ${origin} and /reset didn’t clear it. It auto-heals after ~2 min — wait and retry.`);
|
|
911
|
+
if (res.status === 401 || res.status === 403) return fail(`The profiler on ${origin} is locked. Pass its secret as \`key\` (ask the user for it).`);
|
|
912
|
+
return fail(`profiler did not return JSON (HTTP ${res.status}). ${body || "(check the profiler key / route path)"}`);
|
|
913
|
+
}
|
|
914
|
+
async function tool_profile_open(args) {
|
|
915
|
+
const file = String(args.file ?? "");
|
|
916
|
+
if (!file) return fail("`file` is required — the path to a downloaded .ogp profile.");
|
|
917
|
+
const key = typeof args.key === "string" && args.key ? args.key : void 0;
|
|
918
|
+
let bytes;
|
|
919
|
+
try {
|
|
920
|
+
bytes = new Uint8Array(readFileSync(path.resolve(file)));
|
|
921
|
+
} catch (e) {
|
|
922
|
+
return fail(`could not read ${file}: ${e instanceof Error ? e.message : String(e)}`);
|
|
923
|
+
}
|
|
924
|
+
const reading = await load_profiler_reading();
|
|
925
|
+
if (!reading) return fail("Opening a .ogp needs the profiler: install `@ogygia/devtools` in this app (run this from its root).");
|
|
926
|
+
const { is_ogp, ogp_decode, is_dump, report_json } = reading;
|
|
927
|
+
if (!is_ogp(bytes)) return fail(`${file} is not an ogygia .ogp profile (bad magic).`);
|
|
928
|
+
let dump;
|
|
929
|
+
try {
|
|
930
|
+
dump = await ogp_decode(bytes, key);
|
|
931
|
+
} catch {
|
|
932
|
+
return fail(key ? `Could not open ${file} — that export key does not match this .ogp (its AES tag failed). Confirm the key it was exported with.` : `${file} is encrypted. Re-run with \`key\` set to the export key it was made with. Ask the user for it if you don’t have it — it is the .ogp’s own key, not the app’s profiler secret.`);
|
|
933
|
+
}
|
|
934
|
+
if (!is_dump(dump)) return fail(`${file} decrypted, but it is not an ogygia profiler dump.`);
|
|
935
|
+
const report = report_json(dump.analysis, dump.meta, "/__profiler", dump.extras);
|
|
936
|
+
report.links = void 0;
|
|
937
|
+
const node = dump.meta.node;
|
|
938
|
+
return text(`> Imported from \`${file}\`${node ? ` · Node ${node}` : ""}\n\n` + render_profile("", report));
|
|
939
|
+
}
|
|
940
|
+
const DEFAULT_OBSERVATORY = "https://ogygiajs.dev/observatory";
|
|
941
|
+
/** `#<base64url(gzip(json))>` — the EXACT single-string format the Observatory decodes (browser gunzip).
|
|
942
|
+
* The payload is `{ f: files }` (files only; the app fills in default UI state). */
|
|
943
|
+
function observatory_link(files, base) {
|
|
944
|
+
const b64url = gzipSync(Buffer.from(JSON.stringify({ f: files }), "utf8")).toString("base64").replace(PLUS_G, "-").replace(SLASH_G, "_").replace(TRAILING_EQUALS_RE, "");
|
|
945
|
+
return `${base.replace(HASH_SUFFIX_RE, "")}#${b64url}`;
|
|
946
|
+
}
|
|
947
|
+
function tool_observatory(args) {
|
|
948
|
+
let files = {};
|
|
949
|
+
if (args.files && typeof args.files === "object" && !Array.isArray(args.files)) {
|
|
950
|
+
for (const [k, v] of Object.entries(args.files)) if (typeof v === "string") files[k] = v;
|
|
951
|
+
} else if (typeof args.source === "string") files[String(args.filename ?? "App.svelte")] = args.source;
|
|
952
|
+
const names = Object.keys(files);
|
|
953
|
+
if (!names.length) return fail("Provide `files` (a map of filename → source) or `source` (+ optional `filename`).");
|
|
954
|
+
if (!names.some((n) => n.endsWith(".svelte"))) return text(`⚠️ No .svelte file given — the Observatory renders a Svelte component (App.svelte is the entry). Add one.\n\nFiles: ${names.join(", ")}`);
|
|
955
|
+
const url = observatory_link(files, String(args.base ?? DEFAULT_OBSERVATORY));
|
|
956
|
+
const long = url.length > 8e3 ? `\n\n⚠️ The link is ${url.length} chars — some tools truncate very long URLs. Trim to the files that matter if it breaks.` : "";
|
|
957
|
+
return text(`Open this in the ogygia Observatory to see ${names.length === 1 ? "this component" : `these ${names.length} files`} compiled live — the island map, byte ledger, wire payloads, and a real hydrating preview:\n\n${url}\n\n🔒 Client-only: the code is packed into the URL's **# fragment**, which browsers NEVER send to a server — nothing here touches ogygia's (or anyone's) servers. It compiles entirely in your browser.${long}`);
|
|
958
|
+
}
|
|
959
|
+
const SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
960
|
+
"node_modules",
|
|
961
|
+
".svelte-kit",
|
|
962
|
+
".git",
|
|
963
|
+
"dist",
|
|
964
|
+
"build",
|
|
965
|
+
".vercel",
|
|
966
|
+
".netlify",
|
|
967
|
+
"coverage"
|
|
968
|
+
]);
|
|
969
|
+
function walk_svelte(dir, out = [], depth = 0) {
|
|
970
|
+
if (depth > 12 || out.length > 2e3) return out;
|
|
971
|
+
let entries;
|
|
972
|
+
try {
|
|
973
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
974
|
+
} catch {
|
|
975
|
+
return out;
|
|
976
|
+
}
|
|
977
|
+
for (const e of entries) {
|
|
978
|
+
if (e.name.startsWith(".") && e.name !== ".") continue;
|
|
979
|
+
if (e.isDirectory()) {
|
|
980
|
+
if (!SKIP_DIRS.has(e.name)) walk_svelte(path.join(dir, e.name), out, depth + 1);
|
|
981
|
+
} else if (e.name.endsWith(".svelte")) out.push(path.join(dir, e.name));
|
|
982
|
+
}
|
|
983
|
+
return out;
|
|
984
|
+
}
|
|
985
|
+
function tool_scan(args) {
|
|
986
|
+
const dir = String(args.dir ?? "src");
|
|
987
|
+
const root = path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir);
|
|
988
|
+
const files = walk_svelte(root);
|
|
989
|
+
if (!files.length) return fail(`No .svelte files found under ${dir} (cwd: ${process.cwd()}). Pass \`dir\` (e.g. "src" or "src/routes").`);
|
|
990
|
+
const rel = (f) => path.relative(root, f) || path.basename(f);
|
|
991
|
+
const islands = [];
|
|
992
|
+
const violations = [];
|
|
993
|
+
const kinds = {};
|
|
994
|
+
let preset_marks = 0;
|
|
995
|
+
const island_components = /* @__PURE__ */ new Set();
|
|
996
|
+
const per_file = [];
|
|
997
|
+
for (const f of files.slice(0, 1500)) {
|
|
998
|
+
let source;
|
|
999
|
+
try {
|
|
1000
|
+
source = readFileSync(f, "utf8");
|
|
1001
|
+
} catch {
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
const marks = parse_marks(source);
|
|
1005
|
+
if (!marks.length) continue;
|
|
1006
|
+
per_file.push({
|
|
1007
|
+
marks,
|
|
1008
|
+
file: f
|
|
1009
|
+
});
|
|
1010
|
+
const kind_by_component = /* @__PURE__ */ new Map();
|
|
1011
|
+
try {
|
|
1012
|
+
const r = transformHost(source, "/" + rel(f).replace(BACKSLASH_G, "/"), build_ctx(true));
|
|
1013
|
+
for (const isl of r?.islands ?? []) if (isl.componentPath) kind_by_component.set(base(isl.componentPath), isl.kind ?? "");
|
|
1014
|
+
} catch (e) {
|
|
1015
|
+
const raw = (e instanceof Error ? e.message : String(e)).replace(WS_G, " ").trim();
|
|
1016
|
+
if (UNKNOWN_PRESET_RE.test(raw)) preset_marks++;
|
|
1017
|
+
else violations.push({
|
|
1018
|
+
file: rel(f),
|
|
1019
|
+
severity: "error",
|
|
1020
|
+
msg: raw
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
for (const m of marks) {
|
|
1024
|
+
const kind = kind_by_component.get(base(m.component)) ?? "(mark only)";
|
|
1025
|
+
islands.push({
|
|
1026
|
+
component: m.component,
|
|
1027
|
+
local: m.local,
|
|
1028
|
+
kind,
|
|
1029
|
+
strategy: strategy_label(m.attrs),
|
|
1030
|
+
attrs: m.attrs,
|
|
1031
|
+
file: rel(f)
|
|
1032
|
+
});
|
|
1033
|
+
kinds[kind] = (kinds[kind] ?? 0) + 1;
|
|
1034
|
+
if (m.attrs.wake !== "none" && m.attrs.region !== "raw" && hole_fetch(m.attrs) == null) island_components.add(base(m.component));
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
for (const { marks, file } of per_file) {
|
|
1038
|
+
const this_base = base(file).replace(SVELTE_EXT_RE, "");
|
|
1039
|
+
if (!island_components.has(base(file))) continue;
|
|
1040
|
+
for (const m of marks) {
|
|
1041
|
+
if (m.attrs.wake === "none" || m.attrs.region === "raw") continue;
|
|
1042
|
+
violations.push({
|
|
1043
|
+
file: rel(file),
|
|
1044
|
+
severity: "warn",
|
|
1045
|
+
msg: `${m.component} is marked inside ${this_base} — but ${this_base} is itself an island elsewhere, so this is island-in-island: the child shares the parent's JS and its own wake is ignored (dev warns). Only the closest marked parent's schedule wins.`
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
if (!islands.length) return text(`Scanned ${files.length} .svelte file(s) under ${dir} — no marked regions. The whole tree is free server HTML.`);
|
|
1050
|
+
const by_file = /* @__PURE__ */ new Map();
|
|
1051
|
+
for (const i of islands) (by_file.get(i.file) ?? by_file.set(i.file, []).get(i.file)).push(i);
|
|
1052
|
+
const map_lines = [...by_file.entries()].map(([file, list]) => `### ${file}\n${list.map((i) => `- ${i.component} (as ${i.local}) — ${i.strategy}`).join("\n")}`).join("\n\n");
|
|
1053
|
+
const kind_summary = Object.entries(kinds).map(([k, n]) => `${n} ${k}`).join(" · ");
|
|
1054
|
+
const errs = violations.filter((v) => v.severity === "error");
|
|
1055
|
+
const warns = violations.filter((v) => v.severity === "warn");
|
|
1056
|
+
const vio_block = violations.length ? `\n\n## ⚠️ Findings (${violations.length})\n` + [...errs, ...warns].map((v) => `- ${v.severity === "error" ? "❌" : "⚠️"} ${v.file}: ${v.msg}`).join("\n") : `\n\n## ✅ No rule violations across ${files.length} files.`;
|
|
1057
|
+
const preset_note = preset_marks ? `\n\n> ${preset_marks} import(s) use a \`preset\` — resolved from your \`ogygia({ presets })\` config at build, not checked here.` : "";
|
|
1058
|
+
return text(`# ogygia scan — ${dir}\n\n${islands.length} marked region(s) across ${by_file.size} file(s) (${files.length} .svelte scanned) · ${kind_summary}\n\n## Island map\n${map_lines}` + vio_block + preset_note);
|
|
1059
|
+
}
|
|
1060
|
+
function walk_flag_files(dir, out = [], depth = 0) {
|
|
1061
|
+
if (depth > 12 || out.length > 3e3) return out;
|
|
1062
|
+
let entries;
|
|
1063
|
+
try {
|
|
1064
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1065
|
+
} catch {
|
|
1066
|
+
return out;
|
|
1067
|
+
}
|
|
1068
|
+
for (const e of entries) {
|
|
1069
|
+
if (e.name.startsWith(".")) continue;
|
|
1070
|
+
if (e.isDirectory()) {
|
|
1071
|
+
if (!SKIP_DIRS.has(e.name)) walk_flag_files(path.join(dir, e.name), out, depth + 1);
|
|
1072
|
+
} else if (e.name.endsWith(".ts") && !e.name.endsWith(".d.ts") || e.name.endsWith(".svelte")) out.push(path.join(dir, e.name));
|
|
1073
|
+
}
|
|
1074
|
+
return out;
|
|
1075
|
+
}
|
|
1076
|
+
function tool_flags(args) {
|
|
1077
|
+
const dir = String(args.dir ?? ".");
|
|
1078
|
+
const root = path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir);
|
|
1079
|
+
let manifest = null;
|
|
1080
|
+
try {
|
|
1081
|
+
manifest = JSON.parse(readFileSync(path.join(root, "node_modules", ".ogygia", "flags-manifest.json"), "utf8"));
|
|
1082
|
+
} catch {
|
|
1083
|
+
manifest = null;
|
|
1084
|
+
}
|
|
1085
|
+
const files = walk_flag_files(path.join(root, "src"));
|
|
1086
|
+
const sites = [];
|
|
1087
|
+
for (const f of files.slice(0, 3e3)) {
|
|
1088
|
+
let source;
|
|
1089
|
+
try {
|
|
1090
|
+
source = readFileSync(f, "utf8");
|
|
1091
|
+
} catch {
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
const rel = path.relative(root, f).replace(BACKSLASH_G, "/");
|
|
1095
|
+
sites.push(...collect_flag_sites(source, rel, rel));
|
|
1096
|
+
}
|
|
1097
|
+
const live = flags_manifest(sites);
|
|
1098
|
+
if (!live.flags.length && !manifest) return text(`No flag() call sites under ${dir}/src (${files.length} file(s) scanned), and no build manifest. Flags are defined with \`flag('name', …)\` from '@ogygia/flags' — literal names only (a dynamic first argument is invisible to the inventory).`);
|
|
1099
|
+
const by_name = /* @__PURE__ */ new Map();
|
|
1100
|
+
for (const s of live.flags) {
|
|
1101
|
+
const k = `${s.name} (${s.kind})`;
|
|
1102
|
+
const list = by_name.get(k) ?? [];
|
|
1103
|
+
if (!list.length) by_name.set(k, list);
|
|
1104
|
+
list.push(s);
|
|
1105
|
+
}
|
|
1106
|
+
const site_lines = [...by_name.entries()].map(([k, list]) => `### ${k}\n${list.map((s) => `- ${s.file}:${s.line}`).join("\n")}`).join("\n\n");
|
|
1107
|
+
const live_names = new Set(live.names);
|
|
1108
|
+
const stale = manifest ? manifest.names.filter((n) => !live_names.has(n)) : [];
|
|
1109
|
+
const manifest_note = !manifest ? `\n\n> No build manifest yet — node_modules/.ogygia/flags-manifest.json appears after a client build.` : stale.length ? `\n\n> ⚠️ In the last build's manifest but NOT in source now (deleted or renamed since): ${stale.join(", ")}. Rebuild to refresh.` : `\n\n> Matches the last build's manifest.`;
|
|
1110
|
+
return text(`# ogygia flags — ${dir}\n\n${live.names.length} flag(s) · ${live.flags.length} call site(s) across ${files.length} scanned file(s)\n\n` + site_lines + manifest_note + `\n\n> Override in dev: \`?og-exp=<name>:<variant>\` (repeat or comma-separate). Prod honors overrides only behind \`decide({ overrides })\`.`);
|
|
1111
|
+
}
|
|
1112
|
+
async function tool_fragment(args) {
|
|
1113
|
+
const raw = String(args.origin ?? "");
|
|
1114
|
+
if (!raw) return fail("Pass `origin` — the MFE origin to probe (e.g. https://cms.internal).");
|
|
1115
|
+
let origin;
|
|
1116
|
+
try {
|
|
1117
|
+
origin = new URL(raw).origin;
|
|
1118
|
+
} catch {
|
|
1119
|
+
return fail(`Not a valid origin: ${raw}`);
|
|
1120
|
+
}
|
|
1121
|
+
const get = async (url) => {
|
|
1122
|
+
try {
|
|
1123
|
+
return await fetch(url, {
|
|
1124
|
+
signal: AbortSignal.timeout(1e4),
|
|
1125
|
+
headers: { accept: "application/json" }
|
|
1126
|
+
});
|
|
1127
|
+
} catch {
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
const page_res = await get(`${origin}/og/fragment/page?path=${encodeURIComponent("/")}`);
|
|
1132
|
+
const posture = !page_res ? "❌ unreachable (network error / timeout)" : page_res.status === 401 ? "🔒 signature-verified — an unsigned request is rejected (good)" : page_res.status === 200 ? "⚠️ OPEN — served an UNSIGNED request. Unless this origin is private-network-only, add each caller as a peer with its public `key` in federate({ peers }) (or `open: true` on purpose)." : page_res.status === 404 ? "not exposed here (no /og/fragment/page — page mounting unavailable)" : `HTTP ${page_res.status}`;
|
|
1133
|
+
const cat_res = await get(`${origin}/og/fragment/__catalog`);
|
|
1134
|
+
let widgets_block = "No widget catalog (federate({ widgets }) not declared, or not an ogygia fragment origin).";
|
|
1135
|
+
if (cat_res?.ok) try {
|
|
1136
|
+
const manifest = await cat_res.json();
|
|
1137
|
+
const names = manifest.names ?? [];
|
|
1138
|
+
widgets_block = names.length ? names.map((n) => `- \`${n}(${(manifest.widgets?.[n]?.props ?? []).join(", ")})\``).join("\n") + `\n\nTyped stubs: \`npx ogygia fragments ${origin} --out src/lib/widgets.ts\` (add \`--check\` in CI to fail on drift).` : "Catalog mounted, zero widgets declared.";
|
|
1139
|
+
} catch {
|
|
1140
|
+
widgets_block = "__catalog returned non-JSON — not an ogygia widget catalog.";
|
|
1141
|
+
}
|
|
1142
|
+
return text(`# ogygia fragment probe — ${origin}\n\n- routes endpoint (\`/og/fragment/page\`): ${posture}\n\n## Widgets (\`__catalog\`)\n${widgets_block}`);
|
|
1143
|
+
}
|
|
1144
|
+
async function dispatch_tool(name, args) {
|
|
1145
|
+
switch (name) {
|
|
1146
|
+
case "ogygia_compile": return tool_compile(args);
|
|
1147
|
+
case "ogygia_islands": return tool_islands(args);
|
|
1148
|
+
case "ogygia_check": return tool_check(args);
|
|
1149
|
+
case "ogygia_explain": return tool_explain(args);
|
|
1150
|
+
case "ogygia_debug": return tool_debug(args);
|
|
1151
|
+
case "ogygia_profile": return tool_profile(args);
|
|
1152
|
+
case "ogygia_profile_open": return tool_profile_open(args);
|
|
1153
|
+
case "ogygia_observatory": return tool_observatory(args);
|
|
1154
|
+
case "ogygia_scan": return tool_scan(args);
|
|
1155
|
+
case "ogygia_flags": return tool_flags(args);
|
|
1156
|
+
case "ogygia_fragment": return tool_fragment(args);
|
|
1157
|
+
default: return fail(`Unknown tool: ${name}`);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
function send(msg) {
|
|
1161
|
+
process.stdout.write(JSON.stringify(msg) + "\n");
|
|
1162
|
+
}
|
|
1163
|
+
function log(...parts) {
|
|
1164
|
+
process.stderr.write("[ogygia mcp] " + parts.map(String).join(" ") + "\n");
|
|
1165
|
+
}
|
|
1166
|
+
function handle(msg) {
|
|
1167
|
+
const { id, method, params } = msg;
|
|
1168
|
+
if (method === "initialize") {
|
|
1169
|
+
send({
|
|
1170
|
+
jsonrpc: "2.0",
|
|
1171
|
+
id,
|
|
1172
|
+
result: {
|
|
1173
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1174
|
+
capabilities: { tools: {} },
|
|
1175
|
+
serverInfo: {
|
|
1176
|
+
name: SERVER_NAME,
|
|
1177
|
+
version
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if (method === "tools/list") {
|
|
1184
|
+
send({
|
|
1185
|
+
jsonrpc: "2.0",
|
|
1186
|
+
id,
|
|
1187
|
+
result: { tools: TOOLS }
|
|
1188
|
+
});
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
if (method === "tools/call") {
|
|
1192
|
+
dispatch_tool(String(params?.name ?? ""), params?.arguments ?? {}).then((result) => send({
|
|
1193
|
+
jsonrpc: "2.0",
|
|
1194
|
+
id,
|
|
1195
|
+
result
|
|
1196
|
+
})).catch((e) => send({
|
|
1197
|
+
jsonrpc: "2.0",
|
|
1198
|
+
id,
|
|
1199
|
+
result: fail(`Internal error: ${e instanceof Error ? e.message : String(e)}`)
|
|
1200
|
+
}));
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if (method === "ping") {
|
|
1204
|
+
send({
|
|
1205
|
+
jsonrpc: "2.0",
|
|
1206
|
+
id,
|
|
1207
|
+
result: {}
|
|
1208
|
+
});
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
if (method?.startsWith("notifications/") || id == null) return;
|
|
1212
|
+
send({
|
|
1213
|
+
jsonrpc: "2.0",
|
|
1214
|
+
id,
|
|
1215
|
+
error: {
|
|
1216
|
+
code: -32601,
|
|
1217
|
+
message: `Method not found: ${method}`
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
/** Start the stdio MCP server. Resolves when stdin closes (the client disconnected). */
|
|
1222
|
+
async function runMcp() {
|
|
1223
|
+
log(`ogygia MCP server v${version} ready — ${TOOLS.length} tools (${TOOLS.map((t) => t.name.replace("ogygia_", "")).join(", ")})`);
|
|
1224
|
+
const rl = createInterface({
|
|
1225
|
+
input: process.stdin,
|
|
1226
|
+
crlfDelay: Infinity
|
|
1227
|
+
});
|
|
1228
|
+
for await (const line of rl) {
|
|
1229
|
+
const trimmed = line.trim();
|
|
1230
|
+
if (!trimmed) continue;
|
|
1231
|
+
let msg;
|
|
1232
|
+
try {
|
|
1233
|
+
msg = JSON.parse(trimmed);
|
|
1234
|
+
} catch {
|
|
1235
|
+
log("dropped non-JSON line");
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
try {
|
|
1239
|
+
handle(msg);
|
|
1240
|
+
} catch (e) {
|
|
1241
|
+
log("handler threw:", e instanceof Error ? e.message : String(e));
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
export { render_profile, runMcp };
|