dsh-switchman 1.10.3 → 1.11.1
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 +11 -10
- package/README.es.md +11 -10
- package/README.fr.md +11 -10
- package/README.it.md +11 -10
- package/README.ja.md +11 -10
- package/README.ko.md +11 -10
- package/README.md +19 -10
- package/README.pt.md +11 -10
- package/README.ru.md +11 -10
- package/README.zh-TW.md +11 -10
- package/README.zh.md +11 -10
- package/client.js +326 -43
- package/cordis.patch.yml +24 -21
- package/host/config.js +57 -10
- package/host/context-trim/fork.js +5 -1
- package/host/context-trim/index.js +340 -183
- package/host/context-trim/metrics.js +22 -14
- package/host/context-trim/state.js +351 -50
- package/host/context-trim/summarizer.js +147 -33
- package/host/context-trim/surface.js +222 -2
- package/host/context-trim/trigger.js +170 -51
- package/host/context-trim/view.js +132 -16
- package/host/context-watch.js +81 -73
- package/host/dispatch.js +30 -5
- package/host/routes.js +64 -4
- package/host/teams.js +2 -1
- package/package.json +2 -2
|
@@ -3,21 +3,31 @@
|
|
|
3
3
|
* The trigger half of the port (design §4/§5): decides whether a fold
|
|
4
4
|
* should run NOW. Called only at idle boundaries (agent/status idle) —
|
|
5
5
|
* never inside llm/stream, never while a fold for the same session is in
|
|
6
|
-
* flight.
|
|
6
|
+
* flight. Tiers, checked emergency-first:
|
|
7
7
|
*
|
|
8
8
|
* - emergency : view usage >= trimEmergencyPercent of the context window.
|
|
9
|
-
* Forced MECHANICAL fold (no side-model round trip — at
|
|
10
|
-
* the session cannot afford the latency or the failure).
|
|
11
|
-
* - pressure : view usage >= trimPressurePercent. Side-model fold.
|
|
9
|
+
* Forced MECHANICAL T1 fold (no side-model round trip — at
|
|
10
|
+
* 95% the session cannot afford the latency or the failure).
|
|
11
|
+
* - pressure : view usage >= trimPressurePercent. Side-model T1 fold.
|
|
12
12
|
* - growth : growth since the last fold's post-fold low (the anchor
|
|
13
13
|
* that keeps the gate armed after folds committing at high
|
|
14
14
|
* view points) > trimGrowthTokens AND the contiguous
|
|
15
15
|
* compressible increment >= trimIncrementTokens. The
|
|
16
16
|
* primary, cache-friendly gate (the bili heuristic).
|
|
17
|
+
* - ladder T3 : active-T2 summary mass/count gates met (docs/ladder-fold.md).
|
|
18
|
+
* - ladder T2 : active-T1 summary mass/count gates met.
|
|
19
|
+
*
|
|
20
|
+
* The ladder (T2/T3 distillation) is a MAINTENANCE path: raw T1 folds stay
|
|
21
|
+
* the primary cadence and ladder folds only fire when the summary
|
|
22
|
+
* population itself has grown worth reclaiming (mass gates — bili #379:
|
|
23
|
+
* block count is not a need signal; the count floor only filters
|
|
24
|
+
* degenerate runs). A ladder fold rewrites the wire from its first consumed
|
|
25
|
+
* summary onward (bili #1249), so the mass gates keep it rare.
|
|
17
26
|
*
|
|
18
27
|
* Side-model folds are additionally throttled by trimMaxFoldsPerHour and a
|
|
19
28
|
* hard minimum interval between folds; emergency folds have their own
|
|
20
|
-
* cooldown so a stuck-at-95% session folds at most once per window
|
|
29
|
+
* cooldown so a stuck-at-95% session folds at most once per window; ladder
|
|
30
|
+
* folds observe a longer interval (LADDER_MIN_INTERVAL_MS) after ANY fold.
|
|
21
31
|
* Everything is derived from measured inputs — no clock, no host access —
|
|
22
32
|
* which keeps the decision unit-testable (golden thresholds).
|
|
23
33
|
*/
|
|
@@ -30,10 +40,18 @@ export const FOLD_MIN_INTERVAL_MS = 60_000;
|
|
|
30
40
|
/** Minimum interval between emergency (mechanical) folds. */
|
|
31
41
|
export const EMERGENCY_COOLDOWN_MS = 90_000;
|
|
32
42
|
|
|
43
|
+
/** Minimum wall-clock interval between ladder (T2/T3 distillation) folds,
|
|
44
|
+
* measured from ANY fold: every fold rebuilds the serialized view, and
|
|
45
|
+
* interleaving a distillation between raw folds would churn the tail twice
|
|
46
|
+
* per minute for marginal reclaim. */
|
|
47
|
+
export const LADDER_MIN_INTERVAL_MS = 120_000;
|
|
48
|
+
|
|
33
49
|
/** The foldable span of a captured messages array for one ledger: from the
|
|
34
|
-
* first segment boundary strictly after the frontier
|
|
35
|
-
*
|
|
36
|
-
*
|
|
50
|
+
* first segment boundary strictly after the frontier AND after the task
|
|
51
|
+
* intent (the first user message — bili's protected zone: the session's
|
|
52
|
+
* opening task statement is never folded) to the last segment end that
|
|
53
|
+
* still clears the protected tail. Returns { startIdx, endIdx } or null
|
|
54
|
+
* when nothing is foldable (empty history, everything folded, or
|
|
37
55
|
* protected tail covers it all). */
|
|
38
56
|
export function computeFoldableRange(messages, ledger, protectedTail) {
|
|
39
57
|
if (!Array.isArray(messages) || messages.length === 0) return null;
|
|
@@ -54,9 +72,22 @@ export function computeFoldableRange(messages, ledger, protectedTail) {
|
|
|
54
72
|
// system/message over exactly that node). Covers both the frontier-null
|
|
55
73
|
// first fold and any frontier-set fold that lands on the head.
|
|
56
74
|
if (messages[0]?.role === "system") startFrom = Math.max(startFrom, 1);
|
|
75
|
+
// Task-intent protection: the first user message states the task; the
|
|
76
|
+
// segment containing it is never folded (its summary would become the
|
|
77
|
+
// only carrier of the task for the rest of the session — the dominant
|
|
78
|
+
// early-drift failure this guard prevents).
|
|
79
|
+
const firstUserIdx = messages.findIndex((message) => message?.role === "user");
|
|
80
|
+
if (firstUserIdx >= 0) {
|
|
81
|
+
for (const [start, end] of segmentBounds(messages)) {
|
|
82
|
+
if (start <= firstUserIdx && firstUserIdx <= end) {
|
|
83
|
+
startFrom = Math.max(startFrom, end + 1);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
57
88
|
let best = null;
|
|
58
89
|
for (const [start, end] of segmentBounds(messages)) {
|
|
59
|
-
if (start < startFrom) continue; // behind the frontier
|
|
90
|
+
if (start < startFrom) continue; // behind the frontier / intent
|
|
60
91
|
if (end >= tailStart) break; // segment would cut into the protected tail
|
|
61
92
|
if (best === null) best = { startIdx: start, endIdx: end };
|
|
62
93
|
else best.endIdx = end; // extend the contiguous span
|
|
@@ -64,66 +95,154 @@ export function computeFoldableRange(messages, ledger, protectedTail) {
|
|
|
64
95
|
return best;
|
|
65
96
|
}
|
|
66
97
|
|
|
98
|
+
/** The best ladder RUN for one tier from precomputed runs
|
|
99
|
+
* (index.js: ladderRunsOf — maximal ledger-consecutive runs of active
|
|
100
|
+
* same-tier blocks with their summary mass). Picks the run with the
|
|
101
|
+
* largest mass among those meeting the count floor; null when none does. */
|
|
102
|
+
function bestLadderRun(runs, minBlocks) {
|
|
103
|
+
let best = null;
|
|
104
|
+
for (const run of Array.isArray(runs) ? runs : []) {
|
|
105
|
+
if (!Number.isFinite(run?.mass) || !Array.isArray(run?.consumes) || run.consumes.length === 0) continue;
|
|
106
|
+
if (run.consumes.length < minBlocks) continue;
|
|
107
|
+
if (best === null || run.mass > best.mass) best = run;
|
|
108
|
+
}
|
|
109
|
+
return best;
|
|
110
|
+
}
|
|
111
|
+
|
|
67
112
|
/** Evaluate the trigger. input: {
|
|
68
113
|
* foldable: { startIdx, endIdx } | null, // computeFoldableRange result
|
|
69
114
|
* messages, // captured raw messages (increment measurement)
|
|
70
115
|
* viewTokens, // outbound-view token estimate of the current history
|
|
71
116
|
* windowTokens, // context window size, or null when unknown
|
|
72
|
-
* thresholds: { growthTokens, incrementTokens, pressurePercent, emergencyPercent, maxFoldsPerHour
|
|
117
|
+
* thresholds: { growthTokens, incrementTokens, pressurePercent, emergencyPercent, maxFoldsPerHour,
|
|
118
|
+
* ladderT2Tokens, ladderT2MinBlocks, ladderT3Tokens, ladderT3MinBlocks },
|
|
73
119
|
* now, lastFoldAt, foldsThisHour, lastEmergencyAt,
|
|
120
|
+
* ledger, // the session ledger (growth anchor fields)
|
|
121
|
+
* ladderRuns: { t2: [{ consumes, mass, count }], t3: [...] }, // census runs
|
|
74
122
|
* }.
|
|
75
|
-
* Returns { action: "none"|"fold"|"emergency",
|
|
123
|
+
* Returns { action: "none"|"fold"|"emergency", kind: "raw"|"ladder", tier,
|
|
124
|
+
* reason, startIdx, endIdx, incrementTokens, consumes? }. */
|
|
76
125
|
export function evaluateTrigger(input) {
|
|
77
|
-
const none = { action: "none", tier: null, reason: "", startIdx: -1, endIdx: -1, incrementTokens: 0 };
|
|
78
|
-
const foldable = input?.foldable ?? null;
|
|
79
|
-
if (foldable === null) return { ...none, reason: "nothing-foldable" };
|
|
80
|
-
const { startIdx, endIdx } = foldable;
|
|
126
|
+
const none = { action: "none", kind: "raw", tier: null, reason: "", startIdx: -1, endIdx: -1, incrementTokens: 0, consumes: null };
|
|
81
127
|
const thresholds = input?.thresholds ?? {};
|
|
82
128
|
const now = Number.isFinite(input?.now) ? input.now : Date.now();
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// Tier c — emergency: window pressure, mechanical, cooldown-gated.
|
|
87
|
-
const windowTokens = Number.isFinite(input?.windowTokens) && input.windowTokens > 0 ? input.windowTokens : null;
|
|
88
|
-
const emergencyPercent = Number.isFinite(thresholds.emergencyPercent) && thresholds.emergencyPercent > 0 ? thresholds.emergencyPercent : 95;
|
|
89
|
-
if (windowTokens !== null) {
|
|
90
|
-
const usagePct = (input.viewTokens / windowTokens) * 100;
|
|
91
|
-
if (usagePct >= emergencyPercent) {
|
|
92
|
-
const lastEmergencyAt = Number.isFinite(input?.lastEmergencyAt) ? input.lastEmergencyAt : 0;
|
|
93
|
-
if (now - lastEmergencyAt < EMERGENCY_COOLDOWN_MS) return { ...none, reason: "emergency-cooldown" };
|
|
94
|
-
return { action: "emergency", tier: "emergency", reason: `usage ${usagePct.toFixed(1)}% >= ${emergencyPercent}%`, startIdx, endIdx, incrementTokens };
|
|
95
|
-
}
|
|
96
|
-
}
|
|
129
|
+
const foldable = input?.foldable ?? null;
|
|
130
|
+
const incrementTokens = foldable === null ? 0 : estimateTokensOf(input?.messages ?? [], foldable.startIdx, foldable.endIdx);
|
|
97
131
|
|
|
98
|
-
// Side-model folds (pressure + growth) share the cadence gates.
|
|
132
|
+
// Side-model folds (pressure + growth + ladder) share the cadence gates.
|
|
99
133
|
const lastFoldAt = Number.isFinite(input?.lastFoldAt) ? input.lastFoldAt : 0;
|
|
100
134
|
const hourly = Array.isArray(input?.foldsThisHour) ? input.foldsThisHour.filter((ts) => now - ts < 3_600_000).length : 0;
|
|
101
|
-
const maxFoldsPerHour = Number.isInteger(thresholds.maxFoldsPerHour) && thresholds.maxFoldsPerHour > 0 ? thresholds.maxFoldsPerHour :
|
|
102
|
-
|
|
103
|
-
if (now - lastFoldAt < FOLD_MIN_INTERVAL_MS) return { ...none, reason: "min-interval" };
|
|
135
|
+
const maxFoldsPerHour = Number.isInteger(thresholds.maxFoldsPerHour) && thresholds.maxFoldsPerHour > 0 ? thresholds.maxFoldsPerHour : 30;
|
|
136
|
+
const hourlyBlocked = hourly >= maxFoldsPerHour;
|
|
104
137
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
138
|
+
if (foldable !== null && incrementTokens >= 1) {
|
|
139
|
+
const { startIdx, endIdx } = foldable;
|
|
140
|
+
// Tier c — emergency: window pressure, mechanical, cooldown-gated.
|
|
141
|
+
const windowTokens = Number.isFinite(input?.windowTokens) && input.windowTokens > 0 ? input.windowTokens : null;
|
|
142
|
+
const emergencyPercent = Number.isFinite(thresholds.emergencyPercent) && thresholds.emergencyPercent > 0 ? thresholds.emergencyPercent : 95;
|
|
143
|
+
if (windowTokens !== null) {
|
|
144
|
+
const usagePct = (input.viewTokens / windowTokens) * 100;
|
|
145
|
+
if (usagePct >= emergencyPercent) {
|
|
146
|
+
const lastEmergencyAt = Number.isFinite(input?.lastEmergencyAt) ? input.lastEmergencyAt : 0;
|
|
147
|
+
if (now - lastEmergencyAt < EMERGENCY_COOLDOWN_MS) {
|
|
148
|
+
// [2026-11-07]-[fix ladder starvation under sustained emergency
|
|
149
|
+
// pressure]-[when usage stays above the emergency line the 90 s
|
|
150
|
+
// cooldown returned "none" every boundary and the lower-priority
|
|
151
|
+
// ladder never ran — the summary population grew unbounded,
|
|
152
|
+
// defeating T2/T3's aging goal. During cooldown a READY ladder
|
|
153
|
+
// is the only side-model path that can relieve pressure, so it
|
|
154
|
+
// cuts in here; every other case keeps the exact
|
|
155
|
+
// "emergency-cooldown" row. Inlined on purpose: the main ladder
|
|
156
|
+
// section below keeps its original position and semantics.]
|
|
157
|
+
const runs = input?.ladderRuns ?? {};
|
|
158
|
+
const t3R = bestLadderRun(runs.t3, Number.isInteger(thresholds.ladderT3MinBlocks) && thresholds.ladderT3MinBlocks > 0 ? thresholds.ladderT3MinBlocks : 2);
|
|
159
|
+
const t2R = bestLadderRun(runs.t2, Number.isInteger(thresholds.ladderT2MinBlocks) && thresholds.ladderT2MinBlocks > 0 ? thresholds.ladderT2MinBlocks : 3);
|
|
160
|
+
const t3M = Number.isFinite(thresholds.ladderT3Tokens) && thresholds.ladderT3Tokens > 0 ? thresholds.ladderT3Tokens : 6_000;
|
|
161
|
+
const t2M = Number.isFinite(thresholds.ladderT2Tokens) && thresholds.ladderT2Tokens > 0 ? thresholds.ladderT2Tokens : 8_000;
|
|
162
|
+
const pick = t3R !== null && t3R.mass >= t3M && (t2R === null || t2R.mass < t2M || t3R.mass >= t2R.mass) ? t3R
|
|
163
|
+
: (t2R !== null && t2R.mass >= t2M ? t2R : null);
|
|
164
|
+
// The 120 s LADDER_MIN_INTERVAL_MS is NOT applied here on
|
|
165
|
+
// purpose: the emergency fold that opened this cooldown just
|
|
166
|
+
// advanced lastFoldAt, so the interval (120 s) always exceeds
|
|
167
|
+
// the remaining cooldown (90 s) and the cut-in could never
|
|
168
|
+
// fire — a structural starvation loop (observed live). Inside
|
|
169
|
+
// the cooldown the hourly cap is the only cadence gate; the
|
|
170
|
+
// cooldown itself is the spacing.
|
|
171
|
+
if (pick !== null && !hourlyBlocked) {
|
|
172
|
+
return {
|
|
173
|
+
action: "fold",
|
|
174
|
+
kind: "ladder",
|
|
175
|
+
tier: pick === t3R ? "ladder-3" : "ladder-2",
|
|
176
|
+
reason: `ladder mass ~${Math.round(pick.mass)} blocks=${pick.consumes.length} (emergency-cooldown cut-in)`,
|
|
177
|
+
startIdx: -1,
|
|
178
|
+
endIdx: -1,
|
|
179
|
+
incrementTokens: 0,
|
|
180
|
+
consumes: pick.consumes.slice(),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return { ...none, reason: "emergency-cooldown" };
|
|
184
|
+
}
|
|
185
|
+
return { action: "emergency", kind: "raw", tier: "emergency", reason: `usage ${usagePct.toFixed(1)}% >= ${emergencyPercent}%`, startIdx, endIdx, incrementTokens, consumes: null };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!hourlyBlocked && now - lastFoldAt >= FOLD_MIN_INTERVAL_MS) {
|
|
189
|
+
// Tier b — pressure: soft window threshold, side-model.
|
|
190
|
+
const pressurePercent = Number.isFinite(thresholds.pressurePercent) && thresholds.pressurePercent > 0 ? thresholds.pressurePercent : 65;
|
|
191
|
+
if (windowTokens !== null) {
|
|
192
|
+
const usagePct = (input.viewTokens / windowTokens) * 100;
|
|
193
|
+
if (usagePct >= pressurePercent) {
|
|
194
|
+
return { action: "fold", kind: "raw", tier: "pressure", reason: `usage ${usagePct.toFixed(1)}% >= ${pressurePercent}%`, startIdx, endIdx, incrementTokens, consumes: null };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Tier a — growth gate: the primary cache-friendly heuristic.
|
|
198
|
+
const growthTokens = Number.isFinite(thresholds.growthTokens) && thresholds.growthTokens > 0 ? thresholds.growthTokens : 22_500;
|
|
199
|
+
const incrementGate = Number.isFinite(thresholds.incrementTokens) && thresholds.incrementTokens > 0 ? thresholds.incrementTokens : 25_000;
|
|
200
|
+
// Growth anchor: the post-fold low when known (keeps the gate armed
|
|
201
|
+
// after folds that commit at high view points), else the pre-fold
|
|
202
|
+
// view for ledgers written before lastFoldLowTokens existed.
|
|
203
|
+
const anchor = Number.isFinite(input?.ledger?.lastFoldLowTokens) && input.ledger.lastFoldLowTokens > 0
|
|
204
|
+
? input.ledger.lastFoldLowTokens
|
|
205
|
+
: Number.isFinite(input?.ledger?.lastFoldViewTokens) ? input.ledger.lastFoldViewTokens : 0;
|
|
206
|
+
const growth = input.viewTokens - anchor;
|
|
207
|
+
if (growth > growthTokens && incrementTokens >= incrementGate) {
|
|
208
|
+
return { action: "fold", kind: "raw", tier: "growth", reason: `growth ~${Math.round(growth)} > ${growthTokens} and increment ~${incrementTokens} >= ${incrementGate}`, startIdx, endIdx, incrementTokens, consumes: null };
|
|
209
|
+
}
|
|
111
210
|
}
|
|
112
211
|
}
|
|
113
212
|
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
213
|
+
// Ladder maintenance tiers (T3 before T2 — the deeper distillation is
|
|
214
|
+
// the larger reclaim when both are ready). Mass gates carry bili #198's
|
|
215
|
+
// minimum-benefit idea: a sub-threshold distill never fires, so the
|
|
216
|
+
// trigger cannot enter a zero-yield reset loop.
|
|
217
|
+
const ladderRuns = input?.ladderRuns ?? {};
|
|
218
|
+
const t3Blocks = Number.isInteger(thresholds.ladderT3MinBlocks) && thresholds.ladderT3MinBlocks > 0 ? thresholds.ladderT3MinBlocks : 2;
|
|
219
|
+
const t3MassGate = Number.isFinite(thresholds.ladderT3Tokens) && thresholds.ladderT3Tokens > 0 ? thresholds.ladderT3Tokens : 6_000;
|
|
220
|
+
const t2Blocks = Number.isInteger(thresholds.ladderT2MinBlocks) && thresholds.ladderT2MinBlocks > 0 ? thresholds.ladderT2MinBlocks : 3;
|
|
221
|
+
const t2MassGate = Number.isFinite(thresholds.ladderT2Tokens) && thresholds.ladderT2Tokens > 0 ? thresholds.ladderT2Tokens : 8_000;
|
|
222
|
+
const t3Run = bestLadderRun(ladderRuns.t3, t3Blocks);
|
|
223
|
+
const t2Run = bestLadderRun(ladderRuns.t2, t2Blocks);
|
|
224
|
+
if (t3Run !== null || t2Run !== null) {
|
|
225
|
+
if (now - lastFoldAt < LADDER_MIN_INTERVAL_MS) return { ...none, reason: "ladder-min-interval" };
|
|
226
|
+
if (hourlyBlocked) return { ...none, reason: "hourly-cap" };
|
|
227
|
+
const run = t3Run !== null && (t2Run === null || t3Run.mass >= t2Run.mass) ? t3Run : t2Run;
|
|
228
|
+
const tier = t3Run !== null && (t2Run === null || t3Run.mass >= t2Run.mass) ? "ladder-3" : "ladder-2";
|
|
229
|
+
const gate = tier === "ladder-3" ? t3MassGate : t2MassGate;
|
|
230
|
+
// The run passed its count floor; the mass gate decides readiness.
|
|
231
|
+
if (run.mass < gate) return { ...none, reason: "below-thresholds" };
|
|
232
|
+
return {
|
|
233
|
+
action: "fold",
|
|
234
|
+
kind: "ladder",
|
|
235
|
+
tier,
|
|
236
|
+
reason: `ladder mass ~${Math.round(run.mass)} >= ${gate} blocks=${run.consumes.length}`,
|
|
237
|
+
startIdx: -1,
|
|
238
|
+
endIdx: -1,
|
|
239
|
+
incrementTokens: 0,
|
|
240
|
+
consumes: run.consumes.slice(),
|
|
241
|
+
};
|
|
126
242
|
}
|
|
127
243
|
|
|
244
|
+
if (foldable === null) return { ...none, reason: "nothing-foldable" };
|
|
245
|
+
if (incrementTokens < 1) return { ...none, reason: "empty-increment" };
|
|
246
|
+
if (hourlyBlocked) return { ...none, reason: "hourly-cap" };
|
|
128
247
|
return { ...none, reason: "below-thresholds" };
|
|
129
248
|
}
|
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
* (the golden test pins this), because a stable serialized prefix between
|
|
9
9
|
* folds is exactly what keeps provider prompt-cache hits alive.
|
|
10
10
|
*
|
|
11
|
+
* LADDER VIEW (docs/ladder-fold.md): the rebuild is a deterministic
|
|
12
|
+
* three-pass overlay. Pass 1 locates every T1 block — active AND consumed —
|
|
13
|
+
* on the raw messages (a consumed T1 summary is the ladder's raw material,
|
|
14
|
+
* so it must render). Pass 2 replaces each active T2 block's consumed-run
|
|
15
|
+
* summary messages with the T2 summary; pass 3 does the same for active T3
|
|
16
|
+
* blocks over the T2 summaries. Fail-open: a ladder block whose consumed
|
|
17
|
+
* run is missing, non-adjacent, or already covered is skipped for this
|
|
18
|
+
* view, never guessed.
|
|
19
|
+
*
|
|
11
20
|
* Message refs: messages at the llm/stream boundary are plain JSON-ish
|
|
12
21
|
* objects ({id?, role, content blocks / text}). An explicit id is trusted
|
|
13
22
|
* only when it looks addressable (short, no whitespace/control chars — the
|
|
@@ -25,6 +34,13 @@
|
|
|
25
34
|
|
|
26
35
|
import { createHash } from "node:crypto";
|
|
27
36
|
|
|
37
|
+
/** Ladder-block probe, inlined to keep the state.js ⇄ view.js import graph
|
|
38
|
+
* acyclic (state.js imports this module's estimators). A ladder block is
|
|
39
|
+
* an active distillation of other blocks: tier 2 or 3, no raw refRange. */
|
|
40
|
+
function isLadderBlock(block) {
|
|
41
|
+
return block !== null && typeof block === "object" && (block.tier === 2 || block.tier === 3);
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
/** Cheap char → token estimate (the brief's char/4 heuristic). Applies to
|
|
29
45
|
* ASCII/latin text; CJK text tokenizes far denser (see below). */
|
|
30
46
|
export const TOKENS_PER_CHAR = 1 / 4;
|
|
@@ -226,14 +242,18 @@ export function rangeIsSegmentAligned(messages, startIdx, endIdx) {
|
|
|
226
242
|
|
|
227
243
|
// ---- fold blocks ------------------------------------------------------------
|
|
228
244
|
|
|
229
|
-
/** Deterministic wire text of one fold block's synthetic message.
|
|
245
|
+
/** Deterministic wire text of one fold block's synthetic message. The
|
|
246
|
+
* [SWITCHMAN:TRIM] content prefix is the downstream recognition anchor
|
|
247
|
+
* (surface.js, session tooling) and stays IDENTICAL for every tier — the
|
|
248
|
+
* ladder lives in the ledger, not in the wire text shape. */
|
|
230
249
|
export function renderSummaryText(block) {
|
|
231
250
|
return `[SWITCHMAN:TRIM] ${block.summary}`;
|
|
232
251
|
}
|
|
233
252
|
|
|
234
253
|
/** The ONE synthetic message replacing a folded range (brief: synthetic
|
|
235
254
|
* user message; id carries the acp_summary_ prefix downstream tooling
|
|
236
|
-
* skips). Pure — same block, same bytes.
|
|
255
|
+
* skips). Pure — same block, same bytes. The ladder replaces messages BY
|
|
256
|
+
* this id (id-suffix = block id), which is why ids must stay stable. */
|
|
237
257
|
export function summaryMessageOf(block) {
|
|
238
258
|
return {
|
|
239
259
|
id: `acp_summary_${block.id}`,
|
|
@@ -284,20 +304,107 @@ function locateBlock(positions, block, afterIdx) {
|
|
|
284
304
|
return span === expected ? [firstIdx, lastIdx] : null;
|
|
285
305
|
}
|
|
286
306
|
|
|
307
|
+
// ---- ladder passes ------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
/** Summary-id positions of one intermediate view: id suffix (the block id)
|
|
310
|
+
* -> index, for every synthetic message. One index per id (a block renders
|
|
311
|
+
* at most once — single consumption). */
|
|
312
|
+
function summaryPositionsOf(messages) {
|
|
313
|
+
const prefix = "acp_summary_";
|
|
314
|
+
const positions = new Map();
|
|
315
|
+
messages.forEach((message, index) => {
|
|
316
|
+
const id = message?.id;
|
|
317
|
+
if (typeof id !== "string" || !id.startsWith(prefix)) return;
|
|
318
|
+
positions.set(id.slice(prefix.length), index);
|
|
319
|
+
});
|
|
320
|
+
return positions;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Validate one ladder block's consumed run against a positions map: every
|
|
324
|
+
* consumed id present, indices strictly consecutive ascending. Returns
|
|
325
|
+
* [firstIdx, lastIdx] or null (fail-open skip). */
|
|
326
|
+
function locateLadderRun(positions, block) {
|
|
327
|
+
const consumes = Array.isArray(block?.consumes) ? block.consumes : [];
|
|
328
|
+
if (consumes.length === 0) return null;
|
|
329
|
+
let prev = null;
|
|
330
|
+
let firstIdx = -1;
|
|
331
|
+
for (const id of consumes) {
|
|
332
|
+
const at = positions.get(id);
|
|
333
|
+
if (at === undefined) return null;
|
|
334
|
+
if (prev !== null && at !== prev + 1) return null;
|
|
335
|
+
prev = at;
|
|
336
|
+
if (firstIdx < 0) firstIdx = at;
|
|
337
|
+
}
|
|
338
|
+
return [firstIdx, prev];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Apply one tier's ladder pass to the working view. Participation (docs/
|
|
342
|
+
* ladder-fold.md): ACTIVE tier-`tier` blocks replace their consumed runs —
|
|
343
|
+
* AND inactive tier-`tier` blocks consumed by an ACTIVE tier-`tier+1` block
|
|
344
|
+
* render their summary as pure ANCHOR material (a T3's consumed T2 summary
|
|
345
|
+
* must exist in the intermediate view or the T3 pass could never locate
|
|
346
|
+
* its run). Anchors replaced by the next pass do not double-count
|
|
347
|
+
* `applied`. Entries that fail their adjacency/completeness check are
|
|
348
|
+
* skipped (never guessed); valid runs are disjoint by construction (single
|
|
349
|
+
* consumption) and applied from the highest start index backward, so
|
|
350
|
+
* earlier indices stay valid while splicing. Returns the new array. */
|
|
351
|
+
function applyLadderTier(messages, ladderBlocks, tier, state) {
|
|
352
|
+
if (ladderBlocks.length === 0) return messages;
|
|
353
|
+
const participants = [];
|
|
354
|
+
for (const block of ladderBlocks) {
|
|
355
|
+
if (block.tier !== tier) continue;
|
|
356
|
+
if (block.active === true) {
|
|
357
|
+
participants.push(block);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
// Inactive block: an anchor only when an ACTIVE next-tier block
|
|
361
|
+
// directly consumed it.
|
|
362
|
+
const consumedByActive = ladderBlocks.some((other) => other.active === true && other.tier === tier + 1 && Array.isArray(other.consumes) && other.consumes.includes(block.id));
|
|
363
|
+
if (consumedByActive) participants.push(block);
|
|
364
|
+
}
|
|
365
|
+
if (participants.length === 0) return messages;
|
|
366
|
+
const positions = summaryPositionsOf(messages);
|
|
367
|
+
const runs = [];
|
|
368
|
+
for (const block of participants) {
|
|
369
|
+
const run = locateLadderRun(positions, block);
|
|
370
|
+
if (run === null) continue;
|
|
371
|
+
runs.push({ block, firstIdx: run[0], lastIdx: run[1] });
|
|
372
|
+
}
|
|
373
|
+
runs.sort((a, b) => b.firstIdx - a.firstIdx); // apply back-to-front
|
|
374
|
+
const out = messages.slice();
|
|
375
|
+
for (const run of runs) {
|
|
376
|
+
if (run.lastIdx >= out.length) continue;
|
|
377
|
+
// Splice the run out and drop the ladder summary in its place.
|
|
378
|
+
out.splice(run.firstIdx, run.lastIdx - run.firstIdx + 1, summaryMessageOf(run.block));
|
|
379
|
+
if (run.block.active === true) state.applied += 1;
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
|
|
287
384
|
/** Build the outbound view: apply every applicable fold block to a copy of
|
|
288
385
|
* the messages array. Pure (never mutates the input; the assembly splices
|
|
289
|
-
* the result into the live request array).
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
386
|
+
* the result into the live request array). The rebuild is deterministic
|
|
387
|
+
* three-pass ladder overlay (docs/ladder-fold.md):
|
|
388
|
+
* 1. every T1 block — active AND consumed — locates on raw messages and
|
|
389
|
+
* splices its summary (consumed T1 summaries are the ladder's material);
|
|
390
|
+
* 2. active T2 blocks replace their consumed runs of T1 summaries;
|
|
391
|
+
* 3. active T3 blocks replace their consumed runs of T2 summaries.
|
|
392
|
+
* A block that cannot be located, overlaps an already-applied one, or
|
|
393
|
+
* reaches into the last protectedTail messages is skipped. Returns
|
|
394
|
+
* { messages, applied, foldedMessages, unlocated } (foldedMessages = live
|
|
395
|
+
* raw messages the applied T1 blocks replaced). */
|
|
294
396
|
export function buildView(messages, ledger, options = {}) {
|
|
295
397
|
const protectedTail = Number.isInteger(options.protectedTail) && options.protectedTail > 0 ? options.protectedTail : 0;
|
|
296
398
|
const blocks = Array.isArray(ledger?.blocks) ? ledger.blocks : [];
|
|
399
|
+
const t1Blocks = blocks.filter((block) => block !== null && typeof block === "object" && block.refRange !== null);
|
|
400
|
+
// Ladder blocks participate regardless of active: ACTIVE ones replace
|
|
401
|
+
// their consumed runs; INACTIVE ones may be pure anchors for the next
|
|
402
|
+
// tier's pass (applyLadderTier decides per block).
|
|
403
|
+
const ladderBlocks = blocks.filter((block) => block !== null && typeof block === "object" && isLadderBlock(block));
|
|
297
404
|
const positions = refPositions(messages);
|
|
298
405
|
const located = [];
|
|
299
406
|
let unlocated = 0;
|
|
300
|
-
for (const block of
|
|
407
|
+
for (const block of t1Blocks) {
|
|
301
408
|
const range = locateBlock(positions, block, -1);
|
|
302
409
|
if (range !== null) located.push({ block, firstIdx: range[0], lastIdx: range[1] });
|
|
303
410
|
else unlocated += 1;
|
|
@@ -322,17 +429,26 @@ export function buildView(messages, ledger, options = {}) {
|
|
|
322
429
|
applied += 1;
|
|
323
430
|
}
|
|
324
431
|
for (let i = at; i < messages.length; i++) out.push(messages[i]);
|
|
325
|
-
|
|
432
|
+
// Ladder passes, ascending tier (T3 anchors are T2 summaries that only
|
|
433
|
+
// exist after the T2 pass ran).
|
|
434
|
+
const ladderState = { applied };
|
|
435
|
+
let ladderOut = applyLadderTier(out, ladderBlocks, 2, ladderState);
|
|
436
|
+
ladderOut = applyLadderTier(ladderOut, ladderBlocks, 3, ladderState);
|
|
437
|
+
applied = ladderState.applied;
|
|
438
|
+
return { messages: ladderOut, applied, foldedMessages, unlocated };
|
|
326
439
|
}
|
|
327
440
|
|
|
328
|
-
/** Epoch-boundary detection: every block failed to LOCATE inside the
|
|
329
|
-
* re-send — the host replaced history (its own compaction) and the
|
|
330
|
-
* aged out as a whole.
|
|
331
|
-
*
|
|
332
|
-
*
|
|
441
|
+
/** Epoch-boundary detection: every T1 block failed to LOCATE inside the
|
|
442
|
+
* current re-send — the host replaced history (its own compaction) and the
|
|
443
|
+
* ledger aged out as a whole. Only raw-range blocks count (ladder blocks
|
|
444
|
+
* locate through their consumed T1 summaries, which the T1 pass renders);
|
|
445
|
+
* a ladder-only ledger can never read stale on its own. The fail-open skip
|
|
446
|
+
* stays correct, but a growth baseline carried over from the previous epoch
|
|
447
|
+
* does not: the caller re-anchors it exactly once per epoch. */
|
|
333
448
|
export function ledgerIsStale(view, ledger) {
|
|
334
|
-
const
|
|
335
|
-
|
|
449
|
+
const blocks = Array.isArray(ledger?.blocks) ? ledger.blocks : [];
|
|
450
|
+
const t1Count = blocks.filter((block) => block !== null && typeof block === "object" && block.refRange !== null).length;
|
|
451
|
+
return t1Count > 0 && view.applied === 0 && view.unlocated === t1Count;
|
|
336
452
|
}
|
|
337
453
|
|
|
338
454
|
/** Install the built view for one dispatch. Host immutability varies: probe
|