mixdog 0.9.104 → 0.9.105

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.
package/README.md CHANGED
@@ -103,7 +103,7 @@ every number above live under `benchmarks/terminal-bench-2.1/`.
103
103
  role mode for scripting.
104
104
  - Mixdog Desktop: a full agent workbench for Windows/macOS/Linux (see
105
105
  below).
106
- - Web/mobile companion over relay pairing — scan a QR code to open your
106
+ - Installable web app over relay pairing — scan a QR code to open your
107
107
  running sessions in a phone browser and keep going from any network.
108
108
  - Optional Discord/Telegram channels, webhook endpoints, and cron schedules
109
109
  with quiet hours for remote/event-driven workflows; channel voice messages
@@ -246,7 +246,7 @@ wizard covers first-run setup. For development run `npm run dev` inside
246
246
  - **Automation** — visual editors for workflow and agent packs, cron
247
247
  schedules, webhooks, and channel integrations.
248
248
  - **Settings hub** — provider auth, capability sweep, git identity, and
249
- QR device pairing for the web/mobile companion, preloaded so every
249
+ QR device pairing for the installable web app, preloaded so every
250
250
  category opens instantly.
251
251
 
252
252
  ## Scripts
@@ -302,8 +302,7 @@ src/
302
302
  rules/ # Lead and agent instructions
303
303
  apps/
304
304
  desktop/ # Mixdog Desktop — Electron workbench (main/preload/renderer)
305
- mobile/ # mobile companion shell
306
- relay/ # relay server for remote/web/mobile access
305
+ relay/ # relay server for remote web-app access
307
306
  scripts/
308
307
  smoke*.mjs # smoke checks
309
308
  *test.mjs # focused node:test checks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.104",
3
+ "version": "0.9.105",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -14,7 +14,9 @@
14
14
  facets in one query array. It
15
15
  returns the minimal complete direct `path:line` anchors, not analysis or
16
16
  solutions; resume baseline routing from those anchors.
17
- - Use verified paths (cwd/project/user/tool); explicit paths may be outside cwd;
17
+ - Use verified paths (cwd/project/user/tool). Within the current project, pass
18
+ project-relative paths and omit optional scopes equal to its root; explicit
19
+ paths may be outside cwd only for targets outside the project;
18
20
  stay focused on the requested outcome. Avoid investigation, implementation,
19
21
  or verification not required to satisfy it; once the requirements are met
20
22
  and proven, stop.
@@ -292,20 +292,13 @@ export function applyAnthropicEffortToBody(
292
292
  // Adaptive-thinking models (4.6+) require `thinking:{type:"adaptive"}`
293
293
  // rather than the legacy budget_tokens shape — sending
294
294
  // `thinking:{type:"enabled"}` here 400s on sonnet-5/opus-4-7/4-8.
295
- // display:"summarized" keeps reasoning blocks populated (4.7+ defaults
296
- // to "omitted", silently hiding reasoning text). Gated on the same
297
- // modelSupportsEffort() allowlist so older models never receive it.
298
- // Set unconditionally (independent of `normalized`) so effort-capable
299
- // turns always carry adaptive thinking + round-trip signatures.
300
- // MIXDOG_ANTHROPIC_THINKING_DISPLAY=omitted (operator/bench knob):
301
- // thinking blocks are omitted entirely, so nothing is
302
- // replayed into later requests (saves the 1h cache-write + re-read on
303
- // accumulated thinking) at the cost of losing visible reasoning and
304
- // cross-iteration thinking continuity. Default stays summarized.
305
- const display = (process.env.MIXDOG_ANTHROPIC_THINKING_DISPLAY || '').trim() === 'omitted'
306
- ? 'omitted'
307
- : 'summarized';
308
- body.thinking = { type: 'adaptive', display };
295
+ // Match Claude Code's default wire shape: omit `display` and let the
296
+ // model/API choose its default. Operators and benchmarks can explicitly
297
+ // request either supported display mode.
298
+ const display = (process.env.MIXDOG_ANTHROPIC_THINKING_DISPLAY || '').trim();
299
+ body.thinking = display === 'summarized' || display === 'omitted'
300
+ ? { type: 'adaptive', display }
301
+ : { type: 'adaptive' };
309
302
  // Adaptive/4.7+ models reject any non-default sampling param with a 400.
310
303
  delete body.temperature;
311
304
  delete body.top_p;
@@ -199,6 +199,22 @@ function compactPressureTokens(messageTokensEst, policy) {
199
199
  return Math.max(0, Math.round((messageTokensEst + requestReserve) * calibration) + otherReserve);
200
200
  }
201
201
 
202
+ // Provider-visible context estimate without operator-only compaction reserve.
203
+ // Request/schema reserve remains included because those bytes are sent to the
204
+ // model; configured reserve is merely local headroom and must not inflate the
205
+ // user-facing context gauge.
206
+ function currentContextEstimateTokens(messageTokensEst, policy) {
207
+ if (messageTokensEst === null) return 0;
208
+ const calibration = Number(policy?.tokenCalibration) > 0 ? Number(policy.tokenCalibration) : 1;
209
+ const configured = Math.max(0, Number(policy?.configuredReserveTokens) || 0);
210
+ const totalReserve = Math.max(0, Number(policy?.reserveTokens) || 0);
211
+ const requestReserve = Math.min(
212
+ totalReserve,
213
+ Math.max(0, Number(policy?.requestReserveTokens ?? (totalReserve - configured)) || 0),
214
+ );
215
+ return Math.max(0, Math.round((messageTokensEst + requestReserve) * calibration));
216
+ }
217
+
202
218
  function providerPressureTokens(sessionRef, usage) {
203
219
  if (!usage || typeof usage !== 'object') return 0;
204
220
  const input = Math.max(0, Number(usage.mainInputTokens ?? usage.inputTokens) || 0);
@@ -268,7 +284,9 @@ export function invalidateProviderContextBaseline(sessionRef) {
268
284
  // transcript did NOT grow keeps its baseline regardless of age.
269
285
  const BASELINE_MAX_STALE_GROWTH_MS = 30 * 60 * 1000;
270
286
 
271
- function providerBaselinePressureTokens(messages, sessionRef, policy) {
287
+ function providerBaselinePressureTokens(messages, sessionRef, policy, {
288
+ includeConfiguredReserve = true,
289
+ } = {}) {
272
290
  if (!Array.isArray(messages) || !sessionRef
273
291
  || sessionRef.lastContextTokensStaleAfterCompact === true) return null;
274
292
  let tokens = positiveTokenInt(sessionRef.contextPressureBaselineTokens);
@@ -303,16 +321,31 @@ function providerBaselinePressureTokens(messages, sessionRef, policy) {
303
321
  const growth = count < messages.length
304
322
  ? Math.round(estimateMessagesTokens(messages.slice(count)) * calibration)
305
323
  : 0;
306
- return Math.max(0, tokens + growth + Math.max(0, Number(policy?.configuredReserveTokens) || 0));
324
+ const configuredReserve = includeConfiguredReserve
325
+ ? Math.max(0, Number(policy?.configuredReserveTokens) || 0)
326
+ : 0;
327
+ return Math.max(0, tokens + growth + configuredReserve);
307
328
  } catch {
308
329
  return null;
309
330
  }
310
331
  }
311
332
 
333
+ function preferAlignedBaseline(baseline, estimate) {
334
+ if (baseline == null) return estimate;
335
+ if (Number.isFinite(estimate) && estimate > 0 && baseline * 2 < estimate) return estimate;
336
+ return baseline;
337
+ }
338
+
339
+ export function resolveCurrentContextTokens(messageTokensEst, policy, { messages, sessionRef } = {}) {
340
+ const baseline = providerBaselinePressureTokens(messages, sessionRef, policy, {
341
+ includeConfiguredReserve: false,
342
+ });
343
+ return preferAlignedBaseline(baseline, currentContextEstimateTokens(messageTokensEst, policy));
344
+ }
345
+
312
346
  export function resolveCompactionPressureTokens(messageTokensEst, policy, { messages, sessionRef } = {}) {
313
347
  const baseline = providerBaselinePressureTokens(messages, sessionRef, policy);
314
348
  const estimate = compactPressureTokens(messageTokensEst, policy);
315
- if (baseline == null) return estimate;
316
349
  // Sanity band: the baseline exists to correct OVER-counting estimates
317
350
  // (dense-data floors can inflate the estimate up to ~2x real usage), so a
318
351
  // lower baseline is normally preferred. But a corrupt/stale baseline below
@@ -322,8 +355,7 @@ export function resolveCompactionPressureTokens(messageTokensEst, policy, { mess
322
355
  // both the gauge and the compaction decision. Erring toward the estimate
323
356
  // may compact somewhat early; erring toward a rotten baseline blows past
324
357
  // the context window at full token cost.
325
- if (Number.isFinite(estimate) && estimate > 0 && baseline * 2 < estimate) return estimate;
326
- return baseline;
358
+ return preferAlignedBaseline(baseline, estimate);
327
359
  }
328
360
 
329
361
  /** Telemetry pressure when a reactive overflow retry forces the next compact. */
@@ -856,6 +856,32 @@ function dropUndefinedArgs(args) {
856
856
  }
857
857
  }
858
858
 
859
+ // Provider-facing built-ins use short keys/enum values to reduce repeated
860
+ // tool-call output. Canonicalize them before validation so executors, traces,
861
+ // saved calls, and legacy long-form callers keep one stable internal contract.
862
+ function normalizeCompactSurfaceArgs(toolName, args) {
863
+ if (['grep', 'glob', 'find', 'list'].includes(toolName)
864
+ && !hasOwn(args, 'head_limit')
865
+ && hasOwn(args, 'limit')) {
866
+ args.head_limit = args.limit;
867
+ delete args.limit;
868
+ }
869
+ if (toolName === 'grep'
870
+ && !hasOwn(args, 'output_mode')
871
+ && typeof args.mode === 'string') {
872
+ const mode = args.mode.trim();
873
+ const canonical = {
874
+ content: 'content_with_context',
875
+ files: 'files_with_matches',
876
+ count: 'count',
877
+ }[mode];
878
+ if (canonical) {
879
+ args.output_mode = canonical;
880
+ delete args.mode;
881
+ }
882
+ }
883
+ }
884
+
859
885
  // string | string[] arguments arrive nested ([["a","b"]] from a batching
860
886
  // wrapper), padded with null/empty entries, or empty. The intent is
861
887
  // unambiguous, so flatten one level, drop the blanks, and remove a key that
@@ -866,9 +892,13 @@ const STRING_LIST_ARG_KEYS = [
866
892
  'pattern', 'patterns', 'query', 'regex', 'needle',
867
893
  'glob', 'file_pattern', 'include', 'type', 'symbols',
868
894
  ];
869
- function normalizeStringListArgs(args) {
895
+ function normalizeStringListArgs(args, toolName) {
870
896
  for (const key of STRING_LIST_ARG_KEYS) {
871
897
  if (!hasOwn(args, key) || !Array.isArray(args[key])) continue;
898
+ // read.path may contain compact [path,offset,limit] tuples. Flattening
899
+ // here would destroy their boundaries before guardRead canonicalizes
900
+ // them through coerceReadFamilyPathArg().
901
+ if (toolName === 'read' && key === 'path' && args[key].some(Array.isArray)) continue;
872
902
  const source = args[key];
873
903
  // Flatten ONE nesting level and drop blank entries; every other entry
874
904
  // type is left untouched so the per-tool guards still see (and coerce
@@ -896,7 +926,8 @@ export function validateBuiltinArgs(toolName, args) {
896
926
  return `Error: ${toolName} arguments must be an object (got ${describeType(args)})`;
897
927
  }
898
928
  dropUndefinedArgs(args);
899
- normalizeStringListArgs(args);
929
+ normalizeCompactSurfaceArgs(toolName, args);
930
+ normalizeStringListArgs(args, toolName);
900
931
  if (toolName === 'grep') applyGrepContextLeadPolicy(args);
901
932
  try {
902
933
  return guard(args) || null;
@@ -498,7 +498,10 @@ export async function executeBashTool(args, workDir, options = {}) {
498
498
  const promotedTimeoutMs = hasExplicitTimeout && backgroundOnTimeout
499
499
  ? Math.max(0, totalTimeout - timeout)
500
500
  : 0;
501
- const mergeStderr = args.merge_stderr === true;
501
+ // Provider schema intentionally omits this low-value toggle; all observed
502
+ // live calls requested merged output, which is also the useful default for
503
+ // in-turn diagnostics. Internal callers may still opt out explicitly.
504
+ const mergeStderr = args.merge_stderr !== false;
502
505
  const longForegroundHint = foregroundLongCommandHint(
503
506
  command,
504
507
  timeout,
@@ -46,24 +46,28 @@ export const BUILTIN_TOOLS = [
46
46
  items: {
47
47
  anyOf: [
48
48
  { type: 'string' },
49
+ { type: 'number' },
49
50
  {
50
- type: 'object',
51
- properties: {
52
- path: { type: 'string' },
53
- offset: { type: 'number', minimum: 0 },
54
- limit: { type: 'number', minimum: 1 },
51
+ type: 'array',
52
+ items: {
53
+ anyOf: [
54
+ { type: 'string' },
55
+ { type: 'number' },
56
+ ],
55
57
  },
56
- required: ['path'],
58
+ minItems: 2,
59
+ maxItems: 3,
60
+ description: '[path,offset,limit?].',
57
61
  },
58
62
  ],
59
63
  },
60
64
  minItems: 1,
61
65
  },
62
66
  ],
63
- description: 'File path, or {path,offset,limit}[] regions. Pass real arrays, not JSON strings.',
67
+ description: 'Project-relative file path, string[] files, [path,offset,limit?] range, or range[]; absolute only outside the project.',
64
68
  },
65
69
  offset: { type: 'number', minimum: 0, description: 'Lines to skip.' },
66
- limit: { type: 'number', minimum: 1, description: 'Max lines after offset. Defaults to 2000.' },
70
+ limit: { type: 'number', minimum: 1, description: 'Max lines; default 2000.' },
67
71
  },
68
72
  required: ['path'],
69
73
  additionalProperties: false,
@@ -78,14 +82,11 @@ export const BUILTIN_TOOLS = [
78
82
  type: 'object',
79
83
  properties: {
80
84
  command: { type: 'string', description: `Command.${_shellSyntaxCheat}` },
81
- cwd: { type: 'string', description: 'Working directory; persists. Omit to reuse; an absolute path changes it.' },
85
+ cwd: { type: 'string', description: 'Omit for current directory; use a project-relative subdir or explicit external path.' },
82
86
  timeout: {
83
87
  type: 'number',
84
- description: `Timeout ms; default ${_shellDefaultTimeoutMs()}. `
85
- + 'Sync timeout may return task_id; an explicit value is its deadline. '
86
- + 'Sleeps are killed, not promoted.',
88
+ description: `Timeout ms; default ${_shellDefaultTimeoutMs()}. Explicit values are deadlines; sync may return task_id.`,
87
89
  },
88
- merge_stderr: { type: 'boolean', description: 'Merge stderr.' },
89
90
  mode: { type: 'string', enum: ['sync', 'async'], description: executionModeSchemaDescription('sync') },
90
91
  shell: { type: 'string', enum: ['bash', 'powershell'], description: 'Force shell.' },
91
92
  },
@@ -101,9 +102,9 @@ export const BUILTIN_TOOLS = [
101
102
  inputSchema: {
102
103
  type: 'object',
103
104
  properties: {
104
- task_id: { type: 'string', description: 'shell async task_id.' },
105
- action: { type: 'string', enum: ['list', 'status', 'read', 'wait', 'cancel'], description: 'Default list; with task_id, default wait.' },
106
- timeout_ms: { type: 'number', description: 'Wait timeout ms.' },
105
+ task_id: { type: 'string', description: 'Shell task_id.' },
106
+ action: { type: 'string', enum: ['list', 'status', 'read', 'wait', 'cancel'], description: 'Default list; with task_id: wait.' },
107
+ timeout_ms: { type: 'number', description: 'Wait timeout (ms).' },
107
108
  },
108
109
  required: [],
109
110
  additionalProperties: false,
@@ -122,14 +123,14 @@ export const BUILTIN_TOOLS = [
122
123
  { type: 'string' },
123
124
  { type: 'array', items: { type: 'string' }, minItems: 1 },
124
125
  ],
125
- description: 'Text/regex; pattern[] batches exact query literals and identifier variants in one call.',
126
+ description: 'Text/regex; pattern[] batches exact query literals and identifier variants.',
126
127
  },
127
128
  path: {
128
129
  anyOf: [
129
130
  { type: 'string' },
130
131
  { type: 'array', items: { type: 'string' }, minItems: 1 },
131
132
  ],
132
- description: 'File/dir scope(s).',
133
+ description: 'Project-relative file/dir scope(s); omit for project root; absolute only outside.',
133
134
  },
134
135
  glob: {
135
136
  anyOf: [
@@ -138,10 +139,10 @@ export const BUILTIN_TOOLS = [
138
139
  ],
139
140
  description: 'Glob filter.',
140
141
  },
141
- output_mode: { type: 'string', enum: ['content_with_context', 'files_with_matches', 'count'], description: 'content_with_context (default); files_with_matches/count for existence.' },
142
- head_limit: { type: 'number', minimum: 0, description: 'Max results. Defaults to 250; 0 = unlimited.' },
143
- offset: { type: 'number', minimum: 0, description: 'Skip results for paging.' },
144
- context: { type: 'number', minimum: 0, description: 'Minimum surrounding lines; runtime expands within its call budget. Omit for automatic context; 0 for matches only.' },
142
+ mode: { type: 'string', enum: ['content', 'files', 'count'], description: 'content default; files/count for existence.' },
143
+ limit: { type: 'number', minimum: 0, description: 'Max results; default 250; 0 unlimited.' },
144
+ offset: { type: 'number', minimum: 0, description: 'Result offset.' },
145
+ context: { type: 'number', minimum: 0, description: 'Omit for automatic context; 0 for matches only.' },
145
146
  },
146
147
  anyOf: [
147
148
  { required: ['pattern'] },
@@ -163,17 +164,16 @@ export const BUILTIN_TOOLS = [
163
164
  { type: 'string' },
164
165
  { type: 'array', items: { type: 'string' }, minItems: 1 },
165
166
  ],
166
- description: 'Glob pattern(s); pattern[] batches.',
167
+ description: 'Glob(s); pattern[] batches.',
167
168
  },
168
169
  path: {
169
170
  anyOf: [
170
171
  { type: 'string' },
171
172
  { type: 'array', items: { type: 'string' }, minItems: 1 },
172
173
  ],
173
- description: 'Base directory(ies); path[] batches.',
174
+ description: 'Project-relative base dir(s); omit for project root; path[] batches; absolute only outside.',
174
175
  },
175
- head_limit: { type: 'number', description: 'Max entries. Defaults to 100; 0 = unlimited.' },
176
- offset: { type: 'number', description: 'Skip entries.' },
176
+ limit: { type: 'number', description: 'Max entries; default 100; 0 unlimited.' },
177
177
  },
178
178
  required: ['pattern'],
179
179
  additionalProperties: false,
@@ -194,8 +194,8 @@ export const BUILTIN_TOOLS = [
194
194
  ],
195
195
  description: 'Filename or directory path fragments matched against path strings; query[] batches.',
196
196
  },
197
- path: { type: 'string', description: 'Base directory.' },
198
- head_limit: { type: 'number', description: 'Max paths across the call. Defaults to 25.' },
197
+ path: { type: 'string', description: 'Project-relative base; omit for project root; absolute only outside.' },
198
+ limit: { type: 'number', description: 'Max paths across the call. Defaults to 25.' },
199
199
  },
200
200
  required: ['query'],
201
201
  additionalProperties: false,
@@ -214,12 +214,12 @@ export const BUILTIN_TOOLS = [
214
214
  { type: 'string' },
215
215
  { type: 'array', items: { type: 'string' }, minItems: 1 },
216
216
  ],
217
- description: 'Directory; path[] batches.',
217
+ description: 'Project-relative directory; omit for project root; path[] batches; absolute only outside.',
218
218
  },
219
219
  hidden: { type: 'boolean', description: 'Include dotfiles.' },
220
220
  meta: { type: 'boolean', description: 'Per-entry size bytes, UTC mtime, octal mode.' },
221
- head_limit: { type: 'number', description: 'Max entries. Defaults to 200; 0 = no cap.' },
222
- offset: { type: 'number', description: 'Skip N entries for paging.' },
221
+ limit: { type: 'number', description: 'Max entries; default 200; 0 unlimited.' },
222
+ offset: { type: 'number', description: 'Entry offset.' },
223
223
  },
224
224
  required: [],
225
225
  additionalProperties: false,
@@ -110,6 +110,10 @@ export const GREP_AUTO_CONTEXT_LINES = 25;
110
110
 
111
111
  export function normalizeGrepArgs(args) {
112
112
  if (!args || typeof args !== 'object') return args;
113
+ if ((args.head_limit === undefined || args.head_limit === null) && args.limit !== undefined) {
114
+ args.head_limit = args.limit;
115
+ delete args.limit;
116
+ }
113
117
  if (args.pattern === undefined || args.pattern === null || args.pattern === '') {
114
118
  const alias = firstPresentArg(args, ['query', 'regex', 'regexp', 'needle', 'search', 'literal']);
115
119
  if (alias !== undefined) args.pattern = alias;
@@ -124,13 +128,24 @@ export function normalizeGrepArgs(args) {
124
128
  }
125
129
  if ((args.output_mode === undefined || args.output_mode === null || args.output_mode === '') && typeof args.mode === 'string') {
126
130
  const mode = args.mode.trim();
127
- if (['files_with_matches', 'content', 'content_with_context', 'count'].includes(mode)) args.output_mode = mode;
131
+ const canonical = {
132
+ content: 'content_with_context',
133
+ files: 'files_with_matches',
134
+ count: 'count',
135
+ content_with_context: 'content_with_context',
136
+ files_with_matches: 'files_with_matches',
137
+ }[mode];
138
+ if (canonical) args.output_mode = canonical;
128
139
  }
129
140
  return args;
130
141
  }
131
142
 
132
143
  export function normalizeGlobArgs(args) {
133
144
  if (!args || typeof args !== 'object') return args;
145
+ if ((args.head_limit === undefined || args.head_limit === null) && args.limit !== undefined) {
146
+ args.head_limit = args.limit;
147
+ delete args.limit;
148
+ }
134
149
  if (args.pattern === undefined || args.pattern === null || args.pattern === '') {
135
150
  const alias = firstPresentArg(args, ['glob', 'file_pattern', 'filePattern', 'name', 'include', 'includes', 'files']);
136
151
  if (alias !== undefined) args.pattern = alias;
@@ -247,6 +262,20 @@ export function coerceShapeFlex(value) {
247
262
  // JSON-stringified path arrays (path:"[]") and empty arrays mean "search cwd".
248
263
  // Bracket-shaped strings that exist on disk (e.g. a literal `[x]` directory) are
249
264
  // left untouched — JSON reinterpretation only runs after a stat miss.
265
+ function compactReadRangeTuple(value) {
266
+ if (!Array.isArray(value) || value.length < 2 || value.length > 3 || typeof value[0] !== 'string') return null;
267
+ const numeric = value.slice(1).every((part) => (
268
+ typeof part === 'number'
269
+ || (typeof part === 'string' && /^-?\d+(?:\.\d+)?$/.test(part.trim()))
270
+ ));
271
+ if (!numeric) return null;
272
+ return {
273
+ path: value[0],
274
+ offset: value[1],
275
+ ...(value.length === 3 ? { limit: value[2] } : {}),
276
+ };
277
+ }
278
+
250
279
  export function coerceReadFamilyPathArg(path, workDir = null) {
251
280
  if (path === undefined || path === null || path === '') return path;
252
281
  if (typeof path === 'string' && typeof workDir === 'string' && workDir) {
@@ -305,10 +334,13 @@ export function coerceReadFamilyPathArg(path, workDir = null) {
305
334
  }
306
335
  const coerced = coerceShapeFlex(path);
307
336
  if (!Array.isArray(coerced)) return coerced;
337
+ const directRange = compactReadRangeTuple(coerced);
338
+ if (directRange) return [directRange];
308
339
  const list = coerced
309
- // Region objects ({path,offset,limit}) must pass through untouched
310
- // read's batch dispatcher consumes them. Only strings are trimmed.
311
- .map((p) => (typeof p === 'string' ? p.trim() : p))
340
+ // Provider-facing range tuples are compact; canonicalize them to the
341
+ // legacy region objects consumed by read's batch dispatcher. Existing
342
+ // object callers remain valid and pass through untouched.
343
+ .map((p) => compactReadRangeTuple(p) ?? (typeof p === 'string' ? p.trim() : p))
312
344
  .filter((p) => (typeof p === 'string' ? p.length > 0 : (p && typeof p === 'object')));
313
345
  if (list.length === 0) return '.';
314
346
  // Collapse to scalar only for a lone string; a lone region object must
@@ -8,13 +8,12 @@ export const CODE_GRAPH_TOOL_DEFS = [
8
8
  type: 'object',
9
9
  properties: {
10
10
  mode: { type: 'string', enum: ['overview', 'imports', 'dependents', 'related', 'impact', 'symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'], description: 'File modes: overview/imports/dependents/related/impact. symbols with files[] gives a file outline; others are symbol modes.' },
11
- files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Source file path(s); supported targets only.' },
11
+ files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Project-relative source file path(s); absolute only outside the project; supported targets only.' },
12
12
  symbols: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Exact identifiers or keywords (symbol-index terms); batch multiple in one symbols[] call.' },
13
13
  body: { type: 'boolean', description: 'Include body.' },
14
14
  limit: { type: 'number', minimum: 1, description: 'Max results.' },
15
15
  depth: { type: 'number', minimum: 1, maximum: 5, description: 'Caller depth.' },
16
- page: { type: 'number', minimum: 1, description: 'Caller page.' },
17
- cwd: { type: 'string', description: 'Explicit root.' },
16
+ cwd: { type: 'string', description: 'Explicit root outside the project; omit for project root.' },
18
17
  },
19
18
  required: ['mode'],
20
19
  additionalProperties: false,
@@ -680,6 +680,26 @@ function extractInlinePatchRoot(patchText) {
680
680
  };
681
681
  }
682
682
 
683
+ export function expandCompactPatchInput(patchText) {
684
+ const text = String(patchText || '').replace(/^\uFEFF/, '');
685
+ if (/^\s*\*\*\* Begin Patch(?:\r?\n|$)/.test(text)) return text;
686
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
687
+ while (lines.length && lines[0] === '') lines.shift();
688
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
689
+ if (!lines.length || !/^[RADU] /.test(lines[0])) return text;
690
+ const mapped = lines.map((line) => {
691
+ if (line.startsWith('R ')) return `*** Root: ${line.slice(2)}`;
692
+ if (line.startsWith('A ')) return `*** Add File: ${line.slice(2)}`;
693
+ if (line.startsWith('D ')) return `*** Delete File: ${line.slice(2)}`;
694
+ if (line.startsWith('U ')) return `*** Update File: ${line.slice(2)}`;
695
+ if (line.startsWith('M ')) return `*** Move to: ${line.slice(2)}`;
696
+ if (line === '@') return '@@';
697
+ if (line.startsWith('@ ')) return `@@ ${line.slice(2)}`;
698
+ return line;
699
+ });
700
+ return ['*** Begin Patch', ...mapped, '*** End Patch', ''].join('\n');
701
+ }
702
+
683
703
  function isFilesystemRootSpecifier(value, cwd) {
684
704
  const raw = String(value || '').trim();
685
705
  if (!raw) return false;
@@ -692,7 +712,8 @@ function isFilesystemRootSpecifier(value, cwd) {
692
712
 
693
713
  async function apply_patch(args, cwd, options = {}) {
694
714
  args = salvageShatteredV4APatchArgs(args);
695
- let patchStr = salvageV4AOpening((typeof args?.patch === 'string' ? args.patch : '').replace(/^\uFEFF/, ''));
715
+ let patchStr = expandCompactPatchInput(typeof args?.patch === 'string' ? args.patch : '');
716
+ patchStr = salvageV4AOpening(patchStr);
696
717
  const inlineRoot = extractInlinePatchRoot(patchStr);
697
718
  patchStr = inlineRoot.patch;
698
719
  if (inlineRoot.root) {
@@ -20,16 +20,41 @@ eof_line: "*** End of File" LF
20
20
  %import common.LF
21
21
  `;
22
22
 
23
+ const COMPACT_PATCH_LARK_GRAMMAR = `start: root_line? hunk+
24
+ root_line: "R " filename LF
25
+
26
+ hunk: add_hunk | delete_hunk | update_hunk
27
+ add_hunk: "A " filename LF add_line+
28
+ delete_hunk: "D " filename LF
29
+ update_hunk: "U " filename LF change_move? change
30
+
31
+ filename: /(.+)/
32
+ add_line: "+" /(.*)/ LF -> line
33
+
34
+ change_move: "M " filename LF
35
+ change: (change_context | change_line)+ eof_line?
36
+ change_context: ("@" | "@ " /(.+)/) LF
37
+ change_line: ("+" | "-" | " ") /(.*)/ LF
38
+ eof_line: "*** End of File" LF
39
+
40
+ %import common.LF
41
+ `;
42
+
43
+ const USE_COMPACT_PATCH_FORMAT = process.env.MIXDOG_COMPACT_PATCH_GRAMMAR !== '0';
44
+
23
45
  // Public contract: apply_patch is the PRIMARY edit tool and takes
24
46
  // a raw freeform V4A patch (no JSON envelope) on providers that support custom
25
47
  // grammar tools. No prior `read` is required or implied — send the patch as
26
48
  // soon as the target and content are known. The JSON schema below is only the
27
49
  // fallback for providers that cannot carry freeform/custom tools, so it exposes
28
- // the patch string alone; runtime-only knobs stay off the model surface.
50
+ // the patch string and an optional explicit base; runtime-only knobs stay off
51
+ // the model surface.
29
52
  // Batching stays a rules-level policy: every new edit goes in one patch, with
30
53
  // one file block per target.
31
54
  const APPLY_PATCH_FREEFORM_DESCRIPTION =
32
55
  'Edit files with `apply_patch`. FREEFORM input; do not wrap the patch in JSON.';
56
+ const COMPACT_PATCH_FREEFORM_DESCRIPTION =
57
+ 'Compact patch: U/A/D path, optional M rename or R root, @ hunks, then space/-/+ lines.';
33
58
 
34
59
  // JSON-schema fallback providers (Anthropic and other non-grammar surfaces)
35
60
  // get the full V4A instructions inline: without a grammar the model has
@@ -40,12 +65,20 @@ const APPLY_PATCH_FREEFORM_DESCRIPTION =
40
65
  const APPLY_PATCH_JSON_DESCRIPTION = [
41
66
  'Edit files with this V4A patch:',
42
67
  '*** Begin Patch',
68
+ '[optional *** Root: <path> for out-of-session writes]',
43
69
  '[file sections]',
44
70
  '*** End Patch',
45
71
  'Each section starts with exactly one: *** Add File: <path> (+ lines), *** Delete File: <path> (header only), or *** Update File: <path> (optional *** Move to: <new path>).',
46
72
  'Hunks start with @@ or @@ <symbol>; lines start space, -, or +; optional *** End of File.',
47
73
  'Use 3 verbatim context lines from newest output (post-patch body after edits); avoid overlap; stack @@ only if ambiguous.',
48
- 'Relative or explicit absolute paths; + every added line. Never send compacted-history markers; re-read first.',
74
+ 'Project-relative paths; explicit absolute paths only outside the project; + every added line. Never send compacted-history markers; re-read first.',
75
+ ].join('\n');
76
+
77
+ const COMPACT_PATCH_JSON_DESCRIPTION = [
78
+ 'Edit with a compact patch.',
79
+ 'Sections: A <path> (+ lines), D <path>, or U <path> (optional M <new path>).',
80
+ 'Optional first line R <root>. Update hunks start @ or @ <symbol>; lines start space, -, or +; optional *** End of File.',
81
+ 'Use 3 verbatim context lines. Project-relative paths; absolute only outside the project.',
49
82
  ].join('\n');
50
83
 
51
84
  export const PATCH_TOOL_DEFS = [
@@ -53,21 +86,18 @@ export const PATCH_TOOL_DEFS = [
53
86
  name: 'apply_patch',
54
87
  title: 'Mixdog Apply Patch',
55
88
  annotations: { title: 'Mixdog Apply Patch', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, compressible: false, compressibleLossless: true },
56
- description: APPLY_PATCH_JSON_DESCRIPTION,
57
- freeformDescription: APPLY_PATCH_FREEFORM_DESCRIPTION,
89
+ description: USE_COMPACT_PATCH_FORMAT ? COMPACT_PATCH_JSON_DESCRIPTION : APPLY_PATCH_JSON_DESCRIPTION,
90
+ freeformDescription: USE_COMPACT_PATCH_FORMAT ? COMPACT_PATCH_FREEFORM_DESCRIPTION : APPLY_PATCH_FREEFORM_DESCRIPTION,
58
91
  freeform: {
59
92
  type: 'grammar',
60
93
  syntax: 'lark',
61
- definition: APPLY_PATCH_LARK_GRAMMAR,
94
+ definition: USE_COMPACT_PATCH_FORMAT ? COMPACT_PATCH_LARK_GRAMMAR : APPLY_PATCH_LARK_GRAMMAR,
62
95
  },
63
96
  inputSchema: {
64
97
  type: 'object',
65
98
  properties: {
66
- patch: { type: 'string', description: 'V4A patch; format above.' },
67
- root: {
68
- type: 'string',
69
- description: 'Write root only outside the session directory.',
70
- },
99
+ patch: { type: 'string', description: USE_COMPACT_PATCH_FORMAT ? 'Compact patch.' : 'V4A patch.' },
100
+ root: { type: 'string', description: 'Explicit patch base directory.' },
71
101
  },
72
102
  required: ['patch'],
73
103
  additionalProperties: false,
@@ -11,6 +11,7 @@ import {
11
11
  import { SUMMARY_PREFIX } from '../runtime/agent/orchestrator/session/compact.mjs';
12
12
  import { hasUserConversationMessage } from '../runtime/agent/orchestrator/session/manager/prompt-utils.mjs';
13
13
  import {
14
+ resolveCurrentContextTokens,
14
15
  resolveCompactionPressureTokens,
15
16
  resolveWorkerCompactPolicy,
16
17
  } from '../runtime/agent/orchestrator/session/loop/compact-policy.mjs';
@@ -268,15 +269,18 @@ export function createContextStatus({
268
269
  ...resolveSessionCompactPolicy(session || {}, compactBoundaryTokens),
269
270
  tokenCalibration: providerTokenCalibration(session?.provider || route.provider),
270
271
  };
271
- // Match the pre-provider-send auto-compact check exactly: the gauge uses
272
- // the same provider-baseline-or-estimate pressure, including request and
273
- // configured reserves, rather than a separate transcript-only estimate.
272
+ // Keep the user-facing gauge aligned to provider-visible context. The
273
+ // compaction branch retains its stricter pressure numerator separately.
274
274
  const compactionPressureTokens = resolveCompactionPressureTokens(
275
275
  messageSummary.estimatedTokens,
276
276
  compactPolicy,
277
277
  { messages, sessionRef: session },
278
278
  );
279
- const usedTokens = compactionPressureTokens;
279
+ const usedTokens = resolveCurrentContextTokens(
280
+ messageSummary.estimatedTokens,
281
+ compactPolicy,
282
+ { messages, sessionRef: session },
283
+ );
280
284
  const freeTokens = displayWindow ? Math.max(0, displayWindow - usedTokens) : 0;
281
285
  const compactTriggerTokens = compactPolicy.triggerTokens || 0;
282
286
  const compactBufferTokens = Number.isFinite(Number(compactPolicy.bufferTokens))
@@ -297,7 +301,7 @@ export function createContextStatus({
297
301
  effectiveContextWindowPercent: session?.effectiveContextWindowPercent || null,
298
302
  usedTokens,
299
303
  usedSource: 'estimated',
300
- currentEstimatedTokens: compactionPressureTokens,
304
+ currentEstimatedTokens: usedTokens,
301
305
  lastApiRequestTokens: lastContextTokens || 0,
302
306
  lastApiRequestStale: lastUsageStale,
303
307
  freeTokens,
@@ -308,6 +312,8 @@ export function createContextStatus({
308
312
  bufferTokens: Number.isFinite(compactBufferTokens) ? compactBufferTokens : null,
309
313
  bufferRatio: compactBufferRatio,
310
314
  currentEstimatedTokens: compactionPressureTokens,
315
+ pressureTokens: compactionPressureTokens,
316
+ reserveTokens: Math.max(0, Number(compactPolicy.configuredReserveTokens) || 0),
311
317
  lastApiRequestTokens: lastContextTokens || 0,
312
318
  lastApiRequestStale: lastUsageStale,
313
319
  },
@@ -24,10 +24,9 @@ export const EXPLORE_TOOL = {
24
24
  type: 'object',
25
25
  properties: {
26
26
  query: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'One concrete unknown target per query; return its minimal complete direct path:line set. Never a topic list; array = independent targets fanned out.' },
27
- cwd: { type: 'string', description: 'Project/root directory.' },
28
27
  roots: {
29
28
  anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }],
30
- description: 'Search root(s); defaults to cwd. Use multiple roots for cross-project or machine lookup.',
29
+ description: 'Project-relative or absolute external search root(s); omit for the caller project; use multiple roots for cross-project or machine lookup.',
31
30
  },
32
31
  },
33
32
  required: [],
@@ -157,6 +157,8 @@ export function createUsageContextPanels({
157
157
  const compactDescription = compactDuration
158
158
  ? `${compactState} · ${compactDuration}`
159
159
  : compactState;
160
+ const compactPressure = Number(compaction.pressureTokens || compaction.currentEstimatedTokens || 0);
161
+ const compactReserve = Number(compaction.reserveTokens || 0);
160
162
  const contextRows = [
161
163
  {
162
164
  value: 'summary',
@@ -167,7 +169,7 @@ export function createUsageContextPanels({
167
169
  {
168
170
  value: 'compaction',
169
171
  label: 'Compaction',
170
- description: compactDescription,
172
+ description: `${compactDescription} · ${fmt(compactPressure)} pressure · ${fmt(compactReserve)} reserve`,
171
173
  _action: 'compaction',
172
174
  },
173
175
  {
@@ -249,7 +251,8 @@ export function createUsageContextPanels({
249
251
  if (compactBoundary && compactTrigger) return Math.max(0, compactBoundary - compactTrigger);
250
252
  return null;
251
253
  })(),
252
- pressureTokens: Number(compaction.lastPressureTokens || compaction.currentEstimatedTokens || 0) || null,
254
+ pressureTokens: Number(compaction.lastPressureTokens || compaction.pressureTokens || compaction.currentEstimatedTokens || 0) || null,
255
+ reserveTokens: Number(compaction.reserveTokens || 0) || null,
253
256
  lastChanged: compaction.lastChanged === true,
254
257
  },
255
258
  messages: {
@@ -176,6 +176,8 @@ function ContextUsageView({ detail, columns }) {
176
176
  compaction.type ? `type ${compaction.type}` : '',
177
177
  compaction.triggerTokens ? `trigger ${formatTokens(compaction.triggerTokens)}` : '',
178
178
  compaction.boundaryTokens ? `boundary ${formatTokens(compaction.boundaryTokens)}` : '',
179
+ compaction.pressureTokens ? `pressure ${formatTokens(compaction.pressureTokens)}` : '',
180
+ compaction.reserveTokens ? `reserve ${formatTokens(compaction.reserveTokens)}` : '',
179
181
  ]);
180
182
  const sourceLine = metricValue([
181
183
  usage.effective ? `effective ${formatTokens(windowTokens)}` : `window ${formatTokens(windowTokens)}`,
@@ -7626,7 +7626,9 @@ function ContextUsageView({ detail, columns }) {
7626
7626
  compaction.state,
7627
7627
  compaction.type ? `type ${compaction.type}` : "",
7628
7628
  compaction.triggerTokens ? `trigger ${formatTokens(compaction.triggerTokens)}` : "",
7629
- compaction.boundaryTokens ? `boundary ${formatTokens(compaction.boundaryTokens)}` : ""
7629
+ compaction.boundaryTokens ? `boundary ${formatTokens(compaction.boundaryTokens)}` : "",
7630
+ compaction.pressureTokens ? `pressure ${formatTokens(compaction.pressureTokens)}` : "",
7631
+ compaction.reserveTokens ? `reserve ${formatTokens(compaction.reserveTokens)}` : ""
7630
7632
  ]);
7631
7633
  const sourceLine = metricValue([
7632
7634
  usage.effective ? `effective ${formatTokens(windowTokens)}` : `window ${formatTokens(windowTokens)}`,
@@ -13797,6 +13799,8 @@ function createUsageContextPanels({
13797
13799
  const compactReactive = String(compaction.lastTrigger || "").toLowerCase() === "reactive";
13798
13800
  const compactState = compactRunning ? "Compacting conversation" : compactInterrupted ? "Compact interrupted" : autoClearFailed ? `auto-clear skipped${compaction.lastClearCompactError ? `: ${compaction.lastClearCompactError}` : ""}` : autoClearStage ? "Auto-clear complete" : compaction.lastChanged ? compactReactive ? "Compact complete (overflow recovery)" : "Compact complete" : "Compact checked";
13799
13801
  const compactDescription = compactDuration ? `${compactState} \xB7 ${compactDuration}` : compactState;
13802
+ const compactPressure = Number(compaction.pressureTokens || compaction.currentEstimatedTokens || 0);
13803
+ const compactReserve = Number(compaction.reserveTokens || 0);
13800
13804
  const contextRows = [
13801
13805
  {
13802
13806
  value: "summary",
@@ -13807,7 +13811,7 @@ function createUsageContextPanels({
13807
13811
  {
13808
13812
  value: "compaction",
13809
13813
  label: "Compaction",
13810
- description: compactDescription,
13814
+ description: `${compactDescription} \xB7 ${fmt(compactPressure)} pressure \xB7 ${fmt(compactReserve)} reserve`,
13811
13815
  _action: "compaction"
13812
13816
  },
13813
13817
  {
@@ -13889,7 +13893,8 @@ function createUsageContextPanels({
13889
13893
  if (compactBoundary && compactTrigger) return Math.max(0, compactBoundary - compactTrigger);
13890
13894
  return null;
13891
13895
  })(),
13892
- pressureTokens: Number(compaction.lastPressureTokens || compaction.currentEstimatedTokens || 0) || null,
13896
+ pressureTokens: Number(compaction.lastPressureTokens || compaction.pressureTokens || compaction.currentEstimatedTokens || 0) || null,
13897
+ reserveTokens: Number(compaction.reserveTokens || 0) || null,
13893
13898
  lastChanged: compaction.lastChanged === true
13894
13899
  },
13895
13900
  messages: {