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:
@@ -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();
|
||||
}
|
||||
@@ -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({}), []);
|
||||
});
|
||||
Reference in New Issue
Block a user