sbuilder-mcp 0.48.1 → 0.50.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/CHANGELOG.md +17 -0
- package/CHANGELOG.vi.md +17 -0
- package/dist/catalog/element-search.js +45 -0
- package/dist/domains/site/document.js +22 -0
- package/dist/domains/site/findings.js +4 -0
- package/dist/domains/site/review.js +72 -0
- package/dist/tools/live.js +32 -9
- package/dist/tools/page.js +156 -6
- package/dist/tools/session.js +16 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ All notable changes to this project are documented in this file.
|
|
|
6
6
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
7
7
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
8
|
|
|
9
|
+
## [0.50.0] - 2026-09-15
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- `sb_catalog_search` raises its `limit` ceiling from 30 to 60 so a category can be read whole; the `basic` category alone holds 39 element types, and a 30-cap silently dropped the nine an agent had never used, the same failure browsing exists to fix.
|
|
13
|
+
- `sb_catalog_search`'s browse note now tells the caller to pass a category name with `limit` 60 to read that whole group's descriptions at once, on top of passing a type for its fields.
|
|
14
|
+
|
|
15
|
+
## [0.49.0] - 2026-09-15
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- `sb_catalog_search` now returns every element type, grouped by category, when `query` is omitted, so an agent can browse the catalogue instead of only searching for a name it already knows.
|
|
19
|
+
- `sb_review` reports a `handbuilt_menu` finding when a header contains three or more sibling buttons and nothing else, since a row of buttons cannot supply the mobile drawer, sub-level dropdown, or active state that the platform's own `menu` element brings.
|
|
20
|
+
- Opening a page now joins the live-edit room on its own, through `sb_page_open`, `sb_page_create`, and `sb_template_use` alike, instead of requiring a separate `sb_live_join` call that an agent has to remember to make; a write retries the join if an earlier attempt failed, and `sb_page_open` reports when it newly joined the room or could not.
|
|
21
|
+
- `sb_connect` now mentions the live-edit room in its connect note, in the one place an agent is guaranteed to read on the way in.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- A page whose read came back empty because the fetch failed open no longer risks silently emptying the page on save; `sb_page_open` now reports a `seeded_empty` warning on that page, and `PageSession.save` re-checks the server before writing and refuses the save if the server actually holds content, asking the caller to re-open and reapply instead.
|
|
25
|
+
|
|
9
26
|
## [0.48.1] - 2026-09-14
|
|
10
27
|
|
|
11
28
|
### Fixed
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,23 @@ Mọi thay đổi đáng chú ý của dự án được ghi lại trong file n
|
|
|
6
6
|
Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
7
7
|
và dự án tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
8
|
|
|
9
|
+
## [0.50.0] - 2026-09-15
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- `sb_catalog_search` nâng trần `limit` từ 30 lên 60 để có thể đọc trọn một category; riêng category `basic` đã có 39 loại phần tử, và mức trần 30 âm thầm bỏ sót chín phần tử mà agent chưa từng dùng đến — đúng lỗi mà tính năng duyệt catalog vốn sinh ra để khắc phục.
|
|
13
|
+
- Ghi chú duyệt catalog của `sb_catalog_search` giờ hướng dẫn người gọi truyền tên một category cùng `limit` 60 để đọc toàn bộ mô tả của nhóm đó một lần, bên cạnh việc truyền một loại phần tử để xem các trường lựa chọn của nó.
|
|
14
|
+
|
|
15
|
+
## [0.49.0] - 2026-09-15
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- `sb_catalog_search` giờ trả về mọi loại phần tử, nhóm theo category, khi bỏ trống `query`, nhờ đó agent có thể duyệt qua toàn bộ catalog thay vì chỉ tìm kiếm theo tên đã biết trước.
|
|
19
|
+
- `sb_review` giờ báo lỗi `handbuilt_menu` khi một header chứa từ ba nút button anh em trở lên và không có gì khác, vì một hàng button không thể thay thế drawer cho điện thoại, dropdown cho cấp con, hay trạng thái active cho trang khách đang xem — những thứ mà phần tử `menu` của nền tảng mang lại.
|
|
20
|
+
- Việc mở một trang giờ tự động tham gia phòng live-edit, qua cả `sb_page_open`, `sb_page_create` lẫn `sb_template_use`, thay vì phải gọi riêng `sb_live_join` mà agent phải tự nhớ; một lần ghi sẽ thử tham gia lại nếu lần trước thất bại, và `sb_page_open` báo cáo khi nó vừa tham gia phòng thành công hay không thể.
|
|
21
|
+
- `sb_connect` giờ nhắc đến phòng live-edit trong ghi chú kết nối, ngay tại nơi chắc chắn agent sẽ đọc khi mới vào.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- Một lần đọc trang trả về rỗng vì việc fetch thất bại theo kiểu "mở" giờ không còn nguy cơ âm thầm xoá sạch trang khi lưu; `sb_page_open` giờ báo cảnh báo `seeded_empty` trên trang đó, và `PageSession.save` kiểm tra lại với server trước khi ghi, từ chối lưu nếu server thực sự đang giữ nội dung, và yêu cầu người gọi mở lại trang rồi áp dụng lại thay đổi.
|
|
25
|
+
|
|
9
26
|
## [0.48.1] - 2026-09-14
|
|
10
27
|
|
|
11
28
|
### Fixed
|
|
@@ -3,6 +3,51 @@ import { animationValues, animationVocabulary, elementVocabularies, sharedVocabu
|
|
|
3
3
|
import { neverTranslatedOn, translatableSpecials } from '../domains/site/translate.js';
|
|
4
4
|
/** Eight to choose from; the hints for the chosen one come with sb_traits_for. */
|
|
5
5
|
export const DEFAULT_CATALOG_LIMIT = 8;
|
|
6
|
+
/**
|
|
7
|
+
* EVERY element there is, grouped the way the palette files them.
|
|
8
|
+
*
|
|
9
|
+
* `catalogMatches` below is a SEARCH, and a search only returns what the caller
|
|
10
|
+
* already thought to ask for. Measured on a store built with these tools: the
|
|
11
|
+
* home page used 17 element types out of the hundred-odd here, and the ones it
|
|
12
|
+
* reached for were the five primitives any agent already knows — flex-block,
|
|
13
|
+
* text, heading, button, image. Four category cards were hand-assembled out of
|
|
14
|
+
* them; so were four feature blocks and a stats row. The header's navigation
|
|
15
|
+
* was SIX BUTTONS in a flex-block, on a platform carrying menu, menu-item,
|
|
16
|
+
* menu-dropdown, menu-panel, menu-drawer and hamburger-menu — so that site has
|
|
17
|
+
* no mobile menu at all, and nothing said so.
|
|
18
|
+
*
|
|
19
|
+
* None of that is the agent being careless. It is a catalogue you can only
|
|
20
|
+
* query by name refusing to tell anyone what is in it: rating-stars, carousel,
|
|
21
|
+
* tab, google-map, image-comparison, text-marquee, video, quickview,
|
|
22
|
+
* currency-switcher and popup cannot be searched for by someone who does not
|
|
23
|
+
* know they exist.
|
|
24
|
+
*
|
|
25
|
+
* TYPE AND LABEL ONLY, no descriptions. This is the list you read to find out
|
|
26
|
+
* what is POSSIBLE; `catalogMatches(type)` gives the four fields to choose by
|
|
27
|
+
* and `sb_traits_for` the hints, so repeating a description per element here
|
|
28
|
+
* would pay for the whole catalogue to answer a question about one element.
|
|
29
|
+
*/
|
|
30
|
+
export function catalogBrowse() {
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const el of Object.values(ELEMENTS)) {
|
|
33
|
+
(out[el.category] ??= []).push(`${el.type} — ${el.label}`);
|
|
34
|
+
}
|
|
35
|
+
for (const k of Object.keys(out))
|
|
36
|
+
out[k].sort();
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The biggest category, which is what `limit` has to clear for a caller to read
|
|
41
|
+
* one group WHOLE.
|
|
42
|
+
*
|
|
43
|
+
* A group query that silently returns its first 30 of 39 is the worst answer
|
|
44
|
+
* available here: it looks like the whole group, and the nine it dropped are
|
|
45
|
+
* exactly the elements nobody knew to look for. `catalog-browse.test.ts` fails
|
|
46
|
+
* when a category outgrows the cap, which is the only way anyone would notice.
|
|
47
|
+
*/
|
|
48
|
+
export function largestCategorySize() {
|
|
49
|
+
return Math.max(...Object.values(catalogBrowse()).map((g) => g.length));
|
|
50
|
+
}
|
|
6
51
|
/**
|
|
7
52
|
* Four fields to CHOOSE by.
|
|
8
53
|
*
|
|
@@ -57,7 +57,9 @@ export class PageDoc {
|
|
|
57
57
|
// The genuinely broken case is still refused below: a document that HAS
|
|
58
58
|
// nodes but whose root_node_id names none of them is damage, not emptiness,
|
|
59
59
|
// and inventing a root there would strand every existing node as an orphan.
|
|
60
|
+
let seeded = false;
|
|
60
61
|
if (!d.root_node_id && Object.keys(d.nodes).length === 0) {
|
|
62
|
+
seeded = true;
|
|
61
63
|
d.root_node_id = 'ROOT';
|
|
62
64
|
d.nodes.ROOT = {
|
|
63
65
|
id: 'ROOT',
|
|
@@ -105,6 +107,7 @@ export class PageDoc {
|
|
|
105
107
|
}
|
|
106
108
|
const out = new PageDoc(d);
|
|
107
109
|
out.adoptedRootKey = adopted;
|
|
110
|
+
out.seededRoot = seeded;
|
|
108
111
|
return out;
|
|
109
112
|
}
|
|
110
113
|
/**
|
|
@@ -114,6 +117,25 @@ export class PageDoc {
|
|
|
114
117
|
* a fact no other surface reports, so the tool layer says it out loud.
|
|
115
118
|
*/
|
|
116
119
|
adoptedRootKey;
|
|
120
|
+
/**
|
|
121
|
+
* This ROOT was INVENTED, not read — the source came back `{ root_node_id: "",
|
|
122
|
+
* nodes: {} }` and the empty-document branch above seeded one.
|
|
123
|
+
*
|
|
124
|
+
* For a page the caller has just created that is the whole point, and nothing
|
|
125
|
+
* downstream needs to care. For a page that HAS content it is a read that
|
|
126
|
+
* failed open: the session now holds a blank tree it believes is the page, and
|
|
127
|
+
* the first save writes that blank tree over whatever the server holds. The
|
|
128
|
+
* two are indistinguishable AT READ TIME — the bytes are identical — so the
|
|
129
|
+
* distinction cannot be made here and must survive to the one place that can
|
|
130
|
+
* check it cheaply, which is `PageSession.save`.
|
|
131
|
+
*
|
|
132
|
+
* MEASURED: a product template carrying 24 nodes plus a shared header and
|
|
133
|
+
* footer came back bare to one session and was stored bare. The published copy
|
|
134
|
+
* was untouched, so all 19 product pages kept rendering while the draft the
|
|
135
|
+
* editor opens was blank — one publish from that draft would have taken every
|
|
136
|
+
* one of them down.
|
|
137
|
+
*/
|
|
138
|
+
seededRoot;
|
|
117
139
|
get rev() {
|
|
118
140
|
return this.revision;
|
|
119
141
|
}
|
|
@@ -42,6 +42,10 @@ export const FIX = {
|
|
|
42
42
|
'label-to-control gap INSIDE one field.',
|
|
43
43
|
unlinked_form: 'Point it at a real form: sb_set id "<id>", namespace specials, keys ' +
|
|
44
44
|
'{ "formId": "<a form id from sb_api_find \'list forms\'>" }.',
|
|
45
|
+
handbuilt_menu: 'Replace it with the real thing: sb_add a "menu" in place of the container "<id>", one ' +
|
|
46
|
+
'"menu-item" per link, and delete the buttons with sb_remove. Add a "menu-drawer" (with a ' +
|
|
47
|
+
'"hamburger-menu" trigger) for the phone, and "menu-dropdown" where a link has a sub-level. ' +
|
|
48
|
+
'sb_traits_for "menu" has the controls.',
|
|
45
49
|
dead_menu_link: 'Write the entries the renderer actually reads: sb_set id "<id>", namespace specials, keys ' +
|
|
46
50
|
'{ "menuItems": [{ "id": "mi-1", "label": "Shop", "href": "/shop" }] }. Setting menuId alone ' +
|
|
47
51
|
'publishes an empty nav — the Go renderer never reads it.',
|
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import { childrenOf, childrenWithSatellites, isOverlay, pageChildren, appBlockRoot, SPEC_GLOBAL_REF, SPEC_APP_BLOCK_REF } from '../../core/tree.js';
|
|
2
2
|
import { STUCK_STATE, stickyBlockedBy, stuckHostOf, isPinnedNode } from './sticky.js';
|
|
3
|
+
import { bandOf } from './traps.js';
|
|
3
4
|
import { HOVER_STATE, hoverHome } from './hover.js';
|
|
4
5
|
import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY, SATELLITE_RULES, ELEMENT_SEEDS } from '../../catalog/elements.generated.js';
|
|
5
6
|
import { fill } from './findings.js';
|
|
7
|
+
/**
|
|
8
|
+
* A defect somebody looking at the page would see.
|
|
9
|
+
*
|
|
10
|
+
* Distinct from `validateForSave`, which asks "will the platform store this
|
|
11
|
+
* tree" — dangling ids, orphans, band order. This asks the question a person
|
|
12
|
+
* asks: does the page WORK. A document can be perfectly storable and render as a
|
|
13
|
+
* blank band saying "Enter your text here".
|
|
14
|
+
*
|
|
15
|
+
* Every finding names the fix, because a warning that only states a problem is
|
|
16
|
+
* one the reader has to re-derive.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* How many buttons in a row read as a navigation rather than as a pair of calls
|
|
20
|
+
* to action. Three: two side by side is the ordinary hero shape.
|
|
21
|
+
*/
|
|
22
|
+
const HANDBUILT_MENU_MIN = 3;
|
|
6
23
|
/**
|
|
7
24
|
* Shipped with every non-empty finding list.
|
|
8
25
|
*
|
|
@@ -174,6 +191,61 @@ export function reviewDesign(doc) {
|
|
|
174
191
|
go(k, inner, overlay || overlayIds.has(k));
|
|
175
192
|
};
|
|
176
193
|
go(d.root_node_id);
|
|
194
|
+
// WHICH BAND EACH NODE LIVES IN, taken from the top-level section it descends
|
|
195
|
+
// from. `bandOf` answers for a ROOT child; everything below one inherits it.
|
|
196
|
+
const band = new Map();
|
|
197
|
+
for (const sectionId of pageChildren(d)) {
|
|
198
|
+
const b = bandOf(d, sectionId);
|
|
199
|
+
const stack = [sectionId];
|
|
200
|
+
while (stack.length) {
|
|
201
|
+
const id = stack.pop();
|
|
202
|
+
if (band.has(id))
|
|
203
|
+
continue;
|
|
204
|
+
band.set(id, b);
|
|
205
|
+
for (const k of childrenWithSatellites(d, id))
|
|
206
|
+
stack.push(k);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// BUILT BY HAND OUT OF PRIMITIVES, WHERE THE PLATFORM HAS THE ELEMENT.
|
|
210
|
+
//
|
|
211
|
+
// Every other check here asks whether something is BROKEN. This one asks
|
|
212
|
+
// whether it was built with the wrong thing, which is the defect no amount of
|
|
213
|
+
// looking at the page reveals: it renders correctly on the screen the author
|
|
214
|
+
// is looking at, and fails on the one they are not.
|
|
215
|
+
//
|
|
216
|
+
// Measured on a store built with these tools: the header's navigation was six
|
|
217
|
+
// `button` nodes in a flex-block. It looks right on a desktop canvas. On a
|
|
218
|
+
// phone there is no drawer, because a drawer is something `menu` brings and a
|
|
219
|
+
// row of buttons does not — so the site shipped with no mobile navigation and
|
|
220
|
+
// nothing anywhere said so.
|
|
221
|
+
//
|
|
222
|
+
// A TABLE, because this will not be the only one. The next pattern is a row
|
|
223
|
+
// here, not a second branch somewhere else.
|
|
224
|
+
//
|
|
225
|
+
// DELIBERATELY NARROW. Three or more buttons, ALL the container's children,
|
|
226
|
+
// all leaves, and only in the HEADER band. A footer's link column is the same
|
|
227
|
+
// shape and is legitimately a list of links; a two-button pair is a call to
|
|
228
|
+
// action, not a menu. The check that fires on those is one an author learns to
|
|
229
|
+
// ignore, which costs more than the one it catches.
|
|
230
|
+
for (const [containerId, childIds] of [...Object.keys(d.nodes)].map((id) => [id, childrenOf(d, id)])) {
|
|
231
|
+
if (band.get(containerId) !== 'header')
|
|
232
|
+
continue;
|
|
233
|
+
if (childIds.length < HANDBUILT_MENU_MIN)
|
|
234
|
+
continue;
|
|
235
|
+
const allLeafButtons = childIds.every((k) => d.nodes[k]?.data.type === 'button' && childrenOf(d, k).length === 0);
|
|
236
|
+
if (!allLeafButtons)
|
|
237
|
+
continue;
|
|
238
|
+
out.push({
|
|
239
|
+
code: 'handbuilt_menu',
|
|
240
|
+
nodeId: containerId,
|
|
241
|
+
type: d.nodes[containerId]?.data.type ?? '',
|
|
242
|
+
problem: `${childIds.length} buttons in a row in the header is a navigation built by hand. It ` +
|
|
243
|
+
'renders correctly on a wide canvas and has no drawer on a phone, no dropdown for a ' +
|
|
244
|
+
'sub-level, and no active state on the page the visitor is already reading — all three ' +
|
|
245
|
+
'are things the menu element brings and a row of buttons cannot.',
|
|
246
|
+
fix: fill('handbuilt_menu', {}),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
177
249
|
for (const id of walkOrder) {
|
|
178
250
|
if (id === d.root_node_id)
|
|
179
251
|
continue;
|
package/dist/tools/live.js
CHANGED
|
@@ -208,7 +208,38 @@ export function liveTokenFor(ctx) {
|
|
|
208
208
|
// carries the whole account.
|
|
209
209
|
return () => (ctx.apiKey ? ctx.apiKey : ctx.session.token());
|
|
210
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Open the room and hand it to the page session.
|
|
213
|
+
*
|
|
214
|
+
* EXTRACTED so joining is not something an agent has to REMEMBER. `sb_live_join`
|
|
215
|
+
* was opt-in and one call, which reads as cheap and is not: an agent that never
|
|
216
|
+
* makes it builds an entire site the watching merchant cannot see happening —
|
|
217
|
+
* no peer, no cursor, no element appearing as it lands. Nothing fails, so
|
|
218
|
+
* nothing prompts the question, and the person who asked for an agent watches a
|
|
219
|
+
* static canvas and concludes it is not working.
|
|
220
|
+
*
|
|
221
|
+
* Measured here: one session built 17 pages and 19 products over two hours with
|
|
222
|
+
* an editor open beside it and never joined.
|
|
223
|
+
*
|
|
224
|
+
* `PageSession.ensureLive` calls this on the first page open, which is where
|
|
225
|
+
* designing starts. The explicit tool stays, for a caller who wants to join a
|
|
226
|
+
* different site or re-join after a drop.
|
|
227
|
+
*/
|
|
228
|
+
export function joinRoom(ctx, session, siteId) {
|
|
229
|
+
const tokenFn = liveTokenFor(ctx);
|
|
230
|
+
const wsBase = ctx.base.replace(/^http/, 'ws').replace(/\/$/, '');
|
|
231
|
+
const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(siteId)}`, tokenFn);
|
|
232
|
+
const live = new LiveSession(socket, {
|
|
233
|
+
onRemote: (patches) => session.applyRemote(patches),
|
|
234
|
+
onDesync: (reason) => session.markStale(reason),
|
|
235
|
+
});
|
|
236
|
+
socket.connect();
|
|
237
|
+
session.attachLive(live);
|
|
238
|
+
}
|
|
211
239
|
export function registerLiveTools(server, ctx, session) {
|
|
240
|
+
// The page session joins on its own at the first sb_page_open. Registered
|
|
241
|
+
// here because this module owns the socket and the session must not import it.
|
|
242
|
+
session.setLiveJoiner((siteId) => joinRoom(ctx, session, siteId));
|
|
212
243
|
server.registerTool('sb_live_join', {
|
|
213
244
|
description: "Join the site's live-edit room as a visible peer: every write then appears in any open " +
|
|
214
245
|
'editor as it happens, with the agent shown by the API key\'s own name rather than a ' +
|
|
@@ -218,15 +249,7 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
218
249
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
219
250
|
}, async ({ site_id: given }) => {
|
|
220
251
|
const site_id = siteFor(ctx, given);
|
|
221
|
-
|
|
222
|
-
const wsBase = ctx.base.replace(/^http/, 'ws').replace(/\/$/, '');
|
|
223
|
-
const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(site_id)}`, tokenFn);
|
|
224
|
-
const live = new LiveSession(socket, {
|
|
225
|
-
onRemote: (patches) => session.applyRemote(patches),
|
|
226
|
-
onDesync: (reason) => session.markStale(reason),
|
|
227
|
-
});
|
|
228
|
-
socket.connect();
|
|
229
|
-
session.attachLive(live);
|
|
252
|
+
joinRoom(ctx, session, site_id);
|
|
230
253
|
return text({
|
|
231
254
|
joined: site_id,
|
|
232
255
|
note: 'Edits now publish to the room as they are made. Call sb_page_open next.',
|
package/dist/tools/page.js
CHANGED
|
@@ -19,7 +19,7 @@ import { compactFindings } from '../domains/site/findings.js';
|
|
|
19
19
|
import { readinessGaps, READINESS_NOTICE } from '../domains/site/readiness.js';
|
|
20
20
|
import { gatherReadiness } from '../domains/site/readiness-fetch.js';
|
|
21
21
|
import { globalWarning, restampPatches, RESPONSIVE_NOTICE } from '../domains/site/traps.js';
|
|
22
|
-
import { catalogMatches, traitsFor } from '../catalog/element-search.js';
|
|
22
|
+
import { catalogBrowse, catalogMatches, traitsFor } from '../catalog/element-search.js';
|
|
23
23
|
import { LAYOUT_PATTERNS, PATTERN_BY_ID, THEME_TOKENS, } from '../domains/site/patterns.js';
|
|
24
24
|
import { tokensFromPage } from '../domains/site/importmap.js';
|
|
25
25
|
import { middleEnd } from '../domains/site/traps.js';
|
|
@@ -67,6 +67,8 @@ export class PageSession {
|
|
|
67
67
|
* fence `restampPatches` exists to keep honest.
|
|
68
68
|
*/
|
|
69
69
|
savedRev = -1;
|
|
70
|
+
/** The open read handed back an empty document and a ROOT was invented for it. */
|
|
71
|
+
openedSeeded = false;
|
|
70
72
|
warnings = [];
|
|
71
73
|
boxes = [];
|
|
72
74
|
constructor(ctx) {
|
|
@@ -75,6 +77,49 @@ export class PageSession {
|
|
|
75
77
|
attachLive(live) {
|
|
76
78
|
this.live = live;
|
|
77
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* How to open the live-edit room, handed over by `registerLiveTools`.
|
|
82
|
+
*
|
|
83
|
+
* A CALLBACK rather than an import, because `live.ts` already imports this
|
|
84
|
+
* class and the reverse edge would be a cycle. The direction that matters is
|
|
85
|
+
* the one the design has: the room knows about the page session, not the other
|
|
86
|
+
* way round.
|
|
87
|
+
*/
|
|
88
|
+
liveJoiner = null;
|
|
89
|
+
setLiveJoiner(fn) {
|
|
90
|
+
this.liveJoiner = fn;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* JOIN BEFORE THE FIRST EDIT, rather than when an agent remembers to.
|
|
94
|
+
*
|
|
95
|
+
* `sb_live_join` is one call and reads as cheap, which is exactly why it gets
|
|
96
|
+
* skipped: nothing fails without it. The room is simply empty, so a merchant
|
|
97
|
+
* watching their own site being built sees a static canvas and concludes the
|
|
98
|
+
* agent is not working. Measured here — one session built 17 pages and 19
|
|
99
|
+
* products over two hours with an editor open beside it and never joined.
|
|
100
|
+
*
|
|
101
|
+
* Opening a page is where designing starts, so that is where this fires. The
|
|
102
|
+
* room ALWAYS YIELDS to a human (see the yield rule), so joining early costs
|
|
103
|
+
* a socket and risks nothing.
|
|
104
|
+
*
|
|
105
|
+
* FAILURE IS NOT FATAL. No credential, no network, a server without the
|
|
106
|
+
* endpoint — none of those are reasons to refuse to open a page. The caller is
|
|
107
|
+
* told, and goes on designing with nobody watching, which is the old behaviour
|
|
108
|
+
* rather than a new one.
|
|
109
|
+
*/
|
|
110
|
+
async ensureLive(siteId) {
|
|
111
|
+
if (this.live)
|
|
112
|
+
return 'already';
|
|
113
|
+
if (!this.liveJoiner)
|
|
114
|
+
return 'no live transport is registered';
|
|
115
|
+
try {
|
|
116
|
+
this.liveJoiner(siteId);
|
|
117
|
+
return this.live ? 'joined' : 'the live transport did not attach';
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
return e.message.replace(/^sbuilder:\s*/, '').slice(0, 160);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
78
123
|
location() {
|
|
79
124
|
this.current();
|
|
80
125
|
return { siteId: this.siteId, pageId: this.pageId };
|
|
@@ -126,6 +171,19 @@ export class PageSession {
|
|
|
126
171
|
*/
|
|
127
172
|
async applyAndSave(patches) {
|
|
128
173
|
const d = this.current();
|
|
174
|
+
// EVERY WRITE CHECKS THE ROOM, not only the first page open.
|
|
175
|
+
//
|
|
176
|
+
// Joining once at open is right until the once fails. No network for a
|
|
177
|
+
// moment, a credential that arrived late, a server that had not brought the
|
|
178
|
+
// endpoint up yet — any of those leave a session that edits for an hour with
|
|
179
|
+
// nobody watching and no second attempt, which is the same silence this
|
|
180
|
+
// whole path exists to end.
|
|
181
|
+
//
|
|
182
|
+
// FREE WHEN JOINED: `ensureLive` returns on a null check, so the steady state
|
|
183
|
+
// costs one branch per write. A dropped socket is NOT this method's problem —
|
|
184
|
+
// `RealtimeSocket` owns its own reconnect with backoff, and re-attaching a
|
|
185
|
+
// second LiveSession over a live one would be the bug, not the fix.
|
|
186
|
+
await this.ensureLive(this.siteId);
|
|
129
187
|
const before = new Set(validateForSave(d));
|
|
130
188
|
const after = validateForSave(d.preview(patches));
|
|
131
189
|
const introduced = after.filter((p) => !before.has(p));
|
|
@@ -174,8 +232,24 @@ export class PageSession {
|
|
|
174
232
|
this.warnings = composeWarnings(src.warnings);
|
|
175
233
|
// Freshly pulled IS the stored state.
|
|
176
234
|
this.savedRev = this.doc.rev;
|
|
235
|
+
// WHAT THE READ ACTUALLY RETURNED, kept so `save` can tell a page this
|
|
236
|
+
// session emptied from a page that arrived empty because the read failed
|
|
237
|
+
// open. `PageDoc.from` seeds a ROOT for `{ root_node_id: "", nodes: {} }`,
|
|
238
|
+
// which is right for a page just created and catastrophic for one that has
|
|
239
|
+
// content — and the two are identical bytes, so only the save can judge.
|
|
240
|
+
this.openedSeeded = this.doc.seededRoot === true;
|
|
241
|
+
// A PAGE IS NOW ON THE CANVAS, so the room is joined here rather than in the
|
|
242
|
+
// one tool that happens to be the usual way in. `sb_page_create`,
|
|
243
|
+
// `sb_template_use` and `shareChrome` all open pages too, and a join wired to
|
|
244
|
+
// sb_page_open alone leaves every one of those editing unseen.
|
|
245
|
+
this.liveState = await this.ensureLive(siteId);
|
|
177
246
|
return this.doc.outline();
|
|
178
247
|
}
|
|
248
|
+
/** What the last open's join attempt did, for the tool that reports it. */
|
|
249
|
+
liveState = 'already';
|
|
250
|
+
liveStatus() {
|
|
251
|
+
return this.liveState;
|
|
252
|
+
}
|
|
179
253
|
/** What the server said it could not compose when this page was opened. */
|
|
180
254
|
composeWarnings() {
|
|
181
255
|
return this.warnings;
|
|
@@ -245,6 +319,48 @@ export class PageSession {
|
|
|
245
319
|
if (problems.length > 0) {
|
|
246
320
|
throw new Error(`sbuilder: refusing to save — ${problems.join(' ')}`);
|
|
247
321
|
}
|
|
322
|
+
// A READ THAT FAILED OPEN MUST NOT BECOME A WRITE THAT EMPTIES THE PAGE.
|
|
323
|
+
//
|
|
324
|
+
// `PageDoc.from` seeds a ROOT when the source comes back
|
|
325
|
+
// `{ root_node_id: "", nodes: {} }`. For a page the caller has just created
|
|
326
|
+
// that is the whole point. For a page that HAS content it is a blank tree
|
|
327
|
+
// this session now believes is the page, and the first save stores it over
|
|
328
|
+
// whatever the server holds — silently, because every later gate agrees a
|
|
329
|
+
// bare ROOT is a valid document.
|
|
330
|
+
//
|
|
331
|
+
// MEASURED, not reasoned about. A product template carrying 24 nodes plus a
|
|
332
|
+
// shared header and footer came back bare to one session and was stored
|
|
333
|
+
// bare. The PUBLISHED copy was untouched, so all 19 product pages kept
|
|
334
|
+
// rendering while the draft the editor opens was blank; the damage was
|
|
335
|
+
// invisible until a person opened the page, and one publish from that draft
|
|
336
|
+
// would have taken every one of those 19 down at once.
|
|
337
|
+
//
|
|
338
|
+
// So when the read was seeded, this asks the server what it actually holds
|
|
339
|
+
// before writing. The round trip is paid ONLY here — a seeded read is the
|
|
340
|
+
// first save of a new page or this bug, and nothing else. A page the server
|
|
341
|
+
// also reports as empty is genuinely new and the write goes through.
|
|
342
|
+
//
|
|
343
|
+
// Refusing rather than re-pulling and merging: the session cannot know which
|
|
344
|
+
// of its edits belong on the real tree, and a merge that guesses is how a
|
|
345
|
+
// caller ends up with a page that is neither what it built nor what was
|
|
346
|
+
// there. The caller re-opens and reapplies, which is the yield rule's answer
|
|
347
|
+
// to every other divergence and is already the reflex these tools teach.
|
|
348
|
+
if (this.openedSeeded) {
|
|
349
|
+
const stored = await loadSource(this.ctx, this.siteId, this.pageId);
|
|
350
|
+
const held = (stored.document ?? {});
|
|
351
|
+
const heldCount = Object.keys(held.nodes ?? {}).length;
|
|
352
|
+
if (heldCount > 0) {
|
|
353
|
+
this.openedSeeded = false;
|
|
354
|
+
await this.open(this.siteId, this.pageId);
|
|
355
|
+
throw new Error(`sbuilder: refusing to save — this page was read as EMPTY and a ROOT was invented for ` +
|
|
356
|
+
`it, but the server holds ${heldCount} node(s). Writing would have erased the page. ` +
|
|
357
|
+
'It has been re-loaded from the server; re-read it with sb_outline and reapply your ' +
|
|
358
|
+
'change.');
|
|
359
|
+
}
|
|
360
|
+
// The server agrees the page is empty, so the seeded ROOT is this page's
|
|
361
|
+
// first real tree and every save after this one is an ordinary save.
|
|
362
|
+
this.openedSeeded = false;
|
|
363
|
+
}
|
|
248
364
|
const saved = await saveSource(this.ctx, this.siteId, this.pageId, d.doc);
|
|
249
365
|
// RE-STAMP THE FENCE, or lose every edit after this one.
|
|
250
366
|
//
|
|
@@ -377,6 +493,8 @@ export function registerPageTools(server, ctx) {
|
|
|
377
493
|
annotations: { readOnlyHint: true },
|
|
378
494
|
}, async ({ site_id: given, page_id }) => {
|
|
379
495
|
const outline = await session.open(siteFor(ctx, given), page_id);
|
|
496
|
+
// `open` joined the room on the way through — this only reports what it did.
|
|
497
|
+
const live = session.liveStatus();
|
|
380
498
|
const doc = session.current();
|
|
381
499
|
// A page whose stored document named its root under the app-block key
|
|
382
500
|
// renders as an empty <body> and says nothing about why. Nobody else can
|
|
@@ -386,10 +504,28 @@ export function registerPageTools(server, ctx) {
|
|
|
386
504
|
'the renderer finds no root and publishes an EMPTY BODY. The next save from here writes ' +
|
|
387
505
|
'the canonical key and fixes it; publish afterwards.'
|
|
388
506
|
: undefined;
|
|
507
|
+
// AN EMPTY READ IS REPORTED, because the caller is the only one who knows
|
|
508
|
+
// whether this page is supposed to be empty. A page just created reads
|
|
509
|
+
// this and carries on; a page that was built reads it and stops — which is
|
|
510
|
+
// the whole difference between noticing now and noticing after a publish.
|
|
511
|
+
const seeded_empty = doc.seededRoot
|
|
512
|
+
? 'This page came back EMPTY and a ROOT was seeded for it. That is expected for a page ' +
|
|
513
|
+
'you just created. If this page HAD content, do not edit or publish it — the draft ' +
|
|
514
|
+
'read is blank, not the page: re-open it, and if it is still blank restore it from ' +
|
|
515
|
+
'GET /api/sites/{siteId}/pages/{pageId}/history.'
|
|
516
|
+
: undefined;
|
|
389
517
|
const warnings = session.composeWarnings();
|
|
390
518
|
return text({
|
|
391
519
|
outline,
|
|
520
|
+
// Said ONLY when it is news. 'already' is the steady state after the
|
|
521
|
+
// first open and repeating it on every page is the shape that drifts.
|
|
522
|
+
...(live === 'joined'
|
|
523
|
+
? { live: 'Joined the live-edit room — anyone with this site open sees these edits as they land.' }
|
|
524
|
+
: live === 'already'
|
|
525
|
+
? {}
|
|
526
|
+
: { live_unavailable: live }),
|
|
392
527
|
...(blank_page_repair ? { blank_page_repair } : {}),
|
|
528
|
+
...(seeded_empty ? { seeded_empty } : {}),
|
|
393
529
|
...(warnings.length ? { compose_warnings: warnings } : {}),
|
|
394
530
|
...reviewField(ctx, doc),
|
|
395
531
|
});
|
|
@@ -420,15 +556,29 @@ export function registerPageTools(server, ctx) {
|
|
|
420
556
|
return text({ node, ...(preset ? { preset } : {}), ...(warn ? { warning: warn } : {}) });
|
|
421
557
|
});
|
|
422
558
|
server.registerTool('sb_catalog_search', {
|
|
423
|
-
description: 'Find an element type by what
|
|
424
|
-
|
|
559
|
+
description: 'Find an element type by what it does — or OMIT query to browse every type, the only ' +
|
|
560
|
+
'way to meet one you would not have searched for. detail:true adds the AI hints, as ' +
|
|
561
|
+
'does sb_traits_for.',
|
|
425
562
|
inputSchema: {
|
|
426
|
-
query: z.string(),
|
|
427
|
-
limit: z.number().int().min(1).max(
|
|
563
|
+
query: z.string().optional(),
|
|
564
|
+
limit: z.number().int().min(1).max(60).optional().describe('Default 8'),
|
|
428
565
|
detail: z.boolean().optional().describe('Include useWhen / avoidWhen / contentTips per match'),
|
|
429
566
|
},
|
|
430
567
|
annotations: { readOnlyHint: true },
|
|
431
|
-
},
|
|
568
|
+
},
|
|
569
|
+
// A SEARCH CANNOT INTRODUCE YOU TO ANYTHING. Omitting the query browses the
|
|
570
|
+
// whole catalogue instead — see catalogBrowse for the measurement that made
|
|
571
|
+
// this necessary: a store built with these tools used 17 element types and
|
|
572
|
+
// hand-assembled what a dozen purpose-built ones already do.
|
|
573
|
+
async ({ query, limit, detail }) => text(query && query.trim()
|
|
574
|
+
? catalogMatches(query, { limit, detail })
|
|
575
|
+
: {
|
|
576
|
+
elements: catalogBrowse(),
|
|
577
|
+
note: 'Every element type, grouped as the palette groups them. Pass a TYPE as query ' +
|
|
578
|
+
'for the fields to choose by, or a CATEGORY NAME with limit 60 to read that ' +
|
|
579
|
+
"whole group's descriptions at once — which is how you find out what the ones " +
|
|
580
|
+
'you have never used are for. sb_traits_for then has the controls and hints.',
|
|
581
|
+
}));
|
|
432
582
|
server.registerTool('sb_traits_for', {
|
|
433
583
|
description: "This element's INSPECTOR, as a person sees it: tabs, groups, and every control name — " +
|
|
434
584
|
'with what each DECLARED control writes, and the AI hints for using the element. Read ' +
|
package/dist/tools/session.js
CHANGED
|
@@ -34,6 +34,22 @@ export async function connect(ctx, args) {
|
|
|
34
34
|
'pass that site id to sb_page_open, or set SB_SITE once and leave site_id out. Set ' +
|
|
35
35
|
'SB_EMAIL and SB_PASSWORD as well if you want account-level calls (listing sites, ' +
|
|
36
36
|
'members, roles), which a key cannot make.',
|
|
37
|
+
// SAID HERE BECAUSE HERE IS WHERE IT IS STILL FREE.
|
|
38
|
+
//
|
|
39
|
+
// `sb_live_join` is opt-in and one call, and an agent that never makes it
|
|
40
|
+
// builds an entire site the watching merchant cannot see happening: no
|
|
41
|
+
// peer in the room, no cursor, no element appearing as it lands. Nothing
|
|
42
|
+
// fails, so nothing prompts the question — the room is simply empty, and
|
|
43
|
+
// the person who asked for an agent watches a static canvas and concludes
|
|
44
|
+
// the agent is not working.
|
|
45
|
+
//
|
|
46
|
+
// MEASURED: one session built 17 pages and 19 products over two hours with
|
|
47
|
+
// an editor open beside it and never joined, because no surface an agent
|
|
48
|
+
// reads on the way in mentions the room. The tool's own description says
|
|
49
|
+
// what it does; nothing said WHEN. Connect is that when.
|
|
50
|
+
live: 'Nobody watching an open editor sees these edits until sb_live_join is called. Call it ' +
|
|
51
|
+
'now if a person has the site open — it takes this key\'s own name, always yields to a ' +
|
|
52
|
+
'human, and is safe to leave on for the whole session.',
|
|
37
53
|
};
|
|
38
54
|
}
|
|
39
55
|
if (!email || !password) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0",
|
|
4
4
|
"description": "MCP server that designs and operates a Store Builder site — pages, data, theme and publish — through the platform's own API and live-edit protocol.",
|
|
5
5
|
"mcpName": "io.github.vuluu2k/sbuilder-mcp",
|
|
6
6
|
"type": "module",
|