mixdog 0.9.9 → 0.9.11

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 (49) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-tag-reuse-smoke.mjs +12 -8
  3. package/scripts/bench/cache-probe-tasks.json +8 -0
  4. package/scripts/bench/lead-review-tasks-r3.json +1 -1
  5. package/scripts/bench/r4-mixed-tasks.json +1 -1
  6. package/scripts/bench/review-tasks.json +1 -1
  7. package/scripts/build-runtime-windows.ps1 +242 -242
  8. package/scripts/recall-usecase-cases.json +1 -1
  9. package/scripts/smoke-runtime-negative.ps1 +106 -106
  10. package/scripts/tool-smoke.mjs +109 -0
  11. package/src/defaults/mixdog-config.template.json +1 -0
  12. package/src/mixdog-session-runtime.mjs +8 -0
  13. package/src/rules/agent/30-explorer.md +18 -20
  14. package/src/rules/lead/02-channels.md +3 -3
  15. package/src/runtime/agent/orchestrator/config.mjs +30 -0
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +160 -0
  17. package/src/runtime/agent/orchestrator/mcp/client.mjs +25 -1
  18. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +24 -9
  19. package/src/runtime/agent/orchestrator/providers/gemini.mjs +18 -2
  20. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -0
  22. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +79 -0
  23. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  24. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +9 -2
  25. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -3
  26. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +5 -5
  27. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +1 -22
  28. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +26 -3
  29. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +29 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +17 -16
  31. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -1
  32. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +4 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +30 -4
  34. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +333 -14
  35. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +129 -2
  36. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +58 -18
  37. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +56 -83
  38. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +26 -56
  39. package/src/runtime/channels/lib/webhook/deliveries.mjs +4 -1
  40. package/src/session-runtime/settings-api.mjs +13 -0
  41. package/src/session-runtime/tool-catalog.mjs +34 -7
  42. package/src/standalone/agent-tool/render.mjs +2 -0
  43. package/src/standalone/agent-tool.mjs +28 -6
  44. package/src/standalone/explore-tool.mjs +2 -2
  45. package/src/standalone/memory-runtime-proxy.mjs +85 -21
  46. package/src/tui/App.jsx +20 -1
  47. package/src/tui/app/extension-pickers.mjs +6 -3
  48. package/src/tui/dist/index.mjs +28 -4
  49. package/src/tui/engine.mjs +2 -0
@@ -1,12 +1,14 @@
1
1
  import { readdirSync } from 'fs';
2
2
  import { basename, relative } from 'path';
3
3
  import {
4
+ coerceReadFamilyPathArg,
4
5
  extractGlobBaseDirectory,
5
6
  hasGlobMagic,
6
7
  normalizeInputPath,
7
8
  normalizeOutputPath,
8
9
  resolveAgainstCwd,
9
10
  } from './path-utils.mjs';
11
+ import { tryReadFamilyEnoentRedirect } from './search-path-diagnostics.mjs';
10
12
  import { normalizeErrorMessage } from './path-diagnostics.mjs';
11
13
  import { isUncPath, isWindowsDevicePath, hasUnsafeWin32Component } from './device-paths.mjs';
12
14
  import {
@@ -37,6 +39,19 @@ const LIST_WALK_TIMEOUT_MS = 20_000;
37
39
  const LIST_ABSOLUTE_CAP = 50_000;
38
40
 
39
41
  /** undefined / invalid / negative → defaultCap; 0 = no page cap (absolute caps still apply). */
42
+ async function readFamilyPathEnoentOrError(workDir, fullPath, inputPath, args, options, err, rerunTool) {
43
+ const redirected = await tryReadFamilyEnoentRedirect({
44
+ workDir,
45
+ resolvedPath: fullPath,
46
+ requestedPath: inputPath,
47
+ errCode: err?.code,
48
+ options,
49
+ rerun: (target, opts) => rerunTool({ ...args, path: target }, workDir, opts),
50
+ });
51
+ if (redirected) return redirected;
52
+ return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
53
+ }
54
+
40
55
  function normalizeListHeadLimit(raw, defaultCap) {
41
56
  if (raw === undefined || raw === null || raw === '') return defaultCap;
42
57
  const n = Number(raw);
@@ -60,6 +75,7 @@ function listGuardPath(p) {
60
75
  }
61
76
 
62
77
  export async function executeListTool(args, workDir, options = {}) {
78
+ args.path = coerceReadFamilyPathArg(args.path, workDir);
63
79
  if (Array.isArray(args.path)) {
64
80
  const list = [...new Set(args.path.map((p) => (typeof p === 'string' ? p.trim() : '')).filter((p) => p.length > 0))];
65
81
  const capped = list.length > 10;
@@ -120,7 +136,9 @@ export async function executeListTool(args, workDir, options = {}) {
120
136
  catch (err) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`; }
121
137
  let st;
122
138
  try { st = getCachedReadOnlyStat(fullPath); }
123
- catch (err) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`; }
139
+ catch (err) {
140
+ return await readFamilyPathEnoentOrError(workDir, fullPath, inputPath, args, options, err, executeListTool);
141
+ }
124
142
  if (!st.isDirectory()) return `Error: not a directory — ${normalizeOutputPath(fullPath)}`;
125
143
 
126
144
  const rows = [];
@@ -251,7 +269,9 @@ export async function executeTreeTool(args, workDir, options = {}) {
251
269
  catch (err) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`; }
252
270
  let st;
253
271
  try { st = getCachedReadOnlyStat(fullPath); }
254
- catch (err) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`; }
272
+ catch (err) {
273
+ return await readFamilyPathEnoentOrError(workDir, fullPath, inputPath, args, options, err, executeListTool);
274
+ }
255
275
  if (!st.isDirectory()) return `Error: not a directory — ${normalizeOutputPath(fullPath)}`;
256
276
  const lines = [`${normalizeOutputPath(fullPath)}/`];
257
277
  const prefixStack = [''];
@@ -407,6 +427,7 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
407
427
  }
408
428
 
409
429
  export async function executeFindFilesTool(args, workDir, options = {}) {
430
+ args.path = coerceReadFamilyPathArg(args.path, workDir);
410
431
  args.path = normalizeInputPath(args.path);
411
432
  let inputPath = args.path || '.';
412
433
  let namePattern = typeof args.name === 'string' ? args.name : null;
@@ -511,7 +532,9 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
511
532
  catch (err) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`; }
512
533
  let rootStat;
513
534
  try { rootStat = getCachedReadOnlyStat(fullPath); }
514
- catch (err) { return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`; }
535
+ catch (err) {
536
+ return await readFamilyPathEnoentOrError(workDir, fullPath, inputPath, args, options, err, executeFindFilesTool);
537
+ }
515
538
  if (!rootStat.isDirectory()) return `Error: not a directory — ${normalizeOutputPath(fullPath)}`;
516
539
 
517
540
  const matches = [];
@@ -1,6 +1,6 @@
1
1
  import { homedir } from 'os';
2
2
  import { isAbsolute, relative, resolve } from 'path';
3
- import { realpathSync } from 'node:fs';
3
+ import { realpathSync, statSync } from 'node:fs';
4
4
  import { isWSL } from '../../../../shared/wsl.mjs';
5
5
 
6
6
  // Restore the on-disk casing of a path (win32 only). rg relativizes candidate
@@ -237,3 +237,31 @@ export function coerceShapeFlex(value) {
237
237
  }
238
238
  return value;
239
239
  }
240
+
241
+ // JSON-stringified path arrays (path:"[]") and empty arrays mean "search cwd".
242
+ // Bracket-shaped strings that exist on disk (e.g. a literal `[x]` directory) are
243
+ // left untouched — JSON reinterpretation only runs after a stat miss.
244
+ export function coerceReadFamilyPathArg(path, workDir = null) {
245
+ if (path === undefined || path === null || path === '') return path;
246
+ if (typeof path === 'string' && typeof workDir === 'string' && workDir) {
247
+ const trimmed = path.trim();
248
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
249
+ try {
250
+ const resolved = resolveAgainstCwd(normalizeInputPath(trimmed), workDir);
251
+ statSync(resolved);
252
+ return path;
253
+ } catch { /* not a literal bracket path — allow JSON coercion */ }
254
+ }
255
+ }
256
+ const coerced = coerceShapeFlex(path);
257
+ if (!Array.isArray(coerced)) return coerced;
258
+ const list = coerced
259
+ // Region objects ({path,offset,limit}) must pass through untouched —
260
+ // read's batch dispatcher consumes them. Only strings are trimmed.
261
+ .map((p) => (typeof p === 'string' ? p.trim() : p))
262
+ .filter((p) => (typeof p === 'string' ? p.length > 0 : (p && typeof p === 'object')));
263
+ if (list.length === 0) return '.';
264
+ // Collapse to scalar only for a lone string; a lone region object must
265
+ // stay an array so read's object-batch dispatcher handles it.
266
+ return list.length === 1 && typeof list[0] === 'string' ? list[0] : list;
267
+ }
@@ -3,7 +3,7 @@ import * as fsPromises from 'fs/promises';
3
3
  import { readFile } from 'fs/promises';
4
4
  import { extname } from 'path';
5
5
  import { normalizeInputPath } from './path-utils.mjs';
6
- import { findFileByBasename, findBySuffixStrip } from './path-diagnostics.mjs';
6
+ import { buildNotFoundHint, tryReadFamilyEnoentRedirect } from './search-path-diagnostics.mjs';
7
7
  import { getReadSnapshot } from './read-snapshot-runtime.mjs';
8
8
  import { snapshotCoversFullFile, statMatchesSnapshot } from './snapshot-helpers.mjs';
9
9
  import { formatBinaryReadPreview } from './binary-file.mjs';
@@ -202,24 +202,25 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
202
202
  }
203
203
  if (!st) {
204
204
  const err = _statErr;
205
+ const redirected = await tryReadFamilyEnoentRedirect({
206
+ workDir,
207
+ resolvedPath: fullPath,
208
+ requestedPath: filePath,
209
+ errCode: err?.code,
210
+ options,
211
+ rerun: (target, opts) => executeSingleReadTool(
212
+ { ...args, path: target },
213
+ workDir,
214
+ readStateScope,
215
+ opts,
216
+ helpers,
217
+ ),
218
+ });
219
+ if (redirected) return redirected;
205
220
  const similar = findSimilarFile(fullPath);
206
221
  let hint = similar ? ` Did you mean "${normalizeOutputPath(similar)}"?` : '';
207
- // Right-name / wrong-directory miss: findSimilarFile only checks the
208
- // same dir. Locate the basename elsewhere in the tree and name the real
209
- // path(s) directly — the route a model would otherwise reconstruct with
210
- // a grep/glob storm.
211
222
  if (!similar) {
212
- // Hallucinated absolute-prefix recovery first: no directory walk,
213
- // stats directly (finds vendor/ paths the basename BFS skips).
214
- const suffixHit = findBySuffixStrip(workDir, fullPath);
215
- if (suffixHit) {
216
- hint = ` Not found at this path; the same filename exists at: "${normalizeOutputPath(suffixHit)}". Read that path directly.`;
217
- } else {
218
- const elsewhere = findFileByBasename(workDir, fullPath);
219
- if (elsewhere.length) {
220
- hint = ` Not found at this path; the same filename exists at: ${elsewhere.map((p) => `"${normalizeOutputPath(p)}"`).join(', ')}. Read that path directly.`;
221
- }
222
- }
223
+ hint = buildNotFoundHint(workDir, fullPath, 'Read', err?.code);
223
224
  }
224
225
  const _rawMsg = err instanceof Error ? err.message : String(err);
225
226
  const _safeMsg = normalizeErrorMessage(_rawMsg, workDir);
@@ -5,6 +5,7 @@ import { readEntryCoalescedDiskWindow } from './read-batch.mjs';
5
5
  import { readPathStringGuardError } from './read-open.mjs';
6
6
  import { parseReadLineNumberArg } from './read-args.mjs';
7
7
  import { assertPathsReachable } from './fs-reachability.mjs';
8
+ import { coerceReadFamilyPathArg } from './path-utils.mjs';
8
9
 
9
10
  function hasLineCoordinate(path) {
10
11
  return typeof path === 'string' && /(?:#L\d+|:\d+(?:-\d+)?(?::|$))/i.test(path);
@@ -109,7 +110,7 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
109
110
  args.offset = Math.trunc(ccOffset) - 1;
110
111
  }
111
112
  }
112
- args.path = coerceShapeFlex(args.path);
113
+ args.path = coerceReadFamilyPathArg(args.path, workDir);
113
114
  // Reachability preflight up front (all shapes) — before readPathStringGuardError /
114
115
  // image stat, all of which can touch sync FS.
115
116
  {
@@ -30,6 +30,7 @@ export function buildGrepCacheKey(parts) {
30
30
  fileType,
31
31
  onlyMatching,
32
32
  pcre2,
33
+ withFilename,
33
34
  } = parts;
34
35
  return [
35
36
  'grep',
@@ -48,6 +49,7 @@ export function buildGrepCacheKey(parts) {
48
49
  onlyMatching ? 'o1' : 'o0',
49
50
  Array.isArray(fileType) ? fileType.join('\x01') : (fileType || ''),
50
51
  pcre2 ? 'p1' : 'p0',
52
+ withFilename ? 'H1' : 'H0',
51
53
  ].join('|');
52
54
  }
53
55
 
@@ -67,6 +69,7 @@ export function buildGrepRgArgs(parts) {
67
69
  onlyMatching,
68
70
  fixedStrings = false,
69
71
  pcre2 = false,
72
+ withFilename = false,
70
73
  } = parts;
71
74
  // `--hidden`: search dotfiles/dot-dirs (.github, .mixdog) that
72
75
  // rg skips by default. The DEFAULT_IGNORE_GLOBS below still exclude .git and
@@ -78,6 +81,7 @@ export function buildGrepRgArgs(parts) {
78
81
  rgArgs.push('--count');
79
82
  } else {
80
83
  rgArgs.push('--no-heading');
84
+ if (withFilename) rgArgs.push('-H');
81
85
  if (showLineNumbers) rgArgs.push('--line-number');
82
86
  if (beforeN !== null) rgArgs.push('-B', String(beforeN));
83
87
  if (afterN !== null) rgArgs.push('-A', String(afterN));
@@ -64,12 +64,38 @@ export function stripEmbeddedPathQuotes(p) {
64
64
  // not-found codes: EACCES/EPERM etc. keep their real failure semantics and
65
65
  // must not trigger same-basename guidance or the BFS scan.
66
66
  const NOT_FOUND_CODES = new Set(['ENOENT', 'ENOTDIR']);
67
+ export function resolveUniqueEnoentRedirect(workDir, missingPath, errCode = 'ENOENT') {
68
+ if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return null;
69
+ const suffixHit = findBySuffixStrip(workDir, missingPath);
70
+ if (suffixHit) return suffixHit;
71
+ const elsewhere = findFileByBasename(workDir, missingPath);
72
+ if (elsewhere.length === 1) return elsewhere[0];
73
+ return null;
74
+ }
75
+
76
+ export function redirectedFromPrefix(requestedPath) {
77
+ return `[redirected from ${normalizeOutputPath(requestedPath)}]\n`;
78
+ }
79
+
80
+ export async function tryReadFamilyEnoentRedirect({
81
+ workDir,
82
+ resolvedPath,
83
+ requestedPath,
84
+ errCode,
85
+ options,
86
+ rerun,
87
+ }) {
88
+ if (options?._enoentRedirectFrom) return null;
89
+ const target = resolveUniqueEnoentRedirect(workDir, resolvedPath, errCode);
90
+ if (!target) return null;
91
+ const body = await rerun(target, { ...options, _enoentRedirectFrom: resolvedPath });
92
+ const shown = requestedPath ?? resolvedPath;
93
+ return redirectedFromPrefix(shown) + body;
94
+ }
95
+
67
96
  export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'ENOENT') {
68
97
  if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return '';
69
- const suffixHit = findBySuffixStrip(workDir, missingPath);
70
- if (suffixHit) {
71
- return ` Not found at this path; the same filename exists at: "${normalizeOutputPath(suffixHit)}". ${actionVerb} that path directly.`;
72
- }
98
+ if (resolveUniqueEnoentRedirect(workDir, missingPath, errCode)) return '';
73
99
  const elsewhere = findFileByBasename(workDir, missingPath);
74
100
  if (elsewhere.length) {
75
101
  return ` Not found at this path; the same filename exists at: ${elsewhere.map((p) => `"${normalizeOutputPath(p)}"`).join(', ')}. ${actionVerb} that path directly.`;