wisp-agents 0.6.0 → 0.8.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 +52 -1
- package/README.md +90 -1
- package/bin/wisp.js +52 -5
- package/package.json +2 -2
- package/server/adapters/claude.js +4 -0
- package/server/adapters/codex.js +1 -0
- package/server/adapters/common.js +1 -1
- package/server/adapters/index.js +2 -1
- package/server/adapters/opencode.js +4 -0
- package/server/checkpoint.js +348 -0
- package/server/client.js +26 -2
- package/server/git.js +199 -0
- package/server/index.js +473 -21
- package/server/mcp-ask.js +75 -2
- package/server/mcp-wisp.js +7 -1
- package/server/openapi.js +168 -4
- package/server/preferences.js +296 -0
- package/server/protocol.js +11 -1
- package/server/recipes.js +64 -0
- package/server/remote.js +279 -0
- package/server/render.js +18 -0
- package/server/runner.js +153 -13
- package/server/store.js +9 -1
- package/server/subagents.js +85 -0
- package/server/triggers.js +268 -0
- package/server/update.js +136 -0
- package/server/usage.js +79 -0
- package/server/welcome.js +9 -1
- package/skills/wisp/SKILL.md +3 -2
- package/web/css/app.css +637 -14
- package/web/index.html +15 -3
- package/web/js/app.js +2060 -87
- package/web/js/blocks.js +396 -22
package/server/protocol.js
CHANGED
|
@@ -42,6 +42,13 @@ diff, the test run, the command output — over prose. Use file paths relative t
|
|
|
42
42
|
A test/lint/type-check run you actually executed. Prefer this over "terminal" for test runs.
|
|
43
43
|
- {"type":"commits","items":[{"hash":"a1b2c3d","message":"feat(auth): add rate limiter","files":3,"additions":120,"deletions":8}],"branch":"feat/limit"}
|
|
44
44
|
Commits you created, if any.
|
|
45
|
+
- {"type":"graph","title":"Refresh sessions in auth","ref":"#128","state":"open|draft|merged|closed","into":"main","merge":"conflict|ready|merged|blocked|pending",
|
|
46
|
+
"commits":[{"hash":"d29f50","message":"Add refresh tests","branch":"feat/refresh","author":"You","when":"now"},{"hash":"5be27d","message":"Rotate keys","branch":"main","author":"Lena","when":"6m","conflict":true}],
|
|
47
|
+
"conflicts":[{"file":"src/auth.ts","line":42,"commit":"5be27d","detail":"Both sides rewrote getSession()","ours":"refresh(token)","theirs":"rotate(key)","resolution":"Rotate first, then refresh"}],
|
|
48
|
+
"checks":[{"label":"Tests","value":"42 passed","status":"pass|fail|pending"}],"action":{"label":"Resolve conflicts","reply":"Merge main in and resolve the conflicts"}}
|
|
49
|
+
Branch graph, newest commit first; each commit's "branch" gives its lane ("into" is the base lane).
|
|
50
|
+
Use it to explain merge conflicts, a rebase, or where branches diverged. Mark the base commits that
|
|
51
|
+
conflict with "conflict":true; optional "parents":[hash,…] for merge commits.
|
|
45
52
|
- {"type":"tree","title":"…","items":[{"path":"src/auth/limiter.ts","change":"added","note":"…"}]}
|
|
46
53
|
A file tree; use for new modules/scaffolding where structure matters.
|
|
47
54
|
- {"type":"code","language":"ts","code":"…","caption":"…","file":"src/a.ts","line":10,"highlight":[3,4]}
|
|
@@ -58,6 +65,8 @@ diff, the test run, the command output — over prose. Use file paths relative t
|
|
|
58
65
|
A screenshot or generated image on disk.
|
|
59
66
|
- {"type":"question","question":"…","options":[{"label":"Short choice","reply":"full message sent back to you"}]}
|
|
60
67
|
When you need a decision. The user's click resumes you with the reply.
|
|
68
|
+
- {"type":"actions","title":"…","items":[{"label":"Run tests","command":"npm test"},{"label":"Follow up","reply":"…"}]}
|
|
69
|
+
One-click shell commands (executed in the project root) or follow-up actions the user may want to trigger.
|
|
61
70
|
- {"type":"next","items":["Suggested follow-up the user might send", "…"]}
|
|
62
71
|
Usually last. 2–4 concrete follow-ups.
|
|
63
72
|
|
|
@@ -82,7 +91,8 @@ const ASK_BLOCK = `- If a decision would change the rest of the work (ambiguous
|
|
|
82
91
|
The user's answer comes back as a follow-up in this same session.`;
|
|
83
92
|
|
|
84
93
|
// askTool: how the harness names wisp's injected ask_user tool, or null if it can't take one.
|
|
85
|
-
|
|
94
|
+
// ask: replaces the ask guidance outright (subagents, who have nobody to ask).
|
|
95
|
+
export const systemPrompt = ({ askTool = 'mcp__wisp__ask_user', ask } = {}) => PROTOCOL.replace('{{ASK}}', ask || (askTool ? ASK_TOOL(askTool) : ASK_BLOCK));
|
|
86
96
|
export const SYSTEM_PROMPT = systemPrompt();
|
|
87
97
|
|
|
88
98
|
// Only the opener is matched by regex. The closing fence can't be: the JSON itself often
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Recipes: saved prompts for tasks you send again and again. `{name}` in the prompt is a
|
|
2
|
+
// parameter, filled in when the recipe runs (the run dialog, `wisp run release 0.8.0`, a trigger).
|
|
3
|
+
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { newId } from './runner.js';
|
|
6
|
+
|
|
7
|
+
const PARAM = /\{([a-zA-Z][\w-]{0,30})\}/g;
|
|
8
|
+
const MODES = new Set(['plan', 'edits', 'auto']);
|
|
9
|
+
|
|
10
|
+
export const params = (prompt) => [...new Set([...String(prompt || '').matchAll(PARAM)].map((m) => m[1]))];
|
|
11
|
+
|
|
12
|
+
// strict: every parameter needs a value (a person ran it). Otherwise unknown ones become ''
|
|
13
|
+
// (a trigger filling in an empty issue body). Values are inserted once, never re-expanded.
|
|
14
|
+
export function fill(prompt, values = {}, { strict = true } = {}) {
|
|
15
|
+
const has = (k) => values[k] !== undefined && values[k] !== null && String(values[k]).trim() !== '';
|
|
16
|
+
const missing = params(prompt).filter((k) => !has(k));
|
|
17
|
+
if (strict && missing.length) throw Object.assign(new Error(`Missing ${missing.map((k) => `{${k}}`).join(', ')}`), { status: 400 });
|
|
18
|
+
return String(prompt).replace(PARAM, (_, k) => (has(k) ? String(values[k]) : ''));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Fill positional CLI words into the parameters in the order they appear.
|
|
22
|
+
export function positional(prompt, words = []) {
|
|
23
|
+
const out = {};
|
|
24
|
+
params(prompt).forEach((k, i) => words[i] !== undefined && (out[k] = words[i]));
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const bad = (msg) => Object.assign(new Error(msg), { status: 400 });
|
|
29
|
+
|
|
30
|
+
export function normalizeRecipe(body, prev = null) {
|
|
31
|
+
const name = String(body.name ?? prev?.name ?? '').trim().slice(0, 60);
|
|
32
|
+
const prompt = String(body.prompt ?? prev?.prompt ?? '').trim();
|
|
33
|
+
if (!name) throw bad('A recipe needs a name.');
|
|
34
|
+
if (!prompt) throw bad('A recipe needs a prompt.');
|
|
35
|
+
const cwd = body.cwd !== undefined ? String(body.cwd || '') : prev?.cwd || '';
|
|
36
|
+
if (cwd && !path.isAbsolute(cwd)) throw bad('cwd must be an absolute project path (or empty for any project).');
|
|
37
|
+
const mode = body.mode ?? prev?.mode ?? '';
|
|
38
|
+
if (mode && !MODES.has(mode)) throw bad('mode must be plan, edits or auto.');
|
|
39
|
+
const pick = (k) => String(body[k] ?? prev?.[k] ?? '');
|
|
40
|
+
return {
|
|
41
|
+
id: prev?.id || newId(),
|
|
42
|
+
name,
|
|
43
|
+
prompt,
|
|
44
|
+
cwd, // '' = whichever project it's run in
|
|
45
|
+
agent: pick('agent'),
|
|
46
|
+
model: pick('model'),
|
|
47
|
+
effort: /^[\w-]{0,20}$/.test(pick('effort')) ? pick('effort') : '',
|
|
48
|
+
mode,
|
|
49
|
+
createdAt: prev?.createdAt || Date.now(),
|
|
50
|
+
updatedAt: Date.now(),
|
|
51
|
+
runs: prev?.runs || 0,
|
|
52
|
+
lastRunAt: prev?.lastRunAt || null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// By id, else by name (case- and punctuation-insensitive), so `wisp run "Release"` and
|
|
57
|
+
// `wisp run release` both work.
|
|
58
|
+
export function findRecipe(recipes, key) {
|
|
59
|
+
const k = String(key || '');
|
|
60
|
+
const loose = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
61
|
+
return recipes.find((r) => r.id === k) || recipes.find((r) => loose(r.name) === loose(k)) || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const recipeView = (r) => ({ ...r, params: params(r.prompt) });
|
package/server/remote.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Phone: push "needs you" moments to ntfy or Telegram and take the answers back. Both run on
|
|
2
|
+
// outbound connections only (wisp polls them), so nothing has to reach this machine.
|
|
3
|
+
//
|
|
4
|
+
// ntfy notification with up to 3 buttons. A tap makes the ntfy app POST a signed answer to
|
|
5
|
+
// <topic>-reply, which wisp polls. No free text (ntfy has no reply box).
|
|
6
|
+
// Telegram a bot message with buttons; reply to the message to answer in your own words or
|
|
7
|
+
// send a follow-up. Linked to one chat with a one-time code.
|
|
8
|
+
|
|
9
|
+
import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
const KIND = { a: 'answer', r: 'reply', d: 'done' };
|
|
12
|
+
const CODE = { answer: 'a', reply: 'r', done: 'd' };
|
|
13
|
+
const MAX_TG_IDS = 20;
|
|
14
|
+
const sleep = (ms, signal) => new Promise((r) => (signal?.aborted ? r() : (setTimeout(r, ms).unref?.(), signal?.addEventListener('abort', r, { once: true }))));
|
|
15
|
+
const clip = (s, n) => (String(s || '').length > n ? `${String(s).slice(0, n - 1)}…` : String(s || ''));
|
|
16
|
+
export const mask = (t) => (t ? `••••${String(t).slice(-4)}` : '');
|
|
17
|
+
|
|
18
|
+
export class Remote {
|
|
19
|
+
// act({ sessionId, kind: answer|reply|done|text, ref, index, text }) → a short result line.
|
|
20
|
+
constructor({ store, act, onChange = () => {} }) {
|
|
21
|
+
Object.assign(this, { store, act, onChange });
|
|
22
|
+
this.status = { ntfy: null, telegram: null };
|
|
23
|
+
this.ac = null;
|
|
24
|
+
this.ntfyPollMs = 4000;
|
|
25
|
+
this.tgLongPoll = 25;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get cfg() {
|
|
29
|
+
return (this.store.config.remote ||= {});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get secret() {
|
|
33
|
+
if (!this.cfg.secret) {
|
|
34
|
+
this.cfg.secret = randomBytes(16).toString('hex');
|
|
35
|
+
this.store.saveConfig();
|
|
36
|
+
}
|
|
37
|
+
return this.cfg.secret;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get on() {
|
|
41
|
+
const c = this.cfg;
|
|
42
|
+
return !!((c.ntfy?.enabled && c.ntfy.topic) || (c.telegram?.enabled && c.telegram.token));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
start() {
|
|
46
|
+
this.stop();
|
|
47
|
+
const ac = (this.ac = new AbortController());
|
|
48
|
+
const c = this.cfg;
|
|
49
|
+
if (c.ntfy?.enabled && c.ntfy.topic) this.#ntfyLoop(ac.signal);
|
|
50
|
+
if (c.telegram?.enabled && c.telegram.token) {
|
|
51
|
+
if (!c.telegram.chatId && !c.telegram.linkCode) (c.telegram.linkCode = String(randomInt(100000, 999999))), this.store.saveConfig();
|
|
52
|
+
this.#tgLoop(ac.signal);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
stop() {
|
|
57
|
+
this.ac?.abort();
|
|
58
|
+
this.ac = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// What the phone gets for a session that needs you, and the buttons under it.
|
|
62
|
+
message(s, summary) {
|
|
63
|
+
const where = `${summary?.project || ''}: ${s.title}`;
|
|
64
|
+
if (s.ask) {
|
|
65
|
+
return {
|
|
66
|
+
title: `❓ ${where}`,
|
|
67
|
+
text: [s.ask.question, s.ask.context && clip(s.ask.context, 600)].filter(Boolean).join('\n\n'),
|
|
68
|
+
actions: (s.ask.options || []).map((label, index) => ({ label, kind: 'answer', ref: s.ask.id, index })),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const turn = s.turns.at(-1);
|
|
72
|
+
const blocks = turn?.presentation?.blocks || [];
|
|
73
|
+
const hero = blocks.find((b) => b?.type === 'hero');
|
|
74
|
+
const q = blocks.findLast((b) => b?.type === 'question');
|
|
75
|
+
const icon = { done: '✅', partial: '🟡', blocked: '⛔' }[hero?.status] || (turn?.status === 'error' ? '⛔' : '📝');
|
|
76
|
+
const options = (q?.options || []).map((o) => (typeof o === 'string' ? { label: o } : o));
|
|
77
|
+
return {
|
|
78
|
+
title: `${icon} ${where}`,
|
|
79
|
+
text: [hero?.title && hero.title !== s.title ? hero.title : '', hero?.summary || summary?.preview || 'Ready for review.', q?.question].filter(Boolean).join('\n\n'),
|
|
80
|
+
actions: options.length ? options.map((o, index) => ({ label: o.label || String(o.reply || ''), kind: 'reply', ref: turn.id, index })) : [{ label: 'Mark done', kind: 'done', ref: turn?.id || '', index: 0 }],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async notify(s, summary) {
|
|
85
|
+
if (!this.on || !s || s.agent === 'wisp') return;
|
|
86
|
+
const event = s.state === 'waiting' ? 'waiting' : 'finished';
|
|
87
|
+
if (!(this.cfg.events || ['waiting', 'finished']).includes(event)) return;
|
|
88
|
+
await this.send(this.message(s, summary), s);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async send(msg, s = null) {
|
|
92
|
+
const c = this.cfg;
|
|
93
|
+
const jobs = [];
|
|
94
|
+
if (c.ntfy?.enabled && c.ntfy.topic) jobs.push(this.#ntfyPublish(msg, s).then(() => this.#mark('ntfy', { ok: true }), (e) => this.#mark('ntfy', { ok: false, error: e.message })));
|
|
95
|
+
if (c.telegram?.enabled && c.telegram.token && c.telegram.chatId) jobs.push(this.#tgPublish(msg, s).then(() => this.#mark('telegram', { ok: true }), (e) => this.#mark('telegram', { ok: false, error: e.message })));
|
|
96
|
+
await Promise.all(jobs);
|
|
97
|
+
return jobs.length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#mark(channel, st) {
|
|
101
|
+
this.status[channel] = { ...this.status[channel], ...st, at: Date.now() };
|
|
102
|
+
this.onChange();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- answers: signed, so a stranger who guesses the topic can't steer your agents ------------
|
|
106
|
+
|
|
107
|
+
sign(sessionId, kind, ref, index) {
|
|
108
|
+
return createHmac('sha256', this.secret).update(`${sessionId}.${kind}.${ref}.${index}`).digest('base64url').slice(0, 22);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
verify(p) {
|
|
112
|
+
if (!p || typeof p !== 'object' || !KIND[p.k] || typeof p.sig !== 'string') return false;
|
|
113
|
+
const want = Buffer.from(this.sign(p.s, KIND[p.k], p.r, p.i));
|
|
114
|
+
const got = Buffer.from(p.sig);
|
|
115
|
+
return want.length === got.length && timingSafeEqual(want, got);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- ntfy ------------------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
#ntfy() {
|
|
121
|
+
const n = this.cfg.ntfy;
|
|
122
|
+
const server = String(n.server || 'https://ntfy.sh').replace(/\/+$/, '');
|
|
123
|
+
return { server, topic: n.topic, headers: n.token ? { authorization: `Bearer ${n.token}` } : {} };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async #ntfyPublish(msg, s) {
|
|
127
|
+
const { server, topic, headers } = this.#ntfy();
|
|
128
|
+
const actions = (s ? msg.actions : []).slice(0, 3).map((a) => {
|
|
129
|
+
const k = CODE[a.kind];
|
|
130
|
+
return { action: 'http', label: clip(a.label, 40), url: `${server}/${topic}-reply`, method: 'POST', headers, body: JSON.stringify({ s: s.id, k, r: a.ref, i: a.index, sig: this.sign(s.id, a.kind, a.ref, a.index) }), clear: true };
|
|
131
|
+
});
|
|
132
|
+
const r = await fetch(server, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
135
|
+
body: JSON.stringify({ topic, title: clip(msg.title, 200), message: clip(msg.text, 3000), tags: ['wisp'], ...(actions.length && { actions }) }),
|
|
136
|
+
signal: AbortSignal.timeout(10_000),
|
|
137
|
+
});
|
|
138
|
+
if (!r.ok) throw new Error(`ntfy: ${r.status} ${(await r.text().catch(() => '')).slice(0, 120)}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async #ntfyLoop(signal) {
|
|
142
|
+
let since = String(Math.floor(Date.now() / 1000));
|
|
143
|
+
while (!signal.aborted) {
|
|
144
|
+
try {
|
|
145
|
+
const { server, topic, headers } = this.#ntfy();
|
|
146
|
+
const r = await fetch(`${server}/${topic}-reply/json?poll=1&since=${since}`, { headers, signal });
|
|
147
|
+
if (!r.ok) throw new Error(`ntfy: ${r.status}`);
|
|
148
|
+
for (const line of (await r.text()).split('\n')) {
|
|
149
|
+
if (!line.trim()) continue;
|
|
150
|
+
const m = JSON.parse(line);
|
|
151
|
+
if (m.id) since = m.id;
|
|
152
|
+
if (m.event !== 'message') continue;
|
|
153
|
+
let p = null;
|
|
154
|
+
try {
|
|
155
|
+
p = JSON.parse(m.message);
|
|
156
|
+
} catch {}
|
|
157
|
+
if (!this.verify(p)) continue; // not ours, or forged
|
|
158
|
+
await this.act({ sessionId: p.s, kind: KIND[p.k], ref: p.r, index: Number(p.i) }).catch(() => {});
|
|
159
|
+
}
|
|
160
|
+
if (this.status.ntfy?.error) this.#mark('ntfy', { ok: true, error: '' });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (signal.aborted) return;
|
|
163
|
+
this.#mark('ntfy', { ok: false, error: err.message });
|
|
164
|
+
}
|
|
165
|
+
await sleep(this.ntfyPollMs, signal);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- Telegram --------------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
async #tg(method, body, signal) {
|
|
172
|
+
const base = (process.env.WISP_TELEGRAM_API || 'https://api.telegram.org').replace(/\/+$/, '');
|
|
173
|
+
const r = await fetch(`${base}/bot${this.cfg.telegram.token}/${method}`, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { 'content-type': 'application/json' },
|
|
176
|
+
body: JSON.stringify(body),
|
|
177
|
+
signal: signal || AbortSignal.timeout(15_000),
|
|
178
|
+
});
|
|
179
|
+
const j = await r.json().catch(() => ({}));
|
|
180
|
+
if (!j.ok) throw new Error(`Telegram: ${j.description || r.status}`);
|
|
181
|
+
return j.result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async #tgPublish(msg, s) {
|
|
185
|
+
const buttons = (s ? msg.actions : []).map((a) => [{ text: clip(a.label, 60), callback_data: `${CODE[a.kind]}:${s.id}:${a.ref}:${a.index}` }]);
|
|
186
|
+
const sent = await this.#tg('sendMessage', {
|
|
187
|
+
chat_id: this.cfg.telegram.chatId,
|
|
188
|
+
text: clip(`${msg.title}\n\n${msg.text}${s ? '\n\n↩ Reply to this message to answer or follow up.' : ''}`, 4000),
|
|
189
|
+
...(buttons.length && { reply_markup: { inline_keyboard: buttons } }),
|
|
190
|
+
});
|
|
191
|
+
if (s && sent?.message_id) {
|
|
192
|
+
s.remote = { ...s.remote, tg: [...(s.remote?.tg || []), sent.message_id].slice(-MAX_TG_IDS) };
|
|
193
|
+
this.store.save(s);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async #tgLoop(signal) {
|
|
198
|
+
let offset = 0;
|
|
199
|
+
while (!signal.aborted) {
|
|
200
|
+
try {
|
|
201
|
+
const updates = await this.#tg('getUpdates', { offset, timeout: this.tgLongPoll, allowed_updates: ['message', 'callback_query'] }, signal);
|
|
202
|
+
if (this.status.telegram?.error) this.#mark('telegram', { ok: true, error: '' });
|
|
203
|
+
for (const u of updates) {
|
|
204
|
+
offset = u.update_id + 1;
|
|
205
|
+
await this.#fromTelegram(u).catch((err) => console.warn(`wisp: telegram: ${err.message}`));
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (signal.aborted) return;
|
|
209
|
+
this.#mark('telegram', { ok: false, error: err.message });
|
|
210
|
+
await sleep(5000, signal);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async #fromTelegram(u) {
|
|
216
|
+
const t = this.cfg.telegram;
|
|
217
|
+
const say = (text, extra = {}) => this.#tg('sendMessage', { chat_id: t.chatId, text, ...extra });
|
|
218
|
+
if (u.callback_query) {
|
|
219
|
+
const q = u.callback_query;
|
|
220
|
+
if (!t.chatId || String(q.message?.chat?.id) !== String(t.chatId)) return;
|
|
221
|
+
const [k, sessionId, ref, i] = String(q.data || '').split(':');
|
|
222
|
+
const result = KIND[k] ? await this.act({ sessionId, kind: KIND[k], ref, index: Number(i) }) : 'Unknown button.';
|
|
223
|
+
await this.#tg('answerCallbackQuery', { callback_query_id: q.id, text: clip(result, 190) }).catch(() => {});
|
|
224
|
+
await this.#tg('editMessageReplyMarkup', { chat_id: t.chatId, message_id: q.message.message_id, reply_markup: { inline_keyboard: [] } }).catch(() => {});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const m = u.message;
|
|
228
|
+
if (!m?.text) return;
|
|
229
|
+
if (!t.chatId) {
|
|
230
|
+
// Linking: only the chat that sends the code shown in wisp's settings gets attached.
|
|
231
|
+
if (t.linkCode && m.text.replace(/^\/(start|link)\s*/, '').trim() === t.linkCode) {
|
|
232
|
+
Object.assign(t, { chatId: m.chat.id, chatName: m.chat.title || [m.chat.first_name, m.chat.last_name].filter(Boolean).join(' ') || m.chat.username || '', linkCode: '' });
|
|
233
|
+
this.store.saveConfig();
|
|
234
|
+
this.#mark('telegram', { ok: true, error: '' });
|
|
235
|
+
await say('✓ Linked to wisp. Questions and finished tasks will show up here.');
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (String(m.chat.id) !== String(t.chatId)) return;
|
|
240
|
+
const replyTo = m.reply_to_message?.message_id;
|
|
241
|
+
const s = replyTo && this.store.all().find((x) => x.remote?.tg?.includes(replyTo));
|
|
242
|
+
if (!s) return say('Reply to one of wisp’s messages to answer it or send a follow-up.');
|
|
243
|
+
await say(await this.act({ sessionId: s.id, kind: 'text', text: m.text }), { reply_to_message_id: m.message_id });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// For the settings view: config with secrets masked, plus channel health.
|
|
247
|
+
view() {
|
|
248
|
+
const c = this.cfg;
|
|
249
|
+
return {
|
|
250
|
+
events: c.events || ['waiting', 'finished'],
|
|
251
|
+
ntfy: { enabled: !!c.ntfy?.enabled, server: c.ntfy?.server || 'https://ntfy.sh', topic: c.ntfy?.topic || '', token: mask(c.ntfy?.token) },
|
|
252
|
+
telegram: { enabled: !!c.telegram?.enabled, token: mask(c.telegram?.token), linked: !!c.telegram?.chatId, chatName: c.telegram?.chatName || '', linkCode: c.telegram?.chatId ? '' : c.telegram?.linkCode || '' },
|
|
253
|
+
status: this.status,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// PUT body: { events, ntfy: {…}, telegram: {…, unlink} }. Masked tokens mean "unchanged".
|
|
258
|
+
update(body = {}) {
|
|
259
|
+
const c = this.cfg;
|
|
260
|
+
const keep = (v, prev) => (typeof v === 'string' && v.startsWith('••••') ? prev : v);
|
|
261
|
+
if (Array.isArray(body.events)) c.events = body.events.filter((e) => e === 'waiting' || e === 'finished');
|
|
262
|
+
if (body.ntfy) {
|
|
263
|
+
const n = { ...c.ntfy, ...body.ntfy };
|
|
264
|
+
n.token = keep(body.ntfy.token, c.ntfy?.token) || '';
|
|
265
|
+
if (n.server && !/^https?:\/\/\S+$/.test(n.server)) throw Object.assign(new Error('ntfy server must be an http(s) URL'), { status: 400 });
|
|
266
|
+
if (n.topic && !/^[\w-]{1,64}$/.test(n.topic)) throw Object.assign(new Error('ntfy topic: letters, digits, - and _ only'), { status: 400 });
|
|
267
|
+
c.ntfy = { enabled: !!n.enabled, server: n.server || 'https://ntfy.sh', topic: n.topic || '', token: n.token };
|
|
268
|
+
}
|
|
269
|
+
if (body.telegram) {
|
|
270
|
+
const prev = c.telegram || {};
|
|
271
|
+
const token = keep(body.telegram.token, prev.token) || '';
|
|
272
|
+
const relink = body.telegram.unlink || token !== prev.token;
|
|
273
|
+
c.telegram = { ...prev, enabled: !!(body.telegram.enabled ?? prev.enabled), token, ...(relink && { chatId: null, chatName: '', linkCode: token ? String(randomInt(100000, 999999)) : '' }) };
|
|
274
|
+
}
|
|
275
|
+
this.store.saveConfig();
|
|
276
|
+
this.start();
|
|
277
|
+
return this.view();
|
|
278
|
+
}
|
|
279
|
+
}
|
package/server/render.js
CHANGED
|
@@ -55,6 +55,18 @@ const R = {
|
|
|
55
55
|
`${b.branch ? `**Branch** \`${b.branch}\`\n\n` : ''}${list(b.items)
|
|
56
56
|
.map((c) => `- \`${str(c.hash).slice(0, 7)}\` ${str(c.message)}${c.additions !== undefined ? ` (+${c.additions}/−${c.deletions ?? 0})` : ''}`)
|
|
57
57
|
.join('\n')}`,
|
|
58
|
+
graph: (b) => {
|
|
59
|
+
const head = `**${str(b.title) || 'Branches'}**${b.ref ? ` ${b.ref}` : ''}${b.state ? ` · ${b.state}` : ''}${b.into ? ` → \`${b.into}\`${b.merge ? ` (${b.merge})` : ''}` : ''}`;
|
|
60
|
+
const commits = list(b.commits).map((c) => `- ${c.conflict ? '⚠️ ' : ''}\`${str(c.hash).slice(0, 7)}\` ${str(c.message)}${c.branch ? ` [${c.branch}]` : ''}${c.author ? ` — ${c.author}${c.when ? `, ${c.when}` : ''}` : ''}`);
|
|
61
|
+
const conflicts = list(b.conflicts).map((c, i) => {
|
|
62
|
+
const o = typeof c === 'string' ? { file: c } : c || {};
|
|
63
|
+
return `${i + 1}. \`${str(o.file)}${o.line || o.lines ? `:${o.line || o.lines}` : ''}\`${o.detail ? ` — ${o.detail}` : ''}${o.ours ? `\n - ours: \`${o.ours}\`` : ''}${o.theirs ? `\n - theirs: \`${o.theirs}\`` : ''}${o.resolution ? `\n - ✔︎ ${o.resolution}` : ''}`;
|
|
64
|
+
});
|
|
65
|
+
const checks = list(b.checks).map((c) => `${c.status === 'fail' ? '❌' : c.status === 'pending' ? '⏳' : '✅'} ${str(c.label || c)}${c.value ? ` ${c.value}` : ''}`);
|
|
66
|
+
return [head, commits.join('\n'), conflicts.length ? `**Conflicts**\n${conflicts.join('\n')}` : '', checks.join(' · '), b.summary ? str(b.summary) : '']
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.join('\n\n');
|
|
69
|
+
},
|
|
58
70
|
terminal: (b) => fence(`$ ${str(b.command)}\n${str(b.output)}${b.exitCode !== undefined && b.exitCode !== 0 ? `\n[exit ${b.exitCode}]` : ''}`, 'console'),
|
|
59
71
|
compare: (b) => ['before', 'after'].map((k) => `**${str(b[k]?.label) || (k === 'before' ? 'Before' : 'After')}**\n${list(b[k]?.items).map((i) => `- ${str(i)}`).join('\n')}`).join('\n\n'),
|
|
60
72
|
flow: (b) => {
|
|
@@ -68,6 +80,10 @@ const R = {
|
|
|
68
80
|
callout: (b) => `> **${b.tone === 'danger' ? '⛔' : b.tone === 'warn' ? '⚠️' : b.tone === 'success' ? '✅' : 'ℹ️'} ${str(b.title)}**\n> ${str(b.text).replace(/\n/g, '\n> ')}`,
|
|
69
81
|
image: (b) => `🖼️ \`${str(b.path || b.src)}\`${b.caption ? ` — ${b.caption}` : ''}`,
|
|
70
82
|
question: (b) => `**❓ ${str(b.question)}**\n${list(b.options).map((o, i) => `${i + 1}. ${str(o.label || o)}`).join('\n')}`,
|
|
83
|
+
actions: (b) =>
|
|
84
|
+
`${b.title ? `**${b.title}**\n` : '**Actions**\n'}${list(b.items)
|
|
85
|
+
.map((a) => (typeof a === 'string' ? `- ${a}` : `- **${str(a.label || a.command)}**${a.command ? ` — \`$ ${str(a.command)}\`` : a.reply ? ` — _${str(a.reply)}_` : ''}${a.detail ? ` (${str(a.detail)})` : ''}`))
|
|
86
|
+
.join('\n')}`,
|
|
71
87
|
next: (b) => `**Next**\n${list(b.items).map((i) => `- ${str(i)}`).join('\n')}`,
|
|
72
88
|
};
|
|
73
89
|
|
|
@@ -96,5 +112,7 @@ export function sessionToMarkdown(session) {
|
|
|
96
112
|
out.push(`**❓ The agent is waiting for your answer:** ${session.ask.question}${session.ask.context ? `\n\n${session.ask.context}` : ''}`);
|
|
97
113
|
if (session.ask.options?.length) out.push(session.ask.options.map((o, i) => `${i + 1}. ${o}`).join('\n'));
|
|
98
114
|
}
|
|
115
|
+
// Worktree sessions: say where the changes are, since they aren't in the caller's checkout.
|
|
116
|
+
if (session.worktree && turn?.status !== 'running') out.push(`_Changes are on branch \`${session.worktree.branch}\` in the worktree \`${session.worktree.path}\`${session.pr ? ` · PR #${session.pr.number} (${String(session.pr.state).toLowerCase()}): ${session.pr.url}` : ''}._`);
|
|
99
117
|
return out.join('\n\n');
|
|
100
118
|
}
|