@tpsdev-ai/flair 0.5.6 → 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/dist/deploy.js ADDED
@@ -0,0 +1,161 @@
1
+ import { spawn } from "node:child_process";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createRequire } from "node:module";
6
+ // Files that must be present in a Flair package for deployment.
7
+ // Mirrors the `files` array in package.json — keep in sync.
8
+ export const REQUIRED_PACKAGE_FILES = [
9
+ "dist",
10
+ "schemas",
11
+ "ui",
12
+ "config.yaml",
13
+ ];
14
+ export function validateOptions(opts) {
15
+ const errors = [];
16
+ if (!opts.target) {
17
+ if (!opts.fabricOrg)
18
+ errors.push("--fabric-org required (or FABRIC_ORG env)");
19
+ if (!opts.fabricCluster)
20
+ errors.push("--fabric-cluster required (or FABRIC_CLUSTER env)");
21
+ }
22
+ const hasBasic = !!(opts.fabricUser && opts.fabricPassword);
23
+ const hasBearer = !!opts.fabricToken;
24
+ if (!hasBasic && !hasBearer) {
25
+ errors.push("credentials required: pass --fabric-user + --fabric-password " +
26
+ "(or FABRIC_USER / FABRIC_PASSWORD env), or --fabric-token " +
27
+ "(FABRIC_TOKEN env)");
28
+ }
29
+ return errors;
30
+ }
31
+ export function buildTargetUrl(opts) {
32
+ if (opts.target)
33
+ return opts.target;
34
+ return `https://${opts.fabricCluster}.${opts.fabricOrg}.harperfabric.com`;
35
+ }
36
+ export function resolvePackageRoot(override) {
37
+ if (override) {
38
+ const abs = resolve(override);
39
+ if (!existsSync(join(abs, "package.json"))) {
40
+ throw new Error(`No package.json at ${abs}`);
41
+ }
42
+ return abs;
43
+ }
44
+ // Walk up from this module's location — works when installed locally
45
+ // and when npx extracts the tarball to a tmpdir.
46
+ try {
47
+ const here = dirname(fileURLToPath(import.meta.url));
48
+ let dir = here;
49
+ for (let i = 0; i < 8; i++) {
50
+ const pkgPath = join(dir, "package.json");
51
+ if (existsSync(pkgPath)) {
52
+ const json = JSON.parse(readFileSync(pkgPath, "utf8"));
53
+ if (json.name === "@tpsdev-ai/flair")
54
+ return dir;
55
+ }
56
+ const parent = dirname(dir);
57
+ if (parent === dir)
58
+ break;
59
+ dir = parent;
60
+ }
61
+ }
62
+ catch {
63
+ /* fall through */
64
+ }
65
+ try {
66
+ const req = createRequire(import.meta.url);
67
+ return dirname(req.resolve("@tpsdev-ai/flair/package.json"));
68
+ }
69
+ catch {
70
+ throw new Error("Could not locate @tpsdev-ai/flair package root. Try --package-root.");
71
+ }
72
+ }
73
+ export function validatePackageLayout(packageRoot) {
74
+ const missing = [];
75
+ for (const f of REQUIRED_PACKAGE_FILES) {
76
+ if (!existsSync(join(packageRoot, f)))
77
+ missing.push(f);
78
+ }
79
+ if (missing.length) {
80
+ throw new Error(`Flair package at ${packageRoot} is missing required entries: ` +
81
+ missing.join(", "));
82
+ }
83
+ }
84
+ function resolveHarperBin(packageRoot) {
85
+ const local = join(packageRoot, "node_modules/@harperfast/harper/dist/bin/harper.js");
86
+ if (existsSync(local))
87
+ return local;
88
+ try {
89
+ const req = createRequire(join(packageRoot, "package.json"));
90
+ const mainPath = req.resolve("@harperfast/harper");
91
+ let dir = dirname(mainPath);
92
+ for (let i = 0; i < 6; i++) {
93
+ const candidate = join(dir, "dist/bin/harper.js");
94
+ if (existsSync(candidate))
95
+ return candidate;
96
+ const parent = dirname(dir);
97
+ if (parent === dir)
98
+ break;
99
+ dir = parent;
100
+ }
101
+ }
102
+ catch {
103
+ /* fall through */
104
+ }
105
+ throw new Error("Could not locate Harper CLI binary (@harperfast/harper). " +
106
+ "Flair deploy requires Harper to be installed alongside Flair.");
107
+ }
108
+ function spawnHarper(bin, args, cwd, env) {
109
+ return new Promise((resolveP, rejectP) => {
110
+ const p = spawn(process.execPath, [bin, ...args], {
111
+ cwd,
112
+ stdio: "inherit",
113
+ env,
114
+ });
115
+ p.on("error", rejectP);
116
+ p.on("exit", (code) => {
117
+ if (code === 0)
118
+ resolveP();
119
+ else
120
+ rejectP(new Error(`harper deploy exited with code ${code}`));
121
+ });
122
+ });
123
+ }
124
+ export async function deploy(opts) {
125
+ const errors = validateOptions(opts);
126
+ if (errors.length) {
127
+ throw new Error(errors.join("\n"));
128
+ }
129
+ const packageRoot = resolvePackageRoot(opts.packageRoot);
130
+ validatePackageLayout(packageRoot);
131
+ const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
132
+ const version = opts.version ?? pkg.version;
133
+ const project = opts.project ?? "flair";
134
+ const url = buildTargetUrl(opts);
135
+ if (opts.dryRun) {
136
+ return { url, project, version, packageRoot, dryRun: true };
137
+ }
138
+ if (opts.fabricToken && !(opts.fabricUser && opts.fabricPassword)) {
139
+ throw new Error("Bearer token auth (--fabric-token) is not yet supported — " +
140
+ "Harper's deploy_component CLI path only accepts Basic auth today. " +
141
+ "Pass --fabric-user + --fabric-password instead.");
142
+ }
143
+ const harperBin = resolveHarperBin(packageRoot);
144
+ const args = [
145
+ "deploy",
146
+ `target=${url}`,
147
+ `project=${project}`,
148
+ `restart=${opts.restart !== false}`,
149
+ `replicated=${opts.replicated !== false}`,
150
+ ];
151
+ // Credentials go via env, not argv, so they don't appear in `ps` output
152
+ // for the lifetime of the Harper child process. Harper's cliOperations
153
+ // reads CLI_TARGET_USERNAME / CLI_TARGET_PASSWORD as env fallbacks.
154
+ const childEnv = {
155
+ ...process.env,
156
+ CLI_TARGET_USERNAME: opts.fabricUser,
157
+ CLI_TARGET_PASSWORD: opts.fabricPassword,
158
+ };
159
+ await spawnHarper(harperBin, args, packageRoot, childEnv);
160
+ return { url, project, version, packageRoot, dryRun: false };
161
+ }
@@ -0,0 +1,19 @@
1
+ import { Resource } from "@harperfast/harper";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, resolve } from "node:path";
5
+ const HERE = dirname(fileURLToPath(import.meta.url));
6
+ const HTML_PATH = resolve(HERE, "..", "..", "ui", "observation-center.html");
7
+ const HTML = readFileSync(HTML_PATH, "utf8");
8
+ /**
9
+ * GET /ObservationCenter — serves the read-only dashboard shell.
10
+ * The HTML handles its own Basic-auth prompt and polls authenticated JSON endpoints.
11
+ */
12
+ export class ObservationCenter extends Resource {
13
+ async get() {
14
+ return new Response(HTML, {
15
+ status: 200,
16
+ headers: { "content-type": "text/html; charset=utf-8" },
17
+ });
18
+ }
19
+ }
@@ -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
- list.push(m);
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: list.length,
30
- withEmbeddings: list.filter((m) => m.embeddingModel && m.embeddingModel !== "hash-512d").length,
31
- hashFallback: list.filter((m) => !m.embeddingModel || m.embeddingModel === "hash-512d").length,
79
+ total: memoriesList.length,
80
+ withEmbeddings,
81
+ hashFallback,
82
+ modelCounts,
83
+ byDurability,
84
+ archived,
85
+ expired,
32
86
  };
33
- if (list.length > 0) {
34
- const sorted = list.filter((m) => m.createdAt).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
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
- // ── Soul stats ──
163
+ // ── Relationships ──
57
164
  try {
58
- let count = 0;
59
- for await (const _ of db.flair.Soul.search({})) {
60
- count++;
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
- stats.soulEntries = count;
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());