diff --git a/codex-marketplace/plugins/neatcontext/src/codex/mcp-bridge.mjs b/codex-marketplace/plugins/neatcontext/src/codex/mcp-bridge.mjs index ee5768b..04f5cc6 100644 --- a/codex-marketplace/plugins/neatcontext/src/codex/mcp-bridge.mjs +++ b/codex-marketplace/plugins/neatcontext/src/codex/mcp-bridge.mjs @@ -327,7 +327,7 @@ async function routingMenu(query) { mode: resolveMode(state, sessionId()) }; const entries = menuEntries(contexts, state); - const shortlist = await shortlistFor(contexts, state, entries, query); + const shortlist = await shortlistFor(contexts, state, entries, query, options.connectedId); return shortlist ? renderShortlist(shortlist, { ...options, decision: assess(shortlist) }) : renderMenu(entries, options); @@ -337,7 +337,7 @@ async function routingMenu(query) { // that narrowing gains anything, and at least one that actually matched. Any of // them missing and the full menu goes out instead — a session is never left // with less to work with than it has today. -async function shortlistFor(contexts, state, entries, query) { +async function shortlistFor(contexts, state, entries, query, connectedId) { if ( typeof query !== "string" || query.trim().length === 0 || @@ -345,7 +345,10 @@ async function shortlistFor(contexts, state, entries, query) { ) { return null; } - const ranked = await rankContexts(contexts, state, query, { limit: SHORTLIST_LIMIT }); + const ranked = await rankContexts(contexts, state, query, { + limit: SHORTLIST_LIMIT, + connectedId + }); if (ranked.length === 0) { return null; } diff --git a/codex-marketplace/plugins/neatcontext/src/core/routing-candidates.mjs b/codex-marketplace/plugins/neatcontext/src/core/routing-candidates.mjs index 4ccd0d6..b452f57 100644 --- a/codex-marketplace/plugins/neatcontext/src/core/routing-candidates.mjs +++ b/codex-marketplace/plugins/neatcontext/src/core/routing-candidates.mjs @@ -18,7 +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 { declineFactor, familiarity } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -101,14 +101,15 @@ export function createRoutingIndex({ listFiles }) { let key = null; let index = null; - return async function candidates(contexts, state, query, options) { + return async function candidates(contexts, state, query, options = {}) { const next = fingerprint(contexts, state); if (next !== key || index === null) { index = buildIndex(await routingDocuments(contexts, state, listFiles)); key = next; } - const names = new Map(contexts.map((context) => [context.id, context.name])); + const byId = new Map(contexts.map((context) => [context.id, context])); const now = new Date(); + const { connectedId = null } = options; // 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. @@ -116,12 +117,18 @@ export function createRoutingIndex({ listFiles }) { // 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 - })) + .map((result) => { + const context = byId.get(result.id); + return { + id: result.id, + name: context.name, + score: + result.score * + declineFactor(state, result.id, now) * + familiarity(state, context, { connectedId, 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 5be0c74..bf5675e 100644 --- a/codex-marketplace/plugins/neatcontext/src/core/routing.mjs +++ b/codex-marketplace/plugins/neatcontext/src/core/routing.mjs @@ -59,6 +59,31 @@ const MAX_SESSIONS = 20; // // 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. +// How much a context you actually use is worth. +// +// The same idea a browser address bar runs on: you type two letters and it +// offers the site you open daily, not the one you opened once last year. It +// solves the same problem routing has — many items, a short vague request, and +// one chance to be right. +// +// Deliberately a small thumb on the scale rather than a decision. What you used +// last week is a hint about what you mean; it is not evidence about what you +// asked, and it must never outrank the words. +const FRECENCY_HALF_LIFE_DAYS = 14; +const FRECENCY_MAX_BOOST = 1.25; + +// And how much the context you are already on is worth. +// +// Most requests continue the last one, so leaving is the unusual move and +// should need more evidence than staying. Handing the session to a context that +// is barely ahead is how a conversation ends up flipping between two of them. +// +// This is hysteresis, and it needs no new machinery: inflating the incumbent's +// score means a challenger has to clear it by a real margin, and one that only +// just clears it lands inside the near-tie rule and becomes a question instead +// of a switch. +const STICKY_BOOST = 1.35; + const DECLINE_HALF_LIFE_DAYS = 14; const DECLINE_LIFETIME_DAYS = 42; const DECLINE_WEIGHT = 0.4; @@ -307,6 +332,33 @@ export function declineFactor(state, contextId, now = new Date()) { return (1 - strength) ** entry.count; } +// How much this machine's own history argues for a context: a multiplier at or +// above 1, never below, because this is a hint and not evidence. +// +// Both halves are personal. They read what this machine has done and change +// nothing in the bundle, so a context stays exactly as portable as it was — two +// people will simply reach it in a slightly different order, which is the +// honest answer when one of them lives in it and the other has never opened it. +// +// Matched on name rather than id because that is what the decision log records. +// Names are unique in a store, so this is exact; renaming a context forgets its +// history, which for a hint of this size is a fair trade against threading an +// id through five hosts' bridges. +export function familiarity(state, context, { connectedId = null, now = new Date() } = {}) { + const sticky = context.id === connectedId ? STICKY_BOOST : 1; + let weight = 0; + for (const decision of state.decisions ?? []) { + if (decision?.to !== context.name) continue; + const days = (now.getTime() - Date.parse(decision.at)) / DAY_MS; + if (!Number.isFinite(days) || days < 0) continue; + weight += 0.5 ** (days / FRECENCY_HALF_LIFE_DAYS); + } + // Saturating, so a context used fifty times cannot run away with the ranking + // and no pass over the whole corpus is needed to normalise anything. + const used = 1 + (FRECENCY_MAX_BOOST - 1) * (weight / (weight + 1)); + return sticky * used; +} + // 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) { diff --git a/plugins/claude-code/neatcontext/src/claude/mcp-bridge.mjs b/plugins/claude-code/neatcontext/src/claude/mcp-bridge.mjs index 0bd8e5f..c4f3bd4 100644 --- a/plugins/claude-code/neatcontext/src/claude/mcp-bridge.mjs +++ b/plugins/claude-code/neatcontext/src/claude/mcp-bridge.mjs @@ -326,7 +326,7 @@ async function routingMenu(query) { mode: resolveMode(state, sessionId()) }; const entries = menuEntries(contexts, state); - const shortlist = await shortlistFor(contexts, state, entries, query); + const shortlist = await shortlistFor(contexts, state, entries, query, options.connectedId); return shortlist ? renderShortlist(shortlist, { ...options, decision: assess(shortlist) }) : renderMenu(entries, options); @@ -336,7 +336,7 @@ async function routingMenu(query) { // that narrowing gains anything, and at least one that actually matched. Any of // them missing and the full menu goes out instead — a session is never left // with less to work with than it has today. -async function shortlistFor(contexts, state, entries, query) { +async function shortlistFor(contexts, state, entries, query, connectedId) { if ( typeof query !== "string" || query.trim().length === 0 || @@ -344,7 +344,10 @@ async function shortlistFor(contexts, state, entries, query) { ) { return null; } - const ranked = await rankContexts(contexts, state, query, { limit: SHORTLIST_LIMIT }); + const ranked = await rankContexts(contexts, state, query, { + limit: SHORTLIST_LIMIT, + connectedId + }); if (ranked.length === 0) { return null; } diff --git a/plugins/claude-code/neatcontext/src/core/routing-candidates.mjs b/plugins/claude-code/neatcontext/src/core/routing-candidates.mjs index 4ccd0d6..b452f57 100644 --- a/plugins/claude-code/neatcontext/src/core/routing-candidates.mjs +++ b/plugins/claude-code/neatcontext/src/core/routing-candidates.mjs @@ -18,7 +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 { declineFactor, familiarity } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -101,14 +101,15 @@ export function createRoutingIndex({ listFiles }) { let key = null; let index = null; - return async function candidates(contexts, state, query, options) { + return async function candidates(contexts, state, query, options = {}) { const next = fingerprint(contexts, state); if (next !== key || index === null) { index = buildIndex(await routingDocuments(contexts, state, listFiles)); key = next; } - const names = new Map(contexts.map((context) => [context.id, context.name])); + const byId = new Map(contexts.map((context) => [context.id, context])); const now = new Date(); + const { connectedId = null } = options; // 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. @@ -116,12 +117,18 @@ export function createRoutingIndex({ listFiles }) { // 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 - })) + .map((result) => { + const context = byId.get(result.id); + return { + id: result.id, + name: context.name, + score: + result.score * + declineFactor(state, result.id, now) * + familiarity(state, context, { connectedId, 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 5be0c74..bf5675e 100644 --- a/plugins/claude-code/neatcontext/src/core/routing.mjs +++ b/plugins/claude-code/neatcontext/src/core/routing.mjs @@ -59,6 +59,31 @@ const MAX_SESSIONS = 20; // // 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. +// How much a context you actually use is worth. +// +// The same idea a browser address bar runs on: you type two letters and it +// offers the site you open daily, not the one you opened once last year. It +// solves the same problem routing has — many items, a short vague request, and +// one chance to be right. +// +// Deliberately a small thumb on the scale rather than a decision. What you used +// last week is a hint about what you mean; it is not evidence about what you +// asked, and it must never outrank the words. +const FRECENCY_HALF_LIFE_DAYS = 14; +const FRECENCY_MAX_BOOST = 1.25; + +// And how much the context you are already on is worth. +// +// Most requests continue the last one, so leaving is the unusual move and +// should need more evidence than staying. Handing the session to a context that +// is barely ahead is how a conversation ends up flipping between two of them. +// +// This is hysteresis, and it needs no new machinery: inflating the incumbent's +// score means a challenger has to clear it by a real margin, and one that only +// just clears it lands inside the near-tie rule and becomes a question instead +// of a switch. +const STICKY_BOOST = 1.35; + const DECLINE_HALF_LIFE_DAYS = 14; const DECLINE_LIFETIME_DAYS = 42; const DECLINE_WEIGHT = 0.4; @@ -307,6 +332,33 @@ export function declineFactor(state, contextId, now = new Date()) { return (1 - strength) ** entry.count; } +// How much this machine's own history argues for a context: a multiplier at or +// above 1, never below, because this is a hint and not evidence. +// +// Both halves are personal. They read what this machine has done and change +// nothing in the bundle, so a context stays exactly as portable as it was — two +// people will simply reach it in a slightly different order, which is the +// honest answer when one of them lives in it and the other has never opened it. +// +// Matched on name rather than id because that is what the decision log records. +// Names are unique in a store, so this is exact; renaming a context forgets its +// history, which for a hint of this size is a fair trade against threading an +// id through five hosts' bridges. +export function familiarity(state, context, { connectedId = null, now = new Date() } = {}) { + const sticky = context.id === connectedId ? STICKY_BOOST : 1; + let weight = 0; + for (const decision of state.decisions ?? []) { + if (decision?.to !== context.name) continue; + const days = (now.getTime() - Date.parse(decision.at)) / DAY_MS; + if (!Number.isFinite(days) || days < 0) continue; + weight += 0.5 ** (days / FRECENCY_HALF_LIFE_DAYS); + } + // Saturating, so a context used fifty times cannot run away with the ranking + // and no pass over the whole corpus is needed to normalise anything. + const used = 1 + (FRECENCY_MAX_BOOST - 1) * (weight / (weight + 1)); + return sticky * used; +} + // 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) { diff --git a/plugins/copilot/neatcontext/src/copilot/mcp-bridge.mjs b/plugins/copilot/neatcontext/src/copilot/mcp-bridge.mjs index 5dbfcb3..b702631 100644 --- a/plugins/copilot/neatcontext/src/copilot/mcp-bridge.mjs +++ b/plugins/copilot/neatcontext/src/copilot/mcp-bridge.mjs @@ -320,7 +320,7 @@ async function routingMenu(query) { mode: resolveMode(state, sessionId()) }; const entries = menuEntries(contexts, state); - const shortlist = await shortlistFor(contexts, state, entries, query); + const shortlist = await shortlistFor(contexts, state, entries, query, options.connectedId); return shortlist ? renderShortlist(shortlist, { ...options, decision: assess(shortlist) }) : renderMenu(entries, options); @@ -330,7 +330,7 @@ async function routingMenu(query) { // that narrowing gains anything, and at least one that actually matched. Any of // them missing and the full menu goes out instead — a session is never left // with less to work with than it has today. -async function shortlistFor(contexts, state, entries, query) { +async function shortlistFor(contexts, state, entries, query, connectedId) { if ( typeof query !== "string" || query.trim().length === 0 || @@ -338,7 +338,10 @@ async function shortlistFor(contexts, state, entries, query) { ) { return null; } - const ranked = await rankContexts(contexts, state, query, { limit: SHORTLIST_LIMIT }); + const ranked = await rankContexts(contexts, state, query, { + limit: SHORTLIST_LIMIT, + connectedId + }); if (ranked.length === 0) { return null; } diff --git a/plugins/copilot/neatcontext/src/core/routing-candidates.mjs b/plugins/copilot/neatcontext/src/core/routing-candidates.mjs index 4ccd0d6..b452f57 100644 --- a/plugins/copilot/neatcontext/src/core/routing-candidates.mjs +++ b/plugins/copilot/neatcontext/src/core/routing-candidates.mjs @@ -18,7 +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 { declineFactor, familiarity } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -101,14 +101,15 @@ export function createRoutingIndex({ listFiles }) { let key = null; let index = null; - return async function candidates(contexts, state, query, options) { + return async function candidates(contexts, state, query, options = {}) { const next = fingerprint(contexts, state); if (next !== key || index === null) { index = buildIndex(await routingDocuments(contexts, state, listFiles)); key = next; } - const names = new Map(contexts.map((context) => [context.id, context.name])); + const byId = new Map(contexts.map((context) => [context.id, context])); const now = new Date(); + const { connectedId = null } = options; // 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. @@ -116,12 +117,18 @@ export function createRoutingIndex({ listFiles }) { // 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 - })) + .map((result) => { + const context = byId.get(result.id); + return { + id: result.id, + name: context.name, + score: + result.score * + declineFactor(state, result.id, now) * + familiarity(state, context, { connectedId, 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 5be0c74..bf5675e 100644 --- a/plugins/copilot/neatcontext/src/core/routing.mjs +++ b/plugins/copilot/neatcontext/src/core/routing.mjs @@ -59,6 +59,31 @@ const MAX_SESSIONS = 20; // // 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. +// How much a context you actually use is worth. +// +// The same idea a browser address bar runs on: you type two letters and it +// offers the site you open daily, not the one you opened once last year. It +// solves the same problem routing has — many items, a short vague request, and +// one chance to be right. +// +// Deliberately a small thumb on the scale rather than a decision. What you used +// last week is a hint about what you mean; it is not evidence about what you +// asked, and it must never outrank the words. +const FRECENCY_HALF_LIFE_DAYS = 14; +const FRECENCY_MAX_BOOST = 1.25; + +// And how much the context you are already on is worth. +// +// Most requests continue the last one, so leaving is the unusual move and +// should need more evidence than staying. Handing the session to a context that +// is barely ahead is how a conversation ends up flipping between two of them. +// +// This is hysteresis, and it needs no new machinery: inflating the incumbent's +// score means a challenger has to clear it by a real margin, and one that only +// just clears it lands inside the near-tie rule and becomes a question instead +// of a switch. +const STICKY_BOOST = 1.35; + const DECLINE_HALF_LIFE_DAYS = 14; const DECLINE_LIFETIME_DAYS = 42; const DECLINE_WEIGHT = 0.4; @@ -307,6 +332,33 @@ export function declineFactor(state, contextId, now = new Date()) { return (1 - strength) ** entry.count; } +// How much this machine's own history argues for a context: a multiplier at or +// above 1, never below, because this is a hint and not evidence. +// +// Both halves are personal. They read what this machine has done and change +// nothing in the bundle, so a context stays exactly as portable as it was — two +// people will simply reach it in a slightly different order, which is the +// honest answer when one of them lives in it and the other has never opened it. +// +// Matched on name rather than id because that is what the decision log records. +// Names are unique in a store, so this is exact; renaming a context forgets its +// history, which for a hint of this size is a fair trade against threading an +// id through five hosts' bridges. +export function familiarity(state, context, { connectedId = null, now = new Date() } = {}) { + const sticky = context.id === connectedId ? STICKY_BOOST : 1; + let weight = 0; + for (const decision of state.decisions ?? []) { + if (decision?.to !== context.name) continue; + const days = (now.getTime() - Date.parse(decision.at)) / DAY_MS; + if (!Number.isFinite(days) || days < 0) continue; + weight += 0.5 ** (days / FRECENCY_HALF_LIFE_DAYS); + } + // Saturating, so a context used fifty times cannot run away with the ranking + // and no pass over the whole corpus is needed to normalise anything. + const used = 1 + (FRECENCY_MAX_BOOST - 1) * (weight / (weight + 1)); + return sticky * used; +} + // 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) { diff --git a/plugins/kimi-code/neatcontext/src/core/routing-candidates.mjs b/plugins/kimi-code/neatcontext/src/core/routing-candidates.mjs index 4ccd0d6..b452f57 100644 --- a/plugins/kimi-code/neatcontext/src/core/routing-candidates.mjs +++ b/plugins/kimi-code/neatcontext/src/core/routing-candidates.mjs @@ -18,7 +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 { declineFactor, familiarity } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -101,14 +101,15 @@ export function createRoutingIndex({ listFiles }) { let key = null; let index = null; - return async function candidates(contexts, state, query, options) { + return async function candidates(contexts, state, query, options = {}) { const next = fingerprint(contexts, state); if (next !== key || index === null) { index = buildIndex(await routingDocuments(contexts, state, listFiles)); key = next; } - const names = new Map(contexts.map((context) => [context.id, context.name])); + const byId = new Map(contexts.map((context) => [context.id, context])); const now = new Date(); + const { connectedId = null } = options; // 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. @@ -116,12 +117,18 @@ export function createRoutingIndex({ listFiles }) { // 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 - })) + .map((result) => { + const context = byId.get(result.id); + return { + id: result.id, + name: context.name, + score: + result.score * + declineFactor(state, result.id, now) * + familiarity(state, context, { connectedId, 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 5be0c74..bf5675e 100644 --- a/plugins/kimi-code/neatcontext/src/core/routing.mjs +++ b/plugins/kimi-code/neatcontext/src/core/routing.mjs @@ -59,6 +59,31 @@ const MAX_SESSIONS = 20; // // 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. +// How much a context you actually use is worth. +// +// The same idea a browser address bar runs on: you type two letters and it +// offers the site you open daily, not the one you opened once last year. It +// solves the same problem routing has — many items, a short vague request, and +// one chance to be right. +// +// Deliberately a small thumb on the scale rather than a decision. What you used +// last week is a hint about what you mean; it is not evidence about what you +// asked, and it must never outrank the words. +const FRECENCY_HALF_LIFE_DAYS = 14; +const FRECENCY_MAX_BOOST = 1.25; + +// And how much the context you are already on is worth. +// +// Most requests continue the last one, so leaving is the unusual move and +// should need more evidence than staying. Handing the session to a context that +// is barely ahead is how a conversation ends up flipping between two of them. +// +// This is hysteresis, and it needs no new machinery: inflating the incumbent's +// score means a challenger has to clear it by a real margin, and one that only +// just clears it lands inside the near-tie rule and becomes a question instead +// of a switch. +const STICKY_BOOST = 1.35; + const DECLINE_HALF_LIFE_DAYS = 14; const DECLINE_LIFETIME_DAYS = 42; const DECLINE_WEIGHT = 0.4; @@ -307,6 +332,33 @@ export function declineFactor(state, contextId, now = new Date()) { return (1 - strength) ** entry.count; } +// How much this machine's own history argues for a context: a multiplier at or +// above 1, never below, because this is a hint and not evidence. +// +// Both halves are personal. They read what this machine has done and change +// nothing in the bundle, so a context stays exactly as portable as it was — two +// people will simply reach it in a slightly different order, which is the +// honest answer when one of them lives in it and the other has never opened it. +// +// Matched on name rather than id because that is what the decision log records. +// Names are unique in a store, so this is exact; renaming a context forgets its +// history, which for a hint of this size is a fair trade against threading an +// id through five hosts' bridges. +export function familiarity(state, context, { connectedId = null, now = new Date() } = {}) { + const sticky = context.id === connectedId ? STICKY_BOOST : 1; + let weight = 0; + for (const decision of state.decisions ?? []) { + if (decision?.to !== context.name) continue; + const days = (now.getTime() - Date.parse(decision.at)) / DAY_MS; + if (!Number.isFinite(days) || days < 0) continue; + weight += 0.5 ** (days / FRECENCY_HALF_LIFE_DAYS); + } + // Saturating, so a context used fifty times cannot run away with the ranking + // and no pass over the whole corpus is needed to normalise anything. + const used = 1 + (FRECENCY_MAX_BOOST - 1) * (weight / (weight + 1)); + return sticky * used; +} + // 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) { diff --git a/plugins/kimi-code/neatcontext/src/kimi/mcp-bridge.mjs b/plugins/kimi-code/neatcontext/src/kimi/mcp-bridge.mjs index 4af5671..0b361db 100644 --- a/plugins/kimi-code/neatcontext/src/kimi/mcp-bridge.mjs +++ b/plugins/kimi-code/neatcontext/src/kimi/mcp-bridge.mjs @@ -377,7 +377,7 @@ async function routingMenu(query) { mode: resolveMode(state, sessionId()) }; const entries = menuEntries(contexts, state); - const shortlist = await shortlistFor(contexts, state, entries, query); + const shortlist = await shortlistFor(contexts, state, entries, query, options.connectedId); return shortlist ? renderShortlist(shortlist, { ...options, decision: assess(shortlist) }) : renderMenu(entries, options); @@ -387,7 +387,7 @@ async function routingMenu(query) { // that narrowing gains anything, and at least one that actually matched. Any of // them missing and the full menu goes out instead — a session is never left // with less to work with than it has today. -async function shortlistFor(contexts, state, entries, query) { +async function shortlistFor(contexts, state, entries, query, connectedId) { if ( typeof query !== "string" || query.trim().length === 0 || @@ -395,7 +395,10 @@ async function shortlistFor(contexts, state, entries, query) { ) { return null; } - const ranked = await rankContexts(contexts, state, query, { limit: SHORTLIST_LIMIT }); + const ranked = await rankContexts(contexts, state, query, { + limit: SHORTLIST_LIMIT, + connectedId + }); if (ranked.length === 0) { return null; } diff --git a/plugins/pi/neatcontext/src/core/routing-candidates.mjs b/plugins/pi/neatcontext/src/core/routing-candidates.mjs index 4ccd0d6..b452f57 100644 --- a/plugins/pi/neatcontext/src/core/routing-candidates.mjs +++ b/plugins/pi/neatcontext/src/core/routing-candidates.mjs @@ -18,7 +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 { declineFactor, familiarity } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -101,14 +101,15 @@ export function createRoutingIndex({ listFiles }) { let key = null; let index = null; - return async function candidates(contexts, state, query, options) { + return async function candidates(contexts, state, query, options = {}) { const next = fingerprint(contexts, state); if (next !== key || index === null) { index = buildIndex(await routingDocuments(contexts, state, listFiles)); key = next; } - const names = new Map(contexts.map((context) => [context.id, context.name])); + const byId = new Map(contexts.map((context) => [context.id, context])); const now = new Date(); + const { connectedId = null } = options; // 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. @@ -116,12 +117,18 @@ export function createRoutingIndex({ listFiles }) { // 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 - })) + .map((result) => { + const context = byId.get(result.id); + return { + id: result.id, + name: context.name, + score: + result.score * + declineFactor(state, result.id, now) * + familiarity(state, context, { connectedId, 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 5be0c74..bf5675e 100644 --- a/plugins/pi/neatcontext/src/core/routing.mjs +++ b/plugins/pi/neatcontext/src/core/routing.mjs @@ -59,6 +59,31 @@ const MAX_SESSIONS = 20; // // 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. +// How much a context you actually use is worth. +// +// The same idea a browser address bar runs on: you type two letters and it +// offers the site you open daily, not the one you opened once last year. It +// solves the same problem routing has — many items, a short vague request, and +// one chance to be right. +// +// Deliberately a small thumb on the scale rather than a decision. What you used +// last week is a hint about what you mean; it is not evidence about what you +// asked, and it must never outrank the words. +const FRECENCY_HALF_LIFE_DAYS = 14; +const FRECENCY_MAX_BOOST = 1.25; + +// And how much the context you are already on is worth. +// +// Most requests continue the last one, so leaving is the unusual move and +// should need more evidence than staying. Handing the session to a context that +// is barely ahead is how a conversation ends up flipping between two of them. +// +// This is hysteresis, and it needs no new machinery: inflating the incumbent's +// score means a challenger has to clear it by a real margin, and one that only +// just clears it lands inside the near-tie rule and becomes a question instead +// of a switch. +const STICKY_BOOST = 1.35; + const DECLINE_HALF_LIFE_DAYS = 14; const DECLINE_LIFETIME_DAYS = 42; const DECLINE_WEIGHT = 0.4; @@ -307,6 +332,33 @@ export function declineFactor(state, contextId, now = new Date()) { return (1 - strength) ** entry.count; } +// How much this machine's own history argues for a context: a multiplier at or +// above 1, never below, because this is a hint and not evidence. +// +// Both halves are personal. They read what this machine has done and change +// nothing in the bundle, so a context stays exactly as portable as it was — two +// people will simply reach it in a slightly different order, which is the +// honest answer when one of them lives in it and the other has never opened it. +// +// Matched on name rather than id because that is what the decision log records. +// Names are unique in a store, so this is exact; renaming a context forgets its +// history, which for a hint of this size is a fair trade against threading an +// id through five hosts' bridges. +export function familiarity(state, context, { connectedId = null, now = new Date() } = {}) { + const sticky = context.id === connectedId ? STICKY_BOOST : 1; + let weight = 0; + for (const decision of state.decisions ?? []) { + if (decision?.to !== context.name) continue; + const days = (now.getTime() - Date.parse(decision.at)) / DAY_MS; + if (!Number.isFinite(days) || days < 0) continue; + weight += 0.5 ** (days / FRECENCY_HALF_LIFE_DAYS); + } + // Saturating, so a context used fifty times cannot run away with the ranking + // and no pass over the whole corpus is needed to normalise anything. + const used = 1 + (FRECENCY_MAX_BOOST - 1) * (weight / (weight + 1)); + return sticky * used; +} + // 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) { diff --git a/plugins/pi/neatcontext/src/pi/runtime.mjs b/plugins/pi/neatcontext/src/pi/runtime.mjs index 0d0bdd3..8533c7d 100644 --- a/plugins/pi/neatcontext/src/pi/runtime.mjs +++ b/plugins/pi/neatcontext/src/pi/runtime.mjs @@ -144,7 +144,7 @@ async function routingMenu(query) { mode: resolveMode(state, sessionId()) }; const entries = menuEntries(contexts, state); - const shortlist = await shortlistFor(contexts, state, entries, query); + const shortlist = await shortlistFor(contexts, state, entries, query, options.connectedId); return shortlist ? renderShortlist(shortlist, { ...options, decision: assess(shortlist) }) : renderMenu(entries, options); @@ -154,7 +154,7 @@ async function routingMenu(query) { // that narrowing gains anything, and at least one that actually matched. Any of // them missing and the full menu goes out instead — a session is never left // with less to work with than it has today. -async function shortlistFor(contexts, state, entries, query) { +async function shortlistFor(contexts, state, entries, query, connectedId) { if ( typeof query !== "string" || query.trim().length === 0 || @@ -162,7 +162,10 @@ async function shortlistFor(contexts, state, entries, query) { ) { return null; } - const ranked = await rankContexts(contexts, state, query, { limit: SHORTLIST_LIMIT }); + const ranked = await rankContexts(contexts, state, query, { + limit: SHORTLIST_LIMIT, + connectedId + }); if (ranked.length === 0) { return null; } diff --git a/shared/core/routing-candidates.mjs b/shared/core/routing-candidates.mjs index 4ccd0d6..b452f57 100644 --- a/shared/core/routing-candidates.mjs +++ b/shared/core/routing-candidates.mjs @@ -18,7 +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 { declineFactor, familiarity } from "./routing.mjs"; import { buildIndex, rank } from "./routing-search.mjs"; // The fields, in the shape the scorer weighs. Aliases are joined into one @@ -101,14 +101,15 @@ export function createRoutingIndex({ listFiles }) { let key = null; let index = null; - return async function candidates(contexts, state, query, options) { + return async function candidates(contexts, state, query, options = {}) { const next = fingerprint(contexts, state); if (next !== key || index === null) { index = buildIndex(await routingDocuments(contexts, state, listFiles)); key = next; } - const names = new Map(contexts.map((context) => [context.id, context.name])); + const byId = new Map(contexts.map((context) => [context.id, context])); const now = new Date(); + const { connectedId = null } = options; // 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. @@ -116,12 +117,18 @@ export function createRoutingIndex({ listFiles }) { // 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 - })) + .map((result) => { + const context = byId.get(result.id); + return { + id: result.id, + name: context.name, + score: + result.score * + declineFactor(state, result.id, now) * + familiarity(state, context, { connectedId, 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 5be0c74..bf5675e 100644 --- a/shared/core/routing.mjs +++ b/shared/core/routing.mjs @@ -59,6 +59,31 @@ const MAX_SESSIONS = 20; // // 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. +// How much a context you actually use is worth. +// +// The same idea a browser address bar runs on: you type two letters and it +// offers the site you open daily, not the one you opened once last year. It +// solves the same problem routing has — many items, a short vague request, and +// one chance to be right. +// +// Deliberately a small thumb on the scale rather than a decision. What you used +// last week is a hint about what you mean; it is not evidence about what you +// asked, and it must never outrank the words. +const FRECENCY_HALF_LIFE_DAYS = 14; +const FRECENCY_MAX_BOOST = 1.25; + +// And how much the context you are already on is worth. +// +// Most requests continue the last one, so leaving is the unusual move and +// should need more evidence than staying. Handing the session to a context that +// is barely ahead is how a conversation ends up flipping between two of them. +// +// This is hysteresis, and it needs no new machinery: inflating the incumbent's +// score means a challenger has to clear it by a real margin, and one that only +// just clears it lands inside the near-tie rule and becomes a question instead +// of a switch. +const STICKY_BOOST = 1.35; + const DECLINE_HALF_LIFE_DAYS = 14; const DECLINE_LIFETIME_DAYS = 42; const DECLINE_WEIGHT = 0.4; @@ -307,6 +332,33 @@ export function declineFactor(state, contextId, now = new Date()) { return (1 - strength) ** entry.count; } +// How much this machine's own history argues for a context: a multiplier at or +// above 1, never below, because this is a hint and not evidence. +// +// Both halves are personal. They read what this machine has done and change +// nothing in the bundle, so a context stays exactly as portable as it was — two +// people will simply reach it in a slightly different order, which is the +// honest answer when one of them lives in it and the other has never opened it. +// +// Matched on name rather than id because that is what the decision log records. +// Names are unique in a store, so this is exact; renaming a context forgets its +// history, which for a hint of this size is a fair trade against threading an +// id through five hosts' bridges. +export function familiarity(state, context, { connectedId = null, now = new Date() } = {}) { + const sticky = context.id === connectedId ? STICKY_BOOST : 1; + let weight = 0; + for (const decision of state.decisions ?? []) { + if (decision?.to !== context.name) continue; + const days = (now.getTime() - Date.parse(decision.at)) / DAY_MS; + if (!Number.isFinite(days) || days < 0) continue; + weight += 0.5 ** (days / FRECENCY_HALF_LIFE_DAYS); + } + // Saturating, so a context used fifty times cannot run away with the ranking + // and no pass over the whole corpus is needed to normalise anything. + const used = 1 + (FRECENCY_MAX_BOOST - 1) * (weight / (weight + 1)); + return sticky * used; +} + // 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) { diff --git a/tests/routing-familiarity.test.mjs b/tests/routing-familiarity.test.mjs new file mode 100644 index 0000000..c09a01b --- /dev/null +++ b/tests/routing-familiarity.test.mjs @@ -0,0 +1,175 @@ +// Two hints that are about you rather than about the question. +// +// How often you use a context, and whether you are already on it. Both are read +// from what this machine has done, and neither writes anything to a bundle — a +// context stays exactly as portable as it was. Two people simply reach it in a +// slightly different order, which is the honest answer when one of them lives +// in it and the other has never opened it. +// +// Deliberately absent: anything tying a context to a folder or a repository. A +// context is not about the directory someone happened to be sitting in, and +// anchoring it to one would make the same shared context behave differently for +// teammates with different layouts. + +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { after, before, describe, it } from "node:test"; + +let home; + +before(async () => { + home = await mkdtemp(path.join(os.tmpdir(), "neatcontext-familiarity-test-")); + process.env.NEATCONTEXT_HOME = home; +}); + +after(async () => { + await rm(home, { recursive: true, 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).toISOString(); + +const ORDERS = { id: "orders", name: "Orders" }; +const QUEUE = { id: "queue", name: "Queue" }; + +function stateWith(decisions = []) { + return { decisions, declines: {}, cards: {} }; +} + +describe("how often you use a context", () => { + it("counts for nothing when you never have", () => { + assert.equal(routing.familiarity(stateWith(), ORDERS, { now: NOW }), 1); + }); + + it("helps a context you keep coming back to", () => { + const once = routing.familiarity( + stateWith([{ at: daysAgo(1), to: "Orders" }]), + ORDERS, + { now: NOW } + ); + const often = routing.familiarity( + stateWith([ + { at: daysAgo(1), to: "Orders" }, + { at: daysAgo(2), to: "Orders" }, + { at: daysAgo(3), to: "Orders" } + ]), + ORDERS, + { now: NOW } + ); + assert.ok(once > 1); + assert.ok(often > once); + }); + + it("forgets slowly, so last month counts for less than last week", () => { + const recent = routing.familiarity(stateWith([{ at: daysAgo(2), to: "Orders" }]), ORDERS, { + now: NOW + }); + const old = routing.familiarity(stateWith([{ at: daysAgo(40), to: "Orders" }]), ORDERS, { + now: NOW + }); + assert.ok(recent > old); + assert.ok(old > 1); + }); + + it("stays a hint, never a decision", () => { + // Fifty uses must not let a context run away with the ranking: the words + // asked for decide, and this only leans. + const heavy = stateWith( + Array.from({ length: 50 }, () => ({ at: daysAgo(1), to: "Orders" })) + ); + assert.ok(routing.familiarity(heavy, ORDERS, { now: NOW }) <= 1.25); + }); + + it("credits only the context that was actually chosen", () => { + const state = stateWith([{ at: daysAgo(1), to: "Orders" }]); + assert.equal(routing.familiarity(state, QUEUE, { now: NOW }), 1); + }); + + it("ignores entries it cannot read", () => { + const state = stateWith([ + { at: "not a date", to: "Orders" }, + { at: daysAgo(-5), to: "Orders" }, + { to: "Orders" }, + null + ]); + assert.equal(routing.familiarity(state, ORDERS, { now: NOW }), 1); + }); + + it("copes with a state that has no history at all", () => { + assert.equal(routing.familiarity({}, ORDERS, { now: NOW }), 1); + }); +}); + +describe("staying where you are", () => { + it("gives the connected context a head start", () => { + const connected = routing.familiarity(stateWith(), ORDERS, { + connectedId: "orders", + now: NOW + }); + assert.ok(connected > 1); + }); + + it("gives it to the connected one only", () => { + const state = stateWith(); + assert.equal(routing.familiarity(state, QUEUE, { connectedId: "orders", now: NOW }), 1); + }); +}); + +describe("what it does to a shortlist", () => { + const CONTEXTS = [ + { id: "orders", name: "Orders", revision: 1, routingDescription: "partition lag on the stream" }, + { id: "queue", name: "Queue", revision: 1, routingDescription: "partition lag on the stream" } + ]; + + function rankWith(state, options) { + const candidates = createRoutingIndex({ listFiles: () => Promise.resolve([]) }); + return candidates(CONTEXTS, state, "partition lag on the stream", options); + } + + it("leaves an even match to the id, with no history to go on", async () => { + const results = await rankWith(stateWith(), {}); + assert.equal(results[0].id, "orders"); + assert.equal(results[0].score, results[1].score); + }); + + it("puts the context you keep using first", async () => { + const results = await rankWith( + stateWith([ + { at: new Date().toISOString(), to: "Queue" }, + { at: new Date().toISOString(), to: "Queue" } + ]), + {} + ); + assert.equal(results[0].id, "queue"); + }); + + it("does not hand the session away from an equal match", async () => { + // The incumbent wins a tie, which is the whole point: leaving is the + // unusual move and should need more evidence than staying. + const results = await rankWith(stateWith(), { connectedId: "queue" }); + assert.equal(results[0].id, "queue"); + assert.ok(results[0].score > results[1].score); + }); + + it("still loses to a context the request plainly names", async () => { + // Staying put is a lean, not a lock. A request that names one of them by + // its own words has to win, connected or not. + const named = [ + { id: "orders", name: "Orders", revision: 1, routingDescription: "pgbouncer pool exhaustion" }, + { id: "queue", name: "Queue", revision: 1, routingDescription: "partition lag on the stream" } + ]; + const candidates = createRoutingIndex({ listFiles: () => Promise.resolve([]) }); + const results = await candidates(named, stateWith(), "pgbouncer pool exhaustion", { + connectedId: "queue" + }); + assert.equal(results[0].id, "orders"); + }); +});