@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.
- package/config.yaml +35 -2
- package/dist/build-info.json +6 -0
- package/dist/cli.js +847 -168
- package/dist/doctor-client.js +358 -11
- package/dist/federation/scheduler.js +114 -9
- package/dist/hook-install.js +150 -1
- package/dist/install/global-bin-path.js +234 -0
- package/dist/lib/entity-vocab-cli.js +113 -0
- package/dist/lib/mcp-enable.js +71 -21
- package/dist/lib/scheduler-platform.js +363 -1
- package/dist/postinstall.cjs +88 -0
- package/dist/rem/runner.js +177 -10
- package/dist/rem/scheduler.js +126 -20
- package/dist/resources/AttentionQuery.js +5 -3
- package/dist/resources/AutoPromoteCandidates.js +18 -12
- package/dist/resources/Federation.js +49 -5
- package/dist/resources/Memory.js +36 -2
- package/dist/resources/MemoryBootstrap.js +118 -7
- package/dist/resources/MemoryMaintenance.js +8 -2
- package/dist/resources/MemoryReflect.js +70 -5
- package/dist/resources/auto-promote-lib.js +46 -0
- package/dist/resources/build-info.js +50 -0
- package/dist/resources/entity-vocab.js +25 -1
- package/dist/resources/health.js +25 -5
- package/dist/resources/mcp-oauth-flag.js +20 -0
- package/dist/resources/mcp-oauth.js +6 -1
- package/dist/resources/mcp-tools.js +53 -3
- package/dist/resources/memory-reflect-lib.js +201 -4
- package/dist/src/lib/scheduler-platform.js +363 -1
- package/dist/src/rem/scheduler.js +126 -20
- package/docs/deepseek-harness.md +110 -0
- package/docs/entity-vocabulary.md +15 -0
- package/docs/integrations.md +1 -0
- package/docs/mcp-clients.md +4 -0
- package/docs/notes/mcp-oauth-model2.md +52 -3
- package/package.json +5 -4
- package/schemas/memory.graphql +12 -0
- package/templates/bin/flair-federation-sync.sh.tmpl +8 -1
- package/templates/bin/flair-rem-nightly.sh.tmpl +8 -1
package/dist/rem/runner.js
CHANGED
|
@@ -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
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
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,
|
package/dist/rem/scheduler.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
32
|
-
export const SYSTEMD_SERVICE_PATH = resolve(SYSTEMD_USER_DIR,
|
|
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?.() ?? ""}
|
|
122
|
+
return ["launchctl", "print", `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`];
|
|
116
123
|
}
|
|
117
|
-
return ["systemctl", "--user", "is-active",
|
|
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
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
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(`
|
|
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 {
|
|
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",
|
|
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 {
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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
|
|
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
|
|
132
|
-
//
|
|
133
|
-
// is
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
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 {
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
}
|