@tpsdev-ai/flair 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,89 +3,383 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Observation Center</title>
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:;">
7
+ <title>Flair · Observation Center</title>
7
8
  <style>
8
- :root{--bg:#f4efe6;--panel:rgba(255,251,243,.86);--ink:#1e241f;--muted:#697265;--line:rgba(30,36,31,.12);--accent:#0f766e;--accent-soft:rgba(15,118,110,.12);--ok:#166534;--warn:#b45309;--shadow:0 18px 48px rgba(69,58,35,.14)}
9
+ :root{--bg:#f4efe6;--panel:rgba(255,251,243,.86);--ink:#1e241f;--muted:#697265;--line:rgba(30,36,31,.12);--accent:#0f766e;--accent-soft:rgba(15,118,110,.12);--ok:#166534;--warn:#b45309;--danger:#9f1239;--shadow:0 18px 48px rgba(69,58,35,.14)}
9
10
  *{box-sizing:border-box}body{margin:0;min-height:100vh;font:15px/1.45 "IBM Plex Sans","Avenir Next",sans-serif;color:var(--ink);background:radial-gradient(circle at top left,rgba(15,118,110,.14),transparent 28rem),radial-gradient(circle at top right,rgba(180,83,9,.1),transparent 18rem),linear-gradient(180deg,#fcfaf5,var(--bg))}
10
- .shell{max-width:1280px;margin:0 auto;padding:28px 18px 40px}.hero,.panel{background:var(--panel);border:1px solid var(--line);border-radius:22px;backdrop-filter:blur(14px);box-shadow:var(--shadow)}.hero{padding:24px;margin-bottom:18px}.hero h1,.panel h2{margin:0;font-family:"IBM Plex Serif","Iowan Old Style",serif}.hero h1{font-size:clamp(2rem,4vw,3.1rem)}.hero p,.subtle,.empty{color:var(--muted)}
11
- .summary,.grid,.events,.toolbar,.layout{display:grid;gap:12px}.summary{grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin-top:18px}.chip,.card,.event{background:rgba(255,255,255,.62);border:1px solid var(--line);border-radius:16px;padding:14px}.chip strong{display:block;font-size:1.3rem}.chip span{font-size:.92rem;color:var(--muted)}
12
- .toolbar{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));padding:16px;margin-bottom:18px}.field{display:flex;flex-direction:column;gap:6px}.field label{font-size:.8rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}.field input,.field select,.field button{width:100%;padding:11px 12px;border-radius:12px;border:1px solid var(--line);background:#fffaf1;font:inherit;color:var(--ink)}.field button{cursor:pointer;color:#fff;background:linear-gradient(135deg,var(--accent),#115e59);border:none;font-weight:600}
13
- .layout{grid-template-columns:1.05fr 1.4fr;align-items:start}.panel{padding:18px}.head,.row{display:flex;justify-content:space-between;gap:10px}.head{align-items:baseline;margin-bottom:14px}.grid{grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}.events{max-height:72vh;overflow:auto;padding-right:4px}.card h3,.event h3{margin:0;font-size:1rem}.meta,.workspace{font-size:.9rem;color:var(--muted)}.event p{margin:8px 0 0;white-space:pre-wrap;word-break:break-word}.kind{font:0.82rem "IBM Plex Mono",monospace;color:var(--accent)}.badge{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;font-size:.8rem;font-weight:600;background:var(--accent-soft);color:var(--accent)}.ok{background:rgba(22,101,52,.12);color:var(--ok)}.warn{background:rgba(180,83,9,.14);color:var(--warn)}
14
- @media (max-width:1080px){.toolbar{grid-template-columns:repeat(2,minmax(0,1fr))}.layout{grid-template-columns:1fr}.events{max-height:none}}@media (max-width:640px){.shell{padding:18px 12px 32px}.toolbar{grid-template-columns:1fr}}
11
+ .shell{max-width:1280px;margin:0 auto;padding:28px 18px 40px}
12
+ .hero,.panel{background:var(--panel);border:1px solid var(--line);border-radius:22px;backdrop-filter:blur(14px);box-shadow:var(--shadow)}
13
+ .hero{padding:24px;margin-bottom:18px}.hero h1,.panel h2{margin:0;font-family:"IBM Plex Serif","Iowan Old Style",serif}.hero h1{font-size:clamp(2rem,4vw,3.1rem)}.hero p,.subtle,.empty{color:var(--muted)}
14
+ .summary,.grid,.events,.toolbar,.layout,.tabnav,.memlist,.peers,.bridges{display:grid;gap:12px}
15
+ .summary{grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin-top:18px}
16
+ .chip,.card,.event,.memcard,.peer,.bridge{background:rgba(255,255,255,.62);border:1px solid var(--line);border-radius:16px;padding:14px}
17
+ .chip strong{display:block;font-size:1.3rem}.chip span{font-size:.92rem;color:var(--muted)}
18
+ .toolbar{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));padding:16px;margin-bottom:14px}
19
+ .field{display:flex;flex-direction:column;gap:6px}.field label{font-size:.8rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
20
+ .field input,.field select,.field button,.field textarea{width:100%;padding:11px 12px;border-radius:12px;border:1px solid var(--line);background:#fffaf1;font:inherit;color:var(--ink)}
21
+ .field button{cursor:pointer;color:#fff;background:linear-gradient(135deg,var(--accent),#115e59);border:none;font-weight:600}
22
+ .tabnav{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));padding:8px;margin-bottom:18px;gap:6px}
23
+ .tabnav button{cursor:pointer;background:transparent;border:1px solid transparent;border-radius:14px;padding:10px 14px;font:inherit;color:var(--muted);font-weight:600}
24
+ .tabnav button.active{background:rgba(255,255,255,.7);border-color:var(--line);color:var(--ink);box-shadow:0 4px 12px rgba(69,58,35,.06)}
25
+ .tab{display:none}.tab.active{display:block}
26
+ .layout{grid-template-columns:1.05fr 1.4fr;align-items:start}
27
+ .panel{padding:18px}.head,.row{display:flex;justify-content:space-between;gap:10px}.head{align-items:baseline;margin-bottom:14px;flex-wrap:wrap}
28
+ .grid{grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}
29
+ .events,.memlist{max-height:72vh;overflow:auto;padding-right:4px}
30
+ .card h3,.event h3,.memcard h3,.peer h3,.bridge h3{margin:0;font-size:1rem}
31
+ .meta,.workspace{font-size:.9rem;color:var(--muted)}
32
+ .event p,.memcard p{margin:8px 0 0;white-space:pre-wrap;word-break:break-word}
33
+ .memcard pre{margin:8px 0 0;white-space:pre-wrap;word-break:break-word;font:13px/1.5 "IBM Plex Mono",monospace;background:rgba(15,118,110,.04);padding:10px;border-radius:10px;max-height:14em;overflow:auto}
34
+ .kind,.score{font:0.82rem "IBM Plex Mono",monospace;color:var(--accent)}
35
+ .badge{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;font-size:.8rem;font-weight:600;background:var(--accent-soft);color:var(--accent)}
36
+ .ok{background:rgba(22,101,52,.12);color:var(--ok)}.warn{background:rgba(180,83,9,.14);color:var(--warn)}.danger{background:rgba(159,18,57,.12);color:var(--danger)}
37
+ .mem-toolbar{grid-template-columns:1.5fr 1fr 1fr 0.7fr 0.7fr}
38
+ .topology{display:grid;grid-template-columns:1fr 1.2fr;gap:14px}
39
+ @media (max-width:1080px){.toolbar,.mem-toolbar{grid-template-columns:repeat(2,minmax(0,1fr))}.layout,.topology{grid-template-columns:1fr}.events,.memlist{max-height:none}}
40
+ @media (max-width:640px){.shell{padding:18px 12px 32px}.toolbar,.mem-toolbar,.tabnav{grid-template-columns:1fr}}
15
41
  </style>
16
42
  </head>
17
43
  <body>
18
44
  <div class="shell">
19
45
  <section class="hero">
20
- <h1>Observation Center</h1>
21
- <p>Read-only Flair dashboard for OrgEvents, roster state, and latest workspace snapshots. Polling every 5 seconds.</p>
46
+ <h1>Flair Observation Center</h1>
47
+ <p>Read-only dashboard for memories, agents, federation state, bridges, and org-wide events. Polling every 5 seconds on active tab.</p>
22
48
  <div class="summary" id="summary"></div>
23
49
  </section>
50
+
24
51
  <section class="panel toolbar">
25
52
  <div class="field"><label for="baseUrl">Flair URL</label><input id="baseUrl" placeholder="(same origin)"></div>
26
53
  <div class="field"><label for="adminPass">Admin password</label><input id="adminPass" type="password" placeholder="Basic admin auth"></div>
27
- <div class="field"><label for="agentFilter">Agent</label><select id="agentFilter"><option value="">All agents</option></select></div>
28
- <div class="field"><label for="kindFilter">Event kind</label><select id="kindFilter"><option value="">All kinds</option></select></div>
29
- <div class="field"><label for="timeFilter">Time range</label><select id="timeFilter"><option value="15m">Last 15 minutes</option><option value="1h" selected>Last hour</option><option value="6h">Last 6 hours</option><option value="24h">Last 24 hours</option></select></div>
30
54
  <div class="field"><label>&nbsp;</label><button id="refresh">Refresh now</button></div>
31
55
  </section>
32
- <div class="layout">
56
+
57
+ <nav class="panel tabnav" role="tablist">
58
+ <button data-tab="agents" class="active" role="tab">Agents</button>
59
+ <button data-tab="memories" role="tab">Memories</button>
60
+ <button data-tab="federation" role="tab">Federation</button>
61
+ <button data-tab="bridges" role="tab">Bridges</button>
62
+ <button data-tab="activity" role="tab">Activity</button>
63
+ </nav>
64
+
65
+ <!-- ── Agents tab ──────────────────────────────────────────────────── -->
66
+ <section class="tab active" id="tab-agents">
67
+ <section class="panel toolbar">
68
+ <div class="field"><label for="agentFilter">Agent</label><select id="agentFilter"><option value="">All agents</option></select></div>
69
+ <div class="field"><label for="kindFilter">Event kind</label><select id="kindFilter"><option value="">All kinds</option></select></div>
70
+ <div class="field"><label for="timeFilter">Time range</label><select id="timeFilter"><option value="15m">Last 15m</option><option value="1h" selected>Last hour</option><option value="6h">Last 6h</option><option value="24h">Last 24h</option></select></div>
71
+ </section>
72
+ <div class="layout">
73
+ <section class="panel">
74
+ <div class="head"><div><h2>Agent Grid</h2><p class="subtle">Status derived from recent OrgEvents and workspace snapshots.</p></div><p class="subtle" id="agentStatus">Loading agents…</p></div>
75
+ <div class="grid" id="agentGrid"></div>
76
+ </section>
77
+ <section class="panel">
78
+ <div class="head"><div><h2>Agent-scoped events</h2><p class="subtle">Events visible to <code>anvil</code> via <code>/OrgEventCatchup/anvil</code>.</p></div><p class="subtle" id="feedStatus">Waiting for first poll…</p></div>
79
+ <div class="events" id="eventFeed"></div>
80
+ </section>
81
+ </div>
82
+ </section>
83
+
84
+ <!-- ── Memories tab ────────────────────────────────────────────────── -->
85
+ <section class="tab" id="tab-memories">
86
+ <section class="panel toolbar mem-toolbar">
87
+ <div class="field"><label for="memQuery">Search (semantic)</label><input id="memQuery" placeholder="freetext query…"></div>
88
+ <div class="field"><label for="memAgent">Agent</label><select id="memAgent"><option value="">All</option></select></div>
89
+ <div class="field"><label for="memDurability">Durability</label><select id="memDurability"><option value="">Any</option><option value="permanent">permanent</option><option value="persistent">persistent</option><option value="standard">standard</option><option value="ephemeral">ephemeral</option></select></div>
90
+ <div class="field"><label for="memLimit">Limit</label><input id="memLimit" type="number" value="20" min="1" max="100"></div>
91
+ <div class="field"><label>&nbsp;</label><button id="memSearch">Search</button></div>
92
+ </section>
93
+ <section class="panel">
94
+ <div class="head"><div><h2>Memories</h2><p class="subtle">Semantic search via <code>/SemanticSearch</code>. Empty query returns recent memories.</p></div><p class="subtle" id="memStatus">Enter a query and click Search.</p></div>
95
+ <div class="memlist" id="memList"></div>
96
+ </section>
97
+ </section>
98
+
99
+ <!-- ── Federation tab ──────────────────────────────────────────────── -->
100
+ <section class="tab" id="tab-federation">
101
+ <div class="topology">
102
+ <section class="panel">
103
+ <div class="head"><h2>This instance</h2></div>
104
+ <div id="fedInstance"><p class="subtle">Loading…</p></div>
105
+ </section>
106
+ <section class="panel">
107
+ <div class="head"><div><h2>Peers</h2><p class="subtle">Hub + spoke connections.</p></div><p class="subtle" id="fedPeerStatus"></p></div>
108
+ <div class="peers" id="fedPeers"></div>
109
+ </section>
110
+ </div>
111
+ </section>
112
+
113
+ <!-- ── Bridges tab ─────────────────────────────────────────────────── -->
114
+ <section class="tab" id="tab-bridges">
33
115
  <section class="panel">
34
- <div class="head"><div><h2>Agent Grid</h2><p class="subtle">Status is derived from recent OrgEvents and workspace snapshots.</p></div><p class="subtle" id="agentStatus">Loading agents…</p></div>
35
- <div class="grid" id="agentGrid"></div>
116
+ <div class="head"><div><h2>Memory Bridges</h2><p class="subtle">Pluggable import/export adapters.</p></div><p class="subtle" id="bridgeStatus">Loading…</p></div>
117
+ <div class="bridges" id="bridgeList"></div>
118
+ </section>
119
+ </section>
120
+
121
+ <!-- ── Activity tab ────────────────────────────────────────────────── -->
122
+ <section class="tab" id="tab-activity">
123
+ <section class="panel toolbar">
124
+ <div class="field"><label for="actAgent">Agent</label><select id="actAgent"><option value="">All</option></select></div>
125
+ <div class="field"><label for="actKind">Kind</label><select id="actKind"><option value="">All</option></select></div>
126
+ <div class="field"><label for="actTime">Time range</label><select id="actTime"><option value="15m">Last 15m</option><option value="1h" selected>Last hour</option><option value="6h">Last 6h</option><option value="24h">Last 24h</option></select></div>
36
127
  </section>
37
128
  <section class="panel">
38
- <div class="head"><div><h2>Event Feed</h2><p class="subtle">Events visible to <code>anvil</code> via <code>/OrgEventCatchup/anvil</code>.</p></div><p class="subtle" id="feedStatus">Waiting for first poll…</p></div>
39
- <div class="events" id="eventFeed"></div>
129
+ <div class="head"><div><h2>Org-wide events</h2><p class="subtle">All OrgEvent rows visible to admin.</p></div><p class="subtle" id="actStatus">Loading…</p></div>
130
+ <div class="events" id="actFeed"></div>
40
131
  </section>
41
- </div>
132
+ </section>
42
133
  </div>
134
+
43
135
  <script>
44
- const state={anchorAgent:"anvil",agents:[],events:[],workspace:{},latestPoll:null,timer:null};
45
- const els={summary:document.querySelector("#summary"),baseUrl:document.querySelector("#baseUrl"),adminPass:document.querySelector("#adminPass"),agentFilter:document.querySelector("#agentFilter"),kindFilter:document.querySelector("#kindFilter"),timeFilter:document.querySelector("#timeFilter"),refresh:document.querySelector("#refresh"),agentGrid:document.querySelector("#agentGrid"),eventFeed:document.querySelector("#eventFeed"),agentStatus:document.querySelector("#agentStatus"),feedStatus:document.querySelector("#feedStatus")};
46
- const SS_PASS="flair.observation.adminPass",SS_URL="flair.observation.baseUrl";
47
- const storedPass=sessionStorage.getItem(SS_PASS);if(storedPass)els.adminPass.value=storedPass;
48
- const storedUrl=sessionStorage.getItem(SS_URL);if(storedUrl)els.baseUrl.value=storedUrl;
49
- function authHeader(){const p=clean(els.adminPass.value);return p?"Basic "+btoa("admin:"+p):""}
50
- const clean=v=>String(v??"").trim();
51
- const ESC_MAP={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"};
52
- // esc() is used in BOTH attribute and text contexts; browsers HTML-decode attribute
53
- // values when reading .value, so round-trips (e.g. agentFilter.value) preserve the original.
54
- const esc=v=>String(v??"").replace(/[&<>"']/g,c=>ESC_MAP[c]);
55
- const parse=v=>{if(!v)return null;if(typeof v==="object")return v;try{return JSON.parse(v)}catch{return null}};
56
- const list=v=>Array.isArray(v)?v:Array.isArray(v?.items)?v.items:Array.isArray(v?.data)?v.data:[];
57
- const eventKind=e=>clean(e?.kind||e?.type||e?.status||"unknown");
58
- const eventText=e=>clean(e?.summary||e?.detail||e?.message||"");
59
- const eventAgents=e=>[e?.authorId,...(Array.isArray(e?.targetIds)?e.targetIds:[])].map(clean).filter(Boolean);
60
- const timeAgo=iso=>{const diff=Date.now()-new Date(iso).getTime();if(!Number.isFinite(diff))return"unknown";const m=Math.round(diff/6e4);if(m<1)return"just now";if(m<60)return`${m}m ago`;const h=Math.round(m/60);if(h<24)return`${h}h ago`;return`${Math.round(h/24)}d ago`};
61
- const rangeMs=()=>({"15m":9e5,"1h":36e5,"6h":216e5,"24h":864e5}[els.timeFilter.value]||36e5);
62
- const rangeStart=()=>new Date(Date.now()-rangeMs());
63
- async function getJson(url){const headers={accept:"application/json"};const auth=authHeader();if(auth)headers.authorization=auth;const res=await fetch(url,{headers});if(res.status===401){sessionStorage.removeItem(SS_PASS);els.adminPass.value="";throw new Error("401 unauthorized — enter admin password and refresh")}if(!res.ok)throw new Error(`${res.status} ${res.statusText}`);return res.json()}
64
- async function loadRoster(baseUrl){return list(await getJson(baseUrl+"/Agent"))}
65
- async function loadWorkspace(baseUrl,agentIds){const rows=await Promise.all(agentIds.map(async id=>{try{return[id,await getJson(baseUrl+"/WorkspaceLatest/"+encodeURIComponent(id))]}catch{return[id,null]}}));state.workspace=Object.fromEntries(rows)}
66
- function deriveAgent(agent){
67
- const id=clean(agent.id||agent.agentId||agent.name);
68
- const related=state.events.filter(event=>eventAgents(event).includes(id));
69
- const latest=related.at(-1);
70
- const ws=state.workspace[id];
71
- const snapshot=parse(ws?.snapshot);
72
- const lastAt=clean(latest?.createdAt||ws?.timestamp||ws?.createdAt||agent.updatedAt||agent.createdAt);
73
- const online=lastAt&&(Date.now()-new Date(lastAt).getTime())<6e5;
74
- return{id,name:clean(agent.name||id),role:clean(agent.role||agent.type||"agent"),status:online?"online":"offline",lastAt,currentTask:clean(parse(latest?.detail)?.taskId||latest?.refId||latest?.summary||"idle"),latestKind:eventKind(latest),workspace:clean(snapshot?.gitBranch||ws?.gitBranch||ws?.path||"unavailable")};
136
+ // ── Constants + state ───────────────────────────────────────────────
137
+ const SS_PASS = "flair.observation.adminPass";
138
+ const SS_URL = "flair.observation.baseUrl";
139
+ const ESC_MAP = {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"};
140
+ const state = {
141
+ tab: "agents",
142
+ timer: null,
143
+ anchorAgent: "anvil",
144
+ agents: [],
145
+ events: [],
146
+ workspace: {},
147
+ memories: [],
148
+ federationInstance: null,
149
+ federationPeers: [],
150
+ bridges: [],
151
+ orgEvents: [],
152
+ latestPoll: null,
153
+ };
154
+
155
+ const els = {};
156
+ [
157
+ "summary","baseUrl","adminPass","refresh",
158
+ "agentFilter","kindFilter","timeFilter","agentGrid","agentStatus","eventFeed","feedStatus",
159
+ "memQuery","memAgent","memDurability","memLimit","memSearch","memList","memStatus",
160
+ "fedInstance","fedPeers","fedPeerStatus",
161
+ "bridgeList","bridgeStatus",
162
+ "actAgent","actKind","actTime","actFeed","actStatus",
163
+ ].forEach(k => { els[k] = document.querySelector("#" + k); });
164
+ const tabs = [...document.querySelectorAll(".tabnav button")];
165
+
166
+ // ── Auth + utilities ────────────────────────────────────────────────
167
+ const clean = v => String(v ?? "").trim();
168
+ const esc = v => String(v ?? "").replace(/[&<>"']/g, c => ESC_MAP[c]);
169
+ const parse = v => { if (!v) return null; if (typeof v === "object") return v; try { return JSON.parse(v); } catch { return null; } };
170
+ const list = v => Array.isArray(v) ? v : Array.isArray(v?.items) ? v.items : Array.isArray(v?.data) ? v.data : Array.isArray(v?.results) ? v.results : [];
171
+ const eventKind = e => clean(e?.kind || e?.type || e?.status || "unknown");
172
+ const eventText = e => clean(e?.summary || e?.detail || e?.message || "");
173
+ const eventAgents = e => [e?.authorId, ...(Array.isArray(e?.targetIds) ? e.targetIds : [])].map(clean).filter(Boolean);
174
+ const timeAgo = iso => { const d = Date.now() - new Date(iso).getTime(); if (!Number.isFinite(d)) return "unknown"; const m = Math.round(d/6e4); if (m<1) return "just now"; if (m<60) return `${m}m ago`; const h = Math.round(m/60); if (h<24) return `${h}h ago`; return `${Math.round(h/24)}d ago`; };
175
+
176
+ function authHeader() { const p = clean(els.adminPass.value); return p ? "Basic " + btoa("admin:" + p) : ""; }
177
+ function baseUrl() { return clean(els.baseUrl.value).replace(/\/$/, ""); }
178
+ function rangeMs(sel) { return ({"15m":9e5,"1h":36e5,"6h":216e5,"24h":864e5})[sel] || 36e5; }
179
+
180
+ sessionStorage.getItem(SS_PASS) && (els.adminPass.value = sessionStorage.getItem(SS_PASS));
181
+ sessionStorage.getItem(SS_URL) && (els.baseUrl.value = sessionStorage.getItem(SS_URL));
182
+
183
+ async function api(path, opts = {}) {
184
+ const headers = { accept: "application/json", ...(opts.headers || {}) };
185
+ const auth = authHeader();
186
+ if (auth) headers.authorization = auth;
187
+ if (opts.body && typeof opts.body !== "string") {
188
+ headers["content-type"] = "application/json";
189
+ opts.body = JSON.stringify(opts.body);
190
+ }
191
+ const res = await fetch(baseUrl() + path, { ...opts, headers });
192
+ if (res.status === 401) { sessionStorage.removeItem(SS_PASS); els.adminPass.value = ""; throw new Error("401 unauthorized — enter admin password"); }
193
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
194
+ const ct = res.headers.get("content-type") || "";
195
+ return ct.includes("application/json") ? res.json() : res.text();
196
+ }
197
+
198
+ // ── Tab switching ───────────────────────────────────────────────────
199
+ function setTab(name) {
200
+ state.tab = name;
201
+ tabs.forEach(b => b.classList.toggle("active", b.dataset.tab === name));
202
+ document.querySelectorAll(".tab").forEach(s => s.classList.toggle("active", s.id === "tab-" + name));
203
+ refresh();
204
+ }
205
+ tabs.forEach(b => b.addEventListener("click", () => setTab(b.dataset.tab)));
206
+
207
+ // ── Agents tab ──────────────────────────────────────────────────────
208
+ async function loadAgentsTab() {
209
+ const since = new Date(Date.now() - rangeMs(els.timeFilter.value)).toISOString();
210
+ const [agents, events] = await Promise.all([
211
+ api("/Agent").then(list),
212
+ api(`/OrgEventCatchup/${encodeURIComponent(state.anchorAgent)}?since=${encodeURIComponent(since)}`).then(list),
213
+ ]);
214
+ state.agents = agents;
215
+ state.events = events.sort((a,b) => clean(a.createdAt).localeCompare(clean(b.createdAt)));
216
+ const ids = agents.map(a => clean(a.id || a.agentId || a.name)).filter(Boolean);
217
+ const rows = await Promise.all(ids.map(async id => { try { return [id, await api("/WorkspaceLatest/" + encodeURIComponent(id))]; } catch { return [id, null]; } }));
218
+ state.workspace = Object.fromEntries(rows);
219
+ }
220
+ function deriveAgent(agent) {
221
+ const id = clean(agent.id || agent.agentId || agent.name);
222
+ const related = state.events.filter(e => eventAgents(e).includes(id));
223
+ const latest = related.at(-1);
224
+ const ws = state.workspace[id];
225
+ const snap = parse(ws?.snapshot);
226
+ const lastAt = clean(latest?.createdAt || ws?.timestamp || ws?.createdAt || agent.updatedAt || agent.createdAt);
227
+ const online = lastAt && (Date.now() - new Date(lastAt).getTime()) < 6e5;
228
+ return { id, name: clean(agent.name || id), role: clean(agent.role || agent.type || "agent"), status: online ? "online" : "offline", lastAt, currentTask: clean(parse(latest?.detail)?.taskId || latest?.refId || latest?.summary || "idle"), latestKind: eventKind(latest), workspace: clean(snap?.gitBranch || ws?.gitBranch || ws?.path || "unavailable") };
229
+ }
230
+ function renderAgentsTab() {
231
+ // populate filter dropdowns
232
+ const agentIds = state.agents.map(a => clean(a.id || a.agentId || a.name)).filter(Boolean).sort();
233
+ const kinds = [...new Set(state.events.map(eventKind).filter(Boolean))].sort();
234
+ els.agentFilter.innerHTML = `<option value="">All agents</option>` + agentIds.map(id => `<option value="${esc(id)}" ${id===els.agentFilter.value?"selected":""}>${esc(id)}</option>`).join("");
235
+ els.kindFilter.innerHTML = `<option value="">All kinds</option>` + kinds.map(k => `<option value="${esc(k)}" ${k===els.kindFilter.value?"selected":""}>${esc(k)}</option>`).join("");
236
+ // agent cards
237
+ const cards = state.agents.map(deriveAgent);
238
+ els.agentStatus.textContent = `${cards.length} agents via /Agent`;
239
+ els.agentGrid.innerHTML = cards.length ? cards.map(a => `<article class="card"><div class="row"><h3>${esc(a.name)}</h3><span class="badge ${a.status==="online"?"ok":"warn"}">${esc(a.status)}</span></div><div class="meta">${esc(a.role)}</div><div class="row"><span class="kind">${esc(a.latestKind)}</span><span class="meta">${a.lastAt?timeAgo(a.lastAt):"no signal"}</span></div><p><strong>Current task:</strong> ${esc(a.currentTask)}</p><div class="workspace">Workspace: ${esc(a.workspace)}</div></article>`).join("") : `<div class="empty">No agents found.</div>`;
240
+ // event feed (filtered)
241
+ const start = new Date(Date.now() - rangeMs(els.timeFilter.value));
242
+ const filteredEvents = state.events.filter(e => {
243
+ if (new Date(e.createdAt || 0) < start) return false;
244
+ if (els.agentFilter.value && !eventAgents(e).includes(els.agentFilter.value)) return false;
245
+ if (els.kindFilter.value && eventKind(e) !== els.kindFilter.value) return false;
246
+ return true;
247
+ }).slice().reverse();
248
+ els.feedStatus.textContent = `${filteredEvents.length} matching events`;
249
+ els.eventFeed.innerHTML = filteredEvents.length ? filteredEvents.map(e => `<article class="event"><div class="row"><h3>${esc(clean(e.summary||eventKind(e)))}</h3><span class="meta">${timeAgo(e.createdAt)}</span></div><div class="row"><span class="kind">${esc(eventKind(e))}</span><span class="badge">${esc(eventAgents(e).join(", "))||"org-wide"}</span></div><p>${esc(eventText(e))||"No detail provided."}</p></article>`).join("") : `<div class="empty">No events match.</div>`;
250
+ // summary chips
251
+ els.summary.innerHTML = [["Agents", cards.length], ["Online", cards.filter(c=>c.status==="online").length], ["Events", filteredEvents.length], ["Last Poll", state.latestPoll ? timeAgo(state.latestPoll) : "never"]].map(([l,v]) => `<div class="chip"><strong>${v}</strong><span>${l}</span></div>`).join("");
252
+ }
253
+
254
+ // ── Memories tab ────────────────────────────────────────────────────
255
+ async function loadMemoriesTab() {
256
+ const q = clean(els.memQuery.value);
257
+ const limit = Math.max(1, Math.min(100, Number(els.memLimit.value) || 20));
258
+ els.memStatus.textContent = "Searching…";
259
+ const body = { q: q || "", limit };
260
+ if (els.memAgent.value) body.agentId = els.memAgent.value;
261
+ if (els.memDurability.value) body.durability = els.memDurability.value;
262
+ const res = await api("/SemanticSearch", { method: "POST", body });
263
+ state.memories = list(res);
264
+ }
265
+ function renderMemoriesTab() {
266
+ // populate agent dropdown from agents we already loaded
267
+ const agentIds = state.agents.map(a => clean(a.id || a.agentId || a.name)).filter(Boolean).sort();
268
+ const cur = els.memAgent.value;
269
+ els.memAgent.innerHTML = `<option value="">All</option>` + agentIds.map(id => `<option value="${esc(id)}" ${id===cur?"selected":""}>${esc(id)}</option>`).join("");
270
+ els.memStatus.textContent = `${state.memories.length} result${state.memories.length===1?"":"s"}`;
271
+ els.memList.innerHTML = state.memories.length ? state.memories.map(m => {
272
+ const tags = [m.durability && `<span class="badge">${esc(m.durability)}</span>`, m.archived && `<span class="badge warn">archived</span>`, m._safetyFlags && `<span class="badge danger">flagged</span>`].filter(Boolean).join(" ");
273
+ const score = m._score != null ? `<span class="score">score ${Number(m._score).toFixed(2)}</span>` : "";
274
+ return `<article class="memcard"><div class="row"><h3>${esc(m.id || "(no id)")}</h3>${score}</div><div class="row"><span class="meta">${esc(m.agentId||"")} · ${m.createdAt?timeAgo(m.createdAt):""}</span><span>${tags}</span></div><pre>${esc(m.content || "")}</pre></article>`;
275
+ }).join("") : `<div class="empty">No memories returned.</div>`;
276
+ }
277
+
278
+ // ── Federation tab ──────────────────────────────────────────────────
279
+ async function loadFederationTab() {
280
+ const [inst, peers] = await Promise.all([
281
+ api("/FederationInstance").catch(() => null),
282
+ api("/FederationPeers").catch(() => ({ peers: [] })),
283
+ ]);
284
+ state.federationInstance = inst;
285
+ state.federationPeers = list(peers) || (peers && peers.peers) || [];
286
+ }
287
+ function renderFederationTab() {
288
+ const inst = state.federationInstance;
289
+ els.fedInstance.innerHTML = inst ? `<div class="card"><div class="row"><h3>${esc(inst.id || "(no id)")}</h3><span class="badge ${inst.status==="active"?"ok":"warn"}">${esc(inst.status||"unknown")}</span></div><div class="meta">role: ${esc(inst.role||"unknown")}</div><div class="meta" style="word-break:break-all">pubkey: <code>${esc(inst.publicKey||"")}</code></div></div>` : `<div class="empty">No federation instance configured.</div>`;
290
+ els.fedPeerStatus.textContent = `${state.federationPeers.length} peer(s)`;
291
+ els.fedPeers.innerHTML = state.federationPeers.length ? state.federationPeers.map(p => {
292
+ const status = p.status || "unknown";
293
+ const cls = status==="paired"||status==="connected" ? "ok" : status==="revoked" ? "danger" : "warn";
294
+ const sync = p.lastSyncAt ? `last sync ${timeAgo(p.lastSyncAt)}` : "never synced";
295
+ return `<article class="peer"><div class="row"><h3>${esc(p.id||"(no id)")}</h3><span class="badge ${cls}">${esc(status)}</span></div><div class="meta">role: ${esc(p.role||"")}${p.endpoint?` · <code>${esc(p.endpoint)}</code>`:""}</div><div class="meta">${esc(sync)}${p.relayOnly?" · relay-only":""}</div></article>`;
296
+ }).join("") : `<div class="empty">No peers configured. Use 'flair federation pair' on a spoke.</div>`;
297
+ }
298
+
299
+ // ── Bridges tab ─────────────────────────────────────────────────────
300
+ async function loadBridgesTab() {
301
+ // Try a few likely endpoint shapes; bridges API may have evolved.
302
+ let res = null;
303
+ for (const path of ["/Bridges", "/Bridge", "/bridges"]) {
304
+ try { res = await api(path); break; } catch {}
305
+ }
306
+ state.bridges = list(res);
307
+ }
308
+ function renderBridgesTab() {
309
+ els.bridgeStatus.textContent = `${state.bridges.length} bridge(s)`;
310
+ els.bridgeList.innerHTML = state.bridges.length ? state.bridges.map(b => {
311
+ const status = b.status || b.state || "unknown";
312
+ const cls = status==="active"||status==="allowed" ? "ok" : status==="denied"||status==="error" ? "danger" : "warn";
313
+ return `<article class="bridge"><div class="row"><h3>${esc(b.name||b.id||"(no name)")}</h3><span class="badge ${cls}">${esc(status)}</span></div><div class="meta">${esc(b.kind||b.type||"")}${b.version?` · v${esc(b.version)}`:""}</div><div class="meta">${esc(b.summary||b.description||"")}</div></article>`;
314
+ }).join("") : `<div class="empty">No bridges registered (or /Bridges endpoint unavailable on this instance).</div>`;
315
+ }
316
+
317
+ // ── Activity tab ────────────────────────────────────────────────────
318
+ async function loadActivityTab() {
319
+ const since = new Date(Date.now() - rangeMs(els.actTime.value)).toISOString();
320
+ // Prefer /OrgEvent (admin-wide); fall back to /OrgEventCatchup/anvil.
321
+ let events;
322
+ try { events = list(await api(`/OrgEvent?since=${encodeURIComponent(since)}`)); }
323
+ catch { events = list(await api(`/OrgEventCatchup/anvil?since=${encodeURIComponent(since)}`)); }
324
+ state.orgEvents = events.sort((a,b) => clean(a.createdAt).localeCompare(clean(b.createdAt)));
325
+ }
326
+ function renderActivityTab() {
327
+ const agentIds = state.agents.map(a => clean(a.id || a.agentId || a.name)).filter(Boolean).sort();
328
+ const kinds = [...new Set(state.orgEvents.map(eventKind).filter(Boolean))].sort();
329
+ els.actAgent.innerHTML = `<option value="">All</option>` + agentIds.map(id => `<option value="${esc(id)}" ${id===els.actAgent.value?"selected":""}>${esc(id)}</option>`).join("");
330
+ els.actKind.innerHTML = `<option value="">All</option>` + kinds.map(k => `<option value="${esc(k)}" ${k===els.actKind.value?"selected":""}>${esc(k)}</option>`).join("");
331
+ const filtered = state.orgEvents.filter(e => {
332
+ if (els.actAgent.value && !eventAgents(e).includes(els.actAgent.value)) return false;
333
+ if (els.actKind.value && eventKind(e) !== els.actKind.value) return false;
334
+ return true;
335
+ }).slice().reverse();
336
+ els.actStatus.textContent = `${filtered.length} matching events`;
337
+ els.actFeed.innerHTML = filtered.length ? filtered.map(e => `<article class="event"><div class="row"><h3>${esc(clean(e.summary||eventKind(e)))}</h3><span class="meta">${timeAgo(e.createdAt)}</span></div><div class="row"><span class="kind">${esc(eventKind(e))}</span><span class="badge">${esc(eventAgents(e).join(", "))||"org-wide"}</span></div><p>${esc(eventText(e))||"No detail provided."}</p></article>`).join("") : `<div class="empty">No events match.</div>`;
338
+ }
339
+
340
+ // ── Refresh dispatcher ─────────────────────────────────────────────
341
+ async function refresh() {
342
+ try {
343
+ // Always keep the agent roster fresh (used by multiple tabs for filters).
344
+ if (state.tab !== "memories" || !state.agents.length) {
345
+ try { state.agents = list(await api("/Agent")); } catch {}
346
+ }
347
+ if (state.tab === "agents") await loadAgentsTab();
348
+ if (state.tab === "memories") { /* memories load on Search click only */ }
349
+ if (state.tab === "federation") await loadFederationTab();
350
+ if (state.tab === "bridges") await loadBridgesTab();
351
+ if (state.tab === "activity") await loadActivityTab();
352
+ state.latestPoll = new Date().toISOString();
353
+ } catch (err) {
354
+ // Surface error in the active tab's status field.
355
+ const sel = { agents: els.feedStatus, memories: els.memStatus, federation: els.fedPeerStatus, bridges: els.bridgeStatus, activity: els.actStatus }[state.tab];
356
+ if (sel) sel.textContent = `Failed: ${err.message}`;
357
+ }
358
+ // Render whichever tab is visible.
359
+ if (state.tab === "agents") renderAgentsTab();
360
+ if (state.tab === "memories") renderMemoriesTab();
361
+ if (state.tab === "federation") renderFederationTab();
362
+ if (state.tab === "bridges") renderBridgesTab();
363
+ if (state.tab === "activity") renderActivityTab();
75
364
  }
76
- function filteredEvents(){const agent=els.agentFilter.value,kind=els.kindFilter.value,start=rangeStart();return state.events.filter(e=>{if(new Date(e.createdAt||0)<start)return false;if(agent&&!eventAgents(e).includes(agent))return false;if(kind&&eventKind(e)!==kind)return false;return true}).slice().reverse()}
77
- function renderSummary(cards,events){const items=[["Agents",cards.length],["Online",cards.filter(c=>c.status==="online").length],["Events",events.length],["Last Poll",state.latestPoll?timeAgo(state.latestPoll):"never"]];els.summary.innerHTML=items.map(([l,v])=>`<div class="chip"><strong>${v}</strong><span>${l}</span></div>`).join("")}
78
- function renderFilters(){const agents=state.agents.map(a=>clean(a.id||a.agentId||a.name)).filter(Boolean).sort();const kinds=[...new Set(state.events.map(eventKind).filter(Boolean))].sort();const chosenAgent=els.agentFilter.value,chosenKind=els.kindFilter.value;els.agentFilter.innerHTML=`<option value="">All agents</option>`+agents.map(id=>`<option value="${esc(id)}" ${id===chosenAgent?"selected":""}>${esc(id)}</option>`).join("");els.kindFilter.innerHTML=`<option value="">All kinds</option>`+kinds.map(kind=>`<option value="${esc(kind)}" ${kind===chosenKind?"selected":""}>${esc(kind)}</option>`).join("")}
79
- function renderAgents(cards){els.agentStatus.textContent=`${cards.length} agents via /Agent`;els.agentGrid.innerHTML=cards.length?cards.map(agent=>`<article class="card"><div class="row"><h3>${esc(agent.name)}</h3><span class="badge ${agent.status==="online"?"ok":"warn"}">${esc(agent.status)}</span></div><div class="meta">${esc(agent.role)}</div><div class="row"><span class="kind">${esc(agent.latestKind)}</span><span class="meta">${agent.lastAt?timeAgo(agent.lastAt):"no signal"}</span></div><p><strong>Current task:</strong> ${esc(agent.currentTask)}</p><div class="workspace">Workspace: ${esc(agent.workspace)}</div></article>`).join(""):`<div class="empty">No agents found.</div>`}
80
- function renderEvents(events){els.feedStatus.textContent=`${events.length} matching events`;els.eventFeed.innerHTML=events.length?events.map(event=>`<article class="event"><div class="row"><h3>${esc(clean(event.summary||eventKind(event)))}</h3><span class="meta">${timeAgo(event.createdAt)}</span></div><div class="row"><span class="kind">${esc(eventKind(event))}</span><span class="badge">${esc(eventAgents(event).join(", "))||"org-wide"}</span></div><p>${esc(eventText(event))||"No detail provided."}</p></article>`).join(""):`<div class="empty">No events match the current filters.</div>`}
81
- function render(){renderFilters();const cards=state.agents.map(deriveAgent);const events=filteredEvents();renderSummary(cards,events);renderAgents(cards);renderEvents(events)}
82
- async function refresh(){const baseUrl=clean(els.baseUrl.value).replace(/\/$/,"");const since=rangeStart().toISOString();els.feedStatus.textContent="Polling Flair…";try{const [agents,events]=await Promise.all([loadRoster(baseUrl),getJson(`${baseUrl}/OrgEventCatchup/${encodeURIComponent(state.anchorAgent)}?since=${encodeURIComponent(since)}`)]);state.agents=agents;state.events=list(events).sort((a,b)=>clean(a.createdAt).localeCompare(clean(b.createdAt)));await loadWorkspace(baseUrl,state.agents.map(a=>clean(a.id||a.agentId||a.name)).filter(Boolean));state.latestPoll=new Date().toISOString();render()}catch(error){els.feedStatus.textContent=`Poll failed: ${error.message}`}}
83
- function schedule(){clearInterval(state.timer);state.timer=setInterval(refresh,5e3)}
84
- [els.agentFilter,els.kindFilter,els.timeFilter].forEach(el=>el.addEventListener("change",()=>{render();if(el===els.timeFilter)refresh()}));
85
- els.refresh.addEventListener("click",refresh);
86
- els.baseUrl.addEventListener("change",()=>{sessionStorage.setItem(SS_URL,clean(els.baseUrl.value));refresh();schedule()});
87
- els.adminPass.addEventListener("change",()=>{sessionStorage.setItem(SS_PASS,clean(els.adminPass.value));refresh()});
88
- refresh();schedule();
365
+
366
+ function schedule() { clearInterval(state.timer); state.timer = setInterval(refresh, 5e3); }
367
+
368
+ // ── Wire interactions ───────────────────────────────────────────────
369
+ els.refresh.addEventListener("click", refresh);
370
+ els.baseUrl.addEventListener("change", () => { sessionStorage.setItem(SS_URL, clean(els.baseUrl.value)); refresh(); schedule(); });
371
+ els.adminPass.addEventListener("change", () => { sessionStorage.setItem(SS_PASS, clean(els.adminPass.value)); refresh(); });
372
+ [els.agentFilter, els.kindFilter, els.timeFilter].forEach(el => el.addEventListener("change", () => { renderAgentsTab(); if (el === els.timeFilter) refresh(); }));
373
+ [els.actAgent, els.actKind, els.actTime].forEach(el => el.addEventListener("change", () => { renderActivityTab(); if (el === els.actTime) refresh(); }));
374
+ els.memSearch.addEventListener("click", async () => {
375
+ try { await loadMemoriesTab(); } catch (err) { els.memStatus.textContent = `Search failed: ${err.message}`; return; }
376
+ renderMemoriesTab();
377
+ });
378
+ els.memQuery.addEventListener("keydown", e => { if (e.key === "Enter") els.memSearch.click(); });
379
+
380
+ // Boot
381
+ refresh();
382
+ schedule();
89
383
  </script>
90
384
  </body>
91
385
  </html>
@@ -1,46 +0,0 @@
1
- /**
2
- * Unified descriptor loader.
3
- *
4
- * Given a discovered bridge, resolve it into a `YamlBridgeDescriptor`
5
- * that the runtime executor can drive — regardless of whether the
6
- * descriptor came from the in-tree built-in registry, a project-local
7
- * YAML, a user-scoped YAML, or an npm package.
8
- *
9
- * Code-plugin bridges (Shape B, npm packages) are not supported in
10
- * slice 2 — calling `loadDescriptor` on one returns a structured error
11
- * pointing at slice 3.
12
- */
13
- import { BridgeRuntimeError } from "../types.js";
14
- import { loadYamlDescriptor } from "./yaml-loader.js";
15
- import { BUILTIN_BY_NAME } from "../builtins/index.js";
16
- export async function loadDescriptor(discovered) {
17
- switch (discovered.source) {
18
- case "builtin": {
19
- const builtin = BUILTIN_BY_NAME.get(discovered.name);
20
- if (!builtin) {
21
- throw new BridgeRuntimeError({
22
- bridge: discovered.name,
23
- op: "import",
24
- field: "(builtin)",
25
- expected: `registered built-in name`,
26
- got: discovered.name,
27
- hint: `discovery surfaced built-in "${discovered.name}" but the registry has no entry — likely a code drift between discover.ts and builtins/index.ts`,
28
- });
29
- }
30
- return builtin.descriptor;
31
- }
32
- case "project-yaml":
33
- case "user-yaml":
34
- return await loadYamlDescriptor(discovered.path);
35
- case "npm-package":
36
- throw new BridgeRuntimeError({
37
- bridge: discovered.name,
38
- op: "import",
39
- path: discovered.path,
40
- field: "(kind)",
41
- expected: "yaml file or built-in",
42
- got: "npm code plugin",
43
- hint: "code-plugin bridge runtime ships in slice 3 of FLAIR-BRIDGES (alongside the `flair bridge allow` trust prompt). Use a YAML descriptor in the meantime, or wait for slice 3.",
44
- });
45
- }
46
- }