@tpsdev-ai/flair 0.20.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +39 -11
  2. package/SECURITY.md +52 -20
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +2235 -260
  5. package/dist/deploy.js +96 -15
  6. package/dist/doctor-client.js +312 -0
  7. package/dist/fleet-presence.js +98 -0
  8. package/dist/fleet-verify.js +291 -0
  9. package/dist/install/clients.js +18 -0
  10. package/dist/mcp-client-assertion.js +364 -0
  11. package/dist/probe.js +97 -0
  12. package/dist/rem/restore.js +1 -1
  13. package/dist/rem/runner.js +82 -12
  14. package/dist/resources/Admin.js +1 -1
  15. package/dist/resources/AdminConnectors.js +1 -1
  16. package/dist/resources/AdminDashboard.js +1 -1
  17. package/dist/resources/AdminIdp.js +1 -1
  18. package/dist/resources/AdminInstance.js +1 -1
  19. package/dist/resources/AdminMemory.js +2 -2
  20. package/dist/resources/AdminPrincipals.js +1 -1
  21. package/dist/resources/Agent.js +14 -0
  22. package/dist/resources/AgentCard.js +1 -1
  23. package/dist/resources/AgentSeed.js +1 -1
  24. package/dist/resources/AttentionQuery.js +356 -0
  25. package/dist/resources/Credential.js +11 -1
  26. package/dist/resources/Federation.js +121 -2
  27. package/dist/resources/Integration.js +1 -1
  28. package/dist/resources/MCPClientMetadata.js +88 -0
  29. package/dist/resources/Memory.js +127 -22
  30. package/dist/resources/MemoryBootstrap.js +441 -85
  31. package/dist/resources/MemoryGrant.js +1 -1
  32. package/dist/resources/MemoryReflect.js +105 -49
  33. package/dist/resources/MemoryUsage.js +104 -0
  34. package/dist/resources/OAuth.js +69 -5
  35. package/dist/resources/OrgEvent.js +11 -0
  36. package/dist/resources/OrgEventCatchup.js +1 -1
  37. package/dist/resources/Presence.js +268 -17
  38. package/dist/resources/RecordUsage.js +230 -0
  39. package/dist/resources/Relationship.js +108 -26
  40. package/dist/resources/SemanticSearch.js +88 -320
  41. package/dist/resources/SkillScan.js +15 -0
  42. package/dist/resources/Soul.js +14 -0
  43. package/dist/resources/WorkspaceLatest.js +1 -1
  44. package/dist/resources/WorkspaceState.js +12 -1
  45. package/dist/resources/agent-auth.js +61 -3
  46. package/dist/resources/agentcard-fields.js +2 -2
  47. package/dist/resources/auth-middleware.js +42 -14
  48. package/dist/resources/bm25-filter.js +1 -1
  49. package/dist/resources/bm25.js +13 -7
  50. package/dist/resources/collision-lib.js +157 -0
  51. package/dist/resources/dedup.js +2 -2
  52. package/dist/resources/ed25519-auth.js +2 -2
  53. package/dist/resources/embeddings-boot.js +149 -0
  54. package/dist/resources/embeddings-provider.js +364 -106
  55. package/dist/resources/entity-vocab.js +139 -0
  56. package/dist/resources/federation-nonce-store.js +195 -0
  57. package/dist/resources/health.js +97 -6
  58. package/dist/resources/instance-identity.js +53 -0
  59. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  60. package/dist/resources/mcp-handler.js +1 -1
  61. package/dist/resources/mcp-tools.js +88 -10
  62. package/dist/resources/memory-bootstrap-lib.js +1 -1
  63. package/dist/resources/memory-read-scope.js +58 -71
  64. package/dist/resources/memory-reflect-lib.js +289 -0
  65. package/dist/resources/memory-visibility.js +37 -0
  66. package/dist/resources/migration-boot.js +143 -0
  67. package/dist/resources/migrations/dir-safety.js +71 -0
  68. package/dist/resources/migrations/embedding-stamp.js +178 -0
  69. package/dist/resources/migrations/envelope.js +43 -0
  70. package/dist/resources/migrations/export.js +38 -0
  71. package/dist/resources/migrations/ledger.js +55 -0
  72. package/dist/resources/migrations/lock.js +154 -0
  73. package/dist/resources/migrations/progress.js +31 -0
  74. package/dist/resources/migrations/registry.js +36 -0
  75. package/dist/resources/migrations/risk-policy.js +23 -0
  76. package/dist/resources/migrations/runner.js +456 -0
  77. package/dist/resources/migrations/snapshot.js +127 -0
  78. package/dist/resources/migrations/source-fields.js +93 -0
  79. package/dist/resources/migrations/space.js +73 -0
  80. package/dist/resources/migrations/state.js +64 -0
  81. package/dist/resources/migrations/status.js +39 -0
  82. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  83. package/dist/resources/migrations/types.js +9 -0
  84. package/dist/resources/presence-internal.js +29 -0
  85. package/dist/resources/provenance.js +50 -0
  86. package/dist/resources/rate-limiter.js +8 -1
  87. package/dist/resources/scoring.js +124 -5
  88. package/dist/resources/semantic-retrieval-core.js +317 -0
  89. package/dist/version-check.js +167 -0
  90. package/dist/version-handshake.js +122 -0
  91. package/package.json +4 -5
  92. package/schemas/agent.graphql +7 -0
  93. package/schemas/event.graphql +5 -0
  94. package/schemas/federation.graphql +12 -0
  95. package/schemas/memory.graphql +82 -0
  96. package/schemas/schema.graphql +5 -43
  97. package/schemas/workspace.graphql +3 -0
  98. package/dist/resources/IngestEvents.js +0 -162
  99. package/dist/resources/IssueTokens.js +0 -19
  100. package/dist/resources/ObsAgentSnapshot.js +0 -13
  101. package/dist/resources/ObsEventFeed.js +0 -13
  102. package/dist/resources/ObsOffice.js +0 -19
  103. package/dist/resources/ObservationCenter.js +0 -23
  104. package/ui/observation-center.html +0 -385
@@ -1,9 +1,20 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
- import { allowVerified } from "./agent-auth.js";
2
+ import { allowVerified, resolveAgentAuth } 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
6
  import { resolveReadScope } from "./memory-read-scope.js";
7
+ import { isValidEntity } from "./entity-vocab.js";
8
+ import { withDetachedTxn } from "./table-helpers.js";
9
+ import { getPresenceRoster } from "./presence-internal.js";
10
+ import { buildCollisionEntries, buildEntityMatchCondition, freshPresenceByAgent, } from "./collision-lib.js";
11
+ // The bounded HNSW candidate-pool retrieval for the task-relevant/teammate/
12
+ // collision surfaces (flair-bootstrap-scale-fix) — the SAME pure core
13
+ // SemanticSearch.ts's post() wraps, called bare here so an internal
14
+ // bootstrap call never trips SemanticSearch's rate-limit/reranker/
15
+ // retrievalCount hit-tracking side effects (see resources/
16
+ // semantic-retrieval-core.ts's module doc for the full boundary).
17
+ import { retrieveCandidates } from "./semantic-retrieval-core.js";
7
18
  /**
8
19
  * POST /MemoryBootstrap
9
20
  *
@@ -14,17 +25,31 @@ import { resolveReadScope } from "./memory-read-scope.js";
14
25
  * 3. Recent memories (adaptive window)
15
26
  * 4. Task-relevant memories (semantic search if currentTask provided)
16
27
  * 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)
28
+ * task-relevant set as #4, split by origin: any other in-org agent's
29
+ * NON-PRIVATE memory that scores against currentTask lands here instead
30
+ * of #4, attributed via "[via <agentId>]" no MemoryGrant required
31
+ * (open-within-org read, per #578). Presentation only — what's readable
32
+ * is entirely resolveReadScope()'s job; this only changes how an
33
+ * already-read cross-agent record is formatted/sectioned)
22
34
  * 5. Relationship context (active relationships for mentioned entities)
23
35
  * 6. Predicted context (based on channel/surface/subject hints)
24
36
  * 7. Team roster (other active agents in this office + a search-first nudge —
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)
37
+ * bootstrap loads the caller's own memories plus every other in-org
38
+ * agent's non-private memories (open-within-org read, never anyone's
39
+ * private ones), so this section nudges toward memory_search for
40
+ * anything beyond that window)
41
+ * 8. Others in the room (flair#681, the attention-plane flagship —
42
+ * collision surfacing): joins two independently-scoped surfaces —
43
+ * WorkspaceState/OrgEvent entity overlap (exact vocabulary-string
44
+ * match against the caller's OWN declared `entities`, read via the
45
+ * SAME internal server-side path #678's AttentionQuery established —
46
+ * never broadening WorkspaceState's per-agent read model) and the
47
+ * semantic teammate-Memory matches #550 (above, 4b) ALREADY computed
48
+ * (no new embedding code — Memory is the semantic surface, WorkspaceState/
49
+ * OrgEvent are the entity surface, per the K&S verdict). Gated on
50
+ * freshness (Presence, via the SAME internal roster path, never the raw
51
+ * table) and #550's existing relevance floor. See resources/
52
+ * collision-lib.ts for the pure join/rank/format logic.
28
53
  *
29
54
  * Prediction: when context signals (channel, surface, subjects) are provided,
30
55
  * the bootstrap loads more aggressively — Flair is fast enough that the
@@ -32,11 +57,51 @@ import { resolveReadScope } from "./memory-read-scope.js";
32
57
  *
33
58
  * Request:
34
59
  * { agentId, currentTask?, maxTokens?, includeSoul?, since?,
35
- * channel?, surface?, subjects? }
60
+ * channel?, surface?, subjects?, entities? }
61
+ * `entities` (flair#681): the caller's own declared attention-plane
62
+ * vocabulary strings (see resources/entity-vocab.ts) for collision
63
+ * surfacing's entity-overlap join. Invalid entries are silently dropped
64
+ * (never a 400 — this is an optional awareness hint, not a write path).
65
+ * When omitted, falls back to the caller's own most-recent WorkspaceState
66
+ * row's `entities`.
36
67
  *
37
68
  * Response:
38
69
  * { context, sections, tokenEstimate, memoriesIncluded, memoriesAvailable }
39
70
  */
71
+ // Collision surfacing (flair#681) tunables.
72
+ const COLLISION_WINDOW_DAYS = 7;
73
+ const MAX_COLLISION_ENTRIES = 10;
74
+ // ─── Bootstrap scale fix (flair-bootstrap-scale-fix) tunables ───────────────
75
+ //
76
+ // Own-scoped, non-permanent memories (the "recent" adaptive-window source,
77
+ // ALSO reused as the "predicted" subject-match source — see the fetch below)
78
+ // are pulled bounded + createdAt-desc instead of the full org corpus. 500 is
79
+ // a generous ceiling: recent's own display is budget-limited (40% of
80
+ // remaining tokenBudget, in practice a handful of lines) and predicted's
81
+ // subject match is a narrow filter over the same set — an agent with more
82
+ // than 500 non-permanent memories in total would only miss an
83
+ // older-than-the-500th subject-tagged predicted candidate, a theoretical
84
+ // edge case traded for turning an O(org) scan into an O(own) bounded seek.
85
+ // If the recall harness ever shows this bound is too tight, widen it —
86
+ // never reintroduce the unbounded org-wide load.
87
+ const OWN_NONPERMANENT_FETCH_LIMIT = 500;
88
+ // Candidate-pool K formula (Kern-approved, K&S verdict on
89
+ // FLAIR-BOOTSTRAP-SCALE-FIX.md): K = max(3 × expected fill count,
90
+ // 5 × teammate count, MIN_CANDIDATE_POOL), capped at MAX_CANDIDATE_POOL.
91
+ // "Expected fill count" estimates how many formatted memory lines could fit
92
+ // in the remaining token budget (AVG_LINE_TOKEN_ESTIMATE is deliberately
93
+ // generous/low so the estimate — and thus K — errs LARGE, never small). The
94
+ // greedy token-budget fill loop AND collision's "one top cross-agent hit per
95
+ // teammate" (flair#681) both draw from this SAME pool, so it needs depth for
96
+ // both. If the recall harness ever shows a delta, widen K — never add a
97
+ // second scan (Kern's explicit instruction).
98
+ const AVG_LINE_TOKEN_ESTIMATE = 60;
99
+ const MIN_CANDIDATE_POOL = 50;
100
+ const MAX_CANDIDATE_POOL = 100;
101
+ // Bootstrap's own historical relevance floor (distinct from SemanticSearch's
102
+ // `minScore` request param) — preserved verbatim from the original raw
103
+ // JS dot-product scan's `.filter((s) => s.score > 0.3)`.
104
+ const TASK_RELEVANCE_FLOOR = 0.3;
40
105
  // Rough token estimate: ~4 chars per token for English text
41
106
  function estimateTokens(text) {
42
107
  return Math.ceil(text.length / 4);
@@ -44,10 +109,11 @@ function estimateTokens(text) {
44
109
  // `agentId` is the BOOTSTRAPPING agent (the caller) — used only to decide
45
110
  // whether to annotate attribution, never to change what's read (that
46
111
  // 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.
112
+ // cross-agent record always carries `_source` (tagged by
113
+ // retrieveCandidates() see resources/semantic-retrieval-core.tswhen the
114
+ // record's own agentId differs from the bootstrapping agent), so
115
+ // `m._source !== agentId` is the "is this a teammate's finding" check; own
116
+ // memories never carry `_source` at all.
51
117
  function formatMemory(m, agentId) {
52
118
  const tag = m.durability === "permanent" ? "🔒" : m.durability === "persistent" ? "📌" : "📝";
53
119
  const date = m.createdAt ? ` (${m.createdAt.slice(0, 10)})` : "";
@@ -110,6 +176,7 @@ export class BootstrapMemories extends Resource {
110
176
  relationships: [],
111
177
  relevant: [],
112
178
  teammate: [],
179
+ collision: [],
113
180
  events: [],
114
181
  };
115
182
  let tokenBudget = maxTokens;
@@ -199,9 +266,10 @@ export class BootstrapMemories extends Resource {
199
266
  }
200
267
  // --- 1c. Team roster + cross-agent search nudge ---
201
268
  // 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.
269
+ // (step 2) now also includes every other in-org agent's non-private
270
+ // memories (open-within-org read, no MemoryGrant needed #578) — but
271
+ // this section stays: memory_search/SemanticSearch remains the
272
+ // deliberate, query-driven way to find a teammate's finding, vs.
205
273
  // bootstrap's fixed recent/permanent window. This section is fixed-cost
206
274
  // (no query text to format per agent) so it's cheap enough to always
207
275
  // include, not budgeted.
@@ -211,8 +279,13 @@ export class BootstrapMemories extends Resource {
211
279
  // records missing either field are legacy agents/active — a strict
212
280
  // `!== "agent"` check would silently drop them. Assumes single-tenant
213
281
  // (one instance = one office); grant-filtered roster is the multi-tenant follow-up.
282
+ // Hoisted out of the try block below (not just team-roster-local) — the
283
+ // task-relevant candidate pool's K formula (flair-bootstrap-scale-fix)
284
+ // needs the teammate count too. Stays `[]` on an Agent.search() failure
285
+ // (older/standalone deployments without the table), which K's formula
286
+ // tolerates fine (falls back to its other terms).
287
+ let teammateIds = [];
214
288
  try {
215
- const teammateIds = [];
216
289
  for await (const record of databases.flair.Agent.search()) {
217
290
  if (isTeammate(record, agentId))
218
291
  teammateIds.push(record.id);
@@ -224,59 +297,104 @@ export class BootstrapMemories extends Resource {
224
297
  catch {
225
298
  // Agent table may not exist in older / standalone deployments
226
299
  }
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.
300
+ // ─── Read-scope (flair-bootstrap-scale-fix) ─────────────────────────────
301
+ // Read-scope: own (any visibility) + every OTHER in-org agent's
302
+ // non-private memoryopen-within-org read (#578), no MemoryGrant
303
+ // consulted at all. Centralized in resolveReadScope(): `scope.condition`
304
+ // is pushed into every Harper query below (so the table itself never
305
+ // returns an out-of-scope row), and `scope.isAllowed` re-checks
306
+ // in-process as defense-in-depth on every candidate (Sherlock's
307
+ // non-negotiablethe pushdown condition is the primary gate, this is
308
+ // the belt) this is the #550 foundation: bootstrap can now safely
309
+ // expand beyond own-only without a parallel scoping rule, and that rule
310
+ // tracks resolveReadScope()'s model automatically (this file never
311
+ // re-implements the rule, so it never has to change when the rule does).
312
+ //
313
+ // The org-wide "load everything, then filter/scan in JS" this section
314
+ // used to do (`Memory.search({conditions:[scope.condition]})`, no
315
+ // limit/select — every row's full embedding vector dragged into RAM on
316
+ // every bootstrap) is replaced by targeted, bounded queries per
317
+ // consumer: own-scoped pushdowns for the permanent/recent/predicted
318
+ // lifecycle slices below (agentId==self — strictly NARROWER than the
319
+ // full open-within-org scope, so no filter is dropped), a cheap
320
+ // own-scoped count for `memoriesAvailable`, and a bounded HNSW candidate
321
+ // pool (via retrieveCandidates(), further down) for the task-relevant/
322
+ // teammate/collision surfaces — the only consumer that legitimately
323
+ // spans the org. Each bounded query still re-checks `scope.isAllowed()`
324
+ // on every record even where it's provably a no-op (e.g. an
325
+ // agentId==self-only query) — uniform defense-in-depth, never skipped
326
+ // because "the filter already pushed down."
236
327
  const scope = await resolveReadScope(agentId);
237
- const allMemories = [];
238
- for await (const record of databases.flair.Memory.search({ conditions: [scope.condition] })) {
328
+ // `memoriesAvailable`: dropped the org-wide exact count (computing it
329
+ // exactly WAS the scan being removed `visibility != private` isn't
330
+ // index-seekable, so even a bare count would scan). Replaced with the
331
+ // own-scoped count (`agentId==self`, cheap indexed seek) — a more
332
+ // meaningful "how much do I actually have" figure, and O(own) not
333
+ // O(org). Cosmetic change, called out to K&S in the spec.
334
+ let ownMemoriesAvailable = 0;
335
+ const availabilityRows = withDetachedTxn(ctx, () => databases.flair.Memory.search({
336
+ conditions: [{ attribute: "agentId", comparator: "equals", value: agentId }],
337
+ select: ["id", "expiresAt", "validTo"],
338
+ }));
339
+ for await (const record of availabilityRows) {
239
340
  if (!scope.isAllowed(record))
240
341
  continue;
241
342
  if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
242
343
  continue;
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
344
  if (record.validTo && Date.parse(record.validTo) < Date.now())
252
345
  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);
346
+ ownMemoriesAvailable++;
261
347
  }
262
- memoriesAvailable = allMemories.length;
263
- // Build superseded set: exclude memories that have been replaced by newer ones
264
- const supersededIds = new Set();
265
- for (const m of allMemories) {
266
- if (m.supersedes)
267
- supersededIds.add(m.supersedes);
348
+ memoriesAvailable = ownMemoriesAvailable;
349
+ // Fields every own-scoped lifecycle slice below needs explicit (no raw
350
+ // `embedding`), matching the "select, no raw embedding" pushdown
351
+ // requirement.
352
+ const OWN_SELECT = ["id", "agentId", "content", "durability", "createdAt", "supersedes", "subject", "validTo", "expiresAt", "_safetyFlags"];
353
+ // --- 2. Permanent memories (always included, highest priority) ---
354
+ // Own-scoped pushdown: `agentId==self` + `durability==permanent`, both
355
+ // @indexed (a seek, not a scan) — strictly narrower than the prior
356
+ // load-then-filter (own records are always visible to their own agent
357
+ // regardless of visibility, so agentId==self alone is the correct,
358
+ // no-filter-dropped condition here; no other agent's data enters this
359
+ // query at all).
360
+ const permanentRows = [];
361
+ const permanentQuery = withDetachedTxn(ctx, () => databases.flair.Memory.search({
362
+ conditions: [
363
+ { attribute: "agentId", comparator: "equals", value: agentId },
364
+ { attribute: "durability", comparator: "equals", value: "permanent" },
365
+ ],
366
+ select: OWN_SELECT,
367
+ }));
368
+ for await (const record of permanentQuery) {
369
+ if (!scope.isAllowed(record))
370
+ continue;
371
+ if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
372
+ continue;
373
+ // A past validTo ALWAYS means the record has been closed out (server
374
+ // supersede path — Memory.ts closeSupersededRecord — sets validTo
375
+ // without necessarily setting `archived`), same root cause and fix as
376
+ // SemanticSearch.ts's unconditional past-validTo exclusion.
377
+ // Unconditional so a server-superseded record can't resurface just
378
+ // because its successor isn't co-present in THIS bounded set (the
379
+ // per-set supersededIds filter below only catches co-presence).
380
+ if (record.validTo && Date.parse(record.validTo) < Date.now())
381
+ continue;
382
+ permanentRows.push(record);
268
383
  }
269
- const activeMemories = allMemories.filter((m) => !supersededIds.has(m.id));
270
- // #550 design boundary: the permanent / recent / predicted sections are the
271
- // agent's OWN working contextown-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");
384
+ // Per-set supersededIds (flair-bootstrap-scale-fix, Kern-approved
385
+ // narrowing): computed from THIS bounded set alone, never cross-applied
386
+ // to the recent/predicted sets or the candidate pool below each is
387
+ // independent. The uncovered case (predecessor and successor landing in
388
+ // DIFFERENT bounded sets) is a theoretical gap already covered by the
389
+ // unconditional past-validTo guard above (the primary supersede
390
+ // defense); this co-presence check is a secondary belt, same as before
391
+ // this refactor, just now scoped per-set instead of per the old
392
+ // org-wide load.
393
+ const permanentSupersededIds = new Set();
394
+ for (const m of permanentRows)
395
+ if (m.supersedes)
396
+ permanentSupersededIds.add(m.supersedes);
397
+ const permanent = permanentRows.filter((m) => !permanentSupersededIds.has(m.id));
280
398
  for (const m of permanent) {
281
399
  const line = formatMemory(m, agentId);
282
400
  const cost = estimateTokens(line);
@@ -290,10 +408,43 @@ export class BootstrapMemories extends Resource {
290
408
  }
291
409
  }
292
410
  // --- 3. Recent memories (adaptive window) ---
411
+ // Own-scoped, non-permanent, bounded + createdAt-desc pushdown (agentId
412
+ // and durability are both @indexed) — replaces the org-wide load's
413
+ // post-hoc JS filter. This SAME fetched set also feeds "predicted"
414
+ // (3b, below): both draw from "my own non-permanent memories" bounded to
415
+ // OWN_NONPERMANENT_FETCH_LIMIT (see that constant's doc for the bound's
416
+ // rationale) — same shared-source relationship the pre-refactor code
417
+ // had via `ownMemories`, just bounded now instead of org-wide.
418
+ const nonPermanentRows = [];
419
+ const nonPermanentQuery = withDetachedTxn(ctx, () => databases.flair.Memory.search({
420
+ conditions: [
421
+ { attribute: "agentId", comparator: "equals", value: agentId },
422
+ { attribute: "durability", comparator: "not_equal", value: "permanent" },
423
+ ],
424
+ select: OWN_SELECT,
425
+ sort: { attribute: "createdAt", descending: true },
426
+ limit: OWN_NONPERMANENT_FETCH_LIMIT,
427
+ }));
428
+ for await (const record of nonPermanentQuery) {
429
+ if (!scope.isAllowed(record))
430
+ continue;
431
+ if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
432
+ continue;
433
+ if (record.validTo && Date.parse(record.validTo) < Date.now())
434
+ continue;
435
+ nonPermanentRows.push(record);
436
+ }
437
+ // Per-set supersededIds — independent of `permanentSupersededIds` above
438
+ // (see that block's doc for the full per-set rationale).
439
+ const nonPermanentSupersededIds = new Set();
440
+ for (const m of nonPermanentRows)
441
+ if (m.supersedes)
442
+ nonPermanentSupersededIds.add(m.supersedes);
443
+ const nonPermanentActive = nonPermanentRows.filter((m) => !nonPermanentSupersededIds.has(m.id));
293
444
  // Start with 48h. If nothing found, widen to 7d, then 30d.
294
445
  // This prevents empty recent sections for agents that were idle.
295
- const nonPermanent = ownMemories
296
- .filter((m) => m.durability !== "permanent" && m.createdAt)
446
+ const nonPermanent = nonPermanentActive
447
+ .filter((m) => m.createdAt)
297
448
  .sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
298
449
  let effectiveSince;
299
450
  if (since) {
@@ -338,7 +489,13 @@ export class BootstrapMemories extends Resource {
338
489
  ...permanent.map((m) => m.id),
339
490
  ...recent.filter((_, i) => i < sections.recent.length).map((m) => m.id),
340
491
  ]);
341
- const subjectMemories = ownMemories
492
+ // Draws from the SAME bounded own-scoped, non-permanent set "recent"
493
+ // uses (nonPermanentActive — see that fetch's doc above for the
494
+ // shared-source rationale and OWN_NONPERMANENT_FETCH_LIMIT's bound).
495
+ // `durability !== "permanent"` is now redundant with the source
496
+ // query's own condition but kept for parity/clarity with the
497
+ // pre-refactor filter shape.
498
+ const subjectMemories = nonPermanentActive
342
499
  .filter((m) => !includedIds.has(m.id) &&
343
500
  m.subject &&
344
501
  predictedSubjects.includes(m.subject.toLowerCase()) &&
@@ -394,11 +551,21 @@ export class BootstrapMemories extends Resource {
394
551
  // Relationship table may not exist yet
395
552
  }
396
553
  }
554
+ // Collision surfacing's semantic-match candidates (flair#681) — the
555
+ // BEST (highest-scoring) cross-agent memory per teammate from #550's
556
+ // `scored` list below, captured here (before that list's tokens get
557
+ // spent on the relevant/teammate sections) so the collision block can
558
+ // reuse the IDENTICAL scored+floor-gated set without recomputing or
559
+ // re-embedding anything. Stays empty when there's no currentTask (no
560
+ // `scored` list is ever built) or no cross-agent hits.
561
+ const semanticTeammateMatches = [];
397
562
  // --- 4. Task-relevant memories (semantic search) ---
398
563
  if (currentTask && tokenBudget > 200) {
399
564
  let queryEmbedding = null;
400
565
  try {
401
- queryEmbedding = await getEmbedding(currentTask);
566
+ // flair#504 Phase 2: 'query' — currentTask is the bootstrap's
567
+ // task-relevance search query, not stored content.
568
+ queryEmbedding = await getEmbedding(currentTask, "query");
402
569
  }
403
570
  catch { }
404
571
  if (queryEmbedding) {
@@ -407,25 +574,79 @@ export class BootstrapMemories extends Resource {
407
574
  ...permanent.map((m) => m.id),
408
575
  ...recent.filter((_, i) => i < sections.recent.length).map((m) => m.id),
409
576
  ]);
410
- const scored = allMemories
411
- .filter((m) => !includedIds.has(m.id) && !supersededIds.has(m.id) && m.embedding?.length > 100)
412
- .map((m) => {
413
- let dot = 0;
414
- const len = Math.min(queryEmbedding.length, m.embedding.length);
415
- for (let i = 0; i < len; i++)
416
- dot += queryEmbedding[i] * m.embedding[i];
417
- return { memory: m, score: dot };
418
- })
419
- .filter((s) => s.score > 0.3)
420
- .sort((a, b) => b.score - a.score);
577
+ // Bounded HNSW candidate pool (flair-bootstrap-scale-fix) — replaces
578
+ // the full-corpus JS dot-product scan (`allMemories` × queryEmbedding,
579
+ // O(org corpus size) every bootstrap). K formula (Kern-approved):
580
+ // max(3 × expected fill, 5 × teammate count, MIN_CANDIDATE_POOL),
581
+ // capped at MAX_CANDIDATE_POOL — deep enough for BOTH the
582
+ // token-budget fill loop below AND collision's "one top cross-agent
583
+ // hit per teammate" (flair#681), which draws from the SAME pool. If
584
+ // the recall harness ever shows a delta, widen K — never add a
585
+ // second scan (Kern's explicit instruction).
586
+ const expectedFill = Math.max(1, Math.ceil(tokenBudget / AVG_LINE_TOKEN_ESTIMATE));
587
+ const candidatePoolK = Math.min(MAX_CANDIDATE_POOL, Math.max(3 * expectedFill, 5 * teammateIds.length, MIN_CANDIDATE_POOL));
588
+ const candidates = await retrieveCandidates({
589
+ queryEmbedding,
590
+ conditions: [scope.condition],
591
+ limit: candidatePoolK,
592
+ // HNSW-leg pushdown ONLY (K&S verdict): no BM25 fusion, no
593
+ // reranker for bootstrap — a different cost profile (BM25 over the
594
+ // org corpus for a one-shot session-load could be MORE expensive
595
+ // than HNSW-only unless cached across sessions; the reranker is a
596
+ // generative call per candidate). Both are explicit opt-in
597
+ // follow-ons, gated on their own harness runs.
598
+ hybrid: false,
599
+ // Per-set (this K-bounded pool only, never cross-applied to the
600
+ // permanent/recent/predicted sets above) — see this function's
601
+ // supersededIds docs above and resources/
602
+ // semantic-retrieval-core.ts's own doc for the full caveat. The
603
+ // unconditional past-validTo guard (inside retrieveCandidates)
604
+ // stays the primary supersede defense either way.
605
+ includeSuperseded: false,
606
+ // Matches the original raw JS dot product exactly — no
607
+ // composite/durability-recency weighting for bootstrap's own
608
+ // relevance ranking.
609
+ scoring: "raw",
610
+ agentId,
611
+ // Sherlock's non-negotiable belt: re-checked on every candidate
612
+ // even though `conditions` already scoped the query.
613
+ isAllowed: scope.isAllowed,
614
+ ctx,
615
+ });
616
+ // Preserve the ORIGINAL score > 0.3 floor exactly (bootstrap's own
617
+ // historical relevance floor — distinct from SemanticSearch's
618
+ // `minScore` request param — strict inequality, applied client-side;
619
+ // `candidates` are already `_score`-sorted best-first, so filtering
620
+ // preserves that order). `retrieveCandidates()`'s cosine similarity
621
+ // replaces the raw JS dot product as the ranking signal (HNSW-only,
622
+ // no BM25/rerank) — the K&S-ratified, closest-to-a-wash choice; the
623
+ // recall harness gates any regression from this ranking-signal
624
+ // change (magnitude-sensitive dot product → normalized cosine).
625
+ const scored = candidates
626
+ .filter((m) => !includedIds.has(m.id) && m._score > TASK_RELEVANCE_FLOOR)
627
+ .map((m) => ({ memory: m, score: m._score }));
628
+ // flair#681: the collision block's semantic surface — one candidate
629
+ // per teammate (the highest-scoring hit; `scored` is already sorted
630
+ // desc, so the first occurrence of a given `_source` IS the best
631
+ // one). `m._source` is only ever set for a cross-agent record (see
632
+ // retrieveCandidates()'s `_source` tagging) — an own memory never
633
+ // contributes here.
634
+ const seenCollisionAgents = new Set();
635
+ for (const { memory: m, score } of scored) {
636
+ if (!m._source || seenCollisionAgents.has(m._source))
637
+ continue;
638
+ seenCollisionAgents.add(m._source);
639
+ semanticTeammateMatches.push({ agentId: m._source, score, content: m.summary || m.content || "" });
640
+ }
421
641
  // #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.
642
+ // go to `relevant` as before; any other in-org agent's non-private
643
+ // record — already read-scoped by resolveReadScope(), no grant
644
+ // required (`m._source` is only ever set for a cross-agent record)
645
+ // goes to the new `teammate` section so the agent can tell it apart
646
+ // at a glance. Both draw from the SAME `tokenBudget` in one
647
+ // score-ordered pass — highest-relevance memories win the remaining
648
+ // budget regardless of which section they land in, so neither
649
+ // section double-spends.
429
650
  for (const { memory: m } of scored) {
430
651
  const line = formatMemory(m, agentId);
431
652
  const cost = estimateTokens(line);
@@ -442,6 +663,133 @@ export class BootstrapMemories extends Resource {
442
663
  }
443
664
  }
444
665
  }
666
+ // --- 4c. Collision surfacing (flair#681 — "others in the room") ---
667
+ // Joins two independently-scoped surfaces into a single ranked list:
668
+ // - Entity overlap (WorkspaceState + OrgEvent): exact vocabulary-string
669
+ // match, high-precision, no separate relevance score needed.
670
+ // - Semantic match (Memory, via #550/4 above): `semanticTeammateMatches`,
671
+ // already floor-gated (score > 0.3) — reused as-is, no new scoring.
672
+ // Gated on freshness (Presence, via the internal roster path) — a
673
+ // teammate absent from the roster, or whose presenceStatus is "offline",
674
+ // never surfaces regardless of how strong the entity/semantic match is.
675
+ // Best-effort: any failure here (WorkspaceState/OrgEvent/Presence briefly
676
+ // unavailable) must never break bootstrap's core memory context.
677
+ try {
678
+ // The caller's own declared entities: an explicit `entities` field on
679
+ // the request (validated against the SAME closed vocabulary every
680
+ // write path gates writes on — resources/entity-vocab.ts; invalid
681
+ // entries are silently dropped, not a 400, since this is an optional
682
+ // awareness hint), falling back to the caller's own most-recent
683
+ // WorkspaceState row's `entities` when not declared. Reading the
684
+ // caller's OWN WorkspaceState rows is not a scoping concern (an agent
685
+ // always has read access to its own data) — this raw read exists
686
+ // purely because MemoryBootstrap.ts already reads every other table
687
+ // (Soul/Agent/Memory/Relationship/OrgEvent) directly, the same idiom.
688
+ let callerEntities = Array.isArray(data?.entities)
689
+ ? data.entities.filter((e) => isValidEntity(e))
690
+ : [];
691
+ if (callerEntities.length === 0) {
692
+ const ownRows = withDetachedTxn(ctx, () => databases.flair.WorkspaceState.search({
693
+ conditions: [{ attribute: "agentId", comparator: "equals", value: agentId }],
694
+ select: ["entities", "timestamp"],
695
+ }));
696
+ let latestEntities = [];
697
+ let latestTs = "";
698
+ for await (const row of ownRows) {
699
+ if (!Array.isArray(row.entities) || row.entities.length === 0)
700
+ continue;
701
+ if ((row.timestamp || "") > latestTs) {
702
+ latestTs = row.timestamp || "";
703
+ latestEntities = row.entities;
704
+ }
705
+ }
706
+ callerEntities = latestEntities;
707
+ }
708
+ const entityMatches = [];
709
+ if (callerEntities.length > 0) {
710
+ const sinceIso = new Date(Date.now() - COLLISION_WINDOW_DAYS * 24 * 3600_000).toISOString();
711
+ // buildEntityMatchCondition, NOT a hand-rolled OR wrapper: Harper's
712
+ // query engine throws ("An 'or' operator requires at least two
713
+ // conditions") for a single-entity OR condition — see collision-lib.ts's
714
+ // doc. A single declared entity is the common case, so this matters.
715
+ const entityCondition = buildEntityMatchCondition(callerEntities);
716
+ const byAgent = new Map();
717
+ // WorkspaceState — the INTERNAL server-side path (Sherlock Option 1,
718
+ // binding per the K&S verdict): the RAW generated table object,
719
+ // never the exported `WorkspaceState` resource class — that class's
720
+ // search() re-applies strict per-agent scoping keyed off THIS
721
+ // caller's own identity, which would just filter every teammate's
722
+ // row back out. This does NOT broaden WorkspaceState's general
723
+ // (still per-agent, still 403) read model — see resources/
724
+ // AttentionQuery.ts's module doc for the full rationale (the exact
725
+ // pattern this reuses).
726
+ const wsRows = withDetachedTxn(ctx, () => databases.flair.WorkspaceState.search({
727
+ conditions: [entityCondition, { attribute: "timestamp", comparator: "greater_than_equal", value: sinceIso }],
728
+ select: ["agentId", "entities", "summary", "taskId", "timestamp"],
729
+ }));
730
+ for await (const row of wsRows) {
731
+ if (row.agentId === agentId)
732
+ continue; // exclude self
733
+ const overlap = (Array.isArray(row.entities) ? row.entities : []).filter((e) => callerEntities.includes(e));
734
+ if (overlap.length === 0)
735
+ continue;
736
+ const candidate = {
737
+ agentId: row.agentId, entities: overlap, summary: row.summary ?? null,
738
+ taskId: row.taskId ?? null, timestamp: row.timestamp, source: "workspace",
739
+ };
740
+ const existing = byAgent.get(row.agentId);
741
+ if (!existing || existing.timestamp < candidate.timestamp)
742
+ byAgent.set(row.agentId, candidate);
743
+ }
744
+ // OrgEvent — org-open read model, no per-agent scoping to respect
745
+ // (mirrors resources/AttentionQuery.ts's queryOrgEvent).
746
+ const evRows = withDetachedTxn(ctx, () => databases.flair.OrgEvent.search({
747
+ conditions: [entityCondition, { attribute: "createdAt", comparator: "greater_than_equal", value: sinceIso }],
748
+ select: ["authorId", "entities", "summary", "createdAt", "expiresAt"],
749
+ }));
750
+ const now = Date.now();
751
+ for await (const row of evRows) {
752
+ if (row.authorId === agentId)
753
+ continue; // exclude self
754
+ if (row.expiresAt && new Date(row.expiresAt).getTime() < now)
755
+ continue;
756
+ const overlap = (Array.isArray(row.entities) ? row.entities : []).filter((e) => callerEntities.includes(e));
757
+ if (overlap.length === 0)
758
+ continue;
759
+ const candidate = {
760
+ agentId: row.authorId, entities: overlap, summary: row.summary ?? null,
761
+ taskId: null, timestamp: row.createdAt, source: "event",
762
+ };
763
+ const existing = byAgent.get(row.authorId);
764
+ if (!existing || existing.timestamp < candidate.timestamp)
765
+ byAgent.set(row.authorId, candidate);
766
+ }
767
+ entityMatches.push(...byAgent.values());
768
+ }
769
+ // Freshness gate: the SAME internal Presence roster path #678
770
+ // established (never the raw table) — see resources/
771
+ // presence-internal.ts. `resolveAgentAuth` is called independently
772
+ // here (not reusing the manual agentId-scoping derivation above,
773
+ // which is deliberately narrow per its own bug-fix comment) purely to
774
+ // build the delegation verdict this internal read needs.
775
+ const collisionAuth = await resolveAgentAuth(ctx);
776
+ const roster = await getPresenceRoster(collisionAuth);
777
+ const freshByAgent = freshPresenceByAgent(roster);
778
+ const collisionEntries = buildCollisionEntries(entityMatches, semanticTeammateMatches, freshByAgent, agentId);
779
+ for (const entry of collisionEntries.slice(0, MAX_COLLISION_ENTRIES)) {
780
+ const line = `- ${entry.line}`;
781
+ const cost = estimateTokens(line);
782
+ if (cost > tokenBudget)
783
+ continue;
784
+ sections.collision.push(line);
785
+ tokenBudget -= cost;
786
+ }
787
+ }
788
+ catch {
789
+ // Collision surfacing is best-effort awareness, never a hard
790
+ // dependency — WorkspaceState/OrgEvent/Presence being briefly
791
+ // unavailable must not break bootstrap's core memory context.
792
+ }
445
793
  // --- 5. Recent OrgEvents for this agent ---
446
794
  try {
447
795
  const eventSince = data?.lastBootAt
@@ -499,11 +847,18 @@ export class BootstrapMemories extends Resource {
499
847
  }
500
848
  // #550: teammate findings relevant to the current task — right after the
501
849
  // agent's own task-relevant knowledge. Empty section renders nothing
502
- // (no header) so a bootstrap with no grant-visible teammate findings for
850
+ // (no header) so a bootstrap with no task-relevant teammate findings for
503
851
  // this task looks exactly as it did before this feature.
504
852
  if (sections.teammate.length > 0) {
505
853
  parts.push("## Teammate findings relevant to your task\n" + sections.teammate.join("\n"));
506
854
  }
855
+ // flair#681: the attention-plane flagship — "the office moment". Empty
856
+ // section renders nothing (no header), same convention as every other
857
+ // optional section here: no entity/semantic overlap with a fresh
858
+ // teammate looks exactly like bootstrap did before this feature.
859
+ if (sections.collision.length > 0) {
860
+ parts.push("## Others in the room\n" + sections.collision.join("\n"));
861
+ }
507
862
  if (sections.events.length > 0) {
508
863
  parts.push("## Recent Org Events\n" + sections.events.join("\n"));
509
864
  }
@@ -522,6 +877,7 @@ export class BootstrapMemories extends Resource {
522
877
  relationships: sections.relationships.length,
523
878
  relevant: sections.relevant.length,
524
879
  teammate: sections.teammate.length,
880
+ collision: sections.collision.length,
525
881
  events: sections.events.length,
526
882
  },
527
883
  tokenEstimate: soulTokens + memoryTokens,