mixdog 0.9.19 → 0.9.21

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/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -88,6 +88,12 @@ function indexLiveTurns(live) {
88
88
  function splitTurnStartIndexForBudget(turn, budget) {
89
89
  const { start, end, messages } = turn;
90
90
  for (let i = 0; i < messages.length; i += 1) {
91
+ // A tool result must stay paired with its preceding assistant tool_call,
92
+ // so the preserved tail suffix can never begin at one (mirrors
93
+ // findValidCutIndices). Skip tool-result boundaries; if no non-tool
94
+ // suffix fits the budget, fall through to `end` so the caller keeps the
95
+ // whole turn in the head for summarization instead of orphaning it.
96
+ if (messages[i]?.role === 'tool') continue;
91
97
  const suffixStart = start + i;
92
98
  const suffix = messages.slice(i);
93
99
  if (suffix.length > 0 && estimateMessagesTokens(suffix) <= budget) {
@@ -0,0 +1,153 @@
1
+ // Eager tool-dispatch controller, extracted from agent-loop.mjs. Owns the
2
+ // per-turn pending promise map, the intra-turn in-flight signature set, and
3
+ // the mutation epoch. Read-only tool calls start executing the instant the
4
+ // provider streams a tool_use event so execution overlaps the remaining SSE
5
+ // parse; writes/unknown tools wait for the serial batch loop. Behavior
6
+ // identical to the inline closures it replaced.
7
+ import { normalizeToolEnvelope } from './tool-envelope.mjs';
8
+ import { isInvalidToolArgsMarker } from '../providers/openai-compat-stream.mjs';
9
+ import { _intraTurnSig } from './loop/tool-classify.mjs';
10
+ import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
11
+ import { executeTool } from './loop/tool-exec.mjs';
12
+ import { crossTurnSignature } from './loop/completion-guards.mjs';
13
+ import { getToolKind, isEagerDispatchable } from './loop/tool-helpers.mjs';
14
+
15
+ export function createEagerDispatcher({
16
+ tools, cwd, sessionId, sessionRef, signal, opts,
17
+ crossTurnCalls, getIterations, getNextIteration, repeatFailLimit,
18
+ }) {
19
+ const pending = new Map();
20
+ // Streaming-time intra-turn dedup. When the LLM emits two
21
+ // tool_use blocks with identical (name, args) signatures in
22
+ // sequence, the provider's onToolCall fires for both BEFORE
23
+ // the iter for-body runs, so the batch-level pre-pass would be
24
+ // too late to prevent the eager dispatch of the second one.
25
+ // Track signatures of in-flight eager calls and skip starting a
26
+ // second one for the same sig. The duplicate's executeTool is
27
+ // never invoked; the for-body's pre-pass marks it as a duplicate
28
+ // and emits a stub tool_result. The sig is NOT cleared when the
29
+ // eager promise settles (see finally below): a streaming onToolCall
30
+ // can deliver a same-turn identical call AFTER the first promise
31
+ // settles but BEFORE the deferred cache set (:1256), and the static
32
+ // pre-pass (:909) only runs after send() returns — so clearing the
33
+ // sig on settle would let that second streaming eager call
34
+ // re-execute. A fresh Map() is created per turn, so the sig set
35
+ // resets at the turn boundary without leaking across getIterations().
36
+ const _eagerInFlightSigs = new Map();
37
+ const epoch = { mutation: 0 };
38
+ const startEagerTool = (call) => {
39
+ if (!call?.id || pending.has(call.id) || !isEagerDispatchable(call.name, tools)) return null;
40
+ // Never eager-execute a call whose arguments failed to parse
41
+ // (invalid-args marker). It has no usable arguments; the serial
42
+ // body handles it via the invalid-args feedback path.
43
+ if (isInvalidToolArgsMarker(call.arguments)) return null;
44
+ const _sig = _intraTurnSig(call.name, call.arguments);
45
+ if (_eagerInFlightSigs.has(_sig)) return null;
46
+ // Repeat-failure guard also gates eager dispatch (reviewer-flagged):
47
+ // streaming onToolCall / startEagerRun would otherwise re-run an
48
+ // identical read-only call that already failed repeatFailLimit
49
+ // times before the serial for-body guard runs. Returning null here
50
+ // lets the serial body push the [repeat-failure-guard] stub.
51
+ {
52
+ const _rfg = sessionRef?._repeatFailGuard;
53
+ if (_rfg && _rfg.sig === _sig && _rfg.count >= repeatFailLimit) return null;
54
+ }
55
+ // Cross-turn dedup also gates eager dispatch (mirror of the
56
+ // repeat-failure guard above): a read-only call whose (name,args)
57
+ // signature already ran in an EARLIER turn must NOT be eagerly
58
+ // re-executed — the serial for-body pushes the [cross-turn-dedup]
59
+ // stub instead. Without this gate startEagerRun/onToolCall would
60
+ // re-run the call before the serial dedup check ever sees it.
61
+ {
62
+ const _ctSig = crossTurnSignature(call.name, call.arguments);
63
+ const _prior = crossTurnCalls.get(_ctSig);
64
+ if (_prior && _prior.firstIteration < getIterations()) return null;
65
+ }
66
+ const toolKind = getToolKind(call.name);
67
+ // Shared pre-dispatch deny: identical predicate runs in the
68
+ // serial path below. If any role/permission guard would reject
69
+ // this call there, never start it eagerly here.
70
+ if (preDispatchDenyForSession(sessionRef, call, toolKind) !== null) return null;
71
+ const entry = { startedAt: Date.now(), endedAt: null, mutationEpoch: epoch.mutation };
72
+ _eagerInFlightSigs.set(_sig, call.id);
73
+ entry.promise = (async () => {
74
+ try {
75
+ return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: getNextIteration() }) };
76
+ } catch (error) {
77
+ return { ok: false, error };
78
+ }
79
+ })()
80
+ .then((settled) => {
81
+ entry.endedAt = Date.now();
82
+ // EARLY UI-ONLY NOTIFY (completion-order, NOT history).
83
+ // The serial result-collection loop below `await`s each
84
+ // eager promise strictly in CALL order, so a fast call[1]
85
+ // that settles before a slow call[0] cannot surface its
86
+ // tool card completion until call[0] resolves. Fire
87
+ // onToolResult here — the instant THIS eager tool settles —
88
+ // so parallel cards complete independently in the order they
89
+ // actually finish.
90
+ //
91
+ // This message is NOT pushed into `messages`: provider
92
+ // history ordering stays exactly call-order. The serial loop
93
+ // still builds the REAL tool_result and pushes it via
94
+ // pushToolResultMessage (which fires onToolResult AGAIN for
95
+ // the same toolCallId in call order — the TUI dedupes by id,
96
+ // so the duplicate notify is harmless). __earlyNotify marks
97
+ // this as the pre-history, UI-only signal.
98
+ //
99
+ // Only genuinely-executed eager promises reach here:
100
+ // startEagerTool never creates an entry for dedup /
101
+ // repeat-failure-guard / pre-dispatch-deny / invalid-args
102
+ // calls (they return null above), so those `continue`-before-
103
+ // execution stub paths can never early-notify (contract #5).
104
+ try {
105
+ // UI-only: surface the model-VISIBLE result (envelope
106
+ // stub for envelope returns), never the envelope object
107
+ // or its injected newMessages body — no [object Object],
108
+ // no full skill body in the tool card.
109
+ const _earlyVisible = settled && settled.ok
110
+ ? normalizeToolEnvelope(settled.value).result
111
+ : null;
112
+ const _earlyContent = settled && settled.ok
113
+ ? (typeof _earlyVisible === 'string'
114
+ ? _earlyVisible
115
+ : (_earlyVisible == null ? '' : String(_earlyVisible)))
116
+ : `Error: ${settled && settled.error instanceof Error ? settled.error.message : String(settled && settled.error)}`;
117
+ opts.onToolResult?.({
118
+ role: 'tool',
119
+ toolCallId: call.id,
120
+ content: _earlyContent,
121
+ isError: !(settled && settled.ok),
122
+ __earlyNotify: true,
123
+ });
124
+ } catch { /* best-effort — UI notify must never break the eager path */ }
125
+ // Intentionally do NOT delete _sig here — see the block
126
+ // comment above. The sig must outlive promise settlement
127
+ // so a later same-turn streaming duplicate stays blocked
128
+ // at the _eagerInFlightSigs.has(_sig) guard until the turn
129
+ // boundary recreates the Map.
130
+ return settled;
131
+ });
132
+ pending.set(call.id, entry);
133
+ return entry;
134
+ };
135
+ const startEagerRun = (calls, startIndex, dupSet) => {
136
+ for (let j = startIndex; j < calls.length; j += 1) {
137
+ const call = calls[j];
138
+ if (!call?.id || !isEagerDispatchable(call.name, tools)) break;
139
+ if (dupSet && dupSet.has(call.id)) continue;
140
+ if (!startEagerTool(call) && !pending.has(call.id)) break;
141
+ }
142
+ };
143
+ let _streamEagerBlocked = false;
144
+ const onToolCall = (call) => {
145
+ if (!isEagerDispatchable(call?.name, tools)) {
146
+ _streamEagerBlocked = true;
147
+ return;
148
+ }
149
+ if (_streamEagerBlocked) return;
150
+ startEagerTool(call);
151
+ };
152
+ return { pending, epoch, startEagerTool, startEagerRun, onToolCall };
153
+ }
@@ -67,6 +67,20 @@ function buildRecallDigestText(sessionId, digestBody, maxKb) {
67
67
  ].join('\n');
68
68
  }
69
69
 
70
+ // Abort/cancel detection: a cancelled session (ESC / new prompt / signal abort)
71
+ // surfaces as an AbortError or a DOMException with ABORT_ERR from the internal
72
+ // tool. That is NOT a memory-pipeline failure — rethrow it unchanged so the
73
+ // caller records a cancellation, never an AGENT_CONTEXT_OVERFLOW.
74
+ function isAbortLikeError(err, signal) {
75
+ if (signal?.aborted) return true;
76
+ if (!err) return false;
77
+ const name = err.name || '';
78
+ const code = err.code || '';
79
+ if (name === 'AbortError' || code === 'ABORT_ERR' || code === 'ABORT') return true;
80
+ const msg = String(err.message || err).toLowerCase();
81
+ return /\babort(ed|ing)?\b|\bcancel(l?ed|ling)?\b/.test(msg);
82
+ }
83
+
70
84
  export async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTokens, compactPolicy, sessionId, signal }) {
71
85
  if (!sessionId) throw new Error('recall-fasttrack requires a session id');
72
86
  const startedAt = Date.now();
@@ -103,6 +117,10 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
103
117
  || Math.max(500, Math.min(5000, messages.length || 0));
104
118
  diagnostics.hydrateLimit = hydrateLimit;
105
119
  let t0 = Date.now();
120
+ let ingestFailed = false;
121
+ let searchFailed = false;
122
+ let ingestErr = null;
123
+ let searchErr = null;
106
124
  try {
107
125
  await executeInternalTool('memory', {
108
126
  action: 'ingest_session',
@@ -112,6 +130,8 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
112
130
  limit: hydrateLimit,
113
131
  }, callerCtx);
114
132
  } catch (err) {
133
+ ingestFailed = true;
134
+ ingestErr = err;
115
135
  diagnostics.ingestSkipped = true;
116
136
  diagnostics.ingestError = compactDiagnosticError(err);
117
137
  try { process.stderr.write(`[loop] recall-fasttrack ingest skipped (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
@@ -138,6 +158,8 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
138
158
  }, callerCtx);
139
159
  digestBody = typeof browsed === 'string' ? browsed : String(browsed?.text ?? browsed ?? '');
140
160
  } catch (err) {
161
+ searchFailed = true;
162
+ searchErr = err;
141
163
  diagnostics.cycle1Error = compactDiagnosticError(err);
142
164
  try { process.stderr.write(`[loop] recall-digest browse failed (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
143
165
  }
@@ -145,6 +167,30 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
145
167
  diagnostics.cycle1Skipped = true;
146
168
  diagnostics.cycle1SkipReason = 'digest mode';
147
169
  diagnostics.cycle1Passes = 0;
170
+ // Fail-safe: memory ingest or search failed, so the digest cannot honestly
171
+ // represent "full history is in memory". Do NOT drop head messages behind a
172
+ // false recall notice — abort the fast-track so no context is silently lost
173
+ // for this cycle. The failure is already on stderr above; surface it here
174
+ // too and record it in the diagnostics before throwing.
175
+ if (ingestFailed || searchFailed) {
176
+ diagnostics.totalMs = Date.now() - startedAt;
177
+ diagnostics.failSafeAbort = true;
178
+ compactDebugLog('recall-digest pipeline', diagnostics);
179
+ // Cancellation is not a memory failure: rethrow the original abort error
180
+ // unchanged so the session is marked cancelled, not context-overflow.
181
+ const abortErr = isAbortLikeError(ingestErr, signal) ? ingestErr
182
+ : isAbortLikeError(searchErr, signal) ? searchErr
183
+ : (signal?.aborted ? (ingestErr || searchErr) : null);
184
+ if (abortErr) {
185
+ try { process.stderr.write(`[loop] recall-fasttrack cancelled (sess=${sessionId || 'unknown'}): ${abortErr?.message || abortErr}\n`); } catch {}
186
+ throw abortErr;
187
+ }
188
+ const reason = ingestFailed
189
+ ? (searchFailed ? 'ingest+search failed' : 'ingest failed')
190
+ : 'search failed';
191
+ try { process.stderr.write(`[loop] recall-fasttrack fail-safe abort (sess=${sessionId || 'unknown'}): ${reason} — keeping full history, no recall notice injected\n`); } catch {}
192
+ throw new Error(`recall-fasttrack aborted: memory ${reason}; head preserved`);
193
+ }
148
194
  const digestText = buildRecallDigestText(sessionId, digestBody, digestMaxKb);
149
195
  diagnostics.finalRecallChars = digestText.length;
150
196
  diagnostics.finalRecallBytes = compactByteLength(digestText);
@@ -161,6 +207,9 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
161
207
  recallText: digestText,
162
208
  query,
163
209
  querySha,
210
+ // Ingest + search both succeeded above, so the memory DB genuinely holds
211
+ // this transcript and the recall notice is truthful even when the digest
212
+ // body is small. A failure would have aborted before reaching here.
164
213
  allowEmptyRecall: true,
165
214
  tailTurns: compactPolicy.tailTurns,
166
215
  keepTokens: compactPolicy.keepTokens,
@@ -18,9 +18,17 @@ function compactStoredToolArgString(value, key = '') {
18
18
  const limit = isBody ? STORED_TOOL_ARG_BODY_LIMIT : (isLong ? STORED_TOOL_ARG_LONG_LIMIT : Infinity);
19
19
  if (value.length <= limit) return value;
20
20
  const hash = createHash('sha256').update(value).digest('hex').slice(0, 16);
21
+ const marker = `[mixdog compacted ${key || 'string'}: ${value.length} chars, sha256:${hash}]`;
22
+ // Body args (patch / old_string / new_string / content / rewrite) are
23
+ // apply_patch / edit inputs. Keeping a head/tail preview leaves real patch
24
+ // fragments (a "*** Begin Patch" opening, diff lines) inside a SUCCESSFUL
25
+ // history entry that the model can copy back verbatim as new tool input.
26
+ // Emit the marker ALONE for these keys so nothing copyable survives; a
27
+ // FAILED call still restores the full original via restoreToolCallBodyForId.
28
+ if (isBody) return marker;
21
29
  const head = value.slice(0, STORED_TOOL_ARG_PREVIEW_HEAD).replace(/\r\n/g, '\n');
22
30
  const tail = value.slice(-STORED_TOOL_ARG_PREVIEW_TAIL).replace(/\r\n/g, '\n');
23
- return `[mixdog compacted ${key || 'string'}: ${value.length} chars, sha256:${hash}]\n${head}\n... [middle omitted from stored tool-call args] ...\n${tail}`;
31
+ return `${marker}\n${head}\n... [middle omitted from stored tool-call args] ...\n${tail}`;
24
32
  }
25
33
 
26
34
  function compactStoredToolArgValue(value, key = '', depth = 0) {