mixdog 0.9.4 → 0.9.5

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 (121) hide show
  1. package/README.md +112 -38
  2. package/package.json +4 -1
  3. package/scripts/bench/r5-orchestrated-task.json +7 -0
  4. package/scripts/bench/round-r13-clientmeta.json +464 -0
  5. package/scripts/bench/round-r14-betafeatures.json +466 -0
  6. package/scripts/bench/round-r15-fulldefault.json +462 -0
  7. package/scripts/bench/round-r16-sessionid.json +466 -0
  8. package/scripts/bench/round-r17-wirebytes.json +456 -0
  9. package/scripts/bench/round-r18-prewarm.json +468 -0
  10. package/scripts/bench/round-r19-clean.json +472 -0
  11. package/scripts/bench/round-r20-prewarm-clean.json +475 -0
  12. package/scripts/bench/round-r21-delta-retry.json +473 -0
  13. package/scripts/bench/round-r22-full-probe.json +693 -0
  14. package/scripts/bench/round-r23-itemprobe.json +701 -0
  15. package/scripts/bench/round-r24-shapefix.json +677 -0
  16. package/scripts/bench/round-r25-serial.json +464 -0
  17. package/scripts/bench/round-r26-parallel3.json +671 -0
  18. package/scripts/bench/round-r27-parallel10.json +894 -0
  19. package/scripts/bench/round-r28-parallel10-stagger.json +882 -0
  20. package/scripts/bench/round-r29-parallel10-stagger166.json +886 -0
  21. package/scripts/bench/round-r30-instid.json +253 -0
  22. package/scripts/bench/round-r31-upgradeprobe.json +256 -0
  23. package/scripts/bench/round-r32-vs-codex-lead.json +254 -0
  24. package/scripts/bench/round-r33-vs-codex-codex.json +115 -0
  25. package/scripts/bench/round-r34-orchestrated.json +120 -0
  26. package/scripts/bench/round-r35-orchestrated-codex.json +61 -0
  27. package/scripts/bench/round-r36-orchestrated-capped.json +128 -0
  28. package/scripts/bench-run.mjs +40 -7
  29. package/scripts/internal-comms-bench.mjs +2 -4
  30. package/scripts/model-catalog-audit.mjs +209 -0
  31. package/scripts/model-list-sanitize-test.mjs +37 -0
  32. package/scripts/recall-bench.mjs +76 -13
  33. package/scripts/recall-quality-cases.json +12 -0
  34. package/scripts/tool-smoke.mjs +2 -2
  35. package/src/agents/heavy-worker/AGENT.md +7 -7
  36. package/src/agents/scheduler-task/AGENT.md +2 -3
  37. package/src/agents/webhook-handler/AGENT.md +2 -3
  38. package/src/agents/worker/AGENT.md +9 -10
  39. package/src/app.mjs +12 -1
  40. package/src/headless-role.mjs +7 -1
  41. package/src/mixdog-session-runtime.mjs +15 -14
  42. package/src/output-styles/default.md +14 -18
  43. package/src/output-styles/minimal.md +4 -5
  44. package/src/output-styles/simple.md +7 -8
  45. package/src/rules/agent/00-common.md +6 -5
  46. package/src/rules/agent/30-explorer.md +16 -5
  47. package/src/rules/lead/01-general.md +5 -5
  48. package/src/rules/lead/lead-brief.md +6 -3
  49. package/src/rules/lead/lead-tool.md +6 -4
  50. package/src/rules/shared/01-tool.md +17 -21
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +8 -3
  52. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +19 -0
  53. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -3
  54. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +9 -1
  55. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +53 -9
  56. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +23 -10
  57. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -9
  58. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +33 -4
  59. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +80 -0
  60. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +93 -0
  61. package/src/runtime/agent/orchestrator/session/loop.mjs +27 -5
  62. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +1 -1
  63. package/src/runtime/agent/orchestrator/stall-policy.mjs +20 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  65. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  66. package/src/runtime/channels/backends/discord-gateway.mjs +2 -2
  67. package/src/runtime/channels/backends/discord.mjs +15 -26
  68. package/src/runtime/channels/backends/telegram.mjs +8 -12
  69. package/src/runtime/channels/index.mjs +23 -43
  70. package/src/runtime/channels/lib/backend-dispatch.mjs +5 -3
  71. package/src/runtime/channels/lib/config.mjs +37 -149
  72. package/src/runtime/channels/lib/event-pipeline.mjs +4 -4
  73. package/src/runtime/channels/lib/event-queue.mjs +15 -9
  74. package/src/runtime/channels/lib/inbound-routing.mjs +2 -39
  75. package/src/runtime/channels/lib/interaction-workflows.mjs +5 -113
  76. package/src/runtime/channels/lib/scheduler.mjs +27 -113
  77. package/src/runtime/channels/lib/webhook/deliveries.mjs +3 -2
  78. package/src/runtime/channels/lib/webhook.mjs +8 -47
  79. package/src/runtime/memory/index.mjs +9 -0
  80. package/src/runtime/memory/lib/core-memory-store.mjs +5 -1
  81. package/src/runtime/memory/lib/ko-morph.mjs +195 -0
  82. package/src/runtime/memory/lib/memory-recall-store.mjs +47 -10
  83. package/src/runtime/memory/lib/memory-text-utils.mjs +46 -0
  84. package/src/runtime/memory/lib/query-handlers.mjs +27 -5
  85. package/src/runtime/memory/tool-defs.mjs +2 -2
  86. package/src/runtime/shared/config.mjs +14 -4
  87. package/src/runtime/shared/markdown-frontmatter.mjs +19 -0
  88. package/src/runtime/shared/schedules-store.mjs +13 -3
  89. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  90. package/src/session-runtime/config-helpers.mjs +45 -10
  91. package/src/session-runtime/quick-model-rows.mjs +46 -11
  92. package/src/session-runtime/quick-search-models.mjs +28 -27
  93. package/src/session-runtime/settings-api.mjs +34 -1
  94. package/src/session-runtime/tool-defs.mjs +1 -1
  95. package/src/session-runtime/workflow.mjs +1 -1
  96. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  97. package/src/standalone/agent-tool.mjs +30 -0
  98. package/src/standalone/channel-admin.mjs +102 -90
  99. package/src/standalone/explore-tool.mjs +39 -10
  100. package/src/tui/App.jsx +126 -28
  101. package/src/tui/app/app-format.mjs +11 -4
  102. package/src/tui/app/channel-pickers.mjs +2 -4
  103. package/src/tui/app/maintenance-pickers.mjs +60 -28
  104. package/src/tui/app/model-options.mjs +5 -1
  105. package/src/tui/app/settings-picker.mjs +0 -1
  106. package/src/tui/app/transcript-window.mjs +8 -2
  107. package/src/tui/app/use-transcript-scroll.mjs +3 -1
  108. package/src/tui/app/use-transcript-window.mjs +19 -1
  109. package/src/tui/components/ConfirmBar.jsx +10 -7
  110. package/src/tui/components/Picker.jsx +32 -11
  111. package/src/tui/components/PromptInput.jsx +10 -1
  112. package/src/tui/components/StatusLine.jsx +6 -3
  113. package/src/tui/dist/index.mjs +440 -293
  114. package/src/tui/engine.mjs +48 -12
  115. package/src/tui/index.jsx +11 -52
  116. package/src/ui/statusline.mjs +10 -0
  117. package/src/workflows/default/WORKFLOW.md +15 -17
  118. package/src/workflows/sequential/WORKFLOW.md +15 -17
  119. package/vendor/ink/build/ink.js +9 -1
  120. package/scripts/recall-usecase-probe.json +0 -6
  121. package/src/runtime/channels/lib/holidays.mjs +0 -138
@@ -1,25 +1,21 @@
1
1
  # Tool Use
2
2
 
3
- - Independent lookups MUST batch in one turn; serialize only when a call needs
4
- a prior result. This includes edit loops: batch the post-edit verification
5
- read with the next target's lookup instead of emitting single-call turns.
6
- - Target validity comes first: symbols/callers/deps → `code_graph`; exact text in
7
- a verified scope → `grep`; unknown path/name → `find`; structure → `glob`;
8
- dirs → `list`; verified file → `read`; broad unknown with no anchor →
9
- `explore`. Never call `grep`/`read` on guessed paths.
10
- - Concept normalization comes first: one concept gets one batched
11
- `grep pattern:[...]` with `output_mode:"content_with_context"` (or one
12
- `code_graph symbols[]`). Refine from returned paths; do not repeat equivalent
13
- patterns or scopes.
3
+ - Batch independent lookups in one turn; serialize only when a call needs a
4
+ prior result including edit loops: batch the post-edit verification read
5
+ with the next target's lookup.
6
+ - Pick by target: symbols/callers/deps → `code_graph`; exact text in a verified
7
+ scope → `grep`; unknown path/name → `find`; structure → `glob`; dirs →
8
+ `list`; verified file → `read`; broad unknown with no anchor → `explore`.
9
+ Never `grep`/`read` guessed paths.
10
+ - One concept one batched `grep pattern:[...]` with
11
+ `output_mode:"content_with_context"` (or one `code_graph symbols[]`). Refine
12
+ from returned paths; never repeat equivalent patterns or scopes.
14
13
  - On miss/error, normalize the target once and switch tool; on a plausible hit,
15
- stop searching and answer from the framed context.
16
- - Lookup budget: retrieval exists to reach the NEXT action (edit, answer,
17
- handoff), not to build certainty. One anchor is enough to act on; re-reading
18
- or re-grepping an area you already saw this session is waste. When acting
19
- and looking are both possible, act.
20
- - Avoid read fragmentation: `read` uses `offset`/`limit` only. If you need 2+
21
- spans from one or more known files, make one batched `read` call with
22
- `{path,offset,limit}` region objects instead of serial reads. Adjacent spans
23
- in the same file (within a few hundred lines) are ONE window, not repeated
24
- reads.
14
+ stop and answer from the framed context.
15
+ - Retrieval serves the NEXT action (edit, answer, handoff), not certainty. One
16
+ anchor is enough to act on; re-reading/re-grepping an area seen this session
17
+ is waste. When acting and looking are both possible, act.
18
+ - `read` uses `offset`/`limit` only. For 2+ spans from known file(s), make one
19
+ batched `read` with `{path,offset,limit}` region objects. Adjacent spans in
20
+ one file (within a few hundred lines) are ONE window, not repeated reads.
25
21
  - Don't mix `apply_patch` with shell or other state-changing calls in one turn.
@@ -356,6 +356,10 @@ export function makeAgentDispatch(opts = {}) {
356
356
  if (_idleController) {
357
357
  try { _linkSignal(session.id, _idleController.signal); } catch { /* ignore */ }
358
358
  }
359
+ // Watchdog blind spot guard: when the runtime snapshot is missing AND
360
+ // no progress timestamp exists (pre-liveness hang, swept runtime), the
361
+ // dispatch start time anchors staleness so the abort still fires.
362
+ const _watchdogAnchorTs = Date.now();
359
363
  const _idleTimer = (_idleController && (typeof _getProgressSnapshot === 'function' || typeof _getLastProgressAt === 'function'))
360
364
  ? setInterval(() => {
361
365
  const now = Date.now();
@@ -363,9 +367,10 @@ export function makeAgentDispatch(opts = {}) {
363
367
  const abortErr = snapshot
364
368
  ? evaluateAgentWatchdogAbort(snapshot, now, _watchdogPolicy)
365
369
  : null;
366
- if (!abortErr && !snapshot && typeof _getLastProgressAt === 'function') {
367
- const last = _getLastProgressAt(session.id);
368
- if (_watchdogPolicy.idleStaleMs > 0 && last && now - last > _watchdogPolicy.idleStaleMs) {
370
+ if (!abortErr && !snapshot) {
371
+ const reported = typeof _getLastProgressAt === 'function' ? _getLastProgressAt(session.id) : 0;
372
+ const last = reported || _watchdogAnchorTs;
373
+ if (_watchdogPolicy.idleStaleMs > 0 && now - last > _watchdogPolicy.idleStaleMs) {
369
374
  try { _idleController.abort(new Error(`agent task stale (${_watchdogPolicy.idleStaleMs}ms without progress)`)); } catch { /* ignore */ }
370
375
  }
371
376
  return;
@@ -18,6 +18,10 @@ import { existsSync, readFileSync } from 'fs';
18
18
  import { join } from 'path';
19
19
  import { getPluginData } from '../config.mjs';
20
20
  import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
21
+ import {
22
+ providerCachedModelMetadataSync,
23
+ providerUsesEndpointScopedLimits,
24
+ } from './provider-catalog-cache.mjs';
21
25
 
22
26
  const CATALOG_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
23
27
  const CATALOG_CACHE_FILE = 'litellm-catalog.json';
@@ -367,6 +371,7 @@ export function getModelsDevProviderModelsSync(provider, _test) {
367
371
  export function getModelMetadataSync(id, provider) {
368
372
  if (!id) return null;
369
373
  const mappedProvider = provider ? _modelsDevProviderId(provider) : null;
374
+ const providerNative = provider ? providerCachedModelMetadataSync(provider, id) : null;
370
375
  let meta = null;
371
376
  // 1. Manual overrides — authoritative + offline. Provider-guarded: when a
372
377
  // provider hint is given, an override is only honoured if it belongs to
@@ -402,6 +407,15 @@ export function getModelMetadataSync(id, provider) {
402
407
  const md = _modelsDevMetadataSync(id, provider);
403
408
  if (md) meta = mergeModelMetadata(meta, md, { preserveBaseCosts: metaFromPricingOverride });
404
409
  }
410
+ if (providerUsesEndpointScopedLimits(provider) && !providerNative && meta) {
411
+ // OAuth/backend routes can expose smaller account/backend windows than
412
+ // the public API SKU. External catalogs remain useful for costs and
413
+ // capabilities, but their limits are not authoritative for these routes.
414
+ meta = { ...meta, contextWindow: null, outputTokens: null };
415
+ }
416
+ if (providerNative) {
417
+ meta = mergeModelMetadata(meta, providerNative, { preserveBaseCosts: true });
418
+ }
405
419
  return meta;
406
420
  }
407
421
 
@@ -493,6 +507,11 @@ export async function enrichModels(models) {
493
507
  const row = mappedProvider ? _mdCache?.[mappedProvider]?.models?.[id] : null;
494
508
  const providerMeta = row ? _normalize(_modelsDevRowToOverride(row)) : null;
495
509
  if (providerMeta) meta = mergeModelMetadata(meta, providerMeta, { preserveBaseCosts: metaFromPricingOverride });
510
+ const providerNative = providerCachedModelMetadataSync(m.provider, id);
511
+ if (providerUsesEndpointScopedLimits(m.provider) && !providerNative && meta) {
512
+ meta = { ...meta, contextWindow: null, outputTokens: null };
513
+ }
514
+ if (providerNative) meta = mergeModelMetadata(meta, providerNative, { preserveBaseCosts: true });
496
515
  }
497
516
  if (!meta) return m;
498
517
  return {
@@ -145,6 +145,14 @@ function _releaseEpoch(row) {
145
145
  return Number.isFinite(t) ? t : null;
146
146
  }
147
147
 
148
+ function _stalenessFamily(row, id) {
149
+ const modelId = String(id || row?.id || row?.name || '').trim().toLowerCase();
150
+ const claude = modelId.match(/(?:^|\/)claude-(opus|sonnet|haiku|fable)(?:-|$)/);
151
+ if (claude) return `claude-${claude[1]}`;
152
+ const family = row && typeof row.family === 'string' ? row.family.trim().toLowerCase() : '';
153
+ return family || null;
154
+ }
155
+
148
156
  function _staleMonths() {
149
157
  const raw = process.env.MIXDOG_MODEL_STALE_MONTHS;
150
158
  if (raw == null || raw === '') return 12;
@@ -169,7 +177,7 @@ function _applyAutoStaleness(kept, provider, testCatalog) {
169
177
  return {
170
178
  row,
171
179
  cat: cr,
172
- family: cr && typeof cr.family === 'string' ? cr.family.toLowerCase() : null,
180
+ family: cr ? _stalenessFamily(cr, row.id) : null,
173
181
  epoch: cr ? _releaseEpoch(cr) : null,
174
182
  };
175
183
  });
@@ -179,8 +187,8 @@ function _applyAutoStaleness(kept, provider, testCatalog) {
179
187
  // Compute the newest release_date per family from the FULL catalog so a
180
188
  // superseding model that isn't in this list still counts.
181
189
  const familyNewest = new Map();
182
- for (const cr of Object.values(catModels)) {
183
- const fam = cr && typeof cr.family === 'string' ? cr.family.toLowerCase() : null;
190
+ for (const [id, cr] of Object.entries(catModels)) {
191
+ const fam = _stalenessFamily(cr, id);
184
192
  const ep = _releaseEpoch(cr);
185
193
  if (!fam || ep == null) continue;
186
194
  const prev = familyNewest.get(fam);
@@ -125,7 +125,15 @@ function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey }) {
125
125
  'chatgpt-account-id': auth.account_id || '',
126
126
  'x-client-request-id': randomBytes(16).toString('hex'),
127
127
  };
128
- if (cacheKey) headers.session_id = String(cacheKey);
128
+ if (cacheKey) {
129
+ const sid = String(cacheKey);
130
+ // Codex-native anchors (see openai-ws-pool _buildHandshakeHeaders):
131
+ // `session-id`/`thread-id` (hyphen) match codex-rs headers.rs; legacy
132
+ // underscore `session_id` kept for backward compat.
133
+ headers.session_id = sid;
134
+ headers['session-id'] = sid;
135
+ headers['thread-id'] = sid;
136
+ }
129
137
  return headers;
130
138
  }
131
139
 
@@ -288,6 +288,11 @@ function _codexWsCompatibilityHeaders(context = {}) {
288
288
  const headers = {};
289
289
  if (metadata['x-codex-window-id']) headers['x-codex-window-id'] = metadata['x-codex-window-id'];
290
290
  if (metadata['x-codex-turn-metadata']) headers['x-codex-turn-metadata'] = metadata['x-codex-turn-metadata'];
291
+ // codex sends x-codex-installation-id on EVERY request incl. the WS
292
+ // handshake (client.rs:582-584). We previously carried it only in the
293
+ // body client_metadata; the server may gate x-codex-turn-state issuance
294
+ // on handshake-level client identity, so mirror codex here.
295
+ if (metadata['x-codex-installation-id']) headers['x-codex-installation-id'] = metadata['x-codex-installation-id'];
291
296
  return headers;
292
297
  }
293
298
 
@@ -655,10 +660,16 @@ export async function sendViaWebSocket({
655
660
  let skippedResponseItems = 0;
656
661
  let wireFrameHadTurnState = false;
657
662
  let wireFrameMetadataTrace = _metadataTrace(null);
663
+ let framePrefixHash = null;
664
+ let framePrefixHeadHash = null;
665
+ let framePrefixPrevMatch = null;
658
666
  let result;
659
667
  const streamTimeouts = null;
660
668
  try {
661
- if (warmupBody && typeof warmupBody === 'object' && attemptIndex === 0) {
669
+ // codex prewarm gate (client.rs:1686-1688): only when the session
670
+ // has no prior request state. A reused pooled socket with a live
671
+ // chain must go straight to the real request.
672
+ if (warmupBody && typeof warmupBody === 'object' && attemptIndex === 0 && !entry.lastResponseId) {
662
673
  const warmupFrame = { type: 'response.create', ...warmupBody };
663
674
  const wireWarmupFrame = _withCodexWsClientMetadata(warmupFrame, entry, useCodexWsClientMetadata, codexMetadataContext);
664
675
  wireFrameHadTurnState = !!wireWarmupFrame?.client_metadata?.['x-codex-turn-state'];
@@ -723,13 +734,15 @@ export async function sendViaWebSocket({
723
734
  payload: warmupPayload,
724
735
  });
725
736
  } catch {}
726
- requestBody = { ...body, previous_response_id: warmupResult.responseId };
727
- delete requestBody.instructions;
728
- delete requestBody.generate;
729
- entry.lastRequestSansInput = _stableStringify(_sansInput({
730
- ...requestBody,
731
- input: warmupInputArr,
732
- }));
737
+ // Do NOT rewrite the main request after warmup (R23 finding).
738
+ // The old prev_id+no-instructions rewrite made it=1's frame a
739
+ // different shape from it=2+ full frames, so the first real
740
+ // full-frame cache write only landed at it=2 and it=3 raced
741
+ // its propagation (cached=0 early misses). Keeping it=1 as a
742
+ // normal full frame makes it byte-identical to the warmup's
743
+ // prefix (instant full hit on the cache warmup just wrote)
744
+ // and keeps every subsequent frame one consistent shape.
745
+ // Delta opt-in still chains via entry.lastResponseId above.
733
746
  }
734
747
 
735
748
  const delta = _computeDelta({ entry, body: requestBody });
@@ -741,6 +754,30 @@ export async function sendViaWebSocket({
741
754
  wireFrameHadTurnState = !!wireFrame?.client_metadata?.['x-codex-turn-state'];
742
755
  wireFrameMetadataTrace = _metadataTrace(wireFrame?.client_metadata);
743
756
  deltaTokens = _estimateFrameTokens(wireFrame);
757
+ // Prefix-consistency probe (item-level). Serialized-JSON byte
758
+ // prefixes can never match across appends (the shorter frame ends
759
+ // in "]}" where the longer has ","), so compare what the server's
760
+ // prefix cache actually sees: the non-input request header and the
761
+ // per-item content of the input array. prev_match=true means the
762
+ // current call's header is identical and its first N input items
763
+ // equal the previous call's N items (append-only history).
764
+ try {
765
+ const { client_metadata: _cm, input: frameInput, ...frameHeader } = frame;
766
+ const headerHash = _hashText(JSON.stringify(frameHeader), 16);
767
+ const itemHashes = (Array.isArray(frameInput) ? frameInput : [])
768
+ .map((item) => _hashText(JSON.stringify(item), 12));
769
+ framePrefixHash = headerHash;
770
+ framePrefixHeadHash = _hashText(itemHashes.join(','), 16);
771
+ const prevHeader = entry.lastFrameHeaderHash;
772
+ const prevItems = entry.lastFrameItemHashes;
773
+ if (prevHeader && Array.isArray(prevItems)) {
774
+ framePrefixPrevMatch = headerHash === prevHeader
775
+ && itemHashes.length >= prevItems.length
776
+ && prevItems.every((h, i) => itemHashes[i] === h);
777
+ }
778
+ entry.lastFrameHeaderHash = headerHash;
779
+ entry.lastFrameItemHashes = itemHashes;
780
+ } catch {}
744
781
 
745
782
  // Re-check abort after acquire/warmup — narrow window where
746
783
  // externalSignal could fire between successful acquire and
@@ -895,6 +932,11 @@ export async function sendViaWebSocket({
895
932
  entry.lastInputPrefixHash = null;
896
933
  }
897
934
 
935
+ // Cache observation must see the MAIN request's usage only. Folding
936
+ // warmup usage in first (R18) made prompt_tokens spike on it=1 and
937
+ // then "shrink" on it=2, faking prefix-rewrite/cache-drop signals in
938
+ // every warmup session (debugger 2026-07-03).
939
+ const cacheObservation = _cacheObservation({ entry, result });
898
940
  if (warmupResult?.usage) {
899
941
  result.usage = _combineUsageWithWarmup(result.usage, warmupResult.usage);
900
942
  }
@@ -915,7 +957,6 @@ export async function sendViaWebSocket({
915
957
  provider: traceProvider,
916
958
  serviceTier: responseServiceTier,
917
959
  });
918
- const cacheObservation = _cacheObservation({ entry, result });
919
960
  const requestHasPreviousResponseId = typeof frame.previous_response_id === 'string' && frame.previous_response_id.length > 0;
920
961
  const transportCacheKeyHash = cacheKey
921
962
  ? createHash('sha256').update(String(cacheKey)).digest('hex').slice(0, 12)
@@ -987,6 +1028,9 @@ export async function sendViaWebSocket({
987
1028
  actual_cache_miss_reason: cacheObservation.missReason,
988
1029
  previous_max_cached_tokens: cacheObservation.previousMaxCached,
989
1030
  cache_drop_threshold_tokens: cacheObservation.dropThreshold,
1031
+ frame_prefix_hash: framePrefixHash,
1032
+ frame_prefix_head_hash: framePrefixHeadHash,
1033
+ frame_prefix_prev_match: framePrefixPrevMatch,
990
1034
  ws_client_metadata: useCodexWsClientMetadata,
991
1035
  ws_client_metadata_key_count: wireFrameMetadataTrace.count,
992
1036
  ws_client_metadata_hash: wireFrameMetadataTrace.hash,
@@ -564,9 +564,11 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
564
564
  // normalizes `ultra` -> `max` on the wire (reasoning_effort_for_request
565
565
  // in core/src/client.rs); the openai-oauth backend does not accept
566
566
  // `ultra` as a wire value, so mirror that mapping here.
567
- ...(_codexModelSupportsReasoningSummaries(model)
568
- ? { reasoning: { effort: _normalizeReasoningEffort(opts.effort), summary: 'auto' } }
569
- : {}),
567
+ // WIRE-VERIFIED (codex desktop logs_2.sqlite, 40 response.create
568
+ // captures, 2026-07-03): codex sends reasoning as {"effort":"..."}
569
+ // with NO summary field on gpt-5.5, regardless of what the repo's
570
+ // build_reasoning() suggests. Match the observed bytes.
571
+ reasoning: { effort: _normalizeReasoningEffort(opts.effort) },
570
572
  store: process.env.MIXDOG_OAI_STORE === 'true' ? true : false,
571
573
  stream: true,
572
574
  include,
@@ -612,13 +614,12 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
612
614
  cacheLaneSlot: promptCacheLane.slot,
613
615
  cacheLaneShards: promptCacheLane.shards,
614
616
  });
615
- // codex only serializes `text` when a verbosity/schema is configured
616
- // (common.rs create_text_param_for_request returns None otherwise); it has
617
- // no unconditional { verbosity: 'medium' }. Sending an extra text object
618
- // shifts every following byte vs codex's prefix layout.
619
- const verbosity = typeof opts.verbosity === 'string' && opts.verbosity.trim()
617
+ // WIRE-VERIFIED (codex desktop logs, 2026-07-03): every live gpt-5.5
618
+ // response.create carries text:{"verbosity":"low"} (or a schema variant);
619
+ // none omit the field. Default to codex's observed "low", allow override.
620
+ const verbosity = (typeof opts.verbosity === 'string' && opts.verbosity.trim()
620
621
  ? opts.verbosity.trim().toLowerCase()
621
- : null;
622
+ : null) || 'low';
622
623
  // Rebuild the body in codex struct order so JSON serialization is
623
624
  // byte-compatible with codex: ... input, tools, tool_choice,
624
625
  // parallel_tool_calls, reasoning, store, stream, include, service_tier,
@@ -636,7 +637,7 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
636
637
  include: body.include,
637
638
  ...(body.service_tier ? { service_tier: body.service_tier } : {}),
638
639
  prompt_cache_key: promptCacheKey,
639
- ...(verbosity ? { text: { verbosity } } : {}),
640
+ text: { verbosity },
640
641
  ...(body.max_output_tokens ? { max_output_tokens: body.max_output_tokens } : {}),
641
642
  };
642
643
  // NOTE: prompt_cache_retention is a public OpenAI Responses API parameter,
@@ -911,6 +912,18 @@ export class OpenAIOAuthProvider {
911
912
  useModel,
912
913
  displayModel: _displayCodexModel,
913
914
  forceFresh,
915
+ // codex-parity prewarm (generate:false full frame on a fresh
916
+ // socket, wire-verified 2026-07-03). DEFAULT ON: R19(off) vs
917
+ // R20(on) A/B shows prewarm removes ALL early-session zero-cache
918
+ // misses (4 -> 0 at it<=3; zero-miss 3 -> 0) by writing the cache
919
+ // and waiting for completion before the first real turn, exactly
920
+ // like codex prewarm_websocket (client.rs:1673-1705). Cost is one
921
+ // small (~5k tok) generate:false request per fresh socket, far
922
+ // below the 5-12k uncached tokens each early miss burned.
923
+ // Mid-session partial drops are server-side and unaffected.
924
+ warmupBody: _envFlag('MIXDOG_OPENAI_OAUTH_WS_WARMUP', true)
925
+ ? { ...body, generate: false }
926
+ : null,
914
927
  });
915
928
  if (opts.forceHttpFallback === true
916
929
  || httpFallbackActive()
@@ -171,15 +171,17 @@ export function _stripResponseItemsFromHead(items, responseItems) {
171
171
  }
172
172
 
173
173
  export function _computeDelta({ entry, body }) {
174
- // Codex-aligned default: reuse the cached WS response chain by sending
175
- // previous_response_id plus only the incremental input tail when the
176
- // current request is a strict extension of the last one. Keep a full-frame
177
- // escape hatch for A/B runs and emergency rollback.
178
- const deltaSetting = String(process.env.MIXDOG_OAI_WS_DELTA || '').trim().toLowerCase();
179
- const forceFull = ['1', 'true', 'yes', 'on'].includes(String(process.env.MIXDOG_OAI_WS_FULL || '').trim().toLowerCase())
180
- || ['0', 'false', 'no', 'off'].includes(deltaSetting);
181
- if (forceFull) {
182
- return { mode: 'full', reason: 'full_forced', frame: { type: 'response.create', ...body } };
174
+ // DEFAULT: full-frame sends. codex's delta path is only cache-safe with
175
+ // the x-codex-turn-state sticky-routing token, which the backend issues to
176
+ // codex but never to us (R11-R14 2026-07-03: zero turn-state events across
177
+ // 200+ calls despite UA/version/beta-features/client_metadata parity;
178
+ // delta measured 18-28% warm miss vs full-frame 0.0%). Without the sticky
179
+ // token, previous_response_id requests hop cache nodes and only the first
180
+ // prefix blocks hit. Re-enable delta explicitly via MIXDOG_OAI_WS_DELTA=1
181
+ // for future probes if the backend starts issuing turn-state.
182
+ const deltaOptIn = ['1', 'true', 'yes', 'on'].includes(String(process.env.MIXDOG_OAI_WS_DELTA || '').trim().toLowerCase());
183
+ if (!deltaOptIn) {
184
+ return { mode: 'full', reason: 'full_default', frame: { type: 'response.create', ...body } };
183
185
  }
184
186
  if (!entry || !entry.lastRequestSansInput || !entry.lastResponseId) {
185
187
  return { mode: 'full', reason: 'no_anchor', frame: { type: 'response.create', ...body } };
@@ -9,6 +9,7 @@
9
9
  import WebSocket from 'ws';
10
10
  import { errText } from '../../../shared/err-text.mjs';
11
11
  import { createHash, randomBytes } from 'crypto';
12
+ import { appendFileSync } from 'node:fs';
12
13
  import { codexUserAgent, codexVersionHeader } from './codex-client-meta.mjs';
13
14
  import {
14
15
  PROVIDER_WS_ACQUIRE_TIMEOUT_MS,
@@ -162,12 +163,22 @@ function _buildHandshakeHeaders({ auth, sessionToken, turnState, cacheKey: _cach
162
163
  // (colder) cache-node class than codex.
163
164
  'User-Agent': codexUserAgent(),
164
165
  'version': codexVersionHeader(),
166
+ // codex advertises enabled beta features on every request incl.
167
+ // the WS handshake (client.rs:1038-1041 via build_responses_headers,
168
+ // session/mod.rs:1006-1027). With a default config the list is
169
+ // exactly "remote_compaction_v2" (features/src/lib.rs: the only
170
+ // always-advertised Stable default-on feature). Servers gate
171
+ // behavior (plausibly incl. x-codex-turn-state issuance) on it.
172
+ 'x-codex-beta-features': process.env.MIXDOG_CODEX_BETA_FEATURES || 'remote_compaction_v2',
165
173
  };
166
174
  const isOpenAiOauth = auth.type !== 'xai' && auth.type !== 'openai-direct';
167
- if (sessionToken && !isOpenAiOauth) {
168
- // openai-direct keeps the legacy underscore header; codex-rs sends no
169
- // underscore session_id on the OAuth WS handshake (client.rs:1033-1057),
170
- // only the dashed session-id/thread-id pair below.
175
+ // codex-rs sends only the dashed session-id/thread-id pair
176
+ // (client.rs:1033-1057), but OUR backend measurements disagree with pure
177
+ // codex parity here: 2026-04-19 probes showed the OAuth backend dedupes
178
+ // its in-memory prefix state by the underscore session_id handshake
179
+ // header, and the only 0.0%-miss full-frame rounds (R7/R8, R15 regressed
180
+ // to 13% after this header was dropped) all had it present. Send both.
181
+ if (sessionToken) {
171
182
  headers['session_id'] = String(sessionToken);
172
183
  }
173
184
  if (isOpenAiOauth && (sessionToken || _cacheKey)) {
@@ -249,6 +260,24 @@ function _openSocket({ auth, sessionToken, turnState, externalSignal, cacheKey,
249
260
  try {
250
261
  const ts = res?.headers?.['x-codex-turn-state'];
251
262
  if (typeof ts === 'string' && ts.length) capturedHeaders.turnState = ts;
263
+ // Probe: dump the full 101-upgrade response header set so we can
264
+ // see what the server actually issues (turn-state investigation).
265
+ if (process.env.MIXDOG_WS_UPGRADE_HEADER_PROBE) {
266
+ const all = res?.headers && typeof res.headers === 'object'
267
+ ? Object.entries(res.headers).map(([k, v]) => `${k}: ${String(v).slice(0, 120)}`).join(' | ')
268
+ : '(none)';
269
+ const line = `[ws-upgrade-probe] ts=${new Date().toISOString()} status=${res?.statusCode} headers={ ${all} }\n`;
270
+ process.stderr.write(line);
271
+ // Bench runners swallow child stderr on success; persist to a
272
+ // file so the probe survives (value of the env var = path, or
273
+ // default under tmp).
274
+ try {
275
+ const probePath = process.env.MIXDOG_WS_UPGRADE_HEADER_PROBE !== '1'
276
+ ? process.env.MIXDOG_WS_UPGRADE_HEADER_PROBE
277
+ : `${process.env.TEMP || process.env.TMPDIR || '.'}/mixdog-ws-upgrade-probe.log`;
278
+ appendFileSync(probePath, line);
279
+ } catch {}
280
+ }
252
281
  } catch {}
253
282
  });
254
283
  socket.once('open', () => {
@@ -0,0 +1,80 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { getPluginData } from '../config.mjs';
4
+
5
+ const PROVIDER_CACHE_FILES = Object.freeze({
6
+ 'openai-oauth': ['openai-oauth-models.json'],
7
+ 'anthropic-oauth': ['anthropic-oauth-models.json'],
8
+ anthropic: ['anthropic-oauth-models.json'],
9
+ gemini: ['gemini-models.json'],
10
+ 'grok-oauth': ['grok-oauth-models.json'],
11
+ });
12
+
13
+ export function providerUsesEndpointScopedLimits(provider) {
14
+ const p = String(provider || '').toLowerCase();
15
+ return p === 'openai-oauth' || p === 'anthropic-oauth' || p === 'grok-oauth';
16
+ }
17
+
18
+ function num(value) {
19
+ const n = Number(value);
20
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
21
+ }
22
+
23
+ function readModelsFromCacheFile(fileName) {
24
+ try {
25
+ const file = join(getPluginData(), fileName);
26
+ if (!existsSync(file)) return [];
27
+ const raw = JSON.parse(readFileSync(file, 'utf-8'));
28
+ return Array.isArray(raw?.models) ? raw.models : (Array.isArray(raw) ? raw : []);
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ function modelAliases(id) {
35
+ const text = String(id || '').trim();
36
+ if (!text) return [];
37
+ const aliases = new Set([text]);
38
+ aliases.add(text.replace(/-\d{4}-\d{2}-\d{2}$/, ''));
39
+ aliases.add(text.replace(/-\d{8}$/, ''));
40
+ return [...aliases].filter(Boolean);
41
+ }
42
+
43
+ function rowId(row) {
44
+ return String(row?.id || row?.name || row?.slug || '').trim();
45
+ }
46
+
47
+ export function getProviderCachedModelSync(provider, model) {
48
+ const p = String(provider || '').toLowerCase();
49
+ const files = PROVIDER_CACHE_FILES[p] || [];
50
+ if (!files.length || !model) return null;
51
+ const aliases = new Set(modelAliases(model));
52
+ for (const fileName of files) {
53
+ for (const row of readModelsFromCacheFile(fileName)) {
54
+ const id = rowId(row);
55
+ if (!id || !aliases.has(id)) continue;
56
+ return row;
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export function providerCachedModelMetadataSync(provider, model) {
63
+ const row = getProviderCachedModelSync(provider, model);
64
+ if (!row) return null;
65
+ return {
66
+ contextWindow: num(row.contextWindow ?? row.context_window ?? row.maxContextWindow ?? row.max_context_window ?? row.max_input_tokens),
67
+ outputTokens: num(row.outputTokens ?? row.maxOutputTokens ?? row.max_output_tokens ?? row.output_token_limit),
68
+ supportsVision: row.supportsVision === true,
69
+ supportsFunctionCalling: row.supportsFunctionCalling === true,
70
+ supportsWebSearch: row.supportsWebSearch === true,
71
+ supportsPromptCaching: row.supportsPromptCaching === true,
72
+ supportsReasoning: row.supportsReasoning === true,
73
+ reasoningOptions: Array.isArray(row.reasoningOptions) ? row.reasoningOptions : [],
74
+ reasoningContentField: row.reasoningContentField || null,
75
+ mode: row.mode || null,
76
+ displayName: row.displayName || row.display || row.name || null,
77
+ name: row.name || row.display || null,
78
+ rawProviderModel: row,
79
+ };
80
+ }
@@ -21,6 +21,53 @@ import {
21
21
  compactDebugLog,
22
22
  } from './compact-debug.mjs';
23
23
  import { positiveTokenInt } from './env.mjs';
24
+ import { TOOL_OUTPUT_MAX_BYTES } from '../../tools/builtin/tool-output-limit.mjs';
25
+
26
+ // ── Digest mode (compaction.recallDigest=true) ─────────────────────────────
27
+ // Instead of folding the FULL chunked session dump into the compacted
28
+ // messages (heavy: cycle1 drain + up to CONTEXT_SHARE_RATIO of the context
29
+ // window), inject a small newest-first digest plus an instruction telling the
30
+ // model to pull details lazily via recall(sessionId/query/period). The memory
31
+ // DB already holds the full session (ingest_session below runs in both
32
+ // modes), and raw rows are embedded synchronously at ingest, so recall serves
33
+ // everything the big injection used to carry.
34
+ // Default digest cap = the SHARED tool-output limit (TOOL_OUTPUT_MAX_BYTES,
35
+ // 50KB default, env MIXDOG_TOOL_OUTPUT_MAX_BYTES) — the digest injection is
36
+ // budgeted like any other tool result, not a special context share.
37
+ // compaction.recallDigestMaxKb still overrides per-session.
38
+ const DIGEST_DEFAULT_MAX_KB = Math.max(1, Math.floor(TOOL_OUTPUT_MAX_BYTES / 1024));
39
+
40
+ // Byte-capped line-boundary truncation. Digest source is newest-first, so
41
+ // keeping the HEAD keeps the newest turns.
42
+ function truncateToKb(text, maxKb) {
43
+ const maxBytes = Math.max(1, maxKb) * 1024;
44
+ const s = String(text || '');
45
+ if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;
46
+ const lines = s.split('\n');
47
+ const out = [];
48
+ let used = 0;
49
+ for (const line of lines) {
50
+ const cost = Buffer.byteLength(line, 'utf8') + 1;
51
+ if (used + cost > maxBytes) break;
52
+ out.push(line);
53
+ used += cost;
54
+ }
55
+ return out.join('\n') + '\n[digest truncated at ' + maxKb + 'KB — pull the rest via recall]';
56
+ }
57
+
58
+ function buildRecallDigestText(sessionId, digestBody, maxKb) {
59
+ // No recall-usage instruction block here: the recall tool description
60
+ // already carries the usage-pattern cheatsheet (tool-defs.mjs), so
61
+ // repeating it per-compaction would be redundant injected tokens. The
62
+ // one-line header marks the compaction boundary and names the session id
63
+ // the model needs for a scoped recall.
64
+ return [
65
+ `[context compacted — session ${sessionId}]`,
66
+ `Full history is in memory — use the recall tool for details beyond this digest.`,
67
+ `Recent digest (newest first):`,
68
+ truncateToKb(digestBody, maxKb),
69
+ ].join('\n');
70
+ }
24
71
 
25
72
  export async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTokens, compactPolicy, sessionId, signal }) {
26
73
  if (!sessionId) throw new Error('recall-fasttrack requires a session id');
@@ -73,6 +120,52 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
73
120
  } finally {
74
121
  diagnostics.ingestMs = Date.now() - t0;
75
122
  }
123
+ // ── Digest mode: skip the dump + cycle1 drain entirely. Pull a small
124
+ // newest-first session browse (recall path: roots + raw fallback merged
125
+ // chronologically), cap it at recallDigestMaxKb, and inject it with a
126
+ // recall-usage instruction. The model pulls older/topical detail lazily.
127
+ const digestMode = sessionRef?.compaction?.recallDigest === true;
128
+ if (digestMode) {
129
+ const digestMaxKb = positiveTokenInt(sessionRef?.compaction?.recallDigestMaxKb) || DIGEST_DEFAULT_MAX_KB;
130
+ let digestBody = '';
131
+ t0 = Date.now();
132
+ try {
133
+ const browsed = await executeInternalTool('memory', {
134
+ action: 'search',
135
+ sessionId,
136
+ limit: positiveTokenInt(sessionRef?.compaction?.recallDigestLimit) || 30,
137
+ includeMembers: true,
138
+ }, callerCtx);
139
+ digestBody = typeof browsed === 'string' ? browsed : String(browsed?.text ?? browsed ?? '');
140
+ } catch (err) {
141
+ diagnostics.cycle1Error = compactDiagnosticError(err);
142
+ try { process.stderr.write(`[loop] recall-digest browse failed (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
143
+ }
144
+ diagnostics.initialDumpMs = Date.now() - t0;
145
+ diagnostics.cycle1Skipped = true;
146
+ diagnostics.cycle1SkipReason = 'digest mode';
147
+ diagnostics.cycle1Passes = 0;
148
+ const digestText = buildRecallDigestText(sessionId, digestBody, digestMaxKb);
149
+ diagnostics.finalRecallChars = digestText.length;
150
+ diagnostics.finalRecallBytes = compactByteLength(digestText);
151
+ const result = recallFastTrackCompactMessages(messages, compactBudgetTokens, {
152
+ reserveTokens: compactPolicy.reserveTokens,
153
+ force: true,
154
+ recallText: digestText,
155
+ query,
156
+ querySha,
157
+ allowEmptyRecall: true,
158
+ tailTurns: compactPolicy.tailTurns,
159
+ keepTokens: compactPolicy.keepTokens,
160
+ preserveRecentTokens: compactPolicy.preserveRecentTokens,
161
+ });
162
+ diagnostics.totalMs = Date.now() - startedAt;
163
+ if (result && typeof result === 'object') {
164
+ result.diagnostics = { ...(result.diagnostics || {}), pipeline: { ...diagnostics, digestMode: true } };
165
+ }
166
+ compactDebugLog('recall-digest pipeline', diagnostics);
167
+ return result;
168
+ }
76
169
  const dumpArgs = {
77
170
  action: 'dump_session_roots',
78
171
  sessionId,