@tpsdev-ai/flair 0.44.13 → 0.45.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/dist/cli.js +41 -15
- package/dist/lib/safe-snapshot-extract.js +20 -2
- 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/abstention.js +12 -9
- package/dist/resources/auth-middleware.js +11 -3
- package/dist/resources/bm25.js +7 -3
- package/dist/resources/memory-visibility.js +48 -0
- package/dist/resources/semantic-retrieval-core.js +99 -19
- package/docs/quickstart-fabric.md +42 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3743,6 +3743,7 @@ agent
|
|
|
3743
3743
|
.option("--name <name>", "Display name (defaults to id)")
|
|
3744
3744
|
.option("--port <port>", "Harper HTTP port")
|
|
3745
3745
|
.option("--admin-pass <pass>", "Admin password for registration")
|
|
3746
|
+
.option("--admin-pass-file <path>", "Read the admin password from a file (chmod 600 enforced). Preferred over inline --admin-pass — keeps the secret out of ps and shell history; works for remote targets too (an explicit flag is operator intent).")
|
|
3746
3747
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
3747
3748
|
.option("--ops-port <port>", "Harper operations API port")
|
|
3748
3749
|
.option("--target <url>", "Remote Flair REST URL; derives the ops API URL (port-1) to seed the Agent there (env: FLAIR_TARGET)")
|
|
@@ -3759,6 +3760,22 @@ agent
|
|
|
3759
3760
|
// `flair import`: explicit --ops-target > derive from --target > localhost.
|
|
3760
3761
|
const seedOpsTarget = resolveEffectiveOpsUrl({ target: opts.target, opsTarget: opts.opsTarget }) ?? opsPort;
|
|
3761
3762
|
const isRemoteTarget = typeof seedOpsTarget === "string";
|
|
3763
|
+
// flair#1259 — --admin-pass-file resolves into the same explicit slot the
|
|
3764
|
+
// inline flag uses (same shape as `flair federation sync`), read in-process
|
|
3765
|
+
// via readAdminPassFileSecure so the secret never appears in ps or shell
|
|
3766
|
+
// history. This does NOT weaken the #1085 remote guard below: an explicit
|
|
3767
|
+
// flag naming a file IS operator intent toward this target, exactly like an
|
|
3768
|
+
// explicit inline --admin-pass — what the guard blocks is the AMBIENT
|
|
3769
|
+
// env/local-file fallbacks silently traveling to a third-party host.
|
|
3770
|
+
if (!opts.adminPass && opts.adminPassFile) {
|
|
3771
|
+
try {
|
|
3772
|
+
opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
|
|
3773
|
+
}
|
|
3774
|
+
catch (err) {
|
|
3775
|
+
console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
|
|
3776
|
+
process.exit(1);
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3762
3779
|
// #590 — local convenience fallback: FLAIR_ADMIN_PASS env, then the secure
|
|
3763
3780
|
// ~/.flair/admin-pass file `flair init` already writes (mode 0600). Never
|
|
3764
3781
|
// applied for a remote target — see resolveLocalAdminPass.
|
|
@@ -3772,12 +3789,13 @@ agent
|
|
|
3772
3789
|
}
|
|
3773
3790
|
if (!adminPass) {
|
|
3774
3791
|
if (isRemoteTarget) {
|
|
3775
|
-
console.error("Error: --admin-pass is required for agent add when targeting
|
|
3776
|
-
"(--target/--ops-target) — the local ~/.flair/admin-pass
|
|
3792
|
+
console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for agent add when targeting " +
|
|
3793
|
+
"a remote instance (--target/--ops-target) — the local ~/.flair/admin-pass and FLAIR_ADMIN_PASS " +
|
|
3794
|
+
"fallbacks are never used for remote targets. Prefer --admin-pass-file: it keeps the secret out of ps.");
|
|
3777
3795
|
}
|
|
3778
3796
|
else {
|
|
3779
|
-
console.error("Error: --admin-pass is required for agent add (needed to insert
|
|
3780
|
-
"Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
|
|
3797
|
+
console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for agent add (needed to insert " +
|
|
3798
|
+
"into Agent table). Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
|
|
3781
3799
|
}
|
|
3782
3800
|
process.exit(1);
|
|
3783
3801
|
}
|
|
@@ -13891,18 +13909,26 @@ sessionSnapshot
|
|
|
13891
13909
|
console.error(` Pass --target <new-path> or remove the existing dir.`);
|
|
13892
13910
|
process.exit(1);
|
|
13893
13911
|
}
|
|
13912
|
+
// flair#903 — fail CLOSED on a tampered archive. node-tar's defaults below
|
|
13913
|
+
// do contain malicious entries (leading "/" stripped, ".." entries
|
|
13914
|
+
// dropped, no writing through a symlink — verified on the pinned tar
|
|
13915
|
+
// against all four vectors), but they discard those entries SILENTLY: the
|
|
13916
|
+
// restore printed the target dir as a plain success minus the parts it
|
|
13917
|
+
// never mentioned. Validation runs BEFORE the target directory is even
|
|
13918
|
+
// created — a tampered snapshot aborts the whole restore, names the
|
|
13919
|
+
// offending entry, and writes nothing (same posture as the data-dir
|
|
13920
|
+
// restore's extractSnapshotSafely). The default extract flags stay as
|
|
13921
|
+
// containment defense-in-depth — deliberately NOT preservePaths; if that
|
|
13922
|
+
// flag is ever added here, this call MUST move to extractSnapshotSafely.
|
|
13923
|
+
// See src/lib/safe-snapshot-extract.ts.
|
|
13924
|
+
try {
|
|
13925
|
+
await validateSnapshotArchive({ file: snapshotPath, targetDir });
|
|
13926
|
+
}
|
|
13927
|
+
catch (err) {
|
|
13928
|
+
console.error(`Error: ${err.message}`);
|
|
13929
|
+
process.exit(1);
|
|
13930
|
+
}
|
|
13894
13931
|
mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
|
13895
|
-
// Deliberately NOT preservePaths, and deliberately NOT routed through
|
|
13896
|
-
// extractSnapshotSafely. --snapshot is an operator-supplied path, so
|
|
13897
|
-
// provenance here is no more controlled than the data-dir restore's — the
|
|
13898
|
-
// difference is the flag, not the trust. node-tar's defaults keep their
|
|
13899
|
-
// own containment: leading "/" stripped from entry paths, ".." entries
|
|
13900
|
-
// dropped, and no writing through a symlink (including one created
|
|
13901
|
-
// earlier in the same archive). Verified against the pinned tar (7.5.20)
|
|
13902
|
-
// on all four cases, each contained, with a benign control entry landing
|
|
13903
|
-
// to prove the archives parsed. Add `preservePaths` here and that
|
|
13904
|
-
// containment disappears — this call would then need extractSnapshotSafely,
|
|
13905
|
-
// exactly as the data-dir restore does. See src/lib/safe-snapshot-extract.ts.
|
|
13906
13932
|
await tarExtract({ file: snapshotPath, cwd: targetDir });
|
|
13907
13933
|
console.log(targetDir);
|
|
13908
13934
|
console.error(` extracted to: ${targetDir}`);
|
|
@@ -68,6 +68,18 @@ function hasDotDotSegment(p) {
|
|
|
68
68
|
* `linkpath` is the entry's link target, when it has one.
|
|
69
69
|
*/
|
|
70
70
|
export function checkSnapshotEntry(entryPath, type, linkpath, resolvedTargetDir) {
|
|
71
|
+
// flair#901 fail-closed shape checks: an entry this check cannot READ is an
|
|
72
|
+
// entry it must not PASS. An empty path used to resolve to the target dir
|
|
73
|
+
// itself (inside, so ok), and a link entry with no linkpath skipped the
|
|
74
|
+
// link branches entirely — both silently approved exactly when the input
|
|
75
|
+
// was least trustworthy (tampered archive, or an upstream property rename
|
|
76
|
+
// surviving to runtime).
|
|
77
|
+
if (!entryPath) {
|
|
78
|
+
return { ok: false, reason: "entry has an empty or unreadable path — refusing an entry this check cannot classify" };
|
|
79
|
+
}
|
|
80
|
+
if ((type === "Link" || type === "SymbolicLink") && !linkpath) {
|
|
81
|
+
return { ok: false, reason: `${type === "Link" ? "hard link" : "symlink"} "${entryPath}" has no readable link target — refusing an entry this check cannot classify` };
|
|
82
|
+
}
|
|
71
83
|
if (looksAbsolute(entryPath)) {
|
|
72
84
|
return { ok: false, reason: `entry has an absolute path ("${entryPath}"), which would write outside the target directory` };
|
|
73
85
|
}
|
|
@@ -154,14 +166,20 @@ export async function validateSnapshotArchive(opts) {
|
|
|
154
166
|
const violations = [];
|
|
155
167
|
await tarList({
|
|
156
168
|
file: opts.file,
|
|
169
|
+
// Typed against tar's ReadEntry (flair#901) — a property rename upstream
|
|
170
|
+
// now fails compilation instead of coercing to ""/undefined at runtime.
|
|
171
|
+
// The coercions below remain as the runtime belt, and they no longer
|
|
172
|
+
// default to safe: checkSnapshotEntry refuses an empty path and a
|
|
173
|
+
// link-typed entry with no link target.
|
|
157
174
|
onReadEntry: (entry) => {
|
|
158
|
-
const
|
|
175
|
+
const entryPath = typeof entry.path === "string" ? entry.path : "";
|
|
176
|
+
const verdict = checkSnapshotEntry(entryPath, entry.type, typeof entry.linkpath === "string" && entry.linkpath !== "" ? entry.linkpath : undefined, resolvedTargetDir);
|
|
159
177
|
// `verdict.ok === false` rather than `!verdict.ok`: an explicit
|
|
160
178
|
// comparison against the literal discriminant is what narrows the union
|
|
161
179
|
// to its failure member, so `reason` is known to exist here. The union
|
|
162
180
|
// is deliberately shaped so a caller cannot read a reason off a success.
|
|
163
181
|
if (verdict.ok === false) {
|
|
164
|
-
violations.push({ entryPath
|
|
182
|
+
violations.push({ entryPath, reason: verdict.reason });
|
|
165
183
|
}
|
|
166
184
|
},
|
|
167
185
|
});
|
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
|
}
|
|
@@ -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
|
|
@@ -79,3 +79,51 @@ export function assertValidVisibility(visibility) {
|
|
|
79
79
|
`(got: ${JSON.stringify(visibility)}). Omit it to use the durability-keyed default: ` +
|
|
80
80
|
`permanent/persistent -> shared, standard/ephemeral -> private.`);
|
|
81
81
|
}
|
|
82
|
+
/** Deliberately a LOCAL literal, not an import from memory-durability.ts: this
|
|
83
|
+
* module's zero-imports property is load-bearing (see the header — src/cli.ts
|
|
84
|
+
* must be able to import it with no transitive baggage), and the tripwire
|
|
85
|
+
* tests in test/unit/visibility-write-validation.test.ts pin the value to the
|
|
86
|
+
* durability enum so the two cannot drift apart silently. */
|
|
87
|
+
export const EPHEMERAL_DURABILITY = "ephemeral";
|
|
88
|
+
/**
|
|
89
|
+
* ─── flair#1257 hard precondition: ephemeral memories are private-only ───────
|
|
90
|
+
*
|
|
91
|
+
* `ephemeral` is the continuity-journal tier: auto-captured working state,
|
|
92
|
+
* self-pruning, never meant to leave its owner. `defaultVisibilityForDurability`
|
|
93
|
+
* keys it to `private`, but a DEFAULT is not a CONSTRAINT — before this guard,
|
|
94
|
+
* an explicit `visibility:"shared"` on an ephemeral write was accepted, which
|
|
95
|
+
* would have made journal entries org-readable AND federation-pushed. Kern's
|
|
96
|
+
* #1257 ruling requires the server to REFUSE the combination so the boundary
|
|
97
|
+
* holds for every caller (REST, in-process, any adapter), not just the hooks
|
|
98
|
+
* that promise to send `private` explicitly.
|
|
99
|
+
*
|
|
100
|
+
* The rule is deliberately "ephemeral may only carry `private` or nothing",
|
|
101
|
+
* NOT "refuse ephemeral+shared": on the read side any value other than the
|
|
102
|
+
* literal "private" resolves to non-private (the migration invariant above),
|
|
103
|
+
* so an unknown value on an ephemeral row would leak exactly like "shared".
|
|
104
|
+
* assertValidVisibility refuses unknowns first at both call sites, but this
|
|
105
|
+
* guard must stay fail-closed on its own — unknown means refused, not allowed.
|
|
106
|
+
*
|
|
107
|
+
* Absent (`undefined`/`null`) is accepted: it resolves through the
|
|
108
|
+
* durability-keyed default, which for ephemeral is `private` — the documented,
|
|
109
|
+
* intentional path (and the one the continuity hooks use, belt-and-suspenders
|
|
110
|
+
* with an explicit `private`).
|
|
111
|
+
*
|
|
112
|
+
* Returns an error message, or null when the combination is acceptable.
|
|
113
|
+
* `durability` is the EFFECTIVE durability of the row being written — for
|
|
114
|
+
* Memory.put() updates, where the payload may omit durability, the caller
|
|
115
|
+
* passes the pre-existing row's durability as the fallback so a PUT that flips
|
|
116
|
+
* a stored ephemeral row to shared refuses too.
|
|
117
|
+
*/
|
|
118
|
+
export function assertVisibilityAllowedForDurability(durability, visibility) {
|
|
119
|
+
if (durability !== EPHEMERAL_DURABILITY)
|
|
120
|
+
return null;
|
|
121
|
+
if (visibility === undefined || visibility === null || visibility === PRIVATE_VISIBILITY) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return (`ephemeral memories are private-only (continuity journal tier, flair#1257): ` +
|
|
125
|
+
`durability "${EPHEMERAL_DURABILITY}" cannot be written with visibility ${JSON.stringify(visibility)}. ` +
|
|
126
|
+
`Omit visibility (the durability-keyed default is "${PRIVATE_VISIBILITY}") or set it to ` +
|
|
127
|
+
`"${PRIVATE_VISIBILITY}"; for a memory other agents should read, use durability ` +
|
|
128
|
+
`"standard", "persistent", or "permanent".`);
|
|
129
|
+
}
|
|
@@ -29,14 +29,29 @@
|
|
|
29
29
|
// withDetachedTxn's transaction-chain workaround — both SemanticSearch and
|
|
30
30
|
// MemoryBootstrap are Harper Resources with their own `ctx`).
|
|
31
31
|
//
|
|
32
|
-
// Returns results AFTER all filters, sorted best-first by
|
|
33
|
-
// ONLY by the `limit` the caller chose to push down (the core never
|
|
32
|
+
// Returns results AFTER all filters, sorted best-first by RETRIEVAL RANK —
|
|
33
|
+
// bounded ONLY by the `limit` the caller chose to push down (the core never
|
|
34
34
|
// multiplies `limit` internally; any overfetch policy — SemanticSearch's
|
|
35
35
|
// CANDIDATE_MULTIPLIER, MemoryBootstrap's K formula — is the CALLER's
|
|
36
36
|
// decision, made
|
|
37
37
|
// before calling in). Never exposes which internal leg (BM25+RRF hybrid vs.
|
|
38
38
|
// legacy HNSW-only vs. keyword-only fallback) produced a given result — the
|
|
39
39
|
// output shape is identical regardless of `hybrid`.
|
|
40
|
+
//
|
|
41
|
+
// ── SCORE CONTRACT (flair#985) ───────────────────────────────────────────────
|
|
42
|
+
// `_score` under `scoring:"raw"` is ALWAYS an ABSOLUTE similarity (cosine of
|
|
43
|
+
// the query and the record's stored embedding, plus the legacy +0.05 substring
|
|
44
|
+
// keyword bump) — on every path, hybrid included. It is NEVER a
|
|
45
|
+
// rank-normalized value. Ordering and score are deliberately decoupled on the
|
|
46
|
+
// hybrid path: results are ORDERED by the fused RRF rank (that ordering is the
|
|
47
|
+
// hybrid recall win), but each result's `_score` reports its true evidence, so
|
|
48
|
+
// order and `_score` can disagree. The pre-#985 hybrid path reported the
|
|
49
|
+
// normalized RRF value AS `_score`, which pinned the top result of ANY query
|
|
50
|
+
// at 1.0 — and every consumer thresholding `_score` as a similarity (the
|
|
51
|
+
// pre-0.18 flair-client dedup gate at 0.95, `minScore`, `flair doctor`'s
|
|
52
|
+
// embed-verify probe) failed open at maximal confidence. For the dedup gate
|
|
53
|
+
// that meant EVERY memory_store from a stale client silently dropped its
|
|
54
|
+
// content into the arbitrary top-1 match — the #985 data-loss report.
|
|
40
55
|
import { databases } from "harper";
|
|
41
56
|
import { withDetachedTxn } from "./table-helpers.js";
|
|
42
57
|
import { wrapUntrusted } from "./content-safety.js";
|
|
@@ -85,10 +100,12 @@ export async function retrieveCandidates(params) {
|
|
|
85
100
|
// ── (a) Semantic candidate records (best-first) ──────────────────────
|
|
86
101
|
const semRecords = [];
|
|
87
102
|
const semIds = [];
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
103
|
+
// Absolute cosine similarity per semantic candidate (from the HNSW
|
|
104
|
+
// `$distance`), captured HERE before `$distance` is stripped downstream.
|
|
105
|
+
// Captured UNCONDITIONALLY (flair#985): this is the value `_score` reports
|
|
106
|
+
// under `scoring:"raw"` — see the fused loop below — and, when
|
|
107
|
+
// `withSemSimilarity` (flair#744 slice 2), also the confidence signal the
|
|
108
|
+
// abstention decision reads via the opt-in `_semSimilarity` field.
|
|
92
109
|
const semSimById = new Map();
|
|
93
110
|
if (qEmb) {
|
|
94
111
|
const semQuery = {
|
|
@@ -119,9 +136,20 @@ export async function retrieveCandidates(params) {
|
|
|
119
136
|
continue;
|
|
120
137
|
if (!passesAllowed(record))
|
|
121
138
|
continue;
|
|
122
|
-
if (
|
|
139
|
+
if (record.$distance !== undefined) {
|
|
123
140
|
semSimById.set(record.id, distanceToSimilarity(record.$distance));
|
|
124
141
|
}
|
|
142
|
+
else {
|
|
143
|
+
// Harper's cosine-sort query omits `$distance` for a SINGLETON
|
|
144
|
+
// post-filter result set (see the legacy path below and
|
|
145
|
+
// resources/SemanticSearch.ts's original writeup). Point-lookup the
|
|
146
|
+
// record and compute cosine ourselves from its real stored
|
|
147
|
+
// embedding — a missing/empty stored embedding yields 0 (safe "no
|
|
148
|
+
// semantic evidence"), never a false-high score.
|
|
149
|
+
const full = await withDetachedTxn(ctx, () => databases.flair.Memory.get(record.id));
|
|
150
|
+
const storedEmbedding = Array.isArray(full?.embedding) ? full.embedding : [];
|
|
151
|
+
semSimById.set(record.id, cosineSimilarity(qEmb, storedEmbedding));
|
|
152
|
+
}
|
|
125
153
|
semRecords.push(record);
|
|
126
154
|
semIds.push(record.id);
|
|
127
155
|
}
|
|
@@ -175,40 +203,78 @@ export async function retrieveCandidates(params) {
|
|
|
175
203
|
_score: Math.round(finalScore * 1000) / 1000,
|
|
176
204
|
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
177
205
|
_source: source,
|
|
206
|
+
_rank: finalScore,
|
|
178
207
|
});
|
|
179
208
|
}
|
|
180
209
|
}
|
|
181
210
|
else {
|
|
182
|
-
// ── Candidate-union RRF → normalized [0,1]
|
|
211
|
+
// ── Candidate-union RRF → normalized [0,1] RANKING value ────────────
|
|
212
|
+
// flair#985: the fused RRF value ORDERS results but is never REPORTED
|
|
213
|
+
// as a score. RRF normalization pins the top candidate at exactly 1.0
|
|
214
|
+
// regardless of how weak the real match is — reporting it as `_score`
|
|
215
|
+
// (the pre-#985 behavior) silently changed the meaning of `_score` from
|
|
216
|
+
// "absolute similarity, 0.95 ≈ near-duplicate" to "relative rank". Every
|
|
217
|
+
// consumer that thresholds `_score` as a similarity then fails OPEN at
|
|
218
|
+
// maximal confidence: the pre-0.18 flair-client dedup gate (`score >=
|
|
219
|
+
// 0.95` → suppress the write) suppressed EVERY memory_store into the
|
|
220
|
+
// arbitrary top-1 — however unrelated — which is the #985 field report
|
|
221
|
+
// (4/5 writes silently lost cross-topic). `minScore`, `flair doctor`'s
|
|
222
|
+
// embed-verify probe, and compositeScore's relevance floors all carry
|
|
223
|
+
// the same absolute-scale expectation. So: rank by fusion (`_rank`,
|
|
224
|
+
// internal, stripped before return — the hybrid recall win lives in the
|
|
225
|
+
// fused ORDER), report absolute evidence (`_score` = true cosine + the
|
|
226
|
+
// legacy keyword bump, same scale as the legacy HNSW-only path below).
|
|
183
227
|
const fused = fuseRrfNormalized(semIds, bm25Ids);
|
|
184
228
|
for (const [id, rrfRaw] of fused) {
|
|
185
229
|
const record = allowedById.get(id);
|
|
186
230
|
if (!record)
|
|
187
231
|
continue; // should not happen — union ⊆ allowed
|
|
188
|
-
|
|
189
|
-
|
|
232
|
+
// Absolute semantic similarity for this candidate. Sem-leg candidates
|
|
233
|
+
// already carry it (captured above, incl. the singleton-`$distance`
|
|
234
|
+
// fallback). A BM25-only candidate never went through the HNSW leg —
|
|
235
|
+
// point-lookup its stored embedding and compute the true cosine, so a
|
|
236
|
+
// genuinely-relevant lexical rescue reports its real similarity
|
|
237
|
+
// instead of a fabricated one (missing/legacy embedding ⇒ 0, safe).
|
|
238
|
+
let semSim = semSimById.get(id);
|
|
239
|
+
if (semSim === undefined && qEmb) {
|
|
240
|
+
const full = await withDetachedTxn(ctx, () => databases.flair.Memory.get(id));
|
|
241
|
+
const storedEmbedding = Array.isArray(full?.embedding) ? full.embedding : [];
|
|
242
|
+
semSim = cosineSimilarity(qEmb, storedEmbedding);
|
|
243
|
+
semSimById.set(id, semSim);
|
|
244
|
+
}
|
|
245
|
+
let keywordHit = false;
|
|
246
|
+
if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
|
|
247
|
+
keywordHit = true;
|
|
248
|
+
}
|
|
249
|
+
const rawScore = (semSim ?? 0) + (keywordHit ? 0.05 : 0);
|
|
250
|
+
let finalScore = scoring === "raw" ? rawScore : compositeScore(rrfRaw, record);
|
|
190
251
|
if (temporalBoost > 1.0)
|
|
191
252
|
finalScore *= temporalBoost;
|
|
192
253
|
const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
|
|
193
254
|
const source = record.agentId !== agentId ? record.agentId : undefined;
|
|
194
|
-
// flair#744 slice 2: absolute cosine confidence for the abstention
|
|
195
|
-
// decision — only for records that had a semantic (HNSW) candidate; a
|
|
196
|
-
// BM25-lexical-only match carries no cosine and contributes none.
|
|
197
|
-
const semSim = withSemSimilarity ? semSimById.get(id) : undefined;
|
|
198
255
|
results.push({
|
|
199
256
|
...record,
|
|
200
257
|
content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
|
|
201
258
|
_score: Math.round(finalScore * 1000) / 1000,
|
|
202
259
|
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
203
260
|
_source: source,
|
|
204
|
-
|
|
261
|
+
// Ordering key: fused rank for raw mode; composite value for
|
|
262
|
+
// composite mode (composite ordering is unchanged by #985 — its
|
|
263
|
+
// rrfRaw input and result order are exactly the pre-#985 behavior).
|
|
264
|
+
_rank: scoring === "raw" ? rrfRaw : finalScore,
|
|
265
|
+
// flair#744 slice 2: the opt-in absolute-confidence field for the
|
|
266
|
+
// abstention decision. Attach remains OPT-IN so non-abstain
|
|
267
|
+
// responses stay byte-identical (the capture above is now
|
|
268
|
+
// unconditional, but the response field is not).
|
|
269
|
+
...(withSemSimilarity && semSim !== undefined ? { _semSimilarity: semSim } : {}),
|
|
205
270
|
});
|
|
206
271
|
}
|
|
207
272
|
}
|
|
208
273
|
}
|
|
209
274
|
else if (qEmb) {
|
|
210
|
-
// ─── HNSW vector search path (legacy, hybrid flag OFF —
|
|
211
|
-
//
|
|
275
|
+
// ─── HNSW vector search path (legacy, hybrid flag OFF — the
|
|
276
|
+
// FLAIR_HYBRID_RETRIEVAL kill-switch path for BOTH production callers
|
|
277
|
+
// since flair#1246) ─────────────────────────────────────────────────────
|
|
212
278
|
const query = {
|
|
213
279
|
sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
|
|
214
280
|
select: hnswSelect,
|
|
@@ -269,6 +335,7 @@ export async function retrieveCandidates(params) {
|
|
|
269
335
|
_score: Math.round(finalScore * 1000) / 1000,
|
|
270
336
|
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
271
337
|
_source: source,
|
|
338
|
+
_rank: finalScore,
|
|
272
339
|
...(withSemSimilarity ? { _semSimilarity: semanticScore } : {}),
|
|
273
340
|
});
|
|
274
341
|
}
|
|
@@ -314,6 +381,7 @@ export async function retrieveCandidates(params) {
|
|
|
314
381
|
_score: Math.round(finalScore * 1000) / 1000,
|
|
315
382
|
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
316
383
|
_source: source,
|
|
384
|
+
_rank: finalScore,
|
|
317
385
|
});
|
|
318
386
|
}
|
|
319
387
|
}
|
|
@@ -331,10 +399,22 @@ export async function retrieveCandidates(params) {
|
|
|
331
399
|
}
|
|
332
400
|
filteredResults = results.filter((r) => !supersededIds.has(r.id));
|
|
333
401
|
}
|
|
334
|
-
// Apply minimum score filter
|
|
402
|
+
// Apply minimum score filter — against `_score`, which is ALWAYS on the
|
|
403
|
+
// absolute-similarity scale after flair#985 (the hybrid path used to report
|
|
404
|
+
// the rank-normalized RRF value here, so `minScore: 0.95` matched the
|
|
405
|
+
// always-1.0 top-1 of ANY query instead of meaning "similarity ≥ 0.95").
|
|
335
406
|
if (minScore > 0) {
|
|
336
407
|
filteredResults = filteredResults.filter((r) => r._score >= minScore);
|
|
337
408
|
}
|
|
338
|
-
|
|
409
|
+
// Order by the internal ranking key (fused RRF rank on the hybrid raw path;
|
|
410
|
+
// identical to `_score` everywhere else), then strip it — `_rank` is an
|
|
411
|
+
// ordering key, never part of the response shape. Note the hybrid raw
|
|
412
|
+
// ordering is deliberately NOT by `_score`: the recall win of hybrid
|
|
413
|
+
// retrieval lives in the fused ORDER (a BM25 rank-1 rescue outranks weak
|
|
414
|
+
// semantic hits), while `_score` carries the honest absolute evidence for
|
|
415
|
+
// each result — the two can disagree, and that is correct.
|
|
416
|
+
filteredResults.sort((a, b) => b._rank - a._rank);
|
|
417
|
+
for (const r of filteredResults)
|
|
418
|
+
delete r._rank;
|
|
339
419
|
return filteredResults;
|
|
340
420
|
}
|
|
@@ -50,7 +50,7 @@ The target defaults to `https://<cluster>.<org>.harperfabric.com`. Override with
|
|
|
50
50
|
|
|
51
51
|
A fleet-verify table follows. That HTTPS origin is your `FLAIR_URL`.
|
|
52
52
|
|
|
53
|
-
Then set an admin password in Fabric Studio (Cluster Settings → Admin). `flair agent add` against a remote instance requires `--admin-pass` — it will not reuse `~/.flair/admin-pass` or `FLAIR_ADMIN_PASS` from your laptop.
|
|
53
|
+
Then set an admin password in Fabric Studio (Cluster Settings → Admin). `flair agent add` against a remote instance requires an explicit `--admin-pass-file` or `--admin-pass` — it will not reuse `~/.flair/admin-pass` or `FLAIR_ADMIN_PASS` from your laptop.
|
|
54
54
|
|
|
55
55
|
## 3. Register an agent against the remote instance
|
|
56
56
|
|
|
@@ -61,10 +61,15 @@ On Fabric, ops lives on the **same hostname at port 9925**, not the CLI's defaul
|
|
|
61
61
|
```bash
|
|
62
62
|
export FLAIR_URL=https://<cluster>.<org>.harperfabric.com
|
|
63
63
|
|
|
64
|
+
# Keep the Fabric admin password in an owner-only file; the CLI reads it in-process.
|
|
65
|
+
printf '%s\n' '<fabric-admin-password>' > ~/.flair/fabric-admin-pass && chmod 600 ~/.flair/fabric-admin-pass
|
|
66
|
+
|
|
64
67
|
# Fabric ops is :9925 on the same host, not derived :442. docs-freshness-allow: Fabric ops API
|
|
65
|
-
flair agent add mybot --target "$FLAIR_URL" --ops-target https://<cluster>.<org>.harperfabric.com:9925 --admin-pass
|
|
68
|
+
flair agent add mybot --target "$FLAIR_URL" --ops-target https://<cluster>.<org>.harperfabric.com:9925 --admin-pass-file ~/.flair/fabric-admin-pass
|
|
66
69
|
```
|
|
67
70
|
|
|
71
|
+
`--admin-pass-file` reads the file inside the CLI process (mode `0600` enforced), so the password never appears in shell history **or** `ps`. Inline `--admin-pass <pass>` still works but lands in both; the older `--admin-pass "$(cat <path>)"` workaround stays out of history but is still visible to local `ps` while the command runs. Remote `agent add` honors **only** an explicit flag by design — `FLAIR_ADMIN_PASS` and `~/.flair/admin-pass` are this machine's *local* credentials and are never reused against a remote target.
|
|
72
|
+
|
|
68
73
|
```
|
|
69
74
|
Keypair written: ~/.flair/keys/mybot.key
|
|
70
75
|
✅ Agent 'mybot' (mybot) registered (ops: https://<cluster>.<org>.harperfabric.com:9925) <!-- docs-freshness-allow: Fabric ops API -->
|
|
@@ -86,7 +91,41 @@ In Cursor: **Plugins → Configure**
|
|
|
86
91
|
|
|
87
92
|
Those are the two plugin schema fields. Local Cursor's `npx` can use the key from step 3 at `~/.flair/keys/mybot.key`. A cloud agent's `npx` runs on a different machine — that VM needs the key (or host-env admin credentials). See [`packages/cursor-flair/README.md`](../packages/cursor-flair/README.md).
|
|
88
93
|
|
|
89
|
-
## 5.
|
|
94
|
+
## 5. Wire a Grok Bot agent
|
|
95
|
+
|
|
96
|
+
Same connector, different panel. This is the field-verified sequence — including the two places it fails first.
|
|
97
|
+
|
|
98
|
+
**One identity per agent.** Short lowercase ids (`grok-cos` for a Chief-of-Staff agent). Never share an id across agents, and never leave admin credentials as an agent's standing auth — the admin password is for registration, once.
|
|
99
|
+
|
|
100
|
+
1. In the Grok Bot agent's **Tools & MCPs** panel, add the Flair connector (the same `flair-mcp` stdio server the Cursor plugin runs):
|
|
101
|
+
|
|
102
|
+
| Variable | Value |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `FLAIR_URL` | `https://<cluster>.<org>.harperfabric.com` |
|
|
105
|
+
| `FLAIR_AGENT_ID` | `grok-cos` (this agent's own id) |
|
|
106
|
+
|
|
107
|
+
2. Ask for a bootstrap. **The first one returns 401.** That is fail-closed working as designed: the id is not registered yet and the machine has no key. Do not "fix" it by pasting admin credentials into the agent's environment.
|
|
108
|
+
|
|
109
|
+
3. Provide the Fabric admin password through the platform's secret mechanism — Grok Bot's secure secret card — never pasted in chat. It is needed once, for registration only.
|
|
110
|
+
|
|
111
|
+
4. On the machine that runs the MCP process (for Grok Bot, that is the agent VM): Node.js **22 or newer** — the field machine had 20 and had to upgrade before anything else worked. Then:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npm i -g @tpsdev-ai/flair
|
|
115
|
+
|
|
116
|
+
# Fabric ops is :9925 on the same host, as in step 3. docs-freshness-allow: Fabric ops API
|
|
117
|
+
flair agent add grok-cos --target https://<cluster>.<org>.harperfabric.com --ops-target https://<cluster>.<org>.harperfabric.com:9925 --admin-pass "$(cat <pass-file>)"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Keep the pass file mode `0600`. Newer releases add `--admin-pass-file <path>`, which reads the file in-process (invisible to `ps`) — check `flair agent add --help` and prefer it when present.
|
|
121
|
+
|
|
122
|
+
5. **Restart the MCP process** if it started before the key existed — it does not pick the key up mid-session. Still 401 with the key on disk? Set the key path explicitly in the agent's MCP env: `FLAIR_KEY_PATH=~/.flair/keys/grok-cos.key`. Known papercut, tracked in [flair#1271](https://github.com/tpsdev-ai/flair/issues/1271).
|
|
123
|
+
|
|
124
|
+
6. Verify: ask the agent to "load my Flair bootstrap". You should get soul + memories **including shared org context** — findings written by teammate agents. A shared-visibility write from this agent is now readable by every org agent.
|
|
125
|
+
|
|
126
|
+
**Account-wide connectors are shared.** A connector added at the Grok Bot account level is one identity used by every agent on that account. For per-agent identity, give each agent its own MCP entry with its own `FLAIR_AGENT_ID` — and register each id (steps 2–5).
|
|
127
|
+
|
|
128
|
+
## 6. Verify
|
|
90
129
|
|
|
91
130
|
```bash
|
|
92
131
|
flair status --target "$FLAIR_URL"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"packageManager": "bun@1.3.10",
|
|
5
5
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
6
6
|
"type": "module",
|