@tpsdev-ai/flair 0.27.1 → 0.29.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.
@@ -0,0 +1,250 @@
1
+ /**
2
+ * graph-heal.ts — the recall graph-heal OBSERVABILITY migration.
3
+ *
4
+ * WHAT ACTUALLY HEALS: nothing in this file. The heal is entirely
5
+ * schema-driven — `schemas/memory.graphql`'s `embedding` field now declares
6
+ * `@indexed(type: "HNSW", M: 16)` (M:16 = Harper's own default, a zero
7
+ * behavior change). On the first boot after upgrade, Harper structurally
8
+ * diffs the persisted per-attribute HNSW descriptor (built with NO options on
9
+ * every pre-this-change install) against the schema and, because
10
+ * `canonicalizeIndexOptions` does NOT inject defaults (`{type:"HNSW"}` and
11
+ * `{type:"HNSW",M:16}` are DIFFERENT canonical keys — verified against the
12
+ * installed @harperfast/harper source, resources/databases.js:
13
+ * canonicalizeIndexOptions + the `indexOptionsChanged` reset of
14
+ * `lastIndexedKey` to undefined), clears the graph store and rebuilds it
15
+ * CLEANLY from the already-correct stored vectors. That rebuild heals the
16
+ * stale/asymmetric reverse-edge corruption that an OLDER (pre-fix) Harper's
17
+ * INCREMENTAL HNSW update left on disk during the July bulk embedding-stamp
18
+ * re-embed — a fresh index of the same vectors finds the true neighbors, the
19
+ * mangled live graph did not. The corruption is HISTORICAL: the installed
20
+ * Harper (5.1.22) already fixes that incremental-update path —
21
+ * HierarchicalNavigableSmallWorld reconstructs the prior vector from the
22
+ * stored node and carries its real connections into the reverse-edge cleanup
23
+ * the old build skipped — so current Harper no longer produces it; it just
24
+ * still persists on disk in stores re-embedded under the old engine. The heal
25
+ * is therefore version-agnostic: it repairs any such store regardless of the
26
+ * Harper now running. No re-embed happens — only the graph is rebuilt from the
27
+ * vectors already on disk.
28
+ *
29
+ * WHY THIS MIGRATION EXISTS ANYWAY: the heal is invisible — Harper triggers
30
+ * it, this code cannot force it, and there is no Harper API to observe it
31
+ * directly. So this migration's ONLY job is to VERIFY + LEDGER that recall is
32
+ * healthy after the boot, so the heal is auditable and version-gated (runs
33
+ * once per upgrade, then the runner's state-file short-circuit — state.ts —
34
+ * skips it). It touches ZERO rows: riskClass 'derived-only', countPending()
35
+ * is always 0, run() writes one structural-only OrgEvent and returns
36
+ * `processed: 0`. The runner (resources/migrations/runner.ts) then records
37
+ * success in the state file, short-circuiting every subsequent boot at this
38
+ * version.
39
+ *
40
+ * NON-THROWING throughout (invariant II — the runner halts-not-bricks): a
41
+ * canary that can't run, an index still mid-rebuild, or a ledger-write
42
+ * failure all degrade to "not verified this boot, retry next boot" or a
43
+ * best-effort skip — never an exception into the boot path.
44
+ *
45
+ * DEFENSE-IN-DEPTH RULE (a prudent, version-independent default — NOT a
46
+ * workaround for a live bug): any BULK re-embed of the corpus should be paired
47
+ * with a structural graph REBUILD (a full runIndexing pass) rather than
48
+ * relying on the engine's incremental `index()` updates to converge. The
49
+ * installed Harper (5.1.22) DOES converge — its update path is fixed — but the
50
+ * rebuild keeps recall correct regardless of engine version, closes the rare
51
+ * decode-failure edge case, and is what heals a store already corrupted on
52
+ * disk by an older engine. The lever flair uses for that rebuild is a
53
+ * structural-schema change (the M:16 descriptor bump that ships with this
54
+ * migration) — see the guard comments in
55
+ * resources/migrations/embedding-stamp.ts and the `flair reembed` path in
56
+ * src/cli.ts. Do NOT use resources/MemoryReindex.ts's `_reindex` for graph
57
+ * correctness — it re-PUTs the same vector through the incremental path and
58
+ * does not rebuild the graph.
59
+ */
60
+ import { databases } from "@harperfast/harper";
61
+ import { existsSync, readFileSync } from "node:fs";
62
+ import { dirname, join } from "node:path";
63
+ import { fileURLToPath } from "node:url";
64
+ export const GRAPH_HEAL_ID = "graph-heal";
65
+ /** How many rows to scan for a canary that carries a real embedding. Bounded — detect() must stay cheap. */
66
+ const CANARY_SCAN_LIMIT = 8;
67
+ function defaultMemoryTable() {
68
+ return databases.flair.Memory;
69
+ }
70
+ function defaultOrgEventTable() {
71
+ return databases.flair.OrgEvent;
72
+ }
73
+ /**
74
+ * Same "resolve the running package's own version" idiom as
75
+ * resources/health.ts / resources/migration-boot.ts. Inlined (not imported
76
+ * from migration-boot) to avoid an import cycle: migration-boot → registry →
77
+ * graph-heal. Purely informational — it only stamps the ledger detail blob;
78
+ * a "dev" fallback is harmless.
79
+ */
80
+ function resolveRunningVersionLocal() {
81
+ try {
82
+ const here = dirname(fileURLToPath(import.meta.url));
83
+ const candidates = [
84
+ join(here, "..", "..", "..", "package.json"), // dist/resources/migrations → root
85
+ join(here, "..", "..", "package.json"),
86
+ ];
87
+ for (const p of candidates) {
88
+ if (existsSync(p)) {
89
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
90
+ if (pkg.version)
91
+ return String(pkg.version);
92
+ }
93
+ }
94
+ }
95
+ catch {
96
+ /* fall through */
97
+ }
98
+ return process.env.npm_package_version ?? "dev";
99
+ }
100
+ /**
101
+ * Pick a canary row that carries a real embedding vector: scan a bounded
102
+ * handful of rows and `.get()` the first one whose stored `embedding` is a
103
+ * non-empty array. `.get()` (a primary-key read) always returns the full
104
+ * record including `embedding`, independent of any `select`/projection
105
+ * quirk. Returns null when the corpus has no embedded rows at all — a fresh
106
+ * or empty instance, where there is simply nothing to heal or verify.
107
+ */
108
+ async function pickCanary(table) {
109
+ for await (const row of table.search({ select: ["id"], limit: CANARY_SCAN_LIMIT })) {
110
+ const id = String(row.id ?? "");
111
+ if (!id)
112
+ continue;
113
+ const full = await table.get(id);
114
+ const emb = full?.embedding;
115
+ if (Array.isArray(emb) && emb.length > 0)
116
+ return { id, embedding: emb };
117
+ }
118
+ return null;
119
+ }
120
+ /**
121
+ * The id of the HNSW nearest neighbor of `target` — the exact cosine-sort
122
+ * query the retrieval path (resources/semantic-retrieval-core.ts) uses,
123
+ * bounded to rank 1. Throws if the index is unavailable / mid-rebuild; the
124
+ * caller treats a throw as "not verified this boot".
125
+ */
126
+ async function topNeighborId(table, target) {
127
+ for await (const row of table.search({
128
+ sort: { attribute: "embedding", target, distance: "cosine" },
129
+ select: ["id"],
130
+ limit: 1,
131
+ })) {
132
+ const id = row.id;
133
+ return id ? String(id) : null;
134
+ }
135
+ return null;
136
+ }
137
+ /**
138
+ * Canary self-recall: a healthy HNSW graph returns the canary row itself as
139
+ * the rank-1 neighbor of its OWN stored vector. Cheap, read-only, bounded —
140
+ * and the cleanest black-box proof the graph rebuilt and is serving (a
141
+ * mid-rebuild index throws or returns nothing; a store with no embedded rows
142
+ * has no canary). Never throws (invariant II).
143
+ */
144
+ async function recallHealthy(table) {
145
+ try {
146
+ const canary = await pickCanary(table);
147
+ if (!canary)
148
+ return { verified: false, hasData: false };
149
+ const top = await topNeighborId(table, canary.embedding);
150
+ return { verified: top === canary.id, hasData: true };
151
+ }
152
+ catch {
153
+ return { verified: false, hasData: true };
154
+ }
155
+ }
156
+ /** Count rows carrying a real (non-hash-fallback) embedding — the vectors that live in the HNSW graph. Best-effort. */
157
+ async function countEmbeddedVectors(table) {
158
+ try {
159
+ let n = 0;
160
+ for await (const row of table.search({ select: ["embeddingModel"] })) {
161
+ const m = row.embeddingModel;
162
+ if (typeof m === "string" && m.length > 0 && m !== "hash-512d")
163
+ n++;
164
+ }
165
+ return n;
166
+ }
167
+ catch {
168
+ return null;
169
+ }
170
+ }
171
+ /**
172
+ * Structural-only ledger OrgEvent (Sherlock discipline, same as
173
+ * resources/migrations/ledger.ts): counts, outcome, version — NEVER a memory
174
+ * id or content (the canary row's id is deliberately omitted). Written via an
175
+ * internal (no-HTTP-context) put — resolveAgentAuth(undefined) → internal, the
176
+ * same trusted server-internal write path the migration ledger already uses.
177
+ */
178
+ async function writeGraphHealEvent(obs, table) {
179
+ const countNote = obs.embeddedVectorCount != null
180
+ ? ` (${obs.embeddedVectorCount} embedded vector${obs.embeddedVectorCount === 1 ? "" : "s"})`
181
+ : "";
182
+ await table.put({
183
+ id: `migration-graph-heal-verified-${obs.at}`,
184
+ authorId: "flair-migrations",
185
+ kind: "migration",
186
+ scope: "full",
187
+ summary: `HNSW graph-heal: recall ${obs.verified ? "verified healthy" : "unconfirmed"}${countNote}`,
188
+ detail: JSON.stringify({
189
+ migrationId: GRAPH_HEAL_ID,
190
+ verified: obs.verified,
191
+ canaryRank1: obs.verified,
192
+ embeddedVectorCount: obs.embeddedVectorCount,
193
+ runningVersion: obs.runningVersion,
194
+ verifiedAt: obs.at,
195
+ }),
196
+ refId: GRAPH_HEAL_ID,
197
+ createdAt: obs.at,
198
+ });
199
+ }
200
+ /**
201
+ * `getTable`/`getOrgEventTable`/`getVersion`/`now` are injectable so tests can
202
+ * exercise detect/countPending/run against fakes (matching the mocking style
203
+ * of embedding-stamp.ts / the unit tests). The real HNSW self-recall + ledger
204
+ * path is exercised end-to-end in
205
+ * test/integration/hnsw-graph-heal-e2e.test.ts.
206
+ */
207
+ export function createGraphHealMigration(getTable = defaultMemoryTable, getOrgEventTable = defaultOrgEventTable, getVersion = resolveRunningVersionLocal, now = () => new Date()) {
208
+ return {
209
+ id: GRAPH_HEAL_ID,
210
+ riskClass: "derived-only",
211
+ affectsTables: ["Memory"],
212
+ /**
213
+ * True only when recall is confirmed healthy AND there is embedded data
214
+ * to verify — i.e. the graph rebuilt and is serving. Returns false (the
215
+ * runner marks the migration completed for this boot WITHOUT writing a
216
+ * success state entry, so detect() runs again next boot) when the index
217
+ * is still mid-rebuild or the store has nothing embedded yet. This is the
218
+ * enrollment gate: only a confirmed-healthy boot proceeds to run() +
219
+ * ledger + the version short-circuit.
220
+ */
221
+ async detect() {
222
+ const { verified } = await recallHealthy(getTable());
223
+ return verified;
224
+ },
225
+ /** Observe-only — never any pending row work. */
226
+ async countPending() {
227
+ return 0;
228
+ },
229
+ /**
230
+ * Called exactly once (countPending() is 0 → the runner's batch loop runs
231
+ * once and breaks). Re-verifies recall for the ledger snapshot, counts
232
+ * the embedded vectors, writes ONE structural-only OrgEvent, and returns
233
+ * `processed: 0` so the completion gate (count+marker, derived-only)
234
+ * passes and the runner records success → short-circuit next boot.
235
+ */
236
+ async run(_batchSize) {
237
+ const table = getTable();
238
+ const { verified } = await recallHealthy(table);
239
+ const embeddedVectorCount = await countEmbeddedVectors(table);
240
+ try {
241
+ await writeGraphHealEvent({ verified, embeddedVectorCount, runningVersion: getVersion(), at: now().toISOString() }, getOrgEventTable());
242
+ }
243
+ catch {
244
+ // Observability must never brick the runner — a ledger-write failure
245
+ // is swallowed; the heal itself already happened schema-side.
246
+ }
247
+ return { processed: 0, touchedIds: [] };
248
+ },
249
+ };
250
+ }
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * registry.ts — the MigrationRegistry: an ordered list of registered
3
- * migrations. embedding-stamp always registers; the synthetic CI-only
4
- * variant registers ONLY when shouldRegisterSyntheticMigration() is true
5
- * (see synthetic-test-migration.ts's doc for the exact gating rule).
3
+ * migrations. embedding-stamp and graph-heal always register; the synthetic
4
+ * CI-only variant registers ONLY when shouldRegisterSyntheticMigration() is
5
+ * true (see synthetic-test-migration.ts's doc for the exact gating rule).
6
6
  */
7
7
  import { createEmbeddingStampMigration } from "./embedding-stamp.js";
8
+ import { createGraphHealMigration } from "./graph-heal.js";
8
9
  import { createSyntheticTestMigration, shouldRegisterSyntheticMigration } from "./synthetic-test-migration.js";
9
10
  export class MigrationRegistry {
10
11
  migrations = [];
@@ -23,12 +24,17 @@ export class MigrationRegistry {
23
24
  }
24
25
  }
25
26
  /**
26
- * Builds the production registry. Always includes embedding-stamp;
27
- * conditionally includes the CI-only synthetic migration.
27
+ * Builds the production registry. Always includes embedding-stamp and
28
+ * graph-heal; conditionally includes the CI-only synthetic migration.
29
+ *
30
+ * Order note: graph-heal registers AFTER embedding-stamp so that if a boot
31
+ * ever runs both, the (verify-only) recall check ledgers the state AFTER any
32
+ * re-embed work that cycle already applied — never a stale pre-embed reading.
28
33
  */
29
34
  export function buildRegistry(env = process.env) {
30
35
  const registry = new MigrationRegistry();
31
36
  registry.register(createEmbeddingStampMigration());
37
+ registry.register(createGraphHealMigration());
32
38
  if (shouldRegisterSyntheticMigration(env)) {
33
39
  registry.register(createSyntheticTestMigration());
34
40
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * models-dir.ts — the ONE place that decides where model GGUFs live.
3
+ *
4
+ * Extracted from embeddings-provider.ts (flair#815) so the reranker
5
+ * (rerank-provider.ts) can share the exact same resolution WITHOUT importing
6
+ * embeddings-provider: several unit-isolated tests `mock.module()` the whole
7
+ * embeddings-provider module (with only the named exports THEY consume), and
8
+ * bun's module cache is process-wide — so any new cross-module import of
9
+ * embeddings-provider from another resource makes those mocks incomplete and
10
+ * kills unrelated test files at module load. This module is tiny, pure
11
+ * node-stdlib, and never mocked, so both providers (and any future model
12
+ * consumer) can depend on it safely.
13
+ */
14
+ import { existsSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ /**
18
+ * Resolve the directory model GGUFs live in / download into — shared by the
19
+ * embedding engine (embeddings-boot.ts's `register()` options) and the
20
+ * cross-encoder reranker (rerank-provider.ts, flair#815 — it used to hardcode
21
+ * `<cwd>/models`, silently failing open wherever Harper's cwd wasn't the
22
+ * models location).
23
+ *
24
+ * Resolution order (everything writable, never the read-only package dir):
25
+ * 1. FLAIR_MODELS_DIR — explicit operator/docker override.
26
+ * 2. <ROOTPATH>/models — Harper's data dir (Flair passes ROOTPATH =
27
+ * ~/.flair/data when it spawns Harper). User-
28
+ * owned and writable even on sudo-global installs.
29
+ * 3. <cwd>/models — ONLY if a model already lives there. Backward
30
+ * compat for existing writable installs that
31
+ * downloaded into the package dir before this fix;
32
+ * never used as a download target on fresh installs.
33
+ * 4. ~/.flair/data/models — last-resort default when ROOTPATH is unset.
34
+ *
35
+ * The chosen dir is always writable, so the embeddings engine can download the
36
+ * model on first use without hitting EACCES on a root-owned package dir.
37
+ *
38
+ * Tested independently (test/unit/embeddings-models-dir.test.ts, via
39
+ * embeddings-provider's re-export) as the single documented source of truth
40
+ * for this default.
41
+ */
42
+ export function resolveModelsDir() {
43
+ const override = process.env.FLAIR_MODELS_DIR;
44
+ if (override)
45
+ return override;
46
+ const rootPath = process.env.ROOTPATH;
47
+ if (rootPath)
48
+ return join(rootPath, "models");
49
+ // Backward compat: a prior (writable) install may have the model cached in
50
+ // the package dir. Reuse it rather than re-downloading — but only if present.
51
+ const cwdModels = join(process.cwd(), "models");
52
+ if (existsSync(cwdModels))
53
+ return cwdModels;
54
+ return join(homedir(), ".flair", "data", "models");
55
+ }
@@ -127,8 +127,8 @@
127
127
  *
128
128
  * `remEligible` — MUST be `false` for every entry in v1 (typed as the
129
129
  * literal `false`, not `boolean`, so a stray `true` is a compile error, not
130
- * just a runtime policy mistake). REM's nightly gather step
131
- * (specs/FLAIR-NIGHTLY-REM.md §11) is hardcoded to `GET /Memory?agentId=…`
130
+ * just a runtime policy mistake). REM's nightly gather step (see docs/rem.md)
131
+ * is hardcoded to `GET /Memory?agentId=…`
132
132
  * — single table, single agent, by construction. There is no multi-table
133
133
  * distillation input path to opt into yet; the field exists so a future
134
134
  * REM generalization doesn't require a breaking schema change to every