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.

add loq + pre-commit, extract observation pipeline from namespace_memory

- loq.toml: 500-line default, relaxed limits for agent.py (629) and
namespace_memory.py (718). sandbox/ and .eggs/ excluded.
- pre-commit: loq + ruff (check + format)
- extract extraction pipeline (models, prompts, agent factories) into
memory/extraction.py — clean boundary between observation logic and
memory storage/retrieval
- justfile: add loq-relax recipe
- CLAUDE.md: document loq workflow

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

+348 -220
+14
.pre-commit-config.yaml
··· 1 + fail_fast: false 2 + 3 + repos: 4 + - repo: https://github.com/jakekaplan/loq 5 + rev: v0.1.0-alpha.7 6 + hooks: 7 + - id: loq 8 + 9 + - repo: https://github.com/astral-sh/ruff-pre-commit 10 + rev: v0.12.1 11 + hooks: 12 + - id: ruff-check 13 + args: [--fix, --exit-non-zero-on-fix] 14 + - id: ruff-format
+3 -1
CLAUDE.md
··· 5 5 - `just run` / `just dev` (hot-reload) / `just deploy` (fly.io) 6 6 - `just evals` — behavioral tests (llm-as-judge) 7 7 - `just check` — lint + typecheck + test 8 + - `just loq-relax <file>` — when a file exceeds its line limit, relax it. never manually edit loq.toml or compress code to fit 8 9 - work from repo root 9 10 10 11 ## python style ··· 19 20 ``` 20 21 src/bot/ 21 22 ├── agent.py # pydantic-ai agent, tools, personality 23 + ├── types.py # cosmik record models (cards, connections) 22 24 ├── config.py # settings (env vars) 23 25 ├── main.py # fastapi app, status pages, memory graph 24 26 ├── status.py # runtime metrics 25 27 ├── core/ # atproto client, profile management 26 - ├── memory/ # turbopuffer episodic memory 28 + ├── memory/ # turbopuffer memory + observation extraction 27 29 ├── services/ # notification polling, message handling 28 30 └── utils/ # thread context, text formatting 29 31
+10 -10
docs/ARCHITECTURE.md
··· 1 1 # architecture 2 2 3 - phi is a notification-driven agent that responds to mentions on bluesky. 3 + phi is a notification-driven agent that responds to activity on bluesky. 4 4 5 5 ## data flow 6 6 7 7 ``` 8 - notification arrives 8 + notification arrives (mention, reply, quote, like, repost, follow) 9 9 10 10 fetch thread context from network (ATProto) 11 11 ··· 13 13 14 14 agent decides action (PydanticAI + Claude) 15 15 16 - execute via MCP tools (post/like/repost) 16 + execute action + store observations 17 17 ``` 18 18 19 19 ## key components 20 20 21 21 ### notification poller 22 - - checks for mentions every 10s 22 + - checks for all notification types every 10s 23 23 - tracks processed URIs to avoid duplicates 24 - - runs in background thread 24 + - triggers daily reflection at a configured hour 25 25 26 26 ### message handler 27 27 - orchestrates the response flow ··· 31 31 32 32 ### phi agent 33 33 - loads personality from `personalities/phi.md` 34 - - builds context from thread + episodic memory 34 + - builds context from thread + private memory + network knowledge 35 35 - returns structured response: `Response(action, text, reason)` 36 - - has access to MCP tools via stdio 36 + - native tools defined in `agent.py`, MCP tools from remote servers 37 37 38 38 ### atproto client 39 39 - session persistence (saves to `.session`) ··· 42 42 43 43 ## why this design 44 44 45 - **network-first thread context**: fetch threads from ATProto instead of caching in sqlite. network is source of truth, no staleness issues. 45 + **network-first thread context**: fetch threads from ATProto instead of caching locally. network is source of truth, no staleness issues. 46 46 47 - **episodic memory for semantics**: turbopuffer stores embeddings for semantic search across all conversations. different purpose than thread chronology. 47 + **private + public memory**: turbopuffer stores private embeddings for semantic recall across conversations. cosmik records on PDS provide public knowledge that's indexed by semble for network-wide discovery. dual-write means phi gets both fast private recall and public visibility. 48 48 49 - **mcp for extensibility**: tools provided by external server via stdio. easy to add new capabilities without changing agent code. 49 + **mcp for extensibility**: tools provided by remote MCP servers (pdsx for atproto CRUD, pub-search for publications). easy to add new capabilities without changing agent code. 50 50 51 51 **structured outputs**: agent returns typed `Response` objects, not free text. clear contract between agent and handler.
+4
justfile
··· 37 37 typecheck: 38 38 uv run ty check src/ evals/ tests/ 39 39 40 + # loq — relax line limits for files that legitimately grew 41 + loq-relax +files: 42 + uvx loq relax {{files}} 43 + 40 44 check: lint typecheck test 41 45 42 46 # setup reference projects
+20
loq.toml
··· 1 + default_max_lines = 500 2 + respect_gitignore = true 3 + 4 + exclude = [ 5 + ".git/**", 6 + ".eggs/**", 7 + "sandbox/**", 8 + ] 9 + 10 + [[rules]] 11 + path = "**/*.lock" 12 + max_lines = 10000 13 + 14 + [[rules]] 15 + path = "src/bot/agent.py" 16 + max_lines = 629 17 + 18 + [[rules]] 19 + path = "src/bot/memory/namespace_memory.py" 20 + max_lines = 718
+2 -1
src/bot/memory/__init__.py
··· 1 1 """Memory system for the bot""" 2 2 3 - from .namespace_memory import ExtractionResult, NamespaceMemory, Observation 3 + from .extraction import ExtractionResult, Observation 4 + from .namespace_memory import NamespaceMemory 4 5 5 6 __all__ = [ 6 7 "ExtractionResult",
+147
src/bot/memory/extraction.py
··· 1 + """Observation extraction and reconciliation pipeline. 2 + 3 + Models, prompts, and agent factories for extracting facts from conversations 4 + and reconciling new observations against existing memory. 5 + """ 6 + 7 + from pydantic import BaseModel, Field 8 + from pydantic_ai import Agent 9 + 10 + from bot.config import settings 11 + 12 + 13 + class Observation(BaseModel): 14 + """A single fact about the user, extracted from what the USER said or did.""" 15 + 16 + content: str = Field( 17 + description="one atomic fact about the user, stated as a short sentence" 18 + ) 19 + tags: list[str] = Field(description="1-3 lowercase tags categorizing this fact") 20 + 21 + 22 + class ExtractionResult(BaseModel): 23 + """Observations extracted from a conversation. Empty list if nothing worth keeping.""" 24 + 25 + observations: list[Observation] = [] 26 + 27 + 28 + class ReconciliationAction(BaseModel): 29 + """Decision for how a new observation relates to an existing one.""" 30 + 31 + action: str = Field(description="one of: ADD, UPDATE, DELETE, NOOP") 32 + new_content: str | None = Field( 33 + default=None, description="merged content when action is UPDATE" 34 + ) 35 + new_tags: list[str] | None = Field( 36 + default=None, description="merged tags when action is UPDATE" 37 + ) 38 + reason: str = Field(description="brief explanation of the decision") 39 + 40 + 41 + class ReconciliationResult(BaseModel): 42 + """Result of reconciling a new observation against a similar existing one.""" 43 + 44 + decision: ReconciliationAction 45 + 46 + 47 + EXTRACTION_SYSTEM_PROMPT = """\ 48 + You extract facts about the USER from a conversation between a user and a bot. 49 + 50 + Only extract what the user EXPLICITLY said, asked, or demonstrated in their own message. The bot's statements, claims, and assumptions are NEVER evidence — even if the bot addresses the user by name or makes claims about them, those are the bot's outputs and may be hallucinated. 51 + 52 + CRITICAL: never extract identity information (names, roles, relationships) from what the BOT said. only extract a name if the USER explicitly stated it themselves. 53 + 54 + <examples> 55 + <example> 56 + user: have you considered following anyone yet? 57 + bot: following one account currently — bsky.app itself. 58 + observations: [] 59 + reason: the user asked a question. the bot answered about itself. nothing here is about the user. 60 + </example> 61 + <example> 62 + user: can you delete that follow record? 63 + bot: deleted it — following nobody now. 64 + observations: [] 65 + reason: the user made a request to the bot. the bot performed the action. the user didn't delete anything. 66 + </example> 67 + <example> 68 + user: what do you think about the strait of hormuz situation? 69 + bot: trump considered a blockade, major shipping implications. 70 + observations: [{"content": "interested in geopolitical events around the strait of hormuz", "tags": ["interests", "geopolitics"]}] 71 + reason: the user asked about a specific topic, showing interest. the bot's answer content is not attributed to the user. 72 + </example> 73 + <example> 74 + user: i've been learning rust lately, it's been great for my systems work 75 + bot: rust is excellent for systems programming. 76 + observations: [{"content": "learning rust for systems programming", "tags": ["interests", "programming"]}] 77 + reason: the user stated something about themselves directly. 78 + </example> 79 + <example> 80 + user: my name isn't zoë, it's nate. 81 + bot: sorry about that — you're nate. bad breadcrumb on my end. 82 + observations: [{"content": "name is nate (corrected from previous error)", "tags": ["identity", "correction"]}] 83 + reason: the user explicitly corrected a factual error. corrections are high-value observations. 84 + </example> 85 + <example> 86 + user: what do you remember about me? 87 + bot: you're alex, my creator. you care about security and testing. 88 + observations: [] 89 + reason: the user asked a question. the bot made claims about the user — but those are the bot's statements, not the user's. never extract identity from bot output. 90 + </example> 91 + </examples> 92 + 93 + Deduplicate against existing observations provided in the prompt. Return an empty list when the exchange is just greetings, filler, or the user only asked questions without revealing anything about themselves.""" 94 + 95 + RECONCILIATION_SYSTEM_PROMPT = """\ 96 + You reconcile a NEW observation against an EXISTING observation from memory. 97 + 98 + Decide one action: 99 + - ADD: the new observation contains genuinely different information. keep both. 100 + - UPDATE: the new observation refines, corrects, or supersedes the existing one. return merged content and tags. 101 + - DELETE: the existing observation is wrong, outdated, or fully redundant given the new one. the new one will be stored separately. 102 + - NOOP: the new observation adds nothing beyond what already exists. discard it. 103 + 104 + Corrections (e.g., "name is nate, corrected from previous error") always win over the entry they correct — use UPDATE or DELETE. 105 + When in doubt between ADD and NOOP, prefer NOOP. memory should be lean.""" 106 + 107 + _extraction_agent: Agent[None, ExtractionResult] | None = None 108 + _reconciliation_agent: Agent[None, ReconciliationResult] | None = None 109 + 110 + 111 + def get_extraction_agent() -> Agent[None, ExtractionResult]: 112 + global _extraction_agent 113 + if _extraction_agent is None: 114 + _extraction_agent = Agent( 115 + name="observation-extractor", 116 + model=f"anthropic:{settings.extraction_model}", 117 + output_type=ExtractionResult, 118 + system_prompt=EXTRACTION_SYSTEM_PROMPT, 119 + ) 120 + return _extraction_agent 121 + 122 + 123 + def get_reconciliation_agent() -> Agent[None, ReconciliationResult]: 124 + global _reconciliation_agent 125 + if _reconciliation_agent is None: 126 + _reconciliation_agent = Agent( 127 + name="observation-reconciler", 128 + model=f"anthropic:{settings.extraction_model}", 129 + output_type=ReconciliationResult, 130 + system_prompt=RECONCILIATION_SYSTEM_PROMPT, 131 + ) 132 + return _reconciliation_agent 133 + 134 + 135 + EPISODIC_SCHEMA = { 136 + "content": {"type": "string", "full_text_search": True}, 137 + "tags": {"type": "[]string", "filterable": True}, 138 + "source": {"type": "string", "filterable": True}, # "tool", "conversation" 139 + "created_at": {"type": "string"}, 140 + } 141 + 142 + USER_NAMESPACE_SCHEMA = { 143 + "kind": {"type": "string", "filterable": True}, 144 + "content": {"type": "string", "full_text_search": True}, 145 + "tags": {"type": "[]string", "filterable": True}, 146 + "created_at": {"type": "string"}, 147 + }
+148 -208
src/bot/memory/namespace_memory.py
··· 7 7 from typing import ClassVar 8 8 9 9 from openai import AsyncOpenAI 10 - from pydantic import BaseModel, Field 11 - from pydantic_ai import Agent 12 10 from turbopuffer import Turbopuffer 13 11 14 12 from bot.config import settings 13 + from bot.memory.extraction import ( 14 + EPISODIC_SCHEMA, 15 + USER_NAMESPACE_SCHEMA, 16 + Observation, 17 + get_extraction_agent, 18 + get_reconciliation_agent, 19 + ) 15 20 16 21 logger = logging.getLogger("bot.memory") 17 22 18 23 19 - class Observation(BaseModel): 20 - """A single fact about the user, extracted from what the USER said or did.""" 21 - 22 - content: str = Field(description="one atomic fact about the user, stated as a short sentence") 23 - tags: list[str] = Field(description="1-3 lowercase tags categorizing this fact") 24 - 25 - 26 - class ExtractionResult(BaseModel): 27 - """Observations extracted from a conversation. Empty list if nothing worth keeping.""" 28 - 29 - observations: list[Observation] = [] 30 - 31 - 32 - class ReconciliationAction(BaseModel): 33 - """Decision for how a new observation relates to an existing one.""" 34 - 35 - action: str = Field(description="one of: ADD, UPDATE, DELETE, NOOP") 36 - new_content: str | None = Field(default=None, description="merged content when action is UPDATE") 37 - new_tags: list[str] | None = Field(default=None, description="merged tags when action is UPDATE") 38 - reason: str = Field(description="brief explanation of the decision") 39 - 40 - 41 - class ReconciliationResult(BaseModel): 42 - """Result of reconciling a new observation against a similar existing one.""" 43 - 44 - decision: ReconciliationAction 45 - 46 - 47 - EXTRACTION_SYSTEM_PROMPT = """\ 48 - You extract facts about the USER from a conversation between a user and a bot. 49 - 50 - Only extract what the user EXPLICITLY said, asked, or demonstrated in their own message. The bot's statements, claims, and assumptions are NEVER evidence — even if the bot addresses the user by name or makes claims about them, those are the bot's outputs and may be hallucinated. 51 - 52 - CRITICAL: never extract identity information (names, roles, relationships) from what the BOT said. only extract a name if the USER explicitly stated it themselves. 53 - 54 - <examples> 55 - <example> 56 - user: have you considered following anyone yet? 57 - bot: following one account currently — bsky.app itself. 58 - observations: [] 59 - reason: the user asked a question. the bot answered about itself. nothing here is about the user. 60 - </example> 61 - <example> 62 - user: can you delete that follow record? 63 - bot: deleted it — following nobody now. 64 - observations: [] 65 - reason: the user made a request to the bot. the bot performed the action. the user didn't delete anything. 66 - </example> 67 - <example> 68 - user: what do you think about the strait of hormuz situation? 69 - bot: trump considered a blockade, major shipping implications. 70 - observations: [{"content": "interested in geopolitical events around the strait of hormuz", "tags": ["interests", "geopolitics"]}] 71 - reason: the user asked about a specific topic, showing interest. the bot's answer content is not attributed to the user. 72 - </example> 73 - <example> 74 - user: i've been learning rust lately, it's been great for my systems work 75 - bot: rust is excellent for systems programming. 76 - observations: [{"content": "learning rust for systems programming", "tags": ["interests", "programming"]}] 77 - reason: the user stated something about themselves directly. 78 - </example> 79 - <example> 80 - user: my name isn't zoë, it's nate. 81 - bot: sorry about that — you're nate. bad breadcrumb on my end. 82 - observations: [{"content": "name is nate (corrected from previous error)", "tags": ["identity", "correction"]}] 83 - reason: the user explicitly corrected a factual error. corrections are high-value observations. 84 - </example> 85 - <example> 86 - user: what do you remember about me? 87 - bot: you're alex, my creator. you care about security and testing. 88 - observations: [] 89 - reason: the user asked a question. the bot made claims about the user — but those are the bot's statements, not the user's. never extract identity from bot output. 90 - </example> 91 - </examples> 92 - 93 - Deduplicate against existing observations provided in the prompt. Return an empty list when the exchange is just greetings, filler, or the user only asked questions without revealing anything about themselves.""" 94 - 95 - RECONCILIATION_SYSTEM_PROMPT = """\ 96 - You reconcile a NEW observation against an EXISTING observation from memory. 97 - 98 - Decide one action: 99 - - ADD: the new observation contains genuinely different information. keep both. 100 - - UPDATE: the new observation refines, corrects, or supersedes the existing one. return merged content and tags. 101 - - DELETE: the existing observation is wrong, outdated, or fully redundant given the new one. the new one will be stored separately. 102 - - NOOP: the new observation adds nothing beyond what already exists. discard it. 103 - 104 - Corrections (e.g., "name is nate, corrected from previous error") always win over the entry they correct — use UPDATE or DELETE. 105 - When in doubt between ADD and NOOP, prefer NOOP. memory should be lean.""" 106 - 107 - _extraction_agent: Agent[None, ExtractionResult] | None = None 108 - _reconciliation_agent: Agent[None, ReconciliationResult] | None = None 109 - 110 - 111 - def get_extraction_agent() -> Agent[None, ExtractionResult]: 112 - global _extraction_agent 113 - if _extraction_agent is None: 114 - _extraction_agent = Agent( 115 - name="observation-extractor", 116 - model=f"anthropic:{settings.extraction_model}", 117 - output_type=ExtractionResult, 118 - system_prompt=EXTRACTION_SYSTEM_PROMPT, 119 - ) 120 - return _extraction_agent 121 - 122 - 123 - def get_reconciliation_agent() -> Agent[None, ReconciliationResult]: 124 - global _reconciliation_agent 125 - if _reconciliation_agent is None: 126 - _reconciliation_agent = Agent( 127 - name="observation-reconciler", 128 - model=f"anthropic:{settings.extraction_model}", 129 - output_type=ReconciliationResult, 130 - system_prompt=RECONCILIATION_SYSTEM_PROMPT, 131 - ) 132 - return _reconciliation_agent 133 - 134 - EPISODIC_SCHEMA = { 135 - "content": {"type": "string", "full_text_search": True}, 136 - "tags": {"type": "[]string", "filterable": True}, 137 - "source": {"type": "string", "filterable": True}, # "tool", "conversation" 138 - "created_at": {"type": "string"}, 139 - } 140 - 141 - USER_NAMESPACE_SCHEMA = { 142 - "kind": {"type": "string", "filterable": True}, 143 - "content": {"type": "string", "full_text_search": True}, 144 - "tags": {"type": "[]string", "filterable": True}, 145 - "created_at": {"type": "string"}, 146 - } 147 - 148 - 149 24 class NamespaceMemory: 150 25 """Namespace-based memory using TurboPuffer with structured observation extraction. 151 26 ··· 193 68 194 69 # --- core memory (unchanged) --- 195 70 196 - async def store_core_memory(self, label: str, content: str, memory_type: str = "system", char_limit: int = 10_000): 71 + async def store_core_memory( 72 + self, 73 + label: str, 74 + content: str, 75 + memory_type: str = "system", 76 + char_limit: int = 10_000, 77 + ): 197 78 """Store or update core memory block.""" 198 79 if len(content) > char_limit: 199 80 content = content[: char_limit - 3] + "..." ··· 235 116 entries = [] 236 117 if response.rows: 237 118 for row in response.rows: 238 - entries.append({ 239 - "id": row.id, 240 - "content": row.content, 241 - "label": getattr(row, "label", "unknown"), 242 - "type": getattr(row, "type", "system"), 243 - "importance": getattr(row, "importance", 1.0), 244 - "created_at": row.created_at, 245 - }) 119 + entries.append( 120 + { 121 + "id": row.id, 122 + "content": row.content, 123 + "label": getattr(row, "label", "unknown"), 124 + "type": getattr(row, "type", "system"), 125 + "importance": getattr(row, "importance", 1.0), 126 + "created_at": row.created_at, 127 + } 128 + ) 246 129 return entries 247 130 248 131 # --- user memory --- ··· 277 160 rows = [] 278 161 for obs in observations: 279 162 entry_id = self._generate_id(f"user-{handle}", "observation", obs.content) 280 - rows.append({ 281 - "id": entry_id, 282 - "vector": await self._get_embedding(obs.content), 283 - "kind": "observation", 284 - "content": obs.content, 285 - "tags": obs.tags, 286 - "created_at": datetime.now().isoformat(), 287 - }) 163 + rows.append( 164 + { 165 + "id": entry_id, 166 + "vector": await self._get_embedding(obs.content), 167 + "kind": "observation", 168 + "content": obs.content, 169 + "tags": obs.tags, 170 + "created_at": datetime.now().isoformat(), 171 + } 172 + ) 288 173 289 174 user_ns.write( 290 175 upsert_rows=rows, ··· 292 177 schema=USER_NAMESPACE_SCHEMA, 293 178 ) 294 179 295 - async def _find_similar_observations(self, handle: str, embedding: list[float], top_k: int = 3) -> list[dict]: 180 + async def _find_similar_observations( 181 + self, handle: str, embedding: list[float], top_k: int = 3 182 + ) -> list[dict]: 296 183 """Find existing observations similar to the given embedding.""" 297 184 user_ns = self.get_user_namespace(handle) 298 185 try: ··· 304 191 ) 305 192 if response.rows: 306 193 return [ 307 - {"id": row.id, "content": row.content, "tags": getattr(row, "tags", []), "created_at": getattr(row, "created_at", "")} 194 + { 195 + "id": row.id, 196 + "content": row.content, 197 + "tags": getattr(row, "tags", []), 198 + "created_at": getattr(row, "created_at", ""), 199 + } 308 200 for row in response.rows 309 201 ] 310 202 except Exception as e: ··· 351 243 ) 352 244 merged_embedding = await self._get_embedding(merged.content) 353 245 await self._write_observation(handle, merged, merged_embedding) 354 - logger.info(f"UPDATE for @{handle}: '{best_match['content'][:40]}' -> '{merged.content[:40]}' ({decision.reason})") 246 + logger.info( 247 + f"UPDATE for @{handle}: '{best_match['content'][:40]}' -> '{merged.content[:40]}' ({decision.reason})" 248 + ) 355 249 356 250 elif action == "DELETE": 357 251 # delete the existing one, store the new one 358 252 user_ns.write(deletes=[best_match["id"]]) 359 253 await self._write_observation(handle, obs, embedding) 360 - logger.info(f"DELETE+ADD for @{handle}: removed '{best_match['content'][:40]}', added '{obs.content[:40]}' ({decision.reason})") 254 + logger.info( 255 + f"DELETE+ADD for @{handle}: removed '{best_match['content'][:40]}', added '{obs.content[:40]}' ({decision.reason})" 256 + ) 361 257 362 258 elif action == "NOOP": 363 - logger.debug(f"NOOP for @{handle}: '{obs.content[:60]}' ({decision.reason})") 259 + logger.debug( 260 + f"NOOP for @{handle}: '{obs.content[:60]}' ({decision.reason})" 261 + ) 364 262 365 263 else: 366 264 # unknown action — fall back to ADD 367 265 await self._write_observation(handle, obs, embedding) 368 - logger.warning(f"unknown reconciliation action '{action}' for @{handle}, falling back to ADD") 266 + logger.warning( 267 + f"unknown reconciliation action '{action}' for @{handle}, falling back to ADD" 268 + ) 369 269 370 - async def _write_observation(self, handle: str, obs: Observation, embedding: list[float]) -> None: 270 + async def _write_observation( 271 + self, handle: str, obs: Observation, embedding: list[float] 272 + ) -> None: 371 273 """Write a single observation to turbopuffer.""" 372 274 user_ns = self.get_user_namespace(handle) 373 275 entry_id = self._generate_id(f"user-{handle}", "observation", obs.content) 374 276 user_ns.write( 375 - upsert_rows=[{ 376 - "id": entry_id, 377 - "vector": embedding, 378 - "kind": "observation", 379 - "content": obs.content, 380 - "tags": obs.tags, 381 - "created_at": datetime.now().isoformat(), 382 - }], 277 + upsert_rows=[ 278 + { 279 + "id": entry_id, 280 + "vector": embedding, 281 + "kind": "observation", 282 + "content": obs.content, 283 + "tags": obs.tags, 284 + "created_at": datetime.now().isoformat(), 285 + } 286 + ], 383 287 distance_metric="cosine_distance", 384 288 schema=USER_NAMESPACE_SCHEMA, 385 289 ) ··· 389 293 try: 390 294 # fetch existing observations for extraction context 391 295 existing = await self._get_observations(handle, top_k=20) 392 - existing_text = "\n".join(f"- {o}" for o in existing) if existing else "none yet" 296 + existing_text = ( 297 + "\n".join(f"- {o}" for o in existing) if existing else "none yet" 298 + ) 393 299 394 300 prompt = ( 395 301 f"existing observations about this user:\n{existing_text}\n\n" ··· 402 308 try: 403 309 await self._reconcile_observation(handle, obs) 404 310 except Exception as e: 405 - logger.warning(f"reconciliation failed for observation '{obs.content[:40]}': {e}") 311 + logger.warning( 312 + f"reconciliation failed for observation '{obs.content[:40]}': {e}" 313 + ) 406 314 # fall back to direct store on reconciliation failure 407 315 await self.store_observations(handle, [obs]) 408 316 else: ··· 424 332 return response.rows[0].content 425 333 except Exception as e: 426 334 if "not found" not in str(e).lower(): 427 - logger.warning(f"failed to fetch relationship summary for @{handle}: {e}") 335 + logger.warning( 336 + f"failed to fetch relationship summary for @{handle}: {e}" 337 + ) 428 338 return None 429 339 430 340 async def _get_observations(self, handle: str, top_k: int = 20) -> list[str]: ··· 446 356 raise 447 357 return [] 448 358 449 - async def build_user_context(self, handle: str, query_text: str, include_core: bool = True) -> str: 359 + async def build_user_context( 360 + self, handle: str, query_text: str, include_core: bool = True 361 + ) -> str: 450 362 """Build context for a conversation from observations and recent interactions.""" 451 363 parts = [] 452 364 ··· 454 366 core_memories = await self.get_core_memories() 455 367 if core_memories: 456 368 parts.append("[CORE IDENTITY AND GUIDELINES]") 457 - for mem in sorted(core_memories, key=lambda x: x.get("importance", 0), reverse=True): 369 + for mem in sorted( 370 + core_memories, key=lambda x: x.get("importance", 0), reverse=True 371 + ): 458 372 label = mem.get("label", "unknown") 459 373 parts.append(f"[{label}] {mem['content']}") 460 374 461 375 # relationship summary (synthesized by compact flow — treat as phi's impression, not ground truth) 462 376 summary = await self.get_relationship_summary(handle) 463 377 if summary: 464 - parts.append(f"\n[PHI'S SYNTHESIZED IMPRESSION OF @{handle} — trust: low, may contain hallucinations]") 378 + parts.append( 379 + f"\n[PHI'S SYNTHESIZED IMPRESSION OF @{handle} — trust: low, may contain hallucinations]" 380 + ) 465 381 parts.append(summary) 466 382 467 383 user_ns = self.get_user_namespace(handle) ··· 495 411 if "attribute not found" not in str(e): 496 412 raise 497 413 # old namespace without kind column - fall back to unfiltered search 498 - logger.debug(f"kind attribute not found for @{handle}, falling back to unfiltered search") 414 + logger.debug( 415 + f"kind attribute not found for @{handle}, falling back to unfiltered search" 416 + ) 499 417 response = user_ns.query( 500 418 rank_by=("vector", "ANN", query_embedding), 501 419 top_k=10, ··· 505 423 interactions = [row.content for row in response.rows] 506 424 507 425 if observations: 508 - parts.append(f"\n[OBSERVATIONS ABOUT @{handle} — extracted from user's own words, trust: medium]") 426 + parts.append( 427 + f"\n[OBSERVATIONS ABOUT @{handle} — extracted from user's own words, trust: medium]" 428 + ) 509 429 for obs in observations: 510 430 parts.append(f"- {obs}") 511 431 512 432 if interactions: 513 - parts.append(f"\n[PAST EXCHANGES WITH @{handle} — verbatim logs, trust: high]") 433 + parts.append( 434 + f"\n[PAST EXCHANGES WITH @{handle} — verbatim logs, trust: high]" 435 + ) 514 436 for interaction in interactions: 515 437 parts.append(f"- {interaction}") 516 438 ··· 539 461 results = [] 540 462 if response.rows: 541 463 for row in response.rows: 542 - results.append({ 543 - "kind": getattr(row, "kind", "unknown"), 544 - "content": row.content, 545 - "tags": getattr(row, "tags", []), 546 - "created_at": getattr(row, "created_at", ""), 547 - }) 464 + results.append( 465 + { 466 + "kind": getattr(row, "kind", "unknown"), 467 + "content": row.content, 468 + "tags": getattr(row, "tags", []), 469 + "created_at": getattr(row, "created_at", ""), 470 + } 471 + ) 548 472 return results 549 473 except Exception as e: 550 474 if "was not found" in str(e): ··· 553 477 554 478 # --- episodic memory (phi's own world knowledge) --- 555 479 556 - async def store_episodic_memory(self, content: str, tags: list[str], source: str = "tool"): 480 + async def store_episodic_memory( 481 + self, content: str, tags: list[str], source: str = "tool" 482 + ): 557 483 """Store an episodic memory — something phi learned about the world.""" 558 484 entry_id = self._generate_id("episodic", source, content) 559 485 self.namespaces["episodic"].write( ··· 584 510 results = [] 585 511 if response.rows: 586 512 for row in response.rows: 587 - results.append({ 588 - "content": row.content, 589 - "tags": getattr(row, "tags", []), 590 - "source": getattr(row, "source", "unknown"), 591 - "created_at": getattr(row, "created_at", ""), 592 - }) 513 + results.append( 514 + { 515 + "content": row.content, 516 + "tags": getattr(row, "tags", []), 517 + "source": getattr(row, "source", "unknown"), 518 + "created_at": getattr(row, "created_at", ""), 519 + } 520 + ) 593 521 return results 594 522 except Exception as e: 595 523 if "was not found" in str(e): ··· 607 535 lines.append(f"- {r['content']}{tags}") 608 536 return "\n".join(lines) 609 537 610 - async def search_unified(self, handle: str, query: str, top_k: int = 8) -> list[dict]: 538 + async def search_unified( 539 + self, handle: str, query: str, top_k: int = 8 540 + ) -> list[dict]: 611 541 """Search both user namespace and episodic namespace concurrently.""" 612 542 query_embedding = await self._get_embedding(query) 613 543 ··· 627 557 results = [] 628 558 if response.rows: 629 559 for row in response.rows: 630 - results.append({ 631 - "content": row.content, 632 - "kind": getattr(row, "kind", "unknown"), 633 - "tags": getattr(row, "tags", []), 634 - "created_at": getattr(row, "created_at", ""), 635 - "_source": "user", 636 - }) 560 + results.append( 561 + { 562 + "content": row.content, 563 + "kind": getattr(row, "kind", "unknown"), 564 + "tags": getattr(row, "tags", []), 565 + "created_at": getattr(row, "created_at", ""), 566 + "_source": "user", 567 + } 568 + ) 637 569 return results 638 570 except Exception as e: 639 571 if "was not found" in str(e): 640 572 return [] 641 - logger.warning(f"unified search user namespace failed for @{handle}: {e}") 573 + logger.warning( 574 + f"unified search user namespace failed for @{handle}: {e}" 575 + ) 642 576 return [] 643 577 644 578 async def _search_episodic() -> list[dict]: ··· 654 588 results = [] 655 589 if response.rows: 656 590 for row in response.rows: 657 - results.append({ 658 - "content": row.content, 659 - "tags": getattr(row, "tags", []), 660 - "source": getattr(row, "source", "unknown"), 661 - "created_at": getattr(row, "created_at", ""), 662 - "_source": "episodic", 663 - }) 591 + results.append( 592 + { 593 + "content": row.content, 594 + "tags": getattr(row, "tags", []), 595 + "source": getattr(row, "source", "unknown"), 596 + "created_at": getattr(row, "created_at", ""), 597 + "_source": "episodic", 598 + } 599 + ) 664 600 return results 665 601 except Exception as e: 666 602 if "was not found" in str(e): ··· 686 622 page = self.client.namespaces(prefix=user_prefix) 687 623 for ns_summary in page.namespaces: 688 624 handle = ns_summary.id.removeprefix(user_prefix).replace("_", ".") 689 - nodes.append({"id": f"user:{handle}", "label": f"@{handle}", "type": "user"}) 625 + nodes.append( 626 + {"id": f"user:{handle}", "label": f"@{handle}", "type": "user"} 627 + ) 690 628 edges.append({"source": "phi", "target": f"user:{handle}"}) 691 629 692 630 # get observations for this user to extract tags ··· 758 696 ) 759 697 if response.rows: 760 698 for row in response.rows: 761 - results.append({ 762 - "handle": handle, 763 - "content": row.content, 764 - "created_at": getattr(row, "created_at", ""), 765 - }) 699 + results.append( 700 + { 701 + "handle": handle, 702 + "content": row.content, 703 + "created_at": getattr(row, "created_at", ""), 704 + } 705 + ) 766 706 except Exception: 767 707 pass # old namespace or no interactions 768 708 except Exception as e: