polygram 0.12.0 → 0.12.1

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.
@@ -1159,7 +1159,10 @@ class CliProcess extends Process {
1159
1159
  // happens at the chat_id guard below.
1160
1160
  const chatIdMatches = this.chatId == null || String(args.chat_id) === String(this.chatId);
1161
1161
  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'));
1162
+ this._ledgerAckConsumed(
1163
+ args.consumed_turn_ids.filter((x) => typeof x === 'string'),
1164
+ typeof args.text === 'string' ? args.text : '',
1165
+ );
1163
1166
  } else if (chatIdMatches && msg.name === 'reply' && 'consumed_turn_ids' in args) {
1164
1167
  this._lastAckFieldAt = Date.now(); // field present but empty — contract observed
1165
1168
  }
@@ -1534,8 +1537,16 @@ class CliProcess extends Process {
1534
1537
  if (e._watchdogTimer) { clearTimeout(e._watchdogTimer); e._watchdogTimer = null; }
1535
1538
  }
1536
1539
 
1537
- /** Tier 2C: a reply carried consumed_turn_ids — acknowledge every known id. */
1538
- _ledgerAckConsumed(ids) {
1540
+ /**
1541
+ * Tier 2C: a reply carried consumed_turn_ids — acknowledge every known id.
1542
+ * `consumingText` is the text the consuming reply actually delivered; it is
1543
+ * recorded on each consumed pending so _finalizeTurn can tell a genuine fold
1544
+ * (the answer rode the sibling reply) from an early ack that DIDN'T carry the
1545
+ * answer (prod 2026-06-13: a 294-char "Researching now…" ack, then the real
1546
+ * answer arrived as Stop-fallback text — which must NOT be suppressed).
1547
+ * See docs/0.13-consumed-ack-stop-fallback-drop-spec.md.
1548
+ */
1549
+ _ledgerAckConsumed(ids, consumingText = '') {
1539
1550
  this._lastAckFieldAt = Date.now();
1540
1551
  for (const id of ids) {
1541
1552
  const e = this.inputLedger.get(id);
@@ -1552,6 +1563,14 @@ class CliProcess extends Process {
1552
1563
  const pending = this.pendingTurns.get(id);
1553
1564
  if (pending) {
1554
1565
  pending._consumedAcked = true;
1566
+ // Remember WHAT the consuming reply delivered for this turn. The last
1567
+ // ack wins; a longer subsequent ack is the safer record (more likely to
1568
+ // actually contain the answer). _finalizeTurn only suppresses a
1569
+ // Stop-fallback finalize when its text matches this.
1570
+ if (typeof consumingText === 'string'
1571
+ && consumingText.length > (pending._consumedByText?.length || 0)) {
1572
+ pending._consumedByText = consumingText;
1573
+ }
1555
1574
  // The ack itself flips rung-2 eligibility on — arm now. (The turn's
1556
1575
  // last _noteActivity ran BEFORE this flag was set, so without this
1557
1576
  // a quiet tail would never re-arm and the turn would sit until a
@@ -1890,10 +1909,33 @@ class CliProcess extends Process {
1890
1909
  // the turn without calling mcp__polygram-bridge__reply), use the Stop
1891
1910
  // hook's last_assistant_message as the text. Same rescue pattern rc.41
1892
1911
  // H4 uses on tmux backend when JSONL stream is broken.
1893
- if (!text && pending._stopHookData?.lastAssistantMessage) {
1912
+ const usedStopFallback = !text && !!pending._stopHookData?.lastAssistantMessage;
1913
+ if (usedStopFallback) {
1894
1914
  text = pending._stopHookData.lastAssistantMessage;
1895
1915
  this.logger.warn?.(`[${this.label}] cli: turn finalized via stop-hook fallback (no reply tool call); text_len=${text.length}`);
1896
1916
  }
1917
+ // A _consumedAcked turn is "already delivered" ONLY when the consuming
1918
+ // sibling reply actually carried THIS text — not merely an ack. Prod
1919
+ // 2026-06-13 (Shumabit@UMI/37): the consuming reply was a 294-char
1920
+ // "Researching now…" ack, then the real answer arrived as Stop-fallback
1921
+ // text. Suppressing it (alreadyDelivered=true → polygram.js short-circuit)
1922
+ // dropped the answer silently for 5h20m. Only suppress when the rescued
1923
+ // text matches what the sibling delivered.
1924
+ // docs/0.13-consumed-ack-stop-fallback-drop-spec.md
1925
+ const norm = (s) => (s || '').trim();
1926
+ const consumedCoversFallback = !usedStopFallback || norm(text) === norm(pending._consumedByText);
1927
+ const alreadyDelivered = hadReplyToolCalls
1928
+ || (pending._consumedAcked === true && consumedCoversFallback);
1929
+ if (usedStopFallback && pending._consumedAcked === true && !consumedCoversFallback) {
1930
+ this.logger.warn?.(`[${this.label}] cli: consumed-ack did NOT cover the Stop-fallback answer — delivering rescued text (len=${text.length})`);
1931
+ this._logEvent('cli-consumed-ack-fallback-rescued', {
1932
+ turn_id: turnId,
1933
+ session_key: this.sessionKey,
1934
+ backend: this.backend,
1935
+ rescued_len: text.length,
1936
+ ack_len: norm(pending._consumedByText).length,
1937
+ });
1938
+ }
1897
1939
  const duration = Date.now() - pending.startedAt;
1898
1940
  // Review AC4: cost=null + metrics-tokens=null signal "unmeasured-subscription"
1899
1941
  // (channels protocol doesn't expose per-turn cost or token breakdowns).
@@ -1910,9 +1952,11 @@ class CliProcess extends Process {
1910
1952
  // would resolve the turn silently and the user still sees nothing. So
1911
1953
  // only claim already-delivered when reply tool calls actually fired —
1912
1954
  // 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,
1955
+ // (consumed_turn_ids; the fold-id-echo case) AND that sibling actually
1956
+ // delivered this text re-sending the Stop fallback there would
1957
+ // duplicate. A consumed-ack whose ack did NOT carry the Stop-fallback
1958
+ // answer must still deliver (see consumedCoversFallback above).
1959
+ alreadyDelivered,
1916
1960
  sessionId: this.claudeSessionId,
1917
1961
  cost: null, // Channels protocol doesn't expose per-turn cost
1918
1962
  duration,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
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": {