@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.
@@ -116,14 +116,34 @@ async function unwrap(value) {
116
116
  return value;
117
117
  }
118
118
  /**
119
- * flair#1188 — remove the raw `embedding` vector from a record before it is
120
- * returned over the MCP surface. A stored memory carries a 768-float
121
- * `embedding` (the HNSW vector); inlined into a tool result that is thousands
122
- * of noise tokens per record on chat connectors that have a fixed context
123
- * budget, and the caller can never do anything useful with it. Returns a
124
- * shallow copy WITHOUT `embedding` (never mutates the source record), and
125
- * passes through anything that is not a plain record — null, primitives,
126
- * arrays, and the `{ error, status }` shapes `unwrap` produces — untouched.
119
+ * flair#1188 — the internal, embedding-engine-owned fields a memory record
120
+ * carries that must NEVER cross the MCP surface. Both are server-managed and
121
+ * useless (or misleading) to a connector:
122
+ *
123
+ * - `embedding` — the raw 768-float HNSW vector; thousands of noise
124
+ * tokens per record on a fixed-budget chat connector, and
125
+ * the caller can do nothing with it (flair#1188).
126
+ * - `embeddingModel` the model id stamped on every write
127
+ * (resources/Memory.ts stamps `content.embeddingModel =
128
+ * getModelId()`; schemas/memory.graphql declares it
129
+ * @indexed). The WRITE wrappers already treat it as
130
+ * internal — memory_update `delete`s it from both the
131
+ * overwrite and the supersede record — so the READ path
132
+ * must strip it too, or memory_get leaks an internal
133
+ * field the write echoes hide (flair#1213, Sherlock #1).
134
+ *
135
+ * Exported so the flair#1213 conformance "no leaked internal fields" invariant
136
+ * enumerates the SAME list this function strips: the strip and the assertion
137
+ * cannot drift, and adding a field here automatically extends both.
138
+ */
139
+ export const INTERNAL_MEMORY_FIELDS = ["embedding", "embeddingModel"];
140
+ /**
141
+ * flair#1188 / flair#1213 — remove the internal embedding-engine fields (see
142
+ * `INTERNAL_MEMORY_FIELDS`) from a record before it is returned over the MCP
143
+ * surface. Returns a shallow copy WITHOUT those fields (never mutates the
144
+ * source record), and passes through anything that is not a plain record —
145
+ * null, primitives, arrays, and the `{ error, status }` shapes `unwrap`
146
+ * produces — untouched.
127
147
  *
128
148
  * `memory_search` already projects with an explicit select that omits
129
149
  * `embedding` (resources/semantic-retrieval-core.ts's DEFAULT_SELECT), and
@@ -131,13 +151,21 @@ async function unwrap(value) {
131
151
  * needed on the FULL-record read/write paths (memory_get, and the write
132
152
  * responses that echo the stored row).
133
153
  */
134
- function stripEmbedding(value) {
154
+ function stripInternalFields(value) {
135
155
  if (!value || typeof value !== "object" || Array.isArray(value))
136
156
  return value;
137
- if (!("embedding" in value))
138
- return value;
139
- const { embedding, ...rest } = value;
140
- return rest;
157
+ let out = value;
158
+ let copied = false;
159
+ for (const field of INTERNAL_MEMORY_FIELDS) {
160
+ if (field in out) {
161
+ if (!copied) {
162
+ out = { ...out };
163
+ copied = true;
164
+ }
165
+ delete out[field];
166
+ }
167
+ }
168
+ return out;
141
169
  }
142
170
  // ── Tool implementations (thin wrappers over existing handlers) ──────────────
143
171
  //
@@ -217,7 +245,7 @@ async function memoryStore(agent, args) {
217
245
  // flair#1188 — memory_store's response goes through the same buildWriteResponse
218
246
  // echo as memory_update; strip the server-regenerated embedding so no write
219
247
  // tool ever inlines the vector. No-op when the response carries none.
220
- return stripEmbedding(await unwrap(await h.post(body)));
248
+ return stripInternalFields(await unwrap(await h.post(body)));
221
249
  }
222
250
  /**
223
251
  * memory_update — id-targeted, dedup-BYPASSED overwrite/version path (memory-
@@ -248,8 +276,18 @@ async function memoryUpdate(agent, args) {
248
276
  // `new Cls(undefined, ctx).get(id)` returned `undefined` for the caller's own
249
277
  // record (getProperty on an unloaded instance), so memory_update 404'd
250
278
  // ("memory not found") on the connector path before it ever reached a write.
251
- const existing = await Cls.get(id, delegationContext(agent));
252
- if (!existing) {
279
+ //
280
+ // flair#1213 — the get MUST be `unwrap`ped (as memoryGet does), not consumed
281
+ // raw. Memory.get()'s makeByIdReadGate returns a NOT_FOUND() *Response* (404)
282
+ // for an absent or non-readable id — a TRUTHY object with no `id` — so the
283
+ // bare `if (!existing)` guard never fired: the code fell through and PUT a
284
+ // record whose id spread off the Response as `undefined`, throwing the
285
+ // MISDIRECTING "Invalid primary key of null" instead of a clean 404. This is
286
+ // the same by-id-read-on-the-connector-seam class as #1181, caught by the
287
+ // conformance error contract (Kern #5). Unwrap, then treat a 404/error/absent
288
+ // result as "not found".
289
+ const existing = await unwrap(await Cls.get(id, delegationContext(agent)));
290
+ if (!existing || existing.error != null || existing.status === 404) {
253
291
  return { error: "memory not found", status: 404 };
254
292
  }
255
293
  if (preserveHistory) {
@@ -291,7 +329,7 @@ async function memoryUpdate(agent, args) {
291
329
  // flair#1188 — the write response echoes the stored row (Memory.post
292
330
  // regenerates the embedding server-side), so strip the vector before it
293
331
  // returns over the MCP surface. No-op when the response carries none.
294
- return stripEmbedding(await unwrap(await coll.post(record)));
332
+ return stripInternalFields(await unwrap(await coll.post(record)));
295
333
  }
296
334
  const merged = { ...existing, content, updatedAt: new Date().toISOString() };
297
335
  delete merged.embedding;
@@ -310,7 +348,7 @@ async function memoryUpdate(agent, args) {
310
348
  // Memory.put()'s own ownership gate — no scope change, same as the read.
311
349
  // flair#1188 — strip the embedding from the echoed write response (Memory.put
312
350
  // regenerates the vector server-side); no-op when the response carries none.
313
- return stripEmbedding(await unwrap(await Cls.put(merged, delegationContext(agent))));
351
+ return stripInternalFields(await unwrap(await Cls.put(merged, delegationContext(agent))));
314
352
  }
315
353
  async function memoryGet(agent, args) {
316
354
  const Cls = await handler("Memory");
@@ -338,7 +376,7 @@ async function memoryGet(agent, args) {
338
376
  // Strip it by default so a chat connector isn't flooded with thousands of
339
377
  // useless tokens per record; return it only when the caller explicitly opts
340
378
  // in via includeEmbedding.
341
- return args?.includeEmbedding === true ? result : stripEmbedding(result);
379
+ return args?.includeEmbedding === true ? result : stripInternalFields(result);
342
380
  }
343
381
  async function memoryDelete(agent, args) {
344
382
  const Cls = await handler("Memory");
@@ -380,6 +418,14 @@ async function bootstrap(agent, args) {
380
418
  // ONLY when requested so a plain bootstrap delegates a byte-identical body.
381
419
  if (args?.abstain === true)
382
420
  body.abstain = true;
421
+ // flair#1199 — org-event knobs. `includeEventDetail` opts the verbose per-event
422
+ // `detail` JSON back in (default OFF: a connector reads lean events); `maxEvents`
423
+ // overrides the default cap. Both forwarded ONLY when set, so a plain bootstrap
424
+ // delegates a byte-identical body.
425
+ if (args?.includeEventDetail === true)
426
+ body.includeEventDetail = true;
427
+ if (args?.maxEvents !== undefined)
428
+ body.maxEvents = args.maxEvents;
383
429
  // flair#831 — attach the running Flair version to the RESPONSE (not the
384
430
  // delegated request body) so the calling agent learns the server version
385
431
  // on its very first call.
@@ -497,6 +543,51 @@ async function recordUsage(agent, args) {
497
543
  : typeof args?.memoryId === "string" ? [args.memoryId] : undefined;
498
544
  return unwrap(await h.post({ memoryIds, attribution: args?.attribution }));
499
545
  }
546
+ /**
547
+ * flair#1213 completeness gate — FAIL-CLOSED (the flair#953 lesson, Sherlock
548
+ * #3). Every tool shipped in `tools` must carry a `.contract`; a new /mcp tool
549
+ * without one fails the build.
550
+ *
551
+ * The fail-closed part is the point: if the registry cannot be enumerated — it
552
+ * is not a plain object (a broken import left it `undefined`), or it is empty —
553
+ * this returns `ok:false` with `examined:0`, NEVER a vacuous "0 tools examined,
554
+ * 0 missing, pass". A check that could not run must not render as passed. The
555
+ * conformance suite asserts both `ok` AND `examined > 0` so the vacuous path
556
+ * cannot masquerade as coverage; the fail-closed unit test exercises every
557
+ * branch.
558
+ */
559
+ export function checkContractCompleteness(tools) {
560
+ if (!tools || typeof tools !== "object" || Array.isArray(tools)) {
561
+ return {
562
+ ok: false,
563
+ examined: 0,
564
+ missing: [],
565
+ reason: "TOOLS registry is not an enumerable object (unloadable import?) — refusing to pass vacuously",
566
+ };
567
+ }
568
+ const names = Object.keys(tools);
569
+ if (names.length === 0) {
570
+ return {
571
+ ok: false,
572
+ examined: 0,
573
+ missing: [],
574
+ reason: "TOOLS registry is empty — refusing to pass vacuously (a new tool must carry a conformance contract)",
575
+ };
576
+ }
577
+ const missing = names.filter((n) => {
578
+ const entry = tools[n];
579
+ return !entry || !entry.contract || typeof entry.contract !== "object";
580
+ });
581
+ return {
582
+ ok: missing.length === 0,
583
+ examined: names.length,
584
+ missing,
585
+ reason: missing.length
586
+ ? `${missing.length} tool(s) shipped with no conformance contract: ${missing.join(", ")}. `
587
+ + "Add a `contract` to each in resources/mcp-tools.ts (co-located with its def+impl)."
588
+ : undefined,
589
+ };
590
+ }
500
591
  /**
501
592
  * Verb→tool-name overrides — the three naming quirks where the shipped tool
502
593
  * name isn't record-types.ts's default `${toolPrefix}_${verb}` shape (see
@@ -547,6 +638,16 @@ export const TOOLS = {
547
638
  },
548
639
  },
549
640
  impl: memorySearch,
641
+ contract: {
642
+ summary: "{ results: MemoryRecord[] } — semantic hits scoped to the caller's own + granted memories; each hit carries content, never the raw embedding.",
643
+ requiredFields: ["results"],
644
+ fieldTypes: { results: "array" },
645
+ invariants: {
646
+ selfDescribingEmpty: [{ path: "results", type: "array" }],
647
+ containerRules: [{ container: "results", requiredFields: ["id", "content"], forbiddenFields: INTERNAL_MEMORY_FIELDS }],
648
+ fullyResolved: true,
649
+ },
650
+ },
550
651
  },
551
652
  memory_store: {
552
653
  def: {
@@ -574,6 +675,14 @@ export const TOOLS = {
574
675
  },
575
676
  },
576
677
  impl: memoryStore,
678
+ contract: {
679
+ summary: "Write echo { id, written:true, deduplicated } — the new id + confirmation. No internal embedding fields; round-trips via memory_get.",
680
+ requiredFields: ["id", "written"],
681
+ fieldTypes: { id: "string", written: "boolean", deduplicated: "boolean" },
682
+ forbiddenFields: INTERNAL_MEMORY_FIELDS,
683
+ invariants: { fullyResolved: true },
684
+ errorShape: { trigger: "an unrecognized visibility value (e.g. \"prvate\")", fields: ["error", "status"], mustNotLeak: INTERNAL_MEMORY_FIELDS },
685
+ },
577
686
  },
578
687
  memory_update: {
579
688
  def: {
@@ -592,6 +701,14 @@ export const TOOLS = {
592
701
  },
593
702
  },
594
703
  impl: memoryUpdate,
704
+ contract: {
705
+ summary: "Write echo { id, written:true } for the in-place overwrite (or supersede). No internal embedding fields; the change round-trips via memory_get.",
706
+ requiredFields: ["id", "written"],
707
+ fieldTypes: { id: "string", written: "boolean" },
708
+ forbiddenFields: INTERNAL_MEMORY_FIELDS,
709
+ invariants: { fullyResolved: true },
710
+ errorShape: { trigger: "updating a non-existent id", fields: ["error", "status"] },
711
+ },
595
712
  },
596
713
  memory_get: {
597
714
  def: {
@@ -609,6 +726,14 @@ export const TOOLS = {
609
726
  },
610
727
  },
611
728
  impl: memoryGet,
729
+ contract: {
730
+ summary: "The full memory record { id, agentId, content, durability, createdAt, ... } for the caller's own id — embedding + embeddingModel stripped by default.",
731
+ requiredFields: ["id", "agentId", "content", "createdAt"],
732
+ fieldTypes: { id: "string", agentId: "string", content: "string" },
733
+ forbiddenFields: INTERNAL_MEMORY_FIELDS,
734
+ invariants: { fullyResolved: true },
735
+ errorShape: { trigger: "get a non-existent / unowned id (makeByIdReadGate 404)", fields: ["error", "status"] },
736
+ },
612
737
  },
613
738
  memory_delete: {
614
739
  def: {
@@ -622,6 +747,11 @@ export const TOOLS = {
622
747
  },
623
748
  },
624
749
  impl: memoryDelete,
750
+ contract: {
751
+ summary: "Deletes the caller's own memory (success echo is thin). The permanent-memory guard returns { error, status:403 } for a non-admin; the row round-trips as gone via memory_get.",
752
+ invariants: { fullyResolved: true },
753
+ errorShape: { trigger: "a non-admin deletes a permanent memory", fields: ["error", "status"] },
754
+ },
625
755
  },
626
756
  bootstrap: {
627
757
  def: {
@@ -631,7 +761,7 @@ export const TOOLS = {
631
761
  inputSchema: {
632
762
  type: "object",
633
763
  properties: {
634
- maxTokens: { type: "number", description: "Max tokens in output (default 4000)" },
764
+ maxTokens: { type: "number", description: "Content-selection budget in tokens (default 4000): the hard cap on how much soul/memory/finding CONTENT is selected. The actual serialized response (reported by tokenEstimate) may exceed this by the structured-container JSON scaffolding — maxTokens bounds what is selected, not the raw output size. Raise it to include more content." },
635
765
  currentTask: { type: "string", description: "Current task — enables semantic search for relevant memories" },
636
766
  channel: { type: "string", description: "Channel name (discord, tps-mail, claude-code)" },
637
767
  surface: { type: "string", description: "Surface name (tps-build, tps-review, cli-session)" },
@@ -644,10 +774,70 @@ export const TOOLS = {
644
774
  includeTrust: { type: "boolean", description: "Also return a `trust` array with a per-included-memory trust-evidence block (provenance, author, usage, freshness, supersession). Default false." },
645
775
  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
776
  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." },
777
+ maxEvents: { type: "number", description: "Cap on how many recent org events to return (default 10). Events are counted against maxTokens like every other content section." },
778
+ includeEventDetail: { type: "boolean", description: "Also include each org event's verbose `detail` JSON (migration internals, etc.). Default false: bootstrap ships lean events (id/kind/summary/createdAt/targetIds/scope); `detail` mostly restates the summary and is pure bloat for a connector." },
647
779
  },
648
780
  },
649
781
  },
650
782
  impl: bootstrap,
783
+ contract: {
784
+ summary: "Session context: { agentId, soul, memories, predicted, teammateFindings, events, sections, tokenEstimate, memoriesIncluded, ..., context, flairVersion }. "
785
+ + "Structured containers are canonical and always present; prose `context` is a pointer at the /mcp default (includeContext opt-in).",
786
+ requiredFields: [
787
+ "agentId", "soul", "memories", "predicted", "teammateFindings", "events",
788
+ "sections", "tokenEstimate", "maxTokens", "memoriesIncluded", "memoriesAvailable",
789
+ "memoriesTruncated", "teammateFindingsIncluded", "teammateFindingsTruncated",
790
+ "teammateFindingsMatched", "context", "flairVersion",
791
+ ],
792
+ fieldTypes: {
793
+ agentId: "string", soul: "object", memories: "array", predicted: "array",
794
+ teammateFindings: "array", events: "array", sections: "object",
795
+ tokenEstimate: "number", maxTokens: "number", memoriesIncluded: "number",
796
+ memoriesAvailable: "number", memoriesTruncated: "number",
797
+ teammateFindingsIncluded: "number", teammateFindingsTruncated: "number",
798
+ teammateFindingsMatched: "number", context: "string", flairVersion: "string",
799
+ },
800
+ invariants: {
801
+ // count == delivered — the historical count/charge/deliver drift.
802
+ countEqualsDelivered: [
803
+ // memoriesIncluded spans BOTH own-memory containers (see the type doc).
804
+ { count: "memoriesIncluded", containers: ["memories", "predicted"] }, // #1199
805
+ { count: "teammateFindingsIncluded", containers: ["teammateFindings"] }, // #1199
806
+ { count: "sections.events", containers: ["events"] }, // #1206
807
+ ],
808
+ // present + typed even when empty — never a bare {} / missing key (#1182).
809
+ selfDescribingEmpty: [
810
+ { path: "soul", type: "object" }, { path: "memories", type: "array" },
811
+ { path: "predicted", type: "array" }, { path: "teammateFindings", type: "array" },
812
+ { path: "events", type: "array" }, { path: "sections", type: "object" },
813
+ ],
814
+ // #1200 — dedup by the SEMANTIC content key (excludes id/createdAt, which
815
+ // vary across physical duplicate rows). See ToolInvariants.dedupSignature.
816
+ dedupSignature: { container: "events", signatureFields: ["kind", "summary", "detail", "targetIds"] },
817
+ // #1199 — tokenEstimate via the wrapper's own estimator over the delivered
818
+ // payload (minus the two fields added after it was measured).
819
+ tokenEstimate: { field: "tokenEstimate", excludeKeys: ["tokenEstimate", "flairVersion"] },
820
+ // #1199 — the reported estimate must respect the requested budget: the
821
+ // events blowout (uncounted org events) drove maxTokens=4000 → 6286. The
822
+ // tolerance covers the fixed JSON scaffolding + the #1207 prose-vs-
823
+ // structured charge gap; uncounted content does not fit under it.
824
+ budgetCap: { estimate: "tokenEstimate", budget: "maxTokens", tolerance: 0.25 },
825
+ // #1207 — count arithmetic: included + truncated <= available, for own
826
+ // memories AND teammate findings (each a disjoint split of its pool).
827
+ countCoherence: [
828
+ { included: "memoriesIncluded", truncated: "memoriesTruncated", available: "memoriesAvailable" },
829
+ { included: "teammateFindingsIncluded", truncated: "teammateFindingsTruncated", available: "teammateFindingsMatched" },
830
+ ],
831
+ // #1199 — prose is a pointer at the default, not a second copy.
832
+ proseContextIsPointerAtDefault: { field: "context" },
833
+ // shape of the structured containers a connector reads; #1188 leak bites on memories.
834
+ containerRules: [
835
+ { container: "events", requiredFields: ["id", "kind", "summary", "createdAt"] },
836
+ { container: "memories", requiredFields: ["id", "content"], forbiddenFields: INTERNAL_MEMORY_FIELDS },
837
+ ],
838
+ fullyResolved: true, // #1182 — never a spread pending Promise collapsing to {flairVersion}.
839
+ },
840
+ },
651
841
  },
652
842
  soul_set: {
653
843
  def: {
@@ -663,6 +853,10 @@ export const TOOLS = {
663
853
  },
664
854
  },
665
855
  impl: soulSet,
856
+ contract: {
857
+ summary: "Writes a soul entry keyed `${agentId}:${key}`, attributed to the caller. Correctness is proven by the soul_get round-trip — this is the write flair#1181 broke on the connector path.",
858
+ invariants: { fullyResolved: true },
859
+ },
666
860
  },
667
861
  soul_get: {
668
862
  def: {
@@ -676,6 +870,12 @@ export const TOOLS = {
676
870
  },
677
871
  },
678
872
  impl: soulGet,
873
+ contract: {
874
+ summary: "The soul entry { id, agentId, key, value, createdAt } for the caller's own `${agentId}:${key}`.",
875
+ requiredFields: ["id", "agentId", "key", "value", "createdAt"],
876
+ fieldTypes: { id: "string", agentId: "string", key: "string", value: "string" },
877
+ invariants: { fullyResolved: true },
878
+ },
679
879
  },
680
880
  flair_workspace_set: {
681
881
  def: {
@@ -695,6 +895,10 @@ export const TOOLS = {
695
895
  },
696
896
  },
697
897
  impl: workspaceSet,
898
+ contract: {
899
+ summary: "Writes the caller's workspace state keyed `${agentId}:${ref}`, attributed to the caller (never the body). The echo is thin; persistence is verified in storage.",
900
+ invariants: { fullyResolved: true },
901
+ },
698
902
  },
699
903
  flair_orgevent: {
700
904
  def: {
@@ -713,6 +917,10 @@ export const TOOLS = {
713
917
  },
714
918
  },
715
919
  impl: orgEvent,
920
+ contract: {
921
+ summary: "Publishes an org event attributed to the caller (authorId from identity, never the body). The echo is thin; persistence is verified in storage.",
922
+ invariants: { fullyResolved: true },
923
+ },
716
924
  },
717
925
  attention: {
718
926
  def: {
@@ -731,6 +939,15 @@ export const TOOLS = {
731
939
  },
732
940
  },
733
941
  impl: attention,
942
+ contract: {
943
+ summary: "Grouped-by-source view { entity, windowDays, since, groups:{memory,relationship,workspaceState,presence,orgEvent}, counts } for entity E over N days.",
944
+ requiredFields: ["entity", "windowDays", "groups", "counts"],
945
+ fieldTypes: { entity: "string", windowDays: "number", groups: "object", counts: "object" },
946
+ invariants: {
947
+ selfDescribingEmpty: [{ path: "groups", type: "object" }, { path: "counts", type: "object" }],
948
+ fullyResolved: true,
949
+ },
950
+ },
734
951
  },
735
952
  record_usage: {
736
953
  def: {
@@ -748,6 +965,12 @@ export const TOOLS = {
748
965
  },
749
966
  },
750
967
  impl: recordUsage,
968
+ contract: {
969
+ summary: "Invariant acknowledgement { recorded:true } — byte-identical regardless of how many ids counted (no id enumeration, Sherlock).",
970
+ requiredFields: ["recorded"],
971
+ fieldTypes: { recorded: "boolean" },
972
+ invariants: { fullyResolved: true },
973
+ },
751
974
  },
752
975
  };
753
976
  /** The tool definitions for a tools/list response (exactly the 12 curated tools). */
@@ -23,6 +23,64 @@ export function isTeammate(record, callerId) {
23
23
  return false;
24
24
  return true;
25
25
  }
26
+ /**
27
+ * Is `event` a zero-row, no-op auto-heal migration event (flair#1200)?
28
+ *
29
+ * The migration ledger (resources/migrations/ledger.ts) and the graph-heal
30
+ * observability path (resources/migrations/graph-heal.ts) both emit a
31
+ * `kind: "migration"` OrgEvent on EVERY boot — even when the migration did
32
+ * nothing. On a healthy store these are near-identical `verified` + `success`
33
+ * pairs, seconds apart, twice per version bump (per node): "migration graph-heal
34
+ * success (0 rows processed)" beside "HNSW graph-heal: recall verified healthy".
35
+ * They carry ZERO signal an agent could act on, yet each occupies one of the
36
+ * scarce (maxEvents-capped) bootstrap event slots AND is now token-charged
37
+ * (flair#1199) — so they crowd out events that matter. This suppresses them at
38
+ * RENDER (bootstrap's events section) only; the ledger still records every
39
+ * migration on the OrgEvent table (migration invariant IV is unchanged — this
40
+ * never touches the write path, only what bootstrap surfaces to a connector).
41
+ *
42
+ * A migration event is a suppressible no-op when its structured `detail`
43
+ * (a JSON string) reports:
44
+ * - `rowsProcessed === 0` AND a non-failure outcome (`success`, or a ledger
45
+ * shape with no explicit failure) — a migration that changed nothing; OR
46
+ * - `migrationId === "graph-heal"` with `verified === true` — the graph-heal
47
+ * verification half, which `run()` returns `processed: 0` for by construction
48
+ * (it carries no `rowsProcessed`, so the first rule can't catch it).
49
+ *
50
+ * A migration that PROCESSED rows, HALTED, FAILED, or reported an
51
+ * UNCONFIRMED graph-heal (`verified: false`) is actionable and is NOT
52
+ * suppressed. Pure + Harper-free so bootstrap-events.test.ts can drive it
53
+ * directly against the exact ledger/graph-heal detail shapes.
54
+ */
55
+ export function isZeroRowNoOpEvent(event) {
56
+ if (!event || event.kind !== "migration")
57
+ return false;
58
+ let detail = event.detail;
59
+ if (typeof detail === "string") {
60
+ try {
61
+ detail = JSON.parse(detail);
62
+ }
63
+ catch {
64
+ return false; // unparseable detail — don't guess, keep the event
65
+ }
66
+ }
67
+ if (!detail || typeof detail !== "object")
68
+ return false;
69
+ // Ledger event: a migration that processed no rows AND did not fail/halt is a
70
+ // no-op. A failed/halted migration (even at 0 rows) is actionable — keep it.
71
+ // (Checked FIRST: the graph-heal ledger event carries migrationId "graph-heal"
72
+ // too but no `verified` field, so the graph-heal branch below must not swallow
73
+ // it before its rowsProcessed:0 is seen.)
74
+ if (detail.rowsProcessed === 0 && (detail.outcome === undefined || detail.outcome === "success")) {
75
+ return true;
76
+ }
77
+ // Graph-heal VERIFICATION event: inherently zero-row (run() → processed:0),
78
+ // carries no rowsProcessed. Suppress only the CONFIRMED-healthy ones; an
79
+ // unconfirmed heal (verified:false) is worth surfacing.
80
+ if (detail.migrationId === "graph-heal" && detail.verified === true)
81
+ return true;
82
+ return false;
83
+ }
26
84
  /**
27
85
  * Format the "## Team" roster line for a list of teammate ids, or `null`
28
86
  * when the roster is empty (caller should omit the section entirely).
@@ -287,3 +287,73 @@ export function dedupeCandidates(candidates, existingPendingClaims) {
287
287
  const existingNormalized = new Set(existingPendingClaims.map(normalizeClaim));
288
288
  return candidates.filter((c) => !existingNormalized.has(normalizeClaim(c.claim)));
289
289
  }
290
+ // ─── Scope selection (the cross-user-bleed boundary — #1205b-1) ──────────────
291
+ //
292
+ // The per-user isolation that prevents cross-user bleed lives HERE, not in the
293
+ // LLM: /ReflectMemories only ever hands the model the memories this predicate
294
+ // admits, and generateCandidates() then enforces every candidate's
295
+ // sourceMemoryIds ⊆ the gathered set (parseAndValidateCandidates,
296
+ // "source_id_out_of_set"). So the gathered set is the *ceiling* on any
297
+ // candidate's sources — if this predicate admits only ONE adk:<app>:<user>
298
+ // tag's memories, a candidate physically cannot cite another user's memory.
299
+ //
300
+ // scope:"tagged" is the isolation mode the tag-aware nightly runner
301
+ // (src/rem/runner.ts) drives once per active adk:<app>:<user> tag. scope:
302
+ // "recent"/"all" are the pre-#1205b agentId-wide modes — correct for a
303
+ // single-tenant agent, but for an ADK agentId (which collapses every
304
+ // (app,user) into one agentId, distinguishing users only by tag) they gather
305
+ // EVERY user's memories together, which is exactly the bleed #1205 fixes.
306
+ //
307
+ // Extracted as a pure predicate so the isolation is unit-testable without
308
+ // Harper (the resource's gather loop streams from databases.flair.Memory).
309
+ // Archived/permanent filtering stays in the resource — those are eligibility
310
+ // rules, not scope selection.
311
+ export function memoryMatchesReflectScope(record, params) {
312
+ const { scope, tag, sinceDate } = params;
313
+ if (scope === "tagged") {
314
+ // No tag ⇒ admit nothing. A tagged reflection with no tag must gather an
315
+ // EMPTY set (fail-closed), never fall through to admitting everything —
316
+ // that would silently become an agentId-wide distill (cross-user bleed).
317
+ if (!tag)
318
+ return false;
319
+ return (record.tags ?? []).includes(tag);
320
+ }
321
+ if (scope === "recent") {
322
+ if (!record.createdAt)
323
+ return false;
324
+ return new Date(record.createdAt) >= sinceDate;
325
+ }
326
+ // scope === "all" (or any unknown scope) admits everything eligible.
327
+ return true;
328
+ }
329
+ // ─── Staged candidate row (stamps the authoritative scope tag — #1205b-1) ────
330
+ //
331
+ // Builds the MemoryCandidate row /ReflectMemories persists. The load-bearing
332
+ // addition over an inline object literal is `scopeTag`: when the distillation
333
+ // ran under scope:"tagged" with a known tag, that tag is AUTHORITATIVE context
334
+ // (the engine distilled exactly that one tag), so it is stamped onto the row.
335
+ // Downstream promotion (src/cli.ts derivePromotedTags' stamped-tag override)
336
+ // consumes this stamped tag directly instead of re-reading the source
337
+ // memories — which closes the #1205a seam: an ADK-sourced candidate whose
338
+ // sources are all later unreadable still carries its per-user scope tag and
339
+ // promotes correctly (never tagless into the shared agentId namespace).
340
+ //
341
+ // Non-tagged distillations (scope:"recent"/"all") leave scopeTag ABSENT
342
+ // (undefined) — the field is nullable/additive and promotion falls back to the
343
+ // source-re-read classification for those, unchanged.
344
+ export function buildStagedCandidateRow(params) {
345
+ const row = {
346
+ id: params.id,
347
+ agentId: params.agentId,
348
+ claim: params.claim,
349
+ sourceMemoryIds: params.sourceMemoryIds,
350
+ rationalePrompt: params.rationalePrompt,
351
+ generatedBy: params.generatedBy,
352
+ generatedAt: params.generatedAt,
353
+ status: "pending",
354
+ };
355
+ if (params.scope === "tagged" && typeof params.tag === "string" && params.tag.length > 0) {
356
+ row.scopeTag = params.tag;
357
+ }
358
+ return row;
359
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * token-estimate.ts — the ONE token estimator the bootstrap payload budget and
3
+ * `tokenEstimate` report are computed with.
4
+ *
5
+ * Extracted to a harper-free module (no `import ... from "harper"`) for two
6
+ * reasons:
7
+ *
8
+ * 1. Single source of truth. `MemoryBootstrap` computes both its content-
9
+ * selection budget and the reported `tokenEstimate` with THIS function, so
10
+ * there is exactly one definition of "how many tokens is this text".
11
+ * 2. The flair#1213 connector-conformance suite asserts the tokenEstimate
12
+ * invariant — `tokenEstimate === estimateTokens(JSON.stringify(deliveredPayload))`
13
+ * — with the SAME estimator, not a byte length or a different tokenizer
14
+ * (Kern #1 / Sherlock #2). Importing this module (which never pulls in
15
+ * Harper) lets a plain bun:test process reconstruct the estimate exactly,
16
+ * so the invariant catches the flair#1199 double-serialization class
17
+ * without being brittle to a future estimator change: change the formula
18
+ * here and both the report and the invariant move together.
19
+ *
20
+ * The estimate is deliberately coarse (~4 chars per token for English text). It
21
+ * is a budgeting/reporting heuristic, never a billing figure.
22
+ */
23
+ export function estimateTokens(text) {
24
+ return Math.ceil(text.length / 4);
25
+ }
@@ -151,13 +151,21 @@ export function buildTrustBlock(record, now = Date.now()) {
151
151
  else if (Number.isFinite(validFromMs) && validFromMs > now) {
152
152
  validityStatus = "future";
153
153
  }
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))
154
+ // flair#1201 (refined) carry BOTH temporal signals rather than collapsing
155
+ // to one. `ageDays` is TRUE AGE (days since `createdAt`, fallback updatedAt);
156
+ // `staleDays` is FRESHNESS (days since `updatedAt`, fallback createdAt). The
157
+ // first #1201 pass keyed ageDays off updatedAt only, which overcorrected — a
158
+ // record created weeks ago but edited today then read as "0 days old", losing
159
+ // its true age. Both are the record's OWN fields (never a superseded
160
+ // predecessor's — #1189), so neither reintroduces lineage-inheritance. For a
161
+ // never-updated record updatedAt == createdAt, so ageDays == staleDays.
162
+ const createdMs = parseTime(createdAt ?? updatedAt);
163
+ const ageDays = Number.isFinite(createdMs)
164
+ ? Math.max(0, Math.floor((now - createdMs) / MS_PER_DAY))
165
+ : null;
166
+ const updatedMs = parseTime(updatedAt ?? createdAt);
167
+ const staleDays = Number.isFinite(updatedMs)
168
+ ? Math.max(0, Math.floor((now - updatedMs) / MS_PER_DAY))
161
169
  : null;
162
170
  return {
163
171
  author: typeof record.agentId === "string" ? record.agentId : null,
@@ -172,6 +180,7 @@ export function buildTrustBlock(record, now = Date.now()) {
172
180
  createdAt,
173
181
  updatedAt,
174
182
  ageDays,
183
+ staleDays,
175
184
  supersedes: typeof record.supersedes === "string" ? record.supersedes : null,
176
185
  // flair#744 refinement — confidence band from the result's absolute
177
186
  // similarity (null when there is no signal to judge). Pure, global,
@@ -15,6 +15,7 @@ Where Flair already runs. Each integration shown here is a working surface — t
15
15
  | **Continue.dev** | [`flair-mcp`](#claude-code-cursor-codex-gemini-cli-continuedev-via-flair-mcp) | MCP config | Standard MCP server |
16
16
  | **OpenAI Codex CLI** | [`flair-mcp`](#claude-code-cursor-codex-gemini-cli-continuedev-via-flair-mcp) | MCP config | Standard MCP server |
17
17
  | **Gemini CLI** | [`flair-mcp`](#claude-code-cursor-codex-gemini-cli-continuedev-via-flair-mcp) | MCP config | Standard MCP server |
18
+ | **Antigravity CLI** (`agy`) | [`flair-mcp`](#claude-code-cursor-codex-gemini-cli-continuedev-via-flair-mcp) | MCP config | `~/.gemini/config/mcp_config.json`; pickup by a live `agy` pending verification |
18
19
  | **Goose** (block/goose) | [`flair-mcp`](#claude-code-cursor-codex-gemini-cli-continuedev-via-flair-mcp) | MCP config | Goose ships native MCP support |
19
20
  | **LangGraph (TS)** | [`langgraph-flair`](#langgraph-typescript) | FlairClient | Drop-in `BaseStore` |
20
21
  | **OpenClaw** | [`openclaw-flair`](#openclaw) | Ed25519 | Native plugin + context engine |
@@ -69,6 +70,8 @@ FLAIR_AGENT_ID = "codex"
69
70
 
70
71
  **Gemini CLI** (`~/.gemini/settings.json`): same shape as Cursor.
71
72
 
73
+ **Antigravity CLI** (`agy`) (`~/.gemini/config/mcp_config.json` — Antigravity's own MCP config, separate from Gemini CLI's `settings.json`): same shape as Cursor. Newly added; the config path follows Antigravity's documentation, but Flair has not yet verified end-to-end pickup by a live `agy` — after wiring, restart Antigravity and confirm the flair tools appear.
74
+
72
75
  **Continue.dev** (`~/.continue/config.json`):
73
76
  ```json
74
77
  {
package/docs/rem.md CHANGED
@@ -60,3 +60,9 @@ Snapshot locality follows from this: a nightly cycle's pre-run snapshot (`~/.fla
60
60
  - **Nightly (`flair rem nightly enable` / `run-once`):** fully detached — the scheduler runs the full cycle (snapshot → maintenance → distillation), candidates land as pending rows, and an audit row lands in `~/.flair/logs/rem-nightly.jsonl`. The operator reviews in the morning via `flair rem candidates`.
61
61
 
62
62
  Either path, the review loop is the same: `flair rem candidates` lists pending rows, `flair rem promote <id> --rationale "<why>"` / `flair rem reject <id> --reason "<why>"` decide them. Nothing self-promotes — see [`docs/notes/rem-ux.md`](notes/rem-ux.md) for why that gate is load-bearing and how the surface is expected to evolve.
63
+
64
+ ### ADK agents — per-user (per-tag) distillation
65
+
66
+ adk-flair collapses every `(app, user)` into **one** Flair agentId, separating users only by a per-user tag `adk:<app>:<user>`. Distilling such an agentId with the default `scope:"recent"` would mix every user's sessions into shared claims — cross-user bleed. The nightly cycle therefore detects the agent's active `adk:<app>:<user>` tags (from the memories it already loads for the snapshot, with a recency cutoff that skips idle users and is scoped to the agent's own records) and runs distillation **once per tag** under `scope:"tagged"`, so each user's candidates come only from that user's own sessions. Agents with no `adk:` tags distill agentId-wide exactly as before.
67
+
68
+ A candidate distilled under a tag records that tag in its `scopeTag` field. `flair rem promote` reads `scopeTag` as the authoritative per-user lineage tag and propagates it onto the promoted memory — so the promoted claim stays in that user's retrieval scope even if the source episodes are later archived or deleted. The single-node timer rule above is unchanged; the per-tag loop runs inside the one cycle on the one node. The non-thinking-model requirement (above) still holds — the per-tag path calls the same `models.generate()` route.