polygram 0.8.0-rc.2 → 0.8.0-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/agent-loader.js +219 -64
- package/lib/approval-ui.js +135 -0
- package/lib/autosteer-buffer.js +131 -0
- package/lib/canonical-json.js +44 -0
- package/lib/error-classify.js +38 -9
- package/lib/history-preload.js +160 -0
- package/lib/pm-interface.js +95 -0
- package/lib/pm-router.js +159 -0
- package/lib/process-manager-sdk.js +32 -1
- package/lib/process-manager.js +13 -0
- package/lib/status-reactions.js +70 -19
- package/package.json +1 -1
- package/polygram.js +412 -204
package/polygram.js
CHANGED
|
@@ -31,6 +31,16 @@ 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, makePostToolBatchHook } = require('./lib/autosteer-buffer');
|
|
35
|
+
const { makeRouterPolicy, createPmRouter } = require('./lib/pm-router');
|
|
36
|
+
const { canonicalizeToolInput } = require('./lib/canonical-json');
|
|
37
|
+
const {
|
|
38
|
+
buildApprovalKeyboard,
|
|
39
|
+
buildApprovalKeyboardWithAlways,
|
|
40
|
+
formatToolInputForCard,
|
|
41
|
+
approvalCardText,
|
|
42
|
+
} = require('./lib/approval-ui');
|
|
43
|
+
const { makeSessionStartHook } = require('./lib/history-preload');
|
|
34
44
|
const agentLoader = require('./lib/agent-loader');
|
|
35
45
|
const USE_SDK = process.env.POLYGRAM_USE_SDK === '1';
|
|
36
46
|
const { createSender } = require('./lib/telegram');
|
|
@@ -698,6 +708,91 @@ function formatPrompt(msg, sessionCtx, attachments = []) {
|
|
|
698
708
|
|
|
699
709
|
let pm = null; // ProcessManager, created in main()
|
|
700
710
|
|
|
711
|
+
// 0.8.0-rc.9: per-session autosteer buffer. Holds user follow-ups
|
|
712
|
+
// that arrive mid-turn so the SDK pm's PostToolBatch hook can drain
|
|
713
|
+
// them into `additionalContext` on each tool boundary. Replaces the
|
|
714
|
+
// rc.6/rc.7 approach of pushing priority:'now' SDKUserMessages
|
|
715
|
+
// directly (which violated the SDK's m87 transcript-shape gate when
|
|
716
|
+
// the assistant was mid-tool-use).
|
|
717
|
+
const autosteerBuffer = createAutosteerBuffer();
|
|
718
|
+
|
|
719
|
+
// 0.8.0-rc.14: track msg_ids that received the AUTOSTEERED ✍ ack, per
|
|
720
|
+
// session, so we can clear those reactions when the in-flight turn
|
|
721
|
+
// finishes. Pre-rc.14 the ✍ persisted forever because each autosteer
|
|
722
|
+
// invocation runs in its OWN handleMessage scope (own reactor), and
|
|
723
|
+
// the TRIGGER message's reactor.clear() at turn-end couldn't reach
|
|
724
|
+
// across to other messages. Without this map, users see ✍ stuck on
|
|
725
|
+
// every follow-up and don't know whether the bot incorporated them.
|
|
726
|
+
const autosteeredMsgRefs = new Map(); // sessionKey → [{chatId, msgId}]
|
|
727
|
+
|
|
728
|
+
async function clearAutosteeredReactions(sessionKey) {
|
|
729
|
+
const list = autosteeredMsgRefs.get(sessionKey);
|
|
730
|
+
if (!list || list.length === 0) return;
|
|
731
|
+
autosteeredMsgRefs.delete(sessionKey);
|
|
732
|
+
if (!bot) return;
|
|
733
|
+
for (const { chatId: cid, msgId } of list) {
|
|
734
|
+
try {
|
|
735
|
+
await tg(bot, 'setMessageReaction', {
|
|
736
|
+
chat_id: cid, message_id: msgId, reaction: [],
|
|
737
|
+
}, { source: 'autosteer-clear', botName: BOT_NAME });
|
|
738
|
+
} catch (err) {
|
|
739
|
+
// Ack-clear failures are silent — the ✍ stays on screen
|
|
740
|
+
// but doesn't block the in-flight turn's reply UX.
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// 0.8.0-rc.14: tool-less-turn drain. PostToolBatch hook only fires
|
|
746
|
+
// on tool boundaries — when a Query produces a turn that uses ZERO
|
|
747
|
+
// tools (just a text answer), the autosteerBuffer never gets
|
|
748
|
+
// drained and any user follow-ups buffered during that turn
|
|
749
|
+
// disappear silently into the next tool-using turn (or never, if
|
|
750
|
+
// the chat is purely conversational).
|
|
751
|
+
//
|
|
752
|
+
// Workaround: at every success exit in handleMessage, check if
|
|
753
|
+
// the buffer still has items and dispatch them as a synthetic
|
|
754
|
+
// next turn via pm.send. The bot replies to the drained content
|
|
755
|
+
// in a fresh turn — UX-wise the user sees TWO replies (one to
|
|
756
|
+
// the trigger message, one to "B + C") which is the same as if
|
|
757
|
+
// they'd sent the messages without autosteer. Better than losing.
|
|
758
|
+
async function drainStaleAutosteerBuffer(sessionKey, chatId, threadId) {
|
|
759
|
+
const stale = autosteerBuffer.drain(sessionKey);
|
|
760
|
+
if (stale.length === 0) return;
|
|
761
|
+
const followUpPrompt = stale.join('\n\n');
|
|
762
|
+
logEvent('autosteer-stale-drain', {
|
|
763
|
+
chat_id: chatId,
|
|
764
|
+
session_key: sessionKey,
|
|
765
|
+
message_count: stale.length,
|
|
766
|
+
text_len: followUpPrompt.length,
|
|
767
|
+
});
|
|
768
|
+
// Dispatch as a fresh pm.send via setImmediate so we don't
|
|
769
|
+
// block the current handleMessage's success-path return. No
|
|
770
|
+
// streamer / reactor — the synthetic turn gets a plain bubble
|
|
771
|
+
// reply (no streaming preview, no progress reactions). User
|
|
772
|
+
// already saw their ✍ ack on the original follow-up; this
|
|
773
|
+
// turn's existence is the substantive response.
|
|
774
|
+
setImmediate(async () => {
|
|
775
|
+
try {
|
|
776
|
+
const chatConfig = config.chats[chatId];
|
|
777
|
+
if (!chatConfig) return;
|
|
778
|
+
const result = await sendToProcess(sessionKey, followUpPrompt, {
|
|
779
|
+
streamer: null, reactor: null, sourceMsgId: null,
|
|
780
|
+
});
|
|
781
|
+
if (result?.text && bot) {
|
|
782
|
+
await tg(bot, 'sendMessage', {
|
|
783
|
+
chat_id: chatId,
|
|
784
|
+
text: result.text,
|
|
785
|
+
...(threadId ? { message_thread_id: threadId } : {}),
|
|
786
|
+
}, { source: 'autosteer-stale-reply', botName: BOT_NAME }).catch((err) => {
|
|
787
|
+
console.error(`[${BOT_NAME}] autosteer-stale-reply send: ${err.message}`);
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
} catch (err) {
|
|
791
|
+
console.error(`[${BOT_NAME}] autosteer-stale-drain dispatch: ${err.message}`);
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
701
796
|
function spawnClaude(sessionKey, ctx) {
|
|
702
797
|
const { chatConfig, existingSessionId, label, chatId } = ctx;
|
|
703
798
|
// 0.7.3: Claude Code's Chrome-extension integration (browser
|
|
@@ -784,6 +879,10 @@ function buildSdkOptions(sessionKey, ctx) {
|
|
|
784
879
|
try {
|
|
785
880
|
agentBundle = agentLoader.loadAgent(chatConfig.agent, {
|
|
786
881
|
homeDir: CHILD_HOME,
|
|
882
|
+
// Pass cwd so the loader checks Claude Code's project-level
|
|
883
|
+
// path (`<cwd>/.claude/agents/<name>.md`) before the
|
|
884
|
+
// user-level path or polygram's directory convention.
|
|
885
|
+
cwd: chatConfig.cwd,
|
|
787
886
|
logger: console,
|
|
788
887
|
});
|
|
789
888
|
} catch (err) {
|
|
@@ -817,6 +916,35 @@ function buildSdkOptions(sessionKey, ctx) {
|
|
|
817
916
|
const useCanUseTool = apprCfg && apprCfg.adminChatId
|
|
818
917
|
&& Array.isArray(apprCfg.gatedTools) && apprCfg.gatedTools.length > 0;
|
|
819
918
|
|
|
919
|
+
// 0.8.0-rc.9 (factored to lib/autosteer-buffer.js in rc.17): the
|
|
920
|
+
// PostToolBatch hook drains the autosteer buffer for THIS session
|
|
921
|
+
// and injects queued user follow-ups as `additionalContext` on
|
|
922
|
+
// each tool boundary, wrapped in `<channel source="user-followup">`
|
|
923
|
+
// which Claude is trained to trust as legitimate out-of-band user
|
|
924
|
+
// context.
|
|
925
|
+
const postToolBatchHook = makePostToolBatchHook({
|
|
926
|
+
buffer: autosteerBuffer,
|
|
927
|
+
sessionKey,
|
|
928
|
+
chatId: ctx?.chatId ?? null,
|
|
929
|
+
logEvent,
|
|
930
|
+
logger: console,
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
// 0.8.0-rc.21: SessionStart hook preloads recent polygram-DB
|
|
934
|
+
// history into a fresh Query (no resume). Without this, every
|
|
935
|
+
// /new or daemon-boot starts the agent blank — even though the
|
|
936
|
+
// chat has been running for weeks. Skips when source is
|
|
937
|
+
// 'resume' or 'compact' (transcript already populated).
|
|
938
|
+
const sessionStartHook = ctx?.chatId
|
|
939
|
+
? makeSessionStartHook({
|
|
940
|
+
db,
|
|
941
|
+
chatId: ctx.chatId,
|
|
942
|
+
threadId: ctx.threadId ?? null,
|
|
943
|
+
logEvent,
|
|
944
|
+
logger: console,
|
|
945
|
+
})
|
|
946
|
+
: null;
|
|
947
|
+
|
|
820
948
|
const baseOpts = {
|
|
821
949
|
model: chatConfig.model || config.defaults.model,
|
|
822
950
|
effort: chatConfig.effort || config.defaults.effort,
|
|
@@ -828,6 +956,12 @@ function buildSdkOptions(sessionKey, ctx) {
|
|
|
828
956
|
permissionMode: useCanUseTool ? 'default' : 'bypassPermissions',
|
|
829
957
|
allowDangerouslySkipPermissions: !useCanUseTool,
|
|
830
958
|
...(useCanUseTool && { canUseTool: makeCanUseTool(sessionKey) }),
|
|
959
|
+
hooks: {
|
|
960
|
+
PostToolBatch: [{ hooks: [postToolBatchHook] }],
|
|
961
|
+
...(sessionStartHook && {
|
|
962
|
+
SessionStart: [{ hooks: [sessionStartHook] }],
|
|
963
|
+
}),
|
|
964
|
+
},
|
|
831
965
|
executable: 'node',
|
|
832
966
|
...(existingSessionId && { resume: existingSessionId }),
|
|
833
967
|
...(process.env.POLYGRAM_CLAUDE_BIN && {
|
|
@@ -1123,51 +1257,9 @@ async function handleSendOverIpc(req) {
|
|
|
1123
1257
|
}
|
|
1124
1258
|
|
|
1125
1259
|
// ─── Approvals ─────────────────────────────────────────────────────
|
|
1126
|
-
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1129
|
-
function formatToolInputForCard(input) {
|
|
1130
|
-
let s;
|
|
1131
|
-
try { s = typeof input === 'string' ? input : JSON.stringify(input, null, 2); }
|
|
1132
|
-
catch { s = String(input); }
|
|
1133
|
-
if (s.length <= 1200) return s;
|
|
1134
|
-
return s.slice(0, 900) + '\n…[clipped]…\n' + s.slice(-200);
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
function buildApprovalKeyboard(approvalId, token) {
|
|
1138
|
-
return {
|
|
1139
|
-
inline_keyboard: [[
|
|
1140
|
-
{ text: '✅ Approve', callback_data: `approve:${approvalId}:${token}` },
|
|
1141
|
-
{ text: '❌ Deny', callback_data: `deny:${approvalId}:${token}` },
|
|
1142
|
-
]],
|
|
1143
|
-
};
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
// 0.8.0 Phase 2 step 6: 4-button approval keyboard for SDK canUseTool
|
|
1147
|
-
// flow. Adds "Always allow" and "Always deny" rows that persist the
|
|
1148
|
-
// decision into chat_tool_decisions (via callback_query handler),
|
|
1149
|
-
// so subsequent invocations of the same tool with the same input
|
|
1150
|
-
// short-circuit without prompting.
|
|
1151
|
-
//
|
|
1152
|
-
// Callback_data conventions:
|
|
1153
|
-
// approve:<id>:<token> — one-time allow
|
|
1154
|
-
// deny:<id>:<token> — one-time deny
|
|
1155
|
-
// approve-always:<id>:<token> — allow + persist
|
|
1156
|
-
// deny-always:<id>:<token> — deny + persist
|
|
1157
|
-
function buildApprovalKeyboardWithAlways(approvalId, token) {
|
|
1158
|
-
return {
|
|
1159
|
-
inline_keyboard: [
|
|
1160
|
-
[
|
|
1161
|
-
{ text: '✅ Approve', callback_data: `approve:${approvalId}:${token}` },
|
|
1162
|
-
{ text: '❌ Deny', callback_data: `deny:${approvalId}:${token}` },
|
|
1163
|
-
],
|
|
1164
|
-
[
|
|
1165
|
-
{ text: '🔁 Always allow', callback_data: `approve-always:${approvalId}:${token}` },
|
|
1166
|
-
{ text: '🚫 Always deny', callback_data: `deny-always:${approvalId}:${token}` },
|
|
1167
|
-
],
|
|
1168
|
-
],
|
|
1169
|
-
};
|
|
1170
|
-
}
|
|
1260
|
+
// rc.20: pure UI builders moved to lib/approval-ui.js for testability.
|
|
1261
|
+
// Imported above (buildApprovalKeyboard, buildApprovalKeyboardWithAlways,
|
|
1262
|
+
// approvalCardText, formatToolInputForCard).
|
|
1171
1263
|
|
|
1172
1264
|
// /model and /effort inline keyboard. `show` controls which row(s) appear:
|
|
1173
1265
|
// 'model', 'effort', or 'all'. The current value gets a ✓ marker so the
|
|
@@ -1236,54 +1328,10 @@ function formatConfigInfoText(chatConfig, show, sessionKey) {
|
|
|
1236
1328
|
return body;
|
|
1237
1329
|
}
|
|
1238
1330
|
|
|
1239
|
-
|
|
1240
|
-
// No parse_mode is used on this card — tool_name/turn_id/tool_input
|
|
1241
|
-
// originate from the Claude subprocess and could contain Markdown special
|
|
1242
|
-
// chars or tg:// links crafted for phishing. Plain text renders as-is.
|
|
1243
|
-
const heading = opts.resolvedBy
|
|
1244
|
-
? opts.resolvedBy
|
|
1245
|
-
: `Approval needed — ${row.tool_name}`;
|
|
1246
|
-
const body = formatToolInputForCard(
|
|
1247
|
-
typeof row.tool_input_json === 'string'
|
|
1248
|
-
? safeParse(row.tool_input_json)
|
|
1249
|
-
: row.tool_input_json,
|
|
1250
|
-
);
|
|
1251
|
-
const ttl = Math.max(0, Math.round((row.timeout_ts - Date.now()) / 1000));
|
|
1252
|
-
const footer = opts.resolvedBy
|
|
1253
|
-
? ''
|
|
1254
|
-
: `\n\n⏱ expires in ${ttl}s`;
|
|
1255
|
-
return `${heading}\nChat: ${row.requester_chat_id}\nTurn: ${row.turn_id || '-'}\n\n${body}${footer}`;
|
|
1256
|
-
}
|
|
1331
|
+
// rc.20: approvalCardText + safeParse moved to lib/approval-ui.js.
|
|
1257
1332
|
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
/**
|
|
1263
|
-
* 0.8.0 Phase 2 step 6: canonical-JSON-stringify of a tool input
|
|
1264
|
-
* object. Keys sorted alphabetically; no whitespace. Used as the
|
|
1265
|
-
* dedup key for chat_tool_decisions match_type='exact' lookups
|
|
1266
|
-
* and as the input_pattern stored on "Always allow" clicks.
|
|
1267
|
-
*
|
|
1268
|
-
* Why canonical: Claude can reorder JSON keys between retries of
|
|
1269
|
-
* the same tool call (different SDK versions, different temperature
|
|
1270
|
-
* sampling). Without canonicalisation, the dedup digest would
|
|
1271
|
-
* differ for semantically-identical calls and the user would see
|
|
1272
|
-
* the same approval card twice (ship-breaker M8 mitigation).
|
|
1273
|
-
*/
|
|
1274
|
-
function canonicalizeToolInput(input) {
|
|
1275
|
-
if (input == null || typeof input !== 'object') {
|
|
1276
|
-
return JSON.stringify(input);
|
|
1277
|
-
}
|
|
1278
|
-
const sortRec = (v) => {
|
|
1279
|
-
if (Array.isArray(v)) return v.map(sortRec);
|
|
1280
|
-
if (v == null || typeof v !== 'object') return v;
|
|
1281
|
-
const out = {};
|
|
1282
|
-
for (const k of Object.keys(v).sort()) out[k] = sortRec(v[k]);
|
|
1283
|
-
return out;
|
|
1284
|
-
};
|
|
1285
|
-
return JSON.stringify(sortRec(input));
|
|
1286
|
-
}
|
|
1333
|
+
// 0.8.0-rc.18+: canonicalizeToolInput moved to lib/canonical-json.js
|
|
1334
|
+
// for testability. Same function, no behavior change.
|
|
1287
1335
|
|
|
1288
1336
|
/**
|
|
1289
1337
|
* 0.8.0 Phase 2 step 6: SDK canUseTool callback. Hands back to the
|
|
@@ -1709,16 +1757,38 @@ async function handleConfigCallback(ctx) {
|
|
|
1709
1757
|
user: cmdUser, user_id: cmdUserId, source: 'inline-button',
|
|
1710
1758
|
}), `log ${setting} change`);
|
|
1711
1759
|
|
|
1712
|
-
// Graceful
|
|
1760
|
+
// Graceful application of the change to the topic's session. With
|
|
1713
1761
|
// isolateTopics=false sessionKey is the chat (one shared session). With
|
|
1714
1762
|
// isolateTopics=true sessionKey carries the topic, so other topics'
|
|
1715
1763
|
// in-flight turns are not disturbed and the card update + button toast
|
|
1716
|
-
// only affect the user's own context.
|
|
1717
|
-
//
|
|
1764
|
+
// only affect the user's own context.
|
|
1765
|
+
//
|
|
1766
|
+
// CLI pm: requestRespawn drains pending turns then kills the process;
|
|
1767
|
+
// the next user message spawns fresh with the updated chatConfig.
|
|
1768
|
+
// SDK pm: applies live to the running Query via setModel /
|
|
1769
|
+
// applyFlagSettings — no respawn needed, change takes effect for the
|
|
1770
|
+
// rest of the in-flight turn AND all future ones. Falls back to
|
|
1771
|
+
// {killed: false} if neither method is available, leaving the new
|
|
1772
|
+
// chatConfig value to be picked up by the next cold spawn.
|
|
1718
1773
|
const callbackThreadId = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
|
|
1719
1774
|
const callbackSessionKey = getSessionKey(chatId, callbackThreadId, chatConfig);
|
|
1720
1775
|
const reason = setting === 'model' ? 'model-change' : 'effort-change';
|
|
1721
|
-
|
|
1776
|
+
// Feature-detect on the routed pm for this specific session, not on
|
|
1777
|
+
// the router itself (the router exposes every method as a forwarding
|
|
1778
|
+
// shim so `typeof pm.X` is always 'function').
|
|
1779
|
+
const pmForCb = pm.pickFor(callbackSessionKey);
|
|
1780
|
+
let respawn;
|
|
1781
|
+
if (typeof pmForCb.requestRespawn === 'function') {
|
|
1782
|
+
respawn = pmForCb.requestRespawn(callbackSessionKey, reason);
|
|
1783
|
+
} else if (setting === 'effort' && typeof pmForCb.applyFlagSettings === 'function') {
|
|
1784
|
+
const ok = await pmForCb.applyFlagSettings(callbackSessionKey, { effortLevel: value });
|
|
1785
|
+
respawn = { killed: ok };
|
|
1786
|
+
} else if (setting === 'model' && typeof pmForCb.setModel === 'function') {
|
|
1787
|
+
const ok = await pmForCb.setModel(callbackSessionKey, value);
|
|
1788
|
+
respawn = { killed: ok };
|
|
1789
|
+
} else {
|
|
1790
|
+
respawn = { killed: false };
|
|
1791
|
+
}
|
|
1722
1792
|
const anyActive = !respawn.killed;
|
|
1723
1793
|
|
|
1724
1794
|
// Re-render the card with updated ✓ + the same help text shown initially.
|
|
@@ -1873,8 +1943,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1873
1943
|
// usage report. Only meaningful under SDK pm (CLI pm has no
|
|
1874
1944
|
// getContextUsage equivalent); CLI path replies with a hint.
|
|
1875
1945
|
if (botAllowsCommands && text === '/context') {
|
|
1876
|
-
if (!
|
|
1877
|
-
await sendReply('📚 /context requires the SDK pm
|
|
1946
|
+
if (!pm.isSdkFor(sessionKey)) {
|
|
1947
|
+
await sendReply('📚 /context requires the SDK pm. This chat is on the CLI pm path.');
|
|
1878
1948
|
return;
|
|
1879
1949
|
}
|
|
1880
1950
|
const entry = pm.get(sessionKey);
|
|
@@ -1885,13 +1955,18 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1885
1955
|
}
|
|
1886
1956
|
try {
|
|
1887
1957
|
const u = await q.getContextUsage();
|
|
1888
|
-
|
|
1958
|
+
// SDK returns percentage in 0-100 scale (verified rc.3 prod
|
|
1959
|
+
// — saw "77" for a 77%-used context). Display directly.
|
|
1960
|
+
const pct = (u?.percentage ?? 0).toFixed(0);
|
|
1889
1961
|
const total = (u?.totalTokens ?? 0).toLocaleString();
|
|
1890
1962
|
const max = (u?.maxTokens ?? 0).toLocaleString();
|
|
1891
1963
|
const lines = [`📚 Context: ${total} / ${max} tokens (${pct}%)`];
|
|
1892
1964
|
if (u?.model) lines.push(`Model: ${u.model}`);
|
|
1893
1965
|
if (u?.isAutoCompactEnabled && u?.autoCompactThreshold) {
|
|
1894
|
-
|
|
1966
|
+
// autoCompactThreshold scale is currently unverified; assume
|
|
1967
|
+
// matches percentage (0-100). If it turns out to be 0-1 we'll
|
|
1968
|
+
// see something like "Auto-compact at 0%" and can flip back.
|
|
1969
|
+
const thrPct = u.autoCompactThreshold.toFixed(0);
|
|
1895
1970
|
lines.push(`Auto-compact at ${thrPct}%.`);
|
|
1896
1971
|
}
|
|
1897
1972
|
// Top-3 categories by token cost so the user knows where the
|
|
@@ -1914,9 +1989,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1914
1989
|
}
|
|
1915
1990
|
if (botAllowsCommands && (text === '/new' || text === '/reset')) {
|
|
1916
1991
|
let drained = 0;
|
|
1917
|
-
|
|
1992
|
+
const target = pm.pickFor(sessionKey);
|
|
1993
|
+
if (typeof target.resetSession === 'function') {
|
|
1918
1994
|
try {
|
|
1919
|
-
const r = await
|
|
1995
|
+
const r = await target.resetSession(sessionKey, { reason: text.slice(1) });
|
|
1920
1996
|
drained = r?.drainedPendings ?? 0;
|
|
1921
1997
|
} catch (err) {
|
|
1922
1998
|
console.error(`[${label}] resetSession ${text}: ${err.message}`);
|
|
@@ -1938,48 +2014,39 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1938
2014
|
await sendReply('✨ Started a fresh session.');
|
|
1939
2015
|
return;
|
|
1940
2016
|
}
|
|
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 };
|
|
2017
|
+
// 0.8.0-rc.9: /steer command removed. Mid-turn user input is
|
|
2018
|
+
// handled implicitly by autosteer — any follow-up message during
|
|
2019
|
+
// an in-flight SDK turn flows through autosteerBuffer +
|
|
2020
|
+
// PostToolBatch hook. No explicit command needed; matches Claude
|
|
2021
|
+
// Code interactive UX where you just keep typing.
|
|
2022
|
+
// Graceful application of a model/effort change to the user's CURRENT
|
|
2023
|
+
// session only. With isolateTopics=false the sessionKey is just the
|
|
2024
|
+
// chat (one shared session for the whole chat — every topic
|
|
2025
|
+
// respawns implicitly). With isolateTopics=true each topic is a
|
|
2026
|
+
// separate session, and a /model in topic A should NOT disturb
|
|
2027
|
+
// topic B's in-flight turn or post a phantom "✓ Using sonnet now"
|
|
2028
|
+
// in a topic that didn't ask.
|
|
2029
|
+
//
|
|
2030
|
+
// CLI pm: requestRespawn drains pending turns then kills the process;
|
|
2031
|
+
// the next user message spawns fresh with the updated chatConfig.
|
|
2032
|
+
// SDK pm: applies live to the running Query via setModel /
|
|
2033
|
+
// applyFlagSettings — no respawn needed, change takes effect for
|
|
2034
|
+
// the rest of the in-flight turn AND all future ones.
|
|
2035
|
+
const applyConfigChange = async (reason, setting, value) => {
|
|
2036
|
+
const target = pm.pickFor(sessionKey);
|
|
2037
|
+
if (typeof target.requestRespawn === 'function') {
|
|
2038
|
+
const res = target.requestRespawn(sessionKey, reason);
|
|
2039
|
+
return { queued: res.queued, anyActive: !res.killed };
|
|
2040
|
+
}
|
|
2041
|
+
if (setting === 'effort' && typeof target.applyFlagSettings === 'function') {
|
|
2042
|
+
const ok = await target.applyFlagSettings(sessionKey, { effortLevel: value });
|
|
2043
|
+
return { queued: 0, anyActive: !ok };
|
|
2044
|
+
}
|
|
2045
|
+
if (setting === 'model' && typeof target.setModel === 'function') {
|
|
2046
|
+
const ok = await target.setModel(sessionKey, value);
|
|
2047
|
+
return { queued: 0, anyActive: !ok };
|
|
2048
|
+
}
|
|
2049
|
+
return { queued: 0, anyActive: false };
|
|
1983
2050
|
};
|
|
1984
2051
|
|
|
1985
2052
|
if (botAllowsCommands && text.startsWith('/model ')) {
|
|
@@ -1993,7 +2060,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1993
2060
|
old_value: oldModel, new_value: newModel,
|
|
1994
2061
|
user: cmdUser, user_id: cmdUserId, source: 'command',
|
|
1995
2062
|
}), 'log model change');
|
|
1996
|
-
const { anyActive } =
|
|
2063
|
+
const { anyActive } = await applyConfigChange('model-change', 'model', newModel);
|
|
1997
2064
|
const ver = MODEL_VERSIONS[newModel] || newModel;
|
|
1998
2065
|
const suffix = anyActive ? ` — I'll switch when I finish` : '';
|
|
1999
2066
|
await sendReply(`Model → ${newModel} (${ver})${suffix}`);
|
|
@@ -2013,7 +2080,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2013
2080
|
old_value: oldEffort, new_value: newEffort,
|
|
2014
2081
|
user: cmdUser, user_id: cmdUserId, source: 'command',
|
|
2015
2082
|
}), 'log effort change');
|
|
2016
|
-
const { anyActive } =
|
|
2083
|
+
const { anyActive } = await applyConfigChange('effort-change', 'effort', newEffort);
|
|
2017
2084
|
const suffix = anyActive ? ` — I'll switch when I finish` : '';
|
|
2018
2085
|
await sendReply(`Effort → ${newEffort}${suffix}`);
|
|
2019
2086
|
} else {
|
|
@@ -2366,34 +2433,59 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2366
2433
|
// chatConfig.autosteer === false). CLI pm always falls through
|
|
2367
2434
|
// to the queue-FIFO path (no steer primitive on stream-json).
|
|
2368
2435
|
//
|
|
2369
|
-
// The steered message gets a
|
|
2436
|
+
// The steered message gets a ✍ reaction so the user knows it
|
|
2370
2437
|
// landed; no separate reply is generated (the in-flight turn's
|
|
2371
2438
|
// response covers both messages, OpenClaw-style).
|
|
2439
|
+
//
|
|
2440
|
+
// Reaction emoji must be from Telegram's curated allowlist
|
|
2441
|
+
// (~60 standard emoji per core.telegram.org/bots/api#availablereactions).
|
|
2442
|
+
// 🛞 (steering wheel) is NOT on it — Telegram returns
|
|
2443
|
+
// 400: REACTION_INVALID. ✍ ("writing/noting") is on the list and
|
|
2444
|
+
// conveys "incorporating this".
|
|
2372
2445
|
const chatAutosteer = chatConfig.autosteer != null
|
|
2373
2446
|
? chatConfig.autosteer
|
|
2374
2447
|
: config.bot?.autosteer;
|
|
2375
|
-
|
|
2376
|
-
|
|
2448
|
+
// 0.8.0-rc.9: autosteer now drives through autosteerBuffer +
|
|
2449
|
+
// PostToolBatch hook (in buildSdkOptions), not pm.steer's direct
|
|
2450
|
+
// inputController push. The hook fires on every tool boundary
|
|
2451
|
+
// and injects queued follow-ups as <channel source="user-followup">
|
|
2452
|
+
// additionalContext — the SDK-trusted framing that survives the
|
|
2453
|
+
// m87 transcript-shape gate.
|
|
2454
|
+
//
|
|
2455
|
+
// We still gate on the SDK pm path: under CLI pm there's no
|
|
2456
|
+
// PostToolBatch hook surface, so autosteer falls through to the
|
|
2457
|
+
// regular FIFO send (same UX as 0.7.x).
|
|
2458
|
+
const autosteerEnabled = chatAutosteer !== false
|
|
2459
|
+
&& pm.isSdkFor(sessionKey);
|
|
2460
|
+
if (autosteerEnabled && pm.has(sessionKey)) {
|
|
2377
2461
|
const entry = pm.get(sessionKey);
|
|
2378
2462
|
if (entry?.inFlight) {
|
|
2379
|
-
const ok =
|
|
2463
|
+
const ok = autosteerBuffer.append(sessionKey, prompt);
|
|
2380
2464
|
if (ok) {
|
|
2381
|
-
//
|
|
2382
|
-
//
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
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
|
-
});
|
|
2465
|
+
// Track this msg_id so the in-flight turn's success / abort
|
|
2466
|
+
// / error path can clear the ✍ reaction at turn-end.
|
|
2467
|
+
const refs = autosteeredMsgRefs.get(sessionKey) || [];
|
|
2468
|
+
refs.push({ chatId, msgId: msg.message_id });
|
|
2469
|
+
autosteeredMsgRefs.set(sessionKey, refs);
|
|
2391
2470
|
logEvent('autosteer', {
|
|
2392
2471
|
chat_id: chatId, msg_id: msg.message_id,
|
|
2393
2472
|
text_len: prompt?.length ?? 0,
|
|
2394
2473
|
});
|
|
2395
2474
|
stopTyping();
|
|
2396
|
-
reactor
|
|
2475
|
+
// 0.8.0-rc.11: route the ✍ ack through the reactor's
|
|
2476
|
+
// serialized apply chain. Pre-rc.11 we used a direct
|
|
2477
|
+
// setMessageReaction(✍) racing with the reactor's
|
|
2478
|
+
// QUEUED→👀 apply AND a follow-up reactor.clear() — three
|
|
2479
|
+
// concurrent network calls, final state was whichever
|
|
2480
|
+
// landed last at Telegram. Symptom: 👀 sometimes stuck,
|
|
2481
|
+
// ✍ sometimes vanished, reactions disappeared "almost
|
|
2482
|
+
// immediately" or got stuck arbitrarily.
|
|
2483
|
+
//
|
|
2484
|
+
// setState('AUTOSTEERED') is terminal so it bypasses the
|
|
2485
|
+
// 800ms throttle and flushes synchronously through
|
|
2486
|
+
// applyChain — so it serializes after any in-flight
|
|
2487
|
+
// QUEUED apply and lands as the final visible reaction.
|
|
2488
|
+
await reactor.setState('AUTOSTEERED');
|
|
2397
2489
|
markReplied();
|
|
2398
2490
|
return;
|
|
2399
2491
|
}
|
|
@@ -2454,8 +2546,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2454
2546
|
// Only fires when pm.resetSession is available (SDK pm
|
|
2455
2547
|
// path); CLI pm doesn't have the method.
|
|
2456
2548
|
const cls = classifyError(result.error);
|
|
2457
|
-
|
|
2458
|
-
|
|
2549
|
+
const recoverTarget = pm.pickFor(sessionKey);
|
|
2550
|
+
if (cls.autoRecover === 'reset_session' && typeof recoverTarget.resetSession === 'function') {
|
|
2551
|
+
recoverTarget.resetSession(sessionKey, { reason: cls.kind })
|
|
2459
2552
|
.catch((err) => console.error(`[${label}] auto-reset failed: ${err.message}`));
|
|
2460
2553
|
logEvent('auto-recover', {
|
|
2461
2554
|
chat_id: chatId, kind: cls.kind, action: 'reset_session',
|
|
@@ -2477,23 +2570,42 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2477
2570
|
// every answered message is chat noise (plus triggers reaction
|
|
2478
2571
|
// notifications for other group members).
|
|
2479
2572
|
reactor.clear().catch(() => {});
|
|
2573
|
+
// 0.8.0-rc.14: also clear ✍ reactions on every follow-up
|
|
2574
|
+
// message that was autosteered into THIS turn — they live in
|
|
2575
|
+
// separate handleMessage scopes whose reactors are already GC'd.
|
|
2576
|
+
clearAutosteeredReactions(sessionKey).catch(() => {});
|
|
2577
|
+
// rc.14: tool-less-turn drain. PostToolBatch hook fires only
|
|
2578
|
+
// on tool boundaries; if this turn produced ZERO tools, the
|
|
2579
|
+
// hook never fired and the autosteer buffer still has the
|
|
2580
|
+
// user's follow-ups. Dispatch them as a synthetic next turn
|
|
2581
|
+
// so the bot at least addresses them (better than losing).
|
|
2582
|
+
drainStaleAutosteerBuffer(sessionKey, chatId, threadId).catch(() => {});
|
|
2480
2583
|
|
|
2481
2584
|
// 0.8.0 Phase 2 step 4: 85%-context-full live hint. After a
|
|
2482
2585
|
// successful turn, peek at SDK's getContextUsage(); if past
|
|
2483
2586
|
// 85%, post a quiet hint so the user knows /new will help.
|
|
2484
2587
|
// SDK pm only — CLI pm has no equivalent (no Query object,
|
|
2485
|
-
// no getContextUsage).
|
|
2486
|
-
//
|
|
2487
|
-
|
|
2588
|
+
// no getContextUsage). OPT-IN per-chat or per-bot
|
|
2589
|
+
// (rc.12+) — most chats don't want the noise. Per-chat takes
|
|
2590
|
+
// precedence over per-bot so admins (Ivan DM) can opt in
|
|
2591
|
+
// without forcing it on every other chat.
|
|
2592
|
+
const chatCtxHint = chatConfig.contextHint != null
|
|
2593
|
+
? chatConfig.contextHint
|
|
2594
|
+
: config.bot?.contextHint;
|
|
2595
|
+
if (pm.isSdkFor(sessionKey) && chatCtxHint === true) {
|
|
2488
2596
|
const entry = pm.get(sessionKey);
|
|
2489
2597
|
const q = entry?.query;
|
|
2490
2598
|
if (q && typeof q.getContextUsage === 'function') {
|
|
2491
2599
|
q.getContextUsage().then((usage) => {
|
|
2600
|
+
// SDK returns percentage in 0-100 scale, not 0-1.
|
|
2601
|
+
// Pre-rc.4 we treated it as a 0-1 ratio and multiplied
|
|
2602
|
+
// by 100, which displayed "7700% full" for a 77%-used
|
|
2603
|
+
// context (and fired below the intended 85% threshold).
|
|
2492
2604
|
const pct = usage?.percentage ?? 0;
|
|
2493
|
-
if (pct <
|
|
2605
|
+
if (pct < 85) return;
|
|
2494
2606
|
return tg(bot, 'sendMessage', {
|
|
2495
2607
|
chat_id: chatId,
|
|
2496
|
-
text: `📚 Context window ${
|
|
2608
|
+
text: `📚 Context window ${pct.toFixed(0)}% full. Send /new to start fresh — older messages will start dropping soon.`,
|
|
2497
2609
|
...(threadId ? { message_thread_id: threadId } : {}),
|
|
2498
2610
|
}, { source: 'context-full-hint', botName: BOT_NAME });
|
|
2499
2611
|
}).catch((err) => {
|
|
@@ -2512,6 +2624,31 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2512
2624
|
// those still markReplied silently.
|
|
2513
2625
|
if (result.text === 'NO_REPLY') { markReplied(); return; }
|
|
2514
2626
|
if (!result.text) {
|
|
2627
|
+
// 0.8.0-rc.7: tool-only completion is NOT an error. Under SDK
|
|
2628
|
+
// pm, a turn that ends after running tools (no closing text
|
|
2629
|
+
// block) leaves result.text empty even though the bot DID
|
|
2630
|
+
// respond — via tool side effects the user already saw. Don't
|
|
2631
|
+
// post a "No response generated" apology in that case; it's
|
|
2632
|
+
// confusing and it spams the chat. Just clear the reactor
|
|
2633
|
+
// (otherwise 👀 stays stuck — reactor.stop() doesn't remove
|
|
2634
|
+
// the emoji visually) and silently mark replied.
|
|
2635
|
+
const toolOnlyTurn = (result.metrics?.numToolUses ?? 0) > 0
|
|
2636
|
+
&& (result.metrics?.numAssistantMessages ?? 0) > 0;
|
|
2637
|
+
if (toolOnlyTurn) {
|
|
2638
|
+
await reactor.clear().catch(() => {});
|
|
2639
|
+
clearAutosteeredReactions(sessionKey).catch(() => {});
|
|
2640
|
+
// Tool-only turns DID fire PostToolBatch — buffer was drained
|
|
2641
|
+
// — but autosteers received AFTER the last tool-result still
|
|
2642
|
+
// wouldn't be merged. Defensive drain here too.
|
|
2643
|
+
drainStaleAutosteerBuffer(sessionKey, chatId, threadId).catch(() => {});
|
|
2644
|
+
logEvent('tool-only-completion', {
|
|
2645
|
+
chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
|
|
2646
|
+
num_tool_uses: result.metrics?.numToolUses,
|
|
2647
|
+
num_assistant_messages: result.metrics?.numAssistantMessages,
|
|
2648
|
+
});
|
|
2649
|
+
markReplied();
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2515
2652
|
// 0.7.1: if the fallback send itself fails, throw rather than
|
|
2516
2653
|
// silently markReplied — the user gets nothing AND the inbound
|
|
2517
2654
|
// is marked replied so boot replay won't redispatch. Same
|
|
@@ -2537,6 +2674,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2537
2674
|
logEvent('telegram-empty-response-fallback', {
|
|
2538
2675
|
chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
|
|
2539
2676
|
});
|
|
2677
|
+
// 0.8.0-rc.7: clear the THINKING/QUEUED emoji on the user's
|
|
2678
|
+
// message so 👀 doesn't stay stuck after the apology lands.
|
|
2679
|
+
// reactor.stop() (in the finally block) only kills timers; it
|
|
2680
|
+
// does NOT remove the visible emoji. Without this clear, the
|
|
2681
|
+
// user sees 👀 next to their message indefinitely.
|
|
2682
|
+
await reactor.clear().catch(() => {});
|
|
2540
2683
|
markReplied();
|
|
2541
2684
|
return;
|
|
2542
2685
|
}
|
|
@@ -2661,7 +2804,16 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2661
2804
|
const abortedByUser = isSessionRecentlyAborted(sessionKey);
|
|
2662
2805
|
if (abortedByUser) {
|
|
2663
2806
|
await streamer.finalize('').catch(() => {});
|
|
2664
|
-
//
|
|
2807
|
+
// 0.8.0-rc.13: clear the in-flight emoji on abort so the user
|
|
2808
|
+
// sees a clean message after their /stop ack — pre-rc.13 the
|
|
2809
|
+
// last 👀 / 🤔 / ✍ stayed stuck on the message indefinitely
|
|
2810
|
+
// because reactor.stop() (in finally) only kills timers, not
|
|
2811
|
+
// the visible reaction. We DON'T set 🤯/😨 (those are for
|
|
2812
|
+
// unexpected errors); the user just wants their stop honored.
|
|
2813
|
+
await reactor.clear().catch(() => {});
|
|
2814
|
+
// rc.14: clear ✍ on autosteered followups too (per-msg
|
|
2815
|
+
// reactors are already GC'd in their own handleMessage scopes).
|
|
2816
|
+
await clearAutosteeredReactions(sessionKey).catch(() => {});
|
|
2665
2817
|
} else {
|
|
2666
2818
|
await streamer.finalize('', { errorSuffix: 'stream interrupted' }).catch(() => {});
|
|
2667
2819
|
if (/wall-clock ceiling|idle with no Claude activity/i.test(err?.message || '')) {
|
|
@@ -2716,7 +2868,7 @@ function createBot(token) {
|
|
|
2716
2868
|
// Cached once @botUsername is known — was recompiling per inbound msg.
|
|
2717
2869
|
let mentionRe = null;
|
|
2718
2870
|
// Hoisted admin-command matcher; was re-allocated per message.
|
|
2719
|
-
const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context
|
|
2871
|
+
const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context)(\s|$)/;
|
|
2720
2872
|
const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
|
|
2721
2873
|
|
|
2722
2874
|
// The filter in main() guarantees config.chats only contains chats owned
|
|
@@ -2860,16 +3012,25 @@ function createBot(token) {
|
|
|
2860
3012
|
// sessionKey is the chat itself, so killing one session is
|
|
2861
3013
|
// the same as killing the chat — behavior unchanged for the
|
|
2862
3014
|
// common case.
|
|
2863
|
-
|
|
2864
|
-
|
|
3015
|
+
const stopTarget = pm.pickFor(sessionKey);
|
|
3016
|
+
if (typeof stopTarget.interrupt === 'function') {
|
|
3017
|
+
await stopTarget.interrupt(sessionKey).catch((err) =>
|
|
2865
3018
|
console.error(`[${BOT_NAME}] interrupt failed: ${err.message}`));
|
|
2866
|
-
if (typeof
|
|
2867
|
-
|
|
3019
|
+
if (typeof stopTarget.drainQueue === 'function') {
|
|
3020
|
+
stopTarget.drainQueue(sessionKey, 'INTERRUPTED');
|
|
2868
3021
|
}
|
|
2869
3022
|
} else {
|
|
2870
|
-
await
|
|
3023
|
+
await stopTarget.kill(sessionKey).catch((err) =>
|
|
2871
3024
|
console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
|
|
2872
3025
|
}
|
|
3026
|
+
// 0.8.0-rc.13: drop any buffered autosteer follow-ups for this
|
|
3027
|
+
// session — otherwise they'd be injected into the NEXT turn
|
|
3028
|
+
// (stale steer leak across abort boundary, which is what the
|
|
3029
|
+
// user just asked us not to do).
|
|
3030
|
+
autosteerBuffer.clear(sessionKey);
|
|
3031
|
+
// rc.14: also clear ✍ reactions on already-autosteered
|
|
3032
|
+
// messages from this aborted turn — they're now dead context.
|
|
3033
|
+
clearAutosteeredReactions(sessionKey).catch(() => {});
|
|
2873
3034
|
logEvent('abort-requested', {
|
|
2874
3035
|
chat_id: chatId, user_id: msg.from?.id || null,
|
|
2875
3036
|
had_active: hadActive,
|
|
@@ -3308,17 +3469,32 @@ async function main() {
|
|
|
3308
3469
|
});
|
|
3309
3470
|
|
|
3310
3471
|
const cap = config.maxWarmProcesses || DEFAULT_MAX_WARM_PROCS;
|
|
3311
|
-
|
|
3312
|
-
//
|
|
3313
|
-
//
|
|
3314
|
-
//
|
|
3315
|
-
//
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3472
|
+
|
|
3473
|
+
// 0.8.0-rc.6: per-chat pm selection. Three modes:
|
|
3474
|
+
// 1. POLYGRAM_USE_SDK=1 with no POLYGRAM_SDK_CHATS list → all chats SDK
|
|
3475
|
+
// 2. POLYGRAM_SDK_CHATS=id1,id2,... → those chats
|
|
3476
|
+
// use SDK; everyone else uses CLI (both pms live in the daemon)
|
|
3477
|
+
// 3. neither set → all chats CLI
|
|
3478
|
+
// The per-chat mode lets us soak SDK pm against real traffic in one
|
|
3479
|
+
// chat (Ivan's DM) while keeping partner-facing chats on the
|
|
3480
|
+
// battle-tested CLI path. When both pms run, killChat /shutdown
|
|
3481
|
+
// broadcast to both; everything else routes per-sessionKey via
|
|
3482
|
+
// pickPmFor() based on the chat's set membership.
|
|
3483
|
+
// rc.17: router policy + proxy live in lib/pm-router.js for
|
|
3484
|
+
// testability. Policy parses env config and produces
|
|
3485
|
+
// pickPmKindFor; createPmRouter wraps the cli/sdk pms with the
|
|
3486
|
+
// routed surface.
|
|
3487
|
+
const { sdkActive, sdkAllChats, sdkSomeChats, sdkChatIdSet, pickPmKindFor } = makeRouterPolicy({
|
|
3488
|
+
useSdkAll: USE_SDK,
|
|
3489
|
+
sdkChats: String(process.env.POLYGRAM_SDK_CHATS || '').split(','),
|
|
3490
|
+
getChatIdFromKey,
|
|
3491
|
+
});
|
|
3492
|
+
|
|
3493
|
+
// Shared callbacks: identical instance passed to both pms so a
|
|
3494
|
+
// chat's lifecycle events look the same regardless of which pm
|
|
3495
|
+
// is handling it.
|
|
3496
|
+
const pmOpts = {
|
|
3320
3497
|
cap,
|
|
3321
|
-
spawnFn,
|
|
3322
3498
|
db,
|
|
3323
3499
|
logger: console,
|
|
3324
3500
|
onInit: (sessionKey, event, entry) => {
|
|
@@ -3344,6 +3520,14 @@ async function main() {
|
|
|
3344
3520
|
const head = entry.pendingQueue?.[0];
|
|
3345
3521
|
const s = head?.context?.streamer;
|
|
3346
3522
|
if (s) s.onChunk(partial).catch(() => {});
|
|
3523
|
+
// 0.8.0-rc.16: heartbeat the reactor so long text generation
|
|
3524
|
+
// doesn't trip the 10s STALL → 🥱 / 30s TIMEOUT → 😨 promotion.
|
|
3525
|
+
// Pre-rc.16 the reactor only got setState calls at turn start
|
|
3526
|
+
// (THINKING) and per-tool (CODING/TOOL/...); pure text turns
|
|
3527
|
+
// hit STALL within 10s of streaming. heartbeat() re-arms the
|
|
3528
|
+
// stall timers without changing the visible emoji.
|
|
3529
|
+
const r = head?.context?.reactor;
|
|
3530
|
+
if (r && typeof r.heartbeat === 'function') r.heartbeat();
|
|
3347
3531
|
},
|
|
3348
3532
|
onToolUse: (sessionKey, toolName, entry) => {
|
|
3349
3533
|
const head = entry.pendingQueue?.[0];
|
|
@@ -3352,14 +3536,15 @@ async function main() {
|
|
|
3352
3536
|
// 0.7.0 (Phase J): opt-in subagent announce. When Claude uses
|
|
3353
3537
|
// the Task tool to spawn a subagent, post a brief informational
|
|
3354
3538
|
// message to the chat so the user knows a heavier turn is in
|
|
3355
|
-
// progress.
|
|
3356
|
-
// `announceSubagents:
|
|
3357
|
-
// prevents announce-storms in tool-heavy
|
|
3539
|
+
// progress. ON by default (rc.9+) — set per-chat
|
|
3540
|
+
// `announceSubagents: false` (or per-bot) to silence.
|
|
3541
|
+
// Per-chat debounce 30s prevents announce-storms in tool-heavy
|
|
3542
|
+
// turns.
|
|
3358
3543
|
const chatCfg = config.chats[entry.chatId] || {};
|
|
3359
|
-
const
|
|
3360
|
-
? chatCfg.announceSubagents
|
|
3361
|
-
: config.bot?.announceSubagents;
|
|
3362
|
-
if (toolName === 'Task' &&
|
|
3544
|
+
const optOut = chatCfg.announceSubagents != null
|
|
3545
|
+
? chatCfg.announceSubagents === false
|
|
3546
|
+
: config.bot?.announceSubagents === false;
|
|
3547
|
+
if (toolName === 'Task' && !optOut) {
|
|
3363
3548
|
if (shouldAnnounce(entry.chatId)) {
|
|
3364
3549
|
announce({
|
|
3365
3550
|
send: (b, method, params, m) => tg(b, method, params, m),
|
|
@@ -3385,24 +3570,26 @@ async function main() {
|
|
|
3385
3570
|
// 0.8.0 Phase 2 step 5: SDK auto-compaction observability. Fires
|
|
3386
3571
|
// when SDK emits SDKCompactBoundaryMessage (between turns or
|
|
3387
3572
|
// mid-turn — see Phase 0 gate 8.5). Surfaces a quiet system
|
|
3388
|
-
// status note to the chat so the user knows
|
|
3389
|
-
//
|
|
3573
|
+
// status note to the chat so the user knows the bot is busy
|
|
3574
|
+
// reorganising context (compaction can take seconds, during
|
|
3575
|
+
// which the bot looks unresponsive). ON by default (rc.12+) —
|
|
3576
|
+
// set per-chat or per-bot `announceCompact: false` to silence.
|
|
3390
3577
|
// Only fires under SDK pm — the CLI pm has no equivalent event.
|
|
3578
|
+
//
|
|
3579
|
+
// Wording is intentionally non-technical — the user doesn't
|
|
3580
|
+
// care about "compaction" or "tokens"; they just want to know
|
|
3581
|
+
// the bot didn't hang.
|
|
3391
3582
|
onCompactBoundary: async (sessionKey, msg, entry) => {
|
|
3392
3583
|
const chatCfg = config.chats[entry.chatId] || {};
|
|
3393
|
-
const
|
|
3394
|
-
? chatCfg.announceCompact
|
|
3395
|
-
: config.bot?.announceCompact;
|
|
3396
|
-
if (
|
|
3397
|
-
const meta = msg.compact_metadata || {};
|
|
3398
|
-
const summary = meta.pre_tokens && meta.post_tokens
|
|
3399
|
-
? ` (${(meta.pre_tokens / 1000).toFixed(0)}K → ${(meta.post_tokens / 1000).toFixed(0)}K tokens)`
|
|
3400
|
-
: '';
|
|
3584
|
+
const optOut = chatCfg.announceCompact != null
|
|
3585
|
+
? chatCfg.announceCompact === false
|
|
3586
|
+
: config.bot?.announceCompact === false;
|
|
3587
|
+
if (optOut) return;
|
|
3401
3588
|
const threadId = entry.threadId || undefined;
|
|
3402
3589
|
try {
|
|
3403
3590
|
await tg(bot, 'sendMessage', {
|
|
3404
3591
|
chat_id: entry.chatId,
|
|
3405
|
-
text:
|
|
3592
|
+
text: '💭 Catching up on history, one moment…',
|
|
3406
3593
|
...(threadId ? { message_thread_id: threadId } : {}),
|
|
3407
3594
|
}, { source: 'compact-boundary', botName: BOT_NAME });
|
|
3408
3595
|
} catch (err) {
|
|
@@ -3433,7 +3620,28 @@ async function main() {
|
|
|
3433
3620
|
...(threadId && { message_thread_id: threadId }),
|
|
3434
3621
|
}, { source: 'respawn-confirm', botName: BOT_NAME }).catch(() => {});
|
|
3435
3622
|
},
|
|
3436
|
-
}
|
|
3623
|
+
};
|
|
3624
|
+
|
|
3625
|
+
// Instantiate the actual pm(s). When sdkActive is false we still
|
|
3626
|
+
// build a CLI pm; SDK pm is null. When sdkActive is true we always
|
|
3627
|
+
// build BOTH so chats outside the SDK list still get the CLI path.
|
|
3628
|
+
const cliPm = new ProcessManager({ ...pmOpts, spawnFn: spawnClaude });
|
|
3629
|
+
const sdkPm = sdkActive
|
|
3630
|
+
? new ProcessManagerSdk({ ...pmOpts, spawnFn: buildSdkOptions })
|
|
3631
|
+
: null;
|
|
3632
|
+
|
|
3633
|
+
// Routing pm: same surface as a single pm, but per-method routing
|
|
3634
|
+
// through pickPmKindFor(sessionKey). Per-method semantics
|
|
3635
|
+
// documented in lib/pm-router.js.
|
|
3636
|
+
pm = createPmRouter({ cliPm, sdkPm, pickPmKindFor });
|
|
3637
|
+
|
|
3638
|
+
if (sdkAllChats) {
|
|
3639
|
+
console.log('[polygram] using SDK ProcessManager (all chats)');
|
|
3640
|
+
} else if (sdkSomeChats) {
|
|
3641
|
+
console.log(`[polygram] router active: SDK pm for chats {${Array.from(sdkChatIdSet).join(',')}}, CLI pm for everyone else`);
|
|
3642
|
+
} else {
|
|
3643
|
+
console.log('[polygram] using CLI ProcessManager');
|
|
3644
|
+
}
|
|
3437
3645
|
|
|
3438
3646
|
console.log(`polygram (LRU cap=${cap}, SQLite source of truth)`);
|
|
3439
3647
|
console.log(`Chats: ${Object.entries(config.chats).map(([id, c]) => `${c.name} (${c.model}/${c.effort})`).join(', ')}`);
|