@tpsdev-ai/flair 0.21.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/SECURITY.md +24 -2
- package/config.yaml +32 -5
- package/dist/cli.js +1811 -221
- package/dist/deploy.js +3 -4
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/runner.js +82 -12
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +23 -0
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +52 -53
- package/dist/resources/MemoryBootstrap.js +422 -76
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +8 -1
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/Presence.js +218 -19
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +98 -25
- package/dist/resources/SemanticSearch.js +85 -317
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/WorkspaceState.js +11 -0
- package/dist/resources/agent-auth.js +60 -2
- package/dist/resources/auth-middleware.js +18 -9
- package/dist/resources/bm25.js +12 -6
- package/dist/resources/collision-lib.js +157 -0
- package/dist/resources/embeddings-boot.js +149 -0
- package/dist/resources/embeddings-provider.js +364 -106
- package/dist/resources/entity-vocab.js +139 -0
- package/dist/resources/health.js +97 -6
- package/dist/resources/mcp-client-metadata-fields.js +112 -0
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-tools.js +88 -10
- package/dist/resources/memory-reflect-lib.js +289 -0
- package/dist/resources/migration-boot.js +143 -0
- package/dist/resources/migrations/dir-safety.js +71 -0
- package/dist/resources/migrations/embedding-stamp.js +178 -0
- package/dist/resources/migrations/envelope.js +43 -0
- package/dist/resources/migrations/export.js +38 -0
- package/dist/resources/migrations/ledger.js +55 -0
- package/dist/resources/migrations/lock.js +154 -0
- package/dist/resources/migrations/progress.js +31 -0
- package/dist/resources/migrations/registry.js +36 -0
- package/dist/resources/migrations/risk-policy.js +23 -0
- package/dist/resources/migrations/runner.js +455 -0
- package/dist/resources/migrations/snapshot.js +127 -0
- package/dist/resources/migrations/source-fields.js +93 -0
- package/dist/resources/migrations/space.js +167 -0
- package/dist/resources/migrations/state.js +64 -0
- package/dist/resources/migrations/status.js +39 -0
- package/dist/resources/migrations/synthetic-test-migration.js +93 -0
- package/dist/resources/migrations/types.js +9 -0
- package/dist/resources/presence-internal.js +29 -0
- package/dist/resources/provenance.js +50 -0
- package/dist/resources/rate-limiter.js +8 -1
- package/dist/resources/scoring.js +124 -5
- package/dist/resources/semantic-retrieval-core.js +317 -0
- package/dist/version-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/event.graphql +5 -0
- package/schemas/memory.graphql +66 -0
- package/schemas/schema.graphql +5 -43
- package/schemas/workspace.graphql +3 -0
- package/dist/resources/IngestEvents.js +0 -162
- package/dist/resources/IssueTokens.js +0 -19
- package/dist/resources/ObsAgentSnapshot.js +0 -13
- package/dist/resources/ObsEventFeed.js +0 -13
- package/dist/resources/ObsOffice.js +0 -19
- package/dist/resources/ObservationCenter.js +0 -23
- package/ui/observation-center.html +0 -385
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// ─── retrieveCandidates() — the pure retrieval core (flair bootstrap-scale-fix) ──
|
|
2
|
+
//
|
|
3
|
+
// Extracted from resources/SemanticSearch.ts's post() (Kern-approved
|
|
4
|
+
// refactor, flair#695). Before this module existed,
|
|
5
|
+
// SemanticSearch.post() was one function entangling auth resolution,
|
|
6
|
+
// rate-limiting, HNSW/BM25 retrieval, post-retrieval filtering, the
|
|
7
|
+
// cross-encoder reranker, AND retrievalCount/lastRetrieved hit-tracking side
|
|
8
|
+
// effects — so the ONLY way for MemoryBootstrap (resources/MemoryBootstrap.ts)
|
|
9
|
+
// to get bounded, HNSW-pushed-down candidates was to duplicate the retrieval
|
|
10
|
+
// logic or trip the side effects (an internal bootstrap call spuriously
|
|
11
|
+
// bumping `retrievalCount` would pollute a ranking signal every other agent's
|
|
12
|
+
// searches read).
|
|
13
|
+
//
|
|
14
|
+
// Boundary (Kern's review, folded into the implementation checklist): this
|
|
15
|
+
// function owns SemanticSearch's retrieval + post-retrieval filtering layers —
|
|
16
|
+
// the HNSW leg query construction (sort/select/conditions/limit), the BM25 +
|
|
17
|
+
// union-RRF hybrid fusion, the per-record temporal/expiry/supersede filters,
|
|
18
|
+
// and the scope.isAllowed() defense-in-depth re-check. It does NOT own: auth
|
|
19
|
+
// resolution, rate-limiting, the reranker, or the retrievalCount/lastRetrieved
|
|
20
|
+
// hit-tracking side effects — those stay in SemanticSearch.post()'s wrapper
|
|
21
|
+
// (resources/SemanticSearch.ts) so an internal caller (bootstrap) never trips
|
|
22
|
+
// them.
|
|
23
|
+
//
|
|
24
|
+
// PURE FUNCTION DISCIPLINE (Kern, non-negotiable): every param below is a
|
|
25
|
+
// primitive/plain-value/function — never `this` or a SemanticSearch instance.
|
|
26
|
+
// A core that took `this` would force mocking (or a later re-refactor) for
|
|
27
|
+
// any second caller; this one is callable standalone, no Resource/HTTP
|
|
28
|
+
// context required beyond the optional `ctx` param (only used for
|
|
29
|
+
// withDetachedTxn's transaction-chain workaround — both SemanticSearch and
|
|
30
|
+
// MemoryBootstrap are Harper Resources with their own `ctx`).
|
|
31
|
+
//
|
|
32
|
+
// Returns results AFTER all filters, sorted best-first by `_score` — bounded
|
|
33
|
+
// ONLY by the `limit` the caller chose to push down (the core never
|
|
34
|
+
// multiplies `limit` internally; any overfetch policy — SemanticSearch's
|
|
35
|
+
// CANDIDATE_MULTIPLIER, rerank-topN widening — is the CALLER's decision, made
|
|
36
|
+
// before calling in). Never exposes which internal leg (BM25+RRF hybrid vs.
|
|
37
|
+
// legacy HNSW-only vs. keyword-only fallback) produced a given result — the
|
|
38
|
+
// output shape is identical regardless of `hybrid`.
|
|
39
|
+
import { databases } from "@harperfast/harper";
|
|
40
|
+
import { withDetachedTxn } from "./table-helpers.js";
|
|
41
|
+
import { wrapUntrusted } from "./content-safety.js";
|
|
42
|
+
import { cosineSimilarity } from "./dedup.js";
|
|
43
|
+
import { compositeScore } from "./scoring.js";
|
|
44
|
+
import { buildBM25, fuseRrfNormalized, SEM_LIMIT } from "./bm25.js";
|
|
45
|
+
import { isAllowedBm25Candidate } from "./bm25-filter.js";
|
|
46
|
+
// Convert HNSW cosine distance (1 - similarity) to similarity score.
|
|
47
|
+
function distanceToSimilarity(distance) {
|
|
48
|
+
return 1 - distance;
|
|
49
|
+
}
|
|
50
|
+
// Default field selection for every retrieval leg — explicit (no raw
|
|
51
|
+
// `embedding`, so the large vector never enters a result payload or a
|
|
52
|
+
// bootstrap-sized candidate pool) and shared between the HNSW leg and the
|
|
53
|
+
// BM25 corpus fetch so a fused id always resolves to the same record shape
|
|
54
|
+
// regardless of which leg produced it. Includes `summary` (agent-set dense
|
|
55
|
+
// compression, resources/Memory.ts) even though SemanticSearch's own callers
|
|
56
|
+
// don't read it — MemoryBootstrap's collision-surfacing block
|
|
57
|
+
// (resources/collision-lib.ts's SemanticMatchInput) reads `m.summary ||
|
|
58
|
+
// m.content`, so dropping it here would silently regress bootstrap's
|
|
59
|
+
// "Others in the room" surface even though SemanticSearch never asserts on
|
|
60
|
+
// its absence.
|
|
61
|
+
const DEFAULT_SELECT = ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
|
|
62
|
+
"source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "usageCount", "lastRetrieved",
|
|
63
|
+
"promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
|
|
64
|
+
"parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject", "summary",
|
|
65
|
+
"validFrom", "validTo", "_safetyFlags"];
|
|
66
|
+
export async function retrieveCandidates(params) {
|
|
67
|
+
const { queryEmbedding: qEmb, q, conditions, limit, select = DEFAULT_SELECT, includeSuperseded = false, scoring = "raw", temporalBoost = 1.0, sinceDate = null, asOf, minScore = 0, agentId, isAllowed, hybrid, ctx, } = params;
|
|
68
|
+
const passesAllowed = (record) => !isAllowed || isAllowed(record);
|
|
69
|
+
const hnswSelect = [...select, "$distance"];
|
|
70
|
+
const results = [];
|
|
71
|
+
if (hybrid) {
|
|
72
|
+
// ─── BM25 + union-RRF hybrid path ────────────────────────────────────
|
|
73
|
+
// 1. Semantic candidates via HNSW (unchanged fetch). 2. BM25 lexical pass
|
|
74
|
+
// over the SCOPED corpus. 3. SECURITY: the BM25 candidate set is filtered
|
|
75
|
+
// by the SAME conditions[] + temporal filters BEFORE fusion (the corpus
|
|
76
|
+
// is fetched with those conditions, AND re-checked in-process as
|
|
77
|
+
// defense-in-depth) so no other agent's memory is ever scored or fused.
|
|
78
|
+
// 4. Candidate-union RRF → normalize → feed as rawScore to compositeScore.
|
|
79
|
+
// ── (a) Semantic candidate records (best-first) ──────────────────────
|
|
80
|
+
const semRecords = [];
|
|
81
|
+
const semIds = [];
|
|
82
|
+
if (qEmb) {
|
|
83
|
+
const semQuery = {
|
|
84
|
+
sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
|
|
85
|
+
select: hnswSelect,
|
|
86
|
+
limit,
|
|
87
|
+
};
|
|
88
|
+
if (conditions.length > 0)
|
|
89
|
+
semQuery.conditions = conditions;
|
|
90
|
+
const semResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(semQuery));
|
|
91
|
+
for await (const record of semResults) {
|
|
92
|
+
if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
|
|
93
|
+
continue;
|
|
94
|
+
if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
|
|
95
|
+
continue;
|
|
96
|
+
if (asOf && record.validFrom && record.validFrom > asOf)
|
|
97
|
+
continue;
|
|
98
|
+
if (asOf && record.validTo && record.validTo <= asOf)
|
|
99
|
+
continue;
|
|
100
|
+
// A past validTo ALWAYS means the record has been closed out
|
|
101
|
+
// (server supersede path — Memory.ts closeSupersededRecord — sets
|
|
102
|
+
// validTo without necessarily setting `archived`). Unconditional, not
|
|
103
|
+
// gated on `asOf`, so a server-superseded record can't resurface just
|
|
104
|
+
// because its successor isn't co-present in this result set (the
|
|
105
|
+
// supersededIds filter further down only catches co-presence). A
|
|
106
|
+
// record with no validTo, or a future validTo, is unaffected.
|
|
107
|
+
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
108
|
+
continue;
|
|
109
|
+
if (!passesAllowed(record))
|
|
110
|
+
continue;
|
|
111
|
+
semRecords.push(record);
|
|
112
|
+
semIds.push(record.id);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// ── (b) BM25 candidate records over the SCOPED corpus ────────────────
|
|
116
|
+
const corpusQuery = conditions.length > 0
|
|
117
|
+
? { conditions, select }
|
|
118
|
+
: { select };
|
|
119
|
+
const corpusResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(corpusQuery));
|
|
120
|
+
const allowedById = new Map();
|
|
121
|
+
const bm25Docs = [];
|
|
122
|
+
for await (const record of corpusResults) {
|
|
123
|
+
// Defense-in-depth: re-check the SAME conditions[] + temporal filters
|
|
124
|
+
// in-process. Even if a Harper query change ever let an out-of-scope
|
|
125
|
+
// record through, it is dropped here BEFORE it can be BM25-scored/fused.
|
|
126
|
+
if (!isAllowedBm25Candidate(record, conditions, { sinceDate, asOf }))
|
|
127
|
+
continue;
|
|
128
|
+
if (!passesAllowed(record))
|
|
129
|
+
continue;
|
|
130
|
+
allowedById.set(record.id, record);
|
|
131
|
+
bm25Docs.push({ id: record.id, content: record.content });
|
|
132
|
+
}
|
|
133
|
+
// Carry semantic candidates that survived their temporal gate into the
|
|
134
|
+
// allowed map too (so a fused id always resolves to a record). Semantic
|
|
135
|
+
// records were fetched with the SAME conditions[], so they're in-scope.
|
|
136
|
+
for (const r of semRecords) {
|
|
137
|
+
if (!allowedById.has(r.id)) {
|
|
138
|
+
const { $distance, ...rest } = r;
|
|
139
|
+
allowedById.set(r.id, rest);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// ── (c) BM25 lexical ranking → top SEM_LIMIT (only when q present) ────
|
|
143
|
+
let bm25Ids = [];
|
|
144
|
+
if (q) {
|
|
145
|
+
const bm25 = buildBM25(bm25Docs);
|
|
146
|
+
const ranked = bm25.rank(String(q));
|
|
147
|
+
bm25Ids = ranked.filter(r => r.score > 0).slice(0, SEM_LIMIT).map(r => r.id);
|
|
148
|
+
}
|
|
149
|
+
// ── (d) No retrieval signal at all → full scoped listing ────────────
|
|
150
|
+
if (!q && !qEmb) {
|
|
151
|
+
for (const record of allowedById.values()) {
|
|
152
|
+
const rawScore = 0;
|
|
153
|
+
let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
|
|
154
|
+
if (temporalBoost > 1.0)
|
|
155
|
+
finalScore *= temporalBoost;
|
|
156
|
+
const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
|
|
157
|
+
const source = record.agentId !== agentId ? record.agentId : undefined;
|
|
158
|
+
results.push({
|
|
159
|
+
...record,
|
|
160
|
+
content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
|
|
161
|
+
_score: Math.round(finalScore * 1000) / 1000,
|
|
162
|
+
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
163
|
+
_source: source,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
// ── Candidate-union RRF → normalized [0,1] rawScore ────────────────
|
|
169
|
+
const fused = fuseRrfNormalized(semIds, bm25Ids);
|
|
170
|
+
for (const [id, rrfRaw] of fused) {
|
|
171
|
+
const record = allowedById.get(id);
|
|
172
|
+
if (!record)
|
|
173
|
+
continue; // should not happen — union ⊆ allowed
|
|
174
|
+
const rawScore = rrfRaw; // already normalized to [0,1]
|
|
175
|
+
let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
|
|
176
|
+
if (temporalBoost > 1.0)
|
|
177
|
+
finalScore *= temporalBoost;
|
|
178
|
+
const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
|
|
179
|
+
const source = record.agentId !== agentId ? record.agentId : undefined;
|
|
180
|
+
results.push({
|
|
181
|
+
...record,
|
|
182
|
+
content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
|
|
183
|
+
_score: Math.round(finalScore * 1000) / 1000,
|
|
184
|
+
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
185
|
+
_source: source,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else if (qEmb) {
|
|
191
|
+
// ─── HNSW vector search path (legacy, hybrid flag OFF — or a caller
|
|
192
|
+
// like MemoryBootstrap forcing HNSW-leg-only regardless of the flag) ────
|
|
193
|
+
const query = {
|
|
194
|
+
sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
|
|
195
|
+
select: hnswSelect,
|
|
196
|
+
limit,
|
|
197
|
+
};
|
|
198
|
+
if (conditions.length > 0) {
|
|
199
|
+
query.conditions = conditions;
|
|
200
|
+
}
|
|
201
|
+
const memoryResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
|
|
202
|
+
for await (const record of memoryResults) {
|
|
203
|
+
if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
|
|
204
|
+
continue;
|
|
205
|
+
if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
|
|
206
|
+
continue;
|
|
207
|
+
if (asOf && record.validFrom && record.validFrom > asOf)
|
|
208
|
+
continue;
|
|
209
|
+
if (asOf && record.validTo && record.validTo <= asOf)
|
|
210
|
+
continue;
|
|
211
|
+
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
212
|
+
continue;
|
|
213
|
+
if (!passesAllowed(record))
|
|
214
|
+
continue;
|
|
215
|
+
let semanticScore;
|
|
216
|
+
if (record.$distance !== undefined) {
|
|
217
|
+
semanticScore = distanceToSimilarity(record.$distance);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
// ─── Harper's cosine-sort query omits $distance for a SINGLETON
|
|
221
|
+
// post-filter result set (see resources/SemanticSearch.ts's original
|
|
222
|
+
// writeup of this quirk, and test/integration/
|
|
223
|
+
// semantic-search-singleton-score.test.ts for the real-Harper
|
|
224
|
+
// reproduction). Fix: point-lookup the record by id and compute
|
|
225
|
+
// cosine similarity ourselves from its real stored `embedding`
|
|
226
|
+
// vector. If the stored embedding is missing/empty, cosineSimilarity
|
|
227
|
+
// returns 0 — the same safe "no match" the old `?? 1` fallback
|
|
228
|
+
// produced, never a false-high score.
|
|
229
|
+
const full = await withDetachedTxn(ctx, () => databases.flair.Memory.get(record.id));
|
|
230
|
+
const storedEmbedding = Array.isArray(full?.embedding) ? full.embedding : [];
|
|
231
|
+
semanticScore = cosineSimilarity(qEmb, storedEmbedding);
|
|
232
|
+
}
|
|
233
|
+
let keywordHit = false;
|
|
234
|
+
if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
|
|
235
|
+
keywordHit = true;
|
|
236
|
+
}
|
|
237
|
+
const rawScore = semanticScore + (keywordHit ? 0.05 : 0);
|
|
238
|
+
let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
|
|
239
|
+
if (temporalBoost > 1.0)
|
|
240
|
+
finalScore *= temporalBoost;
|
|
241
|
+
const { $distance, ...rest } = record;
|
|
242
|
+
const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
|
|
243
|
+
const source = record.agentId !== agentId ? record.agentId : undefined;
|
|
244
|
+
results.push({
|
|
245
|
+
...rest,
|
|
246
|
+
content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
|
|
247
|
+
_score: Math.round(finalScore * 1000) / 1000,
|
|
248
|
+
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
249
|
+
_source: source,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
// ─── No embedding available — keyword-only fallback ──────────────────
|
|
255
|
+
// Full scan is only used when there's no query embedding (e.g. tag-only
|
|
256
|
+
// or subject-only searches, or when the embedding engine is unavailable).
|
|
257
|
+
// Pre-existing, out-of-scope-for-this-PR behavior — MemoryBootstrap never
|
|
258
|
+
// reaches this branch (it only calls in when it already has a
|
|
259
|
+
// queryEmbedding).
|
|
260
|
+
const query = conditions.length > 0 ? { conditions } : {};
|
|
261
|
+
const memoryResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
|
|
262
|
+
for await (const record of memoryResults) {
|
|
263
|
+
if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
|
|
264
|
+
continue;
|
|
265
|
+
if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
|
|
266
|
+
continue;
|
|
267
|
+
if (asOf && record.validFrom && record.validFrom > asOf)
|
|
268
|
+
continue;
|
|
269
|
+
if (asOf && record.validTo && record.validTo <= asOf)
|
|
270
|
+
continue;
|
|
271
|
+
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
272
|
+
continue;
|
|
273
|
+
if (!passesAllowed(record))
|
|
274
|
+
continue;
|
|
275
|
+
let keywordHit = false;
|
|
276
|
+
if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
|
|
277
|
+
keywordHit = true;
|
|
278
|
+
}
|
|
279
|
+
const rawScore = keywordHit ? 0.05 : 0;
|
|
280
|
+
if (q && rawScore === 0)
|
|
281
|
+
continue;
|
|
282
|
+
const { embedding, ...rest } = record;
|
|
283
|
+
let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, rest);
|
|
284
|
+
if (temporalBoost > 1.0)
|
|
285
|
+
finalScore *= temporalBoost;
|
|
286
|
+
const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
|
|
287
|
+
const source = record.agentId !== agentId ? record.agentId : undefined;
|
|
288
|
+
results.push({
|
|
289
|
+
...rest,
|
|
290
|
+
content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
|
|
291
|
+
_score: Math.round(finalScore * 1000) / 1000,
|
|
292
|
+
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
293
|
+
_source: source,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Build superseded set and filter (unless caller opts in to see full
|
|
298
|
+
// history) — computed from THIS bounded result set alone (per-set, never
|
|
299
|
+
// cross-applied — see this PR's supersededIds doc in MemoryBootstrap.ts for
|
|
300
|
+
// the full caveat: the unconditional past-validTo exclusion above is the
|
|
301
|
+
// primary supersede guard; this co-presence check is a secondary belt).
|
|
302
|
+
let filteredResults = results;
|
|
303
|
+
if (!includeSuperseded) {
|
|
304
|
+
const supersededIds = new Set();
|
|
305
|
+
for (const r of results) {
|
|
306
|
+
if (r.supersedes)
|
|
307
|
+
supersededIds.add(r.supersedes);
|
|
308
|
+
}
|
|
309
|
+
filteredResults = results.filter((r) => !supersededIds.has(r.id));
|
|
310
|
+
}
|
|
311
|
+
// Apply minimum score filter
|
|
312
|
+
if (minScore > 0) {
|
|
313
|
+
filteredResults = filteredResults.filter((r) => r._score >= minScore);
|
|
314
|
+
}
|
|
315
|
+
filteredResults.sort((a, b) => b._score - a._score);
|
|
316
|
+
return filteredResults;
|
|
317
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* version-handshake.ts — CLI↔server version handshake (flair#695 §B, the
|
|
3
|
+
* bare-npm rescue): "Every CLI invocation (cheap, cached ~60s) compares:
|
|
4
|
+
* CLI version, installed-package version, running-server version. Mismatch
|
|
5
|
+
* → one-line nudge on stderr: `flair 0.23.0 installed but server is running
|
|
6
|
+
* 0.22.1 — run: flair restart`. Never blocks the command."
|
|
7
|
+
*
|
|
8
|
+
* Mirrors src/version-check.ts's discipline exactly (that module compares
|
|
9
|
+
* installed-vs-latest-PUBLISHED; this one compares installed-vs-RUNNING):
|
|
10
|
+
* offline-tolerant, short fetch timeout, cached with a TTL, NEVER throws.
|
|
11
|
+
* The one behavioral difference from version-check.ts: the cache is keyed
|
|
12
|
+
* per (rootPath, serverUrl) rather than one global file — a single CLI can
|
|
13
|
+
* legitimately point at more than one local Flair instance (different
|
|
14
|
+
* ROOTPATH, or a --target-switched remote), and a mismatch cached for one
|
|
15
|
+
* must never bleed into a nudge about a different one.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
export const DEFAULT_HANDSHAKE_TTL_MS = 60_000;
|
|
22
|
+
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 1500; // within the spec's 1-2s budget
|
|
23
|
+
export const DEFAULT_HANDSHAKE_CACHE_DIR = join(homedir(), ".flair", ".version-handshake-cache");
|
|
24
|
+
export function defaultHandshakeDeps() {
|
|
25
|
+
return {
|
|
26
|
+
fetchImpl: fetch,
|
|
27
|
+
cacheDir: DEFAULT_HANDSHAKE_CACHE_DIR,
|
|
28
|
+
ttlMs: DEFAULT_HANDSHAKE_TTL_MS,
|
|
29
|
+
timeoutMs: DEFAULT_HANDSHAKE_TIMEOUT_MS,
|
|
30
|
+
now: () => Date.now(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function cacheFileName(rootPath, serverUrl) {
|
|
34
|
+
return createHash("sha256").update(`${rootPath}|${serverUrl}`).digest("hex").slice(0, 32) + ".json";
|
|
35
|
+
}
|
|
36
|
+
function cacheFilePath(cacheDir, rootPath, serverUrl) {
|
|
37
|
+
return join(cacheDir, cacheFileName(rootPath, serverUrl));
|
|
38
|
+
}
|
|
39
|
+
function readCache(path) {
|
|
40
|
+
try {
|
|
41
|
+
if (!existsSync(path))
|
|
42
|
+
return null;
|
|
43
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
44
|
+
if (typeof raw?.checkedAt === "number" && (typeof raw?.runningVersion === "string" || raw?.runningVersion === null)) {
|
|
45
|
+
return { runningVersion: raw.runningVersion, checkedAt: raw.checkedAt };
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null; // corrupt/unreadable cache — treat as absent, never throw
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function writeCache(path, entry) {
|
|
54
|
+
try {
|
|
55
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
56
|
+
writeFileSync(path, JSON.stringify(entry));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Best-effort — a cache-write failure (e.g. read-only $HOME) must never
|
|
60
|
+
// surface as a command error.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolves the running server's version (via the public, unauthenticated
|
|
65
|
+
* `GET /Health` — reachable even before an agent key exists) and compares
|
|
66
|
+
* it to `cliVersion`. NEVER throws — every failure mode (offline, timeout,
|
|
67
|
+
* non-2xx, malformed JSON, cache I/O error) resolves to `source:
|
|
68
|
+
* "unavailable"` with `mismatch: false`, never an exception or a false
|
|
69
|
+
* nudge.
|
|
70
|
+
*/
|
|
71
|
+
export async function checkServerHandshake(cliVersion, rootPath, serverUrl, injected = {}) {
|
|
72
|
+
const deps = { ...defaultHandshakeDeps(), ...injected };
|
|
73
|
+
const path = cacheFilePath(deps.cacheDir, rootPath, serverUrl);
|
|
74
|
+
const nowMs = deps.now();
|
|
75
|
+
const cached = readCache(path);
|
|
76
|
+
if (cached && nowMs - cached.checkedAt < deps.ttlMs) {
|
|
77
|
+
return {
|
|
78
|
+
cliVersion,
|
|
79
|
+
runningVersion: cached.runningVersion,
|
|
80
|
+
mismatch: !!cached.runningVersion && cached.runningVersion !== cliVersion,
|
|
81
|
+
source: "cache",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
let fetched = null;
|
|
85
|
+
try {
|
|
86
|
+
const res = await deps.fetchImpl(`${serverUrl.replace(/\/+$/, "")}/Health`, {
|
|
87
|
+
signal: AbortSignal.timeout(deps.timeoutMs),
|
|
88
|
+
});
|
|
89
|
+
if (res.ok) {
|
|
90
|
+
const body = (await res.json());
|
|
91
|
+
fetched = typeof body?.version === "string" ? body.version : null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
fetched = null; // offline, DNS failure, timeout, non-JSON body — all the same: couldn't determine it this time
|
|
96
|
+
}
|
|
97
|
+
if (fetched !== null) {
|
|
98
|
+
writeCache(path, { runningVersion: fetched, checkedAt: nowMs });
|
|
99
|
+
return { cliVersion, runningVersion: fetched, mismatch: fetched !== cliVersion, source: "network" };
|
|
100
|
+
}
|
|
101
|
+
if (cached) {
|
|
102
|
+
// Server unreachable this time — fall back to a stale cache rather than
|
|
103
|
+
// reporting nothing.
|
|
104
|
+
return {
|
|
105
|
+
cliVersion,
|
|
106
|
+
runningVersion: cached.runningVersion,
|
|
107
|
+
mismatch: !!cached.runningVersion && cached.runningVersion !== cliVersion,
|
|
108
|
+
source: "cache",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return { cliVersion, runningVersion: null, mismatch: false, source: "unavailable" };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The one-line stderr nudge, or null when there's nothing worth printing
|
|
115
|
+
* (no mismatch, or nothing to compare against). Exact wording per the spec:
|
|
116
|
+
* "flair 0.23.0 installed but server is running 0.22.1 — run: flair restart".
|
|
117
|
+
*/
|
|
118
|
+
export function formatHandshakeNudge(result) {
|
|
119
|
+
if (!result.mismatch || !result.runningVersion)
|
|
120
|
+
return null;
|
|
121
|
+
return `flair ${result.cliVersion} installed but server is running ${result.runningVersion} — run: flair restart`;
|
|
122
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -31,7 +31,6 @@
|
|
|
31
31
|
"dist/",
|
|
32
32
|
"schemas/",
|
|
33
33
|
"templates/",
|
|
34
|
-
"ui/",
|
|
35
34
|
"config.yaml",
|
|
36
35
|
"LICENSE",
|
|
37
36
|
"README.md",
|
|
@@ -55,11 +54,11 @@
|
|
|
55
54
|
"node": ">=22"
|
|
56
55
|
},
|
|
57
56
|
"dependencies": {
|
|
58
|
-
"@harperfast/harper": "5.1.
|
|
59
|
-
"@harperfast/oauth": "2.
|
|
57
|
+
"@harperfast/harper": "5.1.17",
|
|
58
|
+
"@harperfast/oauth": "2.2.0",
|
|
60
59
|
"@types/js-yaml": "4.0.9",
|
|
61
60
|
"commander": "14.0.3",
|
|
62
|
-
"harper-fabric-embeddings": "0.
|
|
61
|
+
"harper-fabric-embeddings": "^0.3.0",
|
|
63
62
|
"jose": "6.2.2",
|
|
64
63
|
"js-yaml": "4.1.1",
|
|
65
64
|
"node-llama-cpp": "3.18.1",
|
package/schemas/event.graphql
CHANGED
|
@@ -9,4 +9,9 @@ type OrgEvent @table(database: "flair") @export {
|
|
|
9
9
|
refId: String @indexed
|
|
10
10
|
createdAt: String! @indexed
|
|
11
11
|
expiresAt: String @indexed
|
|
12
|
+
entities: [String] @indexed # attention-plane vocabulary strings (flair#675); formalizes a typed,
|
|
13
|
+
# vocabulary-consistent entity ref (targetIds stays for actor/target
|
|
14
|
+
# ids). Additive/nullable — old rows carry no entities, readers
|
|
15
|
+
# tolerate absent (same pattern as Presence.activityUpdatedAt).
|
|
16
|
+
# See docs/entity-vocabulary.md.
|
|
12
17
|
}
|
package/schemas/memory.graphql
CHANGED
|
@@ -5,6 +5,16 @@ type Memory @table(database: "flair") {
|
|
|
5
5
|
contentHash: String @indexed
|
|
6
6
|
visibility: String
|
|
7
7
|
embedding: [Float] @indexed(type: "HNSW")
|
|
8
|
+
embeddingModel: String @indexed # ops-1l18: previously written (resources/Memory.ts stamps every
|
|
9
|
+
# write) but never DECLARED in this schema, so it was storable but not
|
|
10
|
+
# queryable — Harper's Table.search({conditions}) rejects a condition on
|
|
11
|
+
# an undeclared attribute ("X is not a defined attribute"), which the
|
|
12
|
+
# zero-touch migration runner's bounded detect()/countPending()/run()
|
|
13
|
+
# queries (resources/migrations/embedding-stamp.ts: `embeddingModel
|
|
14
|
+
# not_equal <current>`) need in order to stay O(1)-bounded rather than a
|
|
15
|
+
# full-corpus scan on every boot. Purely additive — existing rows already
|
|
16
|
+
# carry this value on disk; declaring it queryable changes nothing about
|
|
17
|
+
# what's written or how embeddings/prefixes behave (#689's gate untouched).
|
|
8
18
|
tags: [String] @indexed
|
|
9
19
|
durability: String @indexed
|
|
10
20
|
source: String
|
|
@@ -13,6 +23,18 @@ type Memory @table(database: "flair") {
|
|
|
13
23
|
expiresAt: String @indexed
|
|
14
24
|
retrievalCount: Int
|
|
15
25
|
lastRetrieved: String
|
|
26
|
+
usageCount: Int # flair#683: verified-USE signal, distinct from retrievalCount (a
|
|
27
|
+
# search HIT). Additive/nullable — absent reads as 0, existing rows
|
|
28
|
+
# unaffected. NEVER auto-incremented on search/retrieval; the ONLY
|
|
29
|
+
# writer is the dedicated usage-feedback endpoint (resources/
|
|
30
|
+
# MemoryUsage.ts), which does a targeted patchRecord increment on
|
|
31
|
+
# this field alone (never through Memory.put() — see that
|
|
32
|
+
# resource's module doc for why: usage-feedback writes to ANOTHER
|
|
33
|
+
# agent's memory, and Memory's ownership check would 403 every
|
|
34
|
+
# legit call). Drives usageBoost in resources/scoring.ts, which
|
|
35
|
+
# replaces retrievalBoost in compositeScore (retrievalCount was the
|
|
36
|
+
# contaminated "retrieval counted as usage" signal root-caused in
|
|
37
|
+
# flair#623).
|
|
16
38
|
promotionStatus: String # null | "pending" | "approved" | "rejected"
|
|
17
39
|
promotedAt: String
|
|
18
40
|
promotedBy: String # authenticated agentId who approved
|
|
@@ -41,11 +63,26 @@ type Memory @table(database: "flair") {
|
|
|
41
63
|
# stamped by the ORIGINATING instance itself and preserved (never re-stamped) as the record flows through
|
|
42
64
|
# sync merges. @indexed for the later sync push-query filter (per-record signature verification and the
|
|
43
65
|
# classifier org-gate are separate, later slices — not built here).
|
|
66
|
+
entities: [String] @indexed # attention-plane vocabulary strings (flair#675). Added in v1 (not v2) per K&S
|
|
67
|
+
# verdict on FLAIR-ATTENTION-PLANE.md — gives the future attention query
|
|
68
|
+
# uniform index pushdown across Memory/WorkspaceState/OrgEvent instead of a
|
|
69
|
+
# partial one. Optional/derivable-on-write (no automatic tagging built here —
|
|
70
|
+
# that producer is a follow-up); additive/nullable, existing rows carry no
|
|
71
|
+
# entities, readers tolerate absent (same pattern as Presence.activityUpdatedAt).
|
|
72
|
+
# See docs/entity-vocabulary.md.
|
|
44
73
|
}
|
|
45
74
|
|
|
46
75
|
# Explicit entity-to-entity relationships with temporal validity.
|
|
47
76
|
# Enables queries like "who was team lead in Q1?" or "what changed about X on date Y?"
|
|
48
77
|
# Inspired by knowledge graph triples with time-bounded validity windows.
|
|
78
|
+
#
|
|
79
|
+
# attention-plane vocabulary note (flair#675): Relationship gets NO `entities`
|
|
80
|
+
# field. `subject`/`object` already hold free-form entity-reference strings
|
|
81
|
+
# and are already @indexed — they're the vocabulary carrier for this table.
|
|
82
|
+
# (Today they're lowercased on write in resources/Relationship.ts's put() but
|
|
83
|
+
# not yet validated against resources/entity-vocab.ts — wiring that
|
|
84
|
+
# validation, so `subject`/`object` are enforced attention-plane vocabulary
|
|
85
|
+
# strings, is a follow-up, not this foundation slice.)
|
|
49
86
|
type Relationship @table(database: "flair") {
|
|
50
87
|
id: ID @primaryKey
|
|
51
88
|
agentId: String! @indexed # who created this relationship
|
|
@@ -61,6 +98,13 @@ type Relationship @table(database: "flair") {
|
|
|
61
98
|
originatorInstanceId: String @indexed # federation-edge-hardening slice 1 — see Memory.originatorInstanceId's doc above
|
|
62
99
|
# for the full contract (write-time stamp, nullable, preserved across sync merges).
|
|
63
100
|
# Stamped server-side in resources/Relationship.ts's put().
|
|
101
|
+
provenance: String # JSON blob (relationship-write-path, folded K&S refinement): SAME shape as
|
|
102
|
+
# Memory.provenance above — { v, verified: { agentId, timestamp }, claimed?: { model } }
|
|
103
|
+
# — via the shared resources/provenance.ts buildProvenance(), never a
|
|
104
|
+
# Relationship-specific format. Nullable by design — pre-existing rows read back
|
|
105
|
+
# provenance = null, unchanged behavior (migration-equivalence gate, same
|
|
106
|
+
# discipline as Memory.usageCount/flair#684). Stamped server-side in
|
|
107
|
+
# resources/Relationship.ts's put(); never client-writable.
|
|
64
108
|
}
|
|
65
109
|
|
|
66
110
|
type Soul @table(database: "flair") {
|
|
@@ -87,6 +131,28 @@ type MemoryGrant @table(database: "flair") @export {
|
|
|
87
131
|
createdAt: String
|
|
88
132
|
}
|
|
89
133
|
|
|
134
|
+
# MemoryUsage — the dedup ledger for the usage-feedback signal (flair#683).
|
|
135
|
+
# One row per (agentId, memoryId) contribution: `id` is the deterministic
|
|
136
|
+
# composite key `${agentId}:${memoryId}`, so "has this agent already
|
|
137
|
+
# contributed to this memory's usageCount" is a single point-lookup, not a
|
|
138
|
+
# query. This is what makes the anti-gaming dedup rule ("each (agent, memory)
|
|
139
|
+
# pair contributes at most 1 to usageCount" — Sherlock) enforceable: the
|
|
140
|
+
# dedicated endpoint (resources/MemoryUsage.ts) only bumps Memory.usageCount
|
|
141
|
+
# when a NEW row is created here; a repeat call from the same agent for the
|
|
142
|
+
# same memory is a no-op against a row that already exists.
|
|
143
|
+
#
|
|
144
|
+
# `attribution` is OPAQUE — never parsed, never fed to an LLM, never rendered
|
|
145
|
+
# — a free-text "what used this" hint the caller supplies, sanitized
|
|
146
|
+
# (control-character-stripped, length-capped) and stored as-is for
|
|
147
|
+
# analytics/audit only. Never trust it as anything more than a label.
|
|
148
|
+
type MemoryUsage @table(database: "flair") {
|
|
149
|
+
id: ID @primaryKey # `${agentId}:${memoryId}` — the dedup key itself
|
|
150
|
+
agentId: String! @indexed # the contributing (verified) agent — never client-forgeable
|
|
151
|
+
memoryId: String! @indexed # the memory this contribution counted toward
|
|
152
|
+
attribution: String # opaque/sanitized free-text hint (optional)
|
|
153
|
+
createdAt: String!
|
|
154
|
+
}
|
|
155
|
+
|
|
90
156
|
# MemoryCandidate — staged distillations from the FLAIR-NIGHTLY-REM cycle.
|
|
91
157
|
# Per specs/FLAIR-NIGHTLY-REM.md § 7. Slice 1 of ops-2qq adds the schema +
|
|
92
158
|
# `flair rem candidates` listing command. Future slices wire the nightly
|
package/schemas/schema.graphql
CHANGED
|
@@ -3,48 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
type Presence @table(database: "flair") @export {
|
|
5
5
|
agentId: ID @primaryKey
|
|
6
|
-
lastHeartbeatAt: BigInt # unix ms timestamp
|
|
6
|
+
lastHeartbeatAt: BigInt # unix ms timestamp — pure liveness beacon; every heartbeat refreshes it
|
|
7
7
|
currentTask: String # short human string, e.g. "Reviewing flair#467"
|
|
8
|
-
activity: String @indexed # "coding" | "reviewing" | "planning" | "idle"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
type ObsOffice @table(database: "flair") @export {
|
|
14
|
-
id: ID @primaryKey # e.g. "office1"
|
|
15
|
-
name: String!
|
|
16
|
-
publicKey: String! # Ed25519 hex public key for auth verification
|
|
17
|
-
status: String @indexed # "online" | "offline" | "degraded"
|
|
18
|
-
lastSeen: String
|
|
19
|
-
agentCount: Int
|
|
20
|
-
createdAt: String!
|
|
21
|
-
updatedAt: String
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
type ObsAgentSnapshot @table(database: "flair") @export {
|
|
25
|
-
id: ID @primaryKey # "{officeId}:{agentId}"
|
|
26
|
-
officeId: String! @indexed
|
|
27
|
-
agentId: String! @indexed
|
|
28
|
-
name: String
|
|
29
|
-
role: String
|
|
30
|
-
type: String # "agent" | "human"
|
|
31
|
-
model: String
|
|
32
|
-
status: String @indexed # "active" | "idle" | "offline"
|
|
33
|
-
currentTask: String
|
|
34
|
-
lastActivity: String
|
|
35
|
-
lastHeartbeat: String
|
|
36
|
-
updatedAt: String!
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
type ObsEventFeed @table(database: "flair") @export {
|
|
40
|
-
id: ID @primaryKey # "{officeId}:{eventId}"
|
|
41
|
-
officeId: String! @indexed
|
|
42
|
-
kind: String! @indexed
|
|
43
|
-
authorId: String! @indexed
|
|
44
|
-
summary: String!
|
|
45
|
-
refId: String @indexed
|
|
46
|
-
scope: String @indexed
|
|
47
|
-
createdAt: String! @indexed
|
|
48
|
-
receivedAt: String!
|
|
49
|
-
expiresAt: String @indexed
|
|
8
|
+
activity: String @indexed # "coding" | "reviewing" | "planning" | "debugging" | "idle"
|
|
9
|
+
activityUpdatedAt: BigInt # unix ms — when activity/currentTask were last ASSERTED (natural-presence); nullable/additive, absent on records written before this feature (readers fall back to lastHeartbeatAt)
|
|
10
|
+
flairVersion: String # @tpsdev-ai/flair version serving this heartbeat (flair#639); absent on pre-#639 records
|
|
11
|
+
harperVersion: String # @harperfast/harper version serving this heartbeat (flair#639); absent on pre-#639 records
|
|
50
12
|
}
|
|
@@ -11,4 +11,7 @@ type WorkspaceState @table(database: "flair") @export {
|
|
|
11
11
|
summary: String
|
|
12
12
|
filesChanged: [String]
|
|
13
13
|
createdAt: String!
|
|
14
|
+
entities: [String] @indexed # attention-plane vocabulary strings (flair#675); additive/nullable —
|
|
15
|
+
# existing rows carry no entities, readers tolerate absent (same
|
|
16
|
+
# pattern as Presence.activityUpdatedAt). See docs/entity-vocabulary.md.
|
|
14
17
|
}
|