stenotype 0.1.1 → 0.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.
@@ -0,0 +1,1301 @@
1
+ // ─── State ───────────────────────────────────────────────────────────────────
2
+ const state = {
3
+ currentView: 'memories',
4
+ filters: {
5
+ category: 'all',
6
+ temperature: 'all',
7
+ agent_id: 'all',
8
+ period: 'all',
9
+ q: '',
10
+ },
11
+ showArchived: false,
12
+ page: 1,
13
+ limit: 24,
14
+ total: 0,
15
+ memories: [],
16
+ pinned: [],
17
+ commitments: [],
18
+ stats: null,
19
+ settings: null,
20
+ agents: [],
21
+ selectedMemory: null,
22
+ pendingDeleteId: null,
23
+ loading: false,
24
+ searchTimer: null,
25
+ };
26
+
27
+ // ─── DOM refs ────────────────────────────────────────────────────────────────
28
+ const $ = (sel, ctx = document) => ctx.querySelector(sel);
29
+ const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)];
30
+
31
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
32
+ function formatDate(iso) {
33
+ if (!iso) return '—';
34
+ try {
35
+ const d = new Date(iso);
36
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
37
+ } catch { return iso; }
38
+ }
39
+
40
+ function formatDateTime(iso) {
41
+ if (!iso) return '—';
42
+ try {
43
+ const d = new Date(iso);
44
+ return d.toLocaleString('en-US', {
45
+ month: 'short', day: 'numeric', year: 'numeric',
46
+ hour: '2-digit', minute: '2-digit'
47
+ });
48
+ } catch { return iso; }
49
+ }
50
+
51
+ function parseTags(tagsRaw) {
52
+ if (!tagsRaw) return [];
53
+ try {
54
+ const parsed = JSON.parse(tagsRaw);
55
+ return Array.isArray(parsed) ? parsed : [];
56
+ } catch {
57
+ if (typeof tagsRaw === 'string' && tagsRaw.trim()) {
58
+ return tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
59
+ }
60
+ return [];
61
+ }
62
+ }
63
+
64
+ function escapeHtml(str) {
65
+ if (!str) return '';
66
+ return str
67
+ .replace(/&/g, '&')
68
+ .replace(/</g, '&lt;')
69
+ .replace(/>/g, '&gt;')
70
+ .replace(/"/g, '&quot;')
71
+ .replace(/'/g, '&#39;');
72
+ }
73
+
74
+ function showToast(msg, type = '') {
75
+ const container = $('#toast-container');
76
+ const toast = document.createElement('div');
77
+ toast.className = `toast ${type}`;
78
+ toast.textContent = msg;
79
+ container.appendChild(toast);
80
+ setTimeout(() => {
81
+ toast.style.opacity = '0';
82
+ toast.style.transform = 'translateY(10px)';
83
+ toast.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
84
+ setTimeout(() => toast.remove(), 350);
85
+ }, 2800);
86
+ }
87
+
88
+ // ─── Temperature lifecycle ────────────────────────────────────────────────────
89
+ function computeLifecycle(m) {
90
+ const now = Date.now();
91
+ const created = new Date(m.created_at + 'Z').getTime();
92
+ const lastAccessed = m.last_accessed_at ? new Date(m.last_accessed_at + 'Z').getTime() : null;
93
+
94
+ const daysSinceCreated = (now - created) / 86400000;
95
+ const daysSinceAccessed = lastAccessed != null ? (now - lastAccessed) / 86400000 : null;
96
+
97
+ const isHot = daysSinceCreated < 3 || (daysSinceAccessed != null && daysSinceAccessed < 7);
98
+ const isWarm = !isHot && (daysSinceCreated < 14 || (daysSinceAccessed != null && daysSinceAccessed < 30));
99
+
100
+ if (isHot) {
101
+ const daysFromCreation = Math.max(0, 3 - daysSinceCreated);
102
+ const daysFromAccess = daysSinceAccessed != null ? Math.max(0, 7 - daysSinceAccessed) : 0;
103
+ const daysUntilWarm = Math.max(daysFromCreation, daysFromAccess);
104
+ return { current: 'hot', next: 'warm', daysUntilNext: Math.ceil(daysUntilWarm) };
105
+ }
106
+
107
+ if (isWarm) {
108
+ const daysFromCreation = Math.max(0, 14 - daysSinceCreated);
109
+ const daysFromAccess = daysSinceAccessed != null ? Math.max(0, 30 - daysSinceAccessed) : 0;
110
+ const daysUntilCold = Math.max(daysFromCreation, daysFromAccess);
111
+ return { current: 'warm', next: 'cold', daysUntilNext: Math.ceil(daysUntilCold) };
112
+ }
113
+
114
+ const canBeArchived = (m.access_count === 0 || m.access_count == null) && (m.importance <= 0.5);
115
+ if (canBeArchived) {
116
+ const daysUntilArchive = Math.max(0, 60 - daysSinceCreated);
117
+ return {
118
+ current: 'cold',
119
+ next: 'archived',
120
+ daysUntilNext: Math.ceil(daysUntilArchive),
121
+ atRisk: daysUntilArchive < 14,
122
+ };
123
+ }
124
+
125
+ return { current: 'cold', next: null, safe: true };
126
+ }
127
+
128
+ function renderLifecycle(m) {
129
+ if (!m.is_active) {
130
+ return `<div class="lifecycle-safe">
131
+ <span>📦</span>
132
+ <span>This memory is archived. Restore it to make it active again.</span>
133
+ </div>`;
134
+ }
135
+
136
+ const lc = computeLifecycle(m);
137
+ const stages = ['hot', 'warm', 'cold', 'archived'];
138
+ const currentIdx = stages.indexOf(lc.current);
139
+
140
+ const dotColors = { hot: '#EF4444', warm: '#F59E0B', cold: '#60A5FA', archived: '#9CA3AF' };
141
+ const labels = { hot: 'Hot', warm: 'Warm', cold: 'Cold', archived: 'Archived' };
142
+
143
+ const trackHtml = stages.map((s, i) => {
144
+ const isCurrent = s === lc.current;
145
+ const isPast = i < currentIdx;
146
+ const cls = isCurrent ? 'current' : (isPast ? 'inactive' : 'inactive');
147
+ const hint = isCurrent && lc.next
148
+ ? (lc.daysUntilNext === 0 ? 'transition imminent' : `→ ${lc.next} in ${lc.daysUntilNext}d`)
149
+ : '';
150
+ return `<div class="lifecycle-stage ${cls}">
151
+ <div class="lifecycle-stage-dot" style="background:${dotColors[s]}"></div>
152
+ <div class="lifecycle-stage-label">${labels[s]}</div>
153
+ ${hint ? `<div class="lifecycle-stage-hint">${hint}</div>` : ''}
154
+ </div>`;
155
+ }).join('');
156
+
157
+ let alertHtml = '';
158
+ if (lc.next === 'archived') {
159
+ if (lc.daysUntilNext === 0) {
160
+ alertHtml = `<div class="lifecycle-warning lifecycle-danger">⚠️ Eligible for auto-archiving now (next maintenance cycle)</div>`;
161
+ } else if (lc.atRisk) {
162
+ alertHtml = `<div class="lifecycle-warning">⚠️ At risk — will be archived in ${lc.daysUntilNext} days if not accessed (importance ≤ 50%, access count = 0)</div>`;
163
+ }
164
+ } else if (lc.safe) {
165
+ alertHtml = `<div class="lifecycle-safe">✓ Protected from auto-archiving (access count > 0 or importance > 50%)</div>`;
166
+ }
167
+
168
+ return `<div class="lifecycle-track">${trackHtml}</div>${alertHtml}`;
169
+ }
170
+
171
+ async function restoreMemory(id) {
172
+ try {
173
+ const res = await fetch(`/api/memories/${encodeURIComponent(id)}/restore`, { method: 'PUT' });
174
+ if (!res.ok) throw new Error('Restore failed');
175
+ showToast('Memory restored.', 'success');
176
+ closeModal();
177
+ state.memories = state.memories.filter(m => m.id !== id);
178
+ state.total = Math.max(0, state.total - 1);
179
+ renderMemories();
180
+ updateLoadMore();
181
+ loadStats();
182
+ } catch {
183
+ showToast('Failed to restore memory.', 'error');
184
+ }
185
+ }
186
+
187
+ // ─── Auth check ──────────────────────────────────────────────────────────────
188
+ async function checkAuth() {
189
+ try {
190
+ const res = await fetch('/auth/me');
191
+ const data = await res.json();
192
+ if (!data.user) {
193
+ window.location.href = '/login.html';
194
+ return false;
195
+ }
196
+ const nameEl = $('#user-name');
197
+ if (nameEl) nameEl.textContent = data.user.username;
198
+ const avatarEl = $('#user-avatar');
199
+ if (avatarEl) avatarEl.textContent = (data.user.username || 'U')[0].toUpperCase();
200
+ return true;
201
+ } catch {
202
+ window.location.href = '/login.html';
203
+ return false;
204
+ }
205
+ }
206
+
207
+ // ─── API calls ───────────────────────────────────────────────────────────────
208
+ async function fetchStats() {
209
+ try {
210
+ const res = await fetch('/api/stats');
211
+ if (!res.ok) throw new Error('Stats fetch failed');
212
+ return await res.json();
213
+ } catch (e) {
214
+ console.error('fetchStats:', e);
215
+ return null;
216
+ }
217
+ }
218
+
219
+ async function fetchMemories({ page = 1, append = false } = {}) {
220
+ if (state.loading) return;
221
+ state.loading = true;
222
+
223
+ const container = $('#memories-container');
224
+ if (!append) {
225
+ container.innerHTML = `
226
+ <div class="loading-state">
227
+ <div class="loading-spinner"></div>
228
+ <div class="loading-text">Loading memories…</div>
229
+ </div>`;
230
+ }
231
+
232
+ const params = new URLSearchParams({
233
+ page,
234
+ limit: state.limit,
235
+ });
236
+ if (state.filters.category !== 'all') params.set('category', state.filters.category);
237
+ if (state.filters.temperature !== 'all') params.set('temperature', state.filters.temperature);
238
+ if (state.filters.agent_id !== 'all') params.set('agent_id', state.filters.agent_id);
239
+ if (state.filters.q) params.set('q', state.filters.q);
240
+ if (state.showArchived) params.set('archived', 'true');
241
+ if (state.filters.period !== 'all') {
242
+ const now = new Date();
243
+ const days = { today: 0, '7d': 7, '30d': 30, '90d': 90 }[state.filters.period];
244
+ const from = new Date(now);
245
+ if (days === 0) {
246
+ from.setHours(0, 0, 0, 0);
247
+ } else {
248
+ from.setDate(from.getDate() - days);
249
+ from.setHours(0, 0, 0, 0);
250
+ }
251
+ params.set('date_from', from.toISOString().slice(0, 19).replace('T', ' '));
252
+ }
253
+
254
+ try {
255
+ const res = await fetch(`/api/memories?${params}`);
256
+ if (!res.ok) throw new Error('Failed to load memories');
257
+ const data = await res.json();
258
+
259
+ state.total = data.total;
260
+ state.page = page;
261
+
262
+ if (append) {
263
+ state.memories = [...state.memories, ...data.memories];
264
+ } else {
265
+ state.memories = data.memories;
266
+ }
267
+
268
+ renderMemories(append);
269
+ updateLoadMore();
270
+ } catch (e) {
271
+ console.error('fetchMemories:', e);
272
+ container.innerHTML = `
273
+ <div class="empty-state">
274
+ <div class="empty-icon">⚠️</div>
275
+ <div class="empty-title">Could not load memories</div>
276
+ <div class="empty-subtitle">${escapeHtml(e.message)}</div>
277
+ </div>`;
278
+ } finally {
279
+ state.loading = false;
280
+ }
281
+ }
282
+
283
+ async function fetchPinned() {
284
+ try {
285
+ const res = await fetch('/api/pinned');
286
+ if (!res.ok) throw new Error('Failed');
287
+ const data = await res.json();
288
+ state.pinned = data.pinned || [];
289
+ renderPinned();
290
+ const badge = $('#nav-pinned-count');
291
+ if (badge) badge.textContent = state.pinned.length;
292
+ } catch (e) {
293
+ console.error('fetchPinned:', e);
294
+ $('#pinned-container').innerHTML = `
295
+ <div class="empty-state">
296
+ <div class="empty-icon">📌</div>
297
+ <div class="empty-title">No pinned memories</div>
298
+ <div class="empty-subtitle">Pinned memories will appear here.</div>
299
+ </div>`;
300
+ }
301
+ }
302
+
303
+ async function fetchCommitments() {
304
+ try {
305
+ const res = await fetch('/api/commitments');
306
+ if (!res.ok) throw new Error('Failed');
307
+ const data = await res.json();
308
+ state.commitments = data.commitments || [];
309
+ renderCommitments();
310
+ const badge = $('#nav-commitments-count');
311
+ if (badge) badge.textContent = state.commitments.length;
312
+ } catch (e) {
313
+ console.error('fetchCommitments:', e);
314
+ }
315
+ }
316
+
317
+ async function softDelete(id) {
318
+ try {
319
+ const res = await fetch(`/api/memories/${encodeURIComponent(id)}`, { method: 'DELETE' });
320
+ if (!res.ok) throw new Error('Delete failed');
321
+ showToast('Memory deleted.', 'success');
322
+ closeModal();
323
+ state.memories = state.memories.filter(m => m.id !== id);
324
+ state.total = Math.max(0, state.total - 1);
325
+ renderMemories();
326
+ updateLoadMore();
327
+ loadStats();
328
+ } catch (e) {
329
+ showToast('Failed to delete memory.', 'error');
330
+ }
331
+ }
332
+
333
+ // ─── Render functions ─────────────────────────────────────────────────────────
334
+ function renderMemories(append = false) {
335
+ const container = $('#memories-container');
336
+
337
+ if (state.memories.length === 0) {
338
+ container.innerHTML = `
339
+ <div class="empty-state">
340
+ <div class="empty-icon">🧠</div>
341
+ <div class="empty-title">No memories found</div>
342
+ <div class="empty-subtitle">
343
+ ${state.filters.q
344
+ ? `No results for "<strong>${escapeHtml(state.filters.q)}</strong>". Try a different search.`
345
+ : 'No memories match the current filters.'
346
+ }
347
+ </div>
348
+ </div>`;
349
+ return;
350
+ }
351
+
352
+ if (!append) {
353
+ container.innerHTML = '<div class="memories-grid" id="memories-grid"></div>';
354
+ }
355
+
356
+ const grid = $('#memories-grid') || (() => {
357
+ container.innerHTML = '<div class="memories-grid" id="memories-grid"></div>';
358
+ return $('#memories-grid');
359
+ })();
360
+
361
+ const startIdx = append ? state.memories.length - (state.limit) : 0;
362
+ const renderSlice = append ? state.memories.slice(startIdx) : state.memories;
363
+
364
+ const fragment = document.createDocumentFragment();
365
+ renderSlice.forEach(m => {
366
+ const card = createMemoryCard(m);
367
+ fragment.appendChild(card);
368
+ });
369
+ grid.appendChild(fragment);
370
+ }
371
+
372
+ function createMemoryCard(m) {
373
+ const el = document.createElement('div');
374
+ el.className = m.is_active === 0 ? 'memory-card archived' : 'memory-card';
375
+ el.dataset.id = m.id;
376
+
377
+ const tags = parseTags(m.tags);
378
+ const visibleTags = tags.slice(0, 3);
379
+ const tagsHtml = visibleTags.length
380
+ ? `<div class="tags-row">${visibleTags.map(t => `<span class="tag-pill">${escapeHtml(t)}</span>`).join('')}${tags.length > 3 ? `<span class="tag-pill">+${tags.length - 3}</span>` : ''}</div>`
381
+ : '';
382
+
383
+ const conf = m.confidence != null ? Math.round(m.confidence * 100) : null;
384
+ const imp = m.importance != null ? Math.round(m.importance * 100) : null;
385
+
386
+ const category = m.category || 'other';
387
+ const temp = m.temperature || '';
388
+
389
+ const isArchived = m.is_active === 0;
390
+ el.innerHTML = `
391
+ <div class="card-top">
392
+ <span class="cat-badge ${escapeHtml(category)}">${escapeHtml(category)}</span>
393
+ ${isArchived
394
+ ? `<span class="temp-dot archived" title="archived"></span>`
395
+ : temp ? `<span class="temp-dot ${escapeHtml(temp)}" title="${escapeHtml(temp)}"></span>` : ''}
396
+ </div>
397
+ ${m.subject ? `<div class="card-subject">${escapeHtml(m.subject)}</div>` : ''}
398
+ <div class="card-content">${escapeHtml(m.content || '')}</div>
399
+ ${(conf != null || imp != null) ? `
400
+ <div class="card-bars">
401
+ ${conf != null ? `
402
+ <div class="bar-wrap">
403
+ <div class="bar-label">Confidence</div>
404
+ <div class="bar-track"><div class="bar-fill confidence" style="width:${conf}%"></div></div>
405
+ </div>` : ''}
406
+ ${imp != null ? `
407
+ <div class="bar-wrap">
408
+ <div class="bar-label">Importance</div>
409
+ <div class="bar-track"><div class="bar-fill importance" style="width:${imp}%"></div></div>
410
+ </div>` : ''}
411
+ </div>` : ''}
412
+ <div class="card-footer">
413
+ <span class="card-date">${formatDate(m.created_at)}</span>
414
+ ${m.agent_id ? `<span class="agent-chip">${escapeHtml(m.agent_id)}</span>` : ''}
415
+ </div>
416
+ ${tagsHtml}
417
+ `;
418
+
419
+ el.addEventListener('click', () => openModal(m.id));
420
+ return el;
421
+ }
422
+
423
+ function renderPinned() {
424
+ const container = $('#pinned-container');
425
+ const countLabel = $('#pinned-count-label');
426
+ if (countLabel) countLabel.textContent = `${state.pinned.length} pinned`;
427
+
428
+ if (state.pinned.length === 0) {
429
+ container.innerHTML = `
430
+ <div class="empty-state" style="grid-column:1/-1">
431
+ <div class="empty-icon">📌</div>
432
+ <div class="empty-title">Nothing pinned yet</div>
433
+ <div class="empty-subtitle">Memories you pin will appear here for quick access.</div>
434
+ </div>`;
435
+ return;
436
+ }
437
+
438
+ container.innerHTML = '';
439
+ const fragment = document.createDocumentFragment();
440
+ state.pinned.forEach(p => {
441
+ const el = document.createElement('div');
442
+ el.className = 'pinned-card';
443
+ el.innerHTML = `
444
+ <div class="pinned-card-top">
445
+ <span class="pin-icon">📌</span>
446
+ <span class="pinned-category">${escapeHtml(p.category || 'pinned')}</span>
447
+ <span class="pinned-date">${formatDate(p.created_at)}</span>
448
+ </div>
449
+ <div class="pinned-content">${escapeHtml(p.content || '')}</div>
450
+ ${p.agent_id ? `<div class="pinned-agent">${escapeHtml(p.agent_id)}</div>` : ''}
451
+ `;
452
+ fragment.appendChild(el);
453
+ });
454
+ container.appendChild(fragment);
455
+ }
456
+
457
+ function renderCommitments() {
458
+ const tbody = $('#commitments-body');
459
+ const countLabel = $('#commitments-count-label');
460
+ if (countLabel) countLabel.textContent = `${state.commitments.length} commitments`;
461
+
462
+ if (state.commitments.length === 0) {
463
+ tbody.innerHTML = `
464
+ <tr>
465
+ <td colspan="5" style="text-align:center;padding:48px 16px">
466
+ <div class="empty-state" style="padding:0">
467
+ <div class="empty-icon" style="font-size:32px">✅</div>
468
+ <div class="empty-title" style="font-size:17px">No commitments</div>
469
+ <div class="empty-subtitle">Tracked commitments will appear here.</div>
470
+ </div>
471
+ </td>
472
+ </tr>`;
473
+ return;
474
+ }
475
+
476
+ tbody.innerHTML = '';
477
+ const fragment = document.createDocumentFragment();
478
+ state.commitments.forEach(c => {
479
+ const tr = document.createElement('tr');
480
+ const stateKey = (c.state || 'open').replace(/[^a-z_]/g, '');
481
+ tr.innerHTML = `
482
+ <td><div class="commitment-text">${escapeHtml(c.commitment || '')}</div></td>
483
+ <td><span class="state-badge ${escapeHtml(stateKey)}">${escapeHtml(c.state || 'open')}</span></td>
484
+ <td class="td-source" title="${escapeHtml(c.source || '')}">${escapeHtml(c.source || '—')}</td>
485
+ <td><span class="agent-chip">${escapeHtml(c.agent_id || '—')}</span></td>
486
+ <td class="td-date">${formatDate(c.created_at)}</td>
487
+ `;
488
+ fragment.appendChild(tr);
489
+ });
490
+ tbody.appendChild(fragment);
491
+ }
492
+
493
+ function updateLoadMore() {
494
+ const wrap = $('#load-more-wrap');
495
+ const btn = $('#load-more-btn');
496
+ const loaded = state.memories.length;
497
+ const hasMore = loaded < state.total;
498
+
499
+ wrap.style.display = hasMore ? 'flex' : 'none';
500
+ if (btn) {
501
+ btn.textContent = `Load more (${state.total - loaded} remaining)`;
502
+ btn.disabled = state.loading;
503
+ }
504
+ }
505
+
506
+ // ─── Stats ────────────────────────────────────────────────────────────────────
507
+ async function loadStats() {
508
+ const stats = await fetchStats();
509
+ if (!stats) return;
510
+ state.stats = stats;
511
+
512
+ const activeEl = $('#stats-active-count');
513
+ if (activeEl) activeEl.textContent = stats.active ?? '—';
514
+
515
+ const navCount = $('#nav-memories-count');
516
+ if (navCount) navCount.textContent = stats.active ?? '—';
517
+
518
+ const tempMap = {};
519
+ (stats.byTemperature || []).forEach(row => {
520
+ tempMap[row.temperature] = row.count;
521
+ });
522
+ const hotEl = $('#stats-hot');
523
+ const warmEl = $('#stats-warm');
524
+ const coldEl = $('#stats-cold');
525
+ if (hotEl) hotEl.textContent = tempMap['hot'] ?? 0;
526
+ if (warmEl) warmEl.textContent = tempMap['warm'] ?? 0;
527
+ if (coldEl) coldEl.textContent = tempMap['cold'] ?? 0;
528
+
529
+ const archivedPillCount = $('#archived-pill-count');
530
+ if (archivedPillCount && stats.archived != null) {
531
+ archivedPillCount.textContent = stats.archived.toLocaleString();
532
+ }
533
+
534
+ const agentSelect = $('#agent-select');
535
+ if (agentSelect && stats.byAgent && stats.byAgent.length > 0) {
536
+ while (agentSelect.options.length > 1) agentSelect.remove(1);
537
+ stats.byAgent.forEach(row => {
538
+ const opt = document.createElement('option');
539
+ opt.value = row.agent_id;
540
+ opt.textContent = `${row.agent_id} (${row.count})`;
541
+ agentSelect.appendChild(opt);
542
+ });
543
+ state.agents = stats.byAgent.map(r => r.agent_id);
544
+ }
545
+ }
546
+
547
+ // ─── Modal ────────────────────────────────────────────────────────────────────
548
+ async function openModal(id) {
549
+ state.selectedMemory = id;
550
+
551
+ try {
552
+ const res = await fetch(`/api/memories/${encodeURIComponent(id)}`);
553
+ if (!res.ok) throw new Error('Not found');
554
+ const m = await res.json();
555
+
556
+ const category = m.category || 'other';
557
+ const temp = m.temperature || '';
558
+
559
+ $('#modal-cat').innerHTML = `
560
+ <span class="cat-badge ${escapeHtml(category)}">${escapeHtml(category)}</span>
561
+ ${temp ? `<span class="temp-dot ${escapeHtml(temp)}" title="${escapeHtml(temp)}" style="display:inline-block;margin-left:8px;vertical-align:middle"></span>` : ''}
562
+ `;
563
+ $('#modal-subject').textContent = m.subject || '(no subject)';
564
+ $('#modal-content').textContent = m.content || '';
565
+
566
+ const conf = m.confidence != null ? m.confidence : null;
567
+ const imp = m.importance != null ? m.importance : null;
568
+
569
+ if (conf != null) {
570
+ $('#modal-confidence-val').textContent = `${Math.round(conf * 100)}%`;
571
+ $('#modal-confidence-bar').style.width = `${Math.round(conf * 100)}%`;
572
+ }
573
+ if (imp != null) {
574
+ $('#modal-importance-val').textContent = `${Math.round(imp * 100)}%`;
575
+ $('#modal-importance-bar').style.width = `${Math.round(imp * 100)}%`;
576
+ }
577
+
578
+ const tags = parseTags(m.tags);
579
+ const tagsContainer = $('#modal-tags');
580
+ tagsContainer.innerHTML = tags.length
581
+ ? tags.map(t => `<span class="modal-tag">${escapeHtml(t)}</span>`).join('')
582
+ : '<span style="font-size:12px;color:var(--muted)">No tags</span>';
583
+
584
+ $('#modal-temp').textContent = temp || '—';
585
+ $('#modal-agent').textContent = m.agent_id || '—';
586
+ $('#modal-source').textContent = m.source_channel || '—';
587
+ $('#modal-extracted').textContent = m.extracted_by || '—';
588
+ $('#modal-created').textContent = formatDateTime(m.created_at);
589
+ $('#modal-updated').textContent = formatDateTime(m.updated_at);
590
+ $('#modal-access-count').textContent = m.access_count ?? '—';
591
+ $('#modal-last-accessed').textContent = formatDateTime(m.last_accessed_at);
592
+ $('#modal-id').textContent = m.id;
593
+
594
+ const lifecycleEl = $('#modal-lifecycle');
595
+ if (lifecycleEl) lifecycleEl.innerHTML = renderLifecycle(m);
596
+
597
+ const restoreBtn = $('#modal-restore-btn');
598
+ const deleteBtn = $('#modal-delete-btn');
599
+ if (m.is_active === 0) {
600
+ if (restoreBtn) restoreBtn.style.display = 'block';
601
+ if (deleteBtn) deleteBtn.style.display = 'none';
602
+ } else {
603
+ if (restoreBtn) restoreBtn.style.display = 'none';
604
+ if (deleteBtn) deleteBtn.style.display = 'block';
605
+ }
606
+
607
+ if (m.supersedes_id) {
608
+ $('#modal-supersedes-wrap').style.display = 'block';
609
+ $('#modal-supersedes').textContent = m.supersedes_id;
610
+ } else {
611
+ $('#modal-supersedes-wrap').style.display = 'none';
612
+ }
613
+ if (m.superseded_by) {
614
+ $('#modal-superseded-by-wrap').style.display = 'block';
615
+ $('#modal-superseded-by').textContent = m.superseded_by;
616
+ } else {
617
+ $('#modal-superseded-by-wrap').style.display = 'none';
618
+ }
619
+
620
+ const overlay = $('#modal-overlay');
621
+ overlay.classList.add('open');
622
+ document.body.style.overflow = 'hidden';
623
+
624
+ } catch (e) {
625
+ showToast('Could not load memory details.', 'error');
626
+ }
627
+ }
628
+
629
+ function closeModal() {
630
+ const overlay = $('#modal-overlay');
631
+ overlay.classList.remove('open');
632
+ document.body.style.overflow = '';
633
+ state.selectedMemory = null;
634
+ }
635
+
636
+ // ─── Confirm dialog ───────────────────────────────────────────────────────────
637
+ function openConfirmDelete(id) {
638
+ state.pendingDeleteId = id;
639
+ $('#confirm-overlay').classList.add('open');
640
+ }
641
+
642
+ function closeConfirmDelete() {
643
+ $('#confirm-overlay').classList.remove('open');
644
+ state.pendingDeleteId = null;
645
+ }
646
+
647
+ // ─── View switching ───────────────────────────────────────────────────────────
648
+ function switchView(view) {
649
+ state.currentView = view;
650
+
651
+ $$('.nav-item').forEach(item => {
652
+ item.classList.toggle('active', item.dataset.view === view);
653
+ });
654
+
655
+ $$('.view').forEach(v => {
656
+ v.classList.toggle('active', v.id === `view-${view}`);
657
+ });
658
+
659
+ const titles = { memories: 'Memories', pinned: 'Pinned', commitments: 'Commitments', settings: 'Settings' };
660
+ const pageTitleEl = $('#page-title');
661
+ if (pageTitleEl) pageTitleEl.textContent = titles[view] || view;
662
+
663
+ const filterRow = $('#filter-row');
664
+ const searchWrap = $('#search-wrap');
665
+ if (filterRow) filterRow.style.display = view === 'memories' ? 'flex' : 'none';
666
+ if (searchWrap) searchWrap.style.display = view === 'memories' ? 'block' : 'none';
667
+
668
+ if (view === 'pinned') {
669
+ fetchPinned();
670
+ } else if (view === 'commitments') {
671
+ fetchCommitments();
672
+ } else if (view === 'settings') {
673
+ loadSettings();
674
+ }
675
+ }
676
+
677
+ // ─── Filter handling ──────────────────────────────────────────────────────────
678
+ function resetAndFetch() {
679
+ state.page = 1;
680
+ state.memories = [];
681
+ fetchMemories({ page: 1, append: false });
682
+ }
683
+
684
+ function setupFilters() {
685
+ $$('[data-cat]').forEach(pill => {
686
+ pill.addEventListener('click', () => {
687
+ $$('[data-cat]').forEach(p => p.classList.remove('active'));
688
+ pill.classList.add('active');
689
+ state.filters.category = pill.dataset.cat;
690
+ resetAndFetch();
691
+ });
692
+ });
693
+
694
+ $$('[data-period]').forEach(pill => {
695
+ pill.addEventListener('click', () => {
696
+ $$('[data-period]').forEach(p => p.classList.remove('active'));
697
+ pill.classList.add('active');
698
+ state.filters.period = pill.dataset.period;
699
+ resetAndFetch();
700
+ });
701
+ });
702
+
703
+ $$('[data-temp]').forEach(pill => {
704
+ pill.addEventListener('click', () => {
705
+ $$('[data-temp]').forEach(p => p.classList.remove('active'));
706
+ pill.classList.add('active');
707
+ state.filters.temperature = pill.dataset.temp;
708
+ resetAndFetch();
709
+ });
710
+ });
711
+
712
+ const agentSelect = $('#agent-select');
713
+ if (agentSelect) {
714
+ agentSelect.addEventListener('change', () => {
715
+ state.filters.agent_id = agentSelect.value;
716
+ resetAndFetch();
717
+ });
718
+ }
719
+
720
+ const searchInput = $('#search-input');
721
+ if (searchInput) {
722
+ searchInput.addEventListener('input', () => {
723
+ clearTimeout(state.searchTimer);
724
+ state.searchTimer = setTimeout(() => {
725
+ state.filters.q = searchInput.value.trim();
726
+ resetAndFetch();
727
+ }, 300);
728
+ });
729
+ }
730
+ }
731
+
732
+ // ─── Event listeners ──────────────────────────────────────────────────────────
733
+ function setupEventListeners() {
734
+ $$('.nav-item').forEach(item => {
735
+ item.addEventListener('click', () => {
736
+ if (item.dataset.view) switchView(item.dataset.view);
737
+ });
738
+ });
739
+
740
+ const loadMoreBtn = $('#load-more-btn');
741
+ if (loadMoreBtn) {
742
+ loadMoreBtn.addEventListener('click', () => {
743
+ fetchMemories({ page: state.page + 1, append: true });
744
+ });
745
+ }
746
+
747
+ const modalClose = $('#modal-close');
748
+ if (modalClose) modalClose.addEventListener('click', closeModal);
749
+
750
+ const modalOverlay = $('#modal-overlay');
751
+ if (modalOverlay) {
752
+ modalOverlay.addEventListener('click', (e) => {
753
+ if (e.target === modalOverlay) closeModal();
754
+ });
755
+ }
756
+
757
+ const deleteBtn = $('#modal-delete-btn');
758
+ if (deleteBtn) {
759
+ deleteBtn.addEventListener('click', () => {
760
+ if (state.selectedMemory) {
761
+ openConfirmDelete(state.selectedMemory);
762
+ }
763
+ });
764
+ }
765
+
766
+ const confirmCancel = $('#confirm-cancel');
767
+ if (confirmCancel) confirmCancel.addEventListener('click', closeConfirmDelete);
768
+
769
+ const confirmDelete = $('#confirm-delete');
770
+ if (confirmDelete) {
771
+ confirmDelete.addEventListener('click', async () => {
772
+ if (state.pendingDeleteId) {
773
+ const id = state.pendingDeleteId;
774
+ closeConfirmDelete();
775
+ await softDelete(id);
776
+ }
777
+ });
778
+ }
779
+
780
+ const confirmOverlay = $('#confirm-overlay');
781
+ if (confirmOverlay) {
782
+ confirmOverlay.addEventListener('click', (e) => {
783
+ if (e.target === confirmOverlay) closeConfirmDelete();
784
+ });
785
+ }
786
+
787
+ const archivedToggle = $('#archived-toggle');
788
+ if (archivedToggle) {
789
+ archivedToggle.addEventListener('click', () => {
790
+ state.showArchived = !state.showArchived;
791
+ archivedToggle.dataset.active = String(state.showArchived);
792
+
793
+ const tempPills = $$('[data-temp]');
794
+ if (state.showArchived) {
795
+ state.filters.temperature = 'all';
796
+ tempPills.forEach(p => {
797
+ p.classList.toggle('active', p.dataset.temp === 'all');
798
+ p.disabled = p.dataset.temp !== 'all';
799
+ p.style.opacity = p.dataset.temp !== 'all' ? '0.35' : '';
800
+ p.style.pointerEvents = p.dataset.temp !== 'all' ? 'none' : '';
801
+ });
802
+ } else {
803
+ tempPills.forEach(p => {
804
+ p.disabled = false;
805
+ p.style.opacity = '';
806
+ p.style.pointerEvents = '';
807
+ });
808
+ }
809
+
810
+ resetAndFetch();
811
+ });
812
+ }
813
+
814
+ const restoreBtn = $('#modal-restore-btn');
815
+ if (restoreBtn) {
816
+ restoreBtn.addEventListener('click', () => {
817
+ if (state.selectedMemory) restoreMemory(state.selectedMemory);
818
+ });
819
+ }
820
+
821
+ const logoutBtn = $('#logout-btn');
822
+ if (logoutBtn) {
823
+ logoutBtn.addEventListener('click', async () => {
824
+ try {
825
+ await fetch('/auth/logout', { method: 'POST' });
826
+ } catch {}
827
+ window.location.href = '/login.html';
828
+ });
829
+ }
830
+
831
+ document.addEventListener('keydown', (e) => {
832
+ if (e.key === 'Escape') {
833
+ if ($('#confirm-overlay').classList.contains('open')) {
834
+ closeConfirmDelete();
835
+ } else if ($('#modal-overlay').classList.contains('open')) {
836
+ closeModal();
837
+ }
838
+ }
839
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
840
+ e.preventDefault();
841
+ const searchInput = $('#search-input');
842
+ if (searchInput && state.currentView === 'memories') {
843
+ searchInput.focus();
844
+ searchInput.select();
845
+ }
846
+ }
847
+ });
848
+ }
849
+
850
+ // ─── Wizard ───────────────────────────────────────────────────────────────────
851
+ const wizardState = {
852
+ step: 0,
853
+ data: {
854
+ licenseKey: '',
855
+ geminiApiKey: '',
856
+ embeddingChoice: 'local',
857
+ platform: 'ductor',
858
+ },
859
+ };
860
+
861
+ const WIZARD_CONFIG_STEPS = 4;
862
+
863
+ async function checkSetupStatus() {
864
+ try {
865
+ const res = await fetch('/api/setup-status');
866
+ if (!res.ok) return false;
867
+ const data = await res.json();
868
+ return data.needsSetup === true;
869
+ } catch {
870
+ return false;
871
+ }
872
+ }
873
+
874
+ function renderWizardStep() {
875
+ const step = wizardState.step;
876
+ const titleEl = $('#wizard-title');
877
+ const subtitleEl = $('#wizard-subtitle');
878
+ const bodyEl = $('#wizard-body');
879
+ const stepsEl = $('#wizard-steps');
880
+ const backBtn = $('#wizard-back');
881
+ const nextBtn = $('#wizard-next');
882
+
883
+ if (step === 0 || step > WIZARD_CONFIG_STEPS) {
884
+ stepsEl.innerHTML = Array.from({ length: WIZARD_CONFIG_STEPS }, (_, i) => {
885
+ const cls = step > WIZARD_CONFIG_STEPS ? 'done' : '';
886
+ return `<div class="wizard-step-dot ${cls}"></div>`;
887
+ }).join('');
888
+ } else {
889
+ stepsEl.innerHTML = Array.from({ length: WIZARD_CONFIG_STEPS }, (_, i) => {
890
+ const dotStep = i + 1;
891
+ const cls = dotStep < step ? 'done' : dotStep === step ? 'active' : '';
892
+ return `<div class="wizard-step-dot ${cls}"></div>`;
893
+ }).join('');
894
+ }
895
+
896
+ backBtn.style.display = (step > 0 && step <= WIZARD_CONFIG_STEPS) ? 'block' : 'none';
897
+
898
+ switch (step) {
899
+ case 0:
900
+ titleEl.textContent = 'Welcome to Stenotype';
901
+ subtitleEl.textContent = 'Stenotype captures your AI conversations as searchable memories. Setup takes about 2 minutes.';
902
+ bodyEl.innerHTML = `
903
+ <div class="wizard-welcome-list">
904
+ <div class="wizard-welcome-item">
905
+ <div class="wizard-welcome-icon"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg></div>
906
+ <div>
907
+ <div class="wizard-welcome-title">License key</div>
908
+ <div class="wizard-welcome-desc">Verify your Stenotype license</div>
909
+ </div>
910
+ </div>
911
+ <div class="wizard-welcome-item">
912
+ <div class="wizard-welcome-icon"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/><circle cx="12" cy="12" r="4"/></svg></div>
913
+ <div>
914
+ <div class="wizard-welcome-title">Gemini API key</div>
915
+ <div class="wizard-welcome-desc">Powers memory extraction — bring your own key, free tier works</div>
916
+ </div>
917
+ </div>
918
+ <div class="wizard-welcome-item">
919
+ <div class="wizard-welcome-icon"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></div>
920
+ <div>
921
+ <div class="wizard-welcome-title">Semantic search</div>
922
+ <div class="wizard-welcome-desc">Local embeddings for precise recall, fully private</div>
923
+ </div>
924
+ </div>
925
+ <div class="wizard-welcome-item">
926
+ <div class="wizard-welcome-icon"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="6" height="10" rx="1"/><rect x="16" y="7" width="6" height="10" rx="1"/><path d="M8 12h8"/></svg></div>
927
+ <div>
928
+ <div class="wizard-welcome-title">Platform</div>
929
+ <div class="wizard-welcome-desc">Connect to your AI platform (Ductor, with more coming soon)</div>
930
+ </div>
931
+ </div>
932
+ </div>
933
+ `;
934
+ nextBtn.textContent = 'Get started';
935
+ break;
936
+
937
+ case 1:
938
+ titleEl.textContent = 'License key';
939
+ subtitleEl.textContent = 'Enter the license key from your Stenotype purchase email.';
940
+ bodyEl.innerHTML = `
941
+ <div class="wizard-field">
942
+ <label for="w-license">License key</label>
943
+ <input type="text" id="w-license" class="wizard-input" placeholder="steno_…" value="${escapeHtml(wizardState.data.licenseKey)}" autocomplete="off" spellcheck="false" />
944
+ <div class="wizard-error" id="w-license-error" style="display:none"></div>
945
+ </div>
946
+ `;
947
+ setTimeout(() => $('#w-license')?.focus(), 50);
948
+ nextBtn.textContent = 'Continue';
949
+ break;
950
+
951
+ case 2:
952
+ titleEl.textContent = 'Gemini API key';
953
+ subtitleEl.textContent = 'Stenotype uses Google Gemini Flash Lite to extract memories. Get your key at aistudio.google.com/apikey — the free tier is sufficient.';
954
+ bodyEl.innerHTML = `
955
+ <div class="wizard-field">
956
+ <label for="w-gemini">API key</label>
957
+ <input type="password" id="w-gemini" class="wizard-input" placeholder="AIza…" value="${escapeHtml(wizardState.data.geminiApiKey)}" autocomplete="off" />
958
+ <div class="wizard-error" id="w-gemini-error" style="display:none"></div>
959
+ <div class="wizard-hint">Stored locally on this machine. Never sent to Stenotype servers.</div>
960
+ </div>
961
+ `;
962
+ setTimeout(() => $('#w-gemini')?.focus(), 50);
963
+ nextBtn.textContent = 'Continue';
964
+ break;
965
+
966
+ case 3:
967
+ titleEl.textContent = 'Semantic search';
968
+ subtitleEl.textContent = 'Choose how Stenotype searches your memories.';
969
+ bodyEl.innerHTML = `
970
+ <div class="wizard-option-grid">
971
+ <button class="wizard-option ${wizardState.data.embeddingChoice === 'local' ? 'selected' : ''}" data-choice="local">
972
+ <div class="wizard-option-radio"><div class="wizard-option-radio-inner"></div></div>
973
+ <div>
974
+ <div class="wizard-option-title">Local embeddings (Qwen3-Embedding-0.6B)</div>
975
+ <div class="wizard-option-desc">Best recall accuracy. Fully private, runs on your CPU. One-time ~500MB download when Stenotype first starts.</div>
976
+ </div>
977
+ </button>
978
+ <button class="wizard-option ${wizardState.data.embeddingChoice === 'none' ? 'selected' : ''}" data-choice="none">
979
+ <div class="wizard-option-radio"><div class="wizard-option-radio-inner"></div></div>
980
+ <div>
981
+ <div class="wizard-option-title">Keyword search only</div>
982
+ <div class="wizard-option-desc">Faster setup, no download. Searches by exact keywords rather than semantic meaning.</div>
983
+ </div>
984
+ </button>
985
+ </div>
986
+ `;
987
+ setTimeout(() => {
988
+ $$('.wizard-option[data-choice]', bodyEl).forEach(opt => {
989
+ opt.addEventListener('click', () => {
990
+ if (opt.disabled) return;
991
+ wizardState.data.embeddingChoice = opt.dataset.choice;
992
+ $$('.wizard-option[data-choice]', bodyEl).forEach(o => {
993
+ o.classList.toggle('selected', o.dataset.choice === opt.dataset.choice);
994
+ });
995
+ });
996
+ });
997
+ }, 50);
998
+ nextBtn.textContent = 'Continue';
999
+ break;
1000
+
1001
+ case 4:
1002
+ titleEl.textContent = 'Platform';
1003
+ subtitleEl.textContent = 'Which AI platform is Stenotype integrating with?';
1004
+ bodyEl.innerHTML = `
1005
+ <div class="wizard-option-grid">
1006
+ <button class="wizard-option selected" data-platform="ductor">
1007
+ <div class="wizard-option-radio"><div class="wizard-option-radio-inner"></div></div>
1008
+ <div>
1009
+ <div class="wizard-option-title">Ductor</div>
1010
+ <div class="wizard-option-desc">Multi-agent AI system with Claude Code integration.</div>
1011
+ </div>
1012
+ </button>
1013
+ <button class="wizard-option" data-platform="openclaw" disabled>
1014
+ <div class="wizard-option-radio"><div class="wizard-option-radio-inner"></div></div>
1015
+ <div>
1016
+ <div class="wizard-option-title">OpenClaw</div>
1017
+ <div class="wizard-option-desc">Support coming soon.</div>
1018
+ </div>
1019
+ <div class="wizard-option-badge">Soon</div>
1020
+ </button>
1021
+ <button class="wizard-option" data-platform="hermes" disabled>
1022
+ <div class="wizard-option-radio"><div class="wizard-option-radio-inner"></div></div>
1023
+ <div>
1024
+ <div class="wizard-option-title">Hermes</div>
1025
+ <div class="wizard-option-desc">Support coming soon.</div>
1026
+ </div>
1027
+ <div class="wizard-option-badge">Soon</div>
1028
+ </button>
1029
+ <button class="wizard-option" data-platform="claudecode" disabled>
1030
+ <div class="wizard-option-radio"><div class="wizard-option-radio-inner"></div></div>
1031
+ <div>
1032
+ <div class="wizard-option-title">Claude Code</div>
1033
+ <div class="wizard-option-desc">Support coming soon.</div>
1034
+ </div>
1035
+ <div class="wizard-option-badge">Soon</div>
1036
+ </button>
1037
+ </div>
1038
+ `;
1039
+ nextBtn.textContent = 'Finish setup';
1040
+ break;
1041
+
1042
+ case 5:
1043
+ stepsEl.innerHTML = Array.from({ length: WIZARD_CONFIG_STEPS }, () =>
1044
+ `<div class="wizard-step-dot done"></div>`
1045
+ ).join('');
1046
+ titleEl.textContent = '';
1047
+ subtitleEl.textContent = '';
1048
+ backBtn.style.display = 'none';
1049
+ bodyEl.innerHTML = `
1050
+ <div class="wizard-success">
1051
+ <div class="wizard-success-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></div>
1052
+ <div class="wizard-success-title">You're all set!</div>
1053
+ <div class="wizard-success-sub">Stenotype is configured and ready. Memory extraction will begin automatically as you work with your AI platform.</div>
1054
+ </div>
1055
+ `;
1056
+ nextBtn.textContent = 'Open dashboard';
1057
+ nextBtn.disabled = false;
1058
+ break;
1059
+ }
1060
+ }
1061
+
1062
+ function showWizard() {
1063
+ const overlay = $('#wizard-overlay');
1064
+ overlay.classList.add('open');
1065
+ document.body.style.overflow = 'hidden';
1066
+ renderWizardStep();
1067
+ }
1068
+
1069
+ function hideWizard() {
1070
+ const overlay = $('#wizard-overlay');
1071
+ overlay.classList.remove('open');
1072
+ document.body.style.overflow = '';
1073
+ }
1074
+
1075
+ async function wizardNext() {
1076
+ const step = wizardState.step;
1077
+ const nextBtn = $('#wizard-next');
1078
+
1079
+ if (step === 1) {
1080
+ const key = ($('#w-license')?.value || '').trim();
1081
+ const errEl = $('#w-license-error');
1082
+ if (!key || key.length < 10) {
1083
+ if (errEl) { errEl.textContent = 'Please enter a valid license key.'; errEl.style.display = 'block'; }
1084
+ $('#w-license')?.classList.add('error');
1085
+ return;
1086
+ }
1087
+ wizardState.data.licenseKey = key;
1088
+ if (errEl) errEl.style.display = 'none';
1089
+ $('#w-license')?.classList.remove('error');
1090
+ }
1091
+
1092
+ if (step === 2) {
1093
+ const key = ($('#w-gemini')?.value || '').trim();
1094
+ const errEl = $('#w-gemini-error');
1095
+ if (!key || key.length < 10) {
1096
+ if (errEl) { errEl.textContent = 'Please enter a valid Gemini API key.'; errEl.style.display = 'block'; }
1097
+ $('#w-gemini')?.classList.add('error');
1098
+ return;
1099
+ }
1100
+ wizardState.data.geminiApiKey = key;
1101
+ if (errEl) errEl.style.display = 'none';
1102
+ $('#w-gemini')?.classList.remove('error');
1103
+ }
1104
+
1105
+ if (step === 4) {
1106
+ nextBtn.disabled = true;
1107
+ nextBtn.textContent = 'Setting up…';
1108
+ try {
1109
+ const res = await fetch('/api/setup', {
1110
+ method: 'POST',
1111
+ headers: { 'Content-Type': 'application/json' },
1112
+ body: JSON.stringify(wizardState.data),
1113
+ });
1114
+ if (!res.ok) {
1115
+ const err = await res.json().catch(() => ({}));
1116
+ showToast(err.error || 'Setup failed. Please try again.', 'error');
1117
+ nextBtn.disabled = false;
1118
+ nextBtn.textContent = 'Finish setup';
1119
+ return;
1120
+ }
1121
+ } catch (e) {
1122
+ showToast('Setup failed. Please check your connection.', 'error');
1123
+ nextBtn.disabled = false;
1124
+ nextBtn.textContent = 'Finish setup';
1125
+ return;
1126
+ }
1127
+ wizardState.step = 5;
1128
+ renderWizardStep();
1129
+ return;
1130
+ }
1131
+
1132
+ if (step === 5) {
1133
+ hideWizard();
1134
+ await initDashboard();
1135
+ return;
1136
+ }
1137
+
1138
+ wizardState.step++;
1139
+ renderWizardStep();
1140
+ }
1141
+
1142
+ function wizardBack() {
1143
+ if (wizardState.step > 0) {
1144
+ wizardState.step--;
1145
+ renderWizardStep();
1146
+ }
1147
+ }
1148
+
1149
+ function setupWizardListeners() {
1150
+ const nextBtn = $('#wizard-next');
1151
+ const backBtn = $('#wizard-back');
1152
+ if (nextBtn) nextBtn.addEventListener('click', wizardNext);
1153
+ if (backBtn) backBtn.addEventListener('click', wizardBack);
1154
+ }
1155
+
1156
+ // ─── Settings ─────────────────────────────────────────────────────────────────
1157
+ async function loadSettings() {
1158
+ const container = $('#settings-container');
1159
+ if (!container) return;
1160
+ container.innerHTML = `<div class="loading-state"><div class="loading-spinner"></div><div class="loading-text">Loading settings…</div></div>`;
1161
+
1162
+ try {
1163
+ const res = await fetch('/api/settings');
1164
+ if (!res.ok) throw new Error('Failed');
1165
+ const data = await res.json();
1166
+ state.settings = data.settings;
1167
+ renderSettings();
1168
+ } catch (e) {
1169
+ console.error('loadSettings:', e);
1170
+ container.innerHTML = `<div class="empty-state"><div class="empty-icon"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></div><div class="empty-title">Settings unavailable</div><div class="empty-subtitle">Could not load configuration.</div></div>`;
1171
+ }
1172
+ }
1173
+
1174
+ function renderSettings() {
1175
+ const container = $('#settings-container');
1176
+ if (!container) return;
1177
+
1178
+ const s = state.settings;
1179
+ if (!s) {
1180
+ container.innerHTML = `<div class="empty-state"><div class="empty-icon"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></div><div class="empty-title">No configuration found</div><div class="empty-subtitle">Run the setup wizard to configure Stenotype.</div></div>`;
1181
+ return;
1182
+ }
1183
+
1184
+ container.innerHTML = `
1185
+ <div class="settings-section">
1186
+ <div class="settings-section-title">Extraction</div>
1187
+ <div class="settings-card">
1188
+ <div class="settings-field">
1189
+ <label class="settings-label">Gemini API key</label>
1190
+ <div class="settings-current-key">${escapeHtml(s.geminiApiKeyMasked || '—')}</div>
1191
+ <input type="password" id="settings-new-key" class="wizard-input" placeholder="Enter new key to change…" autocomplete="new-password" />
1192
+ <div class="wizard-hint" style="margin-top:6px">Leave blank to keep existing key.</div>
1193
+ </div>
1194
+ <div class="settings-field">
1195
+ <label class="settings-label">Extraction model</label>
1196
+ <div class="settings-value-text">${escapeHtml(s.extractionModel || 'gemini-2.5-flash-lite')}</div>
1197
+ </div>
1198
+ </div>
1199
+ </div>
1200
+
1201
+ <div class="settings-section">
1202
+ <div class="settings-section-title">Platform</div>
1203
+ <div class="settings-card">
1204
+ <div class="settings-field">
1205
+ <label class="settings-label">Agent platform</label>
1206
+ <select id="settings-platform" class="settings-select">
1207
+ <option value="ductor" ${s.systemIntegration === 'ductor' ? 'selected' : ''}>Ductor</option>
1208
+ <option value="openclaw" disabled>OpenClaw (coming soon)</option>
1209
+ <option value="hermes" disabled>Hermes (coming soon)</option>
1210
+ <option value="claudecode" disabled>Claude Code (coming soon)</option>
1211
+ </select>
1212
+ </div>
1213
+ </div>
1214
+ </div>
1215
+
1216
+ <div class="settings-section">
1217
+ <div class="settings-section-title">Embeddings</div>
1218
+ <div class="settings-card">
1219
+ <div class="settings-field">
1220
+ <label class="settings-label">Embedding provider</label>
1221
+ <select id="settings-embedding" class="settings-select">
1222
+ <option value="local" ${s.embeddingProvider === 'local' ? 'selected' : ''}>Local (Qwen3-Embedding-0.6B)</option>
1223
+ <option value="none" ${s.embeddingProvider !== 'local' ? 'selected' : ''}>None (keyword search only)</option>
1224
+ </select>
1225
+ <div class="wizard-hint" style="margin-top:8px">Changing embedding provider takes effect on next Stenotype restart.</div>
1226
+ </div>
1227
+ </div>
1228
+ </div>
1229
+
1230
+ <div style="margin-top:8px">
1231
+ <button class="btn-wizard-next" id="settings-save-btn" style="padding:10px 32px">Save settings</button>
1232
+ </div>
1233
+ `;
1234
+
1235
+ const saveBtn = $('#settings-save-btn');
1236
+ if (saveBtn) saveBtn.addEventListener('click', saveSettings);
1237
+ }
1238
+
1239
+ async function saveSettings() {
1240
+ const saveBtn = $('#settings-save-btn');
1241
+ const newKey = ($('#settings-new-key')?.value || '').trim();
1242
+ const platform = $('#settings-platform')?.value || 'ductor';
1243
+ const embedding = $('#settings-embedding')?.value || 'none';
1244
+
1245
+ if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = 'Saving…'; }
1246
+
1247
+ try {
1248
+ const body = { systemIntegration: platform, embeddingProvider: embedding };
1249
+ if (newKey && newKey.length >= 10) body.geminiApiKey = newKey;
1250
+
1251
+ const res = await fetch('/api/settings', {
1252
+ method: 'PUT',
1253
+ headers: { 'Content-Type': 'application/json' },
1254
+ body: JSON.stringify(body),
1255
+ });
1256
+
1257
+ if (!res.ok) throw new Error('Save failed');
1258
+ showToast('Settings saved.', 'success');
1259
+
1260
+ const keyInput = $('#settings-new-key');
1261
+ if (keyInput) keyInput.value = '';
1262
+
1263
+ await loadSettings();
1264
+ } catch (e) {
1265
+ showToast('Failed to save settings.', 'error');
1266
+ } finally {
1267
+ if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Save settings'; }
1268
+ }
1269
+ }
1270
+
1271
+ // ─── Dashboard init (runs after wizard completes or if already set up) ────────
1272
+ async function initDashboard() {
1273
+ setupFilters();
1274
+ setupEventListeners();
1275
+
1276
+ await Promise.all([
1277
+ loadStats(),
1278
+ fetchMemories({ page: 1, append: false }),
1279
+ ]);
1280
+
1281
+ fetchPinned();
1282
+ fetchCommitments();
1283
+ }
1284
+
1285
+ // ─── Init ─────────────────────────────────────────────────────────────────────
1286
+ async function init() {
1287
+ const authed = await checkAuth();
1288
+ if (!authed) return;
1289
+
1290
+ setupWizardListeners();
1291
+
1292
+ const setupNeeded = await checkSetupStatus();
1293
+ if (setupNeeded) {
1294
+ showWizard();
1295
+ return;
1296
+ }
1297
+
1298
+ await initDashboard();
1299
+ }
1300
+
1301
+ init();