mixdog 0.9.149 → 0.9.151

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 (113) hide show
  1. package/README.md +28 -12
  2. package/package.json +1 -1
  3. package/src/headless-exec.mjs +40 -4
  4. package/src/headless-exec.test.mjs +116 -1
  5. package/src/rules/lead/01-general.md +3 -2
  6. package/src/rules/shared/00-general.md +1 -0
  7. package/src/rules/shared/10-tool-workflow.md +11 -12
  8. package/src/rules/shared/30-exploration.md +4 -3
  9. package/src/rules/shared/60-verification.md +4 -3
  10. package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +0 -2
  11. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +1 -4
  12. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +0 -18
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +0 -3
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -1
  15. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +4 -72
  16. package/src/runtime/agent/orchestrator/session/approval-mode.mjs +0 -8
  17. package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +2 -4
  18. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +18 -33
  19. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +21 -42
  20. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +20 -125
  21. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +50 -8
  22. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.test.mjs +31 -1
  23. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +27 -4
  24. package/src/runtime/agent/orchestrator/session/store/serialize.test.mjs +60 -0
  25. package/src/runtime/agent/orchestrator/session/task-wait-control.mjs +78 -0
  26. package/src/runtime/agent/orchestrator/session/task-wait-control.test.mjs +78 -0
  27. package/src/runtime/agent/orchestrator/tools/builtin/absence-absorption.test.mjs +43 -0
  28. package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
  29. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +8 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
  31. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  32. package/src/runtime/agent/orchestrator/tools/builtin/enoent-outside-project.test.mjs +56 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -2
  34. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +50 -23
  35. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +50 -1
  36. package/src/runtime/agent/orchestrator/tools/builtin/grep-output-budget.test.mjs +31 -0
  37. package/src/runtime/agent/orchestrator/tools/builtin/grep-single-file-rescue.test.mjs +106 -0
  38. package/src/runtime/agent/orchestrator/tools/builtin/lib/absolute-glob-expand.mjs +129 -0
  39. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +2 -1
  40. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
  41. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +9 -2
  42. package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +6 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +50 -2
  44. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +7 -1
  45. package/src/runtime/agent/orchestrator/tools/builtin/noise-dir-visibility.test.mjs +69 -0
  46. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +33 -6
  47. package/src/runtime/agent/orchestrator/tools/builtin/read-glob-survey.test.mjs +71 -0
  48. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +6 -1
  49. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +7 -0
  50. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +54 -8
  51. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +90 -51
  52. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.test.mjs +56 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +25 -0
  54. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.test.mjs +16 -0
  55. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +30 -1
  56. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +60 -5
  57. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +54 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +49 -24
  59. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +1 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
  61. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -1
  62. package/src/runtime/agent/orchestrator/tools/code-graph/aggregate-anchor-relocation.test.mjs +55 -0
  63. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -3
  64. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +16 -0
  65. package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +33 -1
  66. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  67. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +9 -1
  68. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +44 -10
  69. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +57 -5
  70. package/src/runtime/agent/orchestrator/tools/patch/patch-symlink.test.mjs +141 -0
  71. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +7 -3
  72. package/src/runtime/agent/orchestrator/tools/patch/v4a-pure-move.test.mjs +57 -0
  73. package/src/runtime/agent/orchestrator/tools/patch/v4a-section-coalesce.test.mjs +81 -0
  74. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +42 -23
  75. package/src/runtime/media/renditions.mjs +7 -1
  76. package/src/runtime/memory/index.mjs +2 -4
  77. package/src/runtime/memory/lib/pg/process.mjs +29 -2
  78. package/src/runtime/memory/lib/pg/process.test.mjs +36 -0
  79. package/src/runtime/memory/lib/pg/supervisor.mjs +5 -1
  80. package/src/runtime/memory/lib/query-handlers.mjs +13 -19
  81. package/src/runtime/memory/lib/recall-format.mjs +13 -31
  82. package/src/runtime/memory/tool-defs.mjs +2 -2
  83. package/src/runtime/shared/memory-snapshot.mjs +57 -4
  84. package/src/runtime/shared/pristine-execution-contract.json +3 -0
  85. package/src/runtime/shared/pristine-execution.mjs +6 -3
  86. package/src/runtime/shared/tool-surface.mjs +6 -0
  87. package/src/runtime/shared/tool-surface.test.mjs +8 -0
  88. package/src/session-runtime/lifecycle-api.mjs +1 -0
  89. package/src/session-runtime/session-turn-api.mjs +5 -0
  90. package/src/standalone/agent-dispatch-broker.mjs +25 -80
  91. package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
  92. package/src/standalone/channel-worker.mjs +31 -1
  93. package/src/standalone/daemon.mjs +9 -9
  94. package/src/standalone/memory-runtime-proxy.mjs +5 -2
  95. package/src/standalone/session-client.mjs +23 -3
  96. package/src/standalone/session-runtime-host-health.test.mjs +36 -0
  97. package/src/standalone/session-runtime-host.mjs +26 -76
  98. package/src/standalone/session-runtime-worker.mjs +97 -0
  99. package/src/tui/App.jsx +3 -2
  100. package/src/tui/app/use-global-key-input.mjs +9 -0
  101. package/src/tui/app/use-mouse-input.mjs +77 -14
  102. package/src/tui/components/Spinner.jsx +1 -1
  103. package/src/tui/dist/index.mjs +83 -23
  104. package/src/tui/lib/voice-setup.mjs +3 -2
  105. package/src/tui/session/completion-card-restore.test.mjs +18 -0
  106. package/src/tui/session/session-api-ext.mjs +2 -1
  107. package/src/tui/session/session-api.mjs +8 -1
  108. package/src/tui/session/task-wait-submit.test.mjs +80 -0
  109. package/src/tui/session/turn.mjs +58 -6
  110. package/src/tui/spinner-verbs.mjs +1 -0
  111. package/src/workflows/default/WORKFLOW.md +12 -19
  112. package/src/workflows/headless/WORKFLOW.md +11 -0
  113. package/src/workflows/solo/WORKFLOW.md +6 -11
@@ -45,7 +45,6 @@ import {
45
45
  enforceSemanticSummarySchema,
46
46
  fitSemanticSummaryMessage,
47
47
  fitRecallFastTrackSummaryMessage,
48
- stripNestedSummaryHeaderLines,
49
48
  RECALL_TAIL_TRUNCATION_MARKER,
50
49
  RECALL_TAIL_SHORT_TRUNCATION_MARKER,
51
50
  } from './summary.mjs';
@@ -57,7 +56,6 @@ import {
57
56
  fitRecallHandoffText,
58
57
  conversationLinesFromMemoryText,
59
58
  excludeTailFromConversation,
60
- stripWorkingFileSections,
61
59
  } from './handoff.mjs';
62
60
 
63
61
  // Post-compact file re-attachment: re-inject fresh reads
@@ -555,12 +553,12 @@ export function recallFastTrackCompactMessages(messages, budgetTokens, opts = {}
555
553
  //
556
554
  // Turns are anchored on user-role boundaries: each turn = a user message plus
557
555
  // the assistant/tool/system/developer messages that follow it. Recall-fasttrack
558
- // keeps the newest live turn verbatim. Older conversation comes from memory.
556
+ // keeps the newest five live turns verbatim. Older conversation comes from memory.
559
557
  //
560
558
  // Partial tool_call/tool_result pairs that truncation might leave behind are
561
559
  // repaired by sanitizeToolPairs/reconcileDedupStubs in the caller, so pairing
562
560
  // stays valid even after trimming.
563
- const RECALL_TAIL_USER_MAX = 1;
561
+ const RECALL_TAIL_USER_MAX = 5;
564
562
  const RECALL_TAIL_TOKEN_CAP = DEFAULT_COMPACTION_KEEP_TOKENS; // 8k
565
563
  // A caller may request a cap smaller than one structurally valid message. Keep
566
564
  // the cap strict above this unavoidable floor while retaining a real anchor.
@@ -695,10 +693,10 @@ function truncateTailToCap(messages, cap) {
695
693
  return estimateMessagesTokens(sanitized) <= cap ? sanitized : [];
696
694
  }
697
695
 
698
- function splitRecallFitInputs(recallText, previousSummary) {
696
+ function splitRecallFitInputs(recallText) {
699
697
  return {
700
698
  recall: String(recallText || '').trim(),
701
- prior: previousSummary ? stripNestedSummaryHeaderLines(previousSummary) : '',
699
+ prior: '',
702
700
  };
703
701
  }
704
702
 
@@ -799,7 +797,7 @@ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
799
797
  }
800
798
  const sanitized = redactToolCallSecretsInMessages(baseSanitized);
801
799
 
802
- const { system: safeSystem, live, previousSummary } = splitLiveCompactionContext(sanitized);
800
+ const { system: safeSystem, live } = splitLiveCompactionContext(sanitized);
803
801
  const recallTailOpts = {
804
802
  maxUsers: opts.recallTailMaxUsers ?? opts.tailTurns ?? RECALL_TAIL_USER_MAX,
805
803
  tokenCap: (() => {
@@ -815,8 +813,8 @@ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
815
813
  head: recallHead,
816
814
  lastTurnStartIdx = recallHead.length,
817
815
  } = selectRecallPreservedTail(live, recallTailOpts);
818
- const recallFit = splitRecallFitInputs(opts.recallText, previousSummary);
819
- if (recallHead.length === 0 && !previousSummary
816
+ const recallFit = splitRecallFitInputs(opts.recallText);
817
+ if (recallHead.length === 0
820
818
  && !(recallFit.recall || recallFit.prior || opts.allowEmptyRecall === true)) {
821
819
  throw new Error('recallFastTrackCompactMessages: no compactable prior history before preserved tail');
822
820
  }
@@ -848,44 +846,31 @@ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
848
846
  const recallRoom = (Number.isFinite(recallTokenCap) && recallTokenCap > 0)
849
847
  ? Math.min(recallRoomUncapped, Math.max(512, recallTokenCap - tailTokens))
850
848
  : recallRoomUncapped;
851
- const priorWithoutWorkingFiles = stripWorkingFileSections(recallFit.prior);
852
- const priorLines = new Set(
853
- priorWithoutWorkingFiles
854
- .split('\n')
855
- .map((line) => line.trim())
856
- .filter(Boolean),
857
- );
858
- const toolLines = collectToolOutcomeLines(live)
859
- .filter((line) => !priorLines.has(String(line || '').trim()));
860
- const workingFiles = collectWorkingFileGroups(live, undefined, {
849
+ const toolLines = collectToolOutcomeLines(recallHead)
850
+ .filter((line) => !recallFit.recall.includes(String(line || '').trim()));
851
+ const workingFiles = collectWorkingFileGroups(recallHead, undefined, {
861
852
  cwd: opts.cwd,
862
- previousSummary: recallFit.prior,
853
+ previousSummary: '',
863
854
  now: Date.now(),
864
855
  });
865
856
  const conversationLines = excludeTailFromConversation(
866
857
  conversationLinesFromMemoryText(recallFit.recall),
867
858
  recallTail,
868
- ).filter((line) => !priorLines.has(String(line || '').trim()));
859
+ );
869
860
  const composedRecall = composeRecallHandoff({
870
861
  sessionId: opts.sessionId || '',
871
862
  conversationLines,
872
863
  toolLines,
873
864
  workingFiles,
874
865
  });
875
- const priorRoom = priorWithoutWorkingFiles
876
- ? Math.max(256, Math.floor((recallRoom - 200) * 0.55))
877
- : 0;
878
- const currentRoom = Math.max(256, recallRoom - priorRoom - 200);
866
+ const currentRoom = Math.max(256, recallRoom - 200);
879
867
  const fittedRecall = fitRecallHandoffText(composedRecall, currentRoom);
880
- const priorPart = priorWithoutWorkingFiles
881
- ? fitRecallHandoffText(priorWithoutWorkingFiles, priorRoom)
882
- : '';
883
868
  const summaryMessage = fitRecallFastTrackSummaryMessage(
884
869
  oldHistory,
885
870
  fittedRecall,
886
871
  recallRoom,
887
872
  recallMeta,
888
- priorPart,
873
+ '',
889
874
  );
890
875
  if (!summaryMessage) {
891
876
  throw new Error(`recallFastTrackCompactMessages: summary cannot fit remaining budget=${recallRoom}`);
@@ -929,18 +914,18 @@ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
929
914
  recallRoom,
930
915
  recallChars: recallFit.recall.length,
931
916
  recallBytes: textByteLength(recallFit.recall),
932
- priorChars: recallFit.prior.length,
933
- priorBytes: textByteLength(recallFit.prior),
917
+ priorChars: 0,
918
+ priorBytes: 0,
934
919
  summaryMessageChars: summaryContent.length,
935
920
  summaryMessageBytes: textByteLength(summaryContent),
936
921
  recallEmpty: !recallFit.recall,
937
922
  priorEmpty: !recallFit.prior,
938
923
  recallTruncatedInSummary: !!recallFit.recall && !summaryContent.includes(recallFit.recall),
939
- priorTruncatedInSummary: !!priorPart && !summaryContent.includes(priorPart),
924
+ priorTruncatedInSummary: false,
940
925
  tailTruncated: recallTail.some((m) => messageContentHasMarker(m, RECALL_TAIL_TRUNCATION_MARKER) || messageContentHasMarker(m, RECALL_TAIL_SHORT_TRUNCATION_MARKER)),
941
926
  fileReattached: reattach.reattached,
942
927
  tailOptions: recallTailOpts,
943
- previousSummary: !!previousSummary,
928
+ previousSummary: false,
944
929
  durationMs: Date.now() - startedAt,
945
930
  };
946
931
  compactDebugLog('recall-fasttrack result', diagnostics);
@@ -19,43 +19,11 @@ import {
19
19
  compactDebugLog,
20
20
  } from './compact-debug.mjs';
21
21
  import { positiveTokenInt } from './env.mjs';
22
- import { TOOL_OUTPUT_MAX_BYTES } from '../../tools/builtin/tool-output-limit.mjs';
23
22
 
24
23
  // ── Digest injection ────────────────────────────────────────────────────────
25
- // Inject a small newest-first digest plus an instruction telling the model to
26
- // pull details lazily via recall(sessionId/query/period). The memory DB holds
27
- // the full session (ingest_session below), and raw rows are embedded
28
- // synchronously at ingest, so recall serves everything the old full-dump
29
- // injection used to carry.
30
- // Default digest cap = the SHARED tool-output limit (TOOL_OUTPUT_MAX_BYTES,
31
- // 50KB default, env MIXDOG_TOOL_OUTPUT_MAX_BYTES) — the digest injection is
32
- // budgeted like any other tool result, not a special context share.
33
- // compaction.recallDigestMaxKb still overrides per-session.
34
- export const DIGEST_DEFAULT_MAX_KB = Math.max(1, Math.floor(TOOL_OUTPUT_MAX_BYTES / 1024));
35
-
36
- // Byte-capped line-boundary truncation. Digest source is newest-first, so
37
- // keeping the HEAD keeps the newest turns.
38
- // Exported for manager/compaction-runner.mjs (manual//clear digest path) so
39
- // both digest producers share one cap implementation.
40
- export function truncateToKb(text, maxKb) {
41
- const maxBytes = Math.max(1, maxKb) * 1024;
42
- const s = String(text || '');
43
- if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;
44
- const lines = s.split('\n');
45
- const marker = '[digest truncated at ' + maxKb + 'KB]';
46
- const contentBudget = maxBytes - Buffer.byteLength(marker, 'utf8');
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 > contentBudget) break;
52
- out.push(line);
53
- used += cost;
54
- }
55
- return out.length ? out.join('\n') + '\n' + marker : marker;
56
- }
57
-
58
- function buildRecallDigestText(sessionId, digestBody, maxKb) {
24
+ // Memory provides every available summary/raw row. Only the final compaction
25
+ // budget is allowed to reduce the handoff.
26
+ function buildRecallDigestText(sessionId, digestBody) {
59
27
  // No recall-usage instruction block here: the recall tool description
60
28
  // already carries the usage-pattern cheatsheet (tool-defs.mjs), so
61
29
  // repeating it per-compaction would be redundant injected tokens. The
@@ -64,7 +32,7 @@ function buildRecallDigestText(sessionId, digestBody, maxKb) {
64
32
  return [
65
33
  `[context compacted — session ${sessionId}]`,
66
34
  `Recent digest (newest first):`,
67
- truncateToKb(digestBody, maxKb),
35
+ String(digestBody || '').trim(),
68
36
  ].join('\n');
69
37
  }
70
38
 
@@ -88,7 +56,17 @@ export function isUsableRecallDigestText(value) {
88
56
  return !!trimmed && !/^\((?:no results|no current session)\)$/i.test(trimmed);
89
57
  }
90
58
 
91
- export async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTokens, compactPolicy, sessionId, signal }) {
59
+ export const RECALL_FAST_TRACK_TAIL_TURNS = 5;
60
+
61
+ export async function runRecallFastTrackCompact({
62
+ sessionRef,
63
+ messages,
64
+ compactBudgetTokens,
65
+ compactPolicy,
66
+ sessionId,
67
+ signal,
68
+ executeMemorySearch,
69
+ }) {
92
70
  if (!sessionId) throw new Error('recall-fasttrack requires a session id');
93
71
  const startedAt = Date.now();
94
72
  const diagnostics = {
@@ -126,14 +104,15 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
126
104
  // watcher already persisted. Do not retransmit the live transcript through
127
105
  // the memory RPC: that duplicates canonical history and can exceed the
128
106
  // bounded HTTP request body before compaction gets a chance to run.
129
- const digestMaxKb = positiveTokenInt(sessionRef?.compaction?.recallDigestMaxKb) || DIGEST_DEFAULT_MAX_KB;
130
107
  let digestBody = '';
131
108
  t0 = Date.now();
132
109
  try {
133
- const browsed = await executeInternalTool('memory', {
110
+ const searchMemory = typeof executeMemorySearch === 'function'
111
+ ? executeMemorySearch
112
+ : (args, ctx) => executeInternalTool('memory', args, ctx);
113
+ const browsed = await searchMemory({
134
114
  action: 'search',
135
115
  sessionId,
136
- limit: positiveTokenInt(sessionRef?.compaction?.recallDigestLimit) || 100,
137
116
  includeMembers: true,
138
117
  includeRaw: true,
139
118
  compactHandoff: true,
@@ -171,7 +150,7 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
171
150
  try { process.stderr.write(`[loop] recall-fasttrack fail-safe abort (sess=${sessionId || 'unknown'}): stored session unavailable — keeping full history, no recall notice injected\n`); } catch {}
172
151
  throw new Error(`recall-fasttrack aborted: stored session memory unavailable; head preserved`);
173
152
  }
174
- const digestText = buildRecallDigestText(sessionId, digestBody, digestMaxKb);
153
+ const digestText = buildRecallDigestText(sessionId, digestBody);
175
154
  diagnostics.finalRecallChars = digestText.length;
176
155
  diagnostics.finalRecallBytes = compactByteLength(digestText);
177
156
  const contextWindow = positiveTokenInt(compactPolicy?.contextWindow)
@@ -192,7 +171,7 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
192
171
  // Empty/sentinel browse output was rejected above, so the handoff always
193
172
  // contains real stored session context.
194
173
  allowEmptyRecall: false,
195
- tailTurns: compactPolicy.tailTurns,
174
+ tailTurns: RECALL_FAST_TRACK_TAIL_TURNS,
196
175
  keepTokens: compactPolicy.keepTokens,
197
176
  preserveRecentTokens: compactPolicy.preserveRecentTokens,
198
177
  recallTokenCap,
@@ -6,22 +6,17 @@
6
6
  import { createHash } from 'crypto';
7
7
  import { getProvider } from '../../providers/registry.mjs';
8
8
  import {
9
- recallFastTrackCompactMessages,
10
9
  semanticCompactMessages,
11
10
  pruneToolOutputsUnanchored,
12
11
  effectiveBudget as compactEffectiveBudget,
13
12
  compactTypeIsRecallFastTrack,
14
13
  compactTypeIsSemantic,
15
14
  normalizeCompactType,
16
- CONTEXT_SHARE_RATIO,
17
- RECALL_TOKEN_CAP_FLOOR_TOKENS,
18
15
  } from '../compact.mjs';
19
16
  import { estimateMessagesTokens, estimateRequestReserveTokens, estimateTranscriptContextUsage, resolveCompactBufferRatio } from '../context-utils.mjs';
20
17
  import { executeInternalTool } from '../../internal-tools.mjs';
21
18
  import {
22
- truncateToKb,
23
- DIGEST_DEFAULT_MAX_KB,
24
- isUsableRecallDigestText,
19
+ runRecallFastTrackCompact,
25
20
  } from '../loop/recall-fasttrack.mjs';
26
21
  import {
27
22
  positiveContextWindow,
@@ -159,58 +154,6 @@ async function callMemoryBounded(args, callerCtx, timeoutMs, executeMemory = exe
159
154
  try { outer?.removeEventListener?.('abort', onOuterAbort); } catch {}
160
155
  }
161
156
  }
162
- async function runRecallFastTrackForSession(session, sessionId, opts = {}) {
163
- if (!sessionId) throw new Error('recall-fasttrack requires a session id');
164
- const query = `session:${sessionId}:all-chunks`;
165
- const querySha = createHash('sha256').update(query).digest('hex').slice(0, 16);
166
- const callerCtx = {
167
- callerSessionId: sessionId,
168
- callerCwd: session?.cwd || undefined,
169
- routingSessionId: sessionId,
170
- clientHostPid: session?.clientHostPid,
171
- signal: opts.signal || null,
172
- };
173
- const memoryTimeoutMs = recallMemoryTimeoutMs(session);
174
- const executeMemory = typeof opts.executeInternalToolFn === 'function'
175
- ? opts.executeInternalToolFn
176
- : executeInternalTool;
177
- // The transcript watcher already persists this session incrementally.
178
- // Manual/clear compaction reads those rows directly instead of sending the
179
- // entire live message array through the memory RPC again.
180
- let recallText = '';
181
- try {
182
- const browsed = await callMemoryColdStart({
183
- action: 'search',
184
- sessionId,
185
- limit: positiveContextWindow(session?.compaction?.recallDigestLimit) || 100,
186
- includeMembers: true,
187
- includeRaw: true,
188
- compactHandoff: true,
189
- }, callerCtx, memoryTimeoutMs, executeMemory);
190
- recallText = typeof browsed === 'string' ? browsed : String(browsed?.text ?? browsed ?? '');
191
- if (!isUsableRecallDigestText(recallText)) {
192
- throw new Error('memory has no stored history for this session');
193
- }
194
- } catch (err) {
195
- // Without real stored context we cannot safely replace the live head.
196
- // Bail to the semantic fallback rather than injecting a false recall
197
- // handoff.
198
- try { process.stderr.write(`[session] recall-digest browse failed — bailing (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
199
- throw new Error(`recall-fasttrack search failed: ${err?.message || err}`);
200
- }
201
- return {
202
- query,
203
- querySha,
204
- recallText: [
205
- `session_id=${sessionId}`,
206
- // Same byte cap as the loop digest path (recallDigestMaxKb,
207
- // default = shared tool-output limit) — without it the memory
208
- // renderer bounds the browse at ~200 rows × 1000 chars, letting a
209
- // manual//clear compact process a far larger digest than loop's.
210
- truncateToKb(recallText, positiveContextWindow(session?.compaction?.recallDigestMaxKb) || DIGEST_DEFAULT_MAX_KB),
211
- ].map(v => String(v || '').trim()).filter(Boolean).join('\n\n'),
212
- };
213
- }
214
157
  // Element-identity change detection (same approach as loop.mjs messagesArrayChanged): two
215
158
  // arrays are "unchanged" only when same length AND every slot is the same object
216
159
  // reference. Used to reject a no-op prune (which returns a fresh array whose
@@ -307,27 +250,27 @@ export async function runSessionCompaction(session, opts = {}) {
307
250
  let recallFastTrackError = null;
308
251
  if (compactTypeIsRecallFastTrack(compactType)) {
309
252
  try {
310
- const recallPayload = await runRecallFastTrackForSession(session, resolvedSessionId, opts);
311
253
  const contextWindow = positiveContextWindow(session.contextWindow) || boundary;
312
- const recallTokenCap = Math.max(
313
- RECALL_TOKEN_CAP_FLOOR_TOKENS,
314
- Math.floor(contextWindow * CONTEXT_SHARE_RATIO),
315
- );
316
- recallFastTrackResult = recallFastTrackCompactMessages(messages, budget, {
317
- reserveTokens,
318
- force: true,
319
- recallText: recallPayload.recallText,
320
- query: recallPayload.query,
321
- querySha: recallPayload.querySha,
322
- cwd: session.cwd,
323
- // The stored-memory browse above returned real session context;
324
- // empty/sentinel output already threw into semantic fallback.
325
- allowEmptyRecall: false,
326
- tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 1,
327
- keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
328
- preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
329
- recallTokenCap,
254
+ const memoryTimeoutMs = recallMemoryTimeoutMs(session);
255
+ const executeMemory = typeof opts.executeInternalToolFn === 'function'
256
+ ? opts.executeInternalToolFn
257
+ : executeInternalTool;
258
+ recallFastTrackResult = await runRecallFastTrackCompact({
259
+ sessionRef: session,
260
+ messages,
261
+ compactBudgetTokens: budget,
262
+ compactPolicy: {
263
+ reserveTokens,
264
+ contextWindow,
265
+ boundaryTokens: boundary,
266
+ keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
267
+ preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
268
+ },
330
269
  sessionId: resolvedSessionId,
270
+ signal: opts.signal || null,
271
+ executeMemorySearch: (args, callerCtx) => (
272
+ callMemoryColdStart(args, callerCtx, memoryTimeoutMs, executeMemory)
273
+ ),
331
274
  });
332
275
  if (Array.isArray(recallFastTrackResult?.messages)) {
333
276
  compacted = recallFastTrackResult.messages;
@@ -338,54 +281,6 @@ export async function runSessionCompaction(session, opts = {}) {
338
281
  try {
339
282
  process.stderr.write(`[session] recall-fasttrack ${mode} compact failed (sess=${session.id || 'unknown'}): ${err?.message || err}\n`);
340
283
  } catch { /* best-effort */ }
341
- // Degraded-compact fallback: recall-fasttrack failed (empty recall,
342
- // ingest error, fit failure). Before recording a hard failure, try
343
- // the semantic path once so auto-clear/manual compaction still makes
344
- // progress WITHOUT shipping an empty-recall summary. History is only
345
- // replaced when the semantic summary actually succeeds.
346
- if (semanticCompactionEnabledForSession(session)
347
- && provider && typeof provider.send === 'function') {
348
- try {
349
- semanticCompactResult = await semanticCompactMessages(
350
- provider,
351
- messages,
352
- opts.model || resolveSemanticSummaryModel(session, { budgetTokens: budget }) || session.model,
353
- budget,
354
- {
355
- reserveTokens,
356
- providerName: session.provider || provider?.name || null,
357
- sessionId: resolvedSessionId,
358
- cwd: session.cwd,
359
- signal: opts.signal || null,
360
- // Carries the session's wire identity so the
361
- // summary request stays on the session's own
362
- // prefix-cache slot instead of opening a second one.
363
- sendOpts: { session },
364
- promptCacheKey: session.promptCacheKey || null,
365
- providerCacheKey: session.promptCacheKey || null,
366
- timeoutMs: semanticCompactTimeoutMs(session, beforeMessageTokens),
367
- tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
368
- keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
369
- preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
370
- filterOldHistoryForIngest: opts.filterOldHistoryForIngest === true,
371
- force: true,
372
- },
373
- );
374
- if (Array.isArray(semanticCompactResult?.messages)) {
375
- compacted = semanticCompactResult.messages;
376
- compactError = null;
377
- addCompactUsageToSession(session, semanticCompactResult.usage);
378
- try {
379
- process.stderr.write(`[session] degraded compact: recall-fasttrack failed, semantic fallback succeeded (sess=${session.id || 'unknown'}, mode=${mode})\n`);
380
- } catch { /* best-effort */ }
381
- }
382
- } catch (fallbackErr) {
383
- semanticCompactError = fallbackErr;
384
- try {
385
- process.stderr.write(`[session] degraded compact: semantic fallback also failed (sess=${session.id || 'unknown'}): ${fallbackErr?.message || fallbackErr}\n`);
386
- } catch { /* best-effort */ }
387
- }
388
- }
389
284
  }
390
285
  } else if (compactTypeIsSemantic(compactType)) {
391
286
  try {
@@ -40,11 +40,8 @@ import { getAgentRuntimeSync, warnAgentRuntimeResolveFailureOnce } from './agent
40
40
  import { ensureCodexWireSessionId, mintSessionId, mintUuidV7 } from './session-id.mjs';
41
41
  import { providerCacheKey } from './provider-cache-key.mjs';
42
42
  import { clearTurnCheckpoint, recoverTurnCheckpoint } from './turn-checkpoint.mjs';
43
- import {
44
- IMPLICIT_APPROVAL_MODE,
45
- workflowContextForApprovalMode,
46
- } from '../approval-mode.mjs';
47
- import { describeShellStartupPolicy } from '../../tools/builtin/runtime-capabilities.mjs';
43
+ import { IMPLICIT_APPROVAL_MODE } from '../approval-mode.mjs';
44
+ import { describeGitStartupState } from '../../tools/builtin/runtime-capabilities.mjs';
48
45
  import { captureOriginalUserCwd } from '../../../../shared/user-cwd.mjs';
49
46
  import { refreshSessionBp3Environment } from './prompt-utils.mjs';
50
47
 
@@ -314,6 +311,7 @@ export function createSession(opts) {
314
311
  // resolves explicit session signals first and never leaks the daemon's
315
312
  // install root (user-cwd.mjs safe fallback chain).
316
313
  const sessionCwdLine = opts.cwd || captureOriginalUserCwd();
314
+ const wantsGitStartupLine = toolsForRouting.some((tool) => tool?.name === 'git');
317
315
  const shellEnvironmentContext = [
318
316
  sessionCwdLine
319
317
  ? `- Cwd: ${sessionCwdLine} — the active Project root; relative paths and shell commands resolve here.`
@@ -321,8 +319,15 @@ export function createSession(opts) {
321
319
  !ownerIsAgent
322
320
  ? `- Shell: ${process.platform === 'win32' ? 'PowerShell' : 'Bash'}. Use ${process.platform === 'win32' ? 'PowerShell' : 'Bash'} syntax unless the user specifies otherwise.`
323
321
  : '',
324
- toolsForRouting.some((tool) => tool?.name === 'shell')
325
- ? describeShellStartupPolicy()
322
+ // A startup inventory of PATH binaries used to sit here; see
323
+ // runtime-capabilities.mjs for why it cannot be stated truthfully
324
+ // before a command has run. Whether the cwd is inside a repository is
325
+ // different: it is a property of this directory, true at startup and
326
+ // observable without spawning anything, and the git tool cannot infer
327
+ // it. Without it a session spends a call discovering `exited 128`, and
328
+ // repeats it per candidate path.
329
+ wantsGitStartupLine
330
+ ? describeGitStartupState(sessionCwdLine ? { cwd: sessionCwdLine } : {})
326
331
  : '',
327
332
  ].filter(Boolean).join('\n');
328
333
  const { baseRules, stableSystemContext, sessionMarkerCore, sessionEnvironment } = composeSystemPrompt({
@@ -333,7 +338,7 @@ export function createSession(opts) {
333
338
  skipRoleCatalog: !ownerIsAgent,
334
339
  profile: profile || undefined,
335
340
  agent: resolvedAgent,
336
- workflowContext: workflowContextForApprovalMode(opts.workflowContext, opts.approvalMode),
341
+ workflowContext: opts.workflowContext || null,
337
342
  coreMemoryContext: opts.coreMemoryContext || null,
338
343
  skillManifest: buildSkillManifest(skills),
339
344
  environmentContext: shellEnvironmentContext,
@@ -493,6 +498,36 @@ export function contextSeedForRouteUpdate(session, routeChanged, selectedContext
493
498
  : {};
494
499
  }
495
500
 
501
+ // The shared-rules block (BP1) renders tool-conditional variants against the
502
+ // edit dialect the model actually receives (edit vs apply_patch). An
503
+ // empty-session route change swaps the tool surface, so this block must
504
+ // re-render too — otherwise a session created on a GPT default route and
505
+ // switched to Claude keeps apply_patch placement guidance for a tool it can
506
+ // no longer call (and vice versa). The block is identified by EXACT previous
507
+ // content: the old variant is rebuilt from the same inputs and matched, so
508
+ // only the true BP1 block is ever replaced; custom-prompt or agent layouts
509
+ // without that block are left untouched, and a same-dialect switch is a no-op.
510
+ export function _refreshSessionRuleVariantsForModel(session, previousModel) {
511
+ const deny = [
512
+ ...(Array.isArray(session?.disallowedTools) ? session.disallowedTools : []),
513
+ ...(getHiddenAgent(session?.agent || null) ? ['Skill'] : []),
514
+ ...(!isAgentOwner(session) && workflowDisallowsAgentTool(session?.workflow) ? ['agent'] : []),
515
+ ];
516
+ const previousRules = _buildSharedRules({ omitTools: [...deny, unusedModelEditToolName(previousModel)] });
517
+ const nextRules = _buildSharedRules({ omitTools: [...deny, unusedModelEditToolName(session?.model)] });
518
+ if (!previousRules || previousRules === nextRules) return false;
519
+ const messages = Array.isArray(session?.messages) ? session.messages : [];
520
+ const index = messages.findIndex((message) => (
521
+ message?.role === 'system' && !message.cacheTier && message.content === previousRules
522
+ ));
523
+ if (index < 0) return false;
524
+ // Replace, never mutate: session-store delta saves treat stable message
525
+ // references as an append-only prefix, so a fresh object forces the full
526
+ // snapshot that keeps the persisted transcript in sync.
527
+ messages[index] = { ...messages[index], content: nextRules };
528
+ return true;
529
+ }
530
+
496
531
  export function updateSessionRoute(id, route = {}) {
497
532
  if (!id) return null;
498
533
  const session = loadSession(id);
@@ -576,8 +611,15 @@ export function updateSessionRoute(id, route = {}) {
576
611
  for (const key of ['deferredSelectedTools', 'deferredCallableTools', 'deferredDefaultTools', 'deferredDiscoveredTools']) {
577
612
  if (Array.isArray(session[key])) session[key] = filterModelEditToolNames(session[key], session.model);
578
613
  }
614
+ _refreshSessionRuleVariantsForModel(session, previousModel);
579
615
  _preparedResumes.delete(id);
580
616
  }
617
+ // Route fields feed the `# Session` prompt block (Model: … · EFFORT · FAST).
618
+ // Rebuild it here: createSession stamped the block with the creation-time
619
+ // route and set sessionStartMetaInjected, so the ask-time refresh guard
620
+ // skips it and an empty-session route change would otherwise keep the old
621
+ // model line in the system prompt (model self-identity confusion).
622
+ refreshSessionBp3Environment(session, session.cwd);
581
623
  session.updatedAt = Date.now();
582
624
  setLiveSession(session);
583
625
  void saveSessionAsync(session, { expectedGeneration: session.generation })
@@ -1,7 +1,8 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { test } from 'node:test';
3
3
  import { resolveSessionContextMeta } from './context-meta.mjs';
4
- import { contextSeedForRouteUpdate } from './session-lifecycle.mjs';
4
+ import { contextSeedForRouteUpdate, _refreshSessionRuleVariantsForModel } from './session-lifecycle.mjs';
5
+ import { _buildSharedRules } from './rules-cache.mjs';
5
6
 
6
7
  test('a cold Cursor route uses 200k instead of inheriting another model window', () => {
7
8
  const seed = contextSeedForRouteUpdate({
@@ -38,3 +39,32 @@ test('an explicitly selected context window survives a route update', () => {
38
39
  selectedContextWindow: 300_000,
39
40
  });
40
41
  });
42
+
43
+ test('an empty-session route change re-renders the edit-dialect rule variants', () => {
44
+ const gptRules = _buildSharedRules({ omitTools: ['edit'] });
45
+ const claudeRules = _buildSharedRules({ omitTools: ['apply_patch'] });
46
+ assert.notEqual(gptRules, claudeRules);
47
+ const session = {
48
+ model: 'claude-fable-5',
49
+ messages: [
50
+ { role: 'system', content: gptRules },
51
+ { role: 'system', content: 'profile block' },
52
+ { role: 'system', content: 'core block', cacheTier: 'tier3' },
53
+ ],
54
+ };
55
+ // GPT-created session switched to Claude: BP1 flips to the edit variant.
56
+ assert.equal(_refreshSessionRuleVariantsForModel(session, 'gpt-5.6-sol'), true);
57
+ assert.equal(session.messages[0].content, claudeRules);
58
+ assert.match(session.messages[0].content, /`edit`/);
59
+ assert.doesNotMatch(session.messages[0].content, /apply_patch/);
60
+ // Untouched blocks keep their identity and content.
61
+ assert.equal(session.messages[1].content, 'profile block');
62
+ assert.equal(session.messages[2].cacheTier, 'tier3');
63
+ // Same edit dialect on both sides is a no-op.
64
+ assert.equal(_refreshSessionRuleVariantsForModel(session, 'claude-opus-5'), false);
65
+ // A layout without the expected BP1 content is left untouched.
66
+ assert.equal(_refreshSessionRuleVariantsForModel({
67
+ model: 'claude-fable-5',
68
+ messages: [{ role: 'system', content: 'custom prompt' }],
69
+ }, 'gpt-5.6-sol'), false);
70
+ });
@@ -30,22 +30,41 @@ export function _sessionForDisk(session) {
30
30
  // persist a non-canonical message array).
31
31
  // - toolApprovalHook: the askOpts.onToolApproval callback wired for the
32
32
  // turn — a function that must never be serialized.
33
+ // - _providerPrefixGuardState: hashes of the live provider projection.
34
+ // Stored history intentionally omits inline media, so these hashes are
35
+ // valid only for the current runtime and must not survive a reload.
33
36
  const hasTransient = session && typeof session === 'object'
34
37
  && (Object.prototype.hasOwnProperty.call(session, 'liveTurnMessages')
35
- || Object.prototype.hasOwnProperty.call(session, 'toolApprovalHook'));
38
+ || Object.prototype.hasOwnProperty.call(session, 'toolApprovalHook')
39
+ || Object.prototype.hasOwnProperty.call(session, '_providerPrefixGuardState'));
36
40
  const messages = Array.isArray(session?.messages) ? session.messages : null;
37
41
  if (!messages || messages.length === 0) {
38
42
  if (!hasTransient) return session;
39
- const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session;
43
+ const {
44
+ liveTurnMessages: _dropLTM,
45
+ toolApprovalHook: _dropTAH,
46
+ _providerPrefixGuardState: _dropPPGS,
47
+ ...rest
48
+ } = session;
40
49
  return rest;
41
50
  }
42
51
  const out = _messagesForDisk(messages);
43
52
  if (out === messages) {
44
53
  if (!hasTransient) return session;
45
- const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session;
54
+ const {
55
+ liveTurnMessages: _dropLTM,
56
+ toolApprovalHook: _dropTAH,
57
+ _providerPrefixGuardState: _dropPPGS,
58
+ ...rest
59
+ } = session;
46
60
  return rest;
47
61
  }
48
- const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session;
62
+ const {
63
+ liveTurnMessages: _dropLTM,
64
+ toolApprovalHook: _dropTAH,
65
+ _providerPrefixGuardState: _dropPPGS,
66
+ ...rest
67
+ } = session;
49
68
  return { ...rest, messages: out };
50
69
  }
51
70
 
@@ -95,6 +114,10 @@ export function _storedSessionFromFile(dir, filename, ensureLifecycle = true) {
95
114
  const record = readTopLevelLifecycleRecord(text);
96
115
  if (isLifecycleUnreadable(record) || record.id !== storageId) return null;
97
116
  const session = record.doc;
117
+ // Older builds persisted this runtime-local snapshot. Discard it at
118
+ // the reload boundary before sanitized history establishes a new
119
+ // provider prefix baseline.
120
+ delete session._providerPrefixGuardState;
98
121
  return ensureLifecycle ? _ensureLifecycleFields(session) : session;
99
122
  } catch {
100
123
  return null;