@tpsdev-ai/flair 0.16.1 → 0.18.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,417 @@
1
+ /**
2
+ * mcp-tools.ts — the 10 curated flair tools for the Model-2 custom /mcp handler.
3
+ *
4
+ * Curated BY CONSTRUCTION: this module implements exactly the 10 tools that the
5
+ * `@tpsdev-ai/flair-mcp` stdio proxy exposes, each a thin wrapper over the
6
+ * existing flair Resource handler. No business logic is re-implemented — the
7
+ * wrapped handlers (Memory / SemanticSearch / BootstrapMemories / Soul /
8
+ * WorkspaceState / OrgEvent) enforce per-agent scoping/ownership via
9
+ * `resolveAgentAuth(getContext())`, so the MCP surface inherits the SAME security
10
+ * model as the signed-REST path. There is no raw CRUD surface — the only way to
11
+ * reach the datastore through /mcp is via one of these 10 semantic tools.
12
+ *
13
+ * memory_search · memory_store · memory_update · memory_get · memory_delete ·
14
+ * bootstrap · soul_set · soul_get · flair_workspace_set · flair_orgevent
15
+ *
16
+ * ── The scoping seam ────────────────────────────────────────────────────────
17
+ * The /mcp handler resolves the OAuth token's `sub` → a flair `Agent` id, then
18
+ * calls a tool with a `ResolvedAgent { agentId, isAdmin }`. Each tool builds a
19
+ * flair-shaped Resource context (`delegationContext`) carrying `request.tpsAgent`
20
+ * + `request.tpsAgentIsAdmin`, so the wrapped handler scopes to the verified
21
+ * agent exactly as an Ed25519-signed REST call would. Identity ALWAYS comes from
22
+ * the resolved agent, never from the tool arguments — an agent can only act as
23
+ * itself (no forging of agentId / authorId in the body).
24
+ */
25
+ const H = {};
26
+ const LOADERS = {
27
+ SemanticSearch: async () => (await import("./SemanticSearch.js")).SemanticSearch,
28
+ Memory: async () => (await import("./Memory.js")).Memory,
29
+ BootstrapMemories: async () => (await import("./MemoryBootstrap.js")).BootstrapMemories,
30
+ Soul: async () => (await import("./Soul.js")).Soul,
31
+ WorkspaceState: async () => (await import("./WorkspaceState.js")).WorkspaceState,
32
+ OrgEvent: async () => (await import("./OrgEvent.js")).OrgEvent,
33
+ };
34
+ /** Resolve a handler class — from the test override if set, else lazy-load + cache. */
35
+ async function handler(key) {
36
+ if (H[key])
37
+ return H[key];
38
+ const cls = await LOADERS[key]();
39
+ H[key] = cls;
40
+ return cls;
41
+ }
42
+ /** TEST-ONLY: override the delegated handler classes. Returns a restore fn. */
43
+ export function __setHandlers(overrides) {
44
+ const prev = { ...H };
45
+ Object.assign(H, overrides);
46
+ return () => {
47
+ for (const k of Object.keys(H))
48
+ delete H[k];
49
+ Object.assign(H, prev);
50
+ };
51
+ }
52
+ /**
53
+ * Build a flair-shaped Resource context for a delegated handler call. The
54
+ * handlers read identity via `resolveAgentAuth(getContext())`, which checks
55
+ * `context.request.tpsAgent` / `tpsAgentIsAdmin`. We construct exactly that shape
56
+ * so the wrapped handler scopes to the verified agent — identical to the
57
+ * signed-REST path. `headers.get("x-tps-agent")` is provided for handler paths
58
+ * that read the header directly (e.g. MemoryBootstrap fallback).
59
+ *
60
+ * Critically: `tpsAnonymous` is NOT set and no Authorization header is present,
61
+ * so `resolveAgentAuth` takes the `tpsAgent` annotation branch — a verified
62
+ * agent, never anonymous, never a header re-verify.
63
+ */
64
+ function delegationContext(agent) {
65
+ return {
66
+ request: {
67
+ tpsAgent: agent.agentId,
68
+ tpsAgentIsAdmin: agent.isAdmin,
69
+ headers: {
70
+ get: (k) => (k.toLowerCase() === "x-tps-agent" ? agent.agentId : undefined),
71
+ },
72
+ },
73
+ user: undefined,
74
+ };
75
+ }
76
+ /**
77
+ * Unwrap a handler return value into a plain object/string for the MCP result.
78
+ * Handlers may return a `Response` (the 401/403/400 guards) — surface its JSON
79
+ * body (and status) so the client sees the structured error rather than an
80
+ * opaque object. A thrown handler error propagates to the caller (the handler
81
+ * maps it to a JSON-RPC error).
82
+ */
83
+ async function unwrap(value) {
84
+ if (value && typeof value === "object" && typeof value.json === "function" && "status" in value) {
85
+ try {
86
+ const body = await value.json();
87
+ return { error: body?.error ?? "request failed", status: value.status, ...body };
88
+ }
89
+ catch {
90
+ return { error: "request failed", status: value.status };
91
+ }
92
+ }
93
+ return value;
94
+ }
95
+ // ── Tool implementations (thin wrappers over existing handlers) ──────────────
96
+ //
97
+ // Each takes the resolved agent + the parsed tool arguments and returns a plain
98
+ // JSON-serializable value. Identity is taken from `agent`, never from `args`.
99
+ async function memorySearch(agent, args) {
100
+ const Cls = await handler("SemanticSearch");
101
+ const h = new Cls(undefined, delegationContext(agent));
102
+ return unwrap(await h.post({ q: args?.query, limit: args?.limit ?? 5 }));
103
+ }
104
+ async function memoryStore(agent, args) {
105
+ const Cls = await handler("Memory");
106
+ const h = new Cls(undefined, delegationContext(agent));
107
+ h.isCollection = true;
108
+ // agentId is the RESOLVED agent — Memory.post also re-checks ownership via
109
+ // resolveAgentAuth, so a mismatched body agentId would 403 anyway; we set it
110
+ // to the verified id so the write is correctly owned.
111
+ return unwrap(await h.post({
112
+ agentId: agent.agentId,
113
+ content: args?.content,
114
+ type: args?.type ?? "session",
115
+ durability: args?.durability ?? "standard",
116
+ tags: args?.tags,
117
+ }));
118
+ }
119
+ /**
120
+ * memory_update — id-targeted, dedup-BYPASSED overwrite/version path (memory-
121
+ * integrity fix). Mirrors flair-client's MemoryApi.update() (packages/
122
+ * flair-client/src/client.ts), reimplemented against the resource instance
123
+ * API instead of HTTP since this handler calls the Memory resource directly
124
+ * (same pattern as memoryStore vs the flair-mcp stdio tool). Auth is enforced
125
+ * by Memory.get()/Memory.put()/Memory.post()'s EXISTING ownership checks — no
126
+ * parallel auth logic here.
127
+ *
128
+ * Default (preserveHistory unset/false): read the existing record, merge the
129
+ * new content on top (Harper PUT is full-record replacement — never send a
130
+ * bare partial), clear the stale embedding so the server regenerates it, and
131
+ * PUT the merged record back to the SAME id.
132
+ *
133
+ * preserveHistory: true: write a NEW id with `supersedes: id`. Memory.post()
134
+ * validates/authorizes the supersede (denying a cross-agent supersede without
135
+ * a "write" MemoryGrant) and closes the old record's validTo AFTER the new
136
+ * record is written (never the reverse — see resources/Memory.ts).
137
+ */
138
+ async function memoryUpdate(agent, args) {
139
+ const Cls = await handler("Memory");
140
+ const h = new Cls(undefined, delegationContext(agent));
141
+ const id = args?.id;
142
+ const content = args?.content;
143
+ const preserveHistory = args?.preserveHistory === true;
144
+ const existing = await h.get(id);
145
+ if (!existing) {
146
+ return { error: "memory not found", status: 404 };
147
+ }
148
+ if (preserveHistory) {
149
+ const newId = `${agent.agentId}-${crypto.randomUUID()}`;
150
+ const record = {
151
+ ...existing,
152
+ id: newId,
153
+ content,
154
+ supersedes: id,
155
+ createdAt: new Date().toISOString(),
156
+ };
157
+ delete record.updatedAt;
158
+ delete record.embedding;
159
+ delete record.embeddingModel;
160
+ delete record.validFrom;
161
+ delete record.validTo;
162
+ delete record.archivedAt;
163
+ h.isCollection = true;
164
+ return unwrap(await h.post(record));
165
+ }
166
+ const merged = { ...existing, content, updatedAt: new Date().toISOString() };
167
+ delete merged.embedding;
168
+ delete merged.embeddingModel;
169
+ return unwrap(await h.put(merged));
170
+ }
171
+ async function memoryGet(agent, args) {
172
+ const Cls = await handler("Memory");
173
+ const h = new Cls(undefined, delegationContext(agent));
174
+ return unwrap(await h.get(args?.id));
175
+ }
176
+ async function memoryDelete(agent, args) {
177
+ const Cls = await handler("Memory");
178
+ const h = new Cls(undefined, delegationContext(agent));
179
+ return unwrap(await h.delete(args?.id));
180
+ }
181
+ async function bootstrap(agent, args) {
182
+ const Cls = await handler("BootstrapMemories");
183
+ const h = new Cls(undefined, delegationContext(agent));
184
+ return unwrap(await h.post({
185
+ agentId: agent.agentId,
186
+ maxTokens: args?.maxTokens ?? 4000,
187
+ currentTask: args?.currentTask,
188
+ channel: args?.channel,
189
+ surface: args?.surface,
190
+ subjects: args?.subjects,
191
+ }));
192
+ }
193
+ async function soulSet(agent, args) {
194
+ const Cls = await handler("Soul");
195
+ const h = new Cls(undefined, delegationContext(agent));
196
+ // Soul records are keyed `id = agentId:key` (see flair-client SoulApi.set and
197
+ // schemas/memory.graphql). Use PUT with the explicit id so soul_get's
198
+ // `${agentId}:${key}` lookup finds it — a plain post() would mint a random id
199
+ // and orphan the entry from get(). Soul.put enforces write ownership via
200
+ // resolveAgentAuth (non-admin can only write agentId === self).
201
+ const id = `${agent.agentId}:${args?.key}`;
202
+ return unwrap(await h.put({
203
+ id,
204
+ agentId: agent.agentId,
205
+ key: args?.key,
206
+ value: args?.value,
207
+ }));
208
+ }
209
+ async function soulGet(agent, args) {
210
+ const Cls = await handler("Soul");
211
+ const h = new Cls(undefined, delegationContext(agent));
212
+ return unwrap(await h.get(`${agent.agentId}:${args?.key}`));
213
+ }
214
+ async function workspaceSet(agent, args) {
215
+ const Cls = await handler("WorkspaceState");
216
+ const h = new Cls(undefined, delegationContext(agent));
217
+ h.isCollection = true;
218
+ // No agentId in the body — WorkspaceState.post attributes the record to the
219
+ // authenticated identity (from the context), never the body. Same no-forge
220
+ // contract as the flair-mcp stdio tool.
221
+ const body = {
222
+ id: `${agent.agentId}:${args?.ref}`,
223
+ ref: args?.ref,
224
+ provider: args?.provider ?? "mcp",
225
+ timestamp: new Date().toISOString(),
226
+ };
227
+ if (args?.label)
228
+ body.label = args.label;
229
+ if (args?.task)
230
+ body.taskId = args.task;
231
+ if (args?.phase)
232
+ body.phase = args.phase;
233
+ if (args?.summary)
234
+ body.summary = args.summary;
235
+ return unwrap(await h.post(body));
236
+ }
237
+ async function orgEvent(agent, args) {
238
+ const Cls = await handler("OrgEvent");
239
+ const h = new Cls(undefined, delegationContext(agent));
240
+ h.isCollection = true;
241
+ // No authorId in the body — OrgEvent.post attributes to the authenticated
242
+ // identity, never the body (no forging as another agent).
243
+ const body = { kind: args?.kind, summary: args?.summary };
244
+ if (args?.detail)
245
+ body.detail = args.detail;
246
+ if (args?.scope)
247
+ body.scope = args.scope;
248
+ if (Array.isArray(args?.targets) && args.targets.length > 0)
249
+ body.targetIds = args.targets;
250
+ return unwrap(await h.post(body));
251
+ }
252
+ export const TOOLS = {
253
+ memory_search: {
254
+ def: {
255
+ name: "memory_search",
256
+ description: "Search memories by meaning. Understands temporal queries like 'what happened today'. Scoped to your agent's own + granted memories.",
257
+ annotations: { readOnlyHint: true },
258
+ inputSchema: {
259
+ type: "object",
260
+ properties: {
261
+ query: { type: "string", description: "Search query — natural language, semantic matching" },
262
+ limit: { type: "number", description: "Max results (default 5)" },
263
+ },
264
+ required: ["query"],
265
+ },
266
+ },
267
+ impl: memorySearch,
268
+ },
269
+ memory_store: {
270
+ def: {
271
+ name: "memory_store",
272
+ description: "Save information to persistent memory. Use for lessons, decisions, preferences, facts. Attributed to your authenticated agent.",
273
+ inputSchema: {
274
+ type: "object",
275
+ properties: {
276
+ content: { type: "string", description: "What to remember" },
277
+ type: { type: "string", enum: ["session", "lesson", "decision", "preference", "fact", "goal"], description: "Memory type (default session)" },
278
+ durability: { type: "string", enum: ["permanent", "persistent", "standard", "ephemeral"], description: "permanent > persistent > standard > ephemeral (default standard)" },
279
+ tags: { type: "array", items: { type: "string" }, description: "Tag strings" },
280
+ },
281
+ required: ["content"],
282
+ },
283
+ },
284
+ impl: memoryStore,
285
+ },
286
+ memory_update: {
287
+ def: {
288
+ name: "memory_update",
289
+ description: "Update an existing memory by ID. Dedup-bypassed (this is an intentional overwrite, not a new write). " +
290
+ "Default: overwrites the same id in place. Pass preserveHistory=true to instead write a new version " +
291
+ "linked via `supersedes`, closing the old one's validity window.",
292
+ inputSchema: {
293
+ type: "object",
294
+ properties: {
295
+ id: { type: "string", description: "ID of the memory to update" },
296
+ content: { type: "string", description: "New content" },
297
+ preserveHistory: { type: "boolean", description: "Write a new version (supersedes-linked) instead of overwriting in place (default false)" },
298
+ },
299
+ required: ["id", "content"],
300
+ },
301
+ },
302
+ impl: memoryUpdate,
303
+ },
304
+ memory_get: {
305
+ def: {
306
+ name: "memory_get",
307
+ description: "Retrieve a specific memory by ID.",
308
+ annotations: { readOnlyHint: true },
309
+ inputSchema: {
310
+ type: "object",
311
+ properties: { id: { type: "string", description: "Memory ID" } },
312
+ required: ["id"],
313
+ },
314
+ },
315
+ impl: memoryGet,
316
+ },
317
+ memory_delete: {
318
+ def: {
319
+ name: "memory_delete",
320
+ description: "Delete a memory by ID. You can only delete your own memories.",
321
+ annotations: { destructiveHint: true },
322
+ inputSchema: {
323
+ type: "object",
324
+ properties: { id: { type: "string", description: "Memory ID to delete" } },
325
+ required: ["id"],
326
+ },
327
+ },
328
+ impl: memoryDelete,
329
+ },
330
+ bootstrap: {
331
+ def: {
332
+ name: "bootstrap",
333
+ description: "Get session context: soul + memories + predicted context. Run at session start. Pass subjects for predictive loading.",
334
+ annotations: { readOnlyHint: true },
335
+ inputSchema: {
336
+ type: "object",
337
+ properties: {
338
+ maxTokens: { type: "number", description: "Max tokens in output (default 4000)" },
339
+ currentTask: { type: "string", description: "Current task — enables semantic search for relevant memories" },
340
+ channel: { type: "string", description: "Channel name (discord, tps-mail, claude-code)" },
341
+ surface: { type: "string", description: "Surface name (tps-build, tps-review, cli-session)" },
342
+ subjects: { type: "array", items: { type: "string" }, description: "Entity names to preload context for" },
343
+ },
344
+ },
345
+ },
346
+ impl: bootstrap,
347
+ },
348
+ soul_set: {
349
+ def: {
350
+ name: "soul_set",
351
+ description: "Set a personality or project context entry. Included in every bootstrap.",
352
+ inputSchema: {
353
+ type: "object",
354
+ properties: {
355
+ key: { type: "string", description: "Entry key (e.g. 'role', 'standards', 'project')" },
356
+ value: { type: "string", description: "Entry value" },
357
+ },
358
+ required: ["key", "value"],
359
+ },
360
+ },
361
+ impl: soulSet,
362
+ },
363
+ soul_get: {
364
+ def: {
365
+ name: "soul_get",
366
+ description: "Get a personality or project context entry.",
367
+ annotations: { readOnlyHint: true },
368
+ inputSchema: {
369
+ type: "object",
370
+ properties: { key: { type: "string", description: "Entry key" } },
371
+ required: ["key"],
372
+ },
373
+ },
374
+ impl: soulGet,
375
+ },
376
+ flair_workspace_set: {
377
+ def: {
378
+ name: "flair_workspace_set",
379
+ description: "Set your agent's current workspace state in the Office Space coordination layer. Attributed to you — you can only write your own state.",
380
+ inputSchema: {
381
+ type: "object",
382
+ properties: {
383
+ ref: { type: "string", description: "Workspace ref — branch, worktree, or task ref" },
384
+ label: { type: "string", description: "Human-readable label" },
385
+ provider: { type: "string", description: "Provider/runtime (default mcp)" },
386
+ task: { type: "string", description: "Task/issue id" },
387
+ phase: { type: "string", description: "Current phase (design, implement, review)" },
388
+ summary: { type: "string", description: "Short summary of current state" },
389
+ },
390
+ required: ["ref"],
391
+ },
392
+ },
393
+ impl: workspaceSet,
394
+ },
395
+ flair_orgevent: {
396
+ def: {
397
+ name: "flair_orgevent",
398
+ description: "Publish an org-wide coordination event (claim/release/status) to the Office Space. Attributed to you — you cannot publish as another agent.",
399
+ inputSchema: {
400
+ type: "object",
401
+ properties: {
402
+ kind: { type: "string", description: "Event kind (coord.claim, coord.release, status)" },
403
+ summary: { type: "string", description: "Short summary of the event" },
404
+ detail: { type: "string", description: "Longer detail payload" },
405
+ scope: { type: "string", description: "Scope (an agent id, repo, or 'org')" },
406
+ targets: { type: "array", items: { type: "string" }, description: "Recipient agent ids" },
407
+ },
408
+ required: ["kind", "summary"],
409
+ },
410
+ },
411
+ impl: orgEvent,
412
+ },
413
+ };
414
+ /** The tool definitions for a tools/list response (exactly the 9 curated tools). */
415
+ export function listToolDefs() {
416
+ return Object.values(TOOLS).map((t) => t.def);
417
+ }
@@ -0,0 +1,42 @@
1
+ // ─── MemoryBootstrap — pure evaluation logic ────────────────────────────────
2
+ // Pure helpers extracted from MemoryBootstrap.ts (section 1c, PR #549) so they
3
+ // can be unit-tested directly. Importing MemoryBootstrap.ts pulls in the
4
+ // Harper runtime (`databases` / `Resource`, storage init) and can't run
5
+ // outside a live Harper; this module has no Harper dependency, so
6
+ // test/unit/bootstrap-team.test.ts exercises the real shipped code.
7
+ import { wrapUntrusted } from "./content-safety.js";
8
+ /**
9
+ * Is `record` a live teammate of `callerId` for roster purposes?
10
+ *
11
+ * Permissive by design: pre-1.0 Agent records may not have `kind`/`status` at
12
+ * all (Agent.ts registration only started defaulting both — `kind ||= "agent"`,
13
+ * `status ||= "active"` — from the 1.0 auth reshape onward). A missing field
14
+ * means "legacy agent, active", not "unknown, exclude" — so we only exclude on
15
+ * an explicit non-matching value, never on absence.
16
+ */
17
+ export function isTeammate(record, callerId) {
18
+ if (record.id === callerId)
19
+ return false;
20
+ if (record.kind && record.kind !== "agent")
21
+ return false;
22
+ if (record.status && record.status !== "active")
23
+ return false;
24
+ return true;
25
+ }
26
+ /**
27
+ * Format the "## Team" roster line for a list of teammate ids, or `null`
28
+ * when the roster is empty (caller should omit the section entirely).
29
+ *
30
+ * Teammate ids are registrant-chosen strings, not something Flair controls —
31
+ * they're untrusted the same way memory content is, so only the id list goes
32
+ * through wrapUntrusted; the surrounding instructional text is trusted and
33
+ * stays outside the wrap.
34
+ */
35
+ export function formatTeamLine(teammateIds) {
36
+ if (teammateIds.length === 0)
37
+ return null;
38
+ const plural = teammateIds.length === 1 ? "agent shares" : "agents share";
39
+ return (`${teammateIds.length} other ${plural} this Flair office (${wrapUntrusted(teammateIds.join(", "))}). ` +
40
+ `Before deep-diving an unfamiliar problem, search their memories for related work — ` +
41
+ `\`memory_search\` covers any agent you hold a search grant from.`);
42
+ }