polygram 0.10.0-rc.37 → 0.10.0-rc.39

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.10.0-rc.37",
4
+ "version": "0.10.0-rc.39",
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 plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -344,13 +344,27 @@ function createSdkCallbacks({
344
344
  }
345
345
  },
346
346
 
347
- // 0.10.0 H1 (observer-only): tmux backend hook-based turn
348
- // observability. TmuxProcess emits `hook-event` with normalized
349
- // HookEvent records for every claude-CLI hook firing (PreToolUse,
347
+ // 0.10.0 H1 (observer-only) + H2 (reactor wiring): tmux backend
348
+ // hook-based turn observability + status.
349
+ //
350
+ // H1: TmuxProcess emits `hook-event` with normalized HookEvent
351
+ // records for every claude-CLI hook firing (PreToolUse,
350
352
  // PostToolUse, UserPromptSubmit, Stop, SubagentStop, Notification,
351
- // plus `unknown` for any schema drift). Persisted compact so the
352
- // soak can characterize the stream's reliability against real
353
- // Music traffic before H2/H3/H4 consume it.
353
+ // plus `unknown` for any schema drift). Persisted compact for
354
+ // forensic soak analysis.
355
+ //
356
+ // H2: routes hook events to the head pending's reactor so the
357
+ // Telegram emoji reflects what claude is actually doing — incl.
358
+ // subagent-inner tool fires (PreToolUse with `agent_id`) that
359
+ // JSONL `tool-use` never surfaces. The win: long subagent turns
360
+ // stop tripping the 🥱→😨→🤯 escalation because each inner
361
+ // PostToolUse / SubagentStop / Notification heartbeats the
362
+ // reactor, proving the agent is alive.
363
+ //
364
+ // Augments — does NOT replace — the existing JSONL-driven
365
+ // `onToolUse` setState and stream-chunk heartbeats. Duplicate
366
+ // setState for the same state is a no-op in the reactor; the
367
+ // throttle/cascade timers are unchanged.
354
368
  //
355
369
  // Fields persisted are intentionally narrow: identity + tool/
356
370
  // subagent scoping + `duration_ms` (free per-tool latency from
@@ -359,8 +373,9 @@ function createSdkCallbacks({
359
373
  // (`tool_input`, full `tool_response`, `last_assistant_message`)
360
374
  // are NOT persisted to the events DB — they'd inflate row size
361
375
  // without informing the soak.
362
- onHookEvent: (sessionKey, payload /* , entry */) => {
376
+ onHookEvent: (sessionKey, payload, entry) => {
363
377
  try {
378
+ // ── H1: DB persist ────────────────────────────────────────
364
379
  const detail = {
365
380
  chat_id: getChatIdFromKey(sessionKey),
366
381
  session_key: sessionKey,
@@ -387,6 +402,58 @@ function createSdkCallbacks({
387
402
  detail.parse_error = payload?.error ?? null;
388
403
  }
389
404
  logEvent('hook-event', detail);
405
+
406
+ // ── H2: route to reactor ──────────────────────────────────
407
+ //
408
+ // The reactor lives on the HEAD pending's per-turn context
409
+ // (same shape as `onToolUse` and `onStreamChunk`). Hook
410
+ // events from claude can land in three windows relative to
411
+ // a polygram turn:
412
+ // 1. Mid-turn (the normal case) — head exists, reactor
413
+ // lives, route the event.
414
+ // 2. Between turns / before head is set — head is null,
415
+ // skip silently. The next setState from polygram-side
416
+ // turn lifecycle will recover.
417
+ // 3. UserPromptSubmit fires BEFORE polygram's
418
+ // reactor.setState('THINKING') in some races; that's
419
+ // fine because UserPromptSubmit is intentionally a
420
+ // no-op here (the existing turn-start path owns it).
421
+ const head = entry?.pendingQueue?.[0];
422
+ const reactor = head?.context?.reactor;
423
+ if (!reactor) return;
424
+
425
+ switch (payload?.type) {
426
+ case 'PreToolUse':
427
+ // PreToolUse fires for main-agent AND subagent-inner
428
+ // tools (the latter scoped by `agent_id`). The reactor
429
+ // doesn't care WHO ran the tool, only WHAT — so
430
+ // classifyToolName drives the state regardless of
431
+ // agent context.
432
+ if (payload.toolName) {
433
+ reactor.setState(classifyToolName(payload.toolName));
434
+ }
435
+ break;
436
+
437
+ case 'PostToolUse':
438
+ case 'SubagentStop':
439
+ case 'Notification':
440
+ // Liveness signals — each one proves the agent is still
441
+ // making progress. Heartbeat resets the STALL (🥱) and
442
+ // TIMEOUT (😨) timers, killing the fear escalation on
443
+ // long healthy turns that was the motivating msg-884
444
+ // incident.
445
+ if (typeof reactor.heartbeat === 'function') {
446
+ reactor.heartbeat();
447
+ }
448
+ break;
449
+
450
+ // UserPromptSubmit, Stop, unknown, parse-error: no
451
+ // reactor routing. Turn lifecycle owns start/clear; the
452
+ // observer-only H1 DB persist above still records them
453
+ // for forensics.
454
+ default:
455
+ break;
456
+ }
390
457
  } catch (err) {
391
458
  logger.error?.(`[${botName}] hook-event handler: ${err.message}`);
392
459
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * sanitize-reply — outbound assistant-text sanitizer for claude-CLI
3
+ * canned-string leakage.
4
+ *
5
+ * The model occasionally emits CLI-context boilerplate strings
6
+ * verbatim as Telegram replies — typically when its reasoning
7
+ * decides "no response needed." `POLYGRAM_DISPLAY_HINT` (rc.37
8
+ * hardening) explicitly forbids them, but the hint mitigation
9
+ * proved partial: the model still leaked `No response requested.`
10
+ * on a substantive user question (shumorobot Music, 2026-05-22
11
+ * 14:14). Likely CLI-internal, not prompt-driven.
12
+ *
13
+ * This sanitizer is the polygram-side safety net. Runs AFTER
14
+ * `parseResponse` — sees the parsed text the streamer/deliver
15
+ * path will send. On a verbatim match against a narrow allowlist
16
+ * of known canned strings, replaces with an honest brief message
17
+ * the user can act on (rephrase / retry).
18
+ *
19
+ * Narrow allowlist on purpose:
20
+ * - Exact full-text match (not substring) — paranoia against
21
+ * accidentally rewriting legitimate replies that mention these
22
+ * phrases (e.g. an explanation of the issue itself).
23
+ * - Does NOT include `No response generated. Please try again.`
24
+ * because that's polygram's own R10 empty-turn fallback, which
25
+ * is intentional output.
26
+ * - Does NOT include `Stopped.` because that's polygram's `/stop`
27
+ * confirmation.
28
+ *
29
+ * If new canned strings are observed in production, add them to
30
+ * CANNED_STRINGS with a comment naming the production trace.
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ // Exact-match (trimmed) canned strings to intercept. Keep this list
36
+ // short and explicit — every entry is a known production leak.
37
+ const CANNED_STRINGS = new Set([
38
+ // shumorobot 2026-05-22 (Music topic, 13:17 and 14:14, both on
39
+ // rc.36/37). Model emitted this verbatim on the first occurrence
40
+ // after an ambiguous ack ("okay"); on the second, after a real
41
+ // substantive question. Prompt-side mitigation (rc.37) didn't
42
+ // catch the second case — confirming this is CLI-internal.
43
+ 'No response requested.',
44
+ // Listed in the rc.37 display hint as an adjacent variant. Treated
45
+ // the same way if it ever appears.
46
+ 'No response needed.',
47
+ ]);
48
+
49
+ // Replacement text — italic, brief, honest, actionable. Avoids
50
+ // pretending the bot did useful work; tells the user explicitly that
51
+ // the model didn't generate a real reply.
52
+ const SANITIZED_REPLACEMENT =
53
+ '_(the model returned no actual reply — try rephrasing or asking again)_';
54
+
55
+ /**
56
+ * Inspect an outbound assistant text. If the FULL TRIMMED text
57
+ * matches a known CLI-context canned string, return the honest
58
+ * replacement and a `replaced` flag so the caller can log the
59
+ * substitution. Otherwise return the original text unchanged.
60
+ *
61
+ * @param {string} text — the assistant text about to be sent.
62
+ * @returns {{ text: string, replaced: boolean, original?: string }}
63
+ */
64
+ function sanitizeAssistantReply(text) {
65
+ if (typeof text !== 'string') return { text, replaced: false };
66
+ const trimmed = text.trim();
67
+ if (!trimmed) return { text, replaced: false };
68
+ if (CANNED_STRINGS.has(trimmed)) {
69
+ return {
70
+ text: SANITIZED_REPLACEMENT,
71
+ replaced: true,
72
+ original: trimmed,
73
+ };
74
+ }
75
+ return { text, replaced: false };
76
+ }
77
+
78
+ module.exports = {
79
+ CANNED_STRINGS,
80
+ SANITIZED_REPLACEMENT,
81
+ sanitizeAssistantReply,
82
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.37",
3
+ "version": "0.10.0-rc.39",
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
@@ -80,6 +80,7 @@ const { transcribe: transcribeVoice, isVoiceAttachment } = require('./lib/telegr
80
80
  const { createStreamer } = require('./lib/telegram/streamer');
81
81
  const { chunkMarkdownText } = require('./lib/telegram/chunk');
82
82
  const { deliverReplies } = require('./lib/telegram/deliver');
83
+ const { sanitizeAssistantReply } = require('./lib/telegram/sanitize-reply');
83
84
  const { announce, shouldAnnounce } = require('./lib/announces');
84
85
  const { isAbortRequest } = require('./lib/abort-detector');
85
86
  const { startTyping } = require('./lib/telegram/typing');
@@ -1308,6 +1309,25 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1308
1309
  }
1309
1310
 
1310
1311
  const parsed = parseResponse(result.text);
1312
+ // rc.39: intercept CLI-context canned-string leaks (`No response
1313
+ // requested.` etc.) before they reach the streamer/deliver path.
1314
+ // Replaces with an honest brief message; logs the substitution
1315
+ // for forensic post-hoc analysis of how often the leak fires.
1316
+ // See lib/telegram/sanitize-reply.js for the (narrow) allowlist
1317
+ // and rationale — the rc.37 prompt-side hint mitigation proved
1318
+ // insufficient, so this is the polygram-layer safety net.
1319
+ if (parsed.text) {
1320
+ const sanitized = sanitizeAssistantReply(parsed.text);
1321
+ if (sanitized.replaced) {
1322
+ logEvent('canned-reply-suppressed', {
1323
+ chat_id: chatId,
1324
+ msg_id: msg.message_id,
1325
+ original: sanitized.original,
1326
+ backend: result?.backend || null,
1327
+ });
1328
+ parsed.text = sanitized.text;
1329
+ }
1330
+ }
1311
1331
  const outMeta = { ...outMetaBase, sessionId: result.sessionId, costUsd: result.cost };
1312
1332
 
1313
1333
  // 0.8.0-rc.39: send any inline stickers Claude embedded with