@tpsdev-ai/flair 0.45.0 → 0.47.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 (39) hide show
  1. package/config.yaml +35 -2
  2. package/dist/build-info.json +6 -0
  3. package/dist/cli.js +847 -168
  4. package/dist/doctor-client.js +358 -11
  5. package/dist/federation/scheduler.js +114 -9
  6. package/dist/hook-install.js +150 -1
  7. package/dist/install/global-bin-path.js +234 -0
  8. package/dist/lib/entity-vocab-cli.js +113 -0
  9. package/dist/lib/mcp-enable.js +71 -21
  10. package/dist/lib/scheduler-platform.js +363 -1
  11. package/dist/postinstall.cjs +88 -0
  12. package/dist/rem/runner.js +177 -10
  13. package/dist/rem/scheduler.js +126 -20
  14. package/dist/resources/AttentionQuery.js +5 -3
  15. package/dist/resources/AutoPromoteCandidates.js +18 -12
  16. package/dist/resources/Federation.js +49 -5
  17. package/dist/resources/Memory.js +36 -2
  18. package/dist/resources/MemoryBootstrap.js +118 -7
  19. package/dist/resources/MemoryMaintenance.js +8 -2
  20. package/dist/resources/MemoryReflect.js +70 -5
  21. package/dist/resources/auto-promote-lib.js +46 -0
  22. package/dist/resources/build-info.js +50 -0
  23. package/dist/resources/entity-vocab.js +25 -1
  24. package/dist/resources/health.js +25 -5
  25. package/dist/resources/mcp-oauth-flag.js +20 -0
  26. package/dist/resources/mcp-oauth.js +6 -1
  27. package/dist/resources/mcp-tools.js +53 -3
  28. package/dist/resources/memory-reflect-lib.js +201 -4
  29. package/dist/src/lib/scheduler-platform.js +363 -1
  30. package/dist/src/rem/scheduler.js +126 -20
  31. package/docs/deepseek-harness.md +110 -0
  32. package/docs/entity-vocabulary.md +15 -0
  33. package/docs/integrations.md +1 -0
  34. package/docs/mcp-clients.md +4 -0
  35. package/docs/notes/mcp-oauth-model2.md +52 -3
  36. package/package.json +5 -4
  37. package/schemas/memory.graphql +12 -0
  38. package/templates/bin/flair-federation-sync.sh.tmpl +8 -1
  39. package/templates/bin/flair-rem-nightly.sh.tmpl +8 -1
@@ -67,6 +67,43 @@ export const REM_NIGHTLY_LOG = resolve(homedir(), ".flair", "logs", "rem-nightly
67
67
  // adk: tag under scope:"tagged", so each user's claims come only from that
68
68
  // user's sessions.
69
69
  export const ADK_TAG_PREFIX = "adk:";
70
+ // ─── Continuity-journal distillation (flair#1257 slice 3) ────────────────────
71
+ // The session-continuity journal (slice 2, #1283) writes ephemeral+private
72
+ // rows tagged `adk:continuity:<sessionId>`. Those tags share the `adk:` prefix
73
+ // but are NOT per-user ADK tags — they are per-SESSION journals with their own
74
+ // selection rule: a session is distilled only once it has SETTLED (its newest
75
+ // entry older than the settle window — never distill a live session) and only
76
+ // while it still has un-expired entries (the journal rows carry the ephemeral
77
+ // TTL; a fully-expired session has nothing left to distill). deriveActiveAdkTags
78
+ // EXCLUDES continuity tags for the same reason: the recency-based "active"
79
+ // rule would distill a session that is still live.
80
+ //
81
+ // Canonical prefix string duplicated in packages/flair-mcp/src/continuity.ts
82
+ // (CONTINUITY_TAG_PREFIX — the writer) and resources/memory-reflect-lib.ts /
83
+ // resources/auto-promote-lib.ts (CONTINUITY_SCOPE_TAG_PREFIX) — src/,
84
+ // packages/ and resources/ sit on opposite sides of npm-packaging boundaries
85
+ // (imports across them don't survive packaging; see src/cli.ts's header), so
86
+ // they are kept in sync by the shared canonical string.
87
+ export const CONTINUITY_TAG_PREFIX = "adk:continuity:";
88
+ /**
89
+ * Settle window (ms) for continuity-session selection — a session is
90
+ * distillable only when its NEWEST entry is older than this (Kern's ruling:
91
+ * the window is measured on the newest entry's createdAt, which is
92
+ * load-bearing — a 6h-long session whose last entry is 30min old is still
93
+ * LIVE and must not be distilled; measuring on the oldest entry would
94
+ * distill it). Default 2h, configurable via FLAIR_REM_SETTLE_HOURS.
95
+ */
96
+ export const DEFAULT_REM_SETTLE_MS = 2 * 3600_000;
97
+ /** Resolve the settle window: explicit override > FLAIR_REM_SETTLE_HOURS env
98
+ * (positive, finite) > DEFAULT_REM_SETTLE_MS. Exported for tests. */
99
+ export function resolveSettleMs(override, env = process.env) {
100
+ if (typeof override === "number" && Number.isFinite(override) && override >= 0)
101
+ return override;
102
+ const hours = Number(env.FLAIR_REM_SETTLE_HOURS);
103
+ if (Number.isFinite(hours) && hours > 0)
104
+ return hours * 3600_000;
105
+ return DEFAULT_REM_SETTLE_MS;
106
+ }
70
107
  /**
71
108
  * Recency window (ms) used to decide which adk: tags are ACTIVE — a tag is
72
109
  * enumerated (and distilled) only if it has memory records created within this
@@ -224,12 +261,79 @@ export function deriveActiveAdkTags(memories, sinceDate, agentId) {
224
261
  if (!Array.isArray(mt))
225
262
  continue;
226
263
  for (const t of mt) {
264
+ // flair#1257 slice 3: continuity tags share the adk: prefix but are
265
+ // per-SESSION journals, not per-user ADK tags — they get their own
266
+ // settle-window selection (deriveSettledContinuityTags). Admitting
267
+ // them here would distill a session that is still LIVE (the recency
268
+ // rule treats "written recently" as a reason TO distill; for a
269
+ // journal it is the reason NOT to).
270
+ if (typeof t === "string" && t.startsWith(CONTINUITY_TAG_PREFIX))
271
+ continue;
227
272
  if (typeof t === "string" && t.startsWith(ADK_TAG_PREFIX))
228
273
  tags.add(t);
229
274
  }
230
275
  }
231
276
  return [...tags].sort();
232
277
  }
278
+ /**
279
+ * Derive the DISTINCT continuity-session tags that are SETTLED and still
280
+ * distillable (flair#1257 slice 3 — the session-selection step). A tag is
281
+ * returned iff, over this agent's OWN un-expired journal rows carrying it:
282
+ *
283
+ * - the NEWEST entry's createdAt is at least `settleMs` old (the settle
284
+ * window, Kern's ruling — measured on the newest entry so a long-running
285
+ * session that wrote 30min ago is still live and excluded), and
286
+ * - at least one entry is un-expired (`expiresAt` absent or in the future)
287
+ * — "younger than TTL expiry": a fully-expired session has nothing left
288
+ * to distill (its rows are reaped, or excluded from the gather either
289
+ * way; see resources/MemoryReflect.ts's expired-row skip).
290
+ *
291
+ * Same owner-scoping discipline as deriveActiveAdkTags above (the snapshot
292
+ * fetch is org-wide; the per-record agentId check is load-bearing — no
293
+ * cross-agent session enumeration), and the same no-extra-query property:
294
+ * this reduces over the memories the cycle already fetched.
295
+ */
296
+ export function deriveSettledContinuityTags(memories, params) {
297
+ const { agentId, now, settleMs } = params;
298
+ const newestByTag = new Map();
299
+ for (const m of memories) {
300
+ if (!m || typeof m !== "object")
301
+ continue;
302
+ if (m.agentId !== agentId)
303
+ continue; // owner scope (fetch is org-wide)
304
+ // The JOURNAL is the ephemeral tier. Promoted rows PRESERVE the session
305
+ // scopeTag but are persistent — counting them would keep a session
306
+ // selectable (and re-distilled) forever after its journal expired.
307
+ if (m.durability !== "ephemeral")
308
+ continue;
309
+ // TTL: only un-expired rows count — an expired row neither keeps a
310
+ // session selectable nor (below) marks it live.
311
+ if (typeof m.expiresAt === "string" && m.expiresAt !== "" && new Date(m.expiresAt) <= now)
312
+ continue;
313
+ const createdAt = m.createdAt ? new Date(m.createdAt).getTime() : NaN;
314
+ if (!Number.isFinite(createdAt))
315
+ continue;
316
+ const mt = m.tags;
317
+ if (!Array.isArray(mt))
318
+ continue;
319
+ for (const t of mt) {
320
+ if (typeof t !== "string" || !t.startsWith(CONTINUITY_TAG_PREFIX))
321
+ continue;
322
+ if (t.length <= CONTINUITY_TAG_PREFIX.length)
323
+ continue; // bare prefix is not a session
324
+ const prev = newestByTag.get(t);
325
+ if (prev === undefined || createdAt > prev)
326
+ newestByTag.set(t, createdAt);
327
+ }
328
+ }
329
+ const settled = [];
330
+ const cutoff = now.getTime() - settleMs;
331
+ for (const [tag, newest] of newestByTag) {
332
+ if (newest <= cutoff)
333
+ settled.push(tag);
334
+ }
335
+ return settled.sort();
336
+ }
233
337
  /**
234
338
  * Runs one nightly cycle for the given agent. See module header for steps.
235
339
  * Pure orchestration; all I/O goes through injected dependencies.
@@ -363,8 +467,11 @@ export async function runNightlyCycle(opts) {
363
467
  // this all runs inside the one cycle on the one node.
364
468
  let candidates;
365
469
  // #1205b-2: outcome of the post-distillation auto-promote step, assigned
366
- // inside the !dryRun block below (only when this is an ADK agentId).
470
+ // inside the !dryRun block below (only when this is an ADK agentId or a
471
+ // cycle that distilled settled continuity sessions — flair#1257 slice 3).
367
472
  let autoPromoted;
473
+ // flair#1257 slice 3: settled continuity sessions distilled this cycle.
474
+ let continuitySessions;
368
475
  const collectStagedIds = (obj) => asArray(obj.candidates)
369
476
  .map((c) => (c && typeof c === "object" ? c.id : c))
370
477
  .filter((id) => typeof id === "string");
@@ -438,16 +545,75 @@ export async function runNightlyCycle(opts) {
438
545
  errors.push(`distillation: ${describeApiError(err?.message ?? err)}`);
439
546
  }
440
547
  }
548
+ // ── Step 5a (flair#1257 slice 3): continuity-session distillation ─────────
549
+ // Runs IN ADDITION to whichever path above ran — continuity journals are
550
+ // per-session, not per-user, and a non-ADK agent (the primary continuity
551
+ // consumer) still gets its regular agentId-only distill above. Selection:
552
+ // deriveSettledContinuityTags — only SETTLED (newest entry older than the
553
+ // settle window; never a live session) and un-expired sessions. Each runs
554
+ // scope:"tagged" with focus:"continuity" (the server enforces the
555
+ // continuity guard set for continuity tags regardless — the focus here is
556
+ // explicitness, not the enforcement point). Shares the per-cycle tag cap
557
+ // with the ADK path (Kern 1b); a deferred SETTLED session stays settled
558
+ // and is re-selected next cycle while its rows are un-expired (with the
559
+ // default 24h TTL and a nightly cadence a deferral can age a session out
560
+ // — acceptable at the 200-tag cap, recorded in `errors` when it happens).
561
+ // Same non-fatal per-tag failure discipline as the ADK path.
562
+ const settleMs = resolveSettleMs(opts.settleMs);
563
+ const settledContinuityTags = deriveSettledContinuityTags(fetchedMemories, {
564
+ agentId: opts.agentId,
565
+ now: startedAt,
566
+ settleMs,
567
+ });
568
+ const continuityBudget = Math.max(0, maxTags - Math.min(activeAdkTags.length, maxTags));
569
+ const continuityToRun = settledContinuityTags.slice(0, continuityBudget);
570
+ if (settledContinuityTags.length > continuityToRun.length) {
571
+ 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
+ }
573
+ if (continuityToRun.length > 0) {
574
+ // `candidates` is defined whenever distillation was ATTEMPTED (same
575
+ // contract as both paths above).
576
+ candidates = candidates ?? [];
577
+ let distilled = 0;
578
+ for (const tag of continuityToRun) {
579
+ try {
580
+ const reflectRaw = await opts.apiCall("POST", "/ReflectMemories", {
581
+ agentId: opts.agentId,
582
+ execute: true,
583
+ scope: "tagged",
584
+ tag,
585
+ focus: "continuity",
586
+ });
587
+ const obj = (reflectRaw && typeof reflectRaw === "object") ? reflectRaw : {};
588
+ if (obj.error) {
589
+ errors.push(`distillation[${tag}]: ${describeApiError(obj.error)}`);
590
+ }
591
+ else {
592
+ candidates.push(...collectStagedIds(obj));
593
+ distilled++;
594
+ }
595
+ }
596
+ catch (err) {
597
+ errors.push(`distillation[${tag}]: ${describeApiError(err?.message ?? err)}`);
598
+ }
599
+ }
600
+ continuitySessions = distilled;
601
+ }
441
602
  // ── Step 5b (#1205b-2): server-side ADK auto-promote ───────────────────────
442
- // Only for an ADK agentId (active adk: tags this cycle) a non-ADK agent
443
- // has no scopeTag-bearing candidates, so there is nothing to auto-promote
444
- // and no call is made. The SERVER enforces every security invariant
445
- // (memory-only target, fail-closed tag lineage, content-safety, machine
446
- // reviewerId) the runner only TRIGGERS the sweep; it never itself decides
447
- // where a claim lands. Non-fatal like distillation: a failure is recorded
448
- // and the candidates stay pending (re-swept next cycle, or promotable by the
449
- // human `rem promote` path). Bounded by the per-cycle cap.
450
- if (activeAdkTags.length > 0) {
603
+ // For an ADK agentId (active adk: tags this cycle) OR a cycle that
604
+ // distilled settled continuity sessions (flair#1257 slice 3 continuity
605
+ // candidates carry the session's adk:continuity: scopeTag and ride the
606
+ // same server-side sweep, promoted default-private-unless per Sherlock's
607
+ // ruling). An agent with neither has no scopeTag-bearing candidates, so
608
+ // there is nothing to auto-promote and no call is made. The SERVER
609
+ // enforces every security invariant (memory-only target, fail-closed tag
610
+ // lineage, content-safety, machine reviewerId, default-private
611
+ // visibility) the runner only TRIGGERS the sweep; it never itself
612
+ // decides where a claim lands or who can read it. Non-fatal like
613
+ // distillation: a failure is recorded and the candidates stay pending
614
+ // (re-swept next cycle, or promotable by the human `rem promote` path).
615
+ // Bounded by the per-cycle cap.
616
+ if (activeAdkTags.length > 0 || continuityToRun.length > 0) {
451
617
  try {
452
618
  const apRaw = await opts.apiCall("POST", "/AutoPromoteCandidates", {
453
619
  agentId: opts.agentId,
@@ -525,6 +691,7 @@ export async function runNightlyCycle(opts) {
525
691
  expired,
526
692
  candidates,
527
693
  autoPromoted,
694
+ continuitySessions,
528
695
  dedup,
529
696
  durationMs: Date.now() - startedMs,
530
697
  errors,
@@ -13,23 +13,29 @@
13
13
  * No daemon code lives here — the scheduler invokes the shim, the shim
14
14
  * invokes `flair rem nightly run-once`, the runner module does the work.
15
15
  */
16
- import { existsSync, chmodSync, rmSync } from "node:fs";
16
+ import { existsSync, mkdirSync, chmodSync, rmSync } from "node:fs";
17
17
  import { resolve, dirname } from "node:path";
18
18
  import { homedir } from "node:os";
19
19
  import { spawn } from "node:child_process";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { escapeXml } from "../lib/xml-escape.js";
22
- import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readTemplateFrom, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
22
+ import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readTemplateFrom, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, describeExitCode, resolveNodeBin, verifyFirstRun, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
23
23
  // Re-exported so this module's public surface is unchanged by the extraction
24
24
  // into src/lib/scheduler-platform.ts (a second scheduler — `flair federation
25
25
  // sync enable` — needs the identical launchctl/systemctl interpretation, and
26
26
  // flair#850's lesson must have exactly one implementation).
27
27
  export { interpretActiveResult };
28
28
  export const SHIM_PATH_DEFAULT = resolve(homedir(), ".flair", "bin", "flair-rem-nightly");
29
- export const LAUNCHD_PLIST_PATH = resolve(homedir(), "Library", "LaunchAgents", "dev.flair.rem.nightly.plist");
29
+ // Unit names, exported (flair#1278) so `flair doctor`'s scheduled-drivers
30
+ // section addresses the same job this module installs — same single-source
31
+ // rule as the federation scheduler's LAUNCHD_LABEL/SYSTEMD_*_UNIT constants.
32
+ export const LAUNCHD_LABEL = "dev.flair.rem.nightly";
33
+ export const SYSTEMD_TIMER_UNIT = "flair-rem-nightly.timer";
34
+ export const SYSTEMD_SERVICE_UNIT = "flair-rem-nightly.service";
35
+ export const LAUNCHD_PLIST_PATH = resolve(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
30
36
  export const SYSTEMD_USER_DIR = resolve(homedir(), ".config", "systemd", "user");
31
- export const SYSTEMD_TIMER_PATH = resolve(SYSTEMD_USER_DIR, "flair-rem-nightly.timer");
32
- export const SYSTEMD_SERVICE_PATH = resolve(SYSTEMD_USER_DIR, "flair-rem-nightly.service");
37
+ export const SYSTEMD_TIMER_PATH = resolve(SYSTEMD_USER_DIR, SYSTEMD_TIMER_UNIT);
38
+ export const SYSTEMD_SERVICE_PATH = resolve(SYSTEMD_USER_DIR, SYSTEMD_SERVICE_UNIT);
33
39
  function detectPlatform(override) {
34
40
  return detectPlatformFor("REM nightly scheduler", override);
35
41
  }
@@ -87,15 +93,16 @@ function validateSchedule(hour, minute) {
87
93
  throw new Error(`minute must be an integer 0-59, got ${minute}`);
88
94
  }
89
95
  }
90
- function buildSubstitutions(opts, shimPath, flairBin) {
96
+ function buildSubstitutions(opts, shimPath, flairBin, nodeBin) {
91
97
  validateSchedule(opts.hour, opts.minute);
92
98
  if (!/^[a-zA-Z0-9_-]+$/.test(opts.agentId)) {
93
99
  throw new Error(`invalid agent id: ${opts.agentId}`);
94
100
  }
95
101
  return {
96
102
  FLAIR_BIN: flairBin,
103
+ NODE_BIN: nodeBin,
97
104
  SHIM_PATH: shimPath,
98
- HOME: homedir(),
105
+ HOME: opts.homeOverride ?? homedir(),
99
106
  AGENT_ID: opts.agentId,
100
107
  FLAIR_URL: opts.flairUrl,
101
108
  HOUR: String(opts.hour),
@@ -112,9 +119,9 @@ function buildSubstitutions(opts, shimPath, flairBin) {
112
119
  */
113
120
  function activeCheckCommand(plat) {
114
121
  if (plat === "darwin") {
115
- return ["launchctl", "print", `gui/${process.getuid?.() ?? ""}/dev.flair.rem.nightly`];
122
+ return ["launchctl", "print", `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`];
116
123
  }
117
- return ["systemctl", "--user", "is-active", "flair-rem-nightly.timer"];
124
+ return ["systemctl", "--user", "is-active", SYSTEMD_TIMER_UNIT];
118
125
  }
119
126
  /**
120
127
  * Synchronous active-state check for CLI use (`flair rem nightly status`).
@@ -183,10 +190,14 @@ export function describeLoadFailure(plat, loadResult) {
183
190
  * succeeded) is unit-testable without spawning a real launchctl/systemctl or
184
191
  * parsing CLI argv.
185
192
  *
186
- * `r.loadResult` is only set when the load command actually ran (the CLI
187
- * never sets `skipLoad`). A missing `loadResult` (test-only path) is treated
188
- * as success matches the CLI's real-world behavior, which always runs the
189
- * load command and therefore always gets a `loadResult`.
193
+ * flair#1231 deepened the #850 rule by one layer: activation exiting 0 proves
194
+ * the service manager ACCEPTED the job, not that the job can run — a stripped
195
+ * exec bit and a missing log directory both passed activation and killed the
196
+ * first real run invisibly. So the headline is now additionally gated on
197
+ * `firstRunVerified`: success may not be claimed until the thing the operator
198
+ * asked for — a REM run through the service manager — has been observed to
199
+ * happen once. A missing `loadResult`/`firstRun` (test-only skipLoad shape)
200
+ * therefore withholds the headline too, instead of being treated as success.
190
201
  */
191
202
  export function formatEnableReport(r, input) {
192
203
  const { hour, minute, agentId, flairUrl } = input;
@@ -212,6 +223,57 @@ export function formatEnableReport(r, input) {
212
223
  lines.push(` Nothing is scheduled until activation succeeds. Check anytime with: flair rem nightly status`);
213
224
  return { lines, ok: false };
214
225
  }
226
+ if (!r.firstRunVerified) {
227
+ const fr = r.firstRun;
228
+ const headline = fr?.outcome === "run-failed"
229
+ ? `⚠️ REM nightly scheduler installed but the first run FAILED (${describeExitCode(fr.exitCode)})`
230
+ : fr?.outcome === "timeout"
231
+ ? `⚠️ REM nightly scheduler installed but the first run did not complete within ${Math.round(fr.budgetMs / 1000)}s — cannot confirm it works`
232
+ : fr?.outcome === "manager-unavailable"
233
+ ? `⚠️ REM nightly scheduler installed but the service manager is unreachable — cannot verify the first run`
234
+ : fr?.outcome === "start-failed"
235
+ ? `⚠️ REM nightly scheduler installed but the first run could not be started`
236
+ : `⚠️ REM nightly scheduler installed but the first run was never verified`;
237
+ const lines = [
238
+ headline,
239
+ ` Schedule: ${scheduleTime} local time`,
240
+ ` Scheduler: ${r.schedulerPath}`,
241
+ ` Shim: ${r.shimPath}`,
242
+ ` Agent: ${agentId}`,
243
+ ` Flair URL: ${flairUrl}`,
244
+ ];
245
+ if (r.loadResult)
246
+ lines.push(` Load: ${r.loadCommand.join(" ")} → ok`);
247
+ if (fr) {
248
+ lines.push(` First run: ${fr.detail}`);
249
+ if (fr.stderrTail) {
250
+ lines.push(` Log tail (${fr.logPath}):`);
251
+ for (const l of fr.stderrTail.split("\n"))
252
+ lines.push(` ${l}`);
253
+ }
254
+ else if (fr.logEmpty) {
255
+ lines.push(` Log file ${fr.logPath} exists but is EMPTY — the run died before writing anything.`);
256
+ }
257
+ else {
258
+ lines.push(` No log file at ${fr.logPath}.`);
259
+ }
260
+ }
261
+ lines.push("");
262
+ if (fr?.outcome === "timeout") {
263
+ lines.push(` The run may legitimately still be going (a REM cycle can be slow). Check the log above and`);
264
+ lines.push(` \`flair rem nightly status\`; no cycle has been CONFIRMED to work yet.`);
265
+ }
266
+ else if (fr?.outcome === "manager-unavailable") {
267
+ lines.push(` The scheduler files are installed, but launchctl/systemctl could not be consulted, so whether`);
268
+ lines.push(` the nightly cycle runs is UNKNOWN. Fix the service manager for this session, then re-run \`flair rem nightly enable\`.`);
269
+ }
270
+ else {
271
+ lines.push(` No REM cycle has run. Fix the cause above, then re-run \`flair rem nightly enable\`.`);
272
+ }
273
+ lines.push("");
274
+ lines.push(` Check anytime with: flair rem nightly status`);
275
+ return { lines, ok: false };
276
+ }
215
277
  const lines = [
216
278
  `✅ REM nightly scheduler enabled (${r.platform})`,
217
279
  ` Schedule: ${scheduleTime} local time`,
@@ -223,9 +285,9 @@ export function formatEnableReport(r, input) {
223
285
  if (r.loadResult) {
224
286
  lines.push(` Load: ${r.loadCommand.join(" ")} → ok`);
225
287
  }
288
+ lines.push(` First run: completed through the service manager, exit 0`);
226
289
  lines.push("");
227
- lines.push(`Tip: run \`flair rem nightly run-once --dry-run\` to verify the cycle works`);
228
- lines.push(` before the first scheduled fire. Disable with \`flair rem nightly disable\`.`);
290
+ lines.push(`Disable with \`flair rem nightly disable\`.`);
229
291
  return { lines, ok: true };
230
292
  }
231
293
  /**
@@ -268,9 +330,29 @@ export function formatStatusReport(s) {
268
330
  export function enableScheduler(opts) {
269
331
  const plat = detectPlatform(opts.platformOverride);
270
332
  const flairBin = opts.flairBin ?? process.argv[1] ?? "flair";
333
+ const nodeBin = resolveNodeBin(opts.nodeBin);
271
334
  const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
272
335
  const templateRoot = opts.templateRootOverride ?? defaultTemplateRoot();
273
- const subs = buildSubstitutions(opts, shimPath, flairBin);
336
+ const subs = buildSubstitutions(opts, shimPath, flairBin, nodeBin);
337
+ // 0. Create the log directory the unit files point stdout/stderr at.
338
+ // Nothing else ever creates it — launchd kills a job whose StandardOutPath
339
+ // directory is missing (spawn error 209) and systemd fails the unit (#1231).
340
+ //
341
+ // Mode 0700 is load-bearing, NOT cosmetic: REM's nightly log carries
342
+ // distillation CANDIDATE CONTENT — actual memory text, not just counts.
343
+ // Relaxing it to 0755 (e.g. "for shared debugging") would expose memory
344
+ // content to every local user.
345
+ const logsDir = resolve(subs.HOME, ".flair", "logs");
346
+ try {
347
+ mkdirSync(logsDir, { recursive: true, mode: 0o700 });
348
+ }
349
+ catch (err) {
350
+ throw new Error(`could not create the scheduler log directory ${logsDir}: ${err?.message ?? err}. ` +
351
+ `The service manager writes the job's stdout/stderr there; without it the first run dies ` +
352
+ `before producing any output. Fix whatever blocks creating that directory, then re-run ` +
353
+ `\`flair rem nightly enable\`.`);
354
+ }
355
+ const stderrLogPath = resolve(logsDir, "rem-nightly.stderr.log");
274
356
  // 1. Deploy the shim (always — both platforms invoke it).
275
357
  const shimContents = renderTemplate(readTemplate(templateRoot, "bin/flair-rem-nightly.sh.tmpl"), subs);
276
358
  writeFileWithDir(shimPath, shimContents, 0o700);
@@ -282,12 +364,26 @@ export function enableScheduler(opts) {
282
364
  writeFileWithDir(plistPath, plistContents, 0o600);
283
365
  const loadCommand = ["launchctl", "bootstrap", `gui/${process.getuid?.() ?? ""}`, plistPath];
284
366
  let loadResult;
367
+ let firstRun;
285
368
  if (!opts.skipLoad) {
286
369
  // Bootout first in case a prior install left the job loaded.
287
370
  spawnReport(["launchctl", "bootout", `gui/${process.getuid?.() ?? ""}`, plistPath]);
288
371
  loadResult = spawnReport(loadCommand);
372
+ if (loadResult.code === 0) {
373
+ // Ordering gate (#1231): verify the first run ONLY after the load
374
+ // exited 0. A load failure is its own failure mode with its own
375
+ // remedy — kickstarting on top of it would blur which actor failed.
376
+ firstRun = verifyFirstRun({
377
+ plat,
378
+ darwinTarget: `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`,
379
+ stderrLogPath,
380
+ });
381
+ }
289
382
  }
290
- return { platform: plat, shimPath, schedulerPath: plistPath, loadCommand, loadResult };
383
+ return {
384
+ platform: plat, shimPath, schedulerPath: plistPath, loadCommand, loadResult,
385
+ firstRunVerified: firstRun?.verified === true, firstRun,
386
+ };
291
387
  }
292
388
  // Linux: systemd user units.
293
389
  const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
@@ -296,13 +392,23 @@ export function enableScheduler(opts) {
296
392
  const timerContents = renderTemplate(readTemplate(templateRoot, "systemd/flair-rem-nightly.timer.tmpl"), subs);
297
393
  writeFileWithDir(servicePath, serviceContents, 0o600);
298
394
  writeFileWithDir(timerPath, timerContents, 0o600);
299
- const loadCommand = ["systemctl", "--user", "enable", "--now", "flair-rem-nightly.timer"];
395
+ const loadCommand = ["systemctl", "--user", "enable", "--now", SYSTEMD_TIMER_UNIT];
300
396
  let loadResult;
397
+ let firstRun;
301
398
  if (!opts.skipLoad) {
302
399
  spawnReport(["systemctl", "--user", "daemon-reload"]);
303
400
  loadResult = spawnReport(loadCommand);
401
+ if (loadResult.code === 0) {
402
+ // Ordering gate (#1231): only after the load exited 0. Starts the
403
+ // SERVICE unit directly (oneshot ⇒ blocks until the run exits) rather
404
+ // than waiting for the nightly timer to fire.
405
+ firstRun = verifyFirstRun({ plat, linuxServiceUnit: SYSTEMD_SERVICE_UNIT, stderrLogPath });
406
+ }
304
407
  }
305
- return { platform: plat, shimPath, schedulerPath: timerPath, loadCommand, loadResult };
408
+ return {
409
+ platform: plat, shimPath, schedulerPath: timerPath, loadCommand, loadResult,
410
+ firstRunVerified: firstRun?.verified === true, firstRun,
411
+ };
306
412
  }
307
413
  /**
308
414
  * Removes the scheduler entry. Audit log + snapshots are preserved.
@@ -332,7 +438,7 @@ export function disableScheduler(opts = {}) {
332
438
  }
333
439
  const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
334
440
  const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
335
- const unloadCommand = ["systemctl", "--user", "disable", "--now", "flair-rem-nightly.timer"];
441
+ const unloadCommand = ["systemctl", "--user", "disable", "--now", SYSTEMD_TIMER_UNIT];
336
442
  let unloadResult;
337
443
  if (existsSync(timerPath) || existsSync(servicePath)) {
338
444
  if (!opts.skipUnload) {
@@ -77,7 +77,7 @@
77
77
  */
78
78
  import { Resource, databases } from "harper";
79
79
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
80
- import { isValidEntity } from "./entity-vocab.js";
80
+ import { entityFormatHint, isValidEntity } from "./entity-vocab.js";
81
81
  import { resolveReadScope } from "./memory-read-scope.js";
82
82
  import { withDetachedTxn } from "./table-helpers.js";
83
83
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
@@ -102,7 +102,7 @@ const UNAUTH = () => new Response(JSON.stringify({ error: "authentication requir
102
102
  function parseQueryInput(data) {
103
103
  const entity = data?.entity;
104
104
  if (typeof entity !== "string" || entity.length === 0) {
105
- return badRequest("invalid_entity", "entity is required (a vocabulary string, e.g. 'repo:owner/name')");
105
+ return badRequest("invalid_entity", `entity is required — ${entityFormatHint()}`);
106
106
  }
107
107
  // Exact match on the full type:value string — the SAME validator every
108
108
  // write path (Memory/WorkspaceState/OrgEvent) gates `entities` writes
@@ -110,7 +110,9 @@ function parseQueryInput(data) {
110
110
  // closed, documented type set / grammar — never a prefix/regex match, so
111
111
  // this stays a plain indexed equality lookup, never a scan.
112
112
  if (!isValidEntity(entity)) {
113
- return badRequest("invalid_entity", `'${entity}' is not a well-formed vocabulary string (type:value, closed type set)`);
113
+ // flair#1288 (canary finding 5): the rejection must say what well-formed
114
+ // looks like — name the type:value format and enumerate the valid types.
115
+ return badRequest("invalid_entity", `'${entity}' is not a well-formed vocabulary string — ${entityFormatHint()}`);
114
116
  }
115
117
  let days = DEFAULT_WINDOW_DAYS;
116
118
  if (data?.days !== undefined && data?.days !== null) {
@@ -42,7 +42,7 @@ import { Resource, databases, logger } from "harper";
42
42
  import { isAdmin, allowVerified } from "./agent-auth.js";
43
43
  import { resolveReflectActor } from "./memory-reflect-lib.js";
44
44
  import { agentContext } from "./in-process.js";
45
- import { decideAutoPromote, buildAutoPromotedTags, DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE, } from "./auto-promote-lib.js";
45
+ import { decideAutoPromote, buildAutoPromotedTags, decidePromotedVisibility, DEFAULT_MAX_AUTO_PROMOTE_PER_CYCLE, } from "./auto-promote-lib.js";
46
46
  export class AutoPromoteCandidates extends Resource {
47
47
  // Any verified agent may trigger a sweep of ITS OWN candidates; the actor
48
48
  // resolution in post() enforces the own-only scope. Same gate ReflectMemories
@@ -124,20 +124,26 @@ export class AutoPromoteCandidates extends Resource {
124
124
  agentId,
125
125
  content: c.claim,
126
126
  durability: "persistent",
127
- // ── visibility: PRIVATE, explicit (Sherlock cross-agent leak fix) ──────
127
+ // ── visibility: DEFAULT-PRIVATE-UNLESS, explicit (Sherlock) ───────────
128
128
  // MUST be set. Memory.put() defaults an unset visibility from durability
129
129
  // (Memory.ts) and "persistent" defaults to "shared" — and "shared" is
130
130
  // ORG-OPEN (memory-read-scope.ts): readable by EVERY verified agent on
131
- // the instance. The source episodes are the user's PRIVATE session data
132
- // (durability:"standard" default private), and the per-user boundary
133
- // is adk-flair's CLIENT-SIDE tag re-verification, which OTHER agents do
134
- // NOT run. So a shared auto-promoted claim would leak a user's distilled
135
- // private data to every agent on the box — unattended. "private" is
136
- // owner-only, so the claim is reachable ONLY through the app agent's own
137
- // tag-filtered search (which re-verifies the tag) and is invisible to
138
- // every other agent. This keeps the blast radius inside the one agentId,
139
- // which is the entire #1205 safety argument.
140
- visibility: "private",
131
+ // the instance. The sources are the most sensitive tiers (a user's
132
+ // PRIVATE session episodes, or an agent's ephemeral+private continuity
133
+ // journal), so promotion is a visibility ESCALATION and the default
134
+ // including every uncertainty fallback is "private".
135
+ //
136
+ // decidePromotedVisibility (auto-promote-lib.ts) returns "shared" ONLY
137
+ // for a CONTINUITY-scoped candidate carrying an AFFIRMATIVE distiller
138
+ // ruling with a recorded team-relevance justification (flair#1257
139
+ // slice 3 a shared row always traces to a justification on its
140
+ // candidate, never to a default). ADK per-user candidates remain
141
+ // ALWAYS private regardless of any ruling: their per-user boundary is
142
+ // adk-flair's CLIENT-SIDE tag re-verification, which OTHER agents do
143
+ // NOT run — a shared ADK promotion would leak a user's distilled
144
+ // private data to every agent on the box, unattended. That is the
145
+ // entire #1205 safety argument, unchanged.
146
+ visibility: decidePromotedVisibility(c),
141
147
  // scopeTag FIRST — the per-user access-control boundary (Req 2).
142
148
  tags: buildAutoPromotedTags(c.id, decision.scopeTag),
143
149
  derivedFrom: Array.isArray(c.sourceMemoryIds) ? c.sourceMemoryIds : [],
@@ -86,7 +86,23 @@ export class FederationInstance extends Resource {
86
86
  break;
87
87
  }
88
88
  }
89
- catch { /* table may not exist */ }
89
+ catch (err) {
90
+ // Expected on genuine first boot: the Instance table doesn't exist yet,
91
+ // and the create branch below handles that. But a bare swallow here also
92
+ // hid every OTHER read failure (storage, permissions), making a
93
+ // persistently failing read indistinguishable from first boot
94
+ // (flair#1233). Log the error class so the two are tellable apart in
95
+ // server logs; behavior is unchanged — fall through to create.
96
+ console.warn("[federation] Instance read failed — proceeding as first boot (create branch will run). " +
97
+ "If this instance already has an identity, this is a real read error, not an absent table. " +
98
+ `${err?.constructor?.name ?? "Error"}: ${err?.message ?? err}`);
99
+ }
100
+ // Runtime-only signal (flair#1233): whether this instance's private key
101
+ // seed is present in the keystore, i.e. whether it can SIGN (pair/sync).
102
+ // Deliberately NOT persisted — the Instance.status enum is
103
+ // schema-constrained and this is transient machine state; recomputed on
104
+ // every GET.
105
+ let signingKeyAvailable = false;
90
106
  if (!instance) {
91
107
  // First boot — generate instance identity
92
108
  const kp = nacl.sign.keyPair();
@@ -101,16 +117,42 @@ export class FederationInstance extends Resource {
101
117
  updatedAt: new Date().toISOString(),
102
118
  };
103
119
  await databases.flair.Instance.put(instance);
104
- // Store private key seed in encrypted keystore (not in DB)
120
+ // Store private key seed in encrypted keystore (not in DB).
121
+ //
122
+ // flair#1233: a keystore failure no longer aborts the GET. This is a
123
+ // READ path — the response never uses the private key, and the old
124
+ // throw here left federation state entirely unobservable on hosts
125
+ // whose HOME isn't writable (the #812 ENOTDIR class; keysDir() is
126
+ // homedir()-relative). Fail-closed stays where the key is USED:
127
+ // signing, in pair/sync, still throws when the key is absent. Here the
128
+ // failure is logged server-side and surfaced to the caller as
129
+ // signingKeyAvailable:false. Plaintext keys still never touch the DB.
105
130
  try {
106
131
  const { keystore } = await import("../src/keystore.js");
107
132
  const seed = kp.secretKey.slice(0, 32);
108
133
  keystore.setPrivateKeySeed(id, seed);
134
+ signingKeyAvailable = true;
109
135
  }
110
136
  catch (err) {
111
- // Fail closed never store plaintext keys in the database
112
- console.error("[federation] FATAL: Could not store key seed in keystore. Federation identity not created.", err);
113
- throw new Error("Keystore unavailable cannot create federation identity without secure key storage");
137
+ console.error("[federation] Could not store the federation signing key seed in the keystore " +
138
+ "($HOME/.flair/keys, relative to the Harper process's HOME). The identity row was created and " +
139
+ "reads work, but this instance cannot sign pair/sync will fail until the keystore is fixed. " +
140
+ "Remedy: make $HOME/.flair/keys a directory writable by the Harper process (mode 0700). " +
141
+ "The seed for THIS identity was never stored, so after fixing the keystore, re-key: delete the " +
142
+ "Instance row and re-pair to mint a fresh identity. " +
143
+ `${err?.constructor?.name ?? "Error"}: ${err?.message ?? err}`);
144
+ }
145
+ }
146
+ else {
147
+ // Existing identity: report whether its signing key is present and
148
+ // decryptable. getPrivateKeySeed is a read-only probe that returns null
149
+ // (never throws) when the key file is missing or unreadable.
150
+ try {
151
+ const { keystore } = await import("../src/keystore.js");
152
+ signingKeyAvailable = keystore.getPrivateKeySeed(instance.id) !== null;
153
+ }
154
+ catch {
155
+ signingKeyAvailable = false;
114
156
  }
115
157
  }
116
158
  return {
@@ -118,6 +160,8 @@ export class FederationInstance extends Resource {
118
160
  publicKey: instance.publicKey,
119
161
  role: instance.role,
120
162
  status: instance.status,
163
+ // Runtime-only — see above; never persisted.
164
+ signingKeyAvailable,
121
165
  };
122
166
  }
123
167
  }