dsh-switchman 1.11.6 → 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 -31
- package/README.es.md +36 -31
- package/README.fr.md +36 -31
- package/README.it.md +36 -31
- package/README.ja.md +36 -31
- package/README.ko.md +36 -31
- package/README.md +37 -32
- package/README.pt.md +36 -31
- package/README.ru.md +36 -31
- package/README.zh-TW.md +36 -31
- package/README.zh.md +36 -31
- package/client.js +355 -26
- package/cordis.patch.yml +10 -10
- package/host/config.js +64 -33
- package/host/context-trim/index.js +539 -60
- package/host/context-trim/state.js +3 -2
- package/host/context-trim/surface.js +5 -1
- package/host/context-watch.js +12 -7
- 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 +110 -34
- package/host/teams.js +61 -15
- package/host/vision.js +24 -3
- package/index.js +5 -2
- package/package.json +1 -1
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
* trimLadderT3Tokens, trimLadderT3MinBlocks.
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
35
|
-
import { join } from "node:path";
|
|
34
|
+
import { appendFileSync, mkdirSync, realpathSync, renameSync, statSync } from "node:fs";
|
|
35
|
+
import { join, resolve } from "node:path";
|
|
36
36
|
import { buildView, estimateTokensOf, injectView, isFoldSummaryMessage, ledgerIsStale, messageTokens, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
|
|
37
37
|
import { computeFoldableRange, evaluateTrigger, EMERGENCY_COOLDOWN_MS } from "./trigger.js";
|
|
38
38
|
import { LADDER_SYSTEM_PROMPT, mechanicalSummary, renderLadderMaterial, renderMaterial, summarizeWithModel } from "./summarizer.js";
|
|
@@ -41,6 +41,46 @@ import { createMetrics, extractUsage, promptTokensOf, wrapStreamForMetrics } fro
|
|
|
41
41
|
import { forkLinkOf, loadForkLinks, recordForkLink, resolveInheritance, INHERIT_WINDOW_MS } from "./fork.js";
|
|
42
42
|
import { createSurfaceFoldPass } from "./surface.js";
|
|
43
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
|
+
|
|
44
84
|
|
|
45
85
|
// ---- agent reference helpers --------------------------------------------------
|
|
46
86
|
|
|
@@ -59,6 +99,7 @@ import { childSessionIdOf } from "../subagent-model.js";
|
|
|
59
99
|
// it and the stored tally takes over.
|
|
60
100
|
const trimViewBySession = new Map(); // sessionId -> { viewTokens, at, inject, lastNoneAt }
|
|
61
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.
|
|
62
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
|
|
63
104
|
/** Freshness TTL for the in-memory view/injection registry (watermark and
|
|
64
105
|
* banner display only). NEVER a fold gate: gating folds on "did the surface
|
|
@@ -178,6 +219,45 @@ function noteTrimWireTokens(sessionId, usage) {
|
|
|
178
219
|
}
|
|
179
220
|
}
|
|
180
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
|
+
|
|
181
261
|
/** Injection outcome of the last dispatch for one session ("in-place" |
|
|
182
262
|
* "swapped" | "none"), or null when unknown or stale. Read by maybeFold:
|
|
183
263
|
* when the wire cannot carry the view, folding cannot relieve anything and
|
|
@@ -272,21 +352,33 @@ export const TRIM_PROTOCOL_TEXT = [
|
|
|
272
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.",
|
|
273
353
|
"- NEVER contradict or re-derive content a summary already settled; build on it.",
|
|
274
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.",
|
|
275
356
|
"- Tool-call pairs (an assistant tool call and its results) must always be folded together or not at all.",
|
|
276
357
|
].join("\n");
|
|
277
358
|
|
|
278
|
-
// Advisory nudge thresholds: the default speak line (
|
|
279
|
-
// below the protocol's own 80% fold line so the model is nudged while
|
|
280
|
-
// still has comfortable headroom; critical wording escalates at
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
export const
|
|
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;
|
|
287
379
|
|
|
288
380
|
/** Effective advisory lines for one dispatch: config-tunable, falling back to
|
|
289
|
-
* the configured defaults (
|
|
381
|
+
* the configured defaults (50/80) when absent, inverted, or out of range —
|
|
290
382
|
* a broken pair never silences the nudge. */
|
|
291
383
|
function advisoryNudgeLines(config) {
|
|
292
384
|
const line = readNumber(config, "trimAdvisoryPercent", ADVISORY_NUDGE_PERCENT);
|
|
@@ -296,20 +388,42 @@ function advisoryNudgeLines(config) {
|
|
|
296
388
|
return { line, critical };
|
|
297
389
|
}
|
|
298
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
|
+
|
|
299
402
|
/** Advisory-mode nudge text. Deliberate, bounded exception to the
|
|
300
|
-
* digit-free disclosure policy of TRIM_PROTOCOL_TEXT above: advisory mode
|
|
301
|
-
*
|
|
302
|
-
* the only relief
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
|
|
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) {
|
|
306
410
|
const action = critical
|
|
307
411
|
? `Pressure is CRITICAL (${pressurePercent}% ≥ ${criticalLine}%): fold down to under ~50% of the context IN THIS TURN.`
|
|
308
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.";
|
|
309
422
|
return [
|
|
310
423
|
'[SWITCHMAN:TRIM] Advisory nudge — pinned to the session tail until pressure falls below the fold line.',
|
|
311
424
|
`Live context pressure on this session has reached the fold line. ${action}`,
|
|
312
|
-
"
|
|
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
|
|
313
427
|
].join("\n");
|
|
314
428
|
}
|
|
315
429
|
|
|
@@ -362,7 +476,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
362
476
|
|
|
363
477
|
// Per-process pipeline state (process-wide by design: ledgers persist on
|
|
364
478
|
// disk per session, metrics/captures live per session in memory).
|
|
365
|
-
const store = createStore({ stateDir: readString(config, "trimStateDir", null) ?? undefined, warn });
|
|
479
|
+
const store = createStore({ stateDir: resolveStateDir(readString(config, "trimStateDir", null), warn) ?? undefined, warn });
|
|
366
480
|
|
|
367
481
|
// Surface-replace pass (docs/surface-fold.md): ships committed fold blocks
|
|
368
482
|
// onto the session surface so folds reach the wire even for frozen
|
|
@@ -417,6 +531,32 @@ export function applyContextTrim(ctx, config) {
|
|
|
417
531
|
});
|
|
418
532
|
}
|
|
419
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
|
+
|
|
420
560
|
/** Advisory-mode pressure nudge (text: advisoryNudgeText). Appends the live
|
|
421
561
|
* instruction to the session surface tail once pressure crosses the fold
|
|
422
562
|
* line. Pressure reuses the meter-duality bridge (wire preferred, view
|
|
@@ -429,7 +569,10 @@ export function applyContextTrim(ctx, config) {
|
|
|
429
569
|
* cache reuse survives up to the nudge instead of invalidating the whole
|
|
430
570
|
* prefix as the old system-head splice did. */
|
|
431
571
|
function deliverAdvisoryNudge(sessionId, viewTokens) {
|
|
432
|
-
if (modeNow() !== "advisory")
|
|
572
|
+
if (modeNow() !== "advisory") {
|
|
573
|
+
debug(`nudge skip mode=${modeNow()} session=${sessionId}`);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
433
576
|
const model = capturedModelBySession.get(sessionId) ?? null;
|
|
434
577
|
if (model === null) {
|
|
435
578
|
decisionLog("advisory-nudge skip why=no-captured-model");
|
|
@@ -468,7 +611,10 @@ export function applyContextTrim(ctx, config) {
|
|
|
468
611
|
}
|
|
469
612
|
const wire = wireTokensOf(sessionId);
|
|
470
613
|
const mass = Math.max(Number.isFinite(wire) ? wire : 0, Number.isFinite(viewTokens) ? viewTokens : 0);
|
|
471
|
-
if (mass <= 0)
|
|
614
|
+
if (mass <= 0) {
|
|
615
|
+
debug(`nudge skip empty-mass session=${sessionId}`);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
472
618
|
const pressurePercent = (mass / windowTokens) * 100;
|
|
473
619
|
const nudgeLines = advisoryNudgeLines(config);
|
|
474
620
|
const prior = advisoryNudgeBySession.get(sessionId);
|
|
@@ -486,17 +632,129 @@ export function applyContextTrim(ctx, config) {
|
|
|
486
632
|
return;
|
|
487
633
|
}
|
|
488
634
|
advisoryNudgeBySession.delete(sessionId);
|
|
489
|
-
|
|
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);
|
|
490
640
|
} else if (prior !== undefined) {
|
|
491
641
|
advisoryNudgeBySession.delete(sessionId); // nothing visible left; close the episode quietly
|
|
492
642
|
}
|
|
493
643
|
return; // fresh episode on the next crossing
|
|
494
644
|
}
|
|
495
|
-
|
|
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);
|
|
496
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
|
+
}
|
|
497
741
|
// Any band CHANGE re-speaks immediately: escalation high->critical, and
|
|
498
742
|
// intentionally also a downgrade back into high after a partial fold.
|
|
499
|
-
|
|
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
|
+
}
|
|
500
758
|
if (!surfacePass.nudgeSupported(sessionId)) {
|
|
501
759
|
decisionLog("advisory-nudge skip why=surface-unavailable pressure=" + Math.round(pressurePercent) + "%");
|
|
502
760
|
return;
|
|
@@ -521,7 +779,15 @@ export function applyContextTrim(ctx, config) {
|
|
|
521
779
|
decisionLog("advisory-nudge orphan-pruned seqs=" + orphans.length + " pressure=" + Math.round(pressurePercent) + "%");
|
|
522
780
|
}
|
|
523
781
|
}
|
|
524
|
-
|
|
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
|
+
);
|
|
525
791
|
const appended = surfacePass.nudgeAppend(sessionId, nudge);
|
|
526
792
|
if (appended.error !== null || !Number.isFinite(appended.seq)) {
|
|
527
793
|
advisoryNudgeBySession.delete(sessionId); // a pruned prior must not throttle the retry
|
|
@@ -530,9 +796,10 @@ export function applyContextTrim(ctx, config) {
|
|
|
530
796
|
}
|
|
531
797
|
const seq = appended.seq;
|
|
532
798
|
advisoryNudgeBySession.delete(sessionId); // re-insert: refresh recency so eviction drops the least recently nudged
|
|
533
|
-
advisoryNudgeBySession.set(sessionId, { at: now,
|
|
799
|
+
advisoryNudgeBySession.set(sessionId, { at: now, band, seq, repeats: nextRepeats });
|
|
800
|
+
debug(`nudge prepared seq=${seq} level=${band} pressure=${Math.round(pressurePercent)}% session=${sessionId}`);
|
|
534
801
|
if (advisoryNudgeBySession.size > 200) advisoryNudgeBySession.delete(advisoryNudgeBySession.keys().next().value);
|
|
535
|
-
decisionLog("session=" + sessionId + " advisory-nudge pressure=" + Math.round(pressurePercent) + "% mass=" + Math.round(mass) + " window=" + windowTokens + " level=" +
|
|
802
|
+
decisionLog("session=" + sessionId + " advisory-nudge pressure=" + Math.round(pressurePercent) + "% mass=" + Math.round(mass) + " window=" + windowTokens + " level=" + band + " repeats=" + nextRepeats + " seq=" + seq);
|
|
536
803
|
}
|
|
537
804
|
|
|
538
805
|
const captures = new Map(); // sessionId -> { messages, refs, viewTokens, model, at }
|
|
@@ -552,6 +819,12 @@ export function applyContextTrim(ctx, config) {
|
|
|
552
819
|
const WINDOW_RESOLVE_TIMEOUT_MS = 10_000;
|
|
553
820
|
const windowCache = new Map(); // "provider/model" -> { windowTokens, at }
|
|
554
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)
|
|
555
828
|
const lastStreamCheckAt = new Map(); // sessionId -> epoch ms of last stream-path fold check (throttle)
|
|
556
829
|
const epochResetDoneBySession = new Map(); // sessionId -> ledger-stale baseline reset already done for this host epoch (cleared when a fold applies again)
|
|
557
830
|
|
|
@@ -601,6 +874,34 @@ export function applyContextTrim(ctx, config) {
|
|
|
601
874
|
}
|
|
602
875
|
}
|
|
603
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
|
+
|
|
604
905
|
// ---- fork-state inheritance (copy-on-fork; design in fork.js header) ------
|
|
605
906
|
const linksCache = { mtime: -1, map: null }; // mtime-gated fork-links map
|
|
606
907
|
const inheritFirstSeen = new Map(); // sessionId -> epoch ms of first eligibility check
|
|
@@ -683,17 +984,22 @@ export function applyContextTrim(ctx, config) {
|
|
|
683
984
|
const mode = readString(config, "trimMode", "auto");
|
|
684
985
|
return mode === "off" || mode === "advisory" || mode === "auto" ? mode : "auto";
|
|
685
986
|
};
|
|
987
|
+
const reliefEnabledNow = () => readBool(config, "trimCriticalRelief", true);
|
|
686
988
|
const thresholdsNow = () => ({
|
|
687
989
|
growthTokens: readNumber(config, "trimGrowthTokens", 22_500),
|
|
688
|
-
incrementTokens: readNumber(config, "trimIncrementTokens",
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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))),
|
|
693
999
|
ladderT2Tokens: readNumber(config, "trimLadderT2Tokens", 8_000),
|
|
694
|
-
ladderT2MinBlocks: readNumber(config, "trimLadderT2MinBlocks", 3),
|
|
1000
|
+
ladderT2MinBlocks: Math.max(2, Math.round(readNumber(config, "trimLadderT2MinBlocks", 3))),
|
|
695
1001
|
ladderT3Tokens: readNumber(config, "trimLadderT3Tokens", 6_000),
|
|
696
|
-
ladderT3MinBlocks: readNumber(config, "trimLadderT3MinBlocks", 2),
|
|
1002
|
+
ladderT3MinBlocks: Math.max(2, Math.round(readNumber(config, "trimLadderT3MinBlocks", 2))),
|
|
697
1003
|
protectTaskIntent: readBool(config, "trimProtectTaskIntent", true),
|
|
698
1004
|
});
|
|
699
1005
|
const ladderEnabledNow = () => readBool(config, "trimLadderEnabled", true);
|
|
@@ -736,10 +1042,69 @@ export function applyContextTrim(ctx, config) {
|
|
|
736
1042
|
const windowCapNow = () => {
|
|
737
1043
|
const explicit = readNumber(config, "trimWindowTokens", 0);
|
|
738
1044
|
if (explicit > 0) return explicit;
|
|
739
|
-
const force = readNumber(config, "wmForceTokens",
|
|
1045
|
+
const force = readNumber(config, "wmForceTokens", 200_000);
|
|
740
1046
|
return force > 0 ? force : null;
|
|
741
1047
|
};
|
|
742
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
|
+
|
|
743
1108
|
/** Session id for a stream call: options.sessionId, else the current
|
|
744
1109
|
* initiator's session (try/catch — host internals shift). */
|
|
745
1110
|
function sessionIdOf(options) {
|
|
@@ -848,10 +1213,16 @@ export function applyContextTrim(ctx, config) {
|
|
|
848
1213
|
// previous response's usage) makes the view-vs-wire divergence visible
|
|
849
1214
|
// instead of leaving the view estimate as the only lullaby in the log.
|
|
850
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;
|
|
851
1222
|
// Per-request application evidence: whether the ledger's blocks actually
|
|
852
1223
|
// locate inside THIS re-send (the host may compact/replace history, in
|
|
853
1224
|
// which case applied=0 explains a stuck-high view without any failure).
|
|
854
|
-
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}` : ""}`);
|
|
855
1226
|
// Side-dispatch guard: auxiliary llm calls that inherit the session id
|
|
856
1227
|
// (web-search summaries, title generation, fold-span material) present
|
|
857
1228
|
// tiny arrays in which the ledger cannot locate. Accepting them as the
|
|
@@ -958,7 +1329,10 @@ export function applyContextTrim(ctx, config) {
|
|
|
958
1329
|
* Returns a short outcome string for logging. */
|
|
959
1330
|
async function runFoldCycle(sessionId, decision, cycleStartedAt, cycleToken) {
|
|
960
1331
|
const capture = captures.get(sessionId);
|
|
961
|
-
if (capture === undefined)
|
|
1332
|
+
if (capture === undefined) {
|
|
1333
|
+
debug(`cycle step no-capture session=${sessionId}`);
|
|
1334
|
+
return "no-capture";
|
|
1335
|
+
}
|
|
962
1336
|
const { protectedTail } = thresholdsNow();
|
|
963
1337
|
// Ladder cycle (docs/ladder-fold.md): distill the decision's consumed
|
|
964
1338
|
// run of block summaries into ONE replacement. Never mechanical — a
|
|
@@ -1003,6 +1377,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
1003
1377
|
const refs = slice.map((message, i) => refOf(message, startIdx + i));
|
|
1004
1378
|
const count = slice.length;
|
|
1005
1379
|
const approxTokens = estimateTokensOf(slice);
|
|
1380
|
+
debug(`cycle step begin kind=${decision.kind} tier=${decision.tier ?? "-"} range=${startIdx}..${endIdx} session=${sessionId}`);
|
|
1006
1381
|
let summary = null;
|
|
1007
1382
|
let kind = "summary";
|
|
1008
1383
|
let degraded = false; // set when a PRESSURE fold falls back to mechanical
|
|
@@ -1025,6 +1400,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
1025
1400
|
onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
|
|
1026
1401
|
onNotice: (why) => decisionLog(`session=${sessionId} summary-retry route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
|
|
1027
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}`);
|
|
1028
1404
|
summary = route !== null ? await summarize(route, "side") : null;
|
|
1029
1405
|
if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
|
|
1030
1406
|
// Last resort for PRESSURE folds only: relief beats fidelity when
|
|
@@ -1042,6 +1418,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
1042
1418
|
}
|
|
1043
1419
|
const positions = refPositions(capture.messages);
|
|
1044
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}`);
|
|
1045
1422
|
const result = store.commitFold(
|
|
1046
1423
|
sessionId,
|
|
1047
1424
|
{ refRange: { firstId: refs[0], lastId: refs[refs.length - 1] }, count, approxTokens, summary, kind, tier: decision.tier },
|
|
@@ -1066,15 +1443,28 @@ export function applyContextTrim(ctx, config) {
|
|
|
1066
1443
|
async function maybeFold(agent, via = "idle") {
|
|
1067
1444
|
const sessionId = agent;
|
|
1068
1445
|
if (sessionId === null || sessionId === undefined || sessionId === "") return;
|
|
1069
|
-
if (!enabledNow() || modeNow() !== "auto")
|
|
1070
|
-
|
|
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
|
+
}
|
|
1071
1454
|
// Failure backoff: a session whose recent cycles failed stays quiet
|
|
1072
1455
|
// for 60s*2^(n-1) (cap 15 min) instead of re-firing at every idle
|
|
1073
1456
|
// boundary and stream check. Silent gate — the outcome row that armed
|
|
1074
1457
|
// the cooldown is already in decisions.log.
|
|
1075
|
-
|
|
1458
|
+
const failureCooldown = foldFailureCooldownOf(sessionId);
|
|
1459
|
+
if (failureCooldown > 0) {
|
|
1460
|
+
debug(`session=${sessionId} via=${via} skip failure-backoff remaining=${failureCooldown}ms`);
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1076
1463
|
const capture = captures.get(sessionId);
|
|
1077
|
-
if (capture === undefined)
|
|
1464
|
+
if (capture === undefined) {
|
|
1465
|
+
debug(`session=${sessionId} via=${via} skip no-capture`);
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1078
1468
|
// Injection-unavailable: the last dispatch could not carry the view
|
|
1079
1469
|
// (frozen options AND messages — the wire always ships raw history).
|
|
1080
1470
|
// Folding is pointless ONLY when NEITHER channel can reach the wire:
|
|
@@ -1153,6 +1543,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
1153
1543
|
// that is a reload cut, not a model failure: log it and skip the ladder.
|
|
1154
1544
|
if (disposedSince(cycleStartedAt)) decisionLog(`session=${sessionId} outcome=reload-aborted tier=${decision?.tier ?? "-"}`);
|
|
1155
1545
|
else {
|
|
1546
|
+
errorLog("fold-cycle", error);
|
|
1156
1547
|
recordFoldFailure(sessionId); // a thrown cycle counts as failed too
|
|
1157
1548
|
warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
|
|
1158
1549
|
}
|
|
@@ -1186,6 +1577,9 @@ export function applyContextTrim(ctx, config) {
|
|
|
1186
1577
|
// agent }) — derive the session id per host/context-watch.js:122.
|
|
1187
1578
|
const sessionId = agentSessionIdOf(payload?.agent);
|
|
1188
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);
|
|
1189
1583
|
// Fold first, then the surface-replace idle sweep (design
|
|
1190
1584
|
// docs/surface-fold.md): a block committed by this idle pass
|
|
1191
1585
|
// ships on the same boundary.
|
|
@@ -1195,7 +1589,12 @@ export function applyContextTrim(ctx, config) {
|
|
|
1195
1589
|
} catch {
|
|
1196
1590
|
/* sweep must never break the idle hook */
|
|
1197
1591
|
}
|
|
1198
|
-
|
|
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
|
+
});
|
|
1199
1598
|
} catch {
|
|
1200
1599
|
/* idle hook must never throw */
|
|
1201
1600
|
}
|
|
@@ -1340,8 +1739,26 @@ export function applyContextTrim(ctx, config) {
|
|
|
1340
1739
|
const capture = captures.get(sessionId);
|
|
1341
1740
|
if (capture === undefined) return "No captured history yet — fold is unavailable until the next model call.";
|
|
1342
1741
|
const positions = refPositions(capture.messages);
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
+
|
|
1345
1762
|
if (firstList === undefined || lastList === undefined) return "Refs not found in the current history — re-check with ctx_trim_status.";
|
|
1346
1763
|
// Count-anchored resolution: duplicate refs (identical content)
|
|
1347
1764
|
// make the first/last occurrence lists ambiguous — anchor on the
|
|
@@ -1372,7 +1789,12 @@ export function applyContextTrim(ctx, config) {
|
|
|
1372
1789
|
const result = store.commitFold(
|
|
1373
1790
|
sessionId,
|
|
1374
1791
|
{
|
|
1375
|
-
|
|
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
|
+
},
|
|
1376
1798
|
count,
|
|
1377
1799
|
approxTokens: estimateTokensOf(capture.messages, firstIdx, lastIdx),
|
|
1378
1800
|
summary: String(args.summary ?? ""),
|
|
@@ -1382,6 +1804,11 @@ export function applyContextTrim(ctx, config) {
|
|
|
1382
1804
|
positions,
|
|
1383
1805
|
);
|
|
1384
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
|
+
|
|
1385
1812
|
surfacePass.captureFoldSeqs(sessionId, result.block);
|
|
1386
1813
|
// Growth-gate baseline pair: pre-fold view (banner display) plus the
|
|
1387
1814
|
// post-fold low (tier-a anchor — keeps the gate armed after folds
|
|
@@ -1420,12 +1847,14 @@ export function applyContextTrim(ctx, config) {
|
|
|
1420
1847
|
mainDispatch = applied.sideDispatch !== true;
|
|
1421
1848
|
if (mainDispatch) {
|
|
1422
1849
|
// Advisory pressure nudge (modeNow gates inside): the model's
|
|
1423
|
-
// own fold is advisory mode's
|
|
1424
|
-
//
|
|
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.
|
|
1425
1853
|
try {
|
|
1426
1854
|
deliverAdvisoryNudge(applied.sessionId, applied.viewTokens);
|
|
1427
1855
|
} catch (error) {
|
|
1428
1856
|
decisionLog(`advisory-nudge-failed why=${error?.message ?? error}`);
|
|
1857
|
+
errorLog("advisory-nudge", error);
|
|
1429
1858
|
}
|
|
1430
1859
|
}
|
|
1431
1860
|
}
|
|
@@ -1433,6 +1862,7 @@ export function applyContextTrim(ctx, config) {
|
|
|
1433
1862
|
} catch (error) {
|
|
1434
1863
|
decisionLog(`view-build-failed why=${error?.message ?? error}`);
|
|
1435
1864
|
warn(`[context-trim] view build failed: ${error?.message ?? error}`);
|
|
1865
|
+
errorLog("view-build", error);
|
|
1436
1866
|
}
|
|
1437
1867
|
const result = next();
|
|
1438
1868
|
let wrappedSession = null;
|
|
@@ -1445,10 +1875,24 @@ export function applyContextTrim(ctx, config) {
|
|
|
1445
1875
|
if (usage !== null) {
|
|
1446
1876
|
metrics.recordUsage(wrappedSession, usage);
|
|
1447
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
|
+
}
|
|
1448
1890
|
}
|
|
1449
1891
|
});
|
|
1450
1892
|
} catch (error) {
|
|
1451
1893
|
warn(`[context-trim] stream wrap failed: ${error?.message ?? error}`);
|
|
1894
|
+
decisionLog(`stream-wrap-failed why=${error?.message ?? error}`);
|
|
1895
|
+
errorLog("stream-wrap", error);
|
|
1452
1896
|
return result;
|
|
1453
1897
|
} finally {
|
|
1454
1898
|
// Fallback fold check on the stream path: the agent/status idle event
|
|
@@ -1466,17 +1910,32 @@ export function applyContextTrim(ctx, config) {
|
|
|
1466
1910
|
if (nowMs - (lastStreamCheckAt.get(wrappedSession) ?? 0) >= 60_000) {
|
|
1467
1911
|
lastStreamCheckAt.set(wrappedSession, nowMs);
|
|
1468
1912
|
if (lastStreamCheckAt.size > 200) lastStreamCheckAt.delete(lastStreamCheckAt.keys().next().value);
|
|
1469
|
-
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
|
+
});
|
|
1470
1927
|
}
|
|
1471
1928
|
}
|
|
1472
|
-
} catch {
|
|
1929
|
+
} catch (error) {
|
|
1473
1930
|
/* fallback must never break the stream */
|
|
1931
|
+
errorLog("stream-fallback", error);
|
|
1474
1932
|
}
|
|
1475
1933
|
}
|
|
1476
1934
|
}
|
|
1477
1935
|
|
|
1478
1936
|
try {
|
|
1479
1937
|
ctx.inject(["llm"], (llmCtx) => {
|
|
1938
|
+
const disposeListenerOf = new Map(); // "llm/stream" -> the on() disposer of the current attach
|
|
1480
1939
|
const attach = () => {
|
|
1481
1940
|
try {
|
|
1482
1941
|
// Global + prepend: the cordis dispatch filter silently drops NON-global
|
|
@@ -1485,10 +1944,23 @@ export function applyContextTrim(ctx, config) {
|
|
|
1485
1944
|
// subtree) — that silenced the whole stream path for a resumed session
|
|
1486
1945
|
// while fresh sessions kept logging. Stock consumers (dsh-session-title)
|
|
1487
1946
|
// register exactly this way.
|
|
1488
|
-
|
|
1489
|
-
|
|
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}`);
|
|
1490
1960
|
} catch (error) {
|
|
1491
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);
|
|
1492
1964
|
}
|
|
1493
1965
|
};
|
|
1494
1966
|
// Reload-safe release (applyIntent precedent): undo the listener
|
|
@@ -1497,14 +1969,21 @@ export function applyContextTrim(ctx, config) {
|
|
|
1497
1969
|
const effect = llmCtx.effect;
|
|
1498
1970
|
if (typeof effect === "function") {
|
|
1499
1971
|
effect(function* () {
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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");
|
|
1508
1987
|
}
|
|
1509
1988
|
} catch {
|
|
1510
1989
|
/* effect registration is best-effort */
|