polygram 0.8.0-rc.39 → 0.8.0-rc.40

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.39",
4
+ "version": "0.8.0-rc.40",
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",
@@ -131,6 +131,12 @@ function classifyToolName(name) {
131
131
  // nothing and leave the user wondering whether the bot is alive.
132
132
  const GENERIC_FALLBACKS = ['👍', '👀', '🔥'];
133
133
 
134
+ // Module-scope sentinel for flush()'s `fromStateOverride` parameter.
135
+ // Distinguishes "caller didn't pass fromState" from "caller passed
136
+ // null (= reactor had no prior state)". Module-scoped so we don't
137
+ // allocate a fresh symbol on every flush call.
138
+ const FROM_STATE_UNSET = Symbol('flush.fromState-unset');
139
+
134
140
  /**
135
141
  * Resolve the best-available emoji from a chain given an allowlist.
136
142
  * If allowlist is null/undefined, assume default-available set and
@@ -160,6 +166,20 @@ function resolveEmoji(chain, allowlist) {
160
166
  * from getChat().available_reactions. Null/undefined = assume defaults.
161
167
  * @param {number} [deps.throttleMs] minimum ms between non-terminal changes.
162
168
  * @param {(msg: string) => void} [deps.logError]
169
+ * @param {(transition: object) => void} [deps.onStateChange]
170
+ * rc.39: called after every visible state transition (state OR
171
+ * emoji change). Payload shape:
172
+ * { fromState, toState, fromEmoji, toEmoji, source, ts }
173
+ * where `source` is one of:
174
+ * 'manual' — explicit setState() call
175
+ * 'cascade-deeper' — auto-promotion to THINKING_DEEPER
176
+ * 'cascade-deepest' — auto-promotion to THINKING_DEEPEST
177
+ * 'stall-timer' — auto STALL after stallMs
178
+ * 'freeze-timer' — auto FREEZE after freezeMs
179
+ * 'clear' — clear() call (toEmoji=null)
180
+ * Used by polygram to emit `reactor-state` events to the events
181
+ * table for forensic post-hoc reconstruction of any reaction
182
+ * anomaly. Must be cheap + sync — fired in the hot setState path.
163
183
  */
164
184
  function createReactionManager({
165
185
  apply,
@@ -170,6 +190,7 @@ function createReactionManager({
170
190
  thinkingDeeperMs = DEFAULT_THINKING_DEEPER_MS,
171
191
  thinkingDeepestMs = DEFAULT_THINKING_DEEPEST_MS,
172
192
  logError = () => {},
193
+ onStateChange = null,
173
194
  } = {}) {
174
195
  if (typeof apply !== 'function') throw new Error('apply function required');
175
196
  let currentState = null;
@@ -202,14 +223,37 @@ function createReactionManager({
202
223
  'CODING', 'WEB', 'TOOL', 'WRITING',
203
224
  ]);
204
225
 
205
- const flush = async (stateName) => {
226
+ const flush = async (stateName, source = 'manual', fromStateOverride = FROM_STATE_UNSET) => {
206
227
  if (stopped && !TERMINAL_STATES.has(stateName)) return;
207
228
  const spec = STATES[stateName];
208
229
  if (!spec) return;
209
230
  const emoji = resolveEmoji(spec.chain, availableEmojis);
210
231
  if (emoji === currentEmoji) return;
232
+ // For telemetry: prefer the caller-supplied fromState override
233
+ // (setState/cascade timers swap currentState BEFORE calling flush;
234
+ // we want the PRE-swap value in the event, not the post-swap
235
+ // self-loop). Sentinel symbol distinguishes "no override" from
236
+ // a legitimate `null` (= reactor had no prior state, e.g. very
237
+ // first setState in a fresh handleMessage).
238
+ const fromState = fromStateOverride === FROM_STATE_UNSET ? currentState : fromStateOverride;
239
+ const fromEmoji = currentEmoji;
211
240
  currentEmoji = emoji;
212
241
  lastFlushTs = Date.now();
242
+ // rc.39: emit telemetry on the visible-change moment. Fired
243
+ // synchronously so the events.table row's ts ordering matches
244
+ // the ordering of setMessageReaction calls. Wrapped in try/catch
245
+ // so a buggy onStateChange can't break the reactor.
246
+ if (typeof onStateChange === 'function') {
247
+ try {
248
+ onStateChange({
249
+ fromState, toState: stateName,
250
+ fromEmoji, toEmoji: emoji,
251
+ source, ts: lastFlushTs,
252
+ });
253
+ } catch (err) {
254
+ logError(`onStateChange threw: ${err?.message || err}`);
255
+ }
256
+ }
213
257
  // Chain through applyChain so concurrent flushes are sent to
214
258
  // Telegram serially in invocation order. Returning the chain
215
259
  // promise lets callers await this specific flush completing.
@@ -249,9 +293,12 @@ function createReactionManager({
249
293
  if (currentState !== 'THINKING') return;
250
294
  // Promote without going through setState — we want the visual
251
295
  // change but NOT to reset the deepest timer (which keeps
252
- // counting from the original THINKING start).
296
+ // counting from the original THINKING start). Pass the PRE-swap
297
+ // state to flush() so the telemetry event records the actual
298
+ // transition (THINKING → THINKING_DEEPER), not a self-loop.
299
+ const before = currentState;
253
300
  currentState = 'THINKING_DEEPER';
254
- flush('THINKING_DEEPER');
301
+ flush('THINKING_DEEPER', 'cascade-deeper', before);
255
302
  }, thinkingDeeperMs);
256
303
  deeperTimer.unref?.();
257
304
  deepestTimer = setTimeout(() => {
@@ -259,8 +306,9 @@ function createReactionManager({
259
306
  if (stopped) return;
260
307
  // Promote from THINKING or THINKING_DEEPER (NOT from CODING etc).
261
308
  if (currentState !== 'THINKING' && currentState !== 'THINKING_DEEPER') return;
309
+ const before = currentState;
262
310
  currentState = 'THINKING_DEEPEST';
263
- flush('THINKING_DEEPEST');
311
+ flush('THINKING_DEEPEST', 'cascade-deepest', before);
264
312
  }, thinkingDeepestMs);
265
313
  deepestTimer.unref?.();
266
314
  };
@@ -275,13 +323,13 @@ function createReactionManager({
275
323
  // promotable state in the interim.
276
324
  if (stopped || TERMINAL_STATES.has(currentState)) return;
277
325
  if (!STALL_PROMOTABLE.has(currentState)) return;
278
- flush('STALL');
326
+ flush('STALL', 'stall-timer');
279
327
  }, stallMs);
280
328
  stallTimer.unref?.();
281
329
  freezeTimer = setTimeout(() => {
282
330
  freezeTimer = null;
283
331
  if (stopped || TERMINAL_STATES.has(currentState)) return;
284
- flush('TIMEOUT');
332
+ flush('TIMEOUT', 'freeze-timer');
285
333
  }, freezeMs);
286
334
  freezeTimer.unref?.();
287
335
  };
@@ -289,6 +337,12 @@ function createReactionManager({
289
337
  const setState = (stateName) => {
290
338
  if (stopped) return;
291
339
  if (!STATES[stateName]) return;
340
+ // rc.39: capture pre-swap state for telemetry. Pre-rc.39 the
341
+ // currentState was set BEFORE flush, so the onStateChange event's
342
+ // fromState always equalled toState (looked like a self-loop).
343
+ // Pass `before` through to flush so the audit trail records the
344
+ // real transition.
345
+ const before = currentState;
292
346
  currentState = stateName;
293
347
  lastSetStateTs = Date.now();
294
348
 
@@ -298,7 +352,7 @@ function createReactionManager({
298
352
  if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
299
353
  clearStallTimers();
300
354
  clearDeepeningTimers();
301
- return flush(stateName);
355
+ return flush(stateName, 'manual', before);
302
356
  }
303
357
 
304
358
  // Any explicit setState resets the stall clock — Claude clearly is
@@ -326,7 +380,7 @@ function createReactionManager({
326
380
  // round-trip. User sees 👀 → 🤔 → 🔥 progress, smoothly
327
381
  // paced by network latency.
328
382
  if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
329
- return flush(stateName);
383
+ return flush(stateName, 'manual', before);
330
384
  };
331
385
 
332
386
  const clear = async () => {
@@ -334,7 +388,22 @@ function createReactionManager({
334
388
  clearStallTimers();
335
389
  clearDeepeningTimers();
336
390
  if (currentEmoji == null) return;
391
+ const fromState = currentState;
392
+ const fromEmoji = currentEmoji;
337
393
  currentEmoji = null;
394
+ // rc.39: emit telemetry on clear (toEmoji=null is the "we let go
395
+ // of the message" signal). Mirrors the flush() path.
396
+ if (typeof onStateChange === 'function') {
397
+ try {
398
+ onStateChange({
399
+ fromState, toState: null,
400
+ fromEmoji, toEmoji: null,
401
+ source: 'clear', ts: Date.now(),
402
+ });
403
+ } catch (err) {
404
+ logError(`onStateChange threw: ${err?.message || err}`);
405
+ }
406
+ }
338
407
  // Same applyChain serialization as flush — clear() is a state
339
408
  // transition, just to "no emoji". Without chaining, a clear()
340
409
  // racing with a pending apply (e.g. THINKING flush in flight)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.39",
3
+ "version": "0.8.0-rc.40",
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
@@ -2425,6 +2425,26 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2425
2425
  },
2426
2426
  availableEmojis,
2427
2427
  logError: (m) => console.error(`[${label}] ${m}`),
2428
+ // rc.39: emit reactor-state events for forensic post-hoc
2429
+ // reconstruction of any reaction anomaly (stuck reactions, dual
2430
+ // emojis, unexpected ERROR transitions, etc.). Sync callback —
2431
+ // logEvent is best-effort and never throws. One row per visible
2432
+ // change moment; cascade/stall/freeze auto-promotions get
2433
+ // their own `source` value so we can tell apart manual setState
2434
+ // calls from timer-driven transitions.
2435
+ onStateChange: ({ fromState, toState, fromEmoji, toEmoji, source, ts }) => {
2436
+ logEvent('reactor-state', {
2437
+ chat_id: chatId,
2438
+ msg_id: msg.message_id,
2439
+ session_key: sessionKey,
2440
+ from_state: fromState,
2441
+ to_state: toState,
2442
+ from_emoji: fromEmoji,
2443
+ to_emoji: toEmoji,
2444
+ source,
2445
+ ts,
2446
+ });
2447
+ },
2428
2448
  });
2429
2449
  // rc.32: skip QUEUED (👀) entirely for first-message-in-chain. Go
2430
2450
  // straight to THINKING (🤔). The 👀 → 🤔 two-hop didn't add