@tpsdev-ai/flair 0.10.0 → 0.10.1

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);
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);
3239
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
  }
@@ -4693,7 +4748,7 @@ function oauthDetailLines(o) {
4693
4748
  // discoverLocalFlairPort when the configured URL is unreachable, to detect
4694
4749
  // config-vs-daemon port drift (ops-mbdi). Order is ad-hoc — first hit wins.
4695
4750
  //
4696
- // 9926: original default (long-running rockit installs predate the bump)
4751
+ // 9926: original default (long-running early installs predate the bump)
4697
4752
  // 19926: current default (DEFAULT_PORT)
4698
4753
  // 19925: ops-anvil VM secondary
4699
4754
  const LOCAL_FLAIR_PROBE_PORTS = [9926, 19926, 19925];
@@ -6969,7 +7024,7 @@ memory.command("list")
6969
7024
  });
6970
7025
  // ─── flair memory hygiene ────────────────────────────────────────────────────
6971
7026
  // 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
7027
+ // the 2026-05-07 manual cleanup (ops-ucyy): an instance had 627 records, ~250 of
6973
7028
  // them were noise — `*-compact-*` ID fragments from an old pipeline, pangram
6974
7029
  // test content ("the quick brown fox..." / "Flair 251 test ..."), and
6975
7030
  // near-empty rows (<25 chars). We did the cleanup ad-hoc with raw curl + jq;
@@ -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,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
+ }
@@ -142,7 +142,7 @@ server.http(async (request, nextLayer) => {
142
142
  // and inline JS, with no embedded data. The JS prompts for admin-pass and
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
- // instances (rockit-local works only because authorizeLocal=true).
145
+ // instances (a localhost caller works only because authorizeLocal=true).
146
146
  url.pathname === "/ObservationCenter")
147
147
  return nextLayer(request);
148
148
  // If Harper has already authorized this request (e.g. authorizeLocal=true on localhost),
@@ -272,32 +272,34 @@ server.http(async (request, nextLayer) => {
272
272
  request.tpsAgent = agentId;
273
273
  request._tpsAuthVerified = true;
274
274
  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
- }
275
+ // Grant Harper-level permissions for the cryptographically-verified agent by
276
+ // setting request.user directly to the admin super_user.
277
+ //
278
+ // Prior approach swapped the Authorization header to "Basic admin:<pass>" so
279
+ // Harper's auth pipeline would re-authenticate with full (HNSW-capable)
280
+ // permissions. That silently broke under Harper 5.0.9: its authentication
281
+ // layer (security/auth.ts `authentication()`) reads the Authorization header
282
+ // and resolves request.user BEFORE invoking this middleware via nextHandler.
283
+ // A TPS-Ed25519 header matches no Basic/Bearer strategy, so Harper sets
284
+ // request.user = null and never re-reads the header — our post-hoc swap was
285
+ // ignored, the request ran unauthenticated, and Harper surfaced it downstream
286
+ // as a generic "Login failed" 401. (Broke at the 2026-05-27 daemon restart,
287
+ // which loaded 5.0.9 fresh; the prior long-running process held an older
288
+ // in-memory Harper where the late header read still worked.)
289
+ //
290
+ // Setting request.user directly is the supported extension path and is honored
291
+ // by all downstream resources, including HNSW vector search. getUser(admin,
292
+ // null) looks up the admin record WITHOUT password validation — safe here
293
+ // because the Ed25519 signature verified above already proves agent identity
294
+ // cryptographically; no Basic credential is presented. This preserves the
295
+ // prior privilege model (verified agents act with admin perms; per-agent data
296
+ // isolation is still enforced downstream via the x-tps-agent header below).
297
+ try {
298
+ request.user = await server.getUser("admin", null, request);
294
299
  }
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 { }
300
+ catch {
301
+ // Admin record unavailablerequest proceeds as the verified tpsAgent
302
+ // without elevated perms; resource-level scoping still applies.
301
303
  }
302
304
  // Propagate authenticated agent to downstream resources via header.
303
305
  // 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.10.1",
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",
@@ -2,7 +2,7 @@
2
2
  # ─── Observatory tables ───────────────────────────────────────────────────────
3
3
 
4
4
  type ObsOffice @table(database: "flair") @export {
5
- id: ID @primaryKey # e.g. "rockit"
5
+ id: ID @primaryKey # e.g. "office1"
6
6
  name: String!
7
7
  publicKey: String! # Ed25519 hex public key for auth verification
8
8
  status: String @indexed # "online" | "offline" | "degraded"