wispy-cli 1.1.2 ā 1.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/bin/wispy.mjs +225 -0
- package/core/deploy.mjs +292 -0
- package/core/engine.mjs +112 -58
- package/core/harness.mjs +531 -0
- package/core/index.mjs +2 -0
- package/lib/channels/index.mjs +229 -4
- package/lib/wispy-tui.mjs +430 -30
- package/package.json +2 -2
package/lib/channels/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Channel Manager ā loads config, starts/stops adapters, routes messages
|
|
3
|
-
*
|
|
3
|
+
* v1.2: Harness-integrated channel approval flow (Telegram text, Discord buttons, Slack Block Kit)
|
|
4
4
|
*
|
|
5
5
|
* Config: ~/.wispy/channels.json
|
|
6
6
|
* {
|
|
@@ -45,6 +45,207 @@ export async function saveChannelsConfig(cfg) {
|
|
|
45
45
|
|
|
46
46
|
let _sharedEngine = null;
|
|
47
47
|
|
|
48
|
+
// Pending approval callbacks keyed by a unique request ID
|
|
49
|
+
// { [reqId]: { resolve, timeout } }
|
|
50
|
+
const _pendingApprovals = new Map();
|
|
51
|
+
|
|
52
|
+
let _approvalIdCounter = 0;
|
|
53
|
+
|
|
54
|
+
function makeApprovalId() {
|
|
55
|
+
return `appr-${Date.now().toString(36)}-${(++_approvalIdCounter).toString(36)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Format an approval request message for text-based channels (Telegram, etc.)
|
|
62
|
+
*/
|
|
63
|
+
function formatApprovalText(action, reqId) {
|
|
64
|
+
const riskMap = {
|
|
65
|
+
run_command: "HIGH š“",
|
|
66
|
+
git: "HIGH š“",
|
|
67
|
+
keychain: "HIGH š“",
|
|
68
|
+
delete_file: "HIGH š“",
|
|
69
|
+
write_file: "MEDIUM š”",
|
|
70
|
+
file_edit: "MEDIUM š”",
|
|
71
|
+
};
|
|
72
|
+
const risk = riskMap[action.toolName] ?? "MEDIUM š”";
|
|
73
|
+
const args = action.args ?? {};
|
|
74
|
+
const detail = args.command
|
|
75
|
+
? `š Command: \`${args.command}\``
|
|
76
|
+
: args.path
|
|
77
|
+
? `š Path: \`${args.path}\``
|
|
78
|
+
: `š Args: \`${JSON.stringify(args).slice(0, 80)}\``;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
`ā ļø *Permission Required*\n\n` +
|
|
82
|
+
`š§ Tool: \`${action.toolName}\`\n` +
|
|
83
|
+
`${detail}\n` +
|
|
84
|
+
`ā” Risk: ${risk}\n` +
|
|
85
|
+
`š Request: \`${reqId}\`\n\n` +
|
|
86
|
+
`Reply with:\n` +
|
|
87
|
+
`ā
\`yes\` to approve\n` +
|
|
88
|
+
`ā \`no\` to deny\n` +
|
|
89
|
+
`šļø \`preview\` for dry-run`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Handle an incoming approval response from a user (text reply).
|
|
95
|
+
* Returns true if this was an approval reply.
|
|
96
|
+
*/
|
|
97
|
+
function handleApprovalReply(text) {
|
|
98
|
+
const normalized = text.trim().toLowerCase();
|
|
99
|
+
// Check for pending approvals ā any pending if user says yes/no/preview
|
|
100
|
+
if (!["yes", "no", "preview"].includes(normalized)) return false;
|
|
101
|
+
|
|
102
|
+
// Resolve the most recent pending approval
|
|
103
|
+
const entries = [..._pendingApprovals.entries()];
|
|
104
|
+
if (entries.length === 0) return false;
|
|
105
|
+
|
|
106
|
+
// Take the oldest unresolved (FIFO)
|
|
107
|
+
const [reqId, pending] = entries[0];
|
|
108
|
+
_pendingApprovals.delete(reqId);
|
|
109
|
+
clearTimeout(pending.timeout);
|
|
110
|
+
|
|
111
|
+
if (normalized === "yes") {
|
|
112
|
+
pending.resolve({ approved: true, dryRun: false });
|
|
113
|
+
} else if (normalized === "preview") {
|
|
114
|
+
pending.resolve({ approved: false, dryRun: true });
|
|
115
|
+
} else {
|
|
116
|
+
pending.resolve({ approved: false, dryRun: false });
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Create a channel-aware approval handler for the permission manager.
|
|
123
|
+
* Sends an approval request to the channel and waits for reply.
|
|
124
|
+
*/
|
|
125
|
+
function makeChannelApprovalHandler(adapter, chatId, channelType) {
|
|
126
|
+
return async (action) => {
|
|
127
|
+
const reqId = makeApprovalId();
|
|
128
|
+
|
|
129
|
+
return new Promise(async (resolve) => {
|
|
130
|
+
// Timeout: auto-deny after 5 minutes
|
|
131
|
+
const timeout = setTimeout(() => {
|
|
132
|
+
_pendingApprovals.delete(reqId);
|
|
133
|
+
resolve(false);
|
|
134
|
+
}, APPROVAL_TIMEOUT_MS);
|
|
135
|
+
|
|
136
|
+
_pendingApprovals.set(reqId, { resolve: ({ approved }) => resolve(approved), timeout });
|
|
137
|
+
|
|
138
|
+
// Send approval request
|
|
139
|
+
try {
|
|
140
|
+
if (channelType === "discord") {
|
|
141
|
+
await _sendDiscordApprovalRequest(adapter, chatId, action, reqId, (result) => {
|
|
142
|
+
_pendingApprovals.delete(reqId);
|
|
143
|
+
clearTimeout(timeout);
|
|
144
|
+
resolve(result.approved);
|
|
145
|
+
});
|
|
146
|
+
} else if (channelType === "slack") {
|
|
147
|
+
await _sendSlackApprovalRequest(adapter, chatId, action, reqId, (result) => {
|
|
148
|
+
_pendingApprovals.delete(reqId);
|
|
149
|
+
clearTimeout(timeout);
|
|
150
|
+
resolve(result.approved);
|
|
151
|
+
});
|
|
152
|
+
} else {
|
|
153
|
+
// Telegram and fallback ā text-based
|
|
154
|
+
const msg = formatApprovalText(action, reqId);
|
|
155
|
+
await adapter.sendMessage(chatId, msg);
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error(`[channels] Failed to send approval request: ${err.message}`);
|
|
159
|
+
_pendingApprovals.delete(reqId);
|
|
160
|
+
clearTimeout(timeout);
|
|
161
|
+
resolve(false);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function _sendDiscordApprovalRequest(adapter, chatId, action, reqId, callback) {
|
|
168
|
+
// Discord: send a message with action row buttons
|
|
169
|
+
try {
|
|
170
|
+
const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = await import("discord.js");
|
|
171
|
+
const row = new ActionRowBuilder().addComponents(
|
|
172
|
+
new ButtonBuilder()
|
|
173
|
+
.setCustomId(`wispy_approve_${reqId}`)
|
|
174
|
+
.setLabel("ā
Approve")
|
|
175
|
+
.setStyle(ButtonStyle.Success),
|
|
176
|
+
new ButtonBuilder()
|
|
177
|
+
.setCustomId(`wispy_deny_${reqId}`)
|
|
178
|
+
.setLabel("ā Deny")
|
|
179
|
+
.setStyle(ButtonStyle.Danger),
|
|
180
|
+
new ButtonBuilder()
|
|
181
|
+
.setCustomId(`wispy_preview_${reqId}`)
|
|
182
|
+
.setLabel("šļø Preview")
|
|
183
|
+
.setStyle(ButtonStyle.Secondary),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const riskMap = { run_command: "HIGH š“", git: "HIGH š“", keychain: "HIGH š“", write_file: "MEDIUM š”", file_edit: "MEDIUM š”" };
|
|
187
|
+
const risk = riskMap[action.toolName] ?? "MEDIUM š”";
|
|
188
|
+
const args = action.args ?? {};
|
|
189
|
+
const detail = args.command ? `Command: \`${args.command}\`` : args.path ? `Path: \`${args.path}\`` : `Args: \`${JSON.stringify(args).slice(0, 60)}\``;
|
|
190
|
+
|
|
191
|
+
const channel = await adapter._client?.channels?.fetch(chatId);
|
|
192
|
+
if (channel?.send) {
|
|
193
|
+
const sentMsg = await channel.send({
|
|
194
|
+
content: `ā ļø **Permission Required**\nš§ Tool: \`${action.toolName}\`\n${detail}\nā” Risk: ${risk}`,
|
|
195
|
+
components: [row],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Store callback for button interaction
|
|
199
|
+
adapter._approvalCallbacks = adapter._approvalCallbacks ?? new Map();
|
|
200
|
+
adapter._approvalCallbacks.set(reqId, { callback, sentMsg });
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
// Fallback to text
|
|
204
|
+
const msg = formatApprovalText(action, reqId);
|
|
205
|
+
await adapter.sendMessage(chatId, msg);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function _sendSlackApprovalRequest(adapter, chatId, action, reqId, callback) {
|
|
210
|
+
// Slack: send Block Kit with buttons
|
|
211
|
+
try {
|
|
212
|
+
const riskMap = { run_command: "HIGH š“", git: "HIGH š“", keychain: "HIGH š“", write_file: "MEDIUM š”", file_edit: "MEDIUM š”" };
|
|
213
|
+
const risk = riskMap[action.toolName] ?? "MEDIUM š”";
|
|
214
|
+
const args = action.args ?? {};
|
|
215
|
+
const detail = args.command ? `*Command:* \`${args.command}\`` : args.path ? `*Path:* \`${args.path}\`` : `*Args:* \`${JSON.stringify(args).slice(0, 60)}\``;
|
|
216
|
+
|
|
217
|
+
const blocks = [
|
|
218
|
+
{
|
|
219
|
+
type: "section",
|
|
220
|
+
text: {
|
|
221
|
+
type: "mrkdwn",
|
|
222
|
+
text: `ā ļø *Permission Required*\nš§ Tool: \`${action.toolName}\`\n${detail}\nā” Risk: ${risk}`,
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
type: "actions",
|
|
227
|
+
elements: [
|
|
228
|
+
{ type: "button", text: { type: "plain_text", text: "ā
Approve" }, style: "primary", action_id: `wispy_approve_${reqId}` },
|
|
229
|
+
{ type: "button", text: { type: "plain_text", text: "ā Deny" }, style: "danger", action_id: `wispy_deny_${reqId}` },
|
|
230
|
+
{ type: "button", text: { type: "plain_text", text: "šļø Preview" }, action_id: `wispy_preview_${reqId}` },
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
if (adapter._app?.client) {
|
|
236
|
+
await adapter._app.client.chat.postMessage({ channel: chatId, blocks, text: `Permission required for ${action.toolName}` });
|
|
237
|
+
adapter._approvalCallbacks = adapter._approvalCallbacks ?? new Map();
|
|
238
|
+
adapter._approvalCallbacks.set(reqId, { callback });
|
|
239
|
+
} else {
|
|
240
|
+
const msg = formatApprovalText(action, reqId);
|
|
241
|
+
await adapter.sendMessage(chatId, msg);
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
const msg = formatApprovalText(action, reqId);
|
|
245
|
+
await adapter.sendMessage(chatId, msg);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
48
249
|
async function getEngine() {
|
|
49
250
|
if (_sharedEngine) return _sharedEngine;
|
|
50
251
|
|
|
@@ -65,15 +266,25 @@ Respond in the same language the user writes in.`;
|
|
|
65
266
|
/**
|
|
66
267
|
* Process a user message and return Wispy's response.
|
|
67
268
|
* Uses core/engine.mjs for AI and core/session.mjs for per-chat sessions.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} channelName
|
|
271
|
+
* @param {string|number} chatId
|
|
272
|
+
* @param {string} userText
|
|
273
|
+
* @param {{ adapter?, channelType? }} opts
|
|
68
274
|
*/
|
|
69
|
-
export async function processUserMessage(channelName, chatId, userText) {
|
|
275
|
+
export async function processUserMessage(channelName, chatId, userText, opts = {}) {
|
|
276
|
+
// Check if this is a pending approval reply
|
|
277
|
+
if (handleApprovalReply(userText)) {
|
|
278
|
+
return null; // handled ā no AI response needed
|
|
279
|
+
}
|
|
280
|
+
|
|
70
281
|
// Handle __CLEAR__ signal
|
|
71
282
|
if (userText === "__CLEAR__") {
|
|
72
283
|
const key = `${channelName}:${chatId}`;
|
|
73
284
|
const session = await sessionManager.getOrCreate(key, { channel: channelName });
|
|
74
285
|
sessionManager.clear(session.id);
|
|
75
286
|
await sessionManager.save(session.id);
|
|
76
|
-
return null;
|
|
287
|
+
return null;
|
|
77
288
|
}
|
|
78
289
|
|
|
79
290
|
const engine = await getEngine();
|
|
@@ -85,14 +296,25 @@ export async function processUserMessage(channelName, chatId, userText) {
|
|
|
85
296
|
const key = `${channelName}:${chatId}`;
|
|
86
297
|
const session = await sessionManager.getOrCreate(key, { channel: channelName, chatId: String(chatId) });
|
|
87
298
|
|
|
299
|
+
// Set up channel-specific approval handler
|
|
300
|
+
if (opts.adapter) {
|
|
301
|
+
engine.permissions.setApprovalHandler(
|
|
302
|
+
makeChannelApprovalHandler(opts.adapter, chatId, opts.channelType ?? channelName)
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
88
306
|
try {
|
|
89
307
|
const result = await engine.processMessage(session.id, userText, {
|
|
90
308
|
systemPrompt: CHANNEL_SYSTEM_PROMPT,
|
|
91
309
|
noSave: false,
|
|
310
|
+
channel: channelName,
|
|
92
311
|
});
|
|
93
312
|
return result.content;
|
|
94
313
|
} catch (err) {
|
|
95
314
|
return `ā Error: ${err.message.slice(0, 200)} šæ`;
|
|
315
|
+
} finally {
|
|
316
|
+
// Reset approval handler to CLI default after each message
|
|
317
|
+
engine.permissions.setApprovalHandler(null);
|
|
96
318
|
}
|
|
97
319
|
}
|
|
98
320
|
|
|
@@ -138,7 +360,10 @@ export class ChannelManager {
|
|
|
138
360
|
adapter.onMessage(async ({ chatId, userId, username, text }) => {
|
|
139
361
|
console.log(`[${name}] ${username}: ${text.slice(0, 80)}`);
|
|
140
362
|
try {
|
|
141
|
-
const reply = await processUserMessage(name, chatId, text
|
|
363
|
+
const reply = await processUserMessage(name, chatId, text, {
|
|
364
|
+
adapter,
|
|
365
|
+
channelType: name,
|
|
366
|
+
});
|
|
142
367
|
if (reply) {
|
|
143
368
|
await adapter.sendMessage(chatId, reply);
|
|
144
369
|
}
|