@aexol/spectral 0.9.58 → 0.9.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +43 -0
  3. package/dist/agent/index.d.ts +6 -1
  4. package/dist/agent/index.d.ts.map +1 -1
  5. package/dist/agent/index.js +133 -14
  6. package/dist/extensions/web/index.d.ts +10 -0
  7. package/dist/extensions/web/index.d.ts.map +1 -0
  8. package/dist/extensions/web/index.js +11 -0
  9. package/dist/extensions/web/tools/fetch.d.ts +3 -0
  10. package/dist/extensions/web/tools/fetch.d.ts.map +1 -0
  11. package/dist/extensions/web/tools/fetch.js +80 -0
  12. package/dist/extensions/web/utils/fetcher.d.ts +34 -0
  13. package/dist/extensions/web/utils/fetcher.d.ts.map +1 -0
  14. package/dist/extensions/web/utils/fetcher.js +289 -0
  15. package/dist/extensions/web/utils/parser.d.ts +15 -0
  16. package/dist/extensions/web/utils/parser.d.ts.map +1 -0
  17. package/dist/extensions/web/utils/parser.js +205 -0
  18. package/dist/memory/branch.d.ts.map +1 -1
  19. package/dist/memory/branch.js +16 -8
  20. package/dist/memory/commands/status.d.ts.map +1 -1
  21. package/dist/memory/commands/status.js +3 -2
  22. package/dist/memory/config.d.ts +12 -0
  23. package/dist/memory/config.d.ts.map +1 -1
  24. package/dist/memory/config.js +21 -2
  25. package/dist/memory/hooks/compaction-trigger.d.ts.map +1 -1
  26. package/dist/memory/hooks/compaction-trigger.js +19 -3
  27. package/dist/memory/index.d.ts.map +1 -1
  28. package/dist/memory/index.js +2 -0
  29. package/dist/memory/tool-output-compressor.d.ts +14 -20
  30. package/dist/memory/tool-output-compressor.d.ts.map +1 -1
  31. package/dist/memory/tool-output-compressor.js +391 -137
  32. package/dist/memory/tools/compact-context.d.ts +5 -0
  33. package/dist/memory/tools/compact-context.d.ts.map +1 -0
  34. package/dist/memory/tools/compact-context.js +772 -0
  35. package/dist/relay/dispatcher.d.ts +1 -1
  36. package/dist/relay/dispatcher.d.ts.map +1 -1
  37. package/dist/relay/dispatcher.js +61 -1
  38. package/dist/sdk/coding-agent/core/agent-session.d.ts +56 -3
  39. package/dist/sdk/coding-agent/core/agent-session.d.ts.map +1 -1
  40. package/dist/sdk/coding-agent/core/agent-session.js +408 -210
  41. package/dist/sdk/coding-agent/core/compaction/compaction.d.ts +57 -7
  42. package/dist/sdk/coding-agent/core/compaction/compaction.d.ts.map +1 -1
  43. package/dist/sdk/coding-agent/core/compaction/compaction.js +146 -27
  44. package/dist/sdk/coding-agent/core/compaction/dcp-lite.d.ts +24 -0
  45. package/dist/sdk/coding-agent/core/compaction/dcp-lite.d.ts.map +1 -0
  46. package/dist/sdk/coding-agent/core/compaction/dcp-lite.js +411 -0
  47. package/dist/sdk/coding-agent/core/compaction/index.d.ts +2 -0
  48. package/dist/sdk/coding-agent/core/compaction/index.d.ts.map +1 -1
  49. package/dist/sdk/coding-agent/core/compaction/index.js +2 -0
  50. package/dist/sdk/coding-agent/core/compaction/policy.d.ts +71 -0
  51. package/dist/sdk/coding-agent/core/compaction/policy.d.ts.map +1 -0
  52. package/dist/sdk/coding-agent/core/compaction/policy.js +147 -0
  53. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  54. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +9 -0
  55. package/dist/sdk/coding-agent/core/extensions/types.d.ts +17 -0
  56. package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
  57. package/dist/sdk/coding-agent/core/settings-manager.d.ts +4 -10
  58. package/dist/sdk/coding-agent/core/settings-manager.d.ts.map +1 -1
  59. package/dist/sdk/coding-agent/core/settings-manager.js +17 -17
  60. package/dist/sdk/coding-agent/index.d.ts +1 -1
  61. package/dist/sdk/coding-agent/index.d.ts.map +1 -1
  62. package/dist/server/agent-bridge.d.ts +12 -5
  63. package/dist/server/agent-bridge.d.ts.map +1 -1
  64. package/dist/server/agent-bridge.js +65 -17
  65. package/dist/server/handlers/paths-home.d.ts +11 -0
  66. package/dist/server/handlers/paths-home.d.ts.map +1 -0
  67. package/dist/server/handlers/paths-home.js +10 -0
  68. package/dist/server/handlers/paths-list.d.ts +24 -0
  69. package/dist/server/handlers/paths-list.d.ts.map +1 -0
  70. package/dist/server/handlers/paths-list.js +56 -0
  71. package/dist/server/handlers/paths-pick-directory.d.ts.map +1 -1
  72. package/dist/server/handlers/paths-pick-directory.js +2 -1
  73. package/dist/server/handlers/sessions.d.ts +11 -1
  74. package/dist/server/handlers/sessions.d.ts.map +1 -1
  75. package/dist/server/handlers/sessions.js +22 -2
  76. package/dist/server/handlers/settings.d.ts +18 -1
  77. package/dist/server/handlers/settings.d.ts.map +1 -1
  78. package/dist/server/handlers/settings.js +63 -13
  79. package/dist/server/session-stream.d.ts +65 -37
  80. package/dist/server/session-stream.d.ts.map +1 -1
  81. package/dist/server/session-stream.js +525 -119
  82. package/dist/server/storage.d.ts +3 -0
  83. package/dist/server/storage.d.ts.map +1 -1
  84. package/dist/server/storage.js +25 -0
  85. package/dist/server/wire.d.ts +127 -2
  86. package/dist/server/wire.d.ts.map +1 -1
  87. package/package.json +2 -1
@@ -0,0 +1,772 @@
1
+ import { Type } from "../../sdk/ai/index.js";
2
+ import { compactDcpLite, DEFAULT_COMPACTION_POLICY, estimateContextTokens, getManualCompactionSettings, normalizeAgentDrivenOlderHistoryKeepRecentTokens, prepareCompaction, } from "../../sdk/coding-agent/core/compaction/index.js";
3
+ import { defineTool } from "../../sdk/coding-agent/index.js";
4
+ import { buildSessionContext } from "../../sdk/coding-agent/core/session-manager.js";
5
+ import { rawTokensSinceLastCompaction } from "../branch.js";
6
+ import { debugLog, withDebugLogContext } from "../debug-log.js";
7
+ export const COMPACT_CONTEXT_TOOL_NAME = "compact_context";
8
+ const DCP_NUDGE_HEADING = "## DCP-lite context hygiene";
9
+ const MAX_COMPACT_CONTEXT_TEXT_CHARS = 240;
10
+ const MAX_CUSTOM_INSTRUCTIONS_CHARS = 640;
11
+ const DEFAULT_COMPACT_CONTEXT_SCOPE = "closed_phase";
12
+ const DEFAULT_COMPACT_CONTEXT_MODE = "after_turn";
13
+ const REQUEST_TIME_VIRTUAL_COMPACTION_ID = "__spectral_dcp_lite_request_time_compaction__";
14
+ const COMPACT_CONTEXT_TOOL_PARAMS = Type.Object({
15
+ scope: Type.Optional(Type.Union([
16
+ Type.Literal("closed_phase"),
17
+ Type.Literal("older_history"),
18
+ ], {
19
+ description: "Compaction scope. closed_phase (default) compacts the completed phase boundary. older_history compacts stale older history while preserving a recent raw suffix.",
20
+ })),
21
+ reason: Type.Optional(Type.String({
22
+ description: "Short reason compaction is safe now, e.g. 'implementation phase complete'.",
23
+ })),
24
+ focus: Type.Optional(Type.String({
25
+ description: "Short topic/focus for the compaction summary, e.g. 'preserve verification status and changed files'.",
26
+ })),
27
+ phase: Type.Optional(Type.String({
28
+ description: "Optional completed phase or topic name, e.g. 'implementation' or 'verification'.",
29
+ })),
30
+ customInstructions: Type.Optional(Type.String({
31
+ description: "Optional concise custom preservation instructions. Overrides generated reason/focus/phase text when provided.",
32
+ })),
33
+ phaseComplete: Type.Optional(Type.Boolean({
34
+ description: "Set false if the current phase is still active/open; false rejects closed_phase activation but can be used with older_history.",
35
+ })),
36
+ phaseStatus: Type.Optional(Type.Union([
37
+ Type.Literal("complete"),
38
+ Type.Literal("completed"),
39
+ Type.Literal("done"),
40
+ Type.Literal("closed"),
41
+ Type.Literal("active"),
42
+ Type.Literal("open"),
43
+ Type.Literal("in_progress"),
44
+ ], {
45
+ description: "Optional explicit phase status. active/open/in_progress rejects closed_phase activation but can be used with older_history.",
46
+ })),
47
+ keepRecentTokens: Type.Optional(Type.Number({
48
+ description: "For scope=older_history only: recent raw suffix to preserve. Values are conservatively bounded before activation.",
49
+ })),
50
+ mode: Type.Optional(Type.Union([
51
+ Type.Literal("request_time"),
52
+ Type.Literal("after_turn"),
53
+ ], {
54
+ description: "When to compact. When omitted, the mode is chosen automatically: request_time (virtual pruning of the next provider request, no session mutation) while the agent is mid-run with an active phase; after_turn (persisted post-run compaction) when idle or the phase is closed. Pass explicitly to override.",
55
+ })),
56
+ });
57
+ const stateByRuntime = new WeakMap();
58
+ function getState(runtime) {
59
+ let state = stateByRuntime.get(runtime);
60
+ if (!state) {
61
+ state = {
62
+ request: null,
63
+ nudgeTurnCount: 0,
64
+ lastNudgeTurn: 0,
65
+ lastAgentDrivenRequestTurn: null,
66
+ providerRequestCount: 0,
67
+ lastInRunNudgeRequest: 0,
68
+ };
69
+ stateByRuntime.set(runtime, state);
70
+ }
71
+ return state;
72
+ }
73
+ function normalizeText(value, maxChars) {
74
+ const normalized = value?.replace(/\s+/g, " ").trim();
75
+ if (!normalized)
76
+ return undefined;
77
+ return normalized.length <= maxChars
78
+ ? normalized
79
+ : `${normalized.slice(0, maxChars - 1).trimEnd()}…`;
80
+ }
81
+ function buildCustomInstructions(params) {
82
+ const customInstructions = normalizeText(params.customInstructions, MAX_CUSTOM_INSTRUCTIONS_CHARS);
83
+ if (customInstructions)
84
+ return customInstructions;
85
+ const focus = normalizeText(params.focus, MAX_COMPACT_CONTEXT_TEXT_CHARS);
86
+ const reason = normalizeText(params.reason, MAX_COMPACT_CONTEXT_TEXT_CHARS);
87
+ const phase = normalizeText(params.phase, MAX_COMPACT_CONTEXT_TEXT_CHARS);
88
+ if (focus && !reason && !phase)
89
+ return focus;
90
+ const parts = [];
91
+ if (phase)
92
+ parts.push(`Phase: ${phase}`);
93
+ if (focus)
94
+ parts.push(`Focus: ${focus}`);
95
+ if (reason)
96
+ parts.push(`Reason: ${reason}`);
97
+ return normalizeText(parts.join(". "), MAX_CUSTOM_INSTRUCTIONS_CHARS);
98
+ }
99
+ function formatTokenCount(tokens) {
100
+ return Math.round(tokens).toLocaleString();
101
+ }
102
+ function formatSignals(rawTokens, contextTokens, percent, providerRequests) {
103
+ const parts = [`~${rawTokens.toLocaleString()} raw tokens since last compaction`];
104
+ if (typeof contextTokens === "number" && Number.isFinite(contextTokens)) {
105
+ parts.push(`~${formatTokenCount(contextTokens)} context tokens in the next request`);
106
+ }
107
+ if (typeof percent === "number" && Number.isFinite(percent)) {
108
+ parts.push(`${Math.round(percent)}% context window used`);
109
+ }
110
+ if (providerRequests !== undefined) {
111
+ parts.push(`${formatTokenCount(providerRequests)} provider requests since the last user prompt`);
112
+ }
113
+ return parts.join(", ");
114
+ }
115
+ function buildDcpNudge(rawTokens, contextTokens, percent, policy, providerRequests) {
116
+ return [
117
+ "",
118
+ DCP_NUDGE_HEADING,
119
+ `Context is accumulating (${formatSignals(rawTokens, contextTokens, percent, providerRequests)}).`,
120
+ "Use `compact_context` once when a safe compaction scope is available. When the agent is still working mid-run, the default is virtual `request_time` pruning that shrinks the next provider request without mutating session history; when the phase is closed or the agent is idle, persisted `after_turn` compaction runs after this run for cache-friendly future requests.",
121
+ "If the user explicitly asks to run/trigger `compact_context`, DCP, DCP-lite, context pruning, or context compression, call `compact_context` in the next step with the safest allowed scope; do not run shell tests, explain it away, or claim DCP was activated without a tool result. If guards reject the call, report that rejection.",
122
+ "When the current topic/phase is done, the user changes threads, or old tool output is no longer needed, call `compact_context({ scope: \"closed_phase\", focus: \"short topic/focus\", phaseComplete: true })` once and continue normally.",
123
+ `If the phase remains active/open, use \`compact_context({ scope: "older_history", keepRecentTokens: ${policy.agentDriven.olderHistoryKeepRecentTokens} })\` to prune older stale history in-flight while a recent raw suffix stays available for the next step.`,
124
+ "Prefer `mode: \"request_time\"` while the phase is active and context is growing (prunes the next request virtually); use `mode: \"after_turn\"` once the phase is closed (persists compaction after the run).",
125
+ ].join("\n");
126
+ }
127
+ function isRecord(value) {
128
+ return value !== null && typeof value === "object" && !Array.isArray(value);
129
+ }
130
+ function contentContainsDcpNudge(content) {
131
+ if (typeof content === "string")
132
+ return content.includes(DCP_NUDGE_HEADING);
133
+ if (!Array.isArray(content))
134
+ return false;
135
+ return content.some((part) => {
136
+ if (typeof part === "string")
137
+ return part.includes(DCP_NUDGE_HEADING);
138
+ return isRecord(part) && typeof part.text === "string" && part.text.includes(DCP_NUDGE_HEADING);
139
+ });
140
+ }
141
+ function payloadContainsDcpNudge(payload) {
142
+ if (!isRecord(payload))
143
+ return false;
144
+ if (contentContainsDcpNudge(payload.system))
145
+ return true;
146
+ const messages = payload.messages;
147
+ if (!Array.isArray(messages))
148
+ return false;
149
+ return messages.some((message) => isRecord(message) && contentContainsDcpNudge(message.content));
150
+ }
151
+ function appendNudgeToContent(content, nudge) {
152
+ if (typeof content === "string")
153
+ return `${content}${nudge}`;
154
+ if (Array.isArray(content))
155
+ return [...content, { type: "text", text: nudge.trimStart() }];
156
+ return nudge.trimStart();
157
+ }
158
+ function injectDcpNudgeIntoAnthropicPayload(payload, nudge) {
159
+ if (!("system" in payload))
160
+ return undefined;
161
+ const system = payload.system;
162
+ if (typeof system === "string")
163
+ return { ...payload, system: `${system}${nudge}` };
164
+ if (Array.isArray(system))
165
+ return { ...payload, system: [...system, { type: "text", text: nudge.trimStart() }] };
166
+ if (system === undefined || system === null)
167
+ return { ...payload, system: [{ type: "text", text: nudge.trimStart() }] };
168
+ return undefined;
169
+ }
170
+ function injectDcpNudgeIntoOpenAiPayload(payload, nudge) {
171
+ const messages = payload.messages;
172
+ if (!Array.isArray(messages))
173
+ return undefined;
174
+ const targetIndex = messages.findIndex((message) => {
175
+ if (!isRecord(message))
176
+ return false;
177
+ return message.role === "system" || message.role === "developer";
178
+ });
179
+ if (targetIndex === -1) {
180
+ return { ...payload, messages: [{ role: "system", content: nudge.trimStart() }, ...messages] };
181
+ }
182
+ const targetMessage = messages[targetIndex];
183
+ if (!isRecord(targetMessage))
184
+ return undefined;
185
+ const nextMessages = [...messages];
186
+ nextMessages[targetIndex] = {
187
+ ...targetMessage,
188
+ content: appendNudgeToContent(targetMessage.content, nudge),
189
+ };
190
+ return { ...payload, messages: nextMessages };
191
+ }
192
+ function injectDcpNudgeIntoProviderPayload(payload, nudge) {
193
+ if (!isRecord(payload) || payloadContainsDcpNudge(payload))
194
+ return undefined;
195
+ return injectDcpNudgeIntoAnthropicPayload(payload, nudge) ?? injectDcpNudgeIntoOpenAiPayload(payload, nudge);
196
+ }
197
+ function rawTokensForContext(ctx) {
198
+ const entries = ctx.sessionManager.getBranch();
199
+ return rawTokensSinceLastCompaction(entries);
200
+ }
201
+ function safeRawTokensForContext(ctx) {
202
+ try {
203
+ return rawTokensForContext(ctx);
204
+ }
205
+ catch {
206
+ return 0;
207
+ }
208
+ }
209
+ function isOpenPhase(params) {
210
+ return params.phaseComplete === false ||
211
+ params.phaseStatus === "active" ||
212
+ params.phaseStatus === "open" ||
213
+ params.phaseStatus === "in_progress";
214
+ }
215
+ function normalizeScope(scope) {
216
+ return scope === "older_history" ? "older_history" : DEFAULT_COMPACT_CONTEXT_SCOPE;
217
+ }
218
+ /**
219
+ * Resolve the effective compaction mode for an explicit tool call.
220
+ *
221
+ * An explicit `mode` from the caller always wins. When no mode is provided we
222
+ * promote `request_time` (virtual in-flight pruning) whenever the agent is
223
+ * still working mid-run and the scope is the safe `older_history` scope — this
224
+ * shrinks the next provider request immediately instead of deferring to
225
+ * agent_end. `closed_phase` and idle contexts keep the default `after_turn`
226
+ * (persisted) mode.
227
+ */
228
+ function resolveMode(params, scope, ctx) {
229
+ if (params.mode === "request_time")
230
+ return "request_time";
231
+ if (params.mode === "after_turn")
232
+ return "after_turn";
233
+ if (scope === "older_history" && hasPendingModelRequest(ctx))
234
+ return "request_time";
235
+ return DEFAULT_COMPACT_CONTEXT_MODE;
236
+ }
237
+ function getCompactionPolicy(ctx) {
238
+ return ctx.settingsManager?.getCompactionPolicy?.() ?? DEFAULT_COMPACTION_POLICY;
239
+ }
240
+ function getRequestCompactionSettings(params, policy) {
241
+ const scope = normalizeScope(params.scope);
242
+ if (scope === "older_history") {
243
+ const keepRecentTokens = normalizeAgentDrivenOlderHistoryKeepRecentTokens(params.keepRecentTokens, policy.agentDriven);
244
+ return {
245
+ phaseBoundary: false,
246
+ scope,
247
+ keepRecentTokens,
248
+ minRawTokens: keepRecentTokens + policy.agentDriven.olderHistoryMinCompactableTokens,
249
+ };
250
+ }
251
+ return {
252
+ phaseBoundary: true,
253
+ scope,
254
+ minRawTokens: policy.agentDriven.minRawTokens,
255
+ };
256
+ }
257
+ function getAutoOlderHistoryRequestSettings(policy) {
258
+ const keepRecentTokens = policy.agentDriven.olderHistoryKeepRecentTokens;
259
+ return {
260
+ phaseBoundary: false,
261
+ scope: "older_history",
262
+ keepRecentTokens,
263
+ minRawTokens: keepRecentTokens + policy.agentDriven.olderHistoryMinCompactableTokens,
264
+ };
265
+ }
266
+ function isContextAtOrAboveRatio(percent, ratio) {
267
+ return typeof percent === "number" && Number.isFinite(percent) && percent >= ratio * 100;
268
+ }
269
+ function isContextAtOrAboveTokenLimit(contextTokens, tokenLimit) {
270
+ return typeof contextTokens === "number" && Number.isFinite(contextTokens) && contextTokens >= tokenLimit;
271
+ }
272
+ function hasPendingModelRequest(ctx) {
273
+ try {
274
+ return ctx.hasPendingMessages();
275
+ }
276
+ catch {
277
+ return false;
278
+ }
279
+ }
280
+ function maybeScheduleAutoOlderHistoryCompaction(ctx, runtime, state, policy, rawTokens, contextTokens, percent, iterationSignal = false) {
281
+ if (state.request)
282
+ return false;
283
+ if (!policy.autoOlderHistory.enabled)
284
+ return false;
285
+ const midRun = hasPendingModelRequest(ctx);
286
+ if (remainingCooldownTurns(state, policy) > 0)
287
+ return false;
288
+ const requestSettings = getAutoOlderHistoryRequestSettings(policy);
289
+ if (rawTokens < requestSettings.minRawTokens)
290
+ return false;
291
+ const contextSignal = isContextAtOrAboveRatio(percent, policy.autoOlderHistory.contextLimitRatio);
292
+ const absoluteContextSignal = isContextAtOrAboveTokenLimit(contextTokens, runtime.config.dcpNudgeMinContextTokens);
293
+ const rawSignal = rawTokens >= runtime.config.compactionThresholdTokens;
294
+ if (!contextSignal && !absoluteContextSignal && !rawSignal && !iterationSignal)
295
+ return false;
296
+ state.lastAgentDrivenRequestTurn = state.nudgeTurnCount;
297
+ // Mid-run: prune the next provider request virtually (no session mutation).
298
+ // Idle: persist compaction after the run for cache-friendly future requests.
299
+ const mode = midRun ? "request_time" : "after_turn";
300
+ state.request = { source: "auto", mode, ...requestSettings };
301
+ if (mode === "request_time")
302
+ notifyRequestTimeActive(ctx, state.request, rawTokens);
303
+ else
304
+ notifyAfterTurnScheduled(ctx, state.request, rawTokens);
305
+ return true;
306
+ }
307
+ function remainingCooldownTurns(state, policy) {
308
+ if (state.lastAgentDrivenRequestTurn === null)
309
+ return 0;
310
+ const elapsed = Math.max(0, state.nudgeTurnCount - state.lastAgentDrivenRequestTurn);
311
+ return Math.max(0, policy.agentDriven.cooldownTurns - elapsed);
312
+ }
313
+ function getRequestTimeCompactionSettings(request, policy) {
314
+ let settings = getManualCompactionSettings(policy.conversation, policy.manual);
315
+ if (typeof request.keepRecentTokens === "number" && Number.isFinite(request.keepRecentTokens) && request.keepRecentTokens > 0) {
316
+ settings = { ...settings, keepRecentTokens: request.keepRecentTokens };
317
+ }
318
+ return settings;
319
+ }
320
+ function getBranchEntries(ctx) {
321
+ return ctx.sessionManager.getBranch();
322
+ }
323
+ function createVirtualCompactionEntry(pathEntries, result) {
324
+ return {
325
+ type: "compaction",
326
+ id: REQUEST_TIME_VIRTUAL_COMPACTION_ID,
327
+ parentId: pathEntries[pathEntries.length - 1]?.id ?? null,
328
+ timestamp: pathEntries[pathEntries.length - 1]?.timestamp ?? new Date().toISOString(),
329
+ summary: result.summary,
330
+ firstKeptEntryId: result.firstKeptEntryId,
331
+ tokensBefore: result.tokensBefore,
332
+ details: result.details,
333
+ fromHook: false,
334
+ };
335
+ }
336
+ function buildRequestTimePrunedMessages(ctx, request, policy, currentMessages) {
337
+ const pathEntries = getBranchEntries(ctx);
338
+ const settings = getRequestTimeCompactionSettings(request, policy);
339
+ const preparation = prepareCompaction(pathEntries, settings, { phaseBoundary: request.phaseBoundary });
340
+ if (!preparation)
341
+ return undefined;
342
+ const result = compactDcpLite(preparation);
343
+ const tokensRemoved = result.tokensRemoved ?? 0;
344
+ if (tokensRemoved <= 0)
345
+ return undefined;
346
+ const virtualCompaction = createVirtualCompactionEntry(pathEntries, result);
347
+ const virtualContext = buildSessionContext([...pathEntries, virtualCompaction]);
348
+ if (virtualContext.messages.length === 0)
349
+ return undefined;
350
+ const originalTokens = estimateContextTokens(currentMessages).tokens;
351
+ const prunedTokens = estimateContextTokens(virtualContext.messages).tokens;
352
+ if (prunedTokens >= originalTokens)
353
+ return undefined;
354
+ return { messages: virtualContext.messages, tokensRemoved, originalTokens, prunedTokens };
355
+ }
356
+ function notifyRequestTimeActive(ctx, request, rawTokens) {
357
+ if (!ctx.hasUI)
358
+ return;
359
+ const sourceText = request.source === "auto" ? "auto" : "explicit tool";
360
+ const rawText = typeof rawTokens === "number" && Number.isFinite(rawTokens)
361
+ ? `; raw ~${formatTokenCount(rawTokens)}`
362
+ : "";
363
+ const keepText = typeof request.keepRecentTokens === "number" && Number.isFinite(request.keepRecentTokens)
364
+ ? `; keep recent ~${formatTokenCount(request.keepRecentTokens)}`
365
+ : "";
366
+ ctx.ui?.notify(`DCP-lite request-time pruning active — ${sourceText} ${request.scope}; applies before the current-turn continuation provider request${rawText}${keepText}`, "info");
367
+ }
368
+ function notifyRequestTimeApplied(ctx, result) {
369
+ if (!ctx.hasUI)
370
+ return;
371
+ const removedTokens = Math.max(0, result.originalTokens - result.prunedTokens);
372
+ ctx.ui?.notify(`DCP-lite request-time pruning applied — before ~${formatTokenCount(result.originalTokens)}, after ~${formatTokenCount(result.prunedTokens)}, removed ~${formatTokenCount(removedTokens)} tokens`, "info");
373
+ }
374
+ function notifyRequestTimeSkip(ctx, reason) {
375
+ if (ctx.hasUI)
376
+ ctx.ui?.notify(`DCP-lite request-time pruning skipped — ${reason}`, "info");
377
+ }
378
+ function notifyAfterTurnScheduled(ctx, request, rawTokens) {
379
+ if (!ctx.hasUI)
380
+ return;
381
+ const sourceText = request.source === "auto" ? "auto" : "explicit tool";
382
+ const rawText = typeof rawTokens === "number" && Number.isFinite(rawTokens)
383
+ ? `; raw ~${formatTokenCount(rawTokens)}`
384
+ : "";
385
+ const keepText = typeof request.keepRecentTokens === "number" && Number.isFinite(request.keepRecentTokens)
386
+ ? `; keep recent ~${formatTokenCount(request.keepRecentTokens)}`
387
+ : "";
388
+ ctx.ui?.notify(`DCP-lite after-turn compaction scheduled — ${sourceText} ${request.scope}; persists after this run for cache-friendly future requests${rawText}${keepText}`, "info");
389
+ }
390
+ function registerAgentCompactionTool(ext, runtime, state) {
391
+ ext.registerTool(defineTool({
392
+ name: COMPACT_CONTEXT_TOOL_NAME,
393
+ label: "Compact context",
394
+ description: "Request Spectral DCP-lite context compaction. " +
395
+ "Use closed_phase after a completed phase/topic boundary, or older_history to compact stale older history while preserving recent raw context. " +
396
+ "Mid-run (agent still working) the default is virtual request_time pruning that shrinks the next provider request without mutating session history; when idle or the phase is closed, persisted after_turn compaction runs after this run.",
397
+ promptSnippet: "compact_context: call this tool when DCP/DCP-lite/context pruning is explicitly requested; mid-run it prunes the next provider request virtually (request_time), or persists after-turn compaction for a closed phase / idle agent.",
398
+ promptGuidelines: [
399
+ `If the user explicitly asks to run, trigger, or activate \`${COMPACT_CONTEXT_TOOL_NAME}\`, DCP, DCP-lite, context pruning, or context compression, call \`${COMPACT_CONTEXT_TOOL_NAME}\` immediately with the safest allowed scope; do not run shell tests, explain it away, or claim activation without this tool result. If guards reject it, report the rejection.`,
400
+ `Use \`${COMPACT_CONTEXT_TOOL_NAME}\` with scope=closed_phase only after a major phase/topic is complete and old raw tool output is no longer needed.`,
401
+ `Use scope=older_history when the current phase is still active but older stale history can be summarized while a recent raw suffix stays available.`,
402
+ `Default mode: while the agent is mid-run with an active phase, request_time prunes the next provider request virtually (no session mutation); after_turn persists compaction after the run. Prefer request_time while the phase is active and context is growing, after_turn once the phase is closed.`,
403
+ `Do not call \`${COMPACT_CONTEXT_TOOL_NAME}\` before extracting raw details needed for the next response.`,
404
+ ],
405
+ parameters: COMPACT_CONTEXT_TOOL_PARAMS,
406
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
407
+ runtime.ensureConfig(ctx.cwd);
408
+ const policy = getCompactionPolicy(ctx);
409
+ if (runtime.config.passive === true) {
410
+ return {
411
+ content: [{ type: "text", text: "DCP-lite compaction is disabled because observational memory is in passive mode." }],
412
+ details: { status: "passive" },
413
+ };
414
+ }
415
+ if (!policy.agentDriven.enabled) {
416
+ return {
417
+ content: [{ type: "text", text: "DCP-lite compaction is disabled by compaction.agentDriven.enabled=false." }],
418
+ details: { status: "disabled" },
419
+ };
420
+ }
421
+ const requestSettings = getRequestCompactionSettings(params, policy);
422
+ const mode = resolveMode(params, requestSettings.scope, ctx);
423
+ if (requestSettings.scope === "closed_phase" && isOpenPhase(params)) {
424
+ return {
425
+ content: [{
426
+ type: "text",
427
+ text: "DCP-lite compaction not activated because the current phase is marked active/open and closed_phase scope requires a complete/closed phase. Use older_history only when preserving a recent raw suffix is sufficient.",
428
+ }],
429
+ details: { status: "phase_open" },
430
+ };
431
+ }
432
+ if (runtime.compactInFlight) {
433
+ return {
434
+ content: [{ type: "text", text: "DCP-lite compaction not activated because compaction is already in flight." }],
435
+ details: { status: "in_flight" },
436
+ };
437
+ }
438
+ const replacingAutoRequest = state.request?.source === "auto";
439
+ if (state.request && !replacingAutoRequest) {
440
+ const pendingText = state.request.mode === "after_turn"
441
+ ? "after this agent run"
442
+ : "for the current-turn continuation provider request";
443
+ return {
444
+ content: [{ type: "text", text: `DCP-lite compaction is already ${state.request.mode === "after_turn" ? "scheduled" : "active"} ${pendingText}.` }],
445
+ details: { status: "pending" },
446
+ };
447
+ }
448
+ const cooldownTurns = replacingAutoRequest ? 0 : remainingCooldownTurns(state, policy);
449
+ if (cooldownTurns > 0) {
450
+ return {
451
+ content: [{
452
+ type: "text",
453
+ text: `DCP-lite compaction not activated because a recent agent-driven request is cooling down. Wait ${cooldownTurns} more turn${cooldownTurns === 1 ? "" : "s"}.`,
454
+ }],
455
+ details: { status: "cooldown", cooldownTurns },
456
+ };
457
+ }
458
+ const rawTokens = safeRawTokensForContext(ctx);
459
+ if (rawTokens < requestSettings.minRawTokens) {
460
+ const suffixText = requestSettings.keepRecentTokens
461
+ ? ` while preserving ~${requestSettings.keepRecentTokens.toLocaleString()} recent raw tokens`
462
+ : "";
463
+ return {
464
+ content: [{
465
+ type: "text",
466
+ text: `DCP-lite compaction not activated because there are only ~${rawTokens.toLocaleString()} raw tokens since the last compaction; wait until at least ~${requestSettings.minRawTokens.toLocaleString()} raw tokens accumulate${suffixText}.`,
467
+ }],
468
+ details: {
469
+ status: "insufficient_material",
470
+ rawTokens,
471
+ minRawTokens: requestSettings.minRawTokens,
472
+ scope: requestSettings.scope,
473
+ keepRecentTokens: requestSettings.keepRecentTokens,
474
+ },
475
+ };
476
+ }
477
+ const customInstructions = buildCustomInstructions(params);
478
+ state.lastAgentDrivenRequestTurn = state.nudgeTurnCount;
479
+ state.request = { source: "tool", mode, customInstructions, ...requestSettings };
480
+ if (mode === "request_time")
481
+ notifyRequestTimeActive(ctx, state.request, rawTokens);
482
+ const focusText = customInstructions ? ` Focus: ${customInstructions}` : "";
483
+ const scopeText = requestSettings.scope === "older_history" ? " Scope: older_history." : "";
484
+ const modeText = mode === "after_turn"
485
+ ? "DCP-lite after-turn compaction scheduled after this agent run for cache-friendly future requests."
486
+ : "DCP-lite request-time pruning active for the current-turn continuation provider request.";
487
+ const actionText = mode === "after_turn"
488
+ ? "Spectral will persist the compaction once the agent is idle."
489
+ : "Continue normally; Spectral will build this continuation payload from a virtual DCP-lite summary plus the kept raw suffix without mutating session history.";
490
+ return {
491
+ content: [{
492
+ type: "text",
493
+ text: `${modeText}${scopeText}${focusText} ${actionText}`,
494
+ }],
495
+ details: {
496
+ status: mode === "request_time" ? "active" : "scheduled",
497
+ mode,
498
+ customInstructions,
499
+ rawTokens,
500
+ scope: requestSettings.scope,
501
+ keepRecentTokens: requestSettings.keepRecentTokens,
502
+ },
503
+ };
504
+ },
505
+ }));
506
+ }
507
+ export function registerCompactContextTool(ext, runtime) {
508
+ const state = getState(runtime);
509
+ registerAgentCompactionTool(ext, runtime, state);
510
+ /** Wrap a handler so debugLog calls inside it (and its helpers) are emitted when debugLog is on. */
511
+ const withDebug = (handler) => (event, ctx) => {
512
+ runtime.ensureConfig(ctx.cwd);
513
+ return withDebugLogContext({ enabled: runtime.config.debugLog === true, cwd: ctx.cwd }, () => handler(event, ctx));
514
+ };
515
+ ext.on("before_agent_start", withDebug((_event, ctx) => {
516
+ runtime.ensureConfig(ctx.cwd);
517
+ if (runtime.config.passive === true)
518
+ return;
519
+ const policy = getCompactionPolicy(ctx);
520
+ if (!policy.agentDriven.enabled)
521
+ return;
522
+ state.providerRequestCount = 0;
523
+ state.lastInRunNudgeRequest = 0;
524
+ state.nudgeTurnCount += 1;
525
+ let rawTokens = 0;
526
+ try {
527
+ rawTokens = rawTokensForContext(ctx);
528
+ }
529
+ catch {
530
+ rawTokens = 0;
531
+ }
532
+ const usage = ctx.getContextUsage();
533
+ const contextTokens = usage?.tokens;
534
+ const percent = usage?.percent;
535
+ const minRawTokens = runtime.config.dcpNudgeMinRawTokens;
536
+ const minContextTokens = runtime.config.dcpNudgeMinContextTokens;
537
+ const minPercent = runtime.config.dcpNudgeContextPercent;
538
+ const turnInterval = runtime.config.dcpNudgeTurnInterval;
539
+ const turnsSinceNudge = state.nudgeTurnCount - state.lastNudgeTurn;
540
+ const rawSignal = rawTokens >= minRawTokens;
541
+ const absoluteContextSignal = isContextAtOrAboveTokenLimit(contextTokens, minContextTokens);
542
+ const percentSignal = typeof percent === "number" && Number.isFinite(percent) && percent >= minPercent;
543
+ const intervalSignal = turnsSinceNudge >= turnInterval && rawTokens >= Math.floor(minRawTokens / 2);
544
+ maybeScheduleAutoOlderHistoryCompaction(ctx, runtime, state, policy, rawTokens, contextTokens, percent);
545
+ if (!rawSignal && !absoluteContextSignal && !percentSignal && !intervalSignal)
546
+ return;
547
+ state.lastNudgeTurn = state.nudgeTurnCount;
548
+ const nudge = buildDcpNudge(rawTokens, contextTokens, percent, policy);
549
+ return { systemPrompt: `${_event.systemPrompt}${nudge}` };
550
+ }));
551
+ ext.on("context", withDebug((_event, ctx) => {
552
+ runtime.ensureConfig(ctx.cwd);
553
+ if (runtime.config.passive === true)
554
+ return;
555
+ const policy = getCompactionPolicy(ctx);
556
+ if (!policy.agentDriven.enabled)
557
+ return;
558
+ // 1. Explicit request (agent-driven tool call or turn-start auto-schedule).
559
+ // These set state.request before this context event fires, so pruning
560
+ // applies to the current provider request with no lag.
561
+ const request = state.request;
562
+ if (request && request.mode === "request_time") {
563
+ state.request = null;
564
+ if (runtime.compactInFlight) {
565
+ notifyRequestTimeSkip(ctx, "another compaction is already in flight");
566
+ return;
567
+ }
568
+ const rawTokens = safeRawTokensForContext(ctx);
569
+ if (rawTokens < request.minRawTokens) {
570
+ notifyRequestTimeSkip(ctx, "not enough raw material since the last compaction");
571
+ return;
572
+ }
573
+ const pruned = buildRequestTimePrunedMessages(ctx, request, policy, _event.messages);
574
+ if (!pruned) {
575
+ notifyRequestTimeSkip(ctx, "virtual summary would not shrink the next request");
576
+ return;
577
+ }
578
+ debugLog("compact_context.request_time.explicit", {
579
+ rawTokens,
580
+ originalTokens: pruned.originalTokens,
581
+ prunedTokens: pruned.prunedTokens,
582
+ tokensRemoved: pruned.tokensRemoved,
583
+ });
584
+ notifyRequestTimeApplied(ctx, pruned);
585
+ return { messages: pruned.messages };
586
+ }
587
+ // 2. Auto request_time pruning — mid-run, no explicit request.
588
+ // Decides AND prunes in the same handler invocation. This is critical:
589
+ // before_provider_request fires AFTER this context event within the
590
+ // same provider request cycle, so scheduling there would always lag by
591
+ // one full request (the oversized request would already be sent).
592
+ // Virtual pruning is non-mutating, so we skip the agent-driven cooldown
593
+ // (that guards the expensive persisted after_turn compaction only).
594
+ if (!hasPendingModelRequest(ctx))
595
+ return;
596
+ if (runtime.compactInFlight)
597
+ return;
598
+ if (!policy.autoOlderHistory.enabled)
599
+ return;
600
+ const autoSettings = getAutoOlderHistoryRequestSettings(policy);
601
+ const autoRawTokens = safeRawTokensForContext(ctx);
602
+ if (autoRawTokens < autoSettings.minRawTokens)
603
+ return;
604
+ const autoUsage = ctx.getContextUsage();
605
+ const autoContextTokens = autoUsage?.tokens;
606
+ const autoPercent = autoUsage?.percent;
607
+ const autoContextSignal = isContextAtOrAboveRatio(autoPercent, policy.autoOlderHistory.contextLimitRatio);
608
+ const autoAbsoluteSignal = isContextAtOrAboveTokenLimit(autoContextTokens, runtime.config.dcpNudgeMinContextTokens);
609
+ const autoRawSignal = autoRawTokens >= runtime.config.compactionThresholdTokens;
610
+ if (!autoContextSignal && !autoAbsoluteSignal && !autoRawSignal)
611
+ return;
612
+ const autoRequest = { source: "auto", mode: "request_time", ...autoSettings };
613
+ const autoPruned = buildRequestTimePrunedMessages(ctx, autoRequest, policy, _event.messages);
614
+ if (!autoPruned) {
615
+ debugLog("compact_context.request_time.auto_skip", {
616
+ rawTokens: autoRawTokens,
617
+ contextTokens: autoContextTokens ?? null,
618
+ percent: autoPercent ?? null,
619
+ reason: "virtual summary would not shrink the next request",
620
+ });
621
+ return;
622
+ }
623
+ debugLog("compact_context.request_time.auto", {
624
+ rawTokens: autoRawTokens,
625
+ contextTokens: autoContextTokens ?? null,
626
+ percent: autoPercent ?? null,
627
+ originalTokens: autoPruned.originalTokens,
628
+ prunedTokens: autoPruned.prunedTokens,
629
+ tokensRemoved: autoPruned.tokensRemoved,
630
+ });
631
+ notifyRequestTimeApplied(ctx, autoPruned);
632
+ return { messages: autoPruned.messages };
633
+ }));
634
+ ext.on("before_provider_request", withDebug((_event, ctx) => {
635
+ runtime.ensureConfig(ctx.cwd);
636
+ if (runtime.config.passive === true)
637
+ return;
638
+ const policy = getCompactionPolicy(ctx);
639
+ if (!policy.agentDriven.enabled)
640
+ return;
641
+ state.providerRequestCount += 1;
642
+ const rawTokens = safeRawTokensForContext(ctx);
643
+ const usage = ctx.getContextUsage();
644
+ const contextTokens = usage?.tokens;
645
+ const percent = usage?.percent;
646
+ const minRawTokens = runtime.config.dcpNudgeMinRawTokens;
647
+ const minContextTokens = runtime.config.dcpNudgeMinContextTokens;
648
+ const minPercent = runtime.config.dcpNudgeContextPercent;
649
+ const iterationThreshold = runtime.config.dcpInRunNudgeIterationThreshold;
650
+ const frequency = runtime.config.dcpInRunNudgeFrequency;
651
+ const rawSignal = rawTokens >= minRawTokens;
652
+ const absoluteContextSignal = isContextAtOrAboveTokenLimit(contextTokens, minContextTokens);
653
+ const percentSignal = typeof percent === "number" && Number.isFinite(percent) && percent >= minPercent;
654
+ const iterationSignal = state.providerRequestCount >= iterationThreshold;
655
+ const frequencyReady = state.lastInRunNudgeRequest === 0 ||
656
+ state.providerRequestCount - state.lastInRunNudgeRequest >= frequency;
657
+ debugLog("compact_context.before_provider_request", {
658
+ providerRequestCount: state.providerRequestCount,
659
+ rawTokens,
660
+ contextTokens: contextTokens ?? null,
661
+ percent: percent ?? null,
662
+ rawSignal,
663
+ absoluteContextSignal,
664
+ percentSignal,
665
+ iterationSignal,
666
+ frequencyReady,
667
+ });
668
+ // NOTE: maybeScheduleAutoOlderHistoryCompaction is intentionally NOT called
669
+ // here. This event fires AFTER the context event within the same provider
670
+ // request cycle, so any state.request set here would only be visible to
671
+ // the context event on the NEXT request — a one-request lag that lets the
672
+ // oversized request through unpruned. Auto request_time pruning is now
673
+ // decided inline in the context event handler (no lag).
674
+ if ((!rawSignal && !absoluteContextSignal && !percentSignal && !iterationSignal) || !frequencyReady)
675
+ return;
676
+ if (payloadContainsDcpNudge(_event.payload)) {
677
+ state.lastInRunNudgeRequest = state.providerRequestCount;
678
+ return;
679
+ }
680
+ const nudge = buildDcpNudge(rawTokens, contextTokens, percent, policy, state.providerRequestCount);
681
+ const payload = injectDcpNudgeIntoProviderPayload(_event.payload, nudge);
682
+ if (payload === undefined)
683
+ return;
684
+ state.lastInRunNudgeRequest = state.providerRequestCount;
685
+ return payload;
686
+ }));
687
+ ext.on("agent_end", withDebug((_event, ctx) => {
688
+ state.providerRequestCount = 0;
689
+ state.lastInRunNudgeRequest = 0;
690
+ const request = state.request;
691
+ if (!request || request.mode !== "after_turn")
692
+ return;
693
+ state.request = null;
694
+ runtime.ensureConfig(ctx.cwd);
695
+ if (runtime.config.passive === true)
696
+ return;
697
+ if (runtime.compactInFlight) {
698
+ if (ctx.hasUI)
699
+ ctx.ui?.notify("DCP-lite compaction skipped — another compaction is already in flight", "info");
700
+ return;
701
+ }
702
+ const rawTokens = safeRawTokensForContext(ctx);
703
+ if (rawTokens < request.minRawTokens) {
704
+ if (ctx.hasUI)
705
+ ctx.ui?.notify("DCP-lite compaction skipped — not enough raw material since last compaction", "info");
706
+ return;
707
+ }
708
+ const hasUI = ctx.hasUI;
709
+ const ui = ctx.ui;
710
+ runtime.compactInFlight = true;
711
+ setTimeout(async () => {
712
+ if (runtime.observerPromise) {
713
+ try {
714
+ await runtime.observerPromise;
715
+ }
716
+ catch {
717
+ // errors already surfaced via launchObserverTask
718
+ }
719
+ }
720
+ try {
721
+ if (!ctx.isIdle()) {
722
+ runtime.compactInFlight = false;
723
+ state.request = request;
724
+ if (hasUI)
725
+ ui?.notify("DCP-lite compaction deferred — agent is not idle yet", "info");
726
+ return;
727
+ }
728
+ const currentRawTokens = safeRawTokensForContext(ctx);
729
+ if (currentRawTokens < request.minRawTokens) {
730
+ runtime.compactInFlight = false;
731
+ if (hasUI)
732
+ ui?.notify("DCP-lite compaction skipped — not enough raw material since last compaction", "info");
733
+ return;
734
+ }
735
+ if (hasUI && request.source === "auto") {
736
+ ui?.notify("DCP-lite auto older-history compaction starting", "info");
737
+ }
738
+ ctx.compact({
739
+ customInstructions: request.customInstructions,
740
+ phaseBoundary: request.phaseBoundary,
741
+ keepRecentTokens: request.keepRecentTokens,
742
+ memoryHookMode: "skip",
743
+ onComplete: (result) => {
744
+ runtime.compactInFlight = false;
745
+ if (!hasUI)
746
+ return;
747
+ if (result.skipped) {
748
+ ui?.notify(`DCP-lite compaction skipped — ${result.message ?? "summary would not shrink context"}`, "warning");
749
+ return;
750
+ }
751
+ ui?.notify("DCP-lite compaction complete", "info");
752
+ },
753
+ onError: (error) => {
754
+ runtime.compactInFlight = false;
755
+ if (error.message === "Compaction cancelled" ||
756
+ error.message === "Compaction already in progress") {
757
+ return;
758
+ }
759
+ if (hasUI)
760
+ ui?.notify(`DCP-lite compaction failed: ${error.message}`, "error");
761
+ },
762
+ });
763
+ }
764
+ catch (error) {
765
+ runtime.compactInFlight = false;
766
+ const msg = error instanceof Error ? error.message : String(error);
767
+ if (hasUI)
768
+ ui?.notify(`DCP-lite compaction threw: ${msg}`, "error");
769
+ }
770
+ }, 0);
771
+ }));
772
+ }