polygram 0.8.0-rc.1 → 0.8.0-rc.11
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/attachments.js +7 -9
- package/lib/autosteer-buffer.js +80 -0
- package/lib/error-classify.js +38 -9
- package/lib/process-manager-sdk.js +20 -1
- package/lib/status-reactions.js +54 -19
- package/package.json +1 -1
- package/polygram.js +333 -98
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.8.0-rc.
|
|
4
|
+
"version": "0.8.0-rc.11",
|
|
5
5
|
"description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
package/lib/attachments.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Attachment filter — caps
|
|
2
|
+
* Attachment filter — caps total size + per-file size + MIME allowlist.
|
|
3
3
|
* Rejected items return a human-readable reason that we surface to the
|
|
4
4
|
* user and log to the events table.
|
|
5
|
+
*
|
|
6
|
+
* No count cap: per-file (10 MB) and total-size (20 MB) bound resource
|
|
7
|
+
* usage already; an additional count limit just produces "skipped: max
|
|
8
|
+
* count" surprises on Telegram albums (up to 10 photos in one send).
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
|
-
const MAX_COUNT = 5;
|
|
8
11
|
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
9
12
|
const MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
10
13
|
const MIME_ALLOW = [
|
|
@@ -16,7 +19,6 @@ const MIME_ALLOW = [
|
|
|
16
19
|
];
|
|
17
20
|
|
|
18
21
|
function filterAttachments(attachments, opts = {}) {
|
|
19
|
-
const maxCount = opts.maxCount ?? MAX_COUNT;
|
|
20
22
|
const maxFileBytes = opts.maxFileBytes ?? MAX_FILE_BYTES;
|
|
21
23
|
const maxTotalBytes = opts.maxTotalBytes ?? MAX_TOTAL_BYTES;
|
|
22
24
|
const mimeAllow = opts.mimeAllow ?? MIME_ALLOW;
|
|
@@ -26,10 +28,6 @@ function filterAttachments(attachments, opts = {}) {
|
|
|
26
28
|
let totalBytes = 0;
|
|
27
29
|
|
|
28
30
|
for (const a of attachments || []) {
|
|
29
|
-
if (accepted.length >= maxCount) {
|
|
30
|
-
rejected.push({ att: a, reason: `exceeds max count (${maxCount})` });
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
31
|
const mime = a.mime_type || '';
|
|
34
32
|
if (!mimeAllow.some((re) => re.test(mime))) {
|
|
35
33
|
rejected.push({ att: a, reason: `mime not allowed (${mime || 'unknown'})` });
|
|
@@ -38,7 +36,7 @@ function filterAttachments(attachments, opts = {}) {
|
|
|
38
36
|
const reported = a.size || 0;
|
|
39
37
|
// Telegram sometimes reports file_size=0 or omits it. Pre-0.6.14
|
|
40
38
|
// those bypassed the cumulative cap entirely (totalBytes + 0 always
|
|
41
|
-
// ≤ maxTotalBytes), so
|
|
39
|
+
// ≤ maxTotalBytes), so unsized attachments could blow through the
|
|
42
40
|
// 20 MB total cap. Treat unknown sizes as worst-case (= per-file
|
|
43
41
|
// cap) for budgeting; the per-file cap is still enforced live by
|
|
44
42
|
// the streaming download in polygram.js.
|
|
@@ -57,4 +55,4 @@ function filterAttachments(attachments, opts = {}) {
|
|
|
57
55
|
return { accepted, rejected, totalBytes };
|
|
58
56
|
}
|
|
59
57
|
|
|
60
|
-
module.exports = { filterAttachments,
|
|
58
|
+
module.exports = { filterAttachments, MAX_FILE_BYTES, MAX_TOTAL_BYTES, MIME_ALLOW };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session buffer for mid-turn user follow-ups (autosteer + /steer).
|
|
3
|
+
*
|
|
4
|
+
* 0.8.0-rc.9: lands the steer mechanism that survived production. Earlier
|
|
5
|
+
* rcs pushed `priority:'now'` SDKUserMessages onto the SDK input
|
|
6
|
+
* iterable mid-tool-use; the CLI binary's `m87` gate rejected them with
|
|
7
|
+
* `result.subtype = error_during_execution` because the transcript shape
|
|
8
|
+
* (assistant ending with tool_use → next user message NOT being a
|
|
9
|
+
* tool_result) is malformed per Anthropic's API contract.
|
|
10
|
+
*
|
|
11
|
+
* The mechanism we landed on: append the follow-up to a per-session
|
|
12
|
+
* buffer; on every PostToolBatch hook fire, drain the buffer into the
|
|
13
|
+
* hook's `additionalContext` field wrapped in a `<channel
|
|
14
|
+
* source="user-followup">…</channel>` tag — the same framing Channels
|
|
15
|
+
* MCP uses, which Claude is trained to trust as legitimate
|
|
16
|
+
* out-of-band user context (vs. prompt-injection inside tool output,
|
|
17
|
+
* which the model defends against by refusing to follow).
|
|
18
|
+
*
|
|
19
|
+
* Spike result (post-tool-batch-spike-v2.mjs): with this framing, the
|
|
20
|
+
* marker "spike-marker-9d3e" injected via additionalContext was
|
|
21
|
+
* incorporated verbatim into the assistant's final answer. With the
|
|
22
|
+
* earlier `<user_message_during_turn>` framing, the model recognised
|
|
23
|
+
* it as prompt-injection-shaped and refused.
|
|
24
|
+
*
|
|
25
|
+
* Why a buffer module instead of inlining: per-sessionKey state lives
|
|
26
|
+
* outside the pm and outside polygram.js's handleMessage so both
|
|
27
|
+
* autosteer (handleMessage line ~2418) and /steer (line ~1975) can
|
|
28
|
+
* share it. pm-sdk binds a hook callback per spawn that closes over
|
|
29
|
+
* its sessionKey and drains this buffer.
|
|
30
|
+
*
|
|
31
|
+
* Edge: tool-less turns (Claude answers without firing a tool). The
|
|
32
|
+
* hook never fires, so a queued message would be lost. pm-sdk's
|
|
33
|
+
* onResult handler MUST drain the buffer at turn-end and push the
|
|
34
|
+
* remainder via `inputController.push(..., { shouldQuery: false })`
|
|
35
|
+
* for next-turn injection — no m87 risk because the previous turn
|
|
36
|
+
* ended cleanly with text/end_turn before the push lands.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
'use strict';
|
|
40
|
+
|
|
41
|
+
function createAutosteerBuffer() {
|
|
42
|
+
// sessionKey → array of strings (in order of arrival)
|
|
43
|
+
const queues = new Map();
|
|
44
|
+
|
|
45
|
+
function append(sessionKey, text) {
|
|
46
|
+
if (!sessionKey || typeof text !== 'string' || text.length === 0) return false;
|
|
47
|
+
let q = queues.get(sessionKey);
|
|
48
|
+
if (!q) { q = []; queues.set(sessionKey, q); }
|
|
49
|
+
q.push(text);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function drain(sessionKey) {
|
|
54
|
+
const q = queues.get(sessionKey);
|
|
55
|
+
if (!q || q.length === 0) return [];
|
|
56
|
+
queues.delete(sessionKey);
|
|
57
|
+
return q;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function size(sessionKey) {
|
|
61
|
+
return queues.get(sessionKey)?.length ?? 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clear(sessionKey) {
|
|
65
|
+
queues.delete(sessionKey);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Format the drained messages as the additionalContext payload that
|
|
69
|
+
// Claude trusts. Multiple messages are joined with a blank line so
|
|
70
|
+
// the model sees them as a sequence within a single channel tag.
|
|
71
|
+
function formatForHook(messages) {
|
|
72
|
+
if (!messages || messages.length === 0) return null;
|
|
73
|
+
const body = messages.join('\n\n');
|
|
74
|
+
return `<channel source="user-followup">\n${body}\n</channel>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { append, drain, size, clear, formatForHook };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { createAutosteerBuffer };
|
package/lib/error-classify.js
CHANGED
|
@@ -97,7 +97,10 @@ const USER_MESSAGES = {
|
|
|
97
97
|
missingToolInput: '⚠️ Session history looks corrupted. Try /new.',
|
|
98
98
|
timeout: '⏳ I went quiet too long without finishing. Try resending or simplifying.',
|
|
99
99
|
format: '⚠️ Invalid request format. Try rephrasing or /new.',
|
|
100
|
-
|
|
100
|
+
// Used both for in-flight retry attempts AND for the post-retry-failed
|
|
101
|
+
// bubble-up message. Avoid promising "retrying once" since by the
|
|
102
|
+
// time the user reads it pm has already retried and given up.
|
|
103
|
+
transient5xx: '☁️ Server hiccup — please try again in a moment.',
|
|
101
104
|
};
|
|
102
105
|
|
|
103
106
|
// Auto-recovery actions for kinds where the session is irrecoverable
|
|
@@ -183,15 +186,16 @@ function classify(err) {
|
|
|
183
186
|
}
|
|
184
187
|
|
|
185
188
|
// SDKAssistantMessage.error is a short string code from a fixed
|
|
186
|
-
// union — match those directly, not via regex.
|
|
189
|
+
// union — match those directly, not via regex. Result subtypes
|
|
190
|
+
// are checked LATER (after pattern matching) so a more-specific
|
|
191
|
+
// pattern in the message text (e.g. 'HTTP 401' inside an
|
|
192
|
+
// error_during_execution subtype) wins over the generic subtype
|
|
193
|
+
// mapping that defaults the entire error_during_execution class
|
|
194
|
+
// to transient.
|
|
187
195
|
if (typeof err === 'string') {
|
|
188
196
|
const sdkMessageError = matchSdkMessageError(err);
|
|
189
197
|
if (sdkMessageError) return sdkMessageError;
|
|
190
198
|
}
|
|
191
|
-
if (err?.subtype && typeof err.subtype === 'string') {
|
|
192
|
-
const sdkResultSubtype = matchSdkResultSubtype(err.subtype);
|
|
193
|
-
if (sdkResultSubtype) return sdkResultSubtype;
|
|
194
|
-
}
|
|
195
199
|
|
|
196
200
|
const msg = extractMessage(err);
|
|
197
201
|
for (const [kind, re] of Object.entries(PATTERNS)) {
|
|
@@ -205,6 +209,20 @@ function classify(err) {
|
|
|
205
209
|
}
|
|
206
210
|
}
|
|
207
211
|
|
|
212
|
+
// After pattern matching: try SDK result subtypes. A bare string
|
|
213
|
+
// like 'error_during_execution' (no message context) lands here
|
|
214
|
+
// and gets the friendly transient5xx kind. Object inputs with a
|
|
215
|
+
// subtype field also land here when their message text didn't
|
|
216
|
+
// match a more specific pattern.
|
|
217
|
+
if (typeof err === 'string') {
|
|
218
|
+
const sdkResultSubtype = matchSdkResultSubtype(err);
|
|
219
|
+
if (sdkResultSubtype) return sdkResultSubtype;
|
|
220
|
+
}
|
|
221
|
+
if (err?.subtype && typeof err.subtype === 'string') {
|
|
222
|
+
const sdkResultSubtype = matchSdkResultSubtype(err.subtype);
|
|
223
|
+
if (sdkResultSubtype) return sdkResultSubtype;
|
|
224
|
+
}
|
|
225
|
+
|
|
208
226
|
// Fall-through: surface a snippet of the raw error so users at
|
|
209
227
|
// least know SOMETHING happened. Same shape as before, just
|
|
210
228
|
// routed through the classifier so callers get a uniform return.
|
|
@@ -252,8 +270,15 @@ function matchSdkMessageError(s) {
|
|
|
252
270
|
|
|
253
271
|
// SDKResultMessage.subtype values (sdk.d.ts:3121). Most are
|
|
254
272
|
// terminal-error indicators that don't have a clean pattern equivalent.
|
|
273
|
+
//
|
|
274
|
+
// `error_during_execution` is the SDK's catch-all for "something went
|
|
275
|
+
// wrong mid-turn" — could be a transient stream/network blip OR a
|
|
276
|
+
// systemic model issue. We treat it as transient (1 retry is cheap;
|
|
277
|
+
// if it's systemic the second attempt fails fast). Pre-rc.5 this was
|
|
278
|
+
// mapped to 'unknown' which fell through to the default "Hit a snag:
|
|
279
|
+
// error_during_execution" template — leaking the SDK enum to users.
|
|
255
280
|
const SDK_RESULT_SUBTYPE_MAP = {
|
|
256
|
-
error_during_execution: '
|
|
281
|
+
error_during_execution: 'transient5xx',
|
|
257
282
|
error_max_turns: 'format',
|
|
258
283
|
error_max_budget_usd: 'billing',
|
|
259
284
|
error_max_structured_output_retries: 'format',
|
|
@@ -265,8 +290,12 @@ function matchSdkResultSubtype(s) {
|
|
|
265
290
|
return {
|
|
266
291
|
kind,
|
|
267
292
|
userMessage: USER_MESSAGES[kind] ?? null,
|
|
268
|
-
|
|
269
|
-
|
|
293
|
+
// Derive transience from the kind so error_during_execution →
|
|
294
|
+
// transient5xx → isTransient=true, matching the pattern-match
|
|
295
|
+
// branch's behaviour. pm guards retry with firstAssistantSeen=
|
|
296
|
+
// false, which prevents budget waste when the turn already had
|
|
297
|
+
// billable assistant output.
|
|
298
|
+
isTransient: kind === 'transient5xx' || kind === 'rateLimit',
|
|
270
299
|
autoRecover: AUTO_RECOVER[kind] ?? null,
|
|
271
300
|
};
|
|
272
301
|
}
|
|
@@ -470,6 +470,7 @@ class ProcessManagerSdk {
|
|
|
470
470
|
entry.inputController.push({
|
|
471
471
|
type: 'user',
|
|
472
472
|
message: { role: 'user', content: head.prompt },
|
|
473
|
+
parent_tool_use_id: null,
|
|
473
474
|
});
|
|
474
475
|
} catch (err) {
|
|
475
476
|
entry.pendingQueue.shift();
|
|
@@ -655,6 +656,7 @@ class ProcessManagerSdk {
|
|
|
655
656
|
entry.inputController.push({
|
|
656
657
|
type: 'user',
|
|
657
658
|
message: { role: 'user', content: prompt },
|
|
659
|
+
parent_tool_use_id: null,
|
|
658
660
|
});
|
|
659
661
|
} catch (err) {
|
|
660
662
|
const idx = entry.pendingQueue.indexOf(pending);
|
|
@@ -754,13 +756,30 @@ class ProcessManagerSdk {
|
|
|
754
756
|
* Returns true if push succeeded; false if session not found or
|
|
755
757
|
* input controller closed.
|
|
756
758
|
*/
|
|
757
|
-
steer(sessionKey, text, { shouldQuery =
|
|
759
|
+
steer(sessionKey, text, { shouldQuery = false } = {}) {
|
|
758
760
|
const entry = this.procs.get(sessionKey);
|
|
759
761
|
if (!entry || entry.closed) return false;
|
|
760
762
|
try {
|
|
763
|
+
// 0.8.0-rc.7 (per v4 plan §0 row 9 + Phase 2 step 1's original
|
|
764
|
+
// shape): push with `shouldQuery: false` so the SDK appends to
|
|
765
|
+
// the transcript without trying to terminate the in-flight turn.
|
|
766
|
+
// The previous default `shouldQuery: true` triggered the CLI
|
|
767
|
+
// binary's `m87` gate (transcript well-formedness check) which
|
|
768
|
+
// emitted `result.subtype = error_during_execution` whenever a
|
|
769
|
+
// plain-text user message arrived while the assistant was mid-
|
|
770
|
+
// tool-use. With shouldQuery=false the message merges into the
|
|
771
|
+
// next natural user turn — the in-flight tools complete first,
|
|
772
|
+
// then the assistant sees the steered context.
|
|
773
|
+
//
|
|
774
|
+
// parent_tool_use_id is required by SDKUserMessage type
|
|
775
|
+
// (sdk.d.ts:3479-3498). The SDK runtime checks `!== null` in
|
|
776
|
+
// multiple places; omitting it falls through to wrong handling
|
|
777
|
+
// branches. The SDK's own `mz.send()` and `pz` replay set it
|
|
778
|
+
// to null explicitly.
|
|
761
779
|
entry.inputController.push({
|
|
762
780
|
type: 'user',
|
|
763
781
|
message: { role: 'user', content: text },
|
|
782
|
+
parent_tool_use_id: null,
|
|
764
783
|
priority: 'now',
|
|
765
784
|
shouldQuery,
|
|
766
785
|
});
|
package/lib/status-reactions.js
CHANGED
|
@@ -29,19 +29,30 @@
|
|
|
29
29
|
// are progressively safer. All endings in this list are in Telegram's
|
|
30
30
|
// default available reactions as of 2026-04.
|
|
31
31
|
const STATES = {
|
|
32
|
-
QUEUED:
|
|
33
|
-
THINKING:
|
|
34
|
-
CODING:
|
|
35
|
-
WEB:
|
|
36
|
-
TOOL:
|
|
37
|
-
WRITING:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
QUEUED: { label: 'queued', chain: ['👀', '🤔'] },
|
|
33
|
+
THINKING: { label: 'thinking', chain: ['🤔'] },
|
|
34
|
+
CODING: { label: 'coding', chain: ['👨💻', '✍', '🤔'] },
|
|
35
|
+
WEB: { label: 'web', chain: ['⚡', '🔥', '🤔'] },
|
|
36
|
+
TOOL: { label: 'tool', chain: ['🔥', '🤔'] },
|
|
37
|
+
WRITING: { label: 'writing', chain: ['✍', '🤔'] },
|
|
38
|
+
// 0.8.0-rc.11: terminal "your follow-up was incorporated into the
|
|
39
|
+
// in-flight turn" state. Used by polygram's autosteer block when a
|
|
40
|
+
// mid-turn user message is buffered for the next PostToolBatch
|
|
41
|
+
// injection.
|
|
42
|
+
AUTOSTEERED: { label: 'autosteered', chain: ['✍', '👀'] },
|
|
43
|
+
DONE: { label: 'done', chain: ['👍'] },
|
|
44
|
+
ERROR: { label: 'error', chain: ['🤯', '🤔'] },
|
|
45
|
+
STALL: { label: 'stall', chain: ['🥱', '🤔'] },
|
|
46
|
+
TIMEOUT: { label: 'timeout', chain: ['😨', '🤯'] },
|
|
42
47
|
};
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
// Terminal states bypass throttle, disarm stall promotion, and the
|
|
50
|
+
// reactor stays at this emoji until explicitly cleared. AUTOSTEERED
|
|
51
|
+
// is included so setState('AUTOSTEERED') flushes immediately
|
|
52
|
+
// (matters because the autosteer code path returns from
|
|
53
|
+
// handleMessage right after — we don't want the apply to be
|
|
54
|
+
// scheduled-and-cancelled by reactor.stop in the outer finally).
|
|
55
|
+
const TERMINAL_STATES = new Set(['DONE', 'ERROR', 'TIMEOUT', 'AUTOSTEERED']);
|
|
45
56
|
const DEFAULT_THROTTLE_MS = 800;
|
|
46
57
|
// 0.7.4 (item A): after this long with no setState() call (Claude is
|
|
47
58
|
// silently chugging on a long tool / model latency), auto-flip to STALL
|
|
@@ -132,24 +143,40 @@ function createReactionManager({
|
|
|
132
143
|
let stallTimer = null;
|
|
133
144
|
let freezeTimer = null;
|
|
134
145
|
let stopped = false;
|
|
146
|
+
// 0.8.0-rc.11: serialize Telegram setMessageReaction calls. Without
|
|
147
|
+
// this, multiple flush()es race at the network layer because each
|
|
148
|
+
// calls `await apply(emoji)` from a separate stack — Telegram
|
|
149
|
+
// processes them in arbitrary order and the FINAL visible state is
|
|
150
|
+
// whichever apply landed last. Symptom: 👀 stuck on autosteered
|
|
151
|
+
// messages when the QUEUED apply landed AFTER our explicit ✍ apply.
|
|
152
|
+
// Chaining all applies through `applyChain` guarantees they're sent
|
|
153
|
+
// to Telegram in setState() invocation order.
|
|
154
|
+
let applyChain = Promise.resolve();
|
|
135
155
|
// States the auto-stall path may transition to. Once we've already
|
|
136
156
|
// shown STALL or TIMEOUT we don't downgrade or rearm — only an
|
|
137
157
|
// explicit setState() call (Claude resumed) can move us forward.
|
|
138
158
|
const STALL_PROMOTABLE = new Set(['THINKING', 'CODING', 'WEB', 'TOOL', 'WRITING']);
|
|
139
159
|
|
|
140
160
|
const flush = async (stateName) => {
|
|
141
|
-
if (stopped) return;
|
|
161
|
+
if (stopped && !TERMINAL_STATES.has(stateName)) return;
|
|
142
162
|
const spec = STATES[stateName];
|
|
143
163
|
if (!spec) return;
|
|
144
164
|
const emoji = resolveEmoji(spec.chain, availableEmojis);
|
|
145
165
|
if (emoji === currentEmoji) return;
|
|
146
166
|
currentEmoji = emoji;
|
|
147
167
|
lastFlushTs = Date.now();
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
168
|
+
// Chain through applyChain so concurrent flushes are sent to
|
|
169
|
+
// Telegram serially in invocation order. Returning the chain
|
|
170
|
+
// promise lets callers await this specific flush completing.
|
|
171
|
+
const myApply = applyChain.then(async () => {
|
|
172
|
+
try {
|
|
173
|
+
await apply(emoji);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
logError(`reaction apply failed (${stateName} → ${emoji}): ${err?.message || err}`);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
applyChain = myApply;
|
|
179
|
+
return myApply;
|
|
153
180
|
};
|
|
154
181
|
|
|
155
182
|
const clearStallTimers = () => {
|
|
@@ -217,8 +244,16 @@ function createReactionManager({
|
|
|
217
244
|
clearStallTimers();
|
|
218
245
|
if (currentEmoji == null) return;
|
|
219
246
|
currentEmoji = null;
|
|
220
|
-
|
|
221
|
-
|
|
247
|
+
// Same applyChain serialization as flush — clear() is a state
|
|
248
|
+
// transition, just to "no emoji". Without chaining, a clear()
|
|
249
|
+
// racing with a pending apply (e.g. THINKING flush in flight)
|
|
250
|
+
// could land BEFORE that apply, leaving the emoji visible.
|
|
251
|
+
const myApply = applyChain.then(async () => {
|
|
252
|
+
try { await apply(null); }
|
|
253
|
+
catch (err) { logError(`reaction clear failed: ${err?.message || err}`); }
|
|
254
|
+
});
|
|
255
|
+
applyChain = myApply;
|
|
256
|
+
return myApply;
|
|
222
257
|
};
|
|
223
258
|
|
|
224
259
|
const stop = () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.8.0-rc.
|
|
3
|
+
"version": "0.8.0-rc.11",
|
|
4
4
|
"description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
|
|
5
5
|
"main": "lib/ipc-client.js",
|
|
6
6
|
"bin": {
|
package/polygram.js
CHANGED
|
@@ -31,6 +31,7 @@ const { ProcessManager } = require('./lib/process-manager');
|
|
|
31
31
|
// pick-at-startup. Phase 4 deletes the CLI version after Phase 5
|
|
32
32
|
// soak proves SDK stable. See docs/0.8.0-architecture-decisions.md.
|
|
33
33
|
const { ProcessManagerSdk } = require('./lib/process-manager-sdk');
|
|
34
|
+
const { createAutosteerBuffer } = require('./lib/autosteer-buffer');
|
|
34
35
|
const agentLoader = require('./lib/agent-loader');
|
|
35
36
|
const USE_SDK = process.env.POLYGRAM_USE_SDK === '1';
|
|
36
37
|
const { createSender } = require('./lib/telegram');
|
|
@@ -698,6 +699,14 @@ function formatPrompt(msg, sessionCtx, attachments = []) {
|
|
|
698
699
|
|
|
699
700
|
let pm = null; // ProcessManager, created in main()
|
|
700
701
|
|
|
702
|
+
// 0.8.0-rc.9: per-session autosteer buffer. Holds user follow-ups
|
|
703
|
+
// that arrive mid-turn so the SDK pm's PostToolBatch hook can drain
|
|
704
|
+
// them into `additionalContext` on each tool boundary. Replaces the
|
|
705
|
+
// rc.6/rc.7 approach of pushing priority:'now' SDKUserMessages
|
|
706
|
+
// directly (which violated the SDK's m87 transcript-shape gate when
|
|
707
|
+
// the assistant was mid-tool-use).
|
|
708
|
+
const autosteerBuffer = createAutosteerBuffer();
|
|
709
|
+
|
|
701
710
|
function spawnClaude(sessionKey, ctx) {
|
|
702
711
|
const { chatConfig, existingSessionId, label, chatId } = ctx;
|
|
703
712
|
// 0.7.3: Claude Code's Chrome-extension integration (browser
|
|
@@ -817,6 +826,40 @@ function buildSdkOptions(sessionKey, ctx) {
|
|
|
817
826
|
const useCanUseTool = apprCfg && apprCfg.adminChatId
|
|
818
827
|
&& Array.isArray(apprCfg.gatedTools) && apprCfg.gatedTools.length > 0;
|
|
819
828
|
|
|
829
|
+
// 0.8.0-rc.9: PostToolBatch hook drains the autosteer buffer for
|
|
830
|
+
// this session and injects queued user follow-ups as
|
|
831
|
+
// `additionalContext` on each tool boundary. Framing matters:
|
|
832
|
+
// wrapping in `<channel source="user-followup">…</channel>` is
|
|
833
|
+
// what Claude is trained to trust as legitimate out-of-band user
|
|
834
|
+
// context (verified live via post-tool-batch-spike-v2.mjs); the
|
|
835
|
+
// earlier `<user_message_during_turn>` framing tripped the
|
|
836
|
+
// model's prompt-injection defense and got refused.
|
|
837
|
+
const postToolBatchHook = async () => {
|
|
838
|
+
try {
|
|
839
|
+
const drained = autosteerBuffer.drain(sessionKey);
|
|
840
|
+
if (drained.length === 0) return { continue: true };
|
|
841
|
+
const additionalContext = autosteerBuffer.formatForHook(drained);
|
|
842
|
+
logEvent('autosteer-hook-drained', {
|
|
843
|
+
chat_id: ctx?.chatId ?? null,
|
|
844
|
+
session_key: sessionKey,
|
|
845
|
+
message_count: drained.length,
|
|
846
|
+
});
|
|
847
|
+
return {
|
|
848
|
+
continue: true,
|
|
849
|
+
hookSpecificOutput: {
|
|
850
|
+
hookEventName: 'PostToolBatch',
|
|
851
|
+
additionalContext,
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
} catch (err) {
|
|
855
|
+
console.error(`[${sessionKey}] PostToolBatch hook error: ${err.message}`);
|
|
856
|
+
// Never throw out of a hook — the SDK may treat it as a hard
|
|
857
|
+
// fail (`stop_hook_prevented` result subtype). Drop the
|
|
858
|
+
// queued messages on the floor; the user can re-send.
|
|
859
|
+
return { continue: true };
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
|
|
820
863
|
const baseOpts = {
|
|
821
864
|
model: chatConfig.model || config.defaults.model,
|
|
822
865
|
effort: chatConfig.effort || config.defaults.effort,
|
|
@@ -828,6 +871,9 @@ function buildSdkOptions(sessionKey, ctx) {
|
|
|
828
871
|
permissionMode: useCanUseTool ? 'default' : 'bypassPermissions',
|
|
829
872
|
allowDangerouslySkipPermissions: !useCanUseTool,
|
|
830
873
|
...(useCanUseTool && { canUseTool: makeCanUseTool(sessionKey) }),
|
|
874
|
+
hooks: {
|
|
875
|
+
PostToolBatch: [{ hooks: [postToolBatchHook] }],
|
|
876
|
+
},
|
|
831
877
|
executable: 'node',
|
|
832
878
|
...(existingSessionId && { resume: existingSessionId }),
|
|
833
879
|
...(process.env.POLYGRAM_CLAUDE_BIN && {
|
|
@@ -1709,16 +1755,38 @@ async function handleConfigCallback(ctx) {
|
|
|
1709
1755
|
user: cmdUser, user_id: cmdUserId, source: 'inline-button',
|
|
1710
1756
|
}), `log ${setting} change`);
|
|
1711
1757
|
|
|
1712
|
-
// Graceful
|
|
1758
|
+
// Graceful application of the change to the topic's session. With
|
|
1713
1759
|
// isolateTopics=false sessionKey is the chat (one shared session). With
|
|
1714
1760
|
// isolateTopics=true sessionKey carries the topic, so other topics'
|
|
1715
1761
|
// in-flight turns are not disturbed and the card update + button toast
|
|
1716
|
-
// only affect the user's own context.
|
|
1717
|
-
//
|
|
1762
|
+
// only affect the user's own context.
|
|
1763
|
+
//
|
|
1764
|
+
// CLI pm: requestRespawn drains pending turns then kills the process;
|
|
1765
|
+
// the next user message spawns fresh with the updated chatConfig.
|
|
1766
|
+
// SDK pm: applies live to the running Query via setModel /
|
|
1767
|
+
// applyFlagSettings — no respawn needed, change takes effect for the
|
|
1768
|
+
// rest of the in-flight turn AND all future ones. Falls back to
|
|
1769
|
+
// {killed: false} if neither method is available, leaving the new
|
|
1770
|
+
// chatConfig value to be picked up by the next cold spawn.
|
|
1718
1771
|
const callbackThreadId = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
|
|
1719
1772
|
const callbackSessionKey = getSessionKey(chatId, callbackThreadId, chatConfig);
|
|
1720
1773
|
const reason = setting === 'model' ? 'model-change' : 'effort-change';
|
|
1721
|
-
|
|
1774
|
+
// Feature-detect on the routed pm for this specific session, not on
|
|
1775
|
+
// the router itself (the router exposes every method as a forwarding
|
|
1776
|
+
// shim so `typeof pm.X` is always 'function').
|
|
1777
|
+
const pmForCb = pm.pickFor(callbackSessionKey);
|
|
1778
|
+
let respawn;
|
|
1779
|
+
if (typeof pmForCb.requestRespawn === 'function') {
|
|
1780
|
+
respawn = pmForCb.requestRespawn(callbackSessionKey, reason);
|
|
1781
|
+
} else if (setting === 'effort' && typeof pmForCb.applyFlagSettings === 'function') {
|
|
1782
|
+
const ok = await pmForCb.applyFlagSettings(callbackSessionKey, { effortLevel: value });
|
|
1783
|
+
respawn = { killed: ok };
|
|
1784
|
+
} else if (setting === 'model' && typeof pmForCb.setModel === 'function') {
|
|
1785
|
+
const ok = await pmForCb.setModel(callbackSessionKey, value);
|
|
1786
|
+
respawn = { killed: ok };
|
|
1787
|
+
} else {
|
|
1788
|
+
respawn = { killed: false };
|
|
1789
|
+
}
|
|
1722
1790
|
const anyActive = !respawn.killed;
|
|
1723
1791
|
|
|
1724
1792
|
// Re-render the card with updated ✓ + the same help text shown initially.
|
|
@@ -1873,8 +1941,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1873
1941
|
// usage report. Only meaningful under SDK pm (CLI pm has no
|
|
1874
1942
|
// getContextUsage equivalent); CLI path replies with a hint.
|
|
1875
1943
|
if (botAllowsCommands && text === '/context') {
|
|
1876
|
-
if (!
|
|
1877
|
-
await sendReply('📚 /context requires the SDK pm
|
|
1944
|
+
if (!pm.isSdkFor(sessionKey)) {
|
|
1945
|
+
await sendReply('📚 /context requires the SDK pm. This chat is on the CLI pm path.');
|
|
1878
1946
|
return;
|
|
1879
1947
|
}
|
|
1880
1948
|
const entry = pm.get(sessionKey);
|
|
@@ -1885,13 +1953,18 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1885
1953
|
}
|
|
1886
1954
|
try {
|
|
1887
1955
|
const u = await q.getContextUsage();
|
|
1888
|
-
|
|
1956
|
+
// SDK returns percentage in 0-100 scale (verified rc.3 prod
|
|
1957
|
+
// — saw "77" for a 77%-used context). Display directly.
|
|
1958
|
+
const pct = (u?.percentage ?? 0).toFixed(0);
|
|
1889
1959
|
const total = (u?.totalTokens ?? 0).toLocaleString();
|
|
1890
1960
|
const max = (u?.maxTokens ?? 0).toLocaleString();
|
|
1891
1961
|
const lines = [`📚 Context: ${total} / ${max} tokens (${pct}%)`];
|
|
1892
1962
|
if (u?.model) lines.push(`Model: ${u.model}`);
|
|
1893
1963
|
if (u?.isAutoCompactEnabled && u?.autoCompactThreshold) {
|
|
1894
|
-
|
|
1964
|
+
// autoCompactThreshold scale is currently unverified; assume
|
|
1965
|
+
// matches percentage (0-100). If it turns out to be 0-1 we'll
|
|
1966
|
+
// see something like "Auto-compact at 0%" and can flip back.
|
|
1967
|
+
const thrPct = u.autoCompactThreshold.toFixed(0);
|
|
1895
1968
|
lines.push(`Auto-compact at ${thrPct}%.`);
|
|
1896
1969
|
}
|
|
1897
1970
|
// Top-3 categories by token cost so the user knows where the
|
|
@@ -1914,9 +1987,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1914
1987
|
}
|
|
1915
1988
|
if (botAllowsCommands && (text === '/new' || text === '/reset')) {
|
|
1916
1989
|
let drained = 0;
|
|
1917
|
-
|
|
1990
|
+
const target = pm.pickFor(sessionKey);
|
|
1991
|
+
if (typeof target.resetSession === 'function') {
|
|
1918
1992
|
try {
|
|
1919
|
-
const r = await
|
|
1993
|
+
const r = await target.resetSession(sessionKey, { reason: text.slice(1) });
|
|
1920
1994
|
drained = r?.drainedPendings ?? 0;
|
|
1921
1995
|
} catch (err) {
|
|
1922
1996
|
console.error(`[${label}] resetSession ${text}: ${err.message}`);
|
|
@@ -1938,48 +2012,39 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1938
2012
|
await sendReply('✨ Started a fresh session.');
|
|
1939
2013
|
return;
|
|
1940
2014
|
}
|
|
1941
|
-
// 0.8.0
|
|
1942
|
-
//
|
|
1943
|
-
//
|
|
1944
|
-
//
|
|
1945
|
-
//
|
|
1946
|
-
//
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
// session for the whole chat — every topic respawns implicitly).
|
|
1975
|
-
// With isolateTopics=true each topic is a separate session, and a
|
|
1976
|
-
// /model in topic A should NOT disturb topic B's in-flight turn or
|
|
1977
|
-
// post a phantom "✓ Using sonnet now" in a topic that didn't ask.
|
|
1978
|
-
// Pre-0.6.5 this iterated pm.keys() by chat prefix and incorrectly
|
|
1979
|
-
// fanned out across all topics under isolateTopics=true.
|
|
1980
|
-
const requestRespawnForSession = (reason) => {
|
|
1981
|
-
const res = pm.requestRespawn(sessionKey, reason);
|
|
1982
|
-
return { queued: res.queued, anyActive: !res.killed };
|
|
2015
|
+
// 0.8.0-rc.9: /steer command removed. Mid-turn user input is
|
|
2016
|
+
// handled implicitly by autosteer — any follow-up message during
|
|
2017
|
+
// an in-flight SDK turn flows through autosteerBuffer +
|
|
2018
|
+
// PostToolBatch hook. No explicit command needed; matches Claude
|
|
2019
|
+
// Code interactive UX where you just keep typing.
|
|
2020
|
+
// Graceful application of a model/effort change to the user's CURRENT
|
|
2021
|
+
// session only. With isolateTopics=false the sessionKey is just the
|
|
2022
|
+
// chat (one shared session for the whole chat — every topic
|
|
2023
|
+
// respawns implicitly). With isolateTopics=true each topic is a
|
|
2024
|
+
// separate session, and a /model in topic A should NOT disturb
|
|
2025
|
+
// topic B's in-flight turn or post a phantom "✓ Using sonnet now"
|
|
2026
|
+
// in a topic that didn't ask.
|
|
2027
|
+
//
|
|
2028
|
+
// CLI pm: requestRespawn drains pending turns then kills the process;
|
|
2029
|
+
// the next user message spawns fresh with the updated chatConfig.
|
|
2030
|
+
// SDK pm: applies live to the running Query via setModel /
|
|
2031
|
+
// applyFlagSettings — no respawn needed, change takes effect for
|
|
2032
|
+
// the rest of the in-flight turn AND all future ones.
|
|
2033
|
+
const applyConfigChange = async (reason, setting, value) => {
|
|
2034
|
+
const target = pm.pickFor(sessionKey);
|
|
2035
|
+
if (typeof target.requestRespawn === 'function') {
|
|
2036
|
+
const res = target.requestRespawn(sessionKey, reason);
|
|
2037
|
+
return { queued: res.queued, anyActive: !res.killed };
|
|
2038
|
+
}
|
|
2039
|
+
if (setting === 'effort' && typeof target.applyFlagSettings === 'function') {
|
|
2040
|
+
const ok = await target.applyFlagSettings(sessionKey, { effortLevel: value });
|
|
2041
|
+
return { queued: 0, anyActive: !ok };
|
|
2042
|
+
}
|
|
2043
|
+
if (setting === 'model' && typeof target.setModel === 'function') {
|
|
2044
|
+
const ok = await target.setModel(sessionKey, value);
|
|
2045
|
+
return { queued: 0, anyActive: !ok };
|
|
2046
|
+
}
|
|
2047
|
+
return { queued: 0, anyActive: false };
|
|
1983
2048
|
};
|
|
1984
2049
|
|
|
1985
2050
|
if (botAllowsCommands && text.startsWith('/model ')) {
|
|
@@ -1993,7 +2058,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1993
2058
|
old_value: oldModel, new_value: newModel,
|
|
1994
2059
|
user: cmdUser, user_id: cmdUserId, source: 'command',
|
|
1995
2060
|
}), 'log model change');
|
|
1996
|
-
const { anyActive } =
|
|
2061
|
+
const { anyActive } = await applyConfigChange('model-change', 'model', newModel);
|
|
1997
2062
|
const ver = MODEL_VERSIONS[newModel] || newModel;
|
|
1998
2063
|
const suffix = anyActive ? ` — I'll switch when I finish` : '';
|
|
1999
2064
|
await sendReply(`Model → ${newModel} (${ver})${suffix}`);
|
|
@@ -2013,7 +2078,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2013
2078
|
old_value: oldEffort, new_value: newEffort,
|
|
2014
2079
|
user: cmdUser, user_id: cmdUserId, source: 'command',
|
|
2015
2080
|
}), 'log effort change');
|
|
2016
|
-
const { anyActive } =
|
|
2081
|
+
const { anyActive } = await applyConfigChange('effort-change', 'effort', newEffort);
|
|
2017
2082
|
const suffix = anyActive ? ` — I'll switch when I finish` : '';
|
|
2018
2083
|
await sendReply(`Effort → ${newEffort}${suffix}`);
|
|
2019
2084
|
} else {
|
|
@@ -2366,34 +2431,54 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2366
2431
|
// chatConfig.autosteer === false). CLI pm always falls through
|
|
2367
2432
|
// to the queue-FIFO path (no steer primitive on stream-json).
|
|
2368
2433
|
//
|
|
2369
|
-
// The steered message gets a
|
|
2434
|
+
// The steered message gets a ✍ reaction so the user knows it
|
|
2370
2435
|
// landed; no separate reply is generated (the in-flight turn's
|
|
2371
2436
|
// response covers both messages, OpenClaw-style).
|
|
2437
|
+
//
|
|
2438
|
+
// Reaction emoji must be from Telegram's curated allowlist
|
|
2439
|
+
// (~60 standard emoji per core.telegram.org/bots/api#availablereactions).
|
|
2440
|
+
// 🛞 (steering wheel) is NOT on it — Telegram returns
|
|
2441
|
+
// 400: REACTION_INVALID. ✍ ("writing/noting") is on the list and
|
|
2442
|
+
// conveys "incorporating this".
|
|
2372
2443
|
const chatAutosteer = chatConfig.autosteer != null
|
|
2373
2444
|
? chatConfig.autosteer
|
|
2374
2445
|
: config.bot?.autosteer;
|
|
2375
|
-
|
|
2376
|
-
|
|
2446
|
+
// 0.8.0-rc.9: autosteer now drives through autosteerBuffer +
|
|
2447
|
+
// PostToolBatch hook (in buildSdkOptions), not pm.steer's direct
|
|
2448
|
+
// inputController push. The hook fires on every tool boundary
|
|
2449
|
+
// and injects queued follow-ups as <channel source="user-followup">
|
|
2450
|
+
// additionalContext — the SDK-trusted framing that survives the
|
|
2451
|
+
// m87 transcript-shape gate.
|
|
2452
|
+
//
|
|
2453
|
+
// We still gate on the SDK pm path: under CLI pm there's no
|
|
2454
|
+
// PostToolBatch hook surface, so autosteer falls through to the
|
|
2455
|
+
// regular FIFO send (same UX as 0.7.x).
|
|
2456
|
+
const autosteerEnabled = chatAutosteer !== false
|
|
2457
|
+
&& pm.isSdkFor(sessionKey);
|
|
2458
|
+
if (autosteerEnabled && pm.has(sessionKey)) {
|
|
2377
2459
|
const entry = pm.get(sessionKey);
|
|
2378
2460
|
if (entry?.inFlight) {
|
|
2379
|
-
const ok =
|
|
2461
|
+
const ok = autosteerBuffer.append(sessionKey, prompt);
|
|
2380
2462
|
if (ok) {
|
|
2381
|
-
// Quiet ack — no chat-bubble reply, just a reaction so the
|
|
2382
|
-
// user sees their message was incorporated. The in-flight
|
|
2383
|
-
// turn's response will address both questions.
|
|
2384
|
-
tg(bot, 'setMessageReaction', {
|
|
2385
|
-
chat_id: chatId,
|
|
2386
|
-
message_id: msg.message_id,
|
|
2387
|
-
reaction: [{ type: 'emoji', emoji: '🛞' }],
|
|
2388
|
-
}, { source: 'autosteer-ack', botName: BOT_NAME }).catch((err) => {
|
|
2389
|
-
console.error(`[${label}] autosteer reaction: ${err.message}`);
|
|
2390
|
-
});
|
|
2391
2463
|
logEvent('autosteer', {
|
|
2392
2464
|
chat_id: chatId, msg_id: msg.message_id,
|
|
2393
2465
|
text_len: prompt?.length ?? 0,
|
|
2394
2466
|
});
|
|
2395
2467
|
stopTyping();
|
|
2396
|
-
reactor
|
|
2468
|
+
// 0.8.0-rc.11: route the ✍ ack through the reactor's
|
|
2469
|
+
// serialized apply chain. Pre-rc.11 we used a direct
|
|
2470
|
+
// setMessageReaction(✍) racing with the reactor's
|
|
2471
|
+
// QUEUED→👀 apply AND a follow-up reactor.clear() — three
|
|
2472
|
+
// concurrent network calls, final state was whichever
|
|
2473
|
+
// landed last at Telegram. Symptom: 👀 sometimes stuck,
|
|
2474
|
+
// ✍ sometimes vanished, reactions disappeared "almost
|
|
2475
|
+
// immediately" or got stuck arbitrarily.
|
|
2476
|
+
//
|
|
2477
|
+
// setState('AUTOSTEERED') is terminal so it bypasses the
|
|
2478
|
+
// 800ms throttle and flushes synchronously through
|
|
2479
|
+
// applyChain — so it serializes after any in-flight
|
|
2480
|
+
// QUEUED apply and lands as the final visible reaction.
|
|
2481
|
+
await reactor.setState('AUTOSTEERED');
|
|
2397
2482
|
markReplied();
|
|
2398
2483
|
return;
|
|
2399
2484
|
}
|
|
@@ -2454,8 +2539,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2454
2539
|
// Only fires when pm.resetSession is available (SDK pm
|
|
2455
2540
|
// path); CLI pm doesn't have the method.
|
|
2456
2541
|
const cls = classifyError(result.error);
|
|
2457
|
-
|
|
2458
|
-
|
|
2542
|
+
const recoverTarget = pm.pickFor(sessionKey);
|
|
2543
|
+
if (cls.autoRecover === 'reset_session' && typeof recoverTarget.resetSession === 'function') {
|
|
2544
|
+
recoverTarget.resetSession(sessionKey, { reason: cls.kind })
|
|
2459
2545
|
.catch((err) => console.error(`[${label}] auto-reset failed: ${err.message}`));
|
|
2460
2546
|
logEvent('auto-recover', {
|
|
2461
2547
|
chat_id: chatId, kind: cls.kind, action: 'reset_session',
|
|
@@ -2484,16 +2570,20 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2484
2570
|
// SDK pm only — CLI pm has no equivalent (no Query object,
|
|
2485
2571
|
// no getContextUsage). Per-bot opt-out via
|
|
2486
2572
|
// config.bot.contextHint = false.
|
|
2487
|
-
if (
|
|
2573
|
+
if (pm.isSdkFor(sessionKey) && config.bot?.contextHint !== false) {
|
|
2488
2574
|
const entry = pm.get(sessionKey);
|
|
2489
2575
|
const q = entry?.query;
|
|
2490
2576
|
if (q && typeof q.getContextUsage === 'function') {
|
|
2491
2577
|
q.getContextUsage().then((usage) => {
|
|
2578
|
+
// SDK returns percentage in 0-100 scale, not 0-1.
|
|
2579
|
+
// Pre-rc.4 we treated it as a 0-1 ratio and multiplied
|
|
2580
|
+
// by 100, which displayed "7700% full" for a 77%-used
|
|
2581
|
+
// context (and fired below the intended 85% threshold).
|
|
2492
2582
|
const pct = usage?.percentage ?? 0;
|
|
2493
|
-
if (pct <
|
|
2583
|
+
if (pct < 85) return;
|
|
2494
2584
|
return tg(bot, 'sendMessage', {
|
|
2495
2585
|
chat_id: chatId,
|
|
2496
|
-
text: `📚 Context window ${
|
|
2586
|
+
text: `📚 Context window ${pct.toFixed(0)}% full. Send /new to start fresh — older messages will start dropping soon.`,
|
|
2497
2587
|
...(threadId ? { message_thread_id: threadId } : {}),
|
|
2498
2588
|
}, { source: 'context-full-hint', botName: BOT_NAME });
|
|
2499
2589
|
}).catch((err) => {
|
|
@@ -2512,6 +2602,26 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2512
2602
|
// those still markReplied silently.
|
|
2513
2603
|
if (result.text === 'NO_REPLY') { markReplied(); return; }
|
|
2514
2604
|
if (!result.text) {
|
|
2605
|
+
// 0.8.0-rc.7: tool-only completion is NOT an error. Under SDK
|
|
2606
|
+
// pm, a turn that ends after running tools (no closing text
|
|
2607
|
+
// block) leaves result.text empty even though the bot DID
|
|
2608
|
+
// respond — via tool side effects the user already saw. Don't
|
|
2609
|
+
// post a "No response generated" apology in that case; it's
|
|
2610
|
+
// confusing and it spams the chat. Just clear the reactor
|
|
2611
|
+
// (otherwise 👀 stays stuck — reactor.stop() doesn't remove
|
|
2612
|
+
// the emoji visually) and silently mark replied.
|
|
2613
|
+
const toolOnlyTurn = (result.metrics?.numToolUses ?? 0) > 0
|
|
2614
|
+
&& (result.metrics?.numAssistantMessages ?? 0) > 0;
|
|
2615
|
+
if (toolOnlyTurn) {
|
|
2616
|
+
await reactor.clear().catch(() => {});
|
|
2617
|
+
logEvent('tool-only-completion', {
|
|
2618
|
+
chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
|
|
2619
|
+
num_tool_uses: result.metrics?.numToolUses,
|
|
2620
|
+
num_assistant_messages: result.metrics?.numAssistantMessages,
|
|
2621
|
+
});
|
|
2622
|
+
markReplied();
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2515
2625
|
// 0.7.1: if the fallback send itself fails, throw rather than
|
|
2516
2626
|
// silently markReplied — the user gets nothing AND the inbound
|
|
2517
2627
|
// is marked replied so boot replay won't redispatch. Same
|
|
@@ -2537,6 +2647,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2537
2647
|
logEvent('telegram-empty-response-fallback', {
|
|
2538
2648
|
chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
|
|
2539
2649
|
});
|
|
2650
|
+
// 0.8.0-rc.7: clear the THINKING/QUEUED emoji on the user's
|
|
2651
|
+
// message so 👀 doesn't stay stuck after the apology lands.
|
|
2652
|
+
// reactor.stop() (in the finally block) only kills timers; it
|
|
2653
|
+
// does NOT remove the visible emoji. Without this clear, the
|
|
2654
|
+
// user sees 👀 next to their message indefinitely.
|
|
2655
|
+
await reactor.clear().catch(() => {});
|
|
2540
2656
|
markReplied();
|
|
2541
2657
|
return;
|
|
2542
2658
|
}
|
|
@@ -2716,7 +2832,7 @@ function createBot(token) {
|
|
|
2716
2832
|
// Cached once @botUsername is known — was recompiling per inbound msg.
|
|
2717
2833
|
let mentionRe = null;
|
|
2718
2834
|
// Hoisted admin-command matcher; was re-allocated per message.
|
|
2719
|
-
const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context
|
|
2835
|
+
const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context)(\s|$)/;
|
|
2720
2836
|
const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
|
|
2721
2837
|
|
|
2722
2838
|
// The filter in main() guarantees config.chats only contains chats owned
|
|
@@ -2860,14 +2976,15 @@ function createBot(token) {
|
|
|
2860
2976
|
// sessionKey is the chat itself, so killing one session is
|
|
2861
2977
|
// the same as killing the chat — behavior unchanged for the
|
|
2862
2978
|
// common case.
|
|
2863
|
-
|
|
2864
|
-
|
|
2979
|
+
const stopTarget = pm.pickFor(sessionKey);
|
|
2980
|
+
if (typeof stopTarget.interrupt === 'function') {
|
|
2981
|
+
await stopTarget.interrupt(sessionKey).catch((err) =>
|
|
2865
2982
|
console.error(`[${BOT_NAME}] interrupt failed: ${err.message}`));
|
|
2866
|
-
if (typeof
|
|
2867
|
-
|
|
2983
|
+
if (typeof stopTarget.drainQueue === 'function') {
|
|
2984
|
+
stopTarget.drainQueue(sessionKey, 'INTERRUPTED');
|
|
2868
2985
|
}
|
|
2869
2986
|
} else {
|
|
2870
|
-
await
|
|
2987
|
+
await stopTarget.kill(sessionKey).catch((err) =>
|
|
2871
2988
|
console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
|
|
2872
2989
|
}
|
|
2873
2990
|
logEvent('abort-requested', {
|
|
@@ -3308,17 +3425,37 @@ async function main() {
|
|
|
3308
3425
|
});
|
|
3309
3426
|
|
|
3310
3427
|
const cap = config.maxWarmProcesses || DEFAULT_MAX_WARM_PROCS;
|
|
3311
|
-
|
|
3312
|
-
//
|
|
3313
|
-
//
|
|
3314
|
-
//
|
|
3315
|
-
//
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3428
|
+
|
|
3429
|
+
// 0.8.0-rc.6: per-chat pm selection. Three modes:
|
|
3430
|
+
// 1. POLYGRAM_USE_SDK=1 with no POLYGRAM_SDK_CHATS list → all chats SDK
|
|
3431
|
+
// 2. POLYGRAM_SDK_CHATS=id1,id2,... → those chats
|
|
3432
|
+
// use SDK; everyone else uses CLI (both pms live in the daemon)
|
|
3433
|
+
// 3. neither set → all chats CLI
|
|
3434
|
+
// The per-chat mode lets us soak SDK pm against real traffic in one
|
|
3435
|
+
// chat (Ivan's DM) while keeping partner-facing chats on the
|
|
3436
|
+
// battle-tested CLI path. When both pms run, killChat /shutdown
|
|
3437
|
+
// broadcast to both; everything else routes per-sessionKey via
|
|
3438
|
+
// pickPmFor() based on the chat's set membership.
|
|
3439
|
+
const sdkChatIdSet = new Set(
|
|
3440
|
+
String(process.env.POLYGRAM_SDK_CHATS || '')
|
|
3441
|
+
.split(',').map((s) => s.trim()).filter(Boolean)
|
|
3442
|
+
);
|
|
3443
|
+
const sdkAllChats = USE_SDK && sdkChatIdSet.size === 0;
|
|
3444
|
+
const sdkSomeChats = sdkChatIdSet.size > 0;
|
|
3445
|
+
const sdkActive = sdkAllChats || sdkSomeChats;
|
|
3446
|
+
|
|
3447
|
+
function pickPmKindFor(sessionKey) {
|
|
3448
|
+
if (sdkAllChats) return 'sdk';
|
|
3449
|
+
if (!sdkSomeChats) return 'cli';
|
|
3450
|
+
const chatId = String(getChatIdFromKey(sessionKey) ?? '');
|
|
3451
|
+
return sdkChatIdSet.has(chatId) ? 'sdk' : 'cli';
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
// Shared callbacks: identical instance passed to both pms so a
|
|
3455
|
+
// chat's lifecycle events look the same regardless of which pm
|
|
3456
|
+
// is handling it.
|
|
3457
|
+
const pmOpts = {
|
|
3320
3458
|
cap,
|
|
3321
|
-
spawnFn,
|
|
3322
3459
|
db,
|
|
3323
3460
|
logger: console,
|
|
3324
3461
|
onInit: (sessionKey, event, entry) => {
|
|
@@ -3352,14 +3489,15 @@ async function main() {
|
|
|
3352
3489
|
// 0.7.0 (Phase J): opt-in subagent announce. When Claude uses
|
|
3353
3490
|
// the Task tool to spawn a subagent, post a brief informational
|
|
3354
3491
|
// message to the chat so the user knows a heavier turn is in
|
|
3355
|
-
// progress.
|
|
3356
|
-
// `announceSubagents:
|
|
3357
|
-
// prevents announce-storms in tool-heavy
|
|
3492
|
+
// progress. ON by default (rc.9+) — set per-chat
|
|
3493
|
+
// `announceSubagents: false` (or per-bot) to silence.
|
|
3494
|
+
// Per-chat debounce 30s prevents announce-storms in tool-heavy
|
|
3495
|
+
// turns.
|
|
3358
3496
|
const chatCfg = config.chats[entry.chatId] || {};
|
|
3359
|
-
const
|
|
3360
|
-
? chatCfg.announceSubagents
|
|
3361
|
-
: config.bot?.announceSubagents;
|
|
3362
|
-
if (toolName === 'Task' &&
|
|
3497
|
+
const optOut = chatCfg.announceSubagents != null
|
|
3498
|
+
? chatCfg.announceSubagents === false
|
|
3499
|
+
: config.bot?.announceSubagents === false;
|
|
3500
|
+
if (toolName === 'Task' && !optOut) {
|
|
3363
3501
|
if (shouldAnnounce(entry.chatId)) {
|
|
3364
3502
|
announce({
|
|
3365
3503
|
send: (b, method, params, m) => tg(b, method, params, m),
|
|
@@ -3433,7 +3571,104 @@ async function main() {
|
|
|
3433
3571
|
...(threadId && { message_thread_id: threadId }),
|
|
3434
3572
|
}, { source: 'respawn-confirm', botName: BOT_NAME }).catch(() => {});
|
|
3435
3573
|
},
|
|
3436
|
-
}
|
|
3574
|
+
};
|
|
3575
|
+
|
|
3576
|
+
// Instantiate the actual pm(s). When sdkActive is false we still
|
|
3577
|
+
// build a CLI pm; SDK pm is null. When sdkActive is true we always
|
|
3578
|
+
// build BOTH so chats outside the SDK list still get the CLI path.
|
|
3579
|
+
const cliPm = new ProcessManager({ ...pmOpts, spawnFn: spawnClaude });
|
|
3580
|
+
const sdkPm = sdkActive
|
|
3581
|
+
? new ProcessManagerSdk({ ...pmOpts, spawnFn: buildSdkOptions })
|
|
3582
|
+
: null;
|
|
3583
|
+
|
|
3584
|
+
// Routing pm: same surface as a single pm, but per-method routing
|
|
3585
|
+
// through pickPmKindFor(sessionKey). Methods that don't take a
|
|
3586
|
+
// sessionKey (killChat by chatId, shutdown) broadcast to both.
|
|
3587
|
+
// For optional methods (steer / setModel / applyFlagSettings /
|
|
3588
|
+
// requestRespawn / drainQueue / interrupt / resetSession) we
|
|
3589
|
+
// forward when the routed pm has the method and return a
|
|
3590
|
+
// sentinel otherwise — so feature-detection at the call site
|
|
3591
|
+
// still works via `typeof pm.pickFor(sessionKey).X === 'function'`.
|
|
3592
|
+
pm = (() => {
|
|
3593
|
+
function routedPm(sessionKey) {
|
|
3594
|
+
return pickPmKindFor(sessionKey) === 'sdk' && sdkPm ? sdkPm : cliPm;
|
|
3595
|
+
}
|
|
3596
|
+
const router = {
|
|
3597
|
+
pickFor: routedPm,
|
|
3598
|
+
isSdkFor(sessionKey) {
|
|
3599
|
+
return pickPmKindFor(sessionKey) === 'sdk' && !!sdkPm;
|
|
3600
|
+
},
|
|
3601
|
+
has(sessionKey) { return routedPm(sessionKey).has(sessionKey); },
|
|
3602
|
+
get(sessionKey) { return routedPm(sessionKey).get(sessionKey); },
|
|
3603
|
+
getOrSpawn(sessionKey, ctx) { return routedPm(sessionKey).getOrSpawn(sessionKey, ctx); },
|
|
3604
|
+
send(sessionKey, prompt, opts) { return routedPm(sessionKey).send(sessionKey, prompt, opts); },
|
|
3605
|
+
kill(sessionKey) { return routedPm(sessionKey).kill(sessionKey); },
|
|
3606
|
+
async killChat(chatId) {
|
|
3607
|
+
const tasks = [cliPm.killChat(chatId)];
|
|
3608
|
+
if (sdkPm) tasks.push(sdkPm.killChat(chatId));
|
|
3609
|
+
await Promise.all(tasks);
|
|
3610
|
+
},
|
|
3611
|
+
async shutdown() {
|
|
3612
|
+
const tasks = [cliPm.shutdown()];
|
|
3613
|
+
if (sdkPm) tasks.push(sdkPm.shutdown());
|
|
3614
|
+
await Promise.all(tasks);
|
|
3615
|
+
},
|
|
3616
|
+
// Optional methods. The router returns a function — but the
|
|
3617
|
+
// function returns a sentinel if the routed pm doesn't have
|
|
3618
|
+
// the method. Sites that want feature-detection should use
|
|
3619
|
+
// `pm.pickFor(sessionKey)` and check `typeof X === 'function'`
|
|
3620
|
+
// there instead of probing `pm.X` directly.
|
|
3621
|
+
steer(sessionKey, ...args) {
|
|
3622
|
+
const target = routedPm(sessionKey);
|
|
3623
|
+
return typeof target.steer === 'function' ? target.steer(sessionKey, ...args) : false;
|
|
3624
|
+
},
|
|
3625
|
+
resetSession(sessionKey, opts) {
|
|
3626
|
+
const target = routedPm(sessionKey);
|
|
3627
|
+
return typeof target.resetSession === 'function'
|
|
3628
|
+
? target.resetSession(sessionKey, opts)
|
|
3629
|
+
: Promise.resolve({ closed: false, drainedPendings: 0 });
|
|
3630
|
+
},
|
|
3631
|
+
applyFlagSettings(sessionKey, settings) {
|
|
3632
|
+
const target = routedPm(sessionKey);
|
|
3633
|
+
return typeof target.applyFlagSettings === 'function'
|
|
3634
|
+
? target.applyFlagSettings(sessionKey, settings)
|
|
3635
|
+
: Promise.resolve(false);
|
|
3636
|
+
},
|
|
3637
|
+
setModel(sessionKey, model) {
|
|
3638
|
+
const target = routedPm(sessionKey);
|
|
3639
|
+
return typeof target.setModel === 'function'
|
|
3640
|
+
? target.setModel(sessionKey, model)
|
|
3641
|
+
: Promise.resolve(false);
|
|
3642
|
+
},
|
|
3643
|
+
requestRespawn(sessionKey, reason) {
|
|
3644
|
+
const target = routedPm(sessionKey);
|
|
3645
|
+
return typeof target.requestRespawn === 'function'
|
|
3646
|
+
? target.requestRespawn(sessionKey, reason)
|
|
3647
|
+
: { killed: false, queued: 0 };
|
|
3648
|
+
},
|
|
3649
|
+
drainQueue(sessionKey, errCode) {
|
|
3650
|
+
const target = routedPm(sessionKey);
|
|
3651
|
+
return typeof target.drainQueue === 'function'
|
|
3652
|
+
? target.drainQueue(sessionKey, errCode)
|
|
3653
|
+
: 0;
|
|
3654
|
+
},
|
|
3655
|
+
interrupt(sessionKey) {
|
|
3656
|
+
const target = routedPm(sessionKey);
|
|
3657
|
+
return typeof target.interrupt === 'function'
|
|
3658
|
+
? target.interrupt(sessionKey)
|
|
3659
|
+
: Promise.resolve();
|
|
3660
|
+
},
|
|
3661
|
+
};
|
|
3662
|
+
return router;
|
|
3663
|
+
})();
|
|
3664
|
+
|
|
3665
|
+
if (sdkAllChats) {
|
|
3666
|
+
console.log('[polygram] using SDK ProcessManager (all chats)');
|
|
3667
|
+
} else if (sdkSomeChats) {
|
|
3668
|
+
console.log(`[polygram] router active: SDK pm for chats {${Array.from(sdkChatIdSet).join(',')}}, CLI pm for everyone else`);
|
|
3669
|
+
} else {
|
|
3670
|
+
console.log('[polygram] using CLI ProcessManager');
|
|
3671
|
+
}
|
|
3437
3672
|
|
|
3438
3673
|
console.log(`polygram (LRU cap=${cap}, SQLite source of truth)`);
|
|
3439
3674
|
console.log(`Chats: ${Object.entries(config.chats).map(([id, c]) => `${c.name} (${c.model}/${c.effort})`).join(', ')}`);
|