@luckydraw/cumulus 0.31.65 → 1.0.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 +6 -555
- package/LICENSE +150 -0
- package/README.md +161 -17
- package/dist/gateway/adapters/webchat.d.ts +2 -0
- package/dist/gateway/adapters/webchat.d.ts.map +1 -1
- package/dist/gateway/adapters/webchat.js +22 -2
- package/dist/gateway/adapters/webchat.js.map +1 -1
- package/dist/gateway/config.d.ts +17 -2
- package/dist/gateway/config.d.ts.map +1 -1
- package/dist/gateway/config.js +10 -3
- package/dist/gateway/config.js.map +1 -1
- package/dist/gateway/daemon.d.ts +3 -1
- package/dist/gateway/daemon.d.ts.map +1 -1
- package/dist/gateway/daemon.js +128 -39
- package/dist/gateway/daemon.js.map +1 -1
- package/dist/gateway/namespaces.d.ts +34 -0
- package/dist/gateway/namespaces.d.ts.map +1 -1
- package/dist/gateway/namespaces.js +58 -0
- package/dist/gateway/namespaces.js.map +1 -1
- package/dist/gateway/server.d.ts +8 -0
- package/dist/gateway/server.d.ts.map +1 -1
- package/dist/gateway/server.js +150 -41
- package/dist/gateway/server.js.map +1 -1
- package/dist/gateway/setup.d.ts +32 -0
- package/dist/gateway/setup.d.ts.map +1 -1
- package/dist/gateway/setup.js +23 -3
- package/dist/gateway/setup.js.map +1 -1
- package/dist/gateway/static/widget.js +897 -611
- package/dist/lib/gateway.d.ts +30 -8
- package/dist/lib/gateway.d.ts.map +1 -1
- package/dist/lib/gateway.js +36 -11
- package/dist/lib/gateway.js.map +1 -1
- package/dist/lib/history.d.ts +22 -0
- package/dist/lib/history.d.ts.map +1 -1
- package/dist/lib/history.js +59 -21
- package/dist/lib/history.js.map +1 -1
- package/dist/lib/huggingface-provider.d.ts.map +1 -1
- package/dist/lib/huggingface-provider.js +11 -3
- package/dist/lib/huggingface-provider.js.map +1 -1
- package/dist/lib/license.d.ts +76 -0
- package/dist/lib/license.d.ts.map +1 -0
- package/dist/lib/license.js +141 -0
- package/dist/lib/license.js.map +1 -0
- package/docs/agentic-harness-primer.md +283 -0
- package/docs/conditional-continuation.md +167 -0
- package/docs/web-app-agent-guide.md +520 -0
- package/examples/web-app-agent/README.md +187 -0
- package/examples/web-app-agent/agent/mcp-shim.js +105 -0
- package/examples/web-app-agent/gateway.config.example.json +52 -0
- package/examples/web-app-agent/package.json +13 -0
- package/examples/web-app-agent/public/agent/bridge-mount.js +75 -0
- package/examples/web-app-agent/public/agent/chat-client.js +104 -0
- package/examples/web-app-agent/public/agent/commands.js +250 -0
- package/examples/web-app-agent/public/agent/device-thread.js +48 -0
- package/examples/web-app-agent/public/agent/panel.css +107 -0
- package/examples/web-app-agent/public/agent/panel.js +369 -0
- package/examples/web-app-agent/public/app.js +250 -0
- package/examples/web-app-agent/public/index.html +111 -0
- package/examples/web-app-agent/server.js +242 -0
- package/package.json +7 -3
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Web-App Agent — runnable starter kit
|
|
2
|
+
|
|
3
|
+
A working web app with a persistent cumulus agent that can **see the screen** and
|
|
4
|
+
**drive the app**. Every file here runs. Copy the `agent/` directories into your
|
|
5
|
+
own app, replace one file, and you have the same thing.
|
|
6
|
+
|
|
7
|
+
The full narrative version is [`docs/web-app-agent-guide.md`](../../docs/web-app-agent-guide.md).
|
|
8
|
+
This is the code that guide describes.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## What you get
|
|
13
|
+
|
|
14
|
+
| | |
|
|
15
|
+
| --------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
16
|
+
| **A persistent thread per visitor** | The conversation outlives the tab, the session, and the deploy. Reload and it's still there. |
|
|
17
|
+
| **The agent sees the live screen** | A fresh `describeView` rides along with every turn — never a cached snapshot. |
|
|
18
|
+
| **The agent drives the app** | Through a registry you define, calling your app's own actions — not the DOM. |
|
|
19
|
+
| **A human gate on irreversible things** | `risk: "export"` commands stop for a confirm chip. The model cannot bypass it. |
|
|
20
|
+
| **Capability-scoped access** | The app's key can touch its own namespace and cannot enumerate anything, anywhere. |
|
|
21
|
+
|
|
22
|
+
## Run it
|
|
23
|
+
|
|
24
|
+
**If you installed cumulus from npm** (the usual case — the kit ships inside the
|
|
25
|
+
package, already built):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cd "$(npm root -g)/@luckydraw/cumulus/examples/web-app-agent"
|
|
29
|
+
GATEWAY_API_KEY=sk-demoapp-REPLACE-ME GATEWAY_ORIGIN=http://127.0.0.1:8080 node server.js
|
|
30
|
+
# -> http://127.0.0.1:8199 password: demo (override with APP_PASSWORD)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**If you have the cumulus git repo**, build it once first — the browser bridge
|
|
34
|
+
client is served straight out of `dist/`, since this kit vendors no copy of it:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install && npm run build
|
|
38
|
+
cd examples/web-app-agent
|
|
39
|
+
GATEWAY_API_KEY=sk-demoapp-REPLACE-ME GATEWAY_ORIGIN=http://127.0.0.1:8080 node server.js
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Copy the whole directory somewhere writable before you start editing it — a kit
|
|
43
|
+
inside `node_modules` is replaced on the next upgrade.
|
|
44
|
+
|
|
45
|
+
Without `GATEWAY_API_KEY` the app still runs — it just has no assistant. That is
|
|
46
|
+
the correct degraded state, and it's worth keeping in your own app.
|
|
47
|
+
|
|
48
|
+
`GATEWAY_ORIGIN` has no default and is required whenever the key is set. That's
|
|
49
|
+
deliberate: a defaulting app plus a mistyped variable is an app that quietly
|
|
50
|
+
talks to whatever gateway happens to be listening on the usual port — a failure
|
|
51
|
+
that looks like success. The server also refuses to start on near-miss names
|
|
52
|
+
(`AGENT_API_KEY`, `GATEWAY_URL`, `DEMO_PASSWORD`, …), naming the variable it
|
|
53
|
+
actually reads.
|
|
54
|
+
|
|
55
|
+
For the assistant to actually answer you also need a gateway: merge
|
|
56
|
+
[`gateway.config.example.json`](gateway.config.example.json) into
|
|
57
|
+
`~/.cumulus/gateway.config.json`, then reload it
|
|
58
|
+
(`sudo systemctl reload cumulus-gateway`, or `cumulus-gateway reload` if you
|
|
59
|
+
manage it yourself — never `systemctl restart`, which kills in-flight turns).
|
|
60
|
+
|
|
61
|
+
## What's here
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
server.js your serving layer's one job: hand the scoped key
|
|
65
|
+
to authenticated sessions only
|
|
66
|
+
agent/mcp-shim.js stdio MCP server the gateway spawns per turn;
|
|
67
|
+
turns your registry into the model's tools
|
|
68
|
+
gateway.config.example.json the namespace, the scoped key, the shim
|
|
69
|
+
|
|
70
|
+
public/index.html the demo app
|
|
71
|
+
public/app.js the demo app + window.HostApp (the adapter)
|
|
72
|
+
|
|
73
|
+
public/agent/commands.js ← THE FILE YOU WRITE. Your capability surface.
|
|
74
|
+
public/agent/device-thread.js per-visitor thread identity
|
|
75
|
+
public/agent/bridge-mount.js wires the bridge client to your registry
|
|
76
|
+
public/agent/chat-client.js speaks the gateway's chat API (SSE)
|
|
77
|
+
public/agent/panel.js the chat UI
|
|
78
|
+
public/agent/panel.css themed from six CSS variables
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Copy all of `public/agent/` and `agent/mcp-shim.js` as-is. Then **rewrite
|
|
82
|
+
`commands.js`** against your own app and delete the demo's notes commands.
|
|
83
|
+
That's the whole port.
|
|
84
|
+
|
|
85
|
+
The browser `BridgeClient` is deliberately **not** in this tree. It's served
|
|
86
|
+
from `dist/gateway/bridge/` in the cumulus package, so it can never drift from
|
|
87
|
+
the gateway it talks to. Don't fork it.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## The five things that are easy to get wrong
|
|
92
|
+
|
|
93
|
+
### 1. The thread name is the capability
|
|
94
|
+
|
|
95
|
+
A scoped key cannot list threads — anywhere, including its own namespace. So
|
|
96
|
+
knowing a thread's name is what grants access to that conversation. Which means:
|
|
97
|
+
|
|
98
|
+
- Mint the per-visitor suffix **in the browser** (`device-thread.js`), so the
|
|
99
|
+
full name never travels server → client where it could be logged or cached.
|
|
100
|
+
- Use **at least 16 hex characters** (64 bits). 8 is brute-forceable against a
|
|
101
|
+
live gateway.
|
|
102
|
+
|
|
103
|
+
### 2. Never ship the key in the page
|
|
104
|
+
|
|
105
|
+
`server.js` serves the scoped key from `GET /api/agent-config`, gated on a
|
|
106
|
+
session. If you inject it into static HTML instead, anyone who views source can
|
|
107
|
+
talk to your gateway as your app.
|
|
108
|
+
|
|
109
|
+
### 3. Commands act through an adapter, not the DOM
|
|
110
|
+
|
|
111
|
+
`window.HostApp` is the app's own actions exposed as functions. Commands call
|
|
112
|
+
those. That's why validation, persistence, and re-render work identically
|
|
113
|
+
whether a human or the model is driving — and why your commands survive a UI
|
|
114
|
+
rewrite.
|
|
115
|
+
|
|
116
|
+
### 4. Descriptions are the interface
|
|
117
|
+
|
|
118
|
+
The model decides what to call based entirely on the `description` string.
|
|
119
|
+
Write for a reader who cannot see your UI, and say what a command is _for_, not
|
|
120
|
+
just what it does. Compare:
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
description: 'Sets the filter.'; // useless
|
|
124
|
+
description: 'Set the on-screen filter so the human sees a subset. ' +
|
|
125
|
+
'Use it to SHOW someone something, not to look something up ' +
|
|
126
|
+
'yourself — for that, use notes.list, which changes nothing.';
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The second one prevents a whole class of annoying behaviour.
|
|
130
|
+
|
|
131
|
+
### 5. Pick the risk tier honestly
|
|
132
|
+
|
|
133
|
+
| tier | meaning |
|
|
134
|
+
| --------- | -------------------------------------------------------------------- |
|
|
135
|
+
| `read` | answers a question, changes nothing |
|
|
136
|
+
| `display` | changes the view only, trivially undoable |
|
|
137
|
+
| `mutate` | changes stored data, but recoverably |
|
|
138
|
+
| `export` | irreversible, or leaves the app (sends, publishes, deletes, charges) |
|
|
139
|
+
|
|
140
|
+
`export` is **always** routed through the confirm chip by the gateway — the
|
|
141
|
+
model can't bypass it and neither can your front end. Anything that spends
|
|
142
|
+
money, emails a customer, or destroys data belongs here.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## How a turn actually works
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
visitor types → panel.js sends the message to the gateway
|
|
150
|
+
AND pushes a fresh describeView over the bridge
|
|
151
|
+
↓
|
|
152
|
+
gateway assembles the turn (history + RAG + your describeView)
|
|
153
|
+
↓
|
|
154
|
+
model calls a tool → mcp-shim.js → POST /bridge/call
|
|
155
|
+
↓
|
|
156
|
+
gateway → open WebSocket → the visitor's tab → your registry → HostApp
|
|
157
|
+
↓
|
|
158
|
+
result travels back the same way; model composes an answer; SSE streams it
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Two consequences worth internalising:
|
|
162
|
+
|
|
163
|
+
- **The tab must be open** for tool calls to work. With no tab, the gateway
|
|
164
|
+
answers `{ ok: false }` and the model reports an honest failure instead of
|
|
165
|
+
hanging.
|
|
166
|
+
- **The tool list is live.** Every `tools/list` re-fetches your manifest, so
|
|
167
|
+
capabilities appear and disappear as your app changes — no gateway restart,
|
|
168
|
+
no redeploy.
|
|
169
|
+
|
|
170
|
+
## Demo mode
|
|
171
|
+
|
|
172
|
+
An unlicensed gateway caps each namespace at **5 distinct visitor threads**.
|
|
173
|
+
Existing threads keep working; only minting a _new_ one is refused, with `402`
|
|
174
|
+
and a contact address. `chat-client.js` surfaces that as a readable message
|
|
175
|
+
rather than a generic failure.
|
|
176
|
+
|
|
177
|
+
That's plenty to evaluate with and hits immediately in production, which is the
|
|
178
|
+
point. See [`LICENSE`](../../LICENSE).
|
|
179
|
+
|
|
180
|
+
## Optional extras
|
|
181
|
+
|
|
182
|
+
Not in this kit, but in the guide:
|
|
183
|
+
|
|
184
|
+
- **Selection as context** — let a visitor highlight part of the page and ask
|
|
185
|
+
about it (guide §4.6).
|
|
186
|
+
- **Executor proxy** — reverse-proxy your app's own API through the gateway so
|
|
187
|
+
the tab talks to a single origin (`executorProxy` in the example config).
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Stdio MCP server that the cumulus gateway spawns once per turn, giving the
|
|
2
|
+
// model tools that execute inside the visitor's live browser tab.
|
|
3
|
+
//
|
|
4
|
+
// Register it on your namespace in gateway.config.json (see
|
|
5
|
+
// gateway.config.example.json). The gateway substitutes {thread} for the
|
|
6
|
+
// actual thread name, which is how one shim serves every visitor.
|
|
7
|
+
//
|
|
8
|
+
// Stateless by design: every tools/list re-fetches the tab's live manifest, so
|
|
9
|
+
// tools appear and disappear as the app's capabilities change — no restart, no
|
|
10
|
+
// redeploy. Every tools/call is forwarded to POST /bridge/call, which
|
|
11
|
+
// dispatches into the tab. With no tab connected the gateway answers
|
|
12
|
+
// { ok: false } and the model sees an honest failure rather than a hang.
|
|
13
|
+
//
|
|
14
|
+
// MCP tool names cannot contain dots, so registry command names are exposed
|
|
15
|
+
// with underscores (notes.create -> notes_create) and mapped back on call.
|
|
16
|
+
//
|
|
17
|
+
// Env (set by the gateway from your namespace config):
|
|
18
|
+
// GATEWAY_ORIGIN default http://127.0.0.1:8080 (this shim runs ON the
|
|
19
|
+
// gateway host, so the local gateway is the right guess —
|
|
20
|
+
// unlike server.js, where there is no safe default)
|
|
21
|
+
// GATEWAY_API_KEY a key scoped to this namespace
|
|
22
|
+
// BRIDGE_THREAD the thread being served — pass "{thread}" in the config
|
|
23
|
+
|
|
24
|
+
import readline from 'node:readline';
|
|
25
|
+
|
|
26
|
+
const GATEWAY_ORIGIN = process.env.GATEWAY_ORIGIN ?? 'http://127.0.0.1:8080';
|
|
27
|
+
const GATEWAY_API_KEY = process.env.GATEWAY_API_KEY ?? '';
|
|
28
|
+
const BRIDGE_THREAD = process.env.BRIDGE_THREAD ?? 'demoapp';
|
|
29
|
+
const SERVER_NAME = process.env.MCP_SERVER_NAME ?? 'demoapp-tools';
|
|
30
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
31
|
+
|
|
32
|
+
const toMcpName = command => command.replaceAll('.', '_');
|
|
33
|
+
let nameToCommand = new Map();
|
|
34
|
+
|
|
35
|
+
async function fetchManifest() {
|
|
36
|
+
const res = await fetch(
|
|
37
|
+
`${GATEWAY_ORIGIN}/bridge/manifest/${encodeURIComponent(BRIDGE_THREAD)}`,
|
|
38
|
+
{ headers: { 'X-API-Key': GATEWAY_API_KEY } }
|
|
39
|
+
);
|
|
40
|
+
const manifest = res.ok ? ((await res.json()).manifest ?? []) : [];
|
|
41
|
+
nameToCommand = new Map(manifest.map(c => [toMcpName(c.name), c.name]));
|
|
42
|
+
return manifest.map(c => ({
|
|
43
|
+
name: toMcpName(c.name),
|
|
44
|
+
// The risk tier rides in the description so the model can see which calls
|
|
45
|
+
// are reversible and which will stop for a human.
|
|
46
|
+
description: `[${c.risk ?? 'read'}] ${c.description ?? c.name}`,
|
|
47
|
+
inputSchema: c.input_schema ?? c.inputSchema ?? { type: 'object', properties: {} },
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function callCommand(mcpName, args) {
|
|
52
|
+
if (!nameToCommand.has(mcpName)) await fetchManifest().catch(() => {});
|
|
53
|
+
const command = nameToCommand.get(mcpName) ?? mcpName.replaceAll('_', '.');
|
|
54
|
+
const res = await fetch(`${GATEWAY_ORIGIN}/bridge/call`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'content-type': 'application/json', 'X-API-Key': GATEWAY_API_KEY },
|
|
57
|
+
body: JSON.stringify({ thread: BRIDGE_THREAD, command, params: args ?? {} }),
|
|
58
|
+
});
|
|
59
|
+
if (!res.ok) throw new Error(`bridge call failed: ${res.status}`);
|
|
60
|
+
return res.json();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const handlers = {
|
|
64
|
+
initialize: async () => ({
|
|
65
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
66
|
+
capabilities: { tools: {} },
|
|
67
|
+
serverInfo: { name: SERVER_NAME, version: '1.0.0' },
|
|
68
|
+
}),
|
|
69
|
+
'tools/list': async () => ({ tools: await fetchManifest() }),
|
|
70
|
+
'tools/call': async ({ name, arguments: args }) => {
|
|
71
|
+
const result = await callCommand(name, args);
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
74
|
+
isError: !result.ok,
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
ping: async () => ({}),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
81
|
+
rl.on('line', async line => {
|
|
82
|
+
if (!line.trim()) return;
|
|
83
|
+
let msg;
|
|
84
|
+
try {
|
|
85
|
+
msg = JSON.parse(line);
|
|
86
|
+
} catch {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (msg.id === undefined) return; // notification (e.g. notifications/initialized)
|
|
90
|
+
|
|
91
|
+
const handler = handlers[msg.method];
|
|
92
|
+
let response;
|
|
93
|
+
try {
|
|
94
|
+
if (!handler)
|
|
95
|
+
throw Object.assign(new Error(`Method not found: ${msg.method}`), { code: -32601 });
|
|
96
|
+
response = { jsonrpc: '2.0', id: msg.id, result: await handler(msg.params ?? {}) };
|
|
97
|
+
} catch (err) {
|
|
98
|
+
response = {
|
|
99
|
+
jsonrpc: '2.0',
|
|
100
|
+
id: msg.id,
|
|
101
|
+
error: { code: err.code ?? -32000, message: err?.message ?? String(err) },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
process.stdout.write(JSON.stringify(response) + '\n');
|
|
105
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": [
|
|
3
|
+
"Merge these keys into your ~/.cumulus/gateway.config.json — this file is a fragment, not a whole config.",
|
|
4
|
+
"Then: sudo systemctl reload cumulus-gateway (SIGHUP; never restart, it kills in-flight turns).",
|
|
5
|
+
"Replace sk-demoapp-REPLACE-ME with a long random string. It is the app's only credential."
|
|
6
|
+
],
|
|
7
|
+
|
|
8
|
+
"bridge": { "enabled": true },
|
|
9
|
+
|
|
10
|
+
"namespaces": [
|
|
11
|
+
{
|
|
12
|
+
"name": "demoapp",
|
|
13
|
+
"label": "Demo App",
|
|
14
|
+
|
|
15
|
+
"_apiKeys": [
|
|
16
|
+
"A key listed here is SCOPED: it can touch demoapp-* threads and nothing else,",
|
|
17
|
+
"it cannot enumerate any thread anywhere, and it never sees the base 'demoapp'",
|
|
18
|
+
"thread (that one belongs to you, for working on the app). This is the key your",
|
|
19
|
+
"serving layer hands to logged-in sessions."
|
|
20
|
+
],
|
|
21
|
+
"apiKeys": ["sk-demoapp-REPLACE-ME"],
|
|
22
|
+
|
|
23
|
+
"_extraMcpServers": [
|
|
24
|
+
"Spawned per turn for demoapp-* threads only. {thread} is substituted with the",
|
|
25
|
+
"calling thread's name, so one shim serves every visitor. Use an absolute path",
|
|
26
|
+
"to the shim — the gateway spawns it with the thread's own cwd."
|
|
27
|
+
],
|
|
28
|
+
"extraMcpServers": {
|
|
29
|
+
"demoapp-tools": {
|
|
30
|
+
"command": "node",
|
|
31
|
+
"args": ["/absolute/path/to/examples/web-app-agent/agent/mcp-shim.js"],
|
|
32
|
+
"env": {
|
|
33
|
+
"GATEWAY_ORIGIN": "http://127.0.0.1:8080",
|
|
34
|
+
"GATEWAY_API_KEY": "sk-demoapp-REPLACE-ME",
|
|
35
|
+
"BRIDGE_THREAD": "{thread}",
|
|
36
|
+
"MCP_SERVER_NAME": "demoapp-tools"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
"_executorProxy": [
|
|
42
|
+
"OPTIONAL. Only if your app has a backend the agent should reach directly.",
|
|
43
|
+
"Lets the tab talk to a single origin (the gateway) instead of two.",
|
|
44
|
+
"Delete this block if you don't need it — the demo does not."
|
|
45
|
+
],
|
|
46
|
+
"executorProxy": {
|
|
47
|
+
"origin": "http://127.0.0.1:8199",
|
|
48
|
+
"pathPrefixes": ["/api/public"]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cumulus-web-app-agent-example",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Runnable starter kit: a web app with a persistent cumulus agent that can see and drive it.",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node server.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=20"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/* Bridge mount — wires the cumulus-owned BridgeClient to this app's registry.
|
|
2
|
+
|
|
3
|
+
The BridgeClient is served straight out of the cumulus package (see
|
|
4
|
+
server.js). Don't fork it: it is the seam contract, and the gateway on the
|
|
5
|
+
other end is versioned with it.
|
|
6
|
+
|
|
7
|
+
Because the agent config arrives only after login, this is a function the
|
|
8
|
+
app calls (AgentStart) rather than something that runs at load. AgentStop
|
|
9
|
+
tears it down on logout so a second login re-mounts cleanly. */
|
|
10
|
+
import { BridgeClient } from './bridge-client/client.js';
|
|
11
|
+
|
|
12
|
+
let bridge = null;
|
|
13
|
+
|
|
14
|
+
window.AgentStart = function () {
|
|
15
|
+
if (bridge) return; // already live this page-load
|
|
16
|
+
const cfg = window.__AGENT_CONFIG__;
|
|
17
|
+
if (!cfg || !cfg.API_KEY || !window.AgentRegistry || !window.agentDeviceThread) return;
|
|
18
|
+
|
|
19
|
+
window.agentDeviceThread(cfg); // THREAD_ID -> demoapp-v-<deviceId>
|
|
20
|
+
console.info('[agent] thread: ' + cfg.THREAD_ID);
|
|
21
|
+
|
|
22
|
+
if (!document.getElementById('agent-css')) {
|
|
23
|
+
const link = document.createElement('link');
|
|
24
|
+
link.id = 'agent-css';
|
|
25
|
+
link.rel = 'stylesheet';
|
|
26
|
+
link.href = '/agent/panel.css';
|
|
27
|
+
document.head.appendChild(link);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
window.AgentChat.init(cfg);
|
|
31
|
+
window.AgentPanel.init();
|
|
32
|
+
|
|
33
|
+
bridge = new BridgeClient({
|
|
34
|
+
url: cfg.BRIDGE_URL,
|
|
35
|
+
thread: cfg.THREAD_ID,
|
|
36
|
+
apiKey: cfg.API_KEY,
|
|
37
|
+
|
|
38
|
+
registry: {
|
|
39
|
+
manifest: window.AgentRegistry.manifest(),
|
|
40
|
+
execute: (command, params) => Promise.resolve(window.AgentRegistry.call(command, params)),
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
// Recomputed on EVERY sendContext, never cached — this is what keeps the
|
|
44
|
+
// agent looking at the same screen the human is.
|
|
45
|
+
describeView: () => {
|
|
46
|
+
const r = window.AgentRegistry.callNow('app.describeView');
|
|
47
|
+
return r.ok ? r.data : { error: r.summary };
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// Export-tier commands land here instead of executing. Returning without
|
|
51
|
+
// calling accept() or decline() means the gateway times the call out —
|
|
52
|
+
// which is the safe outcome.
|
|
53
|
+
onConfirmRequest: req => window.AgentPanel.confirm(req),
|
|
54
|
+
|
|
55
|
+
onStateChange: s => {
|
|
56
|
+
console.info('[agent] bridge ' + s);
|
|
57
|
+
window.AgentPanel.setState(s);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
bridge.connect();
|
|
62
|
+
window.AgentBridge = bridge;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
window.AgentStop = function () {
|
|
66
|
+
if (!bridge) return;
|
|
67
|
+
bridge.close();
|
|
68
|
+
bridge = null;
|
|
69
|
+
window.AgentBridge = null;
|
|
70
|
+
if (window.AgentPanel) window.AgentPanel.reset();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// The config may already be present if the session check finished before this
|
|
74
|
+
// module executed.
|
|
75
|
+
if (window.__AGENT_CONFIG__) window.AgentStart();
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/* Gateway chat client. Speaks the cumulus HTTP API:
|
|
2
|
+
|
|
3
|
+
POST /api/thread/:name/message -> SSE stream: token | segment | error | done
|
|
4
|
+
GET /api/thread/:name/history -> prior turns, for reload survival
|
|
5
|
+
|
|
6
|
+
Both authenticate with the scoped key in the X-API-Key header. Note there is
|
|
7
|
+
no thread-creation call: posting to a thread that does not exist creates it.
|
|
8
|
+
That is exactly what the demo-mode visitor cap gates, so a 402 here means the
|
|
9
|
+
gateway is unlicensed and at capacity.
|
|
10
|
+
|
|
11
|
+
window.AgentChat = {
|
|
12
|
+
init(cfg) -> boolean, live,
|
|
13
|
+
send(message, handlers) -> { cancel() }
|
|
14
|
+
handlers: onToken(text), onSegment(seg), onError(err), onDone(payload)
|
|
15
|
+
history() -> Promise<array> ([] when unavailable)
|
|
16
|
+
} */
|
|
17
|
+
(function () {
|
|
18
|
+
'use strict';
|
|
19
|
+
var origin = null,
|
|
20
|
+
thread = null,
|
|
21
|
+
apiKey = null;
|
|
22
|
+
|
|
23
|
+
async function streamSSE(resp, handlers, cancelled) {
|
|
24
|
+
var reader = resp.body.getReader();
|
|
25
|
+
var decoder = new TextDecoder();
|
|
26
|
+
var buf = '';
|
|
27
|
+
while (true) {
|
|
28
|
+
var chunk = await reader.read();
|
|
29
|
+
if (chunk.done || cancelled.is) break;
|
|
30
|
+
buf += decoder.decode(chunk.value, { stream: true });
|
|
31
|
+
var idx;
|
|
32
|
+
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
33
|
+
var raw = buf.slice(0, idx);
|
|
34
|
+
buf = buf.slice(idx + 2);
|
|
35
|
+
var event = 'message',
|
|
36
|
+
data = '';
|
|
37
|
+
raw.split('\n').forEach(function (line) {
|
|
38
|
+
if (line.indexOf('event:') === 0) event = line.slice(6).trim();
|
|
39
|
+
else if (line.indexOf('data:') === 0) data += line.slice(5).trim();
|
|
40
|
+
});
|
|
41
|
+
if (!data) continue;
|
|
42
|
+
var payload;
|
|
43
|
+
try {
|
|
44
|
+
payload = JSON.parse(data);
|
|
45
|
+
} catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (event === 'token' && handlers.onToken) handlers.onToken(payload.text || '');
|
|
49
|
+
else if (event === 'segment' && handlers.onSegment) handlers.onSegment(payload);
|
|
50
|
+
else if (event === 'error' && handlers.onError) handlers.onError(payload);
|
|
51
|
+
else if (event === 'done' && handlers.onDone) handlers.onDone(payload);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
window.AgentChat = {
|
|
57
|
+
live: false,
|
|
58
|
+
|
|
59
|
+
init: function (cfg) {
|
|
60
|
+
origin = cfg.GATEWAY_URL;
|
|
61
|
+
thread = cfg.THREAD_ID;
|
|
62
|
+
apiKey = cfg.API_KEY;
|
|
63
|
+
this.live = !!(origin && thread && apiKey);
|
|
64
|
+
return this.live;
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
send: function (message, handlers) {
|
|
68
|
+
var cancelled = { is: false };
|
|
69
|
+
fetch(origin + '/api/thread/' + encodeURIComponent(thread) + '/message', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
|
72
|
+
body: JSON.stringify({ message: message }),
|
|
73
|
+
})
|
|
74
|
+
.then(function (resp) {
|
|
75
|
+
if (resp.status === 402)
|
|
76
|
+
throw new Error('Gateway is in demo mode and at its visitor limit');
|
|
77
|
+
if (!resp.ok) throw new Error('Gateway returned ' + resp.status);
|
|
78
|
+
return streamSSE(resp, handlers, cancelled);
|
|
79
|
+
})
|
|
80
|
+
.catch(function (err) {
|
|
81
|
+
if (!cancelled.is && handlers.onError)
|
|
82
|
+
handlers.onError({ error: String(err.message || err) });
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
cancel: function () {
|
|
86
|
+
cancelled.is = true;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
history: async function () {
|
|
92
|
+
try {
|
|
93
|
+
var resp = await fetch(origin + '/api/thread/' + encodeURIComponent(thread) + '/history', {
|
|
94
|
+
headers: { 'X-API-Key': apiKey },
|
|
95
|
+
});
|
|
96
|
+
if (!resp.ok) return [];
|
|
97
|
+
var j = await resp.json();
|
|
98
|
+
return Array.isArray(j) ? j : j.messages || j.items || [];
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
})();
|