// 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 = ` ${isProgressLine ? renderMarkdown(displayHeadline, { inlineHeadingBreaks: true }) : escapeHtml(displayHeadline)} 1 ? '' : 'hidden'}>${item.count > 1 ? `${item.count}x` : ''} ${item.ts ? `${escapeHtml(item.ts)}` : ''} `; const headHtml = expandable ? `
${headContent} ${expanded ? 'Collapse' : ((item.truncated && item.fullRef) ? 'Show full' : 'Expand')}
` : `
${headContent}
`; return `
${headHtml} ${displayBody ? `
${renderMarkdown(displayBody, { inlineHeadingBreaks: true })}${loadingFull ? '
Loading full output…
' : ''}
` : ''}
`; } // --------------------------------------------------------------------------- // 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 * never regresses — a start frame that arrives after the finish cannot reopen * the call, and an error stays an error however many frames report that key. */ export function noteToolCall(record, observation) { const key = observation?.key; if (!record || !key) return record; const { calls } = ensureToolFold(record); const prev = calls.get(key); const status = observation.status === 'error' || prev?.status === 'error' ? 'error' : ((observation.status === 'ok' || prev?.status === 'ok') ? 'ok' : 'calling'); calls.set(key, { status, // A call is an addressing receipt only while EVERY frame about it says so // (the host stamps `routing_action`): the first frame without the stamp // makes the call content, and content it stays. receipt: Boolean(observation.receipt) && (prev ? prev.receipt : true), tool: observation.tool || prev?.tool || '', }); 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) { const fold = ensureToolFold(record); 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; 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 calls = Number.isInteger(host?.calls) ? host.calls : live.length; const errors = 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')}` : ''}`, body: '', fullBody: 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