polygram 0.8.0-rc.1 → 0.8.0-rc.10
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/package.json +1 -1
- package/polygram.js +327 -89
|
@@ -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.10",
|
|
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/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.10",
|
|
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,17 +2431,34 @@ 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
2463
|
// Quiet ack — no chat-bubble reply, just a reaction so the
|
|
2382
2464
|
// user sees their message was incorporated. The in-flight
|
|
@@ -2384,7 +2466,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2384
2466
|
tg(bot, 'setMessageReaction', {
|
|
2385
2467
|
chat_id: chatId,
|
|
2386
2468
|
message_id: msg.message_id,
|
|
2387
|
-
reaction: [{ type: 'emoji', emoji: '
|
|
2469
|
+
reaction: [{ type: 'emoji', emoji: '✍' }],
|
|
2388
2470
|
}, { source: 'autosteer-ack', botName: BOT_NAME }).catch((err) => {
|
|
2389
2471
|
console.error(`[${label}] autosteer reaction: ${err.message}`);
|
|
2390
2472
|
});
|
|
@@ -2393,7 +2475,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2393
2475
|
text_len: prompt?.length ?? 0,
|
|
2394
2476
|
});
|
|
2395
2477
|
stopTyping();
|
|
2396
|
-
|
|
2478
|
+
// 0.8.0-rc.8: clear() instead of stop() so the THINKING/QUEUED
|
|
2479
|
+
// 👀 reaction set by the reactor at QUEUED-state actually
|
|
2480
|
+
// disappears from the user's message. reactor.stop() only
|
|
2481
|
+
// cancels timers; the visible emoji persists indefinitely
|
|
2482
|
+
// without an explicit clear() — that's why production showed
|
|
2483
|
+
// 👀 stuck on every steered follow-up under rc.6/rc.7.
|
|
2484
|
+
await reactor.clear().catch(() => {});
|
|
2397
2485
|
markReplied();
|
|
2398
2486
|
return;
|
|
2399
2487
|
}
|
|
@@ -2454,8 +2542,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2454
2542
|
// Only fires when pm.resetSession is available (SDK pm
|
|
2455
2543
|
// path); CLI pm doesn't have the method.
|
|
2456
2544
|
const cls = classifyError(result.error);
|
|
2457
|
-
|
|
2458
|
-
|
|
2545
|
+
const recoverTarget = pm.pickFor(sessionKey);
|
|
2546
|
+
if (cls.autoRecover === 'reset_session' && typeof recoverTarget.resetSession === 'function') {
|
|
2547
|
+
recoverTarget.resetSession(sessionKey, { reason: cls.kind })
|
|
2459
2548
|
.catch((err) => console.error(`[${label}] auto-reset failed: ${err.message}`));
|
|
2460
2549
|
logEvent('auto-recover', {
|
|
2461
2550
|
chat_id: chatId, kind: cls.kind, action: 'reset_session',
|
|
@@ -2484,16 +2573,20 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2484
2573
|
// SDK pm only — CLI pm has no equivalent (no Query object,
|
|
2485
2574
|
// no getContextUsage). Per-bot opt-out via
|
|
2486
2575
|
// config.bot.contextHint = false.
|
|
2487
|
-
if (
|
|
2576
|
+
if (pm.isSdkFor(sessionKey) && config.bot?.contextHint !== false) {
|
|
2488
2577
|
const entry = pm.get(sessionKey);
|
|
2489
2578
|
const q = entry?.query;
|
|
2490
2579
|
if (q && typeof q.getContextUsage === 'function') {
|
|
2491
2580
|
q.getContextUsage().then((usage) => {
|
|
2581
|
+
// SDK returns percentage in 0-100 scale, not 0-1.
|
|
2582
|
+
// Pre-rc.4 we treated it as a 0-1 ratio and multiplied
|
|
2583
|
+
// by 100, which displayed "7700% full" for a 77%-used
|
|
2584
|
+
// context (and fired below the intended 85% threshold).
|
|
2492
2585
|
const pct = usage?.percentage ?? 0;
|
|
2493
|
-
if (pct <
|
|
2586
|
+
if (pct < 85) return;
|
|
2494
2587
|
return tg(bot, 'sendMessage', {
|
|
2495
2588
|
chat_id: chatId,
|
|
2496
|
-
text: `📚 Context window ${
|
|
2589
|
+
text: `📚 Context window ${pct.toFixed(0)}% full. Send /new to start fresh — older messages will start dropping soon.`,
|
|
2497
2590
|
...(threadId ? { message_thread_id: threadId } : {}),
|
|
2498
2591
|
}, { source: 'context-full-hint', botName: BOT_NAME });
|
|
2499
2592
|
}).catch((err) => {
|
|
@@ -2512,6 +2605,26 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2512
2605
|
// those still markReplied silently.
|
|
2513
2606
|
if (result.text === 'NO_REPLY') { markReplied(); return; }
|
|
2514
2607
|
if (!result.text) {
|
|
2608
|
+
// 0.8.0-rc.7: tool-only completion is NOT an error. Under SDK
|
|
2609
|
+
// pm, a turn that ends after running tools (no closing text
|
|
2610
|
+
// block) leaves result.text empty even though the bot DID
|
|
2611
|
+
// respond — via tool side effects the user already saw. Don't
|
|
2612
|
+
// post a "No response generated" apology in that case; it's
|
|
2613
|
+
// confusing and it spams the chat. Just clear the reactor
|
|
2614
|
+
// (otherwise 👀 stays stuck — reactor.stop() doesn't remove
|
|
2615
|
+
// the emoji visually) and silently mark replied.
|
|
2616
|
+
const toolOnlyTurn = (result.metrics?.numToolUses ?? 0) > 0
|
|
2617
|
+
&& (result.metrics?.numAssistantMessages ?? 0) > 0;
|
|
2618
|
+
if (toolOnlyTurn) {
|
|
2619
|
+
await reactor.clear().catch(() => {});
|
|
2620
|
+
logEvent('tool-only-completion', {
|
|
2621
|
+
chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
|
|
2622
|
+
num_tool_uses: result.metrics?.numToolUses,
|
|
2623
|
+
num_assistant_messages: result.metrics?.numAssistantMessages,
|
|
2624
|
+
});
|
|
2625
|
+
markReplied();
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2515
2628
|
// 0.7.1: if the fallback send itself fails, throw rather than
|
|
2516
2629
|
// silently markReplied — the user gets nothing AND the inbound
|
|
2517
2630
|
// is marked replied so boot replay won't redispatch. Same
|
|
@@ -2537,6 +2650,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2537
2650
|
logEvent('telegram-empty-response-fallback', {
|
|
2538
2651
|
chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
|
|
2539
2652
|
});
|
|
2653
|
+
// 0.8.0-rc.7: clear the THINKING/QUEUED emoji on the user's
|
|
2654
|
+
// message so 👀 doesn't stay stuck after the apology lands.
|
|
2655
|
+
// reactor.stop() (in the finally block) only kills timers; it
|
|
2656
|
+
// does NOT remove the visible emoji. Without this clear, the
|
|
2657
|
+
// user sees 👀 next to their message indefinitely.
|
|
2658
|
+
await reactor.clear().catch(() => {});
|
|
2540
2659
|
markReplied();
|
|
2541
2660
|
return;
|
|
2542
2661
|
}
|
|
@@ -2716,7 +2835,7 @@ function createBot(token) {
|
|
|
2716
2835
|
// Cached once @botUsername is known — was recompiling per inbound msg.
|
|
2717
2836
|
let mentionRe = null;
|
|
2718
2837
|
// Hoisted admin-command matcher; was re-allocated per message.
|
|
2719
|
-
const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context
|
|
2838
|
+
const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context)(\s|$)/;
|
|
2720
2839
|
const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
|
|
2721
2840
|
|
|
2722
2841
|
// The filter in main() guarantees config.chats only contains chats owned
|
|
@@ -2860,14 +2979,15 @@ function createBot(token) {
|
|
|
2860
2979
|
// sessionKey is the chat itself, so killing one session is
|
|
2861
2980
|
// the same as killing the chat — behavior unchanged for the
|
|
2862
2981
|
// common case.
|
|
2863
|
-
|
|
2864
|
-
|
|
2982
|
+
const stopTarget = pm.pickFor(sessionKey);
|
|
2983
|
+
if (typeof stopTarget.interrupt === 'function') {
|
|
2984
|
+
await stopTarget.interrupt(sessionKey).catch((err) =>
|
|
2865
2985
|
console.error(`[${BOT_NAME}] interrupt failed: ${err.message}`));
|
|
2866
|
-
if (typeof
|
|
2867
|
-
|
|
2986
|
+
if (typeof stopTarget.drainQueue === 'function') {
|
|
2987
|
+
stopTarget.drainQueue(sessionKey, 'INTERRUPTED');
|
|
2868
2988
|
}
|
|
2869
2989
|
} else {
|
|
2870
|
-
await
|
|
2990
|
+
await stopTarget.kill(sessionKey).catch((err) =>
|
|
2871
2991
|
console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
|
|
2872
2992
|
}
|
|
2873
2993
|
logEvent('abort-requested', {
|
|
@@ -3308,17 +3428,37 @@ async function main() {
|
|
|
3308
3428
|
});
|
|
3309
3429
|
|
|
3310
3430
|
const cap = config.maxWarmProcesses || DEFAULT_MAX_WARM_PROCS;
|
|
3311
|
-
|
|
3312
|
-
//
|
|
3313
|
-
//
|
|
3314
|
-
//
|
|
3315
|
-
//
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3431
|
+
|
|
3432
|
+
// 0.8.0-rc.6: per-chat pm selection. Three modes:
|
|
3433
|
+
// 1. POLYGRAM_USE_SDK=1 with no POLYGRAM_SDK_CHATS list → all chats SDK
|
|
3434
|
+
// 2. POLYGRAM_SDK_CHATS=id1,id2,... → those chats
|
|
3435
|
+
// use SDK; everyone else uses CLI (both pms live in the daemon)
|
|
3436
|
+
// 3. neither set → all chats CLI
|
|
3437
|
+
// The per-chat mode lets us soak SDK pm against real traffic in one
|
|
3438
|
+
// chat (Ivan's DM) while keeping partner-facing chats on the
|
|
3439
|
+
// battle-tested CLI path. When both pms run, killChat /shutdown
|
|
3440
|
+
// broadcast to both; everything else routes per-sessionKey via
|
|
3441
|
+
// pickPmFor() based on the chat's set membership.
|
|
3442
|
+
const sdkChatIdSet = new Set(
|
|
3443
|
+
String(process.env.POLYGRAM_SDK_CHATS || '')
|
|
3444
|
+
.split(',').map((s) => s.trim()).filter(Boolean)
|
|
3445
|
+
);
|
|
3446
|
+
const sdkAllChats = USE_SDK && sdkChatIdSet.size === 0;
|
|
3447
|
+
const sdkSomeChats = sdkChatIdSet.size > 0;
|
|
3448
|
+
const sdkActive = sdkAllChats || sdkSomeChats;
|
|
3449
|
+
|
|
3450
|
+
function pickPmKindFor(sessionKey) {
|
|
3451
|
+
if (sdkAllChats) return 'sdk';
|
|
3452
|
+
if (!sdkSomeChats) return 'cli';
|
|
3453
|
+
const chatId = String(getChatIdFromKey(sessionKey) ?? '');
|
|
3454
|
+
return sdkChatIdSet.has(chatId) ? 'sdk' : 'cli';
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
// Shared callbacks: identical instance passed to both pms so a
|
|
3458
|
+
// chat's lifecycle events look the same regardless of which pm
|
|
3459
|
+
// is handling it.
|
|
3460
|
+
const pmOpts = {
|
|
3320
3461
|
cap,
|
|
3321
|
-
spawnFn,
|
|
3322
3462
|
db,
|
|
3323
3463
|
logger: console,
|
|
3324
3464
|
onInit: (sessionKey, event, entry) => {
|
|
@@ -3352,14 +3492,15 @@ async function main() {
|
|
|
3352
3492
|
// 0.7.0 (Phase J): opt-in subagent announce. When Claude uses
|
|
3353
3493
|
// the Task tool to spawn a subagent, post a brief informational
|
|
3354
3494
|
// message to the chat so the user knows a heavier turn is in
|
|
3355
|
-
// progress.
|
|
3356
|
-
// `announceSubagents:
|
|
3357
|
-
// prevents announce-storms in tool-heavy
|
|
3495
|
+
// progress. ON by default (rc.9+) — set per-chat
|
|
3496
|
+
// `announceSubagents: false` (or per-bot) to silence.
|
|
3497
|
+
// Per-chat debounce 30s prevents announce-storms in tool-heavy
|
|
3498
|
+
// turns.
|
|
3358
3499
|
const chatCfg = config.chats[entry.chatId] || {};
|
|
3359
|
-
const
|
|
3360
|
-
? chatCfg.announceSubagents
|
|
3361
|
-
: config.bot?.announceSubagents;
|
|
3362
|
-
if (toolName === 'Task' &&
|
|
3500
|
+
const optOut = chatCfg.announceSubagents != null
|
|
3501
|
+
? chatCfg.announceSubagents === false
|
|
3502
|
+
: config.bot?.announceSubagents === false;
|
|
3503
|
+
if (toolName === 'Task' && !optOut) {
|
|
3363
3504
|
if (shouldAnnounce(entry.chatId)) {
|
|
3364
3505
|
announce({
|
|
3365
3506
|
send: (b, method, params, m) => tg(b, method, params, m),
|
|
@@ -3433,7 +3574,104 @@ async function main() {
|
|
|
3433
3574
|
...(threadId && { message_thread_id: threadId }),
|
|
3434
3575
|
}, { source: 'respawn-confirm', botName: BOT_NAME }).catch(() => {});
|
|
3435
3576
|
},
|
|
3436
|
-
}
|
|
3577
|
+
};
|
|
3578
|
+
|
|
3579
|
+
// Instantiate the actual pm(s). When sdkActive is false we still
|
|
3580
|
+
// build a CLI pm; SDK pm is null. When sdkActive is true we always
|
|
3581
|
+
// build BOTH so chats outside the SDK list still get the CLI path.
|
|
3582
|
+
const cliPm = new ProcessManager({ ...pmOpts, spawnFn: spawnClaude });
|
|
3583
|
+
const sdkPm = sdkActive
|
|
3584
|
+
? new ProcessManagerSdk({ ...pmOpts, spawnFn: buildSdkOptions })
|
|
3585
|
+
: null;
|
|
3586
|
+
|
|
3587
|
+
// Routing pm: same surface as a single pm, but per-method routing
|
|
3588
|
+
// through pickPmKindFor(sessionKey). Methods that don't take a
|
|
3589
|
+
// sessionKey (killChat by chatId, shutdown) broadcast to both.
|
|
3590
|
+
// For optional methods (steer / setModel / applyFlagSettings /
|
|
3591
|
+
// requestRespawn / drainQueue / interrupt / resetSession) we
|
|
3592
|
+
// forward when the routed pm has the method and return a
|
|
3593
|
+
// sentinel otherwise — so feature-detection at the call site
|
|
3594
|
+
// still works via `typeof pm.pickFor(sessionKey).X === 'function'`.
|
|
3595
|
+
pm = (() => {
|
|
3596
|
+
function routedPm(sessionKey) {
|
|
3597
|
+
return pickPmKindFor(sessionKey) === 'sdk' && sdkPm ? sdkPm : cliPm;
|
|
3598
|
+
}
|
|
3599
|
+
const router = {
|
|
3600
|
+
pickFor: routedPm,
|
|
3601
|
+
isSdkFor(sessionKey) {
|
|
3602
|
+
return pickPmKindFor(sessionKey) === 'sdk' && !!sdkPm;
|
|
3603
|
+
},
|
|
3604
|
+
has(sessionKey) { return routedPm(sessionKey).has(sessionKey); },
|
|
3605
|
+
get(sessionKey) { return routedPm(sessionKey).get(sessionKey); },
|
|
3606
|
+
getOrSpawn(sessionKey, ctx) { return routedPm(sessionKey).getOrSpawn(sessionKey, ctx); },
|
|
3607
|
+
send(sessionKey, prompt, opts) { return routedPm(sessionKey).send(sessionKey, prompt, opts); },
|
|
3608
|
+
kill(sessionKey) { return routedPm(sessionKey).kill(sessionKey); },
|
|
3609
|
+
async killChat(chatId) {
|
|
3610
|
+
const tasks = [cliPm.killChat(chatId)];
|
|
3611
|
+
if (sdkPm) tasks.push(sdkPm.killChat(chatId));
|
|
3612
|
+
await Promise.all(tasks);
|
|
3613
|
+
},
|
|
3614
|
+
async shutdown() {
|
|
3615
|
+
const tasks = [cliPm.shutdown()];
|
|
3616
|
+
if (sdkPm) tasks.push(sdkPm.shutdown());
|
|
3617
|
+
await Promise.all(tasks);
|
|
3618
|
+
},
|
|
3619
|
+
// Optional methods. The router returns a function — but the
|
|
3620
|
+
// function returns a sentinel if the routed pm doesn't have
|
|
3621
|
+
// the method. Sites that want feature-detection should use
|
|
3622
|
+
// `pm.pickFor(sessionKey)` and check `typeof X === 'function'`
|
|
3623
|
+
// there instead of probing `pm.X` directly.
|
|
3624
|
+
steer(sessionKey, ...args) {
|
|
3625
|
+
const target = routedPm(sessionKey);
|
|
3626
|
+
return typeof target.steer === 'function' ? target.steer(sessionKey, ...args) : false;
|
|
3627
|
+
},
|
|
3628
|
+
resetSession(sessionKey, opts) {
|
|
3629
|
+
const target = routedPm(sessionKey);
|
|
3630
|
+
return typeof target.resetSession === 'function'
|
|
3631
|
+
? target.resetSession(sessionKey, opts)
|
|
3632
|
+
: Promise.resolve({ closed: false, drainedPendings: 0 });
|
|
3633
|
+
},
|
|
3634
|
+
applyFlagSettings(sessionKey, settings) {
|
|
3635
|
+
const target = routedPm(sessionKey);
|
|
3636
|
+
return typeof target.applyFlagSettings === 'function'
|
|
3637
|
+
? target.applyFlagSettings(sessionKey, settings)
|
|
3638
|
+
: Promise.resolve(false);
|
|
3639
|
+
},
|
|
3640
|
+
setModel(sessionKey, model) {
|
|
3641
|
+
const target = routedPm(sessionKey);
|
|
3642
|
+
return typeof target.setModel === 'function'
|
|
3643
|
+
? target.setModel(sessionKey, model)
|
|
3644
|
+
: Promise.resolve(false);
|
|
3645
|
+
},
|
|
3646
|
+
requestRespawn(sessionKey, reason) {
|
|
3647
|
+
const target = routedPm(sessionKey);
|
|
3648
|
+
return typeof target.requestRespawn === 'function'
|
|
3649
|
+
? target.requestRespawn(sessionKey, reason)
|
|
3650
|
+
: { killed: false, queued: 0 };
|
|
3651
|
+
},
|
|
3652
|
+
drainQueue(sessionKey, errCode) {
|
|
3653
|
+
const target = routedPm(sessionKey);
|
|
3654
|
+
return typeof target.drainQueue === 'function'
|
|
3655
|
+
? target.drainQueue(sessionKey, errCode)
|
|
3656
|
+
: 0;
|
|
3657
|
+
},
|
|
3658
|
+
interrupt(sessionKey) {
|
|
3659
|
+
const target = routedPm(sessionKey);
|
|
3660
|
+
return typeof target.interrupt === 'function'
|
|
3661
|
+
? target.interrupt(sessionKey)
|
|
3662
|
+
: Promise.resolve();
|
|
3663
|
+
},
|
|
3664
|
+
};
|
|
3665
|
+
return router;
|
|
3666
|
+
})();
|
|
3667
|
+
|
|
3668
|
+
if (sdkAllChats) {
|
|
3669
|
+
console.log('[polygram] using SDK ProcessManager (all chats)');
|
|
3670
|
+
} else if (sdkSomeChats) {
|
|
3671
|
+
console.log(`[polygram] router active: SDK pm for chats {${Array.from(sdkChatIdSet).join(',')}}, CLI pm for everyone else`);
|
|
3672
|
+
} else {
|
|
3673
|
+
console.log('[polygram] using CLI ProcessManager');
|
|
3674
|
+
}
|
|
3437
3675
|
|
|
3438
3676
|
console.log(`polygram (LRU cap=${cap}, SQLite source of truth)`);
|
|
3439
3677
|
console.log(`Chats: ${Object.entries(config.chats).map(([id, c]) => `${c.name} (${c.model}/${c.effort})`).join(', ')}`);
|