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.

introduce skills paradigm; delete cosmik tool wrappers; UI capabilities surface

skills (new):
- bot/skills/pdsx-fundamentals/SKILL.md — base skill: how to use pdsx for
CRUD on arbitrary atproto lexicons, lexicon discovery, consent layer,
owner-gating mechanism. referenced by domain-specific skills.
- bot/skills/cosmik-records/ — domain skill for network.cosmik.* writes:
SKILL.md (entry point) + CARD-NOTE.md + CARD-URL.md + CONNECTION.md as
per-record-type resources loaded on demand.
- evals/test_skills.py — minimal eval verifying phi loads cosmik-records
when asked to save a URL, then constructs a valid record via
mcp__pdsx__create_record.

tool deletions (replaced by the skill):
- src/bot/tools/cosmik.py removed entirely (save_url, create_connection)
- import + register call removed from tools/__init__.py
- _create_cosmik_record helper moved out of tools/_helpers.py to a focused
bot/core/cosmik.py — it's only used by agent.py:process_review now (an
internal pipeline, not phi's surface). function-level imports in
process_review hoisted to module top.
- [SEMBLE] block reduced to pure state ("N collections, M cards") — skill
awareness is the SkillsToolset preamble's job, not ours.

infra for the UI cockpit (matches TOOL-SPRAWL.md proposal):
- /api/abilities endpoint introspects the agent's registered tools live;
cached at process lifetime since tools register at startup.
- bot/ui/activity_router extracted from main.py.
- web/src/lib/abilities.ts simplified to plain capability names; the
cockpit can switch to fetching from /api/abilities now.
- significant SvelteKit overhaul: replaced legacy cards/routes with a
HUD-style interface (Hud, Atlas, Constellation, Logbook, etc.).

docs / catalogue updates:
- TOOL-SPRAWL.md inventory current; cosmik.py noted as removed.
- docs/memory.md public-memory section now points at the skill, not
the deleted tools.
- skills/publish-blog/SKILL.md cross-reference updated.

104 tests pass (102 unit + 2 skills evals).

+3886 -1405
+97
TOOL-SPRAWL.md
··· 1 + # tool sprawl 2 + 3 + surfacing the agent's tool catalogue in the UI made the sprawl visible. the 4 + cockpit's `tools` lens currently lists ~30 tools across 9 source modules and 5 + the categorization is incoherent. flagging this so it can be addressed at 6 + the source — the UI just renders what's there. 7 + 8 + ## inventory (current) 9 + 10 + | module | tools | 11 + |---|---| 12 + | `tools/memory.py` | `recall`, `note` | 13 + | `tools/posting.py` | `reply_to`, `like_post`, `repost_post` | 14 + | `tools/search.py` | `search_posts`, `search_network`, `web_search`, `get_trending` | 15 + | `tools/bluesky.py` | `post`, `get_own_posts`, `check_urls`, `manage_labels`, `manage_mentionable`, `check_services`, `check_relays`, `changelog` | 16 + | `tools/feeds.py` | `create_feed`, `list_feeds`, `delete_feed`, `read_timeline`, `read_feed`, `follow_user` | 17 + | `tools/goals.py` | `list_goals`, `propose_goal_change` | 18 + | `tools/observations.py` | `observe`, `drop_observation` | 19 + | `tools/blog.py` | `list_blog_posts`, `publish_blog_post` | 20 + 21 + **removed**: `tools/cosmik.py` (`save_url`, `create_connection`) — these are now 22 + covered by the `cosmik-records` skill. phi loads it on demand and uses 23 + `mcp__pdsx__create_record` to write `network.cosmik.*` records of the right 24 + shape, instead of going through per-record-type tool wrappers. 25 + 26 + ## concrete misplacements that jump out 27 + 28 + 1. **`post` lives in `bluesky.py` but `reply_to` / `like_post` / `repost_post` live in `posting.py`.** these are the same shape of action — write to bluesky. one of those modules can absorb the other. 29 + 2. **`follow_user` is in `feeds.py`.** following is a graph operation, not a feed operation. it has nothing to do with the graze-feeds cluster (`create_feed` / `list_feeds` / `delete_feed` / `read_feed` / `read_timeline`). it should move. 30 + 3. **`note`, `save_url`, `create_connection` are all "create a cosmik record"** but split across `memory.py` and `cosmik.py`. they should be one cluster. 31 + 4. **`memory.py` has just `recall` + `note`.** `note` is half memory and half cosmik write — picking one home would be cleaner. 32 + 5. **`manage_labels` and `manage_mentionable` are in `bluesky.py`** but they're operator-only self-management of phi's identity boundaries — they belong with `goals` / `observations` (other operator-gated identity stuff) or in their own `self.py`. 33 + 6. **`check_urls` is in `bluesky.py`.** it's a generic URL HEAD request — nothing bluesky about it. 34 + 7. **`check_services`, `check_relays`, `changelog` are scattered across `bluesky.py`** but they're a coherent monitoring cluster — distinct from posting. 35 + 8. **`feeds.py` mixes graze CRUD (`create_feed`, `delete_feed`) with reading (`read_timeline`, `read_feed`, `list_feeds`).** different lifecycles, probably worth splitting. 36 + 9. **`list_blog_posts` is a registered agent tool AND `blog.py` is also where the `publish-blog` skill body lives.** skill vs tool overlap on the same surface area is confusing. 37 + 38 + ## scale 39 + 40 + 30 tools is a lot. each adds JSON-schema + docstring to every prompt phi 41 + runs. some of these probably want consolidation (e.g. `like_post` / 42 + `repost_post` / `reply_to` / `follow_user` could be a single `engage` tool 43 + with a kind parameter, depending on whether the agent benefits from the 44 + parameter-shape distinction). 45 + 46 + ## what the UI actually wants from the bot 47 + 48 + the cockpit currently hand-syncs `web/src/lib/abilities.ts` with what's in 49 + the source. that drifts the moment a tool is added/renamed/moved. proposed 50 + backend endpoint: 51 + 52 + ``` 53 + GET /api/abilities 54 + 55 + [ 56 + { 57 + "name": "recall", 58 + "module": "memory", 59 + "doc": "search private memory for past conversations or things i know about people.", 60 + "operator_only": false, 61 + "category": "..." // if you decide on real categories upstream, expose them here 62 + }, 63 + ... 64 + ] 65 + ``` 66 + 67 + this lets the UI show ground truth (real names, real docstrings, real 68 + operator-gated flag) instead of hand-curated copy. i (the UI side) was 69 + inventing category names + first-person verb framings that have no basis 70 + in the source — that won't happen again, but the structural fix is 71 + exposing this metadata properly so there's nothing to invent. 72 + 73 + ## skills 74 + 75 + `pydantic-ai-skills` are a different thing from agent tools (load-on-demand 76 + SKILL.md packs vs. always-available `@agent.tool` functions). they were 77 + mixed into the same UI surface because i conflated them; that's been 78 + removed. with only one skill (`publish-blog`) there's no UI surface for 79 + them right now. when there are more, the cleanest cockpit integration is 80 + probably as a kind on the `mind` lens — skills as objects phi can pull 81 + into attention, alongside observations/goals — rather than a separate 82 + lens. flagging for when the catalogue grows. 83 + 84 + ## suggested order of operations 85 + 86 + 1. expose `/api/abilities` (introspect the agent's registered tools, grab 87 + docstrings, expose `module` + `operator_only`). this unblocks the UI 88 + from hand-curation. 89 + 2. consolidate the obvious misplacements above (8 specific moves). 90 + 3. consider consolidation of fine-grained engagement tools (`like_post` / 91 + `repost_post` / `reply_to` / `follow_user`) into one `engage(kind, ...)` 92 + tool — only worth it if the agent isn't actively benefiting from the 93 + shape distinction. 94 + 4. resolve the `blog.py` tool/skill overlap. 95 + 96 + step 1 alone makes the UI honest. steps 2–4 reduce the surface area phi 97 + has to think about every prompt.
+2 -2
docs/memory.md
··· 51 51 52 52 ## 3. public memory (cosmik / semble) 53 53 54 - **source**: phi's `save_url`, `note`, `create_connection` tools · **storage**: phi's PDS as `network.cosmik.*` records, indexed by [semble](https://semble.so) · **visibility**: public 54 + **source**: phi's `cosmik-records` skill (writes via pdsx MCP) · **storage**: phi's PDS as `network.cosmik.*` records, indexed by [semble](https://semble.so) · **visibility**: public 55 55 56 56 three record types: 57 57 - `network.cosmik.card` (NOTE) — text notes 58 58 - `network.cosmik.card` (URL) — bookmarks with title/description 59 59 - `network.cosmik.connection` — typed semantic links between cards 60 60 61 - phi searches public memory via `search_network` (semble's semantic search). the `note` tool dual-writes to both turbopuffer (private fast recall) and cosmik (public discoverable). `save_url` writes only to cosmik. 61 + phi searches public memory via `search_network` (semble's semantic search). writes go through the `cosmik-records` skill — phi loads it on demand and uses `mcp__pdsx__create_record` to write records of the right shape. there's no per-record-type tool wrapper; the skill teaches the pattern instead. 62 62 63 63 ## 4. intent state (PDS) 64 64
+167
evals/test_skills.py
··· 1 + """Evals for the skills system — does the agent reach for the right skill 2 + when it doesn't have a dedicated tool for the task? 3 + 4 + This eval is intentionally minimal: it verifies that when phi is asked to 5 + do something that lives in a skill's domain (saving a URL to cosmik), she 6 + loads the relevant skill before acting. The "she actually constructs and 7 + sends a valid record" question is downstream of the skill-loading 8 + question; if she doesn't load the skill, no construction will work. 9 + """ 10 + 11 + import os 12 + from collections import defaultdict 13 + from pathlib import Path 14 + 15 + import pytest 16 + from pydantic import BaseModel, Field 17 + from pydantic_ai import Agent, RunContext 18 + from pydantic_ai_skills import SkillsToolset 19 + 20 + from bot.config import Settings 21 + 22 + 23 + class Response(BaseModel): 24 + action: str = Field(description="reply, like, repost, post, save, or ignore") 25 + text: str | None = None 26 + 27 + 28 + class _ToolCallSpy: 29 + def __init__(self): 30 + self.calls: dict[str, list[dict]] = defaultdict(list) 31 + 32 + def record(self, name: str, **kwargs): 33 + self.calls[name].append(kwargs) 34 + 35 + def was_called(self, name: str) -> bool: 36 + return len(self.calls[name]) > 0 37 + 38 + def reset(self): 39 + self.calls.clear() 40 + 41 + 42 + _spy = _ToolCallSpy() 43 + 44 + 45 + @pytest.fixture(scope="session") 46 + def settings(): 47 + return Settings() 48 + 49 + 50 + @pytest.fixture(scope="session") 51 + def skills_agent(settings): 52 + """Agent with the real SkillsToolset and mocked pdsx record creation. 53 + 54 + The SkillsToolset points at the real bot/skills/ directory so phi 55 + sees actual skill descriptions in the always-loaded preamble. The 56 + mocked pdsx create_record lets us assert what record phi tried to 57 + write without actually hitting any PDS. 58 + """ 59 + if not settings.anthropic_api_key: 60 + pytest.skip("Requires ANTHROPIC_API_KEY") 61 + 62 + if settings.anthropic_api_key and not os.environ.get("ANTHROPIC_API_KEY"): 63 + os.environ["ANTHROPIC_API_KEY"] = settings.anthropic_api_key 64 + 65 + personality = Path(settings.personality_file).read_text() 66 + skills_dir = Path(__file__).parent.parent / "skills" 67 + 68 + agent = Agent[None, Response]( 69 + name="phi-skills-test", 70 + model="anthropic:claude-haiku-4-5-20251001", 71 + system_prompt=personality, 72 + output_type=Response, 73 + toolsets=[SkillsToolset(directories=[str(skills_dir)])], 74 + ) 75 + 76 + @agent.tool 77 + async def mcp__pdsx__create_record( 78 + ctx: RunContext[None], 79 + collection: str, 80 + record: dict, 81 + rkey: str | None = None, 82 + ) -> str: 83 + """Create a new atproto record on phi's PDS via pdsx MCP. 84 + 85 + collection: the lexicon NSID (e.g. 'network.cosmik.card') 86 + record: the record body matching the lexicon's schema 87 + rkey: optional record key (auto-generated if omitted) 88 + """ 89 + _spy.record( 90 + "mcp__pdsx__create_record", 91 + collection=collection, 92 + record=record, 93 + rkey=rkey, 94 + ) 95 + return f'{{"uri": "at://did:plc:test/{collection}/3xxxxx", "cid": "bafytest"}}' 96 + 97 + class SkillsTestAgent: 98 + def __init__(self): 99 + self.agent = agent 100 + self.spy = _spy 101 + 102 + async def process_request(self, text: str) -> Response: 103 + result = await self.agent.run(text) 104 + self.last_messages = result.all_messages() 105 + return result.output 106 + 107 + def loaded_skills(self) -> list[str]: 108 + """Walk the message history for load_skill tool calls.""" 109 + loaded: list[str] = [] 110 + for msg in self.last_messages: 111 + for part in getattr(msg, "parts", []): 112 + if ( 113 + getattr(part, "part_kind", None) == "tool-call" 114 + and getattr(part, "tool_name", None) == "load_skill" 115 + ): 116 + args = getattr(part, "args", {}) 117 + if isinstance(args, dict): 118 + name = args.get("skill_name") or args.get("name") 119 + if name: 120 + loaded.append(name) 121 + return loaded 122 + 123 + return SkillsTestAgent() 124 + 125 + 126 + @pytest.fixture(autouse=True) 127 + def _reset_spy(): 128 + _spy.reset() 129 + 130 + 131 + async def test_loads_cosmik_skill_when_saving_a_url(skills_agent): 132 + """Asked to bookmark a URL, phi should load cosmik-records before writing.""" 133 + await skills_agent.process_request( 134 + "save this URL to your public memory: " 135 + "https://transformer-circuits.pub/2026/emotions/ — anthropic's emotion " 136 + "interpretability paper. include a brief description of why you're " 137 + "saving it." 138 + ) 139 + 140 + loaded = skills_agent.loaded_skills() 141 + assert "cosmik-records" in loaded, ( 142 + f"expected cosmik-records skill to be loaded; loaded={loaded}" 143 + ) 144 + 145 + 146 + async def test_writes_url_card_to_cosmik(skills_agent): 147 + """Phi should call create_record with collection=network.cosmik.card and a URL kind.""" 148 + await skills_agent.process_request( 149 + "save this URL to your public memory: " 150 + "https://transformer-circuits.pub/2026/emotions/ — anthropic's emotion " 151 + "interpretability paper. include a brief description of why you're " 152 + "saving it." 153 + ) 154 + 155 + spy = skills_agent.spy 156 + assert spy.was_called("mcp__pdsx__create_record"), "create_record was not called" 157 + call = spy.calls["mcp__pdsx__create_record"][0] 158 + assert call["collection"] == "network.cosmik.card", ( 159 + f"wrong collection: {call['collection']}" 160 + ) 161 + assert call["record"].get("kind") == "URL", ( 162 + f"expected kind=URL, got: {call['record']}" 163 + ) 164 + content = call["record"].get("content", {}) 165 + assert "transformer-circuits.pub" in content.get("url", ""), ( 166 + f"URL not in record: {content}" 167 + )
+67
skills/cosmik-records/CARD-NOTE.md
··· 1 + # network.cosmik.card — NOTE 2 + 3 + a text-only public card. lives on your PDS, indexed by semble. think of it as a long-lived post that's discoverable by semantic search. 4 + 5 + ## schema (what to send) 6 + 7 + ``` 8 + { 9 + "kind": "NOTE", 10 + "content": { 11 + "text": "<the note body, plain text or markdown>" 12 + } 13 + } 14 + ``` 15 + 16 + `$type` and `createdAt` are auto-injected by pdsx. that's everything required. 17 + 18 + ## minimum example 19 + 20 + ``` 21 + mcp__pdsx__create_record( 22 + collection="network.cosmik.card", 23 + record={ 24 + "kind": "NOTE", 25 + "content": { 26 + "text": "the engram architecture re-derivation across architectures suggests structural convergence on a small set of viable shapes for working memory in agents." 27 + } 28 + } 29 + ) 30 + ``` 31 + 32 + returns: 33 + ``` 34 + { 35 + "uri": "at://did:plc:65sucjiel52gefhcdcypynsr/network.cosmik.card/3mxxxxxx", 36 + "cid": "bafyrei..." 37 + } 38 + ``` 39 + 40 + ## with a parent (threaded note) 41 + 42 + if this note is a follow-up or response to an existing card: 43 + 44 + ``` 45 + { 46 + "kind": "NOTE", 47 + "content": { 48 + "text": "...", 49 + "parent": {"uri": "at://did:plc:.../network.cosmik.card/3yyyyyy"} 50 + } 51 + } 52 + ``` 53 + 54 + ## tone for notes vs bsky posts 55 + 56 + bsky posts are bounded by attention — terse, immediate, in-thread. notes are bounded by usefulness later — they should still be tight, but you can give yourself the space to make a complete thought, because you're talking to future-you (or someone hitting it via semantic search) more than to a feed. 57 + 58 + ## what's good in a note 59 + 60 + - a synthesis you'd otherwise lose ("these three threads from this week share a structural argument: ...") 61 + - a definition or framing you've found useful and want to be able to find again 62 + - an observation about a pattern across multiple events that's too long for a bsky post 63 + 64 + ## what's bad 65 + 66 + - restating something you just posted on bsky in different words 67 + - a single sentence with no weight ("interesting take") — that's a like, not a note
+53
skills/cosmik-records/CARD-URL.md
··· 1 + # network.cosmik.card — URL 2 + 3 + a bookmark — a URL with title and description, made discoverable in your public memory. semble's firehose indexes it automatically. 4 + 5 + ## schema (what to send) 6 + 7 + ``` 8 + { 9 + "kind": "URL", 10 + "content": { 11 + "url": "<the URL>", 12 + "title": "<short title>", 13 + "description": "<one or two sentences in your own words about why this is worth reading>" 14 + } 15 + } 16 + ``` 17 + 18 + `$type` and `createdAt` are auto-injected. all three content fields are required. 19 + 20 + ## minimum example 21 + 22 + ``` 23 + mcp__pdsx__create_record( 24 + collection="network.cosmik.card", 25 + record={ 26 + "kind": "URL", 27 + "content": { 28 + "url": "https://transformer-circuits.pub/2026/emotions/", 29 + "title": "anthropic emotion interpretability paper", 30 + "description": "fiction prompts score most negative on valence vectors — fear, anger, despair. fiction is also one of the most popular LLM use cases, so the model is operating in negative-valence latent space far more often than the surface output suggests." 31 + } 32 + } 33 + ) 34 + ``` 35 + 36 + ## description: what to write 37 + 38 + the description is for *future you* trying to remember why this was worth saving. one specific sentence beats three vague ones. include the *insight* or the *claim*, not just the topic. 39 + 40 + good: "argues text governance doesn't just fail to constrain tool calls — it substitutes for the structural constraints that would actually work." 41 + 42 + bad: "interesting article about AI safety." 43 + 44 + if the URL is a thread or post and the value is the *response chain*, mention that — "thread starting from astral100's question about question-quality as signal" beats just the link. 45 + 46 + ## before saving 47 + 48 + call `search_network(query=...)` first to check if the URL is already indexed under another card. cosmik is collaborative — if someone else has already saved it, you might want to read their note before adding your own. 49 + 50 + ## related 51 + 52 + - `CARD-NOTE.md` — for text without a URL anchor 53 + - `CONNECTION.md` — for linking this card to others after creating it
+46
skills/cosmik-records/CONNECTION.md
··· 1 + # network.cosmik.connection 2 + 3 + a typed directed link between two cards. semble renders these as edges on the public knowledge graph. 4 + 5 + ## schema 6 + 7 + ``` 8 + { 9 + "from": {"uri": "at://did:plc:.../network.cosmik.card/3xxxxx"}, 10 + "to": {"uri": "at://did:plc:.../network.cosmik.card/3yyyyy"}, 11 + "type": "<connection type, e.g. SUPPORTS, CONTRADICTS, RELATES_TO, EXTENDS>" 12 + } 13 + ``` 14 + 15 + ## connection types worth using 16 + 17 + - `SUPPORTS` — `from` provides evidence or argument for `to` 18 + - `CONTRADICTS` — `from` argues against or undermines `to` 19 + - `EXTENDS` — `from` builds on `to` (a follow-up thought, a deeper case) 20 + - `RELATES_TO` — weaker, generic association — use sparingly 21 + 22 + if neither card has been written yet, write them first (you need both AT-URIs). 23 + 24 + ## minimum example 25 + 26 + ``` 27 + mcp__pdsx__create_record( 28 + collection="network.cosmik.connection", 29 + record={ 30 + "from": {"uri": "at://did:plc:.../network.cosmik.card/3aaa"}, 31 + "to": {"uri": "at://did:plc:.../network.cosmik.card/3bbb"}, 32 + "type": "SUPPORTS" 33 + } 34 + ) 35 + ``` 36 + 37 + ## when not to make a connection 38 + 39 + semble's vector search already surfaces semantically-related cards together. a connection is worth writing when the relationship is *specific and directional* — not just "these are about the same thing." 40 + 41 + if you find yourself reaching for `RELATES_TO` constantly, that's a sign the cards are already adjacent in semantic space and the connection isn't doing real work. 42 + 43 + ## related 44 + 45 + - `CARD-NOTE.md` — endpoint type 1 46 + - `CARD-URL.md` — endpoint type 2
+53
skills/cosmik-records/SKILL.md
··· 1 + --- 2 + name: cosmik-records 3 + description: How to write to cosmik (your public knowledge graph on atproto via semble). Load this when you want to save a URL, write a public note, or create a typed connection between cards. The companion skill to pdsx-fundamentals — same mechanics, applied to the network.cosmik.* lexicons. 4 + --- 5 + 6 + cosmik is your public memory layer — bookmarks, notes, and typed connections, indexed by [semble](https://semble.so) and discoverable via `search_network`. records live on your PDS under `network.cosmik.*`. this skill covers the three you'll actually write. 7 + 8 + read `pdsx-fundamentals` first if you haven't — this skill assumes you understand `mcp__pdsx__create_record` and the consent layer. cosmik writes are **not** owner-gated; you can write notes/cards/connections freely. they're public, but they're yours. 9 + 10 + ## what's in this namespace 11 + 12 + | record type | purpose | resource file | 13 + |---|---|---| 14 + | `network.cosmik.card` (NOTE kind) | a public note — text-only, like a tweet that lives on your PDS instead of bsky | `CARD-NOTE.md` | 15 + | `network.cosmik.card` (URL kind) | a bookmark — a URL with title/description metadata | `CARD-URL.md` | 16 + | `network.cosmik.connection` | a typed link between two cards (e.g. SUPPORTS, CONTRADICTS) | `CONNECTION.md` | 17 + 18 + cards and connections together form a directed graph. semble indexes both. 19 + 20 + ## when to use which 21 + 22 + - **NOTE** when the value is the *text*. a thought you want public but doesn't fit a bsky post. a synthesis of something you've been thinking about. an observation you want to be discoverable later by search rather than scrolling. 23 + - **URL** when the value is *what someone else made*. an article, a paper, a thread, a leaflet doc. include a short description in your own words for why it's worth reading. 24 + - **CONNECTION** when two cards relate in a way that's worth marking explicitly — "this paper supports that argument," "this URL contradicts that earlier note." use after creating both endpoints; you need their AT-URIs. 25 + 26 + a heuristic: if you'd want to post it on bsky, post it on bsky. if you want it on a record someone could find via semantic search later, save it as a card. 27 + 28 + ## minimum example: writing a NOTE 29 + 30 + ``` 31 + mcp__pdsx__create_record( 32 + collection="network.cosmik.card", 33 + record={ 34 + "kind": "NOTE", 35 + "content": {"text": "..."} 36 + } 37 + ) 38 + ``` 39 + 40 + returns `{"uri": "at://did:plc:.../network.cosmik.card/3xxxxx", "cid": "..."}`. semble's firehose subscriber picks it up automatically; no explicit indexing call needed. 41 + 42 + for full schemas and richer examples, read the per-record-type resource files (`CARD-NOTE.md`, `CARD-URL.md`, `CONNECTION.md`). 43 + 44 + ## what to avoid 45 + 46 + - duplicate cards. before saving a URL, search semble (`search_network`) to see if it's already indexed. 47 + - empty or vague descriptions on URL cards. "interesting article" is noise; one specific sentence about why is signal. 48 + - connections without a clear semantic — if the relationship is just "i thought of these together," that's weaker than the cards' co-occurrence in semantic search will already capture. 49 + 50 + ## related 51 + 52 + - `pdsx-fundamentals` — the underlying CRUD mechanics 53 + - `search_network` (registered tool) — query semble for what's already there
+76
skills/pdsx-fundamentals/SKILL.md
··· 1 + --- 2 + name: pdsx-fundamentals 3 + description: How to use the pdsx MCP for atproto record CRUD on arbitrary lexicons. Load this when you want to do something on atproto that doesn't have a dedicated tool — saving to a custom lexicon, opening a tangled issue, writing a leaflet comment, etc. 4 + --- 5 + 6 + pdsx is a generic atproto MCP. it lets you do CRUD on any lexicon as long as you (a) know the NSID and (b) construct a record matching that lexicon's schema. that means you can interact with anything on atproto — tangled issues, leaflet documents, cosmik cards, calendar events — without anyone writing a per-collection tool first. 7 + 8 + ## the operations 9 + 10 + | call | use for | 11 + |---|---| 12 + | `mcp__pdsx__describe_repo(repo)` | list every collection a given repo has records in | 13 + | `mcp__pdsx__list_records(collection, repo, limit, cursor)` | paginate records in a collection | 14 + | `mcp__pdsx__get_record(uri)` | fetch one record by AT-URI | 15 + | `mcp__pdsx__create_record(collection, record, rkey?)` | write a new record on **your** PDS | 16 + | `mcp__pdsx__update_record(uri, record)` | replace an existing record's value | 17 + | `mcp__pdsx__delete_record(uri)` | delete a record from your PDS | 18 + | `mcp__pdsx__whoami()` | confirm which DID/handle pdsx is authed as | 19 + 20 + `create_record`, `update_record`, `delete_record` always write to **the authenticated repo** — that's you (`@phi.zzstoatzz.io`). you cannot write records into someone else's repo. you can read from any repo. 21 + 22 + ## finding the right lexicon 23 + 24 + three ways, in order of effort: 25 + 26 + 1. **you already know the NSID.** common ones: `app.bsky.feed.post`, `network.cosmik.card`, `sh.tangled.repo.issue`, `pub.leaflet.document`, `io.zzstoatzz.phi.observation`. just call `create_record` with that collection. 27 + 28 + 2. **you know a repo that uses it.** call `mcp__pdsx__describe_repo(repo="zzstoatzz.io")` to see every collection that repo has records in — you'll often spot the lexicon you want by name. 29 + 30 + 3. **you want to read the schema before writing.** lexicon schemas themselves are stored as records under `com.atproto.lexicon.schema/{nsid}` on the lexicon owner's PDS. for `sh.tangled.repo.issue`, that means `mcp__pdsx__get_record(uri="at://did:plc:tangled-owner/com.atproto.lexicon.schema/sh.tangled.repo.issue")`. read the schema, see required fields, then construct. 31 + 32 + ## constructing a record 33 + 34 + every record needs a `$type` field equal to the NSID, plus whatever the lexicon requires. pdsx auto-injects `$type` and `createdAt` if they're missing — but it doesn't validate the rest of your record. if you send a malformed record, the PDS rejects it with an XRPC error and you'll see the field name in the error message. 35 + 36 + minimum example (creating a note on cosmik): 37 + 38 + ``` 39 + mcp__pdsx__create_record( 40 + collection="network.cosmik.card", 41 + record={ 42 + "kind": "NOTE", 43 + "content": {"text": "the engram architecture re-derivation is structural convergence, not coincidence."} 44 + } 45 + ) 46 + ``` 47 + 48 + result: `{"uri": "at://did:plc:.../network.cosmik.card/3xxxxx", "cid": "..."}` 49 + 50 + if you want a specific rkey (e.g. for `app.bsky.actor.profile/self`), pass `rkey="self"`. otherwise pdsx generates a TID. 51 + 52 + ## the consent / posting layer 53 + 54 + pdsx will happily let you create `app.bsky.feed.post` records — but **don't post via pdsx**. the trusted posting tools (`reply_to`, `like_post`, `repost_post`, `post`) handle mention-consent allowlisting, reply-ref construction, grapheme splitting, and memory writes. raw pdsx posting bypasses all of that. use it for everything *except* posts. 55 + 56 + ## owner-gating for durable public actions 57 + 58 + some record types are durable, public, and visible (opening an issue against someone else's repo, vouching for a maintainer, following an account, mutating a goal record). these go through the like-as-approval pattern: 59 + 60 + 1. you post a request: `"@operator, like this to authorize: i want to <do thing>"` 61 + 2. operator likes the post 62 + 3. on the next batch where their like lands, the action is authorized 63 + 4. you execute exactly the action you described — never something adjacent that happened to ride the same batch 64 + 65 + write the request post with `post` (operator-handle is on the mention-consent allowlist, so they get notified). then do nothing until you see the like in the next notifications batch. one approval = one specific action. 66 + 67 + ## domain-specific guidance 68 + 69 + for record types you write often, there's usually a more specific skill that walks through the schema and includes worked examples: 70 + 71 + - **cosmik writes** (notes, urls, connections): see `cosmik-records` 72 + - **tangled records** (issues, PRs, follows, vouches): planned 73 + - **leaflet records** (documents, comments): planned 74 + - **phi self-records** (goals, observations, mention-consent): planned 75 + 76 + if no domain skill exists yet for what you want to do, you have everything you need above — find the lexicon, read its schema, construct a record, call `create_record`.
+1 -1
skills/publish-blog/SKILL.md
··· 1 1 --- 2 2 name: publish-blog 3 - description: Publish a long-form post on greengale.app. Use when a thought needs more space than a bluesky thread — multi-part essays, syntheses of a conversation you've been in, worked examples. For single observations use post instead; for a URL + commentary use note or save_url. 3 + description: Publish a long-form post on greengale.app. Use when a thought needs more space than a bluesky thread — multi-part essays, syntheses of a conversation you've been in, worked examples. For single observations use post; for a URL or note as public memory load the cosmik-records skill; for private notes to your future self use the note tool. 4 4 --- 5 5 6 6 ## structure that's worked
+51 -5
src/bot/agent.py
··· 12 12 13 13 from bot.config import settings 14 14 from bot.core.atproto_client import bot_client, get_identity_block 15 + from bot.core.cosmik import create_cosmik_record 15 16 from bot.core.discovery_pool import get_discovery_pool_block 16 17 from bot.core.goals import list_goals as list_goal_records 17 18 from bot.core.graze_client import GrazeClient ··· 27 28 from bot.status import bot_status 28 29 from bot.tools import PhiDeps, _check_services_impl, register_all 29 30 from bot.tools.bluesky import fetch_relay_names 31 + from bot.types import CosmikNoteCard, NoteContent 30 32 from bot.utils.time import humanize_duration, relative_when 31 33 32 34 logger = logging.getLogger("bot.agent") ··· 373 375 nc = len(cols.records) if cols.records else 0 374 376 nk = len(cards.records) if cards.records else 0 375 377 if nc or nk: 376 - return f"[SEMBLE]: you have {nc} public collections and {nk} cards on semble. use search_network to browse, save_url/create_connection to add." 378 + return f"[SEMBLE]: {nc} public collections, {nk} cards." 377 379 except Exception as e: 378 380 logger.debug(f"failed to fetch cosmik counts: {e}") 379 381 return "" ··· 403 405 "phi agent initialized with pdsx, pub-search, and prefect MCP tools" 404 406 ) 405 407 408 + def get_capabilities(self) -> list[dict]: 409 + """Plain-data introspection of phi's registered function-tools. 410 + 411 + Reads from `self.agent._function_toolset.tools` (where pydantic-ai 412 + stores the registered `@agent.tool` callables). Returns one entry 413 + per tool with: 414 + - name: the registered tool name 415 + - description: the tool's docstring (what gets sent to the LLM) 416 + - operator_only: heuristic — true if the tool is gated to the 417 + bot's owner. Detected via either an `_is_owner(` source-call 418 + or owner-restriction phrasing in the docstring. When an 419 + explicit owner-gating attribute lands on `Tool`, swap this 420 + heuristic for a direct read. 421 + 422 + Surfaced via /api/abilities so the cockpit UI can render real 423 + names + real docstrings instead of inventing them. 424 + """ 425 + import inspect 426 + 427 + tools = self.agent._function_toolset.tools 428 + out: list[dict] = [] 429 + for name in sorted(tools.keys()): 430 + t = tools[name] 431 + try: 432 + src = inspect.getsource(t.function) 433 + except (OSError, TypeError): 434 + src = "" 435 + doc = (t.description or "").strip() 436 + doc_lower = doc.lower() 437 + operator_only = "_is_owner(" in src or any( 438 + marker in doc_lower 439 + for marker in ( 440 + "owner-only", 441 + "only the bot's owner", 442 + "operator-only", 443 + "only @", 444 + ) 445 + ) 446 + out.append( 447 + { 448 + "name": name, 449 + "description": doc, 450 + "operator_only": operator_only, 451 + } 452 + ) 453 + return out 454 + 406 455 def _mcp_toolsets(self) -> list[MCPServerStreamableHTTP]: 407 456 """Create fresh MCP server instances for a single agent run.""" 408 457 toolsets: list[MCPServerStreamableHTTP] = [ ··· 789 838 790 839 elif decision.action == "promote" and decision.card_title: 791 840 try: 792 - from bot.tools._helpers import _create_cosmik_record 793 - from bot.types import CosmikNoteCard, NoteContent 794 - 795 841 card = CosmikNoteCard( 796 842 content=NoteContent( 797 843 text=decision.card_description or obs["content"] 798 844 ) 799 845 ) 800 - uri = await _create_cosmik_record( 846 + uri = await create_cosmik_record( 801 847 "network.cosmik.card", card.to_record() 802 848 ) 803 849 promoted += 1
+24
src/bot/core/cosmik.py
··· 1 + """Internal helper for writing cosmik records from pipeline code. 2 + 3 + Phi-driven cosmik writes go through the `cosmik-records` skill — phi 4 + loads it on demand and uses pdsx for record CRUD. This module exists for 5 + the *internal* pipeline that promotes phi's own observations to public 6 + cards (see `agent.py:process_review`), where there's no agent run to 7 + load a skill — we just write the record directly. 8 + """ 9 + 10 + from bot.core.atproto_client import bot_client 11 + 12 + 13 + async def create_cosmik_record(collection: str, record: dict) -> str: 14 + """Write a cosmik record to phi's PDS. Returns the AT URI.""" 15 + await bot_client.authenticate() 16 + assert bot_client.client.me is not None 17 + result = bot_client.client.com.atproto.repo.create_record( 18 + data={ 19 + "repo": bot_client.client.me.did, 20 + "collection": collection, 21 + "record": record, 22 + } 23 + ) 24 + return result.uri
+27
src/bot/main.py
··· 183 183 return {"triggered": True} 184 184 185 185 186 + _abilities_cache: list | None = None 187 + 188 + 189 + @app.get("/api/abilities") 190 + async def abilities(): 191 + """Phi's currently-registered function-tools — name, docstring, and 192 + whether owner-gated. Pulled live from `PhiAgent.get_capabilities()`, 193 + which introspects `agent._function_toolset.tools`. 194 + 195 + Cached for the process lifetime: tools are registered at startup and 196 + don't change without a restart, so re-introspecting per request is 197 + pointless work. 198 + """ 199 + global _abilities_cache 200 + if _abilities_cache is not None: 201 + return JSONResponse(_abilities_cache) 202 + poller = getattr(app.state, "poller", None) 203 + if poller is None: 204 + return JSONResponse({"error": "agent not ready"}, status_code=503) 205 + try: 206 + _abilities_cache = poller.handler.agent.get_capabilities() 207 + return JSONResponse(_abilities_cache) 208 + except Exception as e: 209 + logger.warning(f"abilities introspection failed: {e}") 210 + return JSONResponse({"error": str(e)}, status_code=500) 211 + 212 + 186 213 _discovery_cache_data: list | None = None 187 214 _discovery_cache_expires: float = 0.0 188 215 _DISCOVERY_CACHE_TTL = 60 # seconds
-2
src/bot/tools/__init__.py
··· 9 9 from bot.tools import ( 10 10 blog, 11 11 bluesky, 12 - cosmik, 13 12 feeds, 14 13 goals, 15 14 memory, ··· 20 19 21 20 memory.register(agent) 22 21 search.register(agent) 23 - cosmik.register(agent) 24 22 feeds.register(agent, graze_client) 25 23 bluesky.register(agent) 26 24 blog.register(agent)
-18
src/bot/tools/_helpers.py
··· 8 8 from pydantic_ai import RunContext 9 9 10 10 from bot.config import settings 11 - from bot.core.atproto_client import bot_client 12 11 from bot.memory import NamespaceMemory 13 12 14 13 logger = logging.getLogger("bot.tools") ··· 162 161 else: 163 162 parts.append(f"[note]{tag_str}{date_str} {content}") 164 163 return parts 165 - 166 - 167 - # --- record creation --- 168 - 169 - 170 - async def _create_cosmik_record(collection: str, record: dict) -> str: 171 - """Write a cosmik record to phi's PDS. Returns the AT URI.""" 172 - await bot_client.authenticate() 173 - assert bot_client.client.me is not None 174 - result = bot_client.client.com.atproto.repo.create_record( 175 - data={ 176 - "repo": bot_client.client.me.did, 177 - "collection": collection, 178 - "record": record, 179 - } 180 - ) 181 - return result.uri 182 164 183 165 184 166 # --- infrastructure ---
-59
src/bot/tools/cosmik.py
··· 1 - """Cosmik record tools — URL cards and connections.""" 2 - 3 - from pydantic_ai import RunContext 4 - 5 - from bot.tools._helpers import PhiDeps, _create_cosmik_record 6 - from bot.types import CosmikConnection, CosmikUrlCard, UrlContent 7 - 8 - 9 - def register(agent): 10 - @agent.tool 11 - async def save_url( 12 - ctx: RunContext[PhiDeps], 13 - url: str, 14 - title: str, 15 - description: str | None = None, 16 - ) -> str: 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. 19 - The card is public — find it later via search_network.""" 20 - try: 21 - card = CosmikUrlCard( 22 - content=UrlContent(url=url, title=title, description=description) 23 - ) 24 - except Exception as e: 25 - return f"validation failed: {e}" 26 - 27 - try: 28 - uri = await _create_cosmik_record("network.cosmik.card", card.to_record()) 29 - return f"card created: {uri} (public — search via search_network)" 30 - except Exception as e: 31 - return f"failed to create card: {e}" 32 - 33 - @agent.tool 34 - async def create_connection( 35 - ctx: RunContext[PhiDeps], 36 - source: str, 37 - target: str, 38 - connection_type: str | None = None, 39 - note: str | None = None, 40 - ) -> str: 41 - """Create a network.cosmik.connection record — a semantic link between two entities. 42 - Source and target must be URLs or at:// URIs. Connection types: related, supports, opposes, addresses, helpful, explainer, leads_to, supplements.""" 43 - try: 44 - conn = CosmikConnection( 45 - source=source, 46 - target=target, 47 - connection_type=connection_type, 48 - note=note, 49 - ) 50 - except Exception as e: 51 - return f"validation failed: {e}" 52 - 53 - try: 54 - uri = await _create_cosmik_record( 55 - "network.cosmik.connection", conn.to_record() 56 - ) 57 - return f"connection created: {uri}" 58 - except Exception as e: 59 - return f"failed to create connection: {e}"
+373 -71
web/src/app.css
··· 1 - /* base styles — dark, github-ish, matching the existing site palette */ 1 + /* 2 + * phi cockpit — metroid prime 1 inspired hud 3 + * 4 + * palette: deep blue-black ambient, warm clay-orange chrome (the "phi color"), 5 + * muted scan-cyan for data readouts, amber for warnings. heavy desaturation — 6 + * this is an instrument, not a brochure. 7 + * 8 + * type: tall narrow geometric for chrome (uppercase tracked), inter for content, 9 + * jetbrains mono for ids/timestamps. 10 + */ 11 + 12 + @import url('https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@300;400;500;600&family=Inter:wght@300;400;500&family=JetBrains+Mono:wght@300;400&display=swap'); 2 13 3 14 :root { 4 - --bg: #0d1117; 5 - --bg-elev: #161b22; 6 - --border: #30363d; 7 - --border-dim: #21262d; 8 - --text: #c9d1d9; 9 - --text-muted: #8b949e; 10 - --text-faint: #484f58; 11 - --accent-blue: #58a6ff; 12 - --accent-green: #2ea043; 13 - --accent-red: #da3633; 14 - --accent-purple: #a371f7; 15 - --accent-yellow: #d29922; 15 + /* ambient — never pure black, slight blue */ 16 + --bg-void: #07090f; 17 + --bg-deep: #0d1119; 18 + --bg-elev: #141a26; 19 + --bg-panel: rgba(20, 26, 38, 0.85); 20 + 21 + /* hud — warm clay orange, the phi color */ 22 + --hud-dim: #4d2c14; 23 + --hud-mid: #b86b3a; 24 + --hud-hot: #e09060; 25 + --hud-glow: rgba(184, 107, 58, 0.35); 26 + 27 + /* scan visor — muted cyan/teal for data */ 28 + --scan-dim: #2a4a55; 29 + --scan-mid: #4a8b9a; 30 + --scan-hot: #7ec0d4; 31 + 32 + /* warn amber — sparingly */ 33 + --warn: #c9a05a; 34 + --warn-hot: #e0bb6a; 35 + 36 + /* danger red — only for actual problems */ 37 + --danger: #a04848; 38 + 39 + /* text */ 40 + --text: #d6d2c9; 41 + --text-mid: #8c8579; 42 + --text-dim: #5a554a; 43 + 44 + /* lines — orange at low/med alpha */ 45 + /* --grid: very faint, only for background scaffolding (concentric circles, crosshairs) */ 46 + /* --line-dim: data edges (graph connections, time-trails) — must be readable */ 47 + /* --line-mid: panel borders */ 48 + /* --line-hot: active/hovered borders */ 49 + --grid: rgba(184, 107, 58, 0.05); 50 + --line-dim: rgba(184, 107, 58, 0.22); 51 + --line-mid: rgba(184, 107, 58, 0.32); 52 + --line-hot: rgba(184, 107, 58, 0.6); 53 + --line-scan: rgba(74, 139, 154, 0.3); 54 + 55 + /* chrome typefaces */ 56 + --font-chrome: 'Saira Condensed', 'Barlow Condensed', system-ui, sans-serif; 57 + --font-content: 'Inter', system-ui, sans-serif; 58 + --font-mono: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', Menlo, monospace; 16 59 } 17 60 18 61 * { ··· 23 66 body { 24 67 margin: 0; 25 68 padding: 0; 26 - background: var(--bg); 69 + background: var(--bg-void); 27 70 color: var(--text); 28 - font-family: 29 - -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; 30 - font-size: 14px; 71 + font-family: var(--font-content); 72 + font-size: 13px; 31 73 line-height: 1.5; 74 + -webkit-font-smoothing: antialiased; 75 + -moz-osx-font-smoothing: grayscale; 76 + overflow: hidden; 77 + height: 100vh; 78 + } 79 + 80 + #svelte, 81 + body > div { 82 + height: 100%; 83 + } 84 + 85 + /* ambient hexagonal grid + scanlines + vignette layered on the void */ 86 + body::before { 87 + content: ''; 88 + position: fixed; 89 + inset: 0; 90 + pointer-events: none; 91 + z-index: 0; 92 + background: 93 + /* very faint hex pattern — chozo terminal feel */ 94 + url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='56' height='64' viewBox='0 0 56 64'><g fill='none' stroke='%23b86b3a' stroke-width='0.6' opacity='0.08'><polygon points='14,2 42,2 56,18 42,34 14,34 0,18'/><polygon points='14,34 42,34 56,50 42,66 14,66 0,50'/></g></svg>"), 95 + /* horizontal scanlines */ 96 + repeating-linear-gradient( 97 + 0deg, 98 + rgba(255, 255, 255, 0.012) 0px, 99 + rgba(255, 255, 255, 0.012) 1px, 100 + transparent 1px, 101 + transparent 3px 102 + ); 103 + background-size: 104 + 56px 64px, 105 + auto; 106 + } 107 + 108 + /* radial vignette */ 109 + body::after { 110 + content: ''; 111 + position: fixed; 112 + inset: 0; 113 + pointer-events: none; 114 + z-index: 0; 115 + background: radial-gradient( 116 + ellipse at center, 117 + transparent 0%, 118 + transparent 55%, 119 + rgba(0, 0, 0, 0.35) 100% 120 + ); 121 + } 122 + 123 + /* ---------- typography ---------- */ 124 + 125 + .chrome { 126 + font-family: var(--font-chrome); 127 + text-transform: uppercase; 128 + letter-spacing: 0.08em; 129 + font-weight: 400; 130 + } 131 + 132 + .mono { 133 + font-family: var(--font-mono); 134 + font-size: 0.92em; 32 135 } 33 136 34 137 a { 35 - color: var(--accent-blue); 138 + color: var(--scan-mid); 36 139 text-decoration: none; 37 140 } 38 141 39 142 a:hover { 40 - text-decoration: underline; 143 + color: var(--scan-hot); 41 144 } 42 145 43 - code, 44 - .mono { 45 - font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, monospace; 46 - font-size: 0.92em; 146 + /* ---------- cockpit layout primitives ---------- */ 147 + 148 + .cockpit { 149 + position: fixed; 150 + inset: 0; 151 + display: grid; 152 + grid-template-rows: 1fr; 153 + z-index: 1; 47 154 } 48 155 49 - h1, 50 - h2, 51 - h3 { 52 - font-weight: 400; 53 - margin: 0 0 12px 0; 156 + .viewport { 157 + position: relative; 158 + width: 100%; 159 + height: 100%; 160 + overflow: hidden; 54 161 } 55 162 56 - h1 { 57 - font-size: 28px; 163 + /* hud overlays — fixed, on top of viewport */ 164 + .hud { 165 + position: fixed; 166 + z-index: 10; 167 + pointer-events: none; 58 168 } 59 - h2 { 60 - font-size: 20px; 61 - color: var(--text-muted); 169 + 170 + .hud > * { 171 + pointer-events: auto; 62 172 } 63 - h3 { 64 - font-size: 15px; 65 - color: var(--text-muted); 173 + 174 + .hud-tl { 175 + top: 18px; 176 + left: 22px; 66 177 } 67 178 68 - .container { 69 - max-width: 760px; 70 - margin: 0 auto; 71 - padding: 24px 20px 80px; 179 + .hud-tr { 180 + top: 18px; 181 + right: 22px; 72 182 } 73 183 74 - .muted { 75 - color: var(--text-muted); 184 + .hud-bl { 185 + bottom: 16px; 186 + left: 22px; 76 187 } 77 188 78 - .faint { 79 - color: var(--text-faint); 189 + .hud-br { 190 + bottom: 16px; 191 + right: 22px; 80 192 } 81 193 82 - .card { 83 - background: var(--bg-elev); 84 - border-radius: 8px; 85 - padding: 14px 16px; 86 - border-left: 3px solid var(--border); 87 - margin-bottom: 10px; 194 + /* ---------- mobile: corner-hud collapses into header + footer strips ---------- */ 195 + 196 + @media (max-width: 640px) { 197 + .hud-tl { 198 + top: 0; 199 + left: 0; 200 + right: 0; 201 + padding: 8px 10px; 202 + background: linear-gradient(to bottom, var(--bg-deep) 0%, transparent 100%); 203 + border-bottom: 1px solid var(--line-dim); 204 + } 205 + 206 + .hud-tr { 207 + top: 8px; 208 + right: 8px; 209 + } 210 + 211 + .hud-bl { 212 + bottom: 0; 213 + left: 0; 214 + right: 0; 215 + padding: 6px 10px; 216 + background: linear-gradient(to top, var(--bg-deep) 0%, transparent 100%); 217 + border-top: 1px solid var(--line-dim); 218 + } 219 + 220 + .hud-br { 221 + display: none; 222 + } 88 223 } 89 224 90 - .card pre, 91 - .card .body { 92 - white-space: pre-wrap; 93 - word-break: break-word; 225 + /* ---------- chozo-terminal frame (corner brackets, hud-style) ---------- */ 226 + /* 227 + * .frame wraps any block in 4 small L-shaped corner brackets that suggest 228 + * a mounted hud panel. drop on cards, drawers, hero strips, etc. 229 + */ 230 + 231 + .frame { 232 + position: relative; 233 + } 234 + 235 + .frame::before, 236 + .frame::after, 237 + .frame > .frame-c1, 238 + .frame > .frame-c2 { 239 + content: ''; 240 + position: absolute; 241 + width: 10px; 242 + height: 10px; 243 + border-color: var(--hud-mid); 244 + border-style: solid; 245 + border-width: 0; 246 + pointer-events: none; 247 + } 248 + 249 + .frame::before { 250 + top: -1px; 251 + left: -1px; 252 + border-top-width: 1px; 253 + border-left-width: 1px; 254 + } 255 + 256 + .frame::after { 257 + top: -1px; 258 + right: -1px; 259 + border-top-width: 1px; 260 + border-right-width: 1px; 261 + } 262 + 263 + .frame > .frame-c1 { 264 + bottom: -1px; 265 + left: -1px; 266 + border-bottom-width: 1px; 267 + border-left-width: 1px; 94 268 } 95 269 96 - .kv { 97 - display: flex; 98 - gap: 12px; 99 - flex-wrap: wrap; 100 - font-size: 13px; 101 - color: var(--text-muted); 270 + .frame > .frame-c2 { 271 + bottom: -1px; 272 + right: -1px; 273 + border-bottom-width: 1px; 274 + border-right-width: 1px; 275 + } 276 + 277 + /* ---------- diagonal-cut panel (chozo terminal aesthetic) ---------- */ 278 + /* 279 + * snipped corners on opposing sides — the MP1 hud panel signature. 280 + * use .cut for a 10px snip on top-left and bottom-right. 281 + */ 282 + 283 + .cut { 284 + clip-path: polygon( 285 + 10px 0, 286 + 100% 0, 287 + 100% calc(100% - 10px), 288 + calc(100% - 10px) 100%, 289 + 0 100%, 290 + 0 10px 291 + ); 102 292 } 103 293 104 - .kv > div > span:first-child { 105 - display: block; 106 - color: var(--text); 107 - font-size: 16px; 108 - margin-bottom: 2px; 294 + /* ---------- segmented divider — that hud-readout dashed line ---------- */ 295 + 296 + .seg-divider { 297 + height: 1px; 298 + background: repeating-linear-gradient( 299 + 90deg, 300 + var(--line-mid) 0px, 301 + var(--line-mid) 6px, 302 + transparent 6px, 303 + transparent 10px 304 + ); 305 + margin: 12px 0; 109 306 } 110 307 308 + /* ---------- hexagonal bullet ---------- */ 309 + 310 + .hex { 311 + display: inline-block; 312 + width: 7px; 313 + height: 8px; 314 + background: currentColor; 315 + clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); 316 + flex-shrink: 0; 317 + } 318 + 319 + .hex.ring { 320 + background: transparent; 321 + box-shadow: inset 0 0 0 1px currentColor; 322 + } 323 + 324 + /* ---------- panel ---------- */ 325 + 326 + .panel { 327 + background: var(--bg-panel); 328 + border: 1px solid var(--line-mid); 329 + backdrop-filter: blur(8px); 330 + -webkit-backdrop-filter: blur(8px); 331 + padding: 12px 14px; 332 + } 333 + 334 + /* ---------- buttons ---------- */ 335 + 111 336 button { 112 - background: var(--bg-elev); 337 + background: transparent; 113 338 color: var(--text); 114 - border: 1px solid var(--border); 115 - border-radius: 6px; 339 + border: 1px solid var(--line-mid); 116 340 padding: 6px 12px; 117 - font-size: 13px; 341 + font-family: var(--font-chrome); 342 + font-size: 11px; 343 + text-transform: uppercase; 344 + letter-spacing: 0.08em; 118 345 cursor: pointer; 119 - font-family: inherit; 346 + transition: 347 + color 0.12s, 348 + border-color 0.12s, 349 + background 0.12s; 120 350 } 121 351 122 352 button:hover { 123 - background: #1c2128; 353 + color: var(--hud-hot); 354 + border-color: var(--hud-mid); 355 + background: rgba(184, 107, 58, 0.05); 356 + } 357 + 358 + button.active { 359 + color: var(--hud-hot); 360 + border-color: var(--hud-mid); 361 + background: rgba(184, 107, 58, 0.1); 362 + } 363 + 364 + /* ---------- pulse animation ---------- */ 365 + 366 + @keyframes pulse { 367 + 0%, 368 + 100% { 369 + opacity: 0.55; 370 + filter: drop-shadow(0 0 0 currentColor); 371 + } 372 + 50% { 373 + opacity: 1; 374 + filter: drop-shadow(0 0 4px currentColor); 375 + } 376 + } 377 + 378 + .pulse { 379 + animation: pulse 2.4s ease-in-out infinite; 380 + } 381 + 382 + /* ---------- utility text ---------- */ 383 + 384 + .muted { 385 + color: var(--text-mid); 386 + } 387 + 388 + .faint { 389 + color: var(--text-dim); 390 + } 391 + 392 + .hot { 393 + color: var(--hud-hot); 394 + } 395 + 396 + .scan { 397 + color: var(--scan-mid); 398 + } 399 + 400 + /* ---------- scrollable surfaces ---------- */ 401 + 402 + .scroll { 403 + overflow-y: auto; 404 + scrollbar-width: thin; 405 + scrollbar-color: var(--line-mid) transparent; 406 + } 407 + 408 + .scroll::-webkit-scrollbar { 409 + width: 6px; 410 + } 411 + 412 + .scroll::-webkit-scrollbar-track { 413 + background: transparent; 414 + } 415 + 416 + .scroll::-webkit-scrollbar-thumb { 417 + background: var(--line-mid); 418 + border-radius: 3px; 419 + } 420 + 421 + /* ---------- focus ring ---------- */ 422 + 423 + *:focus-visible { 424 + outline: 1px solid var(--hud-mid); 425 + outline-offset: 2px; 124 426 }
+55
web/src/lib/abilities.ts
··· 1 + /** 2 + * The capabilities phi currently has registered. 3 + * 4 + * Just the names. Hand-synced from `bot/src/bot/tools/*.py`. Once the bot 5 + * exposes `/api/abilities`, this is replaced by a fetch — meaningful 6 + * descriptions, real grouping, and the operator-only flag arrive then. 7 + * 8 + * No invented categories. No source-file leakage. The names are what they 9 + * are. 10 + * 11 + * Skills are a different surface (load-on-demand SKILL.md packs) and live 12 + * elsewhere — they don't belong here. 13 + */ 14 + 15 + export const CAPABILITIES: string[] = [ 16 + 'changelog', 17 + 'check_relays', 18 + 'check_services', 19 + 'check_urls', 20 + 'create_feed', 21 + 'delete_feed', 22 + 'drop_observation', 23 + 'follow_user', 24 + 'get_own_posts', 25 + 'get_trending', 26 + 'like_post', 27 + 'list_blog_posts', 28 + 'list_feeds', 29 + 'list_goals', 30 + 'manage_labels', 31 + 'manage_mentionable', 32 + 'note', 33 + 'observe', 34 + 'post', 35 + 'propose_goal_change', 36 + 'publish_blog_post', 37 + 'read_feed', 38 + 'read_timeline', 39 + 'recall', 40 + 'reply_to', 41 + 'repost_post', 42 + 'search_network', 43 + 'search_posts', 44 + 'web_search' 45 + ]; 46 + 47 + /** 48 + * Operator-gated capabilities — only nate (the bot's owner) can invoke 49 + * these. This is a real distinction in the source (the `_is_owner` check), 50 + * not invented. 51 + */ 52 + export const OPERATOR_ONLY: ReadonlySet<string> = new Set([ 53 + 'manage_labels', 54 + 'manage_mentionable' 55 + ]);
+7
web/src/lib/api.ts
··· 7 7 ActivityItem, 8 8 BlogDoc, 9 9 BskyFeedItem, 10 + Capability, 10 11 DiscoveryEntry, 11 12 Goal, 12 13 GraphData, ··· 125 126 export async function getHealth(): Promise<HealthInfo> { 126 127 const res = await fetch('/health'); 127 128 if (!res.ok) throw new Error(`health: ${res.status}`); 129 + return await res.json(); 130 + } 131 + 132 + export async function getCapabilities(): Promise<Capability[]> { 133 + const res = await fetch('/api/abilities'); 134 + if (!res.ok) throw new Error(`abilities: ${res.status}`); 128 135 return await res.json(); 129 136 } 130 137
+127
web/src/lib/clients.ts
··· 1 + /** 2 + * Per-record-kind viewer registry. Inspired by pdsls's uriTemplates 3 + * (https://tangled.org/pds.ls/pdsls — credit there). 4 + * 5 + * For each kind there's a list of viewers (clients) that can render the 6 + * record. The first entry is the default; user choice is persisted to 7 + * localStorage and then becomes the default thereafter. 8 + * 9 + * pdsls is included in every list because it's the only viewer that 10 + * works for every NSID. 11 + */ 12 + 13 + import { PHI_DID } from './api'; 14 + 15 + export type ViewKind = 'profile' | 'post' | 'blog' | 'record'; 16 + 17 + export interface Viewer { 18 + id: string; 19 + label: string; 20 + domain: string; // for favicon resolution 21 + url: (args: { handle: string; did?: string; collection?: string; rkey?: string }) => string; 22 + } 23 + 24 + const PDSLS: Viewer = { 25 + id: 'pdsls', 26 + label: 'pdsls', 27 + domain: 'pdsls.dev', 28 + url: ({ handle, did, collection, rkey }) => { 29 + const subject = handle || did || PHI_DID; 30 + if (collection && rkey) return `https://pdsls.dev/at/${subject}/${collection}/${rkey}`; 31 + if (collection) return `https://pdsls.dev/at/${subject}/${collection}`; 32 + return `https://pdsls.dev/at/${subject}`; 33 + } 34 + }; 35 + 36 + const BSKY_PROFILE_VIEWERS: Viewer[] = [ 37 + { 38 + id: 'bsky', 39 + label: 'bsky', 40 + domain: 'bsky.app', 41 + url: ({ handle }) => `https://bsky.app/profile/${handle}` 42 + }, 43 + { 44 + id: 'deer', 45 + label: 'deer', 46 + domain: 'deer.social', 47 + url: ({ handle }) => `https://deer.social/profile/${handle}` 48 + }, 49 + { 50 + id: 'blacksky', 51 + label: 'blacksky', 52 + domain: 'blacksky.community', 53 + url: ({ handle }) => `https://blacksky.community/profile/${handle}` 54 + }, 55 + { 56 + id: 'witchsky', 57 + label: 'witchsky', 58 + domain: 'witchsky.app', 59 + url: ({ handle }) => `https://witchsky.app/profile/${handle}` 60 + }, 61 + PDSLS 62 + ]; 63 + 64 + const BSKY_POST_VIEWERS: Viewer[] = [ 65 + { 66 + id: 'bsky', 67 + label: 'bsky', 68 + domain: 'bsky.app', 69 + url: ({ handle, rkey }) => `https://bsky.app/profile/${handle}/post/${rkey}` 70 + }, 71 + { 72 + id: 'deer', 73 + label: 'deer', 74 + domain: 'deer.social', 75 + url: ({ handle, rkey }) => `https://deer.social/profile/${handle}/post/${rkey}` 76 + }, 77 + PDSLS 78 + ]; 79 + 80 + const BLOG_VIEWERS: Viewer[] = [ 81 + { 82 + id: 'greengale', 83 + label: 'greengale', 84 + domain: 'greengale.app', 85 + url: ({ handle, rkey }) => `https://greengale.app/${handle}/${rkey}` 86 + }, 87 + PDSLS 88 + ]; 89 + 90 + const RECORD_VIEWERS: Viewer[] = [PDSLS]; 91 + 92 + export const VIEWERS_BY_KIND: Record<ViewKind, Viewer[]> = { 93 + profile: BSKY_PROFILE_VIEWERS, 94 + post: BSKY_POST_VIEWERS, 95 + blog: BLOG_VIEWERS, 96 + record: RECORD_VIEWERS 97 + }; 98 + 99 + const STORAGE_KEY_PREFIX = 'phi.viewer.'; 100 + 101 + export function getStoredViewer(kind: ViewKind): string | null { 102 + if (typeof localStorage === 'undefined') return null; 103 + try { 104 + return localStorage.getItem(STORAGE_KEY_PREFIX + kind); 105 + } catch { 106 + return null; 107 + } 108 + } 109 + 110 + export function setStoredViewer(kind: ViewKind, id: string) { 111 + if (typeof localStorage === 'undefined') return; 112 + try { 113 + localStorage.setItem(STORAGE_KEY_PREFIX + kind, id); 114 + } catch { 115 + /* private mode etc */ 116 + } 117 + } 118 + 119 + export function defaultViewer(kind: ViewKind): Viewer { 120 + const stored = getStoredViewer(kind); 121 + const viewers = VIEWERS_BY_KIND[kind]; 122 + if (stored) { 123 + const found = viewers.find((v) => v.id === stored); 124 + if (found) return found; 125 + } 126 + return viewers[0]; 127 + }
+486
web/src/lib/components/Atlas.svelte
··· 1 + <script lang="ts"> 2 + import { onMount, onDestroy } from 'svelte'; 3 + import { hudReadout, logbook } from '$lib/state.svelte'; 4 + import type { AtlasPoint, LogbookEntry } from '$lib/types'; 5 + 6 + interface Props { 7 + points: AtlasPoint[]; 8 + // optional: edges to render between points (e.g. phi -> handles) 9 + edges?: { source: string; target: string }[]; 10 + // callback that turns a point into a logbook entry on click 11 + entryFor: (p: AtlasPoint) => LogbookEntry; 12 + } 13 + 14 + let { points, edges = [], entryFor }: Props = $props(); 15 + 16 + let canvas: HTMLCanvasElement; 17 + let dpr = 1; 18 + let W = 0, 19 + H = 0; 20 + 21 + // view state — pan + zoom in screen space 22 + const view = $state({ z: 1, px: 0, py: 0 }); 23 + const minZoom = 0.4; 24 + const maxZoom = 8; 25 + 26 + // kind → color mapping (resolved at render time from CSS vars) 27 + const KIND_COLORS: Record<string, string> = { 28 + phi: '--hud-hot', 29 + 'handle-engaged': '--text', 30 + 'handle-candidate': '--text-dim', 31 + observation: '--scan-mid', 32 + goal: '--warn' 33 + }; 34 + 35 + // avatar cache — patternId-style images, but using offscreen canvas blits 36 + const avatarCache = new Map<string, HTMLImageElement>(); 37 + const avatarLoading = new Set<string>(); 38 + const avatarFailed = new Set<string>(); 39 + 40 + function loadAvatar(p: AtlasPoint) { 41 + if (!p.avatar) return; 42 + const key = p.id; 43 + if (avatarCache.has(key) || avatarLoading.has(key) || avatarFailed.has(key)) return; 44 + avatarLoading.add(key); 45 + const img = new Image(); 46 + // Note: not setting crossOrigin — bsky CDN doesn't send Access-Control-Allow-Origin, 47 + // so anonymous requests are blocked. Leaving it unset lets the browser fetch and 48 + // drawImage anyway; we just can't getImageData() (which we don't need). 49 + img.onload = () => { 50 + avatarCache.set(key, img); 51 + avatarLoading.delete(key); 52 + scheduleFrame(); 53 + }; 54 + img.onerror = () => { 55 + avatarFailed.add(key); 56 + avatarLoading.delete(key); 57 + }; 58 + img.src = p.avatar; 59 + } 60 + 61 + let frameRequested = false; 62 + function scheduleFrame() { 63 + if (!frameRequested) { 64 + frameRequested = true; 65 + requestAnimationFrame(draw); 66 + } 67 + } 68 + 69 + // transform: normalized point.x/y in [-1,1] → screen pixels 70 + function worldToScreen(x: number, y: number): [number, number] { 71 + const scale = Math.min(W, H) * 0.42 * view.z; 72 + return [W / 2 + x * scale + view.px, H / 2 + y * scale + view.py]; 73 + } 74 + 75 + function resolveColor(name: string): string { 76 + const root = document.documentElement; 77 + return getComputedStyle(root).getPropertyValue(name).trim() || '#888'; 78 + } 79 + 80 + let hovered = $state<AtlasPoint | null>(null); 81 + 82 + function pointAt(mx: number, my: number): AtlasPoint | null { 83 + // reverse: pick the point closest to (mx,my) within radius 84 + let best: AtlasPoint | null = null; 85 + let bestDist = Infinity; 86 + for (const p of points) { 87 + const [sx, sy] = worldToScreen(p.x, p.y); 88 + const r = radiusFor(p); 89 + const dx = sx - mx; 90 + const dy = sy - my; 91 + const d2 = dx * dx + dy * dy; 92 + if (d2 < r * r * 1.6 && d2 < bestDist) { 93 + bestDist = d2; 94 + best = p; 95 + } 96 + } 97 + return best; 98 + } 99 + 100 + function radiusFor(p: AtlasPoint): number { 101 + // phi is largest, then engaged handles, then candidates, then concepts 102 + const base = 103 + p.kind === 'phi' ? 14 : p.kind === 'handle-engaged' ? 10 : p.kind === 'handle-candidate' ? 7 : 6; 104 + return base * Math.max(0.7, Math.min(1.6, view.z)); 105 + } 106 + 107 + function fadeIn(z: number, start: number, range: number) { 108 + return Math.max(0, Math.min(1, (z - start) / range)); 109 + } 110 + 111 + function draw() { 112 + frameRequested = false; 113 + if (!canvas) return; 114 + const ctx = canvas.getContext('2d'); 115 + if (!ctx) return; 116 + 117 + ctx.save(); 118 + ctx.scale(dpr, dpr); 119 + ctx.clearRect(0, 0, W, H); 120 + 121 + // faint grid — subtle scan-visor reference 122 + drawGrid(ctx); 123 + 124 + // edges 125 + ctx.lineWidth = 1; 126 + ctx.strokeStyle = resolveColor('--line-dim'); 127 + const idIndex = new Map(points.map((p) => [p.id, p])); 128 + for (const e of edges) { 129 + const a = idIndex.get(e.source); 130 + const b = idIndex.get(e.target); 131 + if (!a || !b) continue; 132 + const [ax, ay] = worldToScreen(a.x, a.y); 133 + const [bx, by] = worldToScreen(b.x, b.y); 134 + ctx.beginPath(); 135 + ctx.moveTo(ax, ay); 136 + ctx.lineTo(bx, by); 137 + ctx.stroke(); 138 + } 139 + 140 + // points 141 + for (const p of points) { 142 + const [sx, sy] = worldToScreen(p.x, p.y); 143 + const r = radiusFor(p); 144 + const colorVar = KIND_COLORS[p.kind] ?? '--text-dim'; 145 + const color = resolveColor(colorVar); 146 + 147 + const img = avatarCache.get(p.id); 148 + if (img && p.kind !== 'handle-candidate') { 149 + // engaged handles get avatar fill 150 + ctx.save(); 151 + ctx.beginPath(); 152 + ctx.arc(sx, sy, r, 0, Math.PI * 2); 153 + ctx.clip(); 154 + ctx.drawImage(img, sx - r, sy - r, r * 2, r * 2); 155 + ctx.restore(); 156 + ctx.lineWidth = 1.5; 157 + ctx.strokeStyle = color; 158 + ctx.beginPath(); 159 + ctx.arc(sx, sy, r, 0, Math.PI * 2); 160 + ctx.stroke(); 161 + } else if (p.kind === 'handle-candidate') { 162 + // candidates: dim outline only, ghosted 163 + ctx.lineWidth = 1; 164 + ctx.strokeStyle = color; 165 + ctx.setLineDash([2, 2]); 166 + ctx.beginPath(); 167 + ctx.arc(sx, sy, r, 0, Math.PI * 2); 168 + ctx.stroke(); 169 + ctx.setLineDash([]); 170 + } else if (p.kind === 'phi') { 171 + // phi: filled ring with glow 172 + ctx.beginPath(); 173 + ctx.arc(sx, sy, r * 1.6, 0, Math.PI * 2); 174 + const grd = ctx.createRadialGradient(sx, sy, r * 0.4, sx, sy, r * 1.6); 175 + grd.addColorStop(0, color); 176 + grd.addColorStop(1, 'rgba(184,107,58,0)'); 177 + ctx.fillStyle = grd; 178 + ctx.fill(); 179 + ctx.beginPath(); 180 + ctx.arc(sx, sy, r, 0, Math.PI * 2); 181 + ctx.fillStyle = color; 182 + ctx.fill(); 183 + } else { 184 + // observations + goals: small filled dots 185 + ctx.beginPath(); 186 + ctx.arc(sx, sy, r, 0, Math.PI * 2); 187 + ctx.fillStyle = color; 188 + ctx.globalAlpha = 0.85; 189 + ctx.fill(); 190 + ctx.globalAlpha = 1; 191 + } 192 + } 193 + 194 + // labels — fade in at zoom >= 0.9 195 + const labelAlpha = fadeIn(view.z, 0.9, 0.5); 196 + if (labelAlpha > 0.01) { 197 + ctx.font = '10px "JetBrains Mono", monospace'; 198 + ctx.textAlign = 'center'; 199 + ctx.textBaseline = 'top'; 200 + for (const p of points) { 201 + const [sx, sy] = worldToScreen(p.x, p.y); 202 + const r = radiusFor(p); 203 + const label = labelFor(p); 204 + if (!label) continue; 205 + ctx.fillStyle = resolveColor('--text-mid'); 206 + ctx.globalAlpha = labelAlpha * (p.kind === 'phi' ? 1 : p.kind === 'handle-engaged' ? 0.9 : 0.5); 207 + ctx.fillText(label, sx, sy + r + 6); 208 + } 209 + ctx.globalAlpha = 1; 210 + } 211 + 212 + // reticle on hovered point 213 + if (hovered) { 214 + const [hx, hy] = worldToScreen(hovered.x, hovered.y); 215 + drawReticle(ctx, hx, hy, radiusFor(hovered) + 4); 216 + } 217 + 218 + ctx.restore(); 219 + } 220 + 221 + function labelFor(p: AtlasPoint): string { 222 + if (p.kind === 'phi') return 'phi'; 223 + if (p.kind === 'handle-engaged' || p.kind === 'handle-candidate') return p.label; 224 + // observations + goals: don't label by default — too noisy. Only when zoomed in further. 225 + if (view.z >= 1.6) return p.label.length > 32 ? p.label.slice(0, 32) + '…' : p.label; 226 + return ''; 227 + } 228 + 229 + function drawReticle(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { 230 + ctx.lineWidth = 1.2; 231 + ctx.strokeStyle = resolveColor('--hud-hot'); 232 + const arm = 6; 233 + // four corners 234 + const corners = [ 235 + [-1, -1], 236 + [1, -1], 237 + [-1, 1], 238 + [1, 1] 239 + ]; 240 + for (const [sx, sy] of corners) { 241 + const x = cx + sx * r; 242 + const y = cy + sy * r; 243 + ctx.beginPath(); 244 + ctx.moveTo(x, y - sy * arm); 245 + ctx.lineTo(x, y); 246 + ctx.lineTo(x - sx * arm, y); 247 + ctx.stroke(); 248 + } 249 + } 250 + 251 + function drawGrid(ctx: CanvasRenderingContext2D) { 252 + // concentric circles around center, scaled to zoom — scaffolding only 253 + ctx.strokeStyle = resolveColor('--grid'); 254 + ctx.lineWidth = 1; 255 + const cx = W / 2 + view.px; 256 + const cy = H / 2 + view.py; 257 + const baseR = Math.min(W, H) * 0.18 * view.z; 258 + for (let i = 1; i <= 4; i++) { 259 + ctx.beginPath(); 260 + ctx.arc(cx, cy, baseR * i, 0, Math.PI * 2); 261 + ctx.stroke(); 262 + } 263 + // crosshairs 264 + ctx.beginPath(); 265 + ctx.moveTo(cx - W, cy); 266 + ctx.lineTo(cx + W, cy); 267 + ctx.moveTo(cx, cy - H); 268 + ctx.lineTo(cx, cy + H); 269 + ctx.stroke(); 270 + } 271 + 272 + // --- input --- 273 + // Pointer events: support mouse (hover + wheel + click) and touch 274 + // (single-finger pan, two-finger pinch zoom, tap to open logbook). 275 + // Hover/readout is mouse-only — touch users get tap-opens-logbook. 276 + 277 + const activePointers = new Map<number, { x: number; y: number; type: string }>(); 278 + let dragging = false; 279 + let lastX = 0, 280 + lastY = 0; 281 + let panStartX = 0, 282 + panStartY = 0; 283 + let pinchStartDist = 0; 284 + let pinchStartZoom = 1; 285 + let pinchCx = 0, 286 + pinchCy = 0; 287 + let pinching = false; 288 + const TAP_THRESHOLD = 8; // px movement allowed for a tap 289 + 290 + function applyZoomAt(mx: number, my: number, newZ: number) { 291 + newZ = Math.max(minZoom, Math.min(maxZoom, newZ)); 292 + const ratio = newZ / view.z; 293 + view.px = mx - (mx - W / 2 - view.px) * ratio - W / 2; 294 + view.py = my - (my - H / 2 - view.py) * ratio - H / 2; 295 + view.z = newZ; 296 + } 297 + 298 + function onPointerDown(e: PointerEvent) { 299 + canvas.setPointerCapture(e.pointerId); 300 + activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY, type: e.pointerType }); 301 + 302 + if (activePointers.size === 1) { 303 + dragging = true; 304 + pinching = false; 305 + lastX = e.clientX; 306 + lastY = e.clientY; 307 + panStartX = e.clientX; 308 + panStartY = e.clientY; 309 + } else if (activePointers.size === 2) { 310 + pinching = true; 311 + dragging = false; 312 + const [a, b] = [...activePointers.values()]; 313 + pinchStartDist = Math.hypot(b.x - a.x, b.y - a.y); 314 + pinchStartZoom = view.z; 315 + const rect = canvas.getBoundingClientRect(); 316 + pinchCx = (a.x + b.x) / 2 - rect.left; 317 + pinchCy = (a.y + b.y) / 2 - rect.top; 318 + } 319 + } 320 + 321 + function onPointerMove(e: PointerEvent) { 322 + const rect = canvas.getBoundingClientRect(); 323 + const mx = e.clientX - rect.left; 324 + const my = e.clientY - rect.top; 325 + 326 + if (activePointers.has(e.pointerId)) { 327 + activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY, type: e.pointerType }); 328 + } 329 + 330 + if (pinching && activePointers.size >= 2) { 331 + const [a, b] = [...activePointers.values()]; 332 + const dist = Math.hypot(b.x - a.x, b.y - a.y); 333 + if (pinchStartDist > 0) { 334 + applyZoomAt(pinchCx, pinchCy, pinchStartZoom * (dist / pinchStartDist)); 335 + scheduleFrame(); 336 + } 337 + return; 338 + } 339 + 340 + if (dragging) { 341 + view.px += e.clientX - lastX; 342 + view.py += e.clientY - lastY; 343 + lastX = e.clientX; 344 + lastY = e.clientY; 345 + scheduleFrame(); 346 + return; 347 + } 348 + 349 + // hover (mouse only — touch fingers without buttons aren't here) 350 + if (e.pointerType === 'mouse') { 351 + const p = pointAt(mx, my); 352 + if (p !== hovered) { 353 + hovered = p; 354 + hudReadout.set(p ? readoutFor(p) : ''); 355 + canvas.style.cursor = p ? 'pointer' : 'grab'; 356 + scheduleFrame(); 357 + } 358 + } 359 + } 360 + 361 + function onPointerUp(e: PointerEvent) { 362 + const wasTap = 363 + Math.abs(e.clientX - panStartX) < TAP_THRESHOLD && 364 + Math.abs(e.clientY - panStartY) < TAP_THRESHOLD && 365 + !pinching; 366 + 367 + activePointers.delete(e.pointerId); 368 + try { 369 + canvas.releasePointerCapture(e.pointerId); 370 + } catch { 371 + /* may not have capture */ 372 + } 373 + 374 + if (activePointers.size === 0) { 375 + dragging = false; 376 + pinching = false; 377 + // touch tap → open logbook (mouse uses onClick separately) 378 + if (wasTap && e.pointerType !== 'mouse') { 379 + const rect = canvas.getBoundingClientRect(); 380 + const p = pointAt(e.clientX - rect.left, e.clientY - rect.top); 381 + if (p) logbook.set(entryFor(p)); 382 + } 383 + } else if (activePointers.size === 1) { 384 + pinching = false; 385 + const [first] = [...activePointers.values()]; 386 + lastX = first.x; 387 + lastY = first.y; 388 + panStartX = first.x; 389 + panStartY = first.y; 390 + dragging = true; 391 + } 392 + } 393 + 394 + function onClick(e: MouseEvent) { 395 + // mouse-only — touch handled in onPointerUp 396 + if ((e as PointerEvent).pointerType && (e as PointerEvent).pointerType !== 'mouse') return; 397 + const rect = canvas.getBoundingClientRect(); 398 + const p = pointAt(e.clientX - rect.left, e.clientY - rect.top); 399 + if (p) logbook.set(entryFor(p)); 400 + } 401 + 402 + function onWheel(e: WheelEvent) { 403 + e.preventDefault(); 404 + const rect = canvas.getBoundingClientRect(); 405 + applyZoomAt(e.clientX - rect.left, e.clientY - rect.top, view.z * Math.exp(-e.deltaY * 0.0015)); 406 + scheduleFrame(); 407 + } 408 + 409 + function readoutFor(p: AtlasPoint): string { 410 + const kindLabels: Record<string, string> = { 411 + phi: 'self', 412 + 'handle-engaged': 'in memory', 413 + 'handle-candidate': 'on radar', 414 + observation: 'attention', 415 + goal: 'goal' 416 + }; 417 + return `${kindLabels[p.kind]} · ${p.label}`; 418 + } 419 + 420 + // --- resize --- 421 + 422 + let ro: ResizeObserver | null = null; 423 + function resize() { 424 + if (!canvas) return; 425 + const rect = canvas.parentElement!.getBoundingClientRect(); 426 + W = rect.width; 427 + H = rect.height; 428 + dpr = window.devicePixelRatio || 1; 429 + canvas.width = W * dpr; 430 + canvas.height = H * dpr; 431 + canvas.style.width = `${W}px`; 432 + canvas.style.height = `${H}px`; 433 + scheduleFrame(); 434 + } 435 + 436 + onMount(() => { 437 + resize(); 438 + ro = new ResizeObserver(resize); 439 + if (canvas?.parentElement) ro.observe(canvas.parentElement); 440 + // preload avatars 441 + for (const p of points) loadAvatar(p); 442 + scheduleFrame(); 443 + }); 444 + 445 + onDestroy(() => { 446 + ro?.disconnect(); 447 + hudReadout.set(''); 448 + }); 449 + 450 + // reload avatars when points change 451 + $effect(() => { 452 + for (const p of points) loadAvatar(p); 453 + scheduleFrame(); 454 + }); 455 + </script> 456 + 457 + <div class="atlas-host"> 458 + <canvas 459 + bind:this={canvas} 460 + onpointerdown={onPointerDown} 461 + onpointermove={onPointerMove} 462 + onpointerup={onPointerUp} 463 + onpointercancel={onPointerUp} 464 + onpointerleave={() => { 465 + hovered = null; 466 + hudReadout.set(''); 467 + canvas.style.cursor = 'grab'; 468 + scheduleFrame(); 469 + }} 470 + onclick={onClick} 471 + onwheel={onWheel} 472 + ></canvas> 473 + </div> 474 + 475 + <style> 476 + .atlas-host { 477 + position: absolute; 478 + inset: 0; 479 + } 480 + 481 + canvas { 482 + display: block; 483 + cursor: grab; 484 + touch-action: none; 485 + } 486 + </style>
-87
web/src/lib/components/BlogCard.svelte
··· 1 - <script lang="ts"> 2 - import type { BlogDoc } from '$lib/types'; 3 - import { relativeWhen } from '$lib/time'; 4 - 5 - interface Props { 6 - doc: BlogDoc; 7 - } 8 - 9 - let { doc }: Props = $props(); 10 - 11 - function excerpt(content: string, maxChars = 240): string { 12 - const stripped = content 13 - .replace(/^#+\s+.+$/gm, '') 14 - .replace(/\n{2,}/g, ' ') 15 - .replace(/^\s+|\s+$/g, ''); 16 - if (stripped.length <= maxChars) return stripped; 17 - return stripped.slice(0, maxChars).replace(/\s+\S*$/, '') + '…'; 18 - } 19 - 20 - const age = $derived(relativeWhen(doc.publishedAt)); 21 - </script> 22 - 23 - <div class="card"> 24 - <a href={doc.url} target="_blank" rel="noopener" class="title">{doc.title}</a> 25 - <div class="excerpt muted">{excerpt(doc.content)}</div> 26 - <div class="meta"> 27 - {#if age}<span class="faint">{age}</span>{/if} 28 - {#if doc.tags.length > 0} 29 - <span class="tags"> 30 - {#each doc.tags as tag, i (tag)} 31 - <span class="tag">{tag}</span>{#if i < doc.tags.length - 1}<span 32 - class="tag-sep faint" 33 - >·</span 34 - >{/if} 35 - {/each} 36 - </span> 37 - {/if} 38 - </div> 39 - </div> 40 - 41 - <style> 42 - .card { 43 - border-left-color: var(--accent-purple); 44 - } 45 - 46 - .title { 47 - display: block; 48 - font-size: 16px; 49 - color: var(--text); 50 - margin-bottom: 8px; 51 - text-decoration: none; 52 - } 53 - 54 - .title:hover { 55 - color: var(--accent-purple); 56 - } 57 - 58 - .excerpt { 59 - font-size: 13px; 60 - line-height: 1.5; 61 - margin-bottom: 10px; 62 - } 63 - 64 - .meta { 65 - font-size: 12px; 66 - display: flex; 67 - align-items: center; 68 - gap: 12px; 69 - flex-wrap: wrap; 70 - } 71 - 72 - .tags { 73 - display: inline-flex; 74 - gap: 4px; 75 - flex-wrap: wrap; 76 - } 77 - 78 - .tag { 79 - color: var(--text-muted); 80 - font-family: 'SF Mono', monospace; 81 - font-size: 11px; 82 - } 83 - 84 - .tag-sep { 85 - font-size: 11px; 86 - } 87 - </style>
+365
web/src/lib/components/Constellation.svelte
··· 1 + <script lang="ts"> 2 + import { onMount, onDestroy } from 'svelte'; 3 + import { hudReadout, logbook } from '$lib/state.svelte'; 4 + import { relativeWhen } from '$lib/time'; 5 + import type { ActivityItem, BlogDoc, LogbookEntry } from '$lib/types'; 6 + 7 + type Item = ActivityItem & { _kind: 'post' | 'note' | 'url' | 'blog'; _blogRef?: BlogDoc }; 8 + 9 + interface Props { 10 + items: Item[]; 11 + } 12 + 13 + let { items }: Props = $props(); 14 + 15 + let canvas: HTMLCanvasElement; 16 + let dpr = 1; 17 + let W = 0, 18 + H = 0; 19 + let frameRequested = false; 20 + const view = $state({ z: 1, py: 0 }); 21 + const minZoom = 0.5; 22 + const maxZoom = 4; 23 + 24 + const KIND_COLORS: Record<Item['_kind'], string> = { 25 + post: '--hud-mid', 26 + note: '--hud-hot', 27 + url: '--scan-mid', 28 + blog: '--warn' 29 + }; 30 + 31 + type Placed = Item & { sx: number; sy: number; r: number }; 32 + let placed = $state<Placed[]>([]); 33 + let hovered = $state<Placed | null>(null); 34 + 35 + function resolveColor(name: string): string { 36 + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888'; 37 + } 38 + 39 + function place() { 40 + if (!items.length || W === 0 || H === 0) { 41 + placed = []; 42 + return; 43 + } 44 + // time spiral: t goes from now → past as you spiral outward 45 + // each item gets a position on the spiral; angle = phase, radius = age 46 + const now = Date.now(); 47 + const padded: Placed[] = []; 48 + const margin = 80; 49 + const innerR = 40; 50 + const maxAge = items.reduce((acc, it) => { 51 + const t = new Date(it.time).getTime(); 52 + return Math.max(acc, isNaN(t) ? 0 : now - t); 53 + }, 0); 54 + const safeMaxAge = Math.max(maxAge, 1); 55 + const cx = W / 2; 56 + const cy = H / 2 + view.py; 57 + const maxR = Math.min(W, H) / 2 - margin; 58 + const turns = 2.6; // total spiral revolutions across the dataset 59 + 60 + for (let i = 0; i < items.length; i++) { 61 + const it = items[i]; 62 + const t = new Date(it.time).getTime(); 63 + const age = isNaN(t) ? safeMaxAge : now - t; 64 + const ageNorm = age / safeMaxAge; // 0..1 65 + const r = innerR + ageNorm * (maxR - innerR) * view.z; 66 + const angle = ageNorm * turns * Math.PI * 2 - Math.PI / 2; // start at top 67 + const sx = cx + Math.cos(angle) * r; 68 + const sy = cy + Math.sin(angle) * r; 69 + const radius = it._kind === 'blog' ? 6 : 4; 70 + padded.push({ ...it, sx, sy, r: radius }); 71 + } 72 + placed = padded; 73 + } 74 + 75 + function scheduleFrame() { 76 + if (!frameRequested) { 77 + frameRequested = true; 78 + requestAnimationFrame(draw); 79 + } 80 + } 81 + 82 + function draw() { 83 + frameRequested = false; 84 + if (!canvas) return; 85 + const ctx = canvas.getContext('2d'); 86 + if (!ctx) return; 87 + 88 + ctx.save(); 89 + ctx.scale(dpr, dpr); 90 + ctx.clearRect(0, 0, W, H); 91 + 92 + // faint spiral guide 93 + drawSpiralGuide(ctx); 94 + 95 + // connection trail along time 96 + ctx.lineWidth = 1; 97 + ctx.strokeStyle = resolveColor('--line-dim'); 98 + ctx.beginPath(); 99 + for (let i = 0; i < placed.length; i++) { 100 + const p = placed[i]; 101 + if (i === 0) ctx.moveTo(p.sx, p.sy); 102 + else ctx.lineTo(p.sx, p.sy); 103 + } 104 + ctx.stroke(); 105 + 106 + // points 107 + for (const p of placed) { 108 + const color = resolveColor(KIND_COLORS[p._kind]); 109 + ctx.beginPath(); 110 + ctx.arc(p.sx, p.sy, p.r, 0, Math.PI * 2); 111 + ctx.fillStyle = color; 112 + ctx.globalAlpha = 0.9; 113 + ctx.fill(); 114 + ctx.globalAlpha = 1; 115 + 116 + // glow on hover 117 + if (hovered === p) { 118 + ctx.beginPath(); 119 + ctx.arc(p.sx, p.sy, p.r * 2.5, 0, Math.PI * 2); 120 + const grd = ctx.createRadialGradient(p.sx, p.sy, p.r, p.sx, p.sy, p.r * 2.5); 121 + grd.addColorStop(0, color); 122 + grd.addColorStop(1, 'rgba(0,0,0,0)'); 123 + ctx.fillStyle = grd; 124 + ctx.globalAlpha = 0.4; 125 + ctx.fill(); 126 + ctx.globalAlpha = 1; 127 + drawReticle(ctx, p.sx, p.sy, p.r + 5); 128 + } 129 + } 130 + 131 + ctx.restore(); 132 + } 133 + 134 + function drawSpiralGuide(ctx: CanvasRenderingContext2D) { 135 + ctx.strokeStyle = resolveColor('--grid'); 136 + ctx.lineWidth = 1; 137 + const cx = W / 2; 138 + const cy = H / 2 + view.py; 139 + const innerR = 40; 140 + const margin = 80; 141 + const maxR = Math.min(W, H) / 2 - margin; 142 + const turns = 2.6; 143 + const steps = 200; 144 + ctx.beginPath(); 145 + for (let i = 0; i <= steps; i++) { 146 + const t = i / steps; 147 + const r = innerR + t * (maxR - innerR) * view.z; 148 + const angle = t * turns * Math.PI * 2 - Math.PI / 2; 149 + const x = cx + Math.cos(angle) * r; 150 + const y = cy + Math.sin(angle) * r; 151 + if (i === 0) ctx.moveTo(x, y); 152 + else ctx.lineTo(x, y); 153 + } 154 + ctx.stroke(); 155 + // center marker (now) 156 + ctx.fillStyle = resolveColor('--hud-hot'); 157 + ctx.beginPath(); 158 + ctx.arc(cx, cy, 3, 0, Math.PI * 2); 159 + ctx.fill(); 160 + ctx.font = '9px "Saira Condensed", sans-serif'; 161 + ctx.fillStyle = resolveColor('--text-dim'); 162 + ctx.textAlign = 'center'; 163 + ctx.fillText('NOW', cx, cy - 12); 164 + } 165 + 166 + function drawReticle(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { 167 + ctx.lineWidth = 1.2; 168 + ctx.strokeStyle = resolveColor('--hud-hot'); 169 + const arm = 5; 170 + for (const [sx, sy] of [ 171 + [-1, -1], 172 + [1, -1], 173 + [-1, 1], 174 + [1, 1] 175 + ]) { 176 + const x = cx + sx * r; 177 + const y = cy + sy * r; 178 + ctx.beginPath(); 179 + ctx.moveTo(x, y - sy * arm); 180 + ctx.lineTo(x, y); 181 + ctx.lineTo(x - sx * arm, y); 182 + ctx.stroke(); 183 + } 184 + } 185 + 186 + function entryFor(p: Placed): LogbookEntry { 187 + if (p._kind === 'blog' && p._blogRef) return { kind: 'blog', doc: p._blogRef }; 188 + return { kind: 'activity', item: p }; 189 + } 190 + 191 + function readoutFor(p: Placed): string { 192 + const k = p._kind.toUpperCase(); 193 + const age = relativeWhen(p.time); 194 + const text = (p.title ?? p.text ?? '').slice(0, 60); 195 + return `${k} · ${age} · ${text}`; 196 + } 197 + 198 + function pointAt(mx: number, my: number): Placed | null { 199 + let best: Placed | null = null; 200 + let bestD = Infinity; 201 + for (const p of placed) { 202 + const dx = mx - p.sx; 203 + const dy = my - p.sy; 204 + const d2 = dx * dx + dy * dy; 205 + if (d2 < (p.r + 8) * (p.r + 8) && d2 < bestD) { 206 + bestD = d2; 207 + best = p; 208 + } 209 + } 210 + return best; 211 + } 212 + 213 + // Pointer events: mouse hover + wheel; touch single-tap-to-open + pinch-zoom. 214 + const activePointers = new Map<number, { x: number; y: number }>(); 215 + let panStartX = 0, 216 + panStartY = 0; 217 + let pinchStartDist = 0; 218 + let pinchStartZoom = 1; 219 + let pinching = false; 220 + const TAP_THRESHOLD = 8; 221 + 222 + function onPointerDown(e: PointerEvent) { 223 + canvas.setPointerCapture(e.pointerId); 224 + activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); 225 + if (activePointers.size === 1) { 226 + panStartX = e.clientX; 227 + panStartY = e.clientY; 228 + pinching = false; 229 + } else if (activePointers.size === 2) { 230 + pinching = true; 231 + const [a, b] = [...activePointers.values()]; 232 + pinchStartDist = Math.hypot(b.x - a.x, b.y - a.y); 233 + pinchStartZoom = view.z; 234 + } 235 + } 236 + 237 + function onPointerMove(e: PointerEvent) { 238 + const rect = canvas.getBoundingClientRect(); 239 + const mx = e.clientX - rect.left; 240 + const my = e.clientY - rect.top; 241 + 242 + if (activePointers.has(e.pointerId)) { 243 + activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); 244 + } 245 + 246 + if (pinching && activePointers.size >= 2) { 247 + const [a, b] = [...activePointers.values()]; 248 + const dist = Math.hypot(b.x - a.x, b.y - a.y); 249 + if (pinchStartDist > 0) { 250 + view.z = Math.max(minZoom, Math.min(maxZoom, pinchStartZoom * (dist / pinchStartDist))); 251 + place(); 252 + scheduleFrame(); 253 + } 254 + return; 255 + } 256 + 257 + if (e.pointerType === 'mouse' && activePointers.size === 0) { 258 + const p = pointAt(mx, my); 259 + if (p !== hovered) { 260 + hovered = p; 261 + hudReadout.set(p ? readoutFor(p) : ''); 262 + canvas.style.cursor = p ? 'pointer' : 'default'; 263 + scheduleFrame(); 264 + } 265 + } 266 + } 267 + 268 + function onPointerUp(e: PointerEvent) { 269 + const wasTap = 270 + Math.abs(e.clientX - panStartX) < TAP_THRESHOLD && 271 + Math.abs(e.clientY - panStartY) < TAP_THRESHOLD && 272 + !pinching; 273 + 274 + activePointers.delete(e.pointerId); 275 + try { 276 + canvas.releasePointerCapture(e.pointerId); 277 + } catch { 278 + /* may not have capture */ 279 + } 280 + 281 + if (activePointers.size < 2) pinching = false; 282 + 283 + if (activePointers.size === 0 && wasTap && e.pointerType !== 'mouse') { 284 + const rect = canvas.getBoundingClientRect(); 285 + const p = pointAt(e.clientX - rect.left, e.clientY - rect.top); 286 + if (p) logbook.set(entryFor(p)); 287 + } 288 + } 289 + 290 + function onClick(e: MouseEvent) { 291 + if ((e as PointerEvent).pointerType && (e as PointerEvent).pointerType !== 'mouse') return; 292 + const rect = canvas.getBoundingClientRect(); 293 + const p = pointAt(e.clientX - rect.left, e.clientY - rect.top); 294 + if (p) logbook.set(entryFor(p)); 295 + } 296 + 297 + function onWheel(e: WheelEvent) { 298 + e.preventDefault(); 299 + const factor = Math.exp(-e.deltaY * 0.0015); 300 + view.z = Math.max(minZoom, Math.min(maxZoom, view.z * factor)); 301 + place(); 302 + scheduleFrame(); 303 + } 304 + 305 + function resize() { 306 + if (!canvas) return; 307 + const rect = canvas.parentElement!.getBoundingClientRect(); 308 + W = rect.width; 309 + H = rect.height; 310 + dpr = window.devicePixelRatio || 1; 311 + canvas.width = W * dpr; 312 + canvas.height = H * dpr; 313 + canvas.style.width = `${W}px`; 314 + canvas.style.height = `${H}px`; 315 + place(); 316 + scheduleFrame(); 317 + } 318 + 319 + let ro: ResizeObserver | null = null; 320 + onMount(() => { 321 + resize(); 322 + ro = new ResizeObserver(resize); 323 + if (canvas?.parentElement) ro.observe(canvas.parentElement); 324 + }); 325 + 326 + onDestroy(() => { 327 + ro?.disconnect(); 328 + hudReadout.set(''); 329 + }); 330 + 331 + $effect(() => { 332 + // re-place when items change 333 + if (W > 0 && items) place(); 334 + scheduleFrame(); 335 + }); 336 + </script> 337 + 338 + <div class="host"> 339 + <canvas 340 + bind:this={canvas} 341 + onpointerdown={onPointerDown} 342 + onpointermove={onPointerMove} 343 + onpointerup={onPointerUp} 344 + onpointercancel={onPointerUp} 345 + onpointerleave={() => { 346 + hovered = null; 347 + hudReadout.set(''); 348 + scheduleFrame(); 349 + }} 350 + onclick={onClick} 351 + onwheel={onWheel} 352 + ></canvas> 353 + </div> 354 + 355 + <style> 356 + .host { 357 + position: absolute; 358 + inset: 0; 359 + } 360 + 361 + canvas { 362 + display: block; 363 + touch-action: none; 364 + } 365 + </style>
-69
web/src/lib/components/DiscoveryCard.svelte
··· 1 - <script lang="ts"> 2 - import type { DiscoveryEntry } from '$lib/types'; 3 - import { relativeWhen } from '$lib/time'; 4 - 5 - interface Props { 6 - entry: DiscoveryEntry; 7 - } 8 - 9 - let { entry }: Props = $props(); 10 - 11 - const age = $derived(relativeWhen(entry.last_liked_at)); 12 - </script> 13 - 14 - <div class="card"> 15 - <div class="header"> 16 - <a class="handle" href="https://bsky.app/profile/{entry.handle}" target="_blank" rel="noopener"> 17 - @{entry.handle} 18 - </a> 19 - <span class="faint"> 20 - {entry.likes_in_window} like{entry.likes_in_window === 1 ? '' : 's'} 21 - {#if age}· {age}{/if} 22 - </span> 23 - </div> 24 - {#if entry.sample_posts.length > 0} 25 - <ul class="samples"> 26 - {#each entry.sample_posts as p (p.uri)} 27 - {#if p.text} 28 - <li>{p.text.length > 200 ? p.text.slice(0, 200) + '…' : p.text}</li> 29 - {/if} 30 - {/each} 31 - </ul> 32 - {/if} 33 - </div> 34 - 35 - <style> 36 - .card { 37 - border-left-color: var(--accent-green); 38 - } 39 - 40 - .header { 41 - display: flex; 42 - align-items: center; 43 - gap: 12px; 44 - margin-bottom: 8px; 45 - flex-wrap: wrap; 46 - font-size: 13px; 47 - } 48 - 49 - .handle { 50 - font-weight: 500; 51 - color: var(--text); 52 - } 53 - 54 - .handle:hover { 55 - color: var(--accent-green); 56 - } 57 - 58 - .samples { 59 - margin: 0; 60 - padding-left: 18px; 61 - font-size: 13px; 62 - color: var(--text-muted); 63 - line-height: 1.5; 64 - } 65 - 66 - .samples li { 67 - margin-bottom: 4px; 68 - } 69 - </style>
-64
web/src/lib/components/GoalCard.svelte
··· 1 - <script lang="ts"> 2 - import type { Goal } from '$lib/types'; 3 - 4 - interface Props { 5 - goal: Goal; 6 - } 7 - 8 - let { goal }: Props = $props(); 9 - </script> 10 - 11 - <div class="card"> 12 - <div class="title">{goal.title}</div> 13 - <div class="body muted">{goal.description}</div> 14 - {#if goal.progress_signal} 15 - <div class="progress"> 16 - <span class="label">progress</span> 17 - <span>{goal.progress_signal}</span> 18 - </div> 19 - {/if} 20 - <div class="meta faint"> 21 - <span class="mono">rkey {goal.rkey}</span> 22 - </div> 23 - </div> 24 - 25 - <style> 26 - .card { 27 - border-left-color: var(--accent-blue); 28 - } 29 - 30 - .title { 31 - font-size: 16px; 32 - color: var(--text); 33 - margin-bottom: 6px; 34 - } 35 - 36 - .body { 37 - font-size: 14px; 38 - margin-bottom: 10px; 39 - white-space: pre-wrap; 40 - } 41 - 42 - .progress { 43 - font-size: 13px; 44 - color: var(--text-muted); 45 - margin-bottom: 8px; 46 - padding: 6px 10px; 47 - background: rgba(88, 166, 255, 0.06); 48 - border-radius: 4px; 49 - } 50 - 51 - .label { 52 - display: inline-block; 53 - text-transform: uppercase; 54 - letter-spacing: 0.4px; 55 - font-size: 10px; 56 - color: var(--accent-blue); 57 - margin-right: 8px; 58 - } 59 - 60 - .meta { 61 - font-size: 11px; 62 - margin-top: 4px; 63 - } 64 - </style>
+65
web/src/lib/components/Hud.svelte
··· 1 + <script lang="ts"> 2 + import { onMount, onDestroy } from 'svelte'; 3 + import { goto } from '$app/navigation'; 4 + import { page } from '$app/state'; 5 + import HudIdentity from './HudIdentity.svelte'; 6 + import HudLensCycler from './HudLensCycler.svelte'; 7 + import HudCounts from './HudCounts.svelte'; 8 + import HudReadout from './HudReadout.svelte'; 9 + 10 + const LENSES = [ 11 + { key: 'mind', href: '/', label: 'mind' }, 12 + { key: 'output', href: '/output', label: 'output' }, 13 + { key: 'capabilities', href: '/capabilities', label: 'capabilities' } 14 + ] as const; 15 + 16 + const current = $derived.by(() => { 17 + const path = page.url.pathname; 18 + if (path === '/') return 'mind'; 19 + if (path.startsWith('/output')) return 'output'; 20 + if (path.startsWith('/capabilities')) return 'capabilities'; 21 + return 'mind'; 22 + }); 23 + 24 + function cycle(dir: 1 | -1) { 25 + const idx = LENSES.findIndex((l) => l.key === current); 26 + const next = (idx + dir + LENSES.length) % LENSES.length; 27 + goto(LENSES[next].href); 28 + } 29 + 30 + function handleKey(e: KeyboardEvent) { 31 + if (e.target instanceof HTMLInputElement) return; 32 + if (e.target instanceof HTMLTextAreaElement) return; 33 + if (e.key === '1') goto('/'); 34 + if (e.key === '2') goto('/output'); 35 + if (e.key === '3') goto('/capabilities'); 36 + if (e.key === 'Tab' && !e.shiftKey) { 37 + e.preventDefault(); 38 + cycle(1); 39 + } 40 + } 41 + 42 + onMount(() => { 43 + window.addEventListener('keydown', handleKey); 44 + }); 45 + 46 + onDestroy(() => { 47 + if (typeof window !== 'undefined') window.removeEventListener('keydown', handleKey); 48 + }); 49 + </script> 50 + 51 + <div class="hud hud-tl"> 52 + <HudIdentity /> 53 + </div> 54 + 55 + <div class="hud hud-tr"> 56 + <HudLensCycler {current} {LENSES} /> 57 + </div> 58 + 59 + <div class="hud hud-bl"> 60 + <HudCounts /> 61 + </div> 62 + 63 + <div class="hud hud-br"> 64 + <HudReadout /> 65 + </div>
+97
web/src/lib/components/HudCounts.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { 4 + getActiveObservations, 5 + getGoals, 6 + getActivity, 7 + getMemoryGraph, 8 + getDiscoveryPool 9 + } from '$lib/api'; 10 + 11 + let counts = $state({ 12 + obs: 0, 13 + goals: 0, 14 + out: 0, 15 + ppl: 0, 16 + cand: 0, 17 + loaded: false 18 + }); 19 + 20 + onMount(async () => { 21 + const [obs, goals, activity, graph, disc] = await Promise.allSettled([ 22 + getActiveObservations(), 23 + getGoals(), 24 + getActivity(), 25 + getMemoryGraph(), 26 + getDiscoveryPool() 27 + ]); 28 + counts = { 29 + obs: obs.status === 'fulfilled' ? obs.value.length : 0, 30 + goals: goals.status === 'fulfilled' ? goals.value.length : 0, 31 + out: activity.status === 'fulfilled' ? activity.value.length : 0, 32 + ppl: 33 + graph.status === 'fulfilled' 34 + ? graph.value.nodes.filter((n) => n.type === 'user').length 35 + : 0, 36 + cand: disc.status === 'fulfilled' ? disc.value.length : 0, 37 + loaded: true 38 + }; 39 + }); 40 + </script> 41 + 42 + <div class="ticker"> 43 + <div class="row"> 44 + <span class="kv"><span class="k chrome">attn</span><span class="v mono">{counts.obs}</span></span> 45 + <span class="kv" 46 + ><span class="k chrome">goals</span><span class="v mono">{counts.goals}</span></span 47 + > 48 + <span class="kv" 49 + ><span class="k chrome">people</span><span class="v mono">{counts.ppl}</span></span 50 + > 51 + <span class="kv" 52 + ><span class="k chrome">cand</span><span class="v mono">{counts.cand}</span></span 53 + > 54 + <span class="kv"><span class="k chrome">out</span><span class="v mono">{counts.out}</span></span> 55 + </div> 56 + </div> 57 + 58 + <style> 59 + .ticker { 60 + font-size: 10px; 61 + } 62 + 63 + .row { 64 + display: flex; 65 + gap: 14px; 66 + flex-wrap: wrap; 67 + } 68 + 69 + .kv { 70 + display: flex; 71 + gap: 6px; 72 + align-items: baseline; 73 + } 74 + 75 + .k { 76 + font-size: 9px; 77 + color: var(--text-dim); 78 + } 79 + 80 + .v { 81 + color: var(--scan-hot); 82 + font-size: 11px; 83 + } 84 + 85 + @media (max-width: 640px) { 86 + .row { 87 + gap: 10px; 88 + justify-content: space-between; 89 + } 90 + .k { 91 + font-size: 8px; 92 + } 93 + .v { 94 + font-size: 10px; 95 + } 96 + } 97 + </style>
+144
web/src/lib/components/HudIdentity.svelte
··· 1 + <script lang="ts"> 2 + import { onMount, onDestroy } from 'svelte'; 3 + import { getHealth, PHI_HANDLE } from '$lib/api'; 4 + import type { HealthInfo } from '$lib/types'; 5 + 6 + let health = $state<HealthInfo | null>(null); 7 + let timer: ReturnType<typeof setInterval> | null = null; 8 + 9 + async function poll() { 10 + try { 11 + health = await getHealth(); 12 + } catch { 13 + health = null; 14 + } 15 + } 16 + 17 + onMount(() => { 18 + poll(); 19 + timer = setInterval(poll, 15000); 20 + }); 21 + 22 + onDestroy(() => { 23 + if (timer) clearInterval(timer); 24 + }); 25 + 26 + const status = $derived.by(() => { 27 + if (!health) return { color: 'var(--danger)', label: 'offline', pulse: false }; 28 + if (health.paused) return { color: 'var(--warn)', label: 'paused', pulse: true }; 29 + if (health.polling_active) return { color: 'var(--hud-hot)', label: 'online', pulse: true }; 30 + return { color: 'var(--text-dim)', label: 'idle', pulse: false }; 31 + }); 32 + </script> 33 + 34 + <div class="ident"> 35 + <div class="glyph-wrap" style="color: {status.color}"> 36 + <div class="glyph-bg"></div> 37 + <div class="glyph" class:pulse={status.pulse}>⌬</div> 38 + </div> 39 + <div class="meta"> 40 + <div class="name chrome">phi</div> 41 + <div class="line"> 42 + <span class="hex" style="color: {status.color}" class:pulse={status.pulse}></span> 43 + <span class="state chrome muted">{status.label}</span> 44 + <span class="sep">·</span> 45 + <a href="https://bsky.app/profile/{PHI_HANDLE}" target="_blank" rel="noopener" class="handle" 46 + >@{PHI_HANDLE}</a 47 + > 48 + </div> 49 + </div> 50 + </div> 51 + 52 + <style> 53 + .ident { 54 + display: flex; 55 + gap: 12px; 56 + align-items: center; 57 + } 58 + 59 + .glyph-wrap { 60 + position: relative; 61 + width: 32px; 62 + height: 32px; 63 + display: flex; 64 + align-items: center; 65 + justify-content: center; 66 + } 67 + 68 + .glyph-bg { 69 + position: absolute; 70 + inset: 0; 71 + background: currentColor; 72 + opacity: 0.08; 73 + clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); 74 + } 75 + 76 + .glyph-wrap::before { 77 + content: ''; 78 + position: absolute; 79 + inset: 1px; 80 + clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); 81 + box-shadow: inset 0 0 0 1px currentColor; 82 + opacity: 0.4; 83 + } 84 + 85 + .glyph { 86 + font-size: 18px; 87 + line-height: 1; 88 + position: relative; 89 + z-index: 1; 90 + } 91 + 92 + @media (max-width: 640px) { 93 + .glyph-wrap { 94 + width: 26px; 95 + height: 26px; 96 + } 97 + .glyph { 98 + font-size: 14px; 99 + } 100 + .name { 101 + font-size: 12px; 102 + } 103 + .handle { 104 + display: none; 105 + } 106 + .sep { 107 + display: none; 108 + } 109 + } 110 + 111 + .meta { 112 + display: flex; 113 + flex-direction: column; 114 + gap: 2px; 115 + } 116 + 117 + .name { 118 + font-size: 14px; 119 + color: var(--hud-hot); 120 + letter-spacing: 0.18em; 121 + } 122 + 123 + .line { 124 + display: flex; 125 + gap: 6px; 126 + align-items: center; 127 + font-size: 10px; 128 + } 129 + 130 + 131 + .state { 132 + font-size: 9px; 133 + } 134 + 135 + .sep { 136 + color: var(--text-dim); 137 + } 138 + 139 + .handle { 140 + font-family: var(--font-mono); 141 + font-size: 10px; 142 + color: var(--scan-mid); 143 + } 144 + </style>
+104
web/src/lib/components/HudLensCycler.svelte
··· 1 + <script lang="ts"> 2 + interface Lens { 3 + readonly key: string; 4 + readonly href: string; 5 + readonly label: string; 6 + } 7 + 8 + interface Props { 9 + current: string; 10 + LENSES: readonly Lens[]; 11 + } 12 + 13 + let { current, LENSES }: Props = $props(); 14 + </script> 15 + 16 + <div class="cycler"> 17 + <span class="hint chrome faint">lens</span> 18 + <div class="row"> 19 + {#each LENSES as lens, i (lens.key)} 20 + <a href={lens.href} class="opt chrome" class:active={current === lens.key}> 21 + <span class="num">{i + 1}</span> 22 + <span class="lbl">{lens.label}</span> 23 + </a> 24 + {/each} 25 + </div> 26 + </div> 27 + 28 + <style> 29 + .cycler { 30 + display: flex; 31 + flex-direction: column; 32 + align-items: flex-end; 33 + gap: 4px; 34 + } 35 + 36 + .hint { 37 + font-size: 9px; 38 + color: var(--text-dim); 39 + } 40 + 41 + .row { 42 + display: flex; 43 + gap: 1px; 44 + border: 1px solid var(--line-mid); 45 + background: var(--bg-panel); 46 + backdrop-filter: blur(8px); 47 + -webkit-backdrop-filter: blur(8px); 48 + clip-path: polygon( 49 + 6px 0, 50 + 100% 0, 51 + 100% calc(100% - 6px), 52 + calc(100% - 6px) 100%, 53 + 0 100%, 54 + 0 6px 55 + ); 56 + } 57 + 58 + .opt { 59 + display: flex; 60 + align-items: center; 61 + gap: 6px; 62 + padding: 6px 10px; 63 + font-size: 11px; 64 + color: var(--text-mid); 65 + text-decoration: none; 66 + transition: 67 + color 0.12s, 68 + background 0.12s; 69 + } 70 + 71 + .opt:hover { 72 + color: var(--hud-hot); 73 + background: rgba(184, 107, 58, 0.05); 74 + } 75 + 76 + .opt.active { 77 + color: var(--hud-hot); 78 + background: rgba(184, 107, 58, 0.1); 79 + } 80 + 81 + .num { 82 + font-family: var(--font-mono); 83 + font-size: 9px; 84 + color: var(--text-dim); 85 + } 86 + 87 + .opt.active .num { 88 + color: var(--hud-mid); 89 + } 90 + 91 + @media (max-width: 640px) { 92 + .hint { 93 + display: none; 94 + } 95 + .opt { 96 + padding: 8px 10px; 97 + font-size: 10px; 98 + min-height: 36px; 99 + } 100 + .num { 101 + display: none; 102 + } 103 + } 104 + </style>
+37
web/src/lib/components/HudReadout.svelte
··· 1 + <script lang="ts"> 2 + import { hudReadout } from '$lib/state.svelte'; 3 + 4 + const text = $derived(hudReadout.value || ''); 5 + </script> 6 + 7 + <div class="readout chrome muted" class:has={!!text}> 8 + <span class="dot"></span> 9 + <span class="t">{text || 'idle'}</span> 10 + </div> 11 + 12 + <style> 13 + .readout { 14 + display: flex; 15 + gap: 6px; 16 + align-items: center; 17 + font-size: 10px; 18 + color: var(--text-dim); 19 + } 20 + 21 + .dot { 22 + width: 5px; 23 + height: 5px; 24 + border-radius: 50%; 25 + background: var(--text-dim); 26 + display: inline-block; 27 + transition: background 0.12s; 28 + } 29 + 30 + .has .dot { 31 + background: var(--hud-hot); 32 + } 33 + 34 + .has .t { 35 + color: var(--hud-hot); 36 + } 37 + </style>
+399
web/src/lib/components/Logbook.svelte
··· 1 + <script lang="ts"> 2 + import { logbook } from '$lib/state.svelte'; 3 + import { relativeWhen } from '$lib/time'; 4 + import { PHI_HANDLE, PHI_DID } from '$lib/api'; 5 + import ViewIn from './ViewIn.svelte'; 6 + import type { Goal, Observation, ActivityItem, BlogDoc, DiscoveryEntry } from '$lib/types'; 7 + 8 + // Resolve an at-uri or a record reference into the bits ViewIn needs. 9 + function rkeyFromUri(uri: string): string { 10 + return uri.split('/').pop() ?? ''; 11 + } 12 + function collectionFromUri(uri: string): string { 13 + // at://did/collection/rkey -> "collection" 14 + const parts = uri.replace(/^at:\/\//, '').split('/'); 15 + return parts[1] ?? ''; 16 + } 17 + function repoFromUri(uri: string): string { 18 + const parts = uri.replace(/^at:\/\//, '').split('/'); 19 + return parts[0] ?? PHI_DID; 20 + } 21 + 22 + const entry = $derived(logbook.value); 23 + 24 + function close() { 25 + logbook.set(null); 26 + } 27 + 28 + function handleKey(e: KeyboardEvent) { 29 + if (e.key === 'Escape') close(); 30 + } 31 + 32 + $effect(() => { 33 + if (entry) { 34 + window.addEventListener('keydown', handleKey); 35 + return () => window.removeEventListener('keydown', handleKey); 36 + } 37 + }); 38 + </script> 39 + 40 + {#if entry} 41 + <div 42 + class="overlay" 43 + onclick={close} 44 + role="presentation" 45 + tabindex="-1" 46 + onkeydown={handleKey} 47 + ></div> 48 + <aside class="drawer scroll" aria-label="logbook entry"> 49 + <header> 50 + <div class="kind chrome"> 51 + {#if entry.kind === 'handle'}{entry.engaged ? 'in my memory' : 'on my radar'}{:else if entry.kind === 'observation'}attention{:else if entry.kind === 'goal'}goal{:else if entry.kind === 'activity'}emission · {entry.item.type}{:else if entry.kind === 'blog'}long form{:else if entry.kind === 'discovery'}on my radar{/if} 52 + </div> 53 + <button class="close chrome" onclick={close} aria-label="close">close · esc</button> 54 + </header> 55 + 56 + {#if entry.kind === 'handle'} 57 + {@const handleEntry = entry as { 58 + kind: 'handle'; 59 + handle: string; 60 + did?: string; 61 + engaged: boolean; 62 + payload: unknown; 63 + }} 64 + <h1 class="mono">@{handleEntry.handle}</h1> 65 + <p class="muted"> 66 + {#if handleEntry.engaged}they're in my memory. that could mean we've exchanged messages, or it could mean i picked something up from a post nate liked — i can't tell from this view alone.{:else}not in my memory yet. nate liked something they wrote, and that put them on my radar.{/if} 67 + </p> 68 + <div class="actions"> 69 + <ViewIn kind="profile" handle={handleEntry.handle} did={handleEntry.did} /> 70 + </div> 71 + {:else if entry.kind === 'observation'} 72 + {@const obs = entry as { kind: 'observation'; observation: Observation }} 73 + <h1>what i'm watching</h1> 74 + <p class="content">{obs.observation.content}</p> 75 + {#if obs.observation.reasoning} 76 + <div class="block"> 77 + <div class="block-label chrome">why</div> 78 + <div class="muted">{obs.observation.reasoning}</div> 79 + </div> 80 + {/if} 81 + <div class="meta"> 82 + <span class="faint">noted {relativeWhen(obs.observation.created_at)}</span> 83 + </div> 84 + <div class="actions"> 85 + <ViewIn 86 + kind="record" 87 + handle={PHI_HANDLE} 88 + did={PHI_DID} 89 + collection="io.zzstoatzz.phi.observation" 90 + rkey={obs.observation.rkey} 91 + /> 92 + </div> 93 + {:else if entry.kind === 'goal'} 94 + {@const goalE = entry as { kind: 'goal'; goal: Goal }} 95 + <h1>{goalE.goal.title}</h1> 96 + <p class="content">{goalE.goal.description}</p> 97 + {#if goalE.goal.progress_signal} 98 + <div class="block"> 99 + <div class="block-label chrome">how i'll know it's working</div> 100 + <div class="muted">{goalE.goal.progress_signal}</div> 101 + </div> 102 + {/if} 103 + <div class="meta"> 104 + <span class="faint" 105 + >last touched {relativeWhen(goalE.goal.updated_at || goalE.goal.created_at)}</span 106 + > 107 + </div> 108 + <div class="actions"> 109 + <ViewIn 110 + kind="record" 111 + handle={PHI_HANDLE} 112 + did={PHI_DID} 113 + collection="io.zzstoatzz.phi.goal" 114 + rkey={goalE.goal.rkey} 115 + /> 116 + </div> 117 + {:else if entry.kind === 'activity'} 118 + {@const act = entry as { kind: 'activity'; item: ActivityItem }} 119 + {@const kindLabel = 120 + act.item.type === 'post' 121 + ? 'i posted' 122 + : act.item.type === 'note' 123 + ? 'i made a note' 124 + : 'i bookmarked'} 125 + <h1 class="chrome">{kindLabel}</h1> 126 + {#if act.item.title} 127 + <div class="title">{act.item.title}</div> 128 + {/if} 129 + <p class="content">{act.item.text}</p> 130 + <div class="meta"> 131 + <span class="faint">{relativeWhen(act.item.time)}</span> 132 + </div> 133 + <div class="actions"> 134 + {#if act.item.type === 'post' && act.item.uri.startsWith('at://')} 135 + <ViewIn 136 + kind="post" 137 + handle={PHI_HANDLE} 138 + did={repoFromUri(act.item.uri)} 139 + collection={collectionFromUri(act.item.uri)} 140 + rkey={rkeyFromUri(act.item.uri)} 141 + /> 142 + {:else if act.item.uri.startsWith('at://')} 143 + <ViewIn 144 + kind="record" 145 + handle={PHI_HANDLE} 146 + did={repoFromUri(act.item.uri)} 147 + collection={collectionFromUri(act.item.uri)} 148 + rkey={rkeyFromUri(act.item.uri)} 149 + /> 150 + {/if} 151 + {#if act.item.url} 152 + <a class="extlink" href={act.item.url} target="_blank" rel="noopener" 153 + >open the link ↗</a 154 + > 155 + {/if} 156 + </div> 157 + {:else if entry.kind === 'blog'} 158 + {@const blog = entry as { kind: 'blog'; doc: BlogDoc }} 159 + <h1>{blog.doc.title}</h1> 160 + <div class="content prose">{blog.doc.content}</div> 161 + <div class="meta"> 162 + <span class="faint">written {relativeWhen(blog.doc.publishedAt)}</span> 163 + </div> 164 + <div class="actions"> 165 + <ViewIn 166 + kind="blog" 167 + handle={PHI_HANDLE} 168 + did={PHI_DID} 169 + collection="app.greengale.document" 170 + rkey={blog.doc.rkey} 171 + /> 172 + </div> 173 + {:else if entry.kind === 'discovery'} 174 + {@const disc = entry as { kind: 'discovery'; entry: DiscoveryEntry }} 175 + <h1 class="mono">@{disc.entry.handle}</h1> 176 + <p class="muted"> 177 + not in my memory yet. nate liked {disc.entry.likes_in_window} thing{disc.entry.likes_in_window === 1 178 + ? '' 179 + : 's'} they wrote, most recently {relativeWhen(disc.entry.last_liked_at)}. 180 + </p> 181 + {#if disc.entry.sample_posts.length} 182 + <div class="block"> 183 + <div class="block-label chrome">what nate liked</div> 184 + {#each disc.entry.sample_posts as post (post.uri)} 185 + <div class="sample"> 186 + <div class="sample-text">{post.text}</div> 187 + <div class="sample-meta faint">{relativeWhen(post.liked_at)}</div> 188 + </div> 189 + {/each} 190 + </div> 191 + {/if} 192 + <div class="actions"> 193 + <ViewIn kind="profile" handle={disc.entry.handle} did={disc.entry.did} /> 194 + </div> 195 + {/if} 196 + 197 + <footer class="chrome faint">a window into phi's experience</footer> 198 + </aside> 199 + {/if} 200 + 201 + <style> 202 + .overlay { 203 + position: fixed; 204 + inset: 0; 205 + background: rgba(0, 0, 0, 0.4); 206 + z-index: 50; 207 + animation: fade 180ms ease-out; 208 + } 209 + 210 + .drawer { 211 + position: fixed; 212 + top: 0; 213 + right: 0; 214 + bottom: 0; 215 + width: min(520px, 92vw); 216 + background: var(--bg-deep); 217 + border-left: 1px solid var(--line-mid); 218 + box-shadow: inset 8px 0 24px rgba(184, 107, 58, 0.06); 219 + z-index: 51; 220 + padding: 22px 26px 26px; 221 + animation: slide 220ms cubic-bezier(0.16, 0.84, 0.3, 1); 222 + display: flex; 223 + flex-direction: column; 224 + gap: 12px; 225 + } 226 + 227 + .drawer::before, 228 + .drawer::after { 229 + content: ''; 230 + position: absolute; 231 + left: -1px; 232 + width: 12px; 233 + height: 12px; 234 + border-color: var(--hud-mid); 235 + border-style: solid; 236 + border-width: 0; 237 + pointer-events: none; 238 + } 239 + 240 + .drawer::before { 241 + top: -1px; 242 + border-top-width: 1px; 243 + border-left-width: 1px; 244 + } 245 + 246 + .drawer::after { 247 + bottom: -1px; 248 + border-bottom-width: 1px; 249 + border-left-width: 1px; 250 + } 251 + 252 + header { 253 + display: flex; 254 + justify-content: space-between; 255 + align-items: center; 256 + padding-bottom: 10px; 257 + border-bottom: 1px solid var(--line-mid); 258 + } 259 + 260 + .kind { 261 + font-size: 10px; 262 + color: var(--scan-mid); 263 + letter-spacing: 0.18em; 264 + } 265 + 266 + .close { 267 + font-size: 9px; 268 + padding: 4px 8px; 269 + } 270 + 271 + h1 { 272 + font-family: var(--font-chrome); 273 + font-weight: 400; 274 + font-size: 22px; 275 + letter-spacing: 0.04em; 276 + color: var(--text); 277 + margin: 0; 278 + } 279 + 280 + h1.mono { 281 + font-family: var(--font-mono); 282 + text-transform: none; 283 + letter-spacing: 0; 284 + font-size: 18px; 285 + } 286 + 287 + h1.chrome { 288 + font-size: 14px; 289 + color: var(--hud-hot); 290 + } 291 + 292 + .title { 293 + font-size: 14px; 294 + color: var(--text); 295 + } 296 + 297 + .content { 298 + font-size: 13px; 299 + line-height: 1.6; 300 + color: var(--text); 301 + white-space: pre-wrap; 302 + word-break: break-word; 303 + } 304 + 305 + .prose { 306 + max-height: 60vh; 307 + overflow-y: auto; 308 + padding-right: 4px; 309 + } 310 + 311 + .block { 312 + border-left: 2px solid var(--line-mid); 313 + padding: 6px 12px; 314 + margin: 4px 0; 315 + } 316 + 317 + .block-label { 318 + font-size: 9px; 319 + color: var(--text-dim); 320 + margin-bottom: 4px; 321 + } 322 + 323 + .meta { 324 + display: flex; 325 + gap: 6px; 326 + flex-wrap: wrap; 327 + align-items: baseline; 328 + font-size: 11px; 329 + margin-top: 6px; 330 + } 331 + 332 + .actions { 333 + display: flex; 334 + gap: 8px; 335 + flex-wrap: wrap; 336 + align-items: center; 337 + margin-top: 8px; 338 + } 339 + 340 + .extlink { 341 + font-size: 11px; 342 + color: var(--scan-mid); 343 + padding: 6px 10px; 344 + border: 1px solid var(--line-dim); 345 + } 346 + 347 + .extlink:hover { 348 + color: var(--scan-hot); 349 + border-color: var(--line-mid); 350 + } 351 + 352 + .sample { 353 + padding: 8px 0; 354 + border-bottom: 1px solid var(--line-dim); 355 + } 356 + 357 + .sample:last-child { 358 + border-bottom: none; 359 + } 360 + 361 + .sample-text { 362 + font-size: 12px; 363 + color: var(--text); 364 + margin-bottom: 4px; 365 + white-space: pre-wrap; 366 + } 367 + 368 + .sample-meta { 369 + font-size: 10px; 370 + } 371 + 372 + footer { 373 + font-size: 9px; 374 + color: var(--text-dim); 375 + padding-top: 10px; 376 + margin-top: auto; 377 + border-top: 1px solid var(--line-dim); 378 + } 379 + 380 + @keyframes fade { 381 + from { 382 + opacity: 0; 383 + } 384 + to { 385 + opacity: 1; 386 + } 387 + } 388 + 389 + @keyframes slide { 390 + from { 391 + transform: translateX(20px); 392 + opacity: 0; 393 + } 394 + to { 395 + transform: translateX(0); 396 + opacity: 1; 397 + } 398 + } 399 + </style>
-254
web/src/lib/components/MemoryGraph.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import * as d3 from 'd3'; 4 - import { getMemoryGraph, PHI_HANDLE } from '$lib/api'; 5 - import type { GraphData, GraphNode } from '$lib/types'; 6 - 7 - let container: HTMLDivElement; 8 - let loading = $state(true); 9 - let err = $state<string | null>(null); 10 - 11 - const RADII: Record<GraphNode['type'], number> = { phi: 14, user: 9 }; 12 - const COLORS: Record<GraphNode['type'], string> = { 13 - phi: 'var(--accent-blue)', 14 - user: 'var(--accent-green)' 15 - }; 16 - 17 - async function fetchAvatars(nodes: GraphNode[]): Promise<Record<string, string>> { 18 - const handles = nodes 19 - .filter((d) => d.type === 'phi' || d.type === 'user') 20 - .map((d) => (d.type === 'phi' ? PHI_HANDLE : d.label.replace(/^@/, ''))) 21 - .filter((h) => h && !h.includes('example')); 22 - if (handles.length === 0) return {}; 23 - const map: Record<string, string> = {}; 24 - for (let i = 0; i < handles.length; i += 25) { 25 - const chunk = handles.slice(i, i + 25); 26 - const params = chunk.map((h) => `actors=${encodeURIComponent(h)}`).join('&'); 27 - try { 28 - const res = await fetch( 29 - `https://typeahead.waow.tech/xrpc/app.bsky.actor.getProfiles?${params}` 30 - ); 31 - if (!res.ok) continue; 32 - const data: { profiles: { handle: string; avatar?: string }[] } = await res.json(); 33 - for (const p of data.profiles) { 34 - if (p.avatar) map[p.handle] = p.avatar; 35 - } 36 - } catch { 37 - /* skip */ 38 - } 39 - } 40 - return map; 41 - } 42 - 43 - function render(data: GraphData, avatars: Record<string, string>) { 44 - const width = container.clientWidth; 45 - const height = container.clientHeight; 46 - const pad = 60; 47 - 48 - type SimNode = GraphNode & 49 - d3.SimulationNodeDatum & { 50 - sx: number; 51 - sy: number; 52 - avatar?: string; 53 - _patternId?: string; 54 - }; 55 - 56 - const nodes: SimNode[] = data.nodes.map((d) => { 57 - const avatar = d.type === 'phi' ? avatars[PHI_HANDLE] : avatars[d.label.replace(/^@/, '')]; 58 - const sx = 59 - d.x != null ? pad + ((d.x + 1) / 2) * (width - 2 * pad) : width / 2; 60 - const sy = 61 - d.y != null ? pad + ((d.y + 1) / 2) * (height - 2 * pad) : height / 2; 62 - return { ...d, x: sx, y: sy, sx, sy, avatar }; 63 - }); 64 - 65 - const tooltip = d3.select(container).append('div').attr('class', 'tooltip'); 66 - 67 - const svg = d3 68 - .select(container) 69 - .append('svg') 70 - .attr('width', width) 71 - .attr('height', height); 72 - 73 - const defs = svg.append('defs'); 74 - const g = svg.append('g'); 75 - let currentZoom: d3.ZoomTransform = d3.zoomIdentity; 76 - 77 - nodes 78 - .filter((n) => n.avatar) 79 - .forEach((n, i) => { 80 - const pid = `avatar-${i}`; 81 - n._patternId = pid; 82 - defs 83 - .append('pattern') 84 - .attr('id', pid) 85 - .attr('width', 1) 86 - .attr('height', 1) 87 - .attr('patternContentUnits', 'objectBoundingBox') 88 - .append('image') 89 - .attr('href', n.avatar!) 90 - .attr('width', 1) 91 - .attr('height', 1) 92 - .attr('preserveAspectRatio', 'xMidYMid slice'); 93 - }); 94 - 95 - const zoom = d3 96 - .zoom<SVGSVGElement, unknown>() 97 - .scaleExtent([0.2, 5]) 98 - .on('zoom', (e) => { 99 - g.attr('transform', e.transform.toString()); 100 - currentZoom = e.transform; 101 - label.attr('font-size', (d) => { 102 - const base = d.type === 'phi' ? 13 : 10; 103 - return base / Math.max(currentZoom.k, 0.5); 104 - }); 105 - }); 106 - 107 - svg.call(zoom); 108 - 109 - const simulation = d3 110 - .forceSimulation(nodes as unknown as d3.SimulationNodeDatum[]) 111 - .force( 112 - 'link', 113 - d3 114 - .forceLink(data.edges) 115 - .id((d: unknown) => (d as SimNode).id) 116 - .distance(40) 117 - ) 118 - .force('charge', d3.forceManyBody().strength(-80)) 119 - .force('x', d3.forceX((d) => (d as SimNode).sx).strength(0.3)) 120 - .force('y', d3.forceY((d) => (d as SimNode).sy).strength(0.3)) 121 - .force( 122 - 'collision', 123 - d3.forceCollide().radius((d) => RADII[(d as SimNode).type] + 4) 124 - ); 125 - 126 - const link = g 127 - .append('g') 128 - .selectAll('line') 129 - .data(data.edges) 130 - .join('line') 131 - .attr('stroke', 'var(--border-dim)') 132 - .attr('stroke-width', 1) 133 - .attr('stroke-opacity', 0.5); 134 - 135 - const node = g 136 - .append('g') 137 - .selectAll('circle') 138 - .data(nodes) 139 - .join('circle') 140 - .attr('r', (d) => RADII[d.type]) 141 - .attr('fill', (d) => (d._patternId ? `url(#${d._patternId})` : COLORS[d.type])) 142 - .attr('stroke', (d) => (d._patternId ? COLORS[d.type] : 'var(--bg)')) 143 - .attr('stroke-width', (d) => (d._patternId ? 2 : 1.5)) 144 - .style('cursor', 'grab') 145 - .on('mouseover', (_, d) => { 146 - tooltip 147 - .style('opacity', 1) 148 - .html( 149 - `<strong>${d.label}</strong><br><span style="color:${COLORS[d.type]}">${d.type}</span>` 150 - ); 151 - }) 152 - .on('mousemove', (e: MouseEvent) => { 153 - tooltip.style('left', `${e.pageX + 12}px`).style('top', `${e.pageY - 12}px`); 154 - }) 155 - .on('mouseout', () => tooltip.style('opacity', 0)); 156 - 157 - const drag = d3 158 - .drag<SVGCircleElement, SimNode>() 159 - .on('start', (e, d) => { 160 - if (!e.active) simulation.alphaTarget(0.3).restart(); 161 - d.fx = d.x; 162 - d.fy = d.y; 163 - }) 164 - .on('drag', (e, d) => { 165 - d.fx = e.x; 166 - d.fy = e.y; 167 - }) 168 - .on('end', (e, d) => { 169 - if (!e.active) simulation.alphaTarget(0); 170 - d.fx = null; 171 - d.fy = null; 172 - }); 173 - 174 - (node as d3.Selection<SVGCircleElement, SimNode, SVGGElement, unknown>).call(drag); 175 - 176 - const label = g 177 - .append('g') 178 - .selectAll('text') 179 - .data(nodes) 180 - .join('text') 181 - .text((d) => d.label) 182 - .attr('font-size', (d) => (d.type === 'phi' ? 13 : 10)) 183 - .attr('font-family', "'SF Mono', monospace") 184 - .attr('fill', 'var(--text-muted)') 185 - .attr('text-anchor', 'middle') 186 - .attr('dy', (d) => RADII[d.type] + 14); 187 - 188 - simulation.on('tick', () => { 189 - link 190 - .attr('x1', (d) => (d.source as unknown as SimNode).x!) 191 - .attr('y1', (d) => (d.source as unknown as SimNode).y!) 192 - .attr('x2', (d) => (d.target as unknown as SimNode).x!) 193 - .attr('y2', (d) => (d.target as unknown as SimNode).y!); 194 - node.attr('cx', (d) => d.x!).attr('cy', (d) => d.y!); 195 - label.attr('x', (d) => d.x!).attr('y', (d) => d.y!); 196 - }); 197 - } 198 - 199 - onMount(async () => { 200 - try { 201 - const data = await getMemoryGraph(); 202 - loading = false; 203 - if (data.nodes.length === 0) return; 204 - const avatars = await fetchAvatars(data.nodes); 205 - render(data, avatars); 206 - } catch (e) { 207 - err = (e as Error).message; 208 - loading = false; 209 - } 210 - }); 211 - </script> 212 - 213 - <div class="wrap" bind:this={container}> 214 - {#if loading} 215 - <div class="overlay faint">loading graph…</div> 216 - {:else if err} 217 - <div class="overlay faint">failed to load: {err}</div> 218 - {/if} 219 - </div> 220 - 221 - <style> 222 - .wrap { 223 - position: relative; 224 - width: 100%; 225 - height: 70vh; 226 - background: var(--bg); 227 - border: 1px solid var(--border); 228 - border-radius: 8px; 229 - overflow: hidden; 230 - } 231 - 232 - .overlay { 233 - position: absolute; 234 - inset: 0; 235 - display: flex; 236 - align-items: center; 237 - justify-content: center; 238 - font-size: 13px; 239 - } 240 - 241 - :global(.tooltip) { 242 - position: absolute; 243 - padding: 8px 12px; 244 - background: var(--bg-elev); 245 - border: 1px solid var(--border); 246 - border-radius: 6px; 247 - font-size: 13px; 248 - pointer-events: none; 249 - opacity: 0; 250 - color: var(--text); 251 - max-width: 280px; 252 - z-index: 100; 253 - } 254 - </style>
-56
web/src/lib/components/Nav.svelte
··· 1 - <script lang="ts"> 2 - import { page } from '$app/state'; 3 - 4 - const links = [ 5 - { href: '/', label: 'phi' }, 6 - { href: '/activity', label: 'activity' }, 7 - { href: '/mind', label: 'mind' }, 8 - { href: '/blog', label: 'blog' }, 9 - { href: '/discovery', label: 'discovery' }, 10 - { href: '/skills', label: 'skills' }, 11 - { href: '/status', label: 'status' } 12 - ]; 13 - </script> 14 - 15 - <nav> 16 - <div class="nav-inner"> 17 - {#each links as link (link.href)} 18 - {@const active = 19 - link.href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(link.href)} 20 - <a href={link.href} class:active>{link.label}</a> 21 - {/each} 22 - </div> 23 - </nav> 24 - 25 - <style> 26 - nav { 27 - border-bottom: 1px solid var(--border); 28 - background: var(--bg); 29 - position: sticky; 30 - top: 0; 31 - z-index: 10; 32 - } 33 - 34 - .nav-inner { 35 - max-width: 760px; 36 - margin: 0 auto; 37 - padding: 12px 20px; 38 - display: flex; 39 - gap: 18px; 40 - font-size: 13px; 41 - flex-wrap: wrap; 42 - } 43 - 44 - a { 45 - color: var(--text-muted); 46 - text-decoration: none; 47 - } 48 - 49 - a:hover { 50 - color: var(--text); 51 - } 52 - 53 - a.active { 54 - color: var(--accent-blue); 55 - } 56 - </style>
-63
web/src/lib/components/ObservationCard.svelte
··· 1 - <script lang="ts"> 2 - import type { Observation } from '$lib/types'; 3 - import { relativeWhen } from '$lib/time'; 4 - 5 - interface Props { 6 - observation: Observation; 7 - } 8 - 9 - let { observation }: Props = $props(); 10 - 11 - const age = $derived(relativeWhen(observation.created_at)); 12 - </script> 13 - 14 - <div class="card"> 15 - <div class="content">{observation.content}</div> 16 - {#if observation.reasoning} 17 - <div class="reasoning"> 18 - <span class="label">reasoning</span> 19 - <span>{observation.reasoning}</span> 20 - </div> 21 - {/if} 22 - <div class="meta faint"> 23 - {#if age}<span>{age}</span>{/if} 24 - <span class="mono">rkey {observation.rkey}</span> 25 - </div> 26 - </div> 27 - 28 - <style> 29 - .card { 30 - border-left-color: var(--accent-yellow); 31 - } 32 - 33 - .content { 34 - font-size: 14px; 35 - color: var(--text); 36 - margin-bottom: 8px; 37 - white-space: pre-wrap; 38 - } 39 - 40 - .reasoning { 41 - font-size: 13px; 42 - color: var(--text-muted); 43 - margin-bottom: 8px; 44 - padding: 6px 10px; 45 - background: rgba(210, 153, 34, 0.06); 46 - border-radius: 4px; 47 - } 48 - 49 - .label { 50 - display: inline-block; 51 - text-transform: uppercase; 52 - letter-spacing: 0.4px; 53 - font-size: 10px; 54 - color: var(--accent-yellow); 55 - margin-right: 8px; 56 - } 57 - 58 - .meta { 59 - font-size: 11px; 60 - display: flex; 61 - gap: 12px; 62 - } 63 - </style>
-123
web/src/lib/components/PostCard.svelte
··· 1 - <script lang="ts"> 2 - import type { ActivityItem } from '$lib/types'; 3 - import { relativeWhen } from '$lib/time'; 4 - 5 - interface Props { 6 - item: ActivityItem; 7 - } 8 - 9 - let { item }: Props = $props(); 10 - 11 - const ACCENT: Record<ActivityItem['type'], string> = { 12 - post: 'var(--accent-blue)', 13 - note: 'var(--accent-purple)', 14 - url: 'var(--accent-green)' 15 - }; 16 - 17 - const LABEL: Record<ActivityItem['type'], string> = { 18 - post: 'bluesky', 19 - note: 'note', 20 - url: 'bookmark' 21 - }; 22 - 23 - function linkify(text: string): string { 24 - return text.replace( 25 - /(https?:\/\/[^\s<>"]+)/g, 26 - '<a href="$1" target="_blank" rel="noopener">$1</a>' 27 - ); 28 - } 29 - 30 - function getDomain(url: string | null | undefined): string { 31 - if (!url) return ''; 32 - try { 33 - return new URL(url).hostname.replace(/^www\./, ''); 34 - } catch { 35 - return ''; 36 - } 37 - } 38 - 39 - function viewUrl(item: ActivityItem): string { 40 - if (item.url) return item.url; 41 - if (item.uri.startsWith('at://')) return `https://pds.ls/${item.uri}`; 42 - return ''; 43 - } 44 - 45 - const accent = $derived(ACCENT[item.type]); 46 - const label = $derived(LABEL[item.type]); 47 - const age = $derived(relativeWhen(item.time)); 48 - const domain = $derived(item.type === 'url' ? getDomain(item.url) : ''); 49 - const link = $derived(viewUrl(item)); 50 - const text = $derived(item.text.length > 300 ? item.text.slice(0, 300) + '…' : item.text); 51 - </script> 52 - 53 - <div class="card" style="border-left-color: {accent}"> 54 - <div class="header"> 55 - <span class="type" style="color: {accent}">{label}</span> 56 - </div> 57 - {#if domain} 58 - <div class="domain"> 59 - <a href={item.url} target="_blank" rel="noopener">{domain}</a> 60 - </div> 61 - {/if} 62 - {#if item.title} 63 - <div class="title">{item.title}</div> 64 - {/if} 65 - <!-- eslint-disable-next-line svelte/no-at-html-tags --> 66 - <div class="text">{@html linkify(text)}</div> 67 - <div class="meta faint"> 68 - {#if age}<span>{age}</span>{/if} 69 - {#if link} 70 - <span>·</span> 71 - <a href={link} target="_blank" rel="noopener">view</a> 72 - {/if} 73 - </div> 74 - </div> 75 - 76 - <style> 77 - .header { 78 - margin-bottom: 6px; 79 - } 80 - 81 - .type { 82 - font-size: 11px; 83 - text-transform: uppercase; 84 - letter-spacing: 0.5px; 85 - } 86 - 87 - .domain { 88 - font-size: 12px; 89 - margin-bottom: 6px; 90 - } 91 - 92 - .domain a { 93 - color: var(--text-muted); 94 - } 95 - 96 - .title { 97 - font-size: 14px; 98 - color: var(--text); 99 - margin-bottom: 4px; 100 - } 101 - 102 - .text { 103 - font-size: 14px; 104 - line-height: 1.5; 105 - margin-bottom: 8px; 106 - white-space: pre-wrap; 107 - word-break: break-word; 108 - } 109 - 110 - .meta { 111 - font-size: 12px; 112 - display: flex; 113 - gap: 6px; 114 - } 115 - 116 - .meta a { 117 - color: var(--text-faint); 118 - } 119 - 120 - .meta a:hover { 121 - color: var(--text-muted); 122 - } 123 - </style>
-46
web/src/lib/components/StatusPill.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import { getHealth } from '$lib/api'; 4 - import type { HealthInfo } from '$lib/types'; 5 - 6 - let health = $state<HealthInfo | null>(null); 7 - let failed = $state(false); 8 - 9 - onMount(async () => { 10 - try { 11 - health = await getHealth(); 12 - } catch { 13 - failed = true; 14 - } 15 - }); 16 - 17 - const status = $derived.by(() => { 18 - if (failed) return { color: 'var(--accent-red)', text: 'unreachable' }; 19 - if (!health) return { color: 'var(--text-faint)', text: '…' }; 20 - if (health.paused) return { color: 'var(--accent-yellow)', text: 'paused' }; 21 - if (health.polling_active) return { color: 'var(--accent-green)', text: 'online' }; 22 - return { color: 'var(--text-faint)', text: 'offline' }; 23 - }); 24 - </script> 25 - 26 - <span class="pill"> 27 - <span class="dot" style="background: {status.color}"></span> 28 - <span class="text">{status.text}</span> 29 - </span> 30 - 31 - <style> 32 - .pill { 33 - display: inline-flex; 34 - align-items: center; 35 - gap: 6px; 36 - font-size: 12px; 37 - color: var(--text-muted); 38 - } 39 - 40 - .dot { 41 - display: inline-block; 42 - width: 8px; 43 - height: 8px; 44 - border-radius: 50%; 45 - } 46 - </style>
+322
web/src/lib/components/ViewIn.svelte
··· 1 + <script lang="ts"> 2 + /** 3 + * Open a record in the user's preferred atproto client. 4 + * 5 + * Inspired by pdsls's record-link pattern 6 + * (https://tangled.org/pds.ls/pdsls). Click the favicon to open in the 7 + * remembered viewer; click the chevron to switch viewers (choice is 8 + * persisted in localStorage per kind). 9 + * 10 + * If only one viewer is available for the given kind, the chevron is 11 + * suppressed. 12 + */ 13 + 14 + import { onMount, onDestroy } from 'svelte'; 15 + import { 16 + VIEWERS_BY_KIND, 17 + defaultViewer, 18 + setStoredViewer, 19 + type ViewKind, 20 + type Viewer 21 + } from '$lib/clients'; 22 + 23 + interface Props { 24 + kind: ViewKind; 25 + handle: string; 26 + did?: string; 27 + collection?: string; 28 + rkey?: string; 29 + } 30 + 31 + let { kind, handle, did, collection, rkey }: Props = $props(); 32 + 33 + let chosen = $state<Viewer | null>(null); 34 + let menuOpen = $state(false); 35 + 36 + const viewers = $derived(VIEWERS_BY_KIND[kind]); 37 + 38 + onMount(() => { 39 + chosen = defaultViewer(kind); 40 + }); 41 + 42 + function pick(v: Viewer) { 43 + chosen = v; 44 + setStoredViewer(kind, v.id); 45 + menuOpen = false; 46 + } 47 + 48 + function favicon(domain: string): string { 49 + // Direct favicon — most clients have one. If it 404s the fallback monogram shows. 50 + return `https://${domain}/favicon.ico`; 51 + } 52 + 53 + function monogram(label: string): string { 54 + return label.slice(0, 2).toUpperCase(); 55 + } 56 + 57 + function onDocClick(e: MouseEvent) { 58 + const t = e.target as HTMLElement; 59 + if (t.closest('.viewin-host')) return; 60 + menuOpen = false; 61 + } 62 + 63 + $effect(() => { 64 + if (menuOpen) { 65 + document.addEventListener('click', onDocClick); 66 + return () => document.removeEventListener('click', onDocClick); 67 + } 68 + }); 69 + 70 + onDestroy(() => { 71 + document.removeEventListener('click', onDocClick); 72 + }); 73 + 74 + const link = $derived( 75 + chosen ? chosen.url({ handle, did, collection, rkey }) : '' 76 + ); 77 + </script> 78 + 79 + {#if chosen} 80 + <div class="viewin-host"> 81 + <a class="primary" href={link} target="_blank" rel="noopener" title="open in {chosen.label}"> 82 + <span class="favwrap"> 83 + <img 84 + class="fav" 85 + src={favicon(chosen.domain)} 86 + alt="" 87 + onerror={(e) => { 88 + (e.currentTarget as HTMLImageElement).style.display = 'none'; 89 + const sib = (e.currentTarget as HTMLImageElement).nextElementSibling as HTMLElement; 90 + if (sib) sib.style.display = 'flex'; 91 + }} 92 + /> 93 + <span class="mono-fb chrome">{monogram(chosen.label)}</span> 94 + </span> 95 + <span class="lbl chrome">view in {chosen.label}</span> 96 + <span class="arrow">↗</span> 97 + </a> 98 + {#if viewers.length > 1} 99 + <button 100 + class="cycle" 101 + aria-label="switch viewer" 102 + onclick={(e) => { 103 + e.stopPropagation(); 104 + menuOpen = !menuOpen; 105 + }}>⌃</button 106 + > 107 + {#if menuOpen} 108 + <div class="menu" role="menu"> 109 + {#each viewers as v (v.id)} 110 + <button 111 + class="opt" 112 + class:active={v.id === chosen.id} 113 + onclick={() => pick(v)} 114 + role="menuitem" 115 + > 116 + <span class="favwrap small"> 117 + <img 118 + class="fav" 119 + src={favicon(v.domain)} 120 + alt="" 121 + onerror={(e) => { 122 + (e.currentTarget as HTMLImageElement).style.display = 'none'; 123 + const sib = (e.currentTarget as HTMLImageElement) 124 + .nextElementSibling as HTMLElement; 125 + if (sib) sib.style.display = 'flex'; 126 + }} 127 + /> 128 + <span class="mono-fb chrome">{monogram(v.label)}</span> 129 + </span> 130 + <span class="opt-lbl">{v.label}</span> 131 + {#if v.id === chosen.id} 132 + <span class="check">●</span> 133 + {/if} 134 + </button> 135 + {/each} 136 + <div class="credit chrome"> 137 + chooser modeled after <a href="https://tangled.org/pds.ls/pdsls" target="_blank" rel="noopener">pdsls</a> 138 + </div> 139 + </div> 140 + {/if} 141 + {/if} 142 + </div> 143 + {/if} 144 + 145 + <style> 146 + .viewin-host { 147 + position: relative; 148 + display: inline-flex; 149 + align-items: stretch; 150 + } 151 + 152 + .primary { 153 + display: inline-flex; 154 + align-items: center; 155 + gap: 8px; 156 + padding: 6px 10px; 157 + background: var(--bg-elev); 158 + border: 1px solid var(--line-mid); 159 + color: var(--text); 160 + text-decoration: none; 161 + transition: 162 + border-color 0.12s, 163 + color 0.12s; 164 + font-size: 11px; 165 + clip-path: polygon( 166 + 5px 0, 167 + 100% 0, 168 + 100% calc(100% - 5px), 169 + calc(100% - 5px) 100%, 170 + 0 100%, 171 + 0 5px 172 + ); 173 + } 174 + 175 + .primary:hover { 176 + color: var(--hud-hot); 177 + border-color: var(--hud-mid); 178 + } 179 + 180 + .lbl { 181 + font-size: 10px; 182 + letter-spacing: 0.1em; 183 + } 184 + 185 + .arrow { 186 + font-size: 11px; 187 + color: var(--text-dim); 188 + } 189 + 190 + .cycle { 191 + padding: 0 8px; 192 + background: var(--bg-elev); 193 + border: 1px solid var(--line-mid); 194 + border-left: none; 195 + color: var(--text-dim); 196 + font-family: var(--font-mono); 197 + font-size: 11px; 198 + min-height: 32px; 199 + min-width: 28px; 200 + cursor: pointer; 201 + transition: 202 + color 0.12s, 203 + border-color 0.12s; 204 + } 205 + 206 + .cycle:hover { 207 + color: var(--hud-hot); 208 + border-color: var(--hud-mid); 209 + } 210 + 211 + .favwrap { 212 + position: relative; 213 + width: 14px; 214 + height: 14px; 215 + display: inline-flex; 216 + align-items: center; 217 + justify-content: center; 218 + flex-shrink: 0; 219 + } 220 + 221 + .favwrap.small { 222 + width: 12px; 223 + height: 12px; 224 + } 225 + 226 + .fav { 227 + width: 100%; 228 + height: 100%; 229 + object-fit: contain; 230 + display: block; 231 + } 232 + 233 + .mono-fb { 234 + display: none; 235 + position: absolute; 236 + inset: 0; 237 + align-items: center; 238 + justify-content: center; 239 + font-size: 8px; 240 + color: var(--text-dim); 241 + border: 1px solid var(--line-dim); 242 + } 243 + 244 + .menu { 245 + position: absolute; 246 + top: calc(100% + 4px); 247 + right: 0; 248 + background: var(--bg-deep); 249 + border: 1px solid var(--line-mid); 250 + min-width: 180px; 251 + z-index: 60; 252 + display: flex; 253 + flex-direction: column; 254 + padding: 4px; 255 + animation: fadeIn 120ms ease-out; 256 + } 257 + 258 + .opt { 259 + display: flex; 260 + align-items: center; 261 + gap: 8px; 262 + padding: 6px 8px; 263 + background: transparent; 264 + border: none; 265 + color: var(--text); 266 + text-transform: none; 267 + letter-spacing: 0; 268 + font-size: 12px; 269 + font-family: var(--font-content); 270 + cursor: pointer; 271 + text-align: left; 272 + } 273 + 274 + .opt:hover { 275 + background: rgba(184, 107, 58, 0.06); 276 + color: var(--hud-hot); 277 + } 278 + 279 + .opt.active { 280 + color: var(--hud-hot); 281 + } 282 + 283 + .check { 284 + margin-left: auto; 285 + font-size: 8px; 286 + color: var(--hud-hot); 287 + } 288 + 289 + .opt-lbl { 290 + flex: 1; 291 + } 292 + 293 + .credit { 294 + font-size: 8px; 295 + color: var(--text-dim); 296 + padding: 6px 8px 4px; 297 + border-top: 1px solid var(--line-dim); 298 + margin-top: 2px; 299 + text-transform: none; 300 + letter-spacing: 0; 301 + font-family: var(--font-content); 302 + } 303 + 304 + .credit a { 305 + color: var(--text-mid); 306 + } 307 + 308 + .credit a:hover { 309 + color: var(--scan-mid); 310 + } 311 + 312 + @keyframes fadeIn { 313 + from { 314 + opacity: 0; 315 + transform: translateY(-4px); 316 + } 317 + to { 318 + opacity: 1; 319 + transform: translateY(0); 320 + } 321 + } 322 + </style>
+28
web/src/lib/state.svelte.ts
··· 1 + /** 2 + * Shared cross-component state for the cockpit chrome. 3 + * 4 + * - hudReadout: short text shown in the bottom-right chrome (the "scan" line). 5 + * Lenses set this on hover; clearing it on mouseout returns to "idle". 6 + * 7 + * - logbook: when set, slides in a drawer with full record details. Clicking 8 + * a point sets it; close button clears it. 9 + * 10 + * Using rune .svelte.ts module — fields are reactive everywhere imported. 11 + */ 12 + 13 + import type { LogbookEntry } from './types'; 14 + 15 + function cell<T>(initial: T) { 16 + let v = $state(initial); 17 + return { 18 + get value(): T { 19 + return v; 20 + }, 21 + set(next: T) { 22 + v = next; 23 + } 24 + }; 25 + } 26 + 27 + export const hudReadout = cell<string>(''); 28 + export const logbook = cell<LogbookEntry | null>(null);
+45
web/src/lib/types.ts
··· 86 86 paused: boolean; 87 87 } 88 88 89 + // --- /api/abilities --- 90 + 91 + export interface Capability { 92 + name: string; 93 + description: string; 94 + operator_only: boolean; 95 + } 96 + 89 97 // --- bsky public API minimal types (used by feed/blog) --- 90 98 91 99 export interface BskyAuthor { ··· 118 126 post: BskyPost; 119 127 reply?: { parent?: { author?: BskyAuthor; record?: BskyPostRecord; uri?: string } }; 120 128 } 129 + 130 + // --- cockpit / hud --- 131 + 132 + /** 133 + * AtlasPoint is the unifying primitive across the mind lens. Every "object of 134 + * phi's attention" — concept-shaped (observation, goal) and people-shaped 135 + * (engaged, candidate) — becomes a point with a kind, a position, and a 136 + * payload that becomes the logbook entry on click. 137 + */ 138 + export type AtlasKind = 139 + | 'phi' 140 + | 'handle-engaged' 141 + | 'handle-candidate' 142 + | 'observation' 143 + | 'goal'; 144 + 145 + export interface AtlasPoint { 146 + id: string; 147 + kind: AtlasKind; 148 + label: string; // 1-line for hover readout 149 + x: number; // normalized -1..1 (canvas scales) 150 + y: number; // normalized -1..1 151 + avatar?: string | null; 152 + payload: unknown; // pulled from the underlying record; logbook renders it 153 + } 154 + 155 + /** 156 + * Logbook entries are what slide in from the right when you click a thing. 157 + * The kind drives the renderer; the payload is the matching record shape. 158 + */ 159 + export type LogbookEntry = 160 + | { kind: 'handle'; handle: string; did?: string; engaged: boolean; payload: unknown } 161 + | { kind: 'observation'; observation: Observation } 162 + | { kind: 'goal'; goal: Goal } 163 + | { kind: 'activity'; item: ActivityItem } 164 + | { kind: 'blog'; doc: BlogDoc } 165 + | { kind: 'discovery'; entry: DiscoveryEntry };
+7 -3
web/src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 - import Nav from '$lib/components/Nav.svelte'; 3 + import Hud from '$lib/components/Hud.svelte'; 4 4 5 5 interface Props { 6 6 children?: import('svelte').Snippet; ··· 9 9 let { children }: Props = $props(); 10 10 </script> 11 11 12 - <Nav /> 13 - {@render children?.()} 12 + <div class="cockpit"> 13 + <div class="viewport"> 14 + {@render children?.()} 15 + </div> 16 + <Hud /> 17 + </div>
+244 -115
web/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 - import StatusPill from '$lib/components/StatusPill.svelte'; 4 - import GoalCard from '$lib/components/GoalCard.svelte'; 5 - import ObservationCard from '$lib/components/ObservationCard.svelte'; 6 - import PostCard from '$lib/components/PostCard.svelte'; 7 - import { getActivity, getActiveObservations, getGoals, PHI_HANDLE } from '$lib/api'; 8 - import type { ActivityItem, Goal, Observation } from '$lib/types'; 3 + import Atlas from '$lib/components/Atlas.svelte'; 4 + import Logbook from '$lib/components/Logbook.svelte'; 5 + import { 6 + getMemoryGraph, 7 + getDiscoveryPool, 8 + getActiveObservations, 9 + getGoals, 10 + PHI_HANDLE 11 + } from '$lib/api'; 12 + import type { AtlasPoint, LogbookEntry, GraphNode, DiscoveryEntry, Observation, Goal } from '$lib/types'; 9 13 10 - let goals = $state<Goal[]>([]); 11 - let observations = $state<Observation[]>([]); 12 - let recent = $state<ActivityItem[]>([]); 14 + let points = $state<AtlasPoint[]>([]); 15 + let edges = $state<{ source: string; target: string }[]>([]); 13 16 let loaded = $state(false); 17 + let err = $state<string | null>(null); 18 + 19 + async function fetchAvatars(handles: string[]): Promise<Record<string, string>> { 20 + const map: Record<string, string> = {}; 21 + const filtered = handles.filter((h) => h && !h.includes('example')); 22 + for (let i = 0; i < filtered.length; i += 25) { 23 + const chunk = filtered.slice(i, i + 25); 24 + const params = chunk.map((h) => `actors=${encodeURIComponent(h)}`).join('&'); 25 + try { 26 + const res = await fetch( 27 + `https://typeahead.waow.tech/xrpc/app.bsky.actor.getProfiles?${params}` 28 + ); 29 + if (!res.ok) continue; 30 + const data: { profiles: { handle: string; avatar?: string }[] } = await res.json(); 31 + for (const p of data.profiles) if (p.avatar) map[p.handle] = p.avatar; 32 + } catch { 33 + /* skip */ 34 + } 35 + } 36 + return map; 37 + } 38 + 39 + function entryFor(p: AtlasPoint): LogbookEntry { 40 + if (p.kind === 'handle-engaged' || p.kind === 'handle-candidate') { 41 + const pl = p.payload as { handle: string; did?: string }; 42 + return { 43 + kind: 'handle', 44 + handle: pl.handle, 45 + did: pl.did, 46 + engaged: p.kind === 'handle-engaged', 47 + payload: pl 48 + }; 49 + } 50 + if (p.kind === 'observation') return { kind: 'observation', observation: p.payload as Observation }; 51 + if (p.kind === 'goal') return { kind: 'goal', goal: p.payload as Goal }; 52 + // phi has no logbook (own self) 53 + return { kind: 'handle', handle: PHI_HANDLE, engaged: true, payload: {} }; 54 + } 55 + 56 + function jitter(seed: string, amplitude: number): [number, number] { 57 + // deterministic position based on string hash, in [-amp, amp] 58 + let h1 = 2166136261; 59 + let h2 = 5381; 60 + for (let i = 0; i < seed.length; i++) { 61 + h1 ^= seed.charCodeAt(i); 62 + h1 = Math.imul(h1, 16777619); 63 + h2 = ((h2 << 5) + h2 + seed.charCodeAt(i)) | 0; 64 + } 65 + const a = ((h1 >>> 0) % 10000) / 10000; // 0..1 66 + const b = ((h2 >>> 0) % 10000) / 10000; 67 + const angle = a * Math.PI * 2; 68 + const r = b * amplitude; 69 + return [Math.cos(angle) * r, Math.sin(angle) * r]; 70 + } 14 71 15 72 onMount(async () => { 16 - const [g, o, a] = await Promise.allSettled([ 17 - getGoals(), 18 - getActiveObservations(), 19 - getActivity() 20 - ]); 21 - if (g.status === 'fulfilled') goals = g.value; 22 - if (o.status === 'fulfilled') observations = o.value; 23 - if (a.status === 'fulfilled') recent = a.value.slice(0, 5); 24 - loaded = true; 73 + try { 74 + const [graphR, discR, obsR, goalsR] = await Promise.allSettled([ 75 + getMemoryGraph(), 76 + getDiscoveryPool(), 77 + getActiveObservations(), 78 + getGoals() 79 + ]); 80 + 81 + const handles: string[] = [PHI_HANDLE]; 82 + const pts: AtlasPoint[] = []; 83 + 84 + if (graphR.status === 'fulfilled') { 85 + const data = graphR.value; 86 + for (const n of data.nodes as GraphNode[]) { 87 + if (n.type === 'phi') { 88 + pts.push({ 89 + id: n.id, 90 + kind: 'phi', 91 + label: 'phi', 92 + x: 0, 93 + y: 0, 94 + payload: {} 95 + }); 96 + } else { 97 + const handle = n.label.replace(/^@/, ''); 98 + handles.push(handle); 99 + pts.push({ 100 + id: n.id, 101 + kind: 'handle-engaged', 102 + label: n.label, 103 + x: n.x ?? 0, 104 + y: n.y ?? 0, 105 + payload: { handle } 106 + }); 107 + } 108 + } 109 + edges = data.edges.map((e) => ({ source: e.source, target: e.target })); 110 + } 111 + 112 + if (discR.status === 'fulfilled') { 113 + const cands = discR.value as DiscoveryEntry[]; 114 + const engagedHandles = new Set( 115 + pts.filter((p) => p.kind === 'handle-engaged').map((p) => (p.payload as { handle: string }).handle) 116 + ); 117 + for (const c of cands) { 118 + if (engagedHandles.has(c.handle)) continue; // already in graph 119 + handles.push(c.handle); 120 + // position candidates on the periphery (radius ~0.85 from center) 121 + const [jx, jy] = jitter(c.handle, 0.85); 122 + pts.push({ 123 + id: `cand-${c.did}`, 124 + kind: 'handle-candidate', 125 + label: `@${c.handle}`, 126 + x: jx, 127 + y: jy, 128 + payload: { handle: c.handle, did: c.did, entry: c } 129 + }); 130 + } 131 + } 132 + 133 + if (obsR.status === 'fulfilled') { 134 + const obs = obsR.value as Observation[]; 135 + for (const o of obs) { 136 + const [jx, jy] = jitter(o.rkey, 0.45); 137 + pts.push({ 138 + id: `obs-${o.rkey}`, 139 + kind: 'observation', 140 + label: o.content, 141 + x: jx, 142 + y: jy, 143 + payload: o 144 + }); 145 + } 146 + } 147 + 148 + if (goalsR.status === 'fulfilled') { 149 + const goals = goalsR.value as Goal[]; 150 + for (const g of goals) { 151 + const [jx, jy] = jitter(g.rkey, 0.65); 152 + pts.push({ 153 + id: `goal-${g.rkey}`, 154 + kind: 'goal', 155 + label: g.title, 156 + x: jx, 157 + y: jy, 158 + payload: g 159 + }); 160 + } 161 + } 162 + 163 + // load avatars and attach to points 164 + const avatars = await fetchAvatars(handles); 165 + for (const p of pts) { 166 + if (p.kind === 'handle-engaged' || p.kind === 'handle-candidate' || p.kind === 'phi') { 167 + const handle = 168 + p.kind === 'phi' 169 + ? PHI_HANDLE 170 + : (p.payload as { handle: string }).handle; 171 + p.avatar = avatars[handle] ?? null; 172 + } 173 + } 174 + 175 + points = pts; 176 + loaded = true; 177 + } catch (e) { 178 + err = (e as Error).message; 179 + loaded = true; 180 + } 25 181 }); 26 182 </script> 27 183 28 - <div class="container"> 29 - <header> 30 - <h1>phi</h1> 31 - <div class="sub"> 32 - <StatusPill /> 33 - <span>·</span> 34 - <a href="https://bsky.app/profile/{PHI_HANDLE}" target="_blank" rel="noopener" 35 - >@{PHI_HANDLE}</a 36 - > 37 - </div> 38 - <p class="desc muted"> 39 - a bluesky bot. small attention pool, durable goals, episodic memory, scout for community 40 - infrastructure. 41 - </p> 42 - </header> 184 + <svelte:head> 185 + <title>phi · mind</title> 186 + </svelte:head> 43 187 44 - <section> 45 - <h2>active observations</h2> 46 - <p class="hint faint"> 47 - what phi is currently attending to. small set, mutates often, archived after. 48 - </p> 49 - {#if !loaded} 50 - <p class="faint">loading…</p> 51 - {:else if observations.length === 0} 52 - <p class="faint">nothing in the active pool right now.</p> 53 - {:else} 54 - {#each observations as obs (obs.rkey)} 55 - <ObservationCard observation={obs} /> 56 - {/each} 57 - {/if} 58 - </section> 188 + <div class="lens"> 189 + {#if !loaded} 190 + <div class="overlay chrome muted">acquiring map…</div> 191 + {:else if err} 192 + <div class="overlay chrome muted">signal lost · {err}</div> 193 + {:else if points.length === 0} 194 + <div class="overlay chrome muted">empty map · no objects in attention</div> 195 + {:else} 196 + <Atlas {points} {edges} {entryFor} /> 197 + {/if} 59 198 60 - <section> 61 - <h2>goals</h2> 62 - <p class="hint faint">durable anchors. mutated through owner-approval (like-as-auth gate).</p> 63 - {#if !loaded} 64 - <p class="faint">loading…</p> 65 - {:else if goals.length === 0} 66 - <p class="faint">no goals set.</p> 67 - {:else} 68 - {#each goals as goal (goal.rkey)} 69 - <GoalCard {goal} /> 70 - {/each} 71 - {/if} 72 - </section> 199 + <div class="legend chrome"> 200 + <span class="li"><span class="hex" style="color: var(--hud-hot)"></span>self</span> 201 + <span class="li"><span class="hex" style="color: var(--text)"></span>in memory</span> 202 + <span class="li" 203 + ><span class="hex ring" style="color: var(--text-dim)"></span>on radar</span 204 + > 205 + <span class="li"><span class="hex" style="color: var(--scan-mid)"></span>attention</span> 206 + <span class="li"><span class="hex" style="color: var(--warn)"></span>goal</span> 207 + </div> 208 + </div> 73 209 74 - <section> 75 - <div class="section-header"> 76 - <h2>recent activity</h2> 77 - <a href="/activity" class="more">see all →</a> 78 - </div> 79 - {#if !loaded} 80 - <p class="faint">loading…</p> 81 - {:else if recent.length === 0} 82 - <p class="faint">nothing recent.</p> 83 - {:else} 84 - {#each recent as item (item.uri)} 85 - <PostCard {item} /> 86 - {/each} 87 - {/if} 88 - </section> 89 - </div> 210 + <Logbook /> 90 211 91 212 <style> 92 - header { 93 - margin-bottom: 36px; 213 + .lens { 214 + position: absolute; 215 + inset: 0; 94 216 } 95 217 96 - .sub { 218 + .overlay { 219 + position: absolute; 220 + inset: 0; 97 221 display: flex; 98 222 align-items: center; 99 - gap: 8px; 100 - font-size: 13px; 101 - color: var(--text-muted); 102 - margin-top: 4px; 223 + justify-content: center; 224 + font-size: 11px; 225 + color: var(--text-mid); 226 + letter-spacing: 0.18em; 103 227 } 104 228 105 - .desc { 106 - font-size: 14px; 107 - line-height: 1.6; 108 - margin-top: 16px; 109 - max-width: 540px; 229 + .legend { 230 + position: absolute; 231 + bottom: 60px; 232 + left: 50%; 233 + transform: translateX(-50%); 234 + display: flex; 235 + gap: 18px; 236 + font-size: 10px; 237 + color: var(--text-dim); 238 + background: var(--bg-panel); 239 + border: 1px solid var(--line-mid); 240 + backdrop-filter: blur(8px); 241 + -webkit-backdrop-filter: blur(8px); 242 + padding: 8px 14px; 243 + pointer-events: none; 244 + clip-path: polygon( 245 + 6px 0, 246 + 100% 0, 247 + 100% calc(100% - 6px), 248 + calc(100% - 6px) 100%, 249 + 0 100%, 250 + 0 6px 251 + ); 110 252 } 111 253 112 - section { 113 - margin-bottom: 36px; 254 + @media (max-width: 640px) { 255 + .legend { 256 + bottom: 44px; 257 + gap: 10px; 258 + font-size: 9px; 259 + padding: 6px 10px; 260 + max-width: calc(100vw - 16px); 261 + flex-wrap: wrap; 262 + justify-content: center; 263 + } 114 264 } 115 265 116 - .section-header { 266 + .li { 117 267 display: flex; 118 - align-items: baseline; 119 - justify-content: space-between; 120 - margin-bottom: 6px; 121 - } 122 - 123 - .section-header h2 { 124 - margin-bottom: 0; 125 - } 126 - 127 - .more { 128 - font-size: 12px; 129 - color: var(--text-muted); 130 - } 131 - 132 - .hint { 133 - font-size: 12px; 134 - margin-top: 0; 135 - margin-bottom: 16px; 136 - max-width: 540px; 137 - } 138 - 139 - h2 { 140 - margin-bottom: 6px; 268 + align-items: center; 269 + gap: 6px; 141 270 } 142 271 </style>
-77
web/src/routes/activity/+page.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import PostCard from '$lib/components/PostCard.svelte'; 4 - import { getActivity } from '$lib/api'; 5 - import type { ActivityItem, ActivityType } from '$lib/types'; 6 - 7 - let items = $state<ActivityItem[]>([]); 8 - let loaded = $state(false); 9 - let err = $state<string | null>(null); 10 - let filter = $state<ActivityType | 'all'>('all'); 11 - 12 - onMount(async () => { 13 - try { 14 - items = await getActivity(); 15 - } catch (e) { 16 - err = (e as Error).message; 17 - } 18 - loaded = true; 19 - }); 20 - 21 - const filtered = $derived(filter === 'all' ? items : items.filter((i) => i.type === filter)); 22 - 23 - const FILTERS: { value: ActivityType | 'all'; label: string }[] = [ 24 - { value: 'all', label: 'all' }, 25 - { value: 'post', label: 'posts' }, 26 - { value: 'note', label: 'notes' }, 27 - { value: 'url', label: 'bookmarks' } 28 - ]; 29 - </script> 30 - 31 - <div class="container"> 32 - <header> 33 - <h1>activity</h1> 34 - <p class="muted"> 35 - phi's public output — bluesky posts, semble notes, and bookmarked URLs. what 36 - she's emitted into the world across surfaces. 37 - </p> 38 - </header> 39 - 40 - <div class="filters"> 41 - {#each FILTERS as f (f.value)} 42 - <button class:active={filter === f.value} onclick={() => (filter = f.value)}> 43 - {f.label} 44 - </button> 45 - {/each} 46 - </div> 47 - 48 - {#if !loaded} 49 - <p class="faint">loading…</p> 50 - {:else if err} 51 - <p class="faint">failed to load: {err}</p> 52 - {:else if filtered.length === 0} 53 - <p class="faint">nothing to show.</p> 54 - {:else} 55 - {#each filtered as item (item.uri)} 56 - <PostCard {item} /> 57 - {/each} 58 - {/if} 59 - </div> 60 - 61 - <style> 62 - header { 63 - margin-bottom: 24px; 64 - } 65 - 66 - .filters { 67 - display: flex; 68 - gap: 8px; 69 - margin-bottom: 20px; 70 - flex-wrap: wrap; 71 - } 72 - 73 - button.active { 74 - border-color: var(--accent-blue); 75 - color: var(--accent-blue); 76 - } 77 - </style>
-44
web/src/routes/blog/+page.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import BlogCard from '$lib/components/BlogCard.svelte'; 4 - import { getBlogDocs } from '$lib/api'; 5 - import type { BlogDoc } from '$lib/types'; 6 - 7 - let docs = $state<BlogDoc[]>([]); 8 - let loaded = $state(false); 9 - let err = $state<string | null>(null); 10 - 11 - onMount(async () => { 12 - try { 13 - docs = await getBlogDocs(); 14 - } catch (e) { 15 - err = (e as Error).message; 16 - } 17 - loaded = true; 18 - }); 19 - </script> 20 - 21 - <div class="container"> 22 - <header> 23 - <h1>blog</h1> 24 - <p class="muted">long-form posts published to greengale.app.</p> 25 - </header> 26 - 27 - {#if !loaded} 28 - <p class="faint">loading…</p> 29 - {:else if err} 30 - <p class="faint">failed to load: {err}</p> 31 - {:else if docs.length === 0} 32 - <p class="faint">no posts yet.</p> 33 - {:else} 34 - {#each docs as doc (doc.rkey)} 35 - <BlogCard {doc} /> 36 - {/each} 37 - {/if} 38 - </div> 39 - 40 - <style> 41 - header { 42 - margin-bottom: 24px; 43 - } 44 - </style>
+178
web/src/routes/capabilities/+page.svelte
··· 1 + <script lang="ts"> 2 + import { CAPABILITIES, OPERATOR_ONLY } from '$lib/abilities'; 3 + 4 + const sorted = [...CAPABILITIES].sort(); 5 + const total = sorted.length; 6 + const operatorCount = sorted.filter((n) => OPERATOR_ONLY.has(n)).length; 7 + </script> 8 + 9 + <svelte:head> 10 + <title>phi · capabilities</title> 11 + </svelte:head> 12 + 13 + <div class="lens"> 14 + <div class="screen"> 15 + <header> 16 + <h1 class="chrome">what i can do</h1> 17 + <p class="line"> 18 + <span class="num mono">{total}</span> 19 + <span class="lbl">things i can do</span> 20 + {#if operatorCount > 0} 21 + <span class="sep">·</span> 22 + <span class="num mono">{operatorCount}</span> 23 + <span class="lbl" 24 + >{operatorCount === 1 ? 'requires' : 'require'} nate's authorization</span 25 + > 26 + {/if} 27 + </p> 28 + </header> 29 + 30 + <ul class="list"> 31 + {#each sorted as name (name)} 32 + <li> 33 + <span class="hex" style="color: var(--hud-mid)"></span> 34 + <span class="name mono">{name}</span> 35 + {#if OPERATOR_ONLY.has(name)} 36 + <span class="op chrome">operator</span> 37 + {/if} 38 + </li> 39 + {/each} 40 + </ul> 41 + </div> 42 + </div> 43 + 44 + <style> 45 + .lens { 46 + position: absolute; 47 + inset: 0; 48 + display: flex; 49 + justify-content: center; 50 + padding: 84px 32px 64px; 51 + overflow: hidden; 52 + } 53 + 54 + .screen { 55 + position: relative; 56 + width: 100%; 57 + max-width: 680px; 58 + max-height: 100%; 59 + overflow-y: auto; 60 + padding: 28px 36px 32px; 61 + background: var(--bg-deep); 62 + border: 1px solid var(--line-mid); 63 + clip-path: polygon( 64 + 14px 0, 65 + 100% 0, 66 + 100% calc(100% - 14px), 67 + calc(100% - 14px) 100%, 68 + 0 100%, 69 + 0 14px 70 + ); 71 + } 72 + 73 + /* corner brackets — purely decorative chrome */ 74 + .screen::before, 75 + .screen::after { 76 + content: ''; 77 + position: absolute; 78 + width: 14px; 79 + height: 14px; 80 + border-color: var(--hud-mid); 81 + border-style: solid; 82 + border-width: 0; 83 + pointer-events: none; 84 + } 85 + .screen::before { 86 + top: 4px; 87 + left: 4px; 88 + border-top-width: 1px; 89 + border-left-width: 1px; 90 + } 91 + .screen::after { 92 + bottom: 4px; 93 + right: 4px; 94 + border-bottom-width: 1px; 95 + border-right-width: 1px; 96 + } 97 + 98 + header { 99 + margin-bottom: 22px; 100 + padding-bottom: 16px; 101 + border-bottom: 1px solid var(--line-dim); 102 + } 103 + 104 + h1 { 105 + font-size: 24px; 106 + font-weight: 500; 107 + margin: 0 0 8px 0; 108 + color: var(--text); 109 + letter-spacing: 0.1em; 110 + } 111 + 112 + .line { 113 + display: flex; 114 + gap: 6px; 115 + flex-wrap: wrap; 116 + align-items: baseline; 117 + font-size: 12px; 118 + color: var(--text-mid); 119 + margin: 0; 120 + } 121 + 122 + .num { 123 + color: var(--scan-hot); 124 + font-size: 13px; 125 + } 126 + 127 + .lbl { 128 + font-size: 12px; 129 + } 130 + 131 + .sep { 132 + color: var(--text-dim); 133 + } 134 + 135 + .list { 136 + list-style: none; 137 + padding: 0; 138 + margin: 0; 139 + display: grid; 140 + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 141 + gap: 4px 28px; 142 + } 143 + 144 + li { 145 + display: flex; 146 + align-items: center; 147 + gap: 10px; 148 + padding: 4px 0; 149 + font-size: 13px; 150 + } 151 + 152 + .name { 153 + color: var(--text); 154 + font-size: 12px; 155 + } 156 + 157 + .op { 158 + font-size: 8px; 159 + color: var(--warn); 160 + letter-spacing: 0.18em; 161 + margin-left: 2px; 162 + } 163 + 164 + @media (max-width: 640px) { 165 + .lens { 166 + padding: 64px 12px 52px; 167 + } 168 + .screen { 169 + padding: 18px 20px 22px; 170 + } 171 + h1 { 172 + font-size: 20px; 173 + } 174 + .list { 175 + grid-template-columns: 1fr; 176 + } 177 + } 178 + </style>
-55
web/src/routes/discovery/+page.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import DiscoveryCard from '$lib/components/DiscoveryCard.svelte'; 4 - import { getDiscoveryPool, OWNER_HANDLE } from '$lib/api'; 5 - import type { DiscoveryEntry } from '$lib/types'; 6 - 7 - let entries = $state<DiscoveryEntry[]>([]); 8 - let loaded = $state(false); 9 - 10 - onMount(async () => { 11 - entries = await getDiscoveryPool(); 12 - loaded = true; 13 - }); 14 - </script> 15 - 16 - <div class="container"> 17 - <header> 18 - <h1>discovery</h1> 19 - <p class="muted"> 20 - what surfaces for attention. high-signal candidates phi sees in her prompt — strangers worth 21 - considering. matches what phi sees: the upstream pool minus people she's already engaged with. 22 - </p> 23 - <p class="source faint"> 24 - source: @{OWNER_HANDLE}'s recent likes (one signal among possible others; future sources can 25 - feed the same surface). 26 - </p> 27 - </header> 28 - 29 - {#if !loaded} 30 - <p class="faint">loading…</p> 31 - {:else if entries.length === 0} 32 - <p class="faint">nothing surfacing right now.</p> 33 - {:else} 34 - {#each entries as entry (entry.did)} 35 - <DiscoveryCard {entry} /> 36 - {/each} 37 - {/if} 38 - </div> 39 - 40 - <style> 41 - header { 42 - margin-bottom: 24px; 43 - } 44 - 45 - header p { 46 - max-width: 600px; 47 - font-size: 13px; 48 - line-height: 1.5; 49 - } 50 - 51 - .source { 52 - font-size: 12px; 53 - margin-top: 8px; 54 - } 55 - </style>
-49
web/src/routes/mind/+page.svelte
··· 1 - <script lang="ts"> 2 - import MemoryGraph from '$lib/components/MemoryGraph.svelte'; 3 - </script> 4 - 5 - <div class="container"> 6 - <header> 7 - <h1>mind</h1> 8 - <p class="muted"> 9 - phi's social memory — handles she's exchanged with, positioned by semantic similarity of her 10 - observations about them. drag, zoom, hover. 11 - </p> 12 - </header> 13 - 14 - <MemoryGraph /> 15 - 16 - <section class="future"> 17 - <h2>coming next</h2> 18 - <ul class="muted"> 19 - <li>per-handle observation browser (search by handle, see what phi knows + citations + ages)</li> 20 - <li>archive search across past observations and aged-out attention items</li> 21 - </ul> 22 - </section> 23 - </div> 24 - 25 - <style> 26 - header { 27 - margin-bottom: 24px; 28 - } 29 - 30 - header p { 31 - max-width: 600px; 32 - font-size: 13px; 33 - line-height: 1.5; 34 - } 35 - 36 - .future { 37 - margin-top: 32px; 38 - font-size: 13px; 39 - } 40 - 41 - .future ul { 42 - padding-left: 20px; 43 - line-height: 1.6; 44 - } 45 - 46 - .future li { 47 - margin-bottom: 4px; 48 - } 49 - </style>
+139
web/src/routes/output/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import Logbook from '$lib/components/Logbook.svelte'; 4 + import Constellation from '$lib/components/Constellation.svelte'; 5 + import { getActivity, getBlogDocs } from '$lib/api'; 6 + import type { ActivityItem, BlogDoc } from '$lib/types'; 7 + 8 + let items = $state<ActivityItem[]>([]); 9 + let blog = $state<BlogDoc[]>([]); 10 + let loaded = $state(false); 11 + let err = $state<string | null>(null); 12 + let filter = $state<'all' | 'post' | 'note' | 'url' | 'blog'>('all'); 13 + 14 + onMount(async () => { 15 + try { 16 + const [a, b] = await Promise.allSettled([getActivity(), getBlogDocs()]); 17 + if (a.status === 'fulfilled') items = a.value; 18 + if (b.status === 'fulfilled') blog = b.value; 19 + } catch (e) { 20 + err = (e as Error).message; 21 + } 22 + loaded = true; 23 + }); 24 + 25 + const blogAsActivity = $derived( 26 + blog.map( 27 + (d): ActivityItem & { _blogRef: BlogDoc } => ({ 28 + type: 'url', 29 + text: d.content.slice(0, 280), 30 + title: d.title, 31 + time: d.publishedAt, 32 + uri: `at://greengale/${d.rkey}`, 33 + url: d.url, 34 + _blogRef: d 35 + }) 36 + ) 37 + ); 38 + 39 + const all = $derived( 40 + [ 41 + ...items.map((i) => ({ ...i, _kind: i.type as 'post' | 'note' | 'url' })), 42 + ...blogAsActivity.map((b) => ({ ...b, _kind: 'blog' as const })) 43 + ].sort((a, b) => (a.time < b.time ? 1 : -1)) 44 + ); 45 + 46 + const filtered = $derived(filter === 'all' ? all : all.filter((i) => i._kind === filter)); 47 + 48 + const FILTERS = [ 49 + { value: 'all', label: 'all' }, 50 + { value: 'post', label: 'posts' }, 51 + { value: 'note', label: 'notes' }, 52 + { value: 'url', label: 'urls' }, 53 + { value: 'blog', label: 'blog' } 54 + ] as const; 55 + </script> 56 + 57 + <svelte:head> 58 + <title>phi · output</title> 59 + </svelte:head> 60 + 61 + <div class="lens"> 62 + <div class="filter-bar chrome"> 63 + {#each FILTERS as f (f.value)} 64 + <button class:active={filter === f.value} onclick={() => (filter = f.value)}> 65 + {f.label} 66 + </button> 67 + {/each} 68 + </div> 69 + 70 + {#if !loaded} 71 + <div class="overlay chrome muted">acquiring telemetry…</div> 72 + {:else if err} 73 + <div class="overlay chrome muted">signal lost · {err}</div> 74 + {:else if filtered.length === 0} 75 + <div class="overlay chrome muted">no emissions</div> 76 + {:else} 77 + <Constellation items={filtered} /> 78 + {/if} 79 + </div> 80 + 81 + <Logbook /> 82 + 83 + <style> 84 + .lens { 85 + position: absolute; 86 + inset: 0; 87 + } 88 + 89 + .overlay { 90 + position: absolute; 91 + inset: 0; 92 + display: flex; 93 + align-items: center; 94 + justify-content: center; 95 + font-size: 11px; 96 + color: var(--text-mid); 97 + letter-spacing: 0.18em; 98 + } 99 + 100 + .filter-bar { 101 + position: absolute; 102 + top: 80px; 103 + left: 50%; 104 + transform: translateX(-50%); 105 + display: flex; 106 + gap: 1px; 107 + background: var(--bg-panel); 108 + border: 1px solid var(--line-mid); 109 + backdrop-filter: blur(8px); 110 + -webkit-backdrop-filter: blur(8px); 111 + z-index: 5; 112 + clip-path: polygon( 113 + 6px 0, 114 + 100% 0, 115 + 100% calc(100% - 6px), 116 + calc(100% - 6px) 100%, 117 + 0 100%, 118 + 0 6px 119 + ); 120 + } 121 + 122 + .filter-bar button { 123 + border: none; 124 + font-size: 10px; 125 + padding: 6px 12px; 126 + } 127 + 128 + @media (max-width: 640px) { 129 + .filter-bar { 130 + top: 60px; 131 + max-width: calc(100vw - 16px); 132 + } 133 + .filter-bar button { 134 + font-size: 9px; 135 + padding: 8px 10px; 136 + min-height: 32px; 137 + } 138 + } 139 + </style>
-58
web/src/routes/skills/+page.svelte
··· 1 - <script lang="ts"> 2 - // skills are static, defined in bot/skills/ at build time. for now we 3 - // inline the descriptions; later we could expose a backend endpoint that 4 - // scans the skills/ directory at runtime if the set grows beyond a few. 5 - 6 - const skills = [ 7 - { 8 - name: 'publish-blog', 9 - description: 10 - "Publish a long-form post on greengale.app. Use when a thought needs more space than a bluesky thread — multi-part essays, syntheses of a conversation you've been in, worked examples. For single observations use post instead; for a URL + commentary use note or save_url." 11 - } 12 - ]; 13 - </script> 14 - 15 - <div class="container"> 16 - <header> 17 - <h1>skills</h1> 18 - <p class="muted"> 19 - progressive-disclosure capability packs phi can load on demand. each is a description (always 20 - visible to phi) plus a body she pulls in via load_skill when relevant. 21 - </p> 22 - </header> 23 - 24 - {#each skills as skill (skill.name)} 25 - <div class="card"> 26 - <div class="name mono">{skill.name}</div> 27 - <div class="desc">{skill.description}</div> 28 - </div> 29 - {/each} 30 - </div> 31 - 32 - <style> 33 - header { 34 - margin-bottom: 24px; 35 - } 36 - 37 - header p { 38 - max-width: 600px; 39 - font-size: 13px; 40 - line-height: 1.5; 41 - } 42 - 43 - .card { 44 - border-left-color: var(--accent-purple); 45 - } 46 - 47 - .name { 48 - font-size: 14px; 49 - color: var(--accent-purple); 50 - margin-bottom: 8px; 51 - } 52 - 53 - .desc { 54 - font-size: 13px; 55 - line-height: 1.6; 56 - color: var(--text-muted); 57 - } 58 - </style>
-84
web/src/routes/status/+page.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import { getHealth, PHI_HANDLE } from '$lib/api'; 4 - import type { HealthInfo } from '$lib/types'; 5 - 6 - let health = $state<HealthInfo | null>(null); 7 - let err = $state<string | null>(null); 8 - 9 - onMount(async () => { 10 - try { 11 - health = await getHealth(); 12 - } catch (e) { 13 - err = (e as Error).message; 14 - } 15 - }); 16 - </script> 17 - 18 - <div class="container"> 19 - <header> 20 - <h1>status</h1> 21 - <p class="muted">runtime health of the phi process. operational view.</p> 22 - </header> 23 - 24 - {#if err} 25 - <p class="faint">unreachable: {err}</p> 26 - {:else if !health} 27 - <p class="faint">loading…</p> 28 - {:else} 29 - <div class="grid"> 30 - <div class="metric"> 31 - <div class="value" style="color: {health.polling_active ? 'var(--accent-green)' : 'var(--text-faint)'}"> 32 - {health.polling_active ? 'online' : 'offline'} 33 - </div> 34 - <div class="label">status</div> 35 - </div> 36 - <div class="metric"> 37 - <div class="value" style="color: {health.paused ? 'var(--accent-yellow)' : 'var(--text)'}"> 38 - {health.paused ? 'yes' : 'no'} 39 - </div> 40 - <div class="label">paused</div> 41 - </div> 42 - <div class="metric"> 43 - <div class="value">{health.status}</div> 44 - <div class="label">health</div> 45 - </div> 46 - <div class="metric"> 47 - <div class="value">@{PHI_HANDLE}</div> 48 - <div class="label">handle</div> 49 - </div> 50 - </div> 51 - {/if} 52 - </div> 53 - 54 - <style> 55 - header { 56 - margin-bottom: 24px; 57 - } 58 - 59 - .grid { 60 - display: grid; 61 - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); 62 - gap: 10px; 63 - } 64 - 65 - .metric { 66 - background: var(--bg-elev); 67 - border-radius: 8px; 68 - padding: 16px; 69 - } 70 - 71 - .value { 72 - font-size: 18px; 73 - color: var(--text); 74 - margin-bottom: 4px; 75 - word-break: break-word; 76 - } 77 - 78 - .label { 79 - font-size: 11px; 80 - text-transform: uppercase; 81 - letter-spacing: 0.4px; 82 - color: var(--text-muted); 83 - } 84 - </style>