mixdog 0.9.55 → 0.9.59

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 (120) hide show
  1. package/package.json +3 -1
  2. package/scripts/_smoke_wd.mjs +7 -0
  3. package/scripts/agent-terminal-reap-test.mjs +127 -2
  4. package/scripts/compact-file-reattach-test.mjs +88 -0
  5. package/scripts/compact-pressure-test.mjs +45 -21
  6. package/scripts/compact-recall-digest-test.mjs +57 -0
  7. package/scripts/compact-smoke.mjs +35 -39
  8. package/scripts/desktop-session-bridge-test.mjs +271 -9
  9. package/scripts/generate-oc-icons.mjs +11 -0
  10. package/scripts/live-share-test.mjs +148 -0
  11. package/scripts/live-worker-smoke.mjs +1 -1
  12. package/scripts/max-output-recovery-test.mjs +12 -1
  13. package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
  14. package/scripts/provider-admission-scheduler-test.mjs +100 -0
  15. package/scripts/provider-contract-test.mjs +49 -0
  16. package/scripts/resource-admission-test.mjs +5 -2
  17. package/scripts/session-ingest-compaction-smoke.mjs +38 -0
  18. package/scripts/steering-drain-buckets-test.mjs +15 -0
  19. package/scripts/submit-commandbusy-race-test.mjs +54 -3
  20. package/scripts/tool-smoke.mjs +2 -3
  21. package/scripts/tui-ambiguous-width-test.mjs +113 -0
  22. package/scripts/tui-runtime-load-bench.mjs +5 -0
  23. package/scripts/tui-transcript-perf-test.mjs +167 -0
  24. package/src/app.mjs +23 -0
  25. package/src/cli.mjs +6 -0
  26. package/src/lib/keychain-cjs.cjs +70 -20
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
  28. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
  30. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
  34. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
  35. package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
  36. package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
  37. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
  39. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
  42. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
  43. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
  44. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
  45. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
  48. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
  50. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
  51. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
  53. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
  54. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
  59. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
  61. package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
  62. package/src/runtime/channels/lib/scheduler.mjs +8 -6
  63. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
  64. package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
  65. package/src/runtime/channels/lib/whisper-language.mjs +4 -0
  66. package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
  67. package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
  68. package/src/runtime/memory/lib/query-handlers.mjs +10 -4
  69. package/src/runtime/memory/lib/recall-format.mjs +43 -2
  70. package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
  71. package/src/runtime/shared/err-text.mjs +4 -1
  72. package/src/runtime/shared/resource-admission.mjs +11 -0
  73. package/src/runtime/shared/schedule-model-ref.mjs +28 -0
  74. package/src/runtime/shared/schedule-session-run.mjs +63 -0
  75. package/src/runtime/shared/schedules-db.mjs +8 -3
  76. package/src/runtime/shared/tool-result-summary.mjs +4 -1
  77. package/src/runtime/shared/tool-surface.mjs +7 -7
  78. package/src/runtime/shared/transcript-writer.mjs +17 -0
  79. package/src/session-runtime/channel-config-api.mjs +11 -0
  80. package/src/session-runtime/config-helpers.mjs +9 -6
  81. package/src/session-runtime/context-status.mjs +80 -3
  82. package/src/session-runtime/lifecycle-api.mjs +154 -40
  83. package/src/session-runtime/provider-auth-api.mjs +21 -2
  84. package/src/session-runtime/provider-usage.mjs +4 -1
  85. package/src/session-runtime/resource-api.mjs +12 -11
  86. package/src/session-runtime/runtime-core.mjs +45 -11
  87. package/src/session-runtime/session-text.mjs +56 -6
  88. package/src/session-runtime/session-turn-api.mjs +70 -2
  89. package/src/session-runtime/tool-catalog.mjs +2 -1
  90. package/src/session-runtime/workflow-agents-api.mjs +2 -2
  91. package/src/standalone/agent-tool/render.mjs +5 -1
  92. package/src/standalone/agent-tool.mjs +63 -3
  93. package/src/standalone/channel-admin.mjs +19 -3
  94. package/src/standalone/channel-daemon.mjs +40 -0
  95. package/src/tui/app/resume-picker.mjs +5 -1
  96. package/src/tui/app/settings-picker.mjs +19 -0
  97. package/src/tui/app/transcript-window.mjs +45 -8
  98. package/src/tui/app/use-mouse-input.mjs +101 -31
  99. package/src/tui/app/use-transcript-window.mjs +53 -9
  100. package/src/tui/components/ToolExecution.jsx +3 -4
  101. package/src/tui/components/TranscriptItem.jsx +3 -0
  102. package/src/tui/dist/index.mjs +17391 -14920
  103. package/src/tui/engine/context-state.mjs +10 -1
  104. package/src/tui/engine/live-share.mjs +390 -0
  105. package/src/tui/engine/queue-helpers.mjs +23 -0
  106. package/src/tui/engine/session-api-ext.mjs +439 -40
  107. package/src/tui/engine/session-api.mjs +7 -1
  108. package/src/tui/engine/session-flow.mjs +21 -7
  109. package/src/tui/engine/tool-card-results.mjs +3 -1
  110. package/src/tui/engine/turn.mjs +57 -4
  111. package/src/tui/engine.mjs +211 -7
  112. package/src/tui/index.jsx +2 -0
  113. package/src/tui/lib/voice-setup.mjs +10 -4
  114. package/src/tui/paste-attachments.mjs +4 -1
  115. package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
  116. package/src/workflows/default/WORKFLOW.md +3 -0
  117. package/vendor/ink/build/display-width.js +14 -0
  118. package/vendor/ink/build/output.js +24 -3
  119. package/vendor/ink/build/wrap-text.d.ts +2 -0
  120. package/vendor/ink/build/wrap-text.js +45 -4
@@ -1,7 +1,8 @@
1
1
  import { isOffloadedToolResultText } from './tool-result-offload.mjs';
2
2
  import { createHash } from 'node:crypto';
3
+ import { createRequire } from 'node:module';
3
4
  import { providerNativeToolPrefixCount } from '../../../../session-runtime/provider-request-tools.mjs';
4
- import { contentImageDescriptors, contentToText } from '../providers/media-normalization.mjs';
5
+ import { contentFileDescriptors, contentImageDescriptors, contentToText } from '../providers/media-normalization.mjs';
5
6
  export {
6
7
  DEFAULT_COMPACTION_BUFFER_TOKENS,
7
8
  DEFAULT_COMPACTION_BUFFER_RATIO,
@@ -24,31 +25,137 @@ export {
24
25
  } from './context-compaction-policy.mjs';
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
- // Conservative, Unicode-aware token estimator.
28
+ // Token estimation: real o200k_base BPE (tiktoken) + per-provider calibration.
28
29
  //
29
- // This is a SAFETY estimator, NOT exact tokenizer parity. We cannot run the
30
- // provider's real BPE tokenizer here (no network, no bundled vocab), so the
31
- // goal is to never UNDERcount: a transcript the estimator says "fits" must
32
- // genuinely fit the model context once compaction has run. The legacy chars/4
33
- // heuristic badly undercounts Korean/CJK/kana/emoji and dense JSON/tool-call
34
- // payloads (which BPE often splits into >=1 token per character or per byte),
35
- // so a "fits" verdict was optimistic exactly where it mattered most.
30
+ // estimateTokens() encodes the provider-visible text projection with the real
31
+ // o200k_base BPE (tiktoken, same approach as Roo Code / goose / chatbox), so
32
+ // estimates track actual billing instead of a weighted-chars heuristic that
33
+ // diverged -25%..+60% depending on script and provider. The residual
34
+ // provider-specific gap between an o200k count and billed prompt tokens is
35
+ // reconciled by providerTokenCalibration(): measured on prefix-verified live
36
+ // sessions, OpenAI billing matches o200k directly (median ratio 1.00) while
37
+ // Anthropic bills the identical projection at ~1.7x o200k (Claude tokenizer +
38
+ // wire framing, median 1.71). Calibration is applied at the provider-aware
39
+ // aggregation boundary (compaction pressure / context gauge), never inside
40
+ // the provider-agnostic per-message memo.
36
41
  //
37
- // Strategy: weight each code point by how expensive it tends to be under a
38
- // modern BPE tokenizer (cl100k/o200k-class), then take the MAX of that weighted
39
- // sum and the chars/4 ASCII lower bound, then apply a small safety multiplier.
40
- // Weights deliberately lean high (overcount) for CJK/Hangul/emoji.
42
+ // The previous conservative Unicode-weight heuristic is retained verbatim as
43
+ // legacyEstimateTokens(), used only when the tiktoken WASM cannot be loaded
44
+ // (stripped installs / exotic platforms) so estimation degrades safely.
41
45
  //
42
- // MIXDOG_TOKEN_ESTIMATE_SAFETY_MULTIPLIER (default 1.1, clamped 1.0..2.0) lets
43
- // operators dial extra headroom without code changes.
46
+ // MIXDOG_TOKEN_ESTIMATE_SAFETY_MULTIPLIER (default 1.0, clamped 1.0..2.0)
47
+ // still lets operators dial extra headroom without code changes; the
48
+ // provider-usage baseline remains the primary pressure source when aligned.
44
49
  function readSafetyMultiplier() {
45
50
  const raw = Number(process.env.MIXDOG_TOKEN_ESTIMATE_SAFETY_MULTIPLIER);
46
51
  if (Number.isFinite(raw)) return Math.min(2.0, Math.max(1.0, raw));
47
- return 1.1;
52
+ return 1.0;
48
53
  }
49
54
  const TOKEN_ESTIMATE_SAFETY_MULTIPLIER = readSafetyMultiplier();
50
- export const IMAGE_VISUAL_TOKEN_ALLOWANCE = 4_096;
51
- const IMAGE_TILE_TOKEN_ALLOWANCE = 512;
55
+
56
+ // Lazy singleton o200k_base encoder. createRequire keeps the load dynamic for
57
+ // bundlers (TUI esbuild) and lets a missing/broken WASM fall back cleanly.
58
+ const _requireForTokenizer = createRequire(import.meta.url);
59
+ let _bpeEncoder; // undefined = not attempted, null = unavailable
60
+ function bpeEncoder() {
61
+ if (_bpeEncoder !== undefined) return _bpeEncoder;
62
+ try {
63
+ const { Tiktoken } = _requireForTokenizer('tiktoken/lite');
64
+ const o200k = _requireForTokenizer('tiktoken/encoders/o200k_base.json');
65
+ _bpeEncoder = new Tiktoken(o200k.bpe_ranks, o200k.special_tokens, o200k.pat_str);
66
+ } catch {
67
+ _bpeEncoder = null;
68
+ }
69
+ return _bpeEncoder;
70
+ }
71
+
72
+ // Compaction budget loops re-estimate overlapping transcript slices; identical
73
+ // large strings (tool results, system blocks) would re-encode every pass.
74
+ // Hash-keyed LRU (goose token_counter.rs pattern) absorbs the repeats.
75
+ const TOKEN_COUNT_CACHE_MIN_CHARS = 512;
76
+ const TOKEN_COUNT_CACHE_MAX_ENTRIES = 1_024;
77
+ const tokenCountCache = new Map();
78
+ // BPE merge cost is quadratic in WORD length, not text length: a degenerate
79
+ // single-word run ('p'.repeat(250k), base64/minified blobs) forms one giant
80
+ // pat_str word and encode() spins for minutes while the event loop is blocked
81
+ // (observed live via inspector: compact-smoke hung inside tiktoken encode).
82
+ // Encoding fixed-size slices caps the worst-case word at the chunk size, so
83
+ // cost stays linear. Chunk cuts snap back to the nearest whitespace so the
84
+ // next slice starts at a natural ` word` boundary — o200k tokenizes that
85
+ // identically to the unsliced text, keeping estimates EXACT for prose
86
+ // (compact-smoke asserts est === real o200k count). Only a whitespace-free
87
+ // degenerate run falls back to a hard cut (±1 token per boundary, safely
88
+ // inside the estimate's safety multiplier). Hard cuts avoid splitting a
89
+ // surrogate pair so sliced emoji/CJK-ext code points still encode cleanly.
90
+ const BPE_ENCODE_CHUNK_CHARS = 4_096;
91
+ const BPE_CHUNK_BOUNDARY_SCAN = 512;
92
+ function bpeEncodeCount(enc, s) {
93
+ if (s.length <= BPE_ENCODE_CHUNK_CHARS) return enc.encode(s, undefined, []).length;
94
+ let total = 0;
95
+ let i = 0;
96
+ while (i < s.length) {
97
+ let end = Math.min(s.length, i + BPE_ENCODE_CHUNK_CHARS);
98
+ if (end < s.length) {
99
+ // Prefer cutting BEFORE a whitespace run: the next chunk then
100
+ // starts with ` word`, which o200k merges exactly as it would
101
+ // mid-text. Scan a bounded window so degenerate inputs stay O(1).
102
+ let ws = -1;
103
+ const scanFloor = Math.max(i + 1, end - BPE_CHUNK_BOUNDARY_SCAN);
104
+ for (let j = end - 1; j >= scanFloor; j -= 1) {
105
+ const c = s.charCodeAt(j);
106
+ if (c === 0x20 || c === 0x0A || c === 0x0D || c === 0x09) { ws = j; break; }
107
+ }
108
+ if (ws > i) {
109
+ end = ws; // next chunk starts at the whitespace
110
+ } else {
111
+ const last = s.charCodeAt(end - 1);
112
+ if (last >= 0xD800 && last <= 0xDBFF) end += 1;
113
+ }
114
+ }
115
+ total += enc.encode(s.slice(i, end), undefined, []).length;
116
+ i = end;
117
+ }
118
+ return total;
119
+ }
120
+ function bpeTokenCount(enc, s) {
121
+ if (s.length < TOKEN_COUNT_CACHE_MIN_CHARS) return bpeEncodeCount(enc, s);
122
+ const key = `${s.length}:${createHash('sha1').update(s).digest('base64')}`;
123
+ const hit = tokenCountCache.get(key);
124
+ if (hit !== undefined) {
125
+ tokenCountCache.delete(key);
126
+ tokenCountCache.set(key, hit);
127
+ return hit;
128
+ }
129
+ const count = bpeEncodeCount(enc, s);
130
+ if (tokenCountCache.size >= TOKEN_COUNT_CACHE_MAX_ENTRIES) {
131
+ tokenCountCache.delete(tokenCountCache.keys().next().value);
132
+ }
133
+ tokenCountCache.set(key, count);
134
+ return count;
135
+ }
136
+
137
+ // Billed-prompt / o200k-estimate ratio per provider family, measured against
138
+ // prefix-signature-verified provider baselines from real sessions (opaque
139
+ // signature/encrypted payloads excluded from the projection — see
140
+ // stripOpaquePayloads). Env overrides let a deployment recalibrate without a
141
+ // code change; values are clamped to a plausible band.
142
+ function calibrationEnv(name) {
143
+ const n = Number(process.env[name]);
144
+ return Number.isFinite(n) && n > 0 ? Math.min(3, Math.max(0.25, n)) : null;
145
+ }
146
+ export function providerTokenCalibration(provider) {
147
+ const p = String(provider || '').toLowerCase();
148
+ if (p.startsWith('anthropic')) return calibrationEnv('MIXDOG_TOKEN_CALIBRATION_ANTHROPIC') ?? 1.7;
149
+ if (p.startsWith('gemini') || p.startsWith('google')) return calibrationEnv('MIXDOG_TOKEN_CALIBRATION_GEMINI') ?? 1.15;
150
+ return calibrationEnv('MIXDOG_TOKEN_CALIBRATION_DEFAULT') ?? 1.0;
151
+ }
152
+ // Claude Code parity (services/tokenEstimation.ts): images/documents count a
153
+ // flat 2000 tokens when dimensions are unknown — the conservative constant CC
154
+ // shares with microCompact's IMAGE_MAX_TOKEN_SIZE. Known dimensions may only
155
+ // RAISE the allowance via Anthropic's real vision formula (w*h/750), capped at
156
+ // the 2000x2000 resize ceiling (5333 tokens).
157
+ export const IMAGE_VISUAL_TOKEN_ALLOWANCE = 2_000;
158
+ const IMAGE_MAX_TOKEN_ALLOWANCE = 5_333;
52
159
 
53
160
  // Per-code-point token-cost weight. Tuned to overcount, not match exactly.
54
161
  function codePointTokenWeight(cp) {
@@ -86,13 +193,23 @@ function codePointTokenWeight(cp) {
86
193
  return 0.8;
87
194
  }
88
195
 
89
- // Conservative Unicode-aware token estimate. Iterates by code point (so
90
- // surrogate-pair emoji are scored once, at the high emoji weight), takes the
91
- // max of the weighted sum and the chars/4 ASCII floor, then applies the safety
92
- // multiplier. Always overcounts relative to chars/4 for non-ASCII text.
196
+ // Real-BPE token estimate with heuristic fallback. See module header.
93
197
  export function estimateTokens(text) {
94
198
  const s = String(text ?? '');
95
199
  if (s.length === 0) return 0;
200
+ const enc = bpeEncoder();
201
+ if (enc) {
202
+ try {
203
+ return Math.ceil(bpeTokenCount(enc, s) * TOKEN_ESTIMATE_SAFETY_MULTIPLIER);
204
+ } catch { /* corrupt input — degrade to the heuristic below */ }
205
+ }
206
+ return legacyEstimateTokens(s);
207
+ }
208
+
209
+ // Legacy conservative Unicode-aware estimate (fallback only). Iterates by
210
+ // code point, takes the max of the weighted sum and the chars/4 ASCII floor,
211
+ // then applies the safety multiplier.
212
+ function legacyEstimateTokens(s) {
96
213
  let weighted = 0;
97
214
  for (const ch of s) weighted += codePointTokenWeight(ch.codePointAt(0));
98
215
  // Encoded blobs, minified JSON and generated identifiers do not get the
@@ -101,7 +218,9 @@ export function estimateTokens(text) {
101
218
  // for those runs without penalizing ordinary spaced prose.
102
219
  let denseAsciiFloor = 0;
103
220
  for (const match of s.matchAll(/[\x21-\x7e]{16,}/g)) {
104
- denseAsciiFloor += match[0].length * 0.75;
221
+ // Claude Code prices dense JSON/JSONL at chars/2 (bytesPerTokenForFileType);
222
+ // 0.5/char keeps that parity for long unmerged printable runs.
223
+ denseAsciiFloor += match[0].length * 0.5;
105
224
  }
106
225
  const encodedWords = s.match(/\b(?=[A-Za-z0-9]{8,}\b)(?=[A-Za-z0-9]*[A-Za-z])(?=[A-Za-z0-9]*\d)[A-Za-z0-9]+\b/g) || [];
107
226
  if (encodedWords.length >= 3) {
@@ -111,7 +230,7 @@ export function estimateTokens(text) {
111
230
  const encodedChars = encodedWords.reduce((sum, word) => sum + word.length, 0);
112
231
  denseAsciiFloor = Math.max(
113
232
  denseAsciiFloor,
114
- (encodedChars * 0.75) + ((s.length - encodedChars) * 0.25),
233
+ (encodedChars * 0.5) + ((s.length - encodedChars) * 0.25),
115
234
  );
116
235
  }
117
236
  const lines = s.split(/\r?\n/).filter(line => line.trim());
@@ -122,12 +241,35 @@ export function estimateTokens(text) {
122
241
  && (jsonLikeLines >= Math.ceil(lines.length / 2) || structural / nonWhitespace >= 0.12)) {
123
242
  // JSONL, compact tables and generated line protocols can consist
124
243
  // entirely of short runs while still tokenizing like minified data.
125
- denseAsciiFloor = Math.max(denseAsciiFloor, (nonWhitespace * 0.7) + ((s.length - nonWhitespace) * 0.25));
244
+ // chars/2 on the dense payload chars (Claude Code JSON parity).
245
+ denseAsciiFloor = Math.max(denseAsciiFloor, (nonWhitespace * 0.5) + ((s.length - nonWhitespace) * 0.25));
126
246
  }
127
247
  const asciiFloor = s.length / 4; // never below the legacy chars/4 lower bound
128
248
  return Math.ceil(Math.max(weighted, asciiFloor, denseAsciiFloor) * TOKEN_ESTIMATE_SAFETY_MULTIPLIER);
129
249
  }
130
250
 
251
+ // Opaque replay payloads (Anthropic thinking signatures, OpenAI encrypted
252
+ // reasoning blobs, redacted data) are long base64-ish strings that are NOT
253
+ // billed proportionally to their serialized length — including them made
254
+ // estimates swing wildly per turn. Replace them with a fixed marker before
255
+ // token counting; calibration factors were measured against this projection.
256
+ // Stripping is KEY-SCOPED: only fields that structurally carry opaque replay
257
+ // material (signatures / encrypted / redacted / raw data blobs) are eligible,
258
+ // so genuine long model text (e.g. Gemini thought text) keeps its real cost.
259
+ const OPAQUE_PAYLOAD_KEY_RE = /signature|encrypted|redacted|^data$|^blob$/i;
260
+ const OPAQUE_PAYLOAD_RE = /^[A-Za-z0-9+/_=-]{64,}$/;
261
+ function stripOpaquePayloads(value, depth = 0, keyHint = '') {
262
+ if (typeof value === 'string') {
263
+ return OPAQUE_PAYLOAD_KEY_RE.test(keyHint) && value.length >= 64 && OPAQUE_PAYLOAD_RE.test(value)
264
+ ? '[opaque]'
265
+ : value;
266
+ }
267
+ if (depth >= 8 || !value || typeof value !== 'object') return value;
268
+ if (Array.isArray(value)) return value.map((v) => stripOpaquePayloads(v, depth + 1, keyHint));
269
+ const out = {};
270
+ for (const [k, v] of Object.entries(value)) out[k] = stripOpaquePayloads(v, depth + 1, k);
271
+ return out;
272
+ }
131
273
  function nativeBlocksEstimateText(value) {
132
274
  const list = Array.isArray(value) ? value : [value];
133
275
  return list.map((block) => {
@@ -137,7 +279,7 @@ function nativeBlocksEstimateText(value) {
137
279
  type: 'image', width, height, detail,
138
280
  })));
139
281
  }
140
- try { return JSON.stringify(block); }
282
+ try { return JSON.stringify(stripOpaquePayloads(block)); }
141
283
  catch { return String(block ?? ''); }
142
284
  }).join('\n');
143
285
  }
@@ -181,15 +323,18 @@ export function messageEstimateText(m) {
181
323
  }
182
324
  function imageDescriptorAllowance(descriptor) {
183
325
  if (descriptor.width && descriptor.height) {
184
- const tiles = Math.ceil(descriptor.width / 512) * Math.ceil(descriptor.height / 512);
185
- // Caller-supplied dimensions/detail are not uniformly preserved by all
186
- // provider normalizers, so they may raise the allowance for a known
187
- // multi-tile image but can never lower the conservative unknown-image
188
- // fallback. This avoids trusting metadata the provider never sees.
189
- return Math.max(IMAGE_VISUAL_TOKEN_ALLOWANCE, IMAGE_TILE_TOKEN_ALLOWANCE * (tiles + 1));
190
- }
191
- // Unknown-size auto/high images may be tiled by the provider. A single
192
- // 1k allowance is optimistic for common screenshots and documents.
326
+ // Anthropic vision cost: tokens = (width * height) / 750, with images
327
+ // resized down to at most 2000x2000 (5333 tokens) — Claude Code uses
328
+ // the same formula/cap. Caller-supplied dimensions may RAISE the
329
+ // allowance above the unknown-image floor but never lower it (the
330
+ // provider normalizer may not preserve caller metadata).
331
+ const formula = Math.ceil((descriptor.width * descriptor.height) / 750);
332
+ return Math.min(
333
+ IMAGE_MAX_TOKEN_ALLOWANCE,
334
+ Math.max(IMAGE_VISUAL_TOKEN_ALLOWANCE, formula),
335
+ );
336
+ }
337
+ // Unknown-size images: flat conservative allowance (Claude Code parity).
193
338
  return IMAGE_VISUAL_TOKEN_ALLOWANCE;
194
339
  }
195
340
  function messageImageDescriptors(m) {
@@ -203,8 +348,21 @@ function messageImageAllowance(m) {
203
348
  if (!m || typeof m !== 'object') return 0;
204
349
  return messageImageDescriptors(m).reduce((sum, descriptor) => sum + imageDescriptorAllowance(descriptor), 0);
205
350
  }
351
+ // Inline document (PDF) allowance: Anthropic bills ~1,500-3,000 tokens per
352
+ // page and a typical PDF page is ~50-100KB, so ~bytes/16 with a floor and a
353
+ // cap. The base64 payload itself is excluded from text estimates (see
354
+ // jsonFallbackFromPart), so this allowance is the document's entire cost.
355
+ const FILE_TOKEN_ALLOWANCE_FLOOR = 1_500;
356
+ const FILE_MAX_TOKEN_ALLOWANCE = 300_000;
357
+ function messageFileAllowance(m) {
358
+ if (!m || typeof m !== 'object') return 0;
359
+ return contentFileDescriptors(m.content).reduce((sum, descriptor) => sum + Math.min(
360
+ FILE_MAX_TOKEN_ALLOWANCE,
361
+ Math.max(FILE_TOKEN_ALLOWANCE_FLOOR, Math.ceil((descriptor.sizeBytes || 0) / 16)),
362
+ ), 0);
363
+ }
206
364
  export function estimateMessageTokens(m) {
207
- return estimateTokens(messageEstimateText(m)) + messageImageAllowance(m) + 4;
365
+ return estimateTokens(messageEstimateText(m)) + messageImageAllowance(m) + messageFileAllowance(m) + 4;
208
366
  }
209
367
  export function estimateMessagesTokens(messages) {
210
368
  return messages.reduce((sum, m) => sum + estimateMessageTokens(m), 0);
@@ -479,11 +637,6 @@ export function contextMessagesSignature(messages, count = messages?.length) {
479
637
  return hash.digest('hex');
480
638
  }
481
639
 
482
- // Per-request overhead the provider injects that never appears in the
483
- // `messages` array: function-calling preamble + system-prompt framing the
484
- // provider wraps around the request. The chars/4 message estimate misses all
485
- // of it, so a "fits" verdict computed from messages alone is optimistic.
486
- const REQUEST_OVERHEAD_TOKENS = 512;
487
640
  const toolSchemaTokenMemo = new WeakMap();
488
641
  const requestReserveTokenMemo = new WeakMap();
489
642
 
@@ -531,17 +684,16 @@ export function estimateToolSchemaTokens(tools) {
531
684
  }
532
685
 
533
686
  /**
534
- * Total headroom the caller should reserve out of the context window before
535
- * compaction: tool-schema bytes + fixed request framing overhead. Pass this as
536
- * `opts.reserveTokens` so semantic/recall compaction budgets account for
537
- * request-side bytes the message estimate cannot see.
687
+ * Total request-side bytes the caller should reserve out of the context window
688
+ * before compaction. Only serialized tool schemas are counted; providers do
689
+ * not expose a stable framing cost, so no synthetic fixed allowance is added.
538
690
  */
539
691
  export function estimateRequestReserveTokens(tools) {
540
- if (!Array.isArray(tools)) return estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
692
+ if (!Array.isArray(tools)) return estimateToolSchemaTokens(tools);
541
693
  const signature = toolSchemaSignature(tools);
542
694
  const cached = requestReserveTokenMemo.get(tools);
543
695
  if (cached?.signature === signature) return cached.value;
544
- const reserve = estimateToolSchemaTokens(tools) + REQUEST_OVERHEAD_TOKENS;
696
+ const reserve = estimateToolSchemaTokens(tools);
545
697
  requestReserveTokenMemo.set(tools, { signature, value: reserve });
546
698
  return reserve;
547
699
  }
@@ -554,7 +706,7 @@ export function estimateRequestReserveTokens(tools) {
554
706
  *
555
707
  * @param {unknown[]} messages
556
708
  * @param {unknown[]|number} toolsOrReserve tool list or precomputed reserve tokens
557
- * @param {{ messageCount?: number, estimatedMessageTokens?: number }} [opts]
709
+ * @param {{ messageCount?: number, estimatedMessageTokens?: number, provider?: string }} [opts]
558
710
  */
559
711
  export function estimateTranscriptContextUsage(messages, toolsOrReserve, opts = {}) {
560
712
  const list = Array.isArray(messages) ? messages : [];
@@ -566,7 +718,9 @@ export function estimateTranscriptContextUsage(messages, toolsOrReserve, opts =
566
718
  const reserve = typeof toolsOrReserve === 'number' && Number.isFinite(toolsOrReserve)
567
719
  ? Math.max(0, toolsOrReserve)
568
720
  : estimateRequestReserveTokens(toolsOrReserve);
569
- return messageTokens + reserve;
721
+ // Provider-aware calibration reconciles the o200k estimate with actual
722
+ // billing (see providerTokenCalibration). No provider → neutral 1.0.
723
+ return Math.round((messageTokens + reserve) * providerTokenCalibration(opts.provider));
570
724
  }
571
725
 
572
726
  const TOOL_MISSING_STUB = '[Older tool result unavailable after context compaction]';
@@ -1,8 +1,12 @@
1
1
  // Compaction debug/diagnostic helpers extracted from loop.mjs.
2
- import { estimateMessagesTokens } from '../context-utils.mjs';
2
+ import { summarizeContextMessages } from '../context-utils.mjs';
3
3
 
4
4
  export function estimateMessagesTokensSafe(messages) {
5
- try { return estimateMessagesTokens(messages); }
5
+ // summarizeContextMessages is the fingerprint-validated cached form of
6
+ // estimateMessagesTokens (same per-message estimator, accumulated on the
7
+ // live array). The uncached sum cost ~30ms per call on a long session and
8
+ // ran on EVERY pre-send iteration; the cached path is ~1ms warm.
9
+ try { return summarizeContextMessages(messages).estimatedTokens; }
6
10
  catch { return null; }
7
11
  }
8
12
 
@@ -6,6 +6,7 @@ import {
6
6
  contextMessagesSignature,
7
7
  estimateMessagesTokens,
8
8
  estimateRequestReserveTokens,
9
+ providerTokenCalibration,
9
10
  resolveSessionCompactPolicy,
10
11
  toolSchemaSignature,
11
12
  } from '../context-utils.mjs';
@@ -170,13 +171,32 @@ export function resolveWorkerCompactPolicy(sessionRef, tools) {
170
171
  reserveTokens,
171
172
  requestReserveTokens: requestReserve,
172
173
  configuredReserveTokens: configuredReserve,
174
+ provider: sessionRef.provider || null,
175
+ tokenCalibration: providerTokenCalibration(sessionRef.provider),
173
176
  toolSchemaSignature: toolSchemaSignature(tools),
174
177
  };
175
178
  }
176
- /** Transcript + request reserve fallback used until an aligned provider baseline exists. */
179
+ /**
180
+ * Transcript + request reserve fallback used until an aligned provider
181
+ * baseline exists. The transcript estimate and the serialized-tool-schema
182
+ * reserve are both text the provider tokenizes, so the per-provider billing
183
+ * calibration applies to them; a configured operator reserve is a raw token
184
+ * allowance and stays uncalibrated. With no calibration on the policy the
185
+ * result is the exact legacy sum (estimate + reserveTokens).
186
+ */
177
187
  function compactPressureTokens(messageTokensEst, policy) {
178
188
  if (messageTokensEst === null) return 0;
179
- return Math.max(0, messageTokensEst + (policy?.reserveTokens || 0));
189
+ const calibration = Number(policy?.tokenCalibration) > 0 ? Number(policy.tokenCalibration) : 1;
190
+ const configured = Math.max(0, Number(policy?.configuredReserveTokens) || 0);
191
+ const totalReserve = Math.max(0, Number(policy?.reserveTokens) || 0);
192
+ // reserveTokens is the caller-facing override (tests/policies may zero it);
193
+ // the request-schema share can never exceed it.
194
+ const requestReserve = Math.min(
195
+ totalReserve,
196
+ Math.max(0, Number(policy?.requestReserveTokens ?? (totalReserve - configured)) || 0),
197
+ );
198
+ const otherReserve = Math.max(0, totalReserve - requestReserve);
199
+ return Math.max(0, Math.round((messageTokensEst + requestReserve) * calibration) + otherReserve);
180
200
  }
181
201
 
182
202
  function providerPressureTokens(sessionRef, usage) {
@@ -241,6 +261,13 @@ export function invalidateProviderContextBaseline(sessionRef) {
241
261
  sessionRef.lastContextTokensStaleAfterCompact = true;
242
262
  }
243
263
 
264
+ // A baseline is refreshed on every provider_send/turn-end. When the transcript
265
+ // has grown but no fresh recording landed for this long, recording is failing
266
+ // (zero/absent usage keeps the OLD baseline because record() only returns
267
+ // false) — distrust it and fall back to the estimate. An idle session whose
268
+ // transcript did NOT grow keeps its baseline regardless of age.
269
+ const BASELINE_MAX_STALE_GROWTH_MS = 30 * 60 * 1000;
270
+
244
271
  function providerBaselinePressureTokens(messages, sessionRef, policy) {
245
272
  if (!Array.isArray(messages) || !sessionRef
246
273
  || sessionRef.lastContextTokensStaleAfterCompact === true) return null;
@@ -255,6 +282,8 @@ function providerBaselinePressureTokens(messages, sessionRef, policy) {
255
282
  || sessionRef.contextPressureBaselineModel !== (sessionRef.model || null)
256
283
  || sessionRef.contextPressureBaselineToolSignature !== policy?.toolSchemaSignature
257
284
  || sessionRef.contextPressureBaselinePrefixSignature !== contextMessagesSignature(messages, count)) return null;
285
+ if (messages.length > count && baselineAt > 0
286
+ && (Date.now() - baselineAt) > BASELINE_MAX_STALE_GROWTH_MS) return null;
258
287
  if (sessionRef.contextPressureBaselineBoundary === 'request') {
259
288
  const assistantOffset = messages.slice(count).findIndex(message => message?.role === 'assistant');
260
289
  if (assistantOffset >= 0) {
@@ -268,8 +297,11 @@ function providerBaselinePressureTokens(messages, sessionRef, policy) {
268
297
  }
269
298
  }
270
299
  try {
300
+ // Baseline tokens are authoritative provider billing; only the growth
301
+ // after the baseline is a local estimate and needs billing calibration.
302
+ const calibration = Number(policy?.tokenCalibration) > 0 ? Number(policy.tokenCalibration) : 1;
271
303
  const growth = count < messages.length
272
- ? estimateMessagesTokens(messages.slice(count))
304
+ ? Math.round(estimateMessagesTokens(messages.slice(count)) * calibration)
273
305
  : 0;
274
306
  return Math.max(0, tokens + growth + Math.max(0, Number(policy?.configuredReserveTokens) || 0));
275
307
  } catch {
@@ -278,8 +310,20 @@ function providerBaselinePressureTokens(messages, sessionRef, policy) {
278
310
  }
279
311
 
280
312
  export function resolveCompactionPressureTokens(messageTokensEst, policy, { messages, sessionRef } = {}) {
281
- return providerBaselinePressureTokens(messages, sessionRef, policy)
282
- ?? compactPressureTokens(messageTokensEst, policy);
313
+ const baseline = providerBaselinePressureTokens(messages, sessionRef, policy);
314
+ const estimate = compactPressureTokens(messageTokensEst, policy);
315
+ if (baseline == null) return estimate;
316
+ // Sanity band: the baseline exists to correct OVER-counting estimates
317
+ // (dense-data floors can inflate the estimate up to ~2x real usage), so a
318
+ // lower baseline is normally preferred. But a corrupt/stale baseline below
319
+ // HALF the estimate is no longer plausible as an overcount correction —
320
+ // a live session showed ~70% on the gauge while the provider was billing
321
+ // 111% of a 1M window. Beyond that band, trust the transcript estimate for
322
+ // both the gauge and the compaction decision. Erring toward the estimate
323
+ // may compact somewhat early; erring toward a rotten baseline blows past
324
+ // the context window at full token cost.
325
+ if (Number.isFinite(estimate) && estimate > 0 && baseline * 2 < estimate) return estimate;
326
+ return baseline;
283
327
  }
284
328
 
285
329
  /** Telemetry pressure when a reactive overflow retry forces the next compact. */
@@ -331,7 +375,16 @@ export function shouldCompactForSession(messageTokensEst, policy, {
331
375
  const pressure = Number.isFinite(Number(pressureTokens))
332
376
  ? Number(pressureTokens)
333
377
  : resolveCompactionPressureTokens(messageTokensEst, policy, { messages, sessionRef });
334
- return pressure >= (policy.triggerTokens || policy.boundaryTokens);
378
+ const trigger = policy.triggerTokens || policy.boundaryTokens;
379
+ if (pressure >= trigger) return true;
380
+ // Safety net: the provider-usage baseline exists to correct OVER-counting
381
+ // transcript estimates, so a lower baseline-derived pressure is normally
382
+ // preferred. But a stale/wrong baseline must never SUPPRESS compaction
383
+ // once the raw transcript estimate itself has crossed the trigger — that
384
+ // failure mode let a live session sail past a 950k trigger to 1.1M+ real
385
+ // tokens without a single auto compact. The trigger decision (not the
386
+ // gauge) therefore takes the max of both pressure sources.
387
+ return compactPressureTokens(messageTokensEst, policy) >= trigger;
335
388
  }
336
389
  export function countPrunedToolOutputs(before, after) {
337
390
  if (!Array.isArray(before) || !Array.isArray(after)) return 0;
@@ -349,6 +402,11 @@ export function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
349
402
  ? sessionRef.compaction
350
403
  : {};
351
404
  const changed = meta.compactChanged === true || meta.pruneCount > 0;
405
+ // Both are successful terminal pre-send states. In particular,
406
+ // pre_send_check is the no-op path after a prior recovered/failing compact;
407
+ // retaining its old component error makes status report a failure although
408
+ // this send's compaction stage completed successfully.
409
+ const terminalSuccess = meta.stage === 'pre_send' || meta.stage === 'pre_send_check';
352
410
  sessionRef.compaction = {
353
411
  ...prev,
354
412
  auto: policy.auto !== false,
@@ -383,16 +441,22 @@ export function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
383
441
  lastChanged: changed,
384
442
  lastTrigger: meta.trigger || prev.lastTrigger || null,
385
443
  lastSemantic: meta.semanticCompact === true,
386
- lastSemanticError: Object.hasOwn(meta, 'semanticError')
387
- ? (meta.semanticError ?? null)
388
- : (prev.lastSemanticError ?? null),
444
+ lastSemanticError: terminalSuccess
445
+ ? null
446
+ : Object.hasOwn(meta, 'semanticError')
447
+ ? (meta.semanticError ?? null)
448
+ : (prev.lastSemanticError ?? null),
389
449
  lastRecallFastTrack: meta.recallFastTrack === true,
390
- lastRecallFastTrackError: Object.hasOwn(meta, 'recallFastTrackError')
391
- ? (meta.recallFastTrackError ?? null)
392
- : (prev.lastRecallFastTrackError ?? null),
393
- lastError: Object.hasOwn(meta, 'compactError') || Object.hasOwn(meta, 'lastError')
394
- ? (meta.compactError ?? meta.lastError ?? null)
395
- : (prev.lastError ?? null),
450
+ lastRecallFastTrackError: terminalSuccess
451
+ ? null
452
+ : Object.hasOwn(meta, 'recallFastTrackError')
453
+ ? (meta.recallFastTrackError ?? null)
454
+ : (prev.lastRecallFastTrackError ?? null),
455
+ lastError: terminalSuccess
456
+ ? null
457
+ : Object.hasOwn(meta, 'compactError') || Object.hasOwn(meta, 'lastError')
458
+ ? (meta.compactError ?? meta.lastError ?? null)
459
+ : (prev.lastError ?? null),
396
460
  lastPruneCount: meta.pruneCount || 0,
397
461
  lastDurationMs: meta.durationMs != null && Number.isFinite(Number(meta.durationMs))
398
462
  ? Math.max(0, Math.round(Number(meta.durationMs)))
@@ -402,6 +466,23 @@ export function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
402
466
  ? true
403
467
  : prev.singleShotConsumed === true,
404
468
  };
469
+ // Postmortem ring buffer: the per-check telemetry above is overwritten on
470
+ // every stage change, which erased all pre-compact evidence when a session
471
+ // blew past its trigger without compacting. Keep the last few decisions
472
+ // (pressure vs estimate vs trigger plus the live baseline) on the session
473
+ // so a missed-trigger incident is diagnosable after the fact.
474
+ {
475
+ const prior = Array.isArray(prev.recentChecks) ? prev.recentChecks : [];
476
+ sessionRef.compaction.recentChecks = [...prior, {
477
+ at: Date.now(),
478
+ stage: meta.stage || null,
479
+ pressure: meta.pressureTokens ?? null,
480
+ est: meta.beforeTokens ?? null,
481
+ trigger: policy.triggerTokens || policy.boundaryTokens || null,
482
+ baseline: positiveTokenInt(sessionRef.contextPressureBaselineTokens) || null,
483
+ baselineAt: Number(sessionRef.contextPressureBaselineUpdatedAt) || null,
484
+ }].slice(-8);
485
+ }
405
486
  if (changed) {
406
487
  const changedAt = Date.now();
407
488
  sessionRef.compaction.lastChangedAt = changedAt;
@@ -435,3 +516,17 @@ export function emitCompactEvent(opts, event = {}) {
435
516
  export function compactEventType(policy, fallback = DEFAULT_COMPACT_TYPE) {
436
517
  return policy?.compactType || policy?.type || fallback;
437
518
  }
519
+
520
+ // Semantic-summary model override. NO automatic downshift: the runtime cannot
521
+ // know which models a given account/gateway can actually serve (an OAuth plan
522
+ // or relay may not expose Haiku at all), so guessing a cheaper model risks a
523
+ // hard compact failure. The summary runs on the session's own model unless an
524
+ // operator explicitly configures compaction.semanticModel (or the
525
+ // MIXDOG_AGENT_COMPACT_SEMANTIC_MODEL env) — that opt-in is the only override.
526
+ export function resolveSemanticSummaryModel(sessionRef, _opts = {}) {
527
+ const cfg = sessionRef?.compaction && typeof sessionRef.compaction === 'object' ? sessionRef.compaction : {};
528
+ const explicit = String(cfg.semanticModel || '').trim()
529
+ || String(process.env.MIXDOG_AGENT_COMPACT_SEMANTIC_MODEL || '').trim();
530
+ if (explicit) return explicit;
531
+ return null;
532
+ }
@@ -161,6 +161,7 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
161
161
  limit: positiveTokenInt(sessionRef?.compaction?.recallDigestLimit) || 30,
162
162
  includeMembers: true,
163
163
  includeRaw: true,
164
+ compactDigest: true,
164
165
  }, callerCtx);
165
166
  digestBody = typeof browsed === 'string' ? browsed : String(browsed?.text ?? browsed ?? '');
166
167
  } catch (err) {
@@ -213,6 +214,7 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
213
214
  recallText: digestText,
214
215
  query,
215
216
  querySha,
217
+ cwd: sessionRef?.cwd,
216
218
  // Ingest + search both succeeded above, so the memory DB genuinely holds
217
219
  // this transcript and the recall notice is truthful even when the digest
218
220
  // body is small. A failure would have aborted before reaching here.
@@ -9,7 +9,7 @@ import { executeBashSessionTool } from '../../tools/bash-session.mjs';
9
9
  import { executePatchTool } from '../../tools/patch.mjs';
10
10
  import { executeInternalTool, isInternalTool } from '../../internal-tools.mjs';
11
11
  import { normalizeToolEnvelope, makeToolEnvelope } from '../tool-envelope.mjs';
12
- import { getSessionAbortSignal, enqueuePendingMessage, markCompletionEntry } from '../manager.mjs';
12
+ import { getSessionAbortSignal, enqueuePendingMessage, markCompletionEntry, markSessionToolOutputTail } from '../manager.mjs';
13
13
  import { createScopedCacheOutcome } from '../cache/scoped-cache-outcome.mjs';
14
14
  import { modelVisibleToolCompletionMessage } from '../../../../shared/tool-execution-contract.mjs';
15
15
  import { _isScopedCacheableTool } from './tool-classify.mjs';
@@ -81,6 +81,12 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
81
81
  routingSessionId: callerSessionId,
82
82
  clientHostPid: sessionRef?.clientHostPid,
83
83
  notifyFn,
84
+ // Live shell-output tail → session liveness (~1 s cadence from the
85
+ // shell tool's tail timer), surfaced to transcript consumers (desktop
86
+ // running tool cards) via getSessionProgressSnapshot.
87
+ onOutputTail: (tail) => {
88
+ try { markSessionToolOutputTail(callerSessionId, tail); } catch { /* best effort */ }
89
+ },
84
90
  };
85
91
  const beforeToolHook = typeof executeOpts.beforeToolHook === 'function'
86
92
  ? executeOpts.beforeToolHook