#!/usr/bin/env node /** * UserPromptSubmit hook — ruflo memory advisory recall. * * On each prompt: runs `ruflo memory search` and injects the top matches as * additionalContext so Claude sees relevant recalled memory. * * FAIL-OPEN: any error / timeout / no results -> empty injection, exit 0. * The hook NEVER blocks a prompt. * * Requires the H7 fix (tools/ruflo-h7-patch.mjs) — otherwise ruflo memory * does not persist and search always returns nothing. * * See docs/superpowers/specs/2026-05-15-ruflo-memory-h7-fix-and-advisory-hook-design.md §5 */ import { execFile } from 'node:child_process'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; const SEARCH_TIMEOUT_MS = 3500; const MAX_RESULTS = 3; const MAX_QUERY_CHARS = 400; /** Extract the `prompt` string from the UserPromptSubmit hook stdin JSON. */ export function parsePrompt(stdinRaw) { try { const obj = JSON.parse(stdinRaw); return typeof obj.prompt === 'string' ? obj.prompt : ''; } catch { return ''; } } /** * Extract the JSON object from `ruflo memory search --format json` stdout. * stdout is noisy ([INFO]..., "Using sql.js"...) followed by a pretty-printed * JSON object — take from the first '{' to the last '}'. */ export function extractJson(stdout) { const start = stdout.indexOf('{'); const end = stdout.lastIndexOf('}'); if (start === -1 || end === -1 || end < start) return null; try { return JSON.parse(stdout.slice(start, end + 1)); } catch { return null; } } /** * Format the search result object into an additionalContext block. * Returns '' when there are no usable results. */ export function formatRecall(searchObj) { if (!searchObj || !Array.isArray(searchObj.results) || searchObj.results.length === 0) { return ''; } const lines = searchObj.results.slice(0, MAX_RESULTS).map((r) => { const key = typeof r.key === 'string' ? r.key : '(no-key)'; const preview = typeof r.preview === 'string' ? r.preview : ''; return `- [${key}] ${preview}`; }); return ['[ruflo memory recall — релевантное из прошлых сессий]', ...lines].join('\n'); } /** Build the UserPromptSubmit hook stdout payload. */ export function buildHookOutput(additionalContext) { return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext, }, }; } /** Candidate paths for the ruflo bin JS. RUFLO_RECALL_BIN overrides all. */ export function rufloBinCandidates(env) { if (env.RUFLO_RECALL_BIN) return [env.RUFLO_RECALL_BIN]; const out = []; if (env.APPDATA) { out.push(join(env.APPDATA, 'npm', 'node_modules', 'ruflo', 'bin', 'ruflo.js')); } return out; } function resolveRufloBin(env) { return rufloBinCandidates(env).find((p) => existsSync(p)) ?? null; } function readStdin() { return new Promise((resolve) => { let data = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => { data += c; }); process.stdin.on('end', () => resolve(data)); process.stdin.on('error', () => resolve(data)); }); } function runSearch(rufloBin, query) { return new Promise((resolve) => { execFile( process.execPath, [rufloBin, 'memory', 'search', '-q', query, '--format', 'json', '-l', String(MAX_RESULTS)], { timeout: SEARCH_TIMEOUT_MS, encoding: 'utf8' }, (_err, stdout) => resolve(typeof stdout === 'string' ? stdout : ''), ); }); } async function main() { let additionalContext = ''; try { const stdinRaw = await readStdin(); const prompt = parsePrompt(stdinRaw); const rufloBin = resolveRufloBin(process.env); if (prompt.trim().length > 0 && rufloBin) { const stdout = await runSearch(rufloBin, prompt.slice(0, MAX_QUERY_CHARS)); additionalContext = formatRecall(extractJson(stdout)); } } catch { additionalContext = ''; } if (additionalContext) { process.stdout.write(JSON.stringify(buildHookOutput(additionalContext))); } process.exit(0); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main(); }