@tpsdev-ai/flair 0.5.3 → 0.6.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.
- package/README.md +3 -0
- package/dist/bridges/builtins/agentic-stack.js +58 -0
- package/dist/bridges/builtins/index.js +36 -0
- package/dist/bridges/discover.js +195 -0
- package/dist/bridges/runtime/context.js +59 -0
- package/dist/bridges/runtime/execute.js +92 -0
- package/dist/bridges/runtime/export-runner.js +105 -0
- package/dist/bridges/runtime/formats.js +136 -0
- package/dist/bridges/runtime/import-runner.js +107 -0
- package/dist/bridges/runtime/load-descriptor.js +46 -0
- package/dist/bridges/runtime/mapper.js +139 -0
- package/dist/bridges/runtime/predicate.js +122 -0
- package/dist/bridges/runtime/writers.js +130 -0
- package/dist/bridges/runtime/yaml-loader.js +148 -0
- package/dist/bridges/scaffold.js +224 -0
- package/dist/bridges/types.js +30 -0
- package/dist/cli.js +1019 -223
- package/dist/deploy.js +161 -0
- package/dist/resources/MemoryBootstrap.js +16 -6
- package/dist/resources/MemoryConsolidate.js +13 -4
- package/dist/resources/MemoryReflect.js +14 -5
- package/dist/resources/ObservationCenter.js +19 -0
- package/dist/resources/SemanticSearch.js +22 -11
- package/dist/resources/auth-middleware.js +6 -0
- package/dist/resources/health.js +443 -14
- package/package.json +7 -7
- package/ui/observation-center.html +91 -0
package/dist/resources/health.js
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { promises as fsp } from "node:fs";
|
|
3
|
+
import { homedir, platform } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
const db = databases;
|
|
6
|
+
const redactHome = (p) => {
|
|
7
|
+
const home = homedir();
|
|
8
|
+
return p.startsWith(home) ? "~" + p.slice(home.length) : p;
|
|
9
|
+
};
|
|
10
|
+
const exists = async (path) => {
|
|
11
|
+
try {
|
|
12
|
+
await fsp.stat(path);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
3
19
|
/**
|
|
4
20
|
* Health endpoint — unauthenticated, returns only { ok: true }.
|
|
5
21
|
*
|
|
@@ -15,26 +31,87 @@ export class Health extends Resource {
|
|
|
15
31
|
/**
|
|
16
32
|
* Authenticated health detail — returns memory/agent/soul stats + process info.
|
|
17
33
|
* Requires Ed25519 agent auth or admin basic auth.
|
|
34
|
+
*
|
|
35
|
+
* Every optional-subsystem lookup is wrapped so a missing table or absent
|
|
36
|
+
* schema downgrades to "not configured" rather than failing the whole call.
|
|
18
37
|
*/
|
|
19
38
|
export class HealthDetail extends Resource {
|
|
20
39
|
async get() {
|
|
21
40
|
const stats = { ok: true };
|
|
41
|
+
const nowMs = Date.now();
|
|
42
|
+
const warnings = [];
|
|
43
|
+
const ctx = this.getContext?.();
|
|
44
|
+
const request = ctx?.request ?? ctx;
|
|
45
|
+
const callerAgent = request?.tpsAgent;
|
|
46
|
+
const isAdmin = request?.tpsAgentIsAdmin === true || !callerAgent;
|
|
47
|
+
stats.caller = { agentId: callerAgent ?? null, isAdmin };
|
|
48
|
+
let memoriesList = [];
|
|
22
49
|
// ── Memory stats ──
|
|
23
50
|
try {
|
|
24
|
-
const list = [];
|
|
25
51
|
for await (const m of db.flair.Memory.search({})) {
|
|
26
|
-
|
|
52
|
+
memoriesList.push(m);
|
|
53
|
+
}
|
|
54
|
+
// Per-model counts: "hash-512d" is the hash-fallback marker; any other
|
|
55
|
+
// value (or missing value) is a real embedding. Multiple distinct
|
|
56
|
+
// non-hash models means mixed vector spaces — cross-space searches
|
|
57
|
+
// return garbage unless reconciled via `flair reembed`.
|
|
58
|
+
const modelCounts = {};
|
|
59
|
+
for (const m of memoriesList) {
|
|
60
|
+
const model = m.embeddingModel || "hash-512d";
|
|
61
|
+
modelCounts[model] = (modelCounts[model] ?? 0) + 1;
|
|
62
|
+
}
|
|
63
|
+
const hashFallback = modelCounts["hash-512d"] ?? 0;
|
|
64
|
+
const withEmbeddings = memoriesList.length - hashFallback;
|
|
65
|
+
const realModels = Object.keys(modelCounts).filter((k) => k !== "hash-512d");
|
|
66
|
+
const byDurability = { permanent: 0, persistent: 0, standard: 0, ephemeral: 0 };
|
|
67
|
+
let archived = 0;
|
|
68
|
+
let expired = 0;
|
|
69
|
+
for (const m of memoriesList) {
|
|
70
|
+
const d = (m.durability ?? "standard");
|
|
71
|
+
if (d in byDurability)
|
|
72
|
+
byDurability[d]++;
|
|
73
|
+
if (m.archived)
|
|
74
|
+
archived++;
|
|
75
|
+
if (!m.archived && m.validTo && new Date(m.validTo).getTime() < nowMs)
|
|
76
|
+
expired++;
|
|
27
77
|
}
|
|
28
78
|
stats.memories = {
|
|
29
|
-
total:
|
|
30
|
-
withEmbeddings
|
|
31
|
-
hashFallback
|
|
79
|
+
total: memoriesList.length,
|
|
80
|
+
withEmbeddings,
|
|
81
|
+
hashFallback,
|
|
82
|
+
modelCounts,
|
|
83
|
+
byDurability,
|
|
84
|
+
archived,
|
|
85
|
+
expired,
|
|
32
86
|
};
|
|
33
|
-
if (
|
|
34
|
-
const sorted =
|
|
87
|
+
if (memoriesList.length > 0) {
|
|
88
|
+
const sorted = memoriesList
|
|
89
|
+
.filter((m) => m.createdAt)
|
|
90
|
+
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
35
91
|
if (sorted[0])
|
|
36
92
|
stats.lastWrite = sorted[0].createdAt;
|
|
37
93
|
}
|
|
94
|
+
if (expired > 0)
|
|
95
|
+
warnings.push({ level: "warn", message: `${expired} memories have expired validTo but aren't archived` });
|
|
96
|
+
// Hash-fallback coverage — tiered by percentage. Thresholds are
|
|
97
|
+
// first-pass defaults; Kern's review on ops-n4n may tune them.
|
|
98
|
+
if (memoriesList.length > 0) {
|
|
99
|
+
const pct = Math.round((hashFallback / memoriesList.length) * 100);
|
|
100
|
+
if (pct >= 10) {
|
|
101
|
+
warnings.push({
|
|
102
|
+
level: "warn",
|
|
103
|
+
message: `${hashFallback}/${memoriesList.length} (${pct}%) memories are hash-fallback — run: flair reembed --stale-only --dry-run`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Mixed embedding models — searches across vector spaces return garbage.
|
|
108
|
+
if (realModels.length > 1) {
|
|
109
|
+
const list = realModels.map((k) => `${k}:${modelCounts[k]}`).join(", ");
|
|
110
|
+
warnings.push({
|
|
111
|
+
level: "warn",
|
|
112
|
+
message: `multiple embedding models in use (${list}) — cross-model search unreliable; run: flair reembed against one model`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
38
115
|
}
|
|
39
116
|
catch {
|
|
40
117
|
stats.memories = null;
|
|
@@ -42,28 +119,380 @@ export class HealthDetail extends Resource {
|
|
|
42
119
|
// ── Agent stats ──
|
|
43
120
|
try {
|
|
44
121
|
const agents = [];
|
|
45
|
-
for await (const a of db.flair.Agent.search({}))
|
|
122
|
+
for await (const a of db.flair.Agent.search({}))
|
|
46
123
|
agents.push(a);
|
|
124
|
+
const blank = (id) => ({
|
|
125
|
+
id, memoryCount: 0, hashFallback: 0, writes24h: 0, lastWriteAt: null,
|
|
126
|
+
});
|
|
127
|
+
const perAgentMap = new Map();
|
|
128
|
+
for (const a of agents) {
|
|
129
|
+
if (a.id)
|
|
130
|
+
perAgentMap.set(a.id, blank(a.id));
|
|
131
|
+
}
|
|
132
|
+
const cutoff24h = nowMs - 24 * 3600 * 1000;
|
|
133
|
+
for (const m of memoriesList) {
|
|
134
|
+
if (!m.agentId)
|
|
135
|
+
continue;
|
|
136
|
+
const row = perAgentMap.get(m.agentId) ?? blank(m.agentId);
|
|
137
|
+
row.memoryCount++;
|
|
138
|
+
if (!m.embeddingModel || m.embeddingModel === "hash-512d")
|
|
139
|
+
row.hashFallback++;
|
|
140
|
+
if (m.createdAt) {
|
|
141
|
+
const ts = new Date(m.createdAt).getTime();
|
|
142
|
+
if (ts >= cutoff24h)
|
|
143
|
+
row.writes24h++;
|
|
144
|
+
if (!row.lastWriteAt || ts > new Date(row.lastWriteAt).getTime()) {
|
|
145
|
+
row.lastWriteAt = m.createdAt;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
perAgentMap.set(m.agentId, row);
|
|
47
149
|
}
|
|
150
|
+
const perAgentFull = Array.from(perAgentMap.values()).sort((a, b) => b.memoryCount - a.memoryCount);
|
|
151
|
+
const perAgent = isAdmin
|
|
152
|
+
? perAgentFull
|
|
153
|
+
: perAgentFull.filter((r) => r.id === callerAgent);
|
|
48
154
|
stats.agents = {
|
|
49
155
|
count: agents.length,
|
|
50
|
-
names: agents.map((a) => a.id).filter(Boolean),
|
|
156
|
+
names: isAdmin ? agents.map((a) => a.id).filter(Boolean) : undefined,
|
|
157
|
+
perAgent,
|
|
51
158
|
};
|
|
52
159
|
}
|
|
53
160
|
catch {
|
|
54
161
|
stats.agents = null;
|
|
55
162
|
}
|
|
56
|
-
// ──
|
|
163
|
+
// ── Relationships ──
|
|
57
164
|
try {
|
|
58
|
-
|
|
59
|
-
for await (const
|
|
60
|
-
|
|
165
|
+
const rels = [];
|
|
166
|
+
for await (const r of db.flair.Relationship.search({}))
|
|
167
|
+
rels.push(r);
|
|
168
|
+
if (rels.length === 0) {
|
|
169
|
+
stats.relationships = null;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
const active = rels.filter((r) => !r.validTo || new Date(r.validTo).getTime() > nowMs).length;
|
|
173
|
+
stats.relationships = { total: rels.length, active };
|
|
61
174
|
}
|
|
62
|
-
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
stats.relationships = null;
|
|
178
|
+
}
|
|
179
|
+
// ── Soul ──
|
|
180
|
+
try {
|
|
181
|
+
const souls = [];
|
|
182
|
+
for await (const s of db.flair.Soul.search({}))
|
|
183
|
+
souls.push(s);
|
|
184
|
+
stats.soulEntries = souls.length;
|
|
185
|
+
const byPriority = { critical: 0, high: 0, standard: 0, low: 0 };
|
|
186
|
+
for (const s of souls) {
|
|
187
|
+
const p = (s.priority ?? "standard");
|
|
188
|
+
if (p in byPriority)
|
|
189
|
+
byPriority[p]++;
|
|
190
|
+
}
|
|
191
|
+
stats.soul = { total: souls.length, byPriority };
|
|
63
192
|
}
|
|
64
193
|
catch {
|
|
65
194
|
stats.soulEntries = null;
|
|
195
|
+
stats.soul = null;
|
|
196
|
+
}
|
|
197
|
+
// ── Federation ──
|
|
198
|
+
try {
|
|
199
|
+
const instances = [];
|
|
200
|
+
try {
|
|
201
|
+
for await (const i of db.flair.Instance.search({}))
|
|
202
|
+
instances.push(i);
|
|
203
|
+
}
|
|
204
|
+
catch { /* absent */ }
|
|
205
|
+
const peers = [];
|
|
206
|
+
try {
|
|
207
|
+
for await (const p of db.flair.Peer.search({}))
|
|
208
|
+
peers.push(p);
|
|
209
|
+
}
|
|
210
|
+
catch { /* absent */ }
|
|
211
|
+
const tokens = [];
|
|
212
|
+
try {
|
|
213
|
+
for await (const t of db.flair.PairingToken.search({}))
|
|
214
|
+
tokens.push(t);
|
|
215
|
+
}
|
|
216
|
+
catch { /* absent */ }
|
|
217
|
+
if (instances.length === 0 && peers.length === 0) {
|
|
218
|
+
stats.federation = null;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const inst = instances[0];
|
|
222
|
+
const peersBlock = {
|
|
223
|
+
total: peers.length,
|
|
224
|
+
connected: peers.filter((p) => p.status === "connected").length,
|
|
225
|
+
disconnected: peers.filter((p) => p.status === "disconnected").length,
|
|
226
|
+
revoked: peers.filter((p) => p.status === "revoked").length,
|
|
227
|
+
};
|
|
228
|
+
const pendingTokens = tokens.filter((t) => !t.consumedBy && t.expiresAt && new Date(t.expiresAt).getTime() > nowMs).length;
|
|
229
|
+
stats.federation = {
|
|
230
|
+
instance: inst ? { id: inst.id, role: inst.role, status: inst.status } : null,
|
|
231
|
+
peers: peersBlock,
|
|
232
|
+
pendingTokens,
|
|
233
|
+
peerList: isAdmin
|
|
234
|
+
? peers.map((p) => ({
|
|
235
|
+
id: p.id,
|
|
236
|
+
role: p.role,
|
|
237
|
+
status: p.status,
|
|
238
|
+
lastSyncAt: p.lastSyncAt ?? null,
|
|
239
|
+
}))
|
|
240
|
+
: undefined,
|
|
241
|
+
};
|
|
242
|
+
if (peers.length > 0 && peersBlock.connected === 0) {
|
|
243
|
+
const oldest = peers
|
|
244
|
+
.map((p) => (p.lastSyncAt ? new Date(p.lastSyncAt).getTime() : 0))
|
|
245
|
+
.reduce((a, b) => (a === 0 ? b : b === 0 ? a : Math.min(a, b)), 0);
|
|
246
|
+
if (oldest > 0 && nowMs - oldest > 24 * 3600 * 1000) {
|
|
247
|
+
warnings.push({ level: "warn", message: "federation peers all disconnected >24h" });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (pendingTokens > 0) {
|
|
251
|
+
warnings.push({ level: "info", message: `${pendingTokens} pairing token(s) unconsumed` });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
stats.federation = null;
|
|
257
|
+
}
|
|
258
|
+
// ── OAuth / IdP ──
|
|
259
|
+
try {
|
|
260
|
+
const clients = [];
|
|
261
|
+
try {
|
|
262
|
+
for await (const c of db.flair.OAuthClient.search({}))
|
|
263
|
+
clients.push(c);
|
|
264
|
+
}
|
|
265
|
+
catch { /* absent */ }
|
|
266
|
+
const idps = [];
|
|
267
|
+
try {
|
|
268
|
+
for await (const i of db.flair.IdpConfig.search({}))
|
|
269
|
+
idps.push(i);
|
|
270
|
+
}
|
|
271
|
+
catch { /* absent */ }
|
|
272
|
+
let activeTokens = 0;
|
|
273
|
+
let tokensAvailable = true;
|
|
274
|
+
try {
|
|
275
|
+
const toks = [];
|
|
276
|
+
for await (const t of db.flair.OAuthToken.search({}))
|
|
277
|
+
toks.push(t);
|
|
278
|
+
activeTokens = toks.filter((t) => {
|
|
279
|
+
if (t.revokedAt)
|
|
280
|
+
return false;
|
|
281
|
+
if (t.expiresAt && new Date(t.expiresAt).getTime() < nowMs)
|
|
282
|
+
return false;
|
|
283
|
+
return true;
|
|
284
|
+
}).length;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
tokensAvailable = false;
|
|
288
|
+
}
|
|
289
|
+
if (clients.length === 0 && idps.length === 0) {
|
|
290
|
+
stats.oauth = null;
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
stats.oauth = {
|
|
294
|
+
clients: clients.length,
|
|
295
|
+
idpConfigs: idps.length,
|
|
296
|
+
activeTokens: tokensAvailable ? activeTokens : 0,
|
|
297
|
+
clientList: isAdmin
|
|
298
|
+
? clients.map((c) => ({
|
|
299
|
+
id: c.id,
|
|
300
|
+
name: c.name,
|
|
301
|
+
registeredBy: c.registeredBy ?? null,
|
|
302
|
+
createdAt: c.createdAt ?? null,
|
|
303
|
+
}))
|
|
304
|
+
: undefined,
|
|
305
|
+
idpList: isAdmin
|
|
306
|
+
? idps.map((i) => ({ id: i.id, name: i.name, issuer: i.issuer }))
|
|
307
|
+
: undefined,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
stats.oauth = null;
|
|
313
|
+
}
|
|
314
|
+
// ── REM ──
|
|
315
|
+
try {
|
|
316
|
+
const logsDir = join(homedir(), ".flair", "logs");
|
|
317
|
+
const remLog = join(logsDir, "rem.jsonl");
|
|
318
|
+
const nightlyLog = join(logsDir, "rem-nightly.jsonl");
|
|
319
|
+
const tailJsonl = async (path, maxBytes = 256 * 1024) => {
|
|
320
|
+
let fh = null;
|
|
321
|
+
try {
|
|
322
|
+
const st = await fsp.stat(path).catch(() => null);
|
|
323
|
+
if (!st)
|
|
324
|
+
return [];
|
|
325
|
+
const start = Math.max(0, st.size - maxBytes);
|
|
326
|
+
const len = st.size - start;
|
|
327
|
+
if (len === 0)
|
|
328
|
+
return [];
|
|
329
|
+
fh = await fsp.open(path, "r");
|
|
330
|
+
const buf = Buffer.alloc(len);
|
|
331
|
+
await fh.read(buf, 0, len, start);
|
|
332
|
+
return buf
|
|
333
|
+
.toString("utf-8")
|
|
334
|
+
.split("\n")
|
|
335
|
+
.filter((l) => l.trim())
|
|
336
|
+
.map((l) => { try {
|
|
337
|
+
return JSON.parse(l);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return null;
|
|
341
|
+
} })
|
|
342
|
+
.filter(Boolean);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return [];
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
if (fh)
|
|
349
|
+
await fh.close().catch(() => { });
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const remRecords = await tailJsonl(remLog);
|
|
353
|
+
const findLast = (kind) => {
|
|
354
|
+
for (let i = remRecords.length - 1; i >= 0; i--) {
|
|
355
|
+
const r = remRecords[i];
|
|
356
|
+
if (r && (r.kind === kind || r.type === kind))
|
|
357
|
+
return r.at ?? r.ts ?? r.timestamp ?? null;
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
};
|
|
361
|
+
const lastLightAt = findLast("light");
|
|
362
|
+
const lastRapidAt = findLast("rapid");
|
|
363
|
+
const lastRestorativeAt = findLast("restorative");
|
|
364
|
+
let nightlyEnabled = null;
|
|
365
|
+
const plat = platform();
|
|
366
|
+
if (plat === "darwin") {
|
|
367
|
+
nightlyEnabled = await exists(join(homedir(), "Library", "LaunchAgents", "dev.flair.rem.nightly.plist"));
|
|
368
|
+
}
|
|
369
|
+
else if (plat === "linux") {
|
|
370
|
+
nightlyEnabled = await exists(join(homedir(), ".config", "systemd", "user", "flair-rem-nightly.timer"));
|
|
371
|
+
}
|
|
372
|
+
const nightlyRecords = await tailJsonl(nightlyLog);
|
|
373
|
+
const lastNightlyRec = nightlyRecords[nightlyRecords.length - 1];
|
|
374
|
+
const lastNightlyAt = lastNightlyRec ? (lastNightlyRec.at ?? lastNightlyRec.ts ?? lastNightlyRec.timestamp ?? null) : null;
|
|
375
|
+
let pendingCandidates = null;
|
|
376
|
+
try {
|
|
377
|
+
let count = 0;
|
|
378
|
+
for await (const c of db.flair.MemoryCandidate.search({})) {
|
|
379
|
+
if (c.status === "pending")
|
|
380
|
+
count++;
|
|
381
|
+
}
|
|
382
|
+
pendingCandidates = count;
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
pendingCandidates = null;
|
|
386
|
+
}
|
|
387
|
+
const allNull = !lastLightAt &&
|
|
388
|
+
!lastRapidAt &&
|
|
389
|
+
!lastRestorativeAt &&
|
|
390
|
+
nightlyEnabled === null &&
|
|
391
|
+
!lastNightlyAt &&
|
|
392
|
+
pendingCandidates === null;
|
|
393
|
+
if (allNull) {
|
|
394
|
+
stats.rem = null;
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
stats.rem = {
|
|
398
|
+
lastLightAt,
|
|
399
|
+
lastRapidAt,
|
|
400
|
+
lastRestorativeAt,
|
|
401
|
+
nightlyEnabled,
|
|
402
|
+
lastNightlyAt,
|
|
403
|
+
pendingCandidates,
|
|
404
|
+
};
|
|
405
|
+
if (nightlyEnabled && lastNightlyAt && nowMs - new Date(lastNightlyAt).getTime() > 48 * 3600 * 1000) {
|
|
406
|
+
warnings.push({ level: "warn", message: "nightly REM hasn't run in >48h" });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
stats.rem = null;
|
|
412
|
+
}
|
|
413
|
+
// ── Disk ──
|
|
414
|
+
try {
|
|
415
|
+
const dataDir = process.env.HDB_ROOT ?? join(homedir(), ".flair", "data");
|
|
416
|
+
const snapshotDir = join(homedir(), ".flair", "snapshots");
|
|
417
|
+
const dirSize = async (root, maxDepth = 6) => {
|
|
418
|
+
if (!(await exists(root)))
|
|
419
|
+
return null;
|
|
420
|
+
let total = 0;
|
|
421
|
+
const walk = async (p, depth) => {
|
|
422
|
+
if (depth > maxDepth)
|
|
423
|
+
return;
|
|
424
|
+
let entries;
|
|
425
|
+
try {
|
|
426
|
+
entries = await fsp.readdir(p, { withFileTypes: true });
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const subdirs = [];
|
|
432
|
+
const files = [];
|
|
433
|
+
for (const e of entries) {
|
|
434
|
+
const full = join(p, e.name);
|
|
435
|
+
if (e.isDirectory())
|
|
436
|
+
subdirs.push(full);
|
|
437
|
+
else if (e.isFile())
|
|
438
|
+
files.push(full);
|
|
439
|
+
}
|
|
440
|
+
const sizes = await Promise.all(files.map((f) => fsp.stat(f).then((s) => s.size).catch(() => 0)));
|
|
441
|
+
total += sizes.reduce((a, b) => a + b, 0);
|
|
442
|
+
await Promise.all(subdirs.map((d) => walk(d, depth + 1)));
|
|
443
|
+
};
|
|
444
|
+
await walk(root, 0);
|
|
445
|
+
return total;
|
|
446
|
+
};
|
|
447
|
+
const [dataBytes, snapshotBytes] = await Promise.all([dirSize(dataDir), dirSize(snapshotDir)]);
|
|
448
|
+
if (dataBytes === null && snapshotBytes === null) {
|
|
449
|
+
stats.disk = null;
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
stats.disk = {
|
|
453
|
+
dataDir: isAdmin ? dataDir : redactHome(dataDir),
|
|
454
|
+
dataBytes: dataBytes ?? 0,
|
|
455
|
+
snapshotDir: isAdmin ? snapshotDir : redactHome(snapshotDir),
|
|
456
|
+
snapshotBytes: snapshotBytes ?? 0,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
stats.disk = null;
|
|
462
|
+
}
|
|
463
|
+
// ── Bridges ──
|
|
464
|
+
try {
|
|
465
|
+
const cwd = process.cwd();
|
|
466
|
+
const candidates = [join(cwd, "node_modules"), join(homedir(), ".flair", "node_modules")];
|
|
467
|
+
const installed = new Set();
|
|
468
|
+
await Promise.all(candidates.map(async (base) => {
|
|
469
|
+
if (!(await exists(base)))
|
|
470
|
+
return;
|
|
471
|
+
try {
|
|
472
|
+
const names = await fsp.readdir(base);
|
|
473
|
+
for (const name of names) {
|
|
474
|
+
if (name.startsWith("flair-bridge-"))
|
|
475
|
+
installed.add(name);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch { /* skip */ }
|
|
479
|
+
}));
|
|
480
|
+
if (installed.size === 0) {
|
|
481
|
+
stats.bridges = null;
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
stats.bridges = {
|
|
485
|
+
installed: Array.from(installed).sort(),
|
|
486
|
+
lastImport: null,
|
|
487
|
+
lastExport: null,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
stats.bridges = null;
|
|
66
493
|
}
|
|
494
|
+
// ── Warnings ──
|
|
495
|
+
stats.warnings = warnings;
|
|
67
496
|
// ── Process info ──
|
|
68
497
|
stats.pid = process.pid;
|
|
69
498
|
stats.uptimeSeconds = Math.floor(process.uptime());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"dist/",
|
|
32
32
|
"schemas/",
|
|
33
|
+
"ui/",
|
|
33
34
|
"config.yaml",
|
|
34
35
|
"LICENSE",
|
|
35
36
|
"README.md",
|
|
@@ -52,10 +53,12 @@
|
|
|
52
53
|
"node": ">=22"
|
|
53
54
|
},
|
|
54
55
|
"dependencies": {
|
|
55
|
-
"@harperfast/harper": "5.0.
|
|
56
|
+
"@harperfast/harper": "5.0.1",
|
|
57
|
+
"@types/js-yaml": "^4.0.9",
|
|
56
58
|
"commander": "14.0.3",
|
|
57
|
-
"harper-fabric-embeddings": "0.2.
|
|
59
|
+
"harper-fabric-embeddings": "0.2.3",
|
|
58
60
|
"jose": "^6.2.2",
|
|
61
|
+
"js-yaml": "^4.1.1",
|
|
59
62
|
"tweetnacl": "1.0.3"
|
|
60
63
|
},
|
|
61
64
|
"devDependencies": {
|
|
@@ -70,8 +73,5 @@
|
|
|
70
73
|
"workspaces": [
|
|
71
74
|
"packages/*",
|
|
72
75
|
"plugins/*"
|
|
73
|
-
]
|
|
74
|
-
"optionalDependencies": {
|
|
75
|
-
"@node-llama-cpp/mac-arm64-metal": "3.18.1"
|
|
76
|
-
}
|
|
76
|
+
]
|
|
77
77
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Observation Center</title>
|
|
7
|
+
<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
|
+
*{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}}
|
|
15
|
+
</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<div class="shell">
|
|
19
|
+
<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>
|
|
22
|
+
<div class="summary" id="summary"></div>
|
|
23
|
+
</section>
|
|
24
|
+
<section class="panel toolbar">
|
|
25
|
+
<div class="field"><label for="baseUrl">Flair URL</label><input id="baseUrl" placeholder="(same origin)"></div>
|
|
26
|
+
<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
|
+
<div class="field"><label> </label><button id="refresh">Refresh now</button></div>
|
|
31
|
+
</section>
|
|
32
|
+
<div class="layout">
|
|
33
|
+
<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>
|
|
36
|
+
</section>
|
|
37
|
+
<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>
|
|
40
|
+
</section>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<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={"&":"&","<":"<",">":">",'"':""","'":"'"};
|
|
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")};
|
|
75
|
+
}
|
|
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();
|
|
89
|
+
</script>
|
|
90
|
+
</body>
|
|
91
|
+
</html>
|