@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.
package/README.md CHANGED
@@ -67,7 +67,7 @@ If you need any of those specifically, use them. If you need crypto-pinned ident
67
67
 
68
68
  Anthropic shipped [Claude Dreams](https://platform.claude.com/docs/en/managed-agents/dreams) (research preview, April 2026) — async pipeline that reads a memory store + session transcripts and produces a curated output store: duplicates merged, stale entries replaced, insights surfaced. Validates the category: agent memory accumulates drift and needs cleanup.
69
69
 
70
- Flair ships both the on-demand curation surface (`flair rem rapid`) AND the scheduled nightly cycle, per the [FLAIR-NIGHTLY-REM spec](specs/FLAIR-NIGHTLY-REM.md) and its [in-process distillation slice](specs/FLAIR-NIGHTLY-REM-SLICE-2-DISTILLATION.md). Config recipe (Ollama zero-key default, hosted-provider egress warning, clustered-deploy rules): [`docs/rem.md`](docs/rem.md).
70
+ Flair ships both the on-demand curation surface (`flair rem rapid`) AND the scheduled nightly cycle, per [`docs/rem.md`](docs/rem.md) and its [distillation UX design note](docs/notes/rem-ux.md). **REM requires a configured generative backend** (Ollama, OpenAI, Anthropic, etc.) — without one, `flair rem rapid` fails with `Reflection error: No generative backend configured`. Config recipe (Ollama zero-key default, hosted-provider egress warning, clustered-deploy rules): [`docs/rem.md`](docs/rem.md).
71
71
 
72
72
  - **`flair rem rapid`** — reflects and distills server-side by default, staging candidates in one bounded call. `--focus {lessons_learned, patterns, decisions, errors}` mirrors Dreams' `instructions` parameter. `--prompt-only` falls back to the bring-your-own-model handoff. Outputs *candidates*, not a wholesale store swap.
73
73
  - **`flair rem candidates` / `flair rem promote <id> --rationale "<why>"` / `flair rem reject <id>`** — review and promote distilled candidates with required rationale.
@@ -10,7 +10,7 @@
10
10
  * To add a new built-in:
11
11
  * - YAML: write `src/bridges/builtins/<name>.ts` exporting a `YamlBridgeDescriptor`
12
12
  * - Code plugin: write `src/bridges/builtins/<name>.ts` exporting a `MemoryBridge`
13
- * Then register it here. See specs/FLAIR-BRIDGES.md §2 for shapes A/B.
13
+ * Then register it here. See docs/bridges.md § Two shapes for shapes A/B.
14
14
  */
15
15
  import { agenticStackDescriptor } from "./agentic-stack.js";
16
16
  import { claudeProjectMemoryBridge } from "./claude-project.js";
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Bridge discovery — scans the four conventional sources defined in
3
- * FLAIR-BRIDGES.md §7:
3
+ * docs/bridges.md § Discovery:
4
4
  *
5
5
  * 1. Project YAML: <cwd>/.flair-bridge/*.yaml
6
6
  * 2. User YAML: ~/.flair/bridges/*.yaml
@@ -53,7 +53,7 @@ export async function loadCodePlugin(discovered, opts = {}) {
53
53
  field: "exports",
54
54
  expected: "named `bridge` export or default export implementing MemoryBridge",
55
55
  got: typeof mod === "object" && mod !== null ? `exports=${Object.keys(mod).join(",") || "(empty)"}` : typeof mod,
56
- hint: `flair-bridge-<name> packages must export \`bridge\` (or default-export) a MemoryBridge. See specs/FLAIR-BRIDGES.md §6`,
56
+ hint: `flair-bridge-<name> packages must export \`bridge\` (or default-export) a MemoryBridge. See docs/bridges.md § Shape B`,
57
57
  });
58
58
  }
59
59
  // Validate the shape. Keep this minimal — we trust the plugin author
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Bridge scaffolding — emits a working starter for a new bridge.
3
3
  *
4
- * From FLAIR-BRIDGES.md §2: "Scaffold & test in-tree. `flair bridge
4
+ * From docs/bridges.md § Two shapes: "Scaffold & test in-tree. `flair bridge
5
5
  * scaffold <name>` emits a working starter; `flair bridge test <name>`
6
6
  * runs a round-trip diff." This file handles the `scaffold` half.
7
7
  *
@@ -34,7 +34,7 @@ function fileDescriptorTemplate(name) {
34
34
  # Generated by \`flair bridge scaffold ${name} --file\`.
35
35
  # Edit to match your foreign system, then run \`flair bridge test ${name}\`.
36
36
  #
37
- # Spec: see FLAIR-BRIDGES.md §4 (memory schema) and §5 (YAML shape).
37
+ # Spec: see docs/bridges.md § The memory record and § Shape A.
38
38
  name: ${name}
39
39
  version: 1
40
40
  kind: file
@@ -90,7 +90,7 @@ function apiIndexTemplate(name) {
90
90
  * flair-bridge-${name}
91
91
  *
92
92
  * Generated by \`flair bridge scaffold ${name} --api\`.
93
- * Spec: see FLAIR-BRIDGES.md §4 (memory schema) and §6 (code plugin shape).
93
+ * Spec: see docs/bridges.md § The memory record and § Shape B.
94
94
  *
95
95
  * After editing, run:
96
96
  * flair bridge test ${name}
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Types for the Flair memory bridge plugin system.
3
3
  *
4
- * Mirrors the contract in specs/FLAIR-BRIDGES.md. These types are the
4
+ * Mirrors the contract in docs/bridges.md. These types are the
5
5
  * public surface a bridge author (human or agent) targets.
6
6
  *
7
7
  * IMPORTANT: the YAML bridge format (shape A) also conforms to these types
package/dist/cli.js CHANGED
@@ -384,6 +384,33 @@ export function detectOpsApiAllInterfacesBind(portValue) {
384
384
  }
385
385
  return { allInterfaces: true, boundHost: null };
386
386
  }
387
+ /**
388
+ * Decide the source `flair init` should use for the admin password when no
389
+ * explicit `--admin-pass` / `--admin-pass-file` / env var was given
390
+ * (flair#827).
391
+ *
392
+ * Before this existed, init ALWAYS generated a fresh random password and
393
+ * overwrote `~/.flair/admin-pass` on every run — including a re-run against
394
+ * an install that was already bootstrapped and working (e.g. following
395
+ * `flair doctor`'s ops-bind finding, whose only prescribed remedy is
396
+ * re-running `flair init`). Harper's `HDB_ADMIN_PASSWORD` env var only seeds
397
+ * a brand-new install's user record — it does NOT rotate an existing user's
398
+ * stored password hash on every boot. So overwriting the file desynced it
399
+ * from what Harper actually had persisted, and the very next ops-API call in
400
+ * that SAME init run (seeding the agent) failed with a 401 "Login failed",
401
+ * breaking working auth on an install that had nothing wrong with its
402
+ * credentials.
403
+ *
404
+ * An admin-pass file that already exists on disk IS the working install's
405
+ * password — `flair init` is the only thing that ever writes it — so reuse
406
+ * it instead of generating a new one. That makes re-init idempotent: safe to
407
+ * run again at any time without risking the instance's auth. Deliberately
408
+ * rotating the admin password is a separate operation, not a `flair init`
409
+ * side effect.
410
+ */
411
+ export function resolveInitAdminPasswordSource(adminPassFileExists) {
412
+ return adminPassFileExists ? "reuse-existing" : "generate-new";
413
+ }
387
414
  // ─── Ops-socket permission posture (flair#763) ─────────────────────────────────
388
415
  //
389
416
  // The ops API domain socket (dataDir/operations-server) is protected by a
@@ -1846,6 +1873,39 @@ const __pkgVersion = (() => {
1846
1873
  return "unknown";
1847
1874
  }
1848
1875
  })();
1876
+ /**
1877
+ * The `@tpsdev-ai/flair-mcp` spec written into a client's MCP config.
1878
+ *
1879
+ * PINNED, deliberately. A bare `npx -y @tpsdev-ai/flair-mcp` re-resolves to
1880
+ * whatever is currently published on EVERY agent session — so a single bad
1881
+ * publish (stolen credentials, a malicious commit that clears review, or a
1882
+ * compromised dependency of the MCP package) reaches every wired user
1883
+ * silently, with no lockfile and no review step in the path. The postmark-mcp
1884
+ * incident was exactly this shape: a legitimate publish by the legitimate
1885
+ * owner, propagating for 16 days before anyone noticed. Worse, a yank does
1886
+ * not help — unpinned clients keep resolving latest.
1887
+ *
1888
+ * Our publish side is already hardened (OIDC staged publish, human 2FA at the
1889
+ * release gate), but that defends against credential theft, not against a bad
1890
+ * version being published legitimately. The consumer side is where that gap
1891
+ * closes, and pinning is what closes it: a wired client keeps running the
1892
+ * exact version that was current when it was wired, and moving forward
1893
+ * becomes a deliberate act.
1894
+ *
1895
+ * flair and flair-mcp ship in version lockstep from this monorepo, so the
1896
+ * running CLI's own version is the correct pin. `flair init` re-run rewires
1897
+ * to the then-current version; see the upgrade-rewire follow-up issue for
1898
+ * making `flair upgrade` do the same.
1899
+ *
1900
+ * Falls back to the unpinned spec only when the version can't be read, which
1901
+ * is the same condition under which `--version` reports "unknown" — a broken
1902
+ * install, where a working MCP wiring matters more than a precise pin.
1903
+ */
1904
+ export function mcpServerSpec(version = __pkgVersion) {
1905
+ return version && version !== "unknown"
1906
+ ? `@tpsdev-ai/flair-mcp@${version}`
1907
+ : "@tpsdev-ai/flair-mcp";
1908
+ }
1849
1909
  const program = new Command();
1850
1910
  program.name("flair").version(__pkgVersion, "-v, --version");
1851
1911
  // ─── CLI↔server version handshake (flair#695 §B) ────────────────────────────
@@ -2117,9 +2177,10 @@ program
2117
2177
  selectedClients.push(clientOpt);
2118
2178
  }
2119
2179
  // Admin password: determine from opts, env, or generate
2120
- // Priority: 1) --admin-pass-file, 2) env vars, 3) generate new
2180
+ // Priority: 1) --admin-pass-file, 2) env vars, 3) reuse existing file, 4) generate new
2121
2181
  let adminPass;
2122
2182
  let passwordSource = "generated";
2183
+ let reusedExistingAdminPass = false;
2123
2184
  // Warn if --admin-pass is passed inline (not from env)
2124
2185
  if (shouldShowInlineSecretWarning(opts.adminPass, false, new Set(["--admin-pass"]), "--admin-pass")) {
2125
2186
  console.error("warning: --admin-pass passed inline. Consider --admin-pass-file <path> or FLAIR_ADMIN_PASS env " +
@@ -2151,34 +2212,65 @@ program
2151
2212
  passwordSource = "env"; // Treat same as env for display purposes
2152
2213
  }
2153
2214
  else {
2154
- // Generate new password and write to file atomically
2155
- adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
2156
- passwordSource = "generated";
2157
- // Atomic write: create temp file in same dir, then rename
2158
2215
  const flairDir = join(homedir(), ".flair");
2159
- mkdirSync(flairDir, { recursive: true });
2160
2216
  const adminPassPath = join(flairDir, "admin-pass");
2161
- const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
2162
- const finalTempPath = join(tempPath, "admin-pass");
2163
- try {
2164
- writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
2165
- renameSync(finalTempPath, adminPassPath);
2166
- rmSync(tempPath, { recursive: true, force: true });
2217
+ passwordSource = "generated";
2218
+ if (resolveInitAdminPasswordSource(existsSync(adminPassPath)) === "reuse-existing") {
2219
+ // flair#827: an admin-pass file already on disk means a PRIOR `flair
2220
+ // init` already bootstrapped Harper's admin user with this password.
2221
+ // HDB_ADMIN_PASSWORD only seeds a brand-new install — Harper does
2222
+ // NOT rotate an existing user's stored password hash from env on
2223
+ // every boot. Generating and overwriting the file here would desync
2224
+ // it from what Harper actually has persisted, breaking ops-API auth
2225
+ // (401 "Login failed") on THIS SAME init run (the agent-seeding call
2226
+ // below) without fixing whatever the re-run was meant to fix — e.g.
2227
+ // `flair doctor`'s ops-bind finding, whose only prescribed remedy is
2228
+ // re-running `flair init`. Re-init must be idempotent here: reuse
2229
+ // the existing password so it's always safe to re-run against a
2230
+ // working install. Rotating the admin password on purpose is a
2231
+ // separate, deliberate operation (see the ops runbook), not a side
2232
+ // effect of re-init.
2233
+ try {
2234
+ adminPass = readAdminPassFileSecure(adminPassPath);
2235
+ }
2236
+ catch (err) {
2237
+ console.error(`Error: ${err.message}`);
2238
+ process.exit(1);
2239
+ }
2240
+ reusedExistingAdminPass = true;
2167
2241
  }
2168
- catch (err) {
2169
- // Clean up temp dir on failure
2242
+ else {
2243
+ // Generate new password and write to file atomically
2244
+ adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
2245
+ // Atomic write: create temp file in same dir, then rename
2246
+ mkdirSync(flairDir, { recursive: true });
2247
+ const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
2248
+ const finalTempPath = join(tempPath, "admin-pass");
2170
2249
  try {
2250
+ writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
2251
+ renameSync(finalTempPath, adminPassPath);
2171
2252
  rmSync(tempPath, { recursive: true, force: true });
2172
2253
  }
2173
- catch { }
2174
- throw err;
2254
+ catch (err) {
2255
+ // Clean up temp dir on failure
2256
+ try {
2257
+ rmSync(tempPath, { recursive: true, force: true });
2258
+ }
2259
+ catch { }
2260
+ throw err;
2261
+ }
2175
2262
  }
2176
2263
  }
2177
2264
  const adminUser = DEFAULT_ADMIN_USER;
2178
- // If we generated the password, report where it was saved
2265
+ // If we generated (or reused) the password, report where it lives
2179
2266
  if (passwordSource === "generated") {
2180
2267
  const adminPassPath = join(homedir(), ".flair", "admin-pass");
2181
- console.log(`Admin password saved to: ${adminPassPath}`);
2268
+ if (reusedExistingAdminPass) {
2269
+ console.log(`Reusing existing admin password from: ${adminPassPath} (flair#827: re-init never rotates it — see the ops runbook to change it deliberately)`);
2270
+ }
2271
+ else {
2272
+ console.log(`Admin password saved to: ${adminPassPath}`);
2273
+ }
2182
2274
  }
2183
2275
  // Check Node.js version
2184
2276
  const major = parseInt(process.version.slice(1), 10);
@@ -2515,7 +2607,8 @@ program
2515
2607
  }
2516
2608
  // ── MCP client wiring ────────────────────────────────────────────────
2517
2609
  // The full one-command front door: detect installed MCP clients and wire
2518
- // each to the zero-install `npx -y @tpsdev-ai/flair-mcp` server. Claude
2610
+ // each to the zero-install `npx -y @tpsdev-ai/flair-mcp@<version>` server
2611
+ // (pinned — see mcpServerSpec()). Claude
2519
2612
  // Code is auto-wired into ~/.claude.json (the only client the CLI can
2520
2613
  // safely modify); other clients get copy-paste snippets. `--no-mcp`
2521
2614
  // skips wiring entirely; `--client <name>` targets one client; the
@@ -2551,7 +2644,7 @@ program
2551
2644
  const flairMcpConfig = {
2552
2645
  type: "stdio",
2553
2646
  command: "npx",
2554
- args: ["-y", "@tpsdev-ai/flair-mcp"],
2647
+ args: ["-y", mcpServerSpec()],
2555
2648
  // flair#718 authorship-provenance: each client's wired env block
2556
2649
  // gets its OWN FLAIR_CLIENT label (never the shared mcpEnv
2557
2650
  // object directly — that would stamp the same label into every
@@ -2640,7 +2733,9 @@ program
2640
2733
  if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0) {
2641
2734
  console.log("\n Smoke-testing MCP server...");
2642
2735
  try {
2643
- const mcpProc = spawn("npx", ["-y", "@tpsdev-ai/flair-mcp"], {
2736
+ // Same spec that gets WIRED above — the smoke test must exercise the
2737
+ // exact version the user will run, not whatever npm resolves latest to.
2738
+ const mcpProc = spawn("npx", ["-y", mcpServerSpec()], {
2644
2739
  env: { ...process.env, FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl },
2645
2740
  stdio: ["pipe", "pipe", "pipe"],
2646
2741
  });
@@ -5863,8 +5958,7 @@ export function describeReflectError(message) {
5863
5958
  return { kind: "other", text: message };
5864
5959
  }
5865
5960
  // ─── flair rem rapid ──────────────────────────────────────────────────────────
5866
- // Executes by default (specs/FLAIR-NIGHTLY-REM-SLICE-2-DISTILLATION.md § 3C,
5867
- // issue #707): distills server-side via /ReflectMemories execute:true and
5961
+ // Executes by default (§3C, issue #707): distills server-side via /ReflectMemories execute:true and
5868
5962
  // stages MemoryCandidate rows, printing a staged-candidate summary. --prompt-only
5869
5963
  // preserves the pre-#710 handoff behavior byte-for-byte, for the bring-your-
5870
5964
  // own-model workflow.
@@ -8873,6 +8967,28 @@ program
8873
8967
  }
8874
8968
  });
8875
8969
  // ─── flair reembed ────────────────────────────────────────────────────────────
8970
+ //
8971
+ // ROOT-CAUSE GUARD — recall graph correctness (recall-hnsw-graph-heal).
8972
+ // `flair reembed` replaces the stored embedding of many rows IN PLACE (it
8973
+ // clears embedding/embeddingModel, then re-PUTs through Memory.put()'s regen
8974
+ // branch — the same bulk in-place re-embed path resources/migrations/
8975
+ // embedding-stamp.ts uses). Historically, an OLDER (pre-fix) Harper's
8976
+ // INCREMENTAL HNSW update left stale/asymmetric reverse edges under bulk
8977
+ // re-embed, which collapsed prod recall in July. That engine bug is FIXED in
8978
+ // the Harper this ships against (5.1.22) — its update path reconstructs the
8979
+ // prior vector and does the reverse-edge cleanup the old build skipped — so a
8980
+ // bulk re-embed no longer corrupts the graph. DEFENSE-IN-DEPTH RULE (a
8981
+ // prudent, version-independent default, not a workaround for a live bug): pair
8982
+ // any BULK re-embed with a structural graph REBUILD trigger rather than relying
8983
+ // on incremental HNSW updates to converge (today: the
8984
+ // `@indexed(type:"HNSW", M:16)` descriptor bump in schemas/memory.graphql,
8985
+ // which makes Harper clear + rebuild the graph cleanly from the stored vectors
8986
+ // on the next boot — see resources/migrations/graph-heal.ts). Do NOT use
8987
+ // resources/MemoryReindex.ts's `_reindex` for graph correctness (it re-PUTs the
8988
+ // same vector through the incremental path and rebuilds nothing). If a `flair
8989
+ // reembed` run ever materially changes the vector space (e.g. a model swap),
8990
+ // follow it with a deploy that trips the structural reindex (bump the HNSW
8991
+ // descriptor / restart after a schema change).
8876
8992
  program
8877
8993
  .command("reembed")
8878
8994
  .description("Re-generate embeddings for memories with stale or missing model tags")
@@ -9685,7 +9801,13 @@ program
9685
9801
  // 3b. Ops API bind (flair#670) — report-only finding, never auto-fixed.
9686
9802
  // Rebinding the ops API requires a Harper restart to take effect, so
9687
9803
  // `doctor --fix` deliberately does not touch it here; the fix is
9688
- // `flair init` (re-run) or a manual harper-config.yaml edit + restart.
9804
+ // `flair init` (re-run, then `flair restart` to apply it) or a manual
9805
+ // harper-config.yaml edit + restart. flair#827: re-running `flair init`
9806
+ // used to regenerate ~/.flair/admin-pass unconditionally, desyncing it
9807
+ // from Harper's already-persisted credential and breaking admin auth on
9808
+ // the very re-run this remedy prescribed — resolveInitAdminPasswordSource
9809
+ // (see its doc comment) now reuses the existing password instead, so this
9810
+ // remedy is safe to follow on a working install.
9689
9811
  try {
9690
9812
  const harperConfigPath = join(defaultDataDir(), "harper-config.yaml");
9691
9813
  if (existsSync(harperConfigPath)) {
@@ -9694,7 +9816,7 @@ program
9694
9816
  const bind = detectOpsApiAllInterfacesBind(opsPortValue);
9695
9817
  if (bind.allInterfaces) {
9696
9818
  console.log(` ${render.icons.error} Ops API bound to ${render.wrap(render.c.bold, "all interfaces")} (${render.wrap(render.c.dim, String(opsPortValue))})`);
9697
- console.log(` ${render.wrap(render.c.dim, "Single-host installs don't need this reachable off-box. Fix:")} flair init ${render.wrap(render.c.dim, "(rebinds to loopback + domain socket on next start; pass --ops-bind for deliberate remote admin)")}`);
9819
+ console.log(` ${render.wrap(render.c.dim, "Single-host installs don't need this reachable off-box. Fix:")} flair init && flair restart ${render.wrap(render.c.dim, "(rebinds to loopback + domain socket; re-init reuses your existing admin password, so this is safe on a running install — pass --ops-bind for deliberate remote admin)")}`);
9698
9820
  issues++;
9699
9821
  }
9700
9822
  }
@@ -12060,7 +12182,7 @@ soul.command("list")
12060
12182
  // Slice 1: discovery + scaffold. Slice 2: YAML runtime + `import` for Shape A
12061
12183
  // + agentic-stack reference adapter as a built-in.
12062
12184
  // `test` and `export` are still stubbed; Shape B (npm code plugins) too.
12063
- // See specs/FLAIR-BRIDGES.md.
12185
+ // See docs/bridges.md.
12064
12186
  const bridge = program.command("bridge").description("Manage memory bridges (import/export between Flair and foreign systems)");
12065
12187
  bridge
12066
12188
  .command("list")
@@ -20,8 +20,8 @@
20
20
  * - Steps 1, 2 shipped in slice-1 PR-1 (#414).
21
21
  * - Step 3 (maintenance) shipped in a prior slice-2 PR — fills
22
22
  * `archived`/`expired` in the audit row.
23
- * - Step 4: per specs/FLAIR-NIGHTLY-REM-SLICE-2-DISTILLATION.md § 3B,
24
- * "Deferred from parent § 4 step 4" — trust tiers aren't derivable yet
23
+ * - Step 4: per §3B (issue #707), "Deferred from parent § 4 step 4" —
24
+ * trust tiers aren't derivable yet
25
25
  * (that's the emergent-trust arc). The input filter stays as today (own
26
26
  * agent, non-archived, non-permanent, scope window); the safety net for
27
27
  * un-tiered input is structural — candidates are staged, never
@@ -5,7 +5,7 @@
5
5
  * reflection prompt for a human/agent to run through their own LLM, or (b)
6
6
  * with execute:true, runs the distillation server-side via Harper's models
7
7
  * facade and stages the results as MemoryCandidate rows for review. See
8
- * specs/FLAIR-NIGHTLY-REM-SLICE-2-DISTILLATION.md §3A (issue #707).
8
+ * issue #707 (§3A).
9
9
  *
10
10
  * Request:
11
11
  * agentId string — which agent to reflect on
@@ -34,6 +34,17 @@
34
34
  * (and its ownership gate) — its own auth model is verified-agent +
35
35
  * within-org + explicitly NO ownership requirement.
36
36
  *
37
+ * NO READ-SCOPE GATE — INTENTIONAL ASYMMETRY with citation-on-write
38
+ * (flair#775): this endpoint deliberately does NOT validate reported ids
39
+ * against the caller's read scope. Usage feedback is the cross-agent
40
+ * contract described above (agent B reports using agent A's memory
41
+ * regardless of A's visibility setting), and the no-enumeration response
42
+ * below already denies existence probing on this surface. Citation-on-write
43
+ * (./usage-recording.ts's recordCitations()) DOES scope-gate each cited id —
44
+ * it is a separate write surface with a narrower threat model; see that
45
+ * module's doc. Do not "unify" the two: the difference is a K&S binding
46
+ * condition on the flair#775 locked design, not drift.
47
+ *
37
48
  * WHY THIS ISN'T A @table-BACKED RESOURCE: the actual dedup ledger (one row
38
49
  * per (agentId, memoryId) contribution) lives in the `MemoryUsage` table
39
50
  * (schemas/memory.graphql), guarded by resources/MemoryUsage.ts. But this
@@ -164,7 +175,10 @@ export class RecordUsage extends Resource {
164
175
  // ./usage-recording.ts's recordUsageContribution() — a shared,
165
176
  // single-implementation extraction (also used by citation-on-write,
166
177
  // resources/Memory.ts's post()/put()). Byte-identical behavior to
167
- // the former private _recordOne() this replaced.
178
+ // the former private _recordOne() this replaced. Deliberately NO
179
+ // read-scope check on memoryId here (flair#775 asymmetry — see the
180
+ // module doc's NO READ-SCOPE GATE paragraph; citation-on-write's
181
+ // recordCitations() is the surface that scope-gates).
168
182
  await recordUsageContribution(ctx, agentId, memoryId, attribution, now);
169
183
  }
170
184
  catch (err) {
@@ -107,9 +107,6 @@
107
107
  * still a separate, deliberate step from shipping this code — see the PR
108
108
  * description for that distinction.
109
109
  */
110
- import { join } from "node:path";
111
- import { existsSync } from "node:fs";
112
- import { homedir } from "node:os";
113
110
  const VALID_INPUT_TYPES = new Set(["document", "query"]);
114
111
  /**
115
112
  * THE GATE (flair#504 Phase 2 — flipped ON). A module-level constant, not an
@@ -239,42 +236,15 @@ async function getModelsApi() {
239
236
  /**
240
237
  * Resolve the directory the embeddings model lives in / downloads into.
241
238
  *
242
- * Resolution order (everything writable, never the read-only package dir):
243
- * 1. FLAIR_MODELS_DIR — explicit operator/docker override.
244
- * 2. <ROOTPATH>/models — Harper's data dir (Flair passes ROOTPATH =
245
- * ~/.flair/data when it spawns Harper). User-
246
- * owned and writable even on sudo-global installs.
247
- * 3. <cwd>/models — ONLY if a model already lives there. Backward
248
- * compat for existing writable installs that
249
- * downloaded into the package dir before this fix;
250
- * never used as a download target on fresh installs.
251
- * 4. ~/.flair/data/models — last-resort default when ROOTPATH is unset.
252
- *
253
- * The chosen dir is always writable, so the embeddings engine can download the
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.
239
+ * Implementation moved to `resources/models-dir.ts` (flair#815) so the
240
+ * reranker can share the exact same resolution without importing this module
241
+ * (several unit-isolated tests `mock.module()` this file wholesale — see
242
+ * models-dir.ts's header). Re-exported here so existing consumers keep their
243
+ * import site: `resources/embeddings-boot.ts` (flair#694), which builds the
244
+ * `modelsDir` it passes to harper-fabric-embeddings' `register()`, and
245
+ * test/unit/embeddings-models-dir.test.ts, which pins the resolution order.
263
246
  */
264
- export function resolveModelsDir() {
265
- const override = process.env.FLAIR_MODELS_DIR;
266
- if (override)
267
- return override;
268
- const rootPath = process.env.ROOTPATH;
269
- if (rootPath)
270
- return join(rootPath, "models");
271
- // Backward compat: a prior (writable) install may have the model cached in
272
- // the package dir. Reuse it rather than re-downloading — but only if present.
273
- const cwdModels = join(process.cwd(), "models");
274
- if (existsSync(cwdModels))
275
- return cwdModels;
276
- return join(homedir(), ".flair", "data", "models");
277
- }
247
+ export { resolveModelsDir } from "./models-dir.js";
278
248
  // Kern: "Boot probe MUST have a 5-10s timeout ... on timeout set mode='none'
279
249
  // + log a warning. A hung probe ... must never block Harper boot."
280
250
  const PROBE_TIMEOUT_MS = 8_000;
@@ -35,6 +35,7 @@
35
35
  * version bump + a new FlairClient method, out of scope for this query-only
36
36
  * slice.
37
37
  */
38
+ import { resolveVersion } from "./version.js";
38
39
  const H = {};
39
40
  const LOADERS = {
40
41
  SemanticSearch: async () => (await import("./SemanticSearch.js")).SemanticSearch,
@@ -248,7 +249,14 @@ async function bootstrap(agent, args) {
248
249
  // ONLY when requested so a plain bootstrap delegates a byte-identical body.
249
250
  if (args?.abstain === true)
250
251
  body.abstain = true;
251
- return unwrap(await h.post(body));
252
+ // flair#831 — attach the running Flair version to the RESPONSE (not the
253
+ // delegated request body) so the calling agent learns the server version
254
+ // on its very first call.
255
+ const result = unwrap(await h.post(body));
256
+ if (result && typeof result === "object" && !Array.isArray(result)) {
257
+ return { ...result, flairVersion: resolveVersion() };
258
+ }
259
+ return result;
252
260
  }
253
261
  async function soulSet(agent, args) {
254
262
  const Cls = await handler("Soul");
@@ -1,6 +1,6 @@
1
1
  // ─── Memory Reflection — pure logic for /ReflectMemories ────────────────────
2
2
  // Pure helpers backing resources/MemoryReflect.ts (FLAIR-NIGHTLY-REM slice 2,
3
- // §3A — see specs/FLAIR-NIGHTLY-REM-SLICE-2-DISTILLATION.md, issue #707).
3
+ // §3A — see issue #707).
4
4
  //
5
5
  // Same split as resources/memory-consolidate-lib.ts: importing MemoryReflect.ts
6
6
  // pulls in the Harper runtime (`databases`/`Resource`/`models`, storage init),
@@ -101,6 +101,29 @@
101
101
  * OTHER, not-yet-understood counting artifact; this lets the runner PROVE
102
102
  * (via `.get()`, a primary-key lookup — always live, never index-assisted)
103
103
  * that a nonzero countPending() result is real before halting on it.
104
+ *
105
+ * ROOT-CAUSE GUARD — recall graph correctness (recall-hnsw-graph-heal).
106
+ * This migration RE-EMBEDS rows in BULK via `PUT /Memory/:id` — it replaces
107
+ * the stored vector of many rows in place. Historically, an OLDER (pre-fix)
108
+ * Harper's INCREMENTAL HNSW update left stale/asymmetric reverse edges under
109
+ * exactly this kind of bulk re-embed (order-dependent), which collapsed prod
110
+ * recall (p@3 0.25) in July. That engine bug is FIXED in the Harper this ships
111
+ * against (5.1.22): its index() reconstructs the prior vector from the stored
112
+ * node and carries the node's real connections into the reverse-edge cleanup
113
+ * the old build skipped, so a bulk re-embed no longer corrupts the graph.
114
+ * DEFENSE-IN-DEPTH RULE (a prudent, version-independent default — not a
115
+ * workaround for a live bug): pair any BULK re-embed with a structural graph
116
+ * REBUILD trigger rather than relying on incremental HNSW updates to converge
117
+ * — it keeps recall correct regardless of engine version, closes the rare
118
+ * decode-failure edge case, and heals a graph already corrupted on disk by an
119
+ * older engine. Do NOT reach for resources/MemoryReindex.ts's `_reindex` for
120
+ * graph correctness (it re-PUTs the same vector through the incremental path
121
+ * and rebuilds nothing). The lever flair uses is a structural schema-descriptor
122
+ * change: the `@indexed(type:"HNSW", M:16)` bump in schemas/memory.graphql
123
+ * ships alongside this migration precisely so a graph corrupted under an older
124
+ * engine is rebuilt on the upgrade boot (observed/ledgered by
125
+ * resources/migrations/graph-heal.ts). Before enabling any FUTURE bulk
126
+ * re-embed, ship a structural reindex trigger with it.
104
127
  */
105
128
  import { databases } from "@harperfast/harper";
106
129
  import { getModelId } from "../embeddings-provider.js";