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.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/lib/{agent-loader.js → agents/loader.js} +6 -8
  3. package/lib/{approvals.js → approvals/store.js} +28 -5
  4. package/lib/{approval-ui.js → approvals/ui.js} +1 -17
  5. package/lib/config.js +121 -0
  6. package/lib/{error-classify.js → error/classify.js} +25 -34
  7. package/lib/handlers/abort.js +89 -0
  8. package/lib/handlers/approvals.js +361 -0
  9. package/lib/handlers/autosteer.js +94 -0
  10. package/lib/handlers/config-callback.js +118 -0
  11. package/lib/handlers/config-ui.js +104 -0
  12. package/lib/handlers/dispatcher.js +263 -0
  13. package/lib/handlers/download.js +182 -0
  14. package/lib/handlers/extract-attachments.js +97 -0
  15. package/lib/handlers/ipc-send.js +80 -0
  16. package/lib/handlers/poll.js +140 -0
  17. package/lib/handlers/record-inbound.js +88 -0
  18. package/lib/handlers/slash-commands.js +319 -0
  19. package/lib/handlers/voice.js +107 -0
  20. package/lib/pm-interface.js +27 -29
  21. package/lib/sdk/build-options.js +177 -0
  22. package/lib/sdk/callbacks.js +213 -0
  23. package/lib/{process-manager-sdk.js → sdk/process-manager.js} +19 -31
  24. package/lib/{telegram.js → telegram/api.js} +2 -2
  25. package/lib/{telegram-prompt.js → telegram/display-hint.js} +0 -14
  26. package/lib/{stream-reply.js → telegram/streamer.js} +4 -4
  27. package/package.json +2 -3
  28. package/polygram.js +347 -2581
  29. package/scripts/doctor.js +1 -1
  30. package/scripts/ipc-smoke.js +1 -10
  31. package/bin/approval-hook.js +0 -113
  32. package/lib/approval-waiters.js +0 -201
  33. package/lib/pm-router.js +0 -201
  34. package/lib/process-manager.js +0 -806
  35. /package/lib/{auto-resume.js → db/auto-resume.js} +0 -0
  36. /package/lib/{inbox.js → db/inbox.js} +0 -0
  37. /package/lib/{pairings.js → db/pairings.js} +0 -0
  38. /package/lib/{replay-window.js → db/replay-window.js} +0 -0
  39. /package/lib/{sent-cache.js → db/sent-cache.js} +0 -0
  40. /package/lib/{sessions.js → db/sessions.js} +0 -0
  41. /package/lib/{net-errors.js → error/net.js} +0 -0
  42. /package/lib/{ipc-client.js → ipc/client.js} +0 -0
  43. /package/lib/{ipc-file-validator.js → ipc/file-validator.js} +0 -0
  44. /package/lib/{ipc-server.js → ipc/server.js} +0 -0
  45. /package/lib/{telegram-chunk.js → telegram/chunk.js} +0 -0
  46. /package/lib/{deliver.js → telegram/deliver.js} +0 -0
  47. /package/lib/{telegram-format.js → telegram/format.js} +0 -0
  48. /package/lib/{parse-response.js → telegram/parse.js} +0 -0
  49. /package/lib/{status-reactions.js → telegram/reactions.js} +0 -0
  50. /package/lib/{typing-indicator.js → telegram/typing.js} +0 -0
  51. /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
+ };