@cjhyy/code-shell-core 0.6.0-rc.8 → 0.7.0-beta.1

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 (177) hide show
  1. package/THIRD_PARTY_NOTICES.md +206 -0
  2. package/dist/automation/scheduler.d.ts +13 -7
  3. package/dist/automation/scheduler.js +116 -37
  4. package/dist/capability-control/service.d.ts +2 -0
  5. package/dist/capability-control/service.js +4 -2
  6. package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
  7. package/dist/cc-orchestrator/agent-adapter.js +7 -1
  8. package/dist/cc-orchestrator/codex-session-history.js +1 -1
  9. package/dist/cc-orchestrator/cwd-normalize.d.ts +2 -0
  10. package/dist/cc-orchestrator/cwd-normalize.js +19 -0
  11. package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
  12. package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
  13. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -0
  14. package/dist/cc-orchestrator/external-agent-driver.js +102 -51
  15. package/dist/cc-orchestrator/external-agent-session-store.d.ts +23 -0
  16. package/dist/cc-orchestrator/external-agent-session-store.js +146 -0
  17. package/dist/cc-orchestrator/session-history.js +2 -2
  18. package/dist/cli/agent-server-stdio.js +9 -2
  19. package/dist/context/compaction.d.ts +38 -1
  20. package/dist/context/compaction.js +138 -0
  21. package/dist/context/manager.d.ts +34 -2
  22. package/dist/context/manager.js +254 -58
  23. package/dist/context/token-counter.js +13 -0
  24. package/dist/credentials/access.d.ts +56 -0
  25. package/dist/credentials/access.js +183 -0
  26. package/dist/credentials/index.d.ts +1 -0
  27. package/dist/credentials/index.js +1 -0
  28. package/dist/credentials/inject-credential-tool.d.ts +3 -1
  29. package/dist/credentials/inject-credential-tool.js +30 -11
  30. package/dist/credentials/types.d.ts +2 -2
  31. package/dist/credentials/use-credential-tool.d.ts +9 -1
  32. package/dist/credentials/use-credential-tool.js +58 -45
  33. package/dist/engine/engine.d.ts +38 -12
  34. package/dist/engine/engine.js +477 -163
  35. package/dist/engine/image-policy.d.ts +6 -0
  36. package/dist/engine/image-policy.js +17 -6
  37. package/dist/engine/input-attachments.d.ts +13 -0
  38. package/dist/engine/input-attachments.js +255 -0
  39. package/dist/engine/model-facade.d.ts +5 -2
  40. package/dist/engine/model-facade.js +6 -16
  41. package/dist/engine/parse-task.d.ts +10 -0
  42. package/dist/engine/parse-task.js +5 -0
  43. package/dist/engine/query.js +2 -0
  44. package/dist/engine/session-usage.d.ts +12 -0
  45. package/dist/engine/session-usage.js +56 -0
  46. package/dist/engine/steer-queue.d.ts +2 -1
  47. package/dist/engine/steer-queue.js +2 -2
  48. package/dist/engine/streaming-tool-queue.d.ts +11 -7
  49. package/dist/engine/streaming-tool-queue.js +11 -7
  50. package/dist/engine/turn-loop.d.ts +34 -2
  51. package/dist/engine/turn-loop.js +269 -52
  52. package/dist/engine/types.d.ts +8 -0
  53. package/dist/git/worktree/crud.d.ts +69 -0
  54. package/dist/git/worktree/crud.js +206 -0
  55. package/dist/git/worktree/diff.d.ts +14 -0
  56. package/dist/git/worktree/diff.js +82 -0
  57. package/dist/git/worktree/git-exec.d.ts +7 -0
  58. package/dist/git/worktree/git-exec.js +51 -0
  59. package/dist/git/worktree/index.d.ts +5 -0
  60. package/dist/git/worktree/index.js +5 -0
  61. package/dist/git/worktree/query.d.ts +42 -0
  62. package/dist/git/worktree/query.js +121 -0
  63. package/dist/git/worktree/slug.d.ts +11 -0
  64. package/dist/git/worktree/slug.js +58 -0
  65. package/dist/git/worktree.d.ts +1 -84
  66. package/dist/git/worktree.js +5 -230
  67. package/dist/index.d.ts +31 -28
  68. package/dist/index.js +27 -25
  69. package/dist/logging/logger.js +6 -6
  70. package/dist/logging/sanitize-messages.d.ts +10 -2
  71. package/dist/logging/sanitize-messages.js +21 -6
  72. package/dist/plugins/installer/checkUpdate.d.ts +4 -1
  73. package/dist/plugins/installer/checkUpdate.js +4 -2
  74. package/dist/plugins/installer/install.js +2 -0
  75. package/dist/plugins/installer/installFromSource.js +9 -1
  76. package/dist/plugins/installer/parseSource.d.ts +4 -1
  77. package/dist/plugins/installer/parseSource.js +28 -10
  78. package/dist/plugins/installer/sourcePath.d.ts +9 -0
  79. package/dist/plugins/installer/sourcePath.js +50 -0
  80. package/dist/plugins/installer/update.d.ts +4 -1
  81. package/dist/plugins/installer/update.js +5 -3
  82. package/dist/plugins/parseMarketplaceInput.d.ts +4 -1
  83. package/dist/plugins/parseMarketplaceInput.js +4 -3
  84. package/dist/plugins/pluginInstaller.js +24 -22
  85. package/dist/preset/index.d.ts +1 -0
  86. package/dist/preset/index.js +16 -9
  87. package/dist/prompt/sections/base.md +1 -1
  88. package/dist/protocol/chat-session-manager.d.ts +2 -0
  89. package/dist/protocol/chat-session-manager.js +38 -2
  90. package/dist/protocol/chat-session.d.ts +5 -0
  91. package/dist/protocol/chat-session.js +2 -0
  92. package/dist/protocol/client.d.ts +13 -5
  93. package/dist/protocol/client.js +26 -3
  94. package/dist/protocol/server.d.ts +43 -12
  95. package/dist/protocol/server.js +326 -104
  96. package/dist/protocol/types.d.ts +51 -0
  97. package/dist/protocol/types.js +6 -0
  98. package/dist/run/FileRunStore.js +10 -1
  99. package/dist/run/Heartbeat.js +12 -0
  100. package/dist/run/RunApprovalBackend.d.ts +3 -0
  101. package/dist/run/RunApprovalBackend.js +41 -6
  102. package/dist/run/RunLock.js +2 -0
  103. package/dist/run/RunManager.d.ts +2 -0
  104. package/dist/run/RunManager.js +64 -24
  105. package/dist/run/ids.d.ts +2 -0
  106. package/dist/run/ids.js +23 -0
  107. package/dist/runtime/background-shell.d.ts +1 -0
  108. package/dist/runtime/background-shell.js +23 -13
  109. package/dist/runtime/safe-spawn.js +74 -11
  110. package/dist/runtime/spawn-common.js +10 -0
  111. package/dist/services/auto-dream.d.ts +15 -4
  112. package/dist/services/auto-dream.js +20 -20
  113. package/dist/services/dream-consolidation.d.ts +2 -3
  114. package/dist/services/dream-consolidation.js +65 -15
  115. package/dist/services/extract-memories.d.ts +14 -5
  116. package/dist/services/extract-memories.js +20 -3
  117. package/dist/services/global-dream-promotion.d.ts +23 -0
  118. package/dist/services/global-dream-promotion.js +112 -0
  119. package/dist/services/memory-orchestrator.js +347 -34
  120. package/dist/session/memory.d.ts +61 -18
  121. package/dist/session/memory.js +342 -79
  122. package/dist/session/session-manager.d.ts +35 -1
  123. package/dist/session/session-manager.js +197 -4
  124. package/dist/session/transcript.d.ts +5 -1
  125. package/dist/session/transcript.js +38 -5
  126. package/dist/settings/manager.d.ts +1 -0
  127. package/dist/settings/manager.js +87 -37
  128. package/dist/settings/schema-export.d.ts +2 -3
  129. package/dist/settings/schema-export.js +2 -3
  130. package/dist/settings/schema.d.ts +21 -0
  131. package/dist/settings/schema.js +12 -0
  132. package/dist/skills/scanner.d.ts +3 -2
  133. package/dist/skills/scanner.js +12 -9
  134. package/dist/tool-system/builtin/background-jobs.d.ts +10 -1
  135. package/dist/tool-system/builtin/background-jobs.js +12 -1
  136. package/dist/tool-system/builtin/background-work.d.ts +17 -18
  137. package/dist/tool-system/builtin/background-work.js +51 -5
  138. package/dist/tool-system/builtin/config.d.ts +2 -1
  139. package/dist/tool-system/builtin/config.js +16 -11
  140. package/dist/tool-system/builtin/drive-claude-code.d.ts +16 -2
  141. package/dist/tool-system/builtin/drive-claude-code.js +301 -47
  142. package/dist/tool-system/builtin/edit.js +5 -2
  143. package/dist/tool-system/builtin/generate-video.d.ts +1 -0
  144. package/dist/tool-system/builtin/generate-video.js +13 -4
  145. package/dist/tool-system/builtin/index.d.ts +8 -3
  146. package/dist/tool-system/builtin/index.js +37 -21
  147. package/dist/tool-system/builtin/lsp.d.ts +2 -1
  148. package/dist/tool-system/builtin/lsp.js +6 -3
  149. package/dist/tool-system/builtin/memory.js +40 -8
  150. package/dist/tool-system/builtin/notebook-edit.js +5 -2
  151. package/dist/tool-system/builtin/powershell.d.ts +5 -2
  152. package/dist/tool-system/builtin/powershell.js +11 -7
  153. package/dist/tool-system/builtin/read.js +118 -6
  154. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  155. package/dist/tool-system/builtin/view-image.js +109 -19
  156. package/dist/tool-system/builtin/worktree.d.ts +4 -4
  157. package/dist/tool-system/builtin/worktree.js +297 -74
  158. package/dist/tool-system/builtin/write.js +5 -3
  159. package/dist/tool-system/context.d.ts +23 -1
  160. package/dist/tool-system/executor.d.ts +1 -5
  161. package/dist/tool-system/executor.js +94 -115
  162. package/dist/tool-system/mcp-manager.d.ts +18 -5
  163. package/dist/tool-system/mcp-manager.js +157 -74
  164. package/dist/tool-system/mcp-stdio-diagnostics.d.ts +9 -0
  165. package/dist/tool-system/mcp-stdio-diagnostics.js +93 -0
  166. package/dist/tool-system/path-policy.d.ts +2 -0
  167. package/dist/tool-system/path-policy.js +41 -12
  168. package/dist/tool-system/permission.d.ts +28 -7
  169. package/dist/tool-system/permission.js +130 -49
  170. package/dist/tool-system/registry.js +11 -4
  171. package/dist/tool-system/tool-result-redaction.d.ts +7 -0
  172. package/dist/tool-system/tool-result-redaction.js +48 -0
  173. package/dist/tool-system/workspace-bridge.d.ts +11 -0
  174. package/dist/tool-system/workspace-bridge.js +1 -0
  175. package/dist/types.d.ts +86 -5
  176. package/dist/utils/toolDisplay.js +1 -1
  177. package/package.json +4 -3
@@ -18,6 +18,144 @@ 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
+ export function collectBase64Images(messages) {
24
+ const entries = [];
25
+ let nextImageNumber = 1;
26
+ const visitBlocks = (blocks) => {
27
+ for (const block of blocks) {
28
+ const placeholderNumber = imageHistoryPlaceholderNumber(block);
29
+ if (placeholderNumber !== undefined) {
30
+ nextImageNumber = Math.max(nextImageNumber, placeholderNumber + 1);
31
+ continue;
32
+ }
33
+ if (isBase64ImageBlock(block)) {
34
+ entries.push({ imageNumber: nextImageNumber++, block });
35
+ continue;
36
+ }
37
+ if (block.type === "tool_result" && Array.isArray(block.content)) {
38
+ visitBlocks(block.content);
39
+ }
40
+ }
41
+ };
42
+ for (const msg of messages) {
43
+ if (Array.isArray(msg.content))
44
+ visitBlocks(msg.content);
45
+ }
46
+ return entries;
47
+ }
48
+ export function findImageByNumber(messages, imageNumber) {
49
+ if (!Number.isSafeInteger(imageNumber) || imageNumber <= 0)
50
+ return undefined;
51
+ return collectBase64Images(messages).find((entry) => entry.imageNumber === imageNumber);
52
+ }
53
+ /**
54
+ * Replace already-consumed image payload blocks with compact text markers.
55
+ *
56
+ * The transcript may retain the full image bytes for rendering/resume, but the
57
+ * working message history sent to the model should not re-send base64 after the
58
+ * model has seen it once. This handles both our internal Anthropic-style image
59
+ * blocks and OpenAI-style data-url image blocks defensively, including images
60
+ * nested inside tool_result.content arrays (view_image / browser screenshots).
61
+ */
62
+ export function downgradeImagePayloadsInHistory(messages, options = {}) {
63
+ let replacedCount = 0;
64
+ let changed = false;
65
+ const imageNumberQueues = new WeakMap();
66
+ for (const entry of collectBase64Images(messages)) {
67
+ const key = entry.block;
68
+ const existing = imageNumberQueues.get(key);
69
+ if (existing) {
70
+ existing.push(entry.imageNumber);
71
+ }
72
+ else {
73
+ imageNumberQueues.set(key, [entry.imageNumber]);
74
+ }
75
+ }
76
+ const takeImageNumber = (block) => {
77
+ const queue = imageNumberQueues.get(block);
78
+ return queue?.shift();
79
+ };
80
+ const placeholderFor = (imageNumber) => ({
81
+ type: "text",
82
+ text: `${IMAGE_HISTORY_PLACEHOLDER_PREFIX}${imageNumber}${IMAGE_HISTORY_PLACEHOLDER_SUFFIX}`,
83
+ });
84
+ const transformBlocks = (blocks, preserve) => {
85
+ let blocksChanged = false;
86
+ const out = blocks.map((block) => {
87
+ const placeholderNumber = imageHistoryPlaceholderNumber(block);
88
+ if (placeholderNumber !== undefined) {
89
+ return block;
90
+ }
91
+ if (isBase64ImageBlock(block)) {
92
+ const imageNumber = takeImageNumber(block);
93
+ if (imageNumber === undefined)
94
+ return block;
95
+ if (preserve)
96
+ return block;
97
+ replacedCount++;
98
+ blocksChanged = true;
99
+ return placeholderFor(imageNumber);
100
+ }
101
+ if (block.type === "tool_result" && Array.isArray(block.content)) {
102
+ const nested = transformBlocks(block.content, preserve);
103
+ if (nested.changed) {
104
+ blocksChanged = true;
105
+ return { ...block, content: nested.blocks };
106
+ }
107
+ }
108
+ return block;
109
+ });
110
+ return { blocks: blocksChanged ? out : blocks, changed: blocksChanged };
111
+ };
112
+ const out = messages.map((msg) => {
113
+ if (!Array.isArray(msg.content))
114
+ return msg;
115
+ const preserve = options.preserveMessages?.has(msg) === true;
116
+ const result = transformBlocks(msg.content, preserve);
117
+ if (!result.changed)
118
+ return msg;
119
+ changed = true;
120
+ return { ...msg, content: result.blocks };
121
+ });
122
+ return { messages: changed ? out : messages, replacedCount };
123
+ }
124
+ export function messageHasBase64ImagePayload(message) {
125
+ if (!Array.isArray(message.content))
126
+ return false;
127
+ return message.content.some(blockHasBase64ImagePayload);
128
+ }
129
+ function blockHasBase64ImagePayload(block) {
130
+ if (isBase64ImageBlock(block))
131
+ return true;
132
+ return (block.type === "tool_result" &&
133
+ Array.isArray(block.content) &&
134
+ block.content.some(blockHasBase64ImagePayload));
135
+ }
136
+ function imageHistoryPlaceholderNumber(block) {
137
+ if (block.type !== "text" || typeof block.text !== "string")
138
+ return undefined;
139
+ const escapedPrefix = IMAGE_HISTORY_PLACEHOLDER_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
140
+ const escapedSuffix = IMAGE_HISTORY_PLACEHOLDER_SUFFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
141
+ const match = block.text.match(new RegExp(`^${escapedPrefix}(\\d+)${escapedSuffix}$`));
142
+ if (!match?.[1])
143
+ return undefined;
144
+ const n = Number(match[1]);
145
+ return Number.isSafeInteger(n) && n > 0 ? n : undefined;
146
+ }
147
+ export function isBase64ImageBlock(block) {
148
+ if (block.type === "image" &&
149
+ block.source?.type === "base64" &&
150
+ typeof block.source.data === "string" &&
151
+ block.source.data.length > 0) {
152
+ return true;
153
+ }
154
+ const maybeOpenAI = block;
155
+ return (maybeOpenAI.type === "image_url" &&
156
+ typeof maybeOpenAI.image_url?.url === "string" &&
157
+ /^data:image\/[^;,]+;base64,/i.test(maybeOpenAI.image_url.url));
158
+ }
21
159
  /**
22
160
  * Reconcile user-supplied compaction ratios into a safe ordering.
23
161
  *
@@ -5,7 +5,7 @@
5
5
  * Tier 2: LLM summary (async) — generate summary of older messages via model call
6
6
  * Tier 3: window compact (sync, emergency) — aggressive truncation fallback
7
7
  */
8
- import type { Message } from "../types.js";
8
+ import type { Message, ContextUsageAnchor, PromptTokenConfidence, PromptTokenSource } from "../types.js";
9
9
  export interface ContextManagerConfig {
10
10
  maxTokens: number;
11
11
  compactAtRatio: number;
@@ -41,11 +41,18 @@ 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;
47
49
  /** Message count at the time lastActualTokens was recorded. */
48
50
  private lastActualAtMessageCount;
51
+ /** Heuristic token estimate for the same messages as lastActualTokens. */
52
+ private lastActualAnchorEstimate;
53
+ private lastActualRecordedAt;
54
+ private lastActualProvider;
55
+ private lastActualModel;
49
56
  /** Path to session transcript — passed to summary compaction for on-demand access. */
50
57
  private transcriptPath;
51
58
  /** Notified whenever any compaction tier fires, including microcompact. */
@@ -63,16 +70,24 @@ export declare class ContextManager {
63
70
  * Record actual token usage from API response.
64
71
  * Used for hybrid estimation: actual + estimate for new messages.
65
72
  */
66
- recordActualUsage(inputTokens: number, messageCount: number): void;
73
+ recordActualUsage(inputTokens: number, messageCount: number, messages?: Message[]): ContextUsageAnchor | undefined;
74
+ /**
75
+ * Seed actual prompt-token usage from persisted session state.
76
+ * Returns the normalized anchor when accepted; invalid legacy/tampered data is ignored.
77
+ */
78
+ seedActualUsage(anchor: ContextUsageAnchor | undefined): ContextUsageAnchor | undefined;
79
+ getActualUsageAnchor(): ContextUsageAnchor | undefined;
67
80
  /**
68
81
  * Best-effort token estimate: uses actual API usage as base if available,
69
82
  * plus estimation for messages added since the last API call.
70
83
  */
84
+ private estimateTokensHybridInfo;
71
85
  private estimateTokensHybrid;
72
86
  /**
73
87
  * Set the summarize function (injected by Engine).
74
88
  */
75
89
  setSummarizeFn(fn: SummarizeFn): void;
90
+ private trySummaryCompact;
76
91
  /**
77
92
  * Set the transcript path so compaction can reference it. The
78
93
  * tool-results directory lives alongside the transcript file:
@@ -97,6 +112,21 @@ export declare class ContextManager {
97
112
  * Call this when you have access to the LLM (between turns).
98
113
  */
99
114
  manageAsync(messages: Message[]): Promise<Message[]>;
115
+ /**
116
+ * Force maximum compaction, ignoring the ratio gates. This is what a manual
117
+ * `/compact` invokes: the user explicitly asked to shrink NOW, so we don't
118
+ * wait for the prompt to reach compactAtRatio.
119
+ *
120
+ * Order: tier-0 cleanups (persist/truncate/dedupe/mask) + microcompact, then
121
+ * an unconditional LLM summary of the older messages. If no summarizeFn is
122
+ * wired (or it fails / yields nothing), fall back to snip → window so the
123
+ * call still shrinks the conversation rather than no-opping.
124
+ *
125
+ * Unlike manage()/manageAsync(), there is NO `ratio >= compactAtRatio` gate:
126
+ * a long-but-under-threshold text-only conversation (the /compact bug) still
127
+ * gets summarized here.
128
+ */
129
+ forceSummarize(messages: Message[]): Promise<Message[]>;
100
130
  /**
101
131
  * Persist large tool_result blocks to disk and replace them with a
102
132
  * preview + filepath. No-op when transcript path hasn't been set
@@ -121,5 +151,7 @@ export declare class ContextManager {
121
151
  ratio: number;
122
152
  needsCompact: boolean;
123
153
  needsEmergency: boolean;
154
+ promptTokensSource: PromptTokenSource;
155
+ promptTokensConfidence: PromptTokenConfidence;
124
156
  };
125
157
  }
@@ -37,15 +37,25 @@ const DEFAULT_CONFIG = {
37
37
  function defaultKeepRecent(maxTokens) {
38
38
  return Math.max(5, Math.floor(maxTokens / 100_000));
39
39
  }
40
+ function positiveFinite(value) {
41
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
42
+ }
40
43
  export class ContextManager {
41
44
  config;
42
45
  summarizeFn;
43
46
  consecutiveSummaryFailures = 0;
47
+ /** Prevents repeated spin-band LLM calls after a summary generated no shrink. */
48
+ suppressNoOpMicroSummaryUntilCompact = false;
44
49
  lastSummary;
45
50
  /** Last known actual token count from API usage data. */
46
51
  lastActualTokens;
47
52
  /** Message count at the time lastActualTokens was recorded. */
48
53
  lastActualAtMessageCount;
54
+ /** Heuristic token estimate for the same messages as lastActualTokens. */
55
+ lastActualAnchorEstimate;
56
+ lastActualRecordedAt;
57
+ lastActualProvider;
58
+ lastActualModel;
49
59
  /** Path to session transcript — passed to summary compaction for on-demand access. */
50
60
  transcriptPath;
51
61
  /** Notified whenever any compaction tier fires, including microcompact. */
@@ -67,23 +77,84 @@ export class ContextManager {
67
77
  * Record actual token usage from API response.
68
78
  * Used for hybrid estimation: actual + estimate for new messages.
69
79
  */
70
- recordActualUsage(inputTokens, messageCount) {
71
- this.lastActualTokens = inputTokens;
72
- this.lastActualAtMessageCount = messageCount;
80
+ recordActualUsage(inputTokens, messageCount, messages) {
81
+ const estimateAtAnchor = messages ? estimateTokens(messages) : undefined;
82
+ return this.seedActualUsage({
83
+ promptTokens: inputTokens,
84
+ messageCount,
85
+ ...(estimateAtAnchor !== undefined ? { estimateAtAnchor } : {}),
86
+ recordedAt: Date.now(),
87
+ });
88
+ }
89
+ /**
90
+ * Seed actual prompt-token usage from persisted session state.
91
+ * Returns the normalized anchor when accepted; invalid legacy/tampered data is ignored.
92
+ */
93
+ seedActualUsage(anchor) {
94
+ if (!anchor)
95
+ return undefined;
96
+ if (!positiveFinite(anchor.promptTokens))
97
+ return undefined;
98
+ if (!Number.isSafeInteger(anchor.messageCount) || anchor.messageCount <= 0) {
99
+ return undefined;
100
+ }
101
+ if (anchor.estimateAtAnchor !== undefined && !positiveFinite(anchor.estimateAtAnchor)) {
102
+ return undefined;
103
+ }
104
+ this.lastActualTokens = anchor.promptTokens;
105
+ this.lastActualAtMessageCount = anchor.messageCount;
106
+ this.lastActualAnchorEstimate = anchor.estimateAtAnchor;
107
+ this.lastActualRecordedAt = positiveFinite(anchor.recordedAt) ? anchor.recordedAt : Date.now();
108
+ this.lastActualProvider = anchor.provider;
109
+ this.lastActualModel = anchor.model;
110
+ return this.getActualUsageAnchor();
111
+ }
112
+ getActualUsageAnchor() {
113
+ if (this.lastActualTokens === undefined ||
114
+ this.lastActualAtMessageCount === undefined ||
115
+ this.lastActualRecordedAt === undefined) {
116
+ return undefined;
117
+ }
118
+ return {
119
+ promptTokens: this.lastActualTokens,
120
+ messageCount: this.lastActualAtMessageCount,
121
+ ...(this.lastActualAnchorEstimate !== undefined
122
+ ? { estimateAtAnchor: this.lastActualAnchorEstimate }
123
+ : {}),
124
+ recordedAt: this.lastActualRecordedAt,
125
+ ...(this.lastActualProvider ? { provider: this.lastActualProvider } : {}),
126
+ ...(this.lastActualModel ? { model: this.lastActualModel } : {}),
127
+ };
73
128
  }
74
129
  /**
75
130
  * Best-effort token estimate: uses actual API usage as base if available,
76
131
  * plus estimation for messages added since the last API call.
77
132
  */
78
- estimateTokensHybrid(messages) {
133
+ estimateTokensHybridInfo(messages) {
134
+ const currentEstimate = estimateTokens(messages);
79
135
  if (this.lastActualTokens !== undefined &&
80
- this.lastActualAtMessageCount !== undefined &&
81
- this.lastActualAtMessageCount < messages.length) {
82
- const newMessages = messages.slice(this.lastActualAtMessageCount);
83
- const newTokens = estimateTokens(newMessages);
84
- return this.lastActualTokens + newTokens;
136
+ this.lastActualAtMessageCount !== undefined) {
137
+ if (this.lastActualAtMessageCount < messages.length) {
138
+ const newMessages = messages.slice(this.lastActualAtMessageCount);
139
+ const newTokens = estimateTokens(newMessages);
140
+ return {
141
+ tokens: this.lastActualTokens + newTokens,
142
+ source: "anchor_delta",
143
+ confidence: "medium",
144
+ };
145
+ }
146
+ if (this.lastActualAnchorEstimate !== undefined && this.lastActualAnchorEstimate > 0) {
147
+ return {
148
+ tokens: Math.round(this.lastActualTokens * (currentEstimate / this.lastActualAnchorEstimate)),
149
+ source: "anchor_rescale",
150
+ confidence: "medium",
151
+ };
152
+ }
85
153
  }
86
- return estimateTokens(messages);
154
+ return { tokens: currentEstimate, source: "heuristic_estimate", confidence: "low" };
155
+ }
156
+ estimateTokensHybrid(messages) {
157
+ return this.estimateTokensHybridInfo(messages).tokens;
87
158
  }
88
159
  /**
89
160
  * Set the summarize function (injected by Engine).
@@ -91,6 +162,64 @@ export class ContextManager {
91
162
  setSummarizeFn(fn) {
92
163
  this.summarizeFn = fn;
93
164
  }
165
+ async trySummaryCompact(messages, before, logEvent) {
166
+ if (!this.summarizeFn || this.consecutiveSummaryFailures >= 3) {
167
+ return { messages, tokens: before, compacted: false, noProgress: false };
168
+ }
169
+ try {
170
+ const keepRecentN = Math.max(8, Math.floor(messages.length * 0.3));
171
+ const messagesToSummarize = messages.slice(1, -keepRecentN); // skip first (userContext) and recent
172
+ if (messagesToSummarize.length <= 3) {
173
+ return { messages, tokens: before, compacted: false, noProgress: false };
174
+ }
175
+ // Rolling summary: if a prior summary is already anchored in the
176
+ // messages (from an earlier compaction in this session or in a resumed
177
+ // session), feed it back so the LLM merges-updates rather than
178
+ // re-summarizes from scratch.
179
+ const priorSummary = extractAnchoredSummary(messages) ?? this.lastSummary;
180
+ const prompt = buildSummarizationPrompt(messagesToSummarize, priorSummary);
181
+ const summary = await this.summarizeFn(prompt);
182
+ if (!summary || summary.length <= 50) {
183
+ this.consecutiveSummaryFailures++;
184
+ logger.warn("context.summary_failed", {
185
+ failures: this.consecutiveSummaryFailures,
186
+ error: "summary was empty or too short",
187
+ });
188
+ return { messages, tokens: before, compacted: false, noProgress: true };
189
+ }
190
+ const compacted = applySummaryCompaction(messages, summary, keepRecentN, this.transcriptPath);
191
+ const after = this.estimateTokensHybrid(compacted);
192
+ if (after >= before) {
193
+ this.consecutiveSummaryFailures++;
194
+ logger.warn("context.summary_no_progress", {
195
+ failures: this.consecutiveSummaryFailures,
196
+ before,
197
+ after,
198
+ summaryLen: summary.length,
199
+ rolling: priorSummary !== undefined,
200
+ });
201
+ return { messages, tokens: before, compacted: false, noProgress: true };
202
+ }
203
+ this.consecutiveSummaryFailures = 0;
204
+ this.lastSummary = summary;
205
+ logger.info(logEvent, {
206
+ before,
207
+ after,
208
+ summaryLen: summary.length,
209
+ rolling: priorSummary !== undefined,
210
+ });
211
+ this.onCompact?.({ strategy: "summary", before, after });
212
+ return { messages: compacted, tokens: after, compacted: true, noProgress: false };
213
+ }
214
+ catch (err) {
215
+ this.consecutiveSummaryFailures++;
216
+ logger.warn("context.summary_failed", {
217
+ failures: this.consecutiveSummaryFailures,
218
+ error: err.message,
219
+ });
220
+ return { messages, tokens: before, compacted: false, noProgress: false };
221
+ }
222
+ }
94
223
  /**
95
224
  * Set the transcript path so compaction can reference it. The
96
225
  * tool-results directory lives alongside the transcript file:
@@ -238,9 +367,22 @@ export class ContextManager {
238
367
  result = this.truncateToolResults(result);
239
368
  // Tier 0c: Aggregate tool result budget (per-message)
240
369
  result = applyToolResultBudget(result);
370
+ // Tier 0d: same always-on waste-removal passes as manage().
371
+ const dedup = dedupeFileReads(result);
372
+ if (dedup.clearedCount > 0) {
373
+ result = dedup.messages;
374
+ logger.info("context.dedupe_file_reads", { cleared: dedup.clearedCount });
375
+ }
376
+ const masked = maskOldObservations(result);
377
+ if (masked.maskedCount > 0) {
378
+ result = masked.messages;
379
+ logger.info("context.mask_browser_snapshots", { masked: masked.maskedCount });
380
+ }
241
381
  // Tier 1: microcompact — see manage() for the rationale on the floor.
242
382
  const preTier1Tokens = this.estimateTokensHybrid(result);
243
- if (preTier1Tokens > this.config.maxTokens * this.config.microcompactFloorRatio) {
383
+ const microFloorGate = this.config.maxTokens * this.config.microcompactFloorRatio;
384
+ let microNoOpAtFloor = false;
385
+ if (preTier1Tokens > microFloorGate) {
244
386
  const keepRecentN = this.config.microcompactKeepRecent ?? defaultKeepRecent(this.config.maxTokens);
245
387
  // See manage(): onClear fires synchronously before microcompact returns,
246
388
  // so defer the token re-estimate until result is reassigned.
@@ -251,67 +393,63 @@ export class ContextManager {
251
393
  clearedInfo = info;
252
394
  },
253
395
  });
396
+ const postTier1Tokens = this.estimateTokensHybrid(result);
397
+ microNoOpAtFloor = postTier1Tokens === preTier1Tokens;
398
+ if (postTier1Tokens < preTier1Tokens) {
399
+ this.suppressNoOpMicroSummaryUntilCompact = false;
400
+ }
254
401
  if (clearedInfo) {
255
- const after = this.estimateTokensHybrid(result);
256
402
  logger.info("context.microcompact", {
257
403
  before: preTier1Tokens,
258
- after,
404
+ after: postTier1Tokens,
259
405
  keepRecentN,
260
406
  clearedRounds: clearedInfo.clearedRounds,
261
407
  toolNames: clearedInfo.toolNames,
262
408
  });
263
- this.onCompact?.({ strategy: "micro", before: preTier1Tokens, after });
409
+ this.onCompact?.({
410
+ strategy: "micro",
411
+ before: preTier1Tokens,
412
+ after: postTier1Tokens,
413
+ });
264
414
  }
265
415
  }
266
- const tokens = this.estimateTokensHybrid(result);
416
+ let tokens = this.estimateTokensHybrid(result);
267
417
  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
- }
418
+ if (ratio < this.config.microcompactFloorRatio) {
419
+ this.suppressNoOpMicroSummaryUntilCompact = false;
420
+ }
421
+ const noOpMicroSpinBand = microNoOpAtFloor &&
422
+ ratio >= this.config.microcompactFloorRatio &&
423
+ ratio < this.config.compactAtRatio;
424
+ const shouldEscalateNoOpMicro = noOpMicroSpinBand && !this.suppressNoOpMicroSummaryUntilCompact;
425
+ const snipGate = this.config.maxTokens * this.config.compactAtRatio;
426
+ const windowGate = this.config.maxTokens * (this.config.compactAtRatio + 0.05);
427
+ const emergencyGate = this.config.maxTokens * this.config.summarizeAtRatio;
428
+ // Tier 2: LLM summary if approaching limit, or if micro was the only tier
429
+ // available in the 0.70-0.85 band and it freed nothing.
430
+ if (ratio >= this.config.compactAtRatio || shouldEscalateNoOpMicro) {
431
+ const summarized = await this.trySummaryCompact(result, tokens, "context.summary_compact");
432
+ result = summarized.messages;
433
+ tokens = summarized.tokens;
434
+ if (summarized.compacted) {
435
+ this.suppressNoOpMicroSummaryUntilCompact = false;
436
+ if (tokens <= snipGate)
437
+ return result;
299
438
  }
300
- catch (err) {
301
- this.consecutiveSummaryFailures++;
302
- logger.warn("context.summary_failed", {
303
- failures: this.consecutiveSummaryFailures,
304
- error: err.message,
439
+ if (shouldEscalateNoOpMicro && summarized.noProgress) {
440
+ this.suppressNoOpMicroSummaryUntilCompact = true;
441
+ logger.info("context.micro_noop_summary_suppressed", {
442
+ tokens,
443
+ ratio,
444
+ compactAtRatio: this.config.compactAtRatio,
445
+ microcompactFloorRatio: this.config.microcompactFloorRatio,
305
446
  });
306
447
  }
307
448
  }
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().
449
+ // Reuse the `tokens` we already computed above. We get here if no summary
450
+ // compacted the prompt, or if summary helped but the hybrid estimate is
451
+ // still above the fallback gate.
311
452
  let live = tokens;
312
- const snipGate = this.config.maxTokens * this.config.compactAtRatio;
313
- const windowGate = this.config.maxTokens * (this.config.compactAtRatio + 0.05);
314
- const emergencyGate = this.config.maxTokens * this.config.summarizeAtRatio;
315
453
  // Tier 2 fallback: snip first (cheapest sync option)
316
454
  if (live > snipGate) {
317
455
  const before = live;
@@ -336,6 +474,61 @@ export class ContextManager {
336
474
  }
337
475
  return result;
338
476
  }
477
+ /**
478
+ * Force maximum compaction, ignoring the ratio gates. This is what a manual
479
+ * `/compact` invokes: the user explicitly asked to shrink NOW, so we don't
480
+ * wait for the prompt to reach compactAtRatio.
481
+ *
482
+ * Order: tier-0 cleanups (persist/truncate/dedupe/mask) + microcompact, then
483
+ * an unconditional LLM summary of the older messages. If no summarizeFn is
484
+ * wired (or it fails / yields nothing), fall back to snip → window so the
485
+ * call still shrinks the conversation rather than no-opping.
486
+ *
487
+ * Unlike manage()/manageAsync(), there is NO `ratio >= compactAtRatio` gate:
488
+ * a long-but-under-threshold text-only conversation (the /compact bug) still
489
+ * gets summarized here.
490
+ */
491
+ async forceSummarize(messages) {
492
+ let result = messages;
493
+ // Tier 0: same waste-removal + micro as the automatic path.
494
+ result = this.persistLargeToolResults(result);
495
+ result = this.truncateToolResults(result);
496
+ result = applyToolResultBudget(result);
497
+ const dedup = dedupeFileReads(result);
498
+ if (dedup.clearedCount > 0)
499
+ result = dedup.messages;
500
+ const masked = maskOldObservations(result);
501
+ if (masked.maskedCount > 0)
502
+ result = masked.messages;
503
+ const keepRecentN = this.config.microcompactKeepRecent ?? defaultKeepRecent(this.config.maxTokens);
504
+ result = microcompact(result, { keepRecentN });
505
+ let tokens = this.estimateTokensHybrid(result);
506
+ // Unconditional LLM summary (no ratio gate).
507
+ {
508
+ const summarized = await this.trySummaryCompact(result, tokens, "context.force_summary_compact");
509
+ result = summarized.messages;
510
+ tokens = summarized.tokens;
511
+ if (summarized.compacted)
512
+ return result;
513
+ }
514
+ // Fallback: no summary available — snip, then window, so /compact still
515
+ // shrinks the conversation instead of no-opping.
516
+ {
517
+ const before = tokens;
518
+ result = snipCompact(result, 3, 8);
519
+ tokens = this.estimateTokensHybrid(result);
520
+ if (tokens < before)
521
+ this.onCompact?.({ strategy: "snip", before, after: tokens });
522
+ }
523
+ if (this.estimateTokensHybrid(result) >= this.estimateTokensHybrid(messages)) {
524
+ const before = tokens;
525
+ const keepN = Math.max(10, Math.floor(result.length * 0.4));
526
+ result = windowCompact(result, keepN);
527
+ tokens = this.estimateTokensHybrid(result);
528
+ this.onCompact?.({ strategy: "window", before, after: tokens });
529
+ }
530
+ return result;
531
+ }
339
532
  /**
340
533
  * Persist large tool_result blocks to disk and replace them with a
341
534
  * preview + filepath. No-op when transcript path hasn't been set
@@ -395,13 +588,16 @@ export class ContextManager {
395
588
  * Check if context is approaching limits.
396
589
  */
397
590
  checkLimits(messages) {
398
- const tokens = this.estimateTokensHybrid(messages);
591
+ const estimate = this.estimateTokensHybridInfo(messages);
592
+ const tokens = estimate.tokens;
399
593
  const ratio = tokens / this.config.maxTokens;
400
594
  return {
401
595
  tokens,
402
596
  ratio,
403
597
  needsCompact: ratio >= this.config.compactAtRatio,
404
598
  needsEmergency: ratio >= this.config.summarizeAtRatio,
599
+ promptTokensSource: estimate.source,
600
+ promptTokensConfidence: estimate.confidence,
405
601
  };
406
602
  }
407
603
  }
@@ -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) {