@tpsdev-ai/flair 0.27.1 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,37 +9,86 @@
9
9
  *
10
10
  * Two inference modes (selected by FLAIR_RERANK_MODEL):
11
11
  *
12
- * 1. "qwen3-reranker-0.6b-q8" (DEFAULT, quality) — Qwen3-Reranker-0.6B is a
13
- * causal-LM reranker, NOT a rank-pooling cross-encoder. Its GGUF reports
14
- * supportsRanking=false, so node-llama-cpp's createRankingContext() rejects
15
- * it. The correct path is generative: format query+doc into the official
16
- * instruction prompt ending at the assistant turn, evaluate, and read the
17
- * next-token probability of "yes" vs "no":
18
- * score = P(yes) / (P(yes) + P(no))
19
- * Implemented via seq.controlledEvaluate with generateNext probabilities.
20
- * (Validated offline in RERANK-PILOT-RESULTS.md: 4/4 target cases flipped
21
- * positive, p@1 6/8→8/8, mean margin 0.028→0.688, ~1.2s / 16 docs on rockit.)
22
- *
23
- * 2. "jina-reranker-v2" (latency fallback) — jina-reranker-v2 IS a rank-pooling
12
+ * 1. "jina-reranker-v2" (DEFAULT, working) — jina-reranker-v2 IS a rank-pooling
24
13
  * cross-encoder; its gpustack GGUF loads via createRankingContext()/rankAll
25
- * (supportsRanking=true). ~145ms / 16 docs but weaker (7/8, leaves the
26
- * hardest consensus case negative). Selectable where latency is tight.
14
+ * (supportsRanking=true). ~145ms / 16 docs in the offline pilot (7/8,
15
+ * leaves the hardest consensus case negative weaker than qwen3's
16
+ * generative path in that pilot, but it's the path that actually PRODUCES
17
+ * a score inside Harper's process — see #811 / point 2 below).
18
+ *
19
+ * 2. "qwen3-reranker-0.6b-q8" (EXPERIMENTAL, quality-if-it-worked) —
20
+ * Qwen3-Reranker-0.6B is a causal-LM reranker, NOT a rank-pooling
21
+ * cross-encoder. Its GGUF reports supportsRanking=false, so
22
+ * createRankingContext() rejects it; the intended path is generative:
23
+ * format query+doc into the official instruction prompt ending at the
24
+ * assistant turn, evaluate, and read the next-token probability of "yes"
25
+ * vs "no": score = P(yes) / (P(yes) + P(no)), via seq.controlledEvaluate
26
+ * with generateNext probabilities. Validated OFFLINE, standalone, in
27
+ * RERANK-PILOT-RESULTS.md (4/4 target cases flipped positive, p@1
28
+ * 6/8→8/8, mean margin 0.028→0.688, ~1.2s / 16 docs on rockit) — but
29
+ * flair#811 found that INSIDE Harper's resource runtime,
30
+ * controlledEvaluate reliably returns empty logits (no decoded output),
31
+ * so every generative call throws "generative reranker produced no
32
+ * logits" and falls open. Root cause per docs/rerank-provisioning.md's
33
+ * "Known limitation": HFE's embedding engine has already initialized a
34
+ * separate native llama backend in the same process before this
35
+ * provider's own dynamic import runs, and the low-level
36
+ * controlledEvaluate + custom-sampler logit readout the generative path
37
+ * needs doesn't survive that dual-backend residency (ordinary model
38
+ * loading and the jina rank-pooling call both work fine across it — only
39
+ * this specific low-level readout is affected). Kept available and
40
+ * documented for whoever revisits dual-backend isolation; NOT the
41
+ * default until that's fixed.
42
+ *
43
+ * Context-budget truncation (flair#811 point 1): real memory content is
44
+ * routinely far longer than either model's small context window. Every
45
+ * (query, doc) pair is bounded BEFORE it reaches the engine — a cheap char
46
+ * pre-cut (`truncateChars`, avoids tokenizing pathological multi-MB content)
47
+ * followed by an exact token-level cut (`truncateForModel`, uses the loaded
48
+ * model's own tokenizer — the real guarantee, since char/token ratio varies
49
+ * a lot across prose/code/CJK/emoji). Budgets are derived from the context
50
+ * size each mode actually requests (both NUMERIC, not "auto" — node-llama-cpp
51
+ * grants a numeric contextSize exactly as asked, see GENERATIVE_CONTEXT_SIZE/
52
+ * RANK_CONTEXT_SIZE below) minus reserved template + query overhead. This
53
+ * makes the `rankAll`/context-overflow throw effectively unreachable in
54
+ * normal operation instead of guaranteed on any real-length memory.
55
+ *
56
+ * Config re-validated on every use (flair#811 point 3): `ensureInit()` used
57
+ * to cache `_modelKey`/`_state` permanently on first call — a later change to
58
+ * FLAIR_RERANK_MODEL (or a transient first-call failure) had NO effect for
59
+ * the lifetime of the process, since `_state === "ready"` (or `"failed"`)
60
+ * short-circuited every subsequent call without re-reading env. `needsReinit()`
61
+ * now compares the currently-resolved model key against what's actually
62
+ * loaded and re-initializes (disposing old handles first) whenever they
63
+ * differ, so "configured model X, served model Y" can no longer persist
64
+ * silently across calls. This is the most likely, directly-fixable
65
+ * code-level cause of the model-mismatch symptom reported in #811; without a
66
+ * live process repro we can't rule out an additional contributing factor,
67
+ * but this closes the gap regardless of the exact prior trigger.
27
68
  *
28
69
  * Serving path = the same in-process node-llama-cpp the embedding engine ships.
29
70
  * No Ollama (no logprobs, no rerank endpoint — verified), no network hop, no
30
71
  * new auth boundary.
31
72
  *
32
- * GGUF files are NOT committed; they are provisioned into models/ alongside the
33
- * 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.
34
80
  */
35
81
  import { join } from "node:path";
36
- // Known reranker models GGUF filename + inference mode. The default is the
37
- // quality model (Qwen3 generative). jina is the latency model (rank API).
82
+ import { resolveModelsDir } from "./models-dir.js";
83
+ // Known reranker models → GGUF filename + inference mode. jina is the DEFAULT
84
+ // (working — see file header, flair#811): its rank-pooling path completes
85
+ // inside Harper. qwen3 is EXPERIMENTAL: its generative path is validated
86
+ // offline but reliably fails open inside Harper today (empty logits).
38
87
  const MODELS = {
39
- "qwen3-reranker-0.6b-q8": { file: "Qwen3-Reranker-0.6B-q8_0.gguf", mode: "generative" },
40
88
  "jina-reranker-v2": { file: "jina-reranker-v2-base.Q8_0.gguf", mode: "rank" },
89
+ "qwen3-reranker-0.6b-q8": { file: "Qwen3-Reranker-0.6B-q8_0.gguf", mode: "generative" },
41
90
  };
42
- const DEFAULT_MODEL = "qwen3-reranker-0.6b-q8";
91
+ const DEFAULT_MODEL = "jina-reranker-v2";
43
92
  // Official Qwen3-Reranker prompt scaffold (generative yes/no judgement).
44
93
  const QWEN_PREFIX = '<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be "yes" or "no".<|im_end|>\n<|im_start|>user\n';
45
94
  const QWEN_SUFFIX = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n";
@@ -47,9 +96,71 @@ const QWEN_INSTRUCT = "Given a web search query, retrieve relevant passages that
47
96
  function buildQwenPrompt(q, doc) {
48
97
  return `${QWEN_PREFIX}<Instruct>: ${QWEN_INSTRUCT}\n<Query>: ${q}\n<Document>: ${doc}${QWEN_SUFFIX}`;
49
98
  }
50
- // Cap per-document content fed to the reranker. Short atomic notes are the norm;
51
- // this bounds context blow-up + latency on the occasional huge memory.
52
- const MAX_DOC_CHARS = 2000;
99
+ // ── Context-budget truncation (flair#811 point 1) ──────────────────────────
100
+ // See the file header for the two-layer rationale. Budgets are derived from
101
+ // the context size each mode actually requests, minus reserved overhead —
102
+ // not a flat guess detached from either number.
103
+ /** Context size requested for the generative (Qwen3) context. NUMERIC (not
104
+ * "auto"), so node-llama-cpp grants exactly this many tokens — verified
105
+ * against node-llama-cpp 3.18.1's resolveContextContextSizeOption: a numeric
106
+ * contextSize is granted as-is (or context creation throws
107
+ * InsufficientMemoryError); only "auto"/object requests get VRAM-adaptive
108
+ * shrinking. */
109
+ const GENERATIVE_CONTEXT_SIZE = 1024;
110
+ /** Context size requested for the rank (jina) context — see
111
+ * createRankingContext() below. Also numeric, same guarantee. */
112
+ const RANK_CONTEXT_SIZE = 2048;
113
+ /** Reserved tokens for the Qwen3 prompt scaffold (QWEN_PREFIX + INSTRUCT +
114
+ * SUFFIX + <|im_start|>/<|im_end|> special tokens). Rough estimate from the
115
+ * literal scaffold text is ~130-140 tokens; 180 leaves real headroom above
116
+ * that estimate rather than sitting right on top of it — we don't have the
117
+ * actual Qwen3-Reranker tokenizer available to measure exactly (no GGUF in
118
+ * this worktree), so this errs generous. Belt-and-suspenders: scoreGenerative
119
+ * also hard-checks the FINAL built prompt against GENERATIVE_CONTEXT_SIZE
120
+ * and throws (fail-open) rather than proceeding if this margin ever isn't
121
+ * enough — see there. */
122
+ const GENERATIVE_TEMPLATE_OVERHEAD_TOKENS = 180;
123
+ /** Rank mode's DEFAULT template (no GGUF `chat_template.rerank` metadata) is
124
+ * just BOS + EOS + SEP + EOS — ~4 tokens. But if the loaded GGUF DOES carry
125
+ * a custom rerank template (LlamaRankingContext prefers it when present),
126
+ * that template's literal text adds more; we can't inspect the actual jina
127
+ * GGUF's metadata from this worktree (no model files here), so this reserves
128
+ * well above the no-template case. If the real template needs more than
129
+ * this, `rankAll()` still throws its own clean, caught error (existing
130
+ * fail-open path) rather than corrupting anything. */
131
+ const RANK_TEMPLATE_OVERHEAD_TOKENS = 64;
132
+ /** Queries are normally a handful of words. Bounding them caps the worst
133
+ * case so a pathologically long query can never eat the whole doc budget or
134
+ * overflow the context on its own. */
135
+ const MAX_QUERY_TOKENS = 128;
136
+ /** Per-mode doc token budgets: context size minus template overhead minus
137
+ * the reserved query budget. This is the number `truncateForModel()` enforces
138
+ * exactly (via the model's own tokenizer) for candidate document text. */
139
+ const GENERATIVE_DOC_BUDGET_TOKENS = GENERATIVE_CONTEXT_SIZE - GENERATIVE_TEMPLATE_OVERHEAD_TOKENS - MAX_QUERY_TOKENS;
140
+ const RANK_DOC_BUDGET_TOKENS = RANK_CONTEXT_SIZE - RANK_TEMPLATE_OVERHEAD_TOKENS - MAX_QUERY_TOKENS;
141
+ // Cheap CHAR pre-cuts — layer 1 (see file header). Deliberately generous
142
+ // (not a tight token-accurate estimate): their only job is keeping us from
143
+ // ever tokenizing a pathological multi-MB memory blob before layer 2
144
+ // (`truncateForModel`'s exact token-level cut) does the real enforcement.
145
+ const GENERATIVE_DOC_CHAR_PRECUT = 2000;
146
+ const RANK_DOC_CHAR_PRECUT = 5000;
147
+ const QUERY_CHAR_PRECUT = 800;
148
+ /** Pure, trivial char-length cap — layer 1 of truncation. Returns `text`
149
+ * UNCHANGED (same reference) when already within budget, so callers can
150
+ * skip unnecessary work; otherwise returns the first `maxChars` characters.
151
+ * Exported for unit testing (see test/unit/rerank-provider.test.ts). */
152
+ export function truncateChars(text, maxChars) {
153
+ if (text.length <= maxChars)
154
+ return text;
155
+ return text.slice(0, Math.max(0, maxChars));
156
+ }
157
+ /** Pure, trivial token-array cap — layer 2's core slice. Exported for unit
158
+ * testing independent of a real tokenizer. */
159
+ export function truncateTokenBudget(tokens, maxTokens) {
160
+ if (tokens.length <= maxTokens)
161
+ return tokens;
162
+ return tokens.slice(0, Math.max(0, maxTokens));
163
+ }
53
164
  let _state = "uninitialized";
54
165
  let _initError;
55
166
  let _warnedOnce = false;
@@ -76,6 +187,45 @@ function resolveModelKey() {
76
187
  return requested;
77
188
  return DEFAULT_MODEL;
78
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
+ }
204
+ /**
205
+ * Decide whether `ensureInit()` needs to (re)run the engine init sequence —
206
+ * the flair#811 point-3 fix. Pure so the decision matrix is directly
207
+ * unit-testable without touching the native engine.
208
+ *
209
+ * - Never initialized → always init.
210
+ * - Currently loaded model differs from what's now configured → always
211
+ * (re)init, regardless of whether the PREVIOUS attempt was "ready" or
212
+ * "failed" — a config change deserves a fresh attempt under the new
213
+ * config. This is the fix: the old code short-circuited on `_state ===
214
+ * "ready"`/`"failed"` unconditionally, so a later FLAIR_RERANK_MODEL
215
+ * change (or a transient first-call failure under a config that was since
216
+ * corrected) had NO effect for the life of the process.
217
+ * - Same model, already "ready" → no-op (don't reload a loaded GGUF).
218
+ * - Same model, already "failed" → no-op (don't retry-storm a config that's
219
+ * still broken; avoids hammering a persistently-unavailable engine on
220
+ * every search).
221
+ */
222
+ export function needsReinit(state, cachedModelKey, requestedModelKey) {
223
+ if (state === "uninitialized")
224
+ return true;
225
+ if (cachedModelKey !== requestedModelKey)
226
+ return true;
227
+ return false;
228
+ }
79
229
  /** Discover the platform addon binary the same way embeddings-provider.ts does. */
80
230
  async function findAddonPath() {
81
231
  const { existsSync } = await import("node:fs");
@@ -88,18 +238,25 @@ async function findAddonPath() {
88
238
  return undefined;
89
239
  }
90
240
  async function ensureInit() {
91
- if (_state === "ready")
241
+ const requested = resolveModelKey();
242
+ if (!needsReinit(_state, _modelKey, requested))
92
243
  return;
93
- if (_state === "failed")
94
- return; // already logged once don't thrash
244
+ // Reinitializing under a new config (or first-ever init) — drop any
245
+ // previously loaded engine handles and let a fresh init attempt warn again
246
+ // if IT fails too (a new config's failure is a new fact, not a repeat of
247
+ // the old one).
248
+ if (_state === "ready")
249
+ await disposeHandles().catch(() => { });
250
+ _state = "uninitialized";
251
+ _warnedOnce = false;
95
252
  try {
96
- _modelKey = resolveModelKey();
253
+ _modelKey = requested;
97
254
  const spec = MODELS[_modelKey];
98
255
  _mode = spec.mode;
99
256
  const { existsSync } = await import("node:fs");
100
- const modelPath = join(process.cwd(), "models", spec.file);
257
+ const modelPath = resolveRerankModelPath(spec.file);
101
258
  if (!existsSync(modelPath)) {
102
- 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)`);
103
260
  }
104
261
  // Dynamic import — deferred to avoid Harper 5.x VM linker race (same reason
105
262
  // embeddings-provider.ts defers harper-fabric-embeddings).
@@ -116,7 +273,7 @@ async function ensureInit() {
116
273
  _llama = await _nlc.getLlama();
117
274
  _model = await _llama.loadModel({ modelPath });
118
275
  if (_mode === "generative") {
119
- _ctx = await _model.createContext({ contextSize: 1024 });
276
+ _ctx = await _model.createContext({ contextSize: GENERATIVE_CONTEXT_SIZE });
120
277
  _seq = _ctx.getSequence();
121
278
  // Resolve yes/no token ids (both case variants — the post-</think>
122
279
  // position puts mass on "yes"/"Yes").
@@ -131,9 +288,11 @@ async function ensureInit() {
131
288
  if (!_model.fileInsights?.supportsRanking) {
132
289
  throw new Error(`model ${spec.file} does not support ranking (not a rank-pooling cross-encoder)`);
133
290
  }
134
- // Context must fit query + the longest document (jina concatenates them).
135
- // 512 is too small for real memories; 2048 covers MAX_DOC_CHARS + query.
136
- _rankCtx = await _model.createRankingContext({ contextSize: 2048 });
291
+ // Context must fit query + the longest document (jina concatenates
292
+ // them). 512 is too small for real memories; RANK_CONTEXT_SIZE (2048)
293
+ // is what RANK_DOC_BUDGET_TOKENS/truncateForModel() are derived from —
294
+ // see the file header.
295
+ _rankCtx = await _model.createRankingContext({ contextSize: RANK_CONTEXT_SIZE });
137
296
  }
138
297
  _state = "ready";
139
298
  }
@@ -182,9 +341,51 @@ async function resetSequence() {
182
341
  catch { /* ignore */ }
183
342
  _seq = _ctx.getSequence();
184
343
  }
344
+ /**
345
+ * Bound `text` to at most `maxTokens` tokens using the loaded model's own
346
+ * tokenizer — layer 2 of the truncation scheme (see file header). Layer 1's
347
+ * cheap char pre-cut runs first (`truncateChars`, avoids tokenizing
348
+ * pathological multi-MB content); if the pre-cut string still tokenizes over
349
+ * budget (dense code/CJK/emoji content packs more tokens per char than the
350
+ * pre-cut assumes), the token array itself is cut and detokenized back to
351
+ * text. This is the actual guarantee: whatever this returns tokenizes to
352
+ * AT MOST `maxTokens` tokens (specialTokens=false — plain content text, no
353
+ * markup interpretation), full stop. Not exported/pure (needs `_model`); its
354
+ * two building blocks (`truncateChars`, `truncateTokenBudget`) are each unit
355
+ * tested directly.
356
+ */
357
+ function truncateForModel(text, maxChars, maxTokens) {
358
+ const precut = truncateChars(text, maxChars);
359
+ const tokens = _model.tokenize(precut, false);
360
+ if (tokens.length <= maxTokens)
361
+ return precut;
362
+ const bounded = truncateTokenBudget(Array.from(tokens), maxTokens);
363
+ return _model.detokenize(bounded, false);
364
+ }
185
365
  /** Generative yes/no score for one (query, doc) pair. */
186
366
  async function scoreGenerative(q, doc) {
187
- const tokens = _model.tokenize(buildQwenPrompt(q, doc.slice(0, MAX_DOC_CHARS)), true);
367
+ // Bound query + doc BEFORE building the prompt (flair#811 point 1) — see
368
+ // truncateForModel's doc and the file header for the two-layer rationale.
369
+ // Each is tokenized/bounded independently, then concatenated into the
370
+ // template; GENERATIVE_TEMPLATE_OVERHEAD_TOKENS' margin absorbs the small
371
+ // boundary-tokenization variance a separate-then-concatenate cut can
372
+ // introduce (BPE isn't always compositional across a splice point).
373
+ const boundedQuery = truncateForModel(q, QUERY_CHAR_PRECUT, MAX_QUERY_TOKENS);
374
+ const boundedDoc = truncateForModel(doc, GENERATIVE_DOC_CHAR_PRECUT, GENERATIVE_DOC_BUDGET_TOKENS);
375
+ const tokens = _model.tokenize(buildQwenPrompt(boundedQuery, boundedDoc), true);
376
+ // Belt-and-suspenders: the per-field bounding above reserves
377
+ // GENERATIVE_TEMPLATE_OVERHEAD_TOKENS of margin for the template, but a
378
+ // splice-boundary tokenization surprise is still conceivable. We can't
379
+ // truncate the COMBINED prompt itself (it would cut the required
380
+ // assistant-turn suffix the model needs to answer at the right position),
381
+ // so if it's still over budget here, throw cleanly and let the caller fall
382
+ // open — better than handing an oversized prompt to controlledEvaluate,
383
+ // which doesn't throw on overflow the way rankAll does and could silently
384
+ // context-shift/corrupt instead (a plausible contributor to the "empty
385
+ // logits" symptom in #811, alongside the documented dual-backend issue).
386
+ if (tokens.length > GENERATIVE_CONTEXT_SIZE) {
387
+ throw new Error(`generative reranker prompt (${tokens.length} tokens) exceeds context size (${GENERATIVE_CONTEXT_SIZE}) after truncation`);
388
+ }
188
389
  // Reset the sequence so each pair is scored independently (deterministic) and
189
390
  // a prior eval can't poison this one ("Eval has failed" after a bad state).
190
391
  await resetSequence();
@@ -228,8 +429,15 @@ export async function rerankScores(query, docs) {
228
429
  if (_state !== "ready")
229
430
  throw new Error("reranker not ready");
230
431
  if (_mode === "rank") {
231
- const trimmed = docs.map((d) => String(d ?? "").slice(0, MAX_DOC_CHARS));
232
- return runExclusive(() => _rankCtx.rankAll(query, trimmed));
432
+ // Bound query + every doc BEFORE rankAll (flair#811 point 1) — rankAll
433
+ // computes ALL documents' token lengths up front and throws "The input
434
+ // lengths of some of the given documents exceed the context size" if
435
+ // ANY one exceeds RANK_CONTEXT_SIZE (verified against node-llama-cpp
436
+ // 3.18.1's LlamaRankingContext.rankAll). truncateForModel makes that
437
+ // effectively unreachable instead of routine on real memory content.
438
+ const boundedQuery = truncateForModel(query, QUERY_CHAR_PRECUT, MAX_QUERY_TOKENS);
439
+ const trimmed = docs.map((d) => truncateForModel(String(d ?? ""), RANK_DOC_CHAR_PRECUT, RANK_DOC_BUDGET_TOKENS));
440
+ return runExclusive(() => _rankCtx.rankAll(boundedQuery, trimmed));
233
441
  }
234
442
  // generative — score sequentially under the engine lock (single shared
235
443
  // sequence; deterministic). The whole batch holds the lock for one search so
@@ -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.