Conversation
…d bound every pm2 app's memory Two separate memory problems, found while chasing "portos-ui is holding 2.7 GB". The big one is not in the product at all. `saveProcessList` calls pm2.js's own `execPm2`, and an intra-module call reads the module-local binding, so the test's `vi.spyOn(pm2Module, 'execPm2')` never intercepted it. Every run therefore really executed `pm2 save` against a throwaway PM2_HOME, and PM2 answered by forking a God Daemon that outlived the suite — 641 of them, 38 GB of resident memory, had accumulated on the dev machine. The test now mocks the `spawn` seam the module actually imports (the pattern pm2.launch.test.js already uses) and asserts the call went through it, so a future refactor that moves the seam fails loudly instead of quietly spawning processes again. The second: portos-ui had no `max_memory_restart` and no heap cap, and the other support daemons had no ceiling either. A ceiling alone is not a memory policy — Node sizes V8's heap limit from physical memory (~4 GB on a workstation), which is at or above every ceiling here, so a process hits `max_memory_restart` and gets killed before V8 ever runs the compacting GC that would have reclaimed the garbage. Every app now pairs a ceiling with a `--max-old-space-size` at 75% of it, passed via `node_args` rather than NODE_OPTIONS so it cannot shrink the heap of the agent CLIs and media tooling portos-server spawns. The Vite ceiling is overridable with PORTOS_UI_MAX_MEMORY, matching the existing server knob.
Both sync sources feed the activity Timeline and taste signal, which live in Brain, not Settings — so they show up in the same sidebar section as the data they populate. Now new tabs on the Brain page (/brain/spotify, /brain/youtube) instead of Settings tabs; old /settings/spotify and /settings/youtube URLs redirect to their new home.
…be redirects The new /settings/spotify and /settings/youtube redirects used plain Navigate, dropping the OAuth callback's ?oauthConnected=1/?oauthError query string that SpotifyTab reads on load. Switch to the existing RedirectWithSearch helper used by every other legacy-route redirect. Also updates the stale "authorize in Settings -> Spotify" error string in spotifySync.js now that the page lives under Brain.
…erted the policy, damp portos-ui restarts - `node_args` joins INHERITED_PM2_CONFIG_KEYS. PM2 exports a managed process's config into its environment and the CLI reads it back as config outranking flags — the mechanism that once fed portos-server's 4G ceiling to a loaded 25GB llama-server. Unstripped, portos-server's new heap cap would reach every app PortOS launches. - Drop the `Math.max(256, …)` floor on the heap cap. At the small ceilings PORTOS_UI_MAX_MEMORY now invites (256M) it produced a cap equal to the ceiling, and below that one above it — the exact inversion the cap exists to prevent. `ratio < 1` alone keeps it strictly under. - portos-server drops to a 0.60 ratio: `--max-old-space-size` does not count external memory, and its sharp/libvips raw buffers are large enough that a heap legally at 3072 could still carry RSS past the 4G ceiling. - portos-ui gains restart damping and an explicit interpreter. It was the only app inheriting pm2's `restart_delay: 0`, so a mis-sized ceiling would crash-loop and re-transform the whole client each cycle. - Pin `getSavedProcessNames` in llamaServerManager.test.js — it was reading the developer's real ~/.pm2/dump.pm2, as its twin already notes. - Document on `execPm2` why a namespace spy cannot stub it from inside this module; ~10 other exports share that shape. - Config guard moves to scripts/, beside the other root-config drift tests.
…t still gets a heap cap pm2 accepts an unsuffixed `max_memory_restart` as a byte count, and both PORTOS_SERVER_MAX_MEMORY and PORTOS_UI_MAX_MEMORY are user-set — a machine that already spells its ceiling as `4294967296` parsed to null and got no `--max-old-space-size` at all, leaving exactly the RSS-ceiling-with-no-GC-pressure setup the cap exists to fix. The docstring had claimed the bare form was accepted while the regex required a unit. Covered by a config-load assertion rather than a direct parser test, so it observes what pm2 would actually receive.
Move Spotify and YouTube settings into the Brain section
…5314) The controls bar rendered elapsed/total as raw seconds, which runs into four-digit "s" counts on a long document at a slow WPM. It now shows how much time is left, derived from the live wpm state so the slider and the +/- hotkeys update it immediately. formatCountdown gains an hour bucket (3661 -> 1:01:01) and keeps its MM:SS output under an hour, so the Writers' Room sprint timer is unchanged.
runCliProviderPrompt returns stdout even when the provider CLI exits
non-zero, on the rule that any output is worth parsing. The Google
Calendar MCP sync never read exitCode, so a CLI that printed a truncated
`{"calendars":[…` before dying (rate-limit banner, context overflow,
killed mid-stream) parsed as a complete response — and pushSyncEvents
prunes every cached event the payload doesn't mention, deleting real
calendar entries and then recording the sync as 'success'.
runCliProviderPrompt now flags the disagreement explicitly (`partial`,
plus a capped `stderrTail`), and the calendar sync gates on it:
- pushSyncEvents takes `{ prune, status }`; prune defaults to true so
every existing caller is unchanged.
- mcpSyncAccount upserts a partial payload but skips the prune, records
the account as 'partial' with the stderr tail as the reason, and emits
that status over calendar:sync:completed.
- mcpDiscoverCalendars refuses a partial list outright — discovery
replaces the stored subcalendars, so a truncated array would drop
calendars and their enabled/goal wiring.
- Parse failures now carry the stderr tail instead of a bare,
undiagnosable "Failed to parse calendar data" message.
'partial' was already an accepted lastSyncStatus (capabilityMap degrades
those rows to WARN); the Calendar config tab now colors it as a warning
rather than rendering it in the same gray as a success.
The four singing/tuning surfaces all called getUserMedia({ audio: true })
and took whatever the browser handed back. With the defaults on, AGC rides
the level the tuner's clarity gate reads, noise suppression chews sustained
vowels and soft onsets, and echo cancellation can gate the mic while a
reference melody plays back during sing-to-verify — so the analysis input
was the speech-tuned signal, not the sung one.
openAnalysisMic() in lib/audioRecorder.js now opens those mics with
echoCancellation/noiseSuppression/autoGainControl requested off (plain
booleans, never { exact }, so a browser that can't honor a stage still
opens a usable mic) and reads track.getSettings() back so the UI can say
what actually stuck. A stage the browser doesn't report reads null rather
than false — Firefox omits keys, and unknown must not read as honored.
The tuner and sing-to-verify surface a MicProcessingHint when a stage is
KNOWN to still be on. Memo capture keeps the speech-tuned defaults on
purpose: it feeds Whisper, which wants them.
The three suites that mocked audioRecorder.js with a bare factory now
spread importActual, so the new helper runs for real against their
getUserMedia stubs instead of vanishing from the mocked module.
… it per capture (#5303) Review follow-ups on the raw-capture change: - Song training and sing-to-score discarded the read-back entirely, so a browser that kept AGC on graded and transcribed processed audio with no indication. Both now surface the same MicProcessingHint the tuner does. - The tuner's standalone path re-read track.getSettings() instead of using the report openAnalysisMic had just produced — a second read can disagree with the first. attach() now takes the report when the caller has one and reads the track only for an attached recording stream. - micProcessing outlived the capture it described: after one processed take, a reset (which a score edit triggers) or a denied follow-up request left the old warning standing beside a new attempt. Both hooks clear it when a capture starts and when results are reset.
PortOS has a long prerequisite chain, but every check for it lived somewhere that only runs once something else already works: checkNodeVersion/checkNpmVersion fire inside `npm run dev|start|setup`, `/api/system/health/details` needs the server booted, providerPrerequisites covers only the AI CLIs, and smoke-boot proves the process stays up without saying why it didn't. When the server won't start there was nothing to run. `scripts/doctor.js` probes ~18 prerequisites — Node/npm floors, the lib/slashdo submodule, all four workspace node_modules, seeded data/, Postgres + schema + pgvector, pending migrations, pm2, ffmpeg/python3/uv, gh auth, the TLS cert, and the 5553-5561 port block — and prints one pasteable block (or `--json`). It exits 1 only when a REQUIRED fact is unavailable; ports are advisory on purpose, since a running install legitimately occupies the block. Three properties make it useful rather than another status page: - Read-only. No installs, no migrations, no DB writes, no LLM calls. - Loads from a bare checkout, because a missing node_modules is precisely the failure it exists to explain. Its static imports are builtins and builtin-only repo modules; `pg` is a dynamic import inside the database probe, so an uninstalled tree yields an `unavailable` fact rather than a stack trace. scripts/pre-install-entrypoints.test.js now guards this alongside the two CI impact-job entrypoints. - Pasteable. Every detail is scrubbed through scrubHomePath, paths are repo-relative, and Postgres is reported by host class (`system :5432` / `docker :5561`) — no hostname, username, IP, or password reaches the output. Each probe is independently bounded, so one unreachable service degrades to a single line instead of hanging the report. scrubHomePath moves to its own zero-dependency server/lib/homePath.js so doctor can import it without dragging in zod; agentRunEvents.js re-exports it, leaving every existing caller unchanged. Its "home is the filesystem root" test gains a bypass probe — the previous assertion passed whether or not the `os` mock took.
…'s cleanup Four real defects from the codex review of #5304: - The Postgres probe defaulted to 5432 the way db.js does, but nothing sets PGPORT outside PM2: ecosystem.config.cjs reads PGMODE from .env and passes PGPORT: PORTS.POSTGRES, which DEFAULTS TO DOCKER (5561). A standalone doctor run on a healthy Docker install would have reported Postgres and pgvector unreachable — the exact false alarm the tool exists to prevent. It now resolves the port from the same ecosystem config a launch does, and pins it before importing db.js (which builds its pool at module load). - closeProbeResources() awaited the pg pool unconditionally. db.js sets connectionTimeoutMillis but no statement timeout, so a server that accepts a connection and never answers would hang the CLI in cleanup after the report had already printed. The close is now bounded and the exit explicit. - isPortFree() resolved from close()'s callback, which drains live connections first — a client connecting in the listen/close window would hold the report open — and cleared its guard timer while a bind could still fail. It now settles once, before close(), with the guard live until it does. - db.js logs raw driver output (a PGPASSWORD warning on import, the verbatim message on a failed health check). Those carry hosts, IPs, and role names, which would land in the terminal beside the host-class line that promised not to say them. The probe runs with that logging muted; the facts never quoted it. The "bounds each probe by default" test asserted only that PROBE_TIMEOUT_MS was positive and would have passed with the timeout deleted from runProbe — it now drives the real default. Adds coverage for isPortFree, closeProbeResources, and the port resolution above.
#5320) The "Suggested" chip bar sat directly above "Today's Actions" and surfaced the same nudges that widget already lists as a checklist. Clicking a chip did nothing on its own — it added a widget and scrolled the page to it — so the bar read as a redundant no-op. Deletes WidgetSuggestions and its test, plus the `pendingScroll` state, its scroll-into-view effect, and the `activeLayoutIdRef` mirror in Dashboard.jsx, all of which existed only to support the chip bar's add-then-scroll flow. The `gate` predicates in widgetRegistry stay: they still drive the visible-widget filter (Dashboard.jsx:258).
…#5320) Removing the Suggested chip bar moved Dashboard.jsx's socket listener from line 140 to 128, and the generated catalog records call-site line numbers.
…eating one (#5319) POST's "what to practice next" tiers resolved equivalent candidates by input order, and their signals (windowed accuracy, ladder progress) barely move on a single rep — so Continue Today's Routine, the Start CTA, and Quick Session kept serving the same practice: Elements from the memory tier, digit-span from the heuristic tiers. The recommendation service now derives a three-local-day practice window from the scored sessions and training log it already loads, and orders the two HEURISTIC tiers (weakest skill, stalled ladder) fresh-candidates-first, with a deterministic local-day rotation breaking ties. Weakest-skill selection walks a ranked list filtered for runnability, so a disabled or module-excluded lowest- accuracy drill no longer collapses the whole tier. Schedule-driven tiers are untouched: a genuinely due memory item or review still wins the top slot even when it was practiced yesterday, and the existing same-day demotion is unchanged. The endpoint returns that window as `recentPractice`, which the launcher hands to composeQuickSession so a multi-drill domain rotates off the same signal rather than registry-order list[0]. A memory candidate is identified by its item id, so practicing Elements does not sink every other memory item. Rotation is day-keyed, never random: the same day and inputs always produce the same plan, which "Continue Today's Routine" depends on when it re-requests the list.
…otation (#5319) Review follow-up. The server deliberately exempts the schedule-driven tiers from the multi-day recency window, but the client re-applied it to whatever recommendation came back — so a due memory item or due skill review practiced yesterday could be rotated away in Quick Session, skipping the exact item spaced repetition had just surfaced. Quick now honors a `memory-due` / `skill-review` recommendation unconditionally and only rotates the heuristic kinds. A memory recommendation also names the item it wants practiced. Quick used the domain drill's configured item instead, so a due rec for one item could be "satisfied" by running a different one; the recommendation's `memoryItemId` now wins for both the recency identity and the generated drill config.
stop a test forking a real PM2 daemon per run (38 GB leaked), and bound every pm2 app's memory
feat: move Signal settings into Comms
feat(openworld): add drifting cloud banks
fix: use prior FableLoom shots as references
feat: add explicit IdeaLoom Obsidian exchange
feat: federate FableLoom stories
…ian (#5339) A list that had already been exchanged kept its note path forever, so the next export re-created a note the user had deleted in the vault. Deleting the note is now a decision, not drift to repair: both import and export report it as a distinct `missing` outcome and write nothing, and only an explicit "Recreate deleted notes" request (`recreateMissing`) writes it again. An iCloud note that is merely un-downloaded stays `unavailable`, so it is never a recovery candidate. Adds the opt-in automatic export the storage slice left as a dead setting: `autoSync` now debounces a burst of edits into one vault write, re-reads both toggles when the write fires, and calls the exchange with the fail-closed defaults, so it can never delete a note, recreate one, or resolve a conflict. Only a local list edit schedules it — the import and sync routes deliberately do not — and an export whose rendered Markdown already matches the note on disk is skipped, so a freshly imported list cannot export itself back. Documents the base-hash conflict table, the deletion contract, and the automatic-sync limits in the Brain, API, and storage docs, which previously deferred all three to this slice. Closes #5339
…e pipeline (#5308) Phase 1 could dial and hang up; nothing could hear the call. This adds the audio path and the session that owns it. A new call-host page (/voice/call-host) runs in a browser tab on the Mac — device permissions and setSinkId need a real browser profile — and bridges the two virtual devices: it reads BlackHole 16ch (what FaceTime plays into), streams 16 kHz mono PCM to the server, and plays each reply back through BlackHole 2ch (what FaceTime hears as its microphone). Every failure is named rather than spun on: a browser missing required APIs lists all of them at once, a missing or misconfigured device is named specifically, an unlabeled device list is reported as a missing microphone permission rather than a missing driver, and a second tab is refused by both a Web Lock and the server's single host slot, so two tabs can never double-answer one call. A phone call has no push-to-talk, so the server decides where a turn ends: energy-based VAD with 700 ms of trailing silence and a 20-second ceiling, driven by sample count rather than a wall clock so a delayed socket flush endpoints exactly where a smooth stream would. Each utterance runs the existing pipeline unchanged — same persona, tools, confirm gate, and TTS settings — and speaking over a reply interrupts it like the widget's barge-in. The session treats the helper's view of the FaceTime window as the source of truth: a probe that fails is unknown, never a hangup. It ends on a remote hangup, 60 seconds of caller silence, the configured maximum call length, or the host tab going away, because a call nobody can hear is worse than no call. Only a text transcript is kept — appended to the daily journal labelled Caller and PortOS, with the configured handle nowhere in it. Setup now offers to install BlackHole through the user's own Homebrew (GPLv3, never bundled, declining is supported), and Check setup verifies each device's label, rate, and channel count instead of always reporting them missing. Closes #5308
Adds "Capture system audio" as a second mode on the FaceTime call-host page (/voice/call-host?mode=capture). It reuses the same BlackHole 16ch device, PCM wire format, and whisper STT endpointing as the call bridge, but only transcribes — it never runs the LLM/tools pipeline and needs no BlackHole 2ch reply path. Stopping a capture writes the timestamped transcript to the daily journal under a "Meeting capture" heading and files it as a Brain inbox item with auto-classify off, so no AI provider is ever called until the user asks for a summary. - server/services/voice/captureSession.js: the capture session state machine (single-attach host, start/stop, timestamped transcript, journal + inbox write on stop), mirroring callSession.js. - server/sockets/voice.js: voice:capture:start/stop handlers; the existing voice:call:audio handler now routes to whichever host (call or capture) owns the socket, with each side mutually exclusive of the other since both want the same physical device. - client/src/pages/VoiceCallHost.jsx: mode toggle (deep-linkable via ?mode=), capture-specific device check (no output device required), and capture-specific start/stop/state wiring. - client/src/lib/callAudioBridge.js: describeDeviceProblem's outputLabel is now optional, since capture mode never plays a reply back. Also fixes a bug in callSession.js: endCall() passed the async getToday() as a Promise into appendJournal without awaiting it, so isIsoDate's typeof guard always rejected it and the FaceTime call transcript was silently never written to the journal. Closes #5311
… the fix The suite mocked brainJournal.getToday as a sync function while production has it async, so the assertion passed against the unawaited call this PR fixes — a pending Promise that appendJournal's isIsoDate guard silently rejected, leaving FaceTime call transcripts unwritten. Matching the real signature makes the case fail without the await and pass with it.
…n reach them The mind could only speak through the Mind tab or a browser voice tab, so a user who had walked away was unreachable — and its capability boundary explicitly forbade external messaging. This adds `voice.call-user` as a separate, default-off grant that places a FaceTime Audio call to the single handle already configured in Settings > Voice. The model's `callRequest` carries a reason and an opening line but no recipient, so a confused turn cannot dial anyone else. The gate (server/services/persistentMindCallCapability.js) runs after inference and refuses the call whenever the grant is off, the FaceTime feature or voice is disabled, no identity is saved, a browser tab could speak the message instead, the user's local time is inside voice quiet hours, a call is already up, or the budget is spent — at most 3 calls per rolling 24 hours, at least 30 minutes apart. Those counters live in durable Persistent Mind state (schema 4 -> 5, migration 313) because an in-memory cap would hand back a fresh allowance on every restart, and the budget is charged only to a call that actually went out. Every decision, placed or suppressed, lands on the trajectory as mind.call.*; the dialed handle never does. A suppressed request is appended to the turn's reply, so the mind cannot claim it called when the phone never rang. A placed call speaks its opening line the moment the far end picks up, runs with the mind's persona and a bounded briefing of its trajectory, and hands the outcome back as a Persistent Mind message on hangup — including when nobody answered, which is exactly when the mind would otherwise redial to say the same thing. Critical-notification escalation (Settings > Voice, off by default) shares the gate and the budget but is authorized by facetime.escalateCritical rather than the mind's grant: a critical notification still unread after escalateAfterMinutes with no voice tab available asks to ring the user. Capabilities schema 3 -> 4, accepting every prior wire version on input so an older client bundle can still toggle the grants it knows about. An install upgrading with the mind already running gains nothing until the user opts in. Part of #5306.
# Conflicts: # docs/features/voice.md # server/lib/socketEventCatalog.generated.json # server/sockets/voice.js # server/sockets/voice.test.js
fix: quiet federated media status probes
Let the Persistent Mind call the user when nothing on screen can reach them
## Summary - document the production architecture for consistent FableLoom character identity, approved voices, and reproducible media provenance - define pre-rendered entry/hold/exit playback assets and a scoped, half-duplex QR-hosted session protocol - record Qwen3-TTS as the first richer local voice backend and retain Kokoro/Piper as the zero-setup starting point - capture diegetic FaceTime calls as a later, non-blocking investigation - link the implementation epic and child issues from the architecture and feature docs ## Tracking - #5377 - #5378 - #5379 - #5380 - #5381 - #5382 - #5383 - #5384 - #5385 ## Validation - `git diff --check origin/main...HEAD` - balanced Markdown code fences - resolved repo-local Markdown links - sensitive path/email scan This is documentation-only. Per the requested delivery path, it does not require a code-review loop or CI run.
Phase 4 of the FaceTime Audio bridge (#5306): a call-host tab attached plus the new facetime.autoAnswer setting (default off) lets PortOS pick up a call from the user's own configured identity and run it through the existing voice pipeline, exactly like an outbound call. Fail-closed by construction, not by convention: - callSession.js gains an incoming watcher armed only while a call-host tab is attached; reading the helper (probe) is safe and cheap, but the press (answer) additionally checks the host is attached *at press time*, since answering a call nobody can hear is worse than missing it. The helper boundary reports nothing distinguishing "no call" from "an unauthorized caller" (fail-closed at facetimeBridge.answer), so this module never learns an unauthorized caller exists and never logs one. - An authorized call that rings with no host attached, or one the helper fails to press, raises a `medium` agent_warning notification (deduped once per ring, not once per 2s tick) so the miss is visible instead of silently dropped. - Quiet hours only soften the greeting's wording; they never decide whether to answer — the user placed the call. - When the Persistent Mind is running at answer time, the call carries its persona/context and the transcript goes back to it as a message on hangup (same continuity path outbound mind-placed calls use); when it isn't, the call runs the plain voice persona like the widget. Also adds voice:call:hangup (routed through endCall, not a raw facetimeBridge.hangup(), so the session's journal write and mind handoff run the same as any other end-of-call) and broadcasts voice:call:state to every connected tab via a new callStateEvents emitter, not just the call-host socket — the Mind tab's active-call chip needs call state without being the tab carrying the audio.
- Drop the stale MediaStreamTrackProcessor/MediaStreamTrackGenerator gate from the FaceTime call-host capability probe: the pipeline actually runs on AudioWorklet + createMediaStreamSource and never touches those two APIs, so a fully-capable browser (e.g. current Firefox) was being told it couldn't attach. - Add missing /settings/spotify and /settings/youtube redirects to their new home under Brain — both tabs moved but only Signal got a redirect entry, so bookmarks and stale ⌘K entries silently landed on GeneralTab instead. - Fix IdeaLoom Obsidian frontmatter tag parsing: the bare-scalar branch matched before the flow-sequence branch could ever run, so a note with `tags: [idea-loom, foo]` was parsed as one literal tag string and rejected as missing the idea-loom tag even when it had it. - Add route coverage for the new FaceTime control-plane endpoints and strengthen a FableLoom playTurn test whose transitions both targeted the same node, so it couldn't distinguish "used the graph's first transition" from "honored the caller's transitionId".
… in-flight reply runCallUtterance ran the phone-call LLM/TTS turn with no AbortController at all -- state.ctrl belongs to the browser-widget voice:turn/voice:text path. The barge-in handler's `state.ctrl?.abort()` was therefore a no-op for calls: a caller talking over the bot's reply never actually cancelled it, so the in-flight turn ran to completion and collided with the caller's speech. Give the call path its own AbortController (call.ctrl), thread its signal through runTurn, and abort it on barge-in and socket disconnect.
… scenes as failed failedVideoId starts at null, and the server also sends videoHistoryId: null for a scene that has no rendered video, so `failedVideoId === scene.videoHistoryId` was `null === null` -> true for every never-rendered scene, showing "The rendered video is unavailable; advance manually or retry after rendering." instead of "No video rendered for this cut yet." Gate videoFailed on scene.videoHistoryId being present so null can't match the null sentinel.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release v2.53.0
Released: 2026-08-29
Highlights
npm run doctorcommand reports install prerequisites.Added
npm run doctorinstall-prerequisite report.Changed
Fixed
Full Changelog
Full Diff: v2.52.0...v2.53.0