@aexol/spectral 0.9.58 → 0.9.60

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 (90) 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/kanban-bridge.d.ts.map +1 -1
  7. package/dist/extensions/kanban-bridge.js +21 -1
  8. package/dist/extensions/web/index.d.ts +10 -0
  9. package/dist/extensions/web/index.d.ts.map +1 -0
  10. package/dist/extensions/web/index.js +11 -0
  11. package/dist/extensions/web/tools/fetch.d.ts +3 -0
  12. package/dist/extensions/web/tools/fetch.d.ts.map +1 -0
  13. package/dist/extensions/web/tools/fetch.js +80 -0
  14. package/dist/extensions/web/utils/fetcher.d.ts +34 -0
  15. package/dist/extensions/web/utils/fetcher.d.ts.map +1 -0
  16. package/dist/extensions/web/utils/fetcher.js +289 -0
  17. package/dist/extensions/web/utils/parser.d.ts +15 -0
  18. package/dist/extensions/web/utils/parser.d.ts.map +1 -0
  19. package/dist/extensions/web/utils/parser.js +205 -0
  20. package/dist/memory/branch.d.ts.map +1 -1
  21. package/dist/memory/branch.js +16 -8
  22. package/dist/memory/commands/status.d.ts.map +1 -1
  23. package/dist/memory/commands/status.js +3 -2
  24. package/dist/memory/config.d.ts +12 -0
  25. package/dist/memory/config.d.ts.map +1 -1
  26. package/dist/memory/config.js +21 -2
  27. package/dist/memory/hooks/compaction-trigger.d.ts.map +1 -1
  28. package/dist/memory/hooks/compaction-trigger.js +19 -3
  29. package/dist/memory/index.d.ts.map +1 -1
  30. package/dist/memory/index.js +2 -0
  31. package/dist/memory/testing/random-text.js +1 -1
  32. package/dist/memory/tool-output-compressor.d.ts +14 -20
  33. package/dist/memory/tool-output-compressor.d.ts.map +1 -1
  34. package/dist/memory/tool-output-compressor.js +391 -137
  35. package/dist/memory/tools/compact-context.d.ts +5 -0
  36. package/dist/memory/tools/compact-context.d.ts.map +1 -0
  37. package/dist/memory/tools/compact-context.js +772 -0
  38. package/dist/relay/dispatcher.d.ts +1 -1
  39. package/dist/relay/dispatcher.d.ts.map +1 -1
  40. package/dist/relay/dispatcher.js +61 -1
  41. package/dist/sdk/coding-agent/core/agent-session.d.ts +56 -3
  42. package/dist/sdk/coding-agent/core/agent-session.d.ts.map +1 -1
  43. package/dist/sdk/coding-agent/core/agent-session.js +408 -210
  44. package/dist/sdk/coding-agent/core/compaction/compaction.d.ts +57 -7
  45. package/dist/sdk/coding-agent/core/compaction/compaction.d.ts.map +1 -1
  46. package/dist/sdk/coding-agent/core/compaction/compaction.js +146 -27
  47. package/dist/sdk/coding-agent/core/compaction/dcp-lite.d.ts +24 -0
  48. package/dist/sdk/coding-agent/core/compaction/dcp-lite.d.ts.map +1 -0
  49. package/dist/sdk/coding-agent/core/compaction/dcp-lite.js +411 -0
  50. package/dist/sdk/coding-agent/core/compaction/index.d.ts +2 -0
  51. package/dist/sdk/coding-agent/core/compaction/index.d.ts.map +1 -1
  52. package/dist/sdk/coding-agent/core/compaction/index.js +2 -0
  53. package/dist/sdk/coding-agent/core/compaction/policy.d.ts +71 -0
  54. package/dist/sdk/coding-agent/core/compaction/policy.d.ts.map +1 -0
  55. package/dist/sdk/coding-agent/core/compaction/policy.js +147 -0
  56. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  57. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +9 -0
  58. package/dist/sdk/coding-agent/core/extensions/types.d.ts +17 -0
  59. package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
  60. package/dist/sdk/coding-agent/core/settings-manager.d.ts +4 -10
  61. package/dist/sdk/coding-agent/core/settings-manager.d.ts.map +1 -1
  62. package/dist/sdk/coding-agent/core/settings-manager.js +17 -17
  63. package/dist/sdk/coding-agent/index.d.ts +1 -1
  64. package/dist/sdk/coding-agent/index.d.ts.map +1 -1
  65. package/dist/server/agent-bridge.d.ts +12 -5
  66. package/dist/server/agent-bridge.d.ts.map +1 -1
  67. package/dist/server/agent-bridge.js +65 -17
  68. package/dist/server/handlers/paths-home.d.ts +11 -0
  69. package/dist/server/handlers/paths-home.d.ts.map +1 -0
  70. package/dist/server/handlers/paths-home.js +10 -0
  71. package/dist/server/handlers/paths-list.d.ts +24 -0
  72. package/dist/server/handlers/paths-list.d.ts.map +1 -0
  73. package/dist/server/handlers/paths-list.js +56 -0
  74. package/dist/server/handlers/paths-pick-directory.d.ts.map +1 -1
  75. package/dist/server/handlers/paths-pick-directory.js +2 -1
  76. package/dist/server/handlers/sessions.d.ts +11 -1
  77. package/dist/server/handlers/sessions.d.ts.map +1 -1
  78. package/dist/server/handlers/sessions.js +22 -2
  79. package/dist/server/handlers/settings.d.ts +18 -1
  80. package/dist/server/handlers/settings.d.ts.map +1 -1
  81. package/dist/server/handlers/settings.js +63 -13
  82. package/dist/server/session-stream.d.ts +65 -37
  83. package/dist/server/session-stream.d.ts.map +1 -1
  84. package/dist/server/session-stream.js +525 -119
  85. package/dist/server/storage.d.ts +3 -0
  86. package/dist/server/storage.d.ts.map +1 -1
  87. package/dist/server/storage.js +25 -0
  88. package/dist/server/wire.d.ts +127 -2
  89. package/dist/server/wire.d.ts.map +1 -1
  90. package/package.json +2 -1
@@ -14,14 +14,13 @@
14
14
  */
15
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
16
  import { basename, dirname } from "node:path";
17
- import { clampThinkingLevel, cleanupSessionResources, getSupportedThinkingLevels, isContextOverflow, modelsAreEqual, resetApiProviders, streamSimple, } from "../../ai/index.js";
18
- import { AssistantMessageEventStream } from "../../ai/utils/event-stream.js";
17
+ import { clampThinkingLevel, cleanupSessionResources, getSupportedThinkingLevels, isContextOverflow, modelsAreEqual, resetApiProviders, } from "../../ai/index.js";
19
18
  import { stripFrontmatter } from "../utils/frontmatter.js";
20
19
  import { resolvePath } from "../utils/paths.js";
21
20
  import { sleep } from "../utils/sleep.js";
22
21
  import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.js";
23
22
  import { executeBashWithOperations } from "./bash-executor.js";
24
- import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, generateBranchSummary, prepareCompaction, shouldCompact, } from "./compaction/index.js";
23
+ import { calculateContextTokens, collectEntriesForBranchSummary, compactDcpLite, estimateContextTokens, estimateCompactionTokenMetrics, getManualCompactionSettings, normalizeCompactionPolicy, normalizeManualKeepRecentTokens, generateBranchSummary, prepareCompaction, shouldCompact, } from "./compaction/index.js";
25
24
  import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
26
25
  import { ExtensionRunner, wrapRegisteredTools, } from "./extensions/index.js";
27
26
  import { emitSessionShutdownEvent } from "./extensions/runner.js";
@@ -48,11 +47,110 @@ export function parseSkillBlock(text) {
48
47
  userMessage: match[4]?.trim() || undefined,
49
48
  };
50
49
  }
50
+ function normalizeManualCompactionOptions(input) {
51
+ if (typeof input === "string")
52
+ return { customInstructions: input };
53
+ return {
54
+ customInstructions: input?.customInstructions,
55
+ phaseBoundary: input?.phaseBoundary,
56
+ keepRecentTokens: input?.keepRecentTokens,
57
+ memoryHookMode: input?.memoryHookMode === "skip" ? "skip" : "inline",
58
+ };
59
+ }
60
+ const DCP_LITE_PHASE_BOUNDARY_CUSTOM_TYPE = "spectral.dcp-lite.phase-boundary";
61
+ const DCP_LITE_PHASE_BOUNDARY_MARKER = "DCP-lite phase boundary: prior raw context was compacted. Continue using the compaction summary above.";
62
+ function createVirtualPhaseBoundaryEntry(pathEntries) {
63
+ const parentId = pathEntries[pathEntries.length - 1]?.id ?? null;
64
+ return {
65
+ type: "custom_message",
66
+ customType: DCP_LITE_PHASE_BOUNDARY_CUSTOM_TYPE,
67
+ content: DCP_LITE_PHASE_BOUNDARY_MARKER,
68
+ display: false,
69
+ details: { virtual: true },
70
+ id: "__spectral_dcp_lite_phase_boundary_probe__",
71
+ parentId,
72
+ timestamp: new Date().toISOString(),
73
+ };
74
+ }
75
+ function isDcpLitePhaseBoundaryEntry(entry) {
76
+ return entry?.type === "custom_message" && entry.customType === DCP_LITE_PHASE_BOUNDARY_CUSTOM_TYPE;
77
+ }
78
+ function isRecord(value) {
79
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
80
+ }
81
+ function isObservationalMemoryDetails(details) {
82
+ if (!isRecord(details))
83
+ return false;
84
+ const record = details;
85
+ return (record.type === "observational-memory" &&
86
+ (record.version === 3 || record.version === 4) &&
87
+ Array.isArray(record.observations) &&
88
+ Array.isArray(record.reflections));
89
+ }
90
+ function buildCompactionTelemetryDetails(result) {
91
+ const telemetry = {};
92
+ if (typeof result.tokensCompacted === "number")
93
+ telemetry.tokensCompacted = result.tokensCompacted;
94
+ if (typeof result.tokensRemoved === "number")
95
+ telemetry.tokensRemoved = result.tokensRemoved;
96
+ if (typeof result.summaryTokens === "number")
97
+ telemetry.summaryTokens = result.summaryTokens;
98
+ if (typeof result.reductionPercent === "number")
99
+ telemetry.reductionPercent = result.reductionPercent;
100
+ if (typeof result.compactionNumber === "number")
101
+ telemetry.compactionNumber = result.compactionNumber;
102
+ if (result.largestTokenSources?.length)
103
+ telemetry.largestTokenSources = result.largestTokenSources;
104
+ return telemetry;
105
+ }
106
+ function preserveDcpHookMemoryDetails(dcpResult, extensionCompaction) {
107
+ if (!extensionCompaction ||
108
+ !isRecord(extensionCompaction.details) ||
109
+ !isObservationalMemoryDetails(extensionCompaction.details))
110
+ return dcpResult;
111
+ const extensionDetails = extensionCompaction.details;
112
+ const mergedDetails = {
113
+ ...extensionDetails,
114
+ compactionTelemetry: buildCompactionTelemetryDetails(dcpResult),
115
+ };
116
+ if (isRecord(dcpResult.details)) {
117
+ mergedDetails.dcpLite = dcpResult.details;
118
+ }
119
+ return {
120
+ ...dcpResult,
121
+ details: mergedDetails,
122
+ };
123
+ }
124
+ function getNonShrinkingSkipMessage(result) {
125
+ const tokensCompacted = result.tokensCompacted;
126
+ const summaryTokens = result.summaryTokens;
127
+ const tokensRemoved = result.tokensRemoved;
128
+ if (typeof tokensCompacted !== "number" || typeof summaryTokens !== "number")
129
+ return undefined;
130
+ if (tokensCompacted <= 0) {
131
+ return "Compaction skipped: no eligible history to compact.";
132
+ }
133
+ if ((tokensRemoved ?? 0) <= 0 || summaryTokens >= tokensCompacted) {
134
+ return `Compaction skipped: generated summary would not shrink context (` +
135
+ `compacted ~${tokensCompacted.toLocaleString()} tokens, ` +
136
+ `summary +~${summaryTokens.toLocaleString()} tokens).`;
137
+ }
138
+ return undefined;
139
+ }
140
+ function markCompactionSkipped(result, skipReason, message) {
141
+ return {
142
+ ...result,
143
+ skipped: true,
144
+ skipReason,
145
+ message,
146
+ };
147
+ }
51
148
  // ============================================================================
52
149
  // Constants
53
150
  // ============================================================================
54
151
  /** Standard thinking levels */
55
152
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"];
153
+ const LENGTH_CONTINUATION_PROMPT = "Your previous assistant response ended because the provider hit its maximum output length. Continue exactly from where it stopped. Do not repeat, recap, or summarize earlier content. If the previous response ended mid-sentence, resume with the next missing word; otherwise continue with the next missing content.";
56
154
  // ============================================================================
57
155
  // AgentSession Class
58
156
  // ============================================================================
@@ -80,6 +178,12 @@ export class AgentSession {
80
178
  // Retry state
81
179
  _retryAbortController = undefined;
82
180
  _retryAttempt = 0;
181
+ _lengthContinuationAttempts = 0;
182
+ _lengthContinuationOutputTokens = 0;
183
+ _pendingInternalLengthContinuationMessages = 0;
184
+ _internalLengthContinuationUserMessages = new WeakSet();
185
+ _autoOlderHistoryCooldownTurnsRemaining = 0;
186
+ _lastAutoOlderHistorySkipSignature;
83
187
  // Bash execution state
84
188
  _bashAbortController = undefined;
85
189
  _pendingBashMessages = [];
@@ -158,13 +262,6 @@ export class AgentSession {
158
262
  }
159
263
  throw new Error(formatNoApiKeyFoundMessage(model.provider));
160
264
  }
161
- async _getCompactionRequestAuth(model) {
162
- if (this.agent.streamFn === streamSimple) {
163
- return this._getRequiredRequestAuth(model);
164
- }
165
- const result = await this._modelRegistry.getApiKeyAndHeaders(model);
166
- return result.ok ? { apiKey: result.apiKey, headers: result.headers } : {};
167
- }
168
265
  /**
169
266
  * Install tool hooks once on the Agent instance.
170
267
  *
@@ -243,6 +340,7 @@ export class AgentSession {
243
340
  if (event.type === "message_start" && event.message.role === "user") {
244
341
  this._overflowRecoveryAttempted = false;
245
342
  const messageText = this._getUserMessageText(event.message);
343
+ this._handleUserMessageStart(messageText, event.message);
246
344
  if (messageText) {
247
345
  // Check steering queue first
248
346
  const steeringIndex = this._steeringMessages.indexOf(messageText);
@@ -266,17 +364,7 @@ export class AgentSession {
266
364
  this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
267
365
  // Handle session persistence
268
366
  if (event.type === "message_end") {
269
- // Check if this is a custom message from extensions
270
- if (event.message.role === "custom") {
271
- // Persist as CustomMessageEntry
272
- this.sessionManager.appendCustomMessageEntry(event.message.customType, event.message.content, event.message.display, event.message.details);
273
- }
274
- else if (event.message.role === "user" ||
275
- event.message.role === "assistant" ||
276
- event.message.role === "toolResult") {
277
- // Regular LLM message - persist as SessionMessageEntry
278
- this.sessionManager.appendMessage(event.message);
279
- }
367
+ this._persistMessageEnd(event.message);
280
368
  // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
281
369
  // Track assistant message for auto-compaction (checked on agent_end)
282
370
  if (event.message.role === "assistant") {
@@ -298,6 +386,43 @@ export class AgentSession {
298
386
  }
299
387
  }
300
388
  };
389
+ _persistMessageEnd(message) {
390
+ // Check if this is a custom message from extensions
391
+ if (message.role === "custom") {
392
+ // Persist as CustomMessageEntry
393
+ this.sessionManager.appendCustomMessageEntry(message.customType, message.content, message.display, message.details);
394
+ }
395
+ else if ((message.role === "user" && !this._shouldSkipMessagePersistence(message)) ||
396
+ message.role === "assistant" ||
397
+ message.role === "toolResult") {
398
+ // Regular LLM message - persist as SessionMessageEntry
399
+ this.sessionManager.appendMessage(message);
400
+ }
401
+ }
402
+ _handleUserMessageStart(messageText, message) {
403
+ const isInternalLengthContinuation = messageText === LENGTH_CONTINUATION_PROMPT && this._pendingInternalLengthContinuationMessages > 0;
404
+ if (isInternalLengthContinuation) {
405
+ this._pendingInternalLengthContinuationMessages--;
406
+ this._internalLengthContinuationUserMessages.add(message);
407
+ return;
408
+ }
409
+ this._resetLengthContinuationState();
410
+ if (this._autoOlderHistoryCooldownTurnsRemaining > 0) {
411
+ this._autoOlderHistoryCooldownTurnsRemaining--;
412
+ }
413
+ }
414
+ _resetLengthContinuationState() {
415
+ this._lengthContinuationAttempts = 0;
416
+ this._lengthContinuationOutputTokens = 0;
417
+ }
418
+ _shouldSkipMessagePersistence(message) {
419
+ if (message.role !== "user")
420
+ return false;
421
+ if (!this._internalLengthContinuationUserMessages.has(message))
422
+ return false;
423
+ this._internalLengthContinuationUserMessages.delete(message);
424
+ return true;
425
+ }
301
426
  _willRetryAfterAgentEnd(event) {
302
427
  const settings = this.settingsManager.getRetrySettings();
303
428
  if (!settings.enabled || this._retryAttempt >= settings.maxRetries) {
@@ -707,8 +832,81 @@ export class AgentSession {
707
832
  });
708
833
  this._retryAttempt = 0;
709
834
  }
835
+ if (this._shouldAutoContinueLengthStop(msg)) {
836
+ this._queueInternalLengthContinuation(this._getAssistantOutputTokens(msg));
837
+ return true;
838
+ }
710
839
  return await this._checkCompaction(msg);
711
840
  }
841
+ _getCompactionPolicy() {
842
+ const manager = this.settingsManager;
843
+ return manager.getCompactionPolicy?.() ?? normalizeCompactionPolicy(manager.getCompactionSettings());
844
+ }
845
+ _shouldAutoContinueLengthStop(message) {
846
+ if (message.stopReason !== "length")
847
+ return false;
848
+ const policy = this._getCompactionPolicy().truncationContinuation;
849
+ const outputTokens = this._getAssistantOutputTokens(message);
850
+ if (this._lengthContinuationAttempts > 0) {
851
+ this._lengthContinuationOutputTokens += outputTokens;
852
+ }
853
+ const contextWindow = this.model?.contextWindow ?? 0;
854
+ if (isContextOverflow(message, contextWindow)) {
855
+ this._emitLengthContinuationEvent("skipped", "context_overflow", outputTokens, policy);
856
+ return false;
857
+ }
858
+ if (outputTokens <= 0 || !this._assistantMessageHasVisibleOutput(message)) {
859
+ this._emitLengthContinuationEvent("skipped", "empty_output", outputTokens, policy);
860
+ return false;
861
+ }
862
+ if (this._lengthContinuationAttempts >= policy.maxAttempts) {
863
+ this._emitLengthContinuationEvent("skipped", "max_attempts", outputTokens, policy);
864
+ return false;
865
+ }
866
+ if (this._lengthContinuationOutputTokens >= policy.totalOutputTokenBudget) {
867
+ this._emitLengthContinuationEvent("skipped", "budget_exhausted", outputTokens, policy);
868
+ return false;
869
+ }
870
+ return true;
871
+ }
872
+ _getAssistantOutputTokens(message) {
873
+ const outputTokens = message.usage.output;
874
+ if (!Number.isFinite(outputTokens) || outputTokens <= 0)
875
+ return 0;
876
+ return Math.trunc(outputTokens);
877
+ }
878
+ _emitLengthContinuationEvent(status, reason, outputTokens, policy) {
879
+ this._emit({
880
+ type: "length_continuation",
881
+ status,
882
+ reason,
883
+ attempt: this._lengthContinuationAttempts,
884
+ maxAttempts: policy.maxAttempts,
885
+ outputTokens,
886
+ continuationOutputTokens: this._lengthContinuationOutputTokens,
887
+ totalOutputTokenBudget: policy.totalOutputTokenBudget,
888
+ });
889
+ }
890
+ _assistantMessageHasVisibleOutput(message) {
891
+ return message.content.some((part) => {
892
+ if (part.type === "text")
893
+ return part.text.trim().length > 0;
894
+ if (part.type === "thinking")
895
+ return part.thinking.trim().length > 0;
896
+ return false;
897
+ });
898
+ }
899
+ _queueInternalLengthContinuation(outputTokens = 0) {
900
+ const policy = this._getCompactionPolicy().truncationContinuation;
901
+ this._lengthContinuationAttempts++;
902
+ this.agent.followUp({
903
+ role: "user",
904
+ content: [{ type: "text", text: LENGTH_CONTINUATION_PROMPT }],
905
+ timestamp: Date.now(),
906
+ });
907
+ this._pendingInternalLengthContinuationMessages++;
908
+ this._emitLengthContinuationEvent("queued", "length_stop", outputTokens, policy);
909
+ }
712
910
  /**
713
911
  * Send a prompt to the agent.
714
912
  * - Handles extension commands (registered via spectral.registerCommand) immediately, even during streaming
@@ -1296,25 +1494,94 @@ export class AgentSession {
1296
1494
  * Aborts current agent operation first.
1297
1495
  * @param customInstructions Optional instructions for the compaction summary
1298
1496
  */
1299
- async compact(customInstructions) {
1300
- return this._withCompactionLock(() => this._executeCompaction(customInstructions));
1497
+ async compact(options) {
1498
+ return this._withCompactionLock(() => this._executeCompaction(options));
1499
+ }
1500
+ /**
1501
+ * Build the shared deterministic DCP compaction result and stream the bounded
1502
+ * summary through the same progress event used by compaction previews.
1503
+ */
1504
+ _buildDcpCompactionResult(preparation, extensionCompaction) {
1505
+ return preserveDcpHookMemoryDetails(compactDcpLite(preparation), extensionCompaction);
1506
+ }
1507
+ _emitCompactionResultSummary(result) {
1508
+ if (result.summary) {
1509
+ this._emit({
1510
+ type: "compaction_delta",
1511
+ delta: result.summary,
1512
+ content: result.summary,
1513
+ });
1514
+ }
1515
+ }
1516
+ _markIfNonShrinking(result) {
1517
+ const message = getNonShrinkingSkipMessage(result);
1518
+ return message ? markCompactionSkipped(result, "non_shrinking", message) : undefined;
1519
+ }
1520
+ async _emitSessionBeforeCompact(preparation, branchEntries, customInstructions, signal, emitProgress) {
1521
+ const runner = this._extensionRunner;
1522
+ if (!runner?.hasHandlers("session_before_compact"))
1523
+ return undefined;
1524
+ return (await runner.emit({
1525
+ type: "session_before_compact",
1526
+ preparation,
1527
+ branchEntries,
1528
+ customInstructions,
1529
+ signal,
1530
+ emitProgress,
1531
+ }));
1301
1532
  }
1302
1533
  /**
1303
1534
  * Execute a manual compaction under the compaction lock.
1304
1535
  */
1305
- async _executeCompaction(customInstructions) {
1536
+ async _executeCompaction(options) {
1537
+ const { customInstructions, phaseBoundary, keepRecentTokens, memoryHookMode } = normalizeManualCompactionOptions(options);
1306
1538
  this._disconnectFromAgent();
1307
1539
  await this.abort();
1308
1540
  this._compactionAbortController = new AbortController();
1309
1541
  this._emit({ type: "compaction_start", reason: "manual" });
1310
1542
  try {
1311
- if (!this.model) {
1312
- throw new Error(formatNoModelSelectedMessage());
1543
+ let pathEntries = this.sessionManager.getBranch();
1544
+ const policy = this._getCompactionPolicy();
1545
+ let settings = getManualCompactionSettings(policy.conversation, policy.manual);
1546
+ if (typeof keepRecentTokens === "number" && Number.isFinite(keepRecentTokens) && keepRecentTokens > 0) {
1547
+ const normalizedKeepRecentTokens = normalizeManualKeepRecentTokens(keepRecentTokens, policy.manual);
1548
+ if (normalizedKeepRecentTokens > 0) {
1549
+ settings = { ...settings, keepRecentTokens: normalizedKeepRecentTokens };
1550
+ }
1313
1551
  }
1314
- const { apiKey, headers } = await this._getCompactionRequestAuth(this.model);
1315
- const pathEntries = this.sessionManager.getBranch();
1316
- const settings = this.settingsManager.getCompactionSettings();
1317
- const preparation = prepareCompaction(pathEntries, settings);
1552
+ if (phaseBoundary && !isDcpLitePhaseBoundaryEntry(pathEntries[pathEntries.length - 1])) {
1553
+ const preflightEntries = [...pathEntries, createVirtualPhaseBoundaryEntry(pathEntries)];
1554
+ const preflightPreparation = prepareCompaction(preflightEntries, settings, { phaseBoundary: true });
1555
+ if (!preflightPreparation) {
1556
+ const lastEntry = pathEntries[pathEntries.length - 1];
1557
+ if (lastEntry?.type === "compaction") {
1558
+ throw new Error("Already compacted");
1559
+ }
1560
+ throw new Error("Nothing to compact (session too small)");
1561
+ }
1562
+ const preflightResult = this._buildDcpCompactionResult(preflightPreparation);
1563
+ const preflightSkippedResult = this._markIfNonShrinking(preflightResult);
1564
+ if (preflightSkippedResult) {
1565
+ this._emit({
1566
+ type: "compaction_end",
1567
+ reason: "manual",
1568
+ result: preflightSkippedResult,
1569
+ aborted: false,
1570
+ willRetry: false,
1571
+ errorMessage: preflightSkippedResult.message,
1572
+ skipped: true,
1573
+ skipReason: preflightSkippedResult.skipReason,
1574
+ });
1575
+ return preflightSkippedResult;
1576
+ }
1577
+ this.sessionManager.appendCustomMessageEntry(DCP_LITE_PHASE_BOUNDARY_CUSTOM_TYPE, DCP_LITE_PHASE_BOUNDARY_MARKER, false, {
1578
+ customInstructions,
1579
+ createdAt: new Date().toISOString(),
1580
+ reason: "model-visible-compact-context",
1581
+ });
1582
+ pathEntries = this.sessionManager.getBranch();
1583
+ }
1584
+ const preparation = prepareCompaction(pathEntries, settings, { phaseBoundary });
1318
1585
  if (!preparation) {
1319
1586
  // Check why we can't compact
1320
1587
  const lastEntry = pathEntries[pathEntries.length - 1];
@@ -1324,97 +1591,44 @@ export class AgentSession {
1324
1591
  throw new Error("Nothing to compact (session too small)");
1325
1592
  }
1326
1593
  let extensionCompaction;
1327
- let fromExtension = false;
1328
- let extensionProgressContent = "";
1329
- const emitExtensionProgress = (delta, content) => {
1330
- if (!delta && !content)
1331
- return;
1332
- extensionProgressContent = content ?? `${extensionProgressContent}${delta}`;
1333
- this._emit({
1334
- type: "compaction_delta",
1335
- delta,
1336
- content: extensionProgressContent,
1337
- });
1338
- };
1339
- if (this._extensionRunner.hasHandlers("session_before_compact")) {
1340
- const result = (await this._extensionRunner.emit({
1341
- type: "session_before_compact",
1342
- preparation,
1343
- branchEntries: pathEntries,
1344
- customInstructions,
1345
- signal: this._compactionAbortController.signal,
1346
- emitProgress: emitExtensionProgress,
1347
- }));
1348
- if (result?.cancel) {
1349
- throw new Error("Compaction cancelled");
1350
- }
1351
- if (result?.compaction) {
1352
- extensionCompaction = result.compaction;
1353
- fromExtension = true;
1354
- }
1355
- }
1356
- let summary;
1357
- let firstKeptEntryId;
1358
- let tokensBefore;
1359
- let details;
1360
- if (extensionCompaction) {
1361
- // Extension provided compaction content
1362
- summary = extensionCompaction.summary;
1363
- firstKeptEntryId = extensionCompaction.firstKeptEntryId;
1364
- tokensBefore = extensionCompaction.tokensBefore;
1365
- details = extensionCompaction.details;
1366
- // Emit the full summary as a compaction_delta so the frontend
1367
- // displays it in the live preview panel during compaction.
1368
- if (summary) {
1594
+ if (memoryHookMode !== "skip") {
1595
+ let extensionProgressContent = "";
1596
+ const emitExtensionProgress = (delta, content) => {
1597
+ if (!delta && !content)
1598
+ return;
1599
+ extensionProgressContent = content ?? `${extensionProgressContent}${delta}`;
1369
1600
  this._emit({
1370
1601
  type: "compaction_delta",
1371
- delta: summary,
1372
- content: summary,
1602
+ delta,
1603
+ content: extensionProgressContent,
1373
1604
  });
1605
+ };
1606
+ const beforeCompactResult = await this._emitSessionBeforeCompact(preparation, pathEntries, customInstructions, this._compactionAbortController.signal, emitExtensionProgress);
1607
+ if (!beforeCompactResult?.cancel && beforeCompactResult?.compaction) {
1608
+ extensionCompaction = beforeCompactResult.compaction;
1374
1609
  }
1375
1610
  }
1376
- else {
1377
- // Wrap streamFn to emit compaction_delta events for live progress.
1378
- const originalStreamFn = this.agent.streamFn;
1379
- const wrappedStreamFn = originalStreamFn
1380
- ? async (model, context, options) => {
1381
- const originalStream = await originalStreamFn(model, context, options);
1382
- const wrapperStream = new AssistantMessageEventStream();
1383
- let compactionContent = "";
1384
- (async () => {
1385
- try {
1386
- for await (const event of originalStream) {
1387
- wrapperStream.push(event);
1388
- if (event.type === "text_delta") {
1389
- compactionContent += event.delta;
1390
- this._emit({
1391
- type: "compaction_delta",
1392
- delta: event.delta,
1393
- content: compactionContent,
1394
- });
1395
- }
1396
- }
1397
- wrapperStream.end();
1398
- }
1399
- catch (err) {
1400
- // If the original stream errors, forward the error.
1401
- wrapperStream.end();
1402
- }
1403
- })();
1404
- return wrapperStream;
1405
- }
1406
- : undefined;
1407
- // Generate compaction result
1408
- const result = await compact(preparation, this.model, apiKey, headers, customInstructions, this._compactionAbortController.signal, this.thinkingLevel, wrappedStreamFn);
1409
- summary = result.summary;
1410
- firstKeptEntryId = result.firstKeptEntryId;
1411
- tokensBefore = result.tokensBefore;
1412
- details = result.details;
1413
- }
1611
+ const compactResult = this._buildDcpCompactionResult(preparation, extensionCompaction);
1414
1612
  if (this._compactionAbortController.signal.aborted) {
1415
1613
  throw new Error("Compaction cancelled");
1416
1614
  }
1417
- this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
1615
+ const skippedResult = this._markIfNonShrinking(compactResult);
1616
+ if (skippedResult) {
1617
+ this._emit({
1618
+ type: "compaction_end",
1619
+ reason: "manual",
1620
+ result: skippedResult,
1621
+ aborted: false,
1622
+ willRetry: false,
1623
+ errorMessage: skippedResult.message,
1624
+ skipped: true,
1625
+ skipReason: skippedResult.skipReason,
1626
+ });
1627
+ return skippedResult;
1628
+ }
1629
+ this._emitCompactionResultSummary(compactResult);
1630
+ const { summary, firstKeptEntryId, tokensBefore, details } = compactResult;
1631
+ this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, false);
1418
1632
  const newEntries = this.sessionManager.getEntries();
1419
1633
  const sessionContext = this.sessionManager.buildSessionContext();
1420
1634
  this.agent.state.messages = sessionContext.messages;
@@ -1424,15 +1638,10 @@ export class AgentSession {
1424
1638
  await this._extensionRunner.emit({
1425
1639
  type: "session_compact",
1426
1640
  compactionEntry: savedCompactionEntry,
1427
- fromExtension,
1641
+ fromExtension: false,
1428
1642
  });
1429
1643
  }
1430
- const compactionResult = {
1431
- summary,
1432
- firstKeptEntryId,
1433
- tokensBefore,
1434
- details,
1435
- };
1644
+ const compactionResult = { ...compactResult, details };
1436
1645
  this._emit({
1437
1646
  type: "compaction_end",
1438
1647
  reason: "manual",
@@ -1485,8 +1694,8 @@ export class AgentSession {
1485
1694
  * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
1486
1695
  */
1487
1696
  async _checkCompaction(assistantMessage, skipAbortedCheck = true) {
1488
- const settings = this.settingsManager.getCompactionSettings();
1489
- if (!settings.enabled)
1697
+ const policy = this._getCompactionPolicy();
1698
+ if (!this._isAutoOlderHistoryCompactionEnabled(policy))
1490
1699
  return false;
1491
1700
  // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
1492
1701
  if (skipAbortedCheck && assistantMessage.stopReason === "aborted")
@@ -1550,15 +1759,37 @@ export class AgentSession {
1550
1759
  else {
1551
1760
  contextTokens = calculateContextTokens(assistantMessage.usage);
1552
1761
  }
1553
- if (shouldCompact(contextTokens, contextWindow, settings)) {
1762
+ if (shouldCompact(contextTokens, contextWindow, this._getAutoOlderHistoryCompactionSettings(policy))) {
1554
1763
  return await this._runAutoCompaction("threshold", false);
1555
1764
  }
1556
1765
  return false;
1557
1766
  }
1767
+ _getAutoOlderHistoryCompactionSettings(policy) {
1768
+ return {
1769
+ ...policy.conversation,
1770
+ enabled: this._isAutoOlderHistoryCompactionEnabled(policy),
1771
+ keepRecentTokens: policy.autoOlderHistory.keepRecentTokens,
1772
+ contextLimitRatio: policy.autoOlderHistory.contextLimitRatio,
1773
+ };
1774
+ }
1775
+ _isAutoOlderHistoryCompactionEnabled(policy) {
1776
+ return policy.conversation.enabled && policy.autoOlderHistory.enabled;
1777
+ }
1778
+ _autoOlderHistorySkipSignature(pathEntries, compactableTokens) {
1779
+ const lastEntryId = pathEntries[pathEntries.length - 1]?.id ?? "none";
1780
+ return `${lastEntryId}:${compactableTokens}`;
1781
+ }
1558
1782
  /**
1559
1783
  * Internal: Run auto-compaction with events.
1560
1784
  */
1561
1785
  async _runAutoCompaction(reason, willRetry) {
1786
+ const policy = this._getCompactionPolicy();
1787
+ if (!this._isAutoOlderHistoryCompactionEnabled(policy)) {
1788
+ return false;
1789
+ }
1790
+ if (reason === "threshold" && this._autoOlderHistoryCooldownTurnsRemaining > 0) {
1791
+ return false;
1792
+ }
1562
1793
  if (this._compactionInProgress) {
1563
1794
  this._emit({
1564
1795
  type: "compaction_end",
@@ -1570,11 +1801,13 @@ export class AgentSession {
1570
1801
  return false;
1571
1802
  }
1572
1803
  this._compactionInProgress = true;
1573
- const settings = this.settingsManager.getCompactionSettings();
1804
+ const settings = this._getAutoOlderHistoryCompactionSettings(policy);
1574
1805
  this._emit({ type: "compaction_start", reason });
1575
1806
  this._autoCompactionAbortController = new AbortController();
1576
1807
  try {
1577
- if (!this.model) {
1808
+ const pathEntries = this.sessionManager.getBranch();
1809
+ const preparation = prepareCompaction(pathEntries, settings, { phaseBoundary: false });
1810
+ if (!preparation) {
1578
1811
  this._emit({
1579
1812
  type: "compaction_end",
1580
1813
  reason,
@@ -1584,40 +1817,34 @@ export class AgentSession {
1584
1817
  });
1585
1818
  return false;
1586
1819
  }
1587
- let apiKey;
1588
- let headers;
1589
- if (this.agent.streamFn === streamSimple) {
1590
- const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model);
1591
- if (!authResult.ok || !authResult.apiKey) {
1592
- this._emit({
1593
- type: "compaction_end",
1594
- reason,
1595
- result: undefined,
1596
- aborted: false,
1597
- willRetry: false,
1598
- });
1599
- return false;
1600
- }
1601
- apiKey = authResult.apiKey;
1602
- headers = authResult.headers;
1603
- }
1604
- else {
1605
- ({ apiKey, headers } = await this._getCompactionRequestAuth(this.model));
1820
+ const compactableTokens = estimateCompactionTokenMetrics(preparation, "").tokensCompacted ?? 0;
1821
+ if (compactableTokens < policy.autoOlderHistory.minRawTokens) {
1822
+ this._lastAutoOlderHistorySkipSignature = this._autoOlderHistorySkipSignature(pathEntries, compactableTokens);
1823
+ this._autoOlderHistoryCooldownTurnsRemaining = policy.autoOlderHistory.cooldownTurns;
1824
+ this._emit({
1825
+ type: "compaction_end",
1826
+ reason,
1827
+ result: undefined,
1828
+ aborted: false,
1829
+ willRetry: false,
1830
+ errorMessage: `Auto-compaction skipped: insufficient compactable raw material (` +
1831
+ `~${compactableTokens.toLocaleString()} / ${policy.autoOlderHistory.minRawTokens.toLocaleString()} tokens).`,
1832
+ });
1833
+ return false;
1606
1834
  }
1607
- const pathEntries = this.sessionManager.getBranch();
1608
- const preparation = prepareCompaction(pathEntries, settings);
1609
- if (!preparation) {
1835
+ const skipSignature = this._autoOlderHistorySkipSignature(pathEntries, compactableTokens);
1836
+ if (reason === "threshold" && this._lastAutoOlderHistorySkipSignature === skipSignature) {
1837
+ this._autoOlderHistoryCooldownTurnsRemaining = policy.autoOlderHistory.cooldownTurns;
1610
1838
  this._emit({
1611
1839
  type: "compaction_end",
1612
1840
  reason,
1613
1841
  result: undefined,
1614
1842
  aborted: false,
1615
1843
  willRetry: false,
1844
+ errorMessage: "Auto-compaction skipped: repeated non-shrinking older-history attempt without new raw material.",
1616
1845
  });
1617
1846
  return false;
1618
1847
  }
1619
- let extensionCompaction;
1620
- let fromExtension = false;
1621
1848
  let extensionProgressContent = "";
1622
1849
  const emitExtensionProgress = (delta, content) => {
1623
1850
  if (!delta && !content)
@@ -1629,58 +1856,9 @@ export class AgentSession {
1629
1856
  content: extensionProgressContent,
1630
1857
  });
1631
1858
  };
1632
- if (this._extensionRunner.hasHandlers("session_before_compact")) {
1633
- const extensionResult = (await this._extensionRunner.emit({
1634
- type: "session_before_compact",
1635
- preparation,
1636
- branchEntries: pathEntries,
1637
- customInstructions: undefined,
1638
- signal: this._autoCompactionAbortController.signal,
1639
- emitProgress: emitExtensionProgress,
1640
- }));
1641
- if (extensionResult?.cancel) {
1642
- this._emit({
1643
- type: "compaction_end",
1644
- reason,
1645
- result: undefined,
1646
- aborted: true,
1647
- willRetry: false,
1648
- });
1649
- return false;
1650
- }
1651
- if (extensionResult?.compaction) {
1652
- extensionCompaction = extensionResult.compaction;
1653
- fromExtension = true;
1654
- }
1655
- }
1656
- let summary;
1657
- let firstKeptEntryId;
1658
- let tokensBefore;
1659
- let details;
1660
- if (extensionCompaction) {
1661
- // Extension provided compaction content
1662
- summary = extensionCompaction.summary;
1663
- firstKeptEntryId = extensionCompaction.firstKeptEntryId;
1664
- tokensBefore = extensionCompaction.tokensBefore;
1665
- details = extensionCompaction.details;
1666
- // Emit the full summary as a compaction_delta so the frontend
1667
- // displays it in the live preview panel during compaction.
1668
- if (summary) {
1669
- this._emit({
1670
- type: "compaction_delta",
1671
- delta: summary,
1672
- content: summary,
1673
- });
1674
- }
1675
- }
1676
- else {
1677
- // Generate compaction result
1678
- const compactResult = await compact(preparation, this.model, apiKey, headers, undefined, this._autoCompactionAbortController.signal, this.thinkingLevel, this.agent.streamFn);
1679
- summary = compactResult.summary;
1680
- firstKeptEntryId = compactResult.firstKeptEntryId;
1681
- tokensBefore = compactResult.tokensBefore;
1682
- details = compactResult.details;
1683
- }
1859
+ const beforeCompactResult = await this._emitSessionBeforeCompact(preparation, pathEntries, undefined, this._autoCompactionAbortController.signal, emitExtensionProgress);
1860
+ const extensionCompaction = beforeCompactResult?.cancel ? undefined : beforeCompactResult?.compaction;
1861
+ const compactResult = this._buildDcpCompactionResult(preparation, extensionCompaction);
1684
1862
  if (this._autoCompactionAbortController.signal.aborted) {
1685
1863
  this._emit({
1686
1864
  type: "compaction_end",
@@ -1691,7 +1869,27 @@ export class AgentSession {
1691
1869
  });
1692
1870
  return false;
1693
1871
  }
1694
- this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
1872
+ const skippedResult = this._markIfNonShrinking(compactResult);
1873
+ if (skippedResult) {
1874
+ this._lastAutoOlderHistorySkipSignature = skipSignature;
1875
+ this._autoOlderHistoryCooldownTurnsRemaining = policy.autoOlderHistory.cooldownTurns;
1876
+ this._emit({
1877
+ type: "compaction_end",
1878
+ reason,
1879
+ result: skippedResult,
1880
+ aborted: false,
1881
+ willRetry: false,
1882
+ errorMessage: skippedResult.message,
1883
+ skipped: true,
1884
+ skipReason: skippedResult.skipReason,
1885
+ });
1886
+ return false;
1887
+ }
1888
+ this._lastAutoOlderHistorySkipSignature = undefined;
1889
+ this._autoOlderHistoryCooldownTurnsRemaining = policy.autoOlderHistory.cooldownTurns;
1890
+ this._emitCompactionResultSummary(compactResult);
1891
+ const { summary, firstKeptEntryId, tokensBefore, details } = compactResult;
1892
+ this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, false);
1695
1893
  const newEntries = this.sessionManager.getEntries();
1696
1894
  const sessionContext = this.sessionManager.buildSessionContext();
1697
1895
  this.agent.state.messages = sessionContext.messages;
@@ -1701,15 +1899,10 @@ export class AgentSession {
1701
1899
  await this._extensionRunner.emit({
1702
1900
  type: "session_compact",
1703
1901
  compactionEntry: savedCompactionEntry,
1704
- fromExtension,
1902
+ fromExtension: false,
1705
1903
  });
1706
1904
  }
1707
- const result = {
1708
- summary,
1709
- firstKeptEntryId,
1710
- tokensBefore,
1711
- details,
1712
- };
1905
+ const result = { ...compactResult, details };
1713
1906
  this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry });
1714
1907
  if (willRetry) {
1715
1908
  const messages = this.agent.state.messages;
@@ -1917,7 +2110,12 @@ export class AgentSession {
1917
2110
  compact: (options) => {
1918
2111
  void (async () => {
1919
2112
  try {
1920
- const result = await this.compact(options?.customInstructions);
2113
+ const result = await this.compact({
2114
+ customInstructions: options?.customInstructions,
2115
+ phaseBoundary: options?.phaseBoundary,
2116
+ keepRecentTokens: options?.keepRecentTokens,
2117
+ memoryHookMode: options?.memoryHookMode,
2118
+ });
1921
2119
  options?.onComplete?.(result);
1922
2120
  }
1923
2121
  catch (error) {