@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,88 @@
|
|
|
1
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { mcpIssuer } from "./mcp-oauth-flag.js";
|
|
3
|
+
import { agentPublicKeyToJwk, buildCimdDocument } from "./mcp-client-metadata-fields.js";
|
|
4
|
+
/**
|
|
5
|
+
* MCPClientMetadata — serves a Client ID Metadata Document (CIMD) for a
|
|
6
|
+
* Flair agent at `GET /MCPClientMetadata/{agentId}`.
|
|
7
|
+
*
|
|
8
|
+
* This is the "publish" half of RFC 7523 private_key_jwt / client_credentials
|
|
9
|
+
* agent-auth (see docs/notes/mcp-agent-auth-consumer.md and
|
|
10
|
+
* HarperFast/oauth#159, whose full chain is now shipped in the published
|
|
11
|
+
* @harperfast/oauth@2.2.0: assertion verification #160/PR #165, CIMD-first
|
|
12
|
+
* client resolution #161/#167, token-endpoint grant + issuance rate
|
|
13
|
+
* limiting #162/#163 via PRs #170/#171). An authorization server that
|
|
14
|
+
* treats an agent's `client_id` as this URL fetches this document to learn
|
|
15
|
+
* the agent's JWKS (its EXISTING Ed25519 identity key) instead of doing a
|
|
16
|
+
* DCR registration — no registration state to replicate across Fabric
|
|
17
|
+
* nodes, matching Flair's stateless posture.
|
|
18
|
+
*
|
|
19
|
+
* Public, unauthenticated — mirrors AgentCard.ts. CIMD documents are meant
|
|
20
|
+
* to be fetched by an AS with no prior trust relationship (same posture as
|
|
21
|
+
* an A2A agent card / OIDC client metadata); this document exposes ONLY the
|
|
22
|
+
* agent's already-public identity key (the same value a verified caller
|
|
23
|
+
* already reads via `GET /Agent/{id}`), never anything secret.
|
|
24
|
+
*
|
|
25
|
+
* The document served here is proven against the REAL published plugin:
|
|
26
|
+
* test/unit/mcp-client-credentials-live-package.test.ts feeds it through
|
|
27
|
+
* 2.2.0's actual `resolveCimdClient` fetch+validate pipeline, and
|
|
28
|
+
* test/integration/mcp-client-credentials-e2e.test.ts serves it from a live
|
|
29
|
+
* spawned Harper. Note the plugin's CIMD fetch enforces an UNCONDITIONAL
|
|
30
|
+
* SSRF gate (https only, no private/loopback/link-local DNS answers, no
|
|
31
|
+
* override knob) — so an AS can only ever consume this document when it is
|
|
32
|
+
* served from a genuinely public HTTPS host. See the doc note's
|
|
33
|
+
* "SSRF/loopback boundary" section.
|
|
34
|
+
*
|
|
35
|
+
* Deployment coordination: the AS's `clientIdMetadataDocuments.allowedHosts`
|
|
36
|
+
* config MUST include this route's host (derived from `FLAIR_MCP_ISSUER` /
|
|
37
|
+
* `FLAIR_PUBLIC_URL`, same env vars as `mcpIssuer()` below) — merely being
|
|
38
|
+
* reachable is not enough; the allowlist gate is AS-side config and
|
|
39
|
+
* fail-closed. See docs/notes/mcp-agent-auth-consumer.md.
|
|
40
|
+
*/
|
|
41
|
+
export class MCPClientMetadata extends Resource {
|
|
42
|
+
allowRead() {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
async get(pathInfo) {
|
|
46
|
+
const agentId = (typeof pathInfo === "string" ? pathInfo : null) ??
|
|
47
|
+
this.getId?.() ??
|
|
48
|
+
null;
|
|
49
|
+
if (!agentId) {
|
|
50
|
+
return new Response(JSON.stringify({ error: "agentId required in path: GET /MCPClientMetadata/{agentId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
51
|
+
}
|
|
52
|
+
const issuer = mcpIssuer();
|
|
53
|
+
if (!issuer) {
|
|
54
|
+
// Fail closed rather than guess a client_id URL — CIMD requires the
|
|
55
|
+
// document's client_id to equal the URL it was fetched from, byte for
|
|
56
|
+
// byte, so a floating/unpinned origin would produce a document that
|
|
57
|
+
// never validates.
|
|
58
|
+
return new Response(JSON.stringify({
|
|
59
|
+
error: "mcp_issuer_not_configured",
|
|
60
|
+
message: "FLAIR_MCP_ISSUER (or FLAIR_PUBLIC_URL) must be set to publish a stable client_id URL.",
|
|
61
|
+
}), { status: 501, headers: { "Content-Type": "application/json" } });
|
|
62
|
+
}
|
|
63
|
+
const agent = await databases.flair.Agent.get(agentId).catch(() => null);
|
|
64
|
+
if (!agent?.publicKey) {
|
|
65
|
+
return new Response(JSON.stringify({ error: "agent_not_found_or_no_key", agentId }), {
|
|
66
|
+
status: 404,
|
|
67
|
+
headers: { "Content-Type": "application/json" },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
let jwk;
|
|
71
|
+
try {
|
|
72
|
+
jwk = agentPublicKeyToJwk(String(agent.publicKey), agentId);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
return new Response(JSON.stringify({ error: "invalid_agent_key", message: err?.message ?? String(err) }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
76
|
+
}
|
|
77
|
+
const clientId = `${issuer.replace(/\/+$/, "")}/MCPClientMetadata/${agentId}`;
|
|
78
|
+
const doc = buildCimdDocument({
|
|
79
|
+
clientId,
|
|
80
|
+
clientName: String(agent.name ?? agent.displayName ?? agentId),
|
|
81
|
+
jwk,
|
|
82
|
+
});
|
|
83
|
+
return new Response(JSON.stringify(doc), {
|
|
84
|
+
status: 200,
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
package/dist/resources/Memory.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
2
|
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
3
3
|
import { isAdmin, resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
4
|
+
import { localInstanceId } from "./instance-identity.js";
|
|
4
5
|
import { getEmbedding, getModelId } from "./embeddings-provider.js";
|
|
5
6
|
import { scanFields, isStrictMode } from "./content-safety.js";
|
|
7
|
+
import { invalidEntitiesResponse } from "./entity-vocab.js";
|
|
6
8
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
7
9
|
import { resolveReadScope } from "./memory-read-scope.js";
|
|
8
10
|
import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
|
|
11
|
+
import { buildProvenance } from "./provenance.js";
|
|
9
12
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
10
13
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
11
14
|
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
@@ -15,7 +18,7 @@ const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { s
|
|
|
15
18
|
* live in ./memory-read-scope.ts — the ONE centralized helper every
|
|
16
19
|
* cross-agent Memory read path (search()/get() here, SemanticSearch.ts,
|
|
17
20
|
* MemoryBootstrap.ts, auth-middleware.ts's by-id guard) resolves its scope
|
|
18
|
-
* through, so the scoping rule cannot drift per-path again (
|
|
21
|
+
* through, so the scoping rule cannot drift per-path again (a
|
|
19
22
|
* SemanticSearch inline `visibility === "office"` OR-clause leaked office
|
|
20
23
|
* memories to any authenticated agent because the rule had scattered). See
|
|
21
24
|
* that module's doc for the migration invariant (no-visibility-field reads
|
|
@@ -86,7 +89,7 @@ async function findConservativeDedupMatch(ctx, agentId, contentText, embedding,
|
|
|
86
89
|
}
|
|
87
90
|
if (!top)
|
|
88
91
|
return null;
|
|
89
|
-
// ───
|
|
92
|
+
// ─── Harper's cosine-sort query omits $distance for a SINGLETON
|
|
90
93
|
// result set ─────────────────────────────────────────────────────────────
|
|
91
94
|
// Initial working theory was a per-agentId HNSW "cold-start" (first-ever
|
|
92
95
|
// query cold, second query warm) and the initially-recommended fix was a
|
|
@@ -133,7 +136,7 @@ async function findConservativeDedupMatch(ctx, agentId, contentText, embedding,
|
|
|
133
136
|
cosine = 1 - top.$distance;
|
|
134
137
|
}
|
|
135
138
|
else {
|
|
136
|
-
console.error("Memory.findConservativeDedupMatch: $distance undefined on a singleton cosine result
|
|
139
|
+
console.error("Memory.findConservativeDedupMatch: $distance undefined on a singleton cosine result — " +
|
|
137
140
|
"falling back to a manual cosine computation from the candidate's stored embedding", { agentId, candidateId: top.id });
|
|
138
141
|
const fullCandidate = await withDetachedTxn(ctx, () => databases.flair.Memory.get(top.id));
|
|
139
142
|
const candidateEmbedding = Array.isArray(fullCandidate?.embedding) ? fullCandidate.embedding : [];
|
|
@@ -170,10 +173,29 @@ async function runDedupGate(ctx, content) {
|
|
|
170
173
|
if (typeof content.content !== "string" || content.content.length < DEDUP_MIN_CONTENT_LENGTH) {
|
|
171
174
|
return null;
|
|
172
175
|
}
|
|
176
|
+
// flair#504 Phase 2: 'document' — this embedding IS the stored vector (the
|
|
177
|
+
// "generate embedding if missing" step in post()/put() below reuses
|
|
178
|
+
// whatever this computes), so it MUST use the same inputType as every
|
|
179
|
+
// other document-write site or dedup's cosine compare would cross prefixed
|
|
180
|
+
// and unprefixed spaces. All three Memory doc sites (here, post(), put())
|
|
181
|
+
// move together in one commit for exactly that reason — see
|
|
182
|
+
// embeddings-provider.ts's file header for why the VALUE must be the
|
|
183
|
+
// literal 'document', never the prefix string.
|
|
184
|
+
//
|
|
185
|
+
// Dedup-during-transition transient (documented per Kern's review, not a
|
|
186
|
+
// bug to fix here): mid stage-2 re-embed, a NEW write embeds 'document'
|
|
187
|
+
// (prefixed) but may compare against an OLDER stored vector that hasn't
|
|
188
|
+
// been re-embedded yet (unprefixed) — cross-space cosine, so dedup can
|
|
189
|
+
// miss a near-duplicate during that window. Bounded (the re-embed pass is
|
|
190
|
+
// batched and finishes in minutes), self-healing once the pass completes,
|
|
191
|
+
// and a missed dedup is a duplicate row, not data loss — quality, not
|
|
192
|
+
// correctness. Stage 1 (this PR) doesn't trigger this at all: no re-embed
|
|
193
|
+
// runs, so there's no mixed-space window until stage 2's separate,
|
|
194
|
+
// deliberate ops step.
|
|
173
195
|
let embedding = Array.isArray(content.embedding) ? content.embedding : null;
|
|
174
196
|
if (!embedding) {
|
|
175
197
|
try {
|
|
176
|
-
embedding = await getEmbedding(content.content);
|
|
198
|
+
embedding = await getEmbedding(content.content, "document");
|
|
177
199
|
}
|
|
178
200
|
catch {
|
|
179
201
|
embedding = null;
|
|
@@ -209,7 +231,7 @@ function buildWriteResponse(content, result, dedupMatch) {
|
|
|
209
231
|
* detachment discipline as findConservativeDedupMatch (each discrete Harper
|
|
210
232
|
* call individually wrapped — see withDetachedTxn's doc for why a single
|
|
211
233
|
* wrap around a multi-await async function would not protect the later
|
|
212
|
-
* call). Does NOT swallow failures
|
|
234
|
+
* call). Does NOT swallow failures — throws so the caller can
|
|
213
235
|
* log it. Never called before the new record is already written.
|
|
214
236
|
*/
|
|
215
237
|
async function closeSupersededRecord(ctx, oldId, patch) {
|
|
@@ -273,7 +295,7 @@ async function validateAndAuthorizeSupersedes(content, auth) {
|
|
|
273
295
|
}
|
|
274
296
|
/**
|
|
275
297
|
* Close the superseded record — called AFTER the new record has already been
|
|
276
|
-
* written (write-new-BEFORE-close-old
|
|
298
|
+
* written (write-new-BEFORE-close-old). Safe failure state is
|
|
277
299
|
* two active records (recoverable), never a tombstoned-old-with-lost-new.
|
|
278
300
|
* Failure is logged (observable), never silently swallowed. No-op if
|
|
279
301
|
* `content.supersedes` is not set.
|
|
@@ -293,11 +315,11 @@ async function closeSupersededIfNeeded(ctx, content, methodLabel) {
|
|
|
293
315
|
// `err` arg) would let an id containing %s/%o consume/hide the real error
|
|
294
316
|
// (semgrep unsafe-formatstring). Keep all dynamic values in the data object.
|
|
295
317
|
console.error("Memory.closeSuperseded: failed to close superseded record after writing new record " +
|
|
296
|
-
"(
|
|
318
|
+
"(observable, not silent; new record is safely written, old record remains active until retried)", { method: methodLabel, supersededId: content.supersedes, newRecordId: content.id, err });
|
|
297
319
|
}
|
|
298
320
|
}
|
|
299
321
|
/**
|
|
300
|
-
* ─── Durability-keyed default visibility (
|
|
322
|
+
* ─── Durability-keyed default visibility (Layer 1, part A) ─────────────────
|
|
301
323
|
*
|
|
302
324
|
* Writer intent: an explicit `visibility` on the write ALWAYS overrides this
|
|
303
325
|
* (callers check `content.visibility == null` before calling this). When
|
|
@@ -310,6 +332,51 @@ async function closeSupersededIfNeeded(ctx, content, methodLabel) {
|
|
|
310
332
|
function defaultVisibilityForDurability(durability) {
|
|
311
333
|
return durability === "permanent" || durability === "persistent" ? "shared" : "private";
|
|
312
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* ─── Write-time provenance stamp (memory-provenance slice 1) ────────────────
|
|
337
|
+
*
|
|
338
|
+
* `buildProvenance` itself now lives in ./provenance.ts (imported above) —
|
|
339
|
+
* extracted so resources/Relationship.ts's write path can reuse the EXACT
|
|
340
|
+
* same `{v, verified, claimed?}` shape (the relationship-write-path spec's
|
|
341
|
+
* "reuse buildProvenance as-is" contract) instead of a hand-copied format
|
|
342
|
+
* that could drift. See that module for the full field-by-field rationale
|
|
343
|
+
* (verified.agentId from the auth verdict never the body, verified.timestamp
|
|
344
|
+
* = the server-computed createdAt, optional unverified claimed.model
|
|
345
|
+
* passthrough). Deliberately NOT implemented in this slice: a
|
|
346
|
+
* context-fingerprint field — bootstrap doesn't return the IDs a fingerprint
|
|
347
|
+
* would need, so it requires client cooperation that's out of scope here.
|
|
348
|
+
*/
|
|
349
|
+
/**
|
|
350
|
+
* ─── Write-time originatorInstanceId stamp (federation-edge-hardening slice 1) ──
|
|
351
|
+
*
|
|
352
|
+
* Stamps this instance's own federation identity (resources/instance-
|
|
353
|
+
* identity.ts's localInstanceId(), cached — never a DB read per write) onto
|
|
354
|
+
* every LOCAL write. Deliberately a no-op when `content.originatorInstanceId`
|
|
355
|
+
* already carries a non-null value: this is the anti-clobber rule that keeps
|
|
356
|
+
* a federation-synced record's true origin intact.
|
|
357
|
+
*
|
|
358
|
+
* Why this can never clobber a synced record: FederationSync.post()
|
|
359
|
+
* (resources/Federation.ts) merges incoming records via the RAW table object
|
|
360
|
+
* (`(databases as any).flair.Memory.put(mergedData)`) — Harper's static
|
|
361
|
+
* table-level put, not this Resource subclass's instance put() below. The
|
|
362
|
+
* merge path never runs this function at all, so a record arriving from
|
|
363
|
+
* instance B keeps whatever `originatorInstanceId` it already carried in
|
|
364
|
+
* `mergedData` (that instance's own write-time stamp, carried through in the
|
|
365
|
+
* synced row) with no risk of this instance overwriting it with its own id.
|
|
366
|
+
* The `content.originatorInstanceId == null` guard below is still applied —
|
|
367
|
+
* defense-in-depth for any future path that might route a synced payload
|
|
368
|
+
* through this class's post()/put() — so the invariant holds even if that
|
|
369
|
+
* assumption ever changes.
|
|
370
|
+
*
|
|
371
|
+
* `localInstanceId()` resolves to null on an instance that has never been
|
|
372
|
+
* federation-bootstrapped (no Instance row yet) — the field is nullable by
|
|
373
|
+
* design, so this stamps null rather than inventing an id.
|
|
374
|
+
*/
|
|
375
|
+
async function stampOriginatorInstanceId(content) {
|
|
376
|
+
if (content.originatorInstanceId == null) {
|
|
377
|
+
content.originatorInstanceId = await localInstanceId();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
313
380
|
export class Memory extends databases.flair.Memory {
|
|
314
381
|
/**
|
|
315
382
|
* Self-authorize now that the global gate is non-rejecting. Closes the P0
|
|
@@ -360,8 +427,8 @@ export class Memory extends databases.flair.Memory {
|
|
|
360
427
|
return super.get(target);
|
|
361
428
|
}
|
|
362
429
|
// Non-admin agent: only its own memories (any visibility), or a granted
|
|
363
|
-
// owner's SHARED memories — never that owner's private ones (
|
|
364
|
-
//
|
|
430
|
+
// owner's SHARED memories — never that owner's private ones (Layer 1
|
|
431
|
+
// private-exclusion). Centralized in resolveReadScope() so this
|
|
365
432
|
// and search() below cannot drift.
|
|
366
433
|
const record = await super.get(target);
|
|
367
434
|
if (!record)
|
|
@@ -397,7 +464,7 @@ export class Memory extends databases.flair.Memory {
|
|
|
397
464
|
return super.search(query);
|
|
398
465
|
}
|
|
399
466
|
// Non-admin agent: scope to own (any visibility) + granted owners' SHARED
|
|
400
|
-
// memories only (
|
|
467
|
+
// memories only (Layer 1 private-exclusion). Centralized in
|
|
401
468
|
// resolveReadScope() so get() above and search() here cannot drift.
|
|
402
469
|
const authAgent = auth.agentId;
|
|
403
470
|
const scope = await resolveReadScope(authAgent);
|
|
@@ -450,7 +517,7 @@ export class Memory extends databases.flair.Memory {
|
|
|
450
517
|
content.createdAt = new Date().toISOString();
|
|
451
518
|
content.updatedAt = content.createdAt;
|
|
452
519
|
content.archived = content.archived ?? false;
|
|
453
|
-
// ─── Default visibility (durability-keyed) —
|
|
520
|
+
// ─── Default visibility (durability-keyed) — Layer 1, part A ────────────
|
|
454
521
|
// post() only ever creates a NEW record — patchRecord/supersede-close/
|
|
455
522
|
// retrievalCount bumps all route through put() instead (see put()'s
|
|
456
523
|
// pre-existing-record guard below), so there is no "don't overwrite an
|
|
@@ -483,12 +550,19 @@ export class Memory extends databases.flair.Memory {
|
|
|
483
550
|
if (!content.validFrom) {
|
|
484
551
|
content.validFrom = content.createdAt;
|
|
485
552
|
}
|
|
553
|
+
// attention-plane vocabulary gate (flair#675): `entities`, if present,
|
|
554
|
+
// must be well-formed vocabulary strings — see resources/entity-vocab.ts.
|
|
555
|
+
// Field is additive/optional (v1 schema-only; no auto-derivation here —
|
|
556
|
+
// that producer is a follow-up); absent entities is not an error.
|
|
557
|
+
const entitiesError = invalidEntitiesResponse(content.entities);
|
|
558
|
+
if (entitiesError)
|
|
559
|
+
return entitiesError;
|
|
486
560
|
if (content.durability === "ephemeral" && !content.expiresAt) {
|
|
487
561
|
const ttlHours = Number(process.env.FLAIR_EPHEMERAL_TTL_HOURS || 24);
|
|
488
562
|
content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
|
|
489
563
|
}
|
|
490
564
|
// Content safety scan — covers content + summary (defense-in-depth for
|
|
491
|
-
// agent-set summaries
|
|
565
|
+
// agent-set summaries).
|
|
492
566
|
if (content.content || content.summary) {
|
|
493
567
|
const safety = scanFields(content, ["content", "summary"]);
|
|
494
568
|
if (!safety.safe) {
|
|
@@ -517,17 +591,27 @@ export class Memory extends databases.flair.Memory {
|
|
|
517
591
|
delete content.lexicalThreshold;
|
|
518
592
|
}
|
|
519
593
|
// Generate embedding from content text (no-op if the dedup gate above
|
|
520
|
-
// already computed one for this content).
|
|
594
|
+
// already computed one for this content). flair#504 Phase 2: 'document'
|
|
595
|
+
// — see runDedupGate's comment above for why all three Memory doc sites
|
|
596
|
+
// must move together.
|
|
521
597
|
if (content.content && !content.embedding) {
|
|
522
|
-
const vec = await getEmbedding(content.content);
|
|
598
|
+
const vec = await getEmbedding(content.content, "document");
|
|
523
599
|
if (vec) {
|
|
524
600
|
content.embedding = vec;
|
|
525
601
|
content.embeddingModel = getModelId();
|
|
526
602
|
}
|
|
527
603
|
}
|
|
604
|
+
// Write-time provenance stamp (memory-provenance slice 1) — see
|
|
605
|
+
// buildProvenance's doc above. Stamped last, right before persist, so it
|
|
606
|
+
// reflects the final resolved `content.createdAt`.
|
|
607
|
+
content.provenance = buildProvenance(auth, content.createdAt, content);
|
|
608
|
+
// Write-time originatorInstanceId stamp (federation-edge-hardening slice
|
|
609
|
+
// 1) — see stampOriginatorInstanceId's doc above. No-op if already set
|
|
610
|
+
// (never fires for a genuine local write — no client sets this field).
|
|
611
|
+
await stampOriginatorInstanceId(content);
|
|
528
612
|
// ── Write the new record FIRST ──────────────────────────────────────────
|
|
529
613
|
const result = await super.post(content);
|
|
530
|
-
// ── THEN close the superseded record
|
|
614
|
+
// ── THEN close the superseded record ────────────────────────────────────
|
|
531
615
|
// Write-new-BEFORE-close-old: the previous order (close-old via a fire-
|
|
532
616
|
// and-forget `.catch(()=>{})` BEFORE the new write) could tombstone the
|
|
533
617
|
// old record and then lose the new one if the write failed afterward.
|
|
@@ -582,7 +666,7 @@ export class Memory extends databases.flair.Memory {
|
|
|
582
666
|
// visibility stamped) or an update/patch (dedup-bypassed, visibility left
|
|
583
667
|
// untouched). See the dedup-gate block further down for why an existing
|
|
584
668
|
// id skips the gate; the SAME "does a record already exist" check gates
|
|
585
|
-
// the visibility default (
|
|
669
|
+
// the visibility default (Layer 1 part A): patchRecord/supersede-
|
|
586
670
|
// close/retrievalCount bumps all route through put() with a MERGED
|
|
587
671
|
// `{...existing, ...patch}` payload, and must never have their stored
|
|
588
672
|
// visibility overwritten by a default recomputed from that merged content
|
|
@@ -590,7 +674,7 @@ export class Memory extends databases.flair.Memory {
|
|
|
590
674
|
const preExisting = content.id
|
|
591
675
|
? await databases.flair.Memory.get(content.id).catch(() => null)
|
|
592
676
|
: null;
|
|
593
|
-
// ─── Default visibility (durability-keyed) —
|
|
677
|
+
// ─── Default visibility (durability-keyed) — Layer 1, part A ────────────
|
|
594
678
|
// Explicit visibility on the write ALWAYS overrides; only stamp the
|
|
595
679
|
// default when the caller left it unset AND this is a fresh record.
|
|
596
680
|
// permanent|persistent → shared; standard|ephemeral|absent → private.
|
|
@@ -607,7 +691,11 @@ export class Memory extends databases.flair.Memory {
|
|
|
607
691
|
if (content.supersedes && !content.validFrom) {
|
|
608
692
|
content.validFrom = content.createdAt;
|
|
609
693
|
}
|
|
610
|
-
//
|
|
694
|
+
// attention-plane vocabulary gate (flair#675) — see post()'s comment above.
|
|
695
|
+
const entitiesError = invalidEntitiesResponse(content.entities);
|
|
696
|
+
if (entitiesError)
|
|
697
|
+
return entitiesError;
|
|
698
|
+
// Content safety scan on updated content + summary.
|
|
611
699
|
if (content.content || content.summary) {
|
|
612
700
|
const safety = scanFields(content, ["content", "summary"]);
|
|
613
701
|
if (!safety.safe) {
|
|
@@ -653,9 +741,12 @@ export class Memory extends databases.flair.Memory {
|
|
|
653
741
|
dedupMatch = await runDedupGate(ctx, content);
|
|
654
742
|
}
|
|
655
743
|
// Re-generate embedding if content changed (no-op if the dedup gate above
|
|
656
|
-
// already computed one for this content).
|
|
744
|
+
// already computed one for this content). flair#504 Phase 2: 'document'
|
|
745
|
+
// — this is also the regen branch `flair reembed` triggers (clears
|
|
746
|
+
// embedding/embeddingModel then hits this put()), so it's what actually
|
|
747
|
+
// re-embeds a stale row WITH the prefix once stage 2 runs.
|
|
657
748
|
if (content.content && !content.embedding) {
|
|
658
|
-
const vec = await getEmbedding(content.content);
|
|
749
|
+
const vec = await getEmbedding(content.content, "document");
|
|
659
750
|
if (vec) {
|
|
660
751
|
content.embedding = vec;
|
|
661
752
|
content.embeddingModel = getModelId();
|
|
@@ -674,9 +765,23 @@ export class Memory extends databases.flair.Memory {
|
|
|
674
765
|
if (content.promotionStatus === "approved") {
|
|
675
766
|
content.durability = "permanent";
|
|
676
767
|
}
|
|
768
|
+
// Write-time provenance stamp (memory-provenance slice 1) — see
|
|
769
|
+
// buildProvenance's doc above post(). Applies to every put() (fresh
|
|
770
|
+
// create AND update/patch) — never gated on preExisting, so an update
|
|
771
|
+
// always gets a freshly-stamped provenance reflecting the CURRENT
|
|
772
|
+
// authenticated actor performing this write.
|
|
773
|
+
content.provenance = buildProvenance(auth, content.createdAt, content);
|
|
774
|
+
// Write-time originatorInstanceId stamp (federation-edge-hardening slice
|
|
775
|
+
// 1) — see stampOriginatorInstanceId's doc above post(). No-op if
|
|
776
|
+
// already set: an update/patch of an existing local record carries its
|
|
777
|
+
// own already-stamped originatorInstanceId forward unchanged (the
|
|
778
|
+
// `{...existing, ...patch}` merge pattern every put() caller uses), and a
|
|
779
|
+
// federation-synced record never reaches this method at all (see that
|
|
780
|
+
// function's doc for why the merge path can't clobber it here either).
|
|
781
|
+
await stampOriginatorInstanceId(content);
|
|
677
782
|
// ── Write the new/updated record FIRST ──────────────────────────────────
|
|
678
783
|
const result = await super.put(content);
|
|
679
|
-
// ── THEN close the superseded record (
|
|
784
|
+
// ── THEN close the superseded record (see post()) ───────────────────────
|
|
680
785
|
await closeSupersededIfNeeded(ctx, content, "put");
|
|
681
786
|
return buildWriteResponse(content, result, dedupMatch);
|
|
682
787
|
}
|