Skip to content

Covel v0.0.23

Latest

Choose a tag to compare

@github-actions github-actions released this 28 Jul 10:39
1fe14de

[0.0.23] - 2026-07-28

Added

  • userSettings can declare type: slot. Whether the frontend rendered a model-slot picker used to depend on the setting being literally named modelPresetId — an undocumented convention discoverable only by reading framework source, and one that produced a picker on the prep screen but a free-text box under Settings → Plugins for the same setting. Discovery now keys off the declared type, so the setting can be called anything. The value stays a bare string rather than an enum, so a slot added after boot is still accepted.
  • Colliding userSettings across a plugin's runtimes are caught at load. The storage key is plugin-scoped (plugin.<id>.<key>) while the declaration sits on a runtime manifest, so two runtimes of one plugin could declare the same key and silently disagree. Identical declarations now dedupe quietly; diverging ones warn and keep the first. pnpm validate:plugin on a plugin directory runs the same cross-runtime check.

Removed

  • relations entries are plain plugin-id strings; the object form is gone. It had accumulated four interchangeable ways to spell one plugin id ({plugin}, {runtime}, {target: id}, {target: {plugin}}) alongside the bare string every bundled plugin actually used, plus three fields nothing read: type restated the array the entry already sat in, and optional / reason had no consumer at all (optional: true under requires was recommends spelled differently). Dependency rationale belongs in a YAML comment above the entry — which is what the bundled plugins were already doing. A third-party manifest using the object form now fails to load with a named migration hint rather than resolving to something unintended.
  • relations no longer accepts capability or tag targets. Both parsed cleanly — they were in the schema, the types, and the reference docs — and were then ignored forever, because relations resolve through relationPluginId(), which only ever handled plugin ids. The repo's own schema fixture demonstrated the trap: it declared requires: [{ capability: "scene-cast" }] where scene-cast is a plugin id, not a capability, so the author meant a dependency and wrote a form that could never deliver one. A documented option that never works is worse than no option. Manifests still using them now fail to load with a migration hint instead of losing the dependency at runtime. Capability-based scheduling dependencies are unaffected — needs / after are a separate mechanism.

Changed

  • Working memory renders after the plugin instructions instead of before them. It is the one system-prompt segment that changes every turn, and sitting between the framework preamble and the instructions it invalidated everything downstream: the instructions are usually the largest block in the prompt and were re-billed every single turn purely because a few lines of memory ahead of them had changed. Both caching models paid this — an explicit cache_control segment is only reusable when its whole body is byte-identical, and an automatic prefix cache stops at the first differing byte. The instructions now own a cacheable segment by themselves, and working memory trails the final breakpoint as an uncached tail. Segment numbering is unchanged (it is the segment's identity); only render order moved. Placing it last also puts the turn's freshest state nearest the conversation, where a model weighs it most heavily. This changes what every runtime sees and in what order, so it warrants a real-model check before release — the mechanical behaviour is covered by tests, prompt quality is not.

Fixed

  • A background job orphaned by a restart is reported instead of spinning forever. Background runtimes (scene backdrops, generated illustrations, TTS) run in-process, so killing the process kills the callback while its _jobs row stays pending — the panel showed "generating…" with nothing behind it, permanently and silently. The boot sweep meant to clean this up decided by age, and its predicate read the row's enqueue time, so it could not tell a corpse from a job legitimately still running; raising the threshold only traded false kills for longer spinners, which is why an earlier attempt to run the sweep periodically was reverted. Liveness is now answered by ownership rather than elapsed time: each pending row is stamped with the id of the process that owns it, and a freshly booted process cannot own any pre-existing row. A running job is therefore never reaped by construction, no heartbeat write is needed, and an orphan is failed the moment the server comes back rather than 15 minutes later. Orphans are failed rather than re-run — re-running bills the player again for image or speech generation, and the request-scoped settings a faithful re-run would need are not stored on the row — but the trigger context is carried onto the terminal row so a retry can be offered deliberately. Note this makes the sweep exact for one process per store, which is every deployment Covel ships today; running several instances against one database would additionally need a lease, since a booting instance now reads another instance's in-flight rows as foreign rather than granting them the old grace window.

  • Generating a scene backdrop no longer blocks the next message. A deferred background follower ran its entire turn inside the session lock, and for the media plugins that turn is a provider call — background-gen allows six minutes and one image genuinely takes 60-300s. Anything the player did meanwhile queued behind the artwork. Execution now runs outside the lock and only the commit takes it, so the wait drops from a whole generation to a transaction. The three reasons the lock was originally taken do not apply to this path: it passes no session clock (and completedPlayerTurns counts only player-origin executions), its domain writes are buffered into finalizeExecution's single transaction rather than dribbling out mid-run, and a follower appends no turn messages. What did need the lock — the commit and the post-commit snapshot, which must observe a settled session — still holds it. Two guarantees are preserved explicitly: followers of the same runtime are serialised on a <sessionId>::<runtimeId> job lock, so the "already generated?" checks that prevent double-billing stay atomic; and the session status is re-read under the lock before committing, so results are discarded rather than written into a session the player paused or ended during the minutes the job was running.

  • The Docker image builds again, and its llm.toml is actually read. The image had not built since 2026-07-21 and its slot config was a silent no-op, from three independent breaks: the per-member manifest COPY list went stale when three plugins landed, so pnpm install --frozen-lockfile failed on a missing workspace importer (now COPY --parents globs, so a new workspace member is covered automatically instead of drifting again); Node 25+ no longer bundles corepack, so corepack enable pnpm exited 127 right after the Node 22 → 26 bump (corepack is installed explicitly, still resolving pnpm from the root packageManager field); and the server resolves ./llm.toml from its cwd /app/apps/server while both the COPY and the compose mount target /app/llm.toml, so every slot definition was ignored and the server fell back to built-in defaults (COVEL_LLM_TOML now points at the path the mount actually uses).

  • Losing a race for the session lock returns a retryable 503 instead of a server error. Under PostgreSQL the lock acquire budget is 30 s while a background image generation legitimately holds the lock for minutes, so acting during generation hit the timeout on an entirely normal path. Nothing caught SessionLockTimeoutError, so it reached the global handler and surfaced as 500 Internal server error — indistinguishable from a real fault in logs and monitoring, and giving the client no reason to retry. It is now a 503 { code: "session_busy" }, which is what docs/reference/api.md and this changelog already described for manual snapshots and forks; the behaviour simply had never existed. The raw message names the session and internal module, so only a fixed string crosses the wire and the detail stays in the log.

  • Saving a theme with a Chinese name no longer deletes the previous one. slugifyThemeId strips every non-[a-z0-9] character, so an all-CJK name reduced to an empty string and fell back to the shared constant custom-theme — 我的极光, 我的纸本 and 夜读模式 all produced the same id. ensureThemeId only de-duplicated against builtin ids, and saveCustomTheme writes with filter(id !== …).concat(…), so the second save silently dropped the first theme with no warning and no undo. In a Chinese-first product that is the main path, not an edge case. The fallback is now derived from the name itself, which keeps re-saving the same name an update (same id, as intended) while two different names no longer collide.

  • Colour swatches announce which token they belong to. All 48 shared one hardcoded aria-label="colour", so a screen-reader user heard the same word 48 times with no way to tell the page background from the danger accent. Both the swatch and its paired text field now carry the token's own label. The theme-name field gained an accessible name too — it had only a placeholder, which is not a reliable one and vanishes on input.

  • Reading theme defaults ignores an in-flight drag preview. readTokenDefaults lifted only the properties the override module had written, but the controls also paint directly to the element while a slider is being dragged. Inside the 200 ms commit debounce those two views disagreed, so a theme or scheme switch landing in that window could record a half-dragged preview as the theme's own default. It now lifts every adjustable token regardless of who wrote it.

What's Changed

  • fix(web): appearance review follow-ups — theme id collision + a11y by @ackness in #33
  • docs: sync documentation with the code by @ackness in #34
  • Release v0.0.23 by @ackness in #35

Full Changelog: v0.0.22...v0.0.23