@tpsdev-ai/flair 0.19.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 only ever loads the caller's own memories, so this is the one
19
- * place agents learn teammates' findings exist without a separate call)
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
- function formatMemory(m, supersedes) {
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 base = `${tag} ${m.content}${date}${chain}`;
41
- // Wrap flagged memories in safety delimiters
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
- // Bootstrap only ever loads the caller's OWN soul/memories (every query
182
- // below filters record.agentId === agentId). Nothing here tells the agent
183
- // that teammates exist or that their findings are one memory_search away
184
- // so agents never think to check before re-investigating something a
185
- // teammate already solved. This section is fixed-cost (no query text to
186
- // format per agent) so it's cheap enough to always include, not budgeted.
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.agentId !== agentId)
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
- allMemories.push(record);
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
- const permanent = activeMemories.filter((m) => m.durability === "permanent");
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 = activeMemories
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 = activeMemories
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
- sections.relevant.push(line);
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 "./b64.js";
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 protection ──────────────────────────────────────────────────
35
- const nonceSeen = new Map();
36
- function pruneNonces() {
37
- const now = Date.now();
38
- for (const [k, ts] of nonceSeen.entries()) {
39
- if (now - ts > WINDOW_MS)
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
- pruneNonces();
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
- nonceSeen.set(nonceKey, ts);
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 + office-visible +
32
- // granted memories). Without this, Harper's default denies the POST for the
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
- const searchAgentIds = new Set();
81
- if (agentId)
82
- searchAgentIds.add(agentId);
83
- if (agentId) {
84
- try {
85
- for await (const grant of databases.flair.MemoryGrant.search({
86
- conditions: [{ attribute: "granteeId", comparator: "equals", value: agentId }],
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: filter to allowed agent IDs or office-visible memories
139
- if (searchAgentIds.size === 1) {
140
- const [id] = searchAgentIds;
141
- conditions.push({
142
- operator: "or",
143
- conditions: [
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
- const semanticScore = distanceToSimilarity(record.$distance ?? 1);
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 "./b64.js";
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
- // Replay protection: remember recently-seen (agent:nonce), pruned by the window.
29
- const nonceSeen = new Map();
30
- // ─── Crypto helpers ───────────────────────────────────────────────────────────
31
- // b64ToArrayBuffer lives in ./b64.ts (shared with auth-middleware.ts + Presence.ts
32
- // so the base64/base64url decoder can't drift across the three auth call sites).
33
- const keyCache = new Map();
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
- // Prune expired nonces, then reject replays within the window.
93
- for (const [k, t] of nonceSeen)
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
- nonceSeen.set(nonceKey, ts);
91
+ recordNonce(agentId, nonce, ts);
116
92
  return { agentId, isAdmin: await isAdmin(agentId) };
117
93
  }
118
94
  /**