ouroboros/web/modules/chat_activity.js

1437 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Pure chat-activity helpers shared by chat.js and dependency-free node tests:
// live-card presentation projections (moved verbatim from chat.js) plus the
// in-flight direct/ephemeral turn status reducer and snapshot hydration.
import { executorIdentityMarkup, joinMetaParts } from './harness_presentation.js';
import { resultFilesItemHtml } from './result_files.js';
import { compactModel, formatLogDuration, modelExecutionLabel } from './log_events.js';
import { createSystemMessageActions } from './ui_helpers.js';
import { projectReference } from './project_reference.js';
import { joinMarkdownHeadings } from './utils.js';
import { REUSABLE_TASK_IDS } from './task_control_menu.js';
import {
accountedUpperBound,
accountedUpperBoundWithChildren,
escapeHtmlAttr,
escapeHtmlText as escapeHtml,
formatUsdWhole,
renderMarkdown,
} from './utils.js';
export function withTaskCostMeta(summary, payload, { replace = false, rawTs = '' } = {}) {
const projection = taskCostProjection(payload, rawTs);
// `replace` frames (task_done/task_cost_finalized) never keep the
// summarizer's own meta strings. Cost renders ONLY from the card's sticky
// record.costMeta (applyLiveCardState); summarizer-built `cost=` strings
// are dropped UNCONDITIONALLY — a frame without task-scope accounting
// evidence must show no money at all, not a bare per-call number.
const base = replace ? { ...summary, meta: [] } : summary;
const out = projection ? { ...base, costProjection: projection } : { ...base };
if (payload?.model_execution && typeof payload.model_execution === 'object') {
out.modelExecution = payload.model_execution;
}
if (Array.isArray(out.meta) && out.meta.length) {
out.meta = out.meta.filter((entry) => !String(entry || '').startsWith('cost='));
}
return out;
}
export function applyHistoricalModelExecution(record, historical) {
if (!record || record.modelExecution || !historical?.model_execution) return false;
record.modelExecution = historical.model_execution;
return true;
}
export function senderLabel(role, isProgress = false, systemType = '', opts = {}, chatSessionId = '') {
if (role === 'user') {
if (opts.source === 'telegram') return opts.senderLabel || 'Telegram';
if (opts.senderSessionId && opts.senderSessionId !== chatSessionId) {
return `WebUI (${opts.senderSessionId.slice(0, 8)})`;
}
return opts.senderLabel || 'You';
}
if (role === 'system') {
if (systemType === 'task_summary') return '📋 Task Summary';
if (systemType === 'skill_review') return '📋 Skill Review';
return '📋 System';
}
if (isProgress) return '💬 Thought';
// A self-initiated turn (a consciousness wake-up) signs its final bubble
// through the same sender line the user bubble uses for its source.
if (opts.initiator === 'consciousness') return 'Ouroboros · Consciousness';
return 'Ouroboros';
}
export function isLiveLineExpandable(item) {
return Boolean(
(item.fullHeadline && item.fullHeadline !== item.headline)
|| (item.fullBody && item.fullBody !== item.body)
// P3: even when the preview equals the capped body, a server-truncated line
// with a fetch ref has MORE to show (the genuinely-full output on demand).
|| (item.truncated && item.fullRef)
);
}
export function buildTimelineItemHtml(item, record) {
if (item.resultArtifacts) return resultFilesItemHtml(item);
const expandable = isLiveLineExpandable(item);
const expanded = expandable && record.expandedLineKeys.has(item.lineKey);
const displayHeadline = expanded && item.fullHeadline ? item.fullHeadline : item.headline;
// P3: when expanded, prefer the genuinely-full fetched output, then the capped
// fullBody, then the preview body. A server-truncated line shows the fetched full
// text in a bounded-scroll box so a huge research output never grows the chat.
const displayBody = expanded ? (item.fetchedFull || item.fullBody || item.body) : item.body;
const showingFetched = expanded && Boolean(item.fetchedFull);
const loadingFull = expanded && Boolean(item.truncated && item.fullRef && !item.fetchedFull);
const isProgressLine = item.phase === 'working' || item.phase === 'thinking';
const bodyId = `chat-live-line-body-${String(record.groupId || 'task').replace(/[^A-Za-z0-9_-]/g, '-')}-${String(item.lineKey || '').replace(/[^A-Za-z0-9_-]/g, '-')}`;
const headContent = `
<span class="chat-live-line-title"${isProgressLine ? ' data-chat-markdown-enhanced' : ''}>${isProgressLine ? renderMarkdown(displayHeadline, { inlineHeadingBreaks: true }) : escapeHtml(displayHeadline)}</span>
<span class="chat-live-line-repeat" ${item.count > 1 ? '' : 'hidden'}>${item.count > 1 ? `${item.count}x` : ''}</span>
${item.ts ? `<span class="chat-live-line-time">${escapeHtml(item.ts)}</span>` : ''}
`;
const headHtml = expandable
? `
<div
role="button" tabindex="0"
class="chat-live-line-toggle"
data-live-line-toggle="${escapeHtmlAttr(item.lineKey)}"
aria-expanded="${expanded ? 'true' : 'false'}"
${displayBody ? `aria-controls="${escapeHtmlAttr(bodyId)}"` : ''}
>
<span class="chat-live-line-head">${headContent}</span>
<span class="chat-live-line-expand-label">${expanded ? 'Collapse' : ((item.truncated && item.fullRef) ? 'Show full' : 'Expand')}</span>
</div>
`
: `<div class="chat-live-line-head">${headContent}</div>`;
return `
<div
class="chat-live-line ${item.phase || 'working'}${expandable ? ' expandable' : ''}"
data-live-line-key="${escapeHtmlAttr(item.lineKey || '')}"
${item.historyId ? `data-history-id="${escapeHtmlAttr(item.historyId)}"` : ''}
data-expanded="${expanded ? '1' : '0'}"
>
${headHtml}
${displayBody ? `<div class="chat-live-line-body${showingFetched ? ' chat-live-line-body-full' : ''}" id="${escapeHtmlAttr(bodyId)}">${renderMarkdown(displayBody, { inlineHeadingBreaks: true })}${loadingFull ? '<div class="chat-live-line-loading">Loading full output…</div>' : ''}</div>` : ''}
</div>
`;
}
// ---------------------------------------------------------------------------
// Folded tool evidence (docs/DESIGN.md "Conversation activity block").
// ---------------------------------------------------------------------------
/**
* A turn's routine execution is ONE row, whatever a burst costs in calls, so
* the narration around it stays readable. The accumulator is a state map keyed
* by invocation rather than a pair of counters: duplicate, reordered and
* concurrent frames all settle on the same totals, and the host's own numbers
* replace them without either side double counting.
*/
function ensureToolFold(record) {
if (!record.toolFold) record.toolFold = { calls: new Map(), host: null };
return record.toolFold;
}
/**
* One frame's fact about one invocation: {key, status, receipt, tool}. Status
* preserves independent start/wait/settlement facts. Reordered starts never reopen
* a settled call; a true result replaces only a provisional host-error settlement.
*/
export function noteToolCall(record, observation) {
const key = observation?.key;
if (!record || !key) return record;
const { calls } = ensureToolFold(record);
const prev = calls.get(key);
const fact = observation.fact || (['ok', 'error'].includes(observation.status) ? 'settled' : 'started');
const next = { ...prev,
receipt: Boolean(observation.receipt) && (prev ? prev.receipt : true),
tool: observation.tool || prev?.tool || '',
};
if (fact === 'settled') {
if (!next.settlement || (next.settlement.hostError && !observation.hostError)) {
next.settlement = { status: observation.status, hostError: Boolean(observation.hostError) };
}
} else if (fact === 'wait_ended') next.waitEnded = true;
else next.started = true;
next.live = !record.finished && (next.live || observation.live === true || (!observation.fact && observation.status === 'calling'));
next.status = next.settlement?.status || (next.waitEnded ? 'wait_ended' : next.live ? 'calling' : 'unknown');
calls.set(key, next);
return record;
}
/**
* The host's totals for the turn, merged FIELD-WISE onto what the host already
* stated. An ABSENT field (null/undefined) stays absent and keeps the previous
* known value: a partial snapshot that carries `tool_calls` alone must not read
* as "no addressing calls" and turn a block that only addressed work into
* content, and it must not erase an error or routing count a complete snapshot
* already gave. `counts` is known only as a NON-EMPTY object, so an empty or
* absent `tool_call_counts` keeps the live map's names and the row behind Expand
* is never explicitly emptied while the turn counts calls.
*/
export function noteToolHostMetrics(record, host) {
for (const observation of host?.evidence?.observations || []) applyToolObservation(record, observation);
const fold = ensureToolFold(record);
if (host?.evidence?.coverage) { fold.coverage = host.evidence.coverage; fold.legacy = host.evidence.legacy; }
if (record.finished) for (const call of fold.calls.values()) {
call.live = false;
call.status = call.settlement?.status || (call.waitEnded ? 'wait_ended' : 'unknown');
}
const known = fold.host || {};
const carry = (next, before) => (next === null || next === undefined ? (before ?? null) : next);
const counts = host?.counts && typeof host.counts === 'object' && Object.keys(host.counts).length > 0
? host.counts : (known.counts ?? null);
fold.host = {
calls: carry(host?.calls, known.calls),
errors: carry(host?.errors, known.errors),
routing: carry(host?.routing, known.routing),
counts,
};
return toolEvidenceView(record.toolFold);
}
/**
* One frame about one invocation, applied to the block's fold: the map first,
* then the row it owns, rebuilt from the map and the host's totals together.
* The meta counts follow the same reading, so the header never disagrees with
* the row while a turn runs.
*/
export function applyToolObservation(record, observation) {
noteToolCall(record, observation);
const view = toolEvidenceView(record.toolFold);
record.toolCalls = view.calls;
record.toolErrors = view.errors;
// Successful settlement retires an earlier provisional error/wait notice;
// the fold retains the independent wait fact, including after task terminal.
if (record.toolFold.calls.get(observation.key)?.settlement?.status === 'ok' && record.items) {
const count = record.items.length;
record.items = record.items.filter(item => item.dedupeKey !== observation.key);
view.clearedNotice = count !== record.items.length;
}
return view;
}
const perToolLine = (entries) => entries
.filter(([name, n]) => name && n > 0)
.map(([name, n]) => (n > 1 ? `${name} ×${n}` : name)).join(' · ');
/**
* The block's one tool row, built from the live map and the host's totals
* together: the host answers what it stated, the live map answers the rest.
* The closed row carries the counts only (summary outranks details); the
* per-tool names live behind Expand, so the row stays one line either way.
*/
export function toolEvidenceView(fold = null) {
const live = fold?.calls instanceof Map ? [...fold.calls.values()] : [];
const host = fold?.host || null;
const observed = live.length + (fold?.legacy?.calls || 0);
const calls = Math.max(Number.isInteger(host?.calls) ? host.calls : 0, observed);
// Frozen totals count model wait errors. Canonical evidence reports operation
// outcomes; a bounded partial read discloses its gap instead of reviving waits.
const partial = Boolean(fold?.coverage) && observed > 0 && observed < calls;
const errors = observed >= calls || partial ? live.filter(call => call.status === 'error').length + (fold?.legacy?.errors || 0)
: (Number.isInteger(host?.errors) ? host.errors : live.filter(call => call.status === 'error').length);
const liveCounts = new Map();
for (const call of live) liveCounts.set(call.tool, (liveCounts.get(call.tool) || 0) + 1);
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
return {
phase: errors > 0 ? 'warn'
: ((!host && live.some((call) => call.status === 'calling')) ? 'calling' : 'result'),
headline: `${plural(calls, 'tool call')}${errors > 0 ? ` · ${plural(errors, 'error')}` : ''}`
+ (live.some(call => call.waitEnded) || fold?.legacy?.wait_ended ? ' · wait ended' : '')
+ (live.some(call => call.status === 'unknown') || fold?.legacy?.unknown || partial ? ' · outcome unknown' : ''),
body: '',
fullBody: (partial ? 'Invocation evidence is incomplete. ' : '') + perToolLine(host?.counts && typeof host.counts === 'object'
? Object.entries(host.counts) : [...liveCounts]),
visible: true,
// Addressing calls report themselves on the owner's message, so a block
// that ran nothing else stands on nothing. The host's count decides when
// it stated one; otherwise the live map decides, but only while it
// accounts for every counted call. Knowing neither means content.
receipt: errors <= 0 && (Number.isInteger(host?.routing) ? host.routing >= calls
: live.length >= calls && live.length > 0 && live.every((call) => call.receipt)),
calls,
errors,
};
}
// Sortable data-ts stamping for timeline nodes; anchor mode only ever moves a
// node's effective timestamp earlier so replay cannot teleport it downward.
export function stampNodeTimestamp(node, raw, { anchor = false } = {}) {
if (!node) return false;
const epoch = rawTimestampEpoch(raw);
if (!Number.isFinite(epoch)) return false;
if (anchor && node.dataset.ts) {
const current = Number(node.dataset.ts);
const next = Number.isFinite(current) ? Math.min(current, epoch) : epoch;
if (node.dataset.ts !== String(next)) node.dataset.ts = String(next);
return Number.isFinite(current) && next < current;
}
if (node.dataset.ts !== String(epoch)) node.dataset.ts = String(epoch);
return false;
}
export function durableChatMediaUrl(value) {
const url = String(value || '');
return /^\/api\/tasks\/[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\/artifacts\/chat-media-[0-9a-f]{64}\.(png|jpg|gif|webp|mp4|webm)$/.test(url) ? url : '';
}
export function chatMediaMessageKey(msg) {
return [
msg.msg_type || msg.type,
String(msg.task_id || ''),
String(msg.ts || ''),
String(msg.caption || ''),
String(msg.mime || ''),
].join('|');
}
export function documentMessageKey(msg) {
return [
'document',
String(msg.task_id || ''),
String(msg.ts || ''),
String(msg.download_url || ''),
String(msg.filename || ''),
String(msg.caption || ''),
].join('|');
}
export function pendingAttachmentBytes(items = []) {
return items.reduce((total, item) => total + Number(item.file?.size || 0), 0);
}
export function isFileDrag(event) {
return Array.from(event.dataTransfer?.types || []).includes('Files');
}
export function isNonTerminalMediaHistoryRow(msg) {
return msg.system_type === 'photo' || msg.system_type === 'video';
}
/**
* A history row that carries replay evidence only (a recorded quiz answer, a
* hidden terminal projection) and mounts nothing: the one owner of that
* distinction for the replay passes and the pager's page-row count.
*/
export function isReplayEvidenceRow(row) {
return row?.system_type === 'quiz_answer' || Boolean(row?.summary_kind && row?.historical_terminal);
}
export function isForegroundLiveCard(record) {
return Boolean(
record?.root?.isConnected && !record.finished && !record.reviewAnchor && !record.historicalUnavailable && !record.historicalUnconfirmed
);
}
export function shouldFirePanic(dialogResult) {
return dialogResult === true;
}
export async function confirmAndSendPanic(deps) {
const decision = await deps.openConfirmDialog({
title: 'Panic — stop all workers',
body: 'Kill all workers immediately?',
confirmLabel: 'Kill all workers',
cancelLabel: 'Keep running',
danger: true,
});
if (shouldFirePanic(decision)) {
deps.ws.send({ type: 'command', cmd: '/panic' });
return true;
}
return false;
}
export function getOrCreateChatSessionId(storage, cryptoImpl, now = Date.now, random = Math.random) {
try {
const existing = storage.getItem('ouro_chat_session_id');
if (existing) return existing;
const created = cryptoImpl && typeof cryptoImpl.randomUUID === 'function'
? cryptoImpl.randomUUID()
: `chat-${now()}-${random().toString(16).slice(2)}`;
storage.setItem('ouro_chat_session_id', created);
return created;
} catch {
return `chat-${now()}-${random().toString(16).slice(2)}`;
}
}
export function projectIdFromTask(taskId = '', now = Date.now) {
const seed = String(taskId || '')
.toLowerCase()
.replace(/[^a-z0-9_.-]+/g, '-')
.replace(/^-+|-+$/g, '');
return (seed ? `task-${seed}` : `task-${now().toString(36)}`).slice(0, 64);
}
export function loadChatInputHistory(storage, key) {
try {
const raw = JSON.parse(storage.getItem(key) || '[]');
return Array.isArray(raw) ? raw.filter(Boolean).slice(-50) : [];
} catch {
return [];
}
}
export function saveChatInputHistory(storage, key, entries) {
try {
storage.setItem(key, JSON.stringify(entries.slice(-50)));
} catch {}
}
// Row-surface disclosure guard (v6.71.0), pure for node tests: returns the
// lineKey to toggle for a click landing on `target`, or '' when the click must
// NOT toggle (nested interactive element, or an active text selection inside
// the line).
export function liveLineRowToggleKey(target, selection = null) {
const line = target?.closest?.('.chat-live-line.expandable');
if (!line) return '';
const control = target.closest('button, a, input, textarea, select, label, summary, [contenteditable="true"], [role="button"]');
if (control && !control.matches?.('[data-live-line-toggle]')) return '';
if (selectionInside(line, selection)) return '';
return (line.dataset && line.dataset.liveLineKey) || '';
}
/** One listener owner survives keyed timeline patches and older-page replay. */
export function bindLiveCardTimeline(el, onActivate) {
if (!el) return () => {};
const onClick = (event) => {
const lineKey = liveLineRowToggleKey(event.target, el.ownerDocument?.getSelection?.() || globalThis.getSelection?.());
if (!lineKey) return;
event.stopPropagation();
onActivate(lineKey, event);
};
const onKeydown = (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
const header = event.target?.closest?.('[data-live-line-toggle]');
if (!header || !el.contains(header)) return;
const lineKey = liveLineRowToggleKey(event.target);
if (!lineKey) return;
event.preventDefault();
event.stopPropagation();
if (!event.repeat) onActivate(lineKey, event);
};
el.addEventListener('click', onClick);
el.addEventListener('keydown', onKeydown);
let released = false;
return () => {
if (released) return;
released = true;
el.removeEventListener('click', onClick);
el.removeEventListener('keydown', onKeydown);
};
}
/** Twins share a displayed role; their model is a separately labelled fact. */
export function subagentIdentityKey({ parentId = '', role = '' } = {}) {
return `${parentId}\u0000${subagentIdentityTitle({ role })}`;
}
export function subagentIdentityTitle({ role = '' } = {}) {
return String(role || '').trim() || 'Subagent';
}
export function subagentTwin(children, childId) {
const own = children.get(childId);
if (!own) return false;
const key = subagentIdentityKey(own);
let n = 0;
for (const c of children.values()) if (subagentIdentityKey(c) === key) n += 1;
return n > 1;
}
/**
* A non-collapsed text selection touching `el`: the reader is copying,
* not clicking, so a click-to-toggle surface must not fire (DESIGN.md §5).
*/
export function selectionInside(el, selection = globalThis.getSelection?.()) {
if (!el || !selection || selection.isCollapsed) return false;
if (el.contains?.(selection.anchorNode) || el.contains?.(selection.focusNode)) return true;
// Both endpoints can be outside a header while the selected range crosses it.
for (let i = 0; i < (selection.rangeCount || 0); i += 1) {
if (selection.getRangeAt(i).intersectsNode(el)) return true;
}
return false;
}
/**
* A click-to-toggle surface whose content stays selectable (DESIGN.md §5): a
* pointer click whose drag left a selection inside it does nothing, and Enter /
* Space activate it like a native button. The surface is a `div[role=button]`
* because WebKit never lets text inside a real <button> be selected. A surface that is a
* control only in some of its states passes `isActive`: while it says no, clicks and keys
* pass through untouched (nothing is stopped, so document-level handlers still see them).
*/
export function bindContentButton(el, onActivate, isActive = () => true) {
if (!el) return;
const nestedControl = (event) => {
const control = event.target?.closest?.('button, a, input, textarea, select, label, summary, [contenteditable="true"], [role="button"]');
return control && control !== el;
};
el.addEventListener('click', (event) => {
if (!isActive() || nestedControl(event) || (event.detail && selectionInside(el))) return;
event.stopPropagation?.();
onActivate(event);
});
el.addEventListener('keydown', (event) => {
if (isActive() && !nestedControl(event) && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
event.stopPropagation?.();
if (!event.repeat) el.click();
}
});
}
/** Convert a raw source timestamp to sortable epoch milliseconds. */
export function rawTimestampEpoch(raw) {
if (raw == null || raw === '') return NaN;
const epoch = typeof raw === 'number' ? raw : Date.parse(String(raw));
return Number.isFinite(epoch) ? epoch : NaN;
}
function optionalFiniteNumber(value) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
/** Pure presentation projection used by the header and dependency-free tests. */
export function headerBudgetPresentation(data) {
if (!data || data.accounting_loading === true) {
return { state: 'loading', label: 'Loading…', fillPct: 0 };
}
if (data?.accounting?.available === false) {
return { state: 'unavailable', label: 'Unavailable', fillPct: 0 };
}
// Older state shapes did not carry accounting.available. Keep accepting
// them when they contain a real numeric projection, but never coerce null
// (ledger failure in the new shape) into a convincing $0.
const spent = optionalFiniteNumber(data.spent_usd);
if (spent === null) {
return { state: 'unavailable', label: 'Unavailable', fillPct: 0 };
}
const rawLimit = optionalFiniteNumber(data.budget_limit);
const limit = rawLimit !== null && rawLimit > 0 ? rawLimit : 0;
const label = typeof data.budget_text === 'string' && data.budget_text.trim()
? data.budget_text
: `${formatUsdWhole(spent)} / ${limit > 0 ? formatUsdWhole(limit) : '∞'}`;
return {
state: 'available',
label,
fillPct: limit > 0 ? Math.min(100, Math.max(0, (spent / limit) * 100)) : 0,
};
}
/**
* Render task money without conflating unknown/non-final values with a final
* zero. The returned strings are card metadata, not another cost authority.
*/
/**
* Project the producer's scoped carrier (#498) into card meta. The carrier
* answers the whole question in one fact — which scope the number describes,
* whether a zero is EVIDENCED, whether anything in that scope is unpriced — so
* a frame that has one never re-derives it from two half-matching fields.
*
* `null` means "no carrier here, use the legacy derivation below".
*/
export function costPresentationMeta(presentation) {
if (!presentation || typeof presentation !== 'object') return null;
if (!presentation.has_rows) return presentation.accounting_open ? ['Cost unknown'] : [];
const amount = presentation.tracked_amount;
if (amount === null || amount === undefined || !Number.isFinite(Number(amount))) {
// No priced or bounded row evidenced anything: an empty ledger and a
// ledger of exclusively unpriced calls both sum to 0.0, and neither is a
// measured zero. The card says so instead of inventing a free result.
return ['Cost unknown'];
}
const money = `$${Number(amount).toFixed(2)}`;
if (presentation.has_unpriced) {
// Mixed: this is only the tracked subtotal. The reason is stated in WORDS
// beside it — a title attribute is invisible on touch, to assistive
// technology, and in a copied line.
return [`Tracked: ${presentation.tracked_final ? money : `up to ${money}`}`, 'some steps have no price'];
}
return [presentation.tracked_final ? money : `up to ${money}`];
}
export function taskCostMeta(payload = {}) {
// Presence means a VALUE, exactly as in `resolveCostPair`: a browser
// producer literal (chat.js `costMetaKeys`) materializes every cost name it
// knows, so a bare own property proves nothing about the frame. Counting
// those as evidence made an evidence-free terminal frame project
// "cost pending" and outrank a live ceiling on recency alone — the very
// thing this projection promises never to do.
const has = (key) => Object.prototype.hasOwnProperty.call(payload, key)
&& payload[key] !== undefined;
// Task-scope accounting evidence only (v6.82 P1): a bare `cost_usd` is NOT
// enough — llm_round_finished carries a per-round delta under that key, and
// rendering it as task cost lied on the card. Subagent progress_meta and
// task_done/task_cost_finalized frames carry cost_accounting_status /
// cost_final alongside cost_usd, so honest task-scope frames still qualify.
const hasAccountingEvidence = [
'cost_accounting_status', 'cost_final', 'cost_presentation',
'cost_usd_with_children', 'cost_with_children_partial',
'accounted_upper_bound_usd', 'accounted_upper_bound_usd_with_children',
'reserved_usd', 'unresolved_upper_bound_usd', 'unknown_unmetered',
].some(has);
if (!hasAccountingEvidence) return [];
if (payload.cost_accounting_status === 'unavailable') return ['cost unavailable'];
// #498: a producer that had a readable ledger but NO same-scope facts for
// this frame (a nested child's foreign subtree rollup) sends an explicit
// null carrier. That amount is unknown to this card, not unreadable: the
// owner vocabulary for an unknown amount is "Cost unknown" (DESIGN), while
// "cost unavailable" stays reserved for a ledger that could not be read.
// The projection below still ranks it `unavailable` so a narrower own zero
// cannot outrank it (mergeStickyCostMeta).
if (has('cost_presentation') && payload.cost_presentation === null) return ['Cost unknown'];
// #498: the producer's own carrier wins, because it was built from the exact
// ledger bucket it describes. The derivation below stays for legacy frames
// and is deliberately conservative: it may not know a zero is unevidenced.
const presented = costPresentationMeta(payload.cost_presentation);
if (presented) return presented;
// C2/F12: ONE precedence resolver, shared with the Python seams and with
// log_events — the deprecated alias wins a diverged pair, so the read side
// and the write side never pick opposite winners for the same record.
const own = accountedUpperBound(payload);
// Compact cards show one complete amount. Prefer the subtree projection
// when the producer has one; leaf/legacy frames still fall back to own.
const hasSubtree = has('accounted_upper_bound_usd_with_children') || has('cost_usd_with_children');
const total = hasSubtree ? accountedUpperBoundWithChildren(payload) : own;
const finalKnown = payload.cost_final === true
&& payload.cost_with_children_partial !== true;
const pendingKnown = payload.cost_final === false
|| payload.cost_with_children_partial === true
|| payload.cost_accounting_status === 'available' && !has('cost_final');
// ONE amount (owner decisions, 2026-09-02): the accounted upper bound already
// contains settled + reserved + unresolved (cost_projection.py), so the card
// states that number once and lets its wording carry the openness — a ceiling
// (`up to`) while the ledger is open, a plain amount once final. Calls with no
// known price are not named here (owner: no separate counter); component
// breakdowns and unmetered counts stay on Costs, Logs and task detail.
if (total === null) return ['Cost unknown'];
if (!(finalKnown || pendingKnown || total !== 0)) return [];
const amount = `$${total.toFixed(2)}`;
return [finalKnown ? amount : `up to ${amount}`];
}
/**
* Project one frame's task-scope cost evidence into the sticky structured form
* `{meta, ts, final}` (v6.82 P1). Returns null when the frame carries NO
* task-scope accounting evidence (e.g. an llm_round_finished per-round delta)
* — such frames must never touch a card's cost.
*/
export function taskCostProjection(payload = {}, rawTs = '') {
const meta = taskCostMeta(payload);
if (!meta.length) return null;
const unavailable = payload.cost_accounting_status === 'unavailable' || payload.cost_presentation === null;
const presentation = payload.cost_presentation;
const legacyRollup = payload.cost_presentation === null && (
payload.accounted_upper_bound_usd_with_children !== undefined || payload.cost_usd_with_children !== undefined);
return {
meta,
ts: rawTimestampEpoch(rawTs),
...(presentation?.scope ? { scope: presentation.scope } : legacyRollup ? { scope: 'rollup' } : {}),
// Only a SETTLED ledger value is final. "unavailable" is an honest
// unknown, not a settled truth: marking it final let one transient
// ledger-read failure outrank every later real reading. The scoped
// carrier answers this for its own scope when the frame has one (#498).
final: presentation && typeof presentation === 'object'
? !unavailable && presentation.tracked_final === true
&& presentation.has_unpriced === false && presentation.accounting_open === false
&& payload.cost_final !== false && payload.cost_with_children_partial !== true
: !unavailable && !meta.includes('Cost unknown') && payload.cost_final === true
&& payload.cost_with_children_partial !== true && !(Number(payload.unknown_unmetered) > 0)
&& !(Number(payload.non_final_rows) > 0) && !payload.ledger_integrity_degraded,
unavailable,
};
}
/**
* Sticky per-card cost precedence (v6.82 P1). Rank unavailable < pending < final:
* an honest reading always outranks an unknown (one transient ledger-read failure
* must not pin the card for the whole run) and a settled value outranks both.
* Among equal rank the newer raw source timestamp wins, so an older history replay
* can never overwrite newer evidence; frames without evidence (null `next`) keep
* the previous projection, so an unavailable snapshot is still sticky.
*/
export function mergeStickyCostMeta(previous, next) {
if (!next || !Array.isArray(next.meta) || !next.meta.length) return previous || null;
if (!previous || !Array.isArray(previous.meta) || !previous.meta.length) return next;
if (previous.scope === 'root_tree' && next.scope !== 'root_tree') return previous;
if (next.scope === 'root_tree' && previous.scope !== 'root_tree') return next;
if (previous.scope === 'rollup' && next.scope === 'own') return previous;
if (next.scope === 'rollup' && previous.scope === 'own') return next;
// Rank: unavailable < pending < final. An `unavailable` snapshot is sticky (a
// costless frame must not erase it) but must NOT outrank a later HONEST reading:
// one transient ledger-read failure would otherwise pin the card to "cost
// unavailable" for the rest of the run.
const rank = (p) => (p.final ? 2 : (p.unavailable ? 0 : 1));
const prevRank = rank(previous);
const nextRank = rank(next);
if (prevRank !== nextRank) return nextRank > prevRank ? next : previous;
const prevTs = Number(previous.ts);
const nextTs = Number(next.ts);
if (Number.isFinite(prevTs) && Number.isFinite(nextTs) && nextTs < prevTs) return previous;
// A frame whose source timestamp is unreadable must not defeat a
// timestamped previous value of equal finality.
if (Number.isFinite(prevTs) && !Number.isFinite(nextTs)) return previous;
return next;
}
/**
* Reset the sticky presentation state (collapsed activity + cost projection)
* introduced in v6.82 P1. Used by resetLiveCardRecord; pure over the record
* shape so dependency-free node tests can exercise the recycle path.
*/
export function clearStickyCardState(record) {
if (!record) return record;
record.collapsedActivity = '';
record.costMeta = null;
// The executor chip is cycle state like the cost projection: a recycled
// slot must not claim the previous cycle's delegated route as its own.
record.executorChip = null;
// A recycled slot must not inherit the previous cycle's finalizing hold —
// nor the outcome observed under it (#1110), which would otherwise paint the
// new cycle's chip with the old cycle's Failed.
record.finalizingHold = false;
record.observedOutcome = '';
// The activity clock is cycle state too: a recycled slot ('active') would
// otherwise open showing the previous cycle's "updated" time.
record.latestActivityTs = '';
if (record.activityEl) {
record.activityEl.textContent = '';
record.activityEl.removeAttribute('title');
}
record.modelExecution = null;
record.toolCalls = null;
record.toolErrors = null;
// The folded evidence is cycle state too: a recycled slot must not count
// the previous cycle's invocations.
record.toolFold = null;
record.durationSec = null;
record.historicalUnavailable = false;
record.historicalUnconfirmed = false;
record.historicalTerminal = null;
record.lastLiveObservedAt = 0;
return record;
}
/**
* Decide the collapsed activity line text (v6.82 P1), shared by root and
* subagent cards. Root cards show the latest activity headline ONLY when a
* coined name occupies the title — an unnamed card's title already shows the
* activity, so the line is suppressed to avoid duplication. Subagent titles
* keep the role · model identity (the id only for twins), so their routed progress body always feeds
* the line. A frame without new activity keeps `previous`, so finishing a card
* never blanks its last activity. Geometry is owned by the two-line CSS clamp;
* this character ceiling is only a defensive DOM/accessibility bound.
*/
export const COLLAPSED_ACTIVITY_MAX = 240;
/**
* The collapsed activity line is plain text: the expanded timeline renders the
* same headline through `renderMarkdown`, so the compact projection strips that
* renderer's marker inventory (utils.js) — fences, inline code, bold, emphasis,
* strikethrough, headings, bullets, links, table pipes. It strips line by line
* without the renderer's block context, so a stray pipe row or list marker the
* timeline would show literally is dropped here too: over-stripping is the
* accepted side of that trade, a leaked marker is not. Headings follow the
* renderer's own rule (`joinMarkdownHeadings`: markers off, ` — ` before the
* text under a real heading). A headline that is nothing but markers keeps its
* source text: an empty projection would flip the reserved activity band's
* `:empty` rules.
*/
export function plainActivityText(text = '') {
const source = String(text || '');
const plain = joinMarkdownHeadings(source)
.replace(/```\w*\n([\s\S]*?)```/g, '$1')
.replace(/(``|`)(.+?)\1/g, '$2')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/~~(.+?)~~/g, '$1')
.replace(/^- (.+)$/gm, '$1')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
.replace(/^\|(.+)\|$/gm, (_, row) => row.split('|').map((cell) => cell.trim()).join(' '))
.replace(/^[\s\-:|]+$/gm, '');
const trimmed = plain.trim();
return trimmed || source;
}
export function boundActivityPreview(value = '') {
const candidate = plainActivityText(value).replace(/\s+/g, ' ').trim();
if (candidate.length <= COLLAPSED_ACTIVITY_MAX) return candidate;
return candidate.slice(0, COLLAPSED_ACTIVITY_MAX - 1).trimEnd() + '…';
}
export function projectCollapsedActivity({
isSubagent = false, suggestedName = '', headline = '', body = '', previous = '',
} = {}) {
const current = boundActivityPreview(isSubagent ? body : headline);
const candidate = current || boundActivityPreview(previous);
if (!isSubagent && candidate === boundActivityPreview(suggestedName || headline)) return '';
return candidate;
}
// v6.82 (P5): terminal card phases. 'cancelled' is a first-class terminal phase
// so a force-cancelled root resolves its card instead of re-inflating.
export function isTerminalTaskPhase(phase = '', terminal = false) {
return Boolean(terminal) || ['done', 'lifecycle_error', 'cancelled'].includes(phase);
}
// ---------------------------------------------------------------------------
// In-flight chat activity status (owner decisions 1A-5A; managed continuity).
// ---------------------------------------------------------------------------
/**
* One request/apply clock for every /api/state consumer on a page. Responses
* may finish in either order; once generation N applies, an older generation
* can no longer mutate any projection. requestedAt stays tied to request start
* and is the barrier for the CARD scan (`lastLiveObservedAt`) only — activity
* hydration is a plain projection of the census and has no barrier.
*
* `gate(force)` is the page-wide single-flight admission for the readers: it
* resolves to a request when the caller may read now. A periodic tick that
* lands while a read is in flight is never queued: it resolves to null once
* that read settles, so a caller that only needs some fresh read to have
* landed (the boot prefetch before the socket opens) may await it. A forced
* caller that lands mid-flight is coalesced with every other forced caller
* into ONE follow-up read that starts when the in-flight read settles — the
* first forced caller receives that request, the others resolve to null once
* the follow-up has applied or failed. `begin()` stays the ungated clock for
* synthetic generation bumps. A gated request settles through `apply`/`fail`.
*/
export function createStateSnapshotSequencer(onApply, now = () => Date.now(), onUnavailable = () => {}) {
let requestedGeneration = 0;
let appliedGeneration = 0;
// Newest applied body until an unavailable read retires it (late-mount seed).
let latest = null;
let inflight = null;
let settled = null;
let followUp = null;
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
const begin = () => ({ generation: ++requestedGeneration, requestedAt: now() });
const open = () => { settled = deferred(); inflight = begin(); return inflight; };
const settle = (request) => {
if (!inflight || request !== inflight) return;
const done = settled;
const next = followUp;
inflight = settled = followUp = null;
if (next) next.resolve({ request: open(), done: settled.promise });
done.resolve();
};
return {
begin,
gate(force = false) {
if (!inflight) return Promise.resolve(open());
if (!force) return settled.promise.then(() => null);
if (!followUp) { followUp = deferred(); return followUp.promise.then((f) => f.request); }
return followUp.promise.then((f) => f.done).then(() => null);
},
apply(request, data) {
try {
const generation = Number(request?.generation) || 0;
if (!generation || generation <= appliedGeneration) return false;
appliedGeneration = generation;
latest = data;
onApply(data, request.requestedAt, generation);
return true;
} finally { settle(request); }
},
isCurrent(request) {
return (Number(request?.generation) || 0) > appliedGeneration;
},
fail(request) {
try {
const generation = Number(request?.generation) || 0;
if (!generation || generation <= appliedGeneration) return false;
appliedGeneration = generation;
latest = null;
onUnavailable();
return true;
} finally { settle(request); }
},
latest: () => latest,
};
}
// В9: one /api/state body's `supervisor_ready`, null when it states nothing. Only
// true ends Starting…; a `supervisor_error` is not readiness and is not read here.
export function supervisorReady(data) {
return typeof data?.supervisor_ready === 'boolean' ? data.supervisor_ready : null;
}
/**
* Main-thread fan-out gate for a live WS frame.
*
* Main adopts a frame only when the server did NOT stamp it as a Project
* thread AND its chat_id is not a project the client already knows. The
* server stamp (`project_thread`, set at the message_bus broadcast choke from
* the registry) closes the race where a fresh project's frames arrive before
* `projectChatIds` learns the project — previously Main adopted them and
* minted an empty "Working..." card. Frames without the stamp (main, legacy
* missing, external transports such as Telegram) route exactly as before;
* explicit chat_id=0 remains the internal Skill Review partition. No
* numeric-range heuristic is involved.
*/
export function mainThreadAccepts(msg, projectChatIds) {
if (msg && msg.project_thread) return false;
const cid = Number(msg?.chat_id ?? 1);
// chat_id=0 is the internal Skill Review/panel partition. An explicit zero
// is never a Main conversation. Legacy LOG frames whose inner payload did
// not carry chat_id are handled separately by mainLogFrameAccepts().
// Negative ids are reserved for synthetic A2A traffic and never enter a
// human-facing browser stream.
if (cid <= 0) return false;
return !(projectChatIds instanceof Set && projectChatIds.has(cid));
}
/** Main routing for the legacy LocalChatBridge log envelope. */
export function mainLogFrameAccepts(msg, projectChatIds) {
const data = msg?.data;
if (data && typeof data === 'object' && Object.prototype.hasOwnProperty.call(data, 'chat_id')) {
return mainThreadAccepts({ ...data, ...msg, chat_id: data.chat_id }, projectChatIds);
}
// Older bridges stamped absent inner identity as outer zero. This is the
// one compatibility case; a real inner zero above remains panel-only.
if (Number(msg?.chat_id) === 0) return !msg?.project_thread;
return mainThreadAccepts(msg, projectChatIds);
}
/** Route one ordinary Chat frame to the current Main or Project instance. */
export function chatThreadAccepts(msg, isMain, chatId, projectChatIds) {
if (isMain) return mainThreadAccepts(msg, projectChatIds);
return Number(msg?.chat_id ?? 1) === chatId;
}
/**
* Route one LocalChatBridge log envelope to the current Chat instance while
* keeping an explicit inner chat_id=0 in the hidden panel partition.
*/
export function chatLogThreadAccepts(msg, isMain, chatId, projectChatIds) {
if (isMain) return mainLogFrameAccepts(msg, projectChatIds);
const data = msg?.data;
if (data && typeof data === 'object' && Object.prototype.hasOwnProperty.call(data, 'chat_id')) {
return chatThreadAccepts({ ...msg, ...data, chat_id: data.chat_id }, false, chatId, projectChatIds);
}
// An absent inner identity historically arrives as outer zero. Project
// instances do not adopt that unowned compatibility frame.
if (Number(msg?.chat_id) === 0) return false;
return chatThreadAccepts(msg, false, chatId, projectChatIds);
}
export const TERMINAL_TASK_STATUSES = new Set([
'completed', 'failed', 'cancelled', 'rejected_duplicate',
]);
const TERMINAL_SUBAGENT_EVENTS = new Set([
'completed', 'completed_warn', 'failed', 'cancelled', 'rejected',
]);
/**
* Positive typed task-terminal truth shared by history and live Chat rows.
* Role + task_id is deliberately insufficient: review references, lifecycle
* receipts, annotations and media can all carry a real task id mid-run.
*/
export function positiveTaskTerminalFact(row) {
if (!row || typeof row !== 'object') return false;
if (String(row.system_type || '') === 'task_summary') return true;
if (TERMINAL_TASK_STATUSES.has(String(row.task_terminal_status || '').toLowerCase())) return true;
return String(row.delegation_role || '').toLowerCase() === 'subagent'
&& TERMINAL_SUBAGENT_EVENTS.has(String(row.subagent_event || '').toLowerCase());
}
/**
* Single status reducer for the chat header (owner decisions 2A/5A; managed
* activities added by the project-continuity contract). Priority: disconnected
* > background live card (Working...) > admitted managed work (Working...) >
* server-confirmed direct/ephemeral turns (Thinking...) > local pending
* submissions (Sending...) > queue-admitted but unstarted managed work
* (Queued...) > idle. A queued task ranks below
* Sending... because an unacknowledged local submission is the more actionable
* state. Idle is Starting… until the host proves `supervisor_ready` (В9),
* then Online. Pure over its inputs for dependency-free node tests.
*/
export function computeDerivedChatStatus({
isConnected = true,
hasActiveLiveCard = false,
activeDirectCount = 0,
activeManagedCount = 0,
queuedManagedCount = 0,
pausedManagedCount = 0,
waitingModelCount = 0,
pendingSubmissionsCount = 0,
supervisorStarting = false,
} = {}) {
if (!isConnected) {
return { kind: 'offline', text: 'Reconnecting...', showDots: false };
}
if (hasActiveLiveCard) {
return { kind: 'thinking', text: 'Working...', showDots: false };
}
if (activeManagedCount > 0) {
return { kind: 'thinking', text: 'Working...', showDots: true };
}
if (activeDirectCount > 0) {
return { kind: 'thinking', text: 'Thinking...', showDots: true };
}
if (pendingSubmissionsCount > 0) {
return { kind: 'thinking', text: 'Sending...', showDots: true };
}
if (queuedManagedCount > 0) {
if (waitingModelCount > 0) return { kind: 'online', text: 'Waiting for access', showDots: false };
return { kind: 'thinking', text: 'Queued...', showDots: true };
}
if (waitingModelCount > 0) return { kind: 'online', text: 'Waiting for access', showDots: false };
if (pausedManagedCount > 0) {
// Budget-paused work is NOT running and will not start by itself:
// never dress it up as Working or Queued.
return { kind: 'online', text: 'Paused (budget)', showDots: false };
}
if (supervisorStarting) return { kind: 'starting', text: 'Starting…', showDots: false };
return { kind: 'online', text: 'Online', showDots: false };
}
// The reducer's counted inputs: census activities not waiting on a model, and mounted unfinished
// cards, where a managed root drives Working… and a direct turn keeps the census verdict (Thinking…).
export function chatStatusCounts(activities, records, isWaiting = () => false) {
const counts = { activeDirectCount: 0, activeManagedCount: 0, queuedManagedCount: 0, pausedManagedCount: 0,
hasActiveLiveCard: false, waitingModelCount: 0 };
for (const [id, entry] of activities) {
if (isWaiting(id)) continue;
if (String(entry?.kind || '') !== 'managed_task') counts.activeDirectCount += 1;
else if (String(entry?.phase || '') === 'queued') counts.queuedManagedCount += 1;
else if (/^budget_paus(ed|ing)$/.test(entry?.phase ?? '')) counts.pausedManagedCount += 1;
else counts.activeManagedCount += 1;
}
for (const record of records) {
if (!isForegroundLiveCard(record)) continue;
if (record.modelWaiting) counts.waitingModelCount += 1;
else if (!record.direct) counts.hasActiveLiveCard = true;
}
return counts;
}
/**
* Local-echo continuity: split the bounded journal of locally-sent owner rows
* against ONE fetched history response. Entries whose client_message_id
* appears in the response are CONFIRMED durable (server history is the
* authority; the local copy retires). The rest are UNCONFIRMED and must
* survive a full feed rebuild: a stale history snapshot — fetched before the
* send was logged — has no authority to erase a message the owner just sent.
* Pure over its inputs for dependency-free node tests.
*/
export function partitionLocalEchoJournal(journal, serverClientMessageIds) {
const confirmed = [];
const unconfirmed = [];
const entries = journal instanceof Map ? journal.values() : (journal || []);
for (const entry of entries) {
const cmid = String(entry?.clientMessageId || '');
if (!cmid) continue;
if (serverClientMessageIds && serverClientMessageIds.has(cmid)) confirmed.push(entry);
else unconfirmed.push(entry);
}
return { confirmed, unconfirmed };
}
// ---------------------------------------------------------------------------
// Pure message-presentation helpers (moved verbatim from chat.js — that
// module sits at its byte ceiling).
// ---------------------------------------------------------------------------
/** Dedupe key for one rendered chat row; client_message_id wins when present. */
export function buildMessageKey(role, text, timestamp, opts = {}) {
if (opts.clientMessageId) return `client|${opts.clientMessageId}`;
if (role !== 'user' && !opts.isProgress && opts.taskId) {
return [
'task',
role,
opts.systemType || '',
opts.source || '',
opts.taskId,
text,
].join('|');
}
if (!timestamp) return '';
return [
role,
opts.isProgress ? '1' : '0',
opts.systemType || '',
opts.source || '',
opts.senderLabel || '',
opts.senderSessionId || '',
opts.taskId || '',
timestamp,
text,
].join('|');
}
export function reconnectBannerText(reason = '') {
if (reason === 'sha-change') return '♻️ Restart complete';
if (reason) return '♻️ Reconnected';
return '';
}
/** {short, full} presentation of a message timestamp, or null when unreadable. */
export function formatMsgTime(isoStr) {
if (!isoStr) return null;
try {
const d = new Date(isoStr);
if (isNaN(d)) return null;
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const todayStr = now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
let short;
if (d.toDateString() === todayStr) short = hhmm;
else if (d.toDateString() === yesterday.toDateString()) short = `Yesterday, ${hhmm}`;
else short = `${months[d.getMonth()]} ${d.getDate()}, ${hhmm}`;
const full = `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()} at ${hhmm}`;
return { short, full };
} catch {
return null;
}
}
/** Human label for ONE manual-routing option row (shared with the picker card). */
export function routingOptionLabel(option) {
if (!option || typeof option !== 'object') return '';
if (option.label) return String(option.label);
if (option.action === 'new_task_in_project') {
return `New task in ${String(option.project_name || 'Project')}`;
}
if (option.title || option.project_name) {
return String(option.title || option.project_name);
}
return option.project_id && !option.task_id ? 'Project' : 'Task';
}
/** Human text for a typed routing annotation ('' hides the line). */
export function routingAnnotationText(annotation) {
if (!annotation || typeof annotation !== 'object') return '';
// A refused act carries the host's own owner-facing sentence (`cause`);
// it outranks the status matrix below. Absent on scheduled/delivered/
// pending rows and on the picker frame, so those labels are unchanged.
const cause = String(annotation.cause || '').trim();
if (cause) return cause;
const action = String(annotation.action || '');
const status = String(annotation.status || '');
const target = String(annotation.target || '');
const targetLabel = String(annotation.target_label || '')
|| (target ? (action === 'project_route' ? 'Project' : 'Task') : '');
if (status === 'pending') return 'Choosing the right destination…';
if (status === 'needs_manual_target') {
const optionLabels = (Array.isArray(annotation.options) ? annotation.options : [])
.map(routingOptionLabel)
.filter(Boolean);
if (optionLabels.length) return `Choose a target · ${optionLabels.join(' / ')}`;
// No options and (by the guard above) no cause: a receipt written
// before the host sentence existed, or by a producer that bypasses
// `_emit_routing_receipt`. Nothing can be chosen on such a row, so it
// must not invite a choice.
return targetLabel ? `Not routed · ${targetLabel}` : 'Not routed';
}
if (status === 'project_unavailable') return 'Project is unavailable';
const labels = {
mailbox_delivery: 'Delivered to task',
steer_task: 'Steered task',
promote_chat_to_task: 'Started task',
route_to_project: 'Routed to project',
project_route: 'Project routing',
};
const label = labels[action] || status.replaceAll('_', ' ') || action.replaceAll('_', ' ');
return targetLabel && label ? `${label} · ${targetLabel}` : label;
}
/**
* Project one /api/state activity census onto the client's active-activity map.
*
* This map is a PROJECTION of the census: the client never inserts into it from
* a WS frame (a typing frame is a submission receipt, not liveness), so the
* census is its only inserter (finals and the census delete). Deletion
* authority follows from that — on a
* `complete` snapshot every id the census does not list is gone, whatever its
* kind, with no wall-clock barrier and no generation marker. An incomplete
* snapshot (supervisor not ready, or a source failed) is a partial listing and
* deletes nothing.
*
* `concludedIds` (Set/Map with .has) is the client-side conclusion ledger: a
* turn already concluded by its keyed final must never be re-inserted by a
* snapshot captured while it still ran (activity ids are unique task ids and
* never restart, so conclusion is final).
*/
export function computeHydratedDirectActivities(existingMap, turnsList, chatId, concludedIds = null, complete = true) {
const nextMap = new Map(existingMap || []);
if (!Array.isArray(turnsList)) return nextMap;
const listed = new Set();
for (const turn of turnsList) {
if (Number(turn?.chat_id ?? 1) !== chatId) continue;
const aid = String(turn?.activity_id || '').trim();
if (!aid || (concludedIds && concludedIds.has(aid))) continue;
listed.add(aid);
nextMap.set(aid, {
activityId: aid,
kind: turn.kind || 'direct_chat',
phase: turn.phase || 'thinking',
clientMessageId: turn.client_message_id || nextMap.get(aid)?.clientMessageId || '',
});
}
// The census is the only inserter into this map, so on a complete snapshot
// every id it does not list is gone, whatever its kind. An incomplete
// snapshot (supervisor not ready / a source failed) deletes nothing.
if (complete) for (const aid of nextMap.keys()) if (!listed.has(aid)) nextMap.delete(aid);
return nextMap;
}
/**
* Hydrate one authoritative activity census (see
* computeHydratedDirectActivities for the projection contract) and identify the
* narrower event that can wake durable task-detail convergence: a host-stamped
* managed root the client held, now absent from the GLOBAL snapshot. A root
* still listed under another chat merely departed locally. Direct/ephemeral
* removals carry no task-detail/card authority, but they ARE conclusions
* (#369): the caller records them so a late frame cannot resurrect the
* turn, and clears the linked Sending... submission.
*/
export function reconcileHydratedDirectActivities(
existingMap,
turnsList,
chatId,
concludedIds = null,
complete = true,
) {
const activities = computeHydratedDirectActivities(
existingMap, turnsList, chatId, concludedIds, complete,
);
const globallyActiveActivityIds = new Set();
for (const turn of Array.isArray(turnsList) ? turnsList : []) {
const activityId = String(turn?.activity_id || '').trim();
if (activityId) globallyActiveActivityIds.add(activityId);
}
const departedManagedTaskIds = [];
const disappearedManagedTaskIds = [];
const concludedDirectActivities = [];
for (const [activityId, entry] of existingMap || []) {
if (activities.has(activityId)) continue;
if (concludedIds?.has(activityId)) continue;
if (String(entry?.kind || '') !== 'managed_task') {
// A direct/ephemeral row the authoritative snapshot no longer
// lists is settled: its live final was missed (ephemeral
// task_done frames never reach the card layer), so the snapshot
// is the conclusion of record.
if (!globallyActiveActivityIds.has(activityId)) {
concludedDirectActivities.push({
activityId,
clientMessageId: String(entry?.clientMessageId || ''),
});
}
continue;
}
departedManagedTaskIds.push(activityId);
if (globallyActiveActivityIds.has(activityId)) continue;
disappearedManagedTaskIds.push(activityId);
}
return {
activities,
departedManagedTaskIds,
disappearedManagedTaskIds,
concludedDirectActivities,
globallyActiveActivityIds,
};
}
/**
* Card-set durable-truth reconcile (stuck "Working..." pill class). The header
* reducer reads hasActiveLiveCard — a pure DOM scan of mounted unfinished
* foreground cards — so a card minted by a replayed frame whose own terminal
* row never reached this client (lost task_done, lineage-only subagent final
* re-minting a finished parent) kept the pill on "Working..." forever: every
* existing terminal path keys on the card's OWN id reaching a snapshot or
* frame first. This selector closes the gap from the card side: given the
* compact card projection `{id, finished, isSubagent, connected}` and the set
* of ids the GLOBAL /api/state snapshot confirms live, it returns the mounted
* unfinished foreground card ids the snapshot does NOT vouch for. Each one is
* handed to observeMissingManagedTask, whose durable task-detail read finishes
* the card ONLY on a proven terminal status (`log_events.js::isTerminalTaskDetail`); a 404 or
* nonterminal detail keeps the id and retries on the next snapshot (owner
* Q3=A: no timers, no id-shape heuristics, no fabricated terminal).
*
* Skipped here: finished cards, detached roots (not part of the reducer's
* scan), subagent cards (their parent owns the lineage; observe filters them
* too), reusable slots ('active' — many cycles per id, no single durable
* result) and the 'chat' fallback group id. Pure for node tests.
*/
export function unconfirmedForegroundCardIds(cards, activeIds) {
const out = [];
for (const card of Array.isArray(cards) ? cards : []) {
const id = String(card?.id || '');
if (!id || id === 'chat' || REUSABLE_TASK_IDS.has(id)) continue;
if (card.finished || card.isSubagent || !card.connected) continue;
if (activeIds?.has(id)) continue;
out.push(id);
}
return out;
}
// Extracted from chat.js (byte-ratchet payment): the DOM half of the routing
// acknowledgement, kept beside its text builder above.
export function clearTransientRoutingAnnotations(messagesDiv = globalThis.document?.querySelector?.('#chat-messages')) {
if (!messagesDiv) return false;
let changed = false;
for (const note of messagesDiv.querySelectorAll(
'.msg-routing-annotation[data-annotation-status="pending"]',
)) {
const bubble = note.closest('.chat-bubble');
if (bubble) delete bubble.dataset.chatAnnotationStatus;
note.remove();
changed = true;
}
return changed;
}
// В9: the host stamps a typed `ingress_accepted: true` on an owner echo only after the durable
// chat write, so that client_message_id's bubble says `Input saved` — never that work began.
// No flag is unknown and adds nothing. The note wears the delivery note's quiet style.
export function markIngressSaved(root, row) {
const cmid = String(row?.client_message_id || '');
if (row?.role !== 'user' || row.ingress_accepted !== true || !cmid) return false;
const bubble = [...root.querySelectorAll('.chat-bubble.user[data-client-message-id]')]
.find((node) => node.dataset.clientMessageId === cmid);
if (!bubble || bubble.querySelector('[data-ingress-saved]')) return false;
const note = Object.assign(document.createElement('div'), { className: 'msg-pending', textContent: 'Input saved' });
note.dataset.ingressSaved = '';
bubble.insertBefore(note, bubble.querySelector('.msg-time'));
return true;
}
export function renderRoutingAnnotation(bubble, annotation, chatId = 1) {
if (!bubble) return false;
const text = routingAnnotationText(annotation);
let note = bubble.querySelector('.msg-routing-annotation');
if (!text) {
const hasStatus = bubble.dataset.chatAnnotationStatus !== undefined;
if (!note && !hasStatus) return false;
note?.remove();
bubble.querySelector('.msg-routing-actions')?.remove();
if (hasStatus) delete bubble.dataset.chatAnnotationStatus;
return true;
}
const status = String(annotation.status || '');
const destinationKey = `${annotation.project_id || ''}|${annotation.project_chat_id || ''}|${annotation.target || ''}`;
const changed = !note || note.dataset.annotationText !== text
|| note.dataset.destinationKey !== destinationKey
|| note.dataset.annotationStatus !== status
|| bubble.dataset.chatAnnotationStatus !== status;
if (!note) {
note = document.createElement('div');
note.className = 'msg-routing-annotation';
const time = bubble.querySelector('.msg-time');
if (time) time.before(note);
else bubble.append(note);
}
if (!changed) return false;
bubble.querySelector('.msg-routing-actions')?.remove();
note.textContent = text;
note.dataset.annotationText = text;
note.dataset.destinationKey = destinationKey;
if (note.dataset.annotationStatus !== status) note.dataset.annotationStatus = status;
if (bubble.dataset.chatAnnotationStatus !== status) bubble.dataset.chatAnnotationStatus = status;
const destination = annotation.project_id && Number(annotation.project_chat_id) > 0
&& Number(annotation.project_chat_id) !== Number(chatId)
&& ['scheduled', 'delivered'].includes(status);
if (destination) {
const actions = createSystemMessageActions(projectReference(
{ id: annotation.project_id, chat_id: Number(annotation.project_chat_id) }, { taskId: annotation.target || '' },
));
actions.classList.add('msg-routing-actions');
const time = bubble.querySelector('.msg-time');
if (time) time.before(actions);
else bubble.append(actions);
}
return changed;
}
// Full narration stays in the timeline, not a mouse-only title.
export function renderCollapsedActivity(record, text) {
if (!record?.activityEl) return false;
const changed = record.activityEl.textContent !== text
|| Boolean(record.activityEl.hasAttribute?.('title'));
if (record.activityEl.textContent !== text) record.activityEl.textContent = text;
if (record.activityEl.hasAttribute?.('title')) record.activityEl.removeAttribute('title');
return Boolean(changed && record.activityEl.isConnected);
}
// The 13 cost-meta keys shared by both subagent whitelists (the delegation
// trio stays inline in each literal — the wire test scans those literals).
const COST_META_KEYS = [
'cost_usd', 'accounted_upper_bound_usd', 'accounted_upper_bound_usd_with_children',
'cost_accounting_status', 'cost_accounting_error', 'cost_final', 'cost_usd_with_children',
'cost_with_children_partial', 'reserved_usd', 'unresolved_upper_bound_usd',
'unknown_unmetered', 'non_final_rows', 'cost_presentation',
];
export function costMetaKeys(src) {
return Object.fromEntries(COST_META_KEYS.map((key) => [key, src?.[key]]));
}
const CARD_META_KEYS = [
...COST_META_KEYS, 'executor_route', 'execution_evidence', 'actual_substrate',
'executor_observation', 'model_execution', 'tool_calls', 'model', 'ts', 'initiator', 'cancel_origin',
];
export function cardMetaKeys(src) {
return Object.fromEntries(CARD_META_KEYS.map((key) => [key, src?.[key]]));
}
// the ONE meta-line renderer, fed entirely from record state,
// so a replay batch renders it exactly once per card.
export function renderLiveCardMeta(record, { agentModel = record?.agentModel || '' } = {}) {
if (!record?.metaEl) return false;
// The executor block is ONE part of this line, joined by the same text
// separator as the rest: concatenating it left the chip and the first fact
// touching in a copied line and running together for a screen reader,
// because only the flex gap separated them.
const html = joinMetaParts([
executorIdentityMarkup(record.executorChip, { agentModel: compactModel(agentModel) }),
...[
record.initiator === 'consciousness' ? 'Consciousness' : '',
record.historicalUnavailable ? 'Outcome unavailable' : (record.historicalUnconfirmed ? 'Activity unconfirmed' : ''),
modelExecutionLabel(record.modelExecution),
Number.isInteger(record.toolCalls) ? `${record.toolCalls} tool ${record.toolCalls === 1 ? "call" : "calls"}` : '',
record.toolErrors > 0 ? `${record.toolErrors} error${record.toolErrors === 1 ? '' : 's'}` : '',
Number.isFinite(record.durationSec) ? formatLogDuration(record.durationSec) : '',
...(Array.isArray(record._lastFrameMeta) ? record._lastFrameMeta : []),
...((record.costMeta && Array.isArray(record.costMeta.meta)) ? record.costMeta.meta : []),
record.latestActivityTs ? `updated ${record.latestActivityTs}` : '',
].filter(Boolean).map((item) => `<span class="chat-live-meta-text">${escapeHtml(item)}</span>`),
]);
if (record.metaEl.innerHTML === html) return false;
record.metaEl.innerHTML = html;
return Boolean(record.metaEl.isConnected);
}
// Only host-attested cancelable queue roots receive this control.
// A card's OWN actions row: a nested child card carries a row of its own, and a
// descendant lookup would hand a root without one its child's.
export function ownLiveActionsEl(record) {
return [...(record?.root?.children || [])]
.find((node) => node.classList?.contains('chat-live-actions')) || null;
}
export function ensureLiveActionsEl(record) {
if (!record?.root
|| record.root.dataset.projectCreated === '1'
|| record.root.dataset.projectCreating === '1') return null;
let actions = ownLiveActionsEl(record);
if (!actions) {
actions = document.createElement('div');
actions.className = 'chat-live-actions';
const timeline = record.timelineEl && record.timelineEl.parentElement === record.root
? record.timelineEl
: null;
record.root.insertBefore(actions, timeline);
}
return actions;
}