@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.
@@ -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
- try {
258
- const reflectRaw = await opts.apiCall("POST", "/ReflectMemories", {
259
- agentId: opts.agentId,
260
- execute: true,
261
- });
262
- const obj = (reflectRaw && typeof reflectRaw === "object") ? reflectRaw : {};
263
- if (obj.error) {
264
- // Defensive: a 200 response shouldn't carry { error }, since
265
- // MemoryReflect signals failure via HTTP status (503/502) apiCall
266
- // implementations throw for those. Handled the same way regardless.
267
- errors.push(`distillation: ${describeApiError(obj.error)}`);
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
- else {
270
- const staged = asArray(obj.candidates);
271
- candidates = staged
272
- .map((c) => (c && typeof c === "object" ? c.id : c))
273
- .filter((id) => typeof id === "string");
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
- catch (err) {
277
- // Distillation failure is recorded, not fatal — maintenance already
278
- // succeeded and the cycle's guaranteed steps are done (spec § 3B item
279
- // 3). Zero partial candidates is guaranteed server-side (all-or-
280
- // nothing staging in /ReflectMemories).
281
- errors.push(`distillation: ${describeApiError(err?.message ?? err)}`);
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.