@tpsdev-ai/flair 0.44.13 → 0.46.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/config.yaml +35 -2
- package/dist/cli.js +410 -21
- package/dist/doctor-client.js +266 -0
- package/dist/federation/scheduler.js +114 -9
- package/dist/hook-install.js +150 -1
- package/dist/lib/mcp-enable.js +59 -19
- package/dist/lib/safe-snapshot-extract.js +20 -2
- package/dist/lib/scheduler-platform.js +210 -1
- package/dist/rem/scheduler.js +113 -13
- package/dist/rem/snapshot.js +14 -13
- package/dist/resources/Memory.js +32 -1
- package/dist/resources/MemoryBootstrap.js +88 -51
- package/dist/resources/MemoryFeed.js +56 -1
- package/dist/resources/MemoryMaintenance.js +8 -2
- package/dist/resources/abstention.js +12 -9
- package/dist/resources/auth-middleware.js +11 -3
- package/dist/resources/bm25.js +7 -3
- package/dist/resources/mcp-oauth-flag.js +20 -0
- package/dist/resources/mcp-oauth.js +6 -1
- package/dist/resources/memory-visibility.js +48 -0
- package/dist/resources/semantic-retrieval-core.js +99 -19
- package/dist/src/lib/scheduler-platform.js +210 -1
- package/dist/src/rem/scheduler.js +113 -13
- package/docs/notes/mcp-oauth-model2.md +10 -3
- package/docs/quickstart-fabric.md +42 -3
- package/package.json +1 -1
- package/templates/bin/flair-federation-sync.sh.tmpl +8 -1
- package/templates/bin/flair-rem-nightly.sh.tmpl +8 -1
package/dist/rem/snapshot.js
CHANGED
|
@@ -20,6 +20,7 @@ import { mkdirSync, writeFileSync, statSync, chmodSync, rmSync, existsSync, read
|
|
|
20
20
|
import { resolve } from "node:path";
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
22
|
import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
|
|
23
|
+
import { validateSnapshotArchive } from "../lib/safe-snapshot-extract.js";
|
|
23
24
|
export const SNAPSHOT_ROOT = resolve(homedir(), ".flair", "snapshots");
|
|
24
25
|
/** Validates the agent id and returns its snapshot directory. */
|
|
25
26
|
export function remSnapshotDir(agent) {
|
|
@@ -133,20 +134,20 @@ export async function extractSnapshot(opts) {
|
|
|
133
134
|
if (existsSync(targetDir)) {
|
|
134
135
|
throw new Error(`target directory already exists: ${targetDir}`);
|
|
135
136
|
}
|
|
137
|
+
// flair#903 — fail CLOSED on a tampered archive. node-tar's defaults below
|
|
138
|
+
// do contain malicious entries (leading "/" stripped, ".." entries dropped,
|
|
139
|
+
// no writing through symlinks — verified on the pinned tar against all four
|
|
140
|
+
// vectors), but they discard those entries SILENTLY: the restore reported
|
|
141
|
+
// success minus the parts it never mentioned, and the operator held a
|
|
142
|
+
// partial restore they believed was complete. Validation runs BEFORE the
|
|
143
|
+
// target directory is even created: a tampered snapshot aborts the whole
|
|
144
|
+
// restore, names the offending entry, and writes nothing (same posture as
|
|
145
|
+
// the data-dir restore's extractSnapshotSafely). The default extract flags
|
|
146
|
+
// are kept as containment defense-in-depth — deliberately NOT
|
|
147
|
+
// preservePaths; if that flag is ever added here, this call MUST move to
|
|
148
|
+
// extractSnapshotSafely (see src/lib/safe-snapshot-extract.ts).
|
|
149
|
+
await validateSnapshotArchive({ file: opts.snapshotPath, targetDir });
|
|
136
150
|
mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
|
137
|
-
// Deliberately NOT preservePaths, and deliberately NOT routed through
|
|
138
|
-
// src/lib/safe-snapshot-extract.ts. The snapshot path comes from the
|
|
139
|
-
// operator, so provenance here is no more controlled than the data-dir
|
|
140
|
-
// restore's — the difference is the flag, not the trust. With node-tar's
|
|
141
|
-
// defaults its own containment applies: it strips a leading "/" from entry
|
|
142
|
-
// paths, drops ".." entries, and refuses to write through a symlink,
|
|
143
|
-
// including one created earlier in the same archive. Verified against the
|
|
144
|
-
// pinned tar (7.5.20) on all four cases — absolute path, ".." traversal,
|
|
145
|
-
// in-archive symlink, pre-existing symlink in the target — each contained,
|
|
146
|
-
// with a benign control entry landing to prove the archives were valid.
|
|
147
|
-
// If `preservePaths` is ever added here, that containment is gone and this
|
|
148
|
-
// call MUST move to extractSnapshotSafely, which is why the data-dir
|
|
149
|
-
// restore needs the wrapper and this does not.
|
|
150
151
|
await tarExtract({ file: opts.snapshotPath, cwd: targetDir });
|
|
151
152
|
return { targetDir, entries };
|
|
152
153
|
}
|
package/dist/resources/Memory.js
CHANGED
|
@@ -6,7 +6,7 @@ import { getEmbedding, getModelId } from "./embeddings-provider.js";
|
|
|
6
6
|
import { scanFields, isStrictMode } from "./content-safety.js";
|
|
7
7
|
import { invalidEntitiesResponse } from "./entity-vocab.js";
|
|
8
8
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
9
|
-
import { assertValidVisibility } from "./memory-visibility.js";
|
|
9
|
+
import { assertValidVisibility, assertVisibilityAllowedForDurability } from "./memory-visibility.js";
|
|
10
10
|
import { assertValidDurability } from "./memory-durability.js";
|
|
11
11
|
import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
|
|
12
12
|
import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, makeScopedSearch, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
|
|
@@ -641,6 +641,20 @@ export class Memory extends databases.flair.Memory {
|
|
|
641
641
|
return new Response(JSON.stringify({ error: "invalid_visibility", message: visibilityError }), { status: 400, headers: { "content-type": "application/json" } });
|
|
642
642
|
}
|
|
643
643
|
}
|
|
644
|
+
// ── flair#1257: ephemeral memories are private-only (hard precondition) ──
|
|
645
|
+
// The durability-keyed default below sends ephemeral to "private", but a
|
|
646
|
+
// default is not a constraint — an explicit visibility:"shared" here would
|
|
647
|
+
// make continuity-journal entries org-readable AND federation-pushed.
|
|
648
|
+
// Refused at the server so the boundary holds for every caller, not just
|
|
649
|
+
// the hooks that promise to send "private". content.durability is already
|
|
650
|
+
// defaulted ("standard" when absent) and enum-validated above, so this
|
|
651
|
+
// sees the row's effective durability.
|
|
652
|
+
{
|
|
653
|
+
const tierError = assertVisibilityAllowedForDurability(content.durability, content.visibility);
|
|
654
|
+
if (tierError) {
|
|
655
|
+
return new Response(JSON.stringify({ error: "invalid_visibility_for_durability", message: tierError }), { status: 400, headers: { "content-type": "application/json" } });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
644
658
|
if (content.visibility === undefined || content.visibility === null) {
|
|
645
659
|
content.visibility = defaultVisibilityForDurability(content.durability);
|
|
646
660
|
}
|
|
@@ -853,6 +867,23 @@ export class Memory extends databases.flair.Memory {
|
|
|
853
867
|
return new Response(JSON.stringify({ error: "invalid_visibility", message: visibilityError }), { status: 400, headers: { "content-type": "application/json" } });
|
|
854
868
|
}
|
|
855
869
|
}
|
|
870
|
+
// ── flair#1257: ephemeral memories are private-only (hard precondition) ──
|
|
871
|
+
// Same rule as post(), with one PUT-specific wrinkle: an update payload
|
|
872
|
+
// may omit durability entirely (put() stamps no durability default), so
|
|
873
|
+
// `PUT /Memory/<id> {"visibility":"shared"}` against a stored ephemeral
|
|
874
|
+
// row names no durability of its own — the EFFECTIVE durability is the
|
|
875
|
+
// pre-existing row's, and the flip must refuse just like a fresh
|
|
876
|
+
// ephemeral+shared create. An explicit durability on the write wins: a
|
|
877
|
+
// write that promotes the row OUT of ephemeral (e.g. distillation lifting
|
|
878
|
+
// it to persistent, #1205) while sharing it is a legitimate promotion,
|
|
879
|
+
// not an ephemeral share. preExisting was fetched above.
|
|
880
|
+
{
|
|
881
|
+
const effectiveDurability = content.durability ?? preExisting?.durability;
|
|
882
|
+
const tierError = assertVisibilityAllowedForDurability(effectiveDurability, content.visibility);
|
|
883
|
+
if (tierError) {
|
|
884
|
+
return new Response(JSON.stringify({ error: "invalid_visibility_for_durability", message: tierError }), { status: 400, headers: { "content-type": "application/json" } });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
856
887
|
if (!preExisting && (content.visibility === undefined || content.visibility === null)) {
|
|
857
888
|
content.visibility = defaultVisibilityForDurability(content.durability);
|
|
858
889
|
}
|
|
@@ -15,6 +15,7 @@ import { buildCollisionEntries, buildEntityMatchCondition, freshPresenceByAgent,
|
|
|
15
15
|
// retrievalCount hit-tracking side effects (see resources/
|
|
16
16
|
// semantic-retrieval-core.ts's module doc for the full boundary).
|
|
17
17
|
import { retrieveCandidates, DEFAULT_SELECT } from "./semantic-retrieval-core.js";
|
|
18
|
+
import { hybridEnabled } from "./bm25.js";
|
|
18
19
|
import { buildTrustBlock } from "./trust-block.js";
|
|
19
20
|
import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
|
|
20
21
|
import { estimateTokens } from "./token-estimate.js";
|
|
@@ -51,8 +52,9 @@ import { estimateTokens } from "./token-estimate.js";
|
|
|
51
52
|
* (no new embedding code — Memory is the semantic surface, WorkspaceState/
|
|
52
53
|
* OrgEvent are the entity surface, per the K&S verdict). Gated on
|
|
53
54
|
* freshness (Presence, via the SAME internal roster path, never the raw
|
|
54
|
-
* table)
|
|
55
|
-
* collision-lib.ts for the pure
|
|
55
|
+
* table); drawn from #550's fused-rank scored pool (flair#1246 — no
|
|
56
|
+
* relevance floor). See resources/collision-lib.ts for the pure
|
|
57
|
+
* join/rank/format logic.
|
|
56
58
|
*
|
|
57
59
|
* Prediction: when context signals (channel, surface, subjects) are provided,
|
|
58
60
|
* the bootstrap loads more aggressively — Flair is fast enough that the
|
|
@@ -128,9 +130,12 @@ import { estimateTokens } from "./token-estimate.js";
|
|
|
128
130
|
* memoriesAvailable` — included and truncated are disjoint sets of UNIQUE own
|
|
129
131
|
* memories (a memory budget-skipped in one section but admitted in another counts
|
|
130
132
|
* as included, never both). `teammateFindingsMatched` is the teammate match pool
|
|
131
|
-
* that
|
|
132
|
-
*
|
|
133
|
-
*
|
|
133
|
+
* that entered the scored candidate set — since flair#1246 that is the
|
|
134
|
+
* K-bounded fused-rank retrieval pool (no relevance floor; the historical
|
|
135
|
+
* `_score > 0.3` gate was measured inert and removed); `teammateFindingsIncluded
|
|
136
|
+
* + teammateFindingsTruncated == teammateFindingsMatched` (the #1199 contract,
|
|
137
|
+
* unchanged), so "truncated" means "retrieved but no budget," not "every
|
|
138
|
+
* candidate not selected."
|
|
134
139
|
* `predictedHint` is present only when subjects were provided but `predicted`
|
|
135
140
|
* came back empty.
|
|
136
141
|
*/
|
|
@@ -204,10 +209,18 @@ const OWN_NONPERMANENT_FETCH_LIMIT = 500;
|
|
|
204
209
|
const AVG_LINE_TOKEN_ESTIMATE = 60;
|
|
205
210
|
const MIN_CANDIDATE_POOL = 50;
|
|
206
211
|
const MAX_CANDIDATE_POOL = 100;
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
212
|
+
// flair#1246 — the historical TASK_RELEVANCE_FLOOR (`_score > 0.3`, carried
|
|
213
|
+
// verbatim from the original raw JS dot-product scan) is REMOVED, not
|
|
214
|
+
// recalibrated: a 6-variant measurement on the shipped embedding model proved
|
|
215
|
+
// it inert (126 records across field-analog/nonce/contamination/control/
|
|
216
|
+
// max-dilution/scattered-senses shapes — nothing scored under ~0.44, so the
|
|
217
|
+
// floor cut zero records and never delivered "show nothing when nothing's
|
|
218
|
+
// relevant" either; fully-unrelated bland noise sits 0.44–0.63). Selection on
|
|
219
|
+
// the task-relevant surface is fused retrieval rank + the token budget. Do
|
|
220
|
+
// not reintroduce a constant floor — an absolute bar tight enough to exclude
|
|
221
|
+
// bland noise (max-dilution noise cosine 0.6086) also excludes the
|
|
222
|
+
// pathological on-task case (0.5656); a real relevance gate would need a
|
|
223
|
+
// corpus-relative instrument, which is a new feature, not a constant.
|
|
211
224
|
// flair#1207 — the per-item structured-payload overhead that #1199 charged
|
|
212
225
|
// against the content-selection budget (a `+ STRUCT_ITEM_OVERHEAD_TOKENS = 70`
|
|
213
226
|
// added to every item's cost, PLUS a `structOverheadReserve` pre-deducted from
|
|
@@ -364,9 +377,10 @@ export class BootstrapMemories extends Resource {
|
|
|
364
377
|
// is what let one client see included(9) > available(3).
|
|
365
378
|
let teammateFindingsIncluded = 0;
|
|
366
379
|
// flair#1207 — teammate findings SKIPPED for size in the task-relevant
|
|
367
|
-
// packing loop (
|
|
368
|
-
//
|
|
369
|
-
//
|
|
380
|
+
// packing loop (entered the scored retrieval pool but didn't fit the
|
|
381
|
+
// BUDGET; flair#1246 — the pool is the K-bounded fused-rank candidate
|
|
382
|
+
// set, no relevance floor). Reported ALONGSIDE `teammateFindingsMatched`
|
|
383
|
+
// (the whole scored pool considered), so "truncated" unambiguously means "relevant-but-no-budget",
|
|
370
384
|
// NOT "every candidate not selected" — 0.44.9's `truncated:89` beside
|
|
371
385
|
// `included:4` read as "89 relevant findings cut" with no pool to anchor it
|
|
372
386
|
// (heskew's #1207 nit). Both are disjoint-and-exhaustive over the matched
|
|
@@ -876,12 +890,13 @@ export class BootstrapMemories extends Resource {
|
|
|
876
890
|
}
|
|
877
891
|
}
|
|
878
892
|
// Collision surfacing's semantic-match candidates (flair#681) — the
|
|
879
|
-
// BEST (
|
|
893
|
+
// BEST (top fused-rank) cross-agent memory per teammate from #550's
|
|
880
894
|
// `scored` list below, captured here (before that list's tokens get
|
|
881
895
|
// spent on the relevant/teammate sections) so the collision block can
|
|
882
|
-
// reuse the IDENTICAL scored
|
|
883
|
-
// re-embedding anything. Stays empty
|
|
884
|
-
// `scored` list is ever built) or no
|
|
896
|
+
// reuse the IDENTICAL scored set (flair#1246 — fused retrieval rank, no
|
|
897
|
+
// floor gate) without recomputing or re-embedding anything. Stays empty
|
|
898
|
+
// when there's no currentTask (no `scored` list is ever built) or no
|
|
899
|
+
// cross-agent hits.
|
|
885
900
|
const semanticTeammateMatches = [];
|
|
886
901
|
// --- 4. Task-relevant memories (semantic search) ---
|
|
887
902
|
if (currentTask && tokenBudget > 200) {
|
|
@@ -915,12 +930,27 @@ export class BootstrapMemories extends Resource {
|
|
|
915
930
|
queryEmbedding,
|
|
916
931
|
conditions: [scope.condition],
|
|
917
932
|
limit: candidatePoolK,
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
|
|
933
|
+
// flair#1246 — ONE RANKER, ONE SCALE: this pass now invokes the
|
|
934
|
+
// core in the SAME mode memory_search does (hybrid + q via the
|
|
935
|
+
// shared hybridEnabled() selector, so the FLAIR_HYBRID_RETRIEVAL
|
|
936
|
+
// kill-switch moves BOTH surfaces together — a split mode IS the
|
|
937
|
+
// #1246 bug). HNSW-only here was an accident of early code, and it
|
|
938
|
+
// made bootstrap's teammate picks diverge from search on the same
|
|
939
|
+
// store+query: a record whose task-relevance is LEXICAL (exact task
|
|
940
|
+
// terms in semantically-atypical prose) ranks BELOW bland-generic
|
|
941
|
+
// noise on pure cosine (measured at N=21: on-task 0.5656 vs noise
|
|
942
|
+
// 0.6086 — HNSW rank 6 while search fused it to rank 1), and at
|
|
943
|
+
// field scale (corpus >> candidatePoolK) that inversion becomes
|
|
944
|
+
// exclusion from the K-bounded pool entirely. The BM25 leg is the
|
|
945
|
+
// rescue: it admits the record at lexical rank 1 and union-RRF
|
|
946
|
+
// fusion carries it to the top, same as search. Perf (Kern-ratified
|
|
947
|
+
// trade): the BM25 corpus scan this adds to the bootstrap path is
|
|
948
|
+
// the same per-call scan every memory_search request already runs.
|
|
949
|
+
hybrid: hybridEnabled(),
|
|
950
|
+
// The lexical leg — same query text the embedding was computed
|
|
951
|
+
// from, so both legs rank the same question (parity with search,
|
|
952
|
+
// where `q` drives BM25 and the keyword bump).
|
|
953
|
+
q: currentTask,
|
|
924
954
|
// Per-set (this K-bounded pool only, never cross-applied to the
|
|
925
955
|
// permanent/recent/predicted sets above) — see this function's
|
|
926
956
|
// supersededIds docs above and resources/
|
|
@@ -960,30 +990,32 @@ export class BootstrapMemories extends Resource {
|
|
|
960
990
|
});
|
|
961
991
|
// flair#744 slice 2: the best-match confidence for the abstention
|
|
962
992
|
// decision — the max absolute cosine across the retrieved pool, read
|
|
963
|
-
// ONLY from `_semSimilarity` (never any principal/authority field).
|
|
964
|
-
//
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
// signal, it never removes a memory the reader would otherwise have seen.
|
|
993
|
+
// ONLY from `_semSimilarity` (never any principal/authority field).
|
|
994
|
+
// Abstention is unaffected by #1246's floor removal: it only ADDS an
|
|
995
|
+
// explicit "nothing covered this" signal against its own GLOBAL
|
|
996
|
+
// threshold (resources/abstention.ts), it never removes a memory the
|
|
997
|
+
// reader would otherwise have seen.
|
|
969
998
|
if (abstain)
|
|
970
999
|
taskBestSimilarity = bestSemanticSimilarity(candidates);
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
//
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
//
|
|
1000
|
+
// flair#1246 — selection is FUSED RETRIEVAL RANK + the token budget
|
|
1001
|
+
// in the packing loop below; there is NO score floor (see the
|
|
1002
|
+
// TASK_RELEVANCE_FLOOR removal note by the pool constants above —
|
|
1003
|
+
// measured inert on the shipped embedding model). `candidates` arrive
|
|
1004
|
+
// best-first on the same ranking memory_search uses (fused RRF order
|
|
1005
|
+
// under hybrid; see retrieveCandidates), so this filter+map preserves
|
|
1006
|
+
// that order. `score` carries `_score` — the honest absolute cosine
|
|
1007
|
+
// (+ legacy keyword bump) per #985/#1267 — for display/reporting;
|
|
1008
|
+
// ORDER and score can legitimately disagree (a BM25 rank-1 rescue
|
|
1009
|
+
// outranks higher-cosine bland hits, which is the recall win).
|
|
980
1010
|
const scored = candidates
|
|
981
|
-
.filter((m) => !includedIds.has(m.id)
|
|
1011
|
+
.filter((m) => !includedIds.has(m.id))
|
|
982
1012
|
.map((m) => ({ memory: m, score: m._score }));
|
|
983
1013
|
// flair#681: the collision block's semantic surface — one candidate
|
|
984
|
-
// per teammate (
|
|
985
|
-
//
|
|
986
|
-
//
|
|
1014
|
+
// per teammate (`scored` is sorted best-first by the fused retrieval
|
|
1015
|
+
// rank, so the first occurrence of a given `_source` IS that
|
|
1016
|
+
// teammate's best-ranked hit; its `score` field reports the honest
|
|
1017
|
+
// absolute cosine, which per #985/#1267 can disagree with rank).
|
|
1018
|
+
// `m._source` is only ever set for a cross-agent record (see
|
|
987
1019
|
// retrieveCandidates()'s `_source` tagging) — an own memory never
|
|
988
1020
|
// contributes here.
|
|
989
1021
|
const seenCollisionAgents = new Set();
|
|
@@ -1077,7 +1109,8 @@ export class BootstrapMemories extends Resource {
|
|
|
1077
1109
|
// - Entity overlap (WorkspaceState + OrgEvent): exact vocabulary-string
|
|
1078
1110
|
// match, high-precision, no separate relevance score needed.
|
|
1079
1111
|
// - Semantic match (Memory, via #550/4 above): `semanticTeammateMatches`,
|
|
1080
|
-
//
|
|
1112
|
+
// each teammate's best-ranked hit from the fused retrieval pool
|
|
1113
|
+
// (flair#1246 — no score floor) — reused as-is, no new scoring.
|
|
1081
1114
|
// Gated on freshness (Presence, via the internal roster path) — a
|
|
1082
1115
|
// teammate absent from the roster, or whose presenceStatus is "offline",
|
|
1083
1116
|
// never surfaces regardless of how strong the entity/semantic match is.
|
|
@@ -1311,9 +1344,10 @@ export class BootstrapMemories extends Resource {
|
|
|
1311
1344
|
if (!includedOwnIds.has(id))
|
|
1312
1345
|
memoriesTruncatedUnique++;
|
|
1313
1346
|
memoriesTruncated = memoriesTruncatedUnique;
|
|
1314
|
-
// flair#1207 — the teammate MATCH POOL considered (
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1347
|
+
// flair#1207 — the teammate MATCH POOL considered (flair#1246: the
|
|
1348
|
+
// cross-agent records that entered the scored set — drawn from the
|
|
1349
|
+
// K-bounded fused-rank candidate pool, no relevance floor): every matched
|
|
1350
|
+
// teammate finding is EITHER included OR budget-truncated, so this equals their sum.
|
|
1317
1351
|
// Reporting it anchors `teammateFindingsTruncated` as "relevant-but-no-budget"
|
|
1318
1352
|
// rather than an unexplained large number beside a small `included`.
|
|
1319
1353
|
const teammateFindingsMatched = teammateFindingsIncluded + teammateFindingsTruncated;
|
|
@@ -1455,16 +1489,18 @@ export class BootstrapMemories extends Resource {
|
|
|
1455
1489
|
+ "after zero-row no-op auto-heal filtering. This container is present-but-empty by design, not dropped."
|
|
1456
1490
|
: undefined;
|
|
1457
1491
|
// teammateFindings: [] — name WHICH legitimate empty this is (no task → no
|
|
1458
|
-
// retrieval; matched-but-budget-truncated; or
|
|
1459
|
-
//
|
|
1492
|
+
// retrieval; matched-but-budget-truncated; or no cross-agent record entered
|
|
1493
|
+
// the task-relevant retrieval pool at all — flair#1246: there is no
|
|
1494
|
+
// relevance floor, so "empty" means empty retrieval, never a score cut),
|
|
1495
|
+
// so it never reads as a silent drop.
|
|
1460
1496
|
const teammateFindingsHint = includedTeammateFindings.length === 0
|
|
1461
1497
|
? (!taskProvided
|
|
1462
1498
|
? "No teammateFindings: cross-agent findings are retrieved against your currentTask, and none was provided. "
|
|
1463
1499
|
+ "Pass currentTask to populate this."
|
|
1464
1500
|
: teammateFindingsTruncated > 0
|
|
1465
1501
|
? `No teammateFindings fit the token budget: ${teammateFindingsTruncated} relevant cross-agent finding(s) `
|
|
1466
|
-
+ "
|
|
1467
|
-
: "No cross-agent (teammate) memory
|
|
1502
|
+
+ "were retrieved but budget-truncated. Raise maxTokens to include them."
|
|
1503
|
+
: "No cross-agent (teammate) memory entered the task-relevant retrieval pool for this currentTask. "
|
|
1468
1504
|
+ "This container is present-but-empty by design, not dropped.")
|
|
1469
1505
|
: undefined;
|
|
1470
1506
|
const responseBody = {
|
|
@@ -1532,8 +1568,9 @@ export class BootstrapMemories extends Resource {
|
|
|
1532
1568
|
// size-skip self-describing: "a relevant teammate finding didn't fit" is
|
|
1533
1569
|
// now distinguishable from "no relevant teammate finding".
|
|
1534
1570
|
teammateFindingsTruncated,
|
|
1535
|
-
// flair#1207 — the teammate match POOL considered (
|
|
1536
|
-
//
|
|
1571
|
+
// flair#1207 — the teammate match POOL considered (flair#1246: entered
|
|
1572
|
+
// the scored fused-rank retrieval set; no relevance floor).
|
|
1573
|
+
// teammateFindingsIncluded + teammateFindingsTruncated ==
|
|
1537
1574
|
// teammateFindingsMatched, so "truncated" reads as "relevant-but-no-budget"
|
|
1538
1575
|
// against a stated pool, not an unanchored large number.
|
|
1539
1576
|
teammateFindingsMatched,
|
|
@@ -2,6 +2,8 @@ import { Resource, databases } from "harper";
|
|
|
2
2
|
import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
|
|
3
3
|
import { computeContentHash, findExistingMemoryByContentHash } from "./memory-feed-lib.js";
|
|
4
4
|
import { FORBIDDEN, UNAUTH, stampAttribution } from "./record-type-kit.js";
|
|
5
|
+
import { assertValidVisibility, assertVisibilityAllowedForDurability, PRIVATE_VISIBILITY } from "./memory-visibility.js";
|
|
6
|
+
import { assertValidDurability } from "./memory-durability.js";
|
|
5
7
|
export class FeedMemories extends Resource {
|
|
6
8
|
// Self-authorize via the Ed25519 agent verify (the auth reshape removes the
|
|
7
9
|
// gate's admin elevation).
|
|
@@ -53,6 +55,46 @@ export class FeedMemories extends Resource {
|
|
|
53
55
|
headers: { "Content-Type": "application/json" },
|
|
54
56
|
});
|
|
55
57
|
}
|
|
58
|
+
// ── Write-side durability/visibility validation (#1009/#1238/#1257) ─────
|
|
59
|
+
// This endpoint writes via the RAW table object below — NOT the exported
|
|
60
|
+
// Memory resource — so it inherits NONE of Memory.post()/put()'s write
|
|
61
|
+
// guards (Sherlock's #1261 review: ephemeral+shared, and any invalid
|
|
62
|
+
// visibility or durability, landed through POST /FeedMemories untouched).
|
|
63
|
+
// The three guards are applied here in the same order as Memory.post().
|
|
64
|
+
//
|
|
65
|
+
// Placed BEFORE the content-hash dedup early-return, deliberately: a
|
|
66
|
+
// refused combination must refuse deterministically, not return 200 with
|
|
67
|
+
// the existing record whenever a duplicate happens to exist.
|
|
68
|
+
//
|
|
69
|
+
// The effective durability for the tier rule is the one the record below
|
|
70
|
+
// actually stamps — `content.durability ?? "standard"`. A raw table put
|
|
71
|
+
// REPLACES the row, so even an update-in-place that omits durability
|
|
72
|
+
// produces a "standard" row regardless of what it replaces; the stored
|
|
73
|
+
// row's tier is decided entirely by this payload.
|
|
74
|
+
const durability = content.durability ?? "standard";
|
|
75
|
+
{
|
|
76
|
+
const durabilityError = assertValidDurability(content.durability);
|
|
77
|
+
if (durabilityError) {
|
|
78
|
+
return new Response(JSON.stringify({ error: "invalid_durability", message: durabilityError }), {
|
|
79
|
+
status: 400,
|
|
80
|
+
headers: { "Content-Type": "application/json" },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const visibilityError = assertValidVisibility(content.visibility);
|
|
84
|
+
if (visibilityError) {
|
|
85
|
+
return new Response(JSON.stringify({ error: "invalid_visibility", message: visibilityError }), {
|
|
86
|
+
status: 400,
|
|
87
|
+
headers: { "Content-Type": "application/json" },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const tierError = assertVisibilityAllowedForDurability(durability, content.visibility);
|
|
91
|
+
if (tierError) {
|
|
92
|
+
return new Response(JSON.stringify({ error: "invalid_visibility_for_durability", message: tierError }), {
|
|
93
|
+
status: 400,
|
|
94
|
+
headers: { "Content-Type": "application/json" },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
56
98
|
const now = new Date().toISOString();
|
|
57
99
|
const contentHash = computeContentHash(agentId, body);
|
|
58
100
|
const existing = await findExistingMemoryByContentHash(databases.flair.Memory.search(), agentId, contentHash);
|
|
@@ -64,11 +106,24 @@ export class FeedMemories extends Resource {
|
|
|
64
106
|
agentId,
|
|
65
107
|
content: body,
|
|
66
108
|
contentHash,
|
|
67
|
-
durability
|
|
109
|
+
durability,
|
|
68
110
|
createdAt: content.createdAt ?? now,
|
|
69
111
|
updatedAt: content.updatedAt ?? now,
|
|
70
112
|
archived: content.archived ?? false,
|
|
71
113
|
};
|
|
114
|
+
// flair#1257, omission leak: this endpoint stamps NO durability-keyed
|
|
115
|
+
// visibility default (unlike Memory.post/put — Layer 1), so an ephemeral
|
|
116
|
+
// feed write with visibility omitted would land with no visibility field
|
|
117
|
+
// at all, which the read side resolves to NON-private (the migration
|
|
118
|
+
// invariant). The refusal guard above cannot see an omission, so the
|
|
119
|
+
// private-only tier invariant is closed here by stamping "private" on
|
|
120
|
+
// exactly the ephemeral case. Deliberately NOT the general durability-
|
|
121
|
+
// keyed default: stamping it for standard/persistent/permanent would flip
|
|
122
|
+
// the visibility of every existing feed caller's writes — a behavioural
|
|
123
|
+
// change this fix must not smuggle in.
|
|
124
|
+
if (record.durability === "ephemeral" && (record.visibility === undefined || record.visibility === null)) {
|
|
125
|
+
record.visibility = PRIVATE_VISIBILITY;
|
|
126
|
+
}
|
|
72
127
|
await databases.flair.Memory.put(record);
|
|
73
128
|
return record;
|
|
74
129
|
}
|
|
@@ -54,8 +54,14 @@ export class MemoryMaintenance extends Resource {
|
|
|
54
54
|
if (targetAgent && record.agentId !== targetAgent)
|
|
55
55
|
continue;
|
|
56
56
|
stats.total++;
|
|
57
|
-
// 1. Delete expired memories
|
|
58
|
-
|
|
57
|
+
// 1. Delete expired ephemeral memories. expiresAt is only a reap
|
|
58
|
+
// signal for the ephemeral tier (docstring + Memory.post() TTL
|
|
59
|
+
// stamp). A non-ephemeral row that acquired one (bug, import, API
|
|
60
|
+
// misuse) must survive — missing / unexpected durability is treated
|
|
61
|
+
// as non-ephemeral so we do not silently reap durable rows.
|
|
62
|
+
if (record.durability === "ephemeral" &&
|
|
63
|
+
record.expiresAt &&
|
|
64
|
+
new Date(record.expiresAt) < now) {
|
|
59
65
|
if (!dryRun) {
|
|
60
66
|
try {
|
|
61
67
|
await databases.flair.Memory.delete(record.id);
|
|
@@ -46,11 +46,13 @@
|
|
|
46
46
|
* GLOBAL / data-driven, NEVER per-principal (Sherlock binding condition 2).
|
|
47
47
|
*
|
|
48
48
|
* CONSERVATIVE hand-set value (0.15): well below the strong-match band real
|
|
49
|
-
* embeddings produce for genuinely relevant memories
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* nothing semantically near the query, erring toward
|
|
53
|
-
* than over-abstaining.
|
|
49
|
+
* embeddings produce for genuinely relevant memories (and below anything the
|
|
50
|
+
* flair#1246 measurement observed on the shipped model — 126 records across 6
|
|
51
|
+
* synthetic variants all scored ≥ ~0.44) — so abstention fires only when
|
|
52
|
+
* there is essentially nothing semantically near the query, erring toward
|
|
53
|
+
* returning results rather than over-abstaining. (Bootstrap's own
|
|
54
|
+
* TASK_RELEVANCE_FLOOR, which this comment once referenced, was removed by
|
|
55
|
+
* flair#1246 — that same measurement proved it inert.) Promoting abstention to the DEFAULT recall mode, and
|
|
54
56
|
* tuning this value on the recall-bench corpus, is a SEPARATE follow-up (see
|
|
55
57
|
* flair#744) — this slice ships the response shape at a safe opt-in floor, not
|
|
56
58
|
* the calibrated default.
|
|
@@ -100,10 +102,11 @@ export const STRONG_BAND = 0.55;
|
|
|
100
102
|
* Reads ONLY the `_semSimilarity` number the retrieval core
|
|
101
103
|
* (resources/semantic-retrieval-core.ts) attaches to each semantic-leg result
|
|
102
104
|
* WHEN abstention is requested — an absolute cosine similarity in [0,1],
|
|
103
|
-
* independent of the RRF
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
105
|
+
* independent of the RRF fusion that orders hybrid results. (Historically the
|
|
106
|
+
* hybrid `_score` was itself RRF rank-normalized — top result pinned at 1.0
|
|
107
|
+
* however weak the match — which is why this reads the dedicated absolute
|
|
108
|
+
* field; since flair#985 the raw `_score` is absolute too, and
|
|
109
|
+
* `_semSimilarity` stays the explicit opt-in confidence channel.)
|
|
107
110
|
*
|
|
108
111
|
* Returns null when NO candidate carries a `_semSimilarity` (no embedding-based
|
|
109
112
|
* match at all — e.g. a keyword-only degraded search, or an empty pool). Per
|
|
@@ -4,6 +4,7 @@ import { getEmbedding } from "./embeddings-provider.js";
|
|
|
4
4
|
import { isAdmin, isPrincipalDeactivated, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
|
|
5
5
|
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
|
|
6
6
|
import { resolveReadScope } from "./memory-read-scope.js";
|
|
7
|
+
import { NOT_FOUND } from "./record-type-kit.js";
|
|
7
8
|
import { isForbiddenOwnerMutation, resolveGuardedRecord } from "./record-owner-guard.js";
|
|
8
9
|
import { checkHttpRateLimit } from "./rate-limit.js";
|
|
9
10
|
// --- Admin credentials ---
|
|
@@ -638,11 +639,18 @@ server.http(async (request, nextLayer) => {
|
|
|
638
639
|
// used to be a `visibility === "office"` bypass (any authenticated
|
|
639
640
|
// agent, no grant needed) — that's gone; the private-exclusion is
|
|
640
641
|
// now enforced the same way every other read path enforces it.
|
|
642
|
+
//
|
|
643
|
+
// Denial is the SAME 404 the resource layer returns (flair#1264):
|
|
644
|
+
// Memory.get() deliberately answers NOT_FOUND for a cross-agent
|
|
645
|
+
// private id so a denied caller can't distinguish "doesn't exist"
|
|
646
|
+
// from "exists but not yours" — a 403 here, worse yet one naming
|
|
647
|
+
// the owning agent, confirmed the id exists AND disclosed its
|
|
648
|
+
// owner, defeating that anti-enumeration contract one layer up.
|
|
649
|
+
// Reuses record-type-kit's NOT_FOUND so the two layers cannot
|
|
650
|
+
// drift apart in shape.
|
|
641
651
|
const scope = await resolveReadScope(agentId);
|
|
642
652
|
if (!scope.isAllowed(record)) {
|
|
643
|
-
return
|
|
644
|
-
error: `forbidden: cannot read memory owned by ${record.agentId}`,
|
|
645
|
-
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
653
|
+
return NOT_FOUND();
|
|
646
654
|
}
|
|
647
655
|
}
|
|
648
656
|
}
|
package/dist/resources/bm25.js
CHANGED
|
@@ -107,9 +107,13 @@ export function rrfScores(rankings, universe) {
|
|
|
107
107
|
return score;
|
|
108
108
|
}
|
|
109
109
|
// Fuse semantic + BM25 candidate id-lists via candidate-union RRF and return a
|
|
110
|
-
// per-id score normalized to [0,1] (rrf / max_rrf_in_union).
|
|
111
|
-
//
|
|
112
|
-
//
|
|
110
|
+
// per-id score normalized to [0,1] (rrf / max_rrf_in_union). The top-ranked id
|
|
111
|
+
// is pinned at exactly 1.0 BY CONSTRUCTION — this is a RANKING value, not a
|
|
112
|
+
// similarity, and must never be reported as one (flair#985: reporting it as
|
|
113
|
+
// `_score` made every stale flair-client dedup gate see a ≥0.95 "similarity"
|
|
114
|
+
// on EVERY store and silently drop the write). semantic-retrieval-core.ts uses
|
|
115
|
+
// it to ORDER hybrid results (and as compositeScore's ranking input); the
|
|
116
|
+
// reported raw `_score` is the absolute cosine, computed separately.
|
|
113
117
|
//
|
|
114
118
|
// semIds — semantic candidate ids, best-first (from the HNSW pass).
|
|
115
119
|
// bm25Ids — BM25 candidate ids, best-first, already sliced to SEM_LIMIT and
|
|
@@ -28,6 +28,26 @@
|
|
|
28
28
|
*
|
|
29
29
|
* Read from `FLAIR_MCP_OAUTH` — truthy values: "1", "true", "yes", "on"
|
|
30
30
|
* (case-insensitive). Anything else (incl. unset / empty) → OFF.
|
|
31
|
+
*
|
|
32
|
+
* ASYMMETRY (load-bearing, flair#1152, measured on oauth 2.5.0): config.yaml's
|
|
33
|
+
* `mcp.enabled: ${FLAIR_MCP_OAUTH}` hands the SAME env var to
|
|
34
|
+
* @harperfast/oauth, but the two readers accept DIFFERENT vocabularies. The
|
|
35
|
+
* component's coerceConfigBoolean takes ONLY "true"/"false" and DELETES any
|
|
36
|
+
* other string (unresolved placeholder, "1", "yes", garbage) so its disabled
|
|
37
|
+
* default applies; this function takes 1/true/yes/on. Consequences:
|
|
38
|
+
* - "true" is the ONE value that enables both sides (`flair mcp enable`
|
|
39
|
+
* stages exactly that).
|
|
40
|
+
* - "1"/"yes"/"on" turn flair's /mcp handler ON while the component AS
|
|
41
|
+
* stays OFF — fail-closed broken-on (every request 401s, no AS
|
|
42
|
+
* advertised).
|
|
43
|
+
* - garbage (e.g. "maybe") disables BOTH: the component deletes it, this
|
|
44
|
+
* stays false, no /mcp handler exists — no data path (flair's own
|
|
45
|
+
* discovery documents still serve whenever this flag is off, by design).
|
|
46
|
+
* If the component's vocabulary ever widens back to truthy-string, a garbage
|
|
47
|
+
* value would mount a live AS next to an unregistered /mcp — re-derive the
|
|
48
|
+
* garbage case (test/integration/mcp-oauth-boot-safety.test.ts) before
|
|
49
|
+
* relying on it, and NEVER let component `enabled` drive flair's handler
|
|
50
|
+
* registration directly without re-deriving that table.
|
|
31
51
|
*/
|
|
32
52
|
export function mcpOAuthEnabled() {
|
|
33
53
|
const raw = (process.env.FLAIR_MCP_OAUTH ?? "").trim().toLowerCase();
|
|
@@ -123,7 +123,12 @@ export async function registerMcpOAuthRoute(deps = {}) {
|
|
|
123
123
|
return decide({
|
|
124
124
|
mounted: false,
|
|
125
125
|
status: "Not enabled",
|
|
126
|
-
|
|
126
|
+
// "true" not "1": flair's flag accepts either, but the component's
|
|
127
|
+
// config-side read of the same var (config.yaml `mcp.enabled:
|
|
128
|
+
// ${FLAIR_MCP_OAUTH}`, flair#1152) accepts ONLY "true"/"false" — with
|
|
129
|
+
// "1" the /mcp route registers and every request 401s against a
|
|
130
|
+
// component that never mounted its AS.
|
|
131
|
+
reason: "Set FLAIR_MCP_OAUTH=true (and an issuer) to serve MCP over HTTP.",
|
|
127
132
|
});
|
|
128
133
|
}
|
|
129
134
|
// Boot guard (flair#1021): fail loudly if the operator enabled the flag but
|