create-walle 0.4.6 → 0.6.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/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # create-walle
2
+
3
+ Set up **Wall-E** — your personal digital twin. An AI agent that learns from your Slack, email, and calendar to build a searchable second brain.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npx create-walle install ./my-agent
9
+ ```
10
+
11
+ This copies the project, installs dependencies, auto-detects your name and timezone, and starts the server. Open **http://localhost:3456** to finish setup in the browser.
12
+
13
+ ## Commands
14
+
15
+ ```bash
16
+ npx create-walle install <dir> # Install Wall-E (or update if already installed)
17
+ npx create-walle update # Update to latest version, keep your config
18
+ npx create-walle start # Start as background service (auto-restarts on reboot)
19
+ npx create-walle stop # Stop the service
20
+ npx create-walle status # Check if Wall-E is running
21
+ npx create-walle logs # Tail the service logs
22
+ npx create-walle uninstall # Remove the auto-start service
23
+ npx create-walle -v # Show version
24
+ ```
25
+
26
+ ## Setup
27
+
28
+ On first launch, the browser setup page guides you through:
29
+
30
+ 1. **Owner name** — auto-detected from `git config`
31
+ 2. **API key** — enter manually, or click "Auto-detect" to find it from:
32
+ - Shell environment (`ANTHROPIC_API_KEY`)
33
+ - Claude Code OAuth token (macOS Keychain)
34
+ - Corporate devbox gateway (`~/.devbox/secrets/claude/auth_headers`)
35
+ 3. **Integrations** — connect Slack (OAuth), email and calendar auto-detected on macOS
36
+
37
+ ## API Authentication
38
+
39
+ Wall-E supports three ways to connect to Claude:
40
+
41
+ **Direct API key:**
42
+ ```env
43
+ ANTHROPIC_API_KEY=sk-ant-...
44
+ ```
45
+
46
+ **Portkey / API gateway:**
47
+ ```env
48
+ ANTHROPIC_BASE_URL=https://your-gateway.example.com/v1
49
+ ANTHROPIC_AUTH_TOKEN=your-key
50
+ ANTHROPIC_CUSTOM_HEADERS_B64=<base64-encoded headers>
51
+ ```
52
+
53
+ **Corporate devbox:** Auto-detected — no config needed if you use `devbox ai -c claude`.
54
+
55
+ ## Custom Port
56
+
57
+ ```bash
58
+ CTM_PORT=5000 npx create-walle install ./my-agent
59
+ ```
60
+
61
+ Wall-E API runs on `CTM_PORT + 1` (e.g., 5001).
62
+
63
+ ## What's Included
64
+
65
+ - **CTM Dashboard** — terminal multiplexer, prompt editor, task manager, code review
66
+ - **Wall-E Agent** — ingest/think/reflect loops, 7 bundled skills, chat with tool-use
67
+ - **Brain DB** — SQLite with full-text search across Slack, email, calendar
68
+ - **Multi-device** — share data via Dropbox/iCloud, sessions tagged by hostname
69
+
70
+ ## Links
71
+
72
+ - [Full Documentation](https://walle.sh)
73
+ - [Configuration Reference](https://walle.sh/docs/guides/configuration/)
74
+ - [Skill Catalog](https://walle.sh/docs/skills/)
75
+
76
+ ## License
77
+
78
+ MIT
@@ -151,6 +151,9 @@ function install(targetDir) {
151
151
  fs.mkdirSync(path.join(process.env.HOME, '.walle', 'data'), { recursive: true });
152
152
  try { execFileSync('git', ['init', '-q'], { cwd: targetDir, stdio: 'ignore' }); } catch {}
153
153
 
154
+ // Stamp version into root package.json so the settings page shows it
155
+ stampVersion(targetDir);
156
+
154
157
  saveWalleDir(path.resolve(targetDir));
155
158
 
156
159
  // Start the service
@@ -208,7 +211,10 @@ function update() {
208
211
  fs.writeFileSync(fullPath, content);
209
212
  }
210
213
 
211
- // 5. Reinstall deps (in case package.json changed)
214
+ // 5. Stamp version
215
+ stampVersion(dir);
216
+
217
+ // 6. Reinstall deps (in case package.json changed)
212
218
  console.log(` Installing dependencies...\n`);
213
219
  npmInstall(dir);
214
220
 
@@ -276,19 +282,24 @@ function uninstall() {
276
282
  // ── Service Management ──
277
283
 
278
284
  function stopQuiet(dir, port) {
279
- // Try launchctl first
285
+ // Try launchctl first (macOS)
280
286
  const plist = path.join(process.env.HOME, 'Library', 'LaunchAgents', `${LABEL}.plist`);
281
287
  if (process.platform === 'darwin' && fs.existsSync(plist)) {
282
288
  try { execFileSync('launchctl', ['unload', plist], { stdio: 'ignore' }); } catch {}
283
289
  }
284
- // Also try API
285
- try { execFileSync('curl', ['-sX', 'POST', `http://localhost:${port}/api/restart/ctm`], { timeout: 2000 }); } catch {}
286
- // Wait for port to free up
287
- for (let i = 0; i < 10; i++) {
290
+ // Kill process on the port directly
291
+ try {
292
+ const pids = execFileSync('lsof', ['-ti', ':' + port], { encoding: 'utf8', timeout: 3000 }).trim().split('\n').filter(Boolean);
293
+ for (const pid of pids) { try { process.kill(parseInt(pid), 'SIGTERM'); } catch {} }
294
+ } catch {}
295
+ // Brief wait for port to free
296
+ const deadline = Date.now() + 5000;
297
+ while (Date.now() < deadline) {
288
298
  try {
289
- execFileSync('curl', ['-s', '--max-time', '1', `http://localhost:${port}/api/services/status`], { timeout: 2000 });
290
- // Still running, wait
291
- execFileSync('sleep', ['0.5']);
299
+ execFileSync('curl', ['-s', '--max-time', '1', `http://localhost:${port}/api/services/status`], { encoding: 'utf8', timeout: 2000 });
300
+ // Still running — busy-wait briefly
301
+ const waitUntil = Date.now() + 500;
302
+ while (Date.now() < waitUntil) { /* spin */ }
292
303
  } catch {
293
304
  break; // Port is free
294
305
  }
@@ -392,6 +403,17 @@ function npmInstall(dir) {
392
403
  }
393
404
  }
394
405
 
406
+ function stampVersion(dir) {
407
+ try {
408
+ const ver = require('../package.json').version;
409
+ const pkgPath = path.join(dir, 'package.json');
410
+ let pkg = {};
411
+ try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch {}
412
+ pkg.version = ver;
413
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
414
+ } catch {}
415
+ }
416
+
395
417
  function saveWalleDir(dir) {
396
418
  const metaDir = path.join(process.env.HOME, '.walle');
397
419
  fs.mkdirSync(metaDir, { recursive: true });
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "create-walle",
3
- "version": "0.4.6",
4
- "description": "Set up Wall-E — your personal digital twin",
3
+ "version": "0.6.0",
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"
7
7
  },
8
8
  "files": [
9
9
  "bin/",
10
- "template/"
10
+ "template/",
11
+ "README.md"
11
12
  ],
12
13
  "scripts": {
13
14
  "build": "bash build.sh",
@@ -628,6 +628,13 @@ function handleListConversations(req, res, url) {
628
628
  if (url.searchParams.has('search')) opts.search = url.searchParams.get('search');
629
629
  if (url.searchParams.has('limit')) opts.limit = parseInt(url.searchParams.get('limit'));
630
630
  if (url.searchParams.has('offset')) opts.offset = parseInt(url.searchParams.get('offset'));
631
+ // Filter by current device unless all_devices=1
632
+ if (url.searchParams.get('all_devices') === '1') {
633
+ opts.allDevices = true;
634
+ } else {
635
+ if (!handleListConversations._hostname) handleListConversations._hostname = require('os').hostname();
636
+ opts.hostname = handleListConversations._hostname;
637
+ }
631
638
  jsonResponse(res, 200, db.listSessionConversations(opts));
632
639
  }
633
640
 
@@ -781,7 +788,11 @@ async function handleScreenshot(req, res) {
781
788
 
782
789
  // --- Backups ---
783
790
  function handleListBackups(req, res) {
784
- jsonResponse(res, 200, db.listBackups());
791
+ const backups = db.listBackups();
792
+ const dbPath = db.getDbPath();
793
+ let dbSize = 0;
794
+ try { dbSize = require('fs').statSync(dbPath).size; } catch {}
795
+ jsonResponse(res, 200, { backups, db_path: dbPath, backup_dir: require('path').join(require('path').dirname(dbPath), 'backups'), db_size: dbSize });
785
796
  }
786
797
 
787
798
  async function handleCreateBackup(req, res) {
@@ -588,6 +588,12 @@ function runMigrations() {
588
588
  if (!hsCols.find(c => c.name === 'last_lifecycle_refresh_at')) {
589
589
  getDb().exec("ALTER TABLE harvest_state ADD COLUMN last_lifecycle_refresh_at TEXT");
590
590
  }
591
+
592
+ // Migration: add hostname to session_conversations for multi-device support
593
+ const scCols2 = getDb().prepare("PRAGMA table_info(session_conversations)").all();
594
+ if (!scCols2.find(c => c.name === 'hostname')) {
595
+ getDb().prepare("ALTER TABLE session_conversations ADD COLUMN hostname TEXT DEFAULT ''").run();
596
+ }
591
597
  }
592
598
 
593
599
  // --- Settings CRUD ---
@@ -1083,25 +1089,31 @@ function setAlwaysAsk(toolName, alwaysAsk) {
1083
1089
  }
1084
1090
 
1085
1091
  // --- Session Conversations ---
1086
- function importSessionConversation({ session_id, project_path, messages, user_msg_count, assistant_msg_count, title, first_message, git_branch, file_size, session_created_at }) {
1092
+ function importSessionConversation({ session_id, project_path, messages, user_msg_count, assistant_msg_count, title, first_message, git_branch, file_size, session_created_at, hostname }) {
1087
1093
  getDb().prepare(
1088
- `INSERT INTO session_conversations (session_id, project_path, messages, user_msg_count, assistant_msg_count, title, first_message, git_branch, file_size, session_created_at)
1089
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1094
+ `INSERT INTO session_conversations (session_id, project_path, messages, user_msg_count, assistant_msg_count, title, first_message, git_branch, file_size, session_created_at, hostname)
1095
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1090
1096
  ON CONFLICT(session_id) DO UPDATE SET
1091
1097
  messages=excluded.messages, user_msg_count=excluded.user_msg_count,
1092
1098
  assistant_msg_count=excluded.assistant_msg_count, title=excluded.title,
1093
1099
  first_message=excluded.first_message, git_branch=excluded.git_branch,
1094
- file_size=excluded.file_size, imported_at=datetime('now')`
1100
+ file_size=excluded.file_size, hostname=COALESCE(NULLIF(excluded.hostname,''), session_conversations.hostname),
1101
+ imported_at=datetime('now')`
1095
1102
  ).run(
1096
1103
  session_id, project_path || '', JSON.stringify(messages || []),
1097
1104
  user_msg_count || 0, assistant_msg_count || 0, title || '',
1098
- first_message || '', git_branch || '', file_size || 0, session_created_at || ''
1105
+ first_message || '', git_branch || '', file_size || 0, session_created_at || '',
1106
+ hostname || require('os').hostname()
1099
1107
  );
1100
1108
  }
1101
1109
 
1102
- function listSessionConversations({ search, limit, offset } = {}) {
1103
- let sql = 'SELECT id, session_id, project_path, user_msg_count, assistant_msg_count, title, first_message, git_branch, file_size, session_created_at, imported_at FROM session_conversations WHERE 1=1';
1110
+ function listSessionConversations({ search, limit, offset, hostname, allDevices } = {}) {
1111
+ let sql = 'SELECT id, session_id, project_path, user_msg_count, assistant_msg_count, title, first_message, git_branch, file_size, session_created_at, imported_at, hostname FROM session_conversations WHERE 1=1';
1104
1112
  const params = [];
1113
+ if (!allDevices && hostname) {
1114
+ sql += ' AND (hostname = ? OR hostname = \'\' OR hostname IS NULL)';
1115
+ params.push(hostname);
1116
+ }
1105
1117
  if (search) {
1106
1118
  sql += ' AND (title LIKE ? OR first_message LIKE ? OR project_path LIKE ?)';
1107
1119
  const q = `%${search}%`;
@@ -1280,7 +1292,7 @@ function deleteBackup(backupName) {
1280
1292
  if (fs.existsSync(imagesPath)) fs.unlinkSync(imagesPath);
1281
1293
  }
1282
1294
 
1283
- function cleanOldBackups(maxAge = 30) {
1295
+ function cleanOldBackups(maxAge = 7) {
1284
1296
  // Keep backups for maxAge days, but always keep at least 5
1285
1297
  const backups = listBackups();
1286
1298
  if (backups.length <= 5) return;
@@ -82,6 +82,8 @@
82
82
  }
83
83
  .walle-btn:hover { background: rgba(255,255,255,0.08); }
84
84
  .walle-btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
85
+ .walle-btn.danger { color: #f85149; border-color: #f8514933; }
86
+ .walle-btn.danger:hover { background: #f8514918; border-color: #f85149; }
85
87
 
86
88
  /* Loading / Empty */
87
89
  .walle-loading { text-align: center; padding: 40px; color: var(--fg-muted, #888); font-size: 13px; }
@@ -2239,6 +2239,7 @@
2239
2239
  <button class="nav-pill" data-nav="codereview" onclick="navTo('codereview')" title="Code Review">Review</button>
2240
2240
  <button class="nav-pill" data-nav="walle" onclick="navTo('walle')" title="WALL-E Agent">WALL-E</button>
2241
2241
  <button class="nav-pill" data-nav="rules" onclick="navTo('rules')" title="Edit CLAUDE.md rules">Rules</button>
2242
+ <button class="nav-pill" data-nav="backups" onclick="navTo('backups')" title="Database backups">Backups</button>
2242
2243
  <a href="/setup.html" class="nav-pill" title="Setup & Settings" style="text-decoration:none;margin-left:auto;font-size:16px;padding:4px 8px;">⚙</a>
2243
2244
  </nav>
2244
2245
  <div class="topbar-right">
@@ -2272,6 +2273,7 @@
2272
2273
  <div class="display-toggle">
2273
2274
  <label><input type="checkbox" id="hide-ctm" checked onchange="savePref('hide_ctm', this.checked); renderFilteredSessions()"> Hide CTM</label>
2274
2275
  <label><input type="checkbox" id="show-titles" checked onchange="savePref('show_titles', this.checked); renderFilteredSessions()"> AI titles</label>
2276
+ <label title="Only show sessions from this computer"><input type="checkbox" id="this-device" checked onchange="savePref('this_device', this.checked); loadRecentSessions()"> This device</label>
2275
2277
  <div class="view-mode-toggle">
2276
2278
  <button class="active" id="view-list-btn" onclick="setViewMode('list')" title="List view">List</button>
2277
2279
  <button id="view-group-btn" onclick="setViewMode('groups')" title="Group by topic">Groups</button>
@@ -2387,6 +2389,13 @@
2387
2389
  </div>
2388
2390
  <div id="walle-body" class="walle-body"></div>
2389
2391
  </div>
2392
+ <div id="backups-panel" style="display:none;flex-direction:column;height:100%;overflow:hidden;">
2393
+ <div style="padding:16px 20px;border-bottom:1px solid var(--border);background:var(--bg);">
2394
+ <h2 style="font-size:16px;font-weight:600;margin-bottom:8px;">Backup Manager</h2>
2395
+ <p style="font-size:12px;color:var(--fg-dim);margin:0;">Auto-backups daily (7 day retention, minimum 3 kept). Click paths to copy.</p>
2396
+ </div>
2397
+ <div id="backups-body" style="flex:1;overflow-y:auto;padding:16px 20px;"></div>
2398
+ </div>
2390
2399
  <div id="rules-panel">
2391
2400
  <div id="rules-sidebar">
2392
2401
  <div class="rules-sidebar-header">
@@ -3290,7 +3299,7 @@ function createTerminal(id) {
3290
3299
  }
3291
3300
 
3292
3301
  function activateTab(id) {
3293
- const specialPanels = ['rules', 'insights', 'permissions', 'prompts', 'codereview', 'walle'];
3302
+ const specialPanels = ['rules', 'insights', 'permissions', 'prompts', 'codereview', 'walle', 'backups'];
3294
3303
  const isPanel = specialPanels.includes(id);
3295
3304
 
3296
3305
  // Hide all
@@ -3305,6 +3314,8 @@ function activateTab(id) {
3305
3314
  document.getElementById('permissions-panel').classList.remove('active');
3306
3315
  document.getElementById('prompts-panel').classList.remove('active');
3307
3316
  document.getElementById('walle-panel').classList.remove('active');
3317
+ var bkp = document.getElementById('backups-panel');
3318
+ if (bkp) { bkp.classList.remove('active'); bkp.style.display = 'none'; }
3308
3319
 
3309
3320
  state.activeTab = id;
3310
3321
 
@@ -3407,7 +3418,7 @@ function activateTab(id) {
3407
3418
 
3408
3419
  function syncNavPills(activeId) {
3409
3420
  const navPills = document.querySelectorAll('#topbar-nav .nav-pill');
3410
- const specialPanels = ['rules', 'insights', 'permissions', 'prompts', 'codereview', 'walle'];
3421
+ const specialPanels = ['rules', 'insights', 'permissions', 'prompts', 'codereview', 'walle', 'backups'];
3411
3422
  const navId = specialPanels.includes(activeId) ? activeId : 'sessions';
3412
3423
  navPills.forEach(pill => {
3413
3424
  pill.classList.toggle('active', pill.dataset.nav === navId);
@@ -3479,6 +3490,8 @@ function navTo(target, opts) {
3479
3490
  document.getElementById('review-panel').classList.remove('active');
3480
3491
  document.getElementById('codereview-panel').classList.remove('active');
3481
3492
  document.getElementById('walle-panel').classList.remove('active');
3493
+ var bkPanel = document.getElementById('backups-panel');
3494
+ if (bkPanel) bkPanel.classList.remove('active');
3482
3495
  // Restore sidebar & tabbar
3483
3496
  const sidebar = document.getElementById('sidebar');
3484
3497
  if (!state.sidebarManuallyHidden) {
@@ -3503,6 +3516,8 @@ function navTo(target, opts) {
3503
3516
  showCodeReviewPanel();
3504
3517
  } else if (target === 'walle') {
3505
3518
  showWallePanel();
3519
+ } else if (target === 'backups') {
3520
+ showBackupsPanel();
3506
3521
  }
3507
3522
  }
3508
3523
 
@@ -3526,6 +3541,150 @@ function showWallePanel() {
3526
3541
  if (window.WE) WE.init();
3527
3542
  }
3528
3543
 
3544
+ function showBackupsPanel() {
3545
+ document.querySelectorAll('#terminal-area > div').forEach(function(d) { d.style.display = 'none'; });
3546
+ var panel = document.getElementById('backups-panel');
3547
+ panel.style.display = 'flex';
3548
+ panel.classList.add('active');
3549
+ syncNavPills('backups');
3550
+ updateTopbarContext('backups');
3551
+ document.getElementById('sidebar').classList.add('collapsed');
3552
+ document.getElementById('sidebar-resize').style.display = 'none';
3553
+ document.getElementById('tabbar').style.display = 'none';
3554
+ loadBackupsData();
3555
+ }
3556
+
3557
+ function loadBackupsData() {
3558
+ var body = document.getElementById('backups-body');
3559
+ body.textContent = 'Loading...';
3560
+ var token = window._ctmState ? window._ctmState.token : '';
3561
+ Promise.all([
3562
+ fetch('/api/backups?token=' + token).then(function(r) { return r.json(); }),
3563
+ fetch('/api/wall-e/backups?token=' + token).then(function(r) { return r.json(); })
3564
+ ]).then(function(results) {
3565
+ var ctm = results[0] || {};
3566
+ var brain = (results[1] && results[1].data) ? results[1].data : {};
3567
+ _renderBackupsBody(body, ctm, brain);
3568
+ }).catch(function(e) { body.textContent = 'Failed: ' + e.message; });
3569
+ }
3570
+
3571
+ function _renderBackupsBody(body, ctm, brain) {
3572
+ while (body.firstChild) body.removeChild(body.firstChild);
3573
+
3574
+ // DB info cards
3575
+ var grid = document.createElement('div');
3576
+ grid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;';
3577
+ grid.appendChild(_makeDbCard('CTM Database', ctm.db_path, ctm.db_size, ctm.backup_dir));
3578
+ grid.appendChild(_makeDbCard('Wall-E Brain', brain.db_path, brain.db_size, brain.backup_dir));
3579
+ body.appendChild(grid);
3580
+
3581
+ // Buttons
3582
+ var btns = document.createElement('div');
3583
+ btns.style.cssText = 'display:flex;gap:8px;margin-bottom:16px;';
3584
+ var b1 = document.createElement('button'); b1.className = 'btn primary small'; b1.textContent = 'Backup CTM';
3585
+ b1.onclick = function() { _createBackup('ctm'); };
3586
+ var b2 = document.createElement('button'); b2.className = 'btn primary small'; b2.textContent = 'Backup Brain';
3587
+ b2.onclick = function() { _createBackup('brain'); };
3588
+ btns.appendChild(b1); btns.appendChild(b2);
3589
+ body.appendChild(btns);
3590
+
3591
+ // CTM backups table
3592
+ _appendBackupTable(body, 'CTM Backups', ctm.backups || [], 'ctm', true);
3593
+ // Brain backups table
3594
+ _appendBackupTable(body, 'Wall-E Brain Backups', brain.backups || [], 'brain', false);
3595
+ }
3596
+
3597
+ function _makeDbCard(title, dbPath, dbSize, backupDir) {
3598
+ var card = document.createElement('div');
3599
+ card.style.cssText = 'background:var(--bg-light,#161b22);border:1px solid var(--border);border-radius:8px;padding:12px;';
3600
+ var h = document.createElement('div'); h.style.cssText = 'font-size:13px;font-weight:600;margin-bottom:6px;'; h.textContent = title;
3601
+ card.appendChild(h);
3602
+ card.appendChild(_copyablePath('DB', dbPath));
3603
+ card.appendChild(_copyablePath('Backups', backupDir));
3604
+ var sz = document.createElement('div'); sz.style.cssText = 'font-size:11px;color:var(--fg-dim);'; sz.textContent = 'Size: ' + _fmtSize(dbSize || 0);
3605
+ card.appendChild(sz);
3606
+ return card;
3607
+ }
3608
+
3609
+ function _copyablePath(label, p) {
3610
+ var row = document.createElement('div');
3611
+ row.style.cssText = 'font-size:11px;color:var(--fg-dim);margin-bottom:3px;cursor:pointer;';
3612
+ row.title = 'Click to copy';
3613
+ row.textContent = label + ': ';
3614
+ var span = document.createElement('span'); span.style.color = 'var(--accent)'; span.textContent = p || 'N/A';
3615
+ row.appendChild(span);
3616
+ row.onclick = function() { navigator.clipboard.writeText(p || ''); if (typeof showToast === 'function') showToast('Path copied'); };
3617
+ return row;
3618
+ }
3619
+
3620
+ function _appendBackupTable(container, title, backups, type, showRestore) {
3621
+ var h3 = document.createElement('h3');
3622
+ h3.style.cssText = 'font-size:13px;color:var(--fg-dim);margin:20px 0 8px;';
3623
+ h3.textContent = title + ' (' + backups.length + ')';
3624
+ container.appendChild(h3);
3625
+ if (backups.length === 0) {
3626
+ var p = document.createElement('p'); p.style.cssText = 'font-size:12px;color:var(--fg-dim)'; p.textContent = 'No backups yet.';
3627
+ container.appendChild(p); return;
3628
+ }
3629
+ var table = document.createElement('table');
3630
+ table.style.cssText = 'width:100%;font-size:12px;border-collapse:collapse;';
3631
+ var thead = document.createElement('tr');
3632
+ thead.style.cssText = 'color:var(--fg-dim);text-align:left;';
3633
+ ['Name','Size','Date',''].forEach(function(t) { var th = document.createElement('th'); th.style.padding = '4px 8px'; th.textContent = t; thead.appendChild(th); });
3634
+ table.appendChild(thead);
3635
+ backups.slice(0, 20).forEach(function(b) {
3636
+ var tr = document.createElement('tr'); tr.style.borderTop = '1px solid var(--border)';
3637
+ var td1 = document.createElement('td'); td1.style.cssText = 'padding:6px 8px;color:var(--fg)'; td1.textContent = b.name + (b.hasImages ? ' + images' : '');
3638
+ var td2 = document.createElement('td'); td2.style.cssText = 'padding:6px 8px;color:var(--fg-dim)'; td2.textContent = _fmtSize(b.size);
3639
+ var td3 = document.createElement('td'); td3.style.cssText = 'padding:6px 8px;color:var(--fg-dim)'; td3.textContent = _fmtDate(b.createdAt);
3640
+ var td4 = document.createElement('td'); td4.style.cssText = 'padding:6px 8px;text-align:right;';
3641
+ if (showRestore) {
3642
+ var rb = document.createElement('button'); rb.className = 'btn small'; rb.textContent = 'Restore';
3643
+ rb.onclick = (function(n) { return function() { _restoreBackup(type, n); }; })(b.name);
3644
+ td4.appendChild(rb);
3645
+ td4.appendChild(document.createTextNode(' '));
3646
+ }
3647
+ var db = document.createElement('button'); db.className = 'btn small'; db.style.color = 'var(--red)'; db.textContent = 'Delete';
3648
+ db.onclick = (function(n) { return function() { _deleteBackup(type, n); }; })(b.name);
3649
+ td4.appendChild(db);
3650
+ tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4);
3651
+ table.appendChild(tr);
3652
+ });
3653
+ container.appendChild(table);
3654
+ }
3655
+
3656
+ function _fmtSize(bytes) {
3657
+ if (!bytes) return '0 B';
3658
+ if (bytes < 1024) return bytes + ' B';
3659
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
3660
+ return (bytes / 1048576).toFixed(1) + ' MB';
3661
+ }
3662
+ function _fmtDate(iso) {
3663
+ if (!iso) return '';
3664
+ try { return new Date(iso).toLocaleString(); } catch(e) { return iso; }
3665
+ }
3666
+
3667
+ function _createBackup(type) {
3668
+ var token = window._ctmState ? window._ctmState.token : '';
3669
+ var url = type === 'ctm' ? '/api/backups?token=' + token : '/api/wall-e/backups?token=' + token;
3670
+ fetch(url, { method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}' })
3671
+ .then(function() { if (typeof showToast === 'function') showToast((type === 'ctm' ? 'CTM' : 'Brain') + ' backup created'); loadBackupsData(); })
3672
+ .catch(function(e) { if (typeof showToast === 'function') showToast('Backup failed', 'var(--red)'); });
3673
+ }
3674
+ function _deleteBackup(type, name) {
3675
+ if (!confirm('Delete backup ' + name + '?')) return;
3676
+ var token = window._ctmState ? window._ctmState.token : '';
3677
+ var url = type === 'ctm' ? '/api/backups/' + encodeURIComponent(name) + '?token=' + token : '/api/wall-e/backups/' + encodeURIComponent(name) + '?token=' + token;
3678
+ fetch(url, { method: 'DELETE' }).then(function() { loadBackupsData(); });
3679
+ }
3680
+ function _restoreBackup(type, name) {
3681
+ if (!confirm('Restore from ' + name + '? Current data will be backed up first.')) return;
3682
+ var token = window._ctmState ? window._ctmState.token : '';
3683
+ fetch('/api/backups/restore?token=' + token, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name: name }) })
3684
+ .then(function() { if (typeof showToast === 'function') showToast('Restored from ' + name); loadBackupsData(); })
3685
+ .catch(function(e) { if (typeof showToast === 'function') showToast('Restore failed', 'var(--red)'); });
3686
+ }
3687
+
3529
3688
  // --- Event Handlers ---
3530
3689
  function onCreated(msg) {
3531
3690
  const { id, pid, label, cwd } = msg;
@@ -4320,6 +4479,7 @@ async function loadPrefs() {
4320
4479
  show_titles: 'show-titles',
4321
4480
  hide_tool_msgs: 'hide-tool-msgs',
4322
4481
  show_internal: 'show-internal-toggle',
4482
+ this_device: 'this-device',
4323
4483
  };
4324
4484
  for (const [pref, elId] of Object.entries(checkboxMap)) {
4325
4485
  if (pref in prefs) {
@@ -4595,18 +4755,20 @@ function sessionDisplayText(s, fallback) {
4595
4755
 
4596
4756
  function sessionItemHtml(s) {
4597
4757
  const showTitles = document.getElementById('show-titles').checked;
4758
+ const thisDevice = document.getElementById('this-device')?.checked !== false;
4598
4759
  const ago = timeAgo(s.modifiedAt);
4599
4760
  const project = s.project.replace(/^\/Users\/[^/]+\//, '~/');
4600
4761
  const displayText = sessionDisplayText(s, '(empty session)');
4601
4762
  const branch = s.gitBranch ? `<span>${escHtml(s.gitBranch)}</span>` : '';
4602
4763
  const emptyTag = s.isEmpty ? '<span style="color:var(--yellow);font-size:9px;margin-left:4px">[empty]</span>' : '';
4764
+ const deviceTag = (!thisDevice && s.hostname) ? `<span style="color:var(--accent);font-size:9px;margin-left:4px">${escHtml(s.hostname)}</span>` : '';
4603
4765
  const msgCount = s.userMsgCount > 0 ? `<span>${s.userMsgCount} msgs</span>` : '';
4604
4766
  const reviewing = state.reviewingSessionId === s.sessionId ? ' reviewing' : '';
4605
4767
  const isPinned = pinnedSessionIds.includes(s.sessionId);
4606
4768
  const pinCls = isPinned ? ' pinned' : '';
4607
4769
  const dragAttrs = isPinned ? ` draggable="true" ondragstart="pinDragStart(event)" ondragover="pinDragOver(event)" ondragleave="pinDragLeave(event)" ondrop="pinDrop(event)" ondragend="pinDragEnd(event)"` : '';
4608
4770
  return `<div class="recent-item${reviewing}${pinCls}" data-session-id="${s.sessionId}"${dragAttrs} onclick="openSessionReview('${s.sessionId}')" oncontextmenu="event.preventDefault();togglePinSession('${s.sessionId}')">
4609
- <div class="recent-msg"><span class="pin-icon" style="${isPinned ? 'cursor:grab;' : ''}">&#9733;</span><span class="recent-msg-text">${escHtml(displayText)}</span>${emptyTag}</div>
4771
+ <div class="recent-msg"><span class="pin-icon" style="${isPinned ? 'cursor:grab;' : ''}">&#9733;</span><span class="recent-msg-text">${escHtml(displayText)}</span>${emptyTag}${deviceTag}</div>
4610
4772
  <div class="recent-meta">
4611
4773
  <span class="project">${escHtml(project)}</span>
4612
4774
  ${branch}
@@ -7120,7 +7282,7 @@ window.addEventListener('message', (e) => {
7120
7282
  });
7121
7283
 
7122
7284
  // --- Hash routing ---
7123
- const NAV_TARGETS = ['sessions', 'prompts', 'rules', 'insights', 'permissions', 'codereview', 'walle'];
7285
+ const NAV_TARGETS = ['sessions', 'prompts', 'rules', 'insights', 'permissions', 'codereview', 'walle', 'backups'];
7124
7286
 
7125
7287
  function handleHashRoute() {
7126
7288
  const hash = location.hash.slice(1);
@@ -2315,7 +2315,8 @@ async function deleteTemplate(id) {
2315
2315
  // ============================================================
2316
2316
  async function loadBackups() {
2317
2317
  const res = await fetch(API('/backups'));
2318
- const backups = await res.json();
2318
+ const data = await res.json();
2319
+ const backups = data.backups || (Array.isArray(data) ? data : []);
2319
2320
  const list = document.getElementById('pe-backup-list');
2320
2321
  if (!backups.length) {
2321
2322
  list.innerHTML = `<div class="pe-empty-state"><h3>No Backups Yet</h3><p>Click "Backup Now" to create your first manual backup.</p></div>`;
@@ -778,6 +778,7 @@ function renderChatUI() {
778
778
  html += '<span class="we-export-count">' + chatSelected.size + ' selected</span>';
779
779
  html += '<button class="walle-btn" onclick="WE._exportAsText()">Copy as Text</button>';
780
780
  html += '<button class="walle-btn" onclick="WE._exportAsImage()">Save as Image</button>';
781
+ html += '<button class="walle-btn danger" onclick="WE._deleteSelected()">Delete</button>';
781
782
  html += '<button class="we-chat-search-clear" onclick="WE._clearSelection()" title="Clear selection">&times;</button>';
782
783
  html += '</div>';
783
784
  }
@@ -1381,7 +1382,52 @@ WE._toggleTurn = function(turnIdx) {
1381
1382
  } else {
1382
1383
  chatSelected.add(turnIdx);
1383
1384
  }
1384
- renderChatUI();
1385
+ // Update just the clicked turn visually — don't re-render (preserves scroll)
1386
+ var el = document.querySelector('.we-turn-group[data-turn="' + turnIdx + '"]');
1387
+ if (el) {
1388
+ el.classList.toggle('we-selected', chatSelected.has(turnIdx));
1389
+ var cb = el.querySelector('.we-turn-checkbox');
1390
+ if (cb) cb.textContent = chatSelected.has(turnIdx) ? '\u2611' : '\u2610';
1391
+ }
1392
+ // Update the export bar (insert via DOM to preserve scroll)
1393
+ var bar = document.querySelector('.we-export-bar');
1394
+ if (chatSelected.size > 0 && !bar) {
1395
+ var newBar = document.createElement('div');
1396
+ newBar.className = 'we-export-bar';
1397
+ newBar.textContent = ''; // clear
1398
+ var countSpan = document.createElement('span');
1399
+ countSpan.className = 'we-export-count';
1400
+ countSpan.textContent = chatSelected.size + ' selected';
1401
+ newBar.appendChild(countSpan);
1402
+ var copyBtn = document.createElement('button');
1403
+ copyBtn.className = 'walle-btn';
1404
+ copyBtn.textContent = 'Copy as Text';
1405
+ copyBtn.onclick = function() { WE._exportAsText(); };
1406
+ newBar.appendChild(copyBtn);
1407
+ var imgBtn = document.createElement('button');
1408
+ imgBtn.className = 'walle-btn';
1409
+ imgBtn.textContent = 'Save as Image';
1410
+ imgBtn.onclick = function() { WE._exportAsImage(); };
1411
+ newBar.appendChild(imgBtn);
1412
+ var delBtn = document.createElement('button');
1413
+ delBtn.className = 'walle-btn danger';
1414
+ delBtn.textContent = 'Delete';
1415
+ delBtn.onclick = function() { WE._deleteSelected(); };
1416
+ newBar.appendChild(delBtn);
1417
+ var clearBtn = document.createElement('button');
1418
+ clearBtn.className = 'we-chat-search-clear';
1419
+ clearBtn.title = 'Clear selection';
1420
+ clearBtn.textContent = '\u00d7';
1421
+ clearBtn.onclick = function() { WE._clearSelection(); };
1422
+ newBar.appendChild(clearBtn);
1423
+ var searchBar = document.querySelector('.we-chat-search-bar');
1424
+ if (searchBar) searchBar.after(newBar);
1425
+ } else if (chatSelected.size === 0 && bar) {
1426
+ bar.remove();
1427
+ } else if (bar) {
1428
+ var countEl = bar.querySelector('.we-export-count');
1429
+ if (countEl) countEl.textContent = chatSelected.size + ' selected';
1430
+ }
1385
1431
  };
1386
1432
 
1387
1433
  WE._clearSelection = function() {
@@ -1515,6 +1561,36 @@ WE._exportAsImage = function() {
1515
1561
  }
1516
1562
  };
1517
1563
 
1564
+ WE._deleteSelected = function() {
1565
+ var turns = _getSelectedTurns();
1566
+ if (turns.length === 0) return;
1567
+ if (!confirm('Delete ' + turns.length + ' message' + (turns.length > 1 ? 's' : '') + '? This cannot be undone.')) return;
1568
+
1569
+ var count = turns.length;
1570
+ var promises = turns.map(function(t) {
1571
+ return apiPost('/chat/delete', {
1572
+ session_id: cache.chatSessionId || 'default',
1573
+ user_content: t.userText || undefined,
1574
+ assistant_content: t.assistantText || undefined,
1575
+ });
1576
+ });
1577
+
1578
+ Promise.all(promises).then(function() {
1579
+ chatSelected.clear();
1580
+ chatSelectMode = false;
1581
+ // Reload chat history
1582
+ api('/chat/history?session_id=' + encodeURIComponent(cache.chatSessionId || 'default') + '&limit=200').then(function(result) {
1583
+ chatHistory = (result.data || []).map(function(m) {
1584
+ return { role: m.role, text: m.content || m.text || '', ts: m.timestamp };
1585
+ });
1586
+ renderChatUI();
1587
+ if (typeof showToast === 'function') showToast('Deleted ' + count + ' message' + (count > 1 ? 's' : ''));
1588
+ });
1589
+ }).catch(function(err) {
1590
+ if (typeof showToast === 'function') showToast('Delete failed: ' + (err.message || 'unknown error'), 'var(--red)');
1591
+ });
1592
+ };
1593
+
1518
1594
  // ---- Chat Search ----
1519
1595
  WE._onChatSearch = function(val) {
1520
1596
  chatSearchQuery = val;