@elvisw/dsh-magic-context 0.0.0-stage → 0.14.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.
Files changed (66) hide show
  1. package/LICENSE +23 -0
  2. package/NOTICE +93 -0
  3. package/README.md +501 -2
  4. package/README.zh-CN.md +398 -0
  5. package/cordis.patch.yml +20 -0
  6. package/lib/client.js +2753 -0
  7. package/lib/core/compartments.js +424 -0
  8. package/lib/core/corpus-store.js +176 -0
  9. package/lib/core/directive-detect.js +147 -0
  10. package/lib/core/domain-reconcile.js +394 -0
  11. package/lib/core/domains.js +127 -0
  12. package/lib/core/embedding-local.js +247 -0
  13. package/lib/core/global-autostage.js +223 -0
  14. package/lib/core/global-domains.js +302 -0
  15. package/lib/core/journal.js +248 -0
  16. package/lib/core/llm-deadline.js +53 -0
  17. package/lib/core/machine-domain.js +106 -0
  18. package/lib/core/maintenance.js +380 -0
  19. package/lib/core/memory-bridge.js +818 -0
  20. package/lib/core/memory-categories.js +84 -0
  21. package/lib/core/memory-scoring.js +65 -0
  22. package/lib/core/memory-search.js +414 -0
  23. package/lib/core/memory-write.js +1176 -0
  24. package/lib/core/policy.js +111 -0
  25. package/lib/core/profile-confirmation.js +55 -0
  26. package/lib/core/profile-review.js +375 -0
  27. package/lib/core/profile-write.js +283 -0
  28. package/lib/core/reference-seeds.js +63 -0
  29. package/lib/core/route-health.js +106 -0
  30. package/lib/core/stores.js +1021 -0
  31. package/lib/core/tagger.js +7 -0
  32. package/lib/core/types.js +15 -0
  33. package/lib/core/vector-backfill.js +140 -0
  34. package/lib/core/vector-lane.js +184 -0
  35. package/lib/dsh/command-surface.js +376 -0
  36. package/lib/dsh/compaction-engine.js +231 -0
  37. package/lib/dsh/config-switches.js +56 -0
  38. package/lib/dsh/config.js +13 -0
  39. package/lib/dsh/context-pressure.js +36 -0
  40. package/lib/dsh/drop-landing.js +42 -0
  41. package/lib/dsh/emergency.js +89 -0
  42. package/lib/dsh/flush-command.js +148 -0
  43. package/lib/dsh/global-review.js +412 -0
  44. package/lib/dsh/historian.js +907 -0
  45. package/lib/dsh/live-journal.js +81 -0
  46. package/lib/dsh/maintenance-wiring.js +229 -0
  47. package/lib/dsh/management-pass.js +564 -0
  48. package/lib/dsh/memory-lane.js +535 -0
  49. package/lib/dsh/pre-step.js +675 -0
  50. package/lib/dsh/project-drift.js +116 -0
  51. package/lib/dsh/recomp.js +192 -0
  52. package/lib/dsh/reduce-guidance.js +188 -0
  53. package/lib/dsh/seams.js +588 -0
  54. package/lib/dsh/search-hint.js +186 -0
  55. package/lib/dsh/session-notice.js +100 -0
  56. package/lib/dsh/status-command.js +75 -0
  57. package/lib/dsh/telemetry.js +349 -0
  58. package/lib/dsh/tool-bridge.js +1009 -0
  59. package/lib/dsh/volatile.js +89 -0
  60. package/lib/index.js +931 -0
  61. package/package.json +108 -4
  62. package/tools/migrate-profile-package-name.mjs +317 -0
  63. package/vendor/onnx/LICENSE +27 -0
  64. package/vendor/onnx/ort-wasm-simd-threaded.mjs +59 -0
  65. package/vendor/onnx/ort-wasm-simd-threaded.wasm +0 -0
  66. package/vendor/onnx/ort.node.min.mjs +8 -0
@@ -0,0 +1,907 @@
1
+ /**
2
+ * The historian — Magic Context's summarizer, translated to a durable
3
+ * compartment instead of an in-band checkpoint.
4
+ *
5
+ * Magic Context's discipline, kept intact:
6
+ * - the historian is a MODEL-FREE transform from the pass's point of view:
7
+ * its LLM call runs out of band and only writes a store row; nothing on the
8
+ * wire changes because of it (publishing is a separate, busting pass);
9
+ * - it digests a contiguous span from the FRONT of the context, never the
10
+ * protected tail, never a span that would break tool-call pairing;
11
+ * - it fires only when the eligible span is worth a call (Magic Context's
12
+ * trigger budget: 5% of the window, clamped to 5k–50k tokens);
13
+ * - a failed or invalid response changes nothing (fail closed, one repair
14
+ * attempt only).
15
+ *
16
+ * Divergence from Magic Context, deliberate and documented: the four detail
17
+ * tiers are produced in one call (MC's ladder is accumulated across calls),
18
+ * and the token estimate is chars/4 rather than a real tokenizer.
19
+ */
20
+ import { estimateTokens, HistorianOutputError, parseHistorianOutput, } from "../core/compartments.js";
21
+ import { REFERENCE_ANCHORS } from "../core/reference-seeds.js";
22
+ import { errText } from "../core/types.js";
23
+ import { spanRequestMessages } from "./seams.js";
24
+ /**
25
+ * The historian's default wall-clock budget, used only as a runtime guard for
26
+ * a policy object that predates `policy.historianTimeoutMs` (the core policy
27
+ * table now always carries the field; see `DEFAULT_POLICY`).
28
+ */
29
+ export const DEFAULT_HISTORIAN_TIMEOUT_MS = 120_000;
30
+ /**
31
+ * The run exceeded its wall-clock budget. Distinct from a provider failure so
32
+ * the journal can say *why* nothing was stored.
33
+ */
34
+ export class HistorianTimeoutError extends Error {
35
+ timeoutMs;
36
+ constructor(timeoutMs) {
37
+ super(`historian call timed out after ${String(timeoutMs)}ms`);
38
+ this.timeoutMs = timeoutMs;
39
+ this.name = 'HistorianTimeoutError';
40
+ }
41
+ }
42
+ /**
43
+ * The provider stopped at the output-token cap before the JSON contract was
44
+ * complete. Observed in production with `historianMaxTokens: 2048`: the model
45
+ * spends part of the budget on reasoning and the digest never closes, so every
46
+ * run failed and no compartment (and therefore no fact) was ever stored.
47
+ *
48
+ * Retryable ONCE with an escalated cap — unlike a transport error, the request
49
+ * itself was valid and the answer was merely too long.
50
+ */
51
+ export class HistorianTruncatedError extends Error {
52
+ maxTokens;
53
+ partialChars;
54
+ providerDetail;
55
+ constructor(maxTokens,
56
+ /** Characters already streamed when the cap was hit — 0 means the whole
57
+ * budget went to reasoning, large means the JSON contract was too long. */
58
+ partialChars = 0,
59
+ /** The provider's own terminal message, kept in the failed row. */
60
+ providerDetail) {
61
+ super(`historian call truncated at the ${String(maxTokens)}-token output cap `
62
+ + `(incomplete digest, ${String(partialChars)} chars streamed)`
63
+ + (providerDetail === undefined ? '' : `; provider: ${providerDetail}`));
64
+ this.maxTokens = maxTokens;
65
+ this.partialChars = partialChars;
66
+ this.providerDetail = providerDetail;
67
+ this.name = 'HistorianTruncatedError';
68
+ }
69
+ }
70
+ /**
71
+ * The output cap below which the four-tier JSON contract cannot reliably close.
72
+ * A configured `historianMaxTokens` under this floor is raised TO it (and the
73
+ * raise is journaled), because a smaller cap is a guaranteed truncation rather
74
+ * than a preference. The ceiling matches the largest useful answer and keeps a
75
+ * single attempt inside the default wall-clock budget.
76
+ */
77
+ export const HISTORIAN_MIN_OUTPUT_TOKENS = 8_192;
78
+ /** The hard upper bound for one historian answer. */
79
+ export const HISTORIAN_MAX_OUTPUT_TOKENS = 32_768;
80
+ /**
81
+ * Magic Context's trigger budget: the eligible span must exceed 5% of the
82
+ * context window, clamped to [5k, 50k] tokens, before a call is worth making.
83
+ */
84
+ export function historianTriggerBudget(contextWindow, assumedWindow, minBudgetTokens = 5_000) {
85
+ const window = contextWindow === undefined || contextWindow <= 0 ? assumedWindow : contextWindow;
86
+ const floor = Math.max(1, minBudgetTokens);
87
+ return Math.max(floor, Math.min(50_000, Math.round(window * 0.05)));
88
+ }
89
+ /** A node whose text OPENS with a reclaim marker (a placeholder, or a manual
90
+ * checkpoint that carries its digest inline) holds no content worth digesting. */
91
+ const SKIPPABLE_PLACEHOLDER = /^\[(?:dropped|compacted|checkpoint) §/;
92
+ /**
93
+ * Select the oldest contiguous eligible span.
94
+ *
95
+ * Skipped: the system head, the composed baseline node (our own synthetic
96
+ * node), anything already covered by a compartment, and empty reclaim
97
+ * placeholders. The span never reaches into the newest `protectedNodes`
98
+ * surface nodes, and both of its edges are pulled to a tool-pairing boundary:
99
+ * it never OPENS on a tool result (whose assistant tool-call precedes the
100
+ * span) and never CLOSES on a tool result or on an assistant that requested
101
+ * tools (whose results fall outside the span). Either violation makes the
102
+ * auxiliary request structurally invalid for tool-using providers, which
103
+ * reject it before the historian can produce anything.
104
+ */
105
+ export function selectHistorianRange(input) {
106
+ const { nodes, covered } = input;
107
+ let start = 0;
108
+ const skippable = (node) => node.role === 'system'
109
+ || node.seq === input.baselineNodeSeq
110
+ || covered.has(node.seq)
111
+ || node.isPlaceholder
112
+ || SKIPPABLE_PLACEHOLDER.test(node.text.trim())
113
+ || !node.hasText;
114
+ while (start < nodes.length && skippable(nodes[start]))
115
+ start += 1;
116
+ // TOOL-PAIRING, head: a `tool` node can never OPEN the replay, because its
117
+ // assistant tool-call always precedes it in the surface — opening on one
118
+ // sends an orphan `tool_result`, which tool-using providers reject outright
119
+ // (HTTP 400) before the historian ever sees a token. The skipped node that
120
+ // exposed it (a tool-call-only assistant, an already-digested or placeholder
121
+ // producer) is not eligible either, so skip the whole unpaired run.
122
+ while (start < nodes.length && nodes[start].role === 'tool')
123
+ start += 1;
124
+ if (start >= nodes.length)
125
+ return undefined;
126
+ let cut = Math.max(start, nodes.length - Math.max(0, input.protectedNodes));
127
+ // TOOL-PAIRING, both edges: never end the span on a `tool` result (its
128
+ // producer would be inside while the result is out) and never end it on an
129
+ // assistant that REQUESTED tools (the request would carry a dangling
130
+ // `tool_use`). Dropping the call — and the results it would have dragged
131
+ // along — costs a little digest scope and keeps the auxiliary request valid.
132
+ for (;;) {
133
+ while (cut > start && nodes[cut]?.role === 'tool')
134
+ cut -= 1;
135
+ if (cut > start && nodes[cut - 1]?.hasToolCalls === true) {
136
+ cut -= 1;
137
+ continue;
138
+ }
139
+ break;
140
+ }
141
+ const slice = nodes.slice(start, cut);
142
+ if (slice.length < Math.max(1, input.minNodes))
143
+ return undefined;
144
+ const tokens = slice.reduce((total, node) => total + estimateTokens(node.text), 0);
145
+ if (tokens <= 0)
146
+ return undefined;
147
+ return { nodes: slice, tokens };
148
+ }
149
+ /** The JSON contract the historian must return (validated by parseHistorianOutput). */
150
+ export const HISTORIAN_INSTRUCTION = [
151
+ 'You are now acting as the HISTORIAN for this coding assistant.',
152
+ 'Condense the conversation ABOVE into durable context for a model that will continue this work after the span is removed from its context window.',
153
+ '',
154
+ 'Return ONLY one JSON object, no prose, no code fences, with exactly this shape:',
155
+ '{',
156
+ ' "summary": { "p1": "...", "p2": "...", "p3": "...", "p4": "..." },',
157
+ ' "importance": 0,',
158
+ ' "facts": [{ "category": "CONSTRAINTS", "content": "..." }],',
159
+ ' "events": ["..."],',
160
+ ' "userObservations": ["..."]',
161
+ '}',
162
+ '',
163
+ 'The four summary tiers are the SAME digest at four levels of detail, so it can be degraded as it ages:',
164
+ '- "p1": full detail — decisions with their rationale, exact paths/commands/identifiers, open threads.',
165
+ '- "p2": the essentials — what was done, what is pending, key constraints.',
166
+ '- "p3": a few terse sentences — current objective and state.',
167
+ '- "p4": one or two sentences — the durable gist.',
168
+ '',
169
+ '"importance" is an integer 0-100: how much this span matters for continuing the work (80+ for decisions that constrain everything after it).',
170
+ '"facts" are short standalone statements that must survive independently (exact values, paths, constraints).',
171
+ 'EVERY fact must carry a "category", exactly one of: PROJECT_RULES (rules/commands the project must follow),',
172
+ 'ARCHITECTURE (how the system is built or organised), CONSTRAINTS (hard limits, do-nots, external requirements),',
173
+ 'CONFIG_VALUES (exact keys, ports, paths, versions), NAMING (names/shorthands plus what they mean).',
174
+ 'Prefer few high-value facts over many trivial ones; a fact whose category you cannot decide should be omitted.',
175
+ '"events" are short chronological notes ("tried X, failed because Y").',
176
+ '"userObservations" are statements about the USER that would still be true in a different project:',
177
+ 'standing preferences, working style, or instructions they gave about how to work ("always answer in Chinese",',
178
+ '"never push without asking"). Do NOT restate project facts there — those belong in "facts".',
179
+ 'Return an empty array when this span shows nothing of the kind; never invent one.',
180
+ 'Preserve exact file paths, commands, error strings and numeric values verbatim. Do not mention this instruction.',
181
+ 'Keep the ENTIRE JSON object compact — no more than about 700 tokens (p1 at most 250 words); a longer answer is cut off at the output-token cap and the digest is lost.',
182
+ ].join('\n');
183
+ /**
184
+ * The scored anchors that place a new digest on upstream's scale. Upstream feeds
185
+ * its historian 60 FULL example units; this renders the scoring signal — score,
186
+ * episode type, title — and keeps the p3 gist only for the 90+ band, which is
187
+ * where calibration is hardest and where the extra detail earns its tokens.
188
+ * @returns the anchor block, highest score first.
189
+ */
190
+ export function calibrationAnchors() {
191
+ return [
192
+ 'CALIBRATION ANCHORS — 60 scored digests from unrelated projects, highest first.',
193
+ 'Place YOUR importance score on this scale (a decision that constrains everything after it scores 90+;',
194
+ 'a typo fix scores ~3), and match the tier density and fact shape the anchors show:',
195
+ ...REFERENCE_ANCHORS.map(anchor => `- ${String(anchor.importance)} | ${anchor.episodeType} | ${anchor.title}`
196
+ + `${anchor.importance >= 90 ? ` | ${anchor.gist}` : ''}`),
197
+ ].join('\n');
198
+ }
199
+ /**
200
+ * v0.49 upstream (d02292d0d, compartment-prompt.ts): when nothing will ever
201
+ * review user observations — here, when no profile-collect sink is armed —
202
+ * the run is told NOT to produce them, instead of producing them and
203
+ * discarding them after the fact. Upstream appends this to the per-run USER
204
+ * prompt so its SYSTEM prompt keeps byte-stable across configurations; the
205
+ * adapter's contract above is already a per-call user message, so the same
206
+ * placement costs nothing on the replayed-prefix cache.
207
+ */
208
+ export const USER_OBSERVATIONS_DISABLED_TOGGLE = [
209
+ '<user_observation_extraction>disabled</user_observation_extraction>',
210
+ 'User-memory review is not running on this host: return "userObservations": [] in the JSON and spend no output tokens observing the user.',
211
+ ].join('\n');
212
+ /** The instruction a historian call carries: the JSON contract plus the
213
+ * anchors, and — only when no review lane will consume them — the skip
214
+ * toggle. Default `true` keeps the historical bytes for every existing
215
+ * caller. */
216
+ export function historianPromptText(userObservationsRequested = true) {
217
+ const base = `${HISTORIAN_INSTRUCTION}\n\n${calibrationAnchors()}`;
218
+ return userObservationsRequested ? base : `${base}\n\n${USER_OBSERVATIONS_DISABLED_TOGGLE}`;
219
+ }
220
+ /** Consume a chunk stream into text, honoring the terminal finish reason.
221
+ *
222
+ * @param maxTokens - the cap the request carried, for the truncation error.
223
+ */
224
+ async function collectText(chunks, maxTokens) {
225
+ const byIndex = new Map();
226
+ let usage;
227
+ // The stream's terminal reason, when one arrived at all. A provider that cuts
228
+ // the stream without a `finish` chunk leaves this undefined — the shape that
229
+ // produces invalid JSON with no max-tokens signal (measured live 13:06: JSON
230
+ // cut at 3.2KB, no truncation classified, four attempts burned).
231
+ let finishReason;
232
+ for await (const raw of chunks) {
233
+ const chunk = raw;
234
+ switch (chunk.type) {
235
+ case 'text-delta': {
236
+ const delta = chunk;
237
+ byIndex.set(delta.index, (byIndex.get(delta.index) ?? '') + delta.text);
238
+ break;
239
+ }
240
+ case 'usage': {
241
+ const reported = chunk.usage;
242
+ const numeric = {};
243
+ for (const [key, value] of Object.entries(reported)) {
244
+ if (typeof value === 'number')
245
+ numeric[key] = value;
246
+ }
247
+ usage = numeric;
248
+ break;
249
+ }
250
+ case 'finish': {
251
+ const reason = chunk.reason;
252
+ finishReason = reason.kind;
253
+ if (reason.kind === 'error' || reason.kind === 'aborted') {
254
+ // The host turns a provider throw into a terminal error chunk; the
255
+ // code survives on `reason.failure.code`, not on the thrown Error.
256
+ if (isOutputLimitCode(reason.failure?.code)) {
257
+ throw new HistorianTruncatedError(maxTokens, 0, reason.failure?.message);
258
+ }
259
+ throw new Error(`historian call failed: ${reason.failure?.message ?? reason.kind}`);
260
+ }
261
+ if (reason.kind === 'max-tokens') {
262
+ const partial = [...byIndex.entries()]
263
+ .sort(([a], [b]) => a - b)
264
+ .map(([, value]) => value)
265
+ .join('');
266
+ throw new HistorianTruncatedError(maxTokens, partial.length);
267
+ }
268
+ break;
269
+ }
270
+ default:
271
+ break;
272
+ }
273
+ }
274
+ const text = [...byIndex.entries()].sort(([a], [b]) => a - b).map(([, value]) => value).join('');
275
+ return { text, ...usage === undefined ? {} : { usage }, ...finishReason === undefined ? {} : { finishReason } };
276
+ }
277
+ /**
278
+ * In-flight historian runs, one per session: Magic Context never runs two
279
+ * historians for one session. The value (not just a flag) is the point — the
280
+ * force band and the provider-overflow path can AWAIT the running digest
281
+ * (`historianInFlight`) instead of dropping tool results while a compartment
282
+ * is seconds from landing (the ready-bank pattern from mon3stera's
283
+ * dsh-magic-context `engine.inFlight`).
284
+ */
285
+ const inFlight = new Map();
286
+ /** The historian run in flight for one session, when any. */
287
+ export function historianInFlight(sessionId) {
288
+ return inFlight.get(sessionId);
289
+ }
290
+ /**
291
+ * A provider can reject an empty, token-limited completion before the host
292
+ * emits a `finish` chunk (measured on commandcode: `OUTPUT_TOKEN_LIMIT`). When
293
+ * the throw reaches us directly the code is on the Error; when the host catches
294
+ * the adapter throw first, the code is on `finish.reason.failure.code`. Both
295
+ * paths must normalize to truncation, otherwise the designed cap-escalation
296
+ * retry never fires.
297
+ */
298
+ function isOutputLimitCode(code) {
299
+ return code === 'OUTPUT_TOKEN_LIMIT';
300
+ }
301
+ function isOutputLimitFailure(error) {
302
+ return error instanceof Error && isOutputLimitCode(error.code);
303
+ }
304
+ /**
305
+ * One attempt's stream, bounded by the run's remaining wall-clock budget.
306
+ *
307
+ * The budget is enforced twice on purpose: the abort signal tells a compliant
308
+ * provider stream to stop (so the socket is released), and `Promise.race`
309
+ * rejects even when the seam ignores the signal entirely. Without the race a
310
+ * host whose `llm.stream()` never settles would leave the run — and the
311
+ * session's `inFlight` slot — hanging forever, which is exactly the "start
312
+ * with no terminal event" failure this guards.
313
+ */
314
+ async function collectAttempt(deps, options, budgetMs, outerSignal) {
315
+ const controller = new AbortController();
316
+ const relayAbort = () => controller.abort(outerSignal?.reason);
317
+ if (outerSignal !== undefined) {
318
+ if (outerSignal.aborted)
319
+ relayAbort();
320
+ else
321
+ outerSignal.addEventListener('abort', relayAbort, { once: true });
322
+ }
323
+ let timer;
324
+ const expiry = new Promise((_resolve, reject) => {
325
+ timer = setTimeout(() => {
326
+ controller.abort();
327
+ reject(new HistorianTimeoutError(budgetMs));
328
+ }, budgetMs);
329
+ timer.unref?.();
330
+ });
331
+ try {
332
+ try {
333
+ return await Promise.race([
334
+ collectText(deps.llm.stream({ ...options, signal: controller.signal }), options.maxTokens),
335
+ expiry,
336
+ ]);
337
+ }
338
+ catch (error) {
339
+ // Normalise the provider's pre-finish token-limit rejection into the
340
+ // truncation path so the next attempt doubles the cap and asks for a
341
+ // shorter JSON object.
342
+ if (isOutputLimitFailure(error)) {
343
+ throw new HistorianTruncatedError(options.maxTokens, 0, error instanceof Error ? error.message : String(error));
344
+ }
345
+ throw error;
346
+ }
347
+ }
348
+ finally {
349
+ if (timer !== undefined)
350
+ clearTimeout(timer);
351
+ outerSignal?.removeEventListener('abort', relayAbort);
352
+ }
353
+ }
354
+ /**
355
+ * Numeric-key accumulation across one run's calls. Providers report usage under
356
+ * their own key names (`inputTokens`/`outputTokens` is the common host shape),
357
+ * and a chunked run makes several calls — so the done row carries the
358
+ * per-key SUM, keyed exactly as the provider reported it (no guessing about
359
+ * semantics). Attempts that never produced a usage chunk (timeouts, transport
360
+ * errors) contribute nothing: their cost is already visible in `attempts`.
361
+ */
362
+ function addUsage(total, reported) {
363
+ if (reported === undefined)
364
+ return total;
365
+ const merged = { ...total };
366
+ for (const [key, value] of Object.entries(reported)) {
367
+ if (typeof value === 'number' && Number.isFinite(value))
368
+ merged[key] = (merged[key] ?? 0) + value;
369
+ }
370
+ return merged;
371
+ }
372
+ /** Why a historian run produced no digest, in the journal's vocabulary. */
373
+ function classifyFailure(error) {
374
+ if (error instanceof HistorianTimeoutError)
375
+ return 'timeout';
376
+ if (error instanceof HistorianTruncatedError)
377
+ return 'max-tokens';
378
+ if (error instanceof HistorianOutputError)
379
+ return 'invalid-output';
380
+ if (error instanceof Error && error.name === 'AbortError')
381
+ return 'aborted';
382
+ return 'stream-error';
383
+ }
384
+ /**
385
+ * The cap one attempt carries: the floor-respecting cap, doubled on retry and
386
+ * clamped to the ceiling (`HISTORIAN_MAX_OUTPUT_TOKENS` unless the policy
387
+ * carries an explicit `historianMaxTokensCeiling`).
388
+ */
389
+ function attemptOutputCap(effective, attempt, ceiling = HISTORIAN_MAX_OUTPUT_TOKENS) {
390
+ return Math.min(ceiling, effective * 2 ** attempt);
391
+ }
392
+ /**
393
+ * The retry ceiling, read defensively: `historianMaxTokensCeiling` is an
394
+ * optional policy key, so this adapter works both with and without the Config
395
+ * entry. The ceiling is never allowed below the first attempt's cap.
396
+ */
397
+ function retryCeiling(policy, effective) {
398
+ const configured = policy.historianMaxTokensCeiling;
399
+ const ceiling = Math.min(HISTORIAN_MAX_OUTPUT_TOKENS, typeof configured === 'number' && Number.isFinite(configured) && configured > 0
400
+ ? configured
401
+ : HISTORIAN_MAX_OUTPUT_TOKENS);
402
+ return Math.max(effective, ceiling);
403
+ }
404
+ /**
405
+ * Split an oversized span into contiguous chunks of about `chunkTokens`
406
+ * tokens (never splitting a node, at most 4 chunks, only when the span is at
407
+ * least 4 nodes — smaller spans digest in one call exactly as before).
408
+ */
409
+ export function planChunkSpans(nodes, spanTokens, chunkTokens) {
410
+ if (chunkTokens === undefined || chunkTokens <= 0 || spanTokens <= chunkTokens || nodes.length < 4) {
411
+ return [nodes];
412
+ }
413
+ const count = Math.min(4, Math.max(2, Math.ceil(spanTokens / chunkTokens)));
414
+ const target = Math.ceil(spanTokens / count);
415
+ const groups = [];
416
+ let current = [];
417
+ let acc = 0;
418
+ for (let index = 0; index < nodes.length; index += 1) {
419
+ const node = nodes[index];
420
+ current.push(node);
421
+ acc += Math.max(1, Math.ceil(node.text.length / 4));
422
+ if (acc < target || groups.length >= count - 1)
423
+ continue;
424
+ // TOOL-PAIRING at the chunk edges, not only at the span edges the scan above
425
+ // guards: a chunk must not OPEN on a tool result (its assistant tool-call
426
+ // would sit in the previous chunk) and must not CLOSE on an assistant that
427
+ // requested tools (whose results would land in the next one). Providers
428
+ // reject both (`host: packages/core/session/src/repair.ts:164`: "providers
429
+ // reject dangling assistant calls"), and the live failure was this shape — a
430
+ // chunked run whose second call opened on a tool result answered 400
431
+ // "tool_result ... must have a corresponding tool_use block in the previous
432
+ // message". Waiting for the next safe boundary keeps the target approximate:
433
+ // the chunk count is a cap, not a promise.
434
+ const next = nodes[index + 1];
435
+ if (next === undefined || next.role === 'tool' || node.hasToolCalls)
436
+ continue;
437
+ groups.push(current);
438
+ current = [];
439
+ acc = 0;
440
+ }
441
+ if (current.length > 0)
442
+ groups.push(current);
443
+ return groups.length > 0 ? groups : [nodes];
444
+ }
445
+ /** The merge step's contract: one final JSON object from the partial digests. */
446
+ export const HISTORIAN_MERGE_INSTRUCTION = [
447
+ 'You are the HISTORIAN merging PARTIAL digests of ONE long span into the final single JSON object.',
448
+ 'Return ONLY one JSON object with exactly the same shape: summary {p1,p2,p3,p4}, importance, facts [{category,content}], events.',
449
+ 'Merge and deduplicate the partials; keep the most durable facts; preserve exact paths, commands and error strings verbatim.',
450
+ 'p1 at most 250 words; the other tiers one or two sentences each; at most about 700 tokens overall.',
451
+ ].join('\n');
452
+ /**
453
+ * Run the historian's auxiliary call over a span and return the validated
454
+ * digest WITHOUT storing it (the manual/checkpoint path owns the result).
455
+ *
456
+ * Every exit after the `start` event writes exactly one terminal event:
457
+ * `done` on a validated digest, `failed` (with `reason`, `durationMs`,
458
+ * `attempts`) on every other outcome. The two repair attempts share ONE
459
+ * wall-clock budget so the run cannot outlive `timeoutMs`.
460
+ */
461
+ export async function callHistorian(deps, input) {
462
+ const route = deps.route ?? input.route;
463
+ if (route === undefined || route.provider.length === 0 || route.model.length === 0) {
464
+ throw new Error('no routed provider/model available for the historian call');
465
+ }
466
+ const reasoningEffort = route.reasoningEffort?.trim() === '' ? undefined : route.reasoningEffort?.trim();
467
+ const trigger = input.trigger ?? 'automatic';
468
+ const spanTokens = input.nodes.reduce((total, node) => total + Math.max(1, Math.ceil(node.text.length / 4)), 0);
469
+ const clock = deps.now ?? Date.now;
470
+ const startedAt = clock();
471
+ const timeoutMs = deps.timeoutMs ?? deps.policy.historianTimeoutMs ?? DEFAULT_HISTORIAN_TIMEOUT_MS;
472
+ // A cap below the contract floor is a guaranteed truncation, not a
473
+ // preference: the production failure was `historianMaxTokens: 2048`, which
474
+ // the model spent on reasoning before the JSON ever closed.
475
+ const configuredMaxTokens = deps.policy.historianMaxTokens;
476
+ const effectiveMaxTokens = Math.min(HISTORIAN_MAX_OUTPUT_TOKENS, Math.max(HISTORIAN_MIN_OUTPUT_TOKENS, configuredMaxTokens));
477
+ const ceiling = retryCeiling(deps.policy, effectiveMaxTokens);
478
+ // Everything that can throw is prepared BEFORE the `start` row. The invariant
479
+ // is "no row, or `start` + exactly one terminal sibling", and a preparation
480
+ // failure (e.g. a session store that throws inside `spanRequestMessages`)
481
+ // must not be able to leave a `start` without its terminal event.
482
+ const coveredSeqs = input.nodes.map(node => node.seq);
483
+ const chunks = planChunkSpans(input.nodes, spanTokens, input.chunkTokens);
484
+ const merging = chunks.length > 1;
485
+ const plannedCalls = chunks.length + (merging ? 1 : 0);
486
+ // D3: the run budget scales with the call count (per-call attempts share
487
+ // it), capped at MC's 600s historian wall clock. The extra slot reserves
488
+ // budget for the merge call — measured live (the 17:50 timeout): four
489
+ // chunks with retries spent ~5.9min of the 4×180s budget, leaving the
490
+ // merge 129s, which it overran.
491
+ const deadline = startedAt + Math.min(600_000, timeoutMs * plannedCalls + (merging ? timeoutMs : 0));
492
+ const spanSeqsOf = (call) => chunks[Math.min(call, chunks.length - 1)].map(node => node.seq);
493
+ const replayOf = (call) => {
494
+ const seqs = spanSeqsOf(call);
495
+ return spanRequestMessages(input.session, input.systemSeq === undefined ? seqs : [input.systemSeq, ...seqs]);
496
+ };
497
+ // d02292d0d: observations are requested only when a sink is armed to
498
+ // consume them; otherwise the per-call instruction says so (see the toggle).
499
+ const userObservationsRequested = deps.profileCollect !== undefined;
500
+ const baseMessages = [
501
+ ...replayOf(0),
502
+ { role: 'user', content: [{ type: 'text', text: historianPromptText(userObservationsRequested) }] },
503
+ ];
504
+ const baseData = {
505
+ trigger,
506
+ nodes: input.nodes.length,
507
+ tokens: spanTokens,
508
+ route: `${route.provider}/${route.model}`,
509
+ ...reasoningEffort === undefined ? {} : { reasoningEffort },
510
+ maxTokens: effectiveMaxTokens,
511
+ ...effectiveMaxTokens === configuredMaxTokens ? {} : { configuredMaxTokens },
512
+ timeoutMs,
513
+ ...merging ? { chunks: chunks.length } : {},
514
+ };
515
+ deps.journal?.info('historian', `historian ${trigger} run started over ${String(input.nodes.length)} nodes${merging ? ` in ${String(chunks.length)} chunks` : ''}`, {
516
+ phase: 'start',
517
+ trigger,
518
+ nodes: input.nodes.length,
519
+ tokens: spanTokens,
520
+ route: `${route.provider}/${route.model}`,
521
+ ...reasoningEffort === undefined ? {} : { reasoningEffort },
522
+ maxTokens: effectiveMaxTokens,
523
+ ...effectiveMaxTokens === configuredMaxTokens ? {} : { configuredMaxTokens },
524
+ timeoutMs,
525
+ ...merging ? { chunks: chunks.length } : {},
526
+ }, input.sessionId);
527
+ let terminal = false;
528
+ let attempts = 0;
529
+ let failure;
530
+ // What the LAST attempt's stream actually did: its terminal reason (undefined
531
+ // when the provider never sent a finish chunk) and how many characters made
532
+ // it out. Both land on an invalid-output row so a cut stream is
533
+ // distinguishable from a model that simply wrote bad JSON.
534
+ let lastFinish;
535
+ let lastText = '';
536
+ /** Per-key usage sum over the run's successful calls (journal-observed cost). */
537
+ let usageTotal;
538
+ /** The output cap each attempt carried, in order (did escalation happen?). */
539
+ const attemptCaps = [];
540
+ // The stage a failure should be attributed to: updated as the run advances
541
+ // ('start' → 'chunk 2/4' → 'merge'), so the failed row says WHERE it died.
542
+ let stage = 'start';
543
+ try {
544
+ const partials = [];
545
+ let finalDraft;
546
+ let lastCap = effectiveMaxTokens;
547
+ for (let call = 0; call < plannedCalls; call += 1) {
548
+ const isMerge = merging && call === plannedCalls - 1;
549
+ stage = isMerge ? 'merge' : `chunk ${String(call + 1)}/${String(chunks.length)}`;
550
+ if (isMerge) {
551
+ // The merge was invisible until it timed out (the 17:50 failure) —
552
+ // give it the same per-call beat the chunks have.
553
+ deps.journal?.info('historian', `historian merging ${String(partials.length)} partial digest(s)`, {
554
+ phase: 'merge',
555
+ partials: partials.length,
556
+ ...baseData,
557
+ }, input.sessionId);
558
+ }
559
+ // Annotated: without it the nested ternary widens the message literals
560
+ // (`type: 'text'` becomes `string`) and the whole array collapses to
561
+ // `unknown[]`, which the LLM seam then rejects.
562
+ const baseMessagesForCall = isMerge
563
+ ? [
564
+ {
565
+ role: 'user',
566
+ content: [{
567
+ type: 'text',
568
+ text: `${HISTORIAN_MERGE_INSTRUCTION}\n\n${partials
569
+ .map((partial, index) => `Partial digest ${String(index + 1)}:\n${JSON.stringify(partial)}`)
570
+ .join('\n\n')}`,
571
+ }],
572
+ },
573
+ ]
574
+ : call === 0
575
+ ? baseMessages
576
+ : [
577
+ ...replayOf(call),
578
+ { role: 'user', content: [{ type: 'text', text: historianPromptText(userObservationsRequested) }] },
579
+ ];
580
+ let callDraft;
581
+ for (let attempt = 0; attempt < 2; attempt += 1) {
582
+ attempts += 1;
583
+ const maxTokens = attemptOutputCap(effectiveMaxTokens, attempt, ceiling);
584
+ lastCap = maxTokens;
585
+ attemptCaps.push(maxTokens);
586
+ const truncated = failure instanceof HistorianTruncatedError;
587
+ const messages = attempt === 0
588
+ ? baseMessagesForCall
589
+ : [
590
+ ...baseMessagesForCall,
591
+ {
592
+ role: 'user',
593
+ content: [{
594
+ type: 'text',
595
+ text: truncated
596
+ ? `Your previous response was cut off at the ${String(attemptOutputCap(effectiveMaxTokens, attempt - 1, ceiling))}-token output cap before the JSON closed. `
597
+ + 'Return ONLY the JSON object, and keep it far shorter: p1 at most 150 words, the other tiers one or two sentences.'
598
+ : `Your previous response was rejected: ${failure?.message ?? 'invalid output'}. `
599
+ + 'Return ONLY the JSON object described above.',
600
+ }],
601
+ },
602
+ ];
603
+ const budgetMs = deadline - clock();
604
+ if (budgetMs <= 0)
605
+ throw new HistorianTimeoutError(timeoutMs);
606
+ try {
607
+ const collected = await collectAttempt(deps, {
608
+ provider: route.provider,
609
+ model: route.model,
610
+ ...reasoningEffort === undefined ? {} : { reasoningEffort },
611
+ messages,
612
+ maxTokens,
613
+ sessionId: input.sessionId,
614
+ purpose: 'compaction',
615
+ }, budgetMs, input.signal);
616
+ lastFinish = collected.finishReason;
617
+ lastText = collected.text;
618
+ if (collected.usage !== undefined)
619
+ usageTotal = addUsage(usageTotal, collected.usage);
620
+ const parsed = parseHistorianOutput(collected.text);
621
+ if (isMerge) {
622
+ finalDraft = parsed;
623
+ }
624
+ else {
625
+ partials.push(parsed);
626
+ if (merging) {
627
+ deps.journal?.info('historian', `historian chunk ${String(call + 1)}/${String(chunks.length)} digested (importance ${String(parsed.importance)})`, {
628
+ phase: 'chunk',
629
+ index: call + 1,
630
+ of: chunks.length,
631
+ nodes: chunks[call].length,
632
+ importance: parsed.importance,
633
+ facts: parsed.facts.length,
634
+ events: parsed.events.length,
635
+ attempts,
636
+ }, input.sessionId);
637
+ }
638
+ }
639
+ callDraft = parsed;
640
+ break;
641
+ }
642
+ catch (error) {
643
+ // Truncation and a rejected digest are retryable (once, with a longer
644
+ // cap or a repair nudge). A provider or transport failure is not: the
645
+ // request itself failed, so retrying it would only double the cost.
646
+ if (!(error instanceof HistorianTruncatedError) && !(error instanceof HistorianOutputError))
647
+ throw error;
648
+ failure = error;
649
+ deps.logger.warn(`magic-context historian: ${merging ? `call ${String(call + 1)}/${String(plannedCalls)} ` : ''}attempt ${String(attempt + 1)} rejected at ${String(maxTokens)} tokens (${failure.message})`);
650
+ }
651
+ }
652
+ if (callDraft === undefined)
653
+ throw failure ?? new Error('historian produced no digest');
654
+ }
655
+ const draft = finalDraft ?? partials[0]
656
+ ?? (() => { throw new Error('historian produced no digest'); })();
657
+ deps.journal?.info('historian', `historian digest ready (importance ${String(draft.importance)}, ${String(input.nodes.length)} nodes)`, {
658
+ phase: 'done',
659
+ ...baseData,
660
+ maxTokens: lastCap,
661
+ importance: draft.importance,
662
+ facts: draft.facts.length,
663
+ events: draft.events.length,
664
+ attempts,
665
+ durationMs: clock() - startedAt,
666
+ // The run's observed LLM cost, keyed as the provider reported it
667
+ // (absent when the provider never sent a usage chunk).
668
+ ...(usageTotal !== undefined ? { usage: usageTotal } : {}),
669
+ // 'stop' on a healthy run; 'none' would mean the stream ended without a
670
+ // terminal reason even though the JSON parsed (worth knowing).
671
+ finishReason: lastFinish ?? 'none',
672
+ }, input.sessionId);
673
+ terminal = true;
674
+ return draft;
675
+ }
676
+ catch (error) {
677
+ const reason = error instanceof Error ? error.message : String(error);
678
+ // The invariant this repairs: a `start` row with no terminal sibling is
679
+ // indistinguishable from a wedged run. Anything that escapes the attempt
680
+ // loop — provider errors, aborted streams, the timeout, an invalid digest
681
+ // twice — lands here exactly once.
682
+ if (!terminal) {
683
+ terminal = true;
684
+ deps.journal?.error('historian', `historian run failed (${classifyFailure(error)}): ${reason}`, {
685
+ phase: 'failed',
686
+ ...baseData,
687
+ // WHERE it died: the merge after a long chunk run is the observed
688
+ // failure point (the 17:50 timeout), so name the stage explicitly.
689
+ stage,
690
+ kind: classifyFailure(error),
691
+ reason,
692
+ errorName: error instanceof Error ? error.name : typeof error,
693
+ attempts,
694
+ // Every cap tried, in order: an escalated retry is visible at a glance
695
+ // (and a run that never escalated is the tell for a model-side cut).
696
+ attemptCaps,
697
+ durationMs: clock() - startedAt,
698
+ // The measured shape of a truncation: how much of the answer made it
699
+ // out before the cap. 0 chars ⇒ the budget was spent on reasoning;
700
+ // a long partial ⇒ the requested digest itself is too big.
701
+ ...error instanceof HistorianTruncatedError
702
+ ? { maxTokens: error.maxTokens, partialChars: error.partialChars }
703
+ : {},
704
+ // A rejected digest says nothing about WHY by itself: the same
705
+ // "invalid JSON" is produced by a cut stream and by a chatty model.
706
+ // The last attempt's terminal reason and its output length (plus a
707
+ // short tail) decide between them — `finishReason: 'none'` means the
708
+ // provider closed without a `finish` chunk.
709
+ ...error instanceof HistorianOutputError
710
+ ? {
711
+ finishReason: lastFinish ?? 'none',
712
+ outputChars: lastText.length,
713
+ // 400, not the original 120: every live invalid-JSON failure ended at
714
+ // the LAST character while its recorded tail was well-formed, which
715
+ // means the defect was in the middle and 120 chars could not show it.
716
+ outputTail: lastText.slice(-400),
717
+ }
718
+ : {},
719
+ }, input.sessionId);
720
+ }
721
+ deps.logger.warn(`magic-context historian: run failed (${classifyFailure(error)}): ${reason}`);
722
+ throw error;
723
+ }
724
+ }
725
+ /**
726
+ * #802 (upstream e9075bfec): a run that published nothing must not be
727
+ * re-attempted over the same messages — every later pass would pay for the
728
+ * same failure again. The watermark lives in the store's meta kv (no schema
729
+ * change): `historian_no_progress:<sessionId>` → `{endSeq, at, kind}`.
730
+ * An automatic run waits until the surface has a node NEWER than the failed
731
+ * span's end; manual paths (`forcedRange`, the checkpoint/recomp lane) bypass
732
+ * the gate entirely, mirroring upstream's "near-full and emergency paths
733
+ * still get one attempt".
734
+ */
735
+ const NO_PROGRESS_KEY_PREFIX = 'historian_no_progress:';
736
+ /** Fail-open: an unreadable record never blocks a run, and a store double
737
+ * without meta access (spec fakes) degrades to "no watermark". */
738
+ function readHistorianNoProgress(stores, sessionId) {
739
+ try {
740
+ const raw = stores.getMeta(`${NO_PROGRESS_KEY_PREFIX}${sessionId}`);
741
+ if (raw === undefined || raw === '')
742
+ return undefined;
743
+ const parsed = JSON.parse(raw);
744
+ if (typeof parsed.endSeq !== 'number' || typeof parsed.at !== 'number' || typeof parsed.kind !== 'string') {
745
+ return undefined;
746
+ }
747
+ return { endSeq: parsed.endSeq, at: parsed.at, kind: parsed.kind };
748
+ }
749
+ catch {
750
+ return undefined;
751
+ }
752
+ }
753
+ /** Fail-open: failing to persist the watermark must not mask the run's error. */
754
+ function writeHistorianNoProgress(stores, sessionId, value) {
755
+ try {
756
+ stores.setMeta(`${NO_PROGRESS_KEY_PREFIX}${sessionId}`, JSON.stringify(value));
757
+ }
758
+ catch {
759
+ /* the gate is advisory; a store hiccup only costs one retry */
760
+ }
761
+ }
762
+ function clearHistorianNoProgress(stores, sessionId) {
763
+ try {
764
+ stores.setMeta(`${NO_PROGRESS_KEY_PREFIX}${sessionId}`, '');
765
+ }
766
+ catch {
767
+ /* same fail-open */
768
+ }
769
+ }
770
+ /**
771
+ * Run one historian pass for a session. Returns the stored compartment, or
772
+ * undefined when nothing was eligible. A run already in flight is JOINED —
773
+ * the same eventual compartment, never a second concurrent call. Throws only
774
+ * for call or contract failures (the caller logs and continues).
775
+ */
776
+ export function runHistorian(deps, input) {
777
+ const existing = inFlight.get(input.sessionId);
778
+ if (existing !== undefined)
779
+ return existing;
780
+ const run = runHistorianExclusive(deps, input).finally(() => {
781
+ if (inFlight.get(input.sessionId) === run)
782
+ inFlight.delete(input.sessionId);
783
+ });
784
+ inFlight.set(input.sessionId, run);
785
+ return run;
786
+ }
787
+ async function runHistorianExclusive(deps, input) {
788
+ // #802: after a run that published nothing, automatic runs wait for a node
789
+ // newer than the failed span's end before paying for the span again.
790
+ // Manual paths (`forcedRange`) bypass the gate.
791
+ if (input.forcedRange === undefined) {
792
+ const noProgress = readHistorianNoProgress(deps.stores, input.sessionId);
793
+ const lastNode = input.nodes.length > 0 ? input.nodes[input.nodes.length - 1] : undefined;
794
+ if (noProgress !== undefined && lastNode !== undefined && noProgress.endSeq >= lastNode.seq) {
795
+ deps.journal?.info('historian', 'historian auto-run skipped: the previous run published nothing and no newer message has arrived (#802)', {
796
+ phase: 'no-progress-skip',
797
+ failedEndSeq: noProgress.endSeq,
798
+ lastSeq: lastNode.seq,
799
+ failedAt: noProgress.at,
800
+ kind: noProgress.kind,
801
+ }, input.sessionId);
802
+ return undefined;
803
+ }
804
+ }
805
+ const range = input.forcedRange !== undefined
806
+ ? { nodes: input.forcedRange, tokens: input.forcedRange.reduce((total, node) => total + estimateTokens(node.text), 0) }
807
+ : selectHistorianRange({
808
+ nodes: input.nodes,
809
+ covered: deps.stores.coveredSeqs(input.sessionId),
810
+ ...input.baselineNodeSeq === undefined ? {} : { baselineNodeSeq: input.baselineNodeSeq },
811
+ protectedNodes: deps.policy.historianProtectedNodes,
812
+ minNodes: deps.policy.historianMinNodes,
813
+ });
814
+ if (range === undefined)
815
+ return undefined;
816
+ const budget = historianTriggerBudget(input.contextWindow, deps.policy.historianAssumedWindow, deps.policy.historianMinBudgetTokens);
817
+ if (input.forcedRange === undefined && range.tokens < budget) {
818
+ deps.logger.info(`magic-context historian: eligible span ${String(range.tokens)} tokens is below the ${String(budget)}-token trigger`);
819
+ return undefined;
820
+ }
821
+ const now = deps.now?.() ?? Date.now();
822
+ const first = range.nodes[0];
823
+ const last = range.nodes[range.nodes.length - 1];
824
+ try {
825
+ const draft = await callHistorian(deps, {
826
+ sessionId: input.sessionId,
827
+ session: input.session,
828
+ nodes: range.nodes,
829
+ trigger: 'automatic',
830
+ // D1/D3: an oversized span digests in ~budget-token chunks with a merge
831
+ // call; the run budget scales with the chunk count (capped 600s).
832
+ chunkTokens: budget,
833
+ ...input.route === undefined ? {} : { route: input.route },
834
+ ...input.signal === undefined ? {} : { signal: input.signal },
835
+ ...input.nodes.find(node => node.role === 'system')?.seq === undefined
836
+ ? {}
837
+ : { systemSeq: input.nodes.find(node => node.role === 'system')?.seq },
838
+ });
839
+ const baselineEpoch = deps.stores.baselineState(input.sessionId)?.epoch ?? 0;
840
+ const compartment = deps.stores.allocateCompartment(input.sessionId, draft, {
841
+ startSeq: first.seq,
842
+ endSeq: last.seq,
843
+ seqs: range.nodes.map(node => node.seq),
844
+ tokens: range.tokens,
845
+ }, baselineEpoch, now);
846
+ // #802: a stored compartment IS progress — release the watermark so the
847
+ // next automatic run is judged on its own merits.
848
+ clearHistorianNoProgress(deps.stores, input.sessionId);
849
+ deps.logger.info(`magic-context historian: compartment §C${String(compartment.compartmentId)}§ stored `
850
+ + `(${String(range.nodes.length)} nodes, ~${String(range.tokens)} tokens, importance ${String(draft.importance)})`);
851
+ // WP5 — the `user` domain's collection half. This runs AFTER the compartment
852
+ // is durable, so a candidate that cannot be staged never costs the digest.
853
+ // Unlike the fact promotion next door (which may retry from the stored
854
+ // facts), the observations exist only in this draft: if the write fails they
855
+ // are gone, so the failure is journaled rather than swallowed.
856
+ const observations = draft.userObservations ?? [];
857
+ if (deps.profileCollect !== undefined && observations.length > 0) {
858
+ try {
859
+ const staged = deps.profileCollect({
860
+ sessionId: input.sessionId,
861
+ observations,
862
+ compartmentStart: first.seq,
863
+ compartmentEnd: last.seq,
864
+ });
865
+ deps.journal?.info('historian', `staged ${String(staged?.inserted ?? observations.length)} user observation(s) for review`, {
866
+ phase: 'profile-collect',
867
+ compartmentId: compartment.compartmentId,
868
+ observations: observations.length,
869
+ ...staged === undefined ? {} : { inserted: staged.inserted, skipped: staged.skipped },
870
+ }, input.sessionId);
871
+ }
872
+ catch (error) {
873
+ deps.journal?.error('error', `user observation staging failed: ${errText(error)}`, {
874
+ scope: 'profile-collect',
875
+ compartmentId: compartment.compartmentId,
876
+ observations: observations.length,
877
+ }, input.sessionId);
878
+ }
879
+ }
880
+ deps.journal?.info('historian', `compartment §C${String(compartment.compartmentId)}§ stored (~${String(range.tokens)} tokens, importance ${String(draft.importance)})`, {
881
+ phase: 'stored',
882
+ compartmentId: compartment.compartmentId,
883
+ nodes: range.nodes.length,
884
+ tokens: range.tokens,
885
+ importance: draft.importance,
886
+ // WP5 observability: without these two fields a missing profile-collect
887
+ // row is ambiguous — "empty observations" and "lane not armed" look
888
+ // identical from the journal. The count is the draft's raw output; the
889
+ // armed/off flag is whether the pre-step pass supplied the sink.
890
+ userObservations: observations.length,
891
+ profileCollect: deps.profileCollect === undefined ? 'off' : 'armed',
892
+ coveredStartSeq: first.seq,
893
+ coveredEndSeq: last.seq,
894
+ }, input.sessionId);
895
+ return compartment;
896
+ }
897
+ catch (error) {
898
+ // #802: nothing was published — the surface must grow past `last.seq`
899
+ // before the automatic lane pays for this span again.
900
+ writeHistorianNoProgress(deps.stores, input.sessionId, {
901
+ endSeq: last.seq,
902
+ at: deps.now?.() ?? Date.now(),
903
+ kind: classifyFailure(error),
904
+ });
905
+ throw error;
906
+ }
907
+ }