a digital entity named phi that roams bsky phi.zzstoatzz.io
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix graze client, improve feed tools, add configurable agent model

- fix graze API: login response parsing, migrate_algo response, delete
endpoint, add backfill step, paginated list_feeds
- fix filter manifest: remove fake has_any_tag operator, correct regex_any
docs to match actual grazer engine operators
- read_feed takes a name slug instead of full AT-URI, list_feeds returns
rkeys so tools compose naturally
- add AGENT_MODEL setting (default anthropic:claude-sonnet-4-6) so model
is swappable via fly secret without redeploying
- add source code link to profile bio (always visible)
- document all configurable env vars in README, correct memory to 1GB

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

+128 -37
+1
.gitignore
··· 39 39 logs/ 40 40 *.log 41 41 threads.db 42 + .loq_cache
-1
.loq_cache
··· 1 - {"version":2,"config_hash":10512028690641855710,"entries":{"uv.lock":{"mtime_secs":1775200284,"mtime_nanos":568722069,"result":{"Text":2702}},"docs/README.md":{"mtime_secs":1761463993,"mtime_nanos":761008495,"result":{"Text":19}},"fly.toml":{"mtime_secs":1774309843,"mtime_nanos":958800259,"result":{"Text":43}},".python-version":{"mtime_secs":1753282478,"mtime_nanos":493534132,"result":{"Text":1}},"src/bot/__init__.py":{"mtime_secs":1753282488,"mtime_nanos":103132070,"result":{"Text":2}},"tests/test_tool_usage.py":{"mtime_secs":1775207484,"mtime_nanos":580272402,"result":{"Text":162}},"Dockerfile":{"mtime_secs":1774252732,"mtime_nanos":604736453,"result":{"Text":41}},"justfile":{"mtime_secs":1774479506,"mtime_nanos":585616843,"result":{"Text":51}},"tests/conftest.py":{"mtime_secs":1753282474,"mtime_nanos":400589343,"result":{"Text":15}},"tests/test_rich_text.py":{"mtime_secs":1770883632,"mtime_nanos":61910936,"result":{"Text":44}},"src/bot/main.py":{"mtime_secs":1775207652,"mtime_nanos":520929089,"result":{"Text":811}},"personalities/phi.md":{"mtime_secs":1774416487,"mtime_nanos":90662362,"result":{"Text":61}},"docs/architecture.md":{"mtime_secs":1774478951,"mtime_nanos":860242013,"result":{"Text":51}},"AGENTS.md":{"mtime_secs":1774472202,"mtime_nanos":473692663,"result":{"Text":47}},"src/bot/services/message_handler.py":{"mtime_secs":1775207277,"mtime_nanos":377223824,"result":{"Text":298}},"evals/test_memory_integration.py":{"mtime_secs":1770872666,"mtime_nanos":707005854,"result":{"Text":42}},"scripts/memory_versions.py":{"mtime_secs":1774420925,"mtime_nanos":445567670,"result":{"Text":286}},"src/bot/agent.py":{"mtime_secs":1775207769,"mtime_nanos":657069527,"result":{"Text":1007}},"scripts/memory_inspect.py":{"mtime_secs":1770882770,"mtime_nanos":186251720,"result":{"Text":204}},"tests/test_types.py":{"mtime_secs":1775207466,"mtime_nanos":695432291,"result":{"Text":196}},"README.md":{"mtime_secs":1775197049,"mtime_nanos":507701218,"result":{"Text":111}},"src/bot/types.py":{"mtime_secs":1775207576,"mtime_nanos":302839692,"result":{"Text":196}},"evals/README.md":{"mtime_secs":1775028936,"mtime_nanos":424293949,"result":{"Text":145}},"personalities/default.md":{"mtime_secs":1753282474,"mtime_nanos":406251705,"result":{"Text":10}},"tests/test_rate_limiting.py":{"mtime_secs":1775114219,"mtime_nanos":821677513,"result":{"Text":99}},"evals/test_feed_creation.py":{"mtime_secs":1775027951,"mtime_nanos":912499165,"result":{"Text":127}},"tests/test_config.py":{"mtime_secs":1775025499,"mtime_nanos":21928379,"result":{"Text":40}},"src/bot/logging_config.py":{"mtime_secs":1774506431,"mtime_nanos":857448975,"result":{"Text":55}},"src/bot/core/rich_text.py":{"mtime_secs":1775188156,"mtime_nanos":446781794,"result":{"Text":119}},"evals/conftest.py":{"mtime_secs":1775029593,"mtime_nanos":43917273,"result":{"Text":374}},".claude/settings.local.json":{"mtime_secs":1759737810,"mtime_nanos":986010219,"result":{"Text":9}},"src/bot/status.py":{"mtime_secs":1774382256,"mtime_nanos":403852672,"result":{"Text":101}},".env.example":{"mtime_secs":1753282478,"mtime_nanos":494004642,"result":{"Text":27}},"src/bot/services/__init__.py":{"mtime_secs":1753282488,"mtime_nanos":113309689,"result":{"Text":0}},"src/bot/memory/namespace_memory.py":{"mtime_secs":1775200049,"mtime_nanos":637626318,"result":{"Text":826}},"tests/test_split_text.py":{"mtime_secs":1774295250,"mtime_nanos":260103920,"result":{"Text":56}},"docs/memory.md":{"mtime_secs":1774478916,"mtime_nanos":629189877,"result":{"Text":78}},"personalities/README.md":{"mtime_secs":1753282474,"mtime_nanos":406870843,"result":{"Text":33}},"tests/test_graze_client.py":{"mtime_secs":1775029593,"mtime_nanos":43544226,"result":{"Text":198}},"tests/test_mention_allowlist.py":{"mtime_secs":1775189492,"mtime_nanos":467469875,"result":{"Text":71}},"tests/test_search_network.py":{"mtime_secs":1774475139,"mtime_nanos":341816026,"result":{"Text":123}},".dockerignore":{"mtime_secs":1774252665,"mtime_nanos":317889982,"result":{"Text":23}},".pre-commit-config.yaml":{"mtime_secs":1774479395,"mtime_nanos":264627332,"result":{"Text":14}},".tangled/workflows/deploy.yml":{"mtime_secs":1774338897,"mtime_nanos":737394616,"result":{"Text":23}},"evals/test_feed_consumption.py":{"mtime_secs":1775207429,"mtime_nanos":11817946,"result":{"Text":96}},"src/bot/core/graze_client.py":{"mtime_secs":1775207437,"mtime_nanos":950692855,"result":{"Text":152}},"src/bot/memory/__init__.py":{"mtime_secs":1774479368,"mtime_nanos":409441490,"result":{"Text":10}},"src/bot/memory/extraction.py":{"mtime_secs":1775207541,"mtime_nanos":931959342,"result":{"Text":151}},"src/bot/utils/thread.py":{"mtime_secs":1775207465,"mtime_nanos":618470033,"result":{"Text":259}},"src/bot/services/notification_poller.py":{"mtime_secs":1775207291,"mtime_nanos":425255363,"result":{"Text":192}},"src/bot/core/__init__.py":{"mtime_secs":1753282488,"mtime_nanos":94033139,"result":{"Text":0}},"scripts/fix_cosmik_records.py":{"mtime_secs":1775114229,"mtime_nanos":768730077,"result":{"Text":87}},"src/bot/py.typed":{"mtime_secs":1753282488,"mtime_nanos":111939662,"result":{"Text":0}},".gitignore":{"mtime_secs":1774480406,"mtime_nanos":495761456,"result":{"Text":41}},"pyproject.toml":{"mtime_secs":1775200275,"mtime_nanos":952110606,"result":{"Text":51}},"docs/testing.md":{"mtime_secs":1761463970,"mtime_nanos":338568983,"result":{"Text":111}},"src/bot/config.py":{"mtime_secs":1775207256,"mtime_nanos":643474835,"result":{"Text":120}},"CLAUDE.md":{"mtime_secs":1774479530,"mtime_nanos":9219616,"result":{"Text":49}},"src/bot/core/atproto_client.py":{"mtime_secs":1775207436,"mtime_nanos":326686398,"result":{"Text":262}},"docs/mcp.md":{"mtime_secs":1774478934,"mtime_nanos":301501281,"result":{"Text":38}},"src/bot/core/profile_manager.py":{"mtime_secs":1775207769,"mtime_nanos":656013633,"result":{"Text":185}},"tests/__init__.py":{"mtime_secs":1753282474,"mtime_nanos":401945245,"result":{"Text":0}},"tests/test_resolve_facets.py":{"mtime_secs":1775165643,"mtime_nanos":153828634,"result":{"Text":112}}}}
+9 -2
README.md
··· 12 12 13 13 **required:** `BLUESKY_HANDLE`, `BLUESKY_PASSWORD`, `ANTHROPIC_API_KEY` 14 14 15 - **optional:** `TURBOPUFFER_API_KEY` + `OPENAI_API_KEY` for episodic memory 15 + **optional:** 16 + - `TURBOPUFFER_API_KEY` + `OPENAI_API_KEY` — episodic memory 17 + - `AGENT_MODEL` — pydantic-ai model string for the main agent (default: `anthropic:claude-sonnet-4-6`) 18 + - `EXTRACTION_MODEL` — model for observation extraction (default: `claude-haiku-4-5-20251001`) 19 + - `DAILY_REFLECTION_HOUR` — UTC hour for daily reflection post (default: `14`) 20 + - `THOUGHT_POST_HOURS` — UTC hours for original thought posts (default: `[15, 19, 23]`) 21 + - `CONTROL_TOKEN` — bearer token for `/api/control` endpoints 22 + - `OWNER_HANDLE` — handle of the bot's owner for permission-gated tools (default: `zzstoatzz.io`) 16 23 17 24 ## what phi does 18 25 ··· 100 107 <details> 101 108 <summary>deployment</summary> 102 109 103 - runs on [fly.io](https://fly.io) — `shared-cpu-1x`, 512MB, region `ord`. auto-start is off; the machine sleeps until woken by an API call. 110 + runs on [fly.io](https://fly.io) — `shared-cpu-1x`, 1GB, region `ord`. auto-start is off; the machine sleeps until woken by an API call. 104 111 105 112 secrets are set via `fly secrets set`. the bot uses session persistence (`.session` file) to avoid rate limits — tokens auto-refresh every ~2h. 106 113
+6 -5
evals/conftest.py
··· 181 181 name: url-safe slug (e.g. "electronic-music"). becomes the feed rkey. 182 182 display_name: human-readable feed title. 183 183 description: what the feed shows. 184 - filter_manifest: graze filter DSL. operators: 185 - - regex_any: ["text", ["pattern1", "pattern2"], case_insensitive: bool, whole_word: bool] 186 - - has_any_tag: [["#tag1", "#tag2"]] 187 - - and: [...filters], or: [...filters] 188 - example: {"filter": {"and": [{"regex_any": ["text", ["jazz", "bebop"], true, false]}, {"has_any_tag": [["#jazz"]]}]}} 184 + filter_manifest: graze filter DSL (grazer engine operators). key operators: 185 + - regex_any: ["field", ["term1", "term2"]] — match any term (case-insensitive by default) 186 + - regex_none: ["field", ["term1", "term2"]] — exclude posts matching any term 187 + - regex_matches: ["field", "pattern"] — single regex match 188 + - and: [...filters], or: [...filters] — combine filters 189 + field is usually "text". example: {"filter": {"and": [{"regex_any": ["text", ["jazz", "bebop"]]}]}} 189 190 """ 190 191 _feed_spy.record( 191 192 "create_feed",
+8 -1
evals/test_feed_creation.py
··· 8 8 return "filter" in manifest 9 9 10 10 11 - KNOWN_OPERATORS = {"regex_any", "has_any_tag", "and", "or"} 11 + KNOWN_OPERATORS = { 12 + "regex_any", 13 + "regex_none", 14 + "regex_matches", 15 + "regex_negation_matches", 16 + "and", 17 + "or", 18 + } 12 19 13 20 14 21 def _uses_known_operators(obj: dict | list) -> bool:
+1 -1
fly.toml
··· 13 13 14 14 [[vm]] 15 15 size = "shared-cpu-1x" 16 - memory = "512mb" 16 + memory = "1gb" 17 17 18 18 [env] 19 19 PORT = "8080"
+1 -1
loq.toml
··· 13 13 14 14 [[rules]] 15 15 path = "src/bot/agent.py" 16 - max_lines = 1007 16 + max_lines = 1061 17 17 18 18 [[rules]] 19 19 path = "src/bot/memory/namespace_memory.py"
+65 -11
src/bot/agent.py
··· 308 308 # https://github.com/pydantic/pydantic-ai/issues/2818 309 309 self.agent = Agent[PhiDeps, Response]( 310 310 name="phi", 311 - model="anthropic:claude-sonnet-4-6", 311 + model=settings.agent_model, 312 312 system_prompt=f"{self.base_personality}\n\n{_build_operational_instructions()}", 313 313 output_type=Response, 314 314 deps_type=PhiDeps, ··· 700 700 name: url-safe slug (e.g. "electronic-music"). becomes the feed rkey. 701 701 display_name: human-readable feed title. 702 702 description: what the feed shows. 703 - filter_manifest: graze filter DSL. operators: 704 - - regex_any: ["text", ["pattern1", "pattern2"], case_insensitive: bool, whole_word: bool] 705 - - has_any_tag: [["#tag1", "#tag2"]] 706 - - and: [...filters], or: [...filters] 707 - example: {"filter": {"and": [{"regex_any": ["text", ["jazz", "bebop"], true, false]}, {"has_any_tag": [["#jazz"]]}]}} 703 + filter_manifest: graze filter DSL (grazer engine operators). key operators: 704 + - regex_any: ["field", ["term1", "term2"]] — match any term (case-insensitive by default) 705 + - regex_none: ["field", ["term1", "term2"]] — exclude posts matching any term 706 + - regex_matches: ["field", "pattern"] — single regex match 707 + - and: [...filters], or: [...filters] — combine filters 708 + field is usually "text". example: {"filter": {"and": [{"regex_any": ["text", ["jazz", "bebop"]]}]}} 708 709 """ 709 710 if not _is_owner(ctx): 710 711 return f"only @{settings.owner_handle} can create feeds" ··· 722 723 723 724 @self.agent.tool 724 725 async def list_feeds(ctx: RunContext[PhiDeps]) -> str: 725 - """List your existing graze-powered feeds.""" 726 + """List your existing graze-powered feeds. Returns name (slug for read_feed) and algo_id (for delete_feed).""" 726 727 try: 727 728 feeds = await self.graze_client.list_feeds() 728 729 if not feeds: 729 730 return "no graze feeds found" 730 731 lines = [] 731 732 for f in feeds: 732 - name = f.get("display_name") or f.get("name") or "unnamed" 733 + display = f.get("display_name") or f.get("name") or "unnamed" 733 734 algo_id = f.get("id") or f.get("algo_id") or "?" 734 735 uri = f.get("feed_uri") or f.get("uri") or "" 735 - lines.append(f"- {name} (id={algo_id}) {uri}") 736 + # extract rkey slug from feed_uri for use with read_feed 737 + rkey = f.get("record_name") or ( 738 + uri.rsplit("/", 1)[-1] if uri else "?" 739 + ) 740 + lines.append(f"- {display} | name={rkey} | algo_id={algo_id}") 736 741 return "\n".join(lines) 737 742 except Exception as e: 738 743 logger.warning(f"list_feeds failed: {e}") 739 744 return f"failed to list feeds: {e}" 740 745 746 + @self.agent.tool 747 + async def delete_feed(ctx: RunContext[PhiDeps], algo_id: int) -> str: 748 + """Delete a graze-powered feed by its algo_id. Only the bot's owner can use this tool. 749 + 750 + algo_id: the numeric id from list_feeds (e.g. 33726). 751 + This deletes both the graze registration and the PDS feed generator record. 752 + """ 753 + if not _is_owner(ctx): 754 + return f"only @{settings.owner_handle} can delete feeds" 755 + try: 756 + # find the record_name from graze so we can delete the PDS record too 757 + feeds = await self.graze_client.list_feeds() 758 + record_name = None 759 + for f in feeds: 760 + if f.get("id") == algo_id: 761 + record_name = f.get("record_name") 762 + break 763 + 764 + await self.graze_client.delete_feed(algo_id) 765 + 766 + # also delete the PDS record if we found the rkey 767 + if record_name: 768 + assert bot_client.client.me is not None 769 + try: 770 + bot_client.client.com.atproto.repo.delete_record( 771 + data={ 772 + "repo": bot_client.client.me.did, 773 + "collection": "app.bsky.feed.generator", 774 + "rkey": record_name, 775 + } 776 + ) 777 + except Exception as e: 778 + logger.warning(f"PDS record delete failed: {e}") 779 + 780 + return f"deleted feed algo_id={algo_id}" + ( 781 + f" and PDS record '{record_name}'" if record_name else "" 782 + ) 783 + except Exception as e: 784 + logger.warning(f"delete_feed failed: {e}") 785 + return f"failed to delete feed: {e}" 786 + 741 787 # --- feed consumption + following tools --- 742 788 743 789 @self.agent.tool ··· 756 802 757 803 @self.agent.tool 758 804 async def read_feed( 759 - ctx: RunContext[PhiDeps], feed_uri: str, limit: int = 20 805 + ctx: RunContext[PhiDeps], name: str, limit: int = 20 760 806 ) -> str: 761 - """Read posts from a specific custom feed by AT-URI. Use list_feeds to find feed URIs first.""" 807 + """Read posts from one of your graze-powered feeds. 808 + 809 + name: the feed slug (e.g. "mushroom-foraging"). use list_feeds to see available names. 810 + """ 762 811 try: 812 + await bot_client.authenticate() 813 + assert bot_client.client.me is not None 814 + feed_uri = ( 815 + f"at://{bot_client.client.me.did}/app.bsky.feed.generator/{name}" 816 + ) 763 817 response = await bot_client.get_feed(feed_uri, limit=limit) 764 818 if not response.feed: 765 819 return "no posts in this feed yet"
+5 -1
src/bot/config.py
··· 67 67 default="gcp-us-central1", description="The region for the TurboPuffer API" 68 68 ) 69 69 70 - # Extraction model for observation extraction 70 + # Model configuration 71 + agent_model: str = Field( 72 + default="anthropic:claude-sonnet-4-6", 73 + description="Model for the main agent (pydantic-ai model string)", 74 + ) 71 75 extraction_model: str = Field( 72 76 default="claude-haiku-4-5-20251001", 73 77 description="Model for extracting observations from conversations",
+19 -4
src/bot/core/graze_client.py
··· 41 41 ) 42 42 r.raise_for_status() 43 43 data = r.json() 44 - self._user_id = data["id"] 44 + self._user_id = data["user"]["id"] 45 45 self._cookies = r.cookies 46 46 logger.info(f"graze login ok, user_id={self._user_id}") 47 47 ··· 119 119 "algorithm_manifest": filter_manifest, 120 120 }, 121 121 ) 122 - algo_id = r.json()["algo_id"] 122 + algo_id = r.json()["id"] 123 123 logger.info(f"algo migrated, algo_id={algo_id}") 124 124 125 125 # 3. complete migration ··· 138 138 f"/app/api/v1/algorithm-management/set-publicity/{algo_id}/true", 139 139 ) 140 140 141 + # 6. backfill so the feed isn't empty 142 + await self.backfill_feed(algo_id) 143 + 141 144 logger.info(f"feed published: {feed_uri}") 142 145 return {"uri": feed_uri, "algo_id": algo_id} 143 146 144 147 async def list_feeds(self) -> list[dict]: 145 148 """List phi's existing graze feeds.""" 146 149 r = await self._request("GET", "/app/my_feeds") 147 - return r.json() 150 + data = r.json() 151 + return data.get("user_algos", data) if isinstance(data, dict) else data 148 152 149 153 async def delete_feed(self, algo_id: int) -> None: 150 154 """Delete a graze feed by algo_id.""" 151 - await self._request("DELETE", f"/app/my_feeds/{algo_id}") 155 + await self._request( 156 + "POST", 157 + "/app/delete_algo", 158 + json={"id": algo_id, "user_id": self._user_id}, 159 + ) 152 160 logger.info(f"feed deleted: algo_id={algo_id}") 161 + 162 + async def backfill_feed(self, algo_id: int) -> None: 163 + """Trigger a backfill for a feed so it picks up existing posts.""" 164 + await self._request( 165 + "POST", f"/app/api/v1/algorithm-management/backfill/{algo_id}" 166 + ) 167 + logger.info(f"feed backfill triggered: algo_id={algo_id}")
+3 -2
src/bot/core/profile_manager.py
··· 7 7 8 8 logger = logging.getLogger("bot.profile_manager") 9 9 10 - _ONLINE_SUFFIX = "\n\n🟢 user memory, world memory, thread context, atproto records, publication search, post search, trending" 11 - _OFFLINE_SUFFIX = " • 🔴 offline" 10 + _SOURCE_LINK = "\n\nsource code: https://tangled.sh/zzstoatzz.io/bot" 11 + _ONLINE_SUFFIX = f"{_SOURCE_LINK}\n\n🟢 user memory, world memory, thread context, atproto records, publication search, post search, trending" 12 + _OFFLINE_SUFFIX = f"{_SOURCE_LINK}\n\n🔴 offline" 12 13 _ALL_SUFFIXES = [_ONLINE_SUFFIX, _OFFLINE_SUFFIX] 13 14 14 15
+10 -8
tests/test_graze_client.py
··· 17 17 """Fake successful login response.""" 18 18 resp = httpx.Response( 19 19 200, 20 - json={"id": 42}, 20 + json={"user": {"id": 42}}, 21 21 request=httpx.Request("POST", f"{BASE_URL}/app/login"), 22 22 ) 23 23 return resp ··· 75 75 async def fake_request(method, path, **kwargs): 76 76 call_log.append((method, path)) 77 77 if path == "/app/migrate_algo": 78 - return _ok_response(json={"algo_id": 99}) 78 + return _ok_response(json={"id": 99}) 79 79 return _ok_response() 80 80 81 81 with ( ··· 87 87 display_name="Jazz Music", 88 88 description="posts about jazz", 89 89 filter_manifest={ 90 - "filter": { 91 - "and": [{"regex_any": ["text", ["jazz", "bebop"], True, False]}] 92 - } 90 + "filter": {"and": [{"regex_any": ["text", ["jazz", "bebop"]]}]} 93 91 }, 94 92 ) 95 93 ··· 103 101 assert record["displayName"] == "Jazz Music" 104 102 assert record["did"] == "did:web:api.graze.social" 105 103 106 - # verify all 4 graze API calls in order 104 + # verify all 5 graze API calls in order 107 105 assert call_log == [ 108 106 ("POST", "/app/migrate_algo"), 109 107 ("POST", "/app/complete_migration"), 110 108 ("GET", "/app/publish_algo/99"), 111 109 ("GET", "/app/api/v1/algorithm-management/set-publicity/99/true"), 110 + ("POST", "/app/api/v1/algorithm-management/backfill/99"), 112 111 ] 113 112 114 113 async def test_create_feed_propagates_errors(self, graze): ··· 154 153 155 154 class TestDeleteFeed: 156 155 async def test_delete_feed(self, graze): 156 + graze._user_id = 42 157 + 157 158 async def fake_request(method, path, **kwargs): 158 - assert method == "DELETE" 159 - assert "/app/my_feeds/99" in path 159 + assert method == "POST" 160 + assert path == "/app/delete_algo" 161 + assert kwargs["json"] == {"id": 99, "user_id": 42} 160 162 return _ok_response() 161 163 162 164 with patch.object(graze, "_request", side_effect=fake_request):