@tpsdev-ai/flair 0.21.0 → 0.22.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.
Files changed (74) hide show
  1. package/README.md +10 -7
  2. package/SECURITY.md +24 -2
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +1811 -221
  5. package/dist/deploy.js +3 -4
  6. package/dist/fleet-presence.js +98 -0
  7. package/dist/fleet-verify.js +291 -0
  8. package/dist/mcp-client-assertion.js +364 -0
  9. package/dist/probe.js +97 -0
  10. package/dist/rem/runner.js +82 -12
  11. package/dist/resources/AttentionQuery.js +356 -0
  12. package/dist/resources/Credential.js +11 -1
  13. package/dist/resources/Federation.js +23 -0
  14. package/dist/resources/MCPClientMetadata.js +88 -0
  15. package/dist/resources/Memory.js +52 -53
  16. package/dist/resources/MemoryBootstrap.js +422 -76
  17. package/dist/resources/MemoryReflect.js +105 -49
  18. package/dist/resources/MemoryUsage.js +104 -0
  19. package/dist/resources/OAuth.js +8 -1
  20. package/dist/resources/OrgEvent.js +11 -0
  21. package/dist/resources/Presence.js +218 -19
  22. package/dist/resources/RecordUsage.js +230 -0
  23. package/dist/resources/Relationship.js +98 -25
  24. package/dist/resources/SemanticSearch.js +85 -317
  25. package/dist/resources/SkillScan.js +15 -0
  26. package/dist/resources/WorkspaceState.js +11 -0
  27. package/dist/resources/agent-auth.js +60 -2
  28. package/dist/resources/auth-middleware.js +18 -9
  29. package/dist/resources/bm25.js +12 -6
  30. package/dist/resources/collision-lib.js +157 -0
  31. package/dist/resources/embeddings-boot.js +149 -0
  32. package/dist/resources/embeddings-provider.js +364 -106
  33. package/dist/resources/entity-vocab.js +139 -0
  34. package/dist/resources/health.js +97 -6
  35. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  36. package/dist/resources/mcp-handler.js +1 -1
  37. package/dist/resources/mcp-tools.js +88 -10
  38. package/dist/resources/memory-reflect-lib.js +289 -0
  39. package/dist/resources/migration-boot.js +143 -0
  40. package/dist/resources/migrations/dir-safety.js +71 -0
  41. package/dist/resources/migrations/embedding-stamp.js +178 -0
  42. package/dist/resources/migrations/envelope.js +43 -0
  43. package/dist/resources/migrations/export.js +38 -0
  44. package/dist/resources/migrations/ledger.js +55 -0
  45. package/dist/resources/migrations/lock.js +154 -0
  46. package/dist/resources/migrations/progress.js +31 -0
  47. package/dist/resources/migrations/registry.js +36 -0
  48. package/dist/resources/migrations/risk-policy.js +23 -0
  49. package/dist/resources/migrations/runner.js +455 -0
  50. package/dist/resources/migrations/snapshot.js +127 -0
  51. package/dist/resources/migrations/source-fields.js +93 -0
  52. package/dist/resources/migrations/space.js +167 -0
  53. package/dist/resources/migrations/state.js +64 -0
  54. package/dist/resources/migrations/status.js +39 -0
  55. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  56. package/dist/resources/migrations/types.js +9 -0
  57. package/dist/resources/presence-internal.js +29 -0
  58. package/dist/resources/provenance.js +50 -0
  59. package/dist/resources/rate-limiter.js +8 -1
  60. package/dist/resources/scoring.js +124 -5
  61. package/dist/resources/semantic-retrieval-core.js +317 -0
  62. package/dist/version-handshake.js +122 -0
  63. package/package.json +4 -5
  64. package/schemas/event.graphql +5 -0
  65. package/schemas/memory.graphql +66 -0
  66. package/schemas/schema.graphql +5 -43
  67. package/schemas/workspace.graphql +3 -0
  68. package/dist/resources/IngestEvents.js +0 -162
  69. package/dist/resources/IssueTokens.js +0 -19
  70. package/dist/resources/ObsAgentSnapshot.js +0 -13
  71. package/dist/resources/ObsEventFeed.js +0 -13
  72. package/dist/resources/ObsOffice.js +0 -19
  73. package/dist/resources/ObservationCenter.js +0 -23
  74. 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
  *
@@ -27,6 +38,18 @@ import { resolveReadScope } from "./memory-read-scope.js";
27
38
  * agent's non-private memories (open-within-org read, never anyone's
28
39
  * private ones), so this section nudges toward memory_search for
29
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.
30
53
  *
31
54
  * Prediction: when context signals (channel, surface, subjects) are provided,
32
55
  * the bootstrap loads more aggressively — Flair is fast enough that the
@@ -34,11 +57,51 @@ import { resolveReadScope } from "./memory-read-scope.js";
34
57
  *
35
58
  * Request:
36
59
  * { agentId, currentTask?, maxTokens?, includeSoul?, since?,
37
- * 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`.
38
67
  *
39
68
  * Response:
40
69
  * { context, sections, tokenEstimate, memoriesIncluded, memoriesAvailable }
41
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;
42
105
  // Rough token estimate: ~4 chars per token for English text
43
106
  function estimateTokens(text) {
44
107
  return Math.ceil(text.length / 4);
@@ -46,10 +109,11 @@ function estimateTokens(text) {
46
109
  // `agentId` is the BOOTSTRAPPING agent (the caller) — used only to decide
47
110
  // whether to annotate attribution, never to change what's read (that
48
111
  // boundary is resolveReadScope()'s job, upstream of this function). A
49
- // cross-agent record always carries `_source` (set once, above, when the
50
- // record's own agentId differs from the bootstrapping agent see the
51
- // allMemories loop), so `m._source !== agentId` is the "is this a
52
- // 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.
53
117
  function formatMemory(m, agentId) {
54
118
  const tag = m.durability === "permanent" ? "🔒" : m.durability === "persistent" ? "📌" : "📝";
55
119
  const date = m.createdAt ? ` (${m.createdAt.slice(0, 10)})` : "";
@@ -112,6 +176,7 @@ export class BootstrapMemories extends Resource {
112
176
  relationships: [],
113
177
  relevant: [],
114
178
  teammate: [],
179
+ collision: [],
115
180
  events: [],
116
181
  };
117
182
  let tokenBudget = maxTokens;
@@ -214,8 +279,13 @@ export class BootstrapMemories extends Resource {
214
279
  // records missing either field are legacy agents/active — a strict
215
280
  // `!== "agent"` check would silently drop them. Assumes single-tenant
216
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 = [];
217
288
  try {
218
- const teammateIds = [];
219
289
  for await (const record of databases.flair.Agent.search()) {
220
290
  if (isTeammate(record, agentId))
221
291
  teammateIds.push(record.id);
@@ -227,65 +297,104 @@ export class BootstrapMemories extends Resource {
227
297
  catch {
228
298
  // Agent table may not exist in older / standalone deployments
229
299
  }
230
- // --- 2. Permanent memories (always included, highest priority) ---
300
+ // ─── Read-scope (flair-bootstrap-scale-fix) ─────────────────────────────
231
301
  // Read-scope: own (any visibility) + every OTHER in-org agent's
232
302
  // non-private memory — open-within-org read (#578), no MemoryGrant
233
- // consulted at all. Centralized in resolveReadScope(): the condition is
234
- // pushed into the Harper query (so the table itself never returns an
235
- // out-of-scope row), and `scope.isAllowed` re-checks in-process as
236
- // defense-in-depth (same belt-and-suspenders discipline as
237
- // SemanticSearch's BM25 pre-fusion filter) this is the #550 foundation:
238
- // bootstrap can now safely expand beyond own-only without a parallel
239
- // scoping rule, and that rule tracks resolveReadScope()'s model
240
- // automatically (grant-gated when #568 first built this, open-within-org
241
- // now that #578 has landed this file never re-implements the rule, so
242
- // it never has to change when the rule does).
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-negotiable the 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."
243
327
  const scope = await resolveReadScope(agentId);
244
- const allMemories = [];
245
- 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) {
246
340
  if (!scope.isAllowed(record))
247
341
  continue;
248
342
  if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
249
343
  continue;
250
- // A past validTo ALWAYS means the record has been closed out
251
- // (server supersede path — Memory.ts closeSupersededRecord — sets
252
- // validTo without necessarily setting `archived`), same root cause and
253
- // fix as SemanticSearch.ts's unconditional past-validTo/bm25-filter
254
- // exclusion. Unconditional
255
- // so a server-superseded record can't resurface in bootstrap just
256
- // because its successor isn't co-present in this result set (the
257
- // supersededIds filter further down only catches co-presence). A
258
- // record with no validTo, or a future validTo, is unaffected.
259
344
  if (record.validTo && Date.parse(record.validTo) < Date.now())
260
345
  continue;
261
- // Attribution for cross-agent (any other in-org agent's) records — same
262
- // convention SemanticSearch.ts already uses: formatMemory() below only USES this
263
- // when the record also carries _safetyFlags (labels the untrusted-data
264
- // wrapper with whose memory it is), it never forces wrapping on its own.
265
- // Real Harper's search() results are non-extensible objects — mutating
266
- // `record._source = ...` directly throws ("object is not extensible");
267
- // shallow-copy instead of mutating in place.
268
- allMemories.push(record.agentId !== agentId ? { ...record, _source: record.agentId } : record);
346
+ ownMemoriesAvailable++;
269
347
  }
270
- memoriesAvailable = allMemories.length;
271
- // Build superseded set: exclude memories that have been replaced by newer ones
272
- const supersededIds = new Set();
273
- for (const m of allMemories) {
274
- if (m.supersedes)
275
- 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);
276
383
  }
277
- const activeMemories = allMemories.filter((m) => !supersededIds.has(m.id));
278
- // #550 design boundary: the permanent / recent / predicted sections are the
279
- // agent's OWN working context own-only, always. `activeMemories` also
280
- // carries every other in-org agent's non-private records (`_source` set,
281
- // open-within-org read no grant involved), but that cross-agent
282
- // visibility exists to feed the task-relevant "Teammate findings"
283
- // surfacing (#550) below, NOT to blend a teammate's memories into the
284
- // reader's recent/permanent/predicted view. So these three sections
285
- // filter to own (`!m._source`); team knowledge surfaces only when
286
- // task-relevant (the teammate section) or via an explicit memory_search.
287
- const ownMemories = activeMemories.filter((m) => !m._source);
288
- 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));
289
398
  for (const m of permanent) {
290
399
  const line = formatMemory(m, agentId);
291
400
  const cost = estimateTokens(line);
@@ -299,10 +408,43 @@ export class BootstrapMemories extends Resource {
299
408
  }
300
409
  }
301
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));
302
444
  // Start with 48h. If nothing found, widen to 7d, then 30d.
303
445
  // This prevents empty recent sections for agents that were idle.
304
- const nonPermanent = ownMemories
305
- .filter((m) => m.durability !== "permanent" && m.createdAt)
446
+ const nonPermanent = nonPermanentActive
447
+ .filter((m) => m.createdAt)
306
448
  .sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
307
449
  let effectiveSince;
308
450
  if (since) {
@@ -347,7 +489,13 @@ export class BootstrapMemories extends Resource {
347
489
  ...permanent.map((m) => m.id),
348
490
  ...recent.filter((_, i) => i < sections.recent.length).map((m) => m.id),
349
491
  ]);
350
- 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
351
499
  .filter((m) => !includedIds.has(m.id) &&
352
500
  m.subject &&
353
501
  predictedSubjects.includes(m.subject.toLowerCase()) &&
@@ -403,11 +551,21 @@ export class BootstrapMemories extends Resource {
403
551
  // Relationship table may not exist yet
404
552
  }
405
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 = [];
406
562
  // --- 4. Task-relevant memories (semantic search) ---
407
563
  if (currentTask && tokenBudget > 200) {
408
564
  let queryEmbedding = null;
409
565
  try {
410
- 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");
411
569
  }
412
570
  catch { }
413
571
  if (queryEmbedding) {
@@ -416,26 +574,79 @@ export class BootstrapMemories extends Resource {
416
574
  ...permanent.map((m) => m.id),
417
575
  ...recent.filter((_, i) => i < sections.recent.length).map((m) => m.id),
418
576
  ]);
419
- const scored = allMemories
420
- .filter((m) => !includedIds.has(m.id) && !supersededIds.has(m.id) && m.embedding?.length > 100)
421
- .map((m) => {
422
- let dot = 0;
423
- const len = Math.min(queryEmbedding.length, m.embedding.length);
424
- for (let i = 0; i < len; i++)
425
- dot += queryEmbedding[i] * m.embedding[i];
426
- return { memory: m, score: dot };
427
- })
428
- .filter((s) => s.score > 0.3)
429
- .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
+ }
430
641
  // #550: split the scored, task-relevant set by origin. Own findings
431
642
  // go to `relevant` as before; any other in-org agent's non-private
432
643
  // record — already read-scoped by resolveReadScope(), no grant
433
- // required (`m._source` is only ever set for a cross-agent record,
434
- // see the allMemories loop above) goes to the new `teammate`
435
- // section so the agent can tell it apart at a glance. Both draw from
436
- // the SAME `tokenBudget` in one score-ordered pass — highest-relevance
437
- // memories win the remaining budget regardless of which section they
438
- // land in, so neither section double-spends.
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.
439
650
  for (const { memory: m } of scored) {
440
651
  const line = formatMemory(m, agentId);
441
652
  const cost = estimateTokens(line);
@@ -452,6 +663,133 @@ export class BootstrapMemories extends Resource {
452
663
  }
453
664
  }
454
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
+ }
455
793
  // --- 5. Recent OrgEvents for this agent ---
456
794
  try {
457
795
  const eventSince = data?.lastBootAt
@@ -514,6 +852,13 @@ export class BootstrapMemories extends Resource {
514
852
  if (sections.teammate.length > 0) {
515
853
  parts.push("## Teammate findings relevant to your task\n" + sections.teammate.join("\n"));
516
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
+ }
517
862
  if (sections.events.length > 0) {
518
863
  parts.push("## Recent Org Events\n" + sections.events.join("\n"));
519
864
  }
@@ -532,6 +877,7 @@ export class BootstrapMemories extends Resource {
532
877
  relationships: sections.relationships.length,
533
878
  relevant: sections.relevant.length,
534
879
  teammate: sections.teammate.length,
880
+ collision: sections.collision.length,
535
881
  events: sections.events.length,
536
882
  },
537
883
  tokenEstimate: soulTokens + memoryTokens,