@tpsdev-ai/flair-mcp 0.20.1 → 0.22.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 CHANGED
@@ -8,12 +8,20 @@
8
8
  * - memory_update — update an existing memory by ID (dedup-bypassed)
9
9
  * - memory_get — retrieve a specific memory by ID
10
10
  * - memory_delete — delete a memory
11
+ * - relationship_store — assert a subject/predicate/object relationship triple
11
12
  * - bootstrap — cold-start context (soul + recent memories)
12
13
  * - soul_set — set a personality/context entry
13
14
  * - soul_get — get a personality/context entry
14
15
  * - flair_workspace_set — write own WorkspaceState (Office Space coordination)
15
16
  * - flair_orgevent — publish an OrgEvent attributed to self (no forging)
16
17
  *
18
+ * Auto-presence (flair#598): every tool call above triggers a fire-and-forget,
19
+ * rate-limited `POST /Presence` heartbeat for the calling agent (see
20
+ * ./presence.ts + the `heartbeat()` closure in runMcp()) — presence is now a
21
+ * side effect of normal activity instead of the manual `flair presence set`
22
+ * CLI command nobody ran. `bootstrap` additionally seeds the activity/task
23
+ * from its own arguments (session start signal).
24
+ *
17
25
  * Usage:
18
26
  * npx -y @tpsdev-ai/flair-mcp
19
27
  *
package/dist/index.js CHANGED
@@ -8,12 +8,20 @@
8
8
  * - memory_update — update an existing memory by ID (dedup-bypassed)
9
9
  * - memory_get — retrieve a specific memory by ID
10
10
  * - memory_delete — delete a memory
11
+ * - relationship_store — assert a subject/predicate/object relationship triple
11
12
  * - bootstrap — cold-start context (soul + recent memories)
12
13
  * - soul_set — set a personality/context entry
13
14
  * - soul_get — get a personality/context entry
14
15
  * - flair_workspace_set — write own WorkspaceState (Office Space coordination)
15
16
  * - flair_orgevent — publish an OrgEvent attributed to self (no forging)
16
17
  *
18
+ * Auto-presence (flair#598): every tool call above triggers a fire-and-forget,
19
+ * rate-limited `POST /Presence` heartbeat for the calling agent (see
20
+ * ./presence.ts + the `heartbeat()` closure in runMcp()) — presence is now a
21
+ * side effect of normal activity instead of the manual `flair presence set`
22
+ * CLI command nobody ran. `bootstrap` additionally seeds the activity/task
23
+ * from its own arguments (session start signal).
24
+ *
17
25
  * Usage:
18
26
  * npx -y @tpsdev-ai/flair-mcp
19
27
  *
@@ -34,6 +42,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
34
42
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
35
43
  import { FlairClient, FlairError } from "@tpsdev-ai/flair-client";
36
44
  import { z } from "zod";
45
+ import { deriveActivity, postPresenceSafe, resolveHeartbeatIntervalMs, resolvePresenceTimeoutMs, shouldSendHeartbeat, } from "./presence.js";
37
46
  // ─── Error helpers ──────────────────────────────────────────────────────────
38
47
  function classifyError(err, flairUrl) {
39
48
  if (err instanceof FlairError) {
@@ -138,6 +147,56 @@ export async function runMcp() {
138
147
  url: process.env.FLAIR_URL,
139
148
  keyPath: process.env.FLAIR_KEY_PATH,
140
149
  });
150
+ // ─── Auto-presence (flair#598) ────────────────────────────────────────────────
151
+ //
152
+ // Presence used to be manual-only (`flair presence set`, which in practice
153
+ // nobody runs) — so the roster was permanently stale and the collision-
154
+ // detection it exists for never worked. This makes presence a SIDE EFFECT of
155
+ // normal agent activity instead: `bootstrap` (session start) always attempts
156
+ // a heartbeat, and every other tool call attempts one too, both routed
157
+ // through the SAME rate-limited, fire-and-forget `heartbeat()` below so
158
+ // repeated bootstrap calls in a short window don't spam any more than
159
+ // repeated tool calls would.
160
+ //
161
+ // A SEPARATE FlairClient instance, not the main `flair` above — its own
162
+ // short `timeoutMs` (resolvePresenceTimeoutMs(), default 3s vs the main
163
+ // client's general-purpose 30s default) means a dead/slow daemon can never
164
+ // make a presence write outlive the tool call it rode in on, independent of
165
+ // whatever timeout the main client is configured with.
166
+ const presenceFlair = new FlairClient({
167
+ agentId,
168
+ url: process.env.FLAIR_URL,
169
+ keyPath: process.env.FLAIR_KEY_PATH,
170
+ timeoutMs: resolvePresenceTimeoutMs(),
171
+ });
172
+ // Rate-limit clock + last-known task, in-process only (no persistence —
173
+ // resets on restart, which is fine; the next tool call re-establishes it).
174
+ // Owned here, not in presence.ts, so the pure rate-limit/body-building logic
175
+ // stays testable without any shared mutable state.
176
+ let lastPresenceSentAt = null;
177
+ // currentTask is the caller's responsibility to preserve (see
178
+ // postPresenceSafe's doc comment): Presence.post() treats an ABSENT
179
+ // currentTask as an explicit clear, so a heartbeat that doesn't know about
180
+ // an in-flight task must keep resending the last one bootstrap() told us
181
+ // about, or it would silently erase it every ~3 minutes.
182
+ let lastKnownTask;
183
+ /**
184
+ * Fire-and-forget, rate-limited presence heartbeat. Safe to call from every
185
+ * tool handler unconditionally — the rate limit + postPresenceSafe's
186
+ * internal catch-everything mean this NEVER throws, NEVER awaits network
187
+ * I/O in the caller's path, and NEVER delays the tool response. The
188
+ * `.catch(() => {})` below is belt-and-suspenders (postPresenceSafe already
189
+ * never rejects) purely so a future change to that function can't
190
+ * accidentally reintroduce an unhandled rejection here.
191
+ */
192
+ function heartbeat(activity = "coding") {
193
+ const now = Date.now();
194
+ if (!shouldSendHeartbeat(now, lastPresenceSentAt, resolveHeartbeatIntervalMs()))
195
+ return;
196
+ lastPresenceSentAt = now; // set synchronously, before the async write, so a burst of
197
+ // tool calls in the same tick can't all slip past the rate limit together
198
+ postPresenceSafe(presenceFlair, activity, lastKnownTask, resolvePresenceTimeoutMs()).catch(() => { });
199
+ }
141
200
  // ─── MCP Server ──────────────────────────────────────────────────────────────
142
201
  const server = new McpServer({
143
202
  name: "flair",
@@ -148,6 +207,7 @@ export async function runMcp() {
148
207
  query: z.string().describe("Search query — natural language, semantic matching"),
149
208
  limit: z.coerce.number().optional().default(5).describe("Max results (default 5)"),
150
209
  }, async ({ query, limit }) => {
210
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
151
211
  try {
152
212
  const results = await flair.memory.search(query, { limit });
153
213
  if (results.length === 0) {
@@ -181,6 +241,7 @@ export async function runMcp() {
181
241
  "private -- never visible to another agent, even one with a memory grant. " +
182
242
  "shared -- visible to the owner and any agent holding a read/search grant."),
183
243
  }, async ({ content, type, durability, tags, visibility }) => {
244
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
184
245
  try {
185
246
  const result = await flair.memory.write(content, {
186
247
  type: type,
@@ -233,6 +294,7 @@ export async function runMcp() {
233
294
  preserveHistory: z.coerce.boolean().optional().default(false)
234
295
  .describe("Write a new supersedes-linked version instead of overwriting in place (default false)"),
235
296
  }, async ({ id, content, preserveHistory }) => {
297
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
236
298
  try {
237
299
  const result = await flair.memory.update(id, content, { preserveHistory });
238
300
  const text = preserveHistory
@@ -250,6 +312,7 @@ export async function runMcp() {
250
312
  server.tool("memory_get", "Retrieve a specific memory by ID.", {
251
313
  id: z.string().describe("Memory ID"),
252
314
  }, async ({ id }) => {
315
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
253
316
  try {
254
317
  const mem = await flair.memory.get(id);
255
318
  if (!mem)
@@ -263,6 +326,7 @@ export async function runMcp() {
263
326
  server.tool("memory_delete", "Delete a memory by ID.", {
264
327
  id: z.string().describe("Memory ID to delete"),
265
328
  }, async ({ id }) => {
329
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
266
330
  try {
267
331
  await flair.memory.delete(id);
268
332
  return { content: [{ type: "text", text: `Memory ${id} deleted.` }] };
@@ -271,6 +335,41 @@ export async function runMcp() {
271
335
  return errorResult(err, flair.url);
272
336
  }
273
337
  });
338
+ server.tool("relationship_store", "Record that <subject> <predicate> <object> — an explicit entity-to-entity relationship triple " +
339
+ "(e.g. 'nathan manages flair', 'flint reviews cli'), distinct from a free-text memory. " +
340
+ "ASSERT/UPSERT semantics: writing the SAME triple again (same subject/predicate/object) updates the " +
341
+ "existing row in place (confidence/validTo/source refresh) rather than creating a duplicate — safe to " +
342
+ "re-assert. Predicate is free text (no fixed enum) but prefer a small, consistent vocabulary so the graph " +
343
+ "stays queryable: manages, works_on, reviews, depends_on, replaces, owns, reports_to, advises. " +
344
+ "TO CONTRADICT a prior relationship: (a) re-asserting the identical triple just updates it — fine. " +
345
+ "(b) changing validTo on the SAME subject/predicate/object overwrites the old validTo (the graph tracks " +
346
+ "current state, not full history). (c) changing the PREDICATE (e.g. 'nathan manages flair' -> 'nathan " +
347
+ "advises flair') creates a SEPARATE relationship — it does NOT automatically close the old one. Close it " +
348
+ "yourself first: re-assert the OLD triple with a validTo set to now (or call relationship's delete), THEN " +
349
+ "store the new one.", {
350
+ subject: z.string().describe("Source entity — a person, project, or service (e.g. 'nathan')"),
351
+ predicate: z.string().describe("Relationship type, free text. Recommended vocabulary: manages, works_on, reviews, depends_on, " +
352
+ "replaces, owns, reports_to, advises — consistency helps recall, but any short verb phrase works."),
353
+ object: z.string().describe("Target entity — a person, project, or service (e.g. 'flair')"),
354
+ confidence: z.coerce.number().optional().describe("0.0-1.0, how certain (default 1.0 = explicitly stated)"),
355
+ validFrom: z.string().optional().describe("ISO timestamp this relationship became true (default: now)"),
356
+ validTo: z.string().optional().describe("ISO timestamp this relationship ended. Leave unset for an active relationship; set it (via a re-assert " +
357
+ "of this SAME subject/predicate/object) to close out a relationship you're contradicting with a new predicate."),
358
+ source: z.string().optional().describe("Where this was learned from (a memory ID, conversation, etc.)"),
359
+ }, async ({ subject, predicate, object, confidence, validFrom, validTo, source }) => {
360
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
361
+ try {
362
+ const result = await flair.relationship.write({ subject, predicate, object, confidence, validFrom, validTo, source });
363
+ const confStr = confidence !== undefined ? ` (confidence: ${confidence})` : "";
364
+ return {
365
+ content: [{ type: "text", text: `Relationship recorded: ${subject} → ${predicate} → ${object}${confStr} (id: ${result.id})` }],
366
+ structuredContent: { id: result.id, subject, predicate, object, written: true },
367
+ };
368
+ }
369
+ catch (err) {
370
+ return errorResult(err, flair.url);
371
+ }
372
+ });
274
373
  server.tool("bootstrap", "Get session context: soul + memories + predicted context. Run at session start. Pass subjects for predictive loading.", {
275
374
  maxTokens: z.coerce.number().optional().default(4000).describe("Max tokens in output"),
276
375
  currentTask: z.string().optional().describe("Current task description — enables semantic search for relevant memories"),
@@ -278,6 +377,16 @@ export async function runMcp() {
278
377
  surface: z.string().optional().describe("Surface name (tps-build, tps-review, cli-session) — narrows prediction"),
279
378
  subjects: z.array(z.string()).optional().describe("Entity names to preload context for (e.g., ['flair', 'auth'])"),
280
379
  }, async ({ maxTokens, currentTask, channel, surface, subjects }) => {
380
+ // auto-presence (flair#598) — SESSION START. bootstrap() already receives
381
+ // exactly the payload Presence wants: currentTask is what the agent is
382
+ // about to work on, channel/surface are what deriveActivity() uses to
383
+ // pick something more specific than the "coding" default. Routed through
384
+ // the SAME rate-limited heartbeat() as every other tool, so calling
385
+ // bootstrap twice in quick succession (session resume/compact) doesn't
386
+ // double-send — it's still "sets presence once" per session in practice.
387
+ if (currentTask)
388
+ lastKnownTask = currentTask;
389
+ heartbeat(deriveActivity({ channel, surface }));
281
390
  try {
282
391
  const result = await flair.bootstrap({ maxTokens, currentTask, channel, surface, subjects });
283
392
  if (!result.context) {
@@ -293,6 +402,7 @@ export async function runMcp() {
293
402
  key: z.string().describe("Entry key (e.g., 'role', 'standards', 'project')"),
294
403
  value: z.string().describe("Entry value — personality trait, project context, coding standards, etc."),
295
404
  }, async ({ key, value }) => {
405
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
296
406
  try {
297
407
  await flair.soul.set(key, value);
298
408
  return { content: [{ type: "text", text: `Soul entry '${key}' set.` }] };
@@ -304,6 +414,7 @@ export async function runMcp() {
304
414
  server.tool("soul_get", "Get a personality or project context entry.", {
305
415
  key: z.string().describe("Entry key"),
306
416
  }, async ({ key }) => {
417
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
307
418
  try {
308
419
  const entry = await flair.soul.get(key);
309
420
  if (!entry)
@@ -331,6 +442,7 @@ export async function runMcp() {
331
442
  phase: z.string().optional().describe("Current phase (e.g. design, implement, review)"),
332
443
  summary: z.string().optional().describe("Short summary of current workspace state"),
333
444
  }, async ({ ref, label, provider, task, phase, summary }) => {
445
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
334
446
  try {
335
447
  // No agentId in body — the server attributes from the signed identity.
336
448
  const body = {
@@ -361,6 +473,7 @@ export async function runMcp() {
361
473
  scope: z.string().optional().describe("Scope of the event (e.g. an agent id, repo, or 'org')"),
362
474
  targets: z.array(z.string()).optional().describe("Recipient agent ids"),
363
475
  }, async ({ kind, summary, detail, scope, targets }) => {
476
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
364
477
  try {
365
478
  // No authorId in body — the server attributes from the signed identity.
366
479
  const body = { kind, summary };
@@ -0,0 +1,130 @@
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" | "debugging" | "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. Three 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
+ * "debug"/"investigat"/"incident" is checked FIRST, ahead of "review" and
34
+ * "plan" — an active incident investigation (flair#613: the flagship
35
+ * collision-detection use case) is the most unambiguous and highest-signal
36
+ * surface name an agent can report, and previously had no matching bucket at
37
+ * all, silently falling into "coding" (or "reviewing" if the surface also
38
+ * happened to contain that substring — e.g. "incident-review").
39
+ */
40
+ export declare function deriveActivity(ctx?: {
41
+ surface?: string;
42
+ channel?: string;
43
+ }): PresenceActivity;
44
+ /**
45
+ * Pure "is it time to send another heartbeat" check. The caller owns the
46
+ * clock (`lastSentAt`) — this function has no memory of its own, which is
47
+ * what makes it trivially unit-testable (no fake timers, no module reset
48
+ * between tests).
49
+ */
50
+ export declare function shouldSendHeartbeat(now: number, lastSentAt: number | null, minIntervalMs: number): boolean;
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 declare const DEFAULT_HEARTBEAT_INTERVAL_MS: number;
71
+ /** Resolve the heartbeat rate-limit interval from env, clamped to a sane range
72
+ * (same validate-don't-trust-`??` pattern as FLAIR_MCP_PARENT_POLL_MS /
73
+ * FLAIR_HOOK_TIMEOUT_MS elsewhere in this package — an empty-string override
74
+ * must not silently become `0` and defeat the rate limit). */
75
+ export declare function resolveHeartbeatIntervalMs(): number;
76
+ /**
77
+ * Per-write timeout for auto-presence POSTs. Deliberately much shorter than
78
+ * FlairClient's general-purpose default (30s, client.ts DEFAULT_TIMEOUT) —
79
+ * auto-presence is a side effect, never the reason a tool call or a session
80
+ * hook waits. A dead/slow Flair daemon must not hold anything open.
81
+ */
82
+ export declare const DEFAULT_PRESENCE_TIMEOUT_MS = 3000;
83
+ export declare function resolvePresenceTimeoutMs(): number;
84
+ /**
85
+ * Build the POST /Presence body. Exported so tests can pin the exact shape.
86
+ * Never includes agentId — identity is attributed server-side from the
87
+ * Ed25519 signature on the request (Presence.post(), agent-auth.ts), exactly
88
+ * like flair_workspace_set / flair_orgevent in index.ts. currentTask is
89
+ * omitted (not sent as null/empty) when absent, so callers that don't know a
90
+ * task can still heartbeat without explicitly clearing one that was set
91
+ * earlier — see postPresenceSafe()'s doc comment for why that matters.
92
+ *
93
+ * Natural-presence: this body always carries `activity`, and the server stamps
94
+ * `activityUpdatedAt` (resources/Presence.ts, buildPresenceRecord) on any beat
95
+ * that asserts activity/task — so a working agent keeps activity fresh, and one
96
+ * that stops lets it decay to last-known automatically (no manual clear). A
97
+ * future liveness-only beat (this body without `activity`) would refresh
98
+ * lastHeartbeatAt without re-stamping activity — the server treats the two
99
+ * independently.
100
+ */
101
+ export declare function buildPresenceBody(activity: PresenceActivity, currentTask?: string): Record<string, unknown>;
102
+ /** Minimal surface a presence write needs from a Flair client — matches
103
+ * FlairClient.request()'s shape exactly, so the real client satisfies this
104
+ * with zero adapter code, and tests can inject a stub without a live server
105
+ * or a real FlairClient instance. */
106
+ export interface PresencePoster {
107
+ request<T = unknown>(method: string, path: string, body?: unknown): Promise<T>;
108
+ }
109
+ /**
110
+ * POST /Presence and NEVER throw or reject — every failure mode (timeout,
111
+ * network error, 401/403/500 from the server, a malformed response) is
112
+ * caught and swallowed here, in this one place, so every call site gets the
113
+ * guarantee for free instead of re-implementing it. Not awaiting the
114
+ * returned promise is what makes a call site "fire and forget"; the promise
115
+ * itself resolving-never-rejecting is what makes doing that safe (no
116
+ * unhandled rejection even if a caller forgets the `.catch()` belt-and-
117
+ * suspenders).
118
+ *
119
+ * currentTask is intentionally the caller's responsibility, not derived here:
120
+ * Presence.post() (resources/Presence.ts) treats an ABSENT currentTask as an
121
+ * explicit clear — `sanitizeCurrentTask(undefined)` returns `null`, which then
122
+ * overwrites any previously-set task on every merge. That's correct for the
123
+ * CLI (`flair presence set --activity X` with no `--task` IS an explicit "no
124
+ * task" statement) but wrong for an automatic heartbeat, which should never
125
+ * silently erase a task a human or bootstrap() set minutes earlier. Callers
126
+ * that want tasks preserved across heartbeats must track and re-pass the last
127
+ * known task themselves (see index.ts's `lastKnownTask` / session-start-hook's
128
+ * per-invocation scope) — this function just forwards whatever it's given.
129
+ */
130
+ export declare function postPresenceSafe(poster: PresencePoster, activity: PresenceActivity, currentTask: string | undefined, timeoutMs: number): Promise<void>;
@@ -0,0 +1,179 @@
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. Three 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
+ * "debug"/"investigat"/"incident" is checked FIRST, ahead of "review" and
32
+ * "plan" — an active incident investigation (flair#613: the flagship
33
+ * collision-detection use case) is the most unambiguous and highest-signal
34
+ * surface name an agent can report, and previously had no matching bucket at
35
+ * all, silently falling into "coding" (or "reviewing" if the surface also
36
+ * happened to contain that substring — e.g. "incident-review").
37
+ */
38
+ export function deriveActivity(ctx = {}) {
39
+ const surface = (ctx.surface ?? "").toLowerCase();
40
+ if (surface.includes("debug") || surface.includes("investigat") || surface.includes("incident"))
41
+ return "debugging";
42
+ if (surface.includes("review"))
43
+ return "reviewing";
44
+ if (surface.includes("plan") || surface.includes("spec") || surface.includes("design"))
45
+ return "planning";
46
+ return "coding";
47
+ }
48
+ // ─── Rate limiting ──────────────────────────────────────────────────────────
49
+ /**
50
+ * Pure "is it time to send another heartbeat" check. The caller owns the
51
+ * clock (`lastSentAt`) — this function has no memory of its own, which is
52
+ * what makes it trivially unit-testable (no fake timers, no module reset
53
+ * between tests).
54
+ */
55
+ export function shouldSendHeartbeat(now, lastSentAt, minIntervalMs) {
56
+ if (lastSentAt == null)
57
+ return true;
58
+ return now - lastSentAt >= minIntervalMs;
59
+ }
60
+ /**
61
+ * Minimum gap between auto-presence POSTs triggered by MCP tool activity.
62
+ *
63
+ * 3 minutes: the issue's proposal asks for "once per few minutes" (2-5min).
64
+ * 3min sits in the middle of that range —
65
+ * - frequent enough that an actively-used agent survives at least one
66
+ * missed beat before crossing the 10-minute OFFLINE threshold
67
+ * (offlineThresholdMs() in resources/Presence.ts, default 600_000ms) —
68
+ * a dropped/delayed write doesn't flip the roster to offline;
69
+ * - infrequent enough that a hot loop (e.g. memory_search called several
70
+ * times a minute while an agent works through a task) doesn't turn every
71
+ * tool call into a presence write — this is the rate limiter's whole job.
72
+ * Not tuned to the 90s ACTIVE threshold (idleThresholdMs(), default 90_000ms)
73
+ * — matching that would mean heartbeating roughly every other tool call,
74
+ * defeating the point of rate limiting. An agent that's genuinely active but
75
+ * calls flair-mcp tools less than once every ~3min will read as "idle" (not
76
+ * "offline") between heartbeats, which is the correct signal: idle already
77
+ * means "present but no recent activity", exactly this case.
78
+ */
79
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 3 * 60 * 1000;
80
+ const HEARTBEAT_INTERVAL_FLOOR_MS = 30_000;
81
+ const HEARTBEAT_INTERVAL_CEILING_MS = 15 * 60 * 1000;
82
+ /** Resolve the heartbeat rate-limit interval from env, clamped to a sane range
83
+ * (same validate-don't-trust-`??` pattern as FLAIR_MCP_PARENT_POLL_MS /
84
+ * FLAIR_HOOK_TIMEOUT_MS elsewhere in this package — an empty-string override
85
+ * must not silently become `0` and defeat the rate limit). */
86
+ export function resolveHeartbeatIntervalMs() {
87
+ const raw = process.env.FLAIR_PRESENCE_HEARTBEAT_MS;
88
+ const parsed = raw != null ? Number(raw) : NaN;
89
+ return Number.isFinite(parsed) && parsed >= HEARTBEAT_INTERVAL_FLOOR_MS && parsed <= HEARTBEAT_INTERVAL_CEILING_MS
90
+ ? parsed
91
+ : DEFAULT_HEARTBEAT_INTERVAL_MS;
92
+ }
93
+ // ─── Timeout ────────────────────────────────────────────────────────────────
94
+ /**
95
+ * Per-write timeout for auto-presence POSTs. Deliberately much shorter than
96
+ * FlairClient's general-purpose default (30s, client.ts DEFAULT_TIMEOUT) —
97
+ * auto-presence is a side effect, never the reason a tool call or a session
98
+ * hook waits. A dead/slow Flair daemon must not hold anything open.
99
+ */
100
+ export const DEFAULT_PRESENCE_TIMEOUT_MS = 3_000;
101
+ const PRESENCE_TIMEOUT_FLOOR_MS = 500;
102
+ const PRESENCE_TIMEOUT_CEILING_MS = 10_000;
103
+ export function resolvePresenceTimeoutMs() {
104
+ const raw = process.env.FLAIR_PRESENCE_TIMEOUT_MS;
105
+ const parsed = raw != null ? Number(raw) : NaN;
106
+ return Number.isFinite(parsed) && parsed >= PRESENCE_TIMEOUT_FLOOR_MS && parsed <= PRESENCE_TIMEOUT_CEILING_MS
107
+ ? parsed
108
+ : DEFAULT_PRESENCE_TIMEOUT_MS;
109
+ }
110
+ /** Race a promise against a timeout. Rejects with a timeout error if exceeded.
111
+ * (Same idiom as session-start-hook.ts's withTimeout — duplicated rather than
112
+ * imported to keep this module dependency-free of that one; it's four lines.) */
113
+ function withTimeout(promise, ms) {
114
+ return new Promise((resolve, reject) => {
115
+ const timer = setTimeout(() => reject(new Error("presence_post_timeout")), ms);
116
+ timer.unref?.();
117
+ promise.then((value) => {
118
+ clearTimeout(timer);
119
+ resolve(value);
120
+ }, (err) => {
121
+ clearTimeout(timer);
122
+ reject(err);
123
+ });
124
+ });
125
+ }
126
+ // ─── POST body ──────────────────────────────────────────────────────────────
127
+ /**
128
+ * Build the POST /Presence body. Exported so tests can pin the exact shape.
129
+ * Never includes agentId — identity is attributed server-side from the
130
+ * Ed25519 signature on the request (Presence.post(), agent-auth.ts), exactly
131
+ * like flair_workspace_set / flair_orgevent in index.ts. currentTask is
132
+ * omitted (not sent as null/empty) when absent, so callers that don't know a
133
+ * task can still heartbeat without explicitly clearing one that was set
134
+ * earlier — see postPresenceSafe()'s doc comment for why that matters.
135
+ *
136
+ * Natural-presence: this body always carries `activity`, and the server stamps
137
+ * `activityUpdatedAt` (resources/Presence.ts, buildPresenceRecord) on any beat
138
+ * that asserts activity/task — so a working agent keeps activity fresh, and one
139
+ * that stops lets it decay to last-known automatically (no manual clear). A
140
+ * future liveness-only beat (this body without `activity`) would refresh
141
+ * lastHeartbeatAt without re-stamping activity — the server treats the two
142
+ * independently.
143
+ */
144
+ export function buildPresenceBody(activity, currentTask) {
145
+ const body = { activity };
146
+ if (currentTask)
147
+ body.currentTask = currentTask;
148
+ return body;
149
+ }
150
+ /**
151
+ * POST /Presence and NEVER throw or reject — every failure mode (timeout,
152
+ * network error, 401/403/500 from the server, a malformed response) is
153
+ * caught and swallowed here, in this one place, so every call site gets the
154
+ * guarantee for free instead of re-implementing it. Not awaiting the
155
+ * returned promise is what makes a call site "fire and forget"; the promise
156
+ * itself resolving-never-rejecting is what makes doing that safe (no
157
+ * unhandled rejection even if a caller forgets the `.catch()` belt-and-
158
+ * suspenders).
159
+ *
160
+ * currentTask is intentionally the caller's responsibility, not derived here:
161
+ * Presence.post() (resources/Presence.ts) treats an ABSENT currentTask as an
162
+ * explicit clear — `sanitizeCurrentTask(undefined)` returns `null`, which then
163
+ * overwrites any previously-set task on every merge. That's correct for the
164
+ * CLI (`flair presence set --activity X` with no `--task` IS an explicit "no
165
+ * task" statement) but wrong for an automatic heartbeat, which should never
166
+ * silently erase a task a human or bootstrap() set minutes earlier. Callers
167
+ * that want tasks preserved across heartbeats must track and re-pass the last
168
+ * known task themselves (see index.ts's `lastKnownTask` / session-start-hook's
169
+ * per-invocation scope) — this function just forwards whatever it's given.
170
+ */
171
+ export async function postPresenceSafe(poster, activity, currentTask, timeoutMs) {
172
+ try {
173
+ await withTimeout(poster.request("POST", "/Presence", buildPresenceBody(activity, currentTask)), timeoutMs);
174
+ }
175
+ catch {
176
+ // Silent by design — see doc comment above. A flair instance that's down
177
+ // or slow must never slow down or break the caller.
178
+ }
179
+ }
@@ -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
- /** Minimal surface of FlairClient this hook depends on (eases testing). */
47
- interface BootstrapClient {
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.20.1",
3
+ "version": "0.22.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.20.1",
30
+ "@tpsdev-ai/flair-client": "0.22.0",
31
31
  "zod": "4.3.6"
32
32
  },
33
33
  "license": "Apache-2.0",