feat(ruflo): UserPromptSubmit advisory recall hook

Hook script that runs ruflo memory search per prompt and injects top
matches as additionalContext. Fail-open (error/timeout -> empty inject,
exit 0, never blocks). Pure-function core unit-tested via node --test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-15 15:34:33 +03:00
parent ef71bce0a2
commit 08d5ff1151
2 changed files with 204 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
#!/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();
}
+71
View File
@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parsePrompt, extractJson, formatRecall, buildHookOutput, rufloBinCandidates,
} from './ruflo-recall-hook.mjs';
test('parsePrompt: extracts the prompt field', () => {
assert.equal(parsePrompt('{"prompt":"hello world"}'), 'hello world');
});
test('parsePrompt: empty string when field is missing', () => {
assert.equal(parsePrompt('{"other":1}'), '');
});
test('parsePrompt: empty string on invalid JSON (fail-open)', () => {
assert.equal(parsePrompt('not json at all'), '');
});
test('parsePrompt: empty string when prompt is not a string', () => {
assert.equal(parsePrompt('{"prompt":123}'), '');
});
test('extractJson: pulls the JSON object out of noisy ruflo stdout', () => {
const stdout =
'[INFO] Searching: "x" (semantic)\n\n' +
'✅ Using sql.js\n' +
'{\n "query": "x",\n "results": []\n}\n';
const obj = extractJson(stdout);
assert.equal(obj.query, 'x');
assert.deepEqual(obj.results, []);
});
test('extractJson: null when there is no JSON', () => {
assert.equal(extractJson('[INFO] only noise, no braces'), null);
});
test('extractJson: null on malformed JSON (fail-open)', () => {
assert.equal(extractJson('noise {not: valid'), null);
});
test('formatRecall: empty string when there are no results', () => {
assert.equal(formatRecall({ results: [] }), '');
assert.equal(formatRecall(null), '');
assert.equal(formatRecall({}), '');
});
test('formatRecall: formats at most 3 results and tags the block', () => {
const out = formatRecall({
results: [
{ key: 'k1', preview: 'p1' },
{ key: 'k2', preview: 'p2' },
{ key: 'k3', preview: 'p3' },
{ key: 'k4', preview: 'p4' },
],
});
assert.ok(out.includes('k1') && out.includes('k3'));
assert.ok(!out.includes('k4'));
assert.ok(out.includes('ruflo memory recall'));
});
test('buildHookOutput: correct UserPromptSubmit payload shape', () => {
const o = buildHookOutput('some context');
assert.equal(o.hookSpecificOutput.hookEventName, 'UserPromptSubmit');
assert.equal(o.hookSpecificOutput.additionalContext, 'some context');
});
test('rufloBinCandidates: RUFLO_RECALL_BIN override wins', () => {
assert.deepEqual(rufloBinCandidates({ RUFLO_RECALL_BIN: 'X:/r.js' }), ['X:/r.js']);
});
test('rufloBinCandidates: builds an APPDATA-based candidate', () => {
const c = rufloBinCandidates({ APPDATA: 'C:/Users/X/AppData/Roaming' });
assert.equal(c.length, 1);
assert.ok(c[0].includes('ruflo') && c[0].endsWith('ruflo.js'));
});
test('rufloBinCandidates: empty array when no env hints', () => {
assert.deepEqual(rufloBinCandidates({}), []);
});