@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.
- package/README.md +10 -7
- package/SECURITY.md +24 -2
- package/config.yaml +32 -5
- package/dist/cli.js +1811 -221
- package/dist/deploy.js +3 -4
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/runner.js +82 -12
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +23 -0
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +52 -53
- package/dist/resources/MemoryBootstrap.js +422 -76
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +8 -1
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/Presence.js +218 -19
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +98 -25
- package/dist/resources/SemanticSearch.js +85 -317
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/WorkspaceState.js +11 -0
- package/dist/resources/agent-auth.js +60 -2
- package/dist/resources/auth-middleware.js +18 -9
- package/dist/resources/bm25.js +12 -6
- package/dist/resources/collision-lib.js +157 -0
- package/dist/resources/embeddings-boot.js +149 -0
- package/dist/resources/embeddings-provider.js +364 -106
- package/dist/resources/entity-vocab.js +139 -0
- package/dist/resources/health.js +97 -6
- package/dist/resources/mcp-client-metadata-fields.js +112 -0
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-tools.js +88 -10
- package/dist/resources/memory-reflect-lib.js +289 -0
- package/dist/resources/migration-boot.js +143 -0
- package/dist/resources/migrations/dir-safety.js +71 -0
- package/dist/resources/migrations/embedding-stamp.js +178 -0
- package/dist/resources/migrations/envelope.js +43 -0
- package/dist/resources/migrations/export.js +38 -0
- package/dist/resources/migrations/ledger.js +55 -0
- package/dist/resources/migrations/lock.js +154 -0
- package/dist/resources/migrations/progress.js +31 -0
- package/dist/resources/migrations/registry.js +36 -0
- package/dist/resources/migrations/risk-policy.js +23 -0
- package/dist/resources/migrations/runner.js +455 -0
- package/dist/resources/migrations/snapshot.js +127 -0
- package/dist/resources/migrations/source-fields.js +93 -0
- package/dist/resources/migrations/space.js +167 -0
- package/dist/resources/migrations/state.js +64 -0
- package/dist/resources/migrations/status.js +39 -0
- package/dist/resources/migrations/synthetic-test-migration.js +93 -0
- package/dist/resources/migrations/types.js +9 -0
- package/dist/resources/presence-internal.js +29 -0
- package/dist/resources/provenance.js +50 -0
- package/dist/resources/rate-limiter.js +8 -1
- package/dist/resources/scoring.js +124 -5
- package/dist/resources/semantic-retrieval-core.js +317 -0
- package/dist/version-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/event.graphql +5 -0
- package/schemas/memory.graphql +66 -0
- package/schemas/schema.graphql +5 -43
- package/schemas/workspace.graphql +3 -0
- package/dist/resources/IngestEvents.js +0 -162
- package/dist/resources/IssueTokens.js +0 -19
- package/dist/resources/ObsAgentSnapshot.js +0 -13
- package/dist/resources/ObsEventFeed.js +0 -13
- package/dist/resources/ObsOffice.js +0 -19
- package/dist/resources/ObservationCenter.js +0 -23
- package/ui/observation-center.html +0 -385
|
@@ -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` (
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
// teammate's finding" check; own
|
|
112
|
+
// cross-agent record always carries `_source` (tagged by
|
|
113
|
+
// retrieveCandidates() — see resources/semantic-retrieval-core.ts — when 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
|
-
//
|
|
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():
|
|
234
|
-
// pushed into
|
|
235
|
-
// out-of-scope row), and `scope.isAllowed` re-checks
|
|
236
|
-
// defense-in-depth
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
// scoping rule, and that rule
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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 =
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
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 =
|
|
305
|
-
.filter((m) => m.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
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,
|