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/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
- const m = String(data).match(/^(approve|deny):(\d+):(\S+)$/);
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
- const status = decision === 'approve' ? 'approved' : 'denied';
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
- resolveApprovalWaiter(id, status);
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
- // Kill ONLY the user's own session, not every topic in the chat.
2289
- // Pre-0.6.5 this was pm.killChat(chatId) which fanned out across
2290
- // all topics under isolateTopics=true: the user typed "stop" in
2291
- // topic A and the bot tore down topic B's in-flight turn, surfacing
2292
- // a 💥 reply to topic B's user (whose key was never marked aborted,
2293
- // so the abort grace window didn't apply). With isolateTopics=false
2294
- // the sessionKey is the chat itself, so killing one session is the
2295
- // same as killing the chat — behavior unchanged for the common case.
2296
- await pm.kill(sessionKey).catch((err) =>
2297
- console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
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 = new ProcessManager({
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: spawnClaude,
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