Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
luminary/geometry/pentagon/ luminary/color/convert.py
luminary/color/color.py luminary/comms/ luminary/engine/
luminary/patterns/ luminary/render/ luminary/server/
luminary/drivers/ luminary/cli.py patterns/*.py
luminary/drivers/ luminary/cli.py luminary/vibe/ patterns/*.py
patterns/book-one/ patterns/book-two/ patterns/conifer/

- name: mypy (2.1 modules)
Expand All @@ -49,5 +49,5 @@ jobs:
luminary/color/color.py luminary/comms/ luminary/engine/
luminary/patterns/ luminary/render/ luminary/server/
luminary/drivers/ luminary/cli.py luminary/mapping/ luminary/statedir.py
luminary/stage/
luminary/stage/ luminary/vibe/
--explicit-package-bases --follow-imports=silent
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,9 @@ __marimo__/
output/*
!output/.gitkeep

# Luminary runtime state (spec §15.6): geometry store, pattern uploads,
# mapping YAMLs (var/mapping/, var/mapping-demo/) - operator data. The
# Luminary runtime state (spec §15.6): geometry documents, pattern uploads,
# vibe generations (var/vibe/), mapping YAMLs (var/mapping/,
# var/mapping-demo/) - operator data. The
# directory itself ships in the repo (var/.gitkeep), so a checkout
# always has it; its contents never do. store/ is the dead pre-rename
# tree, ignored so stale deployments don't start tracking it.
Expand Down
62 changes: 61 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ pip install -e '.[dev]' # omit [dev] to skip the test tooling

Python ≥ 3.12. Editable deliberately: geometry configs and patterns live
beside the package rather than inside it, and the code resolves them from the
source tree. `pip install -e '.[flash]'` adds PlatformIO for building firmware.
source tree. `pip install -e '.[flash]'` adds PlatformIO for building firmware;
`'.[vibe]'` adds the Claude Agent SDK for vibe mode's session backend.

## Quick start

Expand Down Expand Up @@ -160,6 +161,7 @@ against shared golden vectors (`firmware/golden/`).
| `POST /api/repeats/move` · `DELETE /api/repeats/{i}` | reorder / cancel turns of the stage's repeats cycle |
| `WS /api/stage` · `GET /api/stage/{layout,patterns,chapters?pattern=N}` | the stage's wire-codec stream (SESSION on join, `{"type":"resync"}` back), its canvas draw layout, panel pattern metadata (notes, `loop`, `has_chapters`), and one pattern's chapter tree (`[]` if chapterless) |
| `GET /api/audio` | audio inventory: `{dir, files: [{name, seconds}]}` — `dir` is the resolved directory the stage reads (the checkout's `var/audio/`), `seconds` null when unreadable |
| `GET /vibe` · `GET/POST /api/vibe` · `POST /api/vibe/select` | vibe mode (below): the prompt-to-pattern page over the stage; its whole shared state (the thread of generations, what is cooking, what is showing, the pattern menu); queue a prompt (`{prompt, name?, author?, model?, from_scratch?}` → 202 with its number); cut the stage to any pattern (`{pattern}`). Mutations take the stage key when one is configured; mounted by `serve` when the stage is (opt out: `--no-vibe`) |

## The stage (play queue)

Expand Down Expand Up @@ -230,6 +232,64 @@ with VJs — the page takes the key from its footer field (persisted in
localStorage) or once via a `#key=…` URL fragment. With no key
configured, endpoints stay open (LAN deployments).

## Vibe mode (prompt → pattern, live)

`/vibe` is the stage for a crowd: the sphere on a canvas, a text box,
and a coding model. Type what the sphere should do and press enter;
the server hands the prompt to the model, validates the module it
writes against the real lights, saves it as **generation #N** (numbered
from 1, in order of asking), and hot-cuts the stage to it — the sphere
and every open page switch together. Optional boxes take a name and
your name; a model select picks who writes it. The point is play:
pair-programming the sphere out loud, several people, quick turns.

- **Shared state.** Everything lives on the server (`/api/vibe`); the
page is a thin adapter that polls it, so open it on every phone at
the sphere and they all see the same thread, the same cooking prompt,
the same menu.
- **Context.** A prompt carries the pattern that was showing *when it
was typed* — name and source — so "slower and more purple" means
that one even if someone cut elsewhere while the model was busy.
Tick "from scratch" to drop it.
- **Always ships.** The model may leave a one- or two-sentence note or
question beside its result, but it ships its best try to every
prompt. A module that fails validation (shape, non-finite values,
statefulness, a frame over budget) gets one repair round; a
generation that still fails is shown failed in the thread, and the
stage stays where it was.
- **Nothing is lost.** Generations are ordinary pattern files under
`var/vibe/` (`vibe-0001.py`…, registered as `vibe-N`; rejected drafts
kept beside them under a leading underscore), and `var/vibe/log.json`
is the whole thread. The side menu (☰ hides it) lists the named
generations, then all of them, then the repo's own patterns by
folder — click anything to cut the stage to it, for comparison or as
the base of the next prompt. Promote a keeper by copying its file
into `patterns/book-two/`.

**Backends.** With the [Claude Agent SDK](https://pypi.org/project/claude-agent-sdk/)
installed (`pip install -e '.[vibe]'`) and a `claude` CLI on PATH (logged
in, or `ANTHROPIC_API_KEY` set), each prompt runs as its own **Claude
Code session** in the checkout, read-only, shipping through one MCP
tool that validates on the spot and hands failures straight back —
the session iterates inside its own turn. It runs in Claude Code's
**auto** permission mode: its read-only tools and the ship tool are
pre-approved, writes, shell and web are denied outright, and only a
call outside both lists goes to the permission classifier — nothing
ever waits on a prompt (`LUMINARY_VIBE_PERMISSION_MODE=default` for
plain allowlist-only mode). Without the SDK, the server makes one
direct Messages API call (`ANTHROPIC_API_KEY` required).
`LUMINARY_VIBE_BACKEND=session|api` forces one; the page's footer says
which is running. `LUMINARY_VIBE_MODELS=claude-sonnet-5,claude-opus-5`
(the default) is the list the select offers, first entry default —
keep the fast one first; a reply that takes a minute is not a
conversation.

**Security.** A generation is model-written code executed in-process,
exactly like an upload — so vibe mode mounts only where upload is
allowed **or** a stage key is configured, and then its mutations take
the key like the queue's do. `serve --no-vibe` leaves it off entirely;
`GET /api/health` reports `"vibe"`.

## Pattern development

Create a file in `patterns/`:
Expand Down
46 changes: 44 additions & 2 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ prefer one of:
Only re-enable pattern upload (drop the flag) on a server locked to people
you would hand a shell.

**Vibe mode is the same class of surface.** `/vibe` (README "Vibe mode")
executes model-written patterns in-process, so the server mounts it only
where upload is allowed *or* a stage key is configured — and then every
vibe mutation (a prompt, a cut) requires the key, like the queue's. With
upload disabled and no key, `/vibe` is simply absent (`"vibe": false` in
`/api/health`); `--no-vibe` keeps it off regardless. Treat the key as the
thing that stands between the internet and code execution on the box:
share it with the people at the sphere, not in a public URL.

## Path 1 — plain VPS (recommended for iteration speed)

Best when you control the box and want `git pull && systemctl restart
Expand Down Expand Up @@ -128,6 +137,39 @@ The default stage geometry is the production `pentagon-4A-33` capture;
the current queue entry from its beginning — there is no mid-file audio
seek.

## Vibe mode (prompt → pattern on the stage)

`/vibe` needs a coding model reachable from the box. Two backends,
picked automatically (`LUMINARY_VIBE_BACKEND=session|api` forces one):

- **session** — each prompt is its own Claude Code session in the
checkout (read-only tools, one MCP tool to ship through). Needs the
Agent SDK and the CLI:

```bash
pip install -e '.[vibe]' # in the service's venv
npm install -g @anthropic-ai/claude-code # `claude` on the unit's PATH
claude login # as the service user, once — or set ANTHROPIC_API_KEY
```

- **api** — one direct Messages API call per prompt; only
`ANTHROPIC_API_KEY` in the unit's environment.

Put `Environment=ANTHROPIC_API_KEY=…` (and `LUMINARY_STAGE_KEY=…`, which
also unlocks vibe mode on an upload-disabled server) in the systemd
unit. `LUMINARY_VIBE_MODELS` is the comma-separated list the page's
model select offers (default `claude-sonnet-5,claude-opus-5`; the first
is the default — keep a fast one first). `LUMINARY_VIBE_PERMISSION_MODE`
(default `auto`) is the session backend's Claude Code permission mode;
the tool allow/deny lists are fixed in code either way, so the
classifier only sees a call outside both. Auto mode needs a Sonnet 4.6+,
Opus 4.6+ or Fable session model — if you put a Haiku model in the list,
set it to `default` (allowlist-only, also never prompts). Generations land in
`var/vibe/` (`vibe-0001.py`…, plus `log.json`, the thread); they are
real patterns to the stage page too, and never deleted or overwritten.
The page's footer names the running backend; the thread's first line
says "no coding model" when neither is configured.

## Smoke test (either path)

```bash
Expand All @@ -142,8 +184,8 @@ B/light·frame readout confirms the wire codec is doing its job.

- **State** is only the checkout's `var/` — geometry documents,
pattern uploads, stage state and audio (`var/stage/`, `var/audio/`),
and the mapping YAMLs (`var/mapping/`, and the tutorial's
`var/mapping-demo/`). The directory ships in the repo
vibe generations (`var/vibe/`), and the mapping YAMLs (`var/mapping/`,
and the tutorial's `var/mapping-demo/`). The directory ships in the repo
(`var/.gitkeep`); its contents are gitignored. Back it up or
volume-mount it; everything else is stateless and rebuilt from the
repo. The default is anchored to the checkout, so no flag is needed;
Expand Down
7 changes: 7 additions & 0 deletions luminary/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ def cmd_serve(args: argparse.Namespace) -> int:
stage_lights=args.stage_lights,
audio_player=args.audio_player,
stage_key=args.stage_key,
vibe=not args.no_vibe,
)
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
return 0
Expand Down Expand Up @@ -772,6 +773,12 @@ def main(argv: Optional[list] = None) -> int:
help="Audio player command for stage entries "
"(default: first of mpv, cvlc, ffplay on PATH)",
)
serve.add_argument(
"--no-vibe",
action="store_true",
help="Don't mount vibe mode (/vibe: prompt-to-pattern on the stage; "
"needs a stage key when pattern upload is disabled)",
)
serve.set_defaults(func=cmd_serve)

seed = sub.add_parser("seed", help="Load demo geometries (idempotent)")
Expand Down
38 changes: 35 additions & 3 deletions luminary/patterns/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import traceback
import types
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Union
from typing import Dict, List, Optional, Sequence, Tuple, Union

from luminary.patterns.base import Pattern

Expand All @@ -24,6 +24,7 @@ def __init__(self, directories: Sequence[Union[str, Path]]):
self.patterns: Dict[str, Pattern] = {}
self.errors: Dict[str, str] = {}
self._by_stem: Dict[str, str] = {}
self._origins: Dict[str, Tuple[Path, Path]] = {}
self._load_counter = 0
self.reload()

Expand All @@ -50,6 +51,7 @@ def reload(self) -> None:
patterns: Dict[str, Pattern] = {}
errors: Dict[str, str] = {}
by_stem: Dict[str, str] = {}
origins: Dict[str, Tuple[Path, Path]] = {}

for directory in self.directories:
if not directory.is_dir():
Expand All @@ -71,10 +73,12 @@ def reload(self) -> None:
continue
patterns[loaded.name] = loaded
by_stem.setdefault(path.stem, loaded.name)
origins[loaded.name] = (directory, path)

self.patterns = patterns
self.errors = errors
self._by_stem = by_stem
self._origins = origins

def _load_file(self, path: Path) -> Optional[Pattern]:
# A fresh module name per reload forces true re-execution, and
Expand Down Expand Up @@ -109,10 +113,38 @@ def get(self, name: str) -> Pattern:
available = ", ".join(sorted(self.patterns)) or "(none)"
raise KeyError(f"Unknown pattern {name!r}; available: {available}")

def origin_of(self, name: str) -> Optional[Tuple[Path, Path]]:
"""``(registry directory, file)`` a pattern was loaded from —
which volume it lives in and where — or None for a name the
registry does not hold."""
return self._origins.get(name)

def source_of(self, name: str) -> Optional[str]:
"""The pattern's own source file, as text (a registration file
is short: what it composes lives in the library)."""
origin = self._origins.get(name)
if origin is None:
return None
try:
return origin[1].read_text()
except OSError:
return None

def list(self) -> List[dict]:
"""Metadata for every discovered pattern plus load errors (spec §15.3)."""
"""Metadata for every discovered pattern plus load errors (spec
§15.3). ``file`` is the pattern's path relative to the registry
directory it came from — the volume structure, for menus."""
entries = [
{"name": pattern.name, "description": pattern.description, "ok": True}
{
"name": pattern.name,
"description": pattern.description,
"ok": True,
"file": str(
self._origins[pattern.name][1].relative_to(
self._origins[pattern.name][0]
)
),
}
for pattern in sorted(self.patterns.values(), key=lambda p: p.name)
]
entries.extend(
Expand Down
53 changes: 46 additions & 7 deletions luminary/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from contextlib import AsyncExitStack, asynccontextmanager
from pathlib import Path
from typing import Any, AsyncIterator, Callable, Dict, Optional
from typing import Any, AsyncIterator, Callable, Dict, List, Optional

from fastapi import FastAPI, HTTPException, Query, UploadFile, WebSocket
from fastapi.websockets import WebSocketDisconnect
Expand Down Expand Up @@ -42,6 +42,8 @@ def create_app(
stage_lights: Optional[str] = None,
audio_player: Optional[str] = None,
stage_key: Optional[str] = None,
vibe: bool = True,
vibe_coder: Optional[Any] = None,
) -> FastAPI:
"""Build the app. ``allow_pattern_upload=False`` hard-disables
POST /api/patterns (403) — uploads execute in-process (spec §15.5.2), so
Expand Down Expand Up @@ -70,9 +72,21 @@ def create_app(
uploads_dir = Path(uploads_dir or state_dir / "patterns-uploads")
uploads_dir.mkdir(parents=True, exist_ok=True)
docs = GeometryStore(state_dir)
registry = registry or default_registry(
[uploads_dir] if allow_pattern_upload else []
)
# Vibe mode executes model-written patterns in-process, exactly like
# upload does — so a server that locked uploads out gets it only
# behind a stage key.
import os

stage_key = stage_key or os.environ.get("LUMINARY_STAGE_KEY") or None
vibe_on = bool(stage and vibe and (allow_pattern_upload or stage_key))
vibe_dir = state_dir / "vibe"
extra_dirs: List[Path] = []
if allow_pattern_upload:
extra_dirs.append(uploads_dir)
if vibe_on:
vibe_dir.mkdir(parents=True, exist_ok=True)
extra_dirs.append(vibe_dir)
registry = registry or default_registry(extra_dirs)

demo_app: Optional[FastAPI] = None
if mapping_demo:
Expand All @@ -83,15 +97,32 @@ def create_app(
)

stage_core = None
vibe_core = None
if stage:
import os

from luminary.stage.web import build_stage

stage_key = stage_key or os.environ.get("LUMINARY_STAGE_KEY") or None
stage_core = build_stage(
state_dir, registry, lights_ref=stage_lights, audio_player=audio_player
)
if vibe_on:
from luminary.vibe.core import VibeCore
from luminary.vibe.setup import default_coder, vibe_models

coder, backend = (
(vibe_coder, "custom")
if vibe_coder is not None
else default_coder(validator=None)
)
vibe_core = VibeCore(
vibe_dir,
registry,
stage_core,
coder,
models=vibe_models(),
backend=backend,
)
if hasattr(coder, "validator"):
coder.validator = vibe_core.validate

@asynccontextmanager
async def _broadcast(_app: FastAPI) -> AsyncIterator[None]:
Expand Down Expand Up @@ -125,6 +156,9 @@ async def _lifespan(_app: FastAPI) -> AsyncIterator[None]:
from luminary.stage.web import stage_lifespan

await stack.enter_async_context(stage_lifespan(stage_core))
if vibe_core is not None:
vibe_core.start()
stack.callback(vibe_core.stop)
if broadcast_factory is not None:
await stack.enter_async_context(_broadcast(_app))
yield
Expand All @@ -140,6 +174,10 @@ async def _lifespan(_app: FastAPI) -> AsyncIterator[None]:
# process can reach the core -- `luminary stage` appends a serial sink
# to it, which is how the queue reaches the boards.
app.state.stage = stage_core
if vibe_core is not None:
from luminary.vibe.web import register_vibe

register_vibe(app, vibe_core, stage_key=stage_key)
app.state.geometry_docs = docs
app.state.registry = registry
app.state.uploads_dir = uploads_dir
Expand All @@ -156,6 +194,7 @@ def health() -> Dict[str, Any]:
"protocol_version": PROTOCOL_VERSION,
"patterns": len(registry.patterns),
"pattern_upload": allow_pattern_upload,
"vibe": vibe_core is not None,
}

# --------------------------------------------------------------- scaffolds
Expand Down
Loading
Loading