scribecast 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.
scribecast/mcp.py ADDED
@@ -0,0 +1,190 @@
1
+ """scribecast.mcp — a minimal stdio JSON-RPC 2.0 MCP server exposing scribecast to any agent.
2
+
3
+ Zero third-party dependency (no mcp-sdk required) so it installs with the base wheel. Implements
4
+ the MCP subset that matters: `initialize`, `tools/list`, `tools/call`. Speaks newline-delimited
5
+ JSON-RPC over stdin/stdout (the common stdio transport). Any MCP client (Claude, Hermes, Cursor)
6
+ or a plain worker can drive it.
7
+
8
+ Tools:
9
+ render_note_video(ref, source?, engine?, voice?, narrate?) -> {output, engine, duration, ...}
10
+ recommend_engine(ref, source?) -> {recommended, reason, scores}
11
+ list_sources() -> {source: {tool, available}}
12
+ list_voices() -> {voices: [...]}
13
+
14
+ Run: python -m scribecast.mcp (stdio)
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ from typing import Any
22
+
23
+ from . import __version__
24
+ from .logging_setup import get_logger
25
+
26
+ _log = get_logger(__name__)
27
+
28
+ PROTOCOL_VERSION = "2024-11-05"
29
+
30
+ TOOLS = [
31
+ {
32
+ "name": "render_note_video",
33
+ "description": "Resolve a note from a source and render it into a narrated video. "
34
+ "Returns the output mp4 path. Engine: user choice wins over recommendation.",
35
+ "inputSchema": {
36
+ "type": "object",
37
+ "properties": {
38
+ "ref": {"type": "string", "description": "source:locator or locator, e.g. file:notes.md, gbrain:my-slug"},
39
+ "source": {"type": "string", "description": "default source if ref has no prefix (file/gbrain/vault/ksum/stdin)"},
40
+ "engine": {"type": "string", "enum": ["hyperframes", "manim", "remotion"], "description": "force engine (wins over recommendation)"},
41
+ "voice": {"type": "string", "description": "edge-tts voice id"},
42
+ "out": {"type": "string", "description": "output mp4 path"},
43
+ "narrate": {"type": "boolean", "description": "narrate with edge-tts (default true)"},
44
+ },
45
+ "required": ["ref"],
46
+ },
47
+ },
48
+ {
49
+ "name": "recommend_engine",
50
+ "description": "Recommend a render engine from a note's content (advisory; user always decides).",
51
+ "inputSchema": {
52
+ "type": "object",
53
+ "properties": {
54
+ "ref": {"type": "string"},
55
+ "source": {"type": "string"},
56
+ },
57
+ "required": ["ref"],
58
+ },
59
+ },
60
+ {
61
+ "name": "list_sources",
62
+ "description": "List note sources and whether each one's tool is available on this machine.",
63
+ "inputSchema": {"type": "object", "properties": {}},
64
+ },
65
+ {
66
+ "name": "list_voices",
67
+ "description": "List common edge-tts voice ids for narration.",
68
+ "inputSchema": {"type": "object", "properties": {}},
69
+ },
70
+ ]
71
+
72
+
73
+ def _ok(result: Any) -> dict:
74
+ # MCP tools/call returns content blocks
75
+ return {"content": [{"type": "text", "text": json.dumps(result, indent=2)}], "isError": False}
76
+
77
+
78
+ def _err(msg: str) -> dict:
79
+ return {"content": [{"type": "text", "text": msg}], "isError": True}
80
+
81
+
82
+ def _call_tool(name: str, args: dict) -> dict:
83
+ """Dispatch a tool call, converting ANY exception (missing args, bad enum, engine error)
84
+ into a JSON-RPC tool error. Nothing here may propagate — an uncaught exception would kill
85
+ the server's stdin loop (P0). args is validated defensively (ref required for render/recommend)."""
86
+ _log.info("mcp: tools/call name=%s", name)
87
+ try:
88
+ return _dispatch_tool(name, args)
89
+ except Exception as exc: # noqa: BLE001 — deliberate catch-all; server must never crash
90
+ _log.error("mcp: tool %s raised %s: %s", name, type(exc).__name__, exc)
91
+ return _err(f"{type(exc).__name__}: {exc}")
92
+
93
+
94
+ def _dispatch_tool(name: str, args: dict) -> dict:
95
+ if name == "render_note_video":
96
+ from .pipeline import render_note, PipelineError
97
+ from .resolver import ResolverError
98
+ if not args.get("ref"):
99
+ return _err("render_note_video requires 'ref'")
100
+ try:
101
+ res = render_note(
102
+ args["ref"], source=args.get("source"), engine=args.get("engine"),
103
+ voice=args.get("voice"), out=args.get("out"),
104
+ narrate=args.get("narrate", True),
105
+ )
106
+ except (PipelineError, ResolverError) as exc:
107
+ return _err(f"{type(exc).__name__}: {exc}")
108
+ return _ok({"output": res.output, "engine": res.engine,
109
+ "recommended": res.recommendation.engine, "cards": res.cards,
110
+ "narrated": res.narrated, "duration": res.duration, "notes": res.notes})
111
+ if name == "recommend_engine":
112
+ from .resolver import resolve, ResolverError
113
+ from .selector import recommend_engine
114
+ if not args.get("ref"):
115
+ return _err("recommend_engine requires 'ref'")
116
+ try:
117
+ text = resolve(args["ref"], source=args.get("source", "file"))
118
+ except ResolverError as exc:
119
+ return _err(str(exc))
120
+ rec = recommend_engine(text)
121
+ return _ok({"recommended": rec.engine, "reason": rec.reason, "scores": rec.scores})
122
+ if name == "list_sources":
123
+ from .resolver import list_sources
124
+ return _ok(list_sources())
125
+ if name == "list_voices":
126
+ return _ok({"voices": ["en-US-AriaNeural", "en-US-GuyNeural", "en-GB-RyanNeural",
127
+ "en-IN-NeerjaNeural", "en-AU-NatashaNeural"]})
128
+ return _err(f"unknown tool: {name}")
129
+
130
+
131
+ def handle(req: dict) -> dict | None:
132
+ """Handle one JSON-RPC request. Returns a response dict, or None for notifications."""
133
+ method = req.get("method")
134
+ rid = req.get("id")
135
+ if method == "initialize":
136
+ return {"jsonrpc": "2.0", "id": rid, "result": {
137
+ "protocolVersion": PROTOCOL_VERSION,
138
+ "capabilities": {"tools": {}},
139
+ "serverInfo": {"name": "scribecast", "version": __version__},
140
+ }}
141
+ if method == "notifications/initialized":
142
+ return None
143
+ if method == "tools/list":
144
+ return {"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}}
145
+ if method == "tools/call":
146
+ params = req.get("params", {})
147
+ result = _call_tool(params.get("name", ""), params.get("arguments", {}) or {})
148
+ return {"jsonrpc": "2.0", "id": rid, "result": result}
149
+ if method == "ping":
150
+ return {"jsonrpc": "2.0", "id": rid, "result": {}}
151
+ # unknown method: notifications (no id) get no response per JSON-RPC; requests get an error
152
+ if rid is None:
153
+ return None
154
+ return {"jsonrpc": "2.0", "id": rid,
155
+ "error": {"code": -32601, "message": f"method not found: {method}"}}
156
+
157
+
158
+ def serve(stdin=None, stdout=None) -> None:
159
+ """Run the stdio JSON-RPC loop (newline-delimited). A handler exception is converted to a
160
+ JSON-RPC internal error (or swallowed for notifications) — the loop NEVER dies on bad input."""
161
+ stdin = stdin or sys.stdin
162
+ stdout = stdout or sys.stdout
163
+ os.environ["SCRIBECAST_MCP_ACTIVE"] = "1" # tells the resolver that stdin is the transport
164
+ # logs MUST go to stderr — stdout is the JSON-RPC transport
165
+ from .logging_setup import configure_logging
166
+ configure_logging(stream=sys.stderr)
167
+ _log.info("mcp: scribecast server started (v%s)", __version__)
168
+ for line in stdin:
169
+ line = line.strip()
170
+ if not line:
171
+ continue
172
+ try:
173
+ req = json.loads(line)
174
+ except json.JSONDecodeError: # pragma: no cover - malformed line skip
175
+ continue
176
+ try:
177
+ resp = handle(req)
178
+ except Exception as exc: # noqa: BLE001 — server must never crash on a single bad request
179
+ rid = req.get("id") if isinstance(req, dict) else None
180
+ resp = None if rid is None else {
181
+ "jsonrpc": "2.0", "id": rid,
182
+ "error": {"code": -32603, "message": f"internal error: {type(exc).__name__}: {exc}"},
183
+ }
184
+ if resp is not None:
185
+ stdout.write(json.dumps(resp) + "\n")
186
+ stdout.flush()
187
+
188
+
189
+ if __name__ == "__main__": # pragma: no cover
190
+ serve()
scribecast/pipeline.py ADDED
@@ -0,0 +1,264 @@
1
+ """scribecast.pipeline — the render_note core: resolve -> script -> render -> narrate -> mux -> mp4.
2
+
3
+ Imports vidkit (vidkit_core) for narration + mux + probe; never shells a vendored copy.
4
+
5
+ Default renderer ("cards"): split the script into titled text cards, render each as a PNG with
6
+ Pillow, assemble an image-sequence mp4 with ffmpeg, then narrate (edge-tts) and mux (the
7
+ bug-fixed vidkit mux — video duration authoritative). This default has only Pillow + ffmpeg as
8
+ needs and works without a browser or Manim. The heavy engines (manim/remotion) are opt-in and
9
+ delegate to the vidkit engine kits when their extras are installed.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import shutil
14
+ import subprocess
15
+ import textwrap
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from .config import Config, load_config
20
+ from .resolver import resolve, ResolverError
21
+ from .selector import choose, Recommendation
22
+ from .logging_setup import get_logger
23
+
24
+ _log = get_logger(__name__)
25
+
26
+
27
+ class PipelineError(RuntimeError):
28
+ """Raised when the render pipeline cannot complete (missing dep, render failure)."""
29
+
30
+
31
+ @dataclass
32
+ class RenderResult:
33
+ output: str # path to the final mp4
34
+ engine: str
35
+ recommendation: Recommendation
36
+ cards: int
37
+ narrated: bool
38
+ duration: float | None = None
39
+ notes: list[str] = field(default_factory=list)
40
+
41
+
42
+ # ---- script segmentation --------------------------------------------------
43
+ def script_from_text(text: str, max_cards: int = 12) -> list[tuple[str, str]]:
44
+ """Turn note text into [(title, body)] cards. Splits on markdown headings; falls back to
45
+ paragraph chunks. Keeps it bounded so a long note doesn't make a 200-card video."""
46
+ text = (text or "").strip()
47
+ if not text:
48
+ raise PipelineError("empty script: nothing to render")
49
+ cards: list[tuple[str, str]] = []
50
+ cur_title, cur_body = "", []
51
+ for line in text.splitlines():
52
+ s = line.strip()
53
+ if s.startswith("#"):
54
+ if cur_title or cur_body:
55
+ cards.append((cur_title, "\n".join(cur_body).strip()))
56
+ cur_title = s.lstrip("#").strip()
57
+ cur_body = []
58
+ else:
59
+ cur_body.append(line)
60
+ if cur_title or cur_body:
61
+ cards.append((cur_title, "\n".join(cur_body).strip()))
62
+
63
+ # fallback: no headings (single untitled card) -> chunk into paragraphs
64
+ if not cards or (len(cards) == 1 and not cards[0][0]):
65
+ paras = [p.strip() for p in text.split("\n\n") if p.strip()]
66
+ if len(paras) > 1:
67
+ cards = [("", p) for p in paras]
68
+ # bound
69
+ if len(cards) > max_cards:
70
+ cards = cards[:max_cards]
71
+ # ensure at least one card with content
72
+ cards = [(t, b) for (t, b) in cards if (t or b)]
73
+ if not cards:
74
+ cards = [("", text[:500])]
75
+ return cards
76
+
77
+
78
+ # ---- default "cards" renderer (Pillow -> ffmpeg image sequence) -----------
79
+ def _render_cards(cards: list[tuple[str, str]], out_path: str, *,
80
+ width: int = 1280, height: int = 720, seconds_per_card: float = 3.0,
81
+ fps: int = 24) -> str:
82
+ try:
83
+ from PIL import Image, ImageDraw, ImageFont
84
+ except ImportError as exc: # pragma: no cover - Pillow present in this env
85
+ raise PipelineError(
86
+ "the default 'cards' renderer needs Pillow. Install: pip install pillow "
87
+ "(or pip install vidkit[manim])."
88
+ ) from exc
89
+ if shutil.which("ffmpeg") is None: # pragma: no cover - ffmpeg present in this env
90
+ raise PipelineError("ffmpeg is required to assemble the video but is not on PATH.")
91
+
92
+ import tempfile
93
+ bg = (16, 18, 28)
94
+ fg = (236, 238, 245)
95
+ accent = (110, 123, 255)
96
+
97
+ def _font(size: int):
98
+ for cand in ("/System/Library/Fonts/Helvetica.ttc",
99
+ "/System/Library/Fonts/Supplemental/Arial.ttf",
100
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"):
101
+ if Path(cand).exists():
102
+ try:
103
+ return ImageFont.truetype(cand, size)
104
+ except Exception: # pragma: no cover - system font present
105
+ pass
106
+ return ImageFont.load_default() # pragma: no cover - only if no system font
107
+
108
+ title_f = _font(56)
109
+ body_f = _font(34)
110
+ seconds_per_card = max(0.3, float(seconds_per_card)) # clamp: negative/zero -> cryptic ffmpeg fail
111
+ frames_dir = Path(tempfile.mkdtemp(prefix="scribecast-frames-"))
112
+ try:
113
+ # One PNG per card (not N identical frames) + a concat demuxer with per-image durations:
114
+ # avoids the frame-explosion (was int(spc*fps) identical files per card).
115
+ concat_lines = []
116
+ for idx, (title, body) in enumerate(cards):
117
+ img = Image.new("RGB", (width, height), bg)
118
+ d = ImageDraw.Draw(img)
119
+ y = 90
120
+ if title:
121
+ d.rectangle([(80, y - 20), (88, y + 60)], fill=accent)
122
+ d.text((110, y), title[:80], font=title_f, fill=fg)
123
+ y += 110
124
+ for para in (body or "").split("\n"):
125
+ for wrapped in textwrap.wrap(para, width=58) or [""]:
126
+ if y > height - 80:
127
+ break
128
+ d.text((110, y), wrapped, font=body_f, fill=fg)
129
+ y += 46
130
+ y += 10
131
+ png = frames_dir / f"card_{idx:04d}.png"
132
+ img.save(png)
133
+ concat_lines.append(f"file '{png.name}'")
134
+ concat_lines.append(f"duration {seconds_per_card}")
135
+ # concat demuxer needs the last file repeated (its duration is otherwise dropped)
136
+ if concat_lines:
137
+ last_png = f"card_{len(cards) - 1:04d}.png"
138
+ concat_lines.append(f"file '{last_png}'")
139
+ (frames_dir / "concat.txt").write_text("\n".join(concat_lines))
140
+
141
+ Path(out_path).parent.mkdir(parents=True, exist_ok=True)
142
+ cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(frames_dir / "concat.txt"),
143
+ "-vf", f"fps={fps},format=yuv420p", "-c:v", "libx264", out_path]
144
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
145
+ finally:
146
+ shutil.rmtree(frames_dir, ignore_errors=True)
147
+ if proc.returncode != 0:
148
+ raise PipelineError(f"ffmpeg card assembly failed: {proc.stderr[-800:]}") # pragma: no cover
149
+ if not Path(out_path).exists() or Path(out_path).stat().st_size == 0:
150
+ raise PipelineError("card render produced no output") # pragma: no cover
151
+ return out_path
152
+
153
+
154
+ def _render_engine(engine: str, cards, out_path, **kw) -> str:
155
+ """Dispatch to the chosen engine. 'hyperframes'/'cards' use the built-in Pillow renderer
156
+ (always available). 'manim'/'remotion' require their extras + kits; raise a clear error if
157
+ not installed (never silently degrade to a different engine)."""
158
+ if engine in ("hyperframes", "cards"):
159
+ return _render_cards(cards, out_path, **kw)
160
+ if engine == "manim":
161
+ try:
162
+ import manim # noqa: F401
163
+ except ImportError as exc: # pragma: no cover - manim installed in this env
164
+ raise PipelineError(
165
+ "engine 'manim' needs the manim extra: pip install vidkit[manim]. "
166
+ "(Or choose --engine hyperframes for the dependency-light default.)"
167
+ ) from exc
168
+ # Real cinematic adapter: renders the cards through vqkit.CinematicScene (moving camera,
169
+ # animated reveal, MathTex). NOT a silent fallback to hyperframes.
170
+ from .engines.manim_adapter import render_cards_manim, ManimAdapterError
171
+ quality = kw.pop("quality", "m")
172
+ try:
173
+ return render_cards_manim(cards, out_path, quality=quality)
174
+ except ManimAdapterError as exc:
175
+ raise PipelineError(str(exc)) from exc
176
+ if engine == "remotion":
177
+ # Real adapter: drives the remotion_kit ScribecastCards composition. Raises a clear
178
+ # error if Node/kit/node_modules are missing — NEVER a silent fallback to another engine.
179
+ from .engines.remotion_adapter import render_cards_remotion, RemotionAdapterError
180
+ spc = kw.pop("seconds_per_card", 3.0)
181
+ try:
182
+ return render_cards_remotion(cards, out_path, per_card_seconds=spc)
183
+ except RemotionAdapterError as exc:
184
+ raise PipelineError(str(exc)) from exc
185
+ raise PipelineError(f"unknown engine '{engine}'")
186
+
187
+
188
+ def render_note(ref, *, source: str | None = None, engine: str | None = None,
189
+ voice: str | None = None, out: str | None = None,
190
+ narrate: bool = True, config: Config | None = None,
191
+ seconds_per_card: float = 3.0) -> RenderResult:
192
+ """Resolve a note reference, build a card script, render a video, narrate it, and mux.
193
+
194
+ ref: a `source:locator` / locator, or a list (merge).
195
+ Returns RenderResult with the output mp4 path. USER-WINS on engine (explicit > recommended).
196
+ """
197
+ cfg = config or load_config(overrides={"engine": engine, "voice": voice, "source": source})
198
+ src = source or cfg.source
199
+ notes: list[str] = []
200
+ _log.info("render_note: ref=%r source=%s narrate=%s", ref, src, narrate)
201
+
202
+ # 1) resolve
203
+ text = resolve(ref, source=src)
204
+ _log.debug("render_note: resolved %d chars", len(text))
205
+
206
+ # 2) engine selection — ALL precedence lives in selector.resolve_engine (single contract:
207
+ # explicit user engine > non-default config engine > heuristic recommendation). Pipeline
208
+ # does NO reconciliation of its own.
209
+ from .selector import resolve_engine
210
+ eng, rec = resolve_engine(text, user_choice=engine, config_engine=cfg.engine)
211
+ notes.append(f"engine={eng} (recommended={rec.engine}: {rec.reason})")
212
+ _log.info("render_note: engine=%s (recommended=%s, user=%s, config=%s)",
213
+ eng, rec.engine, engine, cfg.engine)
214
+
215
+ # 3) script
216
+ cards = script_from_text(text)
217
+ _log.info("render_note: %d card(s) from script", len(cards))
218
+
219
+ # 4) render — intermediates go in a UNIQUE temp dir (not fixed names in the shared out_dir),
220
+ # so concurrent render_note calls can't clobber each other's _silent.mp4 / _vo.mp3.
221
+ out_dir = Path(cfg.out_dir); out_dir.mkdir(parents=True, exist_ok=True)
222
+ out_path = out or str(out_dir / "scribecast.mp4")
223
+ Path(out_path).parent.mkdir(parents=True, exist_ok=True) # honor a user -o into a new dir
224
+ import tempfile as _tf
225
+ work = Path(_tf.mkdtemp(prefix="scribecast-render-"))
226
+ try:
227
+ silent = str(work / "_silent.mp4")
228
+ _log.info("render_note: rendering %d card(s) via %s", len(cards), eng)
229
+ _render_engine(eng, cards, silent, seconds_per_card=seconds_per_card)
230
+ _log.debug("render_note: engine render complete -> %s", silent)
231
+
232
+ # 5) narrate + mux (imports vidkit; video duration authoritative via the apad fix)
233
+ narrated = False
234
+ if narrate:
235
+ try:
236
+ from vidkit_core.audio import synth_voiceover, mux
237
+ script_text = ". ".join(f"{t}. {b}" if t else b for t, b in cards)[:4000]
238
+ vo = str(work / "_vo.mp3")
239
+ _log.info("render_note: narrating (voice=%s)", voice or cfg.voice)
240
+ synth_voiceover(script_text, vo, voice=voice or cfg.voice)
241
+ mux(silent, voiceover=vo, out_path=out_path)
242
+ narrated = True
243
+ _log.info("render_note: narrated + muxed -> %s", out_path)
244
+ except Exception as exc: # narration is best-effort; never silently fake it
245
+ notes.append(f"narration skipped: {type(exc).__name__}: {exc}")
246
+ _log.warning("render_note: narration skipped (%s: %s); using silent video",
247
+ type(exc).__name__, exc)
248
+ shutil.copy(silent, out_path)
249
+ else:
250
+ shutil.copy(silent, out_path)
251
+ finally:
252
+ shutil.rmtree(work, ignore_errors=True)
253
+
254
+ # 6) probe
255
+ duration = None
256
+ try:
257
+ from vidkit_core.render import ffprobe
258
+ duration = ffprobe(out_path).duration
259
+ except Exception:
260
+ pass
261
+ _log.info("render_note: done -> %s (%.2fs, narrated=%s)", out_path, duration or 0.0, narrated)
262
+
263
+ return RenderResult(output=out_path, engine=eng, recommendation=rec,
264
+ cards=len(cards), narrated=narrated, duration=duration, notes=notes)
scribecast/resolver.py ADDED
@@ -0,0 +1,171 @@
1
+ """scribecast.resolver — turn a note reference into clean text from any configured source.
2
+
3
+ A reference is `source:locator` (e.g. `file:notes.md`, `gbrain:two-brain-architecture`,
4
+ `vault:what did I decide about X`, `ksum:https://...`, `stdin:`) OR a bare locator with an
5
+ explicit `source=` argument. `merge` takes multiple refs and concatenates them with headers.
6
+
7
+ Source CLIs are called as subprocess for READING text only (note resolution), never as the
8
+ render engine. A missing CLI raises ResolverError naming the tool — never returns silent empty.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Iterable
18
+
19
+ from .logging_setup import get_logger
20
+
21
+ _log = get_logger(__name__)
22
+
23
+ # Known sources and the CLI each needs (None = no external tool).
24
+ SOURCES: dict[str, str | None] = {
25
+ "file": None,
26
+ "stdin": None,
27
+ "gbrain": "gbrain",
28
+ "vault": "praxvault-ask",
29
+ "ksum": "ksum",
30
+ "openmemory": "openmemory", # optional; config-gated, may not exist
31
+ "merge": None,
32
+ }
33
+
34
+
35
+ class ResolverError(RuntimeError):
36
+ """Raised when a note cannot be resolved (missing tool, missing file, empty result)."""
37
+
38
+
39
+ def _run(cmd: list[str], tool: str, timeout: int = 120) -> str:
40
+ if shutil.which(cmd[0]) is None and not Path(cmd[0]).exists():
41
+ _log.error("resolver: tool %r not on PATH", tool)
42
+ raise ResolverError(
43
+ f"source needs '{tool}' but it is not on PATH. Install/expose it, or pick another source."
44
+ )
45
+ _log.debug("resolver: running %s", " ".join(cmd))
46
+ try:
47
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
48
+ except subprocess.TimeoutExpired as exc:
49
+ _log.error("resolver: %s timed out after %ds", tool, timeout)
50
+ raise ResolverError(f"{tool} timed out after {timeout}s") from exc
51
+ if proc.returncode != 0:
52
+ _log.error("resolver: %s failed (exit %d)", tool, proc.returncode)
53
+ raise ResolverError(f"{tool} failed (exit {proc.returncode}): {proc.stderr.strip()[:500]}")
54
+ text = proc.stdout.strip()
55
+ if not text:
56
+ _log.error("resolver: %s returned empty output", tool)
57
+ raise ResolverError(f"{tool} returned empty output for the given reference")
58
+ _log.info("resolver: %s resolved %d chars", tool, len(text))
59
+ return text
60
+
61
+
62
+ def _resolve_file(locator: str) -> str:
63
+ p = Path(locator).expanduser()
64
+ if not p.is_file():
65
+ raise ResolverError(f"file not found: {p}")
66
+ text = p.read_text(encoding="utf-8", errors="replace").strip()
67
+ if not text:
68
+ raise ResolverError(f"file is empty: {p}")
69
+ return text
70
+
71
+
72
+ def _resolve_stdin(_locator: str) -> str:
73
+ # Guard: if stdin is a TTY (no pipe) it would block forever waiting for EOF; and under the
74
+ # MCP server stdin IS the JSON-RPC transport (reading it would hang the server). Refuse both.
75
+ if sys.stdin is None or sys.stdin.isatty():
76
+ raise ResolverError("stdin source selected but nothing is piped (or stdin is a terminal)")
77
+ if os.environ.get("SCRIBECAST_MCP_ACTIVE") == "1":
78
+ raise ResolverError("stdin source is not available inside the MCP server "
79
+ "(stdin is the JSON-RPC transport); use file/gbrain/vault/ksum")
80
+ data = sys.stdin.read().strip()
81
+ if not data:
82
+ raise ResolverError("stdin source selected but no piped input was received")
83
+ return data
84
+
85
+
86
+ def _check_locator(locator: str, tool: str) -> None:
87
+ """Reject locators that would be mis-parsed as CLI options (argv option injection)."""
88
+ if locator.startswith("-"):
89
+ raise ResolverError(
90
+ f"{tool}: locator may not start with '-' (would be parsed as an option): {locator!r}"
91
+ )
92
+
93
+
94
+ def _resolve_gbrain(locator: str) -> str:
95
+ _check_locator(locator, "gbrain")
96
+ return _run(["gbrain", "get", locator], "gbrain")
97
+
98
+
99
+ def _resolve_vault(locator: str) -> str:
100
+ # praxvault-ask <query> — plain-text answer used as the note body.
101
+ _check_locator(locator, "praxvault-ask")
102
+ return _run(["praxvault-ask", locator], "praxvault-ask")
103
+
104
+
105
+ def _resolve_ksum(locator: str) -> str:
106
+ _check_locator(locator, "ksum")
107
+ return _run(["ksum", locator, "--no-save"], "ksum")
108
+
109
+
110
+ def _resolve_openmemory(locator: str) -> str:
111
+ _check_locator(locator, "openmemory")
112
+ return _run(["openmemory", "get", locator], "openmemory")
113
+
114
+
115
+ _DISPATCH = {
116
+ "file": _resolve_file,
117
+ "stdin": _resolve_stdin,
118
+ "gbrain": _resolve_gbrain,
119
+ "vault": _resolve_vault,
120
+ "ksum": _resolve_ksum,
121
+ "openmemory": _resolve_openmemory,
122
+ }
123
+
124
+
125
+ def _split_ref(ref: str, default_source: str) -> tuple[str, str]:
126
+ """Split `source:locator`. If no known `source:` prefix, use default_source and whole ref."""
127
+ if ":" in ref:
128
+ head, _, tail = ref.partition(":")
129
+ if head in SOURCES:
130
+ return head, tail
131
+ return default_source, ref
132
+
133
+
134
+ def resolve(ref: str | Iterable[str], source: str = "file") -> str:
135
+ """Resolve a reference (or several, for merge) to text.
136
+
137
+ ref: a single `source:locator` / locator, OR an iterable of them (merge).
138
+ source: default source when a ref carries no `source:` prefix.
139
+ """
140
+ # merge: iterable of refs
141
+ if not isinstance(ref, str):
142
+ refs = list(ref)
143
+ if not refs:
144
+ raise ResolverError("merge: no references given")
145
+ parts = []
146
+ for r in refs:
147
+ src, loc = _split_ref(r, source)
148
+ text = _resolve_one(src, loc)
149
+ parts.append(f"## Source: {src}:{loc}\n\n{text}")
150
+ return "\n\n---\n\n".join(parts)
151
+
152
+ src, loc = _split_ref(ref, source)
153
+ if src == "merge":
154
+ raise ResolverError("merge needs multiple references; pass a list to resolve()")
155
+ return _resolve_one(src, loc)
156
+
157
+
158
+ def _resolve_one(src: str, locator: str) -> str:
159
+ fn = _DISPATCH.get(src)
160
+ if fn is None:
161
+ raise ResolverError(f"unknown source '{src}'. Known: {', '.join(sorted(_DISPATCH))}")
162
+ return fn(locator)
163
+
164
+
165
+ def list_sources() -> dict[str, dict]:
166
+ """Describe each source + whether its tool is currently available."""
167
+ out = {}
168
+ for name, tool in SOURCES.items():
169
+ available = True if tool is None else (shutil.which(tool) is not None)
170
+ out[name] = {"tool": tool, "available": available}
171
+ return out