@199-bio/engram 0.8.0 → 0.10.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.
@@ -1,17 +1,17 @@
1
1
  /**
2
2
  * Engram Web Interface
3
- * Vanilla JavaScript - no build step required
3
+ * Modern vanilla JavaScript - no build step required
4
4
  */
5
5
 
6
6
  const API_BASE = '';
7
7
 
8
- // State
8
+ // ============ State ============
9
9
  let currentView = 'memories';
10
10
  let editingMemoryId = null;
11
11
  let memoriesOffset = 0;
12
12
  const MEMORIES_PAGE_SIZE = 25;
13
13
 
14
- // DOM Elements
14
+ // ============ DOM Elements ============
15
15
  const views = {
16
16
  memories: document.getElementById('memories-view'),
17
17
  entities: document.getElementById('entities-view'),
@@ -20,11 +20,34 @@ const views = {
20
20
  settings: document.getElementById('settings-view'),
21
21
  };
22
22
 
23
- const statsEl = document.getElementById('stats');
23
+ const viewTitles = {
24
+ memories: 'Memories',
25
+ entities: 'Entities',
26
+ graph: 'Knowledge Graph',
27
+ consolidation: 'Consolidation',
28
+ settings: 'Settings',
29
+ };
30
+
31
+ // Sidebar elements
32
+ const sidebar = document.getElementById('sidebar');
33
+ const sidebarToggle = document.getElementById('sidebar-toggle');
34
+
35
+ // Stats elements
36
+ const statMemories = document.getElementById('stat-memories');
37
+ const statEntities = document.getElementById('stat-entities');
38
+ const statRelations = document.getElementById('stat-relations');
39
+
40
+ // Header elements
41
+ const viewTitle = document.getElementById('view-title');
42
+ const searchInput = document.getElementById('search-input');
43
+ const headerSearch = document.getElementById('header-search');
44
+
45
+ // List containers
24
46
  const memoriesList = document.getElementById('memories-list');
25
47
  const entitiesList = document.getElementById('entities-list');
26
48
  const graphContainer = document.getElementById('graph-container');
27
- const searchInput = document.getElementById('search-input');
49
+
50
+ // Filters
28
51
  const entityTypeFilter = document.getElementById('entity-type-filter');
29
52
 
30
53
  // Modal elements
@@ -40,7 +63,75 @@ const entityModal = document.getElementById('entity-modal');
40
63
  const entityModalTitle = document.getElementById('entity-modal-title');
41
64
  const entityModalBody = document.getElementById('entity-modal-body');
42
65
 
43
- // API helpers
66
+ // Theme elements
67
+ const themeToggle = document.getElementById('theme-toggle');
68
+ const themeSelect = document.getElementById('theme-select');
69
+
70
+ // ============ Theme Management ============
71
+ function initTheme() {
72
+ const savedTheme = localStorage.getItem('engram-theme') || 'system';
73
+ applyTheme(savedTheme);
74
+ if (themeSelect) {
75
+ themeSelect.value = savedTheme;
76
+ }
77
+ }
78
+
79
+ function applyTheme(theme) {
80
+ if (theme === 'system') {
81
+ document.documentElement.removeAttribute('data-theme');
82
+ } else {
83
+ document.documentElement.setAttribute('data-theme', theme);
84
+ }
85
+ localStorage.setItem('engram-theme', theme);
86
+ }
87
+
88
+ function toggleTheme() {
89
+ const current = document.documentElement.getAttribute('data-theme');
90
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
91
+
92
+ // Determine current effective theme
93
+ let effectiveDark = current === 'dark' || (current !== 'light' && prefersDark);
94
+
95
+ // Toggle to opposite
96
+ const newTheme = effectiveDark ? 'light' : 'dark';
97
+ applyTheme(newTheme);
98
+
99
+ if (themeSelect) {
100
+ themeSelect.value = newTheme;
101
+ }
102
+ }
103
+
104
+ // Theme event listeners
105
+ if (themeToggle) {
106
+ themeToggle.addEventListener('click', toggleTheme);
107
+ }
108
+
109
+ if (themeSelect) {
110
+ themeSelect.addEventListener('change', (e) => {
111
+ applyTheme(e.target.value);
112
+ });
113
+ }
114
+
115
+ // ============ Sidebar Management ============
116
+ function initSidebar() {
117
+ const collapsed = localStorage.getItem('engram-sidebar-collapsed') === 'true';
118
+ if (collapsed) {
119
+ sidebar.classList.add('collapsed');
120
+ document.body.classList.add('sidebar-collapsed');
121
+ }
122
+ }
123
+
124
+ function toggleSidebar() {
125
+ sidebar.classList.toggle('collapsed');
126
+ document.body.classList.toggle('sidebar-collapsed');
127
+ localStorage.setItem('engram-sidebar-collapsed', sidebar.classList.contains('collapsed'));
128
+ }
129
+
130
+ if (sidebarToggle) {
131
+ sidebarToggle.addEventListener('click', toggleSidebar);
132
+ }
133
+
134
+ // ============ API Helper ============
44
135
  async function api(path, options = {}) {
45
136
  const res = await fetch(`${API_BASE}${path}`, {
46
137
  headers: { 'Content-Type': 'application/json' },
@@ -50,7 +141,7 @@ async function api(path, options = {}) {
50
141
  return res.json();
51
142
  }
52
143
 
53
- // Format date
144
+ // ============ Utilities ============
54
145
  function formatDate(dateStr) {
55
146
  const date = new Date(dateStr);
56
147
  return date.toLocaleDateString('en-GB', {
@@ -62,20 +153,38 @@ function formatDate(dateStr) {
62
153
  });
63
154
  }
64
155
 
65
- // Load stats
156
+ function escapeHtml(str) {
157
+ if (!str) return '';
158
+ return str
159
+ .replace(/&/g, '&')
160
+ .replace(/</g, '&lt;')
161
+ .replace(/>/g, '&gt;')
162
+ .replace(/"/g, '&quot;')
163
+ .replace(/'/g, '&#039;');
164
+ }
165
+
166
+ // ============ Stats ============
66
167
  async function loadStats() {
67
- const stats = await api('/api/stats');
68
- let text = `${stats.memories} memories \u00b7 ${stats.entities} entities \u00b7 ${stats.relations} relations`;
69
- if (stats.digests > 0) {
70
- text += ` \u00b7 ${stats.digests} digests`;
71
- }
72
- if (stats.contradictions > 0) {
73
- text += ` \u00b7 ${stats.contradictions} contradictions`;
168
+ try {
169
+ const stats = await api('/api/stats');
170
+ if (statMemories) statMemories.textContent = stats.memories.toLocaleString();
171
+ if (statEntities) statEntities.textContent = stats.entities.toLocaleString();
172
+ if (statRelations) statRelations.textContent = stats.relations.toLocaleString();
173
+
174
+ // Update consolidation badge
175
+ const badge = document.getElementById('consolidation-badge');
176
+ if (badge && stats.contradictions > 0) {
177
+ badge.textContent = stats.contradictions;
178
+ badge.style.display = 'inline-flex';
179
+ } else if (badge) {
180
+ badge.style.display = 'none';
181
+ }
182
+ } catch (e) {
183
+ console.error('Failed to load stats', e);
74
184
  }
75
- statsEl.textContent = text;
76
185
  }
77
186
 
78
- // Load memories with pagination
187
+ // ============ Memories ============
79
188
  async function loadMemories(query = '', append = false) {
80
189
  if (!append) {
81
190
  memoriesOffset = 0;
@@ -92,7 +201,16 @@ async function loadMemories(query = '', append = false) {
92
201
  const data = await api(path);
93
202
 
94
203
  if (data.memories.length === 0 && !append) {
95
- memoriesList.innerHTML = '<div class="empty-state">No memories found</div>';
204
+ memoriesList.innerHTML = `
205
+ <div class="empty-state">
206
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
207
+ <path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/>
208
+ <path d="M3.29 7L12 12l8.71-5M12 22V12"/>
209
+ </svg>
210
+ <h3>No memories found</h3>
211
+ <p>${query ? 'Try a different search term' : 'Add your first memory to get started'}</p>
212
+ </div>
213
+ `;
96
214
  return;
97
215
  }
98
216
 
@@ -100,20 +218,41 @@ async function loadMemories(query = '', append = false) {
100
218
  <div class="list-item memory-item" data-id="${m.id}">
101
219
  <div class="content">${escapeHtml(m.content)}</div>
102
220
  <div class="meta">
103
- <span>${formatDate(m.timestamp)}</span>
104
- <span>${m.source}</span>
105
- <span>importance: ${m.importance}</span>
106
- ${m.score ? `<span class="score">${m.score.toFixed(4)}</span>` : ''}
221
+ <span class="meta-item">
222
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
223
+ <rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
224
+ <path d="M16 2v4M8 2v4M3 10h18"/>
225
+ </svg>
226
+ ${formatDate(m.timestamp)}
227
+ </span>
228
+ <span class="meta-item">
229
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
230
+ <path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/>
231
+ <circle cx="12" cy="7" r="4"/>
232
+ </svg>
233
+ ${escapeHtml(m.source)}
234
+ </span>
235
+ ${m.score ? `<span class="score-badge">${m.score.toFixed(4)}</span>` : ''}
107
236
  </div>
108
237
  <div class="actions">
109
- <button class="edit-btn" data-id="${m.id}">Edit</button>
110
- <button class="delete-btn" data-id="${m.id}">Delete</button>
238
+ <button class="btn btn-sm btn-secondary edit-btn" data-id="${m.id}">
239
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
240
+ <path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
241
+ <path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
242
+ </svg>
243
+ Edit
244
+ </button>
245
+ <button class="btn btn-sm btn-ghost delete-btn" data-id="${m.id}">
246
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
247
+ <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
248
+ </svg>
249
+ Delete
250
+ </button>
111
251
  </div>
112
252
  </div>
113
253
  `).join('');
114
254
 
115
255
  if (append) {
116
- // Remove old "Load More" button if exists
117
256
  const oldLoadMore = memoriesList.querySelector('.load-more-container');
118
257
  if (oldLoadMore) oldLoadMore.remove();
119
258
  memoriesList.insertAdjacentHTML('beforeend', memoriesHtml);
@@ -121,11 +260,11 @@ async function loadMemories(query = '', append = false) {
121
260
  memoriesList.innerHTML = memoriesHtml;
122
261
  }
123
262
 
124
- // Add "Load More" button if we got a full page and not searching
263
+ // Add "Load More" button
125
264
  if (!query && data.memories.length === MEMORIES_PAGE_SIZE) {
126
265
  memoriesList.insertAdjacentHTML('beforeend', `
127
266
  <div class="load-more-container">
128
- <button class="load-more-btn">Load More</button>
267
+ <button class="btn btn-secondary load-more-btn">Load More</button>
129
268
  </div>
130
269
  `);
131
270
  memoriesList.querySelector('.load-more-btn').addEventListener('click', () => {
@@ -134,7 +273,7 @@ async function loadMemories(query = '', append = false) {
134
273
  });
135
274
  }
136
275
 
137
- // Attach event listeners to new items
276
+ // Attach event listeners
138
277
  memoriesList.querySelectorAll('.edit-btn').forEach(btn => {
139
278
  btn.addEventListener('click', (e) => {
140
279
  e.stopPropagation();
@@ -150,24 +289,36 @@ async function loadMemories(query = '', append = false) {
150
289
  });
151
290
  }
152
291
 
153
- // Load entities
292
+ // ============ Entities ============
154
293
  async function loadEntities(type = '') {
155
294
  const path = type ? `/api/entities?type=${type}` : '/api/entities';
156
295
  const data = await api(path);
157
296
 
158
297
  if (data.entities.length === 0) {
159
- entitiesList.innerHTML = '<div class="empty-state">No entities found</div>';
298
+ entitiesList.innerHTML = `
299
+ <div class="empty-state">
300
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
301
+ <path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
302
+ <circle cx="9" cy="7" r="4"/>
303
+ <path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
304
+ </svg>
305
+ <h3>No entities found</h3>
306
+ <p>Entities are extracted automatically from memories</p>
307
+ </div>
308
+ `;
160
309
  return;
161
310
  }
162
311
 
163
312
  entitiesList.innerHTML = data.entities.map(e => `
164
- <div class="list-item entity-item" data-name="${escapeHtml(e.name)}">
165
- <div class="name">${escapeHtml(e.name)}</div>
166
- <div class="type">${e.type}</div>
313
+ <div class="list-item entity-item clickable" data-name="${escapeHtml(e.name)}">
314
+ <div class="entity-name">${escapeHtml(e.name)}</div>
315
+ <div class="entity-type">
316
+ ${getEntityIcon(e.type)}
317
+ ${e.type}
318
+ </div>
167
319
  </div>
168
320
  `).join('');
169
321
 
170
- // Attach event listeners
171
322
  entitiesList.querySelectorAll('.entity-item').forEach(item => {
172
323
  item.addEventListener('click', () => {
173
324
  showEntityDetails(item.dataset.name);
@@ -175,13 +326,29 @@ async function loadEntities(type = '') {
175
326
  });
176
327
  }
177
328
 
178
- // Show entity details
329
+ function getEntityIcon(type) {
330
+ const icons = {
331
+ person: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
332
+ organization: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18M9 8h1M9 12h1M9 16h1M14 8h1M14 12h1M14 16h1M5 21V5a2 2 0 012-2h10a2 2 0 012 2v16"/></svg>',
333
+ place: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>',
334
+ concept: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3M12 17h.01"/></svg>',
335
+ event: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>',
336
+ };
337
+ return icons[type] || icons.concept;
338
+ }
339
+
340
+ // ============ Entity Details ============
179
341
  async function showEntityDetails(name) {
180
342
  const data = await api(`/api/entities/${encodeURIComponent(name)}`);
181
343
 
182
344
  entityModalTitle.textContent = data.name;
183
345
 
184
- let html = `<p><strong>Type:</strong> ${data.type}</p>`;
346
+ let html = `
347
+ <div class="entity-type">
348
+ ${getEntityIcon(data.type)}
349
+ ${data.type}
350
+ </div>
351
+ `;
185
352
 
186
353
  if (data.observations && data.observations.length > 0) {
187
354
  html += `<h3>Observations</h3><ul>`;
@@ -194,7 +361,7 @@ async function showEntityDetails(name) {
194
361
  if (data.relationsFrom && data.relationsFrom.length > 0) {
195
362
  html += `<h3>Relationships (outgoing)</h3><ul>`;
196
363
  data.relationsFrom.forEach(r => {
197
- html += `<li>${r.type} \u2192 ${escapeHtml(r.targetEntity?.name || r.to)}</li>`;
364
+ html += `<li><span class="text-accent">${r.type}</span> ${escapeHtml(r.targetEntity?.name || r.to)}</li>`;
198
365
  });
199
366
  html += `</ul>`;
200
367
  }
@@ -202,7 +369,7 @@ async function showEntityDetails(name) {
202
369
  if (data.relationsTo && data.relationsTo.length > 0) {
203
370
  html += `<h3>Relationships (incoming)</h3><ul>`;
204
371
  data.relationsTo.forEach(r => {
205
- html += `<li>${escapeHtml(r.sourceEntity?.name || r.from)} \u2192 ${r.type}</li>`;
372
+ html += `<li>${escapeHtml(r.sourceEntity?.name || r.from)} <span class="text-accent">${r.type}</span></li>`;
206
373
  });
207
374
  html += `</ul>`;
208
375
  }
@@ -211,55 +378,73 @@ async function showEntityDetails(name) {
211
378
  entityModal.classList.remove('hidden');
212
379
  }
213
380
 
214
- // Load graph
381
+ // ============ Graph ============
215
382
  async function loadGraph() {
216
383
  const data = await api('/api/graph');
217
384
 
218
- // Simple visualization using CSS
219
385
  if (data.nodes.length === 0) {
220
- graphContainer.innerHTML = '<div class="empty-state">No entities in graph</div>';
386
+ graphContainer.innerHTML = `
387
+ <div class="empty-state">
388
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
389
+ <circle cx="18" cy="5" r="3"/>
390
+ <circle cx="6" cy="12" r="3"/>
391
+ <circle cx="18" cy="19" r="3"/>
392
+ <path d="M8.59 13.51l6.82 3.98M15.41 6.51l-6.82 3.98"/>
393
+ </svg>
394
+ <h3>No graph data</h3>
395
+ <p>Add memories with entities to build your knowledge graph</p>
396
+ </div>
397
+ `;
221
398
  return;
222
399
  }
223
400
 
224
- // Create a simple text-based visualization
225
- let html = '<div style="padding: 2rem; font-size: 0.875rem;">';
226
- html += '<p style="margin-bottom: 1rem; color: var(--text-muted);">Knowledge graph visualization. Click entities to see details.</p>';
227
-
228
- // Group by type
401
+ // Group nodes by type
229
402
  const byType = {};
230
403
  data.nodes.forEach(n => {
231
404
  if (!byType[n.type]) byType[n.type] = [];
232
405
  byType[n.type].push(n);
233
406
  });
234
407
 
408
+ let html = '<div style="padding: 24px;">';
409
+ html += '<p class="text-muted" style="margin-bottom: 24px;">Knowledge graph visualization. Click entities to see details.</p>';
410
+
235
411
  for (const [type, nodes] of Object.entries(byType)) {
236
- html += `<div style="margin-bottom: 1.5rem;">`;
237
- html += `<h3 style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-muted); margin-bottom: 0.5rem;">${type}</h3>`;
238
- html += `<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">`;
412
+ html += `
413
+ <div style="margin-bottom: 24px;">
414
+ <h3 style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-tertiary); margin-bottom: 12px;">${type}</h3>
415
+ <div style="display: flex; flex-wrap: wrap; gap: 8px;">
416
+ `;
239
417
  nodes.forEach(n => {
240
- html += `<span class="graph-node" data-name="${escapeHtml(n.label)}" style="padding: 0.375rem 0.75rem; background: var(--bg-tertiary); cursor: pointer;">${escapeHtml(n.label)}</span>`;
418
+ html += `<button class="btn btn-sm btn-secondary graph-node" data-name="${escapeHtml(n.label)}">${escapeHtml(n.label)}</button>`;
241
419
  });
242
- html += `</div></div>`;
420
+ html += '</div></div>';
243
421
  }
244
422
 
245
423
  if (data.edges.length > 0) {
246
- html += `<div style="margin-top: 2rem;">`;
247
- html += `<h3 style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-muted); margin-bottom: 0.5rem;">Relationships</h3>`;
248
- html += `<ul style="list-style: none;">`;
249
- data.edges.forEach(e => {
424
+ html += `
425
+ <div style="margin-top: 32px;">
426
+ <h3 style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-tertiary); margin-bottom: 12px;">Relationships</h3>
427
+ <div class="list">
428
+ `;
429
+ data.edges.slice(0, 50).forEach(e => {
250
430
  const fromNode = data.nodes.find(n => n.id === e.from);
251
431
  const toNode = data.nodes.find(n => n.id === e.to);
252
432
  if (fromNode && toNode) {
253
- html += `<li style="padding: 0.25rem 0; color: var(--text-secondary);">${escapeHtml(fromNode.label)} <span style="color: var(--accent);">\u2192 ${e.label} \u2192</span> ${escapeHtml(toNode.label)}</li>`;
433
+ html += `
434
+ <div class="list-item" style="padding: 12px 16px;">
435
+ <span>${escapeHtml(fromNode.label)}</span>
436
+ <span class="text-accent" style="margin: 0 8px;">→ ${e.label} →</span>
437
+ <span>${escapeHtml(toNode.label)}</span>
438
+ </div>
439
+ `;
254
440
  }
255
441
  });
256
- html += `</ul></div>`;
442
+ html += '</div></div>';
257
443
  }
258
444
 
259
445
  html += '</div>';
260
446
  graphContainer.innerHTML = html;
261
447
 
262
- // Attach click handlers
263
448
  graphContainer.querySelectorAll('.graph-node').forEach(node => {
264
449
  node.addEventListener('click', () => {
265
450
  showEntityDetails(node.dataset.name);
@@ -267,7 +452,7 @@ async function loadGraph() {
267
452
  });
268
453
  }
269
454
 
270
- // Edit memory
455
+ // ============ Memory CRUD ============
271
456
  async function editMemory(id) {
272
457
  const data = await api('/api/memories');
273
458
  const memory = data.memories.find(m => m.id === id);
@@ -282,16 +467,14 @@ async function editMemory(id) {
282
467
  modal.classList.remove('hidden');
283
468
  }
284
469
 
285
- // Delete memory
286
470
  async function deleteMemory(id) {
287
- if (!confirm('Delete this memory?')) return;
471
+ if (!confirm('Delete this memory? This action cannot be undone.')) return;
288
472
 
289
473
  await api(`/api/memories/${id}`, { method: 'DELETE' });
290
474
  await loadMemories(searchInput.value);
291
475
  await loadStats();
292
476
  }
293
477
 
294
- // Save memory
295
478
  async function saveMemory() {
296
479
  const content = modalContentInput.value.trim();
297
480
  if (!content) return;
@@ -313,7 +496,6 @@ async function saveMemory() {
313
496
  await loadStats();
314
497
  }
315
498
 
316
- // Close modal
317
499
  function closeModal() {
318
500
  modal.classList.add('hidden');
319
501
  editingMemoryId = null;
@@ -323,41 +505,37 @@ function closeModal() {
323
505
  importanceValue.textContent = '0.5';
324
506
  }
325
507
 
326
- // Escape HTML
327
- function escapeHtml(str) {
328
- if (!str) return '';
329
- return str
330
- .replace(/&/g, '&amp;')
331
- .replace(/</g, '&lt;')
332
- .replace(/>/g, '&gt;')
333
- .replace(/"/g, '&quot;')
334
- .replace(/'/g, '&#039;');
335
- }
336
-
337
- // Switch view
508
+ // ============ View Switching ============
338
509
  function switchView(view) {
339
510
  currentView = view;
340
511
 
341
- // Update nav buttons
342
- document.querySelectorAll('.nav-btn').forEach(btn => {
512
+ // Update nav items
513
+ document.querySelectorAll('.nav-item').forEach(btn => {
343
514
  btn.classList.toggle('active', btn.dataset.view === view);
344
515
  });
345
516
 
346
517
  // Update views
347
518
  Object.entries(views).forEach(([name, el]) => {
348
- el.classList.toggle('active', name === view);
519
+ if (el) el.classList.toggle('active', name === view);
349
520
  });
350
521
 
351
- // Load data for view
352
- if (view === 'memories') loadMemories(searchInput.value);
353
- if (view === 'entities') loadEntities(entityTypeFilter.value);
522
+ // Update header
523
+ if (viewTitle) viewTitle.textContent = viewTitles[view] || view;
524
+
525
+ // Show/hide search based on view
526
+ if (headerSearch) {
527
+ headerSearch.style.display = view === 'memories' ? 'block' : 'none';
528
+ }
529
+
530
+ // Load data
531
+ if (view === 'memories') loadMemories(searchInput?.value || '');
532
+ if (view === 'entities') loadEntities(entityTypeFilter?.value || '');
354
533
  if (view === 'graph') loadGraph();
355
534
  if (view === 'consolidation') loadConsolidation();
356
535
  if (view === 'settings') loadSettings();
357
536
  }
358
537
 
359
538
  // ============ Consolidation ============
360
-
361
539
  const contradictionsList = document.getElementById('contradictions-list');
362
540
  const digestsList = document.getElementById('digests-list');
363
541
  const unconsolidatedCount = document.getElementById('unconsolidated-count');
@@ -371,7 +549,6 @@ const contradictionResolution = document.getElementById('contradiction-resolutio
371
549
 
372
550
  let currentContradictionId = null;
373
551
 
374
- // Load consolidation view
375
552
  async function loadConsolidation() {
376
553
  await Promise.all([
377
554
  loadConsolidationStatus(),
@@ -380,29 +557,39 @@ async function loadConsolidation() {
380
557
  ]);
381
558
  }
382
559
 
383
- // Load consolidation status
384
560
  async function loadConsolidationStatus() {
385
561
  try {
386
562
  const status = await api('/api/consolidation/status');
387
- unconsolidatedCount.textContent = status.unconsolidatedMemories;
388
- digestsCount.textContent = status.totalDigests;
389
- contradictionsCount.textContent = status.unresolvedContradictions;
390
- runConsolidationBtn.disabled = !status.configured;
391
- if (!status.configured) {
392
- runConsolidationBtn.title = 'Set ANTHROPIC_API_KEY to enable';
563
+ if (unconsolidatedCount) unconsolidatedCount.textContent = status.unconsolidatedMemories;
564
+ if (digestsCount) digestsCount.textContent = status.totalDigests;
565
+ if (contradictionsCount) contradictionsCount.textContent = status.unresolvedContradictions;
566
+ if (runConsolidationBtn) {
567
+ runConsolidationBtn.disabled = !status.configured;
568
+ if (!status.configured) {
569
+ runConsolidationBtn.title = 'Configure ANTHROPIC_API_KEY to enable';
570
+ }
393
571
  }
394
572
  } catch (e) {
395
573
  console.error('Failed to load consolidation status', e);
396
574
  }
397
575
  }
398
576
 
399
- // Load contradictions
400
577
  async function loadContradictions() {
578
+ if (!contradictionsList) return;
579
+
401
580
  try {
402
581
  const data = await api('/api/contradictions?resolved=false');
403
582
 
404
583
  if (data.contradictions.length === 0) {
405
- contradictionsList.innerHTML = '<div class="empty-state">No contradictions found</div>';
584
+ contradictionsList.innerHTML = `
585
+ <div class="empty-state">
586
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
587
+ <path d="M20 6L9 17l-5-5"/>
588
+ </svg>
589
+ <h3>No contradictions</h3>
590
+ <p>All conflicts have been resolved</p>
591
+ </div>
592
+ `;
406
593
  return;
407
594
  }
408
595
 
@@ -410,23 +597,25 @@ async function loadContradictions() {
410
597
  <div class="list-item contradiction-item" data-id="${c.id}">
411
598
  ${c.entity ? `<span class="entity-tag">${escapeHtml(c.entity.name)}</span>` : ''}
412
599
  <div class="description">${escapeHtml(c.description)}</div>
413
- <div class="memories">
414
- <div class="memory-quote">
415
- ${escapeHtml(c.memory_a?.content || 'Memory deleted')}
416
- <span class="date">${c.memory_a ? formatDate(c.memory_a.timestamp) : ''}</span>
417
- </div>
418
- <div class="memory-quote">
419
- ${escapeHtml(c.memory_b?.content || 'Memory deleted')}
420
- <span class="date">${c.memory_b ? formatDate(c.memory_b.timestamp) : ''}</span>
421
- </div>
600
+ <div class="memory-quote">
601
+ ${escapeHtml(c.memory_a?.content || 'Memory deleted')}
602
+ <span class="date">${c.memory_a ? formatDate(c.memory_a.timestamp) : ''}</span>
422
603
  </div>
423
- <div class="actions">
424
- <button class="resolve-btn" data-id="${c.id}">Resolve</button>
604
+ <div class="memory-quote">
605
+ ${escapeHtml(c.memory_b?.content || 'Memory deleted')}
606
+ <span class="date">${c.memory_b ? formatDate(c.memory_b.timestamp) : ''}</span>
607
+ </div>
608
+ <div class="actions" style="margin-top: 16px;">
609
+ <button class="btn btn-sm btn-primary resolve-btn" data-id="${c.id}">
610
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
611
+ <path d="M20 6L9 17l-5-5"/>
612
+ </svg>
613
+ Resolve
614
+ </button>
425
615
  </div>
426
616
  </div>
427
617
  `).join('');
428
618
 
429
- // Attach event listeners
430
619
  contradictionsList.querySelectorAll('.resolve-btn').forEach(btn => {
431
620
  btn.addEventListener('click', (e) => {
432
621
  e.stopPropagation();
@@ -435,21 +624,31 @@ async function loadContradictions() {
435
624
  });
436
625
  } catch (e) {
437
626
  console.error('Failed to load contradictions', e);
438
- contradictionsList.innerHTML = '<div class="empty-state">Failed to load contradictions</div>';
627
+ contradictionsList.innerHTML = '<div class="empty-state"><p>Failed to load contradictions</p></div>';
439
628
  }
440
629
  }
441
630
 
442
- // Load digests with hierarchy visualization
443
631
  async function loadDigests() {
632
+ if (!digestsList) return;
633
+
444
634
  try {
445
635
  const data = await api('/api/digests');
446
636
 
447
637
  if (data.digests.length === 0) {
448
- digestsList.innerHTML = '<div class="empty-state">No digests yet. Run consolidation to create summaries.</div>';
638
+ digestsList.innerHTML = `
639
+ <div class="empty-state">
640
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
641
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
642
+ <path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/>
643
+ </svg>
644
+ <h3>No digests yet</h3>
645
+ <p>Run consolidation to create summaries</p>
646
+ </div>
647
+ `;
449
648
  return;
450
649
  }
451
650
 
452
- // Group digests by level
651
+ // Group by level
453
652
  const byLevel = { 1: [], 2: [], 3: [] };
454
653
  data.digests.forEach(d => {
455
654
  const level = d.level || 1;
@@ -457,12 +656,7 @@ async function loadDigests() {
457
656
  byLevel[level].push(d);
458
657
  });
459
658
 
460
- const levelLabels = {
461
- 1: 'Session Summaries',
462
- 2: 'Topic Digests',
463
- 3: 'Entity Profiles'
464
- };
465
-
659
+ const levelLabels = { 1: 'Session Summaries', 2: 'Topic Digests', 3: 'Entity Profiles' };
466
660
  const levelDescs = {
467
661
  1: 'Summaries of individual conversations',
468
662
  2: 'Consolidated topic-based knowledge',
@@ -470,18 +664,18 @@ async function loadDigests() {
470
664
  };
471
665
 
472
666
  let html = '';
473
- for (const level of [3, 2, 1]) { // Show highest level first
667
+ for (const level of [3, 2, 1]) {
474
668
  const digests = byLevel[level];
475
669
  if (digests.length === 0) continue;
476
670
 
477
671
  html += `
478
672
  <div class="digest-level">
479
- <h3 class="level-header">
673
+ <div class="level-header">
480
674
  <span class="level-badge">L${level}</span>
481
- ${levelLabels[level]}
675
+ <span class="level-title">${levelLabels[level]}</span>
482
676
  <span class="level-count">(${digests.length})</span>
483
- </h3>
484
- <p class="level-desc">${levelDescs[level]}</p>
677
+ </div>
678
+ <p class="section-desc" style="margin-bottom: 16px;">${levelDescs[level]}</p>
485
679
  <div class="digest-list">
486
680
  `;
487
681
 
@@ -504,56 +698,65 @@ async function loadDigests() {
504
698
  digestsList.innerHTML = html;
505
699
  } catch (e) {
506
700
  console.error('Failed to load digests', e);
507
- digestsList.innerHTML = '<div class="empty-state">Failed to load digests</div>';
701
+ digestsList.innerHTML = '<div class="empty-state"><p>Failed to load digests</p></div>';
508
702
  }
509
703
  }
510
704
 
511
- // Run consolidation
512
705
  async function runConsolidation() {
706
+ if (!runConsolidationBtn) return;
707
+
513
708
  runConsolidationBtn.disabled = true;
514
- runConsolidationBtn.textContent = 'Consolidating...';
709
+ runConsolidationBtn.innerHTML = `
710
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin">
711
+ <path d="M21 12a9 9 0 11-6.219-8.56"/>
712
+ </svg>
713
+ Consolidating...
714
+ `;
515
715
 
516
716
  try {
517
717
  const result = await api('/api/consolidation/run', { method: 'POST' });
518
718
  alert(`Consolidation complete!\n\nDigests created: ${result.digestsCreated}\nContradictions found: ${result.contradictionsFound}\nMemories processed: ${result.memoriesProcessed}`);
519
719
  await loadConsolidation();
720
+ await loadStats();
520
721
  } catch (e) {
521
722
  console.error('Consolidation failed', e);
522
723
  alert('Consolidation failed. Check console for details.');
523
724
  } finally {
524
725
  runConsolidationBtn.disabled = false;
525
- runConsolidationBtn.textContent = 'Run Consolidation';
726
+ runConsolidationBtn.innerHTML = `
727
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
728
+ <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
729
+ </svg>
730
+ Run Consolidation
731
+ `;
526
732
  }
527
733
  }
528
734
 
529
- // Open contradiction modal
530
735
  function openContradictionModal(id) {
531
- const item = contradictionsList.querySelector(`[data-id="${id}"]`);
736
+ const item = contradictionsList?.querySelector(`[data-id="${id}"]`);
532
737
  if (!item) return;
533
738
 
534
739
  currentContradictionId = id;
535
-
536
- // Copy the memories to the modal
537
- const description = item.querySelector('.description').textContent;
740
+ const description = item.querySelector('.description')?.textContent || '';
538
741
  const memories = item.querySelectorAll('.memory-quote');
539
742
 
540
- contradictionModalBody.innerHTML = `
541
- <p><strong>${escapeHtml(description)}</strong></p>
542
- <div class="memory-quote">${memories[0].innerHTML}</div>
543
- <div class="memory-quote">${memories[1].innerHTML}</div>
544
- `;
743
+ if (contradictionModalBody) {
744
+ contradictionModalBody.innerHTML = `
745
+ <p style="font-weight: 500; margin-bottom: 16px;">${escapeHtml(description)}</p>
746
+ <div class="memory-quote">${memories[0]?.innerHTML || ''}</div>
747
+ <div class="memory-quote">${memories[1]?.innerHTML || ''}</div>
748
+ `;
749
+ }
545
750
 
546
- contradictionResolution.value = '';
547
- contradictionModal.classList.remove('hidden');
751
+ if (contradictionResolution) contradictionResolution.value = '';
752
+ contradictionModal?.classList.remove('hidden');
548
753
  }
549
754
 
550
- // Close contradiction modal
551
755
  function closeContradictionModal() {
552
- contradictionModal.classList.add('hidden');
756
+ contradictionModal?.classList.add('hidden');
553
757
  currentContradictionId = null;
554
758
  }
555
759
 
556
- // Resolve contradiction
557
760
  async function resolveContradiction(resolution) {
558
761
  if (!currentContradictionId || !resolution.trim()) return;
559
762
 
@@ -571,7 +774,6 @@ async function resolveContradiction(resolution) {
571
774
  }
572
775
  }
573
776
 
574
- // Dismiss contradiction
575
777
  async function dismissContradiction() {
576
778
  if (!currentContradictionId) return;
577
779
  if (!confirm('Dismiss this contradiction without resolution?')) return;
@@ -587,70 +789,40 @@ async function dismissContradiction() {
587
789
  }
588
790
  }
589
791
 
590
- // Event listeners for consolidation
591
- runConsolidationBtn.addEventListener('click', runConsolidation);
592
-
593
- contradictionForm.addEventListener('submit', (e) => {
594
- e.preventDefault();
595
- resolveContradiction(contradictionResolution.value);
596
- });
597
-
598
- document.getElementById('contradiction-cancel').addEventListener('click', closeContradictionModal);
599
- document.getElementById('contradiction-dismiss').addEventListener('click', dismissContradiction);
600
-
601
- contradictionModal.addEventListener('click', (e) => {
602
- if (e.target === contradictionModal) closeContradictionModal();
603
- });
604
-
605
- // Event listeners
606
- document.querySelectorAll('.nav-btn').forEach(btn => {
607
- btn.addEventListener('click', () => switchView(btn.dataset.view));
608
- });
609
-
610
- document.getElementById('search-btn').addEventListener('click', () => {
611
- loadMemories(searchInput.value);
612
- });
613
-
614
- searchInput.addEventListener('keypress', (e) => {
615
- if (e.key === 'Enter') loadMemories(searchInput.value);
616
- });
617
-
618
- entityTypeFilter.addEventListener('change', () => {
619
- loadEntities(entityTypeFilter.value);
620
- });
621
-
622
- document.getElementById('add-memory-btn').addEventListener('click', () => {
623
- editingMemoryId = null;
624
- modalTitle.textContent = 'Add Memory';
625
- modal.classList.remove('hidden');
626
- });
627
-
628
- document.getElementById('modal-cancel').addEventListener('click', closeModal);
629
-
630
- modalForm.addEventListener('submit', (e) => {
631
- e.preventDefault();
632
- saveMemory();
633
- });
634
-
635
- modalImportance.addEventListener('input', () => {
636
- importanceValue.textContent = modalImportance.value;
637
- });
792
+ // ============ Settings ============
793
+ const apiStatusBadge = document.getElementById('api-status-badge');
794
+ const apiKeyInput = document.getElementById('api-key-input');
795
+ const toggleKeyVisibility = document.getElementById('toggle-key-visibility');
796
+ const saveApiKeyBtn = document.getElementById('save-api-key');
797
+ const clearApiKeyBtn = document.getElementById('clear-api-key');
638
798
 
639
- document.getElementById('entity-modal-close').addEventListener('click', () => {
640
- entityModal.classList.add('hidden');
641
- });
799
+ async function loadSettings() {
800
+ try {
801
+ const settings = await api('/api/settings');
802
+ updateSettingsUI(settings);
803
+ } catch (e) {
804
+ console.error('Failed to load settings', e);
805
+ }
806
+ }
642
807
 
643
- // Close modals on backdrop click
644
- modal.addEventListener('click', (e) => {
645
- if (e.target === modal) closeModal();
646
- });
808
+ function updateSettingsUI(settings) {
809
+ if (!apiStatusBadge) return;
647
810
 
648
- entityModal.addEventListener('click', (e) => {
649
- if (e.target === entityModal) entityModal.classList.add('hidden');
650
- });
811
+ if (settings.has_api_key) {
812
+ apiStatusBadge.textContent = `Configured (${settings.api_key_source})`;
813
+ apiStatusBadge.className = 'status-badge configured';
814
+ if (apiKeyInput) {
815
+ apiKeyInput.placeholder = settings.api_key_preview || 'sk-ant-api03-...';
816
+ apiKeyInput.value = '';
817
+ }
818
+ } else {
819
+ apiStatusBadge.textContent = 'Not configured';
820
+ apiStatusBadge.className = 'status-badge not-configured';
821
+ if (apiKeyInput) apiKeyInput.placeholder = 'sk-ant-api03-...';
822
+ }
823
+ }
651
824
 
652
825
  // ============ Chat Panel ============
653
-
654
826
  const chatPanel = document.getElementById('chat-panel');
655
827
  const chatToggle = document.getElementById('chat-toggle');
656
828
  const chatClose = document.getElementById('chat-close');
@@ -662,68 +834,75 @@ const chatInput = document.getElementById('chat-input');
662
834
 
663
835
  let chatConfigured = false;
664
836
 
665
- // Check if chat is configured
666
837
  async function checkChatStatus() {
667
838
  try {
668
839
  const data = await api('/api/chat/status');
669
840
  chatConfigured = data.configured;
670
- if (!chatConfigured) {
671
- chatStatus.textContent = 'Set ANTHROPIC_API_KEY env var to enable chat';
841
+ if (!chatConfigured && chatStatus) {
842
+ chatStatus.textContent = 'Configure ANTHROPIC_API_KEY to enable chat';
672
843
  chatStatus.classList.add('error');
673
- chatInput.disabled = true;
674
- chatInput.placeholder = 'Chat disabled - API key not configured';
675
- } else {
844
+ if (chatInput) {
845
+ chatInput.disabled = true;
846
+ chatInput.placeholder = 'Chat disabled - API key not configured';
847
+ }
848
+ } else if (chatStatus) {
676
849
  chatStatus.textContent = '';
677
850
  chatStatus.classList.remove('error');
678
- chatInput.disabled = false;
679
- chatInput.placeholder = 'Ask me to manage entities...';
851
+ if (chatInput) {
852
+ chatInput.disabled = false;
853
+ chatInput.placeholder = 'Ask me to manage entities...';
854
+ }
680
855
  }
681
856
  } catch (e) {
682
- chatStatus.textContent = 'Failed to connect to chat service';
683
- chatStatus.classList.add('error');
684
- chatInput.disabled = true;
685
- chatInput.placeholder = 'Chat unavailable';
857
+ if (chatStatus) {
858
+ chatStatus.textContent = 'Failed to connect to chat service';
859
+ chatStatus.classList.add('error');
860
+ }
861
+ if (chatInput) {
862
+ chatInput.disabled = true;
863
+ chatInput.placeholder = 'Chat unavailable';
864
+ }
686
865
  }
687
866
  }
688
867
 
689
- // Toggle chat panel
690
868
  function toggleChat() {
869
+ if (!chatPanel) return;
870
+
691
871
  const isHidden = chatPanel.classList.contains('hidden');
692
872
  chatPanel.classList.toggle('hidden');
693
- chatToggle.classList.toggle('active', isHidden);
873
+ chatToggle?.classList.toggle('active', isHidden);
694
874
  document.body.classList.toggle('chat-open', isHidden);
695
875
 
696
876
  if (isHidden) {
697
877
  checkChatStatus();
698
- chatInput.focus();
878
+ chatInput?.focus();
699
879
  }
700
880
  }
701
881
 
702
- // Add message to chat
703
882
  function addChatMessage(content, role) {
883
+ if (!chatMessages) return;
884
+
704
885
  const div = document.createElement('div');
705
886
  div.className = `chat-message ${role}`;
887
+ div.innerHTML = `<p>${formatChatContent(content)}</p>`;
888
+ chatMessages.appendChild(div);
889
+ chatMessages.scrollTop = chatMessages.scrollHeight;
890
+ }
706
891
 
707
- // Simple markdown-like parsing
708
- const formatted = content
892
+ function formatChatContent(content) {
893
+ return content
709
894
  .replace(/\n\n/g, '</p><p>')
710
895
  .replace(/\n/g, '<br>')
711
896
  .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
712
897
  .replace(/`(.+?)`/g, '<code>$1</code>');
713
-
714
- div.innerHTML = `<p>${formatted}</p>`;
715
- chatMessages.appendChild(div);
716
- chatMessages.scrollTop = chatMessages.scrollHeight;
717
898
  }
718
899
 
719
- // Send chat message with streaming
720
900
  async function sendChatMessage(message) {
721
- if (!message.trim()) return;
901
+ if (!message.trim() || !chatMessages) return;
722
902
 
723
- // Add user message
724
903
  addChatMessage(message, 'user');
725
- chatInput.value = '';
726
- chatInput.disabled = true;
904
+ if (chatInput) chatInput.value = '';
905
+ if (chatInput) chatInput.disabled = true;
727
906
 
728
907
  // Create assistant message div for streaming
729
908
  const responseDiv = document.createElement('div');
@@ -765,28 +944,64 @@ async function sendChatMessage(message) {
765
944
  switch (event.type) {
766
945
  case 'text':
767
946
  currentContent += event.content;
768
- contentEl.innerHTML = formatChatContent(currentContent);
947
+ if (contentEl) contentEl.innerHTML = formatChatContent(currentContent);
948
+ chatMessages.scrollTop = chatMessages.scrollHeight;
949
+ break;
950
+
951
+ case 'thinking':
952
+ // Handle streaming thinking content
953
+ let thinkingEl = contentEl?.querySelector('.thinking-indicator');
954
+ if (!thinkingEl) {
955
+ // Create thinking indicator on first thinking event
956
+ thinkingEl = document.createElement('div');
957
+ thinkingEl.className = 'tool-indicator thinking-indicator';
958
+ thinkingEl.innerHTML = `
959
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
960
+ <circle cx="12" cy="12" r="10"/>
961
+ <path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3M12 17h.01"/>
962
+ </svg>
963
+ <span class="thinking-text">Reasoning...</span>
964
+ `;
965
+ contentEl?.appendChild(thinkingEl);
966
+ }
967
+ // Optionally show truncated thinking preview (last 50 chars)
968
+ if (event.content && event.content.length > 0) {
969
+ const thinkingTextEl = thinkingEl.querySelector('.thinking-text');
970
+ if (thinkingTextEl) {
971
+ const preview = event.content.slice(-80).replace(/\n/g, ' ').trim();
972
+ thinkingTextEl.textContent = `Reasoning... ${preview.length > 60 ? '...' + preview.slice(-60) : preview}`;
973
+ }
974
+ }
769
975
  chatMessages.scrollTop = chatMessages.scrollHeight;
770
976
  break;
771
977
 
772
978
  case 'tool_start':
979
+ // Remove thinking indicator if present
980
+ const thinkingInd = contentEl?.querySelector('.thinking-indicator');
981
+ if (thinkingInd) thinkingInd.remove();
982
+
773
983
  // Show tool execution indicator
774
- const toolIndicator = document.createElement('span');
984
+ const toolIndicator = document.createElement('div');
775
985
  toolIndicator.className = 'tool-indicator';
776
- toolIndicator.textContent = `Using ${event.tool}...`;
777
- contentEl.appendChild(toolIndicator);
986
+ toolIndicator.innerHTML = `
987
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
988
+ <path d="M21 12a9 9 0 11-6.219-8.56"/>
989
+ </svg>
990
+ Using ${event.tool}...
991
+ `;
992
+ contentEl?.appendChild(toolIndicator);
778
993
  chatMessages.scrollTop = chatMessages.scrollHeight;
779
994
  break;
780
995
 
781
996
  case 'tool_end':
782
997
  // Remove tool indicator
783
- const indicators = contentEl.querySelectorAll('.tool-indicator');
784
- indicators.forEach(ind => ind.remove());
998
+ const indicators = contentEl?.querySelectorAll('.tool-indicator');
999
+ indicators?.forEach(ind => ind.remove());
785
1000
  break;
786
1001
 
787
1002
  case 'error':
788
- currentContent += `\n\nError: ${event.content}`;
789
- contentEl.innerHTML = formatChatContent(currentContent);
1003
+ currentContent += `\n\n**Error:** ${event.content}`;
1004
+ if (contentEl) contentEl.innerHTML = formatChatContent(currentContent);
790
1005
  break;
791
1006
 
792
1007
  case 'done':
@@ -802,66 +1017,50 @@ async function sendChatMessage(message) {
802
1017
 
803
1018
  // Refresh data in case something changed
804
1019
  loadStats();
805
- if (currentView === 'entities') loadEntities(entityTypeFilter.value);
1020
+ if (currentView === 'entities') loadEntities(entityTypeFilter?.value || '');
806
1021
  if (currentView === 'graph') loadGraph();
807
- if (currentView === 'memories') loadMemories(searchInput.value);
1022
+ if (currentView === 'memories') loadMemories(searchInput?.value || '');
808
1023
 
809
1024
  } catch (e) {
810
1025
  responseDiv.classList.remove('streaming');
811
- contentEl.innerHTML = formatChatContent(`Error: ${e.message || 'Failed to get response'}`);
1026
+ if (contentEl) contentEl.innerHTML = formatChatContent(`**Error:** ${e.message || 'Failed to get response'}`);
812
1027
  }
813
1028
 
814
- chatInput.disabled = false;
815
- chatInput.focus();
816
- }
817
-
818
- // Format chat content (markdown-like)
819
- function formatChatContent(content) {
820
- return content
821
- .replace(/\n\n/g, '</p><p>')
822
- .replace(/\n/g, '<br>')
823
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
824
- .replace(/`(.+?)`/g, '<code>$1</code>');
1029
+ if (chatInput) {
1030
+ chatInput.disabled = false;
1031
+ chatInput.focus();
1032
+ }
825
1033
  }
826
1034
 
827
- // Clear chat history
828
1035
  async function clearChatHistory() {
829
1036
  try {
830
1037
  await api('/api/chat/clear', { method: 'POST' });
831
- // Keep only the initial welcome message
832
- chatMessages.innerHTML = `
833
- <div class="chat-message assistant">
834
- <p>Hi! I can help you manage your memories and entities.</p>
835
- <p><strong>Examples:</strong></p>
836
- <ul>
837
- <li>"Show me all entities"</li>
838
- <li>"Find duplicates"</li>
839
- <li>"Merge Boris into Boris Djordjevic"</li>
840
- <li>"Delete the entity 'crashed'"</li>
841
- </ul>
842
- <p style="font-size: 0.8em; color: var(--text-muted); margin-top: 0.5rem;">Requires ANTHROPIC_API_KEY environment variable.</p>
843
- </div>
844
- `;
1038
+ if (chatMessages) {
1039
+ chatMessages.innerHTML = `
1040
+ <div class="chat-message assistant">
1041
+ <p>Hi! I can help you manage your memories and entities.</p>
1042
+ <p><strong>Examples:</strong></p>
1043
+ <ul>
1044
+ <li>"Show me all entities"</li>
1045
+ <li>"Find duplicates"</li>
1046
+ <li>"Merge Boris into Boris Djordjevic"</li>
1047
+ <li>"Delete the entity 'crashed'"</li>
1048
+ </ul>
1049
+ <p style="font-size: 0.8em; color: var(--text-tertiary); margin-top: 0.5rem;">Requires ANTHROPIC_API_KEY to be configured.</p>
1050
+ </div>
1051
+ `;
1052
+ }
845
1053
  } catch (e) {
846
1054
  console.error('Failed to clear chat history', e);
847
1055
  }
848
1056
  }
849
1057
 
850
- // Chat event listeners
851
- chatToggle.addEventListener('click', toggleChat);
852
- chatClose.addEventListener('click', toggleChat);
853
- chatClear.addEventListener('click', clearChatHistory);
854
-
855
- chatForm.addEventListener('submit', (e) => {
856
- e.preventDefault();
857
- sendChatMessage(chatInput.value);
858
- });
859
-
860
1058
  // ============ API Status Indicator ============
861
-
862
1059
  const apiStatusEl = document.getElementById('api-status');
863
1060
 
864
1061
  async function checkApiStatus() {
1062
+ if (!apiStatusEl) return;
1063
+
865
1064
  apiStatusEl.classList.remove('connected', 'disconnected');
866
1065
  apiStatusEl.classList.add('checking');
867
1066
  apiStatusEl.title = 'Checking API status...';
@@ -874,7 +1073,7 @@ async function checkApiStatus() {
874
1073
  apiStatusEl.title = 'Anthropic API connected';
875
1074
  } else {
876
1075
  apiStatusEl.classList.add('disconnected');
877
- apiStatusEl.title = 'API key not configured - set ANTHROPIC_API_KEY';
1076
+ apiStatusEl.title = 'API key not configured';
878
1077
  }
879
1078
  } catch (e) {
880
1079
  apiStatusEl.classList.remove('checking');
@@ -883,105 +1082,158 @@ async function checkApiStatus() {
883
1082
  }
884
1083
  }
885
1084
 
886
- // ============ Settings ============
1085
+ // ============ Event Listeners ============
887
1086
 
888
- const apiStatusBadge = document.getElementById('api-status-badge');
889
- const apiKeyInput = document.getElementById('api-key-input');
890
- const toggleKeyVisibility = document.getElementById('toggle-key-visibility');
891
- const saveApiKeyBtn = document.getElementById('save-api-key');
892
- const clearApiKeyBtn = document.getElementById('clear-api-key');
1087
+ // Navigation
1088
+ document.querySelectorAll('.nav-item').forEach(btn => {
1089
+ btn.addEventListener('click', () => switchView(btn.dataset.view));
1090
+ });
893
1091
 
894
- async function loadSettings() {
895
- try {
896
- const settings = await api('/api/settings');
897
- updateSettingsUI(settings);
898
- } catch (e) {
899
- console.error('Failed to load settings', e);
900
- }
901
- }
1092
+ // Search
1093
+ document.getElementById('search-input')?.addEventListener('keypress', (e) => {
1094
+ if (e.key === 'Enter') loadMemories(e.target.value);
1095
+ });
902
1096
 
903
- function updateSettingsUI(settings) {
904
- if (settings.has_api_key) {
905
- apiStatusBadge.textContent = `Configured (${settings.api_key_source})`;
906
- apiStatusBadge.className = 'status-badge configured';
907
- apiKeyInput.placeholder = settings.api_key_preview || 'sk-ant-api03-...';
908
- apiKeyInput.value = '';
909
- } else {
910
- apiStatusBadge.textContent = 'Not configured';
911
- apiStatusBadge.className = 'status-badge not-configured';
912
- apiKeyInput.placeholder = 'sk-ant-api03-...';
913
- }
914
- }
1097
+ // Entity filter
1098
+ entityTypeFilter?.addEventListener('change', () => {
1099
+ loadEntities(entityTypeFilter.value);
1100
+ });
915
1101
 
916
- if (toggleKeyVisibility) {
917
- toggleKeyVisibility.addEventListener('click', () => {
918
- const type = apiKeyInput.type === 'password' ? 'text' : 'password';
919
- apiKeyInput.type = type;
920
- toggleKeyVisibility.textContent = type === 'password' ? '👁' : '🙈';
921
- });
922
- }
1102
+ // Add memory button
1103
+ document.getElementById('add-memory-btn')?.addEventListener('click', () => {
1104
+ editingMemoryId = null;
1105
+ if (modalTitle) modalTitle.textContent = 'Add Memory';
1106
+ modal?.classList.remove('hidden');
1107
+ });
923
1108
 
924
- if (saveApiKeyBtn) {
925
- saveApiKeyBtn.addEventListener('click', async () => {
926
- const apiKey = apiKeyInput.value.trim();
927
- if (!apiKey) {
928
- alert('Please enter an API key');
929
- return;
930
- }
1109
+ // Modal events
1110
+ document.getElementById('modal-cancel')?.addEventListener('click', closeModal);
1111
+ document.getElementById('modal-cancel-btn')?.addEventListener('click', closeModal);
1112
+ modalForm?.addEventListener('submit', (e) => {
1113
+ e.preventDefault();
1114
+ saveMemory();
1115
+ });
1116
+ modalImportance?.addEventListener('input', () => {
1117
+ if (importanceValue) importanceValue.textContent = modalImportance.value;
1118
+ });
1119
+ modal?.addEventListener('click', (e) => {
1120
+ if (e.target === modal) closeModal();
1121
+ });
931
1122
 
932
- if (!apiKey.startsWith('sk-ant-')) {
933
- alert('Invalid API key format. Should start with sk-ant-');
934
- return;
935
- }
1123
+ // Entity modal
1124
+ document.getElementById('entity-modal-close')?.addEventListener('click', () => {
1125
+ entityModal?.classList.add('hidden');
1126
+ });
1127
+ document.getElementById('entity-modal-close-btn')?.addEventListener('click', () => {
1128
+ entityModal?.classList.add('hidden');
1129
+ });
1130
+ entityModal?.addEventListener('click', (e) => {
1131
+ if (e.target === entityModal) entityModal.classList.add('hidden');
1132
+ });
936
1133
 
937
- try {
938
- saveApiKeyBtn.disabled = true;
939
- saveApiKeyBtn.textContent = 'Saving...';
1134
+ // Consolidation
1135
+ runConsolidationBtn?.addEventListener('click', runConsolidation);
1136
+ contradictionForm?.addEventListener('submit', (e) => {
1137
+ e.preventDefault();
1138
+ resolveContradiction(contradictionResolution?.value || '');
1139
+ });
1140
+ document.getElementById('contradiction-cancel')?.addEventListener('click', closeContradictionModal);
1141
+ document.getElementById('contradiction-close')?.addEventListener('click', closeContradictionModal);
1142
+ document.getElementById('contradiction-dismiss')?.addEventListener('click', dismissContradiction);
1143
+ contradictionModal?.addEventListener('click', (e) => {
1144
+ if (e.target === contradictionModal) closeContradictionModal();
1145
+ });
940
1146
 
941
- const result = await api('/api/settings', {
942
- method: 'POST',
943
- body: { anthropic_api_key: apiKey },
944
- });
1147
+ // Settings
1148
+ toggleKeyVisibility?.addEventListener('click', () => {
1149
+ if (!apiKeyInput) return;
1150
+ const type = apiKeyInput.type === 'password' ? 'text' : 'password';
1151
+ apiKeyInput.type = type;
1152
+ const eyeIcon = document.getElementById('eye-icon');
1153
+ if (eyeIcon) {
1154
+ eyeIcon.innerHTML = type === 'password'
1155
+ ? '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>'
1156
+ : '<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><path d="M1 1l22 22"/>';
1157
+ }
1158
+ });
945
1159
 
946
- if (result.success) {
947
- apiKeyInput.value = '';
948
- await loadSettings();
949
- await checkApiStatus();
950
- alert('API key saved successfully!');
951
- } else {
952
- alert('Failed to save API key');
953
- }
954
- } catch (e) {
955
- console.error('Failed to save API key', e);
956
- alert('Error saving API key');
957
- } finally {
958
- saveApiKeyBtn.disabled = false;
959
- saveApiKeyBtn.textContent = 'Save API Key';
960
- }
961
- });
962
- }
1160
+ saveApiKeyBtn?.addEventListener('click', async () => {
1161
+ const apiKey = apiKeyInput?.value.trim();
1162
+ if (!apiKey) {
1163
+ alert('Please enter an API key');
1164
+ return;
1165
+ }
1166
+
1167
+ if (!apiKey.startsWith('sk-ant-')) {
1168
+ alert('Invalid API key format. Should start with sk-ant-');
1169
+ return;
1170
+ }
963
1171
 
964
- if (clearApiKeyBtn) {
965
- clearApiKeyBtn.addEventListener('click', async () => {
966
- if (!confirm('Are you sure you want to clear the API key?')) return;
1172
+ try {
1173
+ saveApiKeyBtn.disabled = true;
1174
+ saveApiKeyBtn.textContent = 'Saving...';
967
1175
 
968
- try {
969
- clearApiKeyBtn.disabled = true;
970
- await api('/api/settings', {
971
- method: 'POST',
972
- body: { anthropic_api_key: '' },
973
- });
1176
+ const result = await api('/api/settings', {
1177
+ method: 'POST',
1178
+ body: { anthropic_api_key: apiKey },
1179
+ });
1180
+
1181
+ if (result.success) {
1182
+ if (apiKeyInput) apiKeyInput.value = '';
974
1183
  await loadSettings();
975
1184
  await checkApiStatus();
976
- } catch (e) {
977
- console.error('Failed to clear API key', e);
978
- } finally {
979
- clearApiKeyBtn.disabled = false;
1185
+ alert('API key saved successfully!');
1186
+ } else {
1187
+ alert('Failed to save API key');
980
1188
  }
981
- });
1189
+ } catch (e) {
1190
+ console.error('Failed to save API key', e);
1191
+ alert('Error saving API key');
1192
+ } finally {
1193
+ saveApiKeyBtn.disabled = false;
1194
+ saveApiKeyBtn.textContent = 'Save API Key';
1195
+ }
1196
+ });
1197
+
1198
+ clearApiKeyBtn?.addEventListener('click', async () => {
1199
+ if (!confirm('Are you sure you want to clear the API key?')) return;
1200
+
1201
+ try {
1202
+ clearApiKeyBtn.disabled = true;
1203
+ await api('/api/settings', {
1204
+ method: 'POST',
1205
+ body: { anthropic_api_key: '' },
1206
+ });
1207
+ await loadSettings();
1208
+ await checkApiStatus();
1209
+ } catch (e) {
1210
+ console.error('Failed to clear API key', e);
1211
+ } finally {
1212
+ clearApiKeyBtn.disabled = false;
1213
+ }
1214
+ });
1215
+
1216
+ // Chat
1217
+ chatToggle?.addEventListener('click', toggleChat);
1218
+ chatClose?.addEventListener('click', toggleChat);
1219
+ chatClear?.addEventListener('click', clearChatHistory);
1220
+ chatForm?.addEventListener('submit', (e) => {
1221
+ e.preventDefault();
1222
+ sendChatMessage(chatInput?.value || '');
1223
+ });
1224
+
1225
+ // ============ Initialization ============
1226
+ function init() {
1227
+ initTheme();
1228
+ initSidebar();
1229
+ checkApiStatus();
1230
+ loadStats();
1231
+ loadMemories();
982
1232
  }
983
1233
 
984
- // Initialize
985
- checkApiStatus();
986
- loadStats();
987
- loadMemories();
1234
+ // Run on DOM ready
1235
+ if (document.readyState === 'loading') {
1236
+ document.addEventListener('DOMContentLoaded', init);
1237
+ } else {
1238
+ init();
1239
+ }