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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/CODE_OF_CONDUCT.md +1 -1
- data/CONTRIBUTING.md +92 -0
- data/README.md +10 -0
- data/README_CN.md +10 -0
- data/ROADMAP.md +29 -0
- data/docs/billing-system.md +340 -0
- data/docs/mcp-architecture.md +114 -0
- data/docs/mcp.example.json +22 -0
- data/lib/clacky/agent/cost_tracker.rb +37 -0
- data/lib/clacky/agent/llm_caller.rb +0 -1
- data/lib/clacky/agent/session_serializer.rb +2 -11
- data/lib/clacky/agent/skill_manager.rb +73 -26
- data/lib/clacky/agent/system_prompt_builder.rb +0 -5
- data/lib/clacky/agent/time_machine.rb +6 -0
- data/lib/clacky/agent.rb +26 -1
- data/lib/clacky/agent_config.rb +9 -19
- data/lib/clacky/billing/billing_record.rb +67 -0
- data/lib/clacky/billing/billing_store.rb +193 -0
- data/lib/clacky/cli.rb +108 -6
- data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
- data/lib/clacky/idle_compression_timer.rb +4 -2
- data/lib/clacky/mcp/client.rb +204 -0
- data/lib/clacky/mcp/http_transport.rb +155 -0
- data/lib/clacky/mcp/registry.rb +229 -0
- data/lib/clacky/mcp/skill_provider.rb +75 -0
- data/lib/clacky/mcp/stdio_transport.rb +112 -0
- data/lib/clacky/mcp/transport.rb +23 -0
- data/lib/clacky/mcp/virtual_skill.rb +131 -0
- data/lib/clacky/message_history.rb +0 -1
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
- data/lib/clacky/server/http_server.rb +519 -15
- data/lib/clacky/server/server_master.rb +8 -14
- data/lib/clacky/server/session_registry.rb +24 -2
- data/lib/clacky/server/web_ui_controller.rb +4 -0
- data/lib/clacky/session_manager.rb +41 -12
- data/lib/clacky/skill.rb +1 -5
- data/lib/clacky/skill_loader.rb +36 -5
- data/lib/clacky/tools/browser.rb +217 -38
- data/lib/clacky/tools/trash_manager.rb +154 -3
- data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
- data/lib/clacky/ui_interface.rb +1 -0
- data/lib/clacky/utils/model_pricing.rb +11 -7
- data/lib/clacky/utils/trash_directory.rb +37 -6
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2907 -1764
- data/lib/clacky/web/app.js +84 -10
- data/lib/clacky/web/billing.js +275 -0
- data/lib/clacky/web/brand.js +3 -0
- data/lib/clacky/web/i18n.js +242 -24
- data/lib/clacky/web/index.html +351 -134
- data/lib/clacky/web/mcp.js +328 -0
- data/lib/clacky/web/sessions.js +193 -11
- data/lib/clacky/web/settings.js +686 -174
- data/lib/clacky/web/sidebar.js +2 -0
- data/lib/clacky/web/trash.js +323 -60
- data/lib/clacky/web/ws-dispatcher.js +14 -1
- data/lib/clacky.rb +4 -0
- data/scripts/install.ps1 +23 -11
- metadata +30 -10
data/lib/clacky/web/sidebar.js
CHANGED
|
@@ -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
|
|
data/lib/clacky/web/trash.js
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
|
-
// trash.js —
|
|
1
|
+
// trash.js — File Recall & Session Recycle Bin
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
7
|
-
//
|
|
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
|
|
16
|
-
let
|
|
17
|
-
let
|
|
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
|
-
|
|
51
|
-
if (mins <
|
|
52
|
-
if (
|
|
53
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
170
|
+
_files.forEach(f => list.appendChild(_buildFileCard(f)));
|
|
115
171
|
}
|
|
116
172
|
|
|
117
|
-
function
|
|
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
|
|
134
|
-
//
|
|
135
|
-
// We
|
|
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
|
-
|
|
229
|
+
_restoreFile(file, card));
|
|
174
230
|
card.querySelector(".btn-trash-delete").addEventListener("click", () =>
|
|
175
|
-
|
|
231
|
+
_deleteFile(file, card));
|
|
176
232
|
|
|
177
233
|
return card;
|
|
178
234
|
}
|
|
179
235
|
|
|
180
|
-
|
|
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
|
-
|
|
328
|
+
Modal.toast(I18n.t("trash.restoreFail", {
|
|
195
329
|
msg: data.error || res.statusText
|
|
196
|
-
}));
|
|
330
|
+
}), "error");
|
|
197
331
|
} else {
|
|
198
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
491
|
+
Modal.toast(I18n.t("trash.cleanFail", { msg: data.error || res.statusText }), "error");
|
|
258
492
|
return;
|
|
259
493
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
516
|
+
Modal.toast(I18n.t("trash.sessionsCleaned", {
|
|
517
|
+
count: data.deleted_count || 0
|
|
518
|
+
}), "success");
|
|
519
|
+
await _loadSessions();
|
|
269
520
|
} catch (e) {
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
560
|
+
Modal.toast(I18n.t("trash.orphansCleaned", {
|
|
312
561
|
count: deleted,
|
|
313
562
|
size: _humanBytes(freed),
|
|
314
563
|
failed: failed
|
|
315
|
-
}));
|
|
316
|
-
await
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
$
|
|
77
|
+
$urls = @($Url)
|
|
76
78
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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 $
|
|
83
|
-
|
|
84
|
-
} catch {
|
|
90
|
+
Invoke-WebRequest -Uri $u -OutFile $OutFile -UseBasicParsing -TimeoutSec 60
|
|
91
|
+
return $true
|
|
92
|
+
} catch {}
|
|
85
93
|
}
|
|
86
|
-
return $
|
|
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;
|
|
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
|
-
$
|
|
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 = $
|
|
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*$')
|