@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.
- package/dist/build-info.json +6 -0
- package/dist/cli.js +479 -163
- package/dist/doctor-client.js +92 -11
- package/dist/install/global-bin-path.js +234 -0
- package/dist/lib/entity-vocab-cli.js +113 -0
- package/dist/lib/mcp-enable.js +12 -2
- package/dist/lib/scheduler-platform.js +153 -0
- package/dist/postinstall.cjs +88 -0
- package/dist/rem/runner.js +177 -10
- package/dist/rem/scheduler.js +15 -9
- 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/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-tools.js +53 -3
- package/dist/resources/memory-reflect-lib.js +201 -4
- package/dist/src/lib/scheduler-platform.js +153 -0
- package/dist/src/rem/scheduler.js +15 -9
- 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 +42 -0
- package/package.json +5 -4
- package/schemas/memory.graphql +12 -0
|
@@ -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
|
+
});
|
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
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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?.() ?? ""}
|
|
122
|
+
return ["launchctl", "print", `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`];
|
|
117
123
|
}
|
|
118
|
-
return ["systemctl", "--user", "is-active",
|
|
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?.() ?? ""}
|
|
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",
|
|
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:
|
|
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",
|
|
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",
|
|
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
|
}
|
package/dist/resources/Memory.js
CHANGED
|
@@ -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
|
-
|
|
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
|