@tpsdev-ai/flair 0.48.0 → 0.49.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.
@@ -84,6 +84,7 @@
84
84
  * Harper boot), registration is skipped and logged — Harper falls back to
85
85
  * keyword-only search, matching the pre-existing degrade contract.
86
86
  */
87
+ import { availableParallelism } from "node:os";
87
88
  import { resolveModelsDir } from "./embeddings-provider.js";
88
89
  const LOGICAL_NAME = "default";
89
90
  const MODEL_NAME = "nomic-embed-text";
@@ -148,6 +149,43 @@ const EMBEDDING_POOLING = "mean";
148
149
  function benchModelPathOverride() {
149
150
  return process.env.FLAIR_RECALL_HARNESS_MODEL_PATH || undefined;
150
151
  }
152
+ /**
153
+ * llama.cpp CPU thread count passed to HFE `register({config:{threads}})`.
154
+ *
155
+ * HFE's own default is a fixed 6 (see harper-fabric-embeddings' `init()`
156
+ * table). flair never used to pass `threads`, so every host inherited that
157
+ * 6: an 8-vCPU ingest box left cores idle (flair#1330), a 4-core laptop
158
+ * oversubscribed. We always pass an explicit value.
159
+ *
160
+ * Default (unset / empty / non-integer / <1): `max(1, cores - 1)`. Safer
161
+ * than `min(6, cores)` — that still leaves the 8-vCPU case idle at 6 —
162
+ * and safer than using every core: Harper's JS worker (`THREADS_COUNT=1`)
163
+ * and the OS keep one. `availableParallelism()` (not `os.cpus().length`)
164
+ * so a cgroup CPU quota (Docker / k8s / Fabric) is what we count.
165
+ *
166
+ * Override: `FLAIR_EMBED_THREADS` — a positive integer, env-only. A
167
+ * config.yaml key would go through Harper's models-config persist path,
168
+ * which is exactly the class of state embeddings-boot exists to avoid
169
+ * (flair#694). Invalid values fall through to the host-aware default.
170
+ */
171
+ export function resolveEmbedThreads(env = process.env, cores = availableParallelism()) {
172
+ const parsed = parsePositiveInt(env.FLAIR_EMBED_THREADS);
173
+ if (parsed !== undefined)
174
+ return parsed;
175
+ const safeCores = Number.isFinite(cores) && cores >= 1 ? Math.floor(cores) : 1;
176
+ return Math.max(1, safeCores - 1);
177
+ }
178
+ function parsePositiveInt(raw) {
179
+ if (raw == null)
180
+ return undefined;
181
+ const trimmed = raw.trim();
182
+ if (trimmed === "")
183
+ return undefined;
184
+ const n = Number(trimmed);
185
+ if (!Number.isInteger(n) || n < 1)
186
+ return undefined;
187
+ return n;
188
+ }
151
189
  let registered = false;
152
190
  /**
153
191
  * Register the embedding backend. Idempotent within a process (mirrors
@@ -161,12 +199,16 @@ export async function registerEmbeddingsBackend() {
161
199
  try {
162
200
  const { register } = await import("harper-fabric-embeddings");
163
201
  const modelPath = benchModelPathOverride();
202
+ const threads = resolveEmbedThreads();
164
203
  await register({
165
204
  logicalName: LOGICAL_NAME,
166
205
  kind: "embedding",
167
- config: modelPath
168
- ? { modelPath, pooling: EMBEDDING_POOLING }
169
- : { modelName: MODEL_NAME, modelsDir: resolveModelsDir(), pooling: EMBEDDING_POOLING },
206
+ config: {
207
+ ...(modelPath
208
+ ? { modelPath, pooling: EMBEDDING_POOLING }
209
+ : { modelName: MODEL_NAME, modelsDir: resolveModelsDir(), pooling: EMBEDDING_POOLING }),
210
+ threads,
211
+ },
170
212
  });
171
213
  }
172
214
  catch (err) {
@@ -95,5 +95,7 @@ export async function resolveReadScope(authAgentId) {
95
95
  return false;
96
96
  return record.agentId === authAgentId || !isPrivateVisibility(record.visibility);
97
97
  };
98
+ // See ReadScope.isAllowed's doc: agentId + visibility, nothing else.
99
+ isAllowed.scopableOnly = true;
98
100
  return { allowedOwners: [authAgentId], condition, isAllowed };
99
101
  }
@@ -59,6 +59,7 @@ import { cosineSimilarity } from "./dedup.js";
59
59
  import { compositeScore } from "./scoring.js";
60
60
  import { buildBM25, fuseRrfNormalized, SEM_LIMIT } from "./bm25.js";
61
61
  import { isAllowedBm25Candidate } from "./bm25-filter.js";
62
+ import { indexedBm25Ids } from "./bm25-index-service.js";
62
63
  // Convert HNSW cosine distance (1 - similarity) to similarity score.
63
64
  function distanceToSimilarity(distance) {
64
65
  return 1 - distance;
@@ -154,23 +155,60 @@ export async function retrieveCandidates(params) {
154
155
  semIds.push(record.id);
155
156
  }
156
157
  }
157
- // ── (b) BM25 candidate records over the SCOPED corpus ────────────────
158
- const corpusQuery = conditions.length > 0
159
- ? { conditions, select }
160
- : { select };
161
- const corpusResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(corpusQuery));
158
+ // ── (b) The BM25 lexical leg ─────────────────────────────────────────
159
+ //
160
+ // flair#1357. This used to be unconditional: fetch the WHOLE scoped corpus
161
+ // out of Harper, then `buildBM25()` it — per query. That made retrieval
162
+ // latency linear in store size (5.6s p50 at 60k rows, 28.7s at 180k). The
163
+ // lexical leg is now served from a persistent, incrementally-maintained
164
+ // index (resources/bm25-index.ts) whose contract is RANKING-IDENTICAL:
165
+ // same ids, same order, byte for byte. The legacy scan below is still the
166
+ // reference implementation AND the fallback — the index returns null for
167
+ // any query it cannot reproduce exactly, and for a query with no text
168
+ // there is no lexical leg to serve at all.
162
169
  const allowedById = new Map();
163
- const bm25Docs = [];
164
- for await (const record of corpusResults) {
165
- // Defense-in-depth: re-check the SAME conditions[] + temporal filters
166
- // in-process. Even if a Harper query change ever let an out-of-scope
167
- // record through, it is dropped here BEFORE it can be BM25-scored/fused.
168
- if (!isAllowedBm25Candidate(record, conditions, { sinceDate, asOf }))
169
- continue;
170
- if (!passesAllowed(record))
171
- continue;
172
- allowedById.set(record.id, record);
173
- bm25Docs.push({ id: record.id, content: record.content });
170
+ let bm25Ids = [];
171
+ // Only the no-signal listing branch (d) still needs the full scoped
172
+ // corpus materialised; every other branch resolves records by id.
173
+ const needCorpusListing = !q && !qEmb;
174
+ let servedFromIndex = false;
175
+ if (q) {
176
+ const fromIndex = await indexedBm25Ids({
177
+ q: String(q),
178
+ conditions: conditions,
179
+ timeFilters: { sinceDate, asOf },
180
+ isAllowed,
181
+ limit: SEM_LIMIT,
182
+ ctx,
183
+ });
184
+ if (fromIndex) {
185
+ bm25Ids = fromIndex;
186
+ servedFromIndex = true;
187
+ }
188
+ }
189
+ if (!servedFromIndex && (q || needCorpusListing)) {
190
+ // ── Legacy path: scoped corpus scan + per-query buildBM25() ──────────
191
+ const corpusQuery = conditions.length > 0
192
+ ? { conditions, select }
193
+ : { select };
194
+ const corpusResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(corpusQuery));
195
+ const bm25Docs = [];
196
+ for await (const record of corpusResults) {
197
+ // Defense-in-depth: re-check the SAME conditions[] + temporal filters
198
+ // in-process. Even if a Harper query change ever let an out-of-scope
199
+ // record through, it is dropped here BEFORE it can be BM25-scored/fused.
200
+ if (!isAllowedBm25Candidate(record, conditions, { sinceDate, asOf }))
201
+ continue;
202
+ if (!passesAllowed(record))
203
+ continue;
204
+ allowedById.set(record.id, record);
205
+ bm25Docs.push({ id: record.id, content: record.content });
206
+ }
207
+ if (q) {
208
+ const bm25 = buildBM25(bm25Docs);
209
+ const ranked = bm25.rank(String(q));
210
+ bm25Ids = ranked.filter(r => r.score > 0).slice(0, SEM_LIMIT).map(r => r.id);
211
+ }
174
212
  }
175
213
  // Carry semantic candidates that survived their temporal gate into the
176
214
  // allowed map too (so a fused id always resolves to a record). Semantic
@@ -181,12 +219,45 @@ export async function retrieveCandidates(params) {
181
219
  allowedById.set(r.id, rest);
182
220
  }
183
221
  }
184
- // ── (c) BM25 lexical ranking top SEM_LIMIT (only when q present) ────
185
- let bm25Ids = [];
186
- if (q) {
187
- const bm25 = buildBM25(bm25Docs);
188
- const ranked = bm25.rank(String(q));
189
- bm25Ids = ranked.filter(r => r.score > 0).slice(0, SEM_LIMIT).map(r => r.id);
222
+ // ── (b2) Resolve the index-served BM25 candidates ─────────────────────
223
+ // On the indexed path there is no corpus map to read from, so a BM25-only
224
+ // rescue is point-looked-up and projected down to `select`. The projection
225
+ // reproduces Harper's own `search({select})` shape exactly — the keys of
226
+ // `select` that the row actually carries, in `select` declaration order
227
+ // (measured; pinned by test/integration/bm25-index-scan-order-1357.test.ts).
228
+ //
229
+ // The freshly-read row is then re-checked against the SAME conditions[] +
230
+ // temporal filters + scope predicate before it is allowed into the fusion.
231
+ // The index already applied all three to its own copy of the row; this is
232
+ // the Sherlock gate applied to the row we are actually about to return, so
233
+ // a stale index entry can only ever REMOVE a candidate, never smuggle an
234
+ // out-of-scope record into the union.
235
+ if (servedFromIndex) {
236
+ const resolved = [];
237
+ for (const id of bm25Ids) {
238
+ if (allowedById.has(id)) {
239
+ resolved.push(id);
240
+ continue;
241
+ }
242
+ const full = await withDetachedTxn(ctx, () => databases.flair.Memory.get(id));
243
+ if (!full)
244
+ continue;
245
+ if (!isAllowedBm25Candidate(full, conditions, { sinceDate, asOf }))
246
+ continue;
247
+ if (!passesAllowed(full))
248
+ continue;
249
+ const projected = {};
250
+ for (const key of select)
251
+ if (key in full)
252
+ projected[key] = full[key];
253
+ allowedById.set(id, projected);
254
+ if (qEmb) {
255
+ const storedEmbedding = Array.isArray(full.embedding) ? full.embedding : [];
256
+ semSimById.set(id, cosineSimilarity(qEmb, storedEmbedding));
257
+ }
258
+ resolved.push(id);
259
+ }
260
+ bm25Ids = resolved;
190
261
  }
191
262
  // ── (d) No retrieval signal at all → full scoped listing ────────────
192
263
  if (!q && !qEmb) {
@@ -18,6 +18,15 @@
18
18
  * - No advisory data — we don't know which release fixed which CVE, so the
19
19
  * severity heuristic is purely the version GAP (major/minor count), not
20
20
  * "did this release carry a security fix". See classifyGap().
21
+ *
22
+ * Honest-numbers refinement (flair#1341): the TTL fast-path is only taken
23
+ * when the cached answer implies NOTHING will be printed. When a cached
24
+ * answer would produce a nudge, we spend one fresh fetch (same short timeout,
25
+ * same failure tolerance) so the printed fact is current whenever possible —
26
+ * nudges are rare, so the TTL still protects the common up-to-date path. If
27
+ * that fetch fails, the nudge falls back to the cached value but SAYS so
28
+ * ("latest known (checked 9h ago): …") instead of stating a stale number as
29
+ * current fact.
21
30
  */
22
31
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
32
  import { homedir } from "node:os";
@@ -85,13 +94,23 @@ export function defaultVersionCheckDeps() {
85
94
  * Resolve the latest published @tpsdev-ai/flair version, preferring a fresh
86
95
  * cache hit over a network round trip, and falling back to a stale cache (or
87
96
  * giving up quietly) when the registry is unreachable. NEVER throws.
97
+ *
98
+ * flair#1341: the cache fast-path applies only when the cached answer implies
99
+ * no nudge. A cached answer that WOULD nudge triggers one fresh fetch (same
100
+ * timeout, same failure tolerance) so the printed fact is current whenever
101
+ * the network allows; on failure it falls back to the cache, age attached.
88
102
  */
89
103
  export async function checkVersion(installed, injected = {}) {
90
104
  const deps = { ...defaultVersionCheckDeps(), ...injected };
91
105
  const nowMs = deps.now();
92
106
  const cached = deps.readCache(deps.cachePath);
93
107
  if (cached && nowMs - cached.checkedAt < deps.ttlMs) {
94
- return { installed, latest: cached.latest, source: "cache" };
108
+ if (classifyGap(installed, cached.latest).severity === "none") {
109
+ return { installed, latest: cached.latest, source: "cache", checkedAgoMs: nowMs - cached.checkedAt };
110
+ }
111
+ // The cached answer would print a nudge — fall through to one fresh
112
+ // fetch so we present a CURRENT fact when possible. The failure path
113
+ // below still falls back to this same cache (offline tolerance intact).
95
114
  }
96
115
  // Defense-in-depth: the default fetchLatest already catches everything
97
116
  // internally (network error, timeout, non-2xx, bad JSON) and resolves
@@ -109,10 +128,10 @@ export async function checkVersion(installed, injected = {}) {
109
128
  deps.writeCache(deps.cachePath, { latest: fetched, checkedAt: nowMs });
110
129
  return { installed, latest: fetched, source: "network" };
111
130
  }
112
- // Registry unreachable/timed out — fall back to a stale cache rather than
131
+ // Registry unreachable/timed out — fall back to the cache rather than
113
132
  // reporting nothing, but never block or throw trying to get a fresh one.
114
133
  if (cached) {
115
- return { installed, latest: cached.latest, source: "cache" };
134
+ return { installed, latest: cached.latest, source: "cache", checkedAgoMs: nowMs - cached.checkedAt };
116
135
  }
117
136
  return { installed, latest: null, source: "unavailable" };
118
137
  }
@@ -129,7 +148,7 @@ export function primeVersionCheckCache(latest, injected = {}) {
129
148
  const now = injected.now ?? (() => Date.now());
130
149
  writeCacheFile(cachePath, { latest, checkedAt: now() });
131
150
  }
132
- const NO_GAP = { severity: "none", majorBehind: false, releasesBehind: 0 };
151
+ const NO_GAP = { severity: "none", majorBehind: false, unit: null, versionsBehind: 0 };
133
152
  /**
134
153
  * Classify how far `installed` is behind `latest` using major.minor.patch
135
154
  * math only — we don't have advisory data, so:
@@ -146,24 +165,43 @@ export function classifyGap(installed, latest) {
146
165
  const [aMaj, aMin, aPatch] = a;
147
166
  const [bMaj, bMin, bPatch] = b;
148
167
  if (bMaj > aMaj)
149
- return { severity: "red", majorBehind: true, releasesBehind: 0 };
168
+ return { severity: "red", majorBehind: true, unit: null, versionsBehind: 0 };
150
169
  if (bMaj < aMaj)
151
170
  return NO_GAP; // installed is ahead (e.g. local/pre-release build)
152
171
  if (bMin > aMin) {
153
- const releasesBehind = bMin - aMin;
154
- return { severity: releasesBehind >= 2 ? "red" : "yellow", majorBehind: false, releasesBehind };
172
+ const versionsBehind = bMin - aMin;
173
+ return { severity: versionsBehind >= 2 ? "red" : "yellow", majorBehind: false, unit: "minor", versionsBehind };
155
174
  }
156
175
  if (bMin < aMin)
157
176
  return NO_GAP; // ahead on minor
158
- if (bPatch > aPatch)
159
- return { severity: "yellow", majorBehind: false, releasesBehind: bPatch - aPatch };
177
+ if (bPatch > aPatch) {
178
+ return { severity: "yellow", majorBehind: false, unit: "patch", versionsBehind: bPatch - aPatch };
179
+ }
160
180
  return NO_GAP; // equal, or ahead on patch
161
181
  }
182
+ /** Compact human age for "checked … ago" — coarse on purpose (a nudge, not a log). */
183
+ function formatCheckedAgo(ms) {
184
+ const minutes = Math.round(ms / 60_000);
185
+ if (minutes < 60)
186
+ return `${Math.max(1, minutes)}m`;
187
+ const hours = Math.round(ms / 3_600_000);
188
+ if (hours < 48)
189
+ return `${hours}h`;
190
+ return `${Math.round(ms / 86_400_000)}d`;
191
+ }
162
192
  /**
163
193
  * Build the human-readable nudge line for `flair status`/`flair doctor`, or
164
194
  * null when there's nothing worth printing — current, ahead (local/dev
165
195
  * build), or we couldn't determine latest at all (offline with no cache).
166
196
  * Callers own icon/color; this returns plain text plus a severity to color by.
197
+ *
198
+ * flair#1341 honest-numbers contract:
199
+ * - A cache-sourced answer is labelled as such ("latest known (checked 9h
200
+ * ago): X"), never stated as current fact.
201
+ * - The count names its unit ("N minor versions behind" / "M patch
202
+ * releases behind") — it must say what classifyGap actually counted.
203
+ * - The suggested command is our paved path, `flair upgrade` (refreshes
204
+ * MCP pins, verifies restart — see flair#1324), not a bare npm install.
167
205
  */
168
206
  export function formatVersionNudge(result) {
169
207
  if (!result.latest)
@@ -171,11 +209,19 @@ export function formatVersionNudge(result) {
171
209
  const gap = classifyGap(result.installed, result.latest);
172
210
  if (gap.severity === "none")
173
211
  return null;
212
+ const latestClaim = result.source === "cache"
213
+ ? result.checkedAgoMs != null
214
+ ? `latest known (checked ${formatCheckedAgo(result.checkedAgoMs)} ago): ${result.latest}`
215
+ : `latest known: ${result.latest}`
216
+ : `latest is ${result.latest}`;
217
+ const plural = gap.versionsBehind === 1 ? "" : "s";
174
218
  const countHint = gap.majorBehind
175
- ? "major version"
176
- : `${gap.releasesBehind} release${gap.releasesBehind === 1 ? "" : "s"}`;
177
- const message = `flair ${result.installed} is behind — latest is ${result.latest} (${countHint} behind). ` +
178
- `Upgrade: npm i -g ${FLAIR_PKG_NAME}@latest`;
219
+ ? "major version behind"
220
+ : gap.unit === "patch"
221
+ ? `${gap.versionsBehind} patch release${plural} behind`
222
+ : `${gap.versionsBehind} minor version${plural} behind`;
223
+ const message = `flair ${result.installed} is behind — ${latestClaim} (${countHint}). ` +
224
+ `Run: flair upgrade`;
179
225
  return { severity: gap.severity, message };
180
226
  }
181
227
  /**
@@ -43,10 +43,15 @@ Copy this into your project's `CLAUDE.md` (or `.claude/settings.md`, `AGENTS.md`
43
43
 
44
44
  Run this FIRST, before doing anything else:
45
45
 
46
- flair bootstrap --agent my-project --max-tokens 4000
46
+ mcp__flair__bootstrap
47
47
 
48
+ (`mcp__flair__bootstrap` is Claude Code's namespaced name for the server's `bootstrap` tool.)
48
49
  Read the output — that's your soul and recent memories.
49
50
 
51
+ Use the CLI variant when MCP is not wired — previewing context yourself, a script, or any agent that can run a shell command:
52
+
53
+ flair bootstrap --agent my-project --max-tokens 4000
54
+
50
55
  ### During work
51
56
 
52
57
  - Remember something: `flair memory add --agent my-project --content "what you learned"`
@@ -121,10 +126,12 @@ export FLAIR_URL=http://localhost:19926 # default, only needed if custom
121
126
  Then the CLAUDE.md simplifies to:
122
127
 
123
128
  ## Memory
124
- - Bootstrap: `flair bootstrap`
129
+ - Bootstrap: `mcp__flair__bootstrap`
125
130
  - Remember: `flair memory add --content "what you learned"`
126
131
  - Search: `flair search "your query"`
127
132
 
133
+ Use `flair bootstrap` when MCP is not wired.
134
+
128
135
  ## Soul (Personality / Context)
129
136
 
130
137
  Want Claude Code to have consistent personality or project context? Set soul entries:
@@ -143,7 +150,7 @@ flair soul set --agent my-project --key review \
143
150
  --value "Check for: error handling, edge cases, performance implications, security."
144
151
  ```
145
152
 
146
- Soul entries are included in every `flair bootstrap` — they're the persistent context that shapes how Claude Code thinks about your project.
153
+ Soul entries are included in every bootstrap — they're the persistent context that shapes how Claude Code thinks about your project.
147
154
 
148
155
  ## Remote Flair
149
156
 
@@ -108,7 +108,7 @@ EXPOSE 19926
108
108
  CMD ["flair", "start", "--foreground"]
109
109
  ```
110
110
 
111
- Note: embeddings run on CPU in Docker (no Metal acceleration). Performance is acceptable for small-to-medium memory stores (< 10K memories).
111
+ Note: embeddings run on CPU in Docker (no Metal acceleration). Performance is acceptable for small-to-medium memory stores (< 10K memories). Thread count follows `FLAIR_EMBED_THREADS` (default `max(1, availableParallelism() − 1)`); pin it if the container CPU quota and the host you want to use disagree.
112
112
 
113
113
  ---
114
114
 
@@ -187,6 +187,16 @@ Set these in the Flair process environment (`~/Library/LaunchAgents/ai.tpsdev.fl
187
187
  | `HTTP_PORT` | Override the Harper HTTP port. Useful for sandboxes; production deployments should configure the port in `config.yaml` instead. | Rare. |
188
188
  | `FLAIR_OPS_BIND` | Bind address for the Harper **ops API**. Resolution order: `flair init --ops-bind` > this variable > the `opsBind` key `flair init` persists in `~/.flair/config.yaml` > `127.0.0.1`. Every Flair-managed Harper start re-asserts the resolved value, so the persisted key is what makes a choice survive `flair restart` / `flair upgrade`. | Only for deployments that genuinely need remote ops admin (multi-host / Fabric) — set it to `0.0.0.0`, or record it once with `flair init --ops-bind 0.0.0.0`. Single-host installs want the loopback default. |
189
189
 
190
+ ### Performance-related environment variables
191
+
192
+ These are read by the Harper process at boot (same places as the table above: launchd plist, systemd unit, component `.env` / Fabric env). They are **not** `config.yaml` keys — embedding registration is in-process and must not persist into Harper's config file.
193
+
194
+ | Variable | Default | What it does |
195
+ |----------|---------|--------------|
196
+ | `FLAIR_EMBED_THREADS` | `max(1, availableParallelism() − 1)` | CPU threads for in-process embedding (harper-fabric-embeddings / llama.cpp). Host-aware so a 4-core box does not inherit HFE's fixed 6, and an 8-vCPU ingest host is not stuck at 6 idle cores. One core is left for Harper's event loop and the OS. `availableParallelism()` respects a container CPU quota. Set a positive integer to pin. Invalid values fall back to the default. |
197
+ | `FLAIR_HYBRID_RETRIEVAL` | `true` | Hybrid BM25 + vector retrieval. Set `false` / `0` / `off` to revert to the legacy HNSW + keyword-bump path. |
198
+ | `FLAIR_MODELS_DIR` | `<data-dir>/models` | Directory the embedding GGUF is loaded from (and downloaded into on first boot). Point this at a pre-seeded directory to skip the HuggingFace download; see [troubleshooting.md](troubleshooting.md). |
199
+
190
200
  ---
191
201
 
192
202
  ## Backup & Restore
@@ -22,7 +22,7 @@ Where Flair already runs. Each integration shown here is a working surface — t
22
22
  | **OpenClaw** | [`openclaw-flair`](#openclaw) | Ed25519 | Native plugin + context engine |
23
23
  | **n8n** | [`n8n-nodes-flair`](#n8n) | FlairApi credential | Three nodes (chat memory, search, store) |
24
24
  | **Hermes Agent** | [`hermes-flair`](#hermes-agent) | Ed25519 | Python `MemoryProvider` |
25
- | **Pi agent** | [`pi-flair`](#pi-agent) | Ed25519 | TS plugin |
25
+ | **Pi agent** | [`pi-flair`](#pi-agent) | Ed25519 | Native pi extension (pi has no MCP support); `flair init --client pi` wires it, `flair doctor` checks it |
26
26
 
27
27
  Don't see your harness? If it speaks **MCP** — Flair already works with `flair-mcp`. If it has a **custom memory protocol** like LangGraph's `BaseStore` or CrewAI's `RAGStorage`, an adapter is a ~200-line package; [open an issue](https://github.com/tpsdev-ai/flair/issues) or [send a PR](https://github.com/tpsdev-ai/flair).
28
28
 
@@ -168,13 +168,34 @@ Auth: TPS-Ed25519 (the same model the rest of Flair uses) — writes are isolate
168
168
 
169
169
  ## Pi agent
170
170
 
171
- [`@tpsdev-ai/pi-flair`](https://www.npmjs.com/package/@tpsdev-ai/pi-flair) is the TS plugin for the [Pi coding agent](https://github.com/mariozechner/pi-coding-agent). Memory + identity for the Pi runtime.
171
+ [`@tpsdev-ai/pi-flair`](https://www.npmjs.com/package/@tpsdev-ai/pi-flair) is the **native pi extension** for the [Pi coding agent](https://github.com/mariozechner/pi-coding-agent) — pi has no MCP client support, so this is a first-party plugin, not an MCP bridge. Memory + identity (`memory_search`, `memory_store`, `bootstrap`) for the pi runtime.
172
+
173
+ Wire it (either form is equivalent):
174
+
175
+ ```bash
176
+ flair init --client pi # writes a pinned "packages" entry into ~/.pi/agent/settings.json
177
+ # or
178
+ pi install npm:@tpsdev-ai/pi-flair
179
+ ```
180
+
181
+ Which produces:
182
+
183
+ ```json
184
+ {
185
+ "packages": ["npm:@tpsdev-ai/pi-flair@<version>"]
186
+ }
187
+ ```
188
+
189
+ **Known trap:** the `extensions` settings key takes local file paths only — an `npm:` spec there is *silently ignored* by pi, so the tools never register ([#1346](https://github.com/tpsdev-ai/flair/issues/1346)). Package sources belong under `packages`. `flair doctor` detects pi, verifies the wiring, calls this exact misconfiguration out, and `flair doctor --fix` moves the entry.
190
+
191
+ pi settings carry no per-package env, so identity comes from the environment that launches pi:
172
192
 
173
193
  ```bash
174
- npm install @tpsdev-ai/pi-flair
194
+ export FLAIR_AGENT_ID=my-agent # per host/purpose
195
+ pi
175
196
  ```
176
197
 
177
- Pi resolves the plugin via its standard plugin config; pin `agentId` per host.
198
+ Full details (tools, env reference, auto-recall/auto-capture flags, security notes): [`packages/pi-flair/README.md`](../packages/pi-flair/README.md).
178
199
 
179
200
  ---
180
201
 
@@ -266,6 +266,24 @@ The MCP server has no client-side flags beyond these env vars; everything else (
266
266
 
267
267
  ---
268
268
 
269
+ ## What about pi?
270
+
271
+ pi has no MCP client support, so Flair ships a **native pi extension** instead: [`@tpsdev-ai/pi-flair`](../packages/pi-flair/README.md). Same backend, same agent isolation, zero MCP in the path.
272
+
273
+ Wiring is a `packages` entry in pi's own settings (`~/.pi/agent/settings.json`), not an `mcpServers` block:
274
+
275
+ ```json
276
+ {
277
+ "packages": ["npm:@tpsdev-ai/pi-flair@<version>"]
278
+ }
279
+ ```
280
+
281
+ `flair init --client pi` writes exactly that (pinned), or use pi's own installer: `pi install npm:@tpsdev-ai/pi-flair`. `flair doctor` detects pi and checks the wiring — including the one known trap: **an `npm:` spec under the `extensions` settings key is silently ignored by pi** (`extensions` takes local file paths only; package sources belong under `packages` — [#1346](https://github.com/tpsdev-ai/flair/issues/1346)). Doctor calls that misconfiguration out by name, and `flair doctor --fix` moves the entry.
282
+
283
+ One difference from the MCP clients above: pi settings carry no per-package `env` block, so `FLAIR_AGENT_ID` (and `FLAIR_URL` when non-default) must be exported in the environment that launches pi. Doctor reports what it sees in its own shell and says so — it cannot observe the environment of every pi launch.
284
+
285
+ ---
286
+
269
287
  ## What about Hermes (Nous Research)?
270
288
 
271
289
  Hermes uses its own Python-native `MemoryProvider` ABC instead of MCP. It has its own Flair integration in [`packages/hermes-flair/`](../packages/hermes-flair). Same backend, same agent isolation, different plug shape.
@@ -61,25 +61,43 @@ opt-in, never something the server infers. The practical consequences:
61
61
  agent id to attach the sub to it; the step's output states the resulting
62
62
  `sub → Agent` mapping in as many words.
63
63
  - **Linking a sub to an existing Agent (the same-identity opt-in).** Re-run
64
- `flair mcp enable` with the SAME `--idp-provider`/`--idp-subject` and
65
- `--principal <your-cli-agent-id>`. The existing `(provider, subject)`
66
- Credential is RE-POINTED to that principal — one Credential row per subject,
67
- so resolution stays deterministic. The link *replaces* the mapping; it does
68
- not merge the two agents' memories.
64
+ `flair mcp enable` with the SAME `--idp-subject` and `--principal
65
+ <your-cli-agent-id>`. The existing Credential for that subject is RE-POINTED
66
+ to that principal — one ACTIVE Credential row per subject, so resolution stays
67
+ deterministic. The link *replaces* the mapping; it does not merge the two
68
+ agents' memories.
69
69
  - **First diagnostic: ask the server who you are.** The `bootstrap` tool's
70
70
  response always carries the resolved `agentId` and a `scope` descriptor
71
71
  (`scope.agentId` / `scope.isAdmin` / `scope.reads`, flair#1182). "My memory
72
72
  is empty over the connector" + a `bootstrap.agentId` you don't recognize =
73
73
  the sub resolved to a different (often JIT-provisioned) Agent — link it as
74
74
  above.
75
- - **JIT caveat.** A JIT-provisioned mapping (`FLAIR_MCP_JIT_PROVISION=1`)
76
- stamps `idpProvider: "mcp-oauth"`. Runtime resolution matches on
77
- `(kind, idpSubject)` only, but the *linking* upsert matches on
78
- `(kind, idpProvider, idpSubject)` — so when re-linking a JIT-provisioned
79
- sub, pass `--idp-provider mcp-oauth` (matching the JIT stamp), or first
80
- revoke the JIT credential (`status: "revoked"`). Linking under a different
81
- provider name creates a SECOND active credential for the same subject, and
82
- which one wins resolution is unspecified.
75
+ - **Re-linking under a different provider name SUPERSEDES (flair#1317).** A
76
+ JIT-provisioned mapping (`FLAIR_MCP_JIT_PROVISION=1`) stamps `idpProvider:
77
+ "mcp-oauth"`, so re-linking that sub as, say, `github` is a *provider change*.
78
+ The invariant is **at most one ACTIVE `Credential(kind:"idp", idpSubject)` per
79
+ subject, regardless of provider** the same key runtime resolution uses. So
80
+ the link revokes the prior credential (terminal `status: "revoked"`, the row
81
+ retained for audit) and writes the new one, in a single batched write. You do
82
+ NOT need to match `--idp-provider` to the JIT stamp, and you do not need to
83
+ revoke anything by hand first. `provisionIdpIdentityMapping` returns
84
+ `credentialSuperseded: true` with the revoked ids, and the `flair mcp enable`
85
+ identity-mapping step prints them.
86
+
87
+ Read `credentialSuperseded` as **"the prior credential for this subject is now
88
+ dead"**, not "a duplicate was tidied up". `idpProvider` is audit/diagnostic
89
+ metadata on the row; it does not namespace the subject. Residual risk, ruled
90
+ acceptable (Sherlock, #1317): anyone who can run the link for a subject can
91
+ revoke that subject's existing credential, so two genuinely different people
92
+ sharing one subject string across providers would evict each other. IdP
93
+ subjects are opaque per-IdP identifiers, so this is remote — and the
94
+ alternative, duplicate active credentials resolved by iteration order, is
95
+ strictly worse.
96
+
97
+ Before the fix, the linking upsert deduped on `(kind, idpProvider,
98
+ idpSubject)` while resolution read `(kind, idpSubject)`, so a cross-provider
99
+ re-link silently created a SECOND active credential and which one won was
100
+ unspecified.
83
101
 
84
102
  The two-identity contract (a distinct connector agent sees other agents'
85
103
  org-non-private rows, never their private rows, 404-never-403 by id; a linked
@@ -164,6 +164,14 @@ Add `--explain` to see the ranking inputs per hit — the raw score, the composi
164
164
 
165
165
  ## 6. Give your agent context on boot
166
166
 
167
+ With MCP wired (`flair init` does this for every client it detects), the recommended session-start is the `bootstrap` tool. In Claude Code that appears as `mcp__flair__bootstrap` (Claude Code's namespaced name for the server's `bootstrap` tool). Add this to your `CLAUDE.md`:
168
+
169
+ ```
170
+ At the start of every session, run mcp__flair__bootstrap before responding.
171
+ ```
172
+
173
+ Use the CLI variant — `flair bootstrap --agent <id>` — when MCP is not wired: previewing context yourself, a script, or any agent that can run a shell command.
174
+
167
175
  ```bash
168
176
  flair bootstrap --agent local --max-tokens 2000
169
177
  ```
@@ -174,15 +182,7 @@ flair bootstrap --agent local --max-tokens 2000
174
182
  ✓ budget 20/2000 tokens (1%) · ✓ 1 included · ✓ 0 truncated
175
183
  ```
176
184
 
177
- Soul entries and relevant memories, in one block sized to a token budget. Paste it into any LLM session Claude Code, Codex, Cursor, an API call — to hand the agent its identity and memory in one shot.
178
-
179
- Using Claude Code? Add this to your `CLAUDE.md`:
180
-
181
- ```
182
- At the start of every session, run mcp__flair__bootstrap before responding.
183
- ```
184
-
185
- With the MCP server wired up — `flair init` does this automatically for every client it detects — Claude Code runs bootstrap on every new session. See the [integration section in README.md](../README.md#integration).
185
+ Soul entries and relevant memories, in one block sized to a token budget. Paste that CLI output into any LLM session that does not have the MCP server — Codex, Cursor, an API call — to hand the agent its identity and memory in one shot. See the [integration section in README.md](../README.md#integration).
186
186
 
187
187
  ## What's next
188
188
 
@@ -107,6 +107,9 @@ logging:
107
107
  | `HDB_ADMIN_PASSWORD` | Bootstrap password for the embedded Harper. After first start, the persisted user record is the source of truth. | Set at install time. See [secrets-and-keys.md](secrets-and-keys.md) for rotation. |
108
108
  | `FLAIR_KEY_PASSPHRASE` | Passphrase for AES-256-GCM encryption of federation private-key seeds. | Set explicitly for production federation deployments. |
109
109
  | `FLAIR_URL` | Override the Flair base URL for CLI commands (points to a remote instance). | When connecting from a different machine. |
110
+ | `FLAIR_EMBED_THREADS` | CPU threads for in-process embedding. Default is `max(1, availableParallelism() − 1)` — host-aware, one core left for Harper. | Pin a positive integer on a dedicated ingest host, or when the default leaves cores idle / oversubscribed. See [deployment.md](deployment.md#performance-related-environment-variables). |
111
+ | `FLAIR_HYBRID_RETRIEVAL` | Hybrid BM25 + vector retrieval (default on). | Set `false` to revert to HNSW-only. |
112
+ | `FLAIR_MODELS_DIR` | Directory the embedding GGUF is loaded from. | When the model lives outside `<data-dir>/models`. |
110
113
 
111
114
  ---
112
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.48.0",
3
+ "version": "0.49.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",
@@ -83,7 +83,8 @@
83
83
  "@opentelemetry/core": "^2.8.0",
84
84
  "uuid": "^11.1.1",
85
85
  "tar": "^7.5.22",
86
- "@tootallnate/once": "^2.0.1"
86
+ "@tootallnate/once": "^2.0.1",
87
+ "form-data": "^4.0.6"
87
88
  },
88
89
  "devDependencies": {
89
90
  "@playwright/test": "1.59.1",