@tpsdev-ai/flair 0.46.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.
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ /**
3
+ * postinstall.cts — `npm install -g` PATH check (flair#1134).
4
+ *
5
+ * THIS IS THE POSTINSTALL ENTRY (`package.json` "postinstall" requires
6
+ * dist/postinstall.cjs). It exists for exactly one case: a user-prefix
7
+ * global install (prefix = ~/.npm-global or similar) where npm links the
8
+ * `flair` bin into a directory that is not on PATH. The install "succeeds",
9
+ * then `flair` is command-not-found, and the docs' one-command install claim
10
+ * is a lie. This is the only surface that can reach the user at the moment
11
+ * that happens — a first-run banner can never run, because the bin the user
12
+ * would run is precisely what's unreachable.
13
+ *
14
+ * History note (#1078/#1008): the previous postinstall was removed because it
15
+ * was a NO-OP (chmod +x on bins npm already marks executable) that cost an
16
+ * install-script approval line. This one is not that: it does work no other
17
+ * surface can, it is read-only (env + one existsSync — no network, no
18
+ * writes), and it NEVER fails the install (every path swallows errors and
19
+ * exits 0). Where lifecycle scripts are suppressed — `--ignore-scripts`,
20
+ * bun without trustedDependencies, the fleet's tar-swap deploys (the #1078
21
+ * path, which manages PATH itself) — `flair doctor` runs the same check.
22
+ *
23
+ * Delivery: npm ≥8 hides lifecycle-script output on success (it only shows
24
+ * with --foreground-scripts or on failure), so printing to stderr alone
25
+ * would be invisible exactly where it matters. We write to /dev/tty first —
26
+ * that bypasses npm's captured pipes and lands on the interactive terminal —
27
+ * and fall back to stderr (visible under --foreground-scripts, bun, older
28
+ * npm; harmlessly buffered otherwise). No TTY (CI) ⇒ the fallback is the
29
+ * only path, which is the right amount of noise for CI: none visible.
30
+ *
31
+ * Like cli-shim.cts this is CommonJS on purpose: it parses and runs on any
32
+ * Node a user could have, and the ESM helper is loaded via dynamic import()
33
+ * with every failure swallowed — a postinstall must never be the thing that
34
+ * breaks an install.
35
+ */
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ function writeToTty(text) {
38
+ // npm captures the script's stdout/stderr pipes; the controlling terminal
39
+ // does not go through them. Best-effort, sync, closed either way.
40
+ //
41
+ // FLAIR_POSTINSTALL_NO_TTY: unit tests spawn this entry and assert on the
42
+ // stderr fallback; without the knob, a dev running the suite from a real
43
+ // terminal would have /dev/tty succeed — scribbling the banner over their
44
+ // screen and blanking the stderr the test asserts on (flaky by
45
+ // environment). The tty path's own coverage is the manual pty
46
+ // verification recorded in the flair#1134 PR (`script`-wrapped npm i -g).
47
+ if (process.env.FLAIR_POSTINSTALL_NO_TTY)
48
+ return false;
49
+ try {
50
+ var fs = require("node:fs");
51
+ var fd = fs.openSync("/dev/tty", "w");
52
+ try {
53
+ fs.writeSync(fd, text);
54
+ }
55
+ finally {
56
+ fs.closeSync(fd);
57
+ }
58
+ return true;
59
+ }
60
+ catch (e) {
61
+ return false;
62
+ }
63
+ }
64
+ import("./install/global-bin-path.js")
65
+ .then(function (mod) {
66
+ try {
67
+ var path = require("node:path");
68
+ var message = mod.postinstallWarning({
69
+ npmConfigGlobal: process.env.npm_config_global,
70
+ npmConfigPrefix: process.env.npm_config_prefix,
71
+ pathEnv: process.env.PATH,
72
+ shell: process.env.SHELL,
73
+ // __dirname is <pkg>/dist — the package root is one up.
74
+ packageDir: path.resolve(__dirname, ".."),
75
+ });
76
+ if (message) {
77
+ var banner = "\n@tpsdev-ai/flair postinstall:\n\n" + message + "\n";
78
+ if (!writeToTty(banner))
79
+ console.error(banner);
80
+ }
81
+ }
82
+ catch (e) {
83
+ // Never fail the install over a diagnostic.
84
+ }
85
+ })
86
+ .catch(function () {
87
+ // Helper unloadable (ancient Node, partial install) — never fail the install.
88
+ });
@@ -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,
@@ -26,10 +26,16 @@ import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readT
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
  }
@@ -113,9 +119,9 @@ function buildSubstitutions(opts, shimPath, flairBin, nodeBin) {
113
119
  */
114
120
  function activeCheckCommand(plat) {
115
121
  if (plat === "darwin") {
116
- return ["launchctl", "print", `gui/${process.getuid?.() ?? ""}/dev.flair.rem.nightly`];
122
+ return ["launchctl", "print", `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`];
117
123
  }
118
- return ["systemctl", "--user", "is-active", "flair-rem-nightly.timer"];
124
+ return ["systemctl", "--user", "is-active", SYSTEMD_TIMER_UNIT];
119
125
  }
120
126
  /**
121
127
  * Synchronous active-state check for CLI use (`flair rem nightly status`).
@@ -369,7 +375,7 @@ export function enableScheduler(opts) {
369
375
  // remedy — kickstarting on top of it would blur which actor failed.
370
376
  firstRun = verifyFirstRun({
371
377
  plat,
372
- darwinTarget: `gui/${process.getuid?.() ?? ""}/dev.flair.rem.nightly`,
378
+ darwinTarget: `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`,
373
379
  stderrLogPath,
374
380
  });
375
381
  }
@@ -386,7 +392,7 @@ export function enableScheduler(opts) {
386
392
  const timerContents = renderTemplate(readTemplate(templateRoot, "systemd/flair-rem-nightly.timer.tmpl"), subs);
387
393
  writeFileWithDir(servicePath, serviceContents, 0o600);
388
394
  writeFileWithDir(timerPath, timerContents, 0o600);
389
- const loadCommand = ["systemctl", "--user", "enable", "--now", "flair-rem-nightly.timer"];
395
+ const loadCommand = ["systemctl", "--user", "enable", "--now", SYSTEMD_TIMER_UNIT];
390
396
  let loadResult;
391
397
  let firstRun;
392
398
  if (!opts.skipLoad) {
@@ -396,7 +402,7 @@ export function enableScheduler(opts) {
396
402
  // Ordering gate (#1231): only after the load exited 0. Starts the
397
403
  // SERVICE unit directly (oneshot ⇒ blocks until the run exits) rather
398
404
  // than waiting for the nightly timer to fire.
399
- firstRun = verifyFirstRun({ plat, linuxServiceUnit: "flair-rem-nightly.service", stderrLogPath });
405
+ firstRun = verifyFirstRun({ plat, linuxServiceUnit: SYSTEMD_SERVICE_UNIT, stderrLogPath });
400
406
  }
401
407
  }
402
408
  return {
@@ -432,7 +438,7 @@ export function disableScheduler(opts = {}) {
432
438
  }
433
439
  const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
434
440
  const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
435
- const unloadCommand = ["systemctl", "--user", "disable", "--now", "flair-rem-nightly.timer"];
441
+ const unloadCommand = ["systemctl", "--user", "disable", "--now", SYSTEMD_TIMER_UNIT];
436
442
  let unloadResult;
437
443
  if (existsSync(timerPath) || existsSync(servicePath)) {
438
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
  }
@@ -887,6 +887,30 @@ export class Memory extends databases.flair.Memory {
887
887
  if (!preExisting && (content.visibility === undefined || content.visibility === null)) {
888
888
  content.visibility = defaultVisibilityForDurability(content.durability);
889
889
  }
890
+ // ── flair#1257 slice 3: stamp the ephemeral TTL on the PUT path too ──────
891
+ // post() has stamped expiresAt for ephemeral rows since the tier shipped,
892
+ // but put() — the verb the continuity capture hook actually writes with
893
+ // (`PUT /Memory/<id>`, packages/flair-mcp/src/continuity-capture-hook.ts)
894
+ // — never did. MemoryMaintenance's reap keys on expiresAt (expired =
895
+ // expiresAt < now), so hook-written journal rows carried NO expiry and
896
+ // the tier's load-bearing 24h containment bound (the exposure window the
897
+ // #1257 rulings cite) silently never engaged on the real write path.
898
+ // Effective durability = the write's, else the pre-existing row's (same
899
+ // resolution the visibility guard above uses). A pre-existing expiry is
900
+ // carried forward, never re-stamped — an update must not extend the
901
+ // exposure window; an explicit caller-provided expiresAt always wins.
902
+ {
903
+ const effectiveDurability = content.durability ?? preExisting?.durability;
904
+ if (effectiveDurability === "ephemeral" && !content.expiresAt) {
905
+ if (preExisting?.expiresAt) {
906
+ content.expiresAt = preExisting.expiresAt;
907
+ }
908
+ else {
909
+ const ttlHours = Number(process.env.FLAIR_EPHEMERAL_TTL_HOURS || 24);
910
+ content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
911
+ }
912
+ }
913
+ }
890
914
  // supersedes: optional reference to the ID of the memory this one
891
915
  // replaces. Validates shape + cross-agent-write authorization (shared
892
916
  // with post() — see validateAndAuthorizeSupersedes doc for why PUT needs
@@ -967,8 +991,18 @@ export class Memory extends databases.flair.Memory {
967
991
  if (content.promotionStatus === "approved" && !content.promotedAt) {
968
992
  content.promotedAt = now;
969
993
  }
970
- // Upgrade to permanent when approved
971
- if (content.promotionStatus === "approved") {
994
+ // Upgrade to permanent when approved — the LEGACY in-place approval flow
995
+ // (an admin marks an EXISTING row approved without naming a tier; the
996
+ // auth-middleware admin-gates setting promotionStatus over HTTP). An
997
+ // explicit durability on the SAME write now wins (flair#1257 slice 3):
998
+ // the candidate-promotion paths (#1205b-2 /AutoPromoteCandidates and the
999
+ // human `flair rem promote`) write NEW rows carrying promotionStatus:
1000
+ // "approved" purely as an audit stamp ALONGSIDE an explicit durability:
1001
+ // "persistent" — the unconditional coercion here silently lifted every
1002
+ // promoted claim into the never-reaped permanent tier while every audit
1003
+ // surface (CLI output, specs, review rulings) said persistent. A write
1004
+ // that names its tier keeps it; only a tier-less approval still upgrades.
1005
+ if (content.promotionStatus === "approved" && (content.durability === undefined || content.durability === null)) {
972
1006
  content.durability = "permanent";
973
1007
  }
974
1008
  // Write-time provenance stamp (memory-provenance slice 1) — see