@tpsdev-ai/flair 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/dist/resources/AdminMemory.js +1 -1
- package/dist/resources/Memory.js +120 -33
- package/dist/resources/MemoryBootstrap.js +99 -22
- package/dist/resources/Presence.js +9 -36
- package/dist/resources/SemanticSearch.js +78 -33
- package/dist/resources/agent-auth.js +10 -34
- package/dist/resources/auth-middleware.js +20 -53
- package/dist/resources/bm25-filter.js +9 -0
- package/dist/resources/dedup.js +24 -0
- package/dist/resources/ed25519-auth.js +119 -0
- package/dist/resources/memory-read-scope.js +112 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7123,7 +7123,7 @@ memory.command("add [content]").requiredOption("--agent <id>")
|
|
|
7123
7123
|
.option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content; ops-wkoh)")
|
|
7124
7124
|
.option("--subject <text>", "one-line title / entity this memory is about")
|
|
7125
7125
|
.option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
|
|
7126
|
-
.option("--visibility <value>", "
|
|
7126
|
+
.option("--visibility <value>", "Writer-controlled sharing intent (sets Memory.visibility): 'private' (owner-only, never visible to a grant-holder) or 'shared' (visible to owner + any agent holding a read/search MemoryGrant). Omit to use the server's durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private (flair#509, ops-2dm3)")
|
|
7127
7127
|
.action(async (contentArg, opts) => {
|
|
7128
7128
|
const content = contentArg ?? opts.content;
|
|
7129
7129
|
if (!content) {
|
|
@@ -342,7 +342,7 @@ export class AdminMemory extends Resource {
|
|
|
342
342
|
<dl style="display:grid;grid-template-columns:200px 1fr;gap:6px;margin-top:8px;font-family:ui-monospace,SF Mono,monospace;font-size:0.85em">
|
|
343
343
|
<dt style="color:#666">id</dt><dd>${esc(m.id)}</dd>
|
|
344
344
|
<dt style="color:#666">contentHash</dt><dd>${esc(m.contentHash ?? "—")}</dd>
|
|
345
|
-
<dt style="color:#666">visibility</dt><dd>${esc(m.visibility ?? "
|
|
345
|
+
<dt style="color:#666">visibility</dt><dd>${esc(m.visibility ?? "shared (no field — pre-migration default)")}</dd>
|
|
346
346
|
</dl>
|
|
347
347
|
</div>
|
|
348
348
|
`;
|
package/dist/resources/Memory.js
CHANGED
|
@@ -4,31 +4,23 @@ import { isAdmin, resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
|
4
4
|
import { getEmbedding, getModelId } from "./embeddings-provider.js";
|
|
5
5
|
import { scanFields, isStrictMode } from "./content-safety.js";
|
|
6
6
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
7
|
-
import {
|
|
7
|
+
import { resolveReadScope } from "./memory-read-scope.js";
|
|
8
|
+
import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
|
|
8
9
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
9
10
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
10
11
|
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
11
12
|
/**
|
|
12
|
-
* Owner ids a non-admin agent may READ
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Owner ids a non-admin agent may READ (resolveAllowedOwners) and the full
|
|
14
|
+
* read-scope condition + private-exclusion predicate (resolveReadScope) now
|
|
15
|
+
* live in ./memory-read-scope.ts — the ONE centralized helper every
|
|
16
|
+
* cross-agent Memory read path (search()/get() here, SemanticSearch.ts,
|
|
17
|
+
* MemoryBootstrap.ts, auth-middleware.ts's by-id guard) resolves its scope
|
|
18
|
+
* through, so the scoping rule cannot drift per-path again (ops-nzxa: a
|
|
19
|
+
* SemanticSearch inline `visibility === "office"` OR-clause leaked office
|
|
20
|
+
* memories to any authenticated agent because the rule had scattered). See
|
|
21
|
+
* that module's doc for the migration invariant (no-visibility-field reads
|
|
22
|
+
* as "shared", never "private").
|
|
17
23
|
*/
|
|
18
|
-
async function resolveAllowedOwners(authAgentId) {
|
|
19
|
-
const allowedOwners = [authAgentId];
|
|
20
|
-
try {
|
|
21
|
-
for await (const grant of databases.flair.MemoryGrant.search({
|
|
22
|
-
conditions: [{ attribute: "granteeId", comparator: "equals", value: authAgentId }],
|
|
23
|
-
})) {
|
|
24
|
-
if (grant.ownerId && (grant.scope === "read" || grant.scope === "search")) {
|
|
25
|
-
allowedOwners.push(grant.ownerId);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
catch { /* MemoryGrant table not yet populated — ignore */ }
|
|
30
|
-
return allowedOwners;
|
|
31
|
-
}
|
|
32
24
|
/**
|
|
33
25
|
* ─── Server-side conservative-duplicate gate (memory-integrity fix) ──────────
|
|
34
26
|
*
|
|
@@ -94,7 +86,59 @@ async function findConservativeDedupMatch(ctx, agentId, contentText, embedding,
|
|
|
94
86
|
}
|
|
95
87
|
if (!top)
|
|
96
88
|
return null;
|
|
97
|
-
|
|
89
|
+
// ─── ops-ume4: Harper's cosine-sort query omits $distance for a SINGLETON
|
|
90
|
+
// result set ─────────────────────────────────────────────────────────────
|
|
91
|
+
// Initial working theory was a per-agentId HNSW "cold-start" (first-ever
|
|
92
|
+
// query cold, second query warm) and the initially-recommended fix was a
|
|
93
|
+
// same-query retry. Empirically FALSIFIED: a plain retry of the identical
|
|
94
|
+
// query, 8x with 300ms delays (2.4s total), never recovered a `$distance`
|
|
95
|
+
// for a genuinely singleton candidate set (exactly one record matching
|
|
96
|
+
// `agentId equals X AND archived not_equal true`). The actual trigger,
|
|
97
|
+
// confirmed by direct probing: when this query's post-filter result set
|
|
98
|
+
// has exactly ONE matching record, `$distance` comes back `undefined` for
|
|
99
|
+
// it — regardless of how many prior queries have run for that agentId,
|
|
100
|
+
// how long you wait, or how many other agentIds/records already exist in
|
|
101
|
+
// the table. The moment a SECOND matching record exists, `$distance` is
|
|
102
|
+
// populated correctly on the very first query ever issued for that
|
|
103
|
+
// agentId — no warm-up needed. In practice the singleton case is exactly
|
|
104
|
+
// an agent's SECOND-ever memory (compared against their first) — the most
|
|
105
|
+
// common real-world trigger for this bug, and why it looked "permanent
|
|
106
|
+
// per-agent for the first near-dup query."
|
|
107
|
+
//
|
|
108
|
+
// Also NOT a query-shape/conditions issue: the `{operator:"or"}` wrap
|
|
109
|
+
// SemanticSearch.ts uses elsewhere is unrelated, and neither raising
|
|
110
|
+
// `limit` past 1 nor changing the conditions shape changes the result —
|
|
111
|
+
// confirmed empirically. Harper's SORT ordering is correct even in the
|
|
112
|
+
// singleton case (the right record comes back as `top`); only the
|
|
113
|
+
// numeric `$distance` annotation is missing. Also confirmed: selecting
|
|
114
|
+
// "embedding" directly on THIS sort-by-embedding query does not help
|
|
115
|
+
// either — it comes back as a bare scalar (Harper appears to special-case
|
|
116
|
+
// the sort attribute in `select`), not the stored vector.
|
|
117
|
+
//
|
|
118
|
+
// Fix: when `$distance` is undefined, fetch the ONE candidate's full
|
|
119
|
+
// record by id (a plain point lookup — not a vector-sort query, so
|
|
120
|
+
// unaffected by the quirk above) and compute cosine similarity ourselves
|
|
121
|
+
// in JS from its real stored `embedding` vector against this write's own
|
|
122
|
+
// `embedding` (this function's parameter), via the same math Harper would
|
|
123
|
+
// have used (dedup.ts's cosineSimilarity, Harper-free and unit-tested).
|
|
124
|
+
// This sidesteps the underlying engine quirk entirely rather than
|
|
125
|
+
// depending on its timing, and works identically whether this is the
|
|
126
|
+
// agent's first query ever or its thousandth. Never suppresses the write
|
|
127
|
+
// either way: if the candidate's embedding is somehow also missing (e.g.
|
|
128
|
+
// a legacy record written before embeddings existed), `cosineSimilarity`
|
|
129
|
+
// returns 0 — the same safe "no match" signal the pre-fix `?? 1`
|
|
130
|
+
// fallback produced.
|
|
131
|
+
let cosine;
|
|
132
|
+
if (top.$distance !== undefined) {
|
|
133
|
+
cosine = 1 - top.$distance;
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
console.error("Memory.findConservativeDedupMatch: $distance undefined on a singleton cosine result (ops-ume4) — " +
|
|
137
|
+
"falling back to a manual cosine computation from the candidate's stored embedding", { agentId, candidateId: top.id });
|
|
138
|
+
const fullCandidate = await withDetachedTxn(ctx, () => databases.flair.Memory.get(top.id));
|
|
139
|
+
const candidateEmbedding = Array.isArray(fullCandidate?.embedding) ? fullCandidate.embedding : [];
|
|
140
|
+
cosine = cosineSimilarity(embedding, candidateEmbedding);
|
|
141
|
+
}
|
|
98
142
|
const confidence = computeMatchConfidence(contentText, top.content, cosine);
|
|
99
143
|
if (!isConservativeMatch(confidence.cosine, confidence.lexical, cosineThreshold, lexicalThreshold)) {
|
|
100
144
|
return null;
|
|
@@ -252,6 +296,20 @@ async function closeSupersededIfNeeded(ctx, content, methodLabel) {
|
|
|
252
296
|
"(ops-a4t5 — observable, not silent; new record is safely written, old record remains active until retried)", { method: methodLabel, supersededId: content.supersedes, newRecordId: content.id, err });
|
|
253
297
|
}
|
|
254
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* ─── Durability-keyed default visibility (ops-2dm3 Layer 1, part A) ─────────
|
|
301
|
+
*
|
|
302
|
+
* Writer intent: an explicit `visibility` on the write ALWAYS overrides this
|
|
303
|
+
* (callers check `content.visibility == null` before calling this). When
|
|
304
|
+
* unset, the default is keyed off durability — a durable write (the agent
|
|
305
|
+
* chose to make this stick around) defaults to shared; anything else
|
|
306
|
+
* defaults to private. "absent" durability (not yet defaulted by the caller)
|
|
307
|
+
* falls into the private branch, matching the spec's
|
|
308
|
+
* "standard|ephemeral|absent → private".
|
|
309
|
+
*/
|
|
310
|
+
function defaultVisibilityForDurability(durability) {
|
|
311
|
+
return durability === "permanent" || durability === "persistent" ? "shared" : "private";
|
|
312
|
+
}
|
|
255
313
|
export class Memory extends databases.flair.Memory {
|
|
256
314
|
/**
|
|
257
315
|
* Self-authorize now that the global gate is non-rejecting. Closes the P0
|
|
@@ -301,14 +359,15 @@ export class Memory extends databases.flair.Memory {
|
|
|
301
359
|
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
302
360
|
return super.get(target);
|
|
303
361
|
}
|
|
304
|
-
// Non-admin agent: only its own memories, or
|
|
305
|
-
//
|
|
306
|
-
//
|
|
362
|
+
// Non-admin agent: only its own memories (any visibility), or a granted
|
|
363
|
+
// owner's SHARED memories — never that owner's private ones (ops-2dm3
|
|
364
|
+
// Layer 1 private-exclusion). Centralized in resolveReadScope() so this
|
|
365
|
+
// and search() below cannot drift.
|
|
307
366
|
const record = await super.get(target);
|
|
308
367
|
if (!record)
|
|
309
368
|
return NOT_FOUND();
|
|
310
|
-
const
|
|
311
|
-
if (!
|
|
369
|
+
const scope = await resolveReadScope(auth.agentId);
|
|
370
|
+
if (!scope.isAllowed(record))
|
|
312
371
|
return NOT_FOUND();
|
|
313
372
|
return record;
|
|
314
373
|
}
|
|
@@ -337,13 +396,12 @@ export class Memory extends databases.flair.Memory {
|
|
|
337
396
|
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
338
397
|
return super.search(query);
|
|
339
398
|
}
|
|
340
|
-
// Non-admin agent: scope to own + granted owners
|
|
399
|
+
// Non-admin agent: scope to own (any visibility) + granted owners' SHARED
|
|
400
|
+
// memories only (ops-2dm3 Layer 1 private-exclusion). Centralized in
|
|
401
|
+
// resolveReadScope() so get() above and search() here cannot drift.
|
|
341
402
|
const authAgent = auth.agentId;
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
const agentIdCondition = allowedOwners.length === 1
|
|
345
|
-
? { attribute: "agentId", comparator: "equals", value: allowedOwners[0] }
|
|
346
|
-
: { conditions: allowedOwners.map(id => ({ attribute: "agentId", comparator: "equals", value: id })), operator: "or" };
|
|
403
|
+
const scope = await resolveReadScope(authAgent);
|
|
404
|
+
const agentIdCondition = scope.condition;
|
|
347
405
|
// Harper passes `query` as a RequestTarget (extends URLSearchParams) or a
|
|
348
406
|
// conditions array. For URL-based GET /Memory?... calls, URL params are no
|
|
349
407
|
// longer translated to conditions here — callers should use
|
|
@@ -392,6 +450,16 @@ export class Memory extends databases.flair.Memory {
|
|
|
392
450
|
content.createdAt = new Date().toISOString();
|
|
393
451
|
content.updatedAt = content.createdAt;
|
|
394
452
|
content.archived = content.archived ?? false;
|
|
453
|
+
// ─── Default visibility (durability-keyed) — ops-2dm3 Layer 1, part A ────
|
|
454
|
+
// post() only ever creates a NEW record — patchRecord/supersede-close/
|
|
455
|
+
// retrievalCount bumps all route through put() instead (see put()'s
|
|
456
|
+
// pre-existing-record guard below), so there is no "don't overwrite an
|
|
457
|
+
// existing record's visibility" concern here. Explicit visibility on the
|
|
458
|
+
// write ALWAYS overrides; only stamp the default when the caller left it
|
|
459
|
+
// unset. permanent|persistent → shared; standard|ephemeral|absent → private.
|
|
460
|
+
if (content.visibility === undefined || content.visibility === null) {
|
|
461
|
+
content.visibility = defaultVisibilityForDurability(content.durability);
|
|
462
|
+
}
|
|
395
463
|
// Validate derivedFrom source IDs exist (best-effort, non-blocking)
|
|
396
464
|
if (Array.isArray(content.derivedFrom) && content.derivedFrom.length > 0) {
|
|
397
465
|
const now = content.createdAt;
|
|
@@ -509,6 +577,26 @@ export class Memory extends databases.flair.Memory {
|
|
|
509
577
|
// Set defaults that post() sets — put() is also used for new records via CLI
|
|
510
578
|
content.archived = content.archived ?? false;
|
|
511
579
|
content.createdAt = content.createdAt ?? now;
|
|
580
|
+
// Fetch the pre-existing record (if any) ONCE — reused below both to
|
|
581
|
+
// decide whether this PUT is a fresh create (dedup gate applies, default
|
|
582
|
+
// visibility stamped) or an update/patch (dedup-bypassed, visibility left
|
|
583
|
+
// untouched). See the dedup-gate block further down for why an existing
|
|
584
|
+
// id skips the gate; the SAME "does a record already exist" check gates
|
|
585
|
+
// the visibility default (ops-2dm3 Layer 1 part A): patchRecord/supersede-
|
|
586
|
+
// close/retrievalCount bumps all route through put() with a MERGED
|
|
587
|
+
// `{...existing, ...patch}` payload, and must never have their stored
|
|
588
|
+
// visibility overwritten by a default recomputed from that merged content
|
|
589
|
+
// — only a genuinely NEW id gets the default stamped.
|
|
590
|
+
const preExisting = content.id
|
|
591
|
+
? await databases.flair.Memory.get(content.id).catch(() => null)
|
|
592
|
+
: null;
|
|
593
|
+
// ─── Default visibility (durability-keyed) — ops-2dm3 Layer 1, part A ────
|
|
594
|
+
// Explicit visibility on the write ALWAYS overrides; only stamp the
|
|
595
|
+
// default when the caller left it unset AND this is a fresh record.
|
|
596
|
+
// permanent|persistent → shared; standard|ephemeral|absent → private.
|
|
597
|
+
if (!preExisting && (content.visibility === undefined || content.visibility === null)) {
|
|
598
|
+
content.visibility = defaultVisibilityForDurability(content.durability);
|
|
599
|
+
}
|
|
512
600
|
// supersedes: optional reference to the ID of the memory this one
|
|
513
601
|
// replaces. Validates shape + cross-agent-write authorization (shared
|
|
514
602
|
// with post() — see validateAndAuthorizeSupersedes doc for why PUT needs
|
|
@@ -552,7 +640,6 @@ export class Memory extends databases.flair.Memory {
|
|
|
552
640
|
delete content.lexicalThreshold;
|
|
553
641
|
}
|
|
554
642
|
else if (content.id) {
|
|
555
|
-
const preExisting = await databases.flair.Memory.get(content.id).catch(() => null);
|
|
556
643
|
if (!preExisting) {
|
|
557
644
|
dedupMatch = await runDedupGate(ctx, content);
|
|
558
645
|
}
|
|
@@ -3,6 +3,7 @@ import { allowVerified } from "./agent-auth.js";
|
|
|
3
3
|
import { getEmbedding } from "./embeddings-provider.js";
|
|
4
4
|
import { wrapUntrusted } from "./content-safety.js";
|
|
5
5
|
import { isTeammate, formatTeamLine } from "./memory-bootstrap-lib.js";
|
|
6
|
+
import { resolveReadScope } from "./memory-read-scope.js";
|
|
6
7
|
/**
|
|
7
8
|
* POST /MemoryBootstrap
|
|
8
9
|
*
|
|
@@ -12,11 +13,18 @@ import { isTeammate, formatTeamLine } from "./memory-bootstrap-lib.js";
|
|
|
12
13
|
* 2. Permanent memories (safety rules, core principles)
|
|
13
14
|
* 3. Recent memories (adaptive window)
|
|
14
15
|
* 4. Task-relevant memories (semantic search if currentTask provided)
|
|
16
|
+
* 4b. Teammate findings relevant to your task (flair#550 — the SAME scored
|
|
17
|
+
* task-relevant set as #4, split by origin: a grant-visible teammate's
|
|
18
|
+
* SHARED memory that scores against currentTask lands here instead of
|
|
19
|
+
* #4, attributed via "[via <agentId>]". Presentation only — what's
|
|
20
|
+
* readable is entirely Layer 1's resolveReadScope()/ops-2dm3; this only
|
|
21
|
+
* changes how an already-read cross-agent record is formatted/sectioned)
|
|
15
22
|
* 5. Relationship context (active relationships for mentioned entities)
|
|
16
23
|
* 6. Predicted context (based on channel/surface/subject hints)
|
|
17
24
|
* 7. Team roster (other active agents in this office + a search-first nudge —
|
|
18
|
-
* bootstrap
|
|
19
|
-
*
|
|
25
|
+
* bootstrap loads the caller's own memories plus any granted owner's
|
|
26
|
+
* SHARED memories (ops-2dm3 Layer 1, never their private ones), so this
|
|
27
|
+
* section nudges toward memory_search for anything beyond that window)
|
|
20
28
|
*
|
|
21
29
|
* Prediction: when context signals (channel, surface, subjects) are provided,
|
|
22
30
|
* the bootstrap loads more aggressively — Flair is fast enough that the
|
|
@@ -33,12 +41,23 @@ import { isTeammate, formatTeamLine } from "./memory-bootstrap-lib.js";
|
|
|
33
41
|
function estimateTokens(text) {
|
|
34
42
|
return Math.ceil(text.length / 4);
|
|
35
43
|
}
|
|
36
|
-
|
|
44
|
+
// `agentId` is the BOOTSTRAPPING agent (the caller) — used only to decide
|
|
45
|
+
// whether to annotate attribution, never to change what's read (that
|
|
46
|
+
// boundary is resolveReadScope()'s job, upstream of this function). A
|
|
47
|
+
// cross-agent record always carries `_source` (set once, above, when the
|
|
48
|
+
// record's own agentId differs from the bootstrapping agent — see the
|
|
49
|
+
// allMemories loop), so `m._source !== agentId` is the "is this a
|
|
50
|
+
// teammate's finding" check; own memories never carry `_source` at all.
|
|
51
|
+
function formatMemory(m, agentId) {
|
|
37
52
|
const tag = m.durability === "permanent" ? "🔒" : m.durability === "persistent" ? "📌" : "📝";
|
|
38
53
|
const date = m.createdAt ? ` (${m.createdAt.slice(0, 10)})` : "";
|
|
39
54
|
const chain = m.supersedes ? " [supersedes earlier decision]" : "";
|
|
40
|
-
const
|
|
41
|
-
|
|
55
|
+
const attribution = m._source && m._source !== agentId ? `[via ${m._source}] ` : "";
|
|
56
|
+
const base = `${tag} ${attribution}${m.content}${date}${chain}`;
|
|
57
|
+
// Wrap flagged memories in safety delimiters — composes with attribution
|
|
58
|
+
// above (attribution is baked into `base` before wrapping, so a flagged
|
|
59
|
+
// teammate memory renders with BOTH the "[via <agent>]" tag and the
|
|
60
|
+
// untrusted-content wrapper).
|
|
42
61
|
if (m._safetyFlags && Array.isArray(m._safetyFlags) && m._safetyFlags.length > 0) {
|
|
43
62
|
return wrapUntrusted(base, m._source);
|
|
44
63
|
}
|
|
@@ -90,6 +109,7 @@ export class BootstrapMemories extends Resource {
|
|
|
90
109
|
predicted: [],
|
|
91
110
|
relationships: [],
|
|
92
111
|
relevant: [],
|
|
112
|
+
teammate: [],
|
|
93
113
|
events: [],
|
|
94
114
|
};
|
|
95
115
|
let tokenBudget = maxTokens;
|
|
@@ -178,12 +198,13 @@ export class BootstrapMemories extends Resource {
|
|
|
178
198
|
}
|
|
179
199
|
}
|
|
180
200
|
// --- 1c. Team roster + cross-agent search nudge ---
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// format per agent) so it's cheap enough to always
|
|
201
|
+
// Soul is still caller-own-only (unaffected here). Memory loading below
|
|
202
|
+
// (step 2) now also includes granted owners' SHARED memories (ops-2dm3
|
|
203
|
+
// Layer 1) — but this section stays: memory_search/SemanticSearch remains
|
|
204
|
+
// the deliberate, query-driven way to find a teammate's finding, vs.
|
|
205
|
+
// bootstrap's fixed recent/permanent window. This section is fixed-cost
|
|
206
|
+
// (no query text to format per agent) so it's cheap enough to always
|
|
207
|
+
// include, not budgeted.
|
|
187
208
|
//
|
|
188
209
|
// Permissive kind/status checks are DELIBERATE: Agent.ts registration
|
|
189
210
|
// defaults both (`kind ||= "agent"`, `status ||= "active"`), so pre-1.0
|
|
@@ -204,13 +225,39 @@ export class BootstrapMemories extends Resource {
|
|
|
204
225
|
// Agent table may not exist in older / standalone deployments
|
|
205
226
|
}
|
|
206
227
|
// --- 2. Permanent memories (always included, highest priority) ---
|
|
228
|
+
// Read-scope: own (any visibility) + granted owners' SHARED memories only
|
|
229
|
+
// (ops-2dm3 Layer 1 — never a granted owner's private ones). Centralized
|
|
230
|
+
// in resolveReadScope(): the condition is pushed into the Harper query
|
|
231
|
+
// (so the table itself never returns an out-of-scope row), and
|
|
232
|
+
// `scope.isAllowed` re-checks in-process as defense-in-depth (same
|
|
233
|
+
// belt-and-suspenders discipline as SemanticSearch's BM25 pre-fusion
|
|
234
|
+
// filter) — this is the #550 foundation: bootstrap can now safely expand
|
|
235
|
+
// beyond own-only without a parallel scoping rule.
|
|
236
|
+
const scope = await resolveReadScope(agentId);
|
|
207
237
|
const allMemories = [];
|
|
208
|
-
for await (const record of databases.flair.Memory.search()) {
|
|
209
|
-
if (record
|
|
238
|
+
for await (const record of databases.flair.Memory.search({ conditions: [scope.condition] })) {
|
|
239
|
+
if (!scope.isAllowed(record))
|
|
210
240
|
continue;
|
|
211
241
|
if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
|
|
212
242
|
continue;
|
|
213
|
-
|
|
243
|
+
// ops-hesq: a past validTo ALWAYS means the record has been closed out
|
|
244
|
+
// (server supersede path — Memory.ts closeSupersededRecord — sets
|
|
245
|
+
// validTo without necessarily setting `archived`), same root cause and
|
|
246
|
+
// fix as ops-9rc6's SemanticSearch/bm25-filter exclusion. Unconditional
|
|
247
|
+
// so a server-superseded record can't resurface in bootstrap just
|
|
248
|
+
// because its successor isn't co-present in this result set (the
|
|
249
|
+
// supersededIds filter further down only catches co-presence). A
|
|
250
|
+
// record with no validTo, or a future validTo, is unaffected.
|
|
251
|
+
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
252
|
+
continue;
|
|
253
|
+
// Attribution for cross-agent (granted-owner) records — same convention
|
|
254
|
+
// SemanticSearch.ts already uses: formatMemory() below only USES this
|
|
255
|
+
// when the record also carries _safetyFlags (labels the untrusted-data
|
|
256
|
+
// wrapper with whose memory it is), it never forces wrapping on its own.
|
|
257
|
+
// Real Harper's search() results are non-extensible objects — mutating
|
|
258
|
+
// `record._source = ...` directly throws ("object is not extensible");
|
|
259
|
+
// shallow-copy instead of mutating in place.
|
|
260
|
+
allMemories.push(record.agentId !== agentId ? { ...record, _source: record.agentId } : record);
|
|
214
261
|
}
|
|
215
262
|
memoriesAvailable = allMemories.length;
|
|
216
263
|
// Build superseded set: exclude memories that have been replaced by newer ones
|
|
@@ -220,9 +267,18 @@ export class BootstrapMemories extends Resource {
|
|
|
220
267
|
supersededIds.add(m.supersedes);
|
|
221
268
|
}
|
|
222
269
|
const activeMemories = allMemories.filter((m) => !supersededIds.has(m.id));
|
|
223
|
-
|
|
270
|
+
// #550 design boundary: the permanent / recent / predicted sections are the
|
|
271
|
+
// agent's OWN working context — own-only, always. Post-Layer-1
|
|
272
|
+
// `activeMemories` also carries grant-visible teammate records (`_source`
|
|
273
|
+
// set), but grant-visibility exists to feed the task-relevant "Teammate
|
|
274
|
+
// findings" surfacing (#550) below, NOT to blend a teammate's memories into
|
|
275
|
+
// the reader's recent/permanent/predicted view. So these three sections
|
|
276
|
+
// filter to own (`!m._source`); team knowledge surfaces only when
|
|
277
|
+
// task-relevant (the teammate section) or via an explicit memory_search.
|
|
278
|
+
const ownMemories = activeMemories.filter((m) => !m._source);
|
|
279
|
+
const permanent = ownMemories.filter((m) => m.durability === "permanent");
|
|
224
280
|
for (const m of permanent) {
|
|
225
|
-
const line = formatMemory(m);
|
|
281
|
+
const line = formatMemory(m, agentId);
|
|
226
282
|
const cost = estimateTokens(line);
|
|
227
283
|
if (cost <= tokenBudget) {
|
|
228
284
|
sections.permanent.push(line);
|
|
@@ -236,7 +292,7 @@ export class BootstrapMemories extends Resource {
|
|
|
236
292
|
// --- 3. Recent memories (adaptive window) ---
|
|
237
293
|
// Start with 48h. If nothing found, widen to 7d, then 30d.
|
|
238
294
|
// This prevents empty recent sections for agents that were idle.
|
|
239
|
-
const nonPermanent =
|
|
295
|
+
const nonPermanent = ownMemories
|
|
240
296
|
.filter((m) => m.durability !== "permanent" && m.createdAt)
|
|
241
297
|
.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
|
|
242
298
|
let effectiveSince;
|
|
@@ -258,7 +314,7 @@ export class BootstrapMemories extends Resource {
|
|
|
258
314
|
const recentBudget = Math.floor(tokenBudget * 0.4);
|
|
259
315
|
let recentSpent = 0;
|
|
260
316
|
for (const m of recent) {
|
|
261
|
-
const line = formatMemory(m);
|
|
317
|
+
const line = formatMemory(m, agentId);
|
|
262
318
|
const cost = estimateTokens(line);
|
|
263
319
|
if (recentSpent + cost > recentBudget) {
|
|
264
320
|
memoriesTruncated++;
|
|
@@ -282,7 +338,7 @@ export class BootstrapMemories extends Resource {
|
|
|
282
338
|
...permanent.map((m) => m.id),
|
|
283
339
|
...recent.filter((_, i) => i < sections.recent.length).map((m) => m.id),
|
|
284
340
|
]);
|
|
285
|
-
const subjectMemories =
|
|
341
|
+
const subjectMemories = ownMemories
|
|
286
342
|
.filter((m) => !includedIds.has(m.id) &&
|
|
287
343
|
m.subject &&
|
|
288
344
|
predictedSubjects.includes(m.subject.toLowerCase()) &&
|
|
@@ -292,7 +348,7 @@ export class BootstrapMemories extends Resource {
|
|
|
292
348
|
const predictedBudget = Math.floor(tokenBudget * 0.3);
|
|
293
349
|
let predictedSpent = 0;
|
|
294
350
|
for (const m of subjectMemories) {
|
|
295
|
-
const line = formatMemory(m);
|
|
351
|
+
const line = formatMemory(m, agentId);
|
|
296
352
|
const cost = estimateTokens(line);
|
|
297
353
|
if (predictedSpent + cost > predictedBudget) {
|
|
298
354
|
memoriesTruncated++;
|
|
@@ -362,12 +418,25 @@ export class BootstrapMemories extends Resource {
|
|
|
362
418
|
})
|
|
363
419
|
.filter((s) => s.score > 0.3)
|
|
364
420
|
.sort((a, b) => b.score - a.score);
|
|
421
|
+
// #550: split the scored, task-relevant set by origin. Own findings
|
|
422
|
+
// go to `relevant` as before; a teammate's (grant-visible, already
|
|
423
|
+
// read-scoped by Layer 1 — `m._source` is only ever set for a
|
|
424
|
+
// cross-agent record, see the allMemories loop above) go to the new
|
|
425
|
+
// `teammate` section so the agent can tell it apart at a glance.
|
|
426
|
+
// Both draw from the SAME `tokenBudget` in one score-ordered pass —
|
|
427
|
+
// highest-relevance memories win the remaining budget regardless of
|
|
428
|
+
// which section they land in, so neither section double-spends.
|
|
365
429
|
for (const { memory: m } of scored) {
|
|
366
|
-
const line = formatMemory(m);
|
|
430
|
+
const line = formatMemory(m, agentId);
|
|
367
431
|
const cost = estimateTokens(line);
|
|
368
432
|
if (cost > tokenBudget)
|
|
369
433
|
continue;
|
|
370
|
-
|
|
434
|
+
if (m._source) {
|
|
435
|
+
sections.teammate.push(line);
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
sections.relevant.push(line);
|
|
439
|
+
}
|
|
371
440
|
tokenBudget -= cost;
|
|
372
441
|
memoriesIncluded++;
|
|
373
442
|
}
|
|
@@ -428,6 +497,13 @@ export class BootstrapMemories extends Resource {
|
|
|
428
497
|
if (sections.relevant.length > 0) {
|
|
429
498
|
parts.push("## Relevant Knowledge\n" + sections.relevant.join("\n"));
|
|
430
499
|
}
|
|
500
|
+
// #550: teammate findings relevant to the current task — right after the
|
|
501
|
+
// agent's own task-relevant knowledge. Empty section renders nothing
|
|
502
|
+
// (no header) so a bootstrap with no grant-visible teammate findings for
|
|
503
|
+
// this task looks exactly as it did before this feature.
|
|
504
|
+
if (sections.teammate.length > 0) {
|
|
505
|
+
parts.push("## Teammate findings relevant to your task\n" + sections.teammate.join("\n"));
|
|
506
|
+
}
|
|
431
507
|
if (sections.events.length > 0) {
|
|
432
508
|
parts.push("## Recent Org Events\n" + sections.events.join("\n"));
|
|
433
509
|
}
|
|
@@ -445,6 +521,7 @@ export class BootstrapMemories extends Resource {
|
|
|
445
521
|
predicted: sections.predicted.length,
|
|
446
522
|
relationships: sections.relationships.length,
|
|
447
523
|
relevant: sections.relevant.length,
|
|
524
|
+
teammate: sections.teammate.length,
|
|
448
525
|
events: sections.events.length,
|
|
449
526
|
},
|
|
450
527
|
tokenEstimate: soulTokens + memoryTokens,
|
|
@@ -18,9 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { databases } from "@harperfast/harper";
|
|
20
20
|
import { resolveAgentAuth } from "./agent-auth.js";
|
|
21
|
-
import { b64ToArrayBuffer } from "./
|
|
21
|
+
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
|
|
22
22
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
23
|
-
const WINDOW_MS = 30_000;
|
|
24
23
|
const CURRENT_TASK_MAX_LENGTH = 200;
|
|
25
24
|
const VALID_ACTIVITIES = new Set(["coding", "reviewing", "planning", "idle"]);
|
|
26
25
|
function idleThresholdMs() {
|
|
@@ -31,36 +30,12 @@ function offlineThresholdMs() {
|
|
|
31
30
|
const env = process.env.PRESENCE_OFFLINE_THRESHOLD_MS;
|
|
32
31
|
return env ? Number(env) || 600_000 : 600_000;
|
|
33
32
|
}
|
|
34
|
-
// ─── Nonce replay
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
nonceSeen.delete(k);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
// ─── Crypto helpers ───────────────────────────────────────────────────────────
|
|
44
|
-
// b64ToArrayBuffer lives in ./b64.ts (shared with auth-middleware.ts + agent-auth.ts
|
|
45
|
-
// so the base64/base64url decoder can't drift across the three auth call sites).
|
|
46
|
-
const keyCache = new Map();
|
|
47
|
-
async function importEd25519Key(publicKeyStr) {
|
|
48
|
-
if (keyCache.has(publicKeyStr))
|
|
49
|
-
return keyCache.get(publicKeyStr);
|
|
50
|
-
let raw;
|
|
51
|
-
if (/^[0-9a-f]{64}$/i.test(publicKeyStr)) {
|
|
52
|
-
const bytes = new Uint8Array(32);
|
|
53
|
-
for (let i = 0; i < 32; i++)
|
|
54
|
-
bytes[i] = parseInt(publicKeyStr.slice(i * 2, i * 2 + 2), 16);
|
|
55
|
-
raw = bytes.buffer;
|
|
56
|
-
}
|
|
57
|
-
else {
|
|
58
|
-
raw = b64ToArrayBuffer(publicKeyStr);
|
|
59
|
-
}
|
|
60
|
-
const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
|
|
61
|
-
keyCache.set(publicKeyStr, key);
|
|
62
|
-
return key;
|
|
63
|
-
}
|
|
33
|
+
// ─── Nonce replay + crypto helpers ─────────────────────────────────────────────
|
|
34
|
+
// WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
|
|
35
|
+
// importEd25519Key all live in ./ed25519-auth.ts (bd ops-c4op) — the single
|
|
36
|
+
// shared implementation imported by auth-middleware.ts, agent-auth.ts, and
|
|
37
|
+
// Presence.ts so a nonce recorded via any one of the three call sites is
|
|
38
|
+
// visible to the other two, and the crypto/decoder logic can't drift.
|
|
64
39
|
// ─── Status derivation (pure — exported for unit testing) ─────────────────────
|
|
65
40
|
export function derivePresenceStatus(now, lastHeartbeatAt, idleMs, offlineMs) {
|
|
66
41
|
const idle = idleMs ?? idleThresholdMs();
|
|
@@ -195,9 +170,7 @@ export class Presence extends databases.flair.Presence {
|
|
|
195
170
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS) {
|
|
196
171
|
return new Response(JSON.stringify({ error: "timestamp_out_of_window" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
197
172
|
}
|
|
198
|
-
|
|
199
|
-
const nonceKey = `${headerAgentId}:${nonce}`;
|
|
200
|
-
if (nonceSeen.has(nonceKey)) {
|
|
173
|
+
if (isNonceReplay(headerAgentId, nonce, now)) {
|
|
201
174
|
return new Response(JSON.stringify({ error: "nonce_replay_detected" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
202
175
|
}
|
|
203
176
|
const agent = await databases.flair.Agent.get(headerAgentId).catch(() => null);
|
|
@@ -219,7 +192,7 @@ export class Presence extends databases.flair.Presence {
|
|
|
219
192
|
catch (e) {
|
|
220
193
|
return new Response(JSON.stringify({ error: "signature_verification_failed", detail: e?.message }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
221
194
|
}
|
|
222
|
-
|
|
195
|
+
recordNonce(headerAgentId, nonce, ts);
|
|
223
196
|
agentId = headerAgentId;
|
|
224
197
|
}
|
|
225
198
|
// ── Validate body ────────────────────────────────────────────────────────
|
|
@@ -4,6 +4,8 @@ import { getEmbedding, getMode } from "./embeddings-provider.js";
|
|
|
4
4
|
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
5
5
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
6
6
|
import { wrapUntrusted } from "./content-safety.js";
|
|
7
|
+
import { resolveReadScope } from "./memory-read-scope.js";
|
|
8
|
+
import { cosineSimilarity } from "./dedup.js";
|
|
7
9
|
import { isRerankEnabled, getRerankTopN, getRerankBudgetMs, getRerankMinCandidates, rerankCandidates, } from "./rerank-provider.js";
|
|
8
10
|
// Temporal decay + relevance scoring (incl. the OPS-AYGD retrievalBoost cap +
|
|
9
11
|
// relevance floor) lives in ./scoring.ts — a Harper-free module so it can be
|
|
@@ -28,8 +30,9 @@ export class SemanticSearch extends Resource {
|
|
|
28
30
|
// Self-authorize via the Ed25519 agent verify instead of relying on the auth
|
|
29
31
|
// gate's admin super_user elevation (removed in the auth reshape). Any
|
|
30
32
|
// cryptographically-verified agent may search; per-agent RESULT scoping is
|
|
31
|
-
// enforced in post() below (an agent only sees its own
|
|
32
|
-
//
|
|
33
|
+
// enforced in post() below (an agent only sees its own memories, any
|
|
34
|
+
// visibility, plus granted owners' SHARED memories — never their private
|
|
35
|
+
// ones). Without this, Harper's default denies the POST for the
|
|
33
36
|
// least-privilege flair_agent role (AccessViolation 403).
|
|
34
37
|
async allowCreate() {
|
|
35
38
|
return allowVerified(this.getContext?.());
|
|
@@ -77,21 +80,13 @@ export class SemanticSearch extends Resource {
|
|
|
77
80
|
const agentId = (authenticatedAgent && !callerIsAdmin)
|
|
78
81
|
? authenticatedAgent
|
|
79
82
|
: bodyAgentId;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
})) {
|
|
88
|
-
if (grant.scope === "search" || grant.scope === "read") {
|
|
89
|
-
searchAgentIds.add(grant.ownerId);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
catch { /* MemoryGrant may not exist */ }
|
|
94
|
-
}
|
|
83
|
+
// Read-scope: own (any visibility) + granted owners' SHARED memories only
|
|
84
|
+
// (ops-2dm3 Layer 1). Centralized in resolveReadScope() — this used to be
|
|
85
|
+
// an inline grant-resolution loop here PLUS a `visibility === "office"`
|
|
86
|
+
// global OR-clause below that leaked ANY authenticated agent's read of
|
|
87
|
+
// ANY other agent's office-visible memories (ops-nzxa). Both are gone;
|
|
88
|
+
// this is the ONE scoping resolution for this endpoint now.
|
|
89
|
+
const scope = agentId ? await resolveReadScope(agentId) : null;
|
|
95
90
|
// Generate query embedding
|
|
96
91
|
let qEmb = queryEmbedding;
|
|
97
92
|
if (!qEmb && q) {
|
|
@@ -135,21 +130,12 @@ export class SemanticSearch extends Resource {
|
|
|
135
130
|
}
|
|
136
131
|
// ─── Build conditions for Harper query ──────────────────────────────────
|
|
137
132
|
const conditions = [];
|
|
138
|
-
// Agent scoping:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
{ attribute: "agentId", comparator: "equals", value: id },
|
|
145
|
-
{ attribute: "visibility", comparator: "equals", value: "office" },
|
|
146
|
-
],
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
else if (searchAgentIds.size > 1) {
|
|
150
|
-
const agentConditions = [...searchAgentIds].map(id => ({ attribute: "agentId", comparator: "equals", value: id }));
|
|
151
|
-
agentConditions.push({ attribute: "visibility", comparator: "equals", value: "office" });
|
|
152
|
-
conditions.push({ operator: "or", conditions: agentConditions });
|
|
133
|
+
// Agent scoping: own (any visibility) OR granted-owner's SHARED memories
|
|
134
|
+
// (private-exclusion) — the centralized read-scope condition. No agentId
|
|
135
|
+
// → no scoping condition pushed (trusted internal call / admin without a
|
|
136
|
+
// target agentId — matches the pre-existing unscoped fallback).
|
|
137
|
+
if (scope) {
|
|
138
|
+
conditions.push(scope.condition);
|
|
153
139
|
}
|
|
154
140
|
// Exclude archived records. Use "not_equal" (Harper v5 comparator) instead of
|
|
155
141
|
// "equals false" so records without the archived field are included.
|
|
@@ -220,6 +206,10 @@ export class SemanticSearch extends Resource {
|
|
|
220
206
|
continue;
|
|
221
207
|
if (asOf && record.validTo && record.validTo <= asOf)
|
|
222
208
|
continue;
|
|
209
|
+
// ops-9rc6: unconditional past-validTo exclusion (see legacy HNSW
|
|
210
|
+
// loop below for the full rationale) — applies regardless of asOf.
|
|
211
|
+
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
212
|
+
continue;
|
|
223
213
|
semRecords.push(record);
|
|
224
214
|
semIds.push(record.id);
|
|
225
215
|
}
|
|
@@ -325,7 +315,58 @@ export class SemanticSearch extends Resource {
|
|
|
325
315
|
continue;
|
|
326
316
|
if (asOf && record.validTo && record.validTo <= asOf)
|
|
327
317
|
continue;
|
|
328
|
-
|
|
318
|
+
// ops-9rc6: a past validTo ALWAYS means the record has been closed out
|
|
319
|
+
// (server supersede path — Memory.ts closeSupersededRecord — sets
|
|
320
|
+
// validTo without necessarily setting `archived`). Unconditional, not
|
|
321
|
+
// gated on `asOf`, so a server-superseded record can't resurface in
|
|
322
|
+
// the DEFAULT recall path just because its successor isn't
|
|
323
|
+
// co-present in this result set (the supersededIds filter further
|
|
324
|
+
// down only catches co-presence). A record with no validTo, or a
|
|
325
|
+
// future validTo, is unaffected.
|
|
326
|
+
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
327
|
+
continue;
|
|
328
|
+
let semanticScore;
|
|
329
|
+
if (record.$distance !== undefined) {
|
|
330
|
+
semanticScore = distanceToSimilarity(record.$distance);
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
// ─── ops-syzm: Harper's cosine-sort query omits $distance for a
|
|
334
|
+
// SINGLETON post-filter result set — the SAME quirk root-caused and
|
|
335
|
+
// fixed for the dedup path in ops-ume4 (resources/Memory.ts
|
|
336
|
+
// findConservativeDedupMatch / resources/dedup.ts cosineSimilarity).
|
|
337
|
+
// Sort ORDER is still correct; only the numeric `$distance`
|
|
338
|
+
// annotation is missing on that one record, regardless of the
|
|
339
|
+
// query's own `limit` (reproduced here with candidateLimit=50, not
|
|
340
|
+
// just limit=1 — the trigger is the post-filter MATCH COUNT, not the
|
|
341
|
+
// requested limit).
|
|
342
|
+
//
|
|
343
|
+
// ops-2dm3 Layer 1 made this common: the no-grants agent scope used
|
|
344
|
+
// to ALWAYS be a compound `{operator:"or", conditions:[{agentId},
|
|
345
|
+
// {visibility=="office"}]}` condition; resolveReadScope() now emits
|
|
346
|
+
// a PLAIN single `{agentId==X}` condition for the common (no-grants)
|
|
347
|
+
// case, so a scoped search against an agent with exactly one
|
|
348
|
+
// matching memory hits this Harper quirk directly. Before, the OR
|
|
349
|
+
// wrap incidentally avoided the singleton shape. The old `?? 1`
|
|
350
|
+
// fallback silently collapsed this to similarity 0 — read by
|
|
351
|
+
// callers (including the clean-VM CI gate's single-memory init
|
|
352
|
+
// probe, #533) as "embeddings not loaded", which is WRONG:
|
|
353
|
+
// embeddings ARE loaded, only the score was computed incorrectly.
|
|
354
|
+
//
|
|
355
|
+
// Fix: point-lookup the record by id (a plain get(), unaffected by
|
|
356
|
+
// the sort-query quirk — selecting "embedding" directly on the SAME
|
|
357
|
+
// sort-by-embedding query comes back as a bare scalar per ops-ume4's
|
|
358
|
+
// findings) and compute cosine similarity ourselves in JS from its
|
|
359
|
+
// real stored `embedding` vector against this query's `qEmb`, via
|
|
360
|
+
// the same math as the ume4 fallback (dedup.ts's cosineSimilarity).
|
|
361
|
+
// Only done on this (rare) undefined-$distance branch — never adds
|
|
362
|
+
// a point-lookup to the normal per-record path. If the stored
|
|
363
|
+
// embedding is missing/empty (e.g. a legacy pre-embedding record),
|
|
364
|
+
// cosineSimilarity returns 0 — the same safe "no match" the old
|
|
365
|
+
// `?? 1` fallback produced, never a false-high score.
|
|
366
|
+
const full = await withDetachedTxn(ctx, () => databases.flair.Memory.get(record.id));
|
|
367
|
+
const storedEmbedding = Array.isArray(full?.embedding) ? full.embedding : [];
|
|
368
|
+
semanticScore = cosineSimilarity(qEmb, storedEmbedding);
|
|
369
|
+
}
|
|
329
370
|
let keywordHit = false;
|
|
330
371
|
if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
|
|
331
372
|
keywordHit = true;
|
|
@@ -364,6 +405,10 @@ export class SemanticSearch extends Resource {
|
|
|
364
405
|
continue;
|
|
365
406
|
if (asOf && record.validTo && record.validTo <= asOf)
|
|
366
407
|
continue;
|
|
408
|
+
// ops-9rc6: unconditional past-validTo exclusion (see legacy HNSW
|
|
409
|
+
// loop above for the full rationale) — applies regardless of asOf.
|
|
410
|
+
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
411
|
+
continue;
|
|
367
412
|
let keywordHit = false;
|
|
368
413
|
if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
|
|
369
414
|
keywordHit = true;
|
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
* 30s timestamp window + a per-(agent,nonce) seen-set pruned to that window.
|
|
17
17
|
*/
|
|
18
18
|
import { databases } from "@harperfast/harper";
|
|
19
|
-
import { b64ToArrayBuffer } from "./
|
|
20
|
-
const WINDOW_MS = Number(process.env.FLAIR_AGENT_AUTH_WINDOW_MS) || 30_000;
|
|
19
|
+
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
|
|
21
20
|
/**
|
|
22
21
|
* Shared Harper user that verified Ed25519 agents resolve to (least-privilege
|
|
23
22
|
* `flair_agent` role), replacing the old admin super_user elevation. Single
|
|
@@ -25,31 +24,12 @@ const WINDOW_MS = Number(process.env.FLAIR_AGENT_AUTH_WINDOW_MS) || 30_000;
|
|
|
25
24
|
* provisions it (ensureFlairAgentUser); they MUST agree on the name.
|
|
26
25
|
*/
|
|
27
26
|
export const FLAIR_AGENT_USERNAME = "flair-agent";
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// so
|
|
33
|
-
|
|
34
|
-
async function importEd25519Key(publicKeyStr) {
|
|
35
|
-
const cached = keyCache.get(publicKeyStr);
|
|
36
|
-
if (cached)
|
|
37
|
-
return cached;
|
|
38
|
-
// Accept hex (64-char) or base64 (44-char) encoded 32-byte Ed25519 public key.
|
|
39
|
-
let raw;
|
|
40
|
-
if (/^[0-9a-f]{64}$/i.test(publicKeyStr)) {
|
|
41
|
-
const bytes = new Uint8Array(32);
|
|
42
|
-
for (let i = 0; i < 32; i++)
|
|
43
|
-
bytes[i] = parseInt(publicKeyStr.slice(i * 2, i * 2 + 2), 16);
|
|
44
|
-
raw = bytes.buffer;
|
|
45
|
-
}
|
|
46
|
-
else {
|
|
47
|
-
raw = b64ToArrayBuffer(publicKeyStr);
|
|
48
|
-
}
|
|
49
|
-
const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
|
|
50
|
-
keyCache.set(publicKeyStr, key);
|
|
51
|
-
return key;
|
|
52
|
-
}
|
|
27
|
+
// ─── Crypto + replay-guard helpers ────────────────────────────────────────────
|
|
28
|
+
// WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
|
|
29
|
+
// importEd25519Key all live in ./ed25519-auth.ts (bd ops-c4op) — the single
|
|
30
|
+
// shared implementation imported by auth-middleware.ts, agent-auth.ts, and
|
|
31
|
+
// Presence.ts so a nonce recorded via any one of the three call sites is
|
|
32
|
+
// visible to the other two, and the crypto/decoder logic can't drift.
|
|
53
33
|
// ─── Admin resolution ─────────────────────────────────────────────────────────
|
|
54
34
|
// Admin agents come from FLAIR_ADMIN_AGENTS (comma-separated) OR Agent records
|
|
55
35
|
// with role === "admin". OR-combined, cached 60s. Distinct from Harper's
|
|
@@ -89,12 +69,8 @@ async function doVerify(request) {
|
|
|
89
69
|
const now = Date.now();
|
|
90
70
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS)
|
|
91
71
|
return null;
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
if (now - t > WINDOW_MS)
|
|
95
|
-
nonceSeen.delete(k);
|
|
96
|
-
const nonceKey = `${agentId}:${nonce}`;
|
|
97
|
-
if (nonceSeen.has(nonceKey))
|
|
72
|
+
// Reject replays within the window (isNonceReplay prunes expired entries first).
|
|
73
|
+
if (isNonceReplay(agentId, nonce, now))
|
|
98
74
|
return null;
|
|
99
75
|
const agent = await databases.flair.Agent.get(agentId).catch(() => null);
|
|
100
76
|
if (!agent?.publicKey)
|
|
@@ -112,7 +88,7 @@ async function doVerify(request) {
|
|
|
112
88
|
catch {
|
|
113
89
|
return null;
|
|
114
90
|
}
|
|
115
|
-
|
|
91
|
+
recordNonce(agentId, nonce, ts);
|
|
116
92
|
return { agentId, isAdmin: await isAdmin(agentId) };
|
|
117
93
|
}
|
|
118
94
|
/**
|
|
@@ -2,7 +2,8 @@ import { patchRecord } from "./table-helpers.js";
|
|
|
2
2
|
import { server, databases } from "@harperfast/harper";
|
|
3
3
|
import { getEmbedding } from "./embeddings-provider.js";
|
|
4
4
|
import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
|
|
5
|
-
import { b64ToArrayBuffer } from "./
|
|
5
|
+
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
|
|
6
|
+
import { resolveReadScope } from "./memory-read-scope.js";
|
|
6
7
|
// --- Admin credentials ---
|
|
7
8
|
// Admin auth is sourced exclusively from Harper's own environment variables
|
|
8
9
|
// (HDB_ADMIN_PASSWORD / FLAIR_ADMIN_PASSWORD). No filesystem token file.
|
|
@@ -30,36 +31,17 @@ function getAdminPass() {
|
|
|
30
31
|
// No admin password configured — return null and let callers fall through
|
|
31
32
|
return null;
|
|
32
33
|
}
|
|
33
|
-
const WINDOW_MS = 30_000;
|
|
34
|
-
const nonceSeen = new Map();
|
|
35
34
|
// ─── Admin resolution ─────────────────────────────────────────────────────────
|
|
36
35
|
// `isAdmin` (FLAIR_ADMIN_AGENTS env + Agent role==="admin", 60s-cached) now lives
|
|
37
36
|
// in agent-auth.ts as the single source of truth, imported above. During the
|
|
38
37
|
// auth reshape this gate and the per-resource allow* helpers must agree on who's
|
|
39
38
|
// an admin — one implementation guarantees they can't diverge.
|
|
40
|
-
// ─── Crypto helpers
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return keyCache.get(publicKeyStr);
|
|
47
|
-
// Accept hex (64-char) or base64 (44-char) encoded 32-byte Ed25519 public key
|
|
48
|
-
let raw;
|
|
49
|
-
if (/^[0-9a-f]{64}$/i.test(publicKeyStr)) {
|
|
50
|
-
// Hex-encoded raw key (TPS CLI default: Buffer.toString('hex'))
|
|
51
|
-
const bytes = new Uint8Array(32);
|
|
52
|
-
for (let i = 0; i < 32; i++)
|
|
53
|
-
bytes[i] = parseInt(publicKeyStr.slice(i * 2, i * 2 + 2), 16);
|
|
54
|
-
raw = bytes.buffer;
|
|
55
|
-
}
|
|
56
|
-
else {
|
|
57
|
-
raw = b64ToArrayBuffer(publicKeyStr);
|
|
58
|
-
}
|
|
59
|
-
const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
|
|
60
|
-
keyCache.set(publicKeyStr, key);
|
|
61
|
-
return key;
|
|
62
|
-
}
|
|
39
|
+
// ─── Crypto + replay-guard helpers ────────────────────────────────────────────
|
|
40
|
+
// WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
|
|
41
|
+
// importEd25519Key all live in ./ed25519-auth.ts (bd ops-c4op) — the single
|
|
42
|
+
// shared implementation imported by auth-middleware.ts, agent-auth.ts, and
|
|
43
|
+
// Presence.ts so a nonce recorded via any one of the three call sites is
|
|
44
|
+
// visible to the other two, and the crypto/decoder logic can't drift.
|
|
63
45
|
async function backfillEmbedding(memoryId) {
|
|
64
46
|
try {
|
|
65
47
|
const record = await databases.flair.Memory.get(memoryId);
|
|
@@ -255,11 +237,7 @@ server.http(async (request, nextLayer) => {
|
|
|
255
237
|
const now = Date.now();
|
|
256
238
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS)
|
|
257
239
|
return new Response(JSON.stringify({ error: "timestamp_out_of_window" }), { status: 401 });
|
|
258
|
-
|
|
259
|
-
if (now - signatureTs > WINDOW_MS)
|
|
260
|
-
nonceSeen.delete(k);
|
|
261
|
-
const nonceKey = `${agentId}:${nonce}`;
|
|
262
|
-
if (nonceSeen.has(nonceKey))
|
|
240
|
+
if (isNonceReplay(agentId, nonce, now))
|
|
263
241
|
return new Response(JSON.stringify({ error: "nonce_replay_detected" }), { status: 401 });
|
|
264
242
|
const agent = await databases.flair.Agent.get(agentId);
|
|
265
243
|
if (!agent)
|
|
@@ -276,7 +254,7 @@ server.http(async (request, nextLayer) => {
|
|
|
276
254
|
catch (e) {
|
|
277
255
|
return new Response(JSON.stringify({ error: "signature_verification_failed", detail: e?.message }), { status: 401 });
|
|
278
256
|
}
|
|
279
|
-
|
|
257
|
+
recordNonce(agentId, nonce, ts);
|
|
280
258
|
request.tpsAgent = agentId;
|
|
281
259
|
request._tpsAuthVerified = true;
|
|
282
260
|
request.tpsAgentIsAdmin = await isAdmin(agentId);
|
|
@@ -499,27 +477,16 @@ server.http(async (request, nextLayer) => {
|
|
|
499
477
|
if (memId) {
|
|
500
478
|
const record = await databases.flair.Memory.get(memId);
|
|
501
479
|
if (record && record.agentId && record.agentId !== agentId) {
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
hasGrant = true;
|
|
513
|
-
break;
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
catch { }
|
|
518
|
-
if (!hasGrant) {
|
|
519
|
-
return new Response(JSON.stringify({
|
|
520
|
-
error: `forbidden: cannot read memory owned by ${record.agentId}`,
|
|
521
|
-
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
522
|
-
}
|
|
480
|
+
// Centralized read-scope (ops-2dm3 Layer 1): a grant only covers
|
|
481
|
+
// the owner's SHARED memories, never their private ones. This
|
|
482
|
+
// used to be a `visibility === "office"` bypass (any authenticated
|
|
483
|
+
// agent, no grant needed) — that's gone; the private-exclusion is
|
|
484
|
+
// now enforced the same way every other read path enforces it.
|
|
485
|
+
const scope = await resolveReadScope(agentId);
|
|
486
|
+
if (!scope.isAllowed(record)) {
|
|
487
|
+
return new Response(JSON.stringify({
|
|
488
|
+
error: `forbidden: cannot read memory owned by ${record.agentId}`,
|
|
489
|
+
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
523
490
|
}
|
|
524
491
|
}
|
|
525
492
|
}
|
|
@@ -72,6 +72,15 @@ export function passesRecordFilters(record, f = {}) {
|
|
|
72
72
|
return false;
|
|
73
73
|
if (f.asOf && record?.validTo && record.validTo <= f.asOf)
|
|
74
74
|
return false;
|
|
75
|
+
// ops-9rc6: a past validTo ALWAYS means the record has been closed out —
|
|
76
|
+
// either by the server supersede path (Memory.ts closeSupersededRecord sets
|
|
77
|
+
// validTo without necessarily setting `archived`) or any other writer. This
|
|
78
|
+
// is unconditional (not gated on `f.asOf`) so a server-superseded record
|
|
79
|
+
// can never resurface in the DEFAULT (no-asOf) recall path just because its
|
|
80
|
+
// successor happened not to be co-present in the same result set. A record
|
|
81
|
+
// with no validTo, or a future validTo, is unaffected.
|
|
82
|
+
if (record?.validTo && Date.parse(record.validTo) < now)
|
|
83
|
+
return false;
|
|
75
84
|
return true;
|
|
76
85
|
}
|
|
77
86
|
// The full BM25-candidate security filter: a record is a valid BM25 candidate
|
package/dist/resources/dedup.js
CHANGED
|
@@ -26,6 +26,30 @@ export const DEDUP_LEXICAL_THRESHOLD_DEFAULT = 0.5;
|
|
|
26
26
|
/** Below this content length, similarity scoring is unreliable ("ok" would
|
|
27
27
|
* match "ok" trivially) — the dedup gate is bypassed entirely. */
|
|
28
28
|
export const DEDUP_MIN_CONTENT_LENGTH = 20;
|
|
29
|
+
/**
|
|
30
|
+
* Cosine similarity of two equal-length embedding vectors, computed directly
|
|
31
|
+
* in JS. Used as a fallback for Harper's HNSW cosine-sort query omitting a
|
|
32
|
+
* computed `$distance` on its top candidate when the query's post-filter
|
|
33
|
+
* result set contains exactly ONE matching record (ops-ume4, found in
|
|
34
|
+
* resources/Memory.ts's findConservativeDedupMatch; the identical quirk is
|
|
35
|
+
* also fixed in resources/SemanticSearch.ts's scoring loop for the same
|
|
36
|
+
* reason — see ops-syzm there). A mismatched length, empty vector, or
|
|
37
|
+
* zero-magnitude side yields 0 (no signal, never treated as "identical" by a
|
|
38
|
+
* degenerate computation).
|
|
39
|
+
*/
|
|
40
|
+
export function cosineSimilarity(a, b) {
|
|
41
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length)
|
|
42
|
+
return 0;
|
|
43
|
+
let dot = 0, normA = 0, normB = 0;
|
|
44
|
+
for (let i = 0; i < a.length; i++) {
|
|
45
|
+
dot += a[i] * b[i];
|
|
46
|
+
normA += a[i] * a[i];
|
|
47
|
+
normB += b[i] * b[i];
|
|
48
|
+
}
|
|
49
|
+
if (normA === 0 || normB === 0)
|
|
50
|
+
return 0;
|
|
51
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
52
|
+
}
|
|
29
53
|
/** Jaccard similarity of two token sets: |A∩B| / |A∪B|. An empty side yields 0
|
|
30
54
|
* (no signal — never treated as "fully similar" by vacuous set equality). */
|
|
31
55
|
export function jaccardSimilarity(tokensA, tokensB) {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ed25519-auth — single shared source of truth for the TPS-Ed25519 auth
|
|
3
|
+
* primitives used at all 3 signature-verification call sites:
|
|
4
|
+
*
|
|
5
|
+
* - resources/auth-middleware.ts (instance-wide HTTP gate)
|
|
6
|
+
* - resources/agent-auth.ts (resolveAgentAuth's per-resource fallback)
|
|
7
|
+
* - resources/Presence.ts (POST /Presence heartbeat auth)
|
|
8
|
+
*
|
|
9
|
+
* Before this module existed, each of the 3 files carried its OWN
|
|
10
|
+
* module-level `nonceSeen` Map (replay guard) plus its own copy of
|
|
11
|
+
* `importEd25519Key`. Three independent replay windows meant a nonce
|
|
12
|
+
* recorded as "seen" via one path was invisible to the other two — a
|
|
13
|
+
* defense-in-depth gap, and a drift hazard (any future fix to one copy
|
|
14
|
+
* silently didn't apply to the other two). bd ops-c4op consolidates all
|
|
15
|
+
* three into this one module so there is exactly ONE replay guard and ONE
|
|
16
|
+
* key-import implementation, imported by all 3 sites.
|
|
17
|
+
*
|
|
18
|
+
* `b64ToArrayBuffer` was already unified into resources/b64.ts in a prior
|
|
19
|
+
* pass (see that file's header) — re-exported here so callers can import
|
|
20
|
+
* everything Ed25519-auth-related from one place.
|
|
21
|
+
*
|
|
22
|
+
* NOT included here: resources/Federation.ts's replay guard. Federation
|
|
23
|
+
* uses its own purpose-built NonceStore (federation-crypto.ts) for a
|
|
24
|
+
* different signing scheme (federation peer-to-peer body signatures, not
|
|
25
|
+
* agent TPS-Ed25519 auth) — deliberately left alone.
|
|
26
|
+
*
|
|
27
|
+
* Signed payload format (must match the TPS CLI signer exactly — changing
|
|
28
|
+
* it breaks every agent's auth): `${agentId}:${ts}:${nonce}:${METHOD}:${pathname}${search}`.
|
|
29
|
+
* Auth header format: `TPS-Ed25519 <agentId>:<ts>:<nonce>:<signatureB64>`.
|
|
30
|
+
* nonceKey format (replay-guard map key): `${agentId}:${nonce}`.
|
|
31
|
+
*/
|
|
32
|
+
import { b64ToArrayBuffer } from "./b64.js";
|
|
33
|
+
export { b64ToArrayBuffer };
|
|
34
|
+
/**
|
|
35
|
+
* Replay window, in ms. Confirmed identical (30_000) as a literal constant
|
|
36
|
+
* in auth-middleware.ts and Presence.ts. agent-auth.ts alone additionally
|
|
37
|
+
* read an (undocumented, never-set-anywhere) `FLAIR_AGENT_AUTH_WINDOW_MS`
|
|
38
|
+
* env override — see the flag on this in the consolidation report; the
|
|
39
|
+
* override is preserved here (uniformly, for all 3 sites) since no config,
|
|
40
|
+
* docs, or test in this repo currently sets that var, so preserving it is
|
|
41
|
+
* additive/no-op for today's deployments while keeping agent-auth.ts's
|
|
42
|
+
* stated "plugin-shaped, config via env" design intent.
|
|
43
|
+
*/
|
|
44
|
+
export const WINDOW_MS = Number(process.env.FLAIR_AGENT_AUTH_WINDOW_MS) || 30_000;
|
|
45
|
+
// ─── Replay guard (single shared instance) ─────────────────────────────────
|
|
46
|
+
//
|
|
47
|
+
// nonceSeen is the ONE module-level singleton — the whole point of this
|
|
48
|
+
// consolidation. A nonce recorded via any one of the 3 call sites is
|
|
49
|
+
// immediately visible to the other two, because they all import this same
|
|
50
|
+
// module (Node/bun module cache = one instance per process).
|
|
51
|
+
const nonceSeen = new Map();
|
|
52
|
+
/** Remove nonce records older than WINDOW_MS relative to `now`. */
|
|
53
|
+
export function pruneNonces(now = Date.now()) {
|
|
54
|
+
for (const [k, ts] of nonceSeen) {
|
|
55
|
+
if (now - ts > WINDOW_MS)
|
|
56
|
+
nonceSeen.delete(k);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Prune expired entries, then report whether (agentId, nonce) has already
|
|
61
|
+
* been recorded within the current window. Returns true = REPLAY (reject).
|
|
62
|
+
*
|
|
63
|
+
* Deliberately does NOT record as a side effect — callers check this BEFORE
|
|
64
|
+
* verifying the signature and call `recordNonce` only AFTER the signature is
|
|
65
|
+
* confirmed valid (matches the pre-consolidation per-site behavior at all 3
|
|
66
|
+
* sites exactly: an invalid-signature attempt never burns the nonce, so a
|
|
67
|
+
* client that retries with a corrected signature isn't locked out).
|
|
68
|
+
*/
|
|
69
|
+
export function isNonceReplay(agentId, nonce, now = Date.now()) {
|
|
70
|
+
pruneNonces(now);
|
|
71
|
+
return nonceSeen.has(`${agentId}:${nonce}`);
|
|
72
|
+
}
|
|
73
|
+
/** Record (agentId, nonce) as seen at `ts`. Call only after successful verification. */
|
|
74
|
+
export function recordNonce(agentId, nonce, ts) {
|
|
75
|
+
nonceSeen.set(`${agentId}:${nonce}`, ts);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Test-only escape hatch: clear all recorded nonces. Never called by any
|
|
79
|
+
* production call site — exists so unit tests can isolate the shared
|
|
80
|
+
* singleton between cases instead of relying on WINDOW_MS-based expiry.
|
|
81
|
+
*/
|
|
82
|
+
export function __clearNoncesForTest() {
|
|
83
|
+
nonceSeen.clear();
|
|
84
|
+
}
|
|
85
|
+
// ─── Ed25519 public key import (cached) ────────────────────────────────────
|
|
86
|
+
//
|
|
87
|
+
// Single shared key cache, keyed by the raw publicKeyStr. Consolidating the
|
|
88
|
+
// 3 previously-independent caches into one just avoids redundant
|
|
89
|
+
// crypto.subtle.importKey calls across sites for the same agent — no
|
|
90
|
+
// security-relevant behavior change (the cache holds only CryptoKey handles,
|
|
91
|
+
// never logged or exposed).
|
|
92
|
+
const keyCache = new Map();
|
|
93
|
+
/**
|
|
94
|
+
* Import an agent's Ed25519 public key as a CryptoKey, cached by the raw
|
|
95
|
+
* key string. Accepts hex (64-char) or base64/base64url (44-char, or
|
|
96
|
+
* unpadded) encoded 32-byte Ed25519 public keys — identical logic across
|
|
97
|
+
* all 3 pre-consolidation copies (auth-middleware.ts, agent-auth.ts,
|
|
98
|
+
* Presence.ts).
|
|
99
|
+
*/
|
|
100
|
+
export async function importEd25519Key(publicKeyStr) {
|
|
101
|
+
const cached = keyCache.get(publicKeyStr);
|
|
102
|
+
if (cached)
|
|
103
|
+
return cached;
|
|
104
|
+
// Accept hex (64-char) or base64 (44-char) encoded 32-byte Ed25519 public key.
|
|
105
|
+
let raw;
|
|
106
|
+
if (/^[0-9a-f]{64}$/i.test(publicKeyStr)) {
|
|
107
|
+
// Hex-encoded raw key (TPS CLI default: Buffer.toString('hex'))
|
|
108
|
+
const bytes = new Uint8Array(32);
|
|
109
|
+
for (let i = 0; i < 32; i++)
|
|
110
|
+
bytes[i] = parseInt(publicKeyStr.slice(i * 2, i * 2 + 2), 16);
|
|
111
|
+
raw = bytes.buffer;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
raw = b64ToArrayBuffer(publicKeyStr);
|
|
115
|
+
}
|
|
116
|
+
const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
|
|
117
|
+
keyCache.set(publicKeyStr, key);
|
|
118
|
+
return key;
|
|
119
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { databases } from "@harperfast/harper";
|
|
2
|
+
/**
|
|
3
|
+
* ─── Centralized Memory read-scoping (ops-2dm3 Layer 1) ─────────────────────
|
|
4
|
+
*
|
|
5
|
+
* The SINGLE source every cross-agent Memory read path resolves its scope
|
|
6
|
+
* through: Memory.search()/Memory.get() (resources/Memory.ts), SemanticSearch
|
|
7
|
+
* (resources/SemanticSearch.ts), MemoryBootstrap (resources/MemoryBootstrap.ts),
|
|
8
|
+
* and the by-id GET guard in resources/auth-middleware.ts. Before this module
|
|
9
|
+
* existed, SemanticSearch had its OWN inline grant-resolution + a
|
|
10
|
+
* `visibility === "office"` global OR-clause that leaked ANY authenticated
|
|
11
|
+
* agent's read of ANY other agent's memories once that memory happened to
|
|
12
|
+
* carry `visibility: "office"` (ops-nzxa). Scattering the scoping rule per
|
|
13
|
+
* path is exactly how that leak happened — this module exists so it can't
|
|
14
|
+
* happen again: one rule, one place, every path imports it.
|
|
15
|
+
*
|
|
16
|
+
* This is a plain-function module (no `class X extends databases.flair.X`) —
|
|
17
|
+
* deliberately, so it can be safely imported + exercised under DIFFERENT
|
|
18
|
+
* `@harperfast/harper` mocks from multiple test/unit/ files in the same bun
|
|
19
|
+
* process without the class-capture collision documented in
|
|
20
|
+
* test/unit/memory-soul-read-gate.test.ts (that collision is specific to a
|
|
21
|
+
* class whose `extends` clause evaluates `databases.flair.X` at module-eval
|
|
22
|
+
* time; a plain function that reads `databases` inside its body at CALL time
|
|
23
|
+
* does not have that problem).
|
|
24
|
+
*
|
|
25
|
+
* ── The model (private | shared) ─────────────────────────────────────────────
|
|
26
|
+
* `Memory.visibility` is writer intent: "private" (owner-only) or "shared"
|
|
27
|
+
* (visible to anyone holding a read/search MemoryGrant on the owner). A
|
|
28
|
+
* reader's full read-scope is:
|
|
29
|
+
* - ALL of the reader's own records, any visibility, unrestricted.
|
|
30
|
+
* - A GRANTED owner's records EXCEPT that owner's `private` ones.
|
|
31
|
+
*
|
|
32
|
+
* ── The migration invariant (non-negotiable) ─────────────────────────────────
|
|
33
|
+
* Existing memories (written before this field existed) have NO `visibility`
|
|
34
|
+
* field. They must read EXACTLY as before: to whoever holds a grant today.
|
|
35
|
+
* So a record with no `visibility` field is treated as "shared" — this is why
|
|
36
|
+
* the exclusion condition is `visibility != 'private'` (`not_equal`, which
|
|
37
|
+
* INCLUDES records missing the field entirely), never `visibility == 'shared'`
|
|
38
|
+
* (`equals`, which would EXCLUDE them and silently break every existing grant
|
|
39
|
+
* relationship — nothing is retroactively made private, nothing broadened).
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* Owner ids a non-admin agent may READ: itself, plus any owner who has
|
|
43
|
+
* granted it a "read" or "search" scoped MemoryGrant. This is the pre-existing
|
|
44
|
+
* owner-set resolution (unchanged in shape/behavior) — resolveReadScope()
|
|
45
|
+
* below builds the full private-exclusion-aware scope on top of it.
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveAllowedOwners(authAgentId) {
|
|
48
|
+
const allowedOwners = [authAgentId];
|
|
49
|
+
try {
|
|
50
|
+
for await (const grant of databases.flair.MemoryGrant.search({
|
|
51
|
+
conditions: [{ attribute: "granteeId", comparator: "equals", value: authAgentId }],
|
|
52
|
+
})) {
|
|
53
|
+
if (grant.ownerId && (grant.scope === "read" || grant.scope === "search")) {
|
|
54
|
+
allowedOwners.push(grant.ownerId);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch { /* MemoryGrant table not yet populated — ignore */ }
|
|
59
|
+
return allowedOwners;
|
|
60
|
+
}
|
|
61
|
+
const PRIVATE = "private";
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the full read-scope (owner set + condition + in-process predicate)
|
|
64
|
+
* for a reader. This is the ONE function every cross-agent Memory read path
|
|
65
|
+
* must call — see the module doc above.
|
|
66
|
+
*/
|
|
67
|
+
export async function resolveReadScope(authAgentId) {
|
|
68
|
+
const allowedOwners = await resolveAllowedOwners(authAgentId);
|
|
69
|
+
const grantedOwners = allowedOwners.filter((id) => id !== authAgentId);
|
|
70
|
+
const selfCondition = { attribute: "agentId", comparator: "equals", value: authAgentId };
|
|
71
|
+
let condition;
|
|
72
|
+
if (grantedOwners.length === 0) {
|
|
73
|
+
// No grants held — the reader's scope is just its own records. Emitting
|
|
74
|
+
// the plain leaf condition here (rather than an `or` with a single
|
|
75
|
+
// branch) keeps the common case's query shape identical to what
|
|
76
|
+
// Memory.search() emitted before this change.
|
|
77
|
+
condition = selfCondition;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const grantedOwnerCondition = grantedOwners.length === 1
|
|
81
|
+
? { attribute: "agentId", comparator: "equals", value: grantedOwners[0] }
|
|
82
|
+
: { operator: "or", conditions: grantedOwners.map((id) => ({ attribute: "agentId", comparator: "equals", value: id })) };
|
|
83
|
+
condition = {
|
|
84
|
+
operator: "or",
|
|
85
|
+
conditions: [
|
|
86
|
+
selfCondition,
|
|
87
|
+
{
|
|
88
|
+
operator: "and",
|
|
89
|
+
conditions: [
|
|
90
|
+
grantedOwnerCondition,
|
|
91
|
+
// not_equal (NOT equals 'shared') — see module doc: a record with
|
|
92
|
+
// NO visibility field must still read as shared. This is the
|
|
93
|
+
// migration-equivalence invariant, enforced in the condition
|
|
94
|
+
// itself so every path that uses it gets it for free.
|
|
95
|
+
{ attribute: "visibility", comparator: "not_equal", value: PRIVATE },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const grantedSet = new Set(grantedOwners);
|
|
102
|
+
const isAllowed = (record) => {
|
|
103
|
+
if (!record)
|
|
104
|
+
return false;
|
|
105
|
+
if (record.agentId === authAgentId)
|
|
106
|
+
return true;
|
|
107
|
+
if (!record.agentId || !grantedSet.has(record.agentId))
|
|
108
|
+
return false;
|
|
109
|
+
return record.visibility !== PRIVATE;
|
|
110
|
+
};
|
|
111
|
+
return { allowedOwners, condition, isAllowed };
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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",
|