@cjhyy/code-shell-core 0.6.0-rc.1 → 0.6.0-rc.11
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/dist/context/compaction.d.ts +30 -0
- package/dist/context/compaction.js +93 -0
- package/dist/context/manager.d.ts +18 -0
- package/dist/context/manager.js +156 -44
- package/dist/context/token-counter.js +13 -0
- package/dist/engine/engine.d.ts +22 -12
- package/dist/engine/engine.js +263 -81
- package/dist/engine/model-connections-pool.js +1 -0
- package/dist/engine/model-facade.js +2 -12
- package/dist/engine/query.js +2 -0
- package/dist/engine/runtime.d.ts +2 -0
- package/dist/engine/runtime.js +25 -0
- package/dist/engine/session-usage.d.ts +12 -0
- package/dist/engine/session-usage.js +56 -0
- package/dist/engine/steer-queue.d.ts +2 -1
- package/dist/engine/steer-queue.js +2 -2
- package/dist/engine/turn-loop.d.ts +28 -2
- package/dist/engine/turn-loop.js +153 -26
- package/dist/git/utils.d.ts +12 -0
- package/dist/git/utils.js +33 -6
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/llm/capabilities/rules.js +1 -1
- package/dist/llm/model-pool.d.ts +7 -0
- package/dist/llm/model-pool.js +8 -1
- package/dist/model-catalog/builtin.js +6 -1
- package/dist/preset/index.d.ts +5 -1
- package/dist/preset/index.js +21 -2
- package/dist/prompt/composer.d.ts +5 -0
- package/dist/prompt/composer.js +10 -2
- package/dist/prompt/sections/base.md +1 -0
- package/dist/protocol/chat-session-manager.d.ts +1 -0
- package/dist/protocol/chat-session-manager.js +2 -0
- package/dist/protocol/chat-session.d.ts +4 -1
- package/dist/protocol/chat-session.js +9 -3
- package/dist/protocol/client.d.ts +5 -1
- package/dist/protocol/client.js +8 -2
- package/dist/protocol/server.d.ts +13 -12
- package/dist/protocol/server.js +199 -67
- package/dist/protocol/types.d.ts +14 -0
- package/dist/runtime/background-shell.js +14 -0
- package/dist/runtime/safe-spawn.js +89 -11
- package/dist/runtime/spawn-common.d.ts +15 -4
- package/dist/runtime/spawn-common.js +113 -12
- package/dist/session/session-manager.js +7 -1
- package/dist/session/transcript.d.ts +4 -0
- package/dist/session/transcript.js +21 -0
- package/dist/tool-system/builtin/bash.js +3 -2
- package/dist/tool-system/builtin/cron.js +10 -2
- package/dist/tool-system/builtin/edit-model-catalog.js +15 -5
- package/dist/tool-system/builtin/generate-video.js +3 -0
- package/dist/tool-system/builtin/grep.d.ts +9 -0
- package/dist/tool-system/builtin/grep.js +100 -3
- package/dist/tool-system/builtin/index.d.ts +3 -1
- package/dist/tool-system/builtin/index.js +5 -5
- package/dist/tool-system/builtin/powershell.js +4 -1
- package/dist/tool-system/builtin/sleep.js +5 -0
- package/dist/tool-system/context.d.ts +10 -0
- package/dist/tool-system/executor.js +25 -2
- package/dist/tool-system/mcp-manager.js +17 -0
- package/dist/tool-system/mcp-stdio-diagnostics.d.ts +9 -0
- package/dist/tool-system/mcp-stdio-diagnostics.js +93 -0
- package/dist/tool-system/permission.d.ts +3 -1
- package/dist/tool-system/permission.js +2 -1
- package/dist/tool-system/sandbox/off.js +7 -1
- package/dist/types.d.ts +35 -1
- package/dist/utils/exec.d.ts +8 -0
- package/dist/utils/exec.js +10 -0
- package/package.json +1 -1
|
@@ -16,6 +16,35 @@ import type { Message } from "../types.js";
|
|
|
16
16
|
* Uses per-block-type estimation with 33% overhead padding.
|
|
17
17
|
*/
|
|
18
18
|
export declare function estimateTokens(messages: Message[]): number;
|
|
19
|
+
export declare const IMAGE_HISTORY_PLACEHOLDER_PREFIX = "[image #";
|
|
20
|
+
export declare const IMAGE_HISTORY_PLACEHOLDER_SUFFIX = ", \u5DF2\u5904\u7406 / already provided earlier]";
|
|
21
|
+
interface ImagePreserveSet {
|
|
22
|
+
has(message: Message): boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface DowngradeImageHistoryOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Messages whose image payloads are being sent for their first model
|
|
27
|
+
* consumption in this request. They still count toward image numbering but
|
|
28
|
+
* keep their base64 until the caller clears the preserve set after a
|
|
29
|
+
* successful model response.
|
|
30
|
+
*/
|
|
31
|
+
preserveMessages?: ImagePreserveSet;
|
|
32
|
+
}
|
|
33
|
+
export interface DowngradeImageHistoryResult {
|
|
34
|
+
messages: Message[];
|
|
35
|
+
replacedCount: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Replace already-consumed image payload blocks with compact text markers.
|
|
39
|
+
*
|
|
40
|
+
* The transcript may retain the full image bytes for rendering/resume, but the
|
|
41
|
+
* working message history sent to the model should not re-send base64 after the
|
|
42
|
+
* model has seen it once. This handles both our internal Anthropic-style image
|
|
43
|
+
* blocks and OpenAI-style data-url image blocks defensively, including images
|
|
44
|
+
* nested inside tool_result.content arrays (view_image / browser screenshots).
|
|
45
|
+
*/
|
|
46
|
+
export declare function downgradeImagePayloadsInHistory(messages: Message[], options?: DowngradeImageHistoryOptions): DowngradeImageHistoryResult;
|
|
47
|
+
export declare function messageHasBase64ImagePayload(message: Message): boolean;
|
|
19
48
|
/**
|
|
20
49
|
* Reconcile user-supplied compaction ratios into a safe ordering.
|
|
21
50
|
*
|
|
@@ -170,3 +199,4 @@ export declare function dropOldestRounds(messages: Message[], roundsToDrop: numb
|
|
|
170
199
|
* messages. Returns paths only — the model uses Read tool on demand.
|
|
171
200
|
*/
|
|
172
201
|
export declare function extractReferencedFilePaths(messages: Message[]): string[];
|
|
202
|
+
export {};
|
|
@@ -18,6 +18,99 @@ import { estimateMessagesTokens } from "./token-counter.js";
|
|
|
18
18
|
export function estimateTokens(messages) {
|
|
19
19
|
return Math.ceil(estimateMessagesTokens(messages) * (4 / 3));
|
|
20
20
|
}
|
|
21
|
+
export const IMAGE_HISTORY_PLACEHOLDER_PREFIX = "[image #";
|
|
22
|
+
export const IMAGE_HISTORY_PLACEHOLDER_SUFFIX = ", 已处理 / already provided earlier]";
|
|
23
|
+
/**
|
|
24
|
+
* Replace already-consumed image payload blocks with compact text markers.
|
|
25
|
+
*
|
|
26
|
+
* The transcript may retain the full image bytes for rendering/resume, but the
|
|
27
|
+
* working message history sent to the model should not re-send base64 after the
|
|
28
|
+
* model has seen it once. This handles both our internal Anthropic-style image
|
|
29
|
+
* blocks and OpenAI-style data-url image blocks defensively, including images
|
|
30
|
+
* nested inside tool_result.content arrays (view_image / browser screenshots).
|
|
31
|
+
*/
|
|
32
|
+
export function downgradeImagePayloadsInHistory(messages, options = {}) {
|
|
33
|
+
let nextImageNumber = 1;
|
|
34
|
+
let replacedCount = 0;
|
|
35
|
+
let changed = false;
|
|
36
|
+
const placeholderFor = (imageNumber) => ({
|
|
37
|
+
type: "text",
|
|
38
|
+
text: `${IMAGE_HISTORY_PLACEHOLDER_PREFIX}${imageNumber}${IMAGE_HISTORY_PLACEHOLDER_SUFFIX}`,
|
|
39
|
+
});
|
|
40
|
+
const transformBlocks = (blocks, preserve) => {
|
|
41
|
+
let blocksChanged = false;
|
|
42
|
+
const out = blocks.map((block) => {
|
|
43
|
+
const placeholderNumber = imageHistoryPlaceholderNumber(block);
|
|
44
|
+
if (placeholderNumber !== undefined) {
|
|
45
|
+
nextImageNumber = Math.max(nextImageNumber, placeholderNumber + 1);
|
|
46
|
+
return block;
|
|
47
|
+
}
|
|
48
|
+
if (isBase64ImageBlock(block)) {
|
|
49
|
+
const imageNumber = nextImageNumber++;
|
|
50
|
+
if (preserve)
|
|
51
|
+
return block;
|
|
52
|
+
replacedCount++;
|
|
53
|
+
blocksChanged = true;
|
|
54
|
+
return placeholderFor(imageNumber);
|
|
55
|
+
}
|
|
56
|
+
if (block.type === "tool_result" && Array.isArray(block.content)) {
|
|
57
|
+
const nested = transformBlocks(block.content, preserve);
|
|
58
|
+
if (nested.changed) {
|
|
59
|
+
blocksChanged = true;
|
|
60
|
+
return { ...block, content: nested.blocks };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return block;
|
|
64
|
+
});
|
|
65
|
+
return { blocks: blocksChanged ? out : blocks, changed: blocksChanged };
|
|
66
|
+
};
|
|
67
|
+
const out = messages.map((msg) => {
|
|
68
|
+
if (!Array.isArray(msg.content))
|
|
69
|
+
return msg;
|
|
70
|
+
const preserve = options.preserveMessages?.has(msg) === true;
|
|
71
|
+
const result = transformBlocks(msg.content, preserve);
|
|
72
|
+
if (!result.changed)
|
|
73
|
+
return msg;
|
|
74
|
+
changed = true;
|
|
75
|
+
return { ...msg, content: result.blocks };
|
|
76
|
+
});
|
|
77
|
+
return { messages: changed ? out : messages, replacedCount };
|
|
78
|
+
}
|
|
79
|
+
export function messageHasBase64ImagePayload(message) {
|
|
80
|
+
if (!Array.isArray(message.content))
|
|
81
|
+
return false;
|
|
82
|
+
return message.content.some(blockHasBase64ImagePayload);
|
|
83
|
+
}
|
|
84
|
+
function blockHasBase64ImagePayload(block) {
|
|
85
|
+
if (isBase64ImageBlock(block))
|
|
86
|
+
return true;
|
|
87
|
+
return (block.type === "tool_result" &&
|
|
88
|
+
Array.isArray(block.content) &&
|
|
89
|
+
block.content.some(blockHasBase64ImagePayload));
|
|
90
|
+
}
|
|
91
|
+
function imageHistoryPlaceholderNumber(block) {
|
|
92
|
+
if (block.type !== "text" || typeof block.text !== "string")
|
|
93
|
+
return undefined;
|
|
94
|
+
const escapedPrefix = IMAGE_HISTORY_PLACEHOLDER_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
95
|
+
const escapedSuffix = IMAGE_HISTORY_PLACEHOLDER_SUFFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
96
|
+
const match = block.text.match(new RegExp(`^${escapedPrefix}(\\d+)${escapedSuffix}$`));
|
|
97
|
+
if (!match?.[1])
|
|
98
|
+
return undefined;
|
|
99
|
+
const n = Number(match[1]);
|
|
100
|
+
return Number.isSafeInteger(n) && n > 0 ? n : undefined;
|
|
101
|
+
}
|
|
102
|
+
function isBase64ImageBlock(block) {
|
|
103
|
+
if (block.type === "image" &&
|
|
104
|
+
block.source?.type === "base64" &&
|
|
105
|
+
typeof block.source.data === "string" &&
|
|
106
|
+
block.source.data.length > 0) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
const maybeOpenAI = block;
|
|
110
|
+
return (maybeOpenAI.type === "image_url" &&
|
|
111
|
+
typeof maybeOpenAI.image_url?.url === "string" &&
|
|
112
|
+
/^data:image\/[^;,]+;base64,/i.test(maybeOpenAI.image_url.url));
|
|
113
|
+
}
|
|
21
114
|
/**
|
|
22
115
|
* Reconcile user-supplied compaction ratios into a safe ordering.
|
|
23
116
|
*
|
|
@@ -41,6 +41,8 @@ export declare class ContextManager {
|
|
|
41
41
|
private config;
|
|
42
42
|
private summarizeFn;
|
|
43
43
|
private consecutiveSummaryFailures;
|
|
44
|
+
/** Prevents repeated spin-band LLM calls after a summary generated no shrink. */
|
|
45
|
+
private suppressNoOpMicroSummaryUntilCompact;
|
|
44
46
|
private lastSummary;
|
|
45
47
|
/** Last known actual token count from API usage data. */
|
|
46
48
|
private lastActualTokens;
|
|
@@ -73,6 +75,7 @@ export declare class ContextManager {
|
|
|
73
75
|
* Set the summarize function (injected by Engine).
|
|
74
76
|
*/
|
|
75
77
|
setSummarizeFn(fn: SummarizeFn): void;
|
|
78
|
+
private trySummaryCompact;
|
|
76
79
|
/**
|
|
77
80
|
* Set the transcript path so compaction can reference it. The
|
|
78
81
|
* tool-results directory lives alongside the transcript file:
|
|
@@ -97,6 +100,21 @@ export declare class ContextManager {
|
|
|
97
100
|
* Call this when you have access to the LLM (between turns).
|
|
98
101
|
*/
|
|
99
102
|
manageAsync(messages: Message[]): Promise<Message[]>;
|
|
103
|
+
/**
|
|
104
|
+
* Force maximum compaction, ignoring the ratio gates. This is what a manual
|
|
105
|
+
* `/compact` invokes: the user explicitly asked to shrink NOW, so we don't
|
|
106
|
+
* wait for the prompt to reach compactAtRatio.
|
|
107
|
+
*
|
|
108
|
+
* Order: tier-0 cleanups (persist/truncate/dedupe/mask) + microcompact, then
|
|
109
|
+
* an unconditional LLM summary of the older messages. If no summarizeFn is
|
|
110
|
+
* wired (or it fails / yields nothing), fall back to snip → window so the
|
|
111
|
+
* call still shrinks the conversation rather than no-opping.
|
|
112
|
+
*
|
|
113
|
+
* Unlike manage()/manageAsync(), there is NO `ratio >= compactAtRatio` gate:
|
|
114
|
+
* a long-but-under-threshold text-only conversation (the /compact bug) still
|
|
115
|
+
* gets summarized here.
|
|
116
|
+
*/
|
|
117
|
+
forceSummarize(messages: Message[]): Promise<Message[]>;
|
|
100
118
|
/**
|
|
101
119
|
* Persist large tool_result blocks to disk and replace them with a
|
|
102
120
|
* preview + filepath. No-op when transcript path hasn't been set
|
package/dist/context/manager.js
CHANGED
|
@@ -41,6 +41,8 @@ export class ContextManager {
|
|
|
41
41
|
config;
|
|
42
42
|
summarizeFn;
|
|
43
43
|
consecutiveSummaryFailures = 0;
|
|
44
|
+
/** Prevents repeated spin-band LLM calls after a summary generated no shrink. */
|
|
45
|
+
suppressNoOpMicroSummaryUntilCompact = false;
|
|
44
46
|
lastSummary;
|
|
45
47
|
/** Last known actual token count from API usage data. */
|
|
46
48
|
lastActualTokens;
|
|
@@ -91,6 +93,64 @@ export class ContextManager {
|
|
|
91
93
|
setSummarizeFn(fn) {
|
|
92
94
|
this.summarizeFn = fn;
|
|
93
95
|
}
|
|
96
|
+
async trySummaryCompact(messages, before, logEvent) {
|
|
97
|
+
if (!this.summarizeFn || this.consecutiveSummaryFailures >= 3) {
|
|
98
|
+
return { messages, tokens: before, compacted: false, noProgress: false };
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const keepRecentN = Math.max(8, Math.floor(messages.length * 0.3));
|
|
102
|
+
const messagesToSummarize = messages.slice(1, -keepRecentN); // skip first (userContext) and recent
|
|
103
|
+
if (messagesToSummarize.length <= 3) {
|
|
104
|
+
return { messages, tokens: before, compacted: false, noProgress: false };
|
|
105
|
+
}
|
|
106
|
+
// Rolling summary: if a prior summary is already anchored in the
|
|
107
|
+
// messages (from an earlier compaction in this session or in a resumed
|
|
108
|
+
// session), feed it back so the LLM merges-updates rather than
|
|
109
|
+
// re-summarizes from scratch.
|
|
110
|
+
const priorSummary = extractAnchoredSummary(messages) ?? this.lastSummary;
|
|
111
|
+
const prompt = buildSummarizationPrompt(messagesToSummarize, priorSummary);
|
|
112
|
+
const summary = await this.summarizeFn(prompt);
|
|
113
|
+
if (!summary || summary.length <= 50) {
|
|
114
|
+
this.consecutiveSummaryFailures++;
|
|
115
|
+
logger.warn("context.summary_failed", {
|
|
116
|
+
failures: this.consecutiveSummaryFailures,
|
|
117
|
+
error: "summary was empty or too short",
|
|
118
|
+
});
|
|
119
|
+
return { messages, tokens: before, compacted: false, noProgress: true };
|
|
120
|
+
}
|
|
121
|
+
const compacted = applySummaryCompaction(messages, summary, keepRecentN, this.transcriptPath);
|
|
122
|
+
const after = estimateTokens(compacted);
|
|
123
|
+
if (after >= before) {
|
|
124
|
+
this.consecutiveSummaryFailures++;
|
|
125
|
+
logger.warn("context.summary_no_progress", {
|
|
126
|
+
failures: this.consecutiveSummaryFailures,
|
|
127
|
+
before,
|
|
128
|
+
after,
|
|
129
|
+
summaryLen: summary.length,
|
|
130
|
+
rolling: priorSummary !== undefined,
|
|
131
|
+
});
|
|
132
|
+
return { messages, tokens: before, compacted: false, noProgress: true };
|
|
133
|
+
}
|
|
134
|
+
this.consecutiveSummaryFailures = 0;
|
|
135
|
+
this.lastSummary = summary;
|
|
136
|
+
logger.info(logEvent, {
|
|
137
|
+
before,
|
|
138
|
+
after,
|
|
139
|
+
summaryLen: summary.length,
|
|
140
|
+
rolling: priorSummary !== undefined,
|
|
141
|
+
});
|
|
142
|
+
this.onCompact?.({ strategy: "summary", before, after });
|
|
143
|
+
return { messages: compacted, tokens: after, compacted: true, noProgress: false };
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
this.consecutiveSummaryFailures++;
|
|
147
|
+
logger.warn("context.summary_failed", {
|
|
148
|
+
failures: this.consecutiveSummaryFailures,
|
|
149
|
+
error: err.message,
|
|
150
|
+
});
|
|
151
|
+
return { messages, tokens: before, compacted: false, noProgress: false };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
94
154
|
/**
|
|
95
155
|
* Set the transcript path so compaction can reference it. The
|
|
96
156
|
* tool-results directory lives alongside the transcript file:
|
|
@@ -240,7 +300,9 @@ export class ContextManager {
|
|
|
240
300
|
result = applyToolResultBudget(result);
|
|
241
301
|
// Tier 1: microcompact — see manage() for the rationale on the floor.
|
|
242
302
|
const preTier1Tokens = this.estimateTokensHybrid(result);
|
|
243
|
-
|
|
303
|
+
const microFloorGate = this.config.maxTokens * this.config.microcompactFloorRatio;
|
|
304
|
+
let microNoOpAtFloor = false;
|
|
305
|
+
if (preTier1Tokens > microFloorGate) {
|
|
244
306
|
const keepRecentN = this.config.microcompactKeepRecent ?? defaultKeepRecent(this.config.maxTokens);
|
|
245
307
|
// See manage(): onClear fires synchronously before microcompact returns,
|
|
246
308
|
// so defer the token re-estimate until result is reassigned.
|
|
@@ -251,63 +313,58 @@ export class ContextManager {
|
|
|
251
313
|
clearedInfo = info;
|
|
252
314
|
},
|
|
253
315
|
});
|
|
316
|
+
const postTier1Tokens = this.estimateTokensHybrid(result);
|
|
317
|
+
microNoOpAtFloor = postTier1Tokens === preTier1Tokens;
|
|
318
|
+
if (postTier1Tokens < preTier1Tokens) {
|
|
319
|
+
this.suppressNoOpMicroSummaryUntilCompact = false;
|
|
320
|
+
}
|
|
254
321
|
if (clearedInfo) {
|
|
255
|
-
const after = this.estimateTokensHybrid(result);
|
|
256
322
|
logger.info("context.microcompact", {
|
|
257
323
|
before: preTier1Tokens,
|
|
258
|
-
after,
|
|
324
|
+
after: postTier1Tokens,
|
|
259
325
|
keepRecentN,
|
|
260
326
|
clearedRounds: clearedInfo.clearedRounds,
|
|
261
327
|
toolNames: clearedInfo.toolNames,
|
|
262
328
|
});
|
|
263
|
-
this.onCompact?.({
|
|
329
|
+
this.onCompact?.({
|
|
330
|
+
strategy: "micro",
|
|
331
|
+
before: preTier1Tokens,
|
|
332
|
+
after: postTier1Tokens,
|
|
333
|
+
});
|
|
264
334
|
}
|
|
265
335
|
}
|
|
266
|
-
|
|
336
|
+
let tokens = this.estimateTokensHybrid(result);
|
|
267
337
|
const ratio = tokens / this.config.maxTokens;
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (summary && summary.length > 50) {
|
|
285
|
-
result = applySummaryCompaction(result, summary, keepRecentN, this.transcriptPath);
|
|
286
|
-
this.consecutiveSummaryFailures = 0;
|
|
287
|
-
this.lastSummary = summary;
|
|
288
|
-
const after = estimateTokens(result);
|
|
289
|
-
logger.info("context.summary_compact", {
|
|
290
|
-
before: tokens,
|
|
291
|
-
after,
|
|
292
|
-
summaryLen: summary.length,
|
|
293
|
-
rolling: priorSummary !== undefined,
|
|
294
|
-
});
|
|
295
|
-
this.onCompact?.({ strategy: "summary", before: tokens, after });
|
|
296
|
-
return result;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
338
|
+
if (ratio < this.config.microcompactFloorRatio) {
|
|
339
|
+
this.suppressNoOpMicroSummaryUntilCompact = false;
|
|
340
|
+
}
|
|
341
|
+
const noOpMicroSpinBand = microNoOpAtFloor &&
|
|
342
|
+
ratio >= this.config.microcompactFloorRatio &&
|
|
343
|
+
ratio < this.config.compactAtRatio;
|
|
344
|
+
const shouldEscalateNoOpMicro = noOpMicroSpinBand && !this.suppressNoOpMicroSummaryUntilCompact;
|
|
345
|
+
// Tier 2: LLM summary if approaching limit, or if micro was the only tier
|
|
346
|
+
// available in the 0.70-0.85 band and it freed nothing.
|
|
347
|
+
if (ratio >= this.config.compactAtRatio || shouldEscalateNoOpMicro) {
|
|
348
|
+
const summarized = await this.trySummaryCompact(result, tokens, "context.summary_compact");
|
|
349
|
+
result = summarized.messages;
|
|
350
|
+
tokens = summarized.tokens;
|
|
351
|
+
if (summarized.compacted) {
|
|
352
|
+
this.suppressNoOpMicroSummaryUntilCompact = false;
|
|
353
|
+
return result;
|
|
299
354
|
}
|
|
300
|
-
|
|
301
|
-
this.
|
|
302
|
-
logger.
|
|
303
|
-
|
|
304
|
-
|
|
355
|
+
if (shouldEscalateNoOpMicro && summarized.noProgress) {
|
|
356
|
+
this.suppressNoOpMicroSummaryUntilCompact = true;
|
|
357
|
+
logger.info("context.micro_noop_summary_suppressed", {
|
|
358
|
+
tokens,
|
|
359
|
+
ratio,
|
|
360
|
+
compactAtRatio: this.config.compactAtRatio,
|
|
361
|
+
microcompactFloorRatio: this.config.microcompactFloorRatio,
|
|
305
362
|
});
|
|
306
363
|
}
|
|
307
364
|
}
|
|
308
|
-
// Reuse the `tokens` we already computed above. We only get here if
|
|
309
|
-
//
|
|
310
|
-
//
|
|
365
|
+
// Reuse the `tokens` we already computed above. We only get here if no
|
|
366
|
+
// summary compacted the prompt (no summarizeFn, too many failures, a
|
|
367
|
+
// thrown error, or a generated summary that did not shrink anything).
|
|
311
368
|
let live = tokens;
|
|
312
369
|
const snipGate = this.config.maxTokens * this.config.compactAtRatio;
|
|
313
370
|
const windowGate = this.config.maxTokens * (this.config.compactAtRatio + 0.05);
|
|
@@ -336,6 +393,61 @@ export class ContextManager {
|
|
|
336
393
|
}
|
|
337
394
|
return result;
|
|
338
395
|
}
|
|
396
|
+
/**
|
|
397
|
+
* Force maximum compaction, ignoring the ratio gates. This is what a manual
|
|
398
|
+
* `/compact` invokes: the user explicitly asked to shrink NOW, so we don't
|
|
399
|
+
* wait for the prompt to reach compactAtRatio.
|
|
400
|
+
*
|
|
401
|
+
* Order: tier-0 cleanups (persist/truncate/dedupe/mask) + microcompact, then
|
|
402
|
+
* an unconditional LLM summary of the older messages. If no summarizeFn is
|
|
403
|
+
* wired (or it fails / yields nothing), fall back to snip → window so the
|
|
404
|
+
* call still shrinks the conversation rather than no-opping.
|
|
405
|
+
*
|
|
406
|
+
* Unlike manage()/manageAsync(), there is NO `ratio >= compactAtRatio` gate:
|
|
407
|
+
* a long-but-under-threshold text-only conversation (the /compact bug) still
|
|
408
|
+
* gets summarized here.
|
|
409
|
+
*/
|
|
410
|
+
async forceSummarize(messages) {
|
|
411
|
+
let result = messages;
|
|
412
|
+
// Tier 0: same waste-removal + micro as the automatic path.
|
|
413
|
+
result = this.persistLargeToolResults(result);
|
|
414
|
+
result = this.truncateToolResults(result);
|
|
415
|
+
result = applyToolResultBudget(result);
|
|
416
|
+
const dedup = dedupeFileReads(result);
|
|
417
|
+
if (dedup.clearedCount > 0)
|
|
418
|
+
result = dedup.messages;
|
|
419
|
+
const masked = maskOldObservations(result);
|
|
420
|
+
if (masked.maskedCount > 0)
|
|
421
|
+
result = masked.messages;
|
|
422
|
+
const keepRecentN = this.config.microcompactKeepRecent ?? defaultKeepRecent(this.config.maxTokens);
|
|
423
|
+
result = microcompact(result, { keepRecentN });
|
|
424
|
+
let tokens = this.estimateTokensHybrid(result);
|
|
425
|
+
// Unconditional LLM summary (no ratio gate).
|
|
426
|
+
{
|
|
427
|
+
const summarized = await this.trySummaryCompact(result, tokens, "context.force_summary_compact");
|
|
428
|
+
result = summarized.messages;
|
|
429
|
+
tokens = summarized.tokens;
|
|
430
|
+
if (summarized.compacted)
|
|
431
|
+
return result;
|
|
432
|
+
}
|
|
433
|
+
// Fallback: no summary available — snip, then window, so /compact still
|
|
434
|
+
// shrinks the conversation instead of no-opping.
|
|
435
|
+
{
|
|
436
|
+
const before = tokens;
|
|
437
|
+
result = snipCompact(result, 3, 8);
|
|
438
|
+
tokens = this.estimateTokensHybrid(result);
|
|
439
|
+
if (tokens < before)
|
|
440
|
+
this.onCompact?.({ strategy: "snip", before, after: tokens });
|
|
441
|
+
}
|
|
442
|
+
if (this.estimateTokensHybrid(result) >= this.estimateTokensHybrid(messages)) {
|
|
443
|
+
const before = tokens;
|
|
444
|
+
const keepN = Math.max(10, Math.floor(result.length * 0.4));
|
|
445
|
+
result = windowCompact(result, keepN);
|
|
446
|
+
tokens = this.estimateTokensHybrid(result);
|
|
447
|
+
this.onCompact?.({ strategy: "window", before, after: tokens });
|
|
448
|
+
}
|
|
449
|
+
return result;
|
|
450
|
+
}
|
|
339
451
|
/**
|
|
340
452
|
* Persist large tool_result blocks to disk and replace them with a
|
|
341
453
|
* preview + filepath. No-op when transcript path hasn't been set
|
|
@@ -69,11 +69,24 @@ function estimateBlockTokens(block) {
|
|
|
69
69
|
if (block.content && typeof block.content === "string") {
|
|
70
70
|
tokens += estimateStringTokens(block.content);
|
|
71
71
|
}
|
|
72
|
+
else if (Array.isArray(block.content)) {
|
|
73
|
+
for (const nested of block.content) {
|
|
74
|
+
tokens += estimateBlockTokens(nested);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
72
77
|
if (block.input) {
|
|
73
78
|
tokens += estimateStringTokens(JSON.stringify(block.input));
|
|
74
79
|
}
|
|
75
80
|
if (block.name)
|
|
76
81
|
tokens += estimateStringTokens(block.name);
|
|
82
|
+
if (block.source?.data) {
|
|
83
|
+
tokens += estimateStringTokens(block.source.media_type);
|
|
84
|
+
tokens += estimateStringTokens(block.source.data);
|
|
85
|
+
}
|
|
86
|
+
const maybeOpenAI = block;
|
|
87
|
+
if (typeof maybeOpenAI.image_url?.url === "string") {
|
|
88
|
+
tokens += estimateStringTokens(maybeOpenAI.image_url.url);
|
|
89
|
+
}
|
|
77
90
|
return tokens;
|
|
78
91
|
}
|
|
79
92
|
export function calculateContextUsage(messages, systemPrompt, maxTokens) {
|
package/dist/engine/engine.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { ToolRegistry } from "../tool-system/registry.js";
|
|
|
6
6
|
import { type BuiltinToolFn } from "../tool-system/builtin/index.js";
|
|
7
7
|
import { HookRegistry } from "../hooks/registry.js";
|
|
8
8
|
import { type GoalConfig, type GoalExtension } from "./goal.js";
|
|
9
|
+
import { type CompactStrategy } from "../context/manager.js";
|
|
9
10
|
import { SessionManager } from "../session/session-manager.js";
|
|
10
11
|
import type { AskUserFn } from "../tool-system/builtin/ask-user.js";
|
|
11
12
|
import type { CapabilityOverride } from "../settings/schema.js";
|
|
@@ -27,6 +28,10 @@ export declare function compatFileNamesFrom(instructions?: {
|
|
|
27
28
|
}): string[];
|
|
28
29
|
export type { EngineConfig, EngineHookConfig, EngineResult } from "./types.js";
|
|
29
30
|
import type { EngineConfig, EngineResult } from "./types.js";
|
|
31
|
+
export interface EnqueueSteerResult {
|
|
32
|
+
accepted: boolean;
|
|
33
|
+
id: string;
|
|
34
|
+
}
|
|
30
35
|
export { diskDefaultsFrom, type DiskDefaultPatch } from "../settings/disk-defaults.js";
|
|
31
36
|
/**
|
|
32
37
|
* Resolve the LLM config for a spawned child Engine.
|
|
@@ -260,16 +265,16 @@ export declare class Engine {
|
|
|
260
265
|
* Queue a user message to be spliced into the in-flight run for `sessionId`
|
|
261
266
|
* at the next turn-loop step boundary — the 不打断 steering path (vs cancel +
|
|
262
267
|
* resend). General-purpose: any host path (UI 引导, future agent coordination,
|
|
263
|
-
* external triggers) can call it. If no run is active for
|
|
264
|
-
*
|
|
265
|
-
*
|
|
268
|
+
* external triggers) can call it. If no run is active for this session, reject
|
|
269
|
+
* without queueing so the host can downgrade to a normal run immediately.
|
|
270
|
+
* No-op on blank text.
|
|
266
271
|
*
|
|
267
272
|
* `id` is the host's stable queue-entry id. It rides through to the
|
|
268
273
|
* `steer_injected` event (so the host can match the injected bubble back to
|
|
269
274
|
* the queued draft) and is the handle `unsteer` uses to revoke a still-pending
|
|
270
275
|
* entry. A blank id is tolerated but means the entry can't be revoked.
|
|
271
276
|
*/
|
|
272
|
-
enqueueSteer(sessionId: string, text: string, id?: string):
|
|
277
|
+
enqueueSteer(sessionId: string, text: string, id?: string, clientMessageId?: string): EnqueueSteerResult;
|
|
273
278
|
/**
|
|
274
279
|
* Revoke a still-pending steer entry (the 撤回 path). Returns true if it was
|
|
275
280
|
* removed, false if it was already consumed by the turn loop (can't take it
|
|
@@ -318,6 +323,8 @@ export declare class Engine {
|
|
|
318
323
|
* on replay (matching the live UI, which shows only the assistant reply).
|
|
319
324
|
*/
|
|
320
325
|
injected?: boolean;
|
|
326
|
+
/** Stable id for this user-intent, used to make duplicate submits idempotent. */
|
|
327
|
+
clientMessageId?: string;
|
|
321
328
|
}): Promise<EngineResult>;
|
|
322
329
|
/**
|
|
323
330
|
* Run the end-of-session memory pipeline as a fire-and-forget background
|
|
@@ -332,6 +339,11 @@ export declare class Engine {
|
|
|
332
339
|
* active run's client) when unset, unknown, or on any build failure — aux
|
|
333
340
|
* work is best-effort and must never break a run.
|
|
334
341
|
*/
|
|
342
|
+
/**
|
|
343
|
+
* Build the SummarizeFn used for context compaction. Extracted so both the
|
|
344
|
+
* run path and forceCompact share one definition of the summarization call.
|
|
345
|
+
*/
|
|
346
|
+
private buildSummarizeFn;
|
|
335
347
|
private resolveAuxClient;
|
|
336
348
|
private runMemoryPipeline;
|
|
337
349
|
/**
|
|
@@ -363,10 +375,8 @@ export declare class Engine {
|
|
|
363
375
|
*/
|
|
364
376
|
switchModel(key: string): ModelEntry;
|
|
365
377
|
/**
|
|
366
|
-
* Zero
|
|
367
|
-
*
|
|
368
|
-
* cache-hit stats from the prior model are no longer meaningful. The next
|
|
369
|
-
* run's baseline (snapshotted from state.tokenUsage) then starts from zero.
|
|
378
|
+
* Zero the legacy/model-scoped token/cache usage window on disk. The
|
|
379
|
+
* whole-session cumulative counters are intentionally left alone.
|
|
370
380
|
*/
|
|
371
381
|
resetSessionUsage(sessionId: string): void;
|
|
372
382
|
/**
|
|
@@ -452,14 +462,14 @@ export declare class Engine {
|
|
|
452
462
|
clearGoal(sessionId: string): boolean;
|
|
453
463
|
injectContext(sessionId: string, content: string): void;
|
|
454
464
|
/**
|
|
455
|
-
* Force context compaction on
|
|
465
|
+
* Force context compaction on a session.
|
|
456
466
|
* Returns token stats before/after.
|
|
457
467
|
*/
|
|
458
|
-
forceCompact(): {
|
|
468
|
+
forceCompact(sessionId?: string): Promise<{
|
|
459
469
|
before: number;
|
|
460
470
|
after: number;
|
|
461
|
-
strategy:
|
|
462
|
-
}
|
|
471
|
+
strategy: "none (no active session)" | "no compaction needed" | "compacted" | CompactStrategy;
|
|
472
|
+
}>;
|
|
463
473
|
private stripUserContextMessage;
|
|
464
474
|
private getSettingsManager;
|
|
465
475
|
/**
|