@tpsdev-ai/flair 0.11.0 → 0.13.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.
Files changed (38) hide show
  1. package/dist/cli.js +247 -61
  2. package/dist/resources/Agent.js +20 -7
  3. package/dist/resources/AgentCard.js +6 -0
  4. package/dist/resources/AgentSeed.js +7 -1
  5. package/dist/resources/Credential.js +24 -13
  6. package/dist/resources/IngestEvents.js +7 -0
  7. package/dist/resources/Instance.js +13 -0
  8. package/dist/resources/Integration.js +64 -0
  9. package/dist/resources/Memory.js +53 -8
  10. package/dist/resources/MemoryBootstrap.js +8 -0
  11. package/dist/resources/MemoryConsolidate.js +8 -39
  12. package/dist/resources/MemoryFeed.js +8 -0
  13. package/dist/resources/MemoryGrant.js +79 -0
  14. package/dist/resources/MemoryMaintenance.js +1 -1
  15. package/dist/resources/MemoryReflect.js +7 -1
  16. package/dist/resources/MemoryReindex.js +7 -1
  17. package/dist/resources/OAuthClient.js +15 -0
  18. package/dist/resources/ObsAgentSnapshot.js +13 -0
  19. package/dist/resources/ObsEventFeed.js +13 -0
  20. package/dist/resources/ObsOffice.js +19 -0
  21. package/dist/resources/OrgEvent.js +37 -21
  22. package/dist/resources/OrgEventCatchup.js +8 -0
  23. package/dist/resources/OrgEventMaintenance.js +7 -0
  24. package/dist/resources/PairingToken.js +14 -0
  25. package/dist/resources/Peer.js +15 -0
  26. package/dist/resources/Presence.js +30 -0
  27. package/dist/resources/Relationship.js +13 -7
  28. package/dist/resources/SemanticSearch.js +29 -42
  29. package/dist/resources/Soul.js +18 -9
  30. package/dist/resources/SoulFeed.js +7 -0
  31. package/dist/resources/WorkspaceLatest.js +7 -0
  32. package/dist/resources/WorkspaceState.js +38 -28
  33. package/dist/resources/XAA.js +8 -0
  34. package/dist/resources/agent-auth.js +209 -0
  35. package/dist/resources/auth-middleware.js +83 -55
  36. package/dist/resources/memory-consolidate-lib.js +72 -0
  37. package/dist/resources/scoring.js +52 -0
  38. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { patchRecord } from "./table-helpers.js";
2
2
  import { server, databases } from "@harperfast/harper";
3
3
  import { getEmbedding } from "./embeddings-provider.js";
4
+ import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
4
5
  // --- Admin credentials ---
5
6
  // Admin auth is sourced exclusively from Harper's own environment variables
6
7
  // (HDB_ADMIN_PASSWORD / FLAIR_ADMIN_PASSWORD). No filesystem token file.
@@ -31,34 +32,10 @@ function getAdminPass() {
31
32
  const WINDOW_MS = 30_000;
32
33
  const nonceSeen = new Map();
33
34
  // ─── Admin resolution ─────────────────────────────────────────────────────────
34
- // Admin agents: from FLAIR_ADMIN_AGENTS env var (comma-separated) OR
35
- // Agent records with role === "admin". Both sources are OR-combined.
36
- // Result is cached for 60s to avoid per-request DB hits.
37
- let adminCacheExpiry = 0;
38
- let adminCache = new Set();
39
- async function getAdminAgents() {
40
- const now = Date.now();
41
- if (now < adminCacheExpiry)
42
- return adminCache;
43
- const from_env = (process.env.FLAIR_ADMIN_AGENTS ?? "")
44
- .split(",").map((s) => s.trim()).filter(Boolean);
45
- let from_db = [];
46
- try {
47
- const results = await databases.flair.Agent.search([{ attribute: "role", value: "admin", condition: "equals" }]);
48
- for await (const row of results) {
49
- if (row?.id)
50
- from_db.push(row.id);
51
- }
52
- }
53
- catch { /* Agent table might not be populated yet */ }
54
- adminCache = new Set([...from_env, ...from_db]);
55
- adminCacheExpiry = now + 60_000;
56
- return adminCache;
57
- }
58
- export async function isAdmin(agentId) {
59
- const admins = await getAdminAgents();
60
- return admins.has(agentId);
61
- }
35
+ // `isAdmin` (FLAIR_ADMIN_AGENTS env + Agent role==="admin", 60s-cached) now lives
36
+ // in agent-auth.ts as the single source of truth, imported above. During the
37
+ // auth reshape this gate and the per-resource allow* helpers must agree on who's
38
+ // an admin — one implementation guarantees they can't diverge.
62
39
  // ─── Crypto helpers ───────────────────────────────────────────────────────────
63
40
  function b64ToArrayBuffer(b64) {
64
41
  // Handle both standard and URL-safe base64
@@ -149,9 +126,20 @@ server.http(async (request, nextLayer) => {
149
126
  // (allowCreate=true on the Resource).
150
127
  url.pathname === "/Presence")
151
128
  return nextLayer(request);
152
- // If Harper has already authorized this request (e.g. authorizeLocal=true on localhost),
153
- // trust Harper's auth decision and pass through without requiring additional headers.
129
+ // If Harper has already authorized this request (e.g. Basic admin, or
130
+ // authorizeLocal=true on localhost), trust Harper's auth decision and pass
131
+ // through. Annotate the admin identity so resources' resolveAgentAuth recognizes
132
+ // this as an ADMIN caller (not anonymous) — otherwise a Basic-admin request,
133
+ // which carries no TPS-Ed25519 header, gets classified anonymous and denied.
154
134
  if (request.user?.role?.permission?.super_user === true) {
135
+ request.tpsAgent = request.user.username ?? "admin";
136
+ request.tpsAgentIsAdmin = true;
137
+ try {
138
+ request.headers.set("x-tps-agent", request.tpsAgent);
139
+ if (request.headers.asObject)
140
+ request.headers.asObject["x-tps-agent"] = request.tpsAgent;
141
+ }
142
+ catch { /* frozen headers — annotation on request object still applies */ }
155
143
  return nextLayer(request);
156
144
  }
157
145
  // Skip re-entry: if we already swapped auth to Basic, pass through
@@ -224,8 +212,19 @@ server.http(async (request, nextLayer) => {
224
212
  }
225
213
  }
226
214
  }
227
- catch { /* fall through to Ed25519 check */ }
228
- return new Response(JSON.stringify({ error: "invalid_admin_credentials" }), { status: 401 });
215
+ catch { /* fall through to anonymous */ }
216
+ // NON-REJECTING (auth-rbac flip): a Basic header that matched no admin/super_user/
217
+ // pair path → annotate anonymous + pass through (don't 401 — a sibling component's
218
+ // Basic auth on a shared Harper must not be rejected by flair's gate). Resource
219
+ // allow* denies if the path is flair-protected.
220
+ request.tpsAnonymous = true;
221
+ try {
222
+ request.headers.set("x-tps-anonymous", "1");
223
+ if (request.headers.asObject)
224
+ request.headers.asObject["x-tps-anonymous"] = "1";
225
+ }
226
+ catch { /* frozen headers */ }
227
+ return nextLayer(request);
229
228
  }
230
229
  // ── Ed25519 agent auth ────────────────────────────────────────────────────
231
230
  const m = header.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
@@ -244,7 +243,19 @@ server.http(async (request, nextLayer) => {
244
243
  },
245
244
  });
246
245
  }
247
- return new Response(JSON.stringify({ error: "missing_or_invalid_authorization" }), { status: 401 });
246
+ // NON-REJECTING GATE (auth-rbac flip): no valid agent annotate anonymous and
247
+ // pass through. Per-resource allow* (resolveAgentAuth → anonymous → deny) is the
248
+ // enforcement; the gate no longer 401s instance-wide, which was breaking sibling
249
+ // components on a shared Harper / composite hub. Anonymous reaches only public
250
+ // allow-listed paths + resources whose allow* permit it.
251
+ request.tpsAnonymous = true;
252
+ try {
253
+ request.headers.set("x-tps-anonymous", "1");
254
+ if (request.headers.asObject)
255
+ request.headers.asObject["x-tps-anonymous"] = "1";
256
+ }
257
+ catch { /* frozen headers — annotation on the request object still applies */ }
258
+ return nextLayer(request);
248
259
  }
249
260
  const [, agentId, tsRaw, nonce, signatureB64] = m;
250
261
  const ts = Number(tsRaw);
@@ -277,33 +288,50 @@ server.http(async (request, nextLayer) => {
277
288
  request._tpsAuthVerified = true;
278
289
  request.tpsAgentIsAdmin = await isAdmin(agentId);
279
290
  // Grant Harper-level permissions for the cryptographically-verified agent by
280
- // setting request.user directly to the admin super_user.
291
+ // setting request.user directly. Setting request.user is the supported
292
+ // extension path (and the only one that works post-5.0.9: Harper resolves
293
+ // request.user from the Authorization header BEFORE this middleware runs, and
294
+ // a TPS-Ed25519 header matches no Basic/Bearer strategy, so request.user
295
+ // arrives null — see #456). getUser(name, null) looks up the record WITHOUT
296
+ // password validation, safe here because the Ed25519 signature already proved
297
+ // identity cryptographically.
281
298
  //
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.)
299
+ // RESHAPE (auth-rbac) THE FLIP: per-agent DE-ELEVATION. A cryptographically-
300
+ // verified NON-admin agent resolves to the least-privilege `flair-agent` user,
301
+ // NOT admin super_user. The flair_agent role grants exactly the table CRUD agents
302
+ // need; with no operations grant, /sql + /graphql are natively 403 (the hand-
303
+ // rolled raw-query block below becomes belt-and-suspenders). Admins still resolve
304
+ // to admin. getUser(name, null) looks up WITHOUT password validation — safe
305
+ // because the Ed25519 signature already proved identity. Row-level ownership stays
306
+ // enforced via x-tps-agent / resolveAgentAuth, independent of request.user.
293
307
  //
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).
308
+ // GRACEFUL FALLBACK: if the flair-agent user isn't provisioned on this instance
309
+ // yet (pre-migration ensureFlairAgentUser hasn't run), fall back to admin so
310
+ // agents keep working. De-elevation activates per-instance once the user exists.
301
311
  try {
302
- request.user = await server.getUser("admin", null, request);
312
+ if (request.tpsAgentIsAdmin) {
313
+ request.user = await server.getUser("admin", null, request);
314
+ }
315
+ else {
316
+ let deElevated = null;
317
+ try {
318
+ deElevated = await server.getUser(FLAIR_AGENT_USERNAME, null, request);
319
+ }
320
+ catch { /* not provisioned */ }
321
+ // getUser(name, null) returns a ROLE-LESS phantom `{ username }` (not null,
322
+ // not a throw) for a nonexistent user — harper security/user.js
323
+ // findAndValidateUser: `if (!userTmp) { if (!validatePassword) return { username } }`.
324
+ // A phantom is truthy, so `deElevated ?? admin` would keep it and the request
325
+ // would carry NO role → 403 AccessViolation. Require a real role to use the
326
+ // de-elevated user; otherwise fall back to admin (pre-migration instances).
327
+ request.user = (deElevated && deElevated.role)
328
+ ? deElevated
329
+ : await server.getUser("admin", null, request);
330
+ }
303
331
  }
304
332
  catch {
305
- // Admin record unavailable — request proceeds as the verified tpsAgent
306
- // without elevated perms; resource-level scoping still applies.
333
+ // No usable user record — request proceeds as the verified tpsAgent without
334
+ // elevated perms; resource-level scoping (x-tps-agent) still applies.
307
335
  }
308
336
  // Propagate authenticated agent to downstream resources via header.
309
337
  // Resources can read this to enforce agent-level scoping.
@@ -0,0 +1,72 @@
1
+ // ─── Memory Consolidation — pure evaluation logic ───────────────────────────
2
+ // Pure helpers extracted from MemoryConsolidate.ts (Flair #502) so they can be
3
+ // unit-tested directly. Importing MemoryConsolidate.ts pulls in the Harper
4
+ // runtime (`databases` / `Resource`, storage init) and can't run outside a live
5
+ // Harper; this module has no Harper dependency, so
6
+ // test/unit/memory-consolidate.test.ts exercises the real shipped code.
7
+ export function parseDuration(s) {
8
+ const m = s.match(/^(\d+)([dhm])$/);
9
+ if (!m)
10
+ return 30 * 86400_000;
11
+ const n = Number(m[1]);
12
+ if (m[2] === "d")
13
+ return n * 86400_000;
14
+ if (m[2] === "h")
15
+ return n * 3600_000;
16
+ if (m[2] === "m")
17
+ return n * 60_000;
18
+ return 30 * 86400_000;
19
+ }
20
+ /**
21
+ * Classify a single memory record as a promote/archive/keep candidate.
22
+ *
23
+ * Idle age is `now - (lastRetrieved ?? createdAt)`: a memory that was just
24
+ * written and never read has lastRetrieved=null, so without the createdAt
25
+ * fallback its idle age would be Infinity and a minutes-old memory would read
26
+ * as maximally stale and be archived (Flair #502). The createdAt fallback
27
+ * measures "never retrieved" from when the memory was born.
28
+ *
29
+ * A grace window (olderThanMs — the maintenance olderThan) guarantees a
30
+ * freshly-created memory is never an archive candidate regardless of retrieval
31
+ * count: archival only considers memories that have had a fair chance to be read.
32
+ */
33
+ export function evaluate(record, now, olderThanMs) {
34
+ const ageMs = record.createdAt ? now - new Date(record.createdAt).getTime() : 0;
35
+ const count = record.retrievalCount ?? 0;
36
+ const lastUse = record.lastRetrieved ?? record.createdAt;
37
+ const idleMs = lastUse ? now - new Date(lastUse).getTime() : 0;
38
+ const daysIdle = idleMs / 86400_000;
39
+ const everRetrieved = record.lastRetrieved != null;
40
+ const { embedding, ...memory } = record;
41
+ // Promote: high retrieval + persistent durability
42
+ if (record.durability === "persistent" && count >= 5) {
43
+ return { memory, suggestion: "promote", reason: `Retrieved ${count} times — strong promotion candidate for permanent` };
44
+ }
45
+ // Promote: standard → persistent if retrieved frequently
46
+ if (record.durability === "standard" && count >= 3 && ageMs > 7 * 86400_000) {
47
+ return { memory, suggestion: "promote", reason: `Retrieved ${count} times over ${Math.round(ageMs / 86400_000)} days — worth persisting` };
48
+ }
49
+ // Grace window: a freshly-created memory is never an archive candidate,
50
+ // regardless of retrieval count (Flair #502). Archive branches below are
51
+ // reachable only for memories older than the maintenance window.
52
+ if (ageMs <= olderThanMs) {
53
+ return { memory, suggestion: "keep", reason: everRetrieved
54
+ ? `Retrieved ${count} times, ${Math.round(daysIdle)} days since last retrieval`
55
+ : `Created ${Math.round(ageMs / 86400_000)} days ago, not yet retrieved (within grace window)` };
56
+ }
57
+ // Archive: old + never retrieved
58
+ if (daysIdle > 30 && count === 0) {
59
+ return { memory, suggestion: "archive", reason: everRetrieved
60
+ ? `Not retrieved in ${Math.round(daysIdle)} days, ${Math.round(ageMs / 86400_000)} days old`
61
+ : `Never retrieved, ${Math.round(ageMs / 86400_000)} days old` };
62
+ }
63
+ // Archive: idle > 60 days with few retrievals
64
+ if (daysIdle > 60 && count < 2) {
65
+ return { memory, suggestion: "archive", reason: everRetrieved
66
+ ? `Not retrieved in ${Math.round(daysIdle)} days (only ${count} total retrievals)`
67
+ : `Never retrieved, ${Math.round(ageMs / 86400_000)} days old (0 total retrievals)` };
68
+ }
69
+ return { memory, suggestion: "keep", reason: everRetrieved
70
+ ? `Retrieved ${count} times, ${Math.round(daysIdle)} days since last retrieval`
71
+ : `Created ${Math.round(ageMs / 86400_000)} days ago, not yet retrieved` };
72
+ }
@@ -0,0 +1,52 @@
1
+ // ─── Temporal Decay + Relevance Scoring ─────────────────────────────────────
2
+ // Pure scoring functions extracted from SemanticSearch.ts (OPS-AYGD) so they can
3
+ // be unit-tested directly. Importing SemanticSearch.ts pulls in the Harper runtime
4
+ // (storage init) and can't run outside a live Harper; this module has no Harper
5
+ // dependency, so test/unit/temporal-scoring.test.ts exercises the real shipped code.
6
+ export const DURABILITY_WEIGHTS = {
7
+ permanent: 1.0,
8
+ persistent: 0.9,
9
+ standard: 0.7,
10
+ ephemeral: 0.4,
11
+ };
12
+ // Half-life in days for exponential decay per durability level
13
+ export const DECAY_HALF_LIFE_DAYS = {
14
+ permanent: Infinity, // never decays
15
+ persistent: 90,
16
+ standard: 30,
17
+ ephemeral: 7,
18
+ };
19
+ export function recencyFactor(createdAt, durability) {
20
+ const halfLife = DECAY_HALF_LIFE_DAYS[durability] ?? 30;
21
+ if (halfLife === Infinity)
22
+ return 1.0;
23
+ const ageDays = (Date.now() - Date.parse(createdAt)) / (1000 * 60 * 60 * 24);
24
+ const lambda = Math.LN2 / halfLife;
25
+ return Math.exp(-lambda * ageDays);
26
+ }
27
+ // OPS-AYGD: the retrieval boost was unbounded and OVERRODE semantic ranking —
28
+ // recall-eval 2026-06-19 showed composite p@3 0.83 vs raw 1.00, with a popular doc
29
+ // magnetised into 5/6 queries (its rBoost lifted a 0.55-0.65 semantic score above
30
+ // the correct docs). Bounded to a gentle nudge that breaks near-ties without
31
+ // overriding a clear semantic winner. Tuned offline against the real corpus
32
+ // (floor 0.5 + cap 1.1 → composite p@3 recovers to 1.00, magnet eliminated).
33
+ // A query-relative tie-breaker gate (boost only within ~0.05 of the top raw score)
34
+ // is the principled follow-up for graduated boosting; it needs the search loop to
35
+ // pass the candidate-set top score, so it's deferred to its own change.
36
+ export const RBOOST_CAP = 1.1; // max +10% — a tie-breaker, not an override
37
+ export const RBOOST_RELEVANCE_FLOOR = 0.5; // no boost at all for clearly-irrelevant docs
38
+ export function retrievalBoost(retrievalCount) {
39
+ if (!retrievalCount || retrievalCount <= 0)
40
+ return 1.0;
41
+ return Math.min(1.0 + 0.1 * Math.log2(retrievalCount), RBOOST_CAP); // gentle, capped
42
+ }
43
+ export function compositeScore(semanticScore, record) {
44
+ const durability = record.durability ?? "standard";
45
+ const dWeight = DURABILITY_WEIGHTS[durability] ?? 0.7;
46
+ const rFactor = record.createdAt ? recencyFactor(record.createdAt, durability) : 1.0;
47
+ // OPS-AYGD: only apply the retrieval boost when the record is genuinely relevant to
48
+ // this query (semanticScore clears the floor). Below the floor, a popular doc gets
49
+ // no lift — kills the cross-query magnet while preserving boosts for relevant docs.
50
+ const rBoost = semanticScore >= RBOOST_RELEVANCE_FLOOR ? retrievalBoost(record.retrievalCount ?? 0) : 1.0;
51
+ return semanticScore * dWeight * rFactor * rBoost;
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.11.0",
3
+ "version": "0.13.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",