@tpsdev-ai/flair 0.44.9 → 0.44.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +188 -2
- package/dist/rem/runner.js +167 -23
- package/dist/resources/MemoryBootstrap.js +197 -80
- package/dist/resources/MemoryReflect.js +15 -13
- 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/rem.md +6 -0
- package/package.json +1 -1
- package/schemas/memory.graphql +9 -0
|
@@ -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). */
|
|
@@ -23,6 +23,64 @@ export function isTeammate(record, callerId) {
|
|
|
23
23
|
return false;
|
|
24
24
|
return true;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Is `event` a zero-row, no-op auto-heal migration event (flair#1200)?
|
|
28
|
+
*
|
|
29
|
+
* The migration ledger (resources/migrations/ledger.ts) and the graph-heal
|
|
30
|
+
* observability path (resources/migrations/graph-heal.ts) both emit a
|
|
31
|
+
* `kind: "migration"` OrgEvent on EVERY boot — even when the migration did
|
|
32
|
+
* nothing. On a healthy store these are near-identical `verified` + `success`
|
|
33
|
+
* pairs, seconds apart, twice per version bump (per node): "migration graph-heal
|
|
34
|
+
* success (0 rows processed)" beside "HNSW graph-heal: recall verified healthy".
|
|
35
|
+
* They carry ZERO signal an agent could act on, yet each occupies one of the
|
|
36
|
+
* scarce (maxEvents-capped) bootstrap event slots AND is now token-charged
|
|
37
|
+
* (flair#1199) — so they crowd out events that matter. This suppresses them at
|
|
38
|
+
* RENDER (bootstrap's events section) only; the ledger still records every
|
|
39
|
+
* migration on the OrgEvent table (migration invariant IV is unchanged — this
|
|
40
|
+
* never touches the write path, only what bootstrap surfaces to a connector).
|
|
41
|
+
*
|
|
42
|
+
* A migration event is a suppressible no-op when its structured `detail`
|
|
43
|
+
* (a JSON string) reports:
|
|
44
|
+
* - `rowsProcessed === 0` AND a non-failure outcome (`success`, or a ledger
|
|
45
|
+
* shape with no explicit failure) — a migration that changed nothing; OR
|
|
46
|
+
* - `migrationId === "graph-heal"` with `verified === true` — the graph-heal
|
|
47
|
+
* verification half, which `run()` returns `processed: 0` for by construction
|
|
48
|
+
* (it carries no `rowsProcessed`, so the first rule can't catch it).
|
|
49
|
+
*
|
|
50
|
+
* A migration that PROCESSED rows, HALTED, FAILED, or reported an
|
|
51
|
+
* UNCONFIRMED graph-heal (`verified: false`) is actionable and is NOT
|
|
52
|
+
* suppressed. Pure + Harper-free so bootstrap-events.test.ts can drive it
|
|
53
|
+
* directly against the exact ledger/graph-heal detail shapes.
|
|
54
|
+
*/
|
|
55
|
+
export function isZeroRowNoOpEvent(event) {
|
|
56
|
+
if (!event || event.kind !== "migration")
|
|
57
|
+
return false;
|
|
58
|
+
let detail = event.detail;
|
|
59
|
+
if (typeof detail === "string") {
|
|
60
|
+
try {
|
|
61
|
+
detail = JSON.parse(detail);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false; // unparseable detail — don't guess, keep the event
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!detail || typeof detail !== "object")
|
|
68
|
+
return false;
|
|
69
|
+
// Ledger event: a migration that processed no rows AND did not fail/halt is a
|
|
70
|
+
// no-op. A failed/halted migration (even at 0 rows) is actionable — keep it.
|
|
71
|
+
// (Checked FIRST: the graph-heal ledger event carries migrationId "graph-heal"
|
|
72
|
+
// too but no `verified` field, so the graph-heal branch below must not swallow
|
|
73
|
+
// it before its rowsProcessed:0 is seen.)
|
|
74
|
+
if (detail.rowsProcessed === 0 && (detail.outcome === undefined || detail.outcome === "success")) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
// Graph-heal VERIFICATION event: inherently zero-row (run() → processed:0),
|
|
78
|
+
// carries no rowsProcessed. Suppress only the CONFIRMED-healthy ones; an
|
|
79
|
+
// unconfirmed heal (verified:false) is worth surfacing.
|
|
80
|
+
if (detail.migrationId === "graph-heal" && detail.verified === true)
|
|
81
|
+
return true;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
26
84
|
/**
|
|
27
85
|
* Format the "## Team" roster line for a list of teammate ids, or `null`
|
|
28
86
|
* when the roster is empty (caller should omit the section entirely).
|
|
@@ -287,3 +287,73 @@ export function dedupeCandidates(candidates, existingPendingClaims) {
|
|
|
287
287
|
const existingNormalized = new Set(existingPendingClaims.map(normalizeClaim));
|
|
288
288
|
return candidates.filter((c) => !existingNormalized.has(normalizeClaim(c.claim)));
|
|
289
289
|
}
|
|
290
|
+
// ─── Scope selection (the cross-user-bleed boundary — #1205b-1) ──────────────
|
|
291
|
+
//
|
|
292
|
+
// The per-user isolation that prevents cross-user bleed lives HERE, not in the
|
|
293
|
+
// LLM: /ReflectMemories only ever hands the model the memories this predicate
|
|
294
|
+
// admits, and generateCandidates() then enforces every candidate's
|
|
295
|
+
// sourceMemoryIds ⊆ the gathered set (parseAndValidateCandidates,
|
|
296
|
+
// "source_id_out_of_set"). So the gathered set is the *ceiling* on any
|
|
297
|
+
// candidate's sources — if this predicate admits only ONE adk:<app>:<user>
|
|
298
|
+
// tag's memories, a candidate physically cannot cite another user's memory.
|
|
299
|
+
//
|
|
300
|
+
// scope:"tagged" is the isolation mode the tag-aware nightly runner
|
|
301
|
+
// (src/rem/runner.ts) drives once per active adk:<app>:<user> tag. scope:
|
|
302
|
+
// "recent"/"all" are the pre-#1205b agentId-wide modes — correct for a
|
|
303
|
+
// single-tenant agent, but for an ADK agentId (which collapses every
|
|
304
|
+
// (app,user) into one agentId, distinguishing users only by tag) they gather
|
|
305
|
+
// EVERY user's memories together, which is exactly the bleed #1205 fixes.
|
|
306
|
+
//
|
|
307
|
+
// Extracted as a pure predicate so the isolation is unit-testable without
|
|
308
|
+
// Harper (the resource's gather loop streams from databases.flair.Memory).
|
|
309
|
+
// Archived/permanent filtering stays in the resource — those are eligibility
|
|
310
|
+
// rules, not scope selection.
|
|
311
|
+
export function memoryMatchesReflectScope(record, params) {
|
|
312
|
+
const { scope, tag, sinceDate } = params;
|
|
313
|
+
if (scope === "tagged") {
|
|
314
|
+
// No tag ⇒ admit nothing. A tagged reflection with no tag must gather an
|
|
315
|
+
// EMPTY set (fail-closed), never fall through to admitting everything —
|
|
316
|
+
// that would silently become an agentId-wide distill (cross-user bleed).
|
|
317
|
+
if (!tag)
|
|
318
|
+
return false;
|
|
319
|
+
return (record.tags ?? []).includes(tag);
|
|
320
|
+
}
|
|
321
|
+
if (scope === "recent") {
|
|
322
|
+
if (!record.createdAt)
|
|
323
|
+
return false;
|
|
324
|
+
return new Date(record.createdAt) >= sinceDate;
|
|
325
|
+
}
|
|
326
|
+
// scope === "all" (or any unknown scope) admits everything eligible.
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
// ─── Staged candidate row (stamps the authoritative scope tag — #1205b-1) ────
|
|
330
|
+
//
|
|
331
|
+
// Builds the MemoryCandidate row /ReflectMemories persists. The load-bearing
|
|
332
|
+
// addition over an inline object literal is `scopeTag`: when the distillation
|
|
333
|
+
// ran under scope:"tagged" with a known tag, that tag is AUTHORITATIVE context
|
|
334
|
+
// (the engine distilled exactly that one tag), so it is stamped onto the row.
|
|
335
|
+
// Downstream promotion (src/cli.ts derivePromotedTags' stamped-tag override)
|
|
336
|
+
// consumes this stamped tag directly instead of re-reading the source
|
|
337
|
+
// memories — which closes the #1205a seam: an ADK-sourced candidate whose
|
|
338
|
+
// sources are all later unreadable still carries its per-user scope tag and
|
|
339
|
+
// promotes correctly (never tagless into the shared agentId namespace).
|
|
340
|
+
//
|
|
341
|
+
// Non-tagged distillations (scope:"recent"/"all") leave scopeTag ABSENT
|
|
342
|
+
// (undefined) — the field is nullable/additive and promotion falls back to the
|
|
343
|
+
// source-re-read classification for those, unchanged.
|
|
344
|
+
export function buildStagedCandidateRow(params) {
|
|
345
|
+
const row = {
|
|
346
|
+
id: params.id,
|
|
347
|
+
agentId: params.agentId,
|
|
348
|
+
claim: params.claim,
|
|
349
|
+
sourceMemoryIds: params.sourceMemoryIds,
|
|
350
|
+
rationalePrompt: params.rationalePrompt,
|
|
351
|
+
generatedBy: params.generatedBy,
|
|
352
|
+
generatedAt: params.generatedAt,
|
|
353
|
+
status: "pending",
|
|
354
|
+
};
|
|
355
|
+
if (params.scope === "tagged" && typeof params.tag === "string" && params.tag.length > 0) {
|
|
356
|
+
row.scopeTag = params.tag;
|
|
357
|
+
}
|
|
358
|
+
return row;
|
|
359
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* token-estimate.ts — the ONE token estimator the bootstrap payload budget and
|
|
3
|
+
* `tokenEstimate` report are computed with.
|
|
4
|
+
*
|
|
5
|
+
* Extracted to a harper-free module (no `import ... from "harper"`) for two
|
|
6
|
+
* reasons:
|
|
7
|
+
*
|
|
8
|
+
* 1. Single source of truth. `MemoryBootstrap` computes both its content-
|
|
9
|
+
* selection budget and the reported `tokenEstimate` with THIS function, so
|
|
10
|
+
* there is exactly one definition of "how many tokens is this text".
|
|
11
|
+
* 2. The flair#1213 connector-conformance suite asserts the tokenEstimate
|
|
12
|
+
* invariant — `tokenEstimate === estimateTokens(JSON.stringify(deliveredPayload))`
|
|
13
|
+
* — with the SAME estimator, not a byte length or a different tokenizer
|
|
14
|
+
* (Kern #1 / Sherlock #2). Importing this module (which never pulls in
|
|
15
|
+
* Harper) lets a plain bun:test process reconstruct the estimate exactly,
|
|
16
|
+
* so the invariant catches the flair#1199 double-serialization class
|
|
17
|
+
* without being brittle to a future estimator change: change the formula
|
|
18
|
+
* here and both the report and the invariant move together.
|
|
19
|
+
*
|
|
20
|
+
* The estimate is deliberately coarse (~4 chars per token for English text). It
|
|
21
|
+
* is a budgeting/reporting heuristic, never a billing figure.
|
|
22
|
+
*/
|
|
23
|
+
export function estimateTokens(text) {
|
|
24
|
+
return Math.ceil(text.length / 4);
|
|
25
|
+
}
|
package/docs/rem.md
CHANGED
|
@@ -60,3 +60,9 @@ Snapshot locality follows from this: a nightly cycle's pre-run snapshot (`~/.fla
|
|
|
60
60
|
- **Nightly (`flair rem nightly enable` / `run-once`):** fully detached — the scheduler runs the full cycle (snapshot → maintenance → distillation), candidates land as pending rows, and an audit row lands in `~/.flair/logs/rem-nightly.jsonl`. The operator reviews in the morning via `flair rem candidates`.
|
|
61
61
|
|
|
62
62
|
Either path, the review loop is the same: `flair rem candidates` lists pending rows, `flair rem promote <id> --rationale "<why>"` / `flair rem reject <id> --reason "<why>"` decide them. Nothing self-promotes — see [`docs/notes/rem-ux.md`](notes/rem-ux.md) for why that gate is load-bearing and how the surface is expected to evolve.
|
|
63
|
+
|
|
64
|
+
### ADK agents — per-user (per-tag) distillation
|
|
65
|
+
|
|
66
|
+
adk-flair collapses every `(app, user)` into **one** Flair agentId, separating users only by a per-user tag `adk:<app>:<user>`. Distilling such an agentId with the default `scope:"recent"` would mix every user's sessions into shared claims — cross-user bleed. The nightly cycle therefore detects the agent's active `adk:<app>:<user>` tags (from the memories it already loads for the snapshot, with a recency cutoff that skips idle users and is scoped to the agent's own records) and runs distillation **once per tag** under `scope:"tagged"`, so each user's candidates come only from that user's own sessions. Agents with no `adk:` tags distill agentId-wide exactly as before.
|
|
67
|
+
|
|
68
|
+
A candidate distilled under a tag records that tag in its `scopeTag` field. `flair rem promote` reads `scopeTag` as the authoritative per-user lineage tag and propagates it onto the promoted memory — so the promoted claim stays in that user's retrieval scope even if the source episodes are later archived or deleted. The single-node timer rule above is unchanged; the per-tag loop runs inside the one cycle on the one node. The non-thinking-model requirement (above) still holds — the per-tag path calls the same `models.generate()` route.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.44.
|
|
3
|
+
"version": "0.44.10",
|
|
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",
|
package/schemas/memory.graphql
CHANGED
|
@@ -210,4 +210,13 @@ type MemoryCandidate @table(database: "flair") @export {
|
|
|
210
210
|
reviewRationale: String # required on promote/reject (no rubber-stamp)
|
|
211
211
|
decidedAt: String
|
|
212
212
|
supersedes: String @indexed # previous rejected candidate this replaces
|
|
213
|
+
scopeTag: String # #1205b-1: the authoritative scope:"tagged" tag this candidate was
|
|
214
|
+
# distilled under (e.g. adk:<app>:<user>). Stamped at distillation time by
|
|
215
|
+
# resources/MemoryReflect.ts when scope="tagged", so promotion can consume
|
|
216
|
+
# the per-user scope tag DIRECTLY (src/cli.ts derivePromotedTags' stamped-tag
|
|
217
|
+
# override) instead of re-reading source memories — closing the #1205a seam
|
|
218
|
+
# where an ADK-sourced candidate with all-unreadable sources yields no adk:
|
|
219
|
+
# evidence and would promote tagless (a cross-user leak). Nullable/additive —
|
|
220
|
+
# pre-#1205b candidates and non-tagged (scope:"recent"/"all") distillations
|
|
221
|
+
# read null, unchanged behavior (clean-upgrade-path gate).
|
|
213
222
|
}
|