@christopherlittle51/postclaw 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dashboard/public/app.js +743 -0
  2. package/dashboard/public/index.html +350 -0
  3. package/dashboard/public/styles.css +571 -0
  4. package/dist/dashboard/helpers.d.ts +32 -0
  5. package/dist/dashboard/helpers.d.ts.map +1 -0
  6. package/dist/dashboard/helpers.js +122 -0
  7. package/dist/dashboard/helpers.js.map +1 -0
  8. package/dist/dashboard/router.d.ts +39 -0
  9. package/dist/dashboard/router.d.ts.map +1 -0
  10. package/dist/dashboard/router.js +94 -0
  11. package/dist/dashboard/router.js.map +1 -0
  12. package/dist/dashboard/routes/config.d.ts +3 -0
  13. package/dist/dashboard/routes/config.d.ts.map +1 -0
  14. package/dist/dashboard/routes/config.js +53 -0
  15. package/dist/dashboard/routes/config.js.map +1 -0
  16. package/dist/dashboard/routes/graph.d.ts +11 -0
  17. package/dist/dashboard/routes/graph.d.ts.map +1 -0
  18. package/dist/dashboard/routes/graph.js +131 -0
  19. package/dist/dashboard/routes/graph.js.map +1 -0
  20. package/dist/dashboard/routes/memories.d.ts +12 -0
  21. package/dist/dashboard/routes/memories.d.ts.map +1 -0
  22. package/dist/dashboard/routes/memories.js +199 -0
  23. package/dist/dashboard/routes/memories.js.map +1 -0
  24. package/dist/dashboard/routes/personas.d.ts +11 -0
  25. package/dist/dashboard/routes/personas.d.ts.map +1 -0
  26. package/dist/dashboard/routes/personas.js +71 -0
  27. package/dist/dashboard/routes/personas.js.map +1 -0
  28. package/dist/dashboard/routes/scripts.d.ts +9 -0
  29. package/dist/dashboard/routes/scripts.d.ts.map +1 -0
  30. package/dist/dashboard/routes/scripts.js +62 -0
  31. package/dist/dashboard/routes/scripts.js.map +1 -0
  32. package/dist/dashboard/routes/workspace.d.ts +10 -0
  33. package/dist/dashboard/routes/workspace.d.ts.map +1 -0
  34. package/dist/dashboard/routes/workspace.js +82 -0
  35. package/dist/dashboard/routes/workspace.js.map +1 -0
  36. package/dist/dashboard/server.d.ts +14 -0
  37. package/dist/dashboard/server.d.ts.map +1 -0
  38. package/dist/dashboard/server.js +97 -0
  39. package/dist/dashboard/server.js.map +1 -0
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +142 -46
  43. package/dist/index.js.map +1 -1
  44. package/dist/schemas/validation.d.ts +123 -0
  45. package/dist/schemas/validation.d.ts.map +1 -1
  46. package/dist/schemas/validation.js +70 -1
  47. package/dist/schemas/validation.js.map +1 -1
  48. package/dist/scripts/setup-db.d.ts.map +1 -1
  49. package/dist/scripts/setup-db.js +24 -25
  50. package/dist/scripts/setup-db.js.map +1 -1
  51. package/dist/scripts/sleep_cycle.d.ts +12 -0
  52. package/dist/scripts/sleep_cycle.d.ts.map +1 -1
  53. package/dist/scripts/sleep_cycle.js +45 -33
  54. package/dist/scripts/sleep_cycle.js.map +1 -1
  55. package/dist/services/config.d.ts +31 -0
  56. package/dist/services/config.d.ts.map +1 -0
  57. package/dist/services/config.js +125 -0
  58. package/dist/services/config.js.map +1 -0
  59. package/dist/services/memoryService.d.ts +10 -19
  60. package/dist/services/memoryService.d.ts.map +1 -1
  61. package/dist/services/memoryService.js +20 -76
  62. package/dist/services/memoryService.js.map +1 -1
  63. package/dist/services/personaService.d.ts +22 -0
  64. package/dist/services/personaService.d.ts.map +1 -0
  65. package/dist/services/personaService.js +148 -0
  66. package/dist/services/personaService.js.map +1 -0
  67. package/dist/tests/dashboard-schemas.test.d.ts +6 -0
  68. package/dist/tests/dashboard-schemas.test.d.ts.map +1 -0
  69. package/dist/tests/dashboard-schemas.test.js +171 -0
  70. package/dist/tests/dashboard-schemas.test.js.map +1 -0
  71. package/dist/tests/helpers.test.d.ts +5 -0
  72. package/dist/tests/helpers.test.d.ts.map +1 -0
  73. package/dist/tests/helpers.test.js +66 -0
  74. package/dist/tests/helpers.test.js.map +1 -0
  75. package/dist/tests/router.test.d.ts +5 -0
  76. package/dist/tests/router.test.d.ts.map +1 -0
  77. package/dist/tests/router.test.js +125 -0
  78. package/dist/tests/router.test.js.map +1 -0
  79. package/openclaw.plugin.json +23 -0
  80. package/package.json +7 -2
@@ -0,0 +1,743 @@
1
+ /**
2
+ * PostClaw Dashboard — Frontend Application
3
+ *
4
+ * Tab management, API calls, DOM updates, D3 knowledge graph.
5
+ */
6
+
7
+ // =============================================================================
8
+ // STATE
9
+ // =============================================================================
10
+
11
+ let currentAgent = "main";
12
+ let memoryPage = 0;
13
+ const PAGE_SIZE = 50;
14
+
15
+ // =============================================================================
16
+ // UTILITIES
17
+ // =============================================================================
18
+
19
+ function $(id) { return document.getElementById(id); }
20
+
21
+ async function api(method, path, body = null) {
22
+ 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;
30
+ }
31
+
32
+ function toast(message, type = "info") {
33
+ const container = $("toast-container");
34
+ const el = document.createElement("div");
35
+ el.className = `toast toast-${type}`;
36
+ el.textContent = message;
37
+ container.appendChild(el);
38
+ setTimeout(() => el.remove(), 3000);
39
+ }
40
+
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>`;
47
+ }
48
+
49
+ function truncate(str, len = 80) {
50
+ if (!str) return "";
51
+ return str.length > len ? str.substring(0, len) + "…" : str;
52
+ }
53
+
54
+ // =============================================================================
55
+ // TAB MANAGEMENT
56
+ // =============================================================================
57
+
58
+ document.querySelectorAll(".tab-btn").forEach(btn => {
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"));
62
+ btn.classList.add("active");
63
+ $(`tab-${btn.dataset.tab}`).classList.add("active");
64
+
65
+ // Load data for the active tab
66
+ if (btn.dataset.tab === "personas") loadPersonas();
67
+ if (btn.dataset.tab === "memories") loadMemories();
68
+ if (btn.dataset.tab === "graph") loadGraph();
69
+ if (btn.dataset.tab === "config") loadConfig();
70
+ });
71
+ });
72
+
73
+ // =============================================================================
74
+ // AGENT SELECTOR
75
+ // =============================================================================
76
+
77
+ 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 */ }
90
+ }
91
+
92
+ $("agent-select").addEventListener("change", (e) => {
93
+ currentAgent = e.target.value;
94
+ // Reload active tab
95
+ const activeTab = document.querySelector(".tab-btn.active");
96
+ if (activeTab) activeTab.click();
97
+ });
98
+
99
+ // =============================================================================
100
+ // PERSONAS
101
+ // =============================================================================
102
+
103
+ 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 => `
117
+ <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>
121
+ <td class="actions">
122
+ <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>
124
+ </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"); }
143
+ }
144
+ window.editPersona = editPersona;
145
+
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";
162
+ });
163
+
164
+ $("btn-cancel-persona").addEventListener("click", () => {
165
+ $("persona-form").style.display = "none";
166
+ });
167
+
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,
174
+ };
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";
184
+ loadPersonas();
185
+ } catch (err) { toast(err.message, "error"); }
186
+ });
187
+
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"); }
208
+ };
209
+
210
+ // =============================================================================
211
+ // MEMORIES
212
+ // =============================================================================
213
+
214
+ 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">
245
+ <button class="btn-sm btn-secondary" onclick="editMemory('${m.id}')">✏️</button>
246
+ <button class="btn-sm btn-danger" onclick="archiveMemory('${m.id}')">🗑️</button>
247
+ </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"); }
260
+ }
261
+
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"); }
275
+ };
276
+
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"); }
284
+ };
285
+
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";
293
+ });
294
+
295
+ $("btn-cancel-memory").addEventListener("click", () => {
296
+ $("memory-form").style.display = "none";
297
+ });
298
+
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,
305
+ };
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
+ });
318
+
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";
326
+ });
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";
337
+ loadMemories();
338
+ } catch (err) { toast(err.message, "error"); }
339
+ });
340
+
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(); });
345
+
346
+ function debounce(fn, ms) {
347
+ let timer;
348
+ return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
349
+ }
350
+
351
+ // =============================================================================
352
+ // KNOWLEDGE GRAPH (D3.js)
353
+ // =============================================================================
354
+
355
+ let graphSimulation = null;
356
+ let graphZoom = null;
357
+ let graphLabelsVisible = true;
358
+
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"); }
365
+ }
366
+
367
+ function renderGraph(data) {
368
+ const svg = d3.select("#graph-svg");
369
+ svg.selectAll("*").remove();
370
+
371
+ const container = $("graph-container");
372
+ const width = container.clientWidth;
373
+ const height = container.clientHeight;
374
+
375
+ svg.attr("viewBox", [0, 0, width, height]);
376
+
377
+ // ── Defs: glow filters + arrowhead markers ──
378
+ const defs = svg.append("defs");
379
+
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
+ };
392
+
393
+ // Relationship colors
394
+ const relColor = {
395
+ related_to: "#6b7280", elaborates: "#4f9cf7", contradicts: "#f7555a",
396
+ depends_on: "#f7b955", part_of: "#a78bfa",
397
+ };
398
+
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);
413
+ });
414
+
415
+ // ── Zoom behavior ──
416
+ const world = svg.append("g").attr("class", "graph-world");
417
+
418
+ graphZoom = d3.zoom()
419
+ .scaleExtent([0.1, 6])
420
+ .on("zoom", (event) => {
421
+ world.attr("transform", event.transform);
422
+ });
423
+
424
+ svg.call(graphZoom);
425
+ // Disable double-click zoom (conflicts with node interaction)
426
+ svg.on("dblclick.zoom", null);
427
+
428
+ // ── Force simulation ──
429
+ if (graphSimulation) graphSimulation.stop();
430
+
431
+ const nodeCount = data.nodes.length;
432
+ const chargeStrength = nodeCount > 100 ? -60 : nodeCount > 50 ? -80 : -100;
433
+ const linkDist = nodeCount > 100 ? 80 : 120;
434
+
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))
438
+ .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);
443
+
444
+ // ── Edges ──
445
+ const link = world.append("g").attr("class", "graph-edges")
446
+ .selectAll("line")
447
+ .data(data.edges)
448
+ .join("line")
449
+ .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"})`);
453
+
454
+ // Edge labels
455
+ const linkLabel = world.append("g").attr("class", "graph-edge-labels")
456
+ .selectAll("text")
457
+ .data(data.edges)
458
+ .join("text")
459
+ .attr("class", "graph-edge-label")
460
+ .text(d => d.relationship || "");
461
+
462
+ // ── Nodes ──
463
+ const node = world.append("g").attr("class", "graph-nodes")
464
+ .selectAll("g")
465
+ .data(data.nodes)
466
+ .join("g")
467
+ .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")
475
+ .attr("class", "node-glow")
476
+ .attr("r", d => nodeRadius(d) + 4)
477
+ .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);
490
+
491
+ // Labels
492
+ node.append("text")
493
+ .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);
505
+ });
506
+
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");
512
+ });
513
+
514
+ // ── Legend ──
515
+ renderGraphLegend(tierColor);
516
+
517
+ // ── Tick ──
518
+ graphSimulation.on("tick", () => {
519
+ 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
+ });
527
+
528
+ // Auto-fit after simulation stabilizes
529
+ graphSimulation.on("end", () => {
530
+ fitGraphToView(data.nodes, width, height);
531
+ });
532
+
533
+ function dragStart(event, d) {
534
+ if (!event.active) graphSimulation.alphaTarget(0.3).restart();
535
+ d.fx = d.x; d.fy = d.y;
536
+ }
537
+ function dragging(event, d) { d.fx = event.x; d.fy = event.y; }
538
+ function dragEnd(event, d) {
539
+ if (!event.active) graphSimulation.alphaTarget(0);
540
+ d.fx = null; d.fy = null;
541
+ }
542
+ }
543
+
544
+ function nodeRadius(d) {
545
+ return Math.max(5, Math.min(14, 4 + Math.sqrt(d.accessCount || 1) * 2));
546
+ }
547
+
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;
566
+
567
+ svg.transition().duration(500)
568
+ .call(graphZoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
569
+ }
570
+
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("");
577
+ }
578
+
579
+ function showGraphDetail(d) {
580
+ const panel = $("graph-detail");
581
+ if (!panel) return;
582
+ panel.style.display = "block";
583
+ panel.innerHTML = `
584
+ <div class="detail-header">
585
+ <span class="badge badge-${d.tier || 'daily'}">${d.tier || "daily"}</span>
586
+ <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>
588
+ </div>
589
+ <p class="detail-content">${d.label || "—"}</p>
590
+ <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>
594
+ </div>
595
+ `;
596
+ }
597
+
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);
606
+ });
607
+
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";
612
+ });
613
+
614
+
615
+ // =============================================================================
616
+ // SCRIPTS
617
+ // =============================================================================
618
+
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";
630
+ }
631
+ });
632
+
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";
646
+ }
647
+ });
648
+
649
+ // =============================================================================
650
+ // CONFIGURATION
651
+ // =============================================================================
652
+
653
+ 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"); }
681
+ }
682
+
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)
715
+ }
716
+ };
717
+
718
+ try {
719
+ await api("POST", "/api/config", data);
720
+ toast("Configuration saved!", "success");
721
+ loadConfig();
722
+ } catch (err) { toast(err.message, "error"); }
723
+ });
724
+
725
+ $("btn-reset-config").addEventListener("click", async () => {
726
+ if (!confirm("Reset all settings to defaults?")) return;
727
+ try {
728
+ await api("POST", "/api/config/reset");
729
+ toast("Configuration reset to defaults", "success");
730
+ loadConfig();
731
+ } catch (err) { toast(err.message, "error"); }
732
+ });
733
+
734
+ // =============================================================================
735
+ // INIT
736
+ // =============================================================================
737
+
738
+ (async function init() {
739
+ await loadAgents();
740
+ loadPersonas();
741
+ loadWorkspaceFiles();
742
+ loadConfig(); // Pre-load config state
743
+ })();