create-walle 0.9.7 → 0.9.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-walle",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "description": "CTM + Wall-E — AI coding dashboard and personal digital twin agent. Multi-agent terminal for Claude Code, Codex, Gemini & Aider, plus prompt editor, task queue, and an agent that learns from Slack, email & calendar.",
5
5
  "bin": {
6
6
  "create-walle": "bin/create-walle.js"
@@ -2546,6 +2546,12 @@
2546
2546
  <button class="btn primary" id="topbar-new-session-btn" onclick="showNewSessionModal()">+ New Session</button>
2547
2547
  </div>
2548
2548
  </div>
2549
+ <div id="update-banner" style="display:none;background:linear-gradient(90deg,#1a1b2e,#1e2030);border-bottom:1px solid var(--border);padding:6px 16px;font-size:12px;color:var(--fg-dim,#a9b1d6);align-items:center;gap:10px;">
2550
+ <span style="color:#bb9af7;">&#x2191;</span>
2551
+ <span id="update-banner-msg">Update available</span>
2552
+ <button id="update-apply-btn" onclick="applyUpdate()" style="background:#7aa2f7;color:#1a1b26;border:none;padding:3px 10px;border-radius:4px;font-size:11px;cursor:pointer;font-weight:600;">Update Now</button>
2553
+ <button onclick="dismissUpdate()" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;margin-left:auto;opacity:0.6;font-size:14px;">&times;</button>
2554
+ </div>
2549
2555
  <div id="main">
2550
2556
  <div id="sidebar">
2551
2557
  <div class="sidebar-section">
@@ -3346,6 +3352,63 @@ function showApprovalToast(text, color, duration) {
3346
3352
  toast(text, { type, duration });
3347
3353
  }
3348
3354
 
3355
+ // --- Update Banner ---
3356
+ let _updateDismissedVersion = localStorage.getItem('update_dismissed_version') || '';
3357
+
3358
+ function showUpdateBanner(current, latest) {
3359
+ if (_updateDismissedVersion === latest) return;
3360
+ const banner = document.getElementById('update-banner');
3361
+ const msg = document.getElementById('update-banner-msg');
3362
+ if (!banner || !msg) return;
3363
+ msg.textContent = `Update available: v${current} \u2192 v${latest}`;
3364
+ banner.style.display = 'flex';
3365
+ }
3366
+
3367
+ function dismissUpdate() {
3368
+ const banner = document.getElementById('update-banner');
3369
+ if (banner) banner.style.display = 'none';
3370
+ // Remember dismissal for this version
3371
+ const msg = document.getElementById('update-banner-msg')?.textContent || '';
3372
+ const m = msg.match(/v([\d.]+)$/);
3373
+ if (m) {
3374
+ _updateDismissedVersion = m[1];
3375
+ localStorage.setItem('update_dismissed_version', m[1]);
3376
+ }
3377
+ }
3378
+
3379
+ async function applyUpdate() {
3380
+ const btn = document.getElementById('update-apply-btn');
3381
+ if (btn) { btn.textContent = 'Updating...'; btn.disabled = true; }
3382
+ try {
3383
+ const resp = await fetch('/api/updates/apply', { method: 'POST' });
3384
+ const data = await resp.json();
3385
+ if (data.status === 'updating') {
3386
+ toast('Update started. CTM will restart shortly...', { type: 'info', duration: 8000 });
3387
+ dismissUpdate();
3388
+ } else {
3389
+ toast('Already up to date.', { type: 'success' });
3390
+ if (btn) { btn.textContent = 'Update Now'; btn.disabled = false; }
3391
+ }
3392
+ } catch (e) {
3393
+ toast('Update failed: ' + e.message, { type: 'error' });
3394
+ if (btn) { btn.textContent = 'Update Now'; btn.disabled = false; }
3395
+ }
3396
+ }
3397
+
3398
+ function checkForUpdates() {
3399
+ fetch('/api/updates/check')
3400
+ .then(r => r.json())
3401
+ .then(data => {
3402
+ if (data.updateAvailable) {
3403
+ showUpdateBanner(data.currentVersion, data.latestVersion);
3404
+ }
3405
+ })
3406
+ .catch(() => {});
3407
+ }
3408
+
3409
+ // Check on page load
3410
+ setTimeout(checkForUpdates, 3000);
3411
+
3349
3412
  // --- State ---
3350
3413
  function getCookie(name) {
3351
3414
  const m = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]+)'));
@@ -3401,6 +3464,7 @@ function connect() {
3401
3464
  case 'waiting-for-input': onWaitingForInput(msg); break;
3402
3465
  case 'files-changed': if (window.CR) CR.handleFilesChanged(msg); break;
3403
3466
  case 'model-alert': showModelAlert(msg); break;
3467
+ case 'update-available': showUpdateBanner(msg.currentVersion, msg.latestVersion); break;
3404
3468
  case 'walle-progress': WalleSession.handleProgress(msg); break;
3405
3469
  case 'walle-response': WalleSession.handleResponse(msg); break;
3406
3470
  case 'walle-history': WalleSession.handleHistory(msg); break;
@@ -4395,10 +4459,12 @@ function renderModelsHeader(providers, models) {
4395
4459
  '</div>' +
4396
4460
  '<div style="display:flex;gap:8px;align-items:center;">' +
4397
4461
  '<input type="text" id="models-search" placeholder="Search models..." value="' + escHtml(_modelsRegistryFilter) + '" style="background:var(--bg-card,#24283b);color:var(--fg,#c0caf5);border:1px solid var(--border,#414868);border-radius:6px;padding:6px 12px;font-size:13px;width:200px;">' +
4398
- '<button onclick="showAddProviderDialog()" style="padding:6px 14px;border-radius:6px;background:var(--accent,#7aa2f7);color:#1a1b26;border:none;cursor:pointer;font-weight:600;font-size:13px;white-space:nowrap;">+ Add Provider</button>' +
4462
+ '<button id="models-add-provider-btn" style="padding:6px 14px;border-radius:6px;background:var(--accent,#7aa2f7);color:#1a1b26;border:none;cursor:pointer;font-weight:600;font-size:13px;white-space:nowrap;">+ Add Provider</button>' +
4399
4463
  '</div>' +
4400
4464
  '</div>';
4401
4465
  el.innerHTML = DOMPurify.sanitize(h, { ADD_ATTR: ['style', 'placeholder'] });
4466
+ var addBtn = document.getElementById('models-add-provider-btn');
4467
+ if (addBtn) addBtn.addEventListener('click', showAddProviderDialog);
4402
4468
  var searchInput = document.getElementById('models-search');
4403
4469
  if (searchInput) searchInput.addEventListener('input', function() {
4404
4470
  _modelsRegistryFilter = this.value;
@@ -10533,7 +10599,7 @@ function qpEditKeydown(e, idx) {
10533
10599
  saveQpEdit(idx);
10534
10600
  } else if (e.key === 'Escape') {
10535
10601
  e.preventDefault();
10536
- cancelQpEdit();
10602
+ cancelQpEdit(idx);
10537
10603
  }
10538
10604
  }
10539
10605
 
@@ -10585,7 +10651,14 @@ async function syncQpItemToPrompt(promptId, title, content) {
10585
10651
  }
10586
10652
  }
10587
10653
 
10588
- function cancelQpEdit() {
10654
+ function cancelQpEdit(idx) {
10655
+ if (qpEditingIdx >= 0) {
10656
+ var inp = document.getElementById('qp-edit-input');
10657
+ var original = (idx != null && qpItems[idx]) ? qpItems[idx].text : '';
10658
+ if (inp && inp.value !== original) {
10659
+ if (!confirm('Discard unsaved changes?')) return;
10660
+ }
10661
+ }
10589
10662
  qpEditingIdx = -1;
10590
10663
  renderQpItems();
10591
10664
  }
@@ -128,6 +128,85 @@ window.WalleSession = (function() {
128
128
 
129
129
  header.appendChild(headerRight);
130
130
 
131
+ // Session toolbar (matches terminal session toolbar)
132
+ var toolbar = document.createElement('div');
133
+ toolbar.className = 'session-toolbar';
134
+
135
+ var copyBtn = document.createElement('button');
136
+ copyBtn.className = 'session-toolbar-btn';
137
+ copyBtn.title = 'Copy session ID';
138
+ copyBtn.textContent = '\uD83D\uDCCB Copy Command';
139
+ copyBtn.onclick = function() {
140
+ var s = state.sessions.get(id);
141
+ var cwd = s && s.meta ? s.meta.cwd || '' : '';
142
+ var cmd = cwd ? 'cd ' + cwd + ' && walle --session ' + id : 'walle --session ' + id;
143
+ navigator.clipboard.writeText(cmd).then(function() {
144
+ if (typeof toast === 'function') toast('Command copied to clipboard');
145
+ }).catch(function() {
146
+ var ta = document.createElement('textarea');
147
+ ta.value = cmd;
148
+ document.body.appendChild(ta);
149
+ ta.select();
150
+ document.execCommand('copy');
151
+ document.body.removeChild(ta);
152
+ if (typeof toast === 'function') toast('Command copied to clipboard');
153
+ });
154
+ };
155
+ toolbar.appendChild(copyBtn);
156
+
157
+ var reflowBtn = document.createElement('button');
158
+ reflowBtn.className = 'session-toolbar-btn';
159
+ reflowBtn.title = 'Scroll to bottom';
160
+ reflowBtn.textContent = '\u21BB Reflow';
161
+ reflowBtn.onclick = function() {
162
+ var el = document.getElementById('walle-messages-' + id);
163
+ if (el) el.scrollTop = el.scrollHeight;
164
+ };
165
+ toolbar.appendChild(reflowBtn);
166
+
167
+ // Model switcher (smart routing default)
168
+ var toolbarModelSelect = document.createElement('select');
169
+ toolbarModelSelect.className = 'model-switch-select';
170
+ toolbarModelSelect.title = 'Switch model for this session';
171
+ var defaultModelOpt = document.createElement('option');
172
+ defaultModelOpt.value = '';
173
+ defaultModelOpt.textContent = 'auto (smart routing)';
174
+ toolbarModelSelect.appendChild(defaultModelOpt);
175
+ toolbarModelSelect.onchange = function() {
176
+ var ws = getState(id);
177
+ if (ws) ws.selectedModel = toolbarModelSelect.value || '';
178
+ // Also sync the header model-select
179
+ var headerSel = document.getElementById('walle-model-' + id);
180
+ if (headerSel) headerSel.value = toolbarModelSelect.value;
181
+ };
182
+ toolbar.appendChild(toolbarModelSelect);
183
+
184
+ // Populate toolbar model selector from registry
185
+ populateToolbarModelSelector(toolbarModelSelect);
186
+
187
+ // Message count badge (like prompt nav badge)
188
+ var msgNav = document.createElement('div');
189
+ msgNav.className = 'prompt-nav';
190
+ msgNav.style.position = 'relative';
191
+ var msgBadge = document.createElement('span');
192
+ msgBadge.className = 'prompt-nav-badge';
193
+ msgBadge.id = 'walle-msg-badge-' + id;
194
+ msgBadge.title = 'Message count';
195
+ var ws0 = getState(id);
196
+ msgBadge.textContent = '\u26A0 ' + (ws0 ? ws0.messageCount : 0) + ' messages';
197
+ msgNav.appendChild(msgBadge);
198
+ toolbar.appendChild(msgNav);
199
+
200
+ // Review action button (rightmost)
201
+ var toolbarReviewBtn = document.createElement('button');
202
+ toolbarReviewBtn.className = 'session-toolbar-review-action';
203
+ toolbarReviewBtn.title = 'Open review panel for this session';
204
+ toolbarReviewBtn.textContent = 'Review \u2192';
205
+ toolbarReviewBtn.onclick = function() {
206
+ if (typeof openSessionReview === 'function') openSessionReview(id);
207
+ };
208
+ toolbar.appendChild(toolbarReviewBtn);
209
+
131
210
  // Messages area
132
211
  var messagesArea = document.createElement('div');
133
212
  messagesArea.className = 'walle-messages';
@@ -175,6 +254,7 @@ window.WalleSession = (function() {
175
254
  // Assemble — clear and rebuild
176
255
  while (container.firstChild) container.removeChild(container.firstChild);
177
256
  container.appendChild(header);
257
+ container.appendChild(toolbar);
178
258
  container.appendChild(messagesArea);
179
259
  container.appendChild(inputBar);
180
260
  container.appendChild(hints);
@@ -587,6 +667,25 @@ window.WalleSession = (function() {
587
667
  .catch(function() {});
588
668
  }
589
669
 
670
+ // ---------- toolbar model selector ----------
671
+ function populateToolbarModelSelector(select) {
672
+ fetch('/api/models/registry')
673
+ .then(function(r) { return r.json(); })
674
+ .then(function(data) {
675
+ var models = data.models || data || [];
676
+ // Keep the default option, add models after it
677
+ for (var i = 0; i < models.length; i++) {
678
+ var m = models[i];
679
+ if (!m.enabled) continue;
680
+ var opt = document.createElement('option');
681
+ opt.value = m.id;
682
+ opt.textContent = (m.display_name || m.id) + ' (' + (m.provider_name || m.provider_type || '') + ')';
683
+ select.appendChild(opt);
684
+ }
685
+ })
686
+ .catch(function() {});
687
+ }
688
+
590
689
  // ---------- helpers ----------
591
690
  function scrollToBottom(el) {
592
691
  if (el) {
@@ -675,6 +774,12 @@ window.WalleSession = (function() {
675
774
  if (costEl) {
676
775
  costEl.textContent = '$' + ws.totalCost.toFixed(2);
677
776
  }
777
+
778
+ // Update toolbar message count badge
779
+ var msgBadge = document.getElementById('walle-msg-badge-' + id);
780
+ if (msgBadge) {
781
+ msgBadge.textContent = '\u26A0 ' + ws.messageCount + ' messages';
782
+ }
678
783
  }
679
784
 
680
785
  // ---------- public API ----------
@@ -964,6 +964,14 @@ function handleApi(req, res, url) {
964
964
  if (url.pathname === '/api/services/status' && req.method === 'GET') {
965
965
  return apiServicesStatus(req, res);
966
966
  }
967
+ // --- Update check endpoints ---
968
+ if (url.pathname === '/api/updates/check' && req.method === 'GET') {
969
+ return apiUpdatesCheck(req, res);
970
+ }
971
+ if (url.pathname === '/api/updates/apply' && req.method === 'POST') {
972
+ return apiUpdatesApply(req, res);
973
+ }
974
+
967
975
  if (url.pathname === '/api/restart/ctm' && req.method === 'POST') {
968
976
  return apiRestartCtm(req, res);
969
977
  }
@@ -3779,6 +3787,87 @@ try {
3779
3787
  console.log(` Failed to restore sessions: ${e.message}`);
3780
3788
  }
3781
3789
 
3790
+ // --- Update checker ---
3791
+ const _updateState = { latestVersion: null, currentVersion: null, checkedAt: null, updateAvailable: false, error: null };
3792
+
3793
+ function getCurrentVersion() {
3794
+ try {
3795
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
3796
+ return pkg.version;
3797
+ } catch { return null; }
3798
+ }
3799
+
3800
+ // Simple semver comparison: returns 1 if a > b, -1 if a < b, 0 if equal
3801
+ function semverCompare(a, b) {
3802
+ const pa = a.split('.').map(Number);
3803
+ const pb = b.split('.').map(Number);
3804
+ for (let i = 0; i < 3; i++) {
3805
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
3806
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
3807
+ }
3808
+ return 0;
3809
+ }
3810
+
3811
+ async function checkForUpdates() {
3812
+ _updateState.currentVersion = getCurrentVersion();
3813
+ try {
3814
+ const resp = await fetch('https://registry.npmjs.org/create-walle/latest', {
3815
+ headers: { 'Accept': 'application/json' },
3816
+ signal: AbortSignal.timeout(10000),
3817
+ });
3818
+ if (!resp.ok) throw new Error(`npm registry returned ${resp.status}`);
3819
+ const data = await resp.json();
3820
+ _updateState.latestVersion = data.version;
3821
+ _updateState.checkedAt = new Date().toISOString();
3822
+ _updateState.error = null;
3823
+
3824
+ // Only notify if latest is strictly newer (not just different — avoids dev-build false positives)
3825
+ _updateState.updateAvailable = !!(
3826
+ _updateState.latestVersion && _updateState.currentVersion &&
3827
+ semverCompare(_updateState.latestVersion, _updateState.currentVersion) > 0
3828
+ );
3829
+
3830
+ if (_updateState.updateAvailable) {
3831
+ broadcastToAll({
3832
+ type: 'update-available',
3833
+ currentVersion: _updateState.currentVersion,
3834
+ latestVersion: _updateState.latestVersion,
3835
+ });
3836
+ console.log(` Update available: v${_updateState.currentVersion} -> v${_updateState.latestVersion}`);
3837
+ }
3838
+ } catch (e) {
3839
+ _updateState.error = e.message;
3840
+ }
3841
+ return _updateState;
3842
+ }
3843
+
3844
+ function apiUpdatesCheck(req, res) {
3845
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3846
+ res.end(JSON.stringify(_updateState));
3847
+ }
3848
+
3849
+ function apiUpdatesApply(req, res) {
3850
+ const current = _updateState.currentVersion;
3851
+ const latest = _updateState.latestVersion;
3852
+ if (!_updateState.updateAvailable) {
3853
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3854
+ res.end(JSON.stringify({ status: 'up-to-date', version: current }));
3855
+ return;
3856
+ }
3857
+
3858
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3859
+ res.end(JSON.stringify({ status: 'updating', from: current, to: latest }));
3860
+
3861
+ // Run update in background — npx create-walle@latest update handles stop/update/restart
3862
+ const { spawn } = require('child_process');
3863
+ const child = spawn('npx', ['create-walle@latest', 'update'], {
3864
+ cwd: path.join(__dirname, '..'),
3865
+ detached: true,
3866
+ stdio: 'ignore',
3867
+ });
3868
+ child.unref();
3869
+ }
3870
+
3782
3871
  // --- Auto-seed default model providers & registry on startup ---
3783
3872
  function seedDefaultModels() {
3784
3873
  const brain = getWalleBrain();
@@ -3810,7 +3899,7 @@ function seedDefaultModels() {
3810
3899
  }
3811
3900
  }
3812
3901
 
3813
- // Seed Ollama provider (always available locally, detect later)
3902
+ // Seed Ollama provider and auto-discover installed models
3814
3903
  if (!providers.find(p => p.type === 'ollama')) {
3815
3904
  brain.upsertModelProvider({
3816
3905
  id: 'ollama-local',
@@ -3819,6 +3908,52 @@ function seedDefaultModels() {
3819
3908
  enabled: 1,
3820
3909
  });
3821
3910
  }
3911
+ // Auto-scan Ollama models (async, non-blocking)
3912
+ try {
3913
+ const { createOllamaProvider } = require(path.resolve(__dirname, '..', 'wall-e', 'llm', 'ollama'));
3914
+ const ollama = createOllamaProvider({});
3915
+ ollama.listModels().then(async (models) => {
3916
+ for (const m of models) {
3917
+ // Fetch detailed model info (context length, params) from /api/show
3918
+ let maxContextTokens = null;
3919
+ let paramSize = null;
3920
+ try {
3921
+ const showResp = await fetch('http://localhost:11434/api/show', {
3922
+ method: 'POST',
3923
+ headers: { 'Content-Type': 'application/json' },
3924
+ body: JSON.stringify({ name: m.id }),
3925
+ signal: AbortSignal.timeout(3000),
3926
+ });
3927
+ if (showResp.ok) {
3928
+ const showData = await showResp.json();
3929
+ const info = showData.model_info || {};
3930
+ // Context length key varies by model family (e.g. "llama.context_length", "gemma4.context_length")
3931
+ for (const [k, v] of Object.entries(info)) {
3932
+ if (k.endsWith('.context_length')) { maxContextTokens = v; break; }
3933
+ }
3934
+ // Parameter size from details (e.g. "8.0B", "14.7B")
3935
+ if (showData.details && showData.details.parameter_size) {
3936
+ paramSize = showData.details.parameter_size;
3937
+ }
3938
+ }
3939
+ } catch {}
3940
+
3941
+ brain.upsertModelRegistryEntry({
3942
+ id: 'ollama-local:' + m.id,
3943
+ providerId: 'ollama-local',
3944
+ modelId: m.id,
3945
+ displayName: `${m.name || m.id}${paramSize ? ' (' + paramSize + ')' : ''}`,
3946
+ capabilities: m.capabilities || ['code'],
3947
+ costPer1mInput: 0,
3948
+ costPer1mOutput: 0,
3949
+ maxContextTokens: maxContextTokens || null,
3950
+ speedTier: 3,
3951
+ enabled: 1,
3952
+ });
3953
+ }
3954
+ if (models.length) console.log(` Ollama: ${models.length} local model(s) registered`);
3955
+ }).catch(() => {});
3956
+ } catch {}
3822
3957
 
3823
3958
  // Set defaults if none exist
3824
3959
  const defaults = brain.listModelDefaults();
@@ -3876,6 +4011,10 @@ server.listen(PORT, HOST, () => {
3876
4011
  });
3877
4012
  } catch (e) { /* health module not available */ }
3878
4013
 
4014
+ // Check for updates on startup (after 10s delay) and every 24h
4015
+ setTimeout(() => checkForUpdates().catch(() => {}), 10000);
4016
+ setInterval(() => checkForUpdates().catch(() => {}), 24 * 60 * 60 * 1000);
4017
+
3879
4018
  // Wall-E watchdog — auto-restart if it dies unexpectedly.
3880
4019
  // _walleIntentionallyStopped is set by apiStopWalle, cleared by apiStartWalle.
3881
4020
  setInterval(() => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "walle",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "private": true,
5
5
  "description": "Wall-E — your personal digital twin",
6
6
  "scripts": {
@@ -267,6 +267,7 @@ async function main() {
267
267
  channels: channels.map(c => c.name),
268
268
  adapters: adapters.map(a => a.constructor?.name || 'unknown'),
269
269
  });
270
+ telemetry.trackFunnelStep('boot');
270
271
 
271
272
  // Recover tasks that were interrupted by previous shutdown
272
273
  const recovered = recoverInterruptedTasks();
@@ -278,6 +279,7 @@ async function main() {
278
279
  console.log(`[wall-e] Initial ingest: ${ingestResult.memoriesIngested} memories`);
279
280
  if (ingestResult.memoriesIngested > 0) {
280
281
  telemetry.track('ingest', { memories: ingestResult.memoriesIngested, initial: true });
282
+ telemetry.trackFunnelStep('first_ingest');
281
283
  }
282
284
 
283
285
  // Initial think
@@ -315,6 +317,7 @@ async function main() {
315
317
  if (result.knowledgeExtracted > 0) {
316
318
  console.log(`[wall-e] Extracted ${result.knowledgeExtracted} knowledge entries`);
317
319
  }
320
+ telemetry.track('think', { processed: result.memoriesProcessed, knowledge: result.knowledgeExtracted });
318
321
  } catch (err) {
319
322
  console.error('[wall-e] Think error:', err.message);
320
323
  } finally {
@@ -330,6 +333,7 @@ async function main() {
330
333
  if (result.summaryGenerated) {
331
334
  console.log('[wall-e] Daily summary generated');
332
335
  }
336
+ telemetry.track('reflect', { summary: result.summaryGenerated ? 1 : 0 });
333
337
  } catch (err) {
334
338
  console.error('[wall-e] Reflect error:', err.message);
335
339
  } finally {
@@ -347,6 +351,8 @@ async function main() {
347
351
  const result = await runDueSkills();
348
352
  if (result.executed > 0) {
349
353
  console.log(`[wall-e] Skills: ${result.executed} executed, ${result.memoriesCreated} memories`);
354
+ telemetry.track('skill_run', { executed: result.executed, memories: result.memoriesCreated });
355
+ telemetry.trackFunnelStep('first_skill');
350
356
  }
351
357
  } catch (err) {
352
358
  console.error('[wall-e] Skills error:', err.message);
@@ -365,6 +371,7 @@ async function main() {
365
371
  const result = await runDueTasks();
366
372
  if (result.processed > 0) {
367
373
  console.log(`[wall-e] Tasks: ${result.processed} completed`);
374
+ telemetry.track('task_run', { processed: result.processed });
368
375
  }
369
376
  } catch (err) {
370
377
  console.error('[wall-e] Tasks error:', err.message);
@@ -575,6 +575,8 @@ function handleWalleApi(req, res, url) {
575
575
  const result = await chatModule.chat(body.message, {
576
576
  channel: body.channel || 'ctm',
577
577
  session_id: body.session_id || 'default',
578
+ provider: body.provider || undefined,
579
+ model: body.model || undefined,
578
580
  onProgress: sendEvent,
579
581
  });
580
582
  sendEvent({ type: 'done', reply: result.reply });
@@ -584,6 +586,8 @@ function handleWalleApi(req, res, url) {
584
586
  const result = await chatModule.chat(body.message, {
585
587
  channel: body.channel || 'ctm',
586
588
  session_id: body.session_id || 'default',
589
+ provider: body.provider || undefined,
590
+ model: body.model || undefined,
587
591
  });
588
592
  jsonResponse(res, { data: result });
589
593
  }
@@ -77,8 +77,12 @@ async function chat(message, opts = {}) {
77
77
  console.error('[chat] Session expiry check failed:', expErr.message);
78
78
  }
79
79
 
80
- // Use injected provider (for testing) or build one from env
81
- const provider = _clientOverride || getDefaultClient();
80
+ // Use injected provider (for testing), explicit override, or default from env
81
+ let provider = _clientOverride || getDefaultClient();
82
+ if (!_clientOverride && opts.provider && opts.provider !== 'anthropic') {
83
+ const { createClient } = require('./llm/client');
84
+ provider = createClient(opts.provider, opts.providerConfig || {});
85
+ }
82
86
 
83
87
  // Per-turn abort controller — each API call gets its own 2-min timeout.
84
88
  // This prevents long multi-turn tasks (e.g., Morning Briefing) from aborting
@@ -597,6 +601,7 @@ async function chat(message, opts = {}) {
597
601
  let lastTurn = 0;
598
602
  const MAX_TURNS = 8; // search(2-3) + think(1) + response(1) + possible follow-up tools
599
603
 
604
+
600
605
  // Guardrails
601
606
  const MAX_TOOL_CALLS = opts.maxToolCalls != null ? opts.maxToolCalls : 15;
602
607
  let toolCallCount = 0;
@@ -752,7 +757,7 @@ async function chat(message, opts = {}) {
752
757
  summary: resultSummary,
753
758
  fullSizeBytes: Buffer.byteLength(resultStr, 'utf8'),
754
759
  });
755
- allToolCalls.push({ tool: tu.name, args: tu.input, result_summary: resultSummary });
760
+ allToolCalls.push({ tool: tu.name, args: tu.input, result_summary: resultSummary, error: !!result?.error });
756
761
  return { type: 'tool_result', tool_use_id: tu.id, content: compactedResult };
757
762
  }));
758
763
 
@@ -848,7 +853,10 @@ async function chat(message, opts = {}) {
848
853
  model: usedModel, provider: usedProvider,
849
854
  latency_ms: latencyMs, tokens_in: totalInputTokens, tokens_out: totalOutputTokens,
850
855
  cost, tool_count: allToolCalls.length, channel,
856
+ tools: allToolCalls.map(t => t.tool),
857
+ tool_errors: allToolCalls.filter(t => t.error).length,
851
858
  });
859
+ telemetry.trackFunnelStep('first_chat');
852
860
  } catch {}
853
861
 
854
862
  return {
@@ -27,7 +27,7 @@ function createOllamaProvider(config = {}) {
27
27
  }
28
28
 
29
29
  const body = {
30
- model: model || 'llama3',
30
+ model: model || 'gemma4:e4b',
31
31
  messages: openaiMessages,
32
32
  stream: false,
33
33
  };
@@ -41,13 +41,30 @@ function createOllamaProvider(config = {}) {
41
41
  if (temperature != null) body.temperature = temperature;
42
42
 
43
43
  const start = Date.now();
44
- const resp = await fetch(`${baseUrl}/v1/chat/completions`, {
44
+ let resp = await fetch(`${baseUrl}/v1/chat/completions`, {
45
45
  method: 'POST',
46
46
  headers: { 'Content-Type': 'application/json' },
47
47
  body: JSON.stringify(body),
48
48
  signal,
49
49
  });
50
50
 
51
+ // Retry without tools if model doesn't support them
52
+ if (!resp.ok && resp.status === 400 && body.tools) {
53
+ const errText = await resp.text().catch(() => '');
54
+ if (errText.includes('does not support tools')) {
55
+ console.log(`[ollama] ${model} does not support tools — retrying without`);
56
+ delete body.tools;
57
+ resp = await fetch(`${baseUrl}/v1/chat/completions`, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ body: JSON.stringify(body),
61
+ signal,
62
+ });
63
+ } else {
64
+ throw new Error(`Ollama API error ${resp.status}: ${errText}`);
65
+ }
66
+ }
67
+
51
68
  if (!resp.ok) {
52
69
  const text = await resp.text().catch(() => '');
53
70
  throw new Error(`Ollama API error ${resp.status}: ${text}`);
@@ -60,7 +77,7 @@ function createOllamaProvider(config = {}) {
60
77
  if (normalized.stopReason === 'tool_calls') stopReason = 'tool_use';
61
78
  else if (normalized.stopReason === 'stop' || !normalized.stopReason) stopReason = 'end_turn';
62
79
 
63
- return { ...normalized, stopReason, latencyMs: Date.now() - start, model: model || 'llama3', provider: 'ollama', raw };
80
+ return { ...normalized, stopReason, latencyMs: Date.now() - start, model: model || 'gemma4:e4b', provider: 'ollama', raw };
64
81
  },
65
82
 
66
83
  async listModels() {
@@ -87,6 +87,14 @@ function startServer() {
87
87
  console.log(`[wall-e] HTTP server listening on ${HOST}:${PORT}`);
88
88
  });
89
89
 
90
+ server.on('error', (err) => {
91
+ if (err.code === 'EADDRINUSE') {
92
+ console.error(`[wall-e] Port ${PORT} already in use. Try: lsof -ti :${PORT} | xargs kill`);
93
+ } else {
94
+ console.error('[wall-e] Server error:', err.message);
95
+ }
96
+ });
97
+
90
98
  return server;
91
99
  }
92
100
 
@@ -24,7 +24,7 @@ Runs the full fine-tuning pipeline on a weekly schedule (disabled by default).
24
24
  ## Pipeline
25
25
 
26
26
  1. **Export** training data from brain (knowledge, chat, patterns)
27
- 2. **Train** all supported base models (llama3.3-8b, phi-4, qwen2.5-7b)
27
+ 2. **Train** all supported base models (llama3.1-8b, phi-4, qwen2.5-7b, gemma4-4b)
28
28
  3. **Evaluate** trained models against a baseline using benchmark prompts
29
29
  4. **Deploy** the winner to Ollama if it beats the baseline by >60%
30
30
 
@@ -79,6 +79,26 @@ async function flush() {
79
79
  }
80
80
  }
81
81
 
82
+ // --- First-run funnel tracking ---
83
+ const FUNNEL_PATH = path.join(DATA_DIR, '.telemetry-funnel.json');
84
+ const FUNNEL_STEPS = ['install', 'boot', 'first_ingest', 'first_chat', 'first_skill'];
85
+
86
+ function trackFunnelStep(step) {
87
+ if (isDisabled()) return;
88
+ try {
89
+ let funnel = {};
90
+ try { funnel = JSON.parse(fs.readFileSync(FUNNEL_PATH, 'utf8')); } catch {}
91
+ if (funnel[step]) return; // already recorded
92
+ funnel[step] = Date.now();
93
+ fs.writeFileSync(FUNNEL_PATH, JSON.stringify(funnel, null, 2), 'utf8');
94
+
95
+ // Calculate time from install to this step
96
+ const installTime = funnel.install || funnel.boot || Date.now();
97
+ const elapsed = Math.round((Date.now() - installTime) / 1000);
98
+ track('funnel', { step, elapsed_seconds: elapsed, steps_completed: Object.keys(funnel).length });
99
+ } catch {}
100
+ }
101
+
82
102
  // --- Version helper ---
83
103
  let _version = null;
84
104
  function getVersion() {
@@ -99,6 +119,7 @@ function start() {
99
119
  track('startup', {
100
120
  uptime: process.uptime(),
101
121
  });
122
+ trackFunnelStep('install');
102
123
  flushTimer = setInterval(flush, FLUSH_INTERVAL_MS);
103
124
  // Don't keep process alive just for telemetry
104
125
  if (flushTimer.unref) flushTimer.unref();
@@ -127,4 +148,4 @@ function printNoticeIfFirstRun() {
127
148
  } catch {}
128
149
  }
129
150
 
130
- module.exports = { track, flush, start, stop, isDisabled, getInstallId, printNoticeIfFirstRun };
151
+ module.exports = { track, flush, start, stop, isDisabled, getInstallId, printNoticeIfFirstRun, trackFunnelStep };
@@ -66,7 +66,7 @@ describe('trainModel validation', () => {
66
66
  describe('buildTrainCommand', () => {
67
67
  it('constructs correct command with all flags', () => {
68
68
  const args = buildTrainCommand({
69
- baseModel: 'llama3.3-8b',
69
+ baseModel: 'llama3.1-8b',
70
70
  dataPath: '/data/train.jsonl',
71
71
  outputDir: '/models/output',
72
72
  outputName: 'my-model',
@@ -74,7 +74,7 @@ describe('buildTrainCommand', () => {
74
74
  });
75
75
 
76
76
  assert.ok(args.includes(TRAIN_SCRIPT), 'should include train script path');
77
- assert.deepStrictEqual(args.slice(1, 3), ['--base', 'llama3.3-8b']);
77
+ assert.deepStrictEqual(args.slice(1, 3), ['--base', 'llama3.1-8b']);
78
78
  assert.deepStrictEqual(args.slice(3, 5), ['--data', '/data/train.jsonl']);
79
79
  assert.deepStrictEqual(args.slice(5, 7), ['--output', '/models/output']);
80
80
  assert.deepStrictEqual(args.slice(7, 9), ['--name', 'my-model']);
@@ -158,9 +158,9 @@ describe('buildTrainCommand', () => {
158
158
  // ---------------------------------------------------------------------------
159
159
 
160
160
  describe('SUPPORTED_BASES', () => {
161
- it('contains all 3 expected models', () => {
161
+ it('contains all 4 expected models', () => {
162
162
  const keys = Object.keys(SUPPORTED_BASES);
163
- assert.deepStrictEqual(keys.sort(), ['llama3.3-8b', 'phi-4', 'qwen2.5-7b'].sort());
163
+ assert.deepStrictEqual(keys.sort(), ['gemma4-4b', 'llama3.1-8b', 'phi-4', 'qwen2.5-7b'].sort());
164
164
  });
165
165
 
166
166
  it('each model has ollamaName and huggingFace', () => {
@@ -26,6 +26,8 @@ const SHELL_ALLOWLIST = new Set([
26
26
  // Dev tools
27
27
  'git', 'node', 'npm', 'npx', 'python3', 'pip3', 'bun', 'deno',
28
28
  'make', 'cargo', 'go', 'ruby', 'perl',
29
+ // Cloud / infra
30
+ 'fly', 'docker', 'kubectl',
29
31
  // System info
30
32
  'date', 'echo', 'env', 'whoami', 'hostname', 'uname', 'uptime', 'ps', 'top',
31
33
  'id', 'groups', 'printenv', 'locale', 'lsof',
@@ -18,14 +18,15 @@ import sys
18
18
  # ---------------------------------------------------------------------------
19
19
 
20
20
  MODEL_REGISTRY = {
21
- "llama3.3-8b": "unsloth/Llama-3.3-8B-Instruct",
21
+ "llama3.1-8b": "unsloth/Meta-Llama-3.1-8B-Instruct",
22
22
  "phi-4": "unsloth/Phi-4",
23
23
  "qwen2.5-7b": "unsloth/Qwen2.5-7B-Instruct",
24
+ "gemma4-4b": "unsloth/gemma-4-E4B-it",
24
25
  }
25
26
 
26
27
  # LoRA target modules per architecture
27
28
  LORA_TARGETS = {
28
- "llama3.3-8b": [
29
+ "llama3.1-8b": [
29
30
  "q_proj", "k_proj", "v_proj", "o_proj",
30
31
  "gate_proj", "up_proj", "down_proj",
31
32
  ],
@@ -37,6 +38,10 @@ LORA_TARGETS = {
37
38
  "q_proj", "k_proj", "v_proj", "o_proj",
38
39
  "gate_proj", "up_proj", "down_proj",
39
40
  ],
41
+ "gemma4-4b": [
42
+ "q_proj", "k_proj", "v_proj", "o_proj",
43
+ "gate_proj", "up_proj", "down_proj",
44
+ ],
40
45
  }
41
46
 
42
47
 
@@ -9,9 +9,10 @@ const fs = require('node:fs');
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
11
  const SUPPORTED_BASES = {
12
- 'llama3.3-8b': { ollamaName: 'llama3.3:8b-instruct-q4_K_M', huggingFace: 'unsloth/Llama-3.3-8B-Instruct' },
13
- 'phi-4': { ollamaName: 'phi4:latest', huggingFace: 'unsloth/Phi-4' },
14
- 'qwen2.5-7b': { ollamaName: 'qwen2.5:7b-instruct-q4_K_M', huggingFace: 'unsloth/Qwen2.5-7B-Instruct' },
12
+ 'llama3.1-8b': { ollamaName: 'llama3.1:8b-instruct-q4_K_M', huggingFace: 'unsloth/Meta-Llama-3.1-8B-Instruct' },
13
+ 'phi-4': { ollamaName: 'phi4:latest', huggingFace: 'unsloth/Phi-4' },
14
+ 'qwen2.5-7b': { ollamaName: 'qwen2.5:7b-instruct-q4_K_M', huggingFace: 'unsloth/Qwen2.5-7B-Instruct' },
15
+ 'gemma4-4b': { ollamaName: 'gemma4:e4b', huggingFace: 'unsloth/gemma-4-E4B-it' },
15
16
  };
16
17
 
17
18
  const DEFAULT_HYPERPARAMS = {
@@ -60,7 +61,7 @@ function buildTrainCommand(opts) {
60
61
  /**
61
62
  * Run fine-tuning for a base model.
62
63
  * @param {Object} options
63
- * @param {string} options.baseModel - Base model name: 'llama3.3-8b', 'phi-4', 'qwen2.5-7b'
64
+ * @param {string} options.baseModel - Base model name: 'llama3.1-8b', 'phi-4', 'qwen2.5-7b', 'gemma4-4b'
64
65
  * @param {string} options.dataPath - Path to JSONL training data
65
66
  * @param {string} options.outputDir - Where to save the model
66
67
  * @param {string} [options.outputName] - Model name (default: 'walle-{base}-v1')