Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion hooks/pre-tool-use.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({}));
Expand Down
12 changes: 7 additions & 5 deletions lib/chronicle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -691,15 +691,17 @@ 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,
safeSummary,
classification,
rule_matched || null,
safeReason || null,
// Controlled audit marker (the consumed approval's row id), never secret-bearing.
user_override || null,
Date.now()
);
}
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down
96 changes: 82 additions & 14 deletions lib/coupling.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -40,44 +59,93 @@ 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 };
}

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
};
}

module.exports = {
escalateByMemory,
countOutcomes,
weighOutcomes,
recencyWeight,
MIN_SIMILAR,
FAILURE_RATIO,
MAX_REASON_ENTRIES
MAX_REASON_ENTRIES,
FAILURE_HALFLIFE_HOURS,
MIN_FAILURE_WEIGHT
};
56 changes: 56 additions & 0 deletions lib/query-normalize.js
Original file line number Diff line number Diff line change
@@ -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.<anonymous> (/p/f.js:1:2)"
const PY_FRAME = /^\s*File\s+"/; // ' File "/p/f.py", line 3, in <module>'
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 };
10 changes: 8 additions & 2 deletions lib/surface.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading