wisp-agents 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +8 -2
- package/bin/mac-app.js +64 -0
- package/bin/wisp.js +50 -8
- package/electron/icon.png +0 -0
- package/electron/icon.svg +13 -0
- package/electron/main.js +18 -1
- package/electron/preload.cjs +6 -0
- package/package.json +7 -5
- package/server/protocol.js +76 -8
- package/server/runner.js +1 -0
- package/server/store.js +12 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `wisp-agents` are documented here.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.2.1] - 2026-10-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Desktop app: native right-click menu with spelling suggestions, Copy Link, undo/redo, cut/copy/paste and select all. Inspect Element appears only in dev mode.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- macOS: the Dock shows "wisp" with the wisp icon instead of "Electron". The app now launches from a renamed clone of `Electron.app`, cached per Electron version under `~/Library/Caches/wisp`.
|
|
17
|
+
|
|
18
|
+
## [0.2.0] - 2026-10-16
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `npx wisp-agents` opens the desktop app. Electron is now an optional dependency that is fetched on first launch. The CLI starts the app detached and falls back to the browser when Electron isn't available.
|
|
23
|
+
- `--browser` flag to keep the browser-only behaviour.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Parsing of the `wisp` fence is more robust: malformed blocks are salvaged instead of dropped, and the raw turn text is kept.
|
|
28
|
+
|
|
29
|
+
## [0.1.0] - 2026-10-16
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- First public release on npm as `wisp-agents`, with `wisp` and `wisp-agents` bins and a `--version` / `-v` flag.
|
|
34
|
+
- Launch Claude Code agents in the background and read the presentation they hand back: diffs, tests, commits, flows and questions.
|
|
35
|
+
- Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
|
|
36
|
+
- Older turns are folded, with a floating collapse-all button and back-to-top.
|
|
37
|
+
|
|
38
|
+
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.2.1...HEAD
|
|
39
|
+
[0.2.1]: https://github.com/M0xoo/wisp/compare/v0.2.0...v0.2.1
|
|
40
|
+
[0.2.0]: https://github.com/M0xoo/wisp/compare/v0.1.0...v0.2.0
|
|
41
|
+
[0.1.0]: https://github.com/M0xoo/wisp/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
Launch coding agents in the background, skip the transcript, and read the presentation they hand back when they finish.
|
|
10
10
|
|
|
11
|
+
[](https://www.npmjs.com/package/wisp-agents)
|
|
11
12
|
[](#license)
|
|
12
13
|
[](https://nodejs.org)
|
|
13
14
|
[](https://www.electronjs.org)
|
|
@@ -51,9 +52,13 @@ From there you can **follow up**, **answer its questions**, **take it over** in
|
|
|
51
52
|
> **Requirements:** Node.js ≥ 20 and [Claude Code](https://claude.com/claude-code) (`claude`) installed and signed in.
|
|
52
53
|
|
|
53
54
|
```sh
|
|
54
|
-
npx wisp-agents
|
|
55
|
+
npx wisp-agents # open the wisp desktop app
|
|
56
|
+
npx wisp-agents "fix the flaky auth test" # launch a task in the current folder
|
|
57
|
+
npx wisp-agents --browser # or use it in the browser → http://localhost:4477
|
|
55
58
|
```
|
|
56
59
|
|
|
60
|
+
The first run downloads Electron for the desktop app; if that isn't possible, wisp opens in the browser instead.
|
|
61
|
+
|
|
57
62
|
Or install it globally to get the `wisp` command everywhere:
|
|
58
63
|
|
|
59
64
|
```sh
|
|
@@ -99,7 +104,8 @@ wisp --mode plan "audit deps" # permission mode: plan | edits | auto
|
|
|
99
104
|
|---|---|
|
|
100
105
|
| `--port`, `-p N` | Port to use (default `4477`, or `WISP_PORT`) |
|
|
101
106
|
| `--mode`, `-m` | `plan` · `edits` · `auto` |
|
|
102
|
-
| `--
|
|
107
|
+
| `--browser`, `-b` | Use the browser instead of the desktop app |
|
|
108
|
+
| `--no-open` | Just run the server; open nothing |
|
|
103
109
|
| `--version`, `-v` | Print the version |
|
|
104
110
|
|
|
105
111
|
> `npm install -g wisp-agents` (or `npm link` from a clone) puts `wisp` on your `PATH`. Without installing, use `npx wisp-agents …` with the same arguments.
|
package/bin/mac-app.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// macOS names the Dock tile, menu bar and Cmd-Tab entry after the running .app bundle's
|
|
2
|
+
// Info.plist, not app.setName(). So run Electron from a clone of Electron.app renamed to wisp
|
|
3
|
+
// (APFS clone: instant, no extra disk), cached per Electron version.
|
|
4
|
+
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const REV = 1; // bump when the patching below changes
|
|
12
|
+
const ICON = fileURLToPath(new URL('../electron/icon.png', import.meta.url));
|
|
13
|
+
|
|
14
|
+
// electronBin: …/Electron.app/Contents/MacOS/Electron → the same binary inside wisp.app, or the
|
|
15
|
+
// original path if anything goes wrong (worst case the Dock says "Electron" again).
|
|
16
|
+
export function wispAppBinary(electronBin) {
|
|
17
|
+
try {
|
|
18
|
+
const src = path.resolve(electronBin, '../../..');
|
|
19
|
+
if (!src.endsWith('.app')) return electronBin;
|
|
20
|
+
const version = readFileSync(path.join(src, '../version'), 'utf8').trim();
|
|
21
|
+
const root = path.join(os.homedir(), 'Library/Caches/wisp');
|
|
22
|
+
const app = path.join(root, `electron-${version}-r${REV}`, 'wisp.app');
|
|
23
|
+
const bin = path.join(app, 'Contents/MacOS', path.basename(electronBin));
|
|
24
|
+
if (existsSync(bin)) return bin;
|
|
25
|
+
|
|
26
|
+
mkdirSync(root, { recursive: true });
|
|
27
|
+
const tmp = mkdtempSync(path.join(root, 'build-'));
|
|
28
|
+
const out = path.join(tmp, 'wisp.app');
|
|
29
|
+
try {
|
|
30
|
+
execFileSync('cp', ['-cR', src, out], { stdio: 'ignore' });
|
|
31
|
+
} catch {
|
|
32
|
+
execFileSync('cp', ['-R', src, out], { stdio: 'ignore' }); // not APFS
|
|
33
|
+
}
|
|
34
|
+
const plist = path.join(out, 'Contents/Info.plist');
|
|
35
|
+
const set = (key, value) => execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plist]);
|
|
36
|
+
set('CFBundleName', 'wisp');
|
|
37
|
+
set('CFBundleDisplayName', 'wisp');
|
|
38
|
+
set('CFBundleIdentifier', 'dev.wisp.app');
|
|
39
|
+
try {
|
|
40
|
+
makeIcns(path.join(out, 'Contents/Resources/wisp.icns'), tmp);
|
|
41
|
+
set('CFBundleIconFile', 'wisp.icns');
|
|
42
|
+
} catch {}
|
|
43
|
+
|
|
44
|
+
mkdirSync(path.dirname(app), { recursive: true });
|
|
45
|
+
rmSync(app, { recursive: true, force: true });
|
|
46
|
+
renameSync(out, app);
|
|
47
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
48
|
+
return bin;
|
|
49
|
+
} catch {
|
|
50
|
+
return electronBin;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeIcns(dest, tmp) {
|
|
55
|
+
const set = path.join(tmp, 'wisp.iconset');
|
|
56
|
+
mkdirSync(set);
|
|
57
|
+
for (const s of [16, 32, 128, 256, 512]) {
|
|
58
|
+
for (const [scale, suffix] of [[1, ''], [2, '@2x']]) {
|
|
59
|
+
const px = String(s * scale);
|
|
60
|
+
execFileSync('sips', ['-z', px, px, ICON, '--out', path.join(set, `icon_${s}x${s}${suffix}.png`)], { stdio: 'ignore' });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
execFileSync('iconutil', ['-c', 'icns', set, '-o', dest], { stdio: 'ignore' });
|
|
64
|
+
}
|
package/bin/wisp.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// wisp — launcher + queue for coding agents.
|
|
3
3
|
//
|
|
4
|
-
// wisp
|
|
4
|
+
// wisp open the wisp desktop app (or focus the running one)
|
|
5
5
|
// wisp "fix the tests" launch a wisp in the current folder
|
|
6
6
|
// wisp add [path] add a folder as a project (default: current folder)
|
|
7
7
|
// --port N port (default 4477, or WISP_PORT)
|
|
8
8
|
// --mode plan|edits|auto permission mode for a launch
|
|
9
|
-
// --
|
|
9
|
+
// --browser use the browser instead of the desktop app
|
|
10
|
+
// --no-open just run the server, open nothing
|
|
10
11
|
|
|
11
|
-
import { exec } from 'node:child_process';
|
|
12
|
+
import { exec, spawn } from 'node:child_process';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
12
15
|
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { wispAppBinary } from './mac-app.js';
|
|
13
18
|
|
|
14
19
|
const argv = process.argv.slice(2);
|
|
15
|
-
const flags = { open: true, port: Number(process.env.WISP_PORT) || 4477, mode: null };
|
|
20
|
+
const flags = { open: true, browser: false, port: Number(process.env.WISP_PORT) || 4477, mode: null };
|
|
16
21
|
const words = [];
|
|
17
22
|
for (let i = 0; i < argv.length; i++) {
|
|
18
23
|
const a = argv[i];
|
|
19
24
|
if (a === '--no-open') flags.open = false;
|
|
25
|
+
else if (a === '--browser' || a === '-b') flags.browser = true;
|
|
20
26
|
else if (a === '--port' || a === '-p') flags.port = Number(argv[++i]);
|
|
21
27
|
else if (a === '--mode' || a === '-m') flags.mode = argv[++i];
|
|
22
28
|
else if (a === '--version' || a === '-v') {
|
|
@@ -24,7 +30,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
24
30
|
console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version);
|
|
25
31
|
process.exit(0);
|
|
26
32
|
} else if (a === '--help' || a === '-h') {
|
|
27
|
-
console.log('usage:\n wisp open wisp\n wisp "task" launch a task in this folder\n wisp add [path] add a project\n\noptions: --port N --mode plan|edits|auto --no-open --version');
|
|
33
|
+
console.log('usage:\n wisp open wisp\n wisp "task" launch a task in this folder\n wisp add [path] add a project\n\noptions: --port N --mode plan|edits|auto --browser --no-open --version');
|
|
28
34
|
process.exit(0);
|
|
29
35
|
} else words.push(a);
|
|
30
36
|
}
|
|
@@ -47,7 +53,20 @@ try {
|
|
|
47
53
|
running = true;
|
|
48
54
|
} catch {}
|
|
49
55
|
|
|
50
|
-
|
|
56
|
+
// The desktop app (Electron is an optional dependency) runs the server in its own process, so
|
|
57
|
+
// it outlives this terminal. Without Electron, or with --browser/--no-open, serve from here.
|
|
58
|
+
// A task or `add` sent to an already-running wisp just goes over the API, no window.
|
|
59
|
+
const electron = flags.open && !flags.browser && (!running || !words.length) ? electronBinary() : null;
|
|
60
|
+
if (electron) {
|
|
61
|
+
const child = spawn(electron, [fileURLToPath(new URL('..', import.meta.url))], {
|
|
62
|
+
detached: true,
|
|
63
|
+
stdio: 'ignore',
|
|
64
|
+
env: { ...process.env, WISP_PORT: String(flags.port) },
|
|
65
|
+
});
|
|
66
|
+
child.unref();
|
|
67
|
+
if (!running) await waitForServer();
|
|
68
|
+
console.log(`\n ◌ wisp is open\n`);
|
|
69
|
+
} else if (!running) {
|
|
51
70
|
const { startServer } = await import('../server/index.js');
|
|
52
71
|
await startServer({ port: flags.port });
|
|
53
72
|
console.log(`\n ◌ wisp is floating at ${base}\n`);
|
|
@@ -58,16 +77,39 @@ if (words[0] === 'add') {
|
|
|
58
77
|
const dir = path.resolve(words[1] || process.cwd());
|
|
59
78
|
const p = await api('/projects', { path: dir }).catch((e) => console.error(` ✗ ${e.message}`));
|
|
60
79
|
if (p) console.log(` ✓ project added: ${p.name} (${p.path})`);
|
|
61
|
-
if (running) process.exit(0);
|
|
80
|
+
if (running || electron) process.exit(0);
|
|
62
81
|
} else if (words.length) {
|
|
63
82
|
const cfg = await api('/config');
|
|
64
83
|
const s = await api('/sessions', { prompt: words.join(' '), cwd: process.cwd(), agent: cfg.defaults.agent, mode: flags.mode || cfg.defaults.mode, model: cfg.defaults.model });
|
|
65
84
|
console.log(` ◌ launched: ${s.title}`);
|
|
66
85
|
openPath = `/#/s/${s.id}`;
|
|
67
|
-
if (running) process.exit(0); // the running wisp will show it in the queue
|
|
86
|
+
if (running || electron) process.exit(0); // the running wisp will show it in the queue
|
|
68
87
|
}
|
|
69
88
|
|
|
89
|
+
if (electron) process.exit(0);
|
|
70
90
|
if (flags.open) {
|
|
71
91
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start ""' : 'xdg-open';
|
|
72
92
|
exec(`${cmd} "${base}${openPath}"`);
|
|
73
93
|
}
|
|
94
|
+
|
|
95
|
+
function electronBinary() {
|
|
96
|
+
try {
|
|
97
|
+
const bin = createRequire(import.meta.url)('electron'); // the electron package exports its binary path
|
|
98
|
+
if (typeof bin !== 'string' || !existsSync(bin)) return null;
|
|
99
|
+
return process.platform === 'darwin' ? wispAppBinary(bin) : bin;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function waitForServer() {
|
|
106
|
+
for (let i = 0; i < 150; i++) {
|
|
107
|
+
try {
|
|
108
|
+
return await api('/config');
|
|
109
|
+
} catch {
|
|
110
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
console.error(' ✗ the wisp app did not start in time — try `wisp --browser`');
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1d1c22"/><stop offset="1" stop-color="#0e0e10"/></linearGradient>
|
|
4
|
+
<radialGradient id="glow" cx="50%" cy="50%" r="50%"><stop offset="0" stop-color="#6a5cff" stop-opacity=".55"/><stop offset="1" stop-color="#6a5cff" stop-opacity="0"/></radialGradient>
|
|
5
|
+
<radialGradient id="orb" cx="36%" cy="32%" r="68%"><stop offset="0" stop-color="#fff"/><stop offset=".16" stop-color="#d9fff6"/><stop offset=".38" stop-color="#2fd0b4"/><stop offset=".78" stop-color="#6a5cff"/><stop offset="1" stop-color="#4b3fe0"/></radialGradient>
|
|
6
|
+
</defs>
|
|
7
|
+
<rect x="100" y="100" width="824" height="824" rx="190" fill="url(#bg)"/>
|
|
8
|
+
<circle cx="512" cy="512" r="330" fill="url(#glow)"/>
|
|
9
|
+
<circle cx="512" cy="512" r="210" fill="url(#orb)"/>
|
|
10
|
+
<circle cx="742" cy="360" r="16" fill="#fff" opacity=".9"/>
|
|
11
|
+
<circle cx="300" cy="650" r="10" fill="#bff" opacity=".7"/>
|
|
12
|
+
<circle cx="690" cy="720" r="7" fill="#fff" opacity=".6"/>
|
|
13
|
+
</svg>
|
package/electron/main.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// wisp desktop shell. The Node server runs inside Electron's main process and
|
|
2
2
|
// the window loads the same UI the browser mode serves.
|
|
3
3
|
|
|
4
|
-
import { app, BrowserWindow, shell, nativeTheme, Menu, dialog, ipcMain } from 'electron';
|
|
4
|
+
import { app, BrowserWindow, shell, nativeTheme, Menu, dialog, ipcMain, clipboard } from 'electron';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
@@ -76,6 +76,23 @@ function createWindow() {
|
|
|
76
76
|
app.setBadgeCount(n);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
// Electron has no right-click menu by default: give text and links the usual one.
|
|
80
|
+
win.webContents.on('context-menu', (_, p) => {
|
|
81
|
+
const f = p.editFlags;
|
|
82
|
+
const items = [
|
|
83
|
+
...p.dictionarySuggestions.map((w) => ({ label: w, click: () => win.webContents.replaceMisspelling(w) })),
|
|
84
|
+
...(p.misspelledWord ? [{ type: 'separator' }] : []),
|
|
85
|
+
...(p.linkURL ? [{ label: 'Copy Link', click: () => clipboard.writeText(p.linkURL) }, { type: 'separator' }] : []),
|
|
86
|
+
...(p.isEditable ? [{ role: 'undo', enabled: f.canUndo }, { role: 'redo', enabled: f.canRedo }, { type: 'separator' }] : []),
|
|
87
|
+
...(p.isEditable ? [{ role: 'cut', enabled: f.canCut }] : []),
|
|
88
|
+
...(p.isEditable || p.selectionText ? [{ role: 'copy', enabled: f.canCopy }] : []),
|
|
89
|
+
...(p.isEditable ? [{ role: 'paste', enabled: f.canPaste }, { role: 'pasteAndMatchStyle', enabled: f.canPaste }] : []),
|
|
90
|
+
{ role: 'selectAll', enabled: f.canSelectAll },
|
|
91
|
+
...(DEV ? [{ type: 'separator' }, { label: 'Inspect Element', click: () => win.webContents.inspectElement(p.x, p.y) }] : []),
|
|
92
|
+
];
|
|
93
|
+
Menu.buildFromTemplate(items).popup({ window: win });
|
|
94
|
+
});
|
|
95
|
+
|
|
79
96
|
win.on('closed', () => (win = null));
|
|
80
97
|
}
|
|
81
98
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wisp-agents",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Launch coding agents, skip the transcript, read the presentation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,15 +9,17 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"bin/",
|
|
12
|
+
"CHANGELOG.md",
|
|
13
|
+
"electron/",
|
|
12
14
|
"server/",
|
|
13
15
|
"web/"
|
|
14
16
|
],
|
|
15
17
|
"scripts": {
|
|
16
18
|
"start": "node bin/wisp.js",
|
|
17
|
-
"app": "
|
|
18
|
-
"dev": "WISP_DEV=1
|
|
19
|
+
"app": "node bin/wisp.js",
|
|
20
|
+
"dev": "WISP_DEV=1 node bin/wisp.js",
|
|
19
21
|
"dev:web": "node --watch-path=server bin/wisp.js --no-open",
|
|
20
|
-
"prepublishOnly": "node --check bin/wisp.js && node -e \"import('./server/index.js')\""
|
|
22
|
+
"prepublishOnly": "node --check bin/wisp.js && node --check bin/mac-app.js && node -e \"import('./server/index.js')\" && node --check electron/main.js"
|
|
21
23
|
},
|
|
22
24
|
"engines": {
|
|
23
25
|
"node": ">=20"
|
|
@@ -42,7 +44,7 @@
|
|
|
42
44
|
"url": "https://github.com/M0xoo/wisp/issues"
|
|
43
45
|
},
|
|
44
46
|
"main": "electron/main.js",
|
|
45
|
-
"
|
|
47
|
+
"optionalDependencies": {
|
|
46
48
|
"electron": "^44.8.0"
|
|
47
49
|
}
|
|
48
50
|
}
|
package/server/protocol.js
CHANGED
|
@@ -76,26 +76,94 @@ Strings must be valid JSON (escape newlines as \\n and quotes as \\"). Example:
|
|
|
76
76
|
\`\`\`
|
|
77
77
|
`.trim();
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
// Only the opener is matched by regex. The closing fence can't be: the JSON itself often
|
|
80
|
+
// contains ``` (a markdown block with a code sample), and a lazy match would cut it there.
|
|
81
|
+
const OPEN = /```wisp[^\n]*\n/g;
|
|
80
82
|
|
|
81
83
|
export function parsePresentation(text = '') {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const json =
|
|
84
|
+
const opens = [...text.matchAll(OPEN)];
|
|
85
|
+
for (let i = opens.length - 1; i >= 0; i--) {
|
|
86
|
+
const open = opens[i];
|
|
87
|
+
const json = readJson(text.slice(open.index + open[0].length));
|
|
86
88
|
const blocks = Array.isArray(json) ? json : json?.blocks;
|
|
87
89
|
if (Array.isArray(blocks) && blocks.length) {
|
|
88
|
-
const extra = text.slice(0,
|
|
90
|
+
const extra = text.slice(0, open.index).trim();
|
|
89
91
|
return { blocks: blocks.filter((b) => b && typeof b.type === 'string'), notes: extra || undefined };
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
|
-
// The agent ignored (or broke) the protocol — show its answer as plain markdown
|
|
94
|
+
// The agent ignored (or broke) the protocol — show its answer as plain markdown, with any
|
|
95
|
+
// broken wisp payload kept verbatim in a fence rather than half-rendered as prose.
|
|
96
|
+
const last = opens.at(-1);
|
|
97
|
+
let md = text.trim();
|
|
98
|
+
if (last) {
|
|
99
|
+
const prose = text.slice(0, last.index).trim();
|
|
100
|
+
const raw = text.slice(last.index + last[0].length).replace(/\n?```\s*$/, '');
|
|
101
|
+
md = `${prose}\n\n\`\`\`\`\`json\n${raw}\n\`\`\`\`\``.trim();
|
|
102
|
+
}
|
|
93
103
|
return {
|
|
94
104
|
fallback: true,
|
|
95
|
-
blocks: [{ type: 'markdown', text:
|
|
105
|
+
blocks: [{ type: 'markdown', text: md || '_The agent finished without a message._' }],
|
|
96
106
|
};
|
|
97
107
|
}
|
|
98
108
|
|
|
109
|
+
// Parse the JSON value at the start of `src` (the body after a ```wisp opener). First find its
|
|
110
|
+
// end by bracket matching, respecting strings; if that fails (e.g. a stray unescaped quote), try
|
|
111
|
+
// every ``` after it as the closing fence.
|
|
112
|
+
function readJson(src) {
|
|
113
|
+
const end = jsonEnd(src);
|
|
114
|
+
if (end > 0) {
|
|
115
|
+
const json = tryJson(src.slice(0, end));
|
|
116
|
+
if (json) return json;
|
|
117
|
+
}
|
|
118
|
+
for (let i = src.indexOf('```'); i !== -1; i = src.indexOf('```', i + 3)) {
|
|
119
|
+
const json = tryJson(src.slice(0, i));
|
|
120
|
+
if (json) return json;
|
|
121
|
+
}
|
|
122
|
+
return salvageBlocks(src);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Last resort: parse the blocks array one element at a time, so a single malformed block (an
|
|
126
|
+
// unescaped quote in a terminal output, say) costs that block rather than the whole answer.
|
|
127
|
+
function salvageBlocks(src) {
|
|
128
|
+
const m = /^\s*(\{\s*"blocks"\s*:\s*)?\[/.exec(src);
|
|
129
|
+
if (!m) return null;
|
|
130
|
+
const blocks = [];
|
|
131
|
+
let bad = 0;
|
|
132
|
+
let i = m[0].length;
|
|
133
|
+
while (i < src.length) {
|
|
134
|
+
while (/[\s,]/.test(src[i] || '')) i++;
|
|
135
|
+
if (src[i] !== '{') break;
|
|
136
|
+
const len = jsonEnd(src.slice(i));
|
|
137
|
+
if (len < 0) break;
|
|
138
|
+
const block = tryJson(src.slice(i, i + len));
|
|
139
|
+
if (block && typeof block === 'object') blocks.push(block);
|
|
140
|
+
else bad++;
|
|
141
|
+
i += len;
|
|
142
|
+
}
|
|
143
|
+
if (!blocks.length) return null;
|
|
144
|
+
if (bad) blocks.push({ type: 'callout', tone: 'warn', title: `${bad} block${bad > 1 ? 's' : ''} skipped`, text: 'Part of the agent\'s answer was malformed JSON and could not be shown.' });
|
|
145
|
+
return blocks;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function jsonEnd(src) {
|
|
149
|
+
const start = src.search(/\S/);
|
|
150
|
+
if (src[start] !== '{' && src[start] !== '[') return -1;
|
|
151
|
+
let depth = 0;
|
|
152
|
+
let inStr = false;
|
|
153
|
+
for (let i = start; i < src.length; i++) {
|
|
154
|
+
const c = src[i];
|
|
155
|
+
if (inStr) {
|
|
156
|
+
if (c === '\\') i++;
|
|
157
|
+
else if (c === '"') inStr = false;
|
|
158
|
+
} else if (c === '"') inStr = true;
|
|
159
|
+
else if (c === '{' || c === '[') depth++;
|
|
160
|
+
else if (c === '}' || c === ']') {
|
|
161
|
+
if (--depth === 0) return i + 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return -1;
|
|
165
|
+
}
|
|
166
|
+
|
|
99
167
|
function tryJson(src) {
|
|
100
168
|
try {
|
|
101
169
|
return JSON.parse(src);
|
package/server/runner.js
CHANGED
|
@@ -262,6 +262,7 @@ export class Runner extends EventEmitter {
|
|
|
262
262
|
};
|
|
263
263
|
} else {
|
|
264
264
|
turn.status = 'done';
|
|
265
|
+
turn.raw = ev.text; // kept so the turn can be re-parsed if the parser improves
|
|
265
266
|
turn.presentation = parsePresentation(ev.text);
|
|
266
267
|
const hero = heroOf(turn.presentation);
|
|
267
268
|
if (hero?.title && session.titleAuto && session.turns.length === 1) session.title = hero.title;
|
package/server/store.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import os from 'node:os';
|
|
6
|
+
import { parsePresentation } from './protocol.js';
|
|
6
7
|
|
|
7
8
|
const DEFAULT_CONFIG = {
|
|
8
9
|
projects: [], // [{ id, name, path, color, addedAt }]
|
|
@@ -24,6 +25,7 @@ export class Store {
|
|
|
24
25
|
if (!f.endsWith('.json')) continue;
|
|
25
26
|
try {
|
|
26
27
|
const s = JSON.parse(fs.readFileSync(path.join(this.dir, f), 'utf8'));
|
|
28
|
+
reparse(s);
|
|
27
29
|
this.sessions.set(s.id, s);
|
|
28
30
|
} catch (err) {
|
|
29
31
|
console.warn(`wisp: skipping unreadable session ${f}: ${err.message}`);
|
|
@@ -118,3 +120,13 @@ export class Store {
|
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
122
|
}
|
|
123
|
+
|
|
124
|
+
// Turns that fell back to plain text are re-parsed from their raw answer on load, so parser
|
|
125
|
+
// fixes repair old turns too.
|
|
126
|
+
function reparse(session) {
|
|
127
|
+
for (const t of session.turns || []) {
|
|
128
|
+
if (!t.presentation?.fallback || typeof t.raw !== 'string') continue;
|
|
129
|
+
const p = parsePresentation(t.raw);
|
|
130
|
+
if (!p.fallback) t.presentation = p;
|
|
131
|
+
}
|
|
132
|
+
}
|