@tpsdev-ai/flair-mcp 0.44.13 → 0.46.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,58 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Flair continuity-capture hook for Claude Code (flair#1257 slice 2) — the
4
+ * PostToolUse/Stop hook target that auto-journals the agent's working state
5
+ * into the ephemeral Memory tier, so an unseen kill/crash/OOM never loses the
6
+ * cadence. Registered (opt-in) via `flair doctor --fix` / `flair hook install
7
+ * --continuity`; the resume side lives in ./session-start-hook.ts.
8
+ *
9
+ * WHAT IT WRITES — see ./continuity.ts's module doc for the full, binding
10
+ * capture discipline. In one breath: at most ONE ephemeral+private row per
11
+ * fire; mutating tools only; Bash description-only (NEVER the command);
12
+ * Write/Edit/NotebookEdit path-only; Stop = a hard-bounded excerpt of
13
+ * assistant-chosen prose; never the raw hook JSON, never tool results, never
14
+ * an attempt at secret-scrubbing (the bound is the control).
15
+ *
16
+ * NO-OP-ON-ANY-FAILURE GUARANTEE (same posture as session-start-hook.ts):
17
+ * this binary can never block or break the agent's turn. Malformed stdin,
18
+ * missing identity, missing state file, Flair unreachable, a #1261 guard 400,
19
+ * a timeout, an unexpected throw — every one degrades to "journal nothing",
20
+ * at most one debug-level stderr line (the installed command discards
21
+ * stderr), and exit 0. Malformed hook JSON in particular journals NOTHING —
22
+ * no partial extraction that might pull raw tool results into the journal
23
+ * (Sherlock's input-validation tightening).
24
+ *
25
+ * A hard timeout (FLAIR_CONTINUITY_TIMEOUT_MS, default 2s) bounds the journal
26
+ * write so a slow Flair can't make the agent wait on its own diary.
27
+ *
28
+ * CONFIG (env, read identically to the other hook binaries):
29
+ * FLAIR_AGENT_ID (required — absent → no-op)
30
+ * FLAIR_URL (default http://localhost:19926 via flair-client)
31
+ * FLAIR_KEY_PATH (default ~/.flair/keys/<agent>.key via flair-client)
32
+ * FLAIR_CONTINUITY_TIMEOUT_MS (default 2000; clamped 250..10000)
33
+ * FLAIR_SESSION_DIR (default ~/.flair/session — test override)
34
+ * FLAIR_HOOK_PROBE (probe mode: exit immediately, no stdin read, no writes)
35
+ */
36
+ import { type ContinuityClient } from "./continuity.js";
37
+ /** Injectable dependencies so the whole flow is unit-testable without a live
38
+ * Flair daemon or a real ~/.flair (homeOverride discipline). */
39
+ export interface CaptureDeps {
40
+ makeClient?: (agentId: string) => ContinuityClient | Promise<ContinuityClient>;
41
+ sessionDir?: string;
42
+ env?: Record<string, string | undefined>;
43
+ now?: () => Date;
44
+ /** Debug-level warn sink (default: one stderr line). Never stdout. */
45
+ warn?: (message: string) => void;
46
+ }
47
+ export interface CaptureOutcome {
48
+ /** True only when a journal row was accepted by Flair. */
49
+ wrote: boolean;
50
+ /** Why nothing was written (or "written"). Diagnostic only — the process
51
+ * exit code is 0 regardless (fail-open). */
52
+ reason: "written" | "probe" | "malformed-input" | "not-capturable" | "no-agent-id" | "bad-session-id" | "no-state" | "write-failed";
53
+ }
54
+ /**
55
+ * Core capture flow. NEVER throws; never writes to stdout. Returns a
56
+ * diagnostic outcome for tests — the binary ignores it and exits 0.
57
+ */
58
+ export declare function runCapture(rawInput: string, deps?: CaptureDeps): Promise<CaptureOutcome>;
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Flair continuity-capture hook for Claude Code (flair#1257 slice 2) — the
4
+ * PostToolUse/Stop hook target that auto-journals the agent's working state
5
+ * into the ephemeral Memory tier, so an unseen kill/crash/OOM never loses the
6
+ * cadence. Registered (opt-in) via `flair doctor --fix` / `flair hook install
7
+ * --continuity`; the resume side lives in ./session-start-hook.ts.
8
+ *
9
+ * WHAT IT WRITES — see ./continuity.ts's module doc for the full, binding
10
+ * capture discipline. In one breath: at most ONE ephemeral+private row per
11
+ * fire; mutating tools only; Bash description-only (NEVER the command);
12
+ * Write/Edit/NotebookEdit path-only; Stop = a hard-bounded excerpt of
13
+ * assistant-chosen prose; never the raw hook JSON, never tool results, never
14
+ * an attempt at secret-scrubbing (the bound is the control).
15
+ *
16
+ * NO-OP-ON-ANY-FAILURE GUARANTEE (same posture as session-start-hook.ts):
17
+ * this binary can never block or break the agent's turn. Malformed stdin,
18
+ * missing identity, missing state file, Flair unreachable, a #1261 guard 400,
19
+ * a timeout, an unexpected throw — every one degrades to "journal nothing",
20
+ * at most one debug-level stderr line (the installed command discards
21
+ * stderr), and exit 0. Malformed hook JSON in particular journals NOTHING —
22
+ * no partial extraction that might pull raw tool results into the journal
23
+ * (Sherlock's input-validation tightening).
24
+ *
25
+ * A hard timeout (FLAIR_CONTINUITY_TIMEOUT_MS, default 2s) bounds the journal
26
+ * write so a slow Flair can't make the agent wait on its own diary.
27
+ *
28
+ * CONFIG (env, read identically to the other hook binaries):
29
+ * FLAIR_AGENT_ID (required — absent → no-op)
30
+ * FLAIR_URL (default http://localhost:19926 via flair-client)
31
+ * FLAIR_KEY_PATH (default ~/.flair/keys/<agent>.key via flair-client)
32
+ * FLAIR_CONTINUITY_TIMEOUT_MS (default 2000; clamped 250..10000)
33
+ * FLAIR_SESSION_DIR (default ~/.flair/session — test override)
34
+ * FLAIR_HOOK_PROBE (probe mode: exit immediately, no stdin read, no writes)
35
+ */
36
+ import { isProbeMode, readEnvOrUnset, stripInterpolationLiteralsFromEnv } from "./env-guard.js";
37
+ import { buildJournalRow, bumpSeq, isSafeFileId, planCapture, resolveContinuityTimeoutMs, resolveSessionDir, } from "./continuity.js";
38
+ /** Read all of stdin. Resolves on EOF, with a short fallback for manual runs
39
+ * where nothing is piped (so it never hangs). */
40
+ function readStdin() {
41
+ return new Promise((resolve) => {
42
+ let data = "";
43
+ process.stdin.setEncoding("utf8");
44
+ process.stdin.on("data", (chunk) => (data += chunk));
45
+ process.stdin.on("end", () => resolve(data));
46
+ process.stdin.on("error", () => resolve(data));
47
+ setTimeout(() => resolve(data), 200).unref?.();
48
+ });
49
+ }
50
+ function withTimeout(promise, ms) {
51
+ return new Promise((resolve, reject) => {
52
+ const timer = setTimeout(() => reject(new Error("continuity_write_timeout")), ms);
53
+ timer.unref?.();
54
+ promise.then((value) => {
55
+ clearTimeout(timer);
56
+ resolve(value);
57
+ }, (err) => {
58
+ clearTimeout(timer);
59
+ reject(err);
60
+ });
61
+ });
62
+ }
63
+ /** LAZY on purpose: @tpsdev-ai/flair-client resolves via its BUILT dist/, and
64
+ * this module must both LOAD and TYPECHECK without that dist present — the
65
+ * root `bun test test/unit/` lane AND the strict test-suite typecheck lane
66
+ * each run before the client is built (see the ordering note in
67
+ * test/unit/hook-install.test.ts). Tests always inject makeClient, so only
68
+ * the real binary ever takes this path; the real module boundary is
69
+ * exercised by the package-lane tests, which build the client first.
70
+ *
71
+ * `@ts-ignore`, deliberately NOT `@ts-expect-error`: with the dist built the
72
+ * import DOES resolve, and an expect-error directive would then itself be
73
+ * the error. ts-ignore is inert in that state — verified locally in BOTH
74
+ * states (dist present and dist deleted). */
75
+ async function defaultClientFactory(agentId) {
76
+ // @ts-ignore -- resolvable only once flair-client's dist is built; see doc above
77
+ const mod = await import("@tpsdev-ai/flair-client");
78
+ const FlairClient = mod.FlairClient;
79
+ return new FlairClient({
80
+ agentId,
81
+ url: readEnvOrUnset("FLAIR_URL"),
82
+ keyPath: readEnvOrUnset("FLAIR_KEY_PATH"),
83
+ });
84
+ }
85
+ /**
86
+ * Core capture flow. NEVER throws; never writes to stdout. Returns a
87
+ * diagnostic outcome for tests — the binary ignores it and exits 0.
88
+ */
89
+ export async function runCapture(rawInput, deps = {}) {
90
+ const env = deps.env ?? process.env;
91
+ const warn = deps.warn ?? ((message) => console.error(`flair-continuity-capture: ${message}`));
92
+ const now = deps.now ?? (() => new Date());
93
+ // Malformed / non-object hook JSON ⇒ journal NOTHING (Sherlock: no partial
94
+ // extraction — a half-parsed payload must never leak fields into a row).
95
+ let input;
96
+ try {
97
+ const parsed = JSON.parse(rawInput);
98
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
99
+ return { wrote: false, reason: "malformed-input" };
100
+ }
101
+ input = parsed;
102
+ }
103
+ catch {
104
+ return { wrote: false, reason: "malformed-input" };
105
+ }
106
+ // Pure capture decision first — a read-only tool or an empty Stop exits
107
+ // before touching the filesystem or minting a seq.
108
+ const plan = planCapture(input);
109
+ if (!plan)
110
+ return { wrote: false, reason: "not-capturable" };
111
+ const agentId = env.FLAIR_AGENT_ID;
112
+ if (typeof agentId !== "string" || agentId === "" || !isSafeFileId(agentId)) {
113
+ return { wrote: false, reason: "no-agent-id" };
114
+ }
115
+ const harnessSessionId = input.session_id;
116
+ if (!isSafeFileId(harnessSessionId))
117
+ return { wrote: false, reason: "bad-session-id" };
118
+ // Per-process state, seeded by the SessionStart hook. Missing/unreadable ⇒
119
+ // continuity was never seeded for this harness session ⇒ journal nothing.
120
+ const sessionDir = deps.sessionDir ?? resolveSessionDir(env);
121
+ const state = bumpSeq(sessionDir, agentId, harnessSessionId, now());
122
+ if (!state)
123
+ return { wrote: false, reason: "no-state" };
124
+ const row = buildJournalRow(agentId, state, plan, now());
125
+ const makeClient = deps.makeClient ?? defaultClientFactory;
126
+ try {
127
+ const client = await makeClient(agentId);
128
+ await withTimeout(Promise.resolve(client.request("PUT", `/Memory/${row.id}`, row)), resolveContinuityTimeoutMs(env));
129
+ return { wrote: true, reason: "written" };
130
+ }
131
+ catch (err) {
132
+ // Fail-open: Flair unreachable, timeout, or the #1261 guard's 400 — one
133
+ // debug-level line, no retry, never a non-zero exit. The agent's turn is
134
+ // never blocked by its own journal.
135
+ const detail = err instanceof Error ? err.message : String(err);
136
+ warn(`journal write skipped (${detail.slice(0, 200)})`);
137
+ return { wrote: false, reason: "write-failed" };
138
+ }
139
+ }
140
+ /** Entry point. Reads stdin, captures, exits 0. Prints NOTHING to stdout —
141
+ * a PostToolUse/Stop hook's stdout is harness-interpreted surface, and this
142
+ * hook has nothing to say to it. */
143
+ async function main() {
144
+ // Probe mode (flair#1007 pattern): being reached is the whole answer.
145
+ // Exits BEFORE reading stdin and before any filesystem or network touch —
146
+ // a probe must cost nothing and change nothing (especially no seq burn).
147
+ if (isProbeMode())
148
+ return;
149
+ try {
150
+ stripInterpolationLiteralsFromEnv();
151
+ await runCapture(await readStdin());
152
+ }
153
+ catch {
154
+ // Swallow everything — fail-open is the contract.
155
+ }
156
+ }
157
+ const importMeta = import.meta;
158
+ const isMain = importMeta.main === true ||
159
+ (typeof process !== "undefined" &&
160
+ process.argv[1] != null &&
161
+ import.meta.url === `file://${process.argv[1]}`);
162
+ if (isMain) {
163
+ void main().catch(() => { });
164
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Continuity core (flair#1257 slice 2) — the shared logic behind the Claude
3
+ * Code continuity hook adapter: the ephemeral session journal (write side,
4
+ * `flair-continuity-capture`) and the agent-pull resume path grown into
5
+ * `flair-session-start`.
6
+ *
7
+ * Design record: flair#1257 ("FINAL design (K&S ruled)" + the merged slice-2
8
+ * acceptance scenarios + Sherlock's capture-content rulings). The shape in one
9
+ * paragraph: continuity is the EXISTING `ephemeral` Memory tier (24h TTL,
10
+ * private-only — the #1261 server guard refuses ephemeral+shared), auto-written
11
+ * by harness hooks (PostToolUse/Stop), and resumed by agent-pull: SessionStart
12
+ * emits at most ONE hint line ("N entries from your previous session…"), never
13
+ * journal content. Promotion to durable memory is REM distillation's job
14
+ * (#1205, slice 3) — nothing here summarizes or promotes.
15
+ *
16
+ * CAPTURE DISCIPLINE (Sherlock-ruled, binding — do not drift)
17
+ * -----------------------------------------------------------
18
+ * - PostToolUse journals ONLY the mutating-tool allowlist (MUTATING_TOOLS
19
+ * below). Read-only tools journal nothing — their results are
20
+ * world-recoverable, and journaling them would copy tool payloads into a
21
+ * store with cross-session readback.
22
+ * - Bash: journal the `description` field ONLY — NEVER the command string.
23
+ * Argv is exactly where secrets ride; a journal that copies command lines
24
+ * re-creates the transcript-leak class. No description ⇒ the literal
25
+ * "bash: (no description)", never a fallback to the command.
26
+ * - Write/Edit/NotebookEdit: file path only — never content, never diffs.
27
+ * - Stop: a dedicated summary/intent field from the hook payload when present,
28
+ * else the final assistant text — either way HARD-bounded (see
29
+ * CAPTURE_BOUND_CHARS). Empty text ⇒ no journal (never a placeholder, never
30
+ * a synthesis from tool results).
31
+ * - One row max per hook fire. Never the raw hook JSON. Never tool_response.
32
+ * No secret-scrubbing attempts — the bound plus the already-user-visible
33
+ * property of assistant prose IS the control; pattern-matching secrets is a
34
+ * losing game and would only add false confidence.
35
+ *
36
+ * ROW SHAPE (per the merged acceptance set)
37
+ * -----------------------------------------
38
+ * durability: "ephemeral", visibility: "private" (EXPLICIT — defense in
39
+ * depth above the #1261 guard, never the durability-keyed default),
40
+ * tags: ["adk:continuity:<sessionId>"], sessionId, and
41
+ * meta: { seq, processUUID, sessionId, hook, tool? } — seq is a monotonic
42
+ * per-process counter (ordering insurance over createdAt alone), processUUID
43
+ * disambiguates concurrent processes sharing one agent identity.
44
+ *
45
+ * LOCAL FILES (never journal content — IDs and counters only)
46
+ * -----------------------------------------------------------
47
+ * - Pointer file <dir>/<agentId>.current (0600, dir 0700)
48
+ * { sessionId, processUUID, updatedAt } — the last-known session for this
49
+ * agent identity; the resume fast path. Rotated by SessionStart.
50
+ * - State file <dir>/<agentId>.<harnessSessionId>.state.json (0600)
51
+ * { sessionId, processUUID, seq, … } — seeded by SessionStart, read and
52
+ * seq-incremented (atomically, tmp+rename) by every capture fire. Keyed by
53
+ * the HARNESS session id so concurrent processes never share a state file
54
+ * (scenario S8) and compaction — same harness session id — finds the same
55
+ * file untouched (scenario S7: compaction ≠ restart; no rotation, no new
56
+ * sessionId).
57
+ *
58
+ * FAIL-OPEN THROUGHOUT: continuity is a recovery aid, not a correctness gate.
59
+ * Flair unreachable, a #1261 guard 400, a malformed payload, a missing state
60
+ * file — every failure degrades to "journal nothing / hint nothing" and never
61
+ * blocks the agent's turn or boot.
62
+ */
63
+ /** Tag prefix for journal rows — `adk:continuity:<sessionId>`. One tag, which
64
+ * is exactly the shape #1205's REM `scope:"tagged"` distillation consumes. */
65
+ export declare const CONTINUITY_TAG_PREFIX = "adk:continuity:";
66
+ /**
67
+ * THE capture bound, in characters — applied as a HARD truncate (with a
68
+ * visible ellipsis) to EVERY journal content line, not just the Stop excerpt.
69
+ * Sherlock ruled a single uniform bound ("a bound that varies by content type
70
+ * is hard to audit — 400 chars, hard truncate, every time").
71
+ *
72
+ * This bound is LOAD-BEARING, not cosmetic: it is what keeps a Stop excerpt a
73
+ * summary instead of a dump, and it is the control that makes the capture
74
+ * class acceptable at all. Raising it — or "capturing the full final text" —
75
+ * is a security REGRESSION, not an enhancement.
76
+ */
77
+ export declare const CAPTURE_BOUND_CHARS = 400;
78
+ /**
79
+ * The mutating-tool allowlist — a CLOSED set; anything not listed journals
80
+ * nothing (fail-closed). Why each member is here and the notable exclusions:
81
+ * Write/Edit/NotebookEdit mutate file/cell state (NotebookEdit is included
82
+ * precisely because a cell edit is a state mutation, not a read); Bash can
83
+ * mutate anything. Read/Grep/Glob/WebFetch/WebSearch and every other
84
+ * read-only tool are EXCLUDED because their results are world-recoverable —
85
+ * the agent can simply re-observe them, and journaling them would copy tool
86
+ * payloads into the journal.
87
+ */
88
+ export declare const MUTATING_TOOLS: readonly ["Write", "Edit", "NotebookEdit", "Bash"];
89
+ export type MutatingTool = (typeof MUTATING_TOOLS)[number];
90
+ /** Default resume-search page — older context is distillation's job (#1205). */
91
+ export declare const RESUME_SEARCH_LIMIT = 50;
92
+ /** Fallback (agentId-wide) search page — bounded superset of one session. */
93
+ export declare const FALLBACK_SEARCH_LIMIT = 200;
94
+ /** Journal-write timeout (ms): the hook must never make the agent wait on a
95
+ * slow Flair. Overridable via FLAIR_CONTINUITY_TIMEOUT_MS, clamped below. */
96
+ export declare const DEFAULT_CONTINUITY_TIMEOUT_MS = 2000;
97
+ export declare function isSafeFileId(value: unknown): value is string;
98
+ export declare function resolveContinuityTimeoutMs(env?: Record<string, string | undefined>): number;
99
+ /** Where pointer + state files live. FLAIR_SESSION_DIR overrides for tests
100
+ * (homeOverride discipline — no test ever touches the real ~/.flair). */
101
+ export declare function resolveSessionDir(env?: Record<string, string | undefined>): string;
102
+ export declare function pointerPath(sessionDir: string, agentId: string): string;
103
+ export declare function statePath(sessionDir: string, agentId: string, harnessSessionId: string): string;
104
+ export interface SessionPointer {
105
+ sessionId: string;
106
+ processUUID: string;
107
+ updatedAt: string;
108
+ }
109
+ export interface SessionState {
110
+ sessionId: string;
111
+ processUUID: string;
112
+ seq: number;
113
+ agentId: string;
114
+ harnessSessionId: string;
115
+ updatedAt: string;
116
+ }
117
+ /** Read + parse the pointer file. null on ANY problem (missing, unreadable,
118
+ * malformed, wrong shape) — a bad pointer degrades to the fallback search,
119
+ * never to an error. */
120
+ export declare function readPointer(sessionDir: string, agentId: string): SessionPointer | null;
121
+ /** Read + parse a state file. null on ANY problem — capture then journals
122
+ * nothing (continuity wasn't seeded for this harness session). */
123
+ export declare function readState(sessionDir: string, agentId: string, harnessSessionId: string): SessionState | null;
124
+ /**
125
+ * Mint a fresh sessionId + processUUID, seed the per-harness-session state
126
+ * file (seq 0) and rotate the pointer file to the new session. Called by the
127
+ * SessionStart hook on startup/resume/clear — NEVER on compaction (the caller
128
+ * gates on `source`; compaction keeps the same harness session id, so the same
129
+ * state file — and therefore the same sessionId — stays in place untouched,
130
+ * which is what makes scenario S7 hold by construction).
131
+ *
132
+ * Throws on fs failure — the caller treats that as "continuity unavailable"
133
+ * and proceeds (fail-open), it never propagates out of the hook.
134
+ */
135
+ export declare function seedSession(sessionDir: string, agentId: string, harnessSessionId: string, now?: Date): SessionState;
136
+ /**
137
+ * Atomically increment the state file's seq and return the NEW state (the one
138
+ * whose seq this capture fire owns). Write-to-temp + rename so a concurrent
139
+ * reader never sees a torn file. Returns null on any failure — the caller
140
+ * journals nothing rather than journaling with a wrong/duplicate seq.
141
+ *
142
+ * The seq is consumed BEFORE the network write, so even a failed write burns
143
+ * its seq — gaps are fine, non-monotonicity is not.
144
+ */
145
+ export declare function bumpSeq(sessionDir: string, agentId: string, harnessSessionId: string, now?: Date): SessionState | null;
146
+ /** Subset of the Claude Code hook payload the capture bin reads. It extracts
147
+ * ONLY these fields — the raw hook JSON (tool_input.command, tool_response,
148
+ * transcript_path, …) is NEVER stored or forwarded. */
149
+ export interface CaptureHookInput {
150
+ hook_event_name?: unknown;
151
+ session_id?: unknown;
152
+ tool_name?: unknown;
153
+ tool_input?: unknown;
154
+ /** Stop payloads: the final assistant message text, when the harness
155
+ * provides it. Assistant-chosen prose — already user-visible. */
156
+ last_assistant_message?: unknown;
157
+ /** A dedicated summary/intent field, when the harness provides one —
158
+ * preferred over raw final text (intent-class by construction). */
159
+ summary?: unknown;
160
+ [key: string]: unknown;
161
+ }
162
+ export interface CapturePlan {
163
+ hook: "PostToolUse" | "Stop";
164
+ tool?: MutatingTool;
165
+ /** The full journal content line — already hard-bounded. */
166
+ content: string;
167
+ }
168
+ /** HARD truncate at CAPTURE_BOUND_CHARS with a visible ellipsis, so a reader
169
+ * knows the excerpt is incomplete and never acts on a cut sentence as if it
170
+ * were whole. See CAPTURE_BOUND_CHARS — the bound is load-bearing. */
171
+ export declare function hardBound(text: string): string;
172
+ /**
173
+ * Pure capture decision: hook payload → at most one journal line, or null for
174
+ * "journal nothing" (read-only tool, empty Stop text, unknown/missing fields).
175
+ * This function IS the capture discipline — see the module doc; every branch
176
+ * below maps to a Sherlock-ruled rule.
177
+ */
178
+ export declare function planCapture(input: CaptureHookInput): CapturePlan | null;
179
+ export declare function continuityTag(sessionId: string): string;
180
+ export interface JournalRow {
181
+ id: string;
182
+ agentId: string;
183
+ content: string;
184
+ type: "session";
185
+ durability: "ephemeral";
186
+ /** EXPLICIT on every write — never the durability-keyed default. Hook-side
187
+ * half of the #1261 defense-in-depth pair (server guard is the other). */
188
+ visibility: "private";
189
+ tags: string[];
190
+ sessionId: string;
191
+ meta: {
192
+ seq: number;
193
+ processUUID: string;
194
+ sessionId: string;
195
+ hook: string;
196
+ tool?: string;
197
+ };
198
+ createdAt: string;
199
+ }
200
+ export declare function buildJournalRow(agentId: string, state: SessionState, plan: CapturePlan, now?: Date): JournalRow;
201
+ /** Minimal client surface both hook binaries depend on (eases testing —
202
+ * structurally satisfied by the real FlairClient). */
203
+ export interface ContinuityClient {
204
+ request<T = unknown>(method: string, path: string, body?: unknown): Promise<T>;
205
+ }
206
+ export interface ResumeEntry {
207
+ id: string;
208
+ seq: number | null;
209
+ processUUID: string | null;
210
+ sessionId: string | null;
211
+ createdAt: string;
212
+ }
213
+ export interface ResumeResult {
214
+ /** The prior session's journal entries, seq-ordered — ONE process's entries
215
+ * only, never an interleave of two processUUIDs. */
216
+ entries: ResumeEntry[];
217
+ /** The session those entries belong to (for the hint's search tag). */
218
+ sessionId: string | null;
219
+ }
220
+ /**
221
+ * Resume discovery, both layers as ruled on the issue:
222
+ *
223
+ * FAST PATH — the pointer file named a prior session: search exactly that
224
+ * session's tag. A session's rows are all one process's by construction
225
+ * (sessionId is minted per process), so the tag IS the process filter.
226
+ *
227
+ * FALLBACK — no (readable) pointer: agentId-wide ephemeral search,
228
+ * disambiguated by the processUUID carried in row meta. Entries are grouped
229
+ * by processUUID and ONLY the most recent group is returned — a resume
230
+ * reconstruction MUST NEVER interleave entries from two distinct
231
+ * processUUIDs (scenario S8; two live processes sharing an identity).
232
+ * Rows without a processUUID cannot be attributed and are skipped.
233
+ *
234
+ * Every failure (Flair down, auth error, malformed response) resolves to zero
235
+ * entries — the caller then emits no hint. Never throws.
236
+ */
237
+ export declare function discoverResume(client: ContinuityClient, agentId: string, pointer: SessionPointer | null, now?: Date): Promise<ResumeResult>;
238
+ /**
239
+ * The resume hint — at most ONE line, informational, agent-pull (scenario
240
+ * S10): it names the count and the search tag, and NEVER carries journal
241
+ * content (not even summarized). Zero entries ⇒ null ⇒ the hook emits no hint
242
+ * at all — an empty journal is normal operation, not a warning.
243
+ */
244
+ export declare function buildResumeHint(result: ResumeResult): string | null;
245
+ /** The SessionStart payload fields the boot path reads. The "how did this
246
+ * session start" discriminator has appeared as both `source` and
247
+ * `how_started` across harness doc generations — read either. */
248
+ export interface ContinuityBootInput {
249
+ source?: unknown;
250
+ how_started?: unknown;
251
+ session_id?: unknown;
252
+ [key: string]: unknown;
253
+ }
254
+ export interface ContinuityBoot {
255
+ active: boolean;
256
+ priorPointer: SessionPointer | null;
257
+ }
258
+ /**
259
+ * Filesystem half of the resume path (pointer read → mint → rotate → seed the
260
+ * capture state file), called by the SessionStart hook. Pure local work —
261
+ * runs regardless of Flair reachability so capture always has its state file.
262
+ * Never throws; any fs failure degrades to "resume hint may still fire,
263
+ * capture won't" — fail-open in both directions.
264
+ *
265
+ * COMPACTION IS NOT A RESTART (scenario S7): when the harness reports the
266
+ * session start came from compaction, NOTHING runs — no pointer read, no
267
+ * rotation, no state-file touch. The harness session id is unchanged across
268
+ * compaction, so the capture hook keeps finding the same state file and the
269
+ * same sessionId; the journal never fragments.
270
+ */
271
+ export declare function prepareContinuityBoot(input: ContinuityBootInput, agentId: string, env?: Record<string, string | undefined>): ContinuityBoot;
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Continuity core (flair#1257 slice 2) — the shared logic behind the Claude
3
+ * Code continuity hook adapter: the ephemeral session journal (write side,
4
+ * `flair-continuity-capture`) and the agent-pull resume path grown into
5
+ * `flair-session-start`.
6
+ *
7
+ * Design record: flair#1257 ("FINAL design (K&S ruled)" + the merged slice-2
8
+ * acceptance scenarios + Sherlock's capture-content rulings). The shape in one
9
+ * paragraph: continuity is the EXISTING `ephemeral` Memory tier (24h TTL,
10
+ * private-only — the #1261 server guard refuses ephemeral+shared), auto-written
11
+ * by harness hooks (PostToolUse/Stop), and resumed by agent-pull: SessionStart
12
+ * emits at most ONE hint line ("N entries from your previous session…"), never
13
+ * journal content. Promotion to durable memory is REM distillation's job
14
+ * (#1205, slice 3) — nothing here summarizes or promotes.
15
+ *
16
+ * CAPTURE DISCIPLINE (Sherlock-ruled, binding — do not drift)
17
+ * -----------------------------------------------------------
18
+ * - PostToolUse journals ONLY the mutating-tool allowlist (MUTATING_TOOLS
19
+ * below). Read-only tools journal nothing — their results are
20
+ * world-recoverable, and journaling them would copy tool payloads into a
21
+ * store with cross-session readback.
22
+ * - Bash: journal the `description` field ONLY — NEVER the command string.
23
+ * Argv is exactly where secrets ride; a journal that copies command lines
24
+ * re-creates the transcript-leak class. No description ⇒ the literal
25
+ * "bash: (no description)", never a fallback to the command.
26
+ * - Write/Edit/NotebookEdit: file path only — never content, never diffs.
27
+ * - Stop: a dedicated summary/intent field from the hook payload when present,
28
+ * else the final assistant text — either way HARD-bounded (see
29
+ * CAPTURE_BOUND_CHARS). Empty text ⇒ no journal (never a placeholder, never
30
+ * a synthesis from tool results).
31
+ * - One row max per hook fire. Never the raw hook JSON. Never tool_response.
32
+ * No secret-scrubbing attempts — the bound plus the already-user-visible
33
+ * property of assistant prose IS the control; pattern-matching secrets is a
34
+ * losing game and would only add false confidence.
35
+ *
36
+ * ROW SHAPE (per the merged acceptance set)
37
+ * -----------------------------------------
38
+ * durability: "ephemeral", visibility: "private" (EXPLICIT — defense in
39
+ * depth above the #1261 guard, never the durability-keyed default),
40
+ * tags: ["adk:continuity:<sessionId>"], sessionId, and
41
+ * meta: { seq, processUUID, sessionId, hook, tool? } — seq is a monotonic
42
+ * per-process counter (ordering insurance over createdAt alone), processUUID
43
+ * disambiguates concurrent processes sharing one agent identity.
44
+ *
45
+ * LOCAL FILES (never journal content — IDs and counters only)
46
+ * -----------------------------------------------------------
47
+ * - Pointer file <dir>/<agentId>.current (0600, dir 0700)
48
+ * { sessionId, processUUID, updatedAt } — the last-known session for this
49
+ * agent identity; the resume fast path. Rotated by SessionStart.
50
+ * - State file <dir>/<agentId>.<harnessSessionId>.state.json (0600)
51
+ * { sessionId, processUUID, seq, … } — seeded by SessionStart, read and
52
+ * seq-incremented (atomically, tmp+rename) by every capture fire. Keyed by
53
+ * the HARNESS session id so concurrent processes never share a state file
54
+ * (scenario S8) and compaction — same harness session id — finds the same
55
+ * file untouched (scenario S7: compaction ≠ restart; no rotation, no new
56
+ * sessionId).
57
+ *
58
+ * FAIL-OPEN THROUGHOUT: continuity is a recovery aid, not a correctness gate.
59
+ * Flair unreachable, a #1261 guard 400, a malformed payload, a missing state
60
+ * file — every failure degrades to "journal nothing / hint nothing" and never
61
+ * blocks the agent's turn or boot.
62
+ */
63
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
64
+ import { homedir } from "node:os";
65
+ import { join } from "node:path";
66
+ import { randomUUID } from "node:crypto";
67
+ // ── constants ───────────────────────────────────────────────────────────────
68
+ /** Tag prefix for journal rows — `adk:continuity:<sessionId>`. One tag, which
69
+ * is exactly the shape #1205's REM `scope:"tagged"` distillation consumes. */
70
+ export const CONTINUITY_TAG_PREFIX = "adk:continuity:";
71
+ /**
72
+ * THE capture bound, in characters — applied as a HARD truncate (with a
73
+ * visible ellipsis) to EVERY journal content line, not just the Stop excerpt.
74
+ * Sherlock ruled a single uniform bound ("a bound that varies by content type
75
+ * is hard to audit — 400 chars, hard truncate, every time").
76
+ *
77
+ * This bound is LOAD-BEARING, not cosmetic: it is what keeps a Stop excerpt a
78
+ * summary instead of a dump, and it is the control that makes the capture
79
+ * class acceptable at all. Raising it — or "capturing the full final text" —
80
+ * is a security REGRESSION, not an enhancement.
81
+ */
82
+ export const CAPTURE_BOUND_CHARS = 400;
83
+ /**
84
+ * The mutating-tool allowlist — a CLOSED set; anything not listed journals
85
+ * nothing (fail-closed). Why each member is here and the notable exclusions:
86
+ * Write/Edit/NotebookEdit mutate file/cell state (NotebookEdit is included
87
+ * precisely because a cell edit is a state mutation, not a read); Bash can
88
+ * mutate anything. Read/Grep/Glob/WebFetch/WebSearch and every other
89
+ * read-only tool are EXCLUDED because their results are world-recoverable —
90
+ * the agent can simply re-observe them, and journaling them would copy tool
91
+ * payloads into the journal.
92
+ */
93
+ export const MUTATING_TOOLS = ["Write", "Edit", "NotebookEdit", "Bash"];
94
+ /** Default resume-search page — older context is distillation's job (#1205). */
95
+ export const RESUME_SEARCH_LIMIT = 50;
96
+ /** Fallback (agentId-wide) search page — bounded superset of one session. */
97
+ export const FALLBACK_SEARCH_LIMIT = 200;
98
+ /** Journal-write timeout (ms): the hook must never make the agent wait on a
99
+ * slow Flair. Overridable via FLAIR_CONTINUITY_TIMEOUT_MS, clamped below. */
100
+ export const DEFAULT_CONTINUITY_TIMEOUT_MS = 2000;
101
+ const CONTINUITY_TIMEOUT_FLOOR_MS = 250;
102
+ const CONTINUITY_TIMEOUT_CEILING_MS = 10_000;
103
+ /** Identifier shape for everything interpolated into a session-dir FILENAME
104
+ * (agentId, harness session id). No `/`, no `\`, no whitespace — traversal
105
+ * is impossible by shape; length-capped so a hostile hook payload can't
106
+ * manufacture pathological filenames. */
107
+ const SAFE_FILE_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
108
+ export function isSafeFileId(value) {
109
+ return typeof value === "string" && SAFE_FILE_ID_RE.test(value);
110
+ }
111
+ export function resolveContinuityTimeoutMs(env = process.env) {
112
+ const raw = env.FLAIR_CONTINUITY_TIMEOUT_MS;
113
+ const parsed = raw != null ? Number(raw) : NaN;
114
+ return Number.isFinite(parsed) && parsed >= CONTINUITY_TIMEOUT_FLOOR_MS && parsed <= CONTINUITY_TIMEOUT_CEILING_MS
115
+ ? parsed
116
+ : DEFAULT_CONTINUITY_TIMEOUT_MS;
117
+ }
118
+ // ── session dir / pointer / state files ─────────────────────────────────────
119
+ /** Where pointer + state files live. FLAIR_SESSION_DIR overrides for tests
120
+ * (homeOverride discipline — no test ever touches the real ~/.flair). */
121
+ export function resolveSessionDir(env = process.env) {
122
+ const override = env.FLAIR_SESSION_DIR;
123
+ if (typeof override === "string" && override.trim() !== "")
124
+ return override;
125
+ return join(homedir(), ".flair", "session");
126
+ }
127
+ export function pointerPath(sessionDir, agentId) {
128
+ return join(sessionDir, `${agentId}.current`);
129
+ }
130
+ export function statePath(sessionDir, agentId, harnessSessionId) {
131
+ return join(sessionDir, `${agentId}.${harnessSessionId}.state.json`);
132
+ }
133
+ /** 0600/0700 — the files carry session identifiers (never journal content),
134
+ * but they are still per-agent working state; keep them owner-only. */
135
+ function writeFilePrivate(path, data) {
136
+ writeFileSync(path, data, { mode: 0o600 });
137
+ // writeFileSync's mode only applies on CREATE — an existing file keeps its
138
+ // old bits, so re-assert.
139
+ chmodSync(path, 0o600);
140
+ }
141
+ function ensureSessionDir(sessionDir) {
142
+ mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
143
+ }
144
+ /** Read + parse the pointer file. null on ANY problem (missing, unreadable,
145
+ * malformed, wrong shape) — a bad pointer degrades to the fallback search,
146
+ * never to an error. */
147
+ export function readPointer(sessionDir, agentId) {
148
+ try {
149
+ const raw = readFileSync(pointerPath(sessionDir, agentId), "utf-8");
150
+ const parsed = JSON.parse(raw);
151
+ if (parsed && typeof parsed.sessionId === "string" && parsed.sessionId !== "") {
152
+ return {
153
+ sessionId: parsed.sessionId,
154
+ processUUID: typeof parsed.processUUID === "string" ? parsed.processUUID : "",
155
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : "",
156
+ };
157
+ }
158
+ return null;
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ }
164
+ /** Read + parse a state file. null on ANY problem — capture then journals
165
+ * nothing (continuity wasn't seeded for this harness session). */
166
+ export function readState(sessionDir, agentId, harnessSessionId) {
167
+ try {
168
+ const raw = readFileSync(statePath(sessionDir, agentId, harnessSessionId), "utf-8");
169
+ const parsed = JSON.parse(raw);
170
+ if (parsed &&
171
+ typeof parsed.sessionId === "string" && parsed.sessionId !== "" &&
172
+ typeof parsed.processUUID === "string" && parsed.processUUID !== "" &&
173
+ typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) {
174
+ return {
175
+ sessionId: parsed.sessionId,
176
+ processUUID: parsed.processUUID,
177
+ seq: parsed.seq,
178
+ agentId: typeof parsed.agentId === "string" ? parsed.agentId : agentId,
179
+ harnessSessionId: typeof parsed.harnessSessionId === "string" ? parsed.harnessSessionId : harnessSessionId,
180
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : "",
181
+ };
182
+ }
183
+ return null;
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ }
189
+ /**
190
+ * Mint a fresh sessionId + processUUID, seed the per-harness-session state
191
+ * file (seq 0) and rotate the pointer file to the new session. Called by the
192
+ * SessionStart hook on startup/resume/clear — NEVER on compaction (the caller
193
+ * gates on `source`; compaction keeps the same harness session id, so the same
194
+ * state file — and therefore the same sessionId — stays in place untouched,
195
+ * which is what makes scenario S7 hold by construction).
196
+ *
197
+ * Throws on fs failure — the caller treats that as "continuity unavailable"
198
+ * and proceeds (fail-open), it never propagates out of the hook.
199
+ */
200
+ export function seedSession(sessionDir, agentId, harnessSessionId, now = new Date()) {
201
+ ensureSessionDir(sessionDir);
202
+ const state = {
203
+ sessionId: `cs-${randomUUID()}`,
204
+ processUUID: randomUUID(),
205
+ seq: 0,
206
+ agentId,
207
+ harnessSessionId,
208
+ updatedAt: now.toISOString(),
209
+ };
210
+ writeFilePrivate(statePath(sessionDir, agentId, harnessSessionId), JSON.stringify(state, null, 2) + "\n");
211
+ const pointer = { sessionId: state.sessionId, processUUID: state.processUUID, updatedAt: state.updatedAt };
212
+ writeFilePrivate(pointerPath(sessionDir, agentId), JSON.stringify(pointer, null, 2) + "\n");
213
+ return state;
214
+ }
215
+ /**
216
+ * Atomically increment the state file's seq and return the NEW state (the one
217
+ * whose seq this capture fire owns). Write-to-temp + rename so a concurrent
218
+ * reader never sees a torn file. Returns null on any failure — the caller
219
+ * journals nothing rather than journaling with a wrong/duplicate seq.
220
+ *
221
+ * The seq is consumed BEFORE the network write, so even a failed write burns
222
+ * its seq — gaps are fine, non-monotonicity is not.
223
+ */
224
+ export function bumpSeq(sessionDir, agentId, harnessSessionId, now = new Date()) {
225
+ const current = readState(sessionDir, agentId, harnessSessionId);
226
+ if (!current)
227
+ return null;
228
+ const next = { ...current, seq: current.seq + 1, updatedAt: now.toISOString() };
229
+ const finalPath = statePath(sessionDir, agentId, harnessSessionId);
230
+ const tmpPath = `${finalPath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
231
+ try {
232
+ writeFilePrivate(tmpPath, JSON.stringify(next, null, 2) + "\n");
233
+ renameSync(tmpPath, finalPath);
234
+ return next;
235
+ }
236
+ catch {
237
+ return null;
238
+ }
239
+ }
240
+ /** HARD truncate at CAPTURE_BOUND_CHARS with a visible ellipsis, so a reader
241
+ * knows the excerpt is incomplete and never acts on a cut sentence as if it
242
+ * were whole. See CAPTURE_BOUND_CHARS — the bound is load-bearing. */
243
+ export function hardBound(text) {
244
+ if (text.length <= CAPTURE_BOUND_CHARS)
245
+ return text;
246
+ return `${text.slice(0, CAPTURE_BOUND_CHARS)}…`;
247
+ }
248
+ function asNonEmptyString(value) {
249
+ return typeof value === "string" && value.trim() !== "" ? value : null;
250
+ }
251
+ /**
252
+ * Pure capture decision: hook payload → at most one journal line, or null for
253
+ * "journal nothing" (read-only tool, empty Stop text, unknown/missing fields).
254
+ * This function IS the capture discipline — see the module doc; every branch
255
+ * below maps to a Sherlock-ruled rule.
256
+ */
257
+ export function planCapture(input) {
258
+ const hook = input.hook_event_name;
259
+ if (hook === "PostToolUse") {
260
+ const tool = input.tool_name;
261
+ // Closed allowlist, fail-closed: an unknown or absent tool name journals
262
+ // nothing. Read-only tools land here by design (world-recoverable).
263
+ if (typeof tool !== "string" || !MUTATING_TOOLS.includes(tool))
264
+ return null;
265
+ const toolInput = (input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {});
266
+ if (tool === "Bash") {
267
+ // description ONLY — NEVER the command string, and never a fallback to
268
+ // it. The command is argv: exactly where our leak history lives.
269
+ const description = asNonEmptyString(toolInput.description);
270
+ return { hook, tool, content: hardBound(description ? `bash: ${description}` : "bash: (no description)") };
271
+ }
272
+ // Write / Edit / NotebookEdit: file path only — never content, never
273
+ // diffs. NotebookEdit's path key has appeared as both `notebook_path` and
274
+ // `file_path` across harness doc generations — accept either; both are
275
+ // paths, neither is content.
276
+ const pathField = tool === "NotebookEdit" ? (toolInput.notebook_path ?? toolInput.file_path) : toolInput.file_path;
277
+ const path = asNonEmptyString(pathField);
278
+ const label = tool === "Write" ? "write" : tool === "Edit" ? "edit" : "notebook-edit";
279
+ return { hook, tool: tool, content: hardBound(path ? `${label}: ${path}` : `${label}: (no file path)`) };
280
+ }
281
+ if (hook === "Stop") {
282
+ // Prefer a dedicated summary/intent field when the payload carries one
283
+ // (intent-class by construction beats prose the agent happened to emit);
284
+ // else the final assistant text. Both are assistant-chosen, user-visible
285
+ // prose — never tool payloads — and both get the same hard bound.
286
+ const source = asNonEmptyString(input.summary) ?? asNonEmptyString(input.last_assistant_message);
287
+ if (!source)
288
+ return null; // tool-only turn ⇒ no journal, no placeholder
289
+ return { hook, content: hardBound(`stop: ${source.trim()}`) };
290
+ }
291
+ return null;
292
+ }
293
+ // ── journal row construction ────────────────────────────────────────────────
294
+ export function continuityTag(sessionId) {
295
+ return `${CONTINUITY_TAG_PREFIX}${sessionId}`;
296
+ }
297
+ export function buildJournalRow(agentId, state, plan, now = new Date()) {
298
+ const meta = {
299
+ seq: state.seq,
300
+ processUUID: state.processUUID,
301
+ sessionId: state.sessionId,
302
+ hook: plan.hook,
303
+ };
304
+ if (plan.tool)
305
+ meta.tool = plan.tool;
306
+ return {
307
+ id: `${agentId}-${randomUUID()}`,
308
+ agentId,
309
+ content: plan.content,
310
+ type: "session",
311
+ durability: "ephemeral",
312
+ visibility: "private",
313
+ tags: [continuityTag(state.sessionId)],
314
+ sessionId: state.sessionId,
315
+ meta,
316
+ createdAt: now.toISOString(),
317
+ };
318
+ }
319
+ function searchConditions(agentId, extra, limit) {
320
+ return {
321
+ operator: "and",
322
+ conditions: [
323
+ { search_attribute: "agentId", search_type: "equals", search_value: agentId },
324
+ { search_attribute: "durability", search_type: "equals", search_value: "ephemeral" },
325
+ ...extra,
326
+ ],
327
+ get_attributes: ["*"],
328
+ limit,
329
+ };
330
+ }
331
+ function rowsFrom(result) {
332
+ if (Array.isArray(result))
333
+ return result;
334
+ const wrapped = result?.results;
335
+ return Array.isArray(wrapped) ? wrapped : [];
336
+ }
337
+ /** Expired rows are excluded HERE, not just by MemoryMaintenance — the reap is
338
+ * asynchronous, so a row whose expiresAt is past may still be in storage
339
+ * (scenario S4: it must never reach the agent regardless). */
340
+ function isLive(row, now) {
341
+ if (typeof row.expiresAt !== "string" || row.expiresAt === "")
342
+ return true;
343
+ const expiry = Date.parse(row.expiresAt);
344
+ return !Number.isFinite(expiry) || expiry > now.getTime();
345
+ }
346
+ function continuitySessionOf(row) {
347
+ const meta = row.meta;
348
+ if (meta && typeof meta.sessionId === "string" && meta.sessionId !== "")
349
+ return meta.sessionId;
350
+ if (Array.isArray(row.tags)) {
351
+ for (const tag of row.tags) {
352
+ if (typeof tag === "string" && tag.startsWith(CONTINUITY_TAG_PREFIX)) {
353
+ const sid = tag.slice(CONTINUITY_TAG_PREFIX.length);
354
+ if (sid !== "")
355
+ return sid;
356
+ }
357
+ }
358
+ }
359
+ return null;
360
+ }
361
+ function toEntry(row) {
362
+ const meta = row.meta;
363
+ return {
364
+ id: typeof row.id === "string" ? row.id : "",
365
+ seq: meta && typeof meta.seq === "number" && Number.isFinite(meta.seq) ? meta.seq : null,
366
+ processUUID: meta && typeof meta.processUUID === "string" && meta.processUUID !== "" ? meta.processUUID : null,
367
+ sessionId: continuitySessionOf(row),
368
+ createdAt: typeof row.createdAt === "string" ? row.createdAt : "",
369
+ };
370
+ }
371
+ /** seq ascending (monotonic per process — the primary order), createdAt as
372
+ * tiebreak ONLY (and as the order for legacy rows lacking a seq). */
373
+ function seqOrder(a, b) {
374
+ if (a.seq != null && b.seq != null && a.seq !== b.seq)
375
+ return a.seq - b.seq;
376
+ if (a.createdAt !== b.createdAt)
377
+ return a.createdAt < b.createdAt ? -1 : 1;
378
+ return 0;
379
+ }
380
+ /**
381
+ * Resume discovery, both layers as ruled on the issue:
382
+ *
383
+ * FAST PATH — the pointer file named a prior session: search exactly that
384
+ * session's tag. A session's rows are all one process's by construction
385
+ * (sessionId is minted per process), so the tag IS the process filter.
386
+ *
387
+ * FALLBACK — no (readable) pointer: agentId-wide ephemeral search,
388
+ * disambiguated by the processUUID carried in row meta. Entries are grouped
389
+ * by processUUID and ONLY the most recent group is returned — a resume
390
+ * reconstruction MUST NEVER interleave entries from two distinct
391
+ * processUUIDs (scenario S8; two live processes sharing an identity).
392
+ * Rows without a processUUID cannot be attributed and are skipped.
393
+ *
394
+ * Every failure (Flair down, auth error, malformed response) resolves to zero
395
+ * entries — the caller then emits no hint. Never throws.
396
+ */
397
+ export async function discoverResume(client, agentId, pointer, now = new Date()) {
398
+ try {
399
+ if (pointer) {
400
+ const body = searchConditions(agentId, [{ search_attribute: "tags", search_type: "contains", search_value: continuityTag(pointer.sessionId) }], RESUME_SEARCH_LIMIT);
401
+ const rows = rowsFrom(await client.request("POST", "/Memory/search_by_conditions", body)).filter((r) => isLive(r, now));
402
+ const entries = rows.map(toEntry).sort(seqOrder).slice(0, RESUME_SEARCH_LIMIT);
403
+ return { entries, sessionId: entries.length > 0 ? pointer.sessionId : null };
404
+ }
405
+ const body = searchConditions(agentId, [], FALLBACK_SEARCH_LIMIT);
406
+ const rows = rowsFrom(await client.request("POST", "/Memory/search_by_conditions", body))
407
+ .filter((r) => isLive(r, now))
408
+ .filter((r) => continuitySessionOf(r) !== null);
409
+ const groups = new Map();
410
+ for (const row of rows) {
411
+ const entry = toEntry(row);
412
+ if (!entry.processUUID)
413
+ continue; // unattributable — never guess
414
+ const list = groups.get(entry.processUUID) ?? [];
415
+ list.push(entry);
416
+ groups.set(entry.processUUID, list);
417
+ }
418
+ let best = null;
419
+ let bestLatest = "";
420
+ for (const list of groups.values()) {
421
+ const latest = list.reduce((max, e) => (e.createdAt > max ? e.createdAt : max), "");
422
+ if (best === null || latest > bestLatest) {
423
+ best = list;
424
+ bestLatest = latest;
425
+ }
426
+ }
427
+ if (!best || best.length === 0)
428
+ return { entries: [], sessionId: null };
429
+ const entries = [...best].sort(seqOrder).slice(0, RESUME_SEARCH_LIMIT);
430
+ return { entries, sessionId: entries[0]?.sessionId ?? null };
431
+ }
432
+ catch {
433
+ return { entries: [], sessionId: null };
434
+ }
435
+ }
436
+ /**
437
+ * The resume hint — at most ONE line, informational, agent-pull (scenario
438
+ * S10): it names the count and the search tag, and NEVER carries journal
439
+ * content (not even summarized). Zero entries ⇒ null ⇒ the hook emits no hint
440
+ * at all — an empty journal is normal operation, not a warning.
441
+ */
442
+ export function buildResumeHint(result) {
443
+ const n = result.entries.length;
444
+ if (n === 0 || !result.sessionId)
445
+ return null;
446
+ const noun = n === 1 ? "entry" : "entries";
447
+ return `Continuity: ${n} short-term journal ${noun} from your previous session survived — search memory tag "${continuityTag(result.sessionId)}" if you need to recall what dropped from context.`;
448
+ }
449
+ /**
450
+ * Filesystem half of the resume path (pointer read → mint → rotate → seed the
451
+ * capture state file), called by the SessionStart hook. Pure local work —
452
+ * runs regardless of Flair reachability so capture always has its state file.
453
+ * Never throws; any fs failure degrades to "resume hint may still fire,
454
+ * capture won't" — fail-open in both directions.
455
+ *
456
+ * COMPACTION IS NOT A RESTART (scenario S7): when the harness reports the
457
+ * session start came from compaction, NOTHING runs — no pointer read, no
458
+ * rotation, no state-file touch. The harness session id is unchanged across
459
+ * compaction, so the capture hook keeps finding the same state file and the
460
+ * same sessionId; the journal never fragments.
461
+ */
462
+ export function prepareContinuityBoot(input, agentId, env = process.env) {
463
+ const startedFrom = typeof input.source === "string" ? input.source : typeof input.how_started === "string" ? input.how_started : "";
464
+ if (startedFrom === "compact")
465
+ return { active: false, priorPointer: null };
466
+ // Without a harness session id there is nothing to key the capture state
467
+ // file by (manual runs, older harnesses) — stay fully inert.
468
+ if (!isSafeFileId(agentId) || !isSafeFileId(input.session_id))
469
+ return { active: false, priorPointer: null };
470
+ const sessionDir = resolveSessionDir(env);
471
+ const priorPointer = readPointer(sessionDir, agentId);
472
+ try {
473
+ seedSession(sessionDir, agentId, input.session_id);
474
+ }
475
+ catch {
476
+ // Session dir unwritable — capture can't run this session, but a resume
477
+ // hint from the prior pointer is still valid and still cheap.
478
+ }
479
+ return { active: true, priorPointer };
480
+ }
@@ -31,10 +31,13 @@ export declare function readEnvOrUnset(name: string, env?: NodeJS.ProcessEnv): s
31
31
  /**
32
32
  * Connection env vars that flair-client's OWN constructor reads straight from
33
33
  * `process.env` as a fallback (see flair-client/src/client.ts:
34
- * `this.url = config.url ?? process.env.FLAIR_URL ?? DEFAULT_URL`). For these,
35
- * skipping the literal only at flair-mcp's call site is NOT enough: flair-client
36
- * re-reads `process.env` and resurrects the `${...}` literal, defeating its own
37
- * default. Such a literal has to be removed from the process env itself.
34
+ * `this.url = config.url ?? readEnvOrUnset("FLAIR_URL") ?? DEFAULT_URL`). For
35
+ * these, skipping the literal only at flair-mcp's call site was NOT enough:
36
+ * flair-client re-read `process.env` and resurrected the `${...}` literal,
37
+ * defeating its own default. As of flair#1254 flair-client's env fallbacks
38
+ * apply this same literal-as-unset guard themselves, so the strip below is
39
+ * defense-in-depth rather than the only line — kept deliberately (an older
40
+ * flair-client on a consumer's disk does not have #1254).
38
41
  *
39
42
  * Only FLAIR_URL qualifies today: it is re-read by flair-client AND flair-mcp
40
43
  * still constructs a client when it is a literal. FLAIR_AGENT_ID is also re-read
@@ -51,3 +54,21 @@ export declare const ENV_RESURRECTED_BY_FLAIR_CLIENT: readonly ["FLAIR_URL"];
51
54
  * accepts an injectable env for tests. Real (substituted) values are untouched.
52
55
  */
53
56
  export declare function stripInterpolationLiteralsFromEnv(env?: NodeJS.ProcessEnv, names?: readonly string[]): void;
57
+ /**
58
+ * Hook probe mode (flair#1007) — shared by every hook binary this package
59
+ * ships (session-start-hook.ts, continuity-capture-hook.ts). `flair doctor`
60
+ * sets FLAIR_HOOK_PROBE to ask "does this command still resolve and execute?"
61
+ * and a probed binary answers by exiting immediately, before stdin, clients,
62
+ * network or any side effect — being reached at all IS the answer.
63
+ *
64
+ * Lives here (this module has no @tpsdev-ai/flair-client import) so the
65
+ * capture binary can share the ONE definition without pulling the client's
66
+ * built dist into module graphs that must load before it is built (see
67
+ * test/unit's CI-ordering note in test/unit/hook-install.test.ts).
68
+ * session-start-hook.ts re-exports it unchanged.
69
+ *
70
+ * Any non-empty value other than "0" enables it, so `FLAIR_HOOK_PROBE=1` and
71
+ * `FLAIR_HOOK_PROBE=true` both work and an accidentally-empty variable does
72
+ * not.
73
+ */
74
+ export declare function isProbeMode(env?: Record<string, string | undefined>): boolean;
package/dist/env-guard.js CHANGED
@@ -38,10 +38,13 @@ export function readEnvOrUnset(name, env = process.env) {
38
38
  /**
39
39
  * Connection env vars that flair-client's OWN constructor reads straight from
40
40
  * `process.env` as a fallback (see flair-client/src/client.ts:
41
- * `this.url = config.url ?? process.env.FLAIR_URL ?? DEFAULT_URL`). For these,
42
- * skipping the literal only at flair-mcp's call site is NOT enough: flair-client
43
- * re-reads `process.env` and resurrects the `${...}` literal, defeating its own
44
- * default. Such a literal has to be removed from the process env itself.
41
+ * `this.url = config.url ?? readEnvOrUnset("FLAIR_URL") ?? DEFAULT_URL`). For
42
+ * these, skipping the literal only at flair-mcp's call site was NOT enough:
43
+ * flair-client re-read `process.env` and resurrected the `${...}` literal,
44
+ * defeating its own default. As of flair#1254 flair-client's env fallbacks
45
+ * apply this same literal-as-unset guard themselves, so the strip below is
46
+ * defense-in-depth rather than the only line — kept deliberately (an older
47
+ * flair-client on a consumer's disk does not have #1254).
45
48
  *
46
49
  * Only FLAIR_URL qualifies today: it is re-read by flair-client AND flair-mcp
47
50
  * still constructs a client when it is a literal. FLAIR_AGENT_ID is also re-read
@@ -65,3 +68,24 @@ export function stripInterpolationLiteralsFromEnv(env = process.env, names = ENV
65
68
  }
66
69
  }
67
70
  }
71
+ /**
72
+ * Hook probe mode (flair#1007) — shared by every hook binary this package
73
+ * ships (session-start-hook.ts, continuity-capture-hook.ts). `flair doctor`
74
+ * sets FLAIR_HOOK_PROBE to ask "does this command still resolve and execute?"
75
+ * and a probed binary answers by exiting immediately, before stdin, clients,
76
+ * network or any side effect — being reached at all IS the answer.
77
+ *
78
+ * Lives here (this module has no @tpsdev-ai/flair-client import) so the
79
+ * capture binary can share the ONE definition without pulling the client's
80
+ * built dist into module graphs that must load before it is built (see
81
+ * test/unit's CI-ordering note in test/unit/hook-install.test.ts).
82
+ * session-start-hook.ts re-exports it unchanged.
83
+ *
84
+ * Any non-empty value other than "0" enables it, so `FLAIR_HOOK_PROBE=1` and
85
+ * `FLAIR_HOOK_PROBE=true` both work and an accidentally-empty variable does
86
+ * not.
87
+ */
88
+ export function isProbeMode(env = process.env) {
89
+ const raw = env.FLAIR_HOOK_PROBE;
90
+ return typeof raw === "string" && raw !== "" && raw !== "0";
91
+ }
@@ -74,6 +74,7 @@
74
74
  * }
75
75
  */
76
76
  import { type PresencePoster } from "./presence.js";
77
+ import { isProbeMode } from "./env-guard.js";
77
78
  /** Minimal surface of FlairClient this hook depends on (eases testing).
78
79
  * `request` is optional and structurally matches PresencePoster (presence.ts)
79
80
  * — the real FlairClient always has it. When present, this hook also fires a
@@ -90,11 +91,11 @@ interface BootstrapClient extends Partial<PresencePoster> {
90
91
  } | undefined>;
91
92
  }
92
93
  /**
93
- * Probe mode (flair#1007) — see the module doc. Any non-empty value other
94
- * than "0" enables it, so `FLAIR_HOOK_PROBE=1` and `FLAIR_HOOK_PROBE=true`
95
- * both work and an accidentally-empty variable does not.
94
+ * Probe mode (flair#1007) — see the module doc. The predicate itself moved to
95
+ * ./env-guard.ts when the continuity capture binary (flair#1257) started
96
+ * sharing it; re-exported here unchanged so existing importers keep working.
96
97
  */
97
- export declare function isProbeMode(env?: Record<string, string | undefined>): boolean;
98
+ export { isProbeMode };
98
99
  /**
99
100
  * Core hook logic, with injectable dependencies so it can be unit-tested
100
101
  * without a live Flair daemon. Returns the exact string to print to stdout.
@@ -104,4 +105,3 @@ export declare function isProbeMode(env?: Record<string, string | undefined>): b
104
105
  * @param makeClient factory for the bootstrap client (defaults to FlairClient)
105
106
  */
106
107
  export declare function runHook(rawInput: string, makeClient?: (agentId: string) => BootstrapClient): Promise<string>;
107
- export {};
@@ -76,7 +76,8 @@
76
76
  import { FlairClient } from "@tpsdev-ai/flair-client";
77
77
  import { basename } from "node:path";
78
78
  import { deriveActivity, postPresenceSafe, resolvePresenceTimeoutMs } from "./presence.js";
79
- import { readEnvOrUnset, stripInterpolationLiteralsFromEnv } from "./env-guard.js";
79
+ import { isProbeMode, readEnvOrUnset, stripInterpolationLiteralsFromEnv } from "./env-guard.js";
80
+ import { buildResumeHint, discoverResume, prepareContinuityBoot, resolveContinuityTimeoutMs, } from "./continuity.js";
80
81
  /** Claude Code SessionStart additionalContext hard limit (chars). */
81
82
  const MAX_CHARS = 10_000;
82
83
  /** Token budget for the bootstrap call — matches the proven prototype. */
@@ -88,14 +89,11 @@ const TIMEOUT_CEILING_MS = 30_000;
88
89
  /** Empty, inert hook output. Printing this is always a safe no-op. */
89
90
  const NOOP_OUTPUT = "{}";
90
91
  /**
91
- * Probe mode (flair#1007) — see the module doc. Any non-empty value other
92
- * than "0" enables it, so `FLAIR_HOOK_PROBE=1` and `FLAIR_HOOK_PROBE=true`
93
- * both work and an accidentally-empty variable does not.
92
+ * Probe mode (flair#1007) — see the module doc. The predicate itself moved to
93
+ * ./env-guard.ts when the continuity capture binary (flair#1257) started
94
+ * sharing it; re-exported here unchanged so existing importers keep working.
94
95
  */
95
- export function isProbeMode(env = process.env) {
96
- const raw = env.FLAIR_HOOK_PROBE;
97
- return typeof raw === "string" && raw !== "" && raw !== "0";
98
- }
96
+ export { isProbeMode };
99
97
  /** Resolve the bootstrap timeout from env, clamped to a sane range. */
100
98
  function resolveTimeoutMs() {
101
99
  const raw = process.env.FLAIR_HOOK_TIMEOUT_MS;
@@ -182,6 +180,16 @@ export async function runHook(rawInput, makeClient = defaultClientFactory) {
182
180
  const presenceDone = typeof client.request === "function"
183
181
  ? postPresenceSafe(client, deriveActivity({ channel: "claude-code" }), undefined, resolvePresenceTimeoutMs())
184
182
  : Promise.resolve();
183
+ // Continuity resume (flair#1257 slice 2) — the local half (pointer read →
184
+ // mint → rotate → seed capture state) runs unconditionally when applicable;
185
+ // the search half runs CONCURRENTLY with bootstrap below, bounded by its own
186
+ // short timeout, and resolves to null (no hint) on any failure. It needs the
187
+ // signed request() surface; a lightweight bootstrap-only client (tests)
188
+ // skips it entirely.
189
+ const continuity = prepareContinuityBoot(input, agentId);
190
+ const resumeHintDone = continuity.active && typeof client.request === "function"
191
+ ? withTimeout(discoverResume(client, agentId, continuity.priorPointer).then((result) => buildResumeHint(result)), resolveContinuityTimeoutMs()).catch(() => null)
192
+ : Promise.resolve(null);
185
193
  let context = "";
186
194
  try {
187
195
  const res = await withTimeout(Promise.resolve(client.bootstrap({
@@ -192,15 +200,23 @@ export async function runHook(rawInput, makeClient = defaultClientFactory) {
192
200
  context = res && res.context ? String(res.context) : "";
193
201
  }
194
202
  catch {
195
- await presenceDone;
196
- return NOOP_OUTPUT; // flair unreachable / auth error / timeout → no-op
203
+ context = ""; // flair unreachable / auth error / timeout → no bootstrap context
197
204
  }
205
+ const resumeHint = await resumeHintDone;
198
206
  await presenceDone;
199
- if (!context.trim())
207
+ // Combine: bootstrap context first, then AT MOST one continuity hint line.
208
+ // Either piece may be absent; both absent ⇒ the inert no-op output.
209
+ const pieces = [];
210
+ if (context.trim())
211
+ pieces.push(context);
212
+ if (resumeHint)
213
+ pieces.push(resumeHint);
214
+ if (pieces.length === 0)
200
215
  return NOOP_OUTPUT;
201
- if (context.length > MAX_CHARS)
202
- context = context.slice(0, MAX_CHARS);
203
- return hookOutput(context);
216
+ let combined = pieces.join("\n\n");
217
+ if (combined.length > MAX_CHARS)
218
+ combined = combined.slice(0, MAX_CHARS);
219
+ return hookOutput(combined);
204
220
  }
205
221
  /** Default client factory — constructs a real FlairClient from FLAIR_* env,
206
222
  * identical to how src/index.ts builds it. */
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair-mcp",
3
- "version": "0.44.13",
3
+ "version": "0.46.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",
7
7
  "bin": {
8
8
  "flair-mcp": "dist/mcp-shim.cjs",
9
- "flair-session-start": "dist/session-start-hook.js"
9
+ "flair-session-start": "dist/session-start-hook.js",
10
+ "flair-continuity-capture": "dist/continuity-capture-hook.js"
10
11
  },
11
12
  "files": [
12
13
  "dist/",
@@ -17,7 +18,7 @@
17
18
  "build": "tsc --noCheck",
18
19
  "test": "bun test",
19
20
  "prepublishOnly": "npm run build",
20
- "postinstall": "node -e \"try{const{chmodSync,statSync}=require('fs');for(const p of ['dist/mcp-shim.cjs','dist/index.js','dist/session-start-hook.js']){try{if(statSync(p).isFile()){chmodSync(p,0o755);console.error('@tpsdev-ai/flair-mcp: chmod +x ' + p + ' OK')}}catch(e){if(e.code!=='ENOENT')console.error('postinstall warn:',e.message)}}}catch(e){console.error('postinstall warn:',e.message)}\""
21
+ "postinstall": "node -e \"try{const{chmodSync,statSync}=require('fs');for(const p of ['dist/mcp-shim.cjs','dist/index.js','dist/session-start-hook.js','dist/continuity-capture-hook.js']){try{if(statSync(p).isFile()){chmodSync(p,0o755);console.error('@tpsdev-ai/flair-mcp: chmod +x ' + p + ' OK')}}catch(e){if(e.code!=='ENOENT')console.error('postinstall warn:',e.message)}}}catch(e){console.error('postinstall warn:',e.message)}\""
21
22
  },
22
23
  "publishConfig": {
23
24
  "access": "public"
@@ -27,7 +28,7 @@
27
28
  },
28
29
  "dependencies": {
29
30
  "@modelcontextprotocol/sdk": "1.27.1",
30
- "@tpsdev-ai/flair-client": "0.44.13",
31
+ "@tpsdev-ai/flair-client": "0.46.0",
31
32
  "zod": "4.3.6"
32
33
  },
33
34
  "license": "Apache-2.0",