mixdog 1.4.0 → 1.4.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 (236) hide show
  1. package/package.json +1 -1
  2. package/scripts/check-voice-runtime-inputs.mjs +25 -0
  3. package/scripts/gate-local.mjs +12 -3
  4. package/scripts/latest-guard.mjs +21 -9
  5. package/scripts/lib/isolated-test-env.mjs +10 -5
  6. package/scripts/lib/run-node-tests.mjs +37 -37
  7. package/scripts/lib/scoped-temp.d.mts +16 -0
  8. package/scripts/lib/scoped-temp.mjs +123 -0
  9. package/scripts/lib/stale-temp.mjs +44 -0
  10. package/scripts/post-release-check.mjs +1 -6
  11. package/scripts/pptx-kit-index.mjs +17 -4
  12. package/scripts/suite-health.mjs +10 -1
  13. package/scripts/test-data-dir.mjs +10 -7
  14. package/src/app.mjs +4 -0
  15. package/src/defaults/shipped-history.json +5 -0
  16. package/src/defaults/skills/browser-use/SKILL.md +6 -4
  17. package/src/defaults/skills/computer-use/SKILL.md +4 -2
  18. package/src/lib/rules-builder.cjs +29 -12
  19. package/src/output-styles/common.md +2 -2
  20. package/src/output-styles/simple.md +1 -1
  21. package/src/rules/agent/AGENT.md +5 -4
  22. package/src/rules/lead/LEAD.md +10 -5
  23. package/src/rules/shared/10-tool-workflow.md +3 -1
  24. package/src/rules/shared/45-destructive.md +0 -2
  25. package/src/rules/shared/50-execution.md +2 -0
  26. package/src/rules/shared/70-delivery.md +7 -9
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -6
  28. package/src/runtime/agent/orchestrator/cache-break-trace.mjs +2 -0
  29. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +3 -1
  31. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +5 -2
  32. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +11 -0
  33. package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +1 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +7 -3
  35. package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +8 -2
  36. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +6 -2
  37. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +2 -2
  38. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +3 -1
  39. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +14 -3
  40. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +6 -0
  41. package/src/runtime/agent/orchestrator/providers/anthropic-turn-result.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/providers/command-code-oauth-login.mjs +1 -10
  43. package/src/runtime/agent/orchestrator/providers/cursor.mjs +3 -0
  44. package/src/runtime/agent/orchestrator/providers/devin-wire-transport.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/providers/devin.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +210 -20
  47. package/src/runtime/agent/orchestrator/providers/lib/anthropic-prefix-trace.mjs +76 -11
  48. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +45 -6
  49. package/src/runtime/agent/orchestrator/providers/lib/first-output-window.mjs +58 -0
  50. package/src/runtime/agent/orchestrator/providers/lib/listen-on.mjs +14 -0
  51. package/src/runtime/agent/orchestrator/providers/mimo-oauth-login.mjs +1 -10
  52. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +1 -0
  53. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/chunk-events.mjs +9 -2
  54. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +7 -0
  55. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +14 -3
  56. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +4 -2
  57. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +1 -0
  58. package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +5 -3
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +16 -3
  60. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +1 -0
  61. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/output-items.mjs +5 -2
  62. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events.mjs +13 -3
  63. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +13 -2
  64. package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +81 -42
  65. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +8 -0
  66. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +136 -5
  67. package/src/runtime/agent/orchestrator/providers/openai-ws-stream/event-switch.mjs +20 -12
  68. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +7 -1
  69. package/src/runtime/agent/orchestrator/providers/openai-ws-watchdogs.mjs +48 -22
  70. package/src/runtime/agent/orchestrator/providers/registry.mjs +12 -0
  71. package/src/runtime/agent/orchestrator/runtime-core/ask-user-tool-defs.mjs +41 -13
  72. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +7 -6
  73. package/src/runtime/agent/orchestrator/runtime-core/session-text.mjs +7 -3
  74. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +3 -1
  75. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +5 -2
  76. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +1 -3
  77. package/src/runtime/agent/orchestrator/session/cache-warm.mjs +8 -2
  78. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +4 -1
  79. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +1 -8
  80. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +23 -0
  81. package/src/runtime/agent/orchestrator/session/compact/observation-supersession.mjs +103 -23
  82. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +160 -36
  83. package/src/runtime/agent/orchestrator/session/context-utils.mjs +13 -1
  84. package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +163 -22
  85. package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +132 -17
  86. package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +38 -7
  87. package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +3 -0
  88. package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +90 -22
  89. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +33 -3
  90. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +7 -2
  91. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -1
  92. package/src/runtime/agent/orchestrator/session/loop/provider-tool-exclusions.mjs +13 -5
  93. package/src/runtime/agent/orchestrator/session/loop/route-fallback.mjs +14 -0
  94. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +13 -1
  95. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +2 -2
  96. package/src/runtime/agent/orchestrator/session/loop/tool-exposure-migration.mjs +39 -21
  97. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +3 -3
  98. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +3 -1
  99. package/src/runtime/agent/orchestrator/session/manager/owner-route-fallbacks.mjs +2 -2
  100. package/src/runtime/agent/orchestrator/session/manager/route-fallback-chain.mjs +2 -1
  101. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +27 -9
  102. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +61 -6
  103. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +19 -8
  104. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +12 -5
  105. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +3 -3
  106. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-context.mjs +4 -0
  107. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +3 -0
  108. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +9 -0
  109. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +5 -1
  110. package/src/runtime/agent/orchestrator/session/prompt-cache-touch-store.mjs +30 -7
  111. package/src/runtime/agent/orchestrator/session/restart-prefix-parity.fixture.mjs +154 -0
  112. package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +7 -0
  113. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +4 -0
  114. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +15 -0
  115. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +37 -8
  116. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +9 -1
  117. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +61 -2
  118. package/src/runtime/agent/orchestrator/stall-policy.mjs +23 -0
  119. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +2 -2
  120. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/spawn-env.mjs +4 -2
  121. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +6 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +46 -20
  123. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +7 -1
  124. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +31 -5
  125. package/src/runtime/agent/orchestrator/tools/builtin/git-worktree-tool.mjs +22 -8
  126. package/src/runtime/agent/orchestrator/tools/builtin/lib/scoped-search-flight.mjs +9 -5
  127. package/src/runtime/agent/orchestrator/tools/builtin/read-single-stream.mjs +7 -1
  128. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +6 -3
  129. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +1 -1
  130. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-publication.mjs +23 -12
  131. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-snapshot.mjs +6 -1
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -8
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/federation.mjs +3 -1
  134. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  135. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
  136. package/src/runtime/agent/orchestrator/tools/lib/isolated-test-env.mjs +4 -3
  137. package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +26 -4
  138. package/src/runtime/channels/lib/webhook/signature.mjs +4 -1
  139. package/src/runtime/channels/lib/whisper-port.mjs +10 -1
  140. package/src/runtime/computer-bridge/action-schema.mjs +2 -2
  141. package/src/runtime/computer-bridge/core-actions.mjs +2 -2
  142. package/src/runtime/computer-bridge/error-recovery.mjs +5 -4
  143. package/src/runtime/computer-bridge/linux-action-capabilities.mjs +24 -5
  144. package/src/runtime/computer-bridge/linux-readiness.d.mts +7 -2
  145. package/src/runtime/local-provider/server-process.mjs +2 -1
  146. package/src/runtime/media/adapters/gemini-video.mjs +4 -1
  147. package/src/runtime/media/adapters/xai-media.mjs +2 -1
  148. package/src/runtime/memory/lib/embedding-worker.mjs +6 -3
  149. package/src/runtime/memory/lib/memory-action-handlers/cycle-backfill-action.mjs +1 -7
  150. package/src/runtime/memory/lib/memory-chunk-quality.mjs +1 -6
  151. package/src/runtime/office/authoring/pdf-author-action.mjs +1 -6
  152. package/src/runtime/office/design/design-art-direction.mjs +29 -27
  153. package/src/runtime/office/pdf/pdf-writer.mjs +6 -1
  154. package/src/runtime/office/portable/portable-snapshot-docx.mjs +7 -1
  155. package/src/runtime/office/portable/portable-snapshot-shared.mjs +6 -1
  156. package/src/runtime/office/quality/assurance-rendered.mjs +9 -2
  157. package/src/runtime/office/quality/assurance-structure-docx.mjs +3 -1
  158. package/src/runtime/office/quality/design-review.mjs +1 -5
  159. package/src/runtime/office/quality/document-brief.mjs +1 -5
  160. package/src/runtime/office/quality/forced-break-pages.mjs +9 -6
  161. package/src/runtime/office/quality/quality-pipeline.mjs +1 -1
  162. package/src/runtime/shared/bot-owned-work.mjs +56 -0
  163. package/src/runtime/shared/defaults-separation.mjs +1 -4
  164. package/src/runtime/shared/fetch-safe-listen.d.mts +16 -0
  165. package/src/runtime/shared/fetch-safe-listen.mjs +50 -0
  166. package/src/runtime/shared/handoff-artifacts.d.mts +2 -1
  167. package/src/runtime/shared/handoff-artifacts.mjs +18 -8
  168. package/src/runtime/shared/idle-auto-clear-gate.mjs +29 -7
  169. package/src/runtime/shared/llm/cost.mjs +5 -1
  170. package/src/runtime/shared/loopback-listener.mjs +25 -2
  171. package/src/runtime/shared/model-route-chain.d.mts +7 -2
  172. package/src/runtime/shared/model-route-chain.mjs +22 -4
  173. package/src/runtime/shared/open-url.mjs +10 -0
  174. package/src/runtime/shared/session-activity-signal.mjs +14 -0
  175. package/src/runtime/shared/session-scratch.mjs +37 -0
  176. package/src/runtime/shared/tool-card-model/agent-surface.mjs +1 -3
  177. package/src/runtime/shared/tool-card-model/card-status.mjs +15 -2
  178. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  179. package/src/runtime/shared/tool-surface.mjs +3 -0
  180. package/src/runtime/shared/tool-work-units.mjs +2 -0
  181. package/src/runtime/shared/turn-snapshot-store.mjs +13 -6
  182. package/src/runtime/shared/worktrees/contract.mjs +4 -1
  183. package/src/runtime/shared/worktrees/git.mjs +15 -0
  184. package/src/runtime/shared/worktrees/manager.mjs +87 -38
  185. package/src/runtime/shared/worktrees/test-support.mjs +6 -10
  186. package/src/runtime/visualize-bridge/client.mjs +2 -0
  187. package/src/session-runtime/agent-home-delivery.mjs +41 -0
  188. package/src/session-runtime/agent-task-sessions.mjs +152 -63
  189. package/src/session-runtime/agent-tool-routing.mjs +8 -41
  190. package/src/session-runtime/boot/tools.mjs +5 -1
  191. package/src/session-runtime/bot-status.mjs +12 -6
  192. package/src/session-runtime/bot-target.mjs +38 -0
  193. package/src/session-runtime/channel-config-api.mjs +6 -1
  194. package/src/session-runtime/conversation-import/codex-titles.mjs +88 -0
  195. package/src/session-runtime/conversation-import/discovery.mjs +271 -25
  196. package/src/session-runtime/conversation-import/service.mjs +26 -5
  197. package/src/session-runtime/handoff-attachments.mjs +82 -31
  198. package/src/session-runtime/internal-tool-executor/ask-user-tool.mjs +12 -13
  199. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +1 -0
  200. package/src/session-runtime/internal-tool-executor/reaction-tool.mjs +4 -1
  201. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +8 -2
  202. package/src/session-runtime/lifecycle/cold-heir.mjs +11 -6
  203. package/src/session-runtime/lifecycle/session-catalog.mjs +9 -1
  204. package/src/session-runtime/model-route/set-route.mjs +11 -2
  205. package/src/session-runtime/provider-catalog-cache.mjs +82 -36
  206. package/src/session-runtime/provider-models/catalog-load.mjs +23 -6
  207. package/src/session-runtime/provider-models.mjs +4 -2
  208. package/src/session-runtime/resource-recall-api.mjs +6 -1
  209. package/src/session-runtime/runtime-feature-gates.mjs +5 -1
  210. package/src/session-runtime/services/agent-tool/execute.mjs +14 -9
  211. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +20 -4
  212. package/src/session-runtime/services/agent-tool/model-select.mjs +48 -7
  213. package/src/session-runtime/services/agent-tool/render.mjs +71 -17
  214. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +6 -2
  215. package/src/session-runtime/services/agent-tool/tool-def.mjs +9 -9
  216. package/src/session-runtime/services/provider-admin.mjs +2 -1
  217. package/src/session-runtime/tool-policy/policy-stage.mjs +1 -0
  218. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +5 -1
  219. package/src/session-runtime/workflow-agents-api/agent-route.mjs +15 -4
  220. package/src/session-runtime/workflow-agents-api/onboarding.mjs +3 -3
  221. package/src/session-runtime/workspace/transitions.mjs +25 -0
  222. package/src/session-runtime/workspace/worktree-tool.mjs +26 -9
  223. package/src/standalone/daemon-session-control.mjs +18 -2
  224. package/src/standalone/session-service/agent-tree/agent-turns.mjs +16 -12
  225. package/src/standalone/session-service/session-calls/session-action.mjs +10 -3
  226. package/src/standalone/session-transport/client-registry/client-grace.mjs +12 -9
  227. package/src/tui/app/app-view/floating-panel.jsx +7 -4
  228. package/src/tui/app/model-picker/route-selection/fast-parameters.mjs +1 -3
  229. package/src/tui/app/model-picker/route-selection.mjs +5 -6
  230. package/src/tui/app/provider-setup/oauth-actions.mjs +38 -20
  231. package/src/tui/app/provider-setup/provider-items.mjs +8 -2
  232. package/src/tui/app/provider-setup-picker.mjs +9 -5
  233. package/src/tui/components/QuestionPanel.jsx +13 -4
  234. package/src/tui/dist/index.mjs +75 -28
  235. package/src/tui/session/session-flow/queue/take-entries.mjs +11 -0
  236. package/src/ui/statusline-agents.mjs +1 -3
@@ -366,7 +366,9 @@ let deps = DEFAULT_DEPS;
366
366
 
367
367
  // The idle auto-clear measures idleness from the last cache use, a
368
368
  // successful warm included (durable in the touch store).
369
- setCacheWarmUseSource((sessionId) => (sessionId ? deps.touches().lastWarmUse(sessionId) : 0));
369
+ // The record names the route and account the warm read, so only a request
370
+ // on that same prefix counts it as fresh cache (compact-policy.mjs).
371
+ setCacheWarmUseSource((sessionId) => (sessionId ? deps.touches().warmUse(sessionId) : null));
370
372
 
371
373
  // A session deleted for good drops everything it learned and touched.
372
374
  registerSessionPurgeHook((sessionId) => {
@@ -787,6 +789,10 @@ async function fireWarm(sessionId, chain) {
787
789
  chain.latencyMs = (reportedAt ?? deps.now()) - sentAt;
788
790
  recordTouch(sessionId, chain);
789
791
  // A read without a write is cache use: the idle auto-clear counts from it.
790
- deps.touches().recordWarmUse(sessionId, sentAt);
792
+ deps.touches().recordWarmUse(sessionId, sentAt, {
793
+ provider: chain.request.provider.name,
794
+ model: chain.request.model,
795
+ accountId: target.accountId,
796
+ });
791
797
  scheduleWarm(sessionId, chain);
792
798
  }
@@ -11,7 +11,7 @@ import { createHash } from 'node:crypto';
11
11
 
12
12
  /** Bumped whenever the table layout or a codec changes: the DB is then
13
13
  * dropped and backfilled again from the JSON catalog. */
14
- export const SCHEMA_VERSION = 10;
14
+ export const SCHEMA_VERSION = 11;
15
15
  /** PRAGMA application_id ('MXSC'): a file without it is not this catalog. */
16
16
  export const APPLICATION_ID = 0x4d585343;
17
17
 
@@ -68,6 +68,9 @@ const FIELDS = [
68
68
  // Places of the incoming messages with text among the counted ones, for the
69
69
  // unread count of a bot home / room log; absent otherwise (schema 10).
70
70
  ['incomingTail', 'incoming_tail', 'optjson'],
71
+ // Compactions the session has had: the epoch of its message numbering;
72
+ // absent for a row written before schema 11.
73
+ ['compactCount', 'compact_count', 'optjson'],
71
74
  ];
72
75
 
73
76
  const COLUMN_TYPES = { text: 'TEXT', real: 'REAL', int: 'INTEGER', bool: 'INTEGER', json: 'TEXT', optjson: 'TEXT' };
@@ -9,6 +9,7 @@ import {
9
9
  isProtectedContextAckMessage,
10
10
  latestActualUserInstructionIndex,
11
11
  pendingTaskNotificationIndexes,
12
+ requestStart,
12
13
  } from './messages.mjs';
13
14
  import { staleObservations, supersedeObservation } from './observation-supersession.mjs';
14
15
  import { carryMessageId, withoutMessageIdsReplacer } from '../message-ids.mjs';
@@ -101,14 +102,6 @@ export function executionTokens(messages) {
101
102
  );
102
103
  }
103
104
 
104
- function requestStart(messages, index) {
105
- for (let i = index; i >= 0; i -= 1) {
106
- if (!isActualUserInstructionMessage(messages[i])) continue;
107
- if (messages[i]?.meta?.source !== 'steering') return i;
108
- }
109
- return latestActualUserInstructionIndex(messages.slice(0, index + 1));
110
- }
111
-
112
105
  // Each assistant tool-call turn with the tool results that directly follow it.
113
106
  function executionGroups(messages) {
114
107
  const groups = [];
@@ -43,6 +43,29 @@ export function latestActualUserInstructionIndex(messages) {
43
43
  return -1;
44
44
  }
45
45
 
46
+ // The user instruction that began the request `index` belongs to; a steering
47
+ // message continues the request instead of starting one.
48
+ export function requestStart(messages, index) {
49
+ for (let i = index; i >= 0; i -= 1) {
50
+ if (!isActualUserInstructionMessage(messages[i])) continue;
51
+ if (messages[i]?.meta?.source !== 'steering') return i;
52
+ }
53
+ return latestActualUserInstructionIndex(messages.slice(0, index + 1));
54
+ }
55
+
56
+ // A tool call's arguments as an object, whether stored parsed or as JSON text.
57
+ export function callArguments(call) {
58
+ let args = call?.arguments;
59
+ if (typeof args === 'string') {
60
+ try {
61
+ args = JSON.parse(args);
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
66
+ return args && typeof args === 'object' ? args : {};
67
+ }
68
+
46
69
  export function latestActualUserInstructionMessage(messages) {
47
70
  const index = latestActualUserInstructionIndex(messages);
48
71
  return index >= 0 ? { ...messages[index] } : null;
@@ -1,9 +1,12 @@
1
- // Observations that a later one of the same browser page or desktop window
2
- // replaced. Such a result's refs have expired, and its element list and pixels
3
- // describe a state that no longer exists. Only compaction drops them: between
4
- // compactions the transcript stays append-only so the provider cache prefix
5
- // holds, and compaction rewrites that prefix anyway. The execution archive
6
- // keeps every original.
1
+ // Observations that a later one of the same browser page, desktop window,
2
+ // visualize tab (inspect / check) or image file replaced. Such a result's refs
3
+ // have expired, and its element list and pixels describe a state that no
4
+ // longer exists. Only compaction drops them: between compactions the
5
+ // transcript stays append-only so the provider cache prefix holds, and
6
+ // compaction rewrites that prefix anyway. The execution archive keeps every
7
+ // original.
8
+ import { callArguments, requestStart } from './messages.mjs';
9
+
7
10
  const BROWSER_TOOLS = new Set(['browser', 'browser_devtools']);
8
11
  const SNAPSHOT_LINE = /(?:^|\n)Snapshot:\s+((p\d+)-s\d+)\b/;
9
12
  const TERMINAL_LINE = /(?:^|\n)Terminal:\s+(session-terminal:\S+)/;
@@ -52,13 +55,40 @@ function containsRun(newer, older) {
52
55
  return false;
53
56
  }
54
57
 
55
- // What one tool result observed, keyed by the page or window it describes.
56
- function observationOf(message, toolName) {
58
+ const VISUALIZE_READS = new Set(['inspect', 'check']);
59
+ const IMAGE_FILE = /\.(?:png|jpe?g|gif|webp)$/i;
60
+ // What a superseded computer frame keeps besides its reference.
61
+ const FRAME_OUTCOME_KEYS = ['ok', 'action', 'actions', 'status', 'error', 'window_id'];
62
+
63
+ // A visualize read-out is the same observation only for the same tab, kind of
64
+ // read, viewport and scope: a mobile inspect does not replace a desktop one.
65
+ function visualizeKey(args) {
66
+ const input = args.input && typeof args.input === 'object' ? args.input : {};
67
+ if (!VISUALIZE_READS.has(args.action) || typeof input.tab !== 'string' || !input.tab) return null;
68
+ const scope = args.action === 'inspect' ? `:${JSON.stringify(input.viewport ?? null)}:${input.selector ?? ''}` : '';
69
+ return { key: `visualize:${args.action}:${input.tab}${scope}`, action: args.action, tab: input.tab };
70
+ }
71
+
72
+ // What one tool result observed, keyed by the page, window, tab or file it
73
+ // describes.
74
+ function observationOf(message, call) {
57
75
  if (message?.role !== 'tool' || message.toolKind === 'error') return null;
76
+ const toolName = String(call?.name || '');
58
77
  const parts = contentParts(message.content);
78
+ if (toolName === 'read') {
79
+ const args = callArguments(call);
80
+ const path = typeof args.path === 'string' ? args.path : args.file_path;
81
+ if (typeof path !== 'string' || !IMAGE_FILE.test(path) || !parts.some(isImagePart)) return null;
82
+ return { key: `image:${path.replace(/\\/g, '/')}`, imagePath: path, requestScoped: true };
83
+ }
59
84
  const textIndex = parts.findIndex((part) => part?.type === 'text' && !isImagePart(part));
60
85
  if (textIndex < 0) return null;
61
86
  const text = String(parts[textIndex].text);
87
+ if (toolName === 'visualize') {
88
+ if (/^\[Visualize \w+ of tab \S+ superseded:/.test(text)) return null;
89
+ const read = visualizeKey(callArguments(call));
90
+ return read ? { key: read.key, parts, textIndex, visualize: read, requestScoped: true } : null;
91
+ }
62
92
  if (BROWSER_TOOLS.has(toolName)) {
63
93
  const snapshot = SNAPSHOT_LINE.exec(text);
64
94
  return snapshot ? { key: `browser:${snapshot[2]}`, parts, textIndex, text, snapshot } : null;
@@ -82,40 +112,90 @@ function observationOf(message, toolName) {
82
112
  const frame = value?.observation && typeof value.observation === 'object' ? value.observation : value;
83
113
  const windowId = frame?.window_id || value?.window_id;
84
114
  const observed = Array.isArray(frame?.elements) || frame?.ocr !== undefined || parts.some(isImagePart);
85
- return windowId && observed ? { key: `computer:${windowId}`, parts, textIndex, value, frame } : null;
115
+ if (windowId && observed) return { key: `computer:${windowId}`, parts, textIndex, value, frame };
116
+ // A stub an earlier compaction wrote in the fuller format.
117
+ const slim =
118
+ typeof frame?.superseded === 'string' &&
119
+ Object.keys(frame).some((name) => name !== 'superseded' && !FRAME_OUTCOME_KEYS.includes(name));
120
+ return slim ? { stub: true, parts, textIndex, value, frame, reference: frame.superseded } : null;
86
121
  }
87
122
 
88
- /** Tool results that a later observation of the same page or window
89
- * replaced, keyed by transcript index. */
123
+ // Transcript indices whose results are still in use and must stay verbatim:
124
+ // the latest tool-call group (the batch the session continues from, which
125
+ // compaction always retains) and any group with a call that has no result yet.
126
+ function protectedObservationIndices(messages) {
127
+ const protectedIndices = new Set();
128
+ const protect = (from, to) => {
129
+ for (let index = from; index < to; index += 1) protectedIndices.add(index);
130
+ };
131
+ let latest = null;
132
+ for (let index = 0; index < messages.length; index += 1) {
133
+ const message = messages[index];
134
+ if (message?.role !== 'assistant' || !message.toolCalls?.length) continue;
135
+ const start = index;
136
+ const answered = new Set();
137
+ while (messages[index + 1]?.role === 'tool') {
138
+ index += 1;
139
+ answered.add(messages[index].toolCallId);
140
+ }
141
+ latest = [start, index + 1];
142
+ if (message.toolCalls.some((call) => !answered.has(call?.id))) protect(...latest);
143
+ }
144
+ if (latest) protect(...latest);
145
+ return protectedIndices;
146
+ }
147
+
148
+ /** Tool results that a later observation of the same page, window, tab or
149
+ * file replaced, keyed by transcript index. Protected results are never
150
+ * listed, though they still count as the newer observation of what they
151
+ * describe. An image file read or visualize read-out is also kept for the
152
+ * whole active request (everything since the last user instruction), since
153
+ * the request may compare it with a later one; once a newer user message
154
+ * exists it is replaceable. */
90
155
  export function staleObservations(messages) {
91
- const toolNames = new Map();
156
+ const protectedIndices = protectedObservationIndices(messages);
157
+ const activeRequest = Math.max(requestStart(messages, messages.length - 1), 0);
158
+ const calls = new Map();
92
159
  for (const message of messages) {
93
160
  if (message?.role !== 'assistant' || !Array.isArray(message.toolCalls)) continue;
94
- for (const call of message.toolCalls) if (call?.id) toolNames.set(call.id, String(call.name || ''));
161
+ for (const call of message.toolCalls) if (call?.id) calls.set(call.id, call);
95
162
  }
96
163
  const newest = new Set();
97
164
  const terminalReads = new Map();
98
165
  const stale = new Map();
99
166
  for (let index = messages.length - 1; index >= 0; index -= 1) {
100
- const found = observationOf(messages[index], toolNames.get(messages[index]?.toolCallId));
167
+ const found = observationOf(messages[index], calls.get(messages[index]?.toolCallId));
101
168
  if (!found) continue;
102
- if (found.body) {
169
+ const replaceable = !protectedIndices.has(index) && !(found.requestScoped && index >= activeRequest);
170
+ if (found.stub) {
171
+ if (replaceable) stale.set(index, found);
172
+ } else if (found.body) {
103
173
  const newer = terminalReads.get(found.key) ?? [];
104
- if (newer.some((body) => containsRun(body, found.body))) stale.set(index, found);
174
+ if (replaceable && newer.some((body) => containsRun(body, found.body))) stale.set(index, found);
105
175
  newer.push(found.body);
106
176
  terminalReads.set(found.key, newer);
107
- } else if (newest.has(found.key)) stale.set(index, found);
108
- else newest.add(found.key);
177
+ } else if (newest.has(found.key)) {
178
+ if (replaceable) stale.set(index, found);
179
+ } else newest.add(found.key);
109
180
  }
110
181
  return stale;
111
182
  }
112
183
 
113
184
  /** The result without its replaced observation. A browser result keeps what
114
185
  * precedes its snapshot (effect notes, a script's value) and a computer
115
- * result its action outcome; neither keeps elements, OCR text or pixels. */
186
+ * result its action outcome plus a reference; none keeps elements, OCR text,
187
+ * frame metadata or pixels. A visualize read-out or image file read becomes a
188
+ * one-line placeholder naming what it was and how to get it again. */
116
189
  export function supersedeObservation(message, found, origin) {
117
190
  let text;
118
- if (found.terminal) {
191
+ if (found.imagePath) {
192
+ text = `[Image read of ${found.imagePath} superseded: a newer read of this file follows. Read the file again to view it. ${origin}]`;
193
+ return { ...message, content: withParts(message.content, [{ type: 'text', text }]) };
194
+ }
195
+ if (found.visualize) {
196
+ const { action, tab } = found.visualize;
197
+ text = `[Visualize ${action} of tab ${tab} superseded: a newer ${action} of this tab follows. Run visualize ${action} on the tab again to get it. ${origin}]`;
198
+ } else if (found.terminal) {
119
199
  text = `[Terminal ${found.terminal} read superseded: a newer read of this tab follows. ${origin}]`;
120
200
  } else if (found.snapshot) {
121
201
  const { snapshot } = found;
@@ -126,9 +206,9 @@ export function supersedeObservation(message, found, origin) {
126
206
  const note = `[Snapshot ${snapshot[1]} superseded: a newer snapshot of this page follows. ${origin}]`;
127
207
  text = before ? `${before}\n\n${note}` : note;
128
208
  } else {
129
- const frame = { ...found.frame, superseded: `a newer observation of this window follows. ${origin}` };
130
- delete frame.elements;
131
- delete frame.ocr;
209
+ const frame = {};
210
+ for (const name of FRAME_OUTCOME_KEYS) if (found.frame[name] !== undefined) frame[name] = found.frame[name];
211
+ frame.superseded = found.reference ?? `a newer observation of this window follows. ${origin}`;
132
212
  text = JSON.stringify(found.frame === found.value ? frame : { ...found.value, observation: frame });
133
213
  }
134
214
  const parts = found.parts.flatMap((part, index) => {
@@ -12,6 +12,7 @@ import {
12
12
  textByteLength,
13
13
  } from './text-utils.mjs';
14
14
  import {
15
+ callArguments,
15
16
  contextAckMessage,
16
17
  isCompactConversationRow,
17
18
  isProtectedContextAckMessage,
@@ -25,6 +26,7 @@ import { activeTurnContinuationMessage } from './continuation.mjs';
25
26
  import { EXECUTION_RECOVERY_SOURCE, buildExecutionTail } from './execution-tail.mjs';
26
27
  import { latestSkillBodies, skillMessageText } from '../../context/skill-state.mjs';
27
28
  import { effectiveBudget } from './budget.mjs';
29
+ import { parseMcpToolName } from '../../../../shared/tool-primitives.mjs';
28
30
  import { stripRuntimeUserContext, withRuntimeUserContext } from '../runtime-user-context.mjs';
29
31
  import { carryMessageId, messageIdOf } from '../message-ids.mjs';
30
32
  import { normalizeIngestRole, shouldExcludeIngestMessage } from '../../../../memory/lib/session-ingest.mjs';
@@ -70,12 +72,23 @@ function splitFreshSource(messages) {
70
72
  break;
71
73
  }
72
74
  }
75
+ // Where the previous Compact's skill-use window opens: `live` index after
76
+ // the history it kept (see SKILL_WINDOW_SOURCE). Without a marker the whole
77
+ // transcript is the window.
78
+ const isKept = (message) => !isSummaryMessage(message) && message?.meta?.source !== SKILL_WINDOW_SOURCE;
79
+ const markerAt = conversation.findLastIndex((message) => message?.meta?.source === SKILL_WINDOW_SOURCE);
80
+ const span = conversation[markerAt]?.meta?.span;
81
+ const skillWindowStart =
82
+ markerAt >= 0 && Number.isInteger(span) && span >= 0
83
+ ? conversation.slice(0, markerAt).filter(isKept).length + span
84
+ : 0;
73
85
  const latestUserIndex = latestActualUserInstructionIndex(conversation);
74
86
  return {
75
87
  protectedPrefix,
76
88
  previousSummary: previousSummaryMessage?.content || null,
77
89
  previousSummaryMessage,
78
90
  previousAck,
91
+ skillWindowStart,
79
92
  // Budgeting, AI input and rule-only retention use this same projection.
80
93
  // Keep current-turn reminders; remove only proven runtime additions from
81
94
  // older requests. Unmarked legacy text remains part of the summary budget.
@@ -86,7 +99,7 @@ function splitFreshSource(messages) {
86
99
  .map((message, index) =>
87
100
  index === latestUserIndex ? message : referenceMessageMedia(stripRuntimeUserContext(message))
88
101
  )
89
- .filter((message) => !isSummaryMessage(message)),
102
+ .filter(isKept),
90
103
  sanitized,
91
104
  };
92
105
  }
@@ -135,9 +148,11 @@ function toolCallSummaryLine(call, result) {
135
148
  // Arguments arrive redacted (splitFreshSource); the outcome is redacted
136
149
  // here, before any cut can split a secret from its key.
137
150
  const outcome = redactRawSecretString(contentText(result?.content)).trim();
138
- // An ask_user result is the user's own answer: never cut.
151
+ // An ask_user or handoff_user result is the user's own answer: never cut.
139
152
  const shown =
140
- call.name === 'ask_user' ? outcome : boundedSummaryText(outcome.replace(/\s+/g, ' '), SUMMARY_TOOL_OUTCOME_CHARS);
153
+ call.name === 'ask_user' || call.name === 'handoff_user'
154
+ ? outcome
155
+ : boundedSummaryText(outcome.replace(/\s+/g, ' '), SUMMARY_TOOL_OUTCOME_CHARS);
141
156
  return `[tool call] ${call.name}(${args}) → ${result ? shown || '(empty result)' : '(no result)'}`;
142
157
  }
143
158
 
@@ -564,44 +579,128 @@ function currentSkillBodyMessage(message, current) {
564
579
 
565
580
  const SKILL_RELOAD_SOURCE = 'compact-skill-reload';
566
581
 
567
- function skillReloadReminder(name) {
582
+ // How many Compacts in a row a skill may go unused before even its reload
583
+ // line is dropped: a skill untouched that long belongs to finished work, and
584
+ // the model can still load it by name from the skill manifest.
585
+ export const SKILL_RELOAD_MAX_IDLE = 3;
586
+
587
+ const SKILL_TOOL_NAMES = new Set(['Skill', 'skill_view']);
588
+
589
+ function skillReloadReminder(name, idle = 0) {
568
590
  return {
569
591
  role: 'user',
570
- content: `[mixdog-runtime] Skill "${name}" was active, but its instructions did not fit after Compact. Reload it with the Skill tool before relying on them.`,
571
- meta: { source: SKILL_RELOAD_SOURCE, synthetic: true, skill: name },
592
+ content: `[mixdog-runtime] Skill "${name}" was active earlier, but Compact did not keep its instructions. Reload it with the Skill tool before relying on them.`,
593
+ meta: { source: SKILL_RELOAD_SOURCE, synthetic: true, skill: name, idle },
572
594
  };
573
595
  }
574
596
 
575
- // Restore the skills active in the transcript, each as the Skill tool would
576
- // deliver it now. `currentSkill(name)` (the caller's resolver) answers
577
- // `{ body, native, summary }` for a skill that still loads in the session, or null for
578
- // one switched off, outside its scope or removed: such a skill is restored
579
- // neither as a body nor as a reload reminder, since its instructions are no
580
- // longer in force. Without a resolver the transcript copies are kept.
597
+ // Every Compact leaves this marker just ahead of the skills it restores, with
598
+ // `span`: how many messages follow it (restored skills and retained tail).
599
+ // All of them happened before that Compact, so the next one reads the skill
600
+ // use window from after them, whether or not a summary exists. The marker is
601
+ // not conversation: the next Compact removes it and writes a new one.
602
+ const SKILL_WINDOW_SOURCE = 'compact-skill-window';
603
+
604
+ function skillWindowMarker(span = 0) {
605
+ return {
606
+ role: 'user',
607
+ content: '[mixdog-runtime] Compact boundary.',
608
+ meta: { source: SKILL_WINDOW_SOURCE, synthetic: true, span },
609
+ };
610
+ }
611
+
612
+ // Restore the skills the session used since the previous Compact, each as the
613
+ // Skill tool would deliver it now. A skill is used when it was loaded or one of
614
+ // its linked tools ran in that window (`windowStart` onward); its linked
615
+ // tools are the native tool references of its loader pair and, when the
616
+ // resolver gives them, `tools`. `currentSkill(name)` (the caller's resolver)
617
+ // answers `{ body, native, summary, tools? }` for a skill that still loads in
618
+ // the session, or null for one switched off, outside its scope or removed:
619
+ // such a skill is restored neither as a body nor as a reload line, since its
620
+ // instructions are no longer in force. Without a resolver the transcript
621
+ // copies are kept.
581
622
  // Never summarize/truncate operating instructions or displace the handoff
582
- // and current task. An omitted body leaves a one-line reload reminder, which
583
- // stands through later Compacts until the skill is loaded again. Reminders
584
- // are charged to the same budget: every skill's reminder is reserved before
585
- // any body is restored, and restoring a body releases its own reminder.
586
- function restoreSkillBodies(live, skillBudget, retainedTail, currentSkill = null) {
623
+ // and current task. Every other active skill, and a used one whose body does
624
+ // not fit, is left as a one-line reload line. The line counts the Compacts
625
+ // the skill went unused (`meta.idle`) and is dropped past SKILL_RELOAD_MAX_IDLE.
626
+ // Reload lines are charged to the same budget: every possible line is
627
+ // reserved before any body is restored, and restoring a body releases its own.
628
+ function restoreSkillBodies(live, skillBudget, retainedTail, currentSkill = null, windowStart = 0) {
587
629
  const retainedCallIds = new Set(retainedTail.filter((m) => m?.role === 'tool').map((m) => m.toolCallId));
588
630
  const current = (name) => (currentSkill ? currentSkill(name) : undefined);
589
631
  const loads = (name) => current(name) !== null;
590
632
  const transcriptBodies = latestSkillBodies(live);
591
633
  const loaded = new Set(transcriptBodies.map((body) => body.name));
592
634
  const bodies = transcriptBodies.filter((body) => loads(body.name));
593
- const reload = live
594
- .filter((m) => m?.meta?.source === SKILL_RELOAD_SOURCE && !loaded.has(m.meta.skill) && loads(m.meta.skill))
595
- .map((m) => m.meta.skill);
596
- const reminderCost = (name) => estimateMessagesTokens([skillReloadReminder(name)]);
597
- let reserved = [...new Set([...reload, ...bodies.map((body) => body.name)])].reduce(
598
- (sum, name) => sum + reminderCost(name),
599
- 0
600
- );
601
- const dropped = [];
635
+ // Reload lines from earlier Compacts: skill -> Compacts it went unused.
636
+ const earlier = new Map();
637
+ for (const m of live) {
638
+ if (m?.meta?.source !== SKILL_RELOAD_SOURCE || loaded.has(m.meta.skill) || !loads(m.meta.skill)) continue;
639
+ earlier.delete(m.meta.skill);
640
+ earlier.set(m.meta.skill, Number.isFinite(m.meta.idle) ? m.meta.idle : 0);
641
+ }
642
+
643
+ // A loader pair can sit anywhere in the transcript (a restored body stays
644
+ // ahead of the retained tail's pair), so read them all.
645
+ const results = new Map(live.filter((m) => m?.role === 'tool').map((m) => [m.toolCallId, m]));
646
+ const loaderTools = new Map();
647
+ for (const message of live) {
648
+ for (const call of message?.role === 'assistant' ? message.toolCalls || [] : []) {
649
+ const references = results.get(call.id)?.nativeToolSearch?.toolReferences;
650
+ if (call.name !== 'Skill' || !references) continue;
651
+ const name = String(callArguments(call).name || '').trim();
652
+ loaderTools.set(name, [...(loaderTools.get(name) || []), ...references]);
653
+ }
654
+ }
655
+ const skillsOfTool = new Map();
656
+ for (const name of new Set([...earlier.keys(), ...bodies.map((body) => body.name)])) {
657
+ const skill = current(name);
658
+ const tools = [...(skill?.native?.toolReferences || []), ...(skill?.tools || []), ...(loaderTools.get(name) || [])];
659
+ for (const tool of tools) skillsOfTool.set(tool, (skillsOfTool.get(tool) || new Set()).add(name));
660
+ }
661
+ // Use since the previous Compact (from `windowStart`): the newest transcript
662
+ // position of a load or of a linked tool's call.
663
+ const lastUse = new Map();
664
+ const noteUse = (name, index) => lastUse.set(name, Math.max(lastUse.get(name) ?? -1, index));
665
+ live.forEach((message, index) => {
666
+ if (index < windowStart || message?.role !== 'assistant') return;
667
+ for (const call of message.toolCalls || []) {
668
+ const args = callArguments(call);
669
+ if (SKILL_TOOL_NAMES.has(call.name)) noteUse(String(args.name || '').trim(), index);
670
+ else {
671
+ const tool = call.name === 'call_tool' ? args.name : call.name;
672
+ const mcpServer = `mcp:${parseMcpToolName(tool)?.server}`;
673
+ for (const name of [...(skillsOfTool.get(tool) || []), ...(skillsOfTool.get(mcpServer) || [])]) {
674
+ noteUse(name, index);
675
+ }
676
+ }
677
+ }
678
+ });
679
+ for (const { name, message } of bodies) {
680
+ const index = live.lastIndexOf(message);
681
+ if (index >= windowStart) noteUse(name, index);
682
+ }
683
+
684
+ // Every active skill without a body ends as a reload line: an unused one
685
+ // ages by a Compact and goes once past the bound; one used (or whose body
686
+ // did not fit) starts over.
687
+ const reminders = new Map();
688
+ for (const [name, idle] of earlier) {
689
+ if (lastUse.has(name)) reminders.set(name, 0);
690
+ else if (idle < SKILL_RELOAD_MAX_IDLE) reminders.set(name, idle + 1);
691
+ }
692
+ const used = bodies.filter(({ name }) => lastUse.has(name)).sort((a, b) => lastUse.get(b.name) - lastUse.get(a.name));
693
+ const unused = bodies.filter(({ name }) => !lastUse.has(name));
694
+
695
+ const reminderCost = (name, idle = 0) => estimateMessagesTokens([skillReloadReminder(name, idle)]);
696
+ let reserved =
697
+ [...reminders].reduce((sum, [name, idle]) => sum + reminderCost(name, idle), 0) +
698
+ unused.reduce((sum, { name }) => sum + reminderCost(name, 1), 0) +
699
+ used.reduce((sum, { name }) => sum + reminderCost(name), 0);
700
+ const dropped = new Set();
602
701
  const restoredSkills = [];
603
702
  let skillTokens = 0;
604
- for (const { name, message } of bodies.reverse()) {
703
+ for (const { name, message } of used) {
605
704
  const skill = current(name);
606
705
  const restored = [
607
706
  ...skillLoaderPair(live, live.lastIndexOf(message), name, retainedCallIds, skill),
@@ -610,24 +709,31 @@ function restoreSkillBodies(live, skillBudget, retainedTail, currentSkill = null
610
709
  const cost = estimateMessagesTokens(restored);
611
710
  const own = reminderCost(name);
612
711
  if (skillTokens + cost + reserved - own > skillBudget) {
613
- dropped.unshift(name);
712
+ dropped.add(name);
614
713
  continue;
615
714
  }
616
715
  restoredSkills.unshift(...restored);
617
716
  skillTokens += cost;
618
717
  reserved -= own;
619
718
  }
620
- // Newest skill first; only a budget too small for the reminders themselves
719
+ // In transcript order, after the lines of earlier Compacts.
720
+ for (const { name } of bodies) {
721
+ if (!lastUse.has(name)) reminders.set(name, 1);
722
+ else if (dropped.has(name)) reminders.set(name, 0);
723
+ }
724
+ // Newest skill first; only a budget too small for the lines themselves
621
725
  // leaves one out.
622
- const pending = [...new Set([...reload, ...dropped])];
726
+ const pending = [...reminders];
623
727
  const reminded = new Set();
624
- for (const name of pending.toReversed()) {
625
- const cost = reminderCost(name);
728
+ for (const [name, idle] of pending.toReversed()) {
729
+ const cost = reminderCost(name, idle);
626
730
  if (skillTokens + cost > skillBudget) continue;
627
731
  reminded.add(name);
628
732
  skillTokens += cost;
629
733
  }
630
- restoredSkills.push(...pending.filter((name) => reminded.has(name)).map(skillReloadReminder));
734
+ restoredSkills.push(
735
+ ...pending.filter(([name]) => reminded.has(name)).map(([name, idle]) => skillReloadReminder(name, idle))
736
+ );
631
737
  return { restoredSkills, skillTokens };
632
738
  }
633
739
 
@@ -864,15 +970,33 @@ export function freshContextCompactMessages(messages, budgetTokens, options = {}
864
970
  const summaryContent = String(summaryMessage?.content || '');
865
971
  assertHandoffFits(summaryMessage, handoff.room);
866
972
  const skillBudget = freshSkillBudget(targetBudget, budget, [...stableHead, ...tail.volatileTail]);
973
+ // Restored skills carry the window marker, which is charged to their budget.
974
+ const markerCost = estimateMessagesTokens([skillWindowMarker()]);
867
975
  const skills = {
868
- ...restoreSkillBodies(source.live, skillBudget, tail.volatileTail, opts.currentSkill),
976
+ ...restoreSkillBodies(
977
+ source.live,
978
+ Math.max(0, skillBudget - markerCost),
979
+ tail.volatileTail,
980
+ opts.currentSkill,
981
+ source.skillWindowStart
982
+ ),
869
983
  skillBudget,
870
984
  };
871
- const stablePrefixMessages = [...stableHead, ...skills.restoredSkills];
872
- const result = rebaseCompactedEffortConfiguration(
985
+ // A session with no skill state has no use to window: no marker.
986
+ const marker = skills.restoredSkills.length ? [skillWindowMarker()] : [];
987
+ const stablePrefixMessages = [...stableHead, ...marker, ...skills.restoredSkills];
988
+ const compacted = rebaseCompactedEffortConfiguration(
873
989
  baseSanitized,
874
990
  reconcileDedupStubs(sanitizeToolPairs([...stablePrefixMessages, ...tail.volatileTail]))
875
991
  );
992
+ // The next Compact reads everything after the marker as history, not as use.
993
+ const markerAt = compacted.findLastIndex((message) => message?.meta?.source === SKILL_WINDOW_SOURCE);
994
+ const result =
995
+ markerAt < 0
996
+ ? compacted
997
+ : compacted.map((message, index) =>
998
+ index === markerAt ? skillWindowMarker(compacted.length - markerAt - 1) : message
999
+ );
876
1000
  const finalTokens = estimateMessagesTokens(result);
877
1001
  if (finalTokens > budget) {
878
1002
  throw new Error(`freshContextCompactMessages: compacted result exceeds budget=${budget} (result=${finalTokens})`);
@@ -581,8 +581,20 @@ function reasoningBreakdown(message, fullTextTokens) {
581
581
  };
582
582
  }
583
583
  }
584
+ // Price each reasoning item that is actually sent (a non-empty replay
585
+ // replaces content) at its own cost; the whole-minus-visible difference only
586
+ // caps the sum so the rows never exceed the total.
587
+ const sent = message.providerReplay?.items?.length ? message.providerReplay.items.filter(isReasoningBlock) : blocks;
584
588
  return {
585
- tokens: blocks.length ? Math.max(0, fullTextTokens() - messageTextTokens(visible)) : 0,
589
+ tokens: blocks.length
590
+ ? Math.max(
591
+ 0,
592
+ Math.min(
593
+ sent.reduce((sum, block) => sum + estimateTokens(nativeBlocksEstimateText(block)), 0),
594
+ fullTextTokens() - messageTextTokens(visible)
595
+ )
596
+ )
597
+ : 0,
586
598
  blocks,
587
599
  message: visible,
588
600
  };