@tpsdev-ai/flair 0.8.2 → 0.9.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/README.md +27 -0
- package/dist/cli.js +380 -5
- package/dist/rem/restore.js +205 -0
- package/dist/rem/runner.js +221 -0
- package/dist/rem/scheduler.js +240 -0
- package/dist/rem/snapshot.js +139 -0
- package/dist/resources/A2AAdapter.js +5 -0
- package/dist/resources/Admin.js +16 -0
- package/dist/resources/AdminInstance.js +50 -4
- package/dist/resources/AdminMemory.js +7 -1
- package/dist/resources/MemoryMaintenance.js +50 -22
- package/dist/resources/OAuth.js +22 -0
- package/dist/resources/ObservationCenter.js +4 -0
- package/dist/resources/SkillScan.js +32 -89
- package/dist/resources/admin-layout.js +25 -1
- package/dist/resources/auth-middleware.js +14 -0
- package/dist/resources/health.js +20 -1
- package/dist/resources/scan/skill-scanner.js +166 -0
- package/package.json +2 -1
- package/templates/bin/flair-rem-nightly.sh.tmpl +9 -0
- package/templates/launchd/dev.flair.rem.nightly.plist.tmpl +46 -0
- package/templates/systemd/flair-rem-nightly.service.tmpl +14 -0
- package/templates/systemd/flair-rem-nightly.timer.tmpl +10 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REM nightly runner — orchestrates the cycle.
|
|
3
|
+
*
|
|
4
|
+
* Per FLAIR-NIGHTLY-REM § 4, in order:
|
|
5
|
+
* 1. Pre-flight: check pause sentinel / FLAIR_REM_PAUSE env. Exit clean if paused.
|
|
6
|
+
* 2. Snapshot agent state (memory + soul) to ~/.flair/snapshots/<agent>/.
|
|
7
|
+
* 3. Maintenance — delegate to /MemoryMaintenance (same code path `flair rem light` uses).
|
|
8
|
+
* 4. (slice-2 next) trust-tier filter on input memories.
|
|
9
|
+
* 5. (slice-2 next) distillation — call /ReflectMemories, persist candidates.
|
|
10
|
+
* 6. Append a row to ~/.flair/logs/rem-nightly.jsonl.
|
|
11
|
+
*
|
|
12
|
+
* Status today:
|
|
13
|
+
* - Steps 1, 2, 6 shipped in slice-1 PR-1 (#414).
|
|
14
|
+
* - Step 3 (maintenance) ships in this PR — fills `archived`/`expired` in
|
|
15
|
+
* the audit row.
|
|
16
|
+
* - Steps 4, 5 are slice-2 follow-ups; require an in-process distillation
|
|
17
|
+
* LLM path (today `/ReflectMemories` returns a prompt for human/agent
|
|
18
|
+
* consumption, not server-side candidate generation).
|
|
19
|
+
*
|
|
20
|
+
* The audit row's `slice` field tells readers which steps populated which
|
|
21
|
+
* counts: `slice: "1"` rows have `archived`/`expired` undefined; `slice:
|
|
22
|
+
* "2-maintenance"` rows populate them; future slice-2 rows will populate
|
|
23
|
+
* `consolidated` and `candidates`.
|
|
24
|
+
*
|
|
25
|
+
* Pure dependency injection so the runner is unit-testable without Harper.
|
|
26
|
+
* The CLI wires the real `apiCall` + `pkgVersion`; tests pass stubs.
|
|
27
|
+
*/
|
|
28
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
29
|
+
import { dirname, resolve } from "node:path";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import { createSnapshot } from "./snapshot.js";
|
|
32
|
+
export const REM_PAUSE_FLAG = resolve(homedir(), ".flair", "rem.paused");
|
|
33
|
+
export const REM_NIGHTLY_LOG = resolve(homedir(), ".flair", "logs", "rem-nightly.jsonl");
|
|
34
|
+
function readPauseSentinel(path) {
|
|
35
|
+
try {
|
|
36
|
+
const contents = readFileSync(path, "utf-8");
|
|
37
|
+
// Existence alone is enough; the body is just a touch-timestamp.
|
|
38
|
+
return contents.length >= 0;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function appendLogRow(logPath, row) {
|
|
45
|
+
const dir = dirname(logPath);
|
|
46
|
+
if (!existsSync(dir))
|
|
47
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
48
|
+
appendFileSync(logPath, JSON.stringify(row) + "\n", { mode: 0o600 });
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Coerces a Harper REST list response into a flat array. The /Memory and
|
|
52
|
+
* /Soul resources can return either an array directly or a paginated shape
|
|
53
|
+
* `{ results: [], items: [] }` depending on path/options.
|
|
54
|
+
*/
|
|
55
|
+
function asArray(raw) {
|
|
56
|
+
if (Array.isArray(raw))
|
|
57
|
+
return raw;
|
|
58
|
+
if (raw && typeof raw === "object") {
|
|
59
|
+
const obj = raw;
|
|
60
|
+
if (Array.isArray(obj.results))
|
|
61
|
+
return obj.results;
|
|
62
|
+
if (Array.isArray(obj.items))
|
|
63
|
+
return obj.items;
|
|
64
|
+
}
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Counts pending memory candidates for the agent.
|
|
69
|
+
*
|
|
70
|
+
* Falls back to a `search_by_conditions` POST that mirrors the pattern
|
|
71
|
+
* `flair rem candidates` uses. Returns 0 on any error — the runner should
|
|
72
|
+
* not fail the cycle just because the candidate count couldn't be sampled.
|
|
73
|
+
*/
|
|
74
|
+
async function fetchPendingCandidateCount(api, agentId) {
|
|
75
|
+
try {
|
|
76
|
+
const result = await api("POST", "/MemoryCandidate/search_by_conditions", {
|
|
77
|
+
operator: "and",
|
|
78
|
+
conditions: [
|
|
79
|
+
{ search_attribute: "agentId", search_type: "equals", search_value: agentId },
|
|
80
|
+
{ search_attribute: "status", search_type: "equals", search_value: "pending" },
|
|
81
|
+
],
|
|
82
|
+
get_attributes: ["id"],
|
|
83
|
+
});
|
|
84
|
+
const rows = asArray(result);
|
|
85
|
+
return rows.length;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Runs one nightly cycle for the given agent. See module header for steps.
|
|
93
|
+
* Pure orchestration; all I/O goes through injected dependencies.
|
|
94
|
+
*/
|
|
95
|
+
export async function runNightlyCycle(opts) {
|
|
96
|
+
const startedAt = opts.nowOverride ?? new Date();
|
|
97
|
+
const startedMs = startedAt.getTime();
|
|
98
|
+
const logPath = opts.logPath ?? REM_NIGHTLY_LOG;
|
|
99
|
+
const pauseFlagPath = opts.pauseFlagPath ?? REM_PAUSE_FLAG;
|
|
100
|
+
const envPaused = opts.envPaused ?? process.env.FLAIR_REM_PAUSE === "1";
|
|
101
|
+
const baseRow = {
|
|
102
|
+
agentId: opts.agentId,
|
|
103
|
+
runAt: startedAt.toISOString(),
|
|
104
|
+
slice: "1",
|
|
105
|
+
errors: [],
|
|
106
|
+
};
|
|
107
|
+
// Step 1: pre-flight (pause)
|
|
108
|
+
if (envPaused || (existsSync(pauseFlagPath) && readPauseSentinel(pauseFlagPath))) {
|
|
109
|
+
const row = {
|
|
110
|
+
...baseRow,
|
|
111
|
+
status: "paused",
|
|
112
|
+
durationMs: Date.now() - startedMs,
|
|
113
|
+
errors: [],
|
|
114
|
+
};
|
|
115
|
+
appendLogRow(logPath, row);
|
|
116
|
+
return { status: "paused", logRow: row };
|
|
117
|
+
}
|
|
118
|
+
const errors = [];
|
|
119
|
+
// Step 2: snapshot
|
|
120
|
+
let snapshotPath;
|
|
121
|
+
let memoryCount = 0;
|
|
122
|
+
let soulCount = 0;
|
|
123
|
+
let pendingCandidates = 0;
|
|
124
|
+
try {
|
|
125
|
+
// Fetch agent data
|
|
126
|
+
const memoriesRaw = await opts.apiCall("GET", `/Memory?agentId=${encodeURIComponent(opts.agentId)}`);
|
|
127
|
+
const memories = asArray(memoriesRaw);
|
|
128
|
+
memoryCount = memories.length;
|
|
129
|
+
const soulRaw = await opts.apiCall("GET", `/Soul?agentId=${encodeURIComponent(opts.agentId)}`);
|
|
130
|
+
const souls = asArray(soulRaw);
|
|
131
|
+
soulCount = souls.length;
|
|
132
|
+
// For the snapshot's soul.json: keep the full multi-row shape if there
|
|
133
|
+
// are multiple souls (different keys), or unwrap a single-row response.
|
|
134
|
+
const soulForSnapshot = souls.length === 1 ? souls[0] : souls.length > 1 ? souls : null;
|
|
135
|
+
pendingCandidates = await fetchPendingCandidateCount(opts.apiCall, opts.agentId);
|
|
136
|
+
if (!opts.dryRun) {
|
|
137
|
+
const created = await createSnapshot({
|
|
138
|
+
agentId: opts.agentId,
|
|
139
|
+
flairVersion: opts.flairVersion,
|
|
140
|
+
memories,
|
|
141
|
+
soul: soulForSnapshot,
|
|
142
|
+
pendingCandidateCount: pendingCandidates,
|
|
143
|
+
rootOverride: opts.snapshotRoot,
|
|
144
|
+
nowOverride: opts.nowOverride,
|
|
145
|
+
});
|
|
146
|
+
snapshotPath = created.path;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
errors.push(`snapshot: ${err?.message ?? String(err)}`);
|
|
151
|
+
const row = {
|
|
152
|
+
...baseRow,
|
|
153
|
+
status: "failed",
|
|
154
|
+
memoryCount,
|
|
155
|
+
soulCount,
|
|
156
|
+
pendingCandidates,
|
|
157
|
+
durationMs: Date.now() - startedMs,
|
|
158
|
+
errors,
|
|
159
|
+
};
|
|
160
|
+
appendLogRow(logPath, row);
|
|
161
|
+
return { status: "failed", logRow: row };
|
|
162
|
+
}
|
|
163
|
+
// Step 3: maintenance — soft-delete expired + soft-archive stale.
|
|
164
|
+
// Delegates to /MemoryMaintenance (same endpoint `flair rem light` uses).
|
|
165
|
+
// In dry-run mode the snapshot wasn't written, but we still want to know
|
|
166
|
+
// what maintenance WOULD do — POST with dryRun=true so the response counts
|
|
167
|
+
// are accurate without mutating state.
|
|
168
|
+
let archived = 0;
|
|
169
|
+
let expired = 0;
|
|
170
|
+
let sliceLabel = "2-maintenance";
|
|
171
|
+
try {
|
|
172
|
+
const maintRaw = await opts.apiCall("POST", "/MemoryMaintenance", {
|
|
173
|
+
agentId: opts.agentId,
|
|
174
|
+
dryRun: opts.dryRun ?? false,
|
|
175
|
+
});
|
|
176
|
+
if (maintRaw && typeof maintRaw === "object") {
|
|
177
|
+
const obj = maintRaw;
|
|
178
|
+
if (obj.error) {
|
|
179
|
+
// /MemoryMaintenance returned { error: "..." } — treat as failure.
|
|
180
|
+
throw new Error(String(obj.error));
|
|
181
|
+
}
|
|
182
|
+
expired = typeof obj.expired === "number" ? obj.expired : 0;
|
|
183
|
+
archived = typeof obj.archived === "number" ? obj.archived : 0;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
errors.push(`maintenance: ${err?.message ?? String(err)}`);
|
|
188
|
+
const row = {
|
|
189
|
+
...baseRow,
|
|
190
|
+
slice: sliceLabel,
|
|
191
|
+
status: "failed",
|
|
192
|
+
snapshotPath,
|
|
193
|
+
memoryCount,
|
|
194
|
+
soulCount,
|
|
195
|
+
pendingCandidates,
|
|
196
|
+
durationMs: Date.now() - startedMs,
|
|
197
|
+
errors,
|
|
198
|
+
};
|
|
199
|
+
appendLogRow(logPath, row);
|
|
200
|
+
return { status: "failed", logRow: row, snapshotPath };
|
|
201
|
+
}
|
|
202
|
+
// Step 6: log
|
|
203
|
+
const row = {
|
|
204
|
+
...baseRow,
|
|
205
|
+
slice: sliceLabel,
|
|
206
|
+
status: opts.dryRun ? "dry-run" : "completed",
|
|
207
|
+
dryRun: opts.dryRun || undefined,
|
|
208
|
+
snapshotPath,
|
|
209
|
+
memoryCount,
|
|
210
|
+
soulCount,
|
|
211
|
+
pendingCandidates,
|
|
212
|
+
archived,
|
|
213
|
+
expired,
|
|
214
|
+
durationMs: Date.now() - startedMs,
|
|
215
|
+
errors,
|
|
216
|
+
// `consolidated` and `candidates` populate when slice-2 PR-4 (distillation)
|
|
217
|
+
// lands; today they remain undefined so the row is honest about what ran.
|
|
218
|
+
};
|
|
219
|
+
appendLogRow(logPath, row);
|
|
220
|
+
return { status: row.status, logRow: row, snapshotPath };
|
|
221
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REM nightly scheduler — platform-native scheduler install/uninstall.
|
|
3
|
+
*
|
|
4
|
+
* Per FLAIR-NIGHTLY-REM § 3 (`flair rem nightly enable|disable`). Renders
|
|
5
|
+
* launchd plist (macOS) or systemd timer+service (Linux) from templates,
|
|
6
|
+
* deploys a shim script to `~/.flair/bin/flair-rem-nightly`, and loads the
|
|
7
|
+
* job into the user-session scheduler.
|
|
8
|
+
*
|
|
9
|
+
* Templates use `{{KEY}}` placeholders — single-pass substitution. The full
|
|
10
|
+
* placeholder set is enumerated in `interface SchedulerSubstitutions` so
|
|
11
|
+
* adding a new key requires touching both this module and the template.
|
|
12
|
+
*
|
|
13
|
+
* No daemon code lives here — the scheduler invokes the shim, the shim
|
|
14
|
+
* invokes `flair rem nightly run-once`, the runner module does the work.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, rmSync } from "node:fs";
|
|
17
|
+
import { resolve, dirname } from "node:path";
|
|
18
|
+
import { homedir, platform } from "node:os";
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
export const SHIM_PATH_DEFAULT = resolve(homedir(), ".flair", "bin", "flair-rem-nightly");
|
|
22
|
+
export const LAUNCHD_PLIST_PATH = resolve(homedir(), "Library", "LaunchAgents", "dev.flair.rem.nightly.plist");
|
|
23
|
+
export const SYSTEMD_USER_DIR = resolve(homedir(), ".config", "systemd", "user");
|
|
24
|
+
export const SYSTEMD_TIMER_PATH = resolve(SYSTEMD_USER_DIR, "flair-rem-nightly.timer");
|
|
25
|
+
export const SYSTEMD_SERVICE_PATH = resolve(SYSTEMD_USER_DIR, "flair-rem-nightly.service");
|
|
26
|
+
function detectPlatform(override) {
|
|
27
|
+
if (override)
|
|
28
|
+
return override;
|
|
29
|
+
const p = platform();
|
|
30
|
+
if (p === "darwin")
|
|
31
|
+
return "darwin";
|
|
32
|
+
if (p === "linux")
|
|
33
|
+
return "linux";
|
|
34
|
+
throw new Error(`unsupported platform for REM nightly scheduler: ${p} (only darwin and linux)`);
|
|
35
|
+
}
|
|
36
|
+
function defaultTemplateRoot() {
|
|
37
|
+
// Templates live alongside dist/ in the published package and alongside
|
|
38
|
+
// src/rem/ in the source tree. Walk up from this file until we find
|
|
39
|
+
// a directory containing templates/.
|
|
40
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
const candidates = [
|
|
42
|
+
resolve(here, "..", "..", "templates"),
|
|
43
|
+
resolve(here, "..", "..", "..", "templates"),
|
|
44
|
+
resolve(here, "..", "templates"),
|
|
45
|
+
];
|
|
46
|
+
for (const c of candidates) {
|
|
47
|
+
if (existsSync(c))
|
|
48
|
+
return c;
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`unable to locate templates directory (looked in: ${candidates.join(", ")})`);
|
|
51
|
+
}
|
|
52
|
+
export function renderTemplate(text, subs) {
|
|
53
|
+
return text.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => {
|
|
54
|
+
const value = subs[key];
|
|
55
|
+
if (value === undefined)
|
|
56
|
+
throw new Error(`unknown template placeholder: ${key}`);
|
|
57
|
+
return String(value);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export function readTemplate(rootDir, relativePath) {
|
|
61
|
+
const full = resolve(rootDir, relativePath);
|
|
62
|
+
if (!existsSync(full)) {
|
|
63
|
+
throw new Error(`template not found: ${full}`);
|
|
64
|
+
}
|
|
65
|
+
return readFileSync(full, "utf-8");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Validates the hour:minute schedule. Throws on invalid input rather than
|
|
69
|
+
* silently coercing — surface bad input at the install boundary.
|
|
70
|
+
*/
|
|
71
|
+
function validateSchedule(hour, minute) {
|
|
72
|
+
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
|
|
73
|
+
throw new Error(`hour must be an integer 0-23, got ${hour}`);
|
|
74
|
+
}
|
|
75
|
+
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
|
76
|
+
throw new Error(`minute must be an integer 0-59, got ${minute}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function buildSubstitutions(opts, shimPath, flairBin) {
|
|
80
|
+
validateSchedule(opts.hour, opts.minute);
|
|
81
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(opts.agentId)) {
|
|
82
|
+
throw new Error(`invalid agent id: ${opts.agentId}`);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
FLAIR_BIN: flairBin,
|
|
86
|
+
SHIM_PATH: shimPath,
|
|
87
|
+
HOME: homedir(),
|
|
88
|
+
AGENT_ID: opts.agentId,
|
|
89
|
+
FLAIR_URL: opts.flairUrl,
|
|
90
|
+
HOUR: String(opts.hour),
|
|
91
|
+
HOUR_PAD: String(opts.hour).padStart(2, "0"),
|
|
92
|
+
MINUTE: String(opts.minute),
|
|
93
|
+
MINUTE_PAD: String(opts.minute).padStart(2, "0"),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function writeFileWithDir(path, contents, mode = 0o600) {
|
|
97
|
+
const dir = dirname(path);
|
|
98
|
+
if (!existsSync(dir))
|
|
99
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
100
|
+
writeFileSync(path, contents, { mode });
|
|
101
|
+
}
|
|
102
|
+
// 30s ceiling on launchctl/systemctl invocations so a hung service manager
|
|
103
|
+
// can't block the CLI indefinitely. Sherlock #415 follow-up.
|
|
104
|
+
const SPAWN_TIMEOUT_MS = 30_000;
|
|
105
|
+
function spawnReport(cmd) {
|
|
106
|
+
const r = spawnSync(cmd[0], cmd.slice(1), {
|
|
107
|
+
encoding: "buffer",
|
|
108
|
+
timeout: SPAWN_TIMEOUT_MS,
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
code: r.status,
|
|
112
|
+
stdout: r.stdout?.toString("utf-8") ?? "",
|
|
113
|
+
stderr: r.stderr?.toString("utf-8") ?? "",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Installs the platform-native scheduler entry and the shim script.
|
|
118
|
+
*
|
|
119
|
+
* macOS: writes ~/Library/LaunchAgents/dev.flair.rem.nightly.plist + bootstraps it via launchctl.
|
|
120
|
+
* Linux: writes ~/.config/systemd/user/flair-rem-nightly.{timer,service} + enables the timer.
|
|
121
|
+
*
|
|
122
|
+
* In both cases, also deploys ~/.flair/bin/flair-rem-nightly as the shim
|
|
123
|
+
* the scheduler invokes.
|
|
124
|
+
*/
|
|
125
|
+
export function enableScheduler(opts) {
|
|
126
|
+
const plat = detectPlatform(opts.platformOverride);
|
|
127
|
+
const flairBin = opts.flairBin ?? process.argv[1] ?? "flair";
|
|
128
|
+
const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
|
|
129
|
+
const templateRoot = opts.templateRootOverride ?? defaultTemplateRoot();
|
|
130
|
+
const subs = buildSubstitutions(opts, shimPath, flairBin);
|
|
131
|
+
// 1. Deploy the shim (always — both platforms invoke it).
|
|
132
|
+
const shimContents = renderTemplate(readTemplate(templateRoot, "bin/flair-rem-nightly.sh.tmpl"), subs);
|
|
133
|
+
writeFileWithDir(shimPath, shimContents, 0o700);
|
|
134
|
+
chmodSync(shimPath, 0o700);
|
|
135
|
+
// 2. Write the scheduler entry.
|
|
136
|
+
if (plat === "darwin") {
|
|
137
|
+
const plistPath = opts.launchdPlistOverride ?? LAUNCHD_PLIST_PATH;
|
|
138
|
+
const plistContents = renderTemplate(readTemplate(templateRoot, "launchd/dev.flair.rem.nightly.plist.tmpl"), subs);
|
|
139
|
+
writeFileWithDir(plistPath, plistContents, 0o600);
|
|
140
|
+
const loadCommand = ["launchctl", "bootstrap", `gui/${process.getuid?.() ?? ""}`, plistPath];
|
|
141
|
+
let loadResult;
|
|
142
|
+
if (!opts.skipLoad) {
|
|
143
|
+
// Bootout first in case a prior install left the job loaded.
|
|
144
|
+
spawnReport(["launchctl", "bootout", `gui/${process.getuid?.() ?? ""}`, plistPath]);
|
|
145
|
+
loadResult = spawnReport(loadCommand);
|
|
146
|
+
}
|
|
147
|
+
return { platform: plat, shimPath, schedulerPath: plistPath, loadCommand, loadResult };
|
|
148
|
+
}
|
|
149
|
+
// Linux: systemd user units.
|
|
150
|
+
const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
|
|
151
|
+
const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
|
|
152
|
+
const serviceContents = renderTemplate(readTemplate(templateRoot, "systemd/flair-rem-nightly.service.tmpl"), subs);
|
|
153
|
+
const timerContents = renderTemplate(readTemplate(templateRoot, "systemd/flair-rem-nightly.timer.tmpl"), subs);
|
|
154
|
+
writeFileWithDir(servicePath, serviceContents, 0o600);
|
|
155
|
+
writeFileWithDir(timerPath, timerContents, 0o600);
|
|
156
|
+
const loadCommand = ["systemctl", "--user", "enable", "--now", "flair-rem-nightly.timer"];
|
|
157
|
+
let loadResult;
|
|
158
|
+
if (!opts.skipLoad) {
|
|
159
|
+
spawnReport(["systemctl", "--user", "daemon-reload"]);
|
|
160
|
+
loadResult = spawnReport(loadCommand);
|
|
161
|
+
}
|
|
162
|
+
return { platform: plat, shimPath, schedulerPath: timerPath, loadCommand, loadResult };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Removes the scheduler entry. Audit log + snapshots are preserved.
|
|
166
|
+
*/
|
|
167
|
+
export function disableScheduler(opts = {}) {
|
|
168
|
+
const plat = detectPlatform(opts.platformOverride);
|
|
169
|
+
const removed = [];
|
|
170
|
+
if (plat === "darwin") {
|
|
171
|
+
const plistPath = opts.launchdPlistOverride ?? LAUNCHD_PLIST_PATH;
|
|
172
|
+
const unloadCommand = ["launchctl", "bootout", `gui/${process.getuid?.() ?? ""}`, plistPath];
|
|
173
|
+
let unloadResult;
|
|
174
|
+
if (existsSync(plistPath)) {
|
|
175
|
+
if (!opts.skipUnload) {
|
|
176
|
+
unloadResult = spawnReport(unloadCommand);
|
|
177
|
+
}
|
|
178
|
+
rmSync(plistPath, { force: true });
|
|
179
|
+
removed.push(plistPath);
|
|
180
|
+
}
|
|
181
|
+
if (opts.removeShim) {
|
|
182
|
+
const shim = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
|
|
183
|
+
if (existsSync(shim)) {
|
|
184
|
+
rmSync(shim, { force: true });
|
|
185
|
+
removed.push(shim);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { platform: plat, removed, unloadCommand, unloadResult };
|
|
189
|
+
}
|
|
190
|
+
const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
|
|
191
|
+
const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
|
|
192
|
+
const unloadCommand = ["systemctl", "--user", "disable", "--now", "flair-rem-nightly.timer"];
|
|
193
|
+
let unloadResult;
|
|
194
|
+
if (existsSync(timerPath) || existsSync(servicePath)) {
|
|
195
|
+
if (!opts.skipUnload) {
|
|
196
|
+
unloadResult = spawnReport(unloadCommand);
|
|
197
|
+
spawnReport(["systemctl", "--user", "daemon-reload"]);
|
|
198
|
+
}
|
|
199
|
+
if (existsSync(timerPath)) {
|
|
200
|
+
rmSync(timerPath, { force: true });
|
|
201
|
+
removed.push(timerPath);
|
|
202
|
+
}
|
|
203
|
+
if (existsSync(servicePath)) {
|
|
204
|
+
rmSync(servicePath, { force: true });
|
|
205
|
+
removed.push(servicePath);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (opts.removeShim) {
|
|
209
|
+
const shim = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
|
|
210
|
+
if (existsSync(shim)) {
|
|
211
|
+
rmSync(shim, { force: true });
|
|
212
|
+
removed.push(shim);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { platform: plat, removed, unloadCommand, unloadResult };
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Reports whether the scheduler is installed. Filesystem-only — does not
|
|
219
|
+
* shell out to launchctl/systemctl to query active state. The Health
|
|
220
|
+
* endpoint already does that via existence checks; same approach here.
|
|
221
|
+
*/
|
|
222
|
+
export function schedulerStatus(opts = {}) {
|
|
223
|
+
const plat = detectPlatform(opts.platformOverride);
|
|
224
|
+
if (plat === "darwin") {
|
|
225
|
+
return {
|
|
226
|
+
platform: plat,
|
|
227
|
+
installed: existsSync(LAUNCHD_PLIST_PATH),
|
|
228
|
+
schedulerPath: LAUNCHD_PLIST_PATH,
|
|
229
|
+
shimPath: SHIM_PATH_DEFAULT,
|
|
230
|
+
shimExists: existsSync(SHIM_PATH_DEFAULT),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
platform: plat,
|
|
235
|
+
installed: existsSync(SYSTEMD_TIMER_PATH) && existsSync(SYSTEMD_SERVICE_PATH),
|
|
236
|
+
schedulerPath: SYSTEMD_TIMER_PATH,
|
|
237
|
+
shimPath: SHIM_PATH_DEFAULT,
|
|
238
|
+
shimExists: existsSync(SHIM_PATH_DEFAULT),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REM snapshot module — pre-cycle snapshot + listing + restore extraction.
|
|
3
|
+
*
|
|
4
|
+
* Per FLAIR-NIGHTLY-REM § 4 step 2 and § 9. Produces tar.gz archives at
|
|
5
|
+
* ~/.flair/snapshots/<agentId>/<ISO-timestamp>.tar.gz containing:
|
|
6
|
+
* - memories.jsonl — one Memory row per line
|
|
7
|
+
* - soul.json — Soul row (or null)
|
|
8
|
+
* - metadata.json — agent id, run id, flair version, counts
|
|
9
|
+
*
|
|
10
|
+
* Mirrors the `flair session snapshot` pattern (tar.gz, 600 perms, agent-
|
|
11
|
+
* rooted under ~/.flair/snapshots/). Pure filesystem — the caller fetches
|
|
12
|
+
* the data via the Harper HTTP API and passes it in.
|
|
13
|
+
*
|
|
14
|
+
* Used by:
|
|
15
|
+
* - `flair rem snapshot list`
|
|
16
|
+
* - `flair rem restore <date>`
|
|
17
|
+
* - The nightly runner (slice-1 follow-on)
|
|
18
|
+
*/
|
|
19
|
+
import { mkdirSync, writeFileSync, statSync, chmodSync, rmSync, existsSync, readdirSync } from "node:fs";
|
|
20
|
+
import { resolve } from "node:path";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
|
|
23
|
+
export const SNAPSHOT_ROOT = resolve(homedir(), ".flair", "snapshots");
|
|
24
|
+
/** Validates the agent id and returns its snapshot directory. */
|
|
25
|
+
export function remSnapshotDir(agent) {
|
|
26
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(agent)) {
|
|
27
|
+
throw new Error(`invalid agent id: ${agent}`);
|
|
28
|
+
}
|
|
29
|
+
return resolve(SNAPSHOT_ROOT, agent);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Creates a tar.gz snapshot at <root>/<agentId>/<ISO-timestamp>.tar.gz.
|
|
33
|
+
* Returns the path, byte size, and metadata embedded in the archive.
|
|
34
|
+
*
|
|
35
|
+
* Tarball perms: 0600 (owner-only).
|
|
36
|
+
*/
|
|
37
|
+
export async function createSnapshot(opts) {
|
|
38
|
+
const now = opts.nowOverride ?? new Date();
|
|
39
|
+
const isoFull = now.toISOString().replace(/[:.]/g, "-");
|
|
40
|
+
const root = opts.rootOverride ?? SNAPSHOT_ROOT;
|
|
41
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(opts.agentId)) {
|
|
42
|
+
throw new Error(`invalid agent id: ${opts.agentId}`);
|
|
43
|
+
}
|
|
44
|
+
const dir = resolve(root, opts.agentId);
|
|
45
|
+
if (!existsSync(dir))
|
|
46
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
47
|
+
const tarballName = `${isoFull}.tar.gz`;
|
|
48
|
+
const tarballPath = resolve(dir, tarballName);
|
|
49
|
+
const meta = {
|
|
50
|
+
agentId: opts.agentId,
|
|
51
|
+
runId: opts.runId ?? `rem-nightly-${isoFull}`,
|
|
52
|
+
flairVersion: opts.flairVersion,
|
|
53
|
+
createdAt: now.toISOString(),
|
|
54
|
+
memoryCount: opts.memories.length,
|
|
55
|
+
pendingCandidateCount: opts.pendingCandidateCount,
|
|
56
|
+
soulPresent: opts.soul !== null,
|
|
57
|
+
};
|
|
58
|
+
const tmpDir = resolve(dir, `.tmp-${process.pid}-${Date.now()}`);
|
|
59
|
+
mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
|
|
60
|
+
try {
|
|
61
|
+
const memoryLines = opts.memories.map((m) => JSON.stringify(m)).join("\n");
|
|
62
|
+
writeFileSync(resolve(tmpDir, "memories.jsonl"), memoryLines + (memoryLines.length ? "\n" : ""), { mode: 0o600 });
|
|
63
|
+
writeFileSync(resolve(tmpDir, "soul.json"), JSON.stringify(opts.soul, null, 2) + "\n", { mode: 0o600 });
|
|
64
|
+
writeFileSync(resolve(tmpDir, "metadata.json"), JSON.stringify(meta, null, 2) + "\n", { mode: 0o600 });
|
|
65
|
+
await tarCreate({ gzip: true, cwd: tmpDir, file: tarballPath, portable: true }, ["memories.jsonl", "soul.json", "metadata.json"]);
|
|
66
|
+
chmodSync(tarballPath, 0o600);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
return { path: tarballPath, size: statSync(tarballPath).size, meta };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Lists snapshots at <root>/<agent>/*.tar.gz. Returns rows sorted by mtime
|
|
75
|
+
* descending. Pass an agent filter to restrict to a single agent.
|
|
76
|
+
*/
|
|
77
|
+
export function listSnapshots(agentFilter, rootOverride) {
|
|
78
|
+
const root = rootOverride ?? SNAPSHOT_ROOT;
|
|
79
|
+
if (!existsSync(root))
|
|
80
|
+
return [];
|
|
81
|
+
let agents;
|
|
82
|
+
if (agentFilter) {
|
|
83
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(agentFilter)) {
|
|
84
|
+
throw new Error(`invalid agent id: ${agentFilter}`);
|
|
85
|
+
}
|
|
86
|
+
agents = [agentFilter];
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
agents = readdirSync(root).filter((d) => {
|
|
90
|
+
try {
|
|
91
|
+
return statSync(resolve(root, d)).isDirectory();
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const rows = [];
|
|
99
|
+
for (const a of agents) {
|
|
100
|
+
const dir = resolve(root, a);
|
|
101
|
+
if (!existsSync(dir))
|
|
102
|
+
continue;
|
|
103
|
+
for (const f of readdirSync(dir)) {
|
|
104
|
+
if (!f.endsWith(".tar.gz"))
|
|
105
|
+
continue;
|
|
106
|
+
const p = resolve(dir, f);
|
|
107
|
+
const s = statSync(p);
|
|
108
|
+
rows.push({ agent: a, file: f, path: p, size: s.size, mtime: s.mtime.toISOString() });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
rows.sort((a, b) => b.mtime.localeCompare(a.mtime));
|
|
112
|
+
return rows;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Extracts a snapshot tar.gz into a target directory. Refuses to extract
|
|
116
|
+
* over an existing directory — operator must pass a clean target.
|
|
117
|
+
*
|
|
118
|
+
* In dry-run mode, returns the tarball entry list without writing anything.
|
|
119
|
+
*/
|
|
120
|
+
export async function extractSnapshot(opts) {
|
|
121
|
+
if (!existsSync(opts.snapshotPath)) {
|
|
122
|
+
throw new Error(`snapshot does not exist: ${opts.snapshotPath}`);
|
|
123
|
+
}
|
|
124
|
+
const entries = [];
|
|
125
|
+
await tarList({
|
|
126
|
+
file: opts.snapshotPath,
|
|
127
|
+
onReadEntry: (e) => entries.push({ path: e.path, size: e.size ?? 0 }),
|
|
128
|
+
});
|
|
129
|
+
if (opts.dryRun) {
|
|
130
|
+
return { entries };
|
|
131
|
+
}
|
|
132
|
+
const targetDir = opts.targetDir ?? `${opts.snapshotPath}.restored`;
|
|
133
|
+
if (existsSync(targetDir)) {
|
|
134
|
+
throw new Error(`target directory already exists: ${targetDir}`);
|
|
135
|
+
}
|
|
136
|
+
mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
|
137
|
+
await tarExtract({ file: opts.snapshotPath, cwd: targetDir });
|
|
138
|
+
return { targetDir, entries };
|
|
139
|
+
}
|
|
@@ -265,6 +265,11 @@ function statusFromOrgEvent(event) {
|
|
|
265
265
|
null);
|
|
266
266
|
}
|
|
267
267
|
export class A2AAdapter extends Resource {
|
|
268
|
+
// A2A discovery surface — agent cards and adapter metadata are intentionally
|
|
269
|
+
// public so other agents/clients can discover this Flair as a memory peer.
|
|
270
|
+
// Handler enforces auth on actual A2A actions (POST below).
|
|
271
|
+
allowRead() { return true; }
|
|
272
|
+
allowCreate() { return true; }
|
|
268
273
|
async get() {
|
|
269
274
|
const host = process.env.FLAIR_PUBLIC_URL || "http://localhost:9926";
|
|
270
275
|
return new Response(JSON.stringify({
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Resource } from "@harperfast/harper";
|
|
2
|
+
/**
|
|
3
|
+
* GET /Admin — friendly redirect to /AdminDashboard.
|
|
4
|
+
*
|
|
5
|
+
* Operators bookmark or type the bare /Admin path. Without this resource
|
|
6
|
+
* they hit a 404 and assume the admin UI is broken. The dashboard is the
|
|
7
|
+
* canonical landing surface; redirect there.
|
|
8
|
+
*/
|
|
9
|
+
export class Admin extends Resource {
|
|
10
|
+
async get() {
|
|
11
|
+
return new Response("", {
|
|
12
|
+
status: 302,
|
|
13
|
+
headers: { Location: "/AdminDashboard" },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
}
|