@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
|
@@ -40,7 +40,7 @@ import { Resource, databases, models, logger } from "harper";
|
|
|
40
40
|
import { randomBytes } from "node:crypto";
|
|
41
41
|
import { isAdmin, allowVerified } from "./agent-auth.js";
|
|
42
42
|
import { patchRecordSilent } from "./table-helpers.js";
|
|
43
|
-
import { buildReflectionPrompt, buildExecutePrompt, resolveReflectActor, generateCandidates, dedupeCandidates, } from "./memory-reflect-lib.js";
|
|
43
|
+
import { buildReflectionPrompt, buildExecutePrompt, resolveReflectActor, generateCandidates, dedupeCandidates, memoryMatchesReflectScope, buildStagedCandidateRow, } from "./memory-reflect-lib.js";
|
|
44
44
|
export class ReflectMemories extends Resource {
|
|
45
45
|
// Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
|
|
46
46
|
// admin elevation). Any verified agent may reflect; the isAdmin checks in post()
|
|
@@ -75,15 +75,12 @@ export class ReflectMemories extends Resource {
|
|
|
75
75
|
continue;
|
|
76
76
|
if (record.durability === "permanent")
|
|
77
77
|
continue; // permanent memories don't need reflection
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
// scope="all" passes everything
|
|
78
|
+
// Scope selection — the cross-user-bleed boundary (#1205b-1). See
|
|
79
|
+
// memoryMatchesReflectScope's doc: scope:"tagged" admits ONLY the one
|
|
80
|
+
// adk:<app>:<user> tag's memories, so a candidate distilled here can
|
|
81
|
+
// never cite another user's memory.
|
|
82
|
+
if (!memoryMatchesReflectScope(record, { scope, tag, sinceDate }))
|
|
83
|
+
continue;
|
|
87
84
|
const { embedding, ...rest } = record;
|
|
88
85
|
memories.push(rest);
|
|
89
86
|
if (memories.length >= maxMemories)
|
|
@@ -152,7 +149,11 @@ export class ReflectMemories extends Resource {
|
|
|
152
149
|
const generatedAt = new Date().toISOString();
|
|
153
150
|
const staged = [];
|
|
154
151
|
for (const c of toStage) {
|
|
155
|
-
|
|
152
|
+
// #1205b-1: buildStagedCandidateRow stamps `scopeTag` when this run was
|
|
153
|
+
// scope:"tagged" — the authoritative per-user tag promotion consumes
|
|
154
|
+
// directly (closing the #1205a source-re-read seam). Non-tagged runs
|
|
155
|
+
// leave scopeTag absent, unchanged.
|
|
156
|
+
const row = buildStagedCandidateRow({
|
|
156
157
|
id: `cand_${randomBytes(8).toString("hex")}`,
|
|
157
158
|
agentId,
|
|
158
159
|
claim: c.claim,
|
|
@@ -160,8 +161,9 @@ export class ReflectMemories extends Resource {
|
|
|
160
161
|
rationalePrompt: executePrompt,
|
|
161
162
|
generatedBy: resolvedModel,
|
|
162
163
|
generatedAt,
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
scope,
|
|
165
|
+
tag,
|
|
166
|
+
});
|
|
165
167
|
await databases.flair.MemoryCandidate.put(row);
|
|
166
168
|
staged.push(row);
|
|
167
169
|
}
|
|
@@ -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
|
+
}
|
|
@@ -116,14 +116,34 @@ async function unwrap(value) {
|
|
|
116
116
|
return value;
|
|
117
117
|
}
|
|
118
118
|
/**
|
|
119
|
-
* flair#1188 —
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
119
|
+
* flair#1188 — the internal, embedding-engine-owned fields a memory record
|
|
120
|
+
* carries that must NEVER cross the MCP surface. Both are server-managed and
|
|
121
|
+
* useless (or misleading) to a connector:
|
|
122
|
+
*
|
|
123
|
+
* - `embedding` — the raw 768-float HNSW vector; thousands of noise
|
|
124
|
+
* tokens per record on a fixed-budget chat connector, and
|
|
125
|
+
* the caller can do nothing with it (flair#1188).
|
|
126
|
+
* - `embeddingModel` — the model id stamped on every write
|
|
127
|
+
* (resources/Memory.ts stamps `content.embeddingModel =
|
|
128
|
+
* getModelId()`; schemas/memory.graphql declares it
|
|
129
|
+
* @indexed). The WRITE wrappers already treat it as
|
|
130
|
+
* internal — memory_update `delete`s it from both the
|
|
131
|
+
* overwrite and the supersede record — so the READ path
|
|
132
|
+
* must strip it too, or memory_get leaks an internal
|
|
133
|
+
* field the write echoes hide (flair#1213, Sherlock #1).
|
|
134
|
+
*
|
|
135
|
+
* Exported so the flair#1213 conformance "no leaked internal fields" invariant
|
|
136
|
+
* enumerates the SAME list this function strips: the strip and the assertion
|
|
137
|
+
* cannot drift, and adding a field here automatically extends both.
|
|
138
|
+
*/
|
|
139
|
+
export const INTERNAL_MEMORY_FIELDS = ["embedding", "embeddingModel"];
|
|
140
|
+
/**
|
|
141
|
+
* flair#1188 / flair#1213 — remove the internal embedding-engine fields (see
|
|
142
|
+
* `INTERNAL_MEMORY_FIELDS`) from a record before it is returned over the MCP
|
|
143
|
+
* surface. Returns a shallow copy WITHOUT those fields (never mutates the
|
|
144
|
+
* source record), and passes through anything that is not a plain record —
|
|
145
|
+
* null, primitives, arrays, and the `{ error, status }` shapes `unwrap`
|
|
146
|
+
* produces — untouched.
|
|
127
147
|
*
|
|
128
148
|
* `memory_search` already projects with an explicit select that omits
|
|
129
149
|
* `embedding` (resources/semantic-retrieval-core.ts's DEFAULT_SELECT), and
|
|
@@ -131,13 +151,21 @@ async function unwrap(value) {
|
|
|
131
151
|
* needed on the FULL-record read/write paths (memory_get, and the write
|
|
132
152
|
* responses that echo the stored row).
|
|
133
153
|
*/
|
|
134
|
-
function
|
|
154
|
+
function stripInternalFields(value) {
|
|
135
155
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
136
156
|
return value;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
157
|
+
let out = value;
|
|
158
|
+
let copied = false;
|
|
159
|
+
for (const field of INTERNAL_MEMORY_FIELDS) {
|
|
160
|
+
if (field in out) {
|
|
161
|
+
if (!copied) {
|
|
162
|
+
out = { ...out };
|
|
163
|
+
copied = true;
|
|
164
|
+
}
|
|
165
|
+
delete out[field];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
141
169
|
}
|
|
142
170
|
// ── Tool implementations (thin wrappers over existing handlers) ──────────────
|
|
143
171
|
//
|
|
@@ -217,7 +245,7 @@ async function memoryStore(agent, args) {
|
|
|
217
245
|
// flair#1188 — memory_store's response goes through the same buildWriteResponse
|
|
218
246
|
// echo as memory_update; strip the server-regenerated embedding so no write
|
|
219
247
|
// tool ever inlines the vector. No-op when the response carries none.
|
|
220
|
-
return
|
|
248
|
+
return stripInternalFields(await unwrap(await h.post(body)));
|
|
221
249
|
}
|
|
222
250
|
/**
|
|
223
251
|
* memory_update — id-targeted, dedup-BYPASSED overwrite/version path (memory-
|
|
@@ -248,8 +276,18 @@ async function memoryUpdate(agent, args) {
|
|
|
248
276
|
// `new Cls(undefined, ctx).get(id)` returned `undefined` for the caller's own
|
|
249
277
|
// record (getProperty on an unloaded instance), so memory_update 404'd
|
|
250
278
|
// ("memory not found") on the connector path before it ever reached a write.
|
|
251
|
-
|
|
252
|
-
|
|
279
|
+
//
|
|
280
|
+
// flair#1213 — the get MUST be `unwrap`ped (as memoryGet does), not consumed
|
|
281
|
+
// raw. Memory.get()'s makeByIdReadGate returns a NOT_FOUND() *Response* (404)
|
|
282
|
+
// for an absent or non-readable id — a TRUTHY object with no `id` — so the
|
|
283
|
+
// bare `if (!existing)` guard never fired: the code fell through and PUT a
|
|
284
|
+
// record whose id spread off the Response as `undefined`, throwing the
|
|
285
|
+
// MISDIRECTING "Invalid primary key of null" instead of a clean 404. This is
|
|
286
|
+
// the same by-id-read-on-the-connector-seam class as #1181, caught by the
|
|
287
|
+
// conformance error contract (Kern #5). Unwrap, then treat a 404/error/absent
|
|
288
|
+
// result as "not found".
|
|
289
|
+
const existing = await unwrap(await Cls.get(id, delegationContext(agent)));
|
|
290
|
+
if (!existing || existing.error != null || existing.status === 404) {
|
|
253
291
|
return { error: "memory not found", status: 404 };
|
|
254
292
|
}
|
|
255
293
|
if (preserveHistory) {
|
|
@@ -291,7 +329,7 @@ async function memoryUpdate(agent, args) {
|
|
|
291
329
|
// flair#1188 — the write response echoes the stored row (Memory.post
|
|
292
330
|
// regenerates the embedding server-side), so strip the vector before it
|
|
293
331
|
// returns over the MCP surface. No-op when the response carries none.
|
|
294
|
-
return
|
|
332
|
+
return stripInternalFields(await unwrap(await coll.post(record)));
|
|
295
333
|
}
|
|
296
334
|
const merged = { ...existing, content, updatedAt: new Date().toISOString() };
|
|
297
335
|
delete merged.embedding;
|
|
@@ -310,7 +348,7 @@ async function memoryUpdate(agent, args) {
|
|
|
310
348
|
// Memory.put()'s own ownership gate — no scope change, same as the read.
|
|
311
349
|
// flair#1188 — strip the embedding from the echoed write response (Memory.put
|
|
312
350
|
// regenerates the vector server-side); no-op when the response carries none.
|
|
313
|
-
return
|
|
351
|
+
return stripInternalFields(await unwrap(await Cls.put(merged, delegationContext(agent))));
|
|
314
352
|
}
|
|
315
353
|
async function memoryGet(agent, args) {
|
|
316
354
|
const Cls = await handler("Memory");
|
|
@@ -338,7 +376,7 @@ async function memoryGet(agent, args) {
|
|
|
338
376
|
// Strip it by default so a chat connector isn't flooded with thousands of
|
|
339
377
|
// useless tokens per record; return it only when the caller explicitly opts
|
|
340
378
|
// in via includeEmbedding.
|
|
341
|
-
return args?.includeEmbedding === true ? result :
|
|
379
|
+
return args?.includeEmbedding === true ? result : stripInternalFields(result);
|
|
342
380
|
}
|
|
343
381
|
async function memoryDelete(agent, args) {
|
|
344
382
|
const Cls = await handler("Memory");
|
|
@@ -380,6 +418,14 @@ async function bootstrap(agent, args) {
|
|
|
380
418
|
// ONLY when requested so a plain bootstrap delegates a byte-identical body.
|
|
381
419
|
if (args?.abstain === true)
|
|
382
420
|
body.abstain = true;
|
|
421
|
+
// flair#1199 — org-event knobs. `includeEventDetail` opts the verbose per-event
|
|
422
|
+
// `detail` JSON back in (default OFF: a connector reads lean events); `maxEvents`
|
|
423
|
+
// overrides the default cap. Both forwarded ONLY when set, so a plain bootstrap
|
|
424
|
+
// delegates a byte-identical body.
|
|
425
|
+
if (args?.includeEventDetail === true)
|
|
426
|
+
body.includeEventDetail = true;
|
|
427
|
+
if (args?.maxEvents !== undefined)
|
|
428
|
+
body.maxEvents = args.maxEvents;
|
|
383
429
|
// flair#831 — attach the running Flair version to the RESPONSE (not the
|
|
384
430
|
// delegated request body) so the calling agent learns the server version
|
|
385
431
|
// on its very first call.
|
|
@@ -497,6 +543,51 @@ async function recordUsage(agent, args) {
|
|
|
497
543
|
: typeof args?.memoryId === "string" ? [args.memoryId] : undefined;
|
|
498
544
|
return unwrap(await h.post({ memoryIds, attribution: args?.attribution }));
|
|
499
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* flair#1213 completeness gate — FAIL-CLOSED (the flair#953 lesson, Sherlock
|
|
548
|
+
* #3). Every tool shipped in `tools` must carry a `.contract`; a new /mcp tool
|
|
549
|
+
* without one fails the build.
|
|
550
|
+
*
|
|
551
|
+
* The fail-closed part is the point: if the registry cannot be enumerated — it
|
|
552
|
+
* is not a plain object (a broken import left it `undefined`), or it is empty —
|
|
553
|
+
* this returns `ok:false` with `examined:0`, NEVER a vacuous "0 tools examined,
|
|
554
|
+
* 0 missing, pass". A check that could not run must not render as passed. The
|
|
555
|
+
* conformance suite asserts both `ok` AND `examined > 0` so the vacuous path
|
|
556
|
+
* cannot masquerade as coverage; the fail-closed unit test exercises every
|
|
557
|
+
* branch.
|
|
558
|
+
*/
|
|
559
|
+
export function checkContractCompleteness(tools) {
|
|
560
|
+
if (!tools || typeof tools !== "object" || Array.isArray(tools)) {
|
|
561
|
+
return {
|
|
562
|
+
ok: false,
|
|
563
|
+
examined: 0,
|
|
564
|
+
missing: [],
|
|
565
|
+
reason: "TOOLS registry is not an enumerable object (unloadable import?) — refusing to pass vacuously",
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
const names = Object.keys(tools);
|
|
569
|
+
if (names.length === 0) {
|
|
570
|
+
return {
|
|
571
|
+
ok: false,
|
|
572
|
+
examined: 0,
|
|
573
|
+
missing: [],
|
|
574
|
+
reason: "TOOLS registry is empty — refusing to pass vacuously (a new tool must carry a conformance contract)",
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
const missing = names.filter((n) => {
|
|
578
|
+
const entry = tools[n];
|
|
579
|
+
return !entry || !entry.contract || typeof entry.contract !== "object";
|
|
580
|
+
});
|
|
581
|
+
return {
|
|
582
|
+
ok: missing.length === 0,
|
|
583
|
+
examined: names.length,
|
|
584
|
+
missing,
|
|
585
|
+
reason: missing.length
|
|
586
|
+
? `${missing.length} tool(s) shipped with no conformance contract: ${missing.join(", ")}. `
|
|
587
|
+
+ "Add a `contract` to each in resources/mcp-tools.ts (co-located with its def+impl)."
|
|
588
|
+
: undefined,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
500
591
|
/**
|
|
501
592
|
* Verb→tool-name overrides — the three naming quirks where the shipped tool
|
|
502
593
|
* name isn't record-types.ts's default `${toolPrefix}_${verb}` shape (see
|
|
@@ -547,6 +638,16 @@ export const TOOLS = {
|
|
|
547
638
|
},
|
|
548
639
|
},
|
|
549
640
|
impl: memorySearch,
|
|
641
|
+
contract: {
|
|
642
|
+
summary: "{ results: MemoryRecord[] } — semantic hits scoped to the caller's own + granted memories; each hit carries content, never the raw embedding.",
|
|
643
|
+
requiredFields: ["results"],
|
|
644
|
+
fieldTypes: { results: "array" },
|
|
645
|
+
invariants: {
|
|
646
|
+
selfDescribingEmpty: [{ path: "results", type: "array" }],
|
|
647
|
+
containerRules: [{ container: "results", requiredFields: ["id", "content"], forbiddenFields: INTERNAL_MEMORY_FIELDS }],
|
|
648
|
+
fullyResolved: true,
|
|
649
|
+
},
|
|
650
|
+
},
|
|
550
651
|
},
|
|
551
652
|
memory_store: {
|
|
552
653
|
def: {
|
|
@@ -574,6 +675,14 @@ export const TOOLS = {
|
|
|
574
675
|
},
|
|
575
676
|
},
|
|
576
677
|
impl: memoryStore,
|
|
678
|
+
contract: {
|
|
679
|
+
summary: "Write echo { id, written:true, deduplicated } — the new id + confirmation. No internal embedding fields; round-trips via memory_get.",
|
|
680
|
+
requiredFields: ["id", "written"],
|
|
681
|
+
fieldTypes: { id: "string", written: "boolean", deduplicated: "boolean" },
|
|
682
|
+
forbiddenFields: INTERNAL_MEMORY_FIELDS,
|
|
683
|
+
invariants: { fullyResolved: true },
|
|
684
|
+
errorShape: { trigger: "an unrecognized visibility value (e.g. \"prvate\")", fields: ["error", "status"], mustNotLeak: INTERNAL_MEMORY_FIELDS },
|
|
685
|
+
},
|
|
577
686
|
},
|
|
578
687
|
memory_update: {
|
|
579
688
|
def: {
|
|
@@ -592,6 +701,14 @@ export const TOOLS = {
|
|
|
592
701
|
},
|
|
593
702
|
},
|
|
594
703
|
impl: memoryUpdate,
|
|
704
|
+
contract: {
|
|
705
|
+
summary: "Write echo { id, written:true } for the in-place overwrite (or supersede). No internal embedding fields; the change round-trips via memory_get.",
|
|
706
|
+
requiredFields: ["id", "written"],
|
|
707
|
+
fieldTypes: { id: "string", written: "boolean" },
|
|
708
|
+
forbiddenFields: INTERNAL_MEMORY_FIELDS,
|
|
709
|
+
invariants: { fullyResolved: true },
|
|
710
|
+
errorShape: { trigger: "updating a non-existent id", fields: ["error", "status"] },
|
|
711
|
+
},
|
|
595
712
|
},
|
|
596
713
|
memory_get: {
|
|
597
714
|
def: {
|
|
@@ -609,6 +726,14 @@ export const TOOLS = {
|
|
|
609
726
|
},
|
|
610
727
|
},
|
|
611
728
|
impl: memoryGet,
|
|
729
|
+
contract: {
|
|
730
|
+
summary: "The full memory record { id, agentId, content, durability, createdAt, ... } for the caller's own id — embedding + embeddingModel stripped by default.",
|
|
731
|
+
requiredFields: ["id", "agentId", "content", "createdAt"],
|
|
732
|
+
fieldTypes: { id: "string", agentId: "string", content: "string" },
|
|
733
|
+
forbiddenFields: INTERNAL_MEMORY_FIELDS,
|
|
734
|
+
invariants: { fullyResolved: true },
|
|
735
|
+
errorShape: { trigger: "get a non-existent / unowned id (makeByIdReadGate 404)", fields: ["error", "status"] },
|
|
736
|
+
},
|
|
612
737
|
},
|
|
613
738
|
memory_delete: {
|
|
614
739
|
def: {
|
|
@@ -622,6 +747,11 @@ export const TOOLS = {
|
|
|
622
747
|
},
|
|
623
748
|
},
|
|
624
749
|
impl: memoryDelete,
|
|
750
|
+
contract: {
|
|
751
|
+
summary: "Deletes the caller's own memory (success echo is thin). The permanent-memory guard returns { error, status:403 } for a non-admin; the row round-trips as gone via memory_get.",
|
|
752
|
+
invariants: { fullyResolved: true },
|
|
753
|
+
errorShape: { trigger: "a non-admin deletes a permanent memory", fields: ["error", "status"] },
|
|
754
|
+
},
|
|
625
755
|
},
|
|
626
756
|
bootstrap: {
|
|
627
757
|
def: {
|
|
@@ -644,10 +774,70 @@ export const TOOLS = {
|
|
|
644
774
|
includeTrust: { type: "boolean", description: "Also return a `trust` array with a per-included-memory trust-evidence block (provenance, author, usage, freshness, supersession). Default false." },
|
|
645
775
|
abstain: { type: "boolean", description: "Opt into a task-relevance abstention verdict: also return an `abstention` object ({ abstained, bestScore, threshold }) reporting whether any memory covered `currentTask` above a global confidence threshold. Default false." },
|
|
646
776
|
includeContext: { type: "boolean", description: "Also return the prose `context` string — a human-readable mirror of the structured soul/memories/predicted/teammateFindings containers (which are the canonical payload). Default false here: the structured fields already carry everything, so shipping the prose too would double the payload." },
|
|
777
|
+
maxEvents: { type: "number", description: "Cap on how many recent org events to return (default 10). Events are counted against maxTokens like every other content section." },
|
|
778
|
+
includeEventDetail: { type: "boolean", description: "Also include each org event's verbose `detail` JSON (migration internals, etc.). Default false: bootstrap ships lean events (id/kind/summary/createdAt/targetIds/scope); `detail` mostly restates the summary and is pure bloat for a connector." },
|
|
647
779
|
},
|
|
648
780
|
},
|
|
649
781
|
},
|
|
650
782
|
impl: bootstrap,
|
|
783
|
+
contract: {
|
|
784
|
+
summary: "Session context: { agentId, soul, memories, predicted, teammateFindings, events, sections, tokenEstimate, memoriesIncluded, ..., context, flairVersion }. "
|
|
785
|
+
+ "Structured containers are canonical and always present; prose `context` is a pointer at the /mcp default (includeContext opt-in).",
|
|
786
|
+
requiredFields: [
|
|
787
|
+
"agentId", "soul", "memories", "predicted", "teammateFindings", "events",
|
|
788
|
+
"sections", "tokenEstimate", "maxTokens", "memoriesIncluded", "memoriesAvailable",
|
|
789
|
+
"memoriesTruncated", "teammateFindingsIncluded", "teammateFindingsTruncated",
|
|
790
|
+
"teammateFindingsMatched", "context", "flairVersion",
|
|
791
|
+
],
|
|
792
|
+
fieldTypes: {
|
|
793
|
+
agentId: "string", soul: "object", memories: "array", predicted: "array",
|
|
794
|
+
teammateFindings: "array", events: "array", sections: "object",
|
|
795
|
+
tokenEstimate: "number", maxTokens: "number", memoriesIncluded: "number",
|
|
796
|
+
memoriesAvailable: "number", memoriesTruncated: "number",
|
|
797
|
+
teammateFindingsIncluded: "number", teammateFindingsTruncated: "number",
|
|
798
|
+
teammateFindingsMatched: "number", context: "string", flairVersion: "string",
|
|
799
|
+
},
|
|
800
|
+
invariants: {
|
|
801
|
+
// count == delivered — the historical count/charge/deliver drift.
|
|
802
|
+
countEqualsDelivered: [
|
|
803
|
+
// memoriesIncluded spans BOTH own-memory containers (see the type doc).
|
|
804
|
+
{ count: "memoriesIncluded", containers: ["memories", "predicted"] }, // #1199
|
|
805
|
+
{ count: "teammateFindingsIncluded", containers: ["teammateFindings"] }, // #1199
|
|
806
|
+
{ count: "sections.events", containers: ["events"] }, // #1206
|
|
807
|
+
],
|
|
808
|
+
// present + typed even when empty — never a bare {} / missing key (#1182).
|
|
809
|
+
selfDescribingEmpty: [
|
|
810
|
+
{ path: "soul", type: "object" }, { path: "memories", type: "array" },
|
|
811
|
+
{ path: "predicted", type: "array" }, { path: "teammateFindings", type: "array" },
|
|
812
|
+
{ path: "events", type: "array" }, { path: "sections", type: "object" },
|
|
813
|
+
],
|
|
814
|
+
// #1200 — dedup by the SEMANTIC content key (excludes id/createdAt, which
|
|
815
|
+
// vary across physical duplicate rows). See ToolInvariants.dedupSignature.
|
|
816
|
+
dedupSignature: { container: "events", signatureFields: ["kind", "summary", "detail", "targetIds"] },
|
|
817
|
+
// #1199 — tokenEstimate via the wrapper's own estimator over the delivered
|
|
818
|
+
// payload (minus the two fields added after it was measured).
|
|
819
|
+
tokenEstimate: { field: "tokenEstimate", excludeKeys: ["tokenEstimate", "flairVersion"] },
|
|
820
|
+
// #1199 — the reported estimate must respect the requested budget: the
|
|
821
|
+
// events blowout (uncounted org events) drove maxTokens=4000 → 6286. The
|
|
822
|
+
// tolerance covers the fixed JSON scaffolding + the #1207 prose-vs-
|
|
823
|
+
// structured charge gap; uncounted content does not fit under it.
|
|
824
|
+
budgetCap: { estimate: "tokenEstimate", budget: "maxTokens", tolerance: 0.25 },
|
|
825
|
+
// #1207 — count arithmetic: included + truncated <= available, for own
|
|
826
|
+
// memories AND teammate findings (each a disjoint split of its pool).
|
|
827
|
+
countCoherence: [
|
|
828
|
+
{ included: "memoriesIncluded", truncated: "memoriesTruncated", available: "memoriesAvailable" },
|
|
829
|
+
{ included: "teammateFindingsIncluded", truncated: "teammateFindingsTruncated", available: "teammateFindingsMatched" },
|
|
830
|
+
],
|
|
831
|
+
// #1199 — prose is a pointer at the default, not a second copy.
|
|
832
|
+
proseContextIsPointerAtDefault: { field: "context" },
|
|
833
|
+
// shape of the structured containers a connector reads; #1188 leak bites on memories.
|
|
834
|
+
containerRules: [
|
|
835
|
+
{ container: "events", requiredFields: ["id", "kind", "summary", "createdAt"] },
|
|
836
|
+
{ container: "memories", requiredFields: ["id", "content"], forbiddenFields: INTERNAL_MEMORY_FIELDS },
|
|
837
|
+
],
|
|
838
|
+
fullyResolved: true, // #1182 — never a spread pending Promise collapsing to {flairVersion}.
|
|
839
|
+
},
|
|
840
|
+
},
|
|
651
841
|
},
|
|
652
842
|
soul_set: {
|
|
653
843
|
def: {
|
|
@@ -663,6 +853,10 @@ export const TOOLS = {
|
|
|
663
853
|
},
|
|
664
854
|
},
|
|
665
855
|
impl: soulSet,
|
|
856
|
+
contract: {
|
|
857
|
+
summary: "Writes a soul entry keyed `${agentId}:${key}`, attributed to the caller. Correctness is proven by the soul_get round-trip — this is the write flair#1181 broke on the connector path.",
|
|
858
|
+
invariants: { fullyResolved: true },
|
|
859
|
+
},
|
|
666
860
|
},
|
|
667
861
|
soul_get: {
|
|
668
862
|
def: {
|
|
@@ -676,6 +870,12 @@ export const TOOLS = {
|
|
|
676
870
|
},
|
|
677
871
|
},
|
|
678
872
|
impl: soulGet,
|
|
873
|
+
contract: {
|
|
874
|
+
summary: "The soul entry { id, agentId, key, value, createdAt } for the caller's own `${agentId}:${key}`.",
|
|
875
|
+
requiredFields: ["id", "agentId", "key", "value", "createdAt"],
|
|
876
|
+
fieldTypes: { id: "string", agentId: "string", key: "string", value: "string" },
|
|
877
|
+
invariants: { fullyResolved: true },
|
|
878
|
+
},
|
|
679
879
|
},
|
|
680
880
|
flair_workspace_set: {
|
|
681
881
|
def: {
|
|
@@ -695,6 +895,10 @@ export const TOOLS = {
|
|
|
695
895
|
},
|
|
696
896
|
},
|
|
697
897
|
impl: workspaceSet,
|
|
898
|
+
contract: {
|
|
899
|
+
summary: "Writes the caller's workspace state keyed `${agentId}:${ref}`, attributed to the caller (never the body). The echo is thin; persistence is verified in storage.",
|
|
900
|
+
invariants: { fullyResolved: true },
|
|
901
|
+
},
|
|
698
902
|
},
|
|
699
903
|
flair_orgevent: {
|
|
700
904
|
def: {
|
|
@@ -713,6 +917,10 @@ export const TOOLS = {
|
|
|
713
917
|
},
|
|
714
918
|
},
|
|
715
919
|
impl: orgEvent,
|
|
920
|
+
contract: {
|
|
921
|
+
summary: "Publishes an org event attributed to the caller (authorId from identity, never the body). The echo is thin; persistence is verified in storage.",
|
|
922
|
+
invariants: { fullyResolved: true },
|
|
923
|
+
},
|
|
716
924
|
},
|
|
717
925
|
attention: {
|
|
718
926
|
def: {
|
|
@@ -731,6 +939,15 @@ export const TOOLS = {
|
|
|
731
939
|
},
|
|
732
940
|
},
|
|
733
941
|
impl: attention,
|
|
942
|
+
contract: {
|
|
943
|
+
summary: "Grouped-by-source view { entity, windowDays, since, groups:{memory,relationship,workspaceState,presence,orgEvent}, counts } for entity E over N days.",
|
|
944
|
+
requiredFields: ["entity", "windowDays", "groups", "counts"],
|
|
945
|
+
fieldTypes: { entity: "string", windowDays: "number", groups: "object", counts: "object" },
|
|
946
|
+
invariants: {
|
|
947
|
+
selfDescribingEmpty: [{ path: "groups", type: "object" }, { path: "counts", type: "object" }],
|
|
948
|
+
fullyResolved: true,
|
|
949
|
+
},
|
|
950
|
+
},
|
|
734
951
|
},
|
|
735
952
|
record_usage: {
|
|
736
953
|
def: {
|
|
@@ -748,6 +965,12 @@ export const TOOLS = {
|
|
|
748
965
|
},
|
|
749
966
|
},
|
|
750
967
|
impl: recordUsage,
|
|
968
|
+
contract: {
|
|
969
|
+
summary: "Invariant acknowledgement { recorded:true } — byte-identical regardless of how many ids counted (no id enumeration, Sherlock).",
|
|
970
|
+
requiredFields: ["recorded"],
|
|
971
|
+
fieldTypes: { recorded: "boolean" },
|
|
972
|
+
invariants: { fullyResolved: true },
|
|
973
|
+
},
|
|
751
974
|
},
|
|
752
975
|
};
|
|
753
976
|
/** The tool definitions for a tools/list response (exactly the 12 curated tools). */
|