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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -337,15 +337,18 @@ 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 ||
entries.length < SHORTLIST_MIN_CONTEXTS
) {
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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -101,27 +101,34 @@ 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.
//
// 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));
};
}
52 changes: 52 additions & 0 deletions codex-marketplace/plugins/neatcontext/src/core/routing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 6 additions & 3 deletions plugins/claude-code/neatcontext/src/claude/mcp-bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -336,15 +336,18 @@ 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 ||
entries.length < SHORTLIST_MIN_CONTEXTS
) {
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;
}
Expand Down
25 changes: 16 additions & 9 deletions plugins/claude-code/neatcontext/src/core/routing-candidates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -101,27 +101,34 @@ 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.
//
// 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));
};
}
52 changes: 52 additions & 0 deletions plugins/claude-code/neatcontext/src/core/routing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 6 additions & 3 deletions plugins/copilot/neatcontext/src/copilot/mcp-bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -330,15 +330,18 @@ 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 ||
entries.length < SHORTLIST_MIN_CONTEXTS
) {
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;
}
Expand Down
25 changes: 16 additions & 9 deletions plugins/copilot/neatcontext/src/core/routing-candidates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -101,27 +101,34 @@ 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.
//
// 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));
};
}
Loading