mixdog 0.9.33 → 0.9.34

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 (44) hide show
  1. package/package.json +4 -2
  2. package/scripts/compact-trigger-migration-smoke.mjs +37 -31
  3. package/scripts/provider-toolcall-test.mjs +535 -36
  4. package/src/agents/heavy-worker/AGENT.md +3 -4
  5. package/src/agents/worker/AGENT.md +2 -3
  6. package/src/repl.mjs +9 -1
  7. package/src/rules/shared/01-tool.md +4 -2
  8. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +22 -0
  9. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +21 -2
  10. package/src/runtime/agent/orchestrator/providers/gemini.mjs +2 -1
  11. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +15 -9
  12. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +130 -18
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -12
  15. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +131 -0
  16. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +102 -26
  17. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +194 -9
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -2
  19. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -1
  20. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/session/compact.mjs +3 -0
  22. package/src/runtime/agent/orchestrator/session/context-utils.mjs +39 -6
  23. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +15 -23
  24. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +28 -0
  25. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +5 -0
  26. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +25 -0
  27. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +16 -8
  28. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +50 -4
  29. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +53 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +12 -2
  31. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +270 -47
  32. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -2
  33. package/src/runtime/channels/lib/output-forwarder.mjs +7 -1
  34. package/src/runtime/channels/lib/owned-runtime.mjs +5 -2
  35. package/src/runtime/memory/lib/embedding-worker.mjs +18 -3
  36. package/src/runtime/memory/lib/memory-embed.mjs +89 -8
  37. package/src/session-runtime/context-status.mjs +7 -6
  38. package/src/session-runtime/runtime-core.mjs +0 -10
  39. package/src/tui/app/use-transcript-window.mjs +13 -1
  40. package/src/tui/components/StatusLine.jsx +5 -5
  41. package/src/tui/dist/index.mjs +5 -3
  42. package/src/ui/statusline.mjs +4 -10
  43. package/src/vendor/statusline/bin/statusline-route.mjs +4 -5
  44. package/src/vendor/statusline/src/gateway/route-meta.mjs +3 -2
@@ -11,7 +11,7 @@ import { withBuiltinPathLocks } from '../builtin.mjs';
11
11
  import { withAdvisoryLocks } from '../builtin/advisory-lock.mjs';
12
12
  import { wrapMutationRouteOutput } from '../mutation-planner.mjs';
13
13
  import { getPluginData } from '../../config.mjs';
14
- import { prepareInput, isV4APatchInput, hasUnifiedBareV4AHunk, canFallbackCountedUnified, parseV4APatch, isCompactedPlaceholderPatch, salvageV4AOpening } from './parsing.mjs';
14
+ import { prepareInput, isV4APatchInput, hasUnifiedBareV4AHunk, canFallbackCountedUnified, parseV4APatch, parseUnifiedBareV4APatch, parseUnifiedCountedAsV4APatch, isCompactedPlaceholderPatch, salvageV4AOpening } from './parsing.mjs';
15
15
  import {
16
16
  resolveBasePath,
17
17
  resolveV4AEntryPath,
@@ -21,6 +21,8 @@ import {
21
21
  renderParsedUnifiedPatch,
22
22
  rewriteHeaderPaths,
23
23
  preValidateNativeBatch,
24
+ classifyEntry,
25
+ stripDiffPrefix,
24
26
  } from './paths.mjs';
25
27
  import { ensureNativePatchBinaryAvailable } from './native-server.mjs';
26
28
  import { assertPathReachable } from '../builtin/fs-reachability.mjs';
@@ -33,12 +35,264 @@ import {
33
35
  convertV4ASectionsToUnifiedPatch,
34
36
  convertUnifiedBareV4AToUnifiedPatch,
35
37
  convertUnifiedCountedToUnifiedPatchViaV4A,
38
+ isV4ARenameSection,
36
39
  } from './v4a-convert.mjs';
37
40
 
38
41
  function isPatchErrorText(text) {
39
42
  return /^Error:/i.test(String(text ?? '').trimStart());
40
43
  }
41
44
 
45
+ // Apply one "wave" (a set of unique-target parsed entries) via the native
46
+ // (+ JS out-of-base) split. Returns { backend, text } on success or
47
+ // { backend, error } so the caller decides whether earlier waves already
48
+ // committed to disk. Extracted verbatim from the inline applyWave closure so
49
+ // both the default wave loop and sequence mode share identical apply
50
+ // semantics.
51
+ async function applyParsedWave({ parsed: wparsed, entries: wentries, headerRewrites: whr }, basePath, opts) {
52
+ const { fuzz, rejectPartial, dryRun, fuzzy, readStateScope, abortSignal } = opts;
53
+ const insideEntries = wentries.filter((entry) => !isResolvedPathOutsideBase(entry.fullPath, basePath));
54
+ const outsideEntries = wentries.filter((entry) => isResolvedPathOutsideBase(entry.fullPath, basePath));
55
+ const parsedInside = (wparsed || []).filter(
56
+ (entry) => !isResolvedPathOutsideBase(parsedEntryResolvedPath(entry, basePath), basePath),
57
+ );
58
+ const backend = outsideEntries.length > 0
59
+ ? (insideEntries.length > 0 ? 'native+js-patch' : 'js-patch')
60
+ : 'native-patch';
61
+ const resultParts = [];
62
+ if (insideEntries.length > 0) {
63
+ const nativePatchStr = rewriteHeaderPaths(renderParsedUnifiedPatch(parsedInside), whr);
64
+ const nativeResult = await dispatchNativePatch({
65
+ entries: insideEntries,
66
+ basePath,
67
+ nativePatchStr,
68
+ fuzz,
69
+ rejectPartial,
70
+ dryRun,
71
+ readStateScope,
72
+ signal: abortSignal,
73
+ parsed: parsedInside,
74
+ });
75
+ if (isPatchErrorText(nativeResult)) return { backend, error: nativeResult };
76
+ resultParts.push(nativeResult);
77
+ }
78
+ if (outsideEntries.length > 0) {
79
+ // Out-of-base targets are applied via the JS dispatcher (no base-path
80
+ // confinement); write permission is enforced at the hook layer.
81
+ const jsResult = await dispatchJsPatchEntries({
82
+ rows: outsideEntries,
83
+ parsed: wparsed,
84
+ basePath,
85
+ dryRun,
86
+ fuzzy,
87
+ readStateScope,
88
+ });
89
+ if (isPatchErrorText(jsResult)) return { backend, error: jsResult };
90
+ resultParts.push(jsResult);
91
+ }
92
+ return { backend, text: resultParts.join('\n') };
93
+ }
94
+
95
+ // Default ordered section mode. Apply each file section in listed order,
96
+ // converting every V4A section against the CURRENT on-disk state (i.e. after
97
+ // all earlier sections have committed), and stop at the first section that
98
+ // fails. Reports applied / failed / skipped reflecting true disk state.
99
+ async function applyPatchSequence(patchStr, requestedFormat, basePath, ctx) {
100
+ const {
101
+ v4aConvertOpts, dryRun, fuzz, fuzzy, rejectPartial,
102
+ readStateScope, abortSignal, mutationPlan,
103
+ } = ctx;
104
+
105
+ // Build the ordered section "units". Each unit resolves its own parsed
106
+ // unified entry lazily via buildParsed(), so a V4A section is converted
107
+ // only when it is its turn — against disk mutated by the earlier sections.
108
+ const units = [];
109
+ // Build a per-section unit from a V4A-style section. Conversion is deferred
110
+ // into buildParsed() so each section is resolved against the disk state the
111
+ // earlier sections left behind — a later section that fails to convert/apply
112
+ // never blocks the earlier ones from committing (ordered-stop). This is
113
+ // shared by the V4A path AND the bare-@@/counted-unified fallbacks, so those
114
+ // salvageable formats keep ordered-stop instead of aborting whole-patch.
115
+ const pushSectionUnit = (section) => {
116
+ const displayPath = normalizeOutputPath(section.path);
117
+ const fullPath = resolveV4AEntryPath(basePath, section.path);
118
+ // A V4A rename cannot be sequenced, but ordered-stop requires we still
119
+ // apply every section BEFORE it and surface the rename as the failed
120
+ // section — never abort before earlier sections commit. Defer the
121
+ // rejection into buildParsed() so the loop marks it failed and keeps
122
+ // earlier commits.
123
+ if (isV4ARenameSection(section)) {
124
+ units.push({
125
+ displayPath,
126
+ fullPath,
127
+ buildParsed: async () => {
128
+ throw new Error('sequence mode does not support V4A rename (*** Move to:) sections; apply the rename in a separate non-sequence patch');
129
+ },
130
+ });
131
+ return;
132
+ }
133
+ units.push({
134
+ displayPath,
135
+ fullPath,
136
+ // Honor reject_partial via v4aConvertOpts: a bad hunk throws under
137
+ // reject_partial=true (section fails → sequence stops) but is recorded in
138
+ // rejectedHunks and skipped under reject_partial=false.
139
+ buildParsed: async () => {
140
+ const unified = await convertV4ASectionsToUnifiedPatch([section], basePath, v4aConvertOpts);
141
+ return parsePatch(prepareInput(unified));
142
+ },
143
+ });
144
+ };
145
+ if (isV4APatchInput(patchStr, requestedFormat)) {
146
+ let allSections;
147
+ try {
148
+ allSections = parseV4APatch(patchStr);
149
+ } catch (err) {
150
+ throw new Error(`apply_patch: V4A parse failed — ${err?.message || String(err)}`);
151
+ }
152
+ for (const section of allSections) pushSectionUnit(section);
153
+ } else if (requestedFormat !== 'unified' && hasUnifiedBareV4AHunk(patchStr)) {
154
+ // Bare `@@` V4A hunks in a unified body: parse to sections and defer each
155
+ // section's conversion into its own unit (ordered-stop preserved) rather
156
+ // than converting the whole patch up front.
157
+ let sections;
158
+ try {
159
+ sections = parseUnifiedBareV4APatch(patchStr);
160
+ } catch (err) {
161
+ throw new Error(`apply_patch: bare @@ parse failed — ${err?.message || String(err)}`);
162
+ }
163
+ for (const section of sections) pushSectionUnit(section);
164
+ } else {
165
+ let parsed = null;
166
+ let countedSections = null;
167
+ try {
168
+ parsed = parsePatch(prepareInput(patchStr));
169
+ } catch (err) {
170
+ if (!canFallbackCountedUnified(patchStr, requestedFormat, err)) {
171
+ throw new Error(`apply_patch: parse failed — ${err?.message || String(err)}; prefer V4A envelope for multi-hunk edits (no @@ line counts)`);
172
+ }
173
+ // Counted-unified (`@@ -a,b +c,d @@`) that parsePatch rejects: parse to
174
+ // V4A-style sections and defer per-section conversion — same ordered-stop
175
+ // guarantee as the V4A path (no whole-patch up-front convert).
176
+ try {
177
+ countedSections = parseUnifiedCountedAsV4APatch(patchStr);
178
+ } catch (fallbackErr) {
179
+ throw new Error(`apply_patch: parse failed — ${err?.message || String(err)}; V4A fallback failed — ${fallbackErr?.message || String(fallbackErr)}`);
180
+ }
181
+ }
182
+ if (countedSections) {
183
+ for (const section of countedSections) pushSectionUnit(section);
184
+ } else {
185
+ for (const entry of parsed || []) {
186
+ const kind = classifyEntry(entry);
187
+ const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
188
+ units.push({
189
+ displayPath: normalizeOutputPath(stripDiffPrefix(headerName || '')),
190
+ fullPath: parsedEntryResolvedPath(entry, basePath),
191
+ buildParsed: async () => [entry],
192
+ });
193
+ }
194
+ }
195
+ }
196
+ if (units.length === 0) return 'Error: patch contained no file sections';
197
+
198
+ try {
199
+ await ensureNativePatchBinaryAvailable();
200
+ } catch (err) {
201
+ return `Error: ${err?.message || String(err)}`;
202
+ }
203
+
204
+ const lockPaths = [...new Set(units.map((u) => u.fullPath))];
205
+ const waveOpts = { fuzz, rejectPartial, dryRun, fuzzy, readStateScope, abortSignal };
206
+
207
+ return withBuiltinPathLocks(lockPaths, () =>
208
+ withAdvisoryLocks(lockPaths, async () => {
209
+ const applied = [];
210
+ const skipped = [];
211
+ let failed = null;
212
+ let failedIndex = -1;
213
+ let backend = 'native-patch';
214
+ for (let i = 0; i < units.length; i++) {
215
+ const unit = units[i];
216
+ if (failed) { skipped.push(unit.displayPath); continue; }
217
+ if (abortSignal?.aborted) {
218
+ failed = { displayPath: unit.displayPath, error: 'Error: apply_patch aborted' };
219
+ failedIndex = i;
220
+ continue;
221
+ }
222
+ let parsed;
223
+ try {
224
+ parsed = await unit.buildParsed();
225
+ } catch (err) {
226
+ failed = { displayPath: unit.displayPath, error: `Error: ${err?.message || String(err)}` };
227
+ failedIndex = i;
228
+ continue;
229
+ }
230
+ if (!Array.isArray(parsed) || parsed.length === 0) {
231
+ // Section produced no applicable hunks (all skipped / no-op). Nothing
232
+ // to commit; record and continue.
233
+ applied.push({ displayPath: unit.displayPath, text: `(no changes) ${unit.displayPath}` });
234
+ continue;
235
+ }
236
+ let wave;
237
+ try {
238
+ const { entries, headerRewrites } = await preValidateNativeBatch(parsed, basePath);
239
+ wave = { parsed, entries, headerRewrites };
240
+ } catch (err) {
241
+ failed = { displayPath: unit.displayPath, error: `Error: ${err?.message || String(err)}` };
242
+ failedIndex = i;
243
+ continue;
244
+ }
245
+ const res = await applyParsedWave(wave, basePath, waveOpts);
246
+ backend = res.backend;
247
+ if (res.error) {
248
+ failed = { displayPath: unit.displayPath, error: res.error };
249
+ failedIndex = i;
250
+ continue;
251
+ }
252
+ applied.push({ displayPath: unit.displayPath, text: res.text });
253
+ }
254
+
255
+ const verb = dryRun ? 'validated' : 'applied';
256
+ const dryNote = (dryRun && units.length > 1)
257
+ ? '\n(dry_run: each section validated against unchanged disk; a section depending on an earlier section\'s edits may report a false failure)'
258
+ : '';
259
+ const appliedTexts = applied.map((a) => a.text).filter(Boolean).join('\n');
260
+ // reject_partial=false may have skipped individual V4A hunks in ANY
261
+ // already-processed section; surface them in BOTH the success and failure
262
+ // reports so the reported disk state stays complete even when a later
263
+ // section fails.
264
+ const rejected = Array.isArray(v4aConvertOpts?.rejectedHunks) ? v4aConvertOpts.rejectedHunks : [];
265
+ const rejectedTail = rejected.length > 0
266
+ ? '\n' + [
267
+ '',
268
+ `hunk-level rejected (rejectPartial=false, V4A): ${rejected.length}`,
269
+ ...rejected.map((r) => ` REJECT ${r.file || '(unknown)'} — ${String(r.reason || '').split(';')[0].trim()}`),
270
+ ].join('\n')
271
+ : '';
272
+ if (!failed) {
273
+ const head = `apply_patch sequence: ${verb} ${units.length} section(s) in listed order`;
274
+ const body = (appliedTexts ? `${head}\n${appliedTexts}` : head) + dryNote + rejectedTail;
275
+ return wrapPatchMutationOutput(body, mutationPlan, { backend });
276
+ }
277
+ const failMsg = failed.error.replace(/^Error:\s*/, '');
278
+ const committedPhrase = dryRun
279
+ ? `${applied.length} earlier section(s) were validated`
280
+ : `${applied.length} earlier section(s) were applied to disk (committed) and left in place`;
281
+ const lines = [
282
+ `Error: apply_patch sequence stopped at section ${failedIndex + 1}/${units.length} (${failed.displayPath}); `
283
+ + `${committedPhrase}; ${skipped.length} later section(s) were skipped (not attempted).`,
284
+ ];
285
+ if (appliedTexts) {
286
+ lines.push(`--- ${dryRun ? 'validated' : 'applied (committed to disk)'} ---`, appliedTexts);
287
+ }
288
+ lines.push(`--- failed section: ${failed.displayPath} ---`, failMsg);
289
+ if (skipped.length > 0) {
290
+ lines.push(`--- skipped (not attempted): ${skipped.join(', ')} ---`);
291
+ }
292
+ return wrapPatchMutationOutput(lines.join('\n') + dryNote + rejectedTail, mutationPlan, { backend });
293
+ }));
294
+ }
295
+
42
296
  const APPLY_PATCH_UI_DIFF_MAX_CHARS = 64 * 1024;
43
297
  // Reject oversized patch bodies before parse / native Buffer.from
44
298
  // (native-server.mjs Buffer.from(patchText)). A few MB covers any legitimate
@@ -82,7 +336,7 @@ function wrapPatchMutationOutput(text, plan, extras = {}) {
82
336
  return wrapMutationRouteOutput(text, plan, extras);
83
337
  }
84
338
 
85
- const APPLY_PATCH_SCHEMA_KEYS = new Set(['patch', 'format', 'base_path', 'dry_run', 'reject_partial', 'fuzzy']);
339
+ const APPLY_PATCH_SCHEMA_KEYS = new Set(['patch', 'format', 'base_path', 'dry_run', 'reject_partial', 'fuzzy', 'sequence', 'mode']);
86
340
  function salvageShatteredV4APatchArgs(args) {
87
341
  if (!args || typeof args !== 'object') return args;
88
342
  const rawPatch = typeof args.patch === 'string' ? args.patch : '';
@@ -142,6 +396,17 @@ async function apply_patch(args, cwd, options = {}) {
142
396
  let inputPatchStr = patchStr;
143
397
  const rejectedV4AHunks = [];
144
398
  const v4aConvertOpts = { rejectPartial, rejectedHunks: rejectedV4AHunks, fuzzy, dryRun, readStateScope };
399
+ // Default internal ordered mode: apply sections in listed order, stop at the
400
+ // first failure, and report applied/failed/skipped with true disk state.
401
+ // The legacy bulk/atomic path remains available as an explicit escape hatch.
402
+ const legacyBulkMode = args?.sequence === false
403
+ || ['atomic', 'bulk'].includes(String(args?.mode || '').toLowerCase());
404
+ if (!legacyBulkMode) {
405
+ return applyPatchSequence(patchStr, requestedFormat, basePath, {
406
+ v4aConvertOpts, dryRun, fuzz, fuzzy, rejectPartial,
407
+ readStateScope, abortSignal, mutationPlan,
408
+ });
409
+ }
145
410
  let v4aRenamePlan = null;
146
411
  if (isV4APatchInput(patchStr, requestedFormat)) {
147
412
  try {
@@ -243,51 +508,9 @@ async function apply_patch(args, cwd, options = {}) {
243
508
  if (lines.length === 0) return 'Error: patch contained no applicable file sections';
244
509
  return wrapPatchMutationOutput(`${lines.join('\n')}\n`, mutationPlan, { backend: 'v4a-rename' });
245
510
  }
246
- // Apply one wave (a set of unique targets) via the existing native(+js)
247
- // split. Returns { backend, text } on success or { backend, error } so the
248
- // caller can decide whether earlier waves already committed to disk.
249
- const applyWave = async ({ parsed: wparsed, entries: wentries, headerRewrites: whr }) => {
250
- const insideEntries = wentries.filter((entry) => !isResolvedPathOutsideBase(entry.fullPath, basePath));
251
- const outsideEntries = wentries.filter((entry) => isResolvedPathOutsideBase(entry.fullPath, basePath));
252
- const parsedInside = (wparsed || []).filter(
253
- (entry) => !isResolvedPathOutsideBase(parsedEntryResolvedPath(entry, basePath), basePath),
254
- );
255
- const backend = outsideEntries.length > 0
256
- ? (insideEntries.length > 0 ? 'native+js-patch' : 'js-patch')
257
- : 'native-patch';
258
- const resultParts = [];
259
- if (insideEntries.length > 0) {
260
- const nativePatchStr = rewriteHeaderPaths(renderParsedUnifiedPatch(parsedInside), whr);
261
- const nativeResult = await dispatchNativePatch({
262
- entries: insideEntries,
263
- basePath,
264
- nativePatchStr,
265
- fuzz,
266
- rejectPartial,
267
- dryRun,
268
- readStateScope,
269
- signal: abortSignal,
270
- parsed: parsedInside,
271
- });
272
- if (isPatchErrorText(nativeResult)) return { backend, error: nativeResult };
273
- resultParts.push(nativeResult);
274
- }
275
- if (outsideEntries.length > 0) {
276
- // Out-of-base targets are applied via the JS dispatcher (no base-path
277
- // confinement); write permission is enforced at the hook layer.
278
- const jsResult = await dispatchJsPatchEntries({
279
- rows: outsideEntries,
280
- parsed: wparsed,
281
- basePath,
282
- dryRun,
283
- fuzzy,
284
- readStateScope,
285
- });
286
- if (isPatchErrorText(jsResult)) return { backend, error: jsResult };
287
- resultParts.push(jsResult);
288
- }
289
- return { backend, text: resultParts.join('\n') };
290
- };
511
+ // Apply one wave (a set of unique targets) via applyParsedWave (native +
512
+ // JS split). Returns { backend, text } on success or { backend, error }.
513
+ const applyWave = (wave) => applyParsedWave(wave, basePath, { fuzz, rejectPartial, dryRun, fuzzy, readStateScope, abortSignal });
291
514
 
292
515
  // Duplicate-target blocks were split into contiguous sequential groups
293
516
  // (listed order preserved); apply them in order, each against the prior
@@ -26,7 +26,7 @@ export const PATCH_TOOL_DEFS = [
26
26
  name: 'apply_patch',
27
27
  title: 'Mixdog Apply Patch',
28
28
  annotations: { title: 'Mixdog Apply Patch', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, compressible: false, compressibleLossless: true },
29
- description: 'Apply file patches. Prefer V4A envelope with exact current context. Batch independent edits into ONE patch (multiple file blocks).',
29
+ description: 'Apply file patches. Put known edits in one patch, not multiple turns; sections run in listed order.',
30
30
  freeformDescription: APPLY_PATCH_FREEFORM_DESCRIPTION,
31
31
  freeform: {
32
32
  type: 'grammar',
@@ -36,10 +36,12 @@ export const PATCH_TOOL_DEFS = [
36
36
  inputSchema: {
37
37
  type: 'object',
38
38
  properties: {
39
- patch: { type: 'string', description: 'Patch text. V4A preferred: `*** Begin Patch`; one file block per target file; exact current context.' },
39
+ patch: { type: 'string', description: 'Patch text. V4A preferred; include all known edits in listed order.' },
40
40
  format: { type: 'string', enum: ['unified', 'v4a'], description: 'Auto-detected.' },
41
41
  base_path: { type: 'string', description: 'Repo root.' },
42
42
  dry_run: { type: 'boolean', description: 'Default false. true = validate only, no write.' },
43
+ fuzzy: { type: 'boolean', description: 'Default true. Allows limited context fuzz.' },
44
+ reject_partial: { type: 'boolean', description: 'Default true. false allows V4A hunk-level rejects.' },
43
45
  },
44
46
  required: ['patch'],
45
47
  },
@@ -157,7 +157,13 @@ class OutputForwarder {
157
157
  const noSendEvidence = (!Number.isFinite(sentCount) || sentCount <= 0)
158
158
  && (!Number.isFinite(lastSentTime) || lastSentTime <= 0)
159
159
  && !hasSentHash;
160
- if (!sameTranscript || noSendEvidence) {
160
+ // Recover a genuinely-unsynced tail ONLY for the SAME transcript we are
161
+ // resuming (a reply this session generated but never delivered — crash
162
+ // recovery). NEVER pull the cursor back for a DIFFERENT transcript: on
163
+ // connect/change/rebind that would forward the newly-bound (possibly
164
+ // prior/unrelated) transcript's old tail. A fresh binding must only
165
+ // forward outputs created after the binding, so leave the cursor at EOF.
166
+ if (sameTranscript && noSendEvidence) {
161
167
  const tailOffset = this.findLastAssistantTextOffset(currentSize);
162
168
  if (tailOffset >= 0 && tailOffset < currentSize) {
163
169
  fileSize = tailOffset;
@@ -156,8 +156,11 @@ async function selfHealOwnedRuntime(options = {}) {
156
156
  process.stderr.write(`mixdog: self-heal getBackend() reset failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
157
157
  });
158
158
  }
159
- // Nudge the forwarder to drain anything the rebind/reconnect surfaced.
160
- void forwarder.forwardNewText().catch(() => {});
159
+ // Do NOT nudge forwardNewText() here. bindPersistedTranscriptIfAny() above
160
+ // already forwards the same-session catch-up from the persisted cursor; an
161
+ // extra drain risks surfacing the old tail of a freshly (re)bound transcript
162
+ // on connect/change/rebind. Only outputs created after the new binding
163
+ // should be forwarded, and the normal watch/poll path handles those.
161
164
  } finally {
162
165
  _ownedRuntimeSelfHealing = false;
163
166
  }
@@ -50,6 +50,21 @@ const MODEL_CACHE_DIR = join(resolvePluginData(), 'memory-models')
50
50
  const _envIdleMs = Number(process.env.MIXDOG_EMBED_IDLE_TIMEOUT_MS)
51
51
  const IDLE_TIMEOUT_MS = Number.isFinite(_envIdleMs) && _envIdleMs >= 0 ? _envIdleMs : 0
52
52
 
53
+ // Defensive belt against giant model inputs. Callers should already bound text
54
+ // (see memory-embed truncateForEmbed), but the worker is the last line before
55
+ // ORT: an unbounded string builds a [batch, seq] tensor large enough to trigger
56
+ // multi-GB allocations and an input-tensor dump in the ORT error path. Cap each
57
+ // text to a char budget and ask the tokenizer to truncate to the model window.
58
+ const _envWorkerMaxChars = Number(process.env.MIXDOG_EMBED_MAX_CHARS)
59
+ const WORKER_MAX_CHARS = (Number.isFinite(_envWorkerMaxChars) && _envWorkerMaxChars > 0)
60
+ ? Math.floor(_envWorkerMaxChars)
61
+ : 8000
62
+ const EXTRACT_OPTS = { pooling: 'mean', normalize: true, truncation: true }
63
+ function capEmbedText(text) {
64
+ if (typeof text !== 'string') return ''
65
+ return text.length > WORKER_MAX_CHARS ? text.slice(0, WORKER_MAX_CHARS) : text
66
+ }
67
+
53
68
  let extractorPromise = null
54
69
  let configuredDtype = DEFAULT_DTYPE
55
70
  let _device = 'cpu'
@@ -262,7 +277,7 @@ async function processMessage(msg) {
262
277
  break
263
278
  }
264
279
  const t0 = Date.now()
265
- const output = await extractor(texts, { pooling: 'mean', normalize: true })
280
+ const output = await extractor(texts.map(capEmbedText), EXTRACT_OPTS)
266
281
  const wallMs = Date.now() - t0
267
282
  if (!output.data?.length) throw new Error(`embed-batch output missing data (model=${MODEL_ID})`)
268
283
  const total = output.data.length
@@ -284,7 +299,7 @@ async function processMessage(msg) {
284
299
  resetIdleTimer()
285
300
  const extractor = await loadExtractor()
286
301
  const t0 = Date.now()
287
- const output = await extractor(msg.text, { pooling: 'mean', normalize: true })
302
+ const output = await extractor(capEmbedText(msg.text), EXTRACT_OPTS)
288
303
  const wallMs = Date.now() - t0
289
304
  if (!output.data?.length) throw new Error(`embed output missing data (model=${MODEL_ID})`)
290
305
  const dims = output.data.length
@@ -302,7 +317,7 @@ async function processMessage(msg) {
302
317
  resetIdleTimer()
303
318
  const extractor = await loadExtractor()
304
319
  const t0 = Date.now()
305
- const warmupOutput = await extractor('warmup', { pooling: 'mean', normalize: true })
320
+ const warmupOutput = await extractor('warmup', EXTRACT_OPTS)
306
321
  const wallMs = Date.now() - t0
307
322
  if (!warmupOutput.data?.length) throw new Error(`warmup output missing data (model=${MODEL_ID})`)
308
323
  const measuredDims = warmupOutput.data.length
@@ -75,6 +75,64 @@ const _rawTimeout = Number(process.env.MIXDOG_EMBED_FLUSH_TIMEOUT_MS)
75
75
  const EMBED_FLUSH_TIMEOUT_MS = (Number.isFinite(_rawTimeout) && _rawTimeout > 0) ? _rawTimeout : 30_000
76
76
 
77
77
  const BATCH_SIZE = 32
78
+ const RAW_EMBEDDING_ENABLED = process.env.MIXDOG_ENABLE_RAW_EMBEDDINGS === '1'
79
+ let _rawEmbeddingDisabledLogged = false
80
+
81
+ // Conservative per-text length cap applied before any text reaches the
82
+ // embedding provider. Raw transcript rows and tool-output content can be
83
+ // arbitrarily large; feeding a 14k-token string to the transformer builds a
84
+ // giant [batch, seq] tensor (observed: dims [8,14363] → ORT attempting a 79GB
85
+ // allocation before dumping the input BigInt64Array). The model only attends to
86
+ // its first ~512 tokens anyway, so truncating to a bounded char budget preserves
87
+ // recall while capping tensor size and worker RSS. ~4 chars/token → 8000 chars
88
+ // ≈ 2000 tokens, comfortably above the model window yet far below the blowup.
89
+ const _envEmbedMaxChars = Number(process.env.MIXDOG_EMBED_MAX_CHARS)
90
+ const EMBED_MAX_CHARS = (Number.isFinite(_envEmbedMaxChars) && _envEmbedMaxChars > 0)
91
+ ? Math.floor(_envEmbedMaxChars)
92
+ : 8000
93
+
94
+ export function truncateForEmbed(text) {
95
+ if (typeof text !== 'string') return ''
96
+ return text.length > EMBED_MAX_CHARS ? text.slice(0, EMBED_MAX_CHARS) : text
97
+ }
98
+
99
+ // Raw-row dense-embed eligibility. Storage/recall rows are NEVER deleted or
100
+ // unindexed here — but the dense embedding leg should only cover useful
101
+ // CONVERSATIONAL text. Tool call/result traces and runtime/log/offload/debug/
102
+ // system notifications carry no recall value as dense vectors and pollute the
103
+ // shared embedding cache with mechanical noise, so they are excluded before
104
+ // embedding AND before batch vector mapping. An excluded row keeps its row +
105
+ // embedding-NULL storage state untouched (no delete, no schema change).
106
+ const RAW_EMBED_EXCLUDE_ROLES = new Set(['tool', 'tool_result', 'tool-result', 'function', 'system', 'log', 'offload', 'debug'])
107
+ const RAW_EMBED_EXCLUDE_CONTENT_RES = [
108
+ /^\s*\[tool_call\b/i,
109
+ /^\s*\[tool_result\b/i,
110
+ /^\s*\[mixdog-runtime\]/i,
111
+ /^\s*The async (?:shell task|agent task|\S+ execution|\S+) .*has finished\b.*review this result in your next step/i,
112
+ /^\s*background task\b/i,
113
+ ]
114
+ const RAW_EMBED_EXCLUDE_NON_CONVERSATION_CONTENT_RES = [
115
+ /^\s*\[(?:system|log|offload|debug|trace|info|warn|warning|error|fatal)\]/i,
116
+ ]
117
+ const RAW_EMBED_CONVERSATION_ROLES = new Set(['user', 'assistant'])
118
+
119
+ export function isRawEmbeddable(role, content) {
120
+ const normRole = String(role ?? '').trim().toLowerCase()
121
+ if (RAW_EMBED_EXCLUDE_ROLES.has(normRole)) return false
122
+ const text = typeof content === 'string' ? content : ''
123
+ if (!text.trim()) return false
124
+ for (const re of RAW_EMBED_EXCLUDE_CONTENT_RES) if (re.test(text)) return false
125
+ if (!RAW_EMBED_CONVERSATION_ROLES.has(normRole)) {
126
+ for (const re of RAW_EMBED_EXCLUDE_NON_CONVERSATION_CONTENT_RES) if (re.test(text)) return false
127
+ }
128
+ return true
129
+ }
130
+
131
+ const RAW_EMBED_SQL_EXCLUDE_ROLE_VALUES = [...RAW_EMBED_EXCLUDE_ROLES]
132
+ const RAW_EMBED_SQL_ALWAYS_EXCLUDE_CONTENT_RE =
133
+ '^\\s*(\\[tool_call\\b|\\[tool_result\\b|\\[mixdog-runtime\\]|The async (shell task|agent task|\\S+ execution|\\S+) .*has finished\\b.*review this result in your next step|background task\\b)'
134
+ const RAW_EMBED_SQL_NON_CONVERSATION_EXCLUDE_CONTENT_RE =
135
+ '^\\s*\\[(system|log|offload|debug|trace|info|warn|warning|error|fatal)\\]'
78
136
 
79
137
  function throwIfAborted(signal) {
80
138
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
@@ -106,10 +164,13 @@ export async function cachedEmbedTextBatch(db, texts, options = {}) {
106
164
  // env value that doesn't match the provider would tag vectors with a
107
165
  // stale/wrong label across model/env changes.
108
166
  const modelId = getEmbeddingModelId() || process.env.MIXDOG_EMBED_MODEL || 'default'
109
- const entries = texts.map(t => ({
110
- text: t,
111
- hash: Buffer.from(createHash('sha256').update(t).digest()),
112
- }))
167
+ // Bound each text before hashing/embedding: the cache key and the provider
168
+ // input are both the truncated string, so oversized rows can never build a
169
+ // giant tensor and identical truncations dedup in-cache.
170
+ const entries = texts.map(t => {
171
+ const text = truncateForEmbed(t)
172
+ return { text, hash: Buffer.from(createHash('sha256').update(text).digest()) }
173
+ })
113
174
 
114
175
  // Single SELECT for all hashes
115
176
  const hashBufs = entries.map(e => e.hash)
@@ -285,6 +346,14 @@ export async function flushEmbeddingDirty(db, options = {}) {
285
346
  // dense matching, not staleness.
286
347
  export async function flushRawEmbeddings(db, options = {}) {
287
348
  const { limit = 200, signal } = options ?? {}
349
+ throwIfAborted(signal)
350
+ if (!RAW_EMBEDDING_ENABLED) {
351
+ if (!_rawEmbeddingDisabledLogged) {
352
+ __mixdogMemoryLog('[embed] raw transcript embedding disabled; only root/summary entries are embedded\n')
353
+ _rawEmbeddingDisabledLogged = true
354
+ }
355
+ return { attempted: 0, embedded: 0, skipped: 'raw-embedding-disabled' }
356
+ }
288
357
  // Optional id allow-list: restrict the SKIP LOCKED claim to a specific set of
289
358
  // rows (e.g. exactly the rows a single ingest_session call inserted) so a
290
359
  // caller can flush ONLY its own rows instead of inheriting the whole raw
@@ -294,7 +363,6 @@ export async function flushRawEmbeddings(db, options = {}) {
294
363
  ? options.ids.map(Number).filter(Number.isFinite)
295
364
  : null
296
365
  if (idFilter && idFilter.length === 0) return { attempted: 0, embedded: 0 }
297
- throwIfAborted(signal)
298
366
  await ensureEmbCacheTable(db)
299
367
  throwIfAborted(signal)
300
368
 
@@ -310,7 +378,14 @@ export async function flushRawEmbeddings(db, options = {}) {
310
378
  try {
311
379
  throwIfAborted(signal)
312
380
  await client.query('BEGIN')
313
- const params = [batchCap, cursor]
381
+ const params = [
382
+ batchCap,
383
+ cursor,
384
+ RAW_EMBED_SQL_EXCLUDE_ROLE_VALUES,
385
+ [...RAW_EMBED_CONVERSATION_ROLES],
386
+ RAW_EMBED_SQL_ALWAYS_EXCLUDE_CONTENT_RE,
387
+ RAW_EMBED_SQL_NON_CONVERSATION_EXCLUDE_CONTENT_RE,
388
+ ]
314
389
  let idClause = ''
315
390
  if (idFilter) {
316
391
  params.push(idFilter)
@@ -322,6 +397,9 @@ export async function flushRawEmbeddings(db, options = {}) {
322
397
  AND NULLIF(btrim(session_id), '') IS NOT NULL
323
398
  AND content IS NOT NULL
324
399
  AND id > $2${idClause}
400
+ AND lower(COALESCE(role, '')) <> ALL($3::text[])
401
+ AND content !~* $5
402
+ AND (lower(COALESCE(role, '')) = ANY($4::text[]) OR content !~* $6)
325
403
  ORDER BY id
326
404
  LIMIT $1
327
405
  FOR UPDATE SKIP LOCKED`,
@@ -374,7 +452,7 @@ async function syncRawBatchEmbeddings(db, ids, options = {}) {
374
452
  const signal = options?.signal
375
453
  throwIfAborted(signal)
376
454
  const rows = (await db.query(
377
- `SELECT id, content FROM memory.entries
455
+ `SELECT id, role, content FROM memory.entries
378
456
  WHERE id = ANY($1::bigint[]) AND is_root = 0 AND chunk_root IS NULL`,
379
457
  [ids],
380
458
  )).rows
@@ -385,7 +463,10 @@ async function syncRawBatchEmbeddings(db, ids, options = {}) {
385
463
  throwIfAborted(signal)
386
464
  const expected = Number(dimsRow?.value ?? 0)
387
465
 
388
- const texts = rows.map(row => (typeof row.content === 'string' ? row.content.trim() : ''))
466
+ // Exclude tool/tool_result/log/offload/debug/system-like rows from the dense
467
+ // embed leg (empty text ⇒ dropped below and never vector-mapped). The length
468
+ // cap still applies to survivors via cachedEmbedTextBatch → truncateForEmbed.
469
+ const texts = rows.map(row => (isRawEmbeddable(row.role, row.content) ? row.content.trim() : ''))
389
470
  const vectors = await cachedEmbedTextBatch(db, texts.filter(Boolean), { signal })
390
471
  throwIfAborted(signal)
391
472
  let vecIdx = 0
@@ -2,8 +2,7 @@ import {
2
2
  estimateRequestReserveTokens,
3
3
  estimateToolSchemaTokens,
4
4
  estimateTranscriptContextUsage,
5
- resolveCompactBufferTokens,
6
- resolveCompactTriggerTokens,
5
+ resolveSessionCompactPolicy,
7
6
  summarizeContextMessages,
8
7
  } from '../runtime/agent/orchestrator/session/context-utils.mjs';
9
8
  import { estimateToolSchemaBreakdown } from './tool-catalog.mjs';
@@ -122,10 +121,12 @@ export function createContextStatus({ getSession, getRoute, getCurrentCwd, getMo
122
121
  // Use the same shared compact-policy math as manager/loop. Do not trust
123
122
  // persisted trigger telemetry as an independent policy input: it is an
124
123
  // output snapshot and was the source of repeated /context false positives.
125
- const compactTriggerTokens = resolveCompactTriggerTokens(session || {}, compactBoundaryTokens) || 0;
126
- const compactBufferTokens = compactBoundaryTokens
127
- ? Math.max(0, compactBoundaryTokens - compactTriggerTokens)
128
- : resolveCompactBufferTokens(compactBoundaryTokens, session?.compaction || {});
124
+ // Shared session-compaction policy (same math as manager/loop): agent-owned
125
+ // semantic sessions report/fire at 90% of the boundary; main/user
126
+ // recall-fasttrack report/fire on the boundary itself (100%).
127
+ const compactPolicy = resolveSessionCompactPolicy(session || {}, compactBoundaryTokens);
128
+ const compactTriggerTokens = compactPolicy.triggerTokens || 0;
129
+ const compactBufferTokens = compactPolicy.bufferTokens || 0;
129
130
  const value = {
130
131
  sessionId: session?.id || null,
131
132
  provider: route.provider,
@@ -81,16 +81,6 @@ import {
81
81
  removePlugin as registryRemovePlugin,
82
82
  updatePlugin as registryUpdatePlugin,
83
83
  } from '../standalone/plugin-admin.mjs';
84
- import {
85
- estimateMessagesTokens,
86
- estimateRequestReserveTokens,
87
- estimateTranscriptContextUsage,
88
- estimateToolSchemaTokens,
89
- resolveCompactBufferTokens,
90
- resolveCompactTriggerTokens,
91
- summarizeContextMessages,
92
- } from '../runtime/agent/orchestrator/session/context-utils.mjs';
93
-
94
84
  import {
95
85
  sessionMessageText,
96
86
  messageContextText,