polygram 0.8.0 → 0.9.0-rc.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/.claude-plugin/plugin.json +1 -1
- package/lib/{agent-loader.js → agents/loader.js} +6 -8
- package/lib/{approvals.js → approvals/store.js} +28 -5
- package/lib/{approval-ui.js → approvals/ui.js} +1 -17
- package/lib/config.js +121 -0
- package/lib/{error-classify.js → error/classify.js} +25 -34
- package/lib/handlers/abort.js +89 -0
- package/lib/handlers/approvals.js +361 -0
- package/lib/handlers/autosteer.js +94 -0
- package/lib/handlers/config-callback.js +118 -0
- package/lib/handlers/config-ui.js +104 -0
- package/lib/handlers/dispatcher.js +263 -0
- package/lib/handlers/download.js +182 -0
- package/lib/handlers/extract-attachments.js +97 -0
- package/lib/handlers/ipc-send.js +80 -0
- package/lib/handlers/poll.js +140 -0
- package/lib/handlers/record-inbound.js +88 -0
- package/lib/handlers/slash-commands.js +319 -0
- package/lib/handlers/voice.js +107 -0
- package/lib/pm-interface.js +27 -29
- package/lib/sdk/build-options.js +177 -0
- package/lib/sdk/callbacks.js +213 -0
- package/lib/{process-manager-sdk.js → sdk/process-manager.js} +19 -31
- package/lib/{telegram.js → telegram/api.js} +2 -2
- package/lib/{telegram-prompt.js → telegram/display-hint.js} +0 -14
- package/lib/{stream-reply.js → telegram/streamer.js} +4 -4
- package/package.json +2 -3
- package/polygram.js +347 -2581
- package/scripts/doctor.js +1 -1
- package/scripts/ipc-smoke.js +1 -10
- package/bin/approval-hook.js +0 -113
- package/lib/approval-waiters.js +0 -201
- package/lib/pm-router.js +0 -201
- package/lib/process-manager.js +0 -806
- /package/lib/{auto-resume.js → db/auto-resume.js} +0 -0
- /package/lib/{inbox.js → db/inbox.js} +0 -0
- /package/lib/{pairings.js → db/pairings.js} +0 -0
- /package/lib/{replay-window.js → db/replay-window.js} +0 -0
- /package/lib/{sent-cache.js → db/sent-cache.js} +0 -0
- /package/lib/{sessions.js → db/sessions.js} +0 -0
- /package/lib/{net-errors.js → error/net.js} +0 -0
- /package/lib/{ipc-client.js → ipc/client.js} +0 -0
- /package/lib/{ipc-file-validator.js → ipc/file-validator.js} +0 -0
- /package/lib/{ipc-server.js → ipc/server.js} +0 -0
- /package/lib/{telegram-chunk.js → telegram/chunk.js} +0 -0
- /package/lib/{deliver.js → telegram/deliver.js} +0 -0
- /package/lib/{telegram-format.js → telegram/format.js} +0 -0
- /package/lib/{parse-response.js → telegram/parse.js} +0 -0
- /package/lib/{status-reactions.js → telegram/reactions.js} +0 -0
- /package/lib/{typing-indicator.js → telegram/typing.js} +0 -0
- /package/lib/{voice.js → telegram/voice.js} +0 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval flow — SDK canUseTool callback + Telegram callback_query
|
|
3
|
+
* handler + timeout sweeper.
|
|
4
|
+
*
|
|
5
|
+
* This factory owns the approvalWaiters Map (approval_id → array of
|
|
6
|
+
* Promise-resolver fns) and exposes:
|
|
7
|
+
*
|
|
8
|
+
* - makeCanUseTool(sessionKey) — closure that returns the SDK
|
|
9
|
+
* canUseTool callback. Per-call: lookup chat_tool_decisions,
|
|
10
|
+
* match against gatedTools patterns, issue pending row, post
|
|
11
|
+
* 4-button card to admin chat, park resolver, race against
|
|
12
|
+
* opts.signal + timeout, return PermissionResult.
|
|
13
|
+
*
|
|
14
|
+
* - handleApprovalCallback(ctx) — grammy callback_query handler
|
|
15
|
+
* for the approve / deny / approve-always / deny-always buttons.
|
|
16
|
+
* Validates token + status, atomically resolves the row,
|
|
17
|
+
* persists chat_tool_decisions for always-* clicks, edits the
|
|
18
|
+
* card to show the decision, resolves the parked waiter.
|
|
19
|
+
*
|
|
20
|
+
* - startApprovalSweeper(intervalMs?) — periodic timeout sweeper.
|
|
21
|
+
* Sweeps pending rows past timeout_ts, marks them 'timeout',
|
|
22
|
+
* edits the card to ⏰ Timed out, resolves the waiter.
|
|
23
|
+
*
|
|
24
|
+
* - resolveApprovalWaiter(id, decision, reason?, extra?) +
|
|
25
|
+
* dropWaiter(id, fn) — internal-but-exported for adjacent code
|
|
26
|
+
* that needs to feed back from the callback flow.
|
|
27
|
+
*
|
|
28
|
+
* No more dual-pm IPC: the deleted bin/approval-hook.js used to
|
|
29
|
+
* connect to polygram via Unix-socket IPC; SDK pm wires canUseTool
|
|
30
|
+
* in-process.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
const { canonicalizeToolInput } = require('../canonical-json');
|
|
36
|
+
const {
|
|
37
|
+
matchesAnyPattern: matchesApprovalPattern,
|
|
38
|
+
tokensEqual: approvalTokensEqual,
|
|
39
|
+
DEFAULT_TIMEOUT_MS: APPROVAL_DEFAULT_TIMEOUT_MS,
|
|
40
|
+
} = require('../approvals/store');
|
|
41
|
+
const {
|
|
42
|
+
buildApprovalKeyboardWithAlways,
|
|
43
|
+
approvalCardText,
|
|
44
|
+
} = require('../approvals/ui');
|
|
45
|
+
|
|
46
|
+
function createApprovals({
|
|
47
|
+
config,
|
|
48
|
+
db,
|
|
49
|
+
bot,
|
|
50
|
+
botName,
|
|
51
|
+
tg,
|
|
52
|
+
logEvent,
|
|
53
|
+
approvals, // approvals store instance
|
|
54
|
+
getChatIdFromKey,
|
|
55
|
+
logger = console,
|
|
56
|
+
} = {}) {
|
|
57
|
+
// approval_id → array of resolver fns
|
|
58
|
+
const approvalWaiters = new Map();
|
|
59
|
+
|
|
60
|
+
function dropWaiter(id, fn) {
|
|
61
|
+
const list = approvalWaiters.get(id);
|
|
62
|
+
if (!list) return;
|
|
63
|
+
const i = list.indexOf(fn);
|
|
64
|
+
if (i !== -1) list.splice(i, 1);
|
|
65
|
+
if (list.length === 0) approvalWaiters.delete(id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveApprovalWaiter(id, decision, reason, extra) {
|
|
69
|
+
// `extra` carries SDK-shape updatedPermissions for always-* clicks.
|
|
70
|
+
// canUseTool waiters use it to populate
|
|
71
|
+
// PermissionResult.updatedPermissions so the in-flight Query
|
|
72
|
+
// picks up the new rule for the rest of the turn.
|
|
73
|
+
const list = approvalWaiters.get(id);
|
|
74
|
+
if (!list) return;
|
|
75
|
+
approvalWaiters.delete(id);
|
|
76
|
+
for (const fn of list) {
|
|
77
|
+
try { fn(decision, reason, extra); } catch {}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function makeCanUseTool(sessionKey) {
|
|
82
|
+
const chatId = getChatIdFromKey(sessionKey);
|
|
83
|
+
return async function canUseTool(toolName, input, opts) {
|
|
84
|
+
const apprCfg = config.bot?.approvals;
|
|
85
|
+
if (!apprCfg || !apprCfg.adminChatId) {
|
|
86
|
+
// Not configured for this bot → allow everything.
|
|
87
|
+
return { behavior: 'allow' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const canonicalInput = canonicalizeToolInput(input);
|
|
91
|
+
|
|
92
|
+
// chat_tool_decisions short-circuit.
|
|
93
|
+
try {
|
|
94
|
+
const persisted = db.lookupChatToolDecision({
|
|
95
|
+
bot_name: botName, chat_id: chatId, tool_name: toolName,
|
|
96
|
+
canonical_input: canonicalInput, now: Date.now(),
|
|
97
|
+
});
|
|
98
|
+
if (persisted) {
|
|
99
|
+
logEvent('canusetool-shortcircuit', {
|
|
100
|
+
chat_id: chatId, tool_name: toolName,
|
|
101
|
+
decision: persisted.decision, match_type: persisted.match_type,
|
|
102
|
+
tool_use_id: opts?.toolUseID || null,
|
|
103
|
+
});
|
|
104
|
+
if (persisted.decision === 'allow') return { behavior: 'allow' };
|
|
105
|
+
return { behavior: 'deny', message: 'matched persisted always-deny rule' };
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
logger.error?.(`[${sessionKey}] chat_tool_decisions lookup: ${err.message}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const gated = matchesApprovalPattern(toolName, input, apprCfg.gatedTools || []);
|
|
112
|
+
if (!gated.matched) return { behavior: 'allow' };
|
|
113
|
+
|
|
114
|
+
// Issue + post + park. tool_use_id is the dedup key when
|
|
115
|
+
// the SDK supplies it; falls back to the legacy
|
|
116
|
+
// (turn_id, tool_input_digest) tuple for cron / IPC callers.
|
|
117
|
+
const row = approvals.issue({
|
|
118
|
+
bot_name: botName,
|
|
119
|
+
turn_id: opts?.toolUseID || null,
|
|
120
|
+
tool_use_id: opts?.toolUseID || null,
|
|
121
|
+
requester_chat_id: chatId,
|
|
122
|
+
approver_chat_id: String(apprCfg.adminChatId),
|
|
123
|
+
tool_name: toolName, tool_input: input,
|
|
124
|
+
timeoutMs: apprCfg.timeoutMs || APPROVAL_DEFAULT_TIMEOUT_MS,
|
|
125
|
+
});
|
|
126
|
+
if (!bot) {
|
|
127
|
+
approvals.resolve({ id: row.id, status: 'cancelled', reason: 'bot not ready' });
|
|
128
|
+
return { behavior: 'deny', message: 'bot not ready' };
|
|
129
|
+
}
|
|
130
|
+
if (!row.reused || !row.approver_msg_id) {
|
|
131
|
+
try {
|
|
132
|
+
const sent = await tg(bot, 'sendMessage', {
|
|
133
|
+
chat_id: apprCfg.adminChatId,
|
|
134
|
+
text: approvalCardText(row),
|
|
135
|
+
reply_markup: buildApprovalKeyboardWithAlways(row.id, row.callback_token),
|
|
136
|
+
}, { source: 'canusetool-card', botName, plainText: true });
|
|
137
|
+
if (sent?.message_id) approvals.setApproverMsgId(row.id, sent.message_id);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logger.error?.(`[${sessionKey}] failed to post canUseTool card: ${err.message}`);
|
|
140
|
+
approvals.resolve({ id: row.id, status: 'cancelled', reason: `post failed: ${err.message}` });
|
|
141
|
+
return { behavior: 'deny', message: `post failed: ${err.message}` };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Race signal + timeout + click.
|
|
146
|
+
return await new Promise((resolve) => {
|
|
147
|
+
let settled = false;
|
|
148
|
+
const settle = (decision) => {
|
|
149
|
+
if (settled) return;
|
|
150
|
+
settled = true;
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
if (opts?.signal && sigCleanup) {
|
|
153
|
+
try { opts.signal.removeEventListener('abort', sigCleanup); }
|
|
154
|
+
catch {}
|
|
155
|
+
}
|
|
156
|
+
dropWaiter(row.id, wrappedResolve);
|
|
157
|
+
resolve(decision);
|
|
158
|
+
};
|
|
159
|
+
const timer = setTimeout(() => {
|
|
160
|
+
approvals.resolve({ id: row.id, status: 'timeout' }).catch?.(() => {});
|
|
161
|
+
settle({ behavior: 'deny', message: 'approval timed out' });
|
|
162
|
+
}, Math.max(1000, row.timeout_ts - Date.now()));
|
|
163
|
+
const sigCleanup = opts?.signal
|
|
164
|
+
? () => settle({ behavior: 'deny', message: 'aborted' })
|
|
165
|
+
: null;
|
|
166
|
+
if (opts?.signal && sigCleanup) {
|
|
167
|
+
opts.signal.addEventListener('abort', sigCleanup, { once: true });
|
|
168
|
+
}
|
|
169
|
+
const wrappedResolve = (decision, reason, extra) => {
|
|
170
|
+
// decision: 'approved' | 'denied' | 'approved-always' | 'denied-always'
|
|
171
|
+
if (decision === 'approved' || decision === 'approved-always') {
|
|
172
|
+
settle({
|
|
173
|
+
behavior: 'allow',
|
|
174
|
+
...(decision === 'approved-always' && extra?.updatedPermissions
|
|
175
|
+
? { updatedPermissions: extra.updatedPermissions }
|
|
176
|
+
: {}),
|
|
177
|
+
});
|
|
178
|
+
} else {
|
|
179
|
+
settle({
|
|
180
|
+
behavior: 'deny',
|
|
181
|
+
message: reason || decision || 'denied',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const list = approvalWaiters.get(row.id) || [];
|
|
186
|
+
list.push(wrappedResolve);
|
|
187
|
+
approvalWaiters.set(row.id, list);
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function handleApprovalCallback(ctx) {
|
|
193
|
+
const data = ctx.callbackQuery?.data || '';
|
|
194
|
+
const m = String(data).match(/^(approve|deny|approve-always|deny-always):(\d+):(\S+)$/);
|
|
195
|
+
if (!m) return;
|
|
196
|
+
const decision = m[1];
|
|
197
|
+
const id = parseInt(m[2], 10);
|
|
198
|
+
const token = m[3];
|
|
199
|
+
|
|
200
|
+
const row = approvals.getById(id);
|
|
201
|
+
if (!row) {
|
|
202
|
+
await ctx.answerCallbackQuery({ text: 'Unknown approval.', show_alert: true }).catch(() => {});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!approvalTokensEqual(row.callback_token, token)) {
|
|
206
|
+
logEvent('approval-token-mismatch', { id, from_user: ctx.from?.id });
|
|
207
|
+
await ctx.answerCallbackQuery({ text: 'Bad token.', show_alert: true }).catch(() => {});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (row.status !== 'pending') {
|
|
211
|
+
await ctx.answerCallbackQuery({ text: `Already ${row.status}.`, show_alert: true }).catch(() => {});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Only the configured approver chat is authoritative.
|
|
216
|
+
const apprCfg = config.bot?.approvals;
|
|
217
|
+
const expectedChat = String(apprCfg?.adminChatId || '');
|
|
218
|
+
if (String(ctx.chat?.id) !== expectedChat) {
|
|
219
|
+
logEvent('approval-foreign-chat', {
|
|
220
|
+
id, from_chat: ctx.chat?.id, expected: expectedChat,
|
|
221
|
+
});
|
|
222
|
+
await ctx.answerCallbackQuery({ text: 'Not authorised here.', show_alert: true }).catch(() => {});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const isApprove = decision === 'approve' || decision === 'approve-always';
|
|
227
|
+
const isAlways = decision === 'approve-always' || decision === 'deny-always';
|
|
228
|
+
const status = isApprove ? 'approved' : 'denied';
|
|
229
|
+
const user = ctx.from?.first_name || ctx.from?.username || null;
|
|
230
|
+
const userId = ctx.from?.id || null;
|
|
231
|
+
// Atomic SQL UPDATE ... WHERE status='pending' — double-click
|
|
232
|
+
// race: only one writer wins; the second sees changes=0.
|
|
233
|
+
const changes = approvals.resolve({
|
|
234
|
+
id, status,
|
|
235
|
+
decided_by_user_id: userId, decided_by_user: user,
|
|
236
|
+
});
|
|
237
|
+
if (changes === 0) {
|
|
238
|
+
const fresh = approvals.getById(id);
|
|
239
|
+
await ctx.answerCallbackQuery({
|
|
240
|
+
text: `Already ${fresh?.status || 'resolved'}.`,
|
|
241
|
+
show_alert: true,
|
|
242
|
+
}).catch(() => {});
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
logEvent('approval-resolved', {
|
|
246
|
+
id, status, by: userId, user, bot: botName,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Edit the card to show the decision.
|
|
250
|
+
try {
|
|
251
|
+
const fresh = approvals.getById(id);
|
|
252
|
+
await tg(bot, 'editMessageText', {
|
|
253
|
+
chat_id: row.approver_chat_id,
|
|
254
|
+
message_id: row.approver_msg_id,
|
|
255
|
+
text: approvalCardText(fresh, {
|
|
256
|
+
resolvedBy: `${status === 'approved' ? '✅ Approved' : '❌ Denied'} by ${user || userId}`,
|
|
257
|
+
}),
|
|
258
|
+
}, { source: 'approval-card-decision', botName, plainText: true });
|
|
259
|
+
} catch (err) {
|
|
260
|
+
logger.error?.(`[${botName}] edit approval card failed: ${err.message}`);
|
|
261
|
+
}
|
|
262
|
+
// Persist always-* clicks to chat_tool_decisions.
|
|
263
|
+
let updatedPermissions = null;
|
|
264
|
+
if (isAlways) {
|
|
265
|
+
try {
|
|
266
|
+
const canonical = canonicalizeToolInput(row.tool_input);
|
|
267
|
+
db.insertChatToolDecision({
|
|
268
|
+
bot_name: botName,
|
|
269
|
+
chat_id: row.requester_chat_id,
|
|
270
|
+
tool_name: row.tool_name,
|
|
271
|
+
match_type: 'prefix',
|
|
272
|
+
input_pattern: canonical,
|
|
273
|
+
decision: status === 'approved' ? 'allow' : 'deny',
|
|
274
|
+
issued_by_user_id: userId ? String(userId) : null,
|
|
275
|
+
expires_ts: null,
|
|
276
|
+
});
|
|
277
|
+
logEvent('chat-tool-decision-persisted', {
|
|
278
|
+
chat_id: row.requester_chat_id,
|
|
279
|
+
tool_name: row.tool_name,
|
|
280
|
+
decision: status === 'approved' ? 'allow' : 'deny',
|
|
281
|
+
match_type: 'prefix',
|
|
282
|
+
});
|
|
283
|
+
updatedPermissions = [{
|
|
284
|
+
type: 'addRules',
|
|
285
|
+
rules: [{
|
|
286
|
+
toolName: row.tool_name,
|
|
287
|
+
decision: status === 'approved' ? 'allow' : 'deny',
|
|
288
|
+
}],
|
|
289
|
+
}];
|
|
290
|
+
} catch (err) {
|
|
291
|
+
logger.error?.(`[${botName}] chat_tool_decisions persist failed: ${err.message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await ctx.answerCallbackQuery({ text: status }).catch(() => {});
|
|
296
|
+
|
|
297
|
+
// Pass the original decision back to the waiter so it can
|
|
298
|
+
// distinguish 'approved-always' (SDK gets updatedPermissions)
|
|
299
|
+
// from plain 'approved'.
|
|
300
|
+
resolveApprovalWaiter(id, decision === 'approve-always' ? 'approved-always'
|
|
301
|
+
: decision === 'deny-always' ? 'denied-always'
|
|
302
|
+
: status, undefined, { updatedPermissions });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function startApprovalSweeper(intervalMs = 30_000) {
|
|
306
|
+
return setInterval(() => {
|
|
307
|
+
let rows;
|
|
308
|
+
try {
|
|
309
|
+
rows = approvals.sweepTimedOut();
|
|
310
|
+
} catch (err) {
|
|
311
|
+
logger.error?.(`[approvals] sweeper DB error: ${err.message}`);
|
|
312
|
+
logEvent('approval-sweep-failed', { error: err.message?.slice(0, 300) });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
for (const row of rows) {
|
|
316
|
+
approvals.resolve({ id: row.id, status: 'timeout' });
|
|
317
|
+
logEvent('approval-timeout', { id: row.id, bot: botName, tool: row.tool_name });
|
|
318
|
+
resolveApprovalWaiter(row.id, 'timeout', 'swept');
|
|
319
|
+
if (bot && row.approver_msg_id) {
|
|
320
|
+
tg(bot, 'editMessageText', {
|
|
321
|
+
chat_id: row.approver_chat_id,
|
|
322
|
+
message_id: row.approver_msg_id,
|
|
323
|
+
text: approvalCardText(approvals.getById(row.id), { resolvedBy: '⏰ Timed out' }),
|
|
324
|
+
}, { source: 'approval-card-timeout', botName, plainText: true })
|
|
325
|
+
.catch((err) => logger.error?.(`[${botName}] approval-card-timeout edit: ${err.message}`));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}, intervalMs);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Reject every parked waiter with the supplied decision (default
|
|
333
|
+
* 'denied'). Called from polygram.js's shutdown handler so any
|
|
334
|
+
* in-flight canUseTool Promises don't dangle with the daemon
|
|
335
|
+
* gone — the SDK Query gets a deny and the tool call fails
|
|
336
|
+
* cleanly instead of timing out into the void.
|
|
337
|
+
*/
|
|
338
|
+
function cancelAllWaiters(decision = 'denied', reason = 'shutdown') {
|
|
339
|
+
let count = 0;
|
|
340
|
+
for (const list of approvalWaiters.values()) {
|
|
341
|
+
for (const fn of list) {
|
|
342
|
+
try { fn(decision, reason); count++; } catch {}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
approvalWaiters.clear();
|
|
346
|
+
return count;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
makeCanUseTool,
|
|
351
|
+
handleApprovalCallback,
|
|
352
|
+
resolveApprovalWaiter,
|
|
353
|
+
dropWaiter,
|
|
354
|
+
startApprovalSweeper,
|
|
355
|
+
cancelAllWaiters,
|
|
356
|
+
// Test introspection
|
|
357
|
+
_approvalWaiters: approvalWaiters,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
module.exports = { createApprovals };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autosteer detection + dispatch.
|
|
3
|
+
*
|
|
4
|
+
* When a user types a follow-up message while the bot is mid-reply,
|
|
5
|
+
* absorb it into the current turn instead of queueing a separate
|
|
6
|
+
* response (OpenClaw-style "merge into active"). Saves a turn,
|
|
7
|
+
* saves tokens, feels conversational.
|
|
8
|
+
*
|
|
9
|
+
* Two halves:
|
|
10
|
+
* - shouldAutosteer(sessionKey, chatConfig) — boolean predicate,
|
|
11
|
+
* used pre-THINKING to skip the 🤔 → ✍ flash.
|
|
12
|
+
* - tryAutosteer(...) — full dispatch: pm.injectUserMessage with
|
|
13
|
+
* priority hint ('next' for merge, 'later' for queue), records
|
|
14
|
+
* ✍ reaction ref, logs telemetry, sets reactor to AUTOSTEERED
|
|
15
|
+
* and returns true so caller short-circuits.
|
|
16
|
+
*
|
|
17
|
+
* Opt-out: chatConfig.autosteer === false (per-chat) or
|
|
18
|
+
* config.bot.autosteer === false. Mode: chatConfig.autosteerMode
|
|
19
|
+
* (or config.bot.autosteerMode) of 'merge' (default → priority='next')
|
|
20
|
+
* or 'queue' (→ priority='later'); spike findings in
|
|
21
|
+
* scripts/spikes/native-queue.mjs explain the difference.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
function isAutosteerEnabledFor(chatConfig, config) {
|
|
27
|
+
return chatConfig.autosteer != null
|
|
28
|
+
? chatConfig.autosteer !== false
|
|
29
|
+
: config.bot?.autosteer !== false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function priorityFor(chatConfig, config) {
|
|
33
|
+
const mode = chatConfig.autosteerMode != null
|
|
34
|
+
? chatConfig.autosteerMode
|
|
35
|
+
: config.bot?.autosteerMode;
|
|
36
|
+
return mode === 'queue' ? 'later' : 'next';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createAutosteerHandlers({
|
|
40
|
+
config,
|
|
41
|
+
pm,
|
|
42
|
+
autosteeredRefs,
|
|
43
|
+
logEvent,
|
|
44
|
+
} = {}) {
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pre-THINKING predicate. Returns true when the upcoming message
|
|
48
|
+
* will be autosteered (so the caller skips reactor.setState('THINKING')
|
|
49
|
+
* to avoid the 🤔 → ✍ flash).
|
|
50
|
+
*/
|
|
51
|
+
function willAutosteer(sessionKey, chatConfig) {
|
|
52
|
+
if (!pm.has(sessionKey)) return false;
|
|
53
|
+
if (!pm.get(sessionKey)?.inFlight) return false;
|
|
54
|
+
return isAutosteerEnabledFor(chatConfig, config);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Attempt to inject the user message into the in-flight turn.
|
|
59
|
+
* Returns:
|
|
60
|
+
* - { autosteered: true, priority } — caller marks reactor
|
|
61
|
+
* AUTOSTEERED + records ✍ ref + returns from handleMessage.
|
|
62
|
+
* - { autosteered: false } — caller falls through to normal
|
|
63
|
+
* pm.send queue path.
|
|
64
|
+
*/
|
|
65
|
+
function tryAutosteer({ sessionKey, chatConfig, chatId, msg, prompt }) {
|
|
66
|
+
if (!isAutosteerEnabledFor(chatConfig, config)) return { autosteered: false };
|
|
67
|
+
if (!pm.has(sessionKey)) return { autosteered: false };
|
|
68
|
+
const entry = pm.get(sessionKey);
|
|
69
|
+
if (!entry?.inFlight) return { autosteered: false };
|
|
70
|
+
|
|
71
|
+
const priority = priorityFor(chatConfig, config);
|
|
72
|
+
const ok = pm.injectUserMessage(sessionKey, {
|
|
73
|
+
content: prompt,
|
|
74
|
+
priority,
|
|
75
|
+
});
|
|
76
|
+
if (!ok) return { autosteered: false };
|
|
77
|
+
|
|
78
|
+
autosteeredRefs.add(sessionKey, { chatId, msgId: msg.message_id });
|
|
79
|
+
logEvent('autosteer', {
|
|
80
|
+
chat_id: chatId, msg_id: msg.message_id,
|
|
81
|
+
text_len: prompt?.length ?? 0,
|
|
82
|
+
priority,
|
|
83
|
+
});
|
|
84
|
+
return { autosteered: true, priority };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { willAutosteer, tryAutosteer };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
createAutosteerHandlers,
|
|
92
|
+
isAutosteerEnabledFor,
|
|
93
|
+
priorityFor,
|
|
94
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline-keyboard callback handler for the /model and /effort cards.
|
|
3
|
+
*
|
|
4
|
+
* When a user taps a button on the config card, this routes:
|
|
5
|
+
* 1. validate the new value;
|
|
6
|
+
* 2. mutate chatConfig in-place + persist via db.logConfigChange;
|
|
7
|
+
* 3. apply the change live to the running SDK Query via
|
|
8
|
+
* pm.applyFlagSettings (effort) or pm.setModel (model);
|
|
9
|
+
* 4. re-render the card with the new ✓ marker;
|
|
10
|
+
* 5. acknowledge the button press with a context-aware toast.
|
|
11
|
+
*
|
|
12
|
+
* SDK pm applies the change live — no kill, no respawn. Pre-cleanup
|
|
13
|
+
* the CLI pm path used requestRespawn (drain queue + kill subprocess)
|
|
14
|
+
* for /model + /effort; that's gone with the CLI pm.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const { toTelegramHtml } = require('../telegram/format');
|
|
20
|
+
|
|
21
|
+
const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
|
|
22
|
+
const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
23
|
+
|
|
24
|
+
function createHandleConfigCallback({
|
|
25
|
+
config,
|
|
26
|
+
db,
|
|
27
|
+
dbWrite,
|
|
28
|
+
pm,
|
|
29
|
+
getSessionKey,
|
|
30
|
+
formatConfigInfoText,
|
|
31
|
+
buildConfigKeyboard,
|
|
32
|
+
botName,
|
|
33
|
+
logger = console,
|
|
34
|
+
} = {}) {
|
|
35
|
+
|
|
36
|
+
return async function handleConfigCallback(ctx) {
|
|
37
|
+
const data = ctx.callbackQuery?.data || '';
|
|
38
|
+
const m = String(data).match(/^cfg:(model|effort):(\S+)$/);
|
|
39
|
+
if (!m) return;
|
|
40
|
+
const setting = m[1];
|
|
41
|
+
const value = m[2];
|
|
42
|
+
|
|
43
|
+
const chatId = String(ctx.callbackQuery.message?.chat?.id || '');
|
|
44
|
+
const chatConfig = config.chats[chatId];
|
|
45
|
+
if (!chatConfig) {
|
|
46
|
+
await ctx.answerCallbackQuery({ text: 'Chat not configured', show_alert: true }).catch(() => {});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!config.bot?.allowConfigCommands) {
|
|
50
|
+
await ctx.answerCallbackQuery({ text: 'Config commands disabled', show_alert: true }).catch(() => {});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const validValues = setting === 'model' ? MODEL_OPTIONS : EFFORT_OPTIONS;
|
|
55
|
+
if (!validValues.includes(value)) {
|
|
56
|
+
await ctx.answerCallbackQuery({ text: `Invalid ${setting}` }).catch(() => {});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const oldValue = chatConfig[setting];
|
|
61
|
+
if (oldValue === value) {
|
|
62
|
+
await ctx.answerCallbackQuery({ text: `Already ${value}` }).catch(() => {});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
chatConfig[setting] = value;
|
|
67
|
+
const cmdUserId = ctx.callbackQuery.from?.id || null;
|
|
68
|
+
const cmdUser = ctx.callbackQuery.from?.first_name || ctx.callbackQuery.from?.username || null;
|
|
69
|
+
dbWrite(() => db.logConfigChange({
|
|
70
|
+
chat_id: chatId, thread_id: null, field: setting,
|
|
71
|
+
old_value: oldValue, new_value: value,
|
|
72
|
+
user: cmdUser, user_id: cmdUserId, source: 'inline-button',
|
|
73
|
+
}), `log ${setting} change`);
|
|
74
|
+
|
|
75
|
+
// Graceful application to the topic's session. SDK pm applies live
|
|
76
|
+
// via setModel / applyFlagSettings; chatConfig is already updated
|
|
77
|
+
// on disk above so a missing live session still picks up the new
|
|
78
|
+
// value on its next cold spawn.
|
|
79
|
+
const callbackThreadId = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
|
|
80
|
+
const callbackSessionKey = getSessionKey(chatId, callbackThreadId, chatConfig);
|
|
81
|
+
let applied = false;
|
|
82
|
+
if (setting === 'effort') {
|
|
83
|
+
applied = await pm.applyFlagSettings(callbackSessionKey, { effortLevel: value });
|
|
84
|
+
} else if (setting === 'model') {
|
|
85
|
+
applied = await pm.setModel(callbackSessionKey, value);
|
|
86
|
+
}
|
|
87
|
+
const anyActive = !applied;
|
|
88
|
+
|
|
89
|
+
// Re-render the card with the new ✓ marker. Detect original card
|
|
90
|
+
// type (model-only / effort-only / both) by counting rows in the
|
|
91
|
+
// existing reply_markup so the user sees the same layout they
|
|
92
|
+
// tapped into.
|
|
93
|
+
const existingRows = ctx.callbackQuery.message?.reply_markup?.inline_keyboard?.length || 0;
|
|
94
|
+
const showRow = existingRows >= 2 ? 'all' : setting;
|
|
95
|
+
const newInfo = formatConfigInfoText(chatConfig, showRow, chatId);
|
|
96
|
+
const newKeyboard = buildConfigKeyboard(chatConfig, showRow);
|
|
97
|
+
try {
|
|
98
|
+
const { text: html, parseMode } = toTelegramHtml(newInfo);
|
|
99
|
+
await ctx.editMessageText(html, {
|
|
100
|
+
reply_markup: newKeyboard,
|
|
101
|
+
...(parseMode && { parse_mode: parseMode }),
|
|
102
|
+
});
|
|
103
|
+
} catch (err) {
|
|
104
|
+
logger.error?.(`[${botName}] config-card edit failed: ${err.message}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ackText = anyActive
|
|
108
|
+
? `${setting} → ${value} — switching when finished`
|
|
109
|
+
: `${setting} → ${value}`;
|
|
110
|
+
await ctx.answerCallbackQuery({ text: ackText }).catch(() => {});
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
createHandleConfigCallback,
|
|
116
|
+
MODEL_OPTIONS,
|
|
117
|
+
EFFORT_OPTIONS,
|
|
118
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config card UI builders — inline keyboard + descriptive body
|
|
3
|
+
* shown above it. Used by polygram's /config slash command and
|
|
4
|
+
* the /model + /effort callback re-render path
|
|
5
|
+
* (lib/handlers/config-callback.js).
|
|
6
|
+
*
|
|
7
|
+
* Pure functions (no DB / fs) but `formatConfigInfoText` needs
|
|
8
|
+
* runtime context (pm to check warm/cold, db + getClaudeSessionId
|
|
9
|
+
* to fetch session id) → factory wraps it. Keyboard builder is a
|
|
10
|
+
* top-level export.
|
|
11
|
+
*
|
|
12
|
+
* MODEL_VERSIONS_DESC bumps with each Claude release — see polygram's
|
|
13
|
+
* release notes for the verification step (`claude --model <alias>`
|
|
14
|
+
* + check the system:init event's `model` field).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
|
|
20
|
+
const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
21
|
+
|
|
22
|
+
// Mirrors what `claude --model <alias>` resolves to. Display only —
|
|
23
|
+
// polygram passes the alias (opus / sonnet / haiku) and lets claude
|
|
24
|
+
// resolve. Bump on Claude release.
|
|
25
|
+
const MODEL_VERSIONS_DESC = {
|
|
26
|
+
opus: 'claude-opus-4-7',
|
|
27
|
+
sonnet: 'claude-sonnet-4-6',
|
|
28
|
+
haiku: 'claude-haiku-4-5',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the inline keyboard for /model + /effort.
|
|
33
|
+
* show = 'model' | 'effort' | 'all'
|
|
34
|
+
* The current value gets a ✓ prefix.
|
|
35
|
+
*/
|
|
36
|
+
function buildConfigKeyboard(chatConfig, show = 'all') {
|
|
37
|
+
const rows = [];
|
|
38
|
+
if (show === 'model' || show === 'all') {
|
|
39
|
+
rows.push(MODEL_OPTIONS.map((m) => ({
|
|
40
|
+
text: m === chatConfig.model ? `✓ ${m}` : m,
|
|
41
|
+
callback_data: `cfg:model:${m}`,
|
|
42
|
+
})));
|
|
43
|
+
}
|
|
44
|
+
if (show === 'effort' || show === 'all') {
|
|
45
|
+
rows.push(EFFORT_OPTIONS.map((e) => ({
|
|
46
|
+
text: e === chatConfig.effort ? `✓ ${e}` : e,
|
|
47
|
+
callback_data: `cfg:effort:${e}`,
|
|
48
|
+
})));
|
|
49
|
+
}
|
|
50
|
+
return { inline_keyboard: rows };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Factory for the card-body formatter. Needs runtime pm + db + a
|
|
55
|
+
* getClaudeSessionId fetcher.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} deps
|
|
58
|
+
* @param {object} deps.pm
|
|
59
|
+
* @param {object} deps.db
|
|
60
|
+
* @param {(db, sessionKey) => string|null} deps.getClaudeSessionId
|
|
61
|
+
*/
|
|
62
|
+
function createFormatConfigInfoText({ pm, db, getClaudeSessionId } = {}) {
|
|
63
|
+
return function formatConfigInfoText(chatConfig, show, sessionKey) {
|
|
64
|
+
const alive = pm.has(sessionKey) && !pm.get(sessionKey).closed;
|
|
65
|
+
const ver = MODEL_VERSIONS_DESC[chatConfig.model] || chatConfig.model;
|
|
66
|
+
const sess = getClaudeSessionId(db, sessionKey)?.slice(0, 8) || 'new';
|
|
67
|
+
const head =
|
|
68
|
+
`Model: ${chatConfig.model} (${ver})\n` +
|
|
69
|
+
`Effort: ${chatConfig.effort}\n` +
|
|
70
|
+
`Agent: ${chatConfig.agent}\n` +
|
|
71
|
+
`Process: ${alive ? 'warm' : 'cold'}\n` +
|
|
72
|
+
`Session: ${sess}`;
|
|
73
|
+
|
|
74
|
+
const modelHelp = [
|
|
75
|
+
'',
|
|
76
|
+
'**Models**',
|
|
77
|
+
'🧠 **opus** — deep analysis, code refactor, multi-source reconciliation. ~5× sonnet cost.',
|
|
78
|
+
'🤖 **sonnet** — default. Most ops, code review, document summary.',
|
|
79
|
+
'⚡ **haiku** — quick simple tasks, classification, lookup.',
|
|
80
|
+
].join('\n');
|
|
81
|
+
|
|
82
|
+
const effortHelp = [
|
|
83
|
+
'',
|
|
84
|
+
'**Effort** — ceiling on how much Claude can think. Simple questions get fast replies; hard ones spend more tokens. Safe to set higher — Claude scales down automatically when it doesn\'t need to think.',
|
|
85
|
+
'• **low** — fast replies, minimum reasoning. Casual chat, simple lookups.',
|
|
86
|
+
'• **medium** — balanced default. Fits most use cases.',
|
|
87
|
+
'• **high** — multi-step tasks. Audit, debug, multi-source analysis.',
|
|
88
|
+
'• **xhigh** / **max** — heaviest. Hard reasoning, edge cases.',
|
|
89
|
+
].join('\n');
|
|
90
|
+
|
|
91
|
+
let body = head;
|
|
92
|
+
if (show === 'model' || show === 'all') body += '\n' + modelHelp;
|
|
93
|
+
if (show === 'effort' || show === 'all') body += '\n' + effortHelp;
|
|
94
|
+
return body;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
buildConfigKeyboard,
|
|
100
|
+
createFormatConfigInfoText,
|
|
101
|
+
MODEL_OPTIONS,
|
|
102
|
+
EFFORT_OPTIONS,
|
|
103
|
+
MODEL_VERSIONS_DESC,
|
|
104
|
+
};
|