diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 0a354973..bed2bad5 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -2,6 +2,10 @@
## [Unreleased]
+### Fixed
+
+- Model selector fuzzy search now matches against provider name (not just model ID) and supports space-separated tokens where all tokens must match
+
## [0.21.0] - 2025-12-14
### Added
diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts
index c4b5e0cc..17afdbc7 100644
--- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts
@@ -115,7 +115,7 @@ export class ModelSelectorComponent extends Container {
}
private filterModels(query: string): void {
- this.filteredModels = fuzzyFilter(this.allModels, query, ({ id }) => id);
+ this.filteredModels = fuzzyFilter(this.allModels, query, ({ id, provider }) => `${id} ${provider}`);
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
this.updateList();
}
diff --git a/packages/coding-agent/src/utils/fuzzy.ts b/packages/coding-agent/src/utils/fuzzy.ts
index 837c8bb2..f37c25e1 100644
--- a/packages/coding-agent/src/utils/fuzzy.ts
+++ b/packages/coding-agent/src/utils/fuzzy.ts
@@ -61,23 +61,48 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
}
// Filter and sort items by fuzzy match quality (best matches first)
+// Supports space-separated tokens: all tokens must match, sorted by match count then score
export function fuzzyFilter(items: T[], query: string, getText: (item: T) => string): T[] {
if (!query.trim()) {
return items;
}
- const results: { item: T; score: number }[] = [];
+ // Split query into tokens
+ const tokens = query
+ .trim()
+ .split(/\s+/)
+ .filter((t) => t.length > 0);
+
+ if (tokens.length === 0) {
+ return items;
+ }
+
+ const results: { item: T; totalScore: number }[] = [];
for (const item of items) {
const text = getText(item);
- const match = fuzzyMatch(query, text);
- if (match.matches) {
- results.push({ item, score: match.score });
+ let totalScore = 0;
+ let allMatch = true;
+
+ // Check each token against the text - ALL must match
+ for (const token of tokens) {
+ const match = fuzzyMatch(token, text);
+ if (match.matches) {
+ totalScore += match.score;
+ } else {
+ allMatch = false;
+ break;
+ }
+ }
+
+ // Only include if all tokens match
+ if (allMatch) {
+ results.push({ item, totalScore });
}
}
- // Sort ascending by score (lower = better match)
- results.sort((a, b) => a.score - b.score);
+ // Sort by score (asc, lower is better)
+ results.sort((a, b) => a.totalScore - b.totalScore);
return results.map((r) => r.item);
}
diff --git a/packages/mom/README.md b/packages/mom/README.md
index 4114493e..63d3488e 100644
--- a/packages/mom/README.md
+++ b/packages/mom/README.md
@@ -13,6 +13,13 @@ A Slack bot powered by an LLM that can execute bash commands, read/write files,
- **Working Memory & Custom Tools**: Mom remembers context across sessions and creates workflow-specific CLI tools ([aka "skills"](https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/)) for your tasks
- **Thread-Based Details**: Clean main messages with verbose tool details in threads
+## Documentation
+
+- [Artifacts Server](docs/artifacts-server.md) - Share HTML/JS visualizations publicly with live reload
+- [Events System](docs/events.md) - Schedule reminders and periodic tasks
+- [Sandbox Guide](docs/sandbox.md) - Docker vs host mode security
+- [Slack Bot Setup](docs/slack-bot-minimal-guide.md) - Minimal Slack integration guide
+
## Installation
```bash
diff --git a/packages/mom/docs/artifacts-server.md b/packages/mom/docs/artifacts-server.md
new file mode 100644
index 00000000..3a538b34
--- /dev/null
+++ b/packages/mom/docs/artifacts-server.md
@@ -0,0 +1,475 @@
+# Artifacts Server
+
+Share HTML files, visualizations, and interactive demos publicly via Cloudflare Tunnel with live reload support.
+
+## What is it?
+
+The artifacts server lets Mom create HTML/JS/CSS files that you can instantly view in a browser, with WebSocket-based live reload for development. Perfect for dashboards, visualizations, prototypes, and interactive demos.
+
+## Installation
+
+### 1. Install Dependencies
+
+**Node.js packages:**
+```bash
+cd /workspace/artifacts
+npm init -y
+npm install express ws chokidar
+```
+
+**Cloudflared (Cloudflare Tunnel):**
+```bash
+wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64
+mv cloudflared-linux-amd64 /usr/local/bin/cloudflared
+chmod +x /usr/local/bin/cloudflared
+cloudflared --version
+```
+
+### 2. Create Server
+
+Save this as `/workspace/artifacts/server.js`:
+
+```javascript
+#!/usr/bin/env node
+
+const express = require('express');
+const { WebSocketServer } = require('ws');
+const chokidar = require('chokidar');
+const path = require('path');
+const fs = require('fs');
+const http = require('http');
+
+const PORT = 8080;
+const FILES_DIR = path.join(__dirname, 'files');
+
+// Ensure files directory exists
+if (!fs.existsSync(FILES_DIR)) {
+ fs.mkdirSync(FILES_DIR, { recursive: true });
+}
+
+const app = express();
+const server = http.createServer(app);
+const wss = new WebSocketServer({ server, clientTracking: true });
+
+// Track connected WebSocket clients
+const clients = new Set();
+
+// WebSocket connection handler with error handling
+wss.on('connection', (ws) => {
+ console.log('WebSocket client connected');
+ clients.add(ws);
+
+ ws.on('error', (err) => {
+ console.error('WebSocket client error:', err.message);
+ clients.delete(ws);
+ });
+
+ ws.on('close', () => {
+ console.log('WebSocket client disconnected');
+ clients.delete(ws);
+ });
+});
+
+wss.on('error', (err) => {
+ console.error('WebSocket server error:', err.message);
+});
+
+// Watch for file changes
+const watcher = chokidar.watch(FILES_DIR, {
+ persistent: true,
+ ignoreInitial: true,
+ depth: 99, // Watch all subdirectory levels
+ ignorePermissionErrors: true,
+ awaitWriteFinish: {
+ stabilityThreshold: 100,
+ pollInterval: 50
+ }
+});
+
+watcher.on('all', (event, filepath) => {
+ console.log(`File ${event}: ${filepath}`);
+
+ // If a new directory is created, explicitly watch it
+ // This ensures newly created artifact folders are monitored without restart
+ if (event === 'addDir') {
+ watcher.add(filepath);
+ console.log(`Now watching directory: ${filepath}`);
+ }
+
+ const relativePath = path.relative(FILES_DIR, filepath);
+ const message = JSON.stringify({
+ type: 'reload',
+ file: relativePath
+ });
+
+ clients.forEach(client => {
+ if (client.readyState === 1) {
+ try {
+ client.send(message);
+ } catch (err) {
+ console.error('Error sending to client:', err.message);
+ clients.delete(client);
+ }
+ } else {
+ clients.delete(client);
+ }
+ });
+});
+
+watcher.on('error', (err) => {
+ console.error('File watcher error:', err.message);
+});
+
+// Cache-busting headers
+app.use((req, res, next) => {
+ res.set({
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
+ 'Pragma': 'no-cache',
+ 'Expires': '0',
+ 'Surrogate-Control': 'no-store'
+ });
+ next();
+});
+
+// Inject live reload script for HTML files with ?ws=true
+app.use((req, res, next) => {
+ if (!req.path.endsWith('.html') || req.query.ws !== 'true') {
+ return next();
+ }
+
+ const filePath = path.join(FILES_DIR, req.path);
+
+ // Security: Prevent path traversal attacks
+ const resolvedPath = path.resolve(filePath);
+ const resolvedBase = path.resolve(FILES_DIR);
+ if (!resolvedPath.startsWith(resolvedBase)) {
+ return res.status(403).send('Forbidden: Path traversal detected');
+ }
+
+ fs.readFile(filePath, 'utf8', (err, data) => {
+ if (err) {
+ return next();
+ }
+
+ const liveReloadScript = `
+`;
+
+ if (data.includes('
+ My Dashboard
+
+')) {
+ data = data.replace('', liveReloadScript + '');
+ } else {
+ data = data + liveReloadScript;
+ }
+
+ res.type('html').send(data);
+ });
+});
+
+// Serve static files
+app.use(express.static(FILES_DIR));
+
+// Error handling
+app.use((err, req, res, next) => {
+ console.error('Express error:', err.message);
+ res.status(500).send('Internal server error');
+});
+
+server.on('error', (err) => {
+ if (err.code === 'EADDRINUSE') {
+ console.error(`Port ${PORT} is already in use`);
+ process.exit(1);
+ } else {
+ console.error('Server error:', err.message);
+ }
+});
+
+// Global error handlers
+process.on('uncaughtException', (err) => {
+ console.error('Uncaught exception:', err);
+});
+
+process.on('unhandledRejection', (reason) => {
+ console.error('Unhandled rejection:', reason);
+});
+
+// Graceful shutdown
+process.on('SIGTERM', () => {
+ console.log('SIGTERM received, closing gracefully');
+ watcher.close();
+ server.close(() => process.exit(0));
+});
+
+process.on('SIGINT', () => {
+ console.log('SIGINT received, closing gracefully');
+ watcher.close();
+ server.close(() => process.exit(0));
+});
+
+// Start server
+server.listen(PORT, () => {
+ console.log(`Artifacts server running on http://localhost:${PORT}`);
+ console.log(`Serving files from: ${FILES_DIR}`);
+ console.log(`Add ?ws=true to any URL for live reload`);
+});
+```
+
+Make executable:
+```bash
+chmod +x /workspace/artifacts/server.js
+```
+
+### 3. Create Startup Script
+
+Save this as `/workspace/artifacts/start-server.sh`:
+
+```bash
+#!/bin/sh
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+cd "$SCRIPT_DIR"
+
+echo "Starting artifacts server..."
+
+# Start Node.js server in background
+node server.js > /tmp/server.log 2>&1 &
+NODE_PID=$!
+
+# Wait for server to be ready
+sleep 2
+
+# Start cloudflare tunnel
+echo "Starting Cloudflare Tunnel..."
+cloudflared tunnel --url http://localhost:8080 2>&1 | tee /tmp/cloudflared.log &
+TUNNEL_PID=$!
+
+# Wait for tunnel to establish
+sleep 5
+
+# Extract and display public URL
+PUBLIC_URL=$(grep -o 'https://.*\.trycloudflare\.com' /tmp/cloudflared.log | head -1)
+
+if [ -n "$PUBLIC_URL" ]; then
+ echo ""
+ echo "=========================================="
+ echo "Artifacts server is running!"
+ echo "=========================================="
+ echo "Public URL: $PUBLIC_URL"
+ echo "Files directory: $SCRIPT_DIR/files/"
+ echo ""
+ echo "Add ?ws=true to any URL for live reload"
+ echo "Example: $PUBLIC_URL/test.html?ws=true"
+ echo "=========================================="
+ echo ""
+
+ echo "$PUBLIC_URL" > /tmp/artifacts-url.txt
+else
+ echo "Warning: Could not extract public URL"
+fi
+
+# Keep script running
+cleanup() {
+ echo "Shutting down..."
+ kill $NODE_PID 2>/dev/null || true
+ kill $TUNNEL_PID 2>/dev/null || true
+ exit 0
+}
+
+trap cleanup INT TERM
+wait $NODE_PID $TUNNEL_PID
+```
+
+Make executable:
+```bash
+chmod +x /workspace/artifacts/start-server.sh
+```
+
+## Directory Structure
+
+```
+/workspace/artifacts/
+├── server.js # Node.js server
+├── start-server.sh # Startup script
+├── package.json # Dependencies
+├── node_modules/ # Installed packages
+└── files/ # PUT YOUR ARTIFACTS HERE
+ ├── 2025-12-14-demo/
+ │ ├── index.html
+ │ ├── style.css
+ │ └── logo.png
+ ├── 2025-12-15-chart/
+ │ └── index.html
+ └── test.html (standalone OK)
+```
+
+## Usage
+
+### Starting the Server
+
+```bash
+cd /workspace/artifacts
+./start-server.sh
+```
+
+This will:
+1. Start Node.js server on localhost:8080
+2. Create Cloudflare Tunnel with public URL
+3. Print the URL (e.g., `https://random-words-123.trycloudflare.com`)
+4. Save URL to `/tmp/artifacts-url.txt`
+
+**Note:** URL changes every time you restart (free Cloudflare Tunnel limitation).
+
+### Creating Artifacts
+
+**Folder organization:**
+- Create one subfolder per artifact: `$(date +%Y-%m-%d)-description/`
+- Put main file as `index.html` for clean URLs
+- Include images, CSS, JS, data in same folder
+- CDN resources (Tailwind, Three.js, etc.) work fine
+
+**Example:**
+```bash
+mkdir -p /workspace/artifacts/files/$(date +%Y-%m-%d)-dashboard
+cat > /workspace/artifacts/files/$(date +%Y-%m-%d)-dashboard/index.html << 'EOF'
+
+
+