397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
134 lines
4.1 KiB
JavaScript
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();
|
|
}
|