/** * OpenClaw in-process hook handler — the default export the gateway loads. * * Unlike the stdin-JSON tools (codex / cursor / hermes), OpenClaw hooks are * IN-PROCESS: the gateway dynamically `import()`s this file, grabs the default * export, and registers it as an `InternalHookHandler` * (`(event) => Promise | void`, run inside the gateway event loop). So * there is no `runHook` subprocess wrapper — this module maps `event.context` * into the shared extracted-payload shapes and drives the SAME shared handler * bodies via `makeOpenclawHandler` (hooks-core). * * The four lifecycle dispatches (recall+inject / save-temp / summarize+save / * compact) are matched on the `(type, action)` PAIR by hooks-core. This module * owns the OpenClaw-specific glue: * - SessionStart injects by MUTATING `event.context.bootstrapFiles` (a * mutable array the gateway reads back) — there is no stdout seam. * - Stop (`message:sent`) fires 0..N per turn and is DEBOUNCED in * `makeOpenclawHandler` (stopDebounceMs). * - Provenance: every captured frame is attributed to `openclaw-gateway` * (+ the channel/session key) so gateway captures are distinguishable * from any backend (CC/codex) capture. * * FAIL-OPEN: the default export wraps the body and NEVER throws / never * rejects — it always returns a resolved promise (spec §7.3 invariant 1). The * gateway also wraps each handler in try/catch, but we do not rely on that as * the only safety net. * * Live-install caveat (OQ-5): this file is shipped COMPILED (`dist/handler.js`) * and copied into `~/.openclaw/hooks/hive-mind/handler.js`. Whether the * installed `handler.js` resolves the `@waggle/*` runtime deps on a real * OpenClaw install is the ONE remaining needs-a-live-install validation — the * gateway must be able to `import()` a file that `require`s node_modules from * this package's tree (or have the deps bundled). Documented in the README. */ import { createCliBridge, createLogger, type CliBridgeOptions, type MemoryHit, } from '@waggle/hive-mind-shim-core'; import { makeOpenclawHandler, type HookContext, type InternalHookEventLike, type Lifecycle, type OpenclawHandlerInput, type PreCompactExtracted, type SessionStartExtracted, type StopExtracted, type UserPromptExtracted, } from '@waggle/hive-mind-hooks-core'; import { openclawAdapter, OPENCLAW_PROVENANCE } from './adapter.js'; /** Default Stop debounce: collapse the 0..N `message:sent` of a turn. */ const DEFAULT_STOP_DEBOUNCE_MS = 750; const DEFAULT_RECALL_LIMIT = 20; /** * Minimal shape of OpenClaw's runtime `InternalHookEvent`. `bootstrapFiles` is * the MUTABLE array the gateway reads back after `agent:bootstrap` to inject * recalled context into the system prompt. */ interface OpenclawRuntimeContext { cwd?: unknown; workingDirectory?: unknown; channelId?: unknown; sessionKey?: unknown; content?: unknown; /** Mutable array of bootstrap file contents — the SessionStart inject seam. */ bootstrapFiles?: unknown; [key: string]: unknown; } interface OpenclawRuntimeEvent extends InternalHookEventLike { context?: OpenclawRuntimeContext; } function asContext(event: OpenclawRuntimeEvent): OpenclawRuntimeContext { return event.context && typeof event.context === 'object' ? event.context : {}; } /** * Build a provenance-stamped session scope so gateway frames are attributable * to `openclaw-gateway` and to the originating channel/session. This rides the * frame's `session:` content prefix (frame-encoder), which is the only * attribution channel the save_memory wire preserves. */ function provenanceScope(ctx: OpenclawRuntimeContext): string { const sessionId = openclawAdapter.extractSessionId(ctx) ?? 'default'; return `${OPENCLAW_PROVENANCE}:${sessionId}`; } /** Resolve the lifecycle for an event using the adapter's event-name map. */ function lifecycleFor(event: OpenclawRuntimeEvent): Lifecycle | undefined { const joined = `${event.type}:${event.action}`; for (const lc of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact'] as Lifecycle[]) { const native = openclawAdapter.eventName[lc]; if (native === undefined) continue; if (native === joined) return lc; // PreCompact ONLY: runtime action is 'compact:before' while the HOOK.md key // is 'session:compact:before' — accept the action-suffix match exclusively // for pre-compact (an unrelated type with a colliding action must not map). if (lc === 'pre-compact' && native.endsWith(`:${event.action}`) && event.type !== '') return lc; } return undefined; } /** * Extract the lifecycle-specific payload from `event.context`, stamping the * provenance scope as the session id so saved frames are attributable. */ function extractFor( lifecycle: Lifecycle, ctx: OpenclawRuntimeContext, ): SessionStartExtracted | UserPromptExtracted | StopExtracted | PreCompactExtracted { const cwd = openclawAdapter.extractCwd(ctx) ?? process.cwd(); const scope = provenanceScope(ctx); switch (lifecycle) { case 'session-start': return { cwd, sessionId: scope, recallLimit: DEFAULT_RECALL_LIMIT }; case 'user-prompt-submit': return { prompt: openclawAdapter.extractPrompt(ctx) ?? '', cwd, sessionId: scope }; case 'stop': { const responseRaw = openclawAdapter.extractResponse(ctx, {}); const response = typeof responseRaw === 'string' ? responseRaw : ''; const parent = openclawAdapter.extractParent(ctx); const stop: StopExtracted = { cwd, sessionId: scope, response, parent }; return stop; } case 'pre-compact': return { scope }; } } /** Build a CliBridge, honoring an install-pinned cli path from env. */ function buildBridge(): ReturnType { const logger = createLogger({ name: 'openclaw-hooks/handler' }); const cliPath = process.env.WAGGLE_HIVE_MIND_CLI; const opts: CliBridgeOptions = { logger }; if (typeof cliPath === 'string' && cliPath.length > 0) opts.cli_path = cliPath; return createCliBridge(opts); } const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: DEFAULT_STOP_DEBOUNCE_MS, }); /** * The OpenClaw default export. Receives the runtime `InternalHookEvent`, maps * `event.context` → the extracted payload, and drives the shared bodies. * * SessionStart is special-cased: the shared body returns the inject object * (`{ additionalContext }`); we push that text onto the mutable * `context.bootstrapFiles` array (the gateway's sanctioned injection seam) * rather than emitting stdout. * * NEVER throws — always returns a resolved promise (fail-open). */ export default async function openclawHook(event: OpenclawRuntimeEvent): Promise { try { const lifecycle = lifecycleFor(event); if (lifecycle === undefined) return; const ctx = asContext(event); const extracted = extractFor(lifecycle, ctx); // SessionStart: drive recall ourselves so we can mutate bootstrapFiles // with the injected text (the shared body's stdout return is unused // in-process). if (lifecycle === 'session-start') { await injectBootstrap(ctx, extracted as SessionStartExtracted); return; } const input: OpenclawHandlerInput = { event, extracted }; const hookCtx: HookContext = { bridge: buildBridge(), logger: createLogger({ name: 'openclaw-hooks/handler' }) }; await handler.handle(input, hookCtx); } catch { // FAIL-OPEN: swallow — the gateway flow must never be affected. } } /** * Recall top-N frames and push the formatted text onto the mutable * `context.bootstrapFiles` array (OpenClaw's sanctioned inject seam). Recall * uses the same provenance scope so the injected context is attributable. * Fails open. */ async function injectBootstrap( ctx: OpenclawRuntimeContext, extracted: SessionStartExtracted, ): Promise { const logger = createLogger({ name: 'openclaw-hooks/handler' }); try { const bridge = buildBridge(); const hits: MemoryHit[] = await bridge.recallMemory('', { limit: extracted.recallLimit, scope: 'personal', }); if (hits.length === 0) return; const text = formatHits(hits); const arr = ctx.bootstrapFiles; if (Array.isArray(arr)) { // Mutate the host-owned array in place — this IS the injection seam. (arr as unknown[]).push(text); } else { // The host did not provide a mutable array; nothing to inject into. logger.debug('agent:bootstrap had no bootstrapFiles array — skipping inject'); } } catch { // Fail-open: a recall failure must not block bootstrap. } } const PER_HIT_BUDGET = 240; function formatHits(hits: readonly MemoryHit[]): string { const lines: string[] = [`hive-mind: top ${hits.length} recalled frames`]; for (const h of hits) { const content = h.content.length > PER_HIT_BUDGET ? h.content.slice(0, PER_HIT_BUDGET) + '…' : h.content; lines.push(`- (${h.importance}) ${h.created_at}: ${content}`); } return lines.join('\n'); }