@tpsdev-ai/flair 0.20.1 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -11
- package/SECURITY.md +52 -20
- package/config.yaml +32 -5
- package/dist/cli.js +2235 -260
- package/dist/deploy.js +96 -15
- package/dist/doctor-client.js +312 -0
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/install/clients.js +18 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/restore.js +1 -1
- package/dist/rem/runner.js +82 -12
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +2 -2
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +14 -0
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +121 -2
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +127 -22
- package/dist/resources/MemoryBootstrap.js +441 -85
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +69 -5
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/Presence.js +268 -17
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +108 -26
- package/dist/resources/SemanticSearch.js +88 -320
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/Soul.js +14 -0
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +12 -1
- package/dist/resources/agent-auth.js +61 -3
- package/dist/resources/agentcard-fields.js +2 -2
- package/dist/resources/auth-middleware.js +42 -14
- package/dist/resources/bm25-filter.js +1 -1
- package/dist/resources/bm25.js +13 -7
- package/dist/resources/collision-lib.js +157 -0
- package/dist/resources/dedup.js +2 -2
- package/dist/resources/ed25519-auth.js +2 -2
- 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/federation-nonce-store.js +195 -0
- package/dist/resources/health.js +97 -6
- package/dist/resources/instance-identity.js +53 -0
- 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-bootstrap-lib.js +1 -1
- package/dist/resources/memory-read-scope.js +58 -71
- package/dist/resources/memory-reflect-lib.js +289 -0
- package/dist/resources/memory-visibility.js +37 -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 +456 -0
- package/dist/resources/migrations/snapshot.js +127 -0
- package/dist/resources/migrations/source-fields.js +93 -0
- package/dist/resources/migrations/space.js +73 -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-check.js +167 -0
- package/dist/version-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/agent.graphql +7 -0
- package/schemas/event.graphql +5 -0
- package/schemas/federation.graphql +12 -0
- package/schemas/memory.graphql +82 -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,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* version-check.ts — offline-tolerant, cached check of whether the installed
|
|
3
|
+
* @tpsdev-ai/flair is behind the latest published npm release.
|
|
4
|
+
*
|
|
5
|
+
* Motivation (flair#587): a laptop install sat on 0.16.1 through v0.17.0 and
|
|
6
|
+
* v0.19.0 (P0 security fixes) and v0.18.0 (memory-integrity fix) — `flair
|
|
7
|
+
* status` reported "✓ all checks passing" the whole time. Nothing anywhere
|
|
8
|
+
* in the CLI told the operator they were behind. `flair status` and `flair
|
|
9
|
+
* doctor` both wire this in.
|
|
10
|
+
*
|
|
11
|
+
* Non-negotiable design constraints — this must never make status/doctor
|
|
12
|
+
* WORSE:
|
|
13
|
+
* - Offline-tolerant: a failed/timed-out registry fetch falls back to a
|
|
14
|
+
* stale cache, or is skipped entirely. NEVER throws, NEVER hangs (short
|
|
15
|
+
* fetch timeout).
|
|
16
|
+
* - Cached with a TTL so a healthy network doesn't cost a registry round
|
|
17
|
+
* trip on every single `flair status`/`flair doctor` invocation.
|
|
18
|
+
* - No advisory data — we don't know which release fixed which CVE, so the
|
|
19
|
+
* severity heuristic is purely the version GAP (major/minor count), not
|
|
20
|
+
* "did this release carry a security fix". See classifyGap().
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { parseSemverCore } from "./fabric-upgrade.js";
|
|
26
|
+
export const FLAIR_PKG_NAME = "@tpsdev-ai/flair";
|
|
27
|
+
export const DEFAULT_CACHE_PATH = join(homedir(), ".flair", ".version-check-cache.json");
|
|
28
|
+
/** How long a cached "latest" answer is trusted before we re-hit the registry. */
|
|
29
|
+
export const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000; // 12h
|
|
30
|
+
/** Registry fetch timeout — this runs on every status/doctor call, so it must stay short. */
|
|
31
|
+
export const DEFAULT_TIMEOUT_MS = 3000;
|
|
32
|
+
function readCacheFile(path) {
|
|
33
|
+
try {
|
|
34
|
+
if (!existsSync(path))
|
|
35
|
+
return null;
|
|
36
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
37
|
+
if (typeof raw?.latest === "string" && typeof raw?.checkedAt === "number") {
|
|
38
|
+
return { latest: raw.latest, checkedAt: raw.checkedAt };
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Corrupt/unreadable cache — treat as absent, never throw.
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function writeCacheFile(path, entry) {
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
50
|
+
writeFileSync(path, JSON.stringify(entry), "utf-8");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Best-effort — a cache-write failure must never surface as a
|
|
54
|
+
// status/doctor error (e.g. read-only $HOME).
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function defaultFetchLatest(timeoutMs) {
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(`https://registry.npmjs.org/${FLAIR_PKG_NAME}/latest`, {
|
|
60
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok)
|
|
63
|
+
return null;
|
|
64
|
+
const data = (await res.json());
|
|
65
|
+
return typeof data?.version === "string" ? data.version : null;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Offline, DNS failure, timeout, registry 5xx, bad JSON — all the same:
|
|
69
|
+
// we couldn't determine "latest" over the network this time.
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function defaultVersionCheckDeps() {
|
|
74
|
+
return {
|
|
75
|
+
fetchLatest: defaultFetchLatest,
|
|
76
|
+
cachePath: DEFAULT_CACHE_PATH,
|
|
77
|
+
ttlMs: DEFAULT_TTL_MS,
|
|
78
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
79
|
+
now: () => Date.now(),
|
|
80
|
+
readCache: readCacheFile,
|
|
81
|
+
writeCache: writeCacheFile,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the latest published @tpsdev-ai/flair version, preferring a fresh
|
|
86
|
+
* cache hit over a network round trip, and falling back to a stale cache (or
|
|
87
|
+
* giving up quietly) when the registry is unreachable. NEVER throws.
|
|
88
|
+
*/
|
|
89
|
+
export async function checkVersion(installed, injected = {}) {
|
|
90
|
+
const deps = { ...defaultVersionCheckDeps(), ...injected };
|
|
91
|
+
const nowMs = deps.now();
|
|
92
|
+
const cached = deps.readCache(deps.cachePath);
|
|
93
|
+
if (cached && nowMs - cached.checkedAt < deps.ttlMs) {
|
|
94
|
+
return { installed, latest: cached.latest, source: "cache" };
|
|
95
|
+
}
|
|
96
|
+
// Defense-in-depth: the default fetchLatest already catches everything
|
|
97
|
+
// internally (network error, timeout, non-2xx, bad JSON) and resolves
|
|
98
|
+
// null rather than rejecting. This try/catch guards the contract even if
|
|
99
|
+
// a caller-injected fetchLatest misbehaves and throws/rejects instead —
|
|
100
|
+
// status/doctor must never crash or hang on a version check either way.
|
|
101
|
+
let fetched = null;
|
|
102
|
+
try {
|
|
103
|
+
fetched = await deps.fetchLatest(deps.timeoutMs);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
fetched = null;
|
|
107
|
+
}
|
|
108
|
+
if (fetched) {
|
|
109
|
+
deps.writeCache(deps.cachePath, { latest: fetched, checkedAt: nowMs });
|
|
110
|
+
return { installed, latest: fetched, source: "network" };
|
|
111
|
+
}
|
|
112
|
+
// Registry unreachable/timed out — fall back to a stale cache rather than
|
|
113
|
+
// reporting nothing, but never block or throw trying to get a fresh one.
|
|
114
|
+
if (cached) {
|
|
115
|
+
return { installed, latest: cached.latest, source: "cache" };
|
|
116
|
+
}
|
|
117
|
+
return { installed, latest: null, source: "unavailable" };
|
|
118
|
+
}
|
|
119
|
+
const NO_GAP = { severity: "none", majorBehind: false, releasesBehind: 0 };
|
|
120
|
+
/**
|
|
121
|
+
* Classify how far `installed` is behind `latest` using major.minor.patch
|
|
122
|
+
* math only — we don't have advisory data, so:
|
|
123
|
+
* - any major version behind, or ≥2 minor versions behind → "red" (loud;
|
|
124
|
+
* heuristic for "you've likely missed a security fix")
|
|
125
|
+
* - a single minor version behind, or a patch-only gap → "yellow"
|
|
126
|
+
* - equal, ahead, or unparseable → "none"
|
|
127
|
+
*/
|
|
128
|
+
export function classifyGap(installed, latest) {
|
|
129
|
+
const a = parseSemverCore(installed);
|
|
130
|
+
const b = parseSemverCore(latest);
|
|
131
|
+
if (!a || !b)
|
|
132
|
+
return NO_GAP;
|
|
133
|
+
const [aMaj, aMin, aPatch] = a;
|
|
134
|
+
const [bMaj, bMin, bPatch] = b;
|
|
135
|
+
if (bMaj > aMaj)
|
|
136
|
+
return { severity: "red", majorBehind: true, releasesBehind: 0 };
|
|
137
|
+
if (bMaj < aMaj)
|
|
138
|
+
return NO_GAP; // installed is ahead (e.g. local/pre-release build)
|
|
139
|
+
if (bMin > aMin) {
|
|
140
|
+
const releasesBehind = bMin - aMin;
|
|
141
|
+
return { severity: releasesBehind >= 2 ? "red" : "yellow", majorBehind: false, releasesBehind };
|
|
142
|
+
}
|
|
143
|
+
if (bMin < aMin)
|
|
144
|
+
return NO_GAP; // ahead on minor
|
|
145
|
+
if (bPatch > aPatch)
|
|
146
|
+
return { severity: "yellow", majorBehind: false, releasesBehind: bPatch - aPatch };
|
|
147
|
+
return NO_GAP; // equal, or ahead on patch
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Build the human-readable nudge line for `flair status`/`flair doctor`, or
|
|
151
|
+
* null when there's nothing worth printing — current, ahead (local/dev
|
|
152
|
+
* build), or we couldn't determine latest at all (offline with no cache).
|
|
153
|
+
* Callers own icon/color; this returns plain text plus a severity to color by.
|
|
154
|
+
*/
|
|
155
|
+
export function formatVersionNudge(result) {
|
|
156
|
+
if (!result.latest)
|
|
157
|
+
return null;
|
|
158
|
+
const gap = classifyGap(result.installed, result.latest);
|
|
159
|
+
if (gap.severity === "none")
|
|
160
|
+
return null;
|
|
161
|
+
const countHint = gap.majorBehind
|
|
162
|
+
? "major version"
|
|
163
|
+
: `${gap.releasesBehind} release${gap.releasesBehind === 1 ? "" : "s"}`;
|
|
164
|
+
const message = `flair ${result.installed} is behind — latest is ${result.latest} (${countHint} behind). ` +
|
|
165
|
+
`Upgrade: npm i -g ${FLAIR_PKG_NAME}@latest`;
|
|
166
|
+
return { severity: gap.severity, message };
|
|
167
|
+
}
|
|
@@ -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.0",
|
|
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/agent.graphql
CHANGED
|
@@ -22,6 +22,13 @@ type Agent @table(database: "flair") @export {
|
|
|
22
22
|
|
|
23
23
|
createdAt: String!
|
|
24
24
|
updatedAt: String
|
|
25
|
+
originatorInstanceId: String @indexed # federation-edge-hardening slice 1 — write-time instance identity,
|
|
26
|
+
# stamped server-side in resources/Agent.ts's post()/put() from
|
|
27
|
+
# resources/instance-identity.ts's localInstanceId(). Nullable (existing rows
|
|
28
|
+
# read null = clean upgrade); preserved (never re-stamped) as the record flows
|
|
29
|
+
# through federation sync merges. See schemas/memory.graphql's Memory.
|
|
30
|
+
# originatorInstanceId for the full contract — same field, same idiom, this is
|
|
31
|
+
# one of the 4 synced tables (Memory/Soul/Agent/Relationship).
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
# Credential table — auth surfaces for Principals.
|
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
|
}
|