sepia-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sepia/director.py ADDED
@@ -0,0 +1,162 @@
1
+ """Ask Claude to turn a short request into a prompt for the local image model."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from functools import cache
6
+ from importlib.resources import files
7
+ from string import Template
8
+ from typing import Any
9
+
10
+ import anthropic
11
+ from anthropic.types.beta import BetaOutputConfigParam
12
+ from pydantic import BaseModel, Field, ValidationError
13
+
14
+ from sepia.config import Effort
15
+ from sepia.scene import Scene
16
+
17
+ MAX_TOKENS = 8_000
18
+ """Room for thinking plus a short JSON reply."""
19
+ MAX_RETRIES = 3
20
+ """The SDK retries connection errors, 429 and 5xx with exponential backoff."""
21
+ FALLBACK_BETA = "server-side-fallback-2026-07-01"
22
+ """Lets the API re-run a declined request on Anthropic's recommended fallback model."""
23
+ FALLBACK_MODELS = frozenset(
24
+ {"claude-fable-5-1", "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5-5"}
25
+ )
26
+ """Models that accept `fallbacks: "default"`; Haiku has no server-side fallback."""
27
+
28
+ REPLY_SCHEMA: dict[str, Any] = {
29
+ "type": "object",
30
+ "properties": {
31
+ "message": {
32
+ "type": "string",
33
+ "description": "One short sentence in the language of the latest request.",
34
+ },
35
+ "prompt": {"type": "string", "description": "The English image prompt."},
36
+ "edit": {
37
+ "type": "boolean",
38
+ "description": "True to repaint the previous picture, false for a new one.",
39
+ },
40
+ },
41
+ "required": ["message", "prompt", "edit"],
42
+ "additionalProperties": False,
43
+ }
44
+
45
+
46
+ class Failure(StrEnum):
47
+ """Why a request failed, so the prompt can explain it in the user's language."""
48
+
49
+ AUTH = "auth"
50
+ RATE_LIMIT = "rate_limit"
51
+ SERVER = "server"
52
+ CONNECTION = "connection"
53
+ REJECTED = "rejected"
54
+ REFUSED = "refused"
55
+ TRUNCATED = "truncated"
56
+ INVALID = "invalid"
57
+
58
+
59
+ class DirectorError(Exception):
60
+ """The request failed. The scene is untouched, so the user can simply retry."""
61
+
62
+ def __init__(self, failure: Failure) -> None:
63
+ super().__init__(failure.value)
64
+ self.failure = failure
65
+
66
+
67
+ class _Reply(BaseModel):
68
+ message: str = Field(min_length=1)
69
+ prompt: str = Field(min_length=1)
70
+ edit: bool
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Direction:
75
+ message: str
76
+ prompt: str
77
+ edit: bool
78
+ input_tokens: int
79
+ output_tokens: int
80
+
81
+
82
+ def create_client(api_key: str) -> anthropic.Anthropic:
83
+ return anthropic.Anthropic(api_key=api_key, max_retries=MAX_RETRIES)
84
+
85
+
86
+ class Director:
87
+ def __init__(self, client: anthropic.Anthropic, model: str, effort: Effort) -> None:
88
+ self._client = client
89
+ self._model = model
90
+ self._effort = effort
91
+
92
+ def direct(self, request: str, scene: Scene) -> Direction:
93
+ """Turn `request`, in the context of `scene`, into an image prompt."""
94
+ output_config: BetaOutputConfigParam = {
95
+ "effort": self._effort,
96
+ "format": {"type": "json_schema", "schema": REPLY_SCHEMA},
97
+ }
98
+ fallback = self._model in FALLBACK_MODELS
99
+ try:
100
+ with self._client.beta.messages.stream(
101
+ model=self._model,
102
+ max_tokens=MAX_TOKENS,
103
+ betas=[FALLBACK_BETA] if fallback else anthropic.omit,
104
+ fallbacks="default" if fallback else anthropic.omit,
105
+ system=system_prompt(),
106
+ output_config=output_config,
107
+ messages=[{"role": "user", "content": build_request(request, scene)}],
108
+ ) as stream:
109
+ message = stream.get_final_message()
110
+ except (anthropic.AuthenticationError, anthropic.PermissionDeniedError) as exc:
111
+ raise DirectorError(Failure.AUTH) from exc
112
+ except anthropic.RateLimitError as exc:
113
+ raise DirectorError(Failure.RATE_LIMIT) from exc
114
+ except anthropic.BadRequestError as exc:
115
+ raise DirectorError(Failure.REJECTED) from exc
116
+ except anthropic.APIStatusError as exc:
117
+ raise DirectorError(Failure.SERVER) from exc
118
+ except anthropic.APIConnectionError as exc:
119
+ raise DirectorError(Failure.CONNECTION) from exc
120
+
121
+ if message.stop_reason == "refusal":
122
+ raise DirectorError(Failure.REFUSED)
123
+ if message.stop_reason == "max_tokens":
124
+ raise DirectorError(Failure.TRUNCATED)
125
+ text = "".join(block.text for block in message.content if block.type == "text")
126
+ try:
127
+ reply = _Reply.model_validate_json(text)
128
+ except ValidationError as exc:
129
+ raise DirectorError(Failure.INVALID) from exc
130
+ return Direction(
131
+ message=reply.message.strip(),
132
+ prompt=reply.prompt.strip(),
133
+ edit=reply.edit and not scene.is_empty,
134
+ input_tokens=message.usage.input_tokens,
135
+ output_tokens=message.usage.output_tokens,
136
+ )
137
+
138
+
139
+ @cache
140
+ def system_prompt() -> str:
141
+ return _prompt("director.md")
142
+
143
+
144
+ def build_request(request: str, scene: Scene) -> str:
145
+ if scene.is_empty:
146
+ history = (
147
+ "<previous_requests>none: this is the first picture</previous_requests>"
148
+ )
149
+ else:
150
+ earlier = "\n".join(f"{i}. {text}" for i, text in enumerate(scene.requests, 1))
151
+ history = (
152
+ f"<previous_requests>\n{earlier}\n</previous_requests>\n"
153
+ f"<previous_prompt>{scene.prompts[-1]}</previous_prompt>"
154
+ )
155
+ return Template(_prompt("request.md")).substitute(
156
+ history=history, request=request.strip()
157
+ )
158
+
159
+
160
+ @cache
161
+ def _prompt(name: str) -> str:
162
+ return files("sepia").joinpath("prompts", name).read_text(encoding="utf-8")
sepia/ipc.py ADDED
@@ -0,0 +1,169 @@
1
+ """Authenticated JSON messages between the prompt window and the viewer window.
2
+
3
+ The prompt listens on a random loopback port; the viewer connects with a one-time key
4
+ read from a session file only the current user can read. `multiprocessing.connection`
5
+ does the HMAC handshake. Messages travel as JSON bytes and are never unpickled, so a
6
+ peer can send data but never code.
7
+ """
8
+
9
+ import contextlib
10
+ import json
11
+ import secrets
12
+ import stat
13
+ import threading
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from multiprocessing import AuthenticationError
17
+ from multiprocessing.connection import Client, Listener
18
+ from pathlib import Path
19
+ from typing import Any, Protocol
20
+
21
+ HOST = "127.0.0.1"
22
+
23
+ Message = dict[str, Any]
24
+
25
+
26
+ class _Connection(Protocol):
27
+ """The part of `multiprocessing.connection.Connection` sepia uses."""
28
+
29
+ def send_bytes(self, buf: bytes) -> None: ...
30
+ def recv_bytes(self) -> bytes: ...
31
+ def poll(self, timeout: float | None = ...) -> bool: ...
32
+ def close(self) -> None: ...
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Session:
37
+ """Where the viewer finds the prompt and how it proves who it is."""
38
+
39
+ port: int
40
+ authkey: bytes
41
+
42
+ def save(self, path: Path) -> None:
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ data = {"port": self.port, "authkey": self.authkey.hex()}
45
+ path.write_text(json.dumps(data), encoding="utf-8")
46
+ with contextlib.suppress(OSError): # Windows ignores POSIX modes
47
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
48
+
49
+ @classmethod
50
+ def load(cls, path: Path) -> "Session":
51
+ data = json.loads(path.read_text(encoding="utf-8"))
52
+ return cls(port=int(data["port"]), authkey=bytes.fromhex(data["authkey"]))
53
+
54
+
55
+ class Channel:
56
+ """One end of an open link."""
57
+
58
+ def __init__(self, connection: _Connection) -> None:
59
+ self._connection = connection
60
+ self._send_lock = threading.Lock()
61
+
62
+ def send(self, message: Message) -> None:
63
+ """Send `message`; raises `OSError` or `EOFError` once the peer is gone."""
64
+ data = json.dumps(message).encode("utf-8")
65
+ with self._send_lock:
66
+ self._connection.send_bytes(data)
67
+
68
+ def poll(self, timeout: float = 0.0) -> bool:
69
+ return self._connection.poll(timeout)
70
+
71
+ def receive(self) -> Message:
72
+ """Next message; raises `EOFError` or `OSError` once the peer is gone."""
73
+ message = json.loads(self._connection.recv_bytes().decode("utf-8"))
74
+ if not isinstance(message, dict):
75
+ raise ValueError("expected a JSON object")
76
+ return message
77
+
78
+ def close(self) -> None:
79
+ with contextlib.suppress(OSError):
80
+ self._connection.close()
81
+
82
+
83
+ def connect(session: Session) -> Channel:
84
+ """Viewer side: open the link described by `session`."""
85
+ return Channel(Client((HOST, session.port), authkey=session.authkey))
86
+
87
+
88
+ class Host:
89
+ """Prompt side: waits for one viewer at a time and relays what it reports.
90
+
91
+ `on_message` runs on a background thread for every message the viewer sends.
92
+ """
93
+
94
+ def __init__(self, on_message: Callable[[Message], None]) -> None:
95
+ self._on_message = on_message
96
+ authkey = secrets.token_bytes(32)
97
+ self._listener = Listener((HOST, 0), authkey=authkey)
98
+ self.session = Session(port=int(self._listener.address[1]), authkey=authkey)
99
+ self._channel: Channel | None = None
100
+ self._connected = threading.Event()
101
+ self._lock = threading.Lock()
102
+ self._accepting = False
103
+
104
+ @property
105
+ def connected(self) -> bool:
106
+ return self._connected.is_set()
107
+
108
+ def accept_in_background(self) -> None:
109
+ """Start waiting for a viewer unless one is connected or already awaited."""
110
+ with self._lock:
111
+ if self._accepting or self.connected:
112
+ return
113
+ self._accepting = True
114
+ threading.Thread(target=self._accept, name="sepia-accept", daemon=True).start()
115
+
116
+ def wait_connected(self, timeout: float) -> bool:
117
+ return self._connected.wait(timeout)
118
+
119
+ def send(self, message: Message) -> bool:
120
+ """Deliver `message` to the viewer; False when no viewer is connected."""
121
+ channel = self._channel
122
+ if channel is None:
123
+ return False
124
+ try:
125
+ channel.send(message)
126
+ except (OSError, EOFError):
127
+ self._drop(channel)
128
+ return False
129
+ return True
130
+
131
+ def close(self) -> None:
132
+ if self._channel is not None:
133
+ self._channel.close()
134
+ with contextlib.suppress(OSError):
135
+ self._listener.close()
136
+
137
+ def _accept(self) -> None:
138
+ while True:
139
+ try:
140
+ channel = Channel(self._listener.accept())
141
+ break
142
+ except (AuthenticationError, EOFError):
143
+ continue # a peer without the key; keep waiting for the real viewer
144
+ except OSError: # the listener was closed
145
+ with self._lock:
146
+ self._accepting = False
147
+ return
148
+ with self._lock:
149
+ self._channel = channel
150
+ self._accepting = False
151
+ self._connected.set()
152
+ self._read(channel)
153
+
154
+ def _read(self, channel: Channel) -> None:
155
+ while True:
156
+ try:
157
+ message = channel.receive()
158
+ except (OSError, EOFError, ValueError):
159
+ self._drop(channel)
160
+ return
161
+ self._on_message(message)
162
+
163
+ def _drop(self, channel: Channel) -> None:
164
+ with self._lock:
165
+ if self._channel is not channel:
166
+ return
167
+ self._channel = None
168
+ self._connected.clear()
169
+ channel.close()
sepia/launcher.py ADDED
@@ -0,0 +1,141 @@
1
+ """Open the picture window in a new terminal window."""
2
+
3
+ import contextlib
4
+ import json
5
+ import os
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ VIEWER_COLS = 110
13
+ VIEWER_ROWS = 36
14
+ """Window size in cells of the default font; the small profile font fits about
15
+ 1.8x as many in each direction."""
16
+ VIEWER_FONT_SIZE = 6
17
+ """Points. More, smaller cells give the picture more detail."""
18
+ LAUNCH_TIMEOUT_SECONDS = 15
19
+ PROFILE_NAME = "sepia"
20
+ PROFILE_GUID = "{6f1c2b9e-3a57-4d0e-9c84-5e2f7a1d3b60}"
21
+
22
+
23
+ class LaunchError(RuntimeError):
24
+ """The picture window could not be opened."""
25
+
26
+
27
+ def viewer_command(session_file: Path) -> list[str]:
28
+ """The command the new window runs; same interpreter, so it works in any venv."""
29
+ return [sys.executable, "-m", "sepia", "--viewer", str(session_file)]
30
+
31
+
32
+ def open_viewer(session_file: Path) -> None:
33
+ command = viewer_command(session_file)
34
+ try:
35
+ if sys.platform == "win32":
36
+ _open_windows(command)
37
+ elif sys.platform == "darwin":
38
+ _open_macos(command)
39
+ else:
40
+ raise LaunchError("the picture window opens on Windows and macOS only")
41
+ except (OSError, subprocess.SubprocessError) as exc:
42
+ raise LaunchError(f"could not open the picture window: {exc}") from exc
43
+
44
+
45
+ def windows_terminal_command(wt: str, command: list[str]) -> list[str]:
46
+ """A new Windows Terminal window in focus mode: no tab bar and no title bar."""
47
+ return [
48
+ wt,
49
+ "--window",
50
+ "new",
51
+ "--focus",
52
+ "--size",
53
+ f"{VIEWER_COLS},{VIEWER_ROWS}",
54
+ "new-tab",
55
+ "--profile",
56
+ PROFILE_NAME,
57
+ "--title",
58
+ "sepia",
59
+ "--suppressApplicationTitle",
60
+ "--",
61
+ *command,
62
+ ]
63
+
64
+
65
+ def macos_script(command: list[str]) -> str:
66
+ """AppleScript that runs `command` in a new Terminal.app window.
67
+
68
+ `exec` replaces the shell, so the window closes when the viewer exits.
69
+ """
70
+ shell_line = "exec " + shlex.join(command)
71
+ quoted = shell_line.replace("\\", "\\\\").replace('"', '\\"')
72
+ return f'tell application "Terminal"\n do script "{quoted}"\n activate\nend tell'
73
+
74
+
75
+ def windows_profile() -> dict[str, object]:
76
+ """A Windows Terminal profile for the picture: tiny font, black, no padding."""
77
+ return {
78
+ "guid": PROFILE_GUID,
79
+ "name": PROFILE_NAME,
80
+ "commandline": "cmd.exe",
81
+ "font": {"face": "Cascadia Mono", "size": VIEWER_FONT_SIZE},
82
+ "padding": "0",
83
+ "background": "#000000",
84
+ "scrollbarState": "hidden",
85
+ "closeOnExit": "always",
86
+ "bellStyle": "none",
87
+ }
88
+
89
+
90
+ def install_windows_profile(local_app_data: Path | None = None) -> Path | None:
91
+ """Write sepia's profile as a Windows Terminal fragment; None when not possible.
92
+
93
+ Fragments live in their own folder, so the user's settings.json is never touched,
94
+ and deleting the folder removes the profile again.
95
+ """
96
+ base = local_app_data or Path(os.environ.get("LOCALAPPDATA", ""))
97
+ if not base.is_dir():
98
+ return None
99
+ target = (
100
+ base / "Microsoft" / "Windows Terminal" / "Fragments" / "sepia" / "sepia.json"
101
+ )
102
+ content = json.dumps({"profiles": [windows_profile()]}, indent=2)
103
+ with contextlib.suppress(OSError):
104
+ if not target.exists() or target.read_text(encoding="utf-8") != content:
105
+ target.parent.mkdir(parents=True, exist_ok=True)
106
+ target.write_text(content, encoding="utf-8")
107
+ return target
108
+ return None
109
+
110
+
111
+ def find_windows_terminal(local_app_data: Path | None = None) -> str | None:
112
+ """wt.exe from PATH, or from its app alias folder, which PATH may not include."""
113
+ found = shutil.which("wt")
114
+ if found is not None:
115
+ return found
116
+ base = local_app_data or Path(os.environ.get("LOCALAPPDATA", ""))
117
+ alias = base / "Microsoft" / "WindowsApps" / "wt.exe"
118
+ return str(alias) if alias.is_file() else None
119
+
120
+
121
+ def _open_windows(command: list[str]) -> None:
122
+ wt = find_windows_terminal()
123
+ if wt is not None:
124
+ install_windows_profile()
125
+ subprocess.run(
126
+ windows_terminal_command(wt, command),
127
+ check=True,
128
+ timeout=LAUNCH_TIMEOUT_SECONDS,
129
+ )
130
+ return
131
+ if sys.platform == "win32": # narrows the type for the console flag
132
+ subprocess.Popen(command, creationflags=subprocess.CREATE_NEW_CONSOLE)
133
+
134
+
135
+ def _open_macos(command: list[str]) -> None:
136
+ subprocess.run(
137
+ ["osascript", "-e", macos_script(command)],
138
+ check=True,
139
+ capture_output=True,
140
+ timeout=LAUNCH_TIMEOUT_SECONDS,
141
+ )