sidebud 0.2.0 → 0.4.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 +48 -0
- package/dist/main.js +4441 -704
- package/package.json +2 -1
- package/skills/widget-packs/SKILL.md +8 -7
- package/src/manage/index.html +4 -0
- package/src/manage/manage.css +38 -0
- package/src/manage/manage.js +91 -6
- package/src/manage/packs.js +11 -4
- package/src/manage/settings.js +1 -1
- package/src/manage/setup.js +72 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sidebud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Sidebud companion: talk to the agents on your computer from your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sidebud",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"diff": "^8.0.4",
|
|
37
37
|
"qrcode": "^1.5.4",
|
|
38
38
|
"ws": "^8.18.3",
|
|
39
|
+
"yaml": "^2.9.1",
|
|
39
40
|
"zod": "^4.5.4"
|
|
40
41
|
}
|
|
41
42
|
}
|
|
@@ -43,26 +43,26 @@ A pack folder holds:
|
|
|
43
43
|
| `name`, `description`, `version`, `author`, `license` | Shown before install. `version` is semver. |
|
|
44
44
|
| `logo` | `{ "kind": "icon", "name": "tools" }` (agents, mail, calendar, chat, code, github, files, chart, bell, bolt, globe, home, music, note, star, tools), `{ "kind": "emoji", "value": "💾" }`, or `{ "kind": "file", "path": "logo.png", "source": "https://…" }`. For a pack that wraps an app, use the app's own logo: download it from its official site or repository (its app icon or favicon), save it in the pack, and set `source` to where it came from. The file is what is shown; the phone never loads the link. |
|
|
45
45
|
| `tile.size` | `small` (1×1), `medium` (2×1), or `large` (2×2); users can resize. |
|
|
46
|
-
| `mcp` | `{ "transport": "stdio", "command", "args", "env" }` or `{ "transport": "http", "url", "headers" }`. Commands run in the pack folder, so a pack server is `"command": "node", "args": ["server/index.mjs"]`. `args`, `env`, and `headers` may use `{{setting.x}}`, `{{secret.NAME}}`, `{{env.NAME}}`, and `{{oauth.accessToken}}`. |
|
|
46
|
+
| `mcp` | `{ "transport": "stdio", "command", "args", "env" }` or `{ "transport": "http", "url", "headers" }`. Commands run in the pack folder, so a pack server is `"command": "node", "args": ["server/index.mjs"]`. `args`, `env`, and `headers` may use `{{setting.x}}`, `{{secret.NAME}}`, `{{env.NAME}}`, and `{{oauth.accessToken}}`. An http `url` may be a setting plus a path, `"{{setting.baseUrl}}/api/mcp"`, for a server on the user's own network. |
|
|
47
47
|
| `platforms` | A different launch per OS: `{ "win32": { …launch… }, "darwin": …, "linux": … }`. Use it when an app installs to different places, e.g. `{{env.LOCALAPPDATA}}\\Programs\\…` on Windows. |
|
|
48
48
|
| `auth` | OAuth 2.0: `authorizationUrl`, `tokenUrl`, `scopes`, `clientIdSecret`, optional `clientSecretSecret` (both declared in `secrets`), `authorizationParams`. The companion runs consent on this computer, keeps tokens in the keychain, and refreshes them. An http server gets `Authorization: Bearer`; a stdio server must read `{{oauth.accessToken}}` from its `env` and is restarted when the token refreshes. |
|
|
49
49
|
| `secrets` | `[{ "name": "API_KEY", "description": "…" }]`: names only; values live in the keychain. |
|
|
50
|
-
| `permissions.tools` | **Every** tool the pack may call, with `access: read \| write`, optional `description`, and optional `argumentSettings` (`{ "repo": "repo" }` pins that argument to a required setting on every call). Nothing else is ever called. `permissions.network` lists hosts, for review. |
|
|
51
|
-
| `settings` | `text`, `boolean`, or `select` fields with defaults; `required` and `format` (`directory`, `http-url`) for text. |
|
|
50
|
+
| `permissions.tools` | **Every** tool the pack may call, with `access: read \| write`, optional `description`, and optional `argumentSettings` (`{ "repo": "repo" }` pins that argument to a required setting on every call), and `optional: true` for a tool the server offers only in some setups (for example only when the user has that kind of device), so checking the connection does not require it. Nothing else is ever called. `permissions.network` lists hosts, for review. |
|
|
51
|
+
| `settings` | `text`, `boolean`, or `select` fields with defaults; `required` and `format` (`directory`, `http-url`) for text. An `http-url` must use HTTPS unless it points at this computer or the local network (private addresses, Tailscale, `.local`). |
|
|
52
52
|
| `setup` | `steps` (what you or the user do to set it up), `prerequisites` (`[{ "name": "Node.js", "command": "node" }]`), and `detect.anyPath` (paths that exist when the app is installed; `~` and `{{env.NAME}}` allowed), which lets setup suggest the pack. |
|
|
53
53
|
| `skill` | `{ "path": "SKILL.md" }`. |
|
|
54
54
|
| `listing` | For the widget library: `category` (agents, communication, developer, notes, productivity, system), `account` (what the user signs in with or selects), `connectsTo`, optional `homepage`. Packs without one are not listed. |
|
|
55
55
|
| `preview` | Made-up sample data the widget library shows before anyone installs it: `data` is one sample result per query id (the shape its tool returns, after `extract`), optional `settings` for the templates that read them, and `at`, the time the sample was written; times in the sample move forward so "3m ago" stays true. Never real accounts, names, or messages. ≤32 KB. A listed pack without one shows no phone preview in the library. |
|
|
56
|
-
| `data` | `{ "id", "tool", "arguments", "extract", "refreshSeconds" }` (≤10). Only `read` tools; they run automatically, never if the server marks the tool as writing. `extract`: `json` (default; structured content, else JSON text), `text-json`, `text`,
|
|
56
|
+
| `data` | `{ "id", "tool", "arguments", "extract", "refreshSeconds" }` (≤10). Only `read` tools; they run automatically, never if the server marks the tool as writing. `extract`: `json` (default; structured content, else JSON text), `text-json`, `text`, `records` ("Key: value" blocks), or `yaml` (a heading line or two before the YAML is skipped). `document`: a JSON pointer, for a server whose JSON text wraps the payload in a string field (`{"success": true, "result": "<yaml>"}` → `"document": "/result"`); `extract` then applies to that field, and a result without it fails with the server's `error` text. |
|
|
57
57
|
| `glance` | `{ "stats": [≤2 × { "label", "value", "tone" }] }` or `{ "line", "tone" }`. Tones: neutral, info, success, warning, danger. |
|
|
58
58
|
| `badge` | `{ "text", "tone" }`; hidden when it renders empty or `0`. |
|
|
59
|
-
| `blocks` | ≤8: `stats` (≤4 items; three read best), `list` (`items` resolves to an array; `item.title/subtitle/status` use `{{item/…}}`; `item.statusTones` colors rows by status text, and an empty status shows none, so emit a status only when it is news, e.g. "running"; `item.time` is an ISO date-time shown relative, "3m ago" or "in 12m"; `item.key` makes rows tappable when the pack has a `detail`; `limit` ≤20; `title` is the list's heading, needed when there are several lists), `board` (columns of items, a kanban board: `items`, `column` renders each item's column value, `columns` ≤6 of `{ id, title, tone, values }` where `values` lists the column values it collects (default its id), `item.title/subtitle/key/time`, `limit` ≤10 per column; each column shows its count), `text`. A `large` tile previews its first list's top four rows or, with two lists or boards, each one's title and top row (a board shows its column counts); choose `large` when two are worth seeing at a glance. |
|
|
59
|
+
| `blocks` | ≤8: `stats` (≤4 items; three read best), `list` (`items` resolves to an array; `item.title/subtitle/status` use `{{item/…}}`; `item.statusTones` colors rows by status text, and an empty status shows none, so emit a status only when it is news, e.g. "running"; `item.time` is an ISO date-time shown relative, "3m ago" or "in 12m"; `item.key` makes rows tappable when the pack has a `detail`; `item.buttons` (needs `item.key`) puts a small button at the row's end that calls one declared tool for that row's item straight from a tap, with no agent: `{ "id", "label", "tool", "arguments" (with `{{item/…}}`), "when" (the `where` filter's syntax, e.g. `domain=light,state=on`), optional "confirm" }`; a row shows its first button whose `when` matches, and the companion checks `when` again against fresh data before calling, so a stale button does nothing; `limit` ≤20; `title` is the list's heading, needed when there are several lists), `board` (columns of items, a kanban board: `items`, `column` renders each item's column value, `columns` ≤6 of `{ id, title, tone, values }` where `values` lists the column values it collects (default its id), `item.title/subtitle/key/time`, `limit` ≤10 per column; each column shows its count), `text`. A `large` tile previews its first list's top four rows or, with two lists or boards, each one's title and top row (a board shows its column counts); choose `large` when two are worth seeing at a glance. |
|
|
60
60
|
| `subtitle` | Optional live line under the title when the widget is open, e.g. `"{{agents/count}} agents · {{sessions/active}} running"`; the description is shown when absent. |
|
|
61
|
-
| `detail` | What a tapped row opens: a read `tool` with `arguments` (`{{item/…}}` is the row), `messages` (`{{detail/…}}`, the result's message array), and `message.role/text/at`. |
|
|
61
|
+
| `detail` | What a tapped row opens: a read `tool` with `arguments` (`{{item/…}}` is the row; an argument that is exactly one template and finds no value is left out, so one detail can serve rows of different shapes), `extract`/`document` as for `data`, `messages` (`{{detail/…}}`, the result's message array), and `message.role/text/at`. |
|
|
62
62
|
| `notify` | Spoken updates during calls (≤6): `items`, `key`, optional `state`, `category` (agents, messages, mail, calendar, updates), and `rules` (`{ "state": "failed" }` or `{ "new": true }`, `priority` needs_you/failed/finished/update, `text`, optional `withLatest`). The first read is a baseline. |
|
|
63
63
|
| `controls` | ≤12. `prompt` controls send their `prompt` as the user's own words. `tool` controls call one declared tool with fixed `arguments` (optional `forEach` + `maxCalls`), `effect` read or consequential, and `confirm` text for consequential ones. Mark two or three `default: true`. |
|
|
64
64
|
|
|
65
|
-
**Templates** are lookups, never code: `{{setting.path}}`, `{{disks}}` (a query's data), `{{disks/drives/0/free}}` (JSON pointer), `{{item/name}}` (inside lists and `forEach`), with filters `|count`, `|pluck:id`, `|first`, `|join:", "`, `|default:none`, `|truncate:40`,
|
|
65
|
+
**Templates** are lookups, never code: `{{setting.path}}`, `{{disks}}` (a query's data), `{{disks/drives/0/free}}` (JSON pointer), `{{item/name}}` (inside lists and `forEach`), with filters `|count`, `|pluck:id`, `|first`, `|join:", "`, `|default:none`, `|truncate:40`, `|sort:-updatedAt` (comma-separated keys, `-` descending, `field=value` first), `|where:domain=light,state=on` (items where every `field=value` holds; `!=` negates, `;` separates alternatives: `where:domain=lock,state!=locked;domain=cover,state=open`), `|group:area` (`[{ key, count, items }]` per distinct value; items without it are left out), and `|split:", "` (text into a list). A string that is exactly one template yields the raw value, so `"ids": "{{unread/threads|pluck:id}}"` passes an array.
|
|
66
66
|
|
|
67
67
|
## Writing a pack server
|
|
68
68
|
|
|
@@ -192,6 +192,7 @@ Every pack gets a `SKILL.md` with frontmatter (`name`, `description`) and these
|
|
|
192
192
|
- Every consequential control needs `confirm` text that states the exact effect and count ("Archive {{newsletters/threads|count}} threads?").
|
|
193
193
|
- Controls take fixed arguments (plus settings and query data); no free-form inputs.
|
|
194
194
|
- Keep `forEach` bounded with `maxCalls`; prefer a batch tool when there is one.
|
|
195
|
+
- Give row buttons a `when` that admits only items the button is safe for, and a `confirm` for anything hard to undo. Never put a button on something that lets people in (unlocking, opening a garage door or gate, disarming an alarm).
|
|
195
196
|
- Ask for the narrowest scopes and tokens that make the pack work.
|
|
196
197
|
- Secrets and tokens appear only in `mcp` (`env`, `headers`, `args`); validation rejects them anywhere else.
|
|
197
198
|
- A pack that launches a program or ships a server runs code on the user's computer. Say what it runs before the user enables it, and never enable a pack from a bundle or the library that you have not read.
|
package/src/manage/index.html
CHANGED
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 8 4 4-4 4M12 17h7" /></svg
|
|
36
36
|
>Execution</a
|
|
37
37
|
>
|
|
38
|
+
<a href="#decisions"
|
|
39
|
+
><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2.5 4.5 13.5H12L11 21.5l8.5-11H12l1-8Z" /></svg
|
|
40
|
+
>Fast decisions<span class="tag-beta">Beta</span></a
|
|
41
|
+
>
|
|
38
42
|
<a href="#mcp"
|
|
39
43
|
><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="7" height="7" rx="1.5" /><rect x="13.5" y="3.5" width="7" height="7" rx="1.5" /><rect x="3.5" y="13.5" width="7" height="7" rx="1.5" /><path d="M17 14v6M14 17h6" /></svg
|
|
40
44
|
>MCP & widgets</a
|
package/src/manage/manage.css
CHANGED
|
@@ -406,6 +406,26 @@ button.theme-toggle {
|
|
|
406
406
|
background: var(--amber-soft);
|
|
407
407
|
color: var(--amber-text);
|
|
408
408
|
}
|
|
409
|
+
.label-text {
|
|
410
|
+
display: inline-flex;
|
|
411
|
+
flex-wrap: wrap;
|
|
412
|
+
align-items: center;
|
|
413
|
+
gap: 8px;
|
|
414
|
+
}
|
|
415
|
+
.tag-beta {
|
|
416
|
+
display: inline-block;
|
|
417
|
+
margin-left: 8px;
|
|
418
|
+
padding: 2px 7px;
|
|
419
|
+
border-radius: 999px;
|
|
420
|
+
border: 1px solid var(--amber-text);
|
|
421
|
+
color: var(--amber-text);
|
|
422
|
+
font-size: 10px;
|
|
423
|
+
font-weight: 700;
|
|
424
|
+
letter-spacing: 0.06em;
|
|
425
|
+
line-height: 1.4;
|
|
426
|
+
text-transform: uppercase;
|
|
427
|
+
vertical-align: middle;
|
|
428
|
+
}
|
|
409
429
|
.badge.bad {
|
|
410
430
|
background: var(--red-soft);
|
|
411
431
|
color: var(--red);
|
|
@@ -1406,6 +1426,24 @@ details.menu .menu-list a.button:hover {
|
|
|
1406
1426
|
overflow: hidden;
|
|
1407
1427
|
white-space: nowrap;
|
|
1408
1428
|
}
|
|
1429
|
+
.phone-row-button {
|
|
1430
|
+
flex: 0 0 auto;
|
|
1431
|
+
align-self: center;
|
|
1432
|
+
border-radius: 10px;
|
|
1433
|
+
padding: 4px 10px;
|
|
1434
|
+
font-size: 12px;
|
|
1435
|
+
font-weight: 600;
|
|
1436
|
+
line-height: 1.4;
|
|
1437
|
+
background: var(--phone-raised);
|
|
1438
|
+
}
|
|
1439
|
+
/* A row with a button: the text takes the room, the status and button sit at the end. */
|
|
1440
|
+
.phone-list > div:has(> .phone-row-button) > div:first-child {
|
|
1441
|
+
flex: 1 1 auto;
|
|
1442
|
+
min-width: 0;
|
|
1443
|
+
}
|
|
1444
|
+
.phone-list > div:has(> .phone-row-button) {
|
|
1445
|
+
align-items: center;
|
|
1446
|
+
}
|
|
1409
1447
|
.phone-row-end {
|
|
1410
1448
|
flex: 0 0 auto;
|
|
1411
1449
|
white-space: nowrap;
|
package/src/manage/manage.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const SECTIONS = ['setup', 'connection', 'voice', 'execution', 'mcp', 'phone', 'layouts', 'guide', 'settings'];
|
|
1
|
+
const SECTIONS = ['setup', 'connection', 'voice', 'execution', 'decisions', 'mcp', 'phone', 'layouts', 'guide', 'settings'];
|
|
2
2
|
// Links carry the token in the fragment (never sent to the server): `#t=<token>&s=<section>`.
|
|
3
3
|
const linked = new URLSearchParams(location.hash.startsWith('#t=') ? location.hash.slice(1) : '');
|
|
4
4
|
const token = linked.get('t') || sessionStorage.getItem('rvaManageToken');
|
|
@@ -139,6 +139,7 @@ function render() {
|
|
|
139
139
|
connection: connectionView,
|
|
140
140
|
voice: voiceView,
|
|
141
141
|
execution: executionView,
|
|
142
|
+
decisions: decisionsView,
|
|
142
143
|
mcp: () => (packPageId() ? packPageView() : libraryPageSlug() ? libraryPackView() : mcpView()),
|
|
143
144
|
phone: phoneView,
|
|
144
145
|
layouts: layoutsView,
|
|
@@ -194,11 +195,25 @@ function connectionView() {
|
|
|
194
195
|
const connected = new Set(state.connectedDeviceIds || []);
|
|
195
196
|
return `<section class="section"><div><h2>Connect a phone</h2><p class="lede">Pair your phone with ${esc(state.name)}. This page manages the companion on this computer; choose among your computers in the phone app. Rename this computer under <a href="#settings">Settings</a>.</p></div><div class="card stack"><div><h3>Pairing code</h3><p class="hint">On your phone, choose Add computer → Scan pairing code. Each code works once and expires shortly.</p></div>${pairing ? `<img class="pairing-qr" src="${esc(pairing.qr)}" alt="Pairing QR code"><p class="hint">${Date.parse(pairing.expiresAt) <= Date.now() ? 'This code has expired. Generate a new one.' : `Expires ${esc(new Date(pairing.expiresAt).toLocaleString())}.`}</p>` : `<p class="muted">${pairingLoading ? 'Preparing pairing code…' : 'Generate a code to connect this phone.'}</p>`}${pairingError ? `<p role="alert" class="hint error-text">Could not create a pairing code: ${esc(pairingError)} Check this computer’s reachable address in config.json, then try again.</p>` : ''}<div class="actions"><button class="primary" data-action="pair-phone" ${pairingLoading ? 'disabled' : ''}>${pairingLoading ? 'Preparing…' : pairing ? 'Generate a new code' : pairingError ? 'Try again' : 'Generate pairing code'}</button></div></div><div><h3>Paired phones</h3><p class="lede">${active.length ? 'Phones authorized to use this computer.' : 'No phone is paired yet. Scan the code above to connect your first phone.'}</p></div><div class="grid">${active.map((device) => `<div class="card"><div class="row"><div><h3>${esc(device.name)}</h3><p class="hint">${esc(device.platform)} · Paired ${esc(new Date(device.createdAt).toLocaleDateString())}</p></div><span class="badge ${connected.has(device.id) ? 'good' : ''}">${connected.has(device.id) ? 'Connected now' : 'Offline'}</span></div><p class="hint">${device.lastSeenAt ? `Last seen ${esc(new Date(device.lastSeenAt).toLocaleString())}` : 'Has not connected yet'} · Credential expires ${esc(new Date(device.expiresAt).toLocaleDateString())}</p><div class="actions"><button class="danger" data-action="revoke-phone" data-id="${esc(device.id)}">Remove phone</button></div></div>`).join('') || '<div class="card muted">Your paired phones will appear here.</div>'}</div><div class="actions"><button data-action="refresh">Refresh status</button></div></section>`;
|
|
196
197
|
}
|
|
198
|
+
/** Marks a value pinned on this computer (ADR 0014); account and default values carry no badge. */
|
|
199
|
+
function sourceBadge(section, path) {
|
|
200
|
+
return state.settingSources?.[section]?.[path] === 'override' ? '<span class="badge source" title="Kept on this computer when your account’s settings change">This computer</span>' : '';
|
|
201
|
+
}
|
|
202
|
+
/** A field label with its source badge on the same line. */
|
|
203
|
+
function sourceLabel(text, section, ...paths) {
|
|
204
|
+
const badges = paths.map((path) => sourceBadge(section, path)).join('');
|
|
205
|
+
return badges ? `<span class="label-text">${text}${badges}</span>` : text;
|
|
206
|
+
}
|
|
207
|
+
/** A synced voice choice can name a provider whose key is not on this computer; keys never come from the phone. */
|
|
208
|
+
function voiceNeedsKey(providerId) {
|
|
209
|
+
const key = state.voice.keyStates[providerId];
|
|
210
|
+
return Boolean(key && !key.isSet);
|
|
211
|
+
}
|
|
197
212
|
function voiceKeyView(providerId) {
|
|
198
213
|
const provider = state.voice.catalog[providerId];
|
|
199
214
|
if (!provider) return '';
|
|
200
215
|
const key = state.voice.keyStates[providerId];
|
|
201
|
-
return `<div class="card"><div class="row"><div><h3>${esc(provider.label)} API key</h3><p class="hint">${key.fromEnv ? 'Provided by this computer’s environment. Environment keys take priority.' : key.isSet ? 'Stored in this computer’s keychain. Enter a new key to replace it.' : 'Add
|
|
216
|
+
return `<div class="card"><div class="row"><div><h3>${esc(provider.label)} API key</h3><p class="hint">${key.fromEnv ? 'Provided by this computer’s environment. Environment keys take priority.' : key.isSet ? 'Stored in this computer’s keychain. Enter a new key to replace it.' : 'Add this provider’s key here to use it. It stays in this computer’s keychain and is never synced or asked for on the phone.'}</p></div><span class="badge ${key.isSet ? 'good' : 'warn'}">${key.isSet ? 'Configured' : 'Needs a key on this computer'}</span></div><div class="actions"><input data-voice-key="${esc(providerId)}" type="password" autocomplete="new-password" aria-label="${esc(provider.label)} API key" placeholder="Paste a key to set or replace"><button data-action="save-voice-key" data-provider="${esc(providerId)}">Save key</button>${key.isSet && !key.fromEnv ? `<button class="danger" data-action="clear-voice-key" data-provider="${esc(providerId)}">Clear key</button>` : ''}</div></div>`;
|
|
202
217
|
}
|
|
203
218
|
function voiceKeysView(voiceProvider) {
|
|
204
219
|
return state.voice.catalog[voiceProvider]
|
|
@@ -225,6 +240,10 @@ function voiceEndpointFields(provider, voice) {
|
|
|
225
240
|
}
|
|
226
241
|
/** Model Studio shows an API host (`ws-….ap-southeast-1.maas.aliyuncs.com`) or base URL, not a bare ID; both carry the ID and region. */
|
|
227
242
|
const MODEL_STUDIO_HOST = /^(?:[a-z]+:\/\/)?([a-z0-9-]+)\.(ap-southeast-1|cn-beijing)\.maas\.aliyuncs\.com/i;
|
|
243
|
+
/** The spoken command phrase fields, shared by the Voice page and setup; read back by commandPhrases(). */
|
|
244
|
+
function commandPhraseFields(commands) {
|
|
245
|
+
return `<p class="hint">Your phone listens for these during a call, on the phone itself. Muting the mic stops what the assistant hears (it still speaks reports); muting the voice stops it speaking on the phone (replies still arrive as text). Use at least two words each; separate alternatives with commas. Unmute phrases work while muted. Changes apply from the next call after you apply them.</p><label>Mute mic and voice<input id="voice-mute-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(commands.mute.join(', '))}"></label><label>Mute mic only<input id="voice-mute-mic-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(commands.muteMic.join(', '))}"></label><label>Mute voice only<input id="voice-mute-voice-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(commands.muteVoice.join(', '))}"></label><label>Unmute everything<input id="voice-unmute-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(commands.unmute.join(', '))}"></label><label>End the call<input id="voice-end-call-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(commands.endCall.join(', '))}"></label>`;
|
|
246
|
+
}
|
|
228
247
|
/** Same rules as the companion's voiceCommandPhraseSchema, checked here to say what is wrong. */
|
|
229
248
|
function commandPhrases() {
|
|
230
249
|
const read = (id) => $(id).value.split(',').map((phrase) => phrase.toLowerCase().replace(/[^a-z'\s]/g, ' ').replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
@@ -249,12 +268,36 @@ function voiceUsageView(usage) {
|
|
|
249
268
|
const usd = (value) => `$${value < 0.1 ? value.toFixed(3) : value.toFixed(2)}`;
|
|
250
269
|
return `<p><strong>This month ≈ ${esc(usd(usage.estimatedUsd))}</strong> · ${esc(minutes(usage.minutes))} connected, ${esc(minutes(usage.speakingMinutes))} speaking, across ${usage.calls} ${usage.calls === 1 ? 'call' : 'calls'}</p><ul class="hint">${usage.models.map((row) => `<li>${esc(row.providerLabel)} · ${esc(row.model)}: ${esc(minutes(row.minutes))} connected, ${esc(minutes(row.speakingMinutes))} speaking${row.estimatedUsd === null ? ' (no list price)' : ` ≈ ${esc(usd(row.estimatedUsd))} (billed ${row.billing === 'connected' ? 'while connected' : 'for speech'})`}</li>`).join('')}</ul><p class="hint">An estimate from list prices, not a bill. Speaking time counts your voiced audio and the assistant's replies. Check your provider's billing page for actual charges.</p>`;
|
|
251
270
|
}
|
|
271
|
+
/** Its own page while it is in beta: the fast-decision model and its key. */
|
|
272
|
+
function decisionsView() {
|
|
273
|
+
return `<section class="section"><div><h2>Fast decisions <span class="tag-beta">Beta</span></h2><p class="lede">Let simple requests skip the execution agent: a fast decision model answers or acts on this computer in about a second. Anything it is unsure about goes to the execution agent as usual.</p></div>${jevView()}</section>`;
|
|
274
|
+
}
|
|
275
|
+
/** Fast decisions (Jev): the user's own TypeSafe key, or a local server with the same API. */
|
|
276
|
+
function jevView() {
|
|
277
|
+
const jev = state.jev;
|
|
278
|
+
if (!jev) return '';
|
|
279
|
+
const { status, config, key } = jev;
|
|
280
|
+
const running = status.state === 'ready' ? `On · ${status.kind === 'local' ? 'local server' : 'TypeSafe'}` : status.state === 'needs_key' ? 'Off · needs a key' : 'Off';
|
|
281
|
+
const keyHint = key.fromEnv ? 'Provided by this computer’s environment (TYPESAFE_API_KEY).' : key.isSet ? 'Stored in this computer’s keychain.' : 'Use your own key from typesafe.ai. It stays in this computer’s keychain and is only sent to TypeSafe.';
|
|
282
|
+
return `<div class="card stack"><div class="row"><div><h3>Decision model (Jev)</h3><p class="hint">A System One model decides in about a tenth of a second whether the computer can do a request itself: open or quit apps, volume, music, dark mode, lock the screen, reminders, what agents are doing, one integration’s data, and home tile layout. Anything else, or anything it is unsure about, goes to the worker as usual. Without a key or local server, every request goes to the worker.</p></div><span class="badge ${status.state === 'ready' ? 'good' : 'warn'}">${esc(running)}</span></div>
|
|
283
|
+
<div class="check"><input id="jev-on" type="checkbox" ${config.mode === 'auto' ? 'checked' : ''}><label for="jev-on">Use fast decisions when a key or local server is set</label></div>
|
|
284
|
+
<label>Local Jev-compatible server (optional)<input id="jev-url" type="url" placeholder="Empty: TypeSafe’s hosted API" value="${esc(config.url ?? '')}"></label>
|
|
285
|
+
<p class="hint">For a model running on this computer or your network that serves the same <span class="code">POST /v1/systemone</span> API. No key is sent to it.</p>
|
|
286
|
+
<label>Model<input id="jev-model" value="${esc(config.model)}"></label>
|
|
287
|
+
<label>Act only at this confidence or higher (0.5–0.99)<input id="jev-confidence" type="number" min="0.5" max="0.99" step="0.01" value="${config.minConfidence}"></label>
|
|
288
|
+
<label>Give up after (ms)<input id="jev-timeout" type="number" min="200" max="10000" step="100" value="${config.timeoutMs}"></label>
|
|
289
|
+
${jev.computerUse ? `<div class="stack"><h4>Computer use</h4><p class="hint">${jev.computerUse.available ? 'On: for click, scroll, and type requests that no integration covers, Jev moves the cursor, clicks, scrolls, and types on this computer. The app running the companion needs Accessibility access in System Settings.' : 'Off: turn on fast decisions to let Jev drive the screen.'}</p></div>` : ''}
|
|
290
|
+
<div class="actions"><button class="primary" data-action="save-jev">Save fast decisions</button><button data-action="test-jev" ${status.state === 'ready' ? '' : 'disabled'}>Test</button></div>
|
|
291
|
+
<h4>TypeSafe API key</h4><p class="hint">${keyHint}</p>
|
|
292
|
+
<div class="actions"><input id="jev-key" type="password" autocomplete="new-password" aria-label="TypeSafe API key" placeholder="Paste a key to set or replace"><button data-action="save-jev-key">Save key</button>${key.isSet && !key.fromEnv ? '<button class="danger" data-action="clear-jev-key">Clear key</button>' : ''}</div>
|
|
293
|
+
<p class="hint">Changes apply after a restart; you will be prompted.</p></div>`;
|
|
294
|
+
}
|
|
252
295
|
function voiceView() {
|
|
253
296
|
const { voice, catalog } = state.voice;
|
|
254
297
|
const selected = catalog[voice.provider];
|
|
255
298
|
const voices = voiceOptionsFor(selected, voice.model);
|
|
256
299
|
return `<section class="section"><div><h2>Voice agent</h2><p class="lede">The voice agent talks with you in calls and answers typed messages in the chat, as one assistant. It hands all computer work to the execution agent below and cannot run local tools itself.</p></div>
|
|
257
|
-
<div class="card stack"><h3>Voice agent model</h3><p class="hint">Used for calls and typed chat. Qwen and OpenAI Realtime reply to typed messages in text; the others speak, and their transcript is the reply (billed as audio). GPT-Live cannot read typed messages, so they go straight to the execution agent. With voice off, typed messages get simple local replies.</p
|
|
300
|
+
<div class="card stack"><h3>Voice agent model</h3><p class="hint">Used for calls and typed chat. Qwen and OpenAI Realtime reply to typed messages in text; the others speak, and their transcript is the reply (billed as audio). GPT-Live cannot read typed messages, so they go straight to the execution agent. With voice off, typed messages get simple local replies.</p>${voiceNeedsKey(voice.provider) ? `<p class="hint error-text" role="status">${esc(selected.label)} needs a key on this computer. Add it under the API key below.</p>` : ''}<label>${sourceLabel('Voice provider', 'voice', 'provider')}<select id="voice-provider"><option value="none" ${voice.provider === 'none' ? 'selected' : ''}>Off</option>${Object.values(
|
|
258
301
|
catalog
|
|
259
302
|
)
|
|
260
303
|
.map(
|
|
@@ -263,7 +306,7 @@ function voiceView() {
|
|
|
263
306
|
)
|
|
264
307
|
.join(
|
|
265
308
|
''
|
|
266
|
-
)}<option value="echo" ${voice.provider === 'echo' ? 'selected' : ''}>Echo (audio test)</option></select></label><label
|
|
309
|
+
)}<option value="echo" ${voice.provider === 'echo' ? 'selected' : ''}>Echo (audio test)</option></select></label><label>${sourceLabel('Voice model', 'voice', 'model')}<select id="voice-model" ${selected ? '' : 'disabled'}>${voiceModelOptions(selected, voice.model)}</select></label><p id="voice-untested" class="hint" ${selected?.untested ? '' : 'hidden'}>Untested: this provider has not been run against the real service yet, so expect rough edges.</p><p class="hint">Prices are estimates from list prices. Most models charge only for speech, so a quiet or muted call costs little; models priced "while connected" charge for every minute the call is open, muted or not.</p><div id="voice-endpoint" class="stack">${voiceEndpointFields(selected, voice)}</div><label>${sourceLabel('Voice name', 'voice', 'voiceName')}<select id="voice-name">${voiceNameOptions(voices, voice.voiceName)}</select></label><div class="voice-preview"><button type="button" data-action="preview-voice" ${selected ? '' : 'disabled'}>Play voice sample</button><button type="button" data-action="stop-voice-preview" hidden>Stop sample</button><audio id="voice-preview-audio" controls preload="none" hidden></audio></div><p id="voice-preview-status" class="hint" role="status" aria-live="polite">${selected ? `Sample phrase: “Hi, I'm your assistant. What would you like to work on today?”` : 'Choose a live voice provider to hear a sample.'}</p><label>${sourceLabel('Maximum call length (minutes)', 'voice', 'maxSessionMinutes')}<input id="voice-minutes" type="number" min="1" max="60" value="${voice.maxSessionMinutes}"></label><div class="stack"><h4>${sourceLabel('Spoken mute and unmute', 'voice', 'commands')}</h4>${commandPhraseFields(voice.commands)}<label>${sourceLabel('Auto-mute the mic after this many quiet seconds', 'voice', 'autoMute', 'autoMute.seconds')}<input id="voice-auto-mute" type="number" min="5" max="300" value="${voice.autoMute.seconds}"></label><div class="check"><input id="voice-auto-mute-on" type="checkbox" ${voice.autoMute.enabled ? 'checked' : ''}><label for="voice-auto-mute-on">Start calls with auto-mute on</label></div><p class="hint">Quiet means neither you nor the assistant is speaking. The call screen has an auto-mute toggle to change it during a call. Ending the call stops voice only; messages and task updates keep arriving in the app. Auto-mute works when the phone can listen for your unmute phrase.</p></div><div id="voice-search-row" class="check" ${voiceSearchSupported(selected) ? '' : 'hidden'}><input id="voice-search" type="checkbox" ${voice.webSearch ? 'checked' : ''}><label for="voice-search">${sourceLabel('Allow web search in voice calls', 'voice', 'webSearch')}</label></div><p id="voice-search-note" class="hint" ${selected && !voiceSearchSupported(selected) ? '' : 'hidden'}>This provider's built-in web search is not available in this companion.</p><div class="actions"><button class="primary" data-action="save-voice">Save voice settings</button></div><p class="hint">Provider and model changes apply after a restart; you will be prompted.</p></div><div class="card stack"><h3>Voice usage</h3>${voiceUsageView(state.voice.usage)}</div><div id="voice-key-panel" class="stack">${voiceKeysView(voice.provider)}</div></section>`;
|
|
267
310
|
}
|
|
268
311
|
function previewStatus(message, error = false) {
|
|
269
312
|
const status = $('#voice-preview-status');
|
|
@@ -481,6 +524,15 @@ function updateModelChoices(selectedModel = '') {
|
|
|
481
524
|
const interval = (seconds) => (seconds < 60 ? `${seconds} sec` : seconds >= 3600 && seconds % 3600 === 0 ? `${seconds / 3600} hr` : `${Math.round(seconds / 60)} min`);
|
|
482
525
|
|
|
483
526
|
/** `embedded` drops the page heading when setup shows these controls under its own. */
|
|
527
|
+
/** The user's default agents: the agents app with their main work agents, separate from the execution agent. */
|
|
528
|
+
function defaultAgentsView() {
|
|
529
|
+
const apps = (state.packs?.packs || []).filter(pack => pack.agentsApp && pack.enabled);
|
|
530
|
+
const chosen = state.packs?.defaultAgents || '';
|
|
531
|
+
const hint = apps.length
|
|
532
|
+
? '“What agents are running?” checks this app’s threads. It is not the execution agent above, which takes requests from voice and does the work.'
|
|
533
|
+
: 'Install and turn on an agents app (for example T3 Agents, Hermes, or OpenClaw) under MCP & widgets to choose one.';
|
|
534
|
+
return `<div class="card stack"><h3>Default agents</h3><p class="hint">${hint}</p>${apps.length ? `<label>Your main work agents<select id="default-agents"><option value="" ${chosen ? '' : 'selected'}>${apps.length === 1 ? 'Automatic (the only agents app)' : 'Not chosen: the execution agent checks'}</option>${apps.map(app => `<option value="${esc(app.id)}" ${app.id === chosen ? 'selected' : ''}>${esc(app.name)}</option>`).join('')}</select></label><div class="actions"><button data-action="save-default-agents">Save default agents</button></div>` : ''}</div>`;
|
|
535
|
+
}
|
|
484
536
|
function executionView({ embedded = false } = {}) {
|
|
485
537
|
const providers = state.providers;
|
|
486
538
|
const executors = state.executors || [];
|
|
@@ -506,7 +558,7 @@ function executionView({ embedded = false } = {}) {
|
|
|
506
558
|
const promptFor = executor => executor.connector === 'direct' ? state.profilePrompts?.[executor.id.slice('direct:'.length)] : undefined;
|
|
507
559
|
const agentRows = group.agents.map(executor => `<div class="agent-choice ${executor.isDefault ? 'chosen' : ''}"><div><strong>${esc(executor.label)}</strong><p class="hint">${esc(executor.detail || 'Uses the provider’s default model')}${promptFor(executor) ? ' · Custom system prompt' : ''}</p></div><div class="agent-actions">${executor.connector === 'direct' ? `<button data-action="edit-profile" data-id="${esc(executor.id.slice('direct:'.length))}" aria-label="Edit ${esc(executor.label)} model, thinking level, permissions, and system prompt">Edit</button>` : ''}${executor.isDefault ? '<span class="badge good">Default</span>' : `<button data-action="set-default-executor" data-id="${esc(executor.id)}" aria-label="Use ${esc(executor.label)} by default">Use by default</button>`}</div></div>`).join('');
|
|
508
560
|
return `<section class="section execution">${embedded ? '<p class="lede">Choose the agent on this computer that does the work you ask for. Voice hands requests to it.</p>' : '<div><h2>Who does the work?</h2><p class="lede">Choose the agent that handles tasks you send from your phone.</p></div>'}
|
|
509
|
-
<div class="default-agent"><div><small>YOUR DEFAULT AGENT</small><h3>${esc(selected?.label || 'Choose an agent below')}</h3><p>${esc(selected?.detail || 'Add an agent, then choose it as your default.')}</p></div><span class="badge">${selected ? 'Used unless you name another agent' : 'Not set up yet'}</span></div>
|
|
561
|
+
<div class="default-agent"><div><small>YOUR DEFAULT AGENT</small><h3>${sourceLabel(esc(selected?.label || 'Choose an agent below'), 'agents', 'defaultProfileId')}</h3><p>${esc(selected?.detail || 'Add an agent, then choose it as your default.')}</p></div><span class="badge">${selected ? 'Used unless you name another agent' : 'Not set up yet'}</span></div>
|
|
510
562
|
<div class="row"><h3>Choose a provider</h3><button data-action="refresh-executors">Check availability</button></div>
|
|
511
563
|
<div class="provider-tabs" role="tablist" aria-label="Execution providers">${groups.map(candidate => `<button role="tab" aria-selected="${candidate.id === group.id}" class="provider-tab ${candidate.id === group.id ? 'selected' : ''}" data-action="select-execution-provider" data-id="${esc(candidate.id)}"><strong>${esc(candidate.label)}</strong><span>${candidate.agents.length ? `${candidate.agents.length} agent${candidate.agents.length === 1 ? '' : 's'}` : 'Set up'}</span></button>`).join('')}</div>
|
|
512
564
|
<div class="card stack" role="tabpanel" aria-label="${esc(group.label)} agents"><div class="row"><div><h3>${esc(group.label)}</h3><p class="hint">Agents and models running on this computer.</p></div></div>
|
|
@@ -525,6 +577,7 @@ function executionView({ embedded = false } = {}) {
|
|
|
525
577
|
${instances.length ? `<details><summary>Manage ${esc(group.label)} installation and agents</summary><div class="stack">${instances.map(instance => `<div class="row"><strong>${esc(instance.displayName)}</strong><div class="actions"><button data-action="edit-provider" data-id="${esc(instance.instanceId)}">Settings</button><button data-action="toggle-provider" data-id="${esc(instance.instanceId)}" data-driver="${esc(instance.driver)}" data-enabled="${instance.enabled}">${instance.enabled ? 'Disable' : 'Enable'}</button><button class="danger" data-action="remove-provider" data-id="${esc(instance.instanceId)}">Remove installation</button></div></div>`).join('')}${profiles.map(profile => `<div class="row"><span>${esc(profile.name)}${profile.available ? '' : ' · unavailable'}</span><button class="danger" data-action="remove-profile" data-id="${esc(profile.id)}">Remove agent</button></div>`).join('')}</div></details>` : ''}`}
|
|
526
578
|
</div><div id="profile-editor"></div><div id="provider-editor"></div>
|
|
527
579
|
${providers ? `<div class="card stack"><h3>Where local agents work</h3><p class="hint">${providers.folderRoots?.length ? `Any folder inside ${providers.folderRoots.map(root => `<span class="code">${esc(root)}</span>`).join(', ')}. Ask for a project by name or path; the companion lists the git projects it finds there.` : 'Only the named folders below.'} Set <span class="code">providers.folderRoots</span> in config.json to change this.</p>${providers.workspaces.length ? `<details><summary>Named folders · ${providers.workspaces.length}</summary>${providers.workspaces.map(workspace => `<p><strong>${esc(workspace.name)}</strong><br><span class="code">${esc(workspace.path)}</span></p>`).join('')}</details>` : ''}</div>` : ''}
|
|
580
|
+
${defaultAgentsView()}
|
|
528
581
|
</section>`;
|
|
529
582
|
}
|
|
530
583
|
const RUNTIME_MODES = [['approval-required', 'Ask me first'], ['read-only', 'Read only'], ['auto-accept-edits', 'Accept edits automatically'], ['auto', 'Automatic'], ['full-access', 'Full access']];
|
|
@@ -567,7 +620,7 @@ function packStatusDetail(pack) {
|
|
|
567
620
|
}
|
|
568
621
|
function catalogView() {
|
|
569
622
|
const intro = `<div><h3>Widget library</h3><p class="hint">Widget packs from the Sidebud widget library. Install one and this computer's agent sets it up: it finds settings and keys already on this computer and reports what needs you. Every new install starts disabled until setup enables it. Your agent can also build new packs, or change any installed one, when you ask.</p>${state.libraryError ? `<p class="hint">The library could not be reached: ${esc(state.libraryError)}</p>` : ''}</div>`;
|
|
570
|
-
return `${intro}<div class="grid">${state.integrationCatalog.map(entry => `<article class="card stack"><div class="row"><h3><a href="#mcp/library/${encodeURIComponent(entry.id)}">${esc(entry.name)}</a></h3><span class="badge ${entry.publisher.source === 'community' ? 'warn' : ''}">${entry.installed ? 'Installed' : entry.detected ? 'App found' : entry.publisher.source === 'community' ? 'Community' : esc(entry.publisher.name)}</span></div><p>${esc(entry.description)}</p><p class="hint">${esc(entry.account)} · ${esc(entry.connectsTo)}</p>${entry.tools.length ? `<details><summary>Access</summary><p class="hint">${entry.tools.map(tool => `${code(tool.name)} (${esc(tool.access)})`).join(', ')}</p></details>` : ''}<a class="hint" href="#mcp/library/${encodeURIComponent(entry.id)}">What it can do and how it looks →</a>${entry.publisher.source === 'community' && !entry.installed ? `<a class="button" href="#mcp/library/${encodeURIComponent(entry.id)}">Review and install</a>` : entry.updateAvailable ? `<button data-action="install-library-pack" data-id="${esc(entry.id)}" data-replace="true" aria-label="Update ${esc(entry.name)} from version ${esc(entry.installedVersion)} to ${esc(entry.version)}">Update to ${esc(entry.version)}</button>` : `<button data-action="install-library-pack" data-id="${esc(entry.id)}" ${entry.installed ? 'disabled' : ''} ${entry.needsReinstall ? `title="The installed copy no longer loads. Reinstalling keeps its settings and keys."` : ''}>${entry.installed ? `Installed · ${esc(entry.installedVersion)}` : entry.needsReinstall ? `Reinstall ${esc(entry.name)}` : `Install ${esc(entry.name)}`}</button>`}</article>`).join('')}</div>`;
|
|
623
|
+
return `${intro}<div class="grid">${state.integrationCatalog.map(entry => `<article class="card stack"><div class="row"><h3><a href="#mcp/library/${encodeURIComponent(entry.id)}">${esc(entry.name)}</a></h3><span class="badge ${entry.publisher.source === 'community' && !entry.publisher.official ? 'warn' : ''}">${entry.installed ? 'Installed' : entry.detected ? 'App found' : entry.publisher.official ? `Official · ${esc(entry.publisher.official.domain)}` : entry.publisher.source === 'community' ? 'Community' : esc(entry.publisher.name)}</span></div><p>${esc(entry.description)}</p><p class="hint">${esc(entry.account)} · ${esc(entry.connectsTo)}</p>${entry.tools.length ? `<details><summary>Access</summary><p class="hint">${entry.tools.map(tool => `${code(tool.name)} (${esc(tool.access)})`).join(', ')}</p></details>` : ''}<a class="hint" href="#mcp/library/${encodeURIComponent(entry.id)}">What it can do and how it looks →</a>${entry.publisher.source === 'community' && !entry.installed ? `<a class="button" href="#mcp/library/${encodeURIComponent(entry.id)}">Review and install</a>` : entry.updateAvailable ? `<button data-action="install-library-pack" data-id="${esc(entry.id)}" data-replace="true" aria-label="Update ${esc(entry.name)} from version ${esc(entry.installedVersion)} to ${esc(entry.version)}">Update to ${esc(entry.version)}</button>` : `<button data-action="install-library-pack" data-id="${esc(entry.id)}" ${entry.installed ? 'disabled' : ''} ${entry.needsReinstall ? `title="The installed copy no longer loads. Reinstalling keeps its settings and keys."` : ''}>${entry.installed ? `Installed · ${esc(entry.installedVersion)}` : entry.needsReinstall ? `Reinstall ${esc(entry.name)}` : `Install ${esc(entry.name)}`}</button>`}</article>`).join('')}</div>`;
|
|
571
624
|
}
|
|
572
625
|
function mcpView() {
|
|
573
626
|
return `<section class="section"><div><h2>Integrations & widgets</h2><p class="lede">Connect tools to Sidebud and choose which ones appear on your phone. Credentials and connections stay on this computer. New here? Start with the <a href="#guide">Guide</a>.</p></div><h3>Installed integrations</h3><div class="grid">${state.packs.packs.map(p => `<div class="card"><div class="row"><h3><a href="#mcp/${encodeURIComponent(p.id)}">${esc(p.name)}</a></h3><span class="badge ${{ ok: 'good', needs_setup: 'warn', error: 'bad' }[p.status.state] || ''}">${esc({ ok: 'Enabled', needs_setup: 'Needs setup', error: 'Error', disabled: 'Disabled' }[p.status.state] || p.status.state)}</span></div><p class="hint">${esc(p.description)}</p><p class="hint">${esc(packStatusDetail(p))}</p>${state.packAuth[p.id] ? `<p class="hint">Account: ${esc(accountLabel(state.packAuth[p.id]))}</p>` : ''}${state.connectionErrors[p.id] ? `<p class="hint">Connection failed: ${esc(state.connectionErrors[p.id])}</p>` : ''}${p.refresh ? `<label class="hint">Refresh every <select data-refresh-pack="${esc(p.id)}" aria-label="Refresh ${esc(p.name)} every">${[...new Set([...p.refresh.options, p.refresh.defaultSeconds, p.refresh.seconds])].sort((a, b) => a - b).map((seconds) => `<option value="${seconds}" ${seconds === p.refresh.seconds ? 'selected' : ''}>${esc(interval(seconds))}${seconds === p.refresh.defaultSeconds ? ' (default)' : ''}</option>`).join('')}</select></label>` : ''}${p.announce ? `<p class="hint">Spoken updates: ${p.announce.on ? 'on' : 'off'}${p.announce.custom ? '' : ' (default)'} · <button class="link" data-action="toggle-announce" data-id="${esc(p.id)}" data-on="${p.announce.on}">${p.announce.on ? 'Turn off' : 'Turn on'}</button>${p.announce.custom ? ` · <button class="link" data-action="default-announce" data-id="${esc(p.id)}">Use default</button>` : ''}</p>` : ''}<div class="actions pack-actions"><button class="primary" data-action="setup-pack" data-id="${esc(p.id)}">${p.status.state === 'ok' ? 'Check setup with my agent' : 'Set up with my agent'}</button><button data-action="share-pack" disabled title="Publishing to your Sidebud account arrives with accounts">Share</button><details class="menu"><summary aria-label="More actions for ${esc(p.name)}">…</summary><div class="menu-list" role="menu"><button role="menuitem" data-action="toggle-pack" data-id="${esc(p.id)}" data-enabled="${p.enabled}">${p.enabled ? 'Disable' : 'Enable'}</button><a role="menuitem" class="button" href="#mcp/${encodeURIComponent(p.id)}">Settings & permissions</a><button role="menuitem" data-action="check-pack" data-id="${esc(p.id)}" ${p.enabled ? '' : 'disabled'}>Check connection</button><button role="menuitem" data-action="customize-pack" data-id="${esc(p.id)}">Customize a copy</button><button role="menuitem" data-action="export-pack" data-id="${esc(p.id)}">Export</button><button role="menuitem" data-action="remove-pack" data-id="${esc(p.id)}">Remove</button></div></details></div></div>`).join('') || '<div class="card muted">No integrations installed. Choose one from the widget library below, or ask your agent to build one.</div>'}</div><div id="pack-editor"></div>${catalogView()}<div class="card"><h3>Import a widget pack</h3><p class="hint">Choose a pack file exported from Sidebud or downloaded from the widget library. It installs disabled, then this computer's agent sets it up. Pack files hold the manifest, logo, skill, and any server files, never settings, keys, or account tokens.</p><div class="actions"><input id="pack-import" type="file" accept=".json,application/json" aria-label="Widget pack export file"><button data-action="import-pack">Import</button></div></div><div class="card"><h3>Install your own widget pack</h3><p class="hint">Provide the absolute folder containing pack.json. Existing installations are never overwritten.</p><div class="actions"><input id="pack-folder" aria-label="Widget pack folder" placeholder="/path/to/widget-pack"><button data-action="install-pack">Validate & install</button></div></div><div class="card"><h3>Build or change a pack</h3><p class="hint">Ask your agent: "make a widget that shows …" or "change my Gmail widget to …". It follows the widget-packs skill, builds or edits the pack, and asks before turning it on. See how it will look under <a href="#phone">Phone preview</a>, and how it all works in the <a href="#guide">Guide</a>.</p><p class="hint">Community submissions and reviewed release distribution are planned. Local packs can be installed now.</p></div></section>`;
|
|
@@ -777,6 +830,32 @@ document.addEventListener('click', async (event) => {
|
|
|
777
830
|
await reload();
|
|
778
831
|
return;
|
|
779
832
|
}
|
|
833
|
+
if (action === 'save-jev') {
|
|
834
|
+
const url = $('#jev-url').value.trim();
|
|
835
|
+
await api('jev', {
|
|
836
|
+
jev: {
|
|
837
|
+
mode: $('#jev-on').checked ? 'auto' : 'off',
|
|
838
|
+
url: url || null,
|
|
839
|
+
model: $('#jev-model').value.trim() || 'jev-latest',
|
|
840
|
+
minConfidence: Number($('#jev-confidence').value),
|
|
841
|
+
timeoutMs: Number($('#jev-timeout').value),
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
notice('Fast decision settings saved. Restart to apply them.');
|
|
845
|
+
await reload();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (action === 'save-jev-key' || action === 'clear-jev-key') {
|
|
849
|
+
await api('jev-key', { value: action === 'clear-jev-key' ? '' : $('#jev-key').value });
|
|
850
|
+
notice('Key updated in this computer’s keychain. Restart to apply it.');
|
|
851
|
+
await reload();
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (action === 'test-jev') {
|
|
855
|
+
const result = await api('jev/test', {});
|
|
856
|
+
notice(`Fast decisions work: ${result.model} answered “${result.choice}” in ${result.ms} ms.`);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
780
859
|
if (action === 'save-voice-key' || action === 'clear-voice-key') {
|
|
781
860
|
await api('voice-key', {
|
|
782
861
|
provider: button.dataset.provider,
|
|
@@ -791,6 +870,12 @@ document.addEventListener('click', async (event) => {
|
|
|
791
870
|
await reload();
|
|
792
871
|
return;
|
|
793
872
|
}
|
|
873
|
+
if (action === 'save-default-agents') {
|
|
874
|
+
await api('default-agents', { packId: $('#default-agents').value || null });
|
|
875
|
+
notice('Default agents saved.');
|
|
876
|
+
await reload();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
794
879
|
if (action === 'set-default-executor') {
|
|
795
880
|
await api('executor', { executorId: id });
|
|
796
881
|
notice('Default executor updated.');
|
package/src/manage/packs.js
CHANGED
|
@@ -33,8 +33,15 @@ const accessPill = (access) => `<span class="pill ${access === 'write' ? 'warn'
|
|
|
33
33
|
|
|
34
34
|
/** Who stands behind a pack, and what installing any pack means. Community packs get a warning. */
|
|
35
35
|
function trustNoticeHtml(publisher) {
|
|
36
|
-
const
|
|
37
|
-
|
|
36
|
+
const official = publisher?.official;
|
|
37
|
+
const community = publisher?.source === 'community' && !official;
|
|
38
|
+
const title = official ? `Official widget from ${esc(publisher.name)} (${esc(official.domain)})` : community ? `Community widget by ${esc(publisher.name)}` : 'Made by Sidebud';
|
|
39
|
+
const body = official
|
|
40
|
+
? `Published by a verified ${esc(official.domain)} account, and reviewed by Sidebud before it was listed.`
|
|
41
|
+
: community
|
|
42
|
+
? 'Sidebud reviewed this release before listing it, but did not make it and may not support it. It may stop working or behave differently than described. Install it only if you trust its author, and read what it can do first.'
|
|
43
|
+
: 'The Sidebud team builds and maintains this widget and checks it before it is listed.';
|
|
44
|
+
return `<div class="card trust-notice ${community ? 'community' : ''}" role="${community ? 'alert' : 'note'}"><strong>${title}</strong><p>${body}</p><p class="hint">Every widget runs on this computer with the access listed here. It installs switched off, and your agent sets it up; you choose when to turn it on, and you can turn off any tool.</p></div>`;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
/** The library pack's page: what it can do, its trust, and its widget on a phone, before anything is installed. */
|
|
@@ -53,7 +60,7 @@ function libraryPackView() {
|
|
|
53
60
|
const action = installed
|
|
54
61
|
? `<a class="button" href="#mcp/${encodeURIComponent(page.id)}">Open its page · installed ${esc(page.installedVersion)}</a>`
|
|
55
62
|
: `${community ? `<label class="confirm-line"><input type="checkbox" data-library-confirm ${libraryConfirmed ? 'checked' : ''}> I trust ${esc(page.publisher.name)} and have read what this widget can do</label>` : ''}<button class="primary" data-action="install-library-pack" data-id="${esc(page.slug)}" ${community && !libraryConfirmed ? 'disabled' : ''}>${page.needsReinstall ? 'Reinstall' : 'Install'} ${esc(page.name)}</button>`;
|
|
56
|
-
return `<section class="section pack-page"><a href="#mcp">← Integrations</a><div class="pack-page-head"><div><h2>${esc(page.name)}</h2><p class="hint">${esc(page.author)} · ${esc(page.license)} · version ${esc(page.version)}${page.homepage ? ` · <a href="${esc(page.homepage)}" target="_blank" rel="noreferrer">Homepage</a>` : ''}</p><p>${esc(page.description)}</p></div><span class="badge ${community ? 'warn' : ''}">${community ? 'Community' : 'Sidebud'}</span></div>${trustNoticeHtml(page.publisher)}<div class="actions library-install">${action}</div><div class="pack-page-grid"><div class="stack"><div class="card stack"><h3>What it can do on this computer</h3>${tools}</div><div class="card stack"><h3>What it runs</h3>${runs}</div><div class="card stack"><h3>Keys and accounts</h3>${keys}</div><div class="card stack"><h3>Network</h3><p class="hint">${esc(a.network.join(', ') || 'None declared beyond what it runs.')}</p></div>${a.setup.steps.length ? `<div class="card stack"><h3>Setting it up</h3><ol class="plain-steps">${a.setup.steps.map((step) => `<li>${esc(step)}</li>`).join('')}</ol>${a.setup.prerequisites.length ? `<p class="hint">Needs: ${a.setup.prerequisites.map(esc).join(', ')}</p>` : ''}</div>` : ''}</div><aside class="pack-preview"><h3>On your phone</h3><p class="hint">${page.hasPreview ? 'Made-up sample data from the pack. Tap the tile to open it.' : 'This pack has no sample data, so its widget is drawn empty.'}</p><div id="library-preview">${libraryPreviewBody(page)}</div></aside></div></section>`;
|
|
63
|
+
return `<section class="section pack-page"><a href="#mcp">← Integrations</a><div class="pack-page-head"><div><h2>${esc(page.name)}</h2><p class="hint">${esc(page.author)} · ${esc(page.license)} · version ${esc(page.version)}${page.homepage ? ` · <a href="${esc(page.homepage)}" target="_blank" rel="noreferrer">Homepage</a>` : ''}</p><p>${esc(page.description)}</p></div><span class="badge ${community && !page.publisher.official ? 'warn' : ''}">${page.publisher.official ? `Official · ${esc(page.publisher.official.domain)}` : community ? 'Community' : 'Sidebud'}</span></div>${trustNoticeHtml(page.publisher)}<div class="actions library-install">${action}</div><div class="pack-page-grid"><div class="stack"><div class="card stack"><h3>What it can do on this computer</h3>${tools}</div><div class="card stack"><h3>What it runs</h3>${runs}</div><div class="card stack"><h3>Keys and accounts</h3>${keys}</div><div class="card stack"><h3>Network</h3><p class="hint">${esc(a.network.join(', ') || 'None declared beyond what it runs.')}</p></div>${a.setup.steps.length ? `<div class="card stack"><h3>Setting it up</h3><ol class="plain-steps">${a.setup.steps.map((step) => `<li>${esc(step)}</li>`).join('')}</ol>${a.setup.prerequisites.length ? `<p class="hint">Needs: ${a.setup.prerequisites.map(esc).join(', ')}</p>` : ''}</div>` : ''}</div><aside class="pack-preview"><h3>On your phone</h3><p class="hint">${page.hasPreview ? 'Made-up sample data from the pack. Tap the tile to open it.' : 'This pack has no sample data, so its widget is drawn empty.'}</p><div id="library-preview">${libraryPreviewBody(page)}</div></aside></div></section>`;
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
function libraryPreviewBody(page) {
|
|
@@ -137,7 +144,7 @@ function detailHtml(tile, { back = false } = {}) {
|
|
|
137
144
|
if (!tile) return '';
|
|
138
145
|
const blocks = tile.blocks.map((block) => {
|
|
139
146
|
if (block.type === 'stats') return `<div class="phone-stats">${block.items.map((item) => `<div><strong class="${item.tone === 'neutral' ? '' : tone(item.tone)}">${esc(item.value)}</strong><span class="muted">${esc(item.label)}</span></div>`).join('')}</div>`;
|
|
140
|
-
if (block.type === 'list') return `<div class="phone-list">${block.title ? `<span class="muted phone-list-title">${esc(block.title)}</span>` : ''}${block.items.slice(0, 6).map((item) => `<div><div><strong>${esc(item.title)}</strong>${item.subtitle ? `<span class="muted">${esc(item.subtitle)}</span>` : ''}</div><div class="phone-row-end">${item.status ? `<span class="${tone(item.status.tone)}">${esc(item.status.text)}</span>` : ''}${item.time ? `<span class="muted">${esc(relativeTime(item.time))}</span>` : ''}</div
|
|
147
|
+
if (block.type === 'list') return `<div class="phone-list">${block.title ? `<span class="muted phone-list-title">${esc(block.title)}</span>` : ''}${block.items.slice(0, 6).map((item) => `<div><div><strong>${esc(item.title)}</strong>${item.subtitle ? `<span class="muted">${esc(item.subtitle)}</span>` : ''}</div><div class="phone-row-end">${item.status ? `<span class="${tone(item.status.tone)}">${esc(item.status.text)}</span>` : ''}${item.time ? `<span class="muted">${esc(relativeTime(item.time))}</span>` : ''}</div>${item.button ? `<span class="phone-row-button" title="A tap runs it on the phone">${esc(item.button.label)}</span>` : ''}</div>`).join('') || `<p class="muted">${esc(block.emptyText || 'Nothing here')}</p>`}${block.items.length > 6 ? `<span class="phone-more">Show ${block.items.length - 6} more</span>` : ''}</div>`;
|
|
141
148
|
if (block.type === 'board') return `<div class="phone-board">${block.title ? `<span class="muted phone-list-title">${esc(block.title)}</span>` : ''}<div class="phone-columns">${block.columns.map((column) => `<div class="phone-column"><div class="phone-column-head"><span class="phone-dot bg-${esc(column.tone)}"></span><strong>${esc(column.title)}</strong><span class="muted">${column.count}</span></div>${column.items.map((item) => `<div class="phone-card"><span>${esc(item.title)}</span>${item.subtitle || item.time ? `<span class="muted">${esc(item.subtitle || '')}${item.subtitle && item.time ? ' · ' : ''}${item.time ? esc(relativeTime(item.time)) : ''}</span>` : ''}</div>`).join('') || '<span class="muted">Nothing here</span>'}${column.count > column.items.length ? `<span class="muted">+${column.count - column.items.length} more</span>` : ''}</div>`).join('')}</div></div>`;
|
|
142
149
|
if (block.type === 'text') return `<p class="phone-text ${block.tone === 'neutral' ? '' : tone(block.tone)}">${esc(block.text)}</p>`;
|
|
143
150
|
if (block.type === 'progress') return `<div class="phone-progress"><span>${esc(block.label)}</span><span class="phone-bar"><span data-progress="${Number(block.value) || 0}"></span></span></div>`;
|
package/src/manage/settings.js
CHANGED
|
@@ -53,7 +53,7 @@ function accountCard() {
|
|
|
53
53
|
const head = (text) => `<div><h3>Sidebud account</h3><p class="hint">${text}</p></div>`;
|
|
54
54
|
if (account.status === 'signed_in') {
|
|
55
55
|
const who = account.user.name && account.user.email ? `${esc(account.user.name)} · ${esc(account.user.email)}` : esc(account.user.email || account.user.name || 'Your account');
|
|
56
|
-
return `<div class="card stack">${head(`This computer is signed in to ${site}.`)}<div class="account-row"><span class="avatar" aria-hidden="true">${esc((account.user.name || account.user.email || 'S').slice(0, 1).toUpperCase())}</span><div><strong>${who}</strong><p class="hint">Signed in ${esc(new Date(account.signedInAt).toLocaleString())}</p></div></div><div class="actions"><button class="danger" data-action="account-sign-out">Sign out</button></div></div>`;
|
|
56
|
+
return `<div class="card stack">${head(`This computer is signed in to ${site} and shows in your account's list of computers.`)}<div class="account-row"><span class="avatar" aria-hidden="true">${esc((account.user.name || account.user.email || 'S').slice(0, 1).toUpperCase())}</span><div><strong>${who}</strong><p class="hint">Signed in ${esc(new Date(account.signedInAt).toLocaleString())}</p></div></div><div class="actions"><button class="danger" data-action="account-sign-out">Sign out</button></div></div>`;
|
|
57
57
|
}
|
|
58
58
|
if (account.status === 'pending') {
|
|
59
59
|
const link = account.verificationUriComplete || account.verificationUri;
|