@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.
- package/CHANGELOG.md +6 -0
- package/README.md +43 -0
- package/dist/agent/index.d.ts +6 -1
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +133 -14
- package/dist/extensions/web/index.d.ts +10 -0
- package/dist/extensions/web/index.d.ts.map +1 -0
- package/dist/extensions/web/index.js +11 -0
- package/dist/extensions/web/tools/fetch.d.ts +3 -0
- package/dist/extensions/web/tools/fetch.d.ts.map +1 -0
- package/dist/extensions/web/tools/fetch.js +80 -0
- package/dist/extensions/web/utils/fetcher.d.ts +34 -0
- package/dist/extensions/web/utils/fetcher.d.ts.map +1 -0
- package/dist/extensions/web/utils/fetcher.js +289 -0
- package/dist/extensions/web/utils/parser.d.ts +15 -0
- package/dist/extensions/web/utils/parser.d.ts.map +1 -0
- package/dist/extensions/web/utils/parser.js +205 -0
- package/dist/memory/branch.d.ts.map +1 -1
- package/dist/memory/branch.js +16 -8
- package/dist/memory/commands/status.d.ts.map +1 -1
- package/dist/memory/commands/status.js +3 -2
- package/dist/memory/config.d.ts +12 -0
- package/dist/memory/config.d.ts.map +1 -1
- package/dist/memory/config.js +21 -2
- package/dist/memory/hooks/compaction-trigger.d.ts.map +1 -1
- package/dist/memory/hooks/compaction-trigger.js +19 -3
- package/dist/memory/index.d.ts.map +1 -1
- package/dist/memory/index.js +2 -0
- package/dist/memory/tool-output-compressor.d.ts +14 -20
- package/dist/memory/tool-output-compressor.d.ts.map +1 -1
- package/dist/memory/tool-output-compressor.js +391 -137
- package/dist/memory/tools/compact-context.d.ts +5 -0
- package/dist/memory/tools/compact-context.d.ts.map +1 -0
- package/dist/memory/tools/compact-context.js +772 -0
- package/dist/relay/dispatcher.d.ts +1 -1
- package/dist/relay/dispatcher.d.ts.map +1 -1
- package/dist/relay/dispatcher.js +61 -1
- package/dist/sdk/coding-agent/core/agent-session.d.ts +56 -3
- package/dist/sdk/coding-agent/core/agent-session.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/agent-session.js +408 -210
- package/dist/sdk/coding-agent/core/compaction/compaction.d.ts +57 -7
- package/dist/sdk/coding-agent/core/compaction/compaction.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/compaction/compaction.js +146 -27
- package/dist/sdk/coding-agent/core/compaction/dcp-lite.d.ts +24 -0
- package/dist/sdk/coding-agent/core/compaction/dcp-lite.d.ts.map +1 -0
- package/dist/sdk/coding-agent/core/compaction/dcp-lite.js +411 -0
- package/dist/sdk/coding-agent/core/compaction/index.d.ts +2 -0
- package/dist/sdk/coding-agent/core/compaction/index.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/compaction/index.js +2 -0
- package/dist/sdk/coding-agent/core/compaction/policy.d.ts +71 -0
- package/dist/sdk/coding-agent/core/compaction/policy.d.ts.map +1 -0
- package/dist/sdk/coding-agent/core/compaction/policy.js +147 -0
- package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/native-extensions.js +9 -0
- package/dist/sdk/coding-agent/core/extensions/types.d.ts +17 -0
- package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/settings-manager.d.ts +4 -10
- package/dist/sdk/coding-agent/core/settings-manager.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/settings-manager.js +17 -17
- package/dist/sdk/coding-agent/index.d.ts +1 -1
- package/dist/sdk/coding-agent/index.d.ts.map +1 -1
- package/dist/server/agent-bridge.d.ts +12 -5
- package/dist/server/agent-bridge.d.ts.map +1 -1
- package/dist/server/agent-bridge.js +65 -17
- package/dist/server/handlers/paths-home.d.ts +11 -0
- package/dist/server/handlers/paths-home.d.ts.map +1 -0
- package/dist/server/handlers/paths-home.js +10 -0
- package/dist/server/handlers/paths-list.d.ts +24 -0
- package/dist/server/handlers/paths-list.d.ts.map +1 -0
- package/dist/server/handlers/paths-list.js +56 -0
- package/dist/server/handlers/paths-pick-directory.d.ts.map +1 -1
- package/dist/server/handlers/paths-pick-directory.js +2 -1
- package/dist/server/handlers/sessions.d.ts +11 -1
- package/dist/server/handlers/sessions.d.ts.map +1 -1
- package/dist/server/handlers/sessions.js +22 -2
- package/dist/server/handlers/settings.d.ts +18 -1
- package/dist/server/handlers/settings.d.ts.map +1 -1
- package/dist/server/handlers/settings.js +63 -13
- package/dist/server/session-stream.d.ts +65 -37
- package/dist/server/session-stream.d.ts.map +1 -1
- package/dist/server/session-stream.js +525 -119
- package/dist/server/storage.d.ts +3 -0
- package/dist/server/storage.d.ts.map +1 -1
- package/dist/server/storage.js +25 -0
- package/dist/server/wire.d.ts +127 -2
- package/dist/server/wire.d.ts.map +1 -1
- 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
|
|
387
|
-
if (
|
|
388
|
-
|
|
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
|
-
|
|
667
|
+
return { status: "skipped", source: request.source, reason: "active_turn" };
|
|
399
668
|
}
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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.
|
|
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
|
|
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
|
|
812
|
-
*
|
|
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
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
841
|
-
|
|
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
|
-
|
|
861
|
-
|
|
1264
|
+
if (outcome.status === "skipped") {
|
|
1265
|
+
console.log(`[spectral] fork-compact skipped for ${stream.sessionId}: ${outcome.reason}`);
|
|
1266
|
+
return;
|
|
862
1267
|
}
|
|
863
|
-
|
|
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
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
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.
|
|
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 =
|
|
1236
|
-
? ` (from ~${event.tokensBefore.toLocaleString()} tokens)`
|
|
1237
|
-
: "";
|
|
1635
|
+
const tokensMsg = formatCompactionTokenStats(event);
|
|
1238
1636
|
const sysMsg = event.aborted
|
|
1239
1637
|
? "Compaction aborted."
|
|
1240
|
-
: event.
|
|
1241
|
-
? `Compaction
|
|
1242
|
-
:
|
|
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
|
-
|
|
1486
|
-
|
|
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
|