@tpsdev-ai/flair-mcp 0.20.0 → 0.21.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/dist/index.d.ts +7 -0
- package/dist/index.js +77 -0
- package/dist/presence.d.ts +115 -0
- package/dist/presence.js +162 -0
- package/dist/session-start-hook.d.ts +19 -2
- package/dist/session-start-hook.js +29 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
* - flair_workspace_set — write own WorkspaceState (Office Space coordination)
|
|
15
15
|
* - flair_orgevent — publish an OrgEvent attributed to self (no forging)
|
|
16
16
|
*
|
|
17
|
+
* Auto-presence (flair#598): every tool call above triggers a fire-and-forget,
|
|
18
|
+
* rate-limited `POST /Presence` heartbeat for the calling agent (see
|
|
19
|
+
* ./presence.ts + the `heartbeat()` closure in runMcp()) — presence is now a
|
|
20
|
+
* side effect of normal activity instead of the manual `flair presence set`
|
|
21
|
+
* CLI command nobody ran. `bootstrap` additionally seeds the activity/task
|
|
22
|
+
* from its own arguments (session start signal).
|
|
23
|
+
*
|
|
17
24
|
* Usage:
|
|
18
25
|
* npx -y @tpsdev-ai/flair-mcp
|
|
19
26
|
*
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
* - flair_workspace_set — write own WorkspaceState (Office Space coordination)
|
|
15
15
|
* - flair_orgevent — publish an OrgEvent attributed to self (no forging)
|
|
16
16
|
*
|
|
17
|
+
* Auto-presence (flair#598): every tool call above triggers a fire-and-forget,
|
|
18
|
+
* rate-limited `POST /Presence` heartbeat for the calling agent (see
|
|
19
|
+
* ./presence.ts + the `heartbeat()` closure in runMcp()) — presence is now a
|
|
20
|
+
* side effect of normal activity instead of the manual `flair presence set`
|
|
21
|
+
* CLI command nobody ran. `bootstrap` additionally seeds the activity/task
|
|
22
|
+
* from its own arguments (session start signal).
|
|
23
|
+
*
|
|
17
24
|
* Usage:
|
|
18
25
|
* npx -y @tpsdev-ai/flair-mcp
|
|
19
26
|
*
|
|
@@ -34,6 +41,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
34
41
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
35
42
|
import { FlairClient, FlairError } from "@tpsdev-ai/flair-client";
|
|
36
43
|
import { z } from "zod";
|
|
44
|
+
import { deriveActivity, postPresenceSafe, resolveHeartbeatIntervalMs, resolvePresenceTimeoutMs, shouldSendHeartbeat, } from "./presence.js";
|
|
37
45
|
// ─── Error helpers ──────────────────────────────────────────────────────────
|
|
38
46
|
function classifyError(err, flairUrl) {
|
|
39
47
|
if (err instanceof FlairError) {
|
|
@@ -138,6 +146,56 @@ export async function runMcp() {
|
|
|
138
146
|
url: process.env.FLAIR_URL,
|
|
139
147
|
keyPath: process.env.FLAIR_KEY_PATH,
|
|
140
148
|
});
|
|
149
|
+
// ─── Auto-presence (flair#598) ────────────────────────────────────────────────
|
|
150
|
+
//
|
|
151
|
+
// Presence used to be manual-only (`flair presence set`, which in practice
|
|
152
|
+
// nobody runs) — so the roster was permanently stale and the collision-
|
|
153
|
+
// detection it exists for never worked. This makes presence a SIDE EFFECT of
|
|
154
|
+
// normal agent activity instead: `bootstrap` (session start) always attempts
|
|
155
|
+
// a heartbeat, and every other tool call attempts one too, both routed
|
|
156
|
+
// through the SAME rate-limited, fire-and-forget `heartbeat()` below so
|
|
157
|
+
// repeated bootstrap calls in a short window don't spam any more than
|
|
158
|
+
// repeated tool calls would.
|
|
159
|
+
//
|
|
160
|
+
// A SEPARATE FlairClient instance, not the main `flair` above — its own
|
|
161
|
+
// short `timeoutMs` (resolvePresenceTimeoutMs(), default 3s vs the main
|
|
162
|
+
// client's general-purpose 30s default) means a dead/slow daemon can never
|
|
163
|
+
// make a presence write outlive the tool call it rode in on, independent of
|
|
164
|
+
// whatever timeout the main client is configured with.
|
|
165
|
+
const presenceFlair = new FlairClient({
|
|
166
|
+
agentId,
|
|
167
|
+
url: process.env.FLAIR_URL,
|
|
168
|
+
keyPath: process.env.FLAIR_KEY_PATH,
|
|
169
|
+
timeoutMs: resolvePresenceTimeoutMs(),
|
|
170
|
+
});
|
|
171
|
+
// Rate-limit clock + last-known task, in-process only (no persistence —
|
|
172
|
+
// resets on restart, which is fine; the next tool call re-establishes it).
|
|
173
|
+
// Owned here, not in presence.ts, so the pure rate-limit/body-building logic
|
|
174
|
+
// stays testable without any shared mutable state.
|
|
175
|
+
let lastPresenceSentAt = null;
|
|
176
|
+
// currentTask is the caller's responsibility to preserve (see
|
|
177
|
+
// postPresenceSafe's doc comment): Presence.post() treats an ABSENT
|
|
178
|
+
// currentTask as an explicit clear, so a heartbeat that doesn't know about
|
|
179
|
+
// an in-flight task must keep resending the last one bootstrap() told us
|
|
180
|
+
// about, or it would silently erase it every ~3 minutes.
|
|
181
|
+
let lastKnownTask;
|
|
182
|
+
/**
|
|
183
|
+
* Fire-and-forget, rate-limited presence heartbeat. Safe to call from every
|
|
184
|
+
* tool handler unconditionally — the rate limit + postPresenceSafe's
|
|
185
|
+
* internal catch-everything mean this NEVER throws, NEVER awaits network
|
|
186
|
+
* I/O in the caller's path, and NEVER delays the tool response. The
|
|
187
|
+
* `.catch(() => {})` below is belt-and-suspenders (postPresenceSafe already
|
|
188
|
+
* never rejects) purely so a future change to that function can't
|
|
189
|
+
* accidentally reintroduce an unhandled rejection here.
|
|
190
|
+
*/
|
|
191
|
+
function heartbeat(activity = "coding") {
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
if (!shouldSendHeartbeat(now, lastPresenceSentAt, resolveHeartbeatIntervalMs()))
|
|
194
|
+
return;
|
|
195
|
+
lastPresenceSentAt = now; // set synchronously, before the async write, so a burst of
|
|
196
|
+
// tool calls in the same tick can't all slip past the rate limit together
|
|
197
|
+
postPresenceSafe(presenceFlair, activity, lastKnownTask, resolvePresenceTimeoutMs()).catch(() => { });
|
|
198
|
+
}
|
|
141
199
|
// ─── MCP Server ──────────────────────────────────────────────────────────────
|
|
142
200
|
const server = new McpServer({
|
|
143
201
|
name: "flair",
|
|
@@ -148,6 +206,7 @@ export async function runMcp() {
|
|
|
148
206
|
query: z.string().describe("Search query — natural language, semantic matching"),
|
|
149
207
|
limit: z.coerce.number().optional().default(5).describe("Max results (default 5)"),
|
|
150
208
|
}, async ({ query, limit }) => {
|
|
209
|
+
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
151
210
|
try {
|
|
152
211
|
const results = await flair.memory.search(query, { limit });
|
|
153
212
|
if (results.length === 0) {
|
|
@@ -181,6 +240,7 @@ export async function runMcp() {
|
|
|
181
240
|
"private -- never visible to another agent, even one with a memory grant. " +
|
|
182
241
|
"shared -- visible to the owner and any agent holding a read/search grant."),
|
|
183
242
|
}, async ({ content, type, durability, tags, visibility }) => {
|
|
243
|
+
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
184
244
|
try {
|
|
185
245
|
const result = await flair.memory.write(content, {
|
|
186
246
|
type: type,
|
|
@@ -233,6 +293,7 @@ export async function runMcp() {
|
|
|
233
293
|
preserveHistory: z.coerce.boolean().optional().default(false)
|
|
234
294
|
.describe("Write a new supersedes-linked version instead of overwriting in place (default false)"),
|
|
235
295
|
}, async ({ id, content, preserveHistory }) => {
|
|
296
|
+
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
236
297
|
try {
|
|
237
298
|
const result = await flair.memory.update(id, content, { preserveHistory });
|
|
238
299
|
const text = preserveHistory
|
|
@@ -250,6 +311,7 @@ export async function runMcp() {
|
|
|
250
311
|
server.tool("memory_get", "Retrieve a specific memory by ID.", {
|
|
251
312
|
id: z.string().describe("Memory ID"),
|
|
252
313
|
}, async ({ id }) => {
|
|
314
|
+
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
253
315
|
try {
|
|
254
316
|
const mem = await flair.memory.get(id);
|
|
255
317
|
if (!mem)
|
|
@@ -263,6 +325,7 @@ export async function runMcp() {
|
|
|
263
325
|
server.tool("memory_delete", "Delete a memory by ID.", {
|
|
264
326
|
id: z.string().describe("Memory ID to delete"),
|
|
265
327
|
}, async ({ id }) => {
|
|
328
|
+
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
266
329
|
try {
|
|
267
330
|
await flair.memory.delete(id);
|
|
268
331
|
return { content: [{ type: "text", text: `Memory ${id} deleted.` }] };
|
|
@@ -278,6 +341,16 @@ export async function runMcp() {
|
|
|
278
341
|
surface: z.string().optional().describe("Surface name (tps-build, tps-review, cli-session) — narrows prediction"),
|
|
279
342
|
subjects: z.array(z.string()).optional().describe("Entity names to preload context for (e.g., ['flair', 'auth'])"),
|
|
280
343
|
}, async ({ maxTokens, currentTask, channel, surface, subjects }) => {
|
|
344
|
+
// auto-presence (flair#598) — SESSION START. bootstrap() already receives
|
|
345
|
+
// exactly the payload Presence wants: currentTask is what the agent is
|
|
346
|
+
// about to work on, channel/surface are what deriveActivity() uses to
|
|
347
|
+
// pick something more specific than the "coding" default. Routed through
|
|
348
|
+
// the SAME rate-limited heartbeat() as every other tool, so calling
|
|
349
|
+
// bootstrap twice in quick succession (session resume/compact) doesn't
|
|
350
|
+
// double-send — it's still "sets presence once" per session in practice.
|
|
351
|
+
if (currentTask)
|
|
352
|
+
lastKnownTask = currentTask;
|
|
353
|
+
heartbeat(deriveActivity({ channel, surface }));
|
|
281
354
|
try {
|
|
282
355
|
const result = await flair.bootstrap({ maxTokens, currentTask, channel, surface, subjects });
|
|
283
356
|
if (!result.context) {
|
|
@@ -293,6 +366,7 @@ export async function runMcp() {
|
|
|
293
366
|
key: z.string().describe("Entry key (e.g., 'role', 'standards', 'project')"),
|
|
294
367
|
value: z.string().describe("Entry value — personality trait, project context, coding standards, etc."),
|
|
295
368
|
}, async ({ key, value }) => {
|
|
369
|
+
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
296
370
|
try {
|
|
297
371
|
await flair.soul.set(key, value);
|
|
298
372
|
return { content: [{ type: "text", text: `Soul entry '${key}' set.` }] };
|
|
@@ -304,6 +378,7 @@ export async function runMcp() {
|
|
|
304
378
|
server.tool("soul_get", "Get a personality or project context entry.", {
|
|
305
379
|
key: z.string().describe("Entry key"),
|
|
306
380
|
}, async ({ key }) => {
|
|
381
|
+
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
307
382
|
try {
|
|
308
383
|
const entry = await flair.soul.get(key);
|
|
309
384
|
if (!entry)
|
|
@@ -331,6 +406,7 @@ export async function runMcp() {
|
|
|
331
406
|
phase: z.string().optional().describe("Current phase (e.g. design, implement, review)"),
|
|
332
407
|
summary: z.string().optional().describe("Short summary of current workspace state"),
|
|
333
408
|
}, async ({ ref, label, provider, task, phase, summary }) => {
|
|
409
|
+
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
334
410
|
try {
|
|
335
411
|
// No agentId in body — the server attributes from the signed identity.
|
|
336
412
|
const body = {
|
|
@@ -361,6 +437,7 @@ export async function runMcp() {
|
|
|
361
437
|
scope: z.string().optional().describe("Scope of the event (e.g. an agent id, repo, or 'org')"),
|
|
362
438
|
targets: z.array(z.string()).optional().describe("Recipient agent ids"),
|
|
363
439
|
}, async ({ kind, summary, detail, scope, targets }) => {
|
|
440
|
+
heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
|
|
364
441
|
try {
|
|
365
442
|
// No authorId in body — the server attributes from the signed identity.
|
|
366
443
|
const body = { kind, summary };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-presence — shared, pure/testable logic behind flair#598.
|
|
3
|
+
*
|
|
4
|
+
* Presence (`POST /Presence`, resources/Presence.ts) only ever updated via the
|
|
5
|
+
* manual `flair presence set` CLI command, which in practice nobody runs — so
|
|
6
|
+
* the roster is permanently stale and the active/idle/offline derivation it
|
|
7
|
+
* powers (Office Space collision detection) never means anything. This module
|
|
8
|
+
* makes presence a SIDE EFFECT of normal agent activity instead: every flair-mcp
|
|
9
|
+
* tool call (and the flair-session-start hook) can call `postPresenceSafe()`
|
|
10
|
+
* to refresh the calling agent's own `lastHeartbeatAt`, rate-limited so it
|
|
11
|
+
* doesn't turn into a write storm.
|
|
12
|
+
*
|
|
13
|
+
* Everything here is a pure function or a single fire-and-forget async
|
|
14
|
+
* wrapper — no module-level mutable state lives in this file. The rate-limit
|
|
15
|
+
* clock (`lastSentAt`) and the "last known task" text are owned by each
|
|
16
|
+
* CALLER (flair-mcp/src/index.ts's runMcp() closure, session-start-hook.ts's
|
|
17
|
+
* per-invocation scope) — this file only decides "is it time yet" and "what
|
|
18
|
+
* does the POST body look like", so both call sites share ONE implementation
|
|
19
|
+
* of those decisions without sharing any actual state.
|
|
20
|
+
*/
|
|
21
|
+
/** Mirrors VALID_ACTIVITIES in resources/Presence.ts — keep in sync. */
|
|
22
|
+
export type PresenceActivity = "coding" | "reviewing" | "planning" | "idle";
|
|
23
|
+
/**
|
|
24
|
+
* Derive a presence `activity` from the SAME context signals the `bootstrap`
|
|
25
|
+
* tool and the SessionStart hook already receive (channel/surface) — no new
|
|
26
|
+
* inputs, no separate classifier call. Two narrow, name-based overrides for
|
|
27
|
+
* the surfaces that are unambiguous on their own; everything else (including
|
|
28
|
+
* no surface at all, e.g. the session-start hook) falls through to "coding",
|
|
29
|
+
* since an MCP tool call is, definitionally, an agent doing something — never
|
|
30
|
+
* "idle" (idle/offline are purely functions of elapsed time since the last
|
|
31
|
+
* heartbeat; see derivePresenceStatus() in resources/Presence.ts).
|
|
32
|
+
*/
|
|
33
|
+
export declare function deriveActivity(ctx?: {
|
|
34
|
+
surface?: string;
|
|
35
|
+
channel?: string;
|
|
36
|
+
}): PresenceActivity;
|
|
37
|
+
/**
|
|
38
|
+
* Pure "is it time to send another heartbeat" check. The caller owns the
|
|
39
|
+
* clock (`lastSentAt`) — this function has no memory of its own, which is
|
|
40
|
+
* what makes it trivially unit-testable (no fake timers, no module reset
|
|
41
|
+
* between tests).
|
|
42
|
+
*/
|
|
43
|
+
export declare function shouldSendHeartbeat(now: number, lastSentAt: number | null, minIntervalMs: number): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Minimum gap between auto-presence POSTs triggered by MCP tool activity.
|
|
46
|
+
*
|
|
47
|
+
* 3 minutes: the issue's proposal asks for "once per few minutes" (2-5min).
|
|
48
|
+
* 3min sits in the middle of that range —
|
|
49
|
+
* - frequent enough that an actively-used agent survives at least one
|
|
50
|
+
* missed beat before crossing the 10-minute OFFLINE threshold
|
|
51
|
+
* (offlineThresholdMs() in resources/Presence.ts, default 600_000ms) —
|
|
52
|
+
* a dropped/delayed write doesn't flip the roster to offline;
|
|
53
|
+
* - infrequent enough that a hot loop (e.g. memory_search called several
|
|
54
|
+
* times a minute while an agent works through a task) doesn't turn every
|
|
55
|
+
* tool call into a presence write — this is the rate limiter's whole job.
|
|
56
|
+
* Not tuned to the 90s ACTIVE threshold (idleThresholdMs(), default 90_000ms)
|
|
57
|
+
* — matching that would mean heartbeating roughly every other tool call,
|
|
58
|
+
* defeating the point of rate limiting. An agent that's genuinely active but
|
|
59
|
+
* calls flair-mcp tools less than once every ~3min will read as "idle" (not
|
|
60
|
+
* "offline") between heartbeats, which is the correct signal: idle already
|
|
61
|
+
* means "present but no recent activity", exactly this case.
|
|
62
|
+
*/
|
|
63
|
+
export declare const DEFAULT_HEARTBEAT_INTERVAL_MS: number;
|
|
64
|
+
/** Resolve the heartbeat rate-limit interval from env, clamped to a sane range
|
|
65
|
+
* (same validate-don't-trust-`??` pattern as FLAIR_MCP_PARENT_POLL_MS /
|
|
66
|
+
* FLAIR_HOOK_TIMEOUT_MS elsewhere in this package — an empty-string override
|
|
67
|
+
* must not silently become `0` and defeat the rate limit). */
|
|
68
|
+
export declare function resolveHeartbeatIntervalMs(): number;
|
|
69
|
+
/**
|
|
70
|
+
* Per-write timeout for auto-presence POSTs. Deliberately much shorter than
|
|
71
|
+
* FlairClient's general-purpose default (30s, client.ts DEFAULT_TIMEOUT) —
|
|
72
|
+
* auto-presence is a side effect, never the reason a tool call or a session
|
|
73
|
+
* hook waits. A dead/slow Flair daemon must not hold anything open.
|
|
74
|
+
*/
|
|
75
|
+
export declare const DEFAULT_PRESENCE_TIMEOUT_MS = 3000;
|
|
76
|
+
export declare function resolvePresenceTimeoutMs(): number;
|
|
77
|
+
/**
|
|
78
|
+
* Build the POST /Presence body. Exported so tests can pin the exact shape.
|
|
79
|
+
* Never includes agentId — identity is attributed server-side from the
|
|
80
|
+
* Ed25519 signature on the request (Presence.post(), agent-auth.ts), exactly
|
|
81
|
+
* like flair_workspace_set / flair_orgevent in index.ts. currentTask is
|
|
82
|
+
* omitted (not sent as null/empty) when absent, so callers that don't know a
|
|
83
|
+
* task can still heartbeat without explicitly clearing one that was set
|
|
84
|
+
* earlier — see postPresenceSafe()'s doc comment for why that matters.
|
|
85
|
+
*/
|
|
86
|
+
export declare function buildPresenceBody(activity: PresenceActivity, currentTask?: string): Record<string, unknown>;
|
|
87
|
+
/** Minimal surface a presence write needs from a Flair client — matches
|
|
88
|
+
* FlairClient.request()'s shape exactly, so the real client satisfies this
|
|
89
|
+
* with zero adapter code, and tests can inject a stub without a live server
|
|
90
|
+
* or a real FlairClient instance. */
|
|
91
|
+
export interface PresencePoster {
|
|
92
|
+
request<T = unknown>(method: string, path: string, body?: unknown): Promise<T>;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* POST /Presence and NEVER throw or reject — every failure mode (timeout,
|
|
96
|
+
* network error, 401/403/500 from the server, a malformed response) is
|
|
97
|
+
* caught and swallowed here, in this one place, so every call site gets the
|
|
98
|
+
* guarantee for free instead of re-implementing it. Not awaiting the
|
|
99
|
+
* returned promise is what makes a call site "fire and forget"; the promise
|
|
100
|
+
* itself resolving-never-rejecting is what makes doing that safe (no
|
|
101
|
+
* unhandled rejection even if a caller forgets the `.catch()` belt-and-
|
|
102
|
+
* suspenders).
|
|
103
|
+
*
|
|
104
|
+
* currentTask is intentionally the caller's responsibility, not derived here:
|
|
105
|
+
* Presence.post() (resources/Presence.ts) treats an ABSENT currentTask as an
|
|
106
|
+
* explicit clear — `sanitizeCurrentTask(undefined)` returns `null`, which then
|
|
107
|
+
* overwrites any previously-set task on every merge. That's correct for the
|
|
108
|
+
* CLI (`flair presence set --activity X` with no `--task` IS an explicit "no
|
|
109
|
+
* task" statement) but wrong for an automatic heartbeat, which should never
|
|
110
|
+
* silently erase a task a human or bootstrap() set minutes earlier. Callers
|
|
111
|
+
* that want tasks preserved across heartbeats must track and re-pass the last
|
|
112
|
+
* known task themselves (see index.ts's `lastKnownTask` / session-start-hook's
|
|
113
|
+
* per-invocation scope) — this function just forwards whatever it's given.
|
|
114
|
+
*/
|
|
115
|
+
export declare function postPresenceSafe(poster: PresencePoster, activity: PresenceActivity, currentTask: string | undefined, timeoutMs: number): Promise<void>;
|
package/dist/presence.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-presence — shared, pure/testable logic behind flair#598.
|
|
3
|
+
*
|
|
4
|
+
* Presence (`POST /Presence`, resources/Presence.ts) only ever updated via the
|
|
5
|
+
* manual `flair presence set` CLI command, which in practice nobody runs — so
|
|
6
|
+
* the roster is permanently stale and the active/idle/offline derivation it
|
|
7
|
+
* powers (Office Space collision detection) never means anything. This module
|
|
8
|
+
* makes presence a SIDE EFFECT of normal agent activity instead: every flair-mcp
|
|
9
|
+
* tool call (and the flair-session-start hook) can call `postPresenceSafe()`
|
|
10
|
+
* to refresh the calling agent's own `lastHeartbeatAt`, rate-limited so it
|
|
11
|
+
* doesn't turn into a write storm.
|
|
12
|
+
*
|
|
13
|
+
* Everything here is a pure function or a single fire-and-forget async
|
|
14
|
+
* wrapper — no module-level mutable state lives in this file. The rate-limit
|
|
15
|
+
* clock (`lastSentAt`) and the "last known task" text are owned by each
|
|
16
|
+
* CALLER (flair-mcp/src/index.ts's runMcp() closure, session-start-hook.ts's
|
|
17
|
+
* per-invocation scope) — this file only decides "is it time yet" and "what
|
|
18
|
+
* does the POST body look like", so both call sites share ONE implementation
|
|
19
|
+
* of those decisions without sharing any actual state.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Derive a presence `activity` from the SAME context signals the `bootstrap`
|
|
23
|
+
* tool and the SessionStart hook already receive (channel/surface) — no new
|
|
24
|
+
* inputs, no separate classifier call. Two narrow, name-based overrides for
|
|
25
|
+
* the surfaces that are unambiguous on their own; everything else (including
|
|
26
|
+
* no surface at all, e.g. the session-start hook) falls through to "coding",
|
|
27
|
+
* since an MCP tool call is, definitionally, an agent doing something — never
|
|
28
|
+
* "idle" (idle/offline are purely functions of elapsed time since the last
|
|
29
|
+
* heartbeat; see derivePresenceStatus() in resources/Presence.ts).
|
|
30
|
+
*/
|
|
31
|
+
export function deriveActivity(ctx = {}) {
|
|
32
|
+
const surface = (ctx.surface ?? "").toLowerCase();
|
|
33
|
+
if (surface.includes("review"))
|
|
34
|
+
return "reviewing";
|
|
35
|
+
if (surface.includes("plan") || surface.includes("spec") || surface.includes("design"))
|
|
36
|
+
return "planning";
|
|
37
|
+
return "coding";
|
|
38
|
+
}
|
|
39
|
+
// ─── Rate limiting ──────────────────────────────────────────────────────────
|
|
40
|
+
/**
|
|
41
|
+
* Pure "is it time to send another heartbeat" check. The caller owns the
|
|
42
|
+
* clock (`lastSentAt`) — this function has no memory of its own, which is
|
|
43
|
+
* what makes it trivially unit-testable (no fake timers, no module reset
|
|
44
|
+
* between tests).
|
|
45
|
+
*/
|
|
46
|
+
export function shouldSendHeartbeat(now, lastSentAt, minIntervalMs) {
|
|
47
|
+
if (lastSentAt == null)
|
|
48
|
+
return true;
|
|
49
|
+
return now - lastSentAt >= minIntervalMs;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Minimum gap between auto-presence POSTs triggered by MCP tool activity.
|
|
53
|
+
*
|
|
54
|
+
* 3 minutes: the issue's proposal asks for "once per few minutes" (2-5min).
|
|
55
|
+
* 3min sits in the middle of that range —
|
|
56
|
+
* - frequent enough that an actively-used agent survives at least one
|
|
57
|
+
* missed beat before crossing the 10-minute OFFLINE threshold
|
|
58
|
+
* (offlineThresholdMs() in resources/Presence.ts, default 600_000ms) —
|
|
59
|
+
* a dropped/delayed write doesn't flip the roster to offline;
|
|
60
|
+
* - infrequent enough that a hot loop (e.g. memory_search called several
|
|
61
|
+
* times a minute while an agent works through a task) doesn't turn every
|
|
62
|
+
* tool call into a presence write — this is the rate limiter's whole job.
|
|
63
|
+
* Not tuned to the 90s ACTIVE threshold (idleThresholdMs(), default 90_000ms)
|
|
64
|
+
* — matching that would mean heartbeating roughly every other tool call,
|
|
65
|
+
* defeating the point of rate limiting. An agent that's genuinely active but
|
|
66
|
+
* calls flair-mcp tools less than once every ~3min will read as "idle" (not
|
|
67
|
+
* "offline") between heartbeats, which is the correct signal: idle already
|
|
68
|
+
* means "present but no recent activity", exactly this case.
|
|
69
|
+
*/
|
|
70
|
+
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 3 * 60 * 1000;
|
|
71
|
+
const HEARTBEAT_INTERVAL_FLOOR_MS = 30_000;
|
|
72
|
+
const HEARTBEAT_INTERVAL_CEILING_MS = 15 * 60 * 1000;
|
|
73
|
+
/** Resolve the heartbeat rate-limit interval from env, clamped to a sane range
|
|
74
|
+
* (same validate-don't-trust-`??` pattern as FLAIR_MCP_PARENT_POLL_MS /
|
|
75
|
+
* FLAIR_HOOK_TIMEOUT_MS elsewhere in this package — an empty-string override
|
|
76
|
+
* must not silently become `0` and defeat the rate limit). */
|
|
77
|
+
export function resolveHeartbeatIntervalMs() {
|
|
78
|
+
const raw = process.env.FLAIR_PRESENCE_HEARTBEAT_MS;
|
|
79
|
+
const parsed = raw != null ? Number(raw) : NaN;
|
|
80
|
+
return Number.isFinite(parsed) && parsed >= HEARTBEAT_INTERVAL_FLOOR_MS && parsed <= HEARTBEAT_INTERVAL_CEILING_MS
|
|
81
|
+
? parsed
|
|
82
|
+
: DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
83
|
+
}
|
|
84
|
+
// ─── Timeout ────────────────────────────────────────────────────────────────
|
|
85
|
+
/**
|
|
86
|
+
* Per-write timeout for auto-presence POSTs. Deliberately much shorter than
|
|
87
|
+
* FlairClient's general-purpose default (30s, client.ts DEFAULT_TIMEOUT) —
|
|
88
|
+
* auto-presence is a side effect, never the reason a tool call or a session
|
|
89
|
+
* hook waits. A dead/slow Flair daemon must not hold anything open.
|
|
90
|
+
*/
|
|
91
|
+
export const DEFAULT_PRESENCE_TIMEOUT_MS = 3_000;
|
|
92
|
+
const PRESENCE_TIMEOUT_FLOOR_MS = 500;
|
|
93
|
+
const PRESENCE_TIMEOUT_CEILING_MS = 10_000;
|
|
94
|
+
export function resolvePresenceTimeoutMs() {
|
|
95
|
+
const raw = process.env.FLAIR_PRESENCE_TIMEOUT_MS;
|
|
96
|
+
const parsed = raw != null ? Number(raw) : NaN;
|
|
97
|
+
return Number.isFinite(parsed) && parsed >= PRESENCE_TIMEOUT_FLOOR_MS && parsed <= PRESENCE_TIMEOUT_CEILING_MS
|
|
98
|
+
? parsed
|
|
99
|
+
: DEFAULT_PRESENCE_TIMEOUT_MS;
|
|
100
|
+
}
|
|
101
|
+
/** Race a promise against a timeout. Rejects with a timeout error if exceeded.
|
|
102
|
+
* (Same idiom as session-start-hook.ts's withTimeout — duplicated rather than
|
|
103
|
+
* imported to keep this module dependency-free of that one; it's four lines.) */
|
|
104
|
+
function withTimeout(promise, ms) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const timer = setTimeout(() => reject(new Error("presence_post_timeout")), ms);
|
|
107
|
+
timer.unref?.();
|
|
108
|
+
promise.then((value) => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
resolve(value);
|
|
111
|
+
}, (err) => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
reject(err);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
// ─── POST body ──────────────────────────────────────────────────────────────
|
|
118
|
+
/**
|
|
119
|
+
* Build the POST /Presence body. Exported so tests can pin the exact shape.
|
|
120
|
+
* Never includes agentId — identity is attributed server-side from the
|
|
121
|
+
* Ed25519 signature on the request (Presence.post(), agent-auth.ts), exactly
|
|
122
|
+
* like flair_workspace_set / flair_orgevent in index.ts. currentTask is
|
|
123
|
+
* omitted (not sent as null/empty) when absent, so callers that don't know a
|
|
124
|
+
* task can still heartbeat without explicitly clearing one that was set
|
|
125
|
+
* earlier — see postPresenceSafe()'s doc comment for why that matters.
|
|
126
|
+
*/
|
|
127
|
+
export function buildPresenceBody(activity, currentTask) {
|
|
128
|
+
const body = { activity };
|
|
129
|
+
if (currentTask)
|
|
130
|
+
body.currentTask = currentTask;
|
|
131
|
+
return body;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* POST /Presence and NEVER throw or reject — every failure mode (timeout,
|
|
135
|
+
* network error, 401/403/500 from the server, a malformed response) is
|
|
136
|
+
* caught and swallowed here, in this one place, so every call site gets the
|
|
137
|
+
* guarantee for free instead of re-implementing it. Not awaiting the
|
|
138
|
+
* returned promise is what makes a call site "fire and forget"; the promise
|
|
139
|
+
* itself resolving-never-rejecting is what makes doing that safe (no
|
|
140
|
+
* unhandled rejection even if a caller forgets the `.catch()` belt-and-
|
|
141
|
+
* suspenders).
|
|
142
|
+
*
|
|
143
|
+
* currentTask is intentionally the caller's responsibility, not derived here:
|
|
144
|
+
* Presence.post() (resources/Presence.ts) treats an ABSENT currentTask as an
|
|
145
|
+
* explicit clear — `sanitizeCurrentTask(undefined)` returns `null`, which then
|
|
146
|
+
* overwrites any previously-set task on every merge. That's correct for the
|
|
147
|
+
* CLI (`flair presence set --activity X` with no `--task` IS an explicit "no
|
|
148
|
+
* task" statement) but wrong for an automatic heartbeat, which should never
|
|
149
|
+
* silently erase a task a human or bootstrap() set minutes earlier. Callers
|
|
150
|
+
* that want tasks preserved across heartbeats must track and re-pass the last
|
|
151
|
+
* known task themselves (see index.ts's `lastKnownTask` / session-start-hook's
|
|
152
|
+
* per-invocation scope) — this function just forwards whatever it's given.
|
|
153
|
+
*/
|
|
154
|
+
export async function postPresenceSafe(poster, activity, currentTask, timeoutMs) {
|
|
155
|
+
try {
|
|
156
|
+
await withTimeout(poster.request("POST", "/Presence", buildPresenceBody(activity, currentTask)), timeoutMs);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Silent by design — see doc comment above. A flair instance that's down
|
|
160
|
+
// or slow must never slow down or break the caller.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -26,12 +26,23 @@
|
|
|
26
26
|
* A hard timeout (FLAIR_HOOK_TIMEOUT_MS, default 8s) wraps the bootstrap call
|
|
27
27
|
* so a stalled Flair daemon can't hang session startup; on timeout we no-op.
|
|
28
28
|
*
|
|
29
|
+
* AUTO-PRESENCE (flair#598)
|
|
30
|
+
* -------------------------
|
|
31
|
+
* A session starting is the clearest "this agent is alive" signal flair-mcp
|
|
32
|
+
* gets, so this hook also fires a best-effort `POST /Presence` heartbeat
|
|
33
|
+
* alongside bootstrap — see ./presence.ts. It runs CONCURRENTLY with the
|
|
34
|
+
* bootstrap call (not serially after it), reuses the SAME signed request
|
|
35
|
+
* path (Ed25519, no new auth mechanism), and is bounded by its own short
|
|
36
|
+
* timeout so it can never add meaningful latency or turn a working bootstrap
|
|
37
|
+
* into a no-op. Manual `flair presence set` keeps working unchanged.
|
|
38
|
+
*
|
|
29
39
|
* CONFIG (env, read identically to the MCP server)
|
|
30
40
|
* ------------------------------------------------
|
|
31
41
|
* FLAIR_AGENT_ID (required — absent → no-op) agent identity
|
|
32
42
|
* FLAIR_URL (default http://localhost:19926 via flair-client)
|
|
33
43
|
* FLAIR_KEY_PATH (default ~/.flair/keys/<agent>.key via flair-client)
|
|
34
44
|
* FLAIR_HOOK_TIMEOUT_MS (default 8000; clamped 500..30000)
|
|
45
|
+
* FLAIR_PRESENCE_TIMEOUT_MS (default 3000; clamped 500..10000 — see ./presence.ts)
|
|
35
46
|
*
|
|
36
47
|
* USAGE — register in ~/.claude/settings.json:
|
|
37
48
|
* {
|
|
@@ -43,8 +54,14 @@
|
|
|
43
54
|
* }
|
|
44
55
|
* }
|
|
45
56
|
*/
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
import { type PresencePoster } from "./presence.js";
|
|
58
|
+
/** Minimal surface of FlairClient this hook depends on (eases testing).
|
|
59
|
+
* `request` is optional and structurally matches PresencePoster (presence.ts)
|
|
60
|
+
* — the real FlairClient always has it. When present, this hook also fires a
|
|
61
|
+
* best-effort presence heartbeat (flair#598) alongside bootstrap; when
|
|
62
|
+
* absent (e.g. a lightweight test stub that only implements bootstrap()),
|
|
63
|
+
* the heartbeat is silently skipped — no behavior change for those tests. */
|
|
64
|
+
interface BootstrapClient extends Partial<PresencePoster> {
|
|
48
65
|
bootstrap(opts: {
|
|
49
66
|
maxTokens?: number;
|
|
50
67
|
channel?: string;
|
|
@@ -26,12 +26,23 @@
|
|
|
26
26
|
* A hard timeout (FLAIR_HOOK_TIMEOUT_MS, default 8s) wraps the bootstrap call
|
|
27
27
|
* so a stalled Flair daemon can't hang session startup; on timeout we no-op.
|
|
28
28
|
*
|
|
29
|
+
* AUTO-PRESENCE (flair#598)
|
|
30
|
+
* -------------------------
|
|
31
|
+
* A session starting is the clearest "this agent is alive" signal flair-mcp
|
|
32
|
+
* gets, so this hook also fires a best-effort `POST /Presence` heartbeat
|
|
33
|
+
* alongside bootstrap — see ./presence.ts. It runs CONCURRENTLY with the
|
|
34
|
+
* bootstrap call (not serially after it), reuses the SAME signed request
|
|
35
|
+
* path (Ed25519, no new auth mechanism), and is bounded by its own short
|
|
36
|
+
* timeout so it can never add meaningful latency or turn a working bootstrap
|
|
37
|
+
* into a no-op. Manual `flair presence set` keeps working unchanged.
|
|
38
|
+
*
|
|
29
39
|
* CONFIG (env, read identically to the MCP server)
|
|
30
40
|
* ------------------------------------------------
|
|
31
41
|
* FLAIR_AGENT_ID (required — absent → no-op) agent identity
|
|
32
42
|
* FLAIR_URL (default http://localhost:19926 via flair-client)
|
|
33
43
|
* FLAIR_KEY_PATH (default ~/.flair/keys/<agent>.key via flair-client)
|
|
34
44
|
* FLAIR_HOOK_TIMEOUT_MS (default 8000; clamped 500..30000)
|
|
45
|
+
* FLAIR_PRESENCE_TIMEOUT_MS (default 3000; clamped 500..10000 — see ./presence.ts)
|
|
35
46
|
*
|
|
36
47
|
* USAGE — register in ~/.claude/settings.json:
|
|
37
48
|
* {
|
|
@@ -45,6 +56,7 @@
|
|
|
45
56
|
*/
|
|
46
57
|
import { FlairClient } from "@tpsdev-ai/flair-client";
|
|
47
58
|
import { basename } from "node:path";
|
|
59
|
+
import { deriveActivity, postPresenceSafe, resolvePresenceTimeoutMs } from "./presence.js";
|
|
48
60
|
/** Claude Code SessionStart additionalContext hard limit (chars). */
|
|
49
61
|
const MAX_CHARS = 10_000;
|
|
50
62
|
/** Token budget for the bootstrap call — matches the proven prototype. */
|
|
@@ -120,9 +132,23 @@ export async function runHook(rawInput, makeClient = defaultClientFactory) {
|
|
|
120
132
|
}
|
|
121
133
|
const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
122
134
|
const project = basename(cwd) || undefined;
|
|
135
|
+
const client = makeClient(agentId);
|
|
136
|
+
// Auto-presence (flair#598): a session starting is the clearest "this
|
|
137
|
+
// agent is alive" signal available. Fired CONCURRENTLY with the bootstrap
|
|
138
|
+
// call below (not serially after it) and bounded by its own short timeout
|
|
139
|
+
// (resolvePresenceTimeoutMs(), default 3s) — postPresenceSafe() never
|
|
140
|
+
// throws (see presence.ts's fail-open contract), so this can never turn a
|
|
141
|
+
// working bootstrap into a no-op, and awaiting `presenceDone` below can
|
|
142
|
+
// never add meaningful latency beyond what bootstrap already budgets
|
|
143
|
+
// (resolveTimeoutMs(), default 8s). No currentTask here: the SessionStart
|
|
144
|
+
// payload doesn't carry a task description (unlike the MCP `bootstrap`
|
|
145
|
+
// tool call — see index.ts), so this only ever sets `activity`, leaving
|
|
146
|
+
// currentTask exactly whatever it already was.
|
|
147
|
+
const presenceDone = typeof client.request === "function"
|
|
148
|
+
? postPresenceSafe(client, deriveActivity({ channel: "claude-code" }), undefined, resolvePresenceTimeoutMs())
|
|
149
|
+
: Promise.resolve();
|
|
123
150
|
let context = "";
|
|
124
151
|
try {
|
|
125
|
-
const client = makeClient(agentId);
|
|
126
152
|
const res = await withTimeout(Promise.resolve(client.bootstrap({
|
|
127
153
|
maxTokens: BOOTSTRAP_MAX_TOKENS,
|
|
128
154
|
channel: "claude-code",
|
|
@@ -131,8 +157,10 @@ export async function runHook(rawInput, makeClient = defaultClientFactory) {
|
|
|
131
157
|
context = res && res.context ? String(res.context) : "";
|
|
132
158
|
}
|
|
133
159
|
catch {
|
|
160
|
+
await presenceDone;
|
|
134
161
|
return NOOP_OUTPUT; // flair unreachable / auth error / timeout → no-op
|
|
135
162
|
}
|
|
163
|
+
await presenceDone;
|
|
136
164
|
if (!context.trim())
|
|
137
165
|
return NOOP_OUTPUT;
|
|
138
166
|
if (context.length > MAX_CHARS)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "MCP server for Flair — persistent memory for Claude Code, Cursor, and any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@modelcontextprotocol/sdk": "1.27.1",
|
|
30
|
-
"@tpsdev-ai/flair-client": "0.
|
|
30
|
+
"@tpsdev-ai/flair-client": "0.21.0",
|
|
31
31
|
"zod": "4.3.6"
|
|
32
32
|
},
|
|
33
33
|
"license": "Apache-2.0",
|