dsh-switchman 1.9.0 → 1.10.2
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 +13 -10
- package/README.es.md +13 -10
- package/README.fr.md +13 -10
- package/README.it.md +13 -10
- package/README.ja.md +13 -10
- package/README.ko.md +13 -10
- package/README.md +12 -9
- package/README.pt.md +13 -10
- package/README.ru.md +13 -10
- package/README.zh-TW.md +13 -10
- package/README.zh.md +13 -10
- package/client.js +56 -56
- package/host/config.js +8 -2
- package/host/context-trim/fork.js +16 -6
- package/host/context-trim/index.js +239 -15
- package/host/context-trim/metrics.js +32 -8
- package/host/context-trim/summarizer.js +1 -1
- package/host/context-trim/surface.js +736 -0
- package/host/context-trim/trigger.js +7 -0
- package/host/context-trim/view.js +69 -23
- package/host/context-watch.js +17 -67
- package/host/handover-state.js +2 -2
- package/host/routes.js +1 -1
- package/index.js +3 -3
- package/package.json +1 -1
|
@@ -30,8 +30,9 @@ import { buildView, estimateTokensOf, injectView, ledgerIsStale, rangeIsSegmentA
|
|
|
30
30
|
import { computeFoldableRange, evaluateTrigger, EMERGENCY_COOLDOWN_MS } from "./trigger.js";
|
|
31
31
|
import { mechanicalSummary, renderMaterial, summarizeWithModel } from "./summarizer.js";
|
|
32
32
|
import { createStore } from "./state.js";
|
|
33
|
-
import { createMetrics, extractUsage, wrapStreamForMetrics } from "./metrics.js";
|
|
33
|
+
import { createMetrics, extractUsage, promptTokensOf, wrapStreamForMetrics } from "./metrics.js";
|
|
34
34
|
import { forkLinkOf, loadForkLinks, recordForkLink, resolveInheritance, INHERIT_WINDOW_MS } from "./fork.js";
|
|
35
|
+
import { createSurfaceFoldPass } from "./surface.js";
|
|
35
36
|
import { childSessionIdOf } from "../subagent-model.js";
|
|
36
37
|
|
|
37
38
|
// ---- agent reference helpers --------------------------------------------------
|
|
@@ -47,24 +48,73 @@ import { childSessionIdOf } from "../subagent-model.js";
|
|
|
47
48
|
// stored tally while it is fresh, so the watermark tracks what the model
|
|
48
49
|
// actually sees after billion-context folding instead of over-triggering
|
|
49
50
|
// handovers off a stale stored measure.
|
|
50
|
-
const trimViewBySession = new Map(); // sessionId -> { viewTokens, at }
|
|
51
|
+
const trimViewBySession = new Map(); // sessionId -> { viewTokens, at, inject, lastNoneAt }
|
|
52
|
+
/** Freshness TTL for the in-memory view/injection registry (watermark and
|
|
53
|
+
* banner display only). NEVER a fold gate: gating folds on "did the surface
|
|
54
|
+
* channel ship recently" deadlocks resumed sessions (2026-11-05). The fold
|
|
55
|
+
* gate asks surfacePass.supported() — a durable capability question. */
|
|
51
56
|
const TRIM_VIEW_MAX_AGE_MS = 600_000; // 10 minutes
|
|
52
57
|
|
|
58
|
+
/** Dispatches below this message count are never treated as the session's
|
|
59
|
+
* main history (the side-dispatch guard in applyViewToOptions): auxiliary
|
|
60
|
+
* llm calls (web-search summaries, title generation) inherit the session id
|
|
61
|
+
* with 1-6 message arrays and would clobber every per-session meter. */
|
|
62
|
+
const SIDE_DISPATCH_MIN_MESSAGES = 12;
|
|
63
|
+
|
|
64
|
+
/** Consecutive side-dispatch skips tolerated before one is accepted as the
|
|
65
|
+
* main history anyway — the release valve for a genuine host-compaction
|
|
66
|
+
* epoch that shrank the history below SIDE_DISPATCH_MIN_MESSAGES (without
|
|
67
|
+
* it the guard would stick: no capture refresh, no epoch reset, folds run
|
|
68
|
+
* against a stale capture forever). */
|
|
69
|
+
const SIDE_DISPATCH_SKIP_BUDGET = 2;
|
|
70
|
+
|
|
71
|
+
/** Consecutive side-dispatch skips per session (bounded; reset on every
|
|
72
|
+
* accepted main dispatch). */
|
|
73
|
+
const sideDispatchSkipsBySession = new Map();
|
|
74
|
+
|
|
53
75
|
/** Most recent outbound-view token estimate for a session, or null when
|
|
54
|
-
* missing, non-positive,
|
|
55
|
-
*
|
|
76
|
+
* missing, non-positive, older than maxAgeMs — or when the recent dispatch
|
|
77
|
+
* stream could not carry the view (injected "none": frozen options AND
|
|
78
|
+
* messages, so the model still receives the raw history and the provider
|
|
79
|
+
* tally is the only truthful meter). The gate is deliberately sticky: a
|
|
80
|
+
* single mutable dispatch right after frozen ones re-arms maybeFold, but
|
|
81
|
+
* the WATERMARK keeps falling back to the tally until no "none" occurred
|
|
82
|
+
* within the TTL window (mixed sessions exist — one real session logged 46
|
|
83
|
+
* none + 14 swapped). Read by the watermark layer (meter duality); itself
|
|
84
|
+
* fail-open. */
|
|
56
85
|
export function recentTrimViewTokensOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
|
|
57
86
|
try {
|
|
58
87
|
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
59
88
|
const entry = trimViewBySession.get(sessionId);
|
|
60
89
|
if (entry === undefined) return null;
|
|
61
90
|
if (Date.now() - entry.at > maxAgeMs) return null;
|
|
91
|
+
// Wire-truth gate: a view that never reaches the wire must not drive
|
|
92
|
+
// the watermark (observed 2026-11-04: view ~80k vs a 176k wire while
|
|
93
|
+
// the force tier stayed silent and the auto-handover never fired).
|
|
94
|
+
if (entry.inject === "none") return null;
|
|
95
|
+
if (Number.isFinite(entry.lastNoneAt) && entry.lastNoneAt > 0 && Date.now() - entry.lastNoneAt < maxAgeMs) return null;
|
|
62
96
|
return Number.isFinite(entry.viewTokens) && entry.viewTokens > 0 ? entry.viewTokens : null;
|
|
63
97
|
} catch {
|
|
64
98
|
return null;
|
|
65
99
|
}
|
|
66
100
|
}
|
|
67
101
|
|
|
102
|
+
/** Injection outcome of the last dispatch for one session ("in-place" |
|
|
103
|
+
* "swapped" | "none"), or null when unknown or stale. Read by maybeFold:
|
|
104
|
+
* when the wire cannot carry the view, folding cannot relieve anything and
|
|
105
|
+
* only burns a summarizer call — the watermark's tally-driven auto-handover
|
|
106
|
+
* is the correct relief path instead. */
|
|
107
|
+
export function recentTrimInjectOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
|
|
108
|
+
try {
|
|
109
|
+
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
110
|
+
const entry = trimViewBySession.get(sessionId);
|
|
111
|
+
if (entry === undefined || Date.now() - entry.at > maxAgeMs) return null;
|
|
112
|
+
return typeof entry.inject === "string" ? entry.inject : null;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
68
118
|
export function agentSessionIdOf(agent) {
|
|
69
119
|
if (typeof agent === "string") return agent !== "" ? agent : null;
|
|
70
120
|
if (agent !== null && typeof agent === "object") {
|
|
@@ -160,8 +210,11 @@ export function effectiveWindowTokens(resolved, cap) {
|
|
|
160
210
|
}
|
|
161
211
|
|
|
162
212
|
/** Live context line renderer (pure; exported for tests). Silent in "off"
|
|
163
|
-
* mode and whenever nothing has been measured yet.
|
|
164
|
-
|
|
213
|
+
* mode and whenever nothing has been measured yet. wireRaw marks a session
|
|
214
|
+
* whose last dispatch could not carry the fold view (frozen request) — the
|
|
215
|
+
* displayed view is then meter-only and the watermark governs off the
|
|
216
|
+
* provider tally. */
|
|
217
|
+
export function renderTrimContextLine(agent, { enabled, mode, viewTokens, windowTokens, folds, growthTokens, lastModel, wireRaw = false }) {
|
|
165
218
|
if (!enabled) return "";
|
|
166
219
|
if (mode === "off") return "";
|
|
167
220
|
if (agent === null || agent === undefined || agent === "") return "";
|
|
@@ -175,6 +228,7 @@ export function renderTrimContextLine(agent, { enabled, mode, viewTokens, window
|
|
|
175
228
|
if (Number.isFinite(windowTokens) && windowTokens > 0) usage = ` (${Math.round((viewTokens / windowTokens) * 100)}% of window ${kilo(windowTokens)})`;
|
|
176
229
|
else usage = " (window unknown — pressure tiers off)";
|
|
177
230
|
parts.push(`view ~${kilo(viewTokens)} tokens${usage}`);
|
|
231
|
+
if (wireRaw) parts.push("wire=raw (folds not shipping; watermark reads the tally)");
|
|
178
232
|
}
|
|
179
233
|
if (hasFolds) {
|
|
180
234
|
parts.push(`folds ${folds}`);
|
|
@@ -203,7 +257,56 @@ export function applyContextTrim(ctx, config) {
|
|
|
203
257
|
// Per-process pipeline state (process-wide by design: ledgers persist on
|
|
204
258
|
// disk per session, metrics/captures live per session in memory).
|
|
205
259
|
const store = createStore({ stateDir: readString(config, "trimStateDir", null) ?? undefined, warn });
|
|
260
|
+
|
|
261
|
+
// Surface-replace pass (docs/surface-fold.md): ships committed fold blocks
|
|
262
|
+
// onto the session surface so folds reach the wire even for frozen
|
|
263
|
+
// dispatches. Registers its own agent/turn-stopping trigger; the idle
|
|
264
|
+
// sweep below runs after each fold cycle.
|
|
265
|
+
const surfacePass = createSurfaceFoldPass({ ctx, config, store, decisionLog, warn });
|
|
266
|
+
|
|
267
|
+
// Durable capability probe (surface.js supported): the fold gate asks THIS,
|
|
268
|
+
// never a recency window. An in-memory "did we ship in the last 10 minutes"
|
|
269
|
+
// test deadlocked resumed sessions: dispatches report injected=none, so
|
|
270
|
+
// after the window expired no fold ran, no fold meant no new ship, and the
|
|
271
|
+
// gate stayed shut forever while the provider prompt grew to 135k.
|
|
272
|
+
const surfaceChannelCapable = (sessionId) => {
|
|
273
|
+
try {
|
|
274
|
+
return surfacePass.supported(sessionId);
|
|
275
|
+
} catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
// Durable surface-channel EVIDENCE for the banner: a block that already
|
|
280
|
+
// SHIPPED a replacement (surfaceReplaced) proves the channel reaches the
|
|
281
|
+
// wire, even across a process restart and after the in-memory recency
|
|
282
|
+
// window expired. A commit-time capture (surfaceSeqs) does NOT — the block
|
|
283
|
+
// may never ship, and a capture must not silence the honest wire=raw
|
|
284
|
+
// warning. Display only — the fold gate uses the live capability probe.
|
|
285
|
+
const ledgerShowsShippedReplacement = (sessionId) => {
|
|
286
|
+
try {
|
|
287
|
+
const blocks = store.ledgerOf(sessionId)?.blocks;
|
|
288
|
+
if (!Array.isArray(blocks)) return false;
|
|
289
|
+
return blocks.some((block) => block?.surfaceReplaced !== null && block?.surfaceReplaced !== undefined);
|
|
290
|
+
} catch {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
206
294
|
const metrics = createMetrics();
|
|
295
|
+
|
|
296
|
+
/** Most recent provider prompt size for one session, or null when unknown.
|
|
297
|
+
* The tally is the FULL prompt: the host reports TokenUsage as
|
|
298
|
+
* { inputTokens (uncached), cacheReadTokens, cacheWriteTokens }, so a
|
|
299
|
+
* heavily cached session (~130k of 136k cached) would otherwise read as
|
|
300
|
+
* the uncached delta and keep max(view, wire) silent. The view estimate
|
|
301
|
+
* only counts the messages array, so it under-reports by the system and
|
|
302
|
+
* context sections; pressure tiers must not run blind on it. */
|
|
303
|
+
const wireTokensOf = (sessionId) => {
|
|
304
|
+
try {
|
|
305
|
+
return promptTokensOf(metrics.of(sessionId));
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
};
|
|
207
310
|
const captures = new Map(); // sessionId -> { messages, refs, viewTokens, model, at }
|
|
208
311
|
const inFlight = new Set(); // sessionIds with a fold running
|
|
209
312
|
const windowCache = new Map(); // "provider/model" -> { windowTokens, at }
|
|
@@ -211,6 +314,37 @@ export function applyContextTrim(ctx, config) {
|
|
|
211
314
|
const lastStreamCheckAt = new Map(); // sessionId -> epoch ms of last stream-path fold check (throttle)
|
|
212
315
|
const epochResetDoneBySession = new Map(); // sessionId -> ledger-stale baseline reset already done for this host epoch (cleared when a fold applies again)
|
|
213
316
|
|
|
317
|
+
// Per-session fold-failure cooldown: a failing summarize/commit cycle
|
|
318
|
+
// advances NONE of the cadence gates (lastFoldAt / foldsThisHour only
|
|
319
|
+
// move on a successful commitFold), so without this the same failure
|
|
320
|
+
// re-fires at every idle boundary plus every >=60s stream check forever.
|
|
321
|
+
// In-memory only by design — a process restart re-arms the cycle open.
|
|
322
|
+
const foldFailureBySession = new Map(); // sessionId -> { count, lastAt }
|
|
323
|
+
const FOLD_FAILURE_BASE_MS = 60_000; // backoff ladder start (2^(n-1) growth)
|
|
324
|
+
const FOLD_FAILURE_MAX_MS = 900_000; // 15 min ceiling
|
|
325
|
+
|
|
326
|
+
/** Record one failed fold cycle (summary unavailable / commit rejected /
|
|
327
|
+
* thrown): consecutive-attempt count + timestamp drive the backoff. */
|
|
328
|
+
function recordFoldFailure(sessionId) {
|
|
329
|
+
const prior = foldFailureBySession.get(sessionId);
|
|
330
|
+
foldFailureBySession.set(sessionId, { count: Math.min((prior?.count ?? 0) + 1, 16), lastAt: Date.now() });
|
|
331
|
+
if (foldFailureBySession.size > 200) foldFailureBySession.delete(foldFailureBySession.keys().next().value);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Remaining cooldown ms for one session (0 when not cooling): the delay
|
|
335
|
+
* doubles per consecutive failure from 60s, capped at 15 min. */
|
|
336
|
+
function foldFailureCooldownOf(sessionId, now = Date.now()) {
|
|
337
|
+
const entry = foldFailureBySession.get(sessionId);
|
|
338
|
+
if (entry === undefined || entry.count <= 0) return 0;
|
|
339
|
+
const backoff = Math.min(FOLD_FAILURE_BASE_MS * 2 ** (entry.count - 1), FOLD_FAILURE_MAX_MS);
|
|
340
|
+
return Math.max(0, entry.lastAt + backoff - now);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** A committed fold resets the failure ladder. */
|
|
344
|
+
function clearFoldFailure(sessionId) {
|
|
345
|
+
foldFailureBySession.delete(sessionId);
|
|
346
|
+
}
|
|
347
|
+
|
|
214
348
|
/** Append one fold-decision line to <stateDir>/decisions.log (fail-open,
|
|
215
349
|
* best-effort observability — console warn stays reserved for outcomes). */
|
|
216
350
|
function decisionLog(entry) {
|
|
@@ -399,10 +533,42 @@ export function applyContextTrim(ctx, config) {
|
|
|
399
533
|
const injected = injectView(options, messages, view);
|
|
400
534
|
const viewTokens = estimateTokensOf(view.messages);
|
|
401
535
|
const blocksCount = Array.isArray(ledger.blocks) ? ledger.blocks.length : 0;
|
|
536
|
+
// Wire truth in the row: the most recent provider prompt size (from the
|
|
537
|
+
// previous response's usage) makes the view-vs-wire divergence visible
|
|
538
|
+
// instead of leaving the view estimate as the only lullaby in the log.
|
|
539
|
+
const wireTokens = wireTokensOf(sessionId);
|
|
402
540
|
// Per-request application evidence: whether the ledger's blocks actually
|
|
403
541
|
// locate inside THIS re-send (the host may compact/replace history, in
|
|
404
542
|
// which case applied=0 explains a stuck-high view without any failure).
|
|
405
|
-
decisionLog(`session=${sessionId} view-applied applied=${view.applied} foldedMsgs=${view.foldedMessages} view=${Math.round(viewTokens)} blocks=${blocksCount} injected=${injected}`);
|
|
543
|
+
decisionLog(`session=${sessionId} view-applied applied=${view.applied} foldedMsgs=${view.foldedMessages} view=${Math.round(viewTokens)} blocks=${blocksCount} injected=${injected}${wireTokens === null ? "" : ` wire=${wireTokens}`}`);
|
|
544
|
+
// Side-dispatch guard: auxiliary llm calls that inherit the session id
|
|
545
|
+
// (web-search summaries, title generation, fold-span material) present
|
|
546
|
+
// tiny arrays in which the ledger cannot locate. Accepting them as the
|
|
547
|
+
// session view clobbers the capture, the cross-layer registry that
|
|
548
|
+
// measureNow reads, and the growth baseline (observed 2026-11-04: a
|
|
549
|
+
// baseline reset to ~150 tokens mid-session). Fold-span dispatches are
|
|
550
|
+
// already covered by inFlight. A tiny dispatch is a side candidate
|
|
551
|
+
// whenever the session ALREADY carries trim state — fold blocks, a
|
|
552
|
+
// capture, or a view registry entry — regardless of blocksCount,
|
|
553
|
+
// which is exactly 0 in the young zero-fold sessions an auxiliary
|
|
554
|
+
// dispatch most often hits. A session's very FIRST dispatch is
|
|
555
|
+
// indistinguishable from a real one and stays accepted: it cannot
|
|
556
|
+
// clobber state that does not exist yet, and the next real dispatch
|
|
557
|
+
// overwrites the capture. A genuine host-compaction epoch ALSO shrinks
|
|
558
|
+
// history below the floor, so the guard must not stick: after
|
|
559
|
+
// SIDE_DISPATCH_SKIP_BUDGET consecutive skips the dispatch is accepted
|
|
560
|
+
// as main, which re-arms capture/baseline on the compacted history.
|
|
561
|
+
const sideCandidate = inFlight.has(sessionId)
|
|
562
|
+
|| (view.applied === 0 && messages.length < SIDE_DISPATCH_MIN_MESSAGES && (blocksCount > 0 || captures.has(sessionId) || trimViewBySession.has(sessionId)));
|
|
563
|
+
const skipCount = sideDispatchSkipsBySession.get(sessionId) ?? 0;
|
|
564
|
+
if (sideCandidate && skipCount < SIDE_DISPATCH_SKIP_BUDGET) {
|
|
565
|
+
sideDispatchSkipsBySession.set(sessionId, skipCount + 1);
|
|
566
|
+
if (sideDispatchSkipsBySession.size > 200) sideDispatchSkipsBySession.delete(sideDispatchSkipsBySession.keys().next().value);
|
|
567
|
+
decisionLog(`session=${sessionId} side-dispatch skipped n=${skipCount + 1} msgs=${messages.length} view=${Math.round(viewTokens)} blocks=${blocksCount}`);
|
|
568
|
+
return { sessionId, viewTokens, sideDispatch: true };
|
|
569
|
+
}
|
|
570
|
+
if (sideCandidate) decisionLog(`session=${sessionId} side-dispatch budget exhausted — accepting as main msgs=${messages.length}`);
|
|
571
|
+
sideDispatchSkipsBySession.delete(sessionId);
|
|
406
572
|
// Host-compaction epoch boundary: every block failed to LOCATE inside
|
|
407
573
|
// this re-send, so the ledger aged out as a whole. Re-anchor the growth
|
|
408
574
|
// baseline at the true post-epoch view once per epoch — carrying the
|
|
@@ -424,7 +590,9 @@ export function applyContextTrim(ctx, config) {
|
|
|
424
590
|
const model = { provider: options.provider ?? null, model: options.model ?? null };
|
|
425
591
|
captureOf(sessionId, rawSnapshot, model, viewTokens);
|
|
426
592
|
metrics.noteView(sessionId, viewTokens, model);
|
|
427
|
-
|
|
593
|
+
const priorEntry = trimViewBySession.get(sessionId);
|
|
594
|
+
const lastNoneAt = injected === "none" ? Date.now() : Number.isFinite(priorEntry?.lastNoneAt) ? priorEntry.lastNoneAt : 0;
|
|
595
|
+
trimViewBySession.set(sessionId, { viewTokens, at: Date.now(), inject: injected, lastNoneAt });
|
|
428
596
|
// keep the view registry bounded (200 sessions, same as captures)
|
|
429
597
|
if (trimViewBySession.size > 200) {
|
|
430
598
|
const oldest = trimViewBySession.keys().next().value;
|
|
@@ -459,6 +627,13 @@ export function applyContextTrim(ctx, config) {
|
|
|
459
627
|
let summary = null;
|
|
460
628
|
let kind = "summary";
|
|
461
629
|
if (decision.action === "emergency") {
|
|
630
|
+
// Emergency cooldown timestamp set BEFORE the cycle: a commitFold
|
|
631
|
+
// rejection (e.g. duplicate-range) must cool the emergency tier
|
|
632
|
+
// down like a success, or a rejected emergency fold retries at
|
|
633
|
+
// every idle boundary forever. The 90s trigger gate semantics
|
|
634
|
+
// (EMERGENCY_COOLDOWN_MS) stay untouched.
|
|
635
|
+
lastEmergencyAtBySession.set(sessionId, Date.now());
|
|
636
|
+
if (lastEmergencyAtBySession.size > 200) lastEmergencyAtBySession.delete(lastEmergencyAtBySession.keys().next().value);
|
|
462
637
|
summary = mechanicalSummary(capture.messages, startIdx, endIdx, refOf);
|
|
463
638
|
kind = "mechanical";
|
|
464
639
|
} else {
|
|
@@ -476,14 +651,16 @@ export function applyContextTrim(ctx, config) {
|
|
|
476
651
|
positions,
|
|
477
652
|
);
|
|
478
653
|
if (!result.ok) return `commit-rejected:${result.reason}`;
|
|
654
|
+
// Commit-time surface capture (primary range-mapping strategy, see
|
|
655
|
+
// surface.js header): store the covered event seqs while the live
|
|
656
|
+
// surface still resolves the folded range.
|
|
657
|
+
surfacePass.captureFoldSeqs(sessionId, result.block);
|
|
479
658
|
// Growth-gate baseline pair: pre-fold view (banner display) plus the
|
|
480
659
|
// post-fold low (tier-a anchor — keeps the gate armed after folds
|
|
481
660
|
// that commit at high view points).
|
|
482
661
|
const lowLedger = store.ledgerOf(sessionId);
|
|
483
662
|
const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail: thresholdsNow().protectedTail }).messages);
|
|
484
663
|
store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
|
|
485
|
-
if (decision.action === "emergency") lastEmergencyAtBySession.set(sessionId, Date.now());
|
|
486
|
-
if (lastEmergencyAtBySession.size > 200) lastEmergencyAtBySession.delete(lastEmergencyAtBySession.keys().next().value);
|
|
487
664
|
return `folded:${decision.tier}:${count} msgs (~${approxTokens} tokens)`;
|
|
488
665
|
}
|
|
489
666
|
|
|
@@ -494,18 +671,42 @@ export function applyContextTrim(ctx, config) {
|
|
|
494
671
|
if (sessionId === null || sessionId === undefined || sessionId === "") return;
|
|
495
672
|
if (!enabledNow() || modeNow() !== "auto") return;
|
|
496
673
|
if (inFlight.has(sessionId)) return;
|
|
674
|
+
// Failure backoff: a session whose recent cycles failed stays quiet
|
|
675
|
+
// for 60s*2^(n-1) (cap 15 min) instead of re-firing at every idle
|
|
676
|
+
// boundary and stream check. Silent gate — the outcome row that armed
|
|
677
|
+
// the cooldown is already in decisions.log.
|
|
678
|
+
if (foldFailureCooldownOf(sessionId) > 0) return;
|
|
497
679
|
const capture = captures.get(sessionId);
|
|
498
680
|
if (capture === undefined) return;
|
|
681
|
+
// Injection-unavailable: the last dispatch could not carry the view
|
|
682
|
+
// (frozen options AND messages — the wire always ships raw history).
|
|
683
|
+
// Folding is pointless ONLY when NEITHER channel can reach the wire:
|
|
684
|
+
// the spliced view (inject !== "none") OR the surface-replace pass
|
|
685
|
+
// (durable capability probe, not a recency window — a proven channel
|
|
686
|
+
// that last shipped before the process restarted is still capable and
|
|
687
|
+
// the next fold ships a fresh surface event). When neither can, the
|
|
688
|
+
// watermark's tally-driven force tier + auto-handover is the relief
|
|
689
|
+
// path.
|
|
690
|
+
if (recentTrimInjectOf(sessionId) === "none" && !surfaceChannelCapable(sessionId)) {
|
|
691
|
+
decisionLog(`session=${sessionId} via=${via} action=none reason=injection-unavailable view=${Math.round(capture.viewTokens)}`);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
499
694
|
inFlight.add(sessionId);
|
|
500
695
|
try {
|
|
501
696
|
const { protectedTail, ...thresholds } = thresholdsNow();
|
|
502
697
|
const foldable = computeFoldableRange(capture.messages, store.ledgerOf(sessionId), protectedTail);
|
|
503
698
|
const windowTokens = await windowTokensFor(capture.model);
|
|
504
699
|
const ledger = store.ledgerOf(sessionId);
|
|
700
|
+
// Pressure tiers run on the REAL prompt size when the tally is known:
|
|
701
|
+
// max(view, wire). The view estimate misses the system/context
|
|
702
|
+
// sections, so a 136k wire once read as a 50k view and the tiers
|
|
703
|
+
// stayed silent. Unknown tally → exactly today's behavior.
|
|
704
|
+
const wireTokens = wireTokensOf(sessionId);
|
|
705
|
+
const pressureTokens = wireTokens !== null ? Math.max(capture.viewTokens, wireTokens) : capture.viewTokens;
|
|
505
706
|
const decision = evaluateTrigger({
|
|
506
707
|
foldable,
|
|
507
708
|
messages: capture.messages,
|
|
508
|
-
viewTokens:
|
|
709
|
+
viewTokens: pressureTokens,
|
|
509
710
|
windowTokens,
|
|
510
711
|
thresholds,
|
|
511
712
|
now: Date.now(),
|
|
@@ -514,15 +715,21 @@ export function applyContextTrim(ctx, config) {
|
|
|
514
715
|
lastEmergencyAt: lastEmergencyAtBySession.get(sessionId) ?? 0,
|
|
515
716
|
ledger,
|
|
516
717
|
});
|
|
517
|
-
decisionLog(`session=${sessionId} via=${via} action=${decision.action} tier=${decision.tier ?? "-"} reason=${decision.reason || "-"} view=${Math.round(capture.viewTokens)} window=${windowTokens ?? "null"}`);
|
|
718
|
+
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"}`);
|
|
518
719
|
if (decision.action === "none") return;
|
|
519
720
|
const outcome = await runFoldCycle(sessionId, decision);
|
|
721
|
+
// Failure ladder: commit resets it; a summarize/commit failure
|
|
722
|
+
// climbs it (the no-backoff loop this closes — those outcomes
|
|
723
|
+
// advance no cadence gate on their own).
|
|
724
|
+
if (outcome.startsWith("folded:")) clearFoldFailure(sessionId);
|
|
725
|
+
else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:")) recordFoldFailure(sessionId);
|
|
520
726
|
// Outcome lands in the decision log too: warn() has no reachable
|
|
521
727
|
// sink on some hosts, and the commit/fail boundary is exactly what
|
|
522
728
|
// live diagnosis needs.
|
|
523
729
|
decisionLog(`session=${sessionId} outcome=${outcome} tier=${decision.tier}`);
|
|
524
730
|
warn(`[context-trim] ${sessionId}: ${outcome} (${decision.reason})`);
|
|
525
731
|
} catch (error) {
|
|
732
|
+
recordFoldFailure(sessionId); // a thrown cycle counts as failed too
|
|
526
733
|
warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
|
|
527
734
|
} finally {
|
|
528
735
|
inFlight.delete(sessionId);
|
|
@@ -554,7 +761,13 @@ export function applyContextTrim(ctx, config) {
|
|
|
554
761
|
const model = captureModelOf(sessionId);
|
|
555
762
|
const windowTokens = model === null ? null : cachedWindowOf(model);
|
|
556
763
|
const growthBaseline = ledger !== null && Number.isFinite(ledger.lastFoldLowTokens) && ledger.lastFoldLowTokens > 0 ? ledger.lastFoldLowTokens : ledger !== null && Number.isFinite(ledger.lastFoldViewTokens) && ledger.lastFoldViewTokens > 0 ? ledger.lastFoldViewTokens : null;
|
|
557
|
-
|
|
764
|
+
// Honest wire status: the wire is only "raw" when NEITHER channel
|
|
765
|
+
// can reach it — a capable surface pass, or a ledger that already
|
|
766
|
+
// proves a SHIPPED replacement (a capture alone does not: it may
|
|
767
|
+
// never ship), keeps the marker off even after the in-memory
|
|
768
|
+
// recency window expired.
|
|
769
|
+
const wireRaw = recentTrimInjectOf(sessionId) === "none" && !surfaceChannelCapable(sessionId) && !ledgerShowsShippedReplacement(sessionId);
|
|
770
|
+
return renderTrimContextLine(label, {
|
|
558
771
|
enabled: enabledNow(),
|
|
559
772
|
mode: modeNow(),
|
|
560
773
|
viewTokens: record?.viewTokens ?? null,
|
|
@@ -562,6 +775,7 @@ return renderTrimContextLine(label, {
|
|
|
562
775
|
folds: Array.isArray(ledger?.blocks) ? ledger.blocks.length : 0,
|
|
563
776
|
growthTokens: record?.viewTokens !== null && record !== undefined && growthBaseline !== null ? record.viewTokens - growthBaseline : null,
|
|
564
777
|
lastModel: record?.lastModel ?? null,
|
|
778
|
+
wireRaw,
|
|
565
779
|
});
|
|
566
780
|
} catch {
|
|
567
781
|
return "";
|
|
@@ -590,7 +804,16 @@ return renderTrimContextLine(label, {
|
|
|
590
804
|
// agent }) — derive the session id per host/context-watch.js:122.
|
|
591
805
|
const sessionId = agentSessionIdOf(payload?.agent);
|
|
592
806
|
if (sessionId === null) return;
|
|
593
|
-
|
|
807
|
+
// Fold first, then the surface-replace idle sweep (design
|
|
808
|
+
// docs/surface-fold.md): a block committed by this idle pass
|
|
809
|
+
// ships on the same boundary.
|
|
810
|
+
void maybeFold(sessionId).finally(() => {
|
|
811
|
+
try {
|
|
812
|
+
void surfacePass.sweep(sessionId, "idle");
|
|
813
|
+
} catch {
|
|
814
|
+
/* sweep must never break the idle hook */
|
|
815
|
+
}
|
|
816
|
+
});
|
|
594
817
|
} catch {
|
|
595
818
|
/* idle hook must never throw */
|
|
596
819
|
}
|
|
@@ -727,6 +950,7 @@ return renderTrimContextLine(label, {
|
|
|
727
950
|
positions,
|
|
728
951
|
);
|
|
729
952
|
if (!result.ok) return `Fold rejected: ${result.reason}.`;
|
|
953
|
+
surfacePass.captureFoldSeqs(sessionId, result.block);
|
|
730
954
|
// Growth-gate baseline pair: pre-fold view (banner display) plus the
|
|
731
955
|
// post-fold low (tier-a anchor — keeps the gate armed after folds
|
|
732
956
|
// that commit at high view points).
|
|
@@ -826,5 +1050,5 @@ return renderTrimContextLine(label, {
|
|
|
826
1050
|
warn(`[context-trim] ctx.inject(llm) failed: ${error?.message ?? error}`);
|
|
827
1051
|
}
|
|
828
1052
|
|
|
829
|
-
return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, renderTrimContextLine: (agent) => renderTrimContextLine(agent, { enabled: enabledNow(), mode: modeNow(), viewTokens: null, windowTokens: null, folds: 0, growthTokens: null, lastModel: null }) };
|
|
1053
|
+
return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass, renderTrimContextLine: (agent) => renderTrimContextLine(agent, { enabled: enabledNow(), mode: modeNow(), viewTokens: null, windowTokens: null, folds: 0, growthTokens: null, lastModel: null, wireRaw: false }) };
|
|
830
1054
|
}
|
|
@@ -10,28 +10,50 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
/** Extract usage fields from a stream chunk. Returns
|
|
13
|
-
* { inputTokens, cachedTokens, outputTokens } or null when
|
|
14
|
-
* carries none. Defensive field walk — providers shape usage
|
|
15
|
-
* (usage.input_tokens, usage.prompt_tokens, cached_tokens,
|
|
16
|
-
* cache_read_input_tokens, ...).
|
|
13
|
+
* { inputTokens, cachedTokens, cacheWriteTokens, outputTokens } or null when
|
|
14
|
+
* the chunk carries none. Defensive field walk — providers shape usage
|
|
15
|
+
* differently (usage.input_tokens, usage.prompt_tokens, cached_tokens,
|
|
16
|
+
* cache_read_input_tokens, ...).
|
|
17
|
+
*
|
|
18
|
+
* Two shapes matter here. The host's own stream chunks carry a normalized
|
|
19
|
+
* TokenUsage ({ inputTokens, cacheReadTokens, cacheWriteTokens, ... }) where
|
|
20
|
+
* inputTokens counts UNCACHED prompt tokens only — the camelCase buckets do
|
|
21
|
+
* not overlap (llm-deepseek/src/translate.ts). Raw provider payloads use the
|
|
22
|
+
* snake_case twins, which on the Anthropic side have the same meaning. Only
|
|
23
|
+
* the OpenAI-style prompt_tokens shape would make inputTokens the inclusive
|
|
24
|
+
* total; summing there would over-count the prompt, which merely folds
|
|
25
|
+
* earlier — the safe direction for this plugin. */
|
|
17
26
|
export function extractUsage(chunk) {
|
|
18
27
|
if (chunk === null || typeof chunk !== "object") return null;
|
|
19
28
|
const candidates = [chunk.usage, chunk.metadata?.usage, chunk.response?.usage];
|
|
20
29
|
for (const usage of candidates) {
|
|
21
30
|
if (usage === null || typeof usage !== "object") continue;
|
|
22
31
|
const input = usage.input_tokens ?? usage.prompt_tokens ?? usage.inputTokens;
|
|
23
|
-
const cached = usage.cache_read_input_tokens ?? usage.cached_tokens ?? usage.cachedTokens ?? usage.prompt_tokens_details?.cached_tokens;
|
|
32
|
+
const cached = usage.cache_read_input_tokens ?? usage.cached_tokens ?? usage.cachedTokens ?? usage.cacheReadTokens ?? usage.prompt_tokens_details?.cached_tokens;
|
|
33
|
+
const cacheWrite = usage.cache_creation_input_tokens ?? usage.cache_write_tokens ?? usage.cacheWriteTokens;
|
|
24
34
|
const output = usage.output_tokens ?? usage.completion_tokens ?? usage.outputTokens;
|
|
25
|
-
if (!Number.isFinite(input) && !Number.isFinite(cached) && !Number.isFinite(output)) continue;
|
|
35
|
+
if (!Number.isFinite(input) && !Number.isFinite(cached) && !Number.isFinite(cacheWrite) && !Number.isFinite(output)) continue;
|
|
26
36
|
return {
|
|
27
37
|
inputTokens: Number.isFinite(input) ? input : null,
|
|
28
38
|
cachedTokens: Number.isFinite(cached) ? cached : null,
|
|
39
|
+
cacheWriteTokens: Number.isFinite(cacheWrite) ? cacheWrite : null,
|
|
29
40
|
outputTokens: Number.isFinite(output) ? output : null,
|
|
30
41
|
};
|
|
31
42
|
}
|
|
32
43
|
return null;
|
|
33
44
|
}
|
|
34
45
|
|
|
46
|
+
/** Total prompt tokens for one metrics record: uncached input + cache read +
|
|
47
|
+
* cache write (host TokenUsage buckets do not overlap). Null when no input
|
|
48
|
+
* sample has ever been recorded — callers must treat unknown as "no wire
|
|
49
|
+
* signal" and stay on the view estimate. Missing cache buckets count as 0. */
|
|
50
|
+
export function promptTokensOf(record) {
|
|
51
|
+
if (record === null || record === undefined) return null;
|
|
52
|
+
if (!Number.isFinite(record.lastInputTokens)) return null;
|
|
53
|
+
const total = record.lastInputTokens + (Number.isFinite(record.cachedTokens) ? record.cachedTokens : 0) + (Number.isFinite(record.cacheWriteTokens) ? record.cacheWriteTokens : 0);
|
|
54
|
+
return total > 0 ? Math.round(total) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
35
57
|
/** Per-session metrics store. */
|
|
36
58
|
export function createMetrics() {
|
|
37
59
|
const sessions = new Map(); // sessionId -> record
|
|
@@ -40,8 +62,9 @@ export function createMetrics() {
|
|
|
40
62
|
let record = sessions.get(sessionId);
|
|
41
63
|
if (record === undefined) {
|
|
42
64
|
record = {
|
|
43
|
-
lastInputTokens: null,
|
|
44
|
-
cachedTokens: null,
|
|
65
|
+
lastInputTokens: null, // UNCACHED prompt tokens of the last sample
|
|
66
|
+
cachedTokens: null, // cache-read bucket of the last sample
|
|
67
|
+
cacheWriteTokens: null, // cache-write bucket of the last sample
|
|
45
68
|
outputTokens: null,
|
|
46
69
|
viewTokens: null, // view estimate noted by the listener pre-request
|
|
47
70
|
lastModel: null, // { provider, model } of the last stream
|
|
@@ -75,6 +98,7 @@ export function createMetrics() {
|
|
|
75
98
|
const record = recordOf(sessionId);
|
|
76
99
|
if (usage.inputTokens !== null) record.lastInputTokens = usage.inputTokens;
|
|
77
100
|
if (usage.cachedTokens !== null) record.cachedTokens = usage.cachedTokens;
|
|
101
|
+
if (usage.cacheWriteTokens !== null) record.cacheWriteTokens = usage.cacheWriteTokens;
|
|
78
102
|
if (usage.outputTokens !== null) record.outputTokens = usage.outputTokens;
|
|
79
103
|
return record;
|
|
80
104
|
},
|
|
@@ -97,7 +97,7 @@ export function renderMaterial(messages, startIdx, endIdx, refOf) {
|
|
|
97
97
|
parts.push(head + body);
|
|
98
98
|
chars += head.length + body.length;
|
|
99
99
|
}
|
|
100
|
-
if (truncated) parts.push(`--- [...material truncated after ${chars} chars; ${endIdx - startIdx + 1 -
|
|
100
|
+
if (truncated) parts.push(`--- [...material truncated after ${chars} chars; ${endIdx - startIdx + 1 - parts.length} messages dropped from this render...] ---\n`);
|
|
101
101
|
return parts.join("");
|
|
102
102
|
}
|
|
103
103
|
|