dsh-switchman 1.11.5 → 1.11.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +36 -29
- package/README.es.md +36 -29
- package/README.fr.md +36 -29
- package/README.it.md +36 -29
- package/README.ja.md +36 -29
- package/README.ko.md +36 -29
- package/README.md +38 -31
- package/README.pt.md +36 -29
- package/README.ru.md +36 -29
- package/README.zh-TW.md +36 -29
- package/README.zh.md +36 -29
- package/client.js +445 -35
- package/cordis.patch.yml +25 -9
- package/host/config.js +75 -30
- package/host/context-trim/index.js +806 -51
- package/host/context-trim/state.js +3 -2
- package/host/context-trim/surface.js +218 -7
- package/host/context-trim/trigger.js +29 -12
- package/host/context-trim/view.js +17 -2
- package/host/context-watch.js +17 -8
- package/host/intent.js +41 -4
- package/host/lang.js +4 -2
- package/host/ptc-bindings.js +510 -0
- package/host/ptc-guard.js +9 -1
- package/host/routes.js +111 -32
- package/host/teams.js +61 -15
- package/host/vision.js +24 -3
- package/index.js +5 -2
- package/package.json +1 -1
|
@@ -24,15 +24,16 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Config fields (host/config.js, all volatile): trimEnabled, trimMode
|
|
26
26
|
* ("off"|"advisory"|"auto"), trimGrowthTokens, trimIncrementTokens,
|
|
27
|
-
* trimPressurePercent, trimEmergencyPercent,
|
|
27
|
+
* trimPressurePercent, trimEmergencyPercent, trimAdvisoryPercent,
|
|
28
|
+
* trimAdvisoryCriticalPercent, trimWindowTokens,
|
|
28
29
|
* trimProtectedTail, trimMaxFoldsPerHour, trimSideModel, trimFoldFallbackModel, trimStateDir,
|
|
29
30
|
* trimLadderEnabled, trimLadderT2Tokens, trimLadderT2MinBlocks,
|
|
30
31
|
* trimLadderT3Tokens, trimLadderT3MinBlocks.
|
|
31
32
|
*/
|
|
32
33
|
|
|
33
|
-
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
34
|
-
import { join } from "node:path";
|
|
35
|
-
import { buildView, estimateTokensOf, injectView, ledgerIsStale, messageTokens, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
|
|
34
|
+
import { appendFileSync, mkdirSync, realpathSync, renameSync, statSync } from "node:fs";
|
|
35
|
+
import { join, resolve } from "node:path";
|
|
36
|
+
import { buildView, estimateTokensOf, injectView, isFoldSummaryMessage, ledgerIsStale, messageTokens, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
|
|
36
37
|
import { computeFoldableRange, evaluateTrigger, EMERGENCY_COOLDOWN_MS } from "./trigger.js";
|
|
37
38
|
import { LADDER_SYSTEM_PROMPT, mechanicalSummary, renderLadderMaterial, renderMaterial, summarizeWithModel } from "./summarizer.js";
|
|
38
39
|
import { createStore } from "./state.js";
|
|
@@ -40,6 +41,46 @@ import { createMetrics, extractUsage, promptTokensOf, wrapStreamForMetrics } fro
|
|
|
40
41
|
import { forkLinkOf, loadForkLinks, recordForkLink, resolveInheritance, INHERIT_WINDOW_MS } from "./fork.js";
|
|
41
42
|
import { createSurfaceFoldPass } from "./surface.js";
|
|
42
43
|
import { childSessionIdOf } from "../subagent-model.js";
|
|
44
|
+
import { tmpdir } from "node:os";
|
|
45
|
+
import { dshHome } from "../lib/dsh-home.js";
|
|
46
|
+
import { fileURLToPath } from "node:url";
|
|
47
|
+
|
|
48
|
+
// Module build stamp ([2026-11-15]-[stale-module observability]): the
|
|
49
|
+
// per-dispatch view-applied row carries it, so a host process still
|
|
50
|
+
// running a pre-reload copy of this engine is identifiable in
|
|
51
|
+
// decisions.log without after-the-fact reconciliation. Fail-open: 0
|
|
52
|
+
// omits the field (2026-11-15 incident: a stale module nudged at
|
|
53
|
+
// 81-125% for two hours while the relief code sat uninstalled on disk —
|
|
54
|
+
// nothing in the log distinguished the running copy from the repo one).
|
|
55
|
+
const MODULE_BUILD_STAMP = (() => {
|
|
56
|
+
try {
|
|
57
|
+
return Math.round(statSync(fileURLToPath(import.meta.url)).mtimeMs);
|
|
58
|
+
} catch {
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
/** Resolve the configured ledger state dir to an absolute path that
|
|
63
|
+
* stays inside the plugin's own state root (<dshHome>/.dsh-switchman/
|
|
64
|
+
* state/trim/) or the OS temp root (os.tmpdir(), for throwaway test
|
|
65
|
+
* state). The dir is settings-writable, and the ledger writer mkdirSyncs
|
|
66
|
+
* and writes JSON there — an arbitrary out-of-root value would aim that
|
|
67
|
+
* writer at any directory. Empty/unset keeps the store default; a
|
|
68
|
+
* relative value resolves inside the root; anything else is refused
|
|
69
|
+
* with a warn and the default root is used. Exported for tests. */
|
|
70
|
+
export function resolveStateDir(raw, warn = () => {}) {
|
|
71
|
+
const root = resolve(dshHome(), ".dsh-switchman", "state", "trim");
|
|
72
|
+
if (typeof raw !== "string" || raw.trim() === "") return undefined;
|
|
73
|
+
const candidate = resolve(root, raw);
|
|
74
|
+
if (candidate === root || candidate.startsWith(root + "/")) return candidate;
|
|
75
|
+
// The OS temp root hosts throwaway state (tests mkdtemp under it); it is
|
|
76
|
+
// per-user and 0700 on macOS/Linux, so it stays inside the threat model.
|
|
77
|
+
const tmpRoots = [resolve(tmpdir())];
|
|
78
|
+
try { tmpRoots.push(realpathSync(tmpRoots[0])); } catch { /* first is enough */ }
|
|
79
|
+
if (tmpRoots.some((t) => candidate === t || candidate.startsWith(t + "/"))) return candidate;
|
|
80
|
+
warn(`dsh-switchman: trimStateDir refused (must stay under ${root} or the OS temp root): ${raw}`);
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
43
84
|
|
|
44
85
|
// ---- agent reference helpers --------------------------------------------------
|
|
45
86
|
|
|
@@ -58,6 +99,7 @@ import { childSessionIdOf } from "../subagent-model.js";
|
|
|
58
99
|
// it and the stored tally takes over.
|
|
59
100
|
const trimViewBySession = new Map(); // sessionId -> { viewTokens, at, inject, lastNoneAt }
|
|
60
101
|
const trimWireBySession = new Map(); // sessionId -> { promptTokens, at } — last sampled provider prompt of an accepted MAIN dispatch (side calls never write; a tiny inherited-id usage sample must not lower the watermark's wire floor)
|
|
102
|
+
const trimProjectedBySession = new Map(); // sessionId -> { totalTokens, at } — last host-meter reading (tokenMeter.measure().totalTokens: provider-anchored projection of the NEXT prompt, the figure the DSH context panel displays). Observability only — recorded at the idle boundary where the agent object is reachable; the trim tiers never read it.
|
|
61
103
|
const capturedModelBySession = new Map(); // sessionId -> { provider, model, at } — last stream-captured model route, read by the watermark layer to scale subagent tiers to the session's own model window
|
|
62
104
|
/** Freshness TTL for the in-memory view/injection registry (watermark and
|
|
63
105
|
* banner display only). NEVER a fold gate: gating folds on "did the surface
|
|
@@ -177,6 +219,45 @@ function noteTrimWireTokens(sessionId, usage) {
|
|
|
177
219
|
}
|
|
178
220
|
}
|
|
179
221
|
|
|
222
|
+
/** Record the host meter's projected prompt size for one session. Read from
|
|
223
|
+
* the agent/status idle boundary (the only place the agent object — and thus
|
|
224
|
+
* agent.session for tokenMeter.measure — is reachable in this layer). The
|
|
225
|
+
* figure is the DSH context panel's number (projectedTokens domain):
|
|
226
|
+
* provider-anchored plus heuristic surface deltas, i.e. what the NEXT prompt
|
|
227
|
+
* would cost — ahead of the wire, which only updates when the next response
|
|
228
|
+
* reports usage. Observability only: it lands in the view-applied row as
|
|
229
|
+
* proj=<n> so view/wire/proj divergence (estimator vs provider vs
|
|
230
|
+
* projection-domain) is one grep away. Fail-open, bounded (200 sessions). */
|
|
231
|
+
function noteTrimProjectedTokens(sessionId, agent, ctx) {
|
|
232
|
+
try {
|
|
233
|
+
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
234
|
+
const measurement = agent?.session ? ctx?.tokenMeter?.measure?.(agent.session) : undefined;
|
|
235
|
+
const total = Number.isFinite(measurement?.totalTokens) ? measurement.totalTokens : null;
|
|
236
|
+
if (total === null) return;
|
|
237
|
+
trimProjectedBySession.set(sessionId, { totalTokens: total, at: Date.now() });
|
|
238
|
+
if (trimProjectedBySession.size > 200) trimProjectedBySession.delete(trimProjectedBySession.keys().next().value);
|
|
239
|
+
} catch {
|
|
240
|
+
/* observability must never throw */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- pressure-snapshot bridge (client badge) -------------------------------
|
|
245
|
+
// The routes layer (host/routes.js) serves GET /trim-pressure for the client
|
|
246
|
+
// dock chip; the live window/pressure math lives in THIS closure. A module
|
|
247
|
+
// bridge mirrors the recentTrim*Of pattern: applyContextTrim publishes a
|
|
248
|
+
// snapshot function once at mount; the route reads it per request. Null
|
|
249
|
+
// publisher (module unloaded) or null window (nothing to measure against)
|
|
250
|
+
// degrades to { available: false } — the chip renders nothing.
|
|
251
|
+
let trimPressureSnapshotFn = null;
|
|
252
|
+
export function trimPressureSnapshotOf(sessionId) {
|
|
253
|
+
try {
|
|
254
|
+
if (typeof trimPressureSnapshotFn !== "function") return { available: false };
|
|
255
|
+
return trimPressureSnapshotFn(sessionId) ?? { available: false };
|
|
256
|
+
} catch {
|
|
257
|
+
return { available: false };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
180
261
|
/** Injection outcome of the last dispatch for one session ("in-place" |
|
|
181
262
|
* "swapped" | "none"), or null when unknown or stale. Read by maybeFold:
|
|
182
263
|
* when the wire cannot carry the view, folding cannot relieve anything and
|
|
@@ -271,9 +352,81 @@ export const TRIM_PROTOCOL_TEXT = [
|
|
|
271
352
|
"- Old message ranges may be replaced by ONE synthetic [SWITCHMAN:TRIM] summary message; treat those summaries as faithful compressions, and message refs inside them as historical anchors.",
|
|
272
353
|
"- NEVER contradict or re-derive content a summary already settled; build on it.",
|
|
273
354
|
"- You may fold a contiguous range yourself with the ctx_trim_fold tool (give firstId/lastId message refs and a summary following the compression rules); ctx_trim_status lists the compressible ranges with their true sizes.",
|
|
355
|
+
"- When run_code is your only directly callable tool (program-transport mode), invoke these from inside a run_code program: await tools.ctx_trim_status({}) to list ranges, await tools.ctx_trim_fold({ firstId, lastId, count, summary }) to fold.",
|
|
274
356
|
"- Tool-call pairs (an assistant tool call and its results) must always be folded together or not at all.",
|
|
275
357
|
].join("\n");
|
|
276
358
|
|
|
359
|
+
// Advisory nudge thresholds: the default speak line (50%) deliberately sits
|
|
360
|
+
// well below the protocol's own 80% fold line so the model is nudged while
|
|
361
|
+
// it still has comfortable headroom; critical wording escalates at 80%.
|
|
362
|
+
// [2026-11-15]-[advisory lines recalibrated 65/95 → 50/80]-[the 2026-11-14
|
|
363
|
+
// turn-25 incident (nudges ignored, 74%→99% in 28 minutes) showed a speak
|
|
364
|
+
// line at 65% arrives too late to matter]. At most one nudge per session
|
|
365
|
+
// per 10 minutes (an instant re-nudge when the level band changes). Both
|
|
366
|
+
// lines are config-tunable via trimAdvisoryPercent / trimAdvisoryCriticalPercent.
|
|
367
|
+
export const ADVISORY_NUDGE_PERCENT = 50;
|
|
368
|
+
export const ADVISORY_NUDGE_CRITICAL_PERCENT = 80;
|
|
369
|
+
/** Repeat cadence of the live tail nudge, per band. The nudge REPLACES
|
|
370
|
+
* itself at the new surface tail (prune old seq, append fresh) — the old
|
|
371
|
+
* 10-minute single repeat let a spoken nudge slide under ~50 messages of
|
|
372
|
+
* new turn output and become background noise (2026-11-15 turn observation:
|
|
373
|
+
* the model folded only after the throttle window expired). Critical
|
|
374
|
+
* escalates faster than high; relief (>= 80%) owns the mechanical path
|
|
375
|
+
* independently of this cadence. Config-tunable via trimAdvisoryRepeatMs,
|
|
376
|
+
* falling back to the defaults when absent or out of range. */
|
|
377
|
+
export const ADVISORY_NUDGE_REPEAT_MS = 120_000;
|
|
378
|
+
export const ADVISORY_NUDGE_REPEAT_CRITICAL_MS = 45_000;
|
|
379
|
+
|
|
380
|
+
/** Effective advisory lines for one dispatch: config-tunable, falling back to
|
|
381
|
+
* the configured defaults (50/80) when absent, inverted, or out of range —
|
|
382
|
+
* a broken pair never silences the nudge. */
|
|
383
|
+
function advisoryNudgeLines(config) {
|
|
384
|
+
const line = readNumber(config, "trimAdvisoryPercent", ADVISORY_NUDGE_PERCENT);
|
|
385
|
+
const critical = readNumber(config, "trimAdvisoryCriticalPercent", ADVISORY_NUDGE_CRITICAL_PERCENT);
|
|
386
|
+
const sane = (v) => Number.isFinite(v) && v > 0 && v <= 100;
|
|
387
|
+
if (!sane(line) || !sane(critical) || line >= critical) return { line: ADVISORY_NUDGE_PERCENT, critical: ADVISORY_NUDGE_CRITICAL_PERCENT };
|
|
388
|
+
return { line, critical };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Effective nudge repeat cadence for one dispatch: config-tunable
|
|
392
|
+
* (trimAdvisoryRepeatMs), falling back to the built-in per-band defaults
|
|
393
|
+
* when absent, negative, or above one hour — a broken value never pins
|
|
394
|
+
* the nudge to the old 10-minute cadence nor spams every dispatch. */
|
|
395
|
+
function advisoryRepeatLines(config) {
|
|
396
|
+
const highMs = readNumber(config, "trimAdvisoryRepeatMs", ADVISORY_NUDGE_REPEAT_MS);
|
|
397
|
+
const saneMs = (v) => Number.isFinite(v) && v >= 5_000 && v <= 3_600_000;
|
|
398
|
+
if (!saneMs(highMs)) return { highMs: ADVISORY_NUDGE_REPEAT_MS, criticalMs: ADVISORY_NUDGE_REPEAT_CRITICAL_MS };
|
|
399
|
+
return { highMs, criticalMs: Math.min(highMs, ADVISORY_NUDGE_REPEAT_CRITICAL_MS) };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Advisory-mode nudge text. Deliberate, bounded exception to the
|
|
403
|
+
* digit-free disclosure policy of TRIM_PROTOCOL_TEXT above: advisory mode
|
|
404
|
+
* never folds on a schedule (the modeNow gate); besides the model's own
|
|
405
|
+
* fold, the only engine-initiated relief is the one-shot critical relief
|
|
406
|
+
* fold (trimCriticalRelief, cooldown-gated). At threshold, the live-number
|
|
407
|
+
* instruction is appended to the session surface tail, not injected into
|
|
408
|
+
* the registered system section. */
|
|
409
|
+
export function advisoryNudgeText(pressurePercent, critical, foldLine = ADVISORY_NUDGE_PERCENT, criticalLine = ADVISORY_NUDGE_CRITICAL_PERCENT, target = null, nth = 0) {
|
|
410
|
+
const action = critical
|
|
411
|
+
? `Pressure is CRITICAL (${pressurePercent}% ≥ ${criticalLine}%): fold down to under ~50% of the context IN THIS TURN.`
|
|
412
|
+
: `At ${pressurePercent}% (fold line ${foldLine}%): ${foldLine}–${foldLine + 9}% → fold roughly half of the older history now; ≥${foldLine + 10}% → fold down to under ~50%.`;
|
|
413
|
+
if (nth >= 2) {
|
|
414
|
+
const targetLine = target !== null ? ` Recommended: ${target}` : " Get ranges with ctx_trim_status."; // eslint-disable-line no-template-curly-in-string
|
|
415
|
+
return [
|
|
416
|
+
`[SWITCHMAN:TRIM] Advisory nudge (repeat #${nth + 1}) — still above the fold line. ${action} Fold FIRST, resume afterwards.${targetLine}`
|
|
417
|
+
].join("\n");
|
|
418
|
+
}
|
|
419
|
+
const actNow = target !== null
|
|
420
|
+
? `Act now: ONE ctx_trim_fold call is enough — fold the recommended range. Recommended: ${target}. (tool-call pairs fold together or not at all; existing summaries stay.) If run_code is your only directly callable tool, run: await tools.ctx_trim_fold({ firstId: "<firstRef>", lastId: "<lastRef>", count: <count>, summary: <your summary> }). Do not wait for a routine moment.`
|
|
421
|
+
: "Act now: call ctx_trim_status to list compressible ranges, pick one contiguous range, and fold it with ctx_trim_fold (tool-call pairs fold together or not at all; existing summaries stay). If run_code is your only directly callable tool, run both inside a program: await tools.ctx_trim_status({}) then await tools.ctx_trim_fold({ firstId, lastId, count, summary }). Do not wait for a routine moment.";
|
|
422
|
+
return [
|
|
423
|
+
'[SWITCHMAN:TRIM] Advisory nudge — pinned to the session tail until pressure falls below the fold line.',
|
|
424
|
+
`Live context pressure on this session has reached the fold line. ${action}`,
|
|
425
|
+
"FOLD FIRST: treat this nudge as a preemption, not background advice. Finish the current tool call if (and only if) aborting it mid-flight would leave broken state; otherwise interrupt the task queue and fold before continuing. Every queued step, plan item and pending subtask waits until pressure is back under the fold line. The only valid excuse to defer is a task that genuinely cannot be resumed after an interruption — and even then, fold at the first safe pause rather than at task end.",
|
|
426
|
+
actNow
|
|
427
|
+
].join("\n");
|
|
428
|
+
}
|
|
429
|
+
|
|
277
430
|
/** Effective window for the pressure/emergency tier math:
|
|
278
431
|
* min(model-resolved window, configured cap). Null only when BOTH inputs
|
|
279
432
|
* are unusable — the trigger then skips those tiers (fail-open). */
|
|
@@ -323,7 +476,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
323
476
|
|
|
324
477
|
// Per-process pipeline state (process-wide by design: ledgers persist on
|
|
325
478
|
// disk per session, metrics/captures live per session in memory).
|
|
326
|
-
const store = createStore({ stateDir: readString(config, "trimStateDir", null) ?? undefined, warn });
|
|
479
|
+
const store = createStore({ stateDir: resolveStateDir(readString(config, "trimStateDir", null), warn) ?? undefined, warn });
|
|
327
480
|
|
|
328
481
|
// Surface-replace pass (docs/surface-fold.md): ships committed fold blocks
|
|
329
482
|
// onto the session surface so folds reach the wire even for frozen
|
|
@@ -359,10 +512,319 @@ export function applyContextTrim(ctx, config) {
|
|
|
359
512
|
return null;
|
|
360
513
|
}
|
|
361
514
|
};
|
|
515
|
+
|
|
516
|
+
// Advisory nudge state: sessionId -> { at, critical, seq }. Bounded like the
|
|
517
|
+
// other per-session registries (200 entries).
|
|
518
|
+
const advisoryNudgeBySession = new Map();
|
|
519
|
+
|
|
520
|
+
// Single-flight for nudge warm-ups: during resolveRawWindow's <=10s window
|
|
521
|
+
// every main dispatch would otherwise re-fire the same resolve. Entries
|
|
522
|
+
// self-delete on settle; the expiry is only a belt-and-suspenders bound.
|
|
523
|
+
const windowWarmupInFlight = new Map(); // "provider/model" -> expiresAt
|
|
524
|
+
function warmWindowOnce(model, now) {
|
|
525
|
+
const key = `${model.provider}/${model.model}`;
|
|
526
|
+
const until = windowWarmupInFlight.get(key);
|
|
527
|
+
if (until !== undefined && now < until) return;
|
|
528
|
+
windowWarmupInFlight.set(key, now + 15_000); // > resolveRawWindow's 10s ceiling
|
|
529
|
+
void windowTokensFor(model).catch(() => {}).finally(() => {
|
|
530
|
+
if (windowWarmupInFlight.get(key) === until) windowWarmupInFlight.delete(key);
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** One-step fold target for the nudge text: the SAME recommendation the
|
|
535
|
+
* ctx_trim_status tool prints (first/last message refs + count of the raw
|
|
536
|
+
* compressible range). null when no capture exists yet or nothing is
|
|
537
|
+
* foldable — the nudge then keeps the two-step status-first wording.
|
|
538
|
+
* Read-only over the live capture: no host access, no throw. */
|
|
539
|
+
function recommendedFoldTargetOf(sessionId) {
|
|
540
|
+
try {
|
|
541
|
+
const capture = captures.get(sessionId);
|
|
542
|
+
if (capture === undefined || !Array.isArray(capture.messages) || capture.messages.length === 0) return null;
|
|
543
|
+
const ledger = store.ledgerOf(sessionId);
|
|
544
|
+
const { protectedTail, protectTaskIntent } = thresholdsNow();
|
|
545
|
+
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail, { protectTaskIntent });
|
|
546
|
+
if (foldable === null) return null;
|
|
547
|
+
const firstRef = refOf(capture.messages[foldable.startIdx], foldable.startIdx);
|
|
548
|
+
const lastRef = refOf(capture.messages[foldable.endIdx], foldable.endIdx);
|
|
549
|
+
return {
|
|
550
|
+
firstId: "m" + firstRef,
|
|
551
|
+
lastId: "m" + lastRef,
|
|
552
|
+
count: foldable.endIdx - foldable.startIdx + 1,
|
|
553
|
+
tokens: estimateTokensOf(capture.messages, foldable.startIdx, foldable.endIdx),
|
|
554
|
+
};
|
|
555
|
+
} catch {
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Advisory-mode pressure nudge (text: advisoryNudgeText). Appends the live
|
|
561
|
+
* instruction to the session surface tail once pressure crosses the fold
|
|
562
|
+
* line. Pressure reuses the meter-duality bridge (wire preferred, view
|
|
563
|
+
* estimate as floor) against the session model's window read from the SYNC
|
|
564
|
+
* cache — a cache miss fires an async warm-up (windowTokensFor) and defers
|
|
565
|
+
* the nudge to a later dispatch instead of blocking this one — and it only
|
|
566
|
+
* reaches the wire on the NEXT dispatch (the current request is already
|
|
567
|
+
* assembled). The speak is a pure suffix, so the prompt prefix stays
|
|
568
|
+
* untouched; a later clear or upgrade prunes only the nudge nodes, so
|
|
569
|
+
* cache reuse survives up to the nudge instead of invalidating the whole
|
|
570
|
+
* prefix as the old system-head splice did. */
|
|
571
|
+
function deliverAdvisoryNudge(sessionId, viewTokens) {
|
|
572
|
+
if (modeNow() !== "advisory") {
|
|
573
|
+
debug(`nudge skip mode=${modeNow()} session=${sessionId}`);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const model = capturedModelBySession.get(sessionId) ?? null;
|
|
577
|
+
if (model === null) {
|
|
578
|
+
decisionLog("advisory-nudge skip why=no-captured-model");
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (typeof model.provider !== "string" || typeof model.model !== "string") {
|
|
582
|
+
decisionLog("advisory-nudge skip why=bad-model-shape");
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const cached = windowCache.get(model.provider + "/" + model.model);
|
|
586
|
+
let resolvedWindow; // model-resolved window, or the configured cap when unknown
|
|
587
|
+
if (cached === undefined) {
|
|
588
|
+
// Unknown: warm up single-flight but stay AUDIBLE — fall back to the
|
|
589
|
+
// configured cap (the same denominator the emergency tier uses), so a
|
|
590
|
+
// model whose metadata never resolves still gets nudged at the fold
|
|
591
|
+
// line instead of burning to force unseen (2026-11-14: window-unknown
|
|
592
|
+
// swallowed every nudge at 89% pressure).
|
|
593
|
+
warmWindowOnce(model, Date.now());
|
|
594
|
+
resolvedWindow = windowCapNow();
|
|
595
|
+
} else {
|
|
596
|
+
// Stale-but-known still gates correctly: effectiveWindowTokens binds to the
|
|
597
|
+
// configured cap for every model larger than it, so the pressure math is
|
|
598
|
+
// unchanged. Re-warm in the background instead of burning this boundary.
|
|
599
|
+
if (Date.now() - cached.at >= 600_000) warmWindowOnce(model, Date.now());
|
|
600
|
+
// Resolution failed inside the TTL: same cap fallback instead of going
|
|
601
|
+
// quiet (no retry hammering — the warm-up stays single-flight).
|
|
602
|
+
resolvedWindow = cached.windowTokens ?? windowCapNow();
|
|
603
|
+
}
|
|
604
|
+
// Bind the resolved window to the configured cap before computing pressure.
|
|
605
|
+
const windowTokens = effectiveWindowTokens(resolvedWindow, windowCapNow());
|
|
606
|
+
if (windowTokens === null) {
|
|
607
|
+
// Cap unavailable too (wmForceTokens=0 and no explicit cap): nothing to
|
|
608
|
+
// measure against — the one remaining legitimate silence.
|
|
609
|
+
decisionLog("advisory-nudge skip why=window-unknown");
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const wire = wireTokensOf(sessionId);
|
|
613
|
+
const mass = Math.max(Number.isFinite(wire) ? wire : 0, Number.isFinite(viewTokens) ? viewTokens : 0);
|
|
614
|
+
if (mass <= 0) {
|
|
615
|
+
debug(`nudge skip empty-mass session=${sessionId}`);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const pressurePercent = (mass / windowTokens) * 100;
|
|
619
|
+
const nudgeLines = advisoryNudgeLines(config);
|
|
620
|
+
const prior = advisoryNudgeBySession.get(sessionId);
|
|
621
|
+
if (pressurePercent < nudgeLines.line) {
|
|
622
|
+
// Below the line every live nudge must go. The sweep is NOT gated
|
|
623
|
+
// on `prior`: restarts and registry evictions lose the episode while
|
|
624
|
+
// the node stays visible. The recorded seq joins the union even when
|
|
625
|
+
// it fell out of the 50-node scan window; nudgePrune skips seqs a
|
|
626
|
+
// fold already consumed, so a vanished prior.seq cannot wedge this.
|
|
627
|
+
const seqs = new Set(surfacePass.nudgeScan(sessionId));
|
|
628
|
+
if (Number.isFinite(prior?.seq)) seqs.add(prior.seq);
|
|
629
|
+
if (seqs.size > 0) {
|
|
630
|
+
if (!surfacePass.nudgePrune(sessionId, [...seqs])) {
|
|
631
|
+
decisionLog("advisory-nudge skip why=prune-failed pressure=" + Math.round(pressurePercent) + "%");
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
advisoryNudgeBySession.delete(sessionId);
|
|
635
|
+
// Log the resolved denominator (cap fallback included), not
|
|
636
|
+
// cached.windowTokens — on a cache miss `cached` is undefined
|
|
637
|
+
// here and this row threw after a successful prune
|
|
638
|
+
// (2026-11-15T02:28:12Z advisory-nudge-failed).
|
|
639
|
+
decisionLog("advisory-nudge below-line pressure=" + Math.round(pressurePercent) + "% mass=" + Math.round(mass) + " resolved=" + resolvedWindow + " cap=" + windowCapNow() + " pruned=" + seqs.size);
|
|
640
|
+
} else if (prior !== undefined) {
|
|
641
|
+
advisoryNudgeBySession.delete(sessionId); // nothing visible left; close the episode quietly
|
|
642
|
+
}
|
|
643
|
+
return; // fresh episode on the next crossing
|
|
644
|
+
}
|
|
645
|
+
const critical = pressurePercent >= nudgeLines.critical;
|
|
646
|
+
// One-step fold target (2026-11-15 advisory rework, item 2): the status
|
|
647
|
+
// tool's exact recommendation inlined into the nudge, so the model can
|
|
648
|
+
// comply with a single ctx_trim_fold call instead of a status round trip.
|
|
649
|
+
// The relief cycle below reuses the same target.
|
|
650
|
+
const foldTarget = recommendedFoldTargetOf(sessionId);
|
|
651
|
+
const targetText = foldTarget === null ? null
|
|
652
|
+
: `[m${foldTarget.firstId}]..[m${foldTarget.lastId}] (${foldTarget.count} messages, ~${Math.round(foldTarget.tokens)} tokens) — pass firstId="${foldTarget.firstId}", lastId="${foldTarget.lastId}", count=${foldTarget.count}`;
|
|
653
|
+
// Three-band advisory ladder (2026-11-15 rework, item 3): high = at the
|
|
654
|
+
// speak line (re-pin every trimAdvisoryRepeatMs); escal = ten points
|
|
655
|
+
// above it (re-pin EVERY dispatch — a reminder that keeps sliding under
|
|
656
|
+
// the turn tail must sit at the tail); critical = the escalation line
|
|
657
|
+
// (fastest re-pin + the mechanical relief path below).
|
|
658
|
+
const escal = !critical && pressurePercent >= nudgeLines.line + 10;
|
|
659
|
+
const band = critical ? "critical" : escal ? "escal" : "high";
|
|
660
|
+
// Relief extension (item 4): critical is not the only engine fold
|
|
661
|
+
// trigger — three consecutive same-band speaks with no fold commit
|
|
662
|
+
// (prior.repeats >= 3) put an escal-band session on the SAME mechanical
|
|
663
|
+
// relief path, cooldown-gated like critical. Nudge-only purity survives
|
|
664
|
+
// for genuinely attended sessions: repeats < 3 never folds.
|
|
665
|
+
const reliefDue = critical || (escal && (prior?.repeats ?? 0) >= 3);
|
|
666
|
+
const now = Date.now();
|
|
667
|
+
// Critical relief ([2026-11-15]-[advisory critical relief fold]-[nudges
|
|
668
|
+
// alone let a session burn from the speak line to host compaction —
|
|
669
|
+
// turn-25: nudges ignored, 74%→99% in 28 minutes]). At the critical line
|
|
670
|
+
// the engine performs ONE mechanical (emergency-style) fold itself,
|
|
671
|
+
// cooldown-gated like the emergency tier, instead of only nudging.
|
|
672
|
+
// Runs ahead of the same-band nudge throttle: the relief cadence is owned
|
|
673
|
+
// by reliefBySession + inFlight alone. `trimCriticalRelief: false`
|
|
674
|
+
// restores nudge-only advisory mode.
|
|
675
|
+
if (reliefDue && reliefEnabledNow() && !inFlight.has(sessionId) && captures.has(sessionId)
|
|
676
|
+
&& now - (reliefBySession.get(sessionId) ?? 0) >= EMERGENCY_COOLDOWN_MS) {
|
|
677
|
+
// Cooldown armed BEFORE the cycle (same pre-set semantics as the
|
|
678
|
+
// emergency tier): every attempt, folded or rejected, cools down.
|
|
679
|
+
reliefBySession.set(sessionId, now);
|
|
680
|
+
if (reliefBySession.size > 200) reliefBySession.delete(reliefBySession.keys().next().value);
|
|
681
|
+
const reliefCapture = captures.get(sessionId);
|
|
682
|
+
const cycleToken = {};
|
|
683
|
+
inFlight.set(sessionId, cycleToken);
|
|
684
|
+
void (async () => {
|
|
685
|
+
try {
|
|
686
|
+
if (disposedSince(now)) {
|
|
687
|
+
decisionLog(`session=${sessionId} advisory-relief outcome=reload-aborted tier=emergency pressure=${Math.round(pressurePercent)}% trigger=${band}`);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const { protectedTail, protectTaskIntent, ...reliefThresholds } = thresholdsNow();
|
|
691
|
+
const ledger = store.ledgerOf(sessionId);
|
|
692
|
+
const foldable = computeFoldableRange(reliefCapture.messages, ledger, protectedTail, { protectTaskIntent });
|
|
693
|
+
const decision = evaluateTrigger({
|
|
694
|
+
foldable,
|
|
695
|
+
messages: reliefCapture.messages,
|
|
696
|
+
viewTokens: mass, // same basis as the nudge line: max(view, wire)
|
|
697
|
+
windowTokens,
|
|
698
|
+
thresholds: { ...reliefThresholds, emergencyPercent: 1 }, // force the mechanical tier through
|
|
699
|
+
now,
|
|
700
|
+
lastFoldAt: ledger.lastFoldAt,
|
|
701
|
+
foldsThisHour: ledger.foldsThisHour,
|
|
702
|
+
lastEmergencyAt: 0, // reliefBySession owns the relief cadence
|
|
703
|
+
ledger,
|
|
704
|
+
ladderRuns: { t2: [], t3: [] }, // relief is mechanical-only; no ladder cut-in
|
|
705
|
+
});
|
|
706
|
+
if (decision.action !== "emergency") {
|
|
707
|
+
decisionLog(`session=${sessionId} advisory-relief outcome=none reason=${decision.reason || "-"} pressure=${Math.round(pressurePercent)}% trigger=${band}`);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const outcome = await runFoldCycle(sessionId, decision, now, cycleToken);
|
|
711
|
+
decisionLog(`session=${sessionId} advisory-relief outcome=${outcome} tier=emergency pressure=${Math.round(pressurePercent)}% trigger=${band}`);
|
|
712
|
+
if (outcome.startsWith("folded:") || outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:")) {
|
|
713
|
+
warn(`[context-trim] ${sessionId}: advisory relief ${outcome} at ${Math.round(pressurePercent)}%`);
|
|
714
|
+
}
|
|
715
|
+
} catch (error) {
|
|
716
|
+
decisionLog(`session=${sessionId} advisory-relief outcome=error why=${error?.message ?? error}`);
|
|
717
|
+
warn(`[context-trim] ${sessionId}: advisory relief failed: ${error?.message ?? error}`);
|
|
718
|
+
} finally {
|
|
719
|
+
// Only the cycle owning the CURRENT token may release the lock.
|
|
720
|
+
if (inFlight.get(sessionId) === cycleToken) inFlight.delete(sessionId);
|
|
721
|
+
}
|
|
722
|
+
})();
|
|
723
|
+
} else if (reliefDue) {
|
|
724
|
+
debug(`relief gate held pressure=${Math.round(pressurePercent)}% session=${sessionId}`);
|
|
725
|
+
// Audible silence ([2026-11-15]-[relief skip diagnostics]): the turn-25
|
|
726
|
+
// incident hid here — a session sat at 81-125% critical while the gate
|
|
727
|
+
// held, and the only trace was a two-hour view-applied reconciliation.
|
|
728
|
+
// One throttled row per minute names the exact gate that held the
|
|
729
|
+
// relief back (cooldown / cycle in flight / no capture / disabled).
|
|
730
|
+
if (now - (reliefSkipLogAt.get(sessionId) ?? 0) >= 60_000) {
|
|
731
|
+
reliefSkipLogAt.set(sessionId, now);
|
|
732
|
+
if (reliefSkipLogAt.size > 200) reliefSkipLogAt.delete(reliefSkipLogAt.keys().next().value);
|
|
733
|
+
debug(`relief gate detail enabled=${reliefEnabledNow()} cycle=${inFlight.has(sessionId)} capture=${captures.has(sessionId)} sinceLast=${now - (reliefBySession.get(sessionId) ?? 0)}ms session=${sessionId}`);
|
|
734
|
+
const why = !reliefEnabledNow() ? "relief-disabled"
|
|
735
|
+
: inFlight.has(sessionId) ? "cycle-in-flight"
|
|
736
|
+
: !captures.has(sessionId) ? "no-capture"
|
|
737
|
+
: "cooldown";
|
|
738
|
+
decisionLog(`session=${sessionId} advisory-relief skip why=${why} pressure=${Math.round(pressurePercent)}%`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// Any band CHANGE re-speaks immediately: escalation high->critical, and
|
|
742
|
+
// intentionally also a downgrade back into high after a partial fold.
|
|
743
|
+
// [2026-11-15]-[per-band tail re-pin]-[a spoken nudge slides under the
|
|
744
|
+
// growing turn tail and becomes background noise; the single 10-minute
|
|
745
|
+
// repeat let 2.5 minutes of throttled silence precede the only fold
|
|
746
|
+
// (decisions.log 10:19→10:22). The nudge now REPLACES itself at the new
|
|
747
|
+
// tail every trimAdvisoryRepeatMs (high band) / min(that, 45 s)
|
|
748
|
+
// (critical): prune old seq, append fresh. Counts same-band repeats for
|
|
749
|
+
// the terse repeat wording.]
|
|
750
|
+
const repeatLines = advisoryRepeatLines(config);
|
|
751
|
+
const repeatMs = band === "high" ? repeatLines.highMs
|
|
752
|
+
: band === "escal" ? 0 // every dispatch: the reminder must own the tail
|
|
753
|
+
: Math.min(repeatLines.highMs, repeatLines.criticalMs);
|
|
754
|
+
if (prior !== undefined && prior.band === band && now - prior.at < repeatMs) {
|
|
755
|
+
debug(`nudge throttle band=${band} age=${now - prior.at}ms repeats=${prior.repeats ?? 0} session=${sessionId}`);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
if (!surfacePass.nudgeSupported(sessionId)) {
|
|
759
|
+
decisionLog("advisory-nudge skip why=surface-unavailable pressure=" + Math.round(pressurePercent) + "%");
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Replace the recorded prior nudge, or clear orphans a restart left
|
|
764
|
+
// behind, before speaking fresh. nudgePrune only touches seqs still
|
|
765
|
+
// live on the surface, so a prior a fold already consumed is a no-op.
|
|
766
|
+
const priorSeq = Number.isFinite(prior?.seq) ? prior.seq : null;
|
|
767
|
+
if (priorSeq !== null) {
|
|
768
|
+
if (!surfacePass.nudgePrune(sessionId, [priorSeq])) {
|
|
769
|
+
decisionLog("advisory-nudge skip why=prune-failed pressure=" + Math.round(pressurePercent) + "%");
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
} else {
|
|
773
|
+
const orphans = surfacePass.nudgeScan(sessionId);
|
|
774
|
+
if (orphans.length > 0) {
|
|
775
|
+
if (!surfacePass.nudgePrune(sessionId, orphans)) {
|
|
776
|
+
decisionLog("advisory-nudge skip why=prune-failed pressure=" + Math.round(pressurePercent) + "%");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
decisionLog("advisory-nudge orphan-pruned seqs=" + orphans.length + " pressure=" + Math.round(pressurePercent) + "%");
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
// One-step targeting for the model (2026-11-15 advisory rework): the nudge
|
|
783
|
+
// carries the status tool's exact recommendation, so compliance is a
|
|
784
|
+
// single ctx_trim_fold call; without a foldable range it stays two-step.
|
|
785
|
+
const nextRepeats = prior !== undefined && prior.band === band ? (prior.repeats ?? 0) + 1 : 1;
|
|
786
|
+
const nudge = advisoryNudgeText(
|
|
787
|
+
Math.round(pressurePercent), critical, nudgeLines.line, nudgeLines.critical,
|
|
788
|
+
targetText,
|
|
789
|
+
nextRepeats - 1,
|
|
790
|
+
);
|
|
791
|
+
const appended = surfacePass.nudgeAppend(sessionId, nudge);
|
|
792
|
+
if (appended.error !== null || !Number.isFinite(appended.seq)) {
|
|
793
|
+
advisoryNudgeBySession.delete(sessionId); // a pruned prior must not throttle the retry
|
|
794
|
+
decisionLog("advisory-nudge skip why=append-failed err=" + (appended.error ?? "unknown") + " pressure=" + Math.round(pressurePercent) + "%");
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const seq = appended.seq;
|
|
798
|
+
advisoryNudgeBySession.delete(sessionId); // re-insert: refresh recency so eviction drops the least recently nudged
|
|
799
|
+
advisoryNudgeBySession.set(sessionId, { at: now, band, seq, repeats: nextRepeats });
|
|
800
|
+
debug(`nudge prepared seq=${seq} level=${band} pressure=${Math.round(pressurePercent)}% session=${sessionId}`);
|
|
801
|
+
if (advisoryNudgeBySession.size > 200) advisoryNudgeBySession.delete(advisoryNudgeBySession.keys().next().value);
|
|
802
|
+
decisionLog("session=" + sessionId + " advisory-nudge pressure=" + Math.round(pressurePercent) + "% mass=" + Math.round(mass) + " window=" + windowTokens + " level=" + band + " repeats=" + nextRepeats + " seq=" + seq);
|
|
803
|
+
}
|
|
804
|
+
|
|
362
805
|
const captures = new Map(); // sessionId -> { messages, refs, viewTokens, model, at }
|
|
363
|
-
|
|
806
|
+
// sessionId -> generation token of the running fold cycle. A plain Set
|
|
807
|
+
// pinned a session forever once a cycle's await never settled; the
|
|
808
|
+
// maybeFold watchdog swaps the token so wedged cycles release the lock.
|
|
809
|
+
const inFlight = new Map();
|
|
810
|
+
// Fold-cycle watchdog: a cycle still "in flight" after this long logs a
|
|
811
|
+
// cycle-stuck row, releases the lock, and lets the next trigger retry.
|
|
812
|
+
// The ceiling must exceed the worst LEGITIMATE cycle — a raw fold can
|
|
813
|
+
// summarize on side AND fallback routes (2 x SUMMARY_TIMEOUT_MS = 240s) —
|
|
814
|
+
// so a healthy slow cycle is never preempted.
|
|
815
|
+
const CYCLE_WATCHDOG_MS = 300_000;
|
|
816
|
+
// Hard ceiling for one host model-info lookup: some llm builds never
|
|
817
|
+
// settle resolveModelInfo for unresolvable routes, and that wedged
|
|
818
|
+
// await used to pin the whole fold cycle until restart.
|
|
819
|
+
const WINDOW_RESOLVE_TIMEOUT_MS = 10_000;
|
|
364
820
|
const windowCache = new Map(); // "provider/model" -> { windowTokens, at }
|
|
365
821
|
const lastEmergencyAtBySession = new Map(); // sessionId -> epoch ms of last emergency fold (per-session cooldown)
|
|
822
|
+
// Advisory critical relief cooldown: sessionId -> epoch ms of the last
|
|
823
|
+
// relief fold. Set BEFORE the cycle (emergency semantics: a rejected
|
|
824
|
+
// commit cools the relief like a success, or a failing range retries at
|
|
825
|
+
// every dispatch forever). Bounded 200 like the sibling registries.
|
|
826
|
+
const reliefBySession = new Map();
|
|
827
|
+
const reliefSkipLogAt = new Map(); // sessionId -> epoch ms of the last relief-skip diagnostic row (60 s throttle, bounded 200)
|
|
366
828
|
const lastStreamCheckAt = new Map(); // sessionId -> epoch ms of last stream-path fold check (throttle)
|
|
367
829
|
const epochResetDoneBySession = new Map(); // sessionId -> ledger-stale baseline reset already done for this host epoch (cleared when a fold applies again)
|
|
368
830
|
|
|
@@ -412,6 +874,34 @@ export function applyContextTrim(ctx, config) {
|
|
|
412
874
|
}
|
|
413
875
|
}
|
|
414
876
|
|
|
877
|
+
/** Debug-trace verbosity gate ([2026-11-15]-[verbose debug log]): the
|
|
878
|
+
* turn-25 and window-unknown incidents both hid inside gates that
|
|
879
|
+
* outcomes-only logging never names. With trimVerboseLog on, every
|
|
880
|
+
* step/gate/decision path writes a debug row; off keeps key rows only. */
|
|
881
|
+
function verboseNow() {
|
|
882
|
+
try {
|
|
883
|
+
return readBool(config, "trimVerboseLog", false) === true;
|
|
884
|
+
} catch {
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
/** Verbose-only row: one line naming a step/gate/decision path. */
|
|
889
|
+
function debug(entry) {
|
|
890
|
+
if (!verboseNow()) return;
|
|
891
|
+
decisionLog("debug " + entry);
|
|
892
|
+
}
|
|
893
|
+
/** ALWAYS-ON exception detail ([2026-11-15]-[verbose debug log]):
|
|
894
|
+
* independent of trimVerboseLog — message + top stack frames, so a
|
|
895
|
+
* swallowed catch is reconstructable without reproducing it. */
|
|
896
|
+
function errorLog(where, error) {
|
|
897
|
+
try {
|
|
898
|
+
const stack = typeof error?.stack === "string" ? error.stack.split("\n").slice(0, 5).join(" | ") : "no-stack";
|
|
899
|
+
decisionLog(`error where=${where} msg=${error?.message ?? error} stack=${stack}`);
|
|
900
|
+
} catch {
|
|
901
|
+
/* observability must never throw */
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
415
905
|
// ---- fork-state inheritance (copy-on-fork; design in fork.js header) ------
|
|
416
906
|
const linksCache = { mtime: -1, map: null }; // mtime-gated fork-links map
|
|
417
907
|
const inheritFirstSeen = new Map(); // sessionId -> epoch ms of first eligibility check
|
|
@@ -494,17 +984,23 @@ export function applyContextTrim(ctx, config) {
|
|
|
494
984
|
const mode = readString(config, "trimMode", "auto");
|
|
495
985
|
return mode === "off" || mode === "advisory" || mode === "auto" ? mode : "auto";
|
|
496
986
|
};
|
|
987
|
+
const reliefEnabledNow = () => readBool(config, "trimCriticalRelief", true);
|
|
497
988
|
const thresholdsNow = () => ({
|
|
498
989
|
growthTokens: readNumber(config, "trimGrowthTokens", 22_500),
|
|
499
|
-
incrementTokens: readNumber(config, "trimIncrementTokens",
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
990
|
+
incrementTokens: readNumber(config, "trimIncrementTokens", 45_000),
|
|
991
|
+
// Count-typed fields integerize here (Math.round): the schema allows
|
|
992
|
+
// decimals (z.number()), but trigger.js gates them with Number.isInteger
|
|
993
|
+
// — a non-integer config value would otherwise fall back to the
|
|
994
|
+
// hard-coded default silently (convention mismatch, F6).
|
|
995
|
+
pressurePercent: readNumber(config, "trimPressurePercent", 45),
|
|
996
|
+
emergencyPercent: readNumber(config, "trimEmergencyPercent", 90),
|
|
997
|
+
protectedTail: Math.max(0, Math.round(readNumber(config, "trimProtectedTail", 6))),
|
|
998
|
+
maxFoldsPerHour: Math.max(1, Math.round(readNumber(config, "trimMaxFoldsPerHour", 30))),
|
|
504
999
|
ladderT2Tokens: readNumber(config, "trimLadderT2Tokens", 8_000),
|
|
505
|
-
ladderT2MinBlocks: readNumber(config, "trimLadderT2MinBlocks", 3),
|
|
1000
|
+
ladderT2MinBlocks: Math.max(2, Math.round(readNumber(config, "trimLadderT2MinBlocks", 3))),
|
|
506
1001
|
ladderT3Tokens: readNumber(config, "trimLadderT3Tokens", 6_000),
|
|
507
|
-
ladderT3MinBlocks: readNumber(config, "trimLadderT3MinBlocks", 2),
|
|
1002
|
+
ladderT3MinBlocks: Math.max(2, Math.round(readNumber(config, "trimLadderT3MinBlocks", 2))),
|
|
1003
|
+
protectTaskIntent: readBool(config, "trimProtectTaskIntent", true),
|
|
508
1004
|
});
|
|
509
1005
|
const ladderEnabledNow = () => readBool(config, "trimLadderEnabled", true);
|
|
510
1006
|
/** CJK-aware summary-mass estimator for the ladder census (same units
|
|
@@ -546,10 +1042,69 @@ export function applyContextTrim(ctx, config) {
|
|
|
546
1042
|
const windowCapNow = () => {
|
|
547
1043
|
const explicit = readNumber(config, "trimWindowTokens", 0);
|
|
548
1044
|
if (explicit > 0) return explicit;
|
|
549
|
-
const force = readNumber(config, "wmForceTokens",
|
|
1045
|
+
const force = readNumber(config, "wmForceTokens", 200_000);
|
|
550
1046
|
return force > 0 ? force : null;
|
|
551
1047
|
};
|
|
552
1048
|
|
|
1049
|
+
// Publish the client-badge snapshot bridge (module-level trimPressureSnapshotOf
|
|
1050
|
+
// reads this). Every field is recomputed per request from live config + the
|
|
1051
|
+
// session's captured model/window/wire — same math as the nudge line, no new
|
|
1052
|
+
// state. viewTokens arrives from the recent-view bridge (10-min TTL); proj
|
|
1053
|
+
// from the meter-projection registry. Failure of any component degrades that
|
|
1054
|
+
// field to null; the chip hides on available=false or window=null.
|
|
1055
|
+
trimPressureSnapshotFn = (sessionId) => {
|
|
1056
|
+
try {
|
|
1057
|
+
if (!enabledNow() || typeof sessionId !== "string" || sessionId === "") return { available: false };
|
|
1058
|
+
const model = recentTrimModelOf(sessionId, TRIM_VIEW_MAX_AGE_MS);
|
|
1059
|
+
let resolvedWindow = null;
|
|
1060
|
+
if (model !== null) {
|
|
1061
|
+
const cached = windowCache.get(model.provider + "/" + model.model);
|
|
1062
|
+
if (cached !== undefined) resolvedWindow = cached.windowTokens ?? null;
|
|
1063
|
+
}
|
|
1064
|
+
const cap = windowCapNow();
|
|
1065
|
+
const windowTokens = resolvedWindow !== null ? effectiveWindowTokens(resolvedWindow, cap) : cap;
|
|
1066
|
+
if (windowTokens === null || windowTokens <= 0) return { available: false };
|
|
1067
|
+
const viewTokens = recentTrimViewTokensOf(sessionId, TRIM_VIEW_MAX_AGE_MS);
|
|
1068
|
+
const wire = wireTokensOf(sessionId);
|
|
1069
|
+
const mass = Math.max(Number.isFinite(wire) ? wire : 0, Number.isFinite(viewTokens) ? viewTokens : 0);
|
|
1070
|
+
const projectedEntry = trimProjectedBySession.get(sessionId);
|
|
1071
|
+
const proj = projectedEntry !== undefined && Date.now() - projectedEntry.at <= TRIM_VIEW_MAX_AGE_MS ? Math.round(projectedEntry.totalTokens) : null;
|
|
1072
|
+
const lines = advisoryNudgeLines(config);
|
|
1073
|
+
const pressure = mass > 0 ? (mass / windowTokens) * 100 : 0;
|
|
1074
|
+
// Cooldown story must match the GATE, not just the timestamp
|
|
1075
|
+
// ([2026-11-15]-[relief snapshot coherence]): the gate is
|
|
1076
|
+
// enabled + advisory + no cycle in flight + cooldown elapsed;
|
|
1077
|
+
// reporting only the arithmetic let the chip claim "ready"
|
|
1078
|
+
// while the engine would still skip (cycle in flight).
|
|
1079
|
+
const reliefAt = reliefBySession.get(sessionId) ?? 0;
|
|
1080
|
+
const reliefCooldownRemaining = reliefEnabledNow() && modeNow() === "advisory"
|
|
1081
|
+
? Math.max(0, EMERGENCY_COOLDOWN_MS - (Date.now() - reliefAt))
|
|
1082
|
+
: null;
|
|
1083
|
+
const reliefReady = reliefCooldownRemaining !== null
|
|
1084
|
+
&& reliefCooldownRemaining === 0
|
|
1085
|
+
&& !inFlight.has(sessionId)
|
|
1086
|
+
&& captures.has(sessionId);
|
|
1087
|
+
|
|
1088
|
+
return {
|
|
1089
|
+
available: true,
|
|
1090
|
+
mode: modeNow(),
|
|
1091
|
+
mass: Math.round(mass),
|
|
1092
|
+
window: windowTokens,
|
|
1093
|
+
pressure: Math.round(pressure),
|
|
1094
|
+
view: viewTokens !== null ? Math.round(viewTokens) : null,
|
|
1095
|
+
wire: Number.isFinite(wire) ? Math.round(wire) : null,
|
|
1096
|
+
proj,
|
|
1097
|
+
advisoryLine: lines.line,
|
|
1098
|
+
criticalLine: lines.critical,
|
|
1099
|
+
reliefEnabled: reliefEnabledNow(),
|
|
1100
|
+
reliefCooldownRemaining,
|
|
1101
|
+
reliefReady,
|
|
1102
|
+
};
|
|
1103
|
+
} catch {
|
|
1104
|
+
return { available: false };
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
|
|
553
1108
|
/** Session id for a stream call: options.sessionId, else the current
|
|
554
1109
|
* initiator's session (try/catch — host internals shift). */
|
|
555
1110
|
function sessionIdOf(options) {
|
|
@@ -573,20 +1128,41 @@ export function applyContextTrim(ctx, config) {
|
|
|
573
1128
|
return effectiveWindowTokens(resolved, windowCapNow());
|
|
574
1129
|
}
|
|
575
1130
|
|
|
576
|
-
/** Async raw-window resolution (fills the cache; null when unknown).
|
|
1131
|
+
/** Async raw-window resolution (fills the cache; null when unknown).
|
|
1132
|
+
* Two fixes over the historical behavior: the host contract is
|
|
1133
|
+
* resolveModelInfo(provider, model) — two strings; passing the model
|
|
1134
|
+
* object threw NO_ADAPTER on every route, so every decision row ran on
|
|
1135
|
+
* the cap fallback. And the lookup is raced against a hard timeout:
|
|
1136
|
+
* a never-settling promise must not pin the fold cycle. */
|
|
577
1137
|
async function resolveRawWindow(model, key, now) {
|
|
1138
|
+
let timer = null;
|
|
578
1139
|
try {
|
|
579
1140
|
const llm = ctx.get("llm", false);
|
|
580
|
-
const
|
|
1141
|
+
const lookup = Promise.resolve(llm?.resolveModelInfo?.(model.provider, model.model));
|
|
1142
|
+
const info = await Promise.race([
|
|
1143
|
+
lookup,
|
|
1144
|
+
new Promise((_, reject) => {
|
|
1145
|
+
timer = setTimeout(() => reject(new Error(`no settle within ${WINDOW_RESOLVE_TIMEOUT_MS}ms`)), WINDOW_RESOLVE_TIMEOUT_MS);
|
|
1146
|
+
timer.unref?.();
|
|
1147
|
+
}),
|
|
1148
|
+
]);
|
|
581
1149
|
const windowTokens = info?.context?.contextWindow;
|
|
582
1150
|
const value = Number.isFinite(windowTokens) && windowTokens > 0 ? windowTokens : null;
|
|
1151
|
+
if (value === null) decisionLog(`window-resolve-failed key=${key} why=no context metadata`);
|
|
583
1152
|
windowCache.set(key, { windowTokens: value, at: now });
|
|
584
1153
|
if (windowCache.size > 200) windowCache.delete(windowCache.keys().next().value);
|
|
1154
|
+
if (value !== null) decisionLog(`window-resolved key=${key} window=${value}`);
|
|
585
1155
|
return value;
|
|
586
|
-
} catch {
|
|
1156
|
+
} catch (error) {
|
|
587
1157
|
windowCache.set(key, { windowTokens: null, at: now });
|
|
588
1158
|
if (windowCache.size > 200) windowCache.delete(windowCache.keys().next().value);
|
|
1159
|
+
// One row per model per 10-min cache window (plus rare concurrent
|
|
1160
|
+
// misses inside the 10s race window): a silently failing resolution
|
|
1161
|
+
// is what hid the wrong-arity call for months.
|
|
1162
|
+
decisionLog(`window-resolve-failed key=${key} why=${error?.message ?? error}`);
|
|
589
1163
|
return null;
|
|
1164
|
+
} finally {
|
|
1165
|
+
if (timer !== null) clearTimeout(timer);
|
|
590
1166
|
}
|
|
591
1167
|
}
|
|
592
1168
|
|
|
@@ -595,7 +1171,8 @@ export function applyContextTrim(ctx, config) {
|
|
|
595
1171
|
function captureOf(sessionId, messages, model, viewTokens) {
|
|
596
1172
|
captures.set(sessionId, { messages: messages.slice(), model, viewTokens, at: Date.now() });
|
|
597
1173
|
if (model !== null && typeof model === "object" && typeof model.provider === "string" && typeof model.model === "string") {
|
|
598
|
-
|
|
1174
|
+
capturedModelBySession.set(sessionId, { provider: model.provider, model: model.model, at: Date.now() });
|
|
1175
|
+
if (!windowCache.has(model.provider + "/" + model.model)) warmWindowOnce(model, Date.now());
|
|
599
1176
|
if (capturedModelBySession.size > 200) capturedModelBySession.delete(capturedModelBySession.keys().next().value);
|
|
600
1177
|
}
|
|
601
1178
|
// keep the capture map bounded (200 sessions)
|
|
@@ -636,10 +1213,16 @@ export function applyContextTrim(ctx, config) {
|
|
|
636
1213
|
// previous response's usage) makes the view-vs-wire divergence visible
|
|
637
1214
|
// instead of leaving the view estimate as the only lullaby in the log.
|
|
638
1215
|
const wireTokens = wireTokensOf(sessionId);
|
|
1216
|
+
// Projection truth in the row: the host meter's projected NEXT-prompt
|
|
1217
|
+
// size (context-panel domain) sampled at the last idle boundary. Diverges
|
|
1218
|
+
// from wire by design — wire is the last PAID prompt, proj estimates the
|
|
1219
|
+
// next one — so the two columns bracket the real pressure band.
|
|
1220
|
+
const projectedEntry = trimProjectedBySession.get(sessionId);
|
|
1221
|
+
const projectedTokens = projectedEntry !== undefined && Date.now() - projectedEntry.at <= TRIM_VIEW_MAX_AGE_MS ? Math.round(projectedEntry.totalTokens) : null;
|
|
639
1222
|
// Per-request application evidence: whether the ledger's blocks actually
|
|
640
1223
|
// locate inside THIS re-send (the host may compact/replace history, in
|
|
641
1224
|
// which case applied=0 explains a stuck-high view without any failure).
|
|
642
|
-
decisionLog(`session=${sessionId} view-applied applied=${view.applied} foldedMsgs=${view.foldedMessages} view=${Math.round(viewTokens)} blocks=${blocksCount} injected=${injected}${wireTokens === null ? "" : ` wire=${wireTokens}`}`);
|
|
1225
|
+
decisionLog(`session=${sessionId} view-applied applied=${view.applied} foldedMsgs=${view.foldedMessages} view=${Math.round(viewTokens)} blocks=${blocksCount} injected=${injected}${wireTokens === null ? "" : ` wire=${wireTokens}`}${projectedTokens === null ? "" : ` proj=${projectedTokens}`}${MODULE_BUILD_STAMP > 0 ? ` b=${MODULE_BUILD_STAMP}` : ""}`);
|
|
643
1226
|
// Side-dispatch guard: auxiliary llm calls that inherit the session id
|
|
644
1227
|
// (web-search summaries, title generation, fold-span material) present
|
|
645
1228
|
// tiny arrays in which the ledger cannot locate. Accepting them as the
|
|
@@ -744,9 +1327,12 @@ export function applyContextTrim(ctx, config) {
|
|
|
744
1327
|
|
|
745
1328
|
/** Run one fold cycle for a session (called at the idle boundary ONLY).
|
|
746
1329
|
* Returns a short outcome string for logging. */
|
|
747
|
-
async function runFoldCycle(sessionId, decision, cycleStartedAt) {
|
|
1330
|
+
async function runFoldCycle(sessionId, decision, cycleStartedAt, cycleToken) {
|
|
748
1331
|
const capture = captures.get(sessionId);
|
|
749
|
-
if (capture === undefined)
|
|
1332
|
+
if (capture === undefined) {
|
|
1333
|
+
debug(`cycle step no-capture session=${sessionId}`);
|
|
1334
|
+
return "no-capture";
|
|
1335
|
+
}
|
|
750
1336
|
const { protectedTail } = thresholdsNow();
|
|
751
1337
|
// Ladder cycle (docs/ladder-fold.md): distill the decision's consumed
|
|
752
1338
|
// run of block summaries into ONE replacement. Never mechanical — a
|
|
@@ -770,6 +1356,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
770
1356
|
// Ladder folds never degrade to mechanical (docs/ladder-fold.md):
|
|
771
1357
|
// after both routes fail the cycle stays fail-open with backoff.
|
|
772
1358
|
if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
|
|
1359
|
+
if (inFlight.get(sessionId) !== cycleToken) return "superseded"; // watchdog released us; a retry owns the session
|
|
773
1360
|
const result = store.commitFold(
|
|
774
1361
|
sessionId,
|
|
775
1362
|
{ consumes: decision.consumes.slice(), summary, kind: "summary", tier: decision.tier === "ladder-3" ? 3 : 2, approxTokens },
|
|
@@ -790,6 +1377,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
790
1377
|
const refs = slice.map((message, i) => refOf(message, startIdx + i));
|
|
791
1378
|
const count = slice.length;
|
|
792
1379
|
const approxTokens = estimateTokensOf(slice);
|
|
1380
|
+
debug(`cycle step begin kind=${decision.kind} tier=${decision.tier ?? "-"} range=${startIdx}..${endIdx} session=${sessionId}`);
|
|
793
1381
|
let summary = null;
|
|
794
1382
|
let kind = "summary";
|
|
795
1383
|
let degraded = false; // set when a PRESSURE fold falls back to mechanical
|
|
@@ -812,6 +1400,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
812
1400
|
onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
|
|
813
1401
|
onNotice: (why) => decisionLog(`session=${sessionId} summary-retry route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
|
|
814
1402
|
});
|
|
1403
|
+
debug(`cycle step summarize route=${route ? `${route.provider}/${route.model}` : "none"} fallback=${fallback ? `${fallback.provider}/${fallback.model}` : "none"} msgs=${count} ~${approxTokens}t session=${sessionId}`);
|
|
815
1404
|
summary = route !== null ? await summarize(route, "side") : null;
|
|
816
1405
|
if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
|
|
817
1406
|
// Last resort for PRESSURE folds only: relief beats fidelity when
|
|
@@ -828,6 +1417,8 @@ export function applyContextTrim(ctx, config) {
|
|
|
828
1417
|
if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
|
|
829
1418
|
}
|
|
830
1419
|
const positions = refPositions(capture.messages);
|
|
1420
|
+
if (inFlight.get(sessionId) !== cycleToken) return "superseded"; // watchdog released us; a retry owns the session
|
|
1421
|
+
debug(`cycle step commit kind=${kind} tier=${decision.tier} msgs=${count} ~${approxTokens}t session=${sessionId}`);
|
|
831
1422
|
const result = store.commitFold(
|
|
832
1423
|
sessionId,
|
|
833
1424
|
{ refRange: { firstId: refs[0], lastId: refs[refs.length - 1] }, count, approxTokens, summary, kind, tier: decision.tier },
|
|
@@ -852,15 +1443,28 @@ export function applyContextTrim(ctx, config) {
|
|
|
852
1443
|
async function maybeFold(agent, via = "idle") {
|
|
853
1444
|
const sessionId = agent;
|
|
854
1445
|
if (sessionId === null || sessionId === undefined || sessionId === "") return;
|
|
855
|
-
if (!enabledNow() || modeNow() !== "auto")
|
|
856
|
-
|
|
1446
|
+
if (!enabledNow() || modeNow() !== "auto") {
|
|
1447
|
+
debug(`session=${sessionId} via=${via} skip enabled=${enabledNow()} mode=${modeNow()}`);
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
if (inFlight.has(sessionId)) {
|
|
1451
|
+
debug(`session=${sessionId} via=${via} skip cycle-in-flight`);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
857
1454
|
// Failure backoff: a session whose recent cycles failed stays quiet
|
|
858
1455
|
// for 60s*2^(n-1) (cap 15 min) instead of re-firing at every idle
|
|
859
1456
|
// boundary and stream check. Silent gate — the outcome row that armed
|
|
860
1457
|
// the cooldown is already in decisions.log.
|
|
861
|
-
|
|
1458
|
+
const failureCooldown = foldFailureCooldownOf(sessionId);
|
|
1459
|
+
if (failureCooldown > 0) {
|
|
1460
|
+
debug(`session=${sessionId} via=${via} skip failure-backoff remaining=${failureCooldown}ms`);
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
862
1463
|
const capture = captures.get(sessionId);
|
|
863
|
-
if (capture === undefined)
|
|
1464
|
+
if (capture === undefined) {
|
|
1465
|
+
debug(`session=${sessionId} via=${via} skip no-capture`);
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
864
1468
|
// Injection-unavailable: the last dispatch could not carry the view
|
|
865
1469
|
// (frozen options AND messages — the wire always ships raw history).
|
|
866
1470
|
// Folding is pointless ONLY when NEITHER channel can reach the wire:
|
|
@@ -874,15 +1478,28 @@ export function applyContextTrim(ctx, config) {
|
|
|
874
1478
|
decisionLog(`session=${sessionId} via=${via} action=none reason=injection-unavailable view=${Math.round(capture.viewTokens)}`);
|
|
875
1479
|
return;
|
|
876
1480
|
}
|
|
877
|
-
inFlight.add(sessionId);
|
|
878
1481
|
const cycleStartedAt = Date.now();
|
|
1482
|
+
// Generation token lock (was a plain Set): a cycle wedged on an await
|
|
1483
|
+
// that never settles used to pin this session's fold path until
|
|
1484
|
+
// restart. The watchdog logs the wedge and releases the lock; an
|
|
1485
|
+
// abandoned cycle returns "superseded" at its commit guard, and its
|
|
1486
|
+
// finally can no longer delete a retry's entry.
|
|
1487
|
+
const cycleToken = {};
|
|
1488
|
+
inFlight.set(sessionId, cycleToken);
|
|
1489
|
+
const cycleWatchdog = setTimeout(() => {
|
|
1490
|
+
if (inFlight.get(sessionId) !== cycleToken) return;
|
|
1491
|
+
inFlight.delete(sessionId);
|
|
1492
|
+
decisionLog(`session=${sessionId} via=${via} outcome=cycle-stuck elapsedMs=${Date.now() - cycleStartedAt}`);
|
|
1493
|
+
warn(`[context-trim] ${sessionId}: fold cycle stuck >${CYCLE_WATCHDOG_MS}ms — lock released for the next trigger`);
|
|
1494
|
+
}, CYCLE_WATCHDOG_MS);
|
|
1495
|
+
cycleWatchdog.unref?.();
|
|
879
1496
|
// Hoisted for the catch below: a dispose-cut throw may land before the
|
|
880
1497
|
// trigger ever ran, and the catch must not touch a dead binding.
|
|
881
1498
|
let decision = null;
|
|
882
1499
|
try {
|
|
883
|
-
const { protectedTail, ...thresholds } = thresholdsNow();
|
|
1500
|
+
const { protectedTail, protectTaskIntent, ...thresholds } = thresholdsNow();
|
|
884
1501
|
const ledger = store.ledgerOf(sessionId);
|
|
885
|
-
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
|
|
1502
|
+
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail, { protectTaskIntent });
|
|
886
1503
|
const windowTokens = await windowTokensFor(capture.model);
|
|
887
1504
|
// Pressure tiers run on the REAL prompt size when the tally is known:
|
|
888
1505
|
// max(view, wire). The view estimate misses the system/context
|
|
@@ -905,12 +1522,15 @@ export function applyContextTrim(ctx, config) {
|
|
|
905
1522
|
});
|
|
906
1523
|
decisionLog(`session=${sessionId} via=${via} action=${decision.action} tier=${decision.tier ?? "-"} reason=${decision.reason || "-"} view=${Math.round(capture.viewTokens)}${wireTokens === null ? "" : ` wire=${wireTokens}`} window=${windowTokens ?? "null"}`);
|
|
907
1524
|
if (decision.action === "none") return;
|
|
908
|
-
const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt);
|
|
1525
|
+
const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt, cycleToken);
|
|
909
1526
|
// Failure ladder: commit resets it; a summarize/commit failure
|
|
910
1527
|
// climbs it (the no-backoff loop this closes — those outcomes
|
|
911
1528
|
// advance no cadence gate on their own). A ladder run whose
|
|
912
1529
|
// consumed blocks vanished between trigger and commit is the same
|
|
913
1530
|
// family: without backoff it would re-fire at every boundary.
|
|
1531
|
+
// "superseded" (watchdog released the lock mid-cycle) and neutral
|
|
1532
|
+
// skips advance no ladder either — the retry that owns the session
|
|
1533
|
+
// is judged alone.
|
|
914
1534
|
if (outcome.startsWith("folded:")) clearFoldFailure(sessionId);
|
|
915
1535
|
else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:") || outcome === "ladder-consumed-missing") recordFoldFailure(sessionId);
|
|
916
1536
|
// Outcome lands in the decision log too: warn() has no reachable
|
|
@@ -923,11 +1543,15 @@ export function applyContextTrim(ctx, config) {
|
|
|
923
1543
|
// that is a reload cut, not a model failure: log it and skip the ladder.
|
|
924
1544
|
if (disposedSince(cycleStartedAt)) decisionLog(`session=${sessionId} outcome=reload-aborted tier=${decision?.tier ?? "-"}`);
|
|
925
1545
|
else {
|
|
1546
|
+
errorLog("fold-cycle", error);
|
|
926
1547
|
recordFoldFailure(sessionId); // a thrown cycle counts as failed too
|
|
927
1548
|
warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
|
|
928
1549
|
}
|
|
929
1550
|
} finally {
|
|
930
|
-
|
|
1551
|
+
clearTimeout(cycleWatchdog);
|
|
1552
|
+
// Only the cycle owning the CURRENT token may release the lock —
|
|
1553
|
+
// a watchdog-superseded wedged cycle must not delete a retry's entry.
|
|
1554
|
+
if (inFlight.get(sessionId) === cycleToken) inFlight.delete(sessionId);
|
|
931
1555
|
}
|
|
932
1556
|
}
|
|
933
1557
|
|
|
@@ -953,6 +1577,9 @@ export function applyContextTrim(ctx, config) {
|
|
|
953
1577
|
// agent }) — derive the session id per host/context-watch.js:122.
|
|
954
1578
|
const sessionId = agentSessionIdOf(payload?.agent);
|
|
955
1579
|
if (sessionId === null) return;
|
|
1580
|
+
// Meter projection sample: host meter's projected NEXT-prompt size
|
|
1581
|
+
// (the context panel's figure) for the view-applied proj=<n> column.
|
|
1582
|
+
noteTrimProjectedTokens(sessionId, payload?.agent, ctx);
|
|
956
1583
|
// Fold first, then the surface-replace idle sweep (design
|
|
957
1584
|
// docs/surface-fold.md): a block committed by this idle pass
|
|
958
1585
|
// ships on the same boundary.
|
|
@@ -962,7 +1589,12 @@ export function applyContextTrim(ctx, config) {
|
|
|
962
1589
|
} catch {
|
|
963
1590
|
/* sweep must never break the idle hook */
|
|
964
1591
|
}
|
|
965
|
-
|
|
1592
|
+
}).catch((error) => {
|
|
1593
|
+
// Parity with the stream path: maybeFold is catch-total by design,
|
|
1594
|
+
// but a dispose-cut throw or a future regression must not become
|
|
1595
|
+
// an unhandled rejection on the void'd chain.
|
|
1596
|
+
errorLog("fold-idle", error);
|
|
1597
|
+
});
|
|
966
1598
|
} catch {
|
|
967
1599
|
/* idle hook must never throw */
|
|
968
1600
|
}
|
|
@@ -1037,19 +1669,27 @@ export function applyContextTrim(ctx, config) {
|
|
|
1037
1669
|
name: "ctx_trim_status",
|
|
1038
1670
|
description: "List this session's compressible context ranges with their true sizes: the fold ledger, the raw range a fold would cover, and distillation targets. Read-only. Contains sizes only — never progress percentages.",
|
|
1039
1671
|
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
1040
|
-
|
|
1672
|
+
// Register-level shape: bare ctx.tools.register runs no defineTool DSL
|
|
1673
|
+
// compilation and REJECTS a definition without an output block — the
|
|
1674
|
+
// missing block silently killed both tools in every session (native
|
|
1675
|
+
// and PTC). The execute bodies return plain strings, rendered as one
|
|
1676
|
+
// text block.
|
|
1677
|
+
output: {
|
|
1678
|
+
schema: { type: "string" },
|
|
1679
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
1680
|
+
},
|
|
1041
1681
|
async execute() {
|
|
1042
1682
|
const sessionId = sessionIdOf({});
|
|
1043
1683
|
if (sessionId === null) return "No session context available.";
|
|
1044
1684
|
if (!enabledNow()) return "Context trimming is disabled for this deployment.";
|
|
1045
1685
|
const ledger = store.ledgerOf(sessionId);
|
|
1046
1686
|
const capture = captures.get(sessionId);
|
|
1047
|
-
const { protectedTail } = thresholdsNow();
|
|
1687
|
+
const { protectedTail, protectTaskIntent } = thresholdsNow();
|
|
1048
1688
|
const lines = [`ctx_trim_status — mode: ${modeNow()} (enabled: ${enabledNow()})`];
|
|
1049
1689
|
lines.push(...renderStatusLedger(ledger));
|
|
1050
1690
|
// The raw range a fold would cover next, with its true size.
|
|
1051
1691
|
if (capture !== undefined) {
|
|
1052
|
-
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
|
|
1692
|
+
const foldable = computeFoldableRange(capture.messages, ledger, protectedTail, { protectTaskIntent });
|
|
1053
1693
|
if (foldable !== null) {
|
|
1054
1694
|
const firstRef = refOf(capture.messages[foldable.startIdx], foldable.startIdx);
|
|
1055
1695
|
const lastRef = refOf(capture.messages[foldable.endIdx], foldable.endIdx);
|
|
@@ -1087,6 +1727,11 @@ export function applyContextTrim(ctx, config) {
|
|
|
1087
1727
|
required: ["firstId", "lastId", "count", "summary"],
|
|
1088
1728
|
additionalProperties: false,
|
|
1089
1729
|
},
|
|
1730
|
+
// Register-level output block — see ctx_trim_status above.
|
|
1731
|
+
output: {
|
|
1732
|
+
schema: { type: "string" },
|
|
1733
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
1734
|
+
},
|
|
1090
1735
|
async execute(args) {
|
|
1091
1736
|
const sessionId = sessionIdOf({});
|
|
1092
1737
|
if (sessionId === null) return "No session context available.";
|
|
@@ -1094,8 +1739,26 @@ export function applyContextTrim(ctx, config) {
|
|
|
1094
1739
|
const capture = captures.get(sessionId);
|
|
1095
1740
|
if (capture === undefined) return "No captured history yet — fold is unavailable until the next model call.";
|
|
1096
1741
|
const positions = refPositions(capture.messages);
|
|
1097
|
-
|
|
1098
|
-
|
|
1742
|
+
// Ref normalization ([2026-11-15]-[m-prefix fold refs]): ctx_trim_status
|
|
1743
|
+
// displays anchors in the protocol's [m...] form — "m" prepended to
|
|
1744
|
+
// refOf's raw id/hash — and its description tells the model to pass
|
|
1745
|
+
// exactly that form. The lookup map is keyed by the RAW ref, so an
|
|
1746
|
+
// m-prefixed argument (the only form the model can copy) never matched:
|
|
1747
|
+
// every model-driven fold failed with "Refs not found" (found live in
|
|
1748
|
+
// the PTC runtime — status listed a 234-message range and the fold
|
|
1749
|
+
// rejected it four times in a row). Resolve both forms: try the
|
|
1750
|
+
// argument as given, then with one leading "m" stripped.
|
|
1751
|
+
const resolveKey = (raw) => {
|
|
1752
|
+
const given = String(raw ?? "");
|
|
1753
|
+
if (positions.has(given)) return given;
|
|
1754
|
+
if (given.startsWith("m") && positions.has(given.slice(1))) return given.slice(1);
|
|
1755
|
+
return given;
|
|
1756
|
+
};
|
|
1757
|
+
const resolveRef = (raw) => positions.get(resolveKey(raw));
|
|
1758
|
+
|
|
1759
|
+
const firstList = resolveRef(args?.firstId);
|
|
1760
|
+
const lastList = resolveRef(args?.lastId);
|
|
1761
|
+
|
|
1099
1762
|
if (firstList === undefined || lastList === undefined) return "Refs not found in the current history — re-check with ctx_trim_status.";
|
|
1100
1763
|
// Count-anchored resolution: duplicate refs (identical content)
|
|
1101
1764
|
// make the first/last occurrence lists ambiguous — anchor on the
|
|
@@ -1110,6 +1773,15 @@ export function applyContextTrim(ctx, config) {
|
|
|
1110
1773
|
}
|
|
1111
1774
|
if (firstIdx < 0) { firstIdx = firstList[0]; lastIdx = lastList[lastList.length - 1]; }
|
|
1112
1775
|
if (lastIdx < firstIdx) return "lastId sorts before firstId in the current history.";
|
|
1776
|
+
// Prior fold summaries are never raw-fold material: a T1 fold
|
|
1777
|
+
// covering an acp_summary_* node would shadow the replacement
|
|
1778
|
+
// node and break the ladder's consumed-run bookkeeping.
|
|
1779
|
+
// Summaries consolidate only through ladder T2/T3 distillation.
|
|
1780
|
+
for (let i = firstIdx; i <= lastIdx; i++) {
|
|
1781
|
+
if (isFoldSummaryMessage(capture.messages[i])) {
|
|
1782
|
+
return "Range covers a previous fold's summary (acp_summary_*) — summaries are preserved and consolidate via T2/T3 ladder; pick a range that excludes them.";
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1113
1785
|
const { protectedTail } = thresholdsNow();
|
|
1114
1786
|
if (lastIdx >= capture.messages.length - Math.max(0, protectedTail)) return `Range reaches into the protected tail (last ${protectedTail} messages are never folded).`;
|
|
1115
1787
|
if (!rangeIsSegmentAligned(capture.messages, firstIdx, lastIdx)) return "Range splits a tool-call pair — fold whole assistant/tool segments (extend firstId/lastId to the segment boundaries).";
|
|
@@ -1117,7 +1789,12 @@ export function applyContextTrim(ctx, config) {
|
|
|
1117
1789
|
const result = store.commitFold(
|
|
1118
1790
|
sessionId,
|
|
1119
1791
|
{
|
|
1120
|
-
|
|
1792
|
+
// Resolved refs: strip the display "m" prefix so commitFold's raw-key
|
|
1793
|
+
// posMap lookup matches (same normalization as resolveRef above).
|
|
1794
|
+
refRange: {
|
|
1795
|
+
firstId: resolveKey(args.firstId),
|
|
1796
|
+
lastId: resolveKey(args.lastId),
|
|
1797
|
+
},
|
|
1121
1798
|
count,
|
|
1122
1799
|
approxTokens: estimateTokensOf(capture.messages, firstIdx, lastIdx),
|
|
1123
1800
|
summary: String(args.summary ?? ""),
|
|
@@ -1127,6 +1804,11 @@ export function applyContextTrim(ctx, config) {
|
|
|
1127
1804
|
positions,
|
|
1128
1805
|
);
|
|
1129
1806
|
if (!result.ok) return `Fold rejected: ${result.reason}.`;
|
|
1807
|
+
// Auditable commit ([2026-11-15]-[tool-fold observability]): the only
|
|
1808
|
+
// prior trace was the fold-commit SURFACE sweep; a sweep that failed
|
|
1809
|
+
// or stayed pending left the commit itself invisible in the log.
|
|
1810
|
+
decisionLog(`session=${sessionId} via=tool action=fold tier=advisory block=${result.block.id} msgs=${count} tokens=~${result.block.approxTokens}`);
|
|
1811
|
+
|
|
1130
1812
|
surfacePass.captureFoldSeqs(sessionId, result.block);
|
|
1131
1813
|
// Growth-gate baseline pair: pre-fold view (banner display) plus the
|
|
1132
1814
|
// post-fold low (tier-a anchor — keeps the gate armed after folds
|
|
@@ -1134,7 +1816,18 @@ export function applyContextTrim(ctx, config) {
|
|
|
1134
1816
|
const lowLedger = store.ledgerOf(sessionId);
|
|
1135
1817
|
const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail: thresholdsNow().protectedTail }).messages);
|
|
1136
1818
|
store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
|
|
1137
|
-
|
|
1819
|
+
// Ship the fresh block onto the session surface NOW instead of waiting for
|
|
1820
|
+
// the next idle/turn-stopping boundary: a fold committed mid-turn used to
|
|
1821
|
+
// stay meter-only for the rest of the turn (2026-11-14 live test: two
|
|
1822
|
+
// tool-path folds left the wire carrying raw history for ~11 min at 97%
|
|
1823
|
+
// pressure — injected=none on frozen dispatches, both blocks pending).
|
|
1824
|
+
// The sweep's own guards (enabled flag, capable session, compaction lock,
|
|
1825
|
+
// per-block validation) fail closed to pending; the boundary sweeps
|
|
1826
|
+
// remain the closure pass.
|
|
1827
|
+
void surfacePass.sweep(sessionId, "fold-commit").catch((error) => {
|
|
1828
|
+
decisionLog(`session=${sessionId} surface-replace failed via=fold-commit why=${error?.message ?? error}`);
|
|
1829
|
+
});
|
|
1830
|
+
return `Folded ${count} messages into block ${result.block.id}. An immediate surface ship was queued (via=fold-commit): when it lands, even frozen dispatches carry the reduced history; if the surface cannot accept it right now the block stays pending and the boundary sweeps retry.`;
|
|
1138
1831
|
},
|
|
1139
1832
|
});
|
|
1140
1833
|
} catch (error) {
|
|
@@ -1152,11 +1845,24 @@ export function applyContextTrim(ctx, config) {
|
|
|
1152
1845
|
if (applied !== null) {
|
|
1153
1846
|
viewTokens = applied.viewTokens;
|
|
1154
1847
|
mainDispatch = applied.sideDispatch !== true;
|
|
1848
|
+
if (mainDispatch) {
|
|
1849
|
+
// Advisory pressure nudge (modeNow gates inside): the model's
|
|
1850
|
+
// own fold is advisory mode's primary relief channel, plus the
|
|
1851
|
+
// cooldown-gated critical relief fold below — speak at the fold
|
|
1852
|
+
// line. Fail-open, never breaks the dispatch.
|
|
1853
|
+
try {
|
|
1854
|
+
deliverAdvisoryNudge(applied.sessionId, applied.viewTokens);
|
|
1855
|
+
} catch (error) {
|
|
1856
|
+
decisionLog(`advisory-nudge-failed why=${error?.message ?? error}`);
|
|
1857
|
+
errorLog("advisory-nudge", error);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1155
1860
|
}
|
|
1156
1861
|
}
|
|
1157
1862
|
} catch (error) {
|
|
1158
1863
|
decisionLog(`view-build-failed why=${error?.message ?? error}`);
|
|
1159
1864
|
warn(`[context-trim] view build failed: ${error?.message ?? error}`);
|
|
1865
|
+
errorLog("view-build", error);
|
|
1160
1866
|
}
|
|
1161
1867
|
const result = next();
|
|
1162
1868
|
let wrappedSession = null;
|
|
@@ -1169,10 +1875,24 @@ export function applyContextTrim(ctx, config) {
|
|
|
1169
1875
|
if (usage !== null) {
|
|
1170
1876
|
metrics.recordUsage(wrappedSession, usage);
|
|
1171
1877
|
if (mainDispatch) noteTrimWireTokens(wrappedSession, usage);
|
|
1878
|
+
// Second proj sample point ([2026-11-15]-[proj sampling gaps]):
|
|
1879
|
+
// the agent/status idle boundary alone left proj=null on most rows —
|
|
1880
|
+
// long-running turns never go idle mid-flight, so the view-applied
|
|
1881
|
+
// column went dark exactly when the divergence data mattered. At
|
|
1882
|
+
// response end the agent handle is resolvable via
|
|
1883
|
+
// ctx.agents.get(sessionId) (the same service subagents.js reads);
|
|
1884
|
+
// unreachable => the sample silently skips, as designed.
|
|
1885
|
+
try {
|
|
1886
|
+
noteTrimProjectedTokens(wrappedSession, ctx.agents?.get?.(wrappedSession), ctx);
|
|
1887
|
+
} catch {
|
|
1888
|
+
/* observability must never break the stream */
|
|
1889
|
+
}
|
|
1172
1890
|
}
|
|
1173
1891
|
});
|
|
1174
1892
|
} catch (error) {
|
|
1175
1893
|
warn(`[context-trim] stream wrap failed: ${error?.message ?? error}`);
|
|
1894
|
+
decisionLog(`stream-wrap-failed why=${error?.message ?? error}`);
|
|
1895
|
+
errorLog("stream-wrap", error);
|
|
1176
1896
|
return result;
|
|
1177
1897
|
} finally {
|
|
1178
1898
|
// Fallback fold check on the stream path: the agent/status idle event
|
|
@@ -1190,17 +1910,32 @@ export function applyContextTrim(ctx, config) {
|
|
|
1190
1910
|
if (nowMs - (lastStreamCheckAt.get(wrappedSession) ?? 0) >= 60_000) {
|
|
1191
1911
|
lastStreamCheckAt.set(wrappedSession, nowMs);
|
|
1192
1912
|
if (lastStreamCheckAt.size > 200) lastStreamCheckAt.delete(lastStreamCheckAt.keys().next().value);
|
|
1193
|
-
void maybeFold(wrappedSession, "stream").finally(() =>
|
|
1913
|
+
void maybeFold(wrappedSession, "stream").finally(() => {
|
|
1914
|
+
try {
|
|
1915
|
+
void surfacePass.sweep(wrappedSession, "stream");
|
|
1916
|
+
} catch (error) {
|
|
1917
|
+
errorLog("surface-sweep-stream", error);
|
|
1918
|
+
}
|
|
1919
|
+
}).catch((error) => {
|
|
1920
|
+
// maybeFold never rejects by design (its own catch is total), but a
|
|
1921
|
+
// dispose-cut throw or a future regression must not become an
|
|
1922
|
+
// unhandled rejection: the .finally here chains the sweep, so the
|
|
1923
|
+
// void'd chain needs its own terminal handler (parity with the
|
|
1924
|
+
// fold-commit sweep's .catch below).
|
|
1925
|
+
errorLog("fold-stream", error);
|
|
1926
|
+
});
|
|
1194
1927
|
}
|
|
1195
1928
|
}
|
|
1196
|
-
} catch {
|
|
1929
|
+
} catch (error) {
|
|
1197
1930
|
/* fallback must never break the stream */
|
|
1931
|
+
errorLog("stream-fallback", error);
|
|
1198
1932
|
}
|
|
1199
1933
|
}
|
|
1200
1934
|
}
|
|
1201
1935
|
|
|
1202
1936
|
try {
|
|
1203
1937
|
ctx.inject(["llm"], (llmCtx) => {
|
|
1938
|
+
const disposeListenerOf = new Map(); // "llm/stream" -> the on() disposer of the current attach
|
|
1204
1939
|
const attach = () => {
|
|
1205
1940
|
try {
|
|
1206
1941
|
// Global + prepend: the cordis dispatch filter silently drops NON-global
|
|
@@ -1209,10 +1944,23 @@ export function applyContextTrim(ctx, config) {
|
|
|
1209
1944
|
// subtree) — that silenced the whole stream path for a resumed session
|
|
1210
1945
|
// while fresh sessions kept logging. Stock consumers (dsh-session-title)
|
|
1211
1946
|
// register exactly this way.
|
|
1212
|
-
|
|
1213
|
-
|
|
1947
|
+
// [2026-11-15]-[llm-attach diagnostics proxy trap]: reading llmCtx.off
|
|
1948
|
+
// through the context proxy THROWS on the cordis build the harness
|
|
1949
|
+
// ships (the events mixin exposes on/once/emit/… but NOT off —
|
|
1950
|
+
// vendor/cordis/src/reflect.ts get-trap: `cannot get property "off"
|
|
1951
|
+
// without inject`). `?.` cannot defend against a throwing getter, so
|
|
1952
|
+
// the old SUCCESS row itself raised, landed as llm-attach-failed, and
|
|
1953
|
+
// the true success line vanished (2026-11-15 06:29Z). on()'s returned
|
|
1954
|
+
// disposer is the unsubscribe API; off never existed here.
|
|
1955
|
+
const disposeListener = llmCtx.on?.("llm/stream", streamListener, { global: true, prepend: true });
|
|
1956
|
+
disposeListenerOf.set("llm/stream", disposeListener);
|
|
1957
|
+
let hasOff = false;
|
|
1958
|
+
try { hasOff = typeof llmCtx.off === "function"; } catch { hasOff = false; }
|
|
1959
|
+
decisionLog(`llm-attached hasOn=${typeof llmCtx.on} hasOff=${hasOff} hasDisposer=${typeof disposeListener} hasEffect=${typeof llmCtx.effect} pid=${process.pid}`);
|
|
1214
1960
|
} catch (error) {
|
|
1215
1961
|
warn(`[context-trim] llm/stream attach failed: ${error?.message ?? error}`);
|
|
1962
|
+
decisionLog(`llm-attach-failed why=${error?.message ?? error}`);
|
|
1963
|
+
errorLog("llm-attach", error);
|
|
1216
1964
|
}
|
|
1217
1965
|
};
|
|
1218
1966
|
// Reload-safe release (applyIntent precedent): undo the listener
|
|
@@ -1221,14 +1969,21 @@ export function applyContextTrim(ctx, config) {
|
|
|
1221
1969
|
const effect = llmCtx.effect;
|
|
1222
1970
|
if (typeof effect === "function") {
|
|
1223
1971
|
effect(function* () {
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1972
|
+
yield () => {
|
|
1973
|
+
try {
|
|
1974
|
+
// The on() disposer is the only real unsubscribe: ctx.off is not
|
|
1975
|
+
// exposed by this cordis build (see the attach comment above) — the
|
|
1976
|
+
// old llmCtx.off?.() call threw on EVERY reload teardown and the
|
|
1977
|
+
// cleanup silently never ran (fiber-unload auto-dispose was the
|
|
1978
|
+
// only thing that actually removed the listener).
|
|
1979
|
+
const dispose = disposeListenerOf.get("llm/stream");
|
|
1980
|
+
disposeListenerOf.delete("llm/stream");
|
|
1981
|
+
if (typeof dispose === "function") dispose();
|
|
1982
|
+
} catch {
|
|
1983
|
+
/* best effort */
|
|
1984
|
+
}
|
|
1985
|
+
};
|
|
1986
|
+
}, "context-trim-stream-listener");
|
|
1232
1987
|
}
|
|
1233
1988
|
} catch {
|
|
1234
1989
|
/* effect registration is best-effort */
|
|
@@ -1239,5 +1994,5 @@ export function applyContextTrim(ctx, config) {
|
|
|
1239
1994
|
warn(`[context-trim] ctx.inject(llm) failed: ${error?.message ?? error}`);
|
|
1240
1995
|
}
|
|
1241
1996
|
|
|
1242
|
-
return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
|
|
1997
|
+
return { store, metrics, captures, inFlight, windowCache, advisoryNudgeBySession, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
|
|
1243
1998
|
}
|