openclacky 1.0.0 โ†’ 1.0.2

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +87 -53
  4. data/lib/clacky/agent/cost_tracker.rb +19 -2
  5. data/lib/clacky/agent/llm_caller.rb +218 -0
  6. data/lib/clacky/agent/message_compressor_helper.rb +32 -2
  7. data/lib/clacky/agent.rb +54 -22
  8. data/lib/clacky/client.rb +44 -5
  9. data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
  10. data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
  11. data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
  12. data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
  13. data/lib/clacky/default_skills/new/SKILL.md +3 -114
  14. data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
  15. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
  16. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
  17. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
  18. data/lib/clacky/message_format/anthropic.rb +72 -8
  19. data/lib/clacky/message_format/bedrock.rb +6 -3
  20. data/lib/clacky/providers.rb +146 -3
  21. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
  22. data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
  23. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
  24. data/lib/clacky/server/channel/channel_manager.rb +12 -4
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
  26. data/lib/clacky/server/http_server.rb +746 -13
  27. data/lib/clacky/server/session_registry.rb +55 -24
  28. data/lib/clacky/skill.rb +10 -9
  29. data/lib/clacky/skill_loader.rb +23 -11
  30. data/lib/clacky/tools/file_reader.rb +232 -127
  31. data/lib/clacky/tools/security.rb +42 -64
  32. data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
  33. data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
  34. data/lib/clacky/tools/terminal/session_manager.rb +8 -3
  35. data/lib/clacky/tools/terminal.rb +263 -16
  36. data/lib/clacky/ui2/layout_manager.rb +8 -1
  37. data/lib/clacky/ui2/output_buffer.rb +83 -23
  38. data/lib/clacky/ui2/ui_controller.rb +74 -7
  39. data/lib/clacky/utils/file_processor.rb +14 -40
  40. data/lib/clacky/utils/model_pricing.rb +215 -0
  41. data/lib/clacky/utils/parser_manager.rb +70 -6
  42. data/lib/clacky/utils/string_matcher.rb +23 -1
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +673 -9
  45. data/lib/clacky/web/app.js +40 -1608
  46. data/lib/clacky/web/i18n.js +209 -0
  47. data/lib/clacky/web/index.html +166 -2
  48. data/lib/clacky/web/onboard.js +77 -1
  49. data/lib/clacky/web/profile.js +442 -0
  50. data/lib/clacky/web/sessions.js +1034 -2
  51. data/lib/clacky/web/settings.js +127 -6
  52. data/lib/clacky/web/sidebar.js +39 -0
  53. data/lib/clacky/web/skills.js +460 -0
  54. data/lib/clacky/web/trash.js +343 -0
  55. data/lib/clacky/web/ws-dispatcher.js +255 -0
  56. data/lib/clacky.rb +5 -3
  57. metadata +16 -17
  58. data/lib/clacky/clacky_auth_client.rb +0 -152
  59. data/lib/clacky/clacky_cloud_config.rb +0 -123
  60. data/lib/clacky/cloud_project_client.rb +0 -169
  61. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
  62. data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
  63. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
  64. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
  65. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
  66. data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
  67. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
  68. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
  69. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
  70. data/lib/clacky/deploy_api_client.rb +0 -484
@@ -0,0 +1,442 @@
1
+ // profile.js โ€” Assistant Memory panel
2
+ //
3
+ // Three tabs, all read-only views with single-action buttons that delegate to
4
+ // the agent via slash-commands:
5
+ //
6
+ // ๐Ÿงฌ Soul โ€” SOUL.md rendered. Button opens /onboard scope:soul.
7
+ // ๐Ÿ‘ค User โ€” USER.md rendered. Button opens /onboard scope:user.
8
+ // ๐Ÿง  Memories โ€” list of ~/.clacky/memories/*.md, sorted by updated_at desc.
9
+ // Per-card "Curate" opens /onboard path:<abs>.
10
+ // Per-card "Delete" calls DELETE /api/memories/:filename
11
+ // (with a confirm); the file lands in File Recall.
12
+ //
13
+ // The single `onboard` skill handles all three slash-command shapes: with no
14
+ // args it runs the full first-run ceremony; with `scope:` or `path:` it runs
15
+ // the corresponding light curate flow.
16
+ //
17
+ // Philosophy: the agent's inner state is never hand-edited. The UI shows it
18
+ // and offers curation buttons that start agent-led flows.
19
+ //
20
+ // Load order: after app.js modules (I18n, Sessions, Onboard), before boot.
21
+
22
+ const Profile = (() => {
23
+ let _wired = false;
24
+ let _activeTab = "soul";
25
+ let _data = { user: null, soul: null, memories: [] };
26
+
27
+ function $(id) { return document.getElementById(id); }
28
+
29
+ function _t(key, args) {
30
+ return (I18n && I18n.t) ? I18n.t(key, args) : key;
31
+ }
32
+
33
+ // โ”€โ”€ Minimal safe Markdown renderer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
34
+ // Handles: # H1 / ## H2 / ### H3, **bold**, *em*, `code`,
35
+ // - / * bullets, numbered lists, blank-line paragraphs.
36
+ // Everything is HTML-escaped first, so raw user Markdown can never
37
+ // inject script/style/event attributes.
38
+
39
+ function _escapeHtml(s) {
40
+ return String(s ?? "")
41
+ .replace(/&/g, "&amp;")
42
+ .replace(/</g, "&lt;")
43
+ .replace(/>/g, "&gt;")
44
+ .replace(/"/g, "&quot;")
45
+ .replace(/'/g, "&#39;");
46
+ }
47
+
48
+ function _renderInline(text) {
49
+ // Inline: **bold**, *em*, `code`. Text is already HTML-escaped by caller.
50
+ return text
51
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
52
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
53
+ .replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, "$1<em>$2</em>");
54
+ }
55
+
56
+ function _renderMarkdown(raw) {
57
+ if (!raw || !raw.trim()) return "";
58
+ const escaped = _escapeHtml(raw);
59
+ const lines = escaped.split(/\r?\n/);
60
+
61
+ const out = [];
62
+ let listType = null; // "ul" | "ol" | null
63
+ let paraBuf = [];
64
+
65
+ function flushPara() {
66
+ if (paraBuf.length === 0) return;
67
+ out.push("<p>" + _renderInline(paraBuf.join(" ")) + "</p>");
68
+ paraBuf = [];
69
+ }
70
+ function openList(type) {
71
+ if (listType !== type) {
72
+ closeList();
73
+ out.push("<" + type + ">");
74
+ listType = type;
75
+ }
76
+ }
77
+ function closeList() {
78
+ if (listType) { out.push("</" + listType + ">"); listType = null; }
79
+ }
80
+
81
+ for (let i = 0; i < lines.length; i++) {
82
+ const line = lines[i];
83
+ const trimmed = line.trim();
84
+
85
+ if (trimmed === "") { flushPara(); closeList(); continue; }
86
+
87
+ let m;
88
+ if ((m = trimmed.match(/^(#{1,3})\s+(.+)$/))) {
89
+ flushPara(); closeList();
90
+ const level = m[1].length;
91
+ out.push(`<h${level}>` + _renderInline(m[2]) + `</h${level}>`);
92
+ continue;
93
+ }
94
+ if ((m = trimmed.match(/^[-*]\s+(.+)$/))) {
95
+ flushPara(); openList("ul");
96
+ out.push("<li>" + _renderInline(m[1]) + "</li>");
97
+ continue;
98
+ }
99
+ if ((m = trimmed.match(/^\d+\.\s+(.+)$/))) {
100
+ flushPara(); openList("ol");
101
+ out.push("<li>" + _renderInline(m[1]) + "</li>");
102
+ continue;
103
+ }
104
+ if (listType) closeList();
105
+ paraBuf.push(trimmed);
106
+ }
107
+ flushPara(); closeList();
108
+ return out.join("\n");
109
+ }
110
+
111
+ function _stripFrontmatter(content) {
112
+ if (!content || !content.startsWith("---")) return content || "";
113
+ const m = content.match(/^---\s*\n[\s\S]*?\n---\s*\n?/);
114
+ return m ? content.slice(m[0].length) : content;
115
+ }
116
+
117
+ function _humanBytes(n) {
118
+ if (!n || n < 0) return "0 B";
119
+ const units = ["B", "KB", "MB"];
120
+ let i = 0;
121
+ while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
122
+ return (i === 0 ? n.toFixed(0) : n.toFixed(2)) + " " + units[i];
123
+ }
124
+
125
+ // โ”€โ”€ Data loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
126
+
127
+ async function _loadProfile() {
128
+ try {
129
+ const res = await fetch("/api/profile");
130
+ const data = await res.json();
131
+ if (!res.ok || !data.ok) throw new Error(data.error || "Load failed");
132
+ _data.user = data.user;
133
+ _data.soul = data.soul;
134
+ } catch (e) {
135
+ console.error("[Profile] load profile failed", e);
136
+ _data.user = null;
137
+ _data.soul = null;
138
+ }
139
+ }
140
+
141
+ async function _loadMemories() {
142
+ try {
143
+ const res = await fetch("/api/memories");
144
+ const data = await res.json();
145
+ if (!res.ok || !data.ok) throw new Error(data.error || "Load failed");
146
+ _data.memories = data.memories || [];
147
+ } catch (e) {
148
+ console.error("[Profile] load memories failed", e);
149
+ _data.memories = [];
150
+ }
151
+ }
152
+
153
+ // โ”€โ”€ Rendering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
154
+
155
+ function _renderIdentitySection(kind) {
156
+ const file = _data[kind];
157
+ const wrap = $(`profile-${kind}-body`);
158
+ const status = $(`profile-${kind}-status`);
159
+ const pathEl = $(`profile-${kind}-path`);
160
+ if (!wrap) return;
161
+
162
+ if (!file) {
163
+ wrap.innerHTML = `<div class="profile-empty">${_t("profile.loadFail")}</div>`;
164
+ if (status) { status.textContent = ""; status.className = "profile-status"; }
165
+ if (pathEl) pathEl.textContent = "";
166
+ return;
167
+ }
168
+
169
+ wrap.innerHTML = _renderMarkdown(file.content || "")
170
+ || `<div class="profile-empty">${_t("profile.emptyContent")}</div>`;
171
+ if (pathEl) pathEl.textContent = file.path || "";
172
+ if (status) {
173
+ status.textContent = file.is_default
174
+ ? _t("profile.statusDefault")
175
+ : _t("profile.statusCustom");
176
+ status.className = "profile-status "
177
+ + (file.is_default ? "profile-status-default" : "profile-status-custom");
178
+ }
179
+ }
180
+
181
+ function _renderMemories() {
182
+ const list = $("memories-list");
183
+ const summary = $("memories-summary");
184
+ if (!list) return;
185
+
186
+ if (summary) {
187
+ summary.textContent = _data.memories.length
188
+ ? _t("memories.summary", { count: _data.memories.length })
189
+ : _t("memories.emptyHint");
190
+ }
191
+
192
+ if (_data.memories.length === 0) {
193
+ list.innerHTML = `<div class="profile-empty">${_t("memories.empty")}</div>`;
194
+ return;
195
+ }
196
+
197
+ list.innerHTML = "";
198
+ _data.memories.forEach(m => list.appendChild(_buildMemoryCard(m)));
199
+ }
200
+
201
+ function _buildMemoryCard(m) {
202
+ const card = document.createElement("div");
203
+ card.className = "memory-card";
204
+ card.dataset.filename = m.filename;
205
+
206
+ const topic = m.topic || m.filename;
207
+ const desc = m.description || "";
208
+ const updated = m.updated_at || "";
209
+ const size = _humanBytes(m.size || 0);
210
+
211
+ const head = document.createElement("div");
212
+ head.className = "memory-card-head";
213
+ head.innerHTML = `
214
+ <div class="memory-card-info">
215
+ <div class="memory-card-title" title="${_escapeHtml(m.filename)}">${_escapeHtml(topic)}</div>
216
+ ${desc ? `<div class="memory-card-desc">${_escapeHtml(desc)}</div>` : ""}
217
+ <div class="memory-card-meta">
218
+ <span class="memory-filename">${_escapeHtml(m.filename)}</span>
219
+ <span>${_escapeHtml(updated)}</span>
220
+ <span>${size}</span>
221
+ </div>
222
+ </div>
223
+ <div class="memory-card-actions">
224
+ <button class="btn-memory-curate" title="${_t("memories.curateTitle")}">
225
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
226
+ <path d="M12 20h9"/>
227
+ <path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
228
+ </svg>
229
+ <span>${_t("memories.curate")}</span>
230
+ </button>
231
+ <button class="btn-memory-delete" title="${_t("memories.deleteTitle")}">
232
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
233
+ <polyline points="3 6 5 6 21 6"/>
234
+ <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
235
+ <path d="M10 11v6"/><path d="M14 11v6"/>
236
+ <path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
237
+ </svg>
238
+ <span>${_t("memories.delete")}</span>
239
+ </button>
240
+ <button class="btn-memory-expand" title="${_t("memories.expandTitle")}" aria-expanded="false">
241
+ <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">
242
+ <polyline points="6 9 12 15 18 9"/>
243
+ </svg>
244
+ </button>
245
+ </div>`;
246
+ card.appendChild(head);
247
+
248
+ // Collapsible body โ€” rendered lazily on first expand.
249
+ const body = document.createElement("div");
250
+ body.className = "memory-card-body";
251
+ body.style.display = "none";
252
+ card.appendChild(body);
253
+
254
+ head.querySelector(".btn-memory-curate")
255
+ .addEventListener("click", (e) => { e.stopPropagation(); _curateMemory(m); });
256
+
257
+ head.querySelector(".btn-memory-delete")
258
+ .addEventListener("click", (e) => { e.stopPropagation(); _deleteMemory(m); });
259
+
260
+ const expandBtn = head.querySelector(".btn-memory-expand");
261
+ function toggle() {
262
+ const open = body.style.display !== "none";
263
+ if (open) {
264
+ body.style.display = "none";
265
+ expandBtn.setAttribute("aria-expanded", "false");
266
+ expandBtn.classList.remove("expanded");
267
+ } else {
268
+ if (!body.dataset.loaded) {
269
+ body.innerHTML = `<div class="memory-card-loading">${_t("memories.loading")}</div>`;
270
+ fetch("/api/memories/" + encodeURIComponent(m.filename))
271
+ .then(r => r.json())
272
+ .then(d => {
273
+ if (!d.ok) throw new Error(d.error || "Load failed");
274
+ const stripped = _stripFrontmatter(d.content || "");
275
+ body.innerHTML = _renderMarkdown(stripped)
276
+ || `<div class="profile-empty">${_t("profile.emptyContent")}</div>`;
277
+ body.dataset.loaded = "1";
278
+ })
279
+ .catch(err => {
280
+ body.innerHTML = `<div class="profile-empty">${_escapeHtml(err.message)}</div>`;
281
+ });
282
+ }
283
+ body.style.display = "";
284
+ expandBtn.setAttribute("aria-expanded", "true");
285
+ expandBtn.classList.add("expanded");
286
+ }
287
+ }
288
+ expandBtn.addEventListener("click", (e) => { e.stopPropagation(); toggle(); });
289
+ // Clicking the info area also toggles, for a larger hit-target.
290
+ head.querySelector(".memory-card-info")
291
+ .addEventListener("click", toggle);
292
+
293
+ return card;
294
+ }
295
+
296
+ // โ”€โ”€ Tabs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
297
+
298
+ function _switchTab(tab) {
299
+ if (!tab || tab === _activeTab) return;
300
+ _activeTab = tab;
301
+
302
+ document.querySelectorAll(".profile-tab").forEach(el => {
303
+ const isActive = el.dataset.tab === tab;
304
+ el.classList.toggle("active", isActive);
305
+ el.setAttribute("aria-selected", isActive ? "true" : "false");
306
+ });
307
+
308
+ ["soul", "user", "memories"].forEach(name => {
309
+ const pane = $(`profile-pane-${name}`);
310
+ if (!pane) return;
311
+ const isActive = name === tab;
312
+ pane.classList.toggle("active", isActive);
313
+ pane.style.display = isActive ? "" : "none";
314
+ });
315
+ }
316
+
317
+ // โ”€โ”€ Actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
318
+
319
+ // Curate one of the identity files via /onboard scope:<soul|user>.
320
+ async function _curateProfile(scope) {
321
+ const btn = $(`btn-profile-curate-${scope}`);
322
+ if (btn) btn.disabled = true;
323
+ try {
324
+ const lang = (I18n && I18n.lang) ? I18n.lang() : "en";
325
+ const sessionName = _t(
326
+ scope === "soul" ? "profile.curateName.soul" : "profile.curateName.user"
327
+ );
328
+ const res = await fetch("/api/sessions", {
329
+ method: "POST",
330
+ headers: { "Content-Type": "application/json" },
331
+ body: JSON.stringify({ name: sessionName, source: "onboard" })
332
+ });
333
+ const data = await res.json();
334
+ const session = data.session;
335
+ if (!session) throw new Error("No session returned");
336
+
337
+ Sessions.add(session);
338
+ Sessions.renderList();
339
+ Sessions.setPendingMessage(
340
+ session.id,
341
+ `/onboard scope:${scope} lang:${lang}`
342
+ );
343
+ Sessions.select(session.id);
344
+ } catch (e) {
345
+ console.error("[Profile] curate profile failed", e);
346
+ alert(_t("profile.curateFail") + ": " + e.message);
347
+ if (btn) btn.disabled = false;
348
+ }
349
+ }
350
+
351
+ // Curate a single memory โ†’ /onboard path:<abs> session.
352
+ async function _curateMemory(m) {
353
+ const absPath = m.path || ("~/.clacky/memories/" + m.filename);
354
+ try {
355
+ const name = _t("memories.curateName") + " ยท " + (m.topic || m.filename);
356
+ const res = await fetch("/api/sessions", {
357
+ method: "POST",
358
+ headers: { "Content-Type": "application/json" },
359
+ body: JSON.stringify({ name, source: "onboard" })
360
+ });
361
+ const data = await res.json();
362
+ const session = data.session;
363
+ if (!session) throw new Error("No session returned");
364
+
365
+ Sessions.add(session);
366
+ Sessions.renderList();
367
+ Sessions.setPendingMessage(session.id, `/onboard path:${absPath}`);
368
+ Sessions.select(session.id);
369
+ } catch (e) {
370
+ console.error("[Profile] curate memory failed", e);
371
+ alert(_t("memories.curateFail") + ": " + e.message);
372
+ }
373
+ }
374
+
375
+ // Delete a memory directly. Backend uses `trash` semantics so it lands in
376
+ // File Recall and can still be recovered.
377
+ async function _deleteMemory(m) {
378
+ const label = m.topic || m.filename;
379
+ if (!confirm(_t("memories.confirmDelete", { name: label }))) return;
380
+
381
+ try {
382
+ const res = await fetch(
383
+ "/api/memories/" + encodeURIComponent(m.filename),
384
+ { method: "DELETE" }
385
+ );
386
+ const data = await res.json().catch(() => ({}));
387
+ if (!res.ok || !data.ok) {
388
+ throw new Error(data.error || `HTTP ${res.status}`);
389
+ }
390
+ // Optimistic local remove + re-render.
391
+ _data.memories = _data.memories.filter(x => x.filename !== m.filename);
392
+ _renderMemories();
393
+ } catch (e) {
394
+ console.error("[Profile] delete memory failed", e);
395
+ alert(_t("memories.deleteFail") + ": " + e.message);
396
+ }
397
+ }
398
+
399
+ // โ”€โ”€ Wiring โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
400
+
401
+ function _wire() {
402
+ if (_wired) return;
403
+ _wired = true;
404
+
405
+ // Tabs
406
+ document.querySelectorAll(".profile-tab").forEach(el => {
407
+ el.addEventListener("click", () => _switchTab(el.dataset.tab));
408
+ });
409
+
410
+ // Per-tab curate buttons
411
+ const soulBtn = $("btn-profile-curate-soul");
412
+ if (soulBtn) soulBtn.addEventListener("click", () => _curateProfile("soul"));
413
+ const userBtn = $("btn-profile-curate-user");
414
+ if (userBtn) userBtn.addEventListener("click", () => _curateProfile("user"));
415
+
416
+ // Memories list reload
417
+ const refreshMemBtn = $("btn-memories-refresh-list");
418
+ if (refreshMemBtn) refreshMemBtn.addEventListener("click", () => _loadAndRender());
419
+ }
420
+
421
+ async function _loadAndRender() {
422
+ await Promise.all([_loadProfile(), _loadMemories()]);
423
+ _renderIdentitySection("soul");
424
+ _renderIdentitySection("user");
425
+ _renderMemories();
426
+ }
427
+
428
+ // โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
429
+
430
+ return {
431
+ onPanelShow() {
432
+ _wire();
433
+ // Re-enable curate buttons on every panel entry โ€” they may have been
434
+ // disabled by a prior click that navigated away.
435
+ ["soul", "user"].forEach(s => {
436
+ const b = $(`btn-profile-curate-${s}`);
437
+ if (b) b.disabled = false;
438
+ });
439
+ _loadAndRender();
440
+ }
441
+ };
442
+ })();