@tpsdev-ai/flair 0.31.1 → 0.33.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.
@@ -243,8 +243,27 @@ async function runDedupGate(ctx, content) {
243
243
  return findConservativeDedupMatch(ctx, content.agentId, content.content, embedding, cosineThreshold, lexicalThreshold);
244
244
  }
245
245
  /** Build the final write response: always `written: true`, always includes
246
- * `id`, and layers the dedup collision signal on top when present. Never a
247
- * code path where a match suppresses these base fields. */
246
+ * `id`, `visibility`, and layers the dedup collision signal on top when
247
+ * present. Never a code path where a match suppresses these base fields.
248
+ *
249
+ * ── Why `visibility` is in the write response (flair#991) ──────────────────
250
+ * Visibility is the one field on a memory the caller most often does NOT
251
+ * set and yet most needs to know: the durability-keyed default above stamps
252
+ * `private` for a bare write and `shared` for a permanent/persistent one, so
253
+ * "who can read this" is decided by a rule the writer never typed. Returning
254
+ * it makes the landed value observable on EVERY write surface at once —
255
+ * `flair memory add`'s printed JSON, the REST response, the native /mcp
256
+ * `memory_store` result, and packages/flair-mcp's `effectiveVisibility` line
257
+ * (which read this field all along and had nothing to read, so it always
258
+ * rendered "(server default)").
259
+ *
260
+ * Read from `content`, not from `base`: `content.visibility` is the value
261
+ * that was actually persisted a few lines earlier, and assigning after the
262
+ * `...base` spread means the persisted value wins over anything the storage
263
+ * layer echoes back. Omitted (not `null`) when unset, which happens only on
264
+ * the put()-over-an-existing-record path where a partial merge carried no
265
+ * visibility — reporting `null` there would read as "no one but the owner",
266
+ * the opposite of what an absent field means to `isPrivateVisibility()`. */
248
267
  function buildWriteResponse(content, result, dedupMatch) {
249
268
  const base = result && typeof result === "object" && !Array.isArray(result) ? result : {};
250
269
  const response = {
@@ -253,6 +272,9 @@ function buildWriteResponse(content, result, dedupMatch) {
253
272
  written: true,
254
273
  deduplicated: !!dedupMatch,
255
274
  };
275
+ if (content.visibility !== undefined && content.visibility !== null) {
276
+ response.visibility = content.visibility;
277
+ }
256
278
  if (dedupMatch) {
257
279
  response.matchedId = dedupMatch.matchedId;
258
280
  response.matchConfidence = { cosine: dedupMatch.cosine, lexical: dedupMatch.lexical };
@@ -0,0 +1,386 @@
1
+ /**
2
+ * ─── Public in-process API (flair#956) ───────────────────────────────────────
3
+ *
4
+ * The facade that hides four internal implementation details a Harper engineer
5
+ * should never have to learn:
6
+ *
7
+ * 1. A deep import path into our dist/
8
+ * 2. That server.resources is keyed by REST path with no leading slash
9
+ * 3. That the registry entry wraps the class in .Resource
10
+ * 4. That creates need collectionResource() while reads do not
11
+ *
12
+ * And collapses the agentId double-pass (context + body) into one.
13
+ *
14
+ * ```ts
15
+ * import { Flair } from "@tpsdev-ai/flair";
16
+ * const flair = new Flair(server);
17
+ * const planner = flair.as("planner");
18
+ * await planner.memory.write("deploy runs at 0200 UTC");
19
+ * ```
20
+ *
21
+ * The facade does NOT hide the security boundary. In-process identity is
22
+ * asserted, not verified — co-location IS the grant. flair.as(id) requires a
23
+ * non-empty id (runtime throw). flair.admin and flair.internal are separate,
24
+ * greppable properties. The docs say plainly: build the context from your own
25
+ * server-side state, never from request data.
26
+ *
27
+ * ── Internal implementation ─────────────────────────────────────────────────
28
+ * Every operation delegates to the existing primitives in ./in-process.js
29
+ * (agentContext, adminContext, internalContext, collectionResource). The facade
30
+ * is additive — existing code using the raw seam continues to work.
31
+ */
32
+ import { agentContext, adminContext, internalContext, collectionResource, InProcessContextError, } from "./in-process.js";
33
+ // ─── Re-export for the "./server" entry point ────────────────────────────────
34
+ export { agentContext, adminContext, internalContext, collectionResource, InProcessContextError };
35
+ // ─── Resource resolution ─────────────────────────────────────────────────────
36
+ /**
37
+ * Resolve a Flair resource class from the Harper server registry.
38
+ * Throws with a helpful message listing available resources if not found.
39
+ */
40
+ function resolveResource(server, name) {
41
+ const entry = server.resources.get?.(name) ?? server.resources.getMatch?.(name);
42
+ if (!entry?.Resource) {
43
+ const keys = [...server.resources.keys()].sort();
44
+ const available = keys.length > 0 ? keys.join(", ") : "(none)";
45
+ throw new Error(`Flair is not loaded in this Harper instance.\n` +
46
+ `The '${name}' resource was not found in the registry.\n` +
47
+ `Available: [${available}]\n` +
48
+ `Make sure @tpsdev-ai/flair is installed as a component of this instance.`);
49
+ }
50
+ return entry.Resource;
51
+ }
52
+ // ─── AgentHandle ─────────────────────────────────────────────────────────────
53
+ /**
54
+ * A handle that carries agent identity and scopes every operation to that agent.
55
+ *
56
+ * Returned by {@link Flair.as}. The agentId is validated at construction time
57
+ * (runtime, not types) — missing, empty, blank, or non-string throws
58
+ * {@link InProcessContextError}.
59
+ *
60
+ * **Security:** in-process identity is asserted, not verified. Build the
61
+ * agentId from your own server-side state, never from request data.
62
+ */
63
+ export class AgentHandle {
64
+ agentId;
65
+ #server;
66
+ #ctx;
67
+ constructor(server, agentId) {
68
+ this.#server = server;
69
+ this.agentId = agentId;
70
+ // Throws InProcessContextError on missing/empty/blank id — see
71
+ // resources/in-process.ts's safety-design block for why this is
72
+ // not merely defensive.
73
+ this.#ctx = agentContext(agentId);
74
+ }
75
+ /** Memory operations scoped to this agent. */
76
+ get memory() {
77
+ return new AgentMemory(this.#server, this.#ctx, this.agentId);
78
+ }
79
+ /**
80
+ * Semantic search scoped to this agent.
81
+ *
82
+ * ```ts
83
+ * const hits = await planner.recall("deploy schedule", { limit: 5 });
84
+ * ```
85
+ */
86
+ async recall(query, opts) {
87
+ const Cls = resolveResource(this.#server, "SemanticSearch");
88
+ const h = new Cls(undefined, this.#ctx);
89
+ const body = { q: query, limit: opts?.limit ?? 5 };
90
+ if (opts?.includeTrust === true)
91
+ body.includeTrust = true;
92
+ if (opts?.abstain === true)
93
+ body.abstain = true;
94
+ if (opts?.scoring)
95
+ body.scoring = opts.scoring;
96
+ if (opts?.minScore !== undefined)
97
+ body.minScore = opts.minScore;
98
+ if (opts?.since)
99
+ body.since = opts.since;
100
+ if (opts?.asOf)
101
+ body.asOf = opts.asOf;
102
+ if (opts?.tag)
103
+ body.tag = opts.tag;
104
+ if (opts?.subject)
105
+ body.subject = opts.subject;
106
+ if (opts?.subjects)
107
+ body.subjects = opts.subjects;
108
+ return unwrap(await h.post(body));
109
+ }
110
+ }
111
+ // ─── AgentMemory (per-agent memory operations) ───────────────────────────────
112
+ class AgentMemory {
113
+ #server;
114
+ #ctx;
115
+ #agentId;
116
+ constructor(server, ctx, agentId) {
117
+ this.#server = server;
118
+ this.#ctx = ctx;
119
+ this.#agentId = agentId;
120
+ }
121
+ /**
122
+ * Write a memory as this agent.
123
+ *
124
+ * The agentId is stamped from the handle's context — the caller never
125
+ * passes it, and any agentId in opts is overwritten. This collapses the
126
+ * double-pass (context + body) into one.
127
+ */
128
+ async write(content, opts) {
129
+ const Cls = resolveResource(this.#server, "Memory");
130
+ const h = await collectionResource(Cls, this.#ctx);
131
+ const body = {
132
+ agentId: this.#agentId,
133
+ content,
134
+ };
135
+ if (opts?.durability)
136
+ body.durability = opts.durability;
137
+ if (opts?.visibility)
138
+ body.visibility = opts.visibility;
139
+ if (opts?.tags)
140
+ body.tags = opts.tags;
141
+ if (opts?.type)
142
+ body.type = opts.type;
143
+ if (opts?.id)
144
+ body.id = opts.id;
145
+ return unwrap(await h.post(body));
146
+ }
147
+ /** Get a memory by id, scoped to this agent. */
148
+ async get(id) {
149
+ const Cls = resolveResource(this.#server, "Memory");
150
+ const h = new Cls(undefined, this.#ctx);
151
+ return unwrap(await h.get(id));
152
+ }
153
+ /**
154
+ * Search memories scoped to this agent.
155
+ *
156
+ * Delegates to Memory.search() which applies the agent's read scope
157
+ * (own memories + granted owners' shared memories).
158
+ */
159
+ async search(opts) {
160
+ const Cls = resolveResource(this.#server, "Memory");
161
+ const h = new Cls(undefined, this.#ctx);
162
+ const conditions = [];
163
+ if (opts?.tags) {
164
+ for (const tag of opts.tags) {
165
+ conditions.push({ search_attribute: "tags", search_type: "contains", search_value: tag });
166
+ }
167
+ }
168
+ if (opts?.type) {
169
+ conditions.push({ search_attribute: "type", search_type: "equals", search_value: opts.type });
170
+ }
171
+ if (opts?.durability) {
172
+ conditions.push({ search_attribute: "durability", search_type: "equals", search_value: opts.durability });
173
+ }
174
+ if (opts?.visibility) {
175
+ conditions.push({ search_attribute: "visibility", search_type: "equals", search_value: opts.visibility });
176
+ }
177
+ const query = conditions.length > 0 ? { conditions, operator: "and" } : undefined;
178
+ return unwrap(await h.search(query));
179
+ }
180
+ }
181
+ // ─── AdminHandle ─────────────────────────────────────────────────────────────
182
+ /**
183
+ * Flair-admin operations — unfiltered reads, cross-agent writes.
184
+ *
185
+ * **This is a root shell.** Every call site is greppable via
186
+ * `git grep "flair.admin"`. Use for provisioning and maintenance only,
187
+ * never as a request handler's default.
188
+ *
189
+ * The admin agentId is validated at construction time (same guard as
190
+ * {@link AgentHandle}).
191
+ */
192
+ export class AdminHandle {
193
+ agentId;
194
+ #server;
195
+ #ctx;
196
+ constructor(server, agentId) {
197
+ this.#server = server;
198
+ this.agentId = agentId;
199
+ this.#ctx = adminContext(agentId);
200
+ }
201
+ /**
202
+ * Register an agent through the Agent resource (full Principal shape).
203
+ *
204
+ * ```ts
205
+ * await flair.admin.registerAgent("planner", { publicKey: "pending" });
206
+ * ```
207
+ */
208
+ async registerAgent(id, opts) {
209
+ const Cls = resolveResource(this.#server, "Agent");
210
+ const h = await collectionResource(Cls, this.#ctx);
211
+ const body = {
212
+ id,
213
+ name: id,
214
+ displayName: opts?.displayName ?? id,
215
+ publicKey: opts?.publicKey ?? "pending",
216
+ runtime: opts?.runtime ?? "headless",
217
+ };
218
+ if (opts?.admin === true)
219
+ body.admin = true;
220
+ return unwrap(await h.post(body));
221
+ }
222
+ /** Memory operations with admin authority (unfiltered reads, cross-agent writes). */
223
+ get memory() {
224
+ return new AdminMemory(this.#server, this.#ctx, this.agentId);
225
+ }
226
+ }
227
+ // ─── AdminMemory ─────────────────────────────────────────────────────────────
228
+ class AdminMemory {
229
+ #server;
230
+ #ctx;
231
+ #agentId;
232
+ constructor(server, ctx, agentId) {
233
+ this.#server = server;
234
+ this.#ctx = ctx;
235
+ this.#agentId = agentId;
236
+ }
237
+ /** Read any memory by id, unfiltered. */
238
+ async get(id) {
239
+ const Cls = resolveResource(this.#server, "Memory");
240
+ const h = new Cls(undefined, this.#ctx);
241
+ return unwrap(await h.get(id));
242
+ }
243
+ /**
244
+ * Write a memory attributed to another agent.
245
+ *
246
+ * ```ts
247
+ * await flair.admin.memory.write("researcher", "provisioned memory", { visibility: "shared" });
248
+ * ```
249
+ */
250
+ async write(asAgentId, content, opts) {
251
+ const Cls = resolveResource(this.#server, "Memory");
252
+ // Use adminContext for the acting admin, but stamp the target agentId
253
+ // on the body so the memory is owned by the target agent.
254
+ const h = await collectionResource(Cls, this.#ctx);
255
+ const body = {
256
+ agentId: asAgentId,
257
+ content,
258
+ };
259
+ if (opts?.durability)
260
+ body.durability = opts.durability;
261
+ if (opts?.visibility)
262
+ body.visibility = opts.visibility;
263
+ if (opts?.tags)
264
+ body.tags = opts.tags;
265
+ if (opts?.type)
266
+ body.type = opts.type;
267
+ if (opts?.id)
268
+ body.id = opts.id;
269
+ return unwrap(await h.post(body));
270
+ }
271
+ }
272
+ // ─── InternalHandle ──────────────────────────────────────────────────────────
273
+ /**
274
+ * Trusted, unattributed, unfiltered operations — Flair's `internal` verdict.
275
+ *
276
+ * Reads see every agent's private records; writes are owned by nobody.
277
+ * This exists for work that is genuinely infrastructure: provisioning a
278
+ * principal, a migration, a maintenance sweep.
279
+ *
280
+ * Every call site is greppable via `git grep "flair.internal"`.
281
+ */
282
+ export class InternalHandle {
283
+ #server;
284
+ #ctx;
285
+ constructor(server) {
286
+ this.#server = server;
287
+ this.#ctx = internalContext();
288
+ }
289
+ /** Raw Agent table access for provisioning. */
290
+ get agentTable() {
291
+ return new InternalAgentTable(this.#server, this.#ctx);
292
+ }
293
+ }
294
+ // ─── InternalAgentTable ──────────────────────────────────────────────────────
295
+ class InternalAgentTable {
296
+ #server;
297
+ #ctx;
298
+ constructor(server, ctx) {
299
+ this.#server = server;
300
+ this.#ctx = ctx;
301
+ }
302
+ /** Write directly to the Agent resource (bypasses admin gate via internal context). */
303
+ async put(record) {
304
+ const Cls = resolveResource(this.#server, "Agent");
305
+ const h = await collectionResource(Cls, this.#ctx);
306
+ return unwrap(await h.post(record));
307
+ }
308
+ }
309
+ // ─── Flair (the facade) ──────────────────────────────────────────────────────
310
+ /**
311
+ * The public in-process API for Flair embedded in a Harper app.
312
+ *
313
+ * One handle per Harper instance. Resolves resources lazily on first use.
314
+ *
315
+ * ```ts
316
+ * import { Flair } from "@tpsdev-ai/flair";
317
+ * const flair = new Flair(server);
318
+ * const planner = flair.as("planner");
319
+ * await planner.memory.write("deploy runs at 0200 UTC");
320
+ * ```
321
+ *
322
+ * **Security:** In-process identity is asserted, not verified — co-location
323
+ * IS the grant. Build the agentId from your own server-side state, never
324
+ * from request data. `flair.as(id)` requires a non-empty id (runtime throw).
325
+ * `flair.admin` and `flair.internal` are separate, greppable properties for
326
+ * deliberate escalation.
327
+ */
328
+ export class Flair {
329
+ #server;
330
+ #adminHandle;
331
+ constructor(server) {
332
+ this.#server = server;
333
+ }
334
+ /**
335
+ * Return a handle that acts as the given agent.
336
+ *
337
+ * The agentId is runtime-validated: missing, empty, blank, or non-string
338
+ * throws {@link InProcessContextError}. Build it from your own server-side
339
+ * state, never from request data.
340
+ */
341
+ as(agentId) {
342
+ return new AgentHandle(this.#server, agentId);
343
+ }
344
+ /**
345
+ * Admin operations — unfiltered reads, cross-agent writes.
346
+ *
347
+ * **This is a root shell.** Every call site is greppable via
348
+ * `git grep "flair.admin"`. Use for provisioning and maintenance only.
349
+ */
350
+ get admin() {
351
+ // AdminHandle requires an agentId for attribution. We use a sentinel
352
+ // that makes the admin identity visible in audit logs. The caller
353
+ // should use a real admin agent id when possible.
354
+ // Cached: the getter returns the same handle on every access so
355
+ // flair.admin === flair.admin is true (flair#981).
356
+ this.#adminHandle ??= new AdminHandle(this.#server, "_admin");
357
+ return this.#adminHandle;
358
+ }
359
+ /**
360
+ * Internal operations — trusted, unattributed, unfiltered.
361
+ *
362
+ * Every call site is greppable via `git grep "flair.internal"`.
363
+ * Use for infrastructure work only: provisioning, migrations, maintenance.
364
+ */
365
+ get internal() {
366
+ return new InternalHandle(this.#server);
367
+ }
368
+ }
369
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
370
+ /**
371
+ * Unwrap a handler return value into a plain object.
372
+ * Handlers may return a `Response` (the 401/403/400 guards) — surface its
373
+ * JSON body so the caller sees the structured error rather than an opaque object.
374
+ */
375
+ async function unwrap(value) {
376
+ if (value && typeof value === "object" && typeof value.json === "function" && "status" in value) {
377
+ try {
378
+ const body = await value.json();
379
+ return { error: body?.error ?? "request failed", status: value.status, ...body };
380
+ }
381
+ catch {
382
+ return { error: "request failed", status: value.status };
383
+ }
384
+ }
385
+ return value;
386
+ }
@@ -159,6 +159,37 @@ async function memoryStore(agent, args) {
159
159
  // id post-commit through the shared usage ledger).
160
160
  if (Array.isArray(args?.usedMemoryIds))
161
161
  body.usedMemoryIds = args.usedMemoryIds;
162
+ // flair#991 writer-controlled sharing intent. Forwarded ONLY when the caller
163
+ // actually supplied it, so an omitted visibility delegates a byte-identical
164
+ // body and Memory.post() applies its durability-keyed default.
165
+ //
166
+ // ── Why an unrecognized value is REJECTED, not dropped and not passed on ──
167
+ // `visibility` is a free-form String in schemas/memory.graphql, and the read
168
+ // scope asks `isPrivateVisibility()` — an exact match on the literal
169
+ // "private" — so EVERY other string, typos included, reads as non-private
170
+ // and is returned to every agent on the instance. Both of the softer
171
+ // options therefore fail in the unsafe direction:
172
+ // - forwarding it: `visibility: "prvate"` persists a row the caller
173
+ // believes is owner-only and that every agent can in fact read;
174
+ // - silently dropping it: falls back to the durability-keyed default,
175
+ // which for a permanent/persistent write is `shared` — same outcome,
176
+ // with no argument left in the record to explain it.
177
+ // A misspelled argument must never widen who can read a memory, so the tool
178
+ // call fails and says so. The allowlist is deliberately not derived from
179
+ // isPrivateVisibility(): that predicate must stay "is it exactly private"
180
+ // for the no-visibility-field migration invariant (see
181
+ // resources/memory-visibility.ts), which is a READ-side rule and cannot
182
+ // double as a WRITE-side allowlist.
183
+ if (args?.visibility !== undefined && args?.visibility !== null) {
184
+ if (args.visibility !== "private" && args.visibility !== "shared") {
185
+ return {
186
+ error: "invalid_visibility",
187
+ status: 400,
188
+ message: `visibility must be "private" or "shared" (got: ${JSON.stringify(args.visibility)}). Omit it to use the durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private.`,
189
+ };
190
+ }
191
+ body.visibility = args.visibility;
192
+ }
162
193
  return unwrap(await h.post(body));
163
194
  }
164
195
  /**
@@ -407,6 +438,15 @@ export const TOOLS = {
407
438
  type: { type: "string", enum: ["session", "lesson", "decision", "preference", "fact", "goal"], description: "Memory type (default session)" },
408
439
  durability: { type: "string", enum: ["permanent", "persistent", "standard", "ephemeral"], description: "permanent > persistent > standard > ephemeral (default standard)" },
409
440
  tags: { type: "array", items: { type: "string" }, description: "Tag strings" },
441
+ visibility: {
442
+ type: "string",
443
+ enum: ["private", "shared"],
444
+ description: "Writer-controlled sharing intent. Omit to use the server's durability-keyed default: " +
445
+ "permanent/persistent -> shared, standard/ephemeral -> private. " +
446
+ "private — owner-only, never visible to another agent, even one holding a memory grant. " +
447
+ "shared — visible to the owner and every other agent on this instance. " +
448
+ "The visibility the write actually landed on is returned in the result.",
449
+ },
410
450
  usedMemoryIds: { type: "array", items: { type: "string" }, description: "IDs of memories that informed this write (citation-on-write). Credited via the same deduped usage ledger as record_usage. Optional." },
411
451
  },
412
452
  required: ["content"],
@@ -0,0 +1,35 @@
1
+ # Which Flair deployment shape are you in?
2
+
3
+ Flair runs in one of three shapes. Pick yours and follow only that path.
4
+
5
+ | You want to... | Shape | Start here |
6
+ |---|---|---|
7
+ | Run Flair on your own machine or VPS. `flair init` installs Harper, creates your agent identity, and you're running. | **Standalone local** | [standalone-local.md](standalone-local.md) |
8
+ | Run Flair on [Harper Fabric](https://www.harperdb.io/) — managed hosting, multi-region replication, no shell on the node. You deploy a component; agents connect over HTTPS. | **Hosted on Fabric** | [hosted-on-fabric.md](hosted-on-fabric.md) |
9
+ | Load Flair into a Harper instance you already run. In-process calls — no HTTP, no second process, no key to distribute. Over HTTP it is one memory API among many. | **Embedded in a Harper app** | [embedding-in-a-harper-app.md](embedding-in-a-harper-app.md) |
10
+
11
+ ---
12
+
13
+ ## What changes across shapes
14
+
15
+ Not everything is the same. The critical differences:
16
+
17
+ ### Identity
18
+
19
+ - **Standalone local & Fabric (over HTTP):** Agents authenticate with Ed25519-signed requests. Each agent holds a private key and signs `agentId:timestamp:nonce:METHOD:/path` on every request. The server verifies the signature against the agent's registered public key.
20
+ - **Embedded (in-process):** Identity is **asserted**, not verified. You pass the agent id via the call context — `agentContext("mybot")` — and Flair acts as that agent. No key, no signature, no `Agent`-table lookup. Co-location *is* the trust boundary: a caller inside the same Harper instance could write the storage tables directly anyway, so demanding a signature from same-process code would be theatre. **Never build the context from request data** — that is privilege escalation with no error and no trace.
21
+
22
+ ### Upgrade
23
+
24
+ - **Standalone local:** `flair upgrade` — install, restart, verify, rollback-on-failure in one step.
25
+ - **Hosted on Fabric:** `flair upgrade --target <fabric-url>` — resolves the target version, stages a clean deployable, and pushes it. Fabric has no `flair upgrade` equivalent to the local path; redeploy the component.
26
+ - **Embedded:** Flair follows your host app's dependency lifecycle. When you update `@tpsdev-ai/flair` in your app's `package.json` and redeploy, Flair upgrades with the rest. There is no `flair upgrade` — it's a component, not a standalone process.
27
+
28
+ ### Federation
29
+
30
+ - **Standalone local & Fabric:** Available. Hub-and-spoke sync with pairing tokens, signed requests, and originator enforcement. The CLI drives pairing (`flair federation pair`).
31
+ - **Embedded:** Pairing is **CLI-only** today — it requires shell access on the node ([flair#947](https://github.com/tpsdev-ai/flair/issues/947)). If your deployment has no shell, federation is not available.
32
+
33
+ ### `@export`
34
+
35
+ `@export` on a GraphQL schema means **REST exposure only** — it does not mean replication. `Memory` has no `@export` and still replicates in a Harper Fabric cluster (replication is per-database, not per-export). This is a genuine and easily-inverted trap: do not assume `@export` controls sync or that the absence of `@export` means "not replicated."
@@ -192,10 +192,10 @@ Set these in the Flair process environment (`~/Library/LaunchAgents/ai.tpsdev.fl
192
192
 
193
193
  ```bash
194
194
  # Backup all data (agents, memories, souls)
195
- flair backup > ~/flair-backup-$(date +%Y%m%d).json
195
+ flair backup --output ~/flair-backup-$(date +%Y%m%d).json --admin-pass-file ~/.flair/admin-pass
196
196
 
197
197
  # Restore to a fresh instance
198
- flair restore < ~/flair-backup-20260405.json
198
+ flair restore ~/flair-backup-20260405.json
199
199
  ```
200
200
 
201
201
  Always backup before upgrades.