Files
waggle-os/packages/hive-mind-hooks-openclaw/src/handler.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

228 lines
8.9 KiB
TypeScript

/**
* 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> | 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<typeof createCliBridge> {
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<void> {
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<void> {
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');
}