@tpsdev-ai/flair 0.44.10 → 0.44.11

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
@@ -7918,6 +7918,11 @@ remNightly
7918
7918
  if (row.candidates) {
7919
7919
  console.log(`Staged: ${row.candidates.length} candidate${row.candidates.length === 1 ? "" : "s"}`);
7920
7920
  }
7921
+ // row.autoPromoted populates when step 5b (#1205b-2 ADK auto-promote) ran
7922
+ // this cycle — i.e. a non-dry-run cycle for an ADK agentId.
7923
+ if (row.autoPromoted) {
7924
+ console.log(`Auto-promoted: ${row.autoPromoted.promoted} to own memory (${row.autoPromoted.skipped} left pending)`);
7925
+ }
7921
7926
  // row.dedup populates when step 6 (instance-wide dedup-cluster stat,
7922
7927
  // flair-quality Slice 1c) succeeded this cycle. Absent on dry-run skip
7923
7928
  // or a non-fatal failure (see Errors below — e.g. non-admin caller).
@@ -86,6 +86,17 @@ export const DEFAULT_DISTILL_LOOKBACK_MS = 48 * 3600_000;
86
86
  * recorded in `errors` and picked up on subsequent cycles (they stay active).
87
87
  */
88
88
  export const DEFAULT_MAX_TAGS_PER_CYCLE = 200;
89
+ /**
90
+ * Per-cycle ceiling on ADK candidate auto-promotions (#1205b-2). The nightly
91
+ * cycle sweeps this agent's pending, scopeTag-bearing candidates and promotes
92
+ * the eligible ones to own memory server-side (POST /AutoPromoteCandidates).
93
+ * This bounds the blast radius of one cycle (Kern's cost-ceiling note); the
94
+ * resource applies the same cap, and any overflow stays `pending` for a later
95
+ * cycle. Mirror of resources/auto-promote-lib.ts's DEFAULT_MAX_AUTO_PROMOTE_
96
+ * PER_CYCLE, duplicated across the npm-packaging boundary (src/ can't import
97
+ * resources/ — see src/cli.ts) and kept in sync by value.
98
+ */
99
+ export const DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE = 200;
89
100
  function readPauseSentinel(path) {
90
101
  try {
91
102
  const contents = readFileSync(path, "utf-8");
@@ -351,6 +362,9 @@ export async function runNightlyCycle(opts) {
351
362
  // exactly as before. The single-node-runs-the-timer property is unchanged:
352
363
  // this all runs inside the one cycle on the one node.
353
364
  let candidates;
365
+ // #1205b-2: outcome of the post-distillation auto-promote step, assigned
366
+ // inside the !dryRun block below (only when this is an ADK agentId).
367
+ let autoPromoted;
354
368
  const collectStagedIds = (obj) => asArray(obj.candidates)
355
369
  .map((c) => (c && typeof c === "object" ? c.id : c))
356
370
  .filter((id) => typeof id === "string");
@@ -424,6 +438,35 @@ export async function runNightlyCycle(opts) {
424
438
  errors.push(`distillation: ${describeApiError(err?.message ?? err)}`);
425
439
  }
426
440
  }
441
+ // ── Step 5b (#1205b-2): server-side ADK auto-promote ───────────────────────
442
+ // Only for an ADK agentId (active adk: tags this cycle) — a non-ADK agent
443
+ // has no scopeTag-bearing candidates, so there is nothing to auto-promote
444
+ // and no call is made. The SERVER enforces every security invariant
445
+ // (memory-only target, fail-closed tag lineage, content-safety, machine
446
+ // reviewerId) — the runner only TRIGGERS the sweep; it never itself decides
447
+ // where a claim lands. Non-fatal like distillation: a failure is recorded
448
+ // and the candidates stay pending (re-swept next cycle, or promotable by the
449
+ // human `rem promote` path). Bounded by the per-cycle cap.
450
+ if (activeAdkTags.length > 0) {
451
+ try {
452
+ const apRaw = await opts.apiCall("POST", "/AutoPromoteCandidates", {
453
+ agentId: opts.agentId,
454
+ limit: opts.maxAutoPromotePerCycle ?? DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE,
455
+ });
456
+ const obj = (apRaw && typeof apRaw === "object") ? apRaw : {};
457
+ if (obj.error) {
458
+ errors.push(`auto-promote: ${describeApiError(obj.error)}`);
459
+ }
460
+ else {
461
+ const promotedCount = typeof obj.count === "number" ? obj.count : asArray(obj.promoted).length;
462
+ const skippedCount = asArray(obj.skipped).length;
463
+ autoPromoted = { promoted: promotedCount, skipped: skippedCount };
464
+ }
465
+ }
466
+ catch (err) {
467
+ errors.push(`auto-promote: ${describeApiError(err?.message ?? err)}`);
468
+ }
469
+ }
427
470
  }
428
471
  // Step 6 (flair-quality Slice 1c): instance-wide dedup-cluster stat.
429
472
  // Distinct from every step above — NOT scoped to opts.agentId. Runs ONCE
@@ -481,6 +524,7 @@ export async function runNightlyCycle(opts) {
481
524
  archived,
482
525
  expired,
483
526
  candidates,
527
+ autoPromoted,
484
528
  dedup,
485
529
  durationMs: Date.now() - startedMs,
486
530
  errors,
@@ -0,0 +1,203 @@
1
+ /**
2
+ * POST /AutoPromoteCandidates (#1205b-2 — the UNATTENDED promotion path)
3
+ *
4
+ * Sweeps this agent's PENDING, ADK-sourced (scopeTag-bearing) MemoryCandidates
5
+ * and auto-promotes each eligible one to the agent's OWN persistent Memory — no
6
+ * human reviewer, replacing `flair rem promote` for this one narrow path. Wired
7
+ * into the nightly runner (src/rem/runner.ts) as a post-distillation step.
8
+ *
9
+ * This is the SERVER-SIDE trust-tier enforcement cli.ts noted as deferred. The
10
+ * whole reason it is a resource and not a CLI flag is Sherlock's req 1: the
11
+ * "never Soul" invariant must live where a compromised agent key cannot flip it.
12
+ *
13
+ * Req 1 — memory-only, enforced HERE, structurally. There is NO soul code
14
+ * path in this resource: the only write it can perform is a Memory write.
15
+ * Soul is agentId-scoped and cannot carry a per-user `adk:<app>:<user>` tag,
16
+ * so an ADK-sourced → Soul promotion is cross-user BY CONSTRUCTION. On top
17
+ * of the structural absence, an explicit `target` in the request body that
18
+ * is anything other than "memory" is REFUSED loudly (400) rather than
19
+ * silently ignored — so a caller trying to flip the target gets a hard no.
20
+ * Req 2 — fail-closed tag lineage: decideAutoPromote (auto-promote-lib.ts)
21
+ * refuses any candidate without an authoritative `adk:` stamped scopeTag,
22
+ * and the promoted Memory carries that scopeTag as its first tag.
23
+ * Req 3 — content-safety: decideAutoPromote scans the claim strict (always
24
+ * refuses a flag, independent of FLAIR_CONTENT_SAFETY), and the Memory.put()
25
+ * override below scans again on the write (defense-in-depth).
26
+ * Req 4 — machine reviewerId: the promoted Memory's `promotedBy` and the
27
+ * candidate row's `reviewerId` both record machine:adk-auto-promote.
28
+ *
29
+ * Request:
30
+ * agentId string? — whose candidates to sweep. A non-admin caller may only
31
+ * sweep its OWN (resolveReflectActor); admin may name any.
32
+ * limit number? — per-call cap (default DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE).
33
+ * target string? — MUST be absent or "memory". Any other value is refused
34
+ * (the hard-lock made explicit + testable).
35
+ *
36
+ * Response:
37
+ * { agentId, promoted: string[], skipped: {id, reason}[], count, considered }
38
+ *
39
+ * Thin orchestrator over the pure, tested policy in ./auto-promote-lib.ts.
40
+ */
41
+ import { Resource, databases, logger } from "harper";
42
+ import { isAdmin, allowVerified } from "./agent-auth.js";
43
+ import { resolveReflectActor } from "./memory-reflect-lib.js";
44
+ import { agentContext } from "./in-process.js";
45
+ import { decideAutoPromote, buildAutoPromotedTags, DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE, } from "./auto-promote-lib.js";
46
+ export class AutoPromoteCandidates extends Resource {
47
+ // Any verified agent may trigger a sweep of ITS OWN candidates; the actor
48
+ // resolution in post() enforces the own-only scope. Same gate ReflectMemories
49
+ // uses (allowVerified — verified agents, admins, trusted internal calls).
50
+ async allowCreate() {
51
+ return allowVerified(this.getContext?.());
52
+ }
53
+ async post(data) {
54
+ const { agentId: bodyAgentId, limit, target } = data || {};
55
+ // ── Req 1: target hard-lock, made explicit ────────────────────────────────
56
+ // This resource NEVER writes Soul — there is no soul branch anywhere below.
57
+ // An explicit non-memory `target` in the body is refused loudly so a caller
58
+ // (or a compromised agent key) attempting to flip the target gets a hard no,
59
+ // not a silent memory write it did not ask for.
60
+ if (target !== undefined && target !== "memory") {
61
+ return new Response(JSON.stringify({
62
+ error: "auto_promote_target_locked",
63
+ message: "auto-promote is hard-locked to memory server-side; soul (or any non-memory target) is refused — an ADK-sourced promotion to Soul would leak across users",
64
+ }), { status: 400, headers: { "Content-Type": "application/json" } });
65
+ }
66
+ // ── Identity / actor resolution (own candidates only, unless admin) ────────
67
+ const ctx = this.getContext?.();
68
+ const request = ctx?.request ?? ctx;
69
+ const actorId = request?.tpsAgent;
70
+ const callerIsAdmin = request?.tpsAgentIsAdmin === true || (actorId ? await isAdmin(actorId) : false);
71
+ const actorResolution = resolveReflectActor({ bodyAgentId, actorId, callerIsAdmin });
72
+ if (actorResolution.error) {
73
+ return new Response(JSON.stringify(actorResolution.error.body), { status: actorResolution.error.status });
74
+ }
75
+ const agentId = actorResolution.agentId;
76
+ const cap = typeof limit === "number" && Number.isFinite(limit) && limit > 0
77
+ ? Math.floor(limit)
78
+ : DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE;
79
+ // ── Sweep this agent's pending candidates ──────────────────────────────────
80
+ // Owner-scoped in JS by `c.agentId !== agentId` (the raw table search is
81
+ // org-wide — same discipline ReflectMemories uses), so a non-admin actor can
82
+ // only ever sweep its own candidates: no cross-agent promotion, no oracle.
83
+ //
84
+ // decideAutoPromote (auto-promote-lib.ts) is the SINGLE fail-closed gate:
85
+ // it — not this loop — decides eligibility (ADK scope tag present + content
86
+ // safe). The `status !== "pending"` skip here is a pure enumeration
87
+ // optimization (don't build a skip record for every historical decided row);
88
+ // decideAutoPromote re-checks status authoritatively. The `cap` bounds the
89
+ // number PROMOTED (the expensive part — a Memory write + embedding each),
90
+ // Kern's cost ceiling; overflow stays pending for a later cycle.
91
+ const MemoryCls = (await import("./Memory.js")).Memory;
92
+ const promoted = [];
93
+ const skipped = [];
94
+ let considered = 0;
95
+ // Bounded enumeration (Sherlock non-blocking + Kern's cost ceiling): filter
96
+ // to THIS agent's PENDING candidates in the DB query so a large backlog of
97
+ // other-status / other-agent rows is never scanned each cycle. The JS
98
+ // agentId/status re-checks below stay as FAIL-CLOSED defense-in-depth — the
99
+ // owner-scope guarantee does not rely on the query alone.
100
+ const candidateQuery = {
101
+ operator: "and",
102
+ conditions: [
103
+ { attribute: "agentId", comparator: "equals", value: agentId },
104
+ { attribute: "status", comparator: "equals", value: "pending" },
105
+ ],
106
+ };
107
+ for await (const c of databases.flair.MemoryCandidate.search(candidateQuery)) {
108
+ if (!c || typeof c !== "object")
109
+ continue;
110
+ if (c.agentId !== agentId)
111
+ continue; // owner scope (no cross-agent) — defense-in-depth
112
+ if (c.status !== "pending")
113
+ continue; // defense-in-depth
114
+ considered++;
115
+ const decision = decideAutoPromote(c);
116
+ if (!decision.promote) {
117
+ skipped.push({ id: c.id, reason: decision.reason });
118
+ continue;
119
+ }
120
+ const decidedAt = new Date().toISOString();
121
+ const memId = `${agentId}-promoted-${Date.now()}-${promoted.length}`;
122
+ const memRow = {
123
+ id: memId,
124
+ agentId,
125
+ content: c.claim,
126
+ durability: "persistent",
127
+ // ── visibility: PRIVATE, explicit (Sherlock cross-agent leak fix) ──────
128
+ // MUST be set. Memory.put() defaults an unset visibility from durability
129
+ // (Memory.ts) and "persistent" defaults to "shared" — and "shared" is
130
+ // ORG-OPEN (memory-read-scope.ts): readable by EVERY verified agent on
131
+ // the instance. The source episodes are the user's PRIVATE session data
132
+ // (durability:"standard" → default private), and the per-user boundary
133
+ // is adk-flair's CLIENT-SIDE tag re-verification, which OTHER agents do
134
+ // NOT run. So a shared auto-promoted claim would leak a user's distilled
135
+ // private data to every agent on the box — unattended. "private" is
136
+ // owner-only, so the claim is reachable ONLY through the app agent's own
137
+ // tag-filtered search (which re-verifies the tag) and is invisible to
138
+ // every other agent. This keeps the blast radius inside the one agentId,
139
+ // which is the entire #1205 safety argument.
140
+ visibility: "private",
141
+ // scopeTag FIRST — the per-user access-control boundary (Req 2).
142
+ tags: buildAutoPromotedTags(c.id, decision.scopeTag),
143
+ derivedFrom: Array.isArray(c.sourceMemoryIds) ? c.sourceMemoryIds : [],
144
+ promotionStatus: "approved",
145
+ promotedAt: decidedAt,
146
+ // Req 4 — non-impersonating machine reviewerId.
147
+ promotedBy: decision.reviewerId,
148
+ createdAt: decidedAt,
149
+ };
150
+ // ── The write — MEMORY ONLY ────────────────────────────────────────────
151
+ // Static Cls.put(row, context) routes THROUGH Memory.put()'s override
152
+ // (Req 3 content-safety scan on the write + embedding + provenance +
153
+ // per-agent ownership), acting as the agent itself (agentContext). There
154
+ // is deliberately no Soul equivalent here (Req 1). decideAutoPromote has
155
+ // already refused any content-flagged claim strict, so a normal claim
156
+ // sails through; a Memory.put refusal (e.g. instance-wide strict mode) is
157
+ // treated as a skip and the candidate is left pending — never a lost claim.
158
+ let writeRes;
159
+ try {
160
+ writeRes = await MemoryCls.put(memRow, agentContext(agentId));
161
+ }
162
+ catch (err) {
163
+ logger.warn?.(`AutoPromoteCandidates: Memory write threw for candidate ${c.id}: ${err?.message ?? err}`);
164
+ skipped.push({ id: c.id, reason: "memory_write_error" });
165
+ continue;
166
+ }
167
+ if (writeRes instanceof Response && !writeRes.ok) {
168
+ skipped.push({ id: c.id, reason: `memory_write_rejected:${writeRes.status}` });
169
+ continue;
170
+ }
171
+ // ── Mark the candidate promoted (commit point) ─────────────────────────
172
+ // Ordered AFTER the Memory write, matching the human promote path: the
173
+ // safe failure state is a promoted Memory whose candidate is still pending
174
+ // (re-swept next cycle; the Memory dedup gate absorbs the duplicate),
175
+ // never a candidate marked promoted with no Memory behind it.
176
+ try {
177
+ await databases.flair.MemoryCandidate.put({
178
+ ...c,
179
+ status: "promoted",
180
+ target: "memory",
181
+ reviewerId: decision.reviewerId,
182
+ reviewRationale: decision.rationale,
183
+ decidedAt,
184
+ });
185
+ }
186
+ catch (err) {
187
+ logger.warn?.(`AutoPromoteCandidates: candidate row update failed for ${c.id} (Memory ${memId} written): ${err?.message ?? err}`);
188
+ }
189
+ promoted.push(memId);
190
+ // Cost ceiling (Kern): cap the number PROMOTED this cycle. Remaining
191
+ // eligible candidates stay pending and are swept on a later cycle.
192
+ if (promoted.length >= cap)
193
+ break;
194
+ }
195
+ return {
196
+ agentId,
197
+ promoted,
198
+ skipped,
199
+ count: promoted.length,
200
+ considered,
201
+ };
202
+ }
203
+ }
@@ -94,20 +94,35 @@ import { estimateTokens } from "./token-estimate.js";
94
94
  *
95
95
  * CAP CONTRACT: `maxTokens` is the HARD cap on CONTENT SELECTION — the shared
96
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.
97
+ * AND every org event (flair#1199 — events are content too; before this they
98
+ * were assembled but NEVER charged, so a maxTokens=4000 request serialized at
99
+ * 6286) is gated against the remaining budget, so the sum of selected CONTENT
100
+ * never exceeds `maxTokens`. Each item is charged the cost of what it ACTUALLY
101
+ * SHIPS on the requested surface (see contentCost): on the /mcp connector path
102
+ * (includeContext=false) the prose `context` is a pointer, so only the
103
+ * STRUCTURED container object ships and is charged; on the REST/CLI prose path
104
+ * (includeContext=true) the prose IS the shipped surface and is charged (0.44.6
105
+ * capacity — flair#1207). `tokenEstimate` HONESTLY reports the real serialized
106
+ * payload (`JSON.stringify(responseBody)`). On the connector path where the
107
+ * selection charge and `tokenEstimate` measure the SAME structured bytes
108
+ * `tokenEstimate` exceeds `maxTokens` only by the FIXED JSON scaffolding
109
+ * (keys/braces, counters, the sections map, hints); the connector-conformance
110
+ * budgetCap asserts tokenEstimate <= maxTokens within a small tolerance for
111
+ * exactly that scaffolding. On the prose path the payload ALSO carries the
112
+ * structured mirror, so `tokenEstimate` may exceed `maxTokens` by that mirror —
113
+ * that overage is honest measurement, and shrinking prose selection to hide it
114
+ * is the flair#1207 regression (below). flair#1199 (0.44.11): charging the
115
+ * PROSE line but shipping the heavier STRUCTURED object on the /mcp path let
116
+ * teammate findings ride OUTSIDE the enforced budget (soulTokens 377 +
117
+ * memoryTokens 3574 = 3951 prose, just under a 4000 cap, yet tokenEstimate 5337
118
+ * = +33%; teammateFindingsIncluded crept 4→5) — fixed by charging structured
119
+ * on the connector path. flair#1207: #1199 had ALSO folded a per-item
120
+ * structured overhead + a scaffolding reserve INTO the selection budget, which
121
+ * silently shrank recall below 0.44.6 for the same `maxTokens`; that flat
122
+ * per-item overhead is a reporting concern (already captured by `tokenEstimate`)
123
+ * and no longer shrinks the content budget — the 0.44.11 fix charges the
124
+ * item's REAL shipped serialization, not a flat surcharge, and only on the path
125
+ * where that serialization is the shipped surface.
111
126
  *
112
127
  * COUNT CONTRACT (flair#1207): `memoriesIncluded + memoriesTruncated <=
113
128
  * memoriesAvailable` — included and truncated are disjoint sets of UNIQUE own
@@ -122,6 +137,11 @@ import { estimateTokens } from "./token-estimate.js";
122
137
  // Collision surfacing (flair#681) tunables.
123
138
  const COLLISION_WINDOW_DAYS = 7;
124
139
  const MAX_COLLISION_ENTRIES = 10;
140
+ // flair#1201/#1225 — trust-block sections that are a lifecycle-window LOAD, not
141
+ // a retrieval surface. A trust entry from one of these carries `matchQuality:
142
+ // null` (no relevance score to band), which is CORRECT (Kern's #1220 ruling),
143
+ // never a scoring failure — see the `matchQualityNote` in the response tail.
144
+ const LIFECYCLE_SECTIONS = new Set(["permanent", "recent", "predicted"]);
125
145
  // flair#1199/#1206 — the default cap on how many org events bootstrap ships.
126
146
  // Overridable per-request via `maxEvents`. Event slots are scarce AND (as of
127
147
  // #1199) token-charged, so this bounds both the count and the spend; the shared
@@ -368,6 +388,34 @@ export class BootstrapMemories extends Resource {
368
388
  subject: m.subject ?? null,
369
389
  section,
370
390
  });
391
+ // flair#1199 (0.44.11) — the REAL cost an admitted content item adds to the
392
+ // serialized payload the caller RECEIVES, which is exactly what
393
+ // `tokenEstimate` measures. This is the fix for the teammate-findings
394
+ // budget blowout: the selector used to charge every memory/finding its
395
+ // PROSE line (`formatMemory`) but, on the /mcp connector path, ship the
396
+ // heavier STRUCTURED container object — and `tokenEstimate` measures the
397
+ // structured object. A teammate finding carries `id` + TWO ISO timestamps +
398
+ // `source` + `section` + JSON field names/quotes that the prose line does
399
+ // not, so the shipped object runs ~1.5–1.7× its prose line. Charging prose
400
+ // but shipping structured let several teammate findings ride OUTSIDE the
401
+ // enforced budget: a maxTokens=4000 bootstrap reported soulTokens 377 +
402
+ // memoryTokens 3574 = 3951 (prose, just under cap) yet serialized at 5337
403
+ // (+33%), and teammateFindingsIncluded crept 4→5. So charge what SHIPS:
404
+ // - /mcp connector path (includeContext=false): the prose `context` is a
405
+ // compact pointer (no bodies), so ONLY the structured container ships —
406
+ // charge its serialized size. Now the sum of admitted content ≈
407
+ // `tokenEstimate` minus the FIXED JSON scaffolding, so `tokenEstimate`
408
+ // stays within maxTokens + the small scaffolding tolerance.
409
+ // - REST/CLI prose path (includeContext=true): the prose IS the primary
410
+ // shipped surface and flair#1207 deliberately fixed the selection budget
411
+ // at 0.44.6 (prose) capacity — `tokenEstimate` is HONEST there and may
412
+ // exceed maxTokens by the structured mirror it also ships. Charging
413
+ // structured on THAT path would re-shrink prose recall below 0.44.6
414
+ // (the exact #1207 regression). So keep charging prose on the prose
415
+ // path. This is "fix the budget INPUT, not the cap": the figure the
416
+ // selector tests against maxTokens is now the figure that becomes
417
+ // `tokenEstimate` on each path.
418
+ const contentCost = (structured, proseLine) => includeContext ? estimateTokens(proseLine) : estimateTokens(JSON.stringify(structured));
371
419
  // --- 1. Soul records (budgeted — prioritized by key importance) ---
372
420
  // Soul is who you are, but we still need to respect token budgets.
373
421
  // Workspace files (SOUL.md, AGENTS.md) can be massive — they're already
@@ -617,10 +665,14 @@ export class BootstrapMemories extends Resource {
617
665
  const permanent = permanentRows.filter((m) => !permanentSupersededIds.has(m.id));
618
666
  for (const m of permanent) {
619
667
  const line = formatMemory(m, agentId);
620
- const cost = estimateTokens(line); // #1207 — prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
668
+ const struct = leanMemory(m, "permanent");
669
+ // #1199 (0.44.11) — charge what SHIPS (structured on the /mcp path, prose
670
+ // on the REST path); see contentCost. #1207 stays honored: on the prose
671
+ // path this is still the prose-line cost, so REST recall is unchanged.
672
+ const cost = contentCost(struct, line);
621
673
  if (cost <= tokenBudget) {
622
674
  sections.permanent.push(line);
623
- includedOwnMemories.push(leanMemory(m, "permanent"));
675
+ includedOwnMemories.push(struct);
624
676
  if (includeTrust)
625
677
  includedTrustMemories.push({ m, section: "permanent" });
626
678
  tokenBudget -= cost;
@@ -689,13 +741,14 @@ export class BootstrapMemories extends Resource {
689
741
  let recentSpent = 0;
690
742
  for (const m of recent) {
691
743
  const line = formatMemory(m, agentId);
692
- const cost = estimateTokens(line); // #1207 — prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
744
+ const struct = leanMemory(m, "recent");
745
+ const cost = contentCost(struct, line); // #1199 (0.44.11) — charge what ships; see contentCost
693
746
  if (recentSpent + cost > recentBudget) {
694
747
  truncatedOwnIds.add(m.id); // #1207 — budget-skip; may still be admitted later via the task-relevant loop (deduped at the end)
695
748
  continue;
696
749
  }
697
750
  sections.recent.push(line);
698
- includedOwnMemories.push(leanMemory(m, "recent"));
751
+ includedOwnMemories.push(struct);
699
752
  if (includeTrust)
700
753
  includedTrustMemories.push({ m, section: "recent" });
701
754
  recentSpent += cost;
@@ -732,13 +785,14 @@ export class BootstrapMemories extends Resource {
732
785
  let predictedSpent = 0;
733
786
  for (const m of subjectMemories) {
734
787
  const line = formatMemory(m, agentId);
735
- const cost = estimateTokens(line); // #1207 — prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
788
+ const struct = leanMemory(m, "predicted");
789
+ const cost = contentCost(struct, line); // #1199 (0.44.11) — charge what ships; see contentCost
736
790
  if (predictedSpent + cost > predictedBudget) {
737
791
  truncatedOwnIds.add(m.id); // #1207 — budget-skip (deduped against inclusions at the end)
738
792
  continue;
739
793
  }
740
794
  sections.predicted.push(line);
741
- includedPredicted.push(leanMemory(m, "predicted"));
795
+ includedPredicted.push(struct);
742
796
  if (includeTrust)
743
797
  includedTrustMemories.push({ m, section: "predicted" });
744
798
  predictedSpent += cost;
@@ -908,7 +962,29 @@ export class BootstrapMemories extends Resource {
908
962
  // section double-spends.
909
963
  for (const { memory: m } of scored) {
910
964
  const line = formatMemory(m, agentId);
911
- const cost = estimateTokens(line); // #1207 prose-line cost only; overhead is a reporting concern (tokenEstimate), not a selection constraint
965
+ // flair#1199 (0.44.11) — build the STRUCTURED container object BEFORE
966
+ // the budget check so the finding is charged the cost of what actually
967
+ // ships (structured on the /mcp path), not its cheaper prose line. A
968
+ // teammate finding's structured object (id + two ISO timestamps +
969
+ // source + section) runs well over its prose line, and it — not the
970
+ // prose — is what `tokenEstimate` measures on the connector path. This
971
+ // is the fix for the teammate-findings blowout: charging prose but
972
+ // shipping structured let extra findings ride outside the enforced
973
+ // budget (see contentCost). Cross-agent findings (`m._source` set)
974
+ // ship in `teammateFindings`; own findings in `memories`.
975
+ const struct = m._source
976
+ ? {
977
+ id: m.id,
978
+ content: m.content,
979
+ durability: m.durability ?? null,
980
+ createdAt: m.createdAt ?? null,
981
+ updatedAt: m.updatedAt ?? null,
982
+ subject: m.subject ?? null,
983
+ source: m._source,
984
+ section: "teammate",
985
+ }
986
+ : leanMemory(m, "relevant");
987
+ const cost = contentCost(struct, line);
912
988
  if (cost > tokenBudget) {
913
989
  // flair#1207 — a size-skip in the score-ordered task-relevant loop
914
990
  // is no longer silent: record it on the denominator matching the
@@ -929,16 +1005,7 @@ export class BootstrapMemories extends Resource {
929
1005
  // off. Counted separately (teammateFindingsIncluded), NOT into
930
1006
  // memoriesIncluded — that different-denominator mix is what let
931
1007
  // included exceed available.
932
- includedTeammateFindings.push({
933
- id: m.id,
934
- content: m.content,
935
- durability: m.durability ?? null,
936
- createdAt: m.createdAt ?? null,
937
- updatedAt: m.updatedAt ?? null,
938
- subject: m.subject ?? null,
939
- source: m._source,
940
- section: "teammate",
941
- });
1008
+ includedTeammateFindings.push(struct);
942
1009
  if (includeTrust)
943
1010
  includedTrustMemories.push({ m, section: "teammate" });
944
1011
  tokenBudget -= cost;
@@ -948,7 +1015,7 @@ export class BootstrapMemories extends Resource {
948
1015
  sections.relevant.push(line);
949
1016
  // flair#1182 — own task-relevant records join the `memories`
950
1017
  // container.
951
- includedOwnMemories.push(leanMemory(m, "relevant"));
1018
+ includedOwnMemories.push(struct);
952
1019
  if (includeTrust)
953
1020
  includedTrustMemories.push({ m, section: "relevant" });
954
1021
  tokenBudget -= cost;
@@ -1276,9 +1343,30 @@ export class BootstrapMemories extends Resource {
1276
1343
  // absent ⇒ the response is byte-identical to pre-slice-1. flair#1201 — each
1277
1344
  // entry carries its `section` so `matchQuality: null` on a lifecycle section
1278
1345
  // reads as "not a retrieval surface", not as a scoring failure on the
1279
- // caller's own records.
1346
+ // caller's own records. flair#1225 (0.44.11) — the null on a lifecycle
1347
+ // section is now SELF-EXPLAINING (matchQualityNote below), not just legible
1348
+ // via `section`.
1280
1349
  const trust = includeTrust
1281
- ? includedTrustMemories.map(({ m, section }) => ({ id: m.id, section, ...buildTrustBlock(m) }))
1350
+ ? includedTrustMemories.map(({ m, section }) => {
1351
+ const block = buildTrustBlock(m);
1352
+ // flair#1225 — Kern ruled (on #1220) that a null `matchQuality` on an
1353
+ // own-recent (lifecycle) entry is CORRECT: lifecycle sections
1354
+ // (permanent/recent/predicted) are a window LOAD, not a retrieval
1355
+ // surface, so there is no similarity to band — the null means "not
1356
+ // scored here", never a scoring failure on the caller's own records
1357
+ // (the #1201 misread: own-recent null beside a teammate band). Behavior
1358
+ // is unchanged (per Kern); this only makes the null self-describing in
1359
+ // the payload — Fix 3's "any absent field says why" — so a connector
1360
+ // reads it right without knowing the #1201 contract.
1361
+ const matchQualityNote = block.matchQuality === null
1362
+ ? (LIFECYCLE_SECTIONS.has(section)
1363
+ ? `matchQuality is null because '${section}' is a lifecycle-window section, not a retrieval `
1364
+ + `surface — there is no relevance score to band. This is correct (per flair#1225), not a scoring failure.`
1365
+ : "matchQuality is null because no semantic similarity was attached to this result "
1366
+ + "(e.g. a by-id read or a keyword-only degraded match).")
1367
+ : undefined;
1368
+ return { id: m.id, section, ...block, ...(matchQualityNote ? { matchQualityNote } : {}) };
1369
+ })
1282
1370
  : undefined;
1283
1371
  // flair#744 slice 2 — opt-in abstention verdict for the task-relevance
1284
1372
  // surface. Present ONLY when `abstain` is requested (byte-identical to
@@ -1317,6 +1405,36 @@ export class BootstrapMemories extends Resource {
1317
1405
  + `predicted surfaces your own non-permanent memories whose subject matches one of the provided `
1318
1406
  + `subjects — it fills as you store memories tagged with these subjects.`
1319
1407
  : undefined;
1408
+ // flair#1182 (0.44.11) — GENERALIZE the empty-container "say why" rule
1409
+ // beyond predictedHint. `events: []` (deliberate no-op filtering) was
1410
+ // byte-indistinguishable from the 0.44.8 silent-drop regression, where a
1411
+ // container that SHOULD have had content shipped empty — a connector could
1412
+ // only tell the difference by diffing against a previous payload. The rule
1413
+ // (now applied consistently across the structured containers): any container
1414
+ // that ships EMPTY carries a short hint naming WHY it is empty and what
1415
+ // fills it, so "deliberately empty" is never confused with "silently
1416
+ // dropped". Present ONLY when the container is empty (a populated container
1417
+ // needs no hint), so a healthy payload is unchanged.
1418
+ // events: [] — no org event in the lookback window was relevant to the
1419
+ // caller after zero-row no-op auto-heal filtering (#1200). Present-but-empty
1420
+ // by design, not a drop.
1421
+ const eventsHint = includedEvents.length === 0
1422
+ ? "No org events in the lookback window were relevant to you (org-wide, or targeted at you) "
1423
+ + "after zero-row no-op auto-heal filtering. This container is present-but-empty by design, not dropped."
1424
+ : undefined;
1425
+ // teammateFindings: [] — name WHICH legitimate empty this is (no task → no
1426
+ // retrieval; matched-but-budget-truncated; or nothing cleared the relevance
1427
+ // floor), so it never reads as a silent drop.
1428
+ const teammateFindingsHint = includedTeammateFindings.length === 0
1429
+ ? (!taskProvided
1430
+ ? "No teammateFindings: cross-agent findings are retrieved against your currentTask, and none was provided. "
1431
+ + "Pass currentTask to populate this."
1432
+ : teammateFindingsTruncated > 0
1433
+ ? `No teammateFindings fit the token budget: ${teammateFindingsTruncated} relevant cross-agent finding(s) `
1434
+ + "cleared the relevance floor but were budget-truncated. Raise maxTokens to include them."
1435
+ : "No cross-agent (teammate) memory cleared the task-relevance floor for this currentTask. "
1436
+ + "This container is present-but-empty by design, not dropped.")
1437
+ : undefined;
1320
1438
  const responseBody = {
1321
1439
  context,
1322
1440
  // flair#1182 (part 1) — always-present self-describing keys: who the
@@ -1341,6 +1459,11 @@ export class BootstrapMemories extends Resource {
1341
1459
  events: includedEvents,
1342
1460
  ...(currentTaskHint ? { currentTaskHint } : {}),
1343
1461
  ...(predictedHint ? { predictedHint } : {}),
1462
+ // flair#1182 (0.44.11) — empty-container hints, present only when the
1463
+ // container ships empty (see above), so a deliberately-empty container is
1464
+ // never confused with a silent drop.
1465
+ ...(eventsHint ? { eventsHint } : {}),
1466
+ ...(teammateFindingsHint ? { teammateFindingsHint } : {}),
1344
1467
  ...(trust ? { trust } : {}),
1345
1468
  ...(abstention ? { abstention } : {}),
1346
1469
  sections: {
@@ -1392,16 +1515,20 @@ export class BootstrapMemories extends Resource {
1392
1515
  // the real serialized size. Every CONTENT section — soul, memories, findings,
1393
1516
  // AND events (flair#1199) — is now gated against the shared `maxTokens`
1394
1517
  // 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`.
1518
+ // 0.44.9 events array did (maxTokens=4000 → 6286). flair#1199 (0.44.11): on
1519
+ // the /mcp connector path each content item is charged its STRUCTURED shipped
1520
+ // cost the same bytes tokenEstimate measuresso tokenEstimate exceeds
1521
+ // `maxTokens` only by the FIXED structural JSON scaffolding (container
1522
+ // keys/braces, counters, sections map, hints, char/4 rounding): genuine
1523
+ // payload the caller pays for, small and bounded, NOT uncounted content. The
1524
+ // connector-conformance suite asserts tokenEstimate <= maxTokens within a
1525
+ // small tolerance for exactly that scaffolding. On the PROSE path
1526
+ // (includeContext=true) the payload ALSO carries the structured mirror, so
1527
+ // tokenEstimate legitimately exceeds `maxTokens` by that mirror do NOT
1528
+ // "fix" THAT overrun by shrinking prose selection: that is the #1199→#1207
1529
+ // regression (it dropped relevant findings, charging a flat per-item overhead
1530
+ // against the content budget). If the real payload consistently overruns for
1531
+ // a use case, raise `maxTokens`.
1405
1532
  const tokenEstimate = estimateTokens(JSON.stringify(responseBody));
1406
1533
  return { ...responseBody, tokenEstimate };
1407
1534
  }
@@ -0,0 +1,137 @@
1
+ // ─── ADK auto-promote — pure policy for /AutoPromoteCandidates (#1205b-2) ─────
2
+ //
3
+ // The UNATTENDED promotion path. After the tag-aware nightly cycle (#1205b-1)
4
+ // stages per-user MemoryCandidates each carrying a `scopeTag`, this policy
5
+ // decides whether an ADK-sourced candidate may be auto-promoted to the user's
6
+ // OWN memory with NO human reviewer in the loop — replacing the human
7
+ // `rem promote` for this one narrow path.
8
+ //
9
+ // Because there is no human to catch a mistake, every one of Sherlock's four
10
+ // hard requirements is a load-bearing gate here (issue #1205 authz review):
11
+ //
12
+ // Req 1 (memory-only, server-side): NOT decided here. The target is
13
+ // hard-locked to `memory` STRUCTURALLY in resources/AutoPromoteCandidates.ts
14
+ // — this lib has no notion of a target at all, so no value it returns can
15
+ // ever route a write to Soul. Keeping the target out of the policy object is
16
+ // the point: a policy field could be flipped; an absent one cannot.
17
+ //
18
+ // Req 2 (tag lineage, FAIL-CLOSED): decideAutoPromote REFUSES any candidate
19
+ // whose stamped `scopeTag` is absent/empty or not an `adk:` scope tag. A
20
+ // tagless promoted claim lands in the SHARED agentId namespace and becomes
21
+ // retrievable by every other user of the app (cross-user leak) — so a
22
+ // missing scope tag is a hard STOP, never a benign "promote untagged". The
23
+ // stamped scopeTag (resources/memory-reflect-lib.ts buildStagedCandidateRow)
24
+ // is AUTHORITATIVE and consumed directly — we never re-read source memories
25
+ // (the seam #1205b-1 closed).
26
+ //
27
+ // Req 3 (content-safety, STRICT for the unattended path): the human gate was
28
+ // also a content-safety gate. decideAutoPromote scans the claim through the
29
+ // SAME scanFields path Memory.ts uses (content-safety.ts) and, unlike
30
+ // Memory.ts's write scan, ALWAYS refuses a flagged claim regardless of
31
+ // FLAIR_CONTENT_SAFETY — an unattended write must not silently promote a
32
+ // prompt-injection payload merely because the instance runs in `warn` mode.
33
+ //
34
+ // Req 4 (non-impersonating machine reviewerId): a promoted claim records
35
+ // MACHINE_REVIEWER_ADK_AUTO_PROMOTE in the reserved `machine:` namespace, so
36
+ // audit/attribution can never mistake an automated decision for a human or
37
+ // agent reviewer.
38
+ //
39
+ // Pure and Harper-free (its only import, content-safety.ts, is pure regex), so
40
+ // the whole fail-closed/strict-safety decision is unit-testable directly with no
41
+ // Harper process — the same split resources/memory-reflect-lib.ts uses.
42
+ import { scanFields } from "./content-safety.js";
43
+ // ─── ADK scope tag (the per-user access-control boundary) ────────────────────
44
+ // adk-flair collapses (app, user) → ONE Flair agentId, separating users ONLY by
45
+ // a compound tag `adk:<app>:<user>`. That tag IS the access-control boundary, so
46
+ // an auto-promoted claim that does not carry it is a cross-user leak.
47
+ export const ADK_SCOPE_TAG_PREFIX = "adk:";
48
+ // ─── Machine reviewer namespace (Sherlock req 4) ─────────────────────────────
49
+ // A promotion records a reviewerId that feeds audit/attribution
50
+ // (schemas/memory.graphql). An automated path must record one that can NEVER be
51
+ // mistaken for a human/agent reviewer. Reserved `machine:` namespace; canonical
52
+ // id for this consumer is machine:adk-auto-promote.
53
+ //
54
+ // NOTE ON DUPLICATION: src/cli.ts declares its own copies of these constants
55
+ // (and validateHumanReviewerId, which refuses the reserved namespace on the
56
+ // HUMAN promote path). The two live on opposite sides of the npm-packaging
57
+ // boundary — src/ ships as the CLI bundle, resources/ ships as the Harper
58
+ // component, and cli.ts's own header notes imports across that boundary "don't
59
+ // survive npm packaging". They are kept in sync by the shared canonical string;
60
+ // there is no runtime path that imports one into the other.
61
+ export const MACHINE_REVIEWER_PREFIX = "machine:";
62
+ export const MACHINE_REVIEWER_ADK_AUTO_PROMOTE = "machine:adk-auto-promote";
63
+ /** Standard, honest rationale recorded on every auto-promoted claim + its
64
+ * candidate row, so the audit trail states plainly that no human reviewed it. */
65
+ export const AUTO_PROMOTE_RATIONALE = "auto-promoted from ADK session distillation (#1205) — unattended, own-memory only, scope-tag verified, content-safety scanned; no human reviewer";
66
+ /**
67
+ * Per-call ceiling on auto-promotions (Kern's cost-ceiling note). Auto-promote
68
+ * runs once per nightly cycle, not on every write, and each promotion is a
69
+ * bounded DB write (plus at most one embedding compute on the Memory.put path),
70
+ * so this caps the blast radius of a single cycle rather than throttling a hot
71
+ * path. Overflow stays `pending` and is swept on subsequent cycles.
72
+ */
73
+ export const DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE = 200;
74
+ /**
75
+ * Decide whether an ADK-sourced candidate may be auto-promoted to own memory.
76
+ *
77
+ * FAIL-CLOSED throughout: any condition that cannot be positively confirmed
78
+ * results in `{ promote: false }` (the candidate is left pending for the human
79
+ * `rem promote` path), never a promotion. This function decides ONLY whether to
80
+ * promote and with what per-user scope tag / reviewer — never WHERE (the target
81
+ * is memory-only and enforced structurally by the resource; see this file's
82
+ * header, Req 1).
83
+ */
84
+ export function decideAutoPromote(candidate) {
85
+ // Idempotency (Kern 2d): only ever act on a still-pending candidate. A
86
+ // re-run after a crash re-enumerates and skips anything already promoted.
87
+ if (candidate.status !== "pending") {
88
+ return { promote: false, reason: "not_pending" };
89
+ }
90
+ // Req 2 — tag lineage, FAIL CLOSED. Consume the stamped scopeTag directly
91
+ // (authoritative; never re-read sources). Absent, empty, or non-`adk:` ⇒
92
+ // refuse: a tagless claim in the shared agentId namespace is a cross-user
93
+ // leak, and auto-promote is ONLY for ADK-sourced (scopeTag-bearing)
94
+ // candidates — a non-ADK candidate still requires human `rem promote`.
95
+ const scopeTag = candidate.scopeTag;
96
+ if (typeof scopeTag !== "string" || !scopeTag.startsWith(ADK_SCOPE_TAG_PREFIX)) {
97
+ return { promote: false, reason: "no_adk_scope_tag" };
98
+ }
99
+ const claim = candidate.claim;
100
+ if (typeof claim !== "string" || claim.trim().length === 0) {
101
+ return { promote: false, reason: "empty_claim" };
102
+ }
103
+ // Req 3 — content-safety, STRICT for the unattended path. Same scanFields the
104
+ // Memory write path uses (content-safety.ts), but here a flag is ALWAYS a
105
+ // refusal, independent of FLAIR_CONTENT_SAFETY: an unattended promotion must
106
+ // never let a prompt-injection payload through merely because the instance is
107
+ // in `warn` mode. (The Memory.put() write scan still runs on top of this as
108
+ // defense-in-depth.)
109
+ const safety = scanFields({ content: claim }, ["content"]);
110
+ if (!safety.safe) {
111
+ return { promote: false, reason: `content_safety:${safety.flags.join(",")}` };
112
+ }
113
+ return {
114
+ promote: true,
115
+ scopeTag,
116
+ reviewerId: MACHINE_REVIEWER_ADK_AUTO_PROMOTE,
117
+ rationale: AUTO_PROMOTE_RATIONALE,
118
+ };
119
+ }
120
+ /** True iff `id` is in the reserved machine-reviewer namespace (an automated
121
+ * path, never a human/agent reviewer). Mirror of src/cli.ts isMachineReviewerId
122
+ * on the resources side of the packaging boundary. */
123
+ export function isMachineReviewerId(id) {
124
+ return typeof id === "string" && id.startsWith(MACHINE_REVIEWER_PREFIX);
125
+ }
126
+ /**
127
+ * The tag set for an auto-promoted Memory. The per-user `scopeTag` MUST come
128
+ * first and is load-bearing — it is the access-control boundary that keeps the
129
+ * promoted claim visible only to its own user's tag filter. `auto-promoted`
130
+ * marks the whole class as machine-written so every auto-promoted claim is
131
+ * identifiable and bulk-removable if the policy is ever rolled back (Kern 2b);
132
+ * `nightly-rem-promoted` matches the human promote path; `from:<id>` preserves
133
+ * candidate lineage.
134
+ */
135
+ export function buildAutoPromotedTags(candidateId, scopeTag) {
136
+ return [scopeTag, "nightly-rem-promoted", "auto-promoted", `from:${candidateId}`];
137
+ }
@@ -240,6 +240,14 @@ Writes are scoped per-agent (your `FLAIR_AGENT_ID`) and enforced by Flair's serv
240
240
 
241
241
  Which memories are non-private is decided at write time, and the default is not "shared". `memory_store` defaults `durability` to `standard`, and the server derives visibility from durability — `permanent`/`persistent` → `shared`, `standard`/`ephemeral` → `private` — so **a bare `memory_store` call writes an owner-only memory that no other agent can read.** Pass `visibility: "shared"` (or `"private"`, to be explicit) to say what you mean; the tool reports the visibility the write actually landed on so an agent can confirm it rather than assume.
242
242
 
243
+ ### Reading the `bootstrap` payload
244
+
245
+ `bootstrap` returns the canonical structured containers — `soul`, `memories`, `predicted`, `teammateFindings`, `events` — plus counts and a `tokenEstimate`. The containers are **always present** (empty `[]`/`{}` when there's nothing), so an empty container is distinguishable from an unsupported one.
246
+
247
+ **Empty containers say why they're empty (flair#1182).** When a structured container ships empty, the payload carries a short hint naming the reason and what fills it — `eventsHint`, `teammateFindingsHint`, `predictedHint`. This is present *only* when the container is empty, so a deliberately-empty container is never confused with a silent drop (a connector never has to diff against a previous payload to tell the two apart).
248
+
249
+ **`matchQuality` is null on lifecycle sections — by design (flair#1225).** With `includeTrust: true`, each included memory carries a per-memory trust block, section-tagged, whose `matchQuality` is a `strong`/`moderate`/`breadcrumb` confidence band. On the **lifecycle sections** (`permanent`, `recent`, `predicted`) `matchQuality` is `null`: those are a lifecycle-window *load*, not a retrieval surface, so there is no relevance score to band. This is **correct, not a scoring failure** — an own-recent `null` next to a teammate's band does not mean your own records "scored worse". A retrieval band is only meaningful on the retrieval sections (`relevant`, `teammate`). The entry's `section` field makes this legible, and a `matchQualityNote` on any null entry states the reason inline.
250
+
243
251
  ---
244
252
 
245
253
  ## Configuration reference
package/docs/rem.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # REM — reflection, distillation, and review
2
2
 
3
- REM (Reflect · Extract · Merge) is Flair's memory-curation cycle: it reads an agent's recent memories, distills them into candidate insights, and stages those candidates for explicit human/agent review — nothing is ever auto-promoted. `flair rem rapid` runs it on demand; `flair rem nightly enable` runs it on a schedule. See [`docs/notes/rem-ux.md`](notes/rem-ux.md) for the full trigger model, locality guarantees, and the review-loop UX this page's commands feed into.
3
+ REM (Reflect · Extract · Merge) is Flair's memory-curation cycle: it reads an agent's recent memories, distills them into candidate insights, and stages those candidates for explicit human/agent review — nothing is auto-promoted except the narrow ADK per-user path (see [Auto-promote](#auto-promote-adk-only)). `flair rem rapid` runs it on demand; `flair rem nightly enable` runs it on a schedule. See [`docs/notes/rem-ux.md`](notes/rem-ux.md) for the full trigger model, locality guarantees, and the review-loop UX this page's commands feed into.
4
4
 
5
5
  > **⚠️ Prerequisite: a configured generative backend.** All REM commands (`rapid`, `nightly`, `candidates`, `promote`, `reject`) require Harper's `models.generate()` to be wired — without it, REM calls fail with `Reflection error: No generative backend configured`. Set up a backend first (see [Configuration](#configuration) below) before running any REM command. The fastest path is Ollama with a non-thinking model, which needs zero credentials and keeps all traffic local.
6
6
 
@@ -59,10 +59,21 @@ Snapshot locality follows from this: a nightly cycle's pre-run snapshot (`~/.fla
59
59
  - **Interactive (`flair rem rapid`):** one bounded, synchronous distillation call — gather cap 50 memories, bounded output tokens, seconds not minutes. Executes by default, staging candidates and printing a summary; `--prompt-only` returns the reflection prompt instead, for the bring-your-own-model handoff.
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
- 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.
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 except the narrow ADK per-user path ([Auto-promote](#auto-promote-adk-only)) — 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
63
 
64
64
  ### ADK agents — per-user (per-tag) distillation
65
65
 
66
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
67
 
68
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.
69
+
70
+ #### Auto-promote (ADK only)
71
+
72
+ For ADK agents, the nightly cycle **auto-promotes** these `scopeTag`-bearing candidates to the user's own persistent memory immediately after distillation — the one place REM does not wait for a human `rem promote`. The safety argument is blast-radius, not identity: the claim is distilled from a user's own sessions into that same user's own tag scope, so no cross-agent or Soul trust boundary is crossed. The promotion is enforced entirely server-side (`POST /AutoPromoteCandidates`), never by a CLI flag a compromised agent key could flip, and holds four invariants:
73
+
74
+ - **Memory only, never Soul.** The target is hard-locked to `memory`; there is no Soul code path (Soul is agentId-scoped and cannot carry a per-user tag, so an ADK-sourced Soul promotion would be cross-user by construction).
75
+ - **Fail-closed tag lineage.** A candidate is promoted only if it carries an authoritative `adk:<app>:<user>` scope tag, which the promoted memory then carries. The promoted memory is written `visibility:"private"` (owner-only) — not the org-open `shared` default a `persistent` write would otherwise get — so it is reachable only through the app agent's own tag-filtered search (which re-verifies the tag), invisible both to another user's tag filter and to every other agent on the instance. A candidate whose scope tag is absent or blank is left pending, never promoted tagless into the shared agentId namespace.
76
+ - **Content-safety, strict.** The claim is scanned for prompt injection and refused on a flag regardless of `FLAIR_CONTENT_SAFETY` — an unattended write does not fall back to warn-and-tag.
77
+ - **Non-impersonating reviewer.** The promoted memory and its candidate record `machine:adk-auto-promote`, never a value mistakable for a human or agent reviewer.
78
+
79
+ Anything ineligible (no scope tag, flagged content, already decided) is left pending for the human `rem promote` path. The step is bounded per cycle and non-fatal; `flair rem nightly run-once` reports the count auto-promoted. **Non-ADK candidates never auto-promote** — the human review gate below is unchanged for them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.44.10",
3
+ "version": "0.44.11",
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",