@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.
- package/dist/continuity.d.ts +69 -0
- package/dist/continuity.js +132 -14
- package/dist/env-guard.d.ts +2 -1
- package/dist/env-guard.js +2 -1
- package/dist/precompact-hook.d.ts +174 -0
- package/dist/precompact-hook.js +418 -0
- package/dist/precompact.d.ts +404 -0
- package/dist/precompact.js +895 -0
- package/dist/prompt-recall-hook.d.ts +289 -0
- package/dist/prompt-recall-hook.js +651 -0
- package/dist/session-start-hook.d.ts +7 -4
- package/dist/session-start-hook.js +30 -9
- package/dist/tool-descriptors/index.js +15 -15
- package/package.json +6 -4
package/dist/continuity.d.ts
CHANGED
|
@@ -55,6 +55,25 @@
|
|
|
55
55
|
* file untouched (scenario S7: compaction ≠ restart; no rotation, no new
|
|
56
56
|
* sessionId).
|
|
57
57
|
*
|
|
58
|
+
* PRE-COMPACTION RECORD (flair#2069): just before a compaction, each run of
|
|
59
|
+
* the `flair-precompact` hook that has something to record attempts at most
|
|
60
|
+
* one PUT of a row into the session's journal, once its local checks pass, in
|
|
61
|
+
* this row shape with meta.hook "PreCompact" (a row is added or updated only
|
|
62
|
+
* when Flair applies that PUT); a later run for the same harness session and
|
|
63
|
+
* trigger within its dedup window, which starts when the marker first names
|
|
64
|
+
* the id (before the first PUT is attempted), reuses the row's id, so its
|
|
65
|
+
* PUT, if applied, creates that row if it is absent and updates it if
|
|
66
|
+
* present (./precompact.ts PRECOMPACT_DEDUP_WINDOW_MS). It quotes user turns, so unlike the journal
|
|
67
|
+
* lines it is redacted, and it is the one continuity row whose CONTENT
|
|
68
|
+
* flair-session-start shows (first, after a compaction or a restart, when the
|
|
69
|
+
* local marker names it and the GET returns an eligible live row). Its rules
|
|
70
|
+
* live in ./precompact.ts; nothing above
|
|
71
|
+
* changes for journal rows. That
|
|
72
|
+
* hook arms a process-level deadline, which can fire only between
|
|
73
|
+
* asynchronous steps, so it reads the state file through
|
|
74
|
+
* bumpSeqBounded (asynchronous, size-capped with fstat before any byte is
|
|
75
|
+
* read) instead of the synchronous bumpSeq the capture hook uses.
|
|
76
|
+
*
|
|
58
77
|
* FAIL-OPEN THROUGHOUT: continuity is a recovery aid, not a correctness gate.
|
|
59
78
|
* Flair unreachable, a #1261 guard 400, a malformed payload, a missing state
|
|
60
79
|
* file — every failure degrades to "journal nothing / hint nothing" and never
|
|
@@ -121,6 +140,34 @@ export declare function readPointer(sessionDir: string, agentId: string): Sessio
|
|
|
121
140
|
/** Read + parse a state file. null on ANY problem — capture then journals
|
|
122
141
|
* nothing (continuity wasn't seeded for this harness session). */
|
|
123
142
|
export declare function readState(sessionDir: string, agentId: string, harnessSessionId: string): SessionState | null;
|
|
143
|
+
/**
|
|
144
|
+
* Upper bound on a session file read through readSmallFile: the state file and
|
|
145
|
+
* the pre-compaction marker (./precompact.ts). Each is a few hundred bytes when
|
|
146
|
+
* Flair wrote it, so anything larger is refused unread.
|
|
147
|
+
*/
|
|
148
|
+
export declare const SESSION_FILE_MAX_BYTES: number;
|
|
149
|
+
/** A bounded read's outcome. Only a missing file is "absent"; every other
|
|
150
|
+
* failure is "refused" with a short reason, never an empty read. */
|
|
151
|
+
export type SmallFileRead = {
|
|
152
|
+
kind: "absent";
|
|
153
|
+
} | {
|
|
154
|
+
kind: "ok";
|
|
155
|
+
text: string;
|
|
156
|
+
} | {
|
|
157
|
+
kind: "refused";
|
|
158
|
+
detail: string;
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Read a small regular file without ever holding the event loop:
|
|
162
|
+
* - the open is non-blocking, so a FIFO at the path cannot stall it;
|
|
163
|
+
* - the opened descriptor is checked with fstat BEFORE any byte is read, and
|
|
164
|
+
* anything that is not a regular file of at most `maxBytes` is refused;
|
|
165
|
+
* - at most `maxBytes + 1` bytes are ever read, so a file that grew after
|
|
166
|
+
* the check is refused too;
|
|
167
|
+
* - every step is asynchronous, so a process-level deadline can fire
|
|
168
|
+
* between them.
|
|
169
|
+
*/
|
|
170
|
+
export declare function readSmallFile(path: string, maxBytes?: number): Promise<SmallFileRead>;
|
|
124
171
|
/**
|
|
125
172
|
* Mint a fresh sessionId + processUUID, seed the per-harness-session state
|
|
126
173
|
* file (seq 0) and rotate the pointer file to the new session. Called by the
|
|
@@ -143,6 +190,28 @@ export declare function seedSession(sessionDir: string, agentId: string, harness
|
|
|
143
190
|
* its seq — gaps are fine, non-monotonicity is not.
|
|
144
191
|
*/
|
|
145
192
|
export declare function bumpSeq(sessionDir: string, agentId: string, harnessSessionId: string, now?: Date): SessionState | null;
|
|
193
|
+
/** bumpSeqBounded's outcome. "absent" (never seeded) is kept apart from
|
|
194
|
+
* "unreadable" (a state file that exists but was refused or did not parse),
|
|
195
|
+
* so a caller that reports it never words the second as the first. */
|
|
196
|
+
export type SeqBump = {
|
|
197
|
+
kind: "bumped";
|
|
198
|
+
state: SessionState;
|
|
199
|
+
} | {
|
|
200
|
+
kind: "absent";
|
|
201
|
+
} | {
|
|
202
|
+
kind: "unreadable";
|
|
203
|
+
detail: string;
|
|
204
|
+
} | {
|
|
205
|
+
kind: "unwritable";
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* bumpSeq for a caller with a process-level deadline (the PreCompact hook):
|
|
209
|
+
* the same increment and the same temp-file + rename, with every step
|
|
210
|
+
* asynchronous and the read bounded by readSmallFile (size-capped with fstat
|
|
211
|
+
* before any byte is read; a FIFO or other non-regular file refused). Never
|
|
212
|
+
* throws.
|
|
213
|
+
*/
|
|
214
|
+
export declare function bumpSeqBounded(sessionDir: string, agentId: string, harnessSessionId: string, now?: Date): Promise<SeqBump>;
|
|
146
215
|
/** Subset of the Claude Code hook payload the capture bin reads. It extracts
|
|
147
216
|
* ONLY these fields — the raw hook JSON (tool_input.command, tool_response,
|
|
148
217
|
* transcript_path, …) is NEVER stored or forwarded. */
|
package/dist/continuity.js
CHANGED
|
@@ -55,12 +55,32 @@
|
|
|
55
55
|
* file untouched (scenario S7: compaction ≠ restart; no rotation, no new
|
|
56
56
|
* sessionId).
|
|
57
57
|
*
|
|
58
|
+
* PRE-COMPACTION RECORD (flair#2069): just before a compaction, each run of
|
|
59
|
+
* the `flair-precompact` hook that has something to record attempts at most
|
|
60
|
+
* one PUT of a row into the session's journal, once its local checks pass, in
|
|
61
|
+
* this row shape with meta.hook "PreCompact" (a row is added or updated only
|
|
62
|
+
* when Flair applies that PUT); a later run for the same harness session and
|
|
63
|
+
* trigger within its dedup window, which starts when the marker first names
|
|
64
|
+
* the id (before the first PUT is attempted), reuses the row's id, so its
|
|
65
|
+
* PUT, if applied, creates that row if it is absent and updates it if
|
|
66
|
+
* present (./precompact.ts PRECOMPACT_DEDUP_WINDOW_MS). It quotes user turns, so unlike the journal
|
|
67
|
+
* lines it is redacted, and it is the one continuity row whose CONTENT
|
|
68
|
+
* flair-session-start shows (first, after a compaction or a restart, when the
|
|
69
|
+
* local marker names it and the GET returns an eligible live row). Its rules
|
|
70
|
+
* live in ./precompact.ts; nothing above
|
|
71
|
+
* changes for journal rows. That
|
|
72
|
+
* hook arms a process-level deadline, which can fire only between
|
|
73
|
+
* asynchronous steps, so it reads the state file through
|
|
74
|
+
* bumpSeqBounded (asynchronous, size-capped with fstat before any byte is
|
|
75
|
+
* read) instead of the synchronous bumpSeq the capture hook uses.
|
|
76
|
+
*
|
|
58
77
|
* FAIL-OPEN THROUGHOUT: continuity is a recovery aid, not a correctness gate.
|
|
59
78
|
* Flair unreachable, a #1261 guard 400, a malformed payload, a missing state
|
|
60
79
|
* file — every failure degrades to "journal nothing / hint nothing" and never
|
|
61
80
|
* blocks the agent's turn or boot.
|
|
62
81
|
*/
|
|
63
|
-
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
82
|
+
import { chmodSync, constants as fsConstants, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
83
|
+
import { chmod, open, rename, writeFile } from "node:fs/promises";
|
|
64
84
|
import { homedir } from "node:os";
|
|
65
85
|
import { join } from "node:path";
|
|
66
86
|
import { randomUUID } from "node:crypto";
|
|
@@ -161,31 +181,100 @@ export function readPointer(sessionDir, agentId) {
|
|
|
161
181
|
return null;
|
|
162
182
|
}
|
|
163
183
|
}
|
|
164
|
-
/**
|
|
165
|
-
|
|
166
|
-
|
|
184
|
+
/** Parse a state file's text: the state, or why it is not one. */
|
|
185
|
+
function parseState(raw, agentId, harnessSessionId) {
|
|
186
|
+
let parsed;
|
|
167
187
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
188
|
+
parsed = JSON.parse(raw);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return { ok: false, detail: "malformed JSON" };
|
|
192
|
+
}
|
|
193
|
+
if (parsed &&
|
|
194
|
+
typeof parsed.sessionId === "string" && parsed.sessionId !== "" &&
|
|
195
|
+
typeof parsed.processUUID === "string" && parsed.processUUID !== "" &&
|
|
196
|
+
typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) {
|
|
197
|
+
return {
|
|
198
|
+
ok: true,
|
|
199
|
+
state: {
|
|
175
200
|
sessionId: parsed.sessionId,
|
|
176
201
|
processUUID: parsed.processUUID,
|
|
177
202
|
seq: parsed.seq,
|
|
178
203
|
agentId: typeof parsed.agentId === "string" ? parsed.agentId : agentId,
|
|
179
204
|
harnessSessionId: typeof parsed.harnessSessionId === "string" ? parsed.harnessSessionId : harnessSessionId,
|
|
180
205
|
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : "",
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return { ok: false, detail: "unexpected shape" };
|
|
210
|
+
}
|
|
211
|
+
/** Read + parse a state file. null on ANY problem — capture then journals
|
|
212
|
+
* nothing (continuity wasn't seeded for this harness session). */
|
|
213
|
+
export function readState(sessionDir, agentId, harnessSessionId) {
|
|
214
|
+
try {
|
|
215
|
+
const parsed = parseState(readFileSync(statePath(sessionDir, agentId, harnessSessionId), "utf-8"), agentId, harnessSessionId);
|
|
216
|
+
return parsed.ok ? parsed.state : null;
|
|
184
217
|
}
|
|
185
218
|
catch {
|
|
186
219
|
return null;
|
|
187
220
|
}
|
|
188
221
|
}
|
|
222
|
+
// ── bounded asynchronous reads (the PreCompact hook's deadline) ─────────────
|
|
223
|
+
/**
|
|
224
|
+
* Upper bound on a session file read through readSmallFile: the state file and
|
|
225
|
+
* the pre-compaction marker (./precompact.ts). Each is a few hundred bytes when
|
|
226
|
+
* Flair wrote it, so anything larger is refused unread.
|
|
227
|
+
*/
|
|
228
|
+
export const SESSION_FILE_MAX_BYTES = 16 * 1024;
|
|
229
|
+
/**
|
|
230
|
+
* Read a small regular file without ever holding the event loop:
|
|
231
|
+
* - the open is non-blocking, so a FIFO at the path cannot stall it;
|
|
232
|
+
* - the opened descriptor is checked with fstat BEFORE any byte is read, and
|
|
233
|
+
* anything that is not a regular file of at most `maxBytes` is refused;
|
|
234
|
+
* - at most `maxBytes + 1` bytes are ever read, so a file that grew after
|
|
235
|
+
* the check is refused too;
|
|
236
|
+
* - every step is asynchronous, so a process-level deadline can fire
|
|
237
|
+
* between them.
|
|
238
|
+
*/
|
|
239
|
+
export async function readSmallFile(path, maxBytes = SESSION_FILE_MAX_BYTES) {
|
|
240
|
+
let handle;
|
|
241
|
+
try {
|
|
242
|
+
handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0));
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
const code = err?.code;
|
|
246
|
+
if (code === "ENOENT")
|
|
247
|
+
return { kind: "absent" };
|
|
248
|
+
return { kind: "refused", detail: typeof code === "string" ? code : "unreadable" };
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const opened = await handle.stat();
|
|
252
|
+
if (!opened.isFile())
|
|
253
|
+
return { kind: "refused", detail: "not a regular file" };
|
|
254
|
+
if (opened.size > maxBytes)
|
|
255
|
+
return { kind: "refused", detail: `larger than ${maxBytes} bytes` };
|
|
256
|
+
// Read to EOF, not to the size fstat reported, into a buffer one byte
|
|
257
|
+
// larger than the cap: a file that grew past the cap is refused, never cut.
|
|
258
|
+
const buf = Buffer.alloc(maxBytes + 1);
|
|
259
|
+
let got = 0;
|
|
260
|
+
while (got < buf.length) {
|
|
261
|
+
const { bytesRead } = await handle.read(buf, got, buf.length - got, got);
|
|
262
|
+
if (bytesRead === 0)
|
|
263
|
+
break;
|
|
264
|
+
got += bytesRead;
|
|
265
|
+
}
|
|
266
|
+
if (got > maxBytes)
|
|
267
|
+
return { kind: "refused", detail: `larger than ${maxBytes} bytes` };
|
|
268
|
+
return { kind: "ok", text: buf.subarray(0, got).toString("utf8") };
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
const code = err?.code;
|
|
272
|
+
return { kind: "refused", detail: typeof code === "string" ? code : "unreadable" };
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
await handle.close().catch(() => undefined);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
189
278
|
/**
|
|
190
279
|
* Mint a fresh sessionId + processUUID, seed the per-harness-session state
|
|
191
280
|
* file (seq 0) and rotate the pointer file to the new session. Called by the
|
|
@@ -237,6 +326,35 @@ export function bumpSeq(sessionDir, agentId, harnessSessionId, now = new Date())
|
|
|
237
326
|
return null;
|
|
238
327
|
}
|
|
239
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* bumpSeq for a caller with a process-level deadline (the PreCompact hook):
|
|
331
|
+
* the same increment and the same temp-file + rename, with every step
|
|
332
|
+
* asynchronous and the read bounded by readSmallFile (size-capped with fstat
|
|
333
|
+
* before any byte is read; a FIFO or other non-regular file refused). Never
|
|
334
|
+
* throws.
|
|
335
|
+
*/
|
|
336
|
+
export async function bumpSeqBounded(sessionDir, agentId, harnessSessionId, now = new Date()) {
|
|
337
|
+
const finalPath = statePath(sessionDir, agentId, harnessSessionId);
|
|
338
|
+
const read = await readSmallFile(finalPath);
|
|
339
|
+
if (read.kind === "absent")
|
|
340
|
+
return { kind: "absent" };
|
|
341
|
+
if (read.kind === "refused")
|
|
342
|
+
return { kind: "unreadable", detail: read.detail };
|
|
343
|
+
const parsed = parseState(read.text, agentId, harnessSessionId);
|
|
344
|
+
if (!parsed.ok)
|
|
345
|
+
return { kind: "unreadable", detail: parsed.detail };
|
|
346
|
+
const next = { ...parsed.state, seq: parsed.state.seq + 1, updatedAt: now.toISOString() };
|
|
347
|
+
const tmpPath = `${finalPath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
348
|
+
try {
|
|
349
|
+
await writeFile(tmpPath, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
350
|
+
await chmod(tmpPath, 0o600);
|
|
351
|
+
await rename(tmpPath, finalPath);
|
|
352
|
+
return { kind: "bumped", state: next };
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return { kind: "unwritable" };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
240
358
|
/** HARD truncate at CAPTURE_BOUND_CHARS with a visible ellipsis, so a reader
|
|
241
359
|
* knows the excerpt is incomplete and never acts on a cut sentence as if it
|
|
242
360
|
* were whole. See CAPTURE_BOUND_CHARS — the bound is load-bearing. */
|
package/dist/env-guard.d.ts
CHANGED
|
@@ -56,7 +56,8 @@ export declare const ENV_RESURRECTED_BY_FLAIR_CLIENT: readonly ["FLAIR_URL"];
|
|
|
56
56
|
export declare function stripInterpolationLiteralsFromEnv(env?: NodeJS.ProcessEnv, names?: readonly string[]): void;
|
|
57
57
|
/**
|
|
58
58
|
* Hook probe mode (flair#1007) — shared by every hook binary this package
|
|
59
|
-
* ships (session-start-hook.ts, continuity-capture-hook.ts
|
|
59
|
+
* ships (session-start-hook.ts, continuity-capture-hook.ts,
|
|
60
|
+
* prompt-recall-hook.ts). `flair doctor`
|
|
60
61
|
* sets FLAIR_HOOK_PROBE to ask "does this command still resolve and execute?"
|
|
61
62
|
* and a probed binary answers by exiting immediately, before stdin, clients,
|
|
62
63
|
* network or any side effect — being reached at all IS the answer.
|
package/dist/env-guard.js
CHANGED
|
@@ -70,7 +70,8 @@ export function stripInterpolationLiteralsFromEnv(env = process.env, names = ENV
|
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
72
|
* Hook probe mode (flair#1007) — shared by every hook binary this package
|
|
73
|
-
* ships (session-start-hook.ts, continuity-capture-hook.ts
|
|
73
|
+
* ships (session-start-hook.ts, continuity-capture-hook.ts,
|
|
74
|
+
* prompt-recall-hook.ts). `flair doctor`
|
|
74
75
|
* sets FLAIR_HOOK_PROBE to ask "does this command still resolve and execute?"
|
|
75
76
|
* and a probed binary answers by exiting immediately, before stdin, clients,
|
|
76
77
|
* network or any side effect — being reached at all IS the answer.
|
|
@@ -0,0 +1,174 @@
|
|
|
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 { type ContinuityClient } from "./continuity.js";
|
|
97
|
+
type Env = Record<string, string | undefined>;
|
|
98
|
+
export declare const ENV_PRECOMPACT_TIMEOUT_MS = "FLAIR_PRECOMPACT_TIMEOUT_MS";
|
|
99
|
+
export declare const DEFAULT_PRECOMPACT_TIMEOUT_MS = 5000;
|
|
100
|
+
export declare const PRECOMPACT_TIMEOUT_FLOOR_MS = 250;
|
|
101
|
+
export declare const PRECOMPACT_TIMEOUT_CEILING_MS = 15000;
|
|
102
|
+
/** Upper bound on the hook's stdin (the PreCompact payload is a few hundred bytes). */
|
|
103
|
+
export declare const STDIN_MAX_BYTES: number;
|
|
104
|
+
/** 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):
|
|
105
|
+
* FLAIR_PRECOMPACT_TIMEOUT_MS when in range, else the default. */
|
|
106
|
+
export declare function resolvePreCompactBudgetMs(env?: Env): number;
|
|
107
|
+
/** The hook's OWN budget timer. Recognized by identity, never by message. */
|
|
108
|
+
export declare class PreCompactTimeoutError extends Error {
|
|
109
|
+
constructor();
|
|
110
|
+
}
|
|
111
|
+
export type PreCompactFailureKind = "auth" | "timeout" | "unreachable" | `http-${number}`;
|
|
112
|
+
/** Same rules as the session-start classifier (flair#1943): a numeric HTTP
|
|
113
|
+
* status first (401/403 → auth), then the hook's own timer or an error named
|
|
114
|
+
* exactly TimeoutError, else unreachable. Message text is never read. */
|
|
115
|
+
export declare function classifyPreCompactFailure(err: unknown): PreCompactFailureKind;
|
|
116
|
+
/** The hook's only non-empty output: ONE JSON object with a `systemMessage`
|
|
117
|
+
* (shown to the user). Never a `decision` field: that could block compaction. */
|
|
118
|
+
export declare function preCompactNote(text: string): string;
|
|
119
|
+
/** The note for a write that was not confirmed. It never says the record
|
|
120
|
+
* was not saved: after a timeout the server may still complete the write,
|
|
121
|
+
* and after any other error the server may already have applied it (the
|
|
122
|
+
* client can fail while reading or parsing the answer). What is known is
|
|
123
|
+
* that the save was not confirmed, so the record may be missing. */
|
|
124
|
+
export declare function writeFailedNote(kind: PreCompactFailureKind): string;
|
|
125
|
+
/** The longest local path a note shows, in characters. */
|
|
126
|
+
export declare const NOTE_PATH_MAX_CHARS = 200;
|
|
127
|
+
/**
|
|
128
|
+
* A local path as a note shows it. Each change applies only where it fits:
|
|
129
|
+
* - a path inside the home directory (`env.HOME`, else the OS's) starts
|
|
130
|
+
* with "~" instead; a home directory of "/" collapses nothing;
|
|
131
|
+
* - each control or line-break character is shown as "?";
|
|
132
|
+
* - strings that match the credential patterns are redacted
|
|
133
|
+
* (redactSecrets);
|
|
134
|
+
* - a result longer than NOTE_PATH_MAX_CHARS is cut to that, ending in "…".
|
|
135
|
+
* A path outside the home directory that none of these touch is shown as it
|
|
136
|
+
* is. The session directory and the agent id in the path are configuration;
|
|
137
|
+
* a secret in them that matches no pattern is shown as written.
|
|
138
|
+
*/
|
|
139
|
+
export declare function notePath(path: string, env?: Env): string;
|
|
140
|
+
export type PreCompactReason = "malformed-input" | "not-precompact" | "no-agent-id" | "bad-session-id" | "no-state" | "state-unreadable" | "state-unwritable" | "no-transcript" | "nothing-to-record" | "marker-unreadable" | "marker-unwritable" | "write-failed" | "written";
|
|
141
|
+
export interface PreCompactOutcome {
|
|
142
|
+
/** The exact string the binary prints ("" prints nothing). */
|
|
143
|
+
output: string;
|
|
144
|
+
/** Why. Diagnostic only: the exit code is 0 regardless. */
|
|
145
|
+
reason: PreCompactReason;
|
|
146
|
+
/** The record id, once one was resolved. */
|
|
147
|
+
recordId?: string;
|
|
148
|
+
/** True when this run reused the marker's record id. */
|
|
149
|
+
reused?: boolean;
|
|
150
|
+
}
|
|
151
|
+
export interface PreCompactDeps {
|
|
152
|
+
makeClient?: (agentId: string, timeoutMs: number, env: Env) => ContinuityClient | Promise<ContinuityClient>;
|
|
153
|
+
env?: Env;
|
|
154
|
+
sessionDir?: string;
|
|
155
|
+
now?: () => Date;
|
|
156
|
+
/** When the budget started (epoch ms). The entry point passes its own start. */
|
|
157
|
+
startedAt?: number;
|
|
158
|
+
/** The process-level budget in ms (default: resolvePreCompactBudgetMs(env)). */
|
|
159
|
+
budgetMs?: number;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Core flow with injectable dependencies. Never throws for a failed read or
|
|
163
|
+
* write; returns the exact output plus a diagnostic reason.
|
|
164
|
+
*/
|
|
165
|
+
export declare function runPreCompact(rawInput: string, deps?: PreCompactDeps): Promise<PreCompactOutcome>;
|
|
166
|
+
/**
|
|
167
|
+
* Whether this module is the process entry point. Where the runtime provides
|
|
168
|
+
* `import.meta.main` (Bun; Node 22.18+), its answer decides, true or false.
|
|
169
|
+
* Otherwise compare FILESYSTEM paths resolved through symlinks: the module URL
|
|
170
|
+
* is percent-encoded and an npm bin shim is a symlink, so comparing the URL
|
|
171
|
+
* string with `argv[1]` misses both.
|
|
172
|
+
*/
|
|
173
|
+
export declare function isDirectRun(moduleUrl: string, argv1: string | undefined, metaMain: boolean | undefined, realpath?: (p: string) => string): boolean;
|
|
174
|
+
export {};
|