@tpsdev-ai/flair 0.8.3 → 0.10.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 +36 -11
- package/dist/cli.js +1740 -377
- package/dist/rem/restore.js +265 -0
- package/dist/rem/runner.js +221 -0
- package/dist/rem/scheduler.js +240 -0
- package/dist/rem/snapshot.js +139 -0
- package/dist/render.js +168 -0
- package/dist/resources/A2AAdapter.js +35 -0
- package/dist/resources/Admin.js +16 -0
- package/dist/resources/AdminInstance.js +86 -4
- package/dist/resources/AdminMemory.js +7 -1
- package/dist/resources/Federation.js +64 -33
- 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 +23 -3
- package/dist/resources/federation-classify.js +29 -0
- package/dist/resources/health.js +10 -5
- package/dist/resources/scan/skill-scanner.js +166 -0
- package/package.json +2 -1
- package/schemas/federation.graphql +6 -3
- 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,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REM live replay — atomic state rewind from a snapshot tarball.
|
|
3
|
+
*
|
|
4
|
+
* Slice-2 PR-4. Implements the "every cycle is reversible" property:
|
|
5
|
+
* `flair rem restore <date> --apply` actually rewinds Harper state to the
|
|
6
|
+
* snapshot, not just extracts the tarball for inspection.
|
|
7
|
+
*
|
|
8
|
+
* Approach: client-side. The CLI sequentially calls existing `/Memory` and
|
|
9
|
+
* `/Soul` endpoints (DELETE current rows for the agent, PUT snapshot rows).
|
|
10
|
+
* No new server endpoint — keeps the auth surface unchanged and avoids the
|
|
11
|
+
* Harper body-size limit on uploading large memory exports inline.
|
|
12
|
+
*
|
|
13
|
+
* Reversibility-of-restore guarantee: before any destructive op, this
|
|
14
|
+
* module creates a pre-restore snapshot of the CURRENT state. If something
|
|
15
|
+
* fails mid-flight, the operator can restore from the pre-restore snapshot
|
|
16
|
+
* to roll back.
|
|
17
|
+
*
|
|
18
|
+
* Multi-agent restore (admin restoring another agent's state) is not in
|
|
19
|
+
* scope here; the operator's agent can only restore its own memories.
|
|
20
|
+
* Cross-agent restore is a 1.1+ feature.
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync, existsSync, rmSync, mkdtempSync } from "node:fs";
|
|
23
|
+
import { resolve } from "node:path";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { extractSnapshot, createSnapshot } from "./snapshot.js";
|
|
26
|
+
function asArray(raw) {
|
|
27
|
+
if (Array.isArray(raw))
|
|
28
|
+
return raw;
|
|
29
|
+
if (raw && typeof raw === "object") {
|
|
30
|
+
const obj = raw;
|
|
31
|
+
if (Array.isArray(obj.results))
|
|
32
|
+
return obj.results;
|
|
33
|
+
if (Array.isArray(obj.items))
|
|
34
|
+
return obj.items;
|
|
35
|
+
}
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
function parseJsonlSafe(text) {
|
|
39
|
+
if (!text.trim())
|
|
40
|
+
return [];
|
|
41
|
+
return text
|
|
42
|
+
.split("\n")
|
|
43
|
+
.filter((line) => line.trim().length > 0)
|
|
44
|
+
.map((line) => JSON.parse(line));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Applies a snapshot tarball to live Harper state for the given agent.
|
|
48
|
+
*
|
|
49
|
+
* Steps:
|
|
50
|
+
* 1. Extract the snapshot to a tmpdir.
|
|
51
|
+
* 2. Parse memories.jsonl + soul.json + metadata.json.
|
|
52
|
+
* 3. Verify metadata.agentId matches opts.agentId (prevents accidental
|
|
53
|
+
* cross-agent restore — the file might have been hand-copied).
|
|
54
|
+
* 4. Create a pre-restore snapshot of current state (skip in dry-run).
|
|
55
|
+
* 5. Fetch + delete current memories/souls for the agent (skip in dry-run).
|
|
56
|
+
* 6. PUT snapshot memories/souls back into Harper (skip in dry-run).
|
|
57
|
+
* 7. Return counts.
|
|
58
|
+
*
|
|
59
|
+
* On any error after step 4: the result reports `status: "failed"` with
|
|
60
|
+
* the pre-restore snapshot path included so the operator can roll back.
|
|
61
|
+
*/
|
|
62
|
+
export async function applySnapshot(opts) {
|
|
63
|
+
const errors = [];
|
|
64
|
+
const result = {
|
|
65
|
+
status: "completed",
|
|
66
|
+
agentId: opts.agentId,
|
|
67
|
+
snapshotPath: opts.snapshotPath,
|
|
68
|
+
deleted: { memories: 0, souls: 0 },
|
|
69
|
+
restored: { memories: 0, souls: 0 },
|
|
70
|
+
errors,
|
|
71
|
+
};
|
|
72
|
+
if (!existsSync(opts.snapshotPath)) {
|
|
73
|
+
errors.push(`snapshot does not exist: ${opts.snapshotPath}`);
|
|
74
|
+
result.status = "failed";
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
// 1+2. Extract and parse. mkdtempSync creates an empty dir; extractSnapshot
|
|
78
|
+
// refuses to extract into an existing dir, so we point it at a non-existing
|
|
79
|
+
// subdir of the tmp scratch space.
|
|
80
|
+
const tmpRoot = opts.tmpRootOverride ?? tmpdir();
|
|
81
|
+
const tmp = mkdtempSync(resolve(tmpRoot, "flair-rem-restore-"));
|
|
82
|
+
const extractTo = resolve(tmp, "snapshot");
|
|
83
|
+
let memories;
|
|
84
|
+
let souls;
|
|
85
|
+
let metadata;
|
|
86
|
+
try {
|
|
87
|
+
await extractSnapshot({ snapshotPath: opts.snapshotPath, targetDir: extractTo });
|
|
88
|
+
const memText = readFileSync(resolve(extractTo, "memories.jsonl"), "utf-8");
|
|
89
|
+
memories = parseJsonlSafe(memText);
|
|
90
|
+
const soulRaw = JSON.parse(readFileSync(resolve(extractTo, "soul.json"), "utf-8"));
|
|
91
|
+
souls = Array.isArray(soulRaw)
|
|
92
|
+
? soulRaw
|
|
93
|
+
: soulRaw && typeof soulRaw === "object"
|
|
94
|
+
? [soulRaw]
|
|
95
|
+
: [];
|
|
96
|
+
metadata = JSON.parse(readFileSync(resolve(extractTo, "metadata.json"), "utf-8"));
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
errors.push(`extract: ${err?.message ?? String(err)}`);
|
|
100
|
+
result.status = "failed";
|
|
101
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
// 3. Verify agent id matches. Hard-fail on missing OR mismatched —
|
|
105
|
+
// the previous `metadata.agentId && ...` short-circuited on missing,
|
|
106
|
+
// which means a snapshot crafted without metadata.agentId would bypass
|
|
107
|
+
// the cross-agent guard entirely. v0.9.0+ snapshots always write
|
|
108
|
+
// metadata.agentId (see src/rem/snapshot.ts), so the missing case can
|
|
109
|
+
// only originate from pre-v0.9.0 snapshots or external/hand-edited
|
|
110
|
+
// input — both of which we must reject.
|
|
111
|
+
if (!metadata.agentId) {
|
|
112
|
+
errors.push(`snapshot is missing metadata.agentId — refusing to restore (pre-v0.9.0 or untrusted snapshot)`);
|
|
113
|
+
result.status = "failed";
|
|
114
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
if (metadata.agentId !== opts.agentId) {
|
|
118
|
+
errors.push(`snapshot agentId (${metadata.agentId}) does not match target (${opts.agentId}) — refusing to restore cross-agent`);
|
|
119
|
+
result.status = "failed";
|
|
120
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
if (opts.dryRun) {
|
|
124
|
+
// In dry-run, report planned counts. Still fetch current state for
|
|
125
|
+
// accurate deleted-counts reporting.
|
|
126
|
+
try {
|
|
127
|
+
const currentMem = asArray(await opts.apiCall("GET", `/Memory?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
128
|
+
const currentSouls = asArray(await opts.apiCall("GET", `/Soul?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
129
|
+
result.deleted.memories = currentMem.length;
|
|
130
|
+
result.deleted.souls = currentSouls.length;
|
|
131
|
+
result.restored.memories = memories.length;
|
|
132
|
+
result.restored.souls = souls.length;
|
|
133
|
+
result.status = "dry-run";
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
errors.push(`fetch-current: ${err?.message ?? String(err)}`);
|
|
137
|
+
result.status = "failed";
|
|
138
|
+
}
|
|
139
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
// 4. Pre-restore snapshot of current state.
|
|
143
|
+
let currentMem;
|
|
144
|
+
let currentSouls;
|
|
145
|
+
try {
|
|
146
|
+
currentMem = asArray(await opts.apiCall("GET", `/Memory?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
147
|
+
currentSouls = asArray(await opts.apiCall("GET", `/Soul?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
148
|
+
const preRestore = await createSnapshot({
|
|
149
|
+
agentId: opts.agentId,
|
|
150
|
+
flairVersion: opts.flairVersion,
|
|
151
|
+
memories: currentMem,
|
|
152
|
+
soul: currentSouls.length === 1 ? currentSouls[0] : currentSouls.length > 1 ? currentSouls : null,
|
|
153
|
+
pendingCandidateCount: 0, // not load-bearing at restore time
|
|
154
|
+
rootOverride: opts.preRestoreSnapshotRoot,
|
|
155
|
+
runId: `rem-restore-pre-${(opts.nowOverride ?? new Date()).toISOString().replace(/[:.]/g, "-")}`,
|
|
156
|
+
nowOverride: opts.nowOverride,
|
|
157
|
+
});
|
|
158
|
+
result.preRestoreSnapshotPath = preRestore.path;
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
errors.push(`pre-restore-snapshot: ${err?.message ?? String(err)}`);
|
|
162
|
+
result.status = "failed";
|
|
163
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
// 5. Delete current memories + souls (sequential to keep error semantics).
|
|
167
|
+
for (const m of currentMem) {
|
|
168
|
+
if (!m?.id)
|
|
169
|
+
continue;
|
|
170
|
+
try {
|
|
171
|
+
await opts.apiCall("DELETE", `/Memory/${encodeURIComponent(String(m.id))}`);
|
|
172
|
+
result.deleted.memories++;
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
errors.push(`delete-memory ${m.id}: ${err?.message ?? String(err)}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const s of currentSouls) {
|
|
179
|
+
if (!s?.id)
|
|
180
|
+
continue;
|
|
181
|
+
try {
|
|
182
|
+
await opts.apiCall("DELETE", `/Soul/${encodeURIComponent(String(s.id))}`);
|
|
183
|
+
result.deleted.souls++;
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
errors.push(`delete-soul ${s.id}: ${err?.message ?? String(err)}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// 6. PUT snapshot rows.
|
|
190
|
+
for (const m of memories) {
|
|
191
|
+
if (!m?.id)
|
|
192
|
+
continue;
|
|
193
|
+
try {
|
|
194
|
+
await opts.apiCall("PUT", `/Memory/${encodeURIComponent(String(m.id))}`, m);
|
|
195
|
+
result.restored.memories++;
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
errors.push(`put-memory ${m.id}: ${err?.message ?? String(err)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const s of souls) {
|
|
202
|
+
if (!s?.id)
|
|
203
|
+
continue;
|
|
204
|
+
try {
|
|
205
|
+
await opts.apiCall("PUT", `/Soul/${encodeURIComponent(String(s.id))}`, s);
|
|
206
|
+
result.restored.souls++;
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
errors.push(`put-soul ${s.id}: ${err?.message ?? String(err)}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// 7. Verify post-restore state (default on; opt-out via verifyPostRestore=false).
|
|
213
|
+
// Catches silent failures: Harper schema coercion, 4xx responses the
|
|
214
|
+
// apiCall layer masked as ok, partial-DELETE leftovers. Per-ID diff,
|
|
215
|
+
// not just counts — count parity can hide simultaneous PUT+DELETE
|
|
216
|
+
// failures that wash out numerically. See ops-90dq.
|
|
217
|
+
if (opts.verifyPostRestore !== false) {
|
|
218
|
+
try {
|
|
219
|
+
const verifyMem = asArray(await opts.apiCall("GET", `/Memory?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
220
|
+
const verifySouls = asArray(await opts.apiCall("GET", `/Soul?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
221
|
+
const expectedMemoryIds = memories.filter((m) => m?.id).map((m) => String(m.id));
|
|
222
|
+
const expectedSoulIds = souls.filter((s) => s?.id).map((s) => String(s.id));
|
|
223
|
+
const actualMemoryIds = verifyMem.filter((m) => m?.id).map((m) => String(m.id));
|
|
224
|
+
const actualSoulIds = verifySouls.filter((s) => s?.id).map((s) => String(s.id));
|
|
225
|
+
const expMem = new Set(expectedMemoryIds);
|
|
226
|
+
const expSoul = new Set(expectedSoulIds);
|
|
227
|
+
const actMem = new Set(actualMemoryIds);
|
|
228
|
+
const actSoul = new Set(actualSoulIds);
|
|
229
|
+
const missingMemoryIds = [...expMem].filter((id) => !actMem.has(id));
|
|
230
|
+
const missingSoulIds = [...expSoul].filter((id) => !actSoul.has(id));
|
|
231
|
+
const extraMemoryIds = [...actMem].filter((id) => !expMem.has(id));
|
|
232
|
+
const extraSoulIds = [...actSoul].filter((id) => !expSoul.has(id));
|
|
233
|
+
result.verified = {
|
|
234
|
+
expectedMemoryIds,
|
|
235
|
+
expectedSoulIds,
|
|
236
|
+
actualMemoryIds,
|
|
237
|
+
actualSoulIds,
|
|
238
|
+
missingMemoryIds,
|
|
239
|
+
missingSoulIds,
|
|
240
|
+
extraMemoryIds,
|
|
241
|
+
extraSoulIds,
|
|
242
|
+
};
|
|
243
|
+
if (missingMemoryIds.length > 0) {
|
|
244
|
+
errors.push(`post-restore-verify: ${missingMemoryIds.length} memory rows missing (e.g. ${missingMemoryIds.slice(0, 3).join(", ")})`);
|
|
245
|
+
}
|
|
246
|
+
if (missingSoulIds.length > 0) {
|
|
247
|
+
errors.push(`post-restore-verify: ${missingSoulIds.length} soul rows missing (e.g. ${missingSoulIds.slice(0, 3).join(", ")})`);
|
|
248
|
+
}
|
|
249
|
+
if (extraMemoryIds.length > 0) {
|
|
250
|
+
errors.push(`post-restore-verify: ${extraMemoryIds.length} unexpected memory rows present (e.g. ${extraMemoryIds.slice(0, 3).join(", ")})`);
|
|
251
|
+
}
|
|
252
|
+
if (extraSoulIds.length > 0) {
|
|
253
|
+
errors.push(`post-restore-verify: ${extraSoulIds.length} unexpected soul rows present (e.g. ${extraSoulIds.slice(0, 3).join(", ")})`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
errors.push(`post-restore-verify: ${err?.message ?? String(err)}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
261
|
+
if (errors.length > 0) {
|
|
262
|
+
result.status = "failed";
|
|
263
|
+
}
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
@@ -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
|
+
}
|