mixdog 0.9.37 → 0.9.39

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 (127) hide show
  1. package/package.json +9 -4
  2. package/scripts/abort-recovery-test.mjs +43 -2
  3. package/scripts/agent-tag-reuse-smoke.mjs +146 -6
  4. package/scripts/agent-terminal-reap-test.mjs +127 -0
  5. package/scripts/agent-trace-io-test.mjs +69 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +224 -0
  7. package/scripts/dispatch-persist-recovery-test.mjs +141 -0
  8. package/scripts/execution-completion-dedup-test.mjs +157 -0
  9. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  10. package/scripts/execution-resume-esc-integration-test.mjs +174 -0
  11. package/scripts/explore-bench.mjs +101 -8
  12. package/scripts/explore-prompt-policy-test.mjs +156 -11
  13. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  14. package/scripts/internal-comms-bench-test.mjs +226 -0
  15. package/scripts/internal-comms-bench.mjs +185 -58
  16. package/scripts/internal-comms-smoke.mjs +171 -23
  17. package/scripts/live-worker-smoke.mjs +38 -2
  18. package/scripts/memory-cycle-routing-test.mjs +111 -0
  19. package/scripts/memory-rule-contract-test.mjs +93 -0
  20. package/scripts/notify-completion-mirror-test.mjs +73 -0
  21. package/scripts/output-style-smoke.mjs +2 -2
  22. package/scripts/rg-runner-test.mjs +240 -0
  23. package/scripts/routing-corpus-test.mjs +349 -0
  24. package/scripts/routing-corpus.mjs +211 -32
  25. package/scripts/session-orphan-sweep-test.mjs +83 -0
  26. package/scripts/session-sweep.mjs +266 -0
  27. package/scripts/steering-drain-buckets-test.mjs +179 -0
  28. package/scripts/tool-smoke.mjs +21 -13
  29. package/scripts/tool-tui-presentation-test.mjs +202 -0
  30. package/src/agents/heavy-worker/AGENT.md +10 -7
  31. package/src/agents/reviewer/AGENT.md +6 -4
  32. package/src/agents/worker/AGENT.md +7 -5
  33. package/src/rules/agent/00-common.md +4 -4
  34. package/src/rules/agent/00-core.md +11 -14
  35. package/src/rules/agent/20-skip-protocol.md +3 -3
  36. package/src/rules/agent/30-explorer.md +56 -48
  37. package/src/rules/agent/40-cycle1-agent.md +15 -24
  38. package/src/rules/agent/41-cycle2-agent.md +33 -57
  39. package/src/rules/agent/42-cycle3-agent.md +28 -42
  40. package/src/rules/lead/01-general.md +7 -10
  41. package/src/rules/lead/lead-brief.md +11 -14
  42. package/src/rules/lead/lead-tool.md +6 -5
  43. package/src/rules/shared/01-tool.md +44 -41
  44. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
  45. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  46. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  47. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  48. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  49. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
  50. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  51. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
  52. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  53. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  54. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  55. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  56. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
  57. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  58. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  59. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
  60. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/session/store.mjs +339 -63
  62. package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  66. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  67. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  68. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  69. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  74. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  75. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  76. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  77. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  80. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  81. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  82. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  83. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  84. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  85. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  86. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  87. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  88. package/src/runtime/shared/tool-primitives.mjs +4 -1
  89. package/src/runtime/shared/tool-status.mjs +27 -0
  90. package/src/runtime/shared/tool-surface.mjs +6 -3
  91. package/src/session-runtime/config-helpers.mjs +14 -0
  92. package/src/session-runtime/context-status.mjs +1 -0
  93. package/src/session-runtime/effort.mjs +6 -2
  94. package/src/session-runtime/model-recency.mjs +5 -2
  95. package/src/session-runtime/provider-models.mjs +3 -3
  96. package/src/session-runtime/runtime-core.mjs +78 -10
  97. package/src/session-runtime/tool-catalog.mjs +34 -0
  98. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  99. package/src/standalone/agent-tool/notify.mjs +13 -0
  100. package/src/standalone/agent-tool.mjs +45 -69
  101. package/src/standalone/explore-tool.mjs +6 -7
  102. package/src/tui/App.jsx +31 -0
  103. package/src/tui/app/model-options.mjs +5 -3
  104. package/src/tui/app/model-picker.mjs +12 -24
  105. package/src/tui/app/transcript-window.mjs +1 -0
  106. package/src/tui/components/ToolExecution.jsx +11 -6
  107. package/src/tui/components/TranscriptItem.jsx +1 -1
  108. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  109. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  110. package/src/tui/dist/index.mjs +533 -143
  111. package/src/tui/engine/agent-job-feed.mjs +153 -16
  112. package/src/tui/engine/agent-response-tail.mjs +68 -0
  113. package/src/tui/engine/notification-plan.mjs +16 -0
  114. package/src/tui/engine/queue-helpers.mjs +8 -0
  115. package/src/tui/engine/session-api.mjs +8 -2
  116. package/src/tui/engine/session-flow.mjs +34 -2
  117. package/src/tui/engine/tool-card-results.mjs +54 -32
  118. package/src/tui/engine/tool-result-status.mjs +75 -21
  119. package/src/tui/engine/turn.mjs +83 -43
  120. package/src/tui/engine.mjs +63 -2
  121. package/src/workflows/bench/WORKFLOW.md +25 -35
  122. package/src/workflows/default/WORKFLOW.md +38 -32
  123. package/src/workflows/solo/WORKFLOW.md +19 -22
  124. package/scripts/_jitter-fuzz.mjs +0 -44410
  125. package/scripts/_jitter-fuzz2.mjs +0 -44400
  126. package/scripts/_jitter-probe.mjs +0 -44397
  127. package/scripts/_jp2.mjs +0 -45614
@@ -191,6 +191,43 @@ export const PROVIDER_WS_ACQUIRE_TIMEOUT_MS = resolveTimeoutMs(
191
191
  { minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
192
192
  );
193
193
 
194
+ // WS pool liveness (ping/pong) — closes the gap between a socket going
195
+ // half-open (peer gone / TLS wedge) and the semantic-idle watchdog noticing
196
+ // (120s). Node `ws` send() is fire-and-forget, so a dead pooled socket
197
+ // silently blackholes response.create frames until a downstream timeout. An
198
+ // idle pooled socket is pinged on this cadence; a reused socket that has been
199
+ // quiet longer than the stale window is ping-probed before hand-out.
200
+ // Gated OFF by default for codex/opencode parity — neither pings model
201
+ // sockets. Enable with MIXDOG_PROVIDER_WS_PING_ENABLED=1. When disabled, no
202
+ // liveness interval is armed and the acquire-reuse ping probe is skipped (the
203
+ // non-OPEN eviction scan still runs); lastAliveAt stamping is harmless.
204
+ const _wsPingRaw = process.env.MIXDOG_PROVIDER_WS_PING_ENABLED;
205
+ export const PROVIDER_WS_PING_ENABLED = _wsPingRaw === '1' || _wsPingRaw === 'true' || _wsPingRaw === 'yes';
206
+
207
+ export const PROVIDER_WS_PING_INTERVAL_MS = resolveTimeoutMs(
208
+ 'MIXDOG_PROVIDER_WS_PING_INTERVAL_MS',
209
+ 30_000,
210
+ { minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
211
+ );
212
+
213
+ // Missed-pong grace: after a ping, the pong must land within this bound or the
214
+ // socket is treated as dead (closed + evicted). Doubles as the short probe
215
+ // bound on the acquire-reuse path so a busy caller is never handed a wedged
216
+ // socket.
217
+ export const PROVIDER_WS_PONG_TIMEOUT_MS = resolveTimeoutMs(
218
+ 'MIXDOG_PROVIDER_WS_PONG_TIMEOUT_MS',
219
+ 5_000,
220
+ { minMs: 1_000, maxMs: 60_000 },
221
+ );
222
+
223
+ // Activity freshness window: a pooled socket with observed activity (pong /
224
+ // release) newer than this is assumed live and skips the acquire-reuse probe.
225
+ export const PROVIDER_WS_LIVENESS_STALE_MS = resolveTimeoutMs(
226
+ 'MIXDOG_PROVIDER_WS_LIVENESS_STALE_MS',
227
+ 30_000,
228
+ { minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
229
+ );
230
+
194
231
  // Single inter-chunk idle timer (300s), matching the upstream WS provider's
195
232
  // default stream idle timeout. Mixdog resets one idle timer on every received
196
233
  // WS frame (openai-oauth-ws messageHandler resets on every parsed event). There
@@ -79,6 +79,11 @@ function clearGrepContextKeys(args, keys) {
79
79
  /** Lead-facing grep context: canonicalize aliases and clamp explicit values (overrides still apply). */
80
80
  export function applyGrepContextLeadPolicy(args) {
81
81
  if (!args || typeof args !== 'object' || Array.isArray(args)) return;
82
+ // Idempotent guard: the outer builtin guard (validateBuiltinArgs) and the
83
+ // executor (executeGrepTool) both call this on the SAME args object. The
84
+ // first pass folds aliases onto -A/-B/-C and clamps; a second pass would
85
+ // recompute the same folding for no effect. Skip once applied.
86
+ if (args._grepContextPolicyApplied) return;
82
87
  for (const [canonical, keys] of GREP_CONTEXT_KEY_GROUPS) {
83
88
  const found = firstNonZeroGrepContextArg(args, keys) || firstGrepContextArg(args, keys);
84
89
  if (!found) continue;
@@ -87,6 +92,12 @@ export function applyGrepContextLeadPolicy(args) {
87
92
  clearGrepContextKeys(args, keys);
88
93
  args[canonical] = shaped;
89
94
  }
95
+ // Non-enumerable so it never leaks into arg spreads, cache keys, or output.
96
+ try {
97
+ Object.defineProperty(args, '_grepContextPolicyApplied', {
98
+ value: true, enumerable: false, configurable: true, writable: true,
99
+ });
100
+ } catch { args._grepContextPolicyApplied = true; }
90
101
  }
91
102
 
92
103
  function isString(v) {
@@ -435,7 +446,7 @@ function guardGrep(a) {
435
446
  const grepMode = a.output_mode || a.mode;
436
447
  const hasExplicitCtx = ['-A', '-B', '-C', 'context'].some((k) => grepContextKeyPresent(a, k));
437
448
  const isCountOrFiles = grepMode === 'files_with_matches' || grepMode === 'count';
438
- if (!isCountOrFiles && (grepMode === 'content_with_context' || hasExplicitCtx)) {
449
+ if (!isCountOrFiles && (grepMode == null || grepMode === 'content_with_context' || hasExplicitCtx)) {
439
450
  const hl = Number(a.head_limit);
440
451
  if (grepContextKeyPresent(a, 'head_limit') && Number.isFinite(hl) && hl > GREP_CTX_HEAD_LIMIT_MAX) {
441
452
  a.head_limit = GREP_CTX_HEAD_LIMIT_MAX;
@@ -39,7 +39,7 @@ export const BUILTIN_TOOLS = [
39
39
  name: 'read',
40
40
  title: 'Mixdog Read',
41
41
  annotations: { title: 'Mixdog Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
42
- description: 'Read verified file path(s). Unknown path → find first. Batch paths/regions as real arrays in one call. Not for directory listing.',
42
+ description: 'Read verified file path(s); guessed path/name → find first. Array-first: batch paths/regions as real arrays in one call; put targets in path[] or {path,offset,limit}[] and read the whole logical unit. Content_with_context is actionable; do not page or re-read returned spans. Not for directory listing.',
43
43
  inputSchema: {
44
44
  type: 'object',
45
45
  properties: {
@@ -58,29 +58,31 @@ export const BUILTIN_TOOLS = [
58
58
  offset: { type: 'number', minimum: 0 },
59
59
  limit: { type: 'number', minimum: 1 },
60
60
  },
61
+ required: ['path'],
61
62
  },
62
63
  ],
63
64
  },
64
65
  minItems: 1,
65
66
  },
66
67
  ],
67
- description: 'Verified file path. Batch spans via path[] or {path,offset,limit}[] regions. Pass arrays directly; JSON strings are legacy recovery only.',
68
+ description: 'Verified file path; use {path,offset,limit}[] targets. Pass arrays directly; JSON strings are legacy recovery only.',
68
69
  },
69
- offset: { type: 'number', minimum: 0, description: 'Numeric lines to skip before reading; 0 starts at line 1. Continue with offset:N.' },
70
- limit: { type: 'number', minimum: 1, description: 'Numeric max lines to return after offset.' },
70
+ offset: { type: 'number', minimum: 0, description: 'Numeric lines to skip; 0 starts at line 1. Continue with offset:N.' },
71
+ limit: { type: 'number', minimum: 1, description: 'Numeric max lines after offset.' },
71
72
  },
73
+ required: ['path'],
72
74
  },
73
75
  },
74
76
  {
75
77
  name: 'shell',
76
78
  title: 'Mixdog Shell',
77
79
  annotations: { title: 'Mixdog Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
78
- description: `Run programs or change system state. Set shell: powershell or bash. Not for reading, listing, or searching files. Batch independent commands: ;/&& in one call, or parallel calls in the same turn.${_shellSyntaxCheat} ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
80
+ description: `Run programs or change system state; set shell: powershell or bash. Not for reading, listing or searching. Shell/write calls are serial; use ;/&& only for intentional in-command sequencing.${_shellSyntaxCheat} ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
79
81
  inputSchema: {
80
82
  type: 'object',
81
83
  properties: {
82
84
  command: { type: 'string', description: 'Command.' },
83
- cwd: { type: 'string', description: 'Working directory. Persists across shell calls in a session — omit to reuse the previous command\'s directory (e.g. after cd); pass an absolute path to change it.' },
85
+ cwd: { type: 'string', description: 'Working directory; persists across calls. Omit to reuse; absolute path changes it.' },
84
86
  timeout: { type: 'number', description: `Timeout ms. Default ${_shellDefaultTimeoutMs()} (${_shellDefaultTimeoutMs() / 60000} min) when omitted; an explicit value is honored uncapped — long-running commands may set it higher. On timeout the command is MOVED TO BACKGROUND (a task_id you can wait/status/read/cancel) and keeps running instead of being killed; sleep-like commands and MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS opt out (killed with a [timeout] marker). async/background runs with timeout omitted have NO timeout (run until done/cancelled); an explicit timeout is still enforced.` },
85
87
  merge_stderr: { type: 'boolean', description: 'Merge stderr.' },
86
88
  mode: { type: 'string', enum: ['sync', 'async'], description: executionModeSchemaDescription('sync') },
@@ -93,12 +95,12 @@ export const BUILTIN_TOOLS = [
93
95
  name: 'task',
94
96
  title: 'Background Task Control',
95
97
  annotations: { title: 'Background Task Control', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
96
- description: `Manual control for shell async task_id. actions: list/status/read/wait/cancel. ${TOOL_MANUAL_CONTROL_CONTRACT} Not sess_* or agent ids.`,
98
+ description: `Control a shell async task_id: actions list/status/read/wait/cancel. ${TOOL_MANUAL_CONTROL_CONTRACT} Not sess_* or agent ids.`,
97
99
  inputSchema: {
98
100
  type: 'object',
99
101
  properties: {
100
102
  task_id: { type: 'string', description: 'shell async task_id.' },
101
- action: { type: 'string', enum: ['list', 'status', 'read', 'wait', 'cancel'], description: 'Manual action. Avoid polling loops.' },
103
+ action: { type: 'string', enum: ['list', 'status', 'read', 'wait', 'cancel'], description: 'Action; avoid polling loops.' },
102
104
  timeout_ms: { type: 'number', description: 'Wait timeout ms.' },
103
105
  poll_ms: { type: 'number', description: 'Wait poll ms.' },
104
106
  },
@@ -109,7 +111,7 @@ export const BUILTIN_TOOLS = [
109
111
  name: 'grep',
110
112
  title: 'Mixdog Grep',
111
113
  annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
112
- description: 'Exact text/regex in verified scope. Unknown path/name → find first; no path "." + guessed src/**. Broad: files_with_matches/count; narrow: content_with_context.',
114
+ description: 'Quoted/non-identifier literal or regex→grep. Search in verified scope (project root counts as verified); guessed path fragment → find first. Omitted output_mode and content_with_context are contextual; a nonzero content_with_context result resolves that search concept—act directly, without regex tweaks, narrowing or re-search. Choose files_with_matches/count for existence; Only zero/error results may change tokens or scope. No path "." + guessed src/**.',
113
115
  inputSchema: {
114
116
  type: 'object',
115
117
  properties: {
@@ -118,14 +120,14 @@ export const BUILTIN_TOOLS = [
118
120
  { type: 'string' },
119
121
  { type: 'array', items: { type: 'string' }, minItems: 1 },
120
122
  ],
121
- description: 'Text/regex. Array = variants in one call.',
123
+ description: 'Text/regex. Array = variants in one call; pattern[] batches tokens. path[] batches verified scopes only; file/span reads use read path[] regions.',
122
124
  },
123
125
  path: {
124
126
  anyOf: [
125
127
  { type: 'string' },
126
128
  { type: 'array', items: { type: 'string' }, minItems: 1 },
127
129
  ],
128
- description: 'Verified file/dir only; unknown → find first.',
130
+ description: 'Verified file/dir or project root; guessed → find first.',
129
131
  },
130
132
  glob: {
131
133
  anyOf: [
@@ -134,21 +136,24 @@ export const BUILTIN_TOOLS = [
134
136
  ],
135
137
  description: 'Glob filter; no guessed src/** under path ".".',
136
138
  },
137
- output_mode: { type: 'string', enum: ['content_with_context', 'content', 'files_with_matches', 'count'], description: 'Broad: files_with_matches/count; narrow: content_with_context.' },
139
+ output_mode: { type: 'string', enum: ['content_with_context', 'files_with_matches', 'count'], description: 'Omitted/contextual content; content_with_context; files_with_matches/count are existence modes. Legacy content is runtime-compatible, not public.' },
138
140
  head_limit: { type: 'number', minimum: 0, description: 'Max results.' },
139
141
  offset: { type: 'number', minimum: 0, description: 'Skip results for paging.' },
140
142
  '-A': { type: 'number', minimum: 0, description: 'Lines after each match.' },
141
143
  '-B': { type: 'number', minimum: 0, description: 'Lines before each match.' },
142
144
  '-C': { type: 'number', minimum: 0, description: 'Lines before/after each match.' },
143
145
  },
144
- required: [],
146
+ anyOf: [
147
+ { required: ['pattern'] },
148
+ { required: ['glob'] },
149
+ ],
145
150
  },
146
151
  },
147
152
  {
148
153
  name: 'glob',
149
154
  title: 'Mixdog Glob',
150
155
  annotations: { title: 'Mixdog Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
151
- description: 'Exact glob from verified roots. Unknown root/name → find first; no path "." + guessed src/**. Batch arrays.',
156
+ description: 'Exact glob from verified roots (project root is verified); guessed root/name → find first. Array-first: batch related pattern[]/path[]. No path "." + guessed src/**.',
152
157
  inputSchema: {
153
158
  type: 'object',
154
159
  properties: {
@@ -157,14 +162,14 @@ export const BUILTIN_TOOLS = [
157
162
  { type: 'string' },
158
163
  { type: 'array', items: { type: 'string' }, minItems: 1 },
159
164
  ],
160
- description: 'Exact glob pattern(s). Batch related patterns as pattern[] in one call.',
165
+ description: 'Exact glob pattern(s); batch related pattern[].',
161
166
  },
162
167
  path: {
163
168
  anyOf: [
164
169
  { type: 'string' },
165
170
  { type: 'array', items: { type: 'string' }, minItems: 1 },
166
171
  ],
167
- description: 'Verified base dir(s); unknown → find first. Batch path[].',
172
+ description: 'Verified base dir(s)/project root; guessed → find first; batch path[].',
168
173
  },
169
174
  head_limit: { type: 'number', description: 'Max entries.' },
170
175
  offset: { type: 'number', description: 'Skip entries.' },
@@ -176,7 +181,7 @@ export const BUILTIN_TOOLS = [
176
181
  name: 'find',
177
182
  title: 'Mixdog Find Files',
178
183
  annotations: { title: 'Mixdog Find Files', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
179
- description: 'Partial path/name lookup incl dot dirs; verify roots before grep/glob. Batch query[].',
184
+ description: 'Partial path/name lookup only for unknown partial paths/names (including dot dirs); verify roots before grep/glob. Not for the project root or already-verified roots. Output paths are verified downstream. Array-first: batch query[].',
180
185
  inputSchema: {
181
186
  type: 'object',
182
187
  properties: {
@@ -185,7 +190,7 @@ export const BUILTIN_TOOLS = [
185
190
  { type: 'string' },
186
191
  { type: 'array', items: { type: 'string' }, minItems: 1 },
187
192
  ],
188
- description: 'Partial path/name words (not file contents), or query[] to batch lookups in one call.',
193
+ description: 'Partial path/name words, not contents; query[] batches lookups.',
189
194
  },
190
195
  path: { type: 'string', description: 'Base directory.' },
191
196
  head_limit: { type: 'number', description: 'Max paths.' },
@@ -197,7 +202,7 @@ export const BUILTIN_TOOLS = [
197
202
  name: 'list',
198
203
  title: 'Mixdog List Directory',
199
204
  annotations: { title: 'Mixdog List Directory', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
200
- description: 'List verified directories. Unknown dir → find first. Batch dirs as path[].',
205
+ description: 'List verified directories (project root included); Guessed dir → find first. Array-first: batch independent dirs as path[].',
201
206
  inputSchema: {
202
207
  type: 'object',
203
208
  properties: {
@@ -206,7 +211,7 @@ export const BUILTIN_TOOLS = [
206
211
  { type: 'string' },
207
212
  { type: 'array', items: { type: 'string' }, minItems: 1 },
208
213
  ],
209
- description: 'Verified directory, or path[] to list several directories in one call.',
214
+ description: 'Verified directory; path[] batches directories.',
210
215
  },
211
216
  head_limit: { type: 'number', description: 'Max entries.' },
212
217
  offset: { type: 'number', description: 'Skip N entries for paging.' },
@@ -19,10 +19,13 @@ export const FS_REACHABILITY_DEADLINE_MS = 5000;
19
19
  // error). Reject with EFSUNREACHABLE only when the stat itself exceeds the
20
20
  // deadline, which is the dead-mount / hung-FS signature.
21
21
  export async function assertPathReachable(path, deadlineMs = FS_REACHABILITY_DEADLINE_MS) {
22
- if (typeof path !== 'string' || path.length === 0) return;
22
+ if (typeof path !== 'string' || path.length === 0) return null;
23
23
  const ms = Number(deadlineMs) > 0 ? Number(deadlineMs) : FS_REACHABILITY_DEADLINE_MS;
24
24
  let timer = null;
25
- const probe = stat(path).then(() => true, () => true); // any answer = reachable
25
+ // Resolve to the Stats on success so a caller can seed its stat cache and
26
+ // avoid an immediate redundant synchronous re-stat of the same path; a
27
+ // clean FS rejection (ENOENT/EACCES) resolves to null (still "reachable").
28
+ const probe = stat(path).then((s) => s, () => null);
26
29
  const deadline = new Promise((resolve) => {
27
30
  timer = setTimeout(() => resolve('TIMEOUT'), ms);
28
31
  });
@@ -37,6 +40,7 @@ export async function assertPathReachable(path, deadlineMs = FS_REACHABILITY_DEA
37
40
  err.code = 'EFSUNREACHABLE';
38
41
  throw err;
39
42
  }
43
+ return result; // Stats on success, null on a clean FS rejection
40
44
  }
41
45
 
42
46
  // Batch variant: reject if ANY path is unreachable. Runs probes concurrently so
@@ -25,62 +25,69 @@ function isHump(prevCh, ch) {
25
25
  && /[A-Z]/.test(ch);
26
26
  }
27
27
 
28
- /**
29
- * @param {string} query user query (partial name)
30
- * @param {string} str candidate path (relative)
31
- * @returns {number|null} score, or null if `query` is not a subsequence
32
- */
33
- export function fuzzyScore(query, str) {
28
+ function prepareFuzzyScore(query) {
34
29
  if (!query) return 0;
35
30
  const normalizedQuery = String(query).replace(/[\/\\_.\-\s]+/g, '');
36
31
  if (!normalizedQuery) return 0;
37
32
  const q = normalizedQuery.toLowerCase();
38
- const s = str.toLowerCase();
39
33
  const qlen = q.length;
40
- const slen = s.length;
41
- if (qlen === 0) return 0;
42
- if (qlen > slen) return null;
43
-
44
- const lastSep = Math.max(str.lastIndexOf('/'), str.lastIndexOf('\\'));
45
-
46
- let score = 0;
47
- let si = 0;
48
- let prevMatch = -2;
49
- let firstMatchIdx = -1;
50
-
51
- for (let qi = 0; qi < qlen; qi++) {
52
- const qc = q[qi];
53
- let found = -1;
54
- for (let k = si; k < slen; k++) {
55
- if (s[k] === qc) { found = k; break; }
56
- }
57
- if (found === -1) return null;
58
- if (firstMatchIdx === -1) firstMatchIdx = found;
34
+ return (str) => {
35
+ const s = str.toLowerCase();
36
+ const slen = s.length;
37
+ if (qlen === 0) return 0;
38
+ if (qlen > slen) return null;
59
39
 
60
- score += 1; // base point per matched char
40
+ const lastSep = Math.max(str.lastIndexOf('/'), str.lastIndexOf('\\'));
61
41
 
62
- if (found === prevMatch + 1) score += 5; // contiguous run
42
+ let score = 0;
43
+ let si = 0;
44
+ let prevMatch = -2;
45
+ let firstMatchIdx = -1;
63
46
 
64
- const prevCh = found > 0 ? str[found - 1] : undefined;
65
- if (prevCh === undefined || isBoundaryChar(prevCh) || isHump(prevCh, str[found])) {
66
- score += 8; // word-boundary start
67
- }
47
+ for (let qi = 0; qi < qlen; qi++) {
48
+ const qc = q[qi];
49
+ let found = -1;
50
+ for (let k = si; k < slen; k++) {
51
+ if (s[k] === qc) { found = k; break; }
52
+ }
53
+ if (found === -1) return null;
54
+ if (firstMatchIdx === -1) firstMatchIdx = found;
68
55
 
69
- if (str[found] === normalizedQuery[qi]) score += 1; // exact-case tie-break
56
+ score += 1; // base point per matched char
70
57
 
71
- prevMatch = found;
72
- si = found + 1;
73
- }
58
+ if (found === prevMatch + 1) score += 5; // contiguous run
74
59
 
75
- // Matches that begin inside the basename (after the last separator) are far
76
- // more relevant than ones buried in directory components.
77
- if (firstMatchIdx > lastSep) score += 10;
60
+ const prevCh = found > 0 ? str[found - 1] : undefined;
61
+ if (prevCh === undefined || isBoundaryChar(prevCh) || isHump(prevCh, str[found])) {
62
+ score += 8; // word-boundary start
63
+ }
78
64
 
79
- // Mild pulls: shorter candidates and earlier first matches rank higher.
80
- score -= Math.floor(slen / 16);
81
- score -= Math.floor(firstMatchIdx / 8);
65
+ if (str[found] === normalizedQuery[qi]) score += 1; // exact-case tie-break
82
66
 
83
- return score;
67
+ prevMatch = found;
68
+ si = found + 1;
69
+ }
70
+
71
+ // Matches that begin inside the basename (after the last separator) are far
72
+ // more relevant than ones buried in directory components.
73
+ if (firstMatchIdx > lastSep) score += 10;
74
+
75
+ // Mild pulls: shorter candidates and earlier first matches rank higher.
76
+ score -= Math.floor(slen / 16);
77
+ score -= Math.floor(firstMatchIdx / 8);
78
+
79
+ return score;
80
+ };
81
+ }
82
+
83
+ /**
84
+ * @param {string} query user query (partial name)
85
+ * @param {string} str candidate path (relative)
86
+ * @returns {number|null} score, or null if `query` is not a subsequence
87
+ */
88
+ export function fuzzyScore(query, str) {
89
+ const scorer = prepareFuzzyScore(query);
90
+ return typeof scorer === 'function' ? scorer(str) : scorer;
84
91
  }
85
92
 
86
93
  // Below this per-query-char score, a subsequence-only match (query chars in
@@ -97,6 +104,53 @@ function normalizeForContains(s) {
97
104
  return String(s || '').toLowerCase().replace(/[\/\\_.\-\s]+/g, '');
98
105
  }
99
106
 
107
+ function compareRanked(a, b) {
108
+ return (b.score - a.score)
109
+ || (a.item.path < b.item.path ? -1 : a.item.path > b.item.path ? 1 : 0);
110
+ }
111
+
112
+ function compareRankedNodes(a, b) {
113
+ return compareRanked(a.entry, b.entry) || (a.ordinal - b.ordinal);
114
+ }
115
+
116
+ function siftDownRankedHeap(heap, index) {
117
+ for (;;) {
118
+ const left = index * 2 + 1;
119
+ const right = left + 1;
120
+ let worst = index;
121
+ if (left < heap.length && compareRankedNodes(heap[left], heap[worst]) > 0) worst = left;
122
+ if (right < heap.length && compareRankedNodes(heap[right], heap[worst]) > 0) worst = right;
123
+ if (worst === index) return;
124
+ const node = heap[index];
125
+ heap[index] = heap[worst];
126
+ heap[worst] = node;
127
+ index = worst;
128
+ }
129
+ }
130
+
131
+ // Keep the worst retained candidate at index zero. The ordinal completes the
132
+ // public comparator only for heap bookkeeping, preserving stable-sort behavior
133
+ // for duplicate score/path entries.
134
+ function retainTopRanked(heap, entry, ordinal, limit) {
135
+ const node = { entry, ordinal };
136
+ if (heap.length < limit) {
137
+ heap.push(node);
138
+ for (let index = heap.length - 1; index > 0;) {
139
+ const parent = Math.floor((index - 1) / 2);
140
+ if (compareRankedNodes(heap[index], heap[parent]) <= 0) break;
141
+ const parentNode = heap[parent];
142
+ heap[parent] = heap[index];
143
+ heap[index] = parentNode;
144
+ index = parent;
145
+ }
146
+ return;
147
+ }
148
+ if (compareRankedNodes(node, heap[0]) < 0) {
149
+ heap[0] = node;
150
+ siftDownRankedHeap(heap, 0);
151
+ }
152
+ }
153
+
100
154
  /**
101
155
  * Rank candidates by fuzzy score against `query`, dropping non-matches.
102
156
  * @param {string} query
@@ -106,30 +160,41 @@ function normalizeForContains(s) {
106
160
  */
107
161
  export function fuzzyRank(query, items, limit = 0) {
108
162
  const normQuery = normalizeForContains(query);
163
+ const score = prepareFuzzyScore(query);
109
164
  // Floor scales with query length: a scattered subsequence earns ~1 point
110
165
  // per char, while any contiguous run (+5/char) or word-boundary hit
111
166
  // (+8) pushes a genuine match well past 4/char.
112
167
  const floor = normQuery.length * SUBSEQUENCE_MIN_PER_CHAR;
113
168
  const scored = [];
114
- for (const item of items) {
169
+ // Preserve slice's legacy coercion behavior for non-integer public callers;
170
+ // normal tool limits are positive integers and take the bounded fast path.
171
+ const bounded = Number.isInteger(limit) && limit > 0;
172
+ for (let ordinal = 0; ordinal < items.length; ordinal++) {
173
+ const item = items[ordinal];
115
174
  const p = String(item.path || '');
116
- const pathScore = fuzzyScore(query, p);
117
- const base = p.split(/[\\/]/).pop() || '';
118
- const baseScore = fuzzyScore(query, base);
175
+ const lastSep = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
176
+ const base = p.slice(lastSep + 1);
177
+ const pathScore = typeof score === 'function' ? score(p) : score;
178
+ const baseScore = typeof score === 'function' ? score(base) : score;
119
179
  const sc = Math.max(pathScore ?? -Infinity, baseScore === null ? -Infinity : baseScore + 40);
120
180
  if (!Number.isFinite(sc)) continue;
121
181
  // Strong match: the query (separators stripped) is a contiguous
122
182
  // substring of the basename or the full path. These ALWAYS pass so an
123
183
  // exact substring/basename hit can never be starved out as noise.
124
- const strong = normQuery.length > 0
125
- && (normalizeForContains(base).includes(normQuery)
126
- || normalizeForContains(p).includes(normQuery));
184
+ const strong = normQuery.length > 0 && normalizeForContains(p).includes(normQuery);
127
185
  // Otherwise it is subsequence-only: keep it only if it clears the
128
186
  // per-char floor. Weak scattered matches (the pgAdmin-style junk that
129
187
  // merely contains the query chars in order) fall below it and drop out.
130
188
  if (!strong && sc < floor) continue;
131
- scored.push({ item, score: sc });
189
+ const entry = { item, score: sc };
190
+ if (bounded) retainTopRanked(scored, entry, ordinal, limit);
191
+ else scored.push(entry);
192
+ }
193
+ if (!bounded) {
194
+ scored.sort(compareRanked);
195
+ return limit > 0 ? scored.slice(0, limit) : scored;
132
196
  }
133
- scored.sort((a, b) => (b.score - a.score) || (a.item.path < b.item.path ? -1 : a.item.path > b.item.path ? 1 : 0));
134
- return limit > 0 ? scored.slice(0, limit) : scored;
197
+ return scored
198
+ .sort(compareRankedNodes)
199
+ .map(({ entry }) => entry);
135
200
  }