dsh-switchman 1.8.1 → 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 +50 -7
- package/README.es.md +50 -7
- package/README.fr.md +50 -7
- package/README.it.md +50 -7
- package/README.ja.md +50 -7
- package/README.ko.md +50 -7
- package/README.md +49 -6
- package/README.pt.md +50 -7
- package/README.ru.md +50 -7
- package/README.zh-TW.md +50 -7
- package/README.zh.md +50 -7
- package/client.js +455 -58
- package/cordis.patch.yml +32 -0
- package/host/config.js +48 -2
- package/host/context-trim/fork.js +178 -0
- package/host/context-trim/index.js +1054 -0
- package/host/context-trim/metrics.js +128 -0
- package/host/context-trim/state.js +241 -0
- package/host/context-trim/summarizer.js +179 -0
- package/host/context-trim/surface.js +736 -0
- package/host/context-trim/trigger.js +129 -0
- package/host/context-trim/view.js +362 -0
- package/host/context-watch.js +32 -62
- package/host/handover-state.js +2 -2
- package/host/routes.js +1 -1
- package/host/subagent-model.js +1 -1
- package/index.js +18 -4
- package/package.json +4 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** dsh-switchman context-trim trigger tiers (pure decision logic).
|
|
2
|
+
*
|
|
3
|
+
* The trigger half of the port (design §4/§5): decides whether a fold
|
|
4
|
+
* should run NOW. Called only at idle boundaries (agent/status idle) —
|
|
5
|
+
* never inside llm/stream, never while a fold for the same session is in
|
|
6
|
+
* flight. Three tiers, checked emergency-first:
|
|
7
|
+
*
|
|
8
|
+
* - emergency : view usage >= trimEmergencyPercent of the context window.
|
|
9
|
+
* Forced MECHANICAL fold (no side-model round trip — at 95%
|
|
10
|
+
* the session cannot afford the latency or the failure).
|
|
11
|
+
* - pressure : view usage >= trimPressurePercent. Side-model fold.
|
|
12
|
+
* - growth : growth since the last fold's post-fold low (the anchor
|
|
13
|
+
* that keeps the gate armed after folds committing at high
|
|
14
|
+
* view points) > trimGrowthTokens AND the contiguous
|
|
15
|
+
* compressible increment >= trimIncrementTokens. The
|
|
16
|
+
* primary, cache-friendly gate (the bili heuristic).
|
|
17
|
+
*
|
|
18
|
+
* Side-model folds are additionally throttled by trimMaxFoldsPerHour and a
|
|
19
|
+
* hard minimum interval between folds; emergency folds have their own
|
|
20
|
+
* cooldown so a stuck-at-95% session folds at most once per window.
|
|
21
|
+
* Everything is derived from measured inputs — no clock, no host access —
|
|
22
|
+
* which keeps the decision unit-testable (golden thresholds).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { estimateTokensOf, refPositions, segmentBounds } from "./view.js";
|
|
26
|
+
|
|
27
|
+
/** Hard minimum wall-clock interval between side-model folds. */
|
|
28
|
+
export const FOLD_MIN_INTERVAL_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
/** Minimum interval between emergency (mechanical) folds. */
|
|
31
|
+
export const EMERGENCY_COOLDOWN_MS = 90_000;
|
|
32
|
+
|
|
33
|
+
/** The foldable span of a captured messages array for one ledger: from the
|
|
34
|
+
* first segment boundary strictly after the frontier to the last segment
|
|
35
|
+
* end that still clears the protected tail. Returns { startIdx, endIdx }
|
|
36
|
+
* or null when nothing is foldable (empty history, everything folded, or
|
|
37
|
+
* protected tail covers it all). */
|
|
38
|
+
export function computeFoldableRange(messages, ledger, protectedTail) {
|
|
39
|
+
if (!Array.isArray(messages) || messages.length === 0) return null;
|
|
40
|
+
const tail = Number.isInteger(protectedTail) && protectedTail > 0 ? protectedTail : 0;
|
|
41
|
+
const tailStart = messages.length - tail;
|
|
42
|
+
if (tailStart <= 0) return null;
|
|
43
|
+
// first candidate segment start: strictly after the frontier message
|
|
44
|
+
let startFrom = 0;
|
|
45
|
+
if (typeof ledger?.frontier === "string" && ledger.frontier !== "") {
|
|
46
|
+
const positions = refPositions(messages);
|
|
47
|
+
const list = positions.get(ledger.frontier);
|
|
48
|
+
if (list !== undefined && list.length > 0) startFrom = list[list.length - 1] + 1;
|
|
49
|
+
}
|
|
50
|
+
// A leading system message is the frozen head of the wire array: a fold
|
|
51
|
+
// anchored on it would put a surface replace op (and a view splice) over
|
|
52
|
+
// the system prompt, which the host rejects outright
|
|
53
|
+
// (assertSystemHeadRewrite — node 0 may be rewritten only by a
|
|
54
|
+
// system/message over exactly that node). Covers both the frontier-null
|
|
55
|
+
// first fold and any frontier-set fold that lands on the head.
|
|
56
|
+
if (messages[0]?.role === "system") startFrom = Math.max(startFrom, 1);
|
|
57
|
+
let best = null;
|
|
58
|
+
for (const [start, end] of segmentBounds(messages)) {
|
|
59
|
+
if (start < startFrom) continue; // behind the frontier
|
|
60
|
+
if (end >= tailStart) break; // segment would cut into the protected tail
|
|
61
|
+
if (best === null) best = { startIdx: start, endIdx: end };
|
|
62
|
+
else best.endIdx = end; // extend the contiguous span
|
|
63
|
+
}
|
|
64
|
+
return best;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Evaluate the trigger. input: {
|
|
68
|
+
* foldable: { startIdx, endIdx } | null, // computeFoldableRange result
|
|
69
|
+
* messages, // captured raw messages (increment measurement)
|
|
70
|
+
* viewTokens, // outbound-view token estimate of the current history
|
|
71
|
+
* windowTokens, // context window size, or null when unknown
|
|
72
|
+
* thresholds: { growthTokens, incrementTokens, pressurePercent, emergencyPercent, maxFoldsPerHour },
|
|
73
|
+
* now, lastFoldAt, foldsThisHour, lastEmergencyAt,
|
|
74
|
+
* }.
|
|
75
|
+
* Returns { action: "none"|"fold"|"emergency", tier, reason, startIdx, endIdx, incrementTokens }. */
|
|
76
|
+
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;
|
|
81
|
+
const thresholds = input?.thresholds ?? {};
|
|
82
|
+
const now = Number.isFinite(input?.now) ? input.now : Date.now();
|
|
83
|
+
const incrementTokens = estimateTokensOf(input?.messages ?? [], startIdx, endIdx);
|
|
84
|
+
if (incrementTokens < 1) return { ...none, reason: "empty-increment" };
|
|
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
|
+
}
|
|
97
|
+
|
|
98
|
+
// Side-model folds (pressure + growth) share the cadence gates.
|
|
99
|
+
const lastFoldAt = Number.isFinite(input?.lastFoldAt) ? input.lastFoldAt : 0;
|
|
100
|
+
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 : 6;
|
|
102
|
+
if (hourly >= maxFoldsPerHour) return { ...none, reason: "hourly-cap" };
|
|
103
|
+
if (now - lastFoldAt < FOLD_MIN_INTERVAL_MS) return { ...none, reason: "min-interval" };
|
|
104
|
+
|
|
105
|
+
// Tier b — pressure: soft window threshold, side-model.
|
|
106
|
+
const pressurePercent = Number.isFinite(thresholds.pressurePercent) && thresholds.pressurePercent > 0 ? thresholds.pressurePercent : 60;
|
|
107
|
+
if (windowTokens !== null) {
|
|
108
|
+
const usagePct = (input.viewTokens / windowTokens) * 100;
|
|
109
|
+
if (usagePct >= pressurePercent) {
|
|
110
|
+
return { action: "fold", tier: "pressure", reason: `usage ${usagePct.toFixed(1)}% >= ${pressurePercent}%`, startIdx, endIdx, incrementTokens };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Tier a — growth gate: the primary cache-friendly heuristic.
|
|
115
|
+
const growthTokens = Number.isFinite(thresholds.growthTokens) && thresholds.growthTokens > 0 ? thresholds.growthTokens : 22_500;
|
|
116
|
+
const incrementGate = Number.isFinite(thresholds.incrementTokens) && thresholds.incrementTokens > 0 ? thresholds.incrementTokens : 50_000;
|
|
117
|
+
// Growth anchor: the post-fold low when known (keeps the gate armed
|
|
118
|
+
// after folds that commit at high view points), else the pre-fold
|
|
119
|
+
// view for ledgers written before lastFoldLowTokens existed.
|
|
120
|
+
const anchor = Number.isFinite(input?.ledger?.lastFoldLowTokens) && input.ledger.lastFoldLowTokens > 0
|
|
121
|
+
? input.ledger.lastFoldLowTokens
|
|
122
|
+
: Number.isFinite(input?.ledger?.lastFoldViewTokens) ? input.ledger.lastFoldViewTokens : 0;
|
|
123
|
+
const growth = input.viewTokens - anchor;
|
|
124
|
+
if (growth > growthTokens && incrementTokens >= incrementGate) {
|
|
125
|
+
return { action: "fold", tier: "growth", reason: `growth ~${Math.round(growth)} > ${growthTokens} and increment ~${incrementTokens} >= ${incrementGate}`, startIdx, endIdx, incrementTokens };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { ...none, reason: "below-thresholds" };
|
|
129
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/** dsh-switchman context-trim view builder (pure).
|
|
2
|
+
*
|
|
3
|
+
* The outbound-view half of the context-trim port (design:
|
|
4
|
+
* .dsh-switchman/analysis/context-trim-port-design.md §4): folds stored in
|
|
5
|
+
* a session ledger replace contiguous message ranges with ONE synthetic
|
|
6
|
+
* user message each. Everything here is pure and deterministic — the same
|
|
7
|
+
* (messages, ledger) pair must render a byte-identical view on every call
|
|
8
|
+
* (the golden test pins this), because a stable serialized prefix between
|
|
9
|
+
* folds is exactly what keeps provider prompt-cache hits alive.
|
|
10
|
+
*
|
|
11
|
+
* Message refs: messages at the llm/stream boundary are plain JSON-ish
|
|
12
|
+
* objects ({id?, role, content blocks / text}). An explicit id is trusted
|
|
13
|
+
* only when it looks addressable (short, no whitespace/control chars — the
|
|
14
|
+
* same validation billion-context applies); otherwise the ref is derived as
|
|
15
|
+
* a content hash of the canonicalized message (stable key order, volatile
|
|
16
|
+
* fields dropped), which survives the full-history re-send the agent loop
|
|
17
|
+
* performs every turn. Fold blocks anchor on refs, so a ledger keeps
|
|
18
|
+
* applying across process restarts without a parallel session log.
|
|
19
|
+
*
|
|
20
|
+
* Fail-open: blocks whose refs cannot be located (history rewritten by a
|
|
21
|
+
* racing compaction, content mutated) or that would reach into the
|
|
22
|
+
* protected tail are skipped, never guessed at — a stale ledger can only
|
|
23
|
+
* degrade to "no fold applied", never to lost messages.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
|
|
28
|
+
/** Cheap char → token estimate (the brief's char/4 heuristic). Applies to
|
|
29
|
+
* ASCII/latin text; CJK text tokenizes far denser (see below). */
|
|
30
|
+
export const TOKENS_PER_CHAR = 1 / 4;
|
|
31
|
+
|
|
32
|
+
/** CJK/fullwidth chars cost roughly ONE token each in the glm/deepseek BPE
|
|
33
|
+
* vocabularies (vs 0.25 for the char/4 ASCII heuristic). Pinned at 1.0:
|
|
34
|
+
* slightly high for common hanzi bigrams, which errs toward earlier folds
|
|
35
|
+
* (the safe direction). NOT the 2026-11-04 incident root cause (that session
|
|
36
|
+
* was only 4.8% CJK — the real bug was frozen-dispatch view injection); this
|
|
37
|
+
* is a general accuracy fix for CJK-heavy sessions. */
|
|
38
|
+
export const TOKENS_PER_CJK_CHAR = 1;
|
|
39
|
+
|
|
40
|
+
/** CJK-ish ranges: kana, Hangul jamo+syllables, CJK radicals/blocks and
|
|
41
|
+
* unified ideographs (incl. Ext-A), compat ideographs, vertical forms,
|
|
42
|
+
* fullwidth forms, and the supplementary-plane CJK extensions (Ext-B..F,
|
|
43
|
+
* /u flag — astral chars otherwise count as two ascii units). Halfwidth
|
|
44
|
+
* katakana (\uFF61-\uFF9F) rides in the fullwidth range at the CJK weight:
|
|
45
|
+
* slightly high, the safe direction for fold scheduling. */
|
|
46
|
+
const CJK_CHAR_RE = /[\u1100-\u11FF\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFFEF\u{20000}-\u{2FA1F}]/gu;
|
|
47
|
+
|
|
48
|
+
/** Split one text into (asciiChars, cjkChars) in one pass over the string.
|
|
49
|
+
* The replace allocates a second string — same order of cost as the
|
|
50
|
+
* JSON.stringify this sits next to. */
|
|
51
|
+
function countChars(text) {
|
|
52
|
+
let plain;
|
|
53
|
+
try {
|
|
54
|
+
plain = text.replace(CJK_CHAR_RE, "");
|
|
55
|
+
} catch {
|
|
56
|
+
return { ascii: text.length, cjk: 0 };
|
|
57
|
+
}
|
|
58
|
+
return { ascii: plain.length, cjk: text.length - plain.length };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Object keys dropped before hashing (volatility would churn refs). */
|
|
62
|
+
const VOLATILE_KEYS = new Set(["ts", "time", "timestamp", "createdAt", "updatedAt", "receivedAt", "sentAt"]);
|
|
63
|
+
|
|
64
|
+
/** Canonicalization depth cap (well beyond any real message nesting). */
|
|
65
|
+
const MAX_CANON_DEPTH = 8;
|
|
66
|
+
|
|
67
|
+
/** Canonical JSON of one value: object keys sorted, volatile keys dropped,
|
|
68
|
+
* arrays kept in order. Used ONLY for ref hashing, never for output. */
|
|
69
|
+
function canonicalValue(value, depth) {
|
|
70
|
+
if (value === null || typeof value !== "object") {
|
|
71
|
+
if (typeof value === "bigint") return String(value);
|
|
72
|
+
if (typeof value === "number" && !Number.isFinite(value)) return null;
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
if (depth >= MAX_CANON_DEPTH) return "[depth]";
|
|
76
|
+
if (Array.isArray(value)) return value.map((item) => canonicalValue(item, depth + 1));
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const key of Object.keys(value).sort()) {
|
|
79
|
+
if (VOLATILE_KEYS.has(key)) continue;
|
|
80
|
+
out[key] = canonicalValue(value[key], depth + 1);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Canonical JSON text of one message (ref-hash input). */
|
|
86
|
+
function canonicalizeMessage(message) {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.stringify(canonicalValue(message ?? null, 0)) ?? "null";
|
|
89
|
+
} catch {
|
|
90
|
+
return String(message);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True when an explicit message id is safe to address directly. */
|
|
95
|
+
function usableId(id) {
|
|
96
|
+
if (typeof id !== "string" || id === "" || id.length > 512) return false;
|
|
97
|
+
for (const char of id) {
|
|
98
|
+
const code = char.charCodeAt(0);
|
|
99
|
+
if (code <= 0x20 || code === 0x7f) return false;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Stable ref of one message: the explicit id when usable, else a short
|
|
105
|
+
* content hash (prefixed "h" so the two families can be told apart in
|
|
106
|
+
* logs). Index is unused for hashing — refs are position-independent on
|
|
107
|
+
* purpose (the host may prepend/insert nothing, but ids may be absent). */
|
|
108
|
+
export function refOf(message, _index) {
|
|
109
|
+
const id = message?.id;
|
|
110
|
+
if (usableId(id)) return id;
|
|
111
|
+
return `h${createHash("sha256").update(canonicalizeMessage(message)).digest("hex").slice(0, 12)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** ref -> ascending index list for one messages array. */
|
|
115
|
+
export function refPositions(messages) {
|
|
116
|
+
const positions = new Map();
|
|
117
|
+
messages.forEach((message, index) => {
|
|
118
|
+
const ref = refOf(message, index);
|
|
119
|
+
const list = positions.get(ref);
|
|
120
|
+
if (list === undefined) positions.set(ref, [index]);
|
|
121
|
+
else list.push(index);
|
|
122
|
+
});
|
|
123
|
+
return positions;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Estimated token count of one message (text content + tool-call payloads
|
|
127
|
+
* + small per-message framing overhead). CJK-aware — see TOKENS_PER_CJK_CHAR.
|
|
128
|
+
* Never throws. */
|
|
129
|
+
export function messageTokens(message) {
|
|
130
|
+
let ascii = 8; // per-message framing overhead, counted in the ascii pool
|
|
131
|
+
let cjk = 0;
|
|
132
|
+
const content = message?.content;
|
|
133
|
+
try {
|
|
134
|
+
if (typeof content === "string") {
|
|
135
|
+
const counted = countChars(content);
|
|
136
|
+
ascii += counted.ascii;
|
|
137
|
+
cjk += counted.cjk;
|
|
138
|
+
} else if (Array.isArray(content)) {
|
|
139
|
+
for (const block of content) {
|
|
140
|
+
const text = block !== null && typeof block === "object" && typeof block.text === "string" ? block.text : JSON.stringify(block ?? null);
|
|
141
|
+
const counted = countChars(text);
|
|
142
|
+
ascii += counted.ascii;
|
|
143
|
+
cjk += counted.cjk;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (Array.isArray(message?.tool_calls)) {
|
|
147
|
+
const counted = countChars(JSON.stringify(message.tool_calls));
|
|
148
|
+
ascii += counted.ascii;
|
|
149
|
+
cjk += counted.cjk;
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
/* unserializable block: keep the framing estimate */
|
|
153
|
+
}
|
|
154
|
+
return Math.max(1, Math.round(ascii * TOKENS_PER_CHAR + cjk * TOKENS_PER_CJK_CHAR));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Estimated token count of a message range (inclusive indices). */
|
|
158
|
+
export function estimateTokensOf(messages, startIdx = 0, endIdx = messages.length - 1) {
|
|
159
|
+
let total = 0;
|
|
160
|
+
for (let i = Math.max(0, startIdx); i <= Math.min(messages.length - 1, endIdx); i++) total += messageTokens(messages[i]);
|
|
161
|
+
return total;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- tool-pair segmentation -------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/** True when the message OPENS tool calls (either wire shape). */
|
|
167
|
+
function opensToolCalls(message) {
|
|
168
|
+
if (message?.role !== "assistant") return false;
|
|
169
|
+
if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return true;
|
|
170
|
+
if (Array.isArray(message.content)) return message.content.some((block) => block?.type === "tool_use");
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** True when the message CARRIES tool results (either wire shape). */
|
|
175
|
+
function carriesToolResults(message) {
|
|
176
|
+
if (message?.role === "tool") return true;
|
|
177
|
+
if (Array.isArray(message.content)) return message.content.some((block) => block?.type === "tool_result");
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Atomic segments of a messages array: an assistant message that opens
|
|
182
|
+
* tool calls plus every directly following tool-result message form ONE
|
|
183
|
+
* segment (they must live or fold together — a split pair is rejected by
|
|
184
|
+
* the upstream API, dsh-compaction/lib/index.js:557 validates the same
|
|
185
|
+
* rule); every other message is its own segment. Returns [start, end]
|
|
186
|
+
* index pairs covering the array in order. */
|
|
187
|
+
export function segmentBounds(messages) {
|
|
188
|
+
const bounds = [];
|
|
189
|
+
let i = 0;
|
|
190
|
+
while (i < messages.length) {
|
|
191
|
+
let end = i;
|
|
192
|
+
if (opensToolCalls(messages[i])) {
|
|
193
|
+
let j = i + 1;
|
|
194
|
+
while (j < messages.length && carriesToolResults(messages[j])) j++;
|
|
195
|
+
end = j - 1;
|
|
196
|
+
}
|
|
197
|
+
bounds.push([i, end]);
|
|
198
|
+
i = end + 1;
|
|
199
|
+
}
|
|
200
|
+
return bounds;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** True when [startIdx, endIdx] aligns with segment boundaries (a fold
|
|
204
|
+
* range must never cut inside a tool pair). */
|
|
205
|
+
export function rangeIsSegmentAligned(messages, startIdx, endIdx) {
|
|
206
|
+
if (!Number.isInteger(startIdx) || !Number.isInteger(endIdx) || startIdx < 0 || endIdx < startIdx) return false;
|
|
207
|
+
// The range must START at a segment start and END at a segment end (it may
|
|
208
|
+
// span any number of whole segments — segments tile the array contiguously,
|
|
209
|
+
// so aligned ends guarantee no tool pair is cut).
|
|
210
|
+
let startHit = false;
|
|
211
|
+
for (const [start, end] of segmentBounds(messages)) {
|
|
212
|
+
if (start === startIdx) startHit = true;
|
|
213
|
+
if (end === endIdx) return startHit && start <= endIdx;
|
|
214
|
+
if (start > endIdx) break;
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---- fold blocks ------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
/** Deterministic wire text of one fold block's synthetic message. */
|
|
222
|
+
export function renderSummaryText(block) {
|
|
223
|
+
return `[SWITCHMAN:TRIM] ${block.summary}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The ONE synthetic message replacing a folded range (brief: synthetic
|
|
227
|
+
* user message; id carries the acp_summary_ prefix downstream tooling
|
|
228
|
+
* skips). Pure — same block, same bytes. */
|
|
229
|
+
export function summaryMessageOf(block) {
|
|
230
|
+
return {
|
|
231
|
+
id: `acp_summary_${block.id}`,
|
|
232
|
+
role: "user",
|
|
233
|
+
content: [{ type: "text", text: renderSummaryText(block) }],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Locate a block's range in a positions map at/after afterIdx. Strict:
|
|
238
|
+
* both endpoint refs must resolve and the located span must equal the
|
|
239
|
+
* block's stored message count (guards against re-sent histories that no
|
|
240
|
+
* longer match the folded range). Returns [firstIdx, lastIdx] or null. */
|
|
241
|
+
function locateBlock(positions, block, afterIdx) {
|
|
242
|
+
const range = block?.refRange;
|
|
243
|
+
if (range === null || typeof range !== "object") return null;
|
|
244
|
+
const firstList = positions.get(range.firstId);
|
|
245
|
+
const lastList = positions.get(range.lastId);
|
|
246
|
+
if (firstList === undefined || lastList === undefined) return null;
|
|
247
|
+
const anchored = Number.isInteger(block.count) && block.count > 0;
|
|
248
|
+
let firstIdx = null;
|
|
249
|
+
let lastIdx = null;
|
|
250
|
+
for (const start of firstList) {
|
|
251
|
+
if (start <= afterIdx) continue;
|
|
252
|
+
if (anchored) {
|
|
253
|
+
// Duplicate-content messages share one content-addressed ref, so
|
|
254
|
+
// resolve the pair anchored on the stored count, not on extremes.
|
|
255
|
+
const end = start + block.count - 1;
|
|
256
|
+
if (!lastList.includes(end)) continue;
|
|
257
|
+
firstIdx = start;
|
|
258
|
+
lastIdx = end;
|
|
259
|
+
} else {
|
|
260
|
+
firstIdx = start;
|
|
261
|
+
}
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
if (firstIdx === null) return null;
|
|
265
|
+
if (lastIdx === null) {
|
|
266
|
+
for (const index of lastList) {
|
|
267
|
+
if (index >= firstIdx) {
|
|
268
|
+
lastIdx = index;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (lastIdx === null) return null;
|
|
273
|
+
}
|
|
274
|
+
const span = lastIdx - firstIdx + 1;
|
|
275
|
+
const expected = anchored ? block.count : span;
|
|
276
|
+
return span === expected ? [firstIdx, lastIdx] : null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Build the outbound view: apply every applicable fold block to a copy of
|
|
280
|
+
* the messages array. Pure (never mutates the input; the assembly splices
|
|
281
|
+
* the result into the live request array). Blocks apply in positional
|
|
282
|
+
* order; a block that cannot be located, overlaps an already-applied one,
|
|
283
|
+
* or reaches into the last protectedTail messages is skipped. Returns
|
|
284
|
+
* { messages, applied, foldedMessages } (foldedMessages = live messages
|
|
285
|
+
* the applied blocks replaced). */
|
|
286
|
+
export function buildView(messages, ledger, options = {}) {
|
|
287
|
+
const protectedTail = Number.isInteger(options.protectedTail) && options.protectedTail > 0 ? options.protectedTail : 0;
|
|
288
|
+
const blocks = Array.isArray(ledger?.blocks) ? ledger.blocks : [];
|
|
289
|
+
const positions = refPositions(messages);
|
|
290
|
+
const located = [];
|
|
291
|
+
let unlocated = 0;
|
|
292
|
+
for (const block of blocks) {
|
|
293
|
+
const range = locateBlock(positions, block, -1);
|
|
294
|
+
if (range !== null) located.push({ block, firstIdx: range[0], lastIdx: range[1] });
|
|
295
|
+
else unlocated += 1;
|
|
296
|
+
}
|
|
297
|
+
located.sort((a, b) => a.firstIdx - b.firstIdx || b.lastIdx - a.lastIdx);
|
|
298
|
+
const out = [];
|
|
299
|
+
let at = 0;
|
|
300
|
+
let applied = 0;
|
|
301
|
+
let foldedMessages = 0;
|
|
302
|
+
for (const entry of located) {
|
|
303
|
+
if (entry.firstIdx < at) continue; // overlaps an applied block — skip
|
|
304
|
+
if (protectedTail > 0 && entry.lastIdx >= messages.length - protectedTail) continue;
|
|
305
|
+
// The system prompt at index 0 is never foldable: splicing it off the
|
|
306
|
+
// wire would drop the prompt itself. Plain skip (not unlocated) so
|
|
307
|
+
// the ledgerIsStale epoch semantics stay untouched.
|
|
308
|
+
if (entry.firstIdx === 0 && messages[0]?.role === "system") continue;
|
|
309
|
+
if (!rangeIsSegmentAligned(messages, entry.firstIdx, entry.lastIdx)) continue; // never split a tool pair (design §4)
|
|
310
|
+
for (let i = at; i < entry.firstIdx; i++) out.push(messages[i]);
|
|
311
|
+
out.push(summaryMessageOf(entry.block));
|
|
312
|
+
foldedMessages += entry.lastIdx - entry.firstIdx + 1;
|
|
313
|
+
at = entry.lastIdx + 1;
|
|
314
|
+
applied += 1;
|
|
315
|
+
}
|
|
316
|
+
for (let i = at; i < messages.length; i++) out.push(messages[i]);
|
|
317
|
+
return { messages: out, applied, foldedMessages, unlocated };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Epoch-boundary detection: every block failed to LOCATE inside the current
|
|
321
|
+
* re-send — the host replaced history (its own compaction) and the ledger
|
|
322
|
+
* aged out as a whole. The fail-open skip stays correct, but a growth
|
|
323
|
+
* baseline carried over from the previous epoch does not: the caller
|
|
324
|
+
* re-anchors it exactly once per epoch. */
|
|
325
|
+
export function ledgerIsStale(view, ledger) {
|
|
326
|
+
const blocksCount = Array.isArray(ledger?.blocks) ? ledger.blocks.length : 0;
|
|
327
|
+
return blocksCount > 0 && view.applied === 0 && view.unlocated === blocksCount;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Install the built view for one dispatch. Host immutability varies: probe
|
|
331
|
+
* and fresh re-sends hand out mutable arrays (splice in place — cheapest,
|
|
332
|
+
* identity-preserving), resumed sessions re-send FROZEN host-assembled
|
|
333
|
+
* history (splice throws on its internal index deletes), and some dispatch
|
|
334
|
+
* options are frozen objects on top of that. Returns the strategy used so
|
|
335
|
+
* the caller can log it: "in-place" | "swapped" | "none" (a fold existed but
|
|
336
|
+
* the dispatch could not carry it — the wire ships raw history) |
|
|
337
|
+
* "nothing-to-apply" (no located fold blocks; the raw history IS the view). */
|
|
338
|
+
export function injectView(options, messages, view) {
|
|
339
|
+
if (view.applied <= 0) return "nothing-to-apply";
|
|
340
|
+
try {
|
|
341
|
+
replaceInPlace(messages, view.messages);
|
|
342
|
+
return "in-place";
|
|
343
|
+
} catch {
|
|
344
|
+
/* frozen array — fall through to the reference swap */
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
options.messages = view.messages;
|
|
348
|
+
return "swapped";
|
|
349
|
+
} catch {
|
|
350
|
+
/* frozen options — this dispatch cannot carry the view; fail open */
|
|
351
|
+
}
|
|
352
|
+
return "none";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Replace an array's CONTENT in place (identity preserved — the llm/stream
|
|
356
|
+
* contract mutates the live request array). Chunked pushes keep huge
|
|
357
|
+
* histories clear of the argument-count limit. */
|
|
358
|
+
export function replaceInPlace(target, source) {
|
|
359
|
+
target.splice(0, target.length);
|
|
360
|
+
for (let i = 0; i < source.length; i += 2048) target.push(...source.slice(i, i + 2048));
|
|
361
|
+
return target;
|
|
362
|
+
}
|