@christopherlittle51/postclaw 1.3.1 → 1.3.4

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