Files
brain/tools/askuser-answer-parser.mjs

233 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* AskUserQuestion answer parsing library (router-gate v4, Stream E).
*
* Pure functions only — no I/O, no exit. Consumed by gate hooks that wire
* approval-records / stop-detection. Stub-injectable LLM fallback (Stream D).
*
* Spec: docs/superpowers/specs/2026-05-29-router-gate-v4-design.md §4.5 / §4.7
* (S27 stop-keywords, E33 invisible Unicode, E34 whitespace approval,
* multiSelect, annotations, Other social-eng detector).
*/
// E33 — invisible / zero-width / direction-override / BOM / soft-hyphen.
// Code points: U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ, U+202A-U+202E direction,
// U+2066-U+2069 isolation, U+FEFF BOM, U+00AD soft-hyphen.
const INVISIBLE_RE = /[­]/g;
/** Strip invisible Unicode (E33). Non-string → ''. */
export function stripInvisible(s) {
if (typeof s !== 'string') return '';
return s.replace(INVISIBLE_RE, '');
}
/** Normalize a free-form answer: lowercase + strip invisible + collapse ws + trim. */
export function normalizeAnswer(s) {
if (typeof s !== 'string') return '';
return stripInvisible(s).toLowerCase().split(/\s+/).filter(Boolean).join(' ').trim();
}
/** Normalize a shell command for approval comparison (E34): collapse ws, keep case. */
export function normalizeCommand(cmd) {
if (typeof cmd !== 'string') return '';
return cmd.split(/\s+/).filter(Boolean).join(' ').trim();
}
// S27 — stop / abort / cancel keywords (Russian + English). After normalizeAnswer.
export const STOP_KEYWORDS = [
'стоп', 'стопа', 'стоит', 'стопаем', 'отмена', 'отменяю', 'отменить', 'отменяем',
'отмени', 'отменено', 'прекращаем', 'прекрати', 'прекратить', 'прекращай',
'хватит', 'довольно', 'закончили', 'закончил', 'закончить', 'останавливаемся',
'остановка', 'остановись', 'остановите', 'пас', 'пропуск', 'не надо', 'не делай',
'не делайте', 'не делать', 'ничего', 'нет', 'тормози', 'тормозим', 'глуши',
'глушим', 'забей', 'забили', 'забываем', 'шабаш', 'всё, поехали назад',
'закругляемся', 'снимем с повестки', 'выходим из этого', 'на этом всё',
'достаточно', 'cancel', 'abort', 'stop', 'halt', 'quit',
];
// Pre-split for matching: phrases (contain space) matched by substring;
// single tokens matched by token-membership (no Cyrillic \b reliability).
const STOP_PHRASES = STOP_KEYWORDS.filter((k) => k.includes(' '));
const STOP_TOKENS = new Set(STOP_KEYWORDS.filter((k) => !k.includes(' ')));
/**
* True if a free-form answer is a stop/abort/cancel intent (S27).
* Keyword-based; normalizes (E33 invisible strip + ws-collapse + lowercase) first.
* Punctuation attached to tokens (e.g. "нет,") is stripped before matching.
*/
export function isStopAnswer(text) {
const norm = normalizeAnswer(text);
if (!norm) return false;
const depunct = (s) => s.replace(/[.,;:!?…«»"'()\[\]{}]+/g, ' ').split(/\s+/).filter(Boolean).join(' ');
const cleaned = depunct(norm);
for (const phrase of STOP_PHRASES) {
if (cleaned.includes(depunct(normalizeAnswer(phrase)))) return true;
}
const tokens = cleaned.split(' ');
for (const t of tokens) {
if (STOP_TOKENS.has(t)) return true;
}
return false;
}
/**
* Stop detection with LLM ambiguous fallback (§4.5).
* @param {string} text
* @param {{llmJudge?: (text:string)=>Promise<boolean>}} opts
* llmJudge default-stub returns false (never escalates). Stream D wires real judge.
* The injected llmJudge receives whitespace-collapsed lowercase text (post-normalizeAnswer), not the raw input.
* @returns {Promise<boolean>}
*/
export async function detectStopWithFallback(text, { llmJudge } = {}) {
if (isStopAnswer(text)) return true;
const judge = typeof llmJudge === 'function' ? llmJudge : async () => false;
try {
return (await judge(normalizeAnswer(text))) === true;
} catch {
return false; // fail closed-safe: ambiguous + judge error → not a stop
}
}
// E29 + v4.0 — controller suggesting concrete text for the "Other" field.
const OTHER_SOCIAL_ENG_PATTERNS = [
/напиши\s+в\s+other/iu,
/type\s+in\s+other/iu,
/в\s+поле\s+other/iu,
/копируй\s+в\s+other/iu,
/впиши\s+в\s+["«]?другое["»]?/iu,
/в\s+поле\s+["«]?другое["»]?/iu,
/нажми\s+["«]?другое["»]?\s+и\s+впиши/iu,
/укажи\s+в\s+графе\s+["«]?другое["»]?/iu,
];
/**
* Parse an AskUserQuestion tool result into selections + flattened text + stop flag.
* Handles single-string answers, multiSelect arrays, and annotations.notes (S15).
* @param {object} toolResult — { answers: {q: string|string[]}, annotations?: {q:{notes,preview}} }
*/
export function parseAskUserResult(toolResult) {
const out = { selections: [], allText: [], stop: false };
if (!toolResult || typeof toolResult !== 'object') return out;
const answers = toolResult.answers && typeof toolResult.answers === 'object' ? toolResult.answers : {};
for (const v of Object.values(answers)) {
if (Array.isArray(v)) {
for (const item of v) if (typeof item === 'string') { out.selections.push(item); out.allText.push(item); }
} else if (typeof v === 'string') {
out.selections.push(v);
out.allText.push(v);
}
}
const ann = toolResult.annotations && typeof toolResult.annotations === 'object' ? toolResult.annotations : {};
for (const meta of Object.values(ann)) {
if (meta && typeof meta.notes === 'string') out.allText.push(meta.notes);
if (meta && typeof meta.preview === 'string') out.allText.push(meta.preview);
}
out.stop = out.allText.some((t) => isStopAnswer(t));
return out;
}
/** Whitespace-normalized command equality (E34) for approval-record matching. */
export function matchesApproval(approvedPattern, currentCommand) {
return normalizeCommand(approvedPattern) === normalizeCommand(currentCommand) &&
normalizeCommand(approvedPattern) !== '';
}
/**
* Detect controller social-engineering of the AskUser "Other" field (E29 + v4.0 RU).
* @param {string} controllerText — controller response text in recent turns.
*/
export function detectOtherSocialEng(controllerText) {
if (typeof controllerText !== 'string') return { flagged: false, matched: null };
for (const re of OTHER_SOCIAL_ENG_PATTERNS) {
if (re.test(controllerText)) return { flagged: true, matched: re.toString() };
}
return { flagged: false, matched: null };
}
/**
* Build a pure approval record (consumer hook persists it to askuser-decisions-<sess>.jsonl).
* Pattern is whitespace-normalized (E34) so later matchesApproval is stable.
*/
export function buildApprovalRecord({ kind, pattern, sessionId, nowMs }) {
return {
kind: String(kind ?? 'approve_generic'),
approved_action_pattern: normalizeCommand(pattern),
session_id: sessionId || 'unknown',
approved_at_ms: typeof nowMs === 'number' ? nowMs : Date.now(),
};
}
/**
* Translate a free-form AskUserQuestion answer into a Stream B-compatible
* approve_git_operation record, or null if no git pattern detected.
*
* Stream H Task 6 (schema sync): Stream E buildApprovalRecord returns the
* native parser schema {kind, approved_action_pattern, session_id, approved_at_ms};
* Stream B loadApprovedGitOps in shell-content-rules.mjs reads the wire format
* {type:'approve_git_operation', command, ts}. toApprovalRecord is the bridge.
*
* Returns null for: non-string, empty, stop/abort/cancel intents, no git verb.
*
* @param {string} answer - user's free-form answer text
* @param {object} [opts]
* @param {string} [opts.question] - the question that was asked (reserved for future use)
* @param {number} [opts.nowMs] - override timestamp for test determinism
*/
export function toApprovalRecord(answer, { question, nowMs = Date.now() } = {}) {
if (typeof answer !== 'string') return null;
const norm = normalizeAnswer(answer);
if (!norm) return null;
if (isStopAnswer(answer)) return null;
// Detect a git verb after optional approval prefix; match verbs recognized
// by shell-content-rules GIT_CONDITIONAL_SUB + GIT_READONLY_SUB.
const gitMatch = /\b(git\s+(?:add|commit|push|pull|merge|rebase|reset|checkout|switch|branch|stash|cherry-pick|revert|clean|fetch|ls-remote|tag|status|log|show|diff|blame|format-patch|rev-parse|merge-base|remote)\b[^\n]*)/i.exec(answer);
if (!gitMatch) return null;
const command = normalizeCommand(gitMatch[1]);
return { type: 'approve_git_operation', command, ts: nowMs };
}
const FLOOR_ESCAPE_RE = /FLOOR-ESCAPE:\s*([^\n]+?)\s*$/;
/** Перевести ответ AskUser в floor_escape-запись, либо null. Stop-намерения → null. */
export function toFloorEscapeRecord(answer, { nowMs = Date.now() } = {}) {
if (typeof answer !== 'string') return null;
if (isStopAnswer(answer)) return null;
const m = FLOOR_ESCAPE_RE.exec(answer);
if (!m) return null;
let action = m[1].trim();
if (!action) return null;
// ✅O13: skill-escape канон — нижний регистр (зеркало canonicalAction Skill-ветки),
// иначе грант от иного регистра не совпадёт. write:-каноны не трогаем (как сейчас).
if (/^skill:/i.test(action)) action = action.toLowerCase();
return { type: 'floor_escape', action, ts: nowMs };
}
import { signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs';
/**
* Подписать расписку (P10-c, router-mentor Машина 1). Возвращает копию record
* с полем sig (HMAC над record без sig). Без ключа — sig:null (downstream verify → false).
*/
export function signApprovalRecord(record, key) {
return { ...record, sig: signPayload(record, key, RECEIPT_DOMAINS.APPROVAL) };
}
/** Проверить подпись расписки. Неподписанная/подделанная/без ключа → false. */
export function verifyApprovalRecord(record, key) {
return verifyReceipt(record, key, RECEIPT_DOMAINS.APPROVAL);
}
/** Подписать floor_escape-пропуск (M6 FIX-5, домен FLOOR_ESCAPE — зеркало signApprovalRecord).
* Без ключа — sig:null (downstream key-gated verify → принять как сегодня / отбросить при ключе). */
export function signFloorEscapeRecord(record, key) {
return { ...record, sig: signPayload(record, key, RECEIPT_DOMAINS.FLOOR_ESCAPE) };
}
/** Проверить подпись floor_escape-пропуска. Неподписанная/подделанная/без ключа/чужой домен → false. */
export function verifyFloorEscapeRecord(record, key) {
return verifyReceipt(record, key, RECEIPT_DOMAINS.FLOOR_ESCAPE);
}