@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 +8 -0
- package/dist/index.js +113 -0
- package/dist/presence.d.ts +130 -0
- package/dist/presence.js +179 -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
|
@@ -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>;
|
package/dist/presence.js
ADDED
|
@@ -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
|
-
|
|
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.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.
|
|
30
|
+
"@tpsdev-ai/flair-client": "0.22.0",
|
|
31
31
|
"zod": "4.3.6"
|
|
32
32
|
},
|
|
33
33
|
"license": "Apache-2.0",
|