@ooples/token-optimizer-mcp
Advanced tools
+8
-0
@@ -8,2 +8,10 @@ # Changelog | ||
| ## [5.6.1](https://github.com/ooples/token-optimizer-mcp/compare/v5.6.0...v5.6.1) (2026-08-08) | ||
| ### Bug Fixes | ||
| * **keepwarm,lessons:** make the tripwire reachable, the gaps per-session, the anchors real ([#295](https://github.com/ooples/token-optimizer-mcp/issues/295)) ([c22bc55](https://github.com/ooples/token-optimizer-mcp/commit/c22bc55600014c59c61e6a225034f5c6a2f20b93)) | ||
| * **keepwarm,lessons:** score refreshes with the model that bought them ([#293](https://github.com/ooples/token-optimizer-mcp/issues/293)) ([ff158bf](https://github.com/ooples/token-optimizer-mcp/commit/ff158bf3d4021c5b13398392e1b28d824bebf8fc)) | ||
| ## [5.6.0](https://github.com/ooples/token-optimizer-mcp/compare/v5.5.0...v5.6.0) (2026-08-08) | ||
@@ -10,0 +18,0 @@ |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"wiki-read.d.ts","sourceRoot":"","sources":["../../../src/tools/intelligence/wiki-read.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAaH,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,2DAA2D;IAC3D,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,+EAA+E;IAC/E,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,8FAA8F;IAC9F,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAcD,wBAAsB,QAAQ,CAAC,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,cAAc,CAAC,CAyHrF;AAED,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmCrC,CAAC"} | ||
| {"version":3,"file":"wiki-read.d.ts","sourceRoot":"","sources":["../../../src/tools/intelligence/wiki-read.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAcH,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,2DAA2D;IAC3D,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,+EAA+E;IAC/E,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,8FAA8F;IAC9F,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAcD,wBAAsB,QAAQ,CAC5B,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,cAAc,CAAC,CAiIzB;AAED,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmCrC,CAAC"} |
@@ -29,3 +29,4 @@ /** | ||
| function coreUrl(name) { | ||
| return pathToFileURL(path.join(here, '..', '..', '..', 'hooks-core', name)).href; | ||
| return pathToFileURL(path.join(here, '..', '..', '..', 'hooks-core', name)) | ||
| .href; | ||
| } | ||
@@ -44,3 +45,7 @@ function toFinding(node) { | ||
| export async function wikiRead(options = {}) { | ||
| const empty = { findings: [], shared: [], unresolvedAnchors: [] }; | ||
| const empty = { | ||
| findings: [], | ||
| shared: [], | ||
| unresolvedAnchors: [], | ||
| }; | ||
| const anchors = Array.isArray(options?.anchors) | ||
@@ -78,3 +83,5 @@ ? options.anchors.filter((a) => typeof a === 'string' && a.trim()) | ||
| const primary = options.projectRoot ?? | ||
| (anchors.length ? wiki.projectRootFor(anchors[0].split('#')[0], process.cwd()) : process.cwd()); | ||
| (anchors.length | ||
| ? wiki.projectRootFor(anchors[0].split('#')[0], process.cwd()) | ||
| : process.cwd()); | ||
| const seen = new Set(); | ||
@@ -81,0 +88,0 @@ const findings = []; |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"wiki-read.js","sourceRoot":"","sources":["../../../src/tools/intelligence/wiki-read.ts"],"names":[],"mappings":"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"} | ||
| {"version":3,"file":"wiki-read.js","sourceRoot":"","sources":["../../../src/tools/intelligence/wiki-read.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,KAAK,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAE/B,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAErD,gGAAgG;AAChG,SAAS,OAAO,CAAC,IAAY;IAC3B,OAAO,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,CAAC,CAAC;SACxE,IAAI,CAAC;AACV,CAAC;AA8CD,SAAS,SAAS,CAAC,IAAS;IAC1B,OAAO;QACL,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,SAAS;QAC5B,KAAK,EAAE,IAAI,CAAC,KAAK;QACjB,UAAU,EAAE,OAAO,IAAI,CAAC,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG;QACvE,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,EAAE,EAAE,IAAI,CAAC,EAAE;QACX,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,OAAO,EAAE,IAAI,CAAC,OAAO;KACtB,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,QAAQ,CAC5B,UAA2B,EAAE;IAE7B,MAAM,KAAK,GAAG;QACZ,QAAQ,EAAE,EAAmB;QAC7B,MAAM,EAAE,EAAmB;QAC3B,iBAAiB,EAAE,EAAc;KAClC,CAAC;IAEF,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC;QAC7C,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,IAAI,EAAE,CAAC;QAClE,CAAC,CAAC,EAAE,CAAC;IAEP,IAAI,CAAC,OAAO,CAAC,MAAM,IAAI,CAAC,OAAO,EAAE,WAAW,EAAE,CAAC;QAC7C,OAAO;YACL,OAAO,EAAE,KAAK;YACd,GAAG,KAAK;YACR,KAAK,EAAE,8DAA8D;SACtE,CAAC;IACJ,CAAC;IAED,MAAM,KAAK,GACT,MAAM,CAAC,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC,IAAK,OAAQ,CAAC,KAAgB,GAAG,CAAC;QAC/D,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,OAAQ,CAAC,KAAe,EAAE,GAAG,CAAC;QACzC,CAAC,CAAC,EAAE,CAAC;IAET,IAAI,CAAC;QACH,MAAM,IAAI,GAAQ,MAAM,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC;QAEpD,yFAAyF;QACzF,2FAA2F;QAC3F,uBAAuB;QACvB,EAAE;QACF,2FAA2F;QAC3F,qFAAqF;QACrF,sFAAsF;QACtF,2FAA2F;QAC3F,4FAA4F;QAC5F,qFAAqF;QACrF,MAAM,MAAM,GAAG,IAAI,GAAG,EAAe,CAAC;QACtC,MAAM,QAAQ,GAAG,CAAC,OAAe,EAAE,EAAE;YACnC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC;gBACtB,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;YACxD,OAAO,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAC7B,CAAC,CAAC;QAEF,MAAM,OAAO,GACX,OAAO,CAAC,WAAW;YACnB,CAAC,OAAO,CAAC,MAAM;gBACb,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC;gBAC9D,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;QAErB,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;QAC/B,MAAM,QAAQ,GAAkB,EAAE,CAAC;QACnC,MAAM,iBAAiB,GAAa,EAAE,CAAC;QAEvC,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;YACxB,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;YACpC,uFAAuF;YACvF,2EAA2E;YAC3E,MAAM,OAAO,GACX,OAAO,CAAC,WAAW,IAAI,IAAI,CAAC,cAAc,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;YAClE,MAAM,KAAK,GAAG,QAAQ,CAAC,OAAO,CAAC,CAAC;YAEhC,MAAM,EAAE,GAAG,MAAM;gBACf,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,GAAG,IAAI,IAAI,MAAM,EAAE,CAAC;gBAC5C,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,YAAY,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC;YAEzD,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;gBACzB,iBAAiB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;gBAC1B,SAAS;YACX,CAAC;YACD,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,WAAW,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC;gBAC1D,oFAAoF;gBACpF,qFAAqF;gBACrF,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC;gBACrC,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;oBAAE,SAAS;gBAC5B,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;gBACd,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;YACjC,CAAC;QACH,CAAC;QAED,MAAM,OAAO,GAAG,OAAO,CAAC;QACxB,MAAM,KAAK,GAAG,QAAQ,CAAC,OAAO,CAAC,CAAC;QAEhC,0FAA0F;QAC1F,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;YACpB,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC;gBACxC,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS,IAAI,IAAI,CAAC,OAAO,IAAI,CAAC,IAAI,CAAC,KAAK;oBAAE,SAAS;gBACrE,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC;oBAAE,SAAS;gBACnC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;gBACrB,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;YACjC,CAAC;YACD,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC,CAAC,CAAC;YACnE,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;QACrD,CAAC;QAED,MAAM,MAAM,GAAkB,EAAE,CAAC;QACjC,IAAI,OAAO,EAAE,aAAa,KAAK,KAAK,EAAE,CAAC;YACrC,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;YAChD,KAAK,MAAM,IAAI,IAAI,WAAW,CAAC,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC;gBAC9C,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS,IAAI,IAAI,CAAC,OAAO,IAAI,CAAC,IAAI,CAAC,KAAK;oBAAE,SAAS;gBACrE,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC;oBAAE,SAAS;gBACnC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;gBACrB,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;YAC/B,CAAC;YACD,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC,CAAC,CAAC;YACjE,MAAM,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;QACjD,CAAC;QAED,OAAO;YACL,OAAO,EAAE,IAAI;YACb,OAAO;YACP,QAAQ;YACR,MAAM;YACN,iBAAiB;YACjB,4FAA4F;YAC5F,2FAA2F;YAC3F,2CAA2C;YAC3C,IAAI,EACF,QAAQ,CAAC,MAAM,KAAK,CAAC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;gBAC1C,CAAC,CAAC,2IAA2I;gBAC7I,CAAC,CAAC,SAAS;SAChB,CAAC;IACJ,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO;YACL,OAAO,EAAE,KAAK;YACd,GAAG,KAAK;YACR,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;SAC9D,CAAC;IACJ,CAAC;AACH,CAAC;AAED,MAAM,CAAC,MAAM,yBAAyB,GAAG;IACvC,IAAI,EAAE,WAAW;IACjB,WAAW,EACT,6FAA6F;QAC7F,8FAA8F;QAC9F,8FAA8F;QAC9F,wFAAwF;QACxF,8FAA8F;QAC9F,+EAA+E;IACjF,WAAW,EAAE;QACX,IAAI,EAAE,QAAQ;QACd,UAAU,EAAE;YACV,OAAO,EAAE;gBACP,IAAI,EAAE,OAAO;gBACb,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE;gBACzB,WAAW,EACT,uFAAuF;aAC1F;YACD,WAAW,EAAE;gBACX,IAAI,EAAE,QAAQ;gBACd,WAAW,EACT,iFAAiF;oBACjF,8BAA8B;aACjC;YACD,KAAK,EAAE;gBACL,IAAI,EAAE,QAAQ;gBACd,WAAW,EAAE,yDAAyD;aACvE;YACD,aAAa,EAAE;gBACb,IAAI,EAAE,SAAS;gBACf,WAAW,EACT,yEAAyE;aAC5E;SACF;KACF;CACF,CAAC"} |
+80
-12
@@ -27,3 +27,3 @@ /** | ||
| import { record, readMetrics } from './metrics.mjs'; | ||
| import { record, readMetrics, readBalance } from './metrics.mjs'; | ||
| import { WRITE_MULTIPLIER, READ_MULTIPLIER } from './cache.mjs'; | ||
@@ -58,10 +58,33 @@ | ||
| export function gapDistribution(dir, { events = readMetrics(dir) } = {}) { | ||
| const stamps = events.map((e) => e.at).filter(Number.isFinite).sort((a, b) => a - b); | ||
| // PARTITIONED BY SESSION, because the interval between the last event of one session and the | ||
| // first of the next is not a gap between turns at all. The log spans days, so an overnight | ||
| // sixteen-hour boundary was counted as one -- dominating p90 and p99 and diluting | ||
| // probabilityWithin in the conservative direction, so ttlTier answered "neither tier pays" on | ||
| // projects where it would have paid. Silent, because that bias only ever declines to act. | ||
| // | ||
| // A long gap WITHIN a session is kept. It is real evidence that caching does not pay there, and | ||
| // dropping it would bias the answer the other way -- making keep-warm look better than it is, | ||
| // which is the direction this project cares about most. | ||
| // | ||
| // Events carrying no sessionId are pooled into one group rather than discarded: most kinds do | ||
| // carry one, and discarding the rest would throw away whole projects' history for a technicality. | ||
| const bySession = new Map(); | ||
| for (const event of events) { | ||
| if (!Number.isFinite(event?.at)) continue; | ||
| const key = event.sessionId || ''; | ||
| if (!bySession.has(key)) bySession.set(key, []); | ||
| bySession.get(key).push(event.at); | ||
| } | ||
| const stamps = [...bySession.values()].flat(); | ||
| if (stamps.length < 8) return null; | ||
| const gaps = []; | ||
| for (let i = 1; i < stamps.length; i++) { | ||
| const gap = stamps[i] - stamps[i - 1]; | ||
| // A burst of events inside one turn is not a gap between turns. | ||
| if (gap > 250) gaps.push(gap); | ||
| for (const session of bySession.values()) { | ||
| session.sort((a, b) => a - b); | ||
| for (let i = 1; i < session.length; i++) { | ||
| const gap = session[i] - session[i - 1]; | ||
| // A burst of events inside one turn is not a gap between turns. | ||
| if (gap > 250) gaps.push(gap); | ||
| } | ||
| } | ||
@@ -140,3 +163,10 @@ if (gaps.length < 6) return null; | ||
| const perTurn = hit * READ_MULTIPLIER + (1 - hit) * tier.writeMultiplier; | ||
| const turns = Math.max(1, turnsPerSession); | ||
| // THE SUPPLIED VALUE, not only the computed one. Math.max(1, NaN) is NaN -- Math.max | ||
| // propagates it rather than clamping -- so perTurn became NaN, the `perTurn >= 1` guard below | ||
| // read `NaN >= 1` as false and PASSED, and this returned action:'refresh' with an | ||
| // expectedValue of NaN and a reason string reading "NaN% of gaps land inside 5m". A positive | ||
| // verdict built entirely out of NaN. The guard was on the wrong side of the computation. | ||
| const turns = Number.isFinite(turnsPerSession) && turnsPerSession >= 1 | ||
| ? turnsPerSession | ||
| : DEFAULT_TURNS; | ||
| return { | ||
@@ -180,5 +210,17 @@ tier, | ||
| const tierSpec = TIERS.find((t) => t.name === tier) || TIERS[0]; | ||
| // THE SAME LEDGER keepWarmDecision BUYS THE REFRESH WITH. That function is explicit that a | ||
| // refresh is a PING, which READS the prefix -- costOfPing = prefixTokens * READ_MULTIPLIER -- | ||
| // and its comment warns that pricing the ping as a write "overstates its cost by more than | ||
| // twelvefold". This scored the very same refresh as a re-WRITE, charging (writeMultiplier - 1) | ||
| // on both branches. | ||
| // | ||
| // The disagreement runs in the direction that kills the feature. Mean realised per refresh under | ||
| // the old lines was p*(0.9h - 0.25), negative below a 27.8% hit rate, while the decision's own | ||
| // model makes the ping pay above 8.7%. For any project whose gaps fall in that band, `tripwire` | ||
| // accumulates a negative balance and permanently disables a policy that is genuinely paying -- | ||
| // and tells the user "keep-warm has lost N tokens ... stopping". The backstop fired on its own | ||
| // accounting error rather than on a distribution shift. | ||
| const realised = hit | ||
| ? prefixTokens * (1 - READ_MULTIPLIER) - prefixTokens * (tierSpec.writeMultiplier - 1) | ||
| : -prefixTokens * (tierSpec.writeMultiplier - 1); | ||
| ? prefixTokens * (tierSpec.writeMultiplier - READ_MULTIPLIER) - prefixTokens * READ_MULTIPLIER | ||
| : -prefixTokens * READ_MULTIPLIER; | ||
| record(dir, { kind: 'keepwarm', action: 'outcome', tier, prefixTokens, hit: Boolean(hit), realised: Math.round(realised) }); | ||
@@ -195,3 +237,12 @@ } | ||
| */ | ||
| export function tripwire(dir, { events = readMetrics(dir) } = {}) { | ||
| /** | ||
| * The backstop, read from the log that is NOT windowed. | ||
| * | ||
| * readBalance rather than readMetrics, and that distinction is the difference between a backstop | ||
| * that works and one that cannot. There is one outcome per refresh, in a log dominated by reads | ||
| * and captures, so through the 5000-event window the ten TRIPWIRE_MIN demands aged out before the | ||
| * tenth was written -- and this returned "only N/10 refreshes observed" for the life of the | ||
| * project. A guard that can never reach its own threshold is not a guard. | ||
| */ | ||
| export function tripwire(dir, { events = readBalance(dir) } = {}) { | ||
| const outcomes = events.filter((e) => e.kind === 'keepwarm' && e.action === 'outcome'); | ||
@@ -221,4 +272,13 @@ if (outcomes.length < TRIPWIRE_MIN) { | ||
| */ | ||
| export function shouldKeepWarm(dir, { prefixTokens, events = readMetrics(dir) } = {}) { | ||
| const trip = tripwire(dir, { events }); | ||
| export function shouldKeepWarm(dir, { | ||
| prefixTokens, | ||
| events = readMetrics(dir), | ||
| // TWO SOURCES, because the two consumers want different things. gapDistribution wants the | ||
| // firehose -- every event is a timestamp and the recent ones describe the current rhythm. The | ||
| // tripwire wants the unwindowed balance log, because its outcomes are rare and are exactly what | ||
| // the window evicts. Passing one shared array to both, as this used to, meant whichever reader | ||
| // was chosen was wrong for one of them. | ||
| outcomes = readBalance(dir), | ||
| } = {}) { | ||
| const trip = tripwire(dir, { events: outcomes }); | ||
| if (trip.tripped) return { action: 'skip', reason: trip.reason, trippedWire: true }; | ||
@@ -229,3 +289,11 @@ | ||
| if (!best) { | ||
| // THE TWO MODELS ANSWER DIFFERENT QUESTIONS, so they may legitimately disagree. ttlTier asks | ||
| // whether holding a cache beats not caching at all; keepWarmDecision asks whether ONE ping | ||
| // beats letting the entry lapse -- and a ping can pay where no tier does. Coercing everything | ||
| // that was not 'unknown' to 'skip' while keeping the decision's reason verbatim produced a | ||
| // refusal justified by a GAIN: `{ action: 'skip', reason: '...expected gain 130 tokens' }`. | ||
| // This module's own docstring says a refusal that cannot be checked is indistinguishable from | ||
| // a bug; one that contradicts itself is worse. | ||
| const decision = keepWarmDecision({ prefixTokens, gaps }); | ||
| if (decision.action === 'refresh') return decision; | ||
| return { action: decision.action === 'unknown' ? 'unknown' : 'skip', reason: decision.reason }; | ||
@@ -232,0 +300,0 @@ } |
@@ -31,2 +31,4 @@ /** | ||
| import { ORIGIN_HARVESTED, ORIGIN_HUMAN } from './curate.mjs'; | ||
| import { safeTrigger } from './inject.mjs'; | ||
| import { isFsSafePath } from './paths.mjs'; | ||
@@ -93,2 +95,9 @@ /** Lessons longer than this are prose, not instructions. */ | ||
| const piece = rendered[i]; | ||
| // A SINGLE oversize turn must not end the digest. One pasted stack trace in the last turn | ||
| // made the budget test below true on the FIRST iteration, so `out` stayed empty, this | ||
| // returned null, and harvest-worker skipped the entire feedback pass for that session -- | ||
| // no extraction, no lesson validation, no metrics record, no signal anywhere. The loop runs | ||
| // backwards precisely to keep the end, and a big terminal turn is exactly what a session | ||
| // that went wrong tends to produce. | ||
| if (piece.length > maxChars) continue; | ||
| if (total + piece.length > maxChars) break; | ||
@@ -167,3 +176,3 @@ out.unshift(piece); | ||
| */ | ||
| export function validateLessons(raw, turns) { | ||
| export function validateLessons(raw, turns, { knownFiles = null } = {}) { | ||
| let parsed = raw; | ||
@@ -203,5 +212,13 @@ if (typeof raw === 'string') { | ||
| } | ||
| try { | ||
| new RegExp(trigger); | ||
| } catch { | ||
| // THE SAME GATE THE INJECTOR USES. `new RegExp` only proves the pattern COMPILES. inject.mjs | ||
| // additionally refuses sources over 200 characters and nested-quantifier ReDoS shapes -- and | ||
| // when it refuses, appliesToCommand falls back to a LITERAL substring search of the regex | ||
| // SOURCE against the command. A regex source is not a substring of any real command -- | ||
| // a word-boundary-anchored pattern for the jest runner does not appear literally inside | ||
| // `npx jest --watch` -- | ||
| // so a lesson stored with such a trigger is written to the graph, counted as delivered in the | ||
| // metrics record, and can never surface for the rest of its life. Both rejection shapes are | ||
| // realistic from the extraction prompt: a long alternation over test runners passes 200 | ||
| // characters easily, and `(\w+\s*)+\.csproj` is the kind of thing a model emits unprompted. | ||
| if (!safeTrigger(trigger)) { | ||
| rejected.push({ reason: 'bad-trigger-regex', trigger }); | ||
@@ -227,4 +244,15 @@ continue; | ||
| confidence: verified ? 0.95 : 0.6, | ||
| // HELD TO THE FILES THE SESSION ACTUALLY TOUCHED, exactly as the finding path already is. | ||
| // harvest-worker calls validate() with `knownFiles: filesIn(digest)` and says why in its own | ||
| // comment -- "a model that invents a plausible path cannot anchor a finding to it" -- while | ||
| // this path accepted any non-empty string. The identical hallucination therefore walked | ||
| // straight through, and a lesson anchored to a file nobody opened is then injected on every | ||
| // future touch of it: a permanent instruction attached to code it was never about. | ||
| // | ||
| // isFsSafePath as well, because these strings reach the filesystem later through the graph. | ||
| // A path holding U+10FFFF ABORTS the process inside libuv rather than throwing, so no | ||
| // try/catch downstream can contain it. | ||
| anchors: Array.isArray(item?.anchors) | ||
| ? item.anchors.filter((a) => typeof a === 'string' && a.trim()) | ||
| ? item.anchors.filter((a) => typeof a === 'string' && a.trim() && isFsSafePath(a) | ||
| && (!knownFiles || knownFiles.includes(a))) | ||
| : [], | ||
@@ -231,0 +259,0 @@ }); |
@@ -79,2 +79,7 @@ /** | ||
| 'forecast-outcome', | ||
| // The keep-warm tripwire's evidence. It needs TRIPWIRE_MIN outcomes before it may have an | ||
| // opinion, and there is one outcome per refresh -- so through the windowed reader the tenth | ||
| // aged out before it was written and the backstop was structurally unable to fire, reporting | ||
| // "only N/10 refreshes observed" for the life of the project. | ||
| 'keepwarm', | ||
| ]); | ||
@@ -81,0 +86,0 @@ |
@@ -29,3 +29,3 @@ // GENERATED FILE -- do not edit. | ||
| import { record, readMetrics } from './metrics.mjs'; | ||
| import { record, readMetrics, readBalance } from './metrics.mjs'; | ||
| import { WRITE_MULTIPLIER, READ_MULTIPLIER } from './cache.mjs'; | ||
@@ -60,10 +60,33 @@ | ||
| export function gapDistribution(dir, { events = readMetrics(dir) } = {}) { | ||
| const stamps = events.map((e) => e.at).filter(Number.isFinite).sort((a, b) => a - b); | ||
| // PARTITIONED BY SESSION, because the interval between the last event of one session and the | ||
| // first of the next is not a gap between turns at all. The log spans days, so an overnight | ||
| // sixteen-hour boundary was counted as one -- dominating p90 and p99 and diluting | ||
| // probabilityWithin in the conservative direction, so ttlTier answered "neither tier pays" on | ||
| // projects where it would have paid. Silent, because that bias only ever declines to act. | ||
| // | ||
| // A long gap WITHIN a session is kept. It is real evidence that caching does not pay there, and | ||
| // dropping it would bias the answer the other way -- making keep-warm look better than it is, | ||
| // which is the direction this project cares about most. | ||
| // | ||
| // Events carrying no sessionId are pooled into one group rather than discarded: most kinds do | ||
| // carry one, and discarding the rest would throw away whole projects' history for a technicality. | ||
| const bySession = new Map(); | ||
| for (const event of events) { | ||
| if (!Number.isFinite(event?.at)) continue; | ||
| const key = event.sessionId || ''; | ||
| if (!bySession.has(key)) bySession.set(key, []); | ||
| bySession.get(key).push(event.at); | ||
| } | ||
| const stamps = [...bySession.values()].flat(); | ||
| if (stamps.length < 8) return null; | ||
| const gaps = []; | ||
| for (let i = 1; i < stamps.length; i++) { | ||
| const gap = stamps[i] - stamps[i - 1]; | ||
| // A burst of events inside one turn is not a gap between turns. | ||
| if (gap > 250) gaps.push(gap); | ||
| for (const session of bySession.values()) { | ||
| session.sort((a, b) => a - b); | ||
| for (let i = 1; i < session.length; i++) { | ||
| const gap = session[i] - session[i - 1]; | ||
| // A burst of events inside one turn is not a gap between turns. | ||
| if (gap > 250) gaps.push(gap); | ||
| } | ||
| } | ||
@@ -142,3 +165,10 @@ if (gaps.length < 6) return null; | ||
| const perTurn = hit * READ_MULTIPLIER + (1 - hit) * tier.writeMultiplier; | ||
| const turns = Math.max(1, turnsPerSession); | ||
| // THE SUPPLIED VALUE, not only the computed one. Math.max(1, NaN) is NaN -- Math.max | ||
| // propagates it rather than clamping -- so perTurn became NaN, the `perTurn >= 1` guard below | ||
| // read `NaN >= 1` as false and PASSED, and this returned action:'refresh' with an | ||
| // expectedValue of NaN and a reason string reading "NaN% of gaps land inside 5m". A positive | ||
| // verdict built entirely out of NaN. The guard was on the wrong side of the computation. | ||
| const turns = Number.isFinite(turnsPerSession) && turnsPerSession >= 1 | ||
| ? turnsPerSession | ||
| : DEFAULT_TURNS; | ||
| return { | ||
@@ -182,5 +212,17 @@ tier, | ||
| const tierSpec = TIERS.find((t) => t.name === tier) || TIERS[0]; | ||
| // THE SAME LEDGER keepWarmDecision BUYS THE REFRESH WITH. That function is explicit that a | ||
| // refresh is a PING, which READS the prefix -- costOfPing = prefixTokens * READ_MULTIPLIER -- | ||
| // and its comment warns that pricing the ping as a write "overstates its cost by more than | ||
| // twelvefold". This scored the very same refresh as a re-WRITE, charging (writeMultiplier - 1) | ||
| // on both branches. | ||
| // | ||
| // The disagreement runs in the direction that kills the feature. Mean realised per refresh under | ||
| // the old lines was p*(0.9h - 0.25), negative below a 27.8% hit rate, while the decision's own | ||
| // model makes the ping pay above 8.7%. For any project whose gaps fall in that band, `tripwire` | ||
| // accumulates a negative balance and permanently disables a policy that is genuinely paying -- | ||
| // and tells the user "keep-warm has lost N tokens ... stopping". The backstop fired on its own | ||
| // accounting error rather than on a distribution shift. | ||
| const realised = hit | ||
| ? prefixTokens * (1 - READ_MULTIPLIER) - prefixTokens * (tierSpec.writeMultiplier - 1) | ||
| : -prefixTokens * (tierSpec.writeMultiplier - 1); | ||
| ? prefixTokens * (tierSpec.writeMultiplier - READ_MULTIPLIER) - prefixTokens * READ_MULTIPLIER | ||
| : -prefixTokens * READ_MULTIPLIER; | ||
| record(dir, { kind: 'keepwarm', action: 'outcome', tier, prefixTokens, hit: Boolean(hit), realised: Math.round(realised) }); | ||
@@ -197,3 +239,12 @@ } | ||
| */ | ||
| export function tripwire(dir, { events = readMetrics(dir) } = {}) { | ||
| /** | ||
| * The backstop, read from the log that is NOT windowed. | ||
| * | ||
| * readBalance rather than readMetrics, and that distinction is the difference between a backstop | ||
| * that works and one that cannot. There is one outcome per refresh, in a log dominated by reads | ||
| * and captures, so through the 5000-event window the ten TRIPWIRE_MIN demands aged out before the | ||
| * tenth was written -- and this returned "only N/10 refreshes observed" for the life of the | ||
| * project. A guard that can never reach its own threshold is not a guard. | ||
| */ | ||
| export function tripwire(dir, { events = readBalance(dir) } = {}) { | ||
| const outcomes = events.filter((e) => e.kind === 'keepwarm' && e.action === 'outcome'); | ||
@@ -223,4 +274,13 @@ if (outcomes.length < TRIPWIRE_MIN) { | ||
| */ | ||
| export function shouldKeepWarm(dir, { prefixTokens, events = readMetrics(dir) } = {}) { | ||
| const trip = tripwire(dir, { events }); | ||
| export function shouldKeepWarm(dir, { | ||
| prefixTokens, | ||
| events = readMetrics(dir), | ||
| // TWO SOURCES, because the two consumers want different things. gapDistribution wants the | ||
| // firehose -- every event is a timestamp and the recent ones describe the current rhythm. The | ||
| // tripwire wants the unwindowed balance log, because its outcomes are rare and are exactly what | ||
| // the window evicts. Passing one shared array to both, as this used to, meant whichever reader | ||
| // was chosen was wrong for one of them. | ||
| outcomes = readBalance(dir), | ||
| } = {}) { | ||
| const trip = tripwire(dir, { events: outcomes }); | ||
| if (trip.tripped) return { action: 'skip', reason: trip.reason, trippedWire: true }; | ||
@@ -231,3 +291,11 @@ | ||
| if (!best) { | ||
| // THE TWO MODELS ANSWER DIFFERENT QUESTIONS, so they may legitimately disagree. ttlTier asks | ||
| // whether holding a cache beats not caching at all; keepWarmDecision asks whether ONE ping | ||
| // beats letting the entry lapse -- and a ping can pay where no tier does. Coercing everything | ||
| // that was not 'unknown' to 'skip' while keeping the decision's reason verbatim produced a | ||
| // refusal justified by a GAIN: `{ action: 'skip', reason: '...expected gain 130 tokens' }`. | ||
| // This module's own docstring says a refusal that cannot be checked is indistinguishable from | ||
| // a bug; one that contradicts itself is worse. | ||
| const decision = keepWarmDecision({ prefixTokens, gaps }); | ||
| if (decision.action === 'refresh') return decision; | ||
| return { action: decision.action === 'unknown' ? 'unknown' : 'skip', reason: decision.reason }; | ||
@@ -234,0 +302,0 @@ } |
@@ -33,2 +33,4 @@ // GENERATED FILE -- do not edit. | ||
| import { ORIGIN_HARVESTED, ORIGIN_HUMAN } from './curate.mjs'; | ||
| import { safeTrigger } from './inject.mjs'; | ||
| import { isFsSafePath } from './paths.mjs'; | ||
@@ -95,2 +97,9 @@ /** Lessons longer than this are prose, not instructions. */ | ||
| const piece = rendered[i]; | ||
| // A SINGLE oversize turn must not end the digest. One pasted stack trace in the last turn | ||
| // made the budget test below true on the FIRST iteration, so `out` stayed empty, this | ||
| // returned null, and harvest-worker skipped the entire feedback pass for that session -- | ||
| // no extraction, no lesson validation, no metrics record, no signal anywhere. The loop runs | ||
| // backwards precisely to keep the end, and a big terminal turn is exactly what a session | ||
| // that went wrong tends to produce. | ||
| if (piece.length > maxChars) continue; | ||
| if (total + piece.length > maxChars) break; | ||
@@ -169,3 +178,3 @@ out.unshift(piece); | ||
| */ | ||
| export function validateLessons(raw, turns) { | ||
| export function validateLessons(raw, turns, { knownFiles = null } = {}) { | ||
| let parsed = raw; | ||
@@ -205,5 +214,13 @@ if (typeof raw === 'string') { | ||
| } | ||
| try { | ||
| new RegExp(trigger); | ||
| } catch { | ||
| // THE SAME GATE THE INJECTOR USES. `new RegExp` only proves the pattern COMPILES. inject.mjs | ||
| // additionally refuses sources over 200 characters and nested-quantifier ReDoS shapes -- and | ||
| // when it refuses, appliesToCommand falls back to a LITERAL substring search of the regex | ||
| // SOURCE against the command. A regex source is not a substring of any real command -- | ||
| // a word-boundary-anchored pattern for the jest runner does not appear literally inside | ||
| // `npx jest --watch` -- | ||
| // so a lesson stored with such a trigger is written to the graph, counted as delivered in the | ||
| // metrics record, and can never surface for the rest of its life. Both rejection shapes are | ||
| // realistic from the extraction prompt: a long alternation over test runners passes 200 | ||
| // characters easily, and `(\w+\s*)+\.csproj` is the kind of thing a model emits unprompted. | ||
| if (!safeTrigger(trigger)) { | ||
| rejected.push({ reason: 'bad-trigger-regex', trigger }); | ||
@@ -229,4 +246,15 @@ continue; | ||
| confidence: verified ? 0.95 : 0.6, | ||
| // HELD TO THE FILES THE SESSION ACTUALLY TOUCHED, exactly as the finding path already is. | ||
| // harvest-worker calls validate() with `knownFiles: filesIn(digest)` and says why in its own | ||
| // comment -- "a model that invents a plausible path cannot anchor a finding to it" -- while | ||
| // this path accepted any non-empty string. The identical hallucination therefore walked | ||
| // straight through, and a lesson anchored to a file nobody opened is then injected on every | ||
| // future touch of it: a permanent instruction attached to code it was never about. | ||
| // | ||
| // isFsSafePath as well, because these strings reach the filesystem later through the graph. | ||
| // A path holding U+10FFFF ABORTS the process inside libuv rather than throwing, so no | ||
| // try/catch downstream can contain it. | ||
| anchors: Array.isArray(item?.anchors) | ||
| ? item.anchors.filter((a) => typeof a === 'string' && a.trim()) | ||
| ? item.anchors.filter((a) => typeof a === 'string' && a.trim() && isFsSafePath(a) | ||
| && (!knownFiles || knownFiles.includes(a))) | ||
| : [], | ||
@@ -233,0 +261,0 @@ }); |
@@ -81,2 +81,7 @@ // GENERATED FILE -- do not edit. | ||
| 'forecast-outcome', | ||
| // The keep-warm tripwire's evidence. It needs TRIPWIRE_MIN outcomes before it may have an | ||
| // opinion, and there is one outcome per refresh -- so through the windowed reader the tenth | ||
| // aged out before it was written and the backstop was structurally unable to fire, reporting | ||
| // "only N/10 refreshes observed" for the life of the project. | ||
| 'keepwarm', | ||
| ]); | ||
@@ -83,0 +88,0 @@ |
@@ -29,3 +29,3 @@ // GENERATED FILE -- do not edit. | ||
| import { record, readMetrics } from './metrics.mjs'; | ||
| import { record, readMetrics, readBalance } from './metrics.mjs'; | ||
| import { WRITE_MULTIPLIER, READ_MULTIPLIER } from './cache.mjs'; | ||
@@ -60,10 +60,33 @@ | ||
| export function gapDistribution(dir, { events = readMetrics(dir) } = {}) { | ||
| const stamps = events.map((e) => e.at).filter(Number.isFinite).sort((a, b) => a - b); | ||
| // PARTITIONED BY SESSION, because the interval between the last event of one session and the | ||
| // first of the next is not a gap between turns at all. The log spans days, so an overnight | ||
| // sixteen-hour boundary was counted as one -- dominating p90 and p99 and diluting | ||
| // probabilityWithin in the conservative direction, so ttlTier answered "neither tier pays" on | ||
| // projects where it would have paid. Silent, because that bias only ever declines to act. | ||
| // | ||
| // A long gap WITHIN a session is kept. It is real evidence that caching does not pay there, and | ||
| // dropping it would bias the answer the other way -- making keep-warm look better than it is, | ||
| // which is the direction this project cares about most. | ||
| // | ||
| // Events carrying no sessionId are pooled into one group rather than discarded: most kinds do | ||
| // carry one, and discarding the rest would throw away whole projects' history for a technicality. | ||
| const bySession = new Map(); | ||
| for (const event of events) { | ||
| if (!Number.isFinite(event?.at)) continue; | ||
| const key = event.sessionId || ''; | ||
| if (!bySession.has(key)) bySession.set(key, []); | ||
| bySession.get(key).push(event.at); | ||
| } | ||
| const stamps = [...bySession.values()].flat(); | ||
| if (stamps.length < 8) return null; | ||
| const gaps = []; | ||
| for (let i = 1; i < stamps.length; i++) { | ||
| const gap = stamps[i] - stamps[i - 1]; | ||
| // A burst of events inside one turn is not a gap between turns. | ||
| if (gap > 250) gaps.push(gap); | ||
| for (const session of bySession.values()) { | ||
| session.sort((a, b) => a - b); | ||
| for (let i = 1; i < session.length; i++) { | ||
| const gap = session[i] - session[i - 1]; | ||
| // A burst of events inside one turn is not a gap between turns. | ||
| if (gap > 250) gaps.push(gap); | ||
| } | ||
| } | ||
@@ -142,3 +165,10 @@ if (gaps.length < 6) return null; | ||
| const perTurn = hit * READ_MULTIPLIER + (1 - hit) * tier.writeMultiplier; | ||
| const turns = Math.max(1, turnsPerSession); | ||
| // THE SUPPLIED VALUE, not only the computed one. Math.max(1, NaN) is NaN -- Math.max | ||
| // propagates it rather than clamping -- so perTurn became NaN, the `perTurn >= 1` guard below | ||
| // read `NaN >= 1` as false and PASSED, and this returned action:'refresh' with an | ||
| // expectedValue of NaN and a reason string reading "NaN% of gaps land inside 5m". A positive | ||
| // verdict built entirely out of NaN. The guard was on the wrong side of the computation. | ||
| const turns = Number.isFinite(turnsPerSession) && turnsPerSession >= 1 | ||
| ? turnsPerSession | ||
| : DEFAULT_TURNS; | ||
| return { | ||
@@ -182,5 +212,17 @@ tier, | ||
| const tierSpec = TIERS.find((t) => t.name === tier) || TIERS[0]; | ||
| // THE SAME LEDGER keepWarmDecision BUYS THE REFRESH WITH. That function is explicit that a | ||
| // refresh is a PING, which READS the prefix -- costOfPing = prefixTokens * READ_MULTIPLIER -- | ||
| // and its comment warns that pricing the ping as a write "overstates its cost by more than | ||
| // twelvefold". This scored the very same refresh as a re-WRITE, charging (writeMultiplier - 1) | ||
| // on both branches. | ||
| // | ||
| // The disagreement runs in the direction that kills the feature. Mean realised per refresh under | ||
| // the old lines was p*(0.9h - 0.25), negative below a 27.8% hit rate, while the decision's own | ||
| // model makes the ping pay above 8.7%. For any project whose gaps fall in that band, `tripwire` | ||
| // accumulates a negative balance and permanently disables a policy that is genuinely paying -- | ||
| // and tells the user "keep-warm has lost N tokens ... stopping". The backstop fired on its own | ||
| // accounting error rather than on a distribution shift. | ||
| const realised = hit | ||
| ? prefixTokens * (1 - READ_MULTIPLIER) - prefixTokens * (tierSpec.writeMultiplier - 1) | ||
| : -prefixTokens * (tierSpec.writeMultiplier - 1); | ||
| ? prefixTokens * (tierSpec.writeMultiplier - READ_MULTIPLIER) - prefixTokens * READ_MULTIPLIER | ||
| : -prefixTokens * READ_MULTIPLIER; | ||
| record(dir, { kind: 'keepwarm', action: 'outcome', tier, prefixTokens, hit: Boolean(hit), realised: Math.round(realised) }); | ||
@@ -197,3 +239,12 @@ } | ||
| */ | ||
| export function tripwire(dir, { events = readMetrics(dir) } = {}) { | ||
| /** | ||
| * The backstop, read from the log that is NOT windowed. | ||
| * | ||
| * readBalance rather than readMetrics, and that distinction is the difference between a backstop | ||
| * that works and one that cannot. There is one outcome per refresh, in a log dominated by reads | ||
| * and captures, so through the 5000-event window the ten TRIPWIRE_MIN demands aged out before the | ||
| * tenth was written -- and this returned "only N/10 refreshes observed" for the life of the | ||
| * project. A guard that can never reach its own threshold is not a guard. | ||
| */ | ||
| export function tripwire(dir, { events = readBalance(dir) } = {}) { | ||
| const outcomes = events.filter((e) => e.kind === 'keepwarm' && e.action === 'outcome'); | ||
@@ -223,4 +274,13 @@ if (outcomes.length < TRIPWIRE_MIN) { | ||
| */ | ||
| export function shouldKeepWarm(dir, { prefixTokens, events = readMetrics(dir) } = {}) { | ||
| const trip = tripwire(dir, { events }); | ||
| export function shouldKeepWarm(dir, { | ||
| prefixTokens, | ||
| events = readMetrics(dir), | ||
| // TWO SOURCES, because the two consumers want different things. gapDistribution wants the | ||
| // firehose -- every event is a timestamp and the recent ones describe the current rhythm. The | ||
| // tripwire wants the unwindowed balance log, because its outcomes are rare and are exactly what | ||
| // the window evicts. Passing one shared array to both, as this used to, meant whichever reader | ||
| // was chosen was wrong for one of them. | ||
| outcomes = readBalance(dir), | ||
| } = {}) { | ||
| const trip = tripwire(dir, { events: outcomes }); | ||
| if (trip.tripped) return { action: 'skip', reason: trip.reason, trippedWire: true }; | ||
@@ -231,3 +291,11 @@ | ||
| if (!best) { | ||
| // THE TWO MODELS ANSWER DIFFERENT QUESTIONS, so they may legitimately disagree. ttlTier asks | ||
| // whether holding a cache beats not caching at all; keepWarmDecision asks whether ONE ping | ||
| // beats letting the entry lapse -- and a ping can pay where no tier does. Coercing everything | ||
| // that was not 'unknown' to 'skip' while keeping the decision's reason verbatim produced a | ||
| // refusal justified by a GAIN: `{ action: 'skip', reason: '...expected gain 130 tokens' }`. | ||
| // This module's own docstring says a refusal that cannot be checked is indistinguishable from | ||
| // a bug; one that contradicts itself is worse. | ||
| const decision = keepWarmDecision({ prefixTokens, gaps }); | ||
| if (decision.action === 'refresh') return decision; | ||
| return { action: decision.action === 'unknown' ? 'unknown' : 'skip', reason: decision.reason }; | ||
@@ -234,0 +302,0 @@ } |
@@ -33,2 +33,4 @@ // GENERATED FILE -- do not edit. | ||
| import { ORIGIN_HARVESTED, ORIGIN_HUMAN } from './curate.mjs'; | ||
| import { safeTrigger } from './inject.mjs'; | ||
| import { isFsSafePath } from './paths.mjs'; | ||
@@ -95,2 +97,9 @@ /** Lessons longer than this are prose, not instructions. */ | ||
| const piece = rendered[i]; | ||
| // A SINGLE oversize turn must not end the digest. One pasted stack trace in the last turn | ||
| // made the budget test below true on the FIRST iteration, so `out` stayed empty, this | ||
| // returned null, and harvest-worker skipped the entire feedback pass for that session -- | ||
| // no extraction, no lesson validation, no metrics record, no signal anywhere. The loop runs | ||
| // backwards precisely to keep the end, and a big terminal turn is exactly what a session | ||
| // that went wrong tends to produce. | ||
| if (piece.length > maxChars) continue; | ||
| if (total + piece.length > maxChars) break; | ||
@@ -169,3 +178,3 @@ out.unshift(piece); | ||
| */ | ||
| export function validateLessons(raw, turns) { | ||
| export function validateLessons(raw, turns, { knownFiles = null } = {}) { | ||
| let parsed = raw; | ||
@@ -205,5 +214,13 @@ if (typeof raw === 'string') { | ||
| } | ||
| try { | ||
| new RegExp(trigger); | ||
| } catch { | ||
| // THE SAME GATE THE INJECTOR USES. `new RegExp` only proves the pattern COMPILES. inject.mjs | ||
| // additionally refuses sources over 200 characters and nested-quantifier ReDoS shapes -- and | ||
| // when it refuses, appliesToCommand falls back to a LITERAL substring search of the regex | ||
| // SOURCE against the command. A regex source is not a substring of any real command -- | ||
| // a word-boundary-anchored pattern for the jest runner does not appear literally inside | ||
| // `npx jest --watch` -- | ||
| // so a lesson stored with such a trigger is written to the graph, counted as delivered in the | ||
| // metrics record, and can never surface for the rest of its life. Both rejection shapes are | ||
| // realistic from the extraction prompt: a long alternation over test runners passes 200 | ||
| // characters easily, and `(\w+\s*)+\.csproj` is the kind of thing a model emits unprompted. | ||
| if (!safeTrigger(trigger)) { | ||
| rejected.push({ reason: 'bad-trigger-regex', trigger }); | ||
@@ -229,4 +246,15 @@ continue; | ||
| confidence: verified ? 0.95 : 0.6, | ||
| // HELD TO THE FILES THE SESSION ACTUALLY TOUCHED, exactly as the finding path already is. | ||
| // harvest-worker calls validate() with `knownFiles: filesIn(digest)` and says why in its own | ||
| // comment -- "a model that invents a plausible path cannot anchor a finding to it" -- while | ||
| // this path accepted any non-empty string. The identical hallucination therefore walked | ||
| // straight through, and a lesson anchored to a file nobody opened is then injected on every | ||
| // future touch of it: a permanent instruction attached to code it was never about. | ||
| // | ||
| // isFsSafePath as well, because these strings reach the filesystem later through the graph. | ||
| // A path holding U+10FFFF ABORTS the process inside libuv rather than throwing, so no | ||
| // try/catch downstream can contain it. | ||
| anchors: Array.isArray(item?.anchors) | ||
| ? item.anchors.filter((a) => typeof a === 'string' && a.trim()) | ||
| ? item.anchors.filter((a) => typeof a === 'string' && a.trim() && isFsSafePath(a) | ||
| && (!knownFiles || knownFiles.includes(a))) | ||
| : [], | ||
@@ -233,0 +261,0 @@ }); |
@@ -81,2 +81,7 @@ // GENERATED FILE -- do not edit. | ||
| 'forecast-outcome', | ||
| // The keep-warm tripwire's evidence. It needs TRIPWIRE_MIN outcomes before it may have an | ||
| // opinion, and there is one outcome per refresh -- so through the windowed reader the tenth | ||
| // aged out before it was written and the backstop was structurally unable to fire, reporting | ||
| // "only N/10 refreshes observed" for the life of the project. | ||
| 'keepwarm', | ||
| ]); | ||
@@ -83,0 +88,0 @@ |
@@ -29,3 +29,3 @@ // GENERATED FILE -- do not edit. | ||
| import { record, readMetrics } from './metrics.mjs'; | ||
| import { record, readMetrics, readBalance } from './metrics.mjs'; | ||
| import { WRITE_MULTIPLIER, READ_MULTIPLIER } from './cache.mjs'; | ||
@@ -60,10 +60,33 @@ | ||
| export function gapDistribution(dir, { events = readMetrics(dir) } = {}) { | ||
| const stamps = events.map((e) => e.at).filter(Number.isFinite).sort((a, b) => a - b); | ||
| // PARTITIONED BY SESSION, because the interval between the last event of one session and the | ||
| // first of the next is not a gap between turns at all. The log spans days, so an overnight | ||
| // sixteen-hour boundary was counted as one -- dominating p90 and p99 and diluting | ||
| // probabilityWithin in the conservative direction, so ttlTier answered "neither tier pays" on | ||
| // projects where it would have paid. Silent, because that bias only ever declines to act. | ||
| // | ||
| // A long gap WITHIN a session is kept. It is real evidence that caching does not pay there, and | ||
| // dropping it would bias the answer the other way -- making keep-warm look better than it is, | ||
| // which is the direction this project cares about most. | ||
| // | ||
| // Events carrying no sessionId are pooled into one group rather than discarded: most kinds do | ||
| // carry one, and discarding the rest would throw away whole projects' history for a technicality. | ||
| const bySession = new Map(); | ||
| for (const event of events) { | ||
| if (!Number.isFinite(event?.at)) continue; | ||
| const key = event.sessionId || ''; | ||
| if (!bySession.has(key)) bySession.set(key, []); | ||
| bySession.get(key).push(event.at); | ||
| } | ||
| const stamps = [...bySession.values()].flat(); | ||
| if (stamps.length < 8) return null; | ||
| const gaps = []; | ||
| for (let i = 1; i < stamps.length; i++) { | ||
| const gap = stamps[i] - stamps[i - 1]; | ||
| // A burst of events inside one turn is not a gap between turns. | ||
| if (gap > 250) gaps.push(gap); | ||
| for (const session of bySession.values()) { | ||
| session.sort((a, b) => a - b); | ||
| for (let i = 1; i < session.length; i++) { | ||
| const gap = session[i] - session[i - 1]; | ||
| // A burst of events inside one turn is not a gap between turns. | ||
| if (gap > 250) gaps.push(gap); | ||
| } | ||
| } | ||
@@ -142,3 +165,10 @@ if (gaps.length < 6) return null; | ||
| const perTurn = hit * READ_MULTIPLIER + (1 - hit) * tier.writeMultiplier; | ||
| const turns = Math.max(1, turnsPerSession); | ||
| // THE SUPPLIED VALUE, not only the computed one. Math.max(1, NaN) is NaN -- Math.max | ||
| // propagates it rather than clamping -- so perTurn became NaN, the `perTurn >= 1` guard below | ||
| // read `NaN >= 1` as false and PASSED, and this returned action:'refresh' with an | ||
| // expectedValue of NaN and a reason string reading "NaN% of gaps land inside 5m". A positive | ||
| // verdict built entirely out of NaN. The guard was on the wrong side of the computation. | ||
| const turns = Number.isFinite(turnsPerSession) && turnsPerSession >= 1 | ||
| ? turnsPerSession | ||
| : DEFAULT_TURNS; | ||
| return { | ||
@@ -182,5 +212,17 @@ tier, | ||
| const tierSpec = TIERS.find((t) => t.name === tier) || TIERS[0]; | ||
| // THE SAME LEDGER keepWarmDecision BUYS THE REFRESH WITH. That function is explicit that a | ||
| // refresh is a PING, which READS the prefix -- costOfPing = prefixTokens * READ_MULTIPLIER -- | ||
| // and its comment warns that pricing the ping as a write "overstates its cost by more than | ||
| // twelvefold". This scored the very same refresh as a re-WRITE, charging (writeMultiplier - 1) | ||
| // on both branches. | ||
| // | ||
| // The disagreement runs in the direction that kills the feature. Mean realised per refresh under | ||
| // the old lines was p*(0.9h - 0.25), negative below a 27.8% hit rate, while the decision's own | ||
| // model makes the ping pay above 8.7%. For any project whose gaps fall in that band, `tripwire` | ||
| // accumulates a negative balance and permanently disables a policy that is genuinely paying -- | ||
| // and tells the user "keep-warm has lost N tokens ... stopping". The backstop fired on its own | ||
| // accounting error rather than on a distribution shift. | ||
| const realised = hit | ||
| ? prefixTokens * (1 - READ_MULTIPLIER) - prefixTokens * (tierSpec.writeMultiplier - 1) | ||
| : -prefixTokens * (tierSpec.writeMultiplier - 1); | ||
| ? prefixTokens * (tierSpec.writeMultiplier - READ_MULTIPLIER) - prefixTokens * READ_MULTIPLIER | ||
| : -prefixTokens * READ_MULTIPLIER; | ||
| record(dir, { kind: 'keepwarm', action: 'outcome', tier, prefixTokens, hit: Boolean(hit), realised: Math.round(realised) }); | ||
@@ -197,3 +239,12 @@ } | ||
| */ | ||
| export function tripwire(dir, { events = readMetrics(dir) } = {}) { | ||
| /** | ||
| * The backstop, read from the log that is NOT windowed. | ||
| * | ||
| * readBalance rather than readMetrics, and that distinction is the difference between a backstop | ||
| * that works and one that cannot. There is one outcome per refresh, in a log dominated by reads | ||
| * and captures, so through the 5000-event window the ten TRIPWIRE_MIN demands aged out before the | ||
| * tenth was written -- and this returned "only N/10 refreshes observed" for the life of the | ||
| * project. A guard that can never reach its own threshold is not a guard. | ||
| */ | ||
| export function tripwire(dir, { events = readBalance(dir) } = {}) { | ||
| const outcomes = events.filter((e) => e.kind === 'keepwarm' && e.action === 'outcome'); | ||
@@ -223,4 +274,13 @@ if (outcomes.length < TRIPWIRE_MIN) { | ||
| */ | ||
| export function shouldKeepWarm(dir, { prefixTokens, events = readMetrics(dir) } = {}) { | ||
| const trip = tripwire(dir, { events }); | ||
| export function shouldKeepWarm(dir, { | ||
| prefixTokens, | ||
| events = readMetrics(dir), | ||
| // TWO SOURCES, because the two consumers want different things. gapDistribution wants the | ||
| // firehose -- every event is a timestamp and the recent ones describe the current rhythm. The | ||
| // tripwire wants the unwindowed balance log, because its outcomes are rare and are exactly what | ||
| // the window evicts. Passing one shared array to both, as this used to, meant whichever reader | ||
| // was chosen was wrong for one of them. | ||
| outcomes = readBalance(dir), | ||
| } = {}) { | ||
| const trip = tripwire(dir, { events: outcomes }); | ||
| if (trip.tripped) return { action: 'skip', reason: trip.reason, trippedWire: true }; | ||
@@ -231,3 +291,11 @@ | ||
| if (!best) { | ||
| // THE TWO MODELS ANSWER DIFFERENT QUESTIONS, so they may legitimately disagree. ttlTier asks | ||
| // whether holding a cache beats not caching at all; keepWarmDecision asks whether ONE ping | ||
| // beats letting the entry lapse -- and a ping can pay where no tier does. Coercing everything | ||
| // that was not 'unknown' to 'skip' while keeping the decision's reason verbatim produced a | ||
| // refusal justified by a GAIN: `{ action: 'skip', reason: '...expected gain 130 tokens' }`. | ||
| // This module's own docstring says a refusal that cannot be checked is indistinguishable from | ||
| // a bug; one that contradicts itself is worse. | ||
| const decision = keepWarmDecision({ prefixTokens, gaps }); | ||
| if (decision.action === 'refresh') return decision; | ||
| return { action: decision.action === 'unknown' ? 'unknown' : 'skip', reason: decision.reason }; | ||
@@ -234,0 +302,0 @@ } |
@@ -33,2 +33,4 @@ // GENERATED FILE -- do not edit. | ||
| import { ORIGIN_HARVESTED, ORIGIN_HUMAN } from './curate.mjs'; | ||
| import { safeTrigger } from './inject.mjs'; | ||
| import { isFsSafePath } from './paths.mjs'; | ||
@@ -95,2 +97,9 @@ /** Lessons longer than this are prose, not instructions. */ | ||
| const piece = rendered[i]; | ||
| // A SINGLE oversize turn must not end the digest. One pasted stack trace in the last turn | ||
| // made the budget test below true on the FIRST iteration, so `out` stayed empty, this | ||
| // returned null, and harvest-worker skipped the entire feedback pass for that session -- | ||
| // no extraction, no lesson validation, no metrics record, no signal anywhere. The loop runs | ||
| // backwards precisely to keep the end, and a big terminal turn is exactly what a session | ||
| // that went wrong tends to produce. | ||
| if (piece.length > maxChars) continue; | ||
| if (total + piece.length > maxChars) break; | ||
@@ -169,3 +178,3 @@ out.unshift(piece); | ||
| */ | ||
| export function validateLessons(raw, turns) { | ||
| export function validateLessons(raw, turns, { knownFiles = null } = {}) { | ||
| let parsed = raw; | ||
@@ -205,5 +214,13 @@ if (typeof raw === 'string') { | ||
| } | ||
| try { | ||
| new RegExp(trigger); | ||
| } catch { | ||
| // THE SAME GATE THE INJECTOR USES. `new RegExp` only proves the pattern COMPILES. inject.mjs | ||
| // additionally refuses sources over 200 characters and nested-quantifier ReDoS shapes -- and | ||
| // when it refuses, appliesToCommand falls back to a LITERAL substring search of the regex | ||
| // SOURCE against the command. A regex source is not a substring of any real command -- | ||
| // a word-boundary-anchored pattern for the jest runner does not appear literally inside | ||
| // `npx jest --watch` -- | ||
| // so a lesson stored with such a trigger is written to the graph, counted as delivered in the | ||
| // metrics record, and can never surface for the rest of its life. Both rejection shapes are | ||
| // realistic from the extraction prompt: a long alternation over test runners passes 200 | ||
| // characters easily, and `(\w+\s*)+\.csproj` is the kind of thing a model emits unprompted. | ||
| if (!safeTrigger(trigger)) { | ||
| rejected.push({ reason: 'bad-trigger-regex', trigger }); | ||
@@ -229,4 +246,15 @@ continue; | ||
| confidence: verified ? 0.95 : 0.6, | ||
| // HELD TO THE FILES THE SESSION ACTUALLY TOUCHED, exactly as the finding path already is. | ||
| // harvest-worker calls validate() with `knownFiles: filesIn(digest)` and says why in its own | ||
| // comment -- "a model that invents a plausible path cannot anchor a finding to it" -- while | ||
| // this path accepted any non-empty string. The identical hallucination therefore walked | ||
| // straight through, and a lesson anchored to a file nobody opened is then injected on every | ||
| // future touch of it: a permanent instruction attached to code it was never about. | ||
| // | ||
| // isFsSafePath as well, because these strings reach the filesystem later through the graph. | ||
| // A path holding U+10FFFF ABORTS the process inside libuv rather than throwing, so no | ||
| // try/catch downstream can contain it. | ||
| anchors: Array.isArray(item?.anchors) | ||
| ? item.anchors.filter((a) => typeof a === 'string' && a.trim()) | ||
| ? item.anchors.filter((a) => typeof a === 'string' && a.trim() && isFsSafePath(a) | ||
| && (!knownFiles || knownFiles.includes(a))) | ||
| : [], | ||
@@ -233,0 +261,0 @@ }); |
@@ -81,2 +81,7 @@ // GENERATED FILE -- do not edit. | ||
| 'forecast-outcome', | ||
| // The keep-warm tripwire's evidence. It needs TRIPWIRE_MIN outcomes before it may have an | ||
| // opinion, and there is one outcome per refresh -- so through the windowed reader the tenth | ||
| // aged out before it was written and the backstop was structurally unable to fire, reporting | ||
| // "only N/10 refreshes observed" for the life of the project. | ||
| 'keepwarm', | ||
| ]); | ||
@@ -83,0 +88,0 @@ |
@@ -29,3 +29,3 @@ // GENERATED FILE -- do not edit. | ||
| import { record, readMetrics } from './metrics.mjs'; | ||
| import { record, readMetrics, readBalance } from './metrics.mjs'; | ||
| import { WRITE_MULTIPLIER, READ_MULTIPLIER } from './cache.mjs'; | ||
@@ -60,10 +60,33 @@ | ||
| export function gapDistribution(dir, { events = readMetrics(dir) } = {}) { | ||
| const stamps = events.map((e) => e.at).filter(Number.isFinite).sort((a, b) => a - b); | ||
| // PARTITIONED BY SESSION, because the interval between the last event of one session and the | ||
| // first of the next is not a gap between turns at all. The log spans days, so an overnight | ||
| // sixteen-hour boundary was counted as one -- dominating p90 and p99 and diluting | ||
| // probabilityWithin in the conservative direction, so ttlTier answered "neither tier pays" on | ||
| // projects where it would have paid. Silent, because that bias only ever declines to act. | ||
| // | ||
| // A long gap WITHIN a session is kept. It is real evidence that caching does not pay there, and | ||
| // dropping it would bias the answer the other way -- making keep-warm look better than it is, | ||
| // which is the direction this project cares about most. | ||
| // | ||
| // Events carrying no sessionId are pooled into one group rather than discarded: most kinds do | ||
| // carry one, and discarding the rest would throw away whole projects' history for a technicality. | ||
| const bySession = new Map(); | ||
| for (const event of events) { | ||
| if (!Number.isFinite(event?.at)) continue; | ||
| const key = event.sessionId || ''; | ||
| if (!bySession.has(key)) bySession.set(key, []); | ||
| bySession.get(key).push(event.at); | ||
| } | ||
| const stamps = [...bySession.values()].flat(); | ||
| if (stamps.length < 8) return null; | ||
| const gaps = []; | ||
| for (let i = 1; i < stamps.length; i++) { | ||
| const gap = stamps[i] - stamps[i - 1]; | ||
| // A burst of events inside one turn is not a gap between turns. | ||
| if (gap > 250) gaps.push(gap); | ||
| for (const session of bySession.values()) { | ||
| session.sort((a, b) => a - b); | ||
| for (let i = 1; i < session.length; i++) { | ||
| const gap = session[i] - session[i - 1]; | ||
| // A burst of events inside one turn is not a gap between turns. | ||
| if (gap > 250) gaps.push(gap); | ||
| } | ||
| } | ||
@@ -142,3 +165,10 @@ if (gaps.length < 6) return null; | ||
| const perTurn = hit * READ_MULTIPLIER + (1 - hit) * tier.writeMultiplier; | ||
| const turns = Math.max(1, turnsPerSession); | ||
| // THE SUPPLIED VALUE, not only the computed one. Math.max(1, NaN) is NaN -- Math.max | ||
| // propagates it rather than clamping -- so perTurn became NaN, the `perTurn >= 1` guard below | ||
| // read `NaN >= 1` as false and PASSED, and this returned action:'refresh' with an | ||
| // expectedValue of NaN and a reason string reading "NaN% of gaps land inside 5m". A positive | ||
| // verdict built entirely out of NaN. The guard was on the wrong side of the computation. | ||
| const turns = Number.isFinite(turnsPerSession) && turnsPerSession >= 1 | ||
| ? turnsPerSession | ||
| : DEFAULT_TURNS; | ||
| return { | ||
@@ -182,5 +212,17 @@ tier, | ||
| const tierSpec = TIERS.find((t) => t.name === tier) || TIERS[0]; | ||
| // THE SAME LEDGER keepWarmDecision BUYS THE REFRESH WITH. That function is explicit that a | ||
| // refresh is a PING, which READS the prefix -- costOfPing = prefixTokens * READ_MULTIPLIER -- | ||
| // and its comment warns that pricing the ping as a write "overstates its cost by more than | ||
| // twelvefold". This scored the very same refresh as a re-WRITE, charging (writeMultiplier - 1) | ||
| // on both branches. | ||
| // | ||
| // The disagreement runs in the direction that kills the feature. Mean realised per refresh under | ||
| // the old lines was p*(0.9h - 0.25), negative below a 27.8% hit rate, while the decision's own | ||
| // model makes the ping pay above 8.7%. For any project whose gaps fall in that band, `tripwire` | ||
| // accumulates a negative balance and permanently disables a policy that is genuinely paying -- | ||
| // and tells the user "keep-warm has lost N tokens ... stopping". The backstop fired on its own | ||
| // accounting error rather than on a distribution shift. | ||
| const realised = hit | ||
| ? prefixTokens * (1 - READ_MULTIPLIER) - prefixTokens * (tierSpec.writeMultiplier - 1) | ||
| : -prefixTokens * (tierSpec.writeMultiplier - 1); | ||
| ? prefixTokens * (tierSpec.writeMultiplier - READ_MULTIPLIER) - prefixTokens * READ_MULTIPLIER | ||
| : -prefixTokens * READ_MULTIPLIER; | ||
| record(dir, { kind: 'keepwarm', action: 'outcome', tier, prefixTokens, hit: Boolean(hit), realised: Math.round(realised) }); | ||
@@ -197,3 +239,12 @@ } | ||
| */ | ||
| export function tripwire(dir, { events = readMetrics(dir) } = {}) { | ||
| /** | ||
| * The backstop, read from the log that is NOT windowed. | ||
| * | ||
| * readBalance rather than readMetrics, and that distinction is the difference between a backstop | ||
| * that works and one that cannot. There is one outcome per refresh, in a log dominated by reads | ||
| * and captures, so through the 5000-event window the ten TRIPWIRE_MIN demands aged out before the | ||
| * tenth was written -- and this returned "only N/10 refreshes observed" for the life of the | ||
| * project. A guard that can never reach its own threshold is not a guard. | ||
| */ | ||
| export function tripwire(dir, { events = readBalance(dir) } = {}) { | ||
| const outcomes = events.filter((e) => e.kind === 'keepwarm' && e.action === 'outcome'); | ||
@@ -223,4 +274,13 @@ if (outcomes.length < TRIPWIRE_MIN) { | ||
| */ | ||
| export function shouldKeepWarm(dir, { prefixTokens, events = readMetrics(dir) } = {}) { | ||
| const trip = tripwire(dir, { events }); | ||
| export function shouldKeepWarm(dir, { | ||
| prefixTokens, | ||
| events = readMetrics(dir), | ||
| // TWO SOURCES, because the two consumers want different things. gapDistribution wants the | ||
| // firehose -- every event is a timestamp and the recent ones describe the current rhythm. The | ||
| // tripwire wants the unwindowed balance log, because its outcomes are rare and are exactly what | ||
| // the window evicts. Passing one shared array to both, as this used to, meant whichever reader | ||
| // was chosen was wrong for one of them. | ||
| outcomes = readBalance(dir), | ||
| } = {}) { | ||
| const trip = tripwire(dir, { events: outcomes }); | ||
| if (trip.tripped) return { action: 'skip', reason: trip.reason, trippedWire: true }; | ||
@@ -231,3 +291,11 @@ | ||
| if (!best) { | ||
| // THE TWO MODELS ANSWER DIFFERENT QUESTIONS, so they may legitimately disagree. ttlTier asks | ||
| // whether holding a cache beats not caching at all; keepWarmDecision asks whether ONE ping | ||
| // beats letting the entry lapse -- and a ping can pay where no tier does. Coercing everything | ||
| // that was not 'unknown' to 'skip' while keeping the decision's reason verbatim produced a | ||
| // refusal justified by a GAIN: `{ action: 'skip', reason: '...expected gain 130 tokens' }`. | ||
| // This module's own docstring says a refusal that cannot be checked is indistinguishable from | ||
| // a bug; one that contradicts itself is worse. | ||
| const decision = keepWarmDecision({ prefixTokens, gaps }); | ||
| if (decision.action === 'refresh') return decision; | ||
| return { action: decision.action === 'unknown' ? 'unknown' : 'skip', reason: decision.reason }; | ||
@@ -234,0 +302,0 @@ } |
@@ -33,2 +33,4 @@ // GENERATED FILE -- do not edit. | ||
| import { ORIGIN_HARVESTED, ORIGIN_HUMAN } from './curate.mjs'; | ||
| import { safeTrigger } from './inject.mjs'; | ||
| import { isFsSafePath } from './paths.mjs'; | ||
@@ -95,2 +97,9 @@ /** Lessons longer than this are prose, not instructions. */ | ||
| const piece = rendered[i]; | ||
| // A SINGLE oversize turn must not end the digest. One pasted stack trace in the last turn | ||
| // made the budget test below true on the FIRST iteration, so `out` stayed empty, this | ||
| // returned null, and harvest-worker skipped the entire feedback pass for that session -- | ||
| // no extraction, no lesson validation, no metrics record, no signal anywhere. The loop runs | ||
| // backwards precisely to keep the end, and a big terminal turn is exactly what a session | ||
| // that went wrong tends to produce. | ||
| if (piece.length > maxChars) continue; | ||
| if (total + piece.length > maxChars) break; | ||
@@ -169,3 +178,3 @@ out.unshift(piece); | ||
| */ | ||
| export function validateLessons(raw, turns) { | ||
| export function validateLessons(raw, turns, { knownFiles = null } = {}) { | ||
| let parsed = raw; | ||
@@ -205,5 +214,13 @@ if (typeof raw === 'string') { | ||
| } | ||
| try { | ||
| new RegExp(trigger); | ||
| } catch { | ||
| // THE SAME GATE THE INJECTOR USES. `new RegExp` only proves the pattern COMPILES. inject.mjs | ||
| // additionally refuses sources over 200 characters and nested-quantifier ReDoS shapes -- and | ||
| // when it refuses, appliesToCommand falls back to a LITERAL substring search of the regex | ||
| // SOURCE against the command. A regex source is not a substring of any real command -- | ||
| // a word-boundary-anchored pattern for the jest runner does not appear literally inside | ||
| // `npx jest --watch` -- | ||
| // so a lesson stored with such a trigger is written to the graph, counted as delivered in the | ||
| // metrics record, and can never surface for the rest of its life. Both rejection shapes are | ||
| // realistic from the extraction prompt: a long alternation over test runners passes 200 | ||
| // characters easily, and `(\w+\s*)+\.csproj` is the kind of thing a model emits unprompted. | ||
| if (!safeTrigger(trigger)) { | ||
| rejected.push({ reason: 'bad-trigger-regex', trigger }); | ||
@@ -229,4 +246,15 @@ continue; | ||
| confidence: verified ? 0.95 : 0.6, | ||
| // HELD TO THE FILES THE SESSION ACTUALLY TOUCHED, exactly as the finding path already is. | ||
| // harvest-worker calls validate() with `knownFiles: filesIn(digest)` and says why in its own | ||
| // comment -- "a model that invents a plausible path cannot anchor a finding to it" -- while | ||
| // this path accepted any non-empty string. The identical hallucination therefore walked | ||
| // straight through, and a lesson anchored to a file nobody opened is then injected on every | ||
| // future touch of it: a permanent instruction attached to code it was never about. | ||
| // | ||
| // isFsSafePath as well, because these strings reach the filesystem later through the graph. | ||
| // A path holding U+10FFFF ABORTS the process inside libuv rather than throwing, so no | ||
| // try/catch downstream can contain it. | ||
| anchors: Array.isArray(item?.anchors) | ||
| ? item.anchors.filter((a) => typeof a === 'string' && a.trim()) | ||
| ? item.anchors.filter((a) => typeof a === 'string' && a.trim() && isFsSafePath(a) | ||
| && (!knownFiles || knownFiles.includes(a))) | ||
| : [], | ||
@@ -233,0 +261,0 @@ }); |
@@ -81,2 +81,7 @@ // GENERATED FILE -- do not edit. | ||
| 'forecast-outcome', | ||
| // The keep-warm tripwire's evidence. It needs TRIPWIRE_MIN outcomes before it may have an | ||
| // opinion, and there is one outcome per refresh -- so through the windowed reader the tenth | ||
| // aged out before it was written and the backstop was structurally unable to fire, reporting | ||
| // "only N/10 refreshes observed" for the life of the project. | ||
| 'keepwarm', | ||
| ]); | ||
@@ -83,0 +88,0 @@ |
@@ -29,3 +29,3 @@ // GENERATED FILE -- do not edit. | ||
| import { record, readMetrics } from './metrics.mjs'; | ||
| import { record, readMetrics, readBalance } from './metrics.mjs'; | ||
| import { WRITE_MULTIPLIER, READ_MULTIPLIER } from './cache.mjs'; | ||
@@ -60,10 +60,33 @@ | ||
| export function gapDistribution(dir, { events = readMetrics(dir) } = {}) { | ||
| const stamps = events.map((e) => e.at).filter(Number.isFinite).sort((a, b) => a - b); | ||
| // PARTITIONED BY SESSION, because the interval between the last event of one session and the | ||
| // first of the next is not a gap between turns at all. The log spans days, so an overnight | ||
| // sixteen-hour boundary was counted as one -- dominating p90 and p99 and diluting | ||
| // probabilityWithin in the conservative direction, so ttlTier answered "neither tier pays" on | ||
| // projects where it would have paid. Silent, because that bias only ever declines to act. | ||
| // | ||
| // A long gap WITHIN a session is kept. It is real evidence that caching does not pay there, and | ||
| // dropping it would bias the answer the other way -- making keep-warm look better than it is, | ||
| // which is the direction this project cares about most. | ||
| // | ||
| // Events carrying no sessionId are pooled into one group rather than discarded: most kinds do | ||
| // carry one, and discarding the rest would throw away whole projects' history for a technicality. | ||
| const bySession = new Map(); | ||
| for (const event of events) { | ||
| if (!Number.isFinite(event?.at)) continue; | ||
| const key = event.sessionId || ''; | ||
| if (!bySession.has(key)) bySession.set(key, []); | ||
| bySession.get(key).push(event.at); | ||
| } | ||
| const stamps = [...bySession.values()].flat(); | ||
| if (stamps.length < 8) return null; | ||
| const gaps = []; | ||
| for (let i = 1; i < stamps.length; i++) { | ||
| const gap = stamps[i] - stamps[i - 1]; | ||
| // A burst of events inside one turn is not a gap between turns. | ||
| if (gap > 250) gaps.push(gap); | ||
| for (const session of bySession.values()) { | ||
| session.sort((a, b) => a - b); | ||
| for (let i = 1; i < session.length; i++) { | ||
| const gap = session[i] - session[i - 1]; | ||
| // A burst of events inside one turn is not a gap between turns. | ||
| if (gap > 250) gaps.push(gap); | ||
| } | ||
| } | ||
@@ -142,3 +165,10 @@ if (gaps.length < 6) return null; | ||
| const perTurn = hit * READ_MULTIPLIER + (1 - hit) * tier.writeMultiplier; | ||
| const turns = Math.max(1, turnsPerSession); | ||
| // THE SUPPLIED VALUE, not only the computed one. Math.max(1, NaN) is NaN -- Math.max | ||
| // propagates it rather than clamping -- so perTurn became NaN, the `perTurn >= 1` guard below | ||
| // read `NaN >= 1` as false and PASSED, and this returned action:'refresh' with an | ||
| // expectedValue of NaN and a reason string reading "NaN% of gaps land inside 5m". A positive | ||
| // verdict built entirely out of NaN. The guard was on the wrong side of the computation. | ||
| const turns = Number.isFinite(turnsPerSession) && turnsPerSession >= 1 | ||
| ? turnsPerSession | ||
| : DEFAULT_TURNS; | ||
| return { | ||
@@ -182,5 +212,17 @@ tier, | ||
| const tierSpec = TIERS.find((t) => t.name === tier) || TIERS[0]; | ||
| // THE SAME LEDGER keepWarmDecision BUYS THE REFRESH WITH. That function is explicit that a | ||
| // refresh is a PING, which READS the prefix -- costOfPing = prefixTokens * READ_MULTIPLIER -- | ||
| // and its comment warns that pricing the ping as a write "overstates its cost by more than | ||
| // twelvefold". This scored the very same refresh as a re-WRITE, charging (writeMultiplier - 1) | ||
| // on both branches. | ||
| // | ||
| // The disagreement runs in the direction that kills the feature. Mean realised per refresh under | ||
| // the old lines was p*(0.9h - 0.25), negative below a 27.8% hit rate, while the decision's own | ||
| // model makes the ping pay above 8.7%. For any project whose gaps fall in that band, `tripwire` | ||
| // accumulates a negative balance and permanently disables a policy that is genuinely paying -- | ||
| // and tells the user "keep-warm has lost N tokens ... stopping". The backstop fired on its own | ||
| // accounting error rather than on a distribution shift. | ||
| const realised = hit | ||
| ? prefixTokens * (1 - READ_MULTIPLIER) - prefixTokens * (tierSpec.writeMultiplier - 1) | ||
| : -prefixTokens * (tierSpec.writeMultiplier - 1); | ||
| ? prefixTokens * (tierSpec.writeMultiplier - READ_MULTIPLIER) - prefixTokens * READ_MULTIPLIER | ||
| : -prefixTokens * READ_MULTIPLIER; | ||
| record(dir, { kind: 'keepwarm', action: 'outcome', tier, prefixTokens, hit: Boolean(hit), realised: Math.round(realised) }); | ||
@@ -197,3 +239,12 @@ } | ||
| */ | ||
| export function tripwire(dir, { events = readMetrics(dir) } = {}) { | ||
| /** | ||
| * The backstop, read from the log that is NOT windowed. | ||
| * | ||
| * readBalance rather than readMetrics, and that distinction is the difference between a backstop | ||
| * that works and one that cannot. There is one outcome per refresh, in a log dominated by reads | ||
| * and captures, so through the 5000-event window the ten TRIPWIRE_MIN demands aged out before the | ||
| * tenth was written -- and this returned "only N/10 refreshes observed" for the life of the | ||
| * project. A guard that can never reach its own threshold is not a guard. | ||
| */ | ||
| export function tripwire(dir, { events = readBalance(dir) } = {}) { | ||
| const outcomes = events.filter((e) => e.kind === 'keepwarm' && e.action === 'outcome'); | ||
@@ -223,4 +274,13 @@ if (outcomes.length < TRIPWIRE_MIN) { | ||
| */ | ||
| export function shouldKeepWarm(dir, { prefixTokens, events = readMetrics(dir) } = {}) { | ||
| const trip = tripwire(dir, { events }); | ||
| export function shouldKeepWarm(dir, { | ||
| prefixTokens, | ||
| events = readMetrics(dir), | ||
| // TWO SOURCES, because the two consumers want different things. gapDistribution wants the | ||
| // firehose -- every event is a timestamp and the recent ones describe the current rhythm. The | ||
| // tripwire wants the unwindowed balance log, because its outcomes are rare and are exactly what | ||
| // the window evicts. Passing one shared array to both, as this used to, meant whichever reader | ||
| // was chosen was wrong for one of them. | ||
| outcomes = readBalance(dir), | ||
| } = {}) { | ||
| const trip = tripwire(dir, { events: outcomes }); | ||
| if (trip.tripped) return { action: 'skip', reason: trip.reason, trippedWire: true }; | ||
@@ -231,3 +291,11 @@ | ||
| if (!best) { | ||
| // THE TWO MODELS ANSWER DIFFERENT QUESTIONS, so they may legitimately disagree. ttlTier asks | ||
| // whether holding a cache beats not caching at all; keepWarmDecision asks whether ONE ping | ||
| // beats letting the entry lapse -- and a ping can pay where no tier does. Coercing everything | ||
| // that was not 'unknown' to 'skip' while keeping the decision's reason verbatim produced a | ||
| // refusal justified by a GAIN: `{ action: 'skip', reason: '...expected gain 130 tokens' }`. | ||
| // This module's own docstring says a refusal that cannot be checked is indistinguishable from | ||
| // a bug; one that contradicts itself is worse. | ||
| const decision = keepWarmDecision({ prefixTokens, gaps }); | ||
| if (decision.action === 'refresh') return decision; | ||
| return { action: decision.action === 'unknown' ? 'unknown' : 'skip', reason: decision.reason }; | ||
@@ -234,0 +302,0 @@ } |
@@ -33,2 +33,4 @@ // GENERATED FILE -- do not edit. | ||
| import { ORIGIN_HARVESTED, ORIGIN_HUMAN } from './curate.mjs'; | ||
| import { safeTrigger } from './inject.mjs'; | ||
| import { isFsSafePath } from './paths.mjs'; | ||
@@ -95,2 +97,9 @@ /** Lessons longer than this are prose, not instructions. */ | ||
| const piece = rendered[i]; | ||
| // A SINGLE oversize turn must not end the digest. One pasted stack trace in the last turn | ||
| // made the budget test below true on the FIRST iteration, so `out` stayed empty, this | ||
| // returned null, and harvest-worker skipped the entire feedback pass for that session -- | ||
| // no extraction, no lesson validation, no metrics record, no signal anywhere. The loop runs | ||
| // backwards precisely to keep the end, and a big terminal turn is exactly what a session | ||
| // that went wrong tends to produce. | ||
| if (piece.length > maxChars) continue; | ||
| if (total + piece.length > maxChars) break; | ||
@@ -169,3 +178,3 @@ out.unshift(piece); | ||
| */ | ||
| export function validateLessons(raw, turns) { | ||
| export function validateLessons(raw, turns, { knownFiles = null } = {}) { | ||
| let parsed = raw; | ||
@@ -205,5 +214,13 @@ if (typeof raw === 'string') { | ||
| } | ||
| try { | ||
| new RegExp(trigger); | ||
| } catch { | ||
| // THE SAME GATE THE INJECTOR USES. `new RegExp` only proves the pattern COMPILES. inject.mjs | ||
| // additionally refuses sources over 200 characters and nested-quantifier ReDoS shapes -- and | ||
| // when it refuses, appliesToCommand falls back to a LITERAL substring search of the regex | ||
| // SOURCE against the command. A regex source is not a substring of any real command -- | ||
| // a word-boundary-anchored pattern for the jest runner does not appear literally inside | ||
| // `npx jest --watch` -- | ||
| // so a lesson stored with such a trigger is written to the graph, counted as delivered in the | ||
| // metrics record, and can never surface for the rest of its life. Both rejection shapes are | ||
| // realistic from the extraction prompt: a long alternation over test runners passes 200 | ||
| // characters easily, and `(\w+\s*)+\.csproj` is the kind of thing a model emits unprompted. | ||
| if (!safeTrigger(trigger)) { | ||
| rejected.push({ reason: 'bad-trigger-regex', trigger }); | ||
@@ -229,4 +246,15 @@ continue; | ||
| confidence: verified ? 0.95 : 0.6, | ||
| // HELD TO THE FILES THE SESSION ACTUALLY TOUCHED, exactly as the finding path already is. | ||
| // harvest-worker calls validate() with `knownFiles: filesIn(digest)` and says why in its own | ||
| // comment -- "a model that invents a plausible path cannot anchor a finding to it" -- while | ||
| // this path accepted any non-empty string. The identical hallucination therefore walked | ||
| // straight through, and a lesson anchored to a file nobody opened is then injected on every | ||
| // future touch of it: a permanent instruction attached to code it was never about. | ||
| // | ||
| // isFsSafePath as well, because these strings reach the filesystem later through the graph. | ||
| // A path holding U+10FFFF ABORTS the process inside libuv rather than throwing, so no | ||
| // try/catch downstream can contain it. | ||
| anchors: Array.isArray(item?.anchors) | ||
| ? item.anchors.filter((a) => typeof a === 'string' && a.trim()) | ||
| ? item.anchors.filter((a) => typeof a === 'string' && a.trim() && isFsSafePath(a) | ||
| && (!knownFiles || knownFiles.includes(a))) | ||
| : [], | ||
@@ -233,0 +261,0 @@ }); |
@@ -81,2 +81,7 @@ // GENERATED FILE -- do not edit. | ||
| 'forecast-outcome', | ||
| // The keep-warm tripwire's evidence. It needs TRIPWIRE_MIN outcomes before it may have an | ||
| // opinion, and there is one outcome per refresh -- so through the windowed reader the tenth | ||
| // aged out before it was written and the backstop was structurally unable to fire, reporting | ||
| // "only N/10 refreshes observed" for the life of the project. | ||
| 'keepwarm', | ||
| ]); | ||
@@ -83,0 +88,0 @@ |
+1
-1
| { | ||
| "name": "@ooples/token-optimizer-mcp", | ||
| "version": "5.6.0", | ||
| "version": "5.6.1", | ||
| "mcpName": "io.github.ooples/token-optimizer-mcp", | ||
@@ -5,0 +5,0 @@ "description": "Intelligent context window optimization for Claude Code - store content externally via caching and compression, freeing up your context window for what matters", |
| { | ||
| "name": "token-optimizer", | ||
| "description": "Aggressive context-window optimization by DEFAULT: enforcing hooks route expensive built-in file, search and shell calls to cached/diffed MCP tools, cutting token usage 60-90% without the user configuring anything.", | ||
| "version": "5.6.0", | ||
| "version": "5.6.1", | ||
| "author": { | ||
@@ -6,0 +6,0 @@ "name": "ooples" |
@@ -97,3 +97,8 @@ #!/usr/bin/env node | ||
| const rawLessons = await extract(feedback, { prompt: LESSON_PROMPT }); | ||
| const { lessons, rejected } = validateLessons(rawLessons, turns); | ||
| // The same restriction the finding path above applies, for the same reason: a model that | ||
| // invents a plausible path must not be able to anchor a lesson to it. `turns` is the | ||
| // archived transcript for this session, so its rendered digest is the honest file list. | ||
| const { lessons, rejected } = validateLessons(rawLessons, turns, { | ||
| knownFiles: filesIn(feedback), | ||
| }); | ||
@@ -100,0 +105,0 @@ // Anchors are optional on a lesson -- "always run npm test" is about no |
@@ -29,3 +29,3 @@ // GENERATED FILE -- do not edit. | ||
| import { record, readMetrics } from './metrics.mjs'; | ||
| import { record, readMetrics, readBalance } from './metrics.mjs'; | ||
| import { WRITE_MULTIPLIER, READ_MULTIPLIER } from './cache.mjs'; | ||
@@ -60,10 +60,33 @@ | ||
| export function gapDistribution(dir, { events = readMetrics(dir) } = {}) { | ||
| const stamps = events.map((e) => e.at).filter(Number.isFinite).sort((a, b) => a - b); | ||
| // PARTITIONED BY SESSION, because the interval between the last event of one session and the | ||
| // first of the next is not a gap between turns at all. The log spans days, so an overnight | ||
| // sixteen-hour boundary was counted as one -- dominating p90 and p99 and diluting | ||
| // probabilityWithin in the conservative direction, so ttlTier answered "neither tier pays" on | ||
| // projects where it would have paid. Silent, because that bias only ever declines to act. | ||
| // | ||
| // A long gap WITHIN a session is kept. It is real evidence that caching does not pay there, and | ||
| // dropping it would bias the answer the other way -- making keep-warm look better than it is, | ||
| // which is the direction this project cares about most. | ||
| // | ||
| // Events carrying no sessionId are pooled into one group rather than discarded: most kinds do | ||
| // carry one, and discarding the rest would throw away whole projects' history for a technicality. | ||
| const bySession = new Map(); | ||
| for (const event of events) { | ||
| if (!Number.isFinite(event?.at)) continue; | ||
| const key = event.sessionId || ''; | ||
| if (!bySession.has(key)) bySession.set(key, []); | ||
| bySession.get(key).push(event.at); | ||
| } | ||
| const stamps = [...bySession.values()].flat(); | ||
| if (stamps.length < 8) return null; | ||
| const gaps = []; | ||
| for (let i = 1; i < stamps.length; i++) { | ||
| const gap = stamps[i] - stamps[i - 1]; | ||
| // A burst of events inside one turn is not a gap between turns. | ||
| if (gap > 250) gaps.push(gap); | ||
| for (const session of bySession.values()) { | ||
| session.sort((a, b) => a - b); | ||
| for (let i = 1; i < session.length; i++) { | ||
| const gap = session[i] - session[i - 1]; | ||
| // A burst of events inside one turn is not a gap between turns. | ||
| if (gap > 250) gaps.push(gap); | ||
| } | ||
| } | ||
@@ -142,3 +165,10 @@ if (gaps.length < 6) return null; | ||
| const perTurn = hit * READ_MULTIPLIER + (1 - hit) * tier.writeMultiplier; | ||
| const turns = Math.max(1, turnsPerSession); | ||
| // THE SUPPLIED VALUE, not only the computed one. Math.max(1, NaN) is NaN -- Math.max | ||
| // propagates it rather than clamping -- so perTurn became NaN, the `perTurn >= 1` guard below | ||
| // read `NaN >= 1` as false and PASSED, and this returned action:'refresh' with an | ||
| // expectedValue of NaN and a reason string reading "NaN% of gaps land inside 5m". A positive | ||
| // verdict built entirely out of NaN. The guard was on the wrong side of the computation. | ||
| const turns = Number.isFinite(turnsPerSession) && turnsPerSession >= 1 | ||
| ? turnsPerSession | ||
| : DEFAULT_TURNS; | ||
| return { | ||
@@ -182,5 +212,17 @@ tier, | ||
| const tierSpec = TIERS.find((t) => t.name === tier) || TIERS[0]; | ||
| // THE SAME LEDGER keepWarmDecision BUYS THE REFRESH WITH. That function is explicit that a | ||
| // refresh is a PING, which READS the prefix -- costOfPing = prefixTokens * READ_MULTIPLIER -- | ||
| // and its comment warns that pricing the ping as a write "overstates its cost by more than | ||
| // twelvefold". This scored the very same refresh as a re-WRITE, charging (writeMultiplier - 1) | ||
| // on both branches. | ||
| // | ||
| // The disagreement runs in the direction that kills the feature. Mean realised per refresh under | ||
| // the old lines was p*(0.9h - 0.25), negative below a 27.8% hit rate, while the decision's own | ||
| // model makes the ping pay above 8.7%. For any project whose gaps fall in that band, `tripwire` | ||
| // accumulates a negative balance and permanently disables a policy that is genuinely paying -- | ||
| // and tells the user "keep-warm has lost N tokens ... stopping". The backstop fired on its own | ||
| // accounting error rather than on a distribution shift. | ||
| const realised = hit | ||
| ? prefixTokens * (1 - READ_MULTIPLIER) - prefixTokens * (tierSpec.writeMultiplier - 1) | ||
| : -prefixTokens * (tierSpec.writeMultiplier - 1); | ||
| ? prefixTokens * (tierSpec.writeMultiplier - READ_MULTIPLIER) - prefixTokens * READ_MULTIPLIER | ||
| : -prefixTokens * READ_MULTIPLIER; | ||
| record(dir, { kind: 'keepwarm', action: 'outcome', tier, prefixTokens, hit: Boolean(hit), realised: Math.round(realised) }); | ||
@@ -197,3 +239,12 @@ } | ||
| */ | ||
| export function tripwire(dir, { events = readMetrics(dir) } = {}) { | ||
| /** | ||
| * The backstop, read from the log that is NOT windowed. | ||
| * | ||
| * readBalance rather than readMetrics, and that distinction is the difference between a backstop | ||
| * that works and one that cannot. There is one outcome per refresh, in a log dominated by reads | ||
| * and captures, so through the 5000-event window the ten TRIPWIRE_MIN demands aged out before the | ||
| * tenth was written -- and this returned "only N/10 refreshes observed" for the life of the | ||
| * project. A guard that can never reach its own threshold is not a guard. | ||
| */ | ||
| export function tripwire(dir, { events = readBalance(dir) } = {}) { | ||
| const outcomes = events.filter((e) => e.kind === 'keepwarm' && e.action === 'outcome'); | ||
@@ -223,4 +274,13 @@ if (outcomes.length < TRIPWIRE_MIN) { | ||
| */ | ||
| export function shouldKeepWarm(dir, { prefixTokens, events = readMetrics(dir) } = {}) { | ||
| const trip = tripwire(dir, { events }); | ||
| export function shouldKeepWarm(dir, { | ||
| prefixTokens, | ||
| events = readMetrics(dir), | ||
| // TWO SOURCES, because the two consumers want different things. gapDistribution wants the | ||
| // firehose -- every event is a timestamp and the recent ones describe the current rhythm. The | ||
| // tripwire wants the unwindowed balance log, because its outcomes are rare and are exactly what | ||
| // the window evicts. Passing one shared array to both, as this used to, meant whichever reader | ||
| // was chosen was wrong for one of them. | ||
| outcomes = readBalance(dir), | ||
| } = {}) { | ||
| const trip = tripwire(dir, { events: outcomes }); | ||
| if (trip.tripped) return { action: 'skip', reason: trip.reason, trippedWire: true }; | ||
@@ -231,3 +291,11 @@ | ||
| if (!best) { | ||
| // THE TWO MODELS ANSWER DIFFERENT QUESTIONS, so they may legitimately disagree. ttlTier asks | ||
| // whether holding a cache beats not caching at all; keepWarmDecision asks whether ONE ping | ||
| // beats letting the entry lapse -- and a ping can pay where no tier does. Coercing everything | ||
| // that was not 'unknown' to 'skip' while keeping the decision's reason verbatim produced a | ||
| // refusal justified by a GAIN: `{ action: 'skip', reason: '...expected gain 130 tokens' }`. | ||
| // This module's own docstring says a refusal that cannot be checked is indistinguishable from | ||
| // a bug; one that contradicts itself is worse. | ||
| const decision = keepWarmDecision({ prefixTokens, gaps }); | ||
| if (decision.action === 'refresh') return decision; | ||
| return { action: decision.action === 'unknown' ? 'unknown' : 'skip', reason: decision.reason }; | ||
@@ -234,0 +302,0 @@ } |
@@ -33,2 +33,4 @@ // GENERATED FILE -- do not edit. | ||
| import { ORIGIN_HARVESTED, ORIGIN_HUMAN } from './curate.mjs'; | ||
| import { safeTrigger } from './inject.mjs'; | ||
| import { isFsSafePath } from './paths.mjs'; | ||
@@ -95,2 +97,9 @@ /** Lessons longer than this are prose, not instructions. */ | ||
| const piece = rendered[i]; | ||
| // A SINGLE oversize turn must not end the digest. One pasted stack trace in the last turn | ||
| // made the budget test below true on the FIRST iteration, so `out` stayed empty, this | ||
| // returned null, and harvest-worker skipped the entire feedback pass for that session -- | ||
| // no extraction, no lesson validation, no metrics record, no signal anywhere. The loop runs | ||
| // backwards precisely to keep the end, and a big terminal turn is exactly what a session | ||
| // that went wrong tends to produce. | ||
| if (piece.length > maxChars) continue; | ||
| if (total + piece.length > maxChars) break; | ||
@@ -169,3 +178,3 @@ out.unshift(piece); | ||
| */ | ||
| export function validateLessons(raw, turns) { | ||
| export function validateLessons(raw, turns, { knownFiles = null } = {}) { | ||
| let parsed = raw; | ||
@@ -205,5 +214,13 @@ if (typeof raw === 'string') { | ||
| } | ||
| try { | ||
| new RegExp(trigger); | ||
| } catch { | ||
| // THE SAME GATE THE INJECTOR USES. `new RegExp` only proves the pattern COMPILES. inject.mjs | ||
| // additionally refuses sources over 200 characters and nested-quantifier ReDoS shapes -- and | ||
| // when it refuses, appliesToCommand falls back to a LITERAL substring search of the regex | ||
| // SOURCE against the command. A regex source is not a substring of any real command -- | ||
| // a word-boundary-anchored pattern for the jest runner does not appear literally inside | ||
| // `npx jest --watch` -- | ||
| // so a lesson stored with such a trigger is written to the graph, counted as delivered in the | ||
| // metrics record, and can never surface for the rest of its life. Both rejection shapes are | ||
| // realistic from the extraction prompt: a long alternation over test runners passes 200 | ||
| // characters easily, and `(\w+\s*)+\.csproj` is the kind of thing a model emits unprompted. | ||
| if (!safeTrigger(trigger)) { | ||
| rejected.push({ reason: 'bad-trigger-regex', trigger }); | ||
@@ -229,4 +246,15 @@ continue; | ||
| confidence: verified ? 0.95 : 0.6, | ||
| // HELD TO THE FILES THE SESSION ACTUALLY TOUCHED, exactly as the finding path already is. | ||
| // harvest-worker calls validate() with `knownFiles: filesIn(digest)` and says why in its own | ||
| // comment -- "a model that invents a plausible path cannot anchor a finding to it" -- while | ||
| // this path accepted any non-empty string. The identical hallucination therefore walked | ||
| // straight through, and a lesson anchored to a file nobody opened is then injected on every | ||
| // future touch of it: a permanent instruction attached to code it was never about. | ||
| // | ||
| // isFsSafePath as well, because these strings reach the filesystem later through the graph. | ||
| // A path holding U+10FFFF ABORTS the process inside libuv rather than throwing, so no | ||
| // try/catch downstream can contain it. | ||
| anchors: Array.isArray(item?.anchors) | ||
| ? item.anchors.filter((a) => typeof a === 'string' && a.trim()) | ||
| ? item.anchors.filter((a) => typeof a === 'string' && a.trim() && isFsSafePath(a) | ||
| && (!knownFiles || knownFiles.includes(a))) | ||
| : [], | ||
@@ -233,0 +261,0 @@ }); |
@@ -81,2 +81,7 @@ // GENERATED FILE -- do not edit. | ||
| 'forecast-outcome', | ||
| // The keep-warm tripwire's evidence. It needs TRIPWIRE_MIN outcomes before it may have an | ||
| // opinion, and there is one outcome per refresh -- so through the windowed reader the tenth | ||
| // aged out before it was written and the backstop was structurally unable to fire, reporting | ||
| // "only N/10 refreshes observed" for the life of the project. | ||
| 'keepwarm', | ||
| ]); | ||
@@ -83,0 +88,0 @@ |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 8 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 8 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
11121585
0.47%193495
0.37%