@aragon-agent/cli 0.6.39 → 0.6.42
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/CHANGELOG.md +109 -0
- package/README.md +126 -20
- package/dist/agent/controller.d.ts +139 -14
- package/dist/agent/controller.js +299 -40
- package/dist/agent/model-profile-settings.js +12 -1
- package/dist/agent/plan-prompt.d.ts +1 -3
- package/dist/agent/plan-prompt.js +13 -7
- package/dist/agent/reducer.d.ts +29 -1
- package/dist/agent/reducer.js +34 -6
- package/dist/agent/skill-triage-prompt.d.ts +42 -0
- package/dist/agent/skill-triage-prompt.js +74 -0
- package/dist/agent/system-prompt.d.ts +33 -2
- package/dist/agent/system-prompt.js +12 -4
- package/dist/cli.js +21 -5
- package/dist/commands/builtins.js +28 -185
- package/dist/commands/skills.js +86 -1
- package/dist/commands/subagent-command.d.ts +30 -0
- package/dist/commands/subagent-command.js +39 -0
- package/dist/commands/team-command.d.ts +49 -0
- package/dist/commands/team-command.js +259 -0
- package/dist/compaction/command.js +16 -3
- package/dist/compaction/compactor.d.ts +25 -0
- package/dist/compaction/compactor.js +111 -10
- package/dist/compaction/digest.d.ts +24 -1
- package/dist/compaction/digest.js +139 -17
- package/dist/compaction/limits.d.ts +21 -0
- package/dist/compaction/limits.js +21 -0
- package/dist/compaction/reason-text.d.ts +36 -0
- package/dist/compaction/reason-text.js +78 -0
- package/dist/compaction/types.d.ts +82 -14
- package/dist/compaction/wiring.d.ts +13 -8
- package/dist/compaction/wiring.js +1 -0
- package/dist/config/load.js +50 -3
- package/dist/config/model-kits.d.ts +2 -1
- package/dist/config/schema.d.ts +82 -6
- package/dist/config/schema.js +107 -9
- package/dist/config/store.js +10 -4
- package/dist/proc/log-mirror.d.ts +21 -4
- package/dist/proc/log-mirror.js +29 -7
- package/dist/skills/service.d.ts +41 -0
- package/dist/skills/service.js +62 -0
- package/dist/skills/triage.d.ts +194 -0
- package/dist/skills/triage.js +393 -0
- package/dist/team/dispatch-gate.d.ts +86 -0
- package/dist/team/dispatch-gate.js +75 -0
- package/dist/team/forced-dispatch.d.ts +95 -0
- package/dist/team/forced-dispatch.js +208 -0
- package/dist/team/forced-mode.d.ts +116 -0
- package/dist/team/forced-mode.js +156 -0
- package/dist/team/forced-prompt.d.ts +48 -0
- package/dist/team/forced-prompt.js +84 -0
- package/dist/team/limits.d.ts +61 -1
- package/dist/team/limits.js +69 -1
- package/dist/team/model.d.ts +5 -5
- package/dist/team/model.js +5 -5
- package/dist/team/normalize.d.ts +15 -0
- package/dist/team/normalize.js +21 -4
- package/dist/team/prompt.d.ts +6 -3
- package/dist/team/prompt.js +9 -5
- package/dist/team/task-tool.js +11 -7
- package/dist/tools/human-input.d.ts +35 -2
- package/dist/tools/human-input.js +38 -0
- package/dist/tools/plan-tools.d.ts +20 -12
- package/dist/tools/plan-tools.js +24 -17
- package/dist/ui/App.js +130 -11
- package/dist/ui/StatusBar.d.ts +15 -0
- package/dist/ui/StatusBar.js +2 -0
- package/dist/ui/Transcript.js +1 -1
- package/dist/ui/entries/CompactionCard.d.ts +11 -1
- package/dist/ui/entries/CompactionCard.js +65 -4
- package/dist/ui/layout/status-layout.d.ts +27 -0
- package/dist/ui/layout/status-layout.js +27 -1
- package/dist/ui/overlays/HelpOverlay.js +8 -4
- package/dist/ui/overlays/SettingsScreen.d.ts +14 -0
- package/dist/ui/overlays/SettingsScreen.js +39 -5
- package/dist/ui/overlays/TeamFanoutPage.d.ts +51 -0
- package/dist/ui/overlays/TeamFanoutPage.js +90 -0
- package/dist/ui/overlays/kit-settings-state.js +3 -2
- package/dist/ui/overlays/model-profile-state.d.ts +20 -2
- package/dist/ui/overlays/model-profile-state.js +12 -0
- package/dist/ui/overlays/team-fanout-state.d.ts +106 -0
- package/dist/ui/overlays/team-fanout-state.js +155 -0
- package/package.json +81 -81
|
@@ -13,6 +13,7 @@ import { prepareMemoryInput } from './memory-input.js';
|
|
|
13
13
|
import { mergeMemory, parseMemoryDelta } from './memory.js';
|
|
14
14
|
import { validateMemoryDelta } from './memory-validation.js';
|
|
15
15
|
import { renderMemoryMarkdown } from './memory-render.js';
|
|
16
|
+
import { compactionReasonLine } from './reason-text.js';
|
|
16
17
|
import { createCompactionIdentity, verifyCompactionIdentity, } from './memory-identity.js';
|
|
17
18
|
export class Compactor {
|
|
18
19
|
deps;
|
|
@@ -20,6 +21,7 @@ export class Compactor {
|
|
|
20
21
|
compactionsThisRun = 0;
|
|
21
22
|
lastCompactionTurn = -Infinity;
|
|
22
23
|
compactions = 0;
|
|
24
|
+
appliedCompactions = 0;
|
|
23
25
|
declined = 0;
|
|
24
26
|
tokensReclaimed = 0;
|
|
25
27
|
sessionUsage = { inputTokens: 0, outputTokens: 0 };
|
|
@@ -110,16 +112,24 @@ export class Compactor {
|
|
|
110
112
|
return { action: 'keep', reason: 'already_running' };
|
|
111
113
|
const manual = this.pendingManual;
|
|
112
114
|
this.pendingManual = null;
|
|
115
|
+
const keepRecentTurns = this.deps.getConfig().compaction.keepRecentTurns;
|
|
113
116
|
const state = {
|
|
114
117
|
operation: new CompactionOperation(++this.serial, ctx.signal, COMPACTION_LIMITS.operationTimeoutMs),
|
|
115
118
|
epoch: this.historyEpoch, settings: this.settingsKey(),
|
|
116
119
|
trigger: manual ? 'manual' : ctx.trigger, pressure: this.meter().current(),
|
|
117
120
|
threshold: this.deps.getConfig().compaction.threshold,
|
|
118
121
|
started: Date.now(), messages: [...ctx.messages], model: '',
|
|
122
|
+
keepRelaxed: false, keepRecentTurns,
|
|
119
123
|
};
|
|
120
124
|
this.active = state;
|
|
121
125
|
this.compactionsThisRun += 1;
|
|
122
126
|
this.lastCompactionTurn = ctx.turnIndex;
|
|
127
|
+
// THE CARD OPENS HERE, NOT AT THE FIRST PAID CALL (C). `announce` used to be
|
|
128
|
+
// invoked from `callOnce`, so the two rejections that account for 96 of 96
|
|
129
|
+
// attempts in the field - no safe cut, digest too long - happened BEFORE it
|
|
130
|
+
// and produced no card at all. Every attempt is now visible, and every one is
|
|
131
|
+
// settled by `finishRecord` below or by the wiring's own settle.
|
|
132
|
+
this.announce(state);
|
|
123
133
|
try {
|
|
124
134
|
if (state.operation.signal.aborted)
|
|
125
135
|
throw new Error('aborted');
|
|
@@ -134,23 +144,38 @@ export class Compactor {
|
|
|
134
144
|
if (this.identity && !verifyCompactionIdentity(state.messages, this.identity)) {
|
|
135
145
|
return this.keep(state, 'invalid_prior_memory');
|
|
136
146
|
}
|
|
147
|
+
// B: `retainMessages` IS THE SECOND PASS, AND IT IS OPT-IN (see the core
|
|
148
|
+
// argument at `PlanCompactionOptions.retainMessages`). Without it a long
|
|
149
|
+
// autonomous run - one user prompt and three hundred tool calls - has no
|
|
150
|
+
// legal cut at all, because every candidate leaves fewer than
|
|
151
|
+
// `keepRecentTurns` USER messages behind it. 107 of 119 long sessions on the
|
|
152
|
+
// reporting machine were in exactly that state.
|
|
137
153
|
const plan = planCompaction(state.messages, {
|
|
138
154
|
keepRecentTurns: config.keepRecentTurns,
|
|
139
155
|
protectedPrefix: countProtectedPrefix(state.messages, this.identity),
|
|
156
|
+
retainMessages: COMPACTION_LIMITS.minRetainedMessages,
|
|
140
157
|
});
|
|
141
158
|
if (!plan)
|
|
142
159
|
return this.keep(state, 'nothing_to_drop');
|
|
160
|
+
state.keepRelaxed = plan.keepRelaxed === true;
|
|
161
|
+
state.retainedTurns = plan.retainedTurns;
|
|
162
|
+
state.retainedMessages = state.messages.length - plan.cutIndex;
|
|
163
|
+
this.phase(state, 'digesting');
|
|
143
164
|
const input = prepareMemoryInput({ messages: state.messages, cutIndex: plan.cutIndex,
|
|
144
165
|
...(this.identity ? { identity: this.identity } : {}) });
|
|
145
166
|
const selected = this.selectInput(input, instructions);
|
|
167
|
+
state.elided = selected.digest.omitted;
|
|
146
168
|
this.checkMinimumCandidate(input, selected.digest, state, ctx.systemPrompt);
|
|
169
|
+
this.phase(state, 'summarizing');
|
|
147
170
|
const memory = await this.summarize(state, input, selected, instructions);
|
|
148
171
|
if (!this.isCurrent(state, ctx))
|
|
149
172
|
return this.keep(state, 'stale_history');
|
|
173
|
+
this.phase(state, 'validating');
|
|
150
174
|
const block = buildCompactedBlock({ memory, replaced: input.cutIndex,
|
|
151
175
|
turns: input.tail.filter((m) => m.role === 'user').length,
|
|
152
176
|
anchor: input.anchorShape, generation: memory.generation, truncated: false });
|
|
153
177
|
const candidate = [input.anchor, block, ...input.tail];
|
|
178
|
+
this.phase(state, 'applying');
|
|
154
179
|
this.checkCandidate(candidate, state, ctx.systemPrompt);
|
|
155
180
|
state.candidate = candidate;
|
|
156
181
|
state.generation = memory.generation;
|
|
@@ -168,7 +193,14 @@ export class Compactor {
|
|
|
168
193
|
return { choice, digest: this.digestFor(input, choice, instructions) };
|
|
169
194
|
}
|
|
170
195
|
catch (error) {
|
|
171
|
-
|
|
196
|
+
// BOTH CODES MEAN "THIS SUMMARIZER IS TOO SMALL FOR THIS HEAD", and both are
|
|
197
|
+
// worth one retry on the main model. `digest_budget_exceeded` no longer
|
|
198
|
+
// comes from the digest LENGTH (A1 elides instead of throwing) - it now
|
|
199
|
+
// comes only from the request-vs-window projection below - so the two names
|
|
200
|
+
// differ by which ceiling was hit, not by which is fatal.
|
|
201
|
+
const windowBound = /digest_budget_exceeded|summarizer_window_exceeded/
|
|
202
|
+
.test(errorText(error));
|
|
203
|
+
if (!choice.fromFastTier || !windowBound)
|
|
172
204
|
throw error;
|
|
173
205
|
const main = this.mainChoice();
|
|
174
206
|
return { choice: main, digest: this.digestFor(input, main, instructions) };
|
|
@@ -204,7 +236,12 @@ export class Compactor {
|
|
|
204
236
|
...(instructions ? { instructions } : {}) });
|
|
205
237
|
if (estimatePromptTokens(request.messages, request.systemPrompt) +
|
|
206
238
|
COMPACTION_LIMITS.summaryOutputTokens + CONTEXT_SAFETY_MARGIN_TOKENS >= window) {
|
|
207
|
-
|
|
239
|
+
// A DIFFERENT GATE FROM THE DIGEST'S LENGTH BUDGET, and it keeps a different
|
|
240
|
+
// reason code on purpose (A1). This one is the REAL ceiling - the actual
|
|
241
|
+
// request does not fit the summarizer's window - so it must still refuse.
|
|
242
|
+
// Giving it the length budget's name would send `selectInput`'s retry ladder
|
|
243
|
+
// and every log reader after the wrong cause.
|
|
244
|
+
throw new Error('summarizer_window_exceeded');
|
|
208
245
|
}
|
|
209
246
|
return digest;
|
|
210
247
|
}
|
|
@@ -246,7 +283,10 @@ export class Compactor {
|
|
|
246
283
|
const apiKey = this.deps.getApiKey(choice.ref.providerId, choice.role);
|
|
247
284
|
if (!apiKey)
|
|
248
285
|
throw new Error('no_api_key');
|
|
249
|
-
|
|
286
|
+
// THE RECORD NAMES THE MODEL THAT ACTUALLY RAN, which may be the main model
|
|
287
|
+
// after a fast-tier retry - `announce` can only name the one it EXPECTED.
|
|
288
|
+
if (state.index !== undefined)
|
|
289
|
+
state.model = choice.ref.modelId;
|
|
250
290
|
const outcome = await state.operation.run(() => runSummarizeCall({
|
|
251
291
|
...input, ref: choice.ref, apiKey, signal: state.operation.signal, now: Date.now(),
|
|
252
292
|
complete: (providerId, request) => this.deps.complete(providerId, request),
|
|
@@ -273,13 +313,35 @@ export class Compactor {
|
|
|
273
313
|
state.settings === this.settingsKey() && !ctx.signal.aborted &&
|
|
274
314
|
!state.operation.signal.aborted;
|
|
275
315
|
}
|
|
276
|
-
|
|
277
|
-
|
|
316
|
+
/**
|
|
317
|
+
* Open the card for THIS attempt (C).
|
|
318
|
+
*
|
|
319
|
+
* CALLED FROM `compact()`'S ENTRY, so the two pre-model rejections are visible
|
|
320
|
+
* too. The old position - inside `callOnce` - made an attempt that never called
|
|
321
|
+
* a model indistinguishable from a checkpoint that never fired, which is exactly
|
|
322
|
+
* how this feature spent 96 attempts without a single user-visible symptom.
|
|
323
|
+
*
|
|
324
|
+
* `state.index !== undefined` keeps it idempotent: `callOnce` no longer calls it,
|
|
325
|
+
* but the guard stays so a future re-announce cannot open a second card for one
|
|
326
|
+
* attempt.
|
|
327
|
+
*/
|
|
328
|
+
announce(state) {
|
|
278
329
|
if (state.index !== undefined)
|
|
279
330
|
return;
|
|
331
|
+
// THE MODEL IS NAMED BEFORE IT IS USED, and `''` when none resolves - the card
|
|
332
|
+
// renders that as "compacting context #N" with no dangling separator, and the
|
|
333
|
+
// attempt then declines at `selectInput` with `no_summarizer_model`.
|
|
334
|
+
state.model = this.resolveSummarizer()?.ref.modelId ?? '';
|
|
280
335
|
state.index = ++this.compactions;
|
|
281
336
|
this.deps.emit({ type: 'compaction_start', index: state.index,
|
|
282
337
|
trigger: state.trigger, model: state.model });
|
|
338
|
+
this.phase(state, 'planning');
|
|
339
|
+
}
|
|
340
|
+
/** Publish the current phase, for the live card only. */
|
|
341
|
+
phase(state, phase) {
|
|
342
|
+
if (state.index === undefined)
|
|
343
|
+
return;
|
|
344
|
+
this.deps.emit({ type: 'phase', index: state.index, phase });
|
|
283
345
|
}
|
|
284
346
|
keep(state, reason) {
|
|
285
347
|
state.reason = reason;
|
|
@@ -287,21 +349,50 @@ export class Compactor {
|
|
|
287
349
|
if (this.active !== state)
|
|
288
350
|
return outcome;
|
|
289
351
|
this.finishRecord(state, outcome);
|
|
290
|
-
|
|
352
|
+
// NO DUPLICATE NOTICE FOR A DECLINE THE CARD ALREADY REPORTS (C).
|
|
353
|
+
// `setEnabled`/`command.ts` used to pair every failed `/compact` with a
|
|
354
|
+
// `History preserved: <code>.` toast while the transcript said nothing; now the
|
|
355
|
+
// card is the surface, and two surfaces saying one thing is the failure mode
|
|
356
|
+
// `fast` and `team` both avoid. The notice survives ONLY for the one path that
|
|
357
|
+
// still has no card (an attempt that never announced).
|
|
358
|
+
if (state.trigger === 'manual' && state.index === undefined) {
|
|
291
359
|
this.deps.notify('warn', `History preserved: ${reason}.`);
|
|
360
|
+
}
|
|
292
361
|
if (this.deps.getConfig().compaction.onFailure === 'truncate' && !this.legacyFailureNotified) {
|
|
293
362
|
this.legacyFailureNotified = true;
|
|
294
363
|
this.deps.notify('info', 'Legacy truncate configuration: preserving history on failure.');
|
|
295
364
|
}
|
|
296
365
|
return outcome;
|
|
297
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* The relaxation disclosure, as record fields.
|
|
369
|
+
*
|
|
370
|
+
* ONE PLACE BUILDS IT, so the card, the reducer and the diagnostic log cannot
|
|
371
|
+
* describe one relaxed cut three different ways. Absent entirely when the first
|
|
372
|
+
* planning pass found a cut, which is what keeps a normal compaction's record
|
|
373
|
+
* byte-identical to the round before this change.
|
|
374
|
+
*/
|
|
375
|
+
relaxedFields(state) {
|
|
376
|
+
if (!state.keepRelaxed)
|
|
377
|
+
return {};
|
|
378
|
+
return {
|
|
379
|
+
keepRelaxed: true,
|
|
380
|
+
keepRecentTurnsUsed: state.keepRecentTurns,
|
|
381
|
+
...(state.retainedTurns === undefined ? {} : { retainedTurns: state.retainedTurns }),
|
|
382
|
+
...(state.retainedMessages === undefined
|
|
383
|
+
? {}
|
|
384
|
+
: { retainedMessages: state.retainedMessages }),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
298
387
|
finishRecord(state, outcome, summary) {
|
|
299
388
|
if (this.active !== state)
|
|
300
389
|
return;
|
|
301
|
-
|
|
390
|
+
// A DECLINE IS COUNTED EVEN WHEN IT HAS A CARD, so `/compact status` and the
|
|
391
|
+
// card agree about how many attempts did nothing.
|
|
392
|
+
if (outcome.action === 'keep')
|
|
302
393
|
this.declined += 1;
|
|
394
|
+
if (state.index === undefined)
|
|
303
395
|
return;
|
|
304
|
-
}
|
|
305
396
|
const p = state.pressure;
|
|
306
397
|
const record = {
|
|
307
398
|
index: state.index, trigger: state.trigger,
|
|
@@ -312,6 +403,8 @@ export class Compactor {
|
|
|
312
403
|
tokensBefore: p.occupied, tokensAfter: 0, model: state.model,
|
|
313
404
|
durationMs: Date.now() - state.started, ...(summary ? { summary } : {}),
|
|
314
405
|
memoryVersion: 2,
|
|
406
|
+
...this.relaxedFields(state),
|
|
407
|
+
...(state.elided ? { elidedMessages: state.elided } : {}),
|
|
315
408
|
decision: { occupied: p.occupied, contextWindow: p.contextWindow,
|
|
316
409
|
threshold: state.threshold,
|
|
317
410
|
source: p.source, deltaTokens: p.deltaTokens },
|
|
@@ -327,6 +420,7 @@ export class Compactor {
|
|
|
327
420
|
if (verdict.applied && state.candidate) {
|
|
328
421
|
this.identity = createCompactionIdentity(state.candidate);
|
|
329
422
|
this.generation = state.generation ?? this.generation;
|
|
423
|
+
this.appliedCompactions += 1;
|
|
330
424
|
this.recordApplied(verdict.tokensBefore ?? state.pressure.occupied, verdict.tokensAfter);
|
|
331
425
|
this.meter().onHistorySpliced();
|
|
332
426
|
const projected = (verdict.tokensAfter + (state.pressure.estimateOffset ?? 0)) /
|
|
@@ -359,7 +453,13 @@ export class Compactor {
|
|
|
359
453
|
return;
|
|
360
454
|
this.selfDisabled = true;
|
|
361
455
|
this.selfDisabledReason = reason;
|
|
362
|
-
|
|
456
|
+
// THE LOG LINE IS BACK ON PURPOSE (C / analysis 3.3.1 (c)). Commit 4b61a2f8a
|
|
457
|
+
// replaced `log.warn('compaction_self_disabled')` with a toast, and a toast
|
|
458
|
+
// scrolls away - so the one state that stops auto-compaction for the rest of a
|
|
459
|
+
// run had NO durable trace at all. Diagnosing the next report without this line
|
|
460
|
+
// means reading a session file and a config file by hand.
|
|
461
|
+
this.log.warn('compaction_self_disabled', { reason, trigger });
|
|
462
|
+
this.deps.notify('warn', `Auto-compaction paused: ${compactionReasonLine(reason)}. History preserved. Use /compact.`);
|
|
363
463
|
}
|
|
364
464
|
measure(_probe) { return this.meter().current(); }
|
|
365
465
|
lastMeasured() { return this.meter().lastPublished(); }
|
|
@@ -367,7 +467,8 @@ export class Compactor {
|
|
|
367
467
|
this.tokensReclaimed += Math.max(0, tokensBefore - tokensAfter);
|
|
368
468
|
}
|
|
369
469
|
sessionTotals() {
|
|
370
|
-
return { compactions: this.compactions,
|
|
470
|
+
return { compactions: this.compactions, applied: this.appliedCompactions,
|
|
471
|
+
declined: this.declined,
|
|
371
472
|
tokensReclaimed: this.tokensReclaimed, usage: this.sessionUsage, costUsd: this.costUsd,
|
|
372
473
|
pricingUnknown: this.pricingUnknown, inFlight: this.active !== null,
|
|
373
474
|
selfDisabled: this.selfDisabled, generation: this.generation,
|
|
@@ -22,5 +22,28 @@ export interface DigestResult {
|
|
|
22
22
|
export declare function isPriorSummaryBlock(_message: Message): boolean;
|
|
23
23
|
/** Derive the digest ceiling; known windows below reserve have no input room. */
|
|
24
24
|
export declare function digestBudgetChars(summarizerWindow?: number): number;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Build every head entry without mutating messages.
|
|
27
|
+
*
|
|
28
|
+
* NEVER REJECTS FOR LENGTH (context-compaction-never-fires A1). It used to throw
|
|
29
|
+
* `digest_budget_exceeded` whenever the rendered head exceeded the budget, which is
|
|
30
|
+
* the gate that turned 96 of 96 compaction attempts on the reporting machine into
|
|
31
|
+
* no-ops - the digests were simply too big, and every caller (including the
|
|
32
|
+
* preflight in `memory-input.ts`, which discards the text and only wanted the two
|
|
33
|
+
* counts) inherited the rejection.
|
|
34
|
+
*
|
|
35
|
+
* WHAT IS ELIDED AND WHAT IS NOT. The anchor, the prior memory and the legacy
|
|
36
|
+
* summary are never elided: the first is the user's own instruction and the other
|
|
37
|
+
* two are the memory this compaction is extending. Only `head` entries are
|
|
38
|
+
* candidates, and an elided entry is dropped WHOLE - never half-rendered, which
|
|
39
|
+
* would produce a quoted fragment with a source id that does not correspond to
|
|
40
|
+
* anything the model actually read.
|
|
41
|
+
*
|
|
42
|
+
* AN ELIDED ENTRY IS NOT QUOTABLE, BUT IT IS STILL COUNTED. It does not enter
|
|
43
|
+
* `sourceMap` (so a delta citing it fails validation rather than inventing
|
|
44
|
+
* evidence) and it DOES count towards `coverage.summarizedMessages`, because that
|
|
45
|
+
* counter is what tells `validateMemoryDelta` that this head contained something
|
|
46
|
+
* summarizable at all - an all-elided digest that counted zero would let an empty
|
|
47
|
+
* delta through and record a "successful" compaction with an empty memory.
|
|
48
|
+
*/
|
|
26
49
|
export declare function buildDigest(input: DigestInput): DigestResult;
|
|
@@ -42,7 +42,7 @@ function renderAssistant(message, calls) {
|
|
|
42
42
|
}
|
|
43
43
|
return { text: text.join('\n'), visible };
|
|
44
44
|
}
|
|
45
|
-
function renderEntry(message, messageId, calls
|
|
45
|
+
function renderEntry(message, messageId, calls) {
|
|
46
46
|
const rendered = message.role === 'assistant'
|
|
47
47
|
? renderAssistant(message, calls)
|
|
48
48
|
: contentText(message.content);
|
|
@@ -52,6 +52,12 @@ function renderEntry(message, messageId, calls, result) {
|
|
|
52
52
|
source.isLegacyReference = true;
|
|
53
53
|
heading += ' (legacy read-only reference, not current user acceptance)';
|
|
54
54
|
}
|
|
55
|
+
const entry = {
|
|
56
|
+
id: messageId,
|
|
57
|
+
text: `${heading}\n${rendered.text}`,
|
|
58
|
+
source,
|
|
59
|
+
summarized: message.role !== 'user',
|
|
60
|
+
};
|
|
55
61
|
if (message.role === 'tool_result') {
|
|
56
62
|
source.toolCallId = message.toolCallId;
|
|
57
63
|
source.toolName = calls.get(message.toolCallId);
|
|
@@ -65,15 +71,12 @@ function renderEntry(message, messageId, calls, result) {
|
|
|
65
71
|
const first = rendered.text.slice(0, edge);
|
|
66
72
|
const last = rendered.text.slice(-edge);
|
|
67
73
|
source.visibleText = clippedVisibleSections(rendered.visible, rendered.text, edge);
|
|
68
|
-
|
|
74
|
+
entry.text = `${heading}\n${first}\n[clipped originalChars=${rendered.text.length}`
|
|
69
75
|
+ ` omittedChars=${omittedChars}]\n${last}`;
|
|
70
|
-
|
|
76
|
+
entry.clip = { messageId, omittedChars };
|
|
71
77
|
}
|
|
72
78
|
}
|
|
73
|
-
|
|
74
|
-
if (message.role !== 'user')
|
|
75
|
-
result.coverage.summarizedMessages += 1;
|
|
76
|
-
return `${heading}\n${rendered.text}`;
|
|
79
|
+
return entry;
|
|
77
80
|
}
|
|
78
81
|
function clippedVisibleSections(parts, rendered, edge) {
|
|
79
82
|
const visible = [];
|
|
@@ -90,7 +93,86 @@ function clippedVisibleSections(parts, rendered, edge) {
|
|
|
90
93
|
}
|
|
91
94
|
return visible.filter(Boolean);
|
|
92
95
|
}
|
|
93
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* The elision marker, in the text the summarizer reads.
|
|
98
|
+
*
|
|
99
|
+
* IT CARRIES THE ID RANGE AND SAYS "NOT QUOTABLE", because the model's output is
|
|
100
|
+
* validated against the source map: a model that cannot tell which messages it did
|
|
101
|
+
* not see will cite one, and the delta is then rejected as `invalid_memory_delta`.
|
|
102
|
+
* Naming the range turns a mystery failure into a readable instruction.
|
|
103
|
+
*/
|
|
104
|
+
function elisionMarker(firstId, lastId, count) {
|
|
105
|
+
return `[elided ${count} messages, ids ${firstId}..${lastId} - not quotable]`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Decide the split: the FIRST and LAST entries verbatim, the middle elided.
|
|
109
|
+
*
|
|
110
|
+
* THIS IS THE SHAPE THE USER ASKED FOR ("keep the front and the back, compress
|
|
111
|
+
* the middle"), and it is also the only shape that keeps the digest useful: the
|
|
112
|
+
* front carries the task as it unfolded and the back carries what the run was
|
|
113
|
+
* doing when the budget ran out, which is what the next turn needs.
|
|
114
|
+
*
|
|
115
|
+
* FOUR RULES, AND EACH ONE FIXES A CASE THE OBVIOUS VERSION GETS WRONG:
|
|
116
|
+
*
|
|
117
|
+
* 1. The first entry is kept unless it ALONE overruns the whole budget - a
|
|
118
|
+
* pathological single 130 k message must not evict everything else, and the
|
|
119
|
+
* user's own instruction is protected by the anchor anyway (never elided).
|
|
120
|
+
* 2. The front stops at half the budget, so the back always gets a share. A
|
|
121
|
+
* front that greedily filled the budget would leave the most recent history
|
|
122
|
+
* out of the digest, which is the exact opposite of what the tail is for.
|
|
123
|
+
* 3. The LAST entry gets a floor of one, even when rule 2 spent the budget. The
|
|
124
|
+
* budget is a soft target here: the hard gate is the summarizer's window
|
|
125
|
+
* projection in `compactor.ts`, and exceeding a soft target by one entry is
|
|
126
|
+
* strictly better than handing the model a digest that predates the turn it is
|
|
127
|
+
* about to continue from.
|
|
128
|
+
* 4. At least ONE entry always survives, even at a zero budget, which is what
|
|
129
|
+
* makes `digestBudgetChars(7000) === 0` produce a quotable digest instead of
|
|
130
|
+
* an empty one that `validateMemoryDelta` accepts as an empty delta.
|
|
131
|
+
*/
|
|
132
|
+
function planElision(lengths, available) {
|
|
133
|
+
const total = lengths.length;
|
|
134
|
+
if (total === 0)
|
|
135
|
+
return { front: 0, back: 0 };
|
|
136
|
+
const usable = Math.max(0, available);
|
|
137
|
+
const half = Math.floor(usable / 2);
|
|
138
|
+
let front = lengths[0] > usable ? 0 : 1;
|
|
139
|
+
let frontChars = front === 1 ? lengths[0] : 0;
|
|
140
|
+
while (front < total && frontChars + lengths[front] <= half) {
|
|
141
|
+
frontChars += lengths[front];
|
|
142
|
+
front += 1;
|
|
143
|
+
}
|
|
144
|
+
let back = total - front > 0 ? 1 : 0;
|
|
145
|
+
let backChars = back === 1 ? lengths[total - 1] : 0;
|
|
146
|
+
while (back < total - front && backChars + lengths[total - 1 - back] <= usable - frontChars) {
|
|
147
|
+
backChars += lengths[total - 1 - back];
|
|
148
|
+
back += 1;
|
|
149
|
+
}
|
|
150
|
+
return { front, back };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Build every head entry without mutating messages.
|
|
154
|
+
*
|
|
155
|
+
* NEVER REJECTS FOR LENGTH (context-compaction-never-fires A1). It used to throw
|
|
156
|
+
* `digest_budget_exceeded` whenever the rendered head exceeded the budget, which is
|
|
157
|
+
* the gate that turned 96 of 96 compaction attempts on the reporting machine into
|
|
158
|
+
* no-ops - the digests were simply too big, and every caller (including the
|
|
159
|
+
* preflight in `memory-input.ts`, which discards the text and only wanted the two
|
|
160
|
+
* counts) inherited the rejection.
|
|
161
|
+
*
|
|
162
|
+
* WHAT IS ELIDED AND WHAT IS NOT. The anchor, the prior memory and the legacy
|
|
163
|
+
* summary are never elided: the first is the user's own instruction and the other
|
|
164
|
+
* two are the memory this compaction is extending. Only `head` entries are
|
|
165
|
+
* candidates, and an elided entry is dropped WHOLE - never half-rendered, which
|
|
166
|
+
* would produce a quoted fragment with a source id that does not correspond to
|
|
167
|
+
* anything the model actually read.
|
|
168
|
+
*
|
|
169
|
+
* AN ELIDED ENTRY IS NOT QUOTABLE, BUT IT IS STILL COUNTED. It does not enter
|
|
170
|
+
* `sourceMap` (so a delta citing it fails validation rather than inventing
|
|
171
|
+
* evidence) and it DOES count towards `coverage.summarizedMessages`, because that
|
|
172
|
+
* counter is what tells `validateMemoryDelta` that this head contained something
|
|
173
|
+
* summarizable at all - an all-elided digest that counted zero would let an empty
|
|
174
|
+
* delta through and record a "successful" compaction with an empty memory.
|
|
175
|
+
*/
|
|
94
176
|
export function buildDigest(input) {
|
|
95
177
|
const generation = input.generation ?? 1;
|
|
96
178
|
const start = input.startIndex ?? 0;
|
|
@@ -98,30 +180,70 @@ export function buildDigest(input) {
|
|
|
98
180
|
!Number.isSafeInteger(start) || start < 0) {
|
|
99
181
|
throw new MemoryError('invalid_memory_delta', '$.sourceIds');
|
|
100
182
|
}
|
|
183
|
+
const budgetChars = digestBudgetChars(input.summarizerWindow);
|
|
101
184
|
const result = {
|
|
102
185
|
text: '',
|
|
103
186
|
omitted: 0,
|
|
104
|
-
budgetChars
|
|
187
|
+
budgetChars,
|
|
105
188
|
sourceMap: new Map(),
|
|
106
189
|
coverage: { summarizedMessages: 0, clippedToolResults: [] },
|
|
107
190
|
};
|
|
108
|
-
const
|
|
191
|
+
const prefix = [];
|
|
109
192
|
if (input.anchor) {
|
|
110
|
-
|
|
193
|
+
prefix.push('ORIGINAL TASK (read-only reference, not current evidence):\n'
|
|
111
194
|
+ contentText(input.anchor.content).text);
|
|
112
195
|
}
|
|
113
196
|
if (input.priorMemory) {
|
|
114
|
-
|
|
197
|
+
prefix.push(`PRIOR MEMORY (read-only):\n${serializeMemory(input.priorMemory)}`);
|
|
115
198
|
}
|
|
116
199
|
if (input.legacySummary && !input.priorMemory?.legacySummary) {
|
|
117
|
-
|
|
200
|
+
prefix.push(`LEGACY SUMMARY (read-only, incomplete provenance):\n${input.legacySummary}`);
|
|
118
201
|
}
|
|
119
202
|
const calls = new Map();
|
|
120
|
-
input.head.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
203
|
+
const entries = input.head.map((message, index) => renderEntry(message, `g${generation}:m${start + index}`, calls));
|
|
204
|
+
const prefixChars = prefix.join('\n\n').length;
|
|
205
|
+
const budget = Math.max(budgetChars, LIMITS.digestMinChars);
|
|
206
|
+
const available = budget - prefixChars;
|
|
207
|
+
const fits = entries.reduce((sum, entry) => sum + entry.text.length, 0) <= available;
|
|
208
|
+
const kept = fits
|
|
209
|
+
? { front: entries.length, back: 0 }
|
|
210
|
+
: planElision(entries.map((entry) => entry.text.length), available);
|
|
211
|
+
const elidedFrom = kept.front;
|
|
212
|
+
const elidedTo = entries.length - kept.back;
|
|
213
|
+
if (elidedTo > elidedFrom) {
|
|
214
|
+
// The elided middle still names its tool calls, so a retained `tool_result`
|
|
215
|
+
// whose `tool_call` was dropped keeps `tool=<name>` instead of `unknown`.
|
|
216
|
+
for (const message of input.head.slice(elidedFrom, elidedTo)) {
|
|
217
|
+
if (message.role !== 'assistant')
|
|
218
|
+
continue;
|
|
219
|
+
for (const block of message.content) {
|
|
220
|
+
if (block.type === 'tool_call')
|
|
221
|
+
calls.set(block.toolCallId, block.toolName);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
226
|
+
if (index >= elidedFrom && index < elidedTo) {
|
|
227
|
+
// ELIDED: not quotable, but still counted (see the function's header).
|
|
228
|
+
if (entries[index].summarized)
|
|
229
|
+
result.coverage.summarizedMessages += 1;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const entry = entries[index];
|
|
233
|
+
result.sourceMap.set(entry.id, entry.source);
|
|
234
|
+
if (entry.summarized)
|
|
235
|
+
result.coverage.summarizedMessages += 1;
|
|
236
|
+
if (entry.clip)
|
|
237
|
+
result.coverage.clippedToolResults.push(entry.clip);
|
|
124
238
|
}
|
|
239
|
+
result.omitted = Math.max(0, elidedTo - elidedFrom);
|
|
240
|
+
const parts = [...prefix];
|
|
241
|
+
parts.push(...entries.slice(0, kept.front).map((entry) => entry.text));
|
|
242
|
+
if (result.omitted > 0) {
|
|
243
|
+
parts.push(elisionMarker(entries[elidedFrom].id, entries[elidedTo - 1].id, result.omitted));
|
|
244
|
+
}
|
|
245
|
+
parts.push(...entries.slice(elidedTo).map((entry) => entry.text));
|
|
246
|
+
result.text = parts.join('\n\n');
|
|
125
247
|
if (result.coverage.clippedToolResults.length > LIMITS.coverageClips) {
|
|
126
248
|
throw new MemoryError('protected_memory_too_large', '$.coverage');
|
|
127
249
|
}
|
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
/** Structural limits for v2 compaction. All character counts are UTF-16 units. */
|
|
2
2
|
export declare const COMPACTION_LIMITS: {
|
|
3
3
|
readonly digestMaxChars: 120000;
|
|
4
|
+
/**
|
|
5
|
+
* The smallest digest worth sending when the budget collapses (never-fires A1).
|
|
6
|
+
*
|
|
7
|
+
* `digestBudgetChars(7000)` is 0 - a summarizer whose window is smaller than the
|
|
8
|
+
* reserve has no input room - and before this change a 0 budget simply threw.
|
|
9
|
+
* Eliding is only safe while SOMETHING survives: a digest with no messages and no
|
|
10
|
+
* source map forces the model to invent a delta (`invalid_memory_delta`) or to
|
|
11
|
+
* return none, so the floor is spent on the first entry, the last entry and the
|
|
12
|
+
* elision marker.
|
|
13
|
+
*/
|
|
14
|
+
readonly digestMinChars: 2000;
|
|
15
|
+
/**
|
|
16
|
+
* The tail budget the SECOND planning pass may fall back to (never-fires B).
|
|
17
|
+
*
|
|
18
|
+
* A MEANINGFUL TAIL, NOT A TOKEN-LEVEL FLOOR. `planCompaction` walks down from
|
|
19
|
+
* the LARGEST safe cut, so the cut it relaxes to leaves exactly this many
|
|
20
|
+
* messages behind; too small a number would satisfy the planner while breaking
|
|
21
|
+
* the thing the user asked for (the recent instructions stay verbatim). Tens of
|
|
22
|
+
* messages is one or two agentic turns' worth of tool calls.
|
|
23
|
+
*/
|
|
24
|
+
readonly minRetainedMessages: 40;
|
|
4
25
|
readonly summarizerReserveTokens: 8000;
|
|
5
26
|
readonly summaryMaxChars: 32000;
|
|
6
27
|
readonly summaryOutputTokens: 4096;
|
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
/** Structural limits for v2 compaction. All character counts are UTF-16 units. */
|
|
2
2
|
export const COMPACTION_LIMITS = {
|
|
3
3
|
digestMaxChars: 120_000,
|
|
4
|
+
/**
|
|
5
|
+
* The smallest digest worth sending when the budget collapses (never-fires A1).
|
|
6
|
+
*
|
|
7
|
+
* `digestBudgetChars(7000)` is 0 - a summarizer whose window is smaller than the
|
|
8
|
+
* reserve has no input room - and before this change a 0 budget simply threw.
|
|
9
|
+
* Eliding is only safe while SOMETHING survives: a digest with no messages and no
|
|
10
|
+
* source map forces the model to invent a delta (`invalid_memory_delta`) or to
|
|
11
|
+
* return none, so the floor is spent on the first entry, the last entry and the
|
|
12
|
+
* elision marker.
|
|
13
|
+
*/
|
|
14
|
+
digestMinChars: 2_000,
|
|
15
|
+
/**
|
|
16
|
+
* The tail budget the SECOND planning pass may fall back to (never-fires B).
|
|
17
|
+
*
|
|
18
|
+
* A MEANINGFUL TAIL, NOT A TOKEN-LEVEL FLOOR. `planCompaction` walks down from
|
|
19
|
+
* the LARGEST safe cut, so the cut it relaxes to leaves exactly this many
|
|
20
|
+
* messages behind; too small a number would satisfy the planner while breaking
|
|
21
|
+
* the thing the user asked for (the recent instructions stay verbatim). Tens of
|
|
22
|
+
* messages is one or two agentic turns' worth of tool calls.
|
|
23
|
+
*/
|
|
24
|
+
minRetainedMessages: 40,
|
|
4
25
|
summarizerReserveTokens: 8_000,
|
|
5
26
|
summaryMaxChars: 32_000,
|
|
6
27
|
summaryOutputTokens: 4_096,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human wording for compaction outcomes (context-compaction-never-fires C).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope
|
|
5
|
+
* (`glyphs.test.ts::inScope`), so every glyph the card draws comes from
|
|
6
|
+
* `pickGlyphs` and nothing here may carry a non-ASCII byte.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS MODULE EXISTS AT ALL. The card used to print the raw rejection code
|
|
9
|
+
* (`context not compacted #4: digest_budget_exceeded: $.digest`), which names the
|
|
10
|
+
* internal gate and tells the user nothing they can act on. A decline is a
|
|
11
|
+
* QUESTION ("why, and what do I do now"), so each code gets one phrase and - for
|
|
12
|
+
* the ones a user can act on - one remedy.
|
|
13
|
+
*
|
|
14
|
+
* IT IS ALSO THE SELF-DISABLE NOTICE'S SOURCE. The one line that says
|
|
15
|
+
* auto-compaction has paused reads the same phrase, so the card and the toast
|
|
16
|
+
* cannot describe one failure two ways.
|
|
17
|
+
*/
|
|
18
|
+
import type { CompactionPhase } from './types.js';
|
|
19
|
+
/** One phrase plus, where it helps, the thing the user can do about it. */
|
|
20
|
+
export interface ReasonText {
|
|
21
|
+
/** What happened, in one clause. Never contains the raw code. */
|
|
22
|
+
text: string;
|
|
23
|
+
/** The remedy, or `undefined` when there is nothing to offer. */
|
|
24
|
+
action?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A phrase for a rejection code, falling back to the code itself.
|
|
28
|
+
*
|
|
29
|
+
* THE FALLBACK IS THE RAW CODE, deliberately: a new gate nobody wrote wording for
|
|
30
|
+
* must still say SOMETHING specific on the card, and an invented sentence would
|
|
31
|
+
* be worse than a code the next reviewer can grep.
|
|
32
|
+
*/
|
|
33
|
+
export declare function describeCompactionReason(reason: string): ReasonText;
|
|
34
|
+
/** The card's one-line reason, with the remedy appended when there is one. */
|
|
35
|
+
export declare function compactionReasonLine(reason: string): string;
|
|
36
|
+
export declare function compactionPhaseLabel(phase: CompactionPhase): string;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const REASONS = {
|
|
2
|
+
nothing_to_drop: {
|
|
3
|
+
text: 'no safe cut - this run has too few user turns to keep',
|
|
4
|
+
action: 'keep talking, or lower /compact keep',
|
|
5
|
+
},
|
|
6
|
+
digest_budget_exceeded: {
|
|
7
|
+
text: 'the summary input was too large for one call',
|
|
8
|
+
action: 'retry - the middle is elided now',
|
|
9
|
+
},
|
|
10
|
+
summarizer_window_exceeded: {
|
|
11
|
+
text: 'the summary request does not fit the summarizer window',
|
|
12
|
+
action: 'pick a summarizer with a larger window',
|
|
13
|
+
},
|
|
14
|
+
protected_memory_too_large: {
|
|
15
|
+
text: 'the task memory does not fit its protection budget',
|
|
16
|
+
action: 'start a fresh conversation',
|
|
17
|
+
},
|
|
18
|
+
invalid_memory_delta: {
|
|
19
|
+
text: 'the summarizer returned an unusable memory delta',
|
|
20
|
+
action: 'retry',
|
|
21
|
+
},
|
|
22
|
+
empty_memory_delta: {
|
|
23
|
+
text: 'the summarizer returned an empty memory delta',
|
|
24
|
+
action: 'retry',
|
|
25
|
+
},
|
|
26
|
+
no_token_reclaim: {
|
|
27
|
+
text: 'the summary would not have reclaimed any tokens',
|
|
28
|
+
},
|
|
29
|
+
timeout: { text: 'the summarizer timed out', action: 'retry' },
|
|
30
|
+
no_summarizer_model: {
|
|
31
|
+
text: 'no summarizer model resolves',
|
|
32
|
+
action: 'set a model or add a provider key',
|
|
33
|
+
},
|
|
34
|
+
no_api_key: { text: 'the summarizer provider has no API key' },
|
|
35
|
+
already_running: { text: 'another compaction is already running' },
|
|
36
|
+
invalid_prior_memory: {
|
|
37
|
+
text: 'the existing task memory does not match this history',
|
|
38
|
+
action: 'start a fresh conversation',
|
|
39
|
+
},
|
|
40
|
+
invalid_history: { text: 'this history cannot be split safely' },
|
|
41
|
+
protected_multimodal_tail: {
|
|
42
|
+
text: 'an image in the middle of this history would have to be dropped',
|
|
43
|
+
},
|
|
44
|
+
insufficient_reclaim: {
|
|
45
|
+
text: 'the splice freed too little to stay below the threshold',
|
|
46
|
+
},
|
|
47
|
+
disabled: { text: 'auto-compaction is off for this session', action: '/compact on' },
|
|
48
|
+
cancelled: { text: 'cancelled' },
|
|
49
|
+
aborted: { text: 'cancelled' },
|
|
50
|
+
stale_history: { text: 'the history changed while the summary was running' },
|
|
51
|
+
adoption_failed: { text: 'the compacted history could not be adopted' },
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* A phrase for a rejection code, falling back to the code itself.
|
|
55
|
+
*
|
|
56
|
+
* THE FALLBACK IS THE RAW CODE, deliberately: a new gate nobody wrote wording for
|
|
57
|
+
* must still say SOMETHING specific on the card, and an invented sentence would
|
|
58
|
+
* be worse than a code the next reviewer can grep.
|
|
59
|
+
*/
|
|
60
|
+
export function describeCompactionReason(reason) {
|
|
61
|
+
const code = reason.split(':', 1)[0].trim();
|
|
62
|
+
return REASONS[code] ?? { text: reason };
|
|
63
|
+
}
|
|
64
|
+
/** The card's one-line reason, with the remedy appended when there is one. */
|
|
65
|
+
export function compactionReasonLine(reason) {
|
|
66
|
+
const { text, action } = describeCompactionReason(reason);
|
|
67
|
+
return action ? `${text} (${action})` : text;
|
|
68
|
+
}
|
|
69
|
+
const PHASES = {
|
|
70
|
+
planning: 'planning the cut',
|
|
71
|
+
digesting: 'building the digest',
|
|
72
|
+
summarizing: 'summarizing',
|
|
73
|
+
validating: 'validating the summary',
|
|
74
|
+
applying: 'applying',
|
|
75
|
+
};
|
|
76
|
+
export function compactionPhaseLabel(phase) {
|
|
77
|
+
return PHASES[phase];
|
|
78
|
+
}
|