mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-08 21:34:04 +00:00
Port the dev-beta2 progress-summary work onto the compositor-era dispatch: - Post a one-line activity digest (🧠 N thoughts · 💬 N notes · 🛠️ N tool calls · ⏱️ Ns, Discord parity) as a durable message when the progress window collapses at end-of-turn, before the final answer. Counted per-burst channel-side; only window-streamed activity feeds the bar (durable rv/verbose items never do — persistent message XOR bar count), no bar for error finals or turns where the window never rendered. - Render multi-line commentary with its line structure intact (per-line markdown conversion, joined as line breaks) instead of collapsing to one run-on line; per-line conversion also keeps block markdown (setext headings) from forming across lines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
169 lines
6.7 KiB
TypeScript
169 lines
6.7 KiB
TypeScript
// Post-turn collapse summary for the Telegram progress window.
|
|
//
|
|
// Mirrors Discord's collapse-summary line (extensions/discord/src/monitor/
|
|
// message-handler.process.ts `buildProgressSummaryLine`): when the ephemeral
|
|
// progress draft collapses at end-of-turn, Discord posts a one-line activity
|
|
// digest like `🧠 3 thoughts · 💬 2 notes · 🛠️ 4 tool calls · ⏱️ 21s`.
|
|
//
|
|
// Telegram had no equivalent (conformance discrepancy #4). This tracks the same
|
|
// turn-activity counters channel-side (Discord also tallies these in its handler,
|
|
// not in core) and formats the same content. The only divergence is the line
|
|
// prefix: Discord wraps the line in its `-#` small-text syntax, which Telegram
|
|
// markdown has no analog for, so the Telegram line is emitted plain.
|
|
|
|
export type TelegramProgressSummaryCounters = {
|
|
reasoningSteps: number;
|
|
commentaryNotes: number;
|
|
toolCalls: number;
|
|
};
|
|
|
|
// Tracks turn activity for the collapse summary. The summary reflects ONLY what
|
|
// actually streamed to the progress window — never durable-delivered items
|
|
// (per the user's spec: "only summarize messages that ACTUALLY streamed").
|
|
// So there is deliberately no durable-reasoning counter: in rv (/reasoning on)
|
|
// thoughts persist as standalone messages and must NOT feed the bar, or the bar
|
|
// would show even though nothing streamed to the window. A reasoning "burst" is
|
|
// counted once at whichever boundary arrives first — the reasoning-end event,
|
|
// the next tool call, or the summary flush — because some models (e.g. deepseek)
|
|
// do not emit a reliable thinking_end per burst, so counting on the end event
|
|
// alone undercounts.
|
|
export type TelegramProgressSummaryTracker = {
|
|
/** A reasoning delta arrived; opens (or keeps open) the current burst. */
|
|
noteReasoningActivity(): void;
|
|
/** Reasoning-end fired; close and count the current burst if one is open. */
|
|
closeReasoningBurst(): void;
|
|
/**
|
|
* A window-rendered tool call started: it is the boundary for any open
|
|
* reasoning/commentary burst, so close+count those first, then count one tool.
|
|
* Callers count the tool only when it is suppressed (window-rendered); under
|
|
* verbose the tool persists durably and they close the bursts directly instead
|
|
* (closeReasoningBurst/closeCommentaryBurst) without calling this.
|
|
*/
|
|
noteToolCall(): void;
|
|
/**
|
|
* A commentary/preamble note arrived for the window. Opens (or keeps open) a
|
|
* commentary burst — it is NOT counted here. The burst is counted once when it
|
|
* closes at the next boundary (tool start, reasoning-end, a different note, or
|
|
* the summary flush). Counting per-burst rather than per-id is deliberate: the
|
|
* anthropic core tags every note in a turn with the SAME turn-local id
|
|
* ("commentary-0"), so an id-Set dedup collapsed a multi-tool turn's notes to
|
|
* one (D3). A tool follows each note in a tool-using turn, closing its burst
|
|
* before the next note opens => N notes.
|
|
*/
|
|
noteCommentary(itemId?: string, text?: string): void;
|
|
/** Close and count the current commentary burst if one is open. */
|
|
closeCommentaryBurst(): void;
|
|
/** Snapshot of the current counters (closes any open bursts into the tally). */
|
|
counts(): TelegramProgressSummaryCounters;
|
|
/** True when there is at least one thought, note, or tool call to summarize. */
|
|
hasActivity(): boolean;
|
|
};
|
|
|
|
export function createTelegramProgressSummaryTracker(): TelegramProgressSummaryTracker {
|
|
let reasoningSteps = 0;
|
|
let commentaryNotes = 0;
|
|
let toolCalls = 0;
|
|
let reasoningBurstOpen = false;
|
|
// One open commentary burst at a time (mirrors Discord's windowCommentaryOpen /
|
|
// closePendingWindowCommentary). A re-fire of the SAME note (same id, or prefix
|
|
// growth of the same id-less streamed text) keeps the burst open; a different
|
|
// note with no intervening boundary closes the previous burst before opening
|
|
// the new one.
|
|
let commentaryBurstOpen = false;
|
|
let openCommentaryItemId: string | undefined;
|
|
let openCommentaryText = "";
|
|
|
|
const closeReasoningBurst = () => {
|
|
if (reasoningBurstOpen) {
|
|
reasoningBurstOpen = false;
|
|
reasoningSteps += 1;
|
|
}
|
|
};
|
|
|
|
const closeCommentaryBurst = () => {
|
|
if (commentaryBurstOpen) {
|
|
commentaryBurstOpen = false;
|
|
openCommentaryItemId = undefined;
|
|
openCommentaryText = "";
|
|
commentaryNotes += 1;
|
|
}
|
|
};
|
|
|
|
return {
|
|
noteReasoningActivity() {
|
|
reasoningBurstOpen = true;
|
|
},
|
|
closeReasoningBurst,
|
|
noteToolCall() {
|
|
closeReasoningBurst();
|
|
closeCommentaryBurst();
|
|
toolCalls += 1;
|
|
},
|
|
noteCommentary(itemId?: string, text?: string) {
|
|
const trimmed = text?.trim();
|
|
if (!trimmed) {
|
|
return;
|
|
}
|
|
const id = itemId?.trim() || undefined;
|
|
if (commentaryBurstOpen) {
|
|
const sameNote = openCommentaryItemId
|
|
? id === openCommentaryItemId
|
|
: !id &&
|
|
(trimmed === openCommentaryText ||
|
|
trimmed.startsWith(openCommentaryText) ||
|
|
openCommentaryText.startsWith(trimmed));
|
|
if (sameNote) {
|
|
openCommentaryText = trimmed;
|
|
return;
|
|
}
|
|
// A different note arrived with no intervening boundary: close the
|
|
// previous burst before opening the new one.
|
|
closeCommentaryBurst();
|
|
}
|
|
commentaryBurstOpen = true;
|
|
openCommentaryItemId = id;
|
|
openCommentaryText = trimmed;
|
|
},
|
|
closeCommentaryBurst,
|
|
counts() {
|
|
closeReasoningBurst();
|
|
closeCommentaryBurst();
|
|
return { reasoningSteps, commentaryNotes, toolCalls };
|
|
},
|
|
hasActivity() {
|
|
return (
|
|
reasoningBurstOpen ||
|
|
commentaryBurstOpen ||
|
|
reasoningSteps > 0 ||
|
|
commentaryNotes > 0 ||
|
|
toolCalls > 0
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
// Formats the collapse-summary line. Returns undefined when there is nothing to
|
|
// summarize (no thoughts, notes, or tool calls) so a degenerate "⏱️ Ns"-only
|
|
// line is never emitted. Content and ordering mirror Discord exactly.
|
|
export function formatTelegramProgressSummaryLine(
|
|
counters: TelegramProgressSummaryCounters,
|
|
elapsedMs: number,
|
|
): string | undefined {
|
|
const { reasoningSteps, commentaryNotes, toolCalls } = counters;
|
|
if (reasoningSteps <= 0 && commentaryNotes <= 0 && toolCalls <= 0) {
|
|
return undefined;
|
|
}
|
|
const seconds = Math.max(1, Math.round(elapsedMs / 1000));
|
|
const parts = [
|
|
...(reasoningSteps > 0
|
|
? [`🧠 ${reasoningSteps} thought${reasoningSteps === 1 ? "" : "s"}`]
|
|
: []),
|
|
...(commentaryNotes > 0
|
|
? [`💬 ${commentaryNotes} note${commentaryNotes === 1 ? "" : "s"}`]
|
|
: []),
|
|
...(toolCalls > 0 ? [`🛠️ ${toolCalls} tool call${toolCalls === 1 ? "" : "s"}`] : []),
|
|
`⏱️ ${seconds}s`,
|
|
];
|
|
return parts.join(" · ");
|
|
}
|