@elvisw/dsh-magic-context 0.0.0-bootstrap.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/LICENSE +23 -0
- package/NOTICE +93 -0
- package/README.md +453 -0
- package/README.zh-CN.md +368 -0
- package/cordis.patch.yml +20 -0
- package/lib/client.js +2270 -0
- package/lib/core/compartments.js +424 -0
- package/lib/core/corpus-store.js +176 -0
- package/lib/core/directive-detect.js +147 -0
- package/lib/core/domain-reconcile.js +394 -0
- package/lib/core/domains.js +127 -0
- package/lib/core/embedding-local.js +247 -0
- package/lib/core/global-autostage.js +223 -0
- package/lib/core/global-domains.js +302 -0
- package/lib/core/journal.js +248 -0
- package/lib/core/llm-deadline.js +53 -0
- package/lib/core/machine-domain.js +106 -0
- package/lib/core/maintenance.js +380 -0
- package/lib/core/memory-bridge.js +818 -0
- package/lib/core/memory-categories.js +84 -0
- package/lib/core/memory-scoring.js +65 -0
- package/lib/core/memory-search.js +414 -0
- package/lib/core/memory-write.js +1176 -0
- package/lib/core/policy.js +111 -0
- package/lib/core/profile-confirmation.js +55 -0
- package/lib/core/profile-review.js +375 -0
- package/lib/core/profile-write.js +283 -0
- package/lib/core/reference-seeds.js +63 -0
- package/lib/core/route-health.js +106 -0
- package/lib/core/stores.js +1021 -0
- package/lib/core/tagger.js +7 -0
- package/lib/core/types.js +15 -0
- package/lib/core/vector-backfill.js +140 -0
- package/lib/core/vector-lane.js +184 -0
- package/lib/dsh/command-surface.js +376 -0
- package/lib/dsh/compaction-engine.js +231 -0
- package/lib/dsh/config-switches.js +56 -0
- package/lib/dsh/config.js +13 -0
- package/lib/dsh/context-pressure.js +36 -0
- package/lib/dsh/drop-landing.js +42 -0
- package/lib/dsh/emergency.js +89 -0
- package/lib/dsh/flush-command.js +148 -0
- package/lib/dsh/global-review.js +412 -0
- package/lib/dsh/historian.js +907 -0
- package/lib/dsh/live-journal.js +81 -0
- package/lib/dsh/maintenance-wiring.js +229 -0
- package/lib/dsh/management-pass.js +564 -0
- package/lib/dsh/memory-lane.js +535 -0
- package/lib/dsh/pre-step.js +675 -0
- package/lib/dsh/project-drift.js +116 -0
- package/lib/dsh/recomp.js +192 -0
- package/lib/dsh/reduce-guidance.js +188 -0
- package/lib/dsh/seams.js +588 -0
- package/lib/dsh/search-hint.js +186 -0
- package/lib/dsh/session-notice.js +100 -0
- package/lib/dsh/status-command.js +75 -0
- package/lib/dsh/telemetry.js +349 -0
- package/lib/dsh/tool-bridge.js +1009 -0
- package/lib/dsh/volatile.js +89 -0
- package/lib/index.js +931 -0
- package/package.json +110 -0
- package/tools/migrate-profile-package-name.mjs +317 -0
- package/vendor/onnx/LICENSE +27 -0
- package/vendor/onnx/ort-wasm-simd-threaded.mjs +59 -0
- package/vendor/onnx/ort-wasm-simd-threaded.wasm +0 -0
- 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
|
+
}
|