@tpsdev-ai/flair 0.44.8 → 0.44.10

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.
@@ -2,7 +2,7 @@ import { Resource, databases } from "harper";
2
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
- import { isTeammate, formatTeamLine } from "./memory-bootstrap-lib.js";
5
+ import { isTeammate, formatTeamLine, isZeroRowNoOpEvent } from "./memory-bootstrap-lib.js";
6
6
  import { resolveReadScope } from "./memory-read-scope.js";
7
7
  import { isValidEntity } from "./entity-vocab.js";
8
8
  import { withDetachedTxn } from "./table-helpers.js";
@@ -17,6 +17,7 @@ import { buildCollisionEntries, buildEntityMatchCondition, freshPresenceByAgent,
17
17
  import { retrieveCandidates, DEFAULT_SELECT } from "./semantic-retrieval-core.js";
18
18
  import { buildTrustBlock } from "./trust-block.js";
19
19
  import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
20
+ import { estimateTokens } from "./token-estimate.js";
20
21
  /**
21
22
  * POST /MemoryBootstrap
22
23
  *
@@ -72,9 +73,10 @@ import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
72
73
  * row's `entities`.
73
74
  *
74
75
  * Response:
75
- * { context, sections, tokenEstimate, memoriesIncluded, memoriesAvailable,
76
- * teammateFindingsIncluded, agentId, scope, soul, memories, predicted,
77
- * teammateFindings[, currentTaskHint][, predictedHint] }
76
+ * { context, sections, tokenEstimate, maxTokens, memoriesIncluded, memoriesAvailable,
77
+ * memoriesTruncated, teammateFindingsIncluded, teammateFindingsTruncated,
78
+ * teammateFindingsMatched, agentId, scope, soul, memories, predicted,
79
+ * teammateFindings, events[, currentTaskHint][, predictedHint] }
78
80
  * The self-describing keys (flair#1182 part 1) — `agentId` (resolved caller),
79
81
  * `scope` (read model applied to the caller), `soul`/`memories`/`predicted`
80
82
  * (the caller's OWN records as structured containers), and `currentTaskHint`
@@ -84,14 +86,48 @@ import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
84
86
  * mirror (opt-in via includeContext). Cross-agent teammate findings ship in
85
87
  * the `teammateFindings` container (own memories in `memories`), counted by
86
88
  * `teammateFindingsIncluded` (a separate denominator from `memoriesIncluded`,
87
- * which is own-scoped so it never exceeds `memoriesAvailable`). `tokenEstimate`
88
- * reflects the ACTUAL serialized payload, and `maxTokens` bounds it.
89
+ * which is own-scoped so it never exceeds `memoriesAvailable`). flair#1206 —
90
+ * org events ship in their OWN structured `events` container (ALWAYS present,
91
+ * `[]` when none), so a connector reading the structured payload gets them even
92
+ * when the prose `context` is off (the /mcp default); before #1206 they lived
93
+ * ONLY in the prose string and were orphaned at includeContext=false.
94
+ *
95
+ * CAP CONTRACT: `maxTokens` is the HARD cap on CONTENT SELECTION — the shared
96
+ * `tokenBudget` starts at `maxTokens` and every admitted soul/memory/finding
97
+ * line AND every org event (flair#1199 — events are content too; before this
98
+ * they were assembled but NEVER charged, so a maxTokens=4000 request serialized
99
+ * at 6286) is gated against the remaining budget, so the sum of selected CONTENT
100
+ * never exceeds `maxTokens`. `tokenEstimate` HONESTLY reports the real serialized
101
+ * payload (`JSON.stringify(responseBody)`), which includes the FIXED structured-
102
+ * container JSON scaffolding (keys/braces, counters, the sections map) and so may
103
+ * exceed `maxTokens` slightly by that bounded overhead — measurement is decoupled
104
+ * from budgeting, but the overhead is now scaffolding ONLY, never uncounted
105
+ * content. The connector-conformance suite asserts tokenEstimate <= maxTokens
106
+ * within a small tolerance for that scaffolding. flair#1207: #1199 had ALSO
107
+ * folded a per-item structured overhead + a scaffolding reserve INTO the
108
+ * selection budget, which silently shrank recall below 0.44.6 for the same
109
+ * `maxTokens`; that per-item overhead is a reporting concern (already captured by
110
+ * `tokenEstimate`) and no longer shrinks the content budget.
111
+ *
112
+ * COUNT CONTRACT (flair#1207): `memoriesIncluded + memoriesTruncated <=
113
+ * memoriesAvailable` — included and truncated are disjoint sets of UNIQUE own
114
+ * memories (a memory budget-skipped in one section but admitted in another counts
115
+ * as included, never both). `teammateFindingsMatched` is the teammate match pool
116
+ * that cleared the relevance floor; `teammateFindingsIncluded +
117
+ * teammateFindingsTruncated == teammateFindingsMatched`, so "truncated" means
118
+ * "relevant but no budget," not "every candidate not selected."
89
119
  * `predictedHint` is present only when subjects were provided but `predicted`
90
120
  * came back empty.
91
121
  */
92
122
  // Collision surfacing (flair#681) tunables.
93
123
  const COLLISION_WINDOW_DAYS = 7;
94
124
  const MAX_COLLISION_ENTRIES = 10;
125
+ // flair#1199/#1206 — the default cap on how many org events bootstrap ships.
126
+ // Overridable per-request via `maxEvents`. Event slots are scarce AND (as of
127
+ // #1199) token-charged, so this bounds both the count and the spend; the shared
128
+ // tokenBudget is the harder ceiling (an event that doesn't fit is skipped even
129
+ // under the cap).
130
+ const MAX_ORG_EVENTS = 10;
95
131
  // ─── Bootstrap scale fix (flair-bootstrap-scale-fix) tunables ───────────────
96
132
  //
97
133
  // Own-scoped, non-permanent memories (the "recent" adaptive-window source,
@@ -123,21 +159,23 @@ const MAX_CANDIDATE_POOL = 100;
123
159
  // `minScore` request param) — preserved verbatim from the original raw
124
160
  // JS dot-product scan's `.filter((s) => s.score > 0.3)`.
125
161
  const TASK_RELEVANCE_FLOOR = 0.3;
126
- // flair#1199 — per-memory structured-payload overhead charged against the token
127
- // budget IN ADDITION to the rendered prose line. Each included memory ships as a
128
- // structured object ({id, content, durability, createdAt, updatedAt, agentId,
129
- // subject, section, ...}) which is the CANONICAL payload; its JSON keys cost
130
- // ~30-40 tokens beyond the prose line the fill loop measures. Charging it here
131
- // keeps the ACTUAL serialized payload (measured by tokenEstimate) within
132
- // maxTokens the prose line alone under-charged, so the structured containers
133
- // crossed the cap. Sized to the non-content JSON a lean memory carries (id +
134
- // createdAt + updatedAt + agentId + durability + subject + section + key names,
135
- // ~55-70 tokens); conservative (errs slightly high, never low).
136
- const STRUCT_ITEM_OVERHEAD_TOKENS = 70;
137
- // Rough token estimate: ~4 chars per token for English text
138
- function estimateTokens(text) {
139
- return Math.ceil(text.length / 4);
140
- }
162
+ // flair#1207the per-item structured-payload overhead that #1199 charged
163
+ // against the content-selection budget (a `+ STRUCT_ITEM_OVERHEAD_TOKENS = 70`
164
+ // added to every item's cost, PLUS a `structOverheadReserve` pre-deducted from
165
+ // the starting budget) has been REMOVED. It conflated measurement with
166
+ // budgeting: `tokenEstimate` already measures the real serialized payload
167
+ // (JSON.stringify(responseBody)) the structured JSON scaffolding overhead is
168
+ // captured there, honestly. Folding it into the SELECTION budget too
169
+ // double-penalized and silently shrank recall below 0.44.6 for the same
170
+ // `maxTokens` (6 findings 3). The content budget is now `maxTokens` again
171
+ // (0.44.6 selection capacity), and each item's cost is just the rendered prose
172
+ // line while `tokenEstimate` keeps reporting the true serialized size, which
173
+ // may exceed `maxTokens` by the scaffolding overhead. See the module-doc CAP
174
+ // CONTRACT above.
175
+ // Token estimate (~4 chars per token for English text) now lives in the
176
+ // harper-free ./token-estimate.js module, so the content-selection budget, the
177
+ // reported `tokenEstimate`, and the flair#1213 conformance tokenEstimate
178
+ // invariant are all computed with ONE definition. See that module's header.
141
179
  // `agentId` is the BOOTSTRAPPING agent (the caller) — used only to decide
142
180
  // whether to annotate attribution, never to change what's read (that
143
181
  // boundary is resolveReadScope()'s job, upstream of this function). A
@@ -175,6 +213,15 @@ export class BootstrapMemories extends Resource {
175
213
  subjects, // e.g., ["flair", "auth"] — entities to preload context for
176
214
  includeTrust = false, // flair#744 slice 1 — opt-in per-memory trust block
177
215
  abstain = false, // flair#744 slice 2 — opt-in task-relevance abstention
216
+ // flair#1199 — org-event knobs. `maxEvents` caps how many events ship
217
+ // (default MAX_ORG_EVENTS); `includeEventDetail` gates the verbose per-event
218
+ // `detail` JSON (default OFF — mirrors the includeContext opt-in). By
219
+ // default bootstrap ships LEAN events (id/kind/summary/createdAt/targetIds/
220
+ // scope); `detail` restates the summary + migration internals and is pure
221
+ // bloat for a connector, so it is opt-in. Both are also counted against the
222
+ // shared tokenBudget below (before #1199 the events array was assembled but
223
+ // NEVER charged, so a maxTokens=4000 request serialized well past budget).
224
+ maxEvents, includeEventDetail = false,
178
225
  // flair#1199 — whether to assemble the prose `context` string. The
179
226
  // structured containers (soul/memories/predicted/teammateFindings) are the
180
227
  // CANONICAL payload; `context` is a human/agent-readable MIRROR of the same
@@ -222,19 +269,22 @@ export class BootstrapMemories extends Resource {
222
269
  collision: [],
223
270
  events: [],
224
271
  };
225
- // flair#1199 — the structured containers (soul/memories/predicted/
226
- // teammateFindings) are the CANONICAL payload, and `tokenEstimate` now
227
- // reports the ACTUAL serialized bytes. Reserve headroom for the JSON
228
- // scaffolding those containers and the self-describing keys (scope/sections/
229
- // counters) add on top of the raw content, so `maxTokens` bounds the real
230
- // payload not just the prose. Without this the containers ship on top of
231
- // the memory-line budget and blow the cap (the reported 4000→4275+ overrun).
232
- const structOverheadReserve = Math.min(600, Math.floor(maxTokens * 0.15));
272
+ // flair#1207 — the content-SELECTION budget is `maxTokens`, matching 0.44.6
273
+ // capacity. #1199 pre-deducted a `structOverheadReserve` (min(600, 15% of
274
+ // maxTokens)) here to "reserve headroom" for the structured-container JSON
275
+ // scaffolding, on the theory that `maxTokens` should bound the serialized
276
+ // payload. That silently shrank the content budget and, combined with the
277
+ // per-item overhead (also removed), cut recall below 0.44.6 for the same
278
+ // `maxTokens` (#1207). The reserve is gone: `maxTokens` is the HARD cap on
279
+ // CONTENT SELECTION only. `tokenEstimate` (below) still reports the real
280
+ // serialized size honestly — which may exceed `maxTokens` by the scaffolding
281
+ // overhead, exactly as 0.44.6's payload did (0.44.6 just under-measured it).
233
282
  // Single shared budget across soul + every memory section (soul used to be
234
283
  // budgeted SEPARATELY and added ON TOP, so context alone could reach
235
- // 1.4×maxTokens; #1199). Content selected therefore stays within
236
- // maxTokens reserve, and the serialized payload within maxTokens.
237
- let tokenBudget = Math.max(0, maxTokens - structOverheadReserve);
284
+ // 1.4×maxTokens; #1199 folded soul into this shared budget — that part
285
+ // stays). Every admitted line is gated against the remaining budget, so the
286
+ // sum of selected CONTENT never exceeds `maxTokens`.
287
+ let tokenBudget = Math.max(0, maxTokens);
238
288
  // Own memories included in the payload (permanent + recent + predicted +
239
289
  // own task-relevant). Denominator is `memoriesAvailable` (own-scoped), so
240
290
  // memoriesIncluded ≤ memoriesAvailable always holds (#1199 coherent
@@ -242,10 +292,38 @@ export class BootstrapMemories extends Resource {
242
292
  let memoriesIncluded = 0;
243
293
  let memoriesAvailable = 0;
244
294
  let memoriesTruncated = 0;
295
+ // flair#1207 — count arithmetic by UNIQUE own-memory id, not raw increments.
296
+ // The invariant `memoriesIncluded + memoriesTruncated <= memoriesAvailable`
297
+ // MUST hold (0.44.9 reported available:3 included:2 truncated:2 — 2+2 > 3).
298
+ // Two bugs produced the over-count, both fixed by keying off these sets:
299
+ // (1) a memory truncated for budget in an EARLY section (e.g. recent's
300
+ // 40% sub-budget) reappears in the task-relevant candidate pool and is
301
+ // counted AGAIN (truncated twice, or truncated-then-included) — the
302
+ // same physical memory in two denominators; and
303
+ // (2) the task-relevant loop's "already included" exclusion set was built
304
+ // POSITIONALLY (recent.filter by index) and omitted `predicted`, so a
305
+ // predicted memory could be re-admitted to `relevant`.
306
+ // `includedOwnIds` is now THE authoritative "own memory already placed" set
307
+ // (used as the exclusion set in the predicted + task-relevant loops, replacing
308
+ // the positional hack); `truncatedOwnIds` records own budget-skips. Final
309
+ // counts are DERIVED from these sets (truncated = truncated-but-not-included),
310
+ // so both are disjoint subsets of memoriesAvailable and the invariant holds.
311
+ const includedOwnIds = new Set();
312
+ const truncatedOwnIds = new Set();
245
313
  // flair#1199 — cross-agent teammate findings included (a DIFFERENT
246
314
  // denominator than own memories): counting these into `memoriesIncluded`
247
315
  // is what let one client see included(9) > available(3).
248
316
  let teammateFindingsIncluded = 0;
317
+ // flair#1207 — teammate findings SKIPPED for size in the task-relevant
318
+ // packing loop (cleared the relevance floor but didn't fit the BUDGET).
319
+ // Reported ALONGSIDE `teammateFindingsMatched` (the whole floor-clearing pool
320
+ // considered), so "truncated" unambiguously means "relevant-but-no-budget",
321
+ // NOT "every candidate not selected" — 0.44.9's `truncated:89` beside
322
+ // `included:4` read as "89 relevant findings cut" with no pool to anchor it
323
+ // (heskew's #1207 nit). Both are disjoint-and-exhaustive over the matched
324
+ // pool (each matched teammate finding is EITHER included OR truncated), so
325
+ // teammateFindingsIncluded + teammateFindingsTruncated == teammateFindingsMatched.
326
+ let teammateFindingsTruncated = 0;
249
327
  // flair#1182 (part 1) — self-describing bootstrap. These structured
250
328
  // container keys are ALWAYS emitted on the response (empty `{}`/`[]` when
251
329
  // the caller has nothing), so a client can tell an *empty* instance from
@@ -267,6 +345,17 @@ export class BootstrapMemories extends Resource {
267
345
  // them. Kept SEPARATE from `memories`/`predicted` (which stay own-only per
268
346
  // the #1182 boundary) and clearly attributed via `source`.
269
347
  const includedTeammateFindings = [];
348
+ // flair#1206 — org events get their OWN structured container. Before #1206
349
+ // they lived ONLY in the prose `context` string ("## Recent Org Events"),
350
+ // so at includeContext=false (the /mcp default) they were counted in
351
+ // `sections.events` and measured into `tokenEstimate` (when prose was on) but
352
+ // NEVER delivered in any field a connector could read — orphaned. Populated
353
+ // from the SAME deduped+sliced set the prose lines are (so count, charge and
354
+ // delivery all key off one thing), ALWAYS emitted (`[]` when none), and the
355
+ // targetIds relevance filter is already applied upstream (see the OrgEvent
356
+ // read below). Declared out here so it is in scope for the response body even
357
+ // if the events read (in a try/catch) yields nothing.
358
+ const includedEvents = [];
270
359
  const leanMemory = (m, section) => ({
271
360
  id: m.id,
272
361
  content: m.content,
@@ -528,17 +617,17 @@ export class BootstrapMemories extends Resource {
528
617
  const permanent = permanentRows.filter((m) => !permanentSupersededIds.has(m.id));
529
618
  for (const m of permanent) {
530
619
  const line = formatMemory(m, agentId);
531
- const cost = estimateTokens(line) + STRUCT_ITEM_OVERHEAD_TOKENS; // #1199 structured cost
620
+ const cost = estimateTokens(line); // #1207 prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
532
621
  if (cost <= tokenBudget) {
533
622
  sections.permanent.push(line);
534
623
  includedOwnMemories.push(leanMemory(m, "permanent"));
535
624
  if (includeTrust)
536
625
  includedTrustMemories.push({ m, section: "permanent" });
537
626
  tokenBudget -= cost;
538
- memoriesIncluded++;
627
+ includedOwnIds.add(m.id); // #1207 — count by unique own-memory id
539
628
  }
540
629
  else {
541
- memoriesTruncated++;
630
+ truncatedOwnIds.add(m.id); // #1207 — budget-skip, deduped against inclusions at the end
542
631
  }
543
632
  }
544
633
  // --- 3. Recent memories (adaptive window) ---
@@ -600,9 +689,9 @@ export class BootstrapMemories extends Resource {
600
689
  let recentSpent = 0;
601
690
  for (const m of recent) {
602
691
  const line = formatMemory(m, agentId);
603
- const cost = estimateTokens(line) + STRUCT_ITEM_OVERHEAD_TOKENS; // #1199 structured cost
692
+ const cost = estimateTokens(line); // #1207 prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
604
693
  if (recentSpent + cost > recentBudget) {
605
- memoriesTruncated++;
694
+ truncatedOwnIds.add(m.id); // #1207 — budget-skip; may still be admitted later via the task-relevant loop (deduped at the end)
606
695
  continue;
607
696
  }
608
697
  sections.recent.push(line);
@@ -611,7 +700,7 @@ export class BootstrapMemories extends Resource {
611
700
  includedTrustMemories.push({ m, section: "recent" });
612
701
  recentSpent += cost;
613
702
  tokenBudget -= cost;
614
- memoriesIncluded++;
703
+ includedOwnIds.add(m.id); // #1207 — count by unique own-memory id
615
704
  }
616
705
  // --- 3b. Subject-predicted context ---
617
706
  // When subjects are provided (e.g., ["flair", "auth"]), load memories
@@ -622,10 +711,10 @@ export class BootstrapMemories extends Resource {
622
711
  ? subjects.map((s) => s.toLowerCase())
623
712
  : [];
624
713
  if (predictedSubjects.length > 0 && tokenBudget > 200) {
625
- const includedIds = new Set([
626
- ...permanent.map((m) => m.id),
627
- ...recent.filter((_, i) => i < sections.recent.length).map((m) => m.id),
628
- ]);
714
+ // flair#1207 exclude by the AUTHORITATIVE included-own set (permanent +
715
+ // recent actually admitted), not the old positional `recent.filter(by
716
+ // index)` hack, which mis-tracked when the recent loop skipped an early
717
+ // memory for budget and admitted a later one.
629
718
  // Draws from the SAME bounded own-scoped, non-permanent set "recent"
630
719
  // uses (nonPermanentActive — see that fetch's doc above for the
631
720
  // shared-source rationale and OWN_NONPERMANENT_FETCH_LIMIT's bound).
@@ -633,7 +722,7 @@ export class BootstrapMemories extends Resource {
633
722
  // query's own condition but kept for parity/clarity with the
634
723
  // pre-refactor filter shape.
635
724
  const subjectMemories = nonPermanentActive
636
- .filter((m) => !includedIds.has(m.id) &&
725
+ .filter((m) => !includedOwnIds.has(m.id) &&
637
726
  m.subject &&
638
727
  predictedSubjects.includes(m.subject.toLowerCase()) &&
639
728
  m.durability !== "permanent" // already loaded
@@ -643,9 +732,9 @@ export class BootstrapMemories extends Resource {
643
732
  let predictedSpent = 0;
644
733
  for (const m of subjectMemories) {
645
734
  const line = formatMemory(m, agentId);
646
- const cost = estimateTokens(line) + STRUCT_ITEM_OVERHEAD_TOKENS; // #1199 structured cost
735
+ const cost = estimateTokens(line); // #1207 prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
647
736
  if (predictedSpent + cost > predictedBudget) {
648
- memoriesTruncated++;
737
+ truncatedOwnIds.add(m.id); // #1207 — budget-skip (deduped against inclusions at the end)
649
738
  continue;
650
739
  }
651
740
  sections.predicted.push(line);
@@ -654,8 +743,7 @@ export class BootstrapMemories extends Resource {
654
743
  includedTrustMemories.push({ m, section: "predicted" });
655
744
  predictedSpent += cost;
656
745
  tokenBudget -= cost;
657
- memoriesIncluded++;
658
- includedIds.add(m.id);
746
+ includedOwnIds.add(m.id); // #1207 — count by unique own-memory id; also the task-relevant loop's exclusion set (no predicted→relevant double-admit)
659
747
  }
660
748
  }
661
749
  // --- 3c. Active relationships for predicted subjects ---
@@ -709,11 +797,13 @@ export class BootstrapMemories extends Resource {
709
797
  }
710
798
  catch { }
711
799
  if (queryEmbedding) {
712
- // Score all non-included memories by relevance
713
- const includedIds = new Set([
714
- ...permanent.map((m) => m.id),
715
- ...recent.filter((_, i) => i < sections.recent.length).map((m) => m.id),
716
- ]);
800
+ // flair#1207 exclude own memories ALREADY placed via the authoritative
801
+ // set (permanent + recent + predicted actually admitted). The old set was
802
+ // built positionally (recent.filter by index) AND omitted `predicted`, so
803
+ // a predicted memory could be re-admitted here — double-counting it into
804
+ // memoriesIncluded and shipping it twice (once in `predicted`, once in
805
+ // `memories`). Keying off includedOwnIds fixes both.
806
+ const includedIds = includedOwnIds;
717
807
  // Bounded HNSW candidate pool (flair-bootstrap-scale-fix) — replaces
718
808
  // the full-corpus JS dot-product scan (`allMemories` × queryEmbedding,
719
809
  // O(org corpus size) every bootstrap). K formula (Kern-approved):
@@ -818,9 +908,19 @@ export class BootstrapMemories extends Resource {
818
908
  // section double-spends.
819
909
  for (const { memory: m } of scored) {
820
910
  const line = formatMemory(m, agentId);
821
- const cost = estimateTokens(line) + STRUCT_ITEM_OVERHEAD_TOKENS; // #1199 structured cost
822
- if (cost > tokenBudget)
911
+ const cost = estimateTokens(line); // #1207 prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
912
+ if (cost > tokenBudget) {
913
+ // flair#1207 — a size-skip in the score-ordered task-relevant loop
914
+ // is no longer silent: record it on the denominator matching the
915
+ // record's origin (own → truncatedOwnIds, teammate → the separate
916
+ // teammateFindingsTruncated), so a client can distinguish "no relevant
917
+ // finding" from "a relevant finding didn't fit the budget".
918
+ if (m._source)
919
+ teammateFindingsTruncated++;
920
+ else
921
+ truncatedOwnIds.add(m.id);
823
922
  continue;
923
+ }
824
924
  if (m._source) {
825
925
  sections.teammate.push(line);
826
926
  // flair#1199 — cross-agent teammate findings join their OWN
@@ -852,7 +952,7 @@ export class BootstrapMemories extends Resource {
852
952
  if (includeTrust)
853
953
  includedTrustMemories.push({ m, section: "relevant" });
854
954
  tokenBudget -= cost;
855
- memoriesIncluded++;
955
+ includedOwnIds.add(m.id); // #1207 — count by unique own-memory id
856
956
  }
857
957
  }
858
958
  }
@@ -1000,6 +1100,15 @@ export class BootstrapMemories extends Resource {
1000
1100
  const isRelevant = !targets || targets.length === 0 || targets.includes(agentId);
1001
1101
  if (!isRelevant)
1002
1102
  continue;
1103
+ // flair#1200 — suppress zero-row no-op auto-heal migration events at
1104
+ // render. On a healthy store every boot emits a "migration graph-heal
1105
+ // success (0 rows processed)" ledger event beside an "HNSW graph-heal:
1106
+ // recall verified healthy" observability event — near-identical, zero
1107
+ // signal, and (as of #1199) token-charged. Filtered HERE only: the
1108
+ // ledger still records every migration on the table (invariant IV is
1109
+ // untouched — this is a display filter, never a write-path change).
1110
+ if (isZeroRowNoOpEvent(event))
1111
+ continue;
1003
1112
  eventResults.push(event);
1004
1113
  }
1005
1114
  // flair#1200 — collapse byte-identical duplicate events before rendering.
@@ -1007,10 +1116,10 @@ export class BootstrapMemories extends Resource {
1007
1116
  // that double-fires, or the same broadcast emitted from two paths); each
1008
1117
  // physical row has a distinct id/createdAt (OrgEvent.post keys the id off
1009
1118
  // a millisecond timestamp), so they aren't caught by primary-key upsert
1010
- // and render as exact dupes. Org-event slots are scarce (10), so dedup
1011
- // BEFORE the slice — otherwise ~half the slots are wasted on duplicates.
1012
- // Keyed on the CONTENT (kind + summary + detail + targets), keeping the
1013
- // most-recent occurrence per signature.
1119
+ // and render as exact dupes. Org-event slots are scarce, so dedup BEFORE
1120
+ // admission — otherwise ~half the slots are wasted on duplicates. Keyed on
1121
+ // the CONTENT (kind + summary + detail + targets), keeping the most-recent
1122
+ // occurrence per signature.
1014
1123
  const eventBySignature = new Map();
1015
1124
  for (const evt of eventResults) {
1016
1125
  const sig = JSON.stringify([
@@ -1023,18 +1132,76 @@ export class BootstrapMemories extends Resource {
1023
1132
  if (!prev || (evt.createdAt || "") > (prev.createdAt || ""))
1024
1133
  eventBySignature.set(sig, evt);
1025
1134
  }
1135
+ // flair#1199 — admit events in RECENCY order (most recent first, the
1136
+ // score-analogue for events) against the SHARED tokenBudget, capped at
1137
+ // `maxEvents`. Before #1199 the events array was assembled uncounted and
1138
+ // NEVER charged: a maxTokens=4000 request serialized at 6286 (+57%), the
1139
+ // bulk being 10 events each shipping a `detail` JSON. Now each event's
1140
+ // REAL serialized cost (the structured object that actually ships — lean by
1141
+ // default, `detail` only under includeEventDetail) is charged against the
1142
+ // remaining budget, so events respect maxTokens the same way every other
1143
+ // content section does. An event that doesn't fit is skipped, not silently
1144
+ // over-budget; smaller later events may still fit (hence continue, not
1145
+ // break), and admission stops at the maxEvents cap.
1146
+ const eventCap = Number.isFinite(maxEvents) && maxEvents >= 0
1147
+ ? Math.floor(maxEvents)
1148
+ : MAX_ORG_EVENTS;
1026
1149
  const dedupedEvents = [...eventBySignature.values()]
1027
- .sort((a, b) => (a.createdAt || "").localeCompare(b.createdAt || ""));
1028
- for (const evt of dedupedEvents.slice(0, 10)) {
1150
+ .sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
1151
+ for (const evt of dedupedEvents) {
1152
+ if (sections.events.length >= eventCap)
1153
+ break;
1154
+ // The structured object a connector actually reads (flair#1206). Lean by
1155
+ // default; `detail` (the verbose migration-internals/summary-restating
1156
+ // JSON) only when explicitly requested. Optional fields are omitted when
1157
+ // absent so the object stays compact.
1158
+ const structured = {
1159
+ id: evt.id,
1160
+ kind: evt.kind,
1161
+ summary: evt.summary,
1162
+ ...(includeEventDetail && evt.detail != null ? { detail: evt.detail } : {}),
1163
+ ...(Array.isArray(evt.targetIds) && evt.targetIds.length > 0 ? { targetIds: evt.targetIds } : {}),
1164
+ createdAt: evt.createdAt ?? null,
1165
+ ...(evt.scope != null ? { scope: evt.scope } : {}),
1166
+ };
1167
+ // Charge the REAL serialized cost of what ships (structured object on the
1168
+ // /mcp path; the prose line is a subset of it). This is the #1199 fix:
1169
+ // events are content and must be budgeted like content.
1170
+ const cost = estimateTokens(JSON.stringify(structured));
1171
+ if (cost > tokenBudget)
1172
+ continue;
1029
1173
  const elapsed = Date.now() - new Date(evt.createdAt).getTime();
1030
1174
  const mins = Math.floor(elapsed / 60_000);
1031
1175
  const relTime = mins < 60 ? `${mins}min ago` : `${Math.floor(mins / 60)}h ago`;
1032
1176
  sections.events.push(`- ${evt.kind}: ${evt.summary} (${relTime})`);
1177
+ // Same admitted event ⇒ `sections.events` (the count), the `tokenEstimate`
1178
+ // charge, and the structured delivery all key off ONE thing.
1179
+ includedEvents.push(structured);
1180
+ tokenBudget -= cost;
1033
1181
  }
1034
1182
  }
1035
1183
  catch {
1036
1184
  // non-fatal: OrgEvent table may not exist yet
1037
1185
  }
1186
+ // flair#1207 — derive the own-memory counters from the unique-id sets so the
1187
+ // invariant memoriesIncluded + memoriesTruncated <= memoriesAvailable holds.
1188
+ // `truncated` counts only own memories that were budget-skipped AND never
1189
+ // ultimately admitted (a memory skipped in `recent` but later admitted via
1190
+ // the task-relevant loop is INCLUDED, not truncated) — so included/truncated
1191
+ // are disjoint subsets of the own corpus (memoriesAvailable), never
1192
+ // double-counting the same physical memory across sections.
1193
+ memoriesIncluded = includedOwnIds.size;
1194
+ let memoriesTruncatedUnique = 0;
1195
+ for (const id of truncatedOwnIds)
1196
+ if (!includedOwnIds.has(id))
1197
+ memoriesTruncatedUnique++;
1198
+ memoriesTruncated = memoriesTruncatedUnique;
1199
+ // flair#1207 — the teammate MATCH POOL considered (cleared the relevance
1200
+ // floor, drawn from the bounded candidate pool): every matched teammate
1201
+ // finding is EITHER included OR budget-truncated, so this equals their sum.
1202
+ // Reporting it anchors `teammateFindingsTruncated` as "relevant-but-no-budget"
1203
+ // rather than an unexplained large number beside a small `included`.
1204
+ const teammateFindingsMatched = teammateFindingsIncluded + teammateFindingsTruncated;
1038
1205
  // --- Build context string ---
1039
1206
  const parts = [];
1040
1207
  if (sections.soul.length > 0) {
@@ -1165,6 +1332,13 @@ export class BootstrapMemories extends Resource {
1165
1332
  // (always present, `[]` when none), so a connector that consumes the
1166
1333
  // containers still gets them when prose `context` is off.
1167
1334
  teammateFindings: includedTeammateFindings,
1335
+ // flair#1206 — org events as a structured container (always present, `[]`
1336
+ // when none, same self-describing-empty-state pattern as the containers
1337
+ // above). Before #1206 events lived ONLY in the prose `context`, so at the
1338
+ // /mcp default (includeContext=false) they were counted+measured but never
1339
+ // delivered. Deduped (#1200) and targetIds-scoped (same set as the prose
1340
+ // "## Recent Org Events" lines), so count/charge/delivery all agree.
1341
+ events: includedEvents,
1168
1342
  ...(currentTaskHint ? { currentTaskHint } : {}),
1169
1343
  ...(predictedHint ? { predictedHint } : {}),
1170
1344
  ...(trust ? { trust } : {}),
@@ -1184,22 +1358,50 @@ export class BootstrapMemories extends Resource {
1184
1358
  },
1185
1359
  soulTokens,
1186
1360
  memoryTokens,
1361
+ // flair#1199 — the content-selection budget this response was built
1362
+ // against (echoed so a connector can relate tokenEstimate to the budget it
1363
+ // asked for — and so the conformance tokenEstimate<=maxTokens invariant is
1364
+ // self-contained). Defaults to 4000 when unset.
1365
+ maxTokens,
1187
1366
  // flair#1199 — own memories included (denominator: memoriesAvailable, also
1188
- // own-scoped), so memoriesIncluded memoriesAvailable always holds.
1367
+ // own-scoped). flair#1207 DERIVED from unique own-memory ids, so
1368
+ // memoriesIncluded + memoriesTruncated <= memoriesAvailable always holds.
1189
1369
  memoriesIncluded,
1190
1370
  memoriesAvailable,
1191
1371
  // Cross-agent teammate findings included — a SEPARATE denominator, labelled
1192
1372
  // so it's never confused with the own-memory counters.
1193
1373
  teammateFindingsIncluded,
1194
1374
  memoriesTruncated,
1375
+ // flair#1207 — teammate findings skipped for size in the task-relevant loop
1376
+ // (own size-skips there feed truncatedOwnIds). Surfacing this makes a
1377
+ // size-skip self-describing: "a relevant teammate finding didn't fit" is
1378
+ // now distinguishable from "no relevant teammate finding".
1379
+ teammateFindingsTruncated,
1380
+ // flair#1207 — the teammate match POOL considered (cleared the relevance
1381
+ // floor). teammateFindingsIncluded + teammateFindingsTruncated ==
1382
+ // teammateFindingsMatched, so "truncated" reads as "relevant-but-no-budget"
1383
+ // against a stated pool, not an unanchored large number.
1384
+ teammateFindingsMatched,
1195
1385
  };
1196
1386
  // flair#1199 — tokenEstimate must reflect the ACTUAL serialized payload the
1197
1387
  // caller receives (the structured containers included), not just the prose
1198
1388
  // `context`. The old `soulTokens + memoryTokens` counted only the context
1199
1389
  // string, so it under-reported by ~2× once the structured fields shipped
1200
- // alongside the reported "maxTokens 4000 tokenEstimate 4275 while the
1201
- // real payload was well over the cap". Measured over the assembled body
1202
- // (the ~1-line tokenEstimate field it omits is negligible).
1390
+ // alongside. Measured over the assembled body (the ~1-line tokenEstimate
1391
+ // field it omits is negligible). CAP CONTRACT: this is an HONEST report of
1392
+ // the real serialized size. Every CONTENT section — soul, memories, findings,
1393
+ // AND events (flair#1199) — is now gated against the shared `maxTokens`
1394
+ // budget, so no section blows the budget with uncounted content the way the
1395
+ // 0.44.9 events array did (maxTokens=4000 → 6286). tokenEstimate may still
1396
+ // exceed `maxTokens` slightly, by the FIXED structural JSON scaffolding
1397
+ // (container keys/braces, counters, sections map, char/4 rounding) — that is
1398
+ // genuine payload the caller pays for, and it is small and bounded, NOT
1399
+ // uncounted content. The connector-conformance suite asserts
1400
+ // tokenEstimate <= maxTokens within a small tolerance for exactly that
1401
+ // scaffolding. Do NOT "fix" a small over-maxTokens tokenEstimate by shrinking
1402
+ // memory selection — that is the #1199→#1207 regression (it dropped relevant
1403
+ // findings, charging a per-item overhead against the content budget). If the
1404
+ // real payload consistently overruns for a use case, raise `maxTokens`.
1203
1405
  const tokenEstimate = estimateTokens(JSON.stringify(responseBody));
1204
1406
  return { ...responseBody, tokenEstimate };
1205
1407
  }
@@ -40,7 +40,7 @@ import { Resource, databases, models, logger } from "harper";
40
40
  import { randomBytes } from "node:crypto";
41
41
  import { isAdmin, allowVerified } from "./agent-auth.js";
42
42
  import { patchRecordSilent } from "./table-helpers.js";
43
- import { buildReflectionPrompt, buildExecutePrompt, resolveReflectActor, generateCandidates, dedupeCandidates, } from "./memory-reflect-lib.js";
43
+ import { buildReflectionPrompt, buildExecutePrompt, resolveReflectActor, generateCandidates, dedupeCandidates, memoryMatchesReflectScope, buildStagedCandidateRow, } from "./memory-reflect-lib.js";
44
44
  export class ReflectMemories extends Resource {
45
45
  // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
46
46
  // admin elevation). Any verified agent may reflect; the isAdmin checks in post()
@@ -75,15 +75,12 @@ export class ReflectMemories extends Resource {
75
75
  continue;
76
76
  if (record.durability === "permanent")
77
77
  continue; // permanent memories don't need reflection
78
- if (scope === "tagged") {
79
- if (!tag || !(record.tags ?? []).includes(tag))
80
- continue;
81
- }
82
- else if (scope === "recent") {
83
- if (!record.createdAt || new Date(record.createdAt) < sinceDate)
84
- continue;
85
- }
86
- // scope="all" passes everything
78
+ // Scope selection — the cross-user-bleed boundary (#1205b-1). See
79
+ // memoryMatchesReflectScope's doc: scope:"tagged" admits ONLY the one
80
+ // adk:<app>:<user> tag's memories, so a candidate distilled here can
81
+ // never cite another user's memory.
82
+ if (!memoryMatchesReflectScope(record, { scope, tag, sinceDate }))
83
+ continue;
87
84
  const { embedding, ...rest } = record;
88
85
  memories.push(rest);
89
86
  if (memories.length >= maxMemories)
@@ -152,7 +149,11 @@ export class ReflectMemories extends Resource {
152
149
  const generatedAt = new Date().toISOString();
153
150
  const staged = [];
154
151
  for (const c of toStage) {
155
- const row = {
152
+ // #1205b-1: buildStagedCandidateRow stamps `scopeTag` when this run was
153
+ // scope:"tagged" — the authoritative per-user tag promotion consumes
154
+ // directly (closing the #1205a source-re-read seam). Non-tagged runs
155
+ // leave scopeTag absent, unchanged.
156
+ const row = buildStagedCandidateRow({
156
157
  id: `cand_${randomBytes(8).toString("hex")}`,
157
158
  agentId,
158
159
  claim: c.claim,
@@ -160,8 +161,9 @@ export class ReflectMemories extends Resource {
160
161
  rationalePrompt: executePrompt,
161
162
  generatedBy: resolvedModel,
162
163
  generatedAt,
163
- status: "pending",
164
- };
164
+ scope,
165
+ tag,
166
+ });
165
167
  await databases.flair.MemoryCandidate.put(row);
166
168
  staged.push(row);
167
169
  }