Source code for falaw.bridges.skill

"""Render a Claude SKILL.md (and references) from the falaw tool registry.

The skill is the entry point that teaches Claude Code how to use falaw. It
is generated --- never hand-edited at the install location --- so adding a
new tool automatically updates the skill the next time we run
`write_skill_files()`.
"""

from __future__ import annotations

import os
from typing import Iterable, Optional

from ..base import ToolSpec
from ..registry import list_models, list_tools

_HEADER = """---
name: falaw
description: >-
  Generate and manage AI media (images, video, audio) via fal.ai. Use this
  skill whenever the user wants to generate, edit, upscale, or compose media,
  or asks about fal.ai or fal-client. Also use it for *directorial* workflows:
  the user defines a Scene as data (characters, beats, shots) and falaw renders
  it with caching, so a single edit re-renders only what changed.
---

# falaw

Two ways to use `falaw`:

1. **Single-shot operations** --- `generate_image`, `text_to_speech`, etc. for
   one-off media generation.
2. **Directorial workflow** --- author a Scene as data, render it, give notes
   that re-edit the Scene, re-render only the affected beats. This is the path
   to *direct* a film instead of just *generate* clips.

## Directorial workflow (when the user wants to "plan, parametrize, render")

The thesis: keep the film in *editable structure* all the way down to the
pixels. The creator authors a Scene as data; a directorial note becomes a
single IR edit; the renderer caches everything content-addressed so unchanged
beats don't re-render.

### Phase 1: Plan (LLM-assisted or hand-built)

```python
from falaw import parse_screenplay, Scene, Character, Environment, make_beat, make_shot

# Option A: feed prose / treatment text and let any-llm draft the structure.
scene = parse_screenplay(prose_text, title="Diner Encounter",
                         style="Wes-Anderson pastel")

# Option B: build the Scene directly.
scene = Scene(
    title="Diner Encounter",
    style="Wes-Anderson symmetrical pastel",
    characters=(Character(name="Sarah", description="mid-30s, dark curly hair"),),
    environments=(Environment(name="diner", description="1950s chrome diner",
                              time_of_day="midnight"),),
    shots=(make_shot("two-shot at the booth", framing="medium",
                     environment="diner", characters=("Sarah", "Tom"), index=0),),
    beats=(
        make_beat("Sarah", "Why are you here?",
                  shot_id="...", emotion="wary", index=0),
        make_beat("Tom", "I came to apologize.", index=1),
    ),
)
```

### Phase 2: Parametrize (set identity anchors)

Cast each character with a *canonical* face and voice. These anchors get
reused for every shot/beat that character appears in --- this is what gives
identity continuity.

```python
from falaw import cast_character, establish_environment

sarah = cast_character("Sarah", "mid-30s, dark curly hair, wary eyes",
                       reference_audio_url="https://.../sarah_sample.wav")
diner = establish_environment("diner",
                              "1950s chrome diner, neon outside, half-empty booths",
                              time_of_day="midnight", lighting="cool fluorescents")
scene = scene.with_character(sarah).with_environment(diner)
```

### Phase 3: Render (caches; re-edits are cheap)

```python
from falaw import render_scene, save_scene

manifest = render_scene(scene)             # all beats + shots
save_scene(scene, "out/diner_v1.json")     # snapshot the IR alongside
```

### Phase 4: Direct (notes -> IR edits -> re-render)

```python
from falaw import apply_note_to_beat

# Pick the beat to direct.
beat = scene.beat("002-tom-...")
edited = apply_note_to_beat(beat, "He cracks on this line; tries to hide it.")
scene2 = scene.with_beat(edited)
manifest2 = render_scene(scene2)   # only the edited beat re-renders;
                                   # the rest are cache hits.
```

For cross-cutting notes like "tighten the pacing", use
`apply_note_to_scene(scene, note)`. For non-LLM edits, just construct the
new dataclass yourself --- everything is frozen so `dataclasses.replace`
works.

### Local stitching

`falaw.local` (requires ffmpeg) stitches the per-beat lipsynced clips into
a watchable scene:

```python
from falaw.local import concatenate_clips
concatenate_clips([m["url"] for m in manifest["beats"]],
                  output_path="out/scene.mp4", transition_s=0.2)
```

## Read the journal first

Before novel work, glance at recent entries --- past sessions may have
left notes that save you time:

```python
from falaw import journal
for e in journal.recent(20):
    print(e.kind, '-', e.text[:120])
```

## Leave a journal entry when something surprises you

```python
from falaw import journal
journal.issue("FLUX dev returned NSFW=True for a benign prompt",
              suggestion="Try guidance_scale=2.0", tags=("flux", "safety"))
journal.improvement("Pass beat.emotion as a TTS prompt arg for emotion-aware models",
                    tags=("backlog", "directorial"))
journal.note("schnell at quality='fast' returns 1024x1024 by default")
```

## Pick a model without memorizing IDs

```python
from falaw import list_models, pick_model
[m.id for m in list_models(category='image_to_video')]
pick_model(category='image_edit', quality_tier='ultra').id
```

## Tools

Every function below is a registered tool; bridges (MCP server, HTTP
service, UI) derive their surfaces from the same registry.
"""

_FOOTER_TEMPLATE = """
## Models known to falaw

The model registry lives at `falaw/data/models.json`. Refresh it from
`misc/docs/fal_ai_docs_full.md` when fal ships new models. Quick view:

```
{model_lines}
```

## When you can't find what you need

* Check `falaw/misc/docs/llms-full.txt` for a structured fal.ai overview.
* Check `falaw/misc/docs/fal_ai_docs_full.md` for the full corpus (~3MB).
* Drop into `falaw.call_fal(application, arguments)` for any model not
  yet wrapped --- this is the escape hatch. Then leave a `journal.improvement`
  asking for a proper tool wrapper.
"""


[docs] def build_skill_md(tools: Optional[Iterable[ToolSpec]] = None) -> str: """Render the SKILL.md content from the tool registry.""" tools = list(tools if tools is not None else list_tools()) parts = [_HEADER] for t in tools: parts.append(f"\n### `falaw.{t.name}`\n") parts.append(t.description.strip()) parts.append("") if t.examples: parts.append("Examples:") for ex in t.examples: parts.append(f" - `falaw.{t.name}(**{dict(ex)!r})`") parts.append("") model_lines = "\n".join( f" {m.category:20s} {m.quality_tier:10s} {m.id}" for m in sorted(list_models(), key=lambda x: (x.category, x.quality_tier)) ) parts.append(_FOOTER_TEMPLATE.format(model_lines=model_lines)) return "\n".join(parts)
[docs] def write_skill_files(target_dir: str) -> str: """Write SKILL.md (and a small references/ folder) under `target_dir`. Returns the path written. """ os.makedirs(os.path.join(target_dir, "references"), exist_ok=True) skill_path = os.path.join(target_dir, "SKILL.md") with open(skill_path, "w") as f: f.write(build_skill_md()) return skill_path