polygram 0.12.0-rc.12 → 0.12.0-rc.14

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.
@@ -72,7 +72,9 @@
72
72
  "effort": "medium",
73
73
  "cwd": "/Users/you/admin-agent",
74
74
  "timeout": 600,
75
- "_comment_maxFileBytes": "OPTIONAL per-chat (or per-topic; topic wins) file-size cap in BYTES. There is NO fixed default — the default is backend-derived: cloud Telegram = 50MB send / 20MB receive; with a local Bot API server (bot.apiRoot set) = 2GB both ways. This key only LOWERS that ceiling for this chat (Telegram rejects anything above the backend limit regardless); omit it to use the full backend default. To set one, add e.g. \"maxFileBytes\": 104857600 (=100MB) — only meaningful when apiRoot is set, since cloud already clamps to 50/20MB."
75
+ "_comment_maxFileBytes": "OPTIONAL per-chat (or per-topic; topic wins) file-size cap in BYTES. There is NO fixed default — the default is backend-derived: cloud Telegram = 50MB send / 20MB receive; with a local Bot API server (bot.apiRoot set) = 2GB both ways. This key only LOWERS that ceiling for this chat (Telegram rejects anything above the backend limit regardless); omit it to use the full backend default. To set one, add e.g. \"maxFileBytes\": 104857600 (=100MB) — only meaningful when apiRoot is set, since cloud already clamps to 50/20MB.",
76
+ "_comment_compactionWarnings": "OPTIONAL per-chat (or per-topic; topic wins). CLI/channels backend (pm:'cli') only. Default OFF. When ON, polygram warns the chat as Claude's context fills so you can /compact on your terms BEFORE an auto-compaction interrupts a turn (auto-compaction detaches the channels MCP bridge mid-turn — see docs/0.12.0-compaction-warnings.md). Two forms: `true` (enable at the 75% default threshold) or `{ \"enabled\": true, \"thresholdPct\": 80 }` (custom 1-99 threshold). Proactive: at the threshold it posts 'context ~N% full, run /compact or /new at a break'. Reactive backstop: when claude auto-compacts anyway it posts 'compacting now, resend if quiet'. Manual /compact never warns. Requires the bot to allow /compact + /new commands.",
77
+ "compactionWarnings": true
76
78
  },
77
79
 
78
80
  "-1000000000001": {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * compaction-warn — per-chat config resolution + warn-once state for the
3
+ * compaction warning (0.12.0-rc.13).
4
+ *
5
+ * The warning is OFF by default. A chat (or topic) opts in via
6
+ * `compactionWarnings`:
7
+ * true → enabled, default threshold
8
+ * { enabled: true, thresholdPct: 80 } → enabled, custom threshold
9
+ * false / absent / object w/o enabled → off
10
+ *
11
+ * `thresholdPct` is the context-fill % at which the PROACTIVE warning fires
12
+ * (propose /compact before claude auto-compacts mid-turn). Default 75 — below
13
+ * claude's own auto-compact threshold so the user gets a window to act.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const DEFAULT_THRESHOLD_PCT = 75;
19
+
20
+ /**
21
+ * @param {object|undefined} cfg resolved topic/chat config (getTopicConfig result)
22
+ * @returns {{enabled: boolean, thresholdPct: number}}
23
+ */
24
+ function resolveCompactionWarnConfig(cfg) {
25
+ const raw = cfg?.compactionWarnings;
26
+ const off = { enabled: false, thresholdPct: DEFAULT_THRESHOLD_PCT };
27
+
28
+ if (raw === true) return { enabled: true, thresholdPct: DEFAULT_THRESHOLD_PCT };
29
+ if (raw && typeof raw === 'object' && raw.enabled === true) {
30
+ const t = Number(raw.thresholdPct);
31
+ const thresholdPct = (Number.isFinite(t) && t > 0 && t < 100) ? t : DEFAULT_THRESHOLD_PCT;
32
+ return { enabled: true, thresholdPct };
33
+ }
34
+ return off;
35
+ }
36
+
37
+ /**
38
+ * Per-session "have we already warned on this climb?" state. Warn ONCE per
39
+ * session until reset — without this the proactive warning would re-fire on
40
+ * every turn-end while the context stays high. Reset on a successful
41
+ * compaction (PostCompact → context dropped) or a fresh session so the next
42
+ * climb can warn again. Mirrors the autoResumeTracker shape.
43
+ */
44
+ function createCompactionWarnTracker() {
45
+ const warned = new Set();
46
+ return {
47
+ shouldWarn(sessionKey) { return !warned.has(sessionKey); },
48
+ markWarned(sessionKey) { warned.add(sessionKey); },
49
+ reset(sessionKey) { warned.delete(sessionKey); },
50
+ resetAll() { warned.clear(); },
51
+ _size() { return warned.size; },
52
+ };
53
+ }
54
+
55
+ module.exports = {
56
+ resolveCompactionWarnConfig,
57
+ createCompactionWarnTracker,
58
+ DEFAULT_THRESHOLD_PCT,
59
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * context-usage — read live context occupancy from a Claude Code session
3
+ * transcript (JSONL).
4
+ *
5
+ * Used by the per-chat compaction warning (0.12.0-rc.13). polygram has no
6
+ * usage payload on the channels/CLI backend (hook events carry none — see
7
+ * the rc.13 spike), so the only source of "how full is the context" is the
8
+ * transcript itself. We read it ONCE per turn-end (Stop hook), not on a
9
+ * poll loop, so a single streamed pass is fine.
10
+ *
11
+ * What "occupancy" means: Claude's own context-% / auto-compact threshold is
12
+ * measured against what's fed INTO the model each turn —
13
+ * input_tokens + cache_read_input_tokens + cache_creation_input_tokens
14
+ * (cache_read dominates once the conversation is warm). output_tokens is the
15
+ * reply, not context, so it's excluded.
16
+ *
17
+ * We take the LAST main-thread (non-sidechain) assistant frame with a usage
18
+ * block. Subagents write to their own agent_transcript_path so sidechain
19
+ * frames don't normally appear here, but we skip them defensively: a format
20
+ * change that inlined a subagent's large usage would otherwise spike the
21
+ * parent's apparent context and trigger a false "you're full" warning.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('node:fs');
27
+ const readline = require('node:readline');
28
+
29
+ // Standard Claude context window (sonnet/opus, non-beta). The warning is a
30
+ // heuristic ("you're getting full"), so an approximate denominator is fine;
31
+ // callers can pass a different window for 1M-beta sessions.
32
+ const DEFAULT_WINDOW_TOKENS = 200_000;
33
+
34
+ /**
35
+ * @param {string} transcriptPath
36
+ * @returns {Promise<{inputTokens:number, cacheReadTokens:number, cacheCreationTokens:number, total:number} | null>}
37
+ * null when the path is falsy/unreadable or no usable usage frame exists.
38
+ */
39
+ async function readContextTokens(transcriptPath) {
40
+ if (!transcriptPath) return null;
41
+
42
+ let stream;
43
+ try {
44
+ stream = fs.createReadStream(transcriptPath, { encoding: 'utf8' });
45
+ } catch {
46
+ return null;
47
+ }
48
+
49
+ return new Promise((resolve) => {
50
+ let last = null;
51
+ // Resolve only once — error and close can both fire.
52
+ let done = false;
53
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
54
+
55
+ stream.on('error', () => finish(null));
56
+
57
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
58
+ // readline forwards the input stream's 'error' (e.g. ENOENT on open) to
59
+ // the interface; without this handler that re-emit is unhandled and
60
+ // crashes the process even though we resolved null on the stream error.
61
+ rl.on('error', () => finish(null));
62
+ rl.on('line', (line) => {
63
+ if (!line) return;
64
+ let o;
65
+ try { o = JSON.parse(line); } catch { return; } // skip partial/non-JSON lines
66
+ if (!o || o.type !== 'assistant' || o.isSidechain === true) return;
67
+ const u = o.message?.usage;
68
+ if (!u) return;
69
+ const inputTokens = Number(u.input_tokens) || 0;
70
+ const cacheReadTokens = Number(u.cache_read_input_tokens) || 0;
71
+ const cacheCreationTokens = Number(u.cache_creation_input_tokens) || 0;
72
+ const total = inputTokens + cacheReadTokens + cacheCreationTokens;
73
+ if (total > 0) last = { inputTokens, cacheReadTokens, cacheCreationTokens, total };
74
+ });
75
+ rl.on('close', () => finish(last));
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Fraction (0..1) of the context window currently occupied. Clamps to 0 on
81
+ * non-positive / non-finite inputs so callers never see NaN/Infinity.
82
+ *
83
+ * @param {number} totalTokens
84
+ * @param {number} [windowTokens=DEFAULT_WINDOW_TOKENS]
85
+ * @returns {number}
86
+ */
87
+ function contextPct(totalTokens, windowTokens = DEFAULT_WINDOW_TOKENS) {
88
+ if (!Number.isFinite(totalTokens) || totalTokens <= 0) return 0;
89
+ if (!Number.isFinite(windowTokens) || windowTokens <= 0) return 0;
90
+ return totalTokens / windowTokens;
91
+ }
92
+
93
+ module.exports = { readContextTokens, contextPct, DEFAULT_WINDOW_TOKENS };
@@ -53,6 +53,8 @@ const { createHookTail } = require('./hook-event-tail');
53
53
  // /private/tmp drift — one of the original Music-topic failures).
54
54
  const { DEFAULT_ATTACHMENT_BASE } = require('./channels-tool-dispatcher');
55
55
  const { resolveFileCaps } = require('../attachments');
56
+ const { resolveCompactionWarnConfig } = require('../compaction-warn');
57
+ const { readContextTokens, contextPct } = require('../context-usage');
56
58
  const { runStartupGate } = require('../tmux/startup-gate');
57
59
  const { POLYGRAM_DISPLAY_HINT } = require('../telegram/display-hint');
58
60
 
@@ -118,16 +120,17 @@ const STREAMING_HINT_RE = /esc to interrupt/i;
118
120
  // — false positives surface as no-op telemetry, false negatives surface
119
121
  // as the idle-ceiling timeout (~10min).
120
122
  const UNKNOWN_PROMPT_HEURISTIC_RE = /(\?\s*$|\(y\/N\)|Yes\/No|❯\s|^\s*[12345]\.\s)/im;
121
- // Dead-bridge signal. Claude Code prints "<source> no MCP server configured
122
- // with that name" when a channel source references an MCP server that isn't
123
- // registered. Music topic incident (2026-06-01): seen mid-turn after an
124
- // auto-/compact of a large resumed session redrew the TUI and dropped the
125
- // `server:polygram-bridge` binding the turn could no longer deliver its
126
- // reply and hung. The bridge name precedes the error on the same line, so
127
- // anchor on it: matching the bare phrase risks a false hit on prose that
128
- // merely quotes the error, while the healthy "polygram-bridge: <polygram-info>
129
- // …" connection line never contains "no MCP server configured".
130
- const BRIDGE_DEAD_RE = /polygram-bridge[^\n]*no MCP server configured/i;
123
+ // rc.14: a previous rc (rc.11) had a BRIDGE_DEAD_RE here that matched the pane
124
+ // line "server:polygram-bridge no MCP server configured with that name" and
125
+ // treated it as a dead bridge to recover from. That was a MISDIAGNOSIS: this
126
+ // line is a BENIGN, persistent banner that `--dangerously-load-development-
127
+ // channels` + `--strict-mcp-config` prints on EVERY healthy session the
128
+ // channel still delivers messages and the reply tool still works (reproduced
129
+ // 2026-06-01 with a test MCP server that demonstrably functions). The pane
130
+ // matcher therefore false-fired ~5s into every channels turn and KILLED
131
+ // healthy sessions (the Music-topic "mid-turn detach" regression). Real bridge
132
+ // loss is caught by the socket-close path (bridgeServer 'bridge-disconnected'
133
+ // → _handleBridgeDisconnected). There is no reliable pane signal — removed.
131
134
  // Per-pattern rate limit so a dialog that lingers across multiple polls
132
135
  // doesn't spam sendControl/event emissions. Aligned with the 5s poll cadence.
133
136
  const MID_TURN_DEDUP_WINDOW_MS = 30_000;
@@ -520,6 +523,14 @@ class CliProcess extends Process {
520
523
  override: _capOverride,
521
524
  }).outBytes;
522
525
 
526
+ // 0.12.0-rc.13: per-chat/topic compaction warning (default OFF). Same
527
+ // topic→chat precedence as the file cap above. When enabled, the channels
528
+ // backend warns the chat as context fills (propose /compact at a break)
529
+ // and on auto-compaction (the event that detaches the bridge mid-turn).
530
+ const _compactionWarnRaw = topicConfig?.compactionWarnings ?? opts.chatConfig?.compactionWarnings;
531
+ this.compactionWarn = resolveCompactionWarnConfig({ compactionWarnings: _compactionWarnRaw });
532
+ this._compactionWarned = false; // proactive warn-once per climb; reset on PostCompact
533
+
523
534
  // Parity audit P8 + rc.8 fs-guard (2026-05-26 shumorobot Music topic):
524
535
  // `--session-id <id>` creates a NEW claude session with that id;
525
536
  // `--resume <id>` resumes the EXISTING conversation. Lazy-respawn after
@@ -1738,6 +1749,38 @@ class CliProcess extends Process {
1738
1749
  lastAssistantMessage: ev.lastAssistantMessage,
1739
1750
  backend: this.backend,
1740
1751
  });
1752
+ // 0.12.0-rc.13 proactive compaction warning: on turn-end, if enabled
1753
+ // for this chat and not already warned this climb, sample context
1754
+ // occupancy from the transcript and warn (propose /compact) BEFORE
1755
+ // claude auto-compacts mid-turn and detaches the bridge. Fire-and-
1756
+ // forget — transcript IO must never block the stop path.
1757
+ if (this.compactionWarn?.enabled && !this._compactionWarned && ev.transcriptPath) {
1758
+ this._maybeProactiveCompactionWarn(ev.transcriptPath);
1759
+ }
1760
+ return;
1761
+
1762
+ case 'PreCompact':
1763
+ // 0.12.0-rc.13: auto-compaction is the event that detaches the
1764
+ // channels MCP bridge mid-turn. Record it; and on the dangerous AUTO
1765
+ // case (manual /compact is the user's own deliberate action — never
1766
+ // nag), emit a reactive warning the chat layer posts. The proactive
1767
+ // warning (on Stop) tries to PREVENT this; this is the backstop.
1768
+ this._logEvent('cli-compaction-imminent', { trigger: ev.trigger });
1769
+ if (this.compactionWarn?.enabled && ev.trigger === 'auto') {
1770
+ this.emit('compaction-warn', {
1771
+ kind: 'reactive',
1772
+ trigger: 'auto',
1773
+ sessionId: this.claudeSessionId,
1774
+ backend: this.backend,
1775
+ });
1776
+ }
1777
+ return;
1778
+
1779
+ case 'PostCompact':
1780
+ // Context just dropped — re-arm the proactive warn-once so the next
1781
+ // climb can warn again.
1782
+ this._compactionWarned = false;
1783
+ this._logEvent('cli-compaction-done', { trigger: ev.trigger });
1741
1784
  return;
1742
1785
 
1743
1786
  case 'Notification':
@@ -2320,6 +2363,37 @@ class CliProcess extends Process {
2320
2363
  * Extracted as a separate async method so unit tests can drive it
2321
2364
  * directly without waiting for the setInterval tick.
2322
2365
  */
2366
+ /**
2367
+ * 0.12.0-rc.13: proactive compaction warning. Read the transcript's current
2368
+ * context occupancy and, if past the per-chat threshold, emit a
2369
+ * 'compaction-warn' the chat layer turns into "you're ~N% full, run
2370
+ * /compact" — giving the user a window to compact on their terms BEFORE
2371
+ * claude auto-compacts mid-turn (which detaches the channels bridge). Warns
2372
+ * once per climb (this._compactionWarned), re-armed on PostCompact.
2373
+ * Fire-and-forget: swallows its own errors so transcript IO never breaks
2374
+ * the turn-end path.
2375
+ */
2376
+ async _maybeProactiveCompactionWarn(transcriptPath) {
2377
+ try {
2378
+ if (!this.compactionWarn?.enabled || this._compactionWarned) return;
2379
+ const usage = await readContextTokens(transcriptPath);
2380
+ if (!usage) return;
2381
+ const pct = contextPct(usage.total) * 100;
2382
+ if (pct < this.compactionWarn.thresholdPct) return;
2383
+ if (this._compactionWarned) return; // re-check after the async gap
2384
+ this._compactionWarned = true;
2385
+ this.emit('compaction-warn', {
2386
+ kind: 'proactive',
2387
+ pct: Math.round(pct),
2388
+ totalTokens: usage.total,
2389
+ sessionId: this.claudeSessionId,
2390
+ backend: this.backend,
2391
+ });
2392
+ } catch (err) {
2393
+ this.logger.warn?.(`[${this.label}] compaction-warn sample failed: ${err.message}`);
2394
+ }
2395
+ }
2396
+
2323
2397
  async _pollMidTurnDialogs() {
2324
2398
  if (this.closed) return;
2325
2399
  if (this.pendingTurns.size === 0) return; // no work to do when idle
@@ -2338,35 +2412,14 @@ class CliProcess extends Process {
2338
2412
  }
2339
2413
  if (!pane) return;
2340
2414
 
2341
- // Wedge recovery (Music topic incident, 2026-06-01). Claude Code's
2342
- // channel source can lose its MCP-server registration mid-turn most
2343
- // often after an auto-/compact of a large resumed session redraws the
2344
- // TUI and the `server:polygram-bridge` binding fails to re-resolve
2345
- // ("polygram-bridge no MCP server configured with that name"). The
2346
- // bridge SOCKET stays up, so the socket-close path
2347
- // (bridgeServer 'bridge-disconnected' _handleBridgeDisconnected) never
2348
- // fires but claude can no longer deliver the reply, so the turn
2349
- // orphans and would hang until the wall-clock cap while this watchdog
2350
- // logged cli-mid-turn-unknown-prompt every 30s and recovered nothing.
2351
- // Detect it from the pane and route through the SAME recovery as a real
2352
- // socket disconnect: reject pending turns (user gets the 🔌 resend
2353
- // message via BRIDGE_DISCONNECTED) and emit 'bridge-disconnected' so
2354
- // process-manager kills + lazy-respawns the dead instance (next msg
2355
- // recovers the conversation via `claude --resume`). Gated on
2356
- // pendingTurns.size>0 by the early return above, so it can't fire on an
2357
- // idle session that merely has the string in scrollback.
2358
- if (BRIDGE_DEAD_RE.test(pane)) {
2359
- this.logger.warn?.(
2360
- `[${this.label}] cli: channels MCP registration lost mid-turn ` +
2361
- `(pane-detected) — recovering ${this.pendingTurns.size} orphaned turn(s)`,
2362
- );
2363
- this._logEvent('cli-bridge-detached-midturn', {
2364
- pending_count: this.pendingTurns.size,
2365
- session_id: this.claudeSessionId,
2366
- });
2367
- this._handleBridgeDisconnected('mcp-registration-lost');
2368
- return;
2369
- }
2415
+ // rc.14: removed the rc.11 pane-based "dead bridge" detection here. It
2416
+ // matched the BENIGN banner "server:polygram-bridge no MCP server
2417
+ // configured with that name" a cosmetic line that
2418
+ // `--dangerously-load-development-channels` + `--strict-mcp-config` prints
2419
+ // on EVERY healthy session (channel still delivers; reply tool still
2420
+ // works). The matcher false-fired ~5s into every channels turn and killed
2421
+ // healthy sessions. Real bridge loss is the socket-close path
2422
+ // (_handleBridgeDisconnected), not anything observable in the pane.
2370
2423
 
2371
2424
  const now = Date.now();
2372
2425
 
@@ -51,6 +51,9 @@ const KNOWN_EVENT_NAMES = new Set([
51
51
  'SubagentStop',
52
52
  'Stop',
53
53
  'Notification',
54
+ // 0.12.0-rc.13: compaction lifecycle (carry `trigger` + custom_instructions).
55
+ 'PreCompact',
56
+ 'PostCompact',
54
57
  ]);
55
58
 
56
59
  /**
@@ -87,6 +90,10 @@ function normalizeHookEvent(raw) {
87
90
  prompt: raw?.prompt ?? null,
88
91
  stopHookActive: raw?.stop_hook_active ?? null,
89
92
  lastAssistantMessage: raw?.last_assistant_message ?? null,
93
+ // PreCompact/PostCompact payload: trigger distinguishes auto vs manual
94
+ // compaction; custom_instructions is the `/compact <hint>` text (manual).
95
+ trigger: raw?.trigger ?? null,
96
+ customInstructions: raw?.custom_instructions ?? null,
90
97
  receivedAtMs: raw?.polygram_received_at_ms ?? null,
91
98
  raw,
92
99
  };
@@ -44,6 +44,13 @@ const HOOK_EVENTS = [
44
44
  'SubagentStop',
45
45
  'Stop',
46
46
  'Notification',
47
+ // 0.12.0-rc.13: compaction lifecycle. PreCompact fires when claude is about
48
+ // to (auto-)compact — the moment that detaches the channels MCP bridge.
49
+ // PostCompact fires after, when context has dropped (used to re-arm the
50
+ // per-chat compaction warning). Both confirmed supported by the pinned CLI
51
+ // (2.1.142) and carry a `trigger: auto|manual` field.
52
+ 'PreCompact',
53
+ 'PostCompact',
47
54
  ];
48
55
 
49
56
  /**
@@ -41,6 +41,12 @@ const CALLBACK_TO_EVENT = {
41
41
  onAssistantMessageStart: 'assistant-message-start',
42
42
  onAutonomousAssistantMessage: 'autonomous-assistant-message',
43
43
  onCompactBoundary: 'compact-boundary',
44
+ // 0.12.0-rc.13: per-chat compaction warning. CliProcess emits
45
+ // 'compaction-warn' {kind:'proactive'|'reactive', pct?} when (proactive)
46
+ // context crosses the chat's threshold at turn-end, or (reactive) claude is
47
+ // auto-compacting now. The callback posts a chat message proposing /compact
48
+ // — opt-in per chat. See docs/0.12.0-file-send.md / lib/compaction-warn.js.
49
+ onCompactionWarn: 'compaction-warn',
44
50
  onQueueDrop: 'queue-drop',
45
51
  onThinking: 'thinking',
46
52
  // Tmux backend: TUI shows in-pane approval prompt. SDK backend
@@ -571,6 +571,42 @@ function createSdkCallbacks({
571
571
  }
572
572
  },
573
573
 
574
+ // 0.12.0-rc.13: per-chat compaction warning. CliProcess emits
575
+ // 'compaction-warn' when context crosses the chat's threshold at turn-end
576
+ // (proactive) or claude is auto-compacting now (reactive). Post a chat
577
+ // message proposing /compact so the user can compact on their terms BEFORE
578
+ // an auto-compaction interrupts a turn (and detaches the channels bridge).
579
+ // Opt-in per chat (lib/compaction-warn.js) — CliProcess only emits when
580
+ // enabled, so no extra config gate is needed here. Best-effort send.
581
+ onCompactionWarn: (sessionKey, payload /* , entry */) => {
582
+ try {
583
+ const chatId = getChatIdFromKey(sessionKey);
584
+ const threadIdRaw = getThreadIdFromKey(sessionKey);
585
+ const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
586
+ const kind = payload?.kind === 'reactive' ? 'reactive' : 'proactive';
587
+ logEvent('compaction-warn', {
588
+ chat_id: chatId,
589
+ session_key: sessionKey,
590
+ kind,
591
+ pct: payload?.pct ?? null,
592
+ backend: payload?.backend ?? 'cli',
593
+ });
594
+ if (!bot) return;
595
+ const text = kind === 'reactive'
596
+ ? '🗜️ Auto-compacting now — context filled up. If this turn goes quiet, please resend. (Tip: running `/compact` at a natural break avoids mid-task compactions.)'
597
+ : `📚 Heads up — this chat's context is ~${payload?.pct ?? '?'}% full. To avoid an auto-compaction that can interrupt a turn, run \`/compact\` (optionally with a hint, e.g. \`/compact keep the recent decisions\`) at a natural break — or \`/new\` for a fresh start.`;
598
+ tg(bot, 'sendMessage', {
599
+ chat_id: chatId,
600
+ text,
601
+ ...(threadId ? { message_thread_id: threadId } : {}),
602
+ }, { source: 'compaction-warn', botName }).catch((err) => {
603
+ logger.error?.(`[${botName}] compaction-warn send failed: ${err.message}`);
604
+ });
605
+ } catch (err) {
606
+ logger.error?.(`[${botName}] compaction-warn handler: ${err.message}`);
607
+ }
608
+ },
609
+
574
610
  // 0.10.0 rc.42 #8: tmux backend hook-tail error observability.
575
611
  // Persistent failures of the hook ndjson tail degrade H3 idle-
576
612
  // ceiling accuracy and H4 Stop-synth coverage with no surface
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.0-rc.12",
3
+ "version": "0.12.0-rc.14",
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": {