@tpsdev-ai/flair 0.31.1 → 0.32.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.
@@ -0,0 +1,382 @@
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
+ constructor(server) {
331
+ this.#server = server;
332
+ }
333
+ /**
334
+ * Return a handle that acts as the given agent.
335
+ *
336
+ * The agentId is runtime-validated: missing, empty, blank, or non-string
337
+ * throws {@link InProcessContextError}. Build it from your own server-side
338
+ * state, never from request data.
339
+ */
340
+ as(agentId) {
341
+ return new AgentHandle(this.#server, agentId);
342
+ }
343
+ /**
344
+ * Admin operations — unfiltered reads, cross-agent writes.
345
+ *
346
+ * **This is a root shell.** Every call site is greppable via
347
+ * `git grep "flair.admin"`. Use for provisioning and maintenance only.
348
+ */
349
+ get admin() {
350
+ // AdminHandle requires an agentId for attribution. We use a sentinel
351
+ // that makes the admin identity visible in audit logs. The caller
352
+ // should use a real admin agent id when possible.
353
+ return new AdminHandle(this.#server, "_admin");
354
+ }
355
+ /**
356
+ * Internal operations — trusted, unattributed, unfiltered.
357
+ *
358
+ * Every call site is greppable via `git grep "flair.internal"`.
359
+ * Use for infrastructure work only: provisioning, migrations, maintenance.
360
+ */
361
+ get internal() {
362
+ return new InternalHandle(this.#server);
363
+ }
364
+ }
365
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
366
+ /**
367
+ * Unwrap a handler return value into a plain object.
368
+ * Handlers may return a `Response` (the 401/403/400 guards) — surface its
369
+ * JSON body so the caller sees the structured error rather than an opaque object.
370
+ */
371
+ async function unwrap(value) {
372
+ if (value && typeof value === "object" && typeof value.json === "function" && "status" in value) {
373
+ try {
374
+ const body = await value.json();
375
+ return { error: body?.error ?? "request failed", status: value.status, ...body };
376
+ }
377
+ catch {
378
+ return { error: "request failed", status: value.status };
379
+ }
380
+ }
381
+ return value;
382
+ }
@@ -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.