From d4bf3e42d38e975d245042c8397821382d713dab Mon Sep 17 00:00:00 2001 From: CAF Agent Date: Wed, 17 Jun 2026 23:44:40 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(coupling):=20recency-weighted=20memory?= =?UTF-8?q?=E2=86=92compass=20escalation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit escalateByMemory now decays each outcome-tagged entry by e^(-k·age) (half-life 72h) at recall time instead of flat-counting. Recent evidence arbitrates the failure-vs-success balance, and a live-failure-mass floor lets a cluster of purely stale failures fade rather than warn forever. Contract intact: OPEN→PAUSE only, never WITNESS, no new tables/writes, no row deletion — pure function of timestamps. Dateless entries weigh 1.0, so behavior is byte-identical for callers passing rows without created_at (full back-compat). Salvaged from the external optimization-spec review (the circuit-breaker section's one defensible kernel), folded into the existing coupling rather than a parallel failure_chronicle table. 291 tests green (+6 decay tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/coupling.js | 96 +++++++++++++++++++++++++++++++++++++++++-------- test/smoke.js | 68 ++++++++++++++++++++++++++++++++++- 2 files changed, 149 insertions(+), 15 deletions(-) diff --git a/lib/coupling.js b/lib/coupling.js index 3e5fac8..e0e1ec1 100644 --- a/lib/coupling.js +++ b/lib/coupling.js @@ -11,15 +11,34 @@ // OPEN → WITNESS, never downgrade. Hard denials stay rule-driven; memory // can warn, not absolve. // - Escalation requires N ≥ MIN_SIMILAR outcome-tagged entries (success or -// failure). Below that, the sample is too small to draw a signal. -// - Of those, ≥ FAILURE_RATIO must be tagged outcome:failure. +// failure). Below that, the sample is too small to draw a signal. This +// floor is a RAW count — recency cannot manufacture a sample. +// - Of those, the RECENCY-WEIGHTED failure share must be ≥ FAILURE_RATIO, so +// recent evidence arbitrates the failure-vs-success balance (a fresh success +// can outweigh stale failures, and a fresh failure can outweigh stale +// successes). +// - And the live failure MASS (Σ of decayed failure weights) must be ≥ +// MIN_FAILURE_WEIGHT, so a cluster of purely ancient failures decays below +// the bar and stops warning forever — stale failures fade. // - Untagged entries (pre-criterion-1 or ambiguous shapes) are ignored — // they contribute neither to numerator nor denominator. Silence is safe. -const MIN_SIMILAR = 3; // sample-size floor -const FAILURE_RATIO = 0.5; // escalate when failures ≥ this fraction +const MIN_SIMILAR = 3; // sample-size floor (raw count) +const FAILURE_RATIO = 0.5; // escalate when the weighted failure share ≥ this const MAX_REASON_ENTRIES = 3; // how many failure IDs to surface in the reason +// Read-time recency decay (pure). Each outcome-tagged entry contributes a weight +// w = e^(-k·Δh), where Δh is the entry's age in hours (clamped ≥ 0) and +// k = ln(2)/half-life. So a failure one half-life old counts half as much as a +// fresh one. The decay is computed at recall time from the entry's created_at — +// nothing is mutated, no row is pruned. Entries with no/invalid created_at are +// undateable and are NOT penalized (weight 1.0), which also keeps the policy +// identical to the old flat-count behaviour for callers that pass dateless rows. +const FAILURE_HALFLIFE_HOURS = 72; // 3 days: ~1d≈0.79, 2d≈0.63, 3d=0.50, 1wk≈0.20, 1mo≈0 +const MIN_FAILURE_WEIGHT = 1.0; // ≈ one fresh failure's worth of live evidence +const DECAY_K = Math.log(2) / FAILURE_HALFLIFE_HOURS; +const HOUR_MS = 3600000; + function entryHasTag(entry, tag) { if (!entry || !entry.tags) return false; if (Array.isArray(entry.tags)) return entry.tags.includes(tag); @@ -40,7 +59,33 @@ function countOutcomes(entries) { return { success, failure, total: success + failure, failureEntries }; } -function escalateByMemory(baseResult, similar_entries) { +// Recency weight for one entry, in (0, 1]. Undateable entries (missing or +// non-finite created_at) → 1.0 (no decay). Future timestamps clamp to the +// present, so clock skew can never inflate a weight above 1. +function recencyWeight(entry, now) { + const ts = entry && entry.created_at; + if (typeof ts !== 'number' || !Number.isFinite(ts)) return 1.0; + const ageHours = Math.max(0, (now - ts) / HOUR_MS); + return Math.exp(-DECAY_K * ageHours); +} + +// Like countOutcomes, but each outcome-tagged entry contributes its recency +// weight instead of 1. Pure; reads only tags + created_at. +function weighOutcomes(entries, now) { + let weightedSuccess = 0, weightedFailure = 0; + for (const e of entries || []) { + if (entryHasTag(e, 'outcome:failure')) { + weightedFailure += recencyWeight(e, now); + } else if (entryHasTag(e, 'outcome:success')) { + weightedSuccess += recencyWeight(e, now); + } + } + return { weightedSuccess, weightedFailure, weightedTotal: weightedSuccess + weightedFailure }; +} + +function round2(n) { return Math.round(n * 100) / 100; } + +function escalateByMemory(baseResult, similar_entries, now = Date.now()) { // Memory only touches OPEN. Rule-driven decisions stand. if (!baseResult || baseResult.classification !== 'OPEN') { return { ...baseResult, memory_escalated: false }; @@ -48,28 +93,47 @@ function escalateByMemory(baseResult, similar_entries) { const { success, failure, total, failureEntries } = countOutcomes(similar_entries); + // Sample-size floor: a RAW count. Recency reweights signal strength, but it + // must never let a thin sample speak. if (total < MIN_SIMILAR) { return { ...baseResult, memory_escalated: false, memory_stats: { success, failure, total } }; } - const ratio = failure / total; - if (ratio < FAILURE_RATIO) { - return { ...baseResult, memory_escalated: false, memory_stats: { success, failure, total } }; + const { weightedSuccess, weightedFailure, weightedTotal } = weighOutcomes(similar_entries, now); + const stats = { + success, failure, total, + weightedFailure: round2(weightedFailure), + weightedSuccess: round2(weightedSuccess) + }; + + // Recency-weighted balance: is this action class currently failure-prone? + const weightedRatio = weightedTotal > 0 ? weightedFailure / weightedTotal : 0; + if (weightedRatio < FAILURE_RATIO) { + return { ...baseResult, memory_escalated: false, memory_stats: stats }; + } + + // Live failure mass: has the failure evidence decayed away? A pile of ancient + // failures (no fresh ones) falls below the bar and is allowed to fade. + if (weightedFailure < MIN_FAILURE_WEIGHT) { + return { ...baseResult, memory_escalated: false, memory_stats: stats }; } - // Escalate. Surface the matched failure entries in the reason for auditability. - const shownFailures = failureEntries.slice(0, MAX_REASON_ENTRIES); + // Escalate. Surface the FRESHEST matched failures (most actionable) in the + // reason. (sort is stable, so dateless entries keep their input order.) + const shownFailures = [...failureEntries] + .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) + .slice(0, MAX_REASON_ENTRIES); const idList = shownFailures.map(e => `#${e.id}`).join(', '); const memoryReason = - `memory: ${failure}/${total} similar past actions tagged outcome:failure (${idList}). ` + - `Review before proceeding; if intentional, override via confirm_pending.`; + `memory: ${failure}/${total} similar past actions tagged outcome:failure (${idList}), ` + + `recency-weighted. Review before proceeding; if intentional, override via confirm_pending.`; return { classification: 'PAUSE', rule_id: 'memory:similar-failures', reason: memoryReason, memory_escalated: true, - memory_stats: { success, failure, total }, + memory_stats: stats, similar_entries: shownFailures }; } @@ -77,7 +141,11 @@ function escalateByMemory(baseResult, similar_entries) { module.exports = { escalateByMemory, countOutcomes, + weighOutcomes, + recencyWeight, MIN_SIMILAR, FAILURE_RATIO, - MAX_REASON_ENTRIES + MAX_REASON_ENTRIES, + FAILURE_HALFLIFE_HOURS, + MIN_FAILURE_WEIGHT }; diff --git a/test/smoke.js b/test/smoke.js index 00b56f4..cb50cbd 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -561,7 +561,7 @@ test('recall: tag filter is exact (outcome:fail does NOT match outcome:failure)' // entry list and assert the upgrade behavior. // ============================================================================ -const { escalateByMemory, countOutcomes, MIN_SIMILAR, FAILURE_RATIO } = require('../lib/coupling'); +const { escalateByMemory, countOutcomes, recencyWeight, MIN_SIMILAR, FAILURE_RATIO, FAILURE_HALFLIFE_HOURS, MIN_FAILURE_WEIGHT } = require('../lib/coupling'); function mkEntry(id, tags) { return { id, tags }; @@ -651,6 +651,72 @@ test('coupling: countOutcomes returns success/failure/total/failureEntries shape assert.strictEqual(r.failureEntries.length, 2); }); +// ---- recency decay (read-time, pure) ---- +// Each outcome entry's weight = e^(-k·age) with half-life FAILURE_HALFLIFE_HOURS. +// A fixed clock makes decay deterministic. mkEntry (dateless) is the no-decay +// back-compat case the tests above already exercise. +const DECAY_NOW = 1700000000000; +const HMS = 3600000; +function mkAged(id, tags, hoursAgo) { + return { id, tags, created_at: DECAY_NOW - hoursAgo * HMS }; +} + +test('coupling/decay: recencyWeight 1.0 fresh, 0.5 at one half-life, 1.0 when undateable/future', () => { + assert.ok(Math.abs(recencyWeight(mkAged(1, [], 0), DECAY_NOW) - 1.0) < 1e-9, 'age 0 → 1.0'); + assert.ok(Math.abs(recencyWeight(mkAged(1, [], FAILURE_HALFLIFE_HOURS), DECAY_NOW) - 0.5) < 1e-9, 'one half-life → 0.5'); + assert.strictEqual(recencyWeight({ id: 1, tags: [] }, DECAY_NOW), 1.0, 'no created_at → 1.0 (not penalized)'); + assert.strictEqual(recencyWeight({ id: 1, created_at: 'nope' }, DECAY_NOW), 1.0, 'non-numeric → 1.0'); + assert.strictEqual(recencyWeight(mkAged(1, [], -100), DECAY_NOW), 1.0, 'future timestamp clamps to 1.0 (no inflation)'); +}); + +test('coupling/decay: a FRESH failure cluster still escalates OPEN→PAUSE', () => { + const base = { classification: 'OPEN', rule_id: null, reason: null }; + const similar = [mkAged(1, ['outcome:failure'], 0), mkAged(2, ['outcome:failure'], 1), mkAged(3, ['outcome:failure'], 2)]; + const r = escalateByMemory(base, similar, DECAY_NOW); + assert.strictEqual(r.classification, 'PAUSE'); + assert.strictEqual(r.memory_escalated, true); +}); + +test('coupling/decay: a purely STALE failure cluster fades (live-mass floor blocks)', () => { + const base = { classification: 'OPEN', rule_id: null, reason: null }; + // 3 failures ~30 days old: weight ≈ 2^-10 each → mass ≈ 0.003 < MIN_FAILURE_WEIGHT. + const similar = [mkAged(1, ['outcome:failure'], 720), mkAged(2, ['outcome:failure'], 720), mkAged(3, ['outcome:failure'], 720)]; + const r = escalateByMemory(base, similar, DECAY_NOW); + assert.strictEqual(r.classification, 'OPEN', 'stale failures must not warn forever'); + assert.strictEqual(r.memory_escalated, false); + assert.strictEqual(r.memory_stats.total, 3, 'the raw sample is still counted'); + assert.ok(r.memory_stats.weightedFailure < MIN_FAILURE_WEIGHT, 'decayed failure mass is below the floor'); +}); + +test('coupling/decay: a recent SUCCESS outweighs stale failures (no escalation)', () => { + const base = { classification: 'OPEN', rule_id: null, reason: null }; + // 2 failures 10 days old (≈0.099 each) vs 1 fresh success (1.0) → weighted ratio < 0.5. + const similar = [mkAged(1, ['outcome:failure'], 240), mkAged(2, ['outcome:failure'], 240), mkAged(3, ['outcome:success'], 0)]; + const r = escalateByMemory(base, similar, DECAY_NOW); + assert.strictEqual(r.classification, 'OPEN', 'a fresh success means the action works now'); + assert.strictEqual(r.memory_escalated, false); +}); + +test('coupling/decay: a recent FAILURE outweighs stale successes (escalates at raw ratio 1/3)', () => { + const base = { classification: 'OPEN', rule_id: null, reason: null }; + // raw 1/3 = 0.33 would NOT escalate under flat counting; the fresh failure + // (1.0) vs two stale successes (≈0.099 each) flips the weighted balance. + const similar = [mkAged(1, ['outcome:failure'], 0), mkAged(2, ['outcome:success'], 240), mkAged(3, ['outcome:success'], 240)]; + const r = escalateByMemory(base, similar, DECAY_NOW); + assert.strictEqual(r.classification, 'PAUSE', 'the only recent signal is a failure'); + assert.strictEqual(r.memory_escalated, true); + assert.ok(r.reason.includes('1/3'), 'reason still reports the raw count'); +}); + +test('coupling/decay: dateless entries behave exactly like flat counting (back-compat)', () => { + const base = { classification: 'OPEN', rule_id: null, reason: null }; + // No created_at anywhere → every weight 1.0 → identical to the pre-decay policy. + const similar = [mkEntry(1, ['outcome:failure']), mkEntry(2, ['outcome:failure']), mkEntry(3, ['outcome:failure'])]; + const r = escalateByMemory(base, similar, DECAY_NOW); + assert.strictEqual(r.classification, 'PAUSE'); + assert.strictEqual(r.memory_escalated, true); +}); + test('coupling: thresholds exported and have sensible MVP values', () => { assert.strictEqual(MIN_SIMILAR, 3, 'MIN_SIMILAR should be 3 for MVP'); assert.strictEqual(FAILURE_RATIO, 0.5, 'FAILURE_RATIO should be 0.5 for MVP'); From 90ace028a4ced8eb76752fb783d449a539531d10 Mon Sep 17 00:00:00 2001 From: CAF Agent Date: Wed, 17 Jun 2026 23:49:19 -0400 Subject: [PATCH 2/3] feat(compass): record overridden-fire audit signal in compass_log.user_override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compass_log.user_override column has existed in the schema since phase 1 but was never written. On an approved PAUSE override the PreToolUse hook now sets it to the consumed approval's row id (not the token value), and getCompassHistory / getCompassSince surface it — so "this fire was overridden" is a queryable signal instead of being buried in the free-text reason. recall_compass exposes it for free. Salvaged from the spec review (security section): the one defensible piece of the proposed audit_compass_trail table, done without a new table, HMAC, CAS, or cross-process key — and routed through the existing logCompass scrub chokepoint. Co-Authored-By: Claude Opus 4.8 (1M context) --- hooks/pre-tool-use.js | 5 ++++- lib/chronicle.js | 12 +++++++----- test/smoke.js | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/hooks/pre-tool-use.js b/hooks/pre-tool-use.js index f93abe7..4146c68 100644 --- a/hooks/pre-tool-use.js +++ b/hooks/pre-tool-use.js @@ -135,7 +135,10 @@ async function main() { action_summary, classification: 'OPEN', rule_matched: result.rule_id, - reason: `approved via token ${approval.token} (consumed)` + reason: 'approved via single-use override token (consumed)', + // Queryable "this fire was overridden" audit signal. Store the consumed + // approval's row id, NOT the token value (schema.sql compass_log.user_override). + user_override: String(approval.id) }); } catch (_) {} process.stdout.write(JSON.stringify({})); diff --git a/lib/chronicle.js b/lib/chronicle.js index f457ee1..6db404b 100644 --- a/lib/chronicle.js +++ b/lib/chronicle.js @@ -676,7 +676,7 @@ function dismissMethodCandidate({ id }) { return { ok: true, id }; } -function logCompass({ session_id, tool_name, action_summary, classification, rule_matched, reason }) { +function logCompass({ session_id, tool_name, action_summary, classification, rule_matched, reason, user_override }) { // Redact-or-drop the two free-text columns. compass_log was the second leak // site: action_summary is the raw command (credential and all), and reason can // echo it. tool_name / classification / rule_matched are controlled vocabulary, @@ -691,8 +691,8 @@ function logCompass({ session_id, tool_name, action_summary, classification, rul return; } db().prepare(` - INSERT INTO compass_log (session_id, tool_name, action_summary, classification, rule_matched, reason, occurred_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO compass_log (session_id, tool_name, action_summary, classification, rule_matched, reason, user_override, occurred_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run( session_id || null, tool_name, @@ -700,6 +700,8 @@ function logCompass({ session_id, tool_name, action_summary, classification, rul classification, rule_matched || null, safeReason || null, + // Controlled audit marker (the consumed approval's row id), never secret-bearing. + user_override || null, Date.now() ); } @@ -726,7 +728,7 @@ function getCompassSince({ since_id = 0, limit = 50 } = {}) { try { return db() .prepare( - `SELECT id, session_id, tool_name, action_summary, classification, rule_matched, reason, occurred_at + `SELECT id, session_id, tool_name, action_summary, classification, rule_matched, reason, user_override, occurred_at FROM compass_log WHERE id > ? ORDER BY id ASC LIMIT ?` ) .all(since_id, limit); @@ -746,7 +748,7 @@ function getCompassHistory({ limit = 20, classification, matched_only } = {}) { clauses.push('rule_matched IS NOT NULL'); } const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; - const sql = `SELECT id, session_id, tool_name, action_summary, classification, rule_matched, reason, occurred_at FROM compass_log ${where} ORDER BY occurred_at DESC LIMIT ?`; + const sql = `SELECT id, session_id, tool_name, action_summary, classification, rule_matched, reason, user_override, occurred_at FROM compass_log ${where} ORDER BY occurred_at DESC LIMIT ?`; params.push(limit); return db().prepare(sql).all(...params); } diff --git a/test/smoke.js b/test/smoke.js index cb50cbd..7c21c5b 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -280,6 +280,23 @@ test('phase1: getCompassHistory classification filter narrows correctly', () => assert.ok(pause.every(e => e.classification === 'PAUSE'), 'PAUSE filter returned non-PAUSE rows'); }); +test('phase1: getCompassHistory surfaces user_override (overridden-fire audit signal)', () => { + const sid = 'phase1-override-' + Date.now(); + // An approved PAUSE override re-logs as OPEN carrying the consumed approval's id. + ch.logCompass({ + session_id: sid, tool_name: 'Bash', action_summary: 'ovr-' + sid, + classification: 'OPEN', rule_matched: 'memory:similar-failures', + reason: 'approved via single-use override token (consumed)', user_override: '4242' + }); + const row = ch.getCompassHistory({ limit: 200 }).find(e => e.action_summary === 'ovr-' + sid); + assert.ok(row, 'override row should be present'); + assert.strictEqual(row.user_override, '4242', 'user_override must round-trip through the read'); + // A normal fire leaves the column null — the signal is unambiguous. + ch.logCompass({ session_id: sid, tool_name: 'Bash', action_summary: 'plain-' + sid, classification: 'WITNESS', rule_matched: 'rm-rf', reason: 'r' }); + const plain = ch.getCompassHistory({ limit: 200 }).find(e => e.action_summary === 'plain-' + sid); + assert.strictEqual(plain.user_override, null, 'a non-override fire has null user_override'); +}); + // ============================================================================ // Phase 2: pending_confirmations lifecycle // ============================================================================ From 94395c1cea70e8732809bf17d1e12da7936349ec Mon Sep 17 00:00:00 2001 From: CAF Agent Date: Wed, 17 Jun 2026 23:52:34 -0400 Subject: [PATCH 3/3] feat(surface): de-noise pasted tracebacks before recall (error-fix matching) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New lib/query-normalize.js (pure, ReDoS-safe: line splits + linear regexes only) strips stack-frame lines, file paths, line:col offsets, and hex addresses from a prompt that contains a traceback, leaving the error identity (ModuleNotFoundError, sqlite3.OperationalError, ...) to lead the FTS query. surface.js applies it to the generic-recall query so a pasted error matches the v0.10 error-fix atlas instead of being diluted by path noise. No-op on ordinary prompts; never returns empty. Salvaged from the spec review (traceback section): the one defensible kernel, reframed as a recall-side query normalizer feeding the existing matcher — NOT the rejected sha256 frame-signature identity store. 297 tests green (+5). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/query-normalize.js | 56 ++++++++++++++++++++++++++++++++++++++++++ lib/surface.js | 10 ++++++-- test/smoke.js | 52 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 lib/query-normalize.js diff --git a/lib/query-normalize.js b/lib/query-normalize.js new file mode 100644 index 0000000..06ec1ad --- /dev/null +++ b/lib/query-normalize.js @@ -0,0 +1,56 @@ +'use strict'; + +// Recall-side query de-noiser (error-fix matching stage). +// +// When a user pastes a traceback/stack into the prompt, the file paths, +// line:col offsets, hex addresses, and frame lines flood the FTS query with +// noise tokens that bury the error identity (ModuleNotFoundError, +// sqlite3.OperationalError, ...) and dilute BM25 against the v0.10 error-fix +// atlas. This collapses a pasted traceback down to its error type + message so +// the matcher that already exists (recall() over insights_fts) can find the +// resolution. +// +// Deliberately NOT a signature / hash / frame-state-machine — that was the +// rejected half of the optimization spec's traceback section. This is a pure, +// allocation-light pass over line splits and LINEAR regexes only: no nested +// quantifiers, so it cannot catastrophically backtrack (ReDoS-safe by +// construction). It is a no-op on any text that shows no error signature, so +// ordinary prompts pass through verbatim. + +// An error paste announces itself with a typed exception, a Python Traceback +// header, or a panic/fatal marker. Linear alternation — no backtracking. +const ERROR_SIGNAL = + /[A-Za-z_$][\w$]*(?:Error|Exception|Warning)\b|Traceback \(most recent call last\)|(?:^|\n)\s*(?:panic|fatal):/; + +const V8_FRAME = /^\s*at\s/; // " at Object. (/p/f.js:1:2)" +const PY_FRAME = /^\s*File\s+"/; // ' File "/p/f.py", line 3, in ' +const CARET = /^\s*\^+\s*$/; // Python caret-underline line + +// Reduce a prompt that contains a traceback to its matchable error identity. +// Returns the input unchanged when it shows no error signature or is too short. +function denoiseErrorQuery(text) { + const s = String(text == null ? '' : text); + if (s.length < 16 || !ERROR_SIGNAL.test(s)) return s; + + // 1. Drop stack-frame lines — pure location noise, no matchable identity. + const kept = []; + for (const line of s.split('\n')) { + if (V8_FRAME.test(line) || PY_FRAME.test(line) || CARET.test(line)) continue; + kept.push(line); + } + + // 2. Strip residual location noise from the surviving header/message lines: + // memory addresses, :line[:col] suffixes, and long bare digit runs. + const cleaned = kept.join(' ') + .replace(/0x[0-9a-fA-F]+/g, ' ') + .replace(/:\d+(?::\d+)?/g, ' ') + .replace(/\b\d{4,}\b/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + // Never hand back an empty query (e.g. a paste that was ALL frames) — fall + // back to the original so recall still has something to anchor on. + return cleaned || s; +} + +module.exports = { denoiseErrorQuery }; diff --git a/lib/surface.js b/lib/surface.js index 3ed0709..40797a5 100644 --- a/lib/surface.js +++ b/lib/surface.js @@ -8,6 +8,8 @@ // and on a conversational/trivial prompt the generic insights are suppressed // entirely (a strong method and the goal may still show). +const { denoiseErrorQuery } = require('./query-normalize'); + const STOPWORDS = new Set([ 'the', 'a', 'an', 'to', 'of', 'for', 'and', 'or', 'in', 'on', 'is', 'are', 'this', 'that', 'with', 'my', 'your', 'it', 'be', 'at', 'as', 'do', 'we', @@ -94,8 +96,12 @@ function selectInjection({ goal, prompt, recall }) { if (!trivial) { try { const topK = goalText ? 2 : 3; - const promptTokens = significantTokens(prompt); - const raw = recall({ query: prompt, topK }) || []; + // De-noise a pasted traceback so the error identity (not file paths / + // line numbers) leads the FTS query against the error-fix atlas. No-op on + // ordinary prompts. + const query = denoiseErrorQuery(prompt); + const promptTokens = significantTokens(query); + const raw = recall({ query, topK }) || []; hits = raw // Defense in depth: methods surface ONLY via the targeted lookup above. // recall() already excludes domain:'method' by default, but drop it here diff --git a/test/smoke.js b/test/smoke.js index 7c21c5b..c0c4832 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -1218,6 +1218,58 @@ test('recordMethod: rejects empty shape / no steps; auto-distill ranks lower', ( // v0.3 Stage 2 step 2: recall-surface selection (lib/surface) — the cardinal rule // ============================================================================ const surface = require('../lib/surface'); +const { denoiseErrorQuery } = require('../lib/query-normalize'); + +test('query-normalize: ordinary (non-error) prompts pass through verbatim', () => { + for (const p of ['please refactor the auth module', 'how do I rotate the bridge token', 'add a test for surface.js']) + assert.strictEqual(denoiseErrorQuery(p), p, 'no error signature → unchanged'); +}); + +test('query-normalize: a V8 traceback collapses to its error identity', () => { + const prompt = [ + 'TypeError: Cannot read properties of undefined (reading "id")', + ' at Object. (/Users/me/proj/src/app.js:42:13)', + ' at Module._compile (node:internal/modules/cjs/loader:1234:14)' + ].join('\n'); + const q = denoiseErrorQuery(prompt); + assert.ok(/TypeError/.test(q) && /reading/.test(q), 'error type + message survive'); + assert.ok(!/Users|app\.js|loader|Object/.test(q), 'frame lines (paths, fn names) are dropped'); + assert.ok(!/42|13|1234/.test(q), 'line:col and long digit runs are stripped'); +}); + +test('query-normalize: a Python traceback keeps the exception, drops File frames', () => { + const prompt = [ + 'Traceback (most recent call last):', + ' File "/app/svc/db.py", line 88, in connect', + ' conn = sqlite3.connect(path)', + 'sqlite3.OperationalError: database is locked' + ].join('\n'); + const q = denoiseErrorQuery(prompt); + assert.ok(/OperationalError/.test(q) && /database is locked/.test(q), 'exception + message survive'); + assert.ok(!/db\.py/.test(q), 'File frame path is dropped'); + assert.ok(q.length > 0, 'never returns empty for a non-empty error paste'); +}); + +test('query-normalize: hex addresses scrubbed; frame-flood input stays linear-time', () => { + assert.ok(!/0x[0-9a-f]/i.test(denoiseErrorQuery('RuntimeError: segfault at 0xdeadbeef00')), 'hex address stripped'); + // A long run of repeated frame-shaped lines must not hang (no backtracking). + const big = 'TypeError: boom\n' + ' at f (/a/b.js:1:2)\n'.repeat(5000); + const q = denoiseErrorQuery(big); + assert.ok(/TypeError/.test(q) && q.length < 100, 'frames collapsed, identity kept'); +}); + +test('surface.selectInjection: a pasted traceback is de-noised before it hits recall()', () => { + const prompt = [ + 'ReferenceError: foo is not defined', + ' at Object. (/Users/me/proj/lib/thing.js:42:13)', + ' at Module._compile (node:internal/modules/cjs/loader:1234:14)' + ].join('\n'); + let genericQuery = null; + const recall = ({ query, tag }) => { if (!tag) genericQuery = query; return []; }; + surface.selectInjection({ goal: null, prompt, recall }); + assert.ok(genericQuery && /ReferenceError/.test(genericQuery), 'error type reaches recall'); + assert.ok(!/Users|thing\.js|loader/.test(genericQuery), 'path/frame noise stripped from the recall query'); +}); test('surface.isTrivialPrompt: acks + short-no-token trivial; task prompts not', () => { for (const p of ['yea', 'nice thats fine', 'ok', 'try hq again', 'merge it', 'keep going'])