diff --git a/tools/ruflo-recall-hook.mjs b/tools/ruflo-recall-hook.mjs new file mode 100644 index 00000000..ed4b5fb8 --- /dev/null +++ b/tools/ruflo-recall-hook.mjs @@ -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(); +} diff --git a/tools/ruflo-recall-hook.test.mjs b/tools/ruflo-recall-hook.test.mjs new file mode 100644 index 00000000..48222d6e --- /dev/null +++ b/tools/ruflo-recall-hook.test.mjs @@ -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({}), []); +});