@christopherlittle51/postclaw 1.3.0 → 1.3.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 (48) hide show
  1. package/README.md +69 -695
  2. package/dashboard/README.md +89 -0
  3. package/dashboard/public/app.js +903 -542
  4. package/dashboard/public/index.html +184 -73
  5. package/dashboard/public/styles.css +583 -126
  6. package/dist/dashboard/routes/graph.d.ts +2 -2
  7. package/dist/dashboard/routes/graph.d.ts.map +1 -1
  8. package/dist/dashboard/routes/graph.js +61 -23
  9. package/dist/dashboard/routes/graph.js.map +1 -1
  10. package/dist/dashboard/routes/memories.d.ts.map +1 -1
  11. package/dist/dashboard/routes/memories.js +66 -0
  12. package/dist/dashboard/routes/memories.js.map +1 -1
  13. package/dist/dashboard/routes/workspace.d.ts +4 -3
  14. package/dist/dashboard/routes/workspace.d.ts.map +1 -1
  15. package/dist/dashboard/routes/workspace.js +119 -7
  16. package/dist/dashboard/routes/workspace.js.map +1 -1
  17. package/dist/dashboard/server.js +1 -1
  18. package/dist/dashboard/server.js.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +84 -27
  21. package/dist/index.js.map +1 -1
  22. package/dist/schemas/validation.d.ts +32 -3
  23. package/dist/schemas/validation.d.ts.map +1 -1
  24. package/dist/schemas/validation.js +22 -4
  25. package/dist/schemas/validation.js.map +1 -1
  26. package/dist/scripts/setup-db.d.ts.map +1 -1
  27. package/dist/scripts/setup-db.js +28 -30
  28. package/dist/scripts/setup-db.js.map +1 -1
  29. package/dist/scripts/sleep_cycle.d.ts +1 -0
  30. package/dist/scripts/sleep_cycle.d.ts.map +1 -1
  31. package/dist/scripts/sleep_cycle.js +114 -21
  32. package/dist/scripts/sleep_cycle.js.map +1 -1
  33. package/dist/services/config.d.ts +1 -0
  34. package/dist/services/config.d.ts.map +1 -1
  35. package/dist/services/config.js +5 -22
  36. package/dist/services/config.js.map +1 -1
  37. package/dist/services/memoryService.d.ts +2 -1
  38. package/dist/services/memoryService.d.ts.map +1 -1
  39. package/dist/services/memoryService.js +90 -24
  40. package/dist/services/memoryService.js.map +1 -1
  41. package/dist/services/personaService.d.ts +25 -0
  42. package/dist/services/personaService.d.ts.map +1 -1
  43. package/dist/services/personaService.js +79 -0
  44. package/dist/services/personaService.js.map +1 -1
  45. package/dist/tests/dashboard-schemas.test.js +71 -1
  46. package/dist/tests/dashboard-schemas.test.js.map +1 -1
  47. package/package.json +1 -1
  48. package/schemas/README.md +64 -0
@@ -1,99 +1,90 @@
1
1
  /**
2
- * PostClaw Dashboard — Frontend Application
2
+ * PostClaw Dashboard — Frontend application
3
3
  *
4
- * Tab management, API calls, DOM updates, D3 knowledge graph.
4
+ * Vanilla JS SPA: tab management, agent selection, CRUD for personas/memories,
5
+ * D3.js knowledge graph with persona nodes, memory linking, workspace import,
6
+ * script runner, and full config editor.
5
7
  */
6
8
 
7
9
  // =============================================================================
8
- // STATE
10
+ // GLOBALS
9
11
  // =============================================================================
10
12
 
11
13
  let currentAgent = "main";
12
- let memoryPage = 0;
13
- const PAGE_SIZE = 50;
14
+ const API = "";
15
+ let memoryPage = { offset: 0, limit: 50, total: 0 };
16
+ let graphInstances = { simulation: null, svg: null, g: null, zoom: null };
17
+ let showLabels = true;
18
+ let allNodes = []; // cached for link search
19
+ let allEdges = []; // cached for link search
20
+ let shiftLinkState = null; // { sourceNode, line } for shift+drag linking
14
21
 
15
22
  // =============================================================================
16
- // UTILITIES
23
+ // UTILS
17
24
  // =============================================================================
18
25
 
19
- function $(id) { return document.getElementById(id); }
20
-
21
- async function api(method, path, body = null) {
26
+ async function api(path, opts = {}) {
22
27
  const sep = path.includes("?") ? "&" : "?";
23
- const url = `${path}${sep}agentId=${encodeURIComponent(currentAgent)}`;
24
- const opts = { method, headers: { "Content-Type": "application/json" } };
25
- if (body) opts.body = JSON.stringify(body);
26
- const res = await fetch(url, opts);
27
- const data = await res.json();
28
- if (!data.ok) throw new Error(data.error || "API error");
29
- return data.data;
28
+ const url = `${API}${path}${sep}agentId=${encodeURIComponent(currentAgent)}`;
29
+ const res = await fetch(url, {
30
+ headers: { "Content-Type": "application/json" },
31
+ ...opts,
32
+ });
33
+ return res.json();
30
34
  }
31
35
 
32
- function toast(message, type = "info") {
33
- const container = $("toast-container");
36
+ function toast(msg, type = "info") {
37
+ const container = document.getElementById("toast-container");
34
38
  const el = document.createElement("div");
35
39
  el.className = `toast toast-${type}`;
36
- el.textContent = message;
40
+ el.textContent = msg;
37
41
  container.appendChild(el);
38
42
  setTimeout(() => el.remove(), 3000);
39
43
  }
40
44
 
41
- function tierBadge(tier) {
42
- return `<span class="badge badge-${tier || 'daily'}">${tier || "daily"}</span>`;
43
- }
44
-
45
- function boolBadge(val) {
46
- return `<span class="badge badge-${val}">${val ? "✓" : "✗"}</span>`;
45
+ function badge(value, prefix = "badge") {
46
+ return `<span class="badge ${prefix}-${value}">${value}</span>`;
47
47
  }
48
48
 
49
- function truncate(str, len = 80) {
49
+ function truncate(str, max = 80) {
50
50
  if (!str) return "";
51
- return str.length > len ? str.substring(0, len) + "…" : str;
51
+ return str.length > max ? str.substring(0, max) + "…" : str;
52
52
  }
53
53
 
54
54
  // =============================================================================
55
55
  // TAB MANAGEMENT
56
56
  // =============================================================================
57
57
 
58
- document.querySelectorAll(".tab-btn").forEach(btn => {
58
+ document.querySelectorAll(".tab-btn").forEach((btn) => {
59
59
  btn.addEventListener("click", () => {
60
- document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
61
- document.querySelectorAll(".tab-panel").forEach(p => p.classList.remove("active"));
60
+ document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
61
+ document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active"));
62
62
  btn.classList.add("active");
63
- $(`tab-${btn.dataset.tab}`).classList.add("active");
63
+ const panel = document.getElementById(`tab-${btn.dataset.tab}`);
64
+ if (panel) panel.classList.add("active");
64
65
 
65
- // Load data for the active tab
66
- if (btn.dataset.tab === "personas") loadPersonas();
67
- if (btn.dataset.tab === "memories") loadMemories();
68
66
  if (btn.dataset.tab === "graph") loadGraph();
69
67
  if (btn.dataset.tab === "config") loadConfig();
70
68
  });
71
69
  });
72
70
 
73
71
  // =============================================================================
74
- // AGENT SELECTOR
72
+ // AGENT SELECTION
75
73
  // =============================================================================
76
74
 
77
75
  async function loadAgents() {
78
- try {
79
- const agents = await api("GET", "/api/agents");
80
- const select = $("agent-select");
81
- select.innerHTML = "";
82
- agents.forEach(a => {
83
- const opt = document.createElement("option");
84
- opt.value = a.id;
85
- opt.textContent = a.name || a.id;
86
- if (a.id === currentAgent) opt.selected = true;
87
- select.appendChild(opt);
88
- });
89
- } catch { /* agents table might be empty */ }
76
+ const res = await api("/api/agents");
77
+ if (!res.ok) return;
78
+ const select = document.getElementById("agent-select");
79
+ select.innerHTML = res.data.map((a) => `<option value="${a.id}">${a.id}</option>`).join("");
80
+ select.value = currentAgent;
90
81
  }
91
82
 
92
- $("agent-select").addEventListener("change", (e) => {
83
+ document.getElementById("agent-select").addEventListener("change", (e) => {
93
84
  currentAgent = e.target.value;
94
- // Reload active tab
95
- const activeTab = document.querySelector(".tab-btn.active");
96
- if (activeTab) activeTab.click();
85
+ loadPersonas();
86
+ loadMemories();
87
+ loadWorkspaceFiles();
97
88
  });
98
89
 
99
90
  // =============================================================================
@@ -101,110 +92,91 @@ $("agent-select").addEventListener("change", (e) => {
101
92
  // =============================================================================
102
93
 
103
94
  async function loadPersonas() {
104
- try {
105
- const personas = await api("GET", "/api/personas");
106
- const container = $("persona-list");
107
- if (personas.length === 0) {
108
- container.innerHTML = '<p class="hint">No persona entries yet. Create one above.</p>';
109
- return;
110
- }
111
- container.innerHTML = `
112
- <table class="data-table">
113
- <thead><tr>
114
- <th>Category</th><th>Content</th><th>Always Active</th><th>Actions</th>
115
- </tr></thead>
116
- <tbody>${personas.map(p => `
95
+ const res = await api("/api/personas");
96
+ if (!res.ok) return;
97
+
98
+ const container = document.getElementById("persona-list");
99
+ if (res.data.length === 0) {
100
+ container.innerHTML = '<p class="hint">No persona entries yet.</p>';
101
+ return;
102
+ }
103
+
104
+ container.innerHTML = `
105
+ <table class="data-table">
106
+ <thead><tr><th>Category</th><th>Content</th><th>Always Active</th><th>Actions</th></tr></thead>
107
+ <tbody>
108
+ ${res.data
109
+ .map(
110
+ (p) => `
117
111
  <tr>
118
- <td><strong>${p.category}</strong></td>
119
- <td title="${p.content.replace(/"/g, '&quot;')}">${truncate(p.content, 120)}</td>
120
- <td>${boolBadge(p.is_always_active)}</td>
112
+ <td>${p.category}</td>
113
+ <td title="${p.content.replace(/"/g, "&quot;")}">${truncate(p.content, 60)}</td>
114
+ <td>${badge(String(p.is_always_active))}</td>
121
115
  <td class="actions">
122
116
  <button class="btn-sm btn-secondary" onclick="editPersona('${p.id}')">✏️</button>
123
- <button class="btn-sm btn-danger" onclick="deletePersonaRow('${p.id}', '${p.category}')">🗑️</button>
117
+ <button class="btn-sm btn-danger" onclick="deletePersona('${p.id}')">🗑️</button>
124
118
  </td>
125
- </tr>
126
- `).join("")}</tbody>
127
- </table>`;
128
- } catch (err) { toast(err.message, "error"); }
129
- }
130
-
131
- // Store personas data for editing
132
- let _personasCache = [];
133
-
134
- async function editPersona(id) {
135
- try {
136
- const persona = await api("GET", `/api/personas/${id}`);
137
- $("persona-form-id").value = id;
138
- $("persona-category").value = persona.category;
139
- $("persona-content").value = persona.content;
140
- $("persona-always-active").checked = persona.is_always_active;
141
- $("persona-form").style.display = "block";
142
- } catch (err) { toast(err.message, "error"); }
119
+ </tr>`,
120
+ )
121
+ .join("")}
122
+ </tbody>
123
+ </table>
124
+ `;
143
125
  }
144
- window.editPersona = editPersona;
145
126
 
146
- async function deletePersonaRow(id, category) {
147
- if (!confirm(`Delete persona "${category}"?`)) return;
148
- try {
149
- await api("DELETE", `/api/personas/${id}`);
150
- toast("Persona deleted", "success");
151
- loadPersonas();
152
- } catch (err) { toast(err.message, "error"); }
153
- }
154
- window.deletePersonaRow = deletePersonaRow;
155
-
156
- $("btn-new-persona").addEventListener("click", () => {
157
- $("persona-form-id").value = "";
158
- $("persona-category").value = "";
159
- $("persona-content").value = "";
160
- $("persona-always-active").checked = false;
161
- $("persona-form").style.display = "block";
127
+ document.getElementById("btn-new-persona").addEventListener("click", () => {
128
+ document.getElementById("persona-form-id").value = "";
129
+ document.getElementById("persona-category").value = "";
130
+ document.getElementById("persona-content").value = "";
131
+ document.getElementById("persona-always-active").checked = false;
132
+ document.getElementById("persona-form").style.display = "block";
162
133
  });
163
134
 
164
- $("btn-cancel-persona").addEventListener("click", () => {
165
- $("persona-form").style.display = "none";
135
+ document.getElementById("btn-cancel-persona").addEventListener("click", () => {
136
+ document.getElementById("persona-form").style.display = "none";
166
137
  });
167
138
 
168
- $("btn-save-persona").addEventListener("click", async () => {
169
- const id = $("persona-form-id").value;
170
- const data = {
171
- category: $("persona-category").value,
172
- content: $("persona-content").value,
173
- is_always_active: $("persona-always-active").checked,
139
+ document.getElementById("btn-save-persona").addEventListener("click", async () => {
140
+ const id = document.getElementById("persona-form-id").value;
141
+ const body = {
142
+ category: document.getElementById("persona-category").value,
143
+ content: document.getElementById("persona-content").value,
144
+ is_always_active: document.getElementById("persona-always-active").checked,
174
145
  };
175
- try {
176
- if (id) {
177
- await api("PUT", `/api/personas/${id}`, data);
178
- toast("Persona updated", "success");
179
- } else {
180
- await api("POST", "/api/personas", data);
181
- toast("Persona created", "success");
182
- }
183
- $("persona-form").style.display = "none";
146
+
147
+ const res = id
148
+ ? await api(`/api/personas/${id}`, { method: "PUT", body: JSON.stringify(body) })
149
+ : await api("/api/personas", { method: "POST", body: JSON.stringify(body) });
150
+
151
+ if (res.ok) {
152
+ toast(id ? "Persona updated" : "Persona created", "success");
153
+ document.getElementById("persona-form").style.display = "none";
184
154
  loadPersonas();
185
- } catch (err) { toast(err.message, "error"); }
155
+ } else {
156
+ toast(res.error || "Failed", "error");
157
+ }
186
158
  });
187
159
 
188
- // Workspace files
189
- async function loadWorkspaceFiles() {
190
- try {
191
- const files = await api("GET", "/api/workspace-files");
192
- const container = $("workspace-files");
193
- if (files.length === 0) {
194
- container.innerHTML = '<span class="hint">No .md files found in workspace</span>';
195
- return;
196
- }
197
- container.innerHTML = files.map(f =>
198
- `<button class="workspace-file-btn" onclick="loadWorkspaceFile('${f.name}')">${f.name}</button>`
199
- ).join("");
200
- } catch { /* workspace might not be configured */ }
201
- }
202
- window.loadWorkspaceFile = async function(filename) {
203
- try {
204
- const file = await api("GET", `/api/workspace-files/${filename}`);
205
- $("workspace-content").textContent = file.content;
206
- $("workspace-content").style.display = "block";
207
- } catch (err) { toast(err.message, "error"); }
160
+ window.editPersona = async function (id) {
161
+ const res = await api(`/api/personas/${id}`);
162
+ if (!res.ok) return toast("Not found", "error");
163
+ const p = res.data;
164
+ document.getElementById("persona-form-id").value = p.id;
165
+ document.getElementById("persona-category").value = p.category;
166
+ document.getElementById("persona-content").value = p.content;
167
+ document.getElementById("persona-always-active").checked = p.is_always_active;
168
+ document.getElementById("persona-form").style.display = "block";
169
+ };
170
+
171
+ window.deletePersona = async function (id) {
172
+ if (!confirm("Delete this persona entry?")) return;
173
+ const res = await api(`/api/personas/${id}`, { method: "DELETE" });
174
+ if (res.ok) {
175
+ toast("Deleted", "success");
176
+ loadPersonas();
177
+ } else {
178
+ toast(res.error || "Failed", "error");
179
+ }
208
180
  };
209
181
 
210
182
  // =============================================================================
@@ -212,532 +184,921 @@ window.loadWorkspaceFile = async function(filename) {
212
184
  // =============================================================================
213
185
 
214
186
  async function loadMemories() {
215
- const search = $("memory-search").value;
216
- const tier = $("memory-tier-filter").value;
217
- const archived = $("memory-archived-filter").value;
218
-
219
- let params = `limit=${PAGE_SIZE}&offset=${memoryPage * PAGE_SIZE}`;
220
- if (search) params += `&search=${encodeURIComponent(search)}`;
221
- if (tier) params += `&tier=${tier}`;
222
- if (archived) params += `&archived=${archived}`;
223
-
224
- try {
225
- const result = await api("GET", `/api/memories?${params}`);
226
- const container = $("memory-list");
227
- const memories = result.memories;
228
- if (memories.length === 0) {
229
- container.innerHTML = '<p class="hint">No memories found.</p>';
230
- $("memory-pagination").innerHTML = "";
231
- return;
232
- }
233
- container.innerHTML = `
234
- <table class="data-table">
235
- <thead><tr>
236
- <th>Content</th><th>Category</th><th>Tier</th><th>Access</th><th>Actions</th>
237
- </tr></thead>
238
- <tbody>${memories.map(m => `
239
- <tr>
240
- <td title="${(m.content || '').replace(/"/g, '&quot;')}">${truncate(m.content, 100)}</td>
241
- <td>${m.category || ""}</td>
242
- <td>${tierBadge(m.tier)}</td>
243
- <td>${m.access_count || 0}</td>
187
+ const search = document.getElementById("memory-search").value;
188
+ const tier = document.getElementById("memory-tier-filter").value;
189
+ const archived = document.getElementById("memory-archived-filter").value;
190
+
191
+ const params = new URLSearchParams({
192
+ limit: memoryPage.limit,
193
+ offset: memoryPage.offset,
194
+ });
195
+ if (search) params.set("search", search);
196
+ if (tier) params.set("tier", tier);
197
+ if (archived) params.set("archived", archived);
198
+
199
+ const res = await api(`/api/memories?${params}`);
200
+ if (!res.ok) return;
201
+
202
+ const memories = res.data.memories || res.data;
203
+ memoryPage.total = res.data.total ?? memories.length;
204
+ const container = document.getElementById("memory-list");
205
+
206
+ if (memories.length === 0) {
207
+ container.innerHTML = '<p class="hint">No memories found.</p>';
208
+ document.getElementById("memory-pagination").innerHTML = "";
209
+ return;
210
+ }
211
+
212
+ container.innerHTML = `
213
+ <table class="data-table">
214
+ <thead><tr>
215
+ <th>Content</th><th>Category</th><th>Tier</th><th>Vol.</th><th>Conf.</th>
216
+ <th>Score</th><th>Acc.</th><th>Inj.</th><th>Created</th><th>Actions</th>
217
+ </tr></thead>
218
+ <tbody>
219
+ ${memories
220
+ .map(
221
+ (m) => {
222
+ const created = m.created_at ? new Date(m.created_at).toLocaleDateString() : '—';
223
+ const conf = m.confidence != null ? m.confidence.toFixed(2) : '—';
224
+ const vol = m.volatility || '—';
225
+ const score = m.usefulness_score != null ? m.usefulness_score.toFixed(1) : '—';
226
+ const acc = m.access_count ?? 0;
227
+ const inj = m.injection_count ?? 0;
228
+ return `
229
+ <tr${m.is_pointer ? ' class="memory-pointer"' : ''}>
230
+ <td title="${(m.content || '').replace(/"/g, '&quot;')}">${truncate(m.content, 50)}</td>
231
+ <td>${m.category || '—'}</td>
232
+ <td>${badge(m.tier || 'daily')}</td>
233
+ <td><span class="vol-${vol}">${vol}</span></td>
234
+ <td>${conf}</td>
235
+ <td>${score}</td>
236
+ <td>${acc}</td>
237
+ <td>${inj}</td>
238
+ <td>${created}</td>
244
239
  <td class="actions">
245
240
  <button class="btn-sm btn-secondary" onclick="editMemory('${m.id}')">✏️</button>
246
- <button class="btn-sm btn-danger" onclick="archiveMemory('${m.id}')">🗑️</button>
241
+ <button class="btn-sm btn-danger" onclick="deleteMemory('${m.id}')">🗑️</button>
247
242
  </td>
248
- </tr>
249
- `).join("")}</tbody>
250
- </table>`;
251
-
252
- // Pagination
253
- const totalPages = Math.ceil(result.total / PAGE_SIZE);
254
- $("memory-pagination").innerHTML = totalPages > 1
255
- ? `<button class="btn-sm btn-secondary" ${memoryPage === 0 ? "disabled" : ""} onclick="memPrev()">← Prev</button>
256
- <span>Page ${memoryPage + 1} of ${totalPages} (${result.total} total)</span>
257
- <button class="btn-sm btn-secondary" ${memoryPage >= totalPages - 1 ? "disabled" : ""} onclick="memNext()">Next →</button>`
258
- : `<span>${result.total} memories</span>`;
259
- } catch (err) { toast(err.message, "error"); }
243
+ </tr>`;
244
+ },
245
+ )
246
+ .join('')}
247
+ </tbody>
248
+ </table>
249
+ `;
250
+
251
+ renderPagination();
260
252
  }
261
253
 
262
- window.memPrev = () => { if (memoryPage > 0) { memoryPage--; loadMemories(); } };
263
- window.memNext = () => { memoryPage++; loadMemories(); };
264
-
265
- window.editMemory = async function(id) {
266
- try {
267
- const mem = await api("GET", `/api/memories/${id}`);
268
- if (!mem) return toast("Memory not found", "error");
269
- $("memory-form-id").value = id;
270
- $("memory-content").value = mem.content;
271
- $("memory-category").value = mem.category || "";
272
- $("memory-tier").value = mem.tier || "daily";
273
- $("memory-form").style.display = "block";
274
- } catch (err) { toast(err.message, "error"); }
254
+ function renderPagination() {
255
+ const el = document.getElementById("memory-pagination");
256
+ const page = Math.floor(memoryPage.offset / memoryPage.limit) + 1;
257
+ const totalPages = Math.max(1, Math.ceil(memoryPage.total / memoryPage.limit));
258
+ el.innerHTML = `
259
+ <button class="btn-sm btn-secondary" onclick="prevMemoryPage()" ${memoryPage.offset === 0 ? "disabled" : ""}>← Prev</button>
260
+ <span>Page ${page} of ${totalPages} (${memoryPage.total} total)</span>
261
+ <button class="btn-sm btn-secondary" onclick="nextMemoryPage()" ${page >= totalPages ? "disabled" : ""}>Next →</button>
262
+ `;
263
+ }
264
+
265
+ window.prevMemoryPage = function () {
266
+ memoryPage.offset = Math.max(0, memoryPage.offset - memoryPage.limit);
267
+ loadMemories();
275
268
  };
276
269
 
277
- window.archiveMemory = async function(id) {
278
- if (!confirm("Archive this memory?")) return;
279
- try {
280
- await api("DELETE", `/api/memories/${id}`);
281
- toast("Memory archived", "success");
282
- loadMemories();
283
- } catch (err) { toast(err.message, "error"); }
270
+ window.nextMemoryPage = function () {
271
+ memoryPage.offset += memoryPage.limit;
272
+ loadMemories();
284
273
  };
285
274
 
286
- $("btn-new-memory").addEventListener("click", () => {
287
- $("memory-form-id").value = "";
288
- $("memory-content").value = "";
289
- $("memory-category").value = "";
290
- $("memory-tier").value = "daily";
291
- $("memory-form").style.display = "block";
292
- $("import-form").style.display = "none";
275
+ document.getElementById("memory-search").addEventListener(
276
+ "input",
277
+ debounce(() => {
278
+ memoryPage.offset = 0;
279
+ loadMemories();
280
+ }, 300),
281
+ );
282
+
283
+ document.getElementById("memory-tier-filter").addEventListener("change", () => {
284
+ memoryPage.offset = 0;
285
+ loadMemories();
293
286
  });
294
287
 
295
- $("btn-cancel-memory").addEventListener("click", () => {
296
- $("memory-form").style.display = "none";
288
+ document.getElementById("memory-archived-filter").addEventListener("change", () => {
289
+ memoryPage.offset = 0;
290
+ loadMemories();
297
291
  });
298
292
 
299
- $("btn-save-memory").addEventListener("click", async () => {
300
- const id = $("memory-form-id").value;
301
- const data = {
302
- content: $("memory-content").value,
303
- category: $("memory-category").value || undefined,
304
- tier: $("memory-tier").value,
293
+ function debounce(fn, ms) {
294
+ let t;
295
+ return (...args) => {
296
+ clearTimeout(t);
297
+ t = setTimeout(() => fn(...args), ms);
305
298
  };
306
- try {
307
- if (id) {
308
- await api("PUT", `/api/memories/${id}`, data);
309
- toast("Memory updated", "success");
310
- } else {
311
- await api("POST", "/api/memories", data);
312
- toast("Memory created", "success");
313
- }
314
- $("memory-form").style.display = "none";
315
- loadMemories();
316
- } catch (err) { toast(err.message, "error"); }
317
- });
299
+ }
318
300
 
319
- // Import
320
- $("btn-import-memory").addEventListener("click", () => {
321
- $("import-form").style.display = "block";
322
- $("memory-form").style.display = "none";
301
+ document.getElementById("btn-new-memory").addEventListener("click", () => {
302
+ document.getElementById("memory-form-id").value = "";
303
+ document.getElementById("memory-content").value = "";
304
+ document.getElementById("memory-category").value = "";
305
+ document.getElementById("memory-tier").value = "daily";
306
+ document.getElementById("memory-edge-list").innerHTML = "";
307
+ document.getElementById("memory-form").style.display = "block";
323
308
  });
324
- $("btn-cancel-import").addEventListener("click", () => {
325
- $("import-form").style.display = "none";
309
+
310
+ document.getElementById("btn-cancel-memory").addEventListener("click", () => {
311
+ document.getElementById("memory-form").style.display = "none";
326
312
  });
327
- $("btn-run-import").addEventListener("click", async () => {
328
- const content = $("import-content").value;
329
- const filename = $("import-filename").value;
330
- if (!content.trim()) return toast("No content to import", "error");
331
- try {
332
- const result = await api("POST", "/api/memories/import", {
333
- content, source_filename: filename || undefined,
334
- });
335
- toast(`Imported ${result.imported} chunks`, "success");
336
- $("import-form").style.display = "none";
313
+
314
+ document.getElementById("btn-save-memory").addEventListener("click", async () => {
315
+ const id = document.getElementById("memory-form-id").value;
316
+ const body = {
317
+ content: document.getElementById("memory-content").value,
318
+ category: document.getElementById("memory-category").value || undefined,
319
+ tier: document.getElementById("memory-tier").value,
320
+ };
321
+
322
+ const res = id
323
+ ? await api(`/api/memories/${id}`, { method: "PUT", body: JSON.stringify(body) })
324
+ : await api("/api/memories", { method: "POST", body: JSON.stringify(body) });
325
+
326
+ if (res.ok) {
327
+ toast(id ? "Memory updated" : "Memory created", "success");
328
+ document.getElementById("memory-form").style.display = "none";
337
329
  loadMemories();
338
- } catch (err) { toast(err.message, "error"); }
330
+ } else {
331
+ toast(res.error || "Failed", "error");
332
+ }
339
333
  });
340
334
 
341
- // Filter listeners
342
- $("memory-search").addEventListener("input", debounce(() => { memoryPage = 0; loadMemories(); }, 300));
343
- $("memory-tier-filter").addEventListener("change", () => { memoryPage = 0; loadMemories(); });
344
- $("memory-archived-filter").addEventListener("change", () => { memoryPage = 0; loadMemories(); });
335
+ window.editMemory = async function (id) {
336
+ const res = await api(`/api/memories/${id}`);
337
+ if (!res.ok) return toast("Not found", "error");
338
+ const m = res.data;
339
+ document.getElementById("memory-form-id").value = m.id;
340
+ document.getElementById("memory-content").value = m.content;
341
+ document.getElementById("memory-category").value = m.category || "";
342
+ document.getElementById("memory-tier").value = m.tier || "daily";
343
+ document.getElementById("memory-form").style.display = "block";
344
+ loadMemoryEdges(id);
345
+ };
345
346
 
346
- function debounce(fn, ms) {
347
- let timer;
348
- return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
349
- }
347
+ window.deleteMemory = async function (id) {
348
+ if (!confirm("Archive this memory?")) return;
349
+ const res = await api(`/api/memories/${id}`, { method: "DELETE" });
350
+ if (res.ok) {
351
+ toast("Archived", "success");
352
+ loadMemories();
353
+ } else {
354
+ toast(res.error || "Failed", "error");
355
+ }
356
+ };
350
357
 
351
358
  // =============================================================================
352
- // KNOWLEDGE GRAPH (D3.js)
359
+ // MEMORY LINKING
353
360
  // =============================================================================
354
361
 
355
- let graphSimulation = null;
356
- let graphZoom = null;
357
- let graphLabelsVisible = true;
362
+ async function loadMemoryEdges(memoryId) {
363
+ const res = await api(`/api/memories/${memoryId}/edges`);
364
+ const container = document.getElementById("memory-edge-list");
358
365
 
359
- async function loadGraph() {
360
- try {
361
- const data = await api("GET", "/api/graph");
362
- renderGraph(data);
363
- $("graph-stats").textContent = `${data.nodes.length} nodes, ${data.edges.length} edges`;
364
- } catch (err) { toast(err.message, "error"); }
366
+ if (!res.ok || !res.data.length) {
367
+ container.innerHTML = '<p class="hint" style="font-size:0.8rem;">No links yet.</p>';
368
+ return;
369
+ }
370
+
371
+ container.innerHTML = res.data
372
+ .map((e) => {
373
+ const isSrc = e.source_memory_id === memoryId;
374
+ const targetLabel = isSrc
375
+ ? truncate(e.target_content || e.target_persona_category || "Unknown", 50)
376
+ : truncate(e.source_content || e.source_persona_category || "Unknown", 50);
377
+ const targetType = (isSrc ? (e.target_persona_id ? "persona" : "memory") : (e.source_persona_id ? "persona" : "memory"));
378
+ return `
379
+ <div class="edge-item">
380
+ <span class="edge-rel">${e.relationship_type}</span>
381
+ <span class="edge-type-badge">${targetType}</span>
382
+ <span class="edge-target">${isSrc ? "→" : "←"} ${targetLabel}</span>
383
+ <button class="btn-sm btn-danger" onclick="deleteEdge('${e.id}', '${memoryId}')">✕</button>
384
+ </div>
385
+ `;
386
+ })
387
+ .join("");
365
388
  }
366
389
 
367
- function renderGraph(data) {
368
- const svg = d3.select("#graph-svg");
369
- svg.selectAll("*").remove();
390
+ window.deleteEdge = async function (edgeId, memoryId) {
391
+ const res = await api(`/api/edges/${edgeId}`, { method: "DELETE" });
392
+ if (res.ok) {
393
+ toast("Link removed", "success");
394
+ loadMemoryEdges(memoryId);
395
+ } else {
396
+ toast(res.error || "Failed", "error");
397
+ }
398
+ };
370
399
 
371
- const container = $("graph-container");
372
- const width = container.clientWidth;
373
- const height = container.clientHeight;
400
+ // Link target search
401
+ const linkSearchInput = document.getElementById("link-target-search");
402
+ const linkSearchResults = document.getElementById("link-target-results");
374
403
 
375
- svg.attr("viewBox", [0, 0, width, height]);
404
+ linkSearchInput.addEventListener(
405
+ "input",
406
+ debounce(async () => {
407
+ const q = linkSearchInput.value.trim().toLowerCase();
408
+ if (q.length < 2) {
409
+ linkSearchResults.style.display = "none";
410
+ return;
411
+ }
376
412
 
377
- // ── Defs: glow filters + arrowhead markers ──
378
- const defs = svg.append("defs");
413
+ const res = await api(`/api/memories/search?search=${encodeURIComponent(q)}`);
414
+ if (!res.ok || !res.data || res.data.length === 0) {
415
+ linkSearchResults.style.display = "none";
416
+ return;
417
+ }
379
418
 
380
- // Glow filter
381
- const glow = defs.append("filter").attr("id", "glow");
382
- glow.append("feGaussianBlur").attr("stdDeviation", "3").attr("result", "color");
383
- glow.append("feMerge").selectAll("feMergeNode")
384
- .data(["color", "SourceGraphic"])
385
- .join("feMergeNode").attr("in", d => d);
386
-
387
- // Tier colors
388
- const tierColor = {
389
- permanent: "#4ade80", stable: "#4f9cf7", daily: "#f7b955",
390
- session: "#a78bfa", volatile: "#f7555a",
391
- };
419
+ linkSearchResults.innerHTML = res.data
420
+ .map(
421
+ (r) => `
422
+ <div class="link-search-item" data-id="${r.id}" data-type="${r.type}">
423
+ <span class="node-type ${r.type}">${r.type}</span>
424
+ <span>${truncate(r.content, 60)}</span>
425
+ </div>
426
+ `,
427
+ )
428
+ .join("");
429
+ linkSearchResults.style.display = "block";
430
+ }, 300),
431
+ );
432
+
433
+ linkSearchResults.addEventListener("click", (e) => {
434
+ const item = e.target.closest(".link-search-item");
435
+ if (!item) return;
436
+ document.getElementById("link-target-id").value = item.dataset.id;
437
+ document.getElementById("link-target-type").value = item.dataset.type;
438
+ linkSearchInput.value = item.querySelector("span:last-child").textContent;
439
+ linkSearchResults.style.display = "none";
440
+ });
392
441
 
393
- // Relationship colors
394
- const relColor = {
395
- related_to: "#6b7280", elaborates: "#4f9cf7", contradicts: "#f7555a",
396
- depends_on: "#f7b955", part_of: "#a78bfa",
397
- };
442
+ document.getElementById("btn-add-link").addEventListener("click", async () => {
443
+ const memoryId = document.getElementById("memory-form-id").value;
444
+ const targetId = document.getElementById("link-target-id").value;
445
+ const targetType = document.getElementById("link-target-type").value;
446
+ const relationship = document.getElementById("link-relationship").value.trim();
447
+
448
+ if (!memoryId || !targetId || !relationship) {
449
+ return toast("Select a target and enter a relationship type", "error");
450
+ }
451
+
452
+ const body = { relationship_type: relationship };
453
+ if (targetType === "persona") {
454
+ body.source_memory_id = memoryId;
455
+ body.target_persona_id = targetId;
456
+ } else {
457
+ body.source_memory_id = memoryId;
458
+ body.target_memory_id = targetId;
459
+ }
460
+
461
+ const res = await api("/api/edges", { method: "POST", body: JSON.stringify(body) });
462
+ if (res.ok) {
463
+ toast("Link created", "success");
464
+ linkSearchInput.value = "";
465
+ document.getElementById("link-target-id").value = "";
466
+ document.getElementById("link-relationship").value = "";
467
+ loadMemoryEdges(memoryId);
468
+ } else {
469
+ toast(res.error || "Link failed", "error");
470
+ }
471
+ });
472
+
473
+ // =============================================================================
474
+ // IMPORT (paste markdown)
475
+ // =============================================================================
476
+
477
+ document.getElementById("btn-import-memory").addEventListener("click", () => {
478
+ document.getElementById("import-form").style.display =
479
+ document.getElementById("import-form").style.display === "none" ? "block" : "none";
480
+ });
481
+
482
+ document.getElementById("btn-cancel-import").addEventListener("click", () => {
483
+ document.getElementById("import-form").style.display = "none";
484
+ });
485
+
486
+ document.getElementById("btn-run-import").addEventListener("click", async () => {
487
+ const content = document.getElementById("import-content").value;
488
+ const source_filename = document.getElementById("import-filename").value || undefined;
489
+
490
+ if (!content.trim()) return toast("Paste some content first", "error");
491
+
492
+ const res = await api("/api/memories/import", {
493
+ method: "POST",
494
+ body: JSON.stringify({ content, source_filename }),
495
+ });
398
496
 
399
- // Arrowhead markers for each relationship
400
- Object.entries(relColor).forEach(([rel, color]) => {
401
- defs.append("marker")
402
- .attr("id", `arrow-${rel}`)
403
- .attr("viewBox", "0 -5 10 10")
404
- .attr("refX", 20)
405
- .attr("refY", 0)
406
- .attr("markerWidth", 6)
407
- .attr("markerHeight", 6)
408
- .attr("orient", "auto")
409
- .append("path")
410
- .attr("d", "M0,-5L10,0L0,5")
411
- .attr("fill", color)
412
- .attr("opacity", 0.6);
497
+ if (res.ok) {
498
+ toast(`Imported ${res.data.imported} memories`, "success");
499
+ document.getElementById("import-form").style.display = "none";
500
+ document.getElementById("import-content").value = "";
501
+ loadMemories();
502
+ } else {
503
+ toast(res.error || "Import failed", "error");
504
+ }
505
+ });
506
+
507
+ // =============================================================================
508
+ // WORKSPACE FILES
509
+ // =============================================================================
510
+
511
+ async function loadWorkspaceFiles() {
512
+ const res = await api("/api/workspace-files");
513
+ const container = document.getElementById("workspace-files");
514
+
515
+ if (!res.ok || !res.data.length) {
516
+ container.innerHTML = '<p class="hint">No .md files in workspace.</p>';
517
+ return;
518
+ }
519
+
520
+ container.innerHTML = res.data
521
+ .map(
522
+ (f) => `
523
+ <div style="display:inline-flex;flex-direction:column;gap:0.15rem;">
524
+ <button class="workspace-file-btn" onclick="viewWorkspaceFile('${f.name}')">${f.name}</button>
525
+ <div class="workspace-import-actions">
526
+ <button class="workspace-import-btn" onclick="importWorkspaceTo('${f.name}', 'persona')" title="Import as persona entries via LLM">→ Persona</button>
527
+ <button class="workspace-import-btn" onclick="importWorkspaceTo('${f.name}', 'memory')" title="Import as memory facts via LLM">→ Memory</button>
528
+ </div>
529
+ </div>
530
+ `,
531
+ )
532
+ .join("");
533
+ }
534
+
535
+ window.viewWorkspaceFile = async function (filename) {
536
+ const res = await api(`/api/workspace-files/${encodeURIComponent(filename)}`);
537
+ if (!res.ok) return toast(res.error || "Failed to read", "error");
538
+ const el = document.getElementById("workspace-content");
539
+ el.textContent = res.data.content;
540
+ el.style.display = "block";
541
+ };
542
+
543
+ window.importWorkspaceTo = async function (filename, target) {
544
+ const tierPrompt = target === "memory" ? " (tier: stable)" : "";
545
+ if (!confirm(`Import "${filename}" as ${target} entries${tierPrompt}?\n\nThis uses LLM to semantically chunk the file.`)) return;
546
+
547
+ toast(`Importing ${filename} as ${target}… (LLM processing)`, "info");
548
+
549
+ const res = await api("/api/workspace-import", {
550
+ method: "POST",
551
+ body: JSON.stringify({ filename, target }),
413
552
  });
414
553
 
415
- // ── Zoom behavior ──
416
- const world = svg.append("g").attr("class", "graph-world");
554
+ if (res.ok) {
555
+ toast(`Imported ${res.data.imported}/${res.data.total} ${target} entries from ${filename}`, "success");
556
+ if (target === "persona") loadPersonas();
557
+ else loadMemories();
558
+ } else {
559
+ toast(res.error || "Import failed", "error");
560
+ }
561
+ };
562
+
563
+ // =============================================================================
564
+ // KNOWLEDGE GRAPH (D3.js)
565
+ // =============================================================================
566
+
567
+ const TIER_COLORS = {
568
+ permanent: "#4ade80",
569
+ stable: "#4f9cf7",
570
+ daily: "#f7b955",
571
+ session: "#a78bfa",
572
+ volatile: "#f7555a",
573
+ };
574
+
575
+ const PERSONA_COLOR = "#f7b955";
576
+ const MEMORY_DEFAULT_COLOR = "#4f9cf7";
417
577
 
418
- graphZoom = d3.zoom()
419
- .scaleExtent([0.1, 6])
420
- .on("zoom", (event) => {
421
- world.attr("transform", event.transform);
422
- });
578
+ async function loadGraph() {
579
+ const res = await api("/api/graph");
580
+ if (!res.ok) return;
423
581
 
424
- svg.call(graphZoom);
425
- // Disable double-click zoom (conflicts with node interaction)
426
- svg.on("dblclick.zoom", null);
582
+ allNodes = res.data.nodes;
583
+ allEdges = res.data.edges;
427
584
 
428
- // ── Force simulation ──
429
- if (graphSimulation) graphSimulation.stop();
585
+ const svg = d3.select("#graph-svg");
586
+ svg.selectAll("*").remove();
587
+
588
+ const container = document.getElementById("graph-container");
589
+ const width = container.clientWidth;
590
+ const height = container.clientHeight;
430
591
 
431
- const nodeCount = data.nodes.length;
432
- const chargeStrength = nodeCount > 100 ? -60 : nodeCount > 50 ? -80 : -100;
433
- const linkDist = nodeCount > 100 ? 80 : 120;
592
+ svg.attr("width", width).attr("height", height);
434
593
 
435
- graphSimulation = d3.forceSimulation(data.nodes)
436
- .force("link", d3.forceLink(data.edges).id(d => d.id).distance(linkDist))
437
- .force("charge", d3.forceManyBody().strength(chargeStrength))
594
+ // Defs for arrowheads
595
+ const defs = svg.append("defs");
596
+ defs
597
+ .append("marker")
598
+ .attr("id", "arrowhead")
599
+ .attr("viewBox", "0 -5 10 10")
600
+ .attr("refX", 20)
601
+ .attr("refY", 0)
602
+ .attr("markerWidth", 6)
603
+ .attr("markerHeight", 6)
604
+ .attr("orient", "auto")
605
+ .append("path")
606
+ .attr("d", "M0,-5L10,0L0,5")
607
+ .attr("fill", "#6b7280");
608
+
609
+ const g = svg.append("g");
610
+
611
+ // Zoom
612
+ const zoom = d3.zoom().scaleExtent([0.1, 5]).on("zoom", (event) => {
613
+ g.attr("transform", event.transform);
614
+ });
615
+ svg.call(zoom);
616
+
617
+ // Force simulation
618
+ const simulation = d3
619
+ .forceSimulation(res.data.nodes)
620
+ .force(
621
+ "link",
622
+ d3
623
+ .forceLink(res.data.edges)
624
+ .id((d) => d.id)
625
+ .distance(120),
626
+ )
627
+ .force("charge", d3.forceManyBody().strength(-200))
438
628
  .force("center", d3.forceCenter(width / 2, height / 2))
439
- .force("collision", d3.forceCollide().radius(d => nodeRadius(d) + 4))
440
- .force("x", d3.forceX(width / 2).strength(0.06))
441
- .force("y", d3.forceY(height / 2).strength(0.06))
442
- .alphaDecay(0.03);
629
+ .force("collision", d3.forceCollide().radius(30));
443
630
 
444
- // ── Edges ──
445
- const link = world.append("g").attr("class", "graph-edges")
631
+ // Edges
632
+ const link = g
633
+ .append("g")
446
634
  .selectAll("line")
447
- .data(data.edges)
635
+ .data(res.data.edges)
448
636
  .join("line")
449
637
  .attr("class", "graph-edge")
450
- .attr("stroke", d => relColor[d.relationship] || "#6b7280")
451
- .attr("stroke-width", d => Math.max(0.5, Math.sqrt(d.weight || 1)))
452
- .attr("marker-end", d => `url(#arrow-${d.relationship || "related_to"})`);
638
+ .attr("stroke", (d) => {
639
+ if (d.sourceType === "persona" || d.targetType === "persona") return PERSONA_COLOR;
640
+ return "#6b7280";
641
+ })
642
+ .attr("stroke-width", (d) => Math.max(1, d.weight))
643
+ .attr("marker-end", "url(#arrowhead)");
453
644
 
454
645
  // Edge labels
455
- const linkLabel = world.append("g").attr("class", "graph-edge-labels")
646
+ const edgeLabel = g
647
+ .append("g")
456
648
  .selectAll("text")
457
- .data(data.edges)
649
+ .data(res.data.edges)
458
650
  .join("text")
459
651
  .attr("class", "graph-edge-label")
460
- .text(d => d.relationship || "");
652
+ .text((d) => d.relationship);
461
653
 
462
- // ── Nodes ──
463
- const node = world.append("g").attr("class", "graph-nodes")
654
+ // Nodes
655
+ const node = g
656
+ .append("g")
464
657
  .selectAll("g")
465
- .data(data.nodes)
658
+ .data(res.data.nodes)
466
659
  .join("g")
467
660
  .attr("class", "graph-node")
468
- .call(d3.drag()
469
- .on("start", dragStart)
470
- .on("drag", dragging)
471
- .on("end", dragEnd));
472
-
473
- // Outer glow ring
474
- node.append("circle")
661
+ .call(
662
+ d3.drag()
663
+ .filter((event) => !event.shiftKey) // let shift+drag fall through to link handler
664
+ .on("start", dragStart)
665
+ .on("drag", dragging)
666
+ .on("end", dragEnd)
667
+ );
668
+
669
+ // Glow
670
+ node
671
+ .append("circle")
475
672
  .attr("class", "node-glow")
476
- .attr("r", d => nodeRadius(d) + 4)
673
+ .attr("r", (d) => nodeRadius(d) + 6)
477
674
  .attr("fill", "none")
478
- .attr("stroke", d => tierColor[d.tier] || "#6b7280")
479
- .attr("stroke-width", 2)
480
- .attr("stroke-opacity", 0.15)
481
- .attr("filter", "url(#glow)");
482
-
483
- // Main circle
484
- node.append("circle")
485
- .attr("class", "node-circle")
486
- .attr("r", d => nodeRadius(d))
487
- .attr("fill", d => tierColor[d.tier] || "#6b7280")
488
- .attr("stroke", "rgba(255,255,255,0.15)")
489
- .attr("stroke-width", 1.5);
675
+ .attr("stroke", (d) => nodeColor(d))
676
+ .attr("stroke-width", 3)
677
+ .attr("stroke-opacity", 0);
678
+
679
+ // Memory nodes = circles, Persona nodes = diamonds
680
+ node.each(function (d) {
681
+ const el = d3.select(this);
682
+ if (d.type === "persona") {
683
+ const size = 10;
684
+ el.append("path")
685
+ .attr("class", "node-diamond")
686
+ .attr("d", `M0,${-size} L${size},0 L0,${size} L${-size},0 Z`)
687
+ .attr("fill", PERSONA_COLOR)
688
+ .attr("stroke", "#0f1117")
689
+ .attr("stroke-width", 1.5);
690
+ } else {
691
+ el.append("circle")
692
+ .attr("class", "node-circle")
693
+ .attr("r", nodeRadius(d))
694
+ .attr("fill", nodeColor(d))
695
+ .attr("stroke", "#0f1117")
696
+ .attr("stroke-width", 1.5);
697
+ }
698
+ });
490
699
 
491
700
  // Labels
492
- node.append("text")
701
+ node
702
+ .append("text")
493
703
  .attr("class", "node-label")
494
- .text(d => d.label ? d.label.substring(0, 30) : "")
495
- .attr("dx", d => nodeRadius(d) + 5)
496
- .attr("dy", 4)
497
- .style("display", graphLabelsVisible ? "block" : "none");
498
-
499
- // Click → detail panel
500
- node.on("click", (_event, d) => {
501
- showGraphDetail(d);
502
- // Highlight selected
503
- node.selectAll(".node-circle").attr("stroke", "rgba(255,255,255,0.15)").attr("stroke-width", 1.5);
504
- d3.select(_event.currentTarget).select(".node-circle").attr("stroke", "#fff").attr("stroke-width", 3);
704
+ .attr("dy", (d) => nodeRadius(d) + 14)
705
+ .attr("text-anchor", "middle")
706
+ .text((d) => truncate(d.label, 20))
707
+ .style("display", showLabels ? "block" : "none");
708
+
709
+ // Click handler
710
+ node.on("click", (_event, d) => showNodeDetail(d));
711
+
712
+ // Shift+drag to link — use native mousedown since D3 drag ignores shift events
713
+ node.on("mousedown.link", function (event, d) {
714
+ if (!event.shiftKey) return;
715
+ event.stopPropagation();
716
+ event.preventDefault();
717
+ const svgEl = document.getElementById("graph-svg");
718
+ const pt = svgEl.createSVGPoint();
719
+ pt.x = event.clientX;
720
+ pt.y = event.clientY;
721
+ const ctm = g.node().getScreenCTM();
722
+ if (!ctm) return;
723
+ const svgPt = pt.matrixTransform(ctm.inverse());
724
+
725
+ const line = g
726
+ .append("line")
727
+ .attr("class", "graph-link-preview")
728
+ .attr("x1", d.x)
729
+ .attr("y1", d.y)
730
+ .attr("x2", d.x)
731
+ .attr("y2", d.y);
732
+
733
+ shiftLinkState = { sourceNode: d, line };
505
734
  });
506
735
 
507
- // Hover tooltip
508
- node.on("mouseenter", function(_event, d) {
509
- d3.select(this).select(".node-label").style("display", "block");
510
- }).on("mouseleave", function() {
511
- if (!graphLabelsVisible) d3.select(this).select(".node-label").style("display", "none");
736
+ svg.on("mousemove", function (event) {
737
+ if (!shiftLinkState) return;
738
+ const pt = this.createSVGPoint();
739
+ pt.x = event.clientX;
740
+ pt.y = event.clientY;
741
+ const svgPt = pt.matrixTransform(g.node().getScreenCTM().inverse());
742
+ shiftLinkState.line.attr("x2", svgPt.x).attr("y2", svgPt.y);
512
743
  });
513
744
 
514
- // ── Legend ──
515
- renderGraphLegend(tierColor);
745
+ svg.on("mouseup", async function (event) {
746
+ if (!shiftLinkState) return;
747
+ shiftLinkState.line.remove();
748
+ const source = shiftLinkState.sourceNode;
749
+ shiftLinkState = null;
750
+
751
+ // Find target node under cursor
752
+ const target = findNodeAt(event, res.data.nodes, g);
753
+ if (!target || target.id === source.id) return;
754
+
755
+ const relationship = prompt(`Link "${truncate(source.label, 30)}" → "${truncate(target.label, 30)}"\n\nRelationship type:`);
756
+ if (!relationship) return;
757
+
758
+ const body = { relationship_type: relationship };
759
+ if (source.type === "persona") body.source_persona_id = source.id;
760
+ else body.source_memory_id = source.id;
761
+ if (target.type === "persona") body.target_persona_id = target.id;
762
+ else body.target_memory_id = target.id;
763
+
764
+ const linkRes = await api("/api/edges", { method: "POST", body: JSON.stringify(body) });
765
+ if (linkRes.ok) {
766
+ toast("Link created", "success");
767
+ loadGraph();
768
+ } else {
769
+ toast(linkRes.error || "Link failed", "error");
770
+ }
771
+ });
516
772
 
517
- // ── Tick ──
518
- graphSimulation.on("tick", () => {
773
+ // Tick
774
+ simulation.on("tick", () => {
519
775
  link
520
- .attr("x1", d => d.source.x).attr("y1", d => d.source.y)
521
- .attr("x2", d => d.target.x).attr("y2", d => d.target.y);
522
- linkLabel
523
- .attr("x", d => (d.source.x + d.target.x) / 2)
524
- .attr("y", d => (d.source.y + d.target.y) / 2);
525
- node.attr("transform", d => `translate(${d.x},${d.y})`);
526
- });
776
+ .attr("x1", (d) => d.source.x)
777
+ .attr("y1", (d) => d.source.y)
778
+ .attr("x2", (d) => d.target.x)
779
+ .attr("y2", (d) => d.target.y);
527
780
 
528
- // Auto-fit after simulation stabilizes
529
- graphSimulation.on("end", () => {
530
- fitGraphToView(data.nodes, width, height);
781
+ edgeLabel
782
+ .attr("x", (d) => (d.source.x + d.target.x) / 2)
783
+ .attr("y", (d) => (d.source.y + d.target.y) / 2);
784
+
785
+ node.attr("transform", (d) => `translate(${d.x},${d.y})`);
531
786
  });
532
787
 
788
+ // Store
789
+ graphInstances = { simulation, svg, g, zoom };
790
+
791
+ // Legend
792
+ renderLegend();
793
+
794
+ // Stats
795
+ document.getElementById("graph-stats").textContent =
796
+ `${res.data.nodes.length} nodes • ${res.data.edges.length} edges — Scroll to zoom • Drag to pan • Click node for details • Shift+drag to link`;
797
+
533
798
  function dragStart(event, d) {
534
- if (!event.active) graphSimulation.alphaTarget(0.3).restart();
535
- d.fx = d.x; d.fy = d.y;
799
+ if (event.sourceEvent && event.sourceEvent.shiftKey) return;
800
+ if (!event.active) simulation.alphaTarget(0.3).restart();
801
+ d.fx = d.x;
802
+ d.fy = d.y;
803
+ }
804
+
805
+ function dragging(event, d) {
806
+ if (event.sourceEvent && event.sourceEvent.shiftKey) return;
807
+ d.fx = event.x;
808
+ d.fy = event.y;
536
809
  }
537
- function dragging(event, d) { d.fx = event.x; d.fy = event.y; }
810
+
538
811
  function dragEnd(event, d) {
539
- if (!event.active) graphSimulation.alphaTarget(0);
540
- d.fx = null; d.fy = null;
812
+ if (!event.active) simulation.alphaTarget(0);
813
+ d.fx = null;
814
+ d.fy = null;
541
815
  }
542
816
  }
543
817
 
544
- function nodeRadius(d) {
545
- return Math.max(5, Math.min(14, 4 + Math.sqrt(d.accessCount || 1) * 2));
818
+ function findNodeAt(event, nodes, gGroup) {
819
+ const svgEl = document.getElementById("graph-svg");
820
+ const pt = svgEl.createSVGPoint();
821
+ pt.x = event.clientX;
822
+ pt.y = event.clientY;
823
+ const svgPt = pt.matrixTransform(gGroup.node().getScreenCTM().inverse());
824
+
825
+ let closest = null;
826
+ let minDist = Infinity;
827
+ for (const n of nodes) {
828
+ const dx = n.x - svgPt.x;
829
+ const dy = n.y - svgPt.y;
830
+ const dist = Math.sqrt(dx * dx + dy * dy);
831
+ if (dist < 20 && dist < minDist) {
832
+ minDist = dist;
833
+ closest = n;
834
+ }
835
+ }
836
+ return closest;
546
837
  }
547
838
 
548
- function fitGraphToView(nodes, width, height) {
549
- if (!nodes || nodes.length === 0 || !graphZoom) return;
550
- const svg = d3.select("#graph-svg");
551
-
552
- let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
553
- nodes.forEach(d => {
554
- if (d.x < minX) minX = d.x;
555
- if (d.x > maxX) maxX = d.x;
556
- if (d.y < minY) minY = d.y;
557
- if (d.y > maxY) maxY = d.y;
558
- });
559
-
560
- const padding = 60;
561
- const graphWidth = maxX - minX + padding * 2;
562
- const graphHeight = maxY - minY + padding * 2;
563
- const scale = Math.min(width / graphWidth, height / graphHeight, 2);
564
- const tx = width / 2 - (minX + maxX) / 2 * scale;
565
- const ty = height / 2 - (minY + maxY) / 2 * scale;
839
+ function nodeRadius(d) {
840
+ if (d.type === "persona") return 10;
841
+ const base = 6;
842
+ return Math.min(base + (d.accessCount || 0) * 0.5, 18);
843
+ }
566
844
 
567
- svg.transition().duration(500)
568
- .call(graphZoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
845
+ function nodeColor(d) {
846
+ if (d.type === "persona") return PERSONA_COLOR;
847
+ return TIER_COLORS[d.tier] || MEMORY_DEFAULT_COLOR;
569
848
  }
570
849
 
571
- function renderGraphLegend(tierColor) {
572
- const legendContainer = $("graph-legend");
573
- if (!legendContainer) return;
574
- legendContainer.innerHTML = Object.entries(tierColor).map(([tier, color]) =>
575
- `<span class="legend-item"><span class="legend-dot" style="background:${color}"></span>${tier}</span>`
576
- ).join("");
850
+ function renderLegend() {
851
+ const legend = document.getElementById("graph-legend");
852
+ const items = [
853
+ ...Object.entries(TIER_COLORS).map(([tier, color]) => `<div class="legend-item"><span class="legend-dot" style="background:${color}"></span>${tier}</div>`),
854
+ `<div class="legend-item"><span class="legend-diamond" style="background:${PERSONA_COLOR}"></span>persona</div>`,
855
+ ];
856
+ legend.innerHTML = items.join("");
577
857
  }
578
858
 
579
- function showGraphDetail(d) {
580
- const panel = $("graph-detail");
581
- if (!panel) return;
859
+ function showNodeDetail(d) {
860
+ const panel = document.getElementById("graph-detail");
582
861
  panel.style.display = "block";
862
+
863
+ const typeLabel = d.type === "persona" ? "🎭 Persona Trait" : "🧠 Memory";
583
864
  panel.innerHTML = `
584
865
  <div class="detail-header">
585
- <span class="badge badge-${d.tier || 'daily'}">${d.tier || "daily"}</span>
866
+ <strong>${typeLabel}</strong>
586
867
  <span class="detail-category">${d.category || "uncategorized"}</span>
587
- <button class="btn-sm btn-secondary" onclick="this.closest('.graph-detail-panel').style.display='none'">✕</button>
868
+ ${badge(d.tier || "")}
869
+ <span class="detail-id">${d.id.substring(0, 8)}</span>
870
+ <button class="btn-sm btn-secondary" onclick="document.getElementById('graph-detail').style.display='none'">✕</button>
588
871
  </div>
589
- <p class="detail-content">${d.label || "—"}</p>
872
+ <div class="detail-content">${d.label}</div>
590
873
  <div class="detail-stats">
591
- <span>Access: <strong>${d.accessCount || 0}</strong></span>
592
- <span>Score: <strong>${(d.score || 0).toFixed(2)}</strong></span>
593
- <span class="detail-id">${d.id}</span>
874
+ <span>Accesses: ${d.accessCount || 0}</span>
875
+ <span>Score: ${d.score != null ? d.score.toFixed(1) : "—"}</span>
876
+ ${d.isAlwaysActive ? "<span>Always Active ✅</span>" : ""}
594
877
  </div>
595
878
  `;
596
879
  }
597
880
 
598
- // Controls
599
- $("btn-refresh-graph").addEventListener("click", loadGraph);
600
-
601
- $("btn-fit-graph").addEventListener("click", () => {
602
- if (!graphSimulation) return;
603
- const container = $("graph-container");
604
- const nodes = graphSimulation.nodes();
605
- fitGraphToView(nodes, container.clientWidth, container.clientHeight);
881
+ // Graph controls
882
+ document.getElementById("btn-fit-graph").addEventListener("click", () => {
883
+ if (!graphInstances.svg || !graphInstances.zoom) return;
884
+ graphInstances.svg
885
+ .transition()
886
+ .duration(500)
887
+ .call(graphInstances.zoom.transform, d3.zoomIdentity);
606
888
  });
607
889
 
608
- $("btn-toggle-labels").addEventListener("click", () => {
609
- graphLabelsVisible = !graphLabelsVisible;
610
- d3.selectAll(".node-label").style("display", graphLabelsVisible ? "block" : "none");
611
- $("btn-toggle-labels").textContent = graphLabelsVisible ? "🏷️ Labels On" : "🏷️ Labels Off";
890
+ document.getElementById("btn-toggle-labels").addEventListener("click", () => {
891
+ showLabels = !showLabels;
892
+ d3.selectAll(".node-label").style("display", showLabels ? "block" : "none");
893
+ document.getElementById("btn-toggle-labels").textContent = showLabels ? "🏷️ Labels On" : "🏷️ Labels Off";
612
894
  });
613
895
 
896
+ document.getElementById("btn-refresh-graph").addEventListener("click", () => loadGraph());
614
897
 
615
898
  // =============================================================================
616
899
  // SCRIPTS
617
900
  // =============================================================================
618
901
 
619
- $("btn-run-sleep").addEventListener("click", async () => {
620
- const status = $("sleep-status");
621
- status.textContent = "Running...";
622
- status.className = "script-status running";
623
- try {
624
- await api("POST", "/api/scripts/sleep", { agentId: currentAgent });
625
- status.textContent = "Sleep cycle started! Check server logs for progress.";
626
- status.className = "script-status success";
627
- } catch (err) {
628
- status.textContent = `Error: ${err.message}`;
629
- status.className = "script-status error";
902
+ document.getElementById("btn-run-sleep").addEventListener("click", async () => {
903
+ const statusEl = document.getElementById("sleep-status");
904
+ statusEl.textContent = "Running sleep cycle…";
905
+ statusEl.className = "script-status running";
906
+
907
+ const res = await api("/api/scripts/sleep", { method: "POST", body: JSON.stringify({}) });
908
+
909
+ if (res.ok) {
910
+ statusEl.textContent = "✅ " + (res.data?.message || "Complete");
911
+ statusEl.className = "script-status success";
912
+ } else {
913
+ statusEl.textContent = "❌ " + (res.error || "Failed");
914
+ statusEl.className = "script-status error";
630
915
  }
631
916
  });
632
917
 
633
- $("btn-run-persona-import").addEventListener("click", async () => {
634
- const file = $("persona-import-file").value;
635
- if (!file.trim()) return toast("Enter a file path", "error");
636
- const status = $("persona-import-status");
637
- status.textContent = "Running...";
638
- status.className = "script-status running";
639
- try {
640
- await api("POST", "/api/scripts/persona-import", { agentId: currentAgent, file });
641
- status.textContent = "Persona import started! Check server logs for progress.";
642
- status.className = "script-status success";
643
- } catch (err) {
644
- status.textContent = `Error: ${err.message}`;
645
- status.className = "script-status error";
918
+ document.getElementById("btn-run-persona-import").addEventListener("click", async () => {
919
+ const file = document.getElementById("persona-import-file").value;
920
+ if (!file) return toast("Enter a file path", "error");
921
+
922
+ const statusEl = document.getElementById("persona-import-status");
923
+ statusEl.textContent = "Importing…";
924
+ statusEl.className = "script-status running";
925
+
926
+ const res = await api("/api/scripts/persona-import", {
927
+ method: "POST",
928
+ body: JSON.stringify({ file }),
929
+ });
930
+
931
+ if (res.ok) {
932
+ statusEl.textContent = "✅ " + (res.data?.message || "Done");
933
+ statusEl.className = "script-status success";
934
+ } else {
935
+ statusEl.textContent = "❌ " + (res.error || "Failed");
936
+ statusEl.className = "script-status error";
646
937
  }
647
938
  });
648
939
 
649
940
  // =============================================================================
650
- // CONFIGURATION
941
+ // CONFIG
651
942
  // =============================================================================
652
943
 
944
+ const CONFIG_MAP = [
945
+ { id: "cfg-rag-semantic-limit", path: "rag.semanticLimit", type: "number" },
946
+ { id: "cfg-rag-total-limit", path: "rag.totalLimit", type: "number" },
947
+ { id: "cfg-rag-linked-similarity", path: "rag.linkedSimilarity", type: "number" },
948
+ { id: "cfg-rag-max-traversal-depth", path: "rag.maxTraversalDepth", type: "number" },
949
+ { id: "cfg-persona-situational-limit", path: "persona.situationalLimit", type: "number" },
950
+ { id: "cfg-prompt-memory", path: "prompts.memoryRules", type: "text" },
951
+ { id: "cfg-prompt-persona", path: "prompts.personaRules", type: "text" },
952
+ { id: "cfg-prompt-heartbeat", path: "prompts.heartbeatRules", type: "text" },
953
+ { id: "cfg-prompt-heartbeat-path", path: "prompts.heartbeatFilePath", type: "text" },
954
+ { id: "cfg-sleep-dedup-threshold", path: "sleep.dedupSimilarityThreshold", type: "number" },
955
+ { id: "cfg-sleep-low-value-age", path: "sleep.lowValueAgeDays", type: "number" },
956
+ { id: "cfg-sleep-link-min", path: "sleep.linkMinSimilarity", type: "number" },
957
+ { id: "cfg-sleep-link-max", path: "sleep.linkMaxSimilarity", type: "number" },
958
+ { id: "cfg-sleep-episodic-batch", path: "sleep.episodicBatchLimit", type: "number" },
959
+ { id: "cfg-sleep-dedup-scan", path: "sleep.duplicateScanLimit", type: "number" },
960
+ { id: "cfg-sleep-link-candidates", path: "sleep.linkCandidatesPerMemory", type: "number" },
961
+ { id: "cfg-sleep-link-batch", path: "sleep.linkBatchSize", type: "number" },
962
+ { id: "cfg-sleep-link-scan", path: "sleep.linkScanLimit", type: "number" },
963
+ { id: "cfg-sleep-protected-tiers", path: "sleep.lowValueProtectedTiers", type: "csv" },
964
+ { id: "cfg-dedup-cache", path: "dedup.maxCacheSize", type: "number" },
965
+ ];
966
+
967
+ function getConfigValue(cfg, path) {
968
+ return path.split(".").reduce((o, k) => o?.[k], cfg);
969
+ }
970
+
971
+ function setConfigValue(cfg, path, value) {
972
+ const keys = path.split(".");
973
+ let obj = cfg;
974
+ for (let i = 0; i < keys.length - 1; i++) {
975
+ if (!obj[keys[i]]) obj[keys[i]] = {};
976
+ obj = obj[keys[i]];
977
+ }
978
+ obj[keys[keys.length - 1]] = value;
979
+ }
980
+
653
981
  async function loadConfig() {
654
- try {
655
- const config = await api("GET", "/api/config");
656
-
657
- // RAG
658
- $("cfg-rag-semantic-limit").value = config.rag.semanticLimit;
659
- $("cfg-rag-total-limit").value = config.rag.totalLimit;
660
- $("cfg-rag-linked-similarity").value = config.rag.linkedSimilarity;
661
-
662
- // Persona
663
- $("cfg-persona-situational-limit").value = config.persona.situationalLimit;
664
-
665
- // Prompts
666
- $("cfg-prompt-memory").value = config.prompts.memoryRules;
667
- $("cfg-prompt-persona").value = config.prompts.personaRules;
668
- $("cfg-prompt-heartbeat").value = config.prompts.heartbeatRules;
669
- $("cfg-prompt-heartbeat-path").value = config.prompts.heartbeatFilePath;
670
-
671
- // Sleep
672
- $("cfg-sleep-dedup-threshold").value = config.sleep.duplicateSimilarityThreshold;
673
- $("cfg-sleep-low-value-age").value = config.sleep.lowValueAgeDays;
674
- $("cfg-sleep-link-min").value = config.sleep.linkSimilarityMin;
675
- $("cfg-sleep-link-max").value = config.sleep.linkSimilarityMax;
676
-
677
- // Technical
678
- $("cfg-dedup-cache").value = config.dedup.maxCacheSize;
679
-
680
- } catch (err) { toast(err.message, "error"); }
982
+ const res = await api("/api/config");
983
+ if (!res.ok) return;
984
+ const cfg = res.data;
985
+
986
+ for (const item of CONFIG_MAP) {
987
+ const el = document.getElementById(item.id);
988
+ if (!el) continue;
989
+ const val = getConfigValue(cfg, item.path);
990
+ if (item.type === "csv" && Array.isArray(val)) {
991
+ el.value = val.join(", ");
992
+ } else {
993
+ el.value = val ?? "";
994
+ }
995
+ }
681
996
  }
682
997
 
683
- $("btn-save-config").addEventListener("click", async () => {
684
- const data = {
685
- rag: {
686
- semanticLimit: parseInt($("cfg-rag-semantic-limit").value),
687
- totalLimit: parseInt($("cfg-rag-total-limit").value),
688
- linkedSimilarity: parseFloat($("cfg-rag-linked-similarity").value)
689
- },
690
- persona: {
691
- situationalLimit: parseInt($("cfg-persona-situational-limit").value)
692
- },
693
- prompts: {
694
- memoryRules: $("cfg-prompt-memory").value,
695
- personaRules: $("cfg-prompt-persona").value,
696
- heartbeatRules: $("cfg-prompt-heartbeat").value,
697
- heartbeatFilePath: $("cfg-prompt-heartbeat-path").value
698
- },
699
- sleep: {
700
- // Keep other sleep settings at their defaults if not in UI
701
- episodicBatchLimit: 100,
702
- duplicateScanLimit: 200,
703
- linkCandidatesPerMemory: 5,
704
- linkBatchSize: 20,
705
- linkScanLimit: 50,
706
- lowValueProtectedTiers: ['permanent', 'stable'],
707
-
708
- duplicateSimilarityThreshold: parseFloat($("cfg-sleep-dedup-threshold").value),
709
- lowValueAgeDays: parseInt($("cfg-sleep-low-value-age").value),
710
- linkSimilarityMin: parseFloat($("cfg-sleep-link-min").value),
711
- linkSimilarityMax: parseFloat($("cfg-sleep-link-max").value),
712
- },
713
- dedup: {
714
- maxCacheSize: parseInt($("cfg-dedup-cache").value)
998
+ document.getElementById("btn-save-config").addEventListener("click", async () => {
999
+ const res = await api("/api/config");
1000
+ if (!res.ok) return;
1001
+ const cfg = res.data;
1002
+
1003
+ for (const item of CONFIG_MAP) {
1004
+ const el = document.getElementById(item.id);
1005
+ if (!el) continue;
1006
+ let val;
1007
+ if (item.type === "number") {
1008
+ val = parseFloat(el.value);
1009
+ if (isNaN(val)) continue;
1010
+ } else if (item.type === "csv") {
1011
+ val = el.value.split(",").map((s) => s.trim()).filter(Boolean);
1012
+ } else {
1013
+ val = el.value;
715
1014
  }
716
- };
1015
+ setConfigValue(cfg, item.path, val);
1016
+ }
717
1017
 
718
- try {
719
- await api("POST", "/api/config", data);
720
- toast("Configuration saved!", "success");
721
- loadConfig();
722
- } catch (err) { toast(err.message, "error"); }
1018
+ const saveRes = await api("/api/config", {
1019
+ method: "POST",
1020
+ body: JSON.stringify(cfg),
1021
+ });
1022
+
1023
+ if (saveRes.ok) {
1024
+ toast("Configuration saved", "success");
1025
+ } else {
1026
+ toast(saveRes.error || "Save failed", "error");
1027
+ }
723
1028
  });
724
1029
 
725
- $("btn-reset-config").addEventListener("click", async () => {
1030
+ document.getElementById("btn-reset-config").addEventListener("click", async () => {
726
1031
  if (!confirm("Reset all settings to defaults?")) return;
727
- try {
728
- await api("POST", "/api/config/reset");
729
- toast("Configuration reset to defaults", "success");
1032
+ const res = await api("/api/config/reset", { method: "POST" });
1033
+ if (res.ok) {
1034
+ toast("Reset to defaults", "success");
730
1035
  loadConfig();
731
- } catch (err) { toast(err.message, "error"); }
1036
+ } else {
1037
+ toast(res.error || "Reset failed", "error");
1038
+ }
732
1039
  });
733
1040
 
1041
+ // =============================================================================
1042
+ // LIGHT SLIDER
1043
+ // =============================================================================
1044
+
1045
+ const FILL_LIGHT = 0.75;
1046
+
1047
+ function applyLighting(key) {
1048
+ const root = document.documentElement;
1049
+ const k = parseFloat(key);
1050
+
1051
+ root.style.setProperty('--amb-key-light-intensity', k);
1052
+ root.style.setProperty('--amb-fill-light-intensity', FILL_LIGHT);
1053
+
1054
+ // Text must always contrast against amb-surface (lightness ≈ key × 100%).
1055
+ // Step function: dark surface → light text, light surface → dark text.
1056
+ const s = k * 100;
1057
+ let textL, secL, mutL;
1058
+
1059
+ if (s < 50) {
1060
+ textL = 92;
1061
+ secL = 68;
1062
+ mutL = 55;
1063
+ } else {
1064
+ textL = 10;
1065
+ secL = 30;
1066
+ mutL = 40;
1067
+ }
1068
+
1069
+ root.style.setProperty('--text-primary', `hsl(0 0% ${textL}%)`);
1070
+ root.style.setProperty('--text-secondary', `hsl(0 0% ${secL}%)`);
1071
+ root.style.setProperty('--text-muted', `hsl(0 0% ${mutL}%)`);
1072
+
1073
+ const kDisp = document.getElementById('key-light-value');
1074
+ if (kDisp) kDisp.textContent = k.toFixed(2);
1075
+ }
1076
+
1077
+ function initLightSlider() {
1078
+ const slider = document.getElementById('key-light-slider');
1079
+ if (!slider) return;
1080
+
1081
+ const stored = localStorage.getItem('postclaw-key-light');
1082
+ if (stored) slider.value = stored;
1083
+
1084
+ applyLighting(slider.value);
1085
+
1086
+ slider.addEventListener('input', () => {
1087
+ applyLighting(slider.value);
1088
+ localStorage.setItem('postclaw-key-light', slider.value);
1089
+ });
1090
+ }
1091
+
734
1092
  // =============================================================================
735
1093
  // INIT
736
1094
  // =============================================================================
737
1095
 
738
- (async function init() {
1096
+ async function init() {
1097
+ initLightSlider();
739
1098
  await loadAgents();
740
1099
  loadPersonas();
1100
+ loadMemories();
741
1101
  loadWorkspaceFiles();
742
- loadConfig(); // Pre-load config state
743
- })();
1102
+ }
1103
+
1104
+ init();