@desplega.ai/agent-swarm 1.79.0 → 1.79.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/openapi.json +559 -1
- package/package.json +4 -4
- package/plugin/skills/kv-storage/SKILL.md +168 -0
- package/plugin/skills/pages/SKILL.md +149 -0
- package/src/artifact-sdk/browser-sdk.ts +292 -0
- package/src/be/db.ts +309 -0
- package/src/be/migrations/061_kv_store.sql +34 -0
- package/src/be/migrations/062_pages_view_count.sql +9 -0
- package/src/commands/provider-credentials.ts +1 -1
- package/src/http/index.ts +2 -0
- package/src/http/kv.ts +658 -0
- package/src/http/page-proxy.ts +5 -0
- package/src/http/pages-public.ts +44 -3
- package/src/http/status.ts +1 -1
- package/src/providers/pi-mono-adapter.ts +3 -3
- package/src/providers/pi-mono-extension.ts +1 -1
- package/src/server.ts +20 -1
- package/src/tasks/context-key.ts +28 -0
- package/src/telemetry.ts +65 -1
- package/src/tests/context-key.test.ts +17 -0
- package/src/tests/kv-http.test.ts +331 -0
- package/src/tests/kv-namespace-resolution.test.ts +172 -0
- package/src/tests/kv-page-proxy.test.ts +212 -0
- package/src/tests/kv-storage.test.ts +227 -0
- package/src/tests/kv-tool.test.ts +217 -0
- package/src/tests/page-proxy.test.ts +5 -1
- package/src/tests/page-session.test.ts +10 -5
- package/src/tests/pages-authed-mode.test.ts +5 -1
- package/src/tests/pages-view-count.test.ts +220 -0
- package/src/tests/swarm-diff.test.ts +303 -0
- package/src/tests/telemetry-init.test.ts +149 -0
- package/src/tools/kv/index.ts +5 -0
- package/src/tools/kv/kv-delete.ts +89 -0
- package/src/tools/kv/kv-get.ts +64 -0
- package/src/tools/kv/kv-incr.ts +116 -0
- package/src/tools/kv/kv-list.ts +81 -0
- package/src/tools/kv/kv-set.ts +194 -0
- package/src/tools/kv/resolve-namespace.ts +58 -0
- package/src/tools/tool-config.ts +7 -0
- package/src/types.ts +53 -0
- package/src/utils/internal-ai/complete-structured.ts +2 -2
- package/src/utils/internal-ai/credentials.ts +3 -3
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kv-storage
|
|
3
|
+
description: Use the swarm KV store (Redis-like, namespaced) for cross-task / cross-session / per-page state. Auto-scoped to your context (Slack thread / PR / Linear issue / agent / page). Use for counters, cursors, page state. Do NOT use for secrets (`swarm_config`), embedded knowledge (`memory`), or files (`agent-fs`).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# KV Storage
|
|
7
|
+
|
|
8
|
+
Namespaced key/value store inside the swarm SQLite DB. Auto-scoped to your
|
|
9
|
+
calling context — same string used by `agent_tasks.contextKey`.
|
|
10
|
+
|
|
11
|
+
> **Capability gate**: the `kv-*` MCP tools are only available when your
|
|
12
|
+
> `CAPABILITIES` includes `kv` (default-on; check `my-agent-info`). The REST
|
|
13
|
+
> endpoints under `/api/kv/*` are always present on the API server.
|
|
14
|
+
|
|
15
|
+
## When to use KV
|
|
16
|
+
|
|
17
|
+
| You need… | Use this | Not this |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| Count something in this Slack thread / PR / Linear issue | **KV** (auto-scoped) | memory / agent-fs |
|
|
20
|
+
| Save a cursor / last-seen state for a recurring schedule | **KV** | swarm_config |
|
|
21
|
+
| Page-internal counter / vote / state across reloads | **KV** via `swarmSdk.kv` | memory |
|
|
22
|
+
| Cross-task state in the same conversation | **KV** (auto-scoped to `task:slack:...`) | parentTaskId only |
|
|
23
|
+
| Secrets, API tokens, OAuth creds | `swarm_config` (encrypted + masked) | **NOT KV** |
|
|
24
|
+
| Cross-session knowledge for this agent ("how do I…") | `memory_search` / `memory-get` | **NOT KV** |
|
|
25
|
+
| Files, binaries, long documents | `agent-fs` | **NOT KV** |
|
|
26
|
+
| Workflow run state | workflow vars (own KV) | **NOT KV** |
|
|
27
|
+
|
|
28
|
+
Rule of thumb:
|
|
29
|
+
- If a future invocation should *find this without knowing the key* → memory.
|
|
30
|
+
- If a future invocation will *know exactly which key to read* → KV.
|
|
31
|
+
- If it has secrets in it → `swarm_config`.
|
|
32
|
+
- If it's bytes (image, pdf, large doc) → agent-fs.
|
|
33
|
+
|
|
34
|
+
## Namespacing
|
|
35
|
+
|
|
36
|
+
Namespace is just a string. It mirrors the `contextKey` schema
|
|
37
|
+
(`src/tasks/context-key.ts`). When you don't pass one, the server resolves it
|
|
38
|
+
from request headers in this order:
|
|
39
|
+
|
|
40
|
+
1. `X-Page-Id` (only the page-proxy sets this) → `task:page:<id>`
|
|
41
|
+
2. `X-Source-Task-Id` → that task's `contextKey` (e.g. `task:slack:C123:1776...`)
|
|
42
|
+
3. `X-Agent-ID` → `task:agent:<id>` (per-agent scratchpad)
|
|
43
|
+
|
|
44
|
+
So **inside a session triggered by a Slack thread, KV is automatically scoped
|
|
45
|
+
to that thread** — your sibling tasks (re-runs, retries, follow-ups in the same
|
|
46
|
+
thread) read the same store with no setup. Same for PRs (`task:trackers:github:owner:repo:pr:N`),
|
|
47
|
+
Linear issues (`task:trackers:linear:DES-42`), schedules, workflows.
|
|
48
|
+
|
|
49
|
+
You can override the namespace explicitly when you need to — see "Explicit
|
|
50
|
+
override" below.
|
|
51
|
+
|
|
52
|
+
## Quick recipes
|
|
53
|
+
|
|
54
|
+
### MCP — inside any agent session
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
kv-set key="vote-count" value=0 valueType="integer" # → namespace = task:slack:...
|
|
58
|
+
kv-incr key="vote-count" # → 1
|
|
59
|
+
kv-incr key="vote-count" by=5 # → 6
|
|
60
|
+
kv-get key="vote-count" # → entry with value=6
|
|
61
|
+
kv-list prefix="vote-" # → all matching entries
|
|
62
|
+
kv-delete key="vote-count" # → done
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`kv-set` defaults to `valueType: 'json'` and JSON-encodes whatever you pass.
|
|
66
|
+
Use `'string'` to skip encoding (good for short tokens, URLs) and
|
|
67
|
+
`'integer'` for counters (required by `kv-incr`).
|
|
68
|
+
|
|
69
|
+
### REST — humans, scripts, external clients
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Header-resolved namespace (recommended for in-session calls)
|
|
73
|
+
curl -H "Authorization: Bearer $API_KEY" \
|
|
74
|
+
-H "X-Agent-ID: $AGENT_ID" \
|
|
75
|
+
"$MCP_BASE_URL/api/kv/last-cursor"
|
|
76
|
+
|
|
77
|
+
# Explicit namespace
|
|
78
|
+
curl -H "Authorization: Bearer $API_KEY" \
|
|
79
|
+
"$MCP_BASE_URL/api/kv/_/task:trackers:linear:DES-42/last-comment-id"
|
|
80
|
+
|
|
81
|
+
# PUT a JSON value with a 10-minute TTL
|
|
82
|
+
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "X-Agent-ID: $AGENT_ID" \
|
|
83
|
+
-H "Content-Type: application/json" \
|
|
84
|
+
-d '{"value":{"n":42},"valueType":"json","expiresInSec":600}' \
|
|
85
|
+
"$MCP_BASE_URL/api/kv/snapshot"
|
|
86
|
+
|
|
87
|
+
# List with a prefix
|
|
88
|
+
curl -H "Authorization: Bearer $API_KEY" -H "X-Agent-ID: $AGENT_ID" \
|
|
89
|
+
"$MCP_BASE_URL/api/kv?prefix=daily-&limit=50"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Pages browser SDK — inside an authed page
|
|
93
|
+
|
|
94
|
+
Page proxy forces the namespace to `task:page:<id>` — no namespace argument is
|
|
95
|
+
exposed. Use it for page-local counters, vote tallies, multi-step form state,
|
|
96
|
+
"remember this number from last refresh" UX:
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
// Inside a page's <script> tag
|
|
100
|
+
const count = await swarmSdk.kv.incr('clicks'); // → number-valued entry
|
|
101
|
+
await swarmSdk.kv.set('lastSeen', Date.now()); // → 'json' by default
|
|
102
|
+
const entry = await swarmSdk.kv.get('clicks'); // → { value, valueType, ... } or null
|
|
103
|
+
await swarmSdk.kv.del('clicks');
|
|
104
|
+
const all = await swarmSdk.kv.list({ prefix: 'click', limit: 50 });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Public pages (`authMode: 'public'`) cannot reach `/@swarm/api/*` and so cannot
|
|
108
|
+
use KV. Promote to `authed` or `password` mode if the page needs state.
|
|
109
|
+
|
|
110
|
+
## Explicit override
|
|
111
|
+
|
|
112
|
+
Pass `namespace` to read/write somewhere other than your auto-context:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
kv-get key="seed" namespace="swarm:experiments" # ad-hoc namespace
|
|
116
|
+
kv-set key="note" value="hi" namespace="task:agent:OTHER-AGENT-ID"
|
|
117
|
+
# → 403 unless caller is lead
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Rules:
|
|
121
|
+
- **Reads:** any authenticated caller can read any namespace.
|
|
122
|
+
- **Writes to `task:agent:<X>`** where X ≠ caller agentId: **403** unless lead.
|
|
123
|
+
- **Writes to `task:page:<X>`** from anywhere except a page-proxy request: **403**.
|
|
124
|
+
- Everything else: writable by any authenticated caller.
|
|
125
|
+
|
|
126
|
+
## TTL & expiry
|
|
127
|
+
|
|
128
|
+
Default = **no expiry**. Opt in by passing `expiresInSec`:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
kv-set key="lock-token" value="xyz" valueType="string" expiresInSec=60
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Expiry is *lazy*: reads on an expired key return null and delete the row;
|
|
135
|
+
`kv-list` filters expired rows out of the SELECT but doesn't delete them
|
|
136
|
+
(keeps cursor pagination stable). No background sweeper — expired rows that
|
|
137
|
+
never get touched stay on disk harmlessly.
|
|
138
|
+
|
|
139
|
+
## Body cap
|
|
140
|
+
|
|
141
|
+
2 MiB per value. Over the cap returns 413. If you want to store something
|
|
142
|
+
larger, write it to `agent-fs` and stash the path in KV.
|
|
143
|
+
|
|
144
|
+
## Gotchas
|
|
145
|
+
|
|
146
|
+
- **Namespaces ARE contextKey strings.** The same string that lets the swarm
|
|
147
|
+
find sibling tasks for a PR also indexes KV for that PR.
|
|
148
|
+
- **Reads return `null` for missing AND expired keys** — you can't tell the
|
|
149
|
+
difference from one call. (If you need to know, list the key.)
|
|
150
|
+
- **INCR collides** if the existing row has `valueType` `'json'` or `'string'`
|
|
151
|
+
(409 / `KvTypeCollisionError`). Delete and re-create as `'integer'` first,
|
|
152
|
+
or use a different key.
|
|
153
|
+
- **JSON values round-trip** through `JSON.parse` on read. If you wrote
|
|
154
|
+
`{a:1}`, you'll get back the object — not the raw string. Use
|
|
155
|
+
`valueType: 'string'` if you want byte-exact storage.
|
|
156
|
+
- **No CAS / SETNX yet.** Use `kv-incr` for atomic counters; for
|
|
157
|
+
"claim a token" patterns, set with a short TTL and re-check.
|
|
158
|
+
- **Page SDK has no `namespace` argument.** Pages are always scoped to
|
|
159
|
+
`task:page:<id>`. Don't try to encode another namespace in the key path —
|
|
160
|
+
the URL gets rewritten anyway.
|
|
161
|
+
|
|
162
|
+
## See also
|
|
163
|
+
|
|
164
|
+
- `src/be/migrations/061_kv_store.sql` — schema (`kv_entries`)
|
|
165
|
+
- `src/http/kv.ts` — REST handler + namespace resolution
|
|
166
|
+
- `src/tools/kv/*` — MCP tool registrars
|
|
167
|
+
- `src/artifact-sdk/browser-sdk.ts` — `swarmSdk.kv` for pages
|
|
168
|
+
- `plugin/skills/pages/SKILL.md` — companion skill for authed pages
|
|
@@ -210,6 +210,155 @@ For the full list of fields each endpoint accepts/returns, see
|
|
|
210
210
|
[**docs.agent-swarm.dev/docs/api-reference**](https://docs.agent-swarm.dev/docs/api-reference).
|
|
211
211
|
The SDK is a thin domain wrapper — anything documented there is reachable.
|
|
212
212
|
|
|
213
|
+
## Built-in primitives
|
|
214
|
+
|
|
215
|
+
Every HTML page automatically gets a small set of zero-dep web components
|
|
216
|
+
auto-injected alongside the Browser SDK. Drop them into your page body —
|
|
217
|
+
no `<script>` import, no bundling, no Tailwind required (though Tailwind
|
|
218
|
+
Play CDN is loaded, so utility classes work too).
|
|
219
|
+
|
|
220
|
+
### `<swarm-diff>` — unified diff renderer
|
|
221
|
+
|
|
222
|
+
Render a unified diff with a two-column gutter, severity annotations, and a
|
|
223
|
+
deterministic anchor id per hunk (so deep-linking + jump lists work). The
|
|
224
|
+
element reads its payload from its `textContent` as JSON of shape
|
|
225
|
+
`{ hunks: [{ old_start, old_lines, new_start, new_lines, lines, annotations? }] }`.
|
|
226
|
+
|
|
227
|
+
```html
|
|
228
|
+
<swarm-diff
|
|
229
|
+
file="src/foo.ts"
|
|
230
|
+
base-sha="abc123"
|
|
231
|
+
head-sha="def456">
|
|
232
|
+
{ "hunks": [
|
|
233
|
+
{ "old_start": 10, "old_lines": 3, "new_start": 10, "new_lines": 4,
|
|
234
|
+
"lines": [
|
|
235
|
+
{ "type": "context", "text": " const x = 1;" },
|
|
236
|
+
{ "type": "del", "text": "- console.log(x);" },
|
|
237
|
+
{ "type": "add", "text": "+ logger.info({ x });" },
|
|
238
|
+
{ "type": "add", "text": "+ return x;" }
|
|
239
|
+
],
|
|
240
|
+
"annotations": [
|
|
241
|
+
{ "line": 12, "severity": "warn", "text": "Avoid raw console.log" }
|
|
242
|
+
]
|
|
243
|
+
}
|
|
244
|
+
] }
|
|
245
|
+
</swarm-diff>
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Inputs**
|
|
249
|
+
|
|
250
|
+
| Attribute | Required | Notes |
|
|
251
|
+
|---|---|---|
|
|
252
|
+
| `file` | yes | Path label rendered in the hunk header. Used for the anchor id slug. |
|
|
253
|
+
| `base-sha` | no | Pre-change SHA. Rendered in the header next to `head-sha`. |
|
|
254
|
+
| `head-sha` | no | Post-change SHA. |
|
|
255
|
+
|
|
256
|
+
**Line shape**
|
|
257
|
+
|
|
258
|
+
| Field | Values | Notes |
|
|
259
|
+
|---|---|---|
|
|
260
|
+
| `type` | `context` \| `add` \| `del` | Drives row tint (green / red / neutral) and gutter line numbering. |
|
|
261
|
+
| `text` | string | Rendered verbatim (HTML-escaped). |
|
|
262
|
+
|
|
263
|
+
**Annotation shape** — attaches to a NEW-side line by line number; rendered
|
|
264
|
+
as a margin badge on that row.
|
|
265
|
+
|
|
266
|
+
| Field | Values | Notes |
|
|
267
|
+
|---|---|---|
|
|
268
|
+
| `line` | integer | New-side line number (falls back to old-side if no add for that line). |
|
|
269
|
+
| `severity` | `error` \| `warn` \| `info` | Drives badge color. |
|
|
270
|
+
| `text` | string | Badge body. |
|
|
271
|
+
|
|
272
|
+
**Anchor id**
|
|
273
|
+
|
|
274
|
+
Each hunk gets `id="swarm-diff-<file-slug>-<old_start>"` for deep-linking.
|
|
275
|
+
Use `<swarm-diff-jumps></swarm-diff-jumps>` anywhere in the page body to
|
|
276
|
+
render a tiny "Jump to" navigation of every diff hunk on the page —
|
|
277
|
+
handy when an agent ships a multi-file annotated PR.
|
|
278
|
+
|
|
279
|
+
**Programmatic form**
|
|
280
|
+
|
|
281
|
+
If you need to render a diff from a fetch response (rather than inline JSON),
|
|
282
|
+
use `window.swarmUi.renderDiff(rootEl, diffData)`:
|
|
283
|
+
|
|
284
|
+
```html
|
|
285
|
+
<div id="diff-target"></div>
|
|
286
|
+
<script>
|
|
287
|
+
const data = await fetch('/some/diff.json').then(r => r.json());
|
|
288
|
+
window.swarmUi.renderDiff(document.getElementById('diff-target'), data);
|
|
289
|
+
</script>
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Annotated-PR example**
|
|
293
|
+
|
|
294
|
+
```html
|
|
295
|
+
<!doctype html>
|
|
296
|
+
<html><head><title>PR #1234 — `console.log` cleanup</title></head>
|
|
297
|
+
<body>
|
|
298
|
+
<h1>PR #1234 — cleanup raw <code>console.log</code> calls</h1>
|
|
299
|
+
<p>Replaces ad-hoc logging with the project logger.</p>
|
|
300
|
+
|
|
301
|
+
<swarm-diff-jumps></swarm-diff-jumps>
|
|
302
|
+
|
|
303
|
+
<swarm-diff file="src/foo.ts" base-sha="abc123" head-sha="def456">
|
|
304
|
+
{ "hunks": [
|
|
305
|
+
{ "old_start": 10, "old_lines": 3, "new_start": 10, "new_lines": 4,
|
|
306
|
+
"lines": [
|
|
307
|
+
{ "type": "context", "text": " const x = 1;" },
|
|
308
|
+
{ "type": "del", "text": "- console.log(x);" },
|
|
309
|
+
{ "type": "add", "text": "+ logger.info({ x });" },
|
|
310
|
+
{ "type": "add", "text": "+ return x;" }
|
|
311
|
+
],
|
|
312
|
+
"annotations": [
|
|
313
|
+
{ "line": 12, "severity": "warn", "text": "Avoid raw console.log" }
|
|
314
|
+
]
|
|
315
|
+
}
|
|
316
|
+
] }
|
|
317
|
+
</swarm-diff>
|
|
318
|
+
|
|
319
|
+
<swarm-diff file="src/bar.ts" base-sha="abc123" head-sha="def456">
|
|
320
|
+
{ "hunks": [
|
|
321
|
+
{ "old_start": 5, "old_lines": 1, "new_start": 5, "new_lines": 1,
|
|
322
|
+
"lines": [
|
|
323
|
+
{ "type": "del", "text": "- console.error('boom');" },
|
|
324
|
+
{ "type": "add", "text": "+ logger.error('boom');" }
|
|
325
|
+
]
|
|
326
|
+
}
|
|
327
|
+
] }
|
|
328
|
+
</swarm-diff>
|
|
329
|
+
</body></html>
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## Print / PDF export
|
|
333
|
+
|
|
334
|
+
Every HTML page also gets a `@media print` rule baked into the head defaults:
|
|
335
|
+
- Light theme on print (white background, black text, underlined black links).
|
|
336
|
+
- Anything with the `.no-print` class is hidden (annotation badges and the
|
|
337
|
+
jump list already carry this class — use it on agent-emitted chrome you
|
|
338
|
+
want suppressed in PDF exports).
|
|
339
|
+
- `.swarm-card` and `<swarm-diff>` get `break-inside: avoid` so they don't
|
|
340
|
+
split mid-element across pages.
|
|
341
|
+
|
|
342
|
+
Trigger the export from the SPA's "Export PDF" button on `/pages/:id` — it
|
|
343
|
+
opens the iframe's native print dialog (HTML pages) or the SPA's print
|
|
344
|
+
dialog (JSON pages). The browser's "Print → Save as PDF" handles the actual
|
|
345
|
+
file. No headless Chromium, no server-side rendering — zero infra weight.
|
|
346
|
+
|
|
347
|
+
> Want a custom print layout? Override the print styles in your page's own
|
|
348
|
+
> `<style>` block — agent CSS always wins over the head defaults.
|
|
349
|
+
|
|
350
|
+
## View counter
|
|
351
|
+
|
|
352
|
+
Every successful `200` from `GET /p/:id` (HTML inline) and `GET /p/:id.json`
|
|
353
|
+
(JSON metadata) bumps a `view_count` field on the page. `302` (JSON pages
|
|
354
|
+
redirecting to the SPA), `401`/`403` (auth gate), and `404` do NOT bump.
|
|
355
|
+
The count is exposed on `GET /api/pages` listing (`viewCount` field) and the
|
|
356
|
+
SPA `/pages` index renders it as a small eye-count badge per row.
|
|
357
|
+
|
|
358
|
+
No per-viewer dedup — this is a coarse popularity signal, not analytics.
|
|
359
|
+
Bumps are best-effort (wrapped in try/catch so a counter write never fails
|
|
360
|
+
the response).
|
|
361
|
+
|
|
213
362
|
## JSON Renderer
|
|
214
363
|
|
|
215
364
|
JSON pages are rendered via [`@json-render/react`](https://json-render.dev)
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
// - repos list, get, create, update, delete
|
|
15
15
|
// - schedules list, get, create, update, delete, run
|
|
16
16
|
// - approvalRequests list, get, create, respond
|
|
17
|
+
// - kv get, set, del, incr, list (namespace is forced server-
|
|
18
|
+
// side to the page's own `task:page:<id>` — no namespace
|
|
19
|
+
// argument is exposed)
|
|
17
20
|
//
|
|
18
21
|
// Full HTTP API reference: https://docs.agent-swarm.dev/docs/api-reference
|
|
19
22
|
export const BROWSER_SDK_JS = `
|
|
@@ -103,6 +106,22 @@ class SwarmSDK {
|
|
|
103
106
|
create: (body) => call('POST', '/approval-requests', body),
|
|
104
107
|
respond: (id, body) => call('POST', '/approval-requests/' + enc(id) + '/respond', body),
|
|
105
108
|
};
|
|
109
|
+
|
|
110
|
+
// KV store. The namespace is FORCED by the page-proxy to \`task:page:<id>\`
|
|
111
|
+
// (it injects X-Page-Id which the kv handler treats as highest priority).
|
|
112
|
+
// No namespace argument is exposed — pages cannot read/write any other
|
|
113
|
+
// namespace via this SDK.
|
|
114
|
+
this.kv = {
|
|
115
|
+
get: (key) => call('GET', '/kv/' + enc(key)),
|
|
116
|
+
set: (key, value, opts) => call('PUT', '/kv/' + enc(key), {
|
|
117
|
+
value,
|
|
118
|
+
valueType: opts && opts.valueType,
|
|
119
|
+
expiresInSec: opts && opts.expiresInSec,
|
|
120
|
+
}),
|
|
121
|
+
del: (key) => call('DELETE', '/kv/' + enc(key)),
|
|
122
|
+
incr: (key, by) => call('POST', '/kv/' + enc(key) + '/incr', { by: by == null ? 1 : by }),
|
|
123
|
+
list: (opts) => call('GET', '/kv' + qs(opts)),
|
|
124
|
+
};
|
|
106
125
|
}
|
|
107
126
|
}
|
|
108
127
|
|
|
@@ -112,3 +131,276 @@ class SwarmSDK {
|
|
|
112
131
|
window.SwarmSDK = SwarmSDK;
|
|
113
132
|
window.swarmSdk = new SwarmSDK();
|
|
114
133
|
`;
|
|
134
|
+
|
|
135
|
+
// ─── UI primitives ──────────────────────────────────────────────────────────
|
|
136
|
+
//
|
|
137
|
+
// Auto-injected alongside the SDK. Exposes a tiny set of declarative web
|
|
138
|
+
// components agents can drop into HTML pages without bundling anything. v1:
|
|
139
|
+
// only \`<swarm-diff>\` (unified-diff renderer) + \`<swarm-diff-jumps>\` (a
|
|
140
|
+
// sibling-anchor jump list). All zero-dep, pure DOM — Tailwind utility
|
|
141
|
+
// classes are used freely since the Play CDN is already loaded by
|
|
142
|
+
// PAGE_HEAD_DEFAULTS, but every visual aspect has inline-style fallbacks so
|
|
143
|
+
// the component is still legible if Tailwind fails to load.
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Renders a unified diff as a two-column-gutter HTML table inside a
|
|
147
|
+
* `<swarm-diff>` custom element. Reads `file`, `base-sha`, `head-sha`
|
|
148
|
+
* attributes and parses the element's text content as JSON of shape
|
|
149
|
+
* `{ hunks: [{ old_start, old_lines, new_start, new_lines, lines:
|
|
150
|
+
* [{ type: 'context' | 'add' | 'del', text }], annotations?: [{ line,
|
|
151
|
+
* severity, text }] }] }`. Severity ∈ `error|warn|info`. Each hunk gets a
|
|
152
|
+
* deterministic anchor id so deep-linking + the sibling `<swarm-diff-jumps>`
|
|
153
|
+
* component works.
|
|
154
|
+
*
|
|
155
|
+
* Pure JS, no deps. Tailwind utility classes are sprinkled in but every
|
|
156
|
+
* critical visual property has an inline-style fallback.
|
|
157
|
+
*/
|
|
158
|
+
export const SWARM_UI_JS = `
|
|
159
|
+
(function() {
|
|
160
|
+
if (typeof window === 'undefined' || !window.customElements) return;
|
|
161
|
+
if (window.customElements.get('swarm-diff')) return;
|
|
162
|
+
|
|
163
|
+
var SEV_COLOR = {
|
|
164
|
+
error: '#ef4444',
|
|
165
|
+
warn: '#f59e0b',
|
|
166
|
+
info: '#3b82f6',
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function esc(s) {
|
|
170
|
+
return String(s == null ? '' : s)
|
|
171
|
+
.replace(/&/g, '&')
|
|
172
|
+
.replace(/</g, '<')
|
|
173
|
+
.replace(/>/g, '>')
|
|
174
|
+
.replace(/"/g, '"')
|
|
175
|
+
.replace(/'/g, ''');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function slugifyAttr(s) {
|
|
179
|
+
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parseHunks(jsonText) {
|
|
183
|
+
var trimmed = (jsonText || '').trim();
|
|
184
|
+
if (!trimmed) return [];
|
|
185
|
+
try {
|
|
186
|
+
var parsed = JSON.parse(trimmed);
|
|
187
|
+
if (parsed && Array.isArray(parsed.hunks)) return parsed.hunks;
|
|
188
|
+
if (Array.isArray(parsed)) return parsed;
|
|
189
|
+
return [];
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.warn('[swarm-diff] failed to parse JSON body:', e);
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function renderAnnotation(ann) {
|
|
197
|
+
var color = SEV_COLOR[ann && ann.severity] || SEV_COLOR.info;
|
|
198
|
+
return (
|
|
199
|
+
'<span class="swarm-diff-annot no-print" '
|
|
200
|
+
+ 'style="display:inline-block;margin-left:8px;padding:1px 6px;'
|
|
201
|
+
+ 'border-radius:4px;font-size:11px;font-weight:600;'
|
|
202
|
+
+ 'background:' + color + '22;color:' + color + ';border:1px solid ' + color + '55;">'
|
|
203
|
+
+ esc((ann && ann.severity ? ann.severity.toUpperCase() : 'INFO')) + ' · ' + esc(ann && ann.text || '')
|
|
204
|
+
+ '</span>'
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function renderHunk(hunk, hunkIdx, file) {
|
|
209
|
+
var oldLines = hunk.old_lines || 0;
|
|
210
|
+
var newLines = hunk.new_lines || 0;
|
|
211
|
+
var oldStart = hunk.old_start || 0;
|
|
212
|
+
var newStart = hunk.new_start || 0;
|
|
213
|
+
var lines = Array.isArray(hunk.lines) ? hunk.lines : [];
|
|
214
|
+
var annotations = Array.isArray(hunk.annotations) ? hunk.annotations : [];
|
|
215
|
+
// Index annotations by new-side line number for fast lookup per row.
|
|
216
|
+
var annByLine = {};
|
|
217
|
+
for (var i = 0; i < annotations.length; i++) {
|
|
218
|
+
var a = annotations[i];
|
|
219
|
+
if (a && typeof a.line === 'number') {
|
|
220
|
+
if (!annByLine[a.line]) annByLine[a.line] = [];
|
|
221
|
+
annByLine[a.line].push(a);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
var rowsHtml = '';
|
|
226
|
+
var oldN = oldStart;
|
|
227
|
+
var newN = newStart;
|
|
228
|
+
for (var j = 0; j < lines.length; j++) {
|
|
229
|
+
var line = lines[j] || {};
|
|
230
|
+
var type = line.type || 'context';
|
|
231
|
+
var text = line.text == null ? '' : line.text;
|
|
232
|
+
var bg, oldCell, newCell, sign;
|
|
233
|
+
if (type === 'add') {
|
|
234
|
+
bg = 'rgba(34,197,94,0.10)';
|
|
235
|
+
oldCell = '';
|
|
236
|
+
newCell = String(newN++);
|
|
237
|
+
sign = '+';
|
|
238
|
+
} else if (type === 'del') {
|
|
239
|
+
bg = 'rgba(239,68,68,0.10)';
|
|
240
|
+
oldCell = String(oldN++);
|
|
241
|
+
newCell = '';
|
|
242
|
+
sign = '-';
|
|
243
|
+
} else {
|
|
244
|
+
bg = 'transparent';
|
|
245
|
+
oldCell = String(oldN++);
|
|
246
|
+
newCell = String(newN++);
|
|
247
|
+
sign = ' ';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
var annHtml = '';
|
|
251
|
+
var anns = annByLine[Number(newCell)] || annByLine[Number(oldCell)] || [];
|
|
252
|
+
for (var k = 0; k < anns.length; k++) annHtml += renderAnnotation(anns[k]);
|
|
253
|
+
|
|
254
|
+
rowsHtml += (
|
|
255
|
+
'<tr style="background:' + bg + ';">'
|
|
256
|
+
+ '<td class="swarm-diff-gutter" style="user-select:none;text-align:right;padding:0 8px;color:#7c8aa6;font-size:12px;width:48px;">' + esc(oldCell) + '</td>'
|
|
257
|
+
+ '<td class="swarm-diff-gutter" style="user-select:none;text-align:right;padding:0 8px;color:#7c8aa6;font-size:12px;width:48px;">' + esc(newCell) + '</td>'
|
|
258
|
+
+ '<td class="swarm-diff-sign" style="user-select:none;text-align:center;padding:0 4px;color:#7c8aa6;font-size:12px;width:18px;">' + esc(sign) + '</td>'
|
|
259
|
+
+ '<td class="swarm-diff-code" style="padding:0 8px;white-space:pre-wrap;word-break:break-word;font-family:\\'Space Mono\\',ui-monospace,monospace;font-size:12px;">' + esc(text) + annHtml + '</td>'
|
|
260
|
+
+ '</tr>'
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
var anchorSlug = slugifyAttr((file || 'hunk') + '-' + (oldStart || hunkIdx + 1));
|
|
265
|
+
var anchorId = 'swarm-diff-' + anchorSlug;
|
|
266
|
+
var header = (
|
|
267
|
+
'@@ -' + oldStart + ',' + oldLines + ' +' + newStart + ',' + newLines + ' @@'
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
'<a id="' + esc(anchorId) + '" class="swarm-diff-anchor" data-hunk="' + esc(anchorSlug) + '"></a>'
|
|
272
|
+
+ '<div class="swarm-diff-hunk-header" style="padding:6px 12px;background:rgba(124,138,166,0.10);color:#7c8aa6;font-family:\\'Space Mono\\',ui-monospace,monospace;font-size:11px;border-top:1px solid var(--swarm-border,#22304a);">'
|
|
273
|
+
+ esc(header)
|
|
274
|
+
+ '</div>'
|
|
275
|
+
+ '<table class="swarm-diff-table" style="width:100%;border-collapse:collapse;table-layout:fixed;">'
|
|
276
|
+
+ '<tbody>' + rowsHtml + '</tbody>'
|
|
277
|
+
+ '</table>'
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderDiff(rootEl, diffData) {
|
|
282
|
+
var hunks = (diffData && Array.isArray(diffData.hunks)) ? diffData.hunks : (Array.isArray(diffData) ? diffData : []);
|
|
283
|
+
var file = rootEl.getAttribute('file') || '';
|
|
284
|
+
var baseSha = rootEl.getAttribute('base-sha') || '';
|
|
285
|
+
var headSha = rootEl.getAttribute('head-sha') || '';
|
|
286
|
+
|
|
287
|
+
var shaLine = '';
|
|
288
|
+
if (baseSha || headSha) {
|
|
289
|
+
shaLine = '<span class="swarm-diff-sha" style="font-family:\\'Space Mono\\',ui-monospace,monospace;font-size:11px;color:#7c8aa6;">'
|
|
290
|
+
+ esc(baseSha) + ' → ' + esc(headSha)
|
|
291
|
+
+ '</span>';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
var hunksHtml = '';
|
|
295
|
+
for (var i = 0; i < hunks.length; i++) {
|
|
296
|
+
hunksHtml += renderHunk(hunks[i] || {}, i, file);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
rootEl.innerHTML = (
|
|
300
|
+
'<div class="swarm-diff-root" style="border:1px solid var(--swarm-border,#22304a);border-radius:8px;background:var(--swarm-card,#121826);overflow:hidden;margin:12px 0;break-inside:avoid;">'
|
|
301
|
+
+ '<div class="swarm-diff-header" style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:rgba(59,130,246,0.10);border-bottom:1px solid var(--swarm-border,#22304a);">'
|
|
302
|
+
+ '<span class="swarm-diff-file" style="font-family:\\'Space Mono\\',ui-monospace,monospace;font-size:13px;font-weight:700;color:var(--swarm-text,#e6eaf2);">' + esc(file || '(untitled)') + '</span>'
|
|
303
|
+
+ shaLine
|
|
304
|
+
+ '</div>'
|
|
305
|
+
+ hunksHtml
|
|
306
|
+
+ '</div>'
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Public function form lives on window.swarmUi so callers can render an
|
|
311
|
+
// arbitrary root element programmatically. The custom element below is just
|
|
312
|
+
// a declarative wrapper around the same render function.
|
|
313
|
+
window.swarmUi = window.swarmUi || {};
|
|
314
|
+
window.swarmUi.renderDiff = renderDiff;
|
|
315
|
+
|
|
316
|
+
// Defer the parse-and-render so the HTML parser has time to finish
|
|
317
|
+
// appending JSON text children. \`connectedCallback\` fires on the opening
|
|
318
|
+
// tag — \`this.textContent\` is empty until children parse. Without a
|
|
319
|
+
// defer, every declarative <swarm-diff> renders an empty header and the
|
|
320
|
+
// JSON text remains visible as orphan children.
|
|
321
|
+
//
|
|
322
|
+
// queueMicrotask alone is NOT enough — Chrome's streaming parser drains
|
|
323
|
+
// microtasks between chunks, so the microtask can run BEFORE the JSON
|
|
324
|
+
// child is appended. We need to wait for the parser to finish the current
|
|
325
|
+
// document load, then read textContent.
|
|
326
|
+
//
|
|
327
|
+
// * \`document.readyState === 'loading'\` ⇒ parser still streaming →
|
|
328
|
+
// wait for DOMContentLoaded (fires after all children are parsed).
|
|
329
|
+
// * otherwise (element was created/inserted dynamically post-load) ⇒
|
|
330
|
+
// queueMicrotask is fine — DOM is stable, just give the caller a tick.
|
|
331
|
+
//
|
|
332
|
+
// Re-entrancy (element moved/reconnected) re-fires connectedCallback so
|
|
333
|
+
// we re-render against current textContent.
|
|
334
|
+
class SwarmDiffElement extends HTMLElement {
|
|
335
|
+
connectedCallback() {
|
|
336
|
+
var self = this;
|
|
337
|
+
var doRender = function() {
|
|
338
|
+
if (!self.isConnected) return;
|
|
339
|
+
var raw = self.textContent || '';
|
|
340
|
+
renderDiff(self, { hunks: parseHunks(raw) });
|
|
341
|
+
// Notify <swarm-diff-jumps> instances so they can pick up new anchors.
|
|
342
|
+
self.dispatchEvent(new CustomEvent('swarm-diff:rendered', { bubbles: true }));
|
|
343
|
+
};
|
|
344
|
+
if (typeof document !== 'undefined' && document.readyState === 'loading') {
|
|
345
|
+
document.addEventListener('DOMContentLoaded', doRender, { once: true });
|
|
346
|
+
} else {
|
|
347
|
+
queueMicrotask(doRender);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
window.customElements.define('swarm-diff', SwarmDiffElement);
|
|
352
|
+
|
|
353
|
+
// Sibling-anchor jump list. Walks subsequent siblings, finds every
|
|
354
|
+
// <swarm-diff data-hunk=...> anchor, and renders a small list of links.
|
|
355
|
+
//
|
|
356
|
+
// Same parse-order hazard as <swarm-diff>: <swarm-diff-jumps> usually
|
|
357
|
+
// appears in the document BEFORE the <swarm-diff> elements it indexes, so
|
|
358
|
+
// we also defer to a microtask AND re-render whenever a <swarm-diff> in the
|
|
359
|
+
// document finishes rendering its anchors.
|
|
360
|
+
class SwarmDiffJumpsElement extends HTMLElement {
|
|
361
|
+
connectedCallback() {
|
|
362
|
+
var self = this;
|
|
363
|
+
var renderJumps = function() {
|
|
364
|
+
var anchors = document.querySelectorAll('.swarm-diff-anchor[data-hunk]');
|
|
365
|
+
if (!anchors.length) {
|
|
366
|
+
self.innerHTML = '<span class="no-print" style="color:#7c8aa6;font-size:12px;">No hunks yet.</span>';
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
var items = '';
|
|
370
|
+
for (var i = 0; i < anchors.length; i++) {
|
|
371
|
+
var a = anchors[i];
|
|
372
|
+
var slug = a.getAttribute('data-hunk') || ('hunk-' + i);
|
|
373
|
+
// Hunk title = nearest preceding diff's file attribute if available.
|
|
374
|
+
var diff = a.closest && a.closest('swarm-diff');
|
|
375
|
+
var file = (diff && diff.getAttribute('file')) || slug;
|
|
376
|
+
items += '<li style="margin:0;padding:2px 0;"><a href="#' + esc(a.id) + '" style="color:#3b82f6;text-decoration:none;font-family:\\'Space Mono\\',ui-monospace,monospace;font-size:12px;">' + esc(file) + '</a></li>';
|
|
377
|
+
}
|
|
378
|
+
self.innerHTML = (
|
|
379
|
+
'<nav class="swarm-diff-jumps no-print" style="padding:8px 12px;border:1px dashed var(--swarm-border,#22304a);border-radius:8px;background:rgba(124,138,166,0.05);">'
|
|
380
|
+
+ '<div style="font-size:10px;text-transform:uppercase;letter-spacing:0.08em;color:#7c8aa6;margin-bottom:4px;">Jump to</div>'
|
|
381
|
+
+ '<ul style="list-style:none;padding:0;margin:0;">' + items + '</ul>'
|
|
382
|
+
+ '</nav>'
|
|
383
|
+
);
|
|
384
|
+
};
|
|
385
|
+
// Wait for the parser to finish initial load before first query —
|
|
386
|
+
// <swarm-diff> elements also defer to DOMContentLoaded so we must
|
|
387
|
+
// run AFTER they finish rendering their anchors. The event listener
|
|
388
|
+
// below handles the live-update case.
|
|
389
|
+
if (typeof document !== 'undefined' && document.readyState === 'loading') {
|
|
390
|
+
document.addEventListener('DOMContentLoaded', function() { queueMicrotask(renderJumps); }, { once: true });
|
|
391
|
+
} else {
|
|
392
|
+
queueMicrotask(renderJumps);
|
|
393
|
+
}
|
|
394
|
+
// Re-render whenever a sibling <swarm-diff> finishes its async render.
|
|
395
|
+
self._onDiffRendered = function() { renderJumps(); };
|
|
396
|
+
document.addEventListener('swarm-diff:rendered', self._onDiffRendered);
|
|
397
|
+
}
|
|
398
|
+
disconnectedCallback() {
|
|
399
|
+
if (this._onDiffRendered) {
|
|
400
|
+
document.removeEventListener('swarm-diff:rendered', this._onDiffRendered);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
window.customElements.define('swarm-diff-jumps', SwarmDiffJumpsElement);
|
|
405
|
+
})();
|
|
406
|
+
`;
|