@tpsdev-ai/flair 0.8.3 → 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/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
package/README.md
CHANGED
|
@@ -38,6 +38,12 @@ Most agent memory systems store *facts*. Flair stores facts AND the agent's iden
|
|
|
38
38
|
|
|
39
39
|
`flair memory add` writes a memory; `flair search` finds it by meaning, not keywords. The same memory is then visible to every harness in the catalog above.
|
|
40
40
|
|
|
41
|
+
### Same identity, every orchestrator
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
Same Ed25519 identity, same memory store, three different MCP-capable CLIs (Claude Code, Codex CLI, Gemini CLI). A memory written from one is immediately retrievable from the next. This is the load-bearing differentiation vs vendor-locked memory systems — your agent's identity and history aren't bound to a single orchestrator's runtime.
|
|
46
|
+
|
|
41
47
|
## How Flair compares
|
|
42
48
|
|
|
43
49
|
| | Flair | Mem0 | Honcho | Letta (MemGPT) | Built-ins (OAI/Anthropic/Google) |
|
|
@@ -61,6 +67,27 @@ The honest gaps:
|
|
|
61
67
|
|
|
62
68
|
If you need any of those specifically, use them. If you need crypto-pinned identity + federation + cross-orchestrator breadth + soul-as-a-feature — that's the gap Flair fills.
|
|
63
69
|
|
|
70
|
+
### Memory curation: vs Claude Dreams
|
|
71
|
+
|
|
72
|
+
Anthropic shipped [Claude Dreams](https://platform.claude.com/docs/en/managed-agents/dreams) (research preview, April 2026) — async pipeline that reads a memory store + session transcripts and produces a curated output store: duplicates merged, stale entries replaced, insights surfaced. Validates the category: agent memory accumulates drift and needs cleanup.
|
|
73
|
+
|
|
74
|
+
Flair ships an on-demand curation surface today: `flair rem rapid`. Scheduled nightly REM is in the [FLAIR-NIGHTLY-REM spec](specs/FLAIR-NIGHTLY-REM.md) and partially built.
|
|
75
|
+
|
|
76
|
+
- **`flair rem rapid`** (ships now) — on-demand reflection. `--focus {lessons_learned, patterns, decisions, errors}` mirrors Dreams' `instructions` parameter. Outputs *candidates*, not a wholesale store swap.
|
|
77
|
+
- **`flair rem candidates` / `flair rem promote <id> --rationale "<why>"` / `flair rem reject <id>`** (ship now) — review and promote distilled candidates with required rationale.
|
|
78
|
+
- **`flair rem nightly`** (planned, P0 for 1.0) — scheduled automation with pre-cycle snapshot, `rem restore <date>` rollback, trust-tier filtering. Spec'd; not yet implemented.
|
|
79
|
+
|
|
80
|
+
The substantive difference is the **promotion contract**:
|
|
81
|
+
|
|
82
|
+
| | Claude Dreams | Flair REM (today) |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| **Output** | New memory store — accept or discard | Staged candidates — per-candidate decision |
|
|
85
|
+
| **Promotion gate** | None — accept the whole store | `flair rem promote <id> --rationale "<why>"` |
|
|
86
|
+
| **Reversibility** | Input store is never modified (real safety property) | In-place modifications; nightly snapshot/restore is on the roadmap |
|
|
87
|
+
| **Where it runs** | Anthropic Managed Agents (SaaS, Anthropic models only) | Self-hosted, any model |
|
|
88
|
+
|
|
89
|
+
Dreams is easier to start with — one API call, and the input-never-modified contract gives you a clean rollback by simply not accepting the output. REM is the more granular surface — per-candidate decisions with required rationale — for operators who want to merge what's right and reject what's wrong on the same nightly cycle. Both are legitimate choices; the right one depends on whether you want store-level or candidate-level review.
|
|
90
|
+
|
|
64
91
|
## Why this exists
|
|
65
92
|
|
|
66
93
|
Every agent framework gives you chat history. None of them give you *identity*.
|
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import nacl from "tweetnacl";
|
|
|
4
4
|
import { load as parseYaml } from "js-yaml";
|
|
5
5
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, } from "node:fs";
|
|
6
6
|
import { homedir, hostname, tmpdir } from "node:os";
|
|
7
|
-
import { join, resolve, sep } from "node:path";
|
|
7
|
+
import { join, resolve, sep, dirname } from "node:path";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } from "node:crypto";
|
|
10
10
|
import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
|
|
@@ -2741,16 +2741,51 @@ federation
|
|
|
2741
2741
|
console.log("No peers configured. Use 'flair federation pair' to connect to a hub.");
|
|
2742
2742
|
}
|
|
2743
2743
|
else {
|
|
2744
|
-
|
|
2744
|
+
// Compute lastSync staleness so an operator can tell at a glance whether sync is current.
|
|
2745
|
+
const now = Date.now();
|
|
2746
|
+
const stale = (iso) => {
|
|
2747
|
+
if (!iso)
|
|
2748
|
+
return "never";
|
|
2749
|
+
const t = Date.parse(iso);
|
|
2750
|
+
if (!Number.isFinite(t))
|
|
2751
|
+
return "never";
|
|
2752
|
+
const ageMs = now - t;
|
|
2753
|
+
if (ageMs < 60_000)
|
|
2754
|
+
return "<1m ago";
|
|
2755
|
+
if (ageMs < 3_600_000)
|
|
2756
|
+
return `${Math.floor(ageMs / 60_000)}m ago`;
|
|
2757
|
+
if (ageMs < 86_400_000)
|
|
2758
|
+
return `${Math.floor(ageMs / 3_600_000)}h ago`;
|
|
2759
|
+
return `${Math.floor(ageMs / 86_400_000)}d ago`;
|
|
2760
|
+
};
|
|
2761
|
+
console.log(`${"Peer".padEnd(20)} ${"Role".padEnd(8)} ${"Status".padEnd(14)} ${"Last Sync".padEnd(14)} Relay`);
|
|
2745
2762
|
console.log("─".repeat(80));
|
|
2746
2763
|
for (const p of peers) {
|
|
2747
|
-
|
|
2748
|
-
|
|
2764
|
+
console.log(`${p.id.padEnd(20)} ${(p.role ?? "—").padEnd(8)} ${(p.status ?? "—").padEnd(14)} ${stale(p.lastSyncAt).padEnd(14)} ${p.relayOnly ? "yes" : "no"}`);
|
|
2765
|
+
}
|
|
2766
|
+
const haveStale = peers.some((p) => {
|
|
2767
|
+
if (!p.lastSyncAt)
|
|
2768
|
+
return true;
|
|
2769
|
+
return now - Date.parse(p.lastSyncAt) > 86_400_000;
|
|
2770
|
+
});
|
|
2771
|
+
if (haveStale) {
|
|
2772
|
+
console.log();
|
|
2773
|
+
console.log("⚠ One or more peers haven't synced in >24h. Run 'flair federation sync' or check the launchd watchdog.");
|
|
2749
2774
|
}
|
|
2750
2775
|
}
|
|
2751
2776
|
}
|
|
2752
2777
|
catch (err) {
|
|
2753
|
-
|
|
2778
|
+
// Better UX on the common auth failure: tell the user what to set.
|
|
2779
|
+
const msg = String(err.message ?? err);
|
|
2780
|
+
if (msg.includes("missing_or_invalid_authorization") || msg.includes("401")) {
|
|
2781
|
+
console.error("Error: federation status requires auth.");
|
|
2782
|
+
console.error(" Set one of:");
|
|
2783
|
+
console.error(" FLAIR_AGENT_ID=<your-agent-id> (Ed25519 — uses ~/.flair/keys/<id>.key)");
|
|
2784
|
+
console.error(" FLAIR_ADMIN_PASS=<admin-password> (admin Basic auth, remote targets)");
|
|
2785
|
+
console.error(" FLAIR_TOKEN=<bearer> (legacy)");
|
|
2786
|
+
process.exit(1);
|
|
2787
|
+
}
|
|
2788
|
+
console.error(`Error: ${msg}`);
|
|
2754
2789
|
process.exit(1);
|
|
2755
2790
|
}
|
|
2756
2791
|
});
|
|
@@ -3991,6 +4026,346 @@ rem
|
|
|
3991
4026
|
process.exit(1);
|
|
3992
4027
|
}
|
|
3993
4028
|
});
|
|
4029
|
+
// ─── flair rem nightly run-once ──────────────────────────────────────────────
|
|
4030
|
+
// Slice 1 of FLAIR-NIGHTLY-REM § 3. Manually invokes the nightly cycle code
|
|
4031
|
+
// path — same module the scheduler will call in PR-2. Useful for:
|
|
4032
|
+
// - First-time operators verifying the cycle works before turning on the
|
|
4033
|
+
// scheduled timer.
|
|
4034
|
+
// - The dry-run-first-run guard (spec § 10) when the scheduler isn't yet
|
|
4035
|
+
// installed.
|
|
4036
|
+
// - Debugging a stale snapshot or audit row.
|
|
4037
|
+
//
|
|
4038
|
+
// `nightly enable` / `disable` / `status` land in PR-2 (scheduler templates).
|
|
4039
|
+
const remNightly = rem.command("nightly").description("Scheduled REM nightly cycle (manual trigger + scheduler management)");
|
|
4040
|
+
// `enable` / `disable` / `status` — scheduler install/uninstall (slice-1 PR-2).
|
|
4041
|
+
// macOS: writes ~/Library/LaunchAgents/dev.flair.rem.nightly.plist and bootstraps it.
|
|
4042
|
+
// Linux: writes ~/.config/systemd/user/flair-rem-nightly.{timer,service} and enables the timer.
|
|
4043
|
+
// Snapshot data and the audit log are preserved through enable/disable cycles.
|
|
4044
|
+
remNightly
|
|
4045
|
+
.command("enable")
|
|
4046
|
+
.description("Install the nightly scheduler (launchd on macOS, systemd timer on Linux)")
|
|
4047
|
+
.option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
|
|
4048
|
+
.option("--at <HH:MM>", "Local time to run nightly (default 03:00)", "03:00")
|
|
4049
|
+
.option("--flair-url <url>", "Flair HTTP URL the runner will hit (default http://127.0.0.1:<port>)")
|
|
4050
|
+
.action(async (opts) => {
|
|
4051
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
4052
|
+
if (!agentId) {
|
|
4053
|
+
console.error("Error: --agent or FLAIR_AGENT_ID env required");
|
|
4054
|
+
process.exit(1);
|
|
4055
|
+
}
|
|
4056
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(opts.at);
|
|
4057
|
+
if (!match) {
|
|
4058
|
+
console.error(`Error: --at must be HH:MM (got: ${opts.at})`);
|
|
4059
|
+
process.exit(1);
|
|
4060
|
+
}
|
|
4061
|
+
const hour = parseInt(match[1], 10);
|
|
4062
|
+
const minute = parseInt(match[2], 10);
|
|
4063
|
+
const port = readPortFromConfig() ?? DEFAULT_PORT;
|
|
4064
|
+
const flairUrl = opts.flairUrl || process.env.FLAIR_URL || `http://127.0.0.1:${port}`;
|
|
4065
|
+
const { enableScheduler } = await import("./rem/scheduler.js");
|
|
4066
|
+
try {
|
|
4067
|
+
const r = enableScheduler({ agentId, flairUrl, hour, minute });
|
|
4068
|
+
console.log(`✅ REM nightly scheduler enabled (${r.platform})`);
|
|
4069
|
+
console.log(` Schedule: ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} local time`);
|
|
4070
|
+
console.log(` Scheduler: ${r.schedulerPath}`);
|
|
4071
|
+
console.log(` Shim: ${r.shimPath}`);
|
|
4072
|
+
console.log(` Agent: ${agentId}`);
|
|
4073
|
+
console.log(` Flair URL: ${flairUrl}`);
|
|
4074
|
+
if (r.loadResult) {
|
|
4075
|
+
if (r.loadResult.code === 0) {
|
|
4076
|
+
console.log(` Load: ${r.loadCommand.join(" ")} → ok`);
|
|
4077
|
+
}
|
|
4078
|
+
else {
|
|
4079
|
+
console.log(` Load: ${r.loadCommand.join(" ")} → code ${r.loadResult.code}`);
|
|
4080
|
+
if (r.loadResult.stderr)
|
|
4081
|
+
console.log(` stderr: ${r.loadResult.stderr.trim()}`);
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
4084
|
+
console.log(`\nTip: run \`flair rem nightly run-once --dry-run\` to verify the cycle works`);
|
|
4085
|
+
console.log(` before the first scheduled fire. Disable with \`flair rem nightly disable\`.`);
|
|
4086
|
+
}
|
|
4087
|
+
catch (err) {
|
|
4088
|
+
console.error(`Error: ${err.message}`);
|
|
4089
|
+
process.exit(1);
|
|
4090
|
+
}
|
|
4091
|
+
});
|
|
4092
|
+
remNightly
|
|
4093
|
+
.command("disable")
|
|
4094
|
+
.description("Remove the nightly scheduler (keeps snapshots + audit log)")
|
|
4095
|
+
.option("--remove-shim", "Also delete the ~/.flair/bin/flair-rem-nightly shim")
|
|
4096
|
+
.action(async (opts) => {
|
|
4097
|
+
const { disableScheduler } = await import("./rem/scheduler.js");
|
|
4098
|
+
try {
|
|
4099
|
+
const r = disableScheduler({ removeShim: !!opts.removeShim });
|
|
4100
|
+
if (r.removed.length === 0) {
|
|
4101
|
+
console.log(`(REM nightly scheduler was not installed on ${r.platform})`);
|
|
4102
|
+
return;
|
|
4103
|
+
}
|
|
4104
|
+
console.log(`✅ REM nightly scheduler disabled (${r.platform})`);
|
|
4105
|
+
console.log(` Removed:`);
|
|
4106
|
+
for (const p of r.removed)
|
|
4107
|
+
console.log(` ${p}`);
|
|
4108
|
+
if (r.unloadResult && r.unloadResult.code !== 0) {
|
|
4109
|
+
console.log(` Unload: ${r.unloadCommand.join(" ")} → code ${r.unloadResult.code}`);
|
|
4110
|
+
if (r.unloadResult.stderr)
|
|
4111
|
+
console.log(` stderr: ${r.unloadResult.stderr.trim()}`);
|
|
4112
|
+
}
|
|
4113
|
+
console.log(`\nSnapshots at ~/.flair/snapshots/ and the audit log at`);
|
|
4114
|
+
console.log(`~/.flair/logs/rem-nightly.jsonl are preserved.`);
|
|
4115
|
+
}
|
|
4116
|
+
catch (err) {
|
|
4117
|
+
console.error(`Error: ${err.message}`);
|
|
4118
|
+
process.exit(1);
|
|
4119
|
+
}
|
|
4120
|
+
});
|
|
4121
|
+
remNightly
|
|
4122
|
+
.command("status")
|
|
4123
|
+
.description("Show whether the nightly scheduler is installed")
|
|
4124
|
+
.action(async () => {
|
|
4125
|
+
const { schedulerStatus } = await import("./rem/scheduler.js");
|
|
4126
|
+
try {
|
|
4127
|
+
const s = schedulerStatus();
|
|
4128
|
+
console.log(`REM nightly scheduler (${s.platform}):`);
|
|
4129
|
+
console.log(` Installed: ${s.installed ? "yes" : "no"}`);
|
|
4130
|
+
console.log(` Scheduler: ${s.schedulerPath}`);
|
|
4131
|
+
console.log(` Shim: ${s.shimPath}${s.shimExists ? "" : " (missing)"}`);
|
|
4132
|
+
if (!s.installed) {
|
|
4133
|
+
console.log(`\nEnable with: flair rem nightly enable --agent <id> [--at HH:MM]`);
|
|
4134
|
+
}
|
|
4135
|
+
}
|
|
4136
|
+
catch (err) {
|
|
4137
|
+
console.error(`Error: ${err.message}`);
|
|
4138
|
+
process.exit(1);
|
|
4139
|
+
}
|
|
4140
|
+
});
|
|
4141
|
+
remNightly
|
|
4142
|
+
.command("run-once")
|
|
4143
|
+
.description("Run one nightly cycle now (snapshot + log). Same code path the scheduler will use.")
|
|
4144
|
+
.option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
|
|
4145
|
+
.option("--dry-run", "Log the row but skip the snapshot write")
|
|
4146
|
+
.action(async (opts) => {
|
|
4147
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
4148
|
+
if (!agentId) {
|
|
4149
|
+
console.error("Error: --agent or FLAIR_AGENT_ID env required");
|
|
4150
|
+
process.exit(1);
|
|
4151
|
+
}
|
|
4152
|
+
const { runNightlyCycle } = await import("./rem/runner.js");
|
|
4153
|
+
try {
|
|
4154
|
+
const result = await runNightlyCycle({
|
|
4155
|
+
agentId,
|
|
4156
|
+
flairVersion: __pkgVersion,
|
|
4157
|
+
apiCall: api,
|
|
4158
|
+
dryRun: !!opts.dryRun,
|
|
4159
|
+
});
|
|
4160
|
+
const row = result.logRow;
|
|
4161
|
+
console.log(`-- rem nightly run-once${opts.dryRun ? " (dry-run)" : ""} --`);
|
|
4162
|
+
console.log(`Agent: ${agentId}`);
|
|
4163
|
+
console.log(`Status: ${result.status}`);
|
|
4164
|
+
if (result.snapshotPath) {
|
|
4165
|
+
console.log(`Snapshot: ${result.snapshotPath}`);
|
|
4166
|
+
}
|
|
4167
|
+
console.log(`Memories: ${row.memoryCount ?? "—"}`);
|
|
4168
|
+
console.log(`Souls: ${row.soulCount ?? "—"}`);
|
|
4169
|
+
console.log(`Pending: ${row.pendingCandidates ?? "—"}`);
|
|
4170
|
+
if (typeof row.archived === "number" || typeof row.expired === "number") {
|
|
4171
|
+
console.log(`Archived: ${row.archived ?? "—"}`);
|
|
4172
|
+
console.log(`Expired: ${row.expired ?? "—"}`);
|
|
4173
|
+
}
|
|
4174
|
+
console.log(`Duration: ${row.durationMs}ms`);
|
|
4175
|
+
if (row.errors.length > 0) {
|
|
4176
|
+
console.log(`Errors:`);
|
|
4177
|
+
for (const e of row.errors)
|
|
4178
|
+
console.log(` - ${e}`);
|
|
4179
|
+
process.exit(1);
|
|
4180
|
+
}
|
|
4181
|
+
if (result.status === "paused") {
|
|
4182
|
+
console.log(`\nNote: REM is paused (sentinel ~/.flair/rem.paused or FLAIR_REM_PAUSE env).`);
|
|
4183
|
+
console.log(`Resume with: flair rem resume`);
|
|
4184
|
+
}
|
|
4185
|
+
}
|
|
4186
|
+
catch (err) {
|
|
4187
|
+
console.error(`Error: ${err.message}`);
|
|
4188
|
+
process.exit(1);
|
|
4189
|
+
}
|
|
4190
|
+
});
|
|
4191
|
+
// ─── flair rem snapshot list ─────────────────────────────────────────────────
|
|
4192
|
+
// Slice 1 of FLAIR-NIGHTLY-REM (ops-2qq). Lists snapshot tarballs under
|
|
4193
|
+
// ~/.flair/snapshots/<agent>/. Snapshot creation lives inside the nightly
|
|
4194
|
+
// runner (and exposed via `flair rem nightly run-once`) — there is no
|
|
4195
|
+
// user-facing `rem snapshot create` because that would invite operators to
|
|
4196
|
+
// create snapshots out of sync with the audit log. The list is the surface.
|
|
4197
|
+
const remSnapshot = rem.command("snapshot").description("REM nightly snapshots (tar.gz archives of agent memory + soul)");
|
|
4198
|
+
remSnapshot
|
|
4199
|
+
.command("list")
|
|
4200
|
+
.description("List REM snapshots for an agent (or all agents)")
|
|
4201
|
+
.option("--agent <id>", "Filter to a single agent")
|
|
4202
|
+
.option("--json", "Output as JSON")
|
|
4203
|
+
.action(async (opts) => {
|
|
4204
|
+
const { listSnapshots } = await import("./rem/snapshot.js");
|
|
4205
|
+
const rows = listSnapshots(opts.agent);
|
|
4206
|
+
if (opts.json) {
|
|
4207
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
4208
|
+
return;
|
|
4209
|
+
}
|
|
4210
|
+
if (rows.length === 0) {
|
|
4211
|
+
console.log("(no REM snapshots — ~/.flair/snapshots/ is empty or absent)");
|
|
4212
|
+
console.log("\nSnapshots are produced by the nightly cycle. Run `flair rem nightly run-once`");
|
|
4213
|
+
console.log("to generate one manually (slice 1).");
|
|
4214
|
+
return;
|
|
4215
|
+
}
|
|
4216
|
+
const agentW = Math.max(5, ...rows.map((r) => r.agent.length));
|
|
4217
|
+
const fileW = Math.max(20, ...rows.map((r) => r.file.length));
|
|
4218
|
+
console.log(` ${"agent".padEnd(agentW)} ${"file".padEnd(fileW)} size age`);
|
|
4219
|
+
for (const r of rows) {
|
|
4220
|
+
console.log(` ${r.agent.padEnd(agentW)} ${r.file.padEnd(fileW)} ${humanBytes(r.size).padEnd(8)} ${relativeTime(r.mtime)}`);
|
|
4221
|
+
}
|
|
4222
|
+
console.log(`\n${rows.length} snapshot${rows.length > 1 ? "s" : ""}.`);
|
|
4223
|
+
});
|
|
4224
|
+
// ─── flair rem restore <date> ────────────────────────────────────────────────
|
|
4225
|
+
// Slice 1 + 2 of FLAIR-NIGHTLY-REM § 9.
|
|
4226
|
+
//
|
|
4227
|
+
// Default (no --apply): filesystem-only extract for inspection. Writes
|
|
4228
|
+
// memories.jsonl / soul.json / metadata.json to a target directory.
|
|
4229
|
+
// Harper state is unchanged.
|
|
4230
|
+
//
|
|
4231
|
+
// --apply: live replay. Reads the snapshot contents, takes a pre-restore
|
|
4232
|
+
// snapshot of the agent's CURRENT state (so this restore is itself
|
|
4233
|
+
// reversible), then DELETEs current memories/souls for the agent and PUTs
|
|
4234
|
+
// the snapshot's rows back. Per-row failures are captured per-row; the
|
|
4235
|
+
// pre-restore snapshot's path is reported so operator can roll back if
|
|
4236
|
+
// something goes wrong mid-flight.
|
|
4237
|
+
//
|
|
4238
|
+
// The <date> argument is an ISO-timestamp prefix or date-only prefix; the
|
|
4239
|
+
// command picks the latest snapshot matching that prefix.
|
|
4240
|
+
rem
|
|
4241
|
+
.command("restore <date>")
|
|
4242
|
+
.description("Restore from a REM snapshot (inspect by default; --apply rewinds Harper state)")
|
|
4243
|
+
.option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
|
|
4244
|
+
.option("--target <dir>", "Directory to extract into (default: <snapshot>.restored, only used without --apply)")
|
|
4245
|
+
.option("--dry-run", "Plan-only — list contents or planned counts without writing")
|
|
4246
|
+
.option("--apply", "Live replay: rewind Harper state to the snapshot (irreversible without the pre-restore snapshot)")
|
|
4247
|
+
.action(async (date, opts) => {
|
|
4248
|
+
const { listSnapshots, extractSnapshot } = await import("./rem/snapshot.js");
|
|
4249
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
4250
|
+
if (!agentId) {
|
|
4251
|
+
console.error("Error: --agent or FLAIR_AGENT_ID env required");
|
|
4252
|
+
process.exit(1);
|
|
4253
|
+
}
|
|
4254
|
+
let rows;
|
|
4255
|
+
try {
|
|
4256
|
+
rows = listSnapshots(agentId);
|
|
4257
|
+
}
|
|
4258
|
+
catch (err) {
|
|
4259
|
+
console.error(`Error: ${err.message}`);
|
|
4260
|
+
process.exit(1);
|
|
4261
|
+
}
|
|
4262
|
+
const matches = rows.filter((r) => r.file.startsWith(date));
|
|
4263
|
+
if (matches.length === 0) {
|
|
4264
|
+
console.error(`Error: no snapshot found for agent '${agentId}' matching date '${date}'`);
|
|
4265
|
+
if (rows.length > 0) {
|
|
4266
|
+
console.error(` Available: ${rows.slice(0, 5).map((r) => r.file.replace(/\.tar\.gz$/, "")).join(", ")}`);
|
|
4267
|
+
}
|
|
4268
|
+
else {
|
|
4269
|
+
console.error(` No snapshots exist for ${agentId}. Run \`flair rem nightly run-once\` to create one.`);
|
|
4270
|
+
}
|
|
4271
|
+
process.exit(1);
|
|
4272
|
+
}
|
|
4273
|
+
// listSnapshots returns descending by mtime, so matches[0] is the newest
|
|
4274
|
+
// snapshot for the date prefix.
|
|
4275
|
+
const match = matches[0];
|
|
4276
|
+
// --apply path: live replay via src/rem/restore.ts
|
|
4277
|
+
if (opts.apply) {
|
|
4278
|
+
const { applySnapshot } = await import("./rem/restore.js");
|
|
4279
|
+
try {
|
|
4280
|
+
const result = await applySnapshot({
|
|
4281
|
+
agentId,
|
|
4282
|
+
snapshotPath: match.path,
|
|
4283
|
+
flairVersion: __pkgVersion,
|
|
4284
|
+
apiCall: api,
|
|
4285
|
+
dryRun: !!opts.dryRun,
|
|
4286
|
+
});
|
|
4287
|
+
const verb = opts.dryRun ? "(dry-run) would" : "";
|
|
4288
|
+
console.log(`${opts.dryRun ? "(dry-run) " : ""}flair rem restore --apply${opts.dryRun ? "" : ""}`);
|
|
4289
|
+
console.log(` Status: ${result.status}`);
|
|
4290
|
+
console.log(` Snapshot: ${match.path}`);
|
|
4291
|
+
if (result.preRestoreSnapshotPath) {
|
|
4292
|
+
console.log(` Pre-restore: ${result.preRestoreSnapshotPath}`);
|
|
4293
|
+
console.log(` (rollback: flair rem restore <pre-restore-date> --agent ${agentId} --apply)`);
|
|
4294
|
+
}
|
|
4295
|
+
console.log(` Deleted: ${result.deleted.memories} memories, ${result.deleted.souls} souls`);
|
|
4296
|
+
console.log(` Restored: ${result.restored.memories} memories, ${result.restored.souls} souls`);
|
|
4297
|
+
if (result.errors.length > 0) {
|
|
4298
|
+
console.log(` Errors:`);
|
|
4299
|
+
for (const e of result.errors)
|
|
4300
|
+
console.log(` - ${e}`);
|
|
4301
|
+
}
|
|
4302
|
+
if (result.status === "failed")
|
|
4303
|
+
process.exit(1);
|
|
4304
|
+
}
|
|
4305
|
+
catch (err) {
|
|
4306
|
+
console.error(`Error: ${err.message}`);
|
|
4307
|
+
process.exit(1);
|
|
4308
|
+
}
|
|
4309
|
+
return;
|
|
4310
|
+
}
|
|
4311
|
+
// Default: filesystem extract.
|
|
4312
|
+
try {
|
|
4313
|
+
const result = await extractSnapshot({
|
|
4314
|
+
snapshotPath: match.path,
|
|
4315
|
+
targetDir: opts.target,
|
|
4316
|
+
dryRun: !!opts.dryRun,
|
|
4317
|
+
});
|
|
4318
|
+
if (opts.dryRun) {
|
|
4319
|
+
console.log(`(dry-run) snapshot: ${match.path}`);
|
|
4320
|
+
for (const e of result.entries) {
|
|
4321
|
+
console.log(` ${e.path} (${humanBytes(e.size)})`);
|
|
4322
|
+
}
|
|
4323
|
+
return;
|
|
4324
|
+
}
|
|
4325
|
+
console.log(`✅ Extracted: ${match.path}`);
|
|
4326
|
+
console.log(` To: ${result.targetDir}`);
|
|
4327
|
+
for (const e of result.entries) {
|
|
4328
|
+
console.log(` ${e.path} (${humanBytes(e.size)})`);
|
|
4329
|
+
}
|
|
4330
|
+
console.log(`\nNote: this is a filesystem extract — Harper state is unchanged.`);
|
|
4331
|
+
console.log(`To actually rewind state, re-run with --apply.`);
|
|
4332
|
+
}
|
|
4333
|
+
catch (err) {
|
|
4334
|
+
console.error(`Error: ${err.message}`);
|
|
4335
|
+
process.exit(1);
|
|
4336
|
+
}
|
|
4337
|
+
});
|
|
4338
|
+
// ─── flair rem pause / resume ────────────────────────────────────────────────
|
|
4339
|
+
// Slice 1 of FLAIR-NIGHTLY-REM § 9. The pause sentinel is checked by the
|
|
4340
|
+
// nightly runner before any side effects. Env-var FLAIR_REM_PAUSE=1 is also
|
|
4341
|
+
// honored — lets ops pause fleet-wide without writing a file.
|
|
4342
|
+
const REM_PAUSE_FLAG = resolve(homedir(), ".flair", "rem.paused");
|
|
4343
|
+
rem
|
|
4344
|
+
.command("pause")
|
|
4345
|
+
.description("Pause nightly REM runs — writes ~/.flair/rem.paused sentinel")
|
|
4346
|
+
.action(() => {
|
|
4347
|
+
const dir = dirname(REM_PAUSE_FLAG);
|
|
4348
|
+
if (!existsSync(dir))
|
|
4349
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
4350
|
+
writeFileSync(REM_PAUSE_FLAG, new Date().toISOString() + "\n", { mode: 0o600 });
|
|
4351
|
+
console.log(`✅ REM nightly runs paused (sentinel: ${REM_PAUSE_FLAG})`);
|
|
4352
|
+
console.log(` Resume with: flair rem resume`);
|
|
4353
|
+
});
|
|
4354
|
+
rem
|
|
4355
|
+
.command("resume")
|
|
4356
|
+
.description("Resume nightly REM runs — removes the pause sentinel")
|
|
4357
|
+
.action(() => {
|
|
4358
|
+
if (existsSync(REM_PAUSE_FLAG)) {
|
|
4359
|
+
rmSync(REM_PAUSE_FLAG);
|
|
4360
|
+
console.log(`✅ REM nightly runs resumed (removed ${REM_PAUSE_FLAG})`);
|
|
4361
|
+
}
|
|
4362
|
+
else {
|
|
4363
|
+
console.log(`(REM was not paused — no sentinel at ${REM_PAUSE_FLAG})`);
|
|
4364
|
+
}
|
|
4365
|
+
if (process.env.FLAIR_REM_PAUSE === "1") {
|
|
4366
|
+
console.log(`\n⚠ FLAIR_REM_PAUSE=1 env var is also set; unset it to fully resume.`);
|
|
4367
|
+
}
|
|
4368
|
+
});
|
|
3994
4369
|
// ─── flair status ─────────────────────────────────────────────────────────────
|
|
3995
4370
|
function humanBytes(n) {
|
|
3996
4371
|
if (!Number.isFinite(n) || n < 0)
|
|
@@ -0,0 +1,205 @@
|
|
|
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.
|
|
105
|
+
if (metadata.agentId && metadata.agentId !== opts.agentId) {
|
|
106
|
+
errors.push(`snapshot agentId (${metadata.agentId}) does not match target (${opts.agentId}) — refusing to restore cross-agent`);
|
|
107
|
+
result.status = "failed";
|
|
108
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
if (opts.dryRun) {
|
|
112
|
+
// In dry-run, report planned counts. Still fetch current state for
|
|
113
|
+
// accurate deleted-counts reporting.
|
|
114
|
+
try {
|
|
115
|
+
const currentMem = asArray(await opts.apiCall("GET", `/Memory?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
116
|
+
const currentSouls = asArray(await opts.apiCall("GET", `/Soul?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
117
|
+
result.deleted.memories = currentMem.length;
|
|
118
|
+
result.deleted.souls = currentSouls.length;
|
|
119
|
+
result.restored.memories = memories.length;
|
|
120
|
+
result.restored.souls = souls.length;
|
|
121
|
+
result.status = "dry-run";
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
errors.push(`fetch-current: ${err?.message ?? String(err)}`);
|
|
125
|
+
result.status = "failed";
|
|
126
|
+
}
|
|
127
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
// 4. Pre-restore snapshot of current state.
|
|
131
|
+
let currentMem;
|
|
132
|
+
let currentSouls;
|
|
133
|
+
try {
|
|
134
|
+
currentMem = asArray(await opts.apiCall("GET", `/Memory?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
135
|
+
currentSouls = asArray(await opts.apiCall("GET", `/Soul?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
136
|
+
const preRestore = await createSnapshot({
|
|
137
|
+
agentId: opts.agentId,
|
|
138
|
+
flairVersion: opts.flairVersion,
|
|
139
|
+
memories: currentMem,
|
|
140
|
+
soul: currentSouls.length === 1 ? currentSouls[0] : currentSouls.length > 1 ? currentSouls : null,
|
|
141
|
+
pendingCandidateCount: 0, // not load-bearing at restore time
|
|
142
|
+
rootOverride: opts.preRestoreSnapshotRoot,
|
|
143
|
+
runId: `rem-restore-pre-${(opts.nowOverride ?? new Date()).toISOString().replace(/[:.]/g, "-")}`,
|
|
144
|
+
nowOverride: opts.nowOverride,
|
|
145
|
+
});
|
|
146
|
+
result.preRestoreSnapshotPath = preRestore.path;
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
errors.push(`pre-restore-snapshot: ${err?.message ?? String(err)}`);
|
|
150
|
+
result.status = "failed";
|
|
151
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
// 5. Delete current memories + souls (sequential to keep error semantics).
|
|
155
|
+
for (const m of currentMem) {
|
|
156
|
+
if (!m?.id)
|
|
157
|
+
continue;
|
|
158
|
+
try {
|
|
159
|
+
await opts.apiCall("DELETE", `/Memory/${encodeURIComponent(String(m.id))}`);
|
|
160
|
+
result.deleted.memories++;
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
errors.push(`delete-memory ${m.id}: ${err?.message ?? String(err)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const s of currentSouls) {
|
|
167
|
+
if (!s?.id)
|
|
168
|
+
continue;
|
|
169
|
+
try {
|
|
170
|
+
await opts.apiCall("DELETE", `/Soul/${encodeURIComponent(String(s.id))}`);
|
|
171
|
+
result.deleted.souls++;
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
errors.push(`delete-soul ${s.id}: ${err?.message ?? String(err)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// 6. PUT snapshot rows.
|
|
178
|
+
for (const m of memories) {
|
|
179
|
+
if (!m?.id)
|
|
180
|
+
continue;
|
|
181
|
+
try {
|
|
182
|
+
await opts.apiCall("PUT", `/Memory/${encodeURIComponent(String(m.id))}`, m);
|
|
183
|
+
result.restored.memories++;
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
errors.push(`put-memory ${m.id}: ${err?.message ?? String(err)}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const s of souls) {
|
|
190
|
+
if (!s?.id)
|
|
191
|
+
continue;
|
|
192
|
+
try {
|
|
193
|
+
await opts.apiCall("PUT", `/Soul/${encodeURIComponent(String(s.id))}`, s);
|
|
194
|
+
result.restored.souls++;
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
errors.push(`put-soul ${s.id}: ${err?.message ?? String(err)}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
201
|
+
if (errors.length > 0) {
|
|
202
|
+
result.status = "failed";
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
}
|