@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 +5 -0
- package/dist/rem/runner.js +44 -0
- package/dist/resources/AutoPromoteCandidates.js +203 -0
- package/dist/resources/MemoryBootstrap.js +171 -44
- package/dist/resources/auto-promote-lib.js +137 -0
- package/docs/mcp-clients.md +8 -0
- package/docs/rem.md +13 -2
- package/package.json +1 -1
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).
|
package/dist/rem/runner.js
CHANGED
|
@@ -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
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* never exceeds `maxTokens`.
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 }) =>
|
|
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).
|
|
1396
|
-
//
|
|
1397
|
-
//
|
|
1398
|
-
//
|
|
1399
|
-
//
|
|
1400
|
-
//
|
|
1401
|
-
//
|
|
1402
|
-
//
|
|
1403
|
-
//
|
|
1404
|
-
//
|
|
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 measures — so 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
|
+
}
|
package/docs/mcp-clients.md
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|