@tpsdev-ai/flair-mcp 0.45.0 → 0.47.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-capture-hook.d.ts +58 -0
- package/dist/continuity-capture-hook.js +164 -0
- package/dist/continuity.d.ts +271 -0
- package/dist/continuity.js +494 -0
- package/dist/env-guard.d.ts +18 -0
- package/dist/env-guard.js +21 -0
- package/dist/session-start-hook.d.ts +5 -5
- package/dist/session-start-hook.js +30 -14
- package/package.json +5 -4
|
@@ -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,494 @@
|
|
|
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
|
+
/**
|
|
320
|
+
* Fetch this agent's own ephemeral rows through the SUPPORTED read surface —
|
|
321
|
+
* `GET /Memory?agentId=<id>` (the same verb the REM nightly runner's snapshot
|
|
322
|
+
* step uses) — then filter client-side.
|
|
323
|
+
*
|
|
324
|
+
* This replaces the original `POST /Memory/search_by_conditions` read
|
|
325
|
+
* (flair#1257 slice 3 fix): search_by_conditions is an ops-API operation, and
|
|
326
|
+
* the Memory resource exposes no REST handler for that path — the POST 405s
|
|
327
|
+
* ("does not have a post method... /Memory/search_by_conditions"), verified
|
|
328
|
+
* against a real Harper in test/integration/continuity-rem-promotion-1257.
|
|
329
|
+
* Because discoverResume fails open by design, that 405 didn't error — it
|
|
330
|
+
* silently made EVERY resume come back empty (no hint, ever): the fail-open
|
|
331
|
+
* masked a dead read path, exactly the "unrun check looks like a pass" shape.
|
|
332
|
+
*
|
|
333
|
+
* The client-side filters mirror what the old conditions asked the server
|
|
334
|
+
* for: own agentId (the GET's query param is NOT an owner filter — the read
|
|
335
|
+
* scope returns other agents' non-private rows too; journal rows are private
|
|
336
|
+
* so only our own arrive, but the filter must not lean on that) and
|
|
337
|
+
* durability "ephemeral".
|
|
338
|
+
*/
|
|
339
|
+
async function fetchOwnEphemeralRows(client, agentId) {
|
|
340
|
+
const raw = await client.request("GET", `/Memory?agentId=${encodeURIComponent(agentId)}`);
|
|
341
|
+
return rowsFrom(raw).filter((r) => r.agentId === agentId && r.durability === "ephemeral");
|
|
342
|
+
}
|
|
343
|
+
function rowsFrom(result) {
|
|
344
|
+
if (Array.isArray(result))
|
|
345
|
+
return result;
|
|
346
|
+
const wrapped = result?.results;
|
|
347
|
+
return Array.isArray(wrapped) ? wrapped : [];
|
|
348
|
+
}
|
|
349
|
+
/** Expired rows are excluded HERE, not just by MemoryMaintenance — the reap is
|
|
350
|
+
* asynchronous, so a row whose expiresAt is past may still be in storage
|
|
351
|
+
* (scenario S4: it must never reach the agent regardless). */
|
|
352
|
+
function isLive(row, now) {
|
|
353
|
+
if (typeof row.expiresAt !== "string" || row.expiresAt === "")
|
|
354
|
+
return true;
|
|
355
|
+
const expiry = Date.parse(row.expiresAt);
|
|
356
|
+
return !Number.isFinite(expiry) || expiry > now.getTime();
|
|
357
|
+
}
|
|
358
|
+
function continuitySessionOf(row) {
|
|
359
|
+
const meta = row.meta;
|
|
360
|
+
if (meta && typeof meta.sessionId === "string" && meta.sessionId !== "")
|
|
361
|
+
return meta.sessionId;
|
|
362
|
+
if (Array.isArray(row.tags)) {
|
|
363
|
+
for (const tag of row.tags) {
|
|
364
|
+
if (typeof tag === "string" && tag.startsWith(CONTINUITY_TAG_PREFIX)) {
|
|
365
|
+
const sid = tag.slice(CONTINUITY_TAG_PREFIX.length);
|
|
366
|
+
if (sid !== "")
|
|
367
|
+
return sid;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
function toEntry(row) {
|
|
374
|
+
const meta = row.meta;
|
|
375
|
+
return {
|
|
376
|
+
id: typeof row.id === "string" ? row.id : "",
|
|
377
|
+
seq: meta && typeof meta.seq === "number" && Number.isFinite(meta.seq) ? meta.seq : null,
|
|
378
|
+
processUUID: meta && typeof meta.processUUID === "string" && meta.processUUID !== "" ? meta.processUUID : null,
|
|
379
|
+
sessionId: continuitySessionOf(row),
|
|
380
|
+
createdAt: typeof row.createdAt === "string" ? row.createdAt : "",
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
/** seq ascending (monotonic per process — the primary order), createdAt as
|
|
384
|
+
* tiebreak ONLY (and as the order for legacy rows lacking a seq). */
|
|
385
|
+
function seqOrder(a, b) {
|
|
386
|
+
if (a.seq != null && b.seq != null && a.seq !== b.seq)
|
|
387
|
+
return a.seq - b.seq;
|
|
388
|
+
if (a.createdAt !== b.createdAt)
|
|
389
|
+
return a.createdAt < b.createdAt ? -1 : 1;
|
|
390
|
+
return 0;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Resume discovery, both layers as ruled on the issue:
|
|
394
|
+
*
|
|
395
|
+
* FAST PATH — the pointer file named a prior session: search exactly that
|
|
396
|
+
* session's tag. A session's rows are all one process's by construction
|
|
397
|
+
* (sessionId is minted per process), so the tag IS the process filter.
|
|
398
|
+
*
|
|
399
|
+
* FALLBACK — no (readable) pointer: agentId-wide ephemeral search,
|
|
400
|
+
* disambiguated by the processUUID carried in row meta. Entries are grouped
|
|
401
|
+
* by processUUID and ONLY the most recent group is returned — a resume
|
|
402
|
+
* reconstruction MUST NEVER interleave entries from two distinct
|
|
403
|
+
* processUUIDs (scenario S8; two live processes sharing an identity).
|
|
404
|
+
* Rows without a processUUID cannot be attributed and are skipped.
|
|
405
|
+
*
|
|
406
|
+
* Every failure (Flair down, auth error, malformed response) resolves to zero
|
|
407
|
+
* entries — the caller then emits no hint. Never throws.
|
|
408
|
+
*/
|
|
409
|
+
export async function discoverResume(client, agentId, pointer, now = new Date()) {
|
|
410
|
+
try {
|
|
411
|
+
if (pointer) {
|
|
412
|
+
const priorTag = continuityTag(pointer.sessionId);
|
|
413
|
+
const rows = (await fetchOwnEphemeralRows(client, agentId))
|
|
414
|
+
.filter((r) => Array.isArray(r.tags) && r.tags.includes(priorTag))
|
|
415
|
+
.filter((r) => isLive(r, now));
|
|
416
|
+
const entries = rows.map(toEntry).sort(seqOrder).slice(0, RESUME_SEARCH_LIMIT);
|
|
417
|
+
return { entries, sessionId: entries.length > 0 ? pointer.sessionId : null };
|
|
418
|
+
}
|
|
419
|
+
const rows = (await fetchOwnEphemeralRows(client, agentId))
|
|
420
|
+
.filter((r) => isLive(r, now))
|
|
421
|
+
.filter((r) => continuitySessionOf(r) !== null)
|
|
422
|
+
.slice(0, FALLBACK_SEARCH_LIMIT);
|
|
423
|
+
const groups = new Map();
|
|
424
|
+
for (const row of rows) {
|
|
425
|
+
const entry = toEntry(row);
|
|
426
|
+
if (!entry.processUUID)
|
|
427
|
+
continue; // unattributable — never guess
|
|
428
|
+
const list = groups.get(entry.processUUID) ?? [];
|
|
429
|
+
list.push(entry);
|
|
430
|
+
groups.set(entry.processUUID, list);
|
|
431
|
+
}
|
|
432
|
+
let best = null;
|
|
433
|
+
let bestLatest = "";
|
|
434
|
+
for (const list of groups.values()) {
|
|
435
|
+
const latest = list.reduce((max, e) => (e.createdAt > max ? e.createdAt : max), "");
|
|
436
|
+
if (best === null || latest > bestLatest) {
|
|
437
|
+
best = list;
|
|
438
|
+
bestLatest = latest;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (!best || best.length === 0)
|
|
442
|
+
return { entries: [], sessionId: null };
|
|
443
|
+
const entries = [...best].sort(seqOrder).slice(0, RESUME_SEARCH_LIMIT);
|
|
444
|
+
return { entries, sessionId: entries[0]?.sessionId ?? null };
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return { entries: [], sessionId: null };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* The resume hint — at most ONE line, informational, agent-pull (scenario
|
|
452
|
+
* S10): it names the count and the search tag, and NEVER carries journal
|
|
453
|
+
* content (not even summarized). Zero entries ⇒ null ⇒ the hook emits no hint
|
|
454
|
+
* at all — an empty journal is normal operation, not a warning.
|
|
455
|
+
*/
|
|
456
|
+
export function buildResumeHint(result) {
|
|
457
|
+
const n = result.entries.length;
|
|
458
|
+
if (n === 0 || !result.sessionId)
|
|
459
|
+
return null;
|
|
460
|
+
const noun = n === 1 ? "entry" : "entries";
|
|
461
|
+
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.`;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Filesystem half of the resume path (pointer read → mint → rotate → seed the
|
|
465
|
+
* capture state file), called by the SessionStart hook. Pure local work —
|
|
466
|
+
* runs regardless of Flair reachability so capture always has its state file.
|
|
467
|
+
* Never throws; any fs failure degrades to "resume hint may still fire,
|
|
468
|
+
* capture won't" — fail-open in both directions.
|
|
469
|
+
*
|
|
470
|
+
* COMPACTION IS NOT A RESTART (scenario S7): when the harness reports the
|
|
471
|
+
* session start came from compaction, NOTHING runs — no pointer read, no
|
|
472
|
+
* rotation, no state-file touch. The harness session id is unchanged across
|
|
473
|
+
* compaction, so the capture hook keeps finding the same state file and the
|
|
474
|
+
* same sessionId; the journal never fragments.
|
|
475
|
+
*/
|
|
476
|
+
export function prepareContinuityBoot(input, agentId, env = process.env) {
|
|
477
|
+
const startedFrom = typeof input.source === "string" ? input.source : typeof input.how_started === "string" ? input.how_started : "";
|
|
478
|
+
if (startedFrom === "compact")
|
|
479
|
+
return { active: false, priorPointer: null };
|
|
480
|
+
// Without a harness session id there is nothing to key the capture state
|
|
481
|
+
// file by (manual runs, older harnesses) — stay fully inert.
|
|
482
|
+
if (!isSafeFileId(agentId) || !isSafeFileId(input.session_id))
|
|
483
|
+
return { active: false, priorPointer: null };
|
|
484
|
+
const sessionDir = resolveSessionDir(env);
|
|
485
|
+
const priorPointer = readPointer(sessionDir, agentId);
|
|
486
|
+
try {
|
|
487
|
+
seedSession(sessionDir, agentId, input.session_id);
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
// Session dir unwritable — capture can't run this session, but a resume
|
|
491
|
+
// hint from the prior pointer is still valid and still cheap.
|
|
492
|
+
}
|
|
493
|
+
return { active: true, priorPointer };
|
|
494
|
+
}
|
package/dist/env-guard.d.ts
CHANGED
|
@@ -54,3 +54,21 @@ export declare const ENV_RESURRECTED_BY_FLAIR_CLIENT: readonly ["FLAIR_URL"];
|
|
|
54
54
|
* accepts an injectable env for tests. Real (substituted) values are untouched.
|
|
55
55
|
*/
|
|
56
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
|
@@ -68,3 +68,24 @@ export function stripInterpolationLiteralsFromEnv(env = process.env, names = ENV
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
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.
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
|
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.
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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.
|
|
3
|
+
"version": "0.47.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.
|
|
31
|
+
"@tpsdev-ai/flair-client": "0.47.0",
|
|
31
32
|
"zod": "4.3.6"
|
|
32
33
|
},
|
|
33
34
|
"license": "Apache-2.0",
|