diff --git a/src/components/useEditorMemoryProbeController.ts b/src/components/useEditorMemoryProbeController.ts index b4afaf48..309cc711 100644 --- a/src/components/useEditorMemoryProbeController.ts +++ b/src/components/useEditorMemoryProbeController.ts @@ -94,29 +94,33 @@ export function useEditorMemoryProbeController(entries: VaultEntry[]) { const run = useCallback(async (options: ProbeRunOptions = {}): Promise => { clear() - await settleAfterMount(options.settleMs ?? DEFAULT_PROBE_SETTLE_MS) - const baseline = await readMemorySnapshot() - const selectedEntries = selectProbeEntries(entries, options) - const loadedTargets = await Promise.all(selectedEntries.map(loadProbeTarget)) - const afterContentLoad = await readMemorySnapshot() - const batchSize = Math.max(1, options.batchSize ?? DEFAULT_PROBE_BATCH_SIZE) - const settleMs = options.settleMs ?? DEFAULT_PROBE_SETTLE_MS - const steps: ProbeStep[] = [] + try { + await settleAfterMount(options.settleMs ?? DEFAULT_PROBE_SETTLE_MS) + const baseline = await readMemorySnapshot() + const selectedEntries = selectProbeEntries(entries, options) + const loadedTargets = await Promise.all(selectedEntries.map(loadProbeTarget)) + const afterContentLoad = await readMemorySnapshot() + const batchSize = Math.max(1, options.batchSize ?? DEFAULT_PROBE_BATCH_SIZE) + const settleMs = options.settleMs ?? DEFAULT_PROBE_SETTLE_MS + const steps: ProbeStep[] = [] - for (const count of resolveMountCounts(loadedTargets.length, batchSize)) { - const mountedTargets = loadedTargets.slice(0, count) - setTargets(mountedTargets) - await waitForReadyPaths(mountedTargets.map(target => target.entry.path)) - await settleAfterMount(settleMs) - steps.push(createProbeStep(mountedTargets, await readMemorySnapshot(), baseline)) - } + for (const count of resolveMountCounts(loadedTargets.length, batchSize)) { + const mountedTargets = loadedTargets.slice(0, count) + setTargets(mountedTargets) + await waitForReadyPaths(mountedTargets.map(target => target.entry.path)) + await settleAfterMount(settleMs) + steps.push(createProbeStep(mountedTargets, await readMemorySnapshot(), baseline)) + } - return { - targets: loadedTargets.map(summarizeTarget), - baseline, - afterContentLoad, - contentLoadDeltaBytes: memoryDelta(afterContentLoad, baseline), - steps, + return { + targets: loadedTargets.map(summarizeTarget), + baseline, + afterContentLoad, + contentLoadDeltaBytes: memoryDelta(afterContentLoad, baseline), + steps, + } + } finally { + clear() } }, [clear, entries, waitForReadyPaths]) diff --git a/src/hooks/editorBlockResolution.ts b/src/hooks/editorBlockResolution.ts index 287058e1..cb50da7a 100644 --- a/src/hooks/editorBlockResolution.ts +++ b/src/hooks/editorBlockResolution.ts @@ -33,7 +33,7 @@ export type CachedTabState = { sourceContent: NoteContent } -const TAB_STATE_CACHE_LIMIT = 24 +const TAB_STATE_CACHE_LIMIT = 8 export function cacheEditorState( cache: Map, diff --git a/src/hooks/editorParsedBlockCache.ts b/src/hooks/editorParsedBlockCache.ts index 298f3afa..60991e48 100644 --- a/src/hooks/editorParsedBlockCache.ts +++ b/src/hooks/editorParsedBlockCache.ts @@ -9,9 +9,9 @@ export interface ParsedNoteBlockCacheEntry { vaultPath?: string } -export const PARSED_NOTE_BLOCK_CACHE_LIMIT = 16 -export const PARSED_NOTE_BLOCK_ENTRY_MAX_BYTES = 2 * 1024 * 1024 -export const PARSED_NOTE_BLOCK_CACHE_MAX_SOURCE_BYTES = 12 * 1024 * 1024 +export const PARSED_NOTE_BLOCK_CACHE_LIMIT = 6 +export const PARSED_NOTE_BLOCK_ENTRY_MAX_BYTES = 768 * 1024 +export const PARSED_NOTE_BLOCK_CACHE_MAX_SOURCE_BYTES = 3 * 1024 * 1024 const parsedBlockCache = new Map() const sourceSizeEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null diff --git a/src/hooks/editorParsedBlockPreload.test.tsx b/src/hooks/editorParsedBlockPreload.test.tsx index 77d8d6f3..6ef94d85 100644 --- a/src/hooks/editorParsedBlockPreload.test.tsx +++ b/src/hooks/editorParsedBlockPreload.test.tsx @@ -103,7 +103,7 @@ describe('useParsedBlockPreload', () => { vi.restoreAllMocks() }) - it('prepares parsed blocks for a warmed large markdown note after foreground work is idle', async () => { + it('does not preparse warmed large markdown notes while memory-heavy preload is disabled', async () => { const refs = makeRefs() const prepareParsedBlocks = vi.fn(async () => {}) const entry = makeEntry() @@ -111,12 +111,7 @@ describe('useParsedBlockPreload', () => { renderParsedPreload(refs, prepareParsedBlocks) await emitResolvedContent(entry, '# Large\n\nPrepared body.') - expect(prepareParsedBlocks).toHaveBeenCalledWith({ - entry, - path: entry.path, - content: '# Large\n\nPrepared body.', - parsedBlockPreload: true, - }) + expect(prepareParsedBlocks).not.toHaveBeenCalled() }) it('skips raw prefetches that are not parsed-block warmup candidates', async () => { diff --git a/src/hooks/editorParsedBlockPreload.ts b/src/hooks/editorParsedBlockPreload.ts index 4099003f..64199fcb 100644 --- a/src/hooks/editorParsedBlockPreload.ts +++ b/src/hooks/editorParsedBlockPreload.ts @@ -5,7 +5,7 @@ import { subscribeNoteContentResolved } from './noteContentCache' export const PARSED_BLOCK_PRELOAD_MIN_BYTES = 32 * 1024 export const PARSED_BLOCK_PRELOAD_DELAY_MS = 1800 export const PARSED_BLOCK_PRELOAD_FOREGROUND_IDLE_MS = 1500 -export const PARSED_BLOCK_PRELOAD_ENABLED = true +export const PARSED_BLOCK_PRELOAD_ENABLED = false type PrepareParsedBlocks = (event: NoteContentResolvedEvent) => Promise diff --git a/src/hooks/noteContentCache.ts b/src/hooks/noteContentCache.ts index b93196a3..fc53a808 100644 --- a/src/hooks/noteContentCache.ts +++ b/src/hooks/noteContentCache.ts @@ -47,9 +47,9 @@ const prefetchQueue: NoteContentCacheEntry[] = [] const resolvedListeners = new Set() const contentSizeEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null -export const NOTE_CONTENT_CACHE_LIMIT = 48 -export const NOTE_CONTENT_ENTRY_MAX_BYTES = 2 * 1024 * 1024 -export const NOTE_CONTENT_CACHE_MAX_BYTES = 24 * 1024 * 1024 +export const NOTE_CONTENT_CACHE_LIMIT = 24 +export const NOTE_CONTENT_ENTRY_MAX_BYTES = 1024 * 1024 +export const NOTE_CONTENT_CACHE_MAX_BYTES = 8 * 1024 * 1024 export const NOTE_CONTENT_PREFETCH_CONCURRENCY = 4 const NOTE_CONTENT_REQUEST_CANCELED = 'Note content request canceled' const NOTE_CONTENT_LOAD_RETRY_DELAYS_MS = [120, 320, 800] as const diff --git a/src/lib/aiAgentStreamCallbacks.test.ts b/src/lib/aiAgentStreamCallbacks.test.ts index 6739649b..4bb0261e 100644 --- a/src/lib/aiAgentStreamCallbacks.test.ts +++ b/src/lib/aiAgentStreamCallbacks.test.ts @@ -116,6 +116,35 @@ describe('aiAgentStreamCallbacks', () => { ]) }) + it('truncates large tool output retained in message history', () => { + const messages = createMessageStore([ + { + id: 'msg-1', + userMessage: 'Question', + actions: [], + isStreaming: true, + }, + ]) + const callbacks = createStreamCallbacks({ + agent: 'claude_code', + messageId: 'msg-1', + vaultPath: '/vault', + setMessages: messages.setMessages, + setStatus: createStatusStore().setStatus, + abortRef: { current: { aborted: false } }, + responseAccRef: { current: '' }, + toolInputMapRef: { current: new Map() }, + fileCallbacksRef: { current: undefined }, + }) + + callbacks.onToolStart('Bash', 'tool-1') + callbacks.onToolDone('tool-1', 'x'.repeat(20_050)) + + const output = messages.getMessages()[0].actions[0].output + expect(output?.length).toBeLessThan(20_050) + expect(output).toContain('[Tool output truncated: 50 chars omitted]') + }) + it('marks pending actions as failed when the stream errors', () => { const messages = createMessageStore([ { diff --git a/src/lib/aiAgentStreamCallbacks.ts b/src/lib/aiAgentStreamCallbacks.ts index 30c41445..787c035a 100644 --- a/src/lib/aiAgentStreamCallbacks.ts +++ b/src/lib/aiAgentStreamCallbacks.ts @@ -13,6 +13,8 @@ import { trackAiAgentResponseFailed, } from './productAnalytics' +const MAX_RETAINED_TOOL_OUTPUT_CHARS = 20_000 + export interface StreamMutationContext { agent: AiAgentId messageId: string @@ -39,6 +41,16 @@ function finalResponseText(response: string, agent: AiAgentId): string { return `${getAiAgentDefinition(agent).label} finished without returning a reply.` } +function retainedToolOutput(output: string | undefined): string | undefined { + if (!output || output.length <= MAX_RETAINED_TOOL_OUTPUT_CHARS) return output + + const omitted = output.length - MAX_RETAINED_TOOL_OUTPUT_CHARS + return [ + output.slice(0, MAX_RETAINED_TOOL_OUTPUT_CHARS), + `[Tool output truncated: ${omitted} chars omitted]`, + ].join('\n\n') +} + export function createStreamCallbacks(context: StreamMutationContext) { const { messageId, @@ -97,7 +109,9 @@ export function createStreamCallbacks(context: StreamMutationContext) { updateMessage(setMessages, messageId, (message) => ({ ...message, actions: message.actions.map((action) => ( - action.toolId === toolId ? { ...action, status: 'done' as const, output } : action + action.toolId === toolId + ? { ...action, status: 'done' as const, output: retainedToolOutput(output) } + : action )), })) },