11 KiB
Design — Hermes opportunistic time-gated compact-on-Stop (OQ-4)
Date: 2026-06-01
Status: Approved (brainstorming → spec)
Scope: packages/hive-mind-hooks-hermes only. @waggle/hive-mind-hooks-core is not modified.
Closes: OQ-4 (handoff project_session_handoff_0601_s1.md §"What's still open" → P2) and the
CLAUDE.md §10 residual "hermes has no PreCompact" gap.
1. Problem
Hermes (NousResearch) ships no PreCompact lifecycle event — confirmed absent in source
(adapter.ts sets HERMES_EVENT_NAME['pre-compact'] = undefined; paths.ts omits the basename).
Every other built hook package binds PreCompact to bridge.cleanupFrames() (the
cleanup_frames MCP tool, mode:'compact') so that, before the host truncates context, the
mind runs a maintenance pass: prune expired temporary frames + reconcile superseded ones.
Consequence for a hermes CLI user: that maintenance never runs. Temporary capture frames
(every UserPromptSubmit saves one) accumulate unbounded in their .mind. The Wave-2/3 hermes
package shipped this as a documented gap (adapter.ts: "the gap is documented, never
approximated"). OQ-4 is the optional follow-up that closes it.
2. Goal / non-goals
Goal: give hermes a faithful, low-cost approximation of "occasional before-compaction
maintenance" by running cleanupFrames() opportunistically from the per-turn Stop hook —
opt-in, default off, so OSS consumers see zero behavior change unless they ask for it.
Non-goals: changing the shared runStopBody; touching any other tool package; adding a new
upstream MCP tool; per-workspace gating (hermes hooks target the personal mind — a single global
gate is correct); reconstructing a true PreCompact signal (Hermes has none — this is an
approximation, labelled as such).
3. Core constraint — statelessness
Hermes' Stop is post_llm_call, delivered to a fresh Node subprocess every turn
(stdin-JSON / exit-0 via runHook). Nothing survives between turns in memory, so an
"every N turns" counter is impossible without persistence. PreCompact means "occasionally";
the only per-turn signal we have is Stop. We bridge the two with a persisted timestamp gate:
compact at most once per time window, tracking the last-compact instant in a small file.
(Decision record: alternatives considered were every-Stop — simplest but runs a maintenance pass far more often than its intent, one extra CLI spawn per turn — and probabilistic 1/N — stateless but non-deterministic cadence, can fire twice in a row or never in a short session. Time-gate chosen: faithful to "occasional", bounded cost, deterministic + testable via an injectable clock. Window default 10 min.)
4. Placement
Hermes-package-only. The shared runStopBody / makeStopHandler in hooks-core — run by
codex, cursor, and (via makeOpenclawHandler) openclaw — stay byte-untouched. Hermes is the
only built tool lacking PreCompact, and the gate needs a hermes-specific state path
(~/.hermes/). Pushing file-IO + a clock + a tool-specific path into the generic core would add
blast radius across four packages for zero reuse. (CLAUDE.md §3.3 surgical changes; §3.2 no
speculative flexibility.)
5. Design
5.1 New module: src/compact-on-stop.ts
Small, pure, high-cohesion (CLAUDE.md "many small files"). Exports:
compactStatePath(home?): string
→ join(resolvePaths({home}).hermesDir, '.hive-mind-last-compact')
isCompactEnabled(env = process.env): boolean
→ truthy WAGGLE_HERMES_COMPACT_ON_STOP, parsed exactly like WAGGLE_SIGNAL_EMIT:
flag != null && flag !== '' && flag !== '0' && flag.toLowerCase() !== 'false'
resolveWindowMs(env = process.env, overrideMs?): number
→ overrideMs (test) wins; else parseFloat(WAGGLE_HERMES_COMPACT_WINDOW_MIN) * 60000
when finite and > 0; else DEFAULT_WINDOW_MS (600_000 = 10 min)
readLastCompactTs(path): Promise<number | undefined>
→ read file, parseInt; missing / garbage / NaN → undefined (= "never"); fail-open
writeLastCompactTs(path, ts): Promise<void>
→ mkdir(dirname(path), {recursive:true}) then writeFile(path, String(ts)); errors
caught + swallowed by caller. (The mkdir matters: if the flag is set on a host where
~/.hermes/ doesn't exist yet — e.g. hermes never installed — a bare writeFile would
ENOENT every turn and the throttle would silently degrade to every-turn. mkdir-recursive
is idempotent and cheap; in a real install the dir already exists.)
maybeCompactOnStop(ctx: HookContext, opts?: {
now?: () => number; home?: string; windowMs?: number;
}): Promise<void>
maybeCompactOnStop body (all wrapped in one try/catch that logs+swallows — never throws,
never rejects):
if (!isCompactEnabled()) return;— default-off fast path, no IO.const now = opts.now?.() ?? Date.now();const path = compactStatePath(opts.home);const last = await readLastCompactTs(path);const windowMs = resolveWindowMs(process.env, opts.windowMs);if (last !== undefined && now - last < windowMs) return;— inside window, skip.await ctx.bridge.cleanupFrames();— defaultmode:'compact'.await writeLastCompactTs(path, now);— on success only (a failed compact at step 7 throws → caught → swallowed → timestamp NOT written → eligible to retry next turn, rather than being locked out for a whole window).
Turns are sequential subprocesses (process N exits before N+1 starts) → no read/write race.
5.2 Compose into src/hooks/stop.ts (save-first)
runStop keeps its current contract but composes the shared handler with the compact step.
The base handler is reused as-is; we only wrap its run:
export interface HermesStopOptions extends Partial<HookRunOptions> {
now?: () => number; // test clock
home?: string; // test $HOME override for the state file
compactWindowMs?: number; // test window override
}
export async function runStop(opts: HermesStopOptions = {}): Promise<void> {
const { now, home, compactWindowMs, ...runOpts } = opts;
const base = makeStopHandler(hermesAdapter);
const handler: typeof base = {
parse: base.parse,
async run(payload, ctx) {
await base.run(payload, ctx); // primary save — unchanged
await maybeCompactOnStop(ctx, { now, home, windowMs: compactWindowMs });
return undefined;
},
};
return runHook(handler, { name: 'stop', loggerPrefix: 'hermes-hooks', ...runOpts });
}
Ordering + fail-open guarantees:
- Save before compact. If
base.runthrows (save failed),maybeCompactOnStopis skipped and the throw lands inrunHook's existing try/catch →exit(0). The capture is the priority; compaction is best-effort maintenance layered after it. maybeCompactOnStopitself never throws, so a compact/file error cannot affect the exit code or the already-completed save.- Use
typeof basefor the handler type to avoid importing theStopParsednamed type (it may not be re-exported from thehooks-corebarrel;ReturnType-style typing sidesteps that).
5.3 No changes to install / verify / yaml-merger / paths basenames
This is a runtime capture behavior only. Hermes still binds 3 lifecycle events; the gate is
not a registered hook. paths.ts HOOK_BASENAMES stays 3 (no pre-compact). resolvePaths
already exposes hermesDir + a home override → reused for the state path; no new path API.
6. Tests (TDD — write first, watch fail, then implement)
6.1 Unit — tests/compact-on-stop.test.ts (new)
Drive maybeCompactOnStop directly with makeMockBridge(), a tmp home dir, and an injected
now. Stub env with vi.stubEnv / vi.unstubAllEnvs (afterEach), matching the existing stop test.
- flag off →
bridge.cleanupFramesNOT called; no state file written. - flag on, no prior timestamp file →
cleanupFramescalled once; state file now holdsnow. - flag on, last =
now - 1min, window 10min → NOT called (inside window). - flag on, last =
now - 11min, window 10min → called; timestamp updated tonow. - flag on,
cleanupFramesrejects →maybeCompactOnStopresolves (no throw); state file NOT updated (retry-next-turn). - flag on, state-file write fails (point
homeat an existing file, somkdir(~/.hermes)throws ENOTDIR/EEXIST) →maybeCompactOnStopresolves, no throw;cleanupFrameswas still attempted (compaction is best-effort regardless of whether the timestamp persisted). WAGGLE_HERMES_COMPACT_WINDOW_MIN=5honored;compactWindowMsopt overrides env.isCompactEnabledtruth table: unset/''/'0'/'false'/'FALSE' → false; '1'/'true'/'yes' → true.
6.2 Integration — extend tests/hooks/stop.test.ts
Through runStop with the mock bridge + captures + tmp home + injected now:
- Default-off regression lock: flag unset → existing behavior intact AND
bridge.cleanupFramesNOT called (locks "documented-gap default"). - flag on, eligible → save happens AND
cleanupFramescalled; assert call order (saveMemoryinvoked beforecleanupFrames). - flag on, eligible,
cleanupFramesrejects →exits === [0],saveMemorystill called once. - flag on, but no
assistant_response(no save) →cleanupFramesstill gate-eligible and may run (compaction is independent of whether this turn had a response) — assert it runs and exits 0. (Confirms compaction isn't accidentally coupled to the save path.)
All existing hermes stop tests must stay green unchanged.
7. Docs to correct (same PR)
src/adapter.ts— the block comment "There is NO PreCompact event … the gap is documented, never approximated." → "…no PreCompact event; the maintenance pass is approximated opt-in from Stop (WAGGLE_HERMES_COMPACT_ON_STOP, default off) — seecompact-on-stop.ts." (eventName['pre-compact']staysundefined— we are NOT registering a hook.)src/index.tsheader — note the opt-in approximation; exportmaybeCompactOnStop+compactStatePathif useful for consumers (optional).README.md— capture-fidelity hermes row: add the opt-in compact line + the two env vars.src/bin/hermes-hooks.ts—printInstallSummarycapture-fidelity blurb: one line noting the opt-in flag (so installers learn it exists).- Handoff / CLAUDE.md §10 — move OQ-4 from open (P2) to closed in the next handoff.
8. Risk / rollback
Additive + opt-in: with the flag unset (default) the only change is one extra await base.run
indirection that is behavior-identical to today (test #9 locks this). Rollback = revert the
hermes commit; no other package is touched. No new dependency. No upstream/CLI surface change
(cleanup_frames already exists and is exercised by 4 other packages).
9. Success criteria
- New unit + integration tests pass (12 cases above); full hermes package suite stays green (was 89/89).
npx tsc --noEmitclean onhive-mind-hooks-hermes(+hooks-core/shim-coreunaffected).- Default-off behavior byte-identical to pre-change (regression-locked by test #9).
- Docs no longer claim "never approximated".