From ef9c8ce8d19a712597fb6244aacb9d1253ffe89f Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Sun, 9 Aug 2026 07:08:12 +0800 Subject: [PATCH] feat(routing): weigh what you use and where you already are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing has judged a request purely on words. Two things about the session itself are worth a little weight beside them. A context you keep coming back to gets a small lift, decaying over about two weeks — the idea a browser address bar runs on, and the same problem: many items, a short vague request, one chance to be right. It saturates, so fifty uses cannot let a context run away with the ranking. The context already connected gets a head start too, because most requests continue the last one and leaving should need more evidence than staying. That is hysteresis, and it needs no new machinery: inflating the incumbent 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 rather than a switch. Both stay hints. Neither can outrank a request that plainly names another context, and both are capped well below what the words decide. Nothing ties a context to a folder or a repository, deliberately. 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. These two signals read only what this machine has done and write nothing to a bundle, so a context stays exactly as portable as it was. --- .../neatcontext/src/codex/mcp-bridge.mjs | 9 +- .../src/core/routing-candidates.mjs | 25 ++- .../plugins/neatcontext/src/core/routing.mjs | 52 ++++++ .../neatcontext/src/claude/mcp-bridge.mjs | 9 +- .../src/core/routing-candidates.mjs | 25 ++- .../neatcontext/src/core/routing.mjs | 52 ++++++ .../neatcontext/src/copilot/mcp-bridge.mjs | 9 +- .../src/core/routing-candidates.mjs | 25 ++- .../copilot/neatcontext/src/core/routing.mjs | 52 ++++++ .../src/core/routing-candidates.mjs | 25 ++- .../neatcontext/src/core/routing.mjs | 52 ++++++ .../neatcontext/src/kimi/mcp-bridge.mjs | 9 +- .../src/core/routing-candidates.mjs | 25 ++- plugins/pi/neatcontext/src/core/routing.mjs | 52 ++++++ plugins/pi/neatcontext/src/pi/runtime.mjs | 9 +- shared/core/routing-candidates.mjs | 25 ++- shared/core/routing.mjs | 52 ++++++ tests/routing-familiarity.test.mjs | 175 ++++++++++++++++++ 18 files changed, 613 insertions(+), 69 deletions(-) create mode 100644 tests/routing-familiarity.test.mjs 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"); + }); +});