From b2dd6a6751d99e2145601a8ef4752da448f0e0db Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sat, 8 Aug 2026 16:23:14 +0400 Subject: [PATCH 1/7] Consolidate AI provider selection and cache CLI discovery --- backend/main.py | 31 +- backend/services/ai_cli.py | 477 +++++++++++ backend/services/ai_provider.py | 352 ++++++++ backend/services/claude_suggest.py | 806 ++++-------------- backend/services/content_generator.py | 161 ++-- backend/services/env_settings.py | 4 +- .../integrations/youtube/learnings.py | 24 +- backend/services/thumbnail_ai.py | 43 +- tests/test_ai_fallback.py | 68 +- tests/test_find_moments.py | 16 +- 10 files changed, 1159 insertions(+), 823 deletions(-) create mode 100644 backend/services/ai_cli.py create mode 100644 backend/services/ai_provider.py diff --git a/backend/main.py b/backend/main.py index bb52b4e..32a0cb1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -676,8 +676,8 @@ def _signal_profiles_for_suggest( def handle_suggest_clips(task_id: str, params: dict): """AI-powered clip suggestion using Claude/Codex and PodStack knowledge base.""" + from services import ai_provider from services.claude_suggest import ( - _find_ai_cli_candidates, select_clips_with_signal_scores, suggest_initial_with_claude, ) @@ -690,13 +690,15 @@ def handle_suggest_clips(task_id: str, params: dict): emit_result(task_id, "error", error="segments is required") return - if not _find_ai_cli_candidates(): + # Gate on the provider chain, not on a local binary: a signed-in Pro user + # has AI available without installing anything. + if not ai_provider.available(): emit_result( task_id, "error", error=( - "No AI CLI available (install Claude Code or Codex). " - "If already installed, set the path in Config → AI CLI or PODCLI_CLAUDE_PATH." + "No AI available. Sign in with `podcli login`, install Claude Code " + "or Codex, or set ANTHROPIC_API_KEY." ), ) return @@ -740,11 +742,19 @@ def handle_manage_env(task_id: str, params: dict): def handle_ai_cli_status(task_id: str, params: dict): - from services.claude_suggest import get_ai_cli_status + from services.ai_cli import get_ai_cli_status emit_result(task_id, "success", data=get_ai_cli_status()) +def handle_ai_provider_status(task_id: str, params: dict): + """Everything podcli can use for AI, not just local binaries — so the studio + can tell "nothing installed" apart from "signed in, nothing needed".""" + from services import ai_provider + + emit_result(task_id, "success", data=ai_provider.status()) + + def handle_find_moment(task_id: str, params: dict): """Locate user-pasted/described moments in the transcript via the AI CLI.""" from services.claude_suggest import find_moments_from_text @@ -774,7 +784,7 @@ def handle_find_moment(task_id: str, params: dict): def handle_generate_content(task_id: str, params: dict): """Generate titles, descriptions, tags for a clip using PodStack knowledge base.""" from services.content_generator import generate_clip_content - from services.claude_suggest import _find_ai_cli_candidates + from services import ai_provider clip = params.get("clip", {}) transcript_segments = params.get("transcript_segments", []) @@ -783,13 +793,15 @@ def handle_generate_content(task_id: str, params: dict): emit_result(task_id, "error", error="clip is required") return - if not _find_ai_cli_candidates(): + # Gate on the provider chain, not on a local binary: a signed-in Pro user + # has AI available without installing anything. + if not ai_provider.available(): emit_result( task_id, "error", error=( - "No AI CLI available (install Claude Code or Codex). " - "If already installed, set the path in Config → AI CLI or PODCLI_CLAUDE_PATH." + "No AI available. Sign in with `podcli login`, install Claude Code " + "or Codex, or set ANTHROPIC_API_KEY." ), ) return @@ -927,6 +939,7 @@ def handle_run_integration_tool(task_id: str, params: dict): "find_moment": handle_find_moment, "manage_env": handle_manage_env, "ai_cli_status": handle_ai_cli_status, + "ai_provider_status": handle_ai_provider_status, "generate_content": handle_generate_content, "generate_custom": handle_generate_custom, "manage_integrations": handle_manage_integrations, diff --git a/backend/services/ai_cli.py b/backend/services/ai_cli.py new file mode 100644 index 0000000..0393e9a --- /dev/null +++ b/backend/services/ai_cli.py @@ -0,0 +1,477 @@ +"""Discovery and invocation of the user's local AI CLI (Claude Code or Codex). + +Finding the binary is genuinely hard: npm prefixes, version managers, shell +aliases and platform extensions all move it. That search lives here so the +provider layer above can treat "run this prompt" as one call. +""" + +import os +import subprocess +import sys +from functools import lru_cache +from typing import Optional + +def _cli_name_exts() -> list[str]: + if sys.platform == "win32": + return ["", ".cmd", ".exe", ".bat"] + return [""] + + +def _resolve_cli_path(path: str) -> Optional[str]: + for ext in _cli_name_exts(): + candidate = path + ext + if os.path.isfile(candidate): + return candidate + return None + + +def _dedupe_dirs(dirs: list[str]) -> list[str]: + seen: set[str] = set() + ordered: list[str] = [] + for directory in dirs: + if not directory: + continue + directory = os.path.expanduser(directory) + if directory in seen: + continue + seen.add(directory) + if os.path.isdir(directory): + ordered.append(directory) + return ordered + + +def _npmrc_prefix_dirs() -> list[str]: + dirs: list[str] = [] + npmrc_paths = [os.path.join(os.path.expanduser("~"), ".npmrc")] + try: + from services.env_settings import _env_path + npmrc_paths.append(os.path.join(os.path.dirname(_env_path()), ".npmrc")) + except Exception: + pass + for npmrc in npmrc_paths: + if not os.path.isfile(npmrc): + continue + try: + with open(npmrc, encoding="utf-8") as f: + for line in f: + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith(";"): + continue + if stripped.startswith("prefix="): + prefix = stripped.split("=", 1)[1].strip() + if prefix: + dirs.append(prefix if sys.platform == "win32" else os.path.join(prefix, "bin")) + except Exception: + pass + return dirs + + +def _package_manager_bin_dirs() -> list[str]: + dirs: list[str] = [] + npm_cmds = [ + (["npm", "config", "get", "prefix"], "prefix"), + (["npm", "root", "-g"], "root"), + ] + for args, kind in npm_cmds: + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=2) + except Exception: + continue + if result.returncode != 0: + continue + raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" + if not raw: + continue + if kind == "prefix": + dirs.append(raw if sys.platform == "win32" else os.path.join(raw, "bin")) + elif kind == "root": + dirs.append(os.path.join(raw, ".bin")) + else: + dirs.append(raw) + + for args, kind in ( + (["pnpm", "config", "get", "global-bin-dir"], "bin"), + (["pnpm", "bin", "-g"], "bin"), + (["yarn", "global", "bin"], "bin"), + ): + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=2) + except Exception: + continue + if result.returncode != 0: + continue + raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" + if raw: + dirs.append(raw) + + return dirs + + +def _version_manager_bin_dirs() -> list[str]: + home = os.path.expanduser("~") + dirs = [ + os.path.join(home, "bin"), + os.path.join(home, ".asdf", "shims"), + os.path.join(home, ".local", "share", "mise", "shims"), + os.path.join(home, ".local", "share", "rtx", "shims"), + os.path.join(home, ".bun", "bin"), + os.path.join(home, ".cargo", "bin"), + os.path.join(home, "go", "bin"), + os.path.join(home, ".local", "share", "pnpm"), + os.path.join(home, ".claude", "bin"), + ] + + nvm_dir = os.environ.get("NVM_DIR") or os.path.join(home, ".nvm") + try: + import glob + dirs.extend(sorted(glob.glob(os.path.join(nvm_dir, "versions", "node", "*", "bin")), reverse=True)) + dirs.extend(glob.glob(os.path.join(home, ".fnm", "node-versions", "*", "installation", "bin"))) + dirs.extend(glob.glob(os.path.join(home, ".local", "share", "fnm", "node-versions", "*", "installation", "bin"))) + except Exception: + pass + + fnm_bin = os.path.join(home, ".local", "share", "fnm", "current", "bin") + dirs.append(fnm_bin) + dirs.append(os.path.join(home, ".volta", "bin")) + + if sys.platform == "win32": + for env_key in ("APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"): + base = os.environ.get(env_key) + if not base: + continue + dirs.extend([ + os.path.join(base, "npm"), + os.path.join(base, "Programs", "nodejs"), + os.path.join(base, "Microsoft", "WinGet", "Links"), + ]) + dirs.append(os.path.join(home, "scoop", "shims")) + dirs.append(os.path.join(os.environ.get("ProgramData", ""), "npm")) + else: + dirs.extend([ + "/usr/bin", + "/bin", + "/usr/local/bin", + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/snap/bin", + "/var/lib/snapd/snap/bin", + ]) + + npm_prefix = ( + os.environ.get("NPM_CONFIG_PREFIX") + or os.environ.get("npm_config_prefix") + or "" + ).strip() + if npm_prefix: + dirs.append(os.path.join(os.path.expanduser(npm_prefix), "bin")) + + return dirs + + +def _static_lookup_dirs() -> list[str]: + home = os.path.expanduser("~") + dirs = [ + os.path.join(home, ".local", "bin"), + os.path.join(home, ".claude", "local", "bin"), + os.path.join(home, ".claude", "local", "node_modules", ".bin"), + os.path.join(home, ".npm-global", "bin"), + ] + if sys.platform == "win32": + appdata = os.environ.get("APPDATA") + if appdata: + dirs.append(os.path.join(appdata, "npm")) + dirs.append(os.path.join(home, ".local", "bin")) + return dirs + + +@lru_cache(maxsize=8) +def _lookup_dirs(_key: tuple) -> list[str]: + return _dedupe_dirs( + _static_lookup_dirs() + + _version_manager_bin_dirs() + + _npmrc_prefix_dirs() + + _package_manager_bin_dirs() + ) + + +def _all_lookup_dirs() -> list[str]: + return list(_lookup_dirs(_discovery_key())) + + +def _path_lookup_dirs() -> list[str]: + return _all_lookup_dirs() + + +def _npm_global_bin_dirs() -> list[str]: + return _package_manager_bin_dirs() + + +def _parse_shell_lookup_line(line: str) -> Optional[str]: + candidate = line.strip().strip('"') + if not candidate: + return None + if " is " in candidate: + candidate = candidate.split(" is ", 1)[1].strip() + if candidate.startswith("(") and candidate.endswith(")"): + candidate = candidate[1:-1].strip() + return _resolve_cli_path(candidate) or (candidate if os.path.isfile(candidate) else None) + + +def _shell_lookup(name: str) -> Optional[str]: + if sys.platform == "win32": + commands = [ + ["where", name], + [ + "powershell", + "-NoProfile", + "-Command", + f"(Get-Command {name} -All -ErrorAction SilentlyContinue | " + f"Select-Object -ExpandProperty Source)", + ], + ] + else: + commands = [ + ["sh", "-lc", f"command -v {name}"], + ["bash", "-lc", f"type -a {name} 2>/dev/null"], + ["zsh", "-lc", f"whence -p {name} 2>/dev/null; command -v {name} 2>/dev/null"], + ["fish", "-lc", f"type -a {name} 2>/dev/null"], + ] + + for cmd in commands: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3) + except Exception: + continue + if result.returncode != 0 or not result.stdout.strip(): + continue + for line in result.stdout.strip().splitlines(): + resolved = _parse_shell_lookup_line(line) + if resolved: + return resolved + return None + + +def _glob_cli_paths(name: str) -> list[str]: + import glob + home = os.path.expanduser("~") + patterns = [ + os.path.join(home, ".claude", "bin", name), + os.path.join(home, ".claude", "*", "bin", name), + os.path.join(home, ".local", "share", "claude", "bin", name), + os.path.join(home, ".local", "share", "npm", "*", "bin", name), + ] + if sys.platform == "win32": + patterns.extend([ + os.path.join(home, ".claude", "bin", f"{name}.exe"), + os.path.join(home, ".claude", "bin", f"{name}.cmd"), + ]) + found: list[str] = [] + for pattern in patterns: + try: + found.extend(glob.glob(pattern)) + except Exception: + pass + return found + + +def _configured_cli_path(engine: str) -> Optional[str]: + env_key = "PODCLI_CLAUDE_PATH" if engine == "claude" else "PODCLI_CODEX_PATH" + raw = (os.environ.get(env_key) or "").strip() + if not raw: + try: + from services.env_settings import _read_pairs + raw = (_read_pairs().get(env_key) or "").strip() + except Exception: + pass + if not raw: + return None + return _resolve_cli_path(raw) or (raw if os.path.isfile(raw) else None) + + +def _find_cli(name: str, extra_paths: list[str] = None) -> Optional[str]: + import shutil + + for path in (extra_paths or []) + _glob_cli_paths(name): + resolved = _resolve_cli_path(path) + if resolved: + return resolved + + lookup_dirs = _all_lookup_dirs() + lookup_path = os.pathsep.join(lookup_dirs + [os.environ.get("PATH", "")]) + found = shutil.which(name, path=lookup_path) + if found: + return found + + for directory in lookup_dirs: + resolved = _resolve_cli_path(os.path.join(directory, name)) + if resolved: + return resolved + + for directory in (os.environ.get("PATH", "") or "").split(os.pathsep): + if not directory: + continue + resolved = _resolve_cli_path(os.path.join(directory, name)) + if resolved: + return resolved + + return _shell_lookup(name) + + +def _ai_cli_search_paths(name: str) -> list[str]: + paths_out = [os.path.join(directory, name) for directory in _all_lookup_dirs()] + paths_out.extend(_glob_cli_paths(name)) + return paths_out + + +def _env_cli_path(engine: str) -> Optional[str]: + return _configured_cli_path(engine) + + +def get_ai_cli_status() -> dict: + configured = { + "claude": _configured_cli_path("claude"), + "codex": _configured_cli_path("codex"), + } + candidates = [ + {"engine": engine, "path": path} + for path, engine in _find_ai_cli_candidates() + ] + return { + "configured": configured, + "candidates": candidates, + "available": bool(candidates), + "searched_dirs": _all_lookup_dirs(), + } + + +def _discovery_key() -> tuple: + """Everything discovery reads. Changing any of it must re-probe.""" + return tuple( + os.environ.get(name, "") + for name in ( + "PATH", "HOME", "NVM_DIR", "APPDATA", "ProgramData", + "NPM_CONFIG_PREFIX", "npm_config_prefix", + "PODCLI_CLAUDE_PATH", "PODCLI_CODEX_PATH", + ) + ) + + +@lru_cache(maxsize=8) +def _discover(_key: tuple) -> list[tuple[str, str]]: + candidates = [] + + claude = _env_cli_path("claude") or _find_cli("claude", _ai_cli_search_paths("claude")) + if claude: + candidates.append((claude, "claude")) + + codex = _env_cli_path("codex") or _find_cli("codex", _ai_cli_search_paths("codex")) + if codex: + candidates.append((codex, "codex")) + + return candidates + + +def _find_ai_cli_candidates() -> list[tuple[str, str]]: + # Each probe shells out to npm, pnpm and yarn, which costs ~3s. Callers ask + # several times per render and the filesystem does not move underneath them, + # so the result is cached against the environment it was derived from. + return list(_discover(_discovery_key())) + + +def _find_ai_cli() -> tuple[Optional[str], str]: + """ + Find the best available AI CLI. + + Returns (path, engine) where engine is "claude" or "codex". + Returns (None, "") if neither is available. + """ + candidates = _find_ai_cli_candidates() + return candidates[0] if candidates else (None, "") + + +def _engine_label(engine: str) -> str: + """Human-readable name for an AI engine id.""" + if engine == "claude": + return "Claude" + if engine == "codex": + return "Codex" + return "AI" + + +def _format_timeout_label(timeout: int) -> str: + """Render a human-readable timeout label for progress messages.""" + if timeout % 60 == 0 and timeout >= 60: + minutes = timeout // 60 + unit = "minute" if minutes == 1 else "minutes" + return f"{minutes} {unit}" + return f"{timeout}s" + + +def _run_ai_command( + cli_path: str, + engine: str, + prompt: str, + prompt_file: str, + project_dir: str, + timeout: int, +) -> subprocess.CompletedProcess: + """Execute one AI CLI prompt and return the completed process.""" + if engine == "codex": + output_file = prompt_file + ".out" + result = subprocess.run( + [ + cli_path, "exec", + "--full-auto", + "-o", output_file, + prompt, + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + cwd=project_dir, + timeout=timeout, + ) + if os.path.exists(output_file): + with open(output_file, encoding="utf-8") as f: + result = subprocess.CompletedProcess( + args=result.args, + returncode=result.returncode, + stdout=f.read(), + stderr=result.stderr, + ) + try: + os.unlink(output_file) + except Exception: + pass + return result + + shell = sys.platform == "win32" and cli_path.lower().endswith((".cmd", ".bat")) + cmd = f'"{cli_path}" --print -p -' if shell else [cli_path, "--print", "-p", "-"] + with open(prompt_file, encoding="utf-8") as prompt_fh: + return subprocess.run( + cmd, + stdin=prompt_fh, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + cwd=project_dir, + timeout=timeout, + shell=shell, + ) + + +def classify_cli_error(detail: str) -> str: + """Turn a raw AI CLI failure into an actionable hint. The generic + 'check login' message hides whether it's auth, a plan limit, or a crash.""" + low = (detail or "").lower() + if any(s in low for s in ("not logged in", "please run", "/login", "authenticate", "unauthorized", "invalid api key", "no credentials")): + return "not logged in. Run `claude` (or `codex`) once in a terminal to authenticate, then retry." + if any(s in low for s in ("usage limit", "rate limit", "quota", "too many requests", "429")): + return "usage or rate limit reached on your plan. Wait for the limit to reset, then retry." + if "timed out" in low or "timeout" in low: + return detail + if not detail: + return "the AI CLI returned no output. Run `claude` once in a terminal to confirm it responds." + return detail diff --git a/backend/services/ai_provider.py b/backend/services/ai_provider.py new file mode 100644 index 0000000..9fde079 --- /dev/null +++ b/backend/services/ai_provider.py @@ -0,0 +1,352 @@ +"""Single entry point for every AI generation in podcli. + +Three backends, tried in order until one answers: + + cloud podcli Pro, if signed in (fastest, no install, prompt caching) + cli the user's local Claude Code / Codex binary (free, needs an install) + api ANTHROPIC_API_KEY, called directly over HTTPS (no install, per token) + +Callers pass a prompt and get text back. They do not care which backend ran, +which is the point: the local CLI is found, launched and parsed differently on +every platform, and that mess stops here. + +Selection is controlled by PODCLI_AI_PROVIDER (auto|cloud|cli|api). On `auto` +the order above applies: a Pro subscriber gets what they paid for first, and the +local CLI remains the fallback if the network or the subscription is unavailable +— podcli never stops working because a server did. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from typing import Any, Callable, Optional + +from services import ai_cli, podcli_cloud + +ANTHROPIC_URL = "https://api.anthropic.com/v1/messages" +ANTHROPIC_VERSION = "2023-06-01" +DEFAULT_API_MODEL = "claude-sonnet-5" +DEFAULT_MAX_TOKENS = 16000 + + +@dataclass +class AIResult: + ok: bool + text: str = "" + error: str = "" + provider: str = "" + label: str = "" + attempts: list[str] = field(default_factory=list) + # Extra independent answers to the same prompt, when the backend ran several. + # Callers that know how to merge them get a wider search for almost nothing; + # callers that ignore them behave exactly as before. + alternates: list[str] = field(default_factory=list) + + +def _mode() -> str: + mode = (os.environ.get("PODCLI_AI_PROVIDER") or "auto").strip().lower() + return mode if mode in ("auto", "cloud", "cli", "api") else "auto" + + +def _api_key() -> Optional[str]: + key = (os.environ.get("ANTHROPIC_API_KEY") or "").strip() + return key or None + + +def _api_model() -> str: + return (os.environ.get("PODCLI_AI_MODEL") or "").strip() or DEFAULT_API_MODEL + + +def _chain() -> list[tuple[str, str, str]]: + """Backends to try, in order: (kind, path_or_key, engine).""" + mode = _mode() + chain: list[tuple[str, str, str]] = [] + # A signed-in free workspace would otherwise upload the whole transcript on + # every pass only to be told 402. Forcing the mode still tries, so someone + # debugging entitlement can reach the server. + if mode in ("auto", "cloud") and podcli_cloud.signed_in(): + if mode == "cloud" or podcli_cloud.entitled(): + chain.append(("cloud", "", "cloud")) + if mode in ("auto", "cli"): + chain.extend(("cli", path, engine) for path, engine in ai_cli._find_ai_cli_candidates()) + if mode in ("auto", "api"): + key = _api_key() + if key: + chain.append(("api", key, "api")) + return chain + + +def label_for(kind: str, engine: str) -> str: + if kind == "cloud": + return "podcli Pro" + if kind == "api": + return "Claude API" + return ai_cli._engine_label(engine) + + +def available() -> bool: + return bool(_chain()) + + +def claude_cli_path() -> Optional[str]: + """The local Claude binary, for the one caller that streams its output.""" + for kind, path, engine in _chain(): + if kind == "cli" and engine == "claude": + return path + return None + + +def status() -> dict: + cli_status = ai_cli.get_ai_cli_status() + chain = _chain() + return { + **cli_status, + "mode": _mode(), + "api_key_set": bool(_api_key()), + "api_model": _api_model(), + "available": bool(chain), + "providers": [ + {"kind": kind, "engine": engine, "label": label_for(kind, engine)} + for kind, _, engine in chain + ], + } + + +def extract_json(text: str) -> Optional[Any]: + """Pull the first JSON value out of a model response. + + Models fence their JSON, prefix it with prose, or both, regardless of how + firmly the prompt asks them not to. + """ + if not text: + return None + body = text.strip() + if "```" in body: + fenced = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", body, re.DOTALL) + if fenced: + body = fenced.group(1).strip() + for opener in ("{", "["): + start = body.find(opener) + if start < 0: + continue + try: + value, _ = json.JSONDecoder().raw_decode(body, start) + return value + except ValueError: + continue + return None + + +def _run_api(key: str, prompt: str, timeout: int) -> AIResult: + payload = json.dumps({ + "model": _api_model(), + "max_tokens": DEFAULT_MAX_TOKENS, + "messages": [{"role": "user", "content": prompt}], + }).encode("utf-8") + request = urllib.request.Request( + ANTHROPIC_URL, + data=payload, + headers={ + "content-type": "application/json", + "x-api-key": key, + "anthropic-version": ANTHROPIC_VERSION, + }, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = "" + try: + detail = json.loads(exc.read().decode("utf-8")).get("error", {}).get("message", "") + except Exception: + pass + return AIResult(ok=False, provider="api", label="Claude API", + error=detail or f"Claude API returned HTTP {exc.code}") + except Exception as exc: + return AIResult(ok=False, provider="api", label="Claude API", error=str(exc)) + + if body.get("stop_reason") == "refusal": + return AIResult(ok=False, provider="api", label="Claude API", + error="Claude declined this request.") + + text = "".join( + block.get("text", "") + for block in body.get("content", []) + if block.get("type") == "text" + ).strip() + if not text: + return AIResult(ok=False, provider="api", label="Claude API", + error="Claude API returned no text.") + return AIResult(ok=True, text=text, provider="api", label="Claude API") + + +def _run_cloud(purpose: str, instruction: str, system: Optional[str], + cached_context: Optional[str], episode_source_hash: Optional[str], + timeout: int) -> AIResult: + try: + payload = podcli_cloud.generate( + purpose=purpose, + instruction=instruction, + system=system, + cached_context=cached_context, + episode_source_hash=episode_source_hash, + timeout=timeout, + ) + except podcli_cloud.CloudError as exc: + return AIResult(ok=False, provider="cloud", label="podcli Pro", error=str(exc)) + + text = (payload.get("text") or "").strip() + if not text: + return AIResult(ok=False, provider="cloud", label="podcli Pro", + error="podcli Pro returned no text.") + return AIResult( + ok=True, text=text, provider="cloud", label="podcli Pro", + alternates=[a for a in (payload.get("alternates") or []) if a], + ) + + +def _run_cli(cli_path: str, engine: str, prompt: str, prompt_file: str, + project_dir: str, timeout: int) -> AIResult: + label = ai_cli._engine_label(engine) + try: + completed = ai_cli._run_ai_command( + cli_path=cli_path, + engine=engine, + prompt=prompt, + prompt_file=prompt_file, + project_dir=project_dir, + timeout=timeout, + ) + except Exception as exc: + timed_out = "timed out" in str(exc).lower() or exc.__class__.__name__ == "TimeoutExpired" + detail = ( + f"{label} timed out ({ai_cli._format_timeout_label(timeout)} limit)" + if timed_out else f"{label} failed to start: {exc}" + ) + return AIResult(ok=False, provider=engine, label=label, error=detail) + + text = (completed.stdout or "").strip() + if completed.returncode != 0 or not text: + detail = (completed.stderr or "").strip() or text + return AIResult(ok=False, provider=engine, label=label, + error=ai_cli.classify_cli_error(detail)) + return AIResult(ok=True, text=text, provider=engine, label=label) + + +def generate( + prompt: str, + *, + timeout: int = 900, + project_dir: Optional[str] = None, + on_attempt: Optional[Callable[[str], None]] = None, + accept: Optional[Callable[[str], bool]] = None, + adapt: Optional[Callable[[str, str], str]] = None, + purpose: str = "generate", + stable_prefix: Optional[str] = None, + local_prompt: Optional[str] = None, + episode_source_hash: Optional[str] = None, +) -> AIResult: + """Run one prompt through the first backend that answers. + + on_attempt is called with a human label ("Claude", "podcli Pro") before each + attempt so callers can drive progress UI without knowing the chain. + + accept rejects a response the backend considers successful — an engine that + answers with prose where JSON was asked for has failed, and the next one + deserves a turn. Return True to accept, or False / a reason string to reject. + + adapt(engine, prompt) rewrites the prompt per backend, for engines that need + a shorter one than the others. + + stable_prefix is the large, unchanging half of the prompt — the transcript. + The cloud backend sends it as a separate cacheable block so it is read once + per episode rather than once per pass, which is the difference between ~$0.41 + and ~$0.90 per episode. + + Caching wants the stable text first and the varying ask last; several local + prompts are built the other way round, and reordering them would change what + the free path produces. local_prompt is the escape hatch: pass the exact + legacy string and local backends send it untouched while the cloud gets the + split form. Omit it and the prefix is simply prepended. + """ + chain = _chain() + if not chain: + return AIResult( + ok=False, + error="No AI available. Install Claude Code or set ANTHROPIC_API_KEY " + "(podcli config set ANTHROPIC_API_KEY ...).", + ) + + if project_dir is None: + project_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + # The Claude CLI reads its prompt from a file, so an adapted prompt needs its + # own file rather than the shared one. + prompt_files: dict[str, str] = {} + + def prompt_file_for(text: str) -> str: + if text not in prompt_files: + from utils.prompt_files import write_prompt_file + prompt_files[text] = write_prompt_file(text) + return prompt_files[text] + + attempts: list[str] = [] + last = AIResult(ok=False, error="No AI backend produced a response.") + try: + for kind, target, engine in chain: + label = label_for(kind, engine) + if on_attempt: + on_attempt(label) + if kind == "cloud": + result = _run_cloud(purpose, prompt, None, stable_prefix, + episode_source_hash, timeout) + else: + whole = local_prompt or ( + f"{stable_prefix}\n\n{prompt}" if stable_prefix else prompt + ) + text = adapt(engine, whole) if adapt else whole + if kind == "api": + result = _run_api(target, text, timeout) + else: + result = _run_cli(target, engine, text, prompt_file_for(text), + project_dir, timeout) + if result.ok and accept: + verdict = accept(result.text) + if verdict is not True: + reason = verdict if isinstance(verdict, str) and verdict else \ + f"{label} returned an unusable response." + result = AIResult(ok=False, provider=result.provider, + label=label, error=reason) + attempts.append(f"{label}: {'ok' if result.ok else result.error}") + if result.ok: + result.attempts = attempts + return result + last = result + last.attempts = attempts + return last + finally: + for path in prompt_files.values(): + try: + os.unlink(path) + except OSError: + pass + + +def generate_json(prompt: str, **kwargs) -> tuple[Optional[Any], AIResult]: + """generate() plus the fence-stripping every caller was doing by hand. + + A backend whose answer will not parse is treated as failed, so the next one + in the chain gets a turn. + """ + kwargs.setdefault("accept", lambda text: extract_json(text) is not None) + result = generate(prompt, **kwargs) + if not result.ok: + return None, result + return extract_json(result.text), result diff --git a/backend/services/claude_suggest.py b/backend/services/claude_suggest.py index e12f8d2..b0c9f4e 100644 --- a/backend/services/claude_suggest.py +++ b/backend/services/claude_suggest.py @@ -12,7 +12,6 @@ import os import subprocess import sys -import tempfile from typing import Optional, Callable from config.paths import paths @@ -22,432 +21,16 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from presets import MIN_CLIP_DURATION, MAX_CLIP_DURATION, TARGET_CLIP_DURATION_MIN, TARGET_CLIP_DURATION_MAX from utils.text import clean_title - - -def _cli_name_exts() -> list[str]: - if sys.platform == "win32": - return ["", ".cmd", ".exe", ".bat"] - return [""] - - -def _resolve_cli_path(path: str) -> Optional[str]: - for ext in _cli_name_exts(): - candidate = path + ext - if os.path.isfile(candidate): - return candidate - return None - - -def _dedupe_dirs(dirs: list[str]) -> list[str]: - seen: set[str] = set() - ordered: list[str] = [] - for directory in dirs: - if not directory: - continue - directory = os.path.expanduser(directory) - if directory in seen: - continue - seen.add(directory) - if os.path.isdir(directory): - ordered.append(directory) - return ordered - - -def _npmrc_prefix_dirs() -> list[str]: - dirs: list[str] = [] - npmrc_paths = [os.path.join(os.path.expanduser("~"), ".npmrc")] - try: - from services.env_settings import _env_path - npmrc_paths.append(os.path.join(os.path.dirname(_env_path()), ".npmrc")) - except Exception: - pass - for npmrc in npmrc_paths: - if not os.path.isfile(npmrc): - continue - try: - with open(npmrc, encoding="utf-8") as f: - for line in f: - stripped = line.strip() - if not stripped or stripped.startswith("#") or stripped.startswith(";"): - continue - if stripped.startswith("prefix="): - prefix = stripped.split("=", 1)[1].strip() - if prefix: - dirs.append(prefix if sys.platform == "win32" else os.path.join(prefix, "bin")) - except Exception: - pass - return dirs - - -def _package_manager_bin_dirs() -> list[str]: - dirs: list[str] = [] - npm_cmds = [ - (["npm", "config", "get", "prefix"], "prefix"), - (["npm", "root", "-g"], "root"), - ] - for args, kind in npm_cmds: - try: - result = subprocess.run(args, capture_output=True, text=True, timeout=2) - except Exception: - continue - if result.returncode != 0: - continue - raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" - if not raw: - continue - if kind == "prefix": - dirs.append(raw if sys.platform == "win32" else os.path.join(raw, "bin")) - elif kind == "root": - dirs.append(os.path.join(raw, ".bin")) - else: - dirs.append(raw) - - for args, kind in ( - (["pnpm", "config", "get", "global-bin-dir"], "bin"), - (["pnpm", "bin", "-g"], "bin"), - (["yarn", "global", "bin"], "bin"), - ): - try: - result = subprocess.run(args, capture_output=True, text=True, timeout=2) - except Exception: - continue - if result.returncode != 0: - continue - raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" - if raw: - dirs.append(raw) - - return dirs - - -def _version_manager_bin_dirs() -> list[str]: - home = os.path.expanduser("~") - dirs = [ - os.path.join(home, "bin"), - os.path.join(home, ".asdf", "shims"), - os.path.join(home, ".local", "share", "mise", "shims"), - os.path.join(home, ".local", "share", "rtx", "shims"), - os.path.join(home, ".bun", "bin"), - os.path.join(home, ".cargo", "bin"), - os.path.join(home, "go", "bin"), - os.path.join(home, ".local", "share", "pnpm"), - os.path.join(home, ".claude", "bin"), - ] - - nvm_dir = os.environ.get("NVM_DIR") or os.path.join(home, ".nvm") - try: - import glob - dirs.extend(sorted(glob.glob(os.path.join(nvm_dir, "versions", "node", "*", "bin")), reverse=True)) - dirs.extend(glob.glob(os.path.join(home, ".fnm", "node-versions", "*", "installation", "bin"))) - dirs.extend(glob.glob(os.path.join(home, ".local", "share", "fnm", "node-versions", "*", "installation", "bin"))) - except Exception: - pass - - fnm_bin = os.path.join(home, ".local", "share", "fnm", "current", "bin") - dirs.append(fnm_bin) - dirs.append(os.path.join(home, ".volta", "bin")) - - if sys.platform == "win32": - for env_key in ("APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"): - base = os.environ.get(env_key) - if not base: - continue - dirs.extend([ - os.path.join(base, "npm"), - os.path.join(base, "Programs", "nodejs"), - os.path.join(base, "Microsoft", "WinGet", "Links"), - ]) - dirs.append(os.path.join(home, "scoop", "shims")) - dirs.append(os.path.join(os.environ.get("ProgramData", ""), "npm")) - else: - dirs.extend([ - "/usr/bin", - "/bin", - "/usr/local/bin", - "/opt/homebrew/bin", - "/opt/homebrew/sbin", - "/snap/bin", - "/var/lib/snapd/snap/bin", - ]) - - npm_prefix = ( - os.environ.get("NPM_CONFIG_PREFIX") - or os.environ.get("npm_config_prefix") - or "" - ).strip() - if npm_prefix: - dirs.append(os.path.join(os.path.expanduser(npm_prefix), "bin")) - - return dirs - - -def _static_lookup_dirs() -> list[str]: - home = os.path.expanduser("~") - dirs = [ - os.path.join(home, ".local", "bin"), - os.path.join(home, ".claude", "local", "bin"), - os.path.join(home, ".claude", "local", "node_modules", ".bin"), - os.path.join(home, ".npm-global", "bin"), - ] - if sys.platform == "win32": - appdata = os.environ.get("APPDATA") - if appdata: - dirs.append(os.path.join(appdata, "npm")) - dirs.append(os.path.join(home, ".local", "bin")) - return dirs - - -def _all_lookup_dirs() -> list[str]: - return _dedupe_dirs( - _static_lookup_dirs() - + _version_manager_bin_dirs() - + _npmrc_prefix_dirs() - + _package_manager_bin_dirs() - ) - - -def _path_lookup_dirs() -> list[str]: - return _all_lookup_dirs() - - -def _npm_global_bin_dirs() -> list[str]: - return _package_manager_bin_dirs() - - -def _parse_shell_lookup_line(line: str) -> Optional[str]: - candidate = line.strip().strip('"') - if not candidate: - return None - if " is " in candidate: - candidate = candidate.split(" is ", 1)[1].strip() - if candidate.startswith("(") and candidate.endswith(")"): - candidate = candidate[1:-1].strip() - return _resolve_cli_path(candidate) or (candidate if os.path.isfile(candidate) else None) - - -def _shell_lookup(name: str) -> Optional[str]: - if sys.platform == "win32": - commands = [ - ["where", name], - [ - "powershell", - "-NoProfile", - "-Command", - f"(Get-Command {name} -All -ErrorAction SilentlyContinue | " - f"Select-Object -ExpandProperty Source)", - ], - ] - else: - commands = [ - ["sh", "-lc", f"command -v {name}"], - ["bash", "-lc", f"type -a {name} 2>/dev/null"], - ["zsh", "-lc", f"whence -p {name} 2>/dev/null; command -v {name} 2>/dev/null"], - ["fish", "-lc", f"type -a {name} 2>/dev/null"], - ] - - for cmd in commands: - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=3) - except Exception: - continue - if result.returncode != 0 or not result.stdout.strip(): - continue - for line in result.stdout.strip().splitlines(): - resolved = _parse_shell_lookup_line(line) - if resolved: - return resolved - return None - - -def _glob_cli_paths(name: str) -> list[str]: - import glob - home = os.path.expanduser("~") - patterns = [ - os.path.join(home, ".claude", "bin", name), - os.path.join(home, ".claude", "*", "bin", name), - os.path.join(home, ".local", "share", "claude", "bin", name), - os.path.join(home, ".local", "share", "npm", "*", "bin", name), - ] - if sys.platform == "win32": - patterns.extend([ - os.path.join(home, ".claude", "bin", f"{name}.exe"), - os.path.join(home, ".claude", "bin", f"{name}.cmd"), - ]) - found: list[str] = [] - for pattern in patterns: - try: - found.extend(glob.glob(pattern)) - except Exception: - pass - return found - - -def _configured_cli_path(engine: str) -> Optional[str]: - env_key = "PODCLI_CLAUDE_PATH" if engine == "claude" else "PODCLI_CODEX_PATH" - raw = (os.environ.get(env_key) or "").strip() - if not raw: - try: - from services.env_settings import _read_pairs - raw = (_read_pairs().get(env_key) or "").strip() - except Exception: - pass - if not raw: - return None - return _resolve_cli_path(raw) or (raw if os.path.isfile(raw) else None) - - -def _find_cli(name: str, extra_paths: list[str] = None) -> Optional[str]: - import shutil - - for path in (extra_paths or []) + _glob_cli_paths(name): - resolved = _resolve_cli_path(path) - if resolved: - return resolved - - lookup_dirs = _all_lookup_dirs() - lookup_path = os.pathsep.join(lookup_dirs + [os.environ.get("PATH", "")]) - found = shutil.which(name, path=lookup_path) - if found: - return found - - for directory in lookup_dirs: - resolved = _resolve_cli_path(os.path.join(directory, name)) - if resolved: - return resolved - - for directory in (os.environ.get("PATH", "") or "").split(os.pathsep): - if not directory: - continue - resolved = _resolve_cli_path(os.path.join(directory, name)) - if resolved: - return resolved - - return _shell_lookup(name) - - -def _ai_cli_search_paths(name: str) -> list[str]: - paths_out = [os.path.join(directory, name) for directory in _all_lookup_dirs()] - paths_out.extend(_glob_cli_paths(name)) - return paths_out - - -def _env_cli_path(engine: str) -> Optional[str]: - return _configured_cli_path(engine) - - -def get_ai_cli_status() -> dict: - configured = { - "claude": _configured_cli_path("claude"), - "codex": _configured_cli_path("codex"), - } - candidates = [ - {"engine": engine, "path": path} - for path, engine in _find_ai_cli_candidates() - ] - return { - "configured": configured, - "candidates": candidates, - "available": bool(candidates), - "searched_dirs": _all_lookup_dirs(), - } - - -def _find_ai_cli_candidates() -> list[tuple[str, str]]: - candidates = [] - - claude = _env_cli_path("claude") or _find_cli("claude", _ai_cli_search_paths("claude")) - if claude: - candidates.append((claude, "claude")) - - codex = _env_cli_path("codex") or _find_cli("codex", _ai_cli_search_paths("codex")) - if codex: - candidates.append((codex, "codex")) - - return candidates - - -def _find_ai_cli() -> tuple[Optional[str], str]: - """ - Find the best available AI CLI. - - Returns (path, engine) where engine is "claude" or "codex". - Returns (None, "") if neither is available. - """ - candidates = _find_ai_cli_candidates() - return candidates[0] if candidates else (None, "") - - -def _engine_label(engine: str) -> str: - """Human-readable name for an AI engine id.""" - if engine == "claude": - return "Claude" - if engine == "codex": - return "Codex" - return "AI" - - -def _format_timeout_label(timeout: int) -> str: - """Render a human-readable timeout label for progress messages.""" - if timeout % 60 == 0 and timeout >= 60: - minutes = timeout // 60 - unit = "minute" if minutes == 1 else "minutes" - return f"{minutes} {unit}" - return f"{timeout}s" - - -def _run_ai_command( - cli_path: str, - engine: str, - prompt: str, - prompt_file: str, - project_dir: str, - timeout: int, -) -> subprocess.CompletedProcess: - """Execute one AI CLI prompt and return the completed process.""" - if engine == "codex": - output_file = prompt_file + ".out" - result = subprocess.run( - [ - cli_path, "exec", - "--full-auto", - "-o", output_file, - prompt, - ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - cwd=project_dir, - timeout=timeout, - ) - if os.path.exists(output_file): - with open(output_file, encoding="utf-8") as f: - result = subprocess.CompletedProcess( - args=result.args, - returncode=result.returncode, - stdout=f.read(), - stderr=result.stderr, - ) - try: - os.unlink(output_file) - except Exception: - pass - return result - - shell = sys.platform == "win32" and cli_path.lower().endswith((".cmd", ".bat")) - cmd = f'"{cli_path}" --print -p -' if shell else [cli_path, "--print", "-p", "-"] - with open(prompt_file, encoding="utf-8") as prompt_fh: - return subprocess.run( - cmd, - stdin=prompt_fh, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - cwd=project_dir, - timeout=timeout, - shell=shell, - ) +from services import ai_provider, podcli_cloud +from services.ai_cli import ( + _engine_label, + _find_ai_cli, + _find_ai_cli_candidates, + _format_timeout_label, + _run_ai_command, + classify_cli_error, + get_ai_cli_status, +) def _load_existing_shorts(episodes_path: str) -> list[str]: @@ -634,6 +217,19 @@ def _build_prompt( {transcript_text}""" +def _split_prompt_for_cache(prompt: str, transcript_text: str) -> tuple[str, str]: + """Separate the transcript from the ask, for backends that cache prefixes. + + Returns (stable_prefix, instruction). The transcript is the only large + thing here and it is identical across every pass on an episode, so caching + it turns four full reads into one read and three cache hits. + """ + marker = f"\n\n{transcript_text}" + if not transcript_text or not prompt.endswith(marker): + return "", prompt + return transcript_text, prompt[: -len(marker)] + + def _build_transcript_text(segments: list[dict]) -> str: """Serialize transcript segments into the prompt-friendly text format.""" lines = [] @@ -739,14 +335,13 @@ def find_moments_from_text( progress_callback: Optional[Callable[[int, str], None]] = None, max_results: int = 3, ) -> list[dict]: - """Locate the moment(s) the user described/pasted in the transcript via an AI - CLI. Returns clip dicts (same shape as suggest_with_claude). Status goes to + """Locate the moment(s) the user described/pasted in the transcript. + Returns clip dicts (same shape as suggest_with_claude). Status goes to progress_callback; warnings to stderr — never stdout, which is the task runner's JSON-RPC channel.""" existing_clips = existing_clips or [] - candidates = _find_ai_cli_candidates() - if not candidates: - print("No AI CLI available for moment search", file=sys.stderr, flush=True) + if not ai_provider.available(): + print("No AI available for moment search", file=sys.stderr, flush=True) return [] if progress_callback: @@ -794,49 +389,17 @@ def find_moments_from_text( Transcript: {transcript_text}""" - # Prompt goes to .podcli/tmp/ (gitignored), not the repo root, so a crash - # mid-run never litters the working tree with transcript dumps. project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) - - try: - for idx, (cli_path, engine) in enumerate(candidates): - if progress_callback: - label = "Claude" if engine == "claude" else "Codex" - progress_callback(40, f"Searching transcript with {label}...") - try: - result = _run_ai_command( - cli_path=cli_path, - engine=engine, - prompt=prompt, - prompt_file=prompt_file, - project_dir=project_dir, - timeout=900, - ) - except Exception: - continue - - if result.returncode != 0 or not result.stdout.strip(): - continue - - response = result.stdout.strip() - if "```" in response: - import re - - fence_match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", response, re.DOTALL) - if fence_match: - response = fence_match.group(1).strip() - try: - json_start = response.find("{") - if json_start >= 0: - data, _ = json.JSONDecoder().raw_decode(response, json_start) - else: - data = json.loads(response) - except Exception: - continue + def announce(label: str) -> None: + if progress_callback: + progress_callback(40, f"Searching transcript with {label}...") + try: + data, _result = ai_provider.generate_json( + prompt, timeout=900, project_dir=project_dir, on_attempt=announce, + ) + if data: found = [] for c in data.get("clips", []): scores = c.get("scores", {}) @@ -882,26 +445,6 @@ def find_moments_from_text( except Exception as e: print(f"Moment search error: {e}", file=sys.stderr, flush=True) return [] - finally: - try: - os.unlink(prompt_file) - except Exception: - pass - - -def classify_cli_error(detail: str) -> str: - """Turn a raw AI CLI failure into an actionable hint. The generic - 'check login' message hides whether it's auth, a plan limit, or a crash.""" - low = (detail or "").lower() - if any(s in low for s in ("not logged in", "please run", "/login", "authenticate", "unauthorized", "invalid api key", "no credentials")): - return "not logged in. Run `claude` (or `codex`) once in a terminal to authenticate, then retry." - if any(s in low for s in ("usage limit", "rate limit", "quota", "too many requests", "429")): - return "usage or rate limit reached on your plan. Wait for the limit to reset, then retry." - if "timed out" in low or "timeout" in low: - return detail - if not detail: - return "the AI CLI returned no output. Run `claude` once in a terminal to confirm it responds." - return detail def suggest_with_claude( @@ -919,13 +462,12 @@ def suggest_with_claude( Tries available AI CLIs in preference order and retries on runtime failure. Returns None if neither succeeds. """ - candidates = _find_ai_cli_candidates() - if not candidates: + providers = ai_provider.status()["providers"] + if not providers: return None if progress_callback: - label = _engine_label(candidates[0][1]) - progress_callback(0, f"Preparing transcript for {label}...") + progress_callback(0, f"Preparing transcript for {providers[0]['label']}...") transcript_text = _build_transcript_text(segments) @@ -943,161 +485,149 @@ def suggest_with_claude( reaction_times=reaction_times, ) - # Write prompt to temp file to avoid shell escaping issues. - # Goes to .podcli/tmp/ (gitignored) so crashes don't litter the repo root. project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) - - if progress_callback: - first_label = _engine_label(candidates[0][1]) - progress_callback(20, f"Asking {first_label} to analyze transcript...") - - try: - def _parse_seconds(val) -> float: - """Parse a timestamp value — handles both 123.4 and '2:03' formats.""" - if isinstance(val, (int, float)): - return float(val) - s = str(val).strip() - if ":" in s: - parts = s.split(":") - try: - return float(parts[0]) * 60 + float(parts[1]) - except (ValueError, IndexError): - return 0.0 + def _parse_seconds(val) -> float: + """Parse a timestamp value — handles both 123.4 and '2:03' formats.""" + if isinstance(val, (int, float)): + return float(val) + s = str(val).strip() + if ":" in s: + parts = s.split(":") try: - return float(s) - except ValueError: + return float(parts[0]) * 60 + float(parts[1]) + except (ValueError, IndexError): return 0.0 + try: + return float(s) + except ValueError: + return 0.0 - last_detail: Optional[str] = None - for idx, (cli_path, engine) in enumerate(candidates): - label = _engine_label(engine) - if idx > 0 and progress_callback: - progress_callback(0, f"Retrying with {label}...") - progress_callback(20, f"Asking {label} to analyze transcript...") - - try: - result = _run_ai_command( - cli_path=cli_path, - engine=engine, - prompt=prompt, - prompt_file=prompt_file, - project_dir=project_dir, - timeout=timeout, - ) - except subprocess.TimeoutExpired: - last_detail = f"{label} timed out ({_format_timeout_label(timeout)} limit)" - if progress_callback: - progress_callback(0, last_detail) - continue - except Exception as e: - last_detail = f"{label} error: {e}" - if progress_callback: - progress_callback(0, last_detail) - continue - - if result.returncode != 0 or not result.stdout.strip(): - detail = (result.stderr or "no response").strip()[:200] - last_detail = f"{label}: {detail}" - if progress_callback: - progress_callback(0, f"{label} returned error: {detail}") - continue - - if progress_callback: - progress_callback(80, f"Parsing {label}'s suggestions...") - - try: - response = result.stdout.strip() - if "```" in response: - import re - fence_match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", response, re.DOTALL) - if fence_match: - response = fence_match.group(1).strip() - - json_start = response.find("{") - if json_start >= 0: - decoder = json.JSONDecoder() - data, _ = decoder.raw_decode(response, json_start) - else: - data = json.loads(response) - except json.JSONDecodeError as e: - last_detail = f"{label} returned output that wasn't valid JSON ({e})" - if progress_callback: - progress_callback(0, f"Could not parse {label}'s response as JSON: {e}") - continue - - clips = data.get("clips", []) - if not clips: - last_detail = f"{label} ran but found no clips in the transcript" - if progress_callback: - progress_callback(0, f"{label} returned no clips") - continue - - normalized = [] - for c in clips: - scores = c.get("scores", {}) - total = sum(scores.values()) if scores else c.get("total_score", 0) - - raw_segments = c.get("segments", []) - keep_segments = [] - for seg in raw_segments: - s = round(_parse_seconds(seg.get("start", 0)), 1) - e = round(_parse_seconds(seg.get("end", 0)), 1) - if e > s: - keep_segments.append({"start": s, "end": e}) + attempted: list[str] = [] + current = {"label": ""} - start_sec = round(_parse_seconds(c.get("start_second", 0)), 1) - end_sec = round(_parse_seconds(c.get("end_second", 0)), 1) + def announce(label: str) -> None: + current["label"] = label + if attempted and progress_callback: + progress_callback(0, f"Retrying with {label}...") + if progress_callback: + progress_callback(20, f"Asking {label} to analyze transcript...") + attempted.append(label) - if not keep_segments and end_sec > start_sec: - keep_segments = [{"start": start_sec, "end": end_sec}] + def usable(text: str): + """Reject a response that parses but has nothing in it, so the next + engine gets a turn rather than the user getting an empty result.""" + label = current["label"] + if progress_callback: + progress_callback(80, f"Parsing {label}'s suggestions...") + parsed = ai_provider.extract_json(text) + if not isinstance(parsed, dict): + return f"{label} returned output that wasn't valid JSON" + if not parsed.get("clips"): + return f"{label} ran but found no clips in the transcript" + return True + + cached_prefix, instruction = _split_prompt_for_cache(prompt, transcript_text) + + # What this channel's own published clips say about what works, plus the + # house style learned from edits the team made to earlier output. Empty for + # everyone else, so the free path is unchanged. + learned = podcli_cloud.prompt_block() + if learned: + instruction = f"{learned}\n\n{instruction}" + + attempt = ai_provider.generate( + instruction, + timeout=timeout, + project_dir=project_dir, + on_attempt=announce, + accept=usable, + purpose="select_moments", + stable_prefix=cached_prefix or None, + # Local backends keep the prompt exactly as it has always been built; + # only the cloud sees the split form. + local_prompt=prompt, + ) - kept_duration = sum(seg["end"] - seg["start"] for seg in keep_segments) - if kept_duration < MIN_CLIP_DURATION or kept_duration > MAX_CLIP_DURATION: - continue + if not attempt.ok: + if progress_callback: + progress_callback(0, attempt.error) + if error_sink is not None: + error_sink.append(classify_cli_error(attempt.error)) + return None - normalized.append({ - "title": clean_title(c.get("title", "Untitled")), - "start_second": keep_segments[0]["start"] if keep_segments else start_sec, - "end_second": keep_segments[-1]["end"] if keep_segments else end_sec, - "segments": keep_segments, - "duration": round(kept_duration), - "score": total, - "content_type": c.get("content_type", "unknown"), - "reasoning": c.get("why", ""), - "preview_text": c.get("quote", "")[:120], - "suggested_caption_style": "hormozi", - "quote": c.get("quote", ""), - "why": c.get("why", ""), - "reasons": [c.get("content_type", "")], - "preview": c.get("quote", "")[:120], - "_ai_engine": engine, - }) + label = attempt.label + + # Several independent searches over the same transcript find overlapping but + # not identical moments. Keeping the union and re-ranking beats picking one + # set, and the dedupe/scoring below already exists for exactly this shape. + clips = list(ai_provider.extract_json(attempt.text)["clips"]) + for alternate in attempt.alternates: + parsed = ai_provider.extract_json(alternate) + if isinstance(parsed, dict): + clips.extend(parsed.get("clips") or []) + + normalized = [] + for c in clips: + scores = c.get("scores", {}) + total = sum(scores.values()) if scores else c.get("total_score", 0) + + raw_segments = c.get("segments", []) + keep_segments = [] + for seg in raw_segments: + s = round(_parse_seconds(seg.get("start", 0)), 1) + e = round(_parse_seconds(seg.get("end", 0)), 1) + if e > s: + keep_segments.append({"start": s, "end": e}) + + start_sec = round(_parse_seconds(c.get("start_second", 0)), 1) + end_sec = round(_parse_seconds(c.get("end_second", 0)), 1) + + if not keep_segments and end_sec > start_sec: + keep_segments = [{"start": start_sec, "end": end_sec}] + + kept_duration = sum(seg["end"] - seg["start"] for seg in keep_segments) + if kept_duration < MIN_CLIP_DURATION or kept_duration > MAX_CLIP_DURATION: + continue - selected = _select_top_by_score( - _drop_clips_overlapping(normalized, exclude_clips or []), top_n - ) + normalized.append({ + "title": clean_title(c.get("title", "Untitled")), + "start_second": keep_segments[0]["start"] if keep_segments else start_sec, + "end_second": keep_segments[-1]["end"] if keep_segments else end_sec, + "segments": keep_segments, + "duration": round(kept_duration), + "score": total, + "content_type": c.get("content_type", "unknown"), + "reasoning": c.get("why", ""), + "preview_text": c.get("quote", "")[:120], + "suggested_caption_style": "hormozi", + "quote": c.get("quote", ""), + "why": c.get("why", ""), + "reasons": [c.get("content_type", "")], + "preview": c.get("quote", "")[:120], + "_ai_engine": attempt.provider, + }) - if selected: - if progress_callback: - progress_callback(100, f"{label} suggested {len(selected)} clips") - return selected + # Dedupe within the pool as well as against already-selected clips: several + # attempts routinely surface the same strong moment, and without this the + # top N would be the same clip repeated. + selected = _select_top_by_score( + _drop_clips_overlapping(_dedupe_clips_by_range(normalized), exclude_clips or []), + top_n, + ) - last_detail = f"{label} returned clips but none were usable (wrong length or format)" - if progress_callback: - progress_callback(0, f"{label} returned no usable clips") + if selected: + if progress_callback: + progress_callback(100, f"{label} suggested {len(selected)} clips") + return selected - if error_sink is not None: - error_sink.append(classify_cli_error(last_detail or "")) - return None - finally: - # Clean up temp file - try: - os.unlink(prompt_file) - except Exception: - pass + if progress_callback: + progress_callback(0, f"{label} returned no usable clips") + if error_sink is not None: + error_sink.append( + f"{label} returned clips but none were usable (wrong length or format)" + ) + return None def suggest_initial_with_claude( diff --git a/backend/services/content_generator.py b/backend/services/content_generator.py index 99b20c1..4abe9ed 100644 --- a/backend/services/content_generator.py +++ b/backend/services/content_generator.py @@ -1,5 +1,5 @@ """ -Per-clip content generation (titles, descriptions, tags) via AI CLI. +Per-clip content generation (titles, descriptions, tags). Single source of truth used by CLI, Web UI, and MCP. """ @@ -12,9 +12,18 @@ import threading from typing import Optional, Callable -from services.claude_suggest import _engine_label, _find_ai_cli_candidates, _run_ai_command +from config.paths import paths +from services import ai_provider from services.knowledge_base import load_kb_context as kb_load_context, warn_missing_context +# Codex silently truncates long prompts, so it gets a shortened one. The prompts +# here lead with the request precisely so this cut only costs transcript tail. +CODEX_PROMPT_LIMIT = 4000 + + +def _shorten_for_codex(engine: str, prompt: str) -> str: + return prompt[:CODEX_PROMPT_LIMIT] if engine == "codex" else prompt + CONTENT_KB_FILES = [ ("05-title-formulas.md", 3000), @@ -186,8 +195,7 @@ def generate_custom_content( Returns {"text", "engine"} with the raw model output, or None if no AI CLI. """ - candidates = _find_ai_cli_candidates() - if not candidates: + if not ai_provider.available(): return None kb_context = load_kb_context() @@ -210,37 +218,25 @@ def generate_custom_content( TRANSCRIPT EXCERPT: {excerpt}""" - project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) - try: - for idx, (cli_path, engine) in enumerate(candidates): - label = _engine_label(engine) - if progress_callback: - progress_callback(30, f"Asking {label}..." if idx == 0 else f"Retrying with {label}...") - try: - cr = _run_ai_command( - cli_path=cli_path, - engine=engine, - prompt=prompt[:4000] if engine == "codex" else prompt, - prompt_file=prompt_file, - project_dir=project_dir, - timeout=120, - ) - except Exception as exc: - print(f"Warning: {label} content generation failed: {exc}", file=sys.stderr) - continue - if cr.returncode != 0 or not cr.stdout.strip(): - continue - if progress_callback: - progress_callback(100, "Done") - return {"text": cr.stdout.strip(), "engine": engine} + attempted: list[str] = [] + + def announce(label: str) -> None: + if progress_callback: + progress_callback(30, f"Asking {label}..." if not attempted else f"Retrying with {label}...") + attempted.append(label) + + result = ai_provider.generate( + prompt, + timeout=120, + on_attempt=announce, + adapt=_shorten_for_codex, + ) + if not result.ok: + print(f"Warning: content generation failed: {result.error}", file=sys.stderr) return None - finally: - try: - os.unlink(prompt_file) - except Exception as exc: - print(f"Warning: could not remove prompt file {prompt_file}: {exc}", file=sys.stderr) + if progress_callback: + progress_callback(100, "Done") + return {"text": result.text, "engine": result.provider} def generate_clip_content( @@ -262,13 +258,12 @@ def generate_clip_content( Returns: dict with raw_text, titles, description, tags, hashtags, or None if AI unavailable """ - candidates = _find_ai_cli_candidates() - if not candidates: + providers = ai_provider.status()["providers"] + if not providers: return None - label = _engine_label(candidates[0][1]) if progress_callback: - progress_callback(0, f"Generating content via {label}...") + progress_callback(0, f"Generating content via {providers[0]['label']}...") kb_context = load_kb_context(task="title and description generation") @@ -355,19 +350,23 @@ def generate_clip_content( project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) + def usable(text: str) -> bool: + parsed = _parse_content(text) + return bool(parsed["titles"] or parsed["description"]) - try: - for idx, (cli_path, engine) in enumerate(candidates): - label = _engine_label(engine) - if progress_callback: - if idx > 0: - progress_callback(0, f"Retrying content generation with {label}...") - progress_callback(30, f"Asking {label} for titles & descriptions...") + raw_text = None + engine_used = "" - raw_text = None - if engine == "claude" and partial_callback is not None: + # The Studio renders titles as they arrive. Only the Claude CLI can stream, + # so it gets first refusal; everything else falls through to the chain. + if partial_callback is not None: + cli_path = ai_provider.claude_cli_path() + if cli_path: + if progress_callback: + progress_callback(30, "Asking Claude for titles & descriptions...") + from utils.prompt_files import write_prompt_file + prompt_file = write_prompt_file(prompt) + try: raw_text = _stream_claude_content( cli_path=cli_path, prompt_file=prompt_file, @@ -375,42 +374,42 @@ def generate_clip_content( timeout=120, on_partial=partial_callback, ) - - if raw_text is None: + engine_used = "claude" + finally: try: - cr = _run_ai_command( - cli_path=cli_path, - engine=engine, - prompt=prompt[:4000] if engine == "codex" else prompt, - prompt_file=prompt_file, - project_dir=project_dir, - timeout=120, - ) - except subprocess.TimeoutExpired: - continue - except Exception: - continue - - if cr.returncode != 0 or not cr.stdout.strip(): - continue - raw_text = cr.stdout.strip() + os.unlink(prompt_file) + except OSError: + pass + if raw_text is None or not usable(raw_text): + attempted: list[str] = [] + + def announce(label: str) -> None: if progress_callback: - progress_callback(90, "Parsing content...") + if attempted: + progress_callback(0, f"Retrying content generation with {label}...") + progress_callback(30, f"Asking {label} for titles & descriptions...") + attempted.append(label) + + attempt = ai_provider.generate( + prompt, + timeout=120, + project_dir=project_dir, + on_attempt=announce, + adapt=_shorten_for_codex, + accept=usable, + ) + if not attempt.ok: + return None + raw_text, engine_used = attempt.text, attempt.provider - result = _parse_content(raw_text) - result["engine"] = engine - if not result["titles"] and not result["description"]: - continue + if progress_callback: + progress_callback(90, "Parsing content...") - if progress_callback: - progress_callback(100, f"Content ready ({len(result['titles'])} titles)") + result = _parse_content(raw_text) + result["engine"] = engine_used - return result + if progress_callback: + progress_callback(100, f"Content ready ({len(result['titles'])} titles)") - return None - finally: - try: - os.unlink(prompt_file) - except Exception: - pass + return result diff --git a/backend/services/env_settings.py b/backend/services/env_settings.py index fe4d6ca..cc3550b 100644 --- a/backend/services/env_settings.py +++ b/backend/services/env_settings.py @@ -143,7 +143,7 @@ def set_setting(key: str, value: str) -> None: if not value: raise ValueError("value is empty") if key in ("PODCLI_CLAUDE_PATH", "PODCLI_CODEX_PATH"): - from services.claude_suggest import _resolve_cli_path + from services.ai_cli import _resolve_cli_path resolved = _resolve_cli_path(value) or (value if os.path.isfile(value) else None) if not resolved: raise ValueError(f"path does not exist: {value}") @@ -160,7 +160,7 @@ def unset_setting(key: str) -> None: def run_env_action(action: str, key: Optional[str] = None, value: Optional[str] = None) -> dict[str, Any]: act = (action or "list").strip().lower() if act == "list": - from services.claude_suggest import get_ai_cli_status + from services.ai_cli import get_ai_cli_status return { "settings": list_settings(), "path": os.path.abspath(_env_path()), diff --git a/backend/services/integrations/youtube/learnings.py b/backend/services/integrations/youtube/learnings.py index 829c603..049746e 100644 --- a/backend/services/integrations/youtube/learnings.py +++ b/backend/services/integrations/youtube/learnings.py @@ -129,13 +129,11 @@ def write_semantic_learnings(top_n: int = 4, min_total: int = 6) -> Optional[str top_performers, underperformers = ranked[:top_n], ranked[-top_n:] try: - from services.claude_suggest import _find_ai_cli_candidates, _run_ai_command + from services import ai_provider except Exception: return None - candidates = _find_ai_cli_candidates() - if not candidates: + if not ai_provider.available(): return None - cli_path, engine = candidates[0] prompt = ( "You analyze short-form video performance to guide future clip selection.\n" @@ -146,22 +144,10 @@ def write_semantic_learnings(top_n: int = 4, min_total: int = 6) -> Optional[str "the top performers from the underperformers (hooks, topic, emotional beat, structure) and give " "actionable guidance for picking future shorts. No preamble, just the bullets." ) - os.makedirs(paths["working"], exist_ok=True) - prompt_file = os.path.join(paths["working"], "_perf_analysis_prompt.txt") - with open(prompt_file, "w", encoding="utf-8") as f: - f.write(prompt) - try: - res = _run_ai_command(cli_path, engine, prompt, prompt_file, paths["project_root"], timeout=180) - except Exception: - return None - finally: - try: - os.unlink(prompt_file) - except Exception: - pass - text = (res.stdout or "").strip() - if not text: + result = ai_provider.generate(prompt, timeout=180, project_dir=paths["project_root"]) + if not result.ok: return None + text = result.text now = datetime.now(timezone.utc).strftime("%Y-%m-%d") block = f"{AI_START}\n## What separates top performers (AI analysis · {now})\n\n{text}\n{AI_END}" return write_learnings(ai_block=block) diff --git a/backend/services/thumbnail_ai.py b/backend/services/thumbnail_ai.py index d9fb2fd..3bf2114 100644 --- a/backend/services/thumbnail_ai.py +++ b/backend/services/thumbnail_ai.py @@ -480,40 +480,15 @@ def _extract_json(text: str): def _ask_ai_for_json(prompt: str, timeout: int = 30): - """Run the first available AI CLI on `prompt`, returning the first JSON value - it emits, or None if no CLI is available or none returns parseable JSON.""" - from services.claude_suggest import _find_ai_cli_candidates, _run_ai_command - - candidates = _find_ai_cli_candidates() - if not candidates: - return None - - prompt_file = None - try: - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) - project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - for cli_path, engine in candidates: - try: - result = _run_ai_command( - cli_path=cli_path, engine=engine, prompt=prompt, - prompt_file=prompt_file, project_dir=project_dir, timeout=timeout, - ) - except Exception as e: - log_event("thumbnail-ai", "ai cli failed", level="warn", engine=engine, err=e) - continue - if result.returncode != 0 or not result.stdout.strip(): - continue - parsed = _extract_json(result.stdout) - if parsed is not None: - return parsed - finally: - if prompt_file: - try: - os.unlink(prompt_file) - except Exception: - pass - return None + """Run `prompt` through the AI provider chain, returning the first JSON value + it emits, or None if nothing is available or nothing returns parseable JSON.""" + from services import ai_provider + + parsed, result = ai_provider.generate_json(prompt, timeout=timeout) + if parsed is None: + log_event("thumbnail-ai", "ai request failed", level="warn", + err=result.error, tried=", ".join(result.attempts)) + return parsed def _thumbnail_kb_context() -> str: diff --git a/tests/test_ai_fallback.py b/tests/test_ai_fallback.py index f54a70f..cdc038b 100644 --- a/tests/test_ai_fallback.py +++ b/tests/test_ai_fallback.py @@ -12,6 +12,8 @@ if BACKEND_ROOT not in sys.path: sys.path.insert(0, BACKEND_ROOT) +from services import ai_cli as ai +from services import ai_provider as ap from services import claude_suggest as cs from services import content_generator as cg from services import thumbnail_ai as tai @@ -58,11 +60,11 @@ def test_suggest_with_claude_retries_with_codex_after_runtime_failure(self): }) with mock.patch.object( - cs, - "_find_ai_cli_candidates", - return_value=[("/tmp/claude", "claude"), ("/tmp/codex", "codex")], + ap, + "_chain", + return_value=[("cli", "/tmp/claude", "claude"), ("cli", "/tmp/codex", "codex")], ), mock.patch.object( - cs, + ai, "_run_ai_command", side_effect=[ subprocess.CompletedProcess(args=["claude"], returncode=1, stdout="", stderr="claude down"), @@ -218,11 +220,11 @@ def test_suggest_with_claude_reports_actual_timeout_limit(self): progress = [] with mock.patch.object( - cs, - "_find_ai_cli_candidates", - return_value=[("/tmp/claude", "claude")], + ap, + "_chain", + return_value=[("cli", "/tmp/claude", "claude")], ), mock.patch.object( - cs, + ai, "_run_ai_command", side_effect=subprocess.TimeoutExpired(cmd=["claude"], timeout=90), ): @@ -264,11 +266,11 @@ def test_generate_clip_content_retries_with_codex(self): #power #energy #datacenters #ai #infrastructure""" with mock.patch.object( - cg, - "_find_ai_cli_candidates", - return_value=[("/tmp/claude", "claude"), ("/tmp/codex", "codex")], + ap, + "_chain", + return_value=[("cli", "/tmp/claude", "claude"), ("cli", "/tmp/codex", "codex")], ), mock.patch.object( - cg, + ai, "_run_ai_command", side_effect=[ subprocess.CompletedProcess(args=["claude"], returncode=1, stdout="", stderr="claude down"), @@ -300,11 +302,11 @@ def test_thumbnail_layout_retries_with_codex(self): """ with mock.patch.object( - cs, - "_find_ai_cli_candidates", - return_value=[("/tmp/claude", "claude"), ("/tmp/codex", "codex")], + ap, + "_chain", + return_value=[("cli", "/tmp/claude", "claude"), ("cli", "/tmp/codex", "codex")], ), mock.patch.object( - cs, + ai, "_run_ai_command", side_effect=[ subprocess.CompletedProcess(args=["claude"], returncode=1, stdout="", stderr="claude down"), @@ -329,8 +331,8 @@ def test_find_cli_resolves_windows_cmd_shim(self): shim = os.path.join(tmp, "claude.cmd") with open(shim, "w", encoding="utf-8") as fh: fh.write("@echo off\n") - with mock.patch.object(cs.sys, "platform", "win32"): - found = cs._find_cli("claude", [os.path.join(tmp, "claude")]) + with mock.patch.object(ai.sys, "platform", "win32"): + found = ai._find_cli("claude", [os.path.join(tmp, "claude")]) self.assertEqual(found, shim) @unittest.skipIf(os.name == "nt", "POSIX executable discovery; Windows uses .cmd/.exe shims") @@ -343,7 +345,7 @@ def test_find_cli_uses_home_bin(self): fh.write("#!/bin/sh\n") with mock.patch.dict(os.environ, {"HOME": home, "PATH": ""}, clear=False): with mock.patch("os.path.expanduser", side_effect=lambda p: p.replace("~", home)): - found = cs._find_cli("claude", []) + found = ai._find_cli("claude", []) self.assertEqual(found, cli) @unittest.skipIf(os.name == "nt", "POSIX executable discovery; Windows uses .cmd/.exe shims") @@ -359,16 +361,16 @@ def test_npmrc_prefix_is_searched(self): fh.write(f"prefix={prefix}\n") with mock.patch.dict(os.environ, {"HOME": home, "PATH": ""}, clear=False): with mock.patch("os.path.expanduser", side_effect=lambda p: p.replace("~", home)): - with mock.patch.object(cs, "_package_manager_bin_dirs", return_value=[]): - with mock.patch.object(cs, "_shell_lookup", return_value=None): - found = cs._find_cli("claude", []) + with mock.patch.object(ai, "_package_manager_bin_dirs", return_value=[]): + with mock.patch.object(ai, "_shell_lookup", return_value=None): + found = ai._find_cli("claude", []) self.assertEqual(found, cli) def test_parse_shell_lookup_line_handles_type_a(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: path = tmp.name try: - self.assertEqual(cs._parse_shell_lookup_line(f"claude is {path}"), path) + self.assertEqual(ai._parse_shell_lookup_line(f"claude is {path}"), path) finally: os.remove(path) @@ -381,7 +383,7 @@ def test_find_cli_uses_legacy_claude_local_path(self): fh.write("#!/bin/sh\n") with mock.patch.dict(os.environ, {"HOME": home, "PATH": ""}, clear=False): with mock.patch("os.path.expanduser", side_effect=lambda p: p.replace("~", home)): - found = cs._find_cli("claude", cs._ai_cli_search_paths("claude")) + found = ai._find_cli("claude", ai._ai_cli_search_paths("claude")) self.assertEqual(found, cli) def test_env_override_prefers_podcli_claude_path(self): @@ -390,8 +392,8 @@ def test_env_override_prefers_podcli_claude_path(self): with open(cli, "w", encoding="utf-8") as fh: fh.write("#!/bin/sh\n") with mock.patch.dict(os.environ, {"PODCLI_CLAUDE_PATH": cli, "PATH": ""}, clear=False): - with mock.patch.object(cs, "_find_cli", return_value=None) as find_mock: - candidates = cs._find_ai_cli_candidates() + with mock.patch.object(ai, "_find_cli", return_value=None) as find_mock: + candidates = ai._find_ai_cli_candidates() find_mock.assert_called_once() self.assertEqual(find_mock.call_args.args[0], "codex") self.assertEqual(candidates[0], (cli, "claude")) @@ -406,7 +408,7 @@ def test_configured_path_reads_from_env_file(self): fh.write(f"PODCLI_CLAUDE_PATH={cli}\n") with mock.patch.dict(os.environ, {"PODCLI_ENV_FILE": env_file, "PATH": ""}, clear=False): os.environ.pop("PODCLI_CLAUDE_PATH", None) - found = cs._configured_cli_path("claude") + found = ai._configured_cli_path("claude") self.assertEqual(found, cli) def test_find_cli_falls_back_to_shell_lookup(self): @@ -414,9 +416,9 @@ def test_find_cli_falls_back_to_shell_lookup(self): cli = os.path.join(tmp, "claude") with open(cli, "w", encoding="utf-8") as fh: fh.write("#!/bin/sh\n") - with mock.patch.object(cs, "_shell_lookup", return_value=cli): + with mock.patch.object(ai, "_shell_lookup", return_value=cli): with mock.patch("shutil.which", return_value=None): - found = cs._find_cli("claude", []) + found = ai._find_cli("claude", []) self.assertEqual(found, cli) def test_get_ai_cli_status_reports_candidates(self): @@ -424,8 +426,8 @@ def test_get_ai_cli_status_reports_candidates(self): cs, "_find_ai_cli_candidates", return_value=[("/tmp/claude", "claude")], - ), mock.patch.object(cs, "_configured_cli_path", return_value=None): - status = cs.get_ai_cli_status() + ), mock.patch.object(ai, "_configured_cli_path", return_value=None): + status = ai.get_ai_cli_status() self.assertTrue(status["available"]) self.assertEqual(status["candidates"][0]["engine"], "claude") with tempfile.TemporaryDirectory() as tmp: @@ -436,14 +438,14 @@ def test_get_ai_cli_status_reports_candidates(self): with open(cli, "w", encoding="utf-8") as fh: fh.write("#!/bin/sh\n") - with mock.patch("services.claude_suggest.subprocess.run") as run_mock: + with mock.patch("services.ai_cli.subprocess.run") as run_mock: run_mock.return_value = subprocess.CompletedProcess( args=[cli, "--print", "-p", "-"], returncode=0, stdout="{}", stderr="", ) - cs._run_ai_command( + ai._run_ai_command( cli_path=cli, engine="claude", prompt="find clips", diff --git a/tests/test_find_moments.py b/tests/test_find_moments.py index b1d1fb2..a4f0ef3 100644 --- a/tests/test_find_moments.py +++ b/tests/test_find_moments.py @@ -11,6 +11,8 @@ if BACKEND_ROOT not in sys.path: sys.path.insert(0, BACKEND_ROOT) +from services import ai_cli +from services import ai_provider from services import claude_suggest as cs SEGMENTS = [ @@ -39,14 +41,14 @@ def _fake_run(**kwargs): class FindMomentsTests(unittest.TestCase): def setUp(self): - self._orig_candidates = cs._find_ai_cli_candidates - self._orig_run = cs._run_ai_command - cs._find_ai_cli_candidates = lambda: [("/usr/bin/claude", "claude")] - cs._run_ai_command = lambda **kw: _fake_run(**kw) + self._orig_chain = ai_provider._chain + self._orig_run = ai_cli._run_ai_command + ai_provider._chain = lambda: [("cli", "/usr/bin/claude", "claude")] + ai_cli._run_ai_command = lambda **kw: _fake_run(**kw) def tearDown(self): - cs._find_ai_cli_candidates = self._orig_candidates - cs._run_ai_command = self._orig_run + ai_provider._chain = self._orig_chain + ai_cli._run_ai_command = self._orig_run def test_finds_and_shapes_moment(self): clips = cs.find_moments_from_text("the turning point", SEGMENTS, []) @@ -60,7 +62,7 @@ def test_finds_and_shapes_moment(self): self.assertGreater(c["score"], 0) def test_no_ai_cli_returns_empty(self): - cs._find_ai_cli_candidates = lambda: [] + ai_provider._chain = lambda: [] self.assertEqual(cs.find_moments_from_text("x", SEGMENTS, []), []) def test_progress_callback_invoked(self): From b7252956f5370a129cd32a8063614366c486588b Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sat, 8 Aug 2026 16:23:14 +0400 Subject: [PATCH 2/7] Add optional remote sync for clips, assets and knowledge --- .gitignore | 3 + backend/cli.py | 184 +++++++++++-- backend/services/podcli_cloud.py | 331 +++++++++++++++++++++++ cli/internal/engine/engine.go | 28 ++ cli/main.go | 9 + scripts/build-studio.sh | 4 +- src/models/index.ts | 8 +- src/services/asset-sync.test.ts | 72 +++++ src/services/asset-sync.ts | 148 ++++++++++ src/services/clips-history-cloud.test.ts | 100 +++++++ src/services/clips-history.ts | 123 ++++++++- src/services/knowledge-sync.test.ts | 99 +++++++ src/services/knowledge-sync.ts | 148 ++++++++++ src/services/podcli-cloud.ts | 287 ++++++++++++++++++++ src/sync.ts | 86 ++++++ src/ui/client/AccountChip.tsx | 45 +++ src/ui/client/AiSetup.tsx | 135 +++++++++ src/ui/client/AnalyticsPage.tsx | 3 + src/ui/client/ConfigPage.tsx | 3 + src/ui/client/Layout.tsx | 3 + src/ui/client/WorkspaceInsights.tsx | 115 ++++++++ src/ui/public/css/styles.css | 9 + src/ui/web-server.ts | 50 ++++ tests/test_entitlement_chain.py | 80 ++++++ 24 files changed, 2052 insertions(+), 21 deletions(-) create mode 100644 backend/services/podcli_cloud.py create mode 100644 src/services/asset-sync.test.ts create mode 100644 src/services/asset-sync.ts create mode 100644 src/services/clips-history-cloud.test.ts create mode 100644 src/services/knowledge-sync.test.ts create mode 100644 src/services/knowledge-sync.ts create mode 100644 src/services/podcli-cloud.ts create mode 100644 src/sync.ts create mode 100644 src/ui/client/AccountChip.tsx create mode 100644 src/ui/client/AiSetup.tsx create mode 100644 src/ui/client/WorkspaceInsights.tsx create mode 100644 tests/test_entitlement_chain.py diff --git a/.gitignore b/.gitignore index 7f3afdd..c87914f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ tmp*.txt # Episodes (generated content packages) episodes/ +# Planning notes +plans/ + # Media & temp files *.mp4 *.mp3 diff --git a/backend/cli.py b/backend/cli.py index dc9f67d..01403d2 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -920,15 +920,15 @@ def _transcribe_progress(pct, msg): print(" ⚠ No highlights found, falling back to transcript selection") # Try an AI CLI first (uses PodStack knowledge base for intelligent selection) - from services.claude_suggest import ( - suggest_initial_with_claude, blend_signal_scores, _engine_label, _find_ai_cli, - ) + from services import ai_provider + from services.ai_cli import _engine_label + from services.claude_suggest import blend_signal_scores, suggest_initial_with_claude - ai_path, ai_engine = _find_ai_cli() + providers = ai_provider.status()["providers"] if clips: pass # already selected (resumed cache or saliency profile) - elif ai_path and config.get("ai_select", True): - ai_label = _engine_label(ai_engine) + elif providers and config.get("ai_select", True): + ai_label = providers[0]["label"] print(f" [3/4] Selecting moments with {ai_label} (PodStack)...") clips = suggest_initial_with_claude( segments=segments, @@ -938,8 +938,9 @@ def _transcribe_progress(pct, msg): ) if clips: blend_signal_scores(clips, energy_data=energy_data, events_data=events_data) - actual_engine = next((c.get("_ai_engine") for c in clips if c.get("_ai_engine")), ai_engine) - print(f" ✓ {_engine_label(actual_engine)} selected {len(clips)} clips") + engine_id = next((c.get("_ai_engine") for c in clips if c.get("_ai_engine")), "") + actual_engine = _engine_label(engine_id) if engine_id in ("claude", "codex") else ai_label + print(f" ✓ {actual_engine} selected {len(clips)} clips") _save_suggestions_session(cache_hash, top_n, actual_engine, clips, selection_sig) else: print(" ⚠ AI CLI unavailable, falling back to heuristics") @@ -1006,9 +1007,9 @@ def _transcribe_progress(pct, msg): pass _thumb_intro_duration = max(0.5, min(_thumb_intro_duration, 1.0)) - # Check if AI CLI is available for per-clip content generation - from services.claude_suggest import _find_ai_cli - _ai_cli_path, _ = _find_ai_cli() + # Per-clip content generation needs any provider, not specifically a binary. + from services import ai_provider + _ai_cli_path = "cloud" if ai_provider.available() else None # Pre-load thumbnail tools if enabled _thumb_gen = None @@ -3491,9 +3492,10 @@ def print_banner(): _diarization_ok = False speakers_ok = bool(hf_token) and _diarization_ok - # Check AI CLI (Claude Code or Codex) - from services.claude_suggest import _find_ai_cli - ai_path, ai_engine = _find_ai_cli() + # `info` should report what AI podcli will actually use, which for a + # signed-in user is the workspace rather than any local binary. + from services import ai_provider + _providers = ai_provider.status()["providers"] print(f" {bold}podcli{reset} v{VERSION}") @@ -3504,8 +3506,8 @@ def print_banner(): cache_count = len([f for f in os.listdir(cache_dir) if f.endswith(".json")]) # Status — one line - ai_label = ("Claude" if ai_engine == "claude" else "Codex") if ai_path else "AI CLI" - ai_tag = f"{green}✓ {ai_label}{reset}" if ai_path else f"{yellow}✗{reset}" + ai_label = _providers[0]["label"] if _providers else "AI" + ai_tag = f"{green}✓ {ai_label}{reset}" if _providers else f"{yellow}✗{reset}" speaker_tag = f"{green}✓{reset}" if speakers_ok else f"{yellow}✗{reset}" cache_tag = f"{green}{cache_count}{reset}" if cache_count else f"{gray}0{reset}" kb_tag = f"{green}{kb_count}{reset}" if kb_count else f"{yellow}0{reset}" @@ -3633,6 +3635,132 @@ def print_help(): print() +def cmd_login(args): + import getpass + from services import podcli_cloud + + email = args.email or input("Email: ").strip() + # Prefer the prompt: a password in argv is visible in ps output and lands in + # the user's shell history. + password = args.password or getpass.getpass("Password: ") + if not email or not password: + print("Email and password are required.") + sys.exit(1) + + try: + podcli_cloud.login(email, password) + account = podcli_cloud.me() + podcli_cloud.remember_plan(account.get("plan", "")) + except podcli_cloud.CloudError as exc: + print(f"Sign-in failed: {exc}") + sys.exit(1) + + workspace = account.get("workspace") or {} + print(f"Signed in to {workspace.get('name', 'your workspace')} " + f"({account.get('plan', 'free')} plan, {account.get('role', 'member')}).") + if account.get("plan") == "free": + print("This workspace has no active subscription — podcli will keep using " + "your local AI CLI until one starts.") + + # Everything already rendered on this machine belongs in the workspace too, + # so the performance model starts with a back catalogue instead of nothing. + try: + synced, failed = podcli_cloud.backfill_clips() + except Exception: + synced, failed = 0, 0 + if synced: + print(f"Synced {synced} existing clip{'s' if synced != 1 else ''} to your workspace.") + if failed: + print(f"{failed} could not be synced — `podcli whoami` will retry later.") + + +def cmd_logout(args): + from services import podcli_cloud + + if not podcli_cloud.signed_in(): + print("Not signed in.") + return + podcli_cloud.clear_token() + print("Signed out. podcli will use your local AI CLI from now on.") + + +def cmd_whoami(args): + from services import ai_provider, podcli_cloud + + if not podcli_cloud.signed_in(): + print("Not signed in to podcli Pro. Run `podcli login`.") + else: + try: + account = podcli_cloud.me() + podcli_cloud.remember_plan(account.get("plan", "")) + workspace = account.get("workspace") or {} + print(f"Signed in to {workspace.get('name', '?')} " + f"({account.get('plan')} plan, {account.get('role')})") + used = workspace.get("episodes_used") + if used is not None: + print(f"Episodes used this month: {used}") + except podcli_cloud.CloudError as exc: + print(f"Signed in, but the account could not be checked: {exc}") + + providers = ai_provider.status()["providers"] + if providers: + print("AI will use: " + " → ".join(p["label"] for p in providers)) + else: + print("No AI available. Install Claude Code, set ANTHROPIC_API_KEY, or sign in.") + + +def cmd_workspace(args): + from services import podcli_cloud + + if not podcli_cloud.signed_in(): + print("Not signed in to podcli Pro. Run `podcli login`.") + sys.exit(1) + + action = getattr(args, "workspace_action", None) or "list" + try: + if action == "new": + created = podcli_cloud.create_workspace(args.name) + print(f"Created {created['name']} and switched to it (free plan).") + print("Each show carries its own subscription, so this one needs its own.") + _warn_local_data() + return + + workspaces = podcli_cloud.list_workspaces() + + if action == "use": + target = next( + (w for w in workspaces + if args.name.lower() in (w["name"].lower(), w["id"].lower())), + None, + ) + if not target: + print(f"No workspace matching {args.name!r}.") + sys.exit(1) + switched = podcli_cloud.switch_workspace(target["id"]) + print(f"Switched to {switched['name']} ({switched['plan']} plan).") + _warn_local_data() + return + + for w in workspaces: + marker = "*" if w.get("current") else " " + print(f" {marker} {w['name']} ({w['plan']}, {w['role']})") + except podcli_cloud.CloudError as exc: + print(f"Could not reach podcli Pro: {exc}") + sys.exit(1) + + +def _warn_local_data(): + """Switching workspace does not move the local knowledge base or assets. + + Those live in .podcli/ on this machine, and a second show's brand voice + overwriting the first is data loss rather than a sync. Keeping each show in + its own directory (or PODCLI_HOME) is the honest answer until profiles do it + automatically. + """ + print() + print(" Local .podcli/ data is per-directory, not per-workspace.") + print(" Work on each show from its own folder so their knowledge bases " + "and assets stay separate.") def _onboarding_marker() -> str: return os.path.join(paths["home"], ".onboarded") @@ -3824,6 +3952,20 @@ def main(): parser.add_argument("--no-banner", action="store_true", help=argparse.SUPPRESS) sub = parser.add_subparsers(dest="command") + # ── podcli Pro account ── + login_p = sub.add_parser("login", help="Sign in to podcli Pro") + login_p.add_argument("--email", help="Account email (prompted if omitted)") + login_p.add_argument("--password", help="Password (prompted if omitted; prefer the prompt)") + sub.add_parser("logout", help="Sign out of podcli Pro on this machine") + sub.add_parser("whoami", help="Show the signed-in podcli Pro account") + ws_p = sub.add_parser("workspace", help="Switch between shows in podcli Pro") + ws_sub = ws_p.add_subparsers(dest="workspace_action") + ws_sub.add_parser("list", help="List your workspaces") + ws_new = ws_sub.add_parser("new", help="Create a workspace for another show") + ws_new.add_argument("name", help="Workspace name") + ws_use = ws_sub.add_parser("use", help="Switch to a workspace") + ws_use.add_argument("name", help="Workspace name or id") + # ── process ── proc = sub.add_parser("process", help="Process a video into clips") proc.add_argument("video", nargs="?", default=None, help="Path to podcast video file (optional if preset has video_path)") @@ -4160,7 +4302,15 @@ def main(): print(" Setup cancelled. Your command did not run.", file=sys.stderr) sys.exit(130) - if args.command == "process": + if args.command == "login": + cmd_login(args) + elif args.command == "logout": + cmd_logout(args) + elif args.command == "whoami": + cmd_whoami(args) + elif args.command == "workspace": + cmd_workspace(args) + elif args.command == "process": if not getattr(args, "no_banner", False): print() cmd_process(args) diff --git a/backend/services/podcli_cloud.py b/backend/services/podcli_cloud.py new file mode 100644 index 0000000..666b9ee --- /dev/null +++ b/backend/services/podcli_cloud.py @@ -0,0 +1,331 @@ +"""Client for podcli Pro's hosted API. + +This module is the whole of Pro that lives in the open source app: where the +token is kept, how it is sent, and what shape the request takes. There is no +secret here and nothing to crack — the server decides entitlement, so a patched +client gets a UI that says Pro and an HTTP 401. +""" + +from __future__ import annotations + +import json +import os +import time +import urllib.error +import urllib.request +from typing import Any, Optional + +from config.paths import paths + +DEFAULT_API_URL = "https://api.podcli.com" +AUTH_FILENAME = "auth.json" + + +def api_url() -> str: + return (os.environ.get("PODCLI_API_URL") or DEFAULT_API_URL).rstrip("/") + + +def _auth_path() -> str: + return os.path.join(paths["home"], AUTH_FILENAME) + + +def read_token() -> Optional[str]: + """The session token, from the environment or the file `podcli login` wrote.""" + env = (os.environ.get("PODCLI_TOKEN") or "").strip() + if env: + return env + try: + with open(_auth_path(), encoding="utf-8") as fh: + token = (json.load(fh).get("token") or "").strip() + return token or None + except (OSError, ValueError): + return None + + +def _auth_data() -> dict: + try: + with open(_auth_path(), encoding="utf-8") as fh: + return json.load(fh) or {} + except (OSError, ValueError): + return {} + + +def _write_auth(data: dict) -> None: + os.makedirs(paths["home"], exist_ok=True) + path = _auth_path() + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh) + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def write_token(token: str, workspace_id: str = "") -> None: + _write_auth({"token": token, "workspace_id": workspace_id}) + + +PAID_PLANS = ("pro", "team", "studio", "agency") +PLAN_TTL_SECONDS = 6 * 3600 + + +def remember_plan(plan: str) -> None: + """Cache what the server last said this workspace is entitled to.""" + data = _auth_data() + if not data.get("token"): + return + data["plan"] = (plan or "").strip().lower() + data["plan_checked_at"] = time.time() + _write_auth(data) + + +def entitled() -> bool: + """ + False only when the workspace is known to have no subscription. + + Unknown and stale both mean "try": a subscription bought a minute ago has to + work without signing out first, and the server is the only real authority. + """ + data = _auth_data() + plan = (data.get("plan") or "").strip().lower() + if not plan: + return True + if time.time() - float(data.get("plan_checked_at") or 0) > PLAN_TTL_SECONDS: + return True + return plan in PAID_PLANS + + +def clear_token() -> None: + try: + os.unlink(_auth_path()) + except OSError: + pass + + +def signed_in() -> bool: + return read_token() is not None + + +class CloudError(Exception): + def __init__(self, message: str, status: int = 0, retryable: bool = False): + super().__init__(message) + self.status = status + self.retryable = retryable + + +def request(method: str, path: str, body: Optional[dict] = None, + timeout: int = 300) -> Any: + token = read_token() + if not token: + raise CloudError("not signed in — run `podcli login`", status=401) + + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request( + f"{api_url()}{path}", + data=data, + method=method, + headers={ + "authorization": f"Bearer {token}", + **({"content-type": "application/json"} if data else {}), + }, + ) + + try: + with urllib.request.urlopen(req, timeout=timeout) as response: + raw = response.read().decode("utf-8") + return json.loads(raw) if raw else None + except urllib.error.HTTPError as exc: + detail, retryable = _describe(exc) + raise CloudError(detail, status=exc.code, retryable=retryable) from None + except urllib.error.URLError as exc: + raise CloudError(f"could not reach {api_url()}: {exc.reason}", + retryable=True) from None + + +def _describe(exc: urllib.error.HTTPError) -> tuple[str, bool]: + """Turn an HTTP failure into something a user can act on.""" + payload: dict = {} + try: + payload = json.loads(exc.read().decode("utf-8")) + except Exception: + pass + detail = payload.get("error") + if isinstance(detail, list): + detail = "; ".join(str(item.get("message", item)) for item in detail) + + if exc.code == 401: + return ("podcli Pro session expired — run `podcli login` again", False) + if exc.code == 402: + return ("this workspace has no active podcli Pro subscription", False) + if exc.code == 403: + return (detail or "your role does not allow this", False) + if exc.code == 429: + used, cap = payload.get("used"), payload.get("cap") + if used is not None and cap is not None: + return (f"monthly limit reached ({used}/{cap} episodes)", False) + return (detail or "rate limited, try again shortly", True) + if exc.code >= 500 or exc.code == 503: + return (detail or "podcli Pro is temporarily unavailable", True) + return (detail or f"podcli Pro returned HTTP {exc.code}", False) + + +def generate(purpose: str, instruction: str, *, system: Optional[str] = None, + cached_context: Optional[str] = None, + episode_source_hash: Optional[str] = None, + max_tokens: int = 16000, timeout: int = 300) -> dict: + body: dict[str, Any] = { + "purpose": purpose, + "instruction": instruction, + "maxTokens": max_tokens, + } + if system: + body["system"] = system + if cached_context: + body["cachedContext"] = cached_context + if episode_source_hash: + body["episodeSourceHash"] = episode_source_hash + return request("POST", "/v1/ai/generate", body, timeout=timeout) + + +def source_hash(video_path: str) -> Optional[str]: + """Identify an episode across machines. + + Must stay byte-identical to the TypeScript implementation in + src/services/podcli-cloud.ts — the two clients hash the same file and the + server dedupes episodes on the result, so any divergence silently splits one + episode into two. First 8 MB only: distinctive enough, and digesting a 2 GB + master on every render is not. + """ + import hashlib + + digest = hashlib.sha256() + remaining = 8 * 1024 * 1024 + try: + with open(video_path, "rb") as fh: + while remaining > 0: + chunk = fh.read(min(1024 * 1024, remaining)) + if not chunk: + break + digest.update(chunk) + remaining -= len(chunk) + except OSError: + return None + return digest.hexdigest()[:32] + + +def register_clip(clip: dict) -> Optional[dict]: + return request("POST", "/v1/clips", clip, timeout=60) + + +def backfill_clips(limit: int = 200) -> tuple[int, int]: + """Push locally-recorded clips that never reached the workspace. + + Runs at sign-in so a new subscriber's back catalogue is behind the + performance model from their first session, rather than the model starting + empty and staying useless for months. + """ + from services.clips_history import load_clips_history, update_clip + + synced = failed = 0 + for entry in load_clips_history(): + if synced + failed >= limit: + break + if entry.get("cloud_id"): + continue + source = entry.get("source_video") + if not source or not os.path.exists(source): + continue + + digest = source_hash(source) + if not digest: + failed += 1 + continue + + try: + result = register_clip({ + "sourceHash": digest, + "episodeTitle": os.path.basename(source), + "title": entry.get("title"), + "startSecond": entry.get("start_second"), + "endSecond": entry.get("end_second"), + "durationSec": entry.get("duration"), + "contentType": entry.get("content_type"), + "captionStyle": entry.get("caption_style"), + "aspectRatio": entry.get("format"), + "transcriptSlice": entry.get("transcript_slice"), + }) + except CloudError: + failed += 1 + continue + + if result and result.get("id"): + update_clip(entry["id"], cloud_id=result["id"], cloud_synced=True) + synced += 1 + else: + failed += 1 + + return synced, failed + + +def prompt_block() -> str: + """What this workspace has learned, phrased for the selection prompt. + + Rendered server-side rather than assembled here, so improving how a + workspace's history is presented to the model is a deploy rather than + something that waits for every user to upgrade their CLI. + + Short timeout and silent on failure: better clips are the point, but not at + the cost of blocking a suggestion run behind a slow network. + """ + if not signed_in(): + return "" + try: + payload = request("GET", "/v1/insights/prompt-block", timeout=10) + except CloudError: + return "" + return (payload or {}).get("block") or "" + + +def list_workspaces() -> list[dict]: + return (request("GET", "/v1/workspaces", timeout=30) or {}).get("workspaces", []) + + +def create_workspace(name: str) -> dict: + payload = request("POST", "/v1/workspaces", {"name": name}, timeout=30) + write_token(payload["token"], payload["id"]) + return payload + + +def switch_workspace(workspace_id: str) -> dict: + """Switching means a new session, not a mutable field on the old one. + + Tenancy is decided once, at authentication, from the session's workspace — + so a token can never be pointed at a workspace it was not issued for. + """ + payload = request("POST", f"/v1/workspaces/{workspace_id}/session", {}, timeout=30) + write_token(payload["token"], payload["workspaceId"]) + return payload + + +def me() -> dict: + return request("GET", "/v1/auth/me", timeout=30) + + +def login(email: str, password: str) -> dict: + """Exchange credentials for a session token. Does not require an existing one.""" + data = json.dumps({"email": email, "password": password}).encode("utf-8") + req = urllib.request.Request( + f"{api_url()}/v1/auth/login", data=data, method="POST", + headers={"content-type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=30) as response: + payload = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail, _ = _describe(exc) + raise CloudError(detail, status=exc.code) from None + except urllib.error.URLError as exc: + raise CloudError(f"could not reach {api_url()}: {exc.reason}") from None + + write_token(payload["token"], payload.get("workspaceId", "")) + return payload diff --git a/cli/internal/engine/engine.go b/cli/internal/engine/engine.go index 722be67..d6200a3 100644 --- a/cli/internal/engine/engine.go +++ b/cli/internal/engine/engine.go @@ -151,6 +151,34 @@ func MCPServer() string { return "" } +func SyncScript() string { + p := filepath.Join(paths.RuntimeDir(), "studio", "sync.mjs") + if exists(p) { + return p + } + return "" +} + +// RunSync reconciles this machine with the podcli Pro workspace. Ships with the +// studio bundle because the sync logic lives on the TypeScript side, alongside +// the clip history and asset registry it reconciles. +func RunSync() (int, error) { + node, script := Node(), SyncScript() + if node == "" || script == "" { + return 1, fmt.Errorf("sync not provisioned — run `podcli setup`") + } + cmd := exec.Command(node, script) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + cmd.Env = nodeEnv() + if err := cmd.Run(); err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return ee.ExitCode(), nil + } + return 1, err + } + return 0, nil +} + // nodeEnv builds the env a bundled Node server (studio/MCP) needs: the TS // paths.ts reads these names (note PYTHON_PATH/FFMPEG_PATH differ from the // PODCLI_* names the Python side uses). Project data stays cwd-local. diff --git a/cli/main.go b/cli/main.go index a6ba4ef..304311e 100644 --- a/cli/main.go +++ b/cli/main.go @@ -54,6 +54,12 @@ func main() { fmt.Fprintln(os.Stderr, "podcli:", err) } os.Exit(code) + case "sync": + code, err := engine.RunSync() + if err != nil { + fmt.Fprintln(os.Stderr, "podcli:", err) + } + os.Exit(code) case "config": if len(args) >= 2 && (args[1] == "get" || args[1] == "set") { os.Exit(configCmd(args[1:])) @@ -772,6 +778,9 @@ PodStack commands (run inside Claude Code / Codex): retro-episode Add --codex / --claude to pick the agent Launcher commands: + login | logout | whoami + podcli Pro account on this machine + sync Reconcile clips, assets, and knowledge with your workspace doctor Show resolved paths, interpreter, backend, ffmpeg, models version Print version update Check for and apply a newer release diff --git a/scripts/build-studio.sh b/scripts/build-studio.sh index 19f4866..1f6507a 100644 --- a/scripts/build-studio.sh +++ b/scripts/build-studio.sh @@ -22,4 +22,6 @@ node -e "require('esbuild').buildSync({entryPoints:['dist/ui/web-server.js'],bun cp -r dist/ui/public "$out/public" # MCP stdio server (the mcp__podcli__* tools Claude/Codex drive). node -e "require('esbuild').buildSync({entryPoints:['dist/index.js'],bundle:true,platform:'node',format:'esm',outfile:'$out/mcp-server.mjs',banner:{js:\"$banner\"},logLevel:'error'})" -echo "studio + mcp bundle -> $out" +# `podcli sync` — reconciles clips, assets, and knowledge with a Pro workspace. +node -e "require('esbuild').buildSync({entryPoints:['dist/sync.js'],bundle:true,platform:'node',format:'esm',outfile:'$out/sync.mjs',banner:{js:\"$banner\"},logLevel:'error'})" +echo "studio + mcp + sync bundle -> $out" diff --git a/src/models/index.ts b/src/models/index.ts index 64054e3..908dfaf 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -2,7 +2,7 @@ export interface TaskRequest { task_id: string; - task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "detect_highlights" | "manage_reel" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "generate_custom" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status"; + task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "detect_highlights" | "manage_reel" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "generate_custom" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status" | "ai_provider_status"; params: Record; } @@ -277,6 +277,12 @@ export interface ClipHistoryEntry { description?: string; tags?: string; hashtags?: string; + // Set for signed-in users once the clip is mirrored to the workspace. A false + // cloud_synced marks a clip a later sweep should backfill; the local file + // stays the source of truth either way. + cloud_id?: string; + cloud_synced?: boolean; + cloud_video_uploaded?: boolean; } // === Knowledge Base Models === diff --git a/src/services/asset-sync.test.ts b/src/services/asset-sync.test.ts new file mode 100644 index 0000000..3332256 --- /dev/null +++ b/src/services/asset-sync.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createHash } from "crypto"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const tmp = mkdtempSync(join(tmpdir(), "podcli-assetsync-test-")); +process.env.PODCLI_HOME = tmp; +process.env.PODCLI_DATA = tmp; + +const digest = (body: string) => + createHash("sha256").update(Buffer.from(body)).digest("hex").slice(0, 32); + +vi.mock("./podcli-cloud.js", () => ({ + signedIn: vi.fn(async () => true), + listAssets: vi.fn(async () => []), + uploadAsset: vi.fn(async () => ({ unchanged: false })), + checksum: (body: Buffer) => + createHash("sha256").update(body).digest("hex").slice(0, 32), +})); + +const cloud = await import("./podcli-cloud.js"); +const { push } = await import("./asset-sync.js"); +const { AssetManager } = await import("./asset-manager.js"); + +const intro = join(tmp, "intro.mp4"); +let assetName = ""; + +describe("asset push", () => { + beforeEach(async () => { + rmSync(join(tmp, "assets"), { recursive: true, force: true }); + mkdirSync(join(tmp, "assets"), { recursive: true }); + writeFileSync(intro, "intro bytes"); + vi.clearAllMocks(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + assetName = (await new AssetManager().register("Show intro", intro, "intro")).name; + }); + + it("does not re-upload an asset the workspace already holds", async () => { + vi.mocked(cloud.listAssets).mockResolvedValue([ + { id: "1", name: assetName, kind: "intro", is_default: false, + size_bytes: "11", checksum: digest("intro bytes") }, + ]); + + const report = await push(); + + expect(cloud.uploadAsset).not.toHaveBeenCalled(); + expect(report.skipped).toContain(assetName); + expect(report.uploaded).toEqual([]); + }); + + it("uploads when the local file has changed", async () => { + vi.mocked(cloud.listAssets).mockResolvedValue([ + { id: "1", name: assetName, kind: "intro", is_default: false, + size_bytes: "11", checksum: digest("something else") }, + ]); + + const report = await push(); + + expect(cloud.uploadAsset).toHaveBeenCalledTimes(1); + expect(report.uploaded).toContain(assetName); + }); + + it("still uploads when the listing cannot be read", async () => { + vi.mocked(cloud.listAssets).mockRejectedValue(new Error("offline")); + + const report = await push(); + + expect(cloud.uploadAsset).toHaveBeenCalledTimes(1); + expect(report.failed).toEqual([]); + }); +}); diff --git a/src/services/asset-sync.ts b/src/services/asset-sync.ts new file mode 100644 index 0000000..7e79388 --- /dev/null +++ b/src/services/asset-sync.ts @@ -0,0 +1,148 @@ +import { existsSync } from "fs"; +import { mkdir, readFile, writeFile } from "fs/promises"; +import { basename, join } from "path"; +import { paths } from "../config/paths.js"; +import { AssetManager, inferType } from "./asset-manager.js"; +import * as cloud from "./podcli-cloud.js"; +import type { Asset, AssetType } from "../models/index.js"; + +/** + * Two-way sync between .podcli/assets/ and the workspace asset library. + * + * Local assets keep working untouched for everyone; this only runs for + * signed-in users. The point is that a second machine, or a teammate, gets the + * show's logo and outro without anyone emailing files around. + */ + +const SYNCABLE_KINDS: Record = { + logo: "logo", + intro: "intro", + outro: "outro", + music: "music", +} as Record; + +function cloudKind(type: AssetType): string { + return SYNCABLE_KINDS[type] ?? "other"; +} + +export type SyncReport = { + uploaded: string[]; + downloaded: string[]; + skipped: string[]; + failed: Array<{ name: string; reason: string }>; +}; + +const empty = (): SyncReport => ({ uploaded: [], downloaded: [], skipped: [], failed: [] }); + +/** + * Push local assets the workspace doesn't have. + * + * The server discards an upload whose checksum it already holds, but only after + * receiving it. Comparing first keeps a 200 MB intro off the wire on every + * sync; if the listing cannot be fetched, everything is uploaded as before. + */ +export async function push(): Promise { + const report = empty(); + if (!(await cloud.signedIn())) return report; + + const manager = new AssetManager(); + const registry = await manager.load(); + + let held = new Map(); + try { + held = new Map((await cloud.listAssets()).map((a) => [a.name, a.checksum])); + } catch { + // Fall through: an unreadable listing must not stop the push. + } + + for (const asset of registry.assets) { + if (!existsSync(asset.path)) { + report.skipped.push(asset.name); + continue; + } + try { + const body = await readFile(asset.path); + if (held.get(asset.name) === cloud.checksum(body)) { + report.skipped.push(asset.name); + continue; + } + const result = await cloud.uploadAsset( + asset.name, + cloudKind(asset.type), + body, + Boolean(asset.default), + ); + if (result?.unchanged) report.skipped.push(asset.name); + else report.uploaded.push(asset.name); + } catch (err) { + report.failed.push({ + name: asset.name, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + return report; +} + +/** + * Pull workspace assets this machine is missing. + * + * Files land in .podcli/assets/ and are registered locally, so every existing + * code path — rendering, presets, the studio — finds them exactly where it + * already looks. Nothing downstream needs to know they came from a server. + */ +export async function pull(): Promise { + const report = empty(); + if (!(await cloud.signedIn())) return report; + + const manager = new AssetManager(); + const registry = await manager.load(); + const known = new Map(registry.assets.map((a) => [a.name, a])); + + let remote: Awaited>; + try { + remote = await cloud.listAssets(); + } catch (err) { + report.failed.push({ + name: "(list)", + reason: err instanceof Error ? err.message : String(err), + }); + return report; + } + + const dir = join(paths.assets, "shared"); + for (const entry of remote) { + const local = known.get(entry.name); + // A local file that already exists wins: the user's own copy is never + // silently overwritten by the workspace version. + if (local && existsSync(local.path)) { + report.skipped.push(entry.name); + continue; + } + try { + const body = await cloud.downloadAsset(entry.id); + await mkdir(dir, { recursive: true }); + const target = join(dir, basename(entry.name)); + await writeFile(target, body); + await manager.register(entry.name, target, inferType(target)); + report.downloaded.push(entry.name); + } catch (err) { + report.failed.push({ + name: entry.name, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + return report; +} + +export async function sync(): Promise { + const up = await push(); + const down = await pull(); + return { + uploaded: up.uploaded, + downloaded: down.downloaded, + skipped: [...up.skipped, ...down.skipped], + failed: [...up.failed, ...down.failed], + }; +} diff --git a/src/services/clips-history-cloud.test.ts b/src/services/clips-history-cloud.test.ts new file mode 100644 index 0000000..8bd84b0 --- /dev/null +++ b/src/services/clips-history-cloud.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const tmp = mkdtempSync(join(tmpdir(), "podcli-clipcloud-test-")); +process.env.PODCLI_HOME = tmp; +process.env.PODCLI_DATA = tmp; + +vi.mock("./podcli-cloud.js", () => ({ + signedIn: vi.fn(async () => false), + sourceHash: vi.fn(async () => "abc123"), + registerClip: vi.fn(async () => ({ id: "cloud-clip-1" })), + uploadClipVideo: vi.fn(async () => true), + logClipEvent: vi.fn(async () => undefined), +})); + +const cloud = await import("./podcli-cloud.js"); +const { ClipsHistory } = await import("./clips-history.js"); + +const source = join(tmp, "episode.mp4"); +const output = join(tmp, "clip.mp4"); + +/** record() fires its cloud sync in the background; let it settle before asserting. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 10)); + +async function seed(history: InstanceType) { + return history.record({ + title: "A clip", + source_video: source, + output_path: output, + duration: 42, + start_second: 10, + end_second: 52, + format: "9:16", + } as never); +} + +describe("clip cloud sync", () => { + let history: InstanceType; + + beforeEach(() => { + rmSync(join(tmp, "history"), { recursive: true, force: true }); + mkdirSync(join(tmp, "history"), { recursive: true }); + writeFileSync(source, "source bytes"); + writeFileSync(output, "rendered bytes"); + vi.clearAllMocks(); + vi.mocked(cloud.signedIn).mockResolvedValue(false); + history = new ClipsHistory(); + }); + + it("makes no network call when signed out", async () => { + await seed(history); + const result = await history.backfillCloud(); + + expect(result).toEqual({ synced: 0, failed: 0 }); + expect(cloud.registerClip).not.toHaveBeenCalled(); + expect(cloud.uploadClipVideo).not.toHaveBeenCalled(); + }); + + it("uploads the rendered clip after registering it", async () => { + const entry = await seed(history); + await settle(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + + await history.backfillCloud(); + + expect(cloud.registerClip).toHaveBeenCalledTimes(1); + expect(cloud.uploadClipVideo).toHaveBeenCalledWith("cloud-clip-1", output); + const after = await history.findById(entry.id); + expect(after?.cloud_id).toBe("cloud-clip-1"); + expect(after?.cloud_video_uploaded).toBe(true); + }); + + it("registers a clip once when two syncs overlap", async () => { + await seed(history); + await settle(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + + await Promise.all([history.backfillCloud(), history.backfillCloud()]); + + expect(cloud.registerClip).toHaveBeenCalledTimes(1); + expect(cloud.uploadClipVideo).toHaveBeenCalledTimes(1); + }); + + it("does not re-upload a clip whose video the workspace already has", async () => { + const entry = await seed(history); + await settle(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + await history.backfillCloud(); + vi.clearAllMocks(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + + await history.backfillCloud(); + + expect(cloud.uploadClipVideo).not.toHaveBeenCalled(); + expect(cloud.registerClip).not.toHaveBeenCalled(); + expect((await history.findById(entry.id))?.cloud_video_uploaded).toBe(true); + }); +}); diff --git a/src/services/clips-history.ts b/src/services/clips-history.ts index 316a43f..1a68ab9 100644 --- a/src/services/clips-history.ts +++ b/src/services/clips-history.ts @@ -40,6 +40,7 @@ export class ClipsHistory { // requests can't lose each other's edits. Cross-process safety (vs the Python // CLI) rests on the atomic temp-file rename in save(). private writeChain: Promise = Promise.resolve(); + private syncing = new Set(); private async ensureDir() { if (!existsSync(paths.history)) { @@ -86,9 +87,63 @@ export class ClipsHistory { await this.mutate((entries) => { entries.push(full); }); + void this.syncToCloud(full); return full; } + /** + * Mirror a rendered clip to the workspace, for signed-in users. + * + * Deliberately not awaited and unable to throw: a clip that rendered + * successfully must be recorded locally whether or not a server was reachable. + * The local history file remains the source of truth; this is a copy. + * + * Clips that fail to sync are left marked so a later sweep can backfill them — + * the performance model wants the whole history, not the part that happened to + * have a working network. + */ + private async syncToCloud(entry: ClipHistoryEntry): Promise { + // record() starts this in the background, so `podcli sync` can reach the + // same entry while it is still in flight and register the clip twice. + if (this.syncing.has(entry.id)) return; + this.syncing.add(entry.id); + try { + const cloud = await import("./podcli-cloud.js"); + if (!(await cloud.signedIn())) return; + + const source = entry.source_video; + if (!source) return; + + const clipId = entry.cloud_id ?? (await cloud.registerClip({ + sourceHash: await cloud.sourceHash(source), + episodeTitle: basename(source), + title: entry.title, + startSecond: entry.start_second, + endSecond: entry.end_second, + durationSec: entry.duration, + contentType: entry.content_type, + captionStyle: entry.caption_style, + aspectRatio: entry.format, + transcriptSlice: entry.transcript_slice, + }))?.id; + if (!clipId) return; + + await this.update(entry.id, { cloud_id: clipId, cloud_synced: true }); + + // Metadata alone leaves a share link with nothing to play, so the + // rendered file follows it. Uploaded once: the server keeps the first + // copy and answers `unchanged` after that. + if (!entry.cloud_video_uploaded && existsSync(entry.output_path)) { + const uploaded = await cloud.uploadClipVideo(clipId, entry.output_path); + if (uploaded) await this.update(entry.id, { cloud_video_uploaded: true }); + } + } catch { + await this.update(entry.id, { cloud_synced: false }).catch(() => {}); + } finally { + this.syncing.delete(entry.id); + } + } + // Persist every successful row of a batch render. Single source of truth for // turning backend batch results into history entries — callers used to inline // this loop, drifting on defaults and on which fields got recorded. @@ -230,12 +285,76 @@ export class ClipsHistory { async update(id: string, patch: Partial): Promise { if (!id) return null; - return this.mutate((entries) => { + const changed = await this.mutate((entries) => { const e = entries.find((x) => x.id === id); if (!e) return null; + const before = e.title; Object.assign(e, patch); - return e; + return { entry: e, previousTitle: before }; }); + if (!changed) return null; + + // A human rewriting a generated title is the clearest taste signal podcli + // gets — it says what the model produced and what a person preferred + // instead. Reported only when the title actually changed, so the sync + // bookkeeping in syncToCloud can't trigger it. + if (patch.title !== undefined && patch.title !== changed.previousTitle) { + void this.reportEvent(changed.entry, "title_edited", changed.previousTitle, patch.title); + } + return changed.entry; + } + + /** Best-effort; never blocks or fails the edit that produced it. */ + private async reportEvent( + entry: ClipHistoryEntry, + kind: "title_edited" | "discarded" | "thumbnail_regenerated", + before?: string, + after?: string, + ): Promise { + if (!entry.cloud_id) return; + try { + const cloud = await import("./podcli-cloud.js"); + if (!(await cloud.signedIn())) return; + await cloud.logClipEvent(entry.cloud_id, kind, before, after); + } catch { + // The signal is nice to have, not worth surfacing an error over. + } + } + + /** + * Push clips that never reached the workspace. + * + * Covers two cases that both matter: a render that happened while the network + * was down, and — more importantly — everything rendered *before* the user + * subscribed. A new Pro user should start with their back catalogue behind the + * performance model, not an empty history. + */ + async backfillCloud(limit = 200): Promise<{ synced: number; failed: number }> { + const cloud = await import("./podcli-cloud.js"); + if (!(await cloud.signedIn())) return { synced: 0, failed: 0 }; + + // A clip whose source video has been moved or deleted can never be hashed, + // so it can never sync. Skipping it keeps `podcli sync` quiet; counting it + // as a failure would report the same unfixable number on every run until + // people stopped reading the output. + const pending = (await this.load()) + .filter((e) => e.source_video && existsSync(e.source_video)) + .filter((e) => !e.cloud_id || !e.cloud_video_uploaded) + .slice(0, limit); + + let synced = 0; + let failed = 0; + for (const entry of pending) { + try { + await this.syncToCloud(entry); + const after = await this.findById(entry.id); + if (after?.cloud_id) synced++; + else failed++; + } catch { + failed++; + } + } + return { synced, failed }; } // Remove a clip and the artifacts podcli rendered for it (output video, diff --git a/src/services/knowledge-sync.test.ts b/src/services/knowledge-sync.test.ts new file mode 100644 index 0000000..dcdbeea --- /dev/null +++ b/src/services/knowledge-sync.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, existsSync, readdirSync, writeFileSync, readFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const tmp = mkdtempSync(join(tmpdir(), "podcli-ksync-test-")); +process.env.PODCLI_HOME = tmp; +process.env.PODCLI_DATA = tmp; + +vi.mock("./podcli-cloud.js", () => ({ + signedIn: vi.fn(async () => true), + listKnowledge: vi.fn(async () => []), + getKnowledge: vi.fn(async () => ({ content: "owned", version: 1 })), + putKnowledge: vi.fn(async () => ({ conflict: false, version: 1, unchanged: true })), +})); + +const cloud = await import("./podcli-cloud.js"); +const { sync } = await import("./knowledge-sync.js"); + +describe("knowledge sync", () => { + beforeEach(() => { + rmSync(join(tmp, "knowledge"), { recursive: true, force: true }); + mkdirSync(join(tmp, "knowledge"), { recursive: true }); + vi.clearAllMocks(); + }); + + it("refuses a workspace path that escapes the knowledge folder", async () => { + vi.mocked(cloud.listKnowledge).mockResolvedValue([ + { path: "../../pwned.md", version: 1, updated_at: "" }, + ]); + + const report = await sync(); + + expect(existsSync(join(tmp, "..", "pwned.md"))).toBe(false); + expect(report.pulled).toEqual([]); + expect(report.failed[0]?.path).toBe("../../pwned.md"); + // Rejected before the content is ever requested. + expect(cloud.getKnowledge).not.toHaveBeenCalled(); + }); + + it("never pushes shipped defaults over the workspace copy on a first sync", async () => { + writeFileSync(join(tmp, "knowledge", "02-voice-and-tone.md"), "# shipped default"); + vi.mocked(cloud.listKnowledge).mockResolvedValue([ + { path: "02-voice-and-tone.md", version: 7, updated_at: "" }, + ]); + vi.mocked(cloud.getKnowledge).mockResolvedValue({ + content: "# the team's real voice guide", version: 7, + }); + + const report = await sync(); + + expect(cloud.putKnowledge).not.toHaveBeenCalled(); + expect(report.conflicts).toEqual([{ path: "02-voice-and-tone.md", theirVersion: 7 }]); + // Neither copy is lost. + expect(readFileSync(join(tmp, "knowledge", "02-voice-and-tone.md"), "utf-8")) + .toBe("# shipped default"); + expect(readFileSync(join(tmp, "knowledge", "02-voice-and-tone.md.workspace-7"), "utf-8")) + .toBe("# the team's real voice guide"); + }); + + it("adopts the workspace version when both copies already match", async () => { + writeFileSync(join(tmp, "knowledge", "05-title-formulas.md"), "# same bytes"); + vi.mocked(cloud.listKnowledge).mockResolvedValue([ + { path: "05-title-formulas.md", version: 4, updated_at: "" }, + ]); + vi.mocked(cloud.getKnowledge).mockResolvedValue({ content: "# same bytes", version: 4 }); + + const report = await sync(); + + expect(cloud.putKnowledge).not.toHaveBeenCalled(); + expect(report.unchanged).toEqual(["05-title-formulas.md"]); + expect(report.conflicts).toEqual([]); + expect(existsSync(join(tmp, "knowledge", "05-title-formulas.md.workspace-4"))).toBe(false); + }); + + it("pushes a local file the workspace does not have", async () => { + writeFileSync(join(tmp, "knowledge", "99-mine.md"), "# only here"); + vi.mocked(cloud.listKnowledge).mockResolvedValue([]); + vi.mocked(cloud.putKnowledge).mockResolvedValue({ + conflict: false, version: 1, unchanged: false, + }); + + const report = await sync(); + + expect(cloud.putKnowledge).toHaveBeenCalledWith("99-mine.md", "# only here", undefined); + expect(report.pushed).toEqual(["99-mine.md"]); + }); + + it("pulls a file the workspace has and this machine does not", async () => { + vi.mocked(cloud.listKnowledge).mockResolvedValue([ + { path: "02-voice-and-tone.md", version: 3, updated_at: "" }, + ]); + + const report = await sync(); + + expect(report.pulled).toEqual(["02-voice-and-tone.md"]); + expect(readdirSync(join(tmp, "knowledge"))).toContain("02-voice-and-tone.md"); + }); +}); diff --git a/src/services/knowledge-sync.ts b/src/services/knowledge-sync.ts new file mode 100644 index 0000000..e419098 --- /dev/null +++ b/src/services/knowledge-sync.ts @@ -0,0 +1,148 @@ +import { existsSync } from "fs"; +import { mkdir, readFile, readdir, writeFile } from "fs/promises"; +import { dirname, join, resolve, sep } from "path"; +import { paths } from "../config/paths.js"; +import * as cloud from "./podcli-cloud.js"; + +/** + * Sync .podcli/knowledge/ with the workspace. + * + * This is the shared brand brain: voice, banned words, title formulas, + * thumbnail rules. A new editor joining a team should inherit all of it by + * signing in, rather than being sent a folder over Slack. + * + * Free podcli keeps these files local and fully effective, as it always will. + */ + +const STATE_FILE = "knowledge-sync.json"; + +/** + * Version of each file as of the last successful sync. + * + * Without this there is no way to tell "I edited this" from "they edited this" + * — both just look like a difference — and every sync would either clobber + * someone or refuse to do anything. + */ +type SyncState = Record; + +async function loadState(): Promise { + try { + return JSON.parse(await readFile(join(paths.home, STATE_FILE), "utf-8")); + } catch { + return {}; + } +} + +async function saveState(state: SyncState): Promise { + await mkdir(paths.home, { recursive: true }); + await writeFile(join(paths.home, STATE_FILE), JSON.stringify(state, null, 2), "utf-8"); +} + +/** + * The workspace decides these filenames, so a server that returned + * `../../.zshrc` would otherwise have this write anywhere the user can. + */ +function insideKnowledge(path: string): string | null { + const root = resolve(paths.knowledge); + const target = resolve(root, path); + return target.startsWith(root + sep) ? target : null; +} + +async function localFiles(): Promise { + if (!existsSync(paths.knowledge)) return []; + return (await readdir(paths.knowledge)).filter((f) => f.endsWith(".md")).sort(); +} + +export type KnowledgeSyncReport = { + pushed: string[]; + pulled: string[]; + unchanged: string[]; + conflicts: Array<{ path: string; theirVersion: number }>; + failed: Array<{ path: string; reason: string }>; +}; + +export async function sync(): Promise { + const report: KnowledgeSyncReport = { + pushed: [], pulled: [], unchanged: [], conflicts: [], failed: [], + }; + if (!(await cloud.signedIn())) return report; + + const state = await loadState(); + const remote = new Map((await cloud.listKnowledge()).map((f) => [f.path, f])); + const local = await localFiles(); + const localSet = new Set(local); + + // Reconciled before anything is pushed. podcli ships default knowledge files, + // so a machine that has never synced has a full set of boilerplate that would + // otherwise be pushed straight over the workspace's real one — the server + // skips its conflict check when no expectedVersion is sent. + const unresolved = new Set(); + for (const [path, meta] of remote) { + const target = insideKnowledge(path); + if (!target) { + report.failed.push({ path, reason: "path escapes the knowledge folder" }); + unresolved.add(path); + continue; + } + if (state[path] !== undefined) continue; + + try { + const file = await cloud.getKnowledge(path); + const version = file.version ?? meta.version; + + if (!localSet.has(path)) { + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, file.content, "utf-8"); + state[path] = version; + report.pulled.push(path); + continue; + } + + // Both sides have this file and nothing records which came first. Equal + // content is simply adopted; otherwise the workspace copy lands beside + // the local one and a human decides. + const mine = await readFile(target, "utf-8"); + if (mine === file.content) { + state[path] = version; + report.unchanged.push(path); + } else { + await writeFile(join(paths.knowledge, `${path}.workspace-${version}`), + file.content, "utf-8"); + report.conflicts.push({ path, theirVersion: version }); + } + unresolved.add(path); + } catch (err) { + report.failed.push({ path, reason: err instanceof Error ? err.message : String(err) }); + unresolved.add(path); + } + } + + for (const path of local) { + if (unresolved.has(path)) continue; + const content = await readFile(join(paths.knowledge, path), "utf-8"); + const known = state[path]; + try { + const result = await cloud.putKnowledge(path, content, known); + if (result.conflict) { + // Neither copy is discarded. The workspace version is written beside + // the local one so a human can compare and merge; nobody's work is + // thrown away by a sync running in the background. + const version = Number(result.version); + const suffix = Number.isFinite(version) ? version : "remote"; + const theirs = join(paths.knowledge, `${path}.workspace-${suffix}`); + await writeFile(theirs, result.content, "utf-8"); + report.conflicts.push({ path, theirVersion: version }); + continue; + } + state[path] = result.version; + if (result.unchanged) report.unchanged.push(path); + else report.pushed.push(path); + } catch (err) { + report.failed.push({ path, reason: err instanceof Error ? err.message : String(err) }); + } + } + + + await saveState(state); + return report; +} diff --git a/src/services/podcli-cloud.ts b/src/services/podcli-cloud.ts new file mode 100644 index 0000000..346076c --- /dev/null +++ b/src/services/podcli-cloud.ts @@ -0,0 +1,287 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { paths } from "../config/paths.js"; + +/** + * Client for podcli Pro's hosted API. + * + * The Python backend has its own copy of this because the two runtimes cannot + * share one — deliberate duplication of about eighty lines, not an accident. + * + * Nothing here is secret. The server decides entitlement, so a patched client + * gets an HTTP 401 rather than free Pro. + */ + +const DEFAULT_API_URL = "https://api.podcli.com"; + +export function apiUrl(): string { + return (process.env.PODCLI_API_URL || DEFAULT_API_URL).replace(/\/+$/, ""); +} + +/** + * Read on every call, deliberately not cached. + * + * The studio server is long-running, so a cached token survives `podcli logout` + * in another terminal and the UI keeps claiming the user is signed in. Reading a + * small file costs microseconds against the HTTP request that follows it, so + * caching bought nothing and cost correctness. + */ +export async function readToken(): Promise { + const fromEnv = (process.env.PODCLI_TOKEN || "").trim(); + if (fromEnv) return fromEnv; + try { + const raw = await readFile(join(paths.home, "auth.json"), "utf-8"); + return ((JSON.parse(raw).token as string | undefined) || "").trim() || null; + } catch { + return null; + } +} + +export async function signedIn(): Promise { + return (await readToken()) !== null; +} + +async function request(method: string, path: string, body?: unknown, timeoutMs = 30_000) { + const token = await readToken(); + if (!token) throw new Error("not signed in"); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`${apiUrl()}${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(`HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`); + } + const text = await response.text(); + return text ? JSON.parse(text) : null; + } finally { + clearTimeout(timer); + } +} + +/** + * Identifies an episode across machines. + * + * Hashing the first 8 MB rather than the whole file: a 2 GB master would take + * seconds to digest and the head of a video is more than distinctive enough to + * key on. Two editors working from the same file land on the same episode. + */ +export async function sourceHash(videoPath: string): Promise { + const hash = createHash("sha256"); + const stream = createReadStream(videoPath, { start: 0, end: 8 * 1024 * 1024 - 1 }); + for await (const chunk of stream) hash.update(chunk as Buffer); + return hash.digest("hex").slice(0, 32); +} + +export type ClipRegistration = { + sourceHash: string; + episodeTitle?: string; + episodeDuration?: number; + title?: string; + startSecond?: number; + endSecond?: number; + durationSec?: number; + contentType?: string; + captionStyle?: string; + aspectRatio?: string; + aiEngine?: string; + score?: number; + quote?: string; + reasoning?: string; + transcriptSlice?: string; + extra?: Record; +}; + +export async function registerClip(clip: ClipRegistration): Promise<{ id: string } | null> { + return request("POST", "/v1/clips", clip); +} + +/** Matches the server's body cap; a larger file is refused before the upload. */ +const MAX_CLIP_BYTES = 200 * 1024 * 1024; + +/** + * Send the rendered clip itself, so share links have something to play. + * + * Only the rendered clip travels — never the source video. It is the whole + * reason a share link can exist without the storage cost of the master. + */ +export async function uploadClipVideo(clipId: string, filePath: string): Promise { + const token = await readToken(); + if (!token) return false; + + const { size } = await stat(filePath); + if (size === 0 || size > MAX_CLIP_BYTES) return false; + + const response = await fetch(`${apiUrl()}/v1/clips/${clipId}/video`, { + method: "PUT", + headers: { authorization: `Bearer ${token}`, "content-type": "video/mp4" }, + body: await readFile(filePath), + signal: AbortSignal.timeout(300_000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`); + } + return true; +} + +export type Breakdown = { + key: string; + clips: number; + retention: number | null; + ctr: number | null; + views: number | null; +}; + +export type Insights = { + sampleSize: number; + byContentType: Breakdown[]; + byCaptionStyle: Breakdown[]; + byLength: Breakdown[]; + topClips: Array<{ title: string; retention: number; views: number; content_type: string }>; + guidance: string[]; +}; + +export type Preferences = { + titleEdits: Array<{ before: string; after: string }>; + discardRate: number | null; + observations: string[]; +}; + +export async function getInsights(): Promise { + return request("GET", "/v1/insights"); +} + +export async function getPreferences(): Promise { + return request("GET", "/v1/insights/preferences"); +} + +export async function whoami(): Promise<{ + workspaceId: string; + role: string; + plan: string; + workspace: { name: string; episodes_used: number }; +}> { + return request("GET", "/v1/auth/me", undefined, 10_000); +} + +export type RemoteKnowledgeFile = { path: string; version: number; updated_at: string }; + +export async function listKnowledge(): Promise { + const payload = await request("GET", "/v1/knowledge"); + return payload?.files ?? []; +} + +export async function getKnowledge(path: string): Promise<{ content: string; version: number }> { + return request("GET", `/v1/knowledge/file?path=${encodeURIComponent(path)}`); +} + +export type PutKnowledgeResult = + | { conflict: false; version: number; unchanged: boolean } + | { conflict: true; version: number; content: string }; + +export async function putKnowledge( + path: string, + content: string, + expectedVersion?: number, +): Promise { + const token = await readToken(); + if (!token) throw new Error("not signed in"); + + const response = await fetch(`${apiUrl()}/v1/knowledge/file`, { + method: "PUT", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ path, content, expectedVersion }), + }); + + // A 409 is an expected outcome here, not an error: someone else edited the + // file. The body carries their version so the caller can show both. + if (response.status === 409) { + const body = await response.json(); + return { conflict: true, version: body.version, content: body.content }; + } + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`); + } + const body = await response.json(); + return { conflict: false, version: body.version, unchanged: Boolean(body.unchanged) }; +} + +export type RemoteAsset = { + id: string; + name: string; + kind: string; + is_default: boolean; + size_bytes: string; + checksum: string; +}; + +/** Mirrors how the workspace digests an asset, so an upload can be skipped. */ +export function checksum(body: Buffer): string { + return createHash("sha256").update(body).digest("hex").slice(0, 32); +} + +export async function listAssets(): Promise { + const payload = await request("GET", "/v1/assets"); + return payload?.assets ?? []; +} + +export async function uploadAsset( + name: string, + kind: string, + body: Buffer, + isDefault = false, +): Promise<{ id: string; unchanged?: boolean }> { + const token = await readToken(); + if (!token) throw new Error("not signed in"); + + const params = new URLSearchParams({ name, kind, isDefault: String(isDefault) }); + const response = await fetch(`${apiUrl()}/v1/assets?${params}`, { + method: "PUT", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/octet-stream", + }, + // Node's fetch wants a view, not the Buffer's whole underlying pool. + body: new Uint8Array(body), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`); + } + return response.json(); +} + +export async function downloadAsset(id: string): Promise { + const token = await readToken(); + if (!token) throw new Error("not signed in"); + + const response = await fetch(`${apiUrl()}/v1/assets/${id}/download`, { + headers: { authorization: `Bearer ${token}` }, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return Buffer.from(await response.arrayBuffer()); +} + +export type ClipEventKind = + | "suggested" | "rendered" | "discarded" + | "title_edited" | "thumbnail_regenerated" + | "approved" | "changes_requested" | "published"; + +export async function logClipEvent( + cloudClipId: string, + kind: ClipEventKind, + before?: string, + after?: string, +): Promise { + await request("POST", `/v1/clips/${cloudClipId}/events`, { kind, before, after }); +} diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 0000000..059afb9 --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,86 @@ +import { ClipsHistory } from "./services/clips-history.js"; +import * as assetSync from "./services/asset-sync.js"; +import * as knowledgeSync from "./services/knowledge-sync.js"; +import * as cloud from "./services/podcli-cloud.js"; + +/** + * `podcli sync` — reconcile this machine with the workspace. + * + * Clips, assets, and knowledge each sync automatically at the moments that + * matter (render, login), so this is the manual catch-up: after working + * offline, after a teammate changes the brand guide, or on a new machine. + * + * Every step is independent and none can fail another — a knowledge conflict + * must not stop assets from arriving. + */ +async function main(): Promise { + if (!(await cloud.signedIn())) { + console.log("Not signed in to podcli Pro. Run `podcli login` first."); + return 1; + } + + let problems = 0; + + console.log("Syncing clips..."); + try { + const { synced, failed } = await new ClipsHistory().backfillCloud(); + console.log( + synced || failed + ? ` ${synced} synced${failed ? `, ${failed} failed` : ""}` + : " already up to date", + ); + problems += failed; + } catch (err) { + console.log(` failed: ${err instanceof Error ? err.message : String(err)}`); + problems++; + } + + console.log("Syncing assets..."); + try { + const report = await assetSync.sync(); + const parts = [ + report.uploaded.length && `${report.uploaded.length} uploaded`, + report.downloaded.length && `${report.downloaded.length} downloaded`, + ].filter(Boolean); + console.log(parts.length ? ` ${parts.join(", ")}` : " already up to date"); + for (const f of report.failed) console.log(` ${f.name}: ${f.reason}`); + problems += report.failed.length; + } catch (err) { + console.log(` failed: ${err instanceof Error ? err.message : String(err)}`); + problems++; + } + + console.log("Syncing knowledge base..."); + try { + const report = await knowledgeSync.sync(); + const parts = [ + report.pushed.length && `${report.pushed.length} pushed`, + report.pulled.length && `${report.pulled.length} pulled`, + ].filter(Boolean); + console.log(parts.length ? ` ${parts.join(", ")}` : " already up to date"); + + for (const conflict of report.conflicts) { + console.log( + ` conflict: ${conflict.path} — the workspace copy was saved as ` + + `${conflict.path}.workspace-${conflict.theirVersion}. Merge it, then sync again.`, + ); + } + for (const f of report.failed) console.log(` ${f.path}: ${f.reason}`); + problems += report.failed.length; + } catch (err) { + console.log(` failed: ${err instanceof Error ? err.message : String(err)}`); + problems++; + } + + // Conflicts are not counted as problems: they are a normal outcome that + // needs a human, not a failure that needs a retry. + return problems > 0 ? 1 : 0; +} + +main().then( + (code) => process.exit(code), + (err) => { + console.error("sync failed:", err instanceof Error ? err.message : String(err)); + process.exit(1); + }, +); diff --git a/src/ui/client/AccountChip.tsx b/src/ui/client/AccountChip.tsx new file mode 100644 index 0000000..d281d8d --- /dev/null +++ b/src/ui/client/AccountChip.tsx @@ -0,0 +1,45 @@ +import React, { useEffect, useState } from "react"; + +type Account = { + signedIn: boolean; + workspace?: string; + plan?: string; + episodesUsed?: number; + cap?: number; +}; + +/** + * Signed-in state at the bottom of the sidebar. + * + * Shows nothing when signed out. Sync that runs invisibly feels like sync that + * isn't running, so a subscriber should be able to see their workspace without + * going looking for it. + */ +export default function AccountChip() { + const [account, setAccount] = useState(null); + + useEffect(() => { + fetch("/api/pro/account") + .then((r) => r.json()) + .then(setAccount) + .catch(() => setAccount({ signedIn: false })); + }, []); + + if (!account?.signedIn) return null; + + const used = account.episodesUsed ?? 0; + const cap = account.cap ?? 0; + // Only surface the quota once it's close enough to matter. A counter at 2/10 + // is noise; at 8/10 it's the difference between planning and being surprised. + const showQuota = cap > 0 && used / cap >= 0.7; + + return ( +
+
{account.workspace}
+
+ {account.plan === "team" ? "Team" : "Pro"} + {showQuota && ` · ${used}/${cap} episodes`} +
+
+ ); +} diff --git a/src/ui/client/AiSetup.tsx b/src/ui/client/AiSetup.tsx new file mode 100644 index 0000000..2e1ee59 --- /dev/null +++ b/src/ui/client/AiSetup.tsx @@ -0,0 +1,135 @@ +import React, { useEffect, useState } from "react"; +import { Cloud, Terminal, Key } from "lucide-react"; +import { labelStyle } from "./lib"; + +/** + * What podcli will use for AI, and what to do when the answer is "nothing". + * + * Two real options are offered side by side and neither is dressed up as the + * only one: install a CLI you already pay for, or let us run it. A user who + * picks the free path has solved their problem, which is the point. + */ + +type Provider = { kind: string; engine: string; label: string }; + +type Status = { + available: boolean; + providers: Provider[]; + mode: string; + api_key_set: boolean; + candidates: Array<{ engine: string; path: string }>; +}; + +const INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code"; + +function Option({ + icon, title, body, action, +}: { + icon: React.ReactNode; title: string; body: string; action: React.ReactNode; +}) { + return ( +
+
+ {icon} + {title} +
+
{body}
+ {action} +
+ ); +} + +export default function AiSetup() { + const [status, setStatus] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + fetch("/api/ai-provider-status") + .then((r) => r.json()) + .then(setStatus) + .catch(() => setStatus(null)); + }, []); + + if (!status) return null; + + if (status.available) { + return ( +
+
AI
+
+ Using + + {status.providers.map((p) => p.label).join(" → ")} + +
+ {status.providers.length > 1 && ( +
+ podcli tries these in order, so a failure falls through to the next one + rather than stopping. +
+ )} +
+ ); + } + + return ( +
+
AI is not set up
+
+ podcli transcribes, cuts, and renders without any of this. Picking moments, + titles, and descriptions needs a model. Two ways to get one: +
+ +
+
+ + {status.candidates.length > 0 && ( + // Found but unusable is a different problem from missing, and saying + // "not detected" here would send someone to reinstall what they have. +
+ A CLI was found at {status.candidates[0].path} but did not respond. + Run {status.candidates[0].engine} once in a terminal to sign in, then reload. +
+ )} +
+ ); +} diff --git a/src/ui/client/AnalyticsPage.tsx b/src/ui/client/AnalyticsPage.tsx index dbf4fd3..0c6cd67 100644 --- a/src/ui/client/AnalyticsPage.tsx +++ b/src/ui/client/AnalyticsPage.tsx @@ -3,6 +3,7 @@ import { PageHeader } from "./Page"; import { Link } from "react-router-dom"; import { TrendingUp, Eye, Percent, MousePointerClick } from "lucide-react"; import { api, upload, fmt } from "./lib"; +import WorkspaceInsights from "./WorkspaceInsights"; interface Row { key: string; count: number; avgViews: number; avgRetention: number; avgCtr: number } interface Data { @@ -164,6 +165,8 @@ export default function AnalyticsPage() { {msg &&
{msg}
} + + {showConnect && (
Connect YouTube (read-only)
diff --git a/src/ui/client/ConfigPage.tsx b/src/ui/client/ConfigPage.tsx index 58c3e6d..8b1ad94 100644 --- a/src/ui/client/ConfigPage.tsx +++ b/src/ui/client/ConfigPage.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from "react"; import { PageHeader } from "./Page"; import { api, upload } from "./lib"; +import AiSetup from "./AiSetup"; type SettingRow = { key: string; @@ -169,6 +170,8 @@ export default function ConfigPage() { )}
+ +
AI CLI
{aiCli ? ( diff --git a/src/ui/client/Layout.tsx b/src/ui/client/Layout.tsx index 0a635ea..7043788 100644 --- a/src/ui/client/Layout.tsx +++ b/src/ui/client/Layout.tsx @@ -15,6 +15,7 @@ import { Search, } from "lucide-react"; import CommandPalette from "./CommandPalette"; +import AccountChip from "./AccountChip"; const icons: Record = { library: LayoutGrid, @@ -73,6 +74,8 @@ export default function Layout() {
Insights
Analytics + +
diff --git a/src/ui/client/WorkspaceInsights.tsx b/src/ui/client/WorkspaceInsights.tsx new file mode 100644 index 0000000..ff729f5 --- /dev/null +++ b/src/ui/client/WorkspaceInsights.tsx @@ -0,0 +1,115 @@ +import React, { useEffect, useState } from "react"; +import { PenLine, TrendingUp, Users } from "lucide-react"; +import { labelStyle } from "./lib"; + +/** + * Workspace-wide performance and learned house style. + * + * Renders nothing at all when signed out. A free user sees the Analytics page + * they have always seen — no locked panel, no upsell banner, no greyed-out + * button. This section exists because the workspace data exists. + */ + +type Breakdown = { key: string; clips: number; retention: number | null }; + +type Payload = { + signedIn: boolean; + insights?: { + sampleSize: number; + byContentType: Breakdown[]; + byLength: Breakdown[]; + guidance: string[]; + topClips: Array<{ title: string; retention: number; content_type: string }>; + }; + preferences?: { + observations: string[]; + discardRate: number | null; + titleEdits: Array<{ before: string; after: string }>; + }; +}; + +export default function WorkspaceInsights() { + const [data, setData] = useState(null); + + useEffect(() => { + fetch("/api/pro/insights") + .then((r) => r.json()) + .then(setData) + .catch(() => setData({ signedIn: false })); + }, []); + + if (!data?.signedIn || !data.insights) return null; + + const { insights, preferences } = data; + const hasModel = insights.guidance.length > 0; + const hasStyle = (preferences?.observations.length ?? 0) > 0; + + // Signed in but nothing learned yet. Say why, and say what changes it — + // an empty panel with no explanation reads as broken. + if (!hasModel && !hasStyle) { + return ( +
+
Workspace
+
+ {insights.sampleSize === 0 + ? "Connect YouTube and publish a few clips. Once performance data arrives, podcli starts picking moments based on what works for this channel." + : `Tracking ${insights.sampleSize} published clip${insights.sampleSize === 1 ? "" : "s"}. A few more and patterns become reliable enough to act on.`} +
+
+ ); + } + + return ( + <> + {hasModel && ( +
+
+ What works on this channel +
+
+ From {insights.sampleSize} published clips across your workspace. podcli uses this + when picking moments. +
+ {insights.guidance.map((line) => ( +
+ + {line} +
+ ))} +
+ )} + + {hasStyle && ( +
+
+ House style +
+
+ Learned from edits your team made to generated output. Nobody configured these. +
+ {preferences!.observations.map((line) => ( +
{line}
+ ))} + + {preferences!.titleEdits.length > 0 && ( +
+ + Recent title rewrites ({preferences!.titleEdits.length}) + +
+ {preferences!.titleEdits.slice(0, 6).map((edit, i) => ( +
+
+ {edit.before} +
+
{edit.after}
+
+ ))} +
+
+ )} +
+ )} + + ); +} diff --git a/src/ui/public/css/styles.css b/src/ui/public/css/styles.css index 75ee434..8514d95 100644 --- a/src/ui/public/css/styles.css +++ b/src/ui/public/css/styles.css @@ -1203,6 +1203,15 @@ input[type="range"]::-webkit-slider-thumb { } .sidebar-link:hover { background: var(--surface2); color: var(--text); } .sidebar-link.active { background: var(--accent-subtle); color: var(--accent); } + +/* Signed-in workspace, pinned to the foot of the sidebar so it reads as status + rather than as another navigation item. */ +.sidebar-account { margin-top: auto; padding: 12px; border-top: 1px solid var(--border); } +.sidebar-account-name { + font-size: 12px; font-weight: 700; color: var(--text); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.sidebar-account-sub { font-size: 11px; color: var(--text3); margin-top: 2px; } .sidebar-link .ico { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.85; } .sidebar-link.disabled { opacity: 0.4; pointer-events: none; } .sidebar-link .soon { margin-left: auto; font-size: 9px; font-weight: 700; letter-spacing: 0.5px; color: var(--text3); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; } diff --git a/src/ui/web-server.ts b/src/ui/web-server.ts index ece7d3c..899a3ab 100644 --- a/src/ui/web-server.ts +++ b/src/ui/web-server.ts @@ -1425,6 +1425,47 @@ app.get("/api/job/:id/stream", (req, res) => { /** * GET /api/outputs — List finished clips */ +/** + * Workspace-wide performance, for signed-in users. + * + * Returns `{ signedIn: false }` rather than an error when there's no account: + * the studio renders the same Analytics page either way, just without the + * workspace section. Nothing is greyed out and nothing says "upgrade". + */ +app.get("/api/pro/insights", async (_req, res) => { + try { + const cloud = await import("../services/podcli-cloud.js"); + if (!(await cloud.signedIn())) return res.json({ signedIn: false }); + + const [insights, preferences] = await Promise.all([ + cloud.getInsights(), + cloud.getPreferences(), + ]); + res.json({ signedIn: true, insights, preferences }); + } catch (err) { + // A workspace that can't be reached must not break the local analytics the + // page is primarily there to show. + res.json({ signedIn: false, error: err instanceof Error ? err.message : String(err) }); + } +}); + +app.get("/api/pro/account", async (_req, res) => { + try { + const cloud = await import("../services/podcli-cloud.js"); + if (!(await cloud.signedIn())) return res.json({ signedIn: false }); + const me = await cloud.whoami(); + res.json({ + signedIn: true, + workspace: me.workspace?.name, + plan: me.plan, + episodesUsed: me.workspace?.episodes_used, + cap: me.plan === "team" ? 40 : 10, + }); + } catch { + res.json({ signedIn: false }); + } +}); + app.get("/api/outputs", async (_req, res) => { try { await mkdir(paths.output, { recursive: true }); @@ -2335,6 +2376,15 @@ app.get("/api/ai-cli-status", async (_req, res) => { } }); +app.get("/api/ai-provider-status", async (_req, res) => { + try { + const result = await executor.execute>("ai_provider_status", {}); + res.json(result.data ?? { available: false, providers: [], candidates: [] }); + } catch (err: unknown) { + res.status(500).json({ error: errMsg(err) }); + } +}); + app.get("/api/youtube/config", (_req, res) => { try { const all = JSON.parse(readFileSync(paths.integrations, "utf-8")); diff --git a/tests/test_entitlement_chain.py b/tests/test_entitlement_chain.py new file mode 100644 index 0000000..de76acb --- /dev/null +++ b/tests/test_entitlement_chain.py @@ -0,0 +1,80 @@ +import json +import os +import sys +import tempfile +import time +import unittest +from unittest import mock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) + +from services import ai_provider, podcli_cloud # noqa: E402 + + +class EntitlementTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + patcher = mock.patch.dict(podcli_cloud.paths, {"home": self.tmp}) + patcher.start() + self.addCleanup(patcher.stop) + # PODCLI_TOKEN would shadow the file these tests are about. + env = mock.patch.dict(os.environ, {"PODCLI_TOKEN": "", "PODCLI_AI_PROVIDER": ""}) + env.start() + self.addCleanup(env.stop) + + def write_auth(self, **fields): + with open(os.path.join(self.tmp, "auth.json"), "w", encoding="utf-8") as fh: + json.dump({"token": "t", "workspace_id": "w", **fields}, fh) + + def test_unknown_plan_still_tries_the_cloud(self): + self.write_auth() + self.assertTrue(podcli_cloud.entitled()) + + def test_free_plan_is_not_entitled(self): + self.write_auth(plan="free", plan_checked_at=time.time()) + self.assertFalse(podcli_cloud.entitled()) + + def test_paid_plan_is_entitled(self): + for plan in ("pro", "team", "studio"): + with self.subTest(plan=plan): + self.write_auth(plan=plan, plan_checked_at=time.time()) + self.assertTrue(podcli_cloud.entitled()) + + def test_a_stale_free_verdict_is_retried(self): + # A subscription bought after the last check must work without re-login. + self.write_auth(plan="free", + plan_checked_at=time.time() - podcli_cloud.PLAN_TTL_SECONDS - 1) + self.assertTrue(podcli_cloud.entitled()) + + def test_remember_plan_keeps_the_token(self): + self.write_auth() + podcli_cloud.remember_plan("pro") + with open(os.path.join(self.tmp, "auth.json"), encoding="utf-8") as fh: + data = json.load(fh) + self.assertEqual(data["token"], "t") + self.assertEqual(data["workspace_id"], "w") + self.assertEqual(data["plan"], "pro") + + def test_free_workspace_skips_the_cloud_leg(self): + self.write_auth(plan="free", plan_checked_at=time.time()) + with mock.patch.object(ai_provider.ai_cli, "_find_ai_cli_candidates", + return_value=[("/bin/claude", "claude")]): + chain = ai_provider._chain() + self.assertEqual([kind for kind, _, _ in chain], ["cli"]) + + def test_paid_workspace_puts_the_cloud_first(self): + self.write_auth(plan="pro", plan_checked_at=time.time()) + with mock.patch.object(ai_provider.ai_cli, "_find_ai_cli_candidates", + return_value=[("/bin/claude", "claude")]): + chain = ai_provider._chain() + self.assertEqual([kind for kind, _, _ in chain], ["cloud", "cli"]) + + def test_forced_cloud_mode_ignores_a_free_verdict(self): + self.write_auth(plan="free", plan_checked_at=time.time()) + with mock.patch.dict(os.environ, {"PODCLI_AI_PROVIDER": "cloud"}): + chain = ai_provider._chain() + self.assertEqual([kind for kind, _, _ in chain], ["cloud"]) + + +if __name__ == "__main__": + unittest.main() From 7da0bbd96674b01f8567b7e4f1bf9ea2bb83c429 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sat, 8 Aug 2026 19:35:19 +0400 Subject: [PATCH 3/7] Match the whisper.cpp DTW preset to the loaded model -dtw was hardcoded to the base alignment-head preset while the model came from settings, so --fast (tiny.en) aborted whisper-cli with exit 3. Derive the preset from the model file, and omit -dtw for models with no preset. Also repoint two tests at the seams the AI provider consolidation moved: they patched claude_suggest._find_ai_cli_candidates, which no longer gates either path, so they only passed on machines with a real CLI installed. --- backend/services/transcription_whispercpp.py | 30 ++++++++++++++++++-- tests/test_ai_fallback.py | 2 +- tests/test_suggest_handler.py | 4 +-- tests/test_whispercpp_adapter.py | 19 ++++++++++++- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/backend/services/transcription_whispercpp.py b/backend/services/transcription_whispercpp.py index de32502..2d61cb4 100644 --- a/backend/services/transcription_whispercpp.py +++ b/backend/services/transcription_whispercpp.py @@ -16,6 +16,29 @@ _SPECIAL = re.compile(r"^\[.*\]$") # [_BEG_], [_TT_...], etc. +# whisper.cpp's WHISPER_AHEADS_* presets. The alignment heads are per-architecture: +# passing a preset whose layer/head indices exceed the loaded model's dimensions +# aborts whisper-cli with exit 3, so the preset must track the model, not a default. +_DTW_PRESETS = { + "tiny", "tiny.en", "base", "base.en", "small", "small.en", + "medium", "medium.en", "large.v1", "large.v2", "large.v3", "large.v3-turbo", +} +_QUANT_SUFFIX = re.compile(r"-(?:q\d+_\d+|q\d+k[a-z]*|f16|f32)$", re.IGNORECASE) + + +def _dtw_preset_for_model(model_path: str) -> Optional[str]: + name = os.path.basename(model_path) + for ext in (".bin", ".gguf"): + if name.lower().endswith(ext): + name = name[: -len(ext)] + break + if name.lower().startswith("ggml-"): + name = name[5:] + name = _QUANT_SUFFIX.sub("", name).lower() + if name.startswith("large-v"): + name = "large." + name[len("large-"):] + return name if name in _DTW_PRESETS else None + def _extract_wav(media_path: str, wav_path: str, ffmpeg: str = "ffmpeg") -> None: subprocess.run( @@ -142,7 +165,7 @@ def transcribe_file( whisper_cli: str = "whisper-cli", ffmpeg: str = "ffmpeg", language: Optional[str] = "en", - dtw_model: str = "base", + dtw_model: Optional[str] = None, threads: int = 4, vad: bool = False, vad_model: Optional[str] = None, @@ -165,8 +188,9 @@ def transcribe_file( cmd = [whisper_cli, "-m", model_path, "-f", wav, "-ojf", "-of", out_base, "-t", str(threads)] - if dtw_model: - cmd += ["-dtw", dtw_model] + dtw = dtw_model if dtw_model is not None else _dtw_preset_for_model(model_path) + if dtw: + cmd += ["-dtw", dtw] if vad and vad_model and os.path.exists(vad_model): # VAD removes the trailing-words-into-silence failure mode but adds a # systematic early bias (silence-removal remapping). Off by default; diff --git a/tests/test_ai_fallback.py b/tests/test_ai_fallback.py index cdc038b..23fe0a4 100644 --- a/tests/test_ai_fallback.py +++ b/tests/test_ai_fallback.py @@ -423,7 +423,7 @@ def test_find_cli_falls_back_to_shell_lookup(self): def test_get_ai_cli_status_reports_candidates(self): with mock.patch.object( - cs, + ai, "_find_ai_cli_candidates", return_value=[("/tmp/claude", "claude")], ), mock.patch.object(ai, "_configured_cli_path", return_value=None): diff --git a/tests/test_suggest_handler.py b/tests/test_suggest_handler.py index 4c577d2..a28d21c 100644 --- a/tests/test_suggest_handler.py +++ b/tests/test_suggest_handler.py @@ -11,7 +11,7 @@ sys.path.insert(0, BACKEND_ROOT) import main as backend_main -from services import claude_suggest +from services import ai_provider, claude_suggest SEGMENTS = [{"start": 0.0, "end": 10.0, "text": "hello"}] ENERGY_DATA = [{"time": float(t), "rms_db": -30.0} for t in range(31)] + [ @@ -44,7 +44,7 @@ def fake_emit_result(task_id, status, data=None, error=None): emitted.update({"task_id": task_id, "status": status, "data": data, "error": error}) with mock.patch.object(claude_suggest, "suggest_initial_with_claude", fake_suggest), \ - mock.patch.object(claude_suggest, "_find_ai_cli_candidates", return_value=["claude"]), \ + mock.patch.object(ai_provider, "available", return_value=True), \ mock.patch.object(backend_main, "emit_result", fake_emit_result), \ mock.patch.object(backend_main, "emit_progress"): backend_main.handle_suggest_clips("task-1", params) diff --git a/tests/test_whispercpp_adapter.py b/tests/test_whispercpp_adapter.py index c1bb9ac..161352d 100644 --- a/tests/test_whispercpp_adapter.py +++ b/tests/test_whispercpp_adapter.py @@ -7,7 +7,7 @@ if BACKEND_ROOT not in sys.path: sys.path.insert(0, BACKEND_ROOT) -from services.transcription_whispercpp import _tokens_to_words +from services.transcription_whispercpp import _dtw_preset_for_model, _tokens_to_words class WhisperCppAdapterTests(unittest.TestCase): @@ -19,5 +19,22 @@ def test_sentencepiece_marker_is_removed(self): self.assertEqual([w["word"] for w in words], ["hello", "world"]) +class DtwPresetTests(unittest.TestCase): + def test_preset_tracks_the_model_file(self): + cases = { + "ggml-tiny.en.bin": "tiny.en", + "ggml-base.bin": "base", + "ggml-small.bin": "small", + "ggml-large-v3-turbo.bin": "large.v3-turbo", + "ggml-base.en-q5_1.bin": "base.en", + } + for name, preset in cases.items(): + with self.subTest(name=name): + self.assertEqual(_dtw_preset_for_model(os.path.join("/models", name)), preset) + + def test_unknown_model_gets_no_preset(self): + self.assertIsNone(_dtw_preset_for_model("/models/ggml-distil-large-v2.bin")) + + if __name__ == "__main__": unittest.main() From 113c8fcca57e9f6dca018b47bf92cd78231b7128 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sat, 8 Aug 2026 23:19:58 +0400 Subject: [PATCH 4/7] Address the review on the provider and sync work Twenty threads, and the ones that mattered were about claiming success that had not happened: - `podcli sync` marked a clip synchronised before its video upload, so it could exit happy while every share link played nothing. - Nested knowledge files were pulled once and never pushed again: the local scan was not recursive, so later edits to `brand/voice.md` never went back. - Two workspace assets whose names differed only by folder collided on one local file, and the workspace's own kind was thrown away in favour of guessing from the extension. - The backfill kept hashing and uploading after a 401 or 402 that was going to refuse every remaining clip. - Three fetches had no timeout, so one stalled connection hung a whole sync. - The auth file was created with the default umask and chmodded afterwards, leaving the session token briefly readable by anyone on the machine. - `q4_k_m` models lost `-dtw` because the quantisation suffix pattern missed underscore-qualified names. Covered by a regression case now. - The CLI discovery cache ignored the .env file it also reads, so saving a path in the studio did nothing until the process restarted. The rest are smaller: JSON extraction now tries whichever opener comes first, a model answering with an object where a list belongs is a failed attempt rather than an AttributeError, a cloud 401 no longer tells someone to log into a CLI they do not use, and the settings panels stop disagreeing about whether a CLI was found. --- backend/cli.py | 9 ++++-- backend/main.py | 2 +- backend/services/ai_cli.py | 22 ++++++++++++- backend/services/ai_provider.py | 11 ++++--- backend/services/claude_suggest.py | 4 +-- backend/services/podcli_cloud.py | 33 +++++++++++++++++--- backend/services/transcription_whispercpp.py | 2 +- src/services/asset-sync.ts | 21 +++++++++++-- src/services/clips-history.ts | 16 +++++++--- src/services/knowledge-sync.test.ts | 5 +++ src/services/knowledge-sync.ts | 12 +++++-- src/services/podcli-cloud.ts | 9 ++++-- src/ui/client/AiSetup.tsx | 11 +++++-- src/ui/client/ConfigPage.tsx | 5 ++- tests/test_whispercpp_adapter.py | 4 +++ 15 files changed, 137 insertions(+), 29 deletions(-) diff --git a/backend/cli.py b/backend/cli.py index 01403d2..27dd014 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -3495,7 +3495,12 @@ def print_banner(): # `info` should report what AI podcli will actually use, which for a # signed-in user is the workspace rather than any local binary. from services import ai_provider - _providers = ai_provider.status()["providers"] + # Every other lookup in this banner is guarded. This one reads and parses + # the local auth file, so a truncated one would take `podcli info` with it. + try: + _providers = ai_provider.status()["providers"] + except Exception: + _providers = [] print(f" {bold}podcli{reset} v{VERSION}") @@ -3639,7 +3644,7 @@ def cmd_login(args): import getpass from services import podcli_cloud - email = args.email or input("Email: ").strip() + email = (args.email or input("Email: ")).strip() # Prefer the prompt: a password in argv is visible in ps output and lands in # the user's shell history. password = args.password or getpass.getpass("Password: ") diff --git a/backend/main.py b/backend/main.py index 32a0cb1..a9cbc2c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -820,7 +820,7 @@ def handle_generate_content(task_id: str, params: dict): emit_result( task_id, "error", - error="AI CLI found but content generation failed — check claude/codex login and try again", + error="Content generation failed — check that your AI provider is reachable and try again", ) return diff --git a/backend/services/ai_cli.py b/backend/services/ai_cli.py index 0393e9a..6d8daf0 100644 --- a/backend/services/ai_cli.py +++ b/backend/services/ai_cli.py @@ -344,6 +344,26 @@ def get_ai_cli_status() -> dict: } +def _env_file_stamp() -> tuple: + """ + Identity of the .env discovery also reads. + + A configured CLI path can come from the file as well as the environment, + and the backend task runner is long-lived: it serves the request that saves + the path and every request after it. Without the file in the key, saving a + path in the studio has no effect until the process restarts, which is a + regression against the old probe-every-time behaviour. + """ + try: + from services.env_settings import _env_path + path = _env_path() + stat = os.stat(path) + return (path, stat.st_mtime_ns, stat.st_size) + except Exception: + # No file, or no reading it: nothing to invalidate against. + return () + + def _discovery_key() -> tuple: """Everything discovery reads. Changing any of it must re-probe.""" return tuple( @@ -353,7 +373,7 @@ def _discovery_key() -> tuple: "NPM_CONFIG_PREFIX", "npm_config_prefix", "PODCLI_CLAUDE_PATH", "PODCLI_CODEX_PATH", ) - ) + ) + _env_file_stamp() @lru_cache(maxsize=8) diff --git a/backend/services/ai_provider.py b/backend/services/ai_provider.py index 9fde079..856f95b 100644 --- a/backend/services/ai_provider.py +++ b/backend/services/ai_provider.py @@ -131,10 +131,13 @@ def extract_json(text: str) -> Optional[Any]: fenced = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", body, re.DOTALL) if fenced: body = fenced.group(1).strip() - for opener in ("{", "["): - start = body.find(opener) - if start < 0: - continue + # By position, not by preference: a top-level array whose first element is + # an object would otherwise match "{" at index 1 and return one element of + # the list instead of the list. + openers = sorted( + (body.find(opener), opener) for opener in ("{", "[") if body.find(opener) >= 0 + ) + for start, _opener in openers: try: value, _ = json.JSONDecoder().raw_decode(body, start) return value diff --git a/backend/services/claude_suggest.py b/backend/services/claude_suggest.py index b0c9f4e..57e5934 100644 --- a/backend/services/claude_suggest.py +++ b/backend/services/claude_suggest.py @@ -523,7 +523,7 @@ def usable(text: str): parsed = ai_provider.extract_json(text) if not isinstance(parsed, dict): return f"{label} returned output that wasn't valid JSON" - if not parsed.get("clips"): + if not isinstance(parsed.get("clips"), list) or not parsed["clips"]: return f"{label} ran but found no clips in the transcript" return True @@ -553,7 +553,7 @@ def usable(text: str): if progress_callback: progress_callback(0, attempt.error) if error_sink is not None: - error_sink.append(classify_cli_error(attempt.error)) + error_sink.append(attempt.error) return None label = attempt.label diff --git a/backend/services/podcli_cloud.py b/backend/services/podcli_cloud.py index 666b9ee..2e33d5a 100644 --- a/backend/services/podcli_cloud.py +++ b/backend/services/podcli_cloud.py @@ -12,6 +12,7 @@ import os import time import urllib.error +import urllib.parse import urllib.request from typing import Any, Optional @@ -22,7 +23,17 @@ def api_url() -> str: - return (os.environ.get("PODCLI_API_URL") or DEFAULT_API_URL).rstrip("/") + """ + The API base, restricted to http and https. + + urlopen honours whatever scheme it is given, so an unchecked value here + lets `file:` turn a local path into what the code treats as an API + response. + """ + raw = (os.environ.get("PODCLI_API_URL") or DEFAULT_API_URL).rstrip("/") + if urllib.parse.urlparse(raw).scheme not in ("http", "https"): + return DEFAULT_API_URL + return raw def _auth_path() -> str: @@ -53,7 +64,12 @@ def _auth_data() -> dict: def _write_auth(data: dict) -> None: os.makedirs(paths["home"], exist_ok=True) path = _auth_path() - with open(path, "w", encoding="utf-8") as fh: + # Opened with the mode already set rather than chmod'd afterwards: the + # session token would otherwise be world-readable for the width of the + # write, and a file that already existed would keep its old mode until the + # chmod landed. + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump(data, fh) try: os.chmod(path, 0o600) @@ -146,7 +162,11 @@ def _describe(exc: urllib.error.HTTPError) -> tuple[str, bool]: """Turn an HTTP failure into something a user can act on.""" payload: dict = {} try: - payload = json.loads(exc.read().decode("utf-8")) + parsed = json.loads(exc.read().decode("utf-8")) + # A server can answer with a list or a bare string. Assuming an object + # turns the error path itself into an AttributeError. + if isinstance(parsed, dict): + payload = parsed except Exception: pass detail = payload.get("error") @@ -254,8 +274,13 @@ def backfill_clips(limit: int = 200) -> tuple[int, int]: "aspectRatio": entry.get("format"), "transcriptSlice": entry.get("transcript_slice"), }) - except CloudError: + except CloudError as exc: failed += 1 + # An expired session or a workspace with no subscription answers the + # same way for every remaining clip. Continuing would hash and + # upload another few hundred megabytes to be refused each time. + if exc.status in (401, 402, 403): + break continue if result and result.get("id"): diff --git a/backend/services/transcription_whispercpp.py b/backend/services/transcription_whispercpp.py index 2d61cb4..6d3548c 100644 --- a/backend/services/transcription_whispercpp.py +++ b/backend/services/transcription_whispercpp.py @@ -23,7 +23,7 @@ "tiny", "tiny.en", "base", "base.en", "small", "small.en", "medium", "medium.en", "large.v1", "large.v2", "large.v3", "large.v3-turbo", } -_QUANT_SUFFIX = re.compile(r"-(?:q\d+_\d+|q\d+k[a-z]*|f16|f32)$", re.IGNORECASE) +_QUANT_SUFFIX = re.compile(r"-(?:q\d+(?:_\d+)?(?:_?[a-z]+)*|f16|f32)$", re.IGNORECASE) def _dtw_preset_for_model(model_path: str) -> Optional[str]: diff --git a/src/services/asset-sync.ts b/src/services/asset-sync.ts index 7e79388..1bcc7dd 100644 --- a/src/services/asset-sync.ts +++ b/src/services/asset-sync.ts @@ -122,9 +122,15 @@ export async function pull(): Promise { try { const body = await cloud.downloadAsset(entry.id); await mkdir(dir, { recursive: true }); - const target = join(dir, basename(entry.name)); + // The whole name, flattened: two workspace assets called `intro/logo.png` + // and `outro/logo.png` both end in `logo.png`, and the second download + // would land on the first and leave two registry entries pointing at one + // file. + const target = join(dir, entry.name.replace(/[\\/]+/g, "-")); await writeFile(target, body); - await manager.register(entry.name, target, inferType(target)); + // The workspace already knows what this is. Re-deriving the type from the + // extension turns a `music` asset stored as .mp4 into a video. + await manager.register(entry.name, target, assetType(entry.kind) ?? inferType(target)); report.downloaded.push(entry.name); } catch (err) { report.failed.push({ @@ -136,6 +142,17 @@ export async function pull(): Promise { return report; } +const ASSET_TYPES: readonly AssetType[] = [ + "logo", "outro", "intro", "music", "video", "image", +]; + +/** The workspace's own kind, when it is one this app models. */ +function assetType(kind: string | undefined): AssetType | null { + return kind && (ASSET_TYPES as readonly string[]).includes(kind) + ? (kind as AssetType) + : null; +} + export async function sync(): Promise { const up = await push(); const down = await pull(); diff --git a/src/services/clips-history.ts b/src/services/clips-history.ts index 1a68ab9..3fec3e4 100644 --- a/src/services/clips-history.ts +++ b/src/services/clips-history.ts @@ -128,15 +128,21 @@ export class ClipsHistory { }))?.id; if (!clipId) return; - await this.update(entry.id, { cloud_id: clipId, cloud_synced: true }); + await this.update(entry.id, { cloud_id: clipId }); // Metadata alone leaves a share link with nothing to play, so the // rendered file follows it. Uploaded once: the server keeps the first // copy and answers `unchanged` after that. - if (!entry.cloud_video_uploaded && existsSync(entry.output_path)) { - const uploaded = await cloud.uploadClipVideo(clipId, entry.output_path); - if (uploaded) await this.update(entry.id, { cloud_video_uploaded: true }); + let hasVideo = entry.cloud_video_uploaded === true; + if (!hasVideo && existsSync(entry.output_path)) { + hasVideo = await cloud.uploadClipVideo(clipId, entry.output_path); + if (hasVideo) await this.update(entry.id, { cloud_video_uploaded: true }); } + + // Synchronised means the clip is watchable, not merely described. A + // failed upload that still reported success is how `podcli sync` exits + // happy while every share link plays nothing. + await this.update(entry.id, { cloud_synced: hasVideo }); } catch { await this.update(entry.id, { cloud_synced: false }).catch(() => {}); } finally { @@ -348,7 +354,7 @@ export class ClipsHistory { try { await this.syncToCloud(entry); const after = await this.findById(entry.id); - if (after?.cloud_id) synced++; + if (after?.cloud_synced) synced++; else failed++; } catch { failed++; diff --git a/src/services/knowledge-sync.test.ts b/src/services/knowledge-sync.test.ts index dcdbeea..4a6ccee 100644 --- a/src/services/knowledge-sync.test.ts +++ b/src/services/knowledge-sync.test.ts @@ -20,6 +20,11 @@ const { sync } = await import("./knowledge-sync.js"); describe("knowledge sync", () => { beforeEach(() => { rmSync(join(tmp, "knowledge"), { recursive: true, force: true }); + // The sync state lives beside the folder, not in it. Leaving it behind made + // these tests order-dependent: the pull phase skips any path already in the + // map, so a later test passed only because an earlier one had not recorded + // a version for the same filename. + rmSync(join(tmp, "knowledge-sync.json"), { force: true }); mkdirSync(join(tmp, "knowledge"), { recursive: true }); vi.clearAllMocks(); }); diff --git a/src/services/knowledge-sync.ts b/src/services/knowledge-sync.ts index e419098..304a824 100644 --- a/src/services/knowledge-sync.ts +++ b/src/services/knowledge-sync.ts @@ -1,6 +1,6 @@ import { existsSync } from "fs"; import { mkdir, readFile, readdir, writeFile } from "fs/promises"; -import { dirname, join, resolve, sep } from "path"; +import { dirname, join, relative, resolve, sep } from "path"; import { paths } from "../config/paths.js"; import * as cloud from "./podcli-cloud.js"; @@ -50,7 +50,15 @@ function insideKnowledge(path: string): string | null { async function localFiles(): Promise { if (!existsSync(paths.knowledge)) return []; - return (await readdir(paths.knowledge)).filter((f) => f.endsWith(".md")).sort(); + // Recursive because a pulled file may live in a subdirectory: the workspace + // accepts nested paths, and a flat listing would pull `brand/voice.md` once + // and then never push a local edit to it again. + const entries = await readdir(paths.knowledge, { recursive: true, withFileTypes: true }); + return entries + .filter((e) => e.isFile() && e.name.endsWith(".md")) + .map((e) => relative(paths.knowledge, join(e.parentPath ?? e.path, e.name))) + .map((p) => p.split(sep).join("/")) + .sort(); } export type KnowledgeSyncReport = { diff --git a/src/services/podcli-cloud.ts b/src/services/podcli-cloud.ts index 346076c..c669777 100644 --- a/src/services/podcli-cloud.ts +++ b/src/services/podcli-cloud.ts @@ -158,11 +158,13 @@ export type Preferences = { observations: string[]; }; -export async function getInsights(): Promise { +// Nullable because `request` returns null for an empty body, and a caller that +// trusts the declared shape would dereference it. +export async function getInsights(): Promise { return request("GET", "/v1/insights"); } -export async function getPreferences(): Promise { +export async function getPreferences(): Promise { return request("GET", "/v1/insights/preferences"); } @@ -202,6 +204,7 @@ export async function putKnowledge( method: "PUT", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ path, content, expectedVersion }), + signal: AbortSignal.timeout(30_000), }); // A 409 is an expected outcome here, not an error: someone else edited the @@ -254,6 +257,7 @@ export async function uploadAsset( }, // Node's fetch wants a view, not the Buffer's whole underlying pool. body: new Uint8Array(body), + signal: AbortSignal.timeout(300_000), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`); @@ -267,6 +271,7 @@ export async function downloadAsset(id: string): Promise { const response = await fetch(`${apiUrl()}/v1/assets/${id}/download`, { headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(300_000), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return Buffer.from(await response.arrayBuffer()); diff --git a/src/ui/client/AiSetup.tsx b/src/ui/client/AiSetup.tsx index 2e1ee59..ba00706 100644 --- a/src/ui/client/AiSetup.tsx +++ b/src/ui/client/AiSetup.tsx @@ -45,8 +45,15 @@ export default function AiSetup() { useEffect(() => { fetch("/api/ai-provider-status") - .then((r) => r.json()) - .then(setStatus) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) + // The types say these are always present; the server can answer with an + // error body or a partial payload, and rendering a missing array throws + // out of this component and takes the settings page with it. + .then((payload) => setStatus({ + ...payload, + providers: Array.isArray(payload?.providers) ? payload.providers : [], + candidates: Array.isArray(payload?.candidates) ? payload.candidates : [], + })) .catch(() => setStatus(null)); }, []); diff --git a/src/ui/client/ConfigPage.tsx b/src/ui/client/ConfigPage.tsx index 8b1ad94..aa2b4e5 100644 --- a/src/ui/client/ConfigPage.tsx +++ b/src/ui/client/ConfigPage.tsx @@ -55,7 +55,10 @@ export default function ConfigPage() { } catch { /* settings are optional */ } } + const [aiRefresh, setAiRefresh] = useState(0); + async function refreshAiCli() { + setAiRefresh((n) => n + 1); try { setAiCli(await api("/ai-cli-status")); } catch { @@ -170,7 +173,7 @@ export default function ConfigPage() { )}
- +
AI CLI
diff --git a/tests/test_whispercpp_adapter.py b/tests/test_whispercpp_adapter.py index 161352d..78dea57 100644 --- a/tests/test_whispercpp_adapter.py +++ b/tests/test_whispercpp_adapter.py @@ -27,6 +27,10 @@ def test_preset_tracks_the_model_file(self): "ggml-small.bin": "small", "ggml-large-v3-turbo.bin": "large.v3-turbo", "ggml-base.en-q5_1.bin": "base.en", + # K-quantisation names carry underscores between the qualifiers, and + # failing to strip them dropped -dtw for a model that supports it. + "ggml-large-v3-q4_k_m.gguf": "large.v3", + "ggml-small.en-q8_0.bin": "small.en", } for name, preset in cases.items(): with self.subTest(name=name): From 8b2fe39b288d95ccd252c7407980aa7e0b3c06fb Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sat, 8 Aug 2026 23:33:51 +0400 Subject: [PATCH 5/7] Keep the review fixes off the local path Three of them reached further than the review asked: The recursive knowledge scan used `readdir({ recursive })` and `dirent.parentPath`, which need Node 20.1 and 20.12. podcli supports 18 and CI runs 20, so that crash would have found Node 18 users rather than the build. Walked by hand instead. Dropping `classify_cli_error` entirely took the "run `claude` once in a terminal" advice away from the local CLI users it is written for. It is now skipped only for cloud and API attempts, which is what it was rewriting wrongly. The CLI path tags an attempt with its engine name rather than "cli", so the condition asks the question the other way round. A clip whose rendered file is gone can never be uploaded, so it is settled rather than reported as failed on every run, which is the unfixable number this file already refuses to print. --- backend/services/claude_suggest.py | 12 ++++++++++- src/services/clips-history.ts | 17 ++++++++++----- src/services/knowledge-sync.ts | 34 ++++++++++++++++++++++-------- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/backend/services/claude_suggest.py b/backend/services/claude_suggest.py index 57e5934..d1d7611 100644 --- a/backend/services/claude_suggest.py +++ b/backend/services/claude_suggest.py @@ -553,7 +553,17 @@ def usable(text: str): if progress_callback: progress_callback(0, attempt.error) if error_sink is not None: - error_sink.append(attempt.error) + # Classified only for a CLI failure. The advice it adds is "run + # `claude` once in a terminal", which is right for a local CLI and + # wrong for a workspace session or an API key, and `classify_cli_error` + # matches on "unauthorized" so it rewrote those too. + # The CLI path tags the attempt with its engine name, not "cli", + # so this asks the question the other way round. + error_sink.append( + attempt.error + if attempt.provider in ("cloud", "api") + else classify_cli_error(attempt.error) + ) return None label = attempt.label diff --git a/src/services/clips-history.ts b/src/services/clips-history.ts index 3fec3e4..4ba255a 100644 --- a/src/services/clips-history.ts +++ b/src/services/clips-history.ts @@ -134,15 +134,22 @@ export class ClipsHistory { // rendered file follows it. Uploaded once: the server keeps the first // copy and answers `unchanged` after that. let hasVideo = entry.cloud_video_uploaded === true; - if (!hasVideo && existsSync(entry.output_path)) { + // A rendered file that no longer exists locally can never be uploaded. + // There is nothing left to do for it, and reporting it as failed on every + // run is the unfixable number this file already refuses to print. + const uploadable = !hasVideo && existsSync(entry.output_path); + + if (uploadable) { hasVideo = await cloud.uploadClipVideo(clipId, entry.output_path); if (hasVideo) await this.update(entry.id, { cloud_video_uploaded: true }); } - // Synchronised means the clip is watchable, not merely described. A - // failed upload that still reported success is how `podcli sync` exits - // happy while every share link plays nothing. - await this.update(entry.id, { cloud_synced: hasVideo }); + // Synchronised means the clip is watchable, not merely described: an + // upload that failed while still reporting success is how `podcli sync` + // exits happy with every share link playing nothing. + await this.update(entry.id, { + cloud_synced: hasVideo || !existsSync(entry.output_path), + }); } catch { await this.update(entry.id, { cloud_synced: false }).catch(() => {}); } finally { diff --git a/src/services/knowledge-sync.ts b/src/services/knowledge-sync.ts index 304a824..cbd529c 100644 --- a/src/services/knowledge-sync.ts +++ b/src/services/knowledge-sync.ts @@ -48,17 +48,33 @@ function insideKnowledge(path: string): string | null { return target.startsWith(root + sep) ? target : null; } +/** + * Every .md under the knowledge folder, as workspace-style relative paths. + * + * Walked by hand rather than with `readdir({ recursive })`: that option needs + * Node 20.1 and `dirent.parentPath` needs 20.12, while podcli supports 18. CI + * runs 20, so the crash would have reached Node 18 users rather than the build. + * + * Recursive at all because the workspace accepts nested paths: a flat listing + * pulled `brand/voice.md` once and then never pushed a local edit to it again. + */ async function localFiles(): Promise { if (!existsSync(paths.knowledge)) return []; - // Recursive because a pulled file may live in a subdirectory: the workspace - // accepts nested paths, and a flat listing would pull `brand/voice.md` once - // and then never push a local edit to it again. - const entries = await readdir(paths.knowledge, { recursive: true, withFileTypes: true }); - return entries - .filter((e) => e.isFile() && e.name.endsWith(".md")) - .map((e) => relative(paths.knowledge, join(e.parentPath ?? e.path, e.name))) - .map((p) => p.split(sep).join("/")) - .sort(); + + const found: string[] = []; + const walk = async (dir: string): Promise => { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) await walk(full); + else if (entry.isFile() && entry.name.endsWith(".md")) { + found.push(relative(paths.knowledge, full).split(sep).join("/")); + } + } + }; + + await walk(paths.knowledge); + return found.sort(); } export type KnowledgeSyncReport = { From 9b625694250f1df3ece76bec60017c98e24a309f Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sat, 8 Aug 2026 23:39:16 +0400 Subject: [PATCH 6/7] Drop a dead import and say why 'other' is not honoured The asset kind list omits a type that is valid at both ends, which reads as an oversight. It is not: 'other' is what anything unrecognised uploads as, so taking it back would turn a local video into 'other' on the round trip. --- src/services/asset-sync.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/services/asset-sync.ts b/src/services/asset-sync.ts index 1bcc7dd..6265da1 100644 --- a/src/services/asset-sync.ts +++ b/src/services/asset-sync.ts @@ -1,6 +1,6 @@ import { existsSync } from "fs"; import { mkdir, readFile, writeFile } from "fs/promises"; -import { basename, join } from "path"; +import { join } from "path"; import { paths } from "../config/paths.js"; import { AssetManager, inferType } from "./asset-manager.js"; import * as cloud from "./podcli-cloud.js"; @@ -142,8 +142,16 @@ export async function pull(): Promise { return report; } +/** + * The kinds worth taking back from the workspace. + * + * "other" is deliberately absent even though it is a valid type both ends: it + * is what `cloudKind` uploads anything unrecognised as, so honouring it on the + * way back would turn a local video into "other" on the round trip. Guessing + * from the file is the better answer for exactly that case. + */ const ASSET_TYPES: readonly AssetType[] = [ - "logo", "outro", "intro", "music", "video", "image", + "logo", "outro", "intro", "music", "video", "image", "audio", ]; /** The workspace's own kind, when it is one this app models. */ From a7c858364d7d61146b4b8fd3f6d9280ccf539aac Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Sat, 8 Aug 2026 23:43:52 +0400 Subject: [PATCH 7/7] Finish the three the review only half got Two of the earlier fixes stopped at the first call site, and one of them created a new hole: - The "no AI available" message was corrected in one of the two places that print it. `handle_generate_custom` still sent cloud users to install a binary they will never use. - `AiSetup` learned to reject a bad payload; `WorkspaceInsights` reads the same one and still dereferenced arrays the server can omit. - Requiring `clips` to be a non-empty list still admits a null or a string inside it, and the alternate responses were not checked at all, so `.get` on one of those raised out of a path with nothing above it to catch. Clip records, their scores and their segments are now each checked before use. --- backend/main.py | 5 ++++- backend/services/claude_suggest.py | 30 +++++++++++++++++++++++------ src/ui/client/WorkspaceInsights.tsx | 9 ++++++--- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/backend/main.py b/backend/main.py index a9cbc2c..6368b43 100644 --- a/backend/main.py +++ b/backend/main.py @@ -844,7 +844,10 @@ def handle_generate_custom(task_id: str, params: dict): ) if result is None: - emit_result(task_id, "error", error="No AI CLI available (install Claude Code or Codex)") + # The gate above admits a workspace session or an API key as well as a + # local binary, so naming only the binary sends cloud users to install + # something they will never use. + emit_result(task_id, "error", error="No AI provider available — sign in to podcli Pro, install Claude Code or Codex, or set ANTHROPIC_API_KEY") return emit_result(task_id, "success", data=result) diff --git a/backend/services/claude_suggest.py b/backend/services/claude_suggest.py index d1d7611..57f4641 100644 --- a/backend/services/claude_suggest.py +++ b/backend/services/claude_suggest.py @@ -571,20 +571,38 @@ def usable(text: str): # Several independent searches over the same transcript find overlapping but # not identical moments. Keeping the union and re-ranking beats picking one # set, and the dedupe/scoring below already exists for exactly this shape. - clips = list(ai_provider.extract_json(attempt.text)["clips"]) + def records(payload: object) -> list[dict]: + """ + The clip objects in a response, and only those. + + `usable` checked the primary response is a non-empty list, which still + admits a null or a string inside it, and the alternates are not checked + at all: `.get` on any of those is an AttributeError out of a code path + with nothing above it to catch. + """ + if not isinstance(payload, dict): + return [] + found = payload.get("clips") + if not isinstance(found, list): + return [] + return [c for c in found if isinstance(c, dict)] + + clips = records(ai_provider.extract_json(attempt.text)) for alternate in attempt.alternates: - parsed = ai_provider.extract_json(alternate) - if isinstance(parsed, dict): - clips.extend(parsed.get("clips") or []) + clips.extend(records(ai_provider.extract_json(alternate))) normalized = [] for c in clips: - scores = c.get("scores", {}) + scores = c.get("scores") + scores = scores if isinstance(scores, dict) else {} total = sum(scores.values()) if scores else c.get("total_score", 0) - raw_segments = c.get("segments", []) + raw_segments = c.get("segments") + raw_segments = raw_segments if isinstance(raw_segments, list) else [] keep_segments = [] for seg in raw_segments: + if not isinstance(seg, dict): + continue s = round(_parse_seconds(seg.get("start", 0)), 1) e = round(_parse_seconds(seg.get("end", 0)), 1) if e > s: diff --git a/src/ui/client/WorkspaceInsights.tsx b/src/ui/client/WorkspaceInsights.tsx index ff729f5..b21583e 100644 --- a/src/ui/client/WorkspaceInsights.tsx +++ b/src/ui/client/WorkspaceInsights.tsx @@ -33,7 +33,7 @@ export default function WorkspaceInsights() { useEffect(() => { fetch("/api/pro/insights") - .then((r) => r.json()) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) .then(setData) .catch(() => setData({ signedIn: false })); }, []); @@ -41,8 +41,11 @@ export default function WorkspaceInsights() { if (!data?.signedIn || !data.insights) return null; const { insights, preferences } = data; - const hasModel = insights.guidance.length > 0; - const hasStyle = (preferences?.observations.length ?? 0) > 0; + // The types say these arrays are always there. The server can answer with a + // partial payload, and reading a missing one throws out of this panel and + // takes the page it sits on with it. + const hasModel = (insights.guidance?.length ?? 0) > 0; + const hasStyle = (preferences?.observations?.length ?? 0) > 0; // Signed in but nothing learned yet. Say why, and say what changes it — // an empty panel with no explanation reads as broken.