polygram 0.8.0-rc.23 → 0.8.0-rc.25

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.8.0-rc.23",
4
+ "version": "0.8.0-rc.25",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
package/lib/approvals.js CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  const crypto = require('crypto');
14
+ const { canonicalizeToolInput } = require('./canonical-json');
14
15
 
15
16
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
16
17
  // 16 random bytes → 22 base64url chars ≈ 128 bits of entropy. Prevents
@@ -19,7 +20,14 @@ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
19
20
  const TOKEN_BYTES = 16;
20
21
 
21
22
  function digestInput(input) {
22
- const json = typeof input === 'string' ? input : JSON.stringify(input);
23
+ // Canonicalise object inputs so key-order doesn't change the digest.
24
+ // Pre-fix `JSON.stringify({a:1,b:2})` and `JSON.stringify({b:2,a:1})`
25
+ // produced different hashes — the dedup contract assumed logical
26
+ // equivalence but the impl was order-sensitive, so an SDK that
27
+ // re-serialised the input between turns would dedup-miss.
28
+ const json = typeof input === 'string'
29
+ ? input
30
+ : JSON.stringify(canonicalizeToolInput(input));
23
31
  return crypto.createHash('sha256').update(json).digest('hex').slice(0, 16);
24
32
  }
25
33
 
package/lib/async-lock.js CHANGED
@@ -22,13 +22,21 @@ function createAsyncLock() {
22
22
  const prev = chains.get(key) || Promise.resolve();
23
23
  let release;
24
24
  const next = new Promise((resolve) => { release = resolve; });
25
- chains.set(key, prev.then(() => next));
25
+ // Save the chain-entry promise so the cleanup branch can compare
26
+ // against the SAME reference. Pre-fix this re-evaluated
27
+ // `prev.then(() => next)` (a fresh promise each call), so the
28
+ // === compare was always false and the Map leaked one entry per
29
+ // unique key.
30
+ const myEntry = prev.then(() => next);
31
+ chains.set(key, myEntry);
26
32
  await prev;
27
33
  // Return a wrapper that also clears the chain entry when this is
28
34
  // the last holder — avoids the Map growing unbounded across the
29
- // lifetime of the process.
35
+ // lifetime of the process. Idempotent: a double-release call is
36
+ // harmless (release() is a Promise resolver; calling resolve
37
+ // twice is a no-op).
30
38
  return () => {
31
- if (chains.get(key) === prev.then(() => next)) {
39
+ if (chains.get(key) === myEntry) {
32
40
  chains.delete(key);
33
41
  }
34
42
  release();
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Per-session tracker for messages that received the ✍ AUTOSTEERED
3
+ * reaction, so they can be cleared at turn-end.
4
+ *
5
+ * Why this exists (rc.14): each autosteer invocation runs inside its
6
+ * own `handleMessage` scope with its own `reactor`. When the original
7
+ * (trigger) message's reactor calls `.clear()` at turn-end, it can
8
+ * only clear *its own* message — not the follow-ups whose reactors
9
+ * already called `.stop()` after acking ✍. So we track the
10
+ * (chat_id, message_id) pairs centrally per session and the success-
11
+ * path handler in polygram.js calls `clear(sessionKey)` to drop the
12
+ * reactions in one go.
13
+ *
14
+ * Concurrency: this is a plain Map indexed by sessionKey. Single-
15
+ * thread Node, so add/get/clear race-free.
16
+ *
17
+ * The `applyClear` callback abstracts Telegram's setMessageReaction
18
+ * so tests can inject a fake without spinning up grammy/bot.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ /**
24
+ * @typedef {object} MsgRef
25
+ * @property {number|string} chatId
26
+ * @property {number} msgId
27
+ */
28
+
29
+ /**
30
+ * @typedef {object} AutosteeredRefs
31
+ * @property {(sessionKey: string, ref: MsgRef) => void} add
32
+ * @property {(sessionKey: string) => MsgRef[]} get
33
+ * @property {(sessionKey: string) => Promise<number>} clear
34
+ * resolves with the count of refs that were cleared.
35
+ * @property {(sessionKey: string) => number} size
36
+ * @property {(sessionKey: string) => void} dropSession
37
+ * discard all refs for a session WITHOUT calling applyClear (used
38
+ * when the chat is being torn down — Telegram side will be cleared
39
+ * by the parent reactor).
40
+ */
41
+
42
+ /**
43
+ * @param {object} opts
44
+ * @param {(ref: MsgRef) => Promise<void>} opts.applyClear
45
+ * invoked once per ref during clear(). Errors are caught and
46
+ * logged to opts.logger?.error — they never block clearing of
47
+ * subsequent refs.
48
+ * @param {{ error?: (msg: string) => void }} [opts.logger]
49
+ * @returns {AutosteeredRefs}
50
+ */
51
+ function createAutosteeredRefs({ applyClear, logger = console } = {}) {
52
+ if (typeof applyClear !== 'function') {
53
+ throw new TypeError('applyClear function required');
54
+ }
55
+ /** @type {Map<string, MsgRef[]>} */
56
+ const refs = new Map();
57
+
58
+ function add(sessionKey, ref) {
59
+ if (!sessionKey || !ref || ref.msgId == null || ref.chatId == null) return;
60
+ let list = refs.get(sessionKey);
61
+ if (!list) { list = []; refs.set(sessionKey, list); }
62
+ list.push({ chatId: ref.chatId, msgId: ref.msgId });
63
+ }
64
+
65
+ function get(sessionKey) {
66
+ return refs.get(sessionKey)?.slice() || [];
67
+ }
68
+
69
+ function size(sessionKey) {
70
+ return refs.get(sessionKey)?.length || 0;
71
+ }
72
+
73
+ function dropSession(sessionKey) {
74
+ refs.delete(sessionKey);
75
+ }
76
+
77
+ async function clear(sessionKey) {
78
+ const list = refs.get(sessionKey);
79
+ if (!list || list.length === 0) return 0;
80
+ refs.delete(sessionKey);
81
+ let cleared = 0;
82
+ for (const ref of list) {
83
+ try {
84
+ await applyClear(ref);
85
+ cleared += 1;
86
+ } catch (err) {
87
+ // Ack-clear failures are silent — the ✍ stays on screen but
88
+ // doesn't block the in-flight turn's reply UX.
89
+ logger?.error?.(
90
+ `autosteer-clear failed (chat=${ref.chatId} msg=${ref.msgId}): ${err?.message || err}`,
91
+ );
92
+ }
93
+ }
94
+ return cleared;
95
+ }
96
+
97
+ return { add, get, clear, size, dropSession };
98
+ }
99
+
100
+ module.exports = { createAutosteeredRefs };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Pure formatters for /context command output and the 85%-full hint.
3
+ *
4
+ * Lifted from polygram.js so the formatting can be unit-tested without
5
+ * spinning up the full handleMessage stack. Both functions are pure —
6
+ * no I/O, no Date.now, no module-level state.
7
+ *
8
+ * Background — rc.4 percentage scale:
9
+ * The SDK's `getContextUsage()` returns `percentage` already on a
10
+ * 0-100 scale (verified in rc.3 production: a 77%-used context
11
+ * reported `percentage: 77`). Pre-rc.4 polygram treated it as a
12
+ * 0-1 ratio and multiplied by 100, which displayed "7700% full" and
13
+ * skipped the 85% hint threshold. The formatters below assume the
14
+ * 0-100 scale; do not multiply or divide.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const HINT_THRESHOLD_PCT = 85;
20
+
21
+ /**
22
+ * Format a getContextUsage() result into a multi-line chat reply.
23
+ *
24
+ * @param {object} usage — return value from `Query.getContextUsage()`.
25
+ * Expected fields (all optional, all from SDK):
26
+ * percentage: number (0-100)
27
+ * totalTokens: number
28
+ * maxTokens: number
29
+ * model: string
30
+ * isAutoCompactEnabled: boolean
31
+ * autoCompactThreshold: number (0-100)
32
+ * categories: Array<{ label?: string, name?: string, tokens: number }>
33
+ * @returns {string} pre-formatted text suitable for sendMessage
34
+ */
35
+ function formatContextReply(usage) {
36
+ const u = usage || {};
37
+ const pct = (u.percentage ?? 0).toFixed(0);
38
+ const total = (u.totalTokens ?? 0).toLocaleString();
39
+ const max = (u.maxTokens ?? 0).toLocaleString();
40
+ const lines = [`📚 Context: ${total} / ${max} tokens (${pct}%)`];
41
+ if (u.model) lines.push(`Model: ${u.model}`);
42
+ if (u.isAutoCompactEnabled && u.autoCompactThreshold) {
43
+ const thrPct = u.autoCompactThreshold.toFixed(0);
44
+ lines.push(`Auto-compact at ${thrPct}%.`);
45
+ }
46
+ if (Array.isArray(u.categories) && u.categories.length) {
47
+ const top = [...u.categories]
48
+ .filter((c) => Number.isFinite(c?.tokens) && c.tokens > 0)
49
+ .sort((a, b) => b.tokens - a.tokens)
50
+ .slice(0, 3)
51
+ .map((c) => ` • ${c.label || c.name || '?'}: ${c.tokens.toLocaleString()}`);
52
+ if (top.length) lines.push('Top categories:', ...top);
53
+ }
54
+ return lines.join('\n');
55
+ }
56
+
57
+ /**
58
+ * Decide whether to send the 85% hint and return the hint text if so.
59
+ *
60
+ * @param {object} usage — same shape as formatContextReply input.
61
+ * @returns {string|null} the hint text to send, or null when below threshold.
62
+ */
63
+ function maybeContextFullHint(usage) {
64
+ const pct = usage?.percentage ?? 0;
65
+ if (pct < HINT_THRESHOLD_PCT) return null;
66
+ return [
67
+ `📚 Context window ${pct.toFixed(0)}% full. Three options:`,
68
+ '',
69
+ '• `/new` — start fresh; this conversation ends.',
70
+ '• `/compact` — summarise older messages. Add a hint after the command (e.g. `/compact keep the Q3 commission decisions`) and that becomes the compactor\'s guidance.',
71
+ '• Keep chatting — I\'ll auto-compact when needed; key context is preserved automatically.',
72
+ ].join('\n');
73
+ }
74
+
75
+ module.exports = {
76
+ formatContextReply,
77
+ maybeContextFullHint,
78
+ HINT_THRESHOLD_PCT,
79
+ };
@@ -57,12 +57,19 @@ const DEFAULT_THROTTLE_MS = 800;
57
57
  // 0.7.4 (item A): after this long with no setState() call (Claude is
58
58
  // silently chugging on a long tool / model latency), auto-flip to STALL
59
59
  // (🥱) so the user has a visible cue that the bot is alive but slow.
60
- // 10s matches OpenClaw's "yawn after 10s of nothing".
61
- const DEFAULT_STALL_MS = 10_000;
62
- // 30s without a heartbeat is "we're worried" territory promote to
63
- // TIMEOUT (😨) so the user knows it might be stuck. Distinct from the
64
- // pm's 5-minute hard idle timeout, which actually rejects the turn.
65
- const DEFAULT_FREEZE_MS = 30_000;
60
+ // rc.25: bumped from 10s 45s. The original 10s matched OpenClaw, but
61
+ // SDK pm with effort=high reasoning routinely thinks for 15-30s before
62
+ // firing any tool or text chunk under the old threshold the 🥱 was
63
+ // firing on EVERY substantive turn, training users to ignore it.
64
+ const DEFAULT_STALL_MS = 45_000;
65
+ // rc.25: bumped from 30s → 180s (3 min). The 😨 TIMEOUT was firing
66
+ // during ordinary multi-step agent runs (Ivan DM at 11:32 — bot was
67
+ // actively replying within 20s, but the trigger message stayed at
68
+ // 😨 because the OUTER turn ran for 100+ s across multiple replies
69
+ // and tool calls). Real "stuck" state would be 3+ min of nothing,
70
+ // which 180s captures while letting routine work breathe. Pm has its
71
+ // own 5-minute hard idle timeout that actually rejects stuck turns.
72
+ const DEFAULT_FREEZE_MS = 180_000;
66
73
 
67
74
  // Tool name → state classifier. Case-insensitive substring match so we
68
75
  // don't have to enumerate every existing or future tool. Order matters:
@@ -224,19 +231,21 @@ function createReactionManager({
224
231
  // (no point arming over QUEUED/STALL/TIMEOUT itself).
225
232
  armStallTimers();
226
233
 
227
- const elapsed = Date.now() - lastFlushTs;
228
- if (elapsed >= throttleMs) {
229
- if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
230
- return flush(stateName);
231
- }
232
- // Inside throttle window: schedule for the soonest safe flush.
233
- if (!pendingTimer) {
234
- pendingTimer = setTimeout(() => {
235
- pendingTimer = null;
236
- flush(currentState);
237
- }, throttleMs - elapsed);
238
- pendingTimer.unref?.();
239
- }
234
+ // 0.8.0-rc.24: drop the 800ms throttle. Pre-rc.24, when a tool-
235
+ // using turn fired QUEUED → THINKING → TOOL within a few ms,
236
+ // the throttle squashed THINKING (pendingTimer flushed
237
+ // currentState which was already overwritten to TOOL by the
238
+ // time the timer fired). Users saw 👀 → ❰long pause❱ → 🔥 →
239
+ // 🥱, missing the 🤔 transition entirely.
240
+ //
241
+ // Why the throttle is now redundant: rc.11 added applyChain
242
+ // which serializes every apply() call to Telegram in
243
+ // setState() invocation order. So three rapid setStates in
244
+ // 30ms produce three sequential network calls, each ~200-300ms
245
+ // round-trip. User sees 👀 → 🤔 → 🔥 progress, smoothly
246
+ // paced by network latency.
247
+ if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
248
+ return flush(stateName);
240
249
  };
241
250
 
242
251
  const clear = async () => {
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Polygram-side display constraints injected into every chat's system
3
+ * prompt. This is INFRASTRUCTURE knowledge — the agent's business
4
+ * logic shouldn't have to know that Telegram's `<pre>` block on a
5
+ * portrait iPhone wraps at ~36 monospace chars. The agent decides
6
+ * *what* to render; polygram tells it *how* the surface displays.
7
+ *
8
+ * Why a polygram concern, not an agent concern:
9
+ * - Same agent runs across surfaces (Telegram bot, CLI, future
10
+ * surfaces). Each has its own width / markdown / image support.
11
+ * - Mixing display rules into agent prompts means every agent doc
12
+ * has to be updated when Telegram's rendering changes (or when
13
+ * we onboard a new chat surface). Centralising here keeps
14
+ * `_shumabit-base.md` and friends focused on business logic.
15
+ * - Tested in isolation; no risk of agent drift breaking tables.
16
+ *
17
+ * Width budget — measured 2026-04-30 from production screenshots:
18
+ * - iPhone portrait, default Telegram font: ~36 monospace chars
19
+ * per line in a `<pre>` block before wrap.
20
+ * - iPhone landscape: ~70.
21
+ * - Desktop client (macOS, default): ~85+.
22
+ * Agents see the conservative number (40) so output stays clean on
23
+ * the smallest reasonable surface.
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const TELEGRAM_TABLE_WIDTH_BUDGET = 40;
29
+
30
+ const POLYGRAM_DISPLAY_HINT = [
31
+ '## Telegram display constraints',
32
+ '',
33
+ 'Your replies are sent to Telegram. The user reads them on phone or desktop.',
34
+ '',
35
+ '**Tables:** Telegram renders markdown tables as monospace `<pre>` blocks.',
36
+ `On mobile portrait, lines wrap after ~${TELEGRAM_TABLE_WIDTH_BUDGET} chars and look broken.`,
37
+ '',
38
+ '- Use a markdown table when **every** rendered row (including separators',
39
+ ` and padding) fits in ${TELEGRAM_TABLE_WIDTH_BUDGET} chars or fewer.`,
40
+ '- If any row would exceed that budget, **drop the table** and switch to',
41
+ ' vertical "row blocks": one entity per paragraph, **bold** headline,',
42
+ ' then `Field: value` per data point. Example:',
43
+ '',
44
+ ' ```',
45
+ ' **Mini dress Keen → Black dress mini**',
46
+ ' COGS: ฿546 → ฿1144 (2.1×)',
47
+ ' Margin: 84.8% → 77% ↓',
48
+ '',
49
+ ' **Tank top Sway → Top voluminous cotton**',
50
+ ' COGS: ฿360 → ฿947 (2.6×)',
51
+ ' Margin: 78.7% → 73% ↓',
52
+ ' ```',
53
+ '',
54
+ '- Decide row-by-row before emitting; do not start a wide table assuming',
55
+ ' the user can scroll.',
56
+ '',
57
+ 'Other Telegram quirks:',
58
+ '- Headers `#`, `##`, `###` render as plain text — use **bold** for emphasis.',
59
+ '- Horizontal rules render as a thin divider line.',
60
+ '- Long replies stream in chunks, so prefer concise structure over walls of text.',
61
+ ].join('\n');
62
+
63
+ /**
64
+ * Append the polygram display hint to an existing systemPrompt option,
65
+ * preserving the original shape (string / preset object / undefined).
66
+ * Pure function — does not mutate input.
67
+ *
68
+ * Shapes handled (matches @anthropic-ai/claude-agent-sdk's Options.systemPrompt):
69
+ * - undefined / null → returns `{ type: 'preset', preset: 'claude_code', append: hint }`
70
+ * - string → returns `string + '\n\n' + hint`
71
+ * - { type: 'preset', append?: string }
72
+ * → merges hint into `append`
73
+ * - other (string[], etc.) → returns input unchanged (caller's responsibility)
74
+ *
75
+ * @param {*} systemPromptOpt — current SdkOptions.systemPrompt value
76
+ * @param {string} [hint] — override the default hint (used by tests)
77
+ * @returns {*} new systemPrompt option with the hint appended
78
+ */
79
+ function appendDisplayHint(systemPromptOpt, hint = POLYGRAM_DISPLAY_HINT) {
80
+ if (!hint) return systemPromptOpt;
81
+
82
+ if (systemPromptOpt == null) {
83
+ return { type: 'preset', preset: 'claude_code', append: hint };
84
+ }
85
+
86
+ if (typeof systemPromptOpt === 'string') {
87
+ return `${systemPromptOpt}\n\n${hint}`;
88
+ }
89
+
90
+ if (typeof systemPromptOpt === 'object' && systemPromptOpt.type === 'preset') {
91
+ const existingAppend = typeof systemPromptOpt.append === 'string' ? systemPromptOpt.append : '';
92
+ const newAppend = existingAppend ? `${existingAppend}\n\n${hint}` : hint;
93
+ return { ...systemPromptOpt, append: newAppend };
94
+ }
95
+
96
+ // Unknown shape (e.g. string[]) — return as-is. Caller can opt in
97
+ // by passing a supported shape.
98
+ return systemPromptOpt;
99
+ }
100
+
101
+ /**
102
+ * For the CLI pm (`claude -p ...`), the equivalent of an appended
103
+ * system prompt is the `--append-system-prompt <text>` flag. This
104
+ * helper returns the args the CLI pm should add to its argv.
105
+ *
106
+ * @param {string} [hint] — override (tests)
107
+ * @returns {string[]} — argv tail, e.g. ['--append-system-prompt', '...']
108
+ */
109
+ function appendDisplayHintCliArgs(hint = POLYGRAM_DISPLAY_HINT) {
110
+ if (!hint) return [];
111
+ return ['--append-system-prompt', hint];
112
+ }
113
+
114
+ module.exports = {
115
+ POLYGRAM_DISPLAY_HINT,
116
+ TELEGRAM_TABLE_WIDTH_BUDGET,
117
+ appendDisplayHint,
118
+ appendDisplayHintCliArgs,
119
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.23",
3
+ "version": "0.8.0-rc.25",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc-client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -32,6 +32,7 @@ const { ProcessManager } = require('./lib/process-manager');
32
32
  // soak proves SDK stable. See docs/0.8.0-architecture-decisions.md.
33
33
  const { ProcessManagerSdk } = require('./lib/process-manager-sdk');
34
34
  const { createAutosteerBuffer, makePostToolBatchHook } = require('./lib/autosteer-buffer');
35
+ const { createAutosteeredRefs } = require('./lib/autosteered-refs');
35
36
  const { makeRouterPolicy, createPmRouter } = require('./lib/pm-router');
36
37
  const { canonicalizeToolInput } = require('./lib/canonical-json');
37
38
  const {
@@ -41,6 +42,8 @@ const {
41
42
  approvalCardText,
42
43
  } = require('./lib/approval-ui');
43
44
  const { makeSessionStartHook } = require('./lib/history-preload');
45
+ const { formatContextReply, maybeContextFullHint } = require('./lib/context-format');
46
+ const { appendDisplayHint, appendDisplayHintCliArgs } = require('./lib/telegram-prompt');
44
47
  const agentLoader = require('./lib/agent-loader');
45
48
  const USE_SDK = process.env.POLYGRAM_USE_SDK === '1';
46
49
  const { createSender } = require('./lib/telegram');
@@ -723,23 +726,18 @@ const autosteerBuffer = createAutosteerBuffer();
723
726
  // the TRIGGER message's reactor.clear() at turn-end couldn't reach
724
727
  // across to other messages. Without this map, users see ✍ stuck on
725
728
  // every follow-up and don't know whether the bot incorporated them.
726
- const autosteeredMsgRefs = new Map(); // sessionKey → [{chatId, msgId}]
729
+ const autosteeredRefs = createAutosteeredRefs({
730
+ applyClear: async ({ chatId, msgId }) => {
731
+ if (!bot) return;
732
+ await tg(bot, 'setMessageReaction', {
733
+ chat_id: chatId, message_id: msgId, reaction: [],
734
+ }, { source: 'autosteer-clear', botName: BOT_NAME });
735
+ },
736
+ logger: { error: (m) => console.error(`[${BOT_NAME}] ${m}`) },
737
+ });
727
738
 
728
739
  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
- }
740
+ return autosteeredRefs.clear(sessionKey);
743
741
  }
744
742
 
745
743
  // 0.8.0-rc.14: tool-less-turn drain. PostToolBatch hook only fires
@@ -820,6 +818,10 @@ function spawnClaude(sessionKey, ctx) {
820
818
  ];
821
819
  if (chatConfig.agent) args.push('--agent', chatConfig.agent);
822
820
  if (existingSessionId) args.push('--resume', existingSessionId);
821
+ // Polygram-side display constraints — same hint the SDK pm appends
822
+ // via Options.systemPrompt. Keeps the table-width rule in
823
+ // infrastructure, not in agent docs.
824
+ args.push(...appendDisplayHintCliArgs());
823
825
 
824
826
  console.log(`[${label}] Spawning process (${chatConfig.model}/${chatConfig.effort})`);
825
827
 
@@ -973,7 +975,7 @@ function buildSdkOptions(sessionKey, ctx) {
973
975
  // precedence: chatConfig > agent > defaults. The chatConfig keys
974
976
  // we care about for SDK options are model/effort/cwd/thinking;
975
977
  // others (agent, chrome, isolateTopics) are polygram-only.
976
- return agentLoader.composeSdkOptions(
978
+ const composed = agentLoader.composeSdkOptions(
977
979
  {
978
980
  // chat-level overrides — only the keys SDK understands.
979
981
  model: chatConfig.model,
@@ -984,6 +986,13 @@ function buildSdkOptions(sessionKey, ctx) {
984
986
  agentBundle,
985
987
  baseOpts,
986
988
  );
989
+
990
+ // Append polygram's display constraints to the systemPrompt.
991
+ // Infrastructure-layer hint — the agent's own prompt covers
992
+ // business logic; polygram adds "your output renders in Telegram,
993
+ // here's the width budget for tables".
994
+ composed.systemPrompt = appendDisplayHint(composed.systemPrompt);
995
+ return composed;
987
996
  }
988
997
 
989
998
  function buildSpawnContext(sessionKey) {
@@ -1955,32 +1964,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1955
1964
  }
1956
1965
  try {
1957
1966
  const u = await q.getContextUsage();
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);
1961
- const total = (u?.totalTokens ?? 0).toLocaleString();
1962
- const max = (u?.maxTokens ?? 0).toLocaleString();
1963
- const lines = [`📚 Context: ${total} / ${max} tokens (${pct}%)`];
1964
- if (u?.model) lines.push(`Model: ${u.model}`);
1965
- if (u?.isAutoCompactEnabled && u?.autoCompactThreshold) {
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);
1970
- lines.push(`Auto-compact at ${thrPct}%.`);
1971
- }
1972
- // Top-3 categories by token cost so the user knows where the
1973
- // budget is going. SDK exposes a rich breakdown in
1974
- // u.categories — we just summarise.
1975
- if (Array.isArray(u?.categories) && u.categories.length) {
1976
- const top = [...u.categories]
1977
- .filter((c) => Number.isFinite(c?.tokens) && c.tokens > 0)
1978
- .sort((a, b) => b.tokens - a.tokens)
1979
- .slice(0, 3)
1980
- .map((c) => ` • ${c.label || c.name || '?'}: ${c.tokens.toLocaleString()}`);
1981
- if (top.length) lines.push('Top categories:', ...top);
1982
- }
1983
- await sendReply(lines.join('\n'));
1967
+ await sendReply(formatContextReply(u));
1984
1968
  } catch (err) {
1985
1969
  console.error(`[${label}] /context failed: ${err.message}`);
1986
1970
  await sendReply(`📚 Couldn't fetch context info: ${err.message}`);
@@ -2509,9 +2493,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2509
2493
  if (ok) {
2510
2494
  // Track this msg_id so the in-flight turn's success / abort
2511
2495
  // / error path can clear the ✍ reaction at turn-end.
2512
- const refs = autosteeredMsgRefs.get(sessionKey) || [];
2513
- refs.push({ chatId, msgId: msg.message_id });
2514
- autosteeredMsgRefs.set(sessionKey, refs);
2496
+ autosteeredRefs.add(sessionKey, { chatId, msgId: msg.message_id });
2515
2497
  logEvent('autosteer', {
2516
2498
  chat_id: chatId, msg_id: msg.message_id,
2517
2499
  text_len: prompt?.length ?? 0,
@@ -2642,25 +2624,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2642
2624
  const q = entry?.query;
2643
2625
  if (q && typeof q.getContextUsage === 'function') {
2644
2626
  q.getContextUsage().then((usage) => {
2645
- // SDK returns percentage in 0-100 scale, not 0-1.
2646
- // Pre-rc.4 we treated it as a 0-1 ratio and multiplied
2647
- // by 100, which displayed "7700% full" for a 77%-used
2648
- // context (and fired below the intended 85% threshold).
2649
- const pct = usage?.percentage ?? 0;
2650
- if (pct < 85) return;
2651
- // rc.22: three-choice hint. The original "send /new"
2652
- // message implied the only path forward was a hard
2653
- // reset. Now offer all three options the user actually
2654
- // has — start fresh, compact with their preserve
2655
- // instructions, or keep going (auto-compact eventually
2656
- // fires).
2657
- const text = [
2658
- `📚 Context window ${pct.toFixed(0)}% full. Three options:`,
2659
- '',
2660
- '• `/new` — start fresh; this conversation ends.',
2661
- '• `/compact` — summarise older messages. Add a hint after the command (e.g. `/compact keep the Q3 commission decisions`) and that becomes the compactor\'s guidance.',
2662
- '• Keep chatting — I\'ll auto-compact when needed; key context is preserved automatically.',
2663
- ].join('\n');
2627
+ const text = maybeContextFullHint(usage);
2628
+ if (!text) return;
2664
2629
  return tg(bot, 'sendMessage', {
2665
2630
  chat_id: chatId,
2666
2631
  text,
@@ -3624,6 +3589,13 @@ async function main() {
3624
3589
  const head = entry.pendingQueue?.[0];
3625
3590
  const s = head?.context?.streamer;
3626
3591
  if (s) s.forceNewMessage();
3592
+ // rc.25: heartbeat at every assistant-message boundary too. A
3593
+ // long thinking phase (effort=high, 30+ s before first chunk)
3594
+ // doesn't fire onStreamChunk. Without this, the freeze timer
3595
+ // could expire while the model is "still thinking but about
3596
+ // to speak".
3597
+ const r = head?.context?.reactor;
3598
+ if (r && typeof r.heartbeat === 'function') r.heartbeat();
3627
3599
  },
3628
3600
  // 0.8.0 Phase 2 step 5: SDK auto-compaction observability. Fires
3629
3601
  // when SDK emits SDKCompactBoundaryMessage (between turns or