wispy-cli 1.1.2 → 1.2.2

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Channel Manager — loads config, starts/stops adapters, routes messages
3
- * v0.7: uses core/engine.mjs and core/session.mjs for AI and session management
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; // Caller should send confirmation
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
  }