@cjhyy/code-shell-core 0.6.0-rc.1 → 0.6.0-rc.10

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 (69) hide show
  1. package/dist/context/compaction.d.ts +30 -0
  2. package/dist/context/compaction.js +93 -0
  3. package/dist/context/manager.d.ts +18 -0
  4. package/dist/context/manager.js +156 -44
  5. package/dist/context/token-counter.js +13 -0
  6. package/dist/engine/engine.d.ts +22 -12
  7. package/dist/engine/engine.js +263 -81
  8. package/dist/engine/model-connections-pool.js +1 -0
  9. package/dist/engine/model-facade.js +2 -12
  10. package/dist/engine/query.js +2 -0
  11. package/dist/engine/runtime.d.ts +2 -0
  12. package/dist/engine/runtime.js +25 -0
  13. package/dist/engine/session-usage.d.ts +12 -0
  14. package/dist/engine/session-usage.js +56 -0
  15. package/dist/engine/steer-queue.d.ts +2 -1
  16. package/dist/engine/steer-queue.js +2 -2
  17. package/dist/engine/turn-loop.d.ts +28 -2
  18. package/dist/engine/turn-loop.js +153 -26
  19. package/dist/git/utils.d.ts +12 -0
  20. package/dist/git/utils.js +33 -6
  21. package/dist/index.d.ts +4 -3
  22. package/dist/index.js +4 -3
  23. package/dist/llm/capabilities/rules.js +1 -1
  24. package/dist/llm/model-pool.d.ts +7 -0
  25. package/dist/llm/model-pool.js +8 -1
  26. package/dist/model-catalog/builtin.js +6 -1
  27. package/dist/preset/index.d.ts +5 -1
  28. package/dist/preset/index.js +21 -2
  29. package/dist/prompt/composer.d.ts +5 -0
  30. package/dist/prompt/composer.js +10 -2
  31. package/dist/prompt/sections/base.md +1 -0
  32. package/dist/protocol/chat-session-manager.d.ts +1 -0
  33. package/dist/protocol/chat-session-manager.js +2 -0
  34. package/dist/protocol/chat-session.d.ts +4 -1
  35. package/dist/protocol/chat-session.js +9 -3
  36. package/dist/protocol/client.d.ts +5 -1
  37. package/dist/protocol/client.js +8 -2
  38. package/dist/protocol/server.d.ts +13 -12
  39. package/dist/protocol/server.js +199 -67
  40. package/dist/protocol/types.d.ts +14 -0
  41. package/dist/runtime/background-shell.js +14 -0
  42. package/dist/runtime/safe-spawn.js +89 -11
  43. package/dist/runtime/spawn-common.d.ts +15 -4
  44. package/dist/runtime/spawn-common.js +113 -12
  45. package/dist/session/session-manager.js +7 -1
  46. package/dist/session/transcript.d.ts +4 -0
  47. package/dist/session/transcript.js +21 -0
  48. package/dist/tool-system/builtin/bash.js +3 -2
  49. package/dist/tool-system/builtin/cron.js +10 -2
  50. package/dist/tool-system/builtin/edit-model-catalog.js +15 -5
  51. package/dist/tool-system/builtin/generate-video.js +3 -0
  52. package/dist/tool-system/builtin/grep.d.ts +9 -0
  53. package/dist/tool-system/builtin/grep.js +100 -3
  54. package/dist/tool-system/builtin/index.d.ts +3 -1
  55. package/dist/tool-system/builtin/index.js +5 -5
  56. package/dist/tool-system/builtin/powershell.js +4 -1
  57. package/dist/tool-system/builtin/sleep.js +5 -0
  58. package/dist/tool-system/context.d.ts +10 -0
  59. package/dist/tool-system/executor.js +25 -2
  60. package/dist/tool-system/mcp-manager.js +17 -0
  61. package/dist/tool-system/mcp-stdio-diagnostics.d.ts +9 -0
  62. package/dist/tool-system/mcp-stdio-diagnostics.js +93 -0
  63. package/dist/tool-system/permission.d.ts +3 -1
  64. package/dist/tool-system/permission.js +2 -1
  65. package/dist/tool-system/sandbox/off.js +7 -1
  66. package/dist/types.d.ts +35 -1
  67. package/dist/utils/exec.d.ts +8 -0
  68. package/dist/utils/exec.js +10 -0
  69. 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
@@ -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
- if (preTier1Tokens > this.config.maxTokens * this.config.microcompactFloorRatio) {
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?.({ strategy: "micro", before: preTier1Tokens, after });
329
+ this.onCompact?.({
330
+ strategy: "micro",
331
+ before: preTier1Tokens,
332
+ after: postTier1Tokens,
333
+ });
264
334
  }
265
335
  }
266
- const tokens = this.estimateTokensHybrid(result);
336
+ let tokens = this.estimateTokensHybrid(result);
267
337
  const ratio = tokens / this.config.maxTokens;
268
- // Tier 2: LLM summary if approaching limit
269
- if (ratio >= this.config.compactAtRatio &&
270
- this.summarizeFn &&
271
- this.consecutiveSummaryFailures < 3) {
272
- try {
273
- const keepRecentN = Math.max(8, Math.floor(result.length * 0.3));
274
- const messagesToSummarize = result.slice(1, -keepRecentN); // skip first (userContext) and recent
275
- if (messagesToSummarize.length > 3) {
276
- // Rolling summary: if a prior summary is already anchored in the
277
- // messages (from an earlier compaction in this session or in a
278
- // resumed session), feed it back so the LLM merges-updates rather
279
- // than re-summarizes from scratch — preserves info that would
280
- // otherwise erode across successive compactions.
281
- const priorSummary = extractAnchoredSummary(result) ?? this.lastSummary;
282
- const prompt = buildSummarizationPrompt(messagesToSummarize, priorSummary);
283
- const summary = await this.summarizeFn(prompt);
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
- catch (err) {
301
- this.consecutiveSummaryFailures++;
302
- logger.warn("context.summary_failed", {
303
- failures: this.consecutiveSummaryFailures,
304
- error: err.message,
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 the
309
- // LLM summary path didn't fire (no summarizeFn, too many failures, or it
310
- // threw) fall back to the same severity ladder as manage().
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) {
@@ -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 the session the
264
- * message simply waits in the queue and is consumed when that session next
265
- * runs (rare race; host normally only steers while busy). No-op on blank text.
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): void;
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 a session's cumulative token/cache usage on disk. Called on a model
367
- * switch: a different model has its own prompt cache, so the accumulated
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 the current session.
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: string;
462
- };
471
+ strategy: "none (no active session)" | "no compaction needed" | "compacted" | CompactStrategy;
472
+ }>;
463
473
  private stripUserContextMessage;
464
474
  private getSettingsManager;
465
475
  /**