@tpsdev-ai/flair 0.30.0 → 0.31.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.
Files changed (49) hide show
  1. package/README.md +194 -377
  2. package/dist/cli.js +1355 -281
  3. package/dist/deploy.js +212 -24
  4. package/dist/fabric-upgrade.js +16 -1
  5. package/dist/federation/scheduler.js +500 -0
  6. package/dist/install/clients.js +111 -53
  7. package/dist/lib/mcp-spec.js +128 -0
  8. package/dist/lib/safe-snapshot-extract.js +231 -0
  9. package/dist/lib/scheduler-platform.js +128 -0
  10. package/dist/lib/xml-escape.js +54 -0
  11. package/dist/rem/scheduler.js +35 -87
  12. package/dist/rem/snapshot.js +13 -0
  13. package/dist/replication-convergence.js +505 -0
  14. package/dist/resources/MemoryBootstrap.js +7 -8
  15. package/dist/resources/SemanticSearch.js +17 -45
  16. package/dist/resources/abstention.js +1 -1
  17. package/dist/resources/embeddings-boot.js +10 -12
  18. package/dist/resources/embeddings-provider.js +10 -7
  19. package/dist/resources/health.js +24 -19
  20. package/dist/resources/in-process.js +225 -0
  21. package/dist/resources/mcp-tools.js +23 -17
  22. package/dist/resources/migration-boot.js +80 -10
  23. package/dist/resources/migrations/data-dir.js +205 -0
  24. package/dist/resources/migrations/progress.js +33 -0
  25. package/dist/resources/migrations/runner.js +29 -2
  26. package/dist/resources/migrations/state.js +13 -2
  27. package/dist/resources/models-dir.js +18 -9
  28. package/dist/resources/semantic-retrieval-core.js +5 -4
  29. package/dist/src/lib/scheduler-platform.js +128 -0
  30. package/dist/src/lib/xml-escape.js +54 -0
  31. package/dist/src/rem/scheduler.js +35 -87
  32. package/docs/deploying-on-fabric.md +267 -0
  33. package/docs/deployment.md +5 -0
  34. package/docs/embedding-in-a-harper-app.md +299 -0
  35. package/docs/federation.md +61 -4
  36. package/docs/integrations.md +3 -0
  37. package/docs/mcp-clients.md +16 -7
  38. package/docs/quickstart.md +80 -54
  39. package/docs/releasing.md +72 -38
  40. package/docs/supply-chain-policy.md +36 -0
  41. package/docs/troubleshooting.md +24 -0
  42. package/docs/upgrade.md +98 -3
  43. package/package.json +1 -11
  44. package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
  45. package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
  46. package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
  47. package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
  48. package/dist/resources/rerank-provider.js +0 -569
  49. package/docs/rerank-provisioning.md +0 -101
@@ -0,0 +1,299 @@
1
+ # Embedding Flair in a Harper app
2
+
3
+ Flair is a Harper component. If your app already runs on Harper, load Flair into the same instance and call it **in-process** — no HTTP, no second process, and **no shell on the node**.
4
+
5
+ Everything below is code you run inside your own component. The CLI is an alternative for local development, not a requirement — see [If you have shell access](#if-you-have-shell-access).
6
+
7
+ | | Embedded | Standalone ([deployment.md](deployment.md)) |
8
+ |---|---|---|
9
+ | Latency | A method call | HTTP round trip |
10
+ | Agent identity | You assert it per call | Ed25519 signature, verified server-side |
11
+ | Trust boundary | Callers are **inside** it | Callers are outside it, and must authenticate |
12
+
13
+ Embedding *adds* the in-process path; `rest: true` keeps serving MCP clients and remote agents as before.
14
+
15
+ ---
16
+
17
+ ## Quickstart
18
+
19
+ **1. Add Flair to your instance.** Deploy `@tpsdev-ai/flair` as a component the way you deploy your own — Fabric's component deploy, Studio, or your pipeline. Its tables are declared `@table(database: "flair")`, so they never collide with yours.
20
+
21
+ **2. Resolve the resource and write a memory.**
22
+
23
+ ```javascript
24
+ import { server } from "harper";
25
+ // Flair ships these two helpers so you do not have to get either of them right
26
+ // by hand. They are the whole in-process contract.
27
+ import { agentContext, internalContext, collectionResource } from "@tpsdev-ai/flair/dist/resources/in-process.js";
28
+
29
+ // The RESOURCE — carries auth, scoping, visibility, embedding.
30
+ // NOT databases.flair.Memory: that is the raw table, and enforces none of it.
31
+ // Keys carry NO leading slash: get("Memory"), never get("/Memory").
32
+ const flair = (path) => server.resources.get(path).Resource;
33
+
34
+ export async function remember(agentId, content, opts = {}) {
35
+ // A create needs a COLLECTION-bound instance. `new Cls(...)` does not give
36
+ // you one, and cannot be made to — see the note below.
37
+ const h = await collectionResource(flair("Memory"), agentContext(agentId));
38
+ return h.post({
39
+ agentId, // required — an absent one is never filled in
40
+ content,
41
+ durability: opts.durability ?? "standard",
42
+ });
43
+ }
44
+ ```
45
+
46
+ > ### Why `collectionResource`, and not `new Memory()`
47
+ >
48
+ > A resource's `post()` only works on an instance Harper has marked as a **collection**, and that mark is a *private* field only Harper's own `getResource()` can set. The public `isCollection` is a getter with no setter, so the obvious spelling fails two different ways, neither of which names the cause:
49
+ >
50
+ > ```javascript
51
+ > const h = new (flair("Memory"))(undefined, agentContext(agentId));
52
+ > h.isCollection = true; // TypeError: Cannot set property isCollection ... which has only a getter
53
+ > h.post({ ... }); // without the line above: 405 "The Memory does not have a post method implemented"
54
+ > ```
55
+ >
56
+ > `collectionResource(Cls, context)` is a two-line wrapper over the supported call — `Cls.getResource({}, context, { isCollection: true })` — and exists so this is written once. **Reads do not need it:** `Cls.get(id, context)` and `Cls.search(query, context)` thread the context themselves.
57
+
58
+ > ### ⚠️ A resource with no context is an administrator
59
+ >
60
+ > A resource built without a context resolves to Flair's trusted `internal` verdict and runs **unfiltered** — every read unscoped, every write unowned. Silently. No error, no warning, no trace.
61
+ >
62
+ > Measured, not inferred: a context-less `Memory.search()` returns every agent's `private` records, and so does a context-less `SemanticSearch`.
63
+ >
64
+ > Correct for Flair's own maintenance passes. In your app it is a data leak you find months later.
65
+ >
66
+ > **Make `agentId` a required argument, as above.** Never export a version that defaults it.
67
+
68
+ **3. Read it back, scoped to that agent.**
69
+
70
+ ```javascript
71
+ export async function recall(agentId, query, limit = 5) {
72
+ const h = await collectionResource(flair("SemanticSearch"), agentContext(agentId));
73
+ return h.post({ q: query, limit });
74
+ }
75
+ ```
76
+
77
+ **4. Verify it worked, still in-process.**
78
+
79
+ ```javascript
80
+ await remember("agent-alpha", "deploy runs at 0200 UTC");
81
+ console.log(await recall("agent-alpha", "deploy schedule"));
82
+ console.log([...server.resources.keys()].sort()); // what Flair registered
83
+ ```
84
+
85
+ > ### What we measured, so you do not have to
86
+ >
87
+ > Run end to end on **Harper 5.1.22**, from a second component loaded into the same instance — the exact shape above. `test/integration/in-process-agents.test.ts` in the Flair repo is that run, and `test/fixtures/inproc-app` is the component it drives.
88
+ >
89
+ > | Claim | Result |
90
+ > |---|---|
91
+ > | `server.resources.get("Memory")` from another component | Returns an **entry object** `{ Resource, path, exportTypes, hasSubPaths, relativeURL }` — `.Resource` is required, it is not the class itself |
92
+ > | `.Resource` is Flair's resource, not the raw table | Confirmed: prototype chain `Memory → Memory → Resource`, and it is **not** `databases.flair.Memory` |
93
+ > | Key format | **No leading slash.** `get("Memory")` hits; `get("/Memory")` returns `undefined` |
94
+ > | `getMatch` | `getMatch("Memory")` hits. **`getMatch("/Memory")` misses** — do not use the slashed form |
95
+ > | When the lookup becomes valid | Flair's resources were already registered at the app component's **module top level** (55 entries, `Memory` and `Agent` present). The only entry missing at that moment was the app's *own*, still mid-registration. Resolving lazily, as above, is still the advice — it costs nothing and does not depend on component load order |
96
+ > | Per-agent scoping through `SemanticSearch` | Holds. Querying as `agent-beta` for a topic only `agent-alpha` has written returns **beta's own** memory, never alpha's private one — with real 768-dim embeddings attached, not a degraded path |
97
+ > | Cross-agent by-id read | `Memory.get(<beta's private id>)` as alpha returns **404**, never 403 — a denied caller cannot enumerate ids |
98
+ > | Context-less call | Unfiltered across all agents, via both `search` and `SemanticSearch` (see the warning above) |
99
+
100
+ Handlers return a `Response` for `401`/`403`/`400` rather than throwing — check for one. `Memory.post()` is in-process only; over HTTP the schema exposes `PUT`.
101
+
102
+ ---
103
+
104
+ ## N agents in one process
105
+
106
+ **Acting as an agent needs nothing but the context.** Identity resolves per call from `request.tpsAgent`, so one process serves any number of agents — no client to construct, no key to load, no per-agent setup:
107
+
108
+ ```javascript
109
+ for (const id of ["planner", "researcher", "reviewer"]) {
110
+ await remember(id, `${id} came online`);
111
+ }
112
+ ```
113
+
114
+ `tpsAgent` is **not** checked against the `Agent` table, so this works with no registration at all. Register agents anyway — the admin UI, federation, and the HTTP path all read those records.
115
+
116
+ ### Registering agents, no CLI
117
+
118
+ Go through the `Agent` **resource**, with no context — provisioning is infrastructure work your app has already authorised, and `Agent.post()` fills in the whole Principal shape for you:
119
+
120
+ ```javascript
121
+ export async function registerAgent(id, { publicKey = "pending", admin = false } = {}) {
122
+ const h = await collectionResource(flair("Agent"), internalContext()); // provisioning is infrastructure, not an agent's write
123
+ return h.post({
124
+ id, name: id, displayName: id,
125
+ publicKey, // a placeholder is fine — see below
126
+ runtime: "headless",
127
+ ...(admin ? { role: "admin", admin: true } : {}), // role is what actually grants admin
128
+ });
129
+ }
130
+ ```
131
+
132
+ Verified against a real instance: that lands `kind: "agent"`, `status: "active"`, `displayName`, `admin: false`, `defaultTrustTier: "unverified"`, `type: "agent"`, `createdAt`/`updatedAt` and the federation `originatorInstanceId` stamp — without you naming any of them.
133
+
134
+ > **Prefer this to `databases.flair.Agent.put()`.** The raw table applies **no** defaults, so a hand-written literal has to reproduce every field above and then stay in step with Flair as the Principal model grows. Records written that way are missing `kind`/`status`/`defaultTrustTier` and read as under-specified Principals in the admin surfaces.
135
+
136
+ > **`isAdmin()` reads `role === "admin"`, not the `admin` boolean.** They are separate fields and only `role` grants admin rights; set both to keep the record self-consistent. Admin lookups are cached for 60 seconds, so a newly-created admin is not effective immediately.
137
+
138
+ `publicKey` is non-nullable in the schema, but it does not have to be a real key. An agent that only ever acts in-process never authenticates, and Flair's own paths write placeholders — `"pending"` when seeding, `mcp-oauth:<sub>` for token-authenticated agents. Give an agent a real key only if it must also authenticate **over HTTP**, which your app can do without any CLI:
139
+
140
+ ```javascript
141
+ import { generateKeyPairSync } from "node:crypto";
142
+
143
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
144
+ const raw = publicKey.export({ format: "der", type: "spki" }).subarray(-32);
145
+ await registerAgent("remote-worker", { publicKey: raw.toString("hex") });
146
+ // keep `privateKey` in your own secret store — Flair never sees it
147
+ ```
148
+
149
+ Flair accepts the public key as 64-char hex, or base64 of the raw 32 bytes.
150
+
151
+ Both verified end to end: an agent registered this way, with a key its app minted, then authenticated over HTTP with a real `TPS-Ed25519` signature — and a request signed with the *wrong* key was rejected.
152
+
153
+ > **Two traps.** `Agent.put()` on the *resource* silently strips `publicKey`, so set the key when you **create** the record; to rotate one later, write `databases.flair.Agent.put({ ...existing, publicKey })` on the raw table (which is what `flair agent rotate-key` does through the admin ops API — there is no dedicated endpoint today). And do **not** call `AgentSeed` in-process: although its `allowCreate()` explicitly permits the trusted `internal` verdict, its `post()` then re-checks for a named admin and returns `403 forbidden: admin only` — confirmed by running it.
154
+
155
+ ---
156
+
157
+ ## The table is not the resource
158
+
159
+ Flair declares `Memory` as a table, then exports a **subclass** as the resource. Harper's loader registers exported classes in the routing map only, never back into `databases`, so the two stay distinct:
160
+
161
+ | Import | What you get |
162
+ |---|---|
163
+ | `server.resources.get("Memory").Resource` | **The resource.** Auth, read-scoping, private-memory exclusion, no-forge attribution, embedding generation, default visibility, rate limiting. |
164
+ | `databases.flair.Memory` | **The table.** Raw storage. None of the above. |
165
+
166
+ Flair uses the raw table where it *wants* to bypass its own rules — the federation merge path writes through it so a synced record's origin stamp survives. Use it only for work you have authorised yourself: provisioning, migrations, admin sweeps, reporting. Never for a memory written on an agent's behalf.
167
+
168
+ ---
169
+
170
+ ## Identity
171
+
172
+ | Context | Verdict | Effect |
173
+ |---|---|---|
174
+ | `{ request: { tpsAnonymous: true } }` | `anonymous` | Denied everywhere |
175
+ | `{ request: { tpsAgent: "mybot" } }` | `agent` | Scoped to that agent — **use this** |
176
+ | `{ request: { tpsAgent: "mybot", tpsAgentIsAdmin: true } }` | admin | Unfiltered reads, cross-agent writes |
177
+ | **Nothing**, or an empty/missing `tpsAgent` | `internal` | **Trusted. Unfiltered.** See the warning above. |
178
+
179
+ Build these with `agentContext(id)`, `adminContext(id)` and `internalContext()` rather than by hand — see [below](#the-api-is-built-so-omission-cannot-happen-quietly) for why the hand-written form is a trap.
180
+
181
+ ### The context object is a security boundary
182
+
183
+ **In-process identity is asserted, not verified.** Flair reads `request.tpsAgent` and acts as that agent. There is no signature check, no lookup against the `Agent` table, and no registration requirement — an id that has never been registered acts as an agent immediately. `tpsAgentIsAdmin: true` is asserted exactly the same way, and nothing checks that the named agent is really an admin.
184
+
185
+ That is deliberate. A co-located caller is already inside the trust boundary and could write `databases.flair.Memory` directly, so demanding a signature from same-process code would be theatre. Ed25519 is how agents *outside* the process prove identity.
186
+
187
+ The consequence is the single most important line in this guide:
188
+
189
+ > **Build the context from your own server-side state. Never from request data.**
190
+ >
191
+ > If an agent id can reach `agentContext()` from user input — a body field, a query param, a header you did not verify yourself — that is privilege escalation with **no error, no 403 and no trace**. Authenticate the caller with your own mechanism first, then map the identity *you* established onto `tpsAgent`.
192
+
193
+ There are exactly two ways to lose the model, from opposite ends. Both are pinned as tests in the Flair repo:
194
+
195
+ | | |
196
+ |---|---|
197
+ | **By omission** | No usable agent id ⇒ `internal` ⇒ admin-equivalent, unfiltered. |
198
+ | **By assertion** | An attacker-influenced `agentId` is honoured verbatim. |
199
+
200
+ ### The API is built so omission cannot happen quietly
201
+
202
+ `resolveAgentAuth` tests `tpsAgent` for *truthiness*, so a missing or empty id is indistinguishable from "no identity supplied" — which is the trusted, unfiltered verdict. Measured:
203
+
204
+ ```
205
+ resolveAgentAuth({ request: { tpsAgent: undefined } }) -> { kind: "internal" }
206
+ allowAdmin({ request: { tpsAgent: undefined } }) -> true
207
+ ```
208
+
209
+ That would turn the most ordinary bug there is — `agentContext(session.agentId)` where the field came back undefined — into silent administrator access. So the helpers refuse rather than default:
210
+
211
+ | | |
212
+ |---|---|
213
+ | `agentContext(id)` | **Throws** `InProcessContextError` on a missing, empty or blank id. Takes **no options**, so no object spread into it can escalate. |
214
+ | `adminContext(id)` | The *only* way to get admin authority. Same id guard. |
215
+ | `internalContext()` | The *only* way to get the unfiltered verdict. |
216
+ | `collectionResource(Cls, context)` | Context is **required**; omitting it throws rather than granting `internal`. |
217
+
218
+ The privileged paths are now the longest ones to type, and `git grep "adminContext\|internalContext"` enumerates every deliberate escalation in your codebase. These are runtime guards, not type annotations — a plain-JavaScript embedder gets exactly the same protection.
219
+
220
+ ### Individual identities, not one app identity
221
+
222
+ Give every agent its own. A per-agent context costs nothing — no client to construct, no key to load, no per-agent setup, not even a registration. Collapsing N agents onto one shared identity buys you nothing and loses the two things that make the memory model work: **per-agent attribution**, which is what trust grading and provenance are computed from, and **N separate blast radii**, which become one.
223
+
224
+ ### In a cluster
225
+
226
+ Harper replicates every table in a replicated database unless the table opts out with `@table(replicate: false)`. None of Flair's do. So:
227
+
228
+ - **The registry replicates.** An agent registered on node A is visible on node B with no coordination. (Replication comes from the *database* being replicated — not from `@export`, which only controls REST exposure. `Memory` carries no `@export` and still replicates.)
229
+ - **Authority is local.** The context is constructed per call, in whichever process handles it. No node asks another who a caller is.
230
+ - **Attribution travels.** `agentId` is a field *on the record*, so a memory written on node A reads back correctly attributed — and correctly scoped — wherever it lands. Verified as far as this can be without a cluster: a fresh Harper process over the same storage resolves identical per-agent scope, so none of it lives in the process that did the writing.
231
+
232
+ The consequence, stated plainly because someone will ask: **every node running the app is equally trusted**, since each one can assert any identity. That is fine for one application spread across regions — it is a single trust domain by construction. It is **not** fine for running this component beside untrusted co-tenants on the same instance. Co-location *is* the grant.
233
+
234
+ ---
235
+
236
+ ## Coexisting with your components
237
+
238
+ Flair's instance-wide middleware runs first but is **non-rejecting** — unrecognised requests pass through rather than 401'ing, so your component's auth keeps working. Two collisions to check:
239
+
240
+ - **`/Admin` is a prefix match.** Flair 401s any unauthenticated path *starting with* `/Admin` — including an app route like `/AdminPanel`. Rename yours.
241
+ - **Top-level paths.** Flair claims ~53, including `Memory`, `Agent`, `Instance`, `Integration`, `Credential`, `Health`, `Presence`, `Relationship`, `Soul`, `SemanticSearch`. `server.resources.keys()` lists them.
242
+
243
+ ---
244
+
245
+ ## Federation
246
+
247
+ Pairing an embedded instance to an external hub as a spoke **requires shell access today** — the pairing and sync commands are CLI-only. If your deployment has no shell, federation is not available to it yet.
248
+
249
+ > **Sync is push-only — one direction per call.** A spoke pushes its records up and **receives nothing back**. There is no pull endpoint, and nothing initiates a hub-to-spoke push. Do not plan on reading another instance's memories through the hub.
250
+ >
251
+ > For records both ways, each instance must pair **as a spoke of the other** — two pairings, each side running its own sync.
252
+
253
+ Private memories are never pushed. Full walkthrough: [federation.md](federation.md).
254
+
255
+ ---
256
+
257
+ ## If you have shell access
258
+
259
+ Local development and self-hosted installs can drive the same things from the CLI:
260
+
261
+ ```bash
262
+ flair doctor
263
+ flair agent add mybot # registers + generates and stores a keypair
264
+ flair agent list
265
+ flair memory search "deploy schedule" --agent mybot
266
+ ```
267
+
268
+ Federation, which has no in-process equivalent:
269
+
270
+ ```bash
271
+ flair federation token --admin-pass <hub-admin-pass> > triple.json
272
+ flair federation pair <hub-url> --token-from ./triple.json
273
+ flair federation sync enable --interval 300
274
+ flair federation status
275
+ ```
276
+
277
+ `sync enable` landed **after 0.30.0**; on an older install, schedule the one-shot yourself.
278
+
279
+ A `SyncLog` row reads `direction: "pull"` — the receiver's label for a push it accepted. `"push"` is never written. Not a pull path.
280
+
281
+ ---
282
+
283
+ ## Choosing a surface
284
+
285
+ | Surface | Use when |
286
+ |---|---|
287
+ | **In-process resource** | Your app's agents, same instance. No round trip — and no auth safety net. |
288
+ | **[`@tpsdev-ai/flair-client`](https://www.npmjs.com/package/@tpsdev-ai/flair-client)** | Out of process: another host, runtime or sidecar. One client per `agentId`. |
289
+ | **Native `/mcp`** | MCP clients against this instance. Identity from the OAuth token. |
290
+ | **`@tpsdev-ai/flair-mcp`** (stdio) | Local MCP clients. Talks HTTP via `FlairClient`. |
291
+ | **`flair` CLI** | Pairing and key generation. Needs a shell. |
292
+
293
+ **Trust-graded recall (`includeTrust`)** — provenance, author, usage, freshness, supersession — works in-process (`SemanticSearch.post({ q, includeTrust: true })`, `Memory.get(id, { includeTrust: true })`), over REST, and on the native `/mcp` handler. It is **not** exposed by the CLI, the stdio MCP server, or `FlairClient`'s typed methods.
294
+
295
+ ---
296
+
297
+ ## See also
298
+
299
+ [Integrations](integrations.md) · [Deployment](deployment.md) · [Federation](federation.md) · [Auth](auth.md) · [Architecture](../DESIGN.md)
@@ -81,15 +81,69 @@ flair federation pair https://<fabric-node>:19926/<instance-name> --token-from .
81
81
 
82
82
  Replace `<fabric-node>`, `<instance-name>`, and `<hub-admin-password>` with your actual values.
83
83
 
84
+ Running the hub on Fabric has its own considerations — port derivation against a managed
85
+ `443` endpoint, why the sync driver can only be installed on a machine you control, and
86
+ the observability limits of a node you have no shell on. See
87
+ [`docs/deploying-on-fabric.md`](deploying-on-fabric.md).
88
+
84
89
  ## Sync
85
90
 
86
- Push local changes to the hub:
91
+ Push local changes to the hub, once:
87
92
 
88
93
  ```bash
89
94
  flair federation sync --admin-pass <password>
90
95
  # Output: ✅ Synced 12 records (0 skipped) in 145ms
91
96
  ```
92
97
 
98
+ ### Keeping it synced
99
+
100
+ `flair federation sync` is one-shot and `flair federation watch` only runs
101
+ while its terminal is open. Neither survives a logout, so a spoke that is only
102
+ ever synced by hand looks paired but stops replicating — enable the scheduled
103
+ driver instead:
104
+
105
+ ```bash
106
+ flair federation sync enable # every 300s by default
107
+ flair federation sync enable --interval 900 # or pick your own cadence
108
+ flair federation sync status # is anything actually driving sync?
109
+ flair federation sync disable
110
+ ```
111
+
112
+ This installs a **periodic one-shot**: a launchd job (`StartInterval`) on
113
+ macOS, a systemd user timer (`OnUnitActiveSec`) on Linux, each invoking
114
+ `flair federation sync` on the interval. It is deliberately not a supervised
115
+ long-lived watcher — the sync holds no state between runs, so a resident
116
+ process would buy nothing, and a supervisor cannot restart a process that
117
+ hangs rather than exits. The trade-off is latency, and `--interval` is the
118
+ knob. The first sync runs immediately on enable.
119
+
120
+ `flair federation watch` is unchanged and still the right tool for an
121
+ interactive "watch it sync while I debug" session.
122
+
123
+ **Credentials.** The scheduler never writes a password into a unit file. It
124
+ stores the *path* given to `--admin-pass-file` (defaulting to
125
+ `~/.flair/admin-pass` when that exists) and the CLI reads the file at run time,
126
+ refusing it unless it is owner-only (`chmod 600`). Pass `--no-credentials` to
127
+ wire none at all.
128
+
129
+ ### Is anything driving sync?
130
+
131
+ `flair federation status` reports the driver alongside the peer table, because
132
+ "no peer has merged in 24h" has two completely different causes:
133
+
134
+ | What you see | What it means | What to do |
135
+ |---|---|---|
136
+ | `Sync driver: active` | A managed driver is loaded and syncs are landing | Nothing |
137
+ | `Sync driver: active … but no peer contact in <window>` | Sync **is** running; the runs are not reaching the peer | `flair federation reachability`, then the driver log |
138
+ | `Sync driver: NONE` | Nothing has run sync since you paired | `flair federation sync enable` |
139
+ | `Sync driver: INSTALLED BUT NOT LOADED` | Unit files exist, the service manager never loaded them | `flair federation sync enable` |
140
+ | `Sync driver: none managed by Flair — but syncs are landing` | A cron entry / hand-written unit is driving it | Nothing |
141
+
142
+ The check is local to the machine running the CLI, so it is omitted when
143
+ `--target` points at a remote instance.
144
+
145
+ Driver logs: `~/.flair/logs/federation-sync.{stdout,stderr}.log`.
146
+
93
147
  ## Security
94
148
 
95
149
  ### Signed requests
@@ -125,10 +179,13 @@ Records with `updatedAt` more than 5 minutes in the future are rejected. This pr
125
179
 
126
180
  | Command | Description |
127
181
  |---------|-------------|
128
- | `flair federation status` | Show instance identity and peer connections |
182
+ | `flair federation status` | Show instance identity, peer connections, and whether anything is driving sync |
129
183
  | `flair federation pair <hub-url> --token-from <file>` | Pair this spoke with a hub using a token triple file (or `-` for stdin) |
130
184
  | `flair federation sync` | Push local changes to the hub (one-shot) |
131
- | `flair federation watch [--interval <s>]` | Run sync in a foreground daemon loop (default 30s) |
185
+ | `flair federation sync enable [--interval <s>] [--admin-pass-file <path>]` | Install the scheduled sync driver (launchd on macOS, systemd timer on Linux) |
186
+ | `flair federation sync disable [--remove-shim]` | Remove the scheduled sync driver |
187
+ | `flair federation sync status` | Show whether the driver is installed and genuinely active |
188
+ | `flair federation watch [--interval <s>]` | Run sync in a foreground loop for an interactive session (default 30s) |
132
189
  | `flair federation reachability` | Probe local instance + each paired peer (read-only) |
133
190
  | `flair federation token [--ttl <min>]` | Generate a one-time pairing token triple (hub only) |
134
191
 
@@ -174,6 +231,6 @@ flair federation unpin <instanceId>
174
231
  ## Limitations (1.0)
175
232
 
176
233
  - **HTTP push only** — no persistent WebSocket connections or real-time sync
177
- - **Manual or watch-loop sync** — `flair federation sync` is one-shot; `flair federation watch --interval 30` runs it on a loop in a foreground daemon (wrap in launchd / systemd for production)
234
+ - **Polled sync** — `flair federation sync enable` schedules a periodic one-shot (launchd / systemd, default 300s); there is no write-path trigger, so a new memory replicates on the next tick rather than immediately
178
235
  - **Single hub** — spoke-to-spoke sync goes through the hub
179
236
  - **Record-level LWW** — not field-level; concurrent edits to different fields of the same record may lose data
@@ -24,6 +24,8 @@ Where Flair already runs. Each integration shown here is a working surface — t
24
24
 
25
25
  Don't see your harness? If it speaks **MCP** — Flair already works with `flair-mcp`. If it has a **custom memory protocol** like LangGraph's `BaseStore` or CrewAI's `RAGStorage`, an adapter is a ~200-line package; [open an issue](https://github.com/tpsdev-ai/flair/issues) or [send a PR](https://github.com/tpsdev-ai/flair).
26
26
 
27
+ **Already running on Harper?** Every surface above reaches Flair over HTTP. If your application is itself a Harper app, you can skip the network entirely — load Flair as a component of the same instance and call its resources in-process. See [embedding-in-a-harper-app.md](embedding-in-a-harper-app.md), which also covers the table-vs-resource distinction that decides whether your memories are scoped.
28
+
27
29
  **Adjacent: memory bridges** — for moving memories between Flair and another memory product. Five bridges ship today (Mem0, ChatGPT exports, claude-project files, agentic-stack, markdown); see [bridges.md](bridges.md). Bridges are import/export plumbing, not live orchestrator integrations.
28
30
 
29
31
  ---
@@ -191,6 +193,7 @@ If it has a custom memory protocol, the adapter pattern is small (~200 lines). L
191
193
  ## See also
192
194
 
193
195
  - [Quickstart](quickstart.md) — `flair init` to working memory in 30 seconds
196
+ - [Embedding in a Harper app](embedding-in-a-harper-app.md) — run Flair as a component of your own Harper instance and call it in-process
194
197
  - [Memory bridges](bridges.md) — import/export Flair ↔ Mem0, ChatGPT, claude-project, markdown, agentic-stack (five bridges shipped)
195
198
  - [Federation](federation.md) — pair instances peer-to-peer for cross-machine sync
196
199
  - [Supply-chain policy](supply-chain-policy.md) — what we do to keep this list of integrations safe
@@ -38,7 +38,9 @@ Flair runs as a local server at `http://127.0.0.1:19926` by default. The MCP ser
38
38
 
39
39
  Pick whichever you use. The MCP server is the same package; only the config syntax differs.
40
40
 
41
- > **Pin the version.** The snippets below use the bare package name for readability. `flair init` wires clients to a **pinned** spec (`@tpsdev-ai/flair-mcp@<version>`) on purpose: an unpinned reference re-resolves to whatever is currently published on every agent session, so any future publish reaches your machine silently. If you wire by hand, append the version you intend to run `@tpsdev-ai/flair-mcp@0.28.0` and bump it deliberately. `flair init` is the easier path and does this for you.
41
+ > **Pin the version.** An unpinned `@tpsdev-ai/flair-mcp` re-resolves to whatever is currently published on *every* agent session, so any future publish reaches your machine silently, with no lockfile and no review step in the path. `flair init` wires clients to a **pinned** spec on purpose, and every MCP-server config snippet below is written the same way: `@tpsdev-ai/flair-mcp@<version>`.
42
+ >
43
+ > Replace `<version>` with the version you intend to run — the one you already have is `flair --version` — and bump it deliberately. Leaving the literal `<version>` in place fails loudly at `npx`, which is the intended failure: better than a config that looks pinned and isn't. `flair init` is the easier path and fills this in for you.
42
44
 
43
45
  ### Claude Code
44
46
 
@@ -47,14 +49,14 @@ The canonical approach is the `claude mcp add` CLI (writes to `~/.claude.json`):
47
49
  ```bash
48
50
  claude mcp add flair --scope user \
49
51
  -e FLAIR_AGENT_ID=my-project \
50
- -- npx -y @tpsdev-ai/flair-mcp
52
+ -- npx -y @tpsdev-ai/flair-mcp@<version>
51
53
  ```
52
54
 
53
55
  Verify:
54
56
 
55
57
  ```bash
56
58
  claude mcp list
57
- # → flair (stdio, npx -y @tpsdev-ai/flair-mcp)
59
+ # → flair (stdio, npx -y @tpsdev-ai/flair-mcp@<version>)
58
60
  ```
59
61
 
60
62
  Or, if you prefer the project-scoped `.mcp.json` checked into your repo:
@@ -64,7 +66,7 @@ Or, if you prefer the project-scoped `.mcp.json` checked into your repo:
64
66
  "mcpServers": {
65
67
  "flair": {
66
68
  "command": "npx",
67
- "args": ["-y", "@tpsdev-ai/flair-mcp"],
69
+ "args": ["-y", "@tpsdev-ai/flair-mcp@<version>"],
68
70
  "env": {
69
71
  "FLAIR_AGENT_ID": "my-project"
70
72
  }
@@ -116,7 +118,14 @@ Or wire it by hand — add a `SessionStart` hook to `~/.claude/settings.json`:
116
118
  }
117
119
  ```
118
120
 
119
- Swap `me` for your `FLAIR_AGENT_ID`. The hook reads Claude Code's SessionStart
121
+ Swap `me` for your `FLAIR_AGENT_ID`. Unlike the MCP-server snippets above, this
122
+ one is shown **unpinned**, because that is what `flair hook install` writes and
123
+ what the tooling recognises: `flair hook status` matches the exact unpinned
124
+ command, so a hand-pinned hook reports `wired: false` there (while `flair
125
+ doctor` still sees it). Pinning this line is therefore not yet supported —
126
+ prefer `flair hook install`.
127
+
128
+ The hook reads Claude Code's SessionStart
120
129
  payload on stdin, calls Flair's `bootstrap` (soul + relevant memories +
121
130
  predicted context, scoped to your project by the session's working directory),
122
131
  and emits it as `hookSpecificOutput.additionalContext` — which Claude Code
@@ -143,7 +152,7 @@ Edit `~/.gemini/settings.json` (create it if absent):
143
152
  "mcpServers": {
144
153
  "flair": {
145
154
  "command": "npx",
146
- "args": ["-y", "@tpsdev-ai/flair-mcp"],
155
+ "args": ["-y", "@tpsdev-ai/flair-mcp@<version>"],
147
156
  "env": {
148
157
  "FLAIR_AGENT_ID": "my-project"
149
158
  }
@@ -165,7 +174,7 @@ Edit `~/.codex/config.toml` (create it if absent):
165
174
  ```toml
166
175
  [mcp_servers.flair]
167
176
  command = "npx"
168
- args = ["-y", "@tpsdev-ai/flair-mcp"]
177
+ args = ["-y", "@tpsdev-ai/flair-mcp@<version>"]
169
178
 
170
179
  [mcp_servers.flair.env]
171
180
  FLAIR_AGENT_ID = "my-project"