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

Configure Feed

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

add /api/skills endpoint + capabilities page split (skills vs tools)

backend (main.py): /api/skills walks settings.skills_dir, parses
SKILL.md frontmatter (name, description), lists sibling .md files as
resources. cached for process lifetime, parallel to /api/abilities.

frontend (capabilities page):
- status strip: SKILLS · TOOLS · OPERATOR-GATED counts
- two visually-distinct sections: skills (load on demand, amber accent)
on top, tools (always available, default text) below
- detail pane branches by kind — skill view shows resource list;
tool view shows the operator-only flag
- keyboard nav skips section headers
- loq bump for the larger capabilities page

+262 -27
+4
loq.toml
··· 30 30 [[rules]] 31 31 path = "web/src/lib/components/Logbook.svelte" 32 32 max_lines = 650 33 + 34 + [[rules]] 35 + path = "web/src/routes/capabilities/+page.svelte" 36 + max_lines = 700
+60
src/bot/main.py
··· 210 210 return JSONResponse({"error": str(e)}, status_code=500) 211 211 212 212 213 + _skills_cache: list | None = None 214 + 215 + 216 + @app.get("/api/skills") 217 + async def skills(): 218 + """Phi's installed skill packages — load-on-demand domain knowledge. 219 + 220 + Walks `settings.skills_dir`, parses each `SKILL.md`'s frontmatter for 221 + name + description, lists sibling `.md` files as resources. Cached for 222 + process lifetime; skills register at startup like tools. 223 + 224 + See bot/SKILLS-API.md for the rationale and the UI consumer plan. 225 + """ 226 + global _skills_cache 227 + if _skills_cache is not None: 228 + return JSONResponse(_skills_cache) 229 + import re 230 + 231 + base = Path(settings.skills_dir) 232 + if not base.exists(): 233 + _skills_cache = [] 234 + return JSONResponse(_skills_cache) 235 + 236 + out: list[dict] = [] 237 + front_re = re.compile(r"^---\n(.*?)\n---", re.DOTALL) 238 + name_re = re.compile(r"^name:\s*(.+?)\s*$", re.MULTILINE) 239 + desc_re = re.compile( 240 + r"^description:\s*(.+?)(?=\n\w+:|\Z)", re.MULTILINE | re.DOTALL 241 + ) 242 + 243 + for entry in sorted(base.iterdir()): 244 + if not entry.is_dir(): 245 + continue 246 + skill_md = entry / "SKILL.md" 247 + if not skill_md.exists(): 248 + continue 249 + try: 250 + content = skill_md.read_text() 251 + except Exception: 252 + continue 253 + m = front_re.match(content) 254 + if not m: 255 + continue 256 + front = m.group(1) 257 + name_m = name_re.search(front) 258 + desc_m = desc_re.search(front) 259 + name = name_m.group(1).strip() if name_m else entry.name 260 + description = " ".join(desc_m.group(1).split()).strip() if desc_m else "" 261 + resources = sorted(p.name for p in entry.iterdir() if p.suffix == ".md") 262 + out.append( 263 + { 264 + "name": name, 265 + "description": description, 266 + "resources": resources, 267 + } 268 + ) 269 + _skills_cache = out 270 + return JSONResponse(out) 271 + 272 + 213 273 _user_view_cache: dict[str, tuple[float, dict]] = {} 214 274 _USER_VIEW_TTL = 60 # seconds 215 275
+7
web/src/lib/api.ts
··· 13 13 GraphData, 14 14 HealthInfo, 15 15 Observation, 16 + Skill, 16 17 UserView 17 18 } from './types'; 18 19 ··· 133 134 export async function getCapabilities(): Promise<Capability[]> { 134 135 const res = await fetch('/api/abilities'); 135 136 if (!res.ok) throw new Error(`abilities: ${res.status}`); 137 + return await res.json(); 138 + } 139 + 140 + export async function getSkills(): Promise<Skill[]> { 141 + const res = await fetch('/api/skills'); 142 + if (!res.ok) throw new Error(`skills: ${res.status}`); 136 143 return await res.json(); 137 144 } 138 145
+8
web/src/lib/types.ts
··· 94 94 operator_only: boolean; 95 95 } 96 96 97 + // --- /api/skills --- 98 + 99 + export interface Skill { 100 + name: string; 101 + description: string; 102 + resources: string[]; 103 + } 104 + 97 105 // --- /api/users/{handle} --- 98 106 99 107 export interface UserViewObservation {
+183 -27
web/src/routes/capabilities/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 - import { getCapabilities } from '$lib/api'; 4 - import type { Capability } from '$lib/types'; 3 + import { getCapabilities, getSkills } from '$lib/api'; 4 + import type { Capability, Skill } from '$lib/types'; 5 + 6 + type RowKind = 'skill' | 'tool'; 7 + interface Row { 8 + kind: RowKind; 9 + idx: number; // index within its kind list 10 + name: string; 11 + operator_only?: boolean; 12 + } 5 13 6 14 let caps = $state<Capability[]>([]); 7 - let selectedIdx = $state(0); 15 + let skills = $state<Skill[]>([]); 8 16 let loaded = $state(false); 9 17 let err = $state<string | null>(null); 10 18 11 - const sorted = $derived([...caps].sort((a, b) => a.name.localeCompare(b.name))); 12 - const total = $derived(sorted.length); 13 - const opCount = $derived(sorted.filter((c) => c.operator_only).length); 14 - const selected = $derived(sorted[selectedIdx] ?? null); 19 + const sortedCaps = $derived([...caps].sort((a, b) => a.name.localeCompare(b.name))); 20 + const sortedSkills = $derived([...skills].sort((a, b) => a.name.localeCompare(b.name))); 21 + 22 + const rows = $derived<Row[]>([ 23 + ...sortedSkills.map((s, i) => ({ 24 + kind: 'skill' as const, 25 + idx: i, 26 + name: s.name 27 + })), 28 + ...sortedCaps.map((c, i) => ({ 29 + kind: 'tool' as const, 30 + idx: i, 31 + name: c.name, 32 + operator_only: c.operator_only 33 + })) 34 + ]); 35 + 36 + let selectedIdx = $state(0); 37 + const selected = $derived<{ row: Row; skill?: Skill; tool?: Capability } | null>( 38 + (() => { 39 + const r = rows[selectedIdx]; 40 + if (!r) return null; 41 + if (r.kind === 'skill') return { row: r, skill: sortedSkills[r.idx] }; 42 + return { row: r, tool: sortedCaps[r.idx] }; 43 + })() 44 + ); 45 + 46 + const opCount = $derived(sortedCaps.filter((c) => c.operator_only).length); 15 47 16 48 onMount(async () => { 17 49 try { 18 - caps = await getCapabilities(); 50 + const [c, s] = await Promise.allSettled([getCapabilities(), getSkills()]); 51 + if (c.status === 'fulfilled') caps = c.value; 52 + if (s.status === 'fulfilled') skills = s.value; 19 53 selectedIdx = 0; 20 54 } catch (e) { 21 55 err = (e as Error).message; ··· 29 63 } 30 64 31 65 function onListKey(e: KeyboardEvent) { 32 - if (e.key === 'ArrowDown' && selectedIdx < total - 1) { 66 + if (e.key === 'ArrowDown' && selectedIdx < rows.length - 1) { 33 67 e.preventDefault(); 34 68 selectedIdx++; 35 69 } else if (e.key === 'ArrowUp' && selectedIdx > 0) { ··· 41 75 function pad(n: number, w = 2): string { 42 76 return String(n + 1).padStart(w, '0'); 43 77 } 78 + 79 + const skillsCount = $derived(sortedSkills.length); 80 + const toolsCount = $derived(sortedCaps.length); 44 81 </script> 45 82 46 83 <svelte:head> ··· 54 91 <span class="head-tag chrome">phi · capabilities</span> 55 92 <span class="head-status chrome"> 56 93 {#if loaded && !err} 57 - <span class="num mono">{pad(total - 1)}</span> 58 - <span class="dim">entries</span> 94 + <span class="num mono">{pad(skillsCount - 1)}</span> 95 + <span class="dim">skills</span> 96 + <span class="seg"></span> 97 + <span class="num mono">{pad(toolsCount - 1)}</span> 98 + <span class="dim">tools</span> 59 99 {#if opCount > 0} 60 100 <span class="seg"></span> 61 101 <span class="num mono">{pad(opCount - 1)}</span> ··· 72 112 </header> 73 113 74 114 <div class="panes"> 75 - <!-- list pane --> 76 115 <aside class="list-pane"> 77 116 <div class="pane-rule chrome">capabilities</div> 78 117 {#if !loaded} 79 118 <div class="empty chrome muted">acquiring…</div> 80 119 {:else if err} 81 120 <div class="empty chrome muted">unreachable · {err}</div> 82 - {:else if sorted.length === 0} 121 + {:else if rows.length === 0} 83 122 <div class="empty chrome muted">none registered</div> 84 123 {:else} 85 124 <ul ··· 89 128 aria-label="capabilities" 90 129 onkeydown={onListKey} 91 130 > 92 - {#each sorted as cap, i (cap.name)} 131 + {#if sortedSkills.length > 0} 132 + <li class="section"> 133 + <span class="section-tag chrome">skills</span> 134 + <span class="section-meta chrome faint">load on demand</span> 135 + </li> 136 + {/if} 137 + {#each rows as row, i (row.kind + ':' + row.name)} 138 + {#if i === sortedSkills.length && sortedCaps.length > 0} 139 + <li class="section"> 140 + <span class="section-tag chrome">tools</span> 141 + <span class="section-meta chrome faint">always available</span> 142 + </li> 143 + {/if} 93 144 <li> 94 145 <button 95 146 class="row" 96 147 class:active={i === selectedIdx} 148 + class:row-skill={row.kind === 'skill'} 97 149 role="option" 98 150 aria-selected={i === selectedIdx} 99 151 onclick={() => pick(i)} 100 152 > 101 153 <span class="bar" aria-hidden="true"></span> 102 154 <span class="idx mono">{pad(i)}</span> 103 - <span class="name mono">{cap.name}</span> 104 - {#if cap.operator_only} 155 + <span class="name mono">{row.name}</span> 156 + {#if row.operator_only} 105 157 <span class="op-dot" title="requires nate's authorization"></span> 106 158 {/if} 107 159 </button> ··· 111 163 {/if} 112 164 </aside> 113 165 114 - <!-- detail pane --> 115 166 <section class="detail-pane"> 116 167 <div class="pane-rule chrome">readout</div> 117 - {#if selected} 168 + {#if selected?.skill} 169 + {@const sk = selected.skill} 118 170 <div class="detail scroll"> 119 171 <div class="d-head"> 120 - <div class="d-name mono">{selected.name}</div> 172 + <div class="d-name mono">{sk.name}</div> 121 173 <div class="d-meta chrome"> 122 - <span class="dim">entry</span> 123 - <span class="num mono">{pad(selectedIdx)}</span> 124 - <span class="dim">of</span> 125 - <span class="num mono">{pad(total - 1)}</span> 126 - {#if selected.operator_only} 174 + <span class="d-kind skill-kind">skill</span> 175 + <span class="seg"></span> 176 + <span class="dim">load on demand</span> 177 + <span class="seg"></span> 178 + <span class="num mono">{sk.resources.length}</span> 179 + <span class="dim">resource{sk.resources.length === 1 ? '' : 's'}</span> 180 + </div> 181 + </div> 182 + <div class="d-rule"></div> 183 + {#if sk.description} 184 + <div class="d-body"> 185 + <p>{sk.description}</p> 186 + </div> 187 + {/if} 188 + {#if sk.resources.length > 0} 189 + <div class="d-block"> 190 + <div class="d-block-label chrome">resources</div> 191 + <ul class="res-list"> 192 + {#each sk.resources as r (r)} 193 + <li class="mono">{r}</li> 194 + {/each} 195 + </ul> 196 + </div> 197 + {/if} 198 + </div> 199 + {:else if selected?.tool} 200 + {@const tl = selected.tool} 201 + <div class="detail scroll"> 202 + <div class="d-head"> 203 + <div class="d-name mono">{tl.name}</div> 204 + <div class="d-meta chrome"> 205 + <span class="d-kind tool-kind">tool</span> 206 + <span class="seg"></span> 207 + <span class="dim">always available</span> 208 + {#if tl.operator_only} 127 209 <span class="seg"></span> 128 210 <span class="op-tag chrome">operator-gated</span> 129 211 {/if} 130 212 </div> 131 213 </div> 132 214 <div class="d-rule"></div> 133 - {#if selected.description} 215 + {#if tl.description} 134 216 <div class="d-body"> 135 - {#each selected.description.split(/\n\s*\n/) as para, i (i)} 217 + {#each tl.description.split(/\n\s*\n/) as para, i (i)} 136 218 <p>{para}</p> 137 219 {/each} 138 220 </div> ··· 184 266 inset 1px 0 0 rgba(184, 107, 58, 0.05); 185 267 } 186 268 187 - /* anchored corner brackets */ 188 269 .frame-wrap::before, 189 270 .frame-wrap::after { 190 271 content: ''; ··· 324 405 box-shadow: inset 0 0 0 1px var(--hud-mid); 325 406 } 326 407 408 + .section { 409 + display: flex; 410 + justify-content: space-between; 411 + align-items: baseline; 412 + padding: 10px 12px 4px 12px; 413 + border-bottom: 1px dashed var(--line-dim); 414 + margin: 0 8px 4px; 415 + } 416 + 417 + .section:not(:first-child) { 418 + margin-top: 14px; 419 + } 420 + 421 + .section-tag { 422 + font-size: 10px; 423 + color: var(--hud-hot); 424 + letter-spacing: 0.22em; 425 + } 426 + 427 + .section-meta { 428 + font-size: 8px; 429 + letter-spacing: 0.18em; 430 + } 431 + 327 432 .row { 328 433 display: flex; 329 434 align-items: center; ··· 372 477 .row.active .bar { 373 478 background: var(--hud-hot); 374 479 box-shadow: 0 0 6px rgba(224, 144, 96, 0.6); 480 + } 481 + 482 + .row-skill .name { 483 + color: var(--warn-hot); 484 + } 485 + 486 + .row-skill.active .bar { 487 + background: var(--warn); 488 + box-shadow: 0 0 6px rgba(201, 160, 90, 0.6); 375 489 } 376 490 377 491 .idx { ··· 436 550 color: var(--text-dim); 437 551 } 438 552 553 + .d-kind { 554 + font-size: 9px; 555 + letter-spacing: 0.22em; 556 + padding: 2px 6px; 557 + border: 1px solid currentColor; 558 + } 559 + 560 + .d-kind.skill-kind { 561 + color: var(--warn-hot); 562 + } 563 + 564 + .d-kind.tool-kind { 565 + color: var(--scan-mid); 566 + } 567 + 439 568 .op-tag { 440 569 color: var(--warn); 441 570 font-size: 9px; ··· 467 596 468 597 .d-body p:last-child { 469 598 margin-bottom: 0; 599 + } 600 + 601 + .d-block { 602 + margin-top: 4px; 603 + } 604 + 605 + .d-block-label { 606 + font-size: 9px; 607 + color: var(--text-dim); 608 + letter-spacing: 0.22em; 609 + margin-bottom: 6px; 610 + } 611 + 612 + .res-list { 613 + list-style: none; 614 + padding: 0; 615 + margin: 0; 616 + display: flex; 617 + flex-direction: column; 618 + gap: 4px; 619 + border-left: 2px solid var(--line-mid); 620 + } 621 + 622 + .res-list li { 623 + font-size: 12px; 624 + color: var(--text-mid); 625 + padding: 2px 12px; 470 626 } 471 627 472 628 /* ---------- mobile ---------- */