@tpsdev-ai/flair 0.10.0 → 0.11.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 CHANGED
@@ -17,7 +17,7 @@ Flair is the **identity + memory substrate for AI agents**. Cryptographic per-ag
17
17
  │ Codex CLI ─┼─[ flair-mcp ]─┐ │
18
18
  │ Gemini CLI ─┤ │ │
19
19
  │ Continue.dev ─┤ │ ┌──────────────────────┐ │
20
- │ Goose ─┘ ├─▶ │ Flair (rockit) │ │
20
+ │ Goose ─┘ ├─▶ │ Flair (self-hosted) │ │
21
21
  │ LangGraph ─[ langgraph-flair ]──│ Ed25519 / HNSW / │ │
22
22
  │ OpenClaw ─[ openclaw-flair ]──│ Soul + Memory │ │
23
23
  │ n8n ─[ n8n-nodes-flair ]──└──────────┬───────────┘ │
@@ -146,7 +146,7 @@ Not all memories are equal:
146
146
  Memories can be time-bounded with `validFrom` and `validTo` fields. Expired memories are excluded from search and bootstrap automatically — no manual cleanup.
147
147
 
148
148
  ### Relationship Graph
149
- Entity-to-entity triples with temporal bounds. Model structured knowledge — "Flint works-with Anvil since 2024-01-01" — queryable alongside semantic memory.
149
+ Entity-to-entity triples with temporal bounds. Model structured knowledge — "Alice works-with Bob since 2024-01-01" — queryable alongside semantic memory.
150
150
 
151
151
  ### Real-Time Feeds
152
152
  Subscribe to memory or soul changes via WebSocket/SSE. Useful for dashboards, cross-agent sync, or audit trails.
@@ -392,7 +392,7 @@ Good for teams with multiple machines or always-on agents.
392
392
 
393
393
  ### Harper Fabric
394
394
 
395
- Deploy Flair on [Harper Fabric](https://www.harperdb.io/) for managed hosting with multi-region replication and failover. Federation is live against Harper Fabric hubs (e.g. `flair.heskew.harperfabric.com`) — pair your local instance to sync memories across nodes.
395
+ Deploy Flair on [Harper Fabric](https://www.harperdb.io/) for managed hosting with multi-region replication and failover. Federation runs against Harper Fabric hubs (e.g. `flair.your-org.harperfabric.com`) — pair your local instance to sync memories across nodes.
396
396
 
397
397
  ## Security
398
398
 
package/dist/cli.js CHANGED
@@ -3215,28 +3215,45 @@ federation
3215
3215
  }
3216
3216
  const result = await res.json();
3217
3217
  console.log(`✅ Paired with hub: ${result.instance?.id ?? hubUrl}`);
3218
- // Record the hub as our peer locally or remotely depending on --target
3219
- const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
3220
- if (adminPass) {
3221
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
3222
- const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
3223
- await fetch(`${opsEndpoint}/`, {
3224
- method: "POST",
3225
- headers: { "Content-Type": "application/json", Authorization: auth },
3226
- body: JSON.stringify({
3227
- operation: "upsert", database: "flair", table: "Peer",
3228
- records: [{
3229
- id: result.instance?.id ?? "hub",
3230
- publicKey: result.instance?.publicKey ?? "",
3231
- role: "hub", endpoint: hubUrl, status: "paired",
3232
- pairedAt: new Date().toISOString(),
3233
- createdAt: new Date().toISOString(),
3234
- updatedAt: new Date().toISOString(),
3235
- }],
3236
- }),
3237
- signal: AbortSignal.timeout(10_000),
3238
- });
3218
+ // Record the hub as our local peer. This is REQUIRED, not optional:
3219
+ // `flair federation sync` reads the Peer table to find the hub, so
3220
+ // without this record sync reports "No hub peer configured" and silently
3221
+ // never runs. Previously this was gated on `if (adminPass)` and the write
3222
+ // result was never checked — pairing with only an agent key (or a failed
3223
+ // upsert) left no peer behind a misleadingly green "✅ Paired".
3224
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
3225
+ if (!adminPass) {
3226
+ console.error("Error: paired on the hub, but the local hub-peer record needs admin auth to write — " +
3227
+ "pass --admin-pass, or set FLAIR_ADMIN_PASS / HDB_ADMIN_PASSWORD, then re-run pair. " +
3228
+ "Without it, 'flair federation sync' will report 'No hub peer configured'.");
3229
+ process.exit(1);
3239
3230
  }
3231
+ const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
3232
+ const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
3233
+ const peerRes = await fetch(`${opsEndpoint}/`, {
3234
+ method: "POST",
3235
+ headers: { "Content-Type": "application/json", Authorization: auth },
3236
+ body: JSON.stringify({
3237
+ operation: "upsert", database: "flair", table: "Peer",
3238
+ records: [{
3239
+ id: result.instance?.id ?? "hub",
3240
+ publicKey: result.instance?.publicKey ?? "",
3241
+ role: "hub", endpoint: hubUrl, status: "paired",
3242
+ pairedAt: new Date().toISOString(),
3243
+ createdAt: new Date().toISOString(),
3244
+ updatedAt: new Date().toISOString(),
3245
+ }],
3246
+ }),
3247
+ signal: AbortSignal.timeout(10_000),
3248
+ });
3249
+ if (!peerRes.ok) {
3250
+ const text = await peerRes.text().catch(() => "");
3251
+ console.error(`Error: paired with the hub but failed to write the local hub-peer record ` +
3252
+ `(${peerRes.status} ${text.slice(0, 200)}). Ops endpoint: ${opsEndpoint}. ` +
3253
+ `'flair federation sync' will not find the hub until this succeeds — check --admin-pass and the ops port.`);
3254
+ process.exit(1);
3255
+ }
3256
+ console.log(`✅ Recorded hub as local peer: ${result.instance?.id ?? "hub"} → ${hubUrl}`);
3240
3257
  }
3241
3258
  catch (err) {
3242
3259
  console.error(`Error: ${err.message}`);
@@ -3373,25 +3390,63 @@ export async function runFederationSyncOnce(opts) {
3373
3390
  const hubUrl = hub.endpoint ?? hub.id;
3374
3391
  // ── Batching constants ──────────────────────────────────────────────
3375
3392
  // 2MB JSON budget (server cap is 10MB; 2MB leaves headroom for headers
3376
- // and signature metadata) + 200 records max per batch.
3393
+ // and signature metadata) + 50 records max per batch. The hub merge itself
3394
+ // is fast (~1.7s/50 records, per its SyncLog), but the Fabric ingress was
3395
+ // observed to intermittently stall on larger POSTs — a 50-record batch hung
3396
+ // ~2 min while the same records split into 2×25 went through immediately.
3397
+ // 50 keeps batches in the reliable range, and sendBatch's adaptive split
3398
+ // recovers if a stretch still stalls.
3377
3399
  const BUDGET_BYTES = 2_000_000;
3378
- const BUDGET_RECORDS = 200;
3400
+ const BUDGET_RECORDS = 50;
3379
3401
  // ── sendBatch helper ────────────────────────────────────────────────
3380
3402
  // Secret key is lazy-loaded: only needed when there are records to send.
3381
3403
  // Loading earlier would cause a spurious error when SQL queries fail
3382
3404
  // (e.g. 401) before we know we have records.
3383
3405
  let secretKey;
3406
+ // Statuses the Fabric ingress returns when a batch POST didn't complete in
3407
+ // time (408) or was too large (413), plus the transient gateway 5xx family.
3408
+ // Splitting the batch and retrying smaller chunks lets the sync converge
3409
+ // instead of aborting the whole run.
3410
+ const TIMEOUT_STATUSES = new Set([408, 413, 502, 503, 504]);
3411
+ // Per-batch wall-clock cap. Without it a stalled connection to the Fabric
3412
+ // ingress hangs the whole sync until the *gateway's* timeout fires (~2 min
3413
+ // observed), which is what stranded the re-pair. A 45s cap is generous —
3414
+ // a healthy 50-record batch merges in <2s — so a trip means a real stall,
3415
+ // and we split-and-retry rather than wait it out.
3416
+ const BATCH_TIMEOUT_MS = 45_000;
3384
3417
  async function sendBatch(batch) {
3385
3418
  if (!secretKey)
3386
3419
  secretKey = await loadInstanceSecretKey(instance.id, opts);
3387
3420
  const syncBody = { instanceId: instance.id, records: batch, lamportClock: Date.now() };
3388
3421
  const signedSyncBody = signBodyFresh(syncBody, secretKey);
3389
- const syncRes = await fetch(`${hubUrl}/FederationSync`, {
3390
- method: "POST",
3391
- headers: { "Content-Type": "application/json" },
3392
- body: JSON.stringify(signedSyncBody),
3393
- });
3422
+ // Halve and retry down to a single record. Covers both an explicit
3423
+ // timeout status AND a client-side abort (stalled socket). The hub
3424
+ // merges idempotently (put-by-id), so retried records are safe.
3425
+ const splittable = (status) => batch.length > 1 && (status === null || TIMEOUT_STATUSES.has(status));
3426
+ const split = async () => {
3427
+ const mid = Math.floor(batch.length / 2);
3428
+ const left = await sendBatch(batch.slice(0, mid));
3429
+ const right = await sendBatch(batch.slice(mid));
3430
+ return { merged: left.merged + right.merged, skipped: left.skipped + right.skipped };
3431
+ };
3432
+ let syncRes;
3433
+ try {
3434
+ syncRes = await fetch(`${hubUrl}/FederationSync`, {
3435
+ method: "POST",
3436
+ headers: { "Content-Type": "application/json" },
3437
+ body: JSON.stringify(signedSyncBody),
3438
+ signal: AbortSignal.timeout(BATCH_TIMEOUT_MS),
3439
+ });
3440
+ }
3441
+ catch (err) {
3442
+ // Timeout/abort or network drop — no status. Split if we can.
3443
+ if (splittable(null))
3444
+ return await split();
3445
+ throw new Error(`Sync batch (${batch.length} record${batch.length === 1 ? "" : "s"}) failed: ${err?.message ?? err}`);
3446
+ }
3394
3447
  if (!syncRes.ok) {
3448
+ if (splittable(syncRes.status))
3449
+ return await split();
3395
3450
  const text = await syncRes.text().catch(() => "");
3396
3451
  throw new Error(`Sync batch failed: ${syncRes.status} ${text}`);
3397
3452
  }
@@ -3399,24 +3454,35 @@ export async function runFederationSyncOnce(opts) {
3399
3454
  }
3400
3455
  let totalBatches = 0;
3401
3456
  for (const table of tables) {
3402
- let res;
3403
- try {
3404
- res = await fetch(`${opsEndpoint}/`, {
3405
- method: "POST",
3406
- headers: { "Content-Type": "application/json", Authorization: auth },
3407
- body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions: [{ search_attribute: "updatedAt", search_type: "greater_than", search_value: since }], get_attributes: ["*"] }),
3408
- signal: AbortSignal.timeout(15_000),
3409
- });
3410
- }
3411
- catch (err) {
3412
- return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
3413
- }
3414
- if (!res.ok) {
3415
- const text = await res.text().catch(() => "");
3416
- return { pushed: totalMerged, skipped: totalSkipped, error: new Error(`SQL query failed (${res.status}): ${text}`) };
3457
+ let rows = [];
3458
+ for (const query of [
3459
+ { search_attribute: "updatedAt", search_type: "greater_than", search_value: since },
3460
+ // Rows with null updatedAt (legacy direct-insert rows) use createdAt.
3461
+ // COALESCE(updatedAt, createdAt) > since pick up null-updatedAt rows
3462
+ // whose createdAt > since. Filtered in JS below.
3463
+ { search_attribute: "updatedAt", search_type: "equals", search_value: null },
3464
+ ]) {
3465
+ let res;
3466
+ try {
3467
+ res = await fetch(`${opsEndpoint}/`, {
3468
+ method: "POST",
3469
+ headers: { "Content-Type": "application/json", Authorization: auth },
3470
+ body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions: [query], get_attributes: ["*"] }),
3471
+ signal: AbortSignal.timeout(15_000),
3472
+ });
3473
+ }
3474
+ catch (err) {
3475
+ return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
3476
+ }
3477
+ if (!res.ok) {
3478
+ const text = await res.text().catch(() => "");
3479
+ return { pushed: totalMerged, skipped: totalSkipped, error: new Error(`SQL query failed (${res.status}): ${text}`) };
3480
+ }
3481
+ const batch = await res.json();
3482
+ // For null-updatedAt rows, use createdAt as the effective timestamp.
3483
+ // Skip rows created before the last sync cursor.
3484
+ rows = rows.concat(batch.filter((r) => r.updatedAt !== null || r.createdAt > since));
3417
3485
  }
3418
- // Stream-collect records into batches
3419
- const rows = await res.json();
3420
3486
  if (rows.length === 0)
3421
3487
  continue;
3422
3488
  let batch = [];
@@ -3473,6 +3539,31 @@ export async function runFederationSyncOnce(opts) {
3473
3539
  console.warn(`⚠️ Local hub.lastSyncAt advance error: ${advErr?.message ?? advErr}. Next poll will re-send memories.`);
3474
3540
  }
3475
3541
  if (totalBatches === 0) {
3542
+ // ops-c7t9: no-change syncs must still ping the hub so it updates the
3543
+ // spoke's lastSyncAt (liveness). Without this, idle-but-alive spokes
3544
+ // look indistinguishable from dead ones on the hub dashboard.
3545
+ try {
3546
+ if (!secretKey)
3547
+ secretKey = await loadInstanceSecretKey(instance.id, opts);
3548
+ const pingBody = signBodyFresh({
3549
+ instanceId: instance.id,
3550
+ records: [],
3551
+ lamportClock: Date.now(),
3552
+ }, secretKey);
3553
+ const pingRes = await fetch(`${hubUrl}/FederationSync`, {
3554
+ method: "POST",
3555
+ headers: { "Content-Type": "application/json" },
3556
+ body: JSON.stringify(pingBody),
3557
+ signal: AbortSignal.timeout(10_000),
3558
+ });
3559
+ if (!pingRes.ok) {
3560
+ const txt = await pingRes.text().catch(() => "");
3561
+ console.warn(`⚠️ Liveness ping to hub failed (${pingRes.status}): ${txt.slice(0, 200)}. Hub won't update spoke liveness.`);
3562
+ }
3563
+ }
3564
+ catch (pingErr) {
3565
+ console.warn(`⚠️ Liveness ping error: ${pingErr?.message ?? pingErr}. Hub won't update spoke liveness.`);
3566
+ }
3476
3567
  console.log("No changes since last sync.");
3477
3568
  return { pushed: 0, skipped: 0 };
3478
3569
  }
@@ -4693,7 +4784,7 @@ function oauthDetailLines(o) {
4693
4784
  // discoverLocalFlairPort when the configured URL is unreachable, to detect
4694
4785
  // config-vs-daemon port drift (ops-mbdi). Order is ad-hoc — first hit wins.
4695
4786
  //
4696
- // 9926: original default (long-running rockit installs predate the bump)
4787
+ // 9926: original default (long-running early installs predate the bump)
4697
4788
  // 19926: current default (DEFAULT_PORT)
4698
4789
  // 19925: ops-anvil VM secondary
4699
4790
  const LOCAL_FLAIR_PROBE_PORTS = [9926, 19926, 19925];
@@ -6969,7 +7060,7 @@ memory.command("list")
6969
7060
  });
6970
7061
  // ─── flair memory hygiene ────────────────────────────────────────────────────
6971
7062
  // Detect + remove junk memory rows that accumulate over time. Surfaced from
6972
- // the 2026-05-07 manual cleanup (ops-ucyy): rockit had 627 records, ~250 of
7063
+ // the 2026-05-07 manual cleanup (ops-ucyy): an instance had 627 records, ~250 of
6973
7064
  // them were noise — `*-compact-*` ID fragments from an old pipeline, pangram
6974
7065
  // test content ("the quick brown fox..." / "Flair 251 test ..."), and
6975
7066
  // near-empty rows (<25 chars). We did the cleanup ad-hoc with raw curl + jq;
@@ -8760,9 +8851,74 @@ program
8760
8851
  process.exit(1);
8761
8852
  }
8762
8853
  });
8854
+ // ─── flair presence ─────────────────────────────────────────────────────────
8855
+ const VALID_PRESENCE_ACTIVITIES = ["coding", "reviewing", "planning", "idle"];
8856
+ const MAX_TASK_LENGTH = 120;
8857
+ const presence = program.command("presence").description("Manage agent presence (The Office Space)");
8858
+ presence
8859
+ .command("set")
8860
+ .description("Set your agent's current activity and task (POST /Presence)")
8861
+ .option("--activity <activity>", `Activity type (${VALID_PRESENCE_ACTIVITIES.join("|")})`)
8862
+ .option("--task <text>", "Short description of what you're working on")
8863
+ .option("--agent <id>", "Agent ID (env: FLAIR_AGENT_ID)")
8864
+ .option("--port <port>", "Harper HTTP port")
8865
+ .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
8866
+ .action(async (opts) => {
8867
+ const agentId = resolveAgentIdOrEnv(opts);
8868
+ if (!agentId) {
8869
+ console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
8870
+ process.exit(1);
8871
+ }
8872
+ // Validate activity
8873
+ if (!opts.activity) {
8874
+ console.error("Error: --activity is required.");
8875
+ process.exit(1);
8876
+ }
8877
+ const activity = opts.activity;
8878
+ if (!VALID_PRESENCE_ACTIVITIES.includes(activity)) {
8879
+ console.error(`Error: invalid activity '${activity}'. Must be one of: ${VALID_PRESENCE_ACTIVITIES.join(", ")}`);
8880
+ process.exit(1);
8881
+ }
8882
+ // Validate task length
8883
+ const task = opts.task ?? undefined;
8884
+ if (task && task.length > MAX_TASK_LENGTH) {
8885
+ console.error(`Error: --task exceeds ${MAX_TASK_LENGTH} character limit (got ${task.length}).`);
8886
+ process.exit(1);
8887
+ }
8888
+ // Resolve key path
8889
+ const keyPath = resolveKeyPath(agentId);
8890
+ if (!keyPath) {
8891
+ console.error(`Error: private key not found for agent '${agentId}'. Check ~/.flair/keys/ or set FLAIR_KEY_DIR.`);
8892
+ process.exit(1);
8893
+ }
8894
+ // Build auth + POST
8895
+ const baseUrl = resolveBaseUrl(opts).replace(/\/$/, "");
8896
+ const auth = buildEd25519Auth(agentId, "POST", "/Presence", keyPath);
8897
+ const body = { activity };
8898
+ if (task)
8899
+ body.currentTask = task;
8900
+ const res = await fetch(`${baseUrl}/Presence`, {
8901
+ method: "POST",
8902
+ headers: {
8903
+ "Content-Type": "application/json",
8904
+ Authorization: auth,
8905
+ },
8906
+ body: JSON.stringify(body),
8907
+ });
8908
+ if (!res.ok) {
8909
+ const text = await res.text().catch(() => "");
8910
+ console.error(`Error: POST /Presence failed (${res.status}): ${text}`);
8911
+ process.exit(1);
8912
+ }
8913
+ const data = await res.json().catch(() => null);
8914
+ console.log(`✓ Presence updated for '${agentId}': activity=${activity}${task ? `, task="${task}"` : ""}`);
8915
+ if (data?.presenceStatus) {
8916
+ console.log(` Status: ${data.presenceStatus}`);
8917
+ }
8918
+ });
8763
8919
  // Run CLI only when this is the entry point (not when imported for testing)
8764
8920
  if (import.meta.main) {
8765
8921
  await program.parseAsync();
8766
8922
  }
8767
8923
  // ─── Exported for testing ─────────────────────────────────────────────────────
8768
- export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };
8924
+ export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };
@@ -1,10 +1,5 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
- function readSoulKind(entry) {
3
- return String(entry.kind ?? entry.key ?? "").trim().toLowerCase();
4
- }
5
- function readSoulContent(entry) {
6
- return String(entry.content ?? entry.value ?? "").trim();
7
- }
2
+ import { selectPublicDescription, selectPublicSkills } from "./agentcard-fields.js";
8
3
  export class AgentCard extends Resource {
9
4
  async get(pathInfo) {
10
5
  const agentId = (typeof pathInfo === "string" ? pathInfo : null) ??
@@ -25,19 +20,15 @@ export class AgentCard extends Resource {
25
20
  if (row?.agentId === agentId)
26
21
  souls.push(row);
27
22
  }
28
- const descriptionEntry = souls.find((s) => readSoulKind(s) === "description" && readSoulContent(s)) ??
29
- souls.find((s) => readSoulContent(s));
30
- const skills = souls
31
- .filter((s) => readSoulKind(s) === "capability")
32
- .map((s) => readSoulContent(s))
33
- .filter(Boolean);
34
23
  return {
35
24
  name: String(agent.name ?? agent.id ?? agentId),
36
- description: descriptionEntry ? readSoulContent(descriptionEntry) : "",
25
+ // Only an explicit kind="description" soul publishes no private-soul
26
+ // fallback (ops-vz6j). See agentcard-fields.ts for the security rationale.
27
+ description: selectPublicDescription(souls),
37
28
  url: String(agent.url ?? ""),
38
29
  version: String(agent.version ?? "1.0.0"),
39
30
  capabilities: agent.capabilities && typeof agent.capabilities === "object" ? agent.capabilities : {},
40
- skills,
31
+ skills: selectPublicSkills(souls),
41
32
  defaultInputModes: ["text"],
42
33
  defaultOutputModes: ["text"],
43
34
  };
@@ -0,0 +1,270 @@
1
+ /**
2
+ * POST /Presence — agent heartbeat writes its own presence.
3
+ * GET /Presence — public-safe presence roster for The Office Space.
4
+ *
5
+ * Extends the auto-generated Presence table resource (from schema.graphql).
6
+ * Overrides get() for public-safe roster and post() for Ed25519-authed
7
+ * heartbeat writes.
8
+ *
9
+ * Auth:
10
+ * GET — public (returns only allowlisted fields; safe for public renderer).
11
+ * POST — Ed25519 agent credential (TPS-Ed25519 header). Agent writes only its
12
+ * own record; cross-agent writes are rejected (403).
13
+ *
14
+ * Security (Sherlock):
15
+ * - Write: per-agent Ed25519 auth. Cross-agent → 403.
16
+ * - Read: field-allowlisted to public-safe set. No secrets, no admin data.
17
+ * - currentTask is agent-authored free text → cap length, escape on render.
18
+ */
19
+ import { databases } from "@harperfast/harper";
20
+ // ─── Constants ────────────────────────────────────────────────────────────────
21
+ const WINDOW_MS = 30_000;
22
+ const CURRENT_TASK_MAX_LENGTH = 200;
23
+ const VALID_ACTIVITIES = new Set(["coding", "reviewing", "planning", "idle"]);
24
+ function idleThresholdMs() {
25
+ const env = process.env.PRESENCE_IDLE_THRESHOLD_MS;
26
+ return env ? Number(env) || 90_000 : 90_000;
27
+ }
28
+ function offlineThresholdMs() {
29
+ const env = process.env.PRESENCE_OFFLINE_THRESHOLD_MS;
30
+ return env ? Number(env) || 600_000 : 600_000;
31
+ }
32
+ // ─── Nonce replay protection ──────────────────────────────────────────────────
33
+ const nonceSeen = new Map();
34
+ function pruneNonces() {
35
+ const now = Date.now();
36
+ for (const [k, ts] of nonceSeen.entries()) {
37
+ if (now - ts > WINDOW_MS)
38
+ nonceSeen.delete(k);
39
+ }
40
+ }
41
+ // ─── Crypto helpers ───────────────────────────────────────────────────────────
42
+ function b64ToArrayBuffer(b64) {
43
+ const std = b64.replace(/-/g, "+").replace(/_/g, "/");
44
+ const bin = atob(std);
45
+ const buf = new ArrayBuffer(bin.length);
46
+ const view = new Uint8Array(buf);
47
+ for (let i = 0; i < bin.length; i++)
48
+ view[i] = bin.charCodeAt(i);
49
+ return buf;
50
+ }
51
+ const keyCache = new Map();
52
+ async function importEd25519Key(publicKeyStr) {
53
+ if (keyCache.has(publicKeyStr))
54
+ return keyCache.get(publicKeyStr);
55
+ let raw;
56
+ if (/^[0-9a-f]{64}$/i.test(publicKeyStr)) {
57
+ const bytes = new Uint8Array(32);
58
+ for (let i = 0; i < 32; i++)
59
+ bytes[i] = parseInt(publicKeyStr.slice(i * 2, i * 2 + 2), 16);
60
+ raw = bytes.buffer;
61
+ }
62
+ else {
63
+ raw = b64ToArrayBuffer(publicKeyStr);
64
+ }
65
+ const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
66
+ keyCache.set(publicKeyStr, key);
67
+ return key;
68
+ }
69
+ // ─── Status derivation (pure — exported for unit testing) ─────────────────────
70
+ export function derivePresenceStatus(now, lastHeartbeatAt, idleMs, offlineMs) {
71
+ const idle = idleMs ?? idleThresholdMs();
72
+ const offline = offlineMs ?? offlineThresholdMs();
73
+ if (lastHeartbeatAt == null || !Number.isFinite(lastHeartbeatAt))
74
+ return "offline";
75
+ const elapsed = now - lastHeartbeatAt;
76
+ if (elapsed < 0)
77
+ return "active";
78
+ if (elapsed < idle)
79
+ return "active";
80
+ if (elapsed < offline)
81
+ return "idle";
82
+ return "offline";
83
+ }
84
+ // ─── Public-safe field allowlist ──────────────────────────────────────────────
85
+ const ROSTER_ALLOWLIST = new Set([
86
+ "id",
87
+ "displayName",
88
+ "role",
89
+ "runtime",
90
+ "activity",
91
+ "presenceStatus",
92
+ "currentTask",
93
+ "lastHeartbeatAt",
94
+ ]);
95
+ function sanitizeCurrentTask(task) {
96
+ if (typeof task !== "string")
97
+ return null;
98
+ const trimmed = task.trim();
99
+ if (trimmed.length === 0)
100
+ return null;
101
+ return trimmed.slice(0, CURRENT_TASK_MAX_LENGTH);
102
+ }
103
+ function pickAllowlisted(record) {
104
+ const out = {};
105
+ for (const key of Object.keys(record)) {
106
+ if (ROSTER_ALLOWLIST.has(key))
107
+ out[key] = record[key];
108
+ }
109
+ return out;
110
+ }
111
+ // ─── Resource ─────────────────────────────────────────────────────────────────
112
+ /**
113
+ * Extends the auto-generated Presence table resource (from schema.graphql) so
114
+ * Harper resolves the /Presence path without conflict. Overrides get() for the
115
+ * public-safe roster view and post() for Ed25519-authed heartbeat writes.
116
+ */
117
+ export class Presence extends databases.flair.Presence {
118
+ /** Bypass Harper's role gate for GET (public-safe data only). */
119
+ allowRead() {
120
+ return true;
121
+ }
122
+ /** Bypass Harper's role gate for POST (Ed25519 auth handled internally). */
123
+ allowCreate() {
124
+ return true;
125
+ }
126
+ /**
127
+ * GET /Presence — public-safe presence roster.
128
+ *
129
+ * Joins Presence records with Agent metadata and derives presenceStatus.
130
+ * Only allowlisted fields are returned (no secrets, no admin data).
131
+ */
132
+ async get() {
133
+ const now = Date.now();
134
+ const idleThreshold = idleThresholdMs();
135
+ const offlineThreshold = offlineThresholdMs();
136
+ const results = [];
137
+ try {
138
+ const presenceRows = databases.flair.Presence.search();
139
+ for await (const row of presenceRows) {
140
+ const agentId = row?.agentId;
141
+ if (!agentId)
142
+ continue;
143
+ let agent = null;
144
+ try {
145
+ agent = await databases.flair.Agent.get(agentId);
146
+ }
147
+ catch { /* agent may not exist or be deactivated */ }
148
+ const entry = {
149
+ id: agentId,
150
+ displayName: agent?.displayName ?? agent?.name ?? agentId,
151
+ role: agent?.role ?? "agent",
152
+ runtime: agent?.runtime ?? null,
153
+ activity: row?.activity ?? "idle",
154
+ presenceStatus: derivePresenceStatus(now, typeof row?.lastHeartbeatAt === "number"
155
+ ? row.lastHeartbeatAt
156
+ : Number(row?.lastHeartbeatAt ?? 0), idleThreshold, offlineThreshold),
157
+ currentTask: sanitizeCurrentTask(row?.currentTask),
158
+ lastHeartbeatAt: typeof row?.lastHeartbeatAt === "number"
159
+ ? row.lastHeartbeatAt
160
+ : Number(row?.lastHeartbeatAt ?? 0),
161
+ };
162
+ results.push(pickAllowlisted(entry));
163
+ }
164
+ }
165
+ catch (err) {
166
+ return new Response(JSON.stringify({ error: "presence_query_failed", detail: err?.message }), { status: 500, headers: { "Content-Type": "application/json" } });
167
+ }
168
+ return results;
169
+ }
170
+ /**
171
+ * POST /Presence — agent heartbeat.
172
+ *
173
+ * Auth: TPS-Ed25519 header required. agentId from signature, NOT from body.
174
+ * An agent may update ONLY its own presence; cross-agent writes → 403.
175
+ *
176
+ * Bypasses Harper's default table post handler — writes via databases.flair.Presence
177
+ * directly so we control auth flow end-to-end.
178
+ */
179
+ async post(content, context) {
180
+ const ctx = this.getContext?.();
181
+ const request = ctx?.request ?? ctx;
182
+ // ── Parse Ed25519 auth header ────────────────────────────────────────────
183
+ const authHeader = request?.headers?.get?.("authorization") ??
184
+ request?.headers?.asObject?.authorization ??
185
+ "";
186
+ // If the middleware already verified and set tpsAgent, trust it
187
+ const middlewareAgent = request?.tpsAgent;
188
+ let agentId;
189
+ if (middlewareAgent) {
190
+ agentId = middlewareAgent;
191
+ }
192
+ else {
193
+ const m = authHeader.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
194
+ if (!m) {
195
+ return new Response(JSON.stringify({ error: "Ed25519 agent auth required for heartbeat" }), { status: 401, headers: { "Content-Type": "application/json" } });
196
+ }
197
+ const [, headerAgentId, tsRaw, nonce, sigB64] = m;
198
+ const ts = Number(tsRaw);
199
+ const now = Date.now();
200
+ if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS) {
201
+ return new Response(JSON.stringify({ error: "timestamp_out_of_window" }), { status: 401, headers: { "Content-Type": "application/json" } });
202
+ }
203
+ pruneNonces();
204
+ const nonceKey = `${headerAgentId}:${nonce}`;
205
+ if (nonceSeen.has(nonceKey)) {
206
+ return new Response(JSON.stringify({ error: "nonce_replay_detected" }), { status: 401, headers: { "Content-Type": "application/json" } });
207
+ }
208
+ const agent = await databases.flair.Agent.get(headerAgentId).catch(() => null);
209
+ if (!agent) {
210
+ return new Response(JSON.stringify({ error: "unknown_agent" }), { status: 401, headers: { "Content-Type": "application/json" } });
211
+ }
212
+ try {
213
+ // request.url is just the path portion (e.g. "/Presence")
214
+ const pathname = (request.url ?? "/Presence").split("?")[0];
215
+ const payload = `${headerAgentId}:${tsRaw}:${nonce}:POST:${pathname}`;
216
+ const key = await importEd25519Key(agent.publicKey);
217
+ const sigBuf = b64ToArrayBuffer(sigB64);
218
+ const payloadBuf = new TextEncoder().encode(payload);
219
+ const ok = await crypto.subtle.verify({ name: "Ed25519" }, key, sigBuf, payloadBuf);
220
+ if (!ok) {
221
+ return new Response(JSON.stringify({ error: "invalid_signature" }), { status: 401, headers: { "Content-Type": "application/json" } });
222
+ }
223
+ }
224
+ catch (e) {
225
+ return new Response(JSON.stringify({ error: "signature_verification_failed", detail: e?.message }), { status: 401, headers: { "Content-Type": "application/json" } });
226
+ }
227
+ nonceSeen.set(nonceKey, ts);
228
+ agentId = headerAgentId;
229
+ }
230
+ // ── Validate body ────────────────────────────────────────────────────────
231
+ const { currentTask, activity } = content || {};
232
+ if (activity !== undefined && !VALID_ACTIVITIES.has(activity)) {
233
+ return new Response(JSON.stringify({
234
+ error: "invalid_activity",
235
+ detail: `activity must be one of: ${[...VALID_ACTIVITIES].join(", ")}`,
236
+ }), { status: 400, headers: { "Content-Type": "application/json" } });
237
+ }
238
+ // ── Write presence ───────────────────────────────────────────────────────
239
+ const now = Date.now();
240
+ try {
241
+ let existing = null;
242
+ try {
243
+ existing = await databases.flair.Presence.get(agentId);
244
+ }
245
+ catch { /* first heartbeat */ }
246
+ const record = {
247
+ agentId,
248
+ lastHeartbeatAt: now,
249
+ currentTask: sanitizeCurrentTask(currentTask),
250
+ activity: activity ?? (existing?.activity ?? "idle"),
251
+ };
252
+ if (existing) {
253
+ const merged = { ...existing, ...record };
254
+ await databases.flair.Presence.put(merged);
255
+ }
256
+ else {
257
+ await databases.flair.Presence.put(record);
258
+ }
259
+ return {
260
+ ok: true,
261
+ agentId,
262
+ lastHeartbeatAt: now,
263
+ presenceStatus: "active",
264
+ };
265
+ }
266
+ catch (e) {
267
+ return new Response(JSON.stringify({ error: "presence_write_failed", detail: e?.message }), { status: 500, headers: { "Content-Type": "application/json" } });
268
+ }
269
+ }
270
+ }
@@ -0,0 +1,40 @@
1
+ // Pure selection logic for what an agent's public AgentCard exposes.
2
+ //
3
+ // SECURITY: `GET /AgentCard/{id}` is intentionally UNAUTHENTICATED (A2A spec —
4
+ // agent cards are public discovery metadata, and the auth-middleware allow-list
5
+ // lets it bypass auth). Everything these helpers return is therefore world-
6
+ // readable. They must publish ONLY explicitly-kinded souls — never an implicit
7
+ // fallback that could surface a private soul (an operator's internal note,
8
+ // prompt fragment, or credential reminder) on the public endpoint.
9
+ //
10
+ // Kept free of any @harperfast/harper import so the real logic is unit-testable
11
+ // without spinning up Harper (avoids the simulator-pattern that let the
12
+ // description-fallback leak ship untested — ops-vz6j).
13
+ export function readSoulKind(entry) {
14
+ return String(entry.kind ?? entry.key ?? "").trim().toLowerCase();
15
+ }
16
+ export function readSoulContent(entry) {
17
+ return String(entry.content ?? entry.value ?? "").trim();
18
+ }
19
+ /**
20
+ * The public description is ONLY an explicit `kind="description"` soul.
21
+ *
22
+ * There is deliberately no fallback to "the first soul with any content": that
23
+ * was the ops-vz6j leak — an agent with no description soul would publish an
24
+ * arbitrary private soul on the unauthenticated card. Absent an explicit
25
+ * description, publish nothing.
26
+ */
27
+ export function selectPublicDescription(souls) {
28
+ const entry = souls.find((s) => readSoulKind(s) === "description" && readSoulContent(s));
29
+ return entry ? readSoulContent(entry) : "";
30
+ }
31
+ /**
32
+ * Public skills are ONLY `kind="capability"` souls — an explicit, operator-
33
+ * intended allow-list. Any other soul kind is never published.
34
+ */
35
+ export function selectPublicSkills(souls) {
36
+ return souls
37
+ .filter((s) => readSoulKind(s) === "capability")
38
+ .map((s) => readSoulContent(s))
39
+ .filter(Boolean);
40
+ }
@@ -143,7 +143,11 @@ server.http(async (request, nextLayer) => {
143
143
  // auths every API call (/Agent, /SemanticSearch, /FederationPeers, etc).
144
144
  // Without this allow-list entry, the HTML is 401-blocked on hosted Flair
145
145
  // instances (rockit-local works only because authorizeLocal=true).
146
- url.pathname === "/ObservationCenter")
146
+ url.pathname === "/ObservationCenter" ||
147
+ // Presence roster is public-safe (field-allowlisted); GET serves the
148
+ // Office Space renderer without auth. POST handles Ed25519 auth internally
149
+ // (allowCreate=true on the Resource).
150
+ url.pathname === "/Presence")
147
151
  return nextLayer(request);
148
152
  // If Harper has already authorized this request (e.g. authorizeLocal=true on localhost),
149
153
  // trust Harper's auth decision and pass through without requiring additional headers.
@@ -272,32 +276,34 @@ server.http(async (request, nextLayer) => {
272
276
  request.tpsAgent = agentId;
273
277
  request._tpsAuthVerified = true;
274
278
  request.tpsAgentIsAdmin = await isAdmin(agentId);
275
- // Swap the Authorization header to Basic admin auth so Harper's internal auth
276
- // pipeline (passport) authenticates the request with full permissions including
277
- // HNSW vector search. This requires HDB_ADMIN_PASSWORD to be set.
278
- // NOTE: server.getUser() alone doesn't grant HNSW permissions in Harper v5.
279
- const adminPass = getAdminPass();
280
- if (adminPass !== null) {
281
- try {
282
- const superAuth = "Basic " + btoa("admin:" + adminPass);
283
- request.headers.set("authorization", superAuth);
284
- if (request.headers.asObject)
285
- request.headers.asObject.authorization = superAuth;
286
- }
287
- catch {
288
- // Header manipulation failed fall back to getUser
289
- try {
290
- request.user = await server.getUser("admin", null, request);
291
- }
292
- catch { }
293
- }
279
+ // Grant Harper-level permissions for the cryptographically-verified agent by
280
+ // setting request.user directly to the admin super_user.
281
+ //
282
+ // Prior approach swapped the Authorization header to "Basic admin:<pass>" so
283
+ // Harper's auth pipeline would re-authenticate with full (HNSW-capable)
284
+ // permissions. That silently broke under Harper 5.0.9: its authentication
285
+ // layer (security/auth.ts `authentication()`) reads the Authorization header
286
+ // and resolves request.user BEFORE invoking this middleware via nextHandler.
287
+ // A TPS-Ed25519 header matches no Basic/Bearer strategy, so Harper sets
288
+ // request.user = null and never re-reads the header — our post-hoc swap was
289
+ // ignored, the request ran unauthenticated, and Harper surfaced it downstream
290
+ // as a generic "Login failed" 401. (Broke at the 2026-05-27 daemon restart,
291
+ // which loaded 5.0.9 fresh; the prior long-running process held an older
292
+ // in-memory Harper where the late header read still worked.)
293
+ //
294
+ // Setting request.user directly is the supported extension path and is honored
295
+ // by all downstream resources, including HNSW vector search. getUser(admin,
296
+ // null) looks up the admin record WITHOUT password validation — safe here
297
+ // because the Ed25519 signature verified above already proves agent identity
298
+ // cryptographically; no Basic credential is presented. This preserves the
299
+ // prior privilege model (verified agents act with admin perms; per-agent data
300
+ // isolation is still enforced downstream via the x-tps-agent header below).
301
+ try {
302
+ request.user = await server.getUser("admin", null, request);
294
303
  }
295
- else {
296
- // No admin password configured try server.getUser as fallback (limited permissions)
297
- try {
298
- request.user = await server.getUser("admin", null, request);
299
- }
300
- catch { }
304
+ catch {
305
+ // Admin record unavailablerequest proceeds as the verified tpsAgent
306
+ // without elevated perms; resource-level scoping still applies.
301
307
  }
302
308
  // Propagate authenticated agent to downstream resources via header.
303
309
  // Resources can read this to enforce agent-level scoping.
@@ -23,10 +23,10 @@ const exists = async (path) => {
23
23
  * which is what makes /Health work for callers outside `authorizeLocal`'s
24
24
  * localhost-bypass. Without this, Harper's intrinsic Basic-auth gate fires
25
25
  * BEFORE our HTTP middleware can apply the /Health bypass list in
26
- * auth-middleware.ts, so remote callers (e.g., from rockit hitting a Fabric-
26
+ * auth-middleware.ts, so remote callers (e.g., from a remote host hitting a Fabric-
27
27
  * hosted Flair) get a 401 even though the Resource handler is intentionally
28
28
  * unauthenticated. Symptom matched: Fabric returned 401 + WWW-Authenticate:
29
- * Basic on /Health while rockit-localhost returned 200 — the difference was
29
+ * Basic on /Health while a localhost caller returned 200 — the difference was
30
30
  * Harper's localhost-auto-auth, not anything in our code.
31
31
  *
32
32
  * Same pattern as `FederationPair.allowCreate(){ return true }` (PR #299):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",
@@ -55,7 +55,7 @@
55
55
  "node": ">=22"
56
56
  },
57
57
  "dependencies": {
58
- "@harperfast/harper": "5.0.9",
58
+ "@harperfast/harper": "5.0.21",
59
59
  "@types/js-yaml": "^4.0.9",
60
60
  "commander": "14.0.3",
61
61
  "harper-fabric-embeddings": "0.2.3",
@@ -64,6 +64,15 @@
64
64
  "tar": "^7.5.13",
65
65
  "tweetnacl": "1.0.3"
66
66
  },
67
+ "optionalDependencies": {
68
+ "@node-llama-cpp/linux-arm64": "3.18.1",
69
+ "@node-llama-cpp/linux-armv7l": "3.18.1",
70
+ "@node-llama-cpp/linux-x64": "3.18.1",
71
+ "@node-llama-cpp/mac-arm64-metal": "3.18.1",
72
+ "@node-llama-cpp/mac-x64": "3.18.1",
73
+ "@node-llama-cpp/win-arm64": "3.18.1",
74
+ "@node-llama-cpp/win-x64": "3.18.1"
75
+ },
67
76
  "devDependencies": {
68
77
  "@playwright/test": "^1.59.1",
69
78
  "@types/node": "24.11.0",
@@ -1,8 +1,17 @@
1
1
 
2
+ # ─── Presence table ───────────────────────────────────────────────────────────
3
+
4
+ type Presence @table(database: "flair") @export {
5
+ agentId: ID @primaryKey
6
+ lastHeartbeatAt: BigInt # unix ms timestamp
7
+ currentTask: String # short human string, e.g. "Reviewing flair#467"
8
+ activity: String @indexed # "coding" | "reviewing" | "planning" | "idle"
9
+ }
10
+
2
11
  # ─── Observatory tables ───────────────────────────────────────────────────────
3
12
 
4
13
  type ObsOffice @table(database: "flair") @export {
5
- id: ID @primaryKey # e.g. "rockit"
14
+ id: ID @primaryKey # e.g. "office1"
6
15
  name: String!
7
16
  publicKey: String! # Ed25519 hex public key for auth verification
8
17
  status: String @indexed # "online" | "offline" | "degraded"