@luckydraw/cumulus 1.0.0 → 1.0.2
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/dist/gateway/adapters/webchat.d.ts +20 -0
- package/dist/gateway/adapters/webchat.d.ts.map +1 -1
- package/dist/gateway/adapters/webchat.js +81 -3
- package/dist/gateway/adapters/webchat.js.map +1 -1
- package/dist/gateway/static/blex-render.js +399 -0
- package/dist/gateway/static/chat.html +7 -0
- package/dist/gateway/static/mermaid-esm.js +2143 -0
- package/dist/gateway/static/widget.js +112 -127
- package/docs/web-app-agent-guide.md +117 -21
- package/examples/web-app-agent/README.md +225 -19
- package/examples/web-app-agent/agent/apply-thread-configs.mjs +139 -0
- package/examples/web-app-agent/gateway.config.example.json +22 -4
- package/examples/web-app-agent/public/agent/blex-mount.js +136 -0
- package/examples/web-app-agent/public/agent/bridge-mount.js +19 -3
- package/examples/web-app-agent/public/agent/commands.js +10 -4
- package/examples/web-app-agent/public/agent/device-thread.js +5 -5
- package/examples/web-app-agent/public/agent/panel.css +6 -0
- package/examples/web-app-agent/public/agent/panel.js +27 -4
- package/examples/web-app-agent/public/index.html +28 -0
- package/examples/web-app-agent/server.js +172 -11
- package/examples/web-app-agent/thread-config.example.json +22 -0
- package/examples/web-app-agent/thread-config.visitor.example.json +41 -0
- package/package.json +3 -2
|
@@ -409,6 +409,19 @@
|
|
|
409
409
|
'.blex-block-container[data-blex-ready="true"] {',
|
|
410
410
|
' min-height: auto;',
|
|
411
411
|
'}',
|
|
412
|
+
/* Raw-fence fallback: shown when nothing claims the container (user message,
|
|
413
|
+
changelog, or blex.min.js failed to load). Better a visible fence than the
|
|
414
|
+
blank rectangle min-height used to produce. */
|
|
415
|
+
'.blex-fallback {',
|
|
416
|
+
' margin: 0; padding: 0.6em 0.8em;',
|
|
417
|
+
' background: rgba(255,255,255,0.04);',
|
|
418
|
+
' border: 1px solid rgba(255,255,255,0.08);',
|
|
419
|
+
' border-radius: 8px;',
|
|
420
|
+
' font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;',
|
|
421
|
+
' font-size: 0.82em; line-height: 1.45;',
|
|
422
|
+
' white-space: pre-wrap; word-break: break-word;',
|
|
423
|
+
' opacity: 0.85;',
|
|
424
|
+
'}',
|
|
412
425
|
/* Blex CSS isolation — revert widget resets inside blex blocks */
|
|
413
426
|
'.blex-block-container * { margin: revert; padding: revert; line-height: revert; list-style: revert; border-spacing: revert; border: revert; }',
|
|
414
427
|
'.blex-block-container { isolation: isolate; }',
|
|
@@ -2037,8 +2050,8 @@
|
|
|
2037
2050
|
|
|
2038
2051
|
// ── Blex Integration ─────────────────────────────────────────────────────
|
|
2039
2052
|
|
|
2040
|
-
//
|
|
2041
|
-
|
|
2053
|
+
// Handle lifecycle (element -> BlockHandle[]) lives in blex-render.js — one
|
|
2054
|
+
// owner, so destroy() cannot miss handles the other side created.
|
|
2042
2055
|
|
|
2043
2056
|
// Per-thread store for blex interaction values (poll answers, confirm clicks, etc.)
|
|
2044
2057
|
// Key: "threadName" → Map<"msgTimestamp:blockIdx", interactionValue>
|
|
@@ -2189,136 +2202,99 @@
|
|
|
2189
2202
|
});
|
|
2190
2203
|
}
|
|
2191
2204
|
|
|
2192
|
-
//
|
|
2193
|
-
//
|
|
2205
|
+
// ── Shared renderer seam (task 131) ────────────────────────────────────────
|
|
2206
|
+
//
|
|
2207
|
+
// Extraction, placeholder markup, block rendering and handle lifecycle live in
|
|
2208
|
+
// /blex-render.js — ONE implementation shared with the web-app-agent kit and
|
|
2209
|
+
// any embedding app. chat.html loads it before this file.
|
|
2210
|
+
//
|
|
2211
|
+
// The EMBEDDED widget (a bare <script src="/widget.js"> on a third-party page)
|
|
2212
|
+
// loads neither blex.min.js nor blex-render.js, and never has — blex has only
|
|
2213
|
+
// ever worked on /chat. There, fences degrade to literal text, which is what
|
|
2214
|
+
// an author who typed one would expect to see.
|
|
2215
|
+
function blexCore() {
|
|
2216
|
+
return typeof CumulusBlexRender !== 'undefined' ? CumulusBlexRender : null;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// Fenced-code regex, sourced from the shared module so the blex extractor and
|
|
2220
|
+
// Phase 1 of renderMarkdown cannot disagree about where a code block starts —
|
|
2221
|
+
// a fence falling through that gap is exactly the bug this seam closes.
|
|
2222
|
+
function codeFenceRe() {
|
|
2223
|
+
var core = blexCore();
|
|
2224
|
+
return new RegExp(core ? core.CODE_FENCE_SRC : '```([^\\n`]*)\\n([\\s\\S]*?)```', 'g');
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2194
2227
|
function extractBlexBlocks(text) {
|
|
2195
|
-
var
|
|
2196
|
-
|
|
2197
|
-
var replaced = text.replace(/~~~blex:(\w[\w-]*)\n([\s\S]*?)\n~~~/g, function (_, type, json) {
|
|
2198
|
-
var idx = blocks.length;
|
|
2199
|
-
blocks.push({ type: type, json: json.trim(), idx: idx });
|
|
2200
|
-
return BLEX_PREFIX + idx + '\x00';
|
|
2201
|
-
});
|
|
2202
|
-
return { text: replaced, blocks: blocks };
|
|
2228
|
+
var core = blexCore();
|
|
2229
|
+
return core ? core.extractBlocks(text) : { text: text, blocks: [] };
|
|
2203
2230
|
}
|
|
2204
2231
|
|
|
2205
|
-
//
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
// Get the interaction store for this thread
|
|
2232
|
+
// The widget's adapter over the shared renderer. It supplies all five members;
|
|
2233
|
+
// a render-only surface (the web-app-agent kit's panel) supplies the three
|
|
2234
|
+
// required ones and lets addChip/persist default to no-ops.
|
|
2235
|
+
//
|
|
2236
|
+
// `el` is captured because it may be DETACHED by the time an interaction fires
|
|
2237
|
+
// — renderPanelMessages rebuilds the message list — which is why the panel
|
|
2238
|
+
// lookup falls back to a document query.
|
|
2239
|
+
function widgetBlexAdapter(el, threadName) {
|
|
2214
2240
|
var store = threadName
|
|
2215
2241
|
? blexInteractionStore[threadName] || (blexInteractionStore[threadName] = {})
|
|
2216
2242
|
: null;
|
|
2217
2243
|
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
var blockObj = { type: block.type, id: block.type + '-' + idx, data: data };
|
|
2227
|
-
|
|
2228
|
-
if (isStreaming) {
|
|
2229
|
-
// During streaming, show placeholder skeleton
|
|
2230
|
-
Blex.renderPlaceholder(block.type, container);
|
|
2231
|
-
} else {
|
|
2232
|
-
// Check for previously stored interaction value
|
|
2233
|
-
var renderOpts = {};
|
|
2234
|
-
if (store && storeKey && store[storeKey]) {
|
|
2235
|
-
renderOpts.previousValue = store[storeKey];
|
|
2236
|
-
}
|
|
2244
|
+
function panelFor() {
|
|
2245
|
+
return (
|
|
2246
|
+
el.closest('.cumulus-thread-panel') ||
|
|
2247
|
+
el.closest('.cumulus-panel') ||
|
|
2248
|
+
document.querySelector('.cumulus-thread-panel') ||
|
|
2249
|
+
document.querySelector('.cumulus-panel')
|
|
2250
|
+
);
|
|
2251
|
+
}
|
|
2237
2252
|
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2253
|
+
return {
|
|
2254
|
+
// The widget is the interactive surface: every type is allowed here.
|
|
2255
|
+
allowType: function () {
|
|
2256
|
+
return true;
|
|
2257
|
+
},
|
|
2258
|
+
getInput: function () {
|
|
2259
|
+
var panel = panelFor();
|
|
2260
|
+
return panel
|
|
2261
|
+
? panel.querySelector('[data-testid="webchat-input"]')
|
|
2262
|
+
: document.querySelector('[data-testid="webchat-input"]');
|
|
2263
|
+
},
|
|
2264
|
+
getSendButton: function () {
|
|
2265
|
+
var panel = panelFor();
|
|
2266
|
+
return panel ? panel.querySelector('[data-testid*="send"]') : null;
|
|
2267
|
+
},
|
|
2268
|
+
addChip: function (interaction, handle, sourceId) {
|
|
2269
|
+
addInteractionChip(panelFor(), interaction, handle, sourceId);
|
|
2270
|
+
},
|
|
2271
|
+
readPersisted: function (storeKey) {
|
|
2272
|
+
if (!store || !storeKey) return undefined;
|
|
2273
|
+
return store[storeKey] || undefined;
|
|
2274
|
+
},
|
|
2275
|
+
persist: function (storeKey, value) {
|
|
2276
|
+
if (!store || !storeKey) return;
|
|
2277
|
+
store[storeKey] = value;
|
|
2278
|
+
_flushBlexStore();
|
|
2279
|
+
},
|
|
2280
|
+
onRendered: function (container, storeKey) {
|
|
2281
|
+
attachBlexResize(container, threadName, storeKey);
|
|
2282
|
+
},
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2257
2285
|
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
document.querySelector('.cumulus-panel');
|
|
2265
|
-
}
|
|
2266
|
-
var inputEl = panel
|
|
2267
|
-
? panel.querySelector('[data-testid="webchat-input"]')
|
|
2268
|
-
: document.querySelector('[data-testid="webchat-input"]');
|
|
2269
|
-
|
|
2270
|
-
if (inputEl && interaction.serialized) {
|
|
2271
|
-
if (interaction.immediate) {
|
|
2272
|
-
// Immediate: prepend any existing input text, then auto-send
|
|
2273
|
-
var existingText = inputEl.value.trim();
|
|
2274
|
-
inputEl.value = existingText
|
|
2275
|
-
? existingText + '\n\n' + interaction.serialized
|
|
2276
|
-
: interaction.serialized;
|
|
2277
|
-
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2278
|
-
// Find and click the send button
|
|
2279
|
-
var sendBtn = panel ? panel.querySelector('[data-testid*="send"]') : null;
|
|
2280
|
-
if (sendBtn) {
|
|
2281
|
-
sendBtn.click();
|
|
2282
|
-
}
|
|
2283
|
-
} else {
|
|
2284
|
-
// Deferred: add to chip tray (sourceId deduplicates rapid-fire from same block)
|
|
2285
|
-
addInteractionChip(panel, interaction, handle, block.type + '-' + idx);
|
|
2286
|
-
}
|
|
2287
|
-
}
|
|
2288
|
-
});
|
|
2289
|
-
}
|
|
2290
|
-
})
|
|
2291
|
-
.catch(function (err) {
|
|
2292
|
-
container.innerHTML =
|
|
2293
|
-
'<pre style="color:#f66;font-size:0.85em;padding:0.5em;">Blex error: ' +
|
|
2294
|
-
(err.message || err) +
|
|
2295
|
-
'</pre>';
|
|
2296
|
-
});
|
|
2297
|
-
}
|
|
2298
|
-
} catch (e) {
|
|
2299
|
-
container.innerHTML =
|
|
2300
|
-
'<pre style="color:#f66;font-size:0.85em;padding:0.5em;">Invalid blex JSON: ' +
|
|
2301
|
-
e.message +
|
|
2302
|
-
'</pre>';
|
|
2303
|
-
}
|
|
2286
|
+
function renderBlexBlocks(el, blexBlocks, isStreaming, msgKey, threadName) {
|
|
2287
|
+
var core = blexCore();
|
|
2288
|
+
if (!core) return;
|
|
2289
|
+
core.render(el, blexBlocks, widgetBlexAdapter(el, threadName), {
|
|
2290
|
+
msgKey: msgKey,
|
|
2291
|
+
streaming: isStreaming,
|
|
2304
2292
|
});
|
|
2305
|
-
|
|
2306
|
-
// Note: handles are stored inside the async .then() callback above,
|
|
2307
|
-
// not here — by this point the promises haven't resolved yet.
|
|
2308
2293
|
}
|
|
2309
2294
|
|
|
2310
|
-
// Destroy blex block handles for a message element
|
|
2311
2295
|
function destroyBlexBlocks(el) {
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
if (handles) {
|
|
2315
|
-
handles.forEach(function (h) {
|
|
2316
|
-
try {
|
|
2317
|
-
h.destroy();
|
|
2318
|
-
} catch (e) {}
|
|
2319
|
-
});
|
|
2320
|
-
blexHandles.delete(el);
|
|
2321
|
-
}
|
|
2296
|
+
var core = blexCore();
|
|
2297
|
+
if (core) core.destroy(el);
|
|
2322
2298
|
}
|
|
2323
2299
|
|
|
2324
2300
|
// Add a deferred interaction chip to the chip tray
|
|
@@ -2681,7 +2657,12 @@
|
|
|
2681
2657
|
return out;
|
|
2682
2658
|
}
|
|
2683
2659
|
|
|
2684
|
-
|
|
2660
|
+
// opts.streaming — suppress the raw-fence fallback body while a turn is still
|
|
2661
|
+
// streaming. A completed-but-not-yet-rendered fence would otherwise flash its
|
|
2662
|
+
// JSON before flipping to the rendered block at `done`.
|
|
2663
|
+
function renderMarkdown(text, opts) {
|
|
2664
|
+
var streaming = !!(opts && opts.streaming);
|
|
2665
|
+
|
|
2685
2666
|
// Phase 0.5: Extract blex fences BEFORE code blocks
|
|
2686
2667
|
var blexResult = extractBlexBlocks(text);
|
|
2687
2668
|
text = blexResult.text;
|
|
@@ -2690,7 +2671,7 @@
|
|
|
2690
2671
|
// Phase 1: Extract fenced code blocks — replace with unique tokens
|
|
2691
2672
|
var codeBlocks = [];
|
|
2692
2673
|
var TOKEN_PREFIX = '\x00CODEBLOCK_';
|
|
2693
|
-
var processed = text.replace(
|
|
2674
|
+
var processed = text.replace(codeFenceRe(), function (_, lang, code) {
|
|
2694
2675
|
var idx = codeBlocks.length;
|
|
2695
2676
|
var langLabel = lang.trim() || 'text';
|
|
2696
2677
|
var escapedCode = escapeHtml(code.replace(/\n$/, '')); // trim trailing newline
|
|
@@ -2767,10 +2748,14 @@
|
|
|
2767
2748
|
return codeBlocks[parseInt(idx, 10)];
|
|
2768
2749
|
});
|
|
2769
2750
|
|
|
2770
|
-
// Phase 7.5: Reinsert blex block placeholders
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2751
|
+
// Phase 7.5: Reinsert blex block placeholders — shared markup, so the widget
|
|
2752
|
+
// and any embedding app produce byte-identical containers and one renderer
|
|
2753
|
+
// can claim either. See blex-render.js for why the placeholder carries the
|
|
2754
|
+
// raw fence as its body.
|
|
2755
|
+
var core = blexCore();
|
|
2756
|
+
if (core) {
|
|
2757
|
+
html = core.insertPlaceholders(html, blexBlocks, { streaming: streaming });
|
|
2758
|
+
}
|
|
2774
2759
|
|
|
2775
2760
|
return { html: html, blexBlocks: blexBlocks };
|
|
2776
2761
|
}
|
|
@@ -3760,7 +3745,7 @@
|
|
|
3760
3745
|
el.className = 'cumulus-msg assistant';
|
|
3761
3746
|
if (isStreaming) el.setAttribute('data-testid', 'webchat-streaming');
|
|
3762
3747
|
if (content) {
|
|
3763
|
-
var mdResult = renderMarkdown(content);
|
|
3748
|
+
var mdResult = renderMarkdown(content, { streaming: isStreaming });
|
|
3764
3749
|
el.innerHTML = mdResult.html;
|
|
3765
3750
|
if (isStreaming) {
|
|
3766
3751
|
el.innerHTML += '<span class="cumulus-cursor"></span>';
|
|
@@ -7461,7 +7446,7 @@
|
|
|
7461
7446
|
el.className = 'cumulus-msg assistant';
|
|
7462
7447
|
if (isStreaming) el.setAttribute('data-testid', 'webchat-streaming');
|
|
7463
7448
|
if (content) {
|
|
7464
|
-
var mdResult = renderMarkdown(content);
|
|
7449
|
+
var mdResult = renderMarkdown(content, { streaming: isStreaming });
|
|
7465
7450
|
el.innerHTML = mdResult.html;
|
|
7466
7451
|
if (isStreaming) {
|
|
7467
7452
|
el.innerHTML += '<span class="cumulus-cursor"></span>';
|
|
@@ -92,6 +92,8 @@ Default is off; with it off every bridge surface is inert.
|
|
|
92
92
|
"command": "node",
|
|
93
93
|
"args": ["/path/to/myapp/mcp-shim.js"],
|
|
94
94
|
"env": {
|
|
95
|
+
// 8080 is the fresh-install default — use your gateway's actual
|
|
96
|
+
// top-level "port" here (jq .port ~/.cumulus/gateway.config.json).
|
|
95
97
|
"GATEWAY_ORIGIN": "http://127.0.0.1:8080",
|
|
96
98
|
"GATEWAY_API_KEY": "sk-myapp-<same-scoped-key>",
|
|
97
99
|
"BRIDGE_THREAD": "{thread}",
|
|
@@ -109,11 +111,28 @@ Namespace semantics (locked in task 097):
|
|
|
109
111
|
- Longest prefix wins, so `myapp-demo` can be its own nested namespace under `myapp` later.
|
|
110
112
|
- The scoped key is **confined**: it can read/write only `myapp-*` threads, gets an empty list from every enumeration surface (`/api/threads`, `/api/agents`, dashboard), and is rejected (403) everywhere else.
|
|
111
113
|
|
|
112
|
-
### 2.3 Create the
|
|
114
|
+
### 2.3 Create the thread configs — two of them, and the second is the one people miss
|
|
113
115
|
|
|
114
|
-
|
|
116
|
+
Config is resolved by **prefix-fallback** (task 098): a turn strips one trailing `-segment` at a time and takes the longest match. Writes are always exact, so a visitor session can never mutate a config it inherited.
|
|
115
117
|
|
|
116
|
-
|
|
118
|
+
Your app has **two** thread configs, because it has two kinds of thread:
|
|
119
|
+
|
|
120
|
+
| File | Applies to | Typical shape |
|
|
121
|
+
| --------------------- | --------------------------------------- | ------------------------- |
|
|
122
|
+
| `myapp.config.json` | your own management thread, `myapp` | strong model, high effort |
|
|
123
|
+
| `myapp-v.config.json` | **every visitor**, `myapp-v-<deviceId>` | small fast model |
|
|
124
|
+
|
|
125
|
+
The `-v` layer is not decoration — it is the seam that lets those two differ. The server hands the browser `THREAD_ID = "myapp-v"` and `device-thread.js` appends the device id, so resolution for `myapp-v-a3f8c2d1` goes:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
myapp-v-a3f8c2d1.config.json (none — visitors never get their own)
|
|
129
|
+
myapp-v.config.json <- every visitor turn
|
|
130
|
+
myapp.config.json (only if the -v file is absent)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Skip the `-v` file and every anonymous visitor runs your management thread's model.** That is the single most expensive omission in this guide, and it is invisible in development: with one tester the bill looks fine.
|
|
134
|
+
|
|
135
|
+
`~/.cumulus/threads/myapp.config.json`:
|
|
117
136
|
|
|
118
137
|
```json
|
|
119
138
|
{
|
|
@@ -124,9 +143,34 @@ Visitor threads inherit their config from the base by **prefix-fallback** (task
|
|
|
124
143
|
}
|
|
125
144
|
```
|
|
126
145
|
|
|
146
|
+
`~/.cumulus/threads/myapp-v.config.json`:
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"projectDir": "/home/you/projects/myapp",
|
|
151
|
+
"model": "claude",
|
|
152
|
+
"claudeModel": "claude-haiku-4-5",
|
|
153
|
+
"effort": "medium",
|
|
154
|
+
"alwaysInclude": ["docs/myapp-system-prompt.md"],
|
|
155
|
+
"disallowedTools": ["AskUserQuestion"]
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
127
159
|
- `projectDir` — the working directory for the agent's turns (where `alwaysInclude` paths resolve).
|
|
128
|
-
- `alwaysInclude` — the app's **system prompt document**: what the app is, how to talk to its users, when to use which commands, tone. This is where the agent's product knowledge and persona live.
|
|
129
|
-
- `model` / `effort` — per-
|
|
160
|
+
- `alwaysInclude` — the app's **system prompt document**: what the app is, how to talk to its users, when to use which commands, tone. This is where the agent's product knowledge and persona live. Usually the same document for both threads.
|
|
161
|
+
- `model` / `effort` / `claudeModel` — the per-thread quality, latency and cost dial. `claudeModel` pins the specific Claude model; leave it out to follow the gateway default.
|
|
162
|
+
- `disallowedTools` — on visitor threads, strip `AskUserQuestion`: there is no operator on the other end, so a turn that asks one hangs.
|
|
163
|
+
|
|
164
|
+
Both files ship as editable examples in the kit (`thread-config.example.json`, `thread-config.visitor.example.json`), with a one-command applier:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
GATEWAY_ORIGIN=https://gw.example.com GATEWAY_ADMIN_KEY=sk-... \
|
|
168
|
+
node agent/apply-thread-configs.mjs --namespace myapp
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Use the **admin** key: a namespace covers `myapp-*`, so the app's scoped key can write `myapp-v` but is refused (403) on the bare `myapp` base thread. Note that the config API applies a whitelist — `projectDir`, `template`, `model`, `effort`, `claudeModel`, `contextLimit` — so `alwaysInclude` and `disallowedTools` must be added to the file on the gateway host. (That is deliberate: `alwaysInclude` plus `projectDir` would let a scoped key read an arbitrary file into its own prompt.) The applier reads each config back and names anything that did not stick, so the gap is visible rather than silent.
|
|
172
|
+
|
|
173
|
+
No gateway reload is needed — thread config is read per turn.
|
|
130
174
|
|
|
131
175
|
### 2.4 Verify
|
|
132
176
|
|
|
@@ -191,6 +235,7 @@ The front end then calls `AgentStart()` once the config arrives, and `AgentStop(
|
|
|
191
235
|
Notes:
|
|
192
236
|
|
|
193
237
|
- **Yes, the scoped key reaches the browser.** That is the design: it is worthless outside `myapp-*`, can enumerate nothing, and each visitor's thread name is its own secret. Ship only the namespace-scoped key — never an admin key.
|
|
238
|
+
- **Enumeration fails as `200` + empty, not `403`.** `/api/threads` and `/api/agents` answer `200 {"threads":[]}` to a scoped key even when the namespace holds hundreds of threads — a scoped caller already knows the one name it needs, and listing siblings would hand out every other visitor's capability. `403` is reserved for reaching _outside_ the namespace. Worth knowing if your client branches on status.
|
|
194
239
|
- **Gate it on a session anyway.** Pursuit takes the older route and injects the key into `<head>` at serve time, which puts it in public HTML, in view-source, and in any intermediary cache. It works, and the blast radius is bounded by the namespace — but a session-gated endpoint is strictly better and costs one route. Use it for new apps.
|
|
195
240
|
- In production, pass the key via a systemd drop-in (`Environment=MYAPP_API_KEY=...`), not a file in the repo.
|
|
196
241
|
- `THREAD_ID` is the **base name**; the browser appends the per-device suffix (§4.1). Minting the suffix client-side keeps the full thread name — the actual capability — from ever travelling server → client.
|
|
@@ -292,7 +337,7 @@ MyAppAgent.register(def) MyAppAgent.call(name, params)
|
|
|
292
337
|
MyAppAgent.list() MyAppAgent.manifest() // → [{ name, description, risk, input_schema }]
|
|
293
338
|
```
|
|
294
339
|
|
|
295
|
-
|
|
340
|
+
The gate is binary, and only one tier is on the gated side. **`export` never auto-runs** — the gateway forces a confirm round-trip (§5.3) regardless of caller flags. `read`, `display` and `mutate` all dispatch immediately; the tier is advisory, riding in the tool description the model sees (`[mutate] …`) so it can weigh the call, but nothing stops it. So the question when tiering a command is not "is this irreversible?" — it is **"must a human see this before it happens?"** If yes, it is `export`, whatever the verb is.
|
|
296
341
|
|
|
297
342
|
Two commands every app should register (the agent's eyes):
|
|
298
343
|
|
|
@@ -418,6 +463,54 @@ Delivery is decoupled via an event — `window.dispatchEvent(new CustomEvent('my
|
|
|
418
463
|
|
|
419
464
|
**The `data-agent-ref` convention** is what makes captured feedback _addressable_: any element rendering a record carries `data-agent-ref="accounts:ACME-01"` (optionally `/field:owner`). Ref collection walks ancestors and contained elements of the selection/target, so "this row is wrong" arrives at the agent with the exact records attached.
|
|
420
465
|
|
|
466
|
+
### 4.6b Rich blocks (`blex-mount.js`, ~110 lines)
|
|
467
|
+
|
|
468
|
+
The gateway instructs **every** thread to emit `~~~blex:TYPE` fences for tabular data, status boards, metrics, charts and diagrams. That rule is in the global includes and `mergeConfigs` is union-only, so a thread cannot opt out of it. The consequence is sharp and easy to miss: **a panel with no blex renderer shows the visitor raw JSON**, and it does so for exactly the content the model was told to present richly. Two independent halves of the system, each correct, never introduced.
|
|
469
|
+
|
|
470
|
+
Serve both halves — `blex.min.js` and `blex-render.js` — **from your own origin, resolved out of the installed cumulus package**, exactly as the kit already does for the bridge client (`server.js` → `/agent/blex/*`). Do not vendor them: `blex-render.js` is cumulus's own renderer, shared with the standalone chat widget, so a copy in your tree forks the seam contract and drifts the first time either side moves.
|
|
471
|
+
|
|
472
|
+
> **Do not `<script src="${GATEWAY_ORIGIN}/blex.min.js">`.** That recipe is correct only when your app is on a _different_ origin from the gateway. The common production shape is the opposite: `GATEWAY_ORIGIN` is your own hostname and an edge (Caddy, Cloudflare) routes just `/bridge*` and `/api/thread/*` through to the gateway. Your hostname has no `/blex.min.js`, so the load 404s and the panel degrades silently to plain text. Serving from your own origin is correct in **both** deployments, which is why the kit does it unconditionally.
|
|
473
|
+
|
|
474
|
+
**Diagrams need one extra tag: an import map.** `~~~blex:mermaid` is the one block type whose renderer loads a library at render time, and it does so with a **bare** module specifier (`await import("mermaid")`). A browser has exactly one mechanism for resolving a bare specifier — an import map — so without it the load fails and blex paints the literal string `Mermaid render error` where the fence used to be. Copy the tag from the kit's `index.html`:
|
|
475
|
+
|
|
476
|
+
```html
|
|
477
|
+
<script type="importmap">
|
|
478
|
+
{ "imports": { "mermaid": "/agent/blex/mermaid-esm.js" } }
|
|
479
|
+
</script>
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
The library it points at is the vendored mermaid bundle, served out of the installed cumulus package by the same `/agent/blex/*` route as the rest — not vendored into your tree, and not cross-origin. It is fetched only when a mermaid block actually renders (≈1MB gzipped), so pages without diagrams pay nothing.
|
|
483
|
+
|
|
484
|
+
If you leave the tag out, nothing breaks and you get no error box: `blex-render.js` checks whether the document declares the mapping and, if not, leaves the fence as readable text. That check is on the **document**, not on your adapter, so it protects a surface whose adapter allows everything. Diagram colours are derived from the blex card's own background (`--blex-bg`), so they follow your theme automatically; set `window.__CUMULUS_MERMAID_THEME` to a mermaid theme name only to override it.
|
|
485
|
+
|
|
486
|
+
**Correct origin cache headers are not a defence.** Measured in both directions on this project's own edge, and independently reproduced on a second one: the origin answers `/widget.js` with `Cache-Control: no-cache, must-revalidate` and the browser is handed `max-age=14400`. A CDN-class edge rewrites by **file extension**, regardless of what the origin said. So the `?v=` stamp is not belt-and-braces over your headers — behind such an edge it is the _only_ mechanism you have, and a reader who concludes "my origin already sends no-cache" will skip the one thing that would have worked. HTML is the exception (`no-cache`, `cf-cache-status: DYNAMIC`), which is exactly why an HTML-level stamp works at all — and why everything fetched _after_ the HTML is the hole.
|
|
487
|
+
|
|
488
|
+
Three cache traps — the first two measured at a real Cloudflare edge, and each one makes a _correct_ deploy look broken:
|
|
489
|
+
|
|
490
|
+
- **404s are cached too.** If you probe the route before it exists, the edge caches the 404 for its default TTL (measured: `max-age=14400` with `cf-cache-status: HIT`, overriding the origin's `no-cache`) — so a correct deploy keeps serving "no library" for four hours. This is nastier than stale content because it reads as "my route isn't registered", sending you to re-debug working code. After adding a route the edge has already seen 404, purge or cache-bust before concluding anything about the route.
|
|
491
|
+
- **An HTML stamp only reaches what HTML requests.** A `?v=` on a `<script src>` versions that file and nothing the file goes on to fetch by itself — so a loader that pulls its own dependencies at runtime has to propagate the version token, or the parent is versioned and its children are not. The kit's `server.js` stamps every `src`/`href` it can see in the served HTML, and — because `panel.css` and the two blex scripts are attached from _inside_ JavaScript — also fills a `window.__AGENT_ASSET_V` map that those loaders consult via `window.agentAsset(url)`. **Do not lift the token off `document.currentScript.src`.** It is the obvious shortcut and it has already shipped broken in a real adopter: that token describes _your_ build, but the library it stamps comes out of the _installed cumulus package_, so a cumulus upgrade changes the bytes while your build — and therefore the URL — stands still, and browsers keep the old library indefinitely. A server-published map gives each file its **own** hash, so the upgrade busts it even though the loader's own bytes didn't move. If you test this, assert that the library URL's token moves when the **package** file changes, not when your build does; a test that rebuilds the app passes against the broken version. (This is a general rule, not a blex problem: `blex.min.js` fetches nothing by URL. Measured on `@luckydraw/blex@0.1.16` — its only dynamic `import()` is the bare `"mermaid"` specifier above, `Chart.js v4.5.1` is inlined, and `blex-chart.min.js` is an opt-in companion global that nothing requests.)
|
|
492
|
+
- **Import-map values _are_ stampable; static `import` specifiers are not.** The mermaid module URL lives in JSON inside a `<script type="importmap">`, which the server rewrites along with every `src`/`href` — so that library is versioned like any other, and it needs no runtime token map (there is only one file, so there is no loader→dependency edge at all). Static specifiers are the case that stays uncovered: `bridge-mount.js` imports `client.js`, which imports `protocol.js`; both are fetched bare. They ship from the cumulus package and change only on upgrade, and the kit serves them `no-cache, must-revalidate` — which, per the paragraph above, a CDN-class edge will override anyway. Treat these as genuinely unstamped: excluding `/agent/` from your CDN is the fix, not a precaution.
|
|
493
|
+
|
|
494
|
+
(One caveat if you probe with `curl -I`: the gateway answers `HEAD` on static assets with `401` while `GET` returns `200` with `Access-Control-Allow-Origin: *`. Probe with `GET`; the asset is not auth-gated.)
|
|
495
|
+
|
|
496
|
+
Drive it with an adapter. The required trio is `allowType` / `getInput` / `getSendButton`; `addChip` and `persist` are optional, **and leaving them out is what makes a surface render-only** — there is no mode flag to set.
|
|
497
|
+
|
|
498
|
+
```js
|
|
499
|
+
window.CumulusBlexRender.renderOnlyAdapter({
|
|
500
|
+
getInput: () => document.querySelector('[data-testid="agent-input"]'),
|
|
501
|
+
getSendButton: () => document.querySelector('[data-testid="agent-send"]'),
|
|
502
|
+
});
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
`renderOnlyAdapter` denies `confirm`, `poll`, `form` and `diff`. The test the allow set is derived from is: _a type may render iff every affordance it draws either completes locally or is purely visual._ Selection in `table`/`code`/`file-tree`/`timeline`/`image` is a highlight, so a no-op is invisible. `terminal`/`svg` draw labelled buttons but the clipboard write and the object-URL download both run **before** the emit — the emit is a receipt, not the mechanism. `diff` fails the test: Apply/Reject have no local half, so on a render-only surface they are guaranteed dead buttons that read as "click to apply" — worse than not rendering, because a dead button looks alive in a way raw JSON does not.
|
|
506
|
+
|
|
507
|
+
Two implementation traps:
|
|
508
|
+
|
|
509
|
+
- **Extract fences before your code-block pass**, not after. Otherwise a fence someone is _showing_ inside a ``` block is eaten by the extractor and vanishes from the block that exists to display it.
|
|
510
|
+
- **Do not implement deny with `unregisterBlockType`.** The registry is module-global, so unregistering `confirm` removes it for every surface sharing that module instance. Deny is a per-render gate. (Nor can you route denials to a library fallback renderer — the global bundle exports no such thing; it exists only on the ESM surface. Leaving the container unclaimed shows the raw fence, which is the honest outcome and costs nothing.)
|
|
511
|
+
|
|
512
|
+
Nothing is lost by not rendering `confirm`: export-tier confirms arrive over the **bridge** as a native, audited chip (§4.3), never through message content.
|
|
513
|
+
|
|
421
514
|
### 4.7 Testability (house rules)
|
|
422
515
|
|
|
423
516
|
Per the global standards: every interactive element gets a `data-testid` (`agent-panel-*`, `agent-fb-pop|input|send|caret|queue`), the message list carries `data-loading`/`aria-busy` while streaming, and `window.__PUPPET_TEST_MODE__` sets `data-test-mode` to kill animations.
|
|
@@ -483,7 +576,7 @@ Operational corollaries:
|
|
|
483
576
|
.then(() => console.log("config OK"), e => { console.error(e.message); process.exit(1); })' "$(npm root -g)"
|
|
484
577
|
```
|
|
485
578
|
|
|
486
|
-
3. Write `<app>.config.json` (
|
|
579
|
+
3. Write **both** thread configs — `<app>.config.json` (yours) and `<app>-v.config.json` (every visitor, cheap model) — plus the system-prompt doc they include. `node agent/apply-thread-configs.mjs` covers the API-settable fields; §2.3 says which ones it cannot.
|
|
487
580
|
4. Ask the gateway admin to reload the gateway (if needed), then run the §2.4 probes.
|
|
488
581
|
|
|
489
582
|
**App backend:** 5. Serve `__AGENT_CONFIG__` from a session-gated route (key from env, not the repo) — §3.1. 6. Copy `examples/web-app-agent/agent/mcp-shim.js`; point its env at your gateway + key; wire it in `extraMcpServers` with `BRIDGE_THREAD: "{thread}"`.
|
|
@@ -502,19 +595,22 @@ development and will hit on your first real day.
|
|
|
502
595
|
|
|
503
596
|
Every path below exists in the shipped package — this is the runnable kit, not a description of someone else's repo.
|
|
504
597
|
|
|
505
|
-
| Layer | File
|
|
506
|
-
| --------- |
|
|
507
|
-
| Gateway | `~/.cumulus/gateway.config.json`
|
|
508
|
-
| Gateway | `examples/web-app-agent/gateway.config.example.json`
|
|
509
|
-
| Gateway | `~/.cumulus/threads/myapp
|
|
510
|
-
| Gateway |
|
|
511
|
-
|
|
|
512
|
-
|
|
|
513
|
-
|
|
|
514
|
-
|
|
|
515
|
-
|
|
|
516
|
-
| Front end | `public/agent/
|
|
517
|
-
| Front end | `public/agent/
|
|
518
|
-
| Front end | `public/
|
|
598
|
+
| Layer | File | Role |
|
|
599
|
+
| --------- | ------------------------------------------------------- | --------------------------------------------------------- |
|
|
600
|
+
| Gateway | `~/.cumulus/gateway.config.json` | namespace, scoped key, `executorProxy`, `extraMcpServers` |
|
|
601
|
+
| Gateway | `examples/web-app-agent/gateway.config.example.json` | the fragment to merge into it |
|
|
602
|
+
| Gateway | `~/.cumulus/threads/myapp.config.json` | YOUR management thread: strong model |
|
|
603
|
+
| Gateway | `~/.cumulus/threads/myapp-v.config.json` | EVERY visitor turn: cheap model, prompt, cwd (§2.3) |
|
|
604
|
+
| Gateway | `examples/web-app-agent/thread-config*.example.json` | both of the above, as editable examples |
|
|
605
|
+
| Gateway | `examples/web-app-agent/agent/apply-thread-configs.mjs` | one-command applier for the API-settable fields |
|
|
606
|
+
| Gateway | `dist/gateway/bridge/{protocol,gateway,client}.js` | the bridge itself — cumulus-owned, never forked |
|
|
607
|
+
| Serving | `examples/web-app-agent/server.js` | session-gated `/api/agent-config` |
|
|
608
|
+
| Shim | `examples/web-app-agent/agent/mcp-shim.js` | manifest → MCP tools; calls → `/bridge/call` |
|
|
609
|
+
| Front end | `public/agent/device-thread.js` | per-visitor thread identity (16-hex) |
|
|
610
|
+
| Front end | `public/agent/commands.js` | **the command registry — the file you write** |
|
|
611
|
+
| Front end | `public/agent/bridge-mount.js` | wires the cumulus browser client to your registry |
|
|
612
|
+
| Front end | `public/agent/chat-client.js` | SSE chat against `/api/thread/:name/message` |
|
|
613
|
+
| Front end | `public/agent/panel.js`, `panel.css` | chat window + home bar |
|
|
614
|
+
| Front end | `public/app.js` (`window.HostApp`) | the adapter commands act through — never the DOM |
|
|
519
615
|
|
|
520
616
|
Two pieces described in this guide are **not** in the starter kit, to keep it small: the selection/right-click feedback composer (§4.6) and a rich markdown renderer with entity chips. Both are additive — add them once the core loop works.
|