From aa3121a794a803129b84dc80e102f19362331146 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 27 May 2026 07:28:49 -0700 Subject: [PATCH] add \$B memory command Registers 'memory' in META_COMMANDS, wires the meta-command dispatch to a lazy-imported handler in memory-command.ts. Lazy because the import graph (cdp-bridge + memory-snapshot + buffer accessors) isn't useful to projects that never run the diagnostic. The handler assembles MemoryStructureStats from the modules that own each buffer (cdp-inspector mod history stats, activity subscriber count, console/network/dialog buffer lengths, captureBuffer bytes, inspectorSubscriber count via a new server.ts export) and calls BrowserManager.getMemorySnapshot. Output is text by default, JSON with --json so the sidebar footer and test harness can consume it programmatically. buildMemorySnapshotJson is the entry the /memory endpoint will call in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/src/commands.ts | 2 + browse/src/memory-command.ts | 115 +++++++++++++++++++++++++++++++++++ browse/src/meta-commands.ts | 7 +++ browse/src/server.ts | 5 ++ 4 files changed, 129 insertions(+) create mode 100644 browse/src/memory-command.ts diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 1af127d51..c3637fe9d 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -45,6 +45,7 @@ export const META_COMMANDS = new Set([ 'domain-skill', 'skill', 'cdp', + 'memory', ]); export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]); @@ -89,6 +90,7 @@ export function wrapUntrustedContent(result: string, url: string): string { export const COMMAND_DESCRIPTIONS: Record = { // Navigation + 'memory': { category: 'Diagnostics', description: 'Snapshot Bun heap + per-tab JS heap + Chromium process tree + bounded buffer sizes. JSON output with --json.', usage: 'memory [--json]' }, 'goto': { category: 'Navigation', description: 'Navigate to URL (http://, https://, or file:// scoped to cwd/TEMP_DIR)', usage: 'goto ' }, 'load-html': { category: 'Navigation', description: 'Load HTML via setContent. Accepts a file path under safe-dirs (validated), OR --from-file with {"html":"...","waitUntil":"..."} for large inline HTML (Windows argv safe).', usage: 'load-html [--wait-until load|domcontentloaded|networkidle] [--tab-id ] | load-html --from-file [--tab-id ]' }, 'back': { category: 'Navigation', description: 'History back' }, diff --git a/browse/src/memory-command.ts b/browse/src/memory-command.ts new file mode 100644 index 000000000..29f76d7a8 --- /dev/null +++ b/browse/src/memory-command.ts @@ -0,0 +1,115 @@ +// `$B memory` — diagnostic snapshot of Bun heap + per-tab JS heap + +// Chromium process tree + bounded buffer sizes. Lives in its own file +// because the meta-commands dispatcher imports it lazily — projects +// that never run the diagnostic don't pay the import-graph cost (CDP +// bridge, memory-snapshot types, buffer accessors). + +import type { BrowserManager } from './browser-manager'; +import { formatBytes, type MemorySnapshot, type MemoryStructureStats } from './memory-snapshot'; +import { getModificationHistoryStats } from './cdp-inspector'; +import { getSubscriberCount as getActivitySubscriberCount } from './activity'; +import { getInspectorSubscriberCount } from './server'; +import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers'; +import { getCaptureBuffer } from './network-capture'; + +/** + * Assemble the MemoryStructureStats from the modules that own each buffer. + * Browser-manager doesn't take a hard dep on every buffer-owning module — + * the snapshot caller passes them in. + */ +function collectStructureStats(): MemoryStructureStats { + return { + modificationHistory: getModificationHistoryStats(), + activitySubscribers: getActivitySubscriberCount(), + inspectorSubscribers: getInspectorSubscriberCount(), + consoleBufferLen: consoleBuffer.length, + networkBufferLen: networkBuffer.length, + dialogBufferLen: dialogBuffer.length, + captureBufferBytes: getCaptureBuffer().byteSize, + }; +} + +/** + * Pretty-print the snapshot for terminal output. JSON mode (--json) goes + * straight through JSON.stringify so the extension footer and any test + * harness can consume it programmatically. + */ +function formatSnapshotText(s: MemorySnapshot): string { + const lines: string[] = []; + lines.push( + `Bun server: RSS: ${formatBytes(s.bunServer.rss)} ` + + `heap: ${formatBytes(s.bunServer.heapUsed)} / ${formatBytes(s.bunServer.heapTotal)} ` + + `external: ${formatBytes(s.bunServer.external)}`, + ); + + if (s.processes && s.processes.length > 0) { + // Group by type so the user sees "renderer: 12" vs listing 12 separate rows. + const byType: Record = {}; + for (const p of s.processes) byType[p.type] = (byType[p.type] ?? 0) + 1; + const typeSummary = Object.entries(byType) + .map(([t, n]) => `${t}=${n}`) + .join(' '); + lines.push(`Chromium processes: ${s.processes.length} total (${typeSummary})`); + } else if (s.processes === null) { + lines.push('Chromium processes: (unavailable — see notes)'); + } else { + lines.push('Chromium processes: 0'); + } + + if (s.tabs.length > 0) { + // Sort by JS heap descending; show top 10 plus "...N more" tail. + const sorted = [...s.tabs].sort((a, b) => b.jsHeapUsed - a.jsHeapUsed); + const shown = sorted.slice(0, 10); + lines.push(`Renderers: ${s.tabs.length} tabs (top by JS heap):`); + for (const t of shown) { + const urlShort = t.url.length > 80 ? t.url.slice(0, 77) + '...' : t.url; + lines.push( + ` [${formatBytes(t.jsHeapUsed).padStart(8)} JS, ` + + `${String(t.nodes).padStart(6)} nodes, ` + + `${String(t.listeners).padStart(5)} listeners] ` + + `tab #${t.id} — ${urlShort}`, + ); + } + if (sorted.length > shown.length) { + lines.push(` ...and ${sorted.length - shown.length} more`); + } + } else { + lines.push('Renderers: (no tabs tracked)'); + } + + lines.push('─────────────────────────────────────────────────'); + lines.push('In-memory structures (Bun side):'); + const m = s.structures.modificationHistory; + lines.push( + ` modificationHistory: ${m.current} / ${m.cap} entries` + + (m.evicted > 0 ? ` (${m.evicted} evicted since reset)` : ''), + ); + lines.push(` inspectorSubscribers: ${s.structures.inspectorSubscribers}`); + lines.push(` activitySubscribers: ${s.structures.activitySubscribers}`); + lines.push(` consoleBuffer: ${s.structures.consoleBufferLen} entries`); + lines.push(` networkBuffer: ${s.structures.networkBufferLen} entries`); + lines.push(` dialogBuffer: ${s.structures.dialogBufferLen} entries`); + lines.push(` captureBuffer: ${formatBytes(s.structures.captureBufferBytes)}`); + + if (s.notes.length > 0) { + lines.push(''); + lines.push('Notes:'); + for (const n of s.notes) lines.push(` - ${n}`); + } + + return lines.join('\n'); +} + +export async function handleMemoryCommand(args: string[], bm: BrowserManager): Promise { + const jsonMode = args.includes('--json'); + const structures = collectStructureStats(); + const snapshot = await bm.getMemorySnapshot(structures); + if (jsonMode) return JSON.stringify(snapshot); + return formatSnapshotText(snapshot); +} + +/** Entry point used by the /memory HTTP endpoint — same data, always JSON. */ +export async function buildMemorySnapshotJson(bm: BrowserManager): Promise { + const structures = collectStructureStats(); + return bm.getMemorySnapshot(structures); +} diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 4008099a0..4bd0faae7 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -1161,6 +1161,13 @@ export async function handleMetaCommand( return await handleCdpCommand(args, bm); } + case 'memory': { + // Lazy import — pulls in cdp-bridge + memory-snapshot + buffer accessors + // that aren't useful for projects that never run the diagnostic. + const { handleMemoryCommand } = await import('./memory-command'); + return await handleMemoryCommand(args, bm); + } + default: throw new Error(`Unknown meta command: ${command}`); } diff --git a/browse/src/server.ts b/browse/src/server.ts index 2f6c598f7..3b0052d01 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -724,6 +724,11 @@ let inspectorTimestamp: number = 0; type InspectorSubscriber = (event: any) => void; const inspectorSubscribers = new Set(); +/** Diagnostic accessor used by the $B memory snapshot. */ +export function getInspectorSubscriberCount(): number { + return inspectorSubscribers.size; +} + function emitInspectorEvent(event: any): void { for (const notify of inspectorSubscribers) { queueMicrotask(() => {