@tpsdev-ai/flair 0.51.1 → 0.52.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 +10 -5
- package/dist/build-info.json +3 -3
- package/dist/cli.js +575 -547
- package/dist/doctor-client.js +35 -0
- package/dist/hook-install.js +74 -0
- package/dist/install/global-bin-path.js +14 -0
- package/dist/lib/auth-resolve.js +15 -0
- package/dist/lib/doctor-run.js +28 -15
- package/dist/lib/upgrade-exec-path.js +257 -0
- package/dist/lib/upgrade-plain-tree.js +558 -0
- package/dist/rem/promote-policy.js +204 -0
- package/dist/rem/restore.js +55 -15
- package/dist/rem/runner.js +203 -20
- package/dist/resources/AdminMemory.js +2 -1
- package/dist/resources/AgentSeed.js +26 -10
- package/dist/resources/Asset.js +203 -0
- package/dist/resources/AutoPromoteCandidates.js +2 -4
- package/dist/resources/Credential.js +14 -0
- package/dist/resources/Federation.js +80 -0
- package/dist/resources/Integration.js +12 -0
- package/dist/resources/Memory.js +158 -60
- package/dist/resources/MemoryBootstrap.js +63 -20
- package/dist/resources/MemoryCandidate.js +12 -0
- package/dist/resources/MemoryConsolidate.js +2 -1
- package/dist/resources/MemoryDedupStats.js +17 -2
- package/dist/resources/MemoryFeed.js +30 -0
- package/dist/resources/MemoryGrant.js +14 -0
- package/dist/resources/MemoryReflect.js +75 -17
- package/dist/resources/Message.js +190 -0
- package/dist/resources/OrgEvent.js +12 -0
- package/dist/resources/PromoteMemoryCandidate.js +76 -0
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +12 -0
- package/dist/resources/SemanticSearch.js +45 -13
- package/dist/resources/Soul.js +54 -18
- package/dist/resources/WorkspaceState.js +12 -0
- package/dist/resources/auth-middleware.js +17 -44
- package/dist/resources/authority-field-guard.js +37 -0
- package/dist/resources/bm25-index-service.js +1 -1
- package/dist/resources/bm25-index.js +50 -11
- package/dist/resources/embedding-space-guard.js +238 -0
- package/dist/resources/embeddings-provider.js +32 -5
- package/dist/resources/federation-classify.js +23 -1
- package/dist/resources/health.js +11 -2
- package/dist/resources/hit-tracking.js +244 -0
- package/dist/resources/mcp-tools.js +272 -7
- package/dist/resources/memory-reflect-lib.js +111 -0
- package/dist/resources/migrations/embedding-stamp.js +22 -4
- package/dist/resources/owner-field-guard.js +62 -0
- package/dist/resources/promotion-stamp.js +29 -0
- package/dist/resources/record-owner-guard.js +71 -5
- package/dist/resources/record-types.js +30 -7
- package/dist/resources/relay-lib.js +205 -0
- package/dist/resources/relay-ops.js +294 -0
- package/dist/resources/skill-write.js +120 -0
- package/dist/resources/soul-adk-guard.js +68 -0
- package/dist/resources/soul-write-policy.js +63 -0
- package/dist/resources/table-helpers.js +2 -0
- package/dist/resources/usage-recording.js +3 -3
- package/dist/src/rem/promote-policy.js +204 -0
- package/docs/api-reference.md +374 -0
- package/docs/auth.md +52 -0
- package/docs/federation.md +4 -0
- package/docs/integrations.md +6 -6
- package/docs/mcp-clients.md +16 -1
- package/docs/releasing.md +11 -8
- package/docs/rem.md +20 -2
- package/docs/upgrade.md +47 -2
- package/package.json +13 -8
- package/schemas/memory.graphql +51 -2
- package/schemas/message.graphql +74 -0
package/dist/rem/runner.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* REM nightly runner — orchestrates the cycle.
|
|
3
3
|
*
|
|
4
4
|
* Per FLAIR-NIGHTLY-REM § 4, in order:
|
|
5
|
-
* 1. Pre-flight: check pause sentinel / FLAIR_REM_PAUSE env. Exit clean if
|
|
5
|
+
* 1. Pre-flight: check pause sentinel / FLAIR_REM_PAUSE env. Exit clean if
|
|
6
|
+
* paused. Then GET /Health within 2s (#1515); refuse to start if it
|
|
7
|
+
* cannot be served.
|
|
6
8
|
* 2. Snapshot agent state (memory + soul) to ~/.flair/snapshots/<agent>/.
|
|
7
9
|
* 3. Maintenance — delegate to /MemoryMaintenance (same code path `flair rem light` uses).
|
|
8
10
|
* 4. Trust-tier filter on input memories — permanently deferred (see below).
|
|
@@ -55,6 +57,7 @@
|
|
|
55
57
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
56
58
|
import { dirname, resolve } from "node:path";
|
|
57
59
|
import { homedir } from "node:os";
|
|
60
|
+
import { setImmediate as yieldToEventLoop } from "node:timers/promises";
|
|
58
61
|
import { createSnapshot } from "./snapshot.js";
|
|
59
62
|
export const REM_PAUSE_FLAG = resolve(homedir(), ".flair", "rem.paused");
|
|
60
63
|
export const REM_NIGHTLY_LOG = resolve(homedir(), ".flair", "logs", "rem-nightly.jsonl");
|
|
@@ -134,6 +137,67 @@ export const DEFAULT_MAX_TAGS_PER_CYCLE = 200;
|
|
|
134
137
|
* resources/ — see src/cli.ts) and kept in sync by value.
|
|
135
138
|
*/
|
|
136
139
|
export const DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE = 200;
|
|
140
|
+
/**
|
|
141
|
+
* Per-run distillation gather cap (#1515). Tens, not thousands — a 3k
|
|
142
|
+
* backlog drains across nights. Mirror of resources/memory-reflect-lib.ts
|
|
143
|
+
* (src/ cannot import resources/).
|
|
144
|
+
*/
|
|
145
|
+
export const DEFAULT_MAX_MEMORIES_PER_RUN = 50;
|
|
146
|
+
/** Hard ceiling on FLAIR_REM_MAX_MEMORIES. Mirror of the resource-side cap. */
|
|
147
|
+
export const ABSOLUTE_MAX_MEMORIES_PER_RUN = 200;
|
|
148
|
+
/** Refuse to start if GET /Health cannot be served within this budget. */
|
|
149
|
+
export const DEFAULT_HEALTH_PREFLIGHT_MS = 2000;
|
|
150
|
+
/**
|
|
151
|
+
* Resolve the per-run gather cap: explicit override > FLAIR_REM_MAX_MEMORIES
|
|
152
|
+
* > DEFAULT_MAX_MEMORIES_PER_RUN, clamped to ABSOLUTE_MAX_MEMORIES_PER_RUN.
|
|
153
|
+
*/
|
|
154
|
+
export function resolveMaxMemoriesPerRun(override, env = process.env) {
|
|
155
|
+
let resolved = DEFAULT_MAX_MEMORIES_PER_RUN;
|
|
156
|
+
if (typeof override === "number" && Number.isFinite(override) && override > 0) {
|
|
157
|
+
resolved = Math.floor(override);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const fromEnv = Number(env.FLAIR_REM_MAX_MEMORIES);
|
|
161
|
+
if (Number.isFinite(fromEnv) && fromEnv > 0)
|
|
162
|
+
resolved = Math.floor(fromEnv);
|
|
163
|
+
}
|
|
164
|
+
return Math.min(resolved, ABSOLUTE_MAX_MEMORIES_PER_RUN);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Self-check that /Health can be served before snapshot/distillation.
|
|
168
|
+
* Injected `healthProbe` is preferred (CLI uses fetch + AbortSignal). When
|
|
169
|
+
* omitted, races `GET /Health` through apiCall against the same budget.
|
|
170
|
+
*/
|
|
171
|
+
export async function runHealthPreflight(apiCall, opts = {}) {
|
|
172
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_HEALTH_PREFLIGHT_MS;
|
|
173
|
+
const started = Date.now();
|
|
174
|
+
if (opts.probe)
|
|
175
|
+
return opts.probe(timeoutMs);
|
|
176
|
+
let timer;
|
|
177
|
+
try {
|
|
178
|
+
await Promise.race([
|
|
179
|
+
apiCall("GET", "/Health").then((body) => {
|
|
180
|
+
if (body && typeof body === "object" && body.ok === false) {
|
|
181
|
+
throw new Error("GET /Health returned {ok:false}");
|
|
182
|
+
}
|
|
183
|
+
}),
|
|
184
|
+
new Promise((_, reject) => {
|
|
185
|
+
timer = setTimeout(() => reject(new Error(`GET /Health did not respond within ${timeoutMs}ms`)), timeoutMs);
|
|
186
|
+
}),
|
|
187
|
+
]);
|
|
188
|
+
return { ok: true, elapsedMs: Date.now() - started };
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
return { ok: false, elapsedMs: Date.now() - started, error: err?.message ?? String(err) };
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
if (timer)
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export function formatHealthRefuseMessage(result, timeoutMs = DEFAULT_HEALTH_PREFLIGHT_MS) {
|
|
199
|
+
return `GET /Health could not be served within ${timeoutMs}ms (${result.error ?? "unknown error"}). Refusing to start REM so this run cannot take the instance down. Restore /Health, or \`flair rem pause\` to keep the scheduler from retrying.`;
|
|
200
|
+
}
|
|
137
201
|
function readPauseSentinel(path) {
|
|
138
202
|
try {
|
|
139
203
|
const contents = readFileSync(path, "utf-8");
|
|
@@ -190,24 +254,54 @@ function describeApiError(err) {
|
|
|
190
254
|
}
|
|
191
255
|
return message;
|
|
192
256
|
}
|
|
257
|
+
/** True when /ReflectMemories (or the pause check) refused because the operator aborted. */
|
|
258
|
+
export function isRemAbortedFailure(err) {
|
|
259
|
+
const text = describeApiError(err);
|
|
260
|
+
return text.includes("rem_aborted") || text.includes("REM distillation aborted");
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* True when this agent owns any `adk:<app>:<user>` tag, regardless of
|
|
264
|
+
* recency. Continuity session tags are excluded (they are not per-user).
|
|
265
|
+
* Used so an idle ADK agent (no tag activity inside the 48h window) is
|
|
266
|
+
* never treated as non-ADK and distilled with scope:"all" (#1515 / #1205b).
|
|
267
|
+
*/
|
|
268
|
+
export function hasAdkUserTags(memories, agentId) {
|
|
269
|
+
for (const m of memories) {
|
|
270
|
+
if (!m || typeof m !== "object")
|
|
271
|
+
continue;
|
|
272
|
+
if (m.agentId !== agentId)
|
|
273
|
+
continue;
|
|
274
|
+
const mt = m.tags;
|
|
275
|
+
if (!Array.isArray(mt))
|
|
276
|
+
continue;
|
|
277
|
+
for (const t of mt) {
|
|
278
|
+
if (typeof t !== "string")
|
|
279
|
+
continue;
|
|
280
|
+
if (t.startsWith(CONTINUITY_TAG_PREFIX))
|
|
281
|
+
continue;
|
|
282
|
+
if (t.startsWith(ADK_TAG_PREFIX))
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
193
288
|
/**
|
|
194
289
|
* Counts pending memory candidates for the agent.
|
|
195
290
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
291
|
+
* Uses the ops-API `search_by_conditions` convention (admin-authed) via the
|
|
292
|
+
* injected `opsSearch` helper — the same shape `flair rem candidates` uses.
|
|
293
|
+
* Returns 0 when no `opsSearch` is available (the agent-authed runner carries
|
|
294
|
+
* no admin creds) or on any error — the runner should not fail the cycle just
|
|
295
|
+
* because the candidate count couldn't be sampled.
|
|
199
296
|
*/
|
|
200
|
-
async function fetchPendingCandidateCount(
|
|
297
|
+
async function fetchPendingCandidateCount(opsSearch, agentId) {
|
|
298
|
+
if (!opsSearch)
|
|
299
|
+
return 0;
|
|
201
300
|
try {
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
{ search_attribute: "status", search_type: "equals", search_value: "pending" },
|
|
207
|
-
],
|
|
208
|
-
get_attributes: ["id"],
|
|
209
|
-
});
|
|
210
|
-
const rows = asArray(result);
|
|
301
|
+
const rows = await opsSearch("MemoryCandidate", [
|
|
302
|
+
{ search_attribute: "agentId", search_type: "equals", search_value: agentId },
|
|
303
|
+
{ search_attribute: "status", search_type: "equals", search_value: "pending" },
|
|
304
|
+
], ["id"]);
|
|
211
305
|
return rows.length;
|
|
212
306
|
}
|
|
213
307
|
catch {
|
|
@@ -362,6 +456,21 @@ export async function runNightlyCycle(opts) {
|
|
|
362
456
|
return { status: "paused", logRow: row };
|
|
363
457
|
}
|
|
364
458
|
const errors = [];
|
|
459
|
+
const healthTimeoutMs = opts.healthTimeoutMs ?? DEFAULT_HEALTH_PREFLIGHT_MS;
|
|
460
|
+
const health = await runHealthPreflight(opts.apiCall, {
|
|
461
|
+
probe: opts.healthProbe,
|
|
462
|
+
timeoutMs: healthTimeoutMs,
|
|
463
|
+
});
|
|
464
|
+
if (!health.ok) {
|
|
465
|
+
const row = {
|
|
466
|
+
...baseRow,
|
|
467
|
+
status: "refused",
|
|
468
|
+
durationMs: Date.now() - startedMs,
|
|
469
|
+
errors: [formatHealthRefuseMessage(health, healthTimeoutMs)],
|
|
470
|
+
};
|
|
471
|
+
appendLogRow(logPath, row);
|
|
472
|
+
return { status: "refused", logRow: row };
|
|
473
|
+
}
|
|
365
474
|
// Step 2: snapshot
|
|
366
475
|
let snapshotPath;
|
|
367
476
|
let memoryCount = 0;
|
|
@@ -382,7 +491,7 @@ export async function runNightlyCycle(opts) {
|
|
|
382
491
|
// For the snapshot's soul.json: keep the full multi-row shape if there
|
|
383
492
|
// are multiple souls (different keys), or unwrap a single-row response.
|
|
384
493
|
const soulForSnapshot = souls.length === 1 ? souls[0] : souls.length > 1 ? souls : null;
|
|
385
|
-
pendingCandidates = await fetchPendingCandidateCount(opts.
|
|
494
|
+
pendingCandidates = await fetchPendingCandidateCount(opts.opsSearch, opts.agentId);
|
|
386
495
|
if (!opts.dryRun) {
|
|
387
496
|
const created = await createSnapshot({
|
|
388
497
|
agentId: opts.agentId,
|
|
@@ -472,18 +581,34 @@ export async function runNightlyCycle(opts) {
|
|
|
472
581
|
let autoPromoted;
|
|
473
582
|
// flair#1257 slice 3: settled continuity sessions distilled this cycle.
|
|
474
583
|
let continuitySessions;
|
|
584
|
+
// #1515: aggregate gather stats from every /ReflectMemories call this cycle.
|
|
585
|
+
let distill;
|
|
586
|
+
let distillAborted = false;
|
|
475
587
|
const collectStagedIds = (obj) => asArray(obj.candidates)
|
|
476
588
|
.map((c) => (c && typeof c === "object" ? c.id : c))
|
|
477
589
|
.filter((id) => typeof id === "string");
|
|
590
|
+
const noteGather = (obj, maxMemories) => {
|
|
591
|
+
const gathered = typeof obj.gathered === "number" ? obj.gathered : 0;
|
|
592
|
+
const unreflected = typeof obj.unreflected === "number" ? obj.unreflected : 0;
|
|
593
|
+
if (!distill) {
|
|
594
|
+
distill = { gathered, unreflected, maxMemories };
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
distill.gathered += gathered;
|
|
598
|
+
distill.unreflected += unreflected;
|
|
599
|
+
};
|
|
600
|
+
const cycleIsAborted = () => process.env.FLAIR_REM_PAUSE === "1" || (existsSync(pauseFlagPath) && readPauseSentinel(pauseFlagPath));
|
|
478
601
|
if (!opts.dryRun) {
|
|
479
602
|
sliceLabel = "2";
|
|
480
603
|
const distillSince = opts.distillSince ?? new Date(startedMs - DEFAULT_DISTILL_LOOKBACK_MS);
|
|
481
604
|
const maxTags = opts.maxTagsPerCycle ?? DEFAULT_MAX_TAGS_PER_CYCLE;
|
|
605
|
+
const maxMemories = resolveMaxMemoriesPerRun(opts.maxMemoriesPerRun);
|
|
482
606
|
// Derive active adk: tags from the memories already fetched in step 2 — no
|
|
483
607
|
// extra query/scan. See deriveActiveAdkTags for why a separate bounded DB
|
|
484
608
|
// query isn't available (Memory has no REST search handler; ops-API needs
|
|
485
609
|
// admin the runner lacks).
|
|
486
610
|
const activeAdkTags = deriveActiveAdkTags(fetchedMemories, distillSince, opts.agentId);
|
|
611
|
+
const adkShaped = hasAdkUserTags(fetchedMemories, opts.agentId);
|
|
487
612
|
if (activeAdkTags.length > 0) {
|
|
488
613
|
// Per-tag distillation path (ADK). One scope:"tagged" call per active
|
|
489
614
|
// tag; aggregate every batch's staged ids into the single `candidates`
|
|
@@ -496,35 +621,62 @@ export async function runNightlyCycle(opts) {
|
|
|
496
621
|
errors.push(`distillation: ${activeAdkTags.length} active adk tags exceed the per-cycle cap (${maxTags}); ${activeAdkTags.length - maxTags} deferred to a later cycle`);
|
|
497
622
|
}
|
|
498
623
|
for (const tag of tagsToRun) {
|
|
624
|
+
if (cycleIsAborted()) {
|
|
625
|
+
distillAborted = true;
|
|
626
|
+
errors.push("distillation: aborted by operator (flair rem pause or FLAIR_REM_PAUSE=1)");
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
await yieldToEventLoop();
|
|
499
630
|
try {
|
|
500
631
|
const reflectRaw = await opts.apiCall("POST", "/ReflectMemories", {
|
|
501
632
|
agentId: opts.agentId,
|
|
502
633
|
execute: true,
|
|
503
634
|
scope: "tagged",
|
|
504
635
|
tag,
|
|
636
|
+
maxMemories,
|
|
505
637
|
});
|
|
506
638
|
const obj = (reflectRaw && typeof reflectRaw === "object") ? reflectRaw : {};
|
|
507
639
|
if (obj.error) {
|
|
508
640
|
errors.push(`distillation[${tag}]: ${describeApiError(obj.error)}`);
|
|
641
|
+
if (isRemAbortedFailure(obj.error)) {
|
|
642
|
+
distillAborted = true;
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
509
645
|
}
|
|
510
646
|
else {
|
|
511
647
|
staged.push(...collectStagedIds(obj));
|
|
648
|
+
noteGather(obj, maxMemories);
|
|
512
649
|
}
|
|
513
650
|
}
|
|
514
651
|
catch (err) {
|
|
515
652
|
errors.push(`distillation[${tag}]: ${describeApiError(err?.message ?? err)}`);
|
|
653
|
+
if (isRemAbortedFailure(err?.message ?? err)) {
|
|
654
|
+
distillAborted = true;
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
516
657
|
}
|
|
517
658
|
}
|
|
518
659
|
// `candidates` is defined (even if empty) whenever distillation was
|
|
519
660
|
// ATTEMPTED this cycle — same contract as the agentId-only path.
|
|
520
661
|
candidates = staged;
|
|
521
662
|
}
|
|
663
|
+
else if (adkShaped) {
|
|
664
|
+
// ADK agentId whose users are all idle this window. Do NOT fall through
|
|
665
|
+
// to scope:"all" — that mixes every user's backlog (#1205b bleed).
|
|
666
|
+
// Active tags will be re-selected next cycle when they have recent rows.
|
|
667
|
+
errors.push("distillation: ADK agent has no active adk tags this cycle; skipped agentId-wide distill to avoid cross-user bleed");
|
|
668
|
+
}
|
|
522
669
|
else {
|
|
523
|
-
// AgentId-only path (non-ADK
|
|
670
|
+
// AgentId-only path (non-ADK). scope:"all" + oldest-unreflected cap so
|
|
671
|
+
// a multi-thousand backlog drains across nights (#1515) instead of
|
|
672
|
+
// only the default 24h recent window.
|
|
524
673
|
try {
|
|
674
|
+
await yieldToEventLoop();
|
|
525
675
|
const reflectRaw = await opts.apiCall("POST", "/ReflectMemories", {
|
|
526
676
|
agentId: opts.agentId,
|
|
527
677
|
execute: true,
|
|
678
|
+
scope: "all",
|
|
679
|
+
maxMemories,
|
|
528
680
|
});
|
|
529
681
|
const obj = (reflectRaw && typeof reflectRaw === "object") ? reflectRaw : {};
|
|
530
682
|
if (obj.error) {
|
|
@@ -532,17 +684,23 @@ export async function runNightlyCycle(opts) {
|
|
|
532
684
|
// MemoryReflect signals failure via HTTP status (503/502) — apiCall
|
|
533
685
|
// implementations throw for those. Handled the same way regardless.
|
|
534
686
|
errors.push(`distillation: ${describeApiError(obj.error)}`);
|
|
687
|
+
if (isRemAbortedFailure(obj.error))
|
|
688
|
+
distillAborted = true;
|
|
535
689
|
}
|
|
536
690
|
else {
|
|
537
691
|
candidates = collectStagedIds(obj);
|
|
692
|
+
noteGather(obj, maxMemories);
|
|
538
693
|
}
|
|
539
694
|
}
|
|
540
695
|
catch (err) {
|
|
541
696
|
// Distillation failure is recorded, not fatal — maintenance already
|
|
542
697
|
// succeeded and the cycle's guaranteed steps are done (spec § 3B item
|
|
543
698
|
// 3). Zero partial candidates is guaranteed server-side (all-or-
|
|
544
|
-
// nothing staging in /ReflectMemories).
|
|
699
|
+
// nothing staging in /ReflectMemories). rem_aborted IS fatal to the
|
|
700
|
+
// rest of this cycle (no auto-promote / dedup).
|
|
545
701
|
errors.push(`distillation: ${describeApiError(err?.message ?? err)}`);
|
|
702
|
+
if (isRemAbortedFailure(err?.message ?? err))
|
|
703
|
+
distillAborted = true;
|
|
546
704
|
}
|
|
547
705
|
}
|
|
548
706
|
// ── Step 5a (flair#1257 slice 3): continuity-session distillation ─────────
|
|
@@ -570,12 +728,18 @@ export async function runNightlyCycle(opts) {
|
|
|
570
728
|
if (settledContinuityTags.length > continuityToRun.length) {
|
|
571
729
|
errors.push(`distillation: ${settledContinuityTags.length - continuityToRun.length} settled continuity session(s) deferred by the per-cycle tag cap (${maxTags}); re-selected next cycle while un-expired`);
|
|
572
730
|
}
|
|
573
|
-
if (continuityToRun.length > 0) {
|
|
731
|
+
if (!distillAborted && continuityToRun.length > 0) {
|
|
574
732
|
// `candidates` is defined whenever distillation was ATTEMPTED (same
|
|
575
733
|
// contract as both paths above).
|
|
576
734
|
candidates = candidates ?? [];
|
|
577
735
|
let distilled = 0;
|
|
578
736
|
for (const tag of continuityToRun) {
|
|
737
|
+
if (distillAborted || cycleIsAborted()) {
|
|
738
|
+
distillAborted = true;
|
|
739
|
+
errors.push("distillation: aborted by operator (flair rem pause or FLAIR_REM_PAUSE=1)");
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
await yieldToEventLoop();
|
|
579
743
|
try {
|
|
580
744
|
const reflectRaw = await opts.apiCall("POST", "/ReflectMemories", {
|
|
581
745
|
agentId: opts.agentId,
|
|
@@ -583,18 +747,28 @@ export async function runNightlyCycle(opts) {
|
|
|
583
747
|
scope: "tagged",
|
|
584
748
|
tag,
|
|
585
749
|
focus: "continuity",
|
|
750
|
+
maxMemories,
|
|
586
751
|
});
|
|
587
752
|
const obj = (reflectRaw && typeof reflectRaw === "object") ? reflectRaw : {};
|
|
588
753
|
if (obj.error) {
|
|
589
754
|
errors.push(`distillation[${tag}]: ${describeApiError(obj.error)}`);
|
|
755
|
+
if (isRemAbortedFailure(obj.error)) {
|
|
756
|
+
distillAborted = true;
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
590
759
|
}
|
|
591
760
|
else {
|
|
592
761
|
candidates.push(...collectStagedIds(obj));
|
|
762
|
+
noteGather(obj, maxMemories);
|
|
593
763
|
distilled++;
|
|
594
764
|
}
|
|
595
765
|
}
|
|
596
766
|
catch (err) {
|
|
597
767
|
errors.push(`distillation[${tag}]: ${describeApiError(err?.message ?? err)}`);
|
|
768
|
+
if (isRemAbortedFailure(err?.message ?? err)) {
|
|
769
|
+
distillAborted = true;
|
|
770
|
+
break;
|
|
771
|
+
}
|
|
598
772
|
}
|
|
599
773
|
}
|
|
600
774
|
continuitySessions = distilled;
|
|
@@ -613,7 +787,7 @@ export async function runNightlyCycle(opts) {
|
|
|
613
787
|
// distillation: a failure is recorded and the candidates stay pending
|
|
614
788
|
// (re-swept next cycle, or promotable by the human `rem promote` path).
|
|
615
789
|
// Bounded by the per-cycle cap.
|
|
616
|
-
if (activeAdkTags.length > 0 || continuityToRun.length > 0) {
|
|
790
|
+
if (!distillAborted && (activeAdkTags.length > 0 || continuityToRun.length > 0)) {
|
|
617
791
|
try {
|
|
618
792
|
const apRaw = await opts.apiCall("POST", "/AutoPromoteCandidates", {
|
|
619
793
|
agentId: opts.agentId,
|
|
@@ -651,7 +825,7 @@ export async function runNightlyCycle(opts) {
|
|
|
651
825
|
// recomputation is idempotent (same inputs → same aggregate, just wasted
|
|
652
826
|
// work), not incorrect.
|
|
653
827
|
let dedup;
|
|
654
|
-
if (!opts.dryRun) {
|
|
828
|
+
if (!opts.dryRun && !distillAborted) {
|
|
655
829
|
try {
|
|
656
830
|
const dedupRaw = await opts.apiCall("POST", "/MemoryDedupStats", {});
|
|
657
831
|
const obj = (dedupRaw && typeof dedupRaw === "object") ? dedupRaw : {};
|
|
@@ -678,6 +852,14 @@ export async function runNightlyCycle(opts) {
|
|
|
678
852
|
}
|
|
679
853
|
}
|
|
680
854
|
// Step 7: log
|
|
855
|
+
if (distillAborted) {
|
|
856
|
+
distill = {
|
|
857
|
+
gathered: distill?.gathered ?? 0,
|
|
858
|
+
unreflected: distill?.unreflected ?? 0,
|
|
859
|
+
maxMemories: distill?.maxMemories ?? resolveMaxMemoriesPerRun(opts.maxMemoriesPerRun),
|
|
860
|
+
aborted: true,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
681
863
|
const row = {
|
|
682
864
|
...baseRow,
|
|
683
865
|
slice: sliceLabel,
|
|
@@ -692,6 +874,7 @@ export async function runNightlyCycle(opts) {
|
|
|
692
874
|
candidates,
|
|
693
875
|
autoPromoted,
|
|
694
876
|
continuitySessions,
|
|
877
|
+
distill,
|
|
695
878
|
dedup,
|
|
696
879
|
durationMs: Date.now() - startedMs,
|
|
697
880
|
errors,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Resource, databases } from "harper";
|
|
2
2
|
import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
3
3
|
import { allowAdmin } from "./agent-auth.js";
|
|
4
|
+
import { applyHitStats } from "./hit-tracking.js";
|
|
4
5
|
/**
|
|
5
6
|
* GET /AdminMemory browse + search memories (list view)
|
|
6
7
|
* GET /AdminMemory?id=<id> per-memory detail view with full provenance pane
|
|
@@ -146,7 +147,7 @@ export class AdminMemory extends Resource {
|
|
|
146
147
|
const memDb = databases.flair.Memory;
|
|
147
148
|
let memory = null;
|
|
148
149
|
try {
|
|
149
|
-
memory = await memDb.get(id);
|
|
150
|
+
memory = await applyHitStats(await memDb.get(id), this.getContext?.());
|
|
150
151
|
}
|
|
151
152
|
catch { /* table missing or other error */ }
|
|
152
153
|
if (!memory) {
|
|
@@ -14,12 +14,16 @@
|
|
|
14
14
|
* Response:
|
|
15
15
|
* { agent, soulEntries, memories }
|
|
16
16
|
*
|
|
17
|
-
* Auth:
|
|
17
|
+
* Auth: operator (verified Harper administrator Basic) or deliberate
|
|
18
|
+
* `internalContext()`. Admin-agent Ed25519 keys are refused — role is not
|
|
19
|
+
* source. Intended: provisioning a principal and its Soul is a trust-root act.
|
|
18
20
|
*/
|
|
19
21
|
import { Resource, databases } from "harper";
|
|
20
|
-
import {
|
|
22
|
+
import { allowAdmin, invalidateAdminCache } from "./agent-auth.js";
|
|
23
|
+
import { authorizeSoulWrite, refuseSoulWriteContent, soulProvenance } from "./soul-write-policy.js";
|
|
21
24
|
import { reconcileAdminFields } from "./agent-admin.js";
|
|
22
25
|
import { noteMemoryUpsert } from "./bm25-index-service.js";
|
|
26
|
+
import { rejectSkillWritePath } from "./skill-write.js";
|
|
23
27
|
const DEFAULT_SOUL_KEYS = (agentId, displayName, role, now) => ({
|
|
24
28
|
name: displayName,
|
|
25
29
|
role,
|
|
@@ -47,11 +51,9 @@ export class AgentSeed extends Resource {
|
|
|
47
51
|
// undefined and this belt-and-suspenders check fail-closed every request,
|
|
48
52
|
// even from a real admin already verified by allowCreate()).
|
|
49
53
|
const ctx = this.getContext?.();
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return new Response(JSON.stringify({ error: "forbidden: admin only" }), { status: 403 });
|
|
54
|
-
}
|
|
54
|
+
const { auth, source, denied } = await authorizeSoulWrite(ctx);
|
|
55
|
+
if (denied)
|
|
56
|
+
return denied;
|
|
55
57
|
const { agentId, displayName, role = "agent", soulTemplate, starterMemories } = data || {};
|
|
56
58
|
if (!agentId)
|
|
57
59
|
return new Response(JSON.stringify({ error: "agentId required" }), { status: 400 });
|
|
@@ -60,6 +62,14 @@ export class AgentSeed extends Resource {
|
|
|
60
62
|
}
|
|
61
63
|
const now = new Date().toISOString();
|
|
62
64
|
const name = displayName || agentId;
|
|
65
|
+
// Validate the entire caller-controlled template before creating any rows.
|
|
66
|
+
const defaults = DEFAULT_SOUL_KEYS(agentId, name, role, now);
|
|
67
|
+
const merged = { ...defaults, ...(soulTemplate || {}) };
|
|
68
|
+
for (const value of Object.values(merged)) {
|
|
69
|
+
const refusal = await refuseSoulWriteContent({ agentId, value: String(value) });
|
|
70
|
+
if (refusal)
|
|
71
|
+
return refusal;
|
|
72
|
+
}
|
|
63
73
|
// ── Agent record ──────────────────────────────────────────────────────────
|
|
64
74
|
const existingAgent = await databases.flair.Agent.get(agentId).catch(() => null);
|
|
65
75
|
let agent = existingAgent;
|
|
@@ -75,8 +85,6 @@ export class AgentSeed extends Resource {
|
|
|
75
85
|
invalidateAdminCache();
|
|
76
86
|
}
|
|
77
87
|
// ── Soul entries ──────────────────────────────────────────────────────────
|
|
78
|
-
const defaults = DEFAULT_SOUL_KEYS(agentId, name, role, now);
|
|
79
|
-
const merged = { ...defaults, ...(soulTemplate || {}) };
|
|
80
88
|
const soulEntries = [];
|
|
81
89
|
for (const [key, value] of Object.entries(merged)) {
|
|
82
90
|
const id = `${agentId}:${key}`;
|
|
@@ -85,7 +93,7 @@ export class AgentSeed extends Resource {
|
|
|
85
93
|
soulEntries.push(existing); // skip — don't overwrite existing soul entries
|
|
86
94
|
continue;
|
|
87
95
|
}
|
|
88
|
-
const entry = { id, agentId, key, value: String(value), durability: "permanent", createdAt: now, updatedAt: now };
|
|
96
|
+
const entry = { id, agentId, key, value: String(value), provenance: soulProvenance(auth, source, now), durability: "permanent", createdAt: now, updatedAt: now };
|
|
89
97
|
await databases.flair.Soul.put(entry);
|
|
90
98
|
soulEntries.push(entry);
|
|
91
99
|
}
|
|
@@ -112,6 +120,14 @@ export class AgentSeed extends Resource {
|
|
|
112
120
|
else {
|
|
113
121
|
for (let i = 0; i < memDefs.length; i++) {
|
|
114
122
|
const def = memDefs[i];
|
|
123
|
+
// ── flair#1542: reject skill-tagged starter memories ──
|
|
124
|
+
// This admin-only seed writes via the RAW table object, bypassing
|
|
125
|
+
// Memory.post()/put()'s SkillScan gate + forced durability. A
|
|
126
|
+
// skill-tagged starter memory would land unscanned — reject it (skills
|
|
127
|
+
// are written via skill_store, not seeded as onboarding memories).
|
|
128
|
+
const skillDenial = rejectSkillWritePath(def);
|
|
129
|
+
if (skillDenial)
|
|
130
|
+
return skillDenial;
|
|
115
131
|
const id = `seed-${agentId}-${i}-${Date.now()}`;
|
|
116
132
|
const record = {
|
|
117
133
|
id,
|