@tpsdev-ai/flair-mcp 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,418 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Flair PreCompact hook for Claude Code (flair#2069): when the transcript
4
+ * tail holds something to record and the local checks pass, attempt one PUT
5
+ * of a bounded continuity record before context is lost (to a new row, or,
6
+ * within the dedup window, to the same row), so the next session start can
7
+ * show it first. The record's
8
+ * content, bounds, redaction, storage and dedup are defined in
9
+ * ./precompact.ts; this file is the binary around them.
10
+ *
11
+ * PER RUN
12
+ * -------
13
+ * 1. Reads Claude Code's PreCompact payload on stdin: `hook_event_name`
14
+ * ("PreCompact"), `session_id`, `transcript_path` and `trigger`
15
+ * ("manual" for /compact, "auto" for automatic compaction).
16
+ * 2. Finds this harness session's continuity state (seeded by
17
+ * flair-session-start) and consumes a journal seq, as
18
+ * flair-continuity-capture does, through the bounded asynchronous
19
+ * bumpSeqBounded.
20
+ * 3. Reads the transcript TAIL (bounded in bytes and lines) and builds the
21
+ * record: standing instructions, open tasks, in-flight work and the last
22
+ * assistant message, redacted and cut to the record bound. Nothing to
23
+ * record: nothing written.
24
+ * 4. Resolves the record id through the local marker (a later run for the
25
+ * same harness session and trigger within the dedup window reuses it),
26
+ * writes the marker, then writes the row with one signed
27
+ * `PUT /Memory/<id>` as the agent's own Ed25519 identity.
28
+ *
29
+ * EXIT 0 ON EVERY PATH IT HANDLES
30
+ * -------------------------------
31
+ * Claude Code blocks compaction when a PreCompact hook exits 2 or prints a
32
+ * `decision: "block"` object. Once this binary has started, it does neither
33
+ * on any path it handles: it exits 0 and prints either nothing or ONE
34
+ * `{"systemMessage": …}` object (a warning Claude Code shows the user). A
35
+ * failure before that (the launcher, the runtime's start-up, a static import
36
+ * that fails to load) is outside the binary; the documented command's
37
+ * `|| true` covers it. The time budget (FLAIR_PRECOMPACT_TIMEOUT_MS,
38
+ * default 5 s) is a process-level deadline armed in main(), after the module
39
+ * has loaded and the entry-point check (isDirectRun) has run, and before
40
+ * stdin is read. When it passes, the timer starts finishing whatever
41
+ * asynchronous work is still pending (stdin held open, a slow read, a write
42
+ * in flight): the hook prints the one timeout note when FLAIR_AGENT_ID is
43
+ * set, and nothing when it is not (main() decides this when it arms the
44
+ * deadline), then exits 0 once stdout drains, waiting at most a further
45
+ * STDOUT_DRAIN_GRACE_MS (1 s).
46
+ * Synchronous work can delay the timer itself; Claude Code's hook timeout is
47
+ * the outer bound. stdin is read up
48
+ * to STDIN_MAX_BYTES; a larger payload is ignored. The hook's own local files
49
+ * (the transcript, the continuity state file and the marker) are read
50
+ * asynchronously with a size cap checked (fstat) before any byte is read: the
51
+ * transcript by its tail caps, the state file and the marker by
52
+ * SESSION_FILE_MAX_BYTES. A state file or marker larger than that, or anything
53
+ * at those three paths that is not a regular file, is refused with a note.
54
+ * Its local writes are asynchronous too, so none of the hook's own file work
55
+ * can hold the process past the deadline.
56
+ * The deadline bounds asynchronous work only: a timer cannot preempt
57
+ * synchronous code, and one local read is synchronous and not this hook's
58
+ * own: flair-client reads the agent's key file synchronously while the client
59
+ * is built, outside the size caps above. The outer bound on the whole process
60
+ * is Claude Code's own hook `timeout` in the settings entry. What runs before
61
+ * main() arms the deadline (the launcher, the runtime's start-up, module
62
+ * loading, the entry-point check) is outside the budget too.
63
+ *
64
+ * NOTES (the only output)
65
+ * -----------------------
66
+ * Silent: a probe, a malformed or non-PreCompact payload, no FLAIR_AGENT_ID,
67
+ * a tail with nothing to record, and success. One note, naming the reason: no
68
+ * continuity state for the session, a state file that could not be read (too
69
+ * large, not a regular file, malformed) or updated, an unreadable transcript,
70
+ * a marker that could not be read or written, and a write that was not
71
+ * confirmed (named by its kind, from classifyPreCompactFailure's three
72
+ * inputs: a numeric HTTP status, 401 or 403 as auth and any other as
73
+ * http-<status>; the hook's own timer or an error named exactly TimeoutError,
74
+ * as timeout; anything else as unreachable. Never by an error's message text
75
+ * or URL. Worded as "may be missing", never as "not saved", because the
76
+ * server may have applied it). A note that names a state file or the marker
77
+ * shows its path through notePath, which changes it only where each rule
78
+ * applies (see notePath).
79
+ *
80
+ * IDENTITY
81
+ * --------
82
+ * The agent's own key, resolved like the other hooks (FLAIR_AGENT_ID +
83
+ * FLAIR_KEY_PATH or the standard key locations). The client is built with an
84
+ * empty admin pair, which turns off flair-client's FLAIR_ADMIN_USER /
85
+ * FLAIR_ADMIN_PASSWORD Basic fallback: the record is written as the agent or
86
+ * not at all.
87
+ *
88
+ * CONFIG (env)
89
+ * FLAIR_AGENT_ID (required; absent → silent no-op)
90
+ * FLAIR_URL (default via flair-client)
91
+ * FLAIR_KEY_PATH (default ~/.flair/keys/<agent>.key via flair-client)
92
+ * FLAIR_PRECOMPACT_TIMEOUT_MS (default 5000; 250..15000)
93
+ * FLAIR_SESSION_DIR (default ~/.flair/session; tests)
94
+ * FLAIR_HOOK_PROBE (probe mode: exit 0 before stdin, files or network)
95
+ */
96
+ import { realpathSync } from "node:fs";
97
+ import { homedir } from "node:os";
98
+ import { fileURLToPath } from "node:url";
99
+ import { isProbeMode, readEnvOrUnset, stripInterpolationLiteralsFromEnv } from "./env-guard.js";
100
+ import { memoryPutPath } from "./record-id-path.js";
101
+ import { bumpSeqBounded, isSafeFileId, resolveSessionDir, statePath } from "./continuity.js";
102
+ import { PRECOMPACT_HOOK, buildPreCompactContent, buildPreCompactRow, cutTo, extractFromTranscript, normalizeTrigger, precompactMarkerPath, readPreCompactMarker, readTranscriptTail, redactSecrets, resolvePreCompactRecordId, writePreCompactMarker, } from "./precompact.js";
103
+ export const ENV_PRECOMPACT_TIMEOUT_MS = "FLAIR_PRECOMPACT_TIMEOUT_MS";
104
+ export const DEFAULT_PRECOMPACT_TIMEOUT_MS = 5000;
105
+ export const PRECOMPACT_TIMEOUT_FLOOR_MS = 250;
106
+ export const PRECOMPACT_TIMEOUT_CEILING_MS = 15_000;
107
+ /** Upper bound on the hook's stdin (the PreCompact payload is a few hundred bytes). */
108
+ export const STDIN_MAX_BYTES = 256 * 1024;
109
+ /** The process-level budget (armed in main(), before stdin is read; when it passes, finishing starts and the process exits once stdout drains, at most STDOUT_DRAIN_GRACE_MS later):
110
+ * FLAIR_PRECOMPACT_TIMEOUT_MS when in range, else the default. */
111
+ export function resolvePreCompactBudgetMs(env = process.env) {
112
+ const raw = readEnvOrUnset(ENV_PRECOMPACT_TIMEOUT_MS, env);
113
+ const n = raw != null && raw.trim() !== "" ? Number(raw) : NaN;
114
+ return Number.isFinite(n) && n >= PRECOMPACT_TIMEOUT_FLOOR_MS && n <= PRECOMPACT_TIMEOUT_CEILING_MS
115
+ ? n
116
+ : DEFAULT_PRECOMPACT_TIMEOUT_MS;
117
+ }
118
+ // ── failure → one note ──────────────────────────────────────────────────────
119
+ /** The hook's OWN budget timer. Recognized by identity, never by message. */
120
+ export class PreCompactTimeoutError extends Error {
121
+ constructor() {
122
+ super("precompact timeout");
123
+ this.name = "PreCompactTimeoutError";
124
+ }
125
+ }
126
+ /** Same rules as the session-start classifier (flair#1943): a numeric HTTP
127
+ * status first (401/403 → auth), then the hook's own timer or an error named
128
+ * exactly TimeoutError, else unreachable. Message text is never read. */
129
+ export function classifyPreCompactFailure(err) {
130
+ const e = err;
131
+ for (const key of ["status", "status_code", "statusCode"]) {
132
+ const v = e?.[key];
133
+ if (typeof v === "number" && Number.isFinite(v))
134
+ return v === 401 || v === 403 ? "auth" : `http-${v}`;
135
+ }
136
+ if (err instanceof PreCompactTimeoutError)
137
+ return "timeout";
138
+ if (typeof e?.name === "string" && e.name === "TimeoutError")
139
+ return "timeout";
140
+ return "unreachable";
141
+ }
142
+ /** The hook's only non-empty output: ONE JSON object with a `systemMessage`
143
+ * (shown to the user). Never a `decision` field: that could block compaction. */
144
+ export function preCompactNote(text) {
145
+ return JSON.stringify({ systemMessage: text });
146
+ }
147
+ /** The note for a write that was not confirmed. It never says the record
148
+ * was not saved: after a timeout the server may still complete the write,
149
+ * and after any other error the server may already have applied it (the
150
+ * client can fail while reading or parsing the answer). What is known is
151
+ * that the save was not confirmed, so the record may be missing. */
152
+ export function writeFailedNote(kind) {
153
+ const what = kind === "timeout"
154
+ ? "saving the pre-compaction continuity record did not finish in time (timeout), so it may be missing"
155
+ : `saving the pre-compaction continuity record could not be confirmed (${kind}), so it may be missing`;
156
+ return preCompactNote(`Flair: ${what}; compaction goes ahead. Check Flair with \`flair doctor\`.`);
157
+ }
158
+ function withTimeout(promise, ms) {
159
+ return new Promise((resolve, reject) => {
160
+ const timer = setTimeout(() => reject(new PreCompactTimeoutError()), ms);
161
+ timer.unref?.();
162
+ promise.then((value) => {
163
+ clearTimeout(timer);
164
+ resolve(value);
165
+ }, (err) => {
166
+ clearTimeout(timer);
167
+ reject(err);
168
+ });
169
+ });
170
+ }
171
+ /** The longest local path a note shows, in characters. */
172
+ export const NOTE_PATH_MAX_CHARS = 200;
173
+ /**
174
+ * A local path as a note shows it. Each change applies only where it fits:
175
+ * - a path inside the home directory (`env.HOME`, else the OS's) starts
176
+ * with "~" instead; a home directory of "/" collapses nothing;
177
+ * - each control or line-break character is shown as "?";
178
+ * - strings that match the credential patterns are redacted
179
+ * (redactSecrets);
180
+ * - a result longer than NOTE_PATH_MAX_CHARS is cut to that, ending in "…".
181
+ * A path outside the home directory that none of these touch is shown as it
182
+ * is. The session directory and the agent id in the path are configuration;
183
+ * a secret in them that matches no pattern is shown as written.
184
+ */
185
+ export function notePath(path, env = process.env) {
186
+ const home = (env.HOME || homedir()).replace(/\/+$/, "");
187
+ let shown = path;
188
+ if (home !== "" && (path === home || path.startsWith(`${home}/`)))
189
+ shown = `~${path.slice(home.length)}`;
190
+ shown = shown.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "?");
191
+ return cutTo(redactSecrets(shown), NOTE_PATH_MAX_CHARS);
192
+ }
193
+ /** LAZY for the same reason as ./continuity-capture-hook.ts's factory: the root
194
+ * test lane imports this module before flair-client is built. Tests inject
195
+ * makeClient; only the real binary takes this path. */
196
+ async function defaultClientFactory(agentId, timeoutMs, env) {
197
+ // @ts-ignore -- resolvable only once flair-client's dist is built; see ./continuity-capture-hook.ts
198
+ const mod = await import("@tpsdev-ai/flair-client");
199
+ const FlairClient = mod.FlairClient;
200
+ return new FlairClient({
201
+ agentId,
202
+ url: readEnvOrUnset("FLAIR_URL", env),
203
+ keyPath: readEnvOrUnset("FLAIR_KEY_PATH", env),
204
+ timeoutMs,
205
+ adminUser: "",
206
+ adminPassword: "",
207
+ });
208
+ }
209
+ /**
210
+ * Core flow with injectable dependencies. Never throws for a failed read or
211
+ * write; returns the exact output plus a diagnostic reason.
212
+ */
213
+ export async function runPreCompact(rawInput, deps = {}) {
214
+ const startedAt = deps.startedAt ?? Date.now();
215
+ const env = deps.env ?? process.env;
216
+ const now = deps.now ?? (() => new Date());
217
+ const budgetMs = deps.budgetMs ?? resolvePreCompactBudgetMs(env);
218
+ let input;
219
+ try {
220
+ const parsed = JSON.parse(rawInput);
221
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
222
+ return { output: "", reason: "malformed-input" };
223
+ input = parsed;
224
+ }
225
+ catch {
226
+ return { output: "", reason: "malformed-input" };
227
+ }
228
+ if (input.hook_event_name !== PRECOMPACT_HOOK)
229
+ return { output: "", reason: "not-precompact" };
230
+ const agentId = readEnvOrUnset("FLAIR_AGENT_ID", env);
231
+ if (!agentId || !isSafeFileId(agentId))
232
+ return { output: "", reason: "no-agent-id" };
233
+ const harnessSessionId = input.session_id;
234
+ if (!isSafeFileId(harnessSessionId))
235
+ return { output: "", reason: "bad-session-id" };
236
+ const trigger = normalizeTrigger(input.trigger);
237
+ // The session's continuity state, seeded by flair-session-start. Consuming a
238
+ // seq orders the record inside the session's journal. The read is
239
+ // asynchronous and size-capped before any byte is read, so a large or odd
240
+ // file at the state path cannot hold the process past its deadline; a state
241
+ // file that exists but cannot be used is reported as such, never as absent.
242
+ const sessionDir = deps.sessionDir ?? resolveSessionDir(env);
243
+ const bumped = await bumpSeqBounded(sessionDir, agentId, harnessSessionId, now());
244
+ if (bumped.kind === "absent") {
245
+ return {
246
+ output: preCompactNote("Flair: no continuity state for this session, so no pre-compaction record was saved. flair-session-start creates it when a session starts."),
247
+ reason: "no-state",
248
+ };
249
+ }
250
+ if (bumped.kind === "unreadable" || bumped.kind === "unwritable") {
251
+ const path = notePath(statePath(sessionDir, agentId, harnessSessionId), env);
252
+ return {
253
+ output: preCompactNote(bumped.kind === "unreadable"
254
+ ? `Flair: the continuity state file ${path} could not be read (${bumped.detail}), so no pre-compaction record was saved. Remove that file to reset it; flair-session-start recreates it when a session starts.`
255
+ : `Flair: the continuity state file ${path} could not be updated, so no pre-compaction record was saved.`),
256
+ reason: bumped.kind === "unreadable" ? "state-unreadable" : "state-unwritable",
257
+ };
258
+ }
259
+ const state = bumped.state;
260
+ const tail = await readTranscriptTail(input.transcript_path);
261
+ if (!tail.ok) {
262
+ return {
263
+ output: preCompactNote(`Flair: the transcript could not be read (${tail.reason}), so no pre-compaction record was saved.`),
264
+ reason: "no-transcript",
265
+ };
266
+ }
267
+ const content = buildPreCompactContent(extractFromTranscript(tail.lines), trigger);
268
+ if (content === null)
269
+ return { output: "", reason: "nothing-to-record" };
270
+ // Dedup: an unreadable marker is NOT "no marker". Treating it as absent would
271
+ // license a second record for a compaction that already has one.
272
+ const markerPath = notePath(precompactMarkerPath(sessionDir, agentId), env);
273
+ const read = await readPreCompactMarker(sessionDir, agentId);
274
+ if (read.kind === "unknown") {
275
+ return {
276
+ output: preCompactNote(`Flair: the pre-compaction marker ${markerPath} could not be read (${read.detail}), so no record was saved. Remove that file to reset it.`),
277
+ reason: "marker-unreadable",
278
+ };
279
+ }
280
+ const at = now();
281
+ const resolved = resolvePreCompactRecordId(read.kind === "present" ? read.marker : null, harnessSessionId, trigger, agentId, at);
282
+ try {
283
+ await writePreCompactMarker(sessionDir, agentId, {
284
+ harnessSessionId,
285
+ sessionId: state.sessionId,
286
+ trigger,
287
+ recordId: resolved.recordId,
288
+ firstWrittenAt: resolved.firstWrittenAt,
289
+ });
290
+ }
291
+ catch {
292
+ return {
293
+ output: preCompactNote(`Flair: the pre-compaction marker ${markerPath} could not be written, so no record was saved (a rerun could not be recognized).`),
294
+ reason: "marker-unwritable",
295
+ recordId: resolved.recordId,
296
+ };
297
+ }
298
+ const row = buildPreCompactRow(agentId, state, resolved.recordId, content, trigger, at);
299
+ const remainingMs = startedAt + budgetMs - Date.now();
300
+ if (remainingMs <= 0) {
301
+ return { output: writeFailedNote("timeout"), reason: "write-failed", recordId: row.id, reused: resolved.reused };
302
+ }
303
+ const makeClient = deps.makeClient ?? defaultClientFactory;
304
+ try {
305
+ await withTimeout((async () => {
306
+ const client = await makeClient(agentId, remainingMs, env);
307
+ await client.request("PUT", memoryPutPath(row.id), row);
308
+ })(), remainingMs);
309
+ }
310
+ catch (err) {
311
+ return { output: writeFailedNote(classifyPreCompactFailure(err)), reason: "write-failed", recordId: row.id, reused: resolved.reused };
312
+ }
313
+ return { output: "", reason: "written", recordId: row.id, reused: resolved.reused };
314
+ }
315
+ // ── entry point ─────────────────────────────────────────────────────────────
316
+ /** Read stdin up to `maxBytes`: the text on EOF, or null as soon as it is
317
+ * larger (stdin is then closed). No timer here: stdin held open is bounded
318
+ * by the process-level deadline armed in main(). */
319
+ function readStdin(maxBytes) {
320
+ return new Promise((resolve) => {
321
+ const chunks = [];
322
+ let size = 0;
323
+ let settled = false;
324
+ const settle = (value) => {
325
+ if (settled)
326
+ return;
327
+ settled = true;
328
+ resolve(value);
329
+ };
330
+ process.stdin.on("data", (chunk) => {
331
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
332
+ size += bytes.length;
333
+ if (size > maxBytes) {
334
+ process.stdin.destroy();
335
+ settle(null);
336
+ return;
337
+ }
338
+ chunks.push(bytes);
339
+ });
340
+ process.stdin.on("end", () => settle(Buffer.concat(chunks).toString("utf8")));
341
+ process.stdin.on("error", () => settle(Buffer.concat(chunks).toString("utf8")));
342
+ });
343
+ }
344
+ /** How long to wait for stdout to drain before exiting anyway. */
345
+ const STDOUT_DRAIN_GRACE_MS = 1000;
346
+ let finished = false;
347
+ /** Print the output (possibly nothing) and end the process with exit 0 once
348
+ * the write drains. The first caller wins: the deadline and the normal path
349
+ * can both get here, and only one output may be written. Explicit, so an
350
+ * abandoned request cannot keep the process alive past the budget. */
351
+ function finish(output) {
352
+ if (finished)
353
+ return;
354
+ finished = true;
355
+ let done = false;
356
+ const exit = () => {
357
+ if (done)
358
+ return;
359
+ done = true;
360
+ process.exit(0);
361
+ };
362
+ process.stdout.on("error", exit);
363
+ try {
364
+ process.stdout.write(output, exit);
365
+ }
366
+ catch {
367
+ exit();
368
+ }
369
+ setTimeout(exit, STDOUT_DRAIN_GRACE_MS).unref?.();
370
+ }
371
+ async function main() {
372
+ const startedAt = Date.now();
373
+ // Probe mode (flair#1007 pattern): being reached is the whole answer.
374
+ if (isProbeMode()) {
375
+ finish("");
376
+ return;
377
+ }
378
+ stripInterpolationLiteralsFromEnv();
379
+ const budgetMs = resolvePreCompactBudgetMs(process.env);
380
+ // The process-level deadline, armed before stdin is read.
381
+ const expired = readEnvOrUnset("FLAIR_AGENT_ID") ? writeFailedNote("timeout") : "";
382
+ setTimeout(() => finish(expired), budgetMs);
383
+ let output = "";
384
+ try {
385
+ const input = await readStdin(STDIN_MAX_BYTES);
386
+ if (input !== null)
387
+ output = (await runPreCompact(input, { startedAt, budgetMs })).output;
388
+ }
389
+ catch {
390
+ output = "";
391
+ }
392
+ finish(output);
393
+ }
394
+ // Only run when executed as a script, not when imported by tests.
395
+ /**
396
+ * Whether this module is the process entry point. Where the runtime provides
397
+ * `import.meta.main` (Bun; Node 22.18+), its answer decides, true or false.
398
+ * Otherwise compare FILESYSTEM paths resolved through symlinks: the module URL
399
+ * is percent-encoded and an npm bin shim is a symlink, so comparing the URL
400
+ * string with `argv[1]` misses both.
401
+ */
402
+ export function isDirectRun(moduleUrl, argv1, metaMain, realpath = realpathSync) {
403
+ if (metaMain !== undefined)
404
+ return metaMain;
405
+ if (argv1 == null || argv1 === "")
406
+ return false;
407
+ try {
408
+ return realpath(fileURLToPath(moduleUrl)) === realpath(argv1);
409
+ }
410
+ catch {
411
+ return false;
412
+ }
413
+ }
414
+ const importMeta = import.meta;
415
+ const isMain = typeof process !== "undefined" && isDirectRun(import.meta.url, process.argv[1], importMeta.main);
416
+ if (isMain) {
417
+ void main().catch(() => finish(""));
418
+ }