Files
brain/tools/ruflo-recall-hook.mjs
T

134 lines
4.1 KiB
JavaScript

#!/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();
}