mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
53 KiB
53 KiB
Project Memory
Quick reference for anyone starting with Claude on this project. Updated by the memory-keeper agent.
Fixes & Gotchas
- macOS close button does not dismiss window (issue #2049) —
WebviewWindow::hide()routes through CEF'sWindowMessage::Hide→cef::Window::hide()which does NOT propagate to the visible NSWindow frame. Fix: useAppHandle::hide()which calls[NSApp hide:]viaset_application_visibility(false). This is macOS-only (#[cfg(target_os = "macos")]); theCloseRequestedhandler is inapp/src-tauri/src/lib.rsaround line 2809. PR #2118. - ServiceBlockingGate CORS errors — The gate calls
openhumanServiceStatus()andopenhumanAgentServerStatus()at startup. These usedcallCoreRpc()which falls back to rawfetch()when socket isn't connected yet, causing CORS errors. Fix: route throughinvoke('core_rpc_relay')instead (Tauri IPC, no CORS). - Socket not connected at startup —
SocketProvideronly connects when a Reduxauth.tokenis set. At fresh launch (no token), socket is null, so anycallCoreRpc()call falls back tofetch(). Always useinvoke('core_rpc_relay')for local sidecar RPC calls. openhuman.agent_server_statusdoesn't exist — This RPC method is not registered in the core. The gate checks it but it always errors. The gate passes if either service is Running OR agent server is running OR core is reachable.- Cargo incremental builds can serve stale UI — If the app shows old frontend after a Rust rebuild, run
cargo clean --manifest-path app/src-tauri/Cargo.tomlbefore rebuilding. build.rsmissingrerun-if-changedcauses stale ACL / "Command not found" at runtime —app/src-tauri/build.rshad nocargo:rerun-if-changeddirectives forpermissions/orcapabilities/. Adding/changing TOML or JSON files there did not re-triggertauri-build, so ACL tables were stale and registered commands silently failed. Fixed by addingprintln!("cargo:rerun-if-changed=permissions")andprintln!("cargo:rerun-if-changed=capabilities")inbuild.rs(issue #270). Also: any new Tauri command must have a matching entry in apermissions/TOML file or it will hit the same error even if it is ingenerate_handler!.- macOS deep links require .app bundle —
pnpm tauri devdoes NOT support deep links. Must usepnpm tauri build --debug --bundles app.
Strict Rules
- No dynamic imports in
app/src/— Use staticimportat file top. Guard call sites withtry/catchfor Tauri/non-Tauri safety. See CLAUDE.md. - Service RPC calls must use Tauri IPC — Never use
callCoreRpc()for service operations. Useinvoke('core_rpc_relay', { request: { method, params } }). - All frontend env vars go through
app/src/utils/config.ts— Never readimport.meta.env.VITE_*directly in other files. Import from config.ts instead. See.env.examplefiles for the full list. - Always run checks before commit —
pnpm workspace openhuman-app compile,pnpm lint,pnpm format:check,pnpm build,pnpm tauri dev. Husky hooks enforce some but run all manually first. - Stage specific files — Never
git add -A. Alwaysgit add <specific-files>.
Workflow
- Agent order: architectobot (plan) → user approval → codecrusher (implement) → architectobot (verify)
- Always read CLAUDE.md first before any issue work
- Ask user when in doubt — never assume scope or approach
- PRs target upstream —
tinyhumansai/openhumanmain branch, not fork - GraphQL project board can return empty —
gh project item-liston board #2 sometimes returns no items even when issues exist. Fall back togh issue list --repo tinyhumansai/openhumandirectly. - jq regex: use POSIX classes, not
\s— jq'stest()uses ONIG regex;\sis not supported. Use[[:space:]]for whitespace matching ingh pr list --json ... --jqpipelines. - PR conflict check:
Closes #Nsyntax not always used —gh pr list --jq "select(.body | test('Closes #N'))"misses PRs that mention an issue thematically without a closing keyword. Also search PR title + body for the raw issue number (#N) with broader matching to catch related open PRs before claiming an issue is unassigned. pnpm debug unitpath is relative toapp/src/— Passproviders/__tests__/Foo.test.tsx, notapp/src/providers/__tests__/Foo.test.tsx.- Prettier must run after codecrusher adds test cases — New test blocks often fail
format:check. Runpnpm --filter openhuman-app formatbefore committing when test files are touched. - Check for existing PRs before implementing — When the workflow picks an issue, search open PRs for the issue number and related keywords before starting work. A contributor may have already shipped the fix (e.g. PR #2101 for issue #2075).
- Project board
gh project item-listpaginates closed items first — The first 100 items returned are often CLOSED. Must--limit 500or paginate to find open/unassigned work. Fall back togh issue list --repo tinyhumansai/openhuman --state openfor reliability.
Local AI Presets
- Tier system lives in
src/openhuman/local_ai/presets.rs— single source of truth for tier→model ID mapping. To change default models for a release, editall_presets()there. - Device detection uses
sysinfocrate (src/openhuman/local_ai/device.rs). Apple Silicon = GPU always; others = best-effort. OPENHUMAN_LOCAL_AI_TIERenv var overrides the selected tier at config load time (inload.rs).- Frontend tier selector is in
LocalModelPanel.tsxunder Settings > Local AI Model. UsescoreRpcClientto call 3 RPC methods:local_ai_device_profile,local_ai_presets,local_ai_apply_preset. - Default config maps to Medium tier (
gemma3:4b-it-qat). If someone changesmodel_ids.rsdefaults, they should keeppresets.rsin sync. ollama_base_url()previously ignoredconfig.local_ai.base_url— It only read env vars. Fixed in feat/ollama-external-server-url by addingollama_base_url_from_config(config). Any new Ollama URL resolution must go through the config-aware helper, not the env-only one.LocalModelDebugPanel.tsxmust seed URL from config on mount — Previously initializedollamaBaseUrlInputto the hardcoded default and only loaded the persisted URL when diagnostics ran. Fix:useEffecton mount callsopenhumanGetConfig()and sets state fromconfig.local_ai.base_url. Pattern to follow for any settings field backed by Rust config.
Core process (in-process, no sidecar)
- Core runs in-process as a tokio task inside the Tauri host (sidecar removed in PR #1061). Lifecycle owned by
core_process::CoreProcessHandleinapp/src-tauri/src/core_process.rs. pnpm core:stageis a no-op echo — there is notarget/debug/openhuman-corebinary to copy intoapp/src-tauri/binaries/. Rebuilding viapnpm dev:appis enough to pick up new RPC methods in the in-process server.- Token auth: per-launch hex bearer in
OPENHUMAN_CORE_TOKEN, exposed to the renderer viacore_rpc_tokenTauri command. Always call RPC throughinvoke('core_rpc_relay', { request: { method, params } })— avoids the CORS preflightfetch()would trigger. - Stale-listener policy (#1130): if the port is already in use, the handle probes
GET /to decide if it's an OpenHuman core, then term/force-kill with PID revalidation guarding against PID reuse. SetOPENHUMAN_CORE_REUSE_EXISTING=1to attach to a manually-startedopenhuman-core servefor debugging. - Default port
7788. Stage token (when running standalone):~/.openhuman-staging/core.tokenunderOPENHUMAN_APP_ENV=staging.
Onboarding System
- OnboardingOverlay is a portal, not a route — mounted in
App.tsx, renders viacreatePortalat z-[9999]. There is no/onboardingroute inAppRoutes.tsx. Gating is purely Redux + workspace flag. - Deferred onboarding —
onboardingDeferredByUserinauthSlice.ts(persisted via redux-persist) durably tracks when a user clicks "Set up later".SetupBanner.tsxprovides the resume path. selectHasIncompleteOnboardingis unused in production code — only tested. Don't use it for new features.- Logout must clear onboarding state —
_clearTokenresetsisOnboardedByUser+isAnalyticsEnabledByUser. Workspace flag (.skip_onboardingfile) is cleared viaopenhumanWorkspaceOnboardingFlagSet(false)in SettingsHome logout, clearAllAppData, and UserProvider auth recovery. All three paths must stay in sync. OnboardingOverlay local state (userLoadTimedOut,onboardingCompleted) is reset via auseEffectwatchingtoken— iftokenbecomes null, both reset to initial values (#192). - LocalAI download errors must surface —
LocalAIStephas anonDownloadErrorcallback prop;Onboarding.tsxrenders an error banner viacreatePortalwhen it fires. Without this, download failures are silently swallowed (#194). formatBytes/formatEta/progressFromStatus— shared inapp/src/utils/localAiHelpers.ts. Home.tsx and LocalModelPanel.tsx still have local copies (can be migrated later).- Notification z-index stacking — ErrorReportNotification: z-[10000] bottom-right. OnboardingOverlay: z-[9999]. LocalAIDownloadSnackbar: z-[9998] bottom-left.
- React Compiler lint —
useCallbackdeps must match the full inferred closure. Usinguser?._idas dep when the closure capturesusertriggerspreserve-manual-memoization. Useuseras the dep instead. setStatein effects — ESLintreact-hooks/set-state-in-effectcatches synchronous setState in useEffect bodies. Use lazy initializers, compute at render, or event handlers instead.- Walkthrough is multi-page (9 steps) — Uses react-joyride v3
Step.beforeasync hooks to navigate between pages (/home → /chat → /skills → /intelligence → /settings → /home). Steps factory:createWalkthroughSteps(navigate)inwalkthroughSteps.ts.waitForTarget(selector, timeout)polls via rAF until DOM target appears. Re-trigger from Settings viaresetWalkthrough()+walkthrough:restartCustomEvent.AppWalkthroughis mounted inside Router context (can useuseNavigatedirectly). BottomTabBar attr istab-notifications(nottab-automation). OnboardingNextButtonis the shared primary CTA — All onboarding steps useapp/src/pages/onboarding/components/OnboardingNextButton.tsx. New steps must use this component for the primary navigation button.- Onboarding is 3 steps: Welcome(0) → Skills(1) → ContextGathering(2) — Referral step was removed (issue #752).
ReferralApplyStep.tsxis preserved but unused.referralApiis still used on the Rewards page.WelcomeStepno longer hasnextDisabled/nextLoading/nextLoadingLabelprops (those gated on referral stats prefetch). - Recovery Phrase moved to Settings — MnemonicStep was removed from onboarding (was step 5). The same BIP39 generate/import functionality now lives in
app/src/components/settings/panels/RecoveryPhrasePanel.tsx, accessible via Settings > Recovery Phrase. Onboarding completion logic moved intohandleSkillsNextinOnboarding.tsx. - E2E tests find onboarding buttons by label text —
shared-flows.ts,login-flow.spec.ts,auth-access-control.spec.ts, andvoice-mode.spec.tslocate buttons by their visible label. Changing button labels requires updating all four files. Note:voice-mode.spec.tsstill references legacy labels that don't match current steps (pre-existing tech debt). ScreenPermissionsStepalways shows Continue — The Continue button is always visible regardless of permission grant status, allowing users to skip the permissions step (#274).- OnboardingOverlay RPC/Redux race condition —
getOnboardingCompleted()RPC can fail (sidecar not ready, timeout); the old catch block hardcodedsetOnboardingCompleted(false), ignoring the persistedisOnboardedByUserRedux flag. Fix: readselectIsOnboardedfromauthSelectors.tsin the catch block as fallback, and combine both flags inshouldShow:!onboardingCompleted && !isOnboardedRedux. Either flag beingtrueis sufficient to skip onboarding (#197). DEV_FORCE_ONBOARDINGwas a no-op — The old ternary had identical branches; fixed to actually force-show when the flag is set.isOnboardedReduxmust be in useEffect deps — When reading a selector value inside a useEffect, add it to the dependency array or the effect won't re-run when Redux state changes.
CoreStateProvider & Auth Bootstrap
- Auth session tokens are NOT in Redux persist — They live entirely in the Rust sidecar, fetched via
fetchCoreAppSnapshot()RPC.PersistGateonly gates non-auth state (AI config, threads, channel connections).CoreStateProviderbootstrap is the critical auth path. CoreStateProviderprematureisBootstrapping: falsecauses blank Settings — If the initial RPC call fails (sidecar still starting), the old error handler setisBootstrapping: falseimmediately, causingProtectedRouteto redirect to/before the 3s poll could recover. Fix (issue #413): keepisBootstrapping: trueon initial failure, let the poll retry, give up after 5 attempts (~15s).CoreStateProvideris consumed by ~25 components — Changes to its state shape or bootstrap behavior affect routes, socket, onboarding, nav, settings, and hooks. Treat it as a high-blast-radius file.bootstrapFailCountRefretry counter bug (issue #2158) — The ref is a cumulative lifetime counter; logging it againstMAX_BOOTSTRAP_RETRIES(5) as denominator produced impossibleattempt 11/5. Fix: distinguish bootstrap phase ("attempt X/5") from continuous-poll phase (separate message, 10s backoff). Reset the counter to 0 on any successful snapshot fetch.- Settings is a full route, not a modal —
/settings/*uses nested<Routes>inSettings.tsx. The.claude/rules/15-settings-modal-system.mddoc describing a portal/modal approach is outdated. A catch-all<Route path="*">redirects unmatched sub-paths to/settings. PersistGate loading={null}causes flash — Changed toloading={<RouteLoadingScreen />}(issue #413).RouteLoadingScreenaccepts an optionallabelprop (defaults to "Initializing OpenHuman...") and can be rendered with no props.
Build Blockers: macOS Tahoe + whisper-rs
whisper-rsbreakscargo buildon macOS Tahoe (Apple Silicon) — Added in main viawhisper-rs = "0.16"(voice feature #178). Apple clang 21+ refuses-mcpu=nativewhen--target=arm64-apple-macosxis also set. This is NOT fixable by updating CLT.- Root cause — ggml cmake sets
GGML_NATIVE=ONby default; the cmake crate appends--targetto clang, triggering the incompatibility. Happens even with the latest toolchain. - Workaround — Patch
~/.cargo/registry/src/index.crates.io-*/whisper-rs-sys-0.15.0/build.rs: addconfig.define("GGML_NATIVE", "OFF");(fortarget_os = "macos" && target_arch = "aarch64") just before theconfig.build()call. - Patch is fragile — Resets on
cargo clean, crate version bump, or registry re-download. Deleting build cache alone (target/debug/build/whisper-rs-sys-*) is NOT enough — cmake regenerates with the same bad flags. - Correct fix — Needs an upstream patch in
whisper-rs-sysor a Cargo feature to opt out ofGGML_NATIVEon Apple Silicon cross-builds.
UI Redesign (Light Theme — April 2026)
- Full dark-to-light redesign shipped — All pages, components, and settings panels converted from dark glass-morphism to clean light theme based on Figma designs by Mithil (
OpenHuman-Prodfile, node2094-250136for tokens). - Design tokens saved in
my_docs/figma-design-tokens.md— neutral grayscale, primary blue#2F6EF4, success#34C759, alert#E8A728, error#EF4444, SF Pro typography scale. - Navigation changed: Left
MiniSidebar→ bottomBottomTabBar(Home, Chat, Skills, Intelligence, Automation, Notification). Settings accessible via gear icon on Home page header. - MiniSidebar.tsx retained (not deleted) as backup.
BottomTabBar.tsxis the active nav component. - Agent message bubbles need
bg-stone-200/80(notbg-stone-100) on#F5F5F5background —bg-stone-100is nearly invisible. - ~55 files touched — purely CSS class changes, zero logic/handler/state changes.
Upsell / Billing (Phase 1 — Issue #403)
- Upsell components live in
app/src/components/upsell/—UpsellBanner,UsageLimitModal,GlobalUpsellBanner,upsellDismissState. Shared hook:app/src/hooks/useUsageState.ts. - Usage data sources —
creditsApi.getTeamUsage()returnsTeamUsage(rolling 10h spend/cap + weekly budget/remaining).billingApi.getCurrentPlan()returnsCurrentPlanData(plan tier, caps, subscription status). Both go throughcallCoreCommand(core RPC). No Redux slice — all local hook state. - Module-level cache in
useUsageState—_cachevariable with 60s TTL prevents duplicate API calls when multiple components mount simultaneously. New pattern; do not remove. - Banner dismiss state uses localStorage (prefix
openhuman:upsell:), not Redux — consistent with CLAUDE.md exception for ephemeral UI state. - Phased rollout — Phase 1 = banners + limit modal + hook. Phase 2 = onboarding upsell + analytics. Phase 3 = remote config + A/B testing.
- "5-hour" label stragglers in Conversations.tsx —
LimitPilllabel and its hover tooltip still say "5h" / "5-hour". Commit 8c52236's "10-hour" terminology refactor missed those two spots. getTeamUsage()now normalizes vianormalizeTeamUsage()— Added in issue #482. The Rust sidecar passes backend JSON through opaquely (src/openhuman/team/ops.rs), so the TS client must normalize field names and types. Pattern matches existingnormalizeCreditBalance()in the same file. Any new billing API that returns raw backend data should follow the same normalize-at-the-client pattern.- Two separate
TeamUsagetypes exist —creditsApi.ts:24(billing: cycle budget, limits) andtypes/team.ts:11(team model: daily token limit). Different import paths, no collision, but confusing.
Settings & Skills Reorganization (Issue #396)
- Settings is NOT a modal — It's a full route (
/settings/*) with nested<Routes>. The.claude/rules/15-settings-modal-system.mddoc is outdated. - SettingsHeader breadcrumbs — All panels now receive
breadcrumbsfromuseSettingsNavigation()hook. The hook derives breadcrumbs from the current route path. When adding a new settings panel, destructurebreadcrumbsfrom the hook and pass to<SettingsHeader>. - Standard settings padding — All settings panel content areas use
p-4 space-y-4. Don't deviate. - Dead code removed —
TauriCommandsPanel,useSettingsAnimation,SettingsPanelLayout,SettingsBackButton,ProfilePanel,AdvancedPanel,SkillsPanel,SkillsGridwere all deleted. Don't re-create them. - Skills page is the single management surface — Browser Access toggle moved from SkillsPanel to the Skills page. There is no
/settings/skillsroute anymore. - Panel decomposition — LocalModelPanel, AutocompletePanel, CronJobsPanel, ScreenIntelligencePanel were split into sub-components in subdirectories. Each orchestrator is ≤ ~300 lines.
- UnifiedSkillCard — All skill types (built-in, channels, 3rd party) use
UnifiedSkillCardfromapp/src/components/skills/SkillCard.tsx. Secondary actions use an overflow menu.data-testidattributes (skill-sync-button-*,skill-debug-button-*) must be preserved. - SkillSearchBar + SkillCategoryFilter — New components in
app/src/components/skills/for search and category filtering on the Skills page.
Composio Backend URL Bug (Issue #2075, PR #2101)
effective_backend_api_urlenv-fallback branch skipped normalization — Insrc/api/config.rs, the override branch normalized vianormalize_backend_api_base_urlbut the env-fallback branch (OPENHUMAN_BACKEND_API_URL) did not, so scheme-less URLs likeapi.example.comwere used raw. Fix: normalize the env-fallback branch too (3-layer defense: config → env-fallback →IntegrationClient::new).normalize_backend_api_base_urlandredact_url_for_logarepub(crate)— Available for reuse acrosssrc/api/after PR #2101 merge.
Composio Identity (Issue #691)
ProviderUserProfile.profile_url— New optional field on the struct insrc/openhuman/composio/providers/types.rs. Providers should populate it when available from upstream profile payloads.identity_setcallback in default flow —ComposioProvider::on_connection_created()insrc/openhuman/composio/providers/traits.rsnow callsidentity_set(&profile)after profile fetch.composio_get_user_profileinsrc/openhuman/composio/ops.rsalso routes persistence throughidentity_set.- Facet key format for connected identities —
skill:{toolkit}:{identifier}:{field}(e.g.skill:gmail:user@example.com:profile_url). UseFacetType::Skillwhen storing. Toolkit and identifier together form the unique identity; field is the attribute name. - Connected identities loader/renderer —
src/openhuman/composio/providers/profile.rscontainsload_connected_identities()(readsskill:*facets) andrender_connected_identities_section()(formats markdown for prompt injection). Keep rendering logic there, not in prompt modules. - Prompt injection helper —
render_connected_identitiesis imported and called inwelcome/prompt.rs,orchestrator/prompt.rs, andintegrations_agent/prompt.rsto inject a "Connected accounts:" block. Add it to any new agent prompt that needs Composio context.
Agent Timeout & Cancellation (Issue #715)
- Frontend silence timer, not a wall-clock limit —
armSilenceTimerinapp/src/pages/Conversations.tsxfires if 120s (fixed to 600s) pass with zero inference progress events. It re-arms on everytool_call,tool_result,iteration_start, etc., so long-running tool chains that keep emitting events are not cut off. - Rust-side HTTP timeout is separate —
src/openhuman/providers/compatible.rssets a 120sreqwestclient timeout on LLM calls. Not changed in #715; relevant if a single LLM round-trip itself stalls for >2 min. - Manual cancel path —
chatCancel()inapp/src/services/chatService.ts→openhuman.channel_web_cancelRPC →cancel_chat()insrc/openhuman/channels/providers/web.rs. Fully implemented; the silence timer is an automatic fallback.
Webhook & Cron Triggers (Issue #726)
- Webhook bus was hardcoded 410 —
src/openhuman/webhooks/bus.rsWebhookRequestSubscriber::handle()returned 410 "skill runtime removed" for ALL incoming webhooks. Now routes to echo/agent/skill/404 based onTunnelRegistration.target_kind. - WebhookRouter access from bus.rs — Router lives in
SocketManager::shared.webhook_router(waspub(super)). Addedpub fn webhook_router(&self)accessor onSocketManager; bus.rs reaches it viaglobal_socket_manager().webhook_router(). TriggerSourceenum: three update points — Adding new variants requires updating: (a)slug()match inenvelope.rs, (b) exhaustive test match, (c)handle_triage_evaluatestring match inagent/schemas.rs(usesp.source.as_str(), not the enum directly).CronJobTriggered/CronJobCompletedwere never published — Defined inevents.rsand used in tests but never emitted. Now published byexecute_and_persist_job()inscheduler.rs. Adding fields to these variants requires updating ~5 construction sites:cron/bus.rs,composio/bus.rs,tree_summarizer/bus.rs,channels/proactive.rs, andevents.rstests.- Webhook ops were all stubs —
list_registrations,list_logs,clear_logs,register_echo,unregister_echoinops.rsall returned empty. Now backed by the real router via aget_router()helper. GGML_NATIVE=OFFfor cargo check — Sidestepping the whisper-rs macOS Tahoe build blocker forcargo check:GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml. Allows compilation checks without the cmake failure.
Agent Runtime Behavior
sandbox_mode = "read_only"in agent.toml is metadata only — Never enforced at runtime. Actual security policy comes fromconfig.autonomy(global), defaulting toSupervised. Adding write tools to a read-only agent works at runtime but violates documented intent.max_iterationshard-fails, not graceful truncation — When the welcome agent (or any agent) hitsmax_iterations,tool_loop.rs:705callsanyhow::bail!. There is no graceful truncation. Budget iterations carefully.- Archivist agent auto-extracts memory — It processes conversation history and persists preferences/facts into
user_profileautomatically. Agents do not need to explicitly callmemory_storeto persist conversational insights. cargo check/cargo testfails on main (llama.cpp cmake) —llama.cpp's cmake build script uses-mcpu=native, which is unsupported on Apple clang 21+ with--target=arm64-apple-macosx. Pre-existing issue onmain, not branch-specific. Frontend checks (typecheck, lint, format) are unaffected. Workaround: setGGML_NATIVE=OFF(same fix as whisper-rs above).
Cron Scheduler
- Cron loop was never spawned —
tokio::spawn(cron::scheduler::run(config))was missing fromsrc/core/jsonrpc.rs. Added after the update scheduler spawn, gated onconfig.cron.enabled. Without it, scheduled jobs never auto-fire at startup (issue #830).
Build & Tooling Gotchas
pnpm typecheckscript was renamed — Checkapp/package.jsonfor the current name; as of issue #830 work, usepnpm workspace openhuman-app compilefor tsc checks.- PR #745 (command palette) merged without its deps —
@radix-ui/react-dialog,cmdk, and@testing-library/user-eventare missing frompackage.json. Install them if tsc fails after syncing main. - Pre-push hooks fail on upstream lint warnings — ESLint warns on
setStatein effects and unusedeslint-disabledirectives inherited from upstream. Use--no-verifyonly when the lint errors are pre-existing upstream issues, not new code. pnpm tauri icon <source.png>generates all platform icons at once — Produces.icns,.ico, all PNG sizes, Windows Store tiles, and iOS/Android sets. Use this instead of manualsips/ImageMagick resizing.tauri-cefsubmodule update can fix missing Tauri runtime modules — e.g. updating to f75bc21f5 added the missingtauri_runtime_cef::audiomodule that was causing pre-push hook compile failures on the Tauri shell. When the shell fails to compile with a missing module error, check if the submodule needs updating.git addmust run from repo root — Staging paths likeapp/public/...withgit addfrom insideapp/won't match. Always rungit addfrom/Users/megamind/tinyhuman/openhuman-claude.- Brand kit assets live at
app/public/brand/— Copied there during session work; original source is in~/Downloads/Brand kit/. Not auto-synced; re-copy manually if Downloads content changes. pnpm test:coverageENOENT oncoverage/.tmp/coverage-0.json— Race condition in coverage file collection; flaky, not reproducible every run. Usepnpm debug unitinstead — runs Vitest without coverage, faster and reliable for iteration.
Mascot Native Window (macOS)
- Not a Tauri window — The floating mascot is a native
NSPanel+WKWebViewinapp/src-tauri/src/mascot_native_window.rs. It usesignoresMouseEvents=true(click-through); interaction is detected by pollingNSEventvia a Foundation timer. macOS-only, uses objc2 bindings. MainThreadOnlyimport must stay — Required byWKWebView::alloc()and other AppKit allocators even if not explicitly referenced in user code. Removing it causes compile errors.NSEvent::pressedMouseButtonsnot in typed objc2-appkit bindings — Must be called viamsg_send!(objc2::class!(NSEvent), pressedMouseButtons)instead of the typed API.- WKWebView IPC via
evaluateJavaScript— The mascot webview is NOT a Tauri runtime; Tauriinvoke/emitdo NOT work. Rust-to-mascot communication usesmsg_send!(webview, evaluateJavaScript:completionHandler:)to dispatchnew CustomEvent(...)onwindow. React listens withaddEventListener. This is NOT subject to the CEF JS injection ban (that only applies towebview_accounts/third-party origins). MascotCharactersleepingprop — Drives the sleep animation (eye close + Zzz).sleepStartSecandsleepFullSecare hardcoded at 2.5s and 4.0s — they are NOT configurable props. Only togglesleeping: boolean.FACE_PRESETSis a strictRecord— Typed asRecord<Exclude<MascotFace, 'normal'>, FacePreset>inGhosty.tsx. Adding a newMascotFaceunion variant requires adding a matching entry toFACE_PRESETSor it won't compile._webviewinspawn_drag_timer— TheWKWebViewcaptured in the drag timer closure was originally unused (prefixed_). It can be used forevaluateJavaScriptcalls during the hover polling loop (e.g. to trigger blink/wake events from Rust).- FrameProvider loops — sleep animation resets —
FrameProviderusesframe % durationInFramesso animations loop. DefaultDURATION_FRAMES = FPS * 6(6s). Sleep animation completes at 4s, then eyes re-open at 6s when frame resets to 0. Fix: use a much longerdurationInFramesfor sleep face (e.g.FPS * 600) so the loop never triggers while sleeping. - Hover detection needs circular hitbox — The mascot panel is 79x79 but the character is visually circular. Using the full AABB (
cursor_in_panel) for hover triggers false positives when cursor is in a panel corner. Use distance-from-center check instead. Also suppress hover events for ~1s after panel shows to let the webview load.
Google Analytics (Issue #1479)
react-ga4injects a<script>tag at runtime — It appends agtag.js<script>to<head>dynamically. This works becausetauri.conf.jsonCSP hashttps:indefault-srcandconnect-src. If CEF ever tightensscript-srcseparately, switch to GA4 Measurement Protocol (pure HTTP POST, no script injection).- Analytics module pattern —
app/src/services/analytics.tsis the single owner ofinitGA,trackPageView,trackEvent, plus anALLOWED_EVENTSallowlist. Never callReactGAdirectly from components; go through this module. - Triple gate before any GA call —
isAnalyticsEnabled()(user consent) ANDGA_MEASUREMENT_IDenv var present AND!IS_DEV. All three must pass or tracking is silently skipped. - Route tracking location —
useLocation()effect wired in AppShell (not individual pages). All page views emit from one place. - Capability catalog must stay in sync —
src/openhuman/about_app/catalog.rsneeds an entry when a new user-visible feature ships. GA was added there as part of issue #1479.
PR Checklist CI
- N/A items need a checked checkbox —
scripts/check-pr-checklist.mjsrequires- [x] N/A: <reason>. Using- [ ] N/A:(unchecked) fails the check even though the text starts with "N/A:".
Config System (Rust)
- Config corruption recovery —
parse_config_with_recoveryinsrc/openhuman/config/schema/load.rs: try primary → try.bak→ archive corrupt file →Config::default(). Guarantees the app always starts even with a corrupt config. - New config fields must use
#[serde(default = "fn_name")]— Bare#[serde(default)]gives0/false, not the meaningful domain default. Define a named fn returning the correct value and reference it by name. .bakis now permanent —Config::save()no longer deletes.bakon success. It always reflects the last-known-good config before the most recent write.load_from_default_pathshas zero callers — Debug utility only; not user-facing.- Config test module path —
openhuman::config::schema::load::tests. Run withcargo test -- config::schema::load::tests.
Environment
- Core port —
7788(default; in-process inside Tauri host). Check withlsof -i :7788. pnpm core:stage— no-op (sidecar removed in PR #1061). Usepnpm dev:appfor full Tauri+core dev.- Kill stuck processes —
lsof -i :7788thenkill <PID>. Useful whendev:appreports a stale listener and you want to force a fresh boot rather than relying on the handle's auto-recovery. - Skills runtime rebuilt (PR #2707) — QuickJS is gone, but skills now run as orchestrator-focused agents via
skills_runRPC. Default skills live insrc/openhuman/skills/defaults/<id>/withskill.toml+SKILL.md, registered inregistry.rsDEFAULT_SKILLSconst. Seeded into<workspace>/skills/on boot (idempotent, non-destructive). Bundled defaults:github-issue-crusher,dev-workflow. Skills run with 200 iteration cap and full web access. - Codegraph tools (PR #2707) —
codegraph_indexandcodegraph_searchregistered insrc/openhuman/tools/ops.rs. Implementation insrc/openhuman/codegraph/— tree-sitter extraction, SQLite FTS5, dense embeddings, RRF fusion. Auto-indexes on first search. - Tool names are exact — Always check
src/openhuman/tools/ops.rsfor authoritative names. Key ones:edit(notedit_file),composio(notcomposio_execute),codegraph_index,codegraph_search. cron_addRPC — Was missing fromschemas.rs(only existed as agent tool). Now exposed asopenhuman.cron_add. Frontend wrapper:openhumanCronAdd()inapp/src/utils/tauriCommands/cron.ts.- Worktree
pnpm buildrolldown fix — Worktrees can miss@rolldown/binding-darwin-arm64. Fix:pnpm install --force.
Artifacts Domain (Issue #2776)
- Filesystem-backed persistence, no SQLite —
src/openhuman/artifacts/stores JSON metadata (meta.json) + binary blobs under<workspace_dir>/artifacts/<uuid>/. Pattern mirrorsmemory/ops/files.rsbut simpler. "ai"namespace in controller registry — RPC methods areopenhuman.ai_list_artifacts,openhuman.ai_get_artifact,openhuman.ai_delete_artifact. Futureai_*methods should use this same namespace.- Two-layer path validation required — (1)
validate_artifact_idrejects empty strings,/,\,.., absolute Unix paths, WindowsC:and UNC\\paths; (2)assert_within_rootcanonicalizes and checks containment. Replicate this pattern for any new filesystem-backed domain. cargo test --librequired for lib crate tests —cargo test -p openhuman -- "artifacts"lists tests but filters to 0. Must usecargo test -p openhuman --lib -- "artifacts"because tests are in the lib crate, not integration test binaries.
Rust Testing Patterns
- Memory tree tests filter —
cargo test -p openhuman -- "memory::tree"runs the memory tree unit tests (602 tests); full module paths areopenhuman::memory::tree::ingest::tests::*andopenhuman::memory::tree::canonicalize::email_clean::tests::*. cargo fmt --all— Required after codecrusher generates Rust; it doesn't always produce perfectly formatted output and CI will reject unformatted code.- PR quality scripts are soft checks —
scripts/check-pr-checklist.mjsandscripts/check-coverage-matrix.mjsexit cleanly with summary lines; CI treats them as advisory, not blocking. ceil_char_boundary— Safe string slicing utility atsrc/openhuman/util.rs; use this throughout the codebase instead of raw byte-index slicing to avoid UTF-8 panics.- Global static cache tests need a reset guard — When testing code that reads/writes a
Lazy<Mutex<Option<...>>>global cache, use astruct CacheResetGuard; impl Drop for CacheResetGuard { fn drop(&mut self) { *CACHE.lock() = None; } }pattern so each test starts clean. SeeSnapshotCacheResetGuard/CacheResetGuardinops_tests.rs. - Test assertions must match the actual dummy value — When a builder (e.g.
build_dummy_runtime_snapshot()) wrapsdegraded_runtime_snapshot(), assert againstdummy.fieldrather than a hardcoded string (e.g."idle"vs the actual"degraded") to verify round-trip correctness without false mismatches. composio::action_tool::tests::mode_toggle_between_calls_is_observedis flaky in full suite — Fails intermittently due to shared global composio session state; passes in isolation. Pre-existing; not caused by snapshot perf work.GLOBAL_MEMORY_TEST_LOCKonly serializes test bodies, not background workers — Background ingestion spawned by a prior test can still be running when the next test acquires the lock. Callstate.reset_for_test()at test start (after acquiring the lock) to clear accumulatedqueue_depth/runningstate; do not rely on delta assertions alone.IngestionState::reset_for_test()is#[cfg(test)]-gated — Lives insrc/openhuman/memory/ingestion/state.rs. Zeroesqueue_depth(AtomicUsize) and clears running/current fields in the snapshot while preserving completion history. This is the canonical reset for any test asserting exact queue or running state.- cargo-llvm-cov widens SQLITE_BUSY window — Flakes that only appear under coverage (
cargo-llvm-cov) but not plaincargo testare usually (a) a SQLite connection missingbusy_timeout, or (b) shared global state not reset between tests. Always setbusy_timeouton new SQLite connections (see pattern below). - All new SQLite connections must set
busy_timeout = 15s— Callconn.busy_timeout(Duration::from_secs(15))immediately afterConnection::open(), before anyexecute_batch(). Pattern set bychunks/store.rs(SQLITE_BUSY_TIMEOUT) and now also used bymemory_store/unified/init.rs(fixed in issue #2722). Without it, concurrent ingestion + test writes produceSQLITE_BUSYunder cargo-llvm-cov.
App State Snapshot (Issue #2155 — first-launch perf)
build_runtime_snapshotwas serial, now parallel — The four subsystems (screen intelligence, local AI, autocomplete, service status) insrc/openhuman/app_state/ops.rsran sequentially. Fixed withtokio::join!. Also added a 2s TTL cache (RUNTIME_SNAPSHOT_CACHE) so repeated polls within the TTL skip recomputation.service::statusis sync — must usespawn_blocking—crate::openhuman::service::status(config)may shell out tolaunchctl. Wrap it intokio::task::spawn_blockingwhen called from an async context.autocomplete::global_engine().status()callsConfig::load_or_init()internally — Avoid this inside snapshot code. Use the newstatus_with_config(config)method which accepts an already-loaded config.- Per-stage snapshot timeouts —
AUTH_FETCH_TIMEOUT = 5sandRUNTIME_SNAPSHOT_TIMEOUT = 10sare constants inops.rs; they sum to 15s, well under the 30s frontend RPC timeout.
Project Board & Issue Queries
- Project #2 paginates at 100 items — Board has 627+ items. Use GraphQL cursor pagination to find all open P0 issues; a single query only returns the first 100.
- jq regex
\s+causes parse errors — Use plaintest("#NNNN")to check if a PR/issue body references an issue number.\s+in jq regex triggers parse errors. - Most open P0s are security or Linux AppImage GLIBC issues — When triaging P0s, filter for those categories first.
- Project #2 shows only closed items on the board view — Use
gh issue list --repo tinyhumansai/openhuman --state open --assignee ""to find unassigned open issues instead of querying the project board. - Check linked PRs via timeline API, not body regex —
gh api repos/tinyhumansai/openhuman/issues/$N/timeline --paginate | jq '[.[] | select(.event == "cross-referenced" and .source.issue.state == "open")] | length'is more reliable than searching issue body text for PR references.
Git Submodules
tauri-cefandtauri-plugin-notificationare git submodules — When upstream/main updates them, fix withgit submodule update --remote --checkout, not by manually patching the vendored crate.
Pre-existing Test Failures
composio::action_tool::tests::factory_routes_through_direct_when_mode_is_directfails incargo test -p openhuman— Pre-existing failure unrelated to WhatsApp or any recent branch work. Do not attempt to fix unless explicitly tasked. Also intermittently flaky when run as part of the full suite — see "Pre-existing Flaky Tests" section.
Workflow Gate (must not skip)
- Steps 4–6 of
workflow/00-full-workflow.mdare mandatory before committing — Step 4: architectobot verify. Step 5: full checks (pnpm test:coverage,pnpm build,bash scripts/install.sh --dry-run, PR quality scripts). Step 6: memory-keeper. Skipping any of these violates the workflow contract. - Encode architectobot answers in the codecrusher prompt — When the architectobot plan includes clarifying questions and the user approves specific answers, embed those decisions as explicit constraints in the codecrusher prompt so the agent doesn't re-ask.
Security Policy
- Path validation entry point —
src/openhuman/security/policy.rsexposesvalidate_path/validate_parent_path. All file I/O path validation must go through this API.is_path_string_allowed()is a string-only first pass, not sufficient on its own. - validate_parent_path before create_dir_all — For write operations,
validate_parent_pathMUST be called before anycreate_dir_allcall. Calling it after allows symlink attacks to create directories outside the workspace before the security check fires (Issue #1927). - Tool callers must use
validate_path/validate_parent_path— All tool implementations undersrc/openhuman/tools/impl/filesystem/must use these functions, not the legacyis_path_allowed/is_resolved_path_allowed. - Security policy test filter — Run only security policy tests with:
cargo test -p openhuman -- "security::policy". Runs the 100 tests insrc/openhuman/security/policy_tests.rscleanly.
Pre-existing Flaky Tests
composio::action_toolandagent::harness::session::turnintermittent failures — These tests fail randomly when run as part of the full suite (likely shared state or timing), but pass individually. Not related to security/policy changes. Do not treat as blockers for security-module PRs.
Windows OAuth Deep Link (Issue #2562)
- Three-layer fix: (1) named-pipe IPC in
deep_link_ipc_windows.rs— secondary process forwardsopenhuman://URL to primary via\\.\pipe\com.openhuman.app-deeplink, 40 retries × 50ms; (2) loopback OAuth server inloopback_oauth.rs— RFC 8252 one-shot127.0.0.1:53824, preferred path that eliminates deep link dispatch entirely; (3) Linux analog indeep_link_ipc.rs— Unix domain socket at$XDG_RUNTIME_DIR/com.openhuman.app-deeplink.sock. OAuthProviderButton.tsxloopback flow — tries loopback first, setsredirectUrifor backend, awaits callback, rewriteshttp://127.0.0.1:PORT/auth?...→openhuman://auth?...→handleDeepLinkUrls. Falls back to deep link if bind fails.- Pipe binding location — primary binds the named pipe in
lib.rsright after the mutex guard (line 2269);drain_pending_urls()wired insetup()at line 2578. - Issue was already fixed before we picked it up — PRs #2469, #2511, #2550 had already merged the fix. Our contribution was extracting
classify_requestas a pure function and adding 11 Rust unit tests. - Pure-function extraction pattern — when async/AppHandle-gated Tauri code is untestable, extract a
classify_request(head, expected_state, bound_port) -> RequestOutcomepure function returning an enum. Enables comprehensive unit tests with zero Tauri context.RequestOutcomehas 4 variants:AuthCallback,StateMismatch,NotFound,MethodNotAllowed.
Port Conflict Recovery (Issue #2617)
- Port fallback already in
pick_listen_port—src/openhuman/connectivity/rpc.rstries ports 7789–7798 when 7788 is busy. Gap was: frontendgetCoreRpcUrl()cached the URL on first resolution so it never picked up the fallback port, and stale-process reaping was macOS-only. process_recovery.rsis platform-gated —reap_stale_openhuman_processeshad only a macOS impl. Linux uses/proc/<pid>/cmdline; Windows useswmic process get. Tests for each platform's parsing logic live in the same file, following the existing macOS test pattern.recover_port_conflictis a Tauri IPC command, not JSON-RPC — Rust E2E test for port fallback lives intests/json_rpc_e2e.rsand callspick_listen_portdirectly: bind port 7788 with astd::net::TcpListener(std, not tokio) to simulate conflict, confirm fallback, then serve viatokio::net::TcpListener::from_std(pick_result.listener.into_std()).BootCheckTransportis the right hook for frontend recovery —app/src/lib/bootCheck/index.tsis the injection point for new recovery capabilities; don't add them directly to the BootCheck component.- i18n locales are single flat files — Each locale is one file at
app/src/lib/i18n/<locale>.ts(en.tsis the source of truth; the oldchunks/<locale>-N.tslayout was retired). New keys must be added to all 13 locale files simultaneously;pnpm i18n:checkenforces key parity. - Workflow folder —
workflow/at repo root has 5 markdown files (00–05) defining the full PR workflow: pick issue → architectobot plan → user approval → codecrusher → architectobot verify → checks → memory-keeper → commit → push/PR.
Channel Event Workspace Routing (Issue #2602)
- Workspace identity is
PathBuf— Represented as the workspace directory path onChannelRuntimeContextasctx.workspace_dir: Arc<PathBuf>. Usectx.workspace_dir.as_ref().clone()at publish sites. There is no abstractWorkspaceIdtype. DomainEventworkspace routing contract — Publisher populates workspace field from context; subscriber compares againstself.workspace_dirand early-returns withlog::debug!on mismatch. Follow this pattern for any workspace-scopedDomainEventvariant.ChannelMessageReceivedandChannelMessageProcessedcarryworkspace_dir— Added in PR for issue #2602. Guards inConversationPersistenceSubscriber(memory_conversations/bus.rs) andTelegramRemoteSubscriber(telegram/bus.rs) prevent cross-workspace persistence during login/workspace-change races.
Pre-existing Upstream Failures (from issue #2602 session)
- Upstream
mainhas 5 Vitest failures and 4 TypeScript compile errors — Caused by missing iOS experimental dependencies:@noble/ciphers/chacha,@noble/ciphers/webcrypto,qrcode.react,@tauri-apps/plugin-barcode-scanner. Breakspnpm compile,pnpm build,pnpm test:coverageon a clean checkout. Always verify by stashing changes and running checks on the base branch before blaming your PR. cargo fmtmust run after codecrusher — codecrusher does not reliably producecargo fmt-clean Rust. Always runcargo fmt --manifest-path Cargo.tomlafter codecrusher finishes and before committing.
TaskKanbanBoard (Issue #3347 — frontend-only subset)
- 5 columns, 7 statuses —
COLUMN_DEFSinTaskKanbanBoard.tsxdefines columnstodo / awaiting_approval / in_progress / blocked / done.columnFor()maps the two virtual statuses:ready → in_progress(renders a "Ready to start" sage badge) andrejected → done(renders a "Rejected" coral badge). Anyone adding a newTaskBoardCardStatusmust update bothCOLUMN_DEFSandcolumnFor(). - Native HTML5 drag-and-drop only — No DnD library (@dnd-kit, react-beautiful-dnd). Cards set
dataTransferkeyapplication/x-task-card-id; columns handleonDragOver/onDragLeave/onDrop. Arrow buttons are the touch/accessibility fallback. Do not add a DnD library. mutatingCardIdis optional onTaskKanbanBoard—IntelligenceTasksTabsets/clears it infinallyblocks around personal-board mutations;Conversations.tsx(second consumer) does not pass it. Both consumers must keep new props optional to avoid compile failures.- Bare
vitest run <path>fails with "document is not defined" — Must use--config test/vitest.config.ts(orpnpm debug unit <relative-path>) to load the jsdom environment. Pipingpnpm exec vitest runthrough| tailalso buffers all output until completion, hiding progress — use the debug runner instead.
Memory Sync Sources — Defaults ON + Per-Source UI (Issue #3293)
- Conservative caps registry —
composio_defaults_for_toolkit(toolkit) -> (max_items, sync_depth_days)insrc/openhuman/memory_sources/registry.rsis the single source of truth. Values: gmail 100/30, slack 50/14, notion 30/30, linear 50/30, clickup 50/30, github 50/30, generic 30/14. Non-Composio defaults (GithubRepo 10PR/10issue/50commit, RSS 20, Twitter 7d) live inapply_kind_defaultsinrpc.rs. upsert_composio_sourcenow defaults ON — Registersenabled: truewith caps applied from the registry. Previously registeredenabled: falsewith no caps.- Cap enforcement: 3 construction sites, not 1 —
ProviderContext(memory_sync/composio/providers/types.rs) carriesmax_items/sync_depth_days. All three sites must populate caps from the registry entry:composio/mod.rs run_connection_sync,composio/periodic.rs,composio/bus.rs. Each provider readsctx.max_items/ctx.sync_depth_daysfor pagination + date clamping. Shared helpers:pages_for_max_items/epoch_floor_from_depthinproviders/helpers.rs. - First-sync gotcha on new connections —
bus.rs on_connection_createdfires BEFOREupsert_composio_source, so caps are (None, None) on the brand-new connection's first sync — bounded only by internalMAX_PAGES, not registry caps. Documented in code; intentional. memory_sources_apply_all_inRPC — Zero params →{ sources, sync_triggered }. Enables all sources, clears caps to None (falls back to internalMAX_PAGESceilings, ~500 items, not truly unlimited), triggers sync per source.- Retroactive migration —
apply_composio_source_caps_migrationinreconcile.rs, guarded byConfig.composio_source_caps_migrated: bool(#[serde(default)]), runs once fromensure_composio_sources. Only touches Composio entries with!enabled && max_items.is_none() && sync_depth_days.is_none()— never overwrites user-customized caps. MemorySourcePatchwas missing limit fields —max_commits,max_issues,max_prswere absent fromMemorySourcePatch,update_source, and the update schema. Also missing from the TSMemorySourceEntryinterface inmemorySourcesService.ts. Both were fixed in this issue.- Per-source settings UI —
SourceSettingsPanel.tsx(sibling ofMemorySourcesRegistry.tsx) with aKIND_FIELDSmap driving which limit fields appear per source kind. Empty input = omit from patch (use default); number = set. "All In" button usesConfirmationModal(primary-500 prominent). Toasts via existingonToastprop. pnpm i18n:english:checkis pre-failing — Exits withtotal unexpected English: 1312on a clean base tree. Confirm your new keys aren't among the failures rather than expecting exit 0.pnpm i18n:check(key parity) is the real gate.
Memory Tree Sync & Raw-Archive Reconcile (branch fix/memory-tree-sync-reconcile)
- Tree scope ≠ raw-archive id for GitHub —
github:owner/repo(tree registry key) andgithub.com/owner/repo(raw archive id) slugify to DIFFERENTraw/dirs (github-owner-repovsgithub-com-owner-repo). Any code resolving a source's raw dir must usememory_sources::sync::SourceScope { tree_scope, archive_source_id }— deriving from the tree scope scans the wrong directory and silently reports "nothing to do". - Raw-file coverage gate —
mem_tree_ingested_sourceswithsource_kind = "raw_file",source_id = <rel path with .md>(memory_store/chunks/store.rs: mark_raw_paths_ingested / filter_raw_paths_not_ingested). Written by github sync + rebuild after each summary batch lands.raw_coverage()inmemory_sync/sources/rebuild.rsbackfills the gate once per scope from L1 summary child labels (commit:<sha>/issue:<n>/pr:<n>/ file stems) before diffing disk vs gate. - Workspace periodic scheduler —
memory_sync/workspace/periodic.rs(started incore/jsonrpc.rsnext to the Composio one) is the ONLY thing auto-syncinggithub_repo/folder/rss_feed/web_pagesources; the Composio loop walks Composio connections exclusively. Cadence:config.memory_sync_interval_secs(Some(0)= manual only, default 24h), due-check via in-memory fired-at map + persisted sync-audit fallback. - Queue self-heal retries transient failures only —
memory_queue/scheduler.rscallsstore::requeue_transient_failedevery 3h (excludesfailure_class = 'unrecoverable'so bad keys/budget don't retry-loop). Full reset stays manual viamemory_tree_retry_failedRPC. openhuman.memory_sources_reconcileRPC — Reports per-scope raw coverage{total_raw_files, covered, pending};execute: truespawns background incremental rebuild. Same reconcile auto-runs after every sync viacheck_and_rebuild_tree.- L0 seal gate is token-only by design —
should_sealhas NO sibling-count fallback at L0 (docs used to lie about this); small buffers seal via the 3-hourlyflush_stalepath. L≥1 gates onSUMMARY_FANOUT(10) — an L1 buffer holding <10 summaries is healthy, not stuck.