diff --git a/examples/desktop/scripts/qa-cdp-run-ctx-deep.mjs b/examples/desktop/scripts/qa-cdp-run-ctx-deep.mjs new file mode 100644 index 0000000000..9219dc0492 --- /dev/null +++ b/examples/desktop/scripts/qa-cdp-run-ctx-deep.mjs @@ -0,0 +1,89 @@ +// Real-machine isolated runner for the lane-ctx-deep shots (task #51). +// Boots Electron on a private CDP port with a private USER_DATA and +// DSH_DESKTOP_HOME under $TMPDIR (2026-07-18 postmortem — nothing here +// touches the user's real ~/.dsh-desktop), then invokes the shot script. +// +// Usage: node scripts/qa-cdp-run-ctx-deep.mjs + +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { resolve, join } from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' +import { tmpdir } from 'node:os' + +const WORKTREE = resolve(process.env.DSH_WORKTREE || process.cwd()) +const PARENT = resolve(process.env.DSH_REPO || '/Users/ziya/harness/dsh-desktop-demo') +const ELECTRON = join(PARENT, 'node_modules/.bin/electron') +const CDP_PORT = Number(process.env.DSH_CTX_DEEP_PORT || 9285) +const USER_DATA = join(tmpdir(), 'dsh-ctx-deep-userdata') +const DSH_HOME = join(tmpdir(), 'dsh-ctx-deep-home') +const OUTDIR = join(WORKTREE, 'docs/demo-shots') +const SHOOT_SCRIPT = join(WORKTREE, 'scripts/qa-cdp-shoot-ctx-deep.mjs') + +if (!existsSync(ELECTRON)) { + console.error(`electron binary not found at ${ELECTRON}`) + process.exit(2) +} +mkdirSync(OUTDIR, { recursive: true }) +for (const dir of [USER_DATA, DSH_HOME]) { + try { rmSync(dir, { recursive: true, force: true }) } catch {} + mkdirSync(dir, { recursive: true }) +} +// Seed a minimal config so onboarding modal stays out of the way. Use +// echo-jsonrpc (jsonrpc-demo bin) — daemon-demo has been renamed to +// jsonrpc-demo upstream and the daemon-* config still points at the old +// path, so echo-jsonrpc is the correct keyless profile today. +writeFileSync(join(DSH_HOME, 'user-overlay.cordis.yml'), [ + '# ctx-deep QA seed overlay', + 'plugins:', + ` - "@cordisjs/plugin-include":`, + ` path: ${join(WORKTREE, 'config/echo-jsonrpc.yml')}`, + '', +].join('\n')) +writeFileSync(join(DSH_HOME, 'config.json'), JSON.stringify({ role: 'coding', approvalMode: 'never' })) +writeFileSync(join(DSH_HOME, '.onboarded'), new Date().toISOString()) + +const child = spawn(ELECTRON, [ + `--remote-debugging-port=${CDP_PORT}`, + `--user-data-dir=${USER_DATA}`, + '--disable-gpu', + '--no-sandbox', + '.', +], { + cwd: WORKTREE, + env: { + ...process.env, + DSH_DESKTOP_HOME: DSH_HOME, + DSH_DEV_ROOT: process.env.DSH_DEV_ROOT || '/Users/ziya/harness/deepseek-harness-dev', + DSH_MAXIMIZE: '1', + DSH_QA: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], +}) +const logs = [] +child.stdout.on('data', d => logs.push(String(d))) +child.stderr.on('data', d => logs.push(String(d))) + +let up = false +for (let i = 0; i < 40; i++) { + await sleep(500) + try { + const r = await fetch(`http://localhost:${CDP_PORT}/json/list`) + if (r.ok) { up = true; break } + } catch {} +} +if (!up) { + child.kill('SIGKILL') + console.error('electron did not come up on CDP :' + CDP_PORT) + console.error(logs.join('')) + process.exit(3) +} + +// Run the shot script against our port. +const shooter = spawn(process.execPath, [SHOOT_SCRIPT, String(CDP_PORT), OUTDIR], { + cwd: WORKTREE, + stdio: 'inherit', +}) +const rc = await new Promise((r) => shooter.on('exit', (code) => r(code))) +child.kill('SIGKILL') +process.exit(rc || 0) diff --git a/examples/desktop/scripts/qa-cdp-shoot-ctx-deep.mjs b/examples/desktop/scripts/qa-cdp-shoot-ctx-deep.mjs new file mode 100644 index 0000000000..bd3c5ebc08 --- /dev/null +++ b/examples/desktop/scripts/qa-cdp-shoot-ctx-deep.mjs @@ -0,0 +1,388 @@ +// Shots for lane-ctx-deep (task #51) — four Context-page enhancements. +// Each shot loads a QA fixture, forces a re-render, then captures the +// visible chrome for that feature. Real-machine isolated per team-lead: +// DSH_QA=1 + DSH_DESKTOP_HOME under /tmp/dsh-qa-ctx-deep so nothing here +// touches the user's live session store. +// +// Shots produced (docs/demo-shots/): +// ctx-deep-01-window-bar.png — Context page top strip, window +// occupancy stacked bar + legend. +// ctx-deep-02-intervention.png — Context page top strip, intervention +// markers axis. +// ctx-deep-03-compact-config.png — Compact card on Chat tab with the +// new "Config" tab active. +// ctx-deep-04-subagent.png — Subagent card with drill-down tabs +// (Tool defs / Inbound query). + +import { writeFileSync, mkdirSync } from 'node:fs' +import { resolve } from 'node:path' + +const port = process.argv[2] || '9241' +const outdir = process.argv[3] || 'docs/demo-shots' +mkdirSync(outdir, { recursive: true }) + +async function main () { + const targets = await (await fetch(`http://localhost:${port}/json/list`)).json() + const target = targets.find((t) => t.type === 'page') + if (!target) throw new Error('no page target on port ' + port) + const ws = new WebSocket(target.webSocketDebuggerUrl) + await new Promise((r, x) => { ws.onopen = r; ws.onerror = (e) => x(e) }) + + let id = 1 + const pending = new Map() + ws.onmessage = (ev) => { + const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)) + if (msg.id != null && pending.has(msg.id)) { + const [ok, err] = pending.get(msg.id) + pending.delete(msg.id) + if (msg.error) err(new Error(msg.error.message)) + else ok(msg.result) + } + } + const call = (m, p = {}, timeoutMs = 20000) => new Promise((ok, err) => { + const _id = id++ + const timer = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, timeoutMs) + pending.set(_id, [(v) => { clearTimeout(timer); ok(v) }, (e) => { clearTimeout(timer); err(e) }]) + ws.send(JSON.stringify({ id: _id, method: m, params: p })) + }) + const evj = async (expr) => { + const r = await call('Runtime.evaluate', { + expression: `(async()=>{try{return (${expr})}catch(e){return {__err: String(e)}}})()`, + returnByValue: true, awaitPromise: true, + }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text) + return r.result?.value + } + const wait = (ms) => new Promise((r) => setTimeout(r, ms)) + const shoot = async (name) => { + const shot = await call('Page.captureScreenshot', { + format: 'png', + clip: { x: 0, y: 0, width: 1440, height: 900, scale: 2 }, + }, 30000) + const out = resolve(outdir, name) + writeFileSync(out, Buffer.from(shot.data, 'base64')) + console.log(out) + } + + await call('Page.enable') + await evj(`window.dshQa && window.dshQa.revealWindow ? await window.dshQa.revealWindow() : null`) + await call('Emulation.setDeviceMetricsOverride', { + width: 1440, height: 900, deviceScaleFactor: 2, mobile: false, + }) + // Dismiss onboarding. + await evj(`(function(){ + const btns = Array.from(document.querySelectorAll('button')); + const skip = btns.find(b => /skip and use defaults/i.test(b.textContent || '')); + if (skip) { skip.click(); } + return 'ok'; + })()`) + await wait(300) + // Hide devtools drawer. + await evj(`(function(){ + const d = document.querySelector('.devtools-drawer'); + if (d) d.style.display = 'none'; + return 'ok'; + })()`) + + // Fabricate a session directly in renderer state — bypasses the need for + // a live runtime, which the desktop demo doesn't need for these shots. + // We manufacture a plausible cachedEvents array covering all five window + // families + all three intervention kinds + a compact event, then expose + // it via a temporary __dshChat.getEventsForActive() override so the + // Context page reads the same shape production would. + await evj(`(function(){ + const now = Date.now() + const events = [] + let seq = 1 + function push(ev){ ev.seq = seq++; ev.time = now + seq*10; events.push(ev) } + // system prompt seed + push({ type: 'context/message', data: { content: [{type:'text', text: 'You are DSH, an autonomous agent operating in a research environment. Use the tools carefully.'}], source: { kind: 'system' } } }) + // tool calls (drive the tool_defs slice) + push({ type: 'tool/call', data: { name: 'read_file', arguments: '{"path":"a.md"}' } }) + push({ type: 'tool/call', data: { name: 'search', arguments: '{"q":"context ledger"}' } }) + // user + push({ type: 'user/message', data: { content: [{type:'text', text: 'Show me the ledger'}] } }) + // reasoning + push({ type: 'assistant/reasoning', data: { content: [{type:'text', text: 'The user wants a ledger view. Let me project turns.'}] } }) + // assistant + push({ type: 'assistant/message', data: { content: [{type:'text', text: 'Here is the per-turn ledger with injects and compacts.'}], usage: { inputTokens: 8000, outputTokens: 320, thinking: 180 } } }) + // steer intervention + push({ type: 'steering/message', data: { content: [{type:'text', text: 'Actually skip the recall section.'}] } }) + // plugin inject + push({ type: 'context/message', data: { content: [{type:'text', text: 'time-context: 09:34 UTC'}], source: { kind: 'plugin', plugin: 'time-context' } } }) + push({ type: 'context/message', data: { content: [{type:'text', text: 'guard hint fired'}], source: { kind: 'plugin', plugin: 'guard' } } }) + // turn end + push({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }) + // edit-rerun intervention on the next turn + push({ type: 'user/message', data: { content: [{type:'text', text: 'Show me the ledger and expand turn 1'}], editRerun: { origSeq: 4, reason: 'clarify intent' } } }) + push({ type: 'assistant/reasoning', data: { content: [{type:'text', text: 'Re-running with expanded turn 1.'}] } }) + push({ type: 'assistant/message', data: { content: [{type:'text', text: 'Sure. Turn 1 details attached.'}], usage: { inputTokens: 8500, outputTokens: 250, thinking: 90 } } }) + push({ type: 'compact/summary', data: { summary: [{type:'text', text: 'Turn 1 summary retained.'}], model: 'deepseek-chat', maxTokens: 512, shadowedTokenCount: 6400, shadowedRange: {start:1, end:8} } }) + push({ type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } }) + // fork intervention + push({ type: 'session/forked', data: { parentSeq: 14 } }) + push({ type: 'context/message', data: { content: [{type:'text', text: 'assistant reloaded'}], source: { kind: 'system' } } }) + push({ type: 'user/message', data: { content: [{type:'text', text: 'What happened in the fork?'}] } }) + push({ type: 'assistant/message', data: { content: [{type:'text', text: 'The fork branched at seq 14.'}], usage: { inputTokens: 2100, outputTokens: 90 } } }) + push({ type: 'turn/end', data: { turn: 3, reason: { kind: 'completed' } } }) + + // Install override for the Context page's read path. + const sid = 'ctx-deep-demo-session' + const chat = window.__dshChat = window.__dshChat || {} + const oldGetActive = chat.getActiveSessionId + const oldGetEvents = chat.getEventsForActive + chat.getActiveSessionId = () => sid + chat.getEventsForActive = () => events + chat.getSessions = () => [{ id: sid, title: 'ctx-deep demo', running: false }] + window.__ctxDeepDemoEvents = events + return { sid, count: events.length } + })()`) + await wait(700) + + // -- SHOT 1 + 2: Context page top strip (window bar + intervention) ------ + await evj(`(function(){ + if (window.__dshTabs && window.__dshTabs.switchTo) { window.__dshTabs.switchTo('context'); } + return 'switched'; + })()`) + await wait(500) + await evj(`window.__dshContextPage && window.__dshContextPage.refresh && window.__dshContextPage.refresh()`) + await wait(300) + + const layout = await evj(`(function(){ + const strip = document.querySelector('[data-context-topstrip]'); + const bar = document.getElementById('context-window-bar-track'); + const iv = document.getElementById('context-intervention-track'); + const pane = document.querySelector('.pane[data-pane="context"]'); + const chat = window.__dshChat; + return { + paneHidden: pane ? pane.hidden : 'NO-PANE', + paneHtmlLen: pane ? pane.innerHTML.length : 0, + paneChildren: pane ? Array.from(pane.children).map(c => c.tagName+':'+(c.className||c.id||'')) : null, + documentTitle: document.title, + windowLocation: String(window.location), + chatKeys: chat ? Object.keys(chat) : null, + chatGetEvents: chat && typeof chat.getEventsForActive === 'function', + chatGetSid: chat && typeof chat.getActiveSessionId === 'function' ? chat.getActiveSessionId() : null, + eventsLen: chat && chat.getEventsForActive ? chat.getEventsForActive().length : -1, + stripFound: !!strip, + stripHidden: strip ? strip.hidden : null, + segCount: bar ? bar.children.length : 0, + markerCount: iv ? iv.querySelectorAll('.context-intervention-marker').length : 0, + summary: (document.getElementById('context-window-bar-summary')||{}).textContent||'', + }; + })()`) + console.error('ctx-deep layout ->', JSON.stringify(layout)) + + await shoot('ctx-deep-01-window-bar.png') + + // Highlight the intervention strip: scroll to it if needed and take shot. + await evj(`(function(){ + const strip = document.querySelector('.context-intervention-strip'); + if (strip && strip.scrollIntoView) strip.scrollIntoView({ block: 'start', behavior: 'auto' }); + return 'scrolled'; + })()`) + await wait(300) + await shoot('ctx-deep-02-intervention.png') + + // -- SHOT 3: Compact card Config tab ------------------------------------- + // We render the compact card by direct DOM injection into the chat stream: + // the compact/summary event we seeded already flows through onSessionEvent + // path in real prod, but here we don't have a live session so we build a + // standalone compact card via __dshCompactCard.mountTabs. + await evj(`(function(){ + if (window.__dshTabs && window.__dshTabs.switchTo) window.__dshTabs.switchTo('chat'); + const panes = document.querySelectorAll('.pane[data-pane]'); + for (const p of panes) p.hidden = (p.getAttribute('data-pane') !== 'chat'); + const rail = document.getElementById('context-rail'); if (rail) rail.hidden = true; + const drawer = document.querySelector('.devtools-drawer'); if (drawer) drawer.style.display = 'none'; + for (const sel of ['.onboarding','.mock-cards-menu','.dropdown-open','[data-mock-cards]']) { + for (const n of document.querySelectorAll(sel)) n.style.display = 'none'; + } + return 'chat'; + })()`) + await wait(400) + const compactMount = await evj(`(function(){ + // Build a full-page overlay to keep the shot focused on the card. + const prevOverlay = document.getElementById('ctx-deep-demo-overlay'); + if (prevOverlay) prevOverlay.remove(); + const prev = document.getElementById('ctx-deep-demo-compact-card'); + if (prev) prev.remove(); + const overlay = document.createElement('div'); + overlay.id = 'ctx-deep-demo-overlay'; + overlay.style.cssText = 'position:fixed;left:0;top:0;right:0;bottom:0;background:var(--bg,#fff);z-index:9999;padding:48px 96px;overflow:auto;font-family:inherit;'; + const heading = document.createElement('h1'); + heading.style.cssText = 'font-size:24px;margin:0 0 16px 0;'; + heading.textContent = 'Compact card — Config tab'; + overlay.appendChild(heading); + const sub = document.createElement('div'); + sub.className = 'muted'; + sub.style.cssText = 'margin-bottom:24px;color:var(--muted,#5b6b7d);'; + sub.textContent = 'Read-only view of the current compaction policy, threshold, and distance to next fire.'; + overlay.appendChild(sub); + const stream = overlay; + const card = document.createElement('details'); + card.id = 'ctx-deep-demo-compact-card'; + card.className = 'compact-card compact-card-demo'; + card.open = true; + card.style.margin = '24px auto'; + card.style.maxWidth = '760px'; + const sum = document.createElement('summary'); + sum.className = 'summary'; + const badge = document.createElement('span'); + badge.className = 'compact-badge compact-badge-on-demand'; + badge.textContent = 'on-demand'; + sum.appendChild(badge); + const label = document.createElement('span'); + label.textContent = '── context compacted ──'; + sum.appendChild(label); + const tk = document.createElement('span'); + tk.className = 'tokens'; tk.textContent = '6400 tokens'; sum.appendChild(tk); + const evc = document.createElement('span'); + evc.className = 'events'; evc.textContent = 'compacted 8 events'; sum.appendChild(evc); + card.appendChild(sum); + + const data = { model: 'deepseek-chat', maxTokens: 512, shadowedTokenCount: 6400, + shadowedRange: { start: 1, end: 8 }, + summary: [{ type: 'text', text: 'Turn 1 summary retained (system prompt, tool defs, initial query, guardhint).' }], + }; + const events = window.__ctxDeepDemoEvents || []; + if (window.__dshCompactCard && window.__dshCompactCard.mountTabs) { + window.__dshCompactCard.mountTabs(card, { + document, + initial: 'config', + fillPre(body){ body.textContent = '(see events 1–8; summary compressed to 1 line)'; body.style.padding = '8px'; }, + fillPost(body){ body.textContent = data.summary[0].text; body.style.padding = '8px'; }, + fillMeta(body){ + const dl = document.createElement('dl'); dl.className = 'compact-card-tab-meta'; + for (const [k,v] of Object.entries({ Trigger: 'on-demand', Model: data.model, 'Summary cap': '≤512 tok', 'Compacted range': 'seq 1–8', 'Compacted volume': '6400 tok' })) { + const dt = document.createElement('dt'); dt.textContent = k; + const dd = document.createElement('dd'); dd.textContent = v; + dl.appendChild(dt); dl.appendChild(dd); + } + body.appendChild(dl); + }, + fillConfig(body){ + if (!window.__dshCompactConfigModel) { body.textContent = 'model missing'; return; } + const v = window.__dshCompactConfigModel.buildCompactConfigView(events); + const dl = document.createElement('dl'); dl.className = 'compact-card-tab-meta compact-config-list'; + const rows = [ + ['Threshold', v.thresholdTokens.toLocaleString() + ' tok' + (v.thresholdSource==='assumed'?' (assumed)':'')], + ['Strategy', v.strategyName], + ['Model', v.model || 'deepseek-chat'], + ['Summary cap', v.maxSummaryTokens != null ? '≤' + v.maxSummaryTokens + ' tok' : 'unknown'], + ['Triggers fired', v.triggersFired + ' this session'], + ['Tokens since last compact', v.tokensSinceLastCompact.toLocaleString() + ' tok'], + ['Tokens until next', v.tokensUntilNext.toLocaleString() + ' tok'], + ]; + for (const [k, val] of rows) { + const dt = document.createElement('dt'); dt.textContent = k; + const dd = document.createElement('dd'); dd.textContent = val; + dl.appendChild(dt); dl.appendChild(dd); + } + body.appendChild(dl); + const progWrap = document.createElement('div'); + progWrap.className = 'compact-config-progress compact-config-progress--' + v.progressLevel; + const progHead = document.createElement('div'); + progHead.className = 'compact-config-progress-head'; + const pt = document.createElement('span'); + pt.className = 'compact-config-progress-title'; pt.textContent = 'Progress to next compact'; + const pp = document.createElement('span'); + pp.className = 'compact-config-progress-pct muted small'; pp.textContent = Math.min(100, Math.round(v.progressPct)) + '%'; + progHead.appendChild(pt); progHead.appendChild(pp); + const track = document.createElement('div'); track.className = 'compact-config-progress-track'; + const fill = document.createElement('div'); fill.className = 'compact-config-progress-fill'; + fill.style.setProperty('--fill-pct', Math.min(100, Math.max(0, v.progressPct)) + '%'); + track.appendChild(fill); progWrap.appendChild(progHead); progWrap.appendChild(track); + body.appendChild(progWrap); + const note = document.createElement('div'); + note.className = 'compact-config-note muted small'; + note.textContent = 'Read-only view. Adjust in Settings › Compaction (restart-required until session/set-compact-policy lands, gap G2).'; + body.appendChild(note); + }, + }); + } + stream.appendChild(card); + document.body.appendChild(overlay); + return 'mounted'; + })()`) + console.error('compact mount ->', compactMount) + await wait(400) + await shoot('ctx-deep-03-compact-config.png') + + // -- SHOT 4: Subagent drill-down tabs ----------------------------------- + // Make sure we're on the Chat tab and any drawers/rails are hidden so the + // subagent card owns the frame. + await evj(`(function(){ + if (window.__dshTabs && window.__dshTabs.switchTo) window.__dshTabs.switchTo('chat'); + // Force-show the chat pane and hide every other pane so a mount into + // #stream can't land off-screen. + const panes = document.querySelectorAll('.pane[data-pane]'); + for (const p of panes) p.hidden = (p.getAttribute('data-pane') !== 'chat'); + const rail = document.getElementById('context-rail'); if (rail) rail.hidden = true; + const drawer = document.querySelector('.devtools-drawer'); if (drawer) drawer.style.display = 'none'; + return 'chat-clean'; + })()`) + await wait(400) + const subaMount = await evj(`(function(){ + // Build a full-page overlay that covers the whole viewport with a white + // background — this is a demo shot, so we want the subagent trace to be + // the whole story. This bypasses any pane routing quirks. + const prevOverlay = document.getElementById('ctx-deep-demo-overlay'); + if (prevOverlay) prevOverlay.remove(); + const prev = document.getElementById('ctx-deep-demo-subagent'); + if (prev) prev.remove(); + const prevCompact = document.getElementById('ctx-deep-demo-compact-card'); + if (prevCompact) prevCompact.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'ctx-deep-demo-overlay'; + overlay.style.cssText = 'position:fixed;left:0;top:0;right:0;bottom:0;background:var(--bg,#fff);z-index:9999;padding:48px 96px;overflow:auto;font-family:inherit;'; + const heading = document.createElement('h1'); + heading.style.cssText = 'font-size:24px;margin:0 0 16px 0;'; + heading.textContent = 'Subagent trace — drill-down tabs'; + overlay.appendChild(heading); + const sub = document.createElement('div'); + sub.className = 'muted'; + sub.style.cssText = 'margin-bottom:24px;color:var(--muted,#5b6b7d);'; + sub.textContent = 'Tool defs / Inbound query surface at the foot of every subagent card.'; + overlay.appendChild(sub); + const stream = overlay; + + if (!window.__dshSubagentView || !window.__dshSubagentView.buildInlineSubagentTrace) { + return 'no-view'; + } + const now = Date.now(); + const childEvents = [ + { type: 'user/message', seq: 1, time: now, data: { content: [{type:'text', text:'Locate a file called ledger.md and summarise sections 2–4.'}], source: { kind: 'plugin', plugin: 'subagent-search' } } }, + { type: 'tool/call', seq: 2, time: now+10, data: { name: 'read_file', arguments: '{"path":"ledger.md"}' } }, + { type: 'tool/call', seq: 3, time: now+20, data: { name: 'search', arguments: '{"q":"section 2"}' } }, + { type: 'tool/call', seq: 4, time: now+30, data: { name: 'read_file', arguments: '{"path":"appendix.md"}' } }, + { type: 'turn/end', seq: 5, time: now+40, data: { turn: 0, reason: { kind: 'completed' } } }, + ]; + const lastAssistantMessage = [{ type: 'text', text: '\`\`\`json\\n{"summary":"sections 2-4 cover the ledger schema","found":3}\\n\`\`\`' }]; + const spec = { + parentSessionId: 'parent-sess-1', + childSessionId: 'child-sess-a', + status: 'done', + provider: 'stdio-echo', + stopReason: 'stop', + childEvents, + lastAssistantMessage, + }; + const trace = window.__dshSubagentView.buildInlineSubagentTrace(document, spec, { collapsed: false }); + trace.id = 'ctx-deep-demo-subagent'; + trace.style.margin = '24px auto'; + trace.style.maxWidth = '760px'; + stream.appendChild(trace); + trace.open = true; + // Append the overlay LAST so it sits atop the rest of the page. + document.body.appendChild(overlay); + return { hasDrilldown: !!trace.querySelector('.subagent-drilldown') }; + })()`) + console.error('subagent mount ->', JSON.stringify(subaMount)) + await wait(400) + await shoot('ctx-deep-04-subagent.png') + + ws.close() +} +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/examples/desktop/src/renderer/compact-card.js b/examples/desktop/src/renderer/compact-card.js index 169b399ee7..9b3acec8fe 100644 --- a/examples/desktop/src/renderer/compact-card.js +++ b/examples/desktop/src/renderer/compact-card.js @@ -176,7 +176,10 @@ function buildDiffModel(data, extractText) { * @param {(bodyEl: HTMLElement) => void} [opts.fillPre] * @param {(bodyEl: HTMLElement) => void} [opts.fillPost] * @param {(bodyEl: HTMLElement) => void} [opts.fillMeta] - * @returns {{ preBody: HTMLElement, postBody: HTMLElement, metaBody: HTMLElement }} + * @param {(bodyEl: HTMLElement) => void} [opts.fillConfig] lane-ctx-deep F2 — + * optional 4th tab "Config". Omit to keep the pre-fix three-tab shape + * (the strip auto-hides the tab when the fill callback is not passed). + * @returns {{ preBody: HTMLElement, postBody: HTMLElement, metaBody: HTMLElement, configBody: HTMLElement|null }} */ function mountTabs(parent, opts) { // Resolve doc without touching a bare `document` binding (renderer harness @@ -193,11 +196,13 @@ function mountTabs(parent, opts) { strip.className = 'compact-card-tabstrip' strip.setAttribute('role', 'tablist') const initial = (opts && opts.initial) || 'post' + const hasConfig = opts && typeof opts.fillConfig === 'function' const tabs = [ { id: 'pre', label: 'Diff' }, { id: 'post', label: 'Summary' }, { id: 'meta', label: 'Policy & accounting' }, ] + if (hasConfig) tabs.push({ id: 'config', label: 'Config' }) const bodies = {} const buttons = {} for (const t of tabs) { @@ -232,12 +237,13 @@ function mountTabs(parent, opts) { if (!target) return const id = target === buttons.pre ? 'pre' : target === buttons.post ? 'post' - : target === buttons.meta ? 'meta' : null + : target === buttons.meta ? 'meta' + : (hasConfig && target === buttons.config) ? 'config' : null if (id) activate(id) }) strip.addEventListener('keydown', (ev) => { if (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight') return - const order = ['pre', 'post', 'meta'] + const order = hasConfig ? ['pre', 'post', 'meta', 'config'] : ['pre', 'post', 'meta'] const active = order.find((id) => buttons[id].getAttribute('aria-selected') === 'true') || 'post' const idx = order.indexOf(active) const next = ev.key === 'ArrowRight' ? order[(idx + 1) % order.length] : order[(idx + order.length - 1) % order.length] @@ -249,11 +255,18 @@ function mountTabs(parent, opts) { wrap.appendChild(bodies.pre) wrap.appendChild(bodies.post) wrap.appendChild(bodies.meta) + if (hasConfig) wrap.appendChild(bodies.config) parent.appendChild(wrap) if (opts && typeof opts.fillPre === 'function') opts.fillPre(bodies.pre) if (opts && typeof opts.fillPost === 'function') opts.fillPost(bodies.post) if (opts && typeof opts.fillMeta === 'function') opts.fillMeta(bodies.meta) - return { preBody: bodies.pre, postBody: bodies.post, metaBody: bodies.meta } + if (hasConfig) opts.fillConfig(bodies.config) + return { + preBody: bodies.pre, + postBody: bodies.post, + metaBody: bodies.meta, + configBody: hasConfig ? bodies.config : null, + } } if (typeof module !== 'undefined' && module.exports) { diff --git a/examples/desktop/src/renderer/compact-config-model.js b/examples/desktop/src/renderer/compact-config-model.js new file mode 100644 index 0000000000..13f05be0da --- /dev/null +++ b/examples/desktop/src/renderer/compact-config-model.js @@ -0,0 +1,187 @@ +// Pure model for the compact-card "Config" tab (lane-ctx-deep, task #51 F2). +// +// The Config tab is an info-only entrance to the compaction policy: it names +// the current threshold, the strategy, how many times the daemon has fired +// compact this session, and a "distance to next compact" progress bar. It +// is *not* an edit surface — a live editor belongs on the Settings page, +// and the tab tooltip points there ("Adjust in Settings › Compaction"). +// +// Model shape is a plain object so tests can lock it without a DOM harness. +// The `buildCompactConfigView` function is the single entry point; it takes +// a session's cached events and (optionally) a policy override the shell +// pulls from the Settings profile, and returns: +// +// { +// thresholdTokens, // e.g. 96000 (server-reported) or fallback 96k +// thresholdSource, // 'server'|'assumed' +// strategyName, // e.g. 'summarize-shadowed' / 'unknown' +// model, // summary model, e.g. 'deepseek-chat' or null +// maxSummaryTokens, // policy cap on the summary output +// triggersFired, // total compact/summary events observed +// lastCompactSeq, // seq of the last compact/summary, or null +// currentTokens, // running tokens at end of stream +// tokensSinceLastCompact,// tokens accumulated after the last compact +// tokensUntilNext, // max(threshold − tokensSinceLastCompact, 0) +// progressPct, // tokensSinceLastCompact / threshold × 100 +// progressLevel, // 'nominal'|'warn'|'high'|'critical' +// } +// +// Threshold source: we prefer the wire (`session/list` entry's +// `context.compact.threshold` if the daemon ever ships one), else fall back +// to 75% of the model's context window ("industry default"), else a hard +// fallback of 96000. The `thresholdSource` field marks which path we took +// so the tab tooltip can be honest. + +'use strict' + +const DEFAULT_THRESHOLD_TOKENS = 96000 + +/** + * Return the compact threshold (tokens) plus its provenance. + * Priority order: + * 1. `override.thresholdTokens` (Settings profile / test override). + * 2. `budgetTokens * 0.75` when a wire-reported budget is available. + * 3. DEFAULT_THRESHOLD_TOKENS (96000). + * @param {object} [opts] + * @param {number} [opts.thresholdTokens] explicit override + * @param {number} [opts.budgetTokens] wire-reported model context window + * @returns {{ tokens: number, source: 'server'|'assumed' }} + */ +function resolveThreshold(opts) { + const explicit = opts && Number.isFinite(opts.thresholdTokens) && opts.thresholdTokens > 0 + if (explicit) return { tokens: Number(opts.thresholdTokens), source: 'server' } + const budget = opts && Number.isFinite(opts.budgetTokens) && opts.budgetTokens > 0 + ? Number(opts.budgetTokens) : null + if (budget) return { tokens: Math.round(budget * 0.75), source: 'assumed' } + return { tokens: DEFAULT_THRESHOLD_TOKENS, source: 'assumed' } +} + +/** + * Roughly count tokens the same way context-meter's approx mode does: + * bytes ÷ 4 over `event.data` JSON. Duplicated here (not imported) so the + * config model stays a leaf — importing context-meter would require the + * caller to pass a tracker to keep coherent, and every callsite already + * has cachedEvents in hand. + * @param {object} event + * @returns {number} + */ +function approxTokensFor(event) { + if (!event || typeof event !== 'object') return 0 + // Precise-mode signal: honour the usage envelope if present. + if (event.type === 'assistant/message') { + const u = event.data && event.data.usage + if (u && typeof u === 'object') { + const inp = Number(u.inputTokens) + const out = Number(u.outputTokens) + const sum = (Number.isFinite(inp) ? inp : 0) + (Number.isFinite(out) ? out : 0) + if (sum > 0) return sum + } + } + const payload = event.data !== undefined ? event.data : event + try { return Math.round(JSON.stringify(payload).length / 4) } catch (_) { return 0 } +} + +function levelForPct(pct) { + if (!Number.isFinite(pct)) return 'nominal' + if (pct >= 95) return 'critical' + if (pct >= 80) return 'high' + if (pct >= 50) return 'warn' + return 'nominal' +} + +/** + * Build the full config-tab view model. + * + * @param {Array} events + * @param {object} [opts] + * @param {number} [opts.thresholdTokens] + * @param {number} [opts.budgetTokens] + * @param {string} [opts.strategyName] + * @returns {{ + * thresholdTokens:number, + * thresholdSource:'server'|'assumed', + * strategyName:string, + * model:string|null, + * maxSummaryTokens:number|null, + * triggersFired:number, + * lastCompactSeq:number|null, + * currentTokens:number, + * tokensSinceLastCompact:number, + * tokensUntilNext:number, + * progressPct:number, + * progressLevel:'nominal'|'warn'|'high'|'critical', + * }} + */ +function buildCompactConfigView(events, opts) { + const threshold = resolveThreshold(opts || {}) + + let triggers = 0 + let lastSeq = null + let lastPolicy = null + let tokensTotal = 0 + let tokensSinceLast = 0 + + if (Array.isArray(events)) { + for (const ev of events) { + if (!ev || typeof ev !== 'object') continue + const tk = approxTokensFor(ev) + tokensTotal += tk + tokensSinceLast += tk + if (ev.type === 'compact/summary') { + triggers++ + if (Number.isFinite(ev.seq)) lastSeq = ev.seq + const d = ev.data || {} + lastPolicy = { + model: typeof d.model === 'string' ? d.model : null, + maxTokens: Number.isFinite(d.maxTokens) ? d.maxTokens : null, + } + // A compact resets the "since last" counter; the shadowed range + // just replaced the running budget so tokens after should count + // from zero. + tokensSinceLast = 0 + } + } + } + + const progressPct = threshold.tokens > 0 + ? Math.round((tokensSinceLast / threshold.tokens) * 1000) / 10 + : 0 + const tokensUntilNext = Math.max(0, threshold.tokens - tokensSinceLast) + + const strategyName = (opts && typeof opts.strategyName === 'string' && opts.strategyName) + || (lastPolicy ? 'summarize-shadowed' : 'summarize-shadowed (default)') + + return { + thresholdTokens: threshold.tokens, + thresholdSource: threshold.source, + strategyName, + model: lastPolicy && lastPolicy.model, + maxSummaryTokens: lastPolicy && lastPolicy.maxTokens, + triggersFired: triggers, + lastCompactSeq: lastSeq, + currentTokens: tokensTotal, + tokensSinceLastCompact: tokensSinceLast, + tokensUntilNext, + progressPct: Math.max(0, Math.min(progressPct, 999)), + progressLevel: levelForPct(progressPct), + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + buildCompactConfigView, + resolveThreshold, + approxTokensFor, + levelForPct, + DEFAULT_THRESHOLD_TOKENS, + } +} +if (typeof window !== 'undefined') { + window.__dshCompactConfigModel = { + buildCompactConfigView, + resolveThreshold, + approxTokensFor, + levelForPct, + DEFAULT_THRESHOLD_TOKENS, + } +} diff --git a/examples/desktop/src/renderer/context-page.js b/examples/desktop/src/renderer/context-page.js index 4b627060fe..4988c3b914 100644 --- a/examples/desktop/src/renderer/context-page.js +++ b/examples/desktop/src/renderer/context-page.js @@ -79,6 +79,13 @@ saveProfile: pane.querySelector('#context-page-save-profile'), loadSample: pane.querySelector('#context-page-load-sample'), loadWorkflow: pane.querySelector('#context-page-load-workflow'), + // lane-ctx-deep additions: + topStrip: pane.querySelector('[data-context-topstrip]'), + windowBarTrack: pane.querySelector('#context-window-bar-track'), + windowBarLegend: pane.querySelector('#context-window-bar-legend'), + windowBarSummary: pane.querySelector('#context-window-bar-summary'), + interventionTrack: pane.querySelector('#context-intervention-track'), + interventionSummary: pane.querySelector('#context-intervention-summary'), } if (els.openRail) { @@ -163,6 +170,12 @@ if (els.body) els.body.classList.remove('is-empty') if (els.list) els.list.hidden = false + // lane-ctx-deep F1 + F3: render the top strip (window bar + + // intervention markers) alongside the per-turn rows. + renderWindowBar(events) + renderInterventionStrip(events) + if (els.topStrip) els.topStrip.hidden = false + const rows = model.projectTurnRows(events) state.lastRows = rows if (els.subtitle) { @@ -174,8 +187,156 @@ renderRows(rows, events, model) } + // ---- lane-ctx-deep F1: Window occupancy stacked bar ---------------------- + + function renderWindowBar (events) { + if (!els || !els.windowBarTrack) return + const api = window.__dshContextWindowBreakdown + if (!api || typeof api.computeWindowBreakdown !== 'function') return + // Pull the wire budget from the active session if we can — mirrors + // context-meter's promotion path so the "% of budget" number reads the + // same number the statusbar shows. + const budgetTokens = readActiveBudgetTokens() + const view = api.computeWindowBreakdown(events, budgetTokens ? { budgetTokens } : undefined) + const track = els.windowBarTrack + track.innerHTML = '' + // Render five stacked segments in FAMILY_ORDER — zero-token slices get + // a 0-width segment so the CSS grid keeps its shape (helps DOM tests + // count the number of segments deterministically). + for (const slice of view.slices) { + const seg = document.createElement('div') + seg.className = `context-window-seg context-window-seg--${slice.family}` + seg.style.setProperty('--seg-pct', `${Math.max(0, slice.pct)}%`) + seg.dataset.family = slice.family + seg.dataset.tokens = String(slice.tokens) + seg.dataset.pct = String(slice.pct) + const suffix = slice.family === 'tool_defs' && view.toolsFromCalls + ? ' (estimated from tool/call names)' + : slice.family === 'thinking' && view.mode === 'approx' + ? ' (approx)' + : '' + seg.title = `${slice.label}: ${slice.tokens} tok (${slice.pct}%${suffix})` + seg.setAttribute('aria-label', seg.title) + track.appendChild(seg) + } + if (els.windowBarLegend) { + els.windowBarLegend.innerHTML = '' + for (const slice of view.slices) { + const row = document.createElement('span') + row.className = `context-window-legend-item context-window-legend-item--${slice.family}` + const dot = document.createElement('span') + dot.className = `context-window-legend-dot context-window-legend-dot--${slice.family}` + const label = document.createElement('span') + label.className = 'context-window-legend-label' + label.textContent = slice.label + const value = document.createElement('span') + value.className = 'context-window-legend-value muted' + value.textContent = slice.tokens > 0 + ? `${slice.tokens.toLocaleString()} tok · ${slice.pct}%` + : '0' + row.appendChild(dot); row.appendChild(label); row.appendChild(value) + els.windowBarLegend.appendChild(row) + } + } + if (els.windowBarSummary) { + const bs = view.budgetSource === 'server' ? '' : ' (assumed)' + const modeTag = view.mode === 'precise' ? '' : ' · approx' + els.windowBarSummary.textContent = `${view.totalTokens.toLocaleString()} tok / ${view.budget.toLocaleString()} tok${bs} · ${view.budgetPct}% of budget${modeTag}` + } + } + + function readActiveBudgetTokens () { + const meter = window.__dshContextMeter + const chat = window.__dshChat + if (!meter || !chat || typeof chat.getActiveSessionId !== 'function') return null + const sid = chat.getActiveSessionId() + if (!sid) return null + // Renderer stores per-session context trackers on the state map; peek at + // the snapshot when we can, otherwise fall back to null (which the + // model translates to the 128k assumed budget). + if (window.__dshRendererState && window.__dshRendererState.sessions) { + const meta = window.__dshRendererState.sessions.get(sid) + if (meta && meta.contextTracker && typeof meta.contextTracker.snapshot === 'function') { + const snap = meta.contextTracker.snapshot() + if (snap && snap.budgetSource === 'server' && Number.isFinite(snap.budget)) return snap.budget + } + } + return null + } + + // ---- lane-ctx-deep F3: Intervention marker strip ------------------------ + + function renderInterventionStrip (events) { + if (!els || !els.interventionTrack) return + const api = window.__dshInterventionTimeline + if (!api || typeof api.collectInterventions !== 'function') return + const markers = api.collectInterventions(events) + const track = els.interventionTrack + track.innerHTML = '' + + if (markers.length === 0) { + if (els.interventionSummary) els.interventionSummary.textContent = 'no interventions this session' + const empty = document.createElement('div') + empty.className = 'context-intervention-empty muted small' + empty.textContent = 'No edit-rerun, fork, or steer events yet.' + track.appendChild(empty) + return + } + + // The strip is a timeline: position each marker by its seq relative to + // min/max seq so early interventions cluster left and late ones cluster + // right. Density permitting, this reads like a Perforce swarm marker + // strip — a scannable audit of user overrides. + const minSeq = markers[0].seq + const maxSeq = markers[markers.length - 1].seq + const span = Math.max(1, maxSeq - minSeq) + + for (const m of markers) { + const pct = span > 0 ? ((m.seq - minSeq) / span) * 100 : 50 + const marker = document.createElement('button') + marker.type = 'button' + marker.className = `context-intervention-marker context-intervention-marker--${m.kind}` + marker.style.setProperty('--marker-pos', `${pct}%`) + marker.dataset.kind = m.kind + marker.dataset.seq = String(m.seq) + marker.dataset.turn = String(m.turn) + marker.textContent = m.glyph + const previewLine = m.preview ? ` — ${m.preview}` : '' + marker.title = `${m.label} · turn ${m.turn} · seq ${m.seq}${previewLine}` + marker.setAttribute('aria-label', marker.title) + marker.addEventListener('click', () => jumpToInterventionSeq(m.seq)) + track.appendChild(marker) + } + + // Summary line — count per kind. Uses the model's summariser so tests + // can lock the same shape. + if (els.interventionSummary) { + const roll = api.summariseInterventions(markers) + const parts = roll.map((r) => `${r.count} ${r.label.toLowerCase()}${r.count === 1 ? '' : 's'}`) + els.interventionSummary.textContent = parts.length > 0 ? parts.join(' · ') : 'no interventions' + } + } + + function jumpToInterventionSeq (seq) { + // Same pattern as buildJumpBtn — switch to Chat, then scroll to the + // stream row with the matching data-seq (or data-first-seq for turn + // headers). + const tabs = window.__dshTabs + if (tabs && typeof tabs.switchTo === 'function') tabs.switchTo('chat') + requestAnimationFrame(() => requestAnimationFrame(() => { + const stream = document.getElementById('stream') + if (!stream) return + const target = stream.querySelector(`[data-seq="${seq}"]`) + || stream.querySelector(`[data-first-seq="${seq}"]`) + if (target && typeof target.scrollIntoView === 'function') { + target.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + })) + } + function renderEmpty () { if (!els) return + if (els.topStrip) els.topStrip.hidden = true if (els.list) { els.list.innerHTML = '' // Hide the empty rows container so it doesn't reserve grid track diff --git a/examples/desktop/src/renderer/context-window-breakdown.js b/examples/desktop/src/renderer/context-window-breakdown.js new file mode 100644 index 0000000000..3f708aed04 --- /dev/null +++ b/examples/desktop/src/renderer/context-window-breakdown.js @@ -0,0 +1,293 @@ +// Context-window family breakdown — pure projections for the Context page +// occupancy bar (lane-ctx-deep, task #51 F1). +// +// The Context page's window-occupancy bar splits the current session's +// accumulated context into five families and renders their token shares as +// a stacked horizontal bar with hover tooltips. Because the wire does not +// (yet) tag each event with its context-family, we run a heuristic +// classifier over `cachedEvents`: +// +// - system_prompt — session-start injections and the daemon's own +// system-role seeds (context/message events whose +// source is `{kind:'system'}` or from the compact +// plugin's system seed, plus the running system +// preamble carried by turn/start.data.systemPreamble +// when it lands). +// - tool_defs — the JSON schemas for tool defintions we ship on the +// first turn. Best proxy is turn/start.data.tools (if +// present) or tool/definitions events; otherwise we +// estimate from tool/call event NAMES (schema footprint +// ≈ 400 chars per unique tool, an SDK-typical shape). +// - thinking — assistant/reasoning events. Cost accounting-wise +// these are output tokens the model produced but they +// DO occupy the response prompt on the next turn if +// the adapter round-trips reasoning tokens. +// - responses — assistant/message content the model produced. +// - injections — every OTHER context/message (plugin injects, user +// steer, recall pulls). These are the ones the Context +// Rail already highlights. +// +// The `estimateTokens(x)` primitive uses the same heuristic the +// context-meter approx mode uses (bytes ÷ 4) so a bar whose slices sum to +// the meter's approx-tokens read matches to the token. When +// assistant/message events carry a `usage` envelope we honour it — the +// `responses` slice snaps to precise `outputTokens` and `thinking` to +// `usage.thinking` if the adapter reports it. +// +// Pure module. Tested via node:test. See: +// - test/context-window-breakdown.test.js (this task's coverage) +// - src/renderer/context-page.js (renders the bar) + +'use strict' + +// Family palette hints — the CSS owns the actual color tokens; this map +// exists so the tooltip renderer and legend agree on one label per family. +const FAMILY_ORDER = ['system_prompt', 'tool_defs', 'thinking', 'responses', 'injections'] + +const FAMILY_LABELS = Object.freeze({ + system_prompt: 'System prompt', + tool_defs: 'Tool definitions', + thinking: 'Reasoning', + responses: 'Assistant messages', + injections: 'Injections & recall', +}) + +// Rough per-tool schema footprint (chars). Copied from a survey of DSH's +// bundled MCPs — schemas run 300–500 chars per tool once JSON-encoded with +// description strings and parameter schemas. 400 sits in the middle and +// keeps the bar honest without pretending we sniffed the actual schema. +const TOOL_SCHEMA_APPROX_CHARS = 400 + +/** + * Rough byte-count proxy for one event's payload. Mirrors context-meter's + * `estimateEventBytes` (private in that module) so bar arithmetic reads the + * same as the statusbar meter under approx mode. + * @param {object} event + * @returns {number} + */ +function eventBytes(event) { + if (!event || typeof event !== 'object') return 0 + const payload = event.data !== undefined ? event.data : event + try { return JSON.stringify(payload).length } catch (_) { return 0 } +} + +function tokensFromBytes(bytes) { + return Math.max(0, Math.round((bytes || 0) / 4)) +} + +/** + * Extract explicit `usage.outputTokens` when the adapter reports one, else + * null. Kept separate from `usage.inputTokens` because outputs are what + * `responses` needs — inputs cover the whole running prompt (which is what + * ALL our slices combined represent). + */ +function outputTokensOf(ev) { + if (!ev || ev.type !== 'assistant/message') return null + const u = ev.data && ev.data.usage + if (!u || typeof u !== 'object') return null + const out = Number(u.outputTokens) + return Number.isFinite(out) ? out : null +} + +/** + * Some adapters split reasoning tokens off in `usage.thinking`. When present + * we use it verbatim for the `thinking` slice, otherwise we fall back to + * counting bytes of the reasoning event payload. + */ +function thinkingTokensOf(ev) { + if (!ev || ev.type !== 'assistant/message') return null + const u = ev.data && ev.data.usage + if (!u || typeof u !== 'object') return null + const t = Number(u.thinking) || Number(u.reasoningTokens) + return Number.isFinite(t) ? t : null +} + +/** + * Classify one event into a context family. Multi-family events (an + * assistant/message that carries a usage envelope reporting both output and + * reasoning tokens) are handled at the aggregator level — this per-event + * classifier returns the *primary* family so the bar's chunking still lines + * up with the wire event stream. + * @param {object} ev + * @returns {'system_prompt'|'tool_defs'|'thinking'|'responses'|'injections'|null} + */ +function classifyEventFamily(ev) { + if (!ev || typeof ev !== 'object' || typeof ev.type !== 'string') return null + const t = ev.type + const d = ev.data || {} + + // System-prompt seed. The daemon emits these as context/message with + // source={kind:'system'} on session start; a system preamble sometimes + // lands as its own event type too. + if (t === 'session/start' || t === 'context/system') return 'system_prompt' + if (t === 'context/message') { + const src = d.source + if (src && (src.kind === 'system' || src.kind === 'session-start')) return 'system_prompt' + // The compact plugin's own summary re-injection is also a system-level + // seed for the next turn — count it against system_prompt rather than + // muddying `injections`. + if (src && src.kind === 'plugin' && src.plugin === 'compact') return 'system_prompt' + return 'injections' + } + if (t === 'compact/summary') return 'system_prompt' + if (t === 'steering/message') return 'injections' + + // Tool definitions arrive with these type names on different adapters. + // If nothing ever lands, we synthesize a slice from tool/call event names + // in the aggregator (see toolSchemaEstimate). + if (t === 'tool/definitions' || t === 'tools/available') return 'tool_defs' + + // Reasoning vs. response. + if (t === 'assistant/reasoning') return 'thinking' + if (t === 'assistant/message' || t === 'assistant/chunk') return 'responses' + + return null +} + +/** + * Estimate tool-def slice from unique tool NAMES seen in tool/call events. + * When the wire never ships explicit tool/definitions events, this is the + * fairest proxy: N unique tools × 400-char schema each. + * @param {Array} events + * @returns {number} + */ +function toolSchemaEstimate(events) { + const seen = new Set() + for (const ev of events) { + if (ev && ev.type === 'tool/call' && ev.data && typeof ev.data.name === 'string') { + seen.add(ev.data.name) + } + } + if (seen.size === 0) return 0 + return tokensFromBytes(seen.size * TOOL_SCHEMA_APPROX_CHARS) +} + +/** + * @typedef {Object} FamilySlice + * @property {string} family + * @property {string} label + * @property {number} tokens + * @property {number} eventCount + * @property {number} pct + */ + +/** + * @typedef {Object} WindowBreakdown + * @property {Array} slices Five slices in FAMILY_ORDER. + * @property {number} totalTokens Sum across all slices. + * @property {number} budget Wire-reported context window when known, else 128000. + * @property {'server'|'assumed'} budgetSource + * @property {number} budgetPct totalTokens / budget × 100 (clamped 0..999). + * @property {'precise'|'approx'} mode Whether responses/thinking used a usage envelope anywhere. + * @property {boolean} toolsFromCalls True when the tool_defs slice was estimated from tool/call NAMES rather than an explicit tool/definitions event. + */ + +/** + * Aggregate cachedEvents into a five-family breakdown with token counts and + * percentages. Pure: no DOM, no window.* reads. + * + * Percentages sum to ≤100 (never > because they normalise against total). + * When total is zero we return zeroed slices with pct=0 so the caller can + * render the empty bar without divide-by-zero guards. + * + * @param {Array} events + * @param {object} [opts] + * @param {number} [opts.budgetTokens] Wire-reported context window; sets budgetSource='server'. + * @returns {WindowBreakdown} + */ +function computeWindowBreakdown(events, opts) { + const budgetOverride = opts && Number.isFinite(opts.budgetTokens) && opts.budgetTokens > 0 + ? Number(opts.budgetTokens) + : null + const budget = budgetOverride || 128000 + const budgetSource = budgetOverride ? 'server' : 'assumed' + + const totals = { system_prompt: 0, tool_defs: 0, thinking: 0, responses: 0, injections: 0 } + const counts = { system_prompt: 0, tool_defs: 0, thinking: 0, responses: 0, injections: 0 } + let mode = 'approx' + let toolsFromCalls = false + + if (!Array.isArray(events)) events = [] + + // Walk events, honouring `usage` envelopes when they land. Multi-family + // accounting: an assistant/message with `usage.thinking` splits its + // tokens between the thinking slice and the responses slice; otherwise + // the whole payload byte-count falls into the primary family. + for (const ev of events) { + const fam = classifyEventFamily(ev) + if (fam === null) continue + + // Precise-mode split for assistant/message: outputTokens → responses, + // usage.thinking (or reasoningTokens) → thinking. + if (ev && ev.type === 'assistant/message') { + const out = outputTokensOf(ev) + const think = thinkingTokensOf(ev) + if (out !== null || think !== null) { + mode = 'precise' + if (out !== null) { totals.responses += out; counts.responses++ } + if (think !== null) { totals.thinking += think; counts.thinking++ } + continue + } + } + + totals[fam] += tokensFromBytes(eventBytes(ev)) + counts[fam]++ + } + + // Tool defs: prefer explicit tool/definitions events (already summed + // above). Fall back to the tool-call-name proxy when the wire didn't ship + // any. We mark `toolsFromCalls` in the return so the UI's hover tooltip + // can honestly say "estimated from N unique tools" instead of "counted". + if (totals.tool_defs === 0) { + const est = toolSchemaEstimate(events) + if (est > 0) { + totals.tool_defs = est + counts.tool_defs = 1 // synthetic single-blob slice + toolsFromCalls = true + } + } + + const total = FAMILY_ORDER.reduce((s, f) => s + totals[f], 0) + const slices = FAMILY_ORDER.map((f) => { + const pct = total > 0 ? (totals[f] / total) * 100 : 0 + return { + family: f, + label: FAMILY_LABELS[f], + tokens: totals[f], + eventCount: counts[f], + pct: Math.round(pct * 10) / 10, // one decimal so tests can lock a stable shape + } + }) + + const budgetPct = budget > 0 ? Math.round((total / budget) * 100) : 0 + return { + slices, + totalTokens: total, + budget, + budgetSource, + budgetPct: Math.max(0, Math.min(budgetPct, 999)), + mode, + toolsFromCalls, + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + computeWindowBreakdown, + classifyEventFamily, + toolSchemaEstimate, + FAMILY_ORDER, + FAMILY_LABELS, + TOOL_SCHEMA_APPROX_CHARS, + } +} +if (typeof window !== 'undefined') { + window.__dshContextWindowBreakdown = { + computeWindowBreakdown, + classifyEventFamily, + toolSchemaEstimate, + FAMILY_ORDER, + FAMILY_LABELS, + TOOL_SCHEMA_APPROX_CHARS, + } +} diff --git a/examples/desktop/src/renderer/index.html b/examples/desktop/src/renderer/index.html index 0a474adc23..d9b99ae016 100644 --- a/examples/desktop/src/renderer/index.html +++ b/examples/desktop/src/renderer/index.html @@ -1184,6 +1184,26 @@ top when it shows. The duplicate "No context activity …" head was removed — the header subtitle already carries that line (see context-page.js renderEmpty()). --> + +