@tpsdev-ai/flair-mcp 0.52.0 → 0.54.1
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 +3 -0
- package/dist/adapter-surface.d.ts +66 -0
- package/dist/adapter-surface.js +116 -0
- package/dist/adapter-tools.d.ts +35 -0
- package/dist/adapter-tools.js +381 -0
- package/dist/catchup.d.ts +58 -0
- package/dist/catchup.js +85 -0
- package/dist/errors.d.ts +1 -0
- package/dist/errors.js +39 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +20 -374
- package/dist/json-schema-zod.d.ts +10 -0
- package/dist/json-schema-zod.js +43 -0
- package/dist/skills.d.ts +50 -0
- package/dist/skills.js +120 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/LICENSE +19 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/README.md +22 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.d.ts +70 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.js +665 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/package.json +46 -0
- package/package.json +7 -2
package/dist/catchup.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* catchup.ts — pure helpers for the flair_catchup stdio binding (flair#1583).
|
|
3
|
+
*
|
|
4
|
+
* Owner-scope is enforced BY CONSTRUCTION: the participantId in the request
|
|
5
|
+
* path is always the caller's own agentId (from `FLAIR_AGENT_ID` / the signed
|
|
6
|
+
* identity), never a tool argument. The descriptor advertises no `agentId` /
|
|
7
|
+
* `participantId` property, so there is nothing to name another agent's feed
|
|
8
|
+
* with — and the server independently refuses a cross-agent read (403). This
|
|
9
|
+
* module is HTTP-/Harper-free (plain helpers + types) so the flair-mcp
|
|
10
|
+
* package stays FlairClient-only.
|
|
11
|
+
*/
|
|
12
|
+
function nonEmptyString(value) {
|
|
13
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build the owner-scoped catchup request from tool args. `participantId` is
|
|
17
|
+
* ALWAYS `agentId` — the args cannot redirect it.
|
|
18
|
+
*/
|
|
19
|
+
export function buildCatchupRequest(agentId, args) {
|
|
20
|
+
const path = `/OrgEventCatchup/${encodeURIComponent(agentId)}`;
|
|
21
|
+
const params = new URLSearchParams();
|
|
22
|
+
const after = nonEmptyString(args.after);
|
|
23
|
+
if (after)
|
|
24
|
+
params.set("after", after);
|
|
25
|
+
if (typeof args.limit === "number" && Number.isFinite(args.limit)) {
|
|
26
|
+
params.set("limit", String(Math.trunc(args.limit)));
|
|
27
|
+
}
|
|
28
|
+
const query = params.toString();
|
|
29
|
+
return {
|
|
30
|
+
path,
|
|
31
|
+
getPath: query ? `${path}?${query}` : path,
|
|
32
|
+
ackPath: path,
|
|
33
|
+
ackPosition: nonEmptyString(args.ack),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Project a catchup page into the caller-facing text summary plus the
|
|
38
|
+
* structured echo (the machine-readable payload). Never throws on a
|
|
39
|
+
* malformed/absent page — it degrades to "no new events".
|
|
40
|
+
*/
|
|
41
|
+
export function summarizeCatchup(page, acked) {
|
|
42
|
+
const events = Array.isArray(page?.events) ? page?.events : [];
|
|
43
|
+
const after = page?.after ?? null;
|
|
44
|
+
const nextAfter = page?.nextAfter ?? after;
|
|
45
|
+
const watermark = page?.watermark ?? null;
|
|
46
|
+
const hasMore = page?.hasMore === true;
|
|
47
|
+
const pageSize = page?.pageSize;
|
|
48
|
+
const header = events.length === 0
|
|
49
|
+
? `Catchup: no new events after ${after ?? "(your watermark)"}.`
|
|
50
|
+
: `Catchup: ${events.length} event(s) after ${after ?? "(your watermark)"}${hasMore ? " (more available)" : ""}.`;
|
|
51
|
+
const lines = events.map((event, index) => {
|
|
52
|
+
const kind = typeof event.kind === "string" ? event.kind : "?";
|
|
53
|
+
const summary = typeof event.summary === "string" ? event.summary : "";
|
|
54
|
+
const id = typeof event.id === "string" ? `id:${event.id}` : "";
|
|
55
|
+
const position = typeof event.position === "string" ? `position:${event.position}` : "";
|
|
56
|
+
const targets = Array.isArray(event.targetIds) && event.targetIds.length > 0
|
|
57
|
+
? `targets:${event.targetIds.join(",")}`
|
|
58
|
+
: "";
|
|
59
|
+
const meta = [id, position, targets].filter(Boolean).join(", ");
|
|
60
|
+
return `${index + 1}. [${kind}] ${summary}${meta ? ` (${meta})` : ""}`;
|
|
61
|
+
});
|
|
62
|
+
const cursorLines = [];
|
|
63
|
+
if (nextAfter)
|
|
64
|
+
cursorLines.push(`nextAfter: ${nextAfter}`);
|
|
65
|
+
if (acked)
|
|
66
|
+
cursorLines.push(`acked: ${acked}`);
|
|
67
|
+
if (nextAfter) {
|
|
68
|
+
cursorLines.push(hasMore
|
|
69
|
+
? `More available — page again with after="${nextAfter}", then ack="${nextAfter}" once drained.`
|
|
70
|
+
: `Ack with ack="${nextAfter}" once you have processed these events to advance your watermark.`);
|
|
71
|
+
}
|
|
72
|
+
const body = [header, ...lines, ...(cursorLines.length > 0 ? ["", ...cursorLines] : [])].join("\n");
|
|
73
|
+
const structuredContent = {
|
|
74
|
+
events,
|
|
75
|
+
after,
|
|
76
|
+
nextAfter,
|
|
77
|
+
watermark,
|
|
78
|
+
hasMore,
|
|
79
|
+
};
|
|
80
|
+
if (typeof pageSize === "number")
|
|
81
|
+
structuredContent.pageSize = pageSize;
|
|
82
|
+
if (acked)
|
|
83
|
+
structuredContent.acked = acked;
|
|
84
|
+
return { text: body, structuredContent };
|
|
85
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function classifyError(err: unknown, flairUrl: string): string;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { FlairError, formatKeyLookup, inspectKeyLookup } from "@tpsdev-ai/flair-client";
|
|
2
|
+
import { readEnvOrUnset } from "./env-guard.js";
|
|
3
|
+
export function classifyError(err, flairUrl) {
|
|
4
|
+
if (err instanceof FlairError) {
|
|
5
|
+
const { status, body } = err;
|
|
6
|
+
if (status === 400)
|
|
7
|
+
return `validation_error: ${body}`;
|
|
8
|
+
if (status === 401 || status === 403) {
|
|
9
|
+
// flair#1271: name the agent, the paths that were looked in, and the
|
|
10
|
+
// remedy. A cached-miss / wrong-HOME 401 is not a daemon-restart hint.
|
|
11
|
+
const lookup = err.keyLookup ?? {
|
|
12
|
+
...inspectKeyLookup(readEnvOrUnset("FLAIR_AGENT_ID") ?? "", readEnvOrUnset("FLAIR_KEY_PATH")),
|
|
13
|
+
signed: false,
|
|
14
|
+
authMethod: "none",
|
|
15
|
+
};
|
|
16
|
+
return `auth_error: ${body}\n${formatKeyLookup(lookup)}`;
|
|
17
|
+
}
|
|
18
|
+
if (status === 413)
|
|
19
|
+
return `payload_too_large: ${body}`;
|
|
20
|
+
if (status === 429)
|
|
21
|
+
return "rate_limited — retry after a moment";
|
|
22
|
+
if (status >= 500)
|
|
23
|
+
return `server_error (retriable): ${body}`;
|
|
24
|
+
return `http_error (${status}): ${body}`;
|
|
25
|
+
}
|
|
26
|
+
if (err instanceof Error) {
|
|
27
|
+
if (err.name.includes("Abort") || err.name.includes("Timeout")) {
|
|
28
|
+
return "timeout — the server took too long. This often happens with large content that requires embedding. Try shorter content or retry.";
|
|
29
|
+
}
|
|
30
|
+
if (err instanceof TypeError && err.message.includes("fetch")) {
|
|
31
|
+
return `connection_error (retriable): could not reach Flair at ${flairUrl}. Is it running?\n` +
|
|
32
|
+
`(Diagnostics:\n` +
|
|
33
|
+
` - 'curl ${flairUrl}/Health' — if this responds 200 or 401, daemon is up + this is an auth issue not a connection one.\n` +
|
|
34
|
+
` - 'launchctl list | grep flair' (macOS) or 'systemctl status flair' (Linux).)`;
|
|
35
|
+
}
|
|
36
|
+
return `unexpected_error: ${err.message}`;
|
|
37
|
+
}
|
|
38
|
+
return `unexpected_error: ${String(err)}`;
|
|
39
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Flair MCP Server — persistent memory for Claude Code and any MCP client.
|
|
4
4
|
*
|
|
5
|
-
* Tools:
|
|
5
|
+
* Tools (derived from @tpsdev-ai/flair-tool-descriptors — flair#1580):
|
|
6
6
|
* - memory_search — semantic search across memories
|
|
7
7
|
* - memory_store — save a memory with type + durability
|
|
8
8
|
* - memory_update — update an existing memory by ID (dedup-bypassed)
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
* - soul_get — get a personality/context entry
|
|
15
15
|
* - flair_workspace_set — write own WorkspaceState (Office Space coordination)
|
|
16
16
|
* - flair_orgevent — publish an OrgEvent attributed to self (no forging)
|
|
17
|
+
* - flair_catchup — drain + ack self's own OrgEventCatchup feed (owner-scoped)
|
|
17
18
|
* - record_usage — report that recalled memories were actually used (flair#1147)
|
|
19
|
+
* - skill_store — write a skill-tagged memory (trigger + procedure)
|
|
20
|
+
* - skill_search — catalog skills that apply to a task (not the procedure)
|
|
21
|
+
* - skill_get — retrieve the full skill by id (disclosure after search)
|
|
18
22
|
*
|
|
19
23
|
* Auto-presence (flair#598): every tool call above triggers a fire-and-forget,
|
|
20
24
|
* rate-limited `POST /Presence` heartbeat for the calling agent (see
|
|
@@ -39,5 +43,5 @@
|
|
|
39
43
|
* — the silent `npx -y @tpsdev-ai/flair-mcp` failure. The shim checks the Node
|
|
40
44
|
* version FIRST, then dynamically imports this module and calls runMcp().
|
|
41
45
|
*/
|
|
42
|
-
export
|
|
46
|
+
export { classifyError } from "./errors.js";
|
|
43
47
|
export declare function runMcp(): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Flair MCP Server — persistent memory for Claude Code and any MCP client.
|
|
4
4
|
*
|
|
5
|
-
* Tools:
|
|
5
|
+
* Tools (derived from @tpsdev-ai/flair-tool-descriptors — flair#1580):
|
|
6
6
|
* - memory_search — semantic search across memories
|
|
7
7
|
* - memory_store — save a memory with type + durability
|
|
8
8
|
* - memory_update — update an existing memory by ID (dedup-bypassed)
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
* - soul_get — get a personality/context entry
|
|
15
15
|
* - flair_workspace_set — write own WorkspaceState (Office Space coordination)
|
|
16
16
|
* - flair_orgevent — publish an OrgEvent attributed to self (no forging)
|
|
17
|
+
* - flair_catchup — drain + ack self's own OrgEventCatchup feed (owner-scoped)
|
|
17
18
|
* - record_usage — report that recalled memories were actually used (flair#1147)
|
|
19
|
+
* - skill_store — write a skill-tagged memory (trigger + procedure)
|
|
20
|
+
* - skill_search — catalog skills that apply to a task (not the procedure)
|
|
21
|
+
* - skill_get — retrieve the full skill by id (disclosure after search)
|
|
18
22
|
*
|
|
19
23
|
* Auto-presence (flair#598): every tool call above triggers a fire-and-forget,
|
|
20
24
|
* rate-limited `POST /Presence` heartbeat for the calling agent (see
|
|
@@ -41,53 +45,12 @@
|
|
|
41
45
|
*/
|
|
42
46
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
43
47
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
44
|
-
import { FlairClient
|
|
45
|
-
import {
|
|
46
|
-
import { deriveActivity, postPresenceSafe, resolveHeartbeatIntervalMs, resolvePresenceTimeoutMs, shouldSendHeartbeat, } from "./presence.js";
|
|
48
|
+
import { FlairClient } from "@tpsdev-ai/flair-client";
|
|
49
|
+
import { postPresenceSafe, resolveHeartbeatIntervalMs, resolvePresenceTimeoutMs, shouldSendHeartbeat, } from "./presence.js";
|
|
47
50
|
import { readEnvOrUnset, stripInterpolationLiteralsFromEnv } from "./env-guard.js";
|
|
48
|
-
import { buildRecordUsageBody, citationIds, withCiteNudge, RECORD_USAGE_ID_MERGE_CONTRACT } from "./usage.js";
|
|
49
51
|
import { serverInfo } from "./version.js";
|
|
50
|
-
|
|
51
|
-
export
|
|
52
|
-
if (err instanceof FlairError) {
|
|
53
|
-
const { status, body } = err;
|
|
54
|
-
if (status === 400)
|
|
55
|
-
return `validation_error: ${body}`;
|
|
56
|
-
if (status === 401 || status === 403) {
|
|
57
|
-
// flair#1271: name the agent, the paths that were looked in, and the
|
|
58
|
-
// remedy. A cached-miss / wrong-HOME 401 is not a daemon-restart hint.
|
|
59
|
-
const lookup = err.keyLookup ?? {
|
|
60
|
-
...inspectKeyLookup(readEnvOrUnset("FLAIR_AGENT_ID") ?? "", readEnvOrUnset("FLAIR_KEY_PATH")),
|
|
61
|
-
signed: false,
|
|
62
|
-
authMethod: "none",
|
|
63
|
-
};
|
|
64
|
-
return `auth_error: ${body}\n${formatKeyLookup(lookup)}`;
|
|
65
|
-
}
|
|
66
|
-
if (status === 413)
|
|
67
|
-
return `payload_too_large: ${body}`;
|
|
68
|
-
if (status === 429)
|
|
69
|
-
return "rate_limited — retry after a moment";
|
|
70
|
-
if (status >= 500)
|
|
71
|
-
return `server_error (retriable): ${body}`;
|
|
72
|
-
return `http_error (${status}): ${body}`;
|
|
73
|
-
}
|
|
74
|
-
if (err instanceof Error) {
|
|
75
|
-
if (err.name.includes("Abort") || err.name.includes("Timeout")) {
|
|
76
|
-
return "timeout — the server took too long. This often happens with large content that requires embedding. Try shorter content or retry.";
|
|
77
|
-
}
|
|
78
|
-
if (err instanceof TypeError && err.message.includes("fetch")) {
|
|
79
|
-
return `connection_error (retriable): could not reach Flair at ${flairUrl}. Is it running?\n` +
|
|
80
|
-
`(Diagnostics:\n` +
|
|
81
|
-
` - 'curl ${flairUrl}/Health' — if this responds 200 or 401, daemon is up + this is an auth issue not a connection one.\n` +
|
|
82
|
-
` - 'launchctl list | grep flair' (macOS) or 'systemctl status flair' (Linux).)`;
|
|
83
|
-
}
|
|
84
|
-
return `unexpected_error: ${err.message}`;
|
|
85
|
-
}
|
|
86
|
-
return `unexpected_error: ${String(err)}`;
|
|
87
|
-
}
|
|
88
|
-
function errorResult(err, flairUrl) {
|
|
89
|
-
return { content: [{ type: "text", text: classifyError(err, flairUrl) }], isError: true };
|
|
90
|
-
}
|
|
52
|
+
import { registerStdioTools } from "./adapter-tools.js";
|
|
53
|
+
export { classifyError } from "./errors.js";
|
|
91
54
|
// ─── Entry point ──────────────────────────────────────────────────────────────
|
|
92
55
|
//
|
|
93
56
|
// runMcp() is the real entry point. It is exported so the CommonJS preflight
|
|
@@ -216,336 +179,19 @@ export async function runMcp() {
|
|
|
216
179
|
}
|
|
217
180
|
// ─── MCP Server ──────────────────────────────────────────────────────────────
|
|
218
181
|
const server = new McpServer(serverInfo());
|
|
219
|
-
// ─── Tools
|
|
220
|
-
server.tool("memory_search", "Search memories by meaning. Understands temporal queries like 'what happened today'.", {
|
|
221
|
-
query: z.string().describe("Search query — natural language, semantic matching"),
|
|
222
|
-
limit: z.coerce.number().optional().default(5).describe("Max results (default 5)"),
|
|
223
|
-
}, async ({ query, limit }) => {
|
|
224
|
-
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
225
|
-
try {
|
|
226
|
-
const results = await flair.memory.search(query, { limit });
|
|
227
|
-
if (results.length === 0) {
|
|
228
|
-
return { content: [{ type: "text", text: "No relevant memories found." }] };
|
|
229
|
-
}
|
|
230
|
-
const text = results
|
|
231
|
-
.map((r, i) => {
|
|
232
|
-
const date = r.createdAt ? r.createdAt.slice(0, 10) : "";
|
|
233
|
-
const idStr = r.id ? `id:${r.id}` : "";
|
|
234
|
-
const meta = [date, r.type, idStr].filter(Boolean).join(", ");
|
|
235
|
-
return `${i + 1}. ${r.content}${meta ? ` (${meta})` : ""}`;
|
|
236
|
-
})
|
|
237
|
-
.join("\n");
|
|
238
|
-
return { content: [{ type: "text", text: withCiteNudge(text) }] };
|
|
239
|
-
}
|
|
240
|
-
catch (err) {
|
|
241
|
-
return errorResult(err, flair.url);
|
|
242
|
-
}
|
|
243
|
-
});
|
|
244
|
-
server.tool("memory_store", "Save information to persistent memory. Use for lessons, decisions, preferences, facts.", {
|
|
245
|
-
content: z.string().describe("What to remember"),
|
|
246
|
-
type: z.enum(["session", "lesson", "decision", "preference", "fact", "goal"]).optional().default("session"),
|
|
247
|
-
durability: z.enum(["permanent", "persistent", "standard", "ephemeral"]).optional().default("standard")
|
|
248
|
-
.describe("permanent — inviolable facts, identity, explicit never-forget (e.g., 'my name is Nathan')\n" +
|
|
249
|
-
"persistent — key decisions and lessons to recall weeks later (e.g., 'PR review process')\n" +
|
|
250
|
-
"standard — default working memory, recent context (e.g., 'discussed auth flow today')\n" +
|
|
251
|
-
"ephemeral — scratch state, auto-expires 72h (e.g., 'currently debugging issue #42')"),
|
|
252
|
-
tags: z.array(z.string()).optional().describe("Array of tag strings"),
|
|
253
|
-
visibility: z.enum(["private", "shared"]).optional().describe("Writer-controlled sharing intent (omit to use the server's durability-keyed default: " +
|
|
254
|
-
"permanent/persistent -> shared, standard/ephemeral -> private). " +
|
|
255
|
-
"private -- never visible to another agent, even one with a memory grant. " +
|
|
256
|
-
"shared -- visible to the owner and any agent holding a read/search grant."),
|
|
257
|
-
usedMemoryIds: z.array(z.string()).optional().describe("IDs of memories that informed this write (citation-on-write). Credited via the same " +
|
|
258
|
-
"deduped usage ledger as record_usage. Optional."),
|
|
259
|
-
}, async ({ content, type, durability, tags, visibility, usedMemoryIds }) => {
|
|
260
|
-
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
261
|
-
try {
|
|
262
|
-
const result = await flair.memory.write(content, {
|
|
263
|
-
type: type,
|
|
264
|
-
durability: durability,
|
|
265
|
-
tags,
|
|
266
|
-
visibility: visibility,
|
|
267
|
-
dedup: true,
|
|
268
|
-
dedupThreshold: 0.95,
|
|
269
|
-
usedMemoryIds: citationIds(usedMemoryIds),
|
|
270
|
-
});
|
|
271
|
-
// The server's conservative dedup gate NEVER suppresses a write
|
|
272
|
-
// (memory-integrity fix, flair#526) — `result.deduplicated` is a
|
|
273
|
-
// collision SIGNAL, not a "was this dropped" flag. The new content at
|
|
274
|
-
// `result.id` is ALWAYS written; when flagged, `result.matchedId` names
|
|
275
|
-
// the similar existing memory (see result.matchConfidence for the
|
|
276
|
-
// cosine/lexical scores). Emit both prose AND structuredContent so
|
|
277
|
-
// callers can react programmatically even when LLMs compress prose
|
|
278
|
-
// imprecisely. (Historical note: this tool used to treat a dedup hit as
|
|
279
|
-
// "new content was NOT written" — that WAS the flair#449/#526 silent
|
|
280
|
-
// data-loss bug. The gate is server-side now and never suppresses.)
|
|
281
|
-
const deduplicated = result.deduplicated === true;
|
|
282
|
-
const matchedId = result.matchedId;
|
|
283
|
-
const effectiveVisibility = result.visibility;
|
|
284
|
-
const preview = content.length > 120 ? content.slice(0, 120) + "..." : content;
|
|
285
|
-
const tagStr = tags && tags.length > 0 ? tags.join(", ") : "none";
|
|
286
|
-
const lines = [
|
|
287
|
-
`Memory stored (id: ${result.id})`,
|
|
288
|
-
`Preview: ${preview}`,
|
|
289
|
-
`Size: ${content.length} chars`,
|
|
290
|
-
`Tags: ${tagStr}`,
|
|
291
|
-
`Type: ${type}, Durability: ${durability}, Visibility: ${effectiveVisibility ?? "(server default)"}`,
|
|
292
|
-
];
|
|
293
|
-
if (deduplicated && matchedId) {
|
|
294
|
-
lines.push("", `Note: similar to existing memory id=${matchedId} — both are kept. ` +
|
|
295
|
-
`If this was meant to UPDATE that memory rather than add a new one, use memory_update instead.`);
|
|
296
|
-
}
|
|
297
|
-
return {
|
|
298
|
-
content: [{ type: "text", text: lines.join("\n") }],
|
|
299
|
-
structuredContent: { deduplicated, id: result.id, written: true, ...(deduplicated ? { matchedId } : {}) },
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
catch (err) {
|
|
303
|
-
return errorResult(err, flair.url);
|
|
304
|
-
}
|
|
305
|
-
});
|
|
306
|
-
server.tool("memory_update", "Update an existing memory by ID. Dedup-bypassed — this is an intentional overwrite/version, not an ambiguous new write. " +
|
|
307
|
-
"Default: overwrites the same id in place. Pass preserveHistory=true to instead write a new version linked via " +
|
|
308
|
-
"`supersedes`, closing the old one's validity window (requires owning the memory, or a write grant if it's another agent's).", {
|
|
309
|
-
id: z.string().describe("ID of the memory to update"),
|
|
310
|
-
content: z.string().describe("New content"),
|
|
311
|
-
preserveHistory: z.coerce.boolean().optional().default(false)
|
|
312
|
-
.describe("Write a new supersedes-linked version instead of overwriting in place (default false)"),
|
|
313
|
-
usedMemoryIds: z.array(z.string()).optional().describe("IDs of memories that informed this update (citation-on-write). Credited via the same " +
|
|
314
|
-
"deduped usage ledger as record_usage. Optional."),
|
|
315
|
-
}, async ({ id, content, preserveHistory, usedMemoryIds }) => {
|
|
316
|
-
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
317
|
-
try {
|
|
318
|
-
const result = await flair.memory.update(id, content, {
|
|
319
|
-
preserveHistory,
|
|
320
|
-
usedMemoryIds: citationIds(usedMemoryIds),
|
|
321
|
-
});
|
|
322
|
-
const text = preserveHistory
|
|
323
|
-
? `Memory updated: new version stored (id: ${result.id}), supersedes ${id}.`
|
|
324
|
-
: `Memory updated (id: ${id}).`;
|
|
325
|
-
return {
|
|
326
|
-
content: [{ type: "text", text }],
|
|
327
|
-
structuredContent: { id: result.id, supersedes: preserveHistory ? id : undefined, written: true },
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
catch (err) {
|
|
331
|
-
return errorResult(err, flair.url);
|
|
332
|
-
}
|
|
333
|
-
});
|
|
334
|
-
server.tool("memory_get", "Retrieve a specific memory by ID.", {
|
|
335
|
-
id: z.string().describe("Memory ID"),
|
|
336
|
-
}, async ({ id }) => {
|
|
337
|
-
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
338
|
-
try {
|
|
339
|
-
const mem = await flair.memory.get(id);
|
|
340
|
-
if (!mem)
|
|
341
|
-
return { content: [{ type: "text", text: `Memory ${id} not found.` }] };
|
|
342
|
-
return { content: [{ type: "text", text: `${mem.content}\n\n(type: ${mem.type}, durability: ${mem.durability}, created: ${mem.createdAt})` }] };
|
|
343
|
-
}
|
|
344
|
-
catch (err) {
|
|
345
|
-
return errorResult(err, flair.url);
|
|
346
|
-
}
|
|
347
|
-
});
|
|
348
|
-
server.tool("memory_delete", "Delete a memory by ID.", {
|
|
349
|
-
id: z.string().describe("Memory ID to delete"),
|
|
350
|
-
}, async ({ id }) => {
|
|
351
|
-
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
352
|
-
try {
|
|
353
|
-
await flair.memory.delete(id);
|
|
354
|
-
return { content: [{ type: "text", text: `Memory ${id} deleted.` }] };
|
|
355
|
-
}
|
|
356
|
-
catch (err) {
|
|
357
|
-
return errorResult(err, flair.url);
|
|
358
|
-
}
|
|
359
|
-
});
|
|
360
|
-
server.tool("relationship_store", "Record that <subject> <predicate> <object> — an explicit entity-to-entity relationship triple " +
|
|
361
|
-
"(e.g. 'nathan manages flair', 'flint reviews cli'), distinct from a free-text memory. " +
|
|
362
|
-
"ASSERT/UPSERT semantics: writing the SAME triple again (same subject/predicate/object) updates the " +
|
|
363
|
-
"existing row in place (confidence/validTo/source refresh) rather than creating a duplicate — safe to " +
|
|
364
|
-
"re-assert. Predicate is free text (no fixed enum) but prefer a small, consistent vocabulary so the graph " +
|
|
365
|
-
"stays queryable: manages, works_on, reviews, depends_on, replaces, owns, reports_to, advises. " +
|
|
366
|
-
"TO CONTRADICT a prior relationship: (a) re-asserting the identical triple just updates it — fine. " +
|
|
367
|
-
"(b) changing validTo on the SAME subject/predicate/object overwrites the old validTo (the graph tracks " +
|
|
368
|
-
"current state, not full history). (c) changing the PREDICATE (e.g. 'nathan manages flair' -> 'nathan " +
|
|
369
|
-
"advises flair') creates a SEPARATE relationship — it does NOT automatically close the old one. Close it " +
|
|
370
|
-
"yourself first: re-assert the OLD triple with a validTo set to now (or call relationship's delete), THEN " +
|
|
371
|
-
"store the new one.", {
|
|
372
|
-
subject: z.string().describe("Source entity — a person, project, or service (e.g. 'nathan')"),
|
|
373
|
-
predicate: z.string().describe("Relationship type, free text. Recommended vocabulary: manages, works_on, reviews, depends_on, " +
|
|
374
|
-
"replaces, owns, reports_to, advises — consistency helps recall, but any short verb phrase works."),
|
|
375
|
-
object: z.string().describe("Target entity — a person, project, or service (e.g. 'flair')"),
|
|
376
|
-
confidence: z.coerce.number().optional().describe("0.0-1.0, how certain (default 1.0 = explicitly stated)"),
|
|
377
|
-
validFrom: z.string().optional().describe("ISO timestamp this relationship became true (default: now)"),
|
|
378
|
-
validTo: z.string().optional().describe("ISO timestamp this relationship ended. Leave unset for an active relationship; set it (via a re-assert " +
|
|
379
|
-
"of this SAME subject/predicate/object) to close out a relationship you're contradicting with a new predicate."),
|
|
380
|
-
source: z.string().optional().describe("Where this was learned from (a memory ID, conversation, etc.)"),
|
|
381
|
-
}, async ({ subject, predicate, object, confidence, validFrom, validTo, source }) => {
|
|
382
|
-
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
383
|
-
try {
|
|
384
|
-
const result = await flair.relationship.write({ subject, predicate, object, confidence, validFrom, validTo, source });
|
|
385
|
-
const confStr = confidence !== undefined ? ` (confidence: ${confidence})` : "";
|
|
386
|
-
return {
|
|
387
|
-
content: [{ type: "text", text: `Relationship recorded: ${subject} → ${predicate} → ${object}${confStr} (id: ${result.id})` }],
|
|
388
|
-
structuredContent: { id: result.id, subject, predicate, object, written: true },
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
catch (err) {
|
|
392
|
-
return errorResult(err, flair.url);
|
|
393
|
-
}
|
|
394
|
-
});
|
|
395
|
-
server.tool("bootstrap", "Get session context: soul + memories + predicted context. Run at session start. Pass subjects for predictive loading.", {
|
|
396
|
-
maxTokens: z.coerce.number().optional().default(4000).describe("Max tokens in output"),
|
|
397
|
-
currentTask: z.string().optional().describe("Current task description — enables semantic search for relevant memories"),
|
|
398
|
-
channel: z.string().optional().describe("Channel name (discord, tps-mail, claude-code) — shapes context prediction"),
|
|
399
|
-
surface: z.string().optional().describe("Surface name (tps-build, tps-review, cli-session) — narrows prediction"),
|
|
400
|
-
subjects: z.array(z.string()).optional().describe("Entity names to preload context for (e.g., ['flair', 'auth'])"),
|
|
401
|
-
}, async ({ maxTokens, currentTask, channel, surface, subjects }) => {
|
|
402
|
-
// auto-presence (flair#598) — SESSION START. bootstrap() already receives
|
|
403
|
-
// exactly the payload Presence wants: currentTask is what the agent is
|
|
404
|
-
// about to work on, channel/surface are what deriveActivity() uses to
|
|
405
|
-
// pick something more specific than the "coding" default. Routed through
|
|
406
|
-
// the SAME rate-limited heartbeat() as every other tool, so calling
|
|
407
|
-
// bootstrap twice in quick succession (session resume/compact) doesn't
|
|
408
|
-
// double-send — it's still "sets presence once" per session in practice.
|
|
409
|
-
if (currentTask)
|
|
410
|
-
lastKnownTask = currentTask;
|
|
411
|
-
heartbeat(deriveActivity({ channel, surface }));
|
|
412
|
-
try {
|
|
413
|
-
const result = await flair.bootstrap({ maxTokens, currentTask, channel, surface, subjects });
|
|
414
|
-
if (!result.context) {
|
|
415
|
-
return { content: [{ type: "text", text: "No context available." }] };
|
|
416
|
-
}
|
|
417
|
-
return { content: [{ type: "text", text: withCiteNudge(result.context) }] };
|
|
418
|
-
}
|
|
419
|
-
catch (err) {
|
|
420
|
-
return errorResult(err, flair.url);
|
|
421
|
-
}
|
|
422
|
-
});
|
|
423
|
-
server.tool("soul_set", "Set a personality or project context entry. Included in every bootstrap.", {
|
|
424
|
-
key: z.string().describe("Entry key (e.g., 'role', 'standards', 'project')"),
|
|
425
|
-
value: z.string().describe("Entry value — personality trait, project context, coding standards, etc."),
|
|
426
|
-
}, async ({ key, value }) => {
|
|
427
|
-
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
428
|
-
try {
|
|
429
|
-
await flair.soul.set(key, value);
|
|
430
|
-
return { content: [{ type: "text", text: `Soul entry '${key}' set.` }] };
|
|
431
|
-
}
|
|
432
|
-
catch (err) {
|
|
433
|
-
return errorResult(err, flair.url);
|
|
434
|
-
}
|
|
435
|
-
});
|
|
436
|
-
server.tool("soul_get", "Get a personality or project context entry.", {
|
|
437
|
-
key: z.string().describe("Entry key"),
|
|
438
|
-
}, async ({ key }) => {
|
|
439
|
-
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
440
|
-
try {
|
|
441
|
-
const entry = await flair.soul.get(key);
|
|
442
|
-
if (!entry)
|
|
443
|
-
return { content: [{ type: "text", text: `No soul entry for '${key}'.` }] };
|
|
444
|
-
return { content: [{ type: "text", text: entry.value }] };
|
|
445
|
-
}
|
|
446
|
-
catch (err) {
|
|
447
|
-
return errorResult(err, flair.url);
|
|
448
|
-
}
|
|
449
|
-
});
|
|
450
|
-
// ─── Coordination write surface ──────────────────────────────────────────────
|
|
182
|
+
// ─── Tools (derived from @tpsdev-ai/flair-tool-descriptors) ───────────────
|
|
451
183
|
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
ref: z.string().describe("Workspace ref — branch, worktree, or task ref"),
|
|
461
|
-
label: z.string().optional().describe("Human-readable label for this workspace"),
|
|
462
|
-
provider: z.string().optional().default("mcp").describe("Provider/runtime (e.g. claude-code, openclaw)"),
|
|
463
|
-
task: z.string().optional().describe("Task/issue id this workspace is attached to"),
|
|
464
|
-
phase: z.string().optional().describe("Current phase (e.g. design, implement, review)"),
|
|
465
|
-
summary: z.string().optional().describe("Short summary of current workspace state"),
|
|
466
|
-
}, async ({ ref, label, provider, task, phase, summary }) => {
|
|
467
|
-
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
468
|
-
try {
|
|
469
|
-
// No agentId in body — the server attributes from the signed identity.
|
|
470
|
-
const body = {
|
|
471
|
-
id: `${agentId}:${ref}`,
|
|
472
|
-
ref,
|
|
473
|
-
provider: provider ?? "mcp",
|
|
474
|
-
timestamp: new Date().toISOString(),
|
|
475
|
-
};
|
|
476
|
-
if (label)
|
|
477
|
-
body.label = label;
|
|
184
|
+
// The advertised set is STDIO_TOOL_DESCRIPTORS. Handlers bind each
|
|
185
|
+
// descriptor to a FlairClient HTTP call. A new shared descriptor appears
|
|
186
|
+
// here for free once a FlairClient binding exists — no per-tool literals.
|
|
187
|
+
registerStdioTools(server, {
|
|
188
|
+
flair,
|
|
189
|
+
agentId,
|
|
190
|
+
heartbeat,
|
|
191
|
+
rememberTask: (task) => {
|
|
478
192
|
if (task)
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
body.phase = phase;
|
|
482
|
-
if (summary)
|
|
483
|
-
body.summary = summary;
|
|
484
|
-
await flair.request("POST", "/WorkspaceState", body);
|
|
485
|
-
return { content: [{ type: "text", text: `Workspace state set: ref=${ref}${phase ? `, phase=${phase}` : ""} (attributed to ${agentId}).` }] };
|
|
486
|
-
}
|
|
487
|
-
catch (err) {
|
|
488
|
-
return errorResult(err, flair.url);
|
|
489
|
-
}
|
|
490
|
-
});
|
|
491
|
-
server.tool("flair_orgevent", "Publish an org-wide coordination event (claim/release/status) to the Office Space. Attributed to you from your signed identity — you cannot publish as another agent.", {
|
|
492
|
-
kind: z.string().describe("Event kind (e.g. coord.claim, coord.release, status)"),
|
|
493
|
-
summary: z.string().describe("Short summary of the event"),
|
|
494
|
-
detail: z.string().optional().describe("Longer detail payload"),
|
|
495
|
-
scope: z.string().optional().describe("Scope of the event (e.g. an agent id, repo, or 'org')"),
|
|
496
|
-
targets: z.array(z.string()).optional().describe("Recipient agent ids"),
|
|
497
|
-
}, async ({ kind, summary, detail, scope, targets }) => {
|
|
498
|
-
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
499
|
-
try {
|
|
500
|
-
// No authorId in body — the server attributes from the signed identity.
|
|
501
|
-
const body = { kind, summary };
|
|
502
|
-
if (detail)
|
|
503
|
-
body.detail = detail;
|
|
504
|
-
if (scope)
|
|
505
|
-
body.scope = scope;
|
|
506
|
-
if (targets && targets.length > 0)
|
|
507
|
-
body.targetIds = targets;
|
|
508
|
-
const result = await flair.request("POST", "/OrgEvent", body);
|
|
509
|
-
const targetStr = targets && targets.length > 0 ? ` → ${targets.join(", ")}` : "";
|
|
510
|
-
const idStr = result?.id ? ` (id: ${result.id})` : "";
|
|
511
|
-
return { content: [{ type: "text", text: `OrgEvent published: kind=${kind}${targetStr} (attributed to ${agentId})${idStr}.` }] };
|
|
512
|
-
}
|
|
513
|
-
catch (err) {
|
|
514
|
-
return errorResult(err, flair.url);
|
|
515
|
-
}
|
|
516
|
-
});
|
|
517
|
-
// ─── Usage feedback (flair#1147) ─────────────────────────────────────────────
|
|
518
|
-
//
|
|
519
|
-
// POST /RecordUsage already existed; native /mcp already wrapped it. The
|
|
520
|
-
// stdio package did not, so a Claude Code / Cursor client could not close
|
|
521
|
-
// the usageCount loop. Identity is taken from the signed request — the body
|
|
522
|
-
// carries only memory id(s) + optional attribution, never agentId.
|
|
523
|
-
server.tool("record_usage", "Report that one or more memories were actually USED — cited or relied on to ground an answer or decision. " +
|
|
524
|
-
"Distinct from search (surfacing a memory is not usage). Dedup'd (you can only count once per memory) and rate-limited. " +
|
|
525
|
-
RECORD_USAGE_ID_MERGE_CONTRACT, {
|
|
526
|
-
memoryId: z.string().optional().describe("A single memory id that was used. Merged with memoryIds when both are supplied — not dropped."),
|
|
527
|
-
memoryIds: z.array(z.string()).optional().describe("IDs of the memories that were used (max 20 per call). Merged with memoryId when both are supplied."),
|
|
528
|
-
attribution: z.string().optional().describe("Optional one-line note on how it was used (opaque — stored for audit only)"),
|
|
529
|
-
}, async ({ memoryId, memoryIds, attribution }) => {
|
|
530
|
-
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
531
|
-
try {
|
|
532
|
-
const body = buildRecordUsageBody({ memoryId, memoryIds, attribution });
|
|
533
|
-
if (!body) {
|
|
534
|
-
return {
|
|
535
|
-
content: [{ type: "text", text: "record_usage requires memoryId or memoryIds." }],
|
|
536
|
-
isError: true,
|
|
537
|
-
};
|
|
538
|
-
}
|
|
539
|
-
const result = await flair.request("POST", "/RecordUsage", body);
|
|
540
|
-
const text = result?.recorded === true ? "Usage recorded." : "Usage request accepted.";
|
|
541
|
-
return {
|
|
542
|
-
content: [{ type: "text", text }],
|
|
543
|
-
structuredContent: { recorded: result?.recorded === true },
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
catch (err) {
|
|
547
|
-
return errorResult(err, flair.url);
|
|
548
|
-
}
|
|
193
|
+
lastKnownTask = task;
|
|
194
|
+
},
|
|
549
195
|
});
|
|
550
196
|
// ─── Start ───────────────────────────────────────────────────────────────────
|
|
551
197
|
const transport = new StdioServerTransport();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema → Zod raw shape for MCP SDK `server.tool()` (flair#1580).
|
|
3
|
+
*
|
|
4
|
+
* Lives in the adapter, not the descriptor module: Zod is an SDK concern.
|
|
5
|
+
* Number/boolean use `z.coerce` so MCP clients that send stringified scalars
|
|
6
|
+
* keep working (the previous hand-wired Zod schemas did the same).
|
|
7
|
+
*/
|
|
8
|
+
import { type ZodTypeAny } from "zod";
|
|
9
|
+
import type { JsonSchemaObject } from "@tpsdev-ai/flair-tool-descriptors";
|
|
10
|
+
export declare function jsonSchemaToZodShape(schema: JsonSchemaObject): Record<string, ZodTypeAny>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema → Zod raw shape for MCP SDK `server.tool()` (flair#1580).
|
|
3
|
+
*
|
|
4
|
+
* Lives in the adapter, not the descriptor module: Zod is an SDK concern.
|
|
5
|
+
* Number/boolean use `z.coerce` so MCP clients that send stringified scalars
|
|
6
|
+
* keep working (the previous hand-wired Zod schemas did the same).
|
|
7
|
+
*/
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
export function jsonSchemaToZodShape(schema) {
|
|
10
|
+
const required = new Set(schema.required ?? []);
|
|
11
|
+
const shape = {};
|
|
12
|
+
for (const [key, prop] of Object.entries(schema.properties ?? {})) {
|
|
13
|
+
let t = propToZod(prop);
|
|
14
|
+
if (prop.description)
|
|
15
|
+
t = t.describe(prop.description);
|
|
16
|
+
if (!required.has(key))
|
|
17
|
+
t = t.optional();
|
|
18
|
+
if (prop.default !== undefined)
|
|
19
|
+
t = t.default(prop.default);
|
|
20
|
+
shape[key] = t;
|
|
21
|
+
}
|
|
22
|
+
return shape;
|
|
23
|
+
}
|
|
24
|
+
function propToZod(prop) {
|
|
25
|
+
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
|
|
26
|
+
const [first, ...rest] = prop.enum;
|
|
27
|
+
return z.enum([first, ...rest]);
|
|
28
|
+
}
|
|
29
|
+
switch (prop.type) {
|
|
30
|
+
case "string":
|
|
31
|
+
return z.string();
|
|
32
|
+
case "number":
|
|
33
|
+
return z.coerce.number();
|
|
34
|
+
case "boolean":
|
|
35
|
+
return z.coerce.boolean();
|
|
36
|
+
case "array":
|
|
37
|
+
if (prop.items?.type === "string")
|
|
38
|
+
return z.array(z.string());
|
|
39
|
+
throw new Error(`unsupported array items: ${JSON.stringify(prop.items)}`);
|
|
40
|
+
default:
|
|
41
|
+
throw new Error(`unsupported json schema type: ${JSON.stringify(prop.type)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|