@tpsdev-ai/flair 0.51.2 → 0.53.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.
Files changed (74) hide show
  1. package/README.md +10 -5
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1037 -566
  4. package/dist/doctor-client.js +35 -0
  5. package/dist/hook-install.js +74 -0
  6. package/dist/install/global-bin-path.js +14 -0
  7. package/dist/lib/auth-resolve.js +15 -0
  8. package/dist/lib/doctor-run.js +28 -15
  9. package/dist/lib/launchd-repair.js +198 -0
  10. package/dist/lib/stabilize-mqtt-network.js +123 -0
  11. package/dist/lib/upgrade-exec-path.js +257 -0
  12. package/dist/lib/upgrade-plain-tree.js +558 -0
  13. package/dist/rem/promote-policy.js +204 -0
  14. package/dist/rem/restore.js +55 -15
  15. package/dist/rem/runner.js +203 -20
  16. package/dist/resources/AdminMemory.js +2 -1
  17. package/dist/resources/AgentSeed.js +26 -10
  18. package/dist/resources/Asset.js +203 -0
  19. package/dist/resources/AutoPromoteCandidates.js +2 -4
  20. package/dist/resources/Credential.js +14 -0
  21. package/dist/resources/Federation.js +80 -0
  22. package/dist/resources/Integration.js +12 -0
  23. package/dist/resources/Memory.js +158 -60
  24. package/dist/resources/MemoryBootstrap.js +63 -20
  25. package/dist/resources/MemoryCandidate.js +12 -0
  26. package/dist/resources/MemoryConsolidate.js +2 -1
  27. package/dist/resources/MemoryDedupStats.js +17 -2
  28. package/dist/resources/MemoryFeed.js +30 -0
  29. package/dist/resources/MemoryGrant.js +14 -0
  30. package/dist/resources/MemoryReflect.js +75 -17
  31. package/dist/resources/Message.js +190 -0
  32. package/dist/resources/OrgEvent.js +12 -0
  33. package/dist/resources/PromoteMemoryCandidate.js +76 -0
  34. package/dist/resources/RecordUsage.js +1 -1
  35. package/dist/resources/Relationship.js +12 -0
  36. package/dist/resources/SemanticSearch.js +45 -13
  37. package/dist/resources/Soul.js +54 -18
  38. package/dist/resources/WorkspaceState.js +12 -0
  39. package/dist/resources/auth-middleware.js +17 -44
  40. package/dist/resources/authority-field-guard.js +37 -0
  41. package/dist/resources/bm25-index-service.js +1 -1
  42. package/dist/resources/bm25-index.js +50 -11
  43. package/dist/resources/embedding-space-guard.js +238 -0
  44. package/dist/resources/embeddings-provider.js +32 -5
  45. package/dist/resources/federation-classify.js +23 -1
  46. package/dist/resources/health.js +11 -2
  47. package/dist/resources/hit-tracking.js +244 -0
  48. package/dist/resources/mcp-tools.js +272 -7
  49. package/dist/resources/memory-reflect-lib.js +111 -0
  50. package/dist/resources/migrations/embedding-stamp.js +22 -4
  51. package/dist/resources/owner-field-guard.js +62 -0
  52. package/dist/resources/promotion-stamp.js +29 -0
  53. package/dist/resources/record-owner-guard.js +71 -5
  54. package/dist/resources/record-types.js +30 -7
  55. package/dist/resources/relay-lib.js +205 -0
  56. package/dist/resources/relay-ops.js +294 -0
  57. package/dist/resources/skill-write.js +120 -0
  58. package/dist/resources/soul-adk-guard.js +68 -0
  59. package/dist/resources/soul-write-policy.js +63 -0
  60. package/dist/resources/table-helpers.js +2 -0
  61. package/dist/resources/usage-recording.js +3 -3
  62. package/dist/src/rem/promote-policy.js +204 -0
  63. package/docs/api-reference.md +374 -0
  64. package/docs/auth.md +52 -0
  65. package/docs/federation.md +4 -0
  66. package/docs/integrations.md +6 -6
  67. package/docs/mcp-clients.md +16 -1
  68. package/docs/releasing.md +11 -8
  69. package/docs/rem.md +20 -2
  70. package/docs/upgrade.md +47 -2
  71. package/package.json +6 -5
  72. package/schemas/memory.graphql +51 -2
  73. package/schemas/message.graphql +74 -0
  74. package/templates/launchd/start-flair-with-admin-pass.sh +73 -0
@@ -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 paused.
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
- * Falls back to a `search_by_conditions` POST that mirrors the pattern
197
- * `flair rem candidates` uses. Returns 0 on any error the runner should
198
- * not fail the cycle just because the candidate count couldn't be sampled.
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(api, agentId) {
297
+ async function fetchPendingCandidateCount(opsSearch, agentId) {
298
+ if (!opsSearch)
299
+ return 0;
201
300
  try {
202
- const result = await api("POST", "/MemoryCandidate/search_by_conditions", {
203
- operator: "and",
204
- conditions: [
205
- { search_attribute: "agentId", search_type: "equals", search_value: agentId },
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.apiCall, opts.agentId);
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, unchanged pre-#1205b behavior).
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: admin only.
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 { isAdmin, allowAdmin, invalidateAdminCache } from "./agent-auth.js";
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 request = ctx?.request ?? ctx;
51
- const actorId = request?.tpsAgent;
52
- if (!actorId || !(await isAdmin(actorId))) {
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,