@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.
- package/README.md +2 -0
- package/dist/build-info.json +3 -3
- package/dist/cli.js +525 -121
- package/dist/component-env.js +52 -4
- package/dist/doctor-client.js +46 -1
- package/dist/hook-install.js +52 -4
- package/dist/install/clients.js +318 -9
- package/dist/lib/auth-resolve.js +34 -3
- package/dist/lib/mcp-enable.js +134 -26
- package/dist/resources/AgentSeed.js +2 -0
- package/dist/resources/Memory.js +24 -5
- package/dist/resources/MemoryFeed.js +3 -0
- package/dist/resources/MemoryMaintenance.js +11 -2
- package/dist/resources/bm25-index-service.js +257 -0
- package/dist/resources/bm25-index.js +631 -0
- package/dist/resources/bm25.js +31 -1
- package/dist/resources/embeddings-boot.js +45 -3
- package/dist/resources/memory-read-scope.js +2 -0
- package/dist/resources/semantic-retrieval-core.js +93 -22
- package/dist/version-check.js +59 -13
- package/docs/claude-code.md +10 -3
- package/docs/deployment.md +11 -1
- package/docs/integrations.md +25 -4
- package/docs/mcp-clients.md +18 -0
- package/docs/notes/mcp-oauth-model2.md +31 -13
- package/docs/quickstart.md +9 -9
- package/docs/standalone-local.md +3 -0
- package/package.json +3 -2
|
@@ -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:
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
// ── (
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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) {
|
package/dist/version-check.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
154
|
-
return { severity:
|
|
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,
|
|
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
|
-
:
|
|
177
|
-
|
|
178
|
-
|
|
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
|
/**
|
package/docs/claude-code.md
CHANGED
|
@@ -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
|
-
|
|
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: `
|
|
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
|
|
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
|
|
package/docs/deployment.md
CHANGED
|
@@ -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
|
package/docs/integrations.md
CHANGED
|
@@ -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 |
|
|
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
|
|
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
|
-
|
|
194
|
+
export FLAIR_AGENT_ID=my-agent # per host/purpose
|
|
195
|
+
pi
|
|
175
196
|
```
|
|
176
197
|
|
|
177
|
-
|
|
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
|
|
package/docs/mcp-clients.md
CHANGED
|
@@ -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-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
- **
|
|
76
|
-
stamps `idpProvider:
|
|
77
|
-
|
|
78
|
-
`(kind,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
package/docs/quickstart.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/standalone-local.md
CHANGED
|
@@ -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.
|
|
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",
|