polygram 0.12.0-rc.12 → 0.12.0-rc.13

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
 
@@ -520,6 +522,14 @@ class CliProcess extends Process {
520
522
  override: _capOverride,
521
523
  }).outBytes;
522
524
 
525
+ // 0.12.0-rc.13: per-chat/topic compaction warning (default OFF). Same
526
+ // topic→chat precedence as the file cap above. When enabled, the channels
527
+ // backend warns the chat as context fills (propose /compact at a break)
528
+ // and on auto-compaction (the event that detaches the bridge mid-turn).
529
+ const _compactionWarnRaw = topicConfig?.compactionWarnings ?? opts.chatConfig?.compactionWarnings;
530
+ this.compactionWarn = resolveCompactionWarnConfig({ compactionWarnings: _compactionWarnRaw });
531
+ this._compactionWarned = false; // proactive warn-once per climb; reset on PostCompact
532
+
523
533
  // Parity audit P8 + rc.8 fs-guard (2026-05-26 shumorobot Music topic):
524
534
  // `--session-id <id>` creates a NEW claude session with that id;
525
535
  // `--resume <id>` resumes the EXISTING conversation. Lazy-respawn after
@@ -1738,6 +1748,38 @@ class CliProcess extends Process {
1738
1748
  lastAssistantMessage: ev.lastAssistantMessage,
1739
1749
  backend: this.backend,
1740
1750
  });
1751
+ // 0.12.0-rc.13 proactive compaction warning: on turn-end, if enabled
1752
+ // for this chat and not already warned this climb, sample context
1753
+ // occupancy from the transcript and warn (propose /compact) BEFORE
1754
+ // claude auto-compacts mid-turn and detaches the bridge. Fire-and-
1755
+ // forget — transcript IO must never block the stop path.
1756
+ if (this.compactionWarn?.enabled && !this._compactionWarned && ev.transcriptPath) {
1757
+ this._maybeProactiveCompactionWarn(ev.transcriptPath);
1758
+ }
1759
+ return;
1760
+
1761
+ case 'PreCompact':
1762
+ // 0.12.0-rc.13: auto-compaction is the event that detaches the
1763
+ // channels MCP bridge mid-turn. Record it; and on the dangerous AUTO
1764
+ // case (manual /compact is the user's own deliberate action — never
1765
+ // nag), emit a reactive warning the chat layer posts. The proactive
1766
+ // warning (on Stop) tries to PREVENT this; this is the backstop.
1767
+ this._logEvent('cli-compaction-imminent', { trigger: ev.trigger });
1768
+ if (this.compactionWarn?.enabled && ev.trigger === 'auto') {
1769
+ this.emit('compaction-warn', {
1770
+ kind: 'reactive',
1771
+ trigger: 'auto',
1772
+ sessionId: this.claudeSessionId,
1773
+ backend: this.backend,
1774
+ });
1775
+ }
1776
+ return;
1777
+
1778
+ case 'PostCompact':
1779
+ // Context just dropped — re-arm the proactive warn-once so the next
1780
+ // climb can warn again.
1781
+ this._compactionWarned = false;
1782
+ this._logEvent('cli-compaction-done', { trigger: ev.trigger });
1741
1783
  return;
1742
1784
 
1743
1785
  case 'Notification':
@@ -2320,6 +2362,37 @@ class CliProcess extends Process {
2320
2362
  * Extracted as a separate async method so unit tests can drive it
2321
2363
  * directly without waiting for the setInterval tick.
2322
2364
  */
2365
+ /**
2366
+ * 0.12.0-rc.13: proactive compaction warning. Read the transcript's current
2367
+ * context occupancy and, if past the per-chat threshold, emit a
2368
+ * 'compaction-warn' the chat layer turns into "you're ~N% full, run
2369
+ * /compact" — giving the user a window to compact on their terms BEFORE
2370
+ * claude auto-compacts mid-turn (which detaches the channels bridge). Warns
2371
+ * once per climb (this._compactionWarned), re-armed on PostCompact.
2372
+ * Fire-and-forget: swallows its own errors so transcript IO never breaks
2373
+ * the turn-end path.
2374
+ */
2375
+ async _maybeProactiveCompactionWarn(transcriptPath) {
2376
+ try {
2377
+ if (!this.compactionWarn?.enabled || this._compactionWarned) return;
2378
+ const usage = await readContextTokens(transcriptPath);
2379
+ if (!usage) return;
2380
+ const pct = contextPct(usage.total) * 100;
2381
+ if (pct < this.compactionWarn.thresholdPct) return;
2382
+ if (this._compactionWarned) return; // re-check after the async gap
2383
+ this._compactionWarned = true;
2384
+ this.emit('compaction-warn', {
2385
+ kind: 'proactive',
2386
+ pct: Math.round(pct),
2387
+ totalTokens: usage.total,
2388
+ sessionId: this.claudeSessionId,
2389
+ backend: this.backend,
2390
+ });
2391
+ } catch (err) {
2392
+ this.logger.warn?.(`[${this.label}] compaction-warn sample failed: ${err.message}`);
2393
+ }
2394
+ }
2395
+
2323
2396
  async _pollMidTurnDialogs() {
2324
2397
  if (this.closed) return;
2325
2398
  if (this.pendingTurns.size === 0) return; // no work to do when idle
@@ -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.13",
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": {