@tpsdev-ai/flair 0.27.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/bridges/builtins/index.js +1 -1
- package/dist/bridges/discover.js +1 -1
- package/dist/bridges/runtime/load-plugin.js +1 -1
- package/dist/bridges/scaffold.js +3 -3
- package/dist/bridges/types.js +1 -1
- package/dist/cli.js +148 -26
- package/dist/rem/runner.js +2 -2
- package/dist/resources/MemoryReflect.js +1 -1
- package/dist/resources/RecordUsage.js +15 -1
- package/dist/resources/embeddings-provider.js +8 -38
- package/dist/resources/mcp-tools.js +9 -1
- package/dist/resources/memory-reflect-lib.js +1 -1
- package/dist/resources/migrations/embedding-stamp.js +23 -0
- package/dist/resources/migrations/graph-heal.js +250 -0
- package/dist/resources/migrations/registry.js +11 -5
- package/dist/resources/models-dir.js +55 -0
- package/dist/resources/record-types.js +2 -2
- package/dist/resources/rerank-provider.js +244 -36
- package/dist/resources/usage-recording.js +72 -6
- package/dist/resources/version.js +33 -0
- package/docs/assets/flair-cross-orchestrator.cast +1 -1
- package/docs/bridges.md +3 -3
- package/docs/mcp-clients.md +3 -1
- package/docs/n8n.md +1 -2
- package/docs/notes/rem-ux.md +1 -1
- package/docs/rem.md +2 -0
- package/docs/rerank-provisioning.md +40 -6
- package/package.json +4 -1
- package/schemas/memory.graphql +37 -12
|
@@ -9,37 +9,86 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Two inference modes (selected by FLAIR_RERANK_MODEL):
|
|
11
11
|
*
|
|
12
|
-
* 1. "
|
|
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
|
|
26
|
-
* hardest consensus case negative
|
|
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
|
|
33
|
-
* embedding GGUF
|
|
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
|
-
|
|
37
|
-
//
|
|
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 = "
|
|
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
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
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
|
-
|
|
241
|
+
const requested = resolveModelKey();
|
|
242
|
+
if (!needsReinit(_state, _modelKey, requested))
|
|
92
243
|
return;
|
|
93
|
-
|
|
94
|
-
|
|
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 =
|
|
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 =
|
|
257
|
+
const modelPath = resolveRerankModelPath(spec.file);
|
|
101
258
|
if (!existsSync(modelPath)) {
|
|
102
|
-
throw new Error(`reranker GGUF not found:
|
|
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:
|
|
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
|
|
135
|
-
// 512 is too small for real memories; 2048
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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`
|
|
137
|
-
* auth context, never anything derived from
|
|
138
|
-
* caller-supplied input (flair#744 slice A
|
|
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
|
|
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 [
|
|
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
|
-
##
|
|
289
|
+
## Authority
|
|
290
290
|
|
|
291
|
-
|
|
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.
|
package/docs/mcp-clients.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/docs/notes/rem-ux.md
CHANGED
|
@@ -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.
|
|
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.
|