mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 03:51:16 +00:00
* fix(channels): time-gate progress drafts and defer narration until visible Progress drafts started immediately on the second work event, so the 5s initial delay never filtered small fast tasks and Discord showed narrated status for turns that finish in seconds. The gate is now purely time-based (explicit startNow paths like approvals/patches are unchanged), and the progress narrator no longer spends utility-model completions before the channel draft is visible - notes buffer so the first visible narration has full context. Surface: src/channels/streaming.ts gate, src/auto-reply/reply/progress-narrator.ts, Discord draft-preview wiring; test updates across discord/matrix/msteams/slack. Refs #105872 * feat(channels): model-preamble status headline for progress drafts In progress mode the draft's status line is now the model's own typed pre-tool preamble whenever it is fresh (always on, no new config); utility-model narration fills the slot only when the model has been quiet for ~20s or emits no preamble. Preambles never start the draft early - the time gate still owns the small-vs-big decision - and narration generation is suppressed while a fresh preamble exists, saving utility calls. Discord and Telegram render the headline; the opt-in streaming.progress.commentary lane and its receipt counters are unchanged. Refs #105872 * refactor(channels): split status-text and telegram preview helpers to honor the LOC ratchet The LOC ratchet forbids raising baseline entries, so the headline feature's growth is paid for with clean splits: reasoning/commentary text normalization moves to src/channels/progress-draft-status-text.ts, Telegram progress preview rendering to extensions/telegram/src/progress-draft-preview.ts, and small ownership moves (formatCompactionModelRef, resolveFollowupAbortSignal, shared CLI preamble gate, Discord preamble item-event handling) shrink the runners back under their pins. Baseline entries lowered to exact new counts; the compositor entry is removed (424 lines, under the 500 ceiling). Refs #105872 * test(auto-reply): add resolveFollowupAbortSignal to the queue mock factory The explicit ./queue.js vi.mock factory must export every binding prod touches; the relocated resolveFollowupAbortSignal was missing and failed the whole followup-runner suite. Refs #105872 * fix(channels): sanitize preamble headlines and retry skipped narration Review findings: preamble headlines now strip inline delivery directives and reject silent-control tokens (NO_REPLY) before rendering, sharing the commentary sanitizer; rejected input keeps the previous valid headline. The narrator schedules cancellation-aware retries when a run is skipped for draft invisibility or preamble freshness, so single long tool calls still narrate and the ~20s utility filler fires without needing another event. Silent preambles no longer stamp freshness or add narration notes. Refs #105872 * fix(channels): bound narrator visibility polling and gate Telegram headline mode on accepted preambles Visibility retries stop after 30 one-second attempts (turn completion has no narrator disposal signal) and re-arm on new activity; Telegram derives its headline-rendering state from the compositor's sanitized headline instead of raw payload text, so directive-only or NO_REPLY preambles keep structured tool-line rendering. Docs scope the preamble headline to Discord and Telegram. Refs #105872 * fix(telegram): read headline state from the compositor at render time The progressHeadlineActive mirror flag was set after pushPreambleHeadline had already rendered, so the first valid preamble arriving after the draft opened rendered through the structured-line path and the headline never appeared until the next event. The renderer and formatLine now read the compositor's sanitized hasStatusHeadline directly and the mirror flag is deleted; adds an ordering regression test. LOC baseline lowered to the exact new count. Refs #105872 * fix(channels): let the opt-in commentary lane own preambles and scope narration docs to Discord With streaming.progress.commentary enabled, the status headline would replace the documented interleaved 💬 lines for any turn with a preamble (the exact event the lane consumes). The compositor headline now declines when the commentary lane is enabled, preserving that lane's shipped shape, and docs state the utility-model filler is Discord-only today (Telegram's headline is preamble-only; it has no narrator wiring). Refs #105872 * fix(msteams): cancel the pending progress gate at finalize With the purely time-based gate, every fast Teams progress turn leaves a pending 5s start timer; the SDK resets its stream id on close, so a late timer fired renderInformativeUpdate against the closed stream and posted a stale working card below the final answer. finalize() now cancels the gate and the informative renderer refuses to run once final text is queued. Refs #105872 * docs(channels): record the headline-vs-commentary ownership decision inline Refs #105872 * style: oxfmt the late review-fix rounds Four files from the final review-fix commits were never oxfmt-formatted and failed the shared CI formatting step; baseline lowered for the one-line telegram shrink. Refs #105872 * fix(channels): harden progress draft lifecycle * fix(channels): refresh repeated preamble items * fix(plugin-sdk): expose progress lifecycle options * style(auto-reply): simplify narrator reset * docs(channels): refresh progress draft docs map * fix(auto-reply): clear queued narrator request context * fix(discord): cancel delayed progress gate at final * docs: refresh plugin sdk api baseline
529 lines
18 KiB
TypeScript
529 lines
18 KiB
TypeScript
// Stateful progress-draft compositor for channel streaming previews.
|
|
// It merges status, tool, reasoning, and commentary updates until the final reply replaces them.
|
|
import { removeChannelProgressDraftLine } from "./progress-draft-lines.js";
|
|
import {
|
|
formatReasoningProgressDisplayLine,
|
|
mergeReasoningProgressText,
|
|
normalizeCommentaryProgressText,
|
|
normalizeReasoningProgressLine,
|
|
sanitizeProgressStatusText,
|
|
} from "./progress-draft-status-text.js";
|
|
import {
|
|
createChannelProgressDraftGate,
|
|
type ChannelProgressDraftLine,
|
|
formatChannelProgressDraftText,
|
|
isChannelProgressDraftWorkToolName,
|
|
mergeChannelProgressDraftLine,
|
|
normalizeChannelProgressDraftLineIdentity,
|
|
resolveChannelProgressDraftMaxLineChars,
|
|
resolveChannelProgressDraftMaxLines,
|
|
resolveChannelStreamingProgressCommentary,
|
|
resolveChannelStreamingPreviewToolProgress,
|
|
resolveChannelStreamingSuppressDefaultToolProgressMessages,
|
|
type StreamingCompatEntry,
|
|
type StreamingMode,
|
|
} from "./streaming.js";
|
|
|
|
// A recent model preamble remains the primary status; utility narration fills
|
|
// the slot only after the model has been quiet for this interval. Exported for
|
|
// the narrator, deliberately not re-exported through the SDK barrels.
|
|
export const PROGRESS_STATUS_PREAMBLE_FRESH_MS = 20_000;
|
|
|
|
// Composes transient channel progress drafts from tool, reasoning, and
|
|
// commentary updates. It owns draft lifecycle state before the final reply wins.
|
|
export type ChannelProgressDraftMode = StreamingMode;
|
|
|
|
export type ChannelProgressDraftCompositor = ReturnType<
|
|
typeof createChannelProgressDraftCompositor
|
|
>;
|
|
export type ChannelProgressDraftCompositorLine = string | ChannelProgressDraftLine;
|
|
export type ChannelProgressDraftUpdateOptions = {
|
|
flush?: boolean;
|
|
lines?: readonly ChannelProgressDraftCompositorLine[];
|
|
};
|
|
|
|
/** Creates a stateful compositor for one streaming channel reply. */
|
|
export function createChannelProgressDraftCompositor(params: {
|
|
entry: StreamingCompatEntry | null | undefined;
|
|
mode: ChannelProgressDraftMode;
|
|
active: boolean;
|
|
seed: string;
|
|
update: (text: string, options?: ChannelProgressDraftUpdateOptions) => Promise<void> | void;
|
|
deleteCurrent?: () => Promise<void> | void;
|
|
tryNativeUpdate?: (text: string) => Promise<boolean> | boolean;
|
|
formatLine?: (line: string) => string;
|
|
isEmptyLine?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
|
|
shouldStartNow?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
|
|
reasoningLinePrefix?: string;
|
|
commentaryLinePrefix?: string;
|
|
reasoningGate?: boolean;
|
|
commentaryItalics?: boolean;
|
|
now?: () => number;
|
|
setTimeoutFn?: typeof setTimeout;
|
|
clearTimeoutFn?: typeof clearTimeout;
|
|
}) {
|
|
const now = params.now ?? Date.now;
|
|
const setTimeoutFn = params.setTimeoutFn ?? setTimeout;
|
|
const clearTimeoutFn = params.clearTimeoutFn ?? clearTimeout;
|
|
const reasoningLinePrefix = params.reasoningLinePrefix ?? "";
|
|
const commentaryLinePrefix = params.commentaryLinePrefix ?? "";
|
|
const commentaryItalics = params.commentaryItalics ?? true;
|
|
const stripLaneItalics = (text: string): string =>
|
|
text
|
|
.split("\n")
|
|
.map((line) => line.replace(/^_(.*)_$/su, "$1"))
|
|
.join("\n");
|
|
const previewToolProgressEnabled =
|
|
params.active && resolveChannelStreamingPreviewToolProgress(params.entry);
|
|
const commentaryProgressEnabled =
|
|
params.active && resolveChannelStreamingProgressCommentary(params.entry);
|
|
const thinkingProgressEnabled =
|
|
params.active && (params.reasoningGate ?? previewToolProgressEnabled);
|
|
const suppressDefaultToolProgressMessages =
|
|
params.active &&
|
|
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.entry, {
|
|
draftStreamActive: true,
|
|
previewToolProgressEnabled,
|
|
});
|
|
let progressSuppressed = false;
|
|
let lines: ChannelProgressDraftCompositorLine[] = [];
|
|
let lastRenderedText = "";
|
|
let reasoningRawText = "";
|
|
let lastReasoningLine: string | undefined;
|
|
// Model preambles and narration share the status slot while tool lines keep
|
|
// accumulating underneath for turns where neither source is available.
|
|
let preambleText = "";
|
|
let preambleItemId: string | undefined;
|
|
let preambleAt: number | undefined;
|
|
let narrationText = "";
|
|
let finalReplyStarted = false;
|
|
let finalReplyDelivered = false;
|
|
let preambleExpiryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
const clearPreambleExpiryTimer = () => {
|
|
if (preambleExpiryTimer !== undefined) {
|
|
clearTimeoutFn(preambleExpiryTimer);
|
|
preambleExpiryTimer = undefined;
|
|
}
|
|
};
|
|
|
|
const resolveStatusText = () => {
|
|
const preambleIsFresh =
|
|
preambleAt !== undefined && now() - preambleAt < PROGRESS_STATUS_PREAMBLE_FRESH_MS;
|
|
return preambleText && (preambleIsFresh || !narrationText) ? preambleText : narrationText;
|
|
};
|
|
|
|
const formatDraftText = (draftLines = lines, options?: { formatted?: boolean }) =>
|
|
formatChannelProgressDraftText({
|
|
entry: params.entry,
|
|
lines: draftLines,
|
|
seed: params.seed,
|
|
formatLine: options?.formatted === false ? undefined : params.formatLine,
|
|
narration: resolveStatusText() || undefined,
|
|
});
|
|
|
|
const clearProgressState = (suppressed: boolean) => {
|
|
clearPreambleExpiryTimer();
|
|
progressSuppressed = suppressed;
|
|
lines = [];
|
|
lastRenderedText = "";
|
|
reasoningRawText = "";
|
|
lastReasoningLine = undefined;
|
|
preambleText = "";
|
|
preambleItemId = undefined;
|
|
preambleAt = undefined;
|
|
narrationText = "";
|
|
};
|
|
|
|
const render = async (options?: { flush?: boolean }): Promise<boolean> => {
|
|
if (!params.active || params.mode !== "progress" || finalReplyStarted || finalReplyDelivered) {
|
|
return false;
|
|
}
|
|
const text = formatDraftText();
|
|
if (!text || text === lastRenderedText) {
|
|
return false;
|
|
}
|
|
lastRenderedText = text;
|
|
await params.update(text, { ...options, lines: [...lines] });
|
|
return true;
|
|
};
|
|
|
|
const schedulePreambleExpiryRefresh = () => {
|
|
clearPreambleExpiryTimer();
|
|
if (
|
|
!preambleText ||
|
|
!narrationText ||
|
|
preambleAt === undefined ||
|
|
!gate.hasStarted ||
|
|
finalReplyStarted ||
|
|
finalReplyDelivered
|
|
) {
|
|
return;
|
|
}
|
|
const remaining = PROGRESS_STATUS_PREAMBLE_FRESH_MS - (now() - preambleAt);
|
|
if (remaining <= 0) {
|
|
return;
|
|
}
|
|
preambleExpiryTimer = setTimeoutFn(() => {
|
|
preambleExpiryTimer = undefined;
|
|
void render().catch((err: unknown) => {
|
|
console.warn(`[progress-draft] channel progress status refresh failed: ${String(err)}`);
|
|
});
|
|
}, remaining);
|
|
};
|
|
|
|
const gate = createChannelProgressDraftGate({
|
|
onStart: async () => {
|
|
await render({ flush: true });
|
|
schedulePreambleExpiryRefresh();
|
|
},
|
|
});
|
|
|
|
const clearLine = async (lineId: string) => {
|
|
const nextLines = removeChannelProgressDraftLine(lines, lineId);
|
|
if (nextLines === lines) {
|
|
return;
|
|
}
|
|
lines = nextLines;
|
|
if (!gate.hasStarted) {
|
|
return;
|
|
}
|
|
const text = formatDraftText();
|
|
if (text) {
|
|
await render();
|
|
return;
|
|
}
|
|
lastRenderedText = "";
|
|
await params.deleteCurrent?.();
|
|
};
|
|
|
|
const noteProgress = async (
|
|
line?: ChannelProgressDraftCompositorLine,
|
|
options?: { toolName?: string; startImmediately?: boolean },
|
|
) => {
|
|
if (!params.active || finalReplyStarted || finalReplyDelivered) {
|
|
return false;
|
|
}
|
|
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
|
|
return false;
|
|
}
|
|
if (params.isEmptyLine?.(line)) {
|
|
return false;
|
|
}
|
|
const normalized = normalizeChannelProgressDraftLineIdentity(line);
|
|
if (!normalized || progressSuppressed) {
|
|
return false;
|
|
}
|
|
if (params.mode !== "progress" && !previewToolProgressEnabled) {
|
|
return false;
|
|
}
|
|
const progressLine = typeof line === "object" && line !== undefined ? line : normalized;
|
|
const shouldStoreLine = previewToolProgressEnabled;
|
|
const nextLines = shouldStoreLine
|
|
? mergeChannelProgressDraftLine(lines, progressLine, {
|
|
maxLines: resolveChannelProgressDraftMaxLines(params.entry),
|
|
})
|
|
: lines;
|
|
if (shouldStoreLine && nextLines === lines) {
|
|
return false;
|
|
}
|
|
// A work line lands between reasoning bursts: commit the current thinking
|
|
// line so the next thought appends as its own line, interleaved with tools
|
|
// in arrival order, instead of replacing the prior thought.
|
|
if (shouldStoreLine) {
|
|
reasoningRawText = "";
|
|
lastReasoningLine = undefined;
|
|
}
|
|
if (shouldStoreLine && params.tryNativeUpdate) {
|
|
// Native draft updates get unformatted text; if the channel accepts it,
|
|
// keep local state aligned without sending a generic draft message.
|
|
const text = formatDraftText(nextLines, { formatted: false });
|
|
if (text && (await params.tryNativeUpdate(text))) {
|
|
lines = nextLines;
|
|
lastRenderedText = text;
|
|
return true;
|
|
}
|
|
}
|
|
lines = nextLines;
|
|
if (params.mode !== "progress") {
|
|
if (!shouldStoreLine) {
|
|
return false;
|
|
}
|
|
const text = formatDraftText();
|
|
if (!text || text === lastRenderedText) {
|
|
return false;
|
|
}
|
|
lastRenderedText = text;
|
|
await params.update(text, { lines: [...lines] });
|
|
return true;
|
|
}
|
|
if (options?.startImmediately || params.shouldStartNow?.(line)) {
|
|
const alreadyStarted = gate.hasStarted;
|
|
await gate.startNow();
|
|
if (!gate.hasStarted) {
|
|
return false;
|
|
}
|
|
return alreadyStarted ? await render() : true;
|
|
}
|
|
const alreadyStarted = gate.hasStarted;
|
|
const progressActive = await gate.noteWork();
|
|
if ((alreadyStarted || progressActive) && gate.hasStarted) {
|
|
return await render();
|
|
}
|
|
return false;
|
|
};
|
|
|
|
return {
|
|
get previewToolProgressEnabled() {
|
|
return previewToolProgressEnabled;
|
|
},
|
|
get commentaryProgressEnabled() {
|
|
return commentaryProgressEnabled;
|
|
},
|
|
get suppressDefaultToolProgressMessages() {
|
|
return suppressDefaultToolProgressMessages;
|
|
},
|
|
get hasStarted() {
|
|
return gate.hasStarted;
|
|
},
|
|
get isVisible() {
|
|
return gate.hasStarted && !finalReplyStarted && !finalReplyDelivered;
|
|
},
|
|
get hasStatusHeadline() {
|
|
return Boolean(preambleText);
|
|
},
|
|
markFinalReplyStarted() {
|
|
finalReplyStarted = true;
|
|
// Final delivery must disarm the delayed start before async delivery work.
|
|
// Queued turns reopen the gate through beginNewTurn().
|
|
gate.cancel();
|
|
clearPreambleExpiryTimer();
|
|
},
|
|
markFinalReplyDelivered() {
|
|
finalReplyDelivered = true;
|
|
clearPreambleExpiryTimer();
|
|
},
|
|
// Queued/followup turns reuse this compositor after the primary turn's
|
|
// final reply settled it. Re-arm the draft lanes so the queued turn gets
|
|
// the same in-progress rendering as a primary turn (path independence).
|
|
beginNewTurn() {
|
|
if (!finalReplyStarted && !finalReplyDelivered) {
|
|
return false;
|
|
}
|
|
finalReplyStarted = false;
|
|
finalReplyDelivered = false;
|
|
gate.reset();
|
|
clearProgressState(false);
|
|
return true;
|
|
},
|
|
reset() {
|
|
clearProgressState(false);
|
|
},
|
|
resetReasoningProgress() {
|
|
reasoningRawText = "";
|
|
},
|
|
suppress() {
|
|
clearProgressState(true);
|
|
},
|
|
cancel() {
|
|
gate.cancel();
|
|
clearPreambleExpiryTimer();
|
|
},
|
|
start() {
|
|
return gate.startNow();
|
|
},
|
|
async noteActivity(options?: { startImmediately?: boolean }) {
|
|
if (
|
|
!params.active ||
|
|
params.mode !== "progress" ||
|
|
progressSuppressed ||
|
|
finalReplyStarted ||
|
|
finalReplyDelivered
|
|
) {
|
|
return false;
|
|
}
|
|
if (options?.startImmediately) {
|
|
await gate.startNow();
|
|
return gate.hasStarted ? await render({ flush: true }) : false;
|
|
}
|
|
const alreadyStarted = gate.hasStarted;
|
|
const progressActive = await gate.noteWork();
|
|
if ((alreadyStarted || progressActive) && gate.hasStarted) {
|
|
return await render();
|
|
}
|
|
return false;
|
|
},
|
|
pushToolProgress: noteProgress,
|
|
async pushPreambleHeadline(text?: string, options?: { itemId?: string }) {
|
|
if (!params.active || params.mode !== "progress" || progressSuppressed) {
|
|
return false;
|
|
}
|
|
// The opt-in commentary lane already renders every preamble as an
|
|
// interleaved 💬 line; letting the headline also consume it would
|
|
// replace those documented lines with a duplicate status paragraph.
|
|
// Deliberate: the headline itself is default-on presentation of the
|
|
// typed preamble (owner decision, #105872); `commentary` only picks the
|
|
// interleaved-lane presentation, it is not a preamble kill switch.
|
|
if (commentaryProgressEnabled) {
|
|
return false;
|
|
}
|
|
if (finalReplyStarted || finalReplyDelivered) {
|
|
return false;
|
|
}
|
|
const itemId = options?.itemId?.trim() || undefined;
|
|
const normalized = sanitizeProgressStatusText(text ?? "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
if (!normalized) {
|
|
// Retractions must identify the currently displayed preamble. A late
|
|
// retraction for an older item must not clear a newer headline.
|
|
if (!itemId || itemId !== preambleItemId) {
|
|
return false;
|
|
}
|
|
preambleText = "";
|
|
preambleItemId = undefined;
|
|
preambleAt = undefined;
|
|
clearPreambleExpiryTimer();
|
|
if (!gate.hasStarted) {
|
|
return false;
|
|
}
|
|
const rendered = await render();
|
|
if (rendered || formatDraftText()) {
|
|
return rendered;
|
|
}
|
|
lastRenderedText = "";
|
|
await params.deleteCurrent?.();
|
|
return true;
|
|
}
|
|
const isNewPreambleItem = Boolean(itemId && itemId !== preambleItemId);
|
|
if (isNewPreambleItem) {
|
|
preambleItemId = itemId;
|
|
} else if (!itemId) {
|
|
preambleItemId = undefined;
|
|
}
|
|
if (normalized === preambleText && !isNewPreambleItem) {
|
|
return false;
|
|
}
|
|
preambleText = normalized;
|
|
preambleAt = now();
|
|
schedulePreambleExpiryRefresh();
|
|
// Work activity owns the delayed start gate. Retain preambles from fast
|
|
// turns without making their draft visible.
|
|
return gate.hasStarted ? await render() : false;
|
|
},
|
|
async pushNarrationProgress(text?: string) {
|
|
if (!params.active || params.mode !== "progress" || progressSuppressed) {
|
|
return false;
|
|
}
|
|
if (finalReplyStarted || finalReplyDelivered) {
|
|
return false;
|
|
}
|
|
const normalized = text?.replace(/\s+/g, " ").trim() ?? "";
|
|
if (normalized === narrationText) {
|
|
return false;
|
|
}
|
|
if (!normalized) {
|
|
// Release stopped narration without retracting the model's headline;
|
|
// raw tool lines return only when no preamble remains.
|
|
narrationText = "";
|
|
clearPreambleExpiryTimer();
|
|
return await render();
|
|
}
|
|
narrationText = normalized;
|
|
schedulePreambleExpiryRefresh();
|
|
// Tool activity owns the delayed start gate. Narration may arrive while
|
|
// that timer is pending; retain the newest text without flashing a draft
|
|
// for a turn that finishes inside the grace period.
|
|
return gate.hasStarted ? await render() : false;
|
|
},
|
|
async pushReasoningProgress(text?: string, options?: { snapshot?: boolean }) {
|
|
if (
|
|
!params.active ||
|
|
params.mode !== "progress" ||
|
|
!text ||
|
|
progressSuppressed ||
|
|
finalReplyDelivered ||
|
|
!thinkingProgressEnabled
|
|
) {
|
|
return false;
|
|
}
|
|
reasoningRawText = mergeReasoningProgressText(reasoningRawText, text, {
|
|
snapshot: options?.snapshot === true,
|
|
});
|
|
const normalized = normalizeReasoningProgressLine(reasoningRawText);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
const compactLine = formatReasoningProgressDisplayLine(
|
|
normalized,
|
|
resolveChannelProgressDraftMaxLineChars(params.entry),
|
|
);
|
|
if (!compactLine) {
|
|
return false;
|
|
}
|
|
const displayLine = `${reasoningLinePrefix}${compactLine}`;
|
|
// Reasoning streams usually arrive as deltas. Replace the previous
|
|
// reasoning line so the draft stays compact instead of appending noise.
|
|
const priorIndex =
|
|
lastReasoningLine === undefined ? -1 : lines.lastIndexOf(lastReasoningLine);
|
|
if (priorIndex >= 0) {
|
|
lines = [...lines];
|
|
lines[priorIndex] = displayLine;
|
|
} else {
|
|
lines = [...lines, displayLine].slice(-resolveChannelProgressDraftMaxLines(params.entry));
|
|
}
|
|
lastReasoningLine = displayLine;
|
|
const progressActive = await gate.noteWork();
|
|
if (progressActive && gate.hasStarted) {
|
|
return await render();
|
|
}
|
|
return false;
|
|
},
|
|
async pushCommentaryProgress(text?: string, options?: { itemId?: string }) {
|
|
if (!params.active || params.mode !== "progress" || !commentaryProgressEnabled) {
|
|
return false;
|
|
}
|
|
if (finalReplyStarted || finalReplyDelivered) {
|
|
return false;
|
|
}
|
|
const itemId = options?.itemId?.trim();
|
|
if (!text && !itemId) {
|
|
return false;
|
|
}
|
|
const normalized = normalizeCommentaryProgressText(text ?? "");
|
|
const lineId = itemId ? `commentary:${itemId}` : normalized ? `commentary:${normalized}` : "";
|
|
if (!normalized) {
|
|
// Empty commentary with an item id means the producer retracted that
|
|
// item; remove its draft line if it was already rendered.
|
|
if (lineId) {
|
|
await clearLine(lineId);
|
|
}
|
|
return false;
|
|
}
|
|
const line: ChannelProgressDraftLine = {
|
|
id: lineId,
|
|
// The lane marker (💬, matching 🧠 thinking / 🛠️ tools) is a per-channel
|
|
// presentation choice supplied via commentaryLinePrefix; default none.
|
|
text: `${commentaryLinePrefix}${commentaryItalics ? normalized : stripLaneItalics(normalized)}`,
|
|
kind: "item",
|
|
label: "Commentary",
|
|
prefix: false,
|
|
};
|
|
lines = mergeChannelProgressDraftLine(lines, line, {
|
|
maxLines: resolveChannelProgressDraftMaxLines(params.entry),
|
|
});
|
|
const alreadyStarted = gate.hasStarted;
|
|
await gate.startNow();
|
|
if (!gate.hasStarted) {
|
|
return false;
|
|
}
|
|
if (alreadyStarted) {
|
|
await render();
|
|
}
|
|
// True means the sanitized commentary was accepted into the visible
|
|
// lane. A first item renders inside gate.onStart, not this call site.
|
|
return true;
|
|
},
|
|
};
|
|
}
|