@cjhyy/code-shell-core 0.6.0-rc.7 → 0.6.0-rc.9

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.
@@ -11,7 +11,7 @@ import { applyDynamicToolDef } from "./dynamic-tool-defs.js";
11
11
  import { getMergedCatalog } from "../model-catalog/index.js";
12
12
  import { modelEntriesFromConnections } from "./model-connections-pool.js";
13
13
  import { resolveAuxKey } from "./aux-key.js";
14
- import { foldRunUsage } from "./session-usage.js";
14
+ import { addCumulativeUsage, cumulativeCacheHitRate, foldRunUsage, normalizeCumulativeUsageCounters, } from "./session-usage.js";
15
15
  import { enqueueSteerItem, consumeSteerItems, removeSteerItem, } from "./steer-queue.js";
16
16
  import { resolveSandboxConfig } from "./sandbox-config.js";
17
17
  import { sandboxCacheKey } from "./sandbox-cache-key.js";
@@ -51,9 +51,9 @@ import { resolveAgentPreset, resolveBuiltinToolNames, } from "../preset/index.js
51
51
  import { ModelPool } from "../llm/model-pool.js";
52
52
  import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
53
53
  import { defaultCacheDir } from "../llm/model-cache.js";
54
- import { detectProviderFromApiKey, buildModelPool, } from "../onboarding.js";
54
+ import { detectProviderFromApiKey, buildModelPool } from "../onboarding.js";
55
55
  import { detectPastedNoise } from "../utils/task-sanitizer.js";
56
- import { parseTaskWithImages, } from "./parse-task.js";
56
+ import { parseTaskWithImages } from "./parse-task.js";
57
57
  import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, collectAttachedImagePaths, } from "./image-policy.js";
58
58
  import { tryCompressImages } from "./image-compression.js";
59
59
  import { buildSessionTitle } from "./session-title.js";
@@ -61,7 +61,7 @@ import { capabilitiesFor } from "../llm/capabilities/index.js";
61
61
  import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
62
62
  import { runDreamConsolidation } from "../services/dream-consolidation.js";
63
63
  import { join, isAbsolute } from "node:path";
64
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
64
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
65
65
  /**
66
66
  * Build ScanOptions.compatFileNames from the user's instruction compat toggles.
67
67
  * Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
@@ -440,13 +440,15 @@ export class Engine {
440
440
  // per-turn path can only hide, not add, so a freshly-`on`'d builtin not in
441
441
  // the set needs a session restart to appear.
442
442
  const builtinLists = effectiveBuiltinLists(config.enabledBuiltinTools ?? [], config.disabledBuiltinTools ?? [], this.readBuiltinOverride(config.cwd));
443
- this.toolRegistry = config.runtime?.toolRegistry ?? new ToolRegistry({
444
- builtinTools: resolveBuiltinToolNames({
445
- preset: this.preset.name,
446
- enabledBuiltinTools: builtinLists.enabledBuiltinTools,
447
- disabledBuiltinTools: builtinLists.disabledBuiltinTools,
448
- }),
449
- });
443
+ this.toolRegistry =
444
+ config.runtime?.toolRegistry ??
445
+ new ToolRegistry({
446
+ builtinTools: resolveBuiltinToolNames({
447
+ preset: this.preset.name,
448
+ enabledBuiltinTools: builtinLists.enabledBuiltinTools,
449
+ disabledBuiltinTools: builtinLists.disabledBuiltinTools,
450
+ }),
451
+ });
450
452
  this.hooks = new HookRegistry();
451
453
  // Installed-plugin hooks — declared in each plugin's hooks/hooks.json.
452
454
  // Registered first (priority 80) so user-authored hooks at lower
@@ -637,23 +639,44 @@ export class Engine {
637
639
  * Queue a user message to be spliced into the in-flight run for `sessionId`
638
640
  * at the next turn-loop step boundary — the 不打断 steering path (vs cancel +
639
641
  * resend). General-purpose: any host path (UI 引导, future agent coordination,
640
- * external triggers) can call it. If no run is active for the session the
641
- * message simply waits in the queue and is consumed when that session next
642
- * runs (rare race; host normally only steers while busy). No-op on blank text.
642
+ * external triggers) can call it. If no run is active for this session, reject
643
+ * without queueing so the host can downgrade to a normal run immediately.
644
+ * No-op on blank text.
643
645
  *
644
646
  * `id` is the host's stable queue-entry id. It rides through to the
645
647
  * `steer_injected` event (so the host can match the injected bubble back to
646
648
  * the queued draft) and is the handle `unsteer` uses to revoke a still-pending
647
649
  * entry. A blank id is tolerated but means the entry can't be revoked.
648
650
  */
649
- enqueueSteer(sessionId, text, id = "") {
650
- if (!sessionId)
651
- return;
651
+ enqueueSteer(sessionId, text, id = "", clientMessageId) {
652
652
  const q = this.steerQueueBySid.get(sessionId) ?? [];
653
- const next = enqueueSteerItem(q, id || `steer-${q.length}`, text);
653
+ const entryId = id || `steer-${q.length}`;
654
+ if (!sessionId)
655
+ return { accepted: false, id: entryId };
656
+ const activeRunSessionId = this.activeRunSession?.state.sessionId;
657
+ const active = this.activeTurnLoop !== null && activeRunSessionId === sessionId;
658
+ if (!active) {
659
+ logger.info("steer.enqueue.idle_rejected", {
660
+ sessionId,
661
+ id: entryId,
662
+ clientMessageId,
663
+ activeRunSessionId: activeRunSessionId ?? null,
664
+ queueLength: q.length,
665
+ });
666
+ return { accepted: false, id: entryId };
667
+ }
668
+ const next = enqueueSteerItem(q, entryId, text, clientMessageId);
654
669
  if (next === q)
655
- return; // blank text dropped
670
+ return { accepted: false, id: entryId }; // blank text dropped
656
671
  this.steerQueueBySid.set(sessionId, next);
672
+ logger.info("steer.enqueue.accepted", {
673
+ sessionId,
674
+ id: entryId,
675
+ clientMessageId,
676
+ activeRunSessionId,
677
+ queueLength: next.length,
678
+ });
679
+ return { accepted: true, id: entryId };
657
680
  }
658
681
  /**
659
682
  * Revoke a still-pending steer entry (the 撤回 path). Returns true if it was
@@ -670,12 +693,20 @@ export class Engine {
670
693
  return removed;
671
694
  }
672
695
  /** Drain + clear the steer queue for a session (turn loop consumes per step). */
673
- consumeSteer(sessionId) {
696
+ consumeSteer(sessionId, source = "normal_step") {
674
697
  const q = this.steerQueueBySid.get(sessionId);
675
698
  if (!q || q.length === 0)
676
699
  return [];
677
700
  const { drained, rest } = consumeSteerItems(q);
678
701
  this.steerQueueBySid.set(sessionId, rest);
702
+ logger.info("steer.consume.drained", {
703
+ sessionId,
704
+ source,
705
+ count: drained.length,
706
+ ids: drained.map((item) => item.id),
707
+ clientMessageIds: drained.flatMap((item) => item.clientMessageId ? [item.clientMessageId] : []),
708
+ queueLength: rest.length,
709
+ });
679
710
  return drained;
680
711
  }
681
712
  /** Wire the cookie→browser injection callback (InjectCredential tool). Same
@@ -916,9 +947,8 @@ export class Engine {
916
947
  enabledBuiltinTools: childEnabled,
917
948
  disabledBuiltinTools: childDisabled,
918
949
  customSystemPrompt: this.config.customSystemPrompt,
919
- appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt]
920
- .filter(Boolean)
921
- .join("\n\n") || undefined,
950
+ appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt].filter(Boolean).join("\n\n") ||
951
+ undefined,
922
952
  responseLanguage: this.config.responseLanguage,
923
953
  userProfile: this.config.userProfile,
924
954
  instructions: this.config.instructions,
@@ -1103,6 +1133,23 @@ export class Engine {
1103
1133
  // call) instead of a try/catch on resume.
1104
1134
  let session;
1105
1135
  let messages;
1136
+ let freshImageMessage;
1137
+ const claimedClientMessageIds = new Set();
1138
+ const claimClientMessageId = (bundle, clientMessageId, source) => {
1139
+ if (!clientMessageId)
1140
+ return true;
1141
+ if (claimedClientMessageIds.has(clientMessageId) ||
1142
+ bundle.transcript.hasClientMessageId(clientMessageId)) {
1143
+ logger.info("engine.client_message.duplicate_ignored", {
1144
+ sessionId: bundle.state.sessionId,
1145
+ clientMessageId,
1146
+ source,
1147
+ });
1148
+ return false;
1149
+ }
1150
+ claimedClientMessageIds.add(clientMessageId);
1151
+ return true;
1152
+ };
1106
1153
  if (options?.sessionId && this.sessionManager.exists(options.sessionId)) {
1107
1154
  session = this.sessionManager.resume(options.sessionId);
1108
1155
  const cachedCompacted = this.compactedMessagesBySession.get(options.sessionId);
@@ -1126,8 +1173,31 @@ export class Engine {
1126
1173
  }
1127
1174
  // Append new user message
1128
1175
  const userMsg = { role: "user", content: userMessageContent };
1176
+ if (!claimClientMessageId(session, options?.clientMessageId, "submit")) {
1177
+ const usage = session.state.tokenUsage ?? {
1178
+ promptTokens: 0,
1179
+ completionTokens: 0,
1180
+ totalTokens: 0,
1181
+ };
1182
+ return {
1183
+ text: "",
1184
+ reason: "completed",
1185
+ sessionId: session.state.sessionId,
1186
+ turnCount: session.state.turnCount ?? 0,
1187
+ usage: {
1188
+ promptTokens: usage.promptTokens ?? 0,
1189
+ completionTokens: usage.completionTokens ?? 0,
1190
+ totalTokens: usage.totalTokens ?? 0,
1191
+ },
1192
+ };
1193
+ }
1194
+ if (parsedTask.hasImages)
1195
+ freshImageMessage = userMsg;
1129
1196
  messages.push(userMsg);
1130
- session.transcript.appendMessage("user", userMessageContent, { injected: options?.injected === true });
1197
+ session.transcript.appendMessage("user", userMessageContent, {
1198
+ injected: options?.injected === true,
1199
+ clientMessageId: options?.clientMessageId,
1200
+ });
1131
1201
  // Flush "active" status to disk immediately. resume() set it in memory
1132
1202
  // (session-manager.ts), but without this write the on-disk state.json
1133
1203
  // still shows the previous run's terminal reason — so any external
@@ -1139,13 +1209,20 @@ export class Engine {
1139
1209
  // Cold start: shape (2) reuses the host-supplied sid; shape (3)
1140
1210
  // lets sessionManager generate one with nanoid.
1141
1211
  session = this.sessionManager.create(cwd, this.config.llm.model, this.config.llm.provider, options?.sessionId, this.config.isSubAgent === true ? getCurrentSid() : undefined, this.config.isSubAgent === true ? "subagent" : this.config.origin);
1142
- messages = [{ role: "user", content: userMessageContent }];
1143
- session.transcript.appendMessage("user", userMessageContent);
1212
+ const userMsg = { role: "user", content: userMessageContent };
1213
+ claimClientMessageId(session, options?.clientMessageId, "submit");
1214
+ if (parsedTask.hasImages)
1215
+ freshImageMessage = userMsg;
1216
+ messages = [userMsg];
1217
+ session.transcript.appendMessage("user", userMessageContent, {
1218
+ clientMessageId: options?.clientMessageId,
1219
+ });
1144
1220
  // Save first user message as session summary — text only. The summary
1145
1221
  // shows up in the session list; "[image]" is more informative than a
1146
1222
  // truncated `[object Object]` when the prompt was purely visual.
1147
1223
  const summarySrc = parsedTask.hasImages
1148
- ? parsedTask.text || `[image${parsedTask.images.length > 1 ? `s × ${parsedTask.images.length}` : ""}]`
1224
+ ? parsedTask.text ||
1225
+ `[image${parsedTask.images.length > 1 ? `s × ${parsedTask.images.length}` : ""}]`
1149
1226
  : taskText;
1150
1227
  session.state.summary = summarySrc.slice(0, 80).replace(/\n/g, " ");
1151
1228
  this.sessionManager.saveState(session.state);
@@ -1483,26 +1560,27 @@ export class Engine {
1483
1560
  // run would be evaluated fresh and might get a different replacement
1484
1561
  // string than the one already in the message, breaking idempotency.
1485
1562
  contextManager.initReplacementStateFromMessages(messages);
1486
- // Summarization (context-compaction + tool-result summaries) are auxiliary
1487
- // calls — route them to the configured aux model so they don't burn the
1488
- // expensive primary model every turn (same rationale as runMemoryPipeline).
1489
- // Resolved once here (not per-call) so the magnetic-disk settings re-read
1490
- // in resolveAuxClient stays off the compaction hot path. Falls back to the
1491
- // primary client when no aux model is configured.
1563
+ // Two summarizers with DIFFERENT quality needs:
1564
+ //
1565
+ // 1. Context-compaction summary (setSummarizeFn) PRIMARY model. This
1566
+ // condenses many rounds into the running summary that REPLACES the real
1567
+ // history; a dropped decision makes the conversation "forget" and poisons
1568
+ // every subsequent turn. It fires only near the compact ratio (~0.85), so
1569
+ // it's infrequent — quality far outweighs the occasional extra cost of a
1570
+ // primary-model call. (Manual /compact uses the primary for the same
1571
+ // reason; see forceCompact.)
1572
+ //
1573
+ // 2. Tool-use one-liner summaries (modelFacade.summarize below) → AUX model.
1574
+ // These are tiny throwaway outputs ("Wrote design doc") fired every turn;
1575
+ // that high-frequency, low-stakes chore is exactly what aux is for.
1492
1576
  const auxSummaryClient = await this.resolveAuxClient(llmClient);
1493
- contextManager.setSummarizeFn(async (prompt) => {
1494
- const summaryResponse = await auxSummaryClient.createMessage({
1495
- systemPrompt: "You are a conversation summarizer. Be concise and factual.",
1496
- messages: [{ role: "user", content: prompt }],
1497
- tools: [],
1498
- maxTokens: 1024,
1499
- // Auxiliary call — no need to burn reasoning tokens. On DeepSeek V4
1500
- // this flips thinking off (~3x faster, fewer tokens); on every other
1501
- // OpenAI-compatible provider the field is ignored.
1502
- reasoning: { mode: "off" },
1503
- });
1504
- return summaryResponse.text;
1505
- });
1577
+ Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
1578
+ const recordCumulativeUsage = (usage) => {
1579
+ const next = addCumulativeUsage(session.state, usage);
1580
+ Object.assign(session.state, next);
1581
+ return next;
1582
+ };
1583
+ contextManager.setSummarizeFn(this.buildSummarizeFn(llmClient, recordCumulativeUsage));
1506
1584
  // Create components (requires resolved llmClient).
1507
1585
  const modelFacade = new ModelFacade(llmClient, session.transcript);
1508
1586
  // Session-cumulative usage baseline: the LLM client is recreated per run
@@ -1676,7 +1754,9 @@ export class Engine {
1676
1754
  pendingCompactInfo = null;
1677
1755
  return info;
1678
1756
  },
1679
- consumeSteer: () => this.consumeSteer(sid),
1757
+ consumeSteer: (source) => this.consumeSteer(sid, source),
1758
+ claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
1759
+ recordCumulativeUsage,
1680
1760
  // Clear the persisted goal for a self-reported completion / confirmed
1681
1761
  // cancel. Clears the in-RAM session's activeGoal (so THIS run's later
1682
1762
  // turns don't re-arm) AND persists it, and drops the in-flight stop
@@ -1715,6 +1795,7 @@ export class Engine {
1715
1795
  maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 25,
1716
1796
  onStream: options?.onStream,
1717
1797
  signal: options?.signal,
1798
+ freshImageMessages: freshImageMessage ? [freshImageMessage] : undefined,
1718
1799
  // Goal mode: the active goal is surfaced to the on_stop handler via
1719
1800
  // ctx.data.goal; the GoalStopHook (registered above) judges it.
1720
1801
  goal: normalizedGoal,
@@ -1727,16 +1808,23 @@ export class Engine {
1727
1808
  // baseline + this run's running total (idempotent per boundary,
1728
1809
  // accumulates across runs; carries cacheRead/cacheCreation too).
1729
1810
  session.state.tokenUsage = foldRunUsage(usageBaseline, modelFacade.getUsage());
1730
- // Surface the session-cumulative cache counts to the UI (the "本会话
1731
- // 累计命中率" tooltip). Separate from turn-loop's per-response
1732
- // usage_update (which drives the live context reading).
1733
- const cum = session.state.tokenUsage;
1811
+ // Surface the whole-session monotonic cache counts to the UI.
1812
+ // Separate from turn-loop's authoritative per-response emit (which
1813
+ // drives the live context reading and single-turn metric).
1814
+ const cumulative = normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage);
1815
+ const cumulativeHitRate = cumulativeCacheHitRate(cumulative);
1734
1816
  options?.onStream?.({
1735
1817
  type: "usage_update",
1736
- promptTokens: cum.promptTokens,
1737
- sessionPromptTokens: cum.promptTokens,
1738
- sessionCacheReadTokens: cum.cacheReadTokens ?? 0,
1739
- sessionCacheCreationTokens: cum.cacheCreationTokens ?? 0,
1818
+ promptTokens: cumulative.cumulativePromptTokens,
1819
+ cumulativePromptTokens: cumulative.cumulativePromptTokens,
1820
+ cumulativeCacheReadTokens: cumulative.cumulativeCacheReadTokens,
1821
+ cumulativeCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
1822
+ ...(cumulativeHitRate !== undefined
1823
+ ? { cumulativeCacheHitRate: cumulativeHitRate }
1824
+ : {}),
1825
+ sessionPromptTokens: cumulative.cumulativePromptTokens,
1826
+ sessionCacheReadTokens: cumulative.cumulativeCacheReadTokens,
1827
+ sessionCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
1740
1828
  });
1741
1829
  if (this.config.costStore) {
1742
1830
  session.state.costState = this.config.costStore.serialize();
@@ -1940,6 +2028,28 @@ export class Engine {
1940
2028
  * active run's client) when unset, unknown, or on any build failure — aux
1941
2029
  * work is best-effort and must never break a run.
1942
2030
  */
2031
+ /**
2032
+ * Build the SummarizeFn used for context compaction. Extracted so both the
2033
+ * run path and forceCompact share one definition of the summarization call.
2034
+ */
2035
+ buildSummarizeFn(auxSummaryClient, recordCumulativeUsage) {
2036
+ return async (prompt) => {
2037
+ const summaryResponse = await auxSummaryClient.createMessage({
2038
+ systemPrompt: "You are a conversation summarizer. Be concise and factual.",
2039
+ messages: [{ role: "user", content: prompt }],
2040
+ tools: [],
2041
+ maxTokens: 1024,
2042
+ // Auxiliary call — no need to burn reasoning tokens. On DeepSeek V4
2043
+ // this flips thinking off (~3x faster, fewer tokens); on every other
2044
+ // OpenAI-compatible provider the field is ignored.
2045
+ reasoning: { mode: "off" },
2046
+ });
2047
+ if (summaryResponse.usage) {
2048
+ recordCumulativeUsage?.(summaryResponse.usage);
2049
+ }
2050
+ return summaryResponse.text;
2051
+ };
2052
+ }
1943
2053
  async resolveAuxClient(fallback) {
1944
2054
  let auxKey;
1945
2055
  try {
@@ -2005,7 +2115,9 @@ export class Engine {
2005
2115
  // extraction, which then padded the memory store with low-signal
2006
2116
  // entries. 8 messages is roughly "more than a single back-and-forth"
2007
2117
  // — substantive enough to be worth a durable note.
2008
- const messages = transcript.toMessages().filter((m) => m.role === "user" || m.role === "assistant");
2118
+ const messages = transcript
2119
+ .toMessages()
2120
+ .filter((m) => m.role === "user" || m.role === "assistant");
2009
2121
  if (messages.length < 8)
2010
2122
  return;
2011
2123
  // Memory orchestrator + dream-loop calls are auxiliary LLM calls
@@ -2107,10 +2219,8 @@ export class Engine {
2107
2219
  return entry;
2108
2220
  }
2109
2221
  /**
2110
- * Zero a session's cumulative token/cache usage on disk. Called on a model
2111
- * switch: a different model has its own prompt cache, so the accumulated
2112
- * cache-hit stats from the prior model are no longer meaningful. The next
2113
- * run's baseline (snapshotted from state.tokenUsage) then starts from zero.
2222
+ * Zero the legacy/model-scoped token/cache usage window on disk. The
2223
+ * whole-session cumulative counters are intentionally left alone.
2114
2224
  */
2115
2225
  resetSessionUsage(sessionId) {
2116
2226
  const zero = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
@@ -2182,7 +2292,9 @@ export class Engine {
2182
2292
  try {
2183
2293
  chmodSync(file, 0o600);
2184
2294
  }
2185
- catch { /* best-effort */ }
2295
+ catch {
2296
+ /* best-effort */
2297
+ }
2186
2298
  }
2187
2299
  catch (err) {
2188
2300
  logger.warn(`persistActiveModel failed: ${err.message}`);
@@ -2254,8 +2366,14 @@ export class Engine {
2254
2366
  // The builtin tool SET is ctor-frozen and may be shared via runtime — we
2255
2367
  // do NOT rebuild it here. If the new preset implies a different builtin
2256
2368
  // tool set, that part of the change only lands on session restart.
2257
- const prevTools = resolveBuiltinToolNames({ preset: prevPresetName }).slice().sort().join(",");
2258
- const nextTools = resolveBuiltinToolNames({ preset: nextPreset.name }).slice().sort().join(",");
2369
+ const prevTools = resolveBuiltinToolNames({ preset: prevPresetName })
2370
+ .slice()
2371
+ .sort()
2372
+ .join(",");
2373
+ const nextTools = resolveBuiltinToolNames({ preset: nextPreset.name })
2374
+ .slice()
2375
+ .sort()
2376
+ .join(",");
2259
2377
  if (prevTools !== nextTools) {
2260
2378
  logger.warn("engine.preset_reload.tool_set_change_needs_restart", {
2261
2379
  from: prevPresetName,
@@ -2351,25 +2469,74 @@ export class Engine {
2351
2469
  }
2352
2470
  }
2353
2471
  /**
2354
- * Force context compaction on the current session.
2472
+ * Force context compaction on a session.
2355
2473
  * Returns token stats before/after.
2356
2474
  */
2357
- forceCompact() {
2358
- const sessionId = this.lastSessionId;
2359
- if (!this.lastContextManager || !sessionId) {
2475
+ async forceCompact(sessionId) {
2476
+ const effectiveSessionId = sessionId ?? this.lastSessionId;
2477
+ if (!effectiveSessionId) {
2360
2478
  return { before: 0, after: 0, strategy: "none (no active session)" };
2361
2479
  }
2362
- const sourceMessages = this.compactedMessagesBySession.get(sessionId) ??
2363
- this.sessionManager.resume(sessionId).transcript.toMessages();
2480
+ const session = this.sessionManager.resume(effectiveSessionId);
2481
+ const sourceMessages = this.compactedMessagesBySession.get(effectiveSessionId) ?? session.transcript.toMessages();
2364
2482
  const before = estimateTokens(sourceMessages);
2365
- const compacted = this.lastContextManager.manage(sourceMessages);
2483
+ let contextManager = this.lastContextManager;
2484
+ if (!contextManager || this.lastSessionId !== effectiveSessionId) {
2485
+ contextManager = new ContextManager({
2486
+ maxTokens: this.resolveMaxContextTokens(),
2487
+ ...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
2488
+ });
2489
+ contextManager.setTranscriptPath(session.transcript.getFilePath());
2490
+ contextManager.initReplacementStateFromMessages(sourceMessages);
2491
+ this.lastContextManager = contextManager;
2492
+ }
2493
+ // Manual /compact emits its UI boundary at the protocol layer from the
2494
+ // final before/after result. Capture the tier here, but avoid reusing a
2495
+ // stale run callback retained on lastContextManager, which could otherwise
2496
+ // double-emit.
2497
+ let compactStrategy;
2498
+ contextManager.setOnCompact((info) => {
2499
+ if (info.after < info.before)
2500
+ compactStrategy = info.strategy;
2501
+ });
2502
+ // Manual /compact = maximum compaction NOW. The automatic ladder waits for
2503
+ // compactAtRatio (0.85 * window), so on a 1M-window model an 800k text-only
2504
+ // conversation sits under the gate and manage() only runs a no-op micro.
2505
+ // Wire a summarizeFn (the run path does this per-run; a cold forceCompact on
2506
+ // a resumed-but-never-run session has none) and call forceSummarize, which
2507
+ // ignores the ratio gate and always summarizes (falling back to snip/window).
2508
+ //
2509
+ // Use the PRIMARY model, not the aux model. Automatic background compaction
2510
+ // routes to aux to keep the high-frequency path cheap, but summarization is
2511
+ // a high-fidelity task (drop a decision and the conversation "forgets"), and
2512
+ // a manual /compact is a low-frequency, user-initiated request for quality.
2513
+ // The aux model is sized for tiny outputs (titles, memory extraction), so
2514
+ // downgrading the one compaction the user explicitly asked for is backwards.
2515
+ try {
2516
+ const primaryClient = await createLLMClient(this.config.llm, this.config.clientDefaults);
2517
+ Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
2518
+ const recordCompactUsage = (usage) => {
2519
+ const next = addCumulativeUsage(session.state, usage);
2520
+ Object.assign(session.state, next);
2521
+ this.sessionManager.saveState(session.state);
2522
+ return next;
2523
+ };
2524
+ contextManager.setSummarizeFn(this.buildSummarizeFn(primaryClient, recordCompactUsage));
2525
+ }
2526
+ catch (err) {
2527
+ logger.warn("engine.force_compact_client_failed", {
2528
+ error: err.message,
2529
+ });
2530
+ }
2531
+ const compacted = await contextManager.forceSummarize(sourceMessages);
2366
2532
  const after = estimateTokens(compacted);
2367
- this.compactedMessagesBySession.set(sessionId, compacted);
2533
+ this.compactedMessagesBySession.set(effectiveSessionId, compacted);
2534
+ this.lastSessionId = effectiveSessionId;
2368
2535
  this.lastMessages = compacted;
2369
2536
  return {
2370
2537
  before,
2371
2538
  after,
2372
- strategy: before === after ? "no compaction needed" : "compacted",
2539
+ strategy: after >= before ? "no compaction needed" : (compactStrategy ?? "compacted"),
2373
2540
  };
2374
2541
  }
2375
2542
  stripUserContextMessage(messages, userContextMsg) {
@@ -2463,7 +2630,11 @@ export class Engine {
2463
2630
  backend = interactive;
2464
2631
  }
2465
2632
  else {
2466
- backend = new HeadlessApprovalBackend(mode === "bypassPermissions" ? "approve-all" : mode === "dontAsk" ? "deny-all" : "deny-all");
2633
+ backend = new HeadlessApprovalBackend(mode === "bypassPermissions"
2634
+ ? "approve-all"
2635
+ : mode === "dontAsk"
2636
+ ? "deny-all"
2637
+ : "deny-all");
2467
2638
  }
2468
2639
  }
2469
2640
  return { rules, backend };
@@ -2505,7 +2676,8 @@ export class Engine {
2505
2676
  * rule set buildPermissionConfig does, without constructing a backend.
2506
2677
  */
2507
2678
  getPermissionRules() {
2508
- return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd()).rules;
2679
+ return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd())
2680
+ .rules;
2509
2681
  }
2510
2682
  /**
2511
2683
  * Toggle plan mode directly. Called by the Plan tool (Task 7) via ToolContext.engine.
@@ -2592,12 +2764,8 @@ export class Engine {
2592
2764
  getAgentDefinitions(cwd) {
2593
2765
  const disabledAgents = this.readDisabledAgents(cwd);
2594
2766
  const disabledPlugins = this.readDisabledLists().disabledPlugins;
2595
- const disabledKey = [...disabledAgents, "::", ...disabledPlugins]
2596
- .slice()
2597
- .sort()
2598
- .join(" ");
2599
- if (this.agentDefsCache?.cwd !== cwd ||
2600
- this.agentDefsCache.disabledKey !== disabledKey) {
2767
+ const disabledKey = [...disabledAgents, "::", ...disabledPlugins].slice().sort().join(" ");
2768
+ if (this.agentDefsCache?.cwd !== cwd || this.agentDefsCache.disabledKey !== disabledKey) {
2601
2769
  this.agentDefsCache = {
2602
2770
  cwd,
2603
2771
  disabledKey,
@@ -5,6 +5,7 @@ import { logger, getCurrentSid } from "../logging/logger.js";
5
5
  import { recordLLMError, recordLLMRequest, recordLLMResponse, } from "../logging/session-recorder.js";
6
6
  import { sanitizeMessages } from "../logging/sanitize-messages.js";
7
7
  import { addAPIDuration, addToModelUsage, addInputTokens, addOutputTokens } from "../state.js";
8
+ import { cacheHitRateFromUsage } from "./session-usage.js";
8
9
  let _reqSeq = 0;
9
10
  function nextReqId() {
10
11
  _reqSeq += 1;
@@ -20,18 +21,7 @@ function nextReqId() {
20
21
  * any caching tuning (docs/todo/prompt-cache-optimization.md §四 step 1).
21
22
  */
22
23
  export function cacheHitRate(usage) {
23
- if (!usage)
24
- return undefined;
25
- const read = usage.cacheReadTokens ?? 0;
26
- const creation = usage.cacheCreationTokens ?? 0;
27
- if (read === 0 && creation === 0)
28
- return undefined;
29
- const prompt = usage.promptTokens ?? 0;
30
- const uncached = Math.max(0, prompt - read - creation);
31
- const denom = read + creation + uncached;
32
- if (denom === 0)
33
- return undefined;
34
- return read / denom;
24
+ return cacheHitRateFromUsage(usage);
35
25
  }
36
26
  export class ModelFacade {
37
27
  client;
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { TurnLoop } from "./turn-loop.js";
11
11
  import { logger } from "../logging/logger.js";
12
+ import { messageHasBase64ImagePayload } from "../context/compaction.js";
12
13
  // ─── Query generator ────────────────────────────────────────────────
13
14
  /**
14
15
  * Run an agentic query and yield stream events as they occur.
@@ -37,6 +38,7 @@ export async function* query(params) {
37
38
  maxToolCallsPerTurn,
38
39
  onStream,
39
40
  signal,
41
+ freshImageMessages: messages.filter(messageHasBase64ImagePayload),
40
42
  };
41
43
  // Local overhead store — query() is a standalone entry point without a real
42
44
  // session id, so per-call in-memory state is enough.
@@ -1,5 +1,17 @@
1
1
  import type { TokenUsage } from "../types.js";
2
2
  import type { LLMUsageTracker } from "../llm/types.js";
3
+ export interface CumulativeUsageCounters {
4
+ cumulativePromptTokens: number;
5
+ cumulativeCacheReadTokens: number;
6
+ cumulativeCacheCreationTokens: number;
7
+ }
8
+ export declare function emptyCumulativeUsageCounters(): CumulativeUsageCounters;
9
+ export declare function normalizeCumulativeUsageCounters(counters: Partial<CumulativeUsageCounters> | undefined, legacyUsage?: TokenUsage): CumulativeUsageCounters;
10
+ export declare function addCumulativeUsage(counters: Partial<CumulativeUsageCounters> | undefined, usage: TokenUsage): CumulativeUsageCounters;
11
+ export declare function addTokenUsage(left: TokenUsage, right: TokenUsage): TokenUsage;
12
+ export declare function cacheHitRateFromTokens(promptTokens: number, cacheReadTokens: number | undefined, cacheCreationTokens: number | undefined): number | undefined;
13
+ export declare function cacheHitRateFromUsage(usage: TokenUsage | undefined): number | undefined;
14
+ export declare function cumulativeCacheHitRate(counters: CumulativeUsageCounters): number | undefined;
3
15
  /**
4
16
  * Fold one run's cumulative usage onto a session baseline, producing the new
5
17
  * session-cumulative TokenUsage.
@@ -1,3 +1,59 @@
1
+ export function emptyCumulativeUsageCounters() {
2
+ return {
3
+ cumulativePromptTokens: 0,
4
+ cumulativeCacheReadTokens: 0,
5
+ cumulativeCacheCreationTokens: 0,
6
+ };
7
+ }
8
+ export function normalizeCumulativeUsageCounters(counters, legacyUsage) {
9
+ return {
10
+ cumulativePromptTokens: typeof counters?.cumulativePromptTokens === "number"
11
+ ? counters.cumulativePromptTokens
12
+ : (legacyUsage?.promptTokens ?? 0),
13
+ cumulativeCacheReadTokens: typeof counters?.cumulativeCacheReadTokens === "number"
14
+ ? counters.cumulativeCacheReadTokens
15
+ : (legacyUsage?.cacheReadTokens ?? 0),
16
+ cumulativeCacheCreationTokens: typeof counters?.cumulativeCacheCreationTokens === "number"
17
+ ? counters.cumulativeCacheCreationTokens
18
+ : (legacyUsage?.cacheCreationTokens ?? 0),
19
+ };
20
+ }
21
+ export function addCumulativeUsage(counters, usage) {
22
+ const current = normalizeCumulativeUsageCounters(counters);
23
+ return {
24
+ cumulativePromptTokens: current.cumulativePromptTokens + (usage.promptTokens ?? 0),
25
+ cumulativeCacheReadTokens: current.cumulativeCacheReadTokens + (usage.cacheReadTokens ?? 0),
26
+ cumulativeCacheCreationTokens: current.cumulativeCacheCreationTokens + (usage.cacheCreationTokens ?? 0),
27
+ };
28
+ }
29
+ export function addTokenUsage(left, right) {
30
+ return {
31
+ promptTokens: left.promptTokens + (right.promptTokens ?? 0),
32
+ completionTokens: left.completionTokens + (right.completionTokens ?? 0),
33
+ totalTokens: left.totalTokens + (right.totalTokens ?? 0),
34
+ cacheReadTokens: (left.cacheReadTokens ?? 0) + (right.cacheReadTokens ?? 0),
35
+ cacheCreationTokens: (left.cacheCreationTokens ?? 0) + (right.cacheCreationTokens ?? 0),
36
+ };
37
+ }
38
+ export function cacheHitRateFromTokens(promptTokens, cacheReadTokens, cacheCreationTokens) {
39
+ const read = cacheReadTokens ?? 0;
40
+ const creation = cacheCreationTokens ?? 0;
41
+ if (read === 0 && creation === 0)
42
+ return undefined;
43
+ const uncached = Math.max(0, promptTokens - read - creation);
44
+ const denom = read + creation + uncached;
45
+ if (denom === 0)
46
+ return undefined;
47
+ return read / denom;
48
+ }
49
+ export function cacheHitRateFromUsage(usage) {
50
+ if (!usage)
51
+ return undefined;
52
+ return cacheHitRateFromTokens(usage.promptTokens ?? 0, usage.cacheReadTokens, usage.cacheCreationTokens);
53
+ }
54
+ export function cumulativeCacheHitRate(counters) {
55
+ return cacheHitRateFromTokens(counters.cumulativePromptTokens, counters.cumulativeCacheReadTokens, counters.cumulativeCacheCreationTokens);
56
+ }
1
57
  /**
2
58
  * Fold one run's cumulative usage onto a session baseline, producing the new
3
59
  * session-cumulative TokenUsage.