@tpsdev-ai/flair 0.6.2 → 0.8.0
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/README.md +19 -6
- package/dist/bridges/builtins/index.js +28 -11
- package/dist/bridges/builtins/markdown.js +66 -0
- package/dist/bridges/runtime/context.js +2 -1
- package/dist/bridges/runtime/formats.js +97 -9
- package/dist/bridges/runtime/load-bridge.js +15 -5
- package/dist/cli.js +3113 -298
- package/dist/install/clients.js +225 -0
- package/dist/resources/Federation.js +87 -24
- package/dist/resources/Memory.js +17 -24
- package/dist/resources/MemoryBootstrap.js +11 -2
- package/dist/resources/auth-middleware.js +66 -11
- package/dist/resources/content-safety.js +21 -0
- package/dist/resources/federation-cleanup.js +161 -0
- package/dist/resources/federation-crypto.js +100 -1
- package/dist/resources/health.js +1 -1
- package/package.json +8 -6
- package/schemas/memory.graphql +28 -0
- package/ui/observation-center.html +356 -62
|
@@ -47,6 +47,27 @@ export function scanContent(text) {
|
|
|
47
47
|
}
|
|
48
48
|
return { safe: flags.length === 0, flags };
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Scan multiple string fields on a record, returning a merged SafetyResult.
|
|
52
|
+
* Non-string / empty fields are skipped. Flags are de-duplicated across fields.
|
|
53
|
+
*/
|
|
54
|
+
export function scanFields(record, fields) {
|
|
55
|
+
const flags = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
for (const field of fields) {
|
|
58
|
+
const value = record[field];
|
|
59
|
+
if (typeof value !== "string" || value.length === 0)
|
|
60
|
+
continue;
|
|
61
|
+
const result = scanContent(value);
|
|
62
|
+
for (const flag of result.flags) {
|
|
63
|
+
if (!seen.has(flag)) {
|
|
64
|
+
flags.push(flag);
|
|
65
|
+
seen.add(flag);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { safe: flags.length === 0, flags };
|
|
70
|
+
}
|
|
50
71
|
/**
|
|
51
72
|
* Check if strict mode is enabled (rejects flagged writes).
|
|
52
73
|
*/
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const CLEANUP_INTERVAL_MS = 300_000; // 5 minutes
|
|
2
|
+
let cleanupTimer = null;
|
|
3
|
+
/**
|
|
4
|
+
* Initialise the federation cleanup sweep.
|
|
5
|
+
*
|
|
6
|
+
* Only active on hub instances — reads the Instance table to determine
|
|
7
|
+
* role. On spokes this is a no-op.
|
|
8
|
+
*
|
|
9
|
+
* On hubs: ensures a 5-minute setInterval that sweeps PairingToken records
|
|
10
|
+
* for consumed or expired tokens, drops the corresponding bootstrap users,
|
|
11
|
+
* and performs housekeeping on expired/unconsumed token records.
|
|
12
|
+
*
|
|
13
|
+
* In test environments, callers pass mock serverOp/db via `opts` so this
|
|
14
|
+
* module never imports @harperfast/harper at the top level (which would
|
|
15
|
+
* crash when STORAGE_PATH isn't set).
|
|
16
|
+
*/
|
|
17
|
+
export async function initFederationCleanup(opts) {
|
|
18
|
+
const immediate = opts?.immediateTick ?? true;
|
|
19
|
+
// Resolve server / databases: use caller-supplied mocks when available,
|
|
20
|
+
// otherwise lazy-import @harperfast/harper at call time.
|
|
21
|
+
let svr;
|
|
22
|
+
let db;
|
|
23
|
+
if (opts?.serverOp && opts?.db) {
|
|
24
|
+
svr = opts.serverOp;
|
|
25
|
+
db = opts.db;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
try {
|
|
29
|
+
const harper = await import("@harperfast/harper");
|
|
30
|
+
svr = opts?.serverOp ?? harper.server.operation;
|
|
31
|
+
db = opts?.db ?? harper.databases;
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
console.error("[federation-cleanup] failed to load @harperfast/harper:", err?.message ?? err);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const role = opts?.instanceRole !== undefined
|
|
39
|
+
? opts.instanceRole
|
|
40
|
+
: await getInstanceRole(db);
|
|
41
|
+
if (role !== "hub") {
|
|
42
|
+
console.log("[federation-cleanup] not a hub instance — cleanup disabled");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
console.log("[federation-cleanup] starting cleanup sweep (5-min cadence)");
|
|
46
|
+
if (cleanupTimer)
|
|
47
|
+
clearInterval(cleanupTimer);
|
|
48
|
+
cleanupTimer = setInterval(() => {
|
|
49
|
+
runCleanupTick({ serverOp: svr, db }).catch((err) => {
|
|
50
|
+
console.error("[federation-cleanup] tick error:", err?.message ?? err);
|
|
51
|
+
});
|
|
52
|
+
}, CLEANUP_INTERVAL_MS);
|
|
53
|
+
if (immediate) {
|
|
54
|
+
// Run an immediate first tick after role detection
|
|
55
|
+
runCleanupTick({ serverOp: svr, db }).catch((err) => {
|
|
56
|
+
console.error("[federation-cleanup] initial tick error:", err?.message ?? err);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Look up the instance role from the Instance table.
|
|
62
|
+
* Returns null if the table doesn't exist or no record is present.
|
|
63
|
+
*/
|
|
64
|
+
async function getInstanceRole(db) {
|
|
65
|
+
try {
|
|
66
|
+
for await (const inst of db.flair.Instance.search()) {
|
|
67
|
+
return inst.role ?? null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* table may not exist yet */
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Core cleanup logic — exposed for unit testing.
|
|
77
|
+
*
|
|
78
|
+
* - Finds PairingToken records that are:
|
|
79
|
+
* - consumedBy is non-null (pair succeeded, bootstrap user no longer needed)
|
|
80
|
+
* - OR expiresAt < now (token expired without successful pair)
|
|
81
|
+
* - Drops the bootstrap user via the Harper ops API (idempotent: 404 is
|
|
82
|
+
* swallowed).
|
|
83
|
+
* - Deletes the PairingToken record if expired AND not consumed
|
|
84
|
+
* (housekeeping). Consumed records are kept for audit.
|
|
85
|
+
*
|
|
86
|
+
* Logging emits token-id prefix only (NEVER the full username, NEVER a
|
|
87
|
+
* password).
|
|
88
|
+
*/
|
|
89
|
+
export async function runCleanupTick(opts = {}) {
|
|
90
|
+
let svr;
|
|
91
|
+
let db;
|
|
92
|
+
if (opts.serverOp && opts.db) {
|
|
93
|
+
svr = opts.serverOp;
|
|
94
|
+
db = opts.db;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const harper = await import("@harperfast/harper");
|
|
98
|
+
svr = opts.serverOp ?? harper.server.operation;
|
|
99
|
+
db = opts.db ?? harper.databases;
|
|
100
|
+
}
|
|
101
|
+
const now = opts.now ?? new Date();
|
|
102
|
+
// ── Query candidates ──────────────────────────────────────────────────
|
|
103
|
+
const candidates = [];
|
|
104
|
+
try {
|
|
105
|
+
for await (const token of db.flair.PairingToken.search()) {
|
|
106
|
+
const consumed = !!token.consumedBy;
|
|
107
|
+
const expired = token.expiresAt && new Date(token.expiresAt) < now;
|
|
108
|
+
if (consumed || expired) {
|
|
109
|
+
candidates.push(token);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
console.error("[federation-cleanup] failed to query PairingToken records:", err?.message ?? err);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// ── Process each candidate ────────────────────────────────────────────
|
|
118
|
+
for (const token of candidates) {
|
|
119
|
+
const tokenId = token.id;
|
|
120
|
+
const consumed = !!token.consumedBy;
|
|
121
|
+
const expired = token.expiresAt && new Date(token.expiresAt) < now;
|
|
122
|
+
const bootstrapUsername = `pair-bootstrap-${tokenId.slice(0, 8)}`;
|
|
123
|
+
// Drop the bootstrap user
|
|
124
|
+
try {
|
|
125
|
+
await svr({ operation: "drop_user", username: bootstrapUsername }, { user: null }, false);
|
|
126
|
+
console.log("[federation-cleanup] dropped user", { tid: tokenId.slice(0, 8) });
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
const msg = err?.message ?? "";
|
|
130
|
+
const isNotFound = err?.statusCode === 404 ||
|
|
131
|
+
msg.toLowerCase().includes("not exist") ||
|
|
132
|
+
msg.toLowerCase().includes("not found");
|
|
133
|
+
if (isNotFound) {
|
|
134
|
+
// Idempotent — user already gone, no action needed
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
console.error("[federation-cleanup] drop_user error", { tid: tokenId.slice(0, 8), err: String(err?.message ?? err) });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// ── Housekeeping ────────────────────────────────────────────────────
|
|
141
|
+
if (expired && !consumed) {
|
|
142
|
+
// Delete the expired, unconsumed token record itself
|
|
143
|
+
try {
|
|
144
|
+
await svr({
|
|
145
|
+
operation: "delete",
|
|
146
|
+
database: "flair",
|
|
147
|
+
table: "PairingToken",
|
|
148
|
+
hash_value: tokenId,
|
|
149
|
+
}, { user: null }, false);
|
|
150
|
+
console.log("[federation-cleanup] deleted expired token", { tid: tokenId.slice(0, 8) });
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
console.error("[federation-cleanup] delete token error", { tid: tokenId.slice(0, 8), err: String(err?.message ?? err) });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Consumed tokens: keep record for audit trail
|
|
157
|
+
if (consumed) {
|
|
158
|
+
console.log("[federation-cleanup] keeping audit record", { tid: tokenId.slice(0, 8), consumedBy: token.consumedBy });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -22,10 +22,106 @@ function sortKeys(val) {
|
|
|
22
22
|
}
|
|
23
23
|
return sorted;
|
|
24
24
|
}
|
|
25
|
-
// ───
|
|
25
|
+
// ─── Nonce generation ───────────────────────────────────────────────────────
|
|
26
|
+
/**
|
|
27
|
+
* Generate a random nonce for anti-replay protection.
|
|
28
|
+
* 16 random bytes → base64url (22 chars, no padding). 128 bits of entropy
|
|
29
|
+
* is sufficient for collision-resistance over the signing window.
|
|
30
|
+
*/
|
|
31
|
+
export function generateNonce() {
|
|
32
|
+
return Buffer.from(nacl.randomBytes(16)).toString("base64url");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Default in-memory nonce store backed by a Map.
|
|
36
|
+
* Safe for module-level singleton use (e.g. federationNonceStore).
|
|
37
|
+
*/
|
|
38
|
+
export function createNonceStore() {
|
|
39
|
+
const store = new Map();
|
|
40
|
+
return {
|
|
41
|
+
has(key) { return store.has(key); },
|
|
42
|
+
set(key, value) { store.set(key, value); },
|
|
43
|
+
evict(olderThan) {
|
|
44
|
+
for (const [k, ts] of store.entries()) {
|
|
45
|
+
if (ts < olderThan)
|
|
46
|
+
store.delete(k);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Sign a request body with embedded timestamp and nonce for anti-replay.
|
|
53
|
+
*
|
|
54
|
+
* Adds `_ts` and `_nonce` fields to the body, then signs the canonical form
|
|
55
|
+
* (including those fields) using the existing `signBody`. Returns the body
|
|
56
|
+
* with `_ts`, `_nonce`, and `signature` fields set.
|
|
57
|
+
*
|
|
58
|
+
* The caller sends the returned body as the JSON payload. The receiver
|
|
59
|
+
* uses `verifyBodySignatureFresh` to validate it.
|
|
60
|
+
*/
|
|
61
|
+
export function signBodyFresh(body, secretKey, opts) {
|
|
62
|
+
const tsBody = {
|
|
63
|
+
...body,
|
|
64
|
+
_ts: opts?.ts ?? Date.now(),
|
|
65
|
+
_nonce: opts?.nonce ?? generateNonce(),
|
|
66
|
+
};
|
|
67
|
+
const sig = signBody(tsBody, secretKey);
|
|
68
|
+
return { ...tsBody, signature: sig };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Verify a signed request body with anti-replay protection.
|
|
72
|
+
*
|
|
73
|
+
* 1. Validates the Ed25519 signature over the canonical form (including
|
|
74
|
+
* `_ts`, `_nonce`, and all other fields EXCEPT `signature`).
|
|
75
|
+
* 2. Checks that the embedded `_ts` is within `opts.windowMs` of now.
|
|
76
|
+
* 3. Checks that the embedded `_nonce` has not been seen before (replay).
|
|
77
|
+
* 4. Records the nonce on success.
|
|
78
|
+
*
|
|
79
|
+
* Returns `{ ok: true }` on success, or `{ ok: false, reason: "..." }`.
|
|
80
|
+
*/
|
|
81
|
+
export function verifyBodySignatureFresh(body, publicKeyB64url, opts = {}) {
|
|
82
|
+
const windowMs = opts.windowMs ?? 30_000;
|
|
83
|
+
const nonceStore = opts.nonceStore;
|
|
84
|
+
const { signature, _ts, _nonce, ...rest } = body;
|
|
85
|
+
// ── Field presence ───────────────────────────────────────────────────
|
|
86
|
+
if (!signature)
|
|
87
|
+
return { ok: false, reason: "invalid_signature" };
|
|
88
|
+
if (_ts == null || !Number.isFinite(_ts))
|
|
89
|
+
return { ok: false, reason: "invalid_signature" };
|
|
90
|
+
if (!_nonce || typeof _nonce !== "string")
|
|
91
|
+
return { ok: false, reason: "invalid_signature" };
|
|
92
|
+
// ── Timestamp check ──────────────────────────────────────────────────
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
const delta = now - _ts;
|
|
95
|
+
if (delta > windowMs)
|
|
96
|
+
return { ok: false, reason: "stale" };
|
|
97
|
+
if (delta < -windowMs)
|
|
98
|
+
return { ok: false, reason: "future" };
|
|
99
|
+
// ── Nonce replay check ───────────────────────────────────────────────
|
|
100
|
+
if (nonceStore) {
|
|
101
|
+
// Evict entries older than 2x window — keeps the store bounded
|
|
102
|
+
nonceStore.evict(now - 2 * windowMs);
|
|
103
|
+
if (nonceStore.has(_nonce))
|
|
104
|
+
return { ok: false, reason: "replay" };
|
|
105
|
+
}
|
|
106
|
+
// ── Signature verification — canonical form includes _ts, _nonce ─────
|
|
107
|
+
const verificationBody = { _ts, _nonce, ...rest, signature };
|
|
108
|
+
if (!verifyBodySignature(verificationBody, publicKeyB64url)) {
|
|
109
|
+
return { ok: false, reason: "invalid_signature" };
|
|
110
|
+
}
|
|
111
|
+
// ── Record nonce ─────────────────────────────────────────────────────
|
|
112
|
+
if (nonceStore) {
|
|
113
|
+
nonceStore.set(_nonce, now);
|
|
114
|
+
}
|
|
115
|
+
return { ok: true };
|
|
116
|
+
}
|
|
117
|
+
// ─── Legacy signing (without anti-replay) ────────────────────────────────────
|
|
118
|
+
// Kept as implementation detail for signBodyFresh / verifyBodySignatureFresh.
|
|
119
|
+
// Callers should use the fresh variants for replay-safe federation operations.
|
|
26
120
|
/**
|
|
27
121
|
* Create a detached Ed25519 signature over the canonical form of a body.
|
|
28
122
|
* Returns base64url-encoded signature.
|
|
123
|
+
*
|
|
124
|
+
* NOTE: Prefer `signBodyFresh()` which includes anti-replay metadata (_ts, _nonce).
|
|
29
125
|
*/
|
|
30
126
|
export function signBody(body, secretKey) {
|
|
31
127
|
const message = new TextEncoder().encode(canonicalize(body));
|
|
@@ -35,6 +131,9 @@ export function signBody(body, secretKey) {
|
|
|
35
131
|
/**
|
|
36
132
|
* Verify a signature field on a request body.
|
|
37
133
|
* The canonical form is the body WITHOUT the `signature` field.
|
|
134
|
+
*
|
|
135
|
+
* NOTE: Prefer `verifyBodySignatureFresh()` which adds timestamp and nonce
|
|
136
|
+
* replay protection. This function performs ONLY signature validation.
|
|
38
137
|
*/
|
|
39
138
|
export function verifyBodySignature(body, publicKeyB64url) {
|
|
40
139
|
const { signature, ...rest } = body;
|
package/dist/resources/health.js
CHANGED
|
@@ -150,7 +150,7 @@ export class HealthDetail extends Resource {
|
|
|
150
150
|
const perAgentFull = Array.from(perAgentMap.values()).sort((a, b) => b.memoryCount - a.memoryCount);
|
|
151
151
|
const perAgent = isAdmin
|
|
152
152
|
? perAgentFull
|
|
153
|
-
: perAgentFull.filter((r) => r.id === callerAgent);
|
|
153
|
+
: perAgentFull.filter((r) => r.id === callerAgent || r.memoryCount > 0);
|
|
154
154
|
stats.agents = {
|
|
155
155
|
count: agents.length,
|
|
156
156
|
names: isAdmin ? agents.map((a) => a.id).filter(Boolean) : undefined,
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/tpsdev-ai/flair.git"
|
|
9
|
+
"url": "git+https://github.com/tpsdev-ai/flair.git"
|
|
10
10
|
},
|
|
11
11
|
"homepage": "https://github.com/tpsdev-ai/flair",
|
|
12
12
|
"bugs": {
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"prepublishOnly": "npm run build && npm run build:cli",
|
|
45
45
|
"test": "bun test",
|
|
46
46
|
"test:e2e": "playwright test",
|
|
47
|
-
"release": "./scripts/release.sh"
|
|
47
|
+
"release": "./scripts/release.sh",
|
|
48
|
+
"postinstall": "node -e \"try{const{chmodSync,statSync}=require('fs');const p='dist/cli.js';if(statSync(p).isFile()){chmodSync(p,0o755);console.error('@tpsdev-ai/flair: chmod +x ' + p + ' OK')}}catch(e){if(e.code!=='ENOENT')console.error('postinstall warn:',e.message)}\""
|
|
48
49
|
},
|
|
49
50
|
"publishConfig": {
|
|
50
51
|
"access": "public"
|
|
@@ -53,17 +54,19 @@
|
|
|
53
54
|
"node": ">=22"
|
|
54
55
|
},
|
|
55
56
|
"dependencies": {
|
|
56
|
-
"@harperfast/harper": "5.0.
|
|
57
|
+
"@harperfast/harper": "5.0.9",
|
|
57
58
|
"@types/js-yaml": "^4.0.9",
|
|
58
59
|
"commander": "14.0.3",
|
|
59
60
|
"harper-fabric-embeddings": "0.2.3",
|
|
60
61
|
"jose": "^6.2.2",
|
|
61
62
|
"js-yaml": "^4.1.1",
|
|
63
|
+
"tar": "^7.5.13",
|
|
62
64
|
"tweetnacl": "1.0.3"
|
|
63
65
|
},
|
|
64
66
|
"devDependencies": {
|
|
65
67
|
"@playwright/test": "^1.59.1",
|
|
66
68
|
"@types/node": "24.11.0",
|
|
69
|
+
"@types/tar": "^7.0.87",
|
|
67
70
|
"bun-types": "1.3.11",
|
|
68
71
|
"typescript": "5.9.3"
|
|
69
72
|
},
|
|
@@ -71,7 +74,6 @@
|
|
|
71
74
|
"harper-fabric-embeddings"
|
|
72
75
|
],
|
|
73
76
|
"workspaces": [
|
|
74
|
-
"packages/*"
|
|
75
|
-
"plugins/*"
|
|
77
|
+
"packages/*"
|
|
76
78
|
]
|
|
77
79
|
}
|
package/schemas/memory.graphql
CHANGED
|
@@ -26,6 +26,8 @@ type Memory @table(database: "flair") {
|
|
|
26
26
|
lastReflected: String # ISO timestamp of last reflection pass
|
|
27
27
|
supersedes: String @indexed # ID of memory this one replaces (version chain)
|
|
28
28
|
subject: String @indexed # entity this memory is about (person, service, project)
|
|
29
|
+
summary: String # agent-set multi-sentence dense compression (optional; ops-wkoh)
|
|
30
|
+
# 3-tier compression chain: subject (one-line) → summary (paragraph) → content (full)
|
|
29
31
|
validFrom: String @indexed # ISO timestamp — when this fact became true
|
|
30
32
|
validTo: String @indexed # ISO timestamp — when this fact stopped being true (null = still valid)
|
|
31
33
|
_safetyFlags: [String] # content safety flags from scanContent()
|
|
@@ -68,3 +70,29 @@ type MemoryGrant @table(database: "flair") @export {
|
|
|
68
70
|
filter: String
|
|
69
71
|
createdAt: String
|
|
70
72
|
}
|
|
73
|
+
|
|
74
|
+
# MemoryCandidate — staged distillations from the FLAIR-NIGHTLY-REM cycle.
|
|
75
|
+
# Per specs/FLAIR-NIGHTLY-REM.md § 7. Slice 1 of ops-2qq adds the schema +
|
|
76
|
+
# `flair rem candidates` listing command. Future slices wire the nightly
|
|
77
|
+
# cycle that populates this table and the promote/reject endpoints.
|
|
78
|
+
#
|
|
79
|
+
# Inviolable contract: candidates are NEVER auto-promoted. Promotion is a
|
|
80
|
+
# separate, deliberate action (`flair rem promote <id> --rationale "<why>"`)
|
|
81
|
+
# that requires both --rationale and --to (soul|memory). Rejected candidates
|
|
82
|
+
# retain their decision history so recurring false-positives become visible
|
|
83
|
+
# via the `supersedes` chain.
|
|
84
|
+
type MemoryCandidate @table(database: "flair") {
|
|
85
|
+
id: ID @primaryKey
|
|
86
|
+
agentId: String! @indexed
|
|
87
|
+
claim: String! # distilled lesson text
|
|
88
|
+
sourceMemoryIds: [String] # episodic memories feeding the distillation
|
|
89
|
+
rationalePrompt: String # the prompt given to the distillation LLM
|
|
90
|
+
generatedBy: String # model identifier (e.g. "claude-opus-4-7")
|
|
91
|
+
generatedAt: String! @indexed
|
|
92
|
+
status: String! @indexed # pending | promoted | rejected
|
|
93
|
+
target: String # soul | memory (set on promote)
|
|
94
|
+
reviewerId: String # who decided (agentId or human admin)
|
|
95
|
+
reviewRationale: String # required on promote/reject (no rubber-stamp)
|
|
96
|
+
decidedAt: String
|
|
97
|
+
supersedes: String @indexed # previous rejected candidate this replaces
|
|
98
|
+
}
|