create-walle 0.9.25 → 0.9.26

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 (179) hide show
  1. package/README.md +8 -0
  2. package/bin/create-walle.js +815 -45
  3. package/package.json +2 -2
  4. package/template/bin/ctm-dev-cleanup.js +90 -4
  5. package/template/bin/ctm-launch.sh +49 -1
  6. package/template/bin/dev.sh +45 -1
  7. package/template/bin/ensure-stable-node.js +132 -0
  8. package/template/bin/install-service.sh +9 -0
  9. package/template/claude-task-manager/api-prompts.js +899 -119
  10. package/template/claude-task-manager/approval-agent.js +360 -40
  11. package/template/claude-task-manager/bin/ctm-disclaim.c +42 -0
  12. package/template/claude-task-manager/bin/ctm-hotkey.swift +67 -81
  13. package/template/claude-task-manager/bin/ctm-screen-auth.swift +37 -0
  14. package/template/claude-task-manager/bin/install-hotkey.sh +97 -49
  15. package/template/claude-task-manager/bin/restart-ctm.sh +14 -0
  16. package/template/claude-task-manager/db.js +399 -48
  17. package/template/claude-task-manager/docs/approval-hook-sandbox.md +84 -0
  18. package/template/claude-task-manager/docs/codex-app-server-approvals.md +72 -0
  19. package/template/claude-task-manager/docs/codex-native-sandbox.md +47 -0
  20. package/template/claude-task-manager/docs/prompt-editing-tree-design.md +18 -1
  21. package/template/claude-task-manager/lib/approval-hook.js +200 -0
  22. package/template/claude-task-manager/lib/approval-self-adapt.js +1 -0
  23. package/template/claude-task-manager/lib/auth-rules.js +11 -0
  24. package/template/claude-task-manager/lib/background-llm.js +32 -4
  25. package/template/claude-task-manager/lib/codesign-identity.js +140 -0
  26. package/template/claude-task-manager/lib/codex-app-server-client.js +119 -0
  27. package/template/claude-task-manager/lib/codex-approval-bridge.js +118 -0
  28. package/template/claude-task-manager/lib/codex-history-terminal-renderer.js +571 -0
  29. package/template/claude-task-manager/lib/codex-paths.js +73 -0
  30. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +164 -0
  31. package/template/claude-task-manager/lib/codex-rollout-tail.js +72 -0
  32. package/template/claude-task-manager/lib/codex-sandbox-args.js +47 -0
  33. package/template/claude-task-manager/lib/coding-agent-models.js +118 -71
  34. package/template/claude-task-manager/lib/command-targets.js +163 -0
  35. package/template/claude-task-manager/lib/conversation-tail-merge.js +61 -19
  36. package/template/claude-task-manager/lib/db-owner-worker-client.js +29 -1
  37. package/template/claude-task-manager/lib/escalation-review.js +80 -3
  38. package/template/claude-task-manager/lib/flow-control.js +52 -0
  39. package/template/claude-task-manager/lib/fs-watcher.js +24 -15
  40. package/template/claude-task-manager/lib/ingest-cooldown.js +68 -0
  41. package/template/claude-task-manager/lib/jsonl-conversation-parser.js +8 -4
  42. package/template/claude-task-manager/lib/launchd-recovery.js +92 -0
  43. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +207 -52
  44. package/template/claude-task-manager/lib/mobile-push-store.js +7 -0
  45. package/template/claude-task-manager/lib/model-overview-brain-fallback.js +102 -1
  46. package/template/claude-task-manager/lib/model-overview-cache.js +1 -0
  47. package/template/claude-task-manager/lib/oauth-proxy-supervisor.js +2 -1
  48. package/template/claude-task-manager/lib/perf-tracker.js +29 -2
  49. package/template/claude-task-manager/lib/permission-match.js +146 -16
  50. package/template/claude-task-manager/lib/project-slug.js +33 -0
  51. package/template/claude-task-manager/lib/prompt-intent.js +51 -4
  52. package/template/claude-task-manager/lib/read-pool-client.js +48 -3
  53. package/template/claude-task-manager/lib/real-node.js +73 -0
  54. package/template/claude-task-manager/lib/runtime-work-registry.js +131 -14
  55. package/template/claude-task-manager/lib/session-content-backfill.js +24 -5
  56. package/template/claude-task-manager/lib/session-diagnostics-batch.js +87 -0
  57. package/template/claude-task-manager/lib/session-history.js +5 -7
  58. package/template/claude-task-manager/lib/session-host-manager.js +19 -0
  59. package/template/claude-task-manager/lib/session-jobs.js +6 -0
  60. package/template/claude-task-manager/lib/session-message-response-cache.js +89 -0
  61. package/template/claude-task-manager/lib/session-messages-page.js +211 -0
  62. package/template/claude-task-manager/lib/session-messages-projection.js +170 -0
  63. package/template/claude-task-manager/lib/session-standup.js +8 -0
  64. package/template/claude-task-manager/lib/session-timeline-summary.js +16 -2
  65. package/template/claude-task-manager/lib/session-token-usage.js +30 -8
  66. package/template/claude-task-manager/lib/session-workspace-binding.js +29 -15
  67. package/template/claude-task-manager/lib/storage-migration.js +2 -1
  68. package/template/claude-task-manager/lib/transcript-store.js +179 -12
  69. package/template/claude-task-manager/lib/walle-ctm-history.js +298 -11
  70. package/template/claude-task-manager/lib/walle-permission-reply.js +49 -0
  71. package/template/claude-task-manager/lib/walle-session-cache.js +22 -1
  72. package/template/claude-task-manager/lib/walle-supervisor.js +42 -3
  73. package/template/claude-task-manager/package.json +5 -2
  74. package/template/claude-task-manager/prompt-harvest.js +31 -11
  75. package/template/claude-task-manager/providers/claude-code.js +29 -1
  76. package/template/claude-task-manager/providers/codex.js +13 -1
  77. package/template/claude-task-manager/public/css/setup.css +11 -0
  78. package/template/claude-task-manager/public/css/walle-session.css +132 -4
  79. package/template/claude-task-manager/public/css/walle.css +89 -0
  80. package/template/claude-task-manager/public/icon-16.png +0 -0
  81. package/template/claude-task-manager/public/icon-32.png +0 -0
  82. package/template/claude-task-manager/public/icon-512.png +0 -0
  83. package/template/claude-task-manager/public/index.html +2483 -165
  84. package/template/claude-task-manager/public/js/activation-render-check.js +55 -0
  85. package/template/claude-task-manager/public/js/flow-control-policy.js +52 -0
  86. package/template/claude-task-manager/public/js/message-renderer.js +60 -1
  87. package/template/claude-task-manager/public/js/prompts.js +13 -1
  88. package/template/claude-task-manager/public/js/session-status-precedence.js +9 -3
  89. package/template/claude-task-manager/public/js/setup.js +54 -10
  90. package/template/claude-task-manager/public/js/stream-resize-policy.js +80 -0
  91. package/template/claude-task-manager/public/js/stream-view.js +78 -0
  92. package/template/claude-task-manager/public/js/terminal-reconciler.js +52 -2
  93. package/template/claude-task-manager/public/js/tool-state.js +155 -0
  94. package/template/claude-task-manager/public/js/walle-session.js +887 -326
  95. package/template/claude-task-manager/public/js/walle.js +306 -195
  96. package/template/claude-task-manager/public/m/app.css +1 -0
  97. package/template/claude-task-manager/public/m/app.js +33 -3
  98. package/template/claude-task-manager/queue-engine.js +45 -1
  99. package/template/claude-task-manager/server.js +3367 -540
  100. package/template/claude-task-manager/workers/approval-blocklist.js +130 -17
  101. package/template/claude-task-manager/workers/db-owner-worker.js +31 -1
  102. package/template/claude-task-manager/workers/read-pool-worker.js +92 -5
  103. package/template/claude-task-manager/workers/session-host-process.js +10 -0
  104. package/template/claude-task-manager/workers/state-detectors/codex.js +58 -7
  105. package/template/package.json +2 -3
  106. package/template/shared/icons/AppIcon-ctm.icns +0 -0
  107. package/template/shared/icons/AppIcon-walle.icns +0 -0
  108. package/template/wall-e/agent.js +139 -18
  109. package/template/wall-e/api-walle.js +201 -22
  110. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +1981 -0
  111. package/template/wall-e/brain.js +1049 -39
  112. package/template/wall-e/chat.js +427 -86
  113. package/template/wall-e/coding/acceptance-contract.js +26 -1
  114. package/template/wall-e/coding/action-memory-policy.js +353 -0
  115. package/template/wall-e/coding/action-memory-store.js +814 -0
  116. package/template/wall-e/coding/initial-messages.js +197 -0
  117. package/template/wall-e/coding/no-progress-guard.js +327 -0
  118. package/template/wall-e/coding/permission-service.js +88 -22
  119. package/template/wall-e/coding/session-workspaces.js +81 -0
  120. package/template/wall-e/coding/shell-sandbox.js +124 -0
  121. package/template/wall-e/coding/stream-processor.js +63 -2
  122. package/template/wall-e/coding/tool-execution-controller.js +14 -1
  123. package/template/wall-e/coding/tool-registry.js +1 -1
  124. package/template/wall-e/coding/transcript-writer.js +3 -0
  125. package/template/wall-e/coding-orchestrator.js +636 -35
  126. package/template/wall-e/coding-prompts.js +51 -2
  127. package/template/wall-e/docs/model-routing-policy.md +59 -0
  128. package/template/wall-e/docs/walle-shell-sandbox.md +61 -0
  129. package/template/wall-e/extraction/knowledge-extractor.js +76 -23
  130. package/template/wall-e/http/chat-api.js +30 -12
  131. package/template/wall-e/http/model-admin.js +93 -1
  132. package/template/wall-e/lib/background-lanes.js +133 -0
  133. package/template/wall-e/lib/boot-profile.js +11 -0
  134. package/template/wall-e/lib/brain-owner-worker-client.js +324 -0
  135. package/template/wall-e/lib/brain-read-pool-client.js +311 -0
  136. package/template/wall-e/lib/diagnostics-flags.js +87 -0
  137. package/template/wall-e/lib/event-loop-monitor.js +74 -3
  138. package/template/wall-e/lib/mcp-integration.js +7 -1
  139. package/template/wall-e/lib/real-node.js +98 -0
  140. package/template/wall-e/lib/runtime-health.js +206 -0
  141. package/template/wall-e/lib/runtime-worker-pool.js +101 -0
  142. package/template/wall-e/lib/scheduler-worker-jobs.js +231 -0
  143. package/template/wall-e/lib/scheduler.js +446 -17
  144. package/template/wall-e/lib/service-health.js +61 -2
  145. package/template/wall-e/lib/service-readiness.js +258 -0
  146. package/template/wall-e/lib/usage.js +152 -0
  147. package/template/wall-e/lib/worker-thread-pool.js +389 -0
  148. package/template/wall-e/llm/client.js +81 -4
  149. package/template/wall-e/llm/default-fallback.js +54 -8
  150. package/template/wall-e/llm/mlx.js +536 -73
  151. package/template/wall-e/llm/mlx.plugin.json +1 -1
  152. package/template/wall-e/llm/ollama.js +342 -43
  153. package/template/wall-e/llm/provider-error.js +18 -1
  154. package/template/wall-e/llm/provider-health-state.js +176 -0
  155. package/template/wall-e/llm/routing-policy.js +796 -0
  156. package/template/wall-e/llm/supported-models.js +5 -0
  157. package/template/wall-e/loops/tasks.js +60 -14
  158. package/template/wall-e/loops/think.js +89 -24
  159. package/template/wall-e/mcp-server.js +192 -28
  160. package/template/wall-e/server.js +32 -7
  161. package/template/wall-e/skills/script-skill-runner.js +8 -1
  162. package/template/wall-e/skills/skill-planner.js +64 -1
  163. package/template/wall-e/tools/builtin-middleware.js +67 -2
  164. package/template/wall-e/tools/local-tools.js +116 -26
  165. package/template/wall-e/tools/permission-checker.js +52 -4
  166. package/template/wall-e/tools/permission-rules.js +36 -0
  167. package/template/wall-e/tools/shell-analyzer.js +46 -1
  168. package/template/wall-e/training/gemma-e4b-qlora.js +314 -0
  169. package/template/wall-e/training/real-trajectory-miner.js +2617 -0
  170. package/template/wall-e/training/replay-eval-analysis.js +151 -0
  171. package/template/wall-e/training/run-shell-command-selector.js +277 -0
  172. package/template/wall-e/training/tool-sft-dataset.js +312 -0
  173. package/template/wall-e/training/tool-sft-renderers.js +144 -0
  174. package/template/wall-e/training/tool-trace-harvester.js +1440 -0
  175. package/template/wall-e/training/trajectory-action-selector.js +364 -0
  176. package/template/wall-e/weather-runtime.js +232 -0
  177. package/template/wall-e/workers/brain-owner-worker.js +162 -0
  178. package/template/wall-e/workers/brain-read-worker.js +148 -0
  179. package/template/wall-e/workers/runtime-worker.js +145 -0
@@ -11,7 +11,7 @@
11
11
  "modelPrefixes": [],
12
12
  "capabilities": {
13
13
  "chat": true,
14
- "tools": false,
14
+ "tools": true,
15
15
  "vision": false,
16
16
  "thinking": false,
17
17
  "streaming": true,
@@ -6,6 +6,21 @@ function isGemma4Model(model) {
6
6
  return /^gemma4:/i.test(String(model || ''));
7
7
  }
8
8
 
9
+ function isFastGemma4Model(model) {
10
+ return /^gemma4:e4b(?:$|[-:])/i.test(String(model || ''));
11
+ }
12
+
13
+ function envPositiveInt(name, fallback) {
14
+ const raw = process.env[name];
15
+ const n = Number(raw);
16
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
17
+ }
18
+
19
+ function defaultNumCtxForModel(model) {
20
+ if (isGemma4Model(model)) return envPositiveInt('WALLE_OLLAMA_GEMMA_NUM_CTX', 16384);
21
+ return null;
22
+ }
23
+
9
24
  function isReasoningCapableOllamaModel(model) {
10
25
  return /^(gemma4:|gpt-oss\b|deepseek-r1\b|qwen3\b|qwq\b|phi-?4.*reason)/i.test(String(model || ''));
11
26
  }
@@ -40,26 +55,201 @@ function buildOllamaOptions({ model, maxTokens, temperature, options } = {}) {
40
55
  if (maxTokens != null) out.num_predict = maxTokens;
41
56
  if (temperature != null) out.temperature = temperature;
42
57
  if (isGemma4Model(model) && out.num_ctx == null) {
43
- out.num_ctx = 16384;
58
+ out.num_ctx = defaultNumCtxForModel(model);
44
59
  }
45
60
  return Object.keys(out).length ? out : undefined;
46
61
  }
47
62
 
63
+ function compactError(err) {
64
+ const msg = String(err?.message || err || '').replace(/\s+/g, ' ').trim();
65
+ if (/aborted|timeout/i.test(msg)) return 'request_timeout';
66
+ if (/ECONNREFUSED|fetch failed|connection/i.test(msg)) return 'daemon_unreachable';
67
+ return msg.slice(0, 160) || 'unknown_error';
68
+ }
69
+
70
+ function durationNsToMs(value) {
71
+ const n = Number(value || 0);
72
+ return Number.isFinite(n) && n > 0 ? Math.round(n / 1e6) : null;
73
+ }
74
+
75
+ function usageFromOpenAICompat(raw, latencyMs) {
76
+ const base = raw?.usage
77
+ ? {
78
+ input: raw.usage.prompt_tokens ?? raw.usage.prompt_eval_count ?? 0,
79
+ output: raw.usage.completion_tokens ?? raw.usage.eval_count ?? 0,
80
+ }
81
+ : { input: 0, output: 0 };
82
+ const evalDurationNs = raw?.eval_duration || raw?.usage?.eval_duration || 0;
83
+ const evalCount = raw?.eval_count || raw?.usage?.eval_count || base.output || 0;
84
+ const promptEvalDurationNs = raw?.prompt_eval_duration || raw?.usage?.prompt_eval_duration || 0;
85
+ const promptEvalCount = raw?.prompt_eval_count || raw?.usage?.prompt_eval_count || base.input || 0;
86
+ const genTokPerSec = evalDurationNs > 0 ? evalCount / (evalDurationNs / 1e9) : null;
87
+ const prefillTokPerSec = promptEvalDurationNs > 0 ? promptEvalCount / (promptEvalDurationNs / 1e9) : null;
88
+ return {
89
+ ...base,
90
+ requestLatencyMs: latencyMs,
91
+ openAiCompat: true,
92
+ genTokPerSec,
93
+ prefillTokPerSec,
94
+ evalDurationMs: durationNsToMs(evalDurationNs),
95
+ promptEvalDurationMs: durationNsToMs(promptEvalDurationNs),
96
+ };
97
+ }
98
+
99
+ function textFromContent(content) {
100
+ if (content == null) return '';
101
+ if (typeof content === 'string') return content;
102
+ if (Array.isArray(content)) {
103
+ return content.map((part) => {
104
+ if (!part) return '';
105
+ if (typeof part === 'string') return part;
106
+ return part.text || part.content || '';
107
+ }).filter(Boolean).join('\n');
108
+ }
109
+ if (typeof content === 'object') {
110
+ if (content.text != null) return textFromContent(content.text);
111
+ if (content.content != null) return textFromContent(content.content);
112
+ try { return JSON.stringify(content); } catch { return String(content); }
113
+ }
114
+ return String(content);
115
+ }
116
+
117
+ function messagesToOllamaNative(messages = []) {
118
+ const out = [];
119
+ const toolNamesById = new Map();
120
+
121
+ for (const msg of messages || []) {
122
+ if (!msg || !msg.role) continue;
123
+ if (typeof msg.content === 'string') {
124
+ const converted = { role: msg.role, content: msg.content };
125
+ if (msg.role === 'assistant' && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
126
+ converted.tool_calls = msg.tool_calls.map((call, index) => {
127
+ const id = call.id || call.toolCallId || call.tool_use_id || `ollama_tool_${index}`;
128
+ const name = call.name || call.tool || call.function?.name || '';
129
+ if (id && name) toolNamesById.set(id, name);
130
+ return { function: { name, arguments: call.input || call.arguments || call.function?.arguments || {} } };
131
+ });
132
+ }
133
+ if (msg.role === 'tool') converted.tool_name = msg.tool_name || msg.name || toolNamesById.get(msg.tool_call_id) || '';
134
+ out.push(converted);
135
+ continue;
136
+ }
137
+
138
+ if (!Array.isArray(msg.content)) {
139
+ out.push({ role: msg.role, content: textFromContent(msg.content) });
140
+ continue;
141
+ }
142
+
143
+ const toolResults = msg.content.filter((part) => part?.type === 'tool_result');
144
+ if (toolResults.length > 0) {
145
+ for (const result of toolResults) {
146
+ const id = result.tool_use_id || result.id || result.toolCallId || '';
147
+ out.push({
148
+ role: 'tool',
149
+ content: textFromContent(result.content),
150
+ tool_name: result.tool_name || result.name || toolNamesById.get(id) || '',
151
+ });
152
+ }
153
+ continue;
154
+ }
155
+
156
+ const textParts = msg.content.filter((part) => part?.type === 'text');
157
+ const reasoningParts = msg.content.filter((part) => part?.type === 'reasoning');
158
+ const toolUses = msg.content.filter((part) => part?.type === 'tool_use');
159
+ const converted = {
160
+ role: msg.role,
161
+ content: textParts.map((part) => part.text || part.content || '').filter(Boolean).join('\n') || '',
162
+ };
163
+ if (reasoningParts.length > 0) {
164
+ converted.thinking = reasoningParts.map((part) => part.text || part.content || '').filter(Boolean).join('\n');
165
+ }
166
+ if (toolUses.length > 0) {
167
+ converted.tool_calls = toolUses.map((call, index) => {
168
+ const id = call.id || call.tool_use_id || call.toolCallId || `ollama_tool_${index}`;
169
+ if (id && call.name) toolNamesById.set(id, call.name);
170
+ return { function: { name: call.name || '', arguments: call.input || {} } };
171
+ });
172
+ }
173
+ out.push(converted);
174
+ }
175
+ return out;
176
+ }
177
+
178
+ function responseFromOllamaNative(raw, latencyMs) {
179
+ const msg = raw?.message || {};
180
+ const toolCalls = (msg.tool_calls || []).map((call, index) => ({
181
+ id: call.id || call.toolCallId || call.tool_use_id || `ollama_tool_${index}`,
182
+ name: call.name || call.tool || call.function?.name || '',
183
+ input: call.input || call.arguments || call.function?.arguments || {},
184
+ })).filter((call) => call.name);
185
+ const evalDurationNs = raw?.eval_duration || 0;
186
+ const evalCount = raw?.eval_count || 0;
187
+ const promptEvalDurationNs = raw?.prompt_eval_duration || 0;
188
+ const promptEvalCount = raw?.prompt_eval_count || 0;
189
+ const genTokPerSec = evalDurationNs > 0 ? evalCount / (evalDurationNs / 1e9) : null;
190
+ const prefillTokPerSec = promptEvalDurationNs > 0 ? promptEvalCount / (promptEvalDurationNs / 1e9) : null;
191
+ return {
192
+ content: msg.content || null,
193
+ reasoningContent: msg.thinking || msg.reasoning || msg.reasoning_content || null,
194
+ toolCalls,
195
+ stopReason: toolCalls.length > 0 ? 'tool_use' : 'end_turn',
196
+ usage: {
197
+ input: promptEvalCount,
198
+ output: evalCount,
199
+ genTokPerSec,
200
+ prefillTokPerSec,
201
+ evalDurationMs: evalDurationNs / 1e6,
202
+ promptEvalDurationMs: promptEvalDurationNs / 1e6,
203
+ requestLatencyMs: latencyMs,
204
+ },
205
+ latencyMs,
206
+ raw,
207
+ };
208
+ }
209
+
210
+ async function fetchJson(url, opts = {}) {
211
+ const timeoutMs = opts.timeoutMs || 5000;
212
+ const init = { ...opts };
213
+ delete init.timeoutMs;
214
+ if (!init.signal && timeoutMs > 0) init.signal = AbortSignal.timeout(timeoutMs);
215
+ const resp = await fetch(url, init);
216
+ const text = await resp.text();
217
+ let json = null;
218
+ try { json = text ? JSON.parse(text) : null; } catch {}
219
+ if (!resp.ok) {
220
+ const detail = json?.error || text || `HTTP ${resp.status}`;
221
+ const err = new Error(`Ollama API error ${resp.status}: ${detail}`);
222
+ err.status = resp.status;
223
+ err.body = text;
224
+ throw err;
225
+ }
226
+ return json;
227
+ }
228
+
229
+ function ollamaModelName(model) {
230
+ return String(model?.name || model?.model || '').trim();
231
+ }
232
+
48
233
  /**
49
234
  * Create an Ollama local-model LLM provider.
50
235
  * Uses Ollama's OpenAI-compatible API — no npm dependency needed.
51
236
  * @param {{ baseUrl?: string }} config
52
237
  */
53
238
  function createOllamaProvider(config = {}) {
54
- const baseUrl = (config.baseUrl || 'http://localhost:11434').replace(/\/+$/, '');
239
+ const baseUrl = (config.baseUrl || 'http://127.0.0.1:11434').replace(/\/+$/, '');
55
240
  const defaultThink = config.think ?? false;
241
+ const defaultKeepAlive = config.keepAlive ?? process.env.OLLAMA_KEEP_ALIVE ?? '30m';
56
242
 
57
243
  return {
58
244
  type: 'ollama',
59
245
 
60
- async chat({ model, messages, tools, system, maxTokens, temperature, signal, thinking, reasoningEffort, options } = {}) {
246
+ async chat({ model, messages, tools, system, maxTokens, temperature, signal, thinking, reasoningEffort, options, keepAlive } = {}) {
61
247
  const modelId = model || 'gemma4:e4b';
62
248
  const openaiMessages = [];
249
+ const nativeMessages = messagesToOllamaNative([
250
+ ...(system ? [{ role: 'system', content: system }] : []),
251
+ ...((messages && messages.length > 0) ? messages : []),
252
+ ]);
63
253
 
64
254
  // System prompt as first message
65
255
  if (system) {
@@ -71,28 +261,51 @@ function createOllamaProvider(config = {}) {
71
261
  openaiMessages.push(...messagesToOpenAI(messages));
72
262
  }
73
263
 
74
- // Use native Ollama API when no tools — it returns eval_duration for accurate gen speed
75
- const useNativeApi = !tools || tools.length === 0;
264
+ // Native Ollama API honors options/keep_alive and returns eval_duration
265
+ // timing. Keep tool calls on the OpenAI-compatible path by default: it is
266
+ // currently more reliable for Gemma4 coding-agent tool selection. Native
267
+ // tools remain available for explicit probes via config.useNativeTools.
268
+ const useNativeApi = !tools || tools.length === 0 || config.useNativeTools === true;
76
269
 
77
270
  if (useNativeApi) {
78
271
  const resolvedThink = resolveThinkValue({ model: modelId, thinking, reasoningEffort });
79
272
  const nativeBody = {
80
273
  model: modelId,
81
- messages: openaiMessages,
274
+ messages: nativeMessages,
82
275
  stream: false,
83
276
  think: resolvedThink !== undefined ? resolvedThink : defaultThink,
84
277
  };
278
+ const resolvedKeepAlive = keepAlive ?? defaultKeepAlive;
279
+ if (resolvedKeepAlive != null && resolvedKeepAlive !== '') nativeBody.keep_alive = resolvedKeepAlive;
85
280
  const nativeOptions = buildOllamaOptions({ model: modelId, maxTokens, temperature, options });
86
281
  if (nativeOptions) nativeBody.options = nativeOptions;
282
+ const openaiTools = toOpenAI(tools);
283
+ if (openaiTools && openaiTools.length > 0) nativeBody.tools = openaiTools;
87
284
 
88
285
  const start = Date.now();
89
- const resp = await fetch(`${baseUrl}/api/chat`, {
286
+ let resp = await fetch(`${baseUrl}/api/chat`, {
90
287
  method: 'POST',
91
288
  headers: { 'Content-Type': 'application/json' },
92
289
  body: JSON.stringify(nativeBody),
93
290
  signal,
94
291
  });
95
292
 
293
+ if (!resp.ok && resp.status === 400 && nativeBody.tools) {
294
+ const errText = await resp.text().catch(() => '');
295
+ if (errText.includes('does not support tools')) {
296
+ console.log(`[ollama] ${model} does not support tools — retrying without`);
297
+ delete nativeBody.tools;
298
+ resp = await fetch(`${baseUrl}/api/chat`, {
299
+ method: 'POST',
300
+ headers: { 'Content-Type': 'application/json' },
301
+ body: JSON.stringify(nativeBody),
302
+ signal,
303
+ });
304
+ } else {
305
+ throw new Error(`Ollama API error ${resp.status}: ${errText}`);
306
+ }
307
+ }
308
+
96
309
  if (!resp.ok) {
97
310
  const text = await resp.text().catch(() => '');
98
311
  throw new Error(`Ollama API error ${resp.status}: ${text}`);
@@ -100,34 +313,10 @@ function createOllamaProvider(config = {}) {
100
313
 
101
314
  const raw = await resp.json();
102
315
  const latencyMs = Date.now() - start;
103
- const msg = raw.message || {};
104
- const reasoningContent = msg.thinking || msg.reasoning || msg.reasoning_content || null;
105
-
106
- // Extract accurate timing from native API (durations are in nanoseconds)
107
- const evalDurationNs = raw.eval_duration || 0;
108
- const evalCount = raw.eval_count || 0;
109
- const promptEvalDurationNs = raw.prompt_eval_duration || 0;
110
- const promptEvalCount = raw.prompt_eval_count || 0;
111
- const genTokPerSec = evalDurationNs > 0 ? evalCount / (evalDurationNs / 1e9) : null;
112
- const prefillTokPerSec = promptEvalDurationNs > 0 ? promptEvalCount / (promptEvalDurationNs / 1e9) : null;
113
-
114
316
  return {
115
- content: msg.content || null,
116
- reasoningContent,
117
- toolCalls: [],
118
- stopReason: 'end_turn',
119
- usage: {
120
- input: promptEvalCount,
121
- output: evalCount,
122
- genTokPerSec,
123
- prefillTokPerSec,
124
- evalDurationMs: evalDurationNs / 1e6,
125
- promptEvalDurationMs: promptEvalDurationNs / 1e6,
126
- },
127
- latencyMs,
317
+ ...responseFromOllamaNative(raw, latencyMs),
128
318
  model: modelId,
129
319
  provider: 'ollama',
130
- raw,
131
320
  };
132
321
  }
133
322
 
@@ -145,7 +334,6 @@ function createOllamaProvider(config = {}) {
145
334
 
146
335
  if (maxTokens != null) body.max_tokens = maxTokens;
147
336
  if (temperature != null) body.temperature = temperature;
148
-
149
337
  const start = Date.now();
150
338
  let resp = await fetch(`${baseUrl}/v1/chat/completions`, {
151
339
  method: 'POST',
@@ -178,21 +366,54 @@ function createOllamaProvider(config = {}) {
178
366
 
179
367
  const raw = await resp.json();
180
368
  const normalized = responseFromOpenAI(raw);
369
+ const latencyMs = Date.now() - start;
181
370
 
182
371
  let stopReason = 'end_turn';
183
372
  if (normalized.stopReason === 'tool_calls') stopReason = 'tool_use';
184
373
  else if (normalized.stopReason === 'stop' || !normalized.stopReason) stopReason = 'end_turn';
185
374
 
186
- return { ...normalized, stopReason, latencyMs: Date.now() - start, model: modelId, provider: 'ollama', raw };
375
+ return {
376
+ ...normalized,
377
+ usage: usageFromOpenAICompat(raw, latencyMs),
378
+ stopReason,
379
+ latencyMs,
380
+ model: modelId,
381
+ provider: 'ollama',
382
+ raw,
383
+ };
384
+ },
385
+
386
+ async prewarm({ model, keepAlive, timeoutMs = 120000 } = {}) {
387
+ const modelId = model || config.model || 'gemma4:e4b';
388
+ const body = {
389
+ model: modelId,
390
+ messages: [{ role: 'user', content: 'Reply OK.' }],
391
+ stream: false,
392
+ think: false,
393
+ keep_alive: keepAlive ?? defaultKeepAlive,
394
+ options: { num_predict: 8, num_ctx: Math.min(defaultNumCtxForModel(modelId) || 2048, 2048) },
395
+ };
396
+ const started = Date.now();
397
+ const raw = await fetchJson(`${baseUrl}/api/chat`, {
398
+ method: 'POST',
399
+ headers: { 'Content-Type': 'application/json' },
400
+ body: JSON.stringify(body),
401
+ timeoutMs,
402
+ });
403
+ return {
404
+ ok: Boolean(raw?.message?.content),
405
+ latencyMs: Date.now() - started,
406
+ totalDurationMs: durationNsToMs(raw?.total_duration),
407
+ loadDurationMs: durationNsToMs(raw?.load_duration),
408
+ promptEvalCount: raw?.prompt_eval_count ?? null,
409
+ evalCount: raw?.eval_count ?? null,
410
+ raw,
411
+ };
187
412
  },
188
413
 
189
414
  async listModels() {
190
415
  try {
191
- const resp = await fetch(`${baseUrl}/api/tags`, {
192
- signal: AbortSignal.timeout(3000),
193
- });
194
- if (!resp.ok) return [];
195
- const data = await resp.json();
416
+ const data = await fetchJson(`${baseUrl}/api/tags`, { timeoutMs: 3000 });
196
417
  return (data.models || [])
197
418
  // Drop embedding models — they're not chat-capable.
198
419
  .filter((m) => !/embed|embedding/i.test(m.name || ''))
@@ -209,14 +430,91 @@ function createOllamaProvider(config = {}) {
209
430
 
210
431
  async isAvailable() {
211
432
  try {
212
- const resp = await fetch(`${baseUrl}/api/tags`, {
213
- signal: AbortSignal.timeout(3000),
214
- });
215
- return resp.ok;
433
+ await fetchJson(`${baseUrl}/api/tags`, { timeoutMs: 3000 });
434
+ return true;
216
435
  } catch {
217
436
  return false;
218
437
  }
219
438
  },
439
+
440
+ async probeConnection({ model, timeoutMs = 25000, chatTimeoutMs, keepAlive } = {}) {
441
+ const started = Date.now();
442
+ const modelId = model || config.model || 'gemma4:e4b';
443
+ const probe = {
444
+ ok: false,
445
+ provider: 'ollama',
446
+ model: modelId,
447
+ base_url: baseUrl,
448
+ checked_at: new Date().toISOString(),
449
+ daemon_ok: false,
450
+ model_present: false,
451
+ loaded: null,
452
+ diagnostics: {},
453
+ };
454
+ try {
455
+ const tags = await fetchJson(`${baseUrl}/api/tags`, { timeoutMs: Math.min(timeoutMs, 5000) });
456
+ probe.daemon_ok = true;
457
+ const models = Array.isArray(tags.models) ? tags.models : [];
458
+ probe.models = models
459
+ .map((m) => ollamaModelName(m))
460
+ .filter(Boolean)
461
+ .filter((name) => !/embed|embedding/i.test(name));
462
+ probe.model_present = probe.models.includes(modelId);
463
+ if (!probe.model_present) {
464
+ probe.error_type = 'model_missing';
465
+ probe.error = `Ollama model "${modelId}" is not installed.`;
466
+ probe.latency_ms = Date.now() - started;
467
+ return probe;
468
+ }
469
+
470
+ try {
471
+ const ps = await fetchJson(`${baseUrl}/api/ps`, { timeoutMs: Math.min(timeoutMs, 5000) });
472
+ const loadedModels = Array.isArray(ps.models) ? ps.models : [];
473
+ const loaded = loadedModels.find((m) => ollamaModelName(m) === modelId) || null;
474
+ probe.loaded = loaded ? {
475
+ loaded: true,
476
+ context_length: loaded.context_length ?? loaded.details?.context_length ?? null,
477
+ expires_at: loaded.expires_at || null,
478
+ size_vram: loaded.size_vram ?? null,
479
+ } : { loaded: false };
480
+ } catch (err) {
481
+ probe.loaded = { loaded: null, error: compactError(err) };
482
+ }
483
+
484
+ const chatStart = Date.now();
485
+ const chat = await fetchJson(`${baseUrl}/api/chat`, {
486
+ method: 'POST',
487
+ headers: { 'Content-Type': 'application/json' },
488
+ body: JSON.stringify({
489
+ model: modelId,
490
+ messages: [{ role: 'user', content: 'Reply with OK.' }],
491
+ stream: false,
492
+ think: false,
493
+ keep_alive: keepAlive ?? defaultKeepAlive,
494
+ options: { num_predict: 8, num_ctx: 2048 },
495
+ }),
496
+ timeoutMs: chatTimeoutMs || timeoutMs,
497
+ });
498
+ const content = String(chat?.message?.content || '').trim();
499
+ probe.ok = content.length > 0;
500
+ probe.error_type = probe.ok ? '' : 'empty_response';
501
+ probe.error = probe.ok ? '' : 'Ollama returned an empty probe response.';
502
+ probe.latency_ms = Date.now() - started;
503
+ probe.diagnostics = {
504
+ chat_latency_ms: Date.now() - chatStart,
505
+ total_duration_ms: durationNsToMs(chat?.total_duration),
506
+ load_duration_ms: durationNsToMs(chat?.load_duration),
507
+ prompt_eval_count: chat?.prompt_eval_count ?? null,
508
+ eval_count: chat?.eval_count ?? null,
509
+ };
510
+ return probe;
511
+ } catch (err) {
512
+ probe.error_type = compactError(err);
513
+ probe.error = String(err?.message || err || 'Ollama probe failed').slice(0, 240);
514
+ probe.latency_ms = Date.now() - started;
515
+ return probe;
516
+ }
517
+ },
220
518
  };
221
519
  }
222
520
 
@@ -224,6 +522,7 @@ module.exports = {
224
522
  createOllamaProvider,
225
523
  buildOllamaOptions,
226
524
  resolveThinkValue,
525
+ isFastGemma4Model,
227
526
  isReasoningCapableOllamaModel,
228
527
  capabilitiesForOllamaModel,
229
528
  };
@@ -114,6 +114,11 @@ function classifyType(status, lower) {
114
114
  ) return 'auth_error';
115
115
  if (status === 404 || /model .*not found|not_found_error|model_not_found|does not exist|unknown model/i.test(lower)) return 'model_unavailable';
116
116
  if (/context length exceeded|context_length_exceeded|maximum context|token limit/i.test(lower)) return 'context_window';
117
+ // Connect-phase failures mean the endpoint was never reached (DNS, routing,
118
+ // VPN-only gateways). They must classify as 'network' even though undici
119
+ // spells them "Connect Timeout Error" — the generic 'timeout' advice
120
+ // ("switch provider/model") is wrong when nothing ever connected.
121
+ if (/und_err_connect_timeout|connecttimeouterror|connect timeout|ehostunreach|enetunreach|ehostdown/i.test(lower)) return 'network';
117
122
  if (status === 408 || /timeout|timed out|etimedout|aborterror|aborted/i.test(lower)) return 'timeout';
118
123
  if (status === 529 || status === 503 || /overloaded|service unavailable|temporarily unavailable|unavailable/i.test(lower)) return 'provider_unavailable';
119
124
  if (/fetch failed|network|eai_again|enotfound|econnreset|socket hang up|connection refused|econnrefused/i.test(lower)) return 'network';
@@ -157,7 +162,7 @@ function messageForType(type) {
157
162
  case 'network':
158
163
  return {
159
164
  title: 'AI provider network error',
160
- body: 'Wall-E could not reach the provider endpoint. Check connectivity, base URL, or local provider status, then retry.',
165
+ body: 'Wall-E could not reach the provider endpoint. Check connectivity (VPN if the endpoint is internal), the base URL, or local provider status, then retry.',
161
166
  };
162
167
  case 'provider_unavailable':
163
168
  return {
@@ -258,6 +263,18 @@ function toApiPayload(err, context = {}) {
258
263
  function recordProviderFailureAlert(providerError, brain) {
259
264
  if (!providerError) return { alerted: false };
260
265
  try {
266
+ try {
267
+ const { recordProviderHealthStatus } = require('./provider-health-state');
268
+ recordProviderHealthStatus({
269
+ provider: providerError.provider || 'default',
270
+ model: providerError.model || '',
271
+ ok: false,
272
+ source: 'provider-error',
273
+ error_type: providerError.type || 'provider_error',
274
+ error: providerError.rawMessage || providerError.userMessage || providerError.title || '',
275
+ checked_at: providerError.createdAt || new Date().toISOString(),
276
+ }, { brain, clearTransientAlerts: false });
277
+ } catch {}
261
278
  const planner = require('../skills/skill-planner');
262
279
  const actionUrl = providerError.type === 'request_invariant'
263
280
  ? null