@tpsdev-ai/flair 0.28.0 → 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.
@@ -9707,7 +9801,13 @@ program
9707
9801
  // 3b. Ops API bind (flair#670) — report-only finding, never auto-fixed.
9708
9802
  // Rebinding the ops API requires a Harper restart to take effect, so
9709
9803
  // `doctor --fix` deliberately does not touch it here; the fix is
9710
- // `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.
9711
9811
  try {
9712
9812
  const harperConfigPath = join(defaultDataDir(), "harper-config.yaml");
9713
9813
  if (existsSync(harperConfigPath)) {
@@ -9716,7 +9816,7 @@ program
9716
9816
  const bind = detectOpsApiAllInterfacesBind(opsPortValue);
9717
9817
  if (bind.allInterfaces) {
9718
9818
  console.log(` ${render.icons.error} Ops API bound to ${render.wrap(render.c.bold, "all interfaces")} (${render.wrap(render.c.dim, String(opsPortValue))})`);
9719
- 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)")}`);
9720
9820
  issues++;
9721
9821
  }
9722
9822
  }
@@ -12082,7 +12182,7 @@ soul.command("list")
12082
12182
  // Slice 1: discovery + scaffold. Slice 2: YAML runtime + `import` for Shape A
12083
12183
  // + agentic-stack reference adapter as a built-in.
12084
12184
  // `test` and `export` are still stubbed; Shape B (npm code plugins) too.
12085
- // See specs/FLAIR-BRIDGES.md.
12185
+ // See docs/bridges.md.
12086
12186
  const bridge = program.command("bridge").description("Manage memory bridges (import/export between Flair and foreign systems)");
12087
12187
  bridge
12088
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),
@@ -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
@@ -70,10 +70,16 @@
70
70
  * No Ollama (no logprobs, no rerank endpoint — verified), no network hop, no
71
71
  * new auth boundary.
72
72
  *
73
- * GGUF files are NOT committed; they are provisioned into models/ alongside the
74
- * embedding GGUF. See docs/rerank-provisioning.md for download sources.
73
+ * GGUF files are NOT committed; they are provisioned into the models dir
74
+ * alongside the embedding GGUF resolved via the SAME `resolveModelsDir()`
75
+ * the embedding engine uses (FLAIR_MODELS_DIR → <ROOTPATH>/models →
76
+ * <cwd>/models → ~/.flair/data/models; flair#815 — this file used to hardcode
77
+ * <cwd>/models, so any deployment whose cwd wasn't the models location failed
78
+ * init and silently fell open to vector order). See docs/rerank-provisioning.md
79
+ * for download sources.
75
80
  */
76
81
  import { join } from "node:path";
82
+ import { resolveModelsDir } from "./models-dir.js";
77
83
  // Known reranker models → GGUF filename + inference mode. jina is the DEFAULT
78
84
  // (working — see file header, flair#811): its rank-pooling path completes
79
85
  // inside Harper. qwen3 is EXPERIMENTAL: its generative path is validated
@@ -181,6 +187,20 @@ function resolveModelKey() {
181
187
  return requested;
182
188
  return DEFAULT_MODEL;
183
189
  }
190
+ /**
191
+ * Absolute path the reranker GGUF is expected at: `<models dir>/<file>`,
192
+ * where the models dir is the shared `resolveModelsDir()` (models-dir.ts,
193
+ * same resolution the embedding engine uses) — the single documented source
194
+ * of truth (FLAIR_MODELS_DIR override, ROOTPATH/models, cwd/models backward
195
+ * compat, ~/.flair/data/models default).
196
+ * flair#815: this used to be a hardcoded `<cwd>/models/<file>`, which broke
197
+ * (fail-open, silently) in any deployment where Harper's cwd wasn't the
198
+ * models location — including the recall harness's ephemeral Harpers.
199
+ * Exported for unit testing (see test/unit/rerank-provider.test.ts).
200
+ */
201
+ export function resolveRerankModelPath(file) {
202
+ return join(resolveModelsDir(), file);
203
+ }
184
204
  /**
185
205
  * Decide whether `ensureInit()` needs to (re)run the engine init sequence —
186
206
  * the flair#811 point-3 fix. Pure so the decision matrix is directly
@@ -234,9 +254,9 @@ async function ensureInit() {
234
254
  const spec = MODELS[_modelKey];
235
255
  _mode = spec.mode;
236
256
  const { existsSync } = await import("node:fs");
237
- const modelPath = join(process.cwd(), "models", spec.file);
257
+ const modelPath = resolveRerankModelPath(spec.file);
238
258
  if (!existsSync(modelPath)) {
239
- throw new Error(`reranker GGUF not found: models/${spec.file} (provision per docs/rerank-provisioning.md)`);
259
+ throw new Error(`reranker GGUF not found: ${modelPath} (provision per docs/rerank-provisioning.md; set FLAIR_MODELS_DIR to override the models dir)`);
240
260
  }
241
261
  // Dynamic import — deferred to avoid Harper 5.x VM linker race (same reason
242
262
  // embeddings-provider.ts defers harper-fabric-embeddings).
@@ -26,10 +26,28 @@
26
26
  * RecordUsage.post()'s loop, parameterized so Memory.ts can drive it from a
27
27
  * write body instead of a dedicated POST body. It NEVER reads the ledger for
28
28
  * authority — it only writes contributions; `usedMemoryIds` must never enter
29
- * an access/scope/attribution/dedup decision (flair#744 slice A invariant 3).
29
+ * an access/scope/attribution/dedup decision (flair#744 slice A invariant 3;
30
+ * the flair#775 read-scope gate below is the REVERSE direction — the writer's
31
+ * scope vets the cited ids, the cited ids never widen anything).
32
+ *
33
+ * recordCitations() additionally validates each cited id against the
34
+ * WRITER's own read-scope before crediting (flair#775, a Kern+Sherlock
35
+ * binding condition on the locked design): citing a memory the writer cannot
36
+ * read is silently dropped, uniformly with a nonexistent id — never an
37
+ * error, never a distinguishable code path (an error, or any response/shape
38
+ * difference, would leak "that id exists but you can't see it").
39
+ * POST /RecordUsage is DELIBERATELY not scope-gated — its module doc
40
+ * establishes usage feedback as a cross-agent contract (agent B reports
41
+ * using agent A's memory regardless of A's visibility setting);
42
+ * citation-on-write is a separate write surface with a narrower threat
43
+ * model (the ids ride along on the WRITER's own memory-creation call, so
44
+ * crediting an unreadable id would let a writer both probe for and boost
45
+ * memories it cannot see). So the scope gate lives HERE, not retrofitted
46
+ * onto recordUsageContribution() — do not "unify" the two surfaces.
30
47
  */
31
48
  import { databases } from "@harperfast/harper";
32
49
  import { withDetachedTxn } from "./table-helpers.js";
50
+ import { resolveReadScope } from "./memory-read-scope.js";
33
51
  /**
34
52
  * Per-call cap on ids credited in one batch — shared by RecordUsage.post()'s
35
53
  * validated `memoryIds` body (rejects a batch over the cap with a 400) and
@@ -106,6 +124,17 @@ export async function recordUsageContribution(ctx, agentId, memoryId, attributio
106
124
  return; // deleted between the checks above and now — no-op
107
125
  await withDetachedTxn(ctx, () => databases.flair.Memory.put({ ...fresh, usageCount: (fresh.usageCount ?? 0) + 1 }));
108
126
  }
127
+ /**
128
+ * Default record fetch for recordCitations()'s read-scope gate — the RAW
129
+ * Memory table (bypassing the Memory RESOURCE class's own read wrapper; the
130
+ * same trusted-internal-caller pattern recordUsageContribution() uses) so
131
+ * `scope.isAllowed` runs against the raw stored record. Never throws: a
132
+ * fetch failure reads as "not found", which the gate silently drops —
133
+ * identical to a nonexistent id.
134
+ */
135
+ async function fetchMemoryForScopeCheck(ctx, memoryId) {
136
+ return withDetachedTxn(ctx, () => databases.flair.Memory.get(memoryId)).catch(() => null);
137
+ }
109
138
  /**
110
139
  * Batch citation helper — credits every id in `usedMemoryIds` through
111
140
  * `recordFn` (the real `recordUsageContribution` by default), one contribution
@@ -129,16 +158,30 @@ export async function recordUsageContribution(ctx, agentId, memoryId, attributio
129
158
  * validated request body, so an oversized list is trimmed rather than
130
159
  * rejected (unlike RecordUsage.post()'s validated `memoryIds`, which
131
160
  * 400s over the same cap).
161
+ * - Each cited id is validated against the WRITER's read scope before it
162
+ * is credited (flair#775 slice 1, K&S binding condition — see the module
163
+ * doc above): the scope is resolved ONCE per batch via resolveReadScope
164
+ * (the same single source every cross-agent Memory read path uses), then
165
+ * each id gets one raw fetch + one in-process `scope.isAllowed` check.
166
+ * Not-found and out-of-scope take the SAME silent-drop branch — there is
167
+ * no structurally distinguishable code path, error, or response
168
+ * difference between them that a caller could use to probe whether
169
+ * another agent's private id exists.
132
170
  * - Each id is credited independently: one id throwing never stops the
133
171
  * rest, and the failure is logged server-side, never surfaced to the
134
172
  * caller (the write already committed by the time this runs).
135
173
  *
136
- * `agentId` passed to `recordFn` is ALWAYS `auth.agentId` the resolved
137
- * auth context, never anything derived from `usedMemoryIds` or any other
138
- * caller-supplied input (flair#744 slice A invariant 4: no forging on
139
- * behalf of another identity).
174
+ * `agentId` passed to `recordFn` (and to the scope resolution) is ALWAYS
175
+ * `auth.agentId` — the resolved auth context, never anything derived from
176
+ * `usedMemoryIds` or any other caller-supplied input (flair#744 slice A
177
+ * invariant 4: no forging on behalf of another identity).
178
+ *
179
+ * `recordFn` / `fetchFn` / `scopeFn` are unit-test injection seams
180
+ * (test/unit/usage-recording.test.ts) — production callers pass none of
181
+ * them and always get the real recordUsageContribution / raw-table fetch /
182
+ * resolveReadScope.
140
183
  */
141
- export async function recordCitations(ctx, auth, usedMemoryIds, now, recordFn = recordUsageContribution) {
184
+ export async function recordCitations(ctx, auth, usedMemoryIds, now, recordFn = recordUsageContribution, fetchFn = fetchMemoryForScopeCheck, scopeFn = resolveReadScope) {
142
185
  if (auth.kind !== "agent")
143
186
  return;
144
187
  if (!Array.isArray(usedMemoryIds) ||
@@ -147,8 +190,31 @@ export async function recordCitations(ctx, auth, usedMemoryIds, now, recordFn =
147
190
  return;
148
191
  }
149
192
  const ids = [...new Set(usedMemoryIds)].slice(0, MAX_USAGE_IDS_PER_CALL);
193
+ // Resolve the WRITER's read scope ONCE per batch. Fail CLOSED: if scope
194
+ // resolution itself fails, drop the whole batch rather than credit
195
+ // unvetted ids — citations are advisory signal, so losing a batch is
196
+ // strictly safer than crediting an id the writer may not be able to read.
197
+ let scope;
198
+ try {
199
+ scope = await scopeFn(auth.agentId);
200
+ }
201
+ catch (err) {
202
+ console.error("recordCitations: read-scope resolution failed — batch dropped (no-op)", { err });
203
+ return;
204
+ }
150
205
  for (const id of ids) {
151
206
  try {
207
+ // flair#775 slice 1 read-scope gate. The raw fetch + in-process
208
+ // predicate is deliberately ONE branch for both "doesn't exist" and
209
+ // "exists but out of the writer's read scope" — uniform silent drop
210
+ // (see the module doc). recordUsageContribution's own existence
211
+ // re-check makes this a second point-lookup of the same record;
212
+ // accepted — keeping the shared ledger core byte-identical for
213
+ // RecordUsage.post() is worth two point-lookups on a ≤20-id advisory
214
+ // batch.
215
+ const record = await fetchFn(ctx, id);
216
+ if (!record || !scope.isAllowed(record))
217
+ continue;
152
218
  await recordFn(ctx, auth.agentId, id, undefined, now);
153
219
  }
154
220
  catch (err) {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * version.ts — shared runtime-version resolver.
3
+ *
4
+ * Reads the running @tpsdev-ai/flair version from the bundled package.json.
5
+ * `process.env.npm_package_version` is only populated inside `npm run`, so
6
+ * reading package.json relative to THIS running module is the only way to
7
+ * report the version of the code that's actually executing.
8
+ *
9
+ * Extracted from the duplicated copies in resources/Presence.ts and
10
+ * resources/AdminInstance.ts (flair#831). Those modules still carry their
11
+ * own copies for now — migrating them is a follow-up.
12
+ */
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { join, dirname } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ export function resolveVersion() {
17
+ try {
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+ const candidates = [
20
+ join(here, "..", "..", "package.json"),
21
+ join(here, "..", "package.json"),
22
+ ];
23
+ for (const p of candidates) {
24
+ if (existsSync(p)) {
25
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
26
+ if (pkg.version)
27
+ return pkg.version;
28
+ }
29
+ }
30
+ }
31
+ catch { /* fall through */ }
32
+ return process.env.npm_package_version ?? "dev";
33
+ }
@@ -3,7 +3,7 @@
3
3
  [0.001, "o", " Flair \u00b7 same identity, every orchestrator\r\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n"]
4
4
  [1.3, "o", "$ # One agent identity. One memory store. Three orchestrators.\r\n"]
5
5
  [1.2, "o", "$ flair init --client all --agent flint\r\n"]
6
- [0.5, "o", " \u001b[32m\u2713\u001b[0m Claude Code wired (~/.claude/mcp.json)\r\n"]
6
+ [0.5, "o", " \u001b[32m\u2713\u001b[0m Claude Code wired (~/.claude.json)\r\n"]
7
7
  [0.18, "o", " \u001b[32m\u2713\u001b[0m Codex CLI wired (~/.codex/config.toml)\r\n"]
8
8
  [0.18, "o", " \u001b[32m\u2713\u001b[0m Gemini CLI wired (~/.gemini/settings.json)\r\n\r\n"]
9
9
  [1.0, "o", " Each MCP client now connects to: http://127.0.0.1:9926\r\n"]
package/docs/bridges.md CHANGED
@@ -101,7 +101,7 @@ A few things worth knowing:
101
101
  - **`--agent` is required** unless your descriptor maps an `agentId` column. If you forget, `flair bridge import` errors with a one-line operator-pointer hint plus a structured `BridgeRuntimeError` JSON on stderr.
102
102
  - **`--dry-run` is your friend.** Validates the descriptor, parses every record, applies the mapping, but skips the PUT. Use it to confirm the count and check a few records before committing.
103
103
  - **Output is throttled** to one progress line every 2 seconds (or every 25 records, whichever comes first), so big imports don't flood your terminal.
104
- - **Errors are structured.** Every error includes `bridge`, `op`, `path`, `record`, `field`, `expected`, `got`, `hint` (per [§10 of the spec](../specs/FLAIR-BRIDGES.md#-10-error-format)). The `hint` is the part you act on; the rest is for an LLM to self-correct without operator help.
104
+ - **Errors are structured.** Every error includes `bridge`, `op`, `path`, `record`, `field`, `expected`, `got`, `hint` (per [Error format](#error-format) below). The `hint` is the part you act on; the rest is for an LLM to self-correct without operator help.
105
105
 
106
106
  ## Shape A — Declarative YAML
107
107
 
@@ -286,6 +286,6 @@ If you want an agent to write a bridge for you, here's the one-shot prompt:
286
286
 
287
287
  That's the bar. If an agent can't ship a working bridge from this doc plus the scaffold, the doc is the bug.
288
288
 
289
- ## Full spec
289
+ ## Authority
290
290
 
291
- The authoritative contract is in [`specs/FLAIR-BRIDGES.md`](../specs/FLAIR-BRIDGES.md). This doc is the user-facing view; the spec covers edge cases, future extensions, and design rationale.
291
+ This document is the authoritative contract for the bridge plugin system: the record schema, both plugin shapes, discovery, distribution, trust, round-trip testing, and the error format are all specified above. There is no separate design spec the original planning document was retired once the system shipped, and its design-rationale and milestone-scoping sections are preserved in git history rather than maintained here.
@@ -38,9 +38,11 @@ Flair runs as a local server at `http://127.0.0.1:19926` by default. The MCP ser
38
38
 
39
39
  Pick whichever you use. The MCP server is the same package; only the config syntax differs.
40
40
 
41
+ > **Pin the version.** The snippets below use the bare package name for readability. `flair init` wires clients to a **pinned** spec (`@tpsdev-ai/flair-mcp@<version>`) on purpose: an unpinned reference re-resolves to whatever is currently published on every agent session, so any future publish reaches your machine silently. If you wire by hand, append the version you intend to run — `@tpsdev-ai/flair-mcp@0.28.0` — and bump it deliberately. `flair init` is the easier path and does this for you.
42
+
41
43
  ### Claude Code
42
44
 
43
- The canonical approach is the `claude mcp add` CLI (writes to `~/.claude/mcp.json`):
45
+ The canonical approach is the `claude mcp add` CLI (writes to `~/.claude.json`):
44
46
 
45
47
  ```bash
46
48
  claude mcp add flair --scope user \
package/docs/n8n.md CHANGED
@@ -93,7 +93,7 @@ If any of those don't hold, use Flair's CLI / SDK clients (which support per-age
93
93
 
94
94
  ## Get By Tag — coming soon
95
95
 
96
- The Flair Search node currently exposes Semantic Search and Get By Subject. **Get By Tag** is deferred until `flair-client.memory.list` exposes a `tags` filter (tracked in the [n8n-node spec](https://github.com/tpsdev-ai/flair/blob/main/specs/N8N-NODE-q3qf.md) §6). Workaround for now: use Semantic Search and let the model filter results by tags in the response.
96
+ The Flair Search node currently exposes Semantic Search and Get By Subject. **Get By Tag** is deferred until `flair-client.memory.list` exposes a `tags` filter. Workaround for now: use Semantic Search and let the model filter results by tags in the response.
97
97
 
98
98
  ## Worked examples
99
99
 
@@ -117,6 +117,5 @@ Same Flair instance, same memories, different surfaces.
117
117
 
118
118
  ## See also
119
119
 
120
- - [Spec — `@tpsdev-ai/n8n-nodes-flair`](https://github.com/tpsdev-ai/flair/blob/main/specs/N8N-NODE-q3qf.md) — implementation plan, design decisions, anti-patterns
121
120
  - [Bridges](./bridges.md) — how Flair memories flow between hosts and instances
122
121
  - [Federation](./federation.md) — hub-and-spoke replication
@@ -1,6 +1,6 @@
1
1
  # REM UX — trigger model, attach semantics, review loop
2
2
 
3
- > Design note accompanying REM slice 2 (#707). Describes the intended user experience of in-process distillation so the CLI/docs surfaces stay coherent as the feature grows. Parent spec: `specs/FLAIR-NIGHTLY-REM.md`; slice spec: `specs/FLAIR-NIGHTLY-REM-SLICE-2-DISTILLATION.md`.
3
+ > Design note accompanying REM slice 2 (#707). Describes the intended user experience of in-process distillation so the CLI/docs surfaces stay coherent as the feature grows. See [`docs/rem.md`](../rem.md) for the REM configuration and command reference.
4
4
 
5
5
  ## Triggers — three, nothing implicit
6
6
 
package/docs/rem.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  REM (Reflect · Extract · Merge) is Flair's memory-curation cycle: it reads an agent's recent memories, distills them into candidate insights, and stages those candidates for explicit human/agent review — nothing is ever auto-promoted. `flair rem rapid` runs it on demand; `flair rem nightly enable` runs it on a schedule. See [`docs/notes/rem-ux.md`](notes/rem-ux.md) for the full trigger model, locality guarantees, and the review-loop UX this page's commands feed into.
4
4
 
5
+ > **⚠️ Prerequisite: a configured generative backend.** All REM commands (`rapid`, `nightly`, `candidates`, `promote`, `reject`) require Harper's `models.generate()` to be wired — without it, REM calls fail with `Reflection error: No generative backend configured`. Set up a backend first (see [Configuration](#configuration) below) before running any REM command. The fastest path is Ollama with a non-thinking model, which needs zero credentials and keeps all traffic local.
6
+
5
7
  ## Configuration
6
8
 
7
9
  Distillation runs **server-side**, via Harper's model-access API (`models.generate()`). Flair ships zero provider code — which backend answers a REM call is entirely a Harper `models:` configuration decision.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "packageManager": "bun@1.3.10",
5
5
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
6
6
  "type": "module",
@@ -44,13 +44,17 @@ type Memory @table(database: "flair") {
44
44
  usageCount: Int # flair#683: verified-USE signal, distinct from retrievalCount (a
45
45
  # search HIT). Additive/nullable — absent reads as 0, existing rows
46
46
  # unaffected. NEVER auto-incremented on search/retrieval; the ONLY
47
- # writer is the dedicated usage-feedback endpoint (resources/
48
- # MemoryUsage.ts), which does a targeted patchRecord increment on
49
- # this field alone (never through Memory.put() see that
50
- # resource's module doc for why: usage-feedback writes to ANOTHER
51
- # agent's memory, and Memory's ownership check would 403 every
52
- # legit call). Drives usageBoost in resources/scoring.ts, which
53
- # replaces retrievalBoost in compositeScore (retrievalCount was the
47
+ # writer is resources/usage-recording.ts's recordUsageContribution()
48
+ # the shared ledger core behind BOTH usage surfaces: POST
49
+ # /RecordUsage (resources/RecordUsage.ts) and citation-on-write
50
+ # (resources/Memory.ts post()/put() recordCitations(), which also
51
+ # validates each cited id against the writer's read scope,
52
+ # flair#775). It does a targeted get-then-put increment on this
53
+ # field alone (never through Memory.put() see RecordUsage.ts's
54
+ # module doc for why: usage feedback writes to ANOTHER agent's
55
+ # memory, and Memory's ownership check would 403 every legit
56
+ # call). Drives usageBoost in resources/scoring.ts, which replaces
57
+ # retrievalBoost in compositeScore (retrievalCount was the
54
58
  # contaminated "retrieval counted as usage" signal root-caused in
55
59
  # flair#623).
56
60
  promotionStatus: String # null | "pending" | "approved" | "rejected"
@@ -155,9 +159,12 @@ type MemoryGrant @table(database: "flair") @export {
155
159
  # contributed to this memory's usageCount" is a single point-lookup, not a
156
160
  # query. This is what makes the anti-gaming dedup rule ("each (agent, memory)
157
161
  # pair contributes at most 1 to usageCount" — Sherlock) enforceable: the
158
- # dedicated endpoint (resources/MemoryUsage.ts) only bumps Memory.usageCount
159
- # when a NEW row is created here; a repeat call from the same agent for the
160
- # same memory is a no-op against a row that already exists.
162
+ # shared ledger core (resources/usage-recording.ts's recordUsageContribution(),
163
+ # reached via POST /RecordUsage and via citation-on-write's recordCitations())
164
+ # only bumps Memory.usageCount when a NEW row is created here; a repeat call
165
+ # from the same agent for the same memory is a no-op against a row that
166
+ # already exists. resources/MemoryUsage.ts guards this table's own HTTP
167
+ # surface (it is not the writer).
161
168
  #
162
169
  # `attribution` is OPAQUE — never parsed, never fed to an LLM, never rendered
163
170
  # — a free-text "what used this" hint the caller supplies, sanitized
@@ -172,7 +179,7 @@ type MemoryUsage @table(database: "flair") {
172
179
  }
173
180
 
174
181
  # MemoryCandidate — staged distillations from the FLAIR-NIGHTLY-REM cycle.
175
- # Per specs/FLAIR-NIGHTLY-REM.md § 7. Slice 1 of ops-2qq adds the schema +
182
+ # Per docs/rem.md. Slice 1 of ops-2qq adds the schema +
176
183
  # `flair rem candidates` listing command. Future slices wire the nightly
177
184
  # cycle that populates this table and the promote/reject endpoints.
178
185
  #