@tpsdev-ai/flair 0.44.9 → 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, teammateFindingsTruncated, agentId, scope, soul,
77
- * memories, predicted, teammateFindings, events[, 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`
@@ -90,24 +92,42 @@ import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
90
92
  * when the prose `context` is off (the /mcp default); before #1206 they lived
91
93
  * ONLY in the prose string and were orphaned at includeContext=false.
92
94
  *
93
- * flair#1199 CAP CONTRACT (corrected): `maxTokens` is the HARD cap on CONTENT
94
- * SELECTION — the shared `tokenBudget` starts at `maxTokens` and every admitted
95
- * soul/memory/finding line is gated against the remaining budget, so the sum of
96
- * selected CONTENT never exceeds `maxTokens`. `tokenEstimate` HONESTLY reports
97
- * the real serialized payload (`JSON.stringify(responseBody)`), which includes
98
- * the structured-container JSON scaffolding and so MAY exceed `maxTokens` by
99
- * that overhead — measurement (honest reporting) is deliberately decoupled from
100
- * budgeting (what to select). This is the flair#1207 fix: #1199 had folded a
101
- * per-item structured overhead + a scaffolding reserve INTO the selection
102
- * budget, which silently shrank recall below 0.44.6 for the same `maxTokens`;
103
- * the overhead is a reporting concern (already captured by `tokenEstimate`),
104
- * never a selection constraint, so it no longer shrinks the content budget.
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."
105
119
  * `predictedHint` is present only when subjects were provided but `predicted`
106
120
  * came back empty.
107
121
  */
108
122
  // Collision surfacing (flair#681) tunables.
109
123
  const COLLISION_WINDOW_DAYS = 7;
110
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;
111
131
  // ─── Bootstrap scale fix (flair-bootstrap-scale-fix) tunables ───────────────
112
132
  //
113
133
  // Own-scoped, non-permanent memories (the "recent" adaptive-window source,
@@ -152,10 +172,10 @@ const TASK_RELEVANCE_FLOOR = 0.3;
152
172
  // line — while `tokenEstimate` keeps reporting the true serialized size, which
153
173
  // may exceed `maxTokens` by the scaffolding overhead. See the module-doc CAP
154
174
  // CONTRACT above.
155
- // Rough token estimate: ~4 chars per token for English text
156
- function estimateTokens(text) {
157
- return Math.ceil(text.length / 4);
158
- }
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.
159
179
  // `agentId` is the BOOTSTRAPPING agent (the caller) — used only to decide
160
180
  // whether to annotate attribution, never to change what's read (that
161
181
  // boundary is resolveReadScope()'s job, upstream of this function). A
@@ -193,6 +213,15 @@ export class BootstrapMemories extends Resource {
193
213
  subjects, // e.g., ["flair", "auth"] — entities to preload context for
194
214
  includeTrust = false, // flair#744 slice 1 — opt-in per-memory trust block
195
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,
196
225
  // flair#1199 — whether to assemble the prose `context` string. The
197
226
  // structured containers (soul/memories/predicted/teammateFindings) are the
198
227
  // CANONICAL payload; `context` is a human/agent-readable MIRROR of the same
@@ -263,16 +292,37 @@ export class BootstrapMemories extends Resource {
263
292
  let memoriesIncluded = 0;
264
293
  let memoriesAvailable = 0;
265
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();
266
313
  // flair#1199 — cross-agent teammate findings included (a DIFFERENT
267
314
  // denominator than own memories): counting these into `memoriesIncluded`
268
315
  // is what let one client see included(9) > available(3).
269
316
  let teammateFindingsIncluded = 0;
270
317
  // flair#1207 — teammate findings SKIPPED for size in the task-relevant
271
- // packing loop. That loop `continue`s past an over-budget record silently;
272
- // without a counter, a client can't tell "no relevant teammate finding" from
273
- // "a relevant one existed but didn't fit the budget" (Sherlock's #1207
274
- // self-describing-size-skip note; own-memory size-skips already increment
275
- // `memoriesTruncated`, but that loop never did now both do).
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.
276
326
  let teammateFindingsTruncated = 0;
277
327
  // flair#1182 (part 1) — self-describing bootstrap. These structured
278
328
  // container keys are ALWAYS emitted on the response (empty `{}`/`[]` when
@@ -574,10 +624,10 @@ export class BootstrapMemories extends Resource {
574
624
  if (includeTrust)
575
625
  includedTrustMemories.push({ m, section: "permanent" });
576
626
  tokenBudget -= cost;
577
- memoriesIncluded++;
627
+ includedOwnIds.add(m.id); // #1207 — count by unique own-memory id
578
628
  }
579
629
  else {
580
- memoriesTruncated++;
630
+ truncatedOwnIds.add(m.id); // #1207 — budget-skip, deduped against inclusions at the end
581
631
  }
582
632
  }
583
633
  // --- 3. Recent memories (adaptive window) ---
@@ -641,7 +691,7 @@ export class BootstrapMemories extends Resource {
641
691
  const line = formatMemory(m, agentId);
642
692
  const cost = estimateTokens(line); // #1207 — prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
643
693
  if (recentSpent + cost > recentBudget) {
644
- memoriesTruncated++;
694
+ truncatedOwnIds.add(m.id); // #1207 — budget-skip; may still be admitted later via the task-relevant loop (deduped at the end)
645
695
  continue;
646
696
  }
647
697
  sections.recent.push(line);
@@ -650,7 +700,7 @@ export class BootstrapMemories extends Resource {
650
700
  includedTrustMemories.push({ m, section: "recent" });
651
701
  recentSpent += cost;
652
702
  tokenBudget -= cost;
653
- memoriesIncluded++;
703
+ includedOwnIds.add(m.id); // #1207 — count by unique own-memory id
654
704
  }
655
705
  // --- 3b. Subject-predicted context ---
656
706
  // When subjects are provided (e.g., ["flair", "auth"]), load memories
@@ -661,10 +711,10 @@ export class BootstrapMemories extends Resource {
661
711
  ? subjects.map((s) => s.toLowerCase())
662
712
  : [];
663
713
  if (predictedSubjects.length > 0 && tokenBudget > 200) {
664
- const includedIds = new Set([
665
- ...permanent.map((m) => m.id),
666
- ...recent.filter((_, i) => i < sections.recent.length).map((m) => m.id),
667
- ]);
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.
668
718
  // Draws from the SAME bounded own-scoped, non-permanent set "recent"
669
719
  // uses (nonPermanentActive — see that fetch's doc above for the
670
720
  // shared-source rationale and OWN_NONPERMANENT_FETCH_LIMIT's bound).
@@ -672,7 +722,7 @@ export class BootstrapMemories extends Resource {
672
722
  // query's own condition but kept for parity/clarity with the
673
723
  // pre-refactor filter shape.
674
724
  const subjectMemories = nonPermanentActive
675
- .filter((m) => !includedIds.has(m.id) &&
725
+ .filter((m) => !includedOwnIds.has(m.id) &&
676
726
  m.subject &&
677
727
  predictedSubjects.includes(m.subject.toLowerCase()) &&
678
728
  m.durability !== "permanent" // already loaded
@@ -684,7 +734,7 @@ export class BootstrapMemories extends Resource {
684
734
  const line = formatMemory(m, agentId);
685
735
  const cost = estimateTokens(line); // #1207 — prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
686
736
  if (predictedSpent + cost > predictedBudget) {
687
- memoriesTruncated++;
737
+ truncatedOwnIds.add(m.id); // #1207 — budget-skip (deduped against inclusions at the end)
688
738
  continue;
689
739
  }
690
740
  sections.predicted.push(line);
@@ -693,8 +743,7 @@ export class BootstrapMemories extends Resource {
693
743
  includedTrustMemories.push({ m, section: "predicted" });
694
744
  predictedSpent += cost;
695
745
  tokenBudget -= cost;
696
- memoriesIncluded++;
697
- 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)
698
747
  }
699
748
  }
700
749
  // --- 3c. Active relationships for predicted subjects ---
@@ -748,11 +797,13 @@ export class BootstrapMemories extends Resource {
748
797
  }
749
798
  catch { }
750
799
  if (queryEmbedding) {
751
- // Score all non-included memories by relevance
752
- const includedIds = new Set([
753
- ...permanent.map((m) => m.id),
754
- ...recent.filter((_, i) => i < sections.recent.length).map((m) => m.id),
755
- ]);
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;
756
807
  // Bounded HNSW candidate pool (flair-bootstrap-scale-fix) — replaces
757
808
  // the full-corpus JS dot-product scan (`allMemories` × queryEmbedding,
758
809
  // O(org corpus size) every bootstrap). K formula (Kern-approved):
@@ -860,14 +911,14 @@ export class BootstrapMemories extends Resource {
860
911
  const cost = estimateTokens(line); // #1207 — prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
861
912
  if (cost > tokenBudget) {
862
913
  // flair#1207 — a size-skip in the score-ordered task-relevant loop
863
- // is no longer silent: record it on the counter matching the record's
864
- // denominator (own → memoriesTruncated, teammate → the separate
914
+ // is no longer silent: record it on the denominator matching the
915
+ // record's origin (own → truncatedOwnIds, teammate → the separate
865
916
  // teammateFindingsTruncated), so a client can distinguish "no relevant
866
917
  // finding" from "a relevant finding didn't fit the budget".
867
918
  if (m._source)
868
919
  teammateFindingsTruncated++;
869
920
  else
870
- memoriesTruncated++;
921
+ truncatedOwnIds.add(m.id);
871
922
  continue;
872
923
  }
873
924
  if (m._source) {
@@ -901,7 +952,7 @@ export class BootstrapMemories extends Resource {
901
952
  if (includeTrust)
902
953
  includedTrustMemories.push({ m, section: "relevant" });
903
954
  tokenBudget -= cost;
904
- memoriesIncluded++;
955
+ includedOwnIds.add(m.id); // #1207 — count by unique own-memory id
905
956
  }
906
957
  }
907
958
  }
@@ -1049,6 +1100,15 @@ export class BootstrapMemories extends Resource {
1049
1100
  const isRelevant = !targets || targets.length === 0 || targets.includes(agentId);
1050
1101
  if (!isRelevant)
1051
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;
1052
1112
  eventResults.push(event);
1053
1113
  }
1054
1114
  // flair#1200 — collapse byte-identical duplicate events before rendering.
@@ -1056,10 +1116,10 @@ export class BootstrapMemories extends Resource {
1056
1116
  // that double-fires, or the same broadcast emitted from two paths); each
1057
1117
  // physical row has a distinct id/createdAt (OrgEvent.post keys the id off
1058
1118
  // a millisecond timestamp), so they aren't caught by primary-key upsert
1059
- // and render as exact dupes. Org-event slots are scarce (10), so dedup
1060
- // BEFORE the slice — otherwise ~half the slots are wasted on duplicates.
1061
- // Keyed on the CONTENT (kind + summary + detail + targets), keeping the
1062
- // 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.
1063
1123
  const eventBySignature = new Map();
1064
1124
  for (const evt of eventResults) {
1065
1125
  const sig = JSON.stringify([
@@ -1072,36 +1132,76 @@ export class BootstrapMemories extends Resource {
1072
1132
  if (!prev || (evt.createdAt || "") > (prev.createdAt || ""))
1073
1133
  eventBySignature.set(sig, evt);
1074
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;
1075
1149
  const dedupedEvents = [...eventBySignature.values()]
1076
- .sort((a, b) => (a.createdAt || "").localeCompare(b.createdAt || ""));
1077
- for (const evt of dedupedEvents.slice(0, 10)) {
1078
- const elapsed = Date.now() - new Date(evt.createdAt).getTime();
1079
- const mins = Math.floor(elapsed / 60_000);
1080
- const relTime = mins < 60 ? `${mins}min ago` : `${Math.floor(mins / 60)}h ago`;
1081
- sections.events.push(`- ${evt.kind}: ${evt.summary} (${relTime})`);
1082
- // flair#1206 the SAME deduped+sliced event, structured, so a connector
1083
- // reading the containers gets it when prose `context` is off (the /mcp
1084
- // default). Same set as the prose line ⇒ `sections.events` (the count),
1085
- // the `tokenEstimate` charge (this array is always in the body), and the
1086
- // delivery all key off one thing. Optional fields (detail/targetIds/scope)
1087
- // are omitted when absent so the object stays lean. The targetIds
1088
- // relevance filter and #1200 content-signature dedup were already applied
1089
- // upstream (eventResults → eventBySignature), so this is a pure move from
1090
- // prose to structured — no scope widening, no re-introduced duplicates.
1091
- includedEvents.push({
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 = {
1092
1159
  id: evt.id,
1093
1160
  kind: evt.kind,
1094
1161
  summary: evt.summary,
1095
- ...(evt.detail != null ? { detail: evt.detail } : {}),
1162
+ ...(includeEventDetail && evt.detail != null ? { detail: evt.detail } : {}),
1096
1163
  ...(Array.isArray(evt.targetIds) && evt.targetIds.length > 0 ? { targetIds: evt.targetIds } : {}),
1097
1164
  createdAt: evt.createdAt ?? null,
1098
1165
  ...(evt.scope != null ? { scope: evt.scope } : {}),
1099
- });
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;
1173
+ const elapsed = Date.now() - new Date(evt.createdAt).getTime();
1174
+ const mins = Math.floor(elapsed / 60_000);
1175
+ const relTime = mins < 60 ? `${mins}min ago` : `${Math.floor(mins / 60)}h ago`;
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;
1100
1181
  }
1101
1182
  }
1102
1183
  catch {
1103
1184
  // non-fatal: OrgEvent table may not exist yet
1104
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;
1105
1205
  // --- Build context string ---
1106
1206
  const parts = [];
1107
1207
  if (sections.soul.length > 0) {
@@ -1258,8 +1358,14 @@ export class BootstrapMemories extends Resource {
1258
1358
  },
1259
1359
  soulTokens,
1260
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,
1261
1366
  // flair#1199 — own memories included (denominator: memoriesAvailable, also
1262
- // 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.
1263
1369
  memoriesIncluded,
1264
1370
  memoriesAvailable,
1265
1371
  // Cross-agent teammate findings included — a SEPARATE denominator, labelled
@@ -1267,24 +1373,35 @@ export class BootstrapMemories extends Resource {
1267
1373
  teammateFindingsIncluded,
1268
1374
  memoriesTruncated,
1269
1375
  // flair#1207 — teammate findings skipped for size in the task-relevant loop
1270
- // (own size-skips there increment memoriesTruncated). Surfacing this makes
1271
- // a size-skip self-describing: "a relevant teammate finding didn't fit"
1272
- // is now distinguishable from "no relevant teammate finding".
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".
1273
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,
1274
1385
  };
1275
1386
  // flair#1199 — tokenEstimate must reflect the ACTUAL serialized payload the
1276
1387
  // caller receives (the structured containers included), not just the prose
1277
1388
  // `context`. The old `soulTokens + memoryTokens` counted only the context
1278
1389
  // string, so it under-reported by ~2× once the structured fields shipped
1279
1390
  // alongside. Measured over the assembled body (the ~1-line tokenEstimate
1280
- // field it omits is negligible). flair#1207 CAP CONTRACT: this is an HONEST
1281
- // report of the real serialized size, NOT a value bounded by `maxTokens`.
1282
- // `maxTokens` is the hard cap on CONTENT SELECTION (the shared tokenBudget);
1283
- // the structured-container JSON scaffolding is genuine payload the caller
1284
- // pays for, so tokenEstimate MAY exceed `maxTokens` by that overhead. Do not
1285
- // "fix" an over-maxTokens tokenEstimate by shrinking selection that is the
1286
- // exact #1199→#1207 regression (it dropped relevant findings). If the real
1287
- // payload consistently overruns for a use case, raise `maxTokens`.
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`.
1288
1405
  const tokenEstimate = estimateTokens(JSON.stringify(responseBody));
1289
1406
  return { ...responseBody, tokenEstimate };
1290
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
  }