polygram 0.8.0-rc.4 → 0.8.0-rc.7
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/error-classify.js +38 -9
- package/lib/process-manager-sdk.js +20 -1
- package/package.json +1 -1
- package/polygram.js +195 -41
|
@@ -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.7",
|
|
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/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.7",
|
|
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
|
@@ -1725,14 +1725,18 @@ async function handleConfigCallback(ctx) {
|
|
|
1725
1725
|
const callbackThreadId = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
|
|
1726
1726
|
const callbackSessionKey = getSessionKey(chatId, callbackThreadId, chatConfig);
|
|
1727
1727
|
const reason = setting === 'model' ? 'model-change' : 'effort-change';
|
|
1728
|
+
// Feature-detect on the routed pm for this specific session, not on
|
|
1729
|
+
// the router itself (the router exposes every method as a forwarding
|
|
1730
|
+
// shim so `typeof pm.X` is always 'function').
|
|
1731
|
+
const pmForCb = pm.pickFor(callbackSessionKey);
|
|
1728
1732
|
let respawn;
|
|
1729
|
-
if (typeof
|
|
1730
|
-
respawn =
|
|
1731
|
-
} else if (setting === 'effort' && typeof
|
|
1732
|
-
const ok = await
|
|
1733
|
+
if (typeof pmForCb.requestRespawn === 'function') {
|
|
1734
|
+
respawn = pmForCb.requestRespawn(callbackSessionKey, reason);
|
|
1735
|
+
} else if (setting === 'effort' && typeof pmForCb.applyFlagSettings === 'function') {
|
|
1736
|
+
const ok = await pmForCb.applyFlagSettings(callbackSessionKey, { effortLevel: value });
|
|
1733
1737
|
respawn = { killed: ok };
|
|
1734
|
-
} else if (setting === 'model' && typeof
|
|
1735
|
-
const ok = await
|
|
1738
|
+
} else if (setting === 'model' && typeof pmForCb.setModel === 'function') {
|
|
1739
|
+
const ok = await pmForCb.setModel(callbackSessionKey, value);
|
|
1736
1740
|
respawn = { killed: ok };
|
|
1737
1741
|
} else {
|
|
1738
1742
|
respawn = { killed: false };
|
|
@@ -1891,8 +1895,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1891
1895
|
// usage report. Only meaningful under SDK pm (CLI pm has no
|
|
1892
1896
|
// getContextUsage equivalent); CLI path replies with a hint.
|
|
1893
1897
|
if (botAllowsCommands && text === '/context') {
|
|
1894
|
-
if (!
|
|
1895
|
-
await sendReply('📚 /context requires the SDK pm
|
|
1898
|
+
if (!pm.isSdkFor(sessionKey)) {
|
|
1899
|
+
await sendReply('📚 /context requires the SDK pm. This chat is on the CLI pm path.');
|
|
1896
1900
|
return;
|
|
1897
1901
|
}
|
|
1898
1902
|
const entry = pm.get(sessionKey);
|
|
@@ -1937,9 +1941,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1937
1941
|
}
|
|
1938
1942
|
if (botAllowsCommands && (text === '/new' || text === '/reset')) {
|
|
1939
1943
|
let drained = 0;
|
|
1940
|
-
|
|
1944
|
+
const target = pm.pickFor(sessionKey);
|
|
1945
|
+
if (typeof target.resetSession === 'function') {
|
|
1941
1946
|
try {
|
|
1942
|
-
const r = await
|
|
1947
|
+
const r = await target.resetSession(sessionKey, { reason: text.slice(1) });
|
|
1943
1948
|
drained = r?.drainedPendings ?? 0;
|
|
1944
1949
|
} catch (err) {
|
|
1945
1950
|
console.error(`[${label}] resetSession ${text}: ${err.message}`);
|
|
@@ -1970,15 +1975,16 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1970
1975
|
if (botAllowsCommands && text.startsWith('/steer ')) {
|
|
1971
1976
|
const steerText = text.slice(7).trim();
|
|
1972
1977
|
if (!steerText) { await sendReply('Usage: /steer <text>'); return; }
|
|
1973
|
-
|
|
1974
|
-
|
|
1978
|
+
const target = pm.pickFor(sessionKey);
|
|
1979
|
+
if (typeof target.steer !== 'function') {
|
|
1980
|
+
await sendReply('🛞 /steer requires the SDK pm. This chat is on the CLI pm path.');
|
|
1975
1981
|
return;
|
|
1976
1982
|
}
|
|
1977
1983
|
if (!pm.has(sessionKey)) {
|
|
1978
1984
|
await sendReply('🛞 No active session — /steer only works mid-turn. Send a message first, then /steer while it\'s thinking.');
|
|
1979
1985
|
return;
|
|
1980
1986
|
}
|
|
1981
|
-
const ok =
|
|
1987
|
+
const ok = target.steer(sessionKey, steerText);
|
|
1982
1988
|
if (ok) {
|
|
1983
1989
|
logEvent('steer-command', {
|
|
1984
1990
|
chat_id: chatId, text_len: steerText.length,
|
|
@@ -2006,16 +2012,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2006
2012
|
// applyFlagSettings — no respawn needed, change takes effect for
|
|
2007
2013
|
// the rest of the in-flight turn AND all future ones.
|
|
2008
2014
|
const applyConfigChange = async (reason, setting, value) => {
|
|
2009
|
-
|
|
2010
|
-
|
|
2015
|
+
const target = pm.pickFor(sessionKey);
|
|
2016
|
+
if (typeof target.requestRespawn === 'function') {
|
|
2017
|
+
const res = target.requestRespawn(sessionKey, reason);
|
|
2011
2018
|
return { queued: res.queued, anyActive: !res.killed };
|
|
2012
2019
|
}
|
|
2013
|
-
if (setting === 'effort' && typeof
|
|
2014
|
-
const ok = await
|
|
2020
|
+
if (setting === 'effort' && typeof target.applyFlagSettings === 'function') {
|
|
2021
|
+
const ok = await target.applyFlagSettings(sessionKey, { effortLevel: value });
|
|
2015
2022
|
return { queued: 0, anyActive: !ok };
|
|
2016
2023
|
}
|
|
2017
|
-
if (setting === 'model' && typeof
|
|
2018
|
-
const ok = await
|
|
2024
|
+
if (setting === 'model' && typeof target.setModel === 'function') {
|
|
2025
|
+
const ok = await target.setModel(sessionKey, value);
|
|
2019
2026
|
return { queued: 0, anyActive: !ok };
|
|
2020
2027
|
}
|
|
2021
2028
|
return { queued: 0, anyActive: false };
|
|
@@ -2411,11 +2418,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2411
2418
|
const chatAutosteer = chatConfig.autosteer != null
|
|
2412
2419
|
? chatConfig.autosteer
|
|
2413
2420
|
: config.bot?.autosteer;
|
|
2414
|
-
const
|
|
2415
|
-
|
|
2421
|
+
const autosteerTarget = pm.pickFor(sessionKey);
|
|
2422
|
+
const autosteerEnabled = chatAutosteer !== false
|
|
2423
|
+
&& typeof autosteerTarget.steer === 'function';
|
|
2424
|
+
if (autosteerEnabled && pm.has(sessionKey)) {
|
|
2416
2425
|
const entry = pm.get(sessionKey);
|
|
2417
2426
|
if (entry?.inFlight) {
|
|
2418
|
-
const ok =
|
|
2427
|
+
const ok = autosteerTarget.steer(sessionKey, prompt);
|
|
2419
2428
|
if (ok) {
|
|
2420
2429
|
// Quiet ack — no chat-bubble reply, just a reaction so the
|
|
2421
2430
|
// user sees their message was incorporated. The in-flight
|
|
@@ -2493,8 +2502,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2493
2502
|
// Only fires when pm.resetSession is available (SDK pm
|
|
2494
2503
|
// path); CLI pm doesn't have the method.
|
|
2495
2504
|
const cls = classifyError(result.error);
|
|
2496
|
-
|
|
2497
|
-
|
|
2505
|
+
const recoverTarget = pm.pickFor(sessionKey);
|
|
2506
|
+
if (cls.autoRecover === 'reset_session' && typeof recoverTarget.resetSession === 'function') {
|
|
2507
|
+
recoverTarget.resetSession(sessionKey, { reason: cls.kind })
|
|
2498
2508
|
.catch((err) => console.error(`[${label}] auto-reset failed: ${err.message}`));
|
|
2499
2509
|
logEvent('auto-recover', {
|
|
2500
2510
|
chat_id: chatId, kind: cls.kind, action: 'reset_session',
|
|
@@ -2523,7 +2533,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2523
2533
|
// SDK pm only — CLI pm has no equivalent (no Query object,
|
|
2524
2534
|
// no getContextUsage). Per-bot opt-out via
|
|
2525
2535
|
// config.bot.contextHint = false.
|
|
2526
|
-
if (
|
|
2536
|
+
if (pm.isSdkFor(sessionKey) && config.bot?.contextHint !== false) {
|
|
2527
2537
|
const entry = pm.get(sessionKey);
|
|
2528
2538
|
const q = entry?.query;
|
|
2529
2539
|
if (q && typeof q.getContextUsage === 'function') {
|
|
@@ -2555,6 +2565,26 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2555
2565
|
// those still markReplied silently.
|
|
2556
2566
|
if (result.text === 'NO_REPLY') { markReplied(); return; }
|
|
2557
2567
|
if (!result.text) {
|
|
2568
|
+
// 0.8.0-rc.7: tool-only completion is NOT an error. Under SDK
|
|
2569
|
+
// pm, a turn that ends after running tools (no closing text
|
|
2570
|
+
// block) leaves result.text empty even though the bot DID
|
|
2571
|
+
// respond — via tool side effects the user already saw. Don't
|
|
2572
|
+
// post a "No response generated" apology in that case; it's
|
|
2573
|
+
// confusing and it spams the chat. Just clear the reactor
|
|
2574
|
+
// (otherwise 👀 stays stuck — reactor.stop() doesn't remove
|
|
2575
|
+
// the emoji visually) and silently mark replied.
|
|
2576
|
+
const toolOnlyTurn = (result.metrics?.numToolUses ?? 0) > 0
|
|
2577
|
+
&& (result.metrics?.numAssistantMessages ?? 0) > 0;
|
|
2578
|
+
if (toolOnlyTurn) {
|
|
2579
|
+
await reactor.clear().catch(() => {});
|
|
2580
|
+
logEvent('tool-only-completion', {
|
|
2581
|
+
chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
|
|
2582
|
+
num_tool_uses: result.metrics?.numToolUses,
|
|
2583
|
+
num_assistant_messages: result.metrics?.numAssistantMessages,
|
|
2584
|
+
});
|
|
2585
|
+
markReplied();
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2558
2588
|
// 0.7.1: if the fallback send itself fails, throw rather than
|
|
2559
2589
|
// silently markReplied — the user gets nothing AND the inbound
|
|
2560
2590
|
// is marked replied so boot replay won't redispatch. Same
|
|
@@ -2580,6 +2610,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2580
2610
|
logEvent('telegram-empty-response-fallback', {
|
|
2581
2611
|
chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
|
|
2582
2612
|
});
|
|
2613
|
+
// 0.8.0-rc.7: clear the THINKING/QUEUED emoji on the user's
|
|
2614
|
+
// message so 👀 doesn't stay stuck after the apology lands.
|
|
2615
|
+
// reactor.stop() (in the finally block) only kills timers; it
|
|
2616
|
+
// does NOT remove the visible emoji. Without this clear, the
|
|
2617
|
+
// user sees 👀 next to their message indefinitely.
|
|
2618
|
+
await reactor.clear().catch(() => {});
|
|
2583
2619
|
markReplied();
|
|
2584
2620
|
return;
|
|
2585
2621
|
}
|
|
@@ -2903,14 +2939,15 @@ function createBot(token) {
|
|
|
2903
2939
|
// sessionKey is the chat itself, so killing one session is
|
|
2904
2940
|
// the same as killing the chat — behavior unchanged for the
|
|
2905
2941
|
// common case.
|
|
2906
|
-
|
|
2907
|
-
|
|
2942
|
+
const stopTarget = pm.pickFor(sessionKey);
|
|
2943
|
+
if (typeof stopTarget.interrupt === 'function') {
|
|
2944
|
+
await stopTarget.interrupt(sessionKey).catch((err) =>
|
|
2908
2945
|
console.error(`[${BOT_NAME}] interrupt failed: ${err.message}`));
|
|
2909
|
-
if (typeof
|
|
2910
|
-
|
|
2946
|
+
if (typeof stopTarget.drainQueue === 'function') {
|
|
2947
|
+
stopTarget.drainQueue(sessionKey, 'INTERRUPTED');
|
|
2911
2948
|
}
|
|
2912
2949
|
} else {
|
|
2913
|
-
await
|
|
2950
|
+
await stopTarget.kill(sessionKey).catch((err) =>
|
|
2914
2951
|
console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
|
|
2915
2952
|
}
|
|
2916
2953
|
logEvent('abort-requested', {
|
|
@@ -3351,17 +3388,37 @@ async function main() {
|
|
|
3351
3388
|
});
|
|
3352
3389
|
|
|
3353
3390
|
const cap = config.maxWarmProcesses || DEFAULT_MAX_WARM_PROCS;
|
|
3354
|
-
|
|
3355
|
-
//
|
|
3356
|
-
//
|
|
3357
|
-
//
|
|
3358
|
-
//
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3391
|
+
|
|
3392
|
+
// 0.8.0-rc.6: per-chat pm selection. Three modes:
|
|
3393
|
+
// 1. POLYGRAM_USE_SDK=1 with no POLYGRAM_SDK_CHATS list → all chats SDK
|
|
3394
|
+
// 2. POLYGRAM_SDK_CHATS=id1,id2,... → those chats
|
|
3395
|
+
// use SDK; everyone else uses CLI (both pms live in the daemon)
|
|
3396
|
+
// 3. neither set → all chats CLI
|
|
3397
|
+
// The per-chat mode lets us soak SDK pm against real traffic in one
|
|
3398
|
+
// chat (Ivan's DM) while keeping partner-facing chats on the
|
|
3399
|
+
// battle-tested CLI path. When both pms run, killChat /shutdown
|
|
3400
|
+
// broadcast to both; everything else routes per-sessionKey via
|
|
3401
|
+
// pickPmFor() based on the chat's set membership.
|
|
3402
|
+
const sdkChatIdSet = new Set(
|
|
3403
|
+
String(process.env.POLYGRAM_SDK_CHATS || '')
|
|
3404
|
+
.split(',').map((s) => s.trim()).filter(Boolean)
|
|
3405
|
+
);
|
|
3406
|
+
const sdkAllChats = USE_SDK && sdkChatIdSet.size === 0;
|
|
3407
|
+
const sdkSomeChats = sdkChatIdSet.size > 0;
|
|
3408
|
+
const sdkActive = sdkAllChats || sdkSomeChats;
|
|
3409
|
+
|
|
3410
|
+
function pickPmKindFor(sessionKey) {
|
|
3411
|
+
if (sdkAllChats) return 'sdk';
|
|
3412
|
+
if (!sdkSomeChats) return 'cli';
|
|
3413
|
+
const chatId = String(getChatIdFromKey(sessionKey) ?? '');
|
|
3414
|
+
return sdkChatIdSet.has(chatId) ? 'sdk' : 'cli';
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
// Shared callbacks: identical instance passed to both pms so a
|
|
3418
|
+
// chat's lifecycle events look the same regardless of which pm
|
|
3419
|
+
// is handling it.
|
|
3420
|
+
const pmOpts = {
|
|
3363
3421
|
cap,
|
|
3364
|
-
spawnFn,
|
|
3365
3422
|
db,
|
|
3366
3423
|
logger: console,
|
|
3367
3424
|
onInit: (sessionKey, event, entry) => {
|
|
@@ -3476,7 +3533,104 @@ async function main() {
|
|
|
3476
3533
|
...(threadId && { message_thread_id: threadId }),
|
|
3477
3534
|
}, { source: 'respawn-confirm', botName: BOT_NAME }).catch(() => {});
|
|
3478
3535
|
},
|
|
3479
|
-
}
|
|
3536
|
+
};
|
|
3537
|
+
|
|
3538
|
+
// Instantiate the actual pm(s). When sdkActive is false we still
|
|
3539
|
+
// build a CLI pm; SDK pm is null. When sdkActive is true we always
|
|
3540
|
+
// build BOTH so chats outside the SDK list still get the CLI path.
|
|
3541
|
+
const cliPm = new ProcessManager({ ...pmOpts, spawnFn: spawnClaude });
|
|
3542
|
+
const sdkPm = sdkActive
|
|
3543
|
+
? new ProcessManagerSdk({ ...pmOpts, spawnFn: buildSdkOptions })
|
|
3544
|
+
: null;
|
|
3545
|
+
|
|
3546
|
+
// Routing pm: same surface as a single pm, but per-method routing
|
|
3547
|
+
// through pickPmKindFor(sessionKey). Methods that don't take a
|
|
3548
|
+
// sessionKey (killChat by chatId, shutdown) broadcast to both.
|
|
3549
|
+
// For optional methods (steer / setModel / applyFlagSettings /
|
|
3550
|
+
// requestRespawn / drainQueue / interrupt / resetSession) we
|
|
3551
|
+
// forward when the routed pm has the method and return a
|
|
3552
|
+
// sentinel otherwise — so feature-detection at the call site
|
|
3553
|
+
// still works via `typeof pm.pickFor(sessionKey).X === 'function'`.
|
|
3554
|
+
pm = (() => {
|
|
3555
|
+
function routedPm(sessionKey) {
|
|
3556
|
+
return pickPmKindFor(sessionKey) === 'sdk' && sdkPm ? sdkPm : cliPm;
|
|
3557
|
+
}
|
|
3558
|
+
const router = {
|
|
3559
|
+
pickFor: routedPm,
|
|
3560
|
+
isSdkFor(sessionKey) {
|
|
3561
|
+
return pickPmKindFor(sessionKey) === 'sdk' && !!sdkPm;
|
|
3562
|
+
},
|
|
3563
|
+
has(sessionKey) { return routedPm(sessionKey).has(sessionKey); },
|
|
3564
|
+
get(sessionKey) { return routedPm(sessionKey).get(sessionKey); },
|
|
3565
|
+
getOrSpawn(sessionKey, ctx) { return routedPm(sessionKey).getOrSpawn(sessionKey, ctx); },
|
|
3566
|
+
send(sessionKey, prompt, opts) { return routedPm(sessionKey).send(sessionKey, prompt, opts); },
|
|
3567
|
+
kill(sessionKey) { return routedPm(sessionKey).kill(sessionKey); },
|
|
3568
|
+
async killChat(chatId) {
|
|
3569
|
+
const tasks = [cliPm.killChat(chatId)];
|
|
3570
|
+
if (sdkPm) tasks.push(sdkPm.killChat(chatId));
|
|
3571
|
+
await Promise.all(tasks);
|
|
3572
|
+
},
|
|
3573
|
+
async shutdown() {
|
|
3574
|
+
const tasks = [cliPm.shutdown()];
|
|
3575
|
+
if (sdkPm) tasks.push(sdkPm.shutdown());
|
|
3576
|
+
await Promise.all(tasks);
|
|
3577
|
+
},
|
|
3578
|
+
// Optional methods. The router returns a function — but the
|
|
3579
|
+
// function returns a sentinel if the routed pm doesn't have
|
|
3580
|
+
// the method. Sites that want feature-detection should use
|
|
3581
|
+
// `pm.pickFor(sessionKey)` and check `typeof X === 'function'`
|
|
3582
|
+
// there instead of probing `pm.X` directly.
|
|
3583
|
+
steer(sessionKey, ...args) {
|
|
3584
|
+
const target = routedPm(sessionKey);
|
|
3585
|
+
return typeof target.steer === 'function' ? target.steer(sessionKey, ...args) : false;
|
|
3586
|
+
},
|
|
3587
|
+
resetSession(sessionKey, opts) {
|
|
3588
|
+
const target = routedPm(sessionKey);
|
|
3589
|
+
return typeof target.resetSession === 'function'
|
|
3590
|
+
? target.resetSession(sessionKey, opts)
|
|
3591
|
+
: Promise.resolve({ closed: false, drainedPendings: 0 });
|
|
3592
|
+
},
|
|
3593
|
+
applyFlagSettings(sessionKey, settings) {
|
|
3594
|
+
const target = routedPm(sessionKey);
|
|
3595
|
+
return typeof target.applyFlagSettings === 'function'
|
|
3596
|
+
? target.applyFlagSettings(sessionKey, settings)
|
|
3597
|
+
: Promise.resolve(false);
|
|
3598
|
+
},
|
|
3599
|
+
setModel(sessionKey, model) {
|
|
3600
|
+
const target = routedPm(sessionKey);
|
|
3601
|
+
return typeof target.setModel === 'function'
|
|
3602
|
+
? target.setModel(sessionKey, model)
|
|
3603
|
+
: Promise.resolve(false);
|
|
3604
|
+
},
|
|
3605
|
+
requestRespawn(sessionKey, reason) {
|
|
3606
|
+
const target = routedPm(sessionKey);
|
|
3607
|
+
return typeof target.requestRespawn === 'function'
|
|
3608
|
+
? target.requestRespawn(sessionKey, reason)
|
|
3609
|
+
: { killed: false, queued: 0 };
|
|
3610
|
+
},
|
|
3611
|
+
drainQueue(sessionKey, errCode) {
|
|
3612
|
+
const target = routedPm(sessionKey);
|
|
3613
|
+
return typeof target.drainQueue === 'function'
|
|
3614
|
+
? target.drainQueue(sessionKey, errCode)
|
|
3615
|
+
: 0;
|
|
3616
|
+
},
|
|
3617
|
+
interrupt(sessionKey) {
|
|
3618
|
+
const target = routedPm(sessionKey);
|
|
3619
|
+
return typeof target.interrupt === 'function'
|
|
3620
|
+
? target.interrupt(sessionKey)
|
|
3621
|
+
: Promise.resolve();
|
|
3622
|
+
},
|
|
3623
|
+
};
|
|
3624
|
+
return router;
|
|
3625
|
+
})();
|
|
3626
|
+
|
|
3627
|
+
if (sdkAllChats) {
|
|
3628
|
+
console.log('[polygram] using SDK ProcessManager (all chats)');
|
|
3629
|
+
} else if (sdkSomeChats) {
|
|
3630
|
+
console.log(`[polygram] router active: SDK pm for chats {${Array.from(sdkChatIdSet).join(',')}}, CLI pm for everyone else`);
|
|
3631
|
+
} else {
|
|
3632
|
+
console.log('[polygram] using CLI ProcessManager');
|
|
3633
|
+
}
|
|
3480
3634
|
|
|
3481
3635
|
console.log(`polygram (LRU cap=${cap}, SQLite source of truth)`);
|
|
3482
3636
|
console.log(`Chats: ${Object.entries(config.chats).map(([id, c]) => `${c.name} (${c.model}/${c.effort})`).join(', ')}`);
|