@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.
- package/README.md +80 -54
- package/SECURITY.md +7 -0
- package/config.yaml +34 -0
- package/dist/cli.js +349 -117
- package/dist/resources/Memory.js +24 -2
- package/dist/resources/in-process-api.js +386 -0
- package/dist/resources/mcp-tools.js +40 -0
- package/docs/deployment-shapes.md +35 -0
- package/docs/deployment.md +2 -2
- package/docs/embedding-in-a-harper-app.md +175 -75
- package/docs/hosted-on-fabric.md +203 -0
- package/docs/mcp-clients.md +4 -2
- package/docs/quickstart.md +29 -4
- package/docs/secrets-and-keys.md +4 -4
- package/docs/standalone-local.md +243 -0
- package/docs/the-team.md +8 -4
- package/docs/troubleshooting.md +1 -1
- package/docs/upgrade.md +6 -2
- package/package.json +7 -1
package/dist/resources/Memory.js
CHANGED
|
@@ -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
|
|
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."
|
package/docs/deployment.md
CHANGED
|
@@ -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
|
|
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
|
|
198
|
+
flair restore ~/flair-backup-20260405.json
|
|
199
199
|
```
|
|
200
200
|
|
|
201
201
|
Always backup before upgrades.
|