mixdog 0.9.19 → 0.9.21

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -0,0 +1,274 @@
1
+ import { clean } from './session-text.mjs';
2
+ import { envFlag } from './env.mjs';
3
+ import { normalizeToolMode } from './effort.mjs';
4
+ import {
5
+ toolRow,
6
+ toolSearchMatches,
7
+ sortedNamesByMeasuredUsage,
8
+ selectDeferredTools,
9
+ } from './tool-catalog.mjs';
10
+
11
+ // Turn execution (ask) + session-manage/tool-surface/agent surfaces. Extracted
12
+ // verbatim from the runtime API object; stateless helpers are imported directly
13
+ // and the runtime injects live getters/setters for the mutable session/mode/
14
+ // turn-counter/transcript-writer locals plus the closure callbacks.
15
+ export function createSessionTurnApi(deps) {
16
+ const {
17
+ getSession, setSession, getCurrentCwd, getMode, setMode,
18
+ getActiveTurnCount, setActiveTurnCount, isFirstTurnCompleted, setFirstTurnCompleted,
19
+ getCodeGraphFirstTurnPrewarmDone, setCodeGraphFirstTurnPrewarmDone, codeGraphPrewarmLazy,
20
+ getRemoteEnabled, getCloseRequested,
21
+ getPendingSessionReset, setPendingSessionReset,
22
+ getTranscriptWriter, getTwKey, getLastAppendedAssistant, setLastAppendedAssistant,
23
+ scheduleCodeGraphPrewarm, refreshSessionForCwdIfNeeded, createCurrentSession,
24
+ ensureRemoteTranscriptWriter, channelsEnabled, invokeChannelStart, channels,
25
+ hooks, hookCommonPayload, mgr, notifyFnForSession, bootProfile,
26
+ scheduleProviderWarmup, scheduleProviderModelWarmup, invalidateContextStatusCache,
27
+ agentTool, recreateCurrentSessionIfReady, invalidatePreSessionToolSurface,
28
+ activeToolSurface, applyResolvedCwd, resolveCwdPath, agentStatusState, notificationListeners,
29
+ } = deps;
30
+ return {
31
+ async ask(prompt, options = {}) {
32
+ setActiveTurnCount(getActiveTurnCount() + 1);
33
+ // Lazy code-graph prewarm: kick off the build ONCE, on the first real
34
+ // turn, so a likely code lookup hits a warm cache.
35
+ if (codeGraphPrewarmLazy && !getCodeGraphFirstTurnPrewarmDone()) {
36
+ setCodeGraphFirstTurnPrewarmDone(true);
37
+ scheduleCodeGraphPrewarm(0, 'first-turn');
38
+ }
39
+ const startedAt = Date.now();
40
+ try {
41
+ await refreshSessionForCwdIfNeeded('cwd-change');
42
+ if (!getSession()?.id) await createCurrentSession('turn');
43
+ // Remote outbound: ensure a transcript writer bound to the current
44
+ // session.id + cwd. Gated on remoteEnabled so non-remote sessions write nothing.
45
+ if (getRemoteEnabled()) {
46
+ setLastAppendedAssistant('');
47
+ const prevKey = getTwKey();
48
+ ensureRemoteTranscriptWriter();
49
+ if (getTwKey() && getTwKey() !== prevKey && channelsEnabled() && !envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
50
+ void invokeChannelStart()
51
+ .then(() => {
52
+ if (!getRemoteEnabled() || getCloseRequested()) return undefined;
53
+ return channels.execute('activate_channel_bridge', { active: true });
54
+ })
55
+ .catch((error) => bootProfile('channels:turn-rebind-failed', { error: error?.message || String(error) }));
56
+ }
57
+ }
58
+ const session0 = getSession();
59
+ hooks.emit('turn:start', { sessionId: session0.id, prompt, cwd: getCurrentCwd() });
60
+ // UserPromptSubmit: a hook FAILURE must not block the turn, but blocked===true MUST throw.
61
+ let promptDispatch = null;
62
+ try {
63
+ promptDispatch = await hooks.dispatch('UserPromptSubmit', hookCommonPayload({ session_id: session0.id, prompt }));
64
+ } catch { /* hook failure never blocks the turn */ }
65
+ if (promptDispatch?.blocked === true) {
66
+ throw new Error(`prompt blocked by hook: ${promptDispatch.reason || ''}`);
67
+ }
68
+ const hookContext = Array.isArray(promptDispatch?.additionalContext)
69
+ ? promptDispatch.additionalContext.join('\n\n')
70
+ : String(promptDispatch?.additionalContext || '');
71
+ const turnContext = [options.context || '', hookContext]
72
+ .map((part) => String(part || '').trim())
73
+ .filter(Boolean)
74
+ .join('\n\n');
75
+ const result = await mgr.askSession(
76
+ session0.id,
77
+ prompt,
78
+ turnContext || null,
79
+ async (iter, calls) => {
80
+ for (const call of calls || []) {
81
+ hooks.emit('tool:planned', {
82
+ sessionId: session0.id,
83
+ name: call?.name || 'tool',
84
+ callId: call?.id || null,
85
+ });
86
+ if (getRemoteEnabled() && getTranscriptWriter()) {
87
+ try { getTranscriptWriter().appendToolUse(call?.name, call?.input ?? call?.arguments); }
88
+ catch (error) { process.stderr.write(`mixdog: transcript-writer: onToolCall failed: ${error?.message || error}\n`); }
89
+ }
90
+ }
91
+ if (typeof options.onToolCall === 'function') {
92
+ return await options.onToolCall(iter, calls);
93
+ }
94
+ return undefined;
95
+ },
96
+ getCurrentCwd(),
97
+ options.prefetch || null,
98
+ {
99
+ onTextDelta: options.onTextDelta,
100
+ onReasoningDelta: options.onReasoningDelta,
101
+ onAssistantText: (text) => {
102
+ if (getRemoteEnabled() && getTranscriptWriter()) {
103
+ try {
104
+ const value = typeof text === 'string' ? text : (text == null ? '' : String(text));
105
+ if (value.trim()) {
106
+ getTranscriptWriter().appendAssistant(value);
107
+ setLastAppendedAssistant(value);
108
+ }
109
+ }
110
+ catch (error) { process.stderr.write(`mixdog: transcript-writer: onAssistantText failed: ${error?.message || error}\n`); }
111
+ }
112
+ return options.onAssistantText?.(text);
113
+ },
114
+ onUsageDelta: options.onUsageDelta,
115
+ onToolResult: (message) => {
116
+ if (getRemoteEnabled() && getTranscriptWriter()) {
117
+ try {
118
+ const tur = message?.toolUseResult;
119
+ if (tur && (tur.oldString != null || tur.newString != null)) {
120
+ getTranscriptWriter().appendToolResult({ oldString: tur.oldString ?? '', newString: tur.newString ?? '' });
121
+ }
122
+ } catch (error) { process.stderr.write(`mixdog: transcript-writer: onToolResult failed: ${error?.message || error}\n`); }
123
+ }
124
+ return options.onToolResult?.(message);
125
+ },
126
+ onToolApproval: options.onToolApproval,
127
+ onCompactEvent: options.onCompactEvent,
128
+ onStageChange: options.onStageChange,
129
+ onStreamDelta: options.onStreamDelta,
130
+ drainSteering: options.drainSteering,
131
+ onSteerMessage: options.onSteerMessage,
132
+ notifyFn: notifyFnForSession(session0.id),
133
+ },
134
+ );
135
+ setSession(mgr.getSession(session0.id) || getSession());
136
+ if (getRemoteEnabled() && getTranscriptWriter()) {
137
+ try {
138
+ const finalText = result?.content != null ? String(result.content) : '';
139
+ if (finalText.trim() && finalText !== getLastAppendedAssistant()) {
140
+ getTranscriptWriter().appendAssistant(finalText);
141
+ setLastAppendedAssistant(finalText);
142
+ }
143
+ } catch (error) {
144
+ process.stderr.write(`mixdog: transcript-writer: final append failed: ${error?.message || error}\n`);
145
+ }
146
+ }
147
+ hooks.emit('turn:end', { sessionId: session0.id, elapsedMs: Date.now() - startedAt });
148
+ try {
149
+ await hooks.dispatch('Stop', hookCommonPayload({ session_id: session0.id }));
150
+ } catch { /* best-effort: Stop hook must never break the turn */ }
151
+ return { result, session: getSession() };
152
+ } catch (error) {
153
+ hooks.emit('turn:error', { sessionId: getSession()?.id || null, elapsedMs: Date.now() - startedAt, error: error?.message || String(error) });
154
+ try {
155
+ const msg = String(error?.message || error || '').toLowerCase();
156
+ const errorType = /rate.?limit|429|too many requests/.test(msg) ? 'rate_limit'
157
+ : /overloaded|529/.test(msg) ? 'overloaded'
158
+ : /authenticat|unauthorized|401|invalid.*api.?key/.test(msg) ? 'authentication_failed'
159
+ : /server.?error|5\d\d|internal error/.test(msg) ? 'server_error'
160
+ : 'unknown';
161
+ void hooks.dispatch('StopFailure', hookCommonPayload({ session_id: getSession()?.id || null, error_type: errorType }));
162
+ } catch { /* best-effort: StopFailure hook must never break teardown */ }
163
+ throw error;
164
+ } finally {
165
+ setActiveTurnCount(Math.max(0, getActiveTurnCount() - 1));
166
+ if (!isFirstTurnCompleted()) {
167
+ setFirstTurnCompleted(true);
168
+ scheduleProviderWarmup();
169
+ scheduleProviderModelWarmup();
170
+ }
171
+ }
172
+ },
173
+ async clear(options = {}) {
174
+ const session = getSession();
175
+ if (!session?.id) return false;
176
+ const cleared = await mgr.clearSessionMessages(session.id, options);
177
+ if (!cleared) return false;
178
+ setSession(typeof cleared === 'object' ? cleared : (mgr.getSession(session.id) || session));
179
+ if (options.recoverAgent === true) {
180
+ try { agentTool.recoverWorkers?.({ clientHostPid: getSession()?.clientHostPid || process.pid }); } catch {}
181
+ }
182
+ invalidateContextStatusCache();
183
+ return true;
184
+ },
185
+ // session_manage tool handoff: the engine polls this at turn end and, if
186
+ // set, runs the same clear path the idle auto-clear uses. One-shot read.
187
+ consumePendingSessionReset() {
188
+ const pending = getPendingSessionReset();
189
+ setPendingSessionReset(null);
190
+ if (!pending) return null;
191
+ const session = getSession();
192
+ // Session changed since scheduling (resume / new session) — drop it.
193
+ if (!session?.id || pending.sessionId !== session.id) return null;
194
+ return pending.action;
195
+ },
196
+ async compact(options = {}) {
197
+ const session = getSession();
198
+ if (!session?.id) return null;
199
+ if (getActiveTurnCount() > 0) {
200
+ return { changed: false, reason: 'compact skipped: turn in progress' };
201
+ }
202
+ // Manual compact bypasses loop.mjs, so its PreCompact/PostCompact never
203
+ // fire here — dispatch them explicitly via the session-property hooks.
204
+ try { await session.preCompactHook?.({ trigger: 'manual' }); }
205
+ catch { /* best-effort: PreCompact hook must never break manual compact */ }
206
+ const result = await mgr.compactSessionMessages(session.id);
207
+ try { await session.postCompactHook?.({ trigger: 'manual' }); }
208
+ catch { /* best-effort: PostCompact hook must never break manual compact */ }
209
+ setSession(mgr.getSession(session.id) || session);
210
+ if (options.recoverAgent === true) {
211
+ try { agentTool.recoverWorkers?.({ clientHostPid: getSession()?.clientHostPid || process.pid }); } catch {}
212
+ }
213
+ invalidateContextStatusCache();
214
+ return result;
215
+ },
216
+ async setToolMode(nextMode) {
217
+ const mode = normalizeToolMode(nextMode);
218
+ setMode(mode);
219
+ invalidatePreSessionToolSurface();
220
+ const session = getSession();
221
+ if (session?.id) mgr.closeSession(session.id, 'cli-mode-switch');
222
+ await recreateCurrentSessionIfReady();
223
+ return mode;
224
+ },
225
+ agentStatus() {
226
+ return agentStatusState();
227
+ },
228
+ agentControl(args = {}) {
229
+ const session = getSession();
230
+ const callerSessionId = session?.id || null;
231
+ return agentTool.execute(args, {
232
+ callerCwd: getCurrentCwd(),
233
+ invocationSource: 'user-command',
234
+ callerSessionId,
235
+ clientHostPid: session?.clientHostPid || process.pid,
236
+ notifyFn: notifyFnForSession(callerSessionId),
237
+ });
238
+ },
239
+ onNotification(listener) {
240
+ if (typeof listener !== 'function') return () => {};
241
+ notificationListeners.add(listener);
242
+ return () => notificationListeners.delete(listener);
243
+ },
244
+ toolsStatus(query = '') {
245
+ const surface = activeToolSurface();
246
+ const catalog = Array.isArray(surface?.deferredToolCatalog)
247
+ ? surface.deferredToolCatalog
248
+ : (Array.isArray(surface?.tools) ? surface.tools : []);
249
+ const activeNames = new Set((surface?.tools || []).map((tool) => tool?.name).filter(Boolean));
250
+ const needle = clean(query).toLowerCase();
251
+ const rows = catalog.map((tool) => toolRow(tool, activeNames)).filter((row) => row.name);
252
+ const tools = needle
253
+ ? rows.filter((row) => toolSearchMatches(row, needle))
254
+ : rows;
255
+ return {
256
+ mode: getMode(),
257
+ count: rows.length,
258
+ activeCount: rows.filter((row) => row.active).length,
259
+ tools,
260
+ activeTools: sortedNamesByMeasuredUsage(activeNames),
261
+ discoveredTools: sortedNamesByMeasuredUsage(surface?.deferredDiscoveredTools || []),
262
+ };
263
+ },
264
+ selectTools(names) {
265
+ const list = Array.isArray(names) ? names : String(names || '').split(/[,\s]+/);
266
+ const result = selectDeferredTools(activeToolSurface(), list, getMode());
267
+ return { ...result, status: this.toolsStatus() };
268
+ },
269
+ setCwd(path) {
270
+ applyResolvedCwd(resolveCwdPath(path));
271
+ return getCurrentCwd();
272
+ },
273
+ };
274
+ }
@@ -98,56 +98,6 @@ const DEFERRED_SELECT_ALIASES = {
98
98
  code: ['code_graph'],
99
99
  shell: ['shell', 'task'],
100
100
  };
101
- const TOOL_SEARCH_SAFE_AUTO_ALIASES = new Set([
102
- 'shell',
103
- 'web',
104
- 'search',
105
- 'agent',
106
- 'memory',
107
- ]);
108
- const TOOL_SEARCH_AMBIGUOUS_AUTO_QUERIES = new Set([
109
- 'status',
110
- 'state',
111
- 'info',
112
- 'list',
113
- 'show',
114
- 'config',
115
- ]);
116
- const TOOL_SEARCH_STOP_WORDS = new Set([
117
- 'a',
118
- 'an',
119
- 'and',
120
- 'are',
121
- 'as',
122
- 'for',
123
- 'from',
124
- 'how',
125
- 'i',
126
- 'in',
127
- 'is',
128
- 'me',
129
- 'need',
130
- 'of',
131
- 'on',
132
- 'please',
133
- 'the',
134
- 'to',
135
- 'tool',
136
- 'tools',
137
- 'use',
138
- 'using',
139
- 'with',
140
- ]);
141
- const TOOL_SEARCH_ROW_ALIASES = Object.freeze({
142
- agent: ['delegate', 'subagent', 'worker', 'parallel agent', 'background agent', 'reviewer', 'explorer'],
143
- cwd: ['cwd', 'working directory', 'current directory', 'project root', 'folder'],
144
- memory: ['save memory', 'store memory', 'delete memory', 'forget memory', 'memory status'],
145
- recall: ['recall', 'previous work', 'past work', 'prior context', 'history', 'resume context'],
146
- search: ['web search', 'internet search', 'current info', 'latest info', 'online search', 'docs search'],
147
- shell: ['run command', 'execute command', 'terminal', 'powershell', 'bash', 'run tests', 'test command', 'build command', 'npm', 'node', 'git'],
148
- task: ['background task', 'async task', 'wait task', 'cancel task', 'task status'],
149
- web_fetch: ['fetch url', 'fetch page', 'open url', 'web page', 'read url', 'docs page'],
150
- });
151
101
 
152
102
  export function toolKind(tool) {
153
103
  const name = clean(tool?.name);
@@ -326,189 +276,14 @@ function toolSearchNativePayload(catalog, names, provider = '') {
326
276
  };
327
277
  }
328
278
 
329
- function toolSearchTokens(value) {
330
- return (clean(value).toLowerCase().match(/[a-z0-9_.-]+/g) || [])
331
- .map((token) => token.replace(/[-.]+/g, '_'))
332
- .filter(Boolean);
333
- }
334
-
335
- function toolSearchMeaningfulTokens(value) {
336
- return toolSearchTokens(value).filter((token) => !TOOL_SEARCH_STOP_WORDS.has(token));
337
- }
338
-
339
- function toolSearchText(row) {
340
- const text = `${row.name} ${String(row.name || '').replace(/_/g, ' ')} ${row.kind} ${row.description} ${row.active ? 'active' : 'deferred'}`;
341
- return `${text} ${text.replace(/[-.]+/g, '_')}`.toLowerCase();
342
- }
343
-
344
- function toolSearchRowAliases(name) {
345
- return TOOL_SEARCH_ROW_ALIASES[clean(name)] || [];
346
- }
347
-
348
- function toolSearchRank(row, query) {
349
- const raw = clean(query).toLowerCase();
350
- if (!raw) return { score: 0, reasons: [] };
351
- const name = clean(row?.name).toLowerCase();
352
- const prettyName = name.replace(/_/g, ' ');
353
- const haystack = toolSearchText(row);
354
- const aliases = toolSearchRowAliases(name);
355
- const aliasText = aliases.join(' ').toLowerCase();
356
- const queryTokens = toolSearchMeaningfulTokens(raw);
357
- const nameTokens = new Set(toolSearchTokens(`${name} ${prettyName}`));
358
- const aliasTokens = new Set(toolSearchTokens(aliasText));
359
- let score = 0;
360
- const reasons = [];
361
- if (raw === name || raw === prettyName) {
362
- score += 120;
363
- reasons.push('exact-name');
364
- }
365
- if (aliases.some((alias) => raw === alias || raw === alias.replace(/[_-]+/g, ' '))) {
366
- score += 100;
367
- reasons.push('exact-alias');
368
- }
369
- // Stop-word-only queries (e.g. "tool", "to") have zero meaningful tokens.
370
- // Without an exact name/alias hit, they must not fall through to the raw
371
- // substring/haystack checks below — every row description likely contains
372
- // "tool" somewhere, producing a noisy pseudo-match list instead of "no
373
- // results". Exact name/alias matches stay intact even when the name is
374
- // itself a stop word.
375
- if (!queryTokens.length && !reasons.length) {
376
- return { score: 0, reasons: [] };
377
- }
378
- if (haystack.includes(raw)) {
379
- score += 34;
380
- reasons.push('phrase');
381
- }
382
- for (const alias of aliases) {
383
- const normalizedAlias = alias.toLowerCase();
384
- if (normalizedAlias && raw.includes(normalizedAlias)) {
385
- score += 58;
386
- reasons.push('alias-phrase');
387
- break;
388
- }
389
- }
390
- let matchedTokens = 0;
391
- for (const token of queryTokens) {
392
- if (nameTokens.has(token) || name.includes(token)) {
393
- score += 24;
394
- matchedTokens += 1;
395
- continue;
396
- }
397
- if (aliasTokens.has(token) || aliasText.includes(token)) {
398
- score += 18;
399
- matchedTokens += 1;
400
- continue;
401
- }
402
- if (haystack.includes(token)) {
403
- score += 7;
404
- matchedTokens += 1;
405
- }
406
- }
407
- if (queryTokens.length && matchedTokens === queryTokens.length) {
408
- score += Math.min(18, queryTokens.length * 6);
409
- reasons.push('all-tokens');
410
- }
411
- if (clean(row?.kind).toLowerCase() === raw) score += 10;
412
- if (score > 0) score += Math.min(6, Math.floor(measuredToolUsage(name) / 150));
413
- return { score, reasons };
414
- }
415
-
279
+ // Plain case-insensitive substring filter over name + description. No scores,
280
+ // no aliases, no auto-selection: tool_search only lists and (via select)
281
+ // loads. Empty query matches every row.
416
282
  export function toolSearchMatches(row, query) {
417
283
  const raw = clean(query).toLowerCase();
418
284
  if (!raw) return true;
419
- return toolSearchRank(row, raw).score > 0;
420
- }
421
-
422
- function rankedToolSearchRows(rows, query) {
423
- const raw = clean(query).toLowerCase();
424
- if (!raw) return rows;
425
- return rows
426
- .map((row) => {
427
- const ranked = toolSearchRank(row, raw);
428
- return { ...row, score: ranked.score, reasons: ranked.reasons };
429
- })
430
- .filter((row) => row.score > 0)
431
- .sort((a, b) => {
432
- if (b.score !== a.score) return b.score - a.score;
433
- if (a.active !== b.active) return a.active ? 1 : -1;
434
- if (b.usage !== a.usage) return b.usage - a.usage;
435
- return String(a.name).localeCompare(String(b.name));
436
- });
437
- }
438
-
439
- function isAsciiWordChar(ch) {
440
- return !!ch && /[A-Za-z0-9]/.test(ch);
441
- }
442
-
443
- // Matches `phrase` inside `text` at a word boundary. ASCII letters/digits on
444
- // either side of the match block it (so "webhook" does not match "web" and
445
- // "prune" does not match "run"); non-ASCII characters (e.g. Korean, where
446
- // words are not space-separated) never count as word chars, so Korean
447
- // substring phrases keep matching as before.
448
- function phraseMatchesAsWords(text, phrase) {
449
- if (!phrase) return false;
450
- let idx = text.indexOf(phrase);
451
- while (idx !== -1) {
452
- const before = idx > 0 ? text[idx - 1] : '';
453
- const after = idx + phrase.length < text.length ? text[idx + phrase.length] : '';
454
- if (!isAsciiWordChar(before) && !isAsciiWordChar(after)) return true;
455
- idx = text.indexOf(phrase, idx + 1);
456
- }
457
- return false;
458
- }
459
-
460
- function queryHasAnyPhrase(query, phrases) {
461
- const text = clean(query).toLowerCase().replace(/[_-]+/g, ' ');
462
- return phrases.some((phrase) => phraseMatchesAsWords(text, phrase));
463
- }
464
-
465
- const TOOL_SEARCH_AUTO_CATEGORY_BRANCHES = [
466
- {
467
- names: ['memory'],
468
- phrases: ['save memory', 'store memory', 'delete memory', 'forget memory', 'memory status', '기억 저장', '메모리 저장'],
469
- },
470
- {
471
- names: ['shell', 'task'],
472
- phrases: ['run', 'execute', 'test', 'tests', 'build', 'terminal', 'command', 'powershell', 'bash', 'shell', 'npm', 'node', 'git', '실행', '테스트', '빌드', '터미널', '쉘'],
473
- },
474
- {
475
- names: ['search', 'web_fetch'],
476
- phrases: ['web', 'internet', 'online', 'current info', 'latest', 'news', 'browse', 'docs', 'documentation', '웹', '인터넷', '최신', '뉴스', '문서'],
477
- },
478
- {
479
- names: ['recall'],
480
- phrases: ['recall', 'remember', 'memory previous', 'previous', 'history', 'past', 'prior', 'earlier', 'resume', '이전', '기억', '히스토리'],
481
- },
482
- {
483
- names: ['agent'],
484
- phrases: ['delegate', 'subagent', 'worker', 'parallel agent', 'background agent', 'reviewer', 'explorer', '에이전트', '워커', '병렬'],
485
- },
486
- {
487
- names: ['cwd'],
488
- phrases: ['working directory', 'project root', 'current directory', 'cwd'],
489
- },
490
- ];
491
-
492
- function autoToolSelectionNames(query, rows) {
493
- const raw = clean(query).toLowerCase();
494
- if (!raw || TOOL_SEARCH_AMBIGUOUS_AUTO_QUERIES.has(raw)) return [];
495
- if (TOOL_SEARCH_SAFE_AUTO_ALIASES.has(raw) && DEFERRED_SELECT_ALIASES[raw]) {
496
- return DEFERRED_SELECT_ALIASES[raw];
497
- }
498
- const matchedBranches = TOOL_SEARCH_AUTO_CATEGORY_BRANCHES.filter((branch) =>
499
- queryHasAnyPhrase(raw, branch.phrases)
500
- );
501
- if (matchedBranches.length === 1) return matchedBranches[0].names;
502
- // Ambiguous (0 or 2+ category branches matched): never auto-load a
503
- // category's tools off a possibly-wrong guess. Fall through to the ranked
504
- // exact-name/alias path below; that path returns [] on its own when there
505
- // is no exact match, so an ambiguous query still safely resolves to [].
506
- const ranked = rankedToolSearchRows(rows, raw);
507
- const top = ranked[0];
508
- if (!top) return [];
509
- const reasons = new Set(top.reasons || []);
510
- if (reasons.has('exact-name') || reasons.has('exact-alias')) return [top.name];
511
- return [];
285
+ const haystack = `${clean(row?.name)} ${clean(row?.description)}`.toLowerCase();
286
+ return haystack.includes(raw);
512
287
  }
513
288
 
514
289
  function expandSelectionNames(names) {
@@ -694,39 +469,18 @@ export function renderToolSearch(args = {}, session, mode = 'full') {
694
469
  const forcedSelectedNames = explicitSelectedNames.length ? explicitSelectedNames : querySelectedNames;
695
470
  const query = querySelectedNames.length ? '' : rawQuery.toLowerCase();
696
471
  const limit = Math.max(1, Math.min(50, Number(args.limit) || 20));
697
- const initialActiveNames = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
698
- const initialRows = catalog.map((tool) => toolRow(tool, initialActiveNames)).filter((row) => row.name);
699
- const autoSelectedNames = (!forcedSelectedNames.length && query)
700
- ? autoToolSelectionNames(query, initialRows)
701
- : [];
702
- const selectedNames = forcedSelectedNames.length ? forcedSelectedNames : autoSelectedNames;
703
- let toolSelection = selectedNames.length ? selectDeferredTools(session, selectedNames, mode) : null;
704
- let selectionMode = forcedSelectedNames.length ? 'select' : (autoSelectedNames.length ? 'auto' : null);
705
- let nextActiveNames = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
706
- let rows = catalog.map((tool) => toolRow(tool, nextActiveNames)).filter((row) => row.name);
707
- let matches = query
708
- ? rankedToolSearchRows(rows, query)
709
- : rows;
710
- // Query hits must become loadable without a second explicit select turn:
711
- // if the query produced ranked matches but the forced/auto path above
712
- // loaded nothing (no exact-name/alias/category hit), fall back to
713
- // auto-loading the ranked match names themselves (deferred/non-active
714
- // only, capped at `limit`). Ambiguous queries stay excluded so a guess
715
- // never silently loads a wrong category.
716
- const STRONG_MATCH_REASONS = new Set(['exact-name', 'exact-alias', 'alias-phrase', 'phrase', 'all-tokens']);
717
- const isStrongMatch = (row) => row.score >= 24 || (row.reasons || []).some((reason) => STRONG_MATCH_REASONS.has(reason));
718
- const queryLoadedNothing = !toolSelection
719
- || (!toolSelection.added.length && !toolSelection.already.length);
720
- if (query && !TOOL_SEARCH_AMBIGUOUS_AUTO_QUERIES.has(query) && matches.length && queryLoadedNothing) {
721
- const fallbackNames = matches.filter((row) => !row.active && isStrongMatch(row)).map((row) => row.name).slice(0, limit);
722
- if (fallbackNames.length) {
723
- toolSelection = selectDeferredTools(session, fallbackNames, mode);
724
- selectionMode = 'query';
725
- nextActiveNames = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
726
- rows = catalog.map((tool) => toolRow(tool, nextActiveNames)).filter((row) => row.name);
727
- matches = query ? rankedToolSearchRows(rows, query) : rows;
728
- }
729
- }
472
+ // Explicit loader only: select names/aliases (or query "select:a,b") load
473
+ // deferred tools. A plain query is a case-insensitive substring filter over
474
+ // name+description for listing — it never loads. No scores, no ranking, no
475
+ // category auto-load: deferred tools are called directly (they auto-promote
476
+ // via deferred call-through) and are advertised in the system-prompt manifest.
477
+ const toolSelection = forcedSelectedNames.length
478
+ ? selectDeferredTools(session, forcedSelectedNames, mode)
479
+ : null;
480
+ const selectionMode = forcedSelectedNames.length ? 'select' : null;
481
+ const nextActiveNames = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
482
+ const rows = catalog.map((tool) => toolRow(tool, nextActiveNames)).filter((row) => row.name);
483
+ const matches = query ? rows.filter((row) => toolSearchMatches(row, query)) : rows;
730
484
  const selected = toolSelection
731
485
  ? {
732
486
  mode: selectionMode,
@@ -743,6 +497,6 @@ export function renderToolSearch(args = {}, session, mode = 'full') {
743
497
  matches: matches.slice(0, limit),
744
498
  activeTools: sortedNamesByMeasuredUsage(nextActiveNames),
745
499
  discoveredTools: sortedNamesByMeasuredUsage(session?.deferredDiscoveredTools || []),
746
- note: 'query ranks matches and auto-loads high-confidence deferred tools, including ranked search hits (deferred, non-active) up to limit; already-loaded/discovered tools are re-surfaced on re-selection; select exact tool names, or query select:a,b, to force load.',
500
+ note: 'Deferred tools listed in the system-prompt manifest can be called directly by name (they auto-load on first call). This tool only lists (query = case-insensitive substring over name+description; never loads) or loads exact tools when you pass select names/aliases or query "select:a,b".',
747
501
  }, null, 2);
748
502
  }
@@ -13,11 +13,11 @@ export const TOOL_SEARCH_TOOL = {
13
13
  openWorldHint: false,
14
14
  agentHidden: true,
15
15
  },
16
- description: 'Search deferred tools; confident queries load matches.',
16
+ description: 'List deferred tools by name+description substring, or load exact tools via select. Deferred tools can also be called directly by name.',
17
17
  inputSchema: {
18
18
  type: 'object',
19
19
  properties: {
20
- query: { type: 'string', description: 'Search text; confident hits load. select:a,b forces exact names.' },
20
+ query: { type: 'string', description: 'Case-insensitive substring filter over deferred tool name+description (lists only, no load). Use select:a,b to load exact names.' },
21
21
  select: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], description: 'Force exact tool names.' },
22
22
  limit: { type: 'number', description: 'Max matches.' },
23
23
  },