@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.
- package/README.md +10 -7
- package/SECURITY.md +24 -2
- package/config.yaml +32 -5
- package/dist/cli.js +1755 -213
- package/dist/deploy.js +3 -4
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/runner.js +82 -12
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +23 -0
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +52 -53
- package/dist/resources/MemoryBootstrap.js +422 -76
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +8 -1
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/Presence.js +218 -19
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +98 -25
- package/dist/resources/SemanticSearch.js +85 -317
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/WorkspaceState.js +11 -0
- package/dist/resources/agent-auth.js +60 -2
- package/dist/resources/auth-middleware.js +18 -9
- package/dist/resources/bm25.js +12 -6
- package/dist/resources/collision-lib.js +157 -0
- package/dist/resources/embeddings-boot.js +149 -0
- package/dist/resources/embeddings-provider.js +364 -106
- package/dist/resources/entity-vocab.js +139 -0
- package/dist/resources/health.js +97 -6
- package/dist/resources/mcp-client-metadata-fields.js +112 -0
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-tools.js +88 -10
- package/dist/resources/memory-reflect-lib.js +289 -0
- package/dist/resources/migration-boot.js +143 -0
- package/dist/resources/migrations/dir-safety.js +71 -0
- package/dist/resources/migrations/embedding-stamp.js +178 -0
- package/dist/resources/migrations/envelope.js +43 -0
- package/dist/resources/migrations/export.js +38 -0
- package/dist/resources/migrations/ledger.js +55 -0
- package/dist/resources/migrations/lock.js +154 -0
- package/dist/resources/migrations/progress.js +31 -0
- package/dist/resources/migrations/registry.js +36 -0
- package/dist/resources/migrations/risk-policy.js +23 -0
- package/dist/resources/migrations/runner.js +456 -0
- package/dist/resources/migrations/snapshot.js +127 -0
- package/dist/resources/migrations/source-fields.js +93 -0
- package/dist/resources/migrations/space.js +73 -0
- package/dist/resources/migrations/state.js +64 -0
- package/dist/resources/migrations/status.js +39 -0
- package/dist/resources/migrations/synthetic-test-migration.js +93 -0
- package/dist/resources/migrations/types.js +9 -0
- package/dist/resources/presence-internal.js +29 -0
- package/dist/resources/provenance.js +50 -0
- package/dist/resources/rate-limiter.js +8 -1
- package/dist/resources/scoring.js +124 -5
- package/dist/resources/semantic-retrieval-core.js +317 -0
- package/dist/version-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/event.graphql +5 -0
- package/schemas/memory.graphql +66 -0
- package/schemas/schema.graphql +5 -43
- package/schemas/workspace.graphql +3 -0
- package/dist/resources/IngestEvents.js +0 -162
- package/dist/resources/IssueTokens.js +0 -19
- package/dist/resources/ObsAgentSnapshot.js +0 -13
- package/dist/resources/ObsEventFeed.js +0 -13
- package/dist/resources/ObsOffice.js +0 -19
- package/dist/resources/ObservationCenter.js +0 -23
- package/ui/observation-center.html +0 -385
|
@@ -1,16 +1,241 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* embeddings-provider.ts
|
|
3
3
|
*
|
|
4
|
-
* Wrapper around
|
|
4
|
+
* Wrapper around Harper's native `models.embed()` facade for Flair resources.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
|
109
|
-
|
|
350
|
+
const models = await getModelsApi();
|
|
351
|
+
const [vec] = await models.embed(text, buildEmbedOptions(inputType));
|
|
352
|
+
if (!vec)
|
|
110
353
|
return null;
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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:
|
|
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
|
+
}
|