diff --git a/codex-marketplace/plugins/neatcontext/src/core/routing-candidates.mjs b/codex-marketplace/plugins/neatcontext/src/core/routing-candidates.mjs index ae3ad79..4ccd0d6 100644 --- a/codex-marketplace/plugins/neatcontext/src/core/routing-candidates.mjs +++ b/codex-marketplace/plugins/neatcontext/src/core/routing-candidates.mjs @@ -18,6 +18,7 @@ // has in hand — a context's revision and timestamps, a card's timestamp — so // checking costs no extra reads. Only a real change pays for a rebuild. +import { declineFactor } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -107,11 +108,20 @@ export function createRoutingIndex({ listFiles }) { key = next; } const names = new Map(contexts.map((context) => [context.id, context.name])); - return rank(index, query, options).map((result) => ({ - id: result.id, - name: names.get(result.id), - score: result.score, - matched: result.matched - })); + const now = new Date(); + // Past refusals are applied after ranking rather than folded into the + // index: they change on their own schedule, and rebuilding the index every + // time someone says no would throw away the cache for a multiplier. + // + // Re-sorted afterwards because a discount can change the order, and the + // shortlist's whole meaning is that it is in order. + return rank(index, query, options) + .map((result) => ({ + id: result.id, + name: names.get(result.id), + score: result.score * declineFactor(state, result.id, now), + matched: result.matched + })) + .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); }; } diff --git a/codex-marketplace/plugins/neatcontext/src/core/routing.mjs b/codex-marketplace/plugins/neatcontext/src/core/routing.mjs index 1a25209..5be0c74 100644 --- a/codex-marketplace/plugins/neatcontext/src/core/routing.mjs +++ b/codex-marketplace/plugins/neatcontext/src/core/routing.mjs @@ -47,6 +47,24 @@ const MAX_ALIASES = 12; const MAX_DECISIONS = 100; const MAX_SESSIONS = 20; +// How long a refusal keeps counting for. +// +// "Not that one" is true tomorrow as well, so forgetting it when the window +// closes throws away the clearest signal the user ever gives. But it is +// evidence, not a rule: a context refused during one week's work should not be +// unreachable a quarter later. So it fades — half its strength every two weeks, +// negligible by six — and refusing the same context again resets the clock and +// deepens it, which is the escalation a browser uses for a permission prompt +// dismissed several times over. Once is noise; three times is an answer. +// +// Nothing here ever becomes permanent. The only permanent no in this plugin is +// manual mode, because that one is a decision the user made on purpose. +const DECLINE_HALF_LIFE_DAYS = 14; +const DECLINE_LIFETIME_DAYS = 42; +const DECLINE_WEIGHT = 0.4; +const MAX_DECLINE_COUNT = 10; +const DAY_MS = 24 * 60 * 60 * 1000; + export function routingFilePath() { return path.join(neatContextHome(), "plugin-routing.json"); } @@ -85,8 +103,15 @@ export async function readRouting() { cards[id] = card; } } + const declines = {}; + for (const [id, raw] of Object.entries(parsed?.declines ?? {})) { + if (typeof raw?.at === "string" && Number.isInteger(raw.count) && raw.count > 0) { + declines[id] = { at: raw.at, count: Math.min(raw.count, MAX_DECLINE_COUNT) }; + } + } return { schema: SCHEMA, + declines, mode: MODES.includes(parsed?.mode) ? parsed.mode : DEFAULT_MODE, cards, sessions: typeof parsed?.sessions === "object" && parsed.sessions !== null ? parsed.sessions : {}, @@ -103,7 +128,16 @@ async function writeRouting(state) { await mkdir(path.dirname(file), { recursive: true }); await writeFile( file, - `${JSON.stringify({ ...state, sessions: Object.fromEntries(sessions), decisions: state.decisions.slice(-MAX_DECISIONS) }, null, 2)}\n`, + `${JSON.stringify( + { + ...state, + sessions: Object.fromEntries(sessions), + declines: pruneDeclines(state.declines, Date.now()), + decisions: state.decisions.slice(-MAX_DECISIONS) + }, + null, + 2 + )}\n`, { encoding: "utf8", mode: 0o600 } ); } @@ -226,22 +260,66 @@ export function switchPolicy(state, { id, targetId, connectedId, requested = fal // Remembering a refusal is what stops auto mode from proposing the same wrong // context on every message that shares a word with it. -export function noteDeclined(targetId, { id = sessionId() } = {}) { - if (!id) { - return Promise.resolve(null); - } +// +// Two records, because a refusal means two different things. Inside the session +// it is absolute — that context is not raised again, full stop. Outside it, it +// is evidence that fades: the same request should still reach the same context +// eventually, just not today and not without something better to go on. +export function noteDeclined(targetId, { id = sessionId(), now = new Date() } = {}) { return update((state) => { - const session = state.sessions[id] ?? {}; - const declined = session.declined ?? []; - state.sessions[id] = { - ...session, - declined: declined.includes(targetId) ? declined : [...declined, targetId], - updatedAt: new Date().toISOString() + const previous = state.declines[targetId]; + state.declines[targetId] = { + at: now.toISOString(), + // Repeating a refusal restarts the clock and deepens it, rather than + // simply re-stating what was already known. + count: Math.min((previous?.count ?? 0) + 1, MAX_DECLINE_COUNT) }; + if (id) { + const session = state.sessions[id] ?? {}; + const declined = session.declined ?? []; + state.sessions[id] = { + ...session, + declined: declined.includes(targetId) ? declined : [...declined, targetId], + updatedAt: now.toISOString() + }; + } return targetId; }); } +// What a past refusal does to a context's score now: a multiplier in (0, 1], +// where 1 means it is not holding anything back. +// +// Strength halves every two weeks, so a fresh single refusal costs a candidate +// most of its lead and a six-week-old one costs it almost nothing. Repeats +// compound rather than add, which keeps the result inside the range however +// many there have been. +export function declineFactor(state, contextId, now = new Date()) { + const entry = state.declines?.[contextId]; + if (!entry) { + return 1; + } + const days = (now.getTime() - Date.parse(entry.at)) / DAY_MS; + if (!Number.isFinite(days) || days >= DECLINE_LIFETIME_DAYS) { + return 1; + } + const strength = DECLINE_WEIGHT * 0.5 ** (Math.max(days, 0) / DECLINE_HALF_LIFE_DAYS); + return (1 - strength) ** entry.count; +} + +// Dropped once it can no longer change an outcome, so the file does not +// accumulate a record of every context ever turned down. +function pruneDeclines(declines, now) { + const kept = {}; + for (const [id, entry] of Object.entries(declines)) { + const days = (now - Date.parse(entry.at)) / DAY_MS; + if (Number.isFinite(days) && days < DECLINE_LIFETIME_DAYS) { + kept[id] = entry; + } + } + return kept; +} + // Every switch, with what it was routing away from and why. Thresholds and card // quality are guesses until this has something in it: manual selections are the // ground truth that says whether the derived lines actually route correctly. diff --git a/plugins/claude-code/neatcontext/src/core/routing-candidates.mjs b/plugins/claude-code/neatcontext/src/core/routing-candidates.mjs index ae3ad79..4ccd0d6 100644 --- a/plugins/claude-code/neatcontext/src/core/routing-candidates.mjs +++ b/plugins/claude-code/neatcontext/src/core/routing-candidates.mjs @@ -18,6 +18,7 @@ // has in hand — a context's revision and timestamps, a card's timestamp — so // checking costs no extra reads. Only a real change pays for a rebuild. +import { declineFactor } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -107,11 +108,20 @@ export function createRoutingIndex({ listFiles }) { key = next; } const names = new Map(contexts.map((context) => [context.id, context.name])); - return rank(index, query, options).map((result) => ({ - id: result.id, - name: names.get(result.id), - score: result.score, - matched: result.matched - })); + const now = new Date(); + // Past refusals are applied after ranking rather than folded into the + // index: they change on their own schedule, and rebuilding the index every + // time someone says no would throw away the cache for a multiplier. + // + // Re-sorted afterwards because a discount can change the order, and the + // shortlist's whole meaning is that it is in order. + return rank(index, query, options) + .map((result) => ({ + id: result.id, + name: names.get(result.id), + score: result.score * declineFactor(state, result.id, now), + matched: result.matched + })) + .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); }; } diff --git a/plugins/claude-code/neatcontext/src/core/routing.mjs b/plugins/claude-code/neatcontext/src/core/routing.mjs index 1a25209..5be0c74 100644 --- a/plugins/claude-code/neatcontext/src/core/routing.mjs +++ b/plugins/claude-code/neatcontext/src/core/routing.mjs @@ -47,6 +47,24 @@ const MAX_ALIASES = 12; const MAX_DECISIONS = 100; const MAX_SESSIONS = 20; +// How long a refusal keeps counting for. +// +// "Not that one" is true tomorrow as well, so forgetting it when the window +// closes throws away the clearest signal the user ever gives. But it is +// evidence, not a rule: a context refused during one week's work should not be +// unreachable a quarter later. So it fades — half its strength every two weeks, +// negligible by six — and refusing the same context again resets the clock and +// deepens it, which is the escalation a browser uses for a permission prompt +// dismissed several times over. Once is noise; three times is an answer. +// +// Nothing here ever becomes permanent. The only permanent no in this plugin is +// manual mode, because that one is a decision the user made on purpose. +const DECLINE_HALF_LIFE_DAYS = 14; +const DECLINE_LIFETIME_DAYS = 42; +const DECLINE_WEIGHT = 0.4; +const MAX_DECLINE_COUNT = 10; +const DAY_MS = 24 * 60 * 60 * 1000; + export function routingFilePath() { return path.join(neatContextHome(), "plugin-routing.json"); } @@ -85,8 +103,15 @@ export async function readRouting() { cards[id] = card; } } + const declines = {}; + for (const [id, raw] of Object.entries(parsed?.declines ?? {})) { + if (typeof raw?.at === "string" && Number.isInteger(raw.count) && raw.count > 0) { + declines[id] = { at: raw.at, count: Math.min(raw.count, MAX_DECLINE_COUNT) }; + } + } return { schema: SCHEMA, + declines, mode: MODES.includes(parsed?.mode) ? parsed.mode : DEFAULT_MODE, cards, sessions: typeof parsed?.sessions === "object" && parsed.sessions !== null ? parsed.sessions : {}, @@ -103,7 +128,16 @@ async function writeRouting(state) { await mkdir(path.dirname(file), { recursive: true }); await writeFile( file, - `${JSON.stringify({ ...state, sessions: Object.fromEntries(sessions), decisions: state.decisions.slice(-MAX_DECISIONS) }, null, 2)}\n`, + `${JSON.stringify( + { + ...state, + sessions: Object.fromEntries(sessions), + declines: pruneDeclines(state.declines, Date.now()), + decisions: state.decisions.slice(-MAX_DECISIONS) + }, + null, + 2 + )}\n`, { encoding: "utf8", mode: 0o600 } ); } @@ -226,22 +260,66 @@ export function switchPolicy(state, { id, targetId, connectedId, requested = fal // Remembering a refusal is what stops auto mode from proposing the same wrong // context on every message that shares a word with it. -export function noteDeclined(targetId, { id = sessionId() } = {}) { - if (!id) { - return Promise.resolve(null); - } +// +// Two records, because a refusal means two different things. Inside the session +// it is absolute — that context is not raised again, full stop. Outside it, it +// is evidence that fades: the same request should still reach the same context +// eventually, just not today and not without something better to go on. +export function noteDeclined(targetId, { id = sessionId(), now = new Date() } = {}) { return update((state) => { - const session = state.sessions[id] ?? {}; - const declined = session.declined ?? []; - state.sessions[id] = { - ...session, - declined: declined.includes(targetId) ? declined : [...declined, targetId], - updatedAt: new Date().toISOString() + const previous = state.declines[targetId]; + state.declines[targetId] = { + at: now.toISOString(), + // Repeating a refusal restarts the clock and deepens it, rather than + // simply re-stating what was already known. + count: Math.min((previous?.count ?? 0) + 1, MAX_DECLINE_COUNT) }; + if (id) { + const session = state.sessions[id] ?? {}; + const declined = session.declined ?? []; + state.sessions[id] = { + ...session, + declined: declined.includes(targetId) ? declined : [...declined, targetId], + updatedAt: now.toISOString() + }; + } return targetId; }); } +// What a past refusal does to a context's score now: a multiplier in (0, 1], +// where 1 means it is not holding anything back. +// +// Strength halves every two weeks, so a fresh single refusal costs a candidate +// most of its lead and a six-week-old one costs it almost nothing. Repeats +// compound rather than add, which keeps the result inside the range however +// many there have been. +export function declineFactor(state, contextId, now = new Date()) { + const entry = state.declines?.[contextId]; + if (!entry) { + return 1; + } + const days = (now.getTime() - Date.parse(entry.at)) / DAY_MS; + if (!Number.isFinite(days) || days >= DECLINE_LIFETIME_DAYS) { + return 1; + } + const strength = DECLINE_WEIGHT * 0.5 ** (Math.max(days, 0) / DECLINE_HALF_LIFE_DAYS); + return (1 - strength) ** entry.count; +} + +// Dropped once it can no longer change an outcome, so the file does not +// accumulate a record of every context ever turned down. +function pruneDeclines(declines, now) { + const kept = {}; + for (const [id, entry] of Object.entries(declines)) { + const days = (now - Date.parse(entry.at)) / DAY_MS; + if (Number.isFinite(days) && days < DECLINE_LIFETIME_DAYS) { + kept[id] = entry; + } + } + return kept; +} + // Every switch, with what it was routing away from and why. Thresholds and card // quality are guesses until this has something in it: manual selections are the // ground truth that says whether the derived lines actually route correctly. diff --git a/plugins/copilot/neatcontext/src/core/routing-candidates.mjs b/plugins/copilot/neatcontext/src/core/routing-candidates.mjs index ae3ad79..4ccd0d6 100644 --- a/plugins/copilot/neatcontext/src/core/routing-candidates.mjs +++ b/plugins/copilot/neatcontext/src/core/routing-candidates.mjs @@ -18,6 +18,7 @@ // has in hand — a context's revision and timestamps, a card's timestamp — so // checking costs no extra reads. Only a real change pays for a rebuild. +import { declineFactor } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -107,11 +108,20 @@ export function createRoutingIndex({ listFiles }) { key = next; } const names = new Map(contexts.map((context) => [context.id, context.name])); - return rank(index, query, options).map((result) => ({ - id: result.id, - name: names.get(result.id), - score: result.score, - matched: result.matched - })); + const now = new Date(); + // Past refusals are applied after ranking rather than folded into the + // index: they change on their own schedule, and rebuilding the index every + // time someone says no would throw away the cache for a multiplier. + // + // Re-sorted afterwards because a discount can change the order, and the + // shortlist's whole meaning is that it is in order. + return rank(index, query, options) + .map((result) => ({ + id: result.id, + name: names.get(result.id), + score: result.score * declineFactor(state, result.id, now), + matched: result.matched + })) + .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); }; } diff --git a/plugins/copilot/neatcontext/src/core/routing.mjs b/plugins/copilot/neatcontext/src/core/routing.mjs index 1a25209..5be0c74 100644 --- a/plugins/copilot/neatcontext/src/core/routing.mjs +++ b/plugins/copilot/neatcontext/src/core/routing.mjs @@ -47,6 +47,24 @@ const MAX_ALIASES = 12; const MAX_DECISIONS = 100; const MAX_SESSIONS = 20; +// How long a refusal keeps counting for. +// +// "Not that one" is true tomorrow as well, so forgetting it when the window +// closes throws away the clearest signal the user ever gives. But it is +// evidence, not a rule: a context refused during one week's work should not be +// unreachable a quarter later. So it fades — half its strength every two weeks, +// negligible by six — and refusing the same context again resets the clock and +// deepens it, which is the escalation a browser uses for a permission prompt +// dismissed several times over. Once is noise; three times is an answer. +// +// Nothing here ever becomes permanent. The only permanent no in this plugin is +// manual mode, because that one is a decision the user made on purpose. +const DECLINE_HALF_LIFE_DAYS = 14; +const DECLINE_LIFETIME_DAYS = 42; +const DECLINE_WEIGHT = 0.4; +const MAX_DECLINE_COUNT = 10; +const DAY_MS = 24 * 60 * 60 * 1000; + export function routingFilePath() { return path.join(neatContextHome(), "plugin-routing.json"); } @@ -85,8 +103,15 @@ export async function readRouting() { cards[id] = card; } } + const declines = {}; + for (const [id, raw] of Object.entries(parsed?.declines ?? {})) { + if (typeof raw?.at === "string" && Number.isInteger(raw.count) && raw.count > 0) { + declines[id] = { at: raw.at, count: Math.min(raw.count, MAX_DECLINE_COUNT) }; + } + } return { schema: SCHEMA, + declines, mode: MODES.includes(parsed?.mode) ? parsed.mode : DEFAULT_MODE, cards, sessions: typeof parsed?.sessions === "object" && parsed.sessions !== null ? parsed.sessions : {}, @@ -103,7 +128,16 @@ async function writeRouting(state) { await mkdir(path.dirname(file), { recursive: true }); await writeFile( file, - `${JSON.stringify({ ...state, sessions: Object.fromEntries(sessions), decisions: state.decisions.slice(-MAX_DECISIONS) }, null, 2)}\n`, + `${JSON.stringify( + { + ...state, + sessions: Object.fromEntries(sessions), + declines: pruneDeclines(state.declines, Date.now()), + decisions: state.decisions.slice(-MAX_DECISIONS) + }, + null, + 2 + )}\n`, { encoding: "utf8", mode: 0o600 } ); } @@ -226,22 +260,66 @@ export function switchPolicy(state, { id, targetId, connectedId, requested = fal // Remembering a refusal is what stops auto mode from proposing the same wrong // context on every message that shares a word with it. -export function noteDeclined(targetId, { id = sessionId() } = {}) { - if (!id) { - return Promise.resolve(null); - } +// +// Two records, because a refusal means two different things. Inside the session +// it is absolute — that context is not raised again, full stop. Outside it, it +// is evidence that fades: the same request should still reach the same context +// eventually, just not today and not without something better to go on. +export function noteDeclined(targetId, { id = sessionId(), now = new Date() } = {}) { return update((state) => { - const session = state.sessions[id] ?? {}; - const declined = session.declined ?? []; - state.sessions[id] = { - ...session, - declined: declined.includes(targetId) ? declined : [...declined, targetId], - updatedAt: new Date().toISOString() + const previous = state.declines[targetId]; + state.declines[targetId] = { + at: now.toISOString(), + // Repeating a refusal restarts the clock and deepens it, rather than + // simply re-stating what was already known. + count: Math.min((previous?.count ?? 0) + 1, MAX_DECLINE_COUNT) }; + if (id) { + const session = state.sessions[id] ?? {}; + const declined = session.declined ?? []; + state.sessions[id] = { + ...session, + declined: declined.includes(targetId) ? declined : [...declined, targetId], + updatedAt: now.toISOString() + }; + } return targetId; }); } +// What a past refusal does to a context's score now: a multiplier in (0, 1], +// where 1 means it is not holding anything back. +// +// Strength halves every two weeks, so a fresh single refusal costs a candidate +// most of its lead and a six-week-old one costs it almost nothing. Repeats +// compound rather than add, which keeps the result inside the range however +// many there have been. +export function declineFactor(state, contextId, now = new Date()) { + const entry = state.declines?.[contextId]; + if (!entry) { + return 1; + } + const days = (now.getTime() - Date.parse(entry.at)) / DAY_MS; + if (!Number.isFinite(days) || days >= DECLINE_LIFETIME_DAYS) { + return 1; + } + const strength = DECLINE_WEIGHT * 0.5 ** (Math.max(days, 0) / DECLINE_HALF_LIFE_DAYS); + return (1 - strength) ** entry.count; +} + +// Dropped once it can no longer change an outcome, so the file does not +// accumulate a record of every context ever turned down. +function pruneDeclines(declines, now) { + const kept = {}; + for (const [id, entry] of Object.entries(declines)) { + const days = (now - Date.parse(entry.at)) / DAY_MS; + if (Number.isFinite(days) && days < DECLINE_LIFETIME_DAYS) { + kept[id] = entry; + } + } + return kept; +} + // Every switch, with what it was routing away from and why. Thresholds and card // quality are guesses until this has something in it: manual selections are the // ground truth that says whether the derived lines actually route correctly. diff --git a/plugins/kimi-code/neatcontext/src/core/routing-candidates.mjs b/plugins/kimi-code/neatcontext/src/core/routing-candidates.mjs index ae3ad79..4ccd0d6 100644 --- a/plugins/kimi-code/neatcontext/src/core/routing-candidates.mjs +++ b/plugins/kimi-code/neatcontext/src/core/routing-candidates.mjs @@ -18,6 +18,7 @@ // has in hand — a context's revision and timestamps, a card's timestamp — so // checking costs no extra reads. Only a real change pays for a rebuild. +import { declineFactor } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -107,11 +108,20 @@ export function createRoutingIndex({ listFiles }) { key = next; } const names = new Map(contexts.map((context) => [context.id, context.name])); - return rank(index, query, options).map((result) => ({ - id: result.id, - name: names.get(result.id), - score: result.score, - matched: result.matched - })); + const now = new Date(); + // Past refusals are applied after ranking rather than folded into the + // index: they change on their own schedule, and rebuilding the index every + // time someone says no would throw away the cache for a multiplier. + // + // Re-sorted afterwards because a discount can change the order, and the + // shortlist's whole meaning is that it is in order. + return rank(index, query, options) + .map((result) => ({ + id: result.id, + name: names.get(result.id), + score: result.score * declineFactor(state, result.id, now), + matched: result.matched + })) + .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); }; } diff --git a/plugins/kimi-code/neatcontext/src/core/routing.mjs b/plugins/kimi-code/neatcontext/src/core/routing.mjs index 1a25209..5be0c74 100644 --- a/plugins/kimi-code/neatcontext/src/core/routing.mjs +++ b/plugins/kimi-code/neatcontext/src/core/routing.mjs @@ -47,6 +47,24 @@ const MAX_ALIASES = 12; const MAX_DECISIONS = 100; const MAX_SESSIONS = 20; +// How long a refusal keeps counting for. +// +// "Not that one" is true tomorrow as well, so forgetting it when the window +// closes throws away the clearest signal the user ever gives. But it is +// evidence, not a rule: a context refused during one week's work should not be +// unreachable a quarter later. So it fades — half its strength every two weeks, +// negligible by six — and refusing the same context again resets the clock and +// deepens it, which is the escalation a browser uses for a permission prompt +// dismissed several times over. Once is noise; three times is an answer. +// +// Nothing here ever becomes permanent. The only permanent no in this plugin is +// manual mode, because that one is a decision the user made on purpose. +const DECLINE_HALF_LIFE_DAYS = 14; +const DECLINE_LIFETIME_DAYS = 42; +const DECLINE_WEIGHT = 0.4; +const MAX_DECLINE_COUNT = 10; +const DAY_MS = 24 * 60 * 60 * 1000; + export function routingFilePath() { return path.join(neatContextHome(), "plugin-routing.json"); } @@ -85,8 +103,15 @@ export async function readRouting() { cards[id] = card; } } + const declines = {}; + for (const [id, raw] of Object.entries(parsed?.declines ?? {})) { + if (typeof raw?.at === "string" && Number.isInteger(raw.count) && raw.count > 0) { + declines[id] = { at: raw.at, count: Math.min(raw.count, MAX_DECLINE_COUNT) }; + } + } return { schema: SCHEMA, + declines, mode: MODES.includes(parsed?.mode) ? parsed.mode : DEFAULT_MODE, cards, sessions: typeof parsed?.sessions === "object" && parsed.sessions !== null ? parsed.sessions : {}, @@ -103,7 +128,16 @@ async function writeRouting(state) { await mkdir(path.dirname(file), { recursive: true }); await writeFile( file, - `${JSON.stringify({ ...state, sessions: Object.fromEntries(sessions), decisions: state.decisions.slice(-MAX_DECISIONS) }, null, 2)}\n`, + `${JSON.stringify( + { + ...state, + sessions: Object.fromEntries(sessions), + declines: pruneDeclines(state.declines, Date.now()), + decisions: state.decisions.slice(-MAX_DECISIONS) + }, + null, + 2 + )}\n`, { encoding: "utf8", mode: 0o600 } ); } @@ -226,22 +260,66 @@ export function switchPolicy(state, { id, targetId, connectedId, requested = fal // Remembering a refusal is what stops auto mode from proposing the same wrong // context on every message that shares a word with it. -export function noteDeclined(targetId, { id = sessionId() } = {}) { - if (!id) { - return Promise.resolve(null); - } +// +// Two records, because a refusal means two different things. Inside the session +// it is absolute — that context is not raised again, full stop. Outside it, it +// is evidence that fades: the same request should still reach the same context +// eventually, just not today and not without something better to go on. +export function noteDeclined(targetId, { id = sessionId(), now = new Date() } = {}) { return update((state) => { - const session = state.sessions[id] ?? {}; - const declined = session.declined ?? []; - state.sessions[id] = { - ...session, - declined: declined.includes(targetId) ? declined : [...declined, targetId], - updatedAt: new Date().toISOString() + const previous = state.declines[targetId]; + state.declines[targetId] = { + at: now.toISOString(), + // Repeating a refusal restarts the clock and deepens it, rather than + // simply re-stating what was already known. + count: Math.min((previous?.count ?? 0) + 1, MAX_DECLINE_COUNT) }; + if (id) { + const session = state.sessions[id] ?? {}; + const declined = session.declined ?? []; + state.sessions[id] = { + ...session, + declined: declined.includes(targetId) ? declined : [...declined, targetId], + updatedAt: now.toISOString() + }; + } return targetId; }); } +// What a past refusal does to a context's score now: a multiplier in (0, 1], +// where 1 means it is not holding anything back. +// +// Strength halves every two weeks, so a fresh single refusal costs a candidate +// most of its lead and a six-week-old one costs it almost nothing. Repeats +// compound rather than add, which keeps the result inside the range however +// many there have been. +export function declineFactor(state, contextId, now = new Date()) { + const entry = state.declines?.[contextId]; + if (!entry) { + return 1; + } + const days = (now.getTime() - Date.parse(entry.at)) / DAY_MS; + if (!Number.isFinite(days) || days >= DECLINE_LIFETIME_DAYS) { + return 1; + } + const strength = DECLINE_WEIGHT * 0.5 ** (Math.max(days, 0) / DECLINE_HALF_LIFE_DAYS); + return (1 - strength) ** entry.count; +} + +// Dropped once it can no longer change an outcome, so the file does not +// accumulate a record of every context ever turned down. +function pruneDeclines(declines, now) { + const kept = {}; + for (const [id, entry] of Object.entries(declines)) { + const days = (now - Date.parse(entry.at)) / DAY_MS; + if (Number.isFinite(days) && days < DECLINE_LIFETIME_DAYS) { + kept[id] = entry; + } + } + return kept; +} + // Every switch, with what it was routing away from and why. Thresholds and card // quality are guesses until this has something in it: manual selections are the // ground truth that says whether the derived lines actually route correctly. diff --git a/plugins/pi/neatcontext/src/core/routing-candidates.mjs b/plugins/pi/neatcontext/src/core/routing-candidates.mjs index ae3ad79..4ccd0d6 100644 --- a/plugins/pi/neatcontext/src/core/routing-candidates.mjs +++ b/plugins/pi/neatcontext/src/core/routing-candidates.mjs @@ -18,6 +18,7 @@ // has in hand — a context's revision and timestamps, a card's timestamp — so // checking costs no extra reads. Only a real change pays for a rebuild. +import { declineFactor } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -107,11 +108,20 @@ export function createRoutingIndex({ listFiles }) { key = next; } const names = new Map(contexts.map((context) => [context.id, context.name])); - return rank(index, query, options).map((result) => ({ - id: result.id, - name: names.get(result.id), - score: result.score, - matched: result.matched - })); + const now = new Date(); + // Past refusals are applied after ranking rather than folded into the + // index: they change on their own schedule, and rebuilding the index every + // time someone says no would throw away the cache for a multiplier. + // + // Re-sorted afterwards because a discount can change the order, and the + // shortlist's whole meaning is that it is in order. + return rank(index, query, options) + .map((result) => ({ + id: result.id, + name: names.get(result.id), + score: result.score * declineFactor(state, result.id, now), + matched: result.matched + })) + .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); }; } diff --git a/plugins/pi/neatcontext/src/core/routing.mjs b/plugins/pi/neatcontext/src/core/routing.mjs index 1a25209..5be0c74 100644 --- a/plugins/pi/neatcontext/src/core/routing.mjs +++ b/plugins/pi/neatcontext/src/core/routing.mjs @@ -47,6 +47,24 @@ const MAX_ALIASES = 12; const MAX_DECISIONS = 100; const MAX_SESSIONS = 20; +// How long a refusal keeps counting for. +// +// "Not that one" is true tomorrow as well, so forgetting it when the window +// closes throws away the clearest signal the user ever gives. But it is +// evidence, not a rule: a context refused during one week's work should not be +// unreachable a quarter later. So it fades — half its strength every two weeks, +// negligible by six — and refusing the same context again resets the clock and +// deepens it, which is the escalation a browser uses for a permission prompt +// dismissed several times over. Once is noise; three times is an answer. +// +// Nothing here ever becomes permanent. The only permanent no in this plugin is +// manual mode, because that one is a decision the user made on purpose. +const DECLINE_HALF_LIFE_DAYS = 14; +const DECLINE_LIFETIME_DAYS = 42; +const DECLINE_WEIGHT = 0.4; +const MAX_DECLINE_COUNT = 10; +const DAY_MS = 24 * 60 * 60 * 1000; + export function routingFilePath() { return path.join(neatContextHome(), "plugin-routing.json"); } @@ -85,8 +103,15 @@ export async function readRouting() { cards[id] = card; } } + const declines = {}; + for (const [id, raw] of Object.entries(parsed?.declines ?? {})) { + if (typeof raw?.at === "string" && Number.isInteger(raw.count) && raw.count > 0) { + declines[id] = { at: raw.at, count: Math.min(raw.count, MAX_DECLINE_COUNT) }; + } + } return { schema: SCHEMA, + declines, mode: MODES.includes(parsed?.mode) ? parsed.mode : DEFAULT_MODE, cards, sessions: typeof parsed?.sessions === "object" && parsed.sessions !== null ? parsed.sessions : {}, @@ -103,7 +128,16 @@ async function writeRouting(state) { await mkdir(path.dirname(file), { recursive: true }); await writeFile( file, - `${JSON.stringify({ ...state, sessions: Object.fromEntries(sessions), decisions: state.decisions.slice(-MAX_DECISIONS) }, null, 2)}\n`, + `${JSON.stringify( + { + ...state, + sessions: Object.fromEntries(sessions), + declines: pruneDeclines(state.declines, Date.now()), + decisions: state.decisions.slice(-MAX_DECISIONS) + }, + null, + 2 + )}\n`, { encoding: "utf8", mode: 0o600 } ); } @@ -226,22 +260,66 @@ export function switchPolicy(state, { id, targetId, connectedId, requested = fal // Remembering a refusal is what stops auto mode from proposing the same wrong // context on every message that shares a word with it. -export function noteDeclined(targetId, { id = sessionId() } = {}) { - if (!id) { - return Promise.resolve(null); - } +// +// Two records, because a refusal means two different things. Inside the session +// it is absolute — that context is not raised again, full stop. Outside it, it +// is evidence that fades: the same request should still reach the same context +// eventually, just not today and not without something better to go on. +export function noteDeclined(targetId, { id = sessionId(), now = new Date() } = {}) { return update((state) => { - const session = state.sessions[id] ?? {}; - const declined = session.declined ?? []; - state.sessions[id] = { - ...session, - declined: declined.includes(targetId) ? declined : [...declined, targetId], - updatedAt: new Date().toISOString() + const previous = state.declines[targetId]; + state.declines[targetId] = { + at: now.toISOString(), + // Repeating a refusal restarts the clock and deepens it, rather than + // simply re-stating what was already known. + count: Math.min((previous?.count ?? 0) + 1, MAX_DECLINE_COUNT) }; + if (id) { + const session = state.sessions[id] ?? {}; + const declined = session.declined ?? []; + state.sessions[id] = { + ...session, + declined: declined.includes(targetId) ? declined : [...declined, targetId], + updatedAt: now.toISOString() + }; + } return targetId; }); } +// What a past refusal does to a context's score now: a multiplier in (0, 1], +// where 1 means it is not holding anything back. +// +// Strength halves every two weeks, so a fresh single refusal costs a candidate +// most of its lead and a six-week-old one costs it almost nothing. Repeats +// compound rather than add, which keeps the result inside the range however +// many there have been. +export function declineFactor(state, contextId, now = new Date()) { + const entry = state.declines?.[contextId]; + if (!entry) { + return 1; + } + const days = (now.getTime() - Date.parse(entry.at)) / DAY_MS; + if (!Number.isFinite(days) || days >= DECLINE_LIFETIME_DAYS) { + return 1; + } + const strength = DECLINE_WEIGHT * 0.5 ** (Math.max(days, 0) / DECLINE_HALF_LIFE_DAYS); + return (1 - strength) ** entry.count; +} + +// Dropped once it can no longer change an outcome, so the file does not +// accumulate a record of every context ever turned down. +function pruneDeclines(declines, now) { + const kept = {}; + for (const [id, entry] of Object.entries(declines)) { + const days = (now - Date.parse(entry.at)) / DAY_MS; + if (Number.isFinite(days) && days < DECLINE_LIFETIME_DAYS) { + kept[id] = entry; + } + } + return kept; +} + // Every switch, with what it was routing away from and why. Thresholds and card // quality are guesses until this has something in it: manual selections are the // ground truth that says whether the derived lines actually route correctly. diff --git a/shared/core/routing-candidates.mjs b/shared/core/routing-candidates.mjs index ae3ad79..4ccd0d6 100644 --- a/shared/core/routing-candidates.mjs +++ b/shared/core/routing-candidates.mjs @@ -18,6 +18,7 @@ // has in hand — a context's revision and timestamps, a card's timestamp — so // checking costs no extra reads. Only a real change pays for a rebuild. +import { declineFactor } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -107,11 +108,20 @@ export function createRoutingIndex({ listFiles }) { key = next; } const names = new Map(contexts.map((context) => [context.id, context.name])); - return rank(index, query, options).map((result) => ({ - id: result.id, - name: names.get(result.id), - score: result.score, - matched: result.matched - })); + const now = new Date(); + // Past refusals are applied after ranking rather than folded into the + // index: they change on their own schedule, and rebuilding the index every + // time someone says no would throw away the cache for a multiplier. + // + // Re-sorted afterwards because a discount can change the order, and the + // shortlist's whole meaning is that it is in order. + return rank(index, query, options) + .map((result) => ({ + id: result.id, + name: names.get(result.id), + score: result.score * declineFactor(state, result.id, now), + matched: result.matched + })) + .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); }; } diff --git a/shared/core/routing.mjs b/shared/core/routing.mjs index 1a25209..5be0c74 100644 --- a/shared/core/routing.mjs +++ b/shared/core/routing.mjs @@ -47,6 +47,24 @@ const MAX_ALIASES = 12; const MAX_DECISIONS = 100; const MAX_SESSIONS = 20; +// How long a refusal keeps counting for. +// +// "Not that one" is true tomorrow as well, so forgetting it when the window +// closes throws away the clearest signal the user ever gives. But it is +// evidence, not a rule: a context refused during one week's work should not be +// unreachable a quarter later. So it fades — half its strength every two weeks, +// negligible by six — and refusing the same context again resets the clock and +// deepens it, which is the escalation a browser uses for a permission prompt +// dismissed several times over. Once is noise; three times is an answer. +// +// Nothing here ever becomes permanent. The only permanent no in this plugin is +// manual mode, because that one is a decision the user made on purpose. +const DECLINE_HALF_LIFE_DAYS = 14; +const DECLINE_LIFETIME_DAYS = 42; +const DECLINE_WEIGHT = 0.4; +const MAX_DECLINE_COUNT = 10; +const DAY_MS = 24 * 60 * 60 * 1000; + export function routingFilePath() { return path.join(neatContextHome(), "plugin-routing.json"); } @@ -85,8 +103,15 @@ export async function readRouting() { cards[id] = card; } } + const declines = {}; + for (const [id, raw] of Object.entries(parsed?.declines ?? {})) { + if (typeof raw?.at === "string" && Number.isInteger(raw.count) && raw.count > 0) { + declines[id] = { at: raw.at, count: Math.min(raw.count, MAX_DECLINE_COUNT) }; + } + } return { schema: SCHEMA, + declines, mode: MODES.includes(parsed?.mode) ? parsed.mode : DEFAULT_MODE, cards, sessions: typeof parsed?.sessions === "object" && parsed.sessions !== null ? parsed.sessions : {}, @@ -103,7 +128,16 @@ async function writeRouting(state) { await mkdir(path.dirname(file), { recursive: true }); await writeFile( file, - `${JSON.stringify({ ...state, sessions: Object.fromEntries(sessions), decisions: state.decisions.slice(-MAX_DECISIONS) }, null, 2)}\n`, + `${JSON.stringify( + { + ...state, + sessions: Object.fromEntries(sessions), + declines: pruneDeclines(state.declines, Date.now()), + decisions: state.decisions.slice(-MAX_DECISIONS) + }, + null, + 2 + )}\n`, { encoding: "utf8", mode: 0o600 } ); } @@ -226,22 +260,66 @@ export function switchPolicy(state, { id, targetId, connectedId, requested = fal // Remembering a refusal is what stops auto mode from proposing the same wrong // context on every message that shares a word with it. -export function noteDeclined(targetId, { id = sessionId() } = {}) { - if (!id) { - return Promise.resolve(null); - } +// +// Two records, because a refusal means two different things. Inside the session +// it is absolute — that context is not raised again, full stop. Outside it, it +// is evidence that fades: the same request should still reach the same context +// eventually, just not today and not without something better to go on. +export function noteDeclined(targetId, { id = sessionId(), now = new Date() } = {}) { return update((state) => { - const session = state.sessions[id] ?? {}; - const declined = session.declined ?? []; - state.sessions[id] = { - ...session, - declined: declined.includes(targetId) ? declined : [...declined, targetId], - updatedAt: new Date().toISOString() + const previous = state.declines[targetId]; + state.declines[targetId] = { + at: now.toISOString(), + // Repeating a refusal restarts the clock and deepens it, rather than + // simply re-stating what was already known. + count: Math.min((previous?.count ?? 0) + 1, MAX_DECLINE_COUNT) }; + if (id) { + const session = state.sessions[id] ?? {}; + const declined = session.declined ?? []; + state.sessions[id] = { + ...session, + declined: declined.includes(targetId) ? declined : [...declined, targetId], + updatedAt: now.toISOString() + }; + } return targetId; }); } +// What a past refusal does to a context's score now: a multiplier in (0, 1], +// where 1 means it is not holding anything back. +// +// Strength halves every two weeks, so a fresh single refusal costs a candidate +// most of its lead and a six-week-old one costs it almost nothing. Repeats +// compound rather than add, which keeps the result inside the range however +// many there have been. +export function declineFactor(state, contextId, now = new Date()) { + const entry = state.declines?.[contextId]; + if (!entry) { + return 1; + } + const days = (now.getTime() - Date.parse(entry.at)) / DAY_MS; + if (!Number.isFinite(days) || days >= DECLINE_LIFETIME_DAYS) { + return 1; + } + const strength = DECLINE_WEIGHT * 0.5 ** (Math.max(days, 0) / DECLINE_HALF_LIFE_DAYS); + return (1 - strength) ** entry.count; +} + +// Dropped once it can no longer change an outcome, so the file does not +// accumulate a record of every context ever turned down. +function pruneDeclines(declines, now) { + const kept = {}; + for (const [id, entry] of Object.entries(declines)) { + const days = (now - Date.parse(entry.at)) / DAY_MS; + if (Number.isFinite(days) && days < DECLINE_LIFETIME_DAYS) { + kept[id] = entry; + } + } + return kept; +} + // Every switch, with what it was routing away from and why. Thresholds and card // quality are guesses until this has something in it: manual selections are the // ground truth that says whether the derived lines actually route correctly. diff --git a/tests/routing-declines.test.mjs b/tests/routing-declines.test.mjs new file mode 100644 index 0000000..a01847c --- /dev/null +++ b/tests/routing-declines.test.mjs @@ -0,0 +1,167 @@ +// What a refusal is worth, and for how long. +// +// "Not that one" is the clearest signal a user ever gives about routing, and +// until now it was thrown away when the session closed. It is also not a rule: +// a context turned down during one week's work should not be unreachable a +// quarter later. So it fades, repeats deepen it, and nothing here ever becomes +// permanent. + +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { after, before, beforeEach, describe, it } from "node:test"; + +let home; + +before(async () => { + home = await mkdtemp(path.join(os.tmpdir(), "neatcontext-declines-test-")); + process.env.NEATCONTEXT_HOME = home; +}); + +after(async () => { + await rm(home, { recursive: true, force: true }); +}); + +beforeEach(async () => { + await rm(path.join(home, "plugin-routing.json"), { force: true }); +}); + +const routing = await import("../plugins/claude-code/neatcontext/src/core/routing.mjs"); +const { createRoutingIndex } = await import( + "../plugins/claude-code/neatcontext/src/core/routing-candidates.mjs" +); + +const DAY = 24 * 60 * 60 * 1000; +const NOW = new Date("2026-08-09T12:00:00.000Z"); +const daysAgo = (days) => new Date(NOW.getTime() - days * DAY); + +function stateWith(declines) { + return { declines }; +} + +describe("how a refusal fades", () => { + it("costs a context most of its score the day it happens", () => { + const state = stateWith({ refused: { at: daysAgo(0).toISOString(), count: 1 } }); + const factor = routing.declineFactor(state, "refused", NOW); + assert.ok(factor < 0.65 && factor > 0.55, `expected roughly 0.6, got ${factor}`); + }); + + it("costs half as much after two weeks", () => { + const fresh = routing.declineFactor( + stateWith({ a: { at: daysAgo(0).toISOString(), count: 1 } }), + "a", + NOW + ); + const older = routing.declineFactor( + stateWith({ a: { at: daysAgo(14).toISOString(), count: 1 } }), + "a", + NOW + ); + // Half the strength, so the multiplier is halfway back to 1. + assert.ok(older > fresh); + assert.ok(Math.abs(1 - older - (1 - fresh) / 2) < 0.01); + }); + + it("costs nothing at all after six weeks", () => { + const state = stateWith({ a: { at: daysAgo(42).toISOString(), count: 1 } }); + assert.equal(routing.declineFactor(state, "a", NOW), 1); + }); + + it("deepens when the same context is refused again", () => { + const once = routing.declineFactor( + stateWith({ a: { at: daysAgo(0).toISOString(), count: 1 } }), + "a", + NOW + ); + const thrice = routing.declineFactor( + stateWith({ a: { at: daysAgo(0).toISOString(), count: 3 } }), + "a", + NOW + ); + assert.ok(thrice < once); + }); + + it("never reaches zero, however many times it is refused", () => { + // A guess the system made about someone must not harden into a rule. + const state = stateWith({ a: { at: daysAgo(0).toISOString(), count: 10 } }); + assert.ok(routing.declineFactor(state, "a", NOW) > 0); + }); + + it("holds nothing against a context that was never refused", () => { + assert.equal(routing.declineFactor(stateWith({}), "untouched", NOW), 1); + assert.equal(routing.declineFactor({}, "untouched", NOW), 1); + }); + + it("ignores a record whose timestamp is unreadable", () => { + const state = stateWith({ a: { at: "not a date", count: 1 } }); + assert.equal(routing.declineFactor(state, "a", NOW), 1); + }); +}); + +describe("recording a refusal", () => { + it("keeps it beyond the session that produced it", async () => { + await routing.noteDeclined("context:orders", { id: "session-a" }); + const state = await routing.readRouting(); + assert.equal(state.declines["context:orders"].count, 1); + // And still blocks outright inside the session it happened in. + assert.deepEqual(state.sessions["session-a"].declined, ["context:orders"]); + }); + + it("counts repeats and restarts the clock", async () => { + await routing.noteDeclined("context:orders", { id: "session-a", now: daysAgo(10) }); + await routing.noteDeclined("context:orders", { id: "session-b", now: NOW }); + const state = await routing.readRouting(); + assert.equal(state.declines["context:orders"].count, 2); + assert.equal(state.declines["context:orders"].at, NOW.toISOString()); + }); + + it("records the refusal even with no session to attribute it to", async () => { + await routing.noteDeclined("context:orders", { id: "", now: NOW }); + const state = await routing.readRouting(); + assert.equal(state.declines["context:orders"].count, 1); + }); + + it("drops records too old to change anything", async () => { + await routing.noteDeclined("context:stale", { id: "session-a", now: daysAgo(90) }); + await routing.noteDeclined("context:fresh", { id: "session-a", now: NOW }); + const stored = JSON.parse(await readFile(path.join(home, "plugin-routing.json"), "utf8")); + assert.equal("context:stale" in stored.declines, false); + assert.equal("context:fresh" in stored.declines, true); + }); + + it("survives a hand-broken file rather than failing the session", async () => { + const state = await routing.readRouting(); + assert.deepEqual(state.declines, {}); + }); +}); + +describe("what it does to a shortlist", () => { + const CONTEXTS = [ + { id: "refused", name: "Refused", revision: 1, routingDescription: "partition lag on orders" }, + { id: "other", name: "Other", revision: 1, routingDescription: "partition lag on orders" } + ]; + + function rankWith(declines) { + const candidates = createRoutingIndex({ listFiles: () => Promise.resolve([]) }); + return candidates(CONTEXTS, { cards: {}, declines }, "partition lag on orders"); + } + + it("demotes a context the user turned down, without hiding it", async () => { + // Both match identically, so nothing but the refusal can separate them. + const before = await rankWith({}); + assert.equal(before[0].id, "other", "tie should break by id before any refusal"); + + const after = await rankWith({ refused: { at: new Date().toISOString(), count: 1 } }); + assert.equal(after[0].id, "other"); + assert.equal(after[1].id, "refused"); + assert.ok(after[1].score < after[0].score); + assert.equal(after.length, 2, "a refused context is ranked lower, not removed"); + }); + + it("leaves the order alone once the refusal has expired", async () => { + const expired = new Date(Date.now() - 60 * DAY).toISOString(); + const results = await rankWith({ refused: { at: expired, count: 1 } }); + assert.equal(results[0].score, results[1].score); + }); +});