@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.
- package/dist/cli.js +247 -61
- package/dist/resources/Agent.js +20 -7
- package/dist/resources/AgentCard.js +6 -0
- package/dist/resources/AgentSeed.js +7 -1
- package/dist/resources/Credential.js +24 -13
- package/dist/resources/IngestEvents.js +7 -0
- package/dist/resources/Instance.js +13 -0
- package/dist/resources/Integration.js +64 -0
- package/dist/resources/Memory.js +53 -8
- package/dist/resources/MemoryBootstrap.js +8 -0
- package/dist/resources/MemoryConsolidate.js +8 -39
- package/dist/resources/MemoryFeed.js +8 -0
- package/dist/resources/MemoryGrant.js +79 -0
- package/dist/resources/MemoryMaintenance.js +1 -1
- package/dist/resources/MemoryReflect.js +7 -1
- package/dist/resources/MemoryReindex.js +7 -1
- package/dist/resources/OAuthClient.js +15 -0
- package/dist/resources/ObsAgentSnapshot.js +13 -0
- package/dist/resources/ObsEventFeed.js +13 -0
- package/dist/resources/ObsOffice.js +19 -0
- package/dist/resources/OrgEvent.js +37 -21
- package/dist/resources/OrgEventCatchup.js +8 -0
- package/dist/resources/OrgEventMaintenance.js +7 -0
- package/dist/resources/PairingToken.js +14 -0
- package/dist/resources/Peer.js +15 -0
- package/dist/resources/Presence.js +30 -0
- package/dist/resources/Relationship.js +13 -7
- package/dist/resources/SemanticSearch.js +29 -42
- package/dist/resources/Soul.js +18 -9
- package/dist/resources/SoulFeed.js +7 -0
- package/dist/resources/WorkspaceLatest.js +7 -0
- package/dist/resources/WorkspaceState.js +38 -28
- package/dist/resources/XAA.js +8 -0
- package/dist/resources/agent-auth.js +209 -0
- package/dist/resources/auth-middleware.js +83 -55
- package/dist/resources/memory-consolidate-lib.js +72 -0
- package/dist/resources/scoring.js +52 -0
- 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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
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.
|
|
153
|
-
// trust Harper's auth decision and pass
|
|
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
|
|
228
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
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
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
306
|
-
//
|
|
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.
|
|
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",
|