Skip to content

Release v2.53.0 - #5389

Merged
atomantic merged 133 commits into
releasefrom
main
Aug 29, 2026
Merged

Release v2.53.0#5389
atomantic merged 133 commits into
releasefrom
main

Conversation

@atomantic

Copy link
Copy Markdown
Owner

Release v2.53.0

Released: 2026-08-29

Highlights

  • FableLoom gains a full series-planning workspace: AI-drafted series plans, an episode outline view, interactive playback modes, camera-cut nodes, shot continuity and reference handling, audience participation modeling, and story federation.
  • IdeaLoom adds a two-way Obsidian exchange with an explicit list-editing panel and a local list API, plus hardened conflict handling so edits, deletions, and Markdown round trips stay in sync without clobbering or resurrecting notes.
  • FaceTime Audio gets a full control plane — outbound and inbound calls, live meeting-audio capture through the call-host bridge, and call audio routed through BlackHole into the voice pipeline — and the Persistent Mind can now place a call when nothing on screen can reach the user.
  • Rapid Reader adds a durable shelf so saved books survive a refresh, a live H:MM:SS countdown, and improved navigation and text flow.
  • Signal settings moved into Comms, agent activity streaks were retired, and a new read-only npm run doctor command reports install prerequisites.

Added

  • Added the FableLoom series-planning workspace, AI-drafted series plans, episode outline view, interactive playback modes, camera-cut nodes, and audience participation modeling.
  • Added FableLoom story federation and scene video generation.
  • Added a dedicated Brain Ideas page, IdeaLoom list editing panel, and local IdeaLoom list API.
  • Added an explicit IdeaLoom Obsidian exchange.
  • Added the FaceTime Audio control plane, inbound-call answering, live meeting-audio capture through the call-host bridge, and BlackHole audio routing into the voice pipeline.
  • Added Persistent Mind calling as a fallback channel when nothing on screen can reach the user.
  • Added a durable Rapid Reader shelf, live countdown timer, and navigation/text-flow improvements.
  • Added a read-only npm run doctor install-prerequisite report.
  • Added drifting cloud banks to OpenWorld.

Changed

  • Moved Signal settings into Comms and retired agent activity streaks.
  • FableLoom playback, shot continuity, transitions, and plan replacement are now hardened against regeneration and review-surfaced edge cases.

Fixed

  • Fixed IdeaLoom sync so deletions actually delete, edits don't clobber or resurrect notes from Obsidian, Markdown round trips are preserved, and the federation boundary stays correctly scoped.
  • Fixed Spotify and YouTube redirect URIs to derive from the request origin and Tailscale host, preserving query params on legacy redirects.
  • Fixed pitch-analysis mic capture to disable browser audio processing and report processing state consistently.
  • Fixed calendar sync to never prune events from a partial CLI sync payload, and POST daily practice to rotate across eligible drills instead of repeating one.
  • Fixed generated API route, socket event, and OpenAPI catalogs to stay aligned with their source contracts.
  • Fixed buffered spawn timeout cleanup, NVIDIA Kimi provider configuration, and federated peer label and media-status handling.

Full Changelog

Full Diff: v2.52.0...v2.53.0

atomantic and others added 30 commits August 28, 2026 16:49
…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 new mcpSyncAccount tests mock updateSyncStatus, so nothing proved the
store actually round-trips the new status value. Assert it at the
persistence boundary — capabilityMap keys a WARN row on it.
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
atomantic and others added 29 commits August 29, 2026 03:47
fix: use prior FableLoom shots as references
feat: add explicit IdeaLoom Obsidian exchange
…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
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.
@atomantic
atomantic merged commit e8f1b9b into release Aug 29, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant