1#!/usr/bin/env -S uv run --script --quiet 2# /// script 3# requires-python = ">=3.12" 4# dependencies = [ 5# "agentic-learning>=0.4.0", 6# "anthropic>=0.40.0", 7# "pydantic-settings>=2.0.0", 8# ] 9# [tool.uv] 10# prerelease = "allow" 11# /// 12"""letta-backed status maintenance for plyr.fm. 13 14this script replaces the claude-code-action in the status maintenance workflow. 15it uses letta's learning SDK to maintain persistent memory across runs. 16 17the memory is about PROJECT UNDERSTANDING (architecture, patterns, context), 18NOT about processing history. github is the source of truth for what needs 19processing (last merged PR date → now). 20 21usage: 22 # full maintenance run (archive, generate script, create audio) 23 uv run scripts/status_maintenance.py 24 25 # skip audio generation 26 uv run scripts/status_maintenance.py --skip-audio 27 28 # dry run (no file changes) 29 uv run scripts/status_maintenance.py --dry-run 30 31environment variables: 32 LETTA_API_KEY - letta cloud API key 33 ANTHROPIC_API_KEY - anthropic API key 34 GOOGLE_API_KEY - gemini TTS (for audio generation) 35""" 36 37import argparse 38import json 39import os 40import subprocess 41import sys 42from datetime import datetime 43from pathlib import Path 44 45from pydantic import BaseModel, Field 46from pydantic_settings import BaseSettings, SettingsConfigDict 47 48AGENT_NAME = "plyr-status-maintenance" 49# memory blocks are about PROJECT UNDERSTANDING, not processing history 50# the agent should remember architecture, patterns, and context - NOT what it processed 51MEMORY_BLOCKS = [ 52 "project_architecture", # how plyr.fm is built, key design decisions 53 "atproto_context", # understanding of ATProto, lexicons, NSIDs 54 "recurring_patterns", # themes that come up repeatedly in development 55] 56PROJECT_ROOT = Path(__file__).parent.parent 57 58 59class Settings(BaseSettings): 60 """settings for status maintenance.""" 61 62 model_config = SettingsConfigDict( 63 env_file=PROJECT_ROOT / ".env", 64 case_sensitive=False, 65 extra="ignore", 66 ) 67 68 letta_api_key: str = Field(validation_alias="LETTA_API_KEY") 69 anthropic_api_key: str = Field(validation_alias="ANTHROPIC_API_KEY") 70 google_api_key: str = Field(default="", validation_alias="GOOGLE_API_KEY") 71 model: str = Field( 72 default="claude-opus-4-5-20251101", validation_alias="ANTHROPIC_MODEL" 73 ) 74 75 76class MaintenanceReport(BaseModel): 77 """structured output for status maintenance report.""" 78 79 archive_needed: bool = Field( 80 description="true if STATUS.md > 400 lines and old content should be archived" 81 ) 82 archive_content: str = Field( 83 description="content to move to .status_history/YYYY-MM.md (verbatim, oldest sections). empty string if no archival needed." 84 ) 85 status_updates: str = Field( 86 description="new content to add to the '## recent work' section of STATUS.md" 87 ) 88 podcast_script: str = Field( 89 description="2-3 minute podcast script with 'Host:' and 'Cohost:' lines following the tone and structure guidelines" 90 ) 91 92 93def run_cmd(cmd: list[str], capture: bool = True) -> str: 94 """run a command and return output.""" 95 result = subprocess.run(cmd, capture_output=capture, text=True, cwd=PROJECT_ROOT) 96 return result.stdout.strip() if capture else "" 97 98 99def get_last_maintenance_date() -> str | None: 100 """get the merge date of the last status-maintenance PR. 101 102 this is the SOURCE OF TRUTH for what time window to process. 103 NOT the agent's memory - github is authoritative. 104 """ 105 try: 106 result = run_cmd( 107 [ 108 "gh", 109 "pr", 110 "list", 111 "--state", 112 "merged", 113 "--search", 114 "status-maintenance", 115 "--limit", 116 "20", 117 "--json", 118 "number,title,mergedAt,headRefName", 119 ] 120 ) 121 if not result: 122 return None 123 124 prs = json.loads(result) 125 # filter to status-maintenance branches and sort by merge date 126 maintenance_prs = [ 127 pr 128 for pr in prs 129 if pr.get("headRefName", "").startswith("status-maintenance-") 130 ] 131 if not maintenance_prs: 132 return None 133 134 # sort by mergedAt descending 135 maintenance_prs.sort(key=lambda x: x.get("mergedAt", ""), reverse=True) 136 return maintenance_prs[0].get("mergedAt", "").split("T")[0] 137 except Exception: 138 return None 139 140 141def get_recent_commits(since: str | None = None, limit: int = 50) -> str: 142 """get recent commits, optionally since a date.""" 143 cmd = ["git", "log", "--oneline", f"-{limit}"] 144 if since: 145 cmd.extend(["--since", since]) 146 return run_cmd(cmd) 147 148 149def get_merged_prs(since: str | None = None, limit: int = 30) -> str: 150 """get merged PRs with details.""" 151 search = f"merged:>={since}" if since else "" 152 cmd = [ 153 "gh", 154 "pr", 155 "list", 156 "--state", 157 "merged", 158 "--limit", 159 str(limit), 160 "--json", 161 "number,title,body,mergedAt,additions,deletions", 162 ] 163 if search: 164 cmd.extend(["--search", search]) 165 return run_cmd(cmd) 166 167 168def get_status_md_line_count() -> int: 169 """get current line count of STATUS.md.""" 170 status_file = PROJECT_ROOT / "STATUS.md" 171 if status_file.exists(): 172 return len(status_file.read_text().splitlines()) 173 return 0 174 175 176def read_status_md() -> str: 177 """read current STATUS.md content.""" 178 status_file = PROJECT_ROOT / "STATUS.md" 179 if status_file.exists(): 180 return status_file.read_text() 181 return "" 182 183 184def check_status_history_exists() -> bool: 185 """check if .status_history/ directory exists (implies not first episode).""" 186 return (PROJECT_ROOT / ".status_history").exists() 187 188 189def generate_maintenance_report( 190 settings: Settings, 191 last_maintenance: str | None, 192 dry_run: bool = False, 193) -> MaintenanceReport: 194 """generate the maintenance report using letta-backed claude with structured outputs. 195 196 uses anthropic's structured outputs beta for guaranteed schema compliance. 197 """ 198 # SDK's capture() reads from os.environ 199 os.environ["LETTA_API_KEY"] = settings.letta_api_key 200 201 import anthropic 202 from agentic_learning import AgenticLearning, learning 203 204 # initialize clients 205 letta_client = AgenticLearning(api_key=settings.letta_api_key) 206 anthropic_client = anthropic.Anthropic(api_key=settings.anthropic_api_key) 207 208 # ensure agent exists 209 existing = letta_client.agents.retrieve(agent=AGENT_NAME) 210 if not existing: 211 print(f"creating letta agent '{AGENT_NAME}'...") 212 letta_client.agents.create( 213 agent=AGENT_NAME, 214 memory=MEMORY_BLOCKS, 215 model=f"anthropic/{settings.model}", 216 ) 217 print(f"✓ agent created with memory blocks: {MEMORY_BLOCKS}") 218 219 # gather context - read the FULL STATUS.md, not truncated 220 today = datetime.now().strftime("%Y-%m-%d") 221 today_human = datetime.now().strftime("%B %d, %Y") 222 commits = get_recent_commits(since=last_maintenance) 223 prs = get_merged_prs(since=last_maintenance) 224 status_content = read_status_md() 225 line_count = get_status_md_line_count() 226 has_history = check_status_history_exists() 227 is_first_episode = not has_history 228 229 if last_maintenance: 230 time_window = f"since {last_maintenance}" 231 time_window_human = f"from {last_maintenance} to {today}" 232 else: 233 time_window = "all time (first run)" 234 time_window_human = f"up to {today}" 235 236 system_prompt = f"""you maintain STATUS.md for plyr.fm (pronounced "player FM"), a decentralized 237music streaming platform on AT Protocol. 238 239## memory usage 240 241your letta memory persists across runs. remember: 242- architecture and design decisions 243- ATProto concepts (lexicons, NSIDs, PDS) 244- recurring development patterns 245 246do NOT track what you processed - github determines the time window. 247 248## rules 249 250- STATUS.md must stay under 500 lines 251- archive old content to .status_history/, never delete 252- podcast tone: dry, matter-of-fact, sardonic - never enthusiastic 253 254## context 255 256today: {today_human} 257last maintenance PR: {last_maintenance or "none (first run)"} 258time window: {time_window_human} 259STATUS.md lines: {line_count} 260first episode: {is_first_episode} 261 262focus on what shipped {time_window}. if last PR merged Dec 2nd and today is Dec 8th, 263cover Dec 3rd onwards - not "the last week". 264 265## commits ({time_window}): 266{commits} 267 268## merged PRs ({time_window}): 269{prs} 270 271## STATUS.md: 272{status_content} 273 274## tasks 275 2761. **archival**: if > 400 lines, move oldest sections to .status_history/YYYY-MM.md (by month) 2772. **status_updates**: new content for "## recent work" - concise, technical, what shipped and why 2783. **podcast_script**: "Host:" and "Cohost:" dialogue, 2-3 min (4-5 min if first episode) 279 280## podcast requirements 281 282**pronunciation**: ALWAYS write "player FM" - never "plyr.fm" or "plyr" (TTS will mispronounce it) 283 284**time references**: use specific dates ("since December 2nd"), never "last week" or "recently" 285 286**structure**: tell a coherent story 287- opening: set the date range and focus 288- main story: biggest thing that shipped, design discussion between hosts 289- secondary: other significant changes (lighter treatment) 290- rapid fire: bug fixes, polish, minor improvements 291- closing: wrap up 292 293**tone**: two engineers who are skeptical, amused by the absurdity of building things. 294acknowledge limitations honestly. explain through analogy, not jargon. 295avoid: "exciting", "amazing", "incredible", "great job", any over-congratulating. 296 297**what shipped**: read commits/PRs carefully. new things "shipped", improvements are "fixes" or "polish". 298don't trust commit prefixes - read the actual content. 299""" 300 301 print(f"generating maintenance report for {time_window}...") 302 303 with learning(agent=AGENT_NAME, client=letta_client, memory=MEMORY_BLOCKS): 304 # use structured outputs beta for guaranteed schema compliance 305 response = anthropic_client.beta.messages.parse( 306 model=settings.model, 307 max_tokens=8192, 308 betas=["structured-outputs-2025-11-13"], 309 system=system_prompt, 310 messages=[ 311 { 312 "role": "user", 313 "content": f"""analyze what shipped {time_window} and generate the maintenance report. 314 315remember key architectural insights and patterns for future runs - but do NOT 316remember "what you processed" since github is the source of truth for that.""", 317 } 318 ], 319 output_format=MaintenanceReport, 320 ) 321 322 # structured outputs gives us the parsed model directly 323 report = response.parsed_output 324 325 print("✓ report generated") 326 print(f" archive needed: {report.archive_needed}") 327 print(f" status updates: {len(report.status_updates)} chars") 328 print(f" podcast script: {len(report.podcast_script)} chars") 329 330 return report 331 332 333def apply_maintenance(report: MaintenanceReport, dry_run: bool = False) -> list[str]: 334 """apply the maintenance report to files. 335 336 returns list of modified files. 337 """ 338 modified_files = [] 339 340 # handle archival 341 if report.archive_needed and report.archive_content: 342 archive_dir = PROJECT_ROOT / ".status_history" 343 archive_file = archive_dir / f"{datetime.now().strftime('%Y-%m')}.md" 344 345 if dry_run: 346 print(f"[dry-run] would archive to {archive_file}") 347 else: 348 archive_dir.mkdir(exist_ok=True) 349 # append to existing month file or create new 350 mode = "a" if archive_file.exists() else "w" 351 with open(archive_file, mode) as f: 352 if mode == "a": 353 f.write("\n\n---\n\n") 354 f.write(report.archive_content) 355 modified_files.append(str(archive_file)) 356 print(f"✓ archived content to {archive_file}") 357 358 # update STATUS.md 359 if report.status_updates: 360 status_file = PROJECT_ROOT / "STATUS.md" 361 if dry_run: 362 print(f"[dry-run] would update {status_file}") 363 else: 364 # read current content 365 current = status_file.read_text() if status_file.exists() else "" 366 367 # find "## recent work" section and insert after it 368 if "## recent work" in current: 369 parts = current.split("## recent work", 1) 370 # find the next section or end 371 after_header = parts[1] 372 # insert new content after the header line 373 lines = after_header.split("\n", 1) 374 new_content = ( 375 parts[0] 376 + "## recent work" 377 + lines[0] 378 + "\n\n" 379 + report.status_updates 380 + "\n" 381 + (lines[1] if len(lines) > 1 else "") 382 ) 383 status_file.write_text(new_content) 384 else: 385 # no recent work section, append to end 386 with open(status_file, "a") as f: 387 f.write(f"\n## recent work\n\n{report.status_updates}\n") 388 389 modified_files.append(str(status_file)) 390 print(f"✓ updated {status_file}") 391 392 # write podcast script 393 if report.podcast_script: 394 script_file = PROJECT_ROOT / "podcast_script.txt" 395 if dry_run: 396 print(f"[dry-run] would write {script_file}") 397 else: 398 script_file.write_text(report.podcast_script) 399 modified_files.append(str(script_file)) 400 print(f"✓ wrote podcast script to {script_file}") 401 402 return modified_files 403 404 405def generate_audio(settings: Settings, dry_run: bool = False) -> str | None: 406 """generate audio from podcast script. 407 408 returns path to audio file or None. 409 """ 410 script_file = PROJECT_ROOT / "podcast_script.txt" 411 audio_file = PROJECT_ROOT / "update.wav" 412 413 if not script_file.exists(): 414 print("no podcast script found, skipping audio generation") 415 return None 416 417 if not settings.google_api_key: 418 print("GOOGLE_API_KEY not set, skipping audio generation") 419 return None 420 421 if dry_run: 422 print(f"[dry-run] would generate audio: {audio_file}") 423 return None 424 425 print("generating audio...") 426 result = subprocess.run( 427 ["uv", "run", "scripts/generate_tts.py", str(script_file), str(audio_file)], 428 capture_output=True, 429 text=True, 430 cwd=PROJECT_ROOT, 431 env={**os.environ, "GOOGLE_API_KEY": settings.google_api_key}, 432 ) 433 434 if result.returncode != 0: 435 print(f"audio generation failed: {result.stderr}") 436 return None 437 438 # cleanup script file 439 script_file.unlink() 440 print(f"✓ generated {audio_file}") 441 return str(audio_file) 442 443 444def main() -> None: 445 """main entry point.""" 446 parser = argparse.ArgumentParser(description="letta-backed status maintenance") 447 parser.add_argument( 448 "--skip-audio", action="store_true", help="skip audio generation" 449 ) 450 parser.add_argument("--dry-run", action="store_true", help="don't modify files") 451 args = parser.parse_args() 452 453 print("=" * 60) 454 print("plyr.fm status maintenance (letta-backed)") 455 print("=" * 60) 456 457 # load settings 458 try: 459 settings = Settings() 460 except Exception as e: 461 print(f"error loading settings: {e}") 462 print("\nrequired environment variables:") 463 print(" LETTA_API_KEY") 464 print(" ANTHROPIC_API_KEY") 465 print(" GOOGLE_API_KEY (optional, for audio)") 466 sys.exit(1) 467 468 # determine time window from GITHUB (source of truth), not agent memory 469 last_maintenance = get_last_maintenance_date() 470 if last_maintenance: 471 print(f"last maintenance PR merged: {last_maintenance}") 472 else: 473 print("no previous maintenance PR found - first run") 474 475 # generate report 476 report = generate_maintenance_report(settings, last_maintenance, args.dry_run) 477 478 # apply changes 479 modified_files = apply_maintenance(report, args.dry_run) 480 481 # generate audio 482 if not args.skip_audio: 483 audio_file = generate_audio(settings, args.dry_run) 484 if audio_file: 485 modified_files.append(audio_file) 486 487 print("\n" + "=" * 60) 488 if args.dry_run: 489 print("[dry-run] no files modified") 490 elif modified_files: 491 print(f"modified files: {len(modified_files)}") 492 for f in modified_files: 493 print(f" - {f}") 494 else: 495 print("no changes needed") 496 497 # output for CI 498 if modified_files and not args.dry_run: 499 # write modified files list for CI 500 with open(PROJECT_ROOT / ".modified_files", "w") as f: 501 f.write("\n".join(modified_files)) 502 503 504if __name__ == "__main__": 505 main()