dsh-switchman 1.10.2 → 1.11.0
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 +5 -4
- package/README.es.md +5 -4
- package/README.fr.md +5 -4
- package/README.it.md +5 -4
- package/README.ja.md +5 -4
- package/README.ko.md +5 -4
- package/README.md +14 -5
- package/README.pt.md +5 -4
- package/README.ru.md +5 -4
- package/README.zh-TW.md +5 -4
- package/README.zh.md +5 -4
- package/client.js +323 -40
- package/cordis.patch.yml +23 -20
- package/host/config.js +51 -7
- package/host/context-trim/fork.js +5 -1
- package/host/context-trim/index.js +329 -178
- package/host/context-trim/metrics.js +7 -7
- package/host/context-trim/state.js +351 -50
- package/host/context-trim/summarizer.js +147 -33
- package/host/context-trim/surface.js +415 -11
- package/host/context-trim/trigger.js +170 -51
- package/host/context-trim/view.js +144 -20
- package/host/context-watch.js +80 -73
- package/host/routes.js +64 -4
- package/package.json +2 -2
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/** dsh-switchman context-trim stream metrics.
|
|
2
2
|
*
|
|
3
|
-
* Meter duality (design §8): trim
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* so every provider response is
|
|
8
|
-
* without ever breaking the stream:
|
|
9
|
-
* chunk always flows through untouched.
|
|
3
|
+
* Meter duality (design §8): the trim pressure tiers and the watermark read
|
|
4
|
+
* the SAME wire-domain meter — max(outbound view estimate, the last sampled
|
|
5
|
+
* provider prompt) — while growth cadence stays on the view alone (the
|
|
6
|
+
* meter the fold logic controls). This module supplies the wire half: it
|
|
7
|
+
* wraps the llm/stream result generator so every provider response is
|
|
8
|
+
* sampled for usage (input/cached/output) without ever breaking the stream:
|
|
9
|
+
* sampling errors are swallowed, the chunk always flows through untouched.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
/** Extract usage fields from a stream chunk. Returns
|
|
@@ -8,21 +8,62 @@
|
|
|
8
8
|
* ledger (fail-open: trimming must never brick a session over its own
|
|
9
9
|
* bookkeeping).
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* LEDGER v2 — the T1/T2/T3 ladder (docs/ladder-fold.md):
|
|
12
|
+
* - every block carries `tier` (1 = raw-range fold, 2 = distillation of
|
|
13
|
+
* active T1 blocks, 3 = distillation of active T2 blocks), `active`
|
|
14
|
+
* (false once consumed by a ladder block — blocks are NEVER deleted, so
|
|
15
|
+
* coverage stays expressible), and `consumes` (the ledger-consecutive run
|
|
16
|
+
* of blocks a ladder block absorbed; [] on T1).
|
|
17
|
+
* - a ladder block has no refRange (null): its material is the consumed
|
|
18
|
+
* blocks' summaries, not raw messages. The raw fold frontier stays a
|
|
19
|
+
* T1-only concept — ladder commits never move it.
|
|
20
|
+
* - v1 ledgers migrate on load (tier strings → 1, active: true,
|
|
21
|
+
* consumes: []) and re-persist as v2; unknown formats still reset.
|
|
22
|
+
*
|
|
23
|
+
* commitFold validates a new block before it enters the ledger. T1
|
|
24
|
+
* candidates validate against the CURRENT messages snapshot (the posMap:
|
|
25
|
+
* refs -> indices) — ref range must be locatable, segment-aligned, disjoint
|
|
26
|
+
* from every existing block, and strictly monotonic (behind or inside the
|
|
27
|
+
* existing frontier is rejected). Ladder candidates validate against the
|
|
28
|
+
* LEDGER itself — consumed ids must exist, be active, match tier-1, and be
|
|
29
|
+
* ledger-consecutive; view-adjacency is checked when a posMap is available.
|
|
30
|
+
* Without a posMap (ctx_trim_fold against a stale capture) only structural
|
|
31
|
+
* checks and exact-duplicate guards apply.
|
|
17
32
|
*/
|
|
18
33
|
|
|
19
34
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { randomBytes } from "node:crypto";
|
|
20
36
|
import { dirname, join } from "node:path";
|
|
21
37
|
import { dshHome } from "../lib/dsh-home.js";
|
|
22
38
|
import { estimateTokensOf, refPositions } from "./view.js";
|
|
23
39
|
|
|
24
|
-
/** Current ledger file format version. */
|
|
25
|
-
export const LEDGER_VERSION =
|
|
40
|
+
/** Current ledger file format version (2 = the T1/T2/T3 ladder). */
|
|
41
|
+
export const LEDGER_VERSION = 2;
|
|
42
|
+
|
|
43
|
+
/** Legacy tier-string → numeric tier for v1 migration (and for callers
|
|
44
|
+
* still passing the old strings). Numeric 1|2|3 passes through. */
|
|
45
|
+
export function tierOf(candidate) {
|
|
46
|
+
if (candidate === 1 || candidate === 2 || candidate === 3) return candidate;
|
|
47
|
+
if (candidate === "emergency" || candidate === "pressure" || candidate === "growth" || candidate === "model") return 1;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True when a block is a ladder block (tier ≥ 2, distills other blocks). */
|
|
52
|
+
export function isLadderBlock(block) {
|
|
53
|
+
return block !== null && typeof block === "object" && (block.tier === 2 || block.tier === 3);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Unique block id. `b<ms>` alone collides whenever two folds commit within
|
|
57
|
+
* the same millisecond — and the ladder consumes blocks BY ID (the view
|
|
58
|
+
* builder anchors synthetic-message replacement on the id), so uniqueness
|
|
59
|
+
* is load-bearing: a colliding id would make one block absorb another's
|
|
60
|
+
* coverage. Time prefix stays for chronology, a per-process sequence and a
|
|
61
|
+
* random tail disambiguate. */
|
|
62
|
+
let blockIdSeq = 0;
|
|
63
|
+
function allocateBlockId(now) {
|
|
64
|
+
blockIdSeq += 1;
|
|
65
|
+
return `b${now}-${blockIdSeq.toString(36)}${randomBytes(3).toString("hex")}`;
|
|
66
|
+
}
|
|
26
67
|
|
|
27
68
|
/** Filenames are session-id-derived; keep them filesystem-safe. */
|
|
28
69
|
function sanitizeSessionId(sessionId) {
|
|
@@ -34,7 +75,8 @@ function sanitizeSessionId(sessionId) {
|
|
|
34
75
|
}
|
|
35
76
|
|
|
36
77
|
/** Empty ledger for one session. frontier = ref of the newest folded
|
|
37
|
-
* message (the fold boundary); it only ever advances (monotonic)
|
|
78
|
+
* message (the fold boundary); it only ever advances (monotonic) and is a
|
|
79
|
+
* T1-only concept — ladder folds never move it. */
|
|
38
80
|
export function emptyLedger(sessionId) {
|
|
39
81
|
return {
|
|
40
82
|
version: LEDGER_VERSION,
|
|
@@ -52,10 +94,118 @@ export function emptyLedger(sessionId) {
|
|
|
52
94
|
};
|
|
53
95
|
}
|
|
54
96
|
|
|
55
|
-
/**
|
|
56
|
-
*
|
|
97
|
+
/** Normalize one persisted block record (load path AND migration). Returns
|
|
98
|
+
* a v2-shaped block or null when the record is structurally unusable.
|
|
99
|
+
* Tolerant by design: a ladder-shaped block that lost its consumed ids is
|
|
100
|
+
* a plain invalid record (null), not a crash. */
|
|
101
|
+
function normalizeStoredBlock(record) {
|
|
102
|
+
if (record === null || typeof record !== "object") return null;
|
|
103
|
+
const id = typeof record.id === "string" && record.id !== "" ? record.id : null;
|
|
104
|
+
if (id === null) return null;
|
|
105
|
+
const summary = typeof record.summary === "string" && record.summary.trim() !== "" ? record.summary.trim() : null;
|
|
106
|
+
if (summary === null) return null;
|
|
107
|
+
const tier = tierOf(record.tier);
|
|
108
|
+
if (tier === null) return null;
|
|
109
|
+
const count = Number.isInteger(record.count) && record.count > 0 ? record.count : null;
|
|
110
|
+
if (count === null) return null;
|
|
111
|
+
const approxTokens = Number.isFinite(record.approxTokens) && record.approxTokens > 0 ? Math.round(record.approxTokens) : count;
|
|
112
|
+
const createdAt = Number.isFinite(record.createdAt) && record.createdAt > 0 ? record.createdAt : 0;
|
|
113
|
+
const kind = record.kind === "mechanical" ? "mechanical" : "summary";
|
|
114
|
+
// consumes: a validated list of unique non-empty strings, or [] when
|
|
115
|
+
// absent (v1 migration / T1). A ladder-shaped record (tier ≥ 2) without
|
|
116
|
+
// a usable consumes list is invalid — its coverage is unknowable.
|
|
117
|
+
let consumes = [];
|
|
118
|
+
if (Array.isArray(record.consumes)) {
|
|
119
|
+
consumes = record.consumes.filter((entry) => typeof entry === "string" && entry !== "");
|
|
120
|
+
if (consumes.length !== record.consumes.length) return null;
|
|
121
|
+
if (new Set(consumes).size !== consumes.length) return null;
|
|
122
|
+
}
|
|
123
|
+
if (tier >= 2 && consumes.length === 0) return null;
|
|
124
|
+
// refRange: T1 blocks carry it; ladder blocks carry null (a v1-style
|
|
125
|
+
// ladder-shaped record cannot exist, but a corrupt one must not slip a
|
|
126
|
+
// phantom range through).
|
|
127
|
+
let refRange = null;
|
|
128
|
+
if (tier === 1) {
|
|
129
|
+
const firstId = record.refRange?.firstId;
|
|
130
|
+
const lastId = record.refRange?.lastId;
|
|
131
|
+
if (typeof firstId !== "string" || firstId === "" || typeof lastId !== "string" || lastId === "") return null;
|
|
132
|
+
refRange = { firstId, lastId };
|
|
133
|
+
}
|
|
134
|
+
const block = {
|
|
135
|
+
id,
|
|
136
|
+
kind,
|
|
137
|
+
tier,
|
|
138
|
+
refRange,
|
|
139
|
+
count,
|
|
140
|
+
approxTokens,
|
|
141
|
+
summary,
|
|
142
|
+
createdAt,
|
|
143
|
+
active: record.active === false ? false : true,
|
|
144
|
+
consumes,
|
|
145
|
+
};
|
|
146
|
+
// Surface bookkeeping (surface.js) rides along verbatim when present.
|
|
147
|
+
if (record.surfaceSeqs !== undefined) block.surfaceSeqs = record.surfaceSeqs;
|
|
148
|
+
if (record.surfaceReplaced !== undefined) block.surfaceReplaced = record.surfaceReplaced;
|
|
149
|
+
if (record.surfaceSkip !== undefined) block.surfaceSkip = record.surfaceSkip;
|
|
150
|
+
if (record.surfaceHeadClamped !== undefined) block.surfaceHeadClamped = record.surfaceHeadClamped;
|
|
151
|
+
return block;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Upgrade a parsed v1 ledger file to the v2 shape: every block gains
|
|
155
|
+
* numeric tier 1 (legacy tier strings map), active: true, consumes: [].
|
|
156
|
+
* Returns { ledger, migrated } or null when the file is unusable. */
|
|
157
|
+
function upgradeLedgerFile(parsed, sessionId) {
|
|
158
|
+
if (parsed === null || typeof parsed !== "object" || !Array.isArray(parsed.blocks)) return null;
|
|
159
|
+
if (parsed.version === LEDGER_VERSION) {
|
|
160
|
+
const blocks = parsed.blocks.map(normalizeStoredBlock);
|
|
161
|
+
if (blocks.some((block) => block === null)) return null;
|
|
162
|
+
// [2026-11-07]-[cross-block single-consumption invariant]-[commit
|
|
163
|
+
// enforces one-consumption-per-block, but a hand-edited ledger can
|
|
164
|
+
// double-consume a T1 — applyLadderTier then splices OVERLAPPING
|
|
165
|
+
// runs and corrupts the view. Load-time validation treats such a
|
|
166
|
+
// file as corrupt (inspection P1-7).]
|
|
167
|
+
const consumed = new Set();
|
|
168
|
+
for (const block of blocks) {
|
|
169
|
+
for (const id of block.consumes ?? []) {
|
|
170
|
+
if (consumed.has(id)) return null;
|
|
171
|
+
consumed.add(id);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { ledger: hydrateLedger(sessionId, parsed, blocks), migrated: false };
|
|
175
|
+
}
|
|
176
|
+
if (parsed.version !== 1) return null;
|
|
177
|
+
const blocks = parsed.blocks.map((record) => {
|
|
178
|
+
// v1 blocks always fold raw ranges: rebuild them through the same
|
|
179
|
+
// normalizer with the legacy tier strings it understands.
|
|
180
|
+
return normalizeStoredBlock({ ...record, tier: tierOf(record.tier) ?? 1, active: true, consumes: [] });
|
|
181
|
+
});
|
|
182
|
+
if (blocks.some((block) => block === null)) return null;
|
|
183
|
+
return { ledger: hydrateLedger(sessionId, parsed, blocks), migrated: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Assemble the in-memory ledger from parsed file fields + normalized
|
|
187
|
+
* blocks (shared by the v1 upgrade and the v2 load path). */
|
|
188
|
+
function hydrateLedger(sessionId, parsed, blocks) {
|
|
189
|
+
return {
|
|
190
|
+
version: LEDGER_VERSION,
|
|
191
|
+
sessionId,
|
|
192
|
+
blocks,
|
|
193
|
+
frontier: typeof parsed.frontier === "string" ? parsed.frontier : null,
|
|
194
|
+
lastFoldViewTokens: Number.isFinite(parsed.lastFoldViewTokens) ? parsed.lastFoldViewTokens : 0,
|
|
195
|
+
lastFoldLowTokens: Number.isFinite(parsed.lastFoldLowTokens) ? parsed.lastFoldLowTokens : 0,
|
|
196
|
+
lastFoldAt: Number.isFinite(parsed.lastFoldAt) ? parsed.lastFoldAt : 0,
|
|
197
|
+
foldsThisHour: Array.isArray(parsed.foldsThisHour) ? parsed.foldsThisHour.filter((ts) => Number.isFinite(ts)) : [],
|
|
198
|
+
inheritedFrom: typeof parsed.inheritedFrom === "string" ? parsed.inheritedFrom : null,
|
|
199
|
+
inheritedAt: Number.isFinite(parsed.inheritedAt) ? parsed.inheritedAt : 0,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Validate + normalize a raw T1 fold candidate (public surface; the ladder
|
|
204
|
+
* path validates inside commitFold against the live ledger). Returns the
|
|
205
|
+
* normalized v2-shaped block or null. */
|
|
57
206
|
export function normalizeBlock(candidate, now) {
|
|
58
207
|
if (candidate === null || typeof candidate !== "object") return null;
|
|
208
|
+
if (Array.isArray(candidate.consumes) && candidate.consumes.length > 0) return null; // ladder candidates take the commit path
|
|
59
209
|
const firstId = candidate.refRange?.firstId;
|
|
60
210
|
const lastId = candidate.refRange?.lastId;
|
|
61
211
|
if (typeof firstId !== "string" || firstId === "" || typeof lastId !== "string" || lastId === "") return null;
|
|
@@ -65,9 +215,9 @@ export function normalizeBlock(candidate, now) {
|
|
|
65
215
|
if (count <= 0) return null;
|
|
66
216
|
const approxTokens = Number.isFinite(candidate.approxTokens) && candidate.approxTokens > 0 ? Math.round(candidate.approxTokens) : count;
|
|
67
217
|
const kind = candidate.kind === "mechanical" ? "mechanical" : "summary";
|
|
68
|
-
const tier = candidate.tier
|
|
218
|
+
const tier = tierOf(candidate.tier) ?? 1;
|
|
69
219
|
return {
|
|
70
|
-
id: typeof candidate.id === "string" && candidate.id !== "" ? candidate.id :
|
|
220
|
+
id: typeof candidate.id === "string" && candidate.id !== "" ? candidate.id : allocateBlockId(now),
|
|
71
221
|
kind,
|
|
72
222
|
tier,
|
|
73
223
|
refRange: { firstId, lastId },
|
|
@@ -75,6 +225,8 @@ export function normalizeBlock(candidate, now) {
|
|
|
75
225
|
approxTokens,
|
|
76
226
|
summary,
|
|
77
227
|
createdAt: now,
|
|
228
|
+
active: true,
|
|
229
|
+
consumes: [],
|
|
78
230
|
};
|
|
79
231
|
}
|
|
80
232
|
|
|
@@ -90,7 +242,9 @@ export function createStore(options = {}) {
|
|
|
90
242
|
}
|
|
91
243
|
|
|
92
244
|
/** Load one ledger (cached). Corrupt or unreadable files reset the
|
|
93
|
-
* ledger and are moved aside (best effort) for forensics.
|
|
245
|
+
* ledger and are moved aside (best effort) for forensics. v1 files
|
|
246
|
+
* migrate to v2 in memory and persist lazily on the next write (an
|
|
247
|
+
* immediate write here would cost one disk write per session load). */
|
|
94
248
|
function ledgerOf(sessionId) {
|
|
95
249
|
const cached = ledgers.get(sessionId);
|
|
96
250
|
if (cached !== undefined) return cached;
|
|
@@ -99,19 +253,9 @@ export function createStore(options = {}) {
|
|
|
99
253
|
try {
|
|
100
254
|
if (existsSync(file)) {
|
|
101
255
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
sessionId,
|
|
106
|
-
blocks: parsed.blocks,
|
|
107
|
-
frontier: typeof parsed.frontier === "string" ? parsed.frontier : null,
|
|
108
|
-
lastFoldViewTokens: Number.isFinite(parsed.lastFoldViewTokens) ? parsed.lastFoldViewTokens : 0,
|
|
109
|
-
lastFoldLowTokens: Number.isFinite(parsed.lastFoldLowTokens) ? parsed.lastFoldLowTokens : 0,
|
|
110
|
-
lastFoldAt: Number.isFinite(parsed.lastFoldAt) ? parsed.lastFoldAt : 0,
|
|
111
|
-
foldsThisHour: Array.isArray(parsed.foldsThisHour) ? parsed.foldsThisHour.filter((ts) => Number.isFinite(ts)) : [],
|
|
112
|
-
inheritedFrom: typeof parsed.inheritedFrom === "string" ? parsed.inheritedFrom : null,
|
|
113
|
-
inheritedAt: Number.isFinite(parsed.inheritedAt) ? parsed.inheritedAt : 0,
|
|
114
|
-
};
|
|
256
|
+
const upgraded = upgradeLedgerFile(parsed, sessionId);
|
|
257
|
+
if (upgraded !== null) {
|
|
258
|
+
ledger = upgraded.ledger;
|
|
115
259
|
} else {
|
|
116
260
|
warn(`[context-trim] ledger for ${sanitizeSessionId(sessionId)} has an unknown format — resetting`);
|
|
117
261
|
try {
|
|
@@ -149,41 +293,161 @@ export function createStore(options = {}) {
|
|
|
149
293
|
}
|
|
150
294
|
}
|
|
151
295
|
|
|
296
|
+
/** Locate a T1 candidate range in the posMap, count-anchored (shared by
|
|
297
|
+
* the T1 commit path). Returns [firstIdx, lastIdx] or null. */
|
|
298
|
+
function locateT1Range(positions, refRange, count) {
|
|
299
|
+
const firstList = positions.get(refRange.firstId);
|
|
300
|
+
const lastList = positions.get(refRange.lastId);
|
|
301
|
+
if (firstList === undefined || lastList === undefined) return { reason: "refs-not-found" };
|
|
302
|
+
let firstIdx = -1;
|
|
303
|
+
let lastIdx = -1;
|
|
304
|
+
for (const start of firstList) {
|
|
305
|
+
const end = start + count - 1;
|
|
306
|
+
if (lastList.includes(end)) {
|
|
307
|
+
firstIdx = start;
|
|
308
|
+
lastIdx = end;
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (firstIdx < 0) return { reason: "count-mismatch" };
|
|
313
|
+
return { firstIdx, lastIdx };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Commit a LADDER fold block (tier 2/3): distill a contiguous run of
|
|
317
|
+
* active tier-(n-1) blocks into one summary. Validated against the
|
|
318
|
+
* ledger itself (never the capture). Returns { ok, ledger, block } or
|
|
319
|
+
* { ok: false, reason }. Memory only — persist() separately. */
|
|
320
|
+
function commitLadderFold(sessionId, candidate, posMap, now) {
|
|
321
|
+
const tier = tierOf(candidate.tier);
|
|
322
|
+
if (tier !== 2 && tier !== 3) return { ok: false, reason: "invalid-ladder-tier" };
|
|
323
|
+
const consumes = Array.isArray(candidate.consumes) ? candidate.consumes.filter((entry) => typeof entry === "string" && entry !== "") : [];
|
|
324
|
+
if (consumes.length === 0) return { ok: false, reason: "ladder-empty-consumes" };
|
|
325
|
+
const summary = typeof candidate.summary === "string" ? candidate.summary.trim() : "";
|
|
326
|
+
if (summary === "") return { ok: false, reason: "invalid-block" };
|
|
327
|
+
const ledger = ledgerOf(sessionId);
|
|
328
|
+
const byId = new Map(ledger.blocks.map((block) => [block.id, block]));
|
|
329
|
+
// Crash-retry idempotency: an exact replay of an already-committed
|
|
330
|
+
// ladder block (same id + tier + consumes) returns the committed
|
|
331
|
+
// block as success instead of rejecting — the commit already
|
|
332
|
+
// happened, so a retry must be a no-op, never a second append.
|
|
333
|
+
const candidateId = typeof candidate.id === "string" && candidate.id !== "" ? candidate.id : null;
|
|
334
|
+
if (candidateId !== null) {
|
|
335
|
+
const existing = byId.get(candidateId);
|
|
336
|
+
if (existing !== undefined && existing.kind === "summary" && existing.tier === tier
|
|
337
|
+
&& Array.isArray(existing.consumes) && existing.consumes.length === consumes.length
|
|
338
|
+
&& existing.consumes.every((id, i) => id === consumes[i])) {
|
|
339
|
+
return { ok: true, ledger, block: existing, duplicate: true };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Every consumed id must exist, be active, and sit one tier below.
|
|
343
|
+
let approxTokens = 0;
|
|
344
|
+
const indexes = [];
|
|
345
|
+
for (const id of consumes) {
|
|
346
|
+
const block = byId.get(id);
|
|
347
|
+
if (block === undefined) return { ok: false, reason: "ladder-consumed-missing" };
|
|
348
|
+
if (block.active !== true) return { ok: false, reason: "ladder-consumed-inactive" };
|
|
349
|
+
if (block.tier !== tier - 1) return { ok: false, reason: "ladder-tier-mismatch" };
|
|
350
|
+
approxTokens += Number.isFinite(block.approxTokens) && block.approxTokens > 0 ? block.approxTokens : 0;
|
|
351
|
+
indexes.push(ledger.blocks.indexOf(block));
|
|
352
|
+
}
|
|
353
|
+
// The consumed run must be consecutive in the ledger's block list
|
|
354
|
+
// (creation order). Duplicates were rejected implicitly: two equal
|
|
355
|
+
// ids map to one block index and a duplicated id breaks consecutiveness.
|
|
356
|
+
for (let i = 1; i < indexes.length; i++) {
|
|
357
|
+
if (indexes[i] !== indexes[i - 1] + 1) return { ok: false, reason: "ladder-not-contiguous" };
|
|
358
|
+
}
|
|
359
|
+
// View adjacency (strong check, only with a capture posMap): the run's
|
|
360
|
+
// raw spans must tile — each block starts exactly after the previous
|
|
361
|
+
// one ends. Engine-paced folds always tile; advisory middle-range
|
|
362
|
+
// folds do not, and distilling them would fold un-covered raw
|
|
363
|
+
// messages into the replacement. Without a posMap the structural
|
|
364
|
+
// checks above stand alone (fail-open).
|
|
365
|
+
if (posMap !== null && posMap !== undefined) {
|
|
366
|
+
const positions = posMap instanceof Map ? posMap : refPositions(posMap);
|
|
367
|
+
let prevEnd = null;
|
|
368
|
+
for (const id of consumes) {
|
|
369
|
+
const block = byId.get(id);
|
|
370
|
+
// Ladder blocks carry no raw span (refRange null) — their
|
|
371
|
+
// adjacency is already guaranteed by ledger consecutiveness,
|
|
372
|
+
// so a tier-3 run of T2 blocks skips the view check entirely
|
|
373
|
+
// instead of crashing on a missing span.
|
|
374
|
+
if (block.refRange === null || block.refRange === undefined) continue;
|
|
375
|
+
const located = locateT1Range(positions, block.refRange, block.count);
|
|
376
|
+
if (located.reason !== undefined) break; // stale capture: skip the check, structural holds
|
|
377
|
+
if (prevEnd !== null && located.firstIdx !== prevEnd + 1) return { ok: false, reason: "ladder-not-adjacent" };
|
|
378
|
+
prevEnd = located.lastIdx;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const block = {
|
|
382
|
+
id: typeof candidate.id === "string" && candidate.id !== "" ? candidate.id : allocateBlockId(now),
|
|
383
|
+
kind: "summary",
|
|
384
|
+
tier,
|
|
385
|
+
refRange: null,
|
|
386
|
+
count: consumes.length,
|
|
387
|
+
approxTokens: Number.isFinite(candidate.approxTokens) && candidate.approxTokens > 0 ? Math.round(candidate.approxTokens) : approxTokens,
|
|
388
|
+
summary,
|
|
389
|
+
createdAt: now,
|
|
390
|
+
active: true,
|
|
391
|
+
consumes: consumes.slice(),
|
|
392
|
+
};
|
|
393
|
+
// Single consumption: deactivate every consumed block, then append.
|
|
394
|
+
for (const id of consumes) {
|
|
395
|
+
const consumed = byId.get(id);
|
|
396
|
+
consumed.active = false;
|
|
397
|
+
}
|
|
398
|
+
ledger.blocks.push(block);
|
|
399
|
+
ledger.lastFoldAt = now;
|
|
400
|
+
ledger.foldsThisHour = ledger.foldsThisHour.filter((ts) => now - ts < 3_600_000);
|
|
401
|
+
ledger.foldsThisHour.push(now);
|
|
402
|
+
ledgers.set(sessionId, ledger);
|
|
403
|
+
return { ok: true, ledger, block };
|
|
404
|
+
}
|
|
405
|
+
|
|
152
406
|
/** Commit a fold block. posMap (refs -> indices of the CURRENT capture)
|
|
153
407
|
* enables full validation; returns { ok, ledger, block } or
|
|
154
|
-
* { ok: false, reason }. Memory only — persist() separately.
|
|
408
|
+
* { ok: false, reason }. Memory only — persist() separately.
|
|
409
|
+
* Dispatches on candidate.consumes: non-empty → the ladder path. */
|
|
155
410
|
function commitFold(sessionId, candidate, posMap = null, now = Date.now()) {
|
|
156
|
-
|
|
157
|
-
if (
|
|
411
|
+
if (candidate === null || typeof candidate !== "object") return { ok: false, reason: "invalid-block" };
|
|
412
|
+
if (Array.isArray(candidate.consumes) && candidate.consumes.length > 0) return commitLadderFold(sessionId, candidate, posMap, now);
|
|
413
|
+
const firstId = candidate.refRange?.firstId;
|
|
414
|
+
const lastId = candidate.refRange?.lastId;
|
|
415
|
+
if (typeof firstId !== "string" || firstId === "" || typeof lastId !== "string" || lastId === "") return { ok: false, reason: "invalid-block" };
|
|
416
|
+
const summary = typeof candidate.summary === "string" ? candidate.summary.trim() : "";
|
|
417
|
+
if (summary === "") return { ok: false, reason: "invalid-block" };
|
|
418
|
+
const count = Number.isInteger(candidate.count) && candidate.count > 0 ? candidate.count : 0;
|
|
419
|
+
if (count <= 0) return { ok: false, reason: "invalid-block" };
|
|
420
|
+
const approxTokens = Number.isFinite(candidate.approxTokens) && candidate.approxTokens > 0 ? Math.round(candidate.approxTokens) : count;
|
|
421
|
+
const kind = candidate.kind === "mechanical" ? "mechanical" : "summary";
|
|
422
|
+
const tier = tierOf(candidate.tier) ?? 1;
|
|
423
|
+
const block = {
|
|
424
|
+
id: typeof candidate.id === "string" && candidate.id !== "" ? candidate.id : allocateBlockId(now),
|
|
425
|
+
kind,
|
|
426
|
+
tier,
|
|
427
|
+
refRange: { firstId, lastId },
|
|
428
|
+
count,
|
|
429
|
+
approxTokens,
|
|
430
|
+
summary,
|
|
431
|
+
createdAt: now,
|
|
432
|
+
active: true,
|
|
433
|
+
consumes: [],
|
|
434
|
+
};
|
|
158
435
|
const ledger = ledgerOf(sessionId);
|
|
159
436
|
// exact-duplicate guard (same range already folded)
|
|
160
437
|
for (const existing of ledger.blocks) {
|
|
161
|
-
if (existing.refRange.firstId === block.refRange.firstId && existing.refRange.lastId === block.refRange.lastId) {
|
|
438
|
+
if (existing.refRange !== null && existing.refRange.firstId === block.refRange.firstId && existing.refRange.lastId === block.refRange.lastId) {
|
|
162
439
|
return { ok: false, reason: "duplicate-range" };
|
|
163
440
|
}
|
|
164
441
|
}
|
|
165
442
|
if (posMap !== null) {
|
|
166
443
|
const positions = posMap instanceof Map ? posMap : refPositions(posMap);
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// Content-addressed refs collide for duplicate messages, so one ref
|
|
171
|
-
// may map to many positions. Anchor the pair on block.count and
|
|
172
|
-
// search occurrences instead of trusting list extremes.
|
|
173
|
-
let firstIdx = -1;
|
|
174
|
-
let lastIdx = -1;
|
|
175
|
-
for (const start of firstList) {
|
|
176
|
-
const end = start + block.count - 1;
|
|
177
|
-
if (lastList.includes(end)) {
|
|
178
|
-
firstIdx = start;
|
|
179
|
-
lastIdx = end;
|
|
180
|
-
break;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
if (firstIdx < 0) return { ok: false, reason: "count-mismatch" };
|
|
444
|
+
const located = locateT1Range(positions, block.refRange, block.count);
|
|
445
|
+
if (located.reason !== undefined) return { ok: false, reason: located.reason };
|
|
446
|
+
const { firstIdx, lastIdx } = located;
|
|
184
447
|
// monotonicity: the new range must sit strictly behind the
|
|
185
448
|
// frontier range and be disjoint from every existing block.
|
|
186
449
|
for (const existing of ledger.blocks) {
|
|
450
|
+
if (existing.refRange === null) continue; // ladder block: coverage rides its consumed T1s
|
|
187
451
|
const eFirst = positions.get(existing.refRange.firstId);
|
|
188
452
|
const eLast = positions.get(existing.refRange.lastId);
|
|
189
453
|
if (eFirst === undefined || eLast === undefined) continue; // stale block outside this capture
|
|
@@ -193,7 +457,23 @@ export function createStore(options = {}) {
|
|
|
193
457
|
}
|
|
194
458
|
}
|
|
195
459
|
ledger.blocks.push(block);
|
|
196
|
-
|
|
460
|
+
// [2026-11-07]-[frontier only ever advances]-[an advisory fold of a
|
|
461
|
+
// MIDDLE range must not drag the boundary backward: computeFoldableRange
|
|
462
|
+
// would re-propose already-folded spans and every raw fold would stall
|
|
463
|
+
// on overlaps-existing-fold until an epoch reset (inspection report
|
|
464
|
+
// P0-1). Position comparison when a posMap is available; without one
|
|
465
|
+
// the historical unconditional assignment stands — engine folds always
|
|
466
|
+
// pass a posMap.]
|
|
467
|
+
let advanceFrontier = true;
|
|
468
|
+
if (posMap !== null) {
|
|
469
|
+
const positions = posMap instanceof Map ? posMap : refPositions(posMap);
|
|
470
|
+
const lastList = positions.get(block.refRange.lastId);
|
|
471
|
+
const frontierList = typeof ledger.frontier === "string" ? positions.get(ledger.frontier) : undefined;
|
|
472
|
+
if (lastList !== undefined && frontierList !== undefined && frontierList.length > 0) {
|
|
473
|
+
advanceFrontier = lastList[lastList.length - 1] > frontierList[frontierList.length - 1];
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (advanceFrontier) ledger.frontier = block.refRange.lastId;
|
|
197
477
|
ledger.lastFoldAt = now;
|
|
198
478
|
ledger.foldsThisHour = ledger.foldsThisHour.filter((ts) => now - ts < 3_600_000);
|
|
199
479
|
ledger.foldsThisHour.push(now);
|
|
@@ -227,11 +507,13 @@ export function createStore(options = {}) {
|
|
|
227
507
|
return { ledgerOf, commitFold, recordFoldBaseline, adoptLedger, persist, stateDir };
|
|
228
508
|
}
|
|
229
509
|
|
|
230
|
-
/** Test/inspection helper: estimated tokens of a ledger's folds
|
|
510
|
+
/** Test/inspection helper: estimated tokens of a ledger's raw-range folds
|
|
511
|
+
* (T1 blocks only; ladder blocks have no raw range of their own). */
|
|
231
512
|
export function ledgerFoldedTokens(messages, ledger) {
|
|
232
513
|
const positions = refPositions(messages);
|
|
233
514
|
let total = 0;
|
|
234
515
|
for (const block of ledger?.blocks ?? []) {
|
|
516
|
+
if (block.refRange === null) continue;
|
|
235
517
|
const firstList = positions.get(block.refRange?.firstId);
|
|
236
518
|
const lastList = positions.get(block.refRange?.lastId);
|
|
237
519
|
if (firstList === undefined || lastList === undefined) continue;
|
|
@@ -239,3 +521,22 @@ export function ledgerFoldedTokens(messages, ledger) {
|
|
|
239
521
|
}
|
|
240
522
|
return total;
|
|
241
523
|
}
|
|
524
|
+
|
|
525
|
+
/** Ladder census of one ledger: the ACTIVE tier-1 and tier-2 populations
|
|
526
|
+
* (id + summary mass estimate + approxTokens) the trigger and the ladder
|
|
527
|
+
* ship pass read. Pure; never throws. */
|
|
528
|
+
export function ladderCensusOf(ledger, countTokens = null) {
|
|
529
|
+
const estimate = typeof countTokens === "function"
|
|
530
|
+
? countTokens
|
|
531
|
+
: (text) => Math.max(1, Math.round((typeof text === "string" ? text.length : 0) / 4));
|
|
532
|
+
const t1 = [];
|
|
533
|
+
const t2 = [];
|
|
534
|
+
for (const block of ledger?.blocks ?? []) {
|
|
535
|
+
if (block.active !== true) continue;
|
|
536
|
+
const summaryTokens = estimate(block.summary);
|
|
537
|
+
const entry = { id: block.id, tier: block.tier, summaryTokens, approxTokens: Number.isFinite(block.approxTokens) ? block.approxTokens : 0, count: block.count };
|
|
538
|
+
if (block.tier === 1) t1.push(entry);
|
|
539
|
+
else if (block.tier === 2) t2.push(entry);
|
|
540
|
+
}
|
|
541
|
+
return { t1, t2 };
|
|
542
|
+
}
|