From 064d3f77d6b751a897db8234c58dc4a6a7e48631 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 17:20:15 +0000 Subject: [PATCH 1/2] perf(matrix-bridge): char-delta edit gate + self-tuning 429 backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matrix port of the two patterns from PR #12 that generalize cleanly across both bridges. Matrix already has retry_after_ms honoring via rateLimitedUntil and terminal-render 4× retry; what was missing: (1) Char-delta edit gate On non-terminal renders with no phase transition, skip the edit if the rendered text length changed by fewer than CHAR_DELTA_CUTOFF (50) chars. Stops thinking-preview ticks (5 chars of italic narration delta) from burning matrix.org quota. Phase transitions (STARTING → RUNNING, etc.) always edit through so the header emoji + label swap stays snappy. (2) Self-tuning per-turn 429 backoff bonus Each non-terminal 429 from matrix.org bumps backoffBonusMs by BACKOFF_PER_429_MS (1500) ms, capped at MAX_BACKOFF_BONUS_MS (10s). The bonus adds to the EDIT_MIN_INTERVAL_MS floor so subsequent edits in the same turn space out and the chain settles instead of bouncing off the limit repeatedly. Previously each 429 only deferred to `now + retry_after_ms` without ratcheting, so the next allowed tick could immediately hit another 429. Both knobs wipe on /watch fresh-spawn (added backoffBonusMs + rateLimitedUntil + lastRenderedLen + lastEditedPhase to the existing storage.delete batch) — calm turns start fast, noisy turns self-throttle within their own scope. Group-vs-DM cadence (the third borrow in PR #12) does NOT port to Matrix — matrix.org's per-room rate cap is uniform regardless of room size, so the TG group/DM split isn't the right signal here. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-matrix-bridge/src/poller.ts | 72 +++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/replicas-matrix-bridge/src/poller.ts b/replicas-matrix-bridge/src/poller.ts index 6b31c59e..02b1cfcc 100644 --- a/replicas-matrix-bridge/src/poller.ts +++ b/replicas-matrix-bridge/src/poller.ts @@ -81,6 +81,20 @@ const MAX_WATCH_DURATION_MS = 30 * 60 * 1000; // limit while still feeling live for the user. const EDIT_MIN_INTERVAL_MS = 2000; const TICKER_REFRESH_MS = 4000; + +// Char-delta edit gate (n3d1117/chatgpt-telegram-bot pattern, see +// docs/acp-telegram-status-research.md). On non-terminal renders with no +// phase transition, skip the edit if the rendered text length changed by +// fewer than this many characters. Stops thinking-preview ticks (~5 chars +// of italic narration delta) from burning matrix.org quota. +const CHAR_DELTA_CUTOFF = 50; + +// Self-tuning per-turn backoff bonus. Each non-terminal 429 from +// matrix.org adds 1500ms to the effective edit throttle, capped at 10s. +// Wiped on /watch fresh-spawn so calm turns start fast; noisy turns +// settle into a sustainable cadence (matches the TG bridge's pattern). +const BACKOFF_PER_429_MS = 1500; +const MAX_BACKOFF_BONUS_MS = 10_000; const TYPING_INTERVAL_MS = 25_000; const REPLY_MAX_LEN = 16_000; // Matrix doesn't cap message length the way Telegram does (~16KB safe) @@ -201,6 +215,12 @@ export class ReplicaPoller { "lastTypingAt", "lastEditAt", "plan", + // Per-turn throttle bookkeeping — wipe so a fresh turn starts + // fast even if the prior turn ratcheted the backoff bonus up. + "backoffBonusMs", + "rateLimitedUntil", + "lastRenderedLen", + "lastEditedPhase", ]); const baseline = await baselineP; const seed: Record = { @@ -583,20 +603,39 @@ export class ReplicaPoller { // frame the user sees and the rolling-log freeze depends on them. // Ticker/tool edits get the rate limit to protect the homeserver. const isTerminal = s.terminal !== undefined; - if (!isTerminal && statusEventId !== undefined && since < EDIT_MIN_INTERVAL_MS) return; - - // Honor matrix.org's per-room rate limit. When we hit 429 the server - // hands us a retry_after_ms; respect it instead of dumb-retrying - // every alarm tick. The "until" timestamp is stored in DO storage so - // it survives ticks. Terminal renders skip the gate (the Done frame - // is the load-bearing one — it's worth queueing past the limit if - // needed; the homeserver will accept it on the next allowed slot). - if (!isTerminal) { + + if (!isTerminal && statusEventId !== undefined) { + // (1) Edit floor with self-tuning per-turn backoff bonus. The + // bonus ratchets up by BACKOFF_PER_429_MS each time the + // server says 429, capped at MAX_BACKOFF_BONUS_MS. Resets + // on /watch fresh-spawn. + const backoffBonus = (await this.state.storage.get("backoffBonusMs")) ?? 0; + if (since < EDIT_MIN_INTERVAL_MS + backoffBonus) return; + + // (2) Honor matrix.org's per-room rate limit. When we hit 429 + // the server hands us a retry_after_ms; respect it instead + // of dumb-retrying every alarm tick. Terminal renders skip + // the gate — the Done frame is load-bearing. const rlUntil = (await this.state.storage.get("rateLimitedUntil")) ?? 0; if (Date.now() < rlUntil) return; + + // (3) Char-delta edit gate: skip when the rendered text length + // barely moved AND phase hasn't transitioned. Stops + // thinking-preview ticks (~5 chars of italic narration + // delta) from burning quota. Phase transitions ALWAYS edit + // through so the header emoji + label swap stays snappy. + const lastRenderedLen = (await this.state.storage.get("lastRenderedLen")) ?? 0; + const lastEditedPhase = await this.state.storage.get("lastEditedPhase"); + const phaseChanged = lastEditedPhase !== undefined && lastEditedPhase !== s.phase; + if (!phaseChanged && Math.abs(text.length - lastRenderedLen) < CHAR_DELTA_CUTOFF) return; } - await this.state.storage.put({ lastRendered: text, lastEditAt: Date.now() }); + await this.state.storage.put({ + lastRendered: text, + lastEditAt: Date.now(), + lastRenderedLen: text.length, + lastEditedPhase: s.phase, + }); if (statusEventId !== undefined) { // Terminal edits (Done / Failed) MUST land — the embedded @@ -621,8 +660,17 @@ export class ReplicaPoller { // Non-terminal: park for the next tick. Do NOT fall // through to sendMessage — that fragmenting fallback // was the source of the multi-bubble group-chat bug. - await this.state.storage.put("rateLimitedUntil", Date.now() + waitMs); - console.log(`[poller] editMessage 429 — backing off ${waitMs}ms`); + // Also bump the per-turn self-tuning backoff bonus + // so subsequent edits in this turn space out and the + // chain settles instead of bouncing off the limit + // repeatedly. + const prevBonus = (await this.state.storage.get("backoffBonusMs")) ?? 0; + const nextBonus = Math.min(MAX_BACKOFF_BONUS_MS, prevBonus + BACKOFF_PER_429_MS); + await this.state.storage.put({ + rateLimitedUntil: Date.now() + waitMs, + backoffBonusMs: nextBonus, + }); + console.log(`[poller] editMessage 429 — wait ${waitMs}ms, bonus ${prevBonus}→${nextBonus}ms`); return; } console.log(`[poller] editMessage failed: ${e instanceof Error ? e.message : e}`); From ffc6746d5d982ec91020b3a8bf3a5dea0024e96f Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 17:24:55 +0000 Subject: [PATCH 2/2] docs(research): deep-dive on OpenACP Telegram internals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second follow-up after Jaden asked to go deeper. Read all relevant TG plugin files in Open-ACP/OpenACP end-to-end: - activity.ts (491 lines): ThinkingIndicator + ToolCard + ActivityTracker - streaming.ts (290 lines): MessageDraft text streaming - formatting.ts (451 lines): markdownToTelegramHtml + renderToolCard - topic-manager.ts (159 lines): per-session forum topics - permissions.ts (126 lines): inline keyboard permission flow Documents the actual mechanics the prior summary missed: - ThinkingIndicator state machine: idle → showing (15s refresh) → dismissed (msg stays in chat); auto-stop at 3 min enforced by the refresh body itself; dismissed re-checked AFTER sendQueue wait because the agent may have moved on during the queue wait. - ToolCard internals: flushPromise as serializing chain; lastSentText dedup; overflow strip (strip outputContent from specs when render exceeds 4096); overflow chunks recycled via editMessageText; stale chunks deleted when count decreases; aborted flag for destroy(). - ActivityTracker seal-and-keep vs clear-on-new-prompt: out-of-order tool updates after a seal still route to previousToolStateMap; new prompt clears previous refs to prevent cross-turn leakage. - MessageDraft critical patterns: snapshot buffer BEFORE await (with the explicit "append() can mutate" comment); do NOT reset messageId on transient errors (matches our hard-won d79c798 lesson); displayTruncated flag for finalize correctness; "enqueue all chunks in tight synchronous loop" to prevent concurrent handlers slipping between. - renderToolCard visual order: completed-first, then plan, then running; ACP status icons ⬜🔄✅; tool-kind icon per-spec. - Permission flow: nanoid(8) callback key for 64-byte callback_data limit; in-memory pending Map (not persistent across restarts); two-channel surface (session topic + Notifications topic with deep link); editMessageReplyMarkup({reply_markup: undefined}) strips buttons on response. Closes with a prioritized table of borrowable patterns + effort estimates. Top two cheap wins: 3-min thinking auto-stop (~10 lines render-side) and displayTruncated flag (~5 lines poller-side). Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- docs/acp-telegram-status-research.md | 141 +++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/docs/acp-telegram-status-research.md b/docs/acp-telegram-status-research.md index b50f4321..08439bd6 100644 --- a/docs/acp-telegram-status-research.md +++ b/docs/acp-telegram-status-research.md @@ -3,6 +3,147 @@ **Date:** 2026-05-29 **Why:** Jaden asked for a deep-research pass on how *open ACP / AI Telegram bots* surface live status. The bridges already work; this is to find any pattern we should borrow vs. confirm we're aligned with the field. +## Detailed Deep-Dive (2026-05-29, second follow-up) + +Read every relevant TG plugin file in [Open-ACP/OpenACP](https://github.com/Open-ACP/OpenACP) end-to-end after Jaden asked to go deeper. This section documents the actual implementation mechanics — race-conditions, dedup tricks, state machines — that the prior summary missed. Each subsection cites concrete files + line ranges. + +### 1. ThinkingIndicator state machine ([`activity.ts:33-147`](https://github.com/Open-ACP/OpenACP/blob/main/src/plugins/telegram/activity.ts#L33-L147)) + +``` + show() dismiss()/finalize…() + [idle, no msg] ────────────► [showing] ─────────────────────► [dismissed, msg stays in chat] + │ ▲ + every 15s│ │ refresh timer rearms + ▼ │ + "💭 Still thinking... (Ns)" + │ + elapsed ≥ 3min: stopRefreshTimer +``` + +Concrete details that matter: + +- **`sending` + `dismissed` flags both checked inside the sendQueue continuation.** Lines 67-73: after `await sendQueue.enqueue(...)` resolves, re-checks `this.dismissed` because the agent may have started streaming text during the queue wait. If dismissed, the message stays in chat (no delete call to save API cost) but `msgId` is never captured. +- **Refresh timer is internal, not driven by alarms.** `setInterval(..., 15_000)` runs in-process; doesn't survive worker restart. Workers reset = thinking indicator goes stale until next event. Acceptable because they run a long-lived Node process, not a CF Worker. +- **3-minute auto-stop is enforced by the refresh callback itself** (line 118: `if (Date.now() - this.showTime >= THINKING_MAX_MS) { stopRefreshTimer; return }`). No separate timeout. If thinking runs >3 min, the message just stops updating at the last "Still thinking... (180s)" and that text remains forever. +- **`finalizeWithViewerLink(url)` is the "high mode" exit.** When the tunnel service is configured, the agent's full chain-of-thought is stored to a web viewer; the indicator gets edited to `💭 Thinking... View thinking`. This is the OpenACP equivalent of our `
` collapse — but as a separate hosted page, not inline expansion. + +### 2. ToolCard ([`activity.ts:159-294`](https://github.com/Open-ACP/OpenACP/blob/main/src/plugins/telegram/activity.ts#L159-L294)) + +The most complex piece. Renders a SINGLE Telegram message representing the live state of N tool calls — header `📋 Tools (M/N)` then completed-tools-first, then plan section, then running-tools. + +Key implementation details: + +- **`flushPromise` is a Promise chain.** Every `_sendOrEdit` is chained via `.then(() => this._sendOrEdit(...))`. Serializes ALL card edits — no two card edits run concurrently, period. Snapshots flow through the chain in order even when emitted faster than Telegram can accept them. +- **Snapshot dedup at line 236**: `if (this.msgId && fullText === this.lastSentText) return`. Cheap text equality check before any API call. Stops re-edits when the render didn't actually change. +- **Overflow strip for >4096 chars** (lines 222-233): if the rendered card exceeds Telegram's per-message limit, the spec list is mapped with `outputContent: null` and re-rendered. Drops the inline tool-output previews while keeping the tool-call headers + status. Only the FIRST chunk gets this treatment. +- **Overflow chunks recycled as edits, not new sends each render** (lines 261-279): chunks 1..N go into separate Telegram messages tracked in `overflowMsgIds[]`. Subsequent renders that produce the same chunk count edit those messages in place. If chunk count DECREASES, stale messages are deleted with `deleteMessage`. +- **`aborted` flag for destroy()**: if `destroy()` runs while a flush is in flight (e.g., session cancelled), the next `_sendOrEdit` checks `this.aborted` and bails out without making the API call. + +### 3. ActivityTracker — the orchestrator ([`activity.ts:309-490`](https://github.com/Open-ACP/OpenACP/blob/main/src/plugins/telegram/activity.ts#L309-L490)) + +Owns the ThinkingIndicator + current ToolCard + (importantly) the **previous** ToolCard. + +``` + onNewPrompt() onTextStart() / onThought() + [idle] ──────────────────────────► [new turn] ───────────────────────────────────────► ... + ↓ ↓ + previousToolCard ← undefined sealToolCardIfNeeded(): + toolStateMap ← clear() previousToolCard ← toolCard + toolCard ← new ToolCard toolStateMap ← new ToolStateMap() + toolCard ← new ToolCard + ↓ + onToolUpdate() arrives for an OLD tool id: + if previousToolStateMap.get(id): route THERE + else: route to current +``` + +Two patterns we should pay attention to: + +- **The seal-and-keep pattern.** `sealToolCardIfNeeded()` finalizes the current card, moves it to `previousToolCard`, and opens a fresh card. The previous card is NOT deleted — it stays in chat as historical record AND its `previousToolStateMap` is kept around so that late-arriving `onToolUpdate` events for tools that started before the seal still update the right (old) card. Line 429-436 explicitly handles this out-of-order case. +- **The clear-on-new-prompt pattern.** `onNewPrompt` resets `previousToolCard = undefined` AND clears the `toolStateMap`. So a new prompt FULLY isolates from prior cards — even an out-of-order update arriving for an ancient tool id won't accidentally edit a 3-minute-old card. The boundary between turns is hard; the boundary between phases within a turn (seal) is soft. + +For us: we don't have this distinction because our rolling log is a flat `lines[]` array. An out-of-order Replicas event (rare, but possible — tool_result arriving after another tool_use has already started) just appends to lines and renders as a sequence. We don't actually mutate prior tool-call renderings. Different design tradeoff — ours is simpler but loses the "this specific tool's status updated" semantics. + +### 4. MessageDraft — streaming the final text ([`streaming.ts:22-289`](https://github.com/Open-ACP/OpenACP/blob/main/src/plugins/telegram/streaming.ts#L22-L289)) + +This is where the agent's actual reply text accumulates. Separate from ToolCard. Three subtle patterns worth borrowing: + +- **Snapshot the buffer BEFORE the first await.** Lines 66-69, with an explicit comment: "append() can be called synchronously while we're awaiting sendQueue, so this.buffer may change." `const snapshot = this.buffer;` captures the value going OUT, so `lastSentBuffer` accurately tracks what landed. Our equivalent would be `text = render(s)` then comparing `text` to `lastRendered` — we DO this correctly today. +- **Do NOT reset `messageId` on transient errors.** Lines 135-139, explicit comment: "transient errors would cause the next flush to sendMessage the full buffer as a NEW message, creating duplicates." Same lesson we learned the hard way in our Matrix bridge with the fall-through-to-sendMessage fragmentation bug. They got there preemptively. +- **`displayTruncated` flag for finalize correctness.** Lines 102-106 and 165-167: if `flush()` had to truncate because intermediate HTML > 4096, set the flag. `finalize()` checks: if buffer == lastSentBuffer AND !displayTruncated, skip (already sent). But if displayTruncated, MUST resend the full buffer even though it equals last. Subtle dedup-vs-correctness conflict that's easy to get wrong. +- **"Enqueue all chunks in a tight synchronous loop"** for split messages (lines 204-247). The comment is explicit: "prevents concurrent handlers (usage, session_end) from slipping their messages between our chunks in the sendQueue." We don't have this risk because we don't have a global sendQueue — each replica's DO is its own ordering domain. But noted for the design space. +- **HTML→plaintext fallback on each chunk.** Lines 229-244: if HTML send fails (malformed tags after a chunk boundary), enqueues a plain-text fallback with `chunkMd.slice(0, 4096)` and no `parse_mode`. Acceptable degradation. We don't do this — we just log and move on. Worth considering for the Matrix `markdownToHtml` path if we ever get a malformed-HTML edge case. + +### 5. formatting.ts — render shapes ([`formatting.ts:213-248`](https://github.com/Open-ACP/OpenACP/blob/main/src/plugins/telegram/formatting.ts#L213-L248)) + +The actual visual shape of a ToolCard: + +``` +📋 Tools (3/5) ✅ + ← `headerCheck = allComplete ? " ✅" : ""` +🔧 Bash · "npm test" + 📄 View file + ← completed-tools section, in order + +── Plan: 2/4 ── +✅ 1. Locate the failing test +✅ 2. Add a fixture +🔄 3. Re-run tests +⬜ 4. Update changelog +──── ← plan only renders if planEntries.length > 0 + +🔧 Write · src/foo.ts + ← running-tools section +``` + +Visual conventions worth noting: + +- **Completed tools render BEFORE plan, running tools render AFTER.** The plan acts as a divider; the user reads top-down as "what's done → what's next → what's happening right now." +- **Plan status icons match ACP status names exactly**: `⬜ pending`, `🔄 in_progress`, `✅ completed`. We use `◦ ► ✓` instead. Either works; theirs is closer to ACP semantics. +- **Tool kind icons resolved per-tool via `resolveToolIcon(tool)`**, not by Phase. So a `Read` tool always shows 📖 even in a "Running" phase. Ours uses Phase emoji on the header and tool emoji on the line — the same info shows up in two places, which we get away with because Telegram lines are short. +- **High-verbosity mode adds `Input:` + `Output:` `
` blocks** under each tool with 3800-char truncation. We don't have a verbosity knob — tool inputs/outputs always show in the truncated `↳ output` lines. The OpenACP "low/medium/high" knob is per-session config and would be a clean addition for us if we want it.
+
+### 6. Forum-topic isolation ([`topic-manager.ts:47-158`](https://github.com/Open-ACP/OpenACP/blob/main/src/plugins/telegram/topic-manager.ts#L47-L158) + [`topics.ts`](https://github.com/Open-ACP/OpenACP/blob/main/src/plugins/telegram/topics.ts))
+
+Each session lives in its own Telegram forum topic inside a group. Two system topics are reserved (Notifications + Assistant). Session topics include the session record's metadata: `topicId`, `sessionId`, `agentName`, `lastActiveAt`.
+
+Operational features we don't have:
+
+- **`/cleanup`-style bulk delete** for finished/error/cancelled sessions. Cancels active sessions first (to prevent orphaned processes), then deletes the topic via the platform adapter, then removes the session record.
+- **Active-session deletion requires `confirmed: true`.** If a user tries to delete an active session topic, the call returns `needsConfirmation: true` and a confirmation prompt is sent. Two-step delete.
+- **System topics guarded by ID match**. `isSystemTopic(record)` returns true if the topicId matches the notification or assistant topic IDs. They can't be deleted by any user-facing command. We don't need this because we don't have system topics — but the pattern of "reserved IDs that user commands can never touch" is generally useful.
+
+For our Matrix bridge: Beeper groups support topics too (`org.matrix.msc3440` thread spec). We could give each replica its own thread inside a group room. That'd require Replicas-side support for parallel sessions per room, which we don't have today (per-room → single replica via the KV `room:${roomId}` mapping). Larger refactor than a tunable.
+
+### 7. Permission flow ([`permissions.ts:26-125`](https://github.com/Open-ACP/OpenACP/blob/main/src/plugins/telegram/permissions.ts#L26-L125))
+
+When the agent requests permission to run a sensitive tool, OpenACP surfaces it as an inline keyboard message in the session topic, AND fires a notification in the Notifications topic with a deep link back. Key bits:
+
+- **Callback data shape**: `p::` where `callbackKey` is `nanoid(8)`. Telegram caps callback_data at 64 bytes; long requestId/sessionId can't fit, so a short key maps to in-memory pending state.
+- **Pending state is in-memory only.** `this.pending = new Map()`. NOT persistent — a worker restart loses the mapping and all pending permission requests become orphaned (`'❌ Expired'` response). For us this would be a CF Worker DO restart, which is rare but happens; OpenACP's Node process is similarly fragile here.
+- **Two-channel surfacing.** The actual permission buttons go in the session topic; a separate notification with a deep link (`buildDeepLink(chatId, threadId, msg.message_id)`) goes to the Notifications topic. Lets the user see "I have permissions to approve" without scrolling through every session topic.
+- **On response, the buttons are removed via `editMessageReplyMarkup({ reply_markup: undefined })`.** Locks out double-clicks; the message itself stays. Standard Telegram pattern.
+
+We can't replicate this end-to-end because Replicas doesn't expose permission requests in `/history` — the agent runs autonomously and applies allow/deny based on the workspace policy. But the SHAPE of "request → buttons → callback → resolve → strip buttons" is the right pattern if Replicas ever surfaces it.
+
+### 8. Concrete patterns worth lifting (prioritized)
+
+The Update section listed three borrowable patterns. Now with the deeper look, here's an updated list with effort estimates:
+
+| Pattern | Effort | Reason |
+|---|---|---|
+| **3-minute thinking auto-stop** ("Still working…" header transition at 60s, hard auto-stop at 180s) | ~10 lines, render-side | Cheap UX win; long-thinking turns currently look frozen |
+| **`displayTruncated` flag for finalize correctness** | ~5 lines, poller-side | Defensive fix for an edge case (intermediate truncation forces full-resend) |
+| **`previousToolStateMap` for out-of-order tool updates** | ~30 lines, poller-side | Marginal — Replicas /history is mostly in-order; only matters if we add live tool-streaming via SSE |
+| **Tool-kind icon resolution per spec** | ~20 lines, render-side | Cosmetic; we already do this via `formatToolUseLine` switch |
+| **HTML→plaintext fallback on edit failure** | ~15 lines, matrix.ts | Defense against malformed-HTML edge case; low probability |
+| **Per-session forum topics** | Large refactor — needs Replicas-side multi-session-per-room | Not a tune |
+| **Permission button flow** | Needs Replicas-side surface | Not actionable today |
+
+The first two are clean ~30-line follow-up PRs if we want them. Everything else is either a rewrite or blocked on upstream.
+
+---
+
 ## Update (2026-05-29, follow-up)
 
 Jaden pointed me at **[Open-ACP/OpenACP](https://github.com/Open-ACP/OpenACP)** — a self-hosted bridge that connects Claude Code / Codex / Gemini / Cursor / 28+ AI coding agents to Telegram, Discord, and Slack via ACP. Direct fellow-traveler in our exact space, built in TypeScript, 8MB repo. Their Telegram status design is fundamentally different from ours and worth understanding.