@tpsdev-ai/flair 0.44.8 → 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 +292 -28
- package/dist/doctor-client.js +55 -1
- package/dist/install/clients.js +50 -2
- package/dist/rem/runner.js +167 -23
- package/dist/resources/MemoryBootstrap.js +267 -65
- package/dist/resources/MemoryReflect.js +15 -13
- package/dist/resources/mcp-tools.js +243 -20
- 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/dist/resources/trust-block.js +16 -7
- package/docs/integrations.md +3 -0
- package/docs/rem.md +6 -0
- package/package.json +1 -1
- package/schemas/memory.graphql +9 -0
package/dist/rem/runner.js
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* 3. Maintenance — delegate to /MemoryMaintenance (same code path `flair rem light` uses).
|
|
8
8
|
* 4. Trust-tier filter on input memories — permanently deferred (see below).
|
|
9
9
|
* 5. Distillation — call /ReflectMemories with execute:true, persist staged
|
|
10
|
-
* candidate ids to the audit row.
|
|
10
|
+
* candidate ids to the audit row. TAG-AWARE (#1205b-1): enumerate the
|
|
11
|
+
* active adk:<app>:<user> tags for the agentId and distill ONCE PER TAG
|
|
12
|
+
* under scope:"tagged" (per-user isolation, no cross-user bleed); fall
|
|
13
|
+
* back to the agentId-only scope:"recent" distill when there are no adk:
|
|
14
|
+
* tags (ordinary single-tenant agents, unchanged).
|
|
11
15
|
* 6. Instance-wide dedup-cluster stat — call /MemoryDedupStats (flair-
|
|
12
16
|
* quality Slice 1c). NOT part of FLAIR-NIGHTLY-REM's original per-agent
|
|
13
17
|
* § 4 list — added because a near-duplicate cluster count is inherently
|
|
@@ -54,6 +58,34 @@ import { homedir } from "node:os";
|
|
|
54
58
|
import { createSnapshot } from "./snapshot.js";
|
|
55
59
|
export const REM_PAUSE_FLAG = resolve(homedir(), ".flair", "rem.paused");
|
|
56
60
|
export const REM_NIGHTLY_LOG = resolve(homedir(), ".flair", "logs", "rem-nightly.jsonl");
|
|
61
|
+
// ─── ADK per-tag distillation (#1205b-1) ─────────────────────────────────────
|
|
62
|
+
// adk-flair collapses (app_name, user_id) → ONE Flair agentId, distinguishing
|
|
63
|
+
// users ONLY by a per-user tag `adk:<app>:<user>` (see the adk-flair
|
|
64
|
+
// memory_service compound-tag). An agentId-wide distill (scope:"recent")
|
|
65
|
+
// therefore mixes every user's sessions into shared claims — the cross-user
|
|
66
|
+
// bleed #1205 fixes. The tag-aware cycle instead distills once per active
|
|
67
|
+
// adk: tag under scope:"tagged", so each user's claims come only from that
|
|
68
|
+
// user's sessions.
|
|
69
|
+
export const ADK_TAG_PREFIX = "adk:";
|
|
70
|
+
/**
|
|
71
|
+
* Recency window (ms) used to decide which adk: tags are ACTIVE — a tag is
|
|
72
|
+
* enumerated (and distilled) only if it has memory records created within this
|
|
73
|
+
* window. This is BOTH the bound that keeps enumeration off a full-table scan
|
|
74
|
+
* (the query filters on the indexed `createdAt`) AND the threshold-gate that
|
|
75
|
+
* skips idle users for free (Kern 1a). 48h (not 24h) so a single missed
|
|
76
|
+
* nightly cycle doesn't skip a user who was active only in the gap; distilling
|
|
77
|
+
* a tag pulls ALL its memories regardless, so a skipped cycle only delays, it
|
|
78
|
+
* never loses content.
|
|
79
|
+
*/
|
|
80
|
+
export const DEFAULT_DISTILL_LOOKBACK_MS = 48 * 3600_000;
|
|
81
|
+
/**
|
|
82
|
+
* Per-cycle ceiling on tag-driven /ReflectMemories calls (one LLM call each),
|
|
83
|
+
* so a burst of active ADK users can't starve the nightly window for other
|
|
84
|
+
* agents (Kern 1b). Sequential, not concurrent — matches the runner's existing
|
|
85
|
+
* single-threaded shape. If more tags are active than this, the overflow is
|
|
86
|
+
* recorded in `errors` and picked up on subsequent cycles (they stay active).
|
|
87
|
+
*/
|
|
88
|
+
export const DEFAULT_MAX_TAGS_PER_CYCLE = 200;
|
|
57
89
|
function readPauseSentinel(path) {
|
|
58
90
|
try {
|
|
59
91
|
const contents = readFileSync(path, "utf-8");
|
|
@@ -134,6 +166,59 @@ async function fetchPendingCandidateCount(api, agentId) {
|
|
|
134
166
|
return 0;
|
|
135
167
|
}
|
|
136
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Derive the DISTINCT active `adk:<app>:<user>` tags from an agent's memory
|
|
171
|
+
* set (#1205b-1). "Active" = the memory was created at/after `sinceDate`.
|
|
172
|
+
* Returns the distinct set, sorted for determinism.
|
|
173
|
+
*
|
|
174
|
+
* Operates over the memories the runner ALREADY fetched for the snapshot
|
|
175
|
+
* (step 2's `GET /Memory?agentId=<id>`), so enumeration adds NO extra query
|
|
176
|
+
* and NO additional table scan on top of what the cycle already does — a
|
|
177
|
+
* separate bounded distinct-tag DB query is NOT available here: the Memory
|
|
178
|
+
* resource exposes no REST `search_by_conditions` handler (that verb 405s —
|
|
179
|
+
* only MemoryCandidate overrides it), and Harper's ops-API `search_by_
|
|
180
|
+
* conditions` needs admin creds the agent-authed nightly runner doesn't carry.
|
|
181
|
+
* Reusing the already-loaded set is cheaper than either (no second round-trip)
|
|
182
|
+
* and sidesteps that seam entirely. See the module header + issue #1205.
|
|
183
|
+
*
|
|
184
|
+
* The recency cutoff is applied in-memory here as the threshold-gate (Kern 1a):
|
|
185
|
+
* a tag with no records at/after `sinceDate` is idle and is skipped for free.
|
|
186
|
+
* Distilling a tag later still pulls ALL its memories (scope:"tagged" ignores
|
|
187
|
+
* recency), so a skipped cycle only delays, never loses.
|
|
188
|
+
*
|
|
189
|
+
* OWNER-ONLY (Sherlock's user-enumeration-oracle flag): tags are collected
|
|
190
|
+
* ONLY from records whose `agentId` equals `agentId` (the runner's own id).
|
|
191
|
+
* This is load-bearing, NOT belt-and-suspenders: the snapshot fetch
|
|
192
|
+
* (`GET /Memory?agentId=<id>`) resolves through Memory's "open-within-org"
|
|
193
|
+
* read scope and returns org-wide rows — the `?agentId=` query param is NOT an
|
|
194
|
+
* owner filter (verified empirically against Harper, #1205b-1). Without this
|
|
195
|
+
* per-record agentId check the runner would enumerate (and try to distill)
|
|
196
|
+
* OTHER agents' adk tags — a cross-agent user-enumeration oracle. Filtering
|
|
197
|
+
* here confines enumeration to the runner's own users, and the reduction is
|
|
198
|
+
* pure in-process (no endpoint an attacker could point at another id).
|
|
199
|
+
*/
|
|
200
|
+
export function deriveActiveAdkTags(memories, sinceDate, agentId) {
|
|
201
|
+
const tags = new Set();
|
|
202
|
+
for (const m of memories) {
|
|
203
|
+
if (!m || typeof m !== "object")
|
|
204
|
+
continue;
|
|
205
|
+
// Owner scope: only this agent's own records (the fetch is org-wide).
|
|
206
|
+
if (m.agentId !== agentId)
|
|
207
|
+
continue;
|
|
208
|
+
const createdAt = m.createdAt;
|
|
209
|
+
// Recency / threshold gate: skip idle tags (no record since the cutoff).
|
|
210
|
+
if (!createdAt || new Date(createdAt) < sinceDate)
|
|
211
|
+
continue;
|
|
212
|
+
const mt = m.tags;
|
|
213
|
+
if (!Array.isArray(mt))
|
|
214
|
+
continue;
|
|
215
|
+
for (const t of mt) {
|
|
216
|
+
if (typeof t === "string" && t.startsWith(ADK_TAG_PREFIX))
|
|
217
|
+
tags.add(t);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return [...tags].sort();
|
|
221
|
+
}
|
|
137
222
|
/**
|
|
138
223
|
* Runs one nightly cycle for the given agent. See module header for steps.
|
|
139
224
|
* Pure orchestration; all I/O goes through injected dependencies.
|
|
@@ -167,10 +252,14 @@ export async function runNightlyCycle(opts) {
|
|
|
167
252
|
let memoryCount = 0;
|
|
168
253
|
let soulCount = 0;
|
|
169
254
|
let pendingCandidates = 0;
|
|
255
|
+
// #1205b-1: the memories fetched here (for the snapshot) are reused by the
|
|
256
|
+
// step-5 tag enumeration — no second fetch. Hoisted so step 5 can read them.
|
|
257
|
+
let fetchedMemories = [];
|
|
170
258
|
try {
|
|
171
259
|
// Fetch agent data
|
|
172
260
|
const memoriesRaw = await opts.apiCall("GET", `/Memory?agentId=${encodeURIComponent(opts.agentId)}`);
|
|
173
261
|
const memories = asArray(memoriesRaw);
|
|
262
|
+
fetchedMemories = memories;
|
|
174
263
|
memoryCount = memories.length;
|
|
175
264
|
const soulRaw = await opts.apiCall("GET", `/Soul?agentId=${encodeURIComponent(opts.agentId)}`);
|
|
176
265
|
const souls = asArray(soulRaw);
|
|
@@ -251,34 +340,89 @@ export async function runNightlyCycle(opts) {
|
|
|
251
340
|
// the same way dryRun skips the snapshot write.
|
|
252
341
|
// When the call IS attempted (success or failure), the audit row's `slice`
|
|
253
342
|
// flips to "2" — "2-maintenance" is reserved for the dry-run skip case.
|
|
343
|
+
//
|
|
344
|
+
// #1205b-1 — TAG-AWARE distillation. First enumerate the active
|
|
345
|
+
// adk:<app>:<user> tags for this agentId. If any exist, this is (or includes)
|
|
346
|
+
// an ADK agentId whose users share one agentId and are separated ONLY by
|
|
347
|
+
// tag: distill ONCE PER TAG under scope:"tagged" so each user's candidates
|
|
348
|
+
// are distilled from that user's sessions alone (no cross-user bleed). If
|
|
349
|
+
// NONE exist, this is an ordinary single-tenant agent — fall back to the
|
|
350
|
+
// unchanged agentId-only scope:"recent" distill so non-ADK agents behave
|
|
351
|
+
// exactly as before. The single-node-runs-the-timer property is unchanged:
|
|
352
|
+
// this all runs inside the one cycle on the one node.
|
|
254
353
|
let candidates;
|
|
354
|
+
const collectStagedIds = (obj) => asArray(obj.candidates)
|
|
355
|
+
.map((c) => (c && typeof c === "object" ? c.id : c))
|
|
356
|
+
.filter((id) => typeof id === "string");
|
|
255
357
|
if (!opts.dryRun) {
|
|
256
358
|
sliceLabel = "2";
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
359
|
+
const distillSince = opts.distillSince ?? new Date(startedMs - DEFAULT_DISTILL_LOOKBACK_MS);
|
|
360
|
+
const maxTags = opts.maxTagsPerCycle ?? DEFAULT_MAX_TAGS_PER_CYCLE;
|
|
361
|
+
// Derive active adk: tags from the memories already fetched in step 2 — no
|
|
362
|
+
// extra query/scan. See deriveActiveAdkTags for why a separate bounded DB
|
|
363
|
+
// query isn't available (Memory has no REST search handler; ops-API needs
|
|
364
|
+
// admin the runner lacks).
|
|
365
|
+
const activeAdkTags = deriveActiveAdkTags(fetchedMemories, distillSince, opts.agentId);
|
|
366
|
+
if (activeAdkTags.length > 0) {
|
|
367
|
+
// Per-tag distillation path (ADK). One scope:"tagged" call per active
|
|
368
|
+
// tag; aggregate every batch's staged ids into the single `candidates`
|
|
369
|
+
// list. A per-tag failure is recorded and does NOT abort the remaining
|
|
370
|
+
// tags (or the cycle) — the same non-fatal discipline the agentId-only
|
|
371
|
+
// path uses below.
|
|
372
|
+
const staged = [];
|
|
373
|
+
const tagsToRun = activeAdkTags.slice(0, maxTags);
|
|
374
|
+
if (activeAdkTags.length > maxTags) {
|
|
375
|
+
errors.push(`distillation: ${activeAdkTags.length} active adk tags exceed the per-cycle cap (${maxTags}); ${activeAdkTags.length - maxTags} deferred to a later cycle`);
|
|
268
376
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
377
|
+
for (const tag of tagsToRun) {
|
|
378
|
+
try {
|
|
379
|
+
const reflectRaw = await opts.apiCall("POST", "/ReflectMemories", {
|
|
380
|
+
agentId: opts.agentId,
|
|
381
|
+
execute: true,
|
|
382
|
+
scope: "tagged",
|
|
383
|
+
tag,
|
|
384
|
+
});
|
|
385
|
+
const obj = (reflectRaw && typeof reflectRaw === "object") ? reflectRaw : {};
|
|
386
|
+
if (obj.error) {
|
|
387
|
+
errors.push(`distillation[${tag}]: ${describeApiError(obj.error)}`);
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
staged.push(...collectStagedIds(obj));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
errors.push(`distillation[${tag}]: ${describeApiError(err?.message ?? err)}`);
|
|
395
|
+
}
|
|
274
396
|
}
|
|
397
|
+
// `candidates` is defined (even if empty) whenever distillation was
|
|
398
|
+
// ATTEMPTED this cycle — same contract as the agentId-only path.
|
|
399
|
+
candidates = staged;
|
|
275
400
|
}
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
401
|
+
else {
|
|
402
|
+
// AgentId-only path (non-ADK, unchanged pre-#1205b behavior).
|
|
403
|
+
try {
|
|
404
|
+
const reflectRaw = await opts.apiCall("POST", "/ReflectMemories", {
|
|
405
|
+
agentId: opts.agentId,
|
|
406
|
+
execute: true,
|
|
407
|
+
});
|
|
408
|
+
const obj = (reflectRaw && typeof reflectRaw === "object") ? reflectRaw : {};
|
|
409
|
+
if (obj.error) {
|
|
410
|
+
// Defensive: a 200 response shouldn't carry { error }, since
|
|
411
|
+
// MemoryReflect signals failure via HTTP status (503/502) — apiCall
|
|
412
|
+
// implementations throw for those. Handled the same way regardless.
|
|
413
|
+
errors.push(`distillation: ${describeApiError(obj.error)}`);
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
candidates = collectStagedIds(obj);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
// Distillation failure is recorded, not fatal — maintenance already
|
|
421
|
+
// succeeded and the cycle's guaranteed steps are done (spec § 3B item
|
|
422
|
+
// 3). Zero partial candidates is guaranteed server-side (all-or-
|
|
423
|
+
// nothing staging in /ReflectMemories).
|
|
424
|
+
errors.push(`distillation: ${describeApiError(err?.message ?? err)}`);
|
|
425
|
+
}
|
|
282
426
|
}
|
|
283
427
|
}
|
|
284
428
|
// Step 6 (flair-quality Slice 1c): instance-wide dedup-cluster stat.
|