@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
|
@@ -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/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,4 +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
|
+
|
|
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.
|
|
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",
|
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
|
}
|