polygram 0.7.9 → 0.8.0-rc.2
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 +169 -0
- package/lib/approval-waiters.js +194 -0
- package/lib/attachments.js +7 -9
- package/lib/db.js +93 -7
- package/lib/process-manager-sdk.js +940 -0
- package/migrations/010-tool-use-id.sql +62 -0
- package/package.json +2 -1
- package/polygram.js +662 -18
package/polygram.js
CHANGED
|
@@ -25,6 +25,14 @@ const { migrateJsonToDb, getClaudeSessionId } = require('./lib/sessions');
|
|
|
25
25
|
const { buildPrompt } = require('./lib/prompt');
|
|
26
26
|
const { filterAttachments, MAX_FILE_BYTES } = require('./lib/attachments');
|
|
27
27
|
const { ProcessManager } = require('./lib/process-manager');
|
|
28
|
+
// 0.8.0 Phase 3: SDK-backed pm available behind POLYGRAM_USE_SDK=1.
|
|
29
|
+
// Both implementations expose the same public API (constructor +
|
|
30
|
+
// callbacks), so the rest of polygram.js doesn't branch beyond the
|
|
31
|
+
// pick-at-startup. Phase 4 deletes the CLI version after Phase 5
|
|
32
|
+
// soak proves SDK stable. See docs/0.8.0-architecture-decisions.md.
|
|
33
|
+
const { ProcessManagerSdk } = require('./lib/process-manager-sdk');
|
|
34
|
+
const agentLoader = require('./lib/agent-loader');
|
|
35
|
+
const USE_SDK = process.env.POLYGRAM_USE_SDK === '1';
|
|
28
36
|
const { createSender } = require('./lib/telegram');
|
|
29
37
|
const { createAsyncLock } = require('./lib/async-lock');
|
|
30
38
|
const { sweepInbox } = require('./lib/inbox');
|
|
@@ -752,6 +760,98 @@ function spawnClaude(sessionKey, ctx) {
|
|
|
752
760
|
return proc;
|
|
753
761
|
}
|
|
754
762
|
|
|
763
|
+
/**
|
|
764
|
+
* 0.8.0 Phase 3 — SDK pm spawn factory.
|
|
765
|
+
*
|
|
766
|
+
* Replacement for `spawnClaude` when POLYGRAM_USE_SDK=1. Returns
|
|
767
|
+
* SdkOptions for the SDK pm to pass to `query({ prompt, options })`.
|
|
768
|
+
* The SDK pm wraps this in its inputController + iteration loop;
|
|
769
|
+
* polygram only needs to compose the Options object.
|
|
770
|
+
*
|
|
771
|
+
* Per v4 plan §6.5.7 — explicit env enumeration (Options.env is
|
|
772
|
+
* SHADOW per Phase 0 gate 33), bypassPermissions +
|
|
773
|
+
* allowDangerouslySkipPermissions both set for forward-compat,
|
|
774
|
+
* agent-loader composes per-chat agent into systemPrompt + skills +
|
|
775
|
+
* mcpServers, optional resume sessionId for continuity.
|
|
776
|
+
*/
|
|
777
|
+
function buildSdkOptions(sessionKey, ctx) {
|
|
778
|
+
const { chatConfig, existingSessionId, label, chatId } = ctx;
|
|
779
|
+
|
|
780
|
+
// Per-chat agent (D14): if pinned, load & compose. Failure is
|
|
781
|
+
// non-fatal — chat falls back to defaults; logged for ops.
|
|
782
|
+
let agentBundle = null;
|
|
783
|
+
if (chatConfig.agent) {
|
|
784
|
+
try {
|
|
785
|
+
agentBundle = agentLoader.loadAgent(chatConfig.agent, {
|
|
786
|
+
homeDir: CHILD_HOME,
|
|
787
|
+
logger: console,
|
|
788
|
+
});
|
|
789
|
+
} catch (err) {
|
|
790
|
+
console.error(`[${label}] agent-loader: ${err.message}`);
|
|
791
|
+
logEvent('agent-load-failed', {
|
|
792
|
+
chat_id: chatId, agent: chatConfig.agent, error: err.message,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
console.log(`[${label}] Spawning SDK Query (${chatConfig.model}/${chatConfig.effort})`);
|
|
798
|
+
|
|
799
|
+
// Env: SHADOW semantics (gate 33) — must enumerate every var
|
|
800
|
+
// pollygram needs in the spawned worker.
|
|
801
|
+
const botConfig = config.bot || {};
|
|
802
|
+
const childEnv = filterEnv(process.env);
|
|
803
|
+
childEnv.HOME = CHILD_HOME;
|
|
804
|
+
childEnv.CLAUDE_CHANNEL_BOT = BOT_NAME;
|
|
805
|
+
if (process.env.POLYGRAM_IPC_SECRET) {
|
|
806
|
+
childEnv.POLYGRAM_IPC_SECRET = process.env.POLYGRAM_IPC_SECRET;
|
|
807
|
+
}
|
|
808
|
+
if (botConfig.needsToken) {
|
|
809
|
+
childEnv.TELEGRAM_BOT_TOKEN = botConfig.token || '';
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// 0.8.0 Phase 2 step 6: in-process approval flow via canUseTool.
|
|
813
|
+
// Wire up only when approvals.gatedTools is configured for this
|
|
814
|
+
// bot — otherwise leave canUseTool unset and rely on
|
|
815
|
+
// bypassPermissions for the full allow-all path.
|
|
816
|
+
const apprCfg = config.bot?.approvals;
|
|
817
|
+
const useCanUseTool = apprCfg && apprCfg.adminChatId
|
|
818
|
+
&& Array.isArray(apprCfg.gatedTools) && apprCfg.gatedTools.length > 0;
|
|
819
|
+
|
|
820
|
+
const baseOpts = {
|
|
821
|
+
model: chatConfig.model || config.defaults.model,
|
|
822
|
+
effort: chatConfig.effort || config.defaults.effort,
|
|
823
|
+
cwd: chatConfig.cwd,
|
|
824
|
+
env: childEnv,
|
|
825
|
+
// permissionMode 'default' when canUseTool is wired (so the SDK
|
|
826
|
+
// actually consults our callback). Otherwise stick with
|
|
827
|
+
// bypassPermissions (matches today's CLI behaviour).
|
|
828
|
+
permissionMode: useCanUseTool ? 'default' : 'bypassPermissions',
|
|
829
|
+
allowDangerouslySkipPermissions: !useCanUseTool,
|
|
830
|
+
...(useCanUseTool && { canUseTool: makeCanUseTool(sessionKey) }),
|
|
831
|
+
executable: 'node',
|
|
832
|
+
...(existingSessionId && { resume: existingSessionId }),
|
|
833
|
+
...(process.env.POLYGRAM_CLAUDE_BIN && {
|
|
834
|
+
pathToClaudeCodeExecutable: process.env.POLYGRAM_CLAUDE_BIN,
|
|
835
|
+
}),
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
// Compose with agent overlay + chat-level config. agent-loader
|
|
839
|
+
// precedence: chatConfig > agent > defaults. The chatConfig keys
|
|
840
|
+
// we care about for SDK options are model/effort/cwd/thinking;
|
|
841
|
+
// others (agent, chrome, isolateTopics) are polygram-only.
|
|
842
|
+
return agentLoader.composeSdkOptions(
|
|
843
|
+
{
|
|
844
|
+
// chat-level overrides — only the keys SDK understands.
|
|
845
|
+
model: chatConfig.model,
|
|
846
|
+
effort: chatConfig.effort,
|
|
847
|
+
cwd: chatConfig.cwd,
|
|
848
|
+
...(chatConfig.thinking && { thinking: chatConfig.thinking }),
|
|
849
|
+
},
|
|
850
|
+
agentBundle,
|
|
851
|
+
baseOpts,
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
|
|
755
855
|
function buildSpawnContext(sessionKey) {
|
|
756
856
|
const chatId = getChatIdFromKey(sessionKey);
|
|
757
857
|
const chatConfig = config.chats[chatId];
|
|
@@ -1043,6 +1143,32 @@ function buildApprovalKeyboard(approvalId, token) {
|
|
|
1043
1143
|
};
|
|
1044
1144
|
}
|
|
1045
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
|
+
}
|
|
1171
|
+
|
|
1046
1172
|
// /model and /effort inline keyboard. `show` controls which row(s) appear:
|
|
1047
1173
|
// 'model', 'effort', or 'all'. The current value gets a ✓ marker so the
|
|
1048
1174
|
// user can see at a glance what's selected.
|
|
@@ -1133,6 +1259,174 @@ function safeParse(s) {
|
|
|
1133
1259
|
try { return JSON.parse(s); } catch { return s; }
|
|
1134
1260
|
}
|
|
1135
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
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* 0.8.0 Phase 2 step 6: SDK canUseTool callback. Hands back to the
|
|
1290
|
+
* SDK an async PermissionResult per `@anthropic-ai/claude-agent-sdk`
|
|
1291
|
+
* sdk.d.ts:146-188.
|
|
1292
|
+
*
|
|
1293
|
+
* Flow (per v4 plan §4.2):
|
|
1294
|
+
* 1. If no approval config → allow.
|
|
1295
|
+
* 2. Look up chat_tool_decisions for short-circuit (always-allow/
|
|
1296
|
+
* always-deny by exact / prefix / regex match). If found,
|
|
1297
|
+
* return that decision.
|
|
1298
|
+
* 3. Match against config.bot.approvals.gatedTools; if not gated,
|
|
1299
|
+
* allow.
|
|
1300
|
+
* 4. Issue pending_approvals row (with tool_use_id dedup); post
|
|
1301
|
+
* 4-button card to admin chat; park resolver in
|
|
1302
|
+
* approvalWaiters Map; race against opts.signal + timeout.
|
|
1303
|
+
* 5. Return PermissionResult; the SDK lets the tool run or denies.
|
|
1304
|
+
*
|
|
1305
|
+
* Reuses the existing approvals store + approvalWaiters Map (same
|
|
1306
|
+
* shape as today's IPC flow). Both paths can coexist on the same
|
|
1307
|
+
* daemon — IPC for CLI pm chats, canUseTool for SDK pm chats.
|
|
1308
|
+
*/
|
|
1309
|
+
function makeCanUseTool(sessionKey) {
|
|
1310
|
+
const chatId = getChatIdFromKey(sessionKey);
|
|
1311
|
+
const threadId = sessionKey.includes(':') ? sessionKey.split(':')[1] : null;
|
|
1312
|
+
return async function canUseTool(toolName, input, opts) {
|
|
1313
|
+
const apprCfg = config.bot?.approvals;
|
|
1314
|
+
if (!apprCfg || !apprCfg.adminChatId) {
|
|
1315
|
+
// Not configured for this bot → allow everything (matches CLI
|
|
1316
|
+
// pm's bypassPermissions today when approvals not set).
|
|
1317
|
+
return { behavior: 'allow' };
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const canonicalInput = canonicalizeToolInput(input);
|
|
1321
|
+
|
|
1322
|
+
// Step 2: chat_tool_decisions short-circuit.
|
|
1323
|
+
try {
|
|
1324
|
+
const persisted = db.lookupChatToolDecision({
|
|
1325
|
+
bot_name: BOT_NAME, chat_id: chatId, tool_name: toolName,
|
|
1326
|
+
canonical_input: canonicalInput, now: Date.now(),
|
|
1327
|
+
});
|
|
1328
|
+
if (persisted) {
|
|
1329
|
+
logEvent('canusetool-shortcircuit', {
|
|
1330
|
+
chat_id: chatId, tool_name: toolName,
|
|
1331
|
+
decision: persisted.decision, match_type: persisted.match_type,
|
|
1332
|
+
tool_use_id: opts?.toolUseID || null,
|
|
1333
|
+
});
|
|
1334
|
+
if (persisted.decision === 'allow') return { behavior: 'allow' };
|
|
1335
|
+
return { behavior: 'deny', message: 'matched persisted always-deny rule' };
|
|
1336
|
+
}
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
console.error(`[${sessionKey}] chat_tool_decisions lookup: ${err.message}`);
|
|
1339
|
+
// Non-fatal — fall through to gating + card.
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// Step 3: gating check.
|
|
1343
|
+
const gated = matchesApprovalPattern(toolName, input, apprCfg.gatedTools || []);
|
|
1344
|
+
if (!gated.matched) return { behavior: 'allow' };
|
|
1345
|
+
|
|
1346
|
+
// Step 4: issue + post + park.
|
|
1347
|
+
const row = approvals.issue({
|
|
1348
|
+
bot_name: BOT_NAME, turn_id: opts?.toolUseID || null,
|
|
1349
|
+
requester_chat_id: chatId,
|
|
1350
|
+
approver_chat_id: String(apprCfg.adminChatId),
|
|
1351
|
+
tool_name: toolName, tool_input: input,
|
|
1352
|
+
timeoutMs: apprCfg.timeoutMs || APPROVAL_DEFAULT_TIMEOUT_MS,
|
|
1353
|
+
});
|
|
1354
|
+
if (opts?.toolUseID) {
|
|
1355
|
+
// Persist the SDK's stable per-call ID so dedup-by-toolUseId
|
|
1356
|
+
// works on retries (same call, same row).
|
|
1357
|
+
try {
|
|
1358
|
+
approvals.setToolUseId?.(row.id, opts.toolUseID);
|
|
1359
|
+
} catch { /* swallow if older approvals store */ }
|
|
1360
|
+
}
|
|
1361
|
+
if (!bot) {
|
|
1362
|
+
approvals.resolve({ id: row.id, status: 'cancelled', reason: 'bot not ready' });
|
|
1363
|
+
return { behavior: 'deny', message: 'bot not ready' };
|
|
1364
|
+
}
|
|
1365
|
+
if (!row.reused || !row.approver_msg_id) {
|
|
1366
|
+
try {
|
|
1367
|
+
const sent = await tg(bot, 'sendMessage', {
|
|
1368
|
+
chat_id: apprCfg.adminChatId,
|
|
1369
|
+
text: approvalCardText(row),
|
|
1370
|
+
reply_markup: buildApprovalKeyboardWithAlways(row.id, row.callback_token),
|
|
1371
|
+
}, { source: 'canusetool-card', botName: BOT_NAME, plainText: true });
|
|
1372
|
+
if (sent?.message_id) approvals.setApproverMsgId(row.id, sent.message_id);
|
|
1373
|
+
} catch (err) {
|
|
1374
|
+
console.error(`[${sessionKey}] failed to post canUseTool card: ${err.message}`);
|
|
1375
|
+
approvals.resolve({ id: row.id, status: 'cancelled', reason: `post failed: ${err.message}` });
|
|
1376
|
+
return { behavior: 'deny', message: `post failed: ${err.message}` };
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Step 5: race signal + timeout + click.
|
|
1381
|
+
return await new Promise((resolve) => {
|
|
1382
|
+
let settled = false;
|
|
1383
|
+
const settle = (decision) => {
|
|
1384
|
+
if (settled) return;
|
|
1385
|
+
settled = true;
|
|
1386
|
+
clearTimeout(timer);
|
|
1387
|
+
if (opts?.signal && sigCleanup) {
|
|
1388
|
+
try { opts.signal.removeEventListener('abort', sigCleanup); }
|
|
1389
|
+
catch { /* swallow */ }
|
|
1390
|
+
}
|
|
1391
|
+
dropWaiter(row.id, wrappedResolve);
|
|
1392
|
+
resolve(decision);
|
|
1393
|
+
};
|
|
1394
|
+
const timer = setTimeout(() => {
|
|
1395
|
+
approvals.resolve({ id: row.id, status: 'timeout' }).catch?.(() => {});
|
|
1396
|
+
settle({ behavior: 'deny', message: 'approval timed out' });
|
|
1397
|
+
}, Math.max(1000, row.timeout_ts - Date.now()));
|
|
1398
|
+
const sigCleanup = opts?.signal
|
|
1399
|
+
? () => settle({ behavior: 'deny', message: 'aborted' })
|
|
1400
|
+
: null;
|
|
1401
|
+
if (opts?.signal && sigCleanup) {
|
|
1402
|
+
opts.signal.addEventListener('abort', sigCleanup, { once: true });
|
|
1403
|
+
}
|
|
1404
|
+
const wrappedResolve = (decision, reason, extra) => {
|
|
1405
|
+
// decision here is from resolveApprovalWaiter:
|
|
1406
|
+
// 'approved' | 'denied' | 'approved-always' | 'denied-always'
|
|
1407
|
+
// Map to SDK PermissionResult shape. extra carries
|
|
1408
|
+
// updatedPermissions for the always-* variants.
|
|
1409
|
+
if (decision === 'approved' || decision === 'approved-always') {
|
|
1410
|
+
settle({
|
|
1411
|
+
behavior: 'allow',
|
|
1412
|
+
...(decision === 'approved-always' && extra?.updatedPermissions
|
|
1413
|
+
? { updatedPermissions: extra.updatedPermissions }
|
|
1414
|
+
: {}),
|
|
1415
|
+
});
|
|
1416
|
+
} else {
|
|
1417
|
+
settle({
|
|
1418
|
+
behavior: 'deny',
|
|
1419
|
+
message: reason || decision || 'denied',
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
};
|
|
1423
|
+
const list = approvalWaiters.get(row.id) || [];
|
|
1424
|
+
list.push(wrappedResolve);
|
|
1425
|
+
approvalWaiters.set(row.id, list);
|
|
1426
|
+
});
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1136
1430
|
async function handleApprovalRequest(req) {
|
|
1137
1431
|
const { bot_name, chat_id, turn_id, tool_name, tool_input } = req;
|
|
1138
1432
|
if (!chat_id || !tool_name) {
|
|
@@ -1194,6 +1488,11 @@ async function handleApprovalRequest(req) {
|
|
|
1194
1488
|
|
|
1195
1489
|
const wrappedResolve = (decision, reason) => {
|
|
1196
1490
|
clearTimeout(timer);
|
|
1491
|
+
// Translate 'approved-always' / 'denied-always' to plain
|
|
1492
|
+
// approve/deny for the IPC caller — the IPC hook protocol
|
|
1493
|
+
// doesn't carry persistence state, only the bool decision.
|
|
1494
|
+
if (decision === 'approved-always') decision = 'approved';
|
|
1495
|
+
else if (decision === 'denied-always') decision = 'denied';
|
|
1197
1496
|
resolve({ decision, reason });
|
|
1198
1497
|
};
|
|
1199
1498
|
|
|
@@ -1211,18 +1510,26 @@ function dropWaiter(id, fn) {
|
|
|
1211
1510
|
if (list.length === 0) approvalWaiters.delete(id);
|
|
1212
1511
|
}
|
|
1213
1512
|
|
|
1214
|
-
function resolveApprovalWaiter(id, decision, reason) {
|
|
1513
|
+
function resolveApprovalWaiter(id, decision, reason, extra) {
|
|
1514
|
+
// `extra` carries SDK-shape updatedPermissions for always-* clicks.
|
|
1515
|
+
// IPC waiters (CLI pm) ignore it; SDK canUseTool waiters use it
|
|
1516
|
+
// to populate PermissionResult.updatedPermissions so the in-flight
|
|
1517
|
+
// Query picks up the new rule for the rest of the turn.
|
|
1215
1518
|
const list = approvalWaiters.get(id);
|
|
1216
1519
|
if (!list) return;
|
|
1217
1520
|
approvalWaiters.delete(id);
|
|
1218
1521
|
for (const fn of list) {
|
|
1219
|
-
try { fn(decision, reason); } catch {}
|
|
1522
|
+
try { fn(decision, reason, extra); } catch {}
|
|
1220
1523
|
}
|
|
1221
1524
|
}
|
|
1222
1525
|
|
|
1223
1526
|
async function handleApprovalCallback(ctx) {
|
|
1224
1527
|
const data = ctx.callbackQuery?.data || '';
|
|
1225
|
-
|
|
1528
|
+
// 0.8.0 Phase 2 step 6: extended pattern accepts the 4-button
|
|
1529
|
+
// SDK canUseTool format. `approve-always` / `deny-always`
|
|
1530
|
+
// additionally write a row to chat_tool_decisions so subsequent
|
|
1531
|
+
// calls to the same tool with the same input short-circuit.
|
|
1532
|
+
const m = String(data).match(/^(approve|deny|approve-always|deny-always):(\d+):(\S+)$/);
|
|
1226
1533
|
if (!m) return;
|
|
1227
1534
|
const decision = m[1];
|
|
1228
1535
|
const id = parseInt(m[2], 10);
|
|
@@ -1259,7 +1566,14 @@ async function handleApprovalCallback(ctx) {
|
|
|
1259
1566
|
return;
|
|
1260
1567
|
}
|
|
1261
1568
|
|
|
1262
|
-
|
|
1569
|
+
// 0.8.0 Phase 2 step 6: parse always-variants. The base status
|
|
1570
|
+
// ('approved' / 'denied') drives existing logic + card edit;
|
|
1571
|
+
// `isAlways` triggers the chat_tool_decisions persistence
|
|
1572
|
+
// below (after the atomic SQL resolve succeeds, so we don't
|
|
1573
|
+
// write a "always" rule for a stale double-click).
|
|
1574
|
+
const isApprove = decision === 'approve' || decision === 'approve-always';
|
|
1575
|
+
const isAlways = decision === 'approve-always' || decision === 'deny-always';
|
|
1576
|
+
const status = isApprove ? 'approved' : 'denied';
|
|
1263
1577
|
const user = ctx.from?.first_name || ctx.from?.username || null;
|
|
1264
1578
|
const userId = ctx.from?.id || null;
|
|
1265
1579
|
// SQL-level atomic resolve: UPDATE ... WHERE status='pending' — so in a
|
|
@@ -1300,9 +1614,56 @@ async function handleApprovalCallback(ctx) {
|
|
|
1300
1614
|
} catch (err) {
|
|
1301
1615
|
console.error(`[${BOT_NAME}] edit approval card failed: ${err.message}`);
|
|
1302
1616
|
}
|
|
1617
|
+
// 0.8.0 Phase 2 step 6: persist always-* clicks to chat_tool_decisions
|
|
1618
|
+
// so subsequent SDK canUseTool calls for the same (bot, chat, tool,
|
|
1619
|
+
// input) short-circuit without prompting. Use prefix match by default
|
|
1620
|
+
// (allows minor argument variations) — the user can hand-edit to
|
|
1621
|
+
// exact / regex via SQL if they want narrower rules.
|
|
1622
|
+
let updatedPermissions = null;
|
|
1623
|
+
if (isAlways) {
|
|
1624
|
+
try {
|
|
1625
|
+
const canonical = canonicalizeToolInput(row.tool_input);
|
|
1626
|
+
db.insertChatToolDecision({
|
|
1627
|
+
bot_name: BOT_NAME,
|
|
1628
|
+
chat_id: row.requester_chat_id,
|
|
1629
|
+
tool_name: row.tool_name,
|
|
1630
|
+
match_type: 'prefix', // most useful default; exact would be too narrow
|
|
1631
|
+
input_pattern: canonical,
|
|
1632
|
+
decision: status === 'approved' ? 'allow' : 'deny',
|
|
1633
|
+
issued_by_user_id: userId ? String(userId) : null,
|
|
1634
|
+
expires_ts: null,
|
|
1635
|
+
});
|
|
1636
|
+
logEvent('chat-tool-decision-persisted', {
|
|
1637
|
+
chat_id: row.requester_chat_id,
|
|
1638
|
+
tool_name: row.tool_name,
|
|
1639
|
+
decision: status === 'approved' ? 'allow' : 'deny',
|
|
1640
|
+
match_type: 'prefix',
|
|
1641
|
+
});
|
|
1642
|
+
// Build SDK-shape updatedPermissions so the in-flight Query
|
|
1643
|
+
// also picks up the rule for the rest of THIS turn (avoids
|
|
1644
|
+
// re-prompting on the next sibling tool call).
|
|
1645
|
+
updatedPermissions = [{
|
|
1646
|
+
type: 'addRules',
|
|
1647
|
+
rules: [{
|
|
1648
|
+
toolName: row.tool_name,
|
|
1649
|
+
decision: status === 'approved' ? 'allow' : 'deny',
|
|
1650
|
+
}],
|
|
1651
|
+
}];
|
|
1652
|
+
} catch (err) {
|
|
1653
|
+
console.error(`[${BOT_NAME}] chat_tool_decisions persist failed: ${err.message}`);
|
|
1654
|
+
// Non-fatal — the one-time decision still propagates below.
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1303
1658
|
await ctx.answerCallbackQuery({ text: status }).catch(() => {});
|
|
1304
1659
|
|
|
1305
|
-
|
|
1660
|
+
// Pass the original decision token back to the waiter so it can
|
|
1661
|
+
// distinguish 'approved-always' (SDK gets updatedPermissions)
|
|
1662
|
+
// from plain 'approved'. CLI IPC waiters strip back to plain
|
|
1663
|
+
// approve/deny in their wrappedResolve.
|
|
1664
|
+
resolveApprovalWaiter(id, decision === 'approve-always' ? 'approved-always'
|
|
1665
|
+
: decision === 'deny-always' ? 'denied-always'
|
|
1666
|
+
: status, undefined, { updatedPermissions });
|
|
1306
1667
|
}
|
|
1307
1668
|
|
|
1308
1669
|
// Handles taps on the /model and /effort inline keyboard buttons. Same
|
|
@@ -1499,6 +1860,115 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1499
1860
|
await sendReply(info, { params: { reply_markup } });
|
|
1500
1861
|
return;
|
|
1501
1862
|
}
|
|
1863
|
+
// 0.8.0 Phase 2 step 7: /new and /reset slash commands. Both close
|
|
1864
|
+
// the current Query (if any), clear the claude_session_id from the
|
|
1865
|
+
// sessions table, and post "✨ Started a fresh session." Next user
|
|
1866
|
+
// message starts a fresh subprocess with no resume.
|
|
1867
|
+
//
|
|
1868
|
+
// Equivalent UX to OpenClaw's /new and /reset handlers
|
|
1869
|
+
// (pi-embedded:40594 BARE_SESSION_RESET_PROMPT). Required by the
|
|
1870
|
+
// 85%-context-full hint (Phase 2 step 4) and by classifier-driven
|
|
1871
|
+
// auto-recovery (step 8) — both reference these commands.
|
|
1872
|
+
// 0.8.0 Phase 2 step 9: /context slash command. On-demand context-
|
|
1873
|
+
// usage report. Only meaningful under SDK pm (CLI pm has no
|
|
1874
|
+
// getContextUsage equivalent); CLI path replies with a hint.
|
|
1875
|
+
if (botAllowsCommands && text === '/context') {
|
|
1876
|
+
if (!USE_SDK) {
|
|
1877
|
+
await sendReply('📚 /context requires the SDK pm (set POLYGRAM_USE_SDK=1 to enable).');
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
const entry = pm.get(sessionKey);
|
|
1881
|
+
const q = entry?.query;
|
|
1882
|
+
if (!q || typeof q.getContextUsage !== 'function') {
|
|
1883
|
+
await sendReply('📚 No active session yet — send a message first, then /context.');
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
try {
|
|
1887
|
+
const u = await q.getContextUsage();
|
|
1888
|
+
const pct = ((u?.percentage ?? 0) * 100).toFixed(0);
|
|
1889
|
+
const total = (u?.totalTokens ?? 0).toLocaleString();
|
|
1890
|
+
const max = (u?.maxTokens ?? 0).toLocaleString();
|
|
1891
|
+
const lines = [`📚 Context: ${total} / ${max} tokens (${pct}%)`];
|
|
1892
|
+
if (u?.model) lines.push(`Model: ${u.model}`);
|
|
1893
|
+
if (u?.isAutoCompactEnabled && u?.autoCompactThreshold) {
|
|
1894
|
+
const thrPct = (u.autoCompactThreshold * 100).toFixed(0);
|
|
1895
|
+
lines.push(`Auto-compact at ${thrPct}%.`);
|
|
1896
|
+
}
|
|
1897
|
+
// Top-3 categories by token cost so the user knows where the
|
|
1898
|
+
// budget is going. SDK exposes a rich breakdown in
|
|
1899
|
+
// u.categories — we just summarise.
|
|
1900
|
+
if (Array.isArray(u?.categories) && u.categories.length) {
|
|
1901
|
+
const top = [...u.categories]
|
|
1902
|
+
.filter((c) => Number.isFinite(c?.tokens) && c.tokens > 0)
|
|
1903
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
1904
|
+
.slice(0, 3)
|
|
1905
|
+
.map((c) => ` • ${c.label || c.name || '?'}: ${c.tokens.toLocaleString()}`);
|
|
1906
|
+
if (top.length) lines.push('Top categories:', ...top);
|
|
1907
|
+
}
|
|
1908
|
+
await sendReply(lines.join('\n'));
|
|
1909
|
+
} catch (err) {
|
|
1910
|
+
console.error(`[${label}] /context failed: ${err.message}`);
|
|
1911
|
+
await sendReply(`📚 Couldn't fetch context info: ${err.message}`);
|
|
1912
|
+
}
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
if (botAllowsCommands && (text === '/new' || text === '/reset')) {
|
|
1916
|
+
let drained = 0;
|
|
1917
|
+
if (typeof pm.resetSession === 'function') {
|
|
1918
|
+
try {
|
|
1919
|
+
const r = await pm.resetSession(sessionKey, { reason: text.slice(1) });
|
|
1920
|
+
drained = r?.drainedPendings ?? 0;
|
|
1921
|
+
} catch (err) {
|
|
1922
|
+
console.error(`[${label}] resetSession ${text}: ${err.message}`);
|
|
1923
|
+
}
|
|
1924
|
+
} else {
|
|
1925
|
+
// CLI pm fallback: kill the session; sessions table cleared
|
|
1926
|
+
// via clearSessionId in pm's proc.on('close') resume-fail
|
|
1927
|
+
// path (lib/process-manager.js:457). We force the path by
|
|
1928
|
+
// setting the kill rationale so the close handler treats it
|
|
1929
|
+
// as a successful reset.
|
|
1930
|
+
try { await pm.kill(sessionKey); }
|
|
1931
|
+
catch (err) { console.error(`[${label}] kill on ${text}: ${err.message}`); }
|
|
1932
|
+
try { db.clearSessionId(sessionKey); } catch { /* swallow */ }
|
|
1933
|
+
}
|
|
1934
|
+
logEvent('session-reset-command', {
|
|
1935
|
+
chat_id: chatId, command: text, drained_pendings: drained,
|
|
1936
|
+
user: cmdUser, user_id: cmdUserId,
|
|
1937
|
+
});
|
|
1938
|
+
await sendReply('✨ Started a fresh session.');
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
// 0.8.0 Phase 2 step 1: /steer <text> — mid-turn steering. Pushes
|
|
1942
|
+
// a priority:'now' user message onto the active Query so Claude
|
|
1943
|
+
// sees it without waiting for the in-flight turn to fully
|
|
1944
|
+
// complete. SDK pm only — CLI pm has no steer primitive (its
|
|
1945
|
+
// stream-json transport is request-response, not interruptible
|
|
1946
|
+
// mid-turn). Falls back to /new under CLI pm.
|
|
1947
|
+
if (botAllowsCommands && text.startsWith('/steer ')) {
|
|
1948
|
+
const steerText = text.slice(7).trim();
|
|
1949
|
+
if (!steerText) { await sendReply('Usage: /steer <text>'); return; }
|
|
1950
|
+
if (!USE_SDK || typeof pm.steer !== 'function') {
|
|
1951
|
+
await sendReply('🛞 /steer requires the SDK pm (set POLYGRAM_USE_SDK=1 to enable).');
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
if (!pm.has(sessionKey)) {
|
|
1955
|
+
await sendReply('🛞 No active session — /steer only works mid-turn. Send a message first, then /steer while it\'s thinking.');
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
const ok = pm.steer(sessionKey, steerText);
|
|
1959
|
+
if (ok) {
|
|
1960
|
+
logEvent('steer-command', {
|
|
1961
|
+
chat_id: chatId, text_len: steerText.length,
|
|
1962
|
+
user: cmdUser, user_id: cmdUserId,
|
|
1963
|
+
});
|
|
1964
|
+
// Quiet ack so user knows it landed; the actual response will
|
|
1965
|
+
// arrive as the in-flight turn's continuation.
|
|
1966
|
+
await sendReply('🛞 Steering applied. Watching for the response.');
|
|
1967
|
+
} else {
|
|
1968
|
+
await sendReply('🛞 Couldn\'t apply steer — session may have just closed.');
|
|
1969
|
+
}
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1502
1972
|
// Graceful respawn of the user's CURRENT session only. With
|
|
1503
1973
|
// isolateTopics=false the sessionKey is just the chat (one shared
|
|
1504
1974
|
// session for the whole chat — every topic respawns implicitly).
|
|
@@ -1884,6 +2354,52 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1884
2354
|
chat_id: chatId, msg_id: msg.message_id, status: 'replied',
|
|
1885
2355
|
}), 'set handler_status=replied');
|
|
1886
2356
|
|
|
2357
|
+
// 0.8.0 Phase 2 step 1 — AUTOSTEER. If SDK pm is active AND there's
|
|
2358
|
+
// already an in-flight turn for this session AND autosteer isn't
|
|
2359
|
+
// disabled in config, route this user message via pm.steer()
|
|
2360
|
+
// instead of pm.send(). Matches OpenClaw's default UX: typing a
|
|
2361
|
+
// follow-up while the bot is mid-reply MERGES into the active
|
|
2362
|
+
// turn rather than queueing as a separate response. Saves a turn,
|
|
2363
|
+
// saves tokens, feels more conversational.
|
|
2364
|
+
//
|
|
2365
|
+
// Opt-out: config.bot.autosteer === false (or per-chat
|
|
2366
|
+
// chatConfig.autosteer === false). CLI pm always falls through
|
|
2367
|
+
// to the queue-FIFO path (no steer primitive on stream-json).
|
|
2368
|
+
//
|
|
2369
|
+
// The steered message gets a 🛞 reaction so the user knows it
|
|
2370
|
+
// landed; no separate reply is generated (the in-flight turn's
|
|
2371
|
+
// response covers both messages, OpenClaw-style).
|
|
2372
|
+
const chatAutosteer = chatConfig.autosteer != null
|
|
2373
|
+
? chatConfig.autosteer
|
|
2374
|
+
: config.bot?.autosteer;
|
|
2375
|
+
const autosteerEnabled = USE_SDK && chatAutosteer !== false;
|
|
2376
|
+
if (autosteerEnabled && typeof pm.steer === 'function' && pm.has(sessionKey)) {
|
|
2377
|
+
const entry = pm.get(sessionKey);
|
|
2378
|
+
if (entry?.inFlight) {
|
|
2379
|
+
const ok = pm.steer(sessionKey, prompt);
|
|
2380
|
+
if (ok) {
|
|
2381
|
+
// Quiet ack — no chat-bubble reply, just a reaction so the
|
|
2382
|
+
// user sees their message was incorporated. The in-flight
|
|
2383
|
+
// turn's response will address both questions.
|
|
2384
|
+
tg(bot, 'setMessageReaction', {
|
|
2385
|
+
chat_id: chatId,
|
|
2386
|
+
message_id: msg.message_id,
|
|
2387
|
+
reaction: [{ type: 'emoji', emoji: '🛞' }],
|
|
2388
|
+
}, { source: 'autosteer-ack', botName: BOT_NAME }).catch((err) => {
|
|
2389
|
+
console.error(`[${label}] autosteer reaction: ${err.message}`);
|
|
2390
|
+
});
|
|
2391
|
+
logEvent('autosteer', {
|
|
2392
|
+
chat_id: chatId, msg_id: msg.message_id,
|
|
2393
|
+
text_len: prompt?.length ?? 0,
|
|
2394
|
+
});
|
|
2395
|
+
stopTyping();
|
|
2396
|
+
reactor.stop();
|
|
2397
|
+
markReplied();
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
|
|
1887
2403
|
try {
|
|
1888
2404
|
// Pass streamer + reactor as per-turn context. pm's callbacks pick
|
|
1889
2405
|
// them off entry.pendingQueue[0].context so concurrent pendings each
|
|
@@ -1930,6 +2446,21 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1930
2446
|
if (result.error) {
|
|
1931
2447
|
console.error(`[${label}] Error (${elapsed}s):`, result.error);
|
|
1932
2448
|
reactor.setState('ERROR');
|
|
2449
|
+
// 0.8.0 Phase 2 step 8: classifier-driven auto-recovery. If
|
|
2450
|
+
// the error kind has autoRecover === 'reset_session' (i.e.
|
|
2451
|
+
// role_ordering / context_overflow / missing_tool_input),
|
|
2452
|
+
// tell pm to reset the session NOW so the user's NEXT
|
|
2453
|
+
// message starts fresh — without them having to type /new.
|
|
2454
|
+
// Only fires when pm.resetSession is available (SDK pm
|
|
2455
|
+
// path); CLI pm doesn't have the method.
|
|
2456
|
+
const cls = classifyError(result.error);
|
|
2457
|
+
if (cls.autoRecover === 'reset_session' && typeof pm.resetSession === 'function') {
|
|
2458
|
+
pm.resetSession(sessionKey, { reason: cls.kind })
|
|
2459
|
+
.catch((err) => console.error(`[${label}] auto-reset failed: ${err.message}`));
|
|
2460
|
+
logEvent('auto-recover', {
|
|
2461
|
+
chat_id: chatId, kind: cls.kind, action: 'reset_session',
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
1933
2464
|
// 0.6.16: pre-fix, silently markReplied()+return — the user got an
|
|
1934
2465
|
// error reaction emoji on their message but no actual reply text,
|
|
1935
2466
|
// AND 'replied' status meant boot replay didn't re-dispatch on next
|
|
@@ -1946,6 +2477,30 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1946
2477
|
// every answered message is chat noise (plus triggers reaction
|
|
1947
2478
|
// notifications for other group members).
|
|
1948
2479
|
reactor.clear().catch(() => {});
|
|
2480
|
+
|
|
2481
|
+
// 0.8.0 Phase 2 step 4: 85%-context-full live hint. After a
|
|
2482
|
+
// successful turn, peek at SDK's getContextUsage(); if past
|
|
2483
|
+
// 85%, post a quiet hint so the user knows /new will help.
|
|
2484
|
+
// SDK pm only — CLI pm has no equivalent (no Query object,
|
|
2485
|
+
// no getContextUsage). Per-bot opt-out via
|
|
2486
|
+
// config.bot.contextHint = false.
|
|
2487
|
+
if (USE_SDK && config.bot?.contextHint !== false) {
|
|
2488
|
+
const entry = pm.get(sessionKey);
|
|
2489
|
+
const q = entry?.query;
|
|
2490
|
+
if (q && typeof q.getContextUsage === 'function') {
|
|
2491
|
+
q.getContextUsage().then((usage) => {
|
|
2492
|
+
const pct = usage?.percentage ?? 0;
|
|
2493
|
+
if (pct < 0.85) return;
|
|
2494
|
+
return tg(bot, 'sendMessage', {
|
|
2495
|
+
chat_id: chatId,
|
|
2496
|
+
text: `📚 Context window ${(pct * 100).toFixed(0)}% full. Send /new to start fresh — older messages will start dropping soon.`,
|
|
2497
|
+
...(threadId ? { message_thread_id: threadId } : {}),
|
|
2498
|
+
}, { source: 'context-full-hint', botName: BOT_NAME });
|
|
2499
|
+
}).catch((err) => {
|
|
2500
|
+
console.error(`[${label}] context-hint failed: ${err.message}`);
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
1949
2504
|
}
|
|
1950
2505
|
|
|
1951
2506
|
// 0.7.0: empty-response fallback (port from OpenClaw —
|
|
@@ -2161,7 +2716,7 @@ function createBot(token) {
|
|
|
2161
2716
|
// Cached once @botUsername is known — was recompiling per inbound msg.
|
|
2162
2717
|
let mentionRe = null;
|
|
2163
2718
|
// Hoisted admin-command matcher; was re-allocated per message.
|
|
2164
|
-
const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair)(\s|$)/;
|
|
2719
|
+
const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context|steer)(\s|$)/;
|
|
2165
2720
|
const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
|
|
2166
2721
|
|
|
2167
2722
|
// The filter in main() guarantees config.chats only contains chats owned
|
|
@@ -2285,16 +2840,36 @@ function createBot(token) {
|
|
|
2285
2840
|
// skip the generic error-reply. If we marked after, there'd be a
|
|
2286
2841
|
// race where the error-reply slips through.
|
|
2287
2842
|
if (hadActive) markSessionAborted(sessionKey);
|
|
2288
|
-
//
|
|
2289
|
-
//
|
|
2290
|
-
//
|
|
2291
|
-
//
|
|
2292
|
-
//
|
|
2293
|
-
//
|
|
2294
|
-
//
|
|
2295
|
-
//
|
|
2296
|
-
|
|
2297
|
-
|
|
2843
|
+
// 0.8.0 Phase 2 step 2: under SDK pm, prefer interrupt() +
|
|
2844
|
+
// drainQueue() — keeps the Query alive (cheap to reuse for
|
|
2845
|
+
// the user's next message), no respawn cost. Falls back to
|
|
2846
|
+
// pm.kill() under CLI pm, which is the original behaviour.
|
|
2847
|
+
//
|
|
2848
|
+
// Why both: interrupt() cancels the in-flight turn at SDK
|
|
2849
|
+
// level WITHOUT tearing down the subprocess; drainQueue()
|
|
2850
|
+
// rejects every queued pending with err.code='INTERRUPTED'
|
|
2851
|
+
// so the abort-grace classifier suppresses error replies.
|
|
2852
|
+
//
|
|
2853
|
+
// Kill ONLY the user's own session, not every topic in the
|
|
2854
|
+
// chat. Pre-0.6.5 this was pm.killChat(chatId) which fanned
|
|
2855
|
+
// out across all topics under isolateTopics=true: the user
|
|
2856
|
+
// typed "stop" in topic A and the bot tore down topic B's
|
|
2857
|
+
// in-flight turn, surfacing a 💥 reply to topic B's user
|
|
2858
|
+
// (whose key was never marked aborted, so the abort grace
|
|
2859
|
+
// window didn't apply). With isolateTopics=false the
|
|
2860
|
+
// sessionKey is the chat itself, so killing one session is
|
|
2861
|
+
// the same as killing the chat — behavior unchanged for the
|
|
2862
|
+
// common case.
|
|
2863
|
+
if (USE_SDK && typeof pm.interrupt === 'function') {
|
|
2864
|
+
await pm.interrupt(sessionKey).catch((err) =>
|
|
2865
|
+
console.error(`[${BOT_NAME}] interrupt failed: ${err.message}`));
|
|
2866
|
+
if (typeof pm.drainQueue === 'function') {
|
|
2867
|
+
pm.drainQueue(sessionKey, 'INTERRUPTED');
|
|
2868
|
+
}
|
|
2869
|
+
} else {
|
|
2870
|
+
await pm.kill(sessionKey).catch((err) =>
|
|
2871
|
+
console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
|
|
2872
|
+
}
|
|
2298
2873
|
logEvent('abort-requested', {
|
|
2299
2874
|
chat_id: chatId, user_id: msg.from?.id || null,
|
|
2300
2875
|
had_active: hadActive,
|
|
@@ -2698,10 +3273,52 @@ async function main() {
|
|
|
2698
3273
|
process.exit(1);
|
|
2699
3274
|
}
|
|
2700
3275
|
|
|
3276
|
+
// 0.8.0 Phase 1 step 11: belt-and-suspenders unhandledRejection
|
|
3277
|
+
// logger. The new pm wraps every Query iteration in try/catch so
|
|
3278
|
+
// SDK throws never leak — but if a callback ever does throw async
|
|
3279
|
+
// (canUseTool body, onResult handler, etc.) the rejection could
|
|
3280
|
+
// escape to the global handler. Without this, Node's default is to
|
|
3281
|
+
// exit the process. With this, we log + persist and keep running
|
|
3282
|
+
// so other chats are unaffected.
|
|
3283
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
3284
|
+
const reasonStr = reason instanceof Error
|
|
3285
|
+
? `${reason.message}\n${(reason.stack || '').split('\n').slice(0, 3).join('\n')}`
|
|
3286
|
+
: String(reason);
|
|
3287
|
+
console.error(`[polygram] unhandledRejection: ${reasonStr.slice(0, 1000)}`);
|
|
3288
|
+
try {
|
|
3289
|
+
db.logEvent('unhandled-rejection', {
|
|
3290
|
+
reason: String(reason instanceof Error ? reason.message : reason).slice(0, 500),
|
|
3291
|
+
bot_name: BOT_NAME,
|
|
3292
|
+
});
|
|
3293
|
+
} catch { /* swallow — db might be closing */ }
|
|
3294
|
+
});
|
|
3295
|
+
// Same defensive posture for uncaughtException — Node's default is
|
|
3296
|
+
// exit on these. We want to log + persist + survive (the affected
|
|
3297
|
+
// chat's iteration loop will have rejected its pendings via the
|
|
3298
|
+
// catch in pm's _runIteration, so user-visible UX is "their turn
|
|
3299
|
+
// failed", not "bot died").
|
|
3300
|
+
process.on('uncaughtException', (err) => {
|
|
3301
|
+
console.error(`[polygram] uncaughtException: ${err?.message}\n${err?.stack?.split('\n').slice(0, 5).join('\n')}`);
|
|
3302
|
+
try {
|
|
3303
|
+
db.logEvent('uncaught-exception', {
|
|
3304
|
+
message: String(err?.message || err).slice(0, 500),
|
|
3305
|
+
bot_name: BOT_NAME,
|
|
3306
|
+
});
|
|
3307
|
+
} catch { /* swallow */ }
|
|
3308
|
+
});
|
|
3309
|
+
|
|
2701
3310
|
const cap = config.maxWarmProcesses || DEFAULT_MAX_WARM_PROCS;
|
|
2702
|
-
pm
|
|
3311
|
+
// 0.8.0 Phase 3: pick pm implementation via env flag. Default
|
|
3312
|
+
// (POLYGRAM_USE_SDK unset) keeps the CLI-based pm — same as 0.7.x.
|
|
3313
|
+
// Set POLYGRAM_USE_SDK=1 to switch to the SDK-backed pm.
|
|
3314
|
+
// Phase 5 soak: enable on umi-assistant first, watch for
|
|
3315
|
+
// regressions, then enable on shumabit.
|
|
3316
|
+
const PMClass = USE_SDK ? ProcessManagerSdk : ProcessManager;
|
|
3317
|
+
const spawnFn = USE_SDK ? buildSdkOptions : spawnClaude;
|
|
3318
|
+
console.log(`[polygram] using ${USE_SDK ? 'SDK' : 'CLI'} ProcessManager`);
|
|
3319
|
+
pm = new PMClass({
|
|
2703
3320
|
cap,
|
|
2704
|
-
spawnFn
|
|
3321
|
+
spawnFn,
|
|
2705
3322
|
db,
|
|
2706
3323
|
logger: console,
|
|
2707
3324
|
onInit: (sessionKey, event, entry) => {
|
|
@@ -2765,6 +3382,33 @@ async function main() {
|
|
|
2765
3382
|
const s = head?.context?.streamer;
|
|
2766
3383
|
if (s) s.forceNewMessage();
|
|
2767
3384
|
},
|
|
3385
|
+
// 0.8.0 Phase 2 step 5: SDK auto-compaction observability. Fires
|
|
3386
|
+
// when SDK emits SDKCompactBoundaryMessage (between turns or
|
|
3387
|
+
// mid-turn — see Phase 0 gate 8.5). Surfaces a quiet system
|
|
3388
|
+
// status note to the chat so the user knows context was
|
|
3389
|
+
// reorganised. Off by default per-bot (announceCompact !== true).
|
|
3390
|
+
// Only fires under SDK pm — the CLI pm has no equivalent event.
|
|
3391
|
+
onCompactBoundary: async (sessionKey, msg, entry) => {
|
|
3392
|
+
const chatCfg = config.chats[entry.chatId] || {};
|
|
3393
|
+
const optIn = chatCfg.announceCompact != null
|
|
3394
|
+
? chatCfg.announceCompact
|
|
3395
|
+
: config.bot?.announceCompact;
|
|
3396
|
+
if (optIn !== true) return;
|
|
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
|
+
: '';
|
|
3401
|
+
const threadId = entry.threadId || undefined;
|
|
3402
|
+
try {
|
|
3403
|
+
await tg(bot, 'sendMessage', {
|
|
3404
|
+
chat_id: entry.chatId,
|
|
3405
|
+
text: `🗜️ Memory compacted${summary} — earlier context summarised.`,
|
|
3406
|
+
...(threadId ? { message_thread_id: threadId } : {}),
|
|
3407
|
+
}, { source: 'compact-boundary', botName: BOT_NAME });
|
|
3408
|
+
} catch (err) {
|
|
3409
|
+
console.error(`[${entry.label}] compact-boundary post: ${err.message}`);
|
|
3410
|
+
}
|
|
3411
|
+
},
|
|
2768
3412
|
// Fires after a graceful /model or /effort drain has actually
|
|
2769
3413
|
// swapped to the new settings. Post a confirmation back to the
|
|
2770
3414
|
// chat ONLY when wasDrained=true — the user actively waited for an
|