dist / tools / contextGauge.js
dist / tools / contextGauge.js
"use strict";
/**
* @file contextGauge.ts
* F1 (direction F): reactive context-window gauge. A plugin CANNOT rewrite
* chat history (platform limit — ONTOLOGY §5.3 / T13), but it can MEASURE how
* full the window is and warn the agent BEFORE the host errors out.
*
* Strategy — cheap first, precise on demand:
* 1. every turn: estimate from message chars (chars / CHARS_PER_TOKEN);
* 2. if the estimate crosses PROBE_RATIO — ask the ACTUAL chat model
* (ctl.tokenSource(), never an on-demand load) for applyPromptTemplate +
* countTokens — one localhost roundtrip, trusted over the estimate;
* 3. pct >= WARN_RATIO → one-shot warning; afterwards re-warn only after
* +ESCALATE_DELTA growth. State lives in .plugin_state.json per chat.
*
* Contract: runContextGauge NEVER throws and is bounded by GAUGE_TIMEOUT_MS —
* it is advisory and must never break or stall a chat turn.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.GAUGE_TUNABLES = void 0;
exports.textOfMessage = textOfMessage;
exports.estimateHistoryChars = estimateHistoryChars;
exports.buildGaugeWarning = buildGaugeWarning;
exports.scaleMemoryInjection = scaleMemoryInjection;
exports.runContextGauge = runContextGauge;
const GAUGE_TUNABLES = {
/** Heuristic chars-per-token for the cheap estimate. */
CHARS_PER_TOKEN: 4,
/** Cross this (by estimate) → run the exact measurement. */
PROBE_RATIO: 0.6,
/** Exact/estimated pct at or above this → warn (ONTOLOGY F1: 80%). */
WARN_RATIO: 0.8,
/** Re-warn only after this much additional fill (+5 pct points). */
ESCALATE_DELTA: 0.05,
/** Estimates miss system prompt + tool docs — inflate before trusting. */
ESTIMATE_PESSIMISM: 1.25,
/** Cap for warning-from-estimate (without an exact measurement). */
ESTIMATE_CAP: 0.92,
/** getContextLength() cache TTL — cheap localhost call, but still. */
CTXLEN_CACHE_MS: 30_000,
/** Hard bound for any single model roundtrip in the gauge. */
GAUGE_TIMEOUT_MS: 2_500,
/** At/above this fill — auto-schedule session compaction (F2). */
AUTO_COMPACT_RATIO: 0.9,
};
exports.GAUGE_TUNABLES = GAUGE_TUNABLES;
/** Module-scoped cache (SDK re-invokes the provider every turn). */
const ctxLenCache = { value: null, at: 0 };
/** F3: shrink own injection budgets as the window fills (pct = trusted fill 0..1). */
function scaleMemoryInjection(pct) {
if (pct >= 0.85) return { countFactor: 0.5, charFactor: 0.6 };
if (pct >= 0.7) return { countFactor: 0.8, charFactor: 0.85 };
return { countFactor: 1, charFactor: 1 };
}
/** Text content of one ChatMessage / raw-data message; '' if unreadable. */
function textOfMessage(m) {
try {
if (typeof m?.getText === "function")
return m.getText() ?? ""; // real ChatMessage API (pullHistory)
const data = typeof m?.get === "function" ? m.get() : m;
const content = data?.content;
if (typeof content === "string")
return content;
if (Array.isArray(content)) {
let s = "";
for (const p of content) {
if (p && p.type === "text" && typeof p.content === "string")
s += p.content;
}
return s;
}
}
catch { /* non-standard part */ }
return "";
}
function estimateHistoryChars(messages) {
let total = 0;
for (const m of messages ?? [])
total += textOfMessage(m).length;
return total;
}
function buildGaugeWarning({ pct, usedTokens, totalTokens, measured }) {
const pc = Math.round(pct * 100);
return `\n\n⚠️ [CONTEXT GAUGE: ${pc}% used (${usedTokens.toLocaleString()}/${totalTokens.toLocaleString()} tokens${measured ? "" : ", estimated"}). The window is about to run out — when it does the chat FAILS, and a plugin cannot rewrite history to save it.
ACT NOW: 1) call \`compact_session\` (schedules a full history digest into memory), plus \`Remember\` for anything critical, 2) wrap up the step and recommend the user a fresh chat (it inherits the digest). Do NOT start new large file reads or screenshots.]`;
}
async function withTimeout(promise, ms) {
let timer;
try {
return await Promise.race([
promise,
new Promise((_, rej) => { timer = setTimeout(() => rej(new Error("gauge-timeout")), ms); }),
]);
}
finally {
if (timer)
clearTimeout(timer);
}
}
async function resolveModel(ctl, opts) {
if (opts.model)
return opts.model;
try {
const ts = await ctl.tokenSource();
if (ts && typeof ts.getContextLength === "function")
return ts; // a generator-plugin token source has no getContextLength
}
catch { /* ignore */ }
return null;
}
async function getContextLength(ctl, opts, T, cache) {
const now = (opts.now ? opts.now() : Date.now());
if (cache.value && now - cache.at < T.CTXLEN_CACHE_MS)
return cache.value;
const model = await resolveModel(ctl, opts);
if (model) {
try {
const v = await withTimeout(model.getContextLength(), T.GAUGE_TIMEOUT_MS);
if (typeof v === "number" && v > 0) {
cache.value = v;
cache.at = now;
}
}
catch { /* stale cache is better than none */ }
}
return cache.value;
}
/** Real tokenization of the full chat; null if model/calls unavailable. */
async function measureExactPct(ctl, history, ctxLen, opts, T) {
const model = opts.model ?? (await resolveModel(ctl, opts));
if (!model || typeof model.applyPromptTemplate !== "function" || typeof model.countTokens !== "function")
return null;
try {
const templated = await withTimeout(model.applyPromptTemplate(history), T.GAUGE_TIMEOUT_MS);
const tokens = await withTimeout(model.countTokens(templated), T.GAUGE_TIMEOUT_MS);
if (typeof tokens !== "number" || tokens <= 0)
return null;
return tokens / ctxLen;
}
catch {
return null;
}
}
/**
* The gauge entry. Mutates state.contextGauge ({warned, warnedPct}) on warn —
* persistence happens in the preprocessor's existing end-of-turn save.
* Returns the warning text to inject, or null (no warn this turn).
*/
async function runContextGauge(ctl, history, state, opts = {}) {
try {
const T = opts.tunables ?? GAUGE_TUNABLES;
const cache = opts.cache ?? ctxLenCache;
if (!state || !history || typeof history.getMessagesArray !== "function")
return null;
if (!state.contextGauge)
state.contextGauge = { warned: false, warnedPct: null };
const s = state.contextGauge;
const chars = estimateHistoryChars(history.getMessagesArray());
if (chars === 0)
return null;
const ctxLen = await getContextLength(ctl, opts, T, cache);
if (!ctxLen || ctxLen <= 0)
return null;
const estPct = chars / (ctxLen * T.CHARS_PER_TOKEN);
let exactPct = null;
if (estPct >= T.PROBE_RATIO)
exactPct = await measureExactPct(ctl, history, ctxLen, opts, T);
const trusted = exactPct != null ? exactPct : Math.min(estPct * T.ESTIMATE_PESSIMISM, T.ESTIMATE_CAP);
s.lastPct = exactPct != null ? exactPct : estPct; // F3 feed
if (trusted < T.WARN_RATIO)
return null;
const shouldWarn = !s.warned || (s.warnedPct != null && trusted >= s.warnedPct + T.ESCALATE_DELTA);
if (!shouldWarn)
return null;
s.warned = true;
s.warnedPct = trusted;
let autoNote = "";
if (trusted >= T.AUTO_COMPACT_RATIO && typeof opts.requestCompact === "function" && !state.compactScheduled) {
try {
await opts.requestCompact({ source: "gauge", note: `auto-scheduled by context gauge at ${Math.round(trusted * 100)}%` });
state.compactScheduled = true;
autoNote = "\n🤖 Auto-compact scheduled: the next message triggers a history digest into memory.";
}
catch { }
}
return buildGaugeWarning({
pct: trusted,
usedTokens: Math.round(trusted * ctxLen),
totalTokens: ctxLen,
measured: exactPct != null,
}) + autoNote;
}
catch {
return null; // advisory only — the gauge must never break a turn
}
}
//# sourceMappingURL=contextGauge.js.map
"use strict";
/**
* @file contextGauge.ts
* F1 (direction F): reactive context-window gauge. A plugin CANNOT rewrite
* chat history (platform limit — ONTOLOGY §5.3 / T13), but it can MEASURE how
* full the window is and warn the agent BEFORE the host errors out.
*
* Strategy — cheap first, precise on demand:
* 1. every turn: estimate from message chars (chars / CHARS_PER_TOKEN);
* 2. if the estimate crosses PROBE_RATIO — ask the ACTUAL chat model
* (ctl.tokenSource(), never an on-demand load) for applyPromptTemplate +
* countTokens — one localhost roundtrip, trusted over the estimate;
* 3. pct >= WARN_RATIO → one-shot warning; afterwards re-warn only after
* +ESCALATE_DELTA growth. State lives in .plugin_state.json per chat.
*
* Contract: runContextGauge NEVER throws and is bounded by GAUGE_TIMEOUT_MS —
* it is advisory and must never break or stall a chat turn.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.GAUGE_TUNABLES = void 0;
exports.textOfMessage = textOfMessage;
exports.estimateHistoryChars = estimateHistoryChars;
exports.buildGaugeWarning = buildGaugeWarning;
exports.scaleMemoryInjection = scaleMemoryInjection;
exports.runContextGauge = runContextGauge;
const GAUGE_TUNABLES = {
/** Heuristic chars-per-token for the cheap estimate. */
CHARS_PER_TOKEN: 4,
/** Cross this (by estimate) → run the exact measurement. */
PROBE_RATIO: 0.6,
/** Exact/estimated pct at or above this → warn (ONTOLOGY F1: 80%). */
WARN_RATIO: 0.8,
/** Re-warn only after this much additional fill (+5 pct points). */
ESCALATE_DELTA: 0.05,
/** Estimates miss system prompt + tool docs — inflate before trusting. */
ESTIMATE_PESSIMISM: 1.25,
/** Cap for warning-from-estimate (without an exact measurement). */
ESTIMATE_CAP: 0.92,
/** getContextLength() cache TTL — cheap localhost call, but still. */
CTXLEN_CACHE_MS: 30_000,
/** Hard bound for any single model roundtrip in the gauge. */
GAUGE_TIMEOUT_MS: 2_500,
/** At/above this fill — auto-schedule session compaction (F2). */
AUTO_COMPACT_RATIO: 0.9,
};
exports.GAUGE_TUNABLES = GAUGE_TUNABLES;
/** Module-scoped cache (SDK re-invokes the provider every turn). */
const ctxLenCache = { value: null, at: 0 };
/** F3: shrink own injection budgets as the window fills (pct = trusted fill 0..1). */
function scaleMemoryInjection(pct) {
if (pct >= 0.85) return { countFactor: 0.5, charFactor: 0.6 };
if (pct >= 0.7) return { countFactor: 0.8, charFactor: 0.85 };
return { countFactor: 1, charFactor: 1 };
}
/** Text content of one ChatMessage / raw-data message; '' if unreadable. */
function textOfMessage(m) {
try {
if (typeof m?.getText === "function")
return m.getText() ?? ""; // real ChatMessage API (pullHistory)
const data = typeof m?.get === "function" ? m.get() : m;
const content = data?.content;
if (typeof content === "string")
return content;
if (Array.isArray(content)) {
let s = "";
for (const p of content) {
if (p && p.type === "text" && typeof p.content === "string")
s += p.content;
}
return s;
}
}
catch { /* non-standard part */ }
return "";
}
function estimateHistoryChars(messages) {
let total = 0;
for (const m of messages ?? [])
total += textOfMessage(m).length;
return total;
}
function buildGaugeWarning({ pct, usedTokens, totalTokens, measured }) {
const pc = Math.round(pct * 100);
return `\n\n⚠️ [CONTEXT GAUGE: ${pc}% used (${usedTokens.toLocaleString()}/${totalTokens.toLocaleString()} tokens${measured ? "" : ", estimated"}). The window is about to run out — when it does the chat FAILS, and a plugin cannot rewrite history to save it.
ACT NOW: 1) call \`compact_session\` (schedules a full history digest into memory), plus \`Remember\` for anything critical, 2) wrap up the step and recommend the user a fresh chat (it inherits the digest). Do NOT start new large file reads or screenshots.]`;
}
async function withTimeout(promise, ms) {
let timer;
try {
return await Promise.race([
promise,
new Promise((_, rej) => { timer = setTimeout(() => rej(new Error("gauge-timeout")), ms); }),
]);
}
finally {
if (timer)
clearTimeout(timer);
}
}
async function resolveModel(ctl, opts) {
if (opts.model)
return opts.model;
try {
const ts = await ctl.tokenSource();
if (ts && typeof ts.getContextLength === "function")
return ts; // a generator-plugin token source has no getContextLength
}
catch { /* ignore */ }
return null;
}
async function getContextLength(ctl, opts, T, cache) {
const now = (opts.now ? opts.now() : Date.now());
if (cache.value && now - cache.at < T.CTXLEN_CACHE_MS)
return cache.value;
const model = await resolveModel(ctl, opts);
if (model) {
try {
const v = await withTimeout(model.getContextLength(), T.GAUGE_TIMEOUT_MS);
if (typeof v === "number" && v > 0) {
cache.value = v;
cache.at = now;
}
}
catch { /* stale cache is better than none */ }
}
return cache.value;
}
/** Real tokenization of the full chat; null if model/calls unavailable. */
async function measureExactPct(ctl, history, ctxLen, opts, T) {
const model = opts.model ?? (await resolveModel(ctl, opts));
if (!model || typeof model.applyPromptTemplate !== "function" || typeof model.countTokens !== "function")
return null;
try {
const templated = await withTimeout(model.applyPromptTemplate(history), T.GAUGE_TIMEOUT_MS);
const tokens = await withTimeout(model.countTokens(templated), T.GAUGE_TIMEOUT_MS);
if (typeof tokens !== "number" || tokens <= 0)
return null;
return tokens / ctxLen;
}
catch {
return null;
}
}
/**
* The gauge entry. Mutates state.contextGauge ({warned, warnedPct}) on warn —
* persistence happens in the preprocessor's existing end-of-turn save.
* Returns the warning text to inject, or null (no warn this turn).
*/
async function runContextGauge(ctl, history, state, opts = {}) {
try {
const T = opts.tunables ?? GAUGE_TUNABLES;
const cache = opts.cache ?? ctxLenCache;
if (!state || !history || typeof history.getMessagesArray !== "function")
return null;
if (!state.contextGauge)
state.contextGauge = { warned: false, warnedPct: null };
const s = state.contextGauge;
const chars = estimateHistoryChars(history.getMessagesArray());
if (chars === 0)
return null;
const ctxLen = await getContextLength(ctl, opts, T, cache);
if (!ctxLen || ctxLen <= 0)
return null;
const estPct = chars / (ctxLen * T.CHARS_PER_TOKEN);
let exactPct = null;
if (estPct >= T.PROBE_RATIO)
exactPct = await measureExactPct(ctl, history, ctxLen, opts, T);
const trusted = exactPct != null ? exactPct : Math.min(estPct * T.ESTIMATE_PESSIMISM, T.ESTIMATE_CAP);
s.lastPct = exactPct != null ? exactPct : estPct; // F3 feed
if (trusted < T.WARN_RATIO)
return null;
const shouldWarn = !s.warned || (s.warnedPct != null && trusted >= s.warnedPct + T.ESCALATE_DELTA);
if (!shouldWarn)
return null;
s.warned = true;
s.warnedPct = trusted;
let autoNote = "";
if (trusted >= T.AUTO_COMPACT_RATIO && typeof opts.requestCompact === "function" && !state.compactScheduled) {
try {
await opts.requestCompact({ source: "gauge", note: `auto-scheduled by context gauge at ${Math.round(trusted * 100)}%` });
state.compactScheduled = true;
autoNote = "\n🤖 Auto-compact scheduled: the next message triggers a history digest into memory.";
}
catch { }
}
return buildGaugeWarning({
pct: trusted,
usedTokens: Math.round(trusted * ctxLen),
totalTokens: ctxLen,
measured: exactPct != null,
}) + autoNote;
}
catch {
return null; // advisory only — the gauge must never break a turn
}
}
//# sourceMappingURL=contextGauge.js.map