Files
waggle-os/docs/superpowers/specs/2026-06-01-hermes-compact-on-stop-design.md
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

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):

  1. if (!isCompactEnabled()) return; — default-off fast path, no IO.
  2. const now = opts.now?.() ?? Date.now();
  3. const path = compactStatePath(opts.home);
  4. const last = await readLastCompactTs(path);
  5. const windowMs = resolveWindowMs(process.env, opts.windowMs);
  6. if (last !== undefined && now - last < windowMs) return; — inside window, skip.
  7. await ctx.bridge.cleanupFrames(); — default mode:'compact'.
  8. 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.run throws (save failed), maybeCompactOnStop is skipped and the throw lands in runHook's existing try/catch → exit(0). The capture is the priority; compaction is best-effort maintenance layered after it.
  • maybeCompactOnStop itself never throws, so a compact/file error cannot affect the exit code or the already-completed save.
  • Use typeof base for the handler type to avoid importing the StopParsed named type (it may not be re-exported from the hooks-core barrel; 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.

  1. flag off → bridge.cleanupFrames NOT called; no state file written.
  2. flag on, no prior timestamp file → cleanupFrames called once; state file now holds now.
  3. flag on, last = now - 1min, window 10min → NOT called (inside window).
  4. flag on, last = now - 11min, window 10min → called; timestamp updated to now.
  5. flag on, cleanupFrames rejects → maybeCompactOnStop resolves (no throw); state file NOT updated (retry-next-turn).
  6. flag on, state-file write fails (point home at an existing file, so mkdir(~/.hermes) throws ENOTDIR/EEXIST) → maybeCompactOnStop resolves, no throw; cleanupFrames was still attempted (compaction is best-effort regardless of whether the timestamp persisted).
  7. WAGGLE_HERMES_COMPACT_WINDOW_MIN=5 honored; compactWindowMs opt overrides env.
  8. isCompactEnabled truth 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:

  1. Default-off regression lock: flag unset → existing behavior intact AND bridge.cleanupFrames NOT called (locks "documented-gap default").
  2. flag on, eligible → save happens AND cleanupFrames called; assert call order (saveMemory invoked before cleanupFrames).
  3. flag on, eligible, cleanupFrames rejects → exits === [0], saveMemory still called once.
  4. flag on, but no assistant_response (no save) → cleanupFrames still 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) — see compact-on-stop.ts." (eventName['pre-compact'] stays undefined — we are NOT registering a hook.)
  • src/index.ts header — note the opt-in approximation; export maybeCompactOnStop + compactStatePath if useful for consumers (optional).
  • README.md — capture-fidelity hermes row: add the opt-in compact line + the two env vars.
  • src/bin/hermes-hooks.tsprintInstallSummary capture-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 --noEmit clean on hive-mind-hooks-hermes (+ hooks-core/shim-core unaffected).
  • Default-off behavior byte-identical to pre-change (regression-locked by test #9).
  • Docs no longer claim "never approximated".