diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md deleted file mode 100644 index 20b8dc818a..0000000000 --- a/.changelog/NEXT.md +++ /dev/null @@ -1,15 +0,0 @@ -# Release vNEXT - -Released: TBD - -## Overview - -TBD - -## Added - -## Changed - -## Fixed - -## Removed diff --git a/.changelog/v2.12.0.md b/.changelog/v2.12.0.md new file mode 100644 index 0000000000..f325092b90 --- /dev/null +++ b/.changelog/v2.12.0.md @@ -0,0 +1,120 @@ +# Release v2.12.0 + +Released: 2026-05-29 + +## Overview + +A "big local models" + storytelling release. The image and video stacks open up +to 64+ GB unified-memory Apple Silicon hardware: **FLUX.2 Klein 9B bf16**, +**HiDream-I1 Full/Fast**, **Qwen-Image** / **Qwen-Image-Edit**, **Wan 2.2 T2V/I2V**, +and **HunyuanVideo** land as first-class entries, sharing a single diffusers +runner where they can and getting dedicated BYOV runtime installers where they +can't. A new **Memory Management panel** (Settings → Local LLMs) surfaces what's +resident and lets you evict Ollama models / Whisper / Kokoro before a render. +**FLUX.2 multi-reference editing on Apple Silicon** is wired through diffusers' +KV-cache pipeline, with per-reference strengths actually honored end-to-end via +a runtime patch. + +On the authoring side, a new **Unified Story Builder** (`/story-builder`) walks +a story from idea → universe aesthetic → plot arc → reader map → characters → +issues → production as one guided flow — including an importer that +reverse-engineers a finished work (comic script / screenplay / novel) back into +the same wizard. Universes get a **base style probe image** (canonical render +of the embrace/avoid influences) shared between the Universe Builder and Story +Builder. A series arc now has a **Reader Map** distinct from the protagonist arc. + +**Brain → Links** picks up buckets (drag-to-organize bookmark groups, federated +across peers), keyword search, optional title/tags on quick-add, an editable URL +on existing links, and a fixed Other-Links filter. Image / Video Gen surfaces +**inline model download status + SSE progress** so users see a multi-GB pull +coming before hitting Render; the live preview matches the configured aspect +ratio and shows a step counter; hi-res presets open to HiDream + Qwen; Qwen +gets a comic-page draft preset and a stepwise preview decoder. Ollama +**auto-upgrades in place** when a `pull` hits a 412-newer-version error +(macOS .app replace, brew on headless, install.sh on Linux), with a prominent +progress banner. + +Rounding it out: scheduled reference-watch can actually write to PLAN.md again +(the cron default had drifted to read-only), Vite dev allows `*.ts.net` hosts +for Tailscale MagicDNS, the Apple-Silicon local-Python detector skips x86_64 +candidates, and a multi-agent review pass cleaned up eight cross-file +regressions in the big-local-models branch (HiDream/Qwen route gate, +Wan 2.2 i2v `--image` plumbing, Ollama unload as JSON, FLUX.2 9B license URL, +schema parity on `/local-llm/unload` + `/voice/whisper`, etc.). + +## Added + +- **Brain → Links buckets (bookmark groups)**: the Links tab now has a bucket board above the searchable list, so links can be organized into arbitrary groups (e.g. a "Reading list" or "Work tools" bucket of quick-launch buttons). Each bucket is a colored card whose links render as compact favicon + title chips that open in a new tab; buckets have an inline "add a URL" field (auto-titled from the hostname), inline rename/recolor/icon edit, and can be reordered by dragging. Links can be filed into a bucket by dragging a chip between buckets or via a per-row "+ Bucket…" dropdown in the list; an "Ungrouped" filter tab surfaces links not yet in any bucket. Buckets reuse the existing link records (a new `bucketId`/`bucketOrder` on each link) — single source of truth — and are a new federated brain collection (`buckets`) that syncs across peers. Deleting a bucket keeps its links (they fall back to ungrouped). +- **Big local diffusion + video models for 64+ GB unified-memory hardware.** New entries in the image registry: Flux 2 Klein 9B native bf16 (`flux2-klein-9b-bf16`, ~36 GB) loads the gated `black-forest-labs/FLUX.2-klein-9B` repo directly without SDNQ or Int8; HiDream-I1 Full + Fast (`hidream-i1-full`, `hidream-i1-fast`) — 17B MoE DiT, needs `meta-llama/Meta-Llama-3.1-8B-Instruct` as `text_encoder_4`; Qwen-Image and Qwen-Image-Edit (`qwen-image`, `qwen-image-edit`) — 20B MMDiT, Apache 2.0, strong English + Chinese text rendering. New video entries: LTX-2.3 Unified Beta bf16 un-deprecated (now practical on 128 GB hardware as the quality ceiling for the mlx_video runtime); Wan 2.2 T2V + I2V A14B (~28 GB) via osama-ata/Wan2.2-mlx; HunyuanVideo (~60 GB) via gaurav-nelson/HunyuanVideo_MLX. The HiDream / Qwen / ERNIE / Z-Image dispatch in `server/services/imageGen/local.js` collapsed to a single `usesDiffusersRunner(model)` predicate; all four reuse the FLUX.2 venv with their pipeline-class + (HiDream only) `text_encoder_4` flags passed through to the shared `scripts/z_image_turbo.py` runner. Wan 2.2 and HunyuanVideo land as new `runtime: 'wan22' | 'hunyuan'` paths in `videoGen/local.js`, each backed by a thin subprocess wrapper (`scripts/generate_wan22.py`, `scripts/generate_hunyuan.py`) over upstream's `generate.py` — provisioned via `INSTALL_WAN22=1` / `INSTALL_HUNYUAN=1 bash scripts/setup-image-video.sh`. Upstream CLI surfaces for Wan and Hunyuan aren't pinned across releases — flagged experimental; if upstream renames args, mark the corresponding entries `broken: true` in `data/media-models.json` until the helper script is updated to match. +- **Memory Management panel (Settings → Local LLMs).** Surfaces what's currently resident in unified memory and lets you evict it before kicking off a big diffusion render: each loaded Ollama model with per-row Unload, Whisper STT (Stop/Start), Kokoro TTS (Unload), plus a "Free everything" fan-out. Ollama eviction uses the documented `keep_alive: 0` trick on `/api/generate` with an empty prompt — no tokens generated, immediate evict — exposed via `POST /api/local-llm/unload`. Voice paths add `GET /api/voice/tts/status`, `POST /api/voice/tts/unload`, and `POST /api/voice/whisper` (action: start|stop). The panel polls every 5s, errors surface via `useAsyncAction`'s single-layer toast (each memory-mgmt API helper accepts `{ silent: true }` so the panel owns the toast layer per CLAUDE.md's "custom catch ⇒ `silent: true`" rule), and "Free everything" fans out in parallel via `Promise.allSettled` so individual failures don't block the rest. +- **Brain → Links search**: a keyword search box on the Links tab filters the saved links list in real time across title, URL, description, and tags (case-insensitive). It composes with the existing GitHub/Other category tabs, includes a clear (×) affordance, and shows a distinct "no links match" empty state separate from the "no links saved yet" state. Filtering is client-side over the already-loaded list, so there are no extra round-trips and no server changes. +- **Unified Story Builder** (`/story-builder`): a new guided page that walks a story from idea → universe aesthetic → plot arc → reader map → characters → issues → production as one linear flow. Each step is LLM-assisted with an AI-refinement affordance and ends with an explicit lock before the next unlocks; going back to revise an earlier step soft-flags the downstream locked steps as "stale" (integrity gate) so nothing silently drifts — without destroying their content. The builder is a thin conductor over the existing Universe / Series / Issue records (no data duplication); heavy per-issue production hands off to the existing Pipeline issue page. Reachable from the sidebar (Create → Story Builder), ⌘K, and voice. The intro screen supports two intake modes: **start from a seed idea**, or **import a finished work** (comic script / screenplay / novel / short story) — the importer reverse-engineers it into a universe, arc, characters, and issues, then drops you into the wizard to review and lock each stage. An **AI provider/model picker** at the top of the builder (and the import tab) drives every operation — idea expand, aesthetic, arc, reader map, character refine, and the import analysis — persisted on the session so one selection applies throughout. The **characters step generates a styled preview image per character** (world style fused with the character descriptor, via the same render path as the Universe Builder) and shows it alongside each character so you can eyeball that the world and character styles read correctly together. +- **Brain → Links: optional title & tags on quick-add**: the Links tab quick-add form now has a collapsible "Add title & tags (optional)" section so a link can be saved with a custom title and tags up front, instead of only being editable after the fact. Collapsed by default to keep the paste-and-go flow clean. +- **Brain → Links: edit a link's URL**: the per-link edit form now exposes the URL alongside the title (each with a visible label so they're unambiguous even when the title defaults to the URL). Changing the URL re-derives the GitHub repo metadata, guards against duplicate URLs (409), and resets stale clone state so a cloned repo's local path can't point at the wrong target. +- **Base style image (style probe)** on a universe: generate a canonical image from the universe's embrace/avoid influences as the positive/negative prompt, with no character or subject — to preview the world's base visual emphasis. `styleNotes` is intentionally NOT mixed into the probe prompt because it never reaches the image model on any downstream prompt (canon refs, variations, sheets, comic pages) — including it would misrepresent what those renders will look like. Triggerable from both the Universe Builder (under the style/influences editor) and the Story Builder's Universe Aesthetic step; the result persists on the universe (`styleImageRefs`) so both surfaces share it. Includes a "Regenerate" affordance to re-roll the probe once an image already exists (new filenames append to `styleImageRefs[]` so prior renders survive as walk-back fallbacks). +- **Reader Map** on a series arc (`series.arc.readerMap`): a distinct audience-experience roadmap — hooks, payoffs, emotional beats, and cliffhangers across the arc — built on top of the Vonnegut story shape, separate from the protagonist arc. Generated and refined via the new Story Builder reader-map step (also preserved by arc regeneration). +- **Image + Video Gen: inline model download status**. The model picker on both pages now shows whether the selected model's weights are already in the local HuggingFace cache. Available models get a green "Available · 7.8 GB" badge; missing ones get a "Download (~est)" button that pre-fetches the repo over SSE with a live progress bar (so users learn about a multi-GB pull before hitting Render instead of being surprised by a silent lazy download mid-generation). Hitting Render without pre-downloading still works — the existing lazy-download path is the fallback. The video form also surfaces the active text encoder (a separate ~7–25 GB Gemma pull) with its own button. New endpoints: `GET /api/image-gen/models/status`, `GET /api/image-gen/models/:id/download` (SSE), `GET /api/video-gen/models/status`, `GET /api/video-gen/models/:id/download` (SSE), `GET /api/video-gen/text-encoder/download` (SSE). Cache detection lives in `server/lib/hfCache.js` (`inspectModelCache`); download orchestration in `server/lib/hfDownload.js` and the new `scripts/hf_download_repo.py` helper (runs in the FLUX.2 venv, falls back to the mflux pythonPath). Mflux legacy `dev` / `schnell` map to their canonical `black-forest-labs/FLUX.1-*` repos so they get the same badge as every other entry. +- Local LLMs: when an Ollama model pull fails with `412: requires a newer version of Ollama`, PortOS now auto-upgrades Ollama in place and retries the install — no confirm click. On macOS with `/Applications/Ollama.app` present, this downloads the latest `Ollama-darwin.zip` directly from GitHub releases, force-kills the running Ollama, replaces the `.app` bundle, strips quarantine, relaunches, and polls `/api/version` until the new binary is serving. (The old brew-only path silently left the old binary running, because `/usr/local/bin/ollama` symlinks into the `.app` bundle — even a successful `brew upgrade` doesn't change which binary is on disk inside the `.app`.) On Linux it still re-runs the official Ollama install script; for headless macOS installs (brew formula only, no `.app`), it still runs `brew upgrade ollama`. The UI now shows a prominent yellow warning banner with live step-by-step progress for the whole upgrade flow, instead of a quiet inline confirm row. Failed `brew` runs now also surface the actual stderr (e.g. `Error: ollama not installed`) instead of just an exit code. + +## Changed + +- **[flux2-multi-reference-python-runner] FLUX.2 multi-reference editing on Apple Silicon.** Uploading 2–4 reference images alongside a prompt on a FLUX.2 Klein 9B render now actually conditions the output on those references instead of silently ignoring them — Image Gen routes the request through the diffusers KV-cache multi-reference pipeline. The first time you run a multi-reference render after this update, you'll be asked to accept the FLUX.2-klein-9B-kv license on Hugging Face (one click; the runner surfaces a direct link if the license is missing). + +- **FLUX.2 multi-reference: per-reference strengths are now honored end-to-end.** Each reference image's strength slider (0.0–1.0) actually controls that reference's contribution instead of being advisory — `scripts/flux2_macos.py` installs a runtime patch on diffusers' `Flux2KVLayerCache.store` (steps 1+) and `_flux2_kv_causal_attention` (step 0 extract path) that scales the per-reference V slice by its strength while leaving K and the softmax allocation intact, matching IP-Adapter-style "ref scale" semantics. 1.0 reproduces the upstream baseline, 0.0 fully ignores that reference, intermediate values attenuate proportionally. The misleading "Per-reference strengths not honored yet" warning in the Image Gen UI is gone, and the sidecar field renamed from `referenceImageStrengthsRequested` → `referenceImageStrengths` (Python sidecar `referenceStrengthsRequested` → `referenceStrengths`) since the values reflect what was actually applied. SDNQ-gate on `flux2-klein-4b` / `flux2-klein-9b` unchanged; the bf16/int8 multi-reference gate (PLAN `[flux2-bf16-kv-multi-reference-support]`) is orthogonal. + +- **[test-route-suites-peer-fanout-mock-noise] Quieter test logs.** Two heavily-mocked route test suites stop printing ~128 misleading warnings per run that suggested peer-sync was failing. + +- **Image Gen: hi-res presets opened to HiDream + Qwen-Image, plus a Qwen comic-page draft preset.** `client/src/lib/imageGenResolutions.js`'s `compatibilityKey` previously fell through to `flux1` for HIDREAM/QWEN, silently denying their natural `1024×1536` / `1536×1024` / `1536²` hi-res variants. Both runners now return their own keys, the three hi-res entries advertise compatibility for FLUX2 + QWEN + HIDREAM, and the Flux portrait/landscape entries (`832×1216`, `1216×832`) also open to them. Added a Qwen-only `1328×2048 (Qwen comic draft — experimental)` preset for US-comic-trim-aspect draft renders — past Qwen's documented ~1664 long-edge support window but coherent in practice on the 20B MMDiT; pair with a 1.5× upscale for final 1988×3056 output. Partial fix for `[hidream-qwen-client-runner-consumers]`; LoRA-picker side still pending. +- **Image Gen preview: step counter under the preview.** The live diffusion preview now shows a `step 32/50 · 64%` row below the image while a render is in flight. The progress payload already carried `step`/`totalSteps` from `imageGen/local.js`; just rendered them. Hidden for Codex / external paths that don't emit per-step events. +- **Qwen-Image stepwise preview decode.** `scripts/z_image_turbo.py` gained a Qwen-aware preview-decoder branch: Qwen-Image (and Img2Img / Edit siblings) packs latents to 3D `(B, num_patches, C*4)` like Flux, then unpacks to a 5-D video-VAE layout and unnormalizes with per-channel `latents_mean`/`latents_std` lists from `vae.config` before decoding. The generic `latents/scaling + shift` path matched neither step. New unpack helper (3D→5D) + Qwen-specific decoder; per-channel norm tensors are built CPU-side once and lazily migrated to (device, dtype) per call so the diffusion loop's ~30–50 fires don't rebuild from a list every step. +- **[ref-watch-phosphene-pin-mlx-lazy-eval-cadence-envs] Faster LTX-2 video renders on Apple Silicon.** Tuned the MLX lazy-eval cadence the LTX-2 helper uses so denoise steps run at the cadence phosphene's published M-series perf matrix shows is fastest — also avoids a known post-decode hang the prior setting could exhibit. No UI change; renders just finish sooner. +- **Suppress cosmetic CLIP-truncation warning on multi-encoder pipelines.** The "input was truncated because CLIP can only handle N tokens" warning from diffusers was bleeding red into PortOS server logs on long prompts with HiDream, Qwen-Image, FLUX.2, Z-Image, and ERNIE — but on every one of these pipelines CLIP only contributes pooled style vectors derived from the first ~77 tokens while a high-context encoder (T5 / Llama-3.1-8B / Qwen2-VL) reads the full prompt. New `suppress_cosmetic_clip_truncation()` helper in `scripts/_runner_common.py` attaches an idempotent logging filter to the `diffusers` and `transformers` handlers (filter is on handlers, not loggers, because logger-level filters don't apply to propagated child records). Called from `scripts/z_image_turbo.py` after `import diffusers` and from `scripts/flux2_macos.py` after the pipeline-load dispatcher. + +## Fixed + +- **Wan 2.2 / HunyuanVideo: detect missing Python modules and surface an actionable install prompt.** Wan 2.2's import probe was checking `import torch, transformers, huggingface_hub` — which passed even though `einops` (imported transitively by `wan/modules/vae2_1.py` but absent from upstream's `pyproject.toml`) was never installed. The install banner stayed hidden and renders failed with an opaque `Exit code 1` over a deep `ModuleNotFoundError` in the server log. Two fixes: the probes now exercise the real import chain (`import wan` for Wan 2.2; `sys.path.insert` + `import hyvideo.inference` for HunyuanVideo) so any transitive dep missing trips the probe and re-surfaces the install banner; and the stderr handler in `server/services/videoGen/local.js` now scans for `ModuleNotFoundError: No module named 'X'`, swapping the generic `Exit code 1` for a runtime-specific message naming the module and the re-install command (`INSTALL_WAN22=1 bash scripts/setup-image-video.sh`). The ready cache is also invalidated on detection so the next status poll re-probes and the install banner re-appears immediately. +- **Video Gen preview matches the configured render aspect ratio.** The preview pane was hardcoded `aspect-video` (16:9), so a portrait 576×1024 request previewed in a 16:9 letterbox. The container now computes explicit pixel dimensions from the configured `width × height` fit inside a 420×420 budget — landscape, portrait, and square renders all preview in their true ratio. + +- **HunyuanVideo: pin to fp32 + mark deprecated; fix invisible per-step progress on all BYOV video runtimes.** The hunyuan runner previously defaulted to fp16, which trips an MPS matmul assertion (`MPSNDArrayMatrixMultiplication.mm:5799 failed assertion: Destination NDArray and Accumulator NDArray cannot have different datatype`) within ~2s of `predict()`. Verified bf16 hits the same assertion — only fp32 across DiT + VAE + both text encoders works on Apple Silicon, because MPS matmul kernels always use an fp32 accumulator internally and assert when the output dtype differs. At 576×1024×121 frames × 30 steps fp32 is a 4-8 hr render, so the entry is also flagged `deprecated: true` (lands in the "Legacy" optgroup). Migration `044-hunyuan-fp32-deprecated.js` patches the precision/deprecated/name fields onto existing installs' `data/media-models.json`; fresh installs pick up the shape via the updated `data.reference/media-models.json` and `DEFAULT_REGISTRY` seed. Two adjacent ergonomic fixes also landed in `server/services/videoGen/local.js` that apply to every BYOV video runtime (HunyuanVideo + Wan 2.2 + LTX-2): `PYTHONUNBUFFERED=1` on the spawn env so tqdm + loguru + STAGE: prints flush per-line (previously the UI saw no progress for the entire run because child stdio was line-buffered against a pipe), and `caffeinate -dis` (was `-i`) so display + idle + system sleep are all blocked — fixes a regression where a laptop on battery would sleep mid-render and leave the Python child in uninterruptible-wait until a hard kill. For HunyuanVideo specifically, `scripts/generate_hunyuan.py` monkey-patches `tqdm.tqdm` / `tqdm.auto.tqdm` so each diffusion step emits a `STAGE:inference:step:N:30:` line that `local.js#handleLine` parses into an SSE progress event — without this, even with unbuffered stdio, the pipeline's `tqdm` against a pipe was too sparse for the UI to render a step count. Set `hv.text_encoder_precision_2` alongside `hv.text_encoder_precision` so CLIP-L follows the same dtype as Llama — otherwise CLIP silently loaded at fp16 against an fp32 Llama and tripped the assertion via mixed-dtype embeddings. + +- **Scheduled `reference-watch` was silently read-only, breaking weekly PLAN.md updates.** `DEFAULT_TASK_INTERVALS['reference-watch']` in `server/services/taskSchedule.js` carried `taskMetadata: { readOnly: true }`, which `agentPromptBuilder.js` translated into a "## Read-Only Task — Do NOT commit, push, or modify any files" guard at the bottom of the prompt. But the v2 reference-watch prompt (`PROMPT_VERSIONS['reference-watch'] = 2`) explicitly instructs the agent to APPEND slug-tagged `[ref-watch-…]` checklist items to PLAN.md and commit them — the agent (correctly) refused to write any files, defeating the whole flow on every weekly cron firing. The on-commit trigger path in `referenceRepos.js#triggerReferenceAnalysis` was already correct (`readOnly: false`, with an inline comment documenting exactly this pitfall); the scheduled-cron default drifted. Flipped the default to `readOnly: false`, updated the stale comment, and pinned the contract via tests in `taskSchedule.test.js` (default-interval contract) and `referenceRepos.test.js` (trigger-path contract). + +- **Big-local-models PR review pass.** Multi-agent review of the big-local-models branch surfaced eight cross-file regressions that landed alongside the Memory Management + new-runtime work: + - `HiDream / Qwen image renders were rejected with "Local image generation is not configured" at the route gate before reaching the worker.** `server/routes/imageGen.js` and `server/routes/universeBuilder.js` still ran the legacy `!isFlux2 && !isZImage && !isErnie && !py` predicate chain — adding HiDream + Qwen via the `usesDiffusersRunner(model)` helper (already exported from `server/lib/runners.js`) in both routes lets the new families through without requiring a `settings.imageGen.local.pythonPath` they don't need. + - `Wan 2.2 i2v never received the `--image` arg.** `buildWan22Args` gated `--image` on the *request* `mode === 'i2v'` while `videoGen.js`'s request schema doesn't have an `'i2v'` enum value — so the i2v model was dispatched with `--task i2v-A14B` but no source image, and upstream `generate.py` errored. Now gates on `model.mode === 'i2v'` (registry source of truth) and hard-fails when an i2v model is invoked without a source image rather than silently degrading. + - `Ollama "Unload" was a no-op.** `ollamaManager.unloadModel` passed the request body as a JavaScript object to `fetchWithTimeout` (which does not JSON.stringify) — native fetch sent `[object Object]` and Ollama rejected the request, so clicking Unload appeared to succeed but never evicted the model. Body is now `JSON.stringify`-ed at the call site (mirrors `deleteModel`). + - `Unload of a non-resident Ollama model would *load* it from disk first.** `/api/generate` with `keep_alive: 0` against a model that isn't currently resident triggers Ollama to pull the multi-GB weights into VRAM solely to mark them for eviction. Now `unloadModel` calls `getLoadedModels()` first and returns `{ unloaded: false, reason: 'not loaded' }` when the model isn't resident — closes a LAN-reachable thrash-amplification footgun via `POST /api/local-llm/unload`. + - `FLUX.2 9B (bf16) license-acceptance banner pointed users at the 4B repo.** `/api/image-gen/setup/flux2-status` returned a hardcoded `https://huggingface.co/black-forest-labs/FLUX.2-klein-4B` regardless of the active model — clicking the link from `flux2-klein-9b-bf16` accepted the wrong license. The route now reads `licenseUrl` from the active model when the client passes `?modelId=`, falling back to the 4B URL for legacy callers; the ImageGen page passes the current model id. + - `voice.test.js bootstrap mock missing `startWhisper` / `stopWhisper`.** `routes/voice.js` now destructures those names at module load; the mock factory didn't export them, so the first test ever added for `POST /api/voice/whisper` would TypeError at request time. Mock factory updated; the new entries are no-ops because no existing test exercises the route yet (covered separately by `[memory-mgmt-new-endpoint-tests]` in PLAN.md). + - `/api/local-llm/unload`, `/api/voice/whisper` bypassed Zod validation despite every other route in their respective files using `validateRequest`. Added `localLlmUnloadSchema` (reusing `localLlmModelIdSchema`'s no-leading-dash / no-control-chars rules so log-injection via `console.log(\`🧹 ollama: unloaded ${name}\`)` is rejected at the boundary) and an inline `whisperActionSchema` (`z.enum(['start', 'stop'])`) — closes the schema-parity drift CLAUDE.md flags as a finding. + - `Memory Management panel "Errors surface via single layer" claim was false.** The top-of-file comment promised default-apiCore-toast-only, but the body wired every API call through `useAsyncAction` AND non-silent `apiCore.request` — double toasts on every action failure, plus a toast every 5s of poll failure during an Ollama outage. The `apiLocalLlm` / `apiVoice` memory-mgmt helpers now accept `{ silent: true }` and the panel passes it through, so `useAsyncAction` owns the toast layer (and `refresh()`'s catch handlers stay silent). Same pass: Whisper start/stop now `invalidateHealthCache()` so the panel doesn't see 3s of stale `services.whisper.ok` after the action; the Whisper start banner is gated on `voice.sttEngine === 'whisper'` so web-speech users don't get an unwanted PM2 process; `freeAll` re-polls before the fan-out so a model that idle-evicted between the 5s poll and the click isn't re-requested; and a `mountedRef` gates the polled `setState` calls so a late `/voice/status` response can't write to an unmounted tree. + - `videoGen child env didn't merge HF_TOKEN.** `imageGen/local.js` already merges `hfTokenEnv()` for gated repos; `videoGen/local.js` did not, so the Wan 2.2 / HunyuanVideo helpers couldn't authenticate `snapshot_download()` against gated upstream repos. Same merge added to the video spawn path. + + Test count delta: server 363 files / 8086 tests pass (+0 files / +24 vs. PR baseline; deltas come from sibling branches' tests that re-merge into the worktree). Client 61 files / 608 tests pass. + +- **Brain → Links "Other Links" filter showed GitHub repos**: the filter sent `isGitHubRepo=false` as a query string, but the route schema used `z.coerce.boolean()` — and `Boolean("false")` is `true`, so "Other Links" behaved identically to "GitHub Repos". The schema now parses the string explicitly so `"false"` → `false`. +- **Story Builder data-loss fixes** surfaced by the local-review pass on the unified-story-builder branch: + - `generateStep('plotArc')` was wholesale-replacing `series.arc` and `series.seasons` via a plain `updateSeries({ arc, seasons })`, which silently dropped per-field arc locks, locked seasons, and orphaned every child issue attached to a renamed/removed season. Route through `commitSeasonsWithRemap` (same helper the Arc Canvas regenerator uses) so locks are honored and orphaned issues are remapped via normalized title → number → positional fallback. + - `resolveVerifyIssues` (the auto-resolve path off the Arc Canvas verify panel) was silently wiping `series.arc.readerMap`: the LLM payload doesn't author the reader map, so omitting it from the sanitizeArc call defaulted the field to `null` and `commitSeasonsWithRemap`'s `mergeArcWithLocks` only restores fields when their per-field lock is `true`. A user who'd generated a reader map without locking it lost the entire map the first time they auto-resolved a finding. The sibling `generateArcOverview` already had this fix; `resolveVerifyIssues` drifted from it despite the "Mirrors `resolveVerifyIssues`" comment. + - Reader-map prompt JSON example used a pipe-separated enum string for `kind` (`"hook|reveal|payoff|emotional|cliffhanger"`), which LLMs reproduced literally; `sanitizeReaderBeat` then dropped every beat because the joined string isn't in `READER_MAP_BEAT_KINDS`, leaving `beats: []` and tripping the empty-payload check. Replaced with a single valid example (`"hook"`); the earlier `{{beatKindsCsv}}` line still enumerates the controlled vocabulary. + - Story Builder `currentStep` PATCH schema was `z.string().max(40)` and the sanitizer silently coerced unknown values to `STEP_IDS[0]` — a stale client that posted a removed step id would land on the first step with no error. Now `z.enum(STEP_IDS)`; the sanitizer's coerce-on-load fallback stays for resilience against pre-existing corrupted files. + - `styleImageRefs` route caps (`.max(50)` on POST and PATCH) were 4× the sanitizer cap (`IMAGE_REFS_PER_ENTRY_MAX` = 12), so a 40-entry POST silently 200'd with 28 entries dropped. Aligned to the shared `entryImageRefsField` so over-the-cap requests get a loud 400. + - Boot-time `verifyCollectionVersions` array didn't include the Story Builder store; type-level schema drift would have gone unnoticed. + - "Lock the earlier steps first" toast misfired when the step was blocked by upstream-staleness (every earlier step IS locked, but one is stale). Discriminated `reachable()` → `'unlocked' | 'stale' | true` so the toast says "Re-review the stale earlier step first" in that case. + + Dev machines that already ran migration 043 with the buggy reader-map prompts keep the bad copies in `data/prompts/stages/` — delete `story-builder-reader-map.md` and `story-builder-reader-map-refine.md` from that directory, then restart, and migration 043 will re-copy the fixed versions from `data.reference/`. Fresh installs are unaffected. + +- Series detail page: when the Story Bible drawer is open, the Series Arc + Editorial Roadmap split and the inner text + 260px Themes panel split now respond to the actual content-area width instead of viewport width. Switched to Tailwind v4 container queries — Roadmap drops below Arc when the content area is < 1024px, and Themes stacks below the logline/summary column when the Arc card is < 672px, preventing the text column from being squeezed into an unreadable strip. +- Vite dev server: allow `*.ts.net` hosts so `npm run dev` works when launched via Tailscale MagicDNS (previously rejected with `Blocked request. This host ("…ts.net") is not allowed`). +- Universe Builder base style image lightbox: clicking the rendered probe thumb opened the preview modal with `"Base style"` in the prompt field instead of the actual prompt sent to the renderer. The Universe Builder hydrates each preview item from `galleryByFilename` (built from `listImageGallery()`) so the modal sees the real sidecar prompt/seed/model; that map only refreshes on `runs.length` change or an explicit `bumpGalleryRefresh()` call, and the style-probe path never called the refresh, so its entry fell through to the row label. `StyleProbeImage` now fires an `onRenderComplete` callback after the new filename persists, and `UniverseBuilder` wires it to `bumpGalleryRefresh`. +- Video Gen "Local Python not configured" warning now exposes the Detect / install / Create-venv flow inline on the page instead of linking out to the settings drawer — `LocalSetupPanel` renders directly below the disconnected status pill, wired to the same settings PATCH the drawer uses. Saving a new `pythonPath` re-polls status so the pill flips green without a manual refresh. +- Local video gen on macOS was installing the wrong `mlx_video` PyPI package. The plain `mlx_video` package is unrelated (video classification/captioning) and lacks the `mlx_video.generate_av` CLI that the LTX renderer shells into — both packages publish an `import mlx_video` namespace so the missing-package check passed, but the spawn died with `No module named mlx_video.generate_av`. Fixed: `pipNameFor('mlx_video')` now returns `mlx-video-with-audio>=0.1.35` on macOS; the import probe now checks `import mlx_video.generate_av` instead of `import mlx_video` so the wrong package fails fast and the UI surfaces an install button; `installPackages()` gained a pre-uninstall step driven by a `PIP_PRE_UNINSTALL` map so the conflicting plain `mlx_video` is removed before pip refuses to "downgrade" across the name collision. Mirrors the same conflict-resolution flow `scripts/setup-image-video.sh` already used out-of-band. +- Media job worker now re-resolves `imageGen.local.pythonPath` from live settings at run time for every video job (and every non-codex image job), instead of using the snapshot captured at enqueue time. Symptom this fixes: user switches their Python in the UI, clicks Generate, and the worker still shells out to the previous (broken) interpreter — because both the in-memory queue and the on-disk `media-jobs.json` carried the old path. Now the persisted snapshot is irrelevant; live settings always win. Codex image jobs are unaffected (they don't run a local Python). +- `isExternallyManaged()` no longer false-positives on PortOS-owned venvs created from a PEP 668 base (e.g. Homebrew Python). Inside a venv, `sysconfig.get_path("stdlib")` resolves to the base interpreter's stdlib, so a venv created from Homebrew inherited the `EXTERNALLY-MANAGED` marker even though pip-in-venv ignores PEP 668 entirely. The check now also reads `sys.prefix` and `sys.base_prefix` and short-circuits to `false` when they differ (the canonical "am I in a venv?" test). Symptom this fixes: after switching from Anaconda to Homebrew and clicking "Create PortOS venv", the panel showed the new venv path but still asked to create a venv — because the new venv looked externally-managed too, hiding the regular "Install N missing packages" button. +- Video Gen status pill no longer lies when the saved Python is missing required packages. Previously `/api/video-gen/status` returned `connected: true` whenever any `pythonPath` was stored, so a Python with no `mflux` / `mlx` / `mlx_video` installed showed a green pill until the user clicked Generate and the renderer crashed. `/status` now probes the imports via `checkPackages()` and returns `connected: false` + a `missingPackages` list when anything is absent, which triggers the same inline `LocalSetupPanel`. The panel's header copy adapts: "Set up Local Python" when no path is selected, "Install missing Python packages" (with the count) when the path is valid but packages are missing. +- Local Python auto-detection on Apple Silicon Macs now skips x86_64 candidates: a default Anaconda install (`/opt/anaconda3/bin/python3`) was winning over `/opt/homebrew/bin/python3` and then failing at install time with `No matching distribution found for mlx` because `mlx` ships arm64-only wheels. `detectPython()` now probes `platform.machine()` of each candidate on `darwin/arm64` and prefers a matching interpreter. The `/api/image-gen/setup/check` response also gains `interpreterArch`, `hostArch`, `archMismatch`, and `suggestedArm64Python` fields; `LocalSetupPanel` surfaces a warning and a one-click "Switch to detected arm64 Python" button when the user's saved path is x86_64 on an Apple Silicon host. +- **[triage-gemini-pr515-out-of-scope-findings] Sub-agent index and metadata writes are now crash-safe, and the in-app shell honors quoted arguments.** A mid-write crash during sub-agent completion or pruning no longer leaves a half-written `metadata.json` / agent index on disk — both go through the shared atomic-write helper that swaps via temp file. Quick-commands run from PortOS (`git commit -m "msg with spaces"`, etc.) now keep the quoted argument as a single token instead of splitting on every space and passing the broken tokens to the child process. Same atomic guarantee was added to the small JSON caches used by various settings stores, so a crash mid-save no longer truncates them. + +## Full Changelog + +**Full Diff**: https://github.com/atomantic/PortOS/compare/v2.11.0...v2.12.0 diff --git a/.claude/commands/claim.md b/.claude/commands/claim.md index ece54d9af4..e0cbc24269 100644 --- a/.claude/commands/claim.md +++ b/.claude/commands/claim.md @@ -1,6 +1,6 @@ --- description: Claim the next unclaimed PLAN.md item by its [slug] ID, do the work in an isolated worktree, ship a PR, and clean up. -argument-hint: "[] [--review-with=]" +argument-hint: "[] [--review-with=] [--no-review]" --- # Claim — Pick the next PLAN.md item and ship it @@ -12,7 +12,8 @@ Claim the next unclaimed `- [ ]` item from PLAN.md via the slug-ID system, work **Arguments.** Parse `$ARGUMENTS` by splitting on whitespace — tokens starting with `--` are flags, the first remaining token is the slug. Either order works (`auth-bug --review-with=codex` and `--review-with=codex auth-bug` are equivalent). - **``** — claim THAT specific item instead of auto-picking. Useful for cherry-picking out-of-order work. The slug must already exist in PLAN.md as a `- [ ]` line; this command never assigns new IDs (that's `/do:replan`'s job). -- **`--review-with=`** — pick which reviewer runs the post-PR review loop in Phase 6. Default: `copilot` (current behavior — `/do:pr` drives the GitHub Copilot review-and-fix loop). `codex`/`gemini`/`claude` skip the Copilot loop and run an iterative CLI-based review against the PR diff instead. Record the parsed value as `REVIEWER` and reference it in Phase 6. +- **`--review-with=`** — pick which reviewer runs the post-PR review loop in Phase 6. **No default — when omitted, the agent decides in Phase 6 whether the diff warrants an external review at all** (a 3-line value swap doesn't; a multi-file feature change does). Pass this flag explicitly to force a review regardless of the agent's judgment. `copilot` drives the GitHub Copilot review-and-fix loop via `/do:pr`; `codex`/`gemini`/`claude` skip Copilot and run an iterative CLI-based review against the PR diff. Record the parsed value as `REVIEWER` (or `auto` when omitted) and reference it in Phase 6. +- **`--no-review`** — explicit opt-out from BOTH `/simplify` and the external review pass. Use when you want the agent to just ship without deliberation (e.g. a doc-only revert). Mutually exclusive with `--review-with`. ## Phase 1: Pick @@ -121,13 +122,36 @@ git commit -m "docs([]): remove from PLAN.md and log to changelog" ## Phase 6: Review and ship -1. **`/simplify`** — run the three-agent reuse/quality/efficiency review against your own diff and fix findings in the same diff (per the `feedback_simplify_after_significant_work` memory). Do this BEFORE opening the PR, not retroactively. +**Decide the review intensity before doing any review work.** Three modes; pick exactly one: -2. **Open the PR and run the review loop.** Branch on `REVIEWER` (parsed from `--review-with`, default `copilot`): +| Mode | Trigger | What runs | +|---|---|---| +| **A. Forced** | `--review-with=` was passed | `/simplify` + the named reviewer's loop (6.2a or 6.2b) — no judgment, the user opted in | +| **B. Forced skip** | `--no-review` was passed | Neither `/simplify` nor an external reviewer runs; only the local-review gate inside `/do:pr` (or a manual `/do:review` if opening the PR by hand) — useful for trivial reverts, doc-only changes | +| **C. Judgment** | Neither flag passed (the common case) | The agent judges whether the diff merits each layer. **`/simplify` and the external review pass are independent decisions** — answer each on its own merits | - ### 6.2a — `REVIEWER=copilot` (default) +**Heuristic for mode C — when to skip:** - Run **`/do:pr`**. It runs `/do:review` as a local-review gate AND drives the Copilot review-and-fix loop. Do NOT run `/do:review` separately first — `/do:pr` does it. Trust the loop. When `/do:pr` reports the PR is clean (zero unresolved Copilot comments, or you've judged the remaining findings to be nitpicks not worth another round), the PR is ready to merge. +| Layer | Skip when | Run when | +|---|---|---| +| **`/simplify`** | Diff is a literal value swap (e.g. `"0"` → `"1"`), a single-line typo/comment fix, a PLAN-only edit, or any change with no new code paths / abstractions / helpers — there's nothing for the reuse/quality/efficiency agents to find | New code (functions, classes, components), new abstractions, refactors that move logic between files, multi-file feature work, anything where reuse opportunities or efficiency concerns plausibly exist | +| **External reviewer** | Same as above PLUS the local-review gate (Tier 1+4 checklist in Phase 6.2) is clean AND the change is mechanically obvious (e.g. matches a published reference or follows an in-repo pattern verbatim) | New logic, security-adjacent code, schema/contract changes, route/handler additions, cross-file changes, anything where a second perspective on the *design* (not just the implementation) is worth a round-trip | + +When in mode C, **state the call before acting**: "Diff is N lines across M files, all in ; skipping `/simplify` (no new code paths) and external review (matches published reference)." or "Diff adds a new schema + route handler; running `/simplify` and requesting copilot review." This makes the judgment visible to the user so they can override. + +When in mode C with `/simplify` deferred but external review wanted, run `/simplify` first anyway — it's the cheaper of the two and may surface fixes that change what the reviewer sees. + +1. **`/simplify`** — run iff mode A, or mode C and the heuristic says yes. Fix findings in the same diff (per the `feedback_simplify_after_significant_work` memory). Do this BEFORE opening the PR, not retroactively. + +2. **Open the PR and run the review loop.** Pick the branch by `REVIEWER` (the parsed value from `--review-with`, or `auto` when not passed): + + ### 6.2a — `REVIEWER=copilot` OR (`REVIEWER=auto` AND heuristic says review) + + Run **`/do:pr --review-with=copilot`**. It runs `/do:review` as a local-review gate AND drives the Copilot review-and-fix loop. Do NOT run `/do:review` separately first — `/do:pr` does it. Trust the loop. When `/do:pr` reports the PR is clean (zero unresolved Copilot comments, or you've judged the remaining findings to be nitpicks not worth another round), the PR is ready to merge. + + ### 6.2c — `REVIEWER=auto` AND heuristic says skip, OR `--no-review` + + Skip the external review pass. Still run the local-review gate yourself by invoking **`/do:pr`** with NO `--review-with` flag — that runs the Tier 1+4 checklist against the diff (the spec calls this gate "REQUIRED" and it always fires) without requesting Copilot or any CLI reviewer. State the skip rationale ("3-line value swap, matches phosphene's published matrix, no external review") in the merge commit body so the audit trail is honest. ### 6.2b — `REVIEWER` is `codex`, `gemini`, or `claude` diff --git a/CLAUDE.md b/CLAUDE.md index ee8d730b4a..21e9f9f2c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -269,5 +269,6 @@ port-error: #ef4444 - After each feature or bug fix, run `/simplify` and then commit and push code - **Capture deferred work before finishing.** If during a task you identify a refactor, cleanup, abstraction, or enhancement that you decide *not* to do (out of scope, risk, time), append it to `PLAN.md` as a `- [ ]` item under the most relevant section (or a new sub-heading) with enough specificity that it can be picked up cold — file paths, line numbers, why it was skipped. Examples: code-review findings rated "skip for this PR," `/simplify` items deferred for scope, "we should also do X but later." Don't end a session with these living only in chat — they evaporate. - If we have created enough commits to wrap up a feature or issue to warrant a production release, pull the latest main and release branches and then run `/do:release` from main +- **Archive approved design plans.** When a plan is approved out of plan mode, copy the finalized plan from `~/.claude/plans/` to `./docs/plans/YYYY-MM-DD-.md` (date of approval) as a design record before implementing. See `docs/plans/README.md`. See `.changelog/README.md` for detailed format and best practices. diff --git a/PLAN.md b/PLAN.md index f0d12485c4..93025b14ff 100644 --- a/PLAN.md +++ b/PLAN.md @@ -6,14 +6,21 @@ For project goals, see [GOALS.md](./GOALS.md). For completed work, see [.changel _Batch-cleared 2026-05-25: 23 Next Up items shipped together via parallel sub-agents (env-file helper, formatBytes migration, lazy voice UI text, voice long-term-memory routing, voice tool expansion + `ui_describe_visually`, three phosphene generate_ltx2.py hardenings, the peer-sync snapshot-coverage P1 refactor + ephemeralize-then-delete, per-record tombstone-ack clamp, MediaLightbox→MediaImage, global vitest peer mock, videoHistory sync category, reverse-sub UI broadcast, palette Health disambiguation, insertXxxWithId resurrection side-effects, importer tombstone detection, TUI finish-rejection note, mortalLoom errno widen, VideoGen stale-model toast, Universes sidebar grandchildren). See `git log` + `.changelog/NEXT.md`. The two remaining items below were deferred (gated on external resources)._ -- [ ] [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. _(Deferred 2026-05-25 from the batch-clear: blocked on gated HF model + GPU validation; can't be verified autonomously.)_ - [ ] [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). _(Deferred 2026-05-25 from the batch-clear: multi-file feature with an iOS Shortcut component that needs a device to verify.)_ - [ ] [triage-gemini-out-of-scope-findings] **Triage repo-wide findings gemini surfaced during PR #508 review (UNVERIFIED, out-of-scope from the voice PR).** A `/do:review` gemini pass reviewed the whole repo instead of the branch diff and flagged these pre-existing items in files PR #508 doesn't touch. Not applied to that PR (scope creep); several may be moot under the single-user/private-network trust model — verify before acting: (1) `server/routes/database.js:~236` — DB sync builds a `bash -c` string with `dumpFile` interpolated; consider `spawn` + piped stdin instead of shell string interpolation. (2) `server/services/apps.js:~304` — `updateAppTaskTypeOverride`/`bulkUpdateAppTaskTypeOverride` don't validate `taskType` against `SELF_IMPROVEMENT_TASK_TYPES`. (3) `server/routes/apps.js:~888` — `open-editor`/`open-claude` use `shell:false` for commands that are `.cmd` shims on Windows (would fail on Windows); needs a `needsShell` check + Windows metachar validation. (4) `server/lib/httpClient.js:~110` — `insecureFetch` does a bare `JSON.parse(buffer)` that can throw synchronously; wrap or reject. (5) `server/index.js` god-file (already noted in Deferred Architecture). Surfaced 2026-05-26. - [ ] [voice-code-agent-target-managed-app] **Voice code-agent delegation can't target a managed app yet.** The `dispatch_code_agent` voice tool (`server/services/voice/tools.js`) creates a CoS user task with no `app` set, so the agent always runs against the PortOS repo (the CoS default workspace). Add an optional spoken target ("…in BookLoom") → resolve to an app id and pass `app` through to `addTask` (cos.js already persists `metadata.app`, and `agentLifecycle.js#registerAgent` already reads it). Needs a phrase→app resolver (fuzzy match against managed app names) and a guard for "app not found." Deferred from the initial build (2026-05-26) to keep v1 scoped to the self-repo case. - [ ] [voice-code-agent-status-query] **No mid-task voice status query for dispatched coding agents.** Once a task is dispatched via `dispatch_code_agent`, the user can only learn the outcome from the completion announcement — there's no "how's that coding task going?" tool. Add a `code_agent_status` voice tool that reads `data/cos/state.json` for running agents tagged `metadata.voiceDispatch` and reports phase/elapsed. Deferred from the initial build (2026-05-26); the completion announcement covers the common case. - [ ] [voice-code-agent-announce-pr-url] **Completion announcement can't speak the PR link.** `agent:completed` (cosAgents.js) fires BEFORE `cleanupAgentWorktree` creates the PR (agentLifecycle.js), so `formatAgentCompletionLine` (proactiveTriggers.js) only speaks success/failure + the task description, not the PR. To include "PR #512 is up," either (a) emit a later `agent:pr-opened` event from the cleanup path carrying `{ taskId, prUrl, voiceDispatch }` and announce on that instead, or (b) have the announcement defer until the agent record gains a `prUrl`. Deferred 2026-05-26 — a spoken GitHub URL is poor UX anyway and the user reviews the PR visually; the "done" announcement is enough for v1. +- [ ] [patch-settings-slice-helper] **Add a `patchSettingsSlice(slicePath, partial)` helper in `client/src/services/`.** The settings PUT shallow-merges top-level keys, so every caller that updates a nested field (`imageGen.local.pythonPath`, `sharing.*`, `backup.*`, etc.) re-implements the same fetch-then-spread pattern. Current sites: `ImageGenTab.handleSave` (`client/src/components/settings/ImageGenTab.jsx:211`), `VideoGen.handleSavePythonPath`, `SharingTab`, `BackupTab`, `MortalLoomTab`, `Sharing.jsx`, `StoryboardPanel`, `NounsStage`, `ComicScriptStage` — 9+ sites. A `patchSettingsSlice('imageGen.local', { pythonPath })` helper would eliminate the slice-clobbering bug class. Surfaced by /simplify on 2026-05-28; deferred because it's a cross-cutting refactor of every settings consumer. +- [ ] [warning-banner-component] **Extract a `` component.** The `bg-port-warning/10 border border-port-warning/30 rounded p-2 + AlertTriangle icon` pattern is used 30+ places across `client/src/` (Loras, Security, CreateApp, MemoryTab, ScheduleTab, EditAppModal, BrainGraph, LocalSetupPanel, etc.). No shared component exists — every site re-inlines the same Tailwind classes. Same applies to the matching success/error/info banner variants. Surfaced by /simplify on 2026-05-28. +- [ ] [setup-check-cache] **Server-side cache for `/api/image-gen/setup/check` results, keyed by `(pythonPath, stat(pythonPath).mtimeMs)`.** The `LocalSetupPanel` calls `/setup/check` on debounced (400ms) keystrokes AND on mount AND on refresh-button — each call spawns a python subprocess (~0.5-1s warm). A 30s TTL cache, busted by `/setup/install` completion + settings-PUT-of-pythonPath, would collapse most repeats to memo hits. Less critical now that the 3 subprocesses are consolidated into one, but still hot for typing flows. Surfaced by /simplify on 2026-05-28. +- [ ] [client-use-previous-hook] **Extract `usePrevious(value)` hook in `client/src/hooks/`.** The "compare current to last render via `useRef` + `useEffect`" pattern appears in `LocalSetupPanel.jsx:52-61` (transition-detection for `onPackagesChanged`) and `useMediaJobProgress.js:44` (`prevJobIdRef`). A shared hook + barrel + README row would shrink both to one line each. Surfaced by /simplify on 2026-05-28. +- [ ] [mediajobqueue-resolve-live-params] **Extract `resolveLiveParams(job, safeParams)` in `server/services/mediaJobQueue/index.js#runJob`.** The 8-line block at line 605 that re-resolves `pythonPath` from live settings mixes a settings-read concern into the (already-long) sanitize-uploads section. Pulling it into its own helper makes `runJob` easier to skim and gives the live-settings concern its own seam for future fields (e.g. `model.runtime`-aware overrides). Surfaced by /simplify on 2026-05-28. +- [ ] [pythonsetup-arch-tests] **Test coverage for arch-aware `detectPython()` + `/setup/check` arch fields.** Added 2026-05-28 with the VideoGen inline Local Python setup fix. New helpers (`probePythonArch`, `isArchMismatch`, `detectArm64Python`, `HOST_ARCH`) and the new `/api/image-gen/setup/check` response fields (`interpreterArch`, `hostArch`, `archMismatch`, `suggestedArm64Python`) have no test coverage — the existing `server/routes/imageGen.test.js` skips all `/setup/*` routes entirely. Worth a `pythonSetup.test.js` that mocks `node:os` + `node:child_process` to verify: (1) on `darwin/arm64`, `detectPython` prefers arm64 candidates over x86_64; (2) `/setup/check` includes the new arch fields and only sets `archMismatch: true` when the host is arm64; (3) `suggestedArm64Python` is null when no viable arm64 candidate exists. Deferred from the fix because the `/setup/*` route family has zero existing test harness and adding one is its own piece of work. - [ ] [mediacard-use-mediaimage-for-syncing-assets] **`MediaCard.jsx` grid thumbnails still use a raw ``.** Same peer-sync placeholder/live-swap gap that `[peer-sync-medialightbox-use-mediaimage-for-syncing-assets]` fixed for the lightbox — `client/src/components/media/MediaCard.jsx` (~line 42) doesn't get the "Syncing" placeholder or the `peerSync:asset-arrived` atomic swap. Swap the raw `` for `MediaImage`. Surfaced by that item's cross-check during the batch-clear (2026-05-25); was outside its stated scope. - [ ] [chrome-canary-followups] **Hardening for the custom-Chrome-binary feature (xhigh code-review 2026-05-25).** _DONE in the v2.10.0 release review: (b) both `spawn()` calls now have `.on('error', …)` listeners; (d) `browser/server.js#loadConfig` now try/catches the `JSON.parse`; (e) `setup-browser.js#loadConfig` now warns + returns `null` and `applyCanaryToConfig` skips the save when the existing config is unreadable; (f) top-level `runCanarySetup()` is now `.catch()`-wrapped. (c) is tracked separately in `[setup-browser-canary-headless]`. Remaining: (a), (g)–(o)._ **High-severity:** (a) On macOS headed mode (the default), `browser/server.js:208` uses `macAppBundle` only and silently ignores `chromePath` — a UI user who fills in `chromePath` for Canary/Chromium/Brave but leaves `macAppBundle` empty gets stock Chrome and the log misreports the binary; couple the two fields in the UI (or auto-derive `macAppBundle` from `chromePath` when the latter is inside a `.app`). (b) Neither `spawn(chromePath, …)` (line 216) nor `spawn('/usr/bin/open', …)` (line 208) has an `.on('error', …)` listener — a typo in `chromePath` emits 'error' with no listener → `uncaughtException` → portos-browser PM2 child crashes and restart-loops. (c) `scripts/setup-browser.js#applyCanaryToConfig` writes `chromePath` + `macAppBundle` but doesn't flip `headless: false`; combined with the seed default of `headless: true` (`data.reference/browser-config.json`), a fresh install accepting Canary runs Canary invisibly. (d) `browser/server.js#loadConfig` does bare `JSON.parse(raw)` with no try/catch — combined with setup-browser's non-atomic `writeFileSync` and the `cachedConfig` race, a partial-file write crashes the supervisor on next start (PM2 restart-loop forever). **Medium-severity:** (e) `setup-browser.js#loadConfig` silently catches all JSON parse errors and returns `{}`, then `applyCanaryToConfig` saves `{chromePath, macAppBundle}` only — wiping every other user-customized key. (f) Top-level `await runCanarySetup()` has no try/catch — any EACCES on data/browser-config.json aborts `npm run setup` / `update.sh` at what was previously a no-op step. (g) Idempotency guard only checks `chromePath`: users who decline get re-prompted on every update, and users who set only `macAppBundle` keep getting re-prompted. (h) `PORTOS_USE_CANARY` only matches literal `'0'`/`'false'` (opt-out) or `'1'`/`'true'` (opt-in) — `'no'`/`'off'`/`'yes'`/`'on'`/`'True'` fall through both branches. (i) `spawnSync(install.cmd, …, { stdio: 'inherit' })` for brew/winget can hang on a sudo password prompt under non-TTY + `PORTOS_USE_CANARY=1` (update.sh stalls). (j) `cachedConfig` in `browserService.js` is stale relative to setup-browser's direct write — GET /api/browser/config returns pre-update values until process restart. (k) `saveConfig` uses bare `writeFileSync` — switch to the canonical `atomicWrite` pattern (`server/lib/fileUtils.js`). **Low-severity:** (l) `spawnSync` failure-status check `result.status !== 0` treats `status: null` (spawn-failure / signal kill) identically to a non-zero exit and never logs `result.error` — masks ENOENT/EPERM/SIGKILL. (m) `optionalPath` Zod schema accepts any string up to 1024 chars; no `.app`/`.exe` sanity check — user pastes the bundle into `chromePath` → spawn() EISDIR. (n) `launchBrowser`'s reuse-existing-Chrome early-return (line 167) fires BEFORE `headlessMode = config.headless === true` (line 170), leaving the module-level default `false` after a PM2 restart that reuses Chrome — /health reports wrong mode (pre-existing, but in a function touched by this change). (o) `detectCanary` on macOS only checks `/Applications/...`; misses per-user `~/Applications/Google Chrome Canary.app` installs (corporate Macs, `HOMEBREW_CASK_OPTS=--appdir=$HOME/Applications`). +- [ ] [ref-watch-phosphene-teacache-extend-a2v-denoise] **TeaCache through Extend + A2V Stage-1 denoise loops.** From `reference-watch` review of phosphene (commits `ea98aad8` + `17be2a79`, 2026-05-28). PortOS's `scripts/generate_ltx2.py` invokes `ExtendPipeline.extend_from_video(...)` (~line 378) and `AudioToVideoPipeline.generate_and_save(...)` (~line 417) directly without passing a TeaCache controller, so the slowest two modes in the panel run full denoise. Phosphene reuses the existing Stage-1 calibration (`ti2vid_two_stages._build_teacache_controller(n_steps, thresh=0.5)`) by monkey-patching `guided_denoise_loop` on the `ltx_pipelines_mlx.retake` and `ltx_pipelines_mlx.a2vid_two_stage` modules, gating activation on a per-call module-level config dict, set before the pipeline call and cleared in `finally`. Predicted ~1.2× on extend at default threshold (up to ~3× at 1.5); similar for A2V Stage 1. Fix: install both monkey-patches at the top of `generate_ltx2.py`, wire `_EXTEND_TC_CONFIG` / `_A2V_TC_CONFIG` from the helper's `run_extend`/`run_a2v` paths, and add a `--no-teacache` CLI flag (default-on). **Decision needed:** ride along with the `[ref-watch-phosphene-bump-ltx2-pin-v0148]` pin bump (the calibration constants are part of the same upstream surface that gets renamed) or land independently against today's pin? Scope: small/medium. +- [ ] [ref-watch-phosphene-strip-malloc-subprocess-env] **Strip `Malloc*` env vars before spawning Python subprocesses.** From `reference-watch` review of phosphene (commit `0d7cb137`, 2026-05-28). PortOS's `server/services/videoGen/local.js:609` passes `process.env` through to the Python child unchanged (`const childEnv = { ...process.env, ...(await hfTokenEnv()) };`). On macOS, when the user launched PortOS from Pinokio or any tool that exports an empty/zero `MallocStackLogging` (or related `Malloc*`) var, every Python subprocess prints `MallocStackLogging: can't turn off malloc stack logging because it was not enabled` once per child exit — flooding stderr (~50+ lines per gen for image-gen, which fans out into download/helper subprocesses). Fix: add a `stripDebugMallocEnv(env)` helper in `server/lib/processEnv.js` (new) — `Object.fromEntries(Object.entries(env).filter(([k]) => !k.startsWith('Malloc')))` — barrel it from `server/lib/index.js` per the module-org rule, document in `server/lib/README.md`. Then route every Node→Python spawn through it: `server/services/videoGen/local.js:611`, plus the image-gen spawn sites (grep `spawn.*python` under `server/`). Scope: small. ## Deferred Architecture (human-led planning) @@ -56,7 +63,11 @@ Items here need a research / design pass, an explicit decision, or a preconditio - [ ] [episode-video-provider-picker-runwayml-third-party] **Episode-video provider picker (RunwayML / third-party).** Once the abstraction lands, expose picker on EpisodeVideoStage. - [ ] [comic-book-pdf-export-once-stages-comicpages] **Comic-book PDF export.** Once `stages.comicPages` carries enough panel data + rendered images, export print-ready PDF. - [ ] [ai-assisted-panel-scene-prompt-generation-reserve] **AI-assisted panel/scene prompt generation.** Reserve `pipeline-comic-panel-image-prompt.md` and `pipeline-storyboard-image-prompt.md` for a future "turn script fragment into N image-gen prompts" button. +- [ ] [media-job-store-progress-on-job-record] **mediaJobQueue: persist progress/statusMsg on the job record.** `runJob`'s progress handler in `server/services/mediaJobQueue/index.js` only broadcasts to SSE — it doesn't mutate the job object. So `GET /api/media-jobs/:id` (used by `MediaJobsQueue.jsx` and any other non-SSE consumer) always reports 0 progress for in-flight jobs. SSE-attached pages don't see this (lastPayload replay), but list-view hydration does. Add `job.progress` + `job.statusMsg` updates inside the dispatcher's `progress` handler. Deferred from video-reload PR — cross-cutting across image/video/codex paths. +- [ ] [video-resume-restore-keyframes] **VideoGen resume: restore keyframes from the active job.** `ACTIVE_JOB_PARAM_FIELDS` in `server/routes/videoGen.js` intentionally omits `keyframes` (gallery filename + frame-index pairs for multi-keyframe FFLF) because the v1 resume effect doesn't repopulate the picker UI for them. A user who reloads mid-render on a keyframe job loses the keyframe-picker state even though every other form field is restored. Whitelist `keyframes` in the route AND wire a `setKeyframes()`-style setter in `client/src/pages/VideoGen.jsx`'s resume effect. Deferred from video-reload PR — keyframes UI restoration is independent of the SSE re-attach. +- [ ] [unify-media-job-sse-hook-imagegen-videogen] **Unify ImageGen + VideoGen SSE attach into `useMediaJobSse` hook.** Both pages now have near-duplicate EventSource handlers (ImageGen `startLocalGeneration` lines 653-708 + resume block lines 317-328; VideoGen `attachJobEvents`). Build a hook on top of `useSseProgress` (`client/src/hooks/useSseProgress.js`) that handles the queued / started / status / progress / complete / error / canceled SSE types and consolidate both sites. Deferred from the video-reload PR — scope was just the bug fix. - [ ] [whole-episode-audio-generation-strategy-stop] **Whole-episode audio generation strategy.** Stop relying on per-clip audio; drive audio gen from episode-level prose/script arc. Generator candidates: Suno (commercial, duration control), MusicGen-MLX (local, bounded ~30s), AudioLDM2. New `audioMode: 'per-clip' | 'silent' | 'generated' | 'uploaded-track'`. Treat as a new sub-brainstorm when picked up — investigation first. +- [ ] [ltx2-fflf-skips-last-image-resize-when-both-frames-set] **ltx2 FFLF skips last-image resize when both start and end frames are provided.** `server/services/videoGen/local.js:571` gates `lastImageWillBeUsed` on `mode === 'fflf' && !sourceImagePath` so for the ltx2 runtime's true-FFLF flow (both `sourceImagePath` AND `lastImagePath` set — accepted at `buildLtx2Args` line 233 and passed via `--last-image` at line 337) the end-frame image bypasses the `resizeImage` ffmpeg pass at line 601. Either size-pad the last image (matching the start-image resize), or confirm `scripts/generate_ltx2.py` itself resizes/letterboxes its `--last-image` input before conditioning. Surfaced by gemini's `/do:review` pass against PR #526 (extract-expandhome-helper, 2026-05-29) — UNVERIFIED that this actually causes generation failures; the ltx2 helper script may already handle non-matching dimensions. Out-of-scope from PR #526 (that PR only touches the `expandHome` helper + HF download flags). ### Large product / UX redesigns @@ -98,6 +109,7 @@ Items here need a research / design pass, an explicit decision, or a preconditio - [ ] [cos-on-demand-mark-app-review-started-dedupe] **`evaluateTasks` on-demand loop — dedupe `markAppReviewStarted` calls per app.** Surfaced by gemini review (2026-05-23) at `server/services/cos.js:730`. When multiple on-demand requests for the same app are queued in a single evaluation cycle, `markAppReviewStarted` is called once per request — wasted state writes (single-user / single-instance so not a correctness bug, just churn). Collect unique `appId`s before the loop and call once. Nit-level priority. - [ ] [subagent-spawner-top-level-side-effects] **`subAgentSpawner.js` top-level side effects — move init into an explicit `initSpawner()`.** Surfaced by gemini review (2026-05-23) at `server/services/subAgentSpawner.js:153`. Module-load wires event listeners + timers, which interferes with test isolation (each test import re-arms them). Wrap the init block in `export function initSpawner()` and call it from `server/index.js` after dependencies are ready. Treat as a refactor — needs care that no consumer of the module relies on the import-time wiring. - [ ] [ref-watch-phosphene-long-clip-temporal-boost-12fps] **"Long Clip Temporal Boost" (render at 12fps, interpolate to 24fps).** From `reference-watch` review of phosphene (commit `53585fc`, 2026-05-22). Phosphene added a 12fps render path that uses ffmpeg's `minterpolate` / RIFE downstream to fill back to 24fps, doubling the effective clip length per memory budget. PortOS doesn't have an interpolation post-step in `server/services/videoGen/local.js` — adding one is a feature decision, not a bug fix. The math helpers (`_duration_to_8k_frames`, LTX `8k+1` frame-count snapping) are useful regardless; PortOS's `DEFAULT_NUM_FRAMES = 121` is already 8k+1-aligned but UI-side validation could borrow this pattern. **Decision needed:** is this worth a feature surface? No clear user pain motivating it today. +- [ ] [ref-watch-phosphene-t2v-standard-env-gated-two-stage] **Env-gated two-stage T2V perf experiment.** From `reference-watch` review of phosphene (commit `5004b40b`, 2026-05-28). PortOS's `scripts/generate_ltx2.py#run_text` already routes through `TwoStagePipeline` *when `cfg_scale` is set* (~line 221-222) — but the default no-CFG path uses single-stage `TextToVideoPipeline`. Phosphene's experiment routes T2V Standard through `pipe.generate_two_stage()` (half-res Stage 1 + 2× latent upsample + 3-step Stage 2 refine, the same recipe HQ + I2V Standard already use) even without CFG, gated on `PHOSPHENE_T2V_TWO_STAGE=1`. Predicted ~30-35 % wall-time reduction on T2V Standard (M4 Max 7:40 → 5:15 on a 5 s clip; math: 5.0 step-equivalents + ~30 s vs 8.0 step-equivalents native). Fix: in `run_text`, when `cfg_scale is None`, branch on a new `PORTOS_T2V_TWO_STAGE` env (or settings flag) to call `pipe.generate_two_stage(...)` with stage1_steps=8, stage2_steps=3, cfg_scale=1.0. **Decision needed:** worth the A/B knob, or just flip the default to two-stage T2V universally once we've validated quality? Scope: small. ### Federated media sync — deferred follow-ups (2026-05-23) @@ -163,7 +175,6 @@ Surfaced by the multi-reviewer release gate for v2.10.0. The CRITICAL/correctnes - [x] [share-empty-scalar-helper] _(DONE post-v2.10.0 patch — moved to `server/lib/objects.js`)_ **Move `isEmptyScalar` out of `recordMerge.js` into a shared util.** `recordMerge.js`'s `isEmptyScalar` (null/undefined/empty-string/empty-array predicate) is now used by `resolveScalars` and `unionSeasons` and is a generic enough "is this value blank" check to live in `server/lib/objects.js` (or `validation.js`) for reuse. Low priority. (Batch-3/gemini NIT.) - [x] [test-codereview-defaults-cache-isolation] _(DONE post-v2.10.0 patch)_ **`codeReview.test.js` doesn't reset the module-level `cachedDefaults` between tests.** `getCodeReviewDefaults` memoizes into `cachedDefaults` (invalidated only by a `settings:updated` event the test stubs as a no-op); `afterEach` resets `mockedSettings.current` but never clears the cache, so a future test adding a second `getCodeReviewDefaults()` call with different settings would silently read stale data. Export a test-only `__resetCodeReviewDefaultsCache()` and call it in `afterEach`. Latent (no current failure). (Batch-1/claude NIT.) - [x] [test-codereview-local-route] _(DONE post-v2.10.0 patch)_ **`POST /api/code-review/local` has no route-level test.** Add a supertest-style test (mocking `runLocalCodeReview`/`getSettings`): empty `diff` → 400 (Zod), unknown `backend` → 400 (`z.enum`), `model` omitted falls back to the settings model, and `{ ok: false }` service result → 502. Also add `validation.test.js` assertions that `featureProviderConfigSchema.parse({ providerId:'', model:'' })` yields `undefined`s and `codeReviewSettingsSchema` rejects unknown keys (`.strict()`). (Batch-3 IMPROVEMENT/NIT.) -- [ ] [test-route-suites-peer-fanout-mock-noise] **`pipeline.test.js` / `universeBuilder.test.js` log noisy `auto-subscribe after create failed: [vitest] error when mocking a module` warnings on every record-creating test (pre-existing on main — ~88 in pipeline.test.js alone, surfaced during the v2.10.0 patch code review).** Root cause: `createSeries`/`createUniverse` fire-and-forget `import('../sharing/peerSync.js').then(autoSubscribeRecordToAllPeers)`, and in these suites that dynamic import trips a hoisting-broken `vi.mock` factory elsewhere in the graph, so the `.catch` swallows it and logs. Tests still pass (non-fatal). The canonical fix is to mock `services/instances.js` (`mockNoPeers`) + `services/sharing/peerSync.js` (`mockNoPeerSync`) per the CLAUDE.md test contract — but the route graph statically imports a broad peerSync surface (`installPeerSyncListener`, `peerSyncEvents`, `listPeerSubscriptions`, `assetShaListForRecord`), so a full-replace mock drops needed exports and the `importActual`-spread form currently throws the same vitest mock-factory error. Needs a dedicated investigation into why `vi.importActual('../services/sharing/peerSync.js')` inside the factory errors here (likely a circular/hoist interaction with the other mocks) before the noise can be silenced. Out of scope for the v2.10.0 patch (pre-existing, non-fatal). Surfaced 2026-05-26. ### Importer provider/model picker — deferred follow-ups (2026-05-26) @@ -188,6 +199,41 @@ Surfaced by the multi-reviewer release gate for v2.11.0. The correctness/contrac - [ ] [comic-allcaps-continuation-vs-cue] **`normalizeBareComicScript` in `server/lib/comicScriptParser.js` (~line 193) can misread a short all-caps continuation line (e.g. a bare `STOP` on its own line, mid-balloon) as a new speaker cue.** The continuation branch's `!BARE_SPEAKER_LINE.test(t)` guard exists so a genuine second speaker (`GIANT` … `KESSA`) opens a new balloon — but an all-caps shouted continuation with no punctuation matches the same shape and gets treated as a cue instead of GIANT's next line. This is the inherent flip-side of the two-speaker fix: an all-caps line in *continuation* position is genuinely ambiguous between "shout" and "new cue," and the current heuristic favors "new cue" (the more common multi-speaker-panel case; shouts usually carry `!`/`?` which already excludes them). A proper fix needs one-line lookahead (a real cue is followed by its own dialogue line; a trailing shout is followed by a marker/EOF) — which means converting the `for…of` line loop to indexed iteration. Deferred from the v2.11.0 release review (gemini) as IMPROVEMENT: flipping the heuristic instead would regress the more-common multi-speaker case. Surfaced 2026-05-27. - [ ] [voice-taskcomplete-announce-once-guard] **`onTaskUpdated` in `server/services/voice/proactiveTriggers.js` (~line 193) re-announces if an already-completed voice-dispatched task is updated again.** The spoken "your coding task is done" line keys on `tasks:changed action:'updated'` + terminal status (`completed`/`blocked`) + `voiceDispatch`, with no record of already-announced task ids — so any future caller that runs `updateTask` on an already-completed voice-dispatched task (e.g. attaching a PR URL or metadata *after* marking it completed) would re-fire the announcement. The comment claims it "announces once (at its terminal outcome)," which holds only because no current caller re-updates completed tasks. Fix: track announced task ids in a `Set` in the closure (clear on unwire) and skip if already announced, OR gate on the previous→terminal status *transition* if the event carries the prior status. Low severity (no current re-update path; worst case is a duplicate spoken line, not data loss) — deferred per YAGNI since the path doesn't exist today. Surfaced 2026-05-27. +### Unified Story Builder — deferred follow-ups (2026-05-27) + +The first slice shipped a working **seed-mode** conductor (`/story-builder`): create → idea → universeAesthetic → plotArc → readerMap → characters → issues (deep-link handoff) → production, with per-step lock gating and soft-flag integrity staleness. New code: `server/services/storyBuilder.js`, `server/routes/storyBuilder.js`, `server/lib/storyBuilderSteps.js`, `server/lib/storyBuilderIntegrity.js`, `client/src/pages/StoryBuilder.jsx`, `client/src/services/apiStoryBuilder.js`, plus `series.arc.readerMap` (new field + `generateReaderMap`/`refineReaderMap` in `arcPlanner.js`, prompts + migration 043, `pipelineSeries` schema → 2). These are the deliberately-deferred pieces: + +- [ ] [story-builder-import-aesthetic-readermap-gaps] **Import pre-fills arc/characters/issues but NOT universe aesthetic or reader map.** `analyzeImport` creates the universe with only a name (no logline/premise/styleNotes/influences) and the importer never extracts a reader map — yet `createStorySession` import-branch marks every step `ready`. So in an imported session the universeAesthetic + readerMap steps show empty content under a misleading "Ready" badge; the user must Generate them in-wizard. Mitigations already in place: the session `seedIdea` is seeded from the extracted arc summary so the aesthetic Expand has a real starter, and generateStep's expand falls back through `seedIdea`→universe name (won't crash). Follow-up: either mark only the importer-filled steps `ready` (others `pending`), or extract a starter/aesthetic + reader-map pass during import. Surfaced building import mode (2026-05-27). +- [ ] [story-builder-issue-llm-seeding] **Seed issues from the arc inside the builder.** The `issues` step currently lists existing issues (from `listPipelineIssues`) and links out to the Pipeline to create/produce them. Add a "Generate issues from arc" action that calls `generateSeasonEpisodes` (arcPlanner) per season and persists via `createIssue`, so the user doesn't have to leave the builder to break the arc into issues. Per-issue lock + production handoff already work. +- [ ] [story-builder-sse-progress] **SSE streaming for long generations.** `generateStep`/`refineStep` are synchronous request/response (await the LLM, return the result) — fine for arc/reader-map but a long arc-overview blocks the button with only a spinner. Add a `GET /api/story-builder/:id/steps/:stepId/progress` EventSource endpoint (reuse `client/src/hooks/useSseProgress.js`) and stream stage progress like the pipeline auto-run does. The plan specced this; deferred as a v1 simplification. +- [ ] [story-builder-arc-refine-feedback] **AI refine-with-feedback for the plot-arc step.** `refineStep` supports `universeAesthetic` (via `refineWorldPrompts`), `readerMap` (`refineReaderMap`), and `characters` (`refineUniverseCharacter`), but `plotArc` only offers full regenerate (`generateArcOverview`) — there's no "refine the arc against this note" path. Add an arc-refine prompt + `refineArc` in `arcPlanner.js` and wire it into `refineStep`'s `plotArc` branch + the `StepPanel` plotArc RefineBox. +- [ ] [story-builder-embed-arccanvas] **Embed `ArcCanvas` / `StoryShapes` inline instead of read-only field blocks.** The plotArc + readerMap steps currently render read-only `FieldBlock`s + a deep-link to the Pipeline for editing. The plan called for reusing `client/src/components/pipeline/ArcCanvas.jsx` and the Vonnegut `StoryShapes` sparkline inline (and a beat timeline for the reader map). Embed them so editing happens in-builder. Deferred because `ArcCanvas` has a wide prop surface; the deep-link covers editing for v1. +- [ ] [story-builder-create-rollback] **`createStorySession` seed mode leaves an orphan universe if `createSeries` throws.** `server/services/storyBuilder.js` creates the universe shell, then the series shell, with no rollback — a `createSeries` failure (validation, disk, peer-import) leaves a stray universe (which also fired peer auto-subscribe) with no session pointing at it. Low severity under the single-user trust model, but repeated failed creates accumulate orphans. Wrap the two creates so a series failure deletes the just-created universe, or create the series first. Surfaced by the xhigh code-review (2026-05-27); deferred as non-blocking. +- [ ] [story-builder-goto-step-gate-divergence] **Two `setStoryCurrentStep` paths swallow a rejection and navigate anyway.** (a) `goToStep` in `client/src/pages/StoryBuilder.jsx` (~line 769) on a manual step click and (b) the `lock.toggle` `onSuccess` auto-advance after a successful "Lock & continue" (~line 754). If the server re-gate (fresh hashes) rejects the move while the client's `staleSteps` is stale, the UI lands on the target step but the persisted `currentStep` pointer stays behind, so reopening the session drops the user on a different step. Low severity (disabled controls prevent most cases). Either toast + revert nav on rejection, or refetch on failure. Both paths share the fix. Surfaced by the xhigh code-review (2026-05-27); auto-advance path also surfaced by the codex/gemini local-review pass (2026-05-27). +- [ ] [story-builder-import-partial-commit] **Import flow doesn't handle a partial commit.** `ImportPanel.importAndBuild` (`client/src/pages/StoryBuilder.jsx`) calls `commitImport` once; if it fails after the universe/arc/canon persist but the issue-create rolls back (`IMPORTER_PARTIAL_COMMIT_ISSUES`/`arcAlreadyPersisted`), it just toasts a generic error. Re-clicking re-sends the full payload — lower-risk than the standalone Importer (no editable preview, so arc/seasons are the same idempotent values) but a retry could duplicate issues. The standalone `Importer.jsx` guards this with the `arcAlreadyPersisted` retry (sends `arc:null, seasons:[]`). Either reuse that path or surface a "open in the Importer to recover" link. Surfaced by xhigh code-review (2026-05-27); deferred — rare, not data-corruption. +- [ ] [universe-styleImageRefs-wire-and-journal] **`styleImageRefs` is wire-synced but not version-gated, and feeds the conflict-journal content hash.** Confirmed by review: `sanitizeRecordForWire('universe')` ships `styleImageRefs` to peers; an older (field-unaware) peer drops it on receive and, after its own edit bumps `updatedAt`, LWW-strips it back off a newer peer (same class as the readerMap bug, deliberately left ungated since a probe is one-click-regenerable). Separately, `contentHashForRecord` now includes `styleImageRefs`, so a probe render can contribute to a 3-way-divergence conflict-journal entry whose diff won't mention it (`RESTORABLE_FIELDS.universe` omits it) — Conflicts-tab noise on mixed-version federations. Options if it becomes annoying: bump the `universes` schema version (gate it), make `styleImageRefs` local-only (strip from wire), or exclude it from the content hash. Surfaced 2026-05-27; deferred — low-stakes, regenerable. +- [ ] [story-builder-extract-image-render-hook] **Cleanup: the image-render lifecycle is duplicated 5×.** `StepCharacters` (StoryBuilder.jsx) + `StyleProbeImage.jsx` each re-implement `getSettings → readPipelineImageSettings → imageCfg` + a single-render jobId lifecycle, and `StepCharacters` near-verbatim copies `UniverseCanonSection`'s styled-render-and-persist routine (the `getSettings→imageCfg` block alone repeats in NounsStage/ComicScriptStage/UniverseBuilder too). Extract a `useImageRenderSettings()` + `useSingleImageRender({ buildPrompt, onComplete })` hook (home: `client/src/hooks/`) and a shared canon styled-render helper. Also: `ProviderModelPicker` (StoryBuilder.jsx) duplicates the Importer's `LlmPicker` + the `SeriesLlmPicker`/`FeatureProviderPicker`/`StagePromptModelPicker` pattern — promote one shared provider/model picker. Surfaced by xhigh code-review (2026-05-27); deferred — tech debt, not a bug. +- [ ] [story-builder-session-sync] **Optional cross-machine resumable sessions.** Sessions are intentionally local-only in v1 (excluded from peer sync — see `storyBuilder.js` header). If cross-machine resume is wanted later, key the staleness model on a content hash carried in the session (the integrity helper already produces one) rather than live-record diffing, so a peer's universe edit doesn't false-positive-stale the local session. +- [ ] [story-builder-step-content-staleness] **Upstream-hash detects when a step's INPUTS drift, not when its OUTPUTS are edited out-of-band.** A user can lock the universeAesthetic step, then mutate `universe.logline` directly in Universe Builder, and the aesthetic step lock won't flag stale (the lock is supposed to prevent mutation, but the lock is only soft — it gates the StoryBuilder UI, not the underlying universe record). Same shape for the characters step's `cast`. Either: (i) extend `buildUpstreamInputs` to fingerprint each step's *own outputs* as well as upstream inputs, treating any post-lock mutation as stale (UX over correctness — the integrity hash starts conflating "upstream drift" with "tampering"), or (ii) enforce harder locks at the underlying-record layer so out-of-band edits 400 while a story-builder step is locked. Surfaced by local-review (2026-05-27); deferred — current behavior matches the design intent of "upstream-only" hashing. +- [ ] [story-builder-refine-returns-provider] **`refineStep('readerMap')` drops `providerId`/`model` from the conductor's return.** Every other refine branch surfaces them so the route/UI can show which model ran; the readerMap branch destructures `runId` only. Two-line fix (see local-review SC-6, 2026-05-27). +- [ ] [story-builder-refine-fallback-changes] **`refineReaderMap` returns `changes`/`rationale` describing a refine attempt that was discarded in favor of the existing map.** When the LLM payload is empty, the function falls back to `arc.readerMap` but still surfaces the change list the LLM described, so the UI shows a non-empty change list describing a value that wasn't applied. Fix: when falling back, clear `changes` and `rationale`. Surfaced by local-review SC-7 (2026-05-27). +- [ ] [story-builder-hash-shape-vs-derivation-tests] **storyBuilder.test.js asserts `upstreamHash` is a 64-char hex string but never that it equals `hashUpstream(stepId, inputs)`.** A buggy implementation that always stamped `hashUpstream('idea', null)` would also pass. Import `hashUpstream` and assert exact equality so future input-set drift (the deferred staleness item above) surfaces as a test failure. Surfaced by local-review SC-8 (2026-05-27). +- [ ] [migration-043-line-ending-precedent] **Migration 043 (story-builder prompts) is first-shipment-only — no OLD_SHIPPED_MD5 hashing.** That's correct today: the prompts haven't shipped to anyone, so a hash-compare-and-update would be no-op. But the precedent matters: when a future migration AMENDS these prompts, follow migration 003's pattern — normalize line endings (`\r\n` → `\n`, bare `\r` → `\n`) before hashing, and ship both `OLD_SHIPPED_MD5` and `NEW_SHIPPED_MD5` so existing installs whose copy still matches the prior shipped hash auto-upgrade. Add a one-line reminder comment to the migration. Surfaced by local-review SS-3 (2026-05-27). +- [ ] [style-probe-image-uses-draft-not-saved] **`` in Universe Builder renders against the in-memory draft but persists the resulting filename to the SERVER's saved record.** A probe image semantically built from in-progress styleNotes/influences gets pinned to a saved universe whose actual style fields are still on the prior values; once the user discards the draft, the styleImageRefs persists on a record that never had those style notes. Either gate `canRender` on `!draftDirty` (require save first — but users want to evaluate unsaved edits) or warn when the draft has uncommitted style changes. Story Builder's aesthetic step is fine because it persists before render. Surfaced by local-review C-5 (2026-05-27). + +## Deferred + +- [ ] [consolidate-client-url-normalize] **Consolidate client-side URL normalization.** Three components prepend `https://` to a bare URL with near-identical logic: `normalizeUrl` in `client/src/components/brain/tabs/LinksTab.jsx`, the inline block in `client/src/components/QuickBrainCapture.jsx` (~line 34, with `URL_PATTERN`/`DOMAIN_PATTERN` regexes), and `client/src/components/brain/tabs/FeedsTab.jsx` (~line 50, no `git@` handling). Extract one `normalizeUrl`/`isUrl` pair to a shared `client/src/utils/` helper and migrate all three. Skipped during the buckets PR /simplify (2026-05-28) because a correct consolidation touches two unrelated components and must preserve each one's subtly different behavior (FeedsTab ignores `git@`; QuickBrainCapture splits detection from normalization). +- [ ] [brain-bucket-chip-reorder] **Drag-to-reorder chips *within* a bucket.** The Brain → Links bucket board (`client/src/components/brain/links/BucketBoard.jsx` + `BucketCard.jsx`) supports dragging a chip *between* buckets and reordering buckets, but not repositioning a chip within its own bucket — `bucketOrder` is only ever appended (max+1) on assign. Add intra-bucket position-aware drop (compute target index from the drop location, renumber `bucketOrder`, persist via `updateBrainLink`). Skipped in the initial buckets PR (2026-05-28) to avoid fragile drop-index math; chips currently sort by `bucketOrder` then insertion order, which is fine for the common case. +- [ ] [hidream-qwen-loras-runner-consumers] **Wire HIDREAM/QWEN through `client/src/pages/Loras.jsx`.** `imageGenResolutions.js` was extended on 2026-05-28 (`compatibilityKey` now returns HIDREAM/QWEN as their own keys; `1024×1536` / `1536×1024` / `1536×1536` plus a new Qwen-only `1328×2048` comic draft preset opened to them). Still pending: `client/src/pages/Loras.jsx` `RUNNER_LABELS` / `RUNNER_BG_COLORS` / Top-LoRA section list omit HIDREAM/QWEN so the LoRA picker shows them as unlabeled / no badge color. Surfaced by /do:review of PR #516 (2026-05-28). +- [ ] [wan22-hunyuan-setup-pin-shas] **Pin Wan22 / HunyuanVideo MLX clones to known-good commit SHAs.** `scripts/setup-image-video.sh` defaults `WAN22_PIN="${WAN22_PIN:-main}"` and `HUNYUAN_PIN="${HUNYUAN_PIN:-main}"`. Floating `main` on two community-maintained ports with unpinned CLIs (the PR description explicitly flags both as experimental — "if upstream renames args, flip the registry entry broken: true") means every new install gets whatever HEAD is on those repos that day. Replace the default with a tested SHA for each; let users override via the env var (same shape as the diffusers pin). Surfaced by /do:review of PR #516 (2026-05-28). +- [ ] [memory-mgmt-new-endpoint-tests] **Add tests for the 5 new memory-management routes.** `/api/local-llm/loaded`, `/api/local-llm/unload`, `/api/voice/whisper`, `/api/voice/tts/status`, `/api/voice/tts/unload` have no test coverage. The PR description claims "8062/8062 tests passing" — true for the existing suite, but no asserts pin the new wire contracts (response shapes, residency precondition, validation rejection). Add focused happy-path + validation-rejection tests so future regressions in the schema-parity fixes (or the `unloadModel` residency check) surface as test failures. Surfaced by /do:review of PR #516 (2026-05-28). +- [ ] [qwen-image-edit-init-image-required] **Qwen-Image-Edit dispatches without its required init image.** `qwen-image-edit` (registry `mediaModels.js:202`) loads `QwenImageEditPipeline` which requires an `image` arg; the entry is exposed in the normal image-gen model picker which permits text-only submission. Today the user picks the model, submits a text prompt, and the runner crashes deep inside the diffusers pipeline after loading the 20B-param model from disk. Options: (a) add an `editOnly: true` flag to the model + validate at `routes/imageGen.js` POST; (b) filter the model from the picker when no source image is uploaded; (c) reroute through a future `/api/image-gen/edit` endpoint. Touches the image route schema + UI gating — defer to a focused PR. Surfaced by codex review of PR #516 (2026-05-28). +- [ ] [hidream-requires-hf-token-fail-fast] **Probe HF token upfront for HiDream gated-encoder downloads.** `data.reference/media-models.json`'s HiDream entries carry `requiresHfToken: true` + a `licenseUrl` pointing at `meta-llama/Meta-Llama-3.1-8B-Instruct`, but `scripts/z_image_turbo.py` `load_external_text_encoder()` does NOT call `probe_hf_auth()` the way `flux2_macos.py:265` does for the base FLUX repo. A user without a valid HF token currently sees a multi-GB download attempt that fails with a 401 deep inside transformers instead of a clean upfront error. Mirror the FLUX.2 probe before invoking `transformers.from_pretrained()` on the gated encoder repo. Surfaced by /do:review of PR #516 (2026-05-28). +- [ ] [flux2-bf16-kv-multi-reference-support] **Lift the multi-reference gate on `flux2-klein-9b-bf16`.** `scripts/flux2_macos.py` (~line 280) refuses `--reference-images` on `quantization='none'` because the bf16 entry's `repo` is `black-forest-labs/FLUX.2-klein-9B` (the base 9B, not kv) — `Flux2KleinKVPipeline` would load fine (component init signatures match) but run K/V-cached reference attention against a transformer not tuned for the reference-editing task, producing off-task output. Either swap the bf16 entry's `repo` to `black-forest-labs/FLUX.2-klein-9B-kv` (which carries `flux-2-klein-9b-kv.safetensors` for the reference-tuned transformer) and ship a migration for existing installs, or thread an explicit `kvRepo` field through the runner so single-image and multi-reference renders can pull from different repos for the same logical model. Quality validation requires a 64+ GB machine to host the bf16 weights. Surfaced by gemini review of `[flux2-multi-reference-python-runner]` PR #524 (2026-05-28). +- [ ] [flux2-runner-sidecar-tests] **Test coverage for `generateImage`'s sidecar `meta` payload.** `server/services/imageGen/local.test.js` exercises `buildArgs` (CLI emission) but never asserts on the sidecar `meta` object built inside `generateImage` — including the `referenceImageFilenames` + `referenceImageStrengths` fields added by `[flux2-multi-reference-python-runner]` (the field was `referenceImageStrengthsRequested` before per-ref weighting was honored end-to-end on 2026-05-29). A focused test would need to either factor the meta-builder out of `generateImage` so it's callable without spawning Python, or stub the spawn + assert on the persisted sidecar JSON. Surfaced by gemini review of PR #524 (2026-05-28). +- [ ] [reference-watch-default-prompt-version-bump-check] **Re-audit `DEFAULT_TASK_INTERVALS['reference-watch'].taskMetadata` whenever `PROMPT_VERSIONS['reference-watch']` changes.** The schedule default was flipped from `readOnly: true` → `false` on 2026-05-28 because the v2 prompt instructs the agent to append `[ref-watch-…]` items to PLAN.md and commit them — the read-only guard silently defeated that on every weekly cron firing (the on-commit `triggerReferenceAnalysis` path was already correct). A future v3 of the prompt that returns to a propose-only flow would need to flip the default back. Pin: `server/services/taskSchedule.js#DEFAULT_TASK_INTERVALS` reference-watch entry must stay in sync with the prompt's behavior; tests in `taskSchedule.test.js` and `referenceRepos.test.js` pin the current contract. + ## Future Ideas - **Identity Context Injection** — per-task-type digital twin preamble toggle. diff --git a/client/src/App.jsx b/client/src/App.jsx index c04d277c2c..9de5f8d467 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -77,6 +77,7 @@ const WritersRoomGuide = lazyWithReload(() => import('./pages/WritersRoomGuide') const Pipeline = lazyWithReload(() => import('./pages/Pipeline')); const Sharing = lazyWithReload(() => import('./pages/Sharing')); const Importer = lazyWithReload(() => import('./pages/Importer')); +const StoryBuilder = lazyWithReload(() => import('./pages/StoryBuilder')); const PipelineSeries = lazyWithReload(() => import('./pages/PipelineSeries')); const PipelineSeriesRoadmap = lazyWithReload(() => import('./pages/PipelineSeriesRoadmap')); const PipelineIssue = lazyWithReload(() => import('./pages/PipelineIssue')); @@ -266,6 +267,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index 04b6b03c84..38b22ba6c4 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -200,6 +200,7 @@ const navItems = [ { to: '/media', label: 'Media Gen', icon: Layers }, { to: '/pipeline', label: 'Series Pipeline', icon: WorkflowIcon, dynamic: 'pipelineSeries' }, { to: '/sharing', label: 'Sharing', icon: Share2 }, + { to: '/story-builder', label: 'Story Builder', icon: Wand2 }, { to: '/universes', label: 'Universes', icon: Globe, dynamic: 'universes' }, { to: '/writers-room', label: 'Writers Room', icon: NotebookPen } ] @@ -1039,6 +1040,10 @@ export default function Layout() { // (list/table) takes the normal padded+scrolling main, mirroring // the Series Pipeline index (/pipeline is not full-width either). location.pathname.startsWith('/universes/') || + // Story Builder DETAIL (/story-builder/:id/:step) is a full-width + // stepper that owns its own scroll; the bare /story-builder index + // (list + create form) takes the normal padded+scrolling main. + location.pathname.startsWith('/story-builder/') || location.pathname.startsWith('/writers-room') || location.pathname.startsWith('/agents') || location.pathname === '/shell' || diff --git a/client/src/components/ToggleSwitch.jsx b/client/src/components/ToggleSwitch.jsx index 7a0e070f05..215910941b 100644 --- a/client/src/components/ToggleSwitch.jsx +++ b/client/src/components/ToggleSwitch.jsx @@ -22,7 +22,7 @@ export default function ToggleSwitch({ enabled, onChange, disabled, ariaLabel, s ); const trackClass = `relative inline-flex ${s.track} items-center rounded-full transition-colors shrink-0 ${ enabled ? activeColor : 'bg-gray-600' - } ${disabled ? 'opacity-50' : ''} ${className}`; + } border border-port-border/60 shadow-sm ${disabled ? 'opacity-50' : ''} ${className}`; // Decorative mode renders as a so it can sit inside another + + + + ) : ( + + )} + + + ); +} diff --git a/client/src/components/brain/links/BucketBoard.test.jsx b/client/src/components/brain/links/BucketBoard.test.jsx new file mode 100644 index 0000000000..31a2b0b6cb --- /dev/null +++ b/client/src/components/brain/links/BucketBoard.test.jsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +vi.mock('../../../services/api', () => ({ + createBrainBucket: vi.fn(), + updateBrainBucket: vi.fn(), + deleteBrainBucket: vi.fn(), + reorderBrainBuckets: vi.fn() +})); +vi.mock('../../ui/Toast', () => ({ default: { error: vi.fn(), success: vi.fn() } })); + +import * as api from '../../../services/api'; +import BucketBoard from './BucketBoard'; + +const buckets = [ + { id: 'b1', name: 'Bookmarks', color: 'purple', icon: '', order: 0 }, + { id: 'b2', name: 'Tools', color: 'accent', icon: '', order: 1 } +]; +const links = [ + { id: 'l1', url: 'https://news.example.com', title: 'News', bucketId: 'b1', bucketOrder: 0 }, + { id: 'l2', url: 'https://example.org', title: 'Ungrouped Link', bucketId: null } +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('BucketBoard', () => { + it('renders each bucket and its chips, but not links from other buckets', () => { + render( + + ); + expect(screen.getByText('Bookmarks')).toBeTruthy(); + expect(screen.getByText('Tools')).toBeTruthy(); + expect(screen.getByText('News')).toBeTruthy(); + // The ungrouped link is never shown on the board. + expect(screen.queryByText('Ungrouped Link')).toBeNull(); + }); + + it('creates a new bucket through the inline form', async () => { + api.createBrainBucket.mockResolvedValue({ id: 'b3', name: 'Reading', color: 'accent', order: 2 }); + const setBuckets = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('New bucket')); + fireEvent.change(screen.getByLabelText('New bucket name'), { target: { value: 'Reading' } }); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => expect(api.createBrainBucket).toHaveBeenCalledWith({ name: 'Reading' })); + expect(setBuckets).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/brain/links/BucketCard.jsx b/client/src/components/brain/links/BucketCard.jsx new file mode 100644 index 0000000000..a8f70b358a --- /dev/null +++ b/client/src/components/brain/links/BucketCard.jsx @@ -0,0 +1,213 @@ +import { useState } from 'react'; +import { Plus, Edit2, Trash2, Save, X, Check, GripVertical } from 'lucide-react'; +import BrailleSpinner from '../../BrailleSpinner'; +import LinkChip from './LinkChip'; +import { bucketColor, BUCKET_COLORS, BUCKET_COLOR_KEYS, LINK_DND_TYPE, BUCKET_DND_TYPE } from './bucketColors'; + +/** + * A single bucket (bookmark group): colored header with inline edit/delete, + * a grid of link chips, and an inline "add URL" affordance. Acts as a drop + * target so a link can be dragged in (from the list or another bucket) and + * so buckets can be reordered by dragging their headers. + */ +export default function BucketCard({ + bucket, + links, + onUpdate, + onDelete, + onAddLink, + onRemoveLink, + onDropLink, + onReorderBucket +}) { + const formFromBucket = () => ({ name: bucket.name, color: bucket.color, icon: bucket.icon || '' }); + const [editing, setEditing] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [form, setForm] = useState(formFromBucket); + const [addUrl, setAddUrl] = useState(''); + const [adding, setAdding] = useState(false); + const [dropActive, setDropActive] = useState(false); + + const colors = bucketColor(bucket.color); + + const startEdit = () => { + setForm(formFromBucket()); + setEditing(true); + }; + + const saveEdit = async () => { + const name = form.name.trim(); + if (!name) return; + await onUpdate(bucket.id, { name, color: form.color, icon: form.icon.trim() }); + setEditing(false); + }; + + const handleAdd = async (e) => { + e.preventDefault(); + const url = addUrl.trim(); + if (!url || adding) return; + setAdding(true); + const ok = await onAddLink(url, bucket.id); + setAdding(false); + if (ok) setAddUrl(''); + }; + + return ( +
{ e.preventDefault(); setDropActive(true); }} + onDragLeave={() => setDropActive(false)} + onDrop={(e) => { + e.preventDefault(); + setDropActive(false); + const linkId = e.dataTransfer.getData(LINK_DND_TYPE); + if (linkId) { onDropLink?.(linkId); return; } + const draggedBucketId = e.dataTransfer.getData(BUCKET_DND_TYPE); + if (draggedBucketId) onReorderBucket?.(draggedBucketId); + }} + > + {/* Header */} + {editing ? ( +
+
+ + setForm({ ...form, name: e.target.value })} + className="w-full px-2 py-1 bg-port-bg border border-port-border rounded text-white text-sm" + placeholder="Bucket name" + autoFocus + /> +
+
+ setForm({ ...form, icon: e.target.value })} + className="w-12 px-2 py-1 bg-port-bg border border-port-border rounded text-white text-sm text-center" + placeholder="🔖" + maxLength={4} + aria-label="Bucket icon (emoji)" + /> +
+ {BUCKET_COLOR_KEYS.map(key => ( + + ))} +
+
+
+ + +
+
+ ) : ( +
{ + e.dataTransfer.setData(BUCKET_DND_TYPE, bucket.id); + e.dataTransfer.effectAllowed = 'move'; + }} + > + + {bucket.icon && {bucket.icon}} +

{bucket.name}

+ {links.length} + + +
+ )} + + {/* Delete confirm */} + {confirmDelete && ( +
+ Delete bucket? Its links stay (ungrouped). + + +
+ )} + + {/* Chips */} +
+ {links.length === 0 && ( + Drop links here or add a URL below. + )} + {links.map(link => ( + + ))} +
+ + {/* Add URL */} +
+ + setAddUrl(e.target.value)} + placeholder="Add a URL…" + className="flex-1 min-w-0 px-2 py-1 bg-port-bg border border-port-border rounded text-white text-sm placeholder-gray-600 focus:outline-hidden focus:border-port-accent" + disabled={adding} + /> + +
+
+ ); +} diff --git a/client/src/components/brain/links/LinkChip.jsx b/client/src/components/brain/links/LinkChip.jsx new file mode 100644 index 0000000000..90ff6b133a --- /dev/null +++ b/client/src/components/brain/links/LinkChip.jsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { Link2, X, GripVertical } from 'lucide-react'; +import { faviconUrl, LINK_DND_TYPE } from './bucketColors'; + +/** + * A compact favicon + title button for a link inside a bucket. + * Clicking opens the link in a new tab; the hover-revealed X removes it + * from the bucket (does not delete the underlying link). Draggable chips + * carry their link id so they can be dropped into another bucket. + */ +export default function LinkChip({ link, onRemove, draggable }) { + const [iconFailed, setIconFailed] = useState(false); + const favicon = faviconUrl(link.url); + + return ( +
{ + e.dataTransfer.setData(LINK_DND_TYPE, link.id); + e.dataTransfer.effectAllowed = 'move'; + } : undefined} + > + {draggable && ( + + )} + + {favicon && !iconFailed ? ( + setIconFailed(true)} + /> + ) : ( + + )} + {link.title} + + {onRemove && ( + + )} +
+ ); +} diff --git a/client/src/components/brain/links/LinkChip.test.jsx b/client/src/components/brain/links/LinkChip.test.jsx new file mode 100644 index 0000000000..b0f1461470 --- /dev/null +++ b/client/src/components/brain/links/LinkChip.test.jsx @@ -0,0 +1,34 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import LinkChip from './LinkChip'; + +const link = { id: 'l1', url: 'https://www.example.com/docs', title: 'Example Docs' }; + +describe('LinkChip', () => { + it('renders the title and a favicon img derived from the hostname', () => { + const { container } = render(); + expect(screen.getByText('Example Docs')).toBeTruthy(); + const img = container.querySelector('img'); + expect(img.getAttribute('src')).toContain('example.com'); + }); + + it('opens the link in a new tab', () => { + render(); + const anchor = screen.getByText('Example Docs').closest('a'); + expect(anchor.getAttribute('href')).toBe(link.url); + expect(anchor.getAttribute('target')).toBe('_blank'); + }); + + it('falls back to the link icon when the favicon fails to load', () => { + const { container } = render(); + fireEvent.error(container.querySelector('img')); + expect(container.querySelector('img')).toBeNull(); + }); + + it('calls onRemove when the remove button is clicked', () => { + const onRemove = vi.fn(); + render(); + fireEvent.click(screen.getByTitle('Remove from bucket')); + expect(onRemove).toHaveBeenCalledWith(link); + }); +}); diff --git a/client/src/components/brain/links/bucketColors.js b/client/src/components/brain/links/bucketColors.js new file mode 100644 index 0000000000..3844509750 --- /dev/null +++ b/client/src/components/brain/links/bucketColors.js @@ -0,0 +1,51 @@ +/** + * Bucket color presets — keyed to the PortOS design tokens plus a few extras. + * Each preset carries full literal Tailwind class strings so the JIT compiler + * picks them up (dynamic concatenation would be purged). + */ +export const BUCKET_COLORS = { + accent: { dot: 'bg-port-accent', header: 'bg-port-accent/10 border-port-accent/40', text: 'text-port-accent' }, + success: { dot: 'bg-port-success', header: 'bg-port-success/10 border-port-success/40', text: 'text-port-success' }, + warning: { dot: 'bg-port-warning', header: 'bg-port-warning/10 border-port-warning/40', text: 'text-port-warning' }, + error: { dot: 'bg-port-error', header: 'bg-port-error/10 border-port-error/40', text: 'text-port-error' }, + purple: { dot: 'bg-purple-500', header: 'bg-purple-500/10 border-purple-500/40', text: 'text-purple-400' }, + pink: { dot: 'bg-pink-500', header: 'bg-pink-500/10 border-pink-500/40', text: 'text-pink-400' }, + cyan: { dot: 'bg-cyan-500', header: 'bg-cyan-500/10 border-cyan-500/40', text: 'text-cyan-400' }, + slate: { dot: 'bg-slate-500', header: 'bg-slate-500/10 border-slate-500/40', text: 'text-slate-300' } +}; + +export const BUCKET_COLOR_KEYS = Object.keys(BUCKET_COLORS); + +// Drag-and-drop payload types — shared so a link dragged from the list or +// from another bucket, and a bucket dragged for reordering, are recognized +// by any drop target regardless of which component initiated the drag. +export const LINK_DND_TYPE = 'text/x-brain-link'; +export const BUCKET_DND_TYPE = 'text/x-brain-bucket'; + +export function bucketColor(key) { + return BUCKET_COLORS[key] || BUCKET_COLORS.accent; +} + +/** Extract a clean hostname for favicon lookup; null if unparseable. */ +export function hostnameOf(url) { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return null; + } +} + +/** + * Favicon URL for a link — fetched from the link's own origin so we don't leak + * the user's saved-link hostnames to a third party (e.g. Google's favicon + * service) on every render. The `LinkChip` already has an `onError` fallback + * to a generic icon when `/favicon.ico` is missing. + */ +export function faviconUrl(url) { + try { + const u = new URL(url); + return `${u.protocol}//${u.host}/favicon.ico`; + } catch { + return null; + } +} diff --git a/client/src/components/brain/tabs/LinksTab.jsx b/client/src/components/brain/tabs/LinksTab.jsx index 75ee81e150..5d9c3664e4 100644 --- a/client/src/components/brain/tabs/LinksTab.jsx +++ b/client/src/components/brain/tabs/LinksTab.jsx @@ -15,12 +15,33 @@ import { AlertCircle, FolderOpen, Tag, - ShieldCheck + ShieldCheck, + Search, + ChevronDown, + ChevronUp, + FolderClosed, + GripVertical } from 'lucide-react'; import BrailleSpinner from '../../BrailleSpinner'; import toast from '../../ui/Toast'; import { timeAgo } from '../../../utils/formatters'; import { useAutoRefetch } from '../../../hooks/useAutoRefetch'; +import BucketBoard from '../links/BucketBoard'; +import { LINK_DND_TYPE } from '../links/bucketColors'; + +/** Normalize a user-entered URL the way the quick-add form does. */ +function normalizeUrl(raw) { + let url = raw.trim(); + if (!url) return null; + if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('git@')) { + if (url.includes('github.com') || url.includes('.')) { + url = 'https://' + url; + } else { + return null; + } + } + return url; +} const LINK_TYPE_COLORS = { github: 'bg-purple-500/20 text-purple-400 border-purple-500/30', @@ -41,57 +62,83 @@ const CLONE_STATUS_STYLES = { export default function LinksTab({ onRefresh }) { const [inputUrl, setInputUrl] = useState(''); + const [inputTitle, setInputTitle] = useState(''); + const [inputTags, setInputTags] = useState(''); + const [showDetails, setShowDetails] = useState(false); const [sending, setSending] = useState(false); const [links, setLinks] = useState([]); - const [total, setTotal] = useState(0); + const [buckets, setBuckets] = useState([]); const [loading, setLoading] = useState(true); - const [filter, setFilter] = useState('all'); // all, github, other + const [filter, setFilter] = useState('all'); // all, github, other, ungrouped + const [search, setSearch] = useState(''); const [editingId, setEditingId] = useState(null); const [editForm, setEditForm] = useState({}); const [confirmingDeleteId, setConfirmingDeleteId] = useState(null); const [scanningId, setScanningId] = useState(null); const inputRef = useRef(null); + // Fetch the full link set; filtering, search, and bucket membership are all + // computed client-side, so we need every link in one round-trip — the prior + // `limit: 100` silently truncated power-user collections (>100 links lost + // from list views, search, and bucket boards). The server schema caps at + // 5000, which is ample headroom for a single-user bookmark collection. const fetchLinks = useCallback(async () => { - const options = {}; - if (filter === 'github') { - options.isGitHubRepo = true; - } else if (filter === 'other') { - options.isGitHubRepo = false; - } - - const data = await api.getBrainLinks(options).catch(() => ({ links: [], total: 0 })); + const data = await api.getBrainLinks({ limit: 5000, silent: true }).catch(() => ({ links: [] })); setLinks(data.links || []); - setTotal(data.total || 0); setLoading(false); - }, [filter]); + }, []); useEffect(() => { fetchLinks(); + api.getBrainBuckets({ silent: true }) + .then(data => setBuckets(data.buckets || [])) + .catch(() => setBuckets([])); }, [fetchLinks]); // Poll for clone status updates while at least one link is in flight. const hasInFlightClone = links.some(l => l.cloneStatus === 'cloning' || l.cloneStatus === 'pending'); useAutoRefetch(fetchLinks, 3000, { enabled: hasInFlightClone, pollOnly: true }); + // Client-side filter (type / bucket membership) then keyword search. + const matchesFilter = (link) => { + if (filter === 'github') return link.isGitHubRepo; + if (filter === 'other') return !link.isGitHubRepo; + if (filter === 'ungrouped') return !link.bucketId; + return true; + }; + const filteredLinks = links.filter(matchesFilter); + + const query = search.trim().toLowerCase(); + const visibleLinks = query + ? filteredLinks.filter(link => { + const haystack = [ + link.title, + link.url, + link.description, + ...(link.tags || []) + ].filter(Boolean).join(' ').toLowerCase(); + return haystack.includes(query); + }) + : filteredLinks; + const handleSubmit = async (e) => { e.preventDefault(); if (!inputUrl.trim() || sending) return; - // Basic URL validation - let url = inputUrl.trim(); - if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('git@')) { - // Try adding https:// - if (url.includes('github.com') || url.includes('.')) { - url = 'https://' + url; - } else { - toast.error('Please enter a valid URL'); - return; - } + const url = normalizeUrl(inputUrl); + if (!url) { + toast.error('Please enter a valid URL'); + return; } + const payload = { url }; + const title = inputTitle.trim(); + if (title) payload.title = title; + const tags = inputTags.split(',').map(t => t.trim()).filter(Boolean); + if (tags.length) payload.tags = tags; + setSending(true); - const result = await api.createBrainLink({ url }).catch(err => { + const result = await api.createBrainLink(payload).catch(err => { if (err.message?.includes('already exists')) { toast.error('This URL is already saved'); } else { @@ -105,14 +152,64 @@ export default function LinksTab({ onRefresh }) { const isGitHub = result.isGitHubRepo; toast.success(isGitHub ? 'GitHub repo added - cloning in background' : 'Link saved'); setInputUrl(''); + setInputTitle(''); + setInputTags(''); + setShowDetails(false); fetchLinks(); onRefresh?.(); } }; + // Next bucketOrder for a target bucket (append to the end). + const nextBucketOrder = (bucketId) => links + .filter(l => l.bucketId === bucketId) + .reduce((max, l) => Math.max(max, l.bucketOrder ?? 0), -1) + 1; + + // Assign (or, with bucketId === null, unassign) a link to a bucket. + const handleAssignLink = async (link, bucketId) => { + const patch = bucketId + ? { bucketId, bucketOrder: nextBucketOrder(bucketId) } + : { bucketId: null }; + // Optimistic update so chips move instantly. + setLinks(prev => prev.map(l => (l.id === link.id ? { ...l, ...patch } : l))); + const updated = await api.updateBrainLink(link.id, patch).catch(err => { + toast.error(err.message || 'Failed to update link'); + return null; + }); + if (updated) { + setLinks(prev => prev.map(l => (l.id === updated.id ? updated : l))); + } else { + fetchLinks(); // revert optimistic change on failure + } + }; + + // Quick-add a URL directly into a bucket. Returns true on success. + const handleAddLinkToBucket = async (rawUrl, bucketId) => { + const url = normalizeUrl(rawUrl); + if (!url) { + toast.error('Please enter a valid URL'); + return false; + } + const result = await api.createBrainLink({ url, bucketId, bucketOrder: nextBucketOrder(bucketId) }).catch(err => { + if (err.message?.includes('already exists')) { + toast.error('This URL is already saved'); + } else { + toast.error(err.message || 'Failed to add link'); + } + return null; + }); + if (result) { + setLinks(prev => [result, ...prev]); + onRefresh?.(); + return true; + } + return false; + }; + const handleEdit = (link) => { setEditingId(link.id); setEditForm({ + url: link.url, title: link.title, description: link.description || '', linkType: link.linkType, @@ -121,7 +218,14 @@ export default function LinksTab({ onRefresh }) { }; const handleSaveEdit = async (linkId) => { + const url = editForm.url?.trim(); + if (!url) { + toast.error('URL cannot be empty'); + return; + } + const updates = { + url, title: editForm.title, description: editForm.description, linkType: editForm.linkType, @@ -129,7 +233,11 @@ export default function LinksTab({ onRefresh }) { }; const result = await api.updateBrainLink(linkId, updates).catch(err => { - toast.error(err.message || 'Failed to update'); + if (err.message?.includes('already exists')) { + toast.error('Another link already uses this URL'); + } else { + toast.error(err.message || 'Failed to update'); + } return null; }); @@ -210,7 +318,15 @@ export default function LinksTab({ onRefresh }) { } return ( -
+
+
+ {/* Left column: entry form, filters, and the full link list */} +
+
+ +

All Links

+
+ {/* Quick-add input */}
@@ -236,17 +352,56 @@ export default function LinksTab({ onRefresh }) { )}
+ + + {showDetails && ( +
+
+ + setInputTitle(e.target.value)} + placeholder="Title (defaults to repo name or URL)" + className="w-full px-3 py-2 bg-port-card border border-port-border rounded-lg text-white text-sm placeholder-gray-500 focus:outline-hidden focus:border-port-accent" + disabled={sending} + /> +
+
+ + setInputTags(e.target.value)} + placeholder="Tags (comma-separated)" + className="w-full px-3 py-2 bg-port-card border border-port-border rounded-lg text-white text-sm placeholder-gray-500 focus:outline-hidden focus:border-port-accent" + disabled={sending} + /> +
+
+ )} +

Paste any URL. GitHub repos will be automatically cloned for local reference.

{/* Filter tabs */} -
+
{[ - { id: 'all', label: 'All', count: total }, - { id: 'github', label: 'GitHub Repos', icon: GitBranch }, - { id: 'other', label: 'Other Links', icon: Link2 } + { id: 'all', label: 'All', count: links.length }, + { id: 'github', label: 'GitHub Repos', icon: GitBranch, count: links.filter(l => l.isGitHubRepo).length }, + { id: 'other', label: 'Other Links', icon: Link2, count: links.filter(l => !l.isGitHubRepo).length }, + { id: 'ungrouped', label: 'Ungrouped', icon: FolderClosed, count: links.filter(l => !l.bucketId).length } ].map(tab => { const Icon = tab.icon; const isActive = filter === tab.id; @@ -270,25 +425,71 @@ export default function LinksTab({ onRefresh }) { })}
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Search links by title, URL, description, or tag..." + className="w-full pl-9 pr-9 py-2 bg-port-card border border-port-border rounded-lg text-white text-sm placeholder-gray-500 focus:outline-hidden focus:border-port-accent" + /> + {search && ( + + )} +
+ {/* Links list */}
- {links.map(link => ( + {visibleLinks.map(link => { + const isEditing = editingId === link.id; + return (
{ + e.dataTransfer.setData(LINK_DND_TYPE, link.id); + e.dataTransfer.effectAllowed = 'move'; + } : undefined} + className={`p-4 bg-port-card border border-port-border rounded-lg ${isEditing ? '' : 'cursor-grab'}`} > {/* Header row */}
+ {!isEditing && ( + + )} {editingId === link.id ? (
- setEditForm({ ...editForm, title: e.target.value })} - className="w-full px-2 py-1 bg-port-bg border border-port-border rounded text-white text-sm" - placeholder="Title" - autoFocus - /> +
+ + setEditForm({ ...editForm, url: e.target.value })} + className="w-full px-2 py-1 bg-port-bg border border-port-border rounded text-white text-sm" + placeholder="https://example.com" + /> +
+
+ + setEditForm({ ...editForm, title: e.target.value })} + className="w-full px-2 py-1 bg-port-bg border border-port-border rounded text-white text-sm" + placeholder="Title" + autoFocus + /> +