openclacky 1.3.1 → 1.3.3

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/lib/clacky/agent/session_serializer.rb +65 -11
  7. data/lib/clacky/agent/time_machine.rb +247 -26
  8. data/lib/clacky/agent.rb +12 -1
  9. data/lib/clacky/agent_config.rb +14 -2
  10. data/lib/clacky/brand_config.rb +1 -1
  11. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  12. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  13. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  14. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  15. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  16. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  17. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  18. data/lib/clacky/media/openai_compat.rb +64 -1
  19. data/lib/clacky/media/output_dir.rb +43 -0
  20. data/lib/clacky/message_history.rb +9 -0
  21. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  22. data/lib/clacky/server/git_panel.rb +115 -0
  23. data/lib/clacky/server/http_server.rb +521 -13
  24. data/lib/clacky/server/server_master.rb +6 -4
  25. data/lib/clacky/utils/environment_detector.rb +16 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +512 -60
  28. data/lib/clacky/web/app.js +30 -7
  29. data/lib/clacky/web/components/code-editor.js +197 -0
  30. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  31. data/lib/clacky/web/core/aside.js +112 -0
  32. data/lib/clacky/web/core/ext.js +387 -0
  33. data/lib/clacky/web/features/backup/store.js +92 -0
  34. data/lib/clacky/web/features/backup/view.js +94 -0
  35. data/lib/clacky/web/features/billing/store.js +163 -0
  36. data/lib/clacky/web/{billing.js → features/billing/view.js} +134 -242
  37. data/lib/clacky/web/features/brand/store.js +110 -0
  38. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  39. data/lib/clacky/web/features/channels/store.js +103 -0
  40. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  41. data/lib/clacky/web/features/creator/store.js +81 -0
  42. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  43. data/lib/clacky/web/features/mcp/store.js +158 -0
  44. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  45. data/lib/clacky/web/features/model-tester/store.js +77 -0
  46. data/lib/clacky/web/features/model-tester/view.js +7 -0
  47. data/lib/clacky/web/features/profile/store.js +170 -0
  48. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  49. data/lib/clacky/web/features/share/store.js +145 -0
  50. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  51. data/lib/clacky/web/features/skills/store.js +303 -0
  52. data/lib/clacky/web/features/skills/view.js +550 -0
  53. data/lib/clacky/web/features/tasks/store.js +135 -0
  54. data/lib/clacky/web/features/tasks/view.js +241 -0
  55. data/lib/clacky/web/features/trash/store.js +242 -0
  56. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  57. data/lib/clacky/web/features/version/store.js +165 -0
  58. data/lib/clacky/web/features/version/view.js +323 -0
  59. data/lib/clacky/web/features/workspace/store.js +99 -0
  60. data/lib/clacky/web/features/workspace/view.js +305 -0
  61. data/lib/clacky/web/i18n.js +60 -6
  62. data/lib/clacky/web/index.html +117 -57
  63. data/lib/clacky/web/sessions.js +221 -25
  64. data/lib/clacky/web/settings.js +121 -25
  65. data/lib/clacky/web/skills.js +3 -821
  66. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  67. data/lib/clacky.rb +1 -0
  68. metadata +45 -20
  69. data/lib/clacky/web/backup.js +0 -119
  70. data/lib/clacky/web/model-tester.js +0 -66
  71. data/lib/clacky/web/tasks.js +0 -365
  72. data/lib/clacky/web/version.js +0 -449
  73. data/lib/clacky/web/workspace.js +0 -212
  74. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  75. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  76. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  77. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  78. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -0,0 +1,170 @@
1
+ // ── Profile · store — Soul/User/memories data + curate/delete network ─────
2
+ //
3
+ // Owns the profile data (soul / user identity files, memories list) and the
4
+ // network calls: load profile, load memories, fetch one memory, delete a
5
+ // memory, and open agent-led curate sessions. It never renders.
6
+ //
7
+ // Emits store events the view reacts to; mirrors them to the extension bus via
8
+ // Clacky.ext.emit.
9
+ //
10
+ // `Profile` stays the single public facade.
11
+ //
12
+ // Depends on: Sessions, I18n, Clacky.ext.
13
+ // ───────────────────────────────────────────────────────────────────────────
14
+
15
+ const ProfileStore = (() => {
16
+ let _data = { user: null, soul: null, memories: [] };
17
+
18
+ const _listeners = {};
19
+
20
+ function _on(event, handler) {
21
+ (_listeners[event] ||= []).push(handler);
22
+ return () => {
23
+ const list = _listeners[event];
24
+ const i = list ? list.indexOf(handler) : -1;
25
+ if (i >= 0) list.splice(i, 1);
26
+ };
27
+ }
28
+
29
+ function _emit(event, payload) {
30
+ (_listeners[event] || []).forEach((h) => h(payload));
31
+ if (window.Clacky && Clacky.ext) Clacky.ext.emit(event, payload);
32
+ }
33
+
34
+ const state = {
35
+ get user() { return _data.user; },
36
+ get soul() { return _data.soul; },
37
+ get memories() { return _data.memories; },
38
+ };
39
+
40
+ async function _loadProfile() {
41
+ try {
42
+ const res = await fetch("/api/profile");
43
+ const data = await res.json();
44
+ if (!res.ok || !data.ok) throw new Error(data.error || "Load failed");
45
+ _data.user = data.user;
46
+ _data.soul = data.soul;
47
+ } catch (e) {
48
+ console.error("[Profile] load profile failed", e);
49
+ _data.user = null;
50
+ _data.soul = null;
51
+ }
52
+ }
53
+
54
+ async function _loadMemories() {
55
+ try {
56
+ const res = await fetch("/api/memories");
57
+ const data = await res.json();
58
+ if (!res.ok || !data.ok) throw new Error(data.error || "Load failed");
59
+ _data.memories = data.memories || [];
60
+ } catch (e) {
61
+ console.error("[Profile] load memories failed", e);
62
+ _data.memories = [];
63
+ }
64
+ }
65
+
66
+ async function _openCurateSession(name, command) {
67
+ const res = await fetch("/api/sessions", {
68
+ method: "POST",
69
+ headers: { "Content-Type": "application/json" },
70
+ body: JSON.stringify({ name, source: "onboard" })
71
+ });
72
+ const data = await res.json();
73
+ const session = data.session;
74
+ if (!session) throw new Error("No session returned");
75
+
76
+ Sessions.add(session);
77
+ Sessions.renderList();
78
+ Sessions.setPendingMessage(session.id, command);
79
+ Sessions.select(session.id);
80
+ }
81
+
82
+ const Profile = {
83
+ on: _on,
84
+ state,
85
+
86
+ async loadAll() {
87
+ await Promise.all([_loadProfile(), _loadMemories()]);
88
+ _emit("profile:changed");
89
+ },
90
+
91
+ /** Fetch one memory's raw content. Returns { ok, content, error }. */
92
+ async fetchMemory(filename) {
93
+ try {
94
+ const res = await fetch("/api/memories/" + encodeURIComponent(filename));
95
+ const data = await res.json();
96
+ if (!data.ok) throw new Error(data.error || "Load failed");
97
+ return { ok: true, content: data.content || "" };
98
+ } catch (e) {
99
+ return { ok: false, error: e.message };
100
+ }
101
+ },
102
+
103
+ /** Delete a memory (trash semantics). Returns { ok, error }. */
104
+ async deleteMemory(filename) {
105
+ try {
106
+ const res = await fetch("/api/memories/" + encodeURIComponent(filename), { method: "DELETE" });
107
+ const data = await res.json().catch(() => ({}));
108
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`);
109
+ _data.memories = _data.memories.filter(x => x.filename !== filename);
110
+ _emit("profile:memoriesChanged");
111
+ return { ok: true };
112
+ } catch (e) {
113
+ console.error("[Profile] delete memory failed", e);
114
+ return { ok: false, error: e.message };
115
+ }
116
+ },
117
+
118
+ /** Open an /onboard scope:<soul|user> curate session. */
119
+ async curateProfile(scope) {
120
+ const lang = (I18n && I18n.lang) ? I18n.lang() : "en";
121
+ const sessionName = (I18n && I18n.t)
122
+ ? I18n.t(scope === "soul" ? "profile.curateName.soul" : "profile.curateName.user")
123
+ : scope;
124
+ await _openCurateSession(sessionName, `/onboard scope:${scope} lang:${lang}`);
125
+ },
126
+
127
+ /** Update SOUL.md or USER.md content. Returns { ok, error }. */
128
+ async updateProfile(kind, content) {
129
+ const res = await fetch("/api/profile", {
130
+ method: "PUT",
131
+ headers: { "Content-Type": "application/json" },
132
+ body: JSON.stringify({ kind, content })
133
+ });
134
+ const data = await res.json();
135
+ if (!res.ok || !data.ok) throw new Error(data.error || "Save failed");
136
+ await Profile.loadAll();
137
+ },
138
+
139
+ /** Update a memory file's content. Returns { ok, error }. */
140
+ async updateMemory(filename, content) {
141
+ try {
142
+ const res = await fetch("/api/memories/" + encodeURIComponent(filename), {
143
+ method: "PUT",
144
+ headers: { "Content-Type": "application/json" },
145
+ body: JSON.stringify({ content })
146
+ });
147
+ const data = await res.json();
148
+ if (!res.ok || !data.ok) throw new Error(data.error || "Save failed");
149
+ await _loadMemories();
150
+ _emit("profile:memoriesChanged");
151
+ return { ok: true };
152
+ } catch (e) {
153
+ console.error("[Profile] update memory failed", e);
154
+ return { ok: false, error: e.message };
155
+ }
156
+ },
157
+
158
+ /** Open an /onboard path:<abs> curate session for a memory. */
159
+ async curateMemory(m) {
160
+ const absPath = m.path || ("~/.clacky/memories/" + m.filename);
161
+ const base = (I18n && I18n.t) ? I18n.t("memories.curateName") : "Curate";
162
+ const name = base + " · " + (m.topic || m.filename);
163
+ await _openCurateSession(name, `/onboard path:${absPath}`);
164
+ },
165
+ };
166
+
167
+ return Profile;
168
+ })();
169
+
170
+ const Profile = ProfileStore;
@@ -1,28 +1,17 @@
1
- // profile.jsAssistant Memory panel
1
+ // ── Profile · view markdown render, tabs, memory cards, DOM wiring ───────
2
2
  //
3
- // Three tabs, all read-only views with single-action buttons that delegate to
4
- // the agent via slash-commands:
3
+ // Owns the safe Markdown renderer, identity/memory rendering, tab switching,
4
+ // and all DOM wiring. Reads through ProfileStore.state; load/curate/delete go
5
+ // through store actions. Confirm dialogs and alerts (UI concerns) live here.
5
6
  //
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.
7
+ // Augments the `Profile` facade with onPanelShow.
12
8
  //
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.
9
+ // Depends on: ProfileStore, I18n.
10
+ // ───────────────────────────────────────────────────────────────────────────
21
11
 
22
- const Profile = (() => {
12
+ const ProfileView = (() => {
23
13
  let _wired = false;
24
14
  let _activeTab = "soul";
25
- let _data = { user: null, soul: null, memories: [] };
26
15
 
27
16
  function $(id) { return document.getElementById(id); }
28
17
 
@@ -30,11 +19,8 @@ const Profile = (() => {
30
19
  return (I18n && I18n.t) ? I18n.t(key, args) : key;
31
20
  }
32
21
 
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.
22
+ // ── Minimal safe Markdown renderer ──────────────────────────────────────
23
+ // HTML-escapes first, so raw Markdown can never inject script/style/events.
38
24
 
39
25
  function _escapeHtml(s) {
40
26
  return String(s ?? "")
@@ -46,7 +32,6 @@ const Profile = (() => {
46
32
  }
47
33
 
48
34
  function _renderInline(text) {
49
- // Inline: **bold**, *em*, `code`. Text is already HTML-escaped by caller.
50
35
  return text
51
36
  .replace(/`([^`]+)`/g, "<code>$1</code>")
52
37
  .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
@@ -59,7 +44,7 @@ const Profile = (() => {
59
44
  const lines = escaped.split(/\r?\n/);
60
45
 
61
46
  const out = [];
62
- let listType = null; // "ul" | "ol" | null
47
+ let listType = null;
63
48
  let paraBuf = [];
64
49
 
65
50
  function flushPara() {
@@ -122,38 +107,10 @@ const Profile = (() => {
122
107
  return (i === 0 ? n.toFixed(0) : n.toFixed(2)) + " " + units[i];
123
108
  }
124
109
 
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 ────────────────────────────────────────────────────────
110
+ // ── Rendering ────────────────────────────────────────────────────────────
154
111
 
155
112
  function _renderIdentitySection(kind) {
156
- const file = _data[kind];
113
+ const file = ProfileStore.state[kind];
157
114
  const wrap = $(`profile-${kind}-body`);
158
115
  const status = $(`profile-${kind}-status`);
159
116
  const pathEl = $(`profile-${kind}-path`);
@@ -183,19 +140,20 @@ const Profile = (() => {
183
140
  const summary = $("memories-summary");
184
141
  if (!list) return;
185
142
 
143
+ const memories = ProfileStore.state.memories;
186
144
  if (summary) {
187
- summary.textContent = _data.memories.length
188
- ? _t("memories.summary", { count: _data.memories.length })
145
+ summary.textContent = memories.length
146
+ ? _t("memories.summary", { count: memories.length })
189
147
  : _t("memories.emptyHint");
190
148
  }
191
149
 
192
- if (_data.memories.length === 0) {
150
+ if (memories.length === 0) {
193
151
  list.innerHTML = `<div class="profile-empty">${_t("memories.empty")}</div>`;
194
152
  return;
195
153
  }
196
154
 
197
155
  list.innerHTML = "";
198
- _data.memories.forEach(m => list.appendChild(_buildMemoryCard(m)));
156
+ memories.forEach(m => list.appendChild(_buildMemoryCard(m)));
199
157
  }
200
158
 
201
159
  function _buildMemoryCard(m) {
@@ -222,11 +180,19 @@ const Profile = (() => {
222
180
  </div>
223
181
  <div class="memory-card-actions">
224
182
  <button class="btn-memory-curate" title="${_t("memories.curateTitle")}">
183
+ <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">
184
+ <path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/>
185
+ <path d="M17.8 11.8 19 13"/><path d="M15 9h.01"/><path d="M17.8 6.2 19 5"/>
186
+ <path d="m3 21 9-9"/><path d="M12.2 6.2 11 5"/>
187
+ </svg>
188
+ <span>${_t("memories.curate")}</span>
189
+ </button>
190
+ <button class="btn-memory-edit" title="${_t("memories.edit")}">
225
191
  <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
192
  <path d="M12 20h9"/>
227
193
  <path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
228
194
  </svg>
229
- <span>${_t("memories.curate")}</span>
195
+ <span>${_t("memories.edit")}</span>
230
196
  </button>
231
197
  <button class="btn-memory-delete" title="${_t("memories.deleteTitle")}">
232
198
  <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">
@@ -245,12 +211,14 @@ const Profile = (() => {
245
211
  </div>`;
246
212
  card.appendChild(head);
247
213
 
248
- // Collapsible body — rendered lazily on first expand.
249
214
  const body = document.createElement("div");
250
215
  body.className = "memory-card-body";
251
216
  body.style.display = "none";
252
217
  card.appendChild(body);
253
218
 
219
+ head.querySelector(".btn-memory-edit")
220
+ .addEventListener("click", (e) => { e.stopPropagation(); _editMemory(m); });
221
+
254
222
  head.querySelector(".btn-memory-curate")
255
223
  .addEventListener("click", (e) => { e.stopPropagation(); _curateMemory(m); });
256
224
 
@@ -267,18 +235,16 @@ const Profile = (() => {
267
235
  } else {
268
236
  if (!body.dataset.loaded) {
269
237
  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
- });
238
+ Profile.fetchMemory(m.filename).then(res => {
239
+ if (!res.ok) {
240
+ body.innerHTML = `<div class="profile-empty">${_escapeHtml(res.error)}</div>`;
241
+ return;
242
+ }
243
+ const stripped = _stripFrontmatter(res.content);
244
+ body.innerHTML = _renderMarkdown(stripped)
245
+ || `<div class="profile-empty">${_t("profile.emptyContent")}</div>`;
246
+ body.dataset.loaded = "1";
247
+ });
282
248
  }
283
249
  body.style.display = "";
284
250
  expandBtn.setAttribute("aria-expanded", "true");
@@ -286,14 +252,12 @@ const Profile = (() => {
286
252
  }
287
253
  }
288
254
  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);
255
+ head.querySelector(".memory-card-info").addEventListener("click", toggle);
292
256
 
293
257
  return card;
294
258
  }
295
259
 
296
- // ── Tabs ─────────────────────────────────────────────────────────────
260
+ // ── Tabs ─────────────────────────────────────────────────────────────────
297
261
 
298
262
  function _switchTab(tab) {
299
263
  if (!tab || tab === _activeTab) return;
@@ -314,33 +278,13 @@ const Profile = (() => {
314
278
  });
315
279
  }
316
280
 
317
- // ── Actions ──────────────────────────────────────────────────────────
281
+ // ── Actions (UI side, delegating to store) ───────────────────────────────
318
282
 
319
- // Curate one of the identity files via /onboard scope:<soul|user>.
320
283
  async function _curateProfile(scope) {
321
284
  const btn = $(`btn-profile-curate-${scope}`);
322
285
  if (btn) btn.disabled = true;
323
286
  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);
287
+ await Profile.curateProfile(scope);
344
288
  } catch (e) {
345
289
  console.error("[Profile] curate profile failed", e);
346
290
  alert(_t("profile.curateFail") + ": " + e.message);
@@ -348,52 +292,49 @@ const Profile = (() => {
348
292
  }
349
293
  }
350
294
 
351
- // Curate a single memory → /onboard path:<abs> session.
295
+ async function _editMemory(m) {
296
+ const res = await Profile.fetchMemory(m.filename);
297
+ if (!res.ok) { alert(_t("memories.curateFail") + ": " + res.error); return; }
298
+
299
+ CodeEditor.open({
300
+ content: res.content,
301
+ title: m.topic || m.filename,
302
+ language: "markdown",
303
+ onSave: async (newContent) => {
304
+ const r = await Profile.updateMemory(m.filename, newContent);
305
+ if (!r.ok) throw new Error(r.error);
306
+ }
307
+ });
308
+ }
309
+
352
310
  async function _curateMemory(m) {
353
- const absPath = m.path || ("~/.clacky/memories/" + m.filename);
354
311
  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);
312
+ await Profile.curateMemory(m);
369
313
  } catch (e) {
370
314
  console.error("[Profile] curate memory failed", e);
371
315
  alert(_t("memories.curateFail") + ": " + e.message);
372
316
  }
373
317
  }
374
318
 
375
- // Delete a memory directly. Backend uses `trash` semantics so it lands in
376
- // File Recall and can still be recovered.
377
319
  async function _deleteMemory(m) {
378
320
  const label = m.topic || m.filename;
379
321
  if (!confirm(_t("memories.confirmDelete", { name: label }))) return;
322
+ const res = await Profile.deleteMemory(m.filename);
323
+ if (!res.ok) alert(_t("memories.deleteFail") + ": " + res.error);
324
+ }
380
325
 
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
- }
326
+ function _editProfile(kind) {
327
+ const file = ProfileStore.state[kind];
328
+ if (!file) return;
329
+
330
+ const title = kind === "soul" ? _t("profile.whoIAm") : _t("profile.whoYouAre");
331
+
332
+ CodeEditor.open({
333
+ content: file.content || "",
334
+ title,
335
+ language: "markdown",
336
+ onSave: (newContent) => Profile.updateProfile(kind, newContent)
337
+ });
397
338
  }
398
339
 
399
340
  // ── Wiring ───────────────────────────────────────────────────────────
@@ -402,41 +343,50 @@ const Profile = (() => {
402
343
  if (_wired) return;
403
344
  _wired = true;
404
345
 
405
- // Tabs
406
346
  document.querySelectorAll(".profile-tab").forEach(el => {
407
347
  el.addEventListener("click", () => _switchTab(el.dataset.tab));
408
348
  });
409
349
 
410
- // Per-tab curate buttons
411
350
  const soulBtn = $("btn-profile-curate-soul");
412
351
  if (soulBtn) soulBtn.addEventListener("click", () => _curateProfile("soul"));
413
352
  const userBtn = $("btn-profile-curate-user");
414
353
  if (userBtn) userBtn.addEventListener("click", () => _curateProfile("user"));
415
354
 
355
+ // Per-tab direct edit buttons
356
+ const editSoulBtn = $("btn-profile-edit-soul");
357
+ if (editSoulBtn) editSoulBtn.addEventListener("click", () => _editProfile("soul"));
358
+ const editUserBtn = $("btn-profile-edit-user");
359
+ if (editUserBtn) editUserBtn.addEventListener("click", () => _editProfile("user"));
360
+
416
361
  // Memories list reload
417
362
  const refreshMemBtn = $("btn-memories-refresh-list");
418
- if (refreshMemBtn) refreshMemBtn.addEventListener("click", () => _loadAndRender());
363
+ if (refreshMemBtn) refreshMemBtn.addEventListener("click", () => Profile.loadAll());
419
364
  }
420
365
 
421
- async function _loadAndRender() {
422
- await Promise.all([_loadProfile(), _loadMemories()]);
366
+ function _renderAll() {
423
367
  _renderIdentitySection("soul");
424
368
  _renderIdentitySection("user");
425
369
  _renderMemories();
426
370
  }
427
371
 
428
- // ── Public API ────────────────────────────────────────────────────────
372
+ function _subscribe() {
373
+ Profile.on("profile:changed", _renderAll);
374
+ Profile.on("profile:memoriesChanged", _renderMemories);
375
+ }
429
376
 
430
- return {
377
+ const viewApi = {
431
378
  onPanelShow() {
432
379
  _wire();
433
- // Re-enable curate buttons on every panel entry — they may have been
434
- // disabled by a prior click that navigated away.
435
380
  ["soul", "user"].forEach(s => {
436
381
  const b = $(`btn-profile-curate-${s}`);
437
382
  if (b) b.disabled = false;
438
383
  });
439
- _loadAndRender();
384
+ Profile.loadAll();
440
385
  }
441
386
  };
387
+
388
+ return { init: _subscribe, api: viewApi };
442
389
  })();
390
+
391
+ Object.assign(Profile, ProfileView.api);
392
+ ProfileView.init();