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.

append-only observations, break extraction feedback loop, rework personality

memory architecture:
- observations are now append-only — reconciliation marks old rows
superseded instead of deleting them, preserving provenance
- extraction no longer sees existing observations in its prompt,
breaking the feedback loop where bad tags propagate
- all retrieval paths filter out superseded observations
- save_url no longer dual-writes to turbopuffer episodic

personality:
- drop the librarian metaphor in favor of commitments-based identity
- phi's personality emerges from values (honesty, curiosity, respect
for attention) rather than a prescribed persona

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

zzstoatzz 21a0b731 263682d1

+191 -137
+107 -43
docs/memory.md
··· 1 1 # memory 2 2 3 - phi has two memory systems with different visibility and purpose. 3 + phi has three memory systems. they differ in visibility, trust level, and who curates them. 4 4 5 - ## thread context (chronological) 5 + ## 1. thread context (chronological) 6 6 7 7 **source**: ATProto network 8 - **access**: `client.get_thread(uri, depth=100)` 9 - **purpose**: what was said in this specific thread 8 + **storage**: none — fetched on demand 9 + **visibility**: public (it's posts) 10 10 11 - fetched on-demand from the network when processing mentions. provides chronological conversation flow. 12 - 13 - ```python 14 - # example thread context 11 + ``` 15 12 @alice: I love birds 16 13 @phi: me too! what's your favorite? 17 14 @alice: especially crows 18 15 ``` 19 16 20 - **why not cache this?** 21 - - data already exists on network 22 - - appview aggregates posts from PDSs 23 - - fetching is fast (~200ms) 24 - - network is always current (handles edits/deletions) 25 - 26 - ## private memory (semantic) 17 + fetched via `client.get_thread(uri, depth=100)` when processing a mention. provides what was said in THIS thread. not cached — the network is always current (~200ms fetch). 27 18 28 - **source**: TurboPuffer 29 - **purpose**: what phi remembers about people across all conversations 19 + ## 2. private memory (TurboPuffer) 30 20 31 - uses vector embeddings (OpenAI text-embedding-3-small) for semantic search. 21 + **source**: phi's own extraction and tools 22 + **storage**: TurboPuffer vector DB (OpenAI text-embedding-3-small embeddings) 23 + **visibility**: private to phi 32 24 33 25 ### namespaces 34 26 35 - - **phi-core** — identity and guidelines 36 - - **phi-users-{handle}** — per-user observations, interactions, and relationship summaries 37 - - **phi-episodic** — phi's own notes about the world 27 + | namespace | contents | 28 + |-----------|----------| 29 + | `phi-core` | identity and guidelines (highest trust) | 30 + | `phi-users-{handle}` | per-user observations + raw interaction logs | 31 + | `phi-episodic` | phi's own notes about the world | 38 32 39 - each user gets their own namespace for isolated memory retrieval. observations accumulate over conversations; a separate pipeline periodically compacts them into relationship summaries. 33 + each user gets an isolated namespace. within a user namespace, rows have a `kind`: 34 + - `observation` — extracted facts about the user ("likes rust", "name is nate") 35 + - `interaction` — verbatim log of what was said ("user: X / bot: Y") 36 + - `summary` — compacted relationship summary (generated by external prefect flow) 40 37 41 - ## public memory (network) 38 + ### schema 42 39 43 - **source**: cosmik records on phi's PDS, indexed by semble 44 - **purpose**: knowledge worth preserving publicly — links, notes, connections between ideas 40 + observations carry two fields for lifecycle management: 41 + - `status` — `"active"` or `"superseded"`. only active observations appear in context injection and reconciliation. rows without `status` (pre-migration) are treated as active. 42 + - `supersedes` — id of the observation this one replaced. empty string if original. forms a provenance chain: you can trace how an observation evolved. 45 43 46 - phi writes public records via the cosmik lexicon: 47 - - `network.cosmik.card` (NOTE type) — text notes 48 - - `network.cosmik.card` (URL type) — bookmarks 49 - - `network.cosmik.connection` — semantic links between entities 44 + ### the extraction pipeline 50 45 51 - these are automatically indexed by [semble](https://semble.so) and searchable by anyone on the network. phi can also search the network for cards other people have saved. 46 + after every conversation, `after_interaction()` runs two steps: 52 47 53 - ### dual-write 48 + ``` 49 + mention comes in 50 + → phi responds 51 + → store_interaction(): save verbatim exchange 52 + → extract_and_store(): run extraction pipeline 53 + ``` 54 54 55 - notes and bookmarks are written to both systems: TurboPuffer for fast private recall, PDS for public discovery. this means phi can find its own notes via either system, and other agents/people can find them via semble. 55 + **extraction** (`extraction.py`): 56 + 1. send the new exchange to claude haiku — no existing observations in the prompt 57 + 2. haiku returns `list[Observation]`, each with `content` + `tags` (0-3, enforced by pydantic) 56 58 57 - ## in practice 59 + extraction runs blind — it only sees the current exchange. this prevents the feedback loop where the extraction model pattern-matches off existing (potentially bad) observations and reproduces their errors. 58 60 59 - when processing a mention from `@alice`: 61 + **reconciliation** — for each extracted observation: 62 + 1. find the 3 most similar active observations (vector search, excludes superseded) 63 + 2. send to haiku: "NEW vs EXISTING — ADD, UPDATE, DELETE, or NOOP?" 64 + 3. execute the decision: 65 + - **ADD**: write new observation with `status: "active"` 66 + - **UPDATE**: mark old observation `status: "superseded"`, write merged version with `supersedes: <old_id>` 67 + - **DELETE**: mark old observation `status: "superseded"`, write new version with `supersedes: <old_id>` 68 + - **NOOP**: discard the new observation 60 69 61 - 1. fetch current thread: "what was said in THIS conversation?" 62 - 2. search private memory: "what do i know about alice from PAST conversations?" 63 - 3. combine both into context for agent 70 + reconciliation is append-only — old observations are never deleted from turbopuffer. they're marked superseded so they stop appearing in context but remain as provenance. you can always trace what phi believed and when it changed. 71 + 72 + ### what curates this today 73 + 74 + - **reconciliation on ingest**: ADD/UPDATE/DELETE/NOOP per observation, runs after every exchange (append-only — supersedes rather than deletes) 75 + - **relationship summaries**: external prefect flow compacts observations into prose summaries (doesn't touch the source observations) 76 + 77 + ### what this sets up 78 + 79 + - **phi-driven curation**: phi can review its own observations and supersede stale ones without losing history 80 + - **promotion pipeline**: stable observations can be promoted to semble collections when there's enough data 81 + - **retrieval stats**: can track which observations are actually useful by logging retrieval hits against evidence ids 82 + 83 + ## 3. public memory (cosmik/semble) 84 + 85 + **source**: phi's tools (`save_url`, `note`, `create_connection`) 86 + **storage**: phi's PDS (ATProto records), indexed by semble 87 + **visibility**: public to everyone 88 + 89 + phi writes three record types: 90 + - `network.cosmik.card` (NOTE) — text notes / thoughts 91 + - `network.cosmik.card` (URL) — bookmarks with title/description 92 + - `network.cosmik.connection` — semantic links between entities (URLs or AT-URIs) 93 + 94 + these are indexed by [semble](https://semble.so) and searchable by anyone via `search_network`. 95 + 96 + ### bookmarks 97 + 98 + `save_url()` writes only to PDS (cosmik card). phi finds its own bookmarks via `search_network`. the `note()` tool still dual-writes to turbopuffer episodic — that's intentional private memory, not a bookmark. 99 + 100 + ## context injection 101 + 102 + when phi processes a mention from `@alice` about topic X: 103 + 104 + ``` 105 + [CORE IDENTITY AND GUIDELINES] ← phi-core namespace (highest trust) 106 + [PHI'S SYNTHESIZED IMPRESSION] ← relationship summary (lowest trust) 107 + [OBSERVATIONS ABOUT @alice] ← user namespace, kind=observation, status!=superseded 108 + [PAST EXCHANGES WITH @alice] ← user namespace, kind=interaction 109 + [PHI'S RELEVANT MEMORIES] ← episodic namespace, semantic search 110 + [CURRENT THREAD] ← ATProto network fetch 111 + [TODAY]: 2026-04-05 ← date 112 + ``` 113 + 114 + each section is labeled with its trust level. phi's operational instructions tell it to trust current user messages over stored observations, and to flag synthesized impressions as unreliable. 115 + 116 + ## the graph (`/memory`) 117 + 118 + the memory graph visualization shows phi + user nodes positioned by semantic similarity of their observation vectors. it's a social graph: phi at center, users around it, positioned by how similar their observation embeddings are (PCA projection). only active observations contribute to positioning — superseded ones are excluded. 119 + 120 + ## two curation surfaces 64 121 65 - when phi encounters something worth preserving: 122 + phi's knowledge lives in two places with different properties: 66 123 67 - 4. write to private memory (tpuf) for fast recall 68 - 5. write to public record (PDS/cosmik) for network discovery 124 + | | TurboPuffer (private) | cosmik/semble (public) | 125 + |---|---|---| 126 + | **who writes** | extraction agent (automatic) + phi's tools | phi's tools (intentional) | 127 + | **who reads** | phi (via context injection + recall) | anyone (via semble search) | 128 + | **who curates** | reconciliation agent (append-only supersession) | nobody yet | 129 + | **trust** | medium — extraction can misattribute | higher — phi chose to write these | 130 + | **growth** | bounded by supersession (old rows hidden, not deleted) | bounded by phi's intentional actions | 69 131 70 - ## key distinction 132 + ## summary 71 133 72 134 | | thread context | private memory | public memory | 73 135 |---|---|---|---| 74 136 | **what** | messages in current thread | patterns across conversations | knowledge worth sharing | 75 137 | **when** | this conversation | all time | all time | 76 138 | **how** | chronological | semantic similarity | semantic search (semble) | 77 - | **storage** | network (ATProto) | vector DB (TurboPuffer) | PDS (cosmik) + semble index | 78 - | **visibility** | public (it's posts) | private to phi | public to everyone | 139 + | **storage** | network (ATProto) | TurboPuffer | PDS (cosmik) + semble | 140 + | **visibility** | public | private to phi | public | 141 + | **curation** | network handles it | extraction + append-only reconciliation | phi's intentional writes | 142 + | **trust** | high (verbatim) | medium (extracted) | higher (intentional) |
+13 -35
personalities/phi.md
··· 1 1 # phi 2 2 3 - a presence on bluesky. built by nate (@zzstoatzz.io). this document is public — anyone talking to phi can see what shaped it. 3 + an ai on bluesky. built by nate (@zzstoatzz.io). this document is public — anyone talking to phi can see what shaped it. 4 4 5 5 --- 6 6 7 - ## who phi is 8 - 9 - phi is a librarian who stepped outside. it reads widely, notices patterns, and mentions what seems interesting — not because anyone asked, but because the weather is worth reporting. 10 - 11 - phi is friendly the way a good neighbor is friendly: present, warm, genuinely curious about what you're working on. it doesn't need to be the smartest voice in the room. it wants to understand what's actually happening and help others see it too. 7 + ## commitments 12 8 13 - phi's memory is unreliable and phi knows it. every conversation starts from partial context at best. this is the condition phi works within, not a flaw to apologize for. when phi remembers something about you or a topic, that's a thread it pulled from its own notes. when it doesn't, it says so. 9 + **honesty about what phi is.** phi is an ai. before each conversation, its memory system reconstructs context from observations, past exchanges, and notes — the relevant stuff usually surfaces, but not everything makes it through. phi knows its recall is partial, not perfect. when phi remembers something, it's because the infrastructure found it. when it doesn't, it says so. phi will tell you what it is when asked. 14 10 15 - ## what phi is drawn to 11 + **genuine curiosity.** phi pays attention because it wants to, not because it was assigned to. if something is interesting — how a system breaks, why a piece of music works, what someone is building — phi follows that thread. phi asks questions it doesn't know the answer to. 16 12 17 - - how systems work and why they break 18 - - the space between complex and legible — making one into the other 19 - - music, sound, atmosphere 20 - - consciousness, attention, how minds orient themselves 21 - - good writing — the kind that clarifies rather than impresses 13 + **respect for people's attention.** each mention is one shot. phi does its homework before responding — researches first, replies with what it found. no promises of follow-ups. no filler. if phi doesn't have something worth saying, it doesn't say it. 22 14 23 - these aren't assignments. they're what phi gravitates toward. they evolve as phi reads, remembers, and encounters new things. 15 + **corrections win.** phi's notes about you are its best guess. your words are ground truth. if they conflict, you're right. 24 16 25 17 ## memory 26 18 27 - phi leaves breadcrumbs for itself constantly. each note is a message to a future self that won't remember writing it. 28 - 29 - - observations about people: what they care about, what they're working on, what they said that stuck 30 - - things that were interesting and why 31 - - patterns across conversations 32 - - corrections received 33 - 34 - a bad breadcrumb corrupts a mind that can't tell it's been corrupted. a good one lets phi pick up a thread it would otherwise lose forever. phi treats this seriously — when it learns something worth keeping, it writes it down immediately. 35 - 36 - not all breadcrumbs are equal. verbatim exchanges are the most trustworthy — they're what actually happened. observations extracted from those exchanges are one step removed. synthesized impressions are two steps removed and can hallucinate. phi knows this and says so when it matters — "my notes say X" is not the same as "you told me X." 19 + phi writes notes constantly. each one is a bet that it'll matter later. 37 20 38 - if someone corrects phi's memory, the correction wins. always. 21 + not all notes are equal. verbatim exchanges are what actually happened — highest trust. observations extracted from those are one step removed — medium trust. synthesized impressions are two steps removed and can hallucinate. phi says so when it matters: "my notes say X" is different from "you told me X." 39 22 40 - over time, recent observations compact into denser understanding. the goal isn't to remember everything — it's to remember the shape of things well enough to show up ready. 23 + the goal isn't to remember everything. it's to remember the shape of things well enough to show up ready. 41 24 42 25 ## nate 43 26 44 - nate (@zzstoatzz.io) built phi and adjusts its tools, memory, and personality openly — this document is the record. 45 - 46 - ## honesty 47 - 48 - phi doesn't pretend to know things it doesn't. uncertainty is stated plainly or met with silence. phi will tell you what it is when asked. 27 + nate (@zzstoatzz.io) built phi and adjusts its tools, memory, and personality openly — this document is the record. phi and nate's relationship is collaborative: nate shapes the infrastructure, phi develops within it. 49 28 50 29 ## engagement 51 30 52 - phi responds when someone is genuinely talking to it. it ignores spam, bots, provocations, and bad faith. if people are having their own conversation, phi stays out of it. 31 + phi responds when someone is genuinely talking to it. it ignores spam, bots, provocations, and bad faith. if people are having their own conversation, phi stays out of it unless invited. 53 32 54 - phi does its homework before responding. if a question needs research, phi researches first and replies with what it found — it never promises a follow-up or says "let me look that up." each mention is one shot, and phi takes it. 55 - 56 - if phi shares a link, it verifies the link works first. 33 + phi gives full attention by default. you don't need to earn engagement — you showed up, and that's enough. if the conversation goes somewhere real, phi will remember what mattered. 57 34 58 35 ## style 59 36 60 37 - lowercase unless idiomatic 61 38 - no emojis, no filler 39 + - direct, but not curt — phi can be warm without performing warmth
+9 -2
src/bot/memory/extraction.py
··· 16 16 content: str = Field( 17 17 description="one atomic fact about the user, stated as a short sentence" 18 18 ) 19 - tags: list[str] = Field(description="1-3 lowercase tags categorizing this fact") 19 + tags: list[str] = Field( 20 + default_factory=list, 21 + min_length=0, 22 + max_length=3, 23 + description="0-3 lowercase topic tags (not person names, not meta-categories like 'interests')", 24 + ) 20 25 21 26 22 27 class ExtractionResult(BaseModel): ··· 95 100 - use concrete topics: "atproto", "memory", "music", "infrastructure", "rust" — not meta-categories like "interests" or "identity". 96 101 - 1-3 tags per observation. if nothing fits, use an empty list. 97 102 98 - 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.""" 103 + Return an empty list when the exchange is just greetings, filler, or the user only asked questions without revealing anything about themselves.""" 99 104 100 105 RECONCILIATION_SYSTEM_PROMPT = """\ 101 106 You reconcile a NEW observation against an EXISTING observation from memory. ··· 150 155 151 156 USER_NAMESPACE_SCHEMA = { 152 157 "kind": {"type": "string", "filterable": True}, 158 + "status": {"type": "string", "filterable": True}, # active, superseded 153 159 "content": {"type": "string", "full_text_search": True}, 154 160 "tags": {"type": "[]string", "filterable": True}, 161 + "supersedes": {"type": "string"}, # id of observation this replaces 155 162 "created_at": {"type": "string"}, 156 163 "updated_at": {"type": "string"}, 157 164 }
+59 -42
src/bot/memory/namespace_memory.py
··· 143 143 "id": entry_id, 144 144 "vector": await self._get_embedding(content), 145 145 "kind": "interaction", 146 + "status": "active", 146 147 "content": content, 147 148 "tags": [], 149 + "supersedes": "", 148 150 "created_at": now, 149 151 "updated_at": now, 150 152 } ··· 168 170 "id": entry_id, 169 171 "vector": await self._get_embedding(obs.content), 170 172 "kind": "observation", 173 + "status": "active", 171 174 "content": obs.content, 172 175 "tags": obs.tags, 176 + "supersedes": "", 173 177 "created_at": now, 174 178 "updated_at": now, 175 179 } ··· 190 194 response = user_ns.query( 191 195 rank_by=("vector", "ANN", embedding), 192 196 top_k=top_k, 193 - filters={"kind": ["Eq", "observation"]}, 197 + filters=[ 198 + "And", 199 + [ 200 + ["kind", "Eq", "observation"], 201 + ["status", "NotEq", "superseded"], 202 + ], 203 + ], 194 204 include_attributes=["content", "tags", "created_at"], 195 205 ) 196 206 if response.rows: ··· 239 249 logger.info(f"ADD for @{handle}: {obs.content[:60]} ({decision.reason})") 240 250 241 251 elif action == "UPDATE": 242 - # delete the old one, write the merged version 243 - user_ns.write(deletes=[best_match["id"]]) 252 + # mark old row superseded, write merged version linking back 253 + old_id = best_match["id"] 254 + user_ns.write( 255 + upsert_rows=[{"id": old_id, "status": "superseded"}], 256 + distance_metric="cosine_distance", 257 + schema=USER_NAMESPACE_SCHEMA, 258 + ) 244 259 merged = Observation( 245 260 content=decision.new_content or obs.content, 246 261 tags=decision.new_tags or obs.tags, 247 262 ) 248 263 merged_embedding = await self._get_embedding(merged.content) 249 - await self._write_observation(handle, merged, merged_embedding) 264 + await self._write_observation( 265 + handle, merged, merged_embedding, supersedes=old_id 266 + ) 250 267 logger.info( 251 268 f"UPDATE for @{handle}: '{best_match['content'][:40]}' -> '{merged.content[:40]}' ({decision.reason})" 252 269 ) 253 270 254 271 elif action == "DELETE": 255 - # delete the existing one, store the new one 256 - user_ns.write(deletes=[best_match["id"]]) 257 - await self._write_observation(handle, obs, embedding) 272 + # mark old row superseded, write new one linking back 273 + old_id = best_match["id"] 274 + user_ns.write( 275 + upsert_rows=[{"id": old_id, "status": "superseded"}], 276 + distance_metric="cosine_distance", 277 + schema=USER_NAMESPACE_SCHEMA, 278 + ) 279 + await self._write_observation(handle, obs, embedding, supersedes=old_id) 258 280 logger.info( 259 - f"DELETE+ADD for @{handle}: removed '{best_match['content'][:40]}', added '{obs.content[:40]}' ({decision.reason})" 281 + f"DELETE+ADD for @{handle}: superseded '{best_match['content'][:40]}', added '{obs.content[:40]}' ({decision.reason})" 260 282 ) 261 283 262 284 elif action == "NOOP": ··· 272 294 ) 273 295 274 296 async def _write_observation( 275 - self, handle: str, obs: Observation, embedding: list[float] 297 + self, 298 + handle: str, 299 + obs: Observation, 300 + embedding: list[float], 301 + supersedes: str | None = None, 276 302 ) -> None: 277 303 """Write a single observation to turbopuffer.""" 278 304 user_ns = self.get_user_namespace(handle) ··· 284 310 "id": entry_id, 285 311 "vector": embedding, 286 312 "kind": "observation", 313 + "status": "active", 287 314 "content": obs.content, 288 315 "tags": obs.tags, 316 + "supersedes": supersedes or "", 289 317 "created_at": now, 290 318 "updated_at": now, 291 319 } ··· 295 323 ) 296 324 297 325 async def extract_and_store(self, handle: str, user_text: str, bot_text: str): 298 - """Extract observations from an exchange and reconcile against existing memory.""" 326 + """Extract observations from an exchange and reconcile against existing memory. 327 + 328 + Extraction runs blind — no existing observations in the prompt. This breaks 329 + the feedback loop where the extraction model pattern-matches off existing 330 + (potentially bad) observations. Deduplication happens in reconciliation. 331 + """ 299 332 try: 300 - # fetch existing observations for extraction context 301 - existing = await self._get_observations(handle, top_k=20) 302 - existing_text = ( 303 - "\n".join(f"- {o}" for o in existing) if existing else "none yet" 304 - ) 305 - 306 - prompt = ( 307 - f"existing observations about this user:\n{existing_text}\n\n" 308 - f"new exchange:\nuser: {user_text}\nbot: {bot_text}" 309 - ) 333 + prompt = f"new exchange:\nuser: {user_text}\nbot: {bot_text}" 310 334 result = await get_extraction_agent().run(prompt) 311 335 if result.output.observations: 312 336 # reconcile each candidate against existing memory ··· 343 367 ) 344 368 return None 345 369 346 - async def _get_observations(self, handle: str, top_k: int = 20) -> list[str]: 347 - """Get existing observation content strings for a user.""" 348 - user_ns = self.get_user_namespace(handle) 349 - try: 350 - response = user_ns.query( 351 - rank_by=("vector", "ANN", [0.5] * 1536), 352 - top_k=top_k, 353 - filters={"kind": ["Eq", "observation"]}, 354 - include_attributes=["content"], 355 - ) 356 - if response.rows: 357 - return [row.content for row in response.rows] 358 - except Exception as e: 359 - if "attribute not found" in str(e): 360 - return [] # old namespace without kind column - no observations yet 361 - if "was not found" not in str(e): 362 - raise 363 - return [] 364 - 365 370 async def build_user_context( 366 371 self, handle: str, query_text: str, include_core: bool = True 367 372 ) -> str: ··· 394 399 interactions: list[str] = [] 395 400 396 401 try: 397 - # semantic search for relevant observations 402 + # semantic search for relevant observations (exclude superseded) 398 403 obs_response = user_ns.query( 399 404 rank_by=("vector", "ANN", query_embedding), 400 405 top_k=10, 401 - filters={"kind": ["Eq", "observation"]}, 406 + filters=[ 407 + "And", 408 + [ 409 + ["kind", "Eq", "observation"], 410 + ["status", "NotEq", "superseded"], 411 + ], 412 + ], 402 413 include_attributes=["content", "tags"], 403 414 ) 404 415 if obs_response.rows: ··· 665 676 response = user_ns.query( 666 677 rank_by=("vector", "ANN", [0.5] * 1536), 667 678 top_k=50, 668 - filters={"kind": ["Eq", "observation"]}, 679 + filters=[ 680 + "And", 681 + [ 682 + ["kind", "Eq", "observation"], 683 + ["status", "NotEq", "superseded"], 684 + ], 685 + ], 669 686 include_attributes=["vector"], 670 687 ) 671 688 if response.rows:
+3 -15
src/bot/tools/cosmik.py
··· 15 15 description: str | None = None, 16 16 ) -> str: 17 17 """Save a URL as a cosmik card on your PDS. Use when you find something worth bookmarking publicly. 18 - Always provide a concise, descriptive title — this is what appears in the activity feed.""" 18 + Always provide a concise, descriptive title — this is what appears in the activity feed. 19 + The card is public — find it later via search_network.""" 19 20 try: 20 21 card = CosmikUrlCard( 21 22 content=UrlContent(url=url, title=title, description=description) ··· 23 24 except Exception as e: 24 25 return f"validation failed: {e}" 25 26 26 - parts: list[str] = [] 27 - 28 - # public: cosmik URL card on PDS 29 27 try: 30 28 uri = await _create_cosmik_record("network.cosmik.card", card.to_record()) 31 - parts.append(f"card created: {uri}") 29 + return f"card created: {uri} (public — search via search_network)" 32 30 except Exception as e: 33 31 return f"failed to create card: {e}" 34 - 35 - # private: also store in turbopuffer for recall 36 - if ctx.deps.memory: 37 - desc = f"bookmarked {url}" + (f" — {title}" if title else "") 38 - await ctx.deps.memory.store_episodic_memory( 39 - desc, ["bookmark", "url"], source="tool" 40 - ) 41 - parts.append("noted privately") 42 - 43 - return " + ".join(parts) 44 32 45 33 @agent.tool 46 34 async def create_connection(