@tpsdev-ai/flair 0.21.0 → 0.22.1

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 +1811 -221
  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 +455 -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 +167 -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,16 +1,241 @@
1
1
  /**
2
2
  * embeddings-provider.ts
3
3
  *
4
- * Wrapper around harper-fabric-embeddings for Flair resources.
4
+ * Wrapper around Harper's native `models.embed()` facade for Flair resources.
5
5
  *
6
- * Harper 5.0.0 loads resources in a VM sandbox that can't statically link
7
- * npm packages during module resolution (async race in getOrCreateModule).
8
- * Using dynamic import() defers the module load to first use, bypassing
9
- * the VM linker entirely.
6
+ * Phase 1 (flair#504) infra swap, DEAD-FLAT WASH. Embeddings now resolve
7
+ * through Harper's process-wide `models` singleton instead of this file
8
+ * dynamic-importing `harper-fabric-embeddings` and initializing it itself.
9
+ * The engine is the SAME one, registered as the `embedding`/`default`
10
+ * backend by harper-fabric-embeddings@0.3.0's own `register()` factory —
11
+ * called DIRECTLY, in-process, by `resources/embeddings-boot.ts` on every
12
+ * boot (flair#694; see that file's header for why: the original mechanism
13
+ * drove `register()` off a `models.embedding.default` block Harper's
14
+ * `bootstrapModels()` read from the INSTANCE-ROOT config, delivered via the
15
+ * `HARPER_CONFIG` env var — but that env var's value got PERSISTED into
16
+ * `harper-config.yaml`, and a boot that didn't reassert it (any
17
+ * older/downgraded build) tore the persisted block down to an invalid empty
18
+ * shell that the next boot's config validator rejected outright, bricking
19
+ * the instance). Registering directly means nothing is ever written to the
20
+ * config file, so there is no downgrade-unsafe state — and it works
21
+ * uniformly whether this package's own `config.yaml` loads root or non-root
22
+ * (e.g. a Harper Fabric deploy, where it's always non-root and
23
+ * `bootstrapModels()` was never reachable at all). Phase 1 passed no
24
+ * `inputType`, so no
25
+ * `search_document:`/`search_query:` prefix was applied — output was
26
+ * byte-identical to the pre-migration direct-import path. That's what made
27
+ * that swap a dead-flat wash: same model, same weights, same input, same
28
+ * output — only the plumbing changed. Phase 2 (below) turns `inputType` on.
29
+ *
30
+ * This retires the `@node-llama-cpp/<platform>` addon-path discovery and
31
+ * the VM-sandbox manual-init block that used to live here: Harper 5.0.0's VM
32
+ * sandbox can't statically link npm packages during module resolution (an
33
+ * async race in getOrCreateModule), which is why the OLD code deferred to a
34
+ * dynamic import() on first call. That fragility — a runtime bump silently
35
+ * breaking embeddings on next restart (the node-26 near-miss) — is exactly
36
+ * what this migration removes: `models.embed` is a process-wide singleton
37
+ * Harper initializes at boot, off the first-call dynamic-import race.
38
+ *
39
+ * `@harperfast/harper` is dynamic-imported here (deferred to first actual
40
+ * getEmbedding()/getMode()/getStatus() call), NOT statically imported like
41
+ * every other resource's `Resource`/`databases`/`server` — those work
42
+ * unmocked-import-free only because every test that (transitively) imports
43
+ * them already mocks `@harperfast/harper` first. Statically importing
44
+ * `models` here would make *any* test that imports this file for
45
+ * `resolveModelsDir()` alone (test/unit/embeddings-models-dir.test.ts, which
46
+ * has no reason to know about Harper at all) also eagerly load Harper's real
47
+ * `dist/index.js` — which `require()`s `server/threads/threadServer.js` at
48
+ * module scope and throws ("Unable to determine database storage path...")
49
+ * outside an actual Harper boot (verified: this broke 4 unit-test files
50
+ * before this file switched to a deferred import). Deferring avoids that
51
+ * entirely: the dynamic import only fires when an embedding is actually
52
+ * requested, exactly mirroring this file's own pre-existing pattern for
53
+ * harper-fabric-embeddings.
54
+ *
55
+ * Phase 2 (flair#504) — nomic search prefixes, now ON. Verified from HFE
56
+ * 0.3.0's shipped `engine.js` (`#applyPrefix`): `models.embed(text, { model,
57
+ * inputType })` forwards `inputType` to the backend, which prepends
58
+ * `search_document: ` for `inputType === 'document'`, `search_query: ` for
59
+ * `'query'`, and nothing for `undefined` (Phase 1's wash). nomic-embed-text-
60
+ * v1.5 is *trained* on this asymmetry, so the design hypothesis going in was
61
+ * that turning it on would be a real recall improvement, not just plumbing.
62
+ * The measured A/B (recall-harness instrument v2, N=126 queries,
63
+ * 2026-07-11; see `test/bench/recall-harness/README.md`'s "v2 measured
64
+ * results") didn't confirm that hypothesis, but it didn't refute it either:
65
+ * prefixes=off p@3=0.992/MRR=0.949 vs prefixes=on p@3=0.976/MRR=0.946
66
+ * (Δp@3 -0.016 — 2 of 126 queries — ΔMRR -0.003, SE=±0.000 across 3 runs in
67
+ * both arms). Stated plainly: that delta is noise-scale at this instrument's
68
+ * N, not a directional signal either way — see PR #689 for the full
69
+ * measurement and its initial "park on the numbers" read.
70
+ *
71
+ * This checkpoint of flair#504 revisits that read and flips
72
+ * `EMBEDDING_PREFIXES_ENABLED` to `true` on strategic grounds instead, since
73
+ * the recall measurement alone was a wash: nomic-embed-text-v1.5 is trained
74
+ * expecting these prefixes, so running it unprefixed indefinitely is the
75
+ * actual departure from convention, not the reverse. The flip is also the
76
+ * first real payload for the boot-keyed auto-migration machinery (flair#690,
77
+ * flair#695) — exercising the detect-stale/re-embed path now, on a change
78
+ * with a proven noise-scale recall floor, is lower-risk than letting that
79
+ * machinery sit unexercised until some future higher-stakes change needs it
80
+ * first, and avoids two unrelated stamp-bumping changes compounding into one
81
+ * migration debt pile. This file still ships the same plumbing (typed
82
+ * `inputType`, every call site passing the correct literal), the same stamp
83
+ * mechanism (`EMBEDDING_VARIANT`/`getModelId()`), and the same measurement
84
+ * instrument as PR #689 — what changed is the gate value and the
85
+ * re-baselined `test/bench/recall-harness/BASELINE.json` this flip required.
86
+ *
87
+ * `EmbedInputType` is a closed union — `'document' | 'query'` — because the
88
+ * values are literal and load-bearing: `'search_document'` (the PREFIX
89
+ * STRING, not the inputType VALUE) is truthy but `!== 'document'`, so passing
90
+ * it as a value falls to the engine's `else` branch and applies the QUERY
91
+ * prefix to a document — silently inverting the asymmetry and degrading
92
+ * recall. The union makes that a compile-time error for typed callers;
93
+ * `buildEmbedOptions()` below adds a runtime guard as defense in depth for a
94
+ * caller that bypasses the type system. Every `getEmbedding()` call site
95
+ * passes the correct literal unconditionally — they declare INTENT
96
+ * ('document' for stored content, 'query' for a search query); the gate at
97
+ * `buildEmbedOptions()`/`getModelId()` is the single chokepoint that decides
98
+ * whether that intent actually reaches the backend. Flipping the gate DOES
99
+ * change `getModelId()`'s output (the `+searchprefix` suffix now applies by
100
+ * default — see `EMBEDDING_VARIANT` below), which means every row written
101
+ * before this PR now reads as stale against the new model id — the
102
+ * boot-keyed auto-migration runner
103
+ * (`resources/migrations/embedding-stamp.ts`) picks that up and re-embeds
104
+ * automatically on next boot. That is this flip's intended, not incidental,
105
+ * consequence: proving the migration machinery actually re-embeds a real
106
+ * corpus end to end. A live production instance re-embedding on upgrade is
107
+ * still a separate, deliberate step from shipping this code — see the PR
108
+ * description for that distinction.
10
109
  */
11
110
  import { join } from "node:path";
12
111
  import { existsSync } from "node:fs";
13
112
  import { homedir } from "node:os";
113
+ const VALID_INPUT_TYPES = new Set(["document", "query"]);
114
+ /**
115
+ * THE GATE (flair#504 Phase 2 — flipped ON). A module-level constant, not an
116
+ * env toggle: Kern's original Phase 2 review called this "a code-level
117
+ * decision," and that principle survives the flip — an operator must not be
118
+ * able to move prefix behavior out of sync with a re-baselined A/B. This PR
119
+ * IS that re-baseline: `test/bench/recall-harness/BASELINE.json` now records
120
+ * the prefixes=on numbers this gate change measured, replacing the
121
+ * prefixes=off baseline PR #689 froze. See that file and
122
+ * `test/bench/recall-harness/README.md`'s "Phase 2 prefix A/B" section for
123
+ * why it's on: the measured v2 A/B (N=126) showed a noise-scale delta
124
+ * between arms (Δp@3 -0.016, ΔMRR -0.003, 2 of 126 queries) — not the
125
+ * hypothesized bump, but not a real regression either — so the decision to
126
+ * flip rests on strategic grounds (training-convention alignment, and using
127
+ * this wash-scale change as the auto-migration machinery's proving payload)
128
+ * rather than a recall win. Flipping this back to `false` requires the same
129
+ * process in reverse: a fresh harness run through the ratchet gate that
130
+ * shows staying on is actively worse, not just unproven.
131
+ *
132
+ * This is THE single chokepoint both `buildEmbedOptions()` (inputType
133
+ * forwarding) and `getModelId()` (the `+searchprefix` stamp) read — see
134
+ * `prefixesEnabled()` immediately below. They must never diverge: a suffix
135
+ * without matching inputType-forwarding (or vice versa) would silently
136
+ * mislabel every embedding written while the mismatch existed (dedup and
137
+ * stale-detection both key off `embeddingModel`, and it would no longer
138
+ * describe what was actually forwarded to `models.embed()`). Enforcing this
139
+ * at one chokepoint — rather than trusting every call site and every
140
+ * stamp-reader to independently agree — is what makes that impossible by
141
+ * construction instead of by convention.
142
+ */
143
+ const EMBEDDING_PREFIXES_ENABLED = true; // Phase 2 prefix flip ON — re-baselined through the ratchet gate (flair#504)
144
+ /**
145
+ * BENCH-ONLY escape hatch — NOT a production feature flag, never documented
146
+ * as an operator setting, and never read by any call site (Memory.ts,
147
+ * SemanticSearch.ts, MemoryBootstrap.ts, auth-middleware.ts) — those pass
148
+ * `'document'`/`'query'` unconditionally regardless of the gate; only
149
+ * `prefixesEnabled()` (immediately below) reads this.
150
+ *
151
+ * test/bench/recall-harness/run.ts's `--prefixes on|off` arms and its
152
+ * mixed-space canary need to measure "as if the gate were flipped the other
153
+ * way" against the SAME dist build the default ships — the harness is an
154
+ * external HTTP client with no way to reach into a spawned Harper process's
155
+ * embedding call, so this lets it override the ONE thing that matters
156
+ * (whether `models.embed` receives `inputType`, and whether `getModelId()`
157
+ * stamps the suffix) via the env var it already forwards to `startHarper()`
158
+ * (the same mechanism `FLAIR_HYBRID_RETRIEVAL`/`FLAIR_RERANK_ENABLED` use).
159
+ *
160
+ * Bidirectional, not two separate force-on/force-off hatches: THE GATE has
161
+ * flipped direction once already (off→on, this PR) and may again in the
162
+ * future, so a single override that reads `"true"`/`"false"` (unset = defer
163
+ * to THE GATE) stays correct regardless of which way the gate itself is
164
+ * currently set — the harness always names the arm it wants explicitly
165
+ * rather than assuming "no hatch" means "off" (or "on"). Read lazily (not
166
+ * cached at module load) to match this codebase's existing env-var
167
+ * convention (see resources/rate-limiter.ts).
168
+ */
169
+ function harnessPrefixOverride() {
170
+ const v = process.env.FLAIR_RECALL_HARNESS_FORCE_PREFIX;
171
+ if (v === "true")
172
+ return true;
173
+ if (v === "false")
174
+ return false;
175
+ return undefined;
176
+ }
177
+ /**
178
+ * The single source of truth for "are prefixes active right now" — reads
179
+ * THE GATE, overridden by the bench-only hatch when set. Both
180
+ * `buildEmbedOptions()` and `getModelId()` call this and nothing else, so
181
+ * the suffix and the inputType-forwarding can never diverge (see
182
+ * `EMBEDDING_PREFIXES_ENABLED`'s doc above for why that invariant matters).
183
+ */
184
+ function prefixesEnabled() {
185
+ const override = harnessPrefixOverride();
186
+ return override !== undefined ? override : EMBEDDING_PREFIXES_ENABLED;
187
+ }
188
+ /**
189
+ * Build the options object passed to `models.embed()`. Pulled out as its own
190
+ * pure, harper-free function so the value-forwarding (and the reject-a-
191
+ * wrong-value guard) is unit-testable without touching the deferred
192
+ * `@harperfast/harper` import this file's header explains — see
193
+ * test/unit/embeddings-provider-input-type.test.ts.
194
+ *
195
+ * Gate ON (default): rejects anything other than the literal
196
+ * `'document'`/`'query'` (or omitted): TypeScript's `EmbedInputType` union
197
+ * already makes a wrong value a COMPILE-time error for typed callers; this
198
+ * is defense in depth for a caller that bypasses the type system (`as any`,
199
+ * a future refactor that loosens the type, a plain-JS caller). A rejected
200
+ * value is treated as if omitted (no prefix) rather than forwarded — see the
201
+ * file header for why forwarding the wrong value is actively harmful, not
202
+ * just a no-op.
203
+ *
204
+ * Gate OFF (only reachable now via the bench-only override — see
205
+ * `harnessPrefixOverride()`): `inputType` is dropped even when a call site
206
+ * passes one — call sites keep passing `'document'`/`'query'`
207
+ * unconditionally (they declare intent; this chokepoint enforces the gate),
208
+ * matching Phase 1's wash behavior exactly.
209
+ */
210
+ export function buildEmbedOptions(inputType) {
211
+ if (!prefixesEnabled())
212
+ return { model: "default" };
213
+ if (inputType !== undefined && !VALID_INPUT_TYPES.has(inputType)) {
214
+ console.error(`[embeddings] getEmbedding: invalid inputType ${JSON.stringify(inputType)} ignored (expected 'document' | 'query' | undefined) — see flair#504 Phase 2, passing the prefix STRING as the VALUE inverts the asymmetry`);
215
+ return { model: "default" };
216
+ }
217
+ return inputType ? { model: "default", inputType } : { model: "default" };
218
+ }
219
+ let _modelsApi;
220
+ /**
221
+ * Resolve (and cache) Harper's `models` facade via a deferred import — see file header.
222
+ *
223
+ * NOTE for anyone using this as a reference: `models` (the `@harperfast/harper`
224
+ * package export) and a component's `scope.models` are the SAME boot-time
225
+ * singleton — Harper's jsLoader hands components `scope.models = <the global
226
+ * models>` (two accessors, one object; the model registry lives on that singleton).
227
+ * A Harper *component* (one with a `handleApplication(scope)` hook) reaches it
228
+ * idiomatically as `scope.models`; this file is a Resource *helper* with no
229
+ * `scope`, so it imports the global export. Same registry, same backends — the
230
+ * global import here is the correct accessor for this context, not a workaround.
231
+ */
232
+ async function getModelsApi() {
233
+ if (_modelsApi)
234
+ return _modelsApi;
235
+ const harper = await import("@harperfast/harper");
236
+ _modelsApi = harper.models;
237
+ return _modelsApi;
238
+ }
14
239
  /**
15
240
  * Resolve the directory the embeddings model lives in / downloads into.
16
241
  *
@@ -27,6 +252,14 @@ import { homedir } from "node:os";
27
252
  *
28
253
  * The chosen dir is always writable, so the embeddings engine can download the
29
254
  * model on first use without hitting EACCES on a root-owned package dir.
255
+ *
256
+ * Called by `resources/embeddings-boot.ts` (flair#694) to build the
257
+ * `modelsDir` it passes to harper-fabric-embeddings' `register()` — both run
258
+ * in the SAME process now (unlike the retired src/cli.ts-side computation,
259
+ * which duplicated this exact default because it ran in a separate process
260
+ * that set the resulting value via an env var). Still exported and tested
261
+ * independently (test/unit/embeddings-models-dir.test.ts) as the single
262
+ * documented source of truth for this default.
30
263
  */
31
264
  export function resolveModelsDir() {
32
265
  const override = process.env.FLAIR_MODELS_DIR;
@@ -42,136 +275,161 @@ export function resolveModelsDir() {
42
275
  return cwdModels;
43
276
  return join(homedir(), ".flair", "data", "models");
44
277
  }
45
- let _state = "uninitialized";
46
- let _initError;
278
+ // Kern: "Boot probe MUST have a 5-10s timeout ... on timeout set mode='none'
279
+ // + log a warning. A hung probe ... must never block Harper boot."
280
+ const PROBE_TIMEOUT_MS = 8_000;
281
+ let _mode = "none"; // pessimistic until the probe (or a real call) proves otherwise
282
+ let _dims;
283
+ let _lastError;
284
+ let _probeStarted = false;
47
285
  let _warnedOnce = false;
48
- let _hfe = null;
49
- async function ensureInit() {
50
- if (_state === "ready")
51
- return;
52
- if (_state === "failed")
53
- return; // Don't retry already logged warning
286
+ function withTimeout(p, ms, label) {
287
+ return new Promise((resolve, reject) => {
288
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
289
+ // Never let a pending probe keep the process alive on its own.
290
+ timer.unref?.();
291
+ p.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
292
+ });
293
+ }
294
+ /**
295
+ * Boot-time health probe. Does NOT gate getEmbedding() (which always
296
+ * attempts the real call and lets its own try/catch decide the return
297
+ * value) — this exists purely to populate the synchronous getMode() /
298
+ * getStatus() contract those two functions must preserve.
299
+ *
300
+ * `dims` is sourced from an actual embed call's output, never from a raw
301
+ * `dimensions()` accessor — the facade has no dims-metadata accessor (HFE
302
+ * PR #3 confirmed this), and a backend's own `dimensions()` would reflect
303
+ * ITS default engine, not necessarily the config-registered `default`
304
+ * backend `models.embed` actually calls. A mismatch here would be a silent
305
+ * regression in the embedding-stamp / stale-detection logic (Kern's Q3).
306
+ */
307
+ async function probe() {
54
308
  try {
55
- // Dynamic import deferred to avoid Harper 5.0.0 VM linker race
56
- if (!_hfe) {
57
- _hfe = await import("harper-fabric-embeddings");
58
- }
59
- // Check if already initialized (e.g. shared context)
60
- _hfe.dimensions();
61
- _state = "ready";
62
- return;
309
+ const models = await getModelsApi();
310
+ const [vec] = await withTimeout(models.embed("x", { model: "default" }), PROBE_TIMEOUT_MS, "embedding boot-probe");
311
+ _dims = vec?.length;
312
+ _mode = "local";
313
+ _lastError = undefined;
63
314
  }
64
- catch {
65
- // Not initialized — init with modelsDir pointing at a USER-WRITABLE
66
- // location. On a sudo/root-owned global install the Flair package dir
67
- // (process.cwd()) is root-owned, so a model download into <cwd>/models
68
- // fails with EACCES and semantic search silently dies. The
69
- // model — and everything else Flair writes — must live under ~/.flair.
70
- //
71
- // NOTE: import.meta.dirname and __dirname are both undefined in Harper v5's
72
- // VM sandbox / worker threads, so we resolve from env + process.cwd().
73
- try {
74
- if (!_hfe) {
75
- _hfe = await import("harper-fabric-embeddings");
76
- }
77
- const modelsDir = resolveModelsDir();
78
- // Find the native addon binary explicitly to avoid __dirname-dependent
79
- // discovery in @node-llama-cpp which fails in Harper's VM sandbox.
80
- const platforms = ["linux-x64", "mac-arm64-metal", "mac-arm64", "win-x64"];
81
- let addonPath;
82
- for (const platform of platforms) {
83
- const candidate = join(process.cwd(), "node_modules", "@node-llama-cpp", platform, "bins", platform, "llama-addon.node");
84
- if (existsSync(candidate)) {
85
- addonPath = candidate;
86
- break;
87
- }
88
- }
89
- await _hfe.init({ modelsDir, ...(addonPath ? { addonPath } : {}) });
90
- _state = "ready";
91
- }
92
- catch (err) {
93
- _state = "failed";
94
- _initError = err.message || String(err);
95
- if (!_warnedOnce) {
96
- console.warn(`[embeddings] WARN: native embeddings unavailable, falling back to keyword-only search. Error: ${_initError}`);
97
- _warnedOnce = true;
98
- }
315
+ catch (err) {
316
+ _mode = "none";
317
+ _lastError = err?.message ?? String(err);
318
+ if (!_warnedOnce) {
319
+ console.warn(`[embeddings] WARN: native embeddings unavailable, falling back to keyword-only search. Error: ${_lastError}`);
320
+ _warnedOnce = true;
99
321
  }
100
322
  }
101
323
  }
324
+ function ensureProbeStarted() {
325
+ if (_probeStarted)
326
+ return;
327
+ _probeStarted = true;
328
+ probe().catch(() => { }); // probe() already catches internally; belt-and-suspenders
329
+ }
102
330
  /**
103
- * Generate an embedding vector for the given text.
104
- * Returns null if the embedding engine isn't available on this platform.
331
+ * Generate an embedding vector for the given text via Harper's `models.embed()`
332
+ * facade. Returns null if the embedding backend isn't available preserves
333
+ * the pre-migration null-on-failure contract.
334
+ *
335
+ * Phase 2 (flair#504): callers pass `inputType` — `'document'` for stored
336
+ * memory content (Memory.ts's dedup gate / post / put, auth-middleware.ts's
337
+ * backfill), `'query'` for a search query (SemanticSearch.ts,
338
+ * MemoryBootstrap.ts) — unconditionally, declaring intent. Whether that
339
+ * `inputType` actually reaches HFE 0.3.0 (which would prepend the matching
340
+ * `search_document: `/`search_query: ` prefix — see file header) is gated:
341
+ * `buildEmbedOptions()` is the single chokepoint that turns `inputType` into
342
+ * the options object, and it forwards `inputType` while THE GATE
343
+ * (`EMBEDDING_PREFIXES_ENABLED`) is on — the current default (flipped this
344
+ * PR; see the file header for why). Omitted (no second arg) always stays a
345
+ * no-op regardless of the gate, same as Phase 1.
105
346
  */
106
- export async function getEmbedding(text) {
347
+ export async function getEmbedding(text, inputType) {
348
+ ensureProbeStarted();
107
349
  try {
108
- await ensureInit();
109
- if (_state !== "ready" || !_hfe)
350
+ const models = await getModelsApi();
351
+ const [vec] = await models.embed(text, buildEmbedOptions(inputType));
352
+ if (!vec)
110
353
  return null;
111
- return await _hfe.embed(text);
354
+ // Self-heal on a live success — harper-fabric-embeddings' register() loads
355
+ // the model in the background and retries readiness on the next call, so
356
+ // a transient failure the boot probe caught needn't stay sticky forever.
357
+ _mode = "local";
358
+ _dims = vec.length;
359
+ _lastError = undefined;
360
+ return Array.from(vec);
112
361
  }
113
362
  catch (err) {
114
- console.error(`[embeddings] embed failed: ${err.message}`);
363
+ _mode = "none";
364
+ _lastError = err?.message ?? String(err);
365
+ console.error(`[embeddings] embed failed: ${_lastError}`);
115
366
  return null;
116
367
  }
117
368
  }
118
369
  /**
119
- * Check if the embedding engine is currently available.
120
- * If still uninitialized, attempts initialization first.
121
- * This ensures worker threads (which don't share the main thread's init)
122
- * get a chance to initialize before we give up.
370
+ * Check if the embedding engine is currently believed available.
371
+ * Called synchronously (SemanticSearch.ts gates its keyword-only fallback on
372
+ * this), so it reads the cached flag the boot probe and getEmbedding() keep
373
+ * current rather than awaiting a live call.
123
374
  */
124
- let _getModeInitAttempted = false;
125
375
  export function getMode() {
126
- if (_state === "ready")
127
- return "local";
128
- if (_state === "failed")
129
- return "none";
130
- // Still uninitialized — try direct check first
131
- if (_hfe) {
132
- try {
133
- _hfe.dimensions();
134
- _state = "ready";
135
- return "local";
136
- }
137
- catch {
138
- // fall through
139
- }
140
- }
141
- // Not yet initialized. Trigger async init on first call so subsequent
142
- // calls (including getEmbedding) will find the engine ready.
143
- if (!_getModeInitAttempted) {
144
- _getModeInitAttempted = true;
145
- ensureInit().catch(() => { }); // fire-and-forget
146
- }
147
- return "none";
376
+ ensureProbeStarted();
377
+ return _mode;
148
378
  }
379
+ /**
380
+ * Variant suffix — Kern's call (flair#504 Phase 2 review), NOT
381
+ * `FLAIR_EMBEDDING_MODEL`: prefix-on uses the SAME model/weights as Phase 1,
382
+ * so the base model id alone can't distinguish a prefixed vector from an
383
+ * unprefixed one. Without a distinct stamp, `flair reembed --stale-only`
384
+ * would see every row's `embeddingModel` already match `getModelId()` and
385
+ * skip them all, and `health.ts` would report a false "all uniform" state —
386
+ * this is the linchpin that makes stale-detection (and therefore the
387
+ * re-embed this flip triggers) possible at all. Only appended when
388
+ * `prefixesEnabled()` is true (see `getModelId()` below) — with THE GATE now
389
+ * on (this PR's default), `getModelId()` returns `<base>+searchprefix` by
390
+ * default, so every row embedded before this PR (stamped with the bare base
391
+ * id, since the gate was off when it was written) now reads as stale —
392
+ * exactly the signal the boot-keyed auto-migration runner
393
+ * (`resources/migrations/embedding-stamp.ts`) needs to re-embed them. That
394
+ * mass "stale" transition is this flip's intended payload, not a bug.
395
+ */
396
+ const EMBEDDING_VARIANT = "searchprefix";
149
397
  /**
150
398
  * Get the current embedding model identifier.
151
- * Used for stamping memories and detecting stale embeddings.
399
+ * Used for stamping memories and detecting stale embeddings. Reads the SAME
400
+ * `prefixesEnabled()` chokepoint `buildEmbedOptions()` does (see THE GATE's
401
+ * doc above `EMBEDDING_PREFIXES_ENABLED`) — gate on (default): bumps to
402
+ * `<base>+searchprefix` (see `EMBEDDING_VARIANT` above). Gate off (only
403
+ * reachable now via the bench-only override, see `harnessPrefixOverride()`):
404
+ * bare base id, no suffix. A prefixed vector and an unprefixed vector of the
405
+ * SAME text are genuinely different vectors (dedup must not short-circuit
406
+ * across them), and `--stale-only` needs a distinct string to target the
407
+ * rows that still need re-embedding. `+` is URL-safe and doesn't collide
408
+ * with the existing `-`/`_` id characters. `src/cli.ts` duplicates this
409
+ * exact gate-then-suffix logic for `--stale-only` (separate build target,
410
+ * see its own comment) — the two must never drift.
152
411
  */
153
412
  export function getModelId() {
154
- return process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
413
+ const base = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
414
+ return prefixesEnabled() ? `${base}+${EMBEDDING_VARIANT}` : base;
155
415
  }
156
416
  /**
157
417
  * Get embedding engine status for diagnostics.
418
+ * `dims`/`model` are sourced from what `models.embed()` actually produced
419
+ * (the boot probe or the last real call) — never from a backend's raw
420
+ * `dimensions()` accessor (Kern's Q3 hardening).
158
421
  */
159
422
  export function getStatus() {
160
- const mode = getMode();
161
- if (mode === "local" && _hfe) {
162
- try {
163
- return {
164
- mode,
165
- model: "nomic-embed-text-v1.5",
166
- dims: _hfe.dimensions(),
167
- };
168
- }
169
- catch {
170
- return { mode };
171
- }
423
+ ensureProbeStarted();
424
+ if (_mode === "local") {
425
+ return {
426
+ mode: _mode,
427
+ model: "nomic-embed-text-v1.5",
428
+ dims: _dims,
429
+ };
172
430
  }
173
431
  return {
174
- mode,
175
- error: _initError,
432
+ mode: _mode,
433
+ error: _lastError,
176
434
  };
177
435
  }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * entity-vocab.ts — the attention-plane entity vocabulary convention + validator.
3
+ *
4
+ * Foundation slice of the attention plane (flair#675, spec: FLAIR-ATTENTION-PLANE.md).
5
+ * One vocabulary, used everywhere an entity is referenced: a namespaced
6
+ * `type:value` string, lowercased type, drawn from a CLOSED, documented type
7
+ * set. See docs/entity-vocabulary.md for the full convention writeup — this
8
+ * module is the single source of truth the docs describe and every write
9
+ * path (WorkspaceState, OrgEvent, Memory, and eventually Relationship) should
10
+ * validate against before persisting an `entities` value.
11
+ *
12
+ * Matching is exact on the full `type:value` string — no prefix/regex
13
+ * matching, no case-folding beyond what's enforced here. This keeps the
14
+ * planned attention query (a future slice — NOT built here) a plain indexed
15
+ * equality lookup, not a scan.
16
+ *
17
+ * This module does ONLY vocabulary validation. It does not read/write any
18
+ * table, does not implement the attention query, and does not do collision
19
+ * surfacing — those are separate follow-up slices (see FLAIR-ATTENTION-PLANE.md
20
+ * Phase 1 query / Phase 2 collision surfacing).
21
+ */
22
+ /** The closed set of entity types. Extend deliberately — this list IS the vocabulary. */
23
+ export const ENTITY_TYPES = [
24
+ "repo",
25
+ "issue",
26
+ "customer",
27
+ "subsystem",
28
+ "agent",
29
+ "person",
30
+ ];
31
+ const ENTITY_TYPE_SET = new Set(ENTITY_TYPES);
32
+ export function isEntityType(type) {
33
+ return ENTITY_TYPE_SET.has(type);
34
+ }
35
+ /**
36
+ * A "slug" value: lowercase alphanumeric segments joined by single `-` or
37
+ * `_` separators. No leading/trailing separators, no doubled separators, no
38
+ * empty string. Used for `customer:`, `subsystem:`, `agent:`, `person:`.
39
+ */
40
+ const SLUG_RE = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
41
+ /**
42
+ * A single repo path segment (owner or name): lowercase alphanumeric with
43
+ * `.`, `-`, `_` allowed as internal separators (covers real-world repo/owner
44
+ * names like `tpsdev-ai`, `harper.fast`). No leading/trailing separator.
45
+ */
46
+ const REPO_SEGMENT_RE = /^[a-z0-9]+(?:[.\-_][a-z0-9]+)*$/;
47
+ /** `<owner>/<name>` — both segments valid, exactly one `/`. */
48
+ function isValidRepoValue(value) {
49
+ const parts = value.split("/");
50
+ if (parts.length !== 2)
51
+ return false;
52
+ const [owner, name] = parts;
53
+ return REPO_SEGMENT_RE.test(owner) && REPO_SEGMENT_RE.test(name);
54
+ }
55
+ /** `<owner>/<name>#<n>` — a valid repo value, `#`, then a positive integer (no leading zero). */
56
+ function isValidIssueValue(value) {
57
+ const hashIndex = value.indexOf("#");
58
+ if (hashIndex === -1)
59
+ return false;
60
+ const repoPart = value.slice(0, hashIndex);
61
+ const numberPart = value.slice(hashIndex + 1);
62
+ if (!/^[1-9][0-9]*$/.test(numberPart))
63
+ return false;
64
+ return isValidRepoValue(repoPart);
65
+ }
66
+ function isValidSlugValue(value) {
67
+ return SLUG_RE.test(value);
68
+ }
69
+ const VALUE_VALIDATORS = {
70
+ repo: isValidRepoValue,
71
+ issue: isValidIssueValue,
72
+ customer: isValidSlugValue,
73
+ subsystem: isValidSlugValue,
74
+ agent: isValidSlugValue,
75
+ person: isValidSlugValue,
76
+ };
77
+ /**
78
+ * Split an entity string on its first `:` into { type, value }. Returns null
79
+ * for anything that can't possibly be a well-formed entity string (no colon,
80
+ * empty type, empty value) — callers still need isValidEntity()/type-set
81
+ * membership to confirm it's actually valid.
82
+ */
83
+ export function parseEntity(entity) {
84
+ if (typeof entity !== "string" || entity.length === 0)
85
+ return null;
86
+ const colonIndex = entity.indexOf(":");
87
+ if (colonIndex <= 0)
88
+ return null; // no colon, or colon is the first char (empty type)
89
+ const type = entity.slice(0, colonIndex);
90
+ const value = entity.slice(colonIndex + 1);
91
+ if (value.length === 0)
92
+ return null;
93
+ return { type, value };
94
+ }
95
+ /**
96
+ * Full validation: well-formed `type:value`, type is in the closed set, and
97
+ * the value matches that type's grammar. This is the ONE gate every write
98
+ * path should call before persisting an entity string.
99
+ */
100
+ export function isValidEntity(entity) {
101
+ if (typeof entity !== "string")
102
+ return false;
103
+ const parsed = parseEntity(entity);
104
+ if (!parsed)
105
+ return false;
106
+ if (!isEntityType(parsed.type))
107
+ return false;
108
+ return VALUE_VALIDATORS[parsed.type](parsed.value);
109
+ }
110
+ /**
111
+ * Validate an `entities` field value (expected: string[] | undefined | null).
112
+ * `undefined`/`null` is treated as valid — the field is additive/optional,
113
+ * absence is not an error (mirrors Presence's `activityUpdatedAt` pattern).
114
+ * A non-array, non-null/undefined value is reported invalid as a whole.
115
+ */
116
+ export function validateEntities(entities) {
117
+ if (entities === undefined || entities === null)
118
+ return { valid: true, invalid: [] };
119
+ if (!Array.isArray(entities))
120
+ return { valid: false, invalid: [String(entities)] };
121
+ const invalid = [];
122
+ for (const entity of entities) {
123
+ if (!isValidEntity(entity))
124
+ invalid.push(typeof entity === "string" ? entity : String(entity));
125
+ }
126
+ return { valid: invalid.length === 0, invalid };
127
+ }
128
+ /**
129
+ * Convenience helper for Harper resource write paths (WorkspaceState.ts,
130
+ * OrgEvent.ts, Memory.ts): validates `content.entities` and returns a ready-
131
+ * to-return 400 Response on failure, or `null` if the field is absent/valid.
132
+ * Callers just do: `const err = invalidEntitiesResponse(content.entities); if (err) return err;`
133
+ */
134
+ export function invalidEntitiesResponse(entities) {
135
+ const result = validateEntities(entities);
136
+ if (result.valid)
137
+ return null;
138
+ return new Response(JSON.stringify({ error: "invalid_entities", invalid: result.invalid }), { status: 400, headers: { "Content-Type": "application/json" } });
139
+ }