polygram 0.12.0 → 0.12.2

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.
@@ -322,7 +322,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
322
322
  // (P0 spike Q-B: claude echoes only the trigger id) — this array can.
323
323
  consumed_turn_ids: {
324
324
  type: 'array', items: { type: 'string' },
325
- description: 'turn_id of EVERY <channel> message this reply answers or has absorbed since your last reply (including mid-turn follow-ups). Always set it.',
325
+ description: 'turn_id of EVERY <channel> message this reply answers or has absorbed since your last reply, including mid-turn follow-ups. Set it on EVERY reply, even short one-line ones; if you answered two messages in one reply, list BOTH turn_ids. Omitting a folded follow-up makes polygram treat it as dropped.',
326
326
  },
327
327
  },
328
328
  required: ['chat_id', 'text'],
@@ -754,11 +754,14 @@ class CliProcess extends Process {
754
754
  'as normal — only the FINAL user-visible message needs to go through',
755
755
  'the reply tool.',
756
756
  '',
757
- 'When you call `reply`, ALWAYS set `consumed_turn_ids` to the turn_id',
758
- 'attribute of EVERY <channel> message you are answering or have received',
759
- 'since your last reply including mid-turn follow-ups you absorbed into',
760
- 'the current answer. polygram uses it to confirm follow-up delivery;',
761
- 'omitting it can cause a follow-up to be re-sent to you.',
757
+ 'When you call `reply`, ALWAYS set `consumed_turn_ids` to the turn_id of',
758
+ 'EVERY <channel> message this reply answers or folds in — every mid-turn',
759
+ 'follow-up you absorbed since your last reply. This applies to EVERY reply,',
760
+ 'including SHORT one-line ones: if two messages arrived and you answered both',
761
+ 'in one reply, list BOTH turn_ids (e.g. consumed_turn_ids: ["<original-id>",',
762
+ '"<follow-up-id>"]). Omitting a folded follow-up makes polygram read it as',
763
+ 'DROPPED — it gets re-sent to you or flagged as a lost message. When unsure,',
764
+ 'include the id.',
762
765
  '',
763
766
  '### Staying responsive on a long task',
764
767
  '',
@@ -1159,7 +1162,10 @@ class CliProcess extends Process {
1159
1162
  // happens at the chat_id guard below.
1160
1163
  const chatIdMatches = this.chatId == null || String(args.chat_id) === String(this.chatId);
1161
1164
  if (chatIdMatches && Array.isArray(args.consumed_turn_ids) && args.consumed_turn_ids.length) {
1162
- this._ledgerAckConsumed(args.consumed_turn_ids.filter((x) => typeof x === 'string'));
1165
+ this._ledgerAckConsumed(
1166
+ args.consumed_turn_ids.filter((x) => typeof x === 'string'),
1167
+ typeof args.text === 'string' ? args.text : '',
1168
+ );
1163
1169
  } else if (chatIdMatches && msg.name === 'reply' && 'consumed_turn_ids' in args) {
1164
1170
  this._lastAckFieldAt = Date.now(); // field present but empty — contract observed
1165
1171
  }
@@ -1534,8 +1540,16 @@ class CliProcess extends Process {
1534
1540
  if (e._watchdogTimer) { clearTimeout(e._watchdogTimer); e._watchdogTimer = null; }
1535
1541
  }
1536
1542
 
1537
- /** Tier 2C: a reply carried consumed_turn_ids — acknowledge every known id. */
1538
- _ledgerAckConsumed(ids) {
1543
+ /**
1544
+ * Tier 2C: a reply carried consumed_turn_ids — acknowledge every known id.
1545
+ * `consumingText` is the text the consuming reply actually delivered; it is
1546
+ * recorded on each consumed pending so _finalizeTurn can tell a genuine fold
1547
+ * (the answer rode the sibling reply) from an early ack that DIDN'T carry the
1548
+ * answer (prod 2026-06-13: a 294-char "Researching now…" ack, then the real
1549
+ * answer arrived as Stop-fallback text — which must NOT be suppressed).
1550
+ * See docs/0.13-consumed-ack-stop-fallback-drop-spec.md.
1551
+ */
1552
+ _ledgerAckConsumed(ids, consumingText = '') {
1539
1553
  this._lastAckFieldAt = Date.now();
1540
1554
  for (const id of ids) {
1541
1555
  const e = this.inputLedger.get(id);
@@ -1552,6 +1566,14 @@ class CliProcess extends Process {
1552
1566
  const pending = this.pendingTurns.get(id);
1553
1567
  if (pending) {
1554
1568
  pending._consumedAcked = true;
1569
+ // Remember WHAT the consuming reply delivered for this turn. The last
1570
+ // ack wins; a longer subsequent ack is the safer record (more likely to
1571
+ // actually contain the answer). _finalizeTurn only suppresses a
1572
+ // Stop-fallback finalize when its text matches this.
1573
+ if (typeof consumingText === 'string'
1574
+ && consumingText.length > (pending._consumedByText?.length || 0)) {
1575
+ pending._consumedByText = consumingText;
1576
+ }
1555
1577
  // The ack itself flips rung-2 eligibility on — arm now. (The turn's
1556
1578
  // last _noteActivity ran BEFORE this flag was set, so without this
1557
1579
  // a quiet tail would never re-arm and the turn would sit until a
@@ -1890,10 +1912,33 @@ class CliProcess extends Process {
1890
1912
  // the turn without calling mcp__polygram-bridge__reply), use the Stop
1891
1913
  // hook's last_assistant_message as the text. Same rescue pattern rc.41
1892
1914
  // H4 uses on tmux backend when JSONL stream is broken.
1893
- if (!text && pending._stopHookData?.lastAssistantMessage) {
1915
+ const usedStopFallback = !text && !!pending._stopHookData?.lastAssistantMessage;
1916
+ if (usedStopFallback) {
1894
1917
  text = pending._stopHookData.lastAssistantMessage;
1895
1918
  this.logger.warn?.(`[${this.label}] cli: turn finalized via stop-hook fallback (no reply tool call); text_len=${text.length}`);
1896
1919
  }
1920
+ // A _consumedAcked turn is "already delivered" ONLY when the consuming
1921
+ // sibling reply actually carried THIS text — not merely an ack. Prod
1922
+ // 2026-06-13 (Shumabit@UMI/37): the consuming reply was a 294-char
1923
+ // "Researching now…" ack, then the real answer arrived as Stop-fallback
1924
+ // text. Suppressing it (alreadyDelivered=true → polygram.js short-circuit)
1925
+ // dropped the answer silently for 5h20m. Only suppress when the rescued
1926
+ // text matches what the sibling delivered.
1927
+ // docs/0.13-consumed-ack-stop-fallback-drop-spec.md
1928
+ const norm = (s) => (s || '').trim();
1929
+ const consumedCoversFallback = !usedStopFallback || norm(text) === norm(pending._consumedByText);
1930
+ const alreadyDelivered = hadReplyToolCalls
1931
+ || (pending._consumedAcked === true && consumedCoversFallback);
1932
+ if (usedStopFallback && pending._consumedAcked === true && !consumedCoversFallback) {
1933
+ this.logger.warn?.(`[${this.label}] cli: consumed-ack did NOT cover the Stop-fallback answer — delivering rescued text (len=${text.length})`);
1934
+ this._logEvent('cli-consumed-ack-fallback-rescued', {
1935
+ turn_id: turnId,
1936
+ session_key: this.sessionKey,
1937
+ backend: this.backend,
1938
+ rescued_len: text.length,
1939
+ ack_len: norm(pending._consumedByText).length,
1940
+ });
1941
+ }
1897
1942
  const duration = Date.now() - pending.startedAt;
1898
1943
  // Review AC4: cost=null + metrics-tokens=null signal "unmeasured-subscription"
1899
1944
  // (channels protocol doesn't expose per-turn cost or token breakdowns).
@@ -1910,9 +1955,11 @@ class CliProcess extends Process {
1910
1955
  // would resolve the turn silently and the user still sees nothing. So
1911
1956
  // only claim already-delivered when reply tool calls actually fired —
1912
1957
  // or when claude ACKED consuming this turn in a sibling reply
1913
- // (consumed_turn_ids; the fold-id-echo case): re-sending the Stop
1914
- // fallback there would duplicate the delivered answer.
1915
- alreadyDelivered: hadReplyToolCalls || pending._consumedAcked === true,
1958
+ // (consumed_turn_ids; the fold-id-echo case) AND that sibling actually
1959
+ // delivered this text re-sending the Stop fallback there would
1960
+ // duplicate. A consumed-ack whose ack did NOT carry the Stop-fallback
1961
+ // answer must still deliver (see consumedCoversFallback above).
1962
+ alreadyDelivered,
1916
1963
  sessionId: this.claudeSessionId,
1917
1964
  cost: null, // Channels protocol doesn't expose per-turn cost
1918
1965
  duration,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
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": {