@tpsdev-ai/flair 0.13.0 → 0.15.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.
@@ -50,8 +50,18 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
50
50
  // there was no authenticated agent, so anonymous could write any record).
51
51
  if (auth.kind === "anonymous")
52
52
  return UNAUTH();
53
- if (auth.kind === "agent" && !auth.isAdmin && content.agentId !== auth.agentId) {
54
- return FORBIDDEN("forbidden: cannot write workspace state for another agent");
53
+ // No-forge: a non-admin agent's workspace record is ALWAYS attributed to the
54
+ // authenticated identity (from the Ed25519 signature), never the body. We do
55
+ // NOT trust `content.agentId` — overwriting it (rather than 403'ing a
56
+ // mismatch) mirrors Presence.post(): "agentId from signature, NOT from body".
57
+ // An admin may write on behalf of another agent (content.agentId honored if
58
+ // present, else defaults to the admin's own id). Internal in-process callers
59
+ // keep whatever agentId they pass.
60
+ if (auth.kind === "agent" && !auth.isAdmin) {
61
+ content.agentId = auth.agentId;
62
+ }
63
+ else if (auth.kind === "agent" && auth.isAdmin) {
64
+ content.agentId ||= auth.agentId;
55
65
  }
56
66
  content.createdAt = new Date().toISOString();
57
67
  content.timestamp ||= content.createdAt;
@@ -0,0 +1,53 @@
1
+ // URL resolution for the A2A adapter — flair#507.
2
+ //
3
+ // A default local Flair install listens on DEFAULT_HTTP_PORT (19926, the
4
+ // CLI's `DEFAULT_PORT` in src/cli.ts), NOT on 9926 (the legacy early-install
5
+ // port). The A2A agent-card `url` and the streaming catch-up self-fetch must
6
+ // reflect the REAL listening port, or a remote A2A peer that follows discovery
7
+ // hits a dead port.
8
+ //
9
+ // Kept free of any @harperfast/harper import so the resolution logic is
10
+ // unit-testable without spinning up Harper (mirrors agentcard-fields.ts —
11
+ // avoids the simulator-pattern drift that let the AdminInstance predicate be
12
+ // reproduced-not-imported).
13
+ // The CLI's DEFAULT_HTTP_PORT (src/cli.ts `DEFAULT_PORT`). Keep in sync.
14
+ export const DEFAULT_HTTP_PORT = 19926;
15
+ // Loopback base URL for in-process self-calls (e.g. the streaming catch-up
16
+ // fetch). Points at the port Flair is ACTUALLY listening on, which Harper
17
+ // exposes via HTTP_PORT in the runtime env — never the public/proxy URL.
18
+ // Falls back to DEFAULT_HTTP_PORT for a default local install.
19
+ export function localBaseUrl(env = process.env) {
20
+ return `http://127.0.0.1:${env.HTTP_PORT || DEFAULT_HTTP_PORT}`;
21
+ }
22
+ // Public base URL advertised in the A2A agent card so remote peers can reach
23
+ // this Flair. Mirrors AdminInstance.resolvePublicUrl (flair#404):
24
+ // 1. FLAIR_PUBLIC_URL (explicit override, always wins)
25
+ // 2. Request headers (X-Forwarded-Proto/Host or Host) — how the caller
26
+ // actually reached us
27
+ // 3. http://127.0.0.1:${HTTP_PORT} — local-only fallback on the REAL port
28
+ export function resolvePublicBaseUrl(request, env = process.env) {
29
+ if (env.FLAIR_PUBLIC_URL) {
30
+ return env.FLAIR_PUBLIC_URL.replace(/\/$/, "");
31
+ }
32
+ const getHeader = (name) => {
33
+ const h = request?.headers;
34
+ if (!h)
35
+ return undefined;
36
+ if (typeof h.get === "function")
37
+ return h.get(name) ?? h.get(name.toLowerCase()) ?? undefined;
38
+ if (typeof h === "object") {
39
+ const obj = h.asObject ?? h;
40
+ return obj[name] ?? obj[name.toLowerCase()] ?? undefined;
41
+ }
42
+ return undefined;
43
+ };
44
+ const fwdProto = getHeader("X-Forwarded-Proto");
45
+ const fwdHost = getHeader("X-Forwarded-Host");
46
+ const host = fwdHost ?? getHeader("Host");
47
+ if (host && /^[\w.\-:]+$/.test(host)) {
48
+ const scheme = fwdProto && (fwdProto === "http" || fwdProto === "https") ? fwdProto : "https";
49
+ const effectiveScheme = fwdProto ? scheme : (host.includes(":") ? "http" : scheme);
50
+ return `${effectiveScheme}://${host}`;
51
+ }
52
+ return localBaseUrl(env);
53
+ }
@@ -16,6 +16,7 @@
16
16
  * 30s timestamp window + a per-(agent,nonce) seen-set pruned to that window.
17
17
  */
18
18
  import { databases } from "@harperfast/harper";
19
+ import { b64ToArrayBuffer } from "./b64.js";
19
20
  const WINDOW_MS = Number(process.env.FLAIR_AGENT_AUTH_WINDOW_MS) || 30_000;
20
21
  /**
21
22
  * Shared Harper user that verified Ed25519 agents resolve to (least-privilege
@@ -27,16 +28,8 @@ export const FLAIR_AGENT_USERNAME = "flair-agent";
27
28
  // Replay protection: remember recently-seen (agent:nonce), pruned by the window.
28
29
  const nonceSeen = new Map();
29
30
  // ─── Crypto helpers ───────────────────────────────────────────────────────────
30
- function b64ToArrayBuffer(b64) {
31
- // Handle both standard and URL-safe base64.
32
- const std = b64.replace(/-/g, "+").replace(/_/g, "/");
33
- const bin = atob(std);
34
- const buf = new ArrayBuffer(bin.length);
35
- const view = new Uint8Array(buf);
36
- for (let i = 0; i < bin.length; i++)
37
- view[i] = bin.charCodeAt(i);
38
- return buf;
39
- }
31
+ // b64ToArrayBuffer lives in ./b64.ts (shared with auth-middleware.ts + Presence.ts
32
+ // so the base64/base64url decoder can't drift across the three auth call sites).
40
33
  const keyCache = new Map();
41
34
  async function importEd25519Key(publicKeyStr) {
42
35
  const cached = keyCache.get(publicKeyStr);
@@ -2,6 +2,7 @@ import { patchRecord } from "./table-helpers.js";
2
2
  import { server, databases } from "@harperfast/harper";
3
3
  import { getEmbedding } from "./embeddings-provider.js";
4
4
  import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
5
+ import { b64ToArrayBuffer } from "./b64.js";
5
6
  // --- Admin credentials ---
6
7
  // Admin auth is sourced exclusively from Harper's own environment variables
7
8
  // (HDB_ADMIN_PASSWORD / FLAIR_ADMIN_PASSWORD). No filesystem token file.
@@ -37,16 +38,8 @@ const nonceSeen = new Map();
37
38
  // auth reshape this gate and the per-resource allow* helpers must agree on who's
38
39
  // an admin — one implementation guarantees they can't diverge.
39
40
  // ─── Crypto helpers ───────────────────────────────────────────────────────────
40
- function b64ToArrayBuffer(b64) {
41
- // Handle both standard and URL-safe base64
42
- const std = b64.replace(/-/g, '+').replace(/_/g, '/');
43
- const bin = atob(std);
44
- const buf = new ArrayBuffer(bin.length);
45
- const view = new Uint8Array(buf);
46
- for (let i = 0; i < bin.length; i++)
47
- view[i] = bin.charCodeAt(i);
48
- return buf;
49
- }
41
+ // b64ToArrayBuffer lives in ./b64.ts (shared with agent-auth.ts + Presence.ts so
42
+ // the base64/base64url decoder can't drift across the three auth call sites).
50
43
  const keyCache = new Map();
51
44
  async function importEd25519Key(publicKeyStr) {
52
45
  if (keyCache.has(publicKeyStr))
@@ -0,0 +1,40 @@
1
+ /**
2
+ * b64 — single shared base64 / base64url decoder for Ed25519 auth.
3
+ *
4
+ * This was previously copy-pasted into three files (auth-middleware.ts,
5
+ * agent-auth.ts, Presence.ts) and they drifted: some copies fed url-safe input
6
+ * straight to `atob`, which rejects `-`/`_` ("Invalid character") and 401s any
7
+ * agent whose public key (or signature) is base64url-encoded. Found in the
8
+ * Rivet × krais cross-org dogfood: an Agent registered with a base64url pubkey
9
+ * containing `-`/`_` failed signature verification. One shared decoder so the
10
+ * three call sites can't diverge again (same lesson as HarperFast/harper#1466).
11
+ *
12
+ * Accepts BOTH standard base64 (`+` `/`, with optional `=` padding) AND
13
+ * base64url (`-` `_`, padded or unpadded). Standard input is unchanged.
14
+ */
15
+ /**
16
+ * Decode a base64 OR base64url string to an ArrayBuffer.
17
+ *
18
+ * Normalizes url-safe alphabet (`-`→`+`, `_`→`/`) and right-pads with `=` to a
19
+ * length that is a multiple of 4 before calling `atob`, so unpadded base64url
20
+ * (the common JWK / Buffer.toString('base64url') form — e.g. a 32-byte key is
21
+ * 43 chars, a 64-byte signature is 86 chars) decodes correctly regardless of
22
+ * how lenient the host runtime's `atob` is about missing padding.
23
+ */
24
+ export function b64ToArrayBuffer(b64) {
25
+ // Normalize base64url → standard base64 alphabet.
26
+ let std = b64.replace(/-/g, "+").replace(/_/g, "/");
27
+ // Right-pad to a multiple of 4 so atob accepts unpadded base64url input.
28
+ const remainder = std.length % 4;
29
+ if (remainder === 2)
30
+ std += "==";
31
+ else if (remainder === 3)
32
+ std += "=";
33
+ // remainder === 1 is not a valid base64 length; let atob throw as before.
34
+ const bin = atob(std);
35
+ const buf = new ArrayBuffer(bin.length);
36
+ const view = new Uint8Array(buf);
37
+ for (let i = 0; i < bin.length; i++)
38
+ view[i] = bin.charCodeAt(i);
39
+ return buf;
40
+ }
@@ -0,0 +1,84 @@
1
+ // ─── BM25 candidate SECURITY filter (the hard cross-agent trust boundary) ────
2
+ // Per spec FLAIR-BM25-HYBRID-RETRIEVAL §26 + Sherlock's gate (§45-46):
3
+ //
4
+ // "apply the SAME conditions[] filter (agent scoping, archived exclusion,
5
+ // tag/subject) to the BM25 candidates BEFORE fusion. BM25-scoring other
6
+ // agents' private memories and filtering only after fusion would leak
7
+ // term-frequency / content metadata across agent boundaries. The conditions
8
+ // filter MUST precede the union/fusion."
9
+ //
10
+ // The HNSW path gets this filter FOR FREE — Harper applies conditions[] inside
11
+ // Memory.search(). The BM25 pass runs in-process over the corpus, so it MUST
12
+ // re-apply the IDENTICAL predicate itself, BEFORE any scoring is fused or even
13
+ // returned. This module is the single source of that predicate.
14
+ //
15
+ // Harper-free + pure so the security test exercises the SHIPPED predicate
16
+ // directly (test/unit/bm25-security.test.ts).
17
+ //
18
+ // The condition shapes mirror EXACTLY what SemanticSearch.ts builds:
19
+ // - leaf: { attribute, comparator, value }
20
+ // - group: { operator: "or", conditions: [...] }
21
+ // supported comparators: "equals", "not_equal" (the only ones SemanticSearch
22
+ // emits into conditions[]). An unknown comparator/shape is treated as NON-matching
23
+ // (fail closed — never leak on an unrecognized condition).
24
+ function isGroup(c) {
25
+ return c.operator !== undefined && Array.isArray(c.conditions);
26
+ }
27
+ // Evaluate a single leaf condition against a record. `tags` is array-valued in
28
+ // Harper; "equals" on an array attribute is membership (matches Harper's
29
+ // array-attribute equals semantics used by the tag filter).
30
+ function matchLeaf(cond, record) {
31
+ const actual = record?.[cond.attribute];
32
+ switch (cond.comparator) {
33
+ case "equals":
34
+ if (Array.isArray(actual))
35
+ return actual.includes(cond.value);
36
+ return actual === cond.value;
37
+ case "not_equal":
38
+ // Harper "not_equal" semantics used for `archived not_equal true`: a record
39
+ // WITHOUT the field (undefined) is included — it is "not equal" to true.
40
+ if (Array.isArray(actual))
41
+ return !actual.includes(cond.value);
42
+ return actual !== cond.value;
43
+ default:
44
+ // Unknown comparator → fail closed (do NOT pass a record on a condition we
45
+ // can't evaluate; that could leak across the agent boundary).
46
+ return false;
47
+ }
48
+ }
49
+ function matchCondition(cond, record) {
50
+ if (isGroup(cond)) {
51
+ const results = cond.conditions.map((c) => matchCondition(c, record));
52
+ return cond.operator === "or" ? results.some(Boolean) : results.every(Boolean);
53
+ }
54
+ return matchLeaf(cond, record);
55
+ }
56
+ // AND across the top-level conditions[] (Harper's default for a conditions array
57
+ // — same as SemanticSearch passing them as the implicit-AND query.conditions).
58
+ export function matchesConditions(conditions, record) {
59
+ for (const c of conditions) {
60
+ if (!matchCondition(c, record))
61
+ return false;
62
+ }
63
+ return true;
64
+ }
65
+ export function passesRecordFilters(record, f = {}) {
66
+ const now = f.now ?? Date.now();
67
+ if (record?.expiresAt && Date.parse(record.expiresAt) < now)
68
+ return false;
69
+ if (f.sinceDate && record?.createdAt && new Date(record.createdAt) < f.sinceDate)
70
+ return false;
71
+ if (f.asOf && record?.validFrom && record.validFrom > f.asOf)
72
+ return false;
73
+ if (f.asOf && record?.validTo && record.validTo <= f.asOf)
74
+ return false;
75
+ return true;
76
+ }
77
+ // The full BM25-candidate security filter: a record is a valid BM25 candidate
78
+ // ONLY if it passes BOTH the agent-scoping/archived/tag/subject conditions[] AND
79
+ // the per-record temporal filters — IDENTICAL to the HNSW candidate gate. Apply
80
+ // this to the corpus BEFORE building/scoring the union (defense at the boundary,
81
+ // not after fusion).
82
+ export function isAllowedBm25Candidate(record, conditions, timeFilters = {}) {
83
+ return matchesConditions(conditions, record) && passesRecordFilters(record, timeFilters);
84
+ }
@@ -0,0 +1,130 @@
1
+ // ─── BM25 + union-RRF hybrid retrieval (Harper-free, unit-testable) ──────────
2
+ // Per spec FLAIR-BM25-HYBRID-RETRIEVAL (Kern-approved, ops-i39b). This module is
3
+ // deliberately Harper-free — same rationale as ./scoring.ts — so the BM25
4
+ // scoring, the candidate-union RRF fusion, and the security conditions-filter
5
+ // can be unit-tested directly against the SHIPPED code without a live Harper.
6
+ //
7
+ // Ported from the pilot ops/tools/agent-fabric/bm25-rrf-pilot.mjs (commit
8
+ // 5552320), which validated: BM25 alone recovers 5/6 severe misses into top-3;
9
+ // candidate-UNION RRF (NOT naive whole-corpus RRF) recovers 4/6 into top-10 with
10
+ // no regression on the within-cluster gate (p@3 holds 0.88).
11
+ // ─── Feature flag: BM25 + union-RRF hybrid retrieval ────────────────────────
12
+ // Flag OFF (default) → SemanticSearch behavior is byte-identical to the
13
+ // pre-hybrid path (HNSW + the +0.05 exact-substring keyword bump). Flag ON → the
14
+ // hybrid path. Toggle with FLAIR_HYBRID_RETRIEVAL=true (also "1" / "on"). Read
15
+ // per-call so it can be flipped without a rebuild and set per-case in tests.
16
+ // Lives here (Harper-free) so it's unit-testable.
17
+ export function hybridEnabled() {
18
+ const v = (process.env.FLAIR_HYBRID_RETRIEVAL ?? "").toLowerCase();
19
+ return v === "true" || v === "1" || v === "on";
20
+ }
21
+ // BM25 parameters (Kern-approved): k1≈1.2, b≈0.75; standard IDF + BM25.
22
+ export const BM25_K1 = 1.2;
23
+ export const BM25_B = 0.75;
24
+ // RRF constant (Cormack et al. 2009 default). A doc absent from a list
25
+ // contributes 0 from that list (rank = ∞).
26
+ export const RRF_K = 60;
27
+ // BM25 candidate window — top-N by BM25 score fused into the union (spec §35:
28
+ // "BM25 uses a fixed SEM_LIMIT=50"). Independent of CANDIDATE_MULTIPLIER (the
29
+ // HNSW fetch size, which is left untouched).
30
+ export const SEM_LIMIT = 50;
31
+ // Tokenize: lowercase, split on non-alphanumeric, drop trivial stopwords and
32
+ // 1-char tokens. Standard, language-agnostic enough for the corpus.
33
+ const STOP = new Set(("a an the and or but of to in on at for with from by as is are was were be been being " +
34
+ "this that these those it its do does did so if then than when how what why who whom which while " +
35
+ "i you he she we they them his her our your their not no yes can will would should could may might " +
36
+ "have has had get got into out over under again about up down off all any each").split(" "));
37
+ export function tokenize(text) {
38
+ return ((text || "").toLowerCase().match(/[a-z0-9]+/g) || []).filter((t) => t.length > 1 && !STOP.has(t));
39
+ }
40
+ // Build a BM25 index over docs[].content. Standard Robertson/Sparck-Jones BM25
41
+ // with the +1 IDF variant (always non-negative — the common Lucene/Elasticsearch
42
+ // form).
43
+ export function buildBM25(docs) {
44
+ const N = docs.length;
45
+ const docTokens = docs.map((d) => tokenize(d.content || ""));
46
+ const docLen = docTokens.map((t) => t.length);
47
+ const avgdl = docLen.reduce((s, x) => s + x, 0) / (N || 1);
48
+ const tfPerDoc = docTokens.map((toks) => {
49
+ const tf = new Map();
50
+ for (const t of toks)
51
+ tf.set(t, (tf.get(t) || 0) + 1);
52
+ return tf;
53
+ });
54
+ const df = new Map();
55
+ for (const tf of tfPerDoc)
56
+ for (const term of tf.keys())
57
+ df.set(term, (df.get(term) || 0) + 1);
58
+ const idf = new Map();
59
+ for (const [term, n] of df)
60
+ idf.set(term, Math.log(1 + (N - n + 0.5) / (n + 0.5)));
61
+ function rank(query) {
62
+ const qToks = [...new Set(tokenize(query))];
63
+ const scored = docs.map((d, i) => {
64
+ const tf = tfPerDoc[i];
65
+ const dl = docLen[i];
66
+ let s = 0;
67
+ for (const term of qToks) {
68
+ const f = tf.get(term);
69
+ if (!f)
70
+ continue;
71
+ const numer = f * (BM25_K1 + 1);
72
+ const denom = f + BM25_K1 * (1 - BM25_B + BM25_B * (dl / (avgdl || 1)));
73
+ s += (idf.get(term) || 0) * (numer / denom);
74
+ }
75
+ return { id: d.id, score: s };
76
+ });
77
+ scored.sort((a, b) => b.score - a.score);
78
+ return scored;
79
+ }
80
+ return { rank, get N() { return N; }, get avgdl() { return avgdl; } };
81
+ }
82
+ // ─── Reciprocal Rank Fusion over a candidate UNION ──────────────────────────
83
+ // rankings: array of ordered id-lists (best-first). A doc absent from a list
84
+ // contributes 0 from that list. `universe` = the id set fused over — for the
85
+ // production hybrid this is the candidate UNION (semantic ∪ bm25), NOT the whole
86
+ // corpus (naive whole-corpus RRF FAILS — the broken semantic list floods the
87
+ // fusion and buries BM25's rank-1 hits; pilot confirmed 0/6).
88
+ //
89
+ // Returns a Map id → raw RRF score. Caller normalizes + sorts.
90
+ export function rrfScores(rankings, universe) {
91
+ const score = new Map();
92
+ for (const id of universe)
93
+ score.set(id, 0);
94
+ for (const list of rankings) {
95
+ list.forEach((id, idx) => {
96
+ if (!score.has(id))
97
+ return; // doc not in this universe (union mode)
98
+ score.set(id, (score.get(id) || 0) + 1 / (RRF_K + idx + 1)); // idx+1 = 1-based rank
99
+ });
100
+ }
101
+ return score;
102
+ }
103
+ // Fuse semantic + BM25 candidate id-lists via candidate-union RRF and return a
104
+ // per-id score normalized to [0,1] (rrf / max_rrf_in_union). This normalized
105
+ // value is the rawScore fed to compositeScore so durability/recency/rBoost and
106
+ // the RBOOST_RELEVANCE_FLOOR / minScore thresholds still apply unchanged.
107
+ //
108
+ // semIds — semantic candidate ids, best-first (from the HNSW pass).
109
+ // bm25Ids — BM25 candidate ids, best-first, already sliced to SEM_LIMIT and
110
+ // already SECURITY-FILTERED (see filterBm25Candidates) BEFORE this call.
111
+ //
112
+ // The union dedupes ids across both lists. Absent-from-a-list = 0 contribution.
113
+ export function fuseRrfNormalized(semIds, bm25Ids) {
114
+ const union = new Set([...semIds, ...bm25Ids]);
115
+ const raw = rrfScores([semIds, bm25Ids], union);
116
+ let maxRrf = 0;
117
+ for (const v of raw.values())
118
+ if (v > maxRrf)
119
+ maxRrf = v;
120
+ const norm = new Map();
121
+ if (maxRrf <= 0) {
122
+ // Degenerate (empty union) — nothing to normalize.
123
+ for (const [id] of raw)
124
+ norm.set(id, 0);
125
+ return norm;
126
+ }
127
+ for (const [id, v] of raw)
128
+ norm.set(id, v / maxRrf);
129
+ return norm;
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.13.0",
3
+ "version": "0.15.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",
@@ -25,7 +25,7 @@
25
25
  "openclaw"
26
26
  ],
27
27
  "bin": {
28
- "flair": "dist/cli.js"
28
+ "flair": "dist/cli-shim.cjs"
29
29
  },
30
30
  "files": [
31
31
  "dist/",
@@ -46,7 +46,7 @@
46
46
  "test": "bun test",
47
47
  "test:e2e": "playwright test",
48
48
  "release": "./scripts/release.sh",
49
- "postinstall": "node -e \"try{const{chmodSync,statSync}=require('fs');const p='dist/cli.js';if(statSync(p).isFile()){chmodSync(p,0o755);console.error('@tpsdev-ai/flair: chmod +x ' + p + ' OK')}}catch(e){if(e.code!=='ENOENT')console.error('postinstall warn:',e.message)}\""
49
+ "postinstall": "node -e \"try{const{chmodSync,statSync}=require('fs');for(const p of ['dist/cli-shim.cjs','dist/cli.js']){try{if(statSync(p).isFile()){chmodSync(p,0o755);console.error('@tpsdev-ai/flair: chmod +x ' + p + ' OK')}}catch(e){if(e.code!=='ENOENT')console.error('postinstall warn:',e.message)}}}catch(e){console.error('postinstall warn:',e.message)}\""
50
50
  },
51
51
  "publishConfig": {
52
52
  "access": "public"