@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
@@ -38,8 +38,10 @@
38
38
  */
39
39
  import { randomUUID } from "node:crypto";
40
40
  import { AgentBridge } from "./agent-bridge.js";
41
+ import { SettingsManager } from "../sdk/coding-agent/index.js";
41
42
  import { InterAgentBroker } from "./inter-agent-broker.js";
42
43
  import { setInterAgentBroker } from "../memory/inter-agent-broker-singleton.js";
44
+ import { DEFAULT_COMPACTION_POLICY, getAutoOlderHistoryContextThresholdTokens, } from "../sdk/coding-agent/core/compaction/policy.js";
43
45
  import { getMemoryState, isSourceEntry, rawTokensSinceLastBound, rawTokensSinceLastCompaction, } from "../memory/branch.js";
44
46
  import { observationPoolTokens, renderSummary } from "../memory/compaction.js";
45
47
  import { loadConfig } from "../memory/config.js";
@@ -49,11 +51,6 @@ import { reflectionContent, reflectionId } from "../memory/types.js";
49
51
  const DEFAULT_BRIDGE_FACTORY = (args) => new AgentBridge(args);
50
52
  /** Safety limit for autonomous loop iterations per session. */
51
53
  const MAX_LOOP_ITERATIONS = 100;
52
- /**
53
- * Token threshold above which the loop triggers compaction between iterations.
54
- * Aligned with the observational memory extension default (memory/config.ts).
55
- */
56
- const LOOP_COMPACTION_THRESHOLD_TOKENS = 50_000;
57
54
  /**
58
55
  * Defensive cap for fresh-bridge history replay when we already have a
59
56
  * persisted observational-memory snapshot. spectral's own compaction keeps a
@@ -109,6 +106,160 @@ function estimateStoredMessageReplayTokens(message) {
109
106
  estimateStringTokens(message.events) +
110
107
  (message.images?.length ?? 0) * 2_000);
111
108
  }
109
+ function formatCompactionTokenStats(event) {
110
+ const parts = [];
111
+ if (typeof event.compactionNumber === "number") {
112
+ parts.push(`compaction #${event.compactionNumber.toLocaleString()}`);
113
+ }
114
+ if (typeof event.tokensBefore === "number") {
115
+ parts.push(`from ~${event.tokensBefore.toLocaleString()} tokens`);
116
+ }
117
+ if (typeof event.tokensCompacted === "number") {
118
+ parts.push(`compacted ~${event.tokensCompacted.toLocaleString()} tokens`);
119
+ }
120
+ if (typeof event.tokensRemoved === "number") {
121
+ parts.push(`removed ~${event.tokensRemoved.toLocaleString()} tokens`);
122
+ }
123
+ if (typeof event.summaryTokens === "number") {
124
+ parts.push(`summary +~${event.summaryTokens.toLocaleString()} tokens`);
125
+ }
126
+ if (typeof event.reductionPercent === "number") {
127
+ parts.push(`reduction ~${event.reductionPercent.toLocaleString(undefined, { maximumFractionDigits: 2 })}%`);
128
+ }
129
+ if (event.largestTokenSources?.length) {
130
+ const sources = event.largestTokenSources
131
+ .slice(0, 3)
132
+ .map((bucket) => `${bucket.type}:${bucket.name} ~${bucket.tokens.toLocaleString()}`)
133
+ .join("; ");
134
+ parts.push(`largest sources ${sources}`);
135
+ }
136
+ return parts.length > 0 ? ` (${parts.join(", ")})` : "";
137
+ }
138
+ function isSkippedCompactionResult(result) {
139
+ return Boolean(result && typeof result === "object" && result.skipped === true);
140
+ }
141
+ // Persisted-history prune is deliberately stricter than timeline status
142
+ // rendering: only a successful compaction_end proves Pi appended a usable
143
+ // compaction entry. Aborted, skipped, or failed endings may leave the branch
144
+ // unchanged or partially settled, so pruning must skip instead of guessing.
145
+ function getCompactionEndPruneSkipReason(event) {
146
+ if (event.aborted)
147
+ return "aborted";
148
+ if (event.skipped)
149
+ return "skipped";
150
+ if (event.errorMessage)
151
+ return "failed";
152
+ if (typeof event.tokensRemoved !== "number" || event.tokensRemoved <= 0)
153
+ return "missing_token_reduction";
154
+ return null;
155
+ }
156
+ const BRANCH_MESSAGE_TIMESTAMP_TOLERANCE_MS = 5_000;
157
+ function isRecord(value) {
158
+ return value !== null && typeof value === "object" && !Array.isArray(value);
159
+ }
160
+ function contentAsPersistedText(content) {
161
+ if (typeof content === "string")
162
+ return content;
163
+ if (!Array.isArray(content))
164
+ return null;
165
+ const textParts = [];
166
+ for (const part of content) {
167
+ if (typeof part === "string") {
168
+ textParts.push(part);
169
+ continue;
170
+ }
171
+ if (isRecord(part) && part.type === "text" && typeof part.text === "string") {
172
+ textParts.push(part.text);
173
+ }
174
+ }
175
+ return textParts.length > 0 ? textParts.join("") : null;
176
+ }
177
+ function branchMessageTimestampMs(entry, message) {
178
+ if (typeof message.timestamp === "number" && Number.isFinite(message.timestamp))
179
+ return message.timestamp;
180
+ if (typeof entry.timestamp !== "string")
181
+ return null;
182
+ const parsed = Date.parse(entry.timestamp);
183
+ return Number.isFinite(parsed) ? parsed : null;
184
+ }
185
+ function branchMessageSignature(entry) {
186
+ if (entry.type !== "message" || !isRecord(entry.message))
187
+ return null;
188
+ const role = entry.message.role;
189
+ if (role !== "user" && role !== "assistant")
190
+ return null;
191
+ const content = contentAsPersistedText(entry.message.content);
192
+ if (content === null)
193
+ return null;
194
+ return {
195
+ role,
196
+ content,
197
+ timestampMs: branchMessageTimestampMs(entry, entry.message),
198
+ entryId: entry.id,
199
+ };
200
+ }
201
+ function branchMessageRole(entry) {
202
+ return entry.type === "message" && isRecord(entry.message) ? entry.message.role : undefined;
203
+ }
204
+ function getKeptBranchMessageSignatures(entries, keptEntryIndex) {
205
+ const signatures = [];
206
+ for (let i = keptEntryIndex; i < entries.length; i++) {
207
+ const entry = entries[i];
208
+ if (entry.type !== "message")
209
+ continue;
210
+ const role = branchMessageRole(entry);
211
+ if (role !== "user" && role !== "assistant")
212
+ continue;
213
+ const signature = branchMessageSignature(entry);
214
+ if (!signature) {
215
+ console.log(`[spectral] prune: skipped – reason=unsafe_kept_message, ` +
216
+ `kept branch message ${entry.id} cannot be matched safely`);
217
+ return null;
218
+ }
219
+ signatures.push(signature);
220
+ }
221
+ return signatures;
222
+ }
223
+ function selectUniqueCandidate(candidates, kept) {
224
+ if (candidates.length === 1)
225
+ return candidates[0];
226
+ if (kept.timestampMs === null)
227
+ return null;
228
+ const keptTimestampMs = kept.timestampMs;
229
+ const timestampMatches = candidates.filter((candidate) => Math.abs(candidate.message.createdAt - keptTimestampMs) <= BRANCH_MESSAGE_TIMESTAMP_TOLERANCE_MS);
230
+ return timestampMatches.length === 1 ? timestampMatches[0] : null;
231
+ }
232
+ function matchKeptPersistedMessageIds(existing, keptBranchMessages) {
233
+ const existingConversationMessages = existing
234
+ .filter((message) => message.role === "user" || message.role === "assistant")
235
+ .map((message, conversationIndex) => ({ message, conversationIndex }));
236
+ const matchedIds = new Set();
237
+ let searchStart = 0;
238
+ for (const kept of keptBranchMessages) {
239
+ const candidates = existingConversationMessages.filter((candidate) => candidate.conversationIndex >= searchStart &&
240
+ !matchedIds.has(candidate.message.id) &&
241
+ candidate.message.role === kept.role &&
242
+ candidate.message.content === kept.content);
243
+ const selected = selectUniqueCandidate(candidates, kept);
244
+ if (!selected) {
245
+ console.log(`[spectral] prune: skipped – reason=ambiguous_kept_mapping, ` +
246
+ `could not stably match kept branch entry ${kept.entryId} ` +
247
+ `(${kept.role}, ${kept.content.length.toLocaleString()} chars) to SQLite history`);
248
+ return null;
249
+ }
250
+ matchedIds.add(selected.message.id);
251
+ searchStart = selected.conversationIndex + 1;
252
+ }
253
+ return matchedIds;
254
+ }
255
+ function loadCompactionPolicy(cwd) {
256
+ try {
257
+ return SettingsManager.create(cwd).getCompactionPolicy();
258
+ }
259
+ catch {
260
+ return DEFAULT_COMPACTION_POLICY;
261
+ }
262
+ }
112
263
  function selectHistoryForBridge(history, hasMemorySnapshot) {
113
264
  if (!history || history.length === 0)
114
265
  return history;
@@ -125,8 +276,122 @@ function selectHistoryForBridge(history, hasMemorySnapshot) {
125
276
  const selected = history.slice(Math.max(0, start + 1));
126
277
  return selected.length > 0 ? selected : [history[history.length - 1]];
127
278
  }
279
+ function prunePersistedHistoryAfterCompaction(store, sessionId, bridge) {
280
+ if (!bridge.getSessionBranch)
281
+ return;
282
+ try {
283
+ const entries = bridge.getSessionBranch();
284
+ // Pi appends a CompactionEntry (type "compaction") at the end whose
285
+ // firstKeptEntryId marks the first branch entry that survives in LLM context.
286
+ // We scan backwards so we find the most recent compaction (there should be
287
+ // exactly one per compaction pass).
288
+ const lastCompaction = [...entries].reverse().find((entry) => entry.type === "compaction");
289
+ const firstKeptEntryId = lastCompaction?.firstKeptEntryId;
290
+ if (!firstKeptEntryId) {
291
+ console.log(`[spectral] prune: skipped – reason=missing_compaction_entry, ` +
292
+ `no compaction entry (or no firstKeptEntryId) ` +
293
+ `in branch for ${sessionId} (${entries.length} entries)`);
294
+ return;
295
+ }
296
+ const keptEntryIndex = entries.findIndex((entry) => entry.id === firstKeptEntryId);
297
+ if (keptEntryIndex === -1) {
298
+ console.log(`[spectral] prune: skipped – reason=missing_kept_entry, ` +
299
+ `firstKeptEntryId "${firstKeptEntryId}" ` +
300
+ `not found in ${entries.length} branch entries for ${sessionId}`);
301
+ return;
302
+ }
303
+ const keptBranchMessages = getKeptBranchMessageSignatures(entries, keptEntryIndex);
304
+ if (!keptBranchMessages)
305
+ return;
306
+ const existing = store.getMessages(sessionId);
307
+ if (existing.length === 0)
308
+ return;
309
+ if (keptBranchMessages.length === 0) {
310
+ console.log(`[spectral] prune: skipped – reason=no_kept_conversation_messages, ` +
311
+ `no kept persisted user/assistant messages for ${sessionId}`);
312
+ return;
313
+ }
314
+ console.log(`[spectral] prune: ${sessionId} – source=compaction_end, branch has ${entries.length} entries, ` +
315
+ `kept portion starts at index ${keptEntryIndex} (id=${firstKeptEntryId}), ` +
316
+ `${keptBranchMessages.length} user+assistant entries kept, ` +
317
+ `${existing.length} SQLite messages`);
318
+ const matchedKeptIds = matchKeptPersistedMessageIds(existing, keptBranchMessages);
319
+ if (!matchedKeptIds)
320
+ return;
321
+ const deleteIds = existing
322
+ .filter((message) => (message.role === "user" || message.role === "assistant") && !matchedKeptIds.has(message.id))
323
+ .map((message) => message.id);
324
+ if (deleteIds.length === 0) {
325
+ console.log(`[spectral] prune: skipped – reason=already_pruned, ` +
326
+ `SQLite history already matches compacted branch for ${sessionId}`);
327
+ return;
328
+ }
329
+ const pruned = store.deleteMessagesByIdForSession(sessionId, deleteIds);
330
+ console.log(`[spectral] prune: finished – reason=successful_compaction_end, ` +
331
+ `dropped ${pruned} pre-compaction message(s) ` +
332
+ `for ${sessionId}, ${matchedKeptIds.size} user/assistant message(s) kept`);
333
+ }
334
+ catch (err) {
335
+ console.warn(`[spectral] warn: failed to prune persisted history after compaction for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
336
+ }
337
+ }
338
+ function buildDcpLiteThresholds(config, policy) {
339
+ return {
340
+ nudgeMinRawTokens: config.dcpNudgeMinRawTokens,
341
+ nudgeMinContextTokens: config.dcpNudgeMinContextTokens,
342
+ nudgeContextPercent: config.dcpNudgeContextPercent,
343
+ providerRequestThreshold: config.dcpInRunNudgeIterationThreshold,
344
+ providerRequestFrequency: config.dcpInRunNudgeFrequency,
345
+ postRunCompactionThresholdTokens: Math.max(config.compactionThresholdTokens, policy.autoOlderHistory.minRawTokens),
346
+ autoContextLimitRatio: policy.autoOlderHistory.contextLimitRatio,
347
+ olderHistoryKeepRecentTokens: policy.agentDriven.olderHistoryKeepRecentTokens,
348
+ olderHistoryMinCompactableTokens: policy.agentDriven.olderHistoryMinCompactableTokens,
349
+ cooldownTurns: policy.agentDriven.cooldownTurns,
350
+ };
351
+ }
352
+ function contextPercent(usedTokens, maxTokens) {
353
+ if (typeof usedTokens !== "number" || typeof maxTokens !== "number" || maxTokens <= 0)
354
+ return null;
355
+ return Math.max(0, Math.min(100, (usedTokens / maxTokens) * 100));
356
+ }
357
+ function applyDcpLiteRuntimeStatus(status, stream) {
358
+ if (!status.dcpLite || !stream)
359
+ return;
360
+ const usage = stream.bridge.getContextUsage?.();
361
+ const usedTokens = usage ? usage.tokens : stream.contextWindowUsed;
362
+ const maxTokens = usage ? usage.contextWindow : stream.contextWindowMax;
363
+ const percent = usage?.percent ?? contextPercent(usedTokens, maxTokens);
364
+ if (usage || usedTokens !== null || maxTokens !== null) {
365
+ status.dcpLite.contextWindow = { usedTokens, maxTokens, percent };
366
+ }
367
+ if (stream.lastDcpEvent) {
368
+ status.dcpLite.lastEvent = stream.lastDcpEvent;
369
+ }
370
+ }
371
+ function dcpLiteEventKindFromMessage(message) {
372
+ const lower = message.toLowerCase();
373
+ if (lower.includes("failed") || lower.includes("threw"))
374
+ return "failed";
375
+ if (lower.includes("skipped"))
376
+ return "skipped";
377
+ if (lower.includes("deferred"))
378
+ return "deferred";
379
+ if (lower.includes("complete") || lower.includes("applied"))
380
+ return "completed";
381
+ return "scheduled";
382
+ }
383
+ function dcpLiteEventFromServerEvent(event) {
384
+ if (event.type !== "agent_notification" || !event.message.includes("DCP-lite"))
385
+ return null;
386
+ return {
387
+ kind: dcpLiteEventKindFromMessage(event.message),
388
+ message: event.message,
389
+ timestamp: Date.now(),
390
+ };
391
+ }
128
392
  function defaultMemoryStatus(cwd) {
129
393
  const config = loadConfig(cwd);
394
+ const policy = loadCompactionPolicy(cwd);
130
395
  return {
131
396
  mode: config.passive ? "passive" : "active",
132
397
  phase: "idle",
@@ -153,6 +418,9 @@ function defaultMemoryStatus(cwd) {
153
418
  sinceLastCompactionTokens: 0,
154
419
  observationPoolTokens: 0,
155
420
  },
421
+ dcpLite: {
422
+ thresholds: buildDcpLiteThresholds(config, policy),
423
+ },
156
424
  };
157
425
  }
158
426
  export class SessionStreamManager {
@@ -164,6 +432,7 @@ export class SessionStreamManager {
164
432
  agentDir;
165
433
  broker;
166
434
  streams = new Map();
435
+ managerCompactions = new Map();
167
436
  disposed = false;
168
437
  constructor(opts) {
169
438
  this.store = opts.store;
@@ -224,6 +493,12 @@ export class SessionStreamManager {
224
493
  contextWindowUsed: stream.contextWindowUsed,
225
494
  contextWindowMax: stream.contextWindowMax,
226
495
  compacting: stream.compacting,
496
+ intervalActive: stream.intervalTimer != null,
497
+ intervalMinutes: stream.intervalMinutes,
498
+ intervalNextTickAt: stream.intervalNextTickAt,
499
+ intervalLaunchCount: stream.intervalLaunchCount,
500
+ intervalHasLoop: stream.intervalConfig?.loop ?? false,
501
+ intervalGoal: stream.intervalConfig?.loopGoal ?? null,
227
502
  };
228
503
  }
229
504
  /**
@@ -326,6 +601,7 @@ export class SessionStreamManager {
326
601
  status.phase = "compacting";
327
602
  status.inFlight.compaction = true;
328
603
  }
604
+ applyDcpLiteRuntimeStatus(status, stream);
329
605
  return status;
330
606
  }
331
607
  getSessionMemoryDetails(sessionId) {
@@ -383,38 +659,92 @@ export class SessionStreamManager {
383
659
  reflections: wireReflections,
384
660
  };
385
661
  }
386
- async compactSession(sessionId) {
387
- if (this.disposed)
388
- throw new Error("SessionStreamManager disposed");
389
- const detail = this.store.getSession(sessionId);
390
- if (!detail)
391
- throw new Error(`Unknown sessionId: ${sessionId}`);
392
- let stream = this.streams.get(sessionId);
393
- if (!stream) {
394
- stream = this.createStream(sessionId, detail.messages);
395
- this.streams.set(sessionId, stream);
662
+ async requestManagerCompaction(stream, request) {
663
+ if (!stream.bridge.compact) {
664
+ return { status: "skipped", source: request.source, reason: "unsupported" };
396
665
  }
397
666
  if (stream.currentTurn) {
398
- throw new Error("Session is busy with an active turn");
667
+ return { status: "skipped", source: request.source, reason: "active_turn" };
399
668
  }
400
- if (stream.compacting) {
401
- throw new Error("Session is already being compacted");
669
+ const active = this.managerCompactions.get(stream.sessionId);
670
+ if (active || stream.compacting) {
671
+ const activeSource = active ? ` from ${active.source}` : "";
672
+ console.log(`[spectral] compaction ${request.source} skipped for ${stream.sessionId}: ` +
673
+ `compaction already in flight${activeSource}`);
674
+ return { status: "skipped", source: request.source, reason: "already_in_flight" };
402
675
  }
403
- if (!stream.bridge.compact) {
404
- throw new Error("Manual compaction is not supported for this session");
676
+ try {
677
+ await stream.ready;
678
+ }
679
+ catch (err) {
680
+ const error = err instanceof Error ? err : new Error(String(err));
681
+ return { status: "failed", source: request.source, error };
682
+ }
683
+ if (stream.currentTurn) {
684
+ return { status: "skipped", source: request.source, reason: "active_turn" };
685
+ }
686
+ if (this.managerCompactions.has(stream.sessionId) || stream.compacting) {
687
+ return { status: "skipped", source: request.source, reason: "already_in_flight" };
405
688
  }
406
- await stream.ready;
407
689
  stream.compacting = true;
690
+ let promise;
408
691
  try {
409
- await stream.bridge.compact();
410
- // Prune persisted history synchronously after compaction, so even if the
411
- // compaction_end event handler races or misses, SQLite reflects the
412
- // compacted state before the next user prompt or reconnect.
692
+ promise = stream.bridge.compact(request.options);
693
+ }
694
+ catch (err) {
695
+ stream.compacting = false;
696
+ const error = err instanceof Error ? err : new Error(String(err));
697
+ return { status: "failed", source: request.source, error };
698
+ }
699
+ this.managerCompactions.set(stream.sessionId, {
700
+ source: request.source,
701
+ promise,
702
+ });
703
+ try {
704
+ const result = await promise;
705
+ if (isSkippedCompactionResult(result)) {
706
+ return { status: "skipped", source: request.source, reason: "skipped_result", result };
707
+ }
708
+ return { status: "completed", source: request.source, result };
709
+ }
710
+ catch (err) {
711
+ const error = err instanceof Error ? err : new Error(String(err));
712
+ return { status: "failed", source: request.source, error };
413
713
  }
414
714
  finally {
715
+ const activeAfterRun = this.managerCompactions.get(stream.sessionId);
716
+ if (activeAfterRun?.promise === promise)
717
+ this.managerCompactions.delete(stream.sessionId);
415
718
  if (stream.compacting)
416
719
  stream.compacting = false;
417
- this.maybeAutoDequeue(stream);
720
+ if (!this.disposed)
721
+ this.maybeAutoDequeue(stream);
722
+ }
723
+ }
724
+ async compactSession(sessionId, options) {
725
+ if (this.disposed)
726
+ throw new Error("SessionStreamManager disposed");
727
+ const detail = this.store.getSession(sessionId);
728
+ if (!detail)
729
+ throw new Error(`Unknown sessionId: ${sessionId}`);
730
+ let stream = this.streams.get(sessionId);
731
+ if (!stream) {
732
+ stream = this.createStream(sessionId, detail.messages);
733
+ this.streams.set(sessionId, stream);
734
+ }
735
+ const outcome = await this.requestManagerCompaction(stream, {
736
+ source: "manual_api",
737
+ options,
738
+ });
739
+ if (outcome.status === "failed")
740
+ throw outcome.error;
741
+ if (outcome.status === "skipped") {
742
+ if (outcome.reason === "active_turn")
743
+ throw new Error("Session is busy with an active turn");
744
+ if (outcome.reason === "unsupported")
745
+ throw new Error("Manual compaction is not supported for this session");
746
+ if (outcome.reason === "already_in_flight")
747
+ throw new Error("Session is already being compacted");
418
748
  }
419
749
  }
420
750
  /**
@@ -636,6 +966,7 @@ export class SessionStreamManager {
636
966
  this.disposed = true;
637
967
  for (const stream of this.streams.values()) {
638
968
  stream.loopActive = false;
969
+ this.clearIntervalTimer(stream);
639
970
  try {
640
971
  stream.bridge.dispose();
641
972
  }
@@ -687,6 +1018,10 @@ export class SessionStreamManager {
687
1018
  stream.loopOriginalPrompt = null;
688
1019
  stream.loopGoal = null;
689
1020
  stream.loopIterationCount = 0;
1021
+ this.clearIntervalTimer(stream);
1022
+ stream.intervalMinutes = null;
1023
+ stream.intervalConfig = null;
1024
+ stream.intervalNextTickAt = null;
690
1025
  // Capture whether a turn is in-flight BEFORE we dispose the bridge.
691
1026
  // dispose() tears down spectral, which can cause the in-flight prompt()
692
1027
  // promise to reject synchronously/microtask and emit an error event
@@ -738,6 +1073,10 @@ export class SessionStreamManager {
738
1073
  if (!stream)
739
1074
  return;
740
1075
  stream.loopActive = false;
1076
+ this.clearIntervalTimer(stream);
1077
+ stream.intervalMinutes = null;
1078
+ stream.intervalConfig = null;
1079
+ stream.intervalNextTickAt = null;
741
1080
  // Flush the last batch of in-flight events before tearing down, so a
742
1081
  // shutdown / GC doesn't lose events that haven't hit the interval yet.
743
1082
  this.flushInFlightTurn(stream);
@@ -802,21 +1141,101 @@ export class SessionStreamManager {
802
1141
  }
803
1142
  }
804
1143
  }
1144
+ /**
1145
+ * Arms the interval scheduler for a session. The first launch is assumed
1146
+ * to have already happened (via the normal prompt path); this only sets
1147
+ * up the recurring `setInterval` that re-launches on each idle tick.
1148
+ * Replaces any previously-armed scheduler for the session.
1149
+ */
1150
+ armInterval(sessionId, config, intervalMinutes) {
1151
+ const stream = this.streams.get(sessionId);
1152
+ if (!stream)
1153
+ return;
1154
+ const minutes = Math.max(1, Math.min(1440, Math.floor(intervalMinutes)));
1155
+ this.clearIntervalTimer(stream);
1156
+ stream.intervalMinutes = minutes;
1157
+ stream.intervalConfig = config;
1158
+ stream.intervalLaunchCount = 0;
1159
+ this.scheduleIntervalTick(stream, minutes);
1160
+ this.broadcast(stream, {
1161
+ type: "interval_armed",
1162
+ intervalMinutes: minutes,
1163
+ hasLoop: config.loop,
1164
+ goal: config.loopGoal ?? null,
1165
+ });
1166
+ }
1167
+ /**
1168
+ * Fires the next interval occurrence immediately if the session is idle,
1169
+ * and resets the cadence so the following tick is +N minutes from now.
1170
+ * If the session is busy, this is a no-op (the cadence is still reset).
1171
+ */
1172
+ triggerIntervalNow(sessionId) {
1173
+ const stream = this.streams.get(sessionId);
1174
+ if (!stream || !stream.intervalTimer)
1175
+ return;
1176
+ const minutes = stream.intervalMinutes ?? 1;
1177
+ this.scheduleIntervalTick(stream, minutes);
1178
+ this.intervalTick(stream);
1179
+ }
1180
+ /** Disarms the interval scheduler for a session. */
1181
+ stopInterval(sessionId) {
1182
+ const stream = this.streams.get(sessionId);
1183
+ if (!stream)
1184
+ return;
1185
+ if (!stream.intervalTimer)
1186
+ return;
1187
+ this.clearIntervalTimer(stream);
1188
+ stream.intervalMinutes = null;
1189
+ stream.intervalConfig = null;
1190
+ stream.intervalNextTickAt = null;
1191
+ this.broadcast(stream, { type: "interval_stopped" });
1192
+ }
1193
+ /** True when the interval scheduler is armed for this session. */
1194
+ isIntervalArmed(sessionId) {
1195
+ return this.streams.get(sessionId)?.intervalTimer != null;
1196
+ }
1197
+ scheduleIntervalTick(stream, minutes) {
1198
+ this.clearIntervalTimer(stream);
1199
+ stream.intervalNextTickAt = Date.now() + minutes * 60_000;
1200
+ stream.intervalTimer = setInterval(() => this.intervalTick(stream), minutes * 60_000);
1201
+ }
1202
+ clearIntervalTimer(stream) {
1203
+ if (stream.intervalTimer) {
1204
+ clearInterval(stream.intervalTimer);
1205
+ stream.intervalTimer = null;
1206
+ }
1207
+ }
1208
+ intervalTick(stream) {
1209
+ if (!stream.intervalConfig)
1210
+ return;
1211
+ const idle = !stream.loopActive &&
1212
+ stream.currentTurn === null &&
1213
+ !stream.compacting;
1214
+ if (!idle) {
1215
+ this.broadcast(stream, { type: "interval_skipped", reason: "busy" });
1216
+ return;
1217
+ }
1218
+ stream.intervalLaunchCount++;
1219
+ const occurrence = stream.intervalLaunchCount;
1220
+ this.broadcast(stream, { type: "interval_launched", occurrence });
1221
+ const cfg = stream.intervalConfig;
1222
+ if (cfg.loop) {
1223
+ this.setLoopActive(stream.sessionId, true, cfg.prompt, cfg.loopMaxIterations, cfg.loopGoal);
1224
+ }
1225
+ this.prompt(stream.sessionId, cfg.prompt, cfg.modelId, cfg.images, cfg.reasoningEffort).catch((err) => {
1226
+ console.error(`[interval] launch failed: ${err instanceof Error ? err.message : String(err)}`);
1227
+ });
1228
+ }
805
1229
  /**
806
1230
  * Fork & Compact: trigger compaction after the first assistant turn of a
807
- * forked session. Uses spectral's built-in `compact()` which generates a summary
1231
+ * forked session. Uses spectral's built-in DCP compaction, which generates a summary
808
1232
  * of older context, retaining the most recent ~20K tokens (including the
809
1233
  * user's new message + the assistant's response).
810
1234
  *
811
- * Custom instructions reference the most recent user message so the LLM
812
- * summary prioritizes information relevant to the current task.
1235
+ * Custom instructions reference the most recent user message so compaction
1236
+ * hooks can prioritize information relevant to the current task.
813
1237
  */
814
1238
  triggerForkCompact(stream) {
815
- if (!stream.bridge.compact) {
816
- console.warn(`[spectral] warn: bridge does not support compact ("Fork & Compact" skipped for ${stream.sessionId})`);
817
- return;
818
- }
819
- stream.compacting = true;
820
1239
  // Read the most recent user message to use as guidance for the
821
1240
  // compaction summary.
822
1241
  const detail = this.store.getSession(stream.sessionId);
@@ -827,73 +1246,33 @@ export class SessionStreamManager {
827
1246
  const customInstructions = guidanceMessage
828
1247
  ? `Focus the context summary on information relevant to: "${guidanceMessage}"`
829
1248
  : undefined;
830
- void stream.bridge
831
- .compact(customInstructions)
832
- .then(() => {
833
- stream.compacting = false;
1249
+ void this.requestManagerCompaction(stream, {
1250
+ source: "fork_compact",
1251
+ options: { customInstructions },
1252
+ })
1253
+ .then((outcome) => {
834
1254
  try {
835
1255
  this.store.clearForkCompactSource(stream.sessionId);
836
1256
  }
837
1257
  catch {
838
1258
  // best-effort
839
1259
  }
840
- console.log(`[spectral] fork-compact completed for ${stream.sessionId}`);
841
- this.maybeAutoDequeue(stream);
842
- })
843
- .catch((err) => {
844
- stream.compacting = false;
845
- const msg = err instanceof Error ? err.message : String(err);
846
- if (msg === "Compaction already in progress") {
847
- console.log(`[spectral] fork-compact skipped for ${stream.sessionId}: compaction already in progress`);
848
- }
849
- else {
850
- console.error(`[spectral] fork-compact failed for ${stream.sessionId}: ${msg}`);
851
- this.broadcast(stream, {
852
- type: "error",
853
- message: `Context compaction failed: ${msg}`,
854
- });
855
- }
856
- // Clear the flag even on failure so it doesn't block forever.
857
- try {
858
- this.store.clearForkCompactSource(stream.sessionId);
1260
+ if (outcome.status === "completed") {
1261
+ console.log(`[spectral] fork-compact completed for ${stream.sessionId}`);
1262
+ return;
859
1263
  }
860
- catch {
861
- // best-effort
1264
+ if (outcome.status === "skipped") {
1265
+ console.log(`[spectral] fork-compact skipped for ${stream.sessionId}: ${outcome.reason}`);
1266
+ return;
862
1267
  }
863
- this.maybeAutoDequeue(stream);
1268
+ console.error(`[spectral] fork-compact failed for ${stream.sessionId}: ${outcome.error.message}`);
1269
+ this.broadcast(stream, {
1270
+ type: "error",
1271
+ message: `Context compaction failed: ${outcome.error.message}`,
1272
+ });
864
1273
  });
865
1274
  }
866
- /**
867
- * Send the next prompt for an autonomous loop iteration, waiting for any
868
- * in-flight observational-memory compaction to finish first so the LLM
869
- * sees the compacted context.
870
- *
871
- * The compaction-trigger extension fires on agent_end (via setTimeout),
872
- * so we poll stream.compacting briefly. If compaction hasn't started after
873
- * a short grace period, we check whether the context window exceeds the
874
- * threshold and proactively trigger compaction via the bridge (which
875
- * invokes spectral's full pipeline, including the session_before_compact hook
876
- * where the observational memory extension provides its summary).
877
- *
878
- * A duplicate call from the extension's delayed compaction trigger is
879
- * harmless — spectral throws "Already compacted" which the extension catches.
880
- */
881
- /**
882
- * Send the next prompt for an autonomous loop iteration, compacting first
883
- * if the context window exceeds the threshold.
884
- *
885
- * Instead of polling for the extension's delayed compaction-trigger (which
886
- * fires via setTimeout), we proactively call bridge.compact() directly.
887
- * bridge.compact() → session.compact() → fires session_before_compact
888
- * (where the extension's compaction-hook provides the observational-memory
889
- * summary), then appends the compaction entry, reloads the compacted
890
- * context into agent.state.messages, and emits compaction_start/end.
891
- *
892
- * The extension's trigger still fires (via setTimeout), but by the time
893
- * its callback runs, bridge.compact() has already appended the compaction
894
- * entry → prepareCompaction() returns undefined → "Already compacted"
895
- * → the trigger's onError handler catches it harmlessly.
896
- */
1275
+ /** Build the next autonomous-loop prompt after optional policy-driven compaction. */
897
1276
  buildLoopPrompt(stream) {
898
1277
  const parts = [];
899
1278
  if (stream.loopGoal) {
@@ -918,25 +1297,24 @@ export class SessionStreamManager {
918
1297
  return parts.join("\n");
919
1298
  }
920
1299
  async sendNextLoopIteration(stream) {
921
- const shouldCompact = stream.bridge.compact &&
922
- typeof stream.contextWindowUsed === "number" &&
923
- stream.contextWindowUsed > LOOP_COMPACTION_THRESHOLD_TOKENS;
1300
+ const policy = loadCompactionPolicy(stream.cwd);
1301
+ const loopThresholdTokens = getAutoOlderHistoryContextThresholdTokens(policy, stream.contextWindowMax);
1302
+ const contextWindowUsed = stream.contextWindowUsed;
1303
+ const shouldCompact = loopThresholdTokens !== null &&
1304
+ typeof contextWindowUsed === "number" &&
1305
+ contextWindowUsed > loopThresholdTokens;
924
1306
  if (shouldCompact) {
925
- try {
926
- console.log(`[loop] compacting context (~${stream.contextWindowUsed.toLocaleString()} tokens > ${LOOP_COMPACTION_THRESHOLD_TOKENS.toLocaleString()} threshold)`);
927
- await stream.bridge.compact();
1307
+ console.log(`[loop] compacting context (~${contextWindowUsed.toLocaleString()} tokens > ` +
1308
+ `${loopThresholdTokens.toLocaleString()} policy threshold)`);
1309
+ const outcome = await this.requestManagerCompaction(stream, { source: "loop" });
1310
+ if (outcome.status === "completed") {
928
1311
  console.log("[loop] compaction complete, sending next iteration");
929
1312
  }
930
- catch (err) {
931
- const msg = err instanceof Error ? err.message : String(err);
932
- if (msg === "Already compacted" ||
933
- msg === "Nothing to compact (session too small)" ||
934
- msg === "Compaction already in progress") {
935
- console.log("[loop] compaction already done or not needed, proceeding");
936
- }
937
- else {
938
- console.error(`[loop] compaction failed: ${msg}`);
939
- }
1313
+ else if (outcome.status === "skipped") {
1314
+ console.log(`[loop] compaction skipped (${outcome.reason}), proceeding`);
1315
+ }
1316
+ else {
1317
+ console.error(`[loop] compaction failed: ${outcome.error.message}`);
940
1318
  }
941
1319
  }
942
1320
  await this.prompt(stream.sessionId, this.buildLoopPrompt(stream), undefined);
@@ -972,10 +1350,16 @@ export class SessionStreamManager {
972
1350
  loopOriginalPrompt: null,
973
1351
  loopMaxIterations: MAX_LOOP_ITERATIONS,
974
1352
  loopGoal: null,
1353
+ intervalMinutes: null,
1354
+ intervalTimer: null,
1355
+ intervalNextTickAt: null,
1356
+ intervalLaunchCount: 0,
1357
+ intervalConfig: null,
975
1358
  forkCompactSourceId: forkSourceId ?? null,
976
1359
  compacting: false,
977
1360
  contextWindowUsed: null,
978
1361
  contextWindowMax: null,
1362
+ lastDcpEvent: null,
979
1363
  };
980
1364
  const memorySnapshot = this.store.getSessionMemorySnapshot(sessionId);
981
1365
  const bridgeHistory = selectHistoryForBridge(history, memorySnapshot != null);
@@ -1099,6 +1483,10 @@ export class SessionStreamManager {
1099
1483
  return stream;
1100
1484
  }
1101
1485
  handleBridgeEvent(stream, event) {
1486
+ const dcpLiteEvent = dcpLiteEventFromServerEvent(event);
1487
+ if (dcpLiteEvent) {
1488
+ stream.lastDcpEvent = dcpLiteEvent;
1489
+ }
1102
1490
  // Buffer replayable events into the in-flight turn. We intentionally
1103
1491
  // accept events even if currentTurn is null (rare race: spectral emits before
1104
1492
  // prompt() opened the turn), in which case we open one defensively so
@@ -1195,9 +1583,14 @@ export class SessionStreamManager {
1195
1583
  }
1196
1584
  if (event.type === "compaction_end") {
1197
1585
  stream.compacting = false;
1198
- // Keep the full persisted history in SQLite after compaction.
1199
- // The LLM context is already compacted via the bridge/session manager;
1200
- // landing can still load older messages through the REST endpoint.
1586
+ const pruneSkipReason = getCompactionEndPruneSkipReason(event);
1587
+ if (pruneSkipReason === null) {
1588
+ prunePersistedHistoryAfterCompaction(this.store, stream.sessionId, stream.bridge);
1589
+ }
1590
+ else {
1591
+ console.log(`[spectral] prune: skipped – reason=${pruneSkipReason}, ` +
1592
+ `compaction_end did not authorize persisted-history prune for ${stream.sessionId}`);
1593
+ }
1201
1594
  persistObservationalMemorySnapshot(this.store, stream.sessionId, stream.bridge);
1202
1595
  // Drain the prompt queue after compaction finishes. During
1203
1596
  // compaction, auto-dequeue in agent_end is skipped to prevent the
@@ -1207,7 +1600,14 @@ export class SessionStreamManager {
1207
1600
  // "Agent is already processing" guard because isStreaming is
1208
1601
  // still true until finishRun() fires.
1209
1602
  setImmediate(() => {
1210
- this.maybeAutoDequeue(stream);
1603
+ if (this.disposed)
1604
+ return;
1605
+ try {
1606
+ this.maybeAutoDequeue(stream);
1607
+ }
1608
+ catch (err) {
1609
+ console.warn(`[spectral] warn: failed to drain queue after compaction for ${stream.sessionId}: ${err instanceof Error ? err.message : String(err)}`);
1610
+ }
1211
1611
  });
1212
1612
  // After compaction the session context has been reduced; push updated
1213
1613
  // context-window stats to all subscribers so the frontend's context
@@ -1232,14 +1632,16 @@ export class SessionStreamManager {
1232
1632
  contextWindowMax: ctx.contextWindow,
1233
1633
  });
1234
1634
  }
1235
- const tokensMsg = typeof event.tokensBefore === "number"
1236
- ? ` (from ~${event.tokensBefore.toLocaleString()} tokens)`
1237
- : "";
1635
+ const tokensMsg = formatCompactionTokenStats(event);
1238
1636
  const sysMsg = event.aborted
1239
1637
  ? "Compaction aborted."
1240
- : event.summary
1241
- ? `Compaction complete${tokensMsg}. Summary: ${event.summary}`
1242
- : `Compaction complete${tokensMsg}.`;
1638
+ : event.skipped
1639
+ ? `Compaction skipped${tokensMsg}: ${event.skipReason ? `${event.skipReason}: ` : ""}${event.errorMessage ?? "summary would not shrink context"}`
1640
+ : event.errorMessage
1641
+ ? `Compaction failed${tokensMsg}: ${event.errorMessage}`
1642
+ : event.summary
1643
+ ? `Compaction complete${tokensMsg}. Summary: ${event.summary}`
1644
+ : `Compaction complete${tokensMsg}.`;
1243
1645
  try {
1244
1646
  // Update the existing "compacting…" system message in-place rather than
1245
1647
  // appending a second system row. This keeps history replay at one entry
@@ -1482,8 +1884,12 @@ function isReplayable(event) {
1482
1884
  event.type === "token_usage" ||
1483
1885
  event.type === "error" ||
1484
1886
  event.type === "subagent_start" ||
1485
- event.type === "subagent_progress" ||
1486
- event.type === "subagent_end");
1887
+ // `subagent_progress` carries full accumulated live snapshots (streaming
1888
+ // text/thinking/status) and is intentionally live-only; buffering it into
1889
+ // currentTurn or batch persistence recreates O(n²) snapshot growth while a
1890
+ // long subagent is running. Keep lifecycle/tool/end events replayable.
1891
+ event.type === "subagent_end" ||
1892
+ event.type === "subagent_tool_progress");
1487
1893
  }
1488
1894
  function snapshotTurn(turn) {
1489
1895
  // Defensive copy of the events array so the snapshot can't be mutated by