@tpsdev-ai/flair 0.44.6 → 0.44.8

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/dist/cli.js CHANGED
@@ -14328,7 +14328,13 @@ program
14328
14328
  console.error(`${render.icons.error} No context available.`);
14329
14329
  process.exit(1);
14330
14330
  }
14331
- const tokensUsed = result.tokenEstimate ?? 0;
14331
+ // flair#1199 the budget footer reflects the PROSE the human injects
14332
+ // (stdout = result.context), not the full serialized payload. tokenEstimate
14333
+ // now measures the whole response (structured containers + prose), which
14334
+ // the CLI's structured fields the human doesn't read would inflate.
14335
+ const tokensUsed = typeof result.context === "string" && result.context.length > 0
14336
+ ? Math.ceil(result.context.length / 4)
14337
+ : (result.tokenEstimate ?? 0);
14332
14338
  const maxTokens = parseInt(opts.maxTokens, 10);
14333
14339
  const included = result.memoriesIncluded ?? 0;
14334
14340
  const truncated = result.memoriesTruncated ?? 0;
@@ -59,7 +59,11 @@ import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
59
59
  *
60
60
  * Request:
61
61
  * { agentId, currentTask?, maxTokens?, includeSoul?, since?,
62
- * channel?, surface?, subjects?, entities? }
62
+ * channel?, surface?, subjects?, entities?, includeContext? }
63
+ * `includeContext` (flair#1199): whether to assemble the prose `context`
64
+ * mirror. Default true here (the resource/REST/CLI path); the /mcp bootstrap
65
+ * wrapper passes false so a token-budgeted connector — which reads the
66
+ * structured containers — never receives the same bodies twice.
63
67
  * `entities` (flair#681): the caller's own declared attention-plane
64
68
  * vocabulary strings (see resources/entity-vocab.ts) for collision
65
69
  * surfacing's entity-overlap join. Invalid entries are silently dropped
@@ -69,12 +73,21 @@ import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
69
73
  *
70
74
  * Response:
71
75
  * { context, sections, tokenEstimate, memoriesIncluded, memoriesAvailable,
72
- * agentId, scope, soul, memories, predicted[, currentTaskHint] }
76
+ * teammateFindingsIncluded, agentId, scope, soul, memories, predicted,
77
+ * teammateFindings[, currentTaskHint][, predictedHint] }
73
78
  * The self-describing keys (flair#1182 part 1) — `agentId` (resolved caller),
74
79
  * `scope` (read model applied to the caller), `soul`/`memories`/`predicted`
75
80
  * (the caller's OWN records as structured containers), and `currentTaskHint`
76
81
  * (present only when currentTask is absent/blank) — are ALWAYS emitted so a
77
82
  * client can tell an empty instance from one that doesn't support them.
83
+ * flair#1199 — the structured containers are CANONICAL; `context` is a prose
84
+ * mirror (opt-in via includeContext). Cross-agent teammate findings ship in
85
+ * the `teammateFindings` container (own memories in `memories`), counted by
86
+ * `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
+ * `predictedHint` is present only when subjects were provided but `predicted`
90
+ * came back empty.
78
91
  */
79
92
  // Collision surfacing (flair#681) tunables.
80
93
  const COLLISION_WINDOW_DAYS = 7;
@@ -110,6 +123,17 @@ const MAX_CANDIDATE_POOL = 100;
110
123
  // `minScore` request param) — preserved verbatim from the original raw
111
124
  // JS dot-product scan's `.filter((s) => s.score > 0.3)`.
112
125
  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;
113
137
  // Rough token estimate: ~4 chars per token for English text
114
138
  function estimateTokens(text) {
115
139
  return Math.ceil(text.length / 4);
@@ -151,7 +175,16 @@ export class BootstrapMemories extends Resource {
151
175
  subjects, // e.g., ["flair", "auth"] — entities to preload context for
152
176
  includeTrust = false, // flair#744 slice 1 — opt-in per-memory trust block
153
177
  abstain = false, // flair#744 slice 2 — opt-in task-relevance abstention
154
- } = data || {};
178
+ // flair#1199 whether to assemble the prose `context` string. The
179
+ // structured containers (soul/memories/predicted/teammateFindings) are the
180
+ // CANONICAL payload; `context` is a human/agent-readable MIRROR of the same
181
+ // bytes. Default TRUE here (the resource/REST/CLI path has always emitted
182
+ // prose, and every direct caller reads it) — but the /mcp bootstrap wrapper
183
+ // (resources/mcp-tools.ts) passes `false` by default, so a token-budgeted
184
+ // connector, which consumes the structured fields, never receives the same
185
+ // bodies twice. When false, `context` is a compact structural pointer (no
186
+ // bodies), so nothing crosses the wire twice on that path.
187
+ includeContext = true, } = data || {};
155
188
  // Authenticated identity lives on getContext().request — `this.request` is
156
189
  // NOT populated on Harper v5 Resources. Reading it returned undefined and
157
190
  // the scope check was silently bypassed, letting a non-admin agent read
@@ -189,27 +222,59 @@ export class BootstrapMemories extends Resource {
189
222
  collision: [],
190
223
  events: [],
191
224
  };
192
- let tokenBudget = maxTokens;
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));
233
+ // Single shared budget across soul + every memory section (soul used to be
234
+ // 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);
238
+ // Own memories included in the payload (permanent + recent + predicted +
239
+ // own task-relevant). Denominator is `memoriesAvailable` (own-scoped), so
240
+ // memoriesIncluded ≤ memoriesAvailable always holds (#1199 coherent
241
+ // counters). Cross-agent teammate findings are counted separately below.
193
242
  let memoriesIncluded = 0;
194
243
  let memoriesAvailable = 0;
195
244
  let memoriesTruncated = 0;
245
+ // flair#1199 — cross-agent teammate findings included (a DIFFERENT
246
+ // denominator than own memories): counting these into `memoriesIncluded`
247
+ // is what let one client see included(9) > available(3).
248
+ let teammateFindingsIncluded = 0;
196
249
  // flair#1182 (part 1) — self-describing bootstrap. These structured
197
250
  // container keys are ALWAYS emitted on the response (empty `{}`/`[]` when
198
251
  // the caller has nothing), so a client can tell an *empty* instance from
199
252
  // one that doesn't support these keys at all — and can read the caller's
200
253
  // own soul/memories as structured data instead of parsing the `context`
201
- // markdown string. Scoped to the CALLER'S OWN records only
202
- // (permanent/recent/relevant/predicted are all agentId==self reads);
203
- // teammate findings stay in `context`/`sections.teammate` and are never
204
- // duplicated here, so these containers carry no other agent's data.
254
+ // markdown string. `soul`/`memories`/`predicted` are scoped to the CALLER'S
255
+ // OWN records only (permanent/recent/relevant/predicted are all agentId==self
256
+ // reads). flair#1199 cross-agent teammate findings now have their OWN
257
+ // structured container (`teammateFindings`, below), attributed via `source`,
258
+ // rather than living only in the prose `context` (which is opt-in as of
259
+ // #1199); the own-only containers still carry no other agent's data.
205
260
  const soulMap = {};
206
261
  const includedOwnMemories = [];
207
262
  const includedPredicted = [];
263
+ // flair#1199 — teammate (cross-agent) findings get their OWN structured
264
+ // container. Before #1199 they lived ONLY in the prose `context`; now that
265
+ // `context` is an opt-in mirror (default off on the /mcp path), they need a
266
+ // structured home so a connector that consumes the containers still sees
267
+ // them. Kept SEPARATE from `memories`/`predicted` (which stay own-only per
268
+ // the #1182 boundary) and clearly attributed via `source`.
269
+ const includedTeammateFindings = [];
208
270
  const leanMemory = (m, section) => ({
209
271
  id: m.id,
210
272
  content: m.content,
211
273
  durability: m.durability ?? null,
212
274
  createdAt: m.createdAt ?? null,
275
+ // #1201 — the record's own last-write time, so a structured consumer can
276
+ // compute freshness off the same anchor the trust block's ageDays uses.
277
+ updatedAt: m.updatedAt ?? null,
213
278
  agentId: m.agentId ?? agentId,
214
279
  subject: m.subject ?? null,
215
280
  section,
@@ -257,12 +322,15 @@ export class BootstrapMemories extends Resource {
257
322
  if (maxChars > 100) {
258
323
  const truncated = `**${entry.key}:** ${entry.line.slice(entry.key.length + 6, entry.key.length + 6 + maxChars)}…(truncated)`;
259
324
  sections.soul.push(truncated);
260
- soulTokens += estimateTokens(truncated);
325
+ const cost = estimateTokens(truncated);
326
+ soulTokens += cost;
327
+ tokenBudget -= cost; // #1199 — soul draws from the shared budget
261
328
  }
262
329
  continue;
263
330
  }
264
331
  sections.soul.push(entry.line);
265
332
  soulTokens += entry.tokens;
333
+ tokenBudget -= entry.tokens; // #1199 — soul draws from the shared budget
266
334
  }
267
335
  }
268
336
  // --- 1b. Skill assignments (ordered by priority, conflict detection) ---
@@ -388,14 +456,23 @@ export class BootstrapMemories extends Resource {
388
456
  // requested, so a non-trust bootstrap fetches (and returns) exactly what it
389
457
  // did before. These records are never returned raw when the block is off,
390
458
  // so widening the select cannot change the off-path response bytes.
459
+ // #1201 — `updatedAt` is projected on BOTH paths (not just the trust path):
460
+ // the structured `memories`/`predicted` containers carry it so a consumer
461
+ // can compute freshness, and the trust block's ageDays keys off it.
391
462
  const OWN_SELECT = includeTrust
392
- ? ["id", "agentId", "content", "durability", "createdAt", "supersedes", "subject", "validTo", "expiresAt", "_safetyFlags", "provenance", "usageCount", "validFrom"]
393
- : ["id", "agentId", "content", "durability", "createdAt", "supersedes", "subject", "validTo", "expiresAt", "_safetyFlags"];
463
+ ? ["id", "agentId", "content", "durability", "createdAt", "updatedAt", "supersedes", "subject", "validTo", "expiresAt", "_safetyFlags", "provenance", "usageCount", "validFrom"]
464
+ : ["id", "agentId", "content", "durability", "createdAt", "updatedAt", "supersedes", "subject", "validTo", "expiresAt", "_safetyFlags"];
394
465
  // flair#744 slice 1 — the Memory records that became visible lines in the
395
466
  // memory-bearing sections (permanent/recent/predicted/relevant/teammate),
396
467
  // collected as they're added so the opt-in `trust` array below can carry a
397
468
  // self-contained block per included memory. Stays empty (and unused) when
398
- // includeTrust is off.
469
+ // includeTrust is off. flair#1201 — each entry carries the SECTION it landed
470
+ // in, so a trust entry's `matchQuality` is legible: null on a lifecycle
471
+ // section (permanent/recent/predicted — not a retrieval surface) reads as
472
+ // "not scored", not as a scoring failure, and a band on a retrieval section
473
+ // (relevant/teammate) is applied by the SAME rule to own and teammate
474
+ // records. Fixes the "own recent → null while teammate → strong looks like
475
+ // my own records scored worse" misread.
399
476
  const includedTrustMemories = [];
400
477
  // flair#744 slice 2 — the best absolute semantic similarity seen while
401
478
  // scoring the task-relevant candidate pool (section 4). Drives the opt-in
@@ -451,12 +528,12 @@ export class BootstrapMemories extends Resource {
451
528
  const permanent = permanentRows.filter((m) => !permanentSupersededIds.has(m.id));
452
529
  for (const m of permanent) {
453
530
  const line = formatMemory(m, agentId);
454
- const cost = estimateTokens(line);
531
+ const cost = estimateTokens(line) + STRUCT_ITEM_OVERHEAD_TOKENS; // #1199 structured cost
455
532
  if (cost <= tokenBudget) {
456
533
  sections.permanent.push(line);
457
534
  includedOwnMemories.push(leanMemory(m, "permanent"));
458
535
  if (includeTrust)
459
- includedTrustMemories.push(m);
536
+ includedTrustMemories.push({ m, section: "permanent" });
460
537
  tokenBudget -= cost;
461
538
  memoriesIncluded++;
462
539
  }
@@ -523,7 +600,7 @@ export class BootstrapMemories extends Resource {
523
600
  let recentSpent = 0;
524
601
  for (const m of recent) {
525
602
  const line = formatMemory(m, agentId);
526
- const cost = estimateTokens(line);
603
+ const cost = estimateTokens(line) + STRUCT_ITEM_OVERHEAD_TOKENS; // #1199 structured cost
527
604
  if (recentSpent + cost > recentBudget) {
528
605
  memoriesTruncated++;
529
606
  continue;
@@ -531,7 +608,7 @@ export class BootstrapMemories extends Resource {
531
608
  sections.recent.push(line);
532
609
  includedOwnMemories.push(leanMemory(m, "recent"));
533
610
  if (includeTrust)
534
- includedTrustMemories.push(m);
611
+ includedTrustMemories.push({ m, section: "recent" });
535
612
  recentSpent += cost;
536
613
  tokenBudget -= cost;
537
614
  memoriesIncluded++;
@@ -566,7 +643,7 @@ export class BootstrapMemories extends Resource {
566
643
  let predictedSpent = 0;
567
644
  for (const m of subjectMemories) {
568
645
  const line = formatMemory(m, agentId);
569
- const cost = estimateTokens(line);
646
+ const cost = estimateTokens(line) + STRUCT_ITEM_OVERHEAD_TOKENS; // #1199 structured cost
570
647
  if (predictedSpent + cost > predictedBudget) {
571
648
  memoriesTruncated++;
572
649
  continue;
@@ -574,7 +651,7 @@ export class BootstrapMemories extends Resource {
574
651
  sections.predicted.push(line);
575
652
  includedPredicted.push(leanMemory(m, "predicted"));
576
653
  if (includeTrust)
577
- includedTrustMemories.push(m);
654
+ includedTrustMemories.push({ m, section: "predicted" });
578
655
  predictedSpent += cost;
579
656
  tokenBudget -= cost;
580
657
  memoriesIncluded++;
@@ -741,22 +818,42 @@ export class BootstrapMemories extends Resource {
741
818
  // section double-spends.
742
819
  for (const { memory: m } of scored) {
743
820
  const line = formatMemory(m, agentId);
744
- const cost = estimateTokens(line);
821
+ const cost = estimateTokens(line) + STRUCT_ITEM_OVERHEAD_TOKENS; // #1199 structured cost
745
822
  if (cost > tokenBudget)
746
823
  continue;
747
824
  if (m._source) {
748
825
  sections.teammate.push(line);
826
+ // flair#1199 — cross-agent teammate findings join their OWN
827
+ // structured container (attributed via `source`), so a connector
828
+ // consuming the containers still sees them when prose `context` is
829
+ // off. Counted separately (teammateFindingsIncluded), NOT into
830
+ // memoriesIncluded — that different-denominator mix is what let
831
+ // included exceed available.
832
+ includedTeammateFindings.push({
833
+ id: m.id,
834
+ content: m.content,
835
+ durability: m.durability ?? null,
836
+ createdAt: m.createdAt ?? null,
837
+ updatedAt: m.updatedAt ?? null,
838
+ subject: m.subject ?? null,
839
+ source: m._source,
840
+ section: "teammate",
841
+ });
842
+ if (includeTrust)
843
+ includedTrustMemories.push({ m, section: "teammate" });
844
+ tokenBudget -= cost;
845
+ teammateFindingsIncluded++;
749
846
  }
750
847
  else {
751
848
  sections.relevant.push(line);
752
849
  // flair#1182 — own task-relevant records join the `memories`
753
- // container; teammate (`_source`) records stay in `context` only.
850
+ // container.
754
851
  includedOwnMemories.push(leanMemory(m, "relevant"));
852
+ if (includeTrust)
853
+ includedTrustMemories.push({ m, section: "relevant" });
854
+ tokenBudget -= cost;
855
+ memoriesIncluded++;
755
856
  }
756
- if (includeTrust)
757
- includedTrustMemories.push(m);
758
- tokenBudget -= cost;
759
- memoriesIncluded++;
760
857
  }
761
858
  }
762
859
  }
@@ -905,8 +1002,30 @@ export class BootstrapMemories extends Resource {
905
1002
  continue;
906
1003
  eventResults.push(event);
907
1004
  }
908
- eventResults.sort((a, b) => (a.createdAt || "").localeCompare(b.createdAt || ""));
909
- for (const evt of eventResults.slice(0, 10)) {
1005
+ // flair#1200 collapse byte-identical duplicate events before rendering.
1006
+ // The same logical event can land in the table more than once (a producer
1007
+ // that double-fires, or the same broadcast emitted from two paths); each
1008
+ // physical row has a distinct id/createdAt (OrgEvent.post keys the id off
1009
+ // 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.
1014
+ const eventBySignature = new Map();
1015
+ for (const evt of eventResults) {
1016
+ const sig = JSON.stringify([
1017
+ evt.kind ?? "",
1018
+ evt.summary ?? "",
1019
+ evt.detail ?? "",
1020
+ Array.isArray(evt.targetIds) ? [...evt.targetIds].sort() : (evt.targetIds ?? null),
1021
+ ]);
1022
+ const prev = eventBySignature.get(sig);
1023
+ if (!prev || (evt.createdAt || "") > (prev.createdAt || ""))
1024
+ eventBySignature.set(sig, evt);
1025
+ }
1026
+ const dedupedEvents = [...eventBySignature.values()]
1027
+ .sort((a, b) => (a.createdAt || "").localeCompare(b.createdAt || ""));
1028
+ for (const evt of dedupedEvents.slice(0, 10)) {
910
1029
  const elapsed = Date.now() - new Date(evt.createdAt).getTime();
911
1030
  const mins = Math.floor(elapsed / 60_000);
912
1031
  const relTime = mins < 60 ? `${mins}min ago` : `${Math.floor(mins / 60)}h ago`;
@@ -959,9 +1078,27 @@ export class BootstrapMemories extends Resource {
959
1078
  if (sections.events.length > 0) {
960
1079
  parts.push("## Recent Org Events\n" + sections.events.join("\n"));
961
1080
  }
962
- const context = parts.join("\n\n");
1081
+ const fullContext = parts.join("\n\n");
1082
+ // flair#1199 — the structured containers are canonical; `context` is a prose
1083
+ // MIRROR of the same bytes. When includeContext is off (the /mcp default),
1084
+ // ship a compact structural pointer instead of re-embedding every body — so
1085
+ // no field's bytes cross the wire twice. Always a string, so a client can
1086
+ // still tell an empty instance from an unsupported one. When on, the prose
1087
+ // is the full assembled context (the resource/REST/CLI behaviour, unchanged).
1088
+ const context = includeContext
1089
+ ? fullContext
1090
+ : (fullContext.length === 0
1091
+ ? ""
1092
+ : `Structured payload in soul/memories/predicted/teammateFindings `
1093
+ + `(${memoriesIncluded} own + ${teammateFindingsIncluded} teammate memories, `
1094
+ + `${sections.soul.length} soul entries). Pass includeContext:true for the assembled prose context.`);
963
1095
  const soulTokens = sections.soul.reduce((sum, line) => sum + estimateTokens(line), 0);
964
- const memoryTokens = maxTokens - tokenBudget;
1096
+ // #1199 memory-line token spend (informational breakdown), independent of
1097
+ // the reserve/soul now sharing the budget. Sum of the rendered memory lines.
1098
+ const memoryTokens = [
1099
+ ...sections.permanent, ...sections.recent, ...sections.predicted,
1100
+ ...sections.relevant, ...sections.teammate,
1101
+ ].reduce((sum, line) => sum + estimateTokens(line), 0);
965
1102
  // flair#744 slice 1 — opt-in per-memory trust block. Bootstrap renders
966
1103
  // memories as text lines rather than result objects, so the block is
967
1104
  // surfaced as a `trust` array of self-contained entries (each carries its
@@ -969,9 +1106,12 @@ export class BootstrapMemories extends Resource {
969
1106
  // HERE, in the response tail, strictly after all read-scope resolution and
970
1107
  // purely for the response — never consulted for any authority decision
971
1108
  // (#735-spirit zero-authority invariant). Default OFF ⇒ the `trust` key is
972
- // absent ⇒ the response is byte-identical to pre-slice-1.
1109
+ // absent ⇒ the response is byte-identical to pre-slice-1. flair#1201 — each
1110
+ // entry carries its `section` so `matchQuality: null` on a lifecycle section
1111
+ // reads as "not a retrieval surface", not as a scoring failure on the
1112
+ // caller's own records.
973
1113
  const trust = includeTrust
974
- ? includedTrustMemories.map((m) => ({ id: m.id, ...buildTrustBlock(m) }))
1114
+ ? includedTrustMemories.map(({ m, section }) => ({ id: m.id, section, ...buildTrustBlock(m) }))
975
1115
  : undefined;
976
1116
  // flair#744 slice 2 — opt-in abstention verdict for the task-relevance
977
1117
  // surface. Present ONLY when `abstain` is requested (byte-identical to
@@ -1000,7 +1140,17 @@ export class BootstrapMemories extends Resource {
1000
1140
  const currentTaskHint = taskProvided
1001
1141
  ? undefined
1002
1142
  : "No currentTask was provided. Pass currentTask (a short description of what you're working on) to enable task-relevant memory retrieval, teammate findings, and collision surfacing.";
1003
- return {
1143
+ // flair#1199 — when `subjects` were provided but nothing surfaced in
1144
+ // `predicted`, say WHY (like currentTaskHint), so an empty `predicted: []`
1145
+ // next to a non-empty `subjects` doesn't read as broken. Predicted fills
1146
+ // from your OWN non-permanent memories whose `subject` matches one of the
1147
+ // provided subjects; it stays empty until you've tagged memories that way.
1148
+ const predictedHint = (predictedSubjects.length > 0 && includedPredicted.length === 0)
1149
+ ? `No memories tagged with the requested subjects (${predictedSubjects.join(", ")}) were found. `
1150
+ + `predicted surfaces your own non-permanent memories whose subject matches one of the provided `
1151
+ + `subjects — it fills as you store memories tagged with these subjects.`
1152
+ : undefined;
1153
+ const responseBody = {
1004
1154
  context,
1005
1155
  // flair#1182 (part 1) — always-present self-describing keys: who the
1006
1156
  // server resolved the caller as, the read model applied, and the caller's
@@ -1011,7 +1161,12 @@ export class BootstrapMemories extends Resource {
1011
1161
  soul: soulMap,
1012
1162
  memories: includedOwnMemories,
1013
1163
  predicted: includedPredicted,
1164
+ // flair#1199 — cross-agent teammate findings as a structured container
1165
+ // (always present, `[]` when none), so a connector that consumes the
1166
+ // containers still gets them when prose `context` is off.
1167
+ teammateFindings: includedTeammateFindings,
1014
1168
  ...(currentTaskHint ? { currentTaskHint } : {}),
1169
+ ...(predictedHint ? { predictedHint } : {}),
1015
1170
  ...(trust ? { trust } : {}),
1016
1171
  ...(abstention ? { abstention } : {}),
1017
1172
  sections: {
@@ -1027,12 +1182,25 @@ export class BootstrapMemories extends Resource {
1027
1182
  collision: sections.collision.length,
1028
1183
  events: sections.events.length,
1029
1184
  },
1030
- tokenEstimate: soulTokens + memoryTokens,
1031
1185
  soulTokens,
1032
1186
  memoryTokens,
1187
+ // flair#1199 — own memories included (denominator: memoriesAvailable, also
1188
+ // own-scoped), so memoriesIncluded ≤ memoriesAvailable always holds.
1033
1189
  memoriesIncluded,
1034
1190
  memoriesAvailable,
1191
+ // Cross-agent teammate findings included — a SEPARATE denominator, labelled
1192
+ // so it's never confused with the own-memory counters.
1193
+ teammateFindingsIncluded,
1035
1194
  memoriesTruncated,
1036
1195
  };
1196
+ // flair#1199 — tokenEstimate must reflect the ACTUAL serialized payload the
1197
+ // caller receives (the structured containers included), not just the prose
1198
+ // `context`. The old `soulTokens + memoryTokens` counted only the context
1199
+ // 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).
1203
+ const tokenEstimate = estimateTokens(JSON.stringify(responseBody));
1204
+ return { ...responseBody, tokenEstimate };
1037
1205
  }
1038
1206
  }
@@ -364,6 +364,13 @@ async function bootstrap(agent, args) {
364
364
  surface: args?.surface,
365
365
  subjects: args?.subjects,
366
366
  entities: args?.entities,
367
+ // flair#1199 — a /mcp connector consumes the STRUCTURED containers
368
+ // (soul/memories/predicted/teammateFindings), so the prose `context` mirror
369
+ // is OFF by default here: shipping both doubled the payload past maxTokens
370
+ // (the reported ~2× overrun). The resource itself defaults includeContext
371
+ // true (the REST/CLI prose path); this wrapper flips it for the connector,
372
+ // and forwards an explicit true when a caller wants the prose anyway.
373
+ includeContext: args?.includeContext === true,
367
374
  };
368
375
  // flair#744 slice 1 — opt-in per-memory trust block array. Forwarded ONLY
369
376
  // when requested so a plain bootstrap delegates a byte-identical body.
@@ -392,14 +399,29 @@ async function bootstrap(agent, args) {
392
399
  }
393
400
  async function soulSet(agent, args) {
394
401
  const Cls = await handler("Soul");
395
- const h = new Cls(undefined, delegationContext(agent));
396
- // Soul records are keyed `id = agentId:key` (see flair-client SoulApi.set and
397
- // schemas/memory.graphql). Use PUT with the explicit id so soul_get's
398
- // `${agentId}:${key}` lookup finds it a plain post() would mint a random id
399
- // and orphan the entry from get(). Soul.put enforces write ownership via
400
- // resolveAgentAuth (non-admin can only write agentId === self).
402
+ // flair#1181 this write MUST go through a COLLECTION-bound instance
403
+ // (`collectionResource(Cls, ctx).post(...)`), the same create path the sibling
404
+ // write tools use (memoryStore / workspaceSet / orgEvent). The previous
405
+ // `new Cls(undefined, ctx).put({ id, ... })` was a PUT on an UNLOADED instance:
406
+ // an unloaded instance has no primary key, so Harper's instance put()/save()
407
+ // threw `Invalid primary key type: undefined` (the same defect class the
408
+ // memoryGet/update/delete/soulGet by-id READS were migrated off of — see those
409
+ // wrappers, and resources/in-process.ts's header: "flair itself got [collection
410
+ // binding] wrong in four MCP tool paths"). soul_set's only prior test drove a
411
+ // MOCKED handler, so the real instance-put never ran and it shipped broken on
412
+ // the connector path.
413
+ //
414
+ // A COLLECTION post (not a static `Cls.put(record, ctx)`) is the right form:
415
+ // it routes through Soul.post(), which stamps createdAt (a schema-required,
416
+ // non-null field). Static `Cls.put` reaches Soul.put(), which does NOT stamp
417
+ // createdAt on a create, so it fails a "Property createdAt is required"
418
+ // validation. Soul.post honors the explicit body `id`, so the record is still
419
+ // keyed `id = agentId:key` and soul_get's `${agentId}:${key}` lookup finds it —
420
+ // a random-id create would orphan the entry from get(). Soul.post enforces
421
+ // write ownership via resolveAgentAuth (non-admin can only write agentId === self).
401
422
  const id = `${agent.agentId}:${args?.key}`;
402
- return unwrap(await h.put({
423
+ const h = await collectionResource(Cls, delegationContext(agent));
424
+ return unwrap(await h.post({
403
425
  id,
404
426
  agentId: agent.agentId,
405
427
  key: args?.key,
@@ -621,6 +643,7 @@ export const TOOLS = {
621
643
  },
622
644
  includeTrust: { type: "boolean", description: "Also return a `trust` array with a per-included-memory trust-evidence block (provenance, author, usage, freshness, supersession). Default false." },
623
645
  abstain: { type: "boolean", description: "Opt into a task-relevance abstention verdict: also return an `abstention` object ({ abstained, bestScore, threshold }) reporting whether any memory covered `currentTask` above a global confidence threshold. Default false." },
646
+ includeContext: { type: "boolean", description: "Also return the prose `context` string — a human-readable mirror of the structured soul/memories/predicted/teammateFindings containers (which are the canonical payload). Default false here: the structured fields already carry everything, so shipping the prose too would double the payload." },
624
647
  },
625
648
  },
626
649
  },
@@ -141,6 +141,7 @@ export function buildTrustBlock(record, now = Date.now()) {
141
141
  const validFrom = typeof record.validFrom === "string" ? record.validFrom : null;
142
142
  const validTo = typeof record.validTo === "string" ? record.validTo : null;
143
143
  const createdAt = typeof record.createdAt === "string" ? record.createdAt : null;
144
+ const updatedAt = typeof record.updatedAt === "string" ? record.updatedAt : null;
144
145
  const validToMs = parseTime(validTo);
145
146
  const validFromMs = parseTime(validFrom);
146
147
  let validityStatus = "valid";
@@ -150,9 +151,13 @@ export function buildTrustBlock(record, now = Date.now()) {
150
151
  else if (Number.isFinite(validFromMs) && validFromMs > now) {
151
152
  validityStatus = "future";
152
153
  }
153
- const createdMs = parseTime(createdAt);
154
- const ageDays = Number.isFinite(createdMs)
155
- ? Math.max(0, Math.floor((now - createdMs) / MS_PER_DAY))
154
+ // #1201 freshness keys off the record's OWN last-write time (updatedAt),
155
+ // falling back to createdAt. A record updated today must not read as stale
156
+ // off its original createdAt. updatedAt is the record's own field, so this
157
+ // reintroduces no lineage-inheritance (#1189).
158
+ const freshnessMs = parseTime(updatedAt ?? createdAt);
159
+ const ageDays = Number.isFinite(freshnessMs)
160
+ ? Math.max(0, Math.floor((now - freshnessMs) / MS_PER_DAY))
156
161
  : null;
157
162
  return {
158
163
  author: typeof record.agentId === "string" ? record.agentId : null,
@@ -165,6 +170,7 @@ export function buildTrustBlock(record, now = Date.now()) {
165
170
  validFrom,
166
171
  validTo,
167
172
  createdAt,
173
+ updatedAt,
168
174
  ageDays,
169
175
  supersedes: typeof record.supersedes === "string" ? record.supersedes : null,
170
176
  // flair#744 refinement — confidence band from the result's absolute
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.44.6",
3
+ "version": "0.44.8",
4
4
  "packageManager": "bun@1.3.10",
5
5
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
6
6
  "type": "module",