Skip to content
Merged
1 change: 1 addition & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Unreleased Changes

## Added
- **[codex5-onboarding-capability-map] Capabilities page — every connected system at a glance.** A new Capabilities page (under System) shows the status of each integration on one screen: AI Providers, Calendar, Brain & Memory, Voice, Tailscale & HTTPS, Genome & Health, Telegram, Messages, and Apps & Processes. Every row says whether it's set up and healthy — Ready, Degraded, Error, or Not set up — and links straight to its settings, so the page works as both a first-run setup checklist and a quick runtime health overview. Reachable from the sidebar, ⌘K, and voice.
- **Federated media collections + per-category sync integrity.** Media collections are now a first-class peer-sync record kind (`mediaCollection` in `PEER_SUBSCRIBABLE_KINDS`, schema-version-gated at `mediaCollections: 1`): they auto-subscribe on create, push per-record on edit, and propagate deletes via tombstones — collections now reach parity between federated machines the way universes/series already did, instead of only riding along as `linkedCollection` cargo or the opt-in 60s snapshot. Required adding soft-delete to collections (`deleted`/`deletedAt`; `deleteCollection` marks instead of splicing; `listCollections({ includeDeleted })`; tombstone-aware LWW merge; tombstone GC), so a delete on one node can't be resurrected by a reverse-subscribed peer. Synced images now carry their **generation prompts**: the peer asset manifest advertises a canonical gen-params-only `sidecarSha256` (machine-local hash-cache stripped so it converges across peers), and the asset-pull worker fetches each image's `.metadata.json` sidecar alongside the bytes — fixing images that previously landed in "Unsorted" with no prompts. A new per-category **sync integrity** surface (`GET /api/peer-sync/manifest`, `GET /api/peer-sync/integrity`, pure `computeRecordIntegrity` diff) drives a `SyncBadge` on every Universe / Series / Media-Collection row (in-sync / diverged / assets-missing / local-only / on-peer-only / "not syncing — enable?") plus a deep-linkable `SyncDetailDrawer` (`/…/:id/sync`) showing the per-peer breakdown, previews, and manual actions: **Sync to peer** (`POST /sync-record`, force-push bypassing the unchanged-hash short-circuit), **Sync now** (`POST /sync-now`, backfill-subscribe + retry a peer's pending pushes), and **Pull missing prompts** (`POST /pull-metadata` + a button on the Unsorted view that backfills sidecars from online peers). `mediaCollections` sync stays **opt-in** per peer (default off); the badge surfaces a clear "enable?" state rather than flipping the default.
- **Manual tombstone GC trigger.** The 60s `syncOrchestrator` sweep buffers tombstones for 24h to give every peer time to ack a deletion before the bytes leave disk — fine for normal use, painful right after a mass-delete. New `POST /api/sync/tombstones/sweep` (Zod-validated optional `{ graceMs }` clamped to `[0, 24h]`) calls `sweepTombstones({ graceMs })` so callers can shrink the buffer; the per-kind null-cutoff refusal (snapshot-mode peer with no per-record subscription) still fires independently of `graceMs` to preserve resurrection safety. Companion `GET /api/sync/tombstones/status` returns the dry-run refusal status without invoking the prune helpers. A new "GC tombstones now" section on the Instances page (between SelfCard/AddPeer and the peers list) POSTs `graceMs: 0` and surfaces per-kind prune counts + any refusals in a toast; the button is disabled only when every kind is refused. `scripts/gc-tombstones-now.js` is the equivalent CLI path (dry-run by default; `--apply` to prune) for headless cleanups — uses the same "stop the server first, single-process write-tail isn't cross-process safe" wording as `cleanup-test-data.js`. `sweepTombstones` now returns `{ universes, series, issues, refused }` so the manual trigger can explain why a kind didn't shrink. The orchestrator path keeps the 24h default behavior unchanged.
- `server/lib/collectionStore.js` — per-type, per-record JSON storage helper with explicit type-level `schemaVersion` stamping. `createCollectionStore({ dir, type, schemaVersion, sanitizeRecord })` returns `loadOne` / `saveOne` / `listIds` / `loadAll` / `deleteOne` / `loadTypeIndex` / `saveTypeIndex` / `verifySchemaVersion`. Per-id write queue means writes to different records run in parallel — the win over a single-file write queue. `verifyCollectionVersions([store, ...])` is called at server boot after `runMigrations()` so a missed migration produces a loud, single-line log per collection (does not crash — PortOS is single-user).
Expand Down
4 changes: 2 additions & 2 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@ For project goals, see [GOALS.md](./GOALS.md). For completed work, see [.changel

## Next Up

- [ ] [codex5-onboarding-capability-map] **[P2][ONBOARDING]** Capability map of connected systems. One page showing each integration's status: Providers (per-provider configured/available/throttled), Calendar, Brain/memory embeddings, Voice (mic + TTS), Tailscale + HTTPS, Genome/health imports, Telegram/messages, App registry/PM2. Each row links to the relevant settings page. Doubles as a setup checklist and a runtime health overview.
- [ ] [migrate-taskitem-formatattachmentsize-to-formatbytes] **Migrate `TaskItem.jsx`'s local `formatAttachmentSize` to shared `formatBytes`.** `client/src/components/cos/tabs/TaskItem.jsx` still defines a local `formatAttachmentSize(bytes)` (B/KB/MB with `.toFixed(1)`) that duplicates `formatBytes` in `client/src/utils/formatters.js`. Surfaced by gemini review during the `[migrate-two-remaining-local-formatters]` claim 2026-05-24; deferred as out-of-scope for that item (which named only `formatDateTime` + `formatDurationMin`). Note the edge-case semantics differ: the local helper returns `''` for falsy/0 size (used inside an attachment `title` like `(1.5 KB)`), while `formatBytes(0)` returns `'0 B'` — preserve the empty-on-missing behavior with a guard (`att.size ? formatBytes(att.size) : ''`) so the title doesn't render `(0 B)` for sizeless attachments.
- [ ] [optimize-voice-ui-index-text-payload-lazy-only-run] **Optimize `voice:ui:index` text payload.** Lazy: only run `extractVisibleText` when server requests via `voice:ui:read-request`. Keep current behavior as fallback.
- [ ] [voice-agent-explicit-long-term-memory-routing-on] **Voice agent — explicit long-term memory routing.** On retrieval-shaped voice turns, inject top-N relevant memories into the system prompt via `brain_search`.
- [ ] [flux2-multi-reference-python-runner] **FLUX.2 multi-reference Python runner.** The UI + server contract for multi-reference editing shipped 2026-05-17 (slug `multi-reference-image-editing-for-flux-2-ui`); the Python runner (`scripts/flux2_macos.py`) currently ignores the `--reference-images`/`--reference-strengths` args that `local.js` now passes. Wire diffusers' multi-reference API in the runner and swap `server/lib/mediaModels.js#flux2-klein-9b` `tokenizerRepo` to `FLUX.2-klein-9B-kv` (gated repo — requires the user to accept the license on HF). Validate end-to-end with 2–4 uploaded refs.
- [ ] [voice-cos-tool-expansion-calendar-today-calendar] **Voice CoS tool expansion** — `calendar_today` / `calendar_next` (existing Google Calendar MCP), `meatspace_log_workout` (wraps `meatspaceHealth.js`), `weather_now` (pick API: OpenWeather / WeatherKit / NWS), `timer_set` (reuses `agentActionExecutor.js`).
- [ ] [voice-agent-vision-fallback-ui-describe-visually] **Voice agent vision fallback** — `ui_describe_visually`: screenshot tab and send to a vision-capable model so "what's on this chart?" works on CyberCity / graph views.
- [ ] [apple-health-integration-live-sync] **Apple Health integration for MeatSpace.** iOS live sync (HealthKit Shortcut → `POST /api/meatspace/apple-health` endpoint) plus a bulk historical import path for an exported `export.xml`. Wire into existing MeatSpace tabs so steps / sleep / heart rate / VO2 max / resting HR show alongside the alcohol / blood / body / epigenetic tracks already shipped. GOALS.md flags this as a documented Secondary Goal ("Apple Health integration planned") but no implementation tracking existed until this entry.
- [ ] [apple-health-integration-live-sync] **Apple Health integration for MeatSpace.** iOS live sync (HealthKit Shortcut → `POST /api/meatspace/apple-health` endpoint) plus a bulk historical import path for an exported `export.xml`. Wire into existing MeatSpace tabs so steps / sleep / heart rate / VO2 max / resting HR show alongside the alcohol / blood / body / epigenetic tracks already shipped. GOALS.md flags this as a documented Secondary Goal ("Apple Health integration planned") but no implementation tracking existed until this entry. When this lands, also fold an Apple-Health-imported signal into the Capabilities page "Genome & Health" row (`server/lib/capabilityMap.js#genomeRow` + the route's `genome` fetch) so a health-only setup no longer reports "Not set up" purely because no genome is uploaded (codex review of `[codex5-onboarding-capability-map]`, 2026-05-24).
- [ ] [ref-watch-phosphene-mlx-post-decode-os-exit] **Bypass MLX post-decode deallocator hang via `os._exit(0)`.** From `reference-watch` review of phosphene (commits `adc1cd25` + `94bd6965`, 2026-05-22). At PortOS's current `LTX2_PIN=f8f20c83` the bug isn't reachable, but the moment a user updates `ltx-2-mlx` or PortOS bumps the pin past the May 9 refactor, every Distilled / Extend / two-stage render stalls 5–15 min after the upstream "Decoding done in X.Xs" log while Metal command-buffer completion handlers hold the GIL through frame teardown. Fix: in `scripts/generate_ltx2.py` `main()`, after `print(json.dumps(...))`, call `os._exit(0)` instead of returning normally — mp4 is on disk, stdout was flushed, skipping deallocator teardown saves up to 15 min. Document why in the file's docstring. Subsumes any in-helper watchdog approach.
- [ ] [ref-watch-phosphene-broaden-negative-prompt-override] **Broaden `DEFAULT_NEGATIVE_PROMPT` override to every module that defines it.** From `reference-watch` review of phosphene (commit `4e6129b7`, 2026-05-22). `scripts/generate_ltx2.py:55-70` `configure_negative_prompt()` only patches `ti2vid_one_stage.DEFAULT_NEGATIVE_PROMPT`. At PortOS's current pin this happens to cover every pipeline (the constant resolves against `ti2vid_one_stage` for the base class), but post-May-9 the constant moves to `ltx_pipelines_mlx._base` and `utils.constants` — and the PortOS override silently no-ops on Q8 / two-stage / HQ paths with no error for the user to chase. Fix: rewrite `configure_negative_prompt()` to probe `ti2vid_one_stage`, `_base`, and `utils.constants`, monkey-patch every module that has the constant. Keep PortOS's *replace* semantics (don't switch to phosphene's append-with-comma). No contextmanager needed — the helper is short-lived.
- [ ] [peer-sync-ephemeralize-then-delete-stalls-tombstone] **A previously-shared universe/series that's marked ephemeral, then deleted, never propagates the tombstone when the peer still has other subscriptions of that kind.** Codex iter-8 in v2.7.0 release review (P2). The ephemeralize step's `unsubscribeAllForRecord` removes the per-record sub for that record; the subsequent delete fires `recordEvents.deleted` but there's no sub to push it. The snapshot path in `syncOrchestrator.categoriesCoveredByPeerSync` then suppresses the whole-category snapshot for that peer (because ANY other per-record sub for that kind exists), so the tombstone has no fallback transport either. The peer keeps the old live copy indefinitely. Same root cause as `[peer-sync-snapshot-skip-too-coarse-for-partial-subscriptions]` — the snapshot-skip fix needs to either send the snapshot with subscribed records excluded (so tombstones for unsubscribed-but-previously-known records can still ride it), or detect the ephemeralize-then-delete sequence and proactively push the tombstone before tearing down the sub.
Expand Down Expand Up @@ -96,6 +95,7 @@ Items here need a research / design pass, an explicit decision, or a preconditio
- [ ] [useasyncaction-post-unmount-setstate-guard-add] **`useAsyncAction` post-unmount setState guard.** Add `mountedRef` to gate `setRunning(false)`. YAGNI today; do at 4th consumer.
- [ ] [extract-usecanonpatch-universe-setuniverse] **Extract `useCanonPatch(universe, setUniverse, universeId, mountedRef)`.** `UniverseCanon.jsx` + `NounsStage.jsx` 95% identical optimistic-patch handlers. Extract when a 3rd caller appears.
- [ ] [shallow-equal-guard-in-usemediaannotations-socket] **Shallow-equal guard in `useMediaAnnotations` socket handler.** Speculative micro-opt; theoretical until observed.
- [ ] [capabilities-cheap-memory-count] **Capabilities route should count active memories without sorting/paginating.** `GET /api/capabilities` calls `getMemories({ status: 'active' })` (server/routes/capabilities.js) only to read `.total`, but `getMemories` loads the full index, filters, sorts, and paginates before returning `{ total, memories }` — the sort + slice is wasted work on a page polled every ~20s. Add a lightweight `countMemories({ status })` (or a `countOnly` option) to `server/services/memory.js` that returns the filtered count without the sort/paginate, and switch the route to it. Micro-opt; the index is cached via `loadIndex`, so low urgency. Surfaced by copilot review of `[codex5-onboarding-capability-map]` 2026-05-25.
- [ ] [low-client-extract-shared-usepopoverposition-hook] **[LOW][CLIENT]** Extract shared `usePopoverPosition` hook — **premise eroded; revisit when a 3rd caller lands.** Original PLAN flagged 4 components but `VisualStylePicker` no longer exists and `AddToCollectionMenu` / `BulkTargetPicker` no longer use portal positioning. Today the pattern lives in exactly two components (`client/src/components/ThemeSwitcher.jsx` and `client/src/components/media/CollectionPickerShell.jsx`) — both implement portal-with-fixed-positioning + `useLayoutEffect` measurement + rAF-coalesced scroll/resize listeners. With only 2 callers the abstraction isn't worth the indirection; extract when a third surface needs the same shape.
- [ ] [setup-data-import-drift-tables-from-migrations] **`setup-data.js` should import drift tables from each migration file.** `SHIPPED_PROMPT_OLD_MD5` + `SHIPPED_PROMPT_NEW_MD5` in `scripts/setup-data.js:139-196` manually mirror the `ACCEPTED_OLD_MD5` + `NEW_SHIPPED_MD5` exports each migration file already maintains. The mirror is hand-edited every time a migration ships and is the most likely place to drift. After `[backport-pre-023-migrations-to-_lib]` lands, every prompt-replace migration will export those constants — `setup-data.js` can build `SHIPPED_PROMPT_OLD_MD5` / `SHIPPED_PROMPT_NEW_MD5` by `import * as m025 from './migrations/025-idea-stage-character-detail.js'`-style sweep (or by glob+dynamic import) and removing the mirror entirely. Single source of truth, no hand sync. Surfaced by /simplify during `[pipeline-idea-stage-character-detail-plumbing]`.
- [ ] [parallelize-applypromptreplacemigration-files] **Parallelize `applyPromptReplaceMigration` per-file scan.** Today every prompt-replace migration covers exactly one file, so the serial `for (await readFile…)` loop in `scripts/migrations/_lib.js` costs nothing. When the first multi-file migration lands, fold the loop into `Promise.all(Object.keys(accepted).map(async filename => …))` and accumulate the `{ updated, alreadyCurrent, skipped }` counters post-flight. Theoretical until needed.
Expand Down
2 changes: 2 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const DataManager = lazyWithReload(() => import('./pages/DataManager'));
const Insights = lazyWithReload(() => import('./pages/Insights'));
const Instances = lazyWithReload(() => import('./pages/Instances'));
const SystemHealthPage = lazyWithReload(() => import('./pages/SystemHealthPage'));
const CapabilityMap = lazyWithReload(() => import('./pages/CapabilityMap'));
const MeatSpace = lazyWithReload(() => import('./pages/MeatSpace'));
const Post = lazyWithReload(() => import('./pages/Post'));
const Review = lazyWithReload(() => import('./pages/Review'));
Expand Down Expand Up @@ -193,6 +194,7 @@ export default function App() {
<Route path="insights/:tab" element={<Insights />} />
<Route path="instances" element={<Instances />} />
<Route path="system-health" element={<SystemHealthPage />} />
<Route path="capabilities" element={<CapabilityMap />} />
<Route path="loops" element={<Loops />} />
<Route path="meatspace" element={<Navigate to="/meatspace/overview" replace />} />
<Route path="meatspace/:tab" element={<MeatSpace />} />
Expand Down
1 change: 1 addition & 0 deletions client/src/components/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ const navItems = [
icon: HardDrive,
defaultTo: '/data',
children: [
{ to: '/capabilities', label: 'Capabilities', icon: Compass },
{ to: '/data', label: 'Data', icon: HardDrive },
{ to: '/instances', label: 'Instances', icon: Network },
{ to: '/loops', label: 'Loops', icon: RefreshCw },
Expand Down
Loading