@tpsdev-ai/flair 0.21.0 → 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 (74) hide show
  1. package/README.md +10 -7
  2. package/SECURITY.md +24 -2
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +1755 -213
  5. package/dist/deploy.js +3 -4
  6. package/dist/fleet-presence.js +98 -0
  7. package/dist/fleet-verify.js +291 -0
  8. package/dist/mcp-client-assertion.js +364 -0
  9. package/dist/probe.js +97 -0
  10. package/dist/rem/runner.js +82 -12
  11. package/dist/resources/AttentionQuery.js +356 -0
  12. package/dist/resources/Credential.js +11 -1
  13. package/dist/resources/Federation.js +23 -0
  14. package/dist/resources/MCPClientMetadata.js +88 -0
  15. package/dist/resources/Memory.js +52 -53
  16. package/dist/resources/MemoryBootstrap.js +422 -76
  17. package/dist/resources/MemoryReflect.js +105 -49
  18. package/dist/resources/MemoryUsage.js +104 -0
  19. package/dist/resources/OAuth.js +8 -1
  20. package/dist/resources/OrgEvent.js +11 -0
  21. package/dist/resources/Presence.js +218 -19
  22. package/dist/resources/RecordUsage.js +230 -0
  23. package/dist/resources/Relationship.js +98 -25
  24. package/dist/resources/SemanticSearch.js +85 -317
  25. package/dist/resources/SkillScan.js +15 -0
  26. package/dist/resources/WorkspaceState.js +11 -0
  27. package/dist/resources/agent-auth.js +60 -2
  28. package/dist/resources/auth-middleware.js +18 -9
  29. package/dist/resources/bm25.js +12 -6
  30. package/dist/resources/collision-lib.js +157 -0
  31. package/dist/resources/embeddings-boot.js +149 -0
  32. package/dist/resources/embeddings-provider.js +364 -106
  33. package/dist/resources/entity-vocab.js +139 -0
  34. package/dist/resources/health.js +97 -6
  35. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  36. package/dist/resources/mcp-handler.js +1 -1
  37. package/dist/resources/mcp-tools.js +88 -10
  38. package/dist/resources/memory-reflect-lib.js +289 -0
  39. package/dist/resources/migration-boot.js +143 -0
  40. package/dist/resources/migrations/dir-safety.js +71 -0
  41. package/dist/resources/migrations/embedding-stamp.js +178 -0
  42. package/dist/resources/migrations/envelope.js +43 -0
  43. package/dist/resources/migrations/export.js +38 -0
  44. package/dist/resources/migrations/ledger.js +55 -0
  45. package/dist/resources/migrations/lock.js +154 -0
  46. package/dist/resources/migrations/progress.js +31 -0
  47. package/dist/resources/migrations/registry.js +36 -0
  48. package/dist/resources/migrations/risk-policy.js +23 -0
  49. package/dist/resources/migrations/runner.js +456 -0
  50. package/dist/resources/migrations/snapshot.js +127 -0
  51. package/dist/resources/migrations/source-fields.js +93 -0
  52. package/dist/resources/migrations/space.js +73 -0
  53. package/dist/resources/migrations/state.js +64 -0
  54. package/dist/resources/migrations/status.js +39 -0
  55. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  56. package/dist/resources/migrations/types.js +9 -0
  57. package/dist/resources/presence-internal.js +29 -0
  58. package/dist/resources/provenance.js +50 -0
  59. package/dist/resources/rate-limiter.js +8 -1
  60. package/dist/resources/scoring.js +124 -5
  61. package/dist/resources/semantic-retrieval-core.js +317 -0
  62. package/dist/version-handshake.js +122 -0
  63. package/package.json +4 -5
  64. package/schemas/event.graphql +5 -0
  65. package/schemas/memory.graphql +66 -0
  66. package/schemas/schema.graphql +5 -43
  67. package/schemas/workspace.graphql +3 -0
  68. package/dist/resources/IngestEvents.js +0 -162
  69. package/dist/resources/IssueTokens.js +0 -19
  70. package/dist/resources/ObsAgentSnapshot.js +0 -13
  71. package/dist/resources/ObsEventFeed.js +0 -13
  72. package/dist/resources/ObsOffice.js +0 -19
  73. package/dist/resources/ObservationCenter.js +0 -23
  74. 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,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.21.0",
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.15",
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",
@@ -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
  }
@@ -5,6 +5,16 @@ type Memory @table(database: "flair") {
5
5
  contentHash: String @indexed
6
6
  visibility: String
7
7
  embedding: [Float] @indexed(type: "HNSW")
8
+ embeddingModel: String @indexed # ops-1l18: previously written (resources/Memory.ts stamps every
9
+ # write) but never DECLARED in this schema, so it was storable but not
10
+ # queryable — Harper's Table.search({conditions}) rejects a condition on
11
+ # an undeclared attribute ("X is not a defined attribute"), which the
12
+ # zero-touch migration runner's bounded detect()/countPending()/run()
13
+ # queries (resources/migrations/embedding-stamp.ts: `embeddingModel
14
+ # not_equal <current>`) need in order to stay O(1)-bounded rather than a
15
+ # full-corpus scan on every boot. Purely additive — existing rows already
16
+ # carry this value on disk; declaring it queryable changes nothing about
17
+ # what's written or how embeddings/prefixes behave (#689's gate untouched).
8
18
  tags: [String] @indexed
9
19
  durability: String @indexed
10
20
  source: String
@@ -13,6 +23,18 @@ type Memory @table(database: "flair") {
13
23
  expiresAt: String @indexed
14
24
  retrievalCount: Int
15
25
  lastRetrieved: String
26
+ usageCount: Int # flair#683: verified-USE signal, distinct from retrievalCount (a
27
+ # search HIT). Additive/nullable — absent reads as 0, existing rows
28
+ # unaffected. NEVER auto-incremented on search/retrieval; the ONLY
29
+ # writer is the dedicated usage-feedback endpoint (resources/
30
+ # MemoryUsage.ts), which does a targeted patchRecord increment on
31
+ # this field alone (never through Memory.put() — see that
32
+ # resource's module doc for why: usage-feedback writes to ANOTHER
33
+ # agent's memory, and Memory's ownership check would 403 every
34
+ # legit call). Drives usageBoost in resources/scoring.ts, which
35
+ # replaces retrievalBoost in compositeScore (retrievalCount was the
36
+ # contaminated "retrieval counted as usage" signal root-caused in
37
+ # flair#623).
16
38
  promotionStatus: String # null | "pending" | "approved" | "rejected"
17
39
  promotedAt: String
18
40
  promotedBy: String # authenticated agentId who approved
@@ -41,11 +63,26 @@ type Memory @table(database: "flair") {
41
63
  # stamped by the ORIGINATING instance itself and preserved (never re-stamped) as the record flows through
42
64
  # sync merges. @indexed for the later sync push-query filter (per-record signature verification and the
43
65
  # classifier org-gate are separate, later slices — not built here).
66
+ entities: [String] @indexed # attention-plane vocabulary strings (flair#675). Added in v1 (not v2) per K&S
67
+ # verdict on FLAIR-ATTENTION-PLANE.md — gives the future attention query
68
+ # uniform index pushdown across Memory/WorkspaceState/OrgEvent instead of a
69
+ # partial one. Optional/derivable-on-write (no automatic tagging built here —
70
+ # that producer is a follow-up); additive/nullable, existing rows carry no
71
+ # entities, readers tolerate absent (same pattern as Presence.activityUpdatedAt).
72
+ # See docs/entity-vocabulary.md.
44
73
  }
45
74
 
46
75
  # Explicit entity-to-entity relationships with temporal validity.
47
76
  # Enables queries like "who was team lead in Q1?" or "what changed about X on date Y?"
48
77
  # Inspired by knowledge graph triples with time-bounded validity windows.
78
+ #
79
+ # attention-plane vocabulary note (flair#675): Relationship gets NO `entities`
80
+ # field. `subject`/`object` already hold free-form entity-reference strings
81
+ # and are already @indexed — they're the vocabulary carrier for this table.
82
+ # (Today they're lowercased on write in resources/Relationship.ts's put() but
83
+ # not yet validated against resources/entity-vocab.ts — wiring that
84
+ # validation, so `subject`/`object` are enforced attention-plane vocabulary
85
+ # strings, is a follow-up, not this foundation slice.)
49
86
  type Relationship @table(database: "flair") {
50
87
  id: ID @primaryKey
51
88
  agentId: String! @indexed # who created this relationship
@@ -61,6 +98,13 @@ type Relationship @table(database: "flair") {
61
98
  originatorInstanceId: String @indexed # federation-edge-hardening slice 1 — see Memory.originatorInstanceId's doc above
62
99
  # for the full contract (write-time stamp, nullable, preserved across sync merges).
63
100
  # Stamped server-side in resources/Relationship.ts's put().
101
+ provenance: String # JSON blob (relationship-write-path, folded K&S refinement): SAME shape as
102
+ # Memory.provenance above — { v, verified: { agentId, timestamp }, claimed?: { model } }
103
+ # — via the shared resources/provenance.ts buildProvenance(), never a
104
+ # Relationship-specific format. Nullable by design — pre-existing rows read back
105
+ # provenance = null, unchanged behavior (migration-equivalence gate, same
106
+ # discipline as Memory.usageCount/flair#684). Stamped server-side in
107
+ # resources/Relationship.ts's put(); never client-writable.
64
108
  }
65
109
 
66
110
  type Soul @table(database: "flair") {
@@ -87,6 +131,28 @@ type MemoryGrant @table(database: "flair") @export {
87
131
  createdAt: String
88
132
  }
89
133
 
134
+ # MemoryUsage — the dedup ledger for the usage-feedback signal (flair#683).
135
+ # One row per (agentId, memoryId) contribution: `id` is the deterministic
136
+ # composite key `${agentId}:${memoryId}`, so "has this agent already
137
+ # contributed to this memory's usageCount" is a single point-lookup, not a
138
+ # query. This is what makes the anti-gaming dedup rule ("each (agent, memory)
139
+ # pair contributes at most 1 to usageCount" — Sherlock) enforceable: the
140
+ # dedicated endpoint (resources/MemoryUsage.ts) only bumps Memory.usageCount
141
+ # when a NEW row is created here; a repeat call from the same agent for the
142
+ # same memory is a no-op against a row that already exists.
143
+ #
144
+ # `attribution` is OPAQUE — never parsed, never fed to an LLM, never rendered
145
+ # — a free-text "what used this" hint the caller supplies, sanitized
146
+ # (control-character-stripped, length-capped) and stored as-is for
147
+ # analytics/audit only. Never trust it as anything more than a label.
148
+ type MemoryUsage @table(database: "flair") {
149
+ id: ID @primaryKey # `${agentId}:${memoryId}` — the dedup key itself
150
+ agentId: String! @indexed # the contributing (verified) agent — never client-forgeable
151
+ memoryId: String! @indexed # the memory this contribution counted toward
152
+ attribution: String # opaque/sanitized free-text hint (optional)
153
+ createdAt: String!
154
+ }
155
+
90
156
  # MemoryCandidate — staged distillations from the FLAIR-NIGHTLY-REM cycle.
91
157
  # Per specs/FLAIR-NIGHTLY-REM.md § 7. Slice 1 of ops-2qq adds the schema +
92
158
  # `flair rem candidates` listing command. Future slices wire the nightly
@@ -3,48 +3,10 @@
3
3
 
4
4
  type Presence @table(database: "flair") @export {
5
5
  agentId: ID @primaryKey
6
- lastHeartbeatAt: BigInt # unix ms timestamp
6
+ lastHeartbeatAt: BigInt # unix ms timestamp — pure liveness beacon; every heartbeat refreshes it
7
7
  currentTask: String # short human string, e.g. "Reviewing flair#467"
8
- activity: String @indexed # "coding" | "reviewing" | "planning" | "idle"
9
- }
10
-
11
- # ─── Observatory tables ───────────────────────────────────────────────────────
12
-
13
- type ObsOffice @table(database: "flair") @export {
14
- id: ID @primaryKey # e.g. "office1"
15
- name: String!
16
- publicKey: String! # Ed25519 hex public key for auth verification
17
- status: String @indexed # "online" | "offline" | "degraded"
18
- lastSeen: String
19
- agentCount: Int
20
- createdAt: String!
21
- updatedAt: String
22
- }
23
-
24
- type ObsAgentSnapshot @table(database: "flair") @export {
25
- id: ID @primaryKey # "{officeId}:{agentId}"
26
- officeId: String! @indexed
27
- agentId: String! @indexed
28
- name: String
29
- role: String
30
- type: String # "agent" | "human"
31
- model: String
32
- status: String @indexed # "active" | "idle" | "offline"
33
- currentTask: String
34
- lastActivity: String
35
- lastHeartbeat: String
36
- updatedAt: String!
37
- }
38
-
39
- type ObsEventFeed @table(database: "flair") @export {
40
- id: ID @primaryKey # "{officeId}:{eventId}"
41
- officeId: String! @indexed
42
- kind: String! @indexed
43
- authorId: String! @indexed
44
- summary: String!
45
- refId: String @indexed
46
- scope: String @indexed
47
- createdAt: String! @indexed
48
- receivedAt: String!
49
- expiresAt: String @indexed
8
+ activity: String @indexed # "coding" | "reviewing" | "planning" | "debugging" | "idle"
9
+ activityUpdatedAt: BigInt # unix ms — when activity/currentTask were last ASSERTED (natural-presence); nullable/additive, absent on records written before this feature (readers fall back to lastHeartbeatAt)
10
+ flairVersion: String # @tpsdev-ai/flair version serving this heartbeat (flair#639); absent on pre-#639 records
11
+ harperVersion: String # @harperfast/harper version serving this heartbeat (flair#639); absent on pre-#639 records
50
12
  }
@@ -11,4 +11,7 @@ type WorkspaceState @table(database: "flair") @export {
11
11
  summary: String
12
12
  filesChanged: [String]
13
13
  createdAt: String!
14
+ entities: [String] @indexed # attention-plane vocabulary strings (flair#675); additive/nullable —
15
+ # existing rows carry no entities, readers tolerate absent (same
16
+ # pattern as Presence.activityUpdatedAt). See docs/entity-vocabulary.md.
14
17
  }