mixdog 0.9.151 → 0.9.152

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 (175) hide show
  1. package/README.md +110 -68
  2. package/package.json +3 -2
  3. package/scripts/tool-stress.mjs +0 -2
  4. package/src/defaults/skills/setup/SKILL.md +18 -27
  5. package/src/headless-exec.mjs +3 -10
  6. package/src/headless-exec.test.mjs +2 -7
  7. package/src/runtime/agent/orchestrator/config.mjs +6 -66
  8. package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
  9. package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
  11. package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
  12. package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
  13. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
  14. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  16. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
  17. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
  20. package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
  21. package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
  25. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
  26. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
  28. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
  29. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
  30. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
  32. package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
  33. package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
  34. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
  35. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
  37. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
  38. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
  39. package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
  40. package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
  43. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
  44. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
  45. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
  48. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
  49. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
  50. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
  51. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
  52. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
  55. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
  57. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
  58. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
  59. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
  60. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
  62. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
  63. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
  64. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
  71. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
  73. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
  74. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  75. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  76. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
  77. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  78. package/src/runtime/browser-bridge/client.mjs +103 -0
  79. package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
  80. package/src/runtime/computer-bridge/client.mjs +101 -0
  81. package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
  82. package/src/runtime/media/store.mjs +18 -8
  83. package/src/runtime/media/store.test.mjs +14 -0
  84. package/src/runtime/memory/index.mjs +1 -1
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
  86. package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
  87. package/src/runtime/shared/child-spawn-gate.mjs +17 -0
  88. package/src/runtime/shared/child-spawn-remote.mjs +69 -7
  89. package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
  90. package/src/runtime/shared/pristine-execution-contract.json +2 -1
  91. package/src/runtime/shared/provider-api-key.mjs +1 -0
  92. package/src/runtime/shared/session-runtime-health.mjs +91 -0
  93. package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
  94. package/src/runtime/shared/skill-document.mjs +86 -0
  95. package/src/runtime/shared/skill-document.test.mjs +81 -0
  96. package/src/runtime/shared/tool-surface.mjs +18 -0
  97. package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
  98. package/src/runtime/shared/turn-snapshot.mjs +32 -9
  99. package/src/runtime/shared/user-cwd.mjs +33 -14
  100. package/src/runtime/shared/user-cwd.test.mjs +43 -0
  101. package/src/session-runtime/cwd-plugins.mjs +26 -26
  102. package/src/session-runtime/cwd-plugins.test.mjs +75 -0
  103. package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
  104. package/src/session-runtime/global-extensions.mjs +26 -0
  105. package/src/session-runtime/global-extensions.test.mjs +28 -0
  106. package/src/session-runtime/goal-runtime.mjs +721 -0
  107. package/src/session-runtime/goal-runtime.test.mjs +108 -0
  108. package/src/session-runtime/lifecycle-api.mjs +18 -4
  109. package/src/session-runtime/lifecycle-api.test.mjs +21 -0
  110. package/src/session-runtime/mcp-glue.mjs +49 -47
  111. package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
  112. package/src/session-runtime/plugin-mcp.mjs +96 -22
  113. package/src/session-runtime/prewarm.mjs +1 -10
  114. package/src/session-runtime/resource-api-global.test.mjs +69 -0
  115. package/src/session-runtime/resource-api.mjs +173 -58
  116. package/src/session-runtime/runtime-core.mjs +148 -20
  117. package/src/session-runtime/runtime-tunables.mjs +6 -8
  118. package/src/session-runtime/session-turn-api.mjs +22 -11
  119. package/src/session-runtime/skills-api.mjs +79 -25
  120. package/src/session-runtime/skills-api.test.mjs +99 -0
  121. package/src/session-runtime/tool-catalog-schema.mjs +0 -5
  122. package/src/session-runtime/tool-catalog.mjs +4 -13
  123. package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
  124. package/src/session-runtime/tool-surface.mjs +12 -0
  125. package/src/standalone/agent-dispatch-broker.mjs +5 -9
  126. package/src/standalone/agent-tool/notify.mjs +6 -8
  127. package/src/standalone/agent-tool/notify.test.mjs +37 -0
  128. package/src/standalone/daemon.mjs +31 -15
  129. package/src/standalone/hook-bus/config.mjs +1 -0
  130. package/src/standalone/plugin-admin.mjs +17 -0
  131. package/src/standalone/plugin-admin.test.mjs +7 -0
  132. package/src/standalone/provider-admin.mjs +1 -0
  133. package/src/standalone/session-protocol.mjs +5 -0
  134. package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
  135. package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
  136. package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
  137. package/src/standalone/session-runtime-host-factory.mjs +17 -0
  138. package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
  139. package/src/standalone/session-runtime-host-health.test.mjs +5 -0
  140. package/src/standalone/session-runtime-host.mjs +696 -58
  141. package/src/standalone/session-runtime-inline-host.mjs +351 -0
  142. package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
  143. package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
  144. package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
  145. package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
  146. package/src/standalone/session-runtime-shard-router.mjs +149 -0
  147. package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
  148. package/src/standalone/session-runtime-worker.mjs +476 -18
  149. package/src/standalone/session-service.mjs +81 -6
  150. package/src/tui/app/app-view.jsx +1 -0
  151. package/src/tui/app/core-memory-picker.mjs +2 -0
  152. package/src/tui/app/extension-pickers.mjs +1 -1
  153. package/src/tui/app/maintenance-pickers.mjs +3 -3
  154. package/src/tui/app/model-options.mjs +2 -0
  155. package/src/tui/app/model-picker.mjs +14 -4
  156. package/src/tui/app/panel-handoff.test.mjs +311 -0
  157. package/src/tui/app/project-picker.mjs +1 -0
  158. package/src/tui/app/route-pickers.mjs +21 -6
  159. package/src/tui/app/settings-picker.mjs +26 -3
  160. package/src/tui/app/slash-commands.mjs +1 -0
  161. package/src/tui/app/slash-dispatch.mjs +73 -22
  162. package/src/tui/app/theme-effort-pickers.mjs +7 -2
  163. package/src/tui/components/Picker.jsx +10 -1
  164. package/src/tui/dist/index.mjs +163 -44
  165. package/src/tui/session/context-state.mjs +1 -0
  166. package/src/tui/session/goal-continuation.mjs +110 -0
  167. package/src/tui/session/goal-continuation.test.mjs +84 -0
  168. package/src/tui/session/queue-helpers.mjs +5 -2
  169. package/src/tui/session/session-action-surface.test.mjs +108 -0
  170. package/src/tui/session/session-api-ext.mjs +39 -8
  171. package/src/tui/session/session-api.mjs +58 -0
  172. package/src/tui/session/session-flow.mjs +6 -0
  173. package/src/tui/session/turn.mjs +1 -0
  174. package/src/tui/session-local.mjs +15 -1
  175. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
@@ -1,4 +1,3 @@
1
- import { watch } from 'fs';
2
1
  import { readdir, stat } from 'fs/promises';
3
2
  import { basename, isAbsolute, relative } from 'path';
4
3
  import {
@@ -23,8 +22,6 @@ import {
23
22
  lstatPathsForMtime,
24
23
  statCacheSet,
25
24
  statPathsForMtime,
26
- registerCacheInvalidationListener,
27
- invalidateBuiltinResultCache,
28
25
  } from './cache-layers.mjs';
29
26
  import { formatListSize, formatMtime } from './list-formatting.mjs';
30
27
  import {
@@ -39,11 +36,9 @@ import {
39
36
  } from './tool-output-limit.mjs';
40
37
  import { runRg, runRgWindowedLines } from './native-search-runner.mjs';
41
38
  import { tryServeFuzzySearch, tryServeListMetadata } from './native-search-client.mjs';
42
- import { fuzzyRankMulti, prepareFuzzyItems, splitFuzzyQueryTokens } from './fuzzy-match.mjs';
43
39
  import {
44
40
  recordLocalSearchBackend,
45
41
  recordLocalSearchCacheHit,
46
- recordLocalSearchIndex,
47
42
  recordNativeSearchTiming,
48
43
  } from './local-search-telemetry.mjs';
49
44
  import { assertPathReachable } from './fs-reachability.mjs';
@@ -64,10 +59,6 @@ const FIND_WALK_TIMEOUT_MS = 20_000;
64
59
  // whole-filesystem probes across six benchmark tasks, each burning a turn for
65
60
  // an error). At this cap the native search returns its ranked partial instead.
66
61
  const FIND_FUZZY_TIMEOUT_MS = positiveTimeoutEnv('MIXDOG_FIND_FUZZY_TIMEOUT_MS', 1_500);
67
- // If the fast fuzzy window sees no usable candidate, spend the remainder of
68
- // the outer 20s read-only budget on a literal query-targeted path scan. This
69
- // finds real paths without repeating the complete fuzzy inventory.
70
- const FIND_TARGETED_TIMEOUT_MS = positiveTimeoutEnv('MIXDOG_FIND_TARGETED_TIMEOUT_MS', 17_000);
71
62
  // Same contract for name/glob find: bound the walk and the metadata pass, then
72
63
  // SAY the result is partial. The old 20s walk plus a 5s stat deadline silently
73
64
  // dropped every path whose stat missed the deadline, so a slow scope reported
@@ -243,9 +234,6 @@ async function tryNativeDeepListRows({
243
234
  }
244
235
 
245
236
  export async function executeListTool(args, workDir, options = {}) {
246
- if (typeof args.fuzzy === 'string' && args.fuzzy.length > 0) {
247
- return executeFuzzyFindTool({ ...args, query: args.fuzzy }, workDir, options);
248
- }
249
237
  if (args.mode === 'tree') return executeTreeTool(args, workDir, options);
250
238
  if (args.mode === 'find') return executeFindFilesTool(args, workDir, options);
251
239
  args.path = normalizeInputPath(args.path);
@@ -597,482 +585,15 @@ export async function executeTreeTool(args, workDir, options = {}) {
597
585
  return out;
598
586
  }
599
587
 
600
- // ── Broad-enumeration cache (shared `rg --files` sweep) ──────────────────
601
- // A `rg --files` sweep of a root depends ONLY on (root, hidden, depth,
602
- // includeNoise, ignoreMode) — NOT on the per-query narrowing. Yet both the fuzzy-find
603
- // broad pass and the find_files broad fast path re-run that full sweep for
604
- // every query item AND for every concurrent caller (measured 1-4s each when
605
- // several locator calls hit the same root). Cache the PARSED file list per
606
- // key with in-flight promise dedup (N concurrent callers share ONE sweep)
607
- // plus a short TTL for serial reuse. Truncated/partial sweeps are
608
- // known-incomplete and are NEVER cached.
609
- const FIND_ENUM_CACHE = new Map(); // key -> { files, items, expiresAt, gen }
610
- const FIND_ENUM_INFLIGHT = new Map(); // key -> { promise, controller, subscribers }
611
- const FIND_TARGETED_BATCHES_BY_RUNNER = new WeakMap();
612
- const FIND_ENUM_ROOT_GEN = new Map();
613
- const FIND_ENUM_WATCHERS = new Map();
614
- let FIND_ENUM_GEN = 0;
615
-
616
- // The broad enumeration is a DERIVED cache the scope/path invalidation layer
617
- // does not otherwise know about. Invalidate only inventories whose roots
618
- // overlap the written paths; a patch in an isolated temp root must not force
619
- // every active Project to rescan.
620
- function findEnumerationPathsOverlap(left, right) {
621
- const contains = (base, target) => {
622
- const rel = relative(base, target);
623
- return rel === '' || (!isAbsolute(rel) && !/^\.\.(?:[\\/]|$)/.test(rel));
624
- };
625
- return contains(left, right) || contains(right, left);
626
- }
627
- function findEnumerationRootFromKey(key) {
628
- return String(key).split('\u0000', 1)[0];
629
- }
630
- function findEnumerationRootGeneration(root) {
631
- return FIND_ENUM_ROOT_GEN.get(root) || 0;
632
- }
633
- function invalidateFindEnumerationRoot(root) {
634
- FIND_ENUM_ROOT_GEN.set(root, findEnumerationRootGeneration(root) + 1);
635
- for (const key of [...FIND_ENUM_CACHE.keys()]) {
636
- if (findEnumerationRootFromKey(key) === root) FIND_ENUM_CACHE.delete(key);
637
- }
638
- for (const key of [...FIND_ENUM_INFLIGHT.keys()]) {
639
- if (findEnumerationRootFromKey(key) === root) FIND_ENUM_INFLIGHT.delete(key);
640
- }
641
- }
642
- // Trailing-edge prewarm throttle: sustained write bursts (builds, installs)
643
- // fire the watcher continuously; re-running the two full sweeps on every
644
- // flush kept the serve bulk queue permanently busy. Invalidation stays
645
- // immediate — only the warm rebuild is coalesced to one run per interval
646
- // per root, scheduled at the trailing edge so the cache re-arms after the
647
- // burst settles.
648
- const FIND_ENUM_PREWARM_STATE = new Map(); // root -> { lastAt, timer }
649
- const FIND_ENUM_PREWARM_MIN_INTERVAL_MS = 15_000;
650
- function scheduleFindEnumerationPrewarm(root, delayMs = 100) {
651
- let state = FIND_ENUM_PREWARM_STATE.get(root);
652
- if (!state) {
653
- state = { lastAt: 0, timer: null };
654
- FIND_ENUM_PREWARM_STATE.set(root, state);
655
- }
656
- if (state.timer) return;
657
- const wait = Math.max(delayMs, state.lastAt + FIND_ENUM_PREWARM_MIN_INTERVAL_MS - Date.now());
658
- state.timer = setTimeout(() => {
659
- state.timer = null;
660
- state.lastAt = Date.now();
661
- void prewarmFindEnumeration(root);
662
- }, wait);
663
- state.timer.unref?.();
664
- }
665
- function ensureFindEnumerationWatcher(root) {
666
- const existing = FIND_ENUM_WATCHERS.get(root);
667
- if (existing) {
668
- existing.touchedAt = Date.now();
669
- return true;
670
- }
671
- try {
672
- const pending = new Set();
673
- const record = { watcher: null, timer: null, touchedAt: Date.now() };
674
- const flush = () => {
675
- record.timer = null;
676
- const paths = [...pending];
677
- pending.clear();
678
- invalidateBuiltinResultCache(paths.length ? paths : [root]);
679
- scheduleFindEnumerationPrewarm(root);
680
- };
681
- record.watcher = watch(root, { recursive: true, persistent: false }, (_event, filename) => {
682
- const rel = filename == null ? '' : String(filename);
683
- pending.add(rel ? resolveAgainstCwd(rel, root) : root);
684
- if (record.timer) clearTimeout(record.timer);
685
- record.timer = setTimeout(flush, 40);
686
- record.timer.unref?.();
687
- });
688
- record.watcher.on('error', () => {
689
- if (record.timer) clearTimeout(record.timer);
690
- FIND_ENUM_WATCHERS.delete(root);
691
- invalidateFindEnumerationRoot(root);
692
- try { record.watcher.close(); } catch {}
693
- });
694
- record.watcher.unref?.();
695
- FIND_ENUM_WATCHERS.set(root, record);
696
- if (FIND_ENUM_WATCHERS.size > 16) {
697
- const oldest = [...FIND_ENUM_WATCHERS.entries()]
698
- .filter(([candidate]) => candidate !== root)
699
- .sort((a, b) => a[1].touchedAt - b[1].touchedAt)[0];
700
- if (oldest) {
701
- FIND_ENUM_WATCHERS.delete(oldest[0]);
702
- try { oldest[1].watcher.close(); } catch {}
703
- invalidateFindEnumerationRoot(oldest[0]);
704
- }
705
- }
706
- return true;
707
- } catch {
708
- return false;
709
- }
710
- }
711
- registerCacheInvalidationListener((affectedPaths) => {
712
- if (!Array.isArray(affectedPaths) || affectedPaths.length === 0) {
713
- const roots = new Set([
714
- ...FIND_ENUM_CACHE.keys(),
715
- ...FIND_ENUM_INFLIGHT.keys(),
716
- ].map(findEnumerationRootFromKey));
717
- for (const root of roots) {
718
- if (!FIND_ENUM_WATCHERS.has(root)) invalidateFindEnumerationRoot(root);
719
- }
720
- return;
721
- }
722
- const keys = new Set([...FIND_ENUM_CACHE.keys(), ...FIND_ENUM_INFLIGHT.keys()]);
723
- const affectedRoots = new Set();
724
- for (const key of keys) {
725
- const root = findEnumerationRootFromKey(key);
726
- if (affectedPaths.some((affected) => findEnumerationPathsOverlap(root, affected))) {
727
- affectedRoots.add(root);
728
- }
729
- }
730
- for (const root of affectedRoots) {
731
- invalidateFindEnumerationRoot(root);
732
- }
733
- });
734
-
735
- function findEnumTtlMs() {
736
- const raw = process.env.MIXDOG_FIND_ENUM_CACHE_TTL_MS;
737
- if (raw == null || raw === '') return 30000;
738
- const n = Number(raw);
739
- if (!Number.isFinite(n) || n < 0) return 30000; // malformed → default
740
- return Math.floor(n); // 0 = disabled
741
- }
742
-
743
- function findEnumKey({ root, hidden, depth, includeNoise, ignoreMode }) {
744
- return `${root}\u0000${hidden ? 1 : 0}\u0000${depth ?? ''}\u0000${includeNoise ? 1 : 0}\u0000${ignoreMode}`;
745
- }
746
-
747
- // Parse `rg --files` stdout into the same normalized relative-path list both
748
- // broad passes build (strip trailing CR, drop empties, strip leading `./`,
749
- // forward-slash). Module-level so the cache and both call sites agree.
750
- function parseRgFileList(stdout) {
751
- return String(stdout)
752
- .split('\n')
753
- .map((p) => (p.endsWith('\r') ? p.slice(0, -1) : p))
754
- .filter((p) => p.length > 0)
755
- .map((p) => normalizeOutputPath(p.replace(/^\.[/\\]/, '')));
756
- }
757
-
758
- function findEnumerationAbortError(signal) {
759
- const reason = signal?.reason;
760
- return reason instanceof Error ? reason : new Error(String(reason || 'find canceled'));
761
- }
762
-
763
- function subscribeToFindEnumeration(key, entry, signal = null) {
764
- const token = {};
765
- entry.subscribers.add(token);
766
- return new Promise((resolve, reject) => {
767
- let done = false;
768
- const detach = () => {
769
- if (signal instanceof AbortSignal) {
770
- try { signal.removeEventListener('abort', onAbort); } catch { /* ignore */ }
771
- }
772
- };
773
- const settle = (fn, value) => {
774
- if (done) return;
775
- done = true;
776
- entry.subscribers.delete(token);
777
- detach();
778
- fn(value);
779
- };
780
- function onAbort() {
781
- const error = findEnumerationAbortError(signal);
782
- settle(reject, error);
783
- // A shared broad sweep survives one caller canceling, but when every
784
- // subscriber is gone it is useless work and must release its rg.
785
- if (entry.subscribers.size === 0) {
786
- try { entry.controller.abort(error); } catch { /* ignore */ }
787
- if (FIND_ENUM_INFLIGHT.get(key) === entry) FIND_ENUM_INFLIGHT.delete(key);
788
- }
789
- }
790
- if (signal instanceof AbortSignal) {
791
- if (signal.aborted) { onAbort(); return; }
792
- signal.addEventListener('abort', onAbort, { once: true });
793
- }
794
- entry.promise.then(
795
- (value) => settle(resolve, value),
796
- (error) => settle(reject, error),
797
- );
798
- });
799
- }
800
-
801
- // Run (or reuse) the broad `rg --files` sweep for a root. Returns
802
- // { files, truncated, partial }. The returned `files` array is SHARED — callers
803
- // must treat it as read-only. `rgArgs` must be the broad-pass args (no per-query
804
- // narrowing); the cache key includes every enumeration-affecting dimension, so
805
- // any caller producing an
806
- // equivalent sweep for the same dims reuses the result.
807
- async function getBroadEnumeration({ root, hidden, depth, includeNoise, ignoreMode, rgArgs, cwd, runRgImpl = runRg, bestEffort = false, signal = null }) {
808
- const ttl = findEnumTtlMs();
809
- const key = findEnumKey({ root, hidden, depth, includeNoise, ignoreMode });
810
- const rootGen = findEnumerationRootGeneration(root);
811
- if (ttl > 0) {
812
- const hit = FIND_ENUM_CACHE.get(key);
813
- if (hit && hit.gen === FIND_ENUM_GEN && hit.rootGen === rootGen && hit.expiresAt > Date.now()) {
814
- recordLocalSearchIndex('hit', hit.files.length);
815
- return { files: hit.files, items: hit.items, truncated: false, partial: false };
816
- }
817
- if (hit) FIND_ENUM_CACHE.delete(key); // expired
818
- }
819
- // Single-flight is independent of the persistent TTL cache. Even when
820
- // MIXDOG_FIND_ENUM_CACHE_TTL_MS=0 disables reuse across calls, concurrent
821
- // scalar find calls should still share the one broad `rg --files` sweep
822
- // instead of spawning N identical enumerations.
823
- const inflight = FIND_ENUM_INFLIGHT.get(key);
824
- if (inflight) return subscribeToFindEnumeration(key, inflight, signal);
825
- const genAtStart = FIND_ENUM_GEN;
826
- const rootGenAtStart = rootGen;
827
- const controller = new AbortController();
828
- const entry = { promise: null, controller, subscribers: new Set() };
829
- entry.promise = (async () => {
830
- let truncated = false;
831
- let partial = false;
832
- let files;
833
- if (runRgImpl === runRg) {
834
- const served = await runRgWindowedLines(rgArgs, {
835
- cwd, timeout: 10_000, signal: controller.signal,
836
- }, {
837
- offset: 0,
838
- limit: 50_000,
839
- nativeInventory: true,
840
- // Only explicit boot/prewarm work may finish an inventory after
841
- // its caller leaves. Normal find calls cancel on last waiter.
842
- keepWarm: bestEffort,
843
- });
844
- files = parseRgFileList(served.lines.join('\n'));
845
- truncated = served.complete !== true || served.truncated === true;
846
- partial = served.partial === true || served.timeout === true;
847
- } else {
848
- const stdout = await runRgImpl(rgArgs, { cwd, signal: controller.signal });
849
- truncated = Boolean(stdout && typeof stdout === 'object' && stdout.truncated);
850
- partial = Boolean(stdout && typeof stdout === 'object' && stdout.partial);
851
- files = parseRgFileList(stdout);
852
- }
853
- const items = prepareFuzzyItems(files);
854
- recordLocalSearchIndex('build', files.length);
855
- // Never cache a truncated/partial sweep — it is known-incomplete, so a
856
- // later query with a larger head_limit must re-run the enumeration.
857
- // Also never let an in-flight prewarm/real sweep repopulate after a
858
- // write invalidation cleared the cache during the sweep.
859
- if (ttl > 0 && !truncated && !partial
860
- && FIND_ENUM_GEN === genAtStart
861
- && findEnumerationRootGeneration(root) === rootGenAtStart) {
862
- const watched = ensureFindEnumerationWatcher(root);
863
- FIND_ENUM_CACHE.set(key, {
864
- files,
865
- items,
866
- expiresAt: watched ? Number.MAX_SAFE_INTEGER : Date.now() + ttl,
867
- gen: genAtStart,
868
- rootGen: rootGenAtStart,
869
- });
870
- }
871
- return { files, items, truncated, partial };
872
- })();
873
- FIND_ENUM_INFLIGHT.set(key, entry);
874
- const cleanup = () => {
875
- if (FIND_ENUM_INFLIGHT.get(key) === entry) FIND_ENUM_INFLIGHT.delete(key);
876
- };
877
- entry.promise.then(cleanup, cleanup);
878
- return subscribeToFindEnumeration(key, entry, signal);
879
- }
880
-
881
- // Cache-only peek: returns the SHARED files array of a still-valid broad
882
- // sweep for these dimensions, or null. Never spawns, joins, or waits — the
883
- // targeted find path below must not block behind a full-tree enumeration.
884
- function peekBroadEnumeration({ root, hidden, depth, includeNoise, ignoreMode }) {
885
- if (findEnumTtlMs() <= 0) return null;
886
- const key = findEnumKey({ root, hidden, depth, includeNoise, ignoreMode });
887
- const hit = FIND_ENUM_CACHE.get(key);
888
- if (hit && hit.gen === FIND_ENUM_GEN
889
- && hit.rootGen === findEnumerationRootGeneration(root)
890
- && hit.expiresAt > Date.now()) {
891
- return hit.files;
892
- }
893
- return null;
894
- }
895
-
896
- // Best-effort warm of the broad enumeration for a root using the `find` tool's
897
- // DEFAULT flags (hidden:true, includeNoise:false, depth:unbounded). Swallows
898
- // all errors — a failed prewarm must never surface or block the caller.
899
- export async function prewarmFindEnumeration(root) {
900
- try {
901
- if (!root || typeof root !== 'string') return;
902
- const hidden = true, includeNoise = false, depth = null;
903
- const common = ['--files', '--no-require-git', '--hidden'];
904
- for (const ex of DEFAULT_IGNORE_GLOBS) {
905
- common.push('--glob', ex);
906
- }
907
- for (const ex of rootScanIgnoreGlobs(normalizeOutputPath(root))) common.push('--glob', ex);
908
- common.push('.');
909
- await getBroadEnumeration({
910
- root: normalizeOutputPath(root),
911
- hidden, depth, includeNoise, ignoreMode: 'git',
912
- rgArgs: common, cwd: root, bestEffort: true,
913
- });
914
- } catch { /* best-effort warm; never surface */ }
915
- }
916
-
917
- function escapeFindGlobLiteral(value) {
918
- return String(value).replace(/[*?[\]{}]/g, (char) => `[${char}]`);
919
- }
920
-
921
- async function getTargetedFindEnumeration({
922
- queries,
923
- root,
924
- hidden,
925
- depth,
926
- includeNoise,
927
- runRgImpl,
928
- signal,
929
- timeoutMs = null,
930
- }) {
931
- const terms = [...new Set((queries || []).map((query) => String(query || '').trim()).filter(Boolean))];
932
- // Reuse a WARM broad inventory only (cache peek). A cold cache goes
933
- // straight to the cheap targeted --iglob batch below: front-running the
934
- // full `--no-ignore` sweep made cold finds stall behind the serve bulk
935
- // queue for tens of seconds.
936
- const inventory = peekBroadEnumeration({
937
- root: normalizeOutputPath(root),
938
- hidden,
939
- depth,
940
- includeNoise,
941
- ignoreMode: includeNoise ? 'all' : 'git',
942
- });
943
- if (inventory) {
944
- const lowerTerms = terms.map((term) => term.toLowerCase());
945
- return {
946
- files: inventory.filter((path) => {
947
- const lower = path.toLowerCase();
948
- return lowerTerms.some((term) => lower.includes(term));
949
- }),
950
- truncated: false,
951
- partial: false,
952
- };
953
- }
954
- let batches = FIND_TARGETED_BATCHES_BY_RUNNER.get(runRgImpl);
955
- if (!batches) {
956
- batches = new Map();
957
- FIND_TARGETED_BATCHES_BY_RUNNER.set(runRgImpl, batches);
958
- }
959
- const batchKey = JSON.stringify([root, hidden, depth ?? '', includeNoise]);
960
- let batch = batches.get(batchKey);
961
- if (!batch) {
962
- batch = {
963
- terms: new Set(),
964
- waiters: new Set(),
965
- controller: new AbortController(),
966
- };
967
- batches.set(batchKey, batch);
968
- setImmediate(async () => {
969
- if (batches.get(batchKey) === batch) batches.delete(batchKey);
970
- if (batch.waiters.size === 0) return;
971
- const rgArgs = ['--files', includeNoise ? '--no-ignore' : '--no-require-git'];
972
- if (hidden) rgArgs.push('--hidden');
973
- if (depth != null) rgArgs.push('--max-depth', String(depth));
974
- for (const query of batch.terms) {
975
- rgArgs.push('--iglob', `*${escapeFindGlobLiteral(query)}*`);
976
- }
977
- if (!includeNoise) {
978
- for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
979
- }
980
- for (const ex of rootScanIgnoreGlobs(root)) rgArgs.push('--glob', ex);
981
- rgArgs.push('.');
982
- try {
983
- const stdout = await runRgImpl(rgArgs, {
984
- cwd: root,
985
- signal: batch.controller.signal,
986
- ...(Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
987
- ? { timeout: Math.floor(Number(timeoutMs)) }
988
- : {}),
989
- });
990
- const paths = parseRgFileList(stdout).filter((path) =>
991
- includeNoise || !path.split('/').some((segment) => NOISE_DIR_NAMES.has(segment)));
992
- const result = {
993
- files: paths,
994
- truncated: Boolean(stdout && typeof stdout === 'object' && stdout.truncated),
995
- partial: Boolean(stdout && typeof stdout === 'object' && stdout.partial),
996
- };
997
- for (const waiter of [...batch.waiters]) waiter.resolve(result);
998
- } catch (error) {
999
- for (const waiter of [...batch.waiters]) waiter.reject(error);
1000
- }
1001
- });
1002
- }
1003
- for (const term of terms) batch.terms.add(term);
1004
- const run = new Promise((resolve, reject) => {
1005
- let settled = false;
1006
- const waiter = {
1007
- resolve(value) {
1008
- if (settled) return;
1009
- settled = true;
1010
- batch.waiters.delete(waiter);
1011
- if (signal instanceof AbortSignal) signal.removeEventListener('abort', onAbort);
1012
- resolve(value);
1013
- },
1014
- reject(error) {
1015
- if (settled) return;
1016
- settled = true;
1017
- batch.waiters.delete(waiter);
1018
- if (signal instanceof AbortSignal) signal.removeEventListener('abort', onAbort);
1019
- reject(error);
1020
- },
1021
- };
1022
- const onAbort = () => {
1023
- waiter.reject(findEnumerationAbortError(signal));
1024
- if (batch.waiters.size === 0) {
1025
- try { batch.controller.abort(findEnumerationAbortError(signal)); } catch {}
1026
- }
1027
- };
1028
- batch.waiters.add(waiter);
1029
- if (signal instanceof AbortSignal) {
1030
- if (signal.aborted) onAbort();
1031
- else signal.addEventListener('abort', onAbort, { once: true });
1032
- }
1033
- });
1034
- return run;
1035
- }
1036
-
1037
- // Fuzzy filename search (nucleo-style): collect the file
1038
- // list via `rg --files`, then rank by subsequence score. `list.fuzzy` still
1039
- // routes here for hidden backward compatibility, but the model-facing tool is
1040
- // `find`.
1041
- // A pruned tree cannot report what it never enumerated. Dependency and cache
1042
- // directories are skipped by DEFAULT, so a file that exists only inside one —
1043
- // a __pycache__ artifact, a vendored source, a build cache — came back as
1044
- // "(no fuzzy match for X)", which reads as PROVEN ABSENCE and ends the search.
1045
- // A second, noise-including pass turns that into the truth, but the default
1046
- // answer still may not list dependency paths (that is the point of the
1047
- // default), so the miss keeps its body and gains the one fact it was missing:
1048
- // a match exists, and which flag surfaces it. Only a CLEAN miss retries — a
1049
- // partial or timed-out pass says "no fuzzy match YET" and is already telling
1050
- // the caller to narrow, so it never pays for a second walk.
588
+ // Fuzzy filename search has one canonical implementation: the Rust search
589
+ // server's resident inventory. A server failure is surfaced instead of
590
+ // launching a second filesystem walk with different semantics.
1051
591
  export async function executeFuzzyFindTool(args, workDir, options = {}) {
1052
- const startedAt = performance.now();
1053
- const result = await runFuzzyFindPass(args, workDir, options);
1054
- const text = String(result ?? '');
1055
- if (!/^\(no fuzzy match for /.test(text)) return result;
1056
- if (args?.include_noise === true || options?._findNoiseWidened === true) return result;
1057
- // The retry walks the same tree again. A miss that was already expensive
1058
- // pays for itself twice, so only a cheap pass earns the second look.
1059
- if (performance.now() - startedAt > FIND_NOISE_WIDEN_BUDGET_MS) return result;
1060
- const widened = String(await runFuzzyFindPass(
1061
- { ...args, include_noise: true },
1062
- workDir,
1063
- { ...options, _findNoiseWidened: true },
1064
- ) ?? '');
1065
- if (!widened || /^\(no fuzzy match/.test(widened)) return result;
1066
- const hits = widened.split('\n').filter((line) => line && !line.startsWith('... [') && !line.startsWith('[')).length;
1067
- return `${text}\n[notice] ${hits} match${hits === 1 ? '' : 'es'} exist inside dependency/cache trees the default scan skips`
1068
- + ' — pass include_noise:true to list them.';
592
+ return runFuzzyFindPass(args, workDir, options);
1069
593
  }
1070
594
 
1071
- /** A miss cheaper than this earns the one widening retry below. */
1072
- const FIND_NOISE_WIDEN_BUDGET_MS = 2_000;
1073
-
1074
595
  async function runFuzzyFindPass(args, workDir, options = {}) {
1075
- const query = String(args.query ?? args.fuzzy ?? '').trim();
596
+ const query = String(args.query ?? '').trim();
1076
597
  if (!query) return 'Error: find requires query.';
1077
598
  const inputPath = normalizeInputPath(args.path) || '.';
1078
599
  const guard = listGuardPath(inputPath);
@@ -1115,24 +636,16 @@ async function runFuzzyFindPass(args, workDir, options = {}) {
1115
636
  const nativeFuzzyImpl = typeof options?.__tryServeFuzzySearch === 'function'
1116
637
  ? options.__tryServeFuzzySearch
1117
638
  : tryServeFuzzySearch;
1118
- const runRgImpl = (options && typeof options.__runRg === 'function') ? options.__runRg : runRg;
1119
639
  let nativeEmptyPartial = null;
1120
640
  // Space-separated fragments AND-match one path. The native matcher handles
1121
641
  // every token independently and sums exact per-token scores.
1122
- const queryTokens = splitFuzzyQueryTokens(query);
1123
- if (
1124
- headLimit > 0
1125
- && (
1126
- typeof options?.__runRg !== 'function'
1127
- || typeof options?.__tryServeFuzzySearch === 'function'
1128
- )
1129
- ) {
642
+ {
1130
643
  const nativeStartedAt = performance.now();
1131
644
  try {
1132
645
  const served = await nativeFuzzyImpl({
1133
646
  query,
1134
647
  cwd: fullPath,
1135
- limit: headLimit + 1,
648
+ limit: headLimit > 0 ? headLimit + 1 : 1_000,
1136
649
  hidden,
1137
650
  includeNoise,
1138
651
  maxDepth: depth,
@@ -1151,11 +664,11 @@ async function runFuzzyFindPass(args, workDir, options = {}) {
1151
664
  if (served?.complete) {
1152
665
  recordNativeSearchTiming(served);
1153
666
  recordLocalSearchBackend('native_fuzzy', performance.now() - nativeStartedAt, 'hit');
1154
- const hasMore = served.hasMore || served.matches.length > headLimit;
1155
- const matches = hasMore ? served.matches.slice(0, headLimit) : served.matches;
667
+ const hasMore = served.hasMore || (headLimit > 0 && served.matches.length > headLimit);
668
+ const matches = headLimit > 0 && hasMore ? served.matches.slice(0, headLimit) : served.matches;
1156
669
  const lines = matches.length === 0
1157
670
  ? [`(no fuzzy match for "${query}")`]
1158
- : [...matches, ...(hasMore ? [`... (top ${headLimit}; raise limit for more)`] : [])];
671
+ : [...matches, ...(hasMore ? [`... (top ${headLimit || 1_000}; narrow query or raise limit for more)`] : [])];
1159
672
  const result = lines.join('\n');
1160
673
  if (served.cacheSafe !== false) {
1161
674
  cacheSet(cacheKey, result, { scopes: [fullPath] });
@@ -1181,6 +694,7 @@ async function runFuzzyFindPass(args, workDir, options = {}) {
1181
694
  nativeEmptyPartial = served;
1182
695
  } else {
1183
696
  recordLocalSearchBackend('native_fuzzy', performance.now() - nativeStartedAt, 'miss');
697
+ return capFindResult('Error: native fuzzy search returned an invalid response.');
1184
698
  }
1185
699
  } catch (error) {
1186
700
  if (options.signal?.aborted) throw error;
@@ -1205,197 +719,24 @@ async function runFuzzyFindPass(args, workDir, options = {}) {
1205
719
  };
1206
720
  } else {
1207
721
  recordLocalSearchBackend('native_fuzzy', performance.now() - nativeStartedAt, 'error');
1208
- if (error?.code !== 'NATIVE_SEARCH_UNSUPPORTED') {
1209
- return capFindResult(`Error: ${normalizeErrorMessage(error instanceof Error ? error.message : String(error))}`);
1210
- }
722
+ return capFindResult(`Error: ${normalizeErrorMessage(error instanceof Error ? error.message : String(error))}`);
1211
723
  }
1212
724
  }
1213
725
  }
1214
726
  if (nativeEmptyPartial) {
1215
- const targetedStartedAt = performance.now();
1216
- let targeted;
1217
- try {
1218
- targeted = await getTargetedFindEnumeration({
1219
- queries: queryTokens.length > 0 ? queryTokens : [query],
1220
- root: fullPath,
1221
- hidden,
1222
- depth,
1223
- includeNoise,
1224
- runRgImpl,
1225
- signal: options.signal,
1226
- timeoutMs: FIND_TARGETED_TIMEOUT_MS,
1227
- });
1228
- } catch (error) {
1229
- if (options.signal?.aborted) throw findEnumerationAbortError(options.signal);
1230
- recordLocalSearchBackend('native_fuzzy_targeted', performance.now() - targetedStartedAt, 'error');
1231
- if (options?.scopedCacheOutcome) markScopedCacheIncomplete(options.scopedCacheOutcome);
1232
- return capFindResult([
1233
- `(no fuzzy match yet for "${query}")`,
1234
- '... [native fuzzy ranking was partial and the targeted path scan could not complete; narrow path and retry]',
1235
- ].join('\n'));
1236
- }
1237
- const queryTokensLower = queryTokens.map((token) => token.toLowerCase());
1238
- const targetedPaths = targeted.files.filter((path) => {
1239
- const lower = path.toLowerCase();
1240
- return queryTokensLower.every((token) => lower.includes(token));
1241
- });
1242
- const rankLimit = headLimit > 0 ? headLimit + 1 : headLimit;
1243
- const rankedRaw = fuzzyRankMulti(query, prepareFuzzyItems(targetedPaths), rankLimit);
1244
- const hasMore = headLimit > 0 && rankedRaw.length > headLimit;
1245
- const ranked = hasMore ? rankedRaw.slice(0, headLimit) : rankedRaw;
1246
- recordLocalSearchBackend(
1247
- 'native_fuzzy_targeted',
1248
- performance.now() - targetedStartedAt,
1249
- ranked.length > 0 ? 'hit' : 'miss',
1250
- );
1251
727
  if (options?.scopedCacheOutcome) markScopedCacheIncomplete(options.scopedCacheOutcome);
1252
- const lines = ranked.length > 0
1253
- ? ranked.map((entry) => entry.item.path)
1254
- : [`(no fuzzy match yet for "${query}")`];
1255
- if (hasMore) lines.push(`... (top ${headLimit}; raise limit for more)`);
1256
- if (ranked.length > 0) {
1257
- lines.push('... [native fuzzy ranking was partial; query-targeted path matches shown]');
1258
- } else {
1259
- lines.push('... [native fuzzy ranking was partial and the query-targeted path scan found no candidate; narrow path for a complete result]');
1260
- }
1261
- if (targeted.truncated || targeted.partial) {
1262
- lines.push('... [warning] targeted path scan was partial; returned candidates may be incomplete');
1263
- }
1264
- if (typeof options?.onProgress === 'function') {
1265
- try { options.onProgress(`${ranked.length} targeted candidates`); } catch {}
1266
- }
1267
- return capFindResult(lines.join('\n'));
1268
- }
1269
- // Common discovery respects .gitignore even outside a Git repository.
1270
- // include_noise deliberately retains the old hardened --no-ignore behavior.
1271
- // Noise dirs stay excluded via DEFAULT_IGNORE_GLOBS below.
1272
- const baseRgArgs = ['--files', includeNoise ? '--no-ignore' : '--no-require-git'];
1273
- if (hidden) baseRgArgs.push('--hidden');
1274
- if (depth != null) baseRgArgs.push('--max-depth', String(depth));
1275
- // Noise-exclusion globs are kept SEPARATE and always appended LAST (after
1276
- // any positive --iglob). ripgrep's "last matching glob wins" rule means a
1277
- // positive include placed after these negations would re-admit e.g.
1278
- // `.git/<query>` — so the exclusions must trail the narrowed include.
1279
- const ignoreGlobs = [];
1280
- if (!includeNoise) {
1281
- for (const ex of DEFAULT_IGNORE_GLOBS) ignoreGlobs.push('--glob', ex);
1282
- }
1283
- for (const ex of rootScanIgnoreGlobs(normalizeOutputPath(fullPath))) ignoreGlobs.push('--glob', ex);
1284
- // The narrowed pass must treat `query` as a LITERAL filename substring, not
1285
- // a glob. Wrap every globset metacharacter in a single-char character class
1286
- // (`[` → `[[]`, `*` → `[*]`, …): character-class quoting is the only form
1287
- // globset honors on Windows, where a backslash-escape (`\*`) is read as a
1288
- // literal path separator and does NOT escape. So a query like "[slug].tsx"
1289
- // still produces the intended `*[[]slug[]].tsx*` include instead of a
1290
- // character-class that matches one of s/l/u/g.
1291
- // Test-only seam: allow a caller to inject a runRg stand-in (e.g. to
1292
- // simulate a truncated broad pass) without touching the production path.
1293
- // Never set on the real tool-execution options object.
1294
- const parseRgFiles = (stdout) => String(stdout)
1295
- .split('\n')
1296
- // Strip only the trailing CR from rg's line split — do NOT trim, or a
1297
- // filename with leading/trailing spaces would be corrupted.
1298
- .map((p) => (p.endsWith('\r') ? p.slice(0, -1) : p))
1299
- .filter((p) => p.length > 0)
1300
- .map((p) => normalizeOutputPath(p.replace(/^\.[/\\]/, '')));
1301
- // Broad enumeration: every file under the scope, ranked by fuzzy score.
1302
- // Subject to rg's 20MB/20s cap — an exact-name hit deep in a huge tree can
1303
- // be dropped by cap lottery, so it is backstopped by the narrowed pass.
1304
- // Shared across queries/concurrent callers via the broad-enumeration cache
1305
- // (keyed on root+hidden+depth+includeNoise, i.e. exactly this pass's args).
1306
- let broadEnum;
1307
- try {
1308
- broadEnum = await getBroadEnumeration({
1309
- root: normalizeOutputPath(fullPath),
1310
- hidden, depth, includeNoise, ignoreMode: includeNoise ? 'all' : 'git',
1311
- rgArgs: [...baseRgArgs, ...ignoreGlobs, '.'],
1312
- cwd: fullPath,
1313
- runRgImpl,
1314
- signal: options.signal,
1315
- });
1316
- } catch (err) {
1317
- return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
1318
- }
1319
- let rgTruncated = broadEnum.truncated;
1320
- let rgPartial = broadEnum.partial;
1321
- const passOneItems = broadEnum.items || prepareFuzzyItems(broadEnum.files);
1322
- const queryTokensLower = queryTokens.map((t) => t.toLowerCase());
1323
- const rankLimit = headLimit > 0 ? headLimit + 1 : headLimit;
1324
- const passOneRanked = fuzzyRankMulti(query, passOneItems, rankLimit);
1325
- const passOneHasCandidate = passOneRanked.length > 0;
1326
- let targetedProbeRan = false;
1327
- let targetedPaths = [];
1328
- // A full --no-ignore enumeration can exceed 20MB, then trigger one more
1329
- // whole-tree narrowed pass PER query. Probe only requested names instead,
1330
- // unioning every query in this find batch into one rg process.
1331
- if (rgTruncated || rgPartial || !passOneHasCandidate) {
1332
- try {
1333
- const targeted = await getTargetedFindEnumeration({
1334
- queries: queryTokens.length > 0 ? queryTokens : [query],
1335
- root: fullPath,
1336
- hidden,
1337
- depth,
1338
- includeNoise,
1339
- runRgImpl,
1340
- signal: options.signal,
1341
- });
1342
- rgTruncated ||= targeted.truncated;
1343
- rgPartial ||= targeted.partial;
1344
- // Every fragment must appear in the path (AND) — a single-fragment
1345
- // query keeps the prior substring behavior.
1346
- targetedPaths = targeted.files.filter((path) => {
1347
- const lower = path.toLowerCase();
1348
- return queryTokensLower.every((t) => lower.includes(t));
1349
- });
1350
- targetedProbeRan = true;
1351
- } catch {
1352
- if (options.signal?.aborted) throw findEnumerationAbortError(options.signal);
1353
- rgPartial = true;
1354
- }
1355
- }
1356
- // Merge common + targeted results, preserving common-path order.
1357
- const seen = new Set();
1358
- const items = [];
1359
- for (const item of passOneItems) {
1360
- const p = item.path;
1361
- if (seen.has(p)) continue;
1362
- seen.add(p);
1363
- items.push(item);
1364
- }
1365
- for (const item of prepareFuzzyItems(targetedPaths)) {
1366
- const p = item.path;
1367
- if (seen.has(p)) continue;
1368
- seen.add(p);
1369
- items.push(item);
1370
- }
1371
- const rankedRaw = targetedPaths.length > 0
1372
- ? fuzzyRankMulti(query, items, rankLimit)
1373
- : passOneRanked;
1374
- const hasMore = headLimit > 0 && rankedRaw.length > headLimit;
1375
- const ranked = hasMore ? rankedRaw.slice(0, headLimit) : rankedRaw;
1376
- // Build output lines uniformly for the hit and no-match cases so a
1377
- // truncated/partial broad pass ALWAYS surfaces its warning — otherwise a
1378
- // cut-off enumeration that happened to drop the sole match would silently
1379
- // report "(no fuzzy match …)" as if the tree were exhaustively searched.
1380
- const noMatch = ranked.length === 0;
1381
- const lines = noMatch ? [`(no fuzzy match for "${query}")`] : ranked.map((r) => r.item.path);
1382
- if (!noMatch && hasMore) lines.push(`... (top ${headLimit}; raise limit for more)`);
1383
- if (rgTruncated) lines.push('... [warning] rg stdout truncated at 20MB cap; broad ranking incomplete (exact-name hits still merged)');
1384
- if (rgPartial && !rgTruncated) lines.push('... [warning] rg exit 2 (partial results); broad ranking may be incomplete');
1385
- if (!targetedProbeRan && headLimit > 0 && passOneRanked.length >= headLimit) {
1386
- lines.push('[gitignored trees not searched; include_noise:true is available only if those trees are required]');
1387
- }
1388
- const result = lines.join('\n');
1389
- // Do not cache a truncated/partial enumeration — the broad ranking is
1390
- // known-incomplete, so a later call with a larger head_limit must re-run.
1391
- // A no-match result is also left uncached (mirrors the prior early return).
1392
- if (!noMatch && !rgTruncated && !rgPartial) {
1393
- cacheSet(cacheKey, result, { scopes: [fullPath] });
1394
- }
1395
- if (typeof options?.onProgress === 'function') {
1396
- try { options.onProgress(`${ranked.length} candidates`); } catch { /* best-effort */ }
1397
- }
1398
- return capFindResult(result);
728
+ const scanErrorNote = nativeEmptyPartial.scanErrors > 0
729
+ ? `; ${nativeEmptyPartial.scanErrors} path(s) could not be enumerated`
730
+ + (nativeEmptyPartial.walkErrorDetails?.length
731
+ ? `: ${nativeEmptyPartial.walkErrorDetails.join('; ')}`
732
+ : '')
733
+ : '';
734
+ return capFindResult([
735
+ `(no fuzzy match yet for "${query}")`,
736
+ `... [native inventory is still building${scanErrorNote}; retry the same scope for complete results]`,
737
+ ].join('\n'));
738
+ }
739
+ return capFindResult('Error: native fuzzy search did not return a result.');
1399
740
  }
1400
741
 
1401
742
  export async function executeFindFilesTool(args, workDir, options = {}) {