@tpsdev-ai/flair 0.44.9 → 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 +193 -2
- package/dist/rem/runner.js +211 -23
- package/dist/resources/AutoPromoteCandidates.js +203 -0
- package/dist/resources/MemoryBootstrap.js +344 -100
- package/dist/resources/MemoryReflect.js +15 -13
- package/dist/resources/auto-promote-lib.js +137 -0
- package/dist/resources/mcp-tools.js +242 -19
- package/dist/resources/memory-bootstrap-lib.js +58 -0
- package/dist/resources/memory-reflect-lib.js +70 -0
- package/dist/resources/token-estimate.js +25 -0
- package/docs/mcp-clients.md +8 -0
- package/docs/rem.md +19 -2
- package/package.json +1 -1
- package/schemas/memory.graphql +9 -0
|
@@ -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
|
+
}
|