@tpsdev-ai/flair 0.20.1 → 0.22.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 (104) hide show
  1. package/README.md +39 -11
  2. package/SECURITY.md +52 -20
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +2235 -260
  5. package/dist/deploy.js +96 -15
  6. package/dist/doctor-client.js +312 -0
  7. package/dist/fleet-presence.js +98 -0
  8. package/dist/fleet-verify.js +291 -0
  9. package/dist/install/clients.js +18 -0
  10. package/dist/mcp-client-assertion.js +364 -0
  11. package/dist/probe.js +97 -0
  12. package/dist/rem/restore.js +1 -1
  13. package/dist/rem/runner.js +82 -12
  14. package/dist/resources/Admin.js +1 -1
  15. package/dist/resources/AdminConnectors.js +1 -1
  16. package/dist/resources/AdminDashboard.js +1 -1
  17. package/dist/resources/AdminIdp.js +1 -1
  18. package/dist/resources/AdminInstance.js +1 -1
  19. package/dist/resources/AdminMemory.js +2 -2
  20. package/dist/resources/AdminPrincipals.js +1 -1
  21. package/dist/resources/Agent.js +14 -0
  22. package/dist/resources/AgentCard.js +1 -1
  23. package/dist/resources/AgentSeed.js +1 -1
  24. package/dist/resources/AttentionQuery.js +356 -0
  25. package/dist/resources/Credential.js +11 -1
  26. package/dist/resources/Federation.js +121 -2
  27. package/dist/resources/Integration.js +1 -1
  28. package/dist/resources/MCPClientMetadata.js +88 -0
  29. package/dist/resources/Memory.js +127 -22
  30. package/dist/resources/MemoryBootstrap.js +441 -85
  31. package/dist/resources/MemoryGrant.js +1 -1
  32. package/dist/resources/MemoryReflect.js +105 -49
  33. package/dist/resources/MemoryUsage.js +104 -0
  34. package/dist/resources/OAuth.js +69 -5
  35. package/dist/resources/OrgEvent.js +11 -0
  36. package/dist/resources/OrgEventCatchup.js +1 -1
  37. package/dist/resources/Presence.js +268 -17
  38. package/dist/resources/RecordUsage.js +230 -0
  39. package/dist/resources/Relationship.js +108 -26
  40. package/dist/resources/SemanticSearch.js +88 -320
  41. package/dist/resources/SkillScan.js +15 -0
  42. package/dist/resources/Soul.js +14 -0
  43. package/dist/resources/WorkspaceLatest.js +1 -1
  44. package/dist/resources/WorkspaceState.js +12 -1
  45. package/dist/resources/agent-auth.js +61 -3
  46. package/dist/resources/agentcard-fields.js +2 -2
  47. package/dist/resources/auth-middleware.js +42 -14
  48. package/dist/resources/bm25-filter.js +1 -1
  49. package/dist/resources/bm25.js +13 -7
  50. package/dist/resources/collision-lib.js +157 -0
  51. package/dist/resources/dedup.js +2 -2
  52. package/dist/resources/ed25519-auth.js +2 -2
  53. package/dist/resources/embeddings-boot.js +149 -0
  54. package/dist/resources/embeddings-provider.js +364 -106
  55. package/dist/resources/entity-vocab.js +139 -0
  56. package/dist/resources/federation-nonce-store.js +195 -0
  57. package/dist/resources/health.js +97 -6
  58. package/dist/resources/instance-identity.js +53 -0
  59. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  60. package/dist/resources/mcp-handler.js +1 -1
  61. package/dist/resources/mcp-tools.js +88 -10
  62. package/dist/resources/memory-bootstrap-lib.js +1 -1
  63. package/dist/resources/memory-read-scope.js +58 -71
  64. package/dist/resources/memory-reflect-lib.js +289 -0
  65. package/dist/resources/memory-visibility.js +37 -0
  66. package/dist/resources/migration-boot.js +143 -0
  67. package/dist/resources/migrations/dir-safety.js +71 -0
  68. package/dist/resources/migrations/embedding-stamp.js +178 -0
  69. package/dist/resources/migrations/envelope.js +43 -0
  70. package/dist/resources/migrations/export.js +38 -0
  71. package/dist/resources/migrations/ledger.js +55 -0
  72. package/dist/resources/migrations/lock.js +154 -0
  73. package/dist/resources/migrations/progress.js +31 -0
  74. package/dist/resources/migrations/registry.js +36 -0
  75. package/dist/resources/migrations/risk-policy.js +23 -0
  76. package/dist/resources/migrations/runner.js +456 -0
  77. package/dist/resources/migrations/snapshot.js +127 -0
  78. package/dist/resources/migrations/source-fields.js +93 -0
  79. package/dist/resources/migrations/space.js +73 -0
  80. package/dist/resources/migrations/state.js +64 -0
  81. package/dist/resources/migrations/status.js +39 -0
  82. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  83. package/dist/resources/migrations/types.js +9 -0
  84. package/dist/resources/presence-internal.js +29 -0
  85. package/dist/resources/provenance.js +50 -0
  86. package/dist/resources/rate-limiter.js +8 -1
  87. package/dist/resources/scoring.js +124 -5
  88. package/dist/resources/semantic-retrieval-core.js +317 -0
  89. package/dist/version-check.js +167 -0
  90. package/dist/version-handshake.js +122 -0
  91. package/package.json +4 -5
  92. package/schemas/agent.graphql +7 -0
  93. package/schemas/event.graphql +5 -0
  94. package/schemas/federation.graphql +12 -0
  95. package/schemas/memory.graphql +82 -0
  96. package/schemas/schema.graphql +5 -43
  97. package/schemas/workspace.graphql +3 -0
  98. package/dist/resources/IngestEvents.js +0 -162
  99. package/dist/resources/IssueTokens.js +0 -19
  100. package/dist/resources/ObsAgentSnapshot.js +0 -13
  101. package/dist/resources/ObsEventFeed.js +0 -13
  102. package/dist/resources/ObsOffice.js +0 -19
  103. package/dist/resources/ObservationCenter.js +0 -23
  104. package/ui/observation-center.html +0 -385
@@ -0,0 +1,317 @@
1
+ // ─── retrieveCandidates() — the pure retrieval core (flair bootstrap-scale-fix) ──
2
+ //
3
+ // Extracted from resources/SemanticSearch.ts's post() (Kern-approved
4
+ // refactor, flair#695). Before this module existed,
5
+ // SemanticSearch.post() was one function entangling auth resolution,
6
+ // rate-limiting, HNSW/BM25 retrieval, post-retrieval filtering, the
7
+ // cross-encoder reranker, AND retrievalCount/lastRetrieved hit-tracking side
8
+ // effects — so the ONLY way for MemoryBootstrap (resources/MemoryBootstrap.ts)
9
+ // to get bounded, HNSW-pushed-down candidates was to duplicate the retrieval
10
+ // logic or trip the side effects (an internal bootstrap call spuriously
11
+ // bumping `retrievalCount` would pollute a ranking signal every other agent's
12
+ // searches read).
13
+ //
14
+ // Boundary (Kern's review, folded into the implementation checklist): this
15
+ // function owns SemanticSearch's retrieval + post-retrieval filtering layers —
16
+ // the HNSW leg query construction (sort/select/conditions/limit), the BM25 +
17
+ // union-RRF hybrid fusion, the per-record temporal/expiry/supersede filters,
18
+ // and the scope.isAllowed() defense-in-depth re-check. It does NOT own: auth
19
+ // resolution, rate-limiting, the reranker, or the retrievalCount/lastRetrieved
20
+ // hit-tracking side effects — those stay in SemanticSearch.post()'s wrapper
21
+ // (resources/SemanticSearch.ts) so an internal caller (bootstrap) never trips
22
+ // them.
23
+ //
24
+ // PURE FUNCTION DISCIPLINE (Kern, non-negotiable): every param below is a
25
+ // primitive/plain-value/function — never `this` or a SemanticSearch instance.
26
+ // A core that took `this` would force mocking (or a later re-refactor) for
27
+ // any second caller; this one is callable standalone, no Resource/HTTP
28
+ // context required beyond the optional `ctx` param (only used for
29
+ // withDetachedTxn's transaction-chain workaround — both SemanticSearch and
30
+ // MemoryBootstrap are Harper Resources with their own `ctx`).
31
+ //
32
+ // Returns results AFTER all filters, sorted best-first by `_score` — bounded
33
+ // ONLY by the `limit` the caller chose to push down (the core never
34
+ // multiplies `limit` internally; any overfetch policy — SemanticSearch's
35
+ // CANDIDATE_MULTIPLIER, rerank-topN widening — is the CALLER's decision, made
36
+ // before calling in). Never exposes which internal leg (BM25+RRF hybrid vs.
37
+ // legacy HNSW-only vs. keyword-only fallback) produced a given result — the
38
+ // output shape is identical regardless of `hybrid`.
39
+ import { databases } from "@harperfast/harper";
40
+ import { withDetachedTxn } from "./table-helpers.js";
41
+ import { wrapUntrusted } from "./content-safety.js";
42
+ import { cosineSimilarity } from "./dedup.js";
43
+ import { compositeScore } from "./scoring.js";
44
+ import { buildBM25, fuseRrfNormalized, SEM_LIMIT } from "./bm25.js";
45
+ import { isAllowedBm25Candidate } from "./bm25-filter.js";
46
+ // Convert HNSW cosine distance (1 - similarity) to similarity score.
47
+ function distanceToSimilarity(distance) {
48
+ return 1 - distance;
49
+ }
50
+ // Default field selection for every retrieval leg — explicit (no raw
51
+ // `embedding`, so the large vector never enters a result payload or a
52
+ // bootstrap-sized candidate pool) and shared between the HNSW leg and the
53
+ // BM25 corpus fetch so a fused id always resolves to the same record shape
54
+ // regardless of which leg produced it. Includes `summary` (agent-set dense
55
+ // compression, resources/Memory.ts) even though SemanticSearch's own callers
56
+ // don't read it — MemoryBootstrap's collision-surfacing block
57
+ // (resources/collision-lib.ts's SemanticMatchInput) reads `m.summary ||
58
+ // m.content`, so dropping it here would silently regress bootstrap's
59
+ // "Others in the room" surface even though SemanticSearch never asserts on
60
+ // its absence.
61
+ const DEFAULT_SELECT = ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
62
+ "source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "usageCount", "lastRetrieved",
63
+ "promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
64
+ "parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject", "summary",
65
+ "validFrom", "validTo", "_safetyFlags"];
66
+ export async function retrieveCandidates(params) {
67
+ const { queryEmbedding: qEmb, q, conditions, limit, select = DEFAULT_SELECT, includeSuperseded = false, scoring = "raw", temporalBoost = 1.0, sinceDate = null, asOf, minScore = 0, agentId, isAllowed, hybrid, ctx, } = params;
68
+ const passesAllowed = (record) => !isAllowed || isAllowed(record);
69
+ const hnswSelect = [...select, "$distance"];
70
+ const results = [];
71
+ if (hybrid) {
72
+ // ─── BM25 + union-RRF hybrid path ────────────────────────────────────
73
+ // 1. Semantic candidates via HNSW (unchanged fetch). 2. BM25 lexical pass
74
+ // over the SCOPED corpus. 3. SECURITY: the BM25 candidate set is filtered
75
+ // by the SAME conditions[] + temporal filters BEFORE fusion (the corpus
76
+ // is fetched with those conditions, AND re-checked in-process as
77
+ // defense-in-depth) so no other agent's memory is ever scored or fused.
78
+ // 4. Candidate-union RRF → normalize → feed as rawScore to compositeScore.
79
+ // ── (a) Semantic candidate records (best-first) ──────────────────────
80
+ const semRecords = [];
81
+ const semIds = [];
82
+ if (qEmb) {
83
+ const semQuery = {
84
+ sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
85
+ select: hnswSelect,
86
+ limit,
87
+ };
88
+ if (conditions.length > 0)
89
+ semQuery.conditions = conditions;
90
+ const semResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(semQuery));
91
+ for await (const record of semResults) {
92
+ if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
93
+ continue;
94
+ if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
95
+ continue;
96
+ if (asOf && record.validFrom && record.validFrom > asOf)
97
+ continue;
98
+ if (asOf && record.validTo && record.validTo <= asOf)
99
+ continue;
100
+ // A past validTo ALWAYS means the record has been closed out
101
+ // (server supersede path — Memory.ts closeSupersededRecord — sets
102
+ // validTo without necessarily setting `archived`). Unconditional, not
103
+ // gated on `asOf`, so a server-superseded record can't resurface just
104
+ // because its successor isn't co-present in this result set (the
105
+ // supersededIds filter further down only catches co-presence). A
106
+ // record with no validTo, or a future validTo, is unaffected.
107
+ if (record.validTo && Date.parse(record.validTo) < Date.now())
108
+ continue;
109
+ if (!passesAllowed(record))
110
+ continue;
111
+ semRecords.push(record);
112
+ semIds.push(record.id);
113
+ }
114
+ }
115
+ // ── (b) BM25 candidate records over the SCOPED corpus ────────────────
116
+ const corpusQuery = conditions.length > 0
117
+ ? { conditions, select }
118
+ : { select };
119
+ const corpusResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(corpusQuery));
120
+ const allowedById = new Map();
121
+ const bm25Docs = [];
122
+ for await (const record of corpusResults) {
123
+ // Defense-in-depth: re-check the SAME conditions[] + temporal filters
124
+ // in-process. Even if a Harper query change ever let an out-of-scope
125
+ // record through, it is dropped here BEFORE it can be BM25-scored/fused.
126
+ if (!isAllowedBm25Candidate(record, conditions, { sinceDate, asOf }))
127
+ continue;
128
+ if (!passesAllowed(record))
129
+ continue;
130
+ allowedById.set(record.id, record);
131
+ bm25Docs.push({ id: record.id, content: record.content });
132
+ }
133
+ // Carry semantic candidates that survived their temporal gate into the
134
+ // allowed map too (so a fused id always resolves to a record). Semantic
135
+ // records were fetched with the SAME conditions[], so they're in-scope.
136
+ for (const r of semRecords) {
137
+ if (!allowedById.has(r.id)) {
138
+ const { $distance, ...rest } = r;
139
+ allowedById.set(r.id, rest);
140
+ }
141
+ }
142
+ // ── (c) BM25 lexical ranking → top SEM_LIMIT (only when q present) ────
143
+ let bm25Ids = [];
144
+ if (q) {
145
+ const bm25 = buildBM25(bm25Docs);
146
+ const ranked = bm25.rank(String(q));
147
+ bm25Ids = ranked.filter(r => r.score > 0).slice(0, SEM_LIMIT).map(r => r.id);
148
+ }
149
+ // ── (d) No retrieval signal at all → full scoped listing ────────────
150
+ if (!q && !qEmb) {
151
+ for (const record of allowedById.values()) {
152
+ const rawScore = 0;
153
+ let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
154
+ if (temporalBoost > 1.0)
155
+ finalScore *= temporalBoost;
156
+ const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
157
+ const source = record.agentId !== agentId ? record.agentId : undefined;
158
+ results.push({
159
+ ...record,
160
+ content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
161
+ _score: Math.round(finalScore * 1000) / 1000,
162
+ _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
163
+ _source: source,
164
+ });
165
+ }
166
+ }
167
+ else {
168
+ // ── Candidate-union RRF → normalized [0,1] rawScore ────────────────
169
+ const fused = fuseRrfNormalized(semIds, bm25Ids);
170
+ for (const [id, rrfRaw] of fused) {
171
+ const record = allowedById.get(id);
172
+ if (!record)
173
+ continue; // should not happen — union ⊆ allowed
174
+ const rawScore = rrfRaw; // already normalized to [0,1]
175
+ let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
176
+ if (temporalBoost > 1.0)
177
+ finalScore *= temporalBoost;
178
+ const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
179
+ const source = record.agentId !== agentId ? record.agentId : undefined;
180
+ results.push({
181
+ ...record,
182
+ content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
183
+ _score: Math.round(finalScore * 1000) / 1000,
184
+ _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
185
+ _source: source,
186
+ });
187
+ }
188
+ }
189
+ }
190
+ else if (qEmb) {
191
+ // ─── HNSW vector search path (legacy, hybrid flag OFF — or a caller
192
+ // like MemoryBootstrap forcing HNSW-leg-only regardless of the flag) ────
193
+ const query = {
194
+ sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
195
+ select: hnswSelect,
196
+ limit,
197
+ };
198
+ if (conditions.length > 0) {
199
+ query.conditions = conditions;
200
+ }
201
+ const memoryResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
202
+ for await (const record of memoryResults) {
203
+ if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
204
+ continue;
205
+ if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
206
+ continue;
207
+ if (asOf && record.validFrom && record.validFrom > asOf)
208
+ continue;
209
+ if (asOf && record.validTo && record.validTo <= asOf)
210
+ continue;
211
+ if (record.validTo && Date.parse(record.validTo) < Date.now())
212
+ continue;
213
+ if (!passesAllowed(record))
214
+ continue;
215
+ let semanticScore;
216
+ if (record.$distance !== undefined) {
217
+ semanticScore = distanceToSimilarity(record.$distance);
218
+ }
219
+ else {
220
+ // ─── Harper's cosine-sort query omits $distance for a SINGLETON
221
+ // post-filter result set (see resources/SemanticSearch.ts's original
222
+ // writeup of this quirk, and test/integration/
223
+ // semantic-search-singleton-score.test.ts for the real-Harper
224
+ // reproduction). Fix: point-lookup the record by id and compute
225
+ // cosine similarity ourselves from its real stored `embedding`
226
+ // vector. If the stored embedding is missing/empty, cosineSimilarity
227
+ // returns 0 — the same safe "no match" the old `?? 1` fallback
228
+ // produced, never a false-high score.
229
+ const full = await withDetachedTxn(ctx, () => databases.flair.Memory.get(record.id));
230
+ const storedEmbedding = Array.isArray(full?.embedding) ? full.embedding : [];
231
+ semanticScore = cosineSimilarity(qEmb, storedEmbedding);
232
+ }
233
+ let keywordHit = false;
234
+ if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
235
+ keywordHit = true;
236
+ }
237
+ const rawScore = semanticScore + (keywordHit ? 0.05 : 0);
238
+ let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
239
+ if (temporalBoost > 1.0)
240
+ finalScore *= temporalBoost;
241
+ const { $distance, ...rest } = record;
242
+ const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
243
+ const source = record.agentId !== agentId ? record.agentId : undefined;
244
+ results.push({
245
+ ...rest,
246
+ content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
247
+ _score: Math.round(finalScore * 1000) / 1000,
248
+ _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
249
+ _source: source,
250
+ });
251
+ }
252
+ }
253
+ else {
254
+ // ─── No embedding available — keyword-only fallback ──────────────────
255
+ // Full scan is only used when there's no query embedding (e.g. tag-only
256
+ // or subject-only searches, or when the embedding engine is unavailable).
257
+ // Pre-existing, out-of-scope-for-this-PR behavior — MemoryBootstrap never
258
+ // reaches this branch (it only calls in when it already has a
259
+ // queryEmbedding).
260
+ const query = conditions.length > 0 ? { conditions } : {};
261
+ const memoryResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
262
+ for await (const record of memoryResults) {
263
+ if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
264
+ continue;
265
+ if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
266
+ continue;
267
+ if (asOf && record.validFrom && record.validFrom > asOf)
268
+ continue;
269
+ if (asOf && record.validTo && record.validTo <= asOf)
270
+ continue;
271
+ if (record.validTo && Date.parse(record.validTo) < Date.now())
272
+ continue;
273
+ if (!passesAllowed(record))
274
+ continue;
275
+ let keywordHit = false;
276
+ if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
277
+ keywordHit = true;
278
+ }
279
+ const rawScore = keywordHit ? 0.05 : 0;
280
+ if (q && rawScore === 0)
281
+ continue;
282
+ const { embedding, ...rest } = record;
283
+ let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, rest);
284
+ if (temporalBoost > 1.0)
285
+ finalScore *= temporalBoost;
286
+ const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
287
+ const source = record.agentId !== agentId ? record.agentId : undefined;
288
+ results.push({
289
+ ...rest,
290
+ content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
291
+ _score: Math.round(finalScore * 1000) / 1000,
292
+ _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
293
+ _source: source,
294
+ });
295
+ }
296
+ }
297
+ // Build superseded set and filter (unless caller opts in to see full
298
+ // history) — computed from THIS bounded result set alone (per-set, never
299
+ // cross-applied — see this PR's supersededIds doc in MemoryBootstrap.ts for
300
+ // the full caveat: the unconditional past-validTo exclusion above is the
301
+ // primary supersede guard; this co-presence check is a secondary belt).
302
+ let filteredResults = results;
303
+ if (!includeSuperseded) {
304
+ const supersededIds = new Set();
305
+ for (const r of results) {
306
+ if (r.supersedes)
307
+ supersededIds.add(r.supersedes);
308
+ }
309
+ filteredResults = results.filter((r) => !supersededIds.has(r.id));
310
+ }
311
+ // Apply minimum score filter
312
+ if (minScore > 0) {
313
+ filteredResults = filteredResults.filter((r) => r._score >= minScore);
314
+ }
315
+ filteredResults.sort((a, b) => b._score - a._score);
316
+ return filteredResults;
317
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * version-check.ts — offline-tolerant, cached check of whether the installed
3
+ * @tpsdev-ai/flair is behind the latest published npm release.
4
+ *
5
+ * Motivation (flair#587): a laptop install sat on 0.16.1 through v0.17.0 and
6
+ * v0.19.0 (P0 security fixes) and v0.18.0 (memory-integrity fix) — `flair
7
+ * status` reported "✓ all checks passing" the whole time. Nothing anywhere
8
+ * in the CLI told the operator they were behind. `flair status` and `flair
9
+ * doctor` both wire this in.
10
+ *
11
+ * Non-negotiable design constraints — this must never make status/doctor
12
+ * WORSE:
13
+ * - Offline-tolerant: a failed/timed-out registry fetch falls back to a
14
+ * stale cache, or is skipped entirely. NEVER throws, NEVER hangs (short
15
+ * fetch timeout).
16
+ * - Cached with a TTL so a healthy network doesn't cost a registry round
17
+ * trip on every single `flair status`/`flair doctor` invocation.
18
+ * - No advisory data — we don't know which release fixed which CVE, so the
19
+ * severity heuristic is purely the version GAP (major/minor count), not
20
+ * "did this release carry a security fix". See classifyGap().
21
+ */
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { dirname, join } from "node:path";
25
+ import { parseSemverCore } from "./fabric-upgrade.js";
26
+ export const FLAIR_PKG_NAME = "@tpsdev-ai/flair";
27
+ export const DEFAULT_CACHE_PATH = join(homedir(), ".flair", ".version-check-cache.json");
28
+ /** How long a cached "latest" answer is trusted before we re-hit the registry. */
29
+ export const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000; // 12h
30
+ /** Registry fetch timeout — this runs on every status/doctor call, so it must stay short. */
31
+ export const DEFAULT_TIMEOUT_MS = 3000;
32
+ function readCacheFile(path) {
33
+ try {
34
+ if (!existsSync(path))
35
+ return null;
36
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
37
+ if (typeof raw?.latest === "string" && typeof raw?.checkedAt === "number") {
38
+ return { latest: raw.latest, checkedAt: raw.checkedAt };
39
+ }
40
+ return null;
41
+ }
42
+ catch {
43
+ // Corrupt/unreadable cache — treat as absent, never throw.
44
+ return null;
45
+ }
46
+ }
47
+ function writeCacheFile(path, entry) {
48
+ try {
49
+ mkdirSync(dirname(path), { recursive: true });
50
+ writeFileSync(path, JSON.stringify(entry), "utf-8");
51
+ }
52
+ catch {
53
+ // Best-effort — a cache-write failure must never surface as a
54
+ // status/doctor error (e.g. read-only $HOME).
55
+ }
56
+ }
57
+ async function defaultFetchLatest(timeoutMs) {
58
+ try {
59
+ const res = await fetch(`https://registry.npmjs.org/${FLAIR_PKG_NAME}/latest`, {
60
+ signal: AbortSignal.timeout(timeoutMs),
61
+ });
62
+ if (!res.ok)
63
+ return null;
64
+ const data = (await res.json());
65
+ return typeof data?.version === "string" ? data.version : null;
66
+ }
67
+ catch {
68
+ // Offline, DNS failure, timeout, registry 5xx, bad JSON — all the same:
69
+ // we couldn't determine "latest" over the network this time.
70
+ return null;
71
+ }
72
+ }
73
+ export function defaultVersionCheckDeps() {
74
+ return {
75
+ fetchLatest: defaultFetchLatest,
76
+ cachePath: DEFAULT_CACHE_PATH,
77
+ ttlMs: DEFAULT_TTL_MS,
78
+ timeoutMs: DEFAULT_TIMEOUT_MS,
79
+ now: () => Date.now(),
80
+ readCache: readCacheFile,
81
+ writeCache: writeCacheFile,
82
+ };
83
+ }
84
+ /**
85
+ * Resolve the latest published @tpsdev-ai/flair version, preferring a fresh
86
+ * cache hit over a network round trip, and falling back to a stale cache (or
87
+ * giving up quietly) when the registry is unreachable. NEVER throws.
88
+ */
89
+ export async function checkVersion(installed, injected = {}) {
90
+ const deps = { ...defaultVersionCheckDeps(), ...injected };
91
+ const nowMs = deps.now();
92
+ const cached = deps.readCache(deps.cachePath);
93
+ if (cached && nowMs - cached.checkedAt < deps.ttlMs) {
94
+ return { installed, latest: cached.latest, source: "cache" };
95
+ }
96
+ // Defense-in-depth: the default fetchLatest already catches everything
97
+ // internally (network error, timeout, non-2xx, bad JSON) and resolves
98
+ // null rather than rejecting. This try/catch guards the contract even if
99
+ // a caller-injected fetchLatest misbehaves and throws/rejects instead —
100
+ // status/doctor must never crash or hang on a version check either way.
101
+ let fetched = null;
102
+ try {
103
+ fetched = await deps.fetchLatest(deps.timeoutMs);
104
+ }
105
+ catch {
106
+ fetched = null;
107
+ }
108
+ if (fetched) {
109
+ deps.writeCache(deps.cachePath, { latest: fetched, checkedAt: nowMs });
110
+ return { installed, latest: fetched, source: "network" };
111
+ }
112
+ // Registry unreachable/timed out — fall back to a stale cache rather than
113
+ // reporting nothing, but never block or throw trying to get a fresh one.
114
+ if (cached) {
115
+ return { installed, latest: cached.latest, source: "cache" };
116
+ }
117
+ return { installed, latest: null, source: "unavailable" };
118
+ }
119
+ const NO_GAP = { severity: "none", majorBehind: false, releasesBehind: 0 };
120
+ /**
121
+ * Classify how far `installed` is behind `latest` using major.minor.patch
122
+ * math only — we don't have advisory data, so:
123
+ * - any major version behind, or ≥2 minor versions behind → "red" (loud;
124
+ * heuristic for "you've likely missed a security fix")
125
+ * - a single minor version behind, or a patch-only gap → "yellow"
126
+ * - equal, ahead, or unparseable → "none"
127
+ */
128
+ export function classifyGap(installed, latest) {
129
+ const a = parseSemverCore(installed);
130
+ const b = parseSemverCore(latest);
131
+ if (!a || !b)
132
+ return NO_GAP;
133
+ const [aMaj, aMin, aPatch] = a;
134
+ const [bMaj, bMin, bPatch] = b;
135
+ if (bMaj > aMaj)
136
+ return { severity: "red", majorBehind: true, releasesBehind: 0 };
137
+ if (bMaj < aMaj)
138
+ return NO_GAP; // installed is ahead (e.g. local/pre-release build)
139
+ if (bMin > aMin) {
140
+ const releasesBehind = bMin - aMin;
141
+ return { severity: releasesBehind >= 2 ? "red" : "yellow", majorBehind: false, releasesBehind };
142
+ }
143
+ if (bMin < aMin)
144
+ return NO_GAP; // ahead on minor
145
+ if (bPatch > aPatch)
146
+ return { severity: "yellow", majorBehind: false, releasesBehind: bPatch - aPatch };
147
+ return NO_GAP; // equal, or ahead on patch
148
+ }
149
+ /**
150
+ * Build the human-readable nudge line for `flair status`/`flair doctor`, or
151
+ * null when there's nothing worth printing — current, ahead (local/dev
152
+ * build), or we couldn't determine latest at all (offline with no cache).
153
+ * Callers own icon/color; this returns plain text plus a severity to color by.
154
+ */
155
+ export function formatVersionNudge(result) {
156
+ if (!result.latest)
157
+ return null;
158
+ const gap = classifyGap(result.installed, result.latest);
159
+ if (gap.severity === "none")
160
+ return null;
161
+ const countHint = gap.majorBehind
162
+ ? "major version"
163
+ : `${gap.releasesBehind} release${gap.releasesBehind === 1 ? "" : "s"}`;
164
+ const message = `flair ${result.installed} is behind — latest is ${result.latest} (${countHint} behind). ` +
165
+ `Upgrade: npm i -g ${FLAIR_PKG_NAME}@latest`;
166
+ return { severity: gap.severity, message };
167
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * version-handshake.ts — CLI↔server version handshake (flair#695 §B, the
3
+ * bare-npm rescue): "Every CLI invocation (cheap, cached ~60s) compares:
4
+ * CLI version, installed-package version, running-server version. Mismatch
5
+ * → one-line nudge on stderr: `flair 0.23.0 installed but server is running
6
+ * 0.22.1 — run: flair restart`. Never blocks the command."
7
+ *
8
+ * Mirrors src/version-check.ts's discipline exactly (that module compares
9
+ * installed-vs-latest-PUBLISHED; this one compares installed-vs-RUNNING):
10
+ * offline-tolerant, short fetch timeout, cached with a TTL, NEVER throws.
11
+ * The one behavioral difference from version-check.ts: the cache is keyed
12
+ * per (rootPath, serverUrl) rather than one global file — a single CLI can
13
+ * legitimately point at more than one local Flair instance (different
14
+ * ROOTPATH, or a --target-switched remote), and a mismatch cached for one
15
+ * must never bleed into a nudge about a different one.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { createHash } from "node:crypto";
19
+ import { dirname, join } from "node:path";
20
+ import { homedir } from "node:os";
21
+ export const DEFAULT_HANDSHAKE_TTL_MS = 60_000;
22
+ export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 1500; // within the spec's 1-2s budget
23
+ export const DEFAULT_HANDSHAKE_CACHE_DIR = join(homedir(), ".flair", ".version-handshake-cache");
24
+ export function defaultHandshakeDeps() {
25
+ return {
26
+ fetchImpl: fetch,
27
+ cacheDir: DEFAULT_HANDSHAKE_CACHE_DIR,
28
+ ttlMs: DEFAULT_HANDSHAKE_TTL_MS,
29
+ timeoutMs: DEFAULT_HANDSHAKE_TIMEOUT_MS,
30
+ now: () => Date.now(),
31
+ };
32
+ }
33
+ function cacheFileName(rootPath, serverUrl) {
34
+ return createHash("sha256").update(`${rootPath}|${serverUrl}`).digest("hex").slice(0, 32) + ".json";
35
+ }
36
+ function cacheFilePath(cacheDir, rootPath, serverUrl) {
37
+ return join(cacheDir, cacheFileName(rootPath, serverUrl));
38
+ }
39
+ function readCache(path) {
40
+ try {
41
+ if (!existsSync(path))
42
+ return null;
43
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
44
+ if (typeof raw?.checkedAt === "number" && (typeof raw?.runningVersion === "string" || raw?.runningVersion === null)) {
45
+ return { runningVersion: raw.runningVersion, checkedAt: raw.checkedAt };
46
+ }
47
+ return null;
48
+ }
49
+ catch {
50
+ return null; // corrupt/unreadable cache — treat as absent, never throw
51
+ }
52
+ }
53
+ function writeCache(path, entry) {
54
+ try {
55
+ mkdirSync(dirname(path), { recursive: true });
56
+ writeFileSync(path, JSON.stringify(entry));
57
+ }
58
+ catch {
59
+ // Best-effort — a cache-write failure (e.g. read-only $HOME) must never
60
+ // surface as a command error.
61
+ }
62
+ }
63
+ /**
64
+ * Resolves the running server's version (via the public, unauthenticated
65
+ * `GET /Health` — reachable even before an agent key exists) and compares
66
+ * it to `cliVersion`. NEVER throws — every failure mode (offline, timeout,
67
+ * non-2xx, malformed JSON, cache I/O error) resolves to `source:
68
+ * "unavailable"` with `mismatch: false`, never an exception or a false
69
+ * nudge.
70
+ */
71
+ export async function checkServerHandshake(cliVersion, rootPath, serverUrl, injected = {}) {
72
+ const deps = { ...defaultHandshakeDeps(), ...injected };
73
+ const path = cacheFilePath(deps.cacheDir, rootPath, serverUrl);
74
+ const nowMs = deps.now();
75
+ const cached = readCache(path);
76
+ if (cached && nowMs - cached.checkedAt < deps.ttlMs) {
77
+ return {
78
+ cliVersion,
79
+ runningVersion: cached.runningVersion,
80
+ mismatch: !!cached.runningVersion && cached.runningVersion !== cliVersion,
81
+ source: "cache",
82
+ };
83
+ }
84
+ let fetched = null;
85
+ try {
86
+ const res = await deps.fetchImpl(`${serverUrl.replace(/\/+$/, "")}/Health`, {
87
+ signal: AbortSignal.timeout(deps.timeoutMs),
88
+ });
89
+ if (res.ok) {
90
+ const body = (await res.json());
91
+ fetched = typeof body?.version === "string" ? body.version : null;
92
+ }
93
+ }
94
+ catch {
95
+ fetched = null; // offline, DNS failure, timeout, non-JSON body — all the same: couldn't determine it this time
96
+ }
97
+ if (fetched !== null) {
98
+ writeCache(path, { runningVersion: fetched, checkedAt: nowMs });
99
+ return { cliVersion, runningVersion: fetched, mismatch: fetched !== cliVersion, source: "network" };
100
+ }
101
+ if (cached) {
102
+ // Server unreachable this time — fall back to a stale cache rather than
103
+ // reporting nothing.
104
+ return {
105
+ cliVersion,
106
+ runningVersion: cached.runningVersion,
107
+ mismatch: !!cached.runningVersion && cached.runningVersion !== cliVersion,
108
+ source: "cache",
109
+ };
110
+ }
111
+ return { cliVersion, runningVersion: null, mismatch: false, source: "unavailable" };
112
+ }
113
+ /**
114
+ * The one-line stderr nudge, or null when there's nothing worth printing
115
+ * (no mismatch, or nothing to compare against). Exact wording per the spec:
116
+ * "flair 0.23.0 installed but server is running 0.22.1 — run: flair restart".
117
+ */
118
+ export function formatHandshakeNudge(result) {
119
+ if (!result.mismatch || !result.runningVersion)
120
+ return null;
121
+ return `flair ${result.cliVersion} installed but server is running ${result.runningVersion} — run: flair restart`;
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.20.1",
3
+ "version": "0.22.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",
@@ -31,7 +31,6 @@
31
31
  "dist/",
32
32
  "schemas/",
33
33
  "templates/",
34
- "ui/",
35
34
  "config.yaml",
36
35
  "LICENSE",
37
36
  "README.md",
@@ -55,11 +54,11 @@
55
54
  "node": ">=22"
56
55
  },
57
56
  "dependencies": {
58
- "@harperfast/harper": "5.1.14",
59
- "@harperfast/oauth": "2.1.0",
57
+ "@harperfast/harper": "5.1.17",
58
+ "@harperfast/oauth": "2.2.0",
60
59
  "@types/js-yaml": "4.0.9",
61
60
  "commander": "14.0.3",
62
- "harper-fabric-embeddings": "0.2.3",
61
+ "harper-fabric-embeddings": "^0.3.0",
63
62
  "jose": "6.2.2",
64
63
  "js-yaml": "4.1.1",
65
64
  "node-llama-cpp": "3.18.1",
@@ -22,6 +22,13 @@ type Agent @table(database: "flair") @export {
22
22
 
23
23
  createdAt: String!
24
24
  updatedAt: String
25
+ originatorInstanceId: String @indexed # federation-edge-hardening slice 1 — write-time instance identity,
26
+ # stamped server-side in resources/Agent.ts's post()/put() from
27
+ # resources/instance-identity.ts's localInstanceId(). Nullable (existing rows
28
+ # read null = clean upgrade); preserved (never re-stamped) as the record flows
29
+ # through federation sync merges. See schemas/memory.graphql's Memory.
30
+ # originatorInstanceId for the full contract — same field, same idiom, this is
31
+ # one of the 4 synced tables (Memory/Soul/Agent/Relationship).
25
32
  }
26
33
 
27
34
  # Credential table — auth surfaces for Principals.
@@ -9,4 +9,9 @@ type OrgEvent @table(database: "flair") @export {
9
9
  refId: String @indexed
10
10
  createdAt: String! @indexed
11
11
  expiresAt: String @indexed
12
+ entities: [String] @indexed # attention-plane vocabulary strings (flair#675); formalizes a typed,
13
+ # vocabulary-consistent entity ref (targetIds stays for actor/target
14
+ # ids). Additive/nullable — old rows carry no entities, readers
15
+ # tolerate absent (same pattern as Presence.activityUpdatedAt).
16
+ # See docs/entity-vocabulary.md.
12
17
  }