feat: letta-backed status maintenance (#529)

* feat: letta-backed status maintenance

replaces claude-code-action with a letta-backed python script that
maintains persistent memory across runs. the agent remembers:

- previous status updates and their content
- architectural decisions and patterns
- recurring themes in development

this means the agent doesn't need to re-read everything from scratch
each week - it builds on accumulated context.

new files:
- scripts/status_maintenance.py - main CI script
- scripts/letta_status_agent.py - local testing/interaction

workflow changes:
- uses uv run scripts/status_maintenance.py instead of claude-code-action
- adds LETTA_API_KEY secret requirement
- adds --dry-run option for testing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: use structured outputs and read full STATUS.md

- use anthropic's structured outputs beta (structured-outputs-2025-11-13)
with Pydantic model for guaranteed schema compliance
- read the full STATUS.md content, not truncated to 3000 chars
- remove manual JSON parsing/begging - let the API handle it

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: use opus 4.5 by default, make model configurable

- add model setting defaulting to claude-opus-4-5-20251101
- configurable via ANTHROPIC_MODEL env var
- use settings.model everywhere instead of hardcoded sonnet

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: memory blocks for project understanding, restore full prompt guidance

memory blocks refactored:
- project_architecture: how plyr.fm is built, key design decisions
- atproto_context: understanding of ATProto, lexicons, NSIDs
- recurring_patterns: themes that come up repeatedly

the agent no longer tracks 'what it processed' - github (last merged PR)
is the source of truth for the time window. memory is purely for
accumulating project understanding.

restored all original prompt guidance:
- full narrative structure (opening, main story, secondary, rapid fire, closing)
- tone requirements (skeptical, amused, no superlatives)
- pronunciation rules (player FM, never plyr.fm)
- time reference rules (specific dates, never 'last week')
- identifying what shipped vs improved
- first episode detection and longer script guidance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: tighten system prompt (-75 lines)

preserve critical guidance:
- pronunciation (player FM)
- time bounds (specific dates)
- narrative structure
- tone (dry, sardonic)

remove redundancy:
- consolidated CRITICAL markers
- merged duplicate tone sections
- simplified structure timing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub e72feae5 ca9ddd7e

Changed files
+837 -195
.github
scripts
+75 -195
.github/workflows/status-maintenance.yml
··· 1 - # status maintenance via claude code 1 + # status maintenance via letta-backed agent 2 2 # 3 3 # two-phase workflow: 4 - # 1. workflow_dispatch: archives old STATUS.md sections, generates audio, opens PR 4 + # 1. workflow_dispatch: runs letta-backed script to archive, generate audio, open PR 5 5 # 2. on PR merge: uploads audio to plyr.fm 6 + # 7 + # the letta agent maintains persistent memory across runs, remembering: 8 + # - previous status updates and their content 9 + # - architectural decisions and patterns 10 + # - recurring themes in development 6 11 # 7 12 # required secrets: 8 - # ANTHROPIC_API_KEY - claude code 13 + # LETTA_API_KEY - letta cloud API key (for persistent memory) 14 + # ANTHROPIC_API_KEY - anthropic API key (for claude) 9 15 # GOOGLE_API_KEY - gemini TTS (for audio generation) 10 16 # PLYR_BOT_TOKEN - plyr.fm developer token (for audio upload) 11 17 ··· 21 27 description: "skip audio generation" 22 28 type: boolean 23 29 default: false 30 + dry_run: 31 + description: "dry run (no file changes)" 32 + type: boolean 33 + default: false 24 34 pull_request: 25 35 types: [closed] 26 36 branches: [main] 27 37 28 38 jobs: 29 - # phase 1: archive + generate audio + open PR 39 + # phase 1: run letta-backed maintenance script + open PR 30 40 maintain: 31 41 if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' 32 42 runs-on: ubuntu-latest 33 43 permissions: 34 44 contents: write 35 45 pull-requests: write 36 - id-token: write 37 46 38 47 steps: 39 48 - uses: actions/checkout@v4 ··· 42 51 43 52 - uses: astral-sh/setup-uv@v4 44 53 45 - - uses: anthropics/claude-code-action@v1 46 - with: 47 - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 48 - claude_args: | 49 - --model opus 50 - --allowedTools "Read,Write,Edit,Bash,Fetch,Task" 51 - prompt: | 52 - you are maintaining the plyr.fm (pronounced "player FM") project status file. 53 - 54 - ## critical rules 55 - 56 - 1. STATUS.md MUST be kept under 500 lines. this is non-negotiable. 57 - 2. archive content MUST be moved to .status_history/, not deleted 58 - 3. podcast tone MUST be dry, matter-of-fact, slightly sardonic - NOT enthusiastic or complimentary 59 - 60 - ## task 1: gather temporal context 61 - 62 - CRITICAL: you must determine the correct time window by finding when the LAST status maintenance PR was MERGED (not opened). 63 - 64 - run these commands: 65 - ```bash 66 - date 67 - # get the most recently merged status-maintenance PR (filter by branch name, sort by merge date) 68 - gh pr list --state merged --search "status-maintenance" --limit 20 --json number,title,mergedAt,headRefName | jq '[.[] | select(.headRefName | startswith("status-maintenance-"))] | sort_by(.mergedAt) | reverse | .[0]' 69 - git log --oneline -50 70 - ls -la .status_history/ 2>/dev/null || echo "no archive directory yet" 71 - wc -l STATUS.md 72 - ``` 73 - 74 - determine: 75 - - what is today's date? 76 - - when was the last status-maintenance PR MERGED? (use the mergedAt field from the jq output - it's the most recent PR with a branch starting with "status-maintenance-") 77 - - what shipped SINCE that merge date? (this is your focus window - NOT "last week") 78 - - does .status_history/ exist? (this implies whether or not this is the first episode) 79 - - how many lines is STATUS.md currently? 80 - 81 - IMPORTANT: the time window for this maintenance run is from the last merged status-maintenance PR until now. if the last PR was merged on Dec 2nd and today is Dec 8th, you should focus on everything from Dec 3rd onwards, NOT just "the last week". 82 - 83 - ## task 2: archive old sections (MANDATORY if over 250 lines) 84 - 85 - if STATUS.md > 500 lines: 86 - 1. create .status_history/ directory if it doesn't exist 87 - 2. identify section boundaries (look for "---" separators and "### " headers with dates) 88 - 3. move OLDEST sections to .status_history/YYYY-MM.md (grouped by month) 89 - 4. compact the meaning of the original entire STATUS.md into about 500 lines or less 90 - 5. generally preserve the document structure (keep "## recent work" header, "## immediate priorities", etc) 91 - 6. do NOT summarize archived content - move it verbatim and organize it chronologically 92 - 93 - ARCHIVE FILE NAMING - CRITICAL: 94 - - archive files are organized BY MONTH: .status_history/YYYY-MM.md 95 - - if today is December 2025, archived December content goes to .status_history/2025-12.md 96 - - if today is January 2026, archived January content goes to .status_history/2026-01.md 97 - - check what files already exist in .status_history/ and ADD to the appropriate month file if it exists 98 - - each month gets ONE file - append to existing month files, don't create duplicates 99 - 100 - so STATUS.md is the living overview, slightly recency biased, but a good general overview of the project. 101 - 102 - .status_history/ is the archive of temporally specific sections of STATUS.md that are worth preserving for historical context, but not significant enough to be stated literally in STATUS.md in perpetuity. 103 - 104 - VERIFY: run `wc -l STATUS.md` after archiving. it MUST be under 500 lines. 105 - 106 - ## task 3: generate audio overview (if skip_audio is false) 107 - 108 - skip_audio input: ${{ inputs.skip_audio }} 109 - 110 - if skip_audio is false: 111 - 112 - ### deep investigation phase 113 - 114 - before writing anything, you need to deeply understand what happened in the time window. 115 - use subagents liberally to investigate in parallel: 116 - 117 - 1. **get the full picture of PRs merged in the time window**: 118 - ```bash 119 - gh pr list --state merged --search "merged:>={mergedAt date}" --limit 50 --json number,title,body,mergedAt,additions,deletions,files 120 - ``` 121 - 122 - 2. **for each significant PR, read its body and understand the design decisions**: 123 - - what problem was being solved? 124 - - what approach was taken and why? 125 - - what are the key files changed? 126 - 127 - 3. **read the actual code changes** for the top 2-3 most significant PRs: 128 - - use `gh pr diff {number}` or read the changed files directly 129 - - understand the architecture, not just the commit messages 130 - 131 - 4. **read background context**: 132 - - STATUS.md (the current state) 133 - - docs/deployment/overview.md if it exists 134 - - Fetch https://atproto.com/guides/overview to understand ATProto primitives 135 - - Fetch https://atproto.com/guides/lexicon to understand NSIDs and lexicons 136 - 137 - ### identify the narrative structure 138 - 139 - after investigating, categorize what shipped: 140 - 141 - **big ticket items** (1-3 major features or architectural changes): 142 - - these get the most airtime (60-70% of the script) 143 - - explain HOW they were designed, not just WHAT they do 144 - - discuss interesting technical decisions or tradeoffs 145 - 146 - **smaller but notable changes** (3-6 fixes, improvements, polish): 147 - - these get rapid-fire coverage (20-30% of the script) 148 - - one or two sentences each 149 - - acknowledge they happened without belaboring them 150 - 151 - ### write the podcast script 152 - 153 - write to podcast_script.txt with "Host: ..." and "Cohost: ..." lines. 154 - 155 - **CHRONOLOGICAL NARRATIVE STRUCTURE** (CRITICAL): 156 - 157 - the script must tell a coherent story of the time period, structured as: 158 - 159 - 1. **opening** (10 seconds): set the scene - what's the date range, what was the focus? 160 - 161 - 2. **the main story** (60-90 seconds): the biggest thing that shipped 162 - - what problem did it solve? 163 - - how was it designed? (explain the architecture accessibly) 164 - - what's interesting about the implementation? 165 - - the hosts should have a back-and-forth discussing the design 54 + - name: Run letta-backed status maintenance 55 + id: maintenance 56 + run: | 57 + ARGS="" 58 + if [ "${{ inputs.skip_audio }}" = "true" ]; then 59 + ARGS="$ARGS --skip-audio" 60 + fi 61 + if [ "${{ inputs.dry_run }}" = "true" ]; then 62 + ARGS="$ARGS --dry-run" 63 + fi 166 64 167 - 3. **secondary feature** (30-45 seconds, if applicable): another significant change 168 - - lighter treatment than the main story 169 - - still explain the "why" not just the "what" 65 + uv run scripts/status_maintenance.py $ARGS 170 66 171 - 4. **rapid fire** (20-30 seconds): the smaller changes 172 - - "we also saw..." or "a few other things landed..." 173 - - quick hits: bug fixes, polish, minor improvements 174 - - don't dwell, just acknowledge 67 + # check if files were modified 68 + if [ -f .modified_files ]; then 69 + echo "files_modified=true" >> $GITHUB_OUTPUT 70 + else 71 + echo "files_modified=false" >> $GITHUB_OUTPUT 72 + fi 73 + env: 74 + LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }} 75 + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} 76 + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} 175 77 176 - 5. **closing** (10 seconds): looking ahead or wrapping up 78 + - name: Create PR 79 + if: steps.maintenance.outputs.files_modified == 'true' && inputs.dry_run != true 80 + run: | 81 + # generate unique branch name 82 + BRANCH="status-maintenance-$(date +%Y%m%d-%H%M%S)" 177 83 178 - the narrative should flow like you're telling a friend what happened on the project this week. 179 - use transitions: "but before that landed...", "meanwhile...", "and then to tie it together..." 84 + # configure git 85 + git config user.name "github-actions[bot]" 86 + git config user.email "github-actions[bot]@users.noreply.github.com" 180 87 181 - ### tone requirements (CRITICAL) 88 + # create branch and commit 89 + git checkout -b "$BRANCH" 90 + git add .status_history/ STATUS.md update.wav 2>/dev/null || true 91 + git add -A # catch any other changes 182 92 183 - the hosts should sound like two engineers who: 184 - - are skeptical, amused and somewhat intrigued by the absurdity of building things 185 - - acknowledge problems and limitations honestly 186 - - don't over-use superlatives ("amazing", "incredible", "exciting") 187 - - explain technical concepts through analogy, not hypey jargon 188 - - genuinely find the technical details interesting (not performatively enthusiastic) 93 + # check if there are changes to commit 94 + if git diff --cached --quiet; then 95 + echo "No changes to commit" 96 + exit 0 97 + fi 189 98 190 - avoid excessive phrasing: 191 - - "exciting", "amazing", "incredible", "impressive", "great job" 192 - - "the team has done", "they've really", "fantastic work" 193 - - any variation of over-congratulating or over-sensationalizing the project 99 + git commit -m "chore: status maintenance (letta-backed) 194 100 195 - ### pronunciation (CRITICAL - READ THIS CAREFULLY) 101 + 🤖 Generated with letta-backed status agent 102 + " 196 103 197 - the project name "plyr.fm" is pronounced "player FM" (like "music player"). 104 + git push -u origin "$BRANCH" 198 105 199 - **in your script, ALWAYS write "player FM" or "player dot FM" - NEVER write "plyr.fm" or "plyr".** 106 + # create PR 107 + gh pr create \ 108 + --title "chore: status maintenance - $(date +'%B %d, %Y')" \ 109 + --body "## automated status maintenance 200 110 201 - the TTS engine will mispronounce "plyr" as "plir" or "p-l-y-r" if you write it that way. 202 - write phonetically for correct pronunciation: "player FM", "player dot FM". 203 - 204 - ### identifying what actually shipped 205 - 206 - read the commit messages and PR bodies carefully to understand what changed. 207 - 208 - - if something is completely NEW (didn't exist before), say it "shipped" or "launched" 209 - - if something existing got improved or fixed, call it what it is: fixes, improvements, polish 111 + this PR was generated by the letta-backed status maintenance agent. 210 112 211 - don't rely on commit message prefixes like `feat:` or `fix:` - they're not always accurate. 212 - read the actual content to understand the scope of what changed. 113 + ### what's included 114 + - STATUS.md updates (if any changes were detected) 115 + - archived content moved to .status_history/ (if needed) 116 + - update.wav podcast audio (if audio generation was enabled) 213 117 214 - ### time references (CRITICAL) 118 + ### letta memory 119 + the agent maintains persistent memory across runs, improving context 120 + and consistency over time. 215 121 216 - NEVER say "last week", "this week", "recently", or vague time references. 217 - 218 - ALWAYS use specific date ranges based on the mergedAt date from task 1: 219 - - "since December 2nd" or "from December 3rd to today" 220 - - "in the past six days" (if that's accurate) 221 - - "since the last update" 222 - 223 - the listener doesn't know when "last week" was - be specific. 224 - 225 - target length: 2-3 minutes spoken (~300-400 words) (it should be 4-5 if its the first episode) 226 - 227 - ### generate audio 228 - 229 - run: uv run scripts/generate_tts.py podcast_script.txt update.wav 230 - then: rm podcast_script.txt 231 - 232 - ## task 4: open PR 233 - 234 - if any files changed: 235 - 1. first, generate a unique branch name: BRANCH="status-maintenance-$(date +%Y%m%d-%H%M%S)" 236 - 2. git checkout -b $BRANCH 237 - 3. git add .status_history/ STATUS.md update.wav 238 - 4. git commit -m "chore: status maintenance" 239 - 5. git push -u origin $BRANCH 240 - 6. gh pr create with a title and body you craft: 241 - - title should be descriptive of what this status update covers (e.g. "chore: status maintenance - playlist fast-follow fixes" or "chore: status maintenance - December updates") 242 - - make it clear this is an automated status maintenance PR from the GitHub Action 243 - - body should summarize what changed (archival, audio generation, etc.) 244 - 245 - add a label like "ai-generated" to the PR (create the label if it doesn't exist) 246 - if nothing changed, report that no maintenance was needed. 247 - 122 + --- 123 + 🤖 generated by status-maintenance workflow" \ 124 + --label "ai-generated" || gh label create "ai-generated" --description "Generated by AI" && gh pr create \ 125 + --title "chore: status maintenance - $(date +'%B %d, %Y')" \ 126 + --body "automated status maintenance PR" \ 127 + --label "ai-generated" 248 128 env: 249 - GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} 129 + GH_TOKEN: ${{ github.token }} 250 130 251 131 # phase 2: upload audio after PR merge 252 132 upload-audio:
+257
scripts/letta_status_agent.py
··· 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 + """proof of concept: letta-powered status agent with persistent memory. 13 + 14 + this script demonstrates using letta's learning SDK to give an LLM 15 + persistent memory across runs. the agent will remember context about 16 + plyr.fm's codebase and recent work. 17 + 18 + usage: 19 + # first run - agent learns about the project 20 + uv run scripts/letta_status_agent.py "what is plyr.fm?" 21 + 22 + # second run - agent remembers previous context 23 + uv run scripts/letta_status_agent.py "what did we discuss last time?" 24 + 25 + # ask about recent work 26 + uv run scripts/letta_status_agent.py "summarize recent commits" 27 + 28 + # manage agent 29 + uv run scripts/letta_status_agent.py --create # create the agent 30 + uv run scripts/letta_status_agent.py --delete # delete the agent 31 + uv run scripts/letta_status_agent.py --status # check agent status 32 + 33 + environment variables (in .env): 34 + LETTA_API_KEY - letta cloud API key 35 + ANTHROPIC_API_KEY - anthropic API key 36 + """ 37 + 38 + import asyncio 39 + import subprocess 40 + import sys 41 + from pathlib import Path 42 + 43 + from pydantic import Field 44 + from pydantic_settings import BaseSettings, SettingsConfigDict 45 + 46 + AGENT_NAME = "plyr-status-agent" 47 + MEMORY_BLOCKS = ["project_context", "recent_work"] 48 + 49 + 50 + class AgentSettings(BaseSettings): 51 + """settings for the letta status agent.""" 52 + 53 + model_config = SettingsConfigDict( 54 + env_file=Path(__file__).parent.parent / ".env", 55 + case_sensitive=False, 56 + extra="ignore", 57 + ) 58 + 59 + letta_api_key: str = Field(validation_alias="LETTA_API_KEY") 60 + anthropic_api_key: str = Field(validation_alias="ANTHROPIC_API_KEY") 61 + 62 + 63 + def get_recent_commits(limit: int = 10) -> str: 64 + """get recent commit messages for context.""" 65 + result = subprocess.run( 66 + ["git", "log", "--oneline", f"-{limit}"], 67 + capture_output=True, 68 + text=True, 69 + cwd=Path(__file__).parent.parent, 70 + ) 71 + return result.stdout.strip() 72 + 73 + 74 + def get_open_issues(limit: int = 5) -> str: 75 + """get open issues for context.""" 76 + result = subprocess.run( 77 + ["gh", "issue", "list", "--limit", str(limit)], 78 + capture_output=True, 79 + text=True, 80 + cwd=Path(__file__).parent.parent, 81 + ) 82 + return result.stdout.strip() 83 + 84 + 85 + async def create_agent(settings: AgentSettings) -> None: 86 + """create the letta agent.""" 87 + from agentic_learning import AsyncAgenticLearning 88 + 89 + letta_client = AsyncAgenticLearning(api_key=settings.letta_api_key) 90 + 91 + # check if already exists 92 + existing = await letta_client.agents.retrieve(agent=AGENT_NAME) 93 + if existing: 94 + print(f"agent '{AGENT_NAME}' already exists (id: {existing.id})") 95 + return 96 + 97 + # create 98 + agent = await letta_client.agents.create( 99 + agent=AGENT_NAME, 100 + memory=MEMORY_BLOCKS, 101 + model="anthropic/claude-sonnet-4-20250514", 102 + ) 103 + print(f"✓ created agent '{AGENT_NAME}' (id: {agent.id})") 104 + print(f" memory blocks: {MEMORY_BLOCKS}") 105 + 106 + 107 + async def delete_agent(settings: AgentSettings) -> None: 108 + """delete the letta agent.""" 109 + from agentic_learning import AsyncAgenticLearning 110 + 111 + letta_client = AsyncAgenticLearning(api_key=settings.letta_api_key) 112 + 113 + deleted = await letta_client.agents.delete(agent=AGENT_NAME) 114 + if deleted: 115 + print(f"✓ deleted agent '{AGENT_NAME}'") 116 + else: 117 + print(f"agent '{AGENT_NAME}' not found") 118 + 119 + 120 + async def show_status(settings: AgentSettings) -> None: 121 + """show agent status and memory.""" 122 + from agentic_learning import AsyncAgenticLearning 123 + 124 + letta_client = AsyncAgenticLearning(api_key=settings.letta_api_key) 125 + 126 + agent = await letta_client.agents.retrieve(agent=AGENT_NAME) 127 + if not agent: 128 + print(f"agent '{AGENT_NAME}' not found") 129 + print("run: uv run scripts/letta_status_agent.py --create") 130 + return 131 + 132 + print(f"agent: {AGENT_NAME}") 133 + print(f" id: {agent.id}") 134 + print(f" model: {agent.model}") 135 + 136 + # show memory blocks 137 + if hasattr(agent, "memory") and agent.memory: 138 + print(" memory blocks:") 139 + for block in agent.memory.blocks: 140 + preview = ( 141 + block.value[:100] + "..." if len(block.value) > 100 else block.value 142 + ) 143 + print(f" - {block.label}: {preview}") 144 + 145 + 146 + def run_agent_sync(user_message: str) -> None: 147 + """run the status agent with letta memory (sync version).""" 148 + import os 149 + 150 + settings = AgentSettings() 151 + 152 + # SDK's capture() reads from os.environ, so we need to set it 153 + os.environ["LETTA_API_KEY"] = settings.letta_api_key 154 + 155 + # import after settings validation 156 + import anthropic 157 + from agentic_learning import AgenticLearning, learning 158 + 159 + # initialize clients - use SYNC clients for sync context 160 + letta_client = AgenticLearning(api_key=settings.letta_api_key) 161 + anthropic_client = anthropic.Anthropic(api_key=settings.anthropic_api_key) 162 + 163 + # ensure agent exists (sync) 164 + existing = letta_client.agents.retrieve(agent=AGENT_NAME) 165 + if not existing: 166 + print(f"creating agent '{AGENT_NAME}'...") 167 + try: 168 + letta_client.agents.create( 169 + agent=AGENT_NAME, 170 + memory=MEMORY_BLOCKS, 171 + model="anthropic/claude-sonnet-4-20250514", 172 + ) 173 + print(f"✓ agent '{AGENT_NAME}' created") 174 + except Exception as e: 175 + print(f"✗ failed to create agent: {e}") 176 + sys.exit(1) 177 + 178 + # gather context 179 + recent_commits = get_recent_commits() 180 + open_issues = get_open_issues() 181 + 182 + system_prompt = f"""you are a status agent for plyr.fm, a decentralized music streaming 183 + platform built on AT Protocol. 184 + 185 + your role is to: 186 + 1. understand what's happening in the codebase 187 + 2. remember context across conversations 188 + 3. help maintain STATUS.md and track project progress 189 + 190 + current context: 191 + - recent commits: 192 + {recent_commits} 193 + 194 + - open issues: 195 + {open_issues} 196 + 197 + be concise and technical. use lowercase aesthetic. 198 + """ 199 + 200 + print(f"user: {user_message}\n") 201 + print("agent: ", end="", flush=True) 202 + 203 + # wrap the anthropic call with letta learning context (SYNC) 204 + # this automatically captures the conversation and injects relevant memory 205 + with learning( 206 + agent=AGENT_NAME, 207 + client=letta_client, 208 + memory=MEMORY_BLOCKS, 209 + ): 210 + response = anthropic_client.messages.create( 211 + model="claude-sonnet-4-20250514", 212 + max_tokens=1024, 213 + system=system_prompt, 214 + messages=[{"role": "user", "content": user_message}], 215 + ) 216 + 217 + # print response 218 + for block in response.content: 219 + if hasattr(block, "text"): 220 + print(block.text) 221 + 222 + print("\n✓ conversation saved to letta memory") 223 + 224 + 225 + def main() -> None: 226 + """main entry point.""" 227 + if len(sys.argv) < 2: 228 + print("usage: uv run scripts/letta_status_agent.py <message>") 229 + print("\nexamples:") 230 + print(' uv run scripts/letta_status_agent.py "what is plyr.fm?"') 231 + print(' uv run scripts/letta_status_agent.py "what did we discuss last time?"') 232 + print(' uv run scripts/letta_status_agent.py "summarize recent work"') 233 + print("\nagent management:") 234 + print(" uv run scripts/letta_status_agent.py --create") 235 + print(" uv run scripts/letta_status_agent.py --delete") 236 + print(" uv run scripts/letta_status_agent.py --status") 237 + sys.exit(1) 238 + 239 + settings = AgentSettings() 240 + 241 + # handle management commands 242 + if sys.argv[1] == "--create": 243 + asyncio.run(create_agent(settings)) 244 + return 245 + elif sys.argv[1] == "--delete": 246 + asyncio.run(delete_agent(settings)) 247 + return 248 + elif sys.argv[1] == "--status": 249 + asyncio.run(show_status(settings)) 250 + return 251 + 252 + user_message = " ".join(sys.argv[1:]) 253 + run_agent_sync(user_message) 254 + 255 + 256 + if __name__ == "__main__": 257 + main()
+505
scripts/status_maintenance.py
··· 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 + 14 + this script replaces the claude-code-action in the status maintenance workflow. 15 + it uses letta's learning SDK to maintain persistent memory across runs. 16 + 17 + the memory is about PROJECT UNDERSTANDING (architecture, patterns, context), 18 + NOT about processing history. github is the source of truth for what needs 19 + processing (last merged PR date → now). 20 + 21 + usage: 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 + 31 + environment 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 + 37 + import argparse 38 + import json 39 + import os 40 + import subprocess 41 + import sys 42 + from datetime import datetime 43 + from pathlib import Path 44 + 45 + from pydantic import BaseModel, Field 46 + from pydantic_settings import BaseSettings, SettingsConfigDict 47 + 48 + AGENT_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 51 + MEMORY_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 + ] 56 + PROJECT_ROOT = Path(__file__).parent.parent 57 + 58 + 59 + class 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 + 76 + class 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 + 93 + def 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 + 99 + def 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 + 141 + def 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 + 149 + def 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 + 168 + def 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 + 176 + def 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 + 184 + def 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 + 189 + def 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 237 + music streaming platform on AT Protocol. 238 + 239 + ## memory usage 240 + 241 + your letta memory persists across runs. remember: 242 + - architecture and design decisions 243 + - ATProto concepts (lexicons, NSIDs, PDS) 244 + - recurring development patterns 245 + 246 + do 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 + 256 + today: {today_human} 257 + last maintenance PR: {last_maintenance or "none (first run)"} 258 + time window: {time_window_human} 259 + STATUS.md lines: {line_count} 260 + first episode: {is_first_episode} 261 + 262 + focus on what shipped {time_window}. if last PR merged Dec 2nd and today is Dec 8th, 263 + cover 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 + 276 + 1. **archival**: if > 400 lines, move oldest sections to .status_history/YYYY-MM.md (by month) 277 + 2. **status_updates**: new content for "## recent work" - concise, technical, what shipped and why 278 + 3. **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. 294 + acknowledge limitations honestly. explain through analogy, not jargon. 295 + avoid: "exciting", "amazing", "incredible", "great job", any over-congratulating. 296 + 297 + **what shipped**: read commits/PRs carefully. new things "shipped", improvements are "fixes" or "polish". 298 + don'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 + 315 + remember key architectural insights and patterns for future runs - but do NOT 316 + remember "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 + 333 + def 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 + 405 + def 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 + 444 + def 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 + 504 + if __name__ == "__main__": 505 + main()