create-walle 0.8.0 → 0.9.0

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.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Wall-E — your personal digital twin. AI agent that learns from Slack, email & calendar. Includes dashboard, chat, and 7 bundled skills.",
5
5
  "bin": {
6
6
  "create-walle": "bin/create-walle.js"
@@ -1,34 +1,54 @@
1
- # Wall-E
1
+ # Wall-E + CTM
2
2
 
3
- **Your personal digital twin** — an AI agent that learns from your Slack, email, and calendar to build a searchable second brain, answer questions in your voice, and automate daily workflows.
3
+ **Wall-E** is your personal digital twin — an AI agent that learns from your Slack, email, and calendar to build a searchable second brain, answer questions in your voice, and automate daily workflows.
4
4
 
5
- Wall-E runs locally alongside **CTM** (Claude Task Manager), a terminal multiplexer and task dashboard for Claude Code sessions.
5
+ **CTM** (Claude Task Manager) is an agent task manager and terminal multiplexer a web dashboard for running Claude Code sessions, managing prompts, reviewing code, and controlling Wall-E.
6
6
 
7
- ## What It Does
7
+ Together they give you a local-first AI command center: a browser-based workspace where you manage Claude Code sessions, chat with an AI that knows your work context, and automate the repetitive parts of your day.
8
8
 
9
- - **Ingests** your Slack messages, calendar events, and sent emails into a local SQLite brain
10
- - **Learns** your preferences, relationships, communication style, and knowledge over time
11
- - **Answers** questions about your work, meetings, and conversations — with citations
12
- - **Runs skills** on a schedule: morning briefings, Slack sync, calendar sync, and more
13
- - **Manages tasks** and Claude Code sessions through a web dashboard
9
+ ## What You Get
10
+
11
+ ### CTM Agent Task Manager
12
+ - **Terminal multiplexer** run multiple Claude Code sessions side-by-side in browser tabs, with session history, search, and replay
13
+ - **Prompt library** — rich-text editor with versioning, folders, tags, and one-click send to any active session
14
+ - **Prompt queue** — batch multiple prompts and send them sequentially to a session with idle detection
15
+ - **Shadow Approver** — learns your permission patterns and auto-approves safe operations across sessions
16
+ - **Code review** — AI-assisted diff review across projects with inline comments
17
+ - **Session insights** — AI analysis of your Claude Code usage with actionable recommendations
18
+ - **Permission manager** — fine-grained control over what Claude Code can do automatically, with risk tiers and rule consolidation
19
+
20
+ ### Wall-E — Personal AI Agent
21
+ - **Second brain** — ingests Slack messages, calendar events, and sent emails into a local SQLite database with full-text search
22
+ - **Chat** — ask questions about your work, meetings, and conversations — grounded in your actual data with citations
23
+ - **Scheduled skills** — morning briefings, Slack sync, calendar sync, email digest, and custom skills on configurable schedules
24
+ - **Background tasks** — recurring or one-shot tasks with live logs, skill execution, and checkpoint/resume
25
+ - **Knowledge graph** — automatically extracts facts, relationships, and patterns from your data over time
26
+ - **People intelligence** — tracks relationships, communication patterns, trust levels, and personas
27
+ - **MCP integration** — calls tools on Slack, GitHub, and custom MCP servers directly from chat
28
+ - **macOS native** — calendar via EventKit, email via AppleScript, Spotlight file search, desktop notifications, clipboard access
14
29
 
15
30
  ## Architecture
16
31
 
17
32
  ```
18
- CTM (port 3456) Wall-E
33
+ CTM (port 3456) Wall-E Daemon
19
34
  +-----------------------+ +------------------------+
20
35
  | Web Dashboard | | Agent Daemon |
21
36
  | - Sessions (pty) | <---> | - Ingest loop (60s) |
22
37
  | - Prompts editor | | - Think loop (120s) |
23
- | - Task manager | | - Reflect loop (1hr) |
24
- | - Code review | | - Skills loop (5min) |
25
- | - Wall-E chat tab | | - Tasks loop (30s) |
26
- +-----------------------+ +------------------------+
27
- | |
28
- v v
29
- task-manager.db wall-e-brain.db
30
- (sessions, prompts, (memories, knowledge,
31
- tasks, reviews) people, skills, tasks)
38
+ | - Prompt queue | | - Reflect loop (1hr) |
39
+ | - Shadow Approver | | - Skills loop (5min) |
40
+ | - Code review | | - Tasks loop (30s) |
41
+ | - Session insights | | |
42
+ | - Permission manager | | Chat Engine |
43
+ | - Wall-E chat tab | | - Tool use (local+MCP)|
44
+ +-----------------------+ | - Memory search |
45
+ | | - Skill execution |
46
+ v +------------------------+
47
+ task-manager.db |
48
+ (sessions, prompts, v
49
+ rules, reviews) wall-e-brain.db
50
+ (memories, knowledge,
51
+ people, skills, tasks)
32
52
  ```
33
53
 
34
54
  ## Quickstart
@@ -39,7 +59,7 @@ cd my-agent
39
59
  node claude-task-manager/server.js
40
60
  ```
41
61
 
42
- Open **http://localhost:3456** — the browser setup page walks you through adding your API key and connecting integrations. Your name and timezone are auto-detected.
62
+ Open **http://localhost:3456** — the setup page walks you through adding your API key and connecting integrations. Your name and timezone are auto-detected.
43
63
 
44
64
  Or clone manually:
45
65
 
@@ -52,6 +72,66 @@ node claude-task-manager/server.js
52
72
 
53
73
  The first run auto-creates `.env`, `wall-e-config.json`, and `~/.walle/data/`. Finish setup at http://localhost:3456/setup.html.
54
74
 
75
+ ## Dashboard Features
76
+
77
+ ### Sessions
78
+ Terminal multiplexer for Claude Code — create, monitor, and manage multiple sessions from a single browser tab. Features include:
79
+ - Live terminal output via WebSocket (xterm.js)
80
+ - Session search and AI-powered search across all session history
81
+ - AI auto-titling for sessions
82
+ - Project grouping and filtering
83
+ - Copy session command (with working directory)
84
+ - Session replay and review
85
+
86
+ ### Prompts
87
+ Rich-text prompt editor with a full formatting toolbar. Organize prompts into folders, tag them, track usage across sessions, and send to any active Claude Code session with one click.
88
+ - **Composite prompts** — parent prompt with sub-prompts that compose into a single message
89
+ - **Prompt queue** — batch multiple prompts, send sequentially with idle detection between items
90
+ - **Power tools** — chains (multi-step sequences), templates, pattern detection, harvest (extract prompts from session history), and AI copilot suggestions
91
+ - **Usage tracking** — see which sessions used which prompts and how often
92
+
93
+ ### Wall-E Chat
94
+ Chat directly with Wall-E from the dashboard. Wall-E has access to your full brain (memories, knowledge, people) and can:
95
+ - Search your Slack messages, emails, and calendar
96
+ - Run skills on demand (morning briefing, Slack sync, etc.)
97
+ - Call MCP tools (Slack, GitHub, etc.)
98
+ - Create and manage background tasks
99
+ - Execute shell commands, read files, take screenshots
100
+ - Answer questions grounded in your actual data
101
+
102
+ Sub-tabs: Chat, Tasks (background task manager), Skills (view and run), Brain (knowledge graph), Actions, Timeline, Questions, Status.
103
+
104
+ ### Shadow Approver
105
+ Learns your permission patterns from Claude Code sessions and auto-approves safe operations. Features:
106
+ - Pattern learning from your approval history
107
+ - AI-driven approval decisions with confidence scoring
108
+ - Domain-specific trust tiers (Observe, Draft, Guarded, Autonomous)
109
+ - Rule consolidation (merges similar Bash rules)
110
+ - Decision history and audit trail
111
+
112
+ ### Code Review
113
+ AI-assisted code review across projects:
114
+ - Inline diff viewer
115
+ - Multi-project support
116
+ - AI review comments with confidence levels
117
+ - Send review to a Claude Code session for implementation
118
+
119
+ ### Session Insights
120
+ AI analysis of your Claude Code usage patterns:
121
+ - Workflow recommendations (automate repetitive tasks)
122
+ - Prompt effectiveness analysis
123
+ - Skill gap detection
124
+ - Cost-saving suggestions
125
+ - Session statistics
126
+
127
+ ### Permission Manager
128
+ Fine-grained control over what Claude Code can do automatically:
129
+ - Search and filter across all rules
130
+ - Risk-based categorization (LOW, MEDIUM, HIGH)
131
+ - Per-project or global scope
132
+ - Rule consolidation for similar patterns
133
+ - Import/export with Claude Code's settings files
134
+
55
135
  ## Configuration
56
136
 
57
137
  All configuration lives in `.env` (auto-generated on first run). Edit directly or use the browser setup page.
@@ -84,6 +164,7 @@ If you use `devbox ai -c claude`, Wall-E auto-reads your gateway credentials fro
84
164
  | `ANTHROPIC_AUTH_TOKEN` | No | Auth token when using a gateway |
85
165
  | `ANTHROPIC_CUSTOM_HEADERS_B64` | No | Base64-encoded custom headers (Portkey virtual keys, metadata) |
86
166
  | `WALLE_OWNER_NAME` | Auto | Your name (auto-detected from `git config`) |
167
+ | `WALLE_MODEL` | Auto | Model selection (probed on setup: claude-sonnet-4-6, haiku-4-5, etc.) |
87
168
  | `CTM_PORT` | No | Dashboard port (default: `3456`) |
88
169
  | `WALL_E_PORT` | No | Wall-E API port (default: `CTM_PORT + 1`) |
89
170
  | `WALL_E_DATA_DIR` | No | Data directory (default: `~/.walle/data`) |
@@ -105,8 +186,9 @@ Wall-E ships with skills that run on a schedule to keep your brain up to date:
105
186
  | `slack-backfill` | manual | Full Slack history backfill (2022-present) |
106
187
  | `email-sync` | every 30m | Syncs sent emails from macOS Mail via JXA |
107
188
  | `email-digest` | daily 7am | Summarizes recent email activity |
108
- | `morning-briefing` | daily 7am | AI-generated daily briefing from all sources |
189
+ | `morning-briefing` | daily 7am | AI-generated daily briefing: calendar, Slack activity, tasks, questions |
109
190
  | `memory-search` | on-demand | Full-text search across all memories |
191
+ | `file-ingest` | manual | Read a folder of files into the brain with glob filtering |
110
192
 
111
193
  ### Creating Custom Skills
112
194
 
@@ -128,22 +210,15 @@ tags: [sync, data]
128
210
 
129
211
  Place skills in `wall-e/skills/_bundled/your-skill/SKILL.md` or load from a custom directory.
130
212
 
131
- ## Dashboard Features
132
-
133
- ### Sessions Tab
134
- Terminal multiplexer for Claude Code — create, monitor, and manage multiple sessions from a single browser tab.
213
+ ## Integrations
135
214
 
136
- ### Prompts Tab
137
- Rich-text prompt editor with versioning, tagging, folders, and one-click send to any active Claude session. Supports composite prompts (parent + sub-prompts).
138
-
139
- ### Tasks Tab
140
- Task dashboard with recurring task support, skill execution, checkpoint/resume, and live logs.
141
-
142
- ### Wall-E Tab
143
- Chat directly with Wall-E — ask about your schedule, search your memories, run skills, and get AI-powered answers grounded in your actual data.
144
-
145
- ### Code Review Tab
146
- Review code changes across projects with inline diffs and AI-assisted review.
215
+ | Integration | How it works | Setup |
216
+ |---|---|---|
217
+ | **Slack** | OAuth + MCP protocol for search, read, send | Click "Connect" in setup page |
218
+ | **Google Calendar** | macOS EventKit (reads all calendars: Google, iCloud, Outlook) | Automatic on macOS |
219
+ | **Email** | macOS Mail via JXA/AppleScript | Automatic on macOS |
220
+ | **GitHub** | MCP server (if configured in Claude Code) | Via MCP config |
221
+ | **Custom MCP servers** | Any MCP-compatible server | Add to MCP config |
147
222
 
148
223
  ## API
149
224
 
@@ -152,17 +227,19 @@ CTM runs on port 3456. Key endpoints:
152
227
  | Method | Path | Description |
153
228
  |---|---|---|
154
229
  | GET | `/api/services/status` | CTM and Wall-E process status |
230
+ | POST | `/api/restart/ctm` | Restart CTM server |
155
231
  | POST | `/api/restart/walle` | Restart Wall-E daemon |
156
232
  | POST | `/api/start/walle` | Start Wall-E daemon |
157
233
  | POST | `/api/stop/walle` | Stop Wall-E daemon |
158
- | GET | `/api/wall-e/status` | Wall-E brain stats and owner info |
234
+ | GET | `/api/wall-e/status` | Brain stats, loop health, owner info |
159
235
  | GET | `/api/wall-e/memories` | List memories (filterable by source, since, limit) |
160
236
  | GET | `/api/wall-e/knowledge` | List knowledge entries |
161
237
  | GET | `/api/wall-e/people` | List known people |
162
238
  | GET | `/api/wall-e/timeline` | Memory timeline |
163
- | POST | `/api/wall-e/chat` | Chat with Wall-E |
239
+ | GET | `/api/wall-e/tasks` | List background tasks |
240
+ | POST | `/api/wall-e/chat` | Chat with Wall-E (supports SSE streaming) |
164
241
  | GET | `/api/prompts` | List prompts |
165
- | GET | `/api/sessions` | List active terminal sessions |
242
+ | GET | `/api/recent-sessions` | List recent Claude Code sessions |
166
243
 
167
244
  ## Tech Stack
168
245
 
@@ -170,24 +247,27 @@ CTM runs on port 3456. Key endpoints:
170
247
  - **Database**: SQLite via better-sqlite3 (WAL mode, FTS5 full-text search)
171
248
  - **AI**: Claude API via Anthropic SDK (supports Portkey gateway)
172
249
  - **Frontend**: Vanilla HTML/CSS/JS (no build step, no React)
250
+ - **Terminal**: xterm.js (frontend) + node-pty (backend)
173
251
  - **macOS integration**: EventKit (calendar), JXA (email/AppleScript), Spotlight (file search)
174
- - **Terminal**: node-pty for session management
175
252
 
176
253
  ## Development
177
254
 
178
255
  ```bash
256
+ # Start dev instance (doesn't affect primary on :3456)
257
+ bash bin/dev.sh
258
+
179
259
  # Run Wall-E tests
180
260
  cd wall-e && npm test
181
261
 
182
- # Run a single test file
183
- node --test tests/brain.test.js
184
-
185
262
  # Run Slack backfill manually
186
263
  node scripts/slack-backfill.js 2024-01 # single month
187
264
  node scripts/slack-backfill.js incremental # new messages only
188
265
 
189
266
  # Run calendar sync manually
190
267
  node skills/_bundled/google-calendar/run.js
268
+
269
+ # Run morning briefing manually
270
+ node skills/_bundled/morning-briefing/run.js
191
271
  ```
192
272
 
193
273
  ## License
@@ -1115,9 +1115,9 @@ function listSessionConversations({ search, limit, offset, hostname, allDevices
1115
1115
  params.push(hostname);
1116
1116
  }
1117
1117
  if (search) {
1118
- sql += ' AND (title LIKE ? OR first_message LIKE ? OR project_path LIKE ?)';
1118
+ sql += ' AND (title LIKE ? OR first_message LIKE ? OR project_path LIKE ? OR messages LIKE ?)';
1119
1119
  const q = `%${search}%`;
1120
- params.push(q, q, q);
1120
+ params.push(q, q, q, q);
1121
1121
  }
1122
1122
  sql += ' ORDER BY imported_at DESC';
1123
1123
  if (limit) { sql += ' LIMIT ?'; params.push(limit); }
@@ -2396,6 +2396,7 @@
2396
2396
  <button class="walle-subnav-btn active" data-view="chat" onclick="WE.showView('chat')">Chat</button>
2397
2397
  <button class="walle-subnav-btn" data-view="tasks" onclick="WE.showView('tasks')">Tasks</button>
2398
2398
  <button class="walle-subnav-btn" data-view="skills" onclick="WE.showView('skills')">Skills</button>
2399
+ <button class="walle-subnav-btn" data-view="mcp" onclick="WE.showView('mcp')">MCP</button>
2399
2400
  <div style="position:relative;display:inline-block;" id="we-more-wrap">
2400
2401
  <button class="walle-subnav-btn" onclick="WE.toggleMoreTabs()" id="we-more-btn">More <span style="font-size:10px;">&#9662;</span></button>
2401
2402
  <div id="we-more-dropdown" style="display:none;position:absolute;top:100%;left:0;z-index:999;background:var(--bg-light);border:1px solid var(--border);border-radius:6px;padding:4px 0;min-width:120px;box-shadow:0 4px 12px rgba(0,0,0,0.3);">
@@ -4552,6 +4553,7 @@ cwdInput.addEventListener('focus', () => {
4552
4553
 
4553
4554
  // --- Recent Sessions ---
4554
4555
  let allRecentSessions = [];
4556
+ let _convSearchPending = false;
4555
4557
  let currentFilter = 'all';
4556
4558
  let aiSearchMode = false;
4557
4559
  let aiSearchDebounce = null;
@@ -4780,13 +4782,36 @@ function renderFilteredSessions() {
4780
4782
  const q = document.getElementById('recent-search').value.toLowerCase();
4781
4783
  let sessions = getFilteredSessions();
4782
4784
  if (q && !aiSearchMode) {
4783
- sessions = sessions.filter(s =>
4784
- (s.firstMessage || '').toLowerCase().includes(q) ||
4785
- (s.aiTitle || '').toLowerCase().includes(q) ||
4786
- s.project.toLowerCase().includes(q) ||
4787
- s.sessionId.toLowerCase().includes(q) ||
4788
- (s.gitBranch || '').toLowerCase().includes(q)
4789
- );
4785
+ // First filter local metadata
4786
+ const metaMatches = new Set();
4787
+ sessions = sessions.filter(s => {
4788
+ const match = (s.firstMessage || '').toLowerCase().includes(q) ||
4789
+ (s.aiTitle || '').toLowerCase().includes(q) ||
4790
+ s.project.toLowerCase().includes(q) ||
4791
+ s.sessionId.toLowerCase().includes(q) ||
4792
+ (s.gitBranch || '').toLowerCase().includes(q);
4793
+ if (match) metaMatches.add(s.sessionId);
4794
+ return match;
4795
+ });
4796
+ // Also search imported conversations in DB (async, will re-render when results arrive)
4797
+ if (sessions.length === 0 && q.length >= 3 && !_convSearchPending) {
4798
+ _convSearchPending = true;
4799
+ fetch('/api/conversations?search=' + encodeURIComponent(q) + '&limit=20&all_devices=1&token=' + (state.token || ''))
4800
+ .then(r => r.json())
4801
+ .then(data => {
4802
+ _convSearchPending = false;
4803
+ const convResults = (data.backups ? [] : (Array.isArray(data) ? data : []));
4804
+ if (convResults.length === 0) return;
4805
+ // Merge conversation matches into session list
4806
+ for (const c of convResults) {
4807
+ if (metaMatches.has(c.session_id)) continue;
4808
+ const existing = allRecentSessions.find(s => s.sessionId === c.session_id);
4809
+ if (existing && !sessions.includes(existing)) sessions.push(existing);
4810
+ }
4811
+ if (sessions.length > 0) renderRecentSessions(sessions);
4812
+ })
4813
+ .catch(() => { _convSearchPending = false; });
4814
+ }
4790
4815
  }
4791
4816
 
4792
4817
  // Sort: pinned first (in user-defined order), then by modifiedAt
@@ -8059,6 +8084,15 @@ function removeQpItem(idx) {
8059
8084
  renderQpItems();
8060
8085
  }
8061
8086
 
8087
+ function resendQpItem(idx) {
8088
+ if (idx >= 0 && idx < qpItems.length) {
8089
+ qpItems[idx].status = 'pending';
8090
+ saveQpDraft();
8091
+ renderQpItems();
8092
+ toast('Item reset to pending — will be sent next', { type: 'info' });
8093
+ }
8094
+ }
8095
+
8062
8096
  function toggleQpMode() {
8063
8097
  qpMode = qpMode === 'auto' ? 'manual' : 'auto';
8064
8098
  updateQpModeBtn();
@@ -8382,6 +8416,7 @@ function renderQpItems() {
8382
8416
  </div>
8383
8417
  <div style="display:flex;align-items:center;gap:2px;padding-top:2px;flex-shrink:0;">
8384
8418
  <span style="font-size:9px;color:var(--fg-dim);background:var(--bg-lighter);padding:1px 5px;border-radius:8px;">${item.type === 'prompt' ? '#' + item.promptId : 'inline'}</span>
8419
+ ${isSent && !isEditing ? `<button onclick="resendQpItem(${idx})" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;font-size:10px;padding:0 2px;line-height:1;" onmouseover="this.style.color='var(--accent)'" onmouseout="this.style.color='var(--fg-dim)'" title="Re-send this item">&#8635;</button>` : ''}
8385
8420
  ${!isEditing && item.type === 'inline' ? `<button onclick="saveQpItemAsPrompt(${idx})" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;font-size:10px;padding:0 2px;line-height:1;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--fg-dim)'" title="Save as prompt">💾</button>` : ''}
8386
8421
  ${!isEditing ? `<button onclick="startQpEdit(${idx})" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;font-size:11px;padding:0 2px;line-height:1;" onmouseover="this.style.color='var(--accent)'" onmouseout="this.style.color='var(--fg-dim)'" title="Edit">&#9998;</button>` : ''}
8387
8422
  <button onclick="removeQpItem(${idx})" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;font-size:14px;padding:0 2px;line-height:1;" onmouseover="this.style.color='var(--red)'" onmouseout="this.style.color='var(--fg-dim)'">&times;</button>
@@ -26,10 +26,15 @@ function resolveWalleBase() {
26
26
  // Probe on load
27
27
  resolveWalleBase();
28
28
 
29
- function api(path) {
29
+ function api(path, opts) {
30
30
  var token = window._ctmState?.token || '';
31
31
  var sep = path.includes('?') ? '&' : '?';
32
- return fetch(WALLE_BASE + '/api/wall-e' + path + sep + 'token=' + token).then(function(r) { return r.json(); });
32
+ var url = WALLE_BASE + '/api/wall-e' + path + sep + 'token=' + token;
33
+ var fetchOpts = opts || {};
34
+ return fetch(url, fetchOpts).then(function(r) {
35
+ if (!r.ok) return r.json().then(function(d) { throw Object.assign(new Error(d.error || r.statusText), d); });
36
+ return r.json();
37
+ });
33
38
  }
34
39
 
35
40
  function apiPost(path, body) {
@@ -228,6 +233,7 @@ WE.showView = function(view) {
228
233
  else if (view === 'actions') WE.renderActions();
229
234
  else if (view === 'tasks') WE.renderTasks();
230
235
  else if (view === 'skills') WE.renderSkills();
236
+ else if (view === 'mcp') WE.renderMcp();
231
237
  else if (view === 'timeline') WE.renderTimeline();
232
238
  else if (view === 'questions') WE.renderQuestions();
233
239
  else if (view === 'status') WE.renderStatus();
@@ -459,6 +465,151 @@ WE._filterBrain = function() {
459
465
  };
460
466
 
461
467
  // ---- Timeline View ----
468
+ // ---- MCP Servers tab ----
469
+ WE.renderMcp = function() {
470
+ var body = document.getElementById('walle-body');
471
+ safeSetHtml(body, '<div style="padding:16px;color:var(--fg-dim)">Loading MCP servers...</div>');
472
+ api('/mcp/servers').then(function(data) {
473
+ var servers = data.data || [];
474
+ var meta = data.meta || {};
475
+ var html = '';
476
+ // Header
477
+ html += '<div style="padding:12px 16px;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border,#333)">';
478
+ html += '<h3 style="margin:0;font-size:14px;flex:1">MCP Servers</h3>';
479
+ var connected = servers.filter(function(s) { return s.status === 'connected' || s.status === 'cached'; }).length;
480
+ html += '<span style="font-size:11px;color:var(--fg-dim)">' + connected + '/' + servers.length + ' connected</span>';
481
+ if (meta.last_scan) html += '<span style="font-size:10px;color:var(--fg-dim)">Last scan: ' + esc(new Date(meta.last_scan).toLocaleString()) + '</span>';
482
+ html += '<button class="walle-btn" onclick="WE._scanMcp()">Scan All</button>';
483
+ html += '</div>';
484
+
485
+ if (servers.length === 0) {
486
+ html += '<div style="padding:24px;text-align:center;color:var(--fg-dim)">No MCP servers found.<br><span style="font-size:11px">Configure MCP servers in Claude Code (Settings > MCP Servers) and they will appear here.</span></div>';
487
+ } else {
488
+ for (var i = 0; i < servers.length; i++) {
489
+ var s = servers[i];
490
+ // Status colors
491
+ var dotColor = '#666'; // disconnected
492
+ var statusLabel = 'disconnected';
493
+ var statusColor = '#666';
494
+ if (s.status === 'connected') { dotColor = '#5c940d'; statusLabel = 'connected'; statusColor = '#5c940d'; }
495
+ else if (s.status === 'cached') { dotColor = '#228be6'; statusLabel = 'cached'; statusColor = '#228be6'; }
496
+ else if (s.status === 'authenticated') { dotColor = '#fab005'; statusLabel = 'authenticated'; statusColor = '#fab005'; }
497
+
498
+ html += '<div style="border-bottom:1px solid var(--border,#333);padding:10px 16px;" id="mcp-server-' + esc(s.name) + '">';
499
+ // Row 1: name + status + actions
500
+ html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">';
501
+ html += '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dotColor + ';flex-shrink:0"></span>';
502
+ html += '<strong style="font-size:13px;">' + esc(s.name) + '</strong>';
503
+ html += '<span style="font-size:10px;color:var(--fg-dim);background:var(--bg-lighter,#2a2a3e);padding:1px 6px;border-radius:8px;">' + esc(s.type) + '</span>';
504
+ html += '<span style="font-size:10px;color:' + statusColor + '">' + statusLabel + '</span>';
505
+ html += '<span style="flex:1"></span>';
506
+ // Tool count
507
+ html += '<span style="font-size:10px;color:var(--fg-dim)">' + s.tool_count + ' tools</span>';
508
+ // Connect/Test button
509
+ if (s.status === 'connected' || s.status === 'cached') {
510
+ html += '<button class="walle-btn" style="font-size:10px;padding:2px 8px;" onclick="WE._connectMcp(\'' + esc(s.name) + '\',this)">Refresh</button>';
511
+ } else {
512
+ html += '<button class="walle-btn primary" style="font-size:10px;padding:2px 8px;" onclick="WE._connectMcp(\'' + esc(s.name) + '\',this)">Connect</button>';
513
+ }
514
+ html += '</div>';
515
+ // Row 2: URL
516
+ if (s.url) html += '<div style="font-size:10px;color:var(--fg-dim);margin-left:16px;margin-bottom:4px;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + esc(String(s.url)) + '</div>';
517
+ // Row 3: tools (collapsible if many)
518
+ if (s.tools.length > 0) {
519
+ var showAll = s.tools.length <= 10;
520
+ html += '<div style="margin-left:16px;display:flex;flex-wrap:wrap;gap:4px;">';
521
+ var displayTools = showAll ? s.tools : s.tools.slice(0, 8);
522
+ for (var j = 0; j < displayTools.length; j++) {
523
+ var t = displayTools[j];
524
+ html += '<span title="' + esc(t.description || '') + '" style="font-size:10px;background:var(--bg-lighter,#2a2a3e);padding:2px 6px;border-radius:4px;cursor:default;border:1px solid transparent;" onmouseover="this.style.borderColor=\'var(--accent)\'" onmouseout="this.style.borderColor=\'transparent\'">' + esc(t.name) + '</span>';
525
+ }
526
+ if (!showAll) {
527
+ html += '<span style="font-size:10px;color:var(--fg-dim);padding:2px 6px;cursor:pointer;" onclick="this.parentElement.style.display=\'none\';this.parentElement.nextElementSibling.style.display=\'flex\'">+' + (s.tools.length - 8) + ' more</span>';
528
+ }
529
+ html += '</div>';
530
+ if (!showAll) {
531
+ // Hidden expanded list
532
+ html += '<div style="margin-left:16px;display:none;flex-wrap:wrap;gap:4px;">';
533
+ for (var k = 0; k < s.tools.length; k++) {
534
+ var t2 = s.tools[k];
535
+ html += '<span title="' + esc(t2.description || '') + '" style="font-size:10px;background:var(--bg-lighter,#2a2a3e);padding:2px 6px;border-radius:4px;cursor:default;border:1px solid transparent;" onmouseover="this.style.borderColor=\'var(--accent)\'" onmouseout="this.style.borderColor=\'transparent\'">' + esc(t2.name) + '</span>';
536
+ }
537
+ html += '</div>';
538
+ }
539
+ }
540
+ html += '</div>';
541
+ }
542
+ }
543
+ safeSetHtml(body, html);
544
+ }).catch(function(e) {
545
+ safeSetHtml(body, '<div style="padding:16px;color:var(--red)">Failed to load MCP servers: ' + esc(e.message) + '</div>');
546
+ });
547
+ };
548
+
549
+ WE._connectMcp = function(serverName, btnEl) {
550
+ if (btnEl) {
551
+ btnEl.disabled = true;
552
+ btnEl.textContent = 'Connecting...';
553
+ }
554
+ api('/mcp/connect/' + encodeURIComponent(serverName), { method: 'POST' }).then(function(data) {
555
+ if (typeof window.toast === 'function') window.toast(serverName + ' connected — ' + data.tools + ' tools', { type: 'success' });
556
+ setTimeout(function() { WE.renderMcp(); }, 300);
557
+ }).catch(function(e) {
558
+ var msg = e.message || '';
559
+ if (msg.includes('401') || msg.includes('Unauthorized') || msg.includes('auth')) {
560
+ // Show Authenticate button instead of dead-end error
561
+ if (btnEl) {
562
+ btnEl.textContent = 'Authenticate';
563
+ btnEl.style.color = '#fab005';
564
+ btnEl.disabled = false;
565
+ btnEl.onclick = function() { WE._authMcp(serverName, btnEl); };
566
+ }
567
+ if (typeof window.toast === 'function') window.toast(serverName + ' needs authentication — click Authenticate to log in.', { type: 'warning' });
568
+ } else {
569
+ if (btnEl) { btnEl.textContent = 'Retry'; btnEl.disabled = false; }
570
+ if (typeof window.toast === 'function') window.toast(serverName + ': ' + msg.slice(0, 100), { type: 'error' });
571
+ }
572
+ });
573
+ };
574
+
575
+ WE._authMcp = function(serverName, btnEl) {
576
+ if (btnEl) {
577
+ btnEl.disabled = true;
578
+ btnEl.textContent = 'Opening browser...';
579
+ }
580
+ api('/mcp/auth/' + encodeURIComponent(serverName), { method: 'POST' }).then(function() {
581
+ if (typeof window.toast === 'function') window.toast('Check your browser — log in to ' + serverName, { type: 'info' });
582
+ if (btnEl) { btnEl.textContent = 'Waiting for login...'; }
583
+ // Poll for connection success
584
+ var attempts = 0;
585
+ var poll = setInterval(function() {
586
+ attempts++;
587
+ api('/mcp/connect/' + encodeURIComponent(serverName), { method: 'POST' }).then(function(data) {
588
+ clearInterval(poll);
589
+ if (typeof window.toast === 'function') window.toast(serverName + ' connected — ' + data.tools + ' tools!', { type: 'success' });
590
+ setTimeout(function() { WE.renderMcp(); }, 300);
591
+ }).catch(function() {
592
+ if (attempts > 30) {
593
+ clearInterval(poll);
594
+ if (btnEl) { btnEl.textContent = 'Timed out'; btnEl.disabled = false; }
595
+ }
596
+ });
597
+ }, 3000);
598
+ }).catch(function(e) {
599
+ if (btnEl) { btnEl.textContent = 'Auth failed'; btnEl.disabled = false; }
600
+ if (typeof window.toast === 'function') window.toast('Auth failed: ' + (e.message || '').slice(0, 100), { type: 'error' });
601
+ });
602
+ };
603
+
604
+ WE._scanMcp = function() {
605
+ api('/mcp/scan', { method: 'POST' }).then(function() {
606
+ if (typeof window.toast === 'function') window.toast('Scanning all MCP servers...', { type: 'info' });
607
+ setTimeout(function() { WE.renderMcp(); }, 8000);
608
+ }).catch(function(e) {
609
+ if (typeof window.toast === 'function') window.toast('Scan failed: ' + e.message, { type: 'error' });
610
+ });
611
+ };
612
+
462
613
  WE.renderTimeline = function() {
463
614
  timelineOffset = 0;
464
615
  api('/timeline?limit=100').then(function(data) {
@@ -793,6 +944,7 @@ function renderChatUI() {
793
944
  html += '<div class="we-export-bar">';
794
945
  html += '<span class="we-export-count">' + chatSelected.size + ' selected</span>';
795
946
  html += '<button class="walle-btn" onclick="WE._exportAsText()">Copy as Text</button>';
947
+ html += '<button class="walle-btn" onclick="WE._copyAsImage()">Copy as Image</button>';
796
948
  html += '<button class="walle-btn" onclick="WE._exportAsImage()">Save as Image</button>';
797
949
  html += '<button class="walle-btn danger" onclick="WE._deleteSelected()">Delete</button>';
798
950
  html += '<button class="we-chat-search-clear" onclick="WE._clearSelection()" title="Clear selection">&times;</button>';
@@ -1420,6 +1572,11 @@ WE._toggleTurn = function(turnIdx) {
1420
1572
  copyBtn.textContent = 'Copy as Text';
1421
1573
  copyBtn.onclick = function() { WE._exportAsText(); };
1422
1574
  newBar.appendChild(copyBtn);
1575
+ var clipImgBtn = document.createElement('button');
1576
+ clipImgBtn.className = 'walle-btn';
1577
+ clipImgBtn.textContent = 'Copy as Image';
1578
+ clipImgBtn.onclick = function() { WE._copyAsImage(); };
1579
+ newBar.appendChild(clipImgBtn);
1423
1580
  var imgBtn = document.createElement('button');
1424
1581
  imgBtn.className = 'walle-btn';
1425
1582
  imgBtn.textContent = 'Save as Image';
@@ -1492,11 +1649,29 @@ WE._exportAsText = function() {
1492
1649
  });
1493
1650
  };
1494
1651
 
1495
- WE._exportAsImage = function() {
1652
+ WE._copyAsImage = function() {
1496
1653
  var turns = _getSelectedTurns();
1497
1654
  if (turns.length === 0) return;
1655
+ var container = WE._buildExportContainer(turns);
1656
+ document.body.appendChild(container);
1657
+ if (typeof html2canvas !== 'undefined') {
1658
+ html2canvas(container, { backgroundColor: '#1a1a2e', scale: 2 }).then(function(canvas) {
1659
+ document.body.removeChild(container);
1660
+ canvas.toBlob(function(blob) {
1661
+ navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(function() {
1662
+ if (typeof window.toast === 'function') window.toast('Image copied to clipboard', { type: 'success' });
1663
+ }).catch(function(e) {
1664
+ if (typeof window.toast === 'function') window.toast('Copy failed: ' + e.message, { type: 'error' });
1665
+ });
1666
+ }, 'image/png');
1667
+ });
1668
+ } else {
1669
+ document.body.removeChild(container);
1670
+ if (typeof window.toast === 'function') window.toast('html2canvas not loaded', { type: 'error' });
1671
+ }
1672
+ };
1498
1673
 
1499
- // Build styled content for the export
1674
+ WE._buildExportContainer = function(turns) {
1500
1675
  var styles = [
1501
1676
  'body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 24px; max-width: 700px; margin: 0 auto; }',
1502
1677
  '.turn { border-bottom: 1px solid rgba(255,255,255,0.08); padding: 12px 0; }',
@@ -1512,8 +1687,6 @@ WE._exportAsImage = function() {
1512
1687
  'blockquote { border-left: 2px solid #3b82f6; padding-left: 10px; color: #9ca3af; margin: 4px 0; }',
1513
1688
  '.header { font-size: 10px; color: #666; margin-bottom: 16px; }',
1514
1689
  ].join('\n');
1515
-
1516
- // Build turn HTML fragments
1517
1690
  var turnHtml = '';
1518
1691
  turns.forEach(function(t) {
1519
1692
  turnHtml += '<div class="turn">';
@@ -1527,8 +1700,6 @@ WE._exportAsImage = function() {
1527
1700
  }
1528
1701
  turnHtml += '</div>';
1529
1702
  });
1530
-
1531
- // Render into off-screen container for html2canvas
1532
1703
  var container = document.createElement('div');
1533
1704
  container.style.cssText = 'position:fixed;left:-9999px;top:0;width:700px;background:#1a1a2e;color:#e0e0e0;padding:24px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
1534
1705
  var headerEl = document.createElement('div');
@@ -1538,10 +1709,17 @@ WE._exportAsImage = function() {
1538
1709
  var contentEl = document.createElement('div');
1539
1710
  safeSetHtml(contentEl, turnHtml);
1540
1711
  container.appendChild(contentEl);
1541
- // Inject styles
1542
1712
  var styleEl = document.createElement('style');
1543
1713
  styleEl.textContent = styles;
1544
1714
  container.appendChild(styleEl);
1715
+ container._styles = styles; // stash for fallback
1716
+ return container;
1717
+ };
1718
+
1719
+ WE._exportAsImage = function() {
1720
+ var turns = _getSelectedTurns();
1721
+ if (turns.length === 0) return;
1722
+ var container = WE._buildExportContainer(turns);
1545
1723
  document.body.appendChild(container);
1546
1724
 
1547
1725
  if (typeof html2canvas !== 'undefined') {
@@ -1563,7 +1741,7 @@ WE._exportAsImage = function() {
1563
1741
  var w = window.open('', '_blank', 'width=750,height=600');
1564
1742
  var doc = w.document;
1565
1743
  var styleTag = doc.createElement('style');
1566
- styleTag.textContent = styles;
1744
+ styleTag.textContent = container._styles;
1567
1745
  doc.head.appendChild(styleTag);
1568
1746
  var header = doc.createElement('div');
1569
1747
  header.className = 'header';
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "walle",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Wall-E — your personal digital twin",
6
6
  "scripts": {
@@ -239,6 +239,122 @@ function handleWalleApi(req, res, url) {
239
239
  return true;
240
240
  }
241
241
 
242
+ // GET /api/wall-e/mcp/servers — list all MCP servers and their cached tools
243
+ if (p === '/api/wall-e/mcp/servers' && m === 'GET') {
244
+ try {
245
+ const mcpClient = require('./skills/mcp-client');
246
+ const configs = mcpClient.loadMcpConfigs();
247
+
248
+ // Read cached tools from brain DB
249
+ const db = getReadDb();
250
+ let toolsByServer = {};
251
+ let scanMeta = {};
252
+ if (db) {
253
+ try {
254
+ const tools = db.prepare('SELECT * FROM mcp_tools_cache ORDER BY server, tool_name').all();
255
+ for (const t of tools) {
256
+ if (!toolsByServer[t.server]) toolsByServer[t.server] = [];
257
+ toolsByServer[t.server].push({ name: t.tool_name, description: t.description, scanned_at: t.scanned_at });
258
+ }
259
+ } catch {} // table may not exist yet
260
+ try {
261
+ const meta = db.prepare('SELECT * FROM mcp_scan_meta').all();
262
+ for (const row of meta) scanMeta[row.key] = row.value;
263
+ } catch {}
264
+ }
265
+
266
+ // Check which servers are actually connected (have active transport)
267
+ const activeConnections = mcpClient.getActiveConnections ? mcpClient.getActiveConnections() : new Map();
268
+
269
+ const servers = Object.entries(configs).map(([name, cfg]) => {
270
+ const hasToken = !!(cfg.oauth?.accessToken);
271
+ const isConnected = activeConnections.has(name);
272
+ const hasCachedTools = (toolsByServer[name] || []).length > 0;
273
+ // Status: connected (green), authenticated but not tested (yellow), needs auth (gray)
274
+ let status = 'disconnected';
275
+ if (isConnected) status = 'connected';
276
+ else if (hasCachedTools) status = 'cached'; // previously connected, tools cached
277
+ else if (hasToken) status = 'authenticated';
278
+ return {
279
+ name,
280
+ type: cfg.type || (cfg.command ? 'stdio' : 'http'),
281
+ url: cfg.url || cfg.command || '',
282
+ authenticated: hasToken,
283
+ status,
284
+ tools: toolsByServer[name] || [],
285
+ tool_count: (toolsByServer[name] || []).length,
286
+ };
287
+ });
288
+
289
+ jsonResponse(res, { data: servers, meta: scanMeta });
290
+ } catch (e) {
291
+ jsonResponse(res, { error: e.message }, 500);
292
+ }
293
+ return true;
294
+ }
295
+
296
+ // POST /api/wall-e/mcp/scan — trigger MCP scan now
297
+ if (p === '/api/wall-e/mcp/scan' && m === 'POST') {
298
+ try {
299
+ const { execFile } = require('child_process');
300
+ const scriptPath = require('path').resolve(__dirname, 'skills/_bundled/mcp-scan/run.js');
301
+ execFile('node', [scriptPath], { timeout: 60000 }, (err, stdout, stderr) => {
302
+ if (err) console.error('[mcp-scan] Error:', err.message);
303
+ else console.log('[mcp-scan]', stdout.trim());
304
+ });
305
+ jsonResponse(res, { ok: true, message: 'MCP scan started' });
306
+ } catch (e) {
307
+ jsonResponse(res, { error: e.message }, 500);
308
+ }
309
+ return true;
310
+ }
311
+
312
+ // POST /api/wall-e/mcp/connect/:server — test connection to a specific MCP server
313
+ const mcpConnectMatch = p.match(/^\/api\/wall-e\/mcp\/connect\/([^/]+)$/);
314
+ if (mcpConnectMatch && m === 'POST') {
315
+ const serverName = decodeURIComponent(mcpConnectMatch[1]);
316
+ const mcpClient = require('./skills/mcp-client');
317
+ mcpClient.getConnection(serverName).then(function(conn) {
318
+ return conn.listTools();
319
+ }).then(function(tools) {
320
+ // Cache tools in brain DB (need writable DB)
321
+ if (brain && ensureBrainInit()) {
322
+ try {
323
+ const db = brain.getDb();
324
+ db.exec("CREATE TABLE IF NOT EXISTS mcp_tools_cache (server TEXT NOT NULL, tool_name TEXT NOT NULL, description TEXT, input_schema TEXT, scanned_at TEXT DEFAULT (datetime('now')), PRIMARY KEY (server, tool_name))");
325
+ db.prepare('DELETE FROM mcp_tools_cache WHERE server = ?').run(serverName);
326
+ const ins = db.prepare('INSERT INTO mcp_tools_cache (server, tool_name, description, input_schema) VALUES (?, ?, ?, ?)');
327
+ for (const t of tools) ins.run(serverName, t.name, t.description || '', JSON.stringify(t.inputSchema || {}));
328
+ } catch (e) { console.error('[mcp-connect] Cache error:', e.message); }
329
+ }
330
+ jsonResponse(res, { ok: true, server: serverName, tools: tools.length, status: 'connected' });
331
+ }).catch(function(e) {
332
+ const msg = e.message || '';
333
+ const needsAuth = msg.includes('401') || msg.includes('Unauthorized') || msg.includes('auth');
334
+ jsonResponse(res, { error: msg.slice(0, 200), server: serverName, needs_auth: needsAuth }, needsAuth ? 401 : 500);
335
+ });
336
+ return true;
337
+ }
338
+
339
+ // POST /api/wall-e/mcp/auth/:server — start OAuth flow for an MCP server
340
+ const mcpAuthMatch = p.match(/^\/api\/wall-e\/mcp\/auth\/([^/]+)$/);
341
+ if (mcpAuthMatch && m === 'POST') {
342
+ const serverName = decodeURIComponent(mcpAuthMatch[1]);
343
+ try {
344
+ const mcpClient = require('./skills/mcp-client');
345
+ // Fire and forget — OAuth opens browser, callback handles the rest
346
+ mcpClient.authenticateMcpServer(serverName).then(function(token) {
347
+ console.log('[wall-e] MCP OAuth completed for ' + serverName);
348
+ }).catch(function(err) {
349
+ console.error('[wall-e] MCP OAuth failed for ' + serverName + ':', err.message);
350
+ });
351
+ jsonResponse(res, { ok: true, message: 'OAuth flow started — check your browser' });
352
+ } catch (e) {
353
+ jsonResponse(res, { error: e.message }, 500);
354
+ }
355
+ return true;
356
+ }
357
+
242
358
  // GET /api/wall-e/status
243
359
  if (p === '/api/wall-e/status' && m === 'GET') {
244
360
  const result = getStatus();
@@ -48,14 +48,25 @@ async function chat(message, opts = {}) {
48
48
  }
49
49
  } catch {}
50
50
 
51
- // Load available MCP servers so WALL-E knows what it can connect to
51
+ // Load available MCP servers and their cached tools so WALL-E knows what it can do
52
52
  let mcpServerList = '';
53
53
  try {
54
54
  const { loadMcpConfigs } = require('./skills/mcp-client');
55
55
  const configs = loadMcpConfigs();
56
+ // Read cached tools from brain DB
57
+ let toolsByServer = {};
58
+ try {
59
+ const rows = brain.getDb().prepare('SELECT server, tool_name FROM mcp_tools_cache ORDER BY server').all();
60
+ for (const r of rows) {
61
+ if (!toolsByServer[r.server]) toolsByServer[r.server] = [];
62
+ toolsByServer[r.server].push(r.tool_name);
63
+ }
64
+ } catch {} // table may not exist yet
56
65
  mcpServerList = Object.entries(configs).map(([name, cfg]) => {
57
66
  const hasAuth = cfg.oauth?.accessToken ? 'authenticated' : 'needs auth';
58
- return `- ${name}: ${cfg.type || 'stdio'} (${cfg.url || cfg.command || 'local'}) [${hasAuth}]`;
67
+ const tools = toolsByServer[name];
68
+ const toolStr = tools ? ` — tools: ${tools.slice(0, 10).join(', ')}${tools.length > 10 ? ' +' + (tools.length - 10) + ' more' : ''}` : '';
69
+ return `- ${name}: ${cfg.type || 'stdio'} [${hasAuth}]${toolStr}`;
59
70
  }).join('\n');
60
71
  } catch {}
61
72
 
@@ -184,6 +195,7 @@ After gathering evidence, ALWAYS use the **think** tool before responding. This
184
195
  - **search_memories**: Full-text search with BM25 ranking. Use source:"slack" for Slack only.
185
196
  - remember_fact: Store new knowledge the user teaches you.
186
197
  - run_skill, mcp_call, list_mcp_tools: For actions and external services.
198
+ - When mcp_call returns auth_required, tell the user which MCP server needs authentication and suggest they connect it via Claude Code or the MCP tab in the dashboard.
187
199
 
188
200
  ### Local Machine Tools (macOS)
189
201
  - **web_fetch**: Fetch any URL — weather, news, APIs, documentation. Use for ALL real-time data requests.
@@ -484,7 +496,15 @@ When ${ownerName} asks you to DO something (create a file, set a reminder, searc
484
496
  }
485
497
  return result;
486
498
  } catch (err) {
487
- return { error: err.message };
499
+ const msg = err.message || '';
500
+ if (msg.includes('401') || msg.includes('Unauthorized') || msg.includes('auth')) {
501
+ return {
502
+ error: `Authentication failed for ${input.server}. This MCP server requires OAuth tokens that are managed by Claude Code. Ask the user to authenticate ${input.server} through Claude Code first, then try again.`,
503
+ auth_required: true,
504
+ server: input.server,
505
+ };
506
+ }
507
+ return { error: msg };
488
508
  }
489
509
  }
490
510
  if (name === 'list_mcp_tools') {
@@ -47,7 +47,7 @@ module.exports = [
47
47
  description: 'One-time full sync of sent email history from macOS Mail. Run manually to backfill.',
48
48
  type: 'once',
49
49
  skill: 'email-sync',
50
- skill_config: JSON.stringify({ days_back: 90, sync_inbox: true }),
50
+ skill_config: JSON.stringify({ days_back: 3650, sync_inbox: true }),
51
51
  priority: 'normal',
52
52
  },
53
53
  ];
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: mcp-scan
3
+ description: Discover and cache tools from all Claude Code MCP servers
4
+ version: 1.0.0
5
+ execution: script
6
+ entry: run.js
7
+ trigger:
8
+ type: interval
9
+ schedule: "every 1h"
10
+ tags: [mcp, discovery, tools]
11
+ ---
12
+
13
+ Connects to all MCP servers configured in Claude Code and caches their available tools.
14
+ This lets Wall-E know what tools are available across all integrations (Glean, Slack, Rootly, Mezmo, etc.).
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * MCP Scan Skill — discovers tools from all Claude Code MCP servers
6
+ * and caches them in the brain DB for fast lookup.
7
+ *
8
+ * Runs hourly. Results are stored in the `mcp_tools_cache` table
9
+ * and used by the chat system prompt so Wall-E knows what it can do.
10
+ */
11
+
12
+ const path = require('path');
13
+ const brain = require(path.resolve(__dirname, '../../..', 'brain'));
14
+ const mcpClient = require(path.resolve(__dirname, '../..', 'mcp-client'));
15
+
16
+ async function main() {
17
+ brain.initDb();
18
+ const db = brain.getDb();
19
+
20
+ // Ensure cache table exists
21
+ db.exec(`
22
+ CREATE TABLE IF NOT EXISTS mcp_tools_cache (
23
+ server TEXT NOT NULL,
24
+ tool_name TEXT NOT NULL,
25
+ description TEXT,
26
+ input_schema TEXT,
27
+ scanned_at TEXT DEFAULT (datetime('now')),
28
+ PRIMARY KEY (server, tool_name)
29
+ )
30
+ `);
31
+
32
+ const configs = mcpClient.loadMcpConfigs();
33
+ const serverNames = Object.keys(configs);
34
+ console.log('[mcp-scan] Found ' + serverNames.length + ' MCP servers: ' + serverNames.join(', '));
35
+
36
+ let totalTools = 0;
37
+ let successServers = 0;
38
+ const failedServers = [];
39
+
40
+ for (const [name, config] of Object.entries(configs)) {
41
+ try {
42
+ const conn = await mcpClient.getConnection(name);
43
+ const tools = await conn.listTools();
44
+
45
+ // Clear old tools for this server and insert fresh
46
+ db.prepare('DELETE FROM mcp_tools_cache WHERE server = ?').run(name);
47
+ const insert = db.prepare(
48
+ 'INSERT INTO mcp_tools_cache (server, tool_name, description, input_schema) VALUES (?, ?, ?, ?)'
49
+ );
50
+ for (const tool of tools) {
51
+ insert.run(name, tool.name, tool.description || '', JSON.stringify(tool.inputSchema || {}));
52
+ }
53
+
54
+ totalTools += tools.length;
55
+ successServers++;
56
+ console.log(' ' + name + ': ' + tools.length + ' tools');
57
+ } catch (err) {
58
+ failedServers.push(name + ': ' + err.message.slice(0, 80));
59
+ console.log(' ' + name + ': FAILED - ' + err.message.slice(0, 80));
60
+ }
61
+ }
62
+
63
+ // Update scan metadata
64
+ db.exec(`
65
+ CREATE TABLE IF NOT EXISTS mcp_scan_meta (
66
+ key TEXT PRIMARY KEY,
67
+ value TEXT
68
+ )
69
+ `);
70
+ const upsert = db.prepare('INSERT OR REPLACE INTO mcp_scan_meta (key, value) VALUES (?, ?)');
71
+ upsert.run('last_scan', new Date().toISOString());
72
+ upsert.run('server_count', String(serverNames.length));
73
+ upsert.run('tool_count', String(totalTools));
74
+
75
+ console.log('\n[mcp-scan] Done: ' + successServers + '/' + serverNames.length + ' servers, ' + totalTools + ' tools');
76
+ if (failedServers.length > 0) {
77
+ console.log('Failed: ' + failedServers.join(', '));
78
+ }
79
+
80
+ brain.closeDb();
81
+ }
82
+
83
+ main().catch(function(err) {
84
+ console.error('[mcp-scan] Error:', err.message);
85
+ process.exit(1);
86
+ });
@@ -23,6 +23,14 @@ function loadMcpConfigs() {
23
23
  }
24
24
  } catch {}
25
25
 
26
+ // From .claude.json (global MCP servers — Glean, Mezmo, etc.)
27
+ try {
28
+ const claudeJson = JSON.parse(fs.readFileSync(path.join(process.env.HOME, '.claude.json'), 'utf8'));
29
+ for (const [name, cfg] of Object.entries(claudeJson.mcpServers || {})) {
30
+ if (!configs[name]) configs[name] = { ...cfg, name };
31
+ }
32
+ } catch {}
33
+
26
34
  // From mcp.json (stdio servers)
27
35
  try {
28
36
  const mcp = JSON.parse(fs.readFileSync(path.join(CLAUDE_DIR, 'mcp.json'), 'utf8'));
@@ -201,8 +209,8 @@ class HttpTransport {
201
209
  this.nextId = 1;
202
210
  this.initialized = false;
203
211
  this.sessionUrl = null;
204
- // OAuth tokens would be loaded from stored auth
205
- this.authHeaders = {};
212
+ // Start with static headers from config (e.g. Mezmo, Rootly bearer tokens)
213
+ this.authHeaders = config.headers ? { ...config.headers } : {};
206
214
  }
207
215
 
208
216
  async connect() {
@@ -210,18 +218,77 @@ class HttpTransport {
210
218
  if (this.config.oauth) {
211
219
  this._loadOAuthToken();
212
220
  }
221
+ // Also try loading a previously saved token from our own storage
222
+ if (!this.authHeaders['Authorization']) {
223
+ this._loadSavedToken();
224
+ }
213
225
 
214
- // Initialize
215
- const result = await this._send('initialize', {
216
- protocolVersion: '2024-11-05',
217
- capabilities: {},
218
- clientInfo: { name: 'wall-e', version: '0.1.0' },
219
- });
226
+ try {
227
+ // Initialize
228
+ const result = await this._send('initialize', {
229
+ protocolVersion: '2024-11-05',
230
+ capabilities: {},
231
+ clientInfo: { name: 'wall-e', version: '0.1.0' },
232
+ });
220
233
 
221
- // Send initialized notification
222
- await this._sendNotification('notifications/initialized');
223
- this.initialized = true;
224
- return result;
234
+ // Send initialized notification
235
+ await this._sendNotification('notifications/initialized');
236
+ this.initialized = true;
237
+ return result;
238
+ } catch (err) {
239
+ // If 401, try to discover OAuth and re-throw with auth info
240
+ if (err.message && err.message.includes('401')) {
241
+ const oauthMeta = await this._discoverOAuth();
242
+ if (oauthMeta) {
243
+ err.oauthDiscovered = true;
244
+ err.oauthMeta = oauthMeta;
245
+ }
246
+ }
247
+ throw err;
248
+ }
249
+ }
250
+
251
+ _loadSavedToken() {
252
+ try {
253
+ // Check slack-mcp.js token for Slack servers (setup page uses this path)
254
+ if (this.config.url && this.config.url.includes('mcp.slack.com')) {
255
+ const slackTokenPath = path.join(CLAUDE_DIR, 'wall-e-slack-token.json');
256
+ if (fs.existsSync(slackTokenPath)) {
257
+ const data = JSON.parse(fs.readFileSync(slackTokenPath, 'utf8'));
258
+ if (data.access_token) {
259
+ this.authHeaders['Authorization'] = 'Bearer ' + data.access_token;
260
+ return;
261
+ }
262
+ }
263
+ }
264
+ const tokenPath = path.join(CLAUDE_DIR, 'wall-e-mcp-tokens', this.config.name + '.json');
265
+ if (fs.existsSync(tokenPath)) {
266
+ const data = JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
267
+ if (data.access_token && (!data.expires_at || data.expires_at > Date.now())) {
268
+ this.authHeaders['Authorization'] = 'Bearer ' + data.access_token;
269
+ }
270
+ }
271
+ } catch {}
272
+ }
273
+
274
+ async _discoverOAuth() {
275
+ try {
276
+ // Standard MCP OAuth discovery: /.well-known/oauth-authorization-server at the server's origin
277
+ const urlObj = new URL(this.url);
278
+ const discoveryUrl = urlObj.origin + '/.well-known/oauth-authorization-server';
279
+ const res = await fetch(discoveryUrl, { signal: AbortSignal.timeout(5000) });
280
+ if (res.ok) {
281
+ const meta = await res.json();
282
+ return {
283
+ authorization_endpoint: meta.authorization_endpoint,
284
+ token_endpoint: meta.token_endpoint,
285
+ registration_endpoint: meta.registration_endpoint,
286
+ scopes_supported: meta.scopes_supported,
287
+ issuer: meta.issuer,
288
+ };
289
+ }
290
+ } catch {}
291
+ return null;
225
292
  }
226
293
 
227
294
  _loadOAuthToken() {
@@ -343,7 +410,7 @@ async function getConnection(serverName) {
343
410
  if (!config) throw new Error(`MCP server "${serverName}" not found in config`);
344
411
 
345
412
  let transport;
346
- if (config.type === 'http' && config.url) {
413
+ if ((config.type === 'http' || config.type === 'sse') && config.url) {
347
414
  transport = new HttpTransport(config);
348
415
  } else if (config.command) {
349
416
  transport = new StdioTransport(config);
@@ -396,11 +463,172 @@ function disconnectAll() {
396
463
  connections.clear();
397
464
  }
398
465
 
466
+ function getActiveConnections() {
467
+ return connections;
468
+ }
469
+
470
+ /**
471
+ * Run OAuth flow for an MCP server: discover metadata, register client,
472
+ * open browser for auth, handle callback, save token.
473
+ */
474
+ async function authenticateMcpServer(serverName) {
475
+ const configs = loadMcpConfigs();
476
+ const config = configs[serverName];
477
+ if (!config) throw new Error('MCP server "' + serverName + '" not found');
478
+
479
+ // Slack uses its own OAuth flow — delegate to slack-mcp.js
480
+ if (config.url && config.url.includes('mcp.slack.com')) {
481
+ const slackMcp = require('../tools/slack-mcp');
482
+ return slackMcp.authenticate();
483
+ }
484
+
485
+ // Discover OAuth metadata
486
+ const urlObj = new URL(config.url);
487
+ const discoveryUrl = urlObj.origin + '/.well-known/oauth-authorization-server';
488
+ const discRes = await fetch(discoveryUrl, { signal: AbortSignal.timeout(5000) });
489
+ if (!discRes.ok) throw new Error('No OAuth discovery at ' + discoveryUrl);
490
+ const meta = await discRes.json();
491
+
492
+ if (!meta.authorization_endpoint || !meta.token_endpoint) {
493
+ throw new Error('OAuth metadata missing endpoints');
494
+ }
495
+
496
+ // Dynamic client registration (if supported)
497
+ let clientId, clientSecret;
498
+ if (meta.registration_endpoint) {
499
+ const regRes = await fetch(meta.registration_endpoint, {
500
+ method: 'POST',
501
+ headers: { 'Content-Type': 'application/json' },
502
+ body: JSON.stringify({
503
+ client_name: 'Wall-E AI Agent',
504
+ redirect_uris: ['http://localhost:3119/callback'],
505
+ grant_types: ['authorization_code'],
506
+ response_types: ['code'],
507
+ token_endpoint_auth_method: 'none',
508
+ }),
509
+ signal: AbortSignal.timeout(10000),
510
+ });
511
+ if (regRes.ok) {
512
+ const reg = await regRes.json();
513
+ clientId = reg.client_id;
514
+ clientSecret = reg.client_secret;
515
+ }
516
+ }
517
+ // Fallback: use configured clientId if dynamic registration fails
518
+ if (!clientId && config.oauth?.clientId) {
519
+ clientId = config.oauth.clientId;
520
+ }
521
+ if (!clientId) throw new Error('No client_id available (dynamic registration failed and none configured)');
522
+
523
+ // PKCE challenge
524
+ const crypto = require('crypto');
525
+ const codeVerifier = crypto.randomBytes(32).toString('base64url');
526
+ const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
527
+ const state = crypto.randomBytes(16).toString('hex');
528
+
529
+ // Determine scopes
530
+ const scopes = (meta.scopes_supported || []).filter(function(s) {
531
+ return ['mcp', 'openid', 'search', 'people', 'chat', 'offline_access'].includes(s);
532
+ }).join(' ') || 'mcp';
533
+
534
+ const CALLBACK_PORT = 3119;
535
+
536
+ return new Promise(function(resolve, reject) {
537
+ const http = require('http');
538
+ const timeout = setTimeout(function() {
539
+ server.close();
540
+ reject(new Error('OAuth timeout (2 minutes)'));
541
+ }, 120000);
542
+
543
+ const server = http.createServer(async function(req, res) {
544
+ const reqUrl = new URL(req.url, 'http://localhost:' + CALLBACK_PORT);
545
+ if (reqUrl.pathname !== '/callback') { res.writeHead(404); res.end(); return; }
546
+
547
+ const code = reqUrl.searchParams.get('code');
548
+ const retState = reqUrl.searchParams.get('state');
549
+ if (retState !== state) { res.writeHead(400); res.end('Invalid state'); clearTimeout(timeout); server.close(); reject(new Error('OAuth state mismatch')); return; }
550
+
551
+ try {
552
+ // Exchange code for token
553
+ const tokenBody = new URLSearchParams({
554
+ grant_type: 'authorization_code',
555
+ code: code,
556
+ redirect_uri: 'http://localhost:' + CALLBACK_PORT + '/callback',
557
+ client_id: clientId,
558
+ code_verifier: codeVerifier,
559
+ });
560
+ if (clientSecret) tokenBody.set('client_secret', clientSecret);
561
+
562
+ const tokenRes = await fetch(meta.token_endpoint, {
563
+ method: 'POST',
564
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
565
+ body: tokenBody.toString(),
566
+ });
567
+
568
+ if (!tokenRes.ok) {
569
+ const errText = await tokenRes.text();
570
+ throw new Error('Token exchange failed: ' + errText.slice(0, 200));
571
+ }
572
+
573
+ const tokenData = await tokenRes.json();
574
+
575
+ // Save token
576
+ const tokenDir = path.join(CLAUDE_DIR, 'wall-e-mcp-tokens');
577
+ if (!fs.existsSync(tokenDir)) fs.mkdirSync(tokenDir, { recursive: true });
578
+ fs.writeFileSync(path.join(tokenDir, serverName + '.json'), JSON.stringify({
579
+ access_token: tokenData.access_token,
580
+ refresh_token: tokenData.refresh_token,
581
+ expires_at: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : null,
582
+ obtained_at: new Date().toISOString(),
583
+ server: serverName,
584
+ }, null, 2));
585
+
586
+ res.writeHead(200, { 'Content-Type': 'text/html' });
587
+ res.end('<html><body style="font-family:system-ui;text-align:center;padding:40px"><h2>Wall-E connected to ' + serverName + '!</h2><p>You can close this tab.</p></body></html>');
588
+
589
+ clearTimeout(timeout);
590
+ server.close();
591
+ resolve(tokenData.access_token);
592
+ } catch (err) {
593
+ res.writeHead(500);
594
+ res.end('Token exchange failed: ' + err.message);
595
+ clearTimeout(timeout);
596
+ server.close();
597
+ reject(err);
598
+ }
599
+ });
600
+
601
+ server.listen(CALLBACK_PORT, function() {
602
+ const authUrl = meta.authorization_endpoint
603
+ + '?response_type=code'
604
+ + '&client_id=' + encodeURIComponent(clientId)
605
+ + '&redirect_uri=' + encodeURIComponent('http://localhost:' + CALLBACK_PORT + '/callback')
606
+ + '&scope=' + encodeURIComponent(scopes)
607
+ + '&state=' + state
608
+ + '&code_challenge=' + codeChallenge
609
+ + '&code_challenge_method=S256';
610
+
611
+ console.log('[mcp-client] Opening browser for ' + serverName + ' OAuth...');
612
+ const { execFile } = require('child_process');
613
+ execFile('open', [authUrl], function(err) {
614
+ if (err) console.error('[mcp-client] Failed to open browser:', err.message);
615
+ });
616
+ });
617
+
618
+ server.on('error', function(err) {
619
+ clearTimeout(timeout);
620
+ reject(new Error('OAuth server failed: ' + err.message));
621
+ });
622
+ });
623
+ }
624
+
399
625
  module.exports = {
400
626
  loadMcpConfigs,
401
627
  getConnection,
628
+ getActiveConnections,
402
629
  listAllTools,
403
630
  callMcpTool,
631
+ authenticateMcpServer,
404
632
  disconnectAll,
405
633
  StdioTransport,
406
634
  HttpTransport,
@@ -654,6 +654,39 @@ const LOCAL_TOOL_DEFINITIONS = [
654
654
  required: ['url'],
655
655
  },
656
656
  },
657
+ {
658
+ name: 'glean_search',
659
+ description: 'Search company documents, wikis, Google Docs, Confluence, Jira, and other connected sources via Glean. Use when the user asks to "search docs", "find a document", "look up X in our wiki", "search Glean", or needs information from internal company knowledge bases.',
660
+ input_schema: {
661
+ type: 'object',
662
+ properties: {
663
+ query: { type: 'string', description: 'Search query (e.g., "Q4 OKRs", "onboarding guide", "API rate limits")' },
664
+ },
665
+ required: ['query'],
666
+ },
667
+ },
668
+ {
669
+ name: 'glean_people',
670
+ description: 'Search for people in the company directory via Glean. Use when the user asks "who is X", "who works on Y", "find someone in Z team", org chart questions, or needs contact info for a colleague.',
671
+ input_schema: {
672
+ type: 'object',
673
+ properties: {
674
+ query: { type: 'string', description: 'Person name, role, team, or skill to search for' },
675
+ },
676
+ required: ['query'],
677
+ },
678
+ },
679
+ {
680
+ name: 'glean_chat',
681
+ description: 'Ask Glean AI a question that requires reasoning across multiple company documents. Use for complex questions like "what was the decision on X?", "summarize our policy on Y", or "what are the pros/cons of Z according to our docs?".',
682
+ input_schema: {
683
+ type: 'object',
684
+ properties: {
685
+ question: { type: 'string', description: 'Question to ask Glean AI' },
686
+ },
687
+ required: ['question'],
688
+ },
689
+ },
657
690
  {
658
691
  name: 'ingest_folder',
659
692
  description: 'Read all text files in a folder and store them in Wall-E brain as memories. Supports .md, .txt, .json, .py, .js, .csv, and many more text formats. Files are deduplicated by path. Use when the user asks to "read a folder", "ingest files", "import documents", "load my notes", or "scan a directory into brain".',
@@ -692,11 +725,43 @@ async function executeLocalTool(name, input) {
692
725
  case 'mail_send': return sendMail(input);
693
726
  case 'system_info': return getSystemInfo();
694
727
  case 'web_fetch': return webFetch(input.url, input);
728
+ case 'glean_search': return gleanSearch(input);
729
+ case 'glean_people': return gleanPeople(input);
730
+ case 'glean_chat': return gleanChat(input);
695
731
  case 'ingest_folder': return ingestFolder(input);
696
732
  default: return null; // not a local tool
697
733
  }
698
734
  }
699
735
 
736
+ async function _callGleanMcp(toolName, args) {
737
+ try {
738
+ const mcpClient = require('../skills/mcp-client');
739
+ // Ensure Glean MCP is configured
740
+ const configs = mcpClient.loadMcpConfigs();
741
+ const gleanName = Object.keys(configs).find(k => k.includes('glean'));
742
+ if (!gleanName) return 'Glean MCP not configured. Add it to your Claude Code MCP settings.';
743
+ const result = await mcpClient.callMcpTool(gleanName, toolName, args);
744
+ if (result && result.content) {
745
+ return result.content.map(c => c.text || JSON.stringify(c)).join('\n');
746
+ }
747
+ return JSON.stringify(result);
748
+ } catch (e) {
749
+ return 'Glean error: ' + e.message;
750
+ }
751
+ }
752
+
753
+ async function gleanSearch(input) {
754
+ return _callGleanMcp('search', { query: input.query });
755
+ }
756
+
757
+ async function gleanPeople(input) {
758
+ return _callGleanMcp('employee_search', { query: input.query });
759
+ }
760
+
761
+ async function gleanChat(input) {
762
+ return _callGleanMcp('chat', { query: input.question });
763
+ }
764
+
700
765
  async function ingestFolder(input) {
701
766
  const { execFileSync } = require('child_process');
702
767
  const scriptPath = path.join(__dirname, '..', 'skills', '_bundled', 'file-ingest', 'run.js');