@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
@@ -1,4 +1,5 @@
1
1
  import { Resource } from "@harperfast/harper";
2
+ import { allowVerified } from "./agent-auth.js";
2
3
  import { scanSkillContent } from "./scan/skill-scanner.js";
3
4
  /**
4
5
  * POST /SkillScan/
@@ -30,6 +31,20 @@ import { scanSkillContent } from "./scan/skill-scanner.js";
30
31
  * weakening detection on actual shell content.
31
32
  */
32
33
  export class SkillScan extends Resource {
34
+ /**
35
+ * allowCreate()=allowVerified (authorizeLocal-escalation-class follow-up to
36
+ * #601/#604/#609/#612 — flair#614's backstop found this resource had NO
37
+ * allow* at all). The docstring above already says "Auth: any authenticated
38
+ * agent" — this was never actually enforced; Harper's own default
39
+ * (`user?.role.permission.super_user`, satisfiable only by a genuine admin
40
+ * OR authorizeLocal's forged loopback super_user) silently stood in
41
+ * instead. allowVerified matches the documented intent: any verified agent
42
+ * (not admin-only — this is a stateless text scanner, no agent/memory data
43
+ * touched), anonymous denied.
44
+ */
45
+ async allowCreate() {
46
+ return allowVerified(this.getContext?.());
47
+ }
33
48
  async post(data, _context) {
34
49
  const { content } = data || {};
35
50
  if (!content || typeof content !== "string") {
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { databases } from "@harperfast/harper";
11
11
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
12
+ import { invalidEntitiesResponse } from "./entity-vocab.js";
12
13
  const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
13
14
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
14
15
  const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
@@ -114,6 +115,12 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
114
115
  }
115
116
  content.createdAt = new Date().toISOString();
116
117
  content.timestamp ||= content.createdAt;
118
+ // attention-plane vocabulary gate (flair#675): `entities`, if present,
119
+ // must be well-formed vocabulary strings — see resources/entity-vocab.ts.
120
+ // Field is additive/optional; absent entities is not an error.
121
+ const entitiesError = invalidEntitiesResponse(content.entities);
122
+ if (entitiesError)
123
+ return entitiesError;
117
124
  return super.post(content);
118
125
  }
119
126
  async put(content) {
@@ -123,6 +130,10 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
123
130
  if (auth.kind === "agent" && !auth.isAdmin && content.agentId !== auth.agentId) {
124
131
  return FORBIDDEN("forbidden: cannot write workspace state for another agent");
125
132
  }
133
+ // attention-plane vocabulary gate (flair#675) — same as post() above.
134
+ const entitiesError = invalidEntitiesResponse(content.entities);
135
+ if (entitiesError)
136
+ return entitiesError;
126
137
  return super.put(content);
127
138
  }
128
139
  async delete(id) {
@@ -132,6 +132,52 @@ export async function verifyAgentRequest(request) {
132
132
  * — and if a request object IS present but yields no agent → anonymous
133
133
  * 5. nothing at all → trusted internal call
134
134
  */
135
+ /**
136
+ * Header names that can carry a CLIENT-PRESENTED credential. Today only the
137
+ * standard `authorization` header — it carries Basic, Bearer, AND our custom
138
+ * `TPS-Ed25519 …` scheme (all three ride the Authorization header; see
139
+ * doVerify() above and auth-middleware.ts, both of which read exactly this
140
+ * header to authenticate a caller). Kept as a LIST, read via every header-object
141
+ * shape below, so a future signed-header auth scheme is a one-line addition here
142
+ * — not a scatter of ad-hoc `req.headers.authorization` truthiness checks that
143
+ * would each have to be rediscovered and updated (Kern's review point).
144
+ *
145
+ * DELIBERATELY EXCLUDES the server-set trust markers `x-tps-agent` /
146
+ * `x-tps-anonymous`: auth-middleware.ts STAMPS those AFTER it has verified a
147
+ * caller — they are never presented by the client. Counting them as "credential
148
+ * evidence" would let an unauthenticated caller forge one and defeat this gate.
149
+ */
150
+ const CREDENTIAL_HEADERS = ["authorization"];
151
+ /**
152
+ * Read a header off either header-object shape Harper hands us: the Web
153
+ * Headers-like `.get(name)` (populated for GET/search requests) or the plain
154
+ * `.asObject` bag (PUT/POST). Mirrors doVerify()'s own read exactly, so the
155
+ * evidence check can't miss a shape the verifier would have seen.
156
+ */
157
+ function readRequestHeader(reqLike, name) {
158
+ return (reqLike?.headers?.get?.(name) ??
159
+ reqLike?.headers?.asObject?.[name] ??
160
+ "");
161
+ }
162
+ /**
163
+ * True iff the request carries ANY client-presented credential (CREDENTIAL_HEADERS).
164
+ *
165
+ * WHY THIS EXISTS (flair#610): Harper's `authorizeLocal: true` forges
166
+ * `request.user = super_user` (and can populate `.username`) for a credential-
167
+ * LESS loopback request — one with NO Authorization header at all. A genuine
168
+ * Basic/Bearer/TPS-Ed25519 header SUPPRESSES that forgery. So "is a credential
169
+ * present?" is precisely what separates a genuinely-authenticated caller from
170
+ * Harper's ambient forgery — the gate resolveAgentAuth applies before trusting a
171
+ * `context.user` identity. Handles BOTH context shapes (`getContext().request`
172
+ * for GET/search, context-only for PUT/POST) by resolving `request ?? self`
173
+ * first, then reads both header-object shapes.
174
+ */
175
+ export function hasCredentialEvidence(context) {
176
+ const reqLike = context?.request ?? context;
177
+ if (!reqLike)
178
+ return false;
179
+ return CREDENTIAL_HEADERS.some((h) => readRequestHeader(reqLike, h) !== "");
180
+ }
135
181
  /**
136
182
  * allow* for AGENT-FACING resources: permit verified agents, admins/super_user,
137
183
  * and trusted internal calls; deny anonymous HTTP. Pass getContext(). Per-record
@@ -159,11 +205,23 @@ export async function resolveAgentAuth(context) {
159
205
  if (c.tpsAgent) {
160
206
  return { kind: "agent", agentId: String(c.tpsAgent), isAdmin: c.tpsAgentIsAdmin === true };
161
207
  }
208
+ // flair#610 — CREDENTIAL-EVIDENCE GATE. Harper's `authorizeLocal: true` forges
209
+ // `context.user = super_user` (and can populate `.username`) for a credential-
210
+ // LESS loopback request. Trusting that ambient identity was the forgery
211
+ // vector: a bare local caller resolved to admin with no signature and no
212
+ // password. Only trust a `context.user` identity when the request actually
213
+ // carries a credential (real Basic/Bearer/TPS-Ed25519 header) — the one thing
214
+ // authorizeLocal's forgery cannot manufacture. Without evidence, FALL THROUGH
215
+ // to the verifyAgentRequest fallback below: a genuine signed request still
216
+ // authenticates; a credential-less HTTP request lands on `anonymous` (it has a
217
+ // headers object but no valid agent), and a true in-process call with no
218
+ // request object at all lands on `internal`.
219
+ const credentialed = hasCredentialEvidence(c);
162
220
  const user = context?.user ?? c.user;
163
- if (user?.role?.permission?.super_user === true) {
221
+ if (credentialed && user?.role?.permission?.super_user === true) {
164
222
  return { kind: "agent", agentId: String(user.username ?? "admin"), isAdmin: true };
165
223
  }
166
- if (user?.username && user.username !== FLAIR_AGENT_USERNAME) {
224
+ if (credentialed && user?.username && user.username !== FLAIR_AGENT_USERNAME) {
167
225
  return { kind: "agent", agentId: String(user.username), isAdmin: false };
168
226
  }
169
227
  // A raw request with headers is present → verify it; an HTTP request that
@@ -49,7 +49,9 @@ async function backfillEmbedding(memoryId) {
49
49
  return;
50
50
  if (record.embedding?.length > 100)
51
51
  return;
52
- const embedding = await getEmbedding(record.content);
52
+ // flair#504 Phase 2: 'document' — a backfilled embedding IS a stored
53
+ // document vector, same as the three Memory.ts sites; must match.
54
+ const embedding = await getEmbedding(record.content, "document");
53
55
  if (!embedding)
54
56
  return;
55
57
  await patchRecord(databases.flair.Memory, memoryId, { embedding });
@@ -90,12 +92,6 @@ server.http(async (request, nextLayer) => {
90
92
  url.pathname === "/OAuthRevoke" ||
91
93
  url.pathname === "/.well-known/oauth-authorization-server" ||
92
94
  url.pathname === "/OAuthMetadata" ||
93
- // ObservationCenter HTML shell is public — the page itself is just markup
94
- // and inline JS, with no embedded data. The JS prompts for admin-pass and
95
- // auths every API call (/Agent, /SemanticSearch, /FederationPeers, etc).
96
- // Without this allow-list entry, the HTML is 401-blocked on hosted Flair
97
- // instances (rockit-local works only because authorizeLocal=true).
98
- url.pathname === "/ObservationCenter" ||
99
95
  // Presence roster is public-safe (field-allowlisted); GET serves the
100
96
  // Office Space renderer without auth. Scoped to GET only (#604): the
101
97
  // exact-path match used to match ANY method, so a bare `PUT /Presence`
@@ -120,12 +116,26 @@ server.http(async (request, nextLayer) => {
120
116
  // instead of skipping straight to that fallback parse.
121
117
  (request.method === "GET" && url.pathname === "/Presence"))
122
118
  return nextLayer(request);
119
+ // Read the Authorization header ONCE, up front — the super_user branch below
120
+ // needs it too (hoisted from its former position just after the branch as part
121
+ // of the flair#610 belt-and-suspenders check).
122
+ const header = request.headers.get("authorization") || request.headers?.asObject?.authorization || "";
123
123
  // If Harper has already authorized this request (e.g. Basic admin, or
124
124
  // authorizeLocal=true on localhost), trust Harper's auth decision and pass
125
125
  // through. Annotate the admin identity so resources' resolveAgentAuth recognizes
126
126
  // this as an ADMIN caller (not anonymous) — otherwise a Basic-admin request,
127
127
  // which carries no TPS-Ed25519 header, gets classified anonymous and denied.
128
- if (request.user?.role?.permission?.super_user === true) {
128
+ //
129
+ // flair#610 BELT-AND-SUSPENDERS: require an Authorization header to be present
130
+ // before trusting a super_user `request.user`. Harper's `authorizeLocal: true`
131
+ // forges request.user=super_user for a credential-LESS loopback request; a
132
+ // genuine Basic/super_user caller always carries a header. This is defense-in-
133
+ // depth — the general middleware path below already marks a headerless request
134
+ // tpsAnonymous BEFORE Harper's ambient elevation lands, so this branch isn't a
135
+ // live vector today — but it keeps the trust decision from ever hinging on
136
+ // ambient elevation alone. (The root-cause gate lives in resolveAgentAuth; see
137
+ // agent-auth.ts hasCredentialEvidence.)
138
+ if (header && request.user?.role?.permission?.super_user === true) {
129
139
  request.tpsAgent = request.user.username ?? "admin";
130
140
  request.tpsAgentIsAdmin = true;
131
141
  try {
@@ -139,7 +149,6 @@ server.http(async (request, nextLayer) => {
139
149
  // Skip re-entry: if we already swapped auth to Basic, pass through
140
150
  if (request._tpsAuthVerified)
141
151
  return nextLayer(request);
142
- const header = request.headers.get("authorization") || request.headers?.asObject?.authorization || "";
143
152
  // ── Basic admin / super_user auth ──────────────────────────────────────────
144
153
  // Allow Basic auth for CLI operations (backup, etc.). Two paths:
145
154
  // 1. HDB_ADMIN_PASSWORD env-var fast-path (user must be "admin" with exact pass)
@@ -9,13 +9,19 @@
9
9
  // candidate-UNION RRF (NOT naive whole-corpus RRF) recovers 4/6 into top-10 with
10
10
  // no regression on the within-cluster gate (p@3 holds 0.88).
11
11
  // ─── Feature flag: BM25 + union-RRF hybrid retrieval ────────────────────────
12
- // Flag OFF (default) SemanticSearch behavior is byte-identical to the
13
- // pre-hybrid path (HNSW + the +0.05 exact-substring keyword bump). Flag ON → the
14
- // hybrid path. Toggle with FLAIR_HYBRID_RETRIEVAL=true (also "1" / "on"). Read
15
- // per-call so it can be flipped without a rebuild and set per-case in tests.
16
- // Lives here (Harper-free) so it's unit-testable.
12
+ // ACTIVATED 2026-07-08 (ops-i39b activation follow-up to #519): default is now
13
+ // ON. Recall-eval validated at build time (CHANGELOG 0.20.x): NEW-8
14
+ // within-cluster gate p@3 holds 0.88 (no regression), OLD-6 severe
15
+ // near-verbatim misses recover 0/6 4/6 into top-10. A fresh isolated-Harper
16
+ // measurement at activation time (no prod contact) confirmed zero regression
17
+ // on both severe- and within-cluster-style synthetic queries and a small
18
+ // (~+4ms/query) latency delta. Set FLAIR_HYBRID_RETRIEVAL=false (also "0" /
19
+ // "off") to REVERT to the pre-hybrid legacy path — byte-identical to the
20
+ // original default-OFF behavior, no code rollback needed. Read per-call so it
21
+ // can be flipped without a rebuild and set per-case in tests. Lives here
22
+ // (Harper-free) so it's unit-testable.
17
23
  export function hybridEnabled() {
18
- const v = (process.env.FLAIR_HYBRID_RETRIEVAL ?? "").toLowerCase();
24
+ const v = (process.env.FLAIR_HYBRID_RETRIEVAL ?? "true").toLowerCase();
19
25
  return v === "true" || v === "1" || v === "on";
20
26
  }
21
27
  // BM25 parameters (Kern-approved): k1≈1.2, b≈0.75; standard IDF + BM25.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * collision-lib.ts — pure join/rank/format logic for MemoryBootstrap's
3
+ * "Others in the room" collision-surfacing block (flair#681, the attention-
4
+ * plane flagship — spec: flair#681 "Phase 2 — collision
5
+ * surfacing in bootstrap").
6
+ *
7
+ * This module does NO Harper reads of its own. resources/MemoryBootstrap.ts's
8
+ * post() does the (already-scoped, already-gated) reads —
9
+ * - entity-overlap candidates from WorkspaceState (internal server-side
10
+ * path, Sherlock Option 1 — the #678 AttentionQuery pattern) and OrgEvent
11
+ * (org-open read model, no per-agent scoping to respect),
12
+ * - semantic-match candidates from the SAME scored candidate pool #550's
13
+ * existing code already computes during bootstrap (HNSW cosine
14
+ * similarity against the caller's embedded currentTask, via the bounded
15
+ * retrieveCandidates() core — flair-bootstrap-scale-fix replaced the
16
+ * original full-corpus JS dot-product scan with this bounded pushdown;
17
+ * same `score > 0.3` relevance floor, same "no new embedding code"
18
+ * property — see resources/MemoryBootstrap.ts and resources/
19
+ * semantic-retrieval-core.ts),
20
+ * - the freshness gate from the Presence roster (resources/
21
+ * presence-internal.ts's getPresenceRoster(), the SAME internal path
22
+ * #678 established — never the raw table),
23
+ * and hands the resulting plain-object candidates to this module to join,
24
+ * freshness-gate, rank, and format. Kept Harper-free so it can be unit-tested
25
+ * directly (test/unit/collision-lib.test.ts) — the same reason
26
+ * resources/memory-bootstrap-lib.ts exists for the Team-roster helpers.
27
+ *
28
+ * Per the K&S verdict's correction: Memory is the SEMANTIC surface (reused,
29
+ * via #550); WorkspaceState/OrgEvent are the ENTITY surface (exact index
30
+ * overlap). This module joins the two, never conflating them — an
31
+ * entity-overlap match is always high-precision (exact vocabulary-string
32
+ * equality) and never needs its own relevance score; a semantic-only match
33
+ * always carries the score #550 already floor-gated.
34
+ */
35
+ /**
36
+ * Build a Harper query condition matching ANY of `entities` against an
37
+ * indexed `entities` array attribute. Harper's real query engine THROWS
38
+ * ("An 'or' operator requires at least two conditions") for an `operator:
39
+ * "or"` condition with fewer than two sub-conditions — a single-entity
40
+ * caller (the common case) would otherwise silently break the whole
41
+ * collision block (caught only by a real-Harper e2e test, never the mocked
42
+ * unit suite — see test/integration/bootstrap-collision-e2e.test.ts).
43
+ * Callers must always call this instead of hand-rolling the OR wrapper.
44
+ */
45
+ export function buildEntityMatchCondition(entities, attribute = "entities") {
46
+ if (entities.length === 1) {
47
+ return { attribute, comparator: "equals", value: entities[0] };
48
+ }
49
+ return {
50
+ operator: "or",
51
+ conditions: entities.map((e) => ({ attribute, comparator: "equals", value: e })),
52
+ };
53
+ }
54
+ const MS_MIN = 60_000;
55
+ const MS_HOUR = 3600_000;
56
+ const MS_DAY = 24 * MS_HOUR;
57
+ /** "4m ago" / "2h ago" / "3d ago" — mirrors the issue's own example phrasing. */
58
+ export function formatRelativeTime(fromMs, nowMs = Date.now()) {
59
+ const elapsed = Math.max(0, nowMs - fromMs);
60
+ if (elapsed < MS_MIN)
61
+ return "just now";
62
+ if (elapsed < MS_HOUR)
63
+ return `${Math.floor(elapsed / MS_MIN)}m ago`;
64
+ if (elapsed < MS_DAY)
65
+ return `${Math.floor(elapsed / MS_HOUR)}h ago`;
66
+ return `${Math.floor(elapsed / MS_DAY)}d ago`;
67
+ }
68
+ function clip(s, max = 80) {
69
+ const t = s.trim().replace(/\s+/g, " ");
70
+ if (t.length === 0)
71
+ return t;
72
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
73
+ }
74
+ /**
75
+ * The freshness gate: only agents with a Presence roster row whose
76
+ * `presenceStatus` is NOT "offline" are candidates at all — reuses
77
+ * Presence.ts's OWN derivePresenceStatus()-driven verdict (a heartbeat
78
+ * within the offline threshold), never a re-implemented threshold. An agent
79
+ * absent from the roster entirely (never heartbeated) is excluded outright —
80
+ * no heartbeat is the strictest possible "not fresh."
81
+ */
82
+ export function freshPresenceByAgent(roster) {
83
+ const out = new Map();
84
+ for (const row of roster) {
85
+ const id = row?.id;
86
+ if (typeof id !== "string" || id.length === 0)
87
+ continue;
88
+ if (row.presenceStatus === "offline")
89
+ continue;
90
+ const hb = row.lastHeartbeatAt;
91
+ if (hb === null || hb === undefined)
92
+ continue; // no heartbeat at all → not fresh
93
+ const hbNum = typeof hb === "number" ? hb : Number(hb);
94
+ if (!Number.isFinite(hbNum))
95
+ continue;
96
+ out.set(id, row);
97
+ }
98
+ return out;
99
+ }
100
+ /**
101
+ * Join entity-overlap + semantic-match candidates against the freshness-
102
+ * gated presence map, rank (entity overlap first — high precision, exact
103
+ * match — then semantic by score desc), and format each into a single
104
+ * "others in the room" line. Never returns more than one entry per agent
105
+ * (entity detail wins over semantic when a teammate has both — the
106
+ * higher-precision signal leads). Never surfaces an agent absent from
107
+ * `freshByAgent` (the freshness gate) or the caller itself.
108
+ */
109
+ export function buildCollisionEntries(entityMatches, semanticMatches, freshByAgent, callerAgentId, nowMs = Date.now()) {
110
+ const byAgent = new Map();
111
+ const sortedEntity = entityMatches
112
+ .filter((m) => m.agentId !== callerAgentId && m.entities.length > 0)
113
+ .slice()
114
+ .sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || ""));
115
+ for (const m of sortedEntity) {
116
+ if (byAgent.has(m.agentId))
117
+ continue; // best (most recent) row per agent wins
118
+ const presence = freshByAgent.get(m.agentId);
119
+ if (!presence)
120
+ continue; // freshness gate
121
+ const displayName = typeof presence.displayName === "string" && presence.displayName
122
+ ? presence.displayName
123
+ : m.agentId;
124
+ const hbRaw = presence.lastHeartbeatAt;
125
+ const hb = typeof hbRaw === "number" ? hbRaw : Number(hbRaw);
126
+ const entityList = m.entities.join(", ");
127
+ const detail = m.summary ? ` (${clip(m.summary)})` : "";
128
+ const line = `${displayName} is touching ${entityList}${detail} — last active ${formatRelativeTime(hb, nowMs)}`;
129
+ byAgent.set(m.agentId, { agentId: m.agentId, displayName, kind: "entity", line, lastHeartbeatAt: hb });
130
+ }
131
+ const sortedSemantic = semanticMatches
132
+ .filter((m) => m.agentId !== callerAgentId)
133
+ .slice()
134
+ .sort((a, b) => b.score - a.score);
135
+ for (const m of sortedSemantic) {
136
+ if (byAgent.has(m.agentId))
137
+ continue; // entity-overlap already covers this agent — higher precision wins
138
+ const presence = freshByAgent.get(m.agentId);
139
+ if (!presence)
140
+ continue; // freshness gate
141
+ const displayName = typeof presence.displayName === "string" && presence.displayName
142
+ ? presence.displayName
143
+ : m.agentId;
144
+ const hbRaw = presence.lastHeartbeatAt;
145
+ const hb = typeof hbRaw === "number" ? hbRaw : Number(hbRaw);
146
+ const line = `${displayName} has related work in progress (${clip(m.content)}) — last active ${formatRelativeTime(hb, nowMs)}`;
147
+ byAgent.set(m.agentId, { agentId: m.agentId, displayName, kind: "semantic", line, lastHeartbeatAt: hb });
148
+ }
149
+ // Entity-kind entries first (rank order preserved by insertion above is not
150
+ // guaranteed across the two passes' Map writes), then semantic — within
151
+ // each kind, most-recently-active first.
152
+ return [...byAgent.values()].sort((a, b) => {
153
+ if (a.kind !== b.kind)
154
+ return a.kind === "entity" ? -1 : 1;
155
+ return b.lastHeartbeatAt - a.lastHeartbeatAt;
156
+ });
157
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * embeddings-boot.ts — registers harper-fabric-embeddings as Harper's
3
+ * `embedding`/`default` model backend DIRECTLY, in-process, on every boot
4
+ * (flair#694 fix; invariants at flair#695).
5
+ *
6
+ * ─── Why this file exists (flair#694) ──────────────────────────────────────
7
+ * The previous mechanism (removed by this change) delivered the registration
8
+ * as a `models.embedding.default` block via the `HARPER_CONFIG` env var
9
+ * (src/cli.ts's old `buildEmbeddingsHarperConfigEnv`) — a "merge layer" that
10
+ * Harper's environment-manager (`@harperfast/harper`'s
11
+ * `config/harperConfigEnvVars.js`) reasserts on every boot AND PERSISTS into
12
+ * the instance-root `harper-config.yaml`. That file proved to be config-as-
13
+ * STATE, not config-as-intent: `flair#694`'s downgrade-and-revert CI lane
14
+ * (flair#692) caught a real bricking sequence —
15
+ *
16
+ * 1. A build with this feature boots and HARPER_CONFIG reasserts
17
+ * `models.embedding.default.{backend,modelName,modelsDir}`, which
18
+ * Harper's env-var layer persists to `harper-config.yaml` AND records
19
+ * each of those THREE flattened leaf paths as `sources[path] =
20
+ * 'HARPER_CONFIG'` in `.harper-config-state.json` (no "original value"
21
+ * stored, because the key didn't exist before this boot introduced it).
22
+ * 2. On ANY later boot that does not reassert `HARPER_CONFIG` for those
23
+ * paths (a downgrade to a build that predates this feature; equally, a
24
+ * transient resolution failure on the current build) — Harper's own
25
+ * `applyRuntimeEnvConfig` treats the env var's absence as "the operator
26
+ * removed this config" and calls `cleanupRemovedEnvVar`, which deletes
27
+ * each of the three leaves INDIVIDUALLY (no stored original to restore
28
+ * to), leaving `models.embedding.default: {}` — an empty shell —
29
+ * persisted to disk.
30
+ * 3. The next boot's config schema validator
31
+ * (`@harperfast/harper`'s `validation/configValidator.js`) resolves the
32
+ * entry's backend via `Joi.alternatives().conditional('.backend', {...,
33
+ * otherwise: unknownBackendEntrySchema})`; with `backend` absent, that
34
+ * falls through to `unknownBackendEntrySchema = Joi.object({backend:
35
+ * string.required()})`, which throws exactly: "Harper config file
36
+ * validation error: 'models.embedding.default.backend' is required" —
37
+ * Harper refuses to boot. Confirmed byte-identical in both @harperfast/
38
+ * harper 5.1.15 and 5.1.17 (this is upstream env-var-config behavior,
39
+ * not a schema difference between versions), and reproduced locally by
40
+ * replaying the exact downgrade-and-revert sequence.
41
+ *
42
+ * No shape flair could put IN HARPER_CONFIG fixes this: the deletion is
43
+ * triggered by an OLDER build never setting the env var in the first place,
44
+ * which is unfixable from the newer build's side. Config that gets torn
45
+ * down whenever a boot doesn't reassert it is fundamentally unsafe for
46
+ * anything a downgrade must survive (flair#695 invariant I: "config files
47
+ * are state too").
48
+ *
49
+ * ─── The fix ────────────────────────────────────────────────────────────
50
+ * Skip Harper's config-file-driven bootstrap path ENTIRELY. Call
51
+ * harper-fabric-embeddings' own `register({logicalName, kind, config})`
52
+ * factory DIRECTLY — the same factory Harper's `bootstrapModels()` would
53
+ * have invoked, and (per that package's own source) the "public path for
54
+ * components and apps to add in-process ... backends":
55
+ * `models.registerBackend(kind, id, backend)` on Harper's process-wide
56
+ * `models` singleton. This is genuinely reassert-only: every boot calls this
57
+ * function again (module-scope side effect, same convention as
58
+ * `migration-boot.ts`), NOTHING is ever written to `harper-config.yaml`, so
59
+ * there is no persisted state for a downgrade to trip over — the class of
60
+ * bug this file fixes cannot recur by construction, not by a shape
61
+ * contract that has to keep being honored.
62
+ *
63
+ * Bonus: this also drops the old absolute-path workaround
64
+ * (`resolveEmbeddingBackendModule`'s `require.resolve` + `pathToFileURL`
65
+ * dance) that HARPER_CONFIG's `backend:` needed because
66
+ * `resolveBackendSpecifier` resolves a bare package name from the Harper
67
+ * INSTANCE ROOT's `node_modules`, not flair's own package dir. Importing
68
+ * harper-fabric-embeddings directly from flair's own code needs no such
69
+ * workaround — plain Node module resolution from flair's own `node_modules`
70
+ * always finds it, uniformly across a local install, Docker, AND a Fabric
71
+ * deploy (where flair runs as a non-root cluster component and
72
+ * `bootstrapModels()` — gated on `isRoot` — was never reachable at all; see
73
+ * the removed comment this replaces in `config.yaml`).
74
+ *
75
+ * ─── Loading mechanism ──────────────────────────────────────────────────
76
+ * Plain (non-Resource) module — same shape as `migration-boot.ts` /
77
+ * `embeddings-provider.ts` / `table-helpers.ts` — so Harper's `jsResource:
78
+ * files: dist/resources/*.js` loader (config.yaml) imports it at boot like
79
+ * every other flat file under `resources/`, running its top-level side
80
+ * effect exactly once per process. No config.yaml wiring needed.
81
+ *
82
+ * Graceful degrade preserved: if harper-fabric-embeddings isn't installed,
83
+ * or `globalThis.models` isn't available (this module loaded outside a real
84
+ * Harper boot), registration is skipped and logged — Harper falls back to
85
+ * keyword-only search, matching the pre-existing degrade contract.
86
+ */
87
+ import { resolveModelsDir } from "./embeddings-provider.js";
88
+ const LOGICAL_NAME = "default";
89
+ const MODEL_NAME = "nomic-embed-text";
90
+ /**
91
+ * BENCH-ONLY escape hatch — NOT a production feature flag, never documented
92
+ * as an operator setting, and never read anywhere else in this codebase.
93
+ * Mirrors `embeddings-provider.ts`'s `FLAIR_RECALL_HARNESS_FORCE_PREFIX`
94
+ * hatch (see that file's header for the pattern this follows): a bench-only
95
+ * env var, read lazily, that lets `test/bench/recall-harness/run.ts`'s
96
+ * `--model-file <path>` flag override model SELECTION the same way that
97
+ * hatch overrides prefix behavior — without editing this file's hardcoded
98
+ * `MODEL_NAME`/`resolveModelsDir()` call every time a bakeoff needs a
99
+ * different GGUF (e.g. a Q8_0 quant of the same base model).
100
+ *
101
+ * This is not a new capability grafted on — `harper-fabric-embeddings`'
102
+ * `register()` factory already accepts `config.modelPath` as an alternative
103
+ * to `modelName`+`modelsDir` (see `engineOptionsFromConfig()` in
104
+ * `harper-fabric-embeddings`' `dist/index.js`): an absolute path bypasses its
105
+ * built-in model registry and HuggingFace-download resolution entirely. This
106
+ * hatch is the one line that lets a caller reach that existing parameter.
107
+ * `EmbeddingEngine`'s `modelIdentity` (used for nomic-prefix detection, see
108
+ * `engine.js`'s `#applyPrefix`) becomes the file's basename in this path, so
109
+ * prefix behavior is unaffected as long as the GGUF's filename still
110
+ * contains "nomic-embed-text".
111
+ *
112
+ * No production deploy sets this env var, so the unset (overwhelmingly
113
+ * common) case is byte-identical to before this hatch existed.
114
+ */
115
+ function benchModelPathOverride() {
116
+ return process.env.FLAIR_RECALL_HARNESS_MODEL_PATH || undefined;
117
+ }
118
+ let registered = false;
119
+ /**
120
+ * Register the embedding backend. Idempotent within a process (mirrors
121
+ * `migration-boot.ts`'s `scheduled` guard) — safe to call more than once,
122
+ * only the first call does anything.
123
+ */
124
+ export async function registerEmbeddingsBackend() {
125
+ if (registered)
126
+ return;
127
+ registered = true;
128
+ try {
129
+ const { register } = await import("harper-fabric-embeddings");
130
+ const modelPath = benchModelPathOverride();
131
+ await register({
132
+ logicalName: LOGICAL_NAME,
133
+ kind: "embedding",
134
+ config: modelPath ? { modelPath } : { modelName: MODEL_NAME, modelsDir: resolveModelsDir() },
135
+ });
136
+ }
137
+ catch (err) {
138
+ // Not installed, or globalThis.models isn't ready (module loaded outside
139
+ // a real Harper boot, e.g. some future non-Harper import path) — degrade
140
+ // to Harper's keyword-only fallback, the same contract the old
141
+ // HARPER_CONFIG-omitted path preserved.
142
+ console.error(`[embeddings] backend registration skipped: ${err?.message ?? String(err)}`);
143
+ }
144
+ }
145
+ /** Test-only reset — never used in production (a real process boots once). */
146
+ export function _resetEmbeddingsBackendRegistrationForTests() {
147
+ registered = false;
148
+ }
149
+ void registerEmbeddingsBackend();