openclacky 1.1.6 → 1.2.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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/CODE_OF_CONDUCT.md +1 -1
  4. data/CONTRIBUTING.md +92 -0
  5. data/README.md +10 -0
  6. data/README_CN.md +10 -0
  7. data/ROADMAP.md +29 -0
  8. data/docs/billing-system.md +340 -0
  9. data/docs/mcp-architecture.md +114 -0
  10. data/docs/mcp.example.json +22 -0
  11. data/lib/clacky/agent/cost_tracker.rb +37 -0
  12. data/lib/clacky/agent/llm_caller.rb +0 -1
  13. data/lib/clacky/agent/session_serializer.rb +2 -11
  14. data/lib/clacky/agent/skill_manager.rb +73 -26
  15. data/lib/clacky/agent/system_prompt_builder.rb +0 -5
  16. data/lib/clacky/agent/time_machine.rb +6 -0
  17. data/lib/clacky/agent.rb +26 -1
  18. data/lib/clacky/agent_config.rb +9 -19
  19. data/lib/clacky/billing/billing_record.rb +67 -0
  20. data/lib/clacky/billing/billing_store.rb +193 -0
  21. data/lib/clacky/cli.rb +108 -6
  22. data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
  23. data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
  24. data/lib/clacky/idle_compression_timer.rb +4 -2
  25. data/lib/clacky/mcp/client.rb +204 -0
  26. data/lib/clacky/mcp/http_transport.rb +155 -0
  27. data/lib/clacky/mcp/registry.rb +229 -0
  28. data/lib/clacky/mcp/skill_provider.rb +75 -0
  29. data/lib/clacky/mcp/stdio_transport.rb +112 -0
  30. data/lib/clacky/mcp/transport.rb +23 -0
  31. data/lib/clacky/mcp/virtual_skill.rb +131 -0
  32. data/lib/clacky/message_history.rb +0 -1
  33. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
  34. data/lib/clacky/server/http_server.rb +519 -15
  35. data/lib/clacky/server/server_master.rb +8 -14
  36. data/lib/clacky/server/session_registry.rb +24 -2
  37. data/lib/clacky/server/web_ui_controller.rb +4 -0
  38. data/lib/clacky/session_manager.rb +41 -12
  39. data/lib/clacky/skill.rb +1 -5
  40. data/lib/clacky/skill_loader.rb +36 -5
  41. data/lib/clacky/tools/browser.rb +217 -38
  42. data/lib/clacky/tools/trash_manager.rb +154 -3
  43. data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
  44. data/lib/clacky/ui_interface.rb +1 -0
  45. data/lib/clacky/utils/model_pricing.rb +11 -7
  46. data/lib/clacky/utils/trash_directory.rb +37 -6
  47. data/lib/clacky/version.rb +1 -1
  48. data/lib/clacky/web/app.css +2907 -1764
  49. data/lib/clacky/web/app.js +84 -10
  50. data/lib/clacky/web/billing.js +275 -0
  51. data/lib/clacky/web/brand.js +3 -0
  52. data/lib/clacky/web/i18n.js +242 -24
  53. data/lib/clacky/web/index.html +351 -134
  54. data/lib/clacky/web/mcp.js +328 -0
  55. data/lib/clacky/web/sessions.js +193 -11
  56. data/lib/clacky/web/settings.js +686 -174
  57. data/lib/clacky/web/sidebar.js +2 -0
  58. data/lib/clacky/web/trash.js +323 -60
  59. data/lib/clacky/web/ws-dispatcher.js +14 -1
  60. data/lib/clacky.rb +4 -0
  61. data/scripts/install.ps1 +23 -11
  62. metadata +30 -10
@@ -24,8 +24,10 @@ const Sidebar = (() => {
24
24
  document.getElementById("tasks-sidebar-item").addEventListener("click", () => Router.navigate("tasks"));
25
25
  document.getElementById("skills-sidebar-item").addEventListener("click", () => Router.navigate("skills"));
26
26
  document.getElementById("channels-sidebar-item").addEventListener("click", () => Router.navigate("channels"));
27
+ document.getElementById("mcp-sidebar-item").addEventListener("click", () => Router.navigate("mcp"));
27
28
  document.getElementById("trash-sidebar-item").addEventListener("click", () => Router.navigate("trash"));
28
29
  document.getElementById("profile-sidebar-item").addEventListener("click", () => Router.navigate("profile"));
30
+ document.getElementById("billing-sidebar-item").addEventListener("click", () => Router.navigate("billing"));
29
31
 
30
32
  // memories-sidebar-item is retained as a hidden legacy placeholder — no click handler.
31
33
 
@@ -1,20 +1,23 @@
1
- // trash.js — Recently Deleted panel
1
+ // trash.js — File Recall & Session Recycle Bin
2
2
  //
3
- // Top-level sidebar panel that lists files moved to trash by the agent
4
- // across every project-scoped trash dir under ~/.clacky/trash/.
3
+ // Two-tab panel under the "File Recall" sidebar item:
4
+ // Tab 1 (文件回收 / Agent Files): AI-deleted files from all projects
5
+ // Tab 2 (会话回收 / Sessions): soft-deleted sessions that can be restored
5
6
  //
6
- // Each card shows the original path, project, size and deleted-at,
7
- // plus Restore / Delete buttons. Bulk actions at the top:
8
- // refresh, empty files older than 7 days, empty everything.
7
+ // Each tab has its own toolbar actions (refresh, empty by age, empty all).
8
+ // The "Clean orphans" button is file-trash-only and hidden on the session tab.
9
9
  //
10
10
  // Load order: after app.js modules (I18n, Modal), before app.js boot.
11
11
 
12
12
  const Trash = (() => {
13
13
  // ── Private state ────────────────────────────────────────────────────
14
- let _files = [];
15
- let _totals = { count: 0, size: 0 };
16
- let _loading = false;
17
- let _wired = false;
14
+ let _files = [];
15
+ let _totals = { count: 0, size: 0 };
16
+ let _sessions = [];
17
+ let _sessionTotals = { count: 0, size: 0 };
18
+ let _activeTab = null; // null = no tab shown yet; set on first _switchTab
19
+ let _loading = false;
20
+ let _wired = false;
18
21
 
19
22
  // ── Helpers ──────────────────────────────────────────────────────────
20
23
 
@@ -47,19 +50,54 @@ const Trash = (() => {
47
50
  const mins = Math.floor(ms / 60000);
48
51
  const hours = Math.floor(ms / 3600000);
49
52
  const days = Math.floor(ms / 86400000);
50
- const zh = I18n.lang() === "zh";
51
- if (mins < 1) return zh ? "刚刚" : "just now";
52
- if (mins < 60) return zh ? `${mins} 分钟前` : `${mins}m ago`;
53
- if (hours < 24) return zh ? `${hours} 小时前` : `${hours}h ago`;
54
- if (days < 7) return zh ? `${days} 天前` : `${days}d ago`;
53
+ if (mins < 1) return I18n.t("time.justNow");
54
+ if (mins < 60) return I18n.t("time.minsAgo", { n: mins });
55
+ if (hours < 24) return I18n.t("time.hoursAgo", { n: hours });
56
+ if (days < 7) return I18n.t("time.daysAgo", { n: days });
55
57
  return d.toLocaleDateString();
56
58
  }
57
59
 
60
+ // ── Tab switching ────────────────────────────────────────────────────
61
+
62
+ function _switchTab(tab) {
63
+ if (_activeTab === tab) return;
64
+ _activeTab = tab;
65
+
66
+ // Update tab button active states
67
+ document.querySelectorAll(".trash-tab").forEach(btn => {
68
+ btn.classList.toggle("active", btn.dataset.tab === tab);
69
+ });
70
+
71
+ // Show/hide tab content panels
72
+ const filePane = $("trash-tab-file");
73
+ const sessionPane = $("trash-tab-session");
74
+ if (filePane) filePane.style.display = tab === "file-trash" ? "" : "none";
75
+ if (sessionPane) sessionPane.style.display = tab === "session-trash" ? "" : "none";
76
+
77
+ // Show/hide "Clean orphans" button — only relevant for file trash
78
+ const btnOrphans = $("btn-trash-empty-orphans");
79
+ if (btnOrphans) btnOrphans.style.display = tab === "file-trash" ? "" : "none";
80
+
81
+ // Reload the active tab's data
82
+ _load();
83
+ }
84
+
58
85
  // ── Data loading ─────────────────────────────────────────────────────
59
86
 
60
87
  async function _load() {
61
88
  if (_loading) return;
62
89
  _loading = true;
90
+
91
+ if (_activeTab === "file-trash") {
92
+ await _loadFiles();
93
+ } else {
94
+ await _loadSessions();
95
+ }
96
+
97
+ _loading = false;
98
+ }
99
+
100
+ async function _loadFiles() {
63
101
  const list = $("trash-list");
64
102
  if (list) list.innerHTML =
65
103
  `<div class="creator-loading">${_t("trash.loading")}</div>`;
@@ -69,17 +107,35 @@ const Trash = (() => {
69
107
  if (!res.ok) throw new Error(data.error || "Load failed");
70
108
  _files = data.files || [];
71
109
  _totals = { count: data.total_count || 0, size: data.total_size || 0 };
72
- _render();
110
+ _renderFiles();
73
111
  } catch (e) {
74
- console.error("[Trash] load failed", e);
112
+ console.error("[Trash] load files failed", e);
113
+ if (list) list.innerHTML =
114
+ `<div class="creator-empty creator-error">${escapeHtml(e.message)}</div>`;
115
+ }
116
+ }
117
+
118
+ async function _loadSessions() {
119
+ const list = $("trash-session-list");
120
+ if (list) list.innerHTML =
121
+ `<div class="creator-loading">${_t("trash.loading")}</div>`;
122
+ try {
123
+ const res = await fetch("/api/trash/sessions");
124
+ const data = await res.json();
125
+ if (!res.ok) throw new Error(data.error || "Load failed");
126
+ _sessions = data.sessions || [];
127
+ _sessionTotals = { count: data.count || 0, size: data.total_size || 0 };
128
+ _renderSessions();
129
+ } catch (e) {
130
+ console.error("[Trash] load sessions failed", e);
75
131
  if (list) list.innerHTML =
76
132
  `<div class="creator-empty creator-error">${escapeHtml(e.message)}</div>`;
77
- } finally {
78
- _loading = false;
79
133
  }
80
134
  }
81
135
 
82
- function _render() {
136
+ // ── File trash rendering ─────────────────────────────────────────────
137
+
138
+ function _renderFiles() {
83
139
  const list = $("trash-list");
84
140
  const summary = $("trash-summary");
85
141
  const btnOld = $("btn-trash-empty-old");
@@ -111,10 +167,10 @@ const Trash = (() => {
111
167
  }
112
168
 
113
169
  list.innerHTML = "";
114
- _files.forEach(f => list.appendChild(_buildCard(f)));
170
+ _files.forEach(f => list.appendChild(_buildFileCard(f)));
115
171
  }
116
172
 
117
- function _buildCard(file) {
173
+ function _buildFileCard(file) {
118
174
  const card = document.createElement("div");
119
175
  card.className = "trash-card";
120
176
  card.dataset.project = file.project_root;
@@ -122,17 +178,17 @@ const Trash = (() => {
122
178
 
123
179
  const original = file.original_path || "";
124
180
  const basename = original.split("/").pop() || original;
125
- // Show last two path segments after basename to give agents context when
126
- // many files share the same basename (very common: "package.json", "index.js").
127
181
  const parts = original.split("/").filter(Boolean);
182
+ // Show last three path segments so same-named files (index.js, package.json, …)
183
+ // are still distinguishable in the card title area.
128
184
  const shortPath = parts.length > 3
129
185
  ? ".../" + parts.slice(-3).join("/")
130
186
  : original;
131
187
  const sizeStr = _humanBytes(file.file_size || 0);
132
188
  const whenStr = _humanTime(file.deleted_at);
133
- // Heuristic: if project_root starts with /var/folders or /tmp, or contains
134
- // a tempdir-style name (d20260502-...), the original project is gone.
135
- // We still show it, but mark it so the user can clean it up confidently.
189
+ // Heuristic: if project_root lives under a temp-dir prefix or matches the
190
+ // Ruby Tempdir pattern (dYYYYMMDD-PID-random), the original project is gone.
191
+ // We mark it as an orphan so the user can bulk-clean it confidently.
136
192
  const orphan = /^\/(?:var\/folders|tmp|private\/var\/folders)\b/.test(file.project_root || "") ||
137
193
  /\/d\d{8}-\d+-[a-z0-9]+(?:\/|$)/.test(file.project_root || "");
138
194
 
@@ -170,14 +226,92 @@ const Trash = (() => {
170
226
  </div>`;
171
227
 
172
228
  card.querySelector(".btn-trash-restore").addEventListener("click", () =>
173
- _restoreOne(file, card));
229
+ _restoreFile(file, card));
174
230
  card.querySelector(".btn-trash-delete").addEventListener("click", () =>
175
- _deleteOne(file, card));
231
+ _deleteFile(file, card));
176
232
 
177
233
  return card;
178
234
  }
179
235
 
180
- async function _restoreOne(file, card) {
236
+ // ── Session trash rendering ──────────────────────────────────────────
237
+
238
+ function _renderSessions() {
239
+ const list = $("trash-session-list");
240
+ const summary = $("trash-summary");
241
+ const btnOld = $("btn-trash-empty-old");
242
+ const btnAll = $("btn-trash-empty-all");
243
+ if (!list) return;
244
+
245
+ if (summary) {
246
+ summary.textContent = _sessions.length
247
+ ? I18n.t("trash.summarySessions", {
248
+ count: _sessionTotals.count,
249
+ size: _humanBytes(_sessionTotals.size)
250
+ })
251
+ : "";
252
+ }
253
+ if (btnOld) btnOld.disabled = _sessions.length === 0;
254
+ if (btnAll) btnAll.disabled = _sessions.length === 0;
255
+
256
+ if (_sessions.length === 0) {
257
+ list.innerHTML = `<div class="creator-empty">${_t("trash.noSessionTrash")}</div>`;
258
+ return;
259
+ }
260
+
261
+ list.innerHTML = "";
262
+ _sessions.forEach(s => list.appendChild(_buildSessionCard(s)));
263
+ }
264
+
265
+ function _buildSessionCard(session) {
266
+ const card = document.createElement("div");
267
+ card.className = "trash-session-card";
268
+ card.dataset.sessionId = session.session_id;
269
+
270
+ const name = session.name || session.session_id || "";
271
+ const shortId = (session.session_id || "").slice(0, 8);
272
+ const taskCount = session.total_tasks || 0;
273
+ const sizeStr = _humanBytes(session.file_size || 0);
274
+ const whenStr = _humanTime(session.deleted_at || session.created_at);
275
+ const taskLabel = I18n.t("trash.sessionTasks", { n: taskCount });
276
+
277
+ card.innerHTML = `
278
+ <div class="trash-session-card-info">
279
+ <div class="trash-session-card-name" title="${escapeHtml(name)}">${escapeHtml(name)}</div>
280
+ <div class="trash-session-card-id" title="${escapeHtml(session.session_id || '')}">${escapeHtml(session.session_id || '')}</div>
281
+ <div class="trash-session-card-meta">
282
+ <span>${escapeHtml(taskLabel)}</span>
283
+ <span>${sizeStr}</span>
284
+ <span title="${escapeHtml(session.deleted_at || session.created_at || '')}">${escapeHtml(whenStr)}</span>
285
+ </div>
286
+ </div>
287
+ <div class="trash-session-card-actions">
288
+ <button class="btn-trash-session-restore" title="${_t("trash.restoreSession")}">
289
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
290
+ <polyline points="1 4 1 10 7 10"/>
291
+ <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
292
+ </svg>
293
+ ${_t("trash.restoreSession")}
294
+ </button>
295
+ <button class="btn-trash-session-delete" title="${_t("trash.deleteSession")}">
296
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
297
+ <polyline points="3 6 5 6 21 6"/>
298
+ <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
299
+ <path d="M10 11v6"/><path d="M14 11v6"/>
300
+ </svg>
301
+ </button>
302
+ </div>`;
303
+
304
+ card.querySelector(".btn-trash-session-restore").addEventListener("click", () =>
305
+ _restoreSession(session, card));
306
+ card.querySelector(".btn-trash-session-delete").addEventListener("click", () =>
307
+ _deleteSession(session, card));
308
+
309
+ return card;
310
+ }
311
+
312
+ // ── File actions ─────────────────────────────────────────────────────
313
+
314
+ async function _restoreFile(file, card) {
181
315
  const btn = card.querySelector(".btn-trash-restore");
182
316
  btn.disabled = true;
183
317
  try {
@@ -191,27 +325,31 @@ const Trash = (() => {
191
325
  });
192
326
  const data = await res.json();
193
327
  if (!res.ok || !data.ok) {
194
- alert(I18n.t("trash.restoreFail", {
328
+ Modal.toast(I18n.t("trash.restoreFail", {
195
329
  msg: data.error || res.statusText
196
- }));
330
+ }), "error");
197
331
  } else {
198
- // Remove card, update totals locally for instant feedback.
332
+ // Optimistic update remove from local state and re-render immediately
333
+ // so the UI feels instant without waiting for a full reload.
199
334
  _files = _files.filter(f =>
200
335
  !(f.project_root === file.project_root && f.original_path === file.original_path));
201
336
  _totals = {
202
337
  count: Math.max(0, _totals.count - 1),
203
338
  size: Math.max(0, _totals.size - (file.file_size || 0))
204
339
  };
205
- _render();
340
+ _renderFiles();
341
+ Modal.toast(I18n.t("trash.restoreOk", {
342
+ path: (file.original_path || "").split("/").pop()
343
+ }), "success");
206
344
  }
207
345
  } catch (e) {
208
- alert(I18n.t("trash.restoreFail", { msg: e.message }));
346
+ Modal.toast(I18n.t("trash.restoreFail", { msg: e.message }), "error");
209
347
  } finally {
210
348
  btn.disabled = false;
211
349
  }
212
350
  }
213
351
 
214
- async function _deleteOne(file, card) {
352
+ async function _deleteFile(file, card) {
215
353
  const basename = (file.original_path || "").split("/").pop() || file.original_path;
216
354
  const confirmed = await Modal.confirm(
217
355
  I18n.t("trash.confirmDeleteOne", { name: basename })
@@ -227,25 +365,121 @@ const Trash = (() => {
227
365
  const res = await fetch(url, { method: "DELETE" });
228
366
  const data = await res.json();
229
367
  if (!res.ok || !data.ok) {
230
- alert(data.error || res.statusText);
368
+ Modal.toast(I18n.t("trash.deleteFail", { msg: data.error || res.statusText }), "error");
231
369
  return;
232
370
  }
371
+ // Optimistic update — same pattern as _restoreFile.
233
372
  _files = _files.filter(f =>
234
373
  !(f.project_root === file.project_root && f.original_path === file.original_path));
235
374
  _totals = {
236
375
  count: Math.max(0, _totals.count - 1),
237
376
  size: Math.max(0, _totals.size - (file.file_size || 0))
238
377
  };
239
- _render();
378
+ _renderFiles();
379
+ } catch (e) {
380
+ Modal.toast(I18n.t("trash.deleteFail", { msg: e.message }), "error");
381
+ }
382
+ }
383
+
384
+ // ── Session actions ──────────────────────────────────────────────────
385
+
386
+ async function _restoreSession(session, card) {
387
+ const btn = card.querySelector(".btn-trash-session-restore");
388
+ btn.disabled = true;
389
+ try {
390
+ const res = await fetch("/api/trash/sessions/restore", {
391
+ method: "POST",
392
+ headers: { "Content-Type": "application/json" },
393
+ body: JSON.stringify({ session_id: session.session_id })
394
+ });
395
+ const data = await res.json();
396
+ if (!res.ok || !data.ok) {
397
+ Modal.toast(I18n.t("trash.sessionRestoreFail", {
398
+ msg: data.error || res.statusText
399
+ }), "error");
400
+ } else {
401
+ _sessions = _sessions.filter(s => s.session_id !== session.session_id);
402
+ _sessionTotals = {
403
+ count: Math.max(0, _sessionTotals.count - 1),
404
+ size: Math.max(0, _sessionTotals.size - (session.file_size || 0))
405
+ };
406
+ _renderSessions();
407
+ // Optimistically add the restored session to the sidebar — same pattern
408
+ // as the WS session_restored handler, but covers the case where the WS
409
+ // event is lost (offline tab, slow reconnect). Sessions.add is idempotent.
410
+ const restored = data.session;
411
+ if (restored && typeof Sessions !== "undefined") {
412
+ Sessions.add(restored);
413
+ Sessions.renderList();
414
+ }
415
+ Modal.toast(I18n.t("trash.sessionRestoreOk"), "success", restored && restored.id ? {
416
+ action: {
417
+ label: I18n.t("trash.sessionRestoreOkAction"),
418
+ onClick: () => Sessions.select(restored.id)
419
+ }
420
+ } : {});
421
+ }
422
+ } catch (e) {
423
+ Modal.toast(I18n.t("trash.sessionRestoreFail", { msg: e.message }), "error");
424
+ } finally {
425
+ btn.disabled = false;
426
+ }
427
+ }
428
+
429
+ async function _deleteSession(session, card) {
430
+ const name = session.name || (session.session_id || "").slice(0, 8);
431
+ const confirmed = await Modal.confirm(
432
+ I18n.t("trash.confirmDeleteSession", { name: name })
433
+ );
434
+ if (!confirmed) return;
435
+
436
+ try {
437
+ const res = await fetch(`/api/trash/sessions/${encodeURIComponent(session.session_id)}`, {
438
+ method: "DELETE"
439
+ });
440
+ const data = await res.json();
441
+ if (!res.ok || !data.ok) {
442
+ Modal.toast(I18n.t("trash.deleteFail", { msg: data.error || res.statusText }), "error");
443
+ return;
444
+ }
445
+ _sessions = _sessions.filter(s => s.session_id !== session.session_id);
446
+ _renderSessions();
240
447
  } catch (e) {
241
- alert(e.message);
448
+ Modal.toast(I18n.t("trash.deleteFail", { msg: e.message }), "error");
242
449
  }
243
450
  }
244
451
 
452
+ // ── Bulk actions ─────────────────────────────────────────────────────
453
+
454
+ // Count locally how many entries would be wiped by a bulk operation,
455
+ // so the confirmation dialog can show "delete N items" instead of a vague
456
+ // "all eligible". daysOld=0 means "everything", otherwise filter by deleted_at.
457
+ function _countMatching(items, daysOld) {
458
+ if (!Array.isArray(items)) return 0;
459
+ if (!daysOld || daysOld <= 0) return items.length;
460
+ const cutoff = Date.now() - daysOld * 86400000;
461
+ return items.filter(it => {
462
+ const t = Date.parse(it.deleted_at || "");
463
+ return !isNaN(t) && t < cutoff;
464
+ }).length;
465
+ }
466
+
245
467
  async function _emptyBulk(daysOld, confirmKey) {
246
- const confirmed = await Modal.confirm(_t(confirmKey));
468
+ const isSession = _activeTab === "session-trash";
469
+ const matchCount = _countMatching(isSession ? _sessions : _files, daysOld);
470
+
471
+ if (matchCount === 0) {
472
+ Modal.toast(_t(daysOld > 0 ? "trash.nothingOld" : "trash.empty"), "info");
473
+ return;
474
+ }
475
+
476
+ const confirmed = await Modal.confirm(
477
+ I18n.t(confirmKey, { count: matchCount })
478
+ );
247
479
  if (!confirmed) return;
248
480
 
481
+ if (isSession) return _emptySessionsBulk(daysOld);
482
+
249
483
  const qs = new URLSearchParams();
250
484
  qs.set("days_old", String(daysOld));
251
485
  const url = "/api/trash?" + qs.toString();
@@ -254,34 +488,49 @@ const Trash = (() => {
254
488
  const res = await fetch(url, { method: "DELETE" });
255
489
  const data = await res.json();
256
490
  if (!res.ok || !data.ok) {
257
- alert(data.error || res.statusText);
491
+ Modal.toast(I18n.t("trash.cleanFail", { msg: data.error || res.statusText }), "error");
258
492
  return;
259
493
  }
260
- if (data.deleted_count === 0 && daysOld > 0) {
261
- alert(_t("trash.nothingOld"));
262
- } else {
263
- alert(I18n.t("trash.emptied", {
264
- count: data.deleted_count || 0,
265
- size: _humanBytes(data.freed_size || 0)
266
- }));
494
+ Modal.toast(I18n.t("trash.emptied", {
495
+ count: data.deleted_count || 0,
496
+ size: _humanBytes(data.freed_size || 0)
497
+ }), "success");
498
+ await _loadFiles();
499
+ } catch (e) {
500
+ Modal.toast(I18n.t("trash.cleanFail", { msg: e.message }), "error");
501
+ }
502
+ }
503
+
504
+ async function _emptySessionsBulk(daysOld) {
505
+ const qs = new URLSearchParams();
506
+ qs.set("days_old", String(daysOld));
507
+ const url = "/api/trash/sessions?" + qs.toString();
508
+
509
+ try {
510
+ const res = await fetch(url, { method: "DELETE" });
511
+ const data = await res.json();
512
+ if (!res.ok || !data.ok) {
513
+ Modal.toast(I18n.t("trash.cleanFail", { msg: data.error || res.statusText }), "error");
514
+ return;
267
515
  }
268
- await _load();
516
+ Modal.toast(I18n.t("trash.sessionsCleaned", {
517
+ count: data.deleted_count || 0
518
+ }), "success");
519
+ await _loadSessions();
269
520
  } catch (e) {
270
- alert(e.message);
521
+ Modal.toast(I18n.t("trash.cleanFail", { msg: e.message }), "error");
271
522
  }
272
523
  }
273
524
 
274
- // Detects trash entries whose original project_root clearly no longer
275
- // exists (test temp dirs under /var/folders, /tmp, or dir-format "dYYYYMMDD-...").
276
- // The delete API does permanent deletion on a per-file basis.
277
525
  async function _emptyOrphans() {
526
+ // Same heuristic as in _buildFileCard — keep both in sync if you ever change it.
278
527
  const orphans = _files.filter(f => {
279
528
  const root = f.project_root || "";
280
529
  return /^\/(?:var\/folders|tmp|private\/var\/folders)\b/.test(root) ||
281
530
  /\/d\d{8}-\d+-[a-z0-9]+(?:\/|$)/.test(root);
282
531
  });
283
532
  if (orphans.length === 0) {
284
- alert(_t("trash.noOrphans"));
533
+ Modal.toast(_t("trash.noOrphans"), "info");
285
534
  return;
286
535
  }
287
536
  const confirmed = await Modal.confirm(
@@ -308,27 +557,39 @@ const Trash = (() => {
308
557
  failed += 1;
309
558
  }
310
559
  }
311
- alert(I18n.t("trash.orphansCleaned", {
560
+ Modal.toast(I18n.t("trash.orphansCleaned", {
312
561
  count: deleted,
313
562
  size: _humanBytes(freed),
314
563
  failed: failed
315
- }));
316
- await _load();
564
+ }), failed > 0 ? "warning" : "success");
565
+ await _loadFiles();
317
566
  }
318
567
 
568
+ // ── Event wiring ─────────────────────────────────────────────────────
569
+
319
570
  function _wire() {
320
571
  if (_wired) return;
321
572
  _wired = true;
573
+
574
+ // Tab switches
575
+ const tabFile = $("tab-file-trash");
576
+ const tabSession = $("tab-session-trash");
577
+ if (tabFile) tabFile.addEventListener("click", () => _switchTab("file-trash"));
578
+ if (tabSession) tabSession.addEventListener("click", () => _switchTab("session-trash"));
579
+
580
+ // Toolbar buttons
322
581
  const btnRefresh = $("btn-trash-refresh");
323
582
  const btnOld = $("btn-trash-empty-old");
324
583
  const btnOrphans = $("btn-trash-empty-orphans");
325
584
  const btnAll = $("btn-trash-empty-all");
326
585
  if (btnRefresh) btnRefresh.addEventListener("click", () => _load());
327
586
  if (btnOld) btnOld.addEventListener("click",
328
- () => _emptyBulk(7, "trash.confirmEmptyOld"));
587
+ () => _emptyBulk(7, _activeTab === "session-trash"
588
+ ? "trash.confirmEmptySessionOld" : "trash.confirmEmptyOld"));
329
589
  if (btnOrphans) btnOrphans.addEventListener("click", () => _emptyOrphans());
330
590
  if (btnAll) btnAll.addEventListener("click",
331
- () => _emptyBulk(0, "trash.confirmEmptyAll"));
591
+ () => _emptyBulk(0, _activeTab === "session-trash"
592
+ ? "trash.confirmEmptySessionAll" : "trash.confirmEmptyAll"));
332
593
  }
333
594
 
334
595
  // ── Public API ────────────────────────────────────────────────────────
@@ -337,7 +598,9 @@ const Trash = (() => {
337
598
  /** Called by Router when the trash panel becomes active. */
338
599
  onPanelShow() {
339
600
  _wire();
340
- _load();
601
+ // Reset to file-trash tab on each panel show so the user always
602
+ // starts on the familiar "agent files" view.
603
+ _switchTab("file-trash");
341
604
  },
342
605
  };
343
606
  })();
@@ -145,6 +145,16 @@ WS.onEvent(ev => {
145
145
  Sessions.renderList();
146
146
  break;
147
147
 
148
+ case "session_restored":
149
+ // A soft-deleted session was restored from the session trash.
150
+ // Insert it back into the local list (idempotent — Sessions.add no-ops
151
+ // if the id already exists) and re-render the sidebar.
152
+ if (ev.session) {
153
+ Sessions.add(ev.session);
154
+ Sessions.renderList();
155
+ }
156
+ break;
157
+
148
158
  // ── Chat messages ──────────────────────────────────────────────────
149
159
  case "history_user_message":
150
160
  // Emitted only during history replay — never from live WS.
@@ -200,9 +210,12 @@ WS.onEvent(ev => {
200
210
  Sessions.collapseToolGroup();
201
211
  {
202
212
  const costSource = ev.cost_source;
213
+ const symbol = typeof Billing !== "undefined" ? Billing.getCurrencySymbol() : "$";
214
+ const rawCost = ev.cost || 0;
215
+ const cost = typeof Billing !== "undefined" ? Billing.convertCost(rawCost) : rawCost;
203
216
  const costDisplay = (!costSource || costSource === "estimated")
204
217
  ? "N/A"
205
- : `$${(ev.cost || 0).toFixed(4)}`;
218
+ : `${symbol}${cost.toFixed(4)}`;
206
219
  let mainLine = I18n.t("chat.done", { n: ev.iterations, cost: costDisplay });
207
220
  if (typeof ev.duration === "number" && ev.duration > 0) {
208
221
  mainLine += I18n.t("chat.done.duration", { duration: ev.duration.toFixed(1) });
data/lib/clacky.rb CHANGED
@@ -123,6 +123,10 @@ require_relative "clacky/tools/redo_task"
123
123
  require_relative "clacky/tools/list_tasks"
124
124
  require_relative "clacky/tools/browser"
125
125
  require_relative "clacky/tools/terminal"
126
+ require_relative "clacky/mcp/client"
127
+ require_relative "clacky/mcp/virtual_skill"
128
+ require_relative "clacky/mcp/registry"
129
+ require_relative "clacky/mcp/skill_provider"
126
130
  require_relative "clacky/telemetry"
127
131
  require_relative "clacky/agent"
128
132
 
data/scripts/install.ps1 CHANGED
@@ -33,6 +33,8 @@ $global:DisplayName = if ($BrandName) { $BrandName } else { "OpenClacky" }
33
33
  $global:DisplayCmd = if ($CommandName) { $CommandName } else { "openclacky" }
34
34
 
35
35
  $CLACKY_CDN_BASE_URL = "https://oss.1024code.com"
36
+ $CLACKY_CDN_PRIMARY_HOST = "oss.1024code.com"
37
+ $CLACKY_CDN_BACKUP_HOST = "clackyai-1258723534.cos.ap-guangzhou.myqcloud.com"
36
38
  $INSTALL_PS1_COMMAND = "powershell -c `"irm $CLACKY_CDN_BASE_URL/clacky-ai/openclacky/main/scripts/install.ps1 | iex`""
37
39
  $INSTALL_SCRIPT_URL = "$CLACKY_CDN_BASE_URL/clacky-ai/openclacky/main/scripts/install.sh"
38
40
  $UBUNTU_WSL_AMD64_URL = "$CLACKY_CDN_BASE_URL/ubuntu-jammy-wsl-amd64-ubuntu22.04lts.rootfs.tar.gz"
@@ -72,18 +74,24 @@ function Get-SafeTempDir {
72
74
  # Invoke-WebRequest. Returns $true on success, $false on failure.
73
75
  function Invoke-Download {
74
76
  param([string]$Url, [string]$OutFile)
75
- $ok = $false
77
+ $urls = @($Url)
76
78
  try {
77
- curl.exe -L --fail --progress-bar $Url -o $OutFile
78
- $ok = ($LASTEXITCODE -eq 0)
79
+ if (([Uri]$Url).Host -eq $CLACKY_CDN_PRIMARY_HOST) {
80
+ $urls += ([Uri]$Url).AbsoluteUri.Replace($CLACKY_CDN_PRIMARY_HOST, $CLACKY_CDN_BACKUP_HOST)
81
+ }
79
82
  } catch {}
80
- if (-not $ok) {
83
+ foreach ($u in $urls) {
84
+ if ($u -ne $Url) { Write-Warn "Primary download failed, retrying with backup mirror." }
85
+ try {
86
+ curl.exe -L --fail --progress-bar $u -o $OutFile
87
+ if ($LASTEXITCODE -eq 0) { return $true }
88
+ } catch {}
81
89
  try {
82
- Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing
83
- $ok = $true
84
- } catch { $ok = $false }
90
+ Invoke-WebRequest -Uri $u -OutFile $OutFile -UseBasicParsing -TimeoutSec 60
91
+ return $true
92
+ } catch {}
85
93
  }
86
- return $ok
94
+ return $false
87
95
  }
88
96
 
89
97
  # Verify SHA256 of a local file against a remote .sha256 file.
@@ -143,14 +151,18 @@ function Invoke-WslStatusExitCode {
143
151
  }
144
152
 
145
153
  # Returns $true if a distro named exactly "Ubuntu" is registered.
146
- # wsl --list outputs UTF-16LE; switch OutputEncoding to decode correctly.
154
+ # wsl --list outputs UTF-16LE regardless of WSL_UTF8; temporarily clear it and switch
155
+ # OutputEncoding to Unicode so the output decodes correctly.
147
156
  function Test-UbuntuInstalled {
148
- $prev = [Console]::OutputEncoding
157
+ $prevEnc = [Console]::OutputEncoding
158
+ $prevUtf8 = $env:WSL_UTF8
149
159
  [Console]::OutputEncoding = [System.Text.Encoding]::Unicode
160
+ $env:WSL_UTF8 = $null
150
161
  try {
151
162
  $out = (wsl.exe --list --quiet 2>$null) -join "`n"
152
163
  } finally {
153
- [Console]::OutputEncoding = $prev
164
+ [Console]::OutputEncoding = $prevEnc
165
+ $env:WSL_UTF8 = $prevUtf8
154
166
  }
155
167
  # Whole-line match to avoid false positives from Ubuntu-22.04, Ubuntu-24.04, etc.
156
168
  return ($out -match '(?im)^Ubuntu\s*$')