@tpsdev-ai/flair 0.53.0 → 0.54.2

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.
Files changed (97) hide show
  1. package/README.md +4 -1
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1791 -15648
  4. package/dist/commands/agent.js +453 -0
  5. package/dist/commands/attention.js +121 -0
  6. package/dist/commands/backup.js +115 -0
  7. package/dist/commands/bootstrap.js +91 -0
  8. package/dist/commands/bridge.js +608 -0
  9. package/dist/commands/deploy.js +180 -0
  10. package/dist/commands/doctor.js +1665 -0
  11. package/dist/commands/export.js +110 -0
  12. package/dist/commands/federation.js +1575 -0
  13. package/dist/commands/fleet.js +73 -0
  14. package/dist/commands/grant.js +109 -0
  15. package/dist/commands/hook.js +193 -0
  16. package/dist/commands/idp.js +193 -0
  17. package/dist/commands/import.js +134 -0
  18. package/dist/commands/init.js +1203 -0
  19. package/dist/commands/inspect.js +45 -0
  20. package/dist/commands/keys.js +187 -0
  21. package/dist/commands/mcp.js +707 -0
  22. package/dist/commands/memory.js +501 -0
  23. package/dist/commands/migrate-harness-memory.js +270 -0
  24. package/dist/commands/orgevent.js +138 -0
  25. package/dist/commands/presence.js +76 -0
  26. package/dist/commands/principal.js +338 -0
  27. package/dist/commands/quality.js +1164 -0
  28. package/dist/commands/reembed.js +296 -0
  29. package/dist/commands/relationship.js +76 -0
  30. package/dist/commands/rem.js +1048 -0
  31. package/dist/commands/restore.js +130 -0
  32. package/dist/commands/search.js +244 -0
  33. package/dist/commands/service.js +315 -0
  34. package/dist/commands/session.js +184 -0
  35. package/dist/commands/soul.js +155 -0
  36. package/dist/commands/status.js +931 -0
  37. package/dist/commands/test.js +93 -0
  38. package/dist/commands/uninstall.js +143 -0
  39. package/dist/commands/upgrade.js +1628 -0
  40. package/dist/commands/workspace.js +114 -0
  41. package/dist/deploy.js +24 -0
  42. package/dist/engine-version.js +12 -4
  43. package/dist/fabric-npm-install.js +87 -0
  44. package/dist/fabric-upgrade.js +30 -15
  45. package/dist/federation-verify.js +498 -0
  46. package/dist/fleet-verify.js +144 -21
  47. package/dist/install/clients.js +167 -0
  48. package/dist/lib/auth-resolve.js +76 -1
  49. package/dist/lib/daemon-liveness.js +131 -2
  50. package/dist/lib/doctor-config-path.js +61 -0
  51. package/dist/lib/doctor-federation-driver.js +189 -0
  52. package/dist/lib/doctor-run.js +40 -0
  53. package/dist/lib/entity-vocab-cli.js +3 -3
  54. package/dist/lib/federation-pair-identity.js +47 -0
  55. package/dist/lib/launchd-repair.js +5 -4
  56. package/dist/lib/npm-registry.js +578 -0
  57. package/dist/lib/ops-api-bind.js +115 -0
  58. package/dist/lib/owned-pins.js +219 -0
  59. package/dist/lib/uninstall-purge.js +218 -0
  60. package/dist/rem/restore.js +8 -10
  61. package/dist/resources/AgentReadPosition.js +74 -0
  62. package/dist/resources/Federation.js +8 -2
  63. package/dist/resources/Memory.js +4 -3
  64. package/dist/resources/MemoryBootstrap.js +41 -25
  65. package/dist/resources/MemoryCandidate.js +5 -6
  66. package/dist/resources/OrgEventCatchup.js +126 -47
  67. package/dist/resources/agent-read-position-lib.js +83 -0
  68. package/dist/resources/agent-read-position.js +120 -0
  69. package/dist/resources/embeddings-boot.js +32 -0
  70. package/dist/resources/federation-peer-liveness.js +73 -0
  71. package/dist/resources/health.js +68 -19
  72. package/dist/resources/mcp-tools.js +48 -279
  73. package/dist/resources/memory-visibility.js +3 -3
  74. package/dist/resources/migration-boot.js +59 -18
  75. package/dist/resources/migrations/embedding-stamp.js +20 -1
  76. package/dist/resources/migrations/recheck.js +43 -0
  77. package/dist/resources/migrations/runner.js +6 -1
  78. package/dist/resources/migrations/stamp-outstanding.js +171 -0
  79. package/dist/resources/migrations/visibility-backfill.js +2 -2
  80. package/dist/resources/org-event-catchup-lib.js +47 -0
  81. package/dist/resources/record-owner-guard.js +1 -0
  82. package/dist/resources/tool-descriptors/index.js +669 -0
  83. package/dist/stamp-migration-verify.js +163 -0
  84. package/dist/stamp-outstanding.js +144 -0
  85. package/dist/version-check.js +29 -8
  86. package/docs/api-reference.md +4 -2
  87. package/docs/deploying-on-fabric.md +11 -10
  88. package/docs/deployment.md +3 -1
  89. package/docs/federation.md +19 -0
  90. package/docs/hosted-on-fabric.md +3 -3
  91. package/docs/quickstart.md +2 -1
  92. package/docs/releasing.md +20 -6
  93. package/docs/spoke-bringup.md +10 -5
  94. package/docs/standalone-local.md +3 -1
  95. package/docs/upgrade.md +25 -6
  96. package/package.json +4 -4
  97. package/schemas/agent.graphql +15 -0
@@ -0,0 +1,1048 @@
1
+ import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, resolve } from "node:path";
4
+ import * as render from "../render.js";
5
+ import { isLocalBase, resolveAdminUser, resolveLocalAdminPass } from "../lib/auth-resolve.js";
6
+ import { validatePromoteOpts, validateRejectOpts, decideCandidateAction, derivePromotedTags, validateHumanReviewerId, } from "../rem/promote-policy.js";
7
+ export { validatePromoteOpts, validateRejectOpts, decideCandidateAction, derivePromotedTags, derivePromotedVisibility, validateHumanReviewerId, isMachineReviewerId, ADK_SCOPE_TAG_PREFIX, CONTINUITY_SCOPE_TAG_PREFIX, MACHINE_REVIEWER_PREFIX, MACHINE_REVIEWER_ADK_AUTO_PROMOTE, } from "../rem/promote-policy.js";
8
+ let cli;
9
+ /** Bind shared CLI helpers. cli.ts calls this immediately before register(program). */
10
+ export function bindCli(fns) {
11
+ cli = fns;
12
+ }
13
+ function api(...args) {
14
+ return cli.api(...args);
15
+ }
16
+ function resolveOpsPort(opts) {
17
+ return cli.resolveOpsPort(opts);
18
+ }
19
+ function applyAdminPassFile(opts) {
20
+ cli.applyAdminPassFile(opts);
21
+ }
22
+ function addSharedCredentialOptions(cmd) {
23
+ return cli.addSharedCredentialOptions(cmd);
24
+ }
25
+ function readPortFromConfig() {
26
+ return cli.readPortFromConfig();
27
+ }
28
+ function resolveHttpPort(opts, mode = "address") {
29
+ return cli.resolveHttpPort(opts, mode);
30
+ }
31
+ function humanBytes(n) {
32
+ return cli.humanBytes(n);
33
+ }
34
+ function relativeTime(iso) {
35
+ return cli.relativeTime(iso);
36
+ }
37
+ /**
38
+ * Build the admin-authed ops-API `search_by_conditions` helper shared by the
39
+ * rem commands. `search_by_conditions` is an ops-API operation, not a Harper
40
+ * REST route — Harper's REST dispatcher maps `POST /<table>` to
41
+ * `resource.post()` and never routes a URL suffix, so `/MemoryCandidate/
42
+ * search_by_conditions` 405s. Both the nightly pending-candidate count and
43
+ * the restore-time candidate cleanup must reach the ops port this way.
44
+ * `fetchImpl` is injectable for tests.
45
+ */
46
+ export function buildOpsSearch(opts, fetchImpl = fetch) {
47
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${opts.adminPass}`).toString("base64")}`;
48
+ return async (table, conditions, getAttributes) => {
49
+ const res = await fetchImpl(`http://127.0.0.1:${opts.opsPort}/`, {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json", Authorization: auth },
52
+ body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions, get_attributes: getAttributes }),
53
+ });
54
+ if (!res.ok)
55
+ throw new Error(`ops API failed (${res.status})`);
56
+ const raw = await res.json();
57
+ return Array.isArray(raw) ? raw : (raw?.results ?? []);
58
+ };
59
+ }
60
+ // ─── flair rem rapid — pure helpers ──────────────────────────────────────────
61
+ // Extracted for testability, same pattern as validatePromoteOpts /
62
+ // decideCandidateAction above: the action callback below spawns api() +
63
+ // process.exit, which makes it high-effort/low-value to drive directly;
64
+ // these two functions are the actual decision logic.
65
+ /** One staged-candidate summary line: `[id] claim, truncated to ~80 chars`. */
66
+ export function formatCandidateLine(candidate, maxClaimLen = 80) {
67
+ const claim = candidate.claim ?? "";
68
+ const truncated = claim.length > maxClaimLen ? `${claim.slice(0, maxClaimLen)}…` : claim;
69
+ return ` [${candidate.id ?? "?"}] ${truncated}`;
70
+ }
71
+ /**
72
+ * Classifies a thrown /ReflectMemories execute-mode error for CLI display.
73
+ * `api()` throws `Error(responseBodyText)` for non-2xx responses (see api()
74
+ * above) — the two execute-mode failure bodies are:
75
+ * 503 no-backend: { error: "No generative backend configured..." }
76
+ * 502 distillation_failed: { error: "distillation_failed", detail: "..." }
77
+ * Any other shape (network errors, the 400/403 actor-resolution errors
78
+ * prompt mode shares) falls back to "other" — printed as a plain message,
79
+ * no docs pointer or retry hint attached since neither applies.
80
+ */
81
+ export function describeReflectError(message) {
82
+ try {
83
+ const parsed = JSON.parse(message);
84
+ if (parsed && typeof parsed === "object") {
85
+ if (parsed.error === "distillation_failed") {
86
+ return { kind: "distillation-failed", text: String(parsed.detail ?? parsed.error) };
87
+ }
88
+ if (typeof parsed.error === "string" && parsed.error.startsWith("No generative backend configured")) {
89
+ return { kind: "no-backend", text: parsed.error };
90
+ }
91
+ if (typeof parsed.error === "string") {
92
+ return { kind: "other", text: parsed.error };
93
+ }
94
+ }
95
+ }
96
+ catch {
97
+ // Not a JSON error body — network error, etc. Pass the raw message through.
98
+ }
99
+ return { kind: "other", text: message };
100
+ }
101
+ const REM_PAUSE_FLAG = resolve(homedir(), ".flair", "rem.paused");
102
+ function writeRemPauseSentinel() {
103
+ const dir = dirname(REM_PAUSE_FLAG);
104
+ if (!existsSync(dir))
105
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
106
+ writeFileSync(REM_PAUSE_FLAG, new Date().toISOString() + "\n", { mode: 0o600 });
107
+ }
108
+ export function register(program) {
109
+ const DEFAULT_PORT = cli.DEFAULT_PORT;
110
+ const __pkgVersion = cli.pkgVersion;
111
+ // ─── flair rem ───────────────────────────────────────────────────────────────
112
+ // Memory hygiene and reflection: light (NREM), rapid (REM), restorative (deep).
113
+ const rem = program.command("rem").description("Memory hygiene and reflection");
114
+ rem
115
+ .command("light")
116
+ .description("NREM — quick cleanup: delete expired, archive old, consolidate candidates")
117
+ .option("--port <port>", "Harper HTTP port")
118
+ .option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
119
+ .option("--dry-run", "Preview changes without applying them")
120
+ .action(async (opts) => {
121
+ const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
122
+ const dryRun = !!opts.dryRun;
123
+ console.log(`\n-- rem light${dryRun ? " (dry run)" : ""} --`);
124
+ if (agentId)
125
+ console.log(`Agent: ${agentId}`);
126
+ try {
127
+ // Step 1: Maintenance — expire + archive
128
+ const maint = await api("POST", "/MemoryMaintenance", {
129
+ ...(agentId ? { agentId } : {}),
130
+ dryRun,
131
+ });
132
+ if (maint.error) {
133
+ console.error(`Maintenance error: ${maint.error}`);
134
+ process.exit(1);
135
+ }
136
+ const s = maint.stats ?? {};
137
+ console.log("\nCleanup");
138
+ console.log(` Expired (deleted): ${s.expired ?? 0}`);
139
+ console.log(` Archived (soft): ${s.archived ?? 0}`);
140
+ console.log(` Total scanned: ${s.total ?? 0}`);
141
+ if (s.errors)
142
+ console.log(` Errors: ${s.errors}`);
143
+ // Step 2: Consolidation candidates
144
+ if (!agentId) {
145
+ console.log("\nConsolidation skipped — no agent ID (pass --agent or set FLAIR_AGENT_ID)");
146
+ return;
147
+ }
148
+ const consol = await api("POST", "/ConsolidateMemories", {
149
+ agentId,
150
+ scope: "all",
151
+ });
152
+ if (consol.error) {
153
+ console.error(`Consolidation error: ${consol.error}`);
154
+ process.exit(1);
155
+ }
156
+ const candidates = consol.candidates ?? [];
157
+ const promote = candidates.filter((c) => c.suggestion === "promote");
158
+ const archive = candidates.filter((c) => c.suggestion === "archive");
159
+ console.log("\nConsolidation candidates");
160
+ console.log(` Promote: ${promote.length}`);
161
+ console.log(` Archive: ${archive.length}`);
162
+ if (promote.length > 0) {
163
+ console.log("\n Promote:");
164
+ for (const c of promote) {
165
+ console.log(` [${c.memory?.id ?? "?"}] ${c.reason}`);
166
+ }
167
+ }
168
+ if (archive.length > 0) {
169
+ console.log("\n Archive:");
170
+ for (const c of archive) {
171
+ console.log(` [${c.memory?.id ?? "?"}] ${c.reason}`);
172
+ }
173
+ }
174
+ console.log(`\nDone.${dryRun ? " No changes applied (dry run)." : ""}`);
175
+ }
176
+ catch (err) {
177
+ console.error(`Error: ${err.message}`);
178
+ process.exit(1);
179
+ }
180
+ });
181
+ // ─── flair rem rapid ──────────────────────────────────────────────────────────
182
+ // Executes by default (§3C, issue #707): distills server-side via /ReflectMemories execute:true and
183
+ // stages MemoryCandidate rows, printing a staged-candidate summary. --prompt-only
184
+ // preserves the pre-#710 handoff behavior byte-for-byte, for the bring-your-
185
+ // own-model workflow.
186
+ rem
187
+ .command("rapid")
188
+ .description("REM — reflection/learning: distill recent memories into staged candidates")
189
+ .option("--port <port>", "Harper HTTP port")
190
+ .option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
191
+ .option("--focus <type>", "lessons_learned | patterns | decisions | errors", "lessons_learned")
192
+ .option("--since <date>", "ISO timestamp lower bound (default: 24h ago)")
193
+ .option("--prompt-only", "Return the reflection prompt instead of executing (pre-#710 handoff behavior)")
194
+ .action(async (opts) => {
195
+ const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
196
+ if (!agentId) {
197
+ console.error("Error: --agent <id> or FLAIR_AGENT_ID env required");
198
+ process.exit(1);
199
+ }
200
+ console.log(`\n-- rem rapid --`);
201
+ console.log(`Agent: ${agentId} Focus: ${opts.focus}`);
202
+ const body = {
203
+ agentId,
204
+ focus: opts.focus,
205
+ };
206
+ if (opts.since)
207
+ body.since = opts.since;
208
+ if (opts.promptOnly) {
209
+ // --prompt-only: EXACT pre-#710 behavior — prompt-return mode, unchanged.
210
+ try {
211
+ const result = await api("POST", "/ReflectMemories", body);
212
+ if (result.error) {
213
+ console.error(`Reflection error: ${result.error}`);
214
+ process.exit(1);
215
+ }
216
+ console.log(`\nSource memories: ${result.count ?? 0}`);
217
+ if (result.suggestedTags?.length) {
218
+ console.log(`Tags: ${result.suggestedTags.join(", ")}`);
219
+ }
220
+ console.log("\n--- Reflection Prompt ---");
221
+ console.log(result.prompt ?? "(no prompt returned)");
222
+ console.log("--- End Prompt ---\n");
223
+ console.log("Feed the prompt above to your LLM, then write insights back with:");
224
+ console.log(" flair memory add --agent <id> --content <insight> --durability persistent --derived-from <source-ids>");
225
+ }
226
+ catch (err) {
227
+ console.error(`Error: ${err.message}`);
228
+ process.exit(1);
229
+ }
230
+ return;
231
+ }
232
+ // Default: execute mode — distill server-side, stage candidates.
233
+ try {
234
+ const result = await api("POST", "/ReflectMemories", { ...body, execute: true });
235
+ const candidates = Array.isArray(result.candidates) ? result.candidates : [];
236
+ console.log(`\nModel: ${result.model ?? "?"}`);
237
+ console.log(`Candidates: ${result.count ?? candidates.length}`);
238
+ if (candidates.length > 0) {
239
+ console.log();
240
+ for (const c of candidates)
241
+ console.log(formatCandidateLine(c));
242
+ }
243
+ console.log(`\nreview: flair rem candidates / flair rem promote <id>`);
244
+ }
245
+ catch (err) {
246
+ const desc = describeReflectError(err.message ?? String(err));
247
+ if (desc.kind === "no-backend") {
248
+ console.error(`Reflection error: ${desc.text}`);
249
+ console.error(`See docs/rem.md#configuration for how to point Flair at a models: backend.`);
250
+ }
251
+ else if (desc.kind === "distillation-failed") {
252
+ console.error(`Reflection error: distillation failed — ${desc.text}`);
253
+ console.error(`Retry, or run with --prompt-only for the manual handoff.`);
254
+ }
255
+ else {
256
+ console.error(`Error: ${desc.text}`);
257
+ }
258
+ process.exit(1);
259
+ }
260
+ });
261
+ rem
262
+ .command("restorative")
263
+ .description("Deep audit: full maintenance + consolidation (olderThan=7d) + reflection")
264
+ .option("--port <port>", "Harper HTTP port")
265
+ .option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
266
+ .option("--dry-run", "Preview maintenance changes without applying them")
267
+ .action(async (opts) => {
268
+ const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
269
+ const dryRun = !!opts.dryRun;
270
+ console.log(`\n== rem restorative${dryRun ? " (dry run)" : ""} ==`);
271
+ if (agentId)
272
+ console.log(`Agent: ${agentId}`);
273
+ try {
274
+ // Step 1: Maintenance
275
+ console.log("\n[1/3] Maintenance...");
276
+ const maint = await api("POST", "/MemoryMaintenance", {
277
+ ...(agentId ? { agentId } : {}),
278
+ dryRun,
279
+ });
280
+ if (maint.error) {
281
+ console.error(`Maintenance error: ${maint.error}`);
282
+ process.exit(1);
283
+ }
284
+ const s = maint.stats ?? {};
285
+ console.log(` Expired: ${s.expired ?? 0} Archived: ${s.archived ?? 0} Scanned: ${s.total ?? 0}${s.errors ? ` Errors: ${s.errors}` : ""}`);
286
+ // Step 2: Consolidation (skip if no agentId)
287
+ if (agentId) {
288
+ console.log("\n[2/3] Consolidation (scope=all, olderThan=7d)...");
289
+ const consol = await api("POST", "/ConsolidateMemories", {
290
+ agentId,
291
+ scope: "all",
292
+ olderThan: "7d",
293
+ });
294
+ if (consol.error) {
295
+ console.error(`Consolidation error: ${consol.error}`);
296
+ process.exit(1);
297
+ }
298
+ const candidates = consol.candidates ?? [];
299
+ const promote = candidates.filter((c) => c.suggestion === "promote");
300
+ const archive = candidates.filter((c) => c.suggestion === "archive");
301
+ console.log(` Promote candidates: ${promote.length} Archive candidates: ${archive.length}`);
302
+ for (const c of promote) {
303
+ console.log(` promote [${c.memory?.id ?? "?"}] ${c.reason}`);
304
+ }
305
+ for (const c of archive) {
306
+ console.log(` archive [${c.memory?.id ?? "?"}] ${c.reason}`);
307
+ }
308
+ }
309
+ else {
310
+ console.log("\n[2/3] Consolidation skipped — no agent ID");
311
+ }
312
+ // Step 3: Reflection
313
+ if (agentId) {
314
+ console.log("\n[3/3] Reflection (scope=all)...");
315
+ const reflect = await api("POST", "/ReflectMemories", {
316
+ agentId,
317
+ scope: "all",
318
+ });
319
+ if (reflect.error) {
320
+ console.error(`Reflection error: ${reflect.error}`);
321
+ process.exit(1);
322
+ }
323
+ console.log(` Source memories: ${reflect.count ?? 0}`);
324
+ if (reflect.suggestedTags?.length) {
325
+ console.log(` Tags: ${reflect.suggestedTags.join(", ")}`);
326
+ }
327
+ console.log("\n--- Reflection Prompt ---");
328
+ console.log(reflect.prompt ?? "(no prompt returned)");
329
+ console.log("--- End Prompt ---");
330
+ }
331
+ else {
332
+ console.log("\n[3/3] Reflection skipped — no agent ID");
333
+ }
334
+ console.log(`\nRestorative cycle complete.${dryRun ? " No changes applied (dry run)." : ""}`);
335
+ }
336
+ catch (err) {
337
+ console.error(`Error: ${err.message}`);
338
+ process.exit(1);
339
+ }
340
+ });
341
+ // ─── flair rem candidates ─────────────────────────────────────────────────────
342
+ // Slice 1 of FLAIR-NIGHTLY-REM (ops-2qq). Lists staged distillations from the
343
+ // MemoryCandidate table. Empty until the nightly cycle (later slice) starts
344
+ // populating. Per spec § 5: candidates are NEVER auto-promoted; this command
345
+ // is the operator's review surface.
346
+ rem
347
+ .command("candidates")
348
+ .description("List staged memory candidates from the FLAIR-NIGHTLY-REM cycle (pending review)")
349
+ .option("--port <port>", "Harper HTTP port")
350
+ .option("--ops-port <port>", "Harper operations API port")
351
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
352
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
353
+ .option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
354
+ .option("--status <s>", "Filter by status: pending | promoted | rejected (default: pending)")
355
+ .option("--json", "Output as JSON for scripting")
356
+ .action(async (opts) => {
357
+ const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
358
+ const status = opts.status ?? "pending";
359
+ const validStatuses = new Set(["pending", "promoted", "rejected"]);
360
+ if (!validStatuses.has(status)) {
361
+ console.error(`Error: --status must be one of: pending, promoted, rejected (got: ${status})`);
362
+ process.exit(1);
363
+ }
364
+ if (!agentId) {
365
+ console.error(`${render.icons.error} --agent is required (or set FLAIR_AGENT_ID)`);
366
+ process.exit(1);
367
+ }
368
+ const opsPort = resolveOpsPort(opts);
369
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
370
+ if (!adminPass) {
371
+ console.error(`${render.icons.error} --admin-pass or FLAIR_ADMIN_PASS required`);
372
+ process.exit(1);
373
+ }
374
+ const auth = `Basic ${Buffer.from(`${resolveAdminUser(opts.adminUser)}:${adminPass}`).toString("base64")}`;
375
+ try {
376
+ const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
377
+ method: "POST",
378
+ headers: { "Content-Type": "application/json", Authorization: auth },
379
+ body: JSON.stringify({
380
+ operation: "search_by_conditions",
381
+ schema: "flair",
382
+ table: "MemoryCandidate",
383
+ operator: "and",
384
+ conditions: [
385
+ { search_attribute: "agentId", search_type: "equals", search_value: agentId },
386
+ { search_attribute: "status", search_type: "equals", search_value: status },
387
+ ],
388
+ get_attributes: ["id", "claim", "generatedBy", "generatedAt", "status", "target", "reviewerId", "decidedAt", "supersedes"],
389
+ }),
390
+ });
391
+ if (!res.ok) {
392
+ const text = await res.text().catch(() => "");
393
+ console.error(`${render.icons.error} ${res.status} ${text}`);
394
+ process.exit(1);
395
+ }
396
+ const candidates = await res.json();
397
+ const mode = render.resolveOutputMode(opts);
398
+ if (mode === "json") {
399
+ console.log(render.asJSON({ agentId, status, count: candidates.length, candidates }));
400
+ return;
401
+ }
402
+ const statusColor = status === "promoted" ? render.c.green : status === "rejected" ? render.c.red : render.c.yellow;
403
+ console.log(`${render.wrap(render.c.bold, "REM candidates")} ${render.wrap(render.c.dim, "—")} agent ${render.wrap(render.c.bold, agentId)} ${render.wrap(render.c.dim, "·")} ${render.wrap(statusColor, status)}`);
404
+ if (candidates.length === 0) {
405
+ console.log(`\n${render.icons.info} ${render.wrap(render.c.dim, `No ${status} candidates.`)}`);
406
+ if (status === "pending") {
407
+ console.log(`${render.wrap(render.c.dim, " Run")} flair rem nightly enable ${render.wrap(render.c.dim, "to start the nightly distillation cycle that populates this table.")}`);
408
+ }
409
+ return;
410
+ }
411
+ candidates.sort((a, b) => String(b.generatedAt ?? "").localeCompare(String(a.generatedAt ?? "")));
412
+ console.log();
413
+ for (const c of candidates) {
414
+ let tag;
415
+ if (c.status === "promoted") {
416
+ tag = `${render.wrap(render.c.green, "✓ promoted")} ${render.wrap(render.c.dim, "→")} ${render.wrap(render.c.bold, c.target ?? "?")} ${render.wrap(render.c.dim, `by ${c.reviewerId ?? "?"} ${render.relativeTime(c.decidedAt)}`)}`;
417
+ }
418
+ else if (c.status === "rejected") {
419
+ tag = `${render.wrap(render.c.red, "✗ rejected")} ${render.wrap(render.c.dim, `by ${c.reviewerId ?? "?"} ${render.relativeTime(c.decidedAt)}`)}`;
420
+ }
421
+ else {
422
+ tag = `${render.wrap(render.c.yellow, "○ pending")} ${render.wrap(render.c.dim, `— ${c.generatedBy ?? "?"} ${render.relativeTime(c.generatedAt)}`)}`;
423
+ }
424
+ console.log(` ${render.wrap(render.c.dim, c.id)} ${tag}`);
425
+ console.log(` ${c.claim}`);
426
+ if (c.supersedes) {
427
+ console.log(` ${render.wrap(render.c.dim, `(supersedes ${c.supersedes} — recurring proposal)`)}`);
428
+ }
429
+ console.log("");
430
+ }
431
+ console.log(`${render.wrap(render.c.bold, String(candidates.length))} candidate${candidates.length > 1 ? "s" : ""}.`);
432
+ if (status === "pending") {
433
+ console.log(`${render.wrap(render.c.dim, "Promote:")} flair rem promote <id> --rationale "<why>" --to (soul|memory)`);
434
+ console.log(`${render.wrap(render.c.dim, "Reject: ")} flair rem reject <id> --reason "<why>"`);
435
+ }
436
+ }
437
+ catch (err) {
438
+ console.error(`${render.icons.error} ${err.message}`);
439
+ process.exit(1);
440
+ }
441
+ });
442
+ // ─── flair rem promote / reject helpers ──────────────────────────────────────
443
+ // Pure validators extracted for testability. The action callbacks below thread
444
+ // these through process.exit on failure; the helpers themselves are
445
+ // side-effect-free.
446
+ // ─── flair rem promote ───────────────────────────────────────────────────────
447
+ // Slice 2 of FLAIR-NIGHTLY-REM (ops-2qq). Promote a candidate to either Soul
448
+ // or persistent Memory. Both --rationale and --to are required (spec § 5: no
449
+ // rubber-stamp). When --to=soul, --key is also required so the resulting
450
+ // Soul row has a meaningful identifier.
451
+ //
452
+ // Trust-tier policy is enforced by the caller's authentication today (1.0):
453
+ // admin pass → any promote; agent key → can write to own Memory/Soul. Server-
454
+ // side trust-tier enforcement (endorsed agents → memory only, never soul) is
455
+ // scoped for slice 2b when agent-routed promotion lands. For now, the
456
+ // human-operator workflow is the supported path.
457
+ rem
458
+ .command("promote")
459
+ .description("Promote a memory candidate to Soul or persistent Memory (rationale required)")
460
+ .argument("<candidate-id>", "MemoryCandidate id to promote")
461
+ .option("--port <port>", "Harper HTTP port")
462
+ .option("--rationale <text>", "Why this candidate is being promoted (required, no rubber-stamp)")
463
+ .option("--to <target>", "Promotion target: 'soul' or 'memory'")
464
+ .option("--key <key>", "Soul key (required when --to=soul; e.g. 'lessons', 'preference-X')")
465
+ .option("--reviewer <id>", "Reviewer agent id (default: FLAIR_AGENT_ID or 'admin')")
466
+ .action(async (candidateId, opts) => {
467
+ const validationErr = validatePromoteOpts(opts);
468
+ if (validationErr) {
469
+ console.error(`Error: ${validationErr}`);
470
+ process.exit(1);
471
+ }
472
+ const reviewerId = opts.reviewer || process.env.FLAIR_AGENT_ID || "admin";
473
+ // The human promote path must not record a reserved machine reviewerId
474
+ // (Sherlock #4): that would launder automated attribution.
475
+ const reviewerErr = validateHumanReviewerId(reviewerId);
476
+ if (reviewerErr) {
477
+ console.error(`Error: ${reviewerErr}`);
478
+ process.exit(1);
479
+ }
480
+ try {
481
+ if (opts.to === "memory") {
482
+ const promoted = await api("POST", "/PromoteMemoryCandidate", {
483
+ candidateId, rationale: opts.rationale,
484
+ ...(opts.reviewer ? { reviewerId: opts.reviewer } : {}),
485
+ });
486
+ if (promoted?.error)
487
+ throw new Error(promoted.error);
488
+ console.log(`✅ Wrote Memory ${promoted.memoryId} (durability=persistent)`);
489
+ console.log(`✅ Candidate ${candidateId} marked promoted → memory, reviewer=${promoted.reviewerId}`);
490
+ return;
491
+ }
492
+ // Fetch the candidate
493
+ const candidate = await api("GET", `/MemoryCandidate/${encodeURIComponent(candidateId)}`);
494
+ const candidateData = (candidate && !candidate.error) ? candidate : null;
495
+ const decision = decideCandidateAction(candidateData, "promote");
496
+ if (!decision.ok) {
497
+ const msg = decision.message;
498
+ console.error(`Error: candidate ${candidateId} ${msg}`);
499
+ process.exit(1);
500
+ }
501
+ // ADK tag-lineage: derive the promoted-claim tag set.
502
+ //
503
+ // #1205b-1: if the engine stamped an authoritative `scopeTag` on the
504
+ // candidate (scope:"tagged" distillation), consume it DIRECTLY and skip
505
+ // the source re-read — correctness no longer depends on the source
506
+ // memories still being readable (the #1205a seam closure). We only fall
507
+ // back to re-reading sources when there is NO stamp (a pre-#1205b
508
+ // candidate, or a non-tagged distillation).
509
+ const stampedScopeTag = typeof candidate.scopeTag === "string" && candidate.scopeTag.length > 0 ? candidate.scopeTag : undefined;
510
+ const sourceFetches = [];
511
+ if (!stampedScopeTag) {
512
+ // No authoritative stamp — re-read sources to classify. Fail-closed for
513
+ // ADK-sourced candidates whose per-user scope tag can't be confirmed;
514
+ // unchanged for non-ADK candidates. See derivePromotedTags for rules.
515
+ const sourceIds = Array.isArray(candidate.sourceMemoryIds) ? candidate.sourceMemoryIds : [];
516
+ for (const sid of sourceIds) {
517
+ try {
518
+ const mem = await api("GET", `/Memory/${encodeURIComponent(String(sid))}`);
519
+ if (mem && !mem.error) {
520
+ sourceFetches.push({ ok: true, tags: Array.isArray(mem.tags) ? mem.tags : [] });
521
+ }
522
+ else {
523
+ sourceFetches.push({ ok: false });
524
+ }
525
+ }
526
+ catch {
527
+ sourceFetches.push({ ok: false });
528
+ }
529
+ }
530
+ }
531
+ const tagDecision = derivePromotedTags(candidateId, sourceFetches, stampedScopeTag);
532
+ if (!tagDecision.ok) {
533
+ console.error(`Error: candidate ${candidateId} — ${tagDecision.reason}`);
534
+ process.exit(1);
535
+ }
536
+ // Soul entries are agentId-scoped and cannot carry a per-user scope tag,
537
+ // so an ADK-sourced candidate promoted to Soul is a cross-user leak by
538
+ // construction — fail closed here and again on Soul.post/put so a
539
+ // scripted PUT /Soul cannot bypass the CLI.
540
+ if (opts.to === "soul" && tagDecision.adkSourced) {
541
+ console.error(`Error: candidate ${candidateId} is ADK-sourced (scope tag ${tagDecision.tags[0]}); Soul is agentId-scoped and cannot carry a per-user scope tag — refusing to promote to Soul (would leak across users). Promote ADK-sourced candidates to memory.`);
542
+ process.exit(1);
543
+ }
544
+ const promotedTags = tagDecision.tags;
545
+ const decidedAt = new Date().toISOString();
546
+ // Memory promotion is handled by the server workflow above.
547
+ const soulId = `${candidate.agentId}-${opts.key}`;
548
+ const soulWrite = await api("PUT", `/Soul/${encodeURIComponent(soulId)}`, {
549
+ id: soulId,
550
+ agentId: candidate.agentId,
551
+ key: opts.key,
552
+ value: candidate.claim,
553
+ priority: "standard",
554
+ durability: "persistent",
555
+ createdAt: decidedAt,
556
+ updatedAt: decidedAt,
557
+ });
558
+ if (soulWrite?.error) {
559
+ console.error(`Error writing Soul: ${soulWrite.error}`);
560
+ process.exit(1);
561
+ }
562
+ console.log(`✅ Wrote Soul ${soulId} (key=${opts.key})`);
563
+ // Update the candidate row
564
+ const upd = await api("PUT", `/MemoryCandidate/${encodeURIComponent(candidateId)}`, {
565
+ ...candidate,
566
+ status: "promoted",
567
+ target: opts.to,
568
+ reviewerId,
569
+ reviewRationale: opts.rationale,
570
+ decidedAt,
571
+ });
572
+ if (upd?.error) {
573
+ console.error(`Warning: candidate row update returned: ${upd.error}`);
574
+ }
575
+ console.log(`✅ Candidate ${candidateId} marked promoted → ${opts.to}, reviewer=${reviewerId}`);
576
+ }
577
+ catch (err) {
578
+ console.error(`Error: ${err.message}`);
579
+ process.exit(1);
580
+ }
581
+ });
582
+ // ─── flair rem reject ────────────────────────────────────────────────────────
583
+ // Reject a candidate with a required --reason. Per spec § 5, rejected
584
+ // candidates retain full decision history so recurring proposals are visible
585
+ // via the supersedes chain.
586
+ rem
587
+ .command("reject")
588
+ .description("Reject a memory candidate with a required reason")
589
+ .argument("<candidate-id>", "MemoryCandidate id to reject")
590
+ .option("--port <port>", "Harper HTTP port")
591
+ .option("--reason <text>", "Why this candidate is being rejected (required)")
592
+ .option("--reviewer <id>", "Reviewer agent id (default: FLAIR_AGENT_ID or 'admin')")
593
+ .action(async (candidateId, opts) => {
594
+ const validationErr = validateRejectOpts(opts);
595
+ if (validationErr) {
596
+ console.error(`Error: ${validationErr}`);
597
+ process.exit(1);
598
+ }
599
+ const reviewerId = opts.reviewer || process.env.FLAIR_AGENT_ID || "admin";
600
+ try {
601
+ const candidate = await api("GET", `/MemoryCandidate/${encodeURIComponent(candidateId)}`);
602
+ const candidateData = (candidate && !candidate.error) ? candidate : null;
603
+ const decision = decideCandidateAction(candidateData, "reject");
604
+ if (!decision.ok) {
605
+ const _d = decision;
606
+ if (_d.severity === "info") {
607
+ console.log(`(candidate ${candidateId} ${_d.message})`);
608
+ return;
609
+ }
610
+ console.error(`Error: candidate ${candidateId} ${_d.message}`);
611
+ process.exit(1);
612
+ }
613
+ const decidedAt = new Date().toISOString();
614
+ const upd = await api("PUT", `/MemoryCandidate/${encodeURIComponent(candidateId)}`, {
615
+ ...candidate,
616
+ status: "rejected",
617
+ reviewerId,
618
+ reviewRationale: opts.reason,
619
+ decidedAt,
620
+ });
621
+ if (upd?.error) {
622
+ console.error(`Error: candidate row update failed: ${upd.error}`);
623
+ process.exit(1);
624
+ }
625
+ console.log(`✅ Candidate ${candidateId} rejected by ${reviewerId}`);
626
+ console.log(` Reason: ${opts.reason}`);
627
+ }
628
+ catch (err) {
629
+ console.error(`Error: ${err.message}`);
630
+ process.exit(1);
631
+ }
632
+ });
633
+ // ─── flair rem nightly run-once ──────────────────────────────────────────────
634
+ // Slice 1 of FLAIR-NIGHTLY-REM § 3. Manually invokes the nightly cycle code
635
+ // path — same module the scheduler will call in PR-2. Useful for:
636
+ // - First-time operators verifying the cycle works before turning on the
637
+ // scheduled timer.
638
+ // - The dry-run-first-run guard (spec § 10) when the scheduler isn't yet
639
+ // installed.
640
+ // - Debugging a stale snapshot or audit row.
641
+ //
642
+ // `nightly enable` / `disable` / `status` land in PR-2 (scheduler templates).
643
+ const remNightly = rem.command("nightly").description("Scheduled REM nightly cycle (manual trigger + scheduler management)");
644
+ // `enable` / `disable` / `status` — scheduler install/uninstall (slice-1 PR-2).
645
+ // macOS: writes ~/Library/LaunchAgents/dev.flair.rem.nightly.plist and bootstraps it.
646
+ // Linux: writes ~/.config/systemd/user/flair-rem-nightly.{timer,service} and enables the timer.
647
+ // Snapshot data and the audit log are preserved through enable/disable cycles.
648
+ remNightly
649
+ .command("enable")
650
+ .description("Install the nightly scheduler (launchd on macOS, systemd timer on Linux)")
651
+ .option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
652
+ .option("--at <HH:MM>", "Local time to run nightly (default 03:00)", "03:00")
653
+ .option("--flair-url <url>", "Flair HTTP URL the runner will hit (default http://127.0.0.1:<port>)")
654
+ .action(async (opts) => {
655
+ const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
656
+ if (!agentId) {
657
+ console.error("Error: --agent or FLAIR_AGENT_ID env required");
658
+ process.exit(1);
659
+ }
660
+ const match = /^(\d{1,2}):(\d{2})$/.exec(opts.at);
661
+ if (!match) {
662
+ console.error(`Error: --at must be HH:MM (got: ${opts.at})`);
663
+ process.exit(1);
664
+ }
665
+ const hour = parseInt(match[1], 10);
666
+ const minute = parseInt(match[2], 10);
667
+ const port = readPortFromConfig() ?? DEFAULT_PORT;
668
+ const flairUrl = opts.flairUrl || process.env.FLAIR_URL || `http://127.0.0.1:${port}`;
669
+ const { enableScheduler, formatEnableReport } = await import("../rem/scheduler.js");
670
+ try {
671
+ const r = enableScheduler({ agentId, flairUrl, hour, minute });
672
+ // formatEnableReport() owns the success-vs-failure decision (flair#850:
673
+ // do not print a success headline before activation is known to have
674
+ // succeeded) — see src/rem/scheduler.ts for the unit-tested logic.
675
+ const { lines, ok } = formatEnableReport(r, { hour, minute, agentId, flairUrl });
676
+ for (const line of lines)
677
+ console.log(line);
678
+ if (!ok)
679
+ process.exit(1);
680
+ }
681
+ catch (err) {
682
+ console.error(`Error: ${err.message}`);
683
+ process.exit(1);
684
+ }
685
+ });
686
+ remNightly
687
+ .command("disable")
688
+ .description("Remove the nightly scheduler (keeps snapshots + audit log)")
689
+ .option("--remove-shim", "Also delete the ~/.flair/bin/flair-rem-nightly shim")
690
+ .action(async (opts) => {
691
+ const { disableScheduler } = await import("../rem/scheduler.js");
692
+ try {
693
+ const r = disableScheduler({ removeShim: !!opts.removeShim });
694
+ if (r.removed.length === 0) {
695
+ console.log(`(REM nightly scheduler was not installed on ${r.platform})`);
696
+ return;
697
+ }
698
+ console.log(`✅ REM nightly scheduler disabled (${r.platform})`);
699
+ console.log(` Removed:`);
700
+ for (const p of r.removed)
701
+ console.log(` ${p}`);
702
+ if (r.unloadResult && r.unloadResult.code !== 0) {
703
+ console.log(` Unload: ${r.unloadCommand.join(" ")} → code ${r.unloadResult.code}`);
704
+ if (r.unloadResult.stderr)
705
+ console.log(` stderr: ${r.unloadResult.stderr.trim()}`);
706
+ }
707
+ console.log(`\nSnapshots at ~/.flair/snapshots/ and the audit log at`);
708
+ console.log(`~/.flair/logs/rem-nightly.jsonl are preserved.`);
709
+ }
710
+ catch (err) {
711
+ console.error(`Error: ${err.message}`);
712
+ process.exit(1);
713
+ }
714
+ });
715
+ remNightly
716
+ .command("status")
717
+ .description("Show whether the nightly scheduler is installed and genuinely active")
718
+ .action(async () => {
719
+ const { schedulerStatus, formatStatusReport } = await import("../rem/scheduler.js");
720
+ try {
721
+ const s = schedulerStatus();
722
+ const { lines } = formatStatusReport(s);
723
+ for (const line of lines)
724
+ console.log(line);
725
+ }
726
+ catch (err) {
727
+ console.error(`Error: ${err.message}`);
728
+ process.exit(1);
729
+ }
730
+ });
731
+ remNightly
732
+ .command("run-once")
733
+ .description("Run one nightly cycle now (snapshot + log). Same code path the scheduler will use.")
734
+ .option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
735
+ .option("--ops-port <port>", "Harper operations API port")
736
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
737
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
738
+ .option("--dry-run", "Log the row but skip the snapshot write")
739
+ .action(async (opts) => {
740
+ const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
741
+ if (!agentId) {
742
+ console.error("Error: --agent or FLAIR_AGENT_ID env required");
743
+ process.exit(1);
744
+ }
745
+ const { runNightlyCycle } = await import("../rem/runner.js");
746
+ // The runner is agent-authed and cannot reach the ops port itself. When
747
+ // admin credentials are available, inject an ops-API `search_by_conditions`
748
+ // helper so the pending-candidate count can be sampled; otherwise the count
749
+ // is best-effort 0 (the cycle still runs).
750
+ const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
751
+ const opsSearch = adminPass
752
+ ? buildOpsSearch({ opsPort: resolveOpsPort(opts), adminUser: opts.adminUser, adminPass })
753
+ : undefined;
754
+ try {
755
+ const healthBase = (process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`).replace(/\/+$/, "");
756
+ const result = await runNightlyCycle({
757
+ agentId,
758
+ flairVersion: __pkgVersion,
759
+ apiCall: api,
760
+ opsSearch,
761
+ dryRun: !!opts.dryRun,
762
+ healthProbe: async (timeoutMs) => {
763
+ const t = Date.now();
764
+ try {
765
+ const res = await fetch(`${healthBase}/Health`, { signal: AbortSignal.timeout(timeoutMs) });
766
+ if (!res.ok) {
767
+ return { ok: false, elapsedMs: Date.now() - t, error: `GET /Health returned HTTP ${res.status}` };
768
+ }
769
+ return { ok: true, elapsedMs: Date.now() - t };
770
+ }
771
+ catch (err) {
772
+ return { ok: false, elapsedMs: Date.now() - t, error: err?.message ?? String(err) };
773
+ }
774
+ },
775
+ });
776
+ const row = result.logRow;
777
+ console.log(`-- rem nightly run-once${opts.dryRun ? " (dry-run)" : ""} --`);
778
+ console.log(`Agent: ${agentId}`);
779
+ console.log(`Status: ${result.status}`);
780
+ if (result.snapshotPath) {
781
+ console.log(`Snapshot: ${result.snapshotPath}`);
782
+ }
783
+ console.log(`Memories: ${row.memoryCount ?? "—"}`);
784
+ console.log(`Souls: ${row.soulCount ?? "—"}`);
785
+ console.log(`Pending: ${row.pendingCandidates ?? "—"}`);
786
+ if (typeof row.archived === "number" || typeof row.expired === "number") {
787
+ console.log(`Archived: ${row.archived ?? "—"}`);
788
+ console.log(`Expired: ${row.expired ?? "—"}`);
789
+ }
790
+ // row.candidates populates when step 5 (distillation) was attempted
791
+ // this cycle — see src/rem/runner.ts. Absent when dry-run skipped it.
792
+ if (row.candidates) {
793
+ console.log(`Staged: ${row.candidates.length} candidate${row.candidates.length === 1 ? "" : "s"}`);
794
+ }
795
+ if (row.distill) {
796
+ const remaining = Math.max(0, row.distill.unreflected - row.distill.gathered);
797
+ console.log(`Distilled: ${row.distill.gathered} memor${row.distill.gathered === 1 ? "y" : "ies"} (cap ${row.distill.maxMemories}; ${remaining} unreflected remaining)`);
798
+ if (row.distill.aborted)
799
+ console.log(`Aborted: yes — in-flight distillation stopped (flair rem pause)`);
800
+ }
801
+ // row.autoPromoted populates when step 5b (#1205b-2 ADK auto-promote) ran
802
+ // this cycle — i.e. a non-dry-run cycle for an ADK agentId.
803
+ if (row.autoPromoted) {
804
+ console.log(`Auto-promoted: ${row.autoPromoted.promoted} to own memory (${row.autoPromoted.skipped} left pending)`);
805
+ }
806
+ // row.dedup populates when step 6 (instance-wide dedup-cluster stat,
807
+ // flair-quality Slice 1c) succeeded this cycle. Absent on dry-run skip
808
+ // or a non-fatal failure (see Errors below — e.g. non-admin caller).
809
+ if (row.dedup) {
810
+ console.log(`Dedup: ${row.dedup.clusterCount} cluster${row.dedup.clusterCount === 1 ? "" : "s"} (${row.dedup.totalMemoriesInClusters} memories, largest ${row.dedup.largestClusterSize})`);
811
+ }
812
+ console.log(`Duration: ${row.durationMs}ms`);
813
+ if (result.status === "refused") {
814
+ console.log(`\nNote: REM refused to start because /Health could not be served.`);
815
+ console.log(`Restore /Health before retrying, or \`flair rem pause\` to stop the scheduler.`);
816
+ }
817
+ if (row.errors.length > 0) {
818
+ console.log(`Errors:`);
819
+ for (const e of row.errors)
820
+ console.log(` - ${e}`);
821
+ process.exit(1);
822
+ }
823
+ if (result.status === "paused") {
824
+ console.log(`\nNote: REM is paused (sentinel ~/.flair/rem.paused or FLAIR_REM_PAUSE env).`);
825
+ console.log(`Resume with: flair rem resume`);
826
+ }
827
+ }
828
+ catch (err) {
829
+ console.error(`Error: ${err.message}`);
830
+ process.exit(1);
831
+ }
832
+ });
833
+ // ─── flair rem snapshot list ─────────────────────────────────────────────────
834
+ // Slice 1 of FLAIR-NIGHTLY-REM (ops-2qq). Lists snapshot tarballs under
835
+ // ~/.flair/snapshots/<agent>/. Snapshot creation lives inside the nightly
836
+ // runner (and exposed via `flair rem nightly run-once`) — there is no
837
+ // user-facing `rem snapshot create` because that would invite operators to
838
+ // create snapshots out of sync with the audit log. The list is the surface.
839
+ const remSnapshot = rem.command("snapshot").description("REM nightly snapshots (tar.gz archives of agent memory + soul)");
840
+ remSnapshot
841
+ .command("list")
842
+ .description("List REM snapshots for an agent (or all agents)")
843
+ .option("--agent <id>", "Filter to a single agent")
844
+ .option("--json", "Output as JSON")
845
+ .action(async (opts) => {
846
+ const { listSnapshots } = await import("../rem/snapshot.js");
847
+ const rows = listSnapshots(opts.agent);
848
+ if (opts.json) {
849
+ console.log(JSON.stringify(rows, null, 2));
850
+ return;
851
+ }
852
+ if (rows.length === 0) {
853
+ console.log("(no REM snapshots — ~/.flair/snapshots/ is empty or absent)");
854
+ console.log("\nSnapshots are produced by the nightly cycle. Run `flair rem nightly run-once`");
855
+ console.log("to generate one manually (slice 1).");
856
+ return;
857
+ }
858
+ const agentW = Math.max(5, ...rows.map((r) => r.agent.length));
859
+ const fileW = Math.max(20, ...rows.map((r) => r.file.length));
860
+ console.log(` ${"agent".padEnd(agentW)} ${"file".padEnd(fileW)} size age`);
861
+ for (const r of rows) {
862
+ console.log(` ${r.agent.padEnd(agentW)} ${r.file.padEnd(fileW)} ${humanBytes(r.size).padEnd(8)} ${relativeTime(r.mtime)}`);
863
+ }
864
+ console.log(`\n${rows.length} snapshot${rows.length > 1 ? "s" : ""}.`);
865
+ });
866
+ // ─── flair rem restore <date> ────────────────────────────────────────────────
867
+ // Slice 1 + 2 of FLAIR-NIGHTLY-REM § 9.
868
+ //
869
+ // Default (no --apply): filesystem-only extract for inspection. Writes
870
+ // memories.jsonl / soul.json / metadata.json to a target directory.
871
+ // Harper state is unchanged.
872
+ //
873
+ // --apply: live replay. Reads the snapshot contents, takes a pre-restore
874
+ // snapshot of the agent's CURRENT state (so this restore is itself
875
+ // reversible), then DELETEs current memories/souls for the agent and PUTs
876
+ // the snapshot's rows back. Per-row failures are captured per-row; the
877
+ // pre-restore snapshot's path is reported so operator can roll back if
878
+ // something goes wrong mid-flight.
879
+ //
880
+ // The <date> argument is an ISO-timestamp prefix or date-only prefix; the
881
+ // command picks the latest snapshot matching that prefix.
882
+ addSharedCredentialOptions(rem.command("restore <date>"))
883
+ .description("Restore from a REM snapshot (inspect by default; --apply rewinds Harper state)")
884
+ .option("--agent <id>", "Agent id (or FLAIR_AGENT_ID env)")
885
+ .option("--target <dir>", "Directory to extract into (default: <snapshot>.restored, only used without --apply)")
886
+ .option("--dry-run", "Plan-only — list contents or planned counts without writing")
887
+ .option("--apply", "Live replay: rewind Harper state to the snapshot (irreversible without the pre-restore snapshot)")
888
+ .action(async (date, opts) => {
889
+ const { listSnapshots, extractSnapshot } = await import("../rem/snapshot.js");
890
+ const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
891
+ if (!agentId) {
892
+ console.error("Error: --agent or FLAIR_AGENT_ID env required");
893
+ process.exit(1);
894
+ }
895
+ let rows;
896
+ try {
897
+ rows = listSnapshots(agentId);
898
+ }
899
+ catch (err) {
900
+ console.error(`Error: ${err.message}`);
901
+ process.exit(1);
902
+ }
903
+ const matches = rows.filter((r) => r.file.startsWith(date));
904
+ if (matches.length === 0) {
905
+ console.error(`Error: no snapshot found for agent '${agentId}' matching date '${date}'`);
906
+ if (rows.length > 0) {
907
+ console.error(` Available: ${rows.slice(0, 5).map((r) => r.file.replace(/\.tar\.gz$/, "")).join(", ")}`);
908
+ }
909
+ else {
910
+ console.error(` No snapshots exist for ${agentId}. Run \`flair rem nightly run-once\` to create one.`);
911
+ }
912
+ process.exit(1);
913
+ }
914
+ // listSnapshots returns descending by mtime, so matches[0] is the newest
915
+ // snapshot for the date prefix.
916
+ const match = matches[0];
917
+ // --apply path: live replay via src/rem/restore.ts
918
+ if (opts.apply) {
919
+ const { applySnapshot } = await import("../rem/restore.js");
920
+ applyAdminPassFile(opts);
921
+ const restoreBase = process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`;
922
+ // Candidates are only reachable through the ops port (Harper has no
923
+ // REST search_by_conditions route), so the dry-run candidate count
924
+ // needs admin creds too. Dry-run treats credential resolution as
925
+ // best-effort — a plan-only command must not fail on a bad credential
926
+ // file — while --apply requires creds (Soul rewrite) and fails loudly.
927
+ let adminPass;
928
+ try {
929
+ adminPass = resolveLocalAdminPass(opts.adminPass, !isLocalBase(restoreBase));
930
+ }
931
+ catch (err) {
932
+ if (!opts.dryRun)
933
+ throw err;
934
+ adminPass = undefined;
935
+ }
936
+ if (!opts.dryRun && !adminPass) {
937
+ console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for rem restore --apply " +
938
+ "(Soul rewrite is operator-only; an agent key is refused).");
939
+ process.exit(1);
940
+ }
941
+ const opsSearch = adminPass
942
+ ? buildOpsSearch({ opsPort: resolveOpsPort(opts), adminUser: opts.adminUser, adminPass })
943
+ : undefined;
944
+ const soulApiCall = adminPass
945
+ ? (method, path, body) => api(method, path, body, { explicitAdminPass: adminPass, adminUser: opts.adminUser, agentId: null })
946
+ : undefined;
947
+ try {
948
+ const result = await applySnapshot({
949
+ agentId,
950
+ snapshotPath: match.path,
951
+ flairVersion: __pkgVersion,
952
+ apiCall: api,
953
+ opsSearch,
954
+ soulApiCall,
955
+ dryRun: !!opts.dryRun,
956
+ });
957
+ const verb = opts.dryRun ? "(dry-run) would" : "";
958
+ console.log(`${opts.dryRun ? "(dry-run) " : ""}flair rem restore --apply${opts.dryRun ? "" : ""}`);
959
+ console.log(` Status: ${result.status}`);
960
+ console.log(` Snapshot: ${match.path}`);
961
+ if (result.preRestoreSnapshotPath) {
962
+ console.log(` Pre-restore: ${result.preRestoreSnapshotPath}`);
963
+ console.log(` (rollback: flair rem restore <pre-restore-date> --agent ${agentId} --apply)`);
964
+ }
965
+ console.log(` Deleted: ${result.deleted.memories} memories, ${result.deleted.souls} souls, ${result.deleted.candidates} candidates`);
966
+ console.log(` Restored: ${result.restored.memories} memories, ${result.restored.souls} souls`);
967
+ if (result.errors.length > 0) {
968
+ console.log(` Errors:`);
969
+ for (const e of result.errors)
970
+ console.log(` - ${e}`);
971
+ }
972
+ if (result.status === "failed")
973
+ process.exit(1);
974
+ }
975
+ catch (err) {
976
+ console.error(`Error: ${err.message}`);
977
+ process.exit(1);
978
+ }
979
+ return;
980
+ }
981
+ // Default: filesystem extract.
982
+ try {
983
+ const result = await extractSnapshot({
984
+ snapshotPath: match.path,
985
+ targetDir: opts.target,
986
+ dryRun: !!opts.dryRun,
987
+ });
988
+ if (opts.dryRun) {
989
+ console.log(`(dry-run) snapshot: ${match.path}`);
990
+ for (const e of result.entries) {
991
+ console.log(` ${e.path} (${humanBytes(e.size)})`);
992
+ }
993
+ return;
994
+ }
995
+ console.log(`✅ Extracted: ${match.path}`);
996
+ console.log(` To: ${result.targetDir}`);
997
+ for (const e of result.entries) {
998
+ console.log(` ${e.path} (${humanBytes(e.size)})`);
999
+ }
1000
+ console.log(`\nNote: this is a filesystem extract — Harper state is unchanged.`);
1001
+ console.log(`To actually rewind state, re-run with --apply.`);
1002
+ }
1003
+ catch (err) {
1004
+ console.error(`Error: ${err.message}`);
1005
+ process.exit(1);
1006
+ }
1007
+ });
1008
+ // ─── flair rem pause / resume ────────────────────────────────────────────────
1009
+ // Slice 1 of FLAIR-NIGHTLY-REM § 9. The pause sentinel is checked by the
1010
+ // nightly runner before any side effects. Env-var FLAIR_REM_PAUSE=1 is also
1011
+ // honored — lets ops pause fleet-wide without writing a file.
1012
+ // #1515: the same sentinel aborts an in-flight /ReflectMemories gather on
1013
+ // the Harper host (checked between yield points) so an operator can stop a
1014
+ // runaway run without restarting Harper.
1015
+ rem
1016
+ .command("pause")
1017
+ .description("Pause nightly REM runs and abort an in-flight distillation gather")
1018
+ .action(() => {
1019
+ writeRemPauseSentinel();
1020
+ console.log(`✅ REM nightly runs paused (sentinel: ${REM_PAUSE_FLAG})`);
1021
+ console.log(` In-flight distillation will abort at the next yield.`);
1022
+ console.log(` Resume with: flair rem resume`);
1023
+ });
1024
+ rem
1025
+ .command("abort")
1026
+ .description("Abort an in-flight REM distillation (same sentinel as pause)")
1027
+ .action(() => {
1028
+ writeRemPauseSentinel();
1029
+ console.log(`✅ REM abort requested (sentinel: ${REM_PAUSE_FLAG})`);
1030
+ console.log(` In-flight distillation will stop at the next yield; the scheduler stays paused.`);
1031
+ console.log(` Resume with: flair rem resume`);
1032
+ });
1033
+ rem
1034
+ .command("resume")
1035
+ .description("Resume nightly REM runs — removes the pause sentinel")
1036
+ .action(() => {
1037
+ if (existsSync(REM_PAUSE_FLAG)) {
1038
+ rmSync(REM_PAUSE_FLAG);
1039
+ console.log(`✅ REM nightly runs resumed (removed ${REM_PAUSE_FLAG})`);
1040
+ }
1041
+ else {
1042
+ console.log(`(REM was not paused — no sentinel at ${REM_PAUSE_FLAG})`);
1043
+ }
1044
+ if (process.env.FLAIR_REM_PAUSE === "1") {
1045
+ console.log(`\n⚠ FLAIR_REM_PAUSE=1 env var is also set; unset it to fully resume.`);
1046
+ }
1047
+ });
1048
+ }