@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.
@@ -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
+ }