music on atproto
plyr.fm
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()