wisp-agents 0.24.1 → 0.25.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 +16 -1
- package/README.md +44 -4
- package/bin/wisp.js +55 -1
- package/package.json +2 -2
- package/server/adapters/codex.js +3 -0
- package/server/client.js +2 -2
- package/server/index.js +17 -2
- package/server/mcp-ask.js +44 -2
- package/server/openapi.js +38 -0
- package/server/protocol.js +32 -3
- package/server/render.js +16 -1
- package/server/routes/automations.js +8 -5
- package/server/routes/internal.js +61 -0
- package/server/routes/meta.js +5 -1
- package/server/routes/settings.js +17 -3
- package/server/routes/spells.js +147 -0
- package/server/runner.js +10 -3
- package/server/services/launch.js +7 -2
- package/server/services/spell-hooks.js +99 -0
- package/server/spell-guide.js +135 -0
- package/server/spells-data.js +60 -0
- package/server/spells.js +647 -0
- package/web/css/feed.css +1 -1
- package/web/css/spells.css +205 -0
- package/web/index.html +4 -0
- package/web/js/app.js +3 -0
- package/web/js/blocks.js +7 -1
- package/web/js/features/actions.js +3 -0
- package/web/js/features/commands.js +11 -0
- package/web/js/features/composer.js +12 -0
- package/web/js/features/feed.js +3 -0
- package/web/js/features/keys.js +15 -0
- package/web/js/features/launcher.js +56 -11
- package/web/js/features/live.js +9 -1
- package/web/js/features/settings.js +21 -0
- package/web/js/features/slash.js +2 -1
- package/web/js/features/spell-actions.js +64 -0
- package/web/js/features/spell-ui.js +398 -0
- package/web/js/features/spells.js +715 -0
- package/web/js/features/turns.js +2 -0
- package/web/js/spell-schema.js +246 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.25.0] - 2026-10-21
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Spells: change wisp itself, by asking.** A spell is a change wisp layers over itself at runtime: a floating button that opens all your PRs (with how many), a top bar button with your tool stats, a `deploy` block agents can use in their reports, compact cards with an Open PR button on hover, quick replies above the follow-up box, a monospace prompt box with a *Plan on Codex* button, a hook that sends a task back once when its tests fail. Cast one from ⌘K → *Cast a spell…*, Settings → Spells, `/spell …` in the prompt box or `wisp spell cast "…"`. An agent writes it in an empty folder of its own, using new `wisp_spell_schema`, `wisp_spells`, `wisp_spell_data` and `wisp_spell_check` tools, and its report ends with the spell: a live preview with your real data and what it does in plain words. **Try it** applies it to the window only; **Keep** turns it on; a follow-up changes it. Spells add buttons, chips and panels to named places (top bar, floating, prompt box, follow-up box, card head / hover / menu / body, ⌘K, above the feed), restyle or hide named parts, recolour wisp, add block types and prompt rules for agents, `/commands`, settings (shown as *Set by …* in Settings) and hooks. Their data comes from read-only wisp routes and read-only `gh` commands that wisp runs; their code runs in a sandboxed frame with no network, DOM or API and can only return values and blocks. Each spell says how far it reaches (Safe, Acts for you, Reads GitHub, Runs commands). Turning one off puts wisp back exactly as it was; `WISP_SAFE=1` turns them all off. Share a spell as a `.spell.json` file, pasted JSON or a link: Settings → Spells (export, copy, import by file, paste or link) or `wisp spell install|on|off|rm|export`. API: `/spells` (list, install, `check`, on/off, export, data, `try-data`, `off`), `POST /open`, `GET /usage/tools`, and `kind: "spell"` on `POST /sessions`.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **Spell inputs look like the rest of wisp.** In Settings → Spells and the *Cast a spell* dialog the prompt box was a 32px input squeezed to one line with its resize grip over the text, and *Paste JSON or a link* was monospace; both sat flush against their panel's edge. They now use wisp's standard text box and input, inset like every other settings row.
|
|
17
|
+
- **Settings → Spells casts from a prompt box like the launcher's.** The Cast button no longer sits beside the text box: the prompt is a rounded box with the launcher's focus ring and an Enter button in a bar under the text, it grows as you type, and a click anywhere on it puts the cursor in. Long example prompts wrap inside their chip instead of running over the next one.
|
|
18
|
+
- **Space above "Needs you" is back.** When something needed you, the first feed group sat flush under the launcher's recipes row.
|
|
19
|
+
- **Codex can use wisp's tools outside Full auto.** In Edits and Plan mode `codex exec` refused every MCP call ("requires approval, but approval policy is never"), so `ask_user` and the other wisp tools failed there. wisp's own MCP server is now pre-approved.
|
|
20
|
+
- **A report whose last block lost a closing brace keeps that block.** Agents sometimes drop a `}` after a long string (a spell's script); wisp used to skip the block with a warning, and now closes it.
|
|
21
|
+
|
|
8
22
|
## [0.24.1] - 2026-10-20
|
|
9
23
|
|
|
10
24
|
### Fixed
|
|
@@ -442,7 +456,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
442
456
|
- Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
|
|
443
457
|
- Older turns are folded, with a floating collapse-all button and back-to-top.
|
|
444
458
|
|
|
445
|
-
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.
|
|
459
|
+
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.25.0...HEAD
|
|
460
|
+
[0.25.0]: https://github.com/M0xoo/wisp/compare/v0.24.1...v0.25.0
|
|
446
461
|
[0.24.1]: https://github.com/M0xoo/wisp/compare/v0.24.0...v0.24.1
|
|
447
462
|
[0.24.0]: https://github.com/M0xoo/wisp/compare/v0.23.0...v0.24.0
|
|
448
463
|
[0.23.0]: https://github.com/M0xoo/wisp/compare/v0.22.0...v0.23.0
|
package/README.md
CHANGED
|
@@ -56,7 +56,8 @@ From there you can **follow up**, **answer its questions**, **take it over** in
|
|
|
56
56
|
| 🤝 **Subagents** | Give the lead agent a team on other harnesses (e.g. Claude Code hands tests to Codex ×3). It delegates through a `delegate` tool; their edits land in its worktree. See [Subagents](#subagents). |
|
|
57
57
|
| ⏪ **Checkpoints & rollback** | Every turn is snapshotted first. Restore to before any turn, revert a single file, or re-run a command block from the presentation. |
|
|
58
58
|
| ⚖️ **Compare** | Run one task on two harnesses side by side, and keep the better run. |
|
|
59
|
-
|
|
|
59
|
+
| ✨ **Spells** | Change wisp itself while it runs: ask for a floating button that opens your PRs, a panel with your tool stats, a block agents can use in their reports, a different prompt box, quick replies, buttons on cards, a new look. An agent writes it as a *spell*; you try it in the window and keep it. Share one as a file, and turn any of them off to put wisp back as it was. See [Spells](#spells). |
|
|
60
|
+
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.25.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
|
|
60
61
|
| 📊 **Usage** | Spend, success rate and questions per project and per harness · model. |
|
|
61
62
|
| 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there, or start a task with `/new`. |
|
|
62
63
|
| 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** (no permission prompts, the default) for each launch. `Shift+Tab` in the prompt cycles modes. In Edits mode, anything the permissions block comes to you: Claude Code waits for **Allow / Always / Deny**, and the other harnesses show an **Allow & retry** card. |
|
|
@@ -85,7 +86,7 @@ brew install m0xoo/tap/wisp-agents # Homebrew: CLI and browser mode,
|
|
|
85
86
|
wisp "fix the flaky auth test"
|
|
86
87
|
```
|
|
87
88
|
|
|
88
|
-
The Homebrew formula lives in [M0xoo/homebrew-tap](https://github.com/M0xoo/homebrew-tap). `install.sh` honors `WISP_VERSION` to pin a release (`curl -fsSL https://trywisp.dev/install.sh | WISP_VERSION=0.
|
|
89
|
+
The Homebrew formula lives in [M0xoo/homebrew-tap](https://github.com/M0xoo/homebrew-tap). `install.sh` honors `WISP_VERSION` to pin a release (`curl -fsSL https://trywisp.dev/install.sh | WISP_VERSION=0.25.0 bash`).
|
|
89
90
|
|
|
90
91
|
From source:
|
|
91
92
|
|
|
@@ -135,7 +136,7 @@ wisp btw <id> "did you do X?" # ask on the side without interrupting the run
|
|
|
135
136
|
wisp stop <id>
|
|
136
137
|
wisp ship <id> [--draft] # commit, push and open a PR for a worktree task
|
|
137
138
|
wisp merge <id> # squash-merge its PR, fast-forward your local main
|
|
138
|
-
wisp run release 0.
|
|
139
|
+
wisp run release 0.25.0 # run a recipe; values fill its {params} (or version=0.25.0)
|
|
139
140
|
wisp recipes # list recipes
|
|
140
141
|
wisp usage [--days 7] # spend and outcomes
|
|
141
142
|
wisp mcp # MCP server for other agents and editors
|
|
@@ -354,7 +355,7 @@ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/chec
|
|
|
354
355
|
Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}.
|
|
355
356
|
```
|
|
356
357
|
|
|
357
|
-
Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.
|
|
358
|
+
Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.25.0` fills the fields in order, `wisp run release version=0.25.0` by name.
|
|
358
359
|
|
|
359
360
|
**Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
|
|
360
361
|
|
|
@@ -366,6 +367,45 @@ Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe m
|
|
|
366
367
|
|
|
367
368
|
GitHub triggers poll with your `gh` login (every 2–60 minutes), so nothing has to reach your machine; the first check only records what already exists. Issue triggers start in Plan mode: issue text comes from whoever can open issues, so think twice before giving it Full auto.
|
|
368
369
|
|
|
370
|
+
## Spells
|
|
371
|
+
|
|
372
|
+
A spell is a change to wisp that wisp layers over itself at runtime, without anyone editing its code: buttons, panels, blocks agents can use, a different look, how agents behave. Each is one JSON file in `~/.wisp/spells/`. Turning one off puts back exactly what it changed, and the file is all you need to share it.
|
|
373
|
+
|
|
374
|
+
**Cast one.** ⌘K → *Cast a spell…*, Settings → Spells, or `/spell …` in the prompt box. Say what you want:
|
|
375
|
+
|
|
376
|
+
```text
|
|
377
|
+
/spell a floating button that opens all my open PRs on GitHub, with how many there are
|
|
378
|
+
/spell a top bar button with my wisp tool stats for the last 30 days
|
|
379
|
+
/spell a "deploy" block agents can use to show where they deployed and whether it's healthy
|
|
380
|
+
/spell compact cards, the PR first, and an Open PR button on cards that have one
|
|
381
|
+
/spell quick replies above the follow-up box: Run the tests, Explain why…
|
|
382
|
+
/spell when a task finishes with failing tests, send it back once
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
An agent (in an empty folder of its own) reads the spell reference, samples the real data, checks the spell with wisp and hands it back as a card with a live preview: the buttons as they will look, panels drawn with your data, and what the spell does in plain words. **Try it** applies it to this window only (**Undo** takes it back); **Keep** turns it on. Ask a follow-up to change it, or "remove the … spell" to drop it.
|
|
386
|
+
|
|
387
|
+
**What a spell can do**
|
|
388
|
+
|
|
389
|
+
| | |
|
|
390
|
+
|---|---|
|
|
391
|
+
| Add | Buttons in the top bar, floating in a corner (grouped into a menu), in the prompt box and the follow-up box, on cards (on hover, in the head, in the menu) and in ⌘K. Chips and quick replies. Panels as a dialog, a drawer, above the feed or inside each card. |
|
|
392
|
+
| Change | Restyle or hide named parts (`card.title`, `launcher.compare`, `topbar.machines`, …), reorder a card's head, set the prompt box's placeholder, rows and font, recolour wisp (`--accent`, …). |
|
|
393
|
+
| Agents | New block types for reports (`deploy`, …), rules added to agents' prompts (all or per project / harness), `/commands`, settings while the spell is on. |
|
|
394
|
+
| On its own | Hooks: when a turn finishes or fails, a task starts or waits for you, reply once, mark done, archive, notify or POST to a URL. |
|
|
395
|
+
| Data | Read-only wisp routes (`/sessions`, `/usage/tools`, …) and read-only `gh` commands (`search prs`, `pr checks`, `run list`, …). wisp fetches them; spell code never touches the network. |
|
|
396
|
+
|
|
397
|
+
A spell's code (`script`) runs only in a sandboxed frame with no network, no DOM and no wisp API: it gets the data wisp fetched and returns values or blocks, which wisp checks and draws with its own renderer. Every spell shows how far it reaches: **Safe**, **Acts for you** (follow-ups, launches; only when you click, or a hook), **Reads GitHub**, or **Runs commands**. Questions, approvals and Settings → Spells can't be restyled or hidden. Start wisp with `WISP_SAFE=1` to turn every spell off, or use **Turn every spell off** in Settings → Spells.
|
|
398
|
+
|
|
399
|
+
**Share one.** Settings → Spells → export (a `.spell.json` file) or copy (JSON for a chat). Your colleague drops the file on wisp, pastes it or a link (https or `gist:<id>`) into Settings → Spells, or runs `wisp spell install ./my-prs.spell.json`. They see the same review before anything changes, and it stays off until they turn it on.
|
|
400
|
+
|
|
401
|
+
```sh
|
|
402
|
+
wisp spell # list: ● on ○ off
|
|
403
|
+
wisp spell cast "a /explain command that asks for a plain-words explanation"
|
|
404
|
+
wisp spell install my-prs.spell.json --on
|
|
405
|
+
wisp spell export my-prs > my-prs.spell.json
|
|
406
|
+
wisp spell off --all
|
|
407
|
+
```
|
|
408
|
+
|
|
369
409
|
## Usage
|
|
370
410
|
|
|
371
411
|
⌘K → *Usage* shows spend per day and, per project and per harness · model: spend, turns, how often the run worked (finished, not partial or blocked), how often the agent had to ask you, and the average time. Claude Code, opencode and Grok report cost (Grok only when its plan does); Codex, Cursor and Antigravity only report tokens, so their turns are counted but not priced. Also `wisp usage` and `GET /api/usage?days=30`.
|
package/bin/wisp.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// wisp add [path] add a folder as a project (default: current folder)
|
|
7
7
|
// wisp ls | show | wait | reply | btw | stop script sessions (see --help)
|
|
8
8
|
// wisp run <recipe> [values…] run a saved recipe
|
|
9
|
+
// wisp spell [ls|cast|install|on|off|rm|export] spells: changes to wisp itself
|
|
9
10
|
// wisp mcp stdio MCP server, so other agents and editors can use wisp
|
|
10
11
|
// wisp skill [install] the agent skill that teaches other agents to use wisp
|
|
11
12
|
|
|
@@ -33,6 +34,11 @@ const HELP = `usage:
|
|
|
33
34
|
wisp run <recipe> [values…] run a saved recipe; values fill its {params} in order, or key=value
|
|
34
35
|
wisp recipes list recipes
|
|
35
36
|
wisp usage spend and outcomes (--days 7|30|90)
|
|
37
|
+
wisp spell list spells (changes to wisp itself: buttons, panels, blocks, looks)
|
|
38
|
+
wisp spell cast "<change>" an agent writes a spell for it; keep it in the app (--wait)
|
|
39
|
+
wisp spell install <file|url> add a spell someone sent (https, gist:<id>); off until --on
|
|
40
|
+
wisp spell on|off|rm <id> turn a spell on or off, or remove it (wisp spell off --all)
|
|
41
|
+
wisp spell export <id> print the spell file to share
|
|
36
42
|
wisp mcp run the MCP server (stdio) for other agents and editors
|
|
37
43
|
wisp skill [install] print / install the wisp agent skill (--agent claude|codex|opencode|all --project)
|
|
38
44
|
wisp -- "ls the repo" a task that starts with a command name
|
|
@@ -41,7 +47,7 @@ launch options: --mode plan|edits|auto --agent claude|codex|opencode|cursor|ant
|
|
|
41
47
|
--cwd DIR --also DIR (repeatable) --wait --json --no-worktree --compare agent[:model],…
|
|
42
48
|
options: --port N --browser --no-open --json --version`;
|
|
43
49
|
|
|
44
|
-
const COMMANDS = new Set(['add', 'ls', 'list', 'show', 'wait', 'reply', 'btw', 'stop', 'mcp', 'skill', 'ship', 'merge', 'run', 'recipes', 'usage']);
|
|
50
|
+
const COMMANDS = new Set(['add', 'ls', 'list', 'show', 'wait', 'reply', 'btw', 'stop', 'mcp', 'skill', 'ship', 'merge', 'run', 'recipes', 'usage', 'spell', 'spells']);
|
|
45
51
|
const argv = process.argv.slice(2);
|
|
46
52
|
const flags = { open: true, browser: false, port: Number(process.env.WISP_PORT) || 4477, mode: null, agent: null, model: null, effort: null, cwd: null, also: [], json: false, wait: false, queue: undefined, state: null, limit: null, project: false, worktree: undefined, compare: null, draft: false, days: null };
|
|
47
53
|
const words = [];
|
|
@@ -69,6 +75,8 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
69
75
|
else if (a === '--worktree') flags.worktree = true;
|
|
70
76
|
else if (a === '--compare') flags.compare = argv[++i].split(',').filter(Boolean).map((x) => ({ agent: x.split(':')[0], model: x.split(':').slice(1).join(':') || undefined }));
|
|
71
77
|
else if (a === '--draft') flags.draft = true;
|
|
78
|
+
else if (a === '--on') flags.on = true;
|
|
79
|
+
else if (a === '--all') flags.all = true;
|
|
72
80
|
else if (a === '--days') flags.days = Number(argv[++i]);
|
|
73
81
|
else if (a === '--version' || a === '-v') {
|
|
74
82
|
console.log(JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url))).version);
|
|
@@ -171,6 +179,9 @@ async function scripted(cmd, args) {
|
|
|
171
179
|
if (!flags.json) console.error(` ◌ launched: ${s.title} (${s.id}) — waiting…`);
|
|
172
180
|
return show(s.id, await wisp.wait(s.id));
|
|
173
181
|
}
|
|
182
|
+
case 'spell':
|
|
183
|
+
case 'spells':
|
|
184
|
+
return spellCommand(wisp, args, { out, need, show });
|
|
174
185
|
case 'usage': {
|
|
175
186
|
const u = await wisp.usage({ days: flags.days || 30 });
|
|
176
187
|
const money = (v) => `$${v.toFixed(2)}`;
|
|
@@ -181,6 +192,49 @@ async function scripted(cmd, args) {
|
|
|
181
192
|
}
|
|
182
193
|
}
|
|
183
194
|
|
|
195
|
+
// wisp spell …: list, cast, install, turn on/off, remove and export spells (server/routes/spells.js).
|
|
196
|
+
async function spellCommand(wisp, [sub = 'ls', ...rest], { out, need, show }) {
|
|
197
|
+
const api = (method, p, body) => wisp.request(method, p, body);
|
|
198
|
+
const line = (s) => `${s.enabled ? '●' : '○'} ${s.id.padEnd(24)} ${`v${s.version}`.padEnd(8)} ${s.kind.padEnd(5)} ${s.name}${s.description ? ` — ${s.description}` : ''}`;
|
|
199
|
+
switch (sub) {
|
|
200
|
+
case 'ls':
|
|
201
|
+
case 'list': {
|
|
202
|
+
const r = await api('GET', '/spells');
|
|
203
|
+
return out(r, [r.safe ? '! safe mode: every spell is off' : '', ...r.spells.map(line), ...r.broken.map((b) => `! ${b.id}: ${b.error}`)].filter(Boolean).join('\n') || 'No spells yet. Cast one: wisp spell cast "a floating button that opens my PRs"');
|
|
204
|
+
}
|
|
205
|
+
case 'cast': {
|
|
206
|
+
const s = await wisp.launch({ prompt: need(rest.join(' ').trim(), 'what to change'), kind: 'spell', agent: flags.agent || undefined, model: flags.model ?? undefined, effort: flags.effort ?? undefined });
|
|
207
|
+
if (!flags.wait) return out(s, ` ✨ casting: ${s.title} (${s.id})\n Try it and keep it from its card in wisp.`);
|
|
208
|
+
if (!flags.json) console.error(` ✨ casting: ${s.title} (${s.id}) — waiting…`);
|
|
209
|
+
return show(s.id, await wisp.wait(s.id));
|
|
210
|
+
}
|
|
211
|
+
case 'install':
|
|
212
|
+
case 'add': {
|
|
213
|
+
const src = need(rest[0], 'a .spell.json file or a link');
|
|
214
|
+
const body = /^(https:\/\/|gist:)/i.test(src) ? { url: src } : { text: fs.readFileSync(path.resolve(src), 'utf8'), name: path.basename(src) };
|
|
215
|
+
const check = await api('POST', '/spells/check', body);
|
|
216
|
+
if (!check.ok) die(`That spell has problems:\n- ${check.problems.join('\n- ')}`);
|
|
217
|
+
const r = await api('POST', '/spells', { ...body, enable: !!flags.on });
|
|
218
|
+
return out(r, [` ✓ ${r.updated ? 'updated' : 'added'} ${r.spell.name} (${r.spell.id} v${r.spell.version}, ${r.spell.kind})${r.spell.enabled ? ', on' : ' — off; turn it on with: wisp spell on ' + r.spell.id}`, ...check.summary.map((l) => ` · ${l}`)].join('\n'));
|
|
219
|
+
}
|
|
220
|
+
case 'on':
|
|
221
|
+
case 'off': {
|
|
222
|
+
if (sub === 'off' && flags.all) return out(await api('POST', '/spells/off', {}), ' ✓ every spell is off');
|
|
223
|
+
const r = await api('PATCH', `/spells/${encodeURIComponent(need(rest[0], 'spell id'))}`, { enabled: sub === 'on' });
|
|
224
|
+
return out(r, ` ✓ ${r.name} is ${r.enabled ? 'on' : 'off'}`);
|
|
225
|
+
}
|
|
226
|
+
case 'rm':
|
|
227
|
+
case 'remove':
|
|
228
|
+
return out(await api('DELETE', `/spells/${encodeURIComponent(need(rest[0], 'spell id'))}`, {}), ' ✓ removed');
|
|
229
|
+
case 'export': {
|
|
230
|
+
const s = await api('GET', `/spells/${encodeURIComponent(need(rest[0], 'spell id'))}/export`);
|
|
231
|
+
return console.log(JSON.stringify(s, null, 2));
|
|
232
|
+
}
|
|
233
|
+
default:
|
|
234
|
+
die(`unknown: wisp spell ${sub} (ls, cast, install, on, off, rm, export)`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
184
238
|
// Installs skills/wisp/SKILL.md where each harness looks for skills.
|
|
185
239
|
function skill(sub) {
|
|
186
240
|
const src = fileURLToPath(new URL('../skills/wisp/SKILL.md', import.meta.url));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wisp-agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Run Claude Code, Codex, Cursor and other coding agents in parallel, in the background, on any of your machines. Skip the transcript, read the report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"app": "node bin/wisp.js",
|
|
21
21
|
"dev": "WISP_DEV=1 node bin/wisp.js",
|
|
22
22
|
"dev:web": "node --watch-path=server bin/wisp.js --no-open",
|
|
23
|
-
"test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js test/feedback.test.js test/router.test.js test/routes.test.js test/adapters-exit.test.js test/web-pure.test.js test/install.test.js test/install-script.test.js test/mentions.test.js test/clone.test.js test/pipeline.test.js test/commands.test.js test/btw.test.js test/site.test.js",
|
|
23
|
+
"test": "node --test test/proc.test.js test/terminal.test.js test/adapters.test.js test/e2e.test.js test/fuzzy.test.js test/integrations.test.js test/update.test.js test/learn.test.js test/ship.test.js test/subagents.test.js test/machines.test.js test/feedback.test.js test/router.test.js test/routes.test.js test/adapters-exit.test.js test/web-pure.test.js test/install.test.js test/install-script.test.js test/mentions.test.js test/clone.test.js test/pipeline.test.js test/commands.test.js test/btw.test.js test/site.test.js test/spells.test.js",
|
|
24
24
|
"site": "node scripts/site.mjs",
|
|
25
25
|
"test:real": "node test/real-clis.mjs",
|
|
26
26
|
"prepublishOnly": "node --check bin/wisp.js && node --check bin/mac-app.js && node -e \"import('./server/index.js').then(() => import('./server/mcp-wisp.js'))\" && node --check electron/main.js"
|
package/server/adapters/codex.js
CHANGED
|
@@ -83,6 +83,9 @@ export default {
|
|
|
83
83
|
args.push('-c', `${key}.command=${toml(srv.command)}`, '-c', `${key}.args=${toml(srv.args || [])}`);
|
|
84
84
|
if (srv.env) args.push('-c', `${key}.env=${toml(srv.env)}`);
|
|
85
85
|
args.push('-c', `${key}.tool_timeout_sec=${ASK_TIMEOUT_MS / 1000}`);
|
|
86
|
+
// wisp's own tools (ask_user, the spell tools…) need no approval: outside Full auto, codex
|
|
87
|
+
// exec's approval policy is never, which otherwise refuses every MCP call.
|
|
88
|
+
args.push('-c', `${key}.default_tools_approval_mode=${toml('approve')}`);
|
|
86
89
|
}
|
|
87
90
|
const images = attachments.filter((a) => IMAGE.test(a.mime));
|
|
88
91
|
for (const img of images) args.push('-i', img.path);
|
package/server/client.js
CHANGED
|
@@ -133,14 +133,14 @@ export class WispClient {
|
|
|
133
133
|
// runs the same task on more harnesses at once, each in its own worktree. subagents:
|
|
134
134
|
// [{ name, agent, model, effort, use }] (or preset: a saved team's id) the lead can delegate to.
|
|
135
135
|
// also: more folders the same task works in (each git repo gets its own worktree and PR).
|
|
136
|
-
async launch({ prompt, cwd = process.cwd(), also, agent, mode, model, effort, attachments, worktree, compare, subagents, preset } = {}) {
|
|
136
|
+
async launch({ prompt, cwd = process.cwd(), also, agent, mode, model, effort, attachments, worktree, compare, subagents, preset, kind } = {}) {
|
|
137
137
|
if (!agent || !mode || model === undefined) {
|
|
138
138
|
const { defaults = {} } = await this.config();
|
|
139
139
|
agent ||= defaults.agent || 'auto';
|
|
140
140
|
mode ||= defaults.mode || 'auto';
|
|
141
141
|
model ??= defaults.models?.[agent] ?? (agent === defaults.agent ? defaults.model || '' : '');
|
|
142
142
|
}
|
|
143
|
-
return this.request('POST', '/sessions', { prompt, cwd: path.resolve(cwd), also: also?.length ? also.map((d) => path.resolve(d)) : undefined, agent, mode, model, effort, attachments, worktree, compare, subagents, preset });
|
|
143
|
+
return this.request('POST', '/sessions', { prompt, cwd: path.resolve(cwd), also: also?.length ? also.map((d) => path.resolve(d)) : undefined, agent, mode, model, effort, attachments, worktree, compare, subagents, preset, kind });
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
// Ship it: commit the session's worktree, push its branch, open (or update) the PR.
|
package/server/index.js
CHANGED
|
@@ -16,6 +16,10 @@ import { welcomeSession } from './welcome.js';
|
|
|
16
16
|
import { Telemetry } from './telemetry.js';
|
|
17
17
|
import { Updater } from './update.js';
|
|
18
18
|
import { Preferences, Retro } from './preferences.js';
|
|
19
|
+
import { Spells, overlayDefaults } from './spells.js';
|
|
20
|
+
import { createSpellData } from './spells-data.js';
|
|
21
|
+
import { setSpellBlocks } from './render.js';
|
|
22
|
+
import { createSpellHooks } from './services/spell-hooks.js';
|
|
19
23
|
import { Triggers } from './triggers.js';
|
|
20
24
|
import { Remote } from './remote.js';
|
|
21
25
|
import { Machines, splitId } from './machines.js';
|
|
@@ -47,11 +51,12 @@ import * as machineRoutes from './routes/machines.js';
|
|
|
47
51
|
import * as proxyRoutes from './routes/proxy.js';
|
|
48
52
|
import * as sessionRoutes from './routes/sessions.js';
|
|
49
53
|
import * as shipRoutes from './routes/ship.js';
|
|
54
|
+
import * as spellRoutes from './routes/spells.js';
|
|
50
55
|
|
|
51
56
|
export { API_VERSION };
|
|
52
57
|
|
|
53
58
|
// Order matters only where patterns overlap: the machine proxy claims <id>@<machine> first.
|
|
54
|
-
const ROUTES = [metaRoutes, autoRoutes, internalRoutes, uploadRoutes, settingsRoutes, automationRoutes, machineRoutes, proxyRoutes, sessionRoutes, shipRoutes];
|
|
59
|
+
const ROUTES = [metaRoutes, autoRoutes, internalRoutes, uploadRoutes, settingsRoutes, automationRoutes, machineRoutes, proxyRoutes, sessionRoutes, shipRoutes, spellRoutes];
|
|
55
60
|
|
|
56
61
|
// Idle keep-alive connections are closed by the client first (Node's fetch: 4s, browsers: longer
|
|
57
62
|
// but they retry). With Node's 5s default the two raced, and a request sent on a socket the
|
|
@@ -87,6 +92,14 @@ export async function startServer({ port = DEFAULT_PORT, host = '127.0.0.1', han
|
|
|
87
92
|
|
|
88
93
|
if (store.all().length === 0) store.put(welcomeSession());
|
|
89
94
|
|
|
95
|
+
// Spells: changes layered over wisp at runtime (server/spells.js). Settings a spell sets win
|
|
96
|
+
// while it's on; config.json keeps only yours.
|
|
97
|
+
app.spells = new Spells(store.home);
|
|
98
|
+
store.config.defaults = overlayDefaults(store.config.defaults, () => app.spells.settings());
|
|
99
|
+
app.spells.onChange = () => app.configChanged();
|
|
100
|
+
app.spellData = createSpellData(app);
|
|
101
|
+
setSpellBlocks((type) => app.spells.enabled().flatMap((x) => x.blocks || []).find((b) => b.type === type) || null);
|
|
102
|
+
|
|
90
103
|
runner.on('summary', (s) => app.broadcast('summary', s));
|
|
91
104
|
runner.on('activity', (a) => app.broadcast('activity', a));
|
|
92
105
|
runner.on('turn', (t) => app.broadcast('turn', t));
|
|
@@ -122,7 +135,8 @@ export async function startServer({ port = DEFAULT_PORT, host = '127.0.0.1', han
|
|
|
122
135
|
app.prefs = new Preferences(store.home);
|
|
123
136
|
app.learning = () => store.config.defaults.learn !== 'off';
|
|
124
137
|
const projectOf = (cwd) => store.projectFor(cwd)?.path || cwd;
|
|
125
|
-
runner.promptExtra = (s) => (app.learning() && s.agent !== 'wisp' ? app.prefs.prompt(projectOf(s.cwd)) : '') + scoutNote(s.scout);
|
|
138
|
+
runner.promptExtra = (s) => (app.learning() && s.agent !== 'wisp' ? app.prefs.prompt(projectOf(s.cwd)) : '') + (s.parentId ? '' : app.spells.promptFor(s, store.projectFor(s.cwd))) + scoutNote(s.scout);
|
|
139
|
+
runner.spellBlocks = (s) => app.spells.blocksFor(s, store.projectFor(s.cwd));
|
|
126
140
|
app.retro = new Retro({
|
|
127
141
|
store,
|
|
128
142
|
runner,
|
|
@@ -158,6 +172,7 @@ export async function startServer({ port = DEFAULT_PORT, host = '127.0.0.1', han
|
|
|
158
172
|
// Phone: answers and follow-ups coming back from ntfy / Telegram.
|
|
159
173
|
app.remote = new Remote({ store, onChange: () => app.broadcast('remote', {}), ...phoneActions(app) });
|
|
160
174
|
app.ship = createShip(app);
|
|
175
|
+
createSpellHooks(app);
|
|
161
176
|
|
|
162
177
|
const routes = new Routes();
|
|
163
178
|
for (const mod of ROUTES) mod.register(routes, app);
|
package/server/mcp-ask.js
CHANGED
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
// `wisp_stats` is read-only: tool counts, spend and success rate across the user's wisp sessions.
|
|
10
10
|
// `wisp_sessions` and `wisp_session` are read-only too: list the user's other tasks and read one's
|
|
11
11
|
// reports, e.g. a task the user mentioned in the prompt.
|
|
12
|
+
// With WISP_SPELLS (a Cast a spell task): `wisp_spells`, `wisp_spell_schema`, `wisp_spell_data` and
|
|
13
|
+
// `wisp_spell_check`, to write a spell for the user to keep.
|
|
12
14
|
|
|
13
15
|
import readline from 'node:readline';
|
|
14
16
|
|
|
15
|
-
const { WISP_URL, WISP_TOKEN, WISP_SESSION, WISP_APPROVE } = process.env;
|
|
17
|
+
const { WISP_URL, WISP_TOKEN, WISP_SESSION, WISP_APPROVE, WISP_SPELLS } = process.env;
|
|
16
18
|
let TEAM = [];
|
|
17
19
|
try {
|
|
18
20
|
TEAM = JSON.parse(process.env.WISP_TEAM || '[]');
|
|
@@ -96,6 +98,44 @@ const SESSION = {
|
|
|
96
98
|
},
|
|
97
99
|
};
|
|
98
100
|
|
|
101
|
+
// Cast a spell tasks (WISP_SPELLS) get the tools to write one: what's installed, the reference,
|
|
102
|
+
// a data source's real answer, and a check that validates without installing anything.
|
|
103
|
+
const SPELL_TOOLS = [
|
|
104
|
+
{
|
|
105
|
+
name: 'wisp_spells',
|
|
106
|
+
description: 'The spells the user has (on or off), each as its full JSON with a plain-words summary. Read-only.',
|
|
107
|
+
inputSchema: { type: 'object', properties: {} },
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'wisp_spell_schema',
|
|
111
|
+
description: 'The complete reference for writing a spell: fields, slots, parts, props, style properties, tokens, icons, actions, data sources and worked examples.',
|
|
112
|
+
inputSchema: { type: 'object', properties: {} },
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'wisp_spell_data',
|
|
116
|
+
description: 'Run a spell data source and return a sample of its real answer, so script reads the right shape. Read-only (wisp GET routes from the allowlist, read-only gh commands).',
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
source: { type: 'object', description: '{ "wisp": "/usage/tools?days=30" } or { "gh": ["search", "prs", "--author", "@me", "--json", "url,title"] }' },
|
|
121
|
+
task: { type: 'string', description: 'Optional task id, to fill {field} templates in gh args from that task (e.g. {pr.number}).' },
|
|
122
|
+
},
|
|
123
|
+
required: ['source'],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'wisp_spell_check',
|
|
128
|
+
description: 'Validate a spell without installing it. Returns ok and what it does in plain words, or every problem to fix. The script is compiled, never run.',
|
|
129
|
+
inputSchema: { type: 'object', properties: { spell: { type: 'object', description: 'The whole spell JSON.' } }, required: ['spell'] },
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
async function spellTool(name, args = {}) {
|
|
134
|
+
if (name === 'wisp_spell_data') return (await call(`/api/internal/spell-data/${WISP_SESSION}`, args)).text;
|
|
135
|
+
if (name === 'wisp_spell_check') return (await call(`/api/internal/spell-check/${WISP_SESSION}`, args)).text;
|
|
136
|
+
return (await call(`/api/internal/${name === 'wisp_spells' ? 'spells' : 'spell-schema'}/${WISP_SESSION}`)).text;
|
|
137
|
+
}
|
|
138
|
+
|
|
99
139
|
async function sessions(args = {}) {
|
|
100
140
|
const q = new URLSearchParams(Object.entries(args).filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => [k, String(v)]));
|
|
101
141
|
return (await call(`/api/internal/sessions/${WISP_SESSION}?${q}`)).text;
|
|
@@ -228,7 +268,7 @@ readline.createInterface({ input: process.stdin }).on('line', async (line) => {
|
|
|
228
268
|
if (method === 'initialize') {
|
|
229
269
|
send({ id, result: { protocolVersion: params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'wisp', version: '0.1.0' } } });
|
|
230
270
|
} else if (method === 'tools/list') {
|
|
231
|
-
send({ id, result: { tools: [TOOL, STATS, SESSIONS, SESSION, ...(WISP_APPROVE ? [APPROVE] : []), ...(TEAM.length ? TEAM_TOOLS : [])] } });
|
|
271
|
+
send({ id, result: { tools: [TOOL, STATS, SESSIONS, SESSION, ...(WISP_APPROVE ? [APPROVE] : []), ...(TEAM.length ? TEAM_TOOLS : []), ...(WISP_SPELLS ? SPELL_TOOLS : [])] } });
|
|
232
272
|
} else if (method === 'tools/call' && params?.name === 'ask_user') {
|
|
233
273
|
const answer = await askUser(params.arguments || {});
|
|
234
274
|
const text = answer === null ? 'The user did not answer (the question was cancelled). Use your best judgment and note the assumption.' : `The user answered: ${answer}`;
|
|
@@ -246,6 +286,8 @@ readline.createInterface({ input: process.stdin }).on('line', async (line) => {
|
|
|
246
286
|
} else if (method === 'tools/call' && TEAM.length && params?.name === 'subagent_results') {
|
|
247
287
|
const a = params.arguments || {};
|
|
248
288
|
send({ id, result: { content: [{ type: 'text', text: await results(a.ids, a.wait !== false) }] } });
|
|
289
|
+
} else if (method === 'tools/call' && WISP_SPELLS && SPELL_TOOLS.some((t) => t.name === params?.name)) {
|
|
290
|
+
send({ id, result: { content: [{ type: 'text', text: await spellTool(params.name, params.arguments || {}) }] } });
|
|
249
291
|
} else if (method === 'ping') {
|
|
250
292
|
send({ id, result: {} });
|
|
251
293
|
} else {
|
package/server/openapi.js
CHANGED
|
@@ -6,6 +6,7 @@ const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
|
6
6
|
const json = (schema, description = 'OK') => ({ description, content: { 'application/json': { schema } } });
|
|
7
7
|
const id = { name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Session id' };
|
|
8
8
|
const body = (schema) => ({ required: true, content: { 'application/json': { schema } } });
|
|
9
|
+
const spellId = { name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Spell id' };
|
|
9
10
|
const err = { 400: json(ref('Error'), 'Bad request'), 404: json(ref('Error'), 'Not found') };
|
|
10
11
|
const ok = json({ type: 'object', properties: { ok: { type: 'boolean' } } });
|
|
11
12
|
|
|
@@ -219,6 +220,42 @@ export function openapi({ version, port }) {
|
|
|
219
220
|
responses: { 200: json({ type: 'object' }) },
|
|
220
221
|
},
|
|
221
222
|
},
|
|
223
|
+
'/usage/tools': {
|
|
224
|
+
get: {
|
|
225
|
+
summary: 'Tool calls by kind, harness, project and day, with turns, cost and success rate (what wisp_stats tells agents)',
|
|
226
|
+
parameters: [{ name: 'days', in: 'query', schema: { type: 'integer', default: 30, maximum: 365 } }, { name: 'agent', in: 'query', schema: { type: 'string' } }, { name: 'project', in: 'query', schema: { type: 'string' }, description: 'Project id or name' }],
|
|
227
|
+
responses: { 200: json({ type: 'object' }) },
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
'/spells': {
|
|
231
|
+
get: { summary: 'Every spell: its JSON, on or off, where it came from, a plain-words summary and its kind (safe | acts | reads | runs); broken files; safe mode', responses: { 200: json({ type: 'object' }) } },
|
|
232
|
+
post: {
|
|
233
|
+
summary: 'Install or update a spell from { spell }, { text } (pasted JSON) or { url } (https or gist:<id>). It stays off unless enable: true',
|
|
234
|
+
requestBody: body({ type: 'object', properties: { spell: { type: 'object' }, text: { type: 'string' }, url: { type: 'string' }, enable: { type: 'boolean' } } }),
|
|
235
|
+
responses: { 200: json({ type: 'object' }), 201: json({ type: 'object' }), 400: err[400], 409: json({ type: 'object' }) },
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
'/spells/check': {
|
|
239
|
+
post: {
|
|
240
|
+
summary: 'Validate a spell without installing it: { ok, spell, summary, kind, warnings, installed } or { ok: false, problems }',
|
|
241
|
+
requestBody: body({ type: 'object', properties: { spell: { type: 'object' }, text: { type: 'string' }, url: { type: 'string' } } }),
|
|
242
|
+
responses: { 200: json({ type: 'object' }), 400: err[400] },
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
'/spells/try-data': { post: { summary: "Fetch a data source of a spell that isn't installed (Try it); the spell is validated first", requestBody: body({ type: 'object', required: ['spell', 'name'], properties: { spell: { type: 'object' }, name: { type: 'string' }, session: { type: 'string' } } }), responses: { 200: json({ type: 'object', properties: { value: {} } }), 400: err[400], 404: err[404] } } },
|
|
246
|
+
'/spells/off': { post: { summary: 'Turn every spell off', responses: { 200: json({ type: 'object' }) } } },
|
|
247
|
+
'/spells/{id}': {
|
|
248
|
+
parameters: [spellId],
|
|
249
|
+
get: { summary: 'One spell', responses: { 200: json({ type: 'object' }), 404: err[404] } },
|
|
250
|
+
patch: { summary: 'Turn a spell on or off. 409 when its block types or slash commands clash with one that is on', requestBody: body({ type: 'object', required: ['enabled'], properties: { enabled: { type: 'boolean' } } }), responses: { 200: json({ type: 'object' }), 404: err[404], 409: json({ type: 'object' }) } },
|
|
251
|
+
delete: { summary: 'Remove a spell', responses: { 200: json({ type: 'object' }), 404: err[404] } },
|
|
252
|
+
},
|
|
253
|
+
'/spells/{id}/export': { parameters: [spellId], get: { summary: 'The spell file to share (.spell.json)', responses: { 200: json({ type: 'object' }), 404: err[404] } } },
|
|
254
|
+
'/spells/{id}/data/{name}': {
|
|
255
|
+
parameters: [spellId, { name: 'name', in: 'path', required: true, schema: { type: 'string' } }],
|
|
256
|
+
post: { summary: "Fetch one of a spell's data sources. session: the card's task, for per-task sources; fresh skips the cache", requestBody: body({ type: 'object', properties: { session: { type: 'string' }, fresh: { type: 'boolean' } } }), responses: { 200: json({ type: 'object', properties: { value: {} } }), 404: err[404] } },
|
|
257
|
+
},
|
|
258
|
+
'/open': { post: { summary: 'Open http(s) links in the default browser (at most 20)', requestBody: body({ type: 'object', required: ['urls'], properties: { urls: { type: 'array', items: { type: 'string' } } } }), responses: { 200: json({ type: 'object' }), 400: err[400] } } },
|
|
222
259
|
'/sessions/{id}/wait': {
|
|
223
260
|
parameters: [id, { name: 'timeout', in: 'query', schema: { type: 'integer', default: 60, maximum: 290 }, description: 'Seconds' }],
|
|
224
261
|
get: { summary: 'Long-poll until the session needs you (finished or asking)', responses: { 200: json({ allOf: [ref('Summary'), { type: 'object', properties: { timedOut: { type: 'boolean' } } }] }), 404: err[404] } },
|
|
@@ -315,6 +352,7 @@ export function openapi({ version, port }) {
|
|
|
315
352
|
cwd: { type: 'string', description: 'Project folder (absolute; ~ allowed). Default: the server’s cwd' },
|
|
316
353
|
also: { type: 'array', maxItems: 8, items: { type: 'string' }, description: 'More folders the same task works in (absolute; ~ allowed). The agent gets each; git repos get their own worktree, and Ship it opens a PR in each' },
|
|
317
354
|
scratch: { type: 'boolean', description: 'No project folder: run in an empty scratch directory (no worktree). Overrides cwd' },
|
|
355
|
+
kind: { type: 'string', enum: ['spell'], description: 'spell: Cast a spell. The agent writes a spell that changes wisp (an empty scratch folder, Edits mode, this computer); its report ends with a spell block to keep' },
|
|
318
356
|
agent: { enum: ['auto', 'claude', 'codex', 'opencode', 'cursor', 'antigravity', 'grok'], default: 'auto', description: 'auto: wisp picks the harness and model from what worked before; model is then the goal (cheap, balanced, best)' },
|
|
319
357
|
important: { type: 'boolean', description: 'With agent auto: no exploring, take the pick with the best record' },
|
|
320
358
|
mode: { enum: ['plan', 'edits', 'auto'], default: 'auto' },
|
package/server/protocol.js
CHANGED
|
@@ -103,7 +103,13 @@ const ASK_BLOCK = `- If a decision would change the rest of the work (ambiguous
|
|
|
103
103
|
|
|
104
104
|
// askTool: how the harness names wisp's injected ask_user tool, or null if it can't take one.
|
|
105
105
|
// ask: replaces the ask guidance outright (subagents, who have nobody to ask).
|
|
106
|
-
|
|
106
|
+
// extraBlocks: block types spells add ({ type, doc, spell }), listed with the built-in ones.
|
|
107
|
+
export const systemPrompt = ({ askTool = 'mcp__wisp__ask_user', ask, extraBlocks = [] } = {}) => {
|
|
108
|
+
const out = PROTOCOL.replace('{{ASK}}', ask || (askTool ? ASK_TOOL(askTool) : ASK_BLOCK));
|
|
109
|
+
if (!extraBlocks.length) return out;
|
|
110
|
+
const docs = extraBlocks.map((b) => `- ${b.doc.trim().startsWith('{') ? b.doc.trim().replace(/\}\s+(?=\S)/, '}\n ') : `{"type":"${b.type}"} ${b.doc}`}\n (From the user's spell ${b.spell}.)`).join('\n');
|
|
111
|
+
return out.replace('\nStrings must be valid JSON', `\n${docs}\n\nStrings must be valid JSON`);
|
|
112
|
+
};
|
|
107
113
|
export const SYSTEM_PROMPT = systemPrompt();
|
|
108
114
|
|
|
109
115
|
// Only the opener is matched by regex. The closing fence can't be: the JSON itself often
|
|
@@ -164,8 +170,15 @@ function salvageBlocks(src) {
|
|
|
164
170
|
while (/[\s,]/.test(src[i] || '')) i++;
|
|
165
171
|
if (src[i] !== '{') break;
|
|
166
172
|
const len = jsonEnd(src.slice(i));
|
|
167
|
-
if (len < 0)
|
|
168
|
-
|
|
173
|
+
if (len < 0) {
|
|
174
|
+
// The last block is missing closing braces (a common slip after a long string): close it.
|
|
175
|
+
const fixed = closeBraces(src.slice(i));
|
|
176
|
+
if (fixed) blocks.push(fixed);
|
|
177
|
+
else bad++;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
// A bracket count that ends early on a `]` usually means a block lost a closing brace.
|
|
181
|
+
const block = tryJson(src.slice(i, i + len)) || closeBraces(src.slice(i, i + len));
|
|
169
182
|
if (block && typeof block === 'object') blocks.push(block);
|
|
170
183
|
else bad++;
|
|
171
184
|
i += len;
|
|
@@ -175,6 +188,22 @@ function salvageBlocks(src) {
|
|
|
175
188
|
return blocks;
|
|
176
189
|
}
|
|
177
190
|
|
|
191
|
+
// A block whose closing braces went missing at the end of the answer: cut it at each of its last
|
|
192
|
+
// few `}` and add up to three more until it parses. null when that doesn't help.
|
|
193
|
+
function closeBraces(src) {
|
|
194
|
+
const body = src.replace(/```[\s\S]*$/, '').trimEnd();
|
|
195
|
+
let cut = body.length;
|
|
196
|
+
for (let tries = 0; tries < 6 && cut > 0; tries++) {
|
|
197
|
+
cut = body.lastIndexOf('}', cut - 1);
|
|
198
|
+
if (cut < 0) return null;
|
|
199
|
+
for (let k = 1; k <= 3; k++) {
|
|
200
|
+
const json = tryJson(body.slice(0, cut + 1) + '}'.repeat(k));
|
|
201
|
+
if (json && typeof json === 'object' && typeof json.type === 'string') return json;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
178
207
|
function jsonEnd(src) {
|
|
179
208
|
const start = src.search(/\S/);
|
|
180
209
|
if (src[start] !== '{' && src[start] !== '[') return -1;
|
package/server/render.js
CHANGED
|
@@ -89,12 +89,27 @@ const R = {
|
|
|
89
89
|
next: (b) => `**Next**\n${list(b.items).map((i) => `- ${str(i)}`).join('\n')}`,
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
+
// Block types spells add: their summary template stands in for them here (set from server/index.js).
|
|
93
|
+
let spellBlock = () => null;
|
|
94
|
+
export const setSpellBlocks = (fn) => (spellBlock = fn);
|
|
95
|
+
const fillFields = (tpl, o) => String(tpl).replace(/\{([a-zA-Z_][\w.]*)\}/g, (_, k) => str(k.split('.').reduce((x, y) => x?.[y], o)));
|
|
96
|
+
|
|
97
|
+
R.spell = (b) => {
|
|
98
|
+
const remove = b.remove || (!b.spell?.wispSpell && b.spell?.remove);
|
|
99
|
+
if (remove) return `**✨ Spell:** remove \`${str(remove)}\``;
|
|
100
|
+
const s = b.spell || {};
|
|
101
|
+
return `**✨ Spell: ${str(s.name) || str(s.id)}**${s.description ? ` — ${s.description}` : ''}\n\n_Keep it in wisp to turn it on._`;
|
|
102
|
+
};
|
|
103
|
+
|
|
92
104
|
// blocks: presentation blocks; turn (optional) supplies captured edits for diff blocks.
|
|
93
105
|
export function blocksToMarkdown(blocks = [], turn = null) {
|
|
94
106
|
return list(blocks)
|
|
95
107
|
.map((b) => {
|
|
96
108
|
try {
|
|
97
|
-
|
|
109
|
+
if (R[b?.type]) return R[b.type](b, turn);
|
|
110
|
+
const def = spellBlock(b?.type);
|
|
111
|
+
if (def?.summary) return `${fillFields(def.summary, b)}`;
|
|
112
|
+
return fence(JSON.stringify(b, null, 2), 'json');
|
|
98
113
|
} catch {
|
|
99
114
|
return '';
|
|
100
115
|
}
|
|
@@ -6,7 +6,9 @@ import { normalizeTrigger, triggerView } from '../triggers.js';
|
|
|
6
6
|
export function register(r, app) {
|
|
7
7
|
const { store } = app;
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// Yours, then the ones spells add (read-only: they belong to the spell).
|
|
10
|
+
const all = () => [...store.config.recipes, ...app.spells.recipes()];
|
|
11
|
+
r.route('GET', '/recipes', (c) => c.send(200, all().map(recipeView)));
|
|
10
12
|
r.route('POST', '/recipes', async (c) => {
|
|
11
13
|
const x = normalizeRecipe(await c.body());
|
|
12
14
|
store.config.recipes.push(x);
|
|
@@ -14,9 +16,10 @@ export function register(r, app) {
|
|
|
14
16
|
c.send(201, recipeView(x));
|
|
15
17
|
});
|
|
16
18
|
// A recipe by id or by name (loosely: "Fix Bug" finds fix-bug).
|
|
17
|
-
const recipe = (fn) => async (c) => {
|
|
18
|
-
const x = findRecipe(
|
|
19
|
+
const recipe = (fn, { mine = false } = {}) => async (c) => {
|
|
20
|
+
const x = findRecipe(all(), c.params.key);
|
|
19
21
|
if (!x) return c.send(404, { error: 'no such recipe' });
|
|
22
|
+
if (mine && x.spell) return c.send(409, { error: `This recipe comes from the spell ${x.spell.name}; change the spell instead.` });
|
|
20
23
|
return fn(c, x);
|
|
21
24
|
};
|
|
22
25
|
r.route('GET', '/recipes/:key', recipe((c, x) => c.send(200, recipeView(x))));
|
|
@@ -27,7 +30,7 @@ export function register(r, app) {
|
|
|
27
30
|
Object.assign(x, normalizeRecipe(await c.body(), x));
|
|
28
31
|
app.configChanged();
|
|
29
32
|
c.send(200, recipeView(x));
|
|
30
|
-
}),
|
|
33
|
+
}, { mine: true }),
|
|
31
34
|
);
|
|
32
35
|
r.route(
|
|
33
36
|
'DELETE',
|
|
@@ -36,7 +39,7 @@ export function register(r, app) {
|
|
|
36
39
|
store.config.recipes = store.config.recipes.filter((y) => y !== x);
|
|
37
40
|
app.configChanged();
|
|
38
41
|
c.send(200, { ok: true });
|
|
39
|
-
}),
|
|
42
|
+
}, { mine: true }),
|
|
40
43
|
);
|
|
41
44
|
// POST /recipes/:key/run { params | words, cwd, … }. machine: run it in a project there
|
|
42
45
|
// (r.cwd empty, or a path on that machine).
|