kimi-code-memory-mcp-server 0.1.2 → 0.2.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,395 +1,1468 @@
1
- (() => {
2
- const state = {
3
- workspace: null,
4
- themes: [],
5
- decisions: [],
6
- memories: null,
7
- activeMemoryPath: null,
8
- };
9
-
10
- const $ = (sel) => document.querySelector(sel);
11
- const $$ = (sel) => Array.from(document.querySelectorAll(sel));
12
-
13
- function formatDate(iso) {
14
- if (!iso) return '–';
15
- const d = new Date(iso);
16
- return isNaN(d.getTime()) ? iso : d.toLocaleString();
17
- }
18
-
19
- function escapeHtml(str) {
20
- return String(str)
21
- .replace(/&/g, '&')
22
- .replace(/</g, '&lt;')
23
- .replace(/>/g, '&gt;')
24
- .replace(/"/g, '&quot;');
25
- }
26
-
27
- async function api(path, options = {}) {
28
- const res = await fetch(path, {
29
- headers: { 'Content-Type': 'application/json' },
30
- ...options,
31
- });
32
- if (!res.ok) {
33
- const text = await res.text().catch(() => '');
34
- throw new Error(`${res.status}: ${text}`);
35
- }
36
- return res.json();
1
+ import { state, LS_KEY_AUTO_VIS } from './state.js';
2
+ import {
3
+ formatDate,
4
+ escapeHtml,
5
+ iconKimiMemory,
6
+ iconPanelLeftClose,
7
+ iconPanelLeftOpen,
8
+ setStatus,
9
+ getInitialCollapsed,
10
+ updateCollapsedClass,
11
+ toggleSidebar,
12
+ toggleMobileSidebar,
13
+ sectionFor,
14
+ workspaceFolderName,
15
+ updateDocumentTitle,
16
+ } from './utils/helpers.js';
17
+ import { renderMarkdown } from './utils/markdown.js';
18
+ import {
19
+ api,
20
+ listMemoryFolders,
21
+ writeMemory,
22
+ deleteMemoryFile,
23
+ createMemoryFolder,
24
+ renameMemoryFolder,
25
+ deleteMemoryFolder,
26
+ deleteThemeApi,
27
+ deleteSearchViewApi,
28
+ } from './api.js';
29
+
30
+ const sections = [
31
+ { id: 'workspace', label: 'Workspace', icon: '◈' },
32
+ { id: 'themes', label: 'Themes', icon: '◉' },
33
+ { id: 'searches', label: 'Searches', icon: '🔍' },
34
+ { id: 'decisions', label: 'Decisions', icon: '◆' },
35
+ { id: 'memories', label: 'Memories', icon: '▣' },
36
+ { id: 'settings', label: 'Settings', icon: '⚙' },
37
+ ];
38
+
39
+ const navGroups = [
40
+ {
41
+ label: 'Workspace',
42
+ items: [{ id: 'workspace', label: 'Workspace', icon: '◈' }],
43
+ },
44
+ {
45
+ label: 'Analysis',
46
+ items: [
47
+ { id: 'themes', label: 'Themes', icon: '◉' },
48
+ { id: 'searches', label: 'Searches', icon: '🔍' },
49
+ ],
50
+ },
51
+ {
52
+ label: 'Memory',
53
+ items: [
54
+ { id: 'decisions', label: 'Decisions', icon: '◆' },
55
+ { id: 'memories', label: 'Memories', icon: '▣' },
56
+ ],
57
+ },
58
+ {
59
+ label: 'System',
60
+ items: [{ id: 'settings', label: 'Settings', icon: '⚙' }],
61
+ },
62
+ ];
63
+
64
+ const $ = (sel) => document.querySelector(sel);
65
+ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
66
+
67
+ function setHash(view, theme) {
68
+ const hash = theme ? `#${view}/${encodeURIComponent(theme)}` : `#${view}`;
69
+ if (location.hash !== hash) {
70
+ history.pushState(null, '', hash);
37
71
  }
72
+ }
38
73
 
39
- function setStatus(el, message, type = 'success') {
40
- el.textContent = message;
41
- el.className = `status ${type}`;
42
- if (message) {
43
- setTimeout(() => {
44
- el.textContent = '';
45
- el.className = 'status';
46
- }, 3000);
47
- }
74
+ function parseHash() {
75
+ const raw = location.hash.replace(/^#/, '');
76
+ if (!raw) return { view: 'workspace', theme: null };
77
+ if (raw.startsWith('themes/')) {
78
+ return { view: 'theme-detail', theme: decodeURIComponent(raw.slice(7)) };
79
+ }
80
+ if (raw.startsWith('searches/')) {
81
+ return { view: 'search-detail', key: decodeURIComponent(raw.slice(9)) };
48
82
  }
83
+ const known = ['workspace', 'themes', 'searches', 'decisions', 'memories', 'settings', 'theme-detail'];
84
+ if (known.includes(raw)) return { view: raw, theme: null };
85
+ return { view: 'workspace', theme: null };
86
+ }
49
87
 
50
- function showView(name) {
51
- $$('.view').forEach((v) => v.classList.remove('view-active'));
52
- $(`#view-${name}`).classList.add('view-active');
53
- $$('.nav-item').forEach((n) => n.classList.toggle('active', n.dataset.view === name));
54
- $('#sidebar')?.classList.remove('open');
88
+ function applyHash() {
89
+ const { view, theme, key } = parseHash();
90
+ state.currentView = view;
91
+ const newKey = key || null;
92
+ if (view === 'search-detail' && newKey !== state.currentSearchKey) {
93
+ state.data.searchDetail = null;
55
94
  }
95
+ state.currentSearchKey = newKey;
96
+ if (view === 'theme-detail') {
97
+ if (theme !== state.currentTheme) {
98
+ state.data.themeDetail = null;
99
+ }
100
+ state.currentTheme = theme;
101
+ } else {
102
+ state.currentTheme = theme;
103
+ }
104
+ state.sidebarOpenMobile = false;
105
+ $('#sidebar').classList.remove('open');
106
+ renderAll();
107
+ loadDataForView(view, theme, key);
108
+ }
109
+
110
+ function renderAll() {
111
+ renderSidebarCollapsedTop();
112
+ renderSidebarHeader();
113
+ renderSidebar();
114
+ renderTopbar();
115
+ renderContent();
116
+ }
117
+
118
+ function renderSidebarCollapsedTop() {
119
+ const expandIcon = iconPanelLeftOpen();
120
+ $('#sidebarCollapsedTop').innerHTML = `
121
+ <button class="sidebar-toggle-btn" id="sidebarExpandBtn" type="button" aria-label="Expand sidebar">
122
+ ${expandIcon}
123
+ </button>
124
+ `;
125
+ $('#sidebarExpandBtn')?.addEventListener('click', toggleSidebar);
126
+ }
127
+
128
+ function renderSidebarHeader() {
129
+ const toggleIcon = state.sidebarCollapsed ? iconPanelLeftOpen() : iconPanelLeftClose();
130
+ $('#sidebarHeader').innerHTML = `
131
+ <a class="brand" href="#workspace" title="Kimi Memory">
132
+ ${iconKimiMemory()}
133
+ <span>Kimi Memory</span>
134
+ </a>
135
+ <button class="sidebar-toggle-btn" id="sidebarToggleBtn" type="button" aria-label="Toggle sidebar">
136
+ ${toggleIcon}
137
+ </button>
138
+ `;
139
+
140
+ $('#sidebarToggleBtn').addEventListener('click', toggleSidebar);
141
+ }
142
+
143
+ function renderSidebar() {
144
+ const currentSection = sectionFor(state.currentView);
145
+ const currentView = state.currentView;
56
146
 
57
- function renderStats(stats) {
58
- const grid = $('#stats-grid');
59
- grid.innerHTML = Object.entries(stats)
60
- .map(
61
- ([key, value]) => `
62
- <div class="stat-card">
63
- <div class="stat-value">${escapeHtml(String(value))}</div>
64
- <div class="stat-label">${escapeHtml(key.replace(/([A-Z])/g, ' $1').toLowerCase())}</div>
147
+ $('#sidebarNav').innerHTML = navGroups
148
+ .map((group) => {
149
+ const itemsHtml = group.items
150
+ .map(
151
+ (item) => `
152
+ <a class="nav-item ${item.id === currentView ? 'active' : ''}" href="#${item.id}" data-view="${escapeHtml(
153
+ item.id,
154
+ )}">
155
+ <span class="nav-icon">${escapeHtml(item.icon)}</span>
156
+ <span>${escapeHtml(item.label)}</span>
157
+ </a>
158
+ `,
159
+ )
160
+ .join('');
161
+ const isGroupActive = group.items.some((item) => item.id === currentSection || item.id === currentView);
162
+ return `
163
+ <div class="nav-group" style="${isGroupActive ? '' : 'opacity:0.7'}">
164
+ <div class="nav-group-label">${escapeHtml(group.label)}</div>
165
+ ${itemsHtml}
65
166
  </div>
66
- `,
67
- )
68
- .join('');
167
+ `;
168
+ })
169
+ .join('');
170
+
171
+ $('#sidebarNav').querySelectorAll('.nav-item').forEach((el) => {
172
+ el.addEventListener('click', (e) => {
173
+ e.preventDefault();
174
+ const view = el.dataset.view;
175
+ setHash(view);
176
+ applyHash();
177
+ });
178
+ });
179
+
180
+ const workspaceId = state.data.workspace?.id || '–';
181
+ $('#sidebarFooter').textContent = workspaceId;
182
+ }
183
+
184
+ function renderBreadcrumb() {
185
+ const parts = [{ label: workspaceFolderName(), hash: '#workspace' }];
186
+ if (state.currentView === 'themes' || state.currentView === 'theme-detail') {
187
+ parts.push({ label: 'Themes', hash: '#themes' });
69
188
  }
70
189
 
71
- async function loadWorkspace() {
72
- const data = await api('/api/workspace');
73
- state.workspace = data;
74
- $('#workspace-id').textContent = data.id;
75
- renderStats(data.stats);
76
- $('#essence-editor').value = data.essence;
190
+ if (state.currentView === 'theme-detail' && state.currentTheme) {
191
+ const displayName = state.data.themeDetail?.displayName || state.currentTheme;
192
+ parts.push({ label: displayName, hash: null });
193
+ } else if (state.currentView === 'decisions') {
194
+ parts.push({ label: 'Decisions', hash: null });
195
+ } else if (state.currentView === 'memories') {
196
+ parts.push({ label: 'Memories', hash: null });
197
+ } else if (state.currentView === 'settings') {
198
+ parts.push({ label: 'Settings', hash: null });
199
+ } else if (state.currentView === 'themes') {
200
+ // list already handled
77
201
  }
78
202
 
79
- async function saveEssence() {
80
- const content = $('#essence-editor').value;
81
- const status = $('#essence-status');
82
- try {
83
- await api('/api/essence', {
84
- method: 'POST',
85
- body: JSON.stringify({ content }),
86
- });
87
- setStatus(status, 'Essence saved.', 'success');
88
- } catch (err) {
89
- setStatus(status, `Save failed: ${err.message}`, 'error');
90
- }
203
+ return parts
204
+ .map((p, idx) => {
205
+ const isLast = idx === parts.length - 1;
206
+ const content = p.hash && !isLast
207
+ ? `<a href="${p.hash}">${escapeHtml(p.label)}</a>`
208
+ : `<span>${escapeHtml(p.label)}</span>`;
209
+ const sep = idx > 0 ? `<span class="sep">/</span>` : '';
210
+ return `${sep}${content}`;
211
+ })
212
+ .join('');
213
+ }
214
+
215
+ function renderTopbar() {
216
+ $('#topbar').innerHTML = `
217
+ <div class="topbar-left">
218
+ <button class="menu-toggle" id="menuToggle" type="button" aria-label="Toggle menu">☰</button>
219
+ <nav class="breadcrumb" id="breadcrumb">${renderBreadcrumb()}</nav>
220
+ </div>
221
+ <div class="topbar-right">
222
+ <button class="btn btn-secondary btn-sm" id="syncTopbarBtn" type="button" title="Reconcile index.json with filesystem">Sync index</button>
223
+ <button class="btn btn-secondary btn-sm" id="refreshBtn" type="button" title="Refresh current view">↻ Refresh</button>
224
+ <span class="status-badge"><span class="status-dot"></span>Online</span>
225
+ </div>
226
+ `;
227
+
228
+ $('#menuToggle').addEventListener('click', toggleMobileSidebar);
229
+ $('#syncTopbarBtn').addEventListener('click', syncIndex);
230
+ $('#refreshBtn').addEventListener('click', () => loadDataForView(state.currentView, state.currentTheme));
231
+ $('#breadcrumb').querySelectorAll('a').forEach((a) => {
232
+ a.addEventListener('click', (e) => {
233
+ e.preventDefault();
234
+ const href = a.getAttribute('href');
235
+ if (href) {
236
+ location.hash = href;
237
+ applyHash();
238
+ }
239
+ });
240
+ });
241
+ }
242
+
243
+ function renderContent() {
244
+ const content = $('#content');
245
+ switch (state.currentView) {
246
+ case 'workspace':
247
+ content.innerHTML = renderWorkspaceView();
248
+ bindWorkspaceView();
249
+ break;
250
+ case 'themes':
251
+ content.innerHTML = renderThemesView();
252
+ bindThemesView();
253
+ break;
254
+ case 'theme-detail':
255
+ content.innerHTML = renderThemeDetailView();
256
+ bindThemeDetailView();
257
+ break;
258
+ case 'decisions':
259
+ content.innerHTML = renderDecisionsView();
260
+ bindDecisionsView();
261
+ break;
262
+ case 'memories':
263
+ content.innerHTML = renderMemoriesView();
264
+ bindMemoriesView();
265
+ break;
266
+ case 'searches':
267
+ content.innerHTML = renderSearchesView();
268
+ bindSearchesView();
269
+ break;
270
+ case 'search-detail':
271
+ content.innerHTML = renderSearchDetailView();
272
+ bindSearchDetailView();
273
+ break;
274
+ case 'settings':
275
+ content.innerHTML = renderSettingsView();
276
+ bindSettingsView();
277
+ break;
278
+ default:
279
+ content.innerHTML = renderWorkspaceView();
280
+ bindWorkspaceView();
91
281
  }
282
+ }
92
283
 
93
- async function syncIndex() {
94
- const btn = $('#sync-btn');
95
- const original = btn.textContent;
96
- btn.textContent = 'Syncing…';
97
- try {
98
- await api('/api/sync', { method: 'POST' });
99
- await loadWorkspace();
100
- btn.textContent = 'Synced';
101
- } catch (err) {
102
- btn.textContent = `Failed: ${err.message}`;
103
- }
104
- setTimeout(() => (btn.textContent = original), 1500);
284
+ // ---- Workspace view ----
285
+
286
+ function renderWorkspaceView() {
287
+ const editing = state.editingEssence;
288
+ const essence = state.data.workspace?.essence || '';
289
+ const bodyHtml = editing
290
+ ? `<textarea class="essence-editor" id="essenceEditor" placeholder="Workspace essence is empty. Write a short constitution here…">${escapeHtml(
291
+ essence,
292
+ )}</textarea>`
293
+ : `<div class="essence-content md-content" id="essenceContent">${
294
+ essence ? renderMarkdown(essence) : '<span class="muted">No essence yet. Click Edit to write one.</span>'
295
+ }</div>`;
296
+ const actionsHtml = editing
297
+ ? `
298
+ <button class="btn btn-primary btn-sm" id="saveEssenceBtn" type="button">Save</button>
299
+ <button class="btn btn-secondary btn-sm" id="cancelEditEssenceBtn" type="button">Cancel</button>
300
+ `
301
+ : `<button class="btn btn-secondary btn-sm" id="editEssenceBtn" type="button">Edit</button>`;
302
+ return `
303
+ <section class="view view-active" data-view="workspace">
304
+ <div class="page-header">
305
+ <h1 class="page-title">Workspace</h1>
306
+ </div>
307
+ <div class="stat-grid" id="statsGrid"></div>
308
+ <div class="composer-card">
309
+ <div class="composer-header">
310
+ <h2 class="composer-title">Workspace essence</h2>
311
+ <div class="page-header-actions">${actionsHtml}</div>
312
+ </div>
313
+ <div class="composer-body">
314
+ ${bodyHtml}
315
+ </div>
316
+ <div class="composer-status" id="essenceStatus"></div>
317
+ </div>
318
+ </section>
319
+ `;
320
+ }
321
+
322
+ function bindWorkspaceView() {
323
+ renderStats(state.data.workspace?.stats || {});
324
+ $('#editEssenceBtn')?.addEventListener('click', startEditEssence);
325
+ $('#saveEssenceBtn')?.addEventListener('click', saveEssence);
326
+ $('#cancelEditEssenceBtn')?.addEventListener('click', cancelEditEssence);
327
+ }
328
+
329
+ function startEditEssence() {
330
+ state.editingEssence = true;
331
+ renderContent();
332
+ }
333
+
334
+ function cancelEditEssence() {
335
+ state.editingEssence = false;
336
+ renderContent();
337
+ }
338
+
339
+ function renderStats(stats) {
340
+ const grid = $('#statsGrid');
341
+ if (!grid) return;
342
+ const entries = Object.entries(stats);
343
+ if (entries.length === 0) {
344
+ grid.innerHTML = '<div class="empty-state">Loading stats…</div>';
345
+ return;
105
346
  }
347
+ grid.innerHTML = entries
348
+ .map(
349
+ ([key, value]) => `
350
+ <div class="stat-card">
351
+ <div class="stat-value">${escapeHtml(String(value))}</div>
352
+ <div class="stat-label">${escapeHtml(key.replace(/([A-Z])/g, ' $1').toLowerCase())}</div>
353
+ </div>
354
+ `,
355
+ )
356
+ .join('');
357
+ }
106
358
 
107
- function renderThemes() {
108
- const grid = $('#themes-grid');
109
- if (state.themes.length === 0) {
110
- grid.innerHTML = '<div class="empty-state">No themes yet.</div>';
111
- return;
359
+ async function loadWorkspace() {
360
+ const data = await api('/api/workspace');
361
+ state.data.workspace = data;
362
+ updateDocumentTitle();
363
+ renderSidebar();
364
+ if (state.currentView === 'workspace') {
365
+ renderStats(data.stats);
366
+ const contentEl = $('#essenceContent');
367
+ if (contentEl && !state.editingEssence) {
368
+ contentEl.innerHTML = data.essence ? renderMarkdown(data.essence) : '<span class="muted">No essence yet. Click Edit to write one.</span>';
112
369
  }
113
- grid.innerHTML = state.themes
114
- .map(
115
- (theme) => `
116
- <div class="theme-card" data-theme="${escapeHtml(theme.name)}">
117
- <div class="theme-name">${escapeHtml(theme.displayName || theme.name)}</div>
118
- <div class="theme-meta">
119
- <span>${theme.turnCount} turns</span>
120
- <span>${theme.memoryCount} memories</span>
121
- </div>
122
- </div>
123
- `,
124
- )
125
- .join('');
370
+ }
371
+ if (state.currentView === 'settings') {
372
+ updateSettingsView();
373
+ }
374
+ }
126
375
 
127
- grid.querySelectorAll('.theme-card').forEach((card) => {
128
- card.addEventListener('click', () => openThemeModal(card.dataset.theme));
376
+ async function saveEssence() {
377
+ const content = $('#essenceEditor').value;
378
+ const status = $('#essenceStatus');
379
+ try {
380
+ await api('/api/essence', {
381
+ method: 'POST',
382
+ body: JSON.stringify({ content }),
129
383
  });
384
+ if (state.data.workspace) state.data.workspace.essence = content;
385
+ state.editingEssence = false;
386
+ renderContent();
387
+ setStatus(status, 'Essence saved.', 'success');
388
+ } catch (err) {
389
+ setStatus(status, `Save failed: ${err.message}`, 'error');
130
390
  }
391
+ }
131
392
 
132
- async function loadThemes() {
133
- state.themes = await api('/api/themes');
134
- renderThemes();
393
+ async function syncIndex() {
394
+ const btn = $('#syncTopbarBtn');
395
+ if (!btn) return;
396
+ const original = btn.textContent;
397
+ btn.textContent = 'Syncing…';
398
+ btn.disabled = true;
399
+ try {
400
+ await api('/api/sync', { method: 'POST' });
401
+ await loadWorkspace();
402
+ btn.textContent = 'Synced';
403
+ } catch (err) {
404
+ btn.textContent = `Failed: ${err.message}`;
135
405
  }
406
+ setTimeout(() => {
407
+ btn.textContent = original;
408
+ btn.disabled = false;
409
+ }, 1500);
410
+ }
136
411
 
137
- async function openThemeModal(themeName) {
138
- const data = await api(`/api/themes/${encodeURIComponent(themeName)}`);
139
- $('#modal-title').textContent = data.displayName || data.theme;
140
- const body = $('#modal-body');
412
+ // ---- Themes view ----
141
413
 
142
- body.innerHTML = `
143
- <div class="inline-edit">
144
- <input id="theme-display-name" type="text" value="${escapeHtml(
145
- data.displayName || data.theme,
146
- )}" placeholder="Display name" />
147
- <button id="theme-save-name" class="btn btn-primary btn-sm" type="button">Rename</button>
414
+ function renderThemesView() {
415
+ return `
416
+ <section class="view view-active" data-view="themes">
417
+ <div class="page-header">
418
+ <h1 class="page-title">Themes</h1>
419
+ <span class="badge" id="themeCountBadge">0 themes</span>
420
+ </div>
421
+ <div class="search-table-wrap">
422
+ <table class="search-table" id="themesTable">
423
+ <thead>
424
+ <tr>
425
+ <th>Theme</th>
426
+ <th>Created</th>
427
+ <th>Updated</th>
428
+ <th class="search-number">Turns</th>
429
+ <th class="search-number">Memories</th>
430
+ <th class="search-actions">Actions</th>
431
+ </tr>
432
+ </thead>
433
+ <tbody id="themesTableBody"></tbody>
434
+ </table>
148
435
  </div>
149
- <div class="timeline" id="theme-timeline"></div>
436
+ </section>
437
+ `;
438
+ }
439
+
440
+ function bindThemesView() {
441
+ renderThemes();
442
+ }
443
+
444
+ function renderThemes() {
445
+ const tbody = $('#themesTableBody');
446
+ const badge = $('#themeCountBadge');
447
+ if (!tbody) return;
448
+ badge.textContent = `${state.data.themes.length} theme${state.data.themes.length === 1 ? '' : 's'}`;
449
+ if (state.data.themes.length === 0) {
450
+ tbody.innerHTML = '<tr><td colspan="6" class="empty-state">No themes yet.</td></tr>';
451
+ return;
452
+ }
453
+ tbody.innerHTML = state.data.themes
454
+ .map(
455
+ (theme) => `
456
+ <tr class="search-row theme-row" data-theme="${escapeHtml(theme.name)}">
457
+ <td class="search-query">
458
+ <span class="theme-icon">◆</span>
459
+ ${escapeHtml(theme.displayName || theme.name)}
460
+ </td>
461
+ <td>${escapeHtml(theme.createdAt ? new Date(theme.createdAt).toLocaleString() : '-')}</td>
462
+ <td>${escapeHtml(theme.updatedAt ? new Date(theme.updatedAt).toLocaleString() : '-')}</td>
463
+ <td class="search-number">${theme.turnCount ?? 0}</td>
464
+ <td class="search-number">${theme.memoryCount ?? 0}</td>
465
+ <td class="search-actions">
466
+ <button class="btn btn-danger btn-sm" data-action="delete-theme" data-theme="${escapeHtml(
467
+ theme.name,
468
+ )}" type="button">Delete</button>
469
+ </td>
470
+ </tr>
471
+ `,
472
+ )
473
+ .join('');
474
+ }
475
+
476
+ async function loadThemes() {
477
+ state.data.themes = await api('/api/themes');
478
+ if (state.currentView === 'themes') renderThemes();
479
+ }
480
+
481
+ function renderThemeDetailView() {
482
+ const detail = state.data.themeDetail;
483
+ const title = detail?.displayName || state.currentTheme || 'Theme';
484
+ if (!detail) {
485
+ return `
486
+ <section class="view view-active" data-view="theme-detail">
487
+ <div class="page-header">
488
+ <h1 class="page-title">${escapeHtml(title)}</h1>
489
+ </div>
490
+ <div class="empty-state">Loading theme…</div>
491
+ </section>
150
492
  `;
493
+ }
494
+ return `
495
+ <section class="view view-active" data-view="theme-detail">
496
+ <div class="page-header">
497
+ <div>
498
+ <h1 class="page-title">${escapeHtml(title)}</h1>
499
+ <div class="muted">${escapeHtml(state.currentTheme || '')}</div>
500
+ </div>
501
+ <div class="inline-edit">
502
+ <input id="themeDisplayName" type="text" value="${escapeHtml(title)}" placeholder="Display name" />
503
+ <button id="renameThemeBtn" class="btn btn-primary btn-sm" type="button">Rename</button>
504
+ <div class="page-header-actions">
505
+ <button class="btn btn-secondary btn-sm" id="backToThemesBtn" type="button">Back</button>
506
+ <button class="btn btn-danger btn-sm" id="deleteThemeBtn" type="button">Delete</button>
507
+ </div>
508
+ </div>
509
+ </div>
510
+ <div class="timeline" id="themeTimeline"></div>
511
+ </section>
512
+ `;
513
+ }
151
514
 
152
- const timeline = $('#theme-timeline');
153
- if (data.items.length === 0) {
154
- timeline.innerHTML = '<div class="empty-state">No turns or memories linked to this theme.</div>';
155
- } else {
156
- timeline.innerHTML = data.items
157
- .map((item) => {
158
- if (item.type === 'turn') {
159
- const turn = item.data;
160
- const bullets =
161
- Array.isArray(turn.facts) && turn.facts.length
162
- ? `<ul class="decision-bullets">${turn.facts
163
- .map((f) => `<li>${escapeHtml(f)}</li>`)
164
- .join('')}</ul>`
165
- : '';
166
- const tags = [
167
- ...(turn.entities?.files || []),
168
- ...(Object.values(turn.categories || {}).flat()),
169
- ];
170
- return `
171
- <div class="timeline-item">
172
- <div class="timeline-dot"></div>
173
- <div class="timeline-time">${formatDate(turn.timestamp)} · Turn ${turn.turnId}</div>
174
- <div class="timeline-card">
175
- <h4>${escapeHtml(turn.summary || 'Untitled turn')}</h4>
176
- ${bullets}
177
- <div class="tag-list">
178
- ${tags
179
- .map((t) => `<span class="tag${t.includes('.') ? ' file' : ''}">${escapeHtml(t)}</span>`)
180
- .join('')}
181
- </div>
182
- </div>
183
- </div>
184
- `;
185
- }
186
- const memory = item.data;
187
- return `
188
- <div class="timeline-item">
189
- <div class="timeline-dot memory"></div>
190
- <div class="timeline-time">${formatDate(memory.timestamp)} · Memory</div>
191
- <div class="timeline-card">
192
- <h4>${escapeHtml(memory.title || memory.key)}</h4>
193
- <p>${escapeHtml(memory.content.slice(0, 240))}${memory.content.length > 240 ? '…' : ''}</p>
194
- <div class="timeline-meta">${escapeHtml(memory.folder)}/${escapeHtml(memory.key)}</div>
515
+ function bindThemeDetailView() {
516
+ renderThemeTimeline();
517
+ $('#renameThemeBtn')?.addEventListener('click', renameTheme);
518
+ $('#deleteThemeBtn')?.addEventListener('click', () => deleteTheme(state.currentTheme));
519
+ $('#backToThemesBtn')?.addEventListener('click', () => {
520
+ setHash('themes');
521
+ applyHash();
522
+ });
523
+ }
524
+
525
+ function renderThemeTimeline() {
526
+ const timeline = $('#themeTimeline');
527
+ if (!timeline) return;
528
+ const detail = state.data.themeDetail;
529
+ if (!detail || !detail.items || detail.items.length === 0) {
530
+ timeline.innerHTML = '<div class="empty-state">No turns or memories linked to this theme.</div>';
531
+ return;
532
+ }
533
+
534
+ timeline.innerHTML = detail.items
535
+ .map((item) => {
536
+ if (item.type === 'turn') {
537
+ const turn = item.data;
538
+ const bullets =
539
+ Array.isArray(turn.facts) && turn.facts.length
540
+ ? `<ul class="decision-bullets">${turn.facts.map((f) => `<li>${escapeHtml(f)}</li>`).join('')}</ul>`
541
+ : '';
542
+ const tags = [...(turn.entities?.files || []), ...(Object.values(turn.categories || {}).flat())];
543
+ return `
544
+ <div class="timeline-item hit" data-session="${escapeHtml(turn.sessionId)}" data-turn="${turn.turnId}">
545
+ <div class="timeline-dot"></div>
546
+ <div class="timeline-card">
547
+ <div class="timeline-meta">${escapeHtml(turn.sessionId)} · turn ${turn.turnId}</div>
548
+ <h4>${escapeHtml(turn.summary || 'Untitled turn')}</h4>
549
+ ${bullets}
550
+ <div class="tag-list">
551
+ ${tags
552
+ .map((t) => `<span class="tag${t.includes('.') ? ' file' : ''}">${escapeHtml(t)}</span>`)
553
+ .join('')}
195
554
  </div>
196
555
  </div>
197
- `;
198
- })
199
- .join('');
200
- }
201
-
202
- $('#theme-save-name').addEventListener('click', async () => {
203
- const displayName = $('#theme-display-name').value;
204
- try {
205
- await api(`/api/themes/${encodeURIComponent(themeName)}`, {
206
- method: 'POST',
207
- body: JSON.stringify({ displayName }),
208
- });
209
- await loadThemes();
210
- $('#modal-title').textContent = displayName;
211
- } catch (err) {
212
- alert(`Rename failed: ${err.message}`);
556
+ </div>
557
+ `;
213
558
  }
559
+ const memory = item.data;
560
+ return `
561
+ <div class="timeline-item memory">
562
+ <div class="timeline-header">
563
+ <span class="badge">Memory</span>
564
+ <span class="timeline-time">${formatDate(memory.timestamp)}</span>
565
+ </div>
566
+ <div class="timeline-card">
567
+ <h4>${escapeHtml(memory.title || memory.key)}</h4>
568
+ <p>${escapeHtml(memory.content.slice(0, 240))}${memory.content.length > 240 ? '…' : ''}</p>
569
+ <div class="timeline-meta">${escapeHtml(memory.folder)}/${escapeHtml(memory.key)}</div>
570
+ </div>
571
+ </div>
572
+ `;
573
+ })
574
+ .join('');
575
+ }
576
+
577
+ async function loadThemeDetail(themeName) {
578
+ if (!themeName) return;
579
+ const data = await api(`/api/themes/${encodeURIComponent(themeName)}`);
580
+ state.data.themeDetail = data;
581
+ state.currentTheme = themeName;
582
+ if (state.currentView === 'theme-detail') {
583
+ renderContent();
584
+ }
585
+ }
586
+
587
+ async function renameTheme() {
588
+ const themeName = state.currentTheme;
589
+ const displayName = $('#themeDisplayName').value.trim();
590
+ if (!themeName || !displayName) return;
591
+ try {
592
+ await api(`/api/themes/${encodeURIComponent(themeName)}`, {
593
+ method: 'POST',
594
+ body: JSON.stringify({ displayName }),
214
595
  });
596
+ if (state.data.themeDetail) state.data.themeDetail.displayName = displayName;
597
+ await loadThemes();
598
+ renderTopbar();
599
+ } catch (err) {
600
+ alert(`Rename failed: ${err.message}`);
601
+ }
602
+ }
215
603
 
216
- openModal();
604
+ async function deleteTheme(themeName) {
605
+ if (!themeName) return;
606
+ if (!confirm(`Delete theme "${themeName}"?`)) return;
607
+ try {
608
+ await deleteThemeApi(themeName);
609
+ if (state.currentTheme === themeName) {
610
+ setHash('themes');
611
+ applyHash();
612
+ }
613
+ await loadThemes();
614
+ await loadWorkspace();
615
+ } catch (err) {
616
+ alert(`Delete failed: ${err.message}`);
217
617
  }
618
+ }
218
619
 
219
- function renderDecisions() {
220
- const list = $('#decisions-list');
221
- if (state.decisions.length === 0) {
222
- list.innerHTML = '<div class="empty-state">No recent decisions.</div>';
223
- return;
620
+ // ---- Searches view ----
621
+
622
+ async function deleteSearchView(key) {
623
+ if (!key) return;
624
+ if (!confirm(`Delete search view "${key}"?`)) return;
625
+ const deleteRefinedTurns = confirm('Also delete the refined turns referenced by this view?\n\nCancel keeps the refined turns.');
626
+ try {
627
+ await deleteSearchViewApi(key, deleteRefinedTurns);
628
+ if (state.currentSearchKey === key) {
629
+ setHash('searches');
630
+ applyHash();
224
631
  }
225
- list.innerHTML = state.decisions
226
- .map(
227
- (d) => `
228
- <div class="decision-card">
229
- <div class="decision-header">
230
- <h3 class="decision-title">Turn ${d.turnId} · ${escapeHtml(d.sessionId)}</h3>
231
- <span class="decision-time">${formatDate(d.timestamp)}</span>
232
- </div>
233
- <div class="decision-summary">${escapeHtml(d.summary)}</div>
234
- <ul class="decision-bullets">
235
- ${d.decisions.map((dec) => `<li>${escapeHtml(dec)}</li>`).join('')}
236
- </ul>
632
+ await loadSearches();
633
+ await loadWorkspace();
634
+ } catch (err) {
635
+ alert(`Delete failed: ${err.message}`);
636
+ }
637
+ }
638
+
639
+ function renderSearchesView() {
640
+ const searches = state.data.searches || [];
641
+ if (searches.length === 0) {
642
+ return `
643
+ <section class="view view-active" data-view="searches">
644
+ <div class="page-header">
645
+ <h1 class="page-title">Saved Searches</h1>
646
+ </div>
647
+ <div class="empty-state">No saved search views yet.</div>
648
+ </section>
649
+ `;
650
+ }
651
+ const rowsHtml = searches
652
+ .map(
653
+ (s) => `
654
+ <tr class="search-row" data-key="${escapeHtml(s.key)}">
655
+ <td class="search-cell search-query">${escapeHtml(s.query || s.key)}</td>
656
+ <td class="search-cell">${escapeHtml(s.createdAt ? new Date(s.createdAt).toLocaleString() : '-')}</td>
657
+ <td class="search-cell search-number">${s.totalHits ?? s.resultCount ?? 0}</td>
658
+ <td class="search-cell search-number">${s.clusterCount ?? 0}</td>
659
+ <td class="search-actions">
660
+ <button class="btn btn-danger btn-sm" data-action="delete-search" data-key="${escapeHtml(
661
+ s.key,
662
+ )}" type="button">Delete</button>
663
+ </td>
664
+ </tr>
665
+ `,
666
+ )
667
+ .join('');
668
+ return `
669
+ <section class="view view-active" data-view="searches">
670
+ <div class="page-header">
671
+ <h1 class="page-title">Saved Searches</h1>
672
+ </div>
673
+ <div class="search-table-wrap">
674
+ <table class="search-table" id="searchesTable">
675
+ <thead>
676
+ <tr>
677
+ <th>Keywords</th>
678
+ <th>Created</th>
679
+ <th class="search-number">Hits</th>
680
+ <th class="search-number">Clusters</th>
681
+ <th class="search-actions">Actions</th>
682
+ </tr>
683
+ </thead>
684
+ <tbody>${rowsHtml}</tbody>
685
+ </table>
686
+ </div>
687
+ </section>
688
+ `;
689
+ }
690
+
691
+ function bindSearchesView() {
692
+ // Clicks are handled via content event delegation to avoid duplicate listeners.
693
+ }
694
+
695
+ async function loadSearches() {
696
+ try {
697
+ const searches = await api('/api/searches');
698
+ state.data.searches = Array.isArray(searches) ? searches : [];
699
+ } catch (err) {
700
+ state.data.searches = [];
701
+ console.error('loadSearches failed:', err);
702
+ }
703
+ if (state.currentView === 'searches') {
704
+ renderContent();
705
+ bindSearchesView();
706
+ }
707
+ }
708
+
709
+ function renderSearchDetailView() {
710
+ const detail = state.data.searchDetail;
711
+ if (!detail) {
712
+ return `
713
+ <section class="view view-active" data-view="search-detail">
714
+ <div class="page-header">
715
+ <h1 class="page-title">Search Detail</h1>
716
+ </div>
717
+ <div class="empty-state">Loading search view…</div>
718
+ </section>
719
+ `;
720
+ }
721
+ const turnsHtml = (detail.turns || [])
722
+ .map(
723
+ (turn, idx) => `
724
+ <div class="timeline-item ${turn.isHit ? 'hit' : ''}" data-session="${escapeHtml(turn.sessionId)}" data-turn="${turn.turnId}" style="--i:${idx}">
725
+ <div class="timeline-dot"></div>
726
+ <div class="timeline-card">
727
+ <div class="timeline-meta">${escapeHtml(turn.sessionId)} · turn ${turn.turnId}</div>
728
+ <div class="timeline-summary">${escapeHtml(turn.summary || 'No summary')}</div>
729
+ </div>
730
+ </div>
731
+ `,
732
+ )
733
+ .join('');
734
+ return `
735
+ <section class="view view-active" data-view="search-detail">
736
+ <div class="page-header">
737
+ <div>
738
+ <h1 class="page-title">${escapeHtml(detail.query || detail.key)}</h1>
739
+ <div class="muted">${detail.totalHits ?? 0} hits · ${detail.clusterCount ?? 0} clusters · ${escapeHtml(
740
+ detail.createdAt ? new Date(detail.createdAt).toLocaleString() : '',
741
+ )}</div>
742
+ </div>
743
+ <div class="page-header-actions">
744
+ <button class="btn btn-secondary btn-sm" id="backToSearchesBtn" type="button">Back</button>
745
+ <button class="btn btn-danger btn-sm" id="deleteSearchViewBtn" type="button">Delete</button>
746
+ </div>
747
+ </div>
748
+ <div class="timeline" id="searchTimeline">${turnsHtml || '<div class="empty-state">No refined turns.</div>'}</div>
749
+ </section>
750
+ `;
751
+ }
752
+
753
+ function bindSearchDetailView() {
754
+ $('#backToSearchesBtn')?.addEventListener('click', () => {
755
+ setHash('searches');
756
+ applyHash();
757
+ });
758
+ $('#deleteSearchViewBtn')?.addEventListener('click', () => deleteSearchView(state.currentSearchKey));
759
+ // Timeline item clicks are handled via content event delegation.
760
+ }
761
+
762
+ async function loadSearchDetail() {
763
+ const key = state.currentSearchKey;
764
+ if (!key) return;
765
+ try {
766
+ const detail = await api(`/api/searches/${encodeURIComponent(key)}`);
767
+ state.data.searchDetail = detail;
768
+ } catch (err) {
769
+ state.data.searchDetail = null;
770
+ console.error('loadSearchDetail failed:', err);
771
+ }
772
+ if (state.currentView === 'search-detail') {
773
+ renderContent();
774
+ bindSearchDetailView();
775
+ }
776
+ }
777
+
778
+ // ---- Decisions view ----
779
+
780
+ function renderList(items, empty) {
781
+ if (!Array.isArray(items) || items.length === 0) {
782
+ return `<p class="muted">${escapeHtml(empty)}</p>`;
783
+ }
784
+ return `<ul>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>`;
785
+ }
786
+
787
+ async function showRefinedTurnModal(sessionId, turnId) {
788
+ try {
789
+ const turn = await api(`/api/refined-turn/${encodeURIComponent(sessionId)}/${turnId}`);
790
+ const factsHtml = renderList(turn.facts, 'No facts');
791
+ const notesHtml = renderList(turn.notes, 'No notes');
792
+ const modal = document.createElement('div');
793
+ modal.className = 'modal-backdrop';
794
+ modal.innerHTML = `
795
+ <div class="modal">
796
+ <div class="modal-header">
797
+ <h3>${escapeHtml(sessionId)} · turn ${turnId}</h3>
798
+ <button class="modal-close" type="button">×</button>
799
+ </div>
800
+ <div class="modal-body">
801
+ <div class="muted">${escapeHtml(turn.timestamp ? new Date(turn.timestamp).toLocaleString() : '')}</div>
802
+ <h4>Summary</h4>
803
+ <p>${escapeHtml(turn.summary || 'No summary')}</p>
804
+ <h4>Facts</h4>
805
+ ${factsHtml}
806
+ <h4>Notes</h4>
807
+ ${notesHtml}
808
+ </div>
809
+ </div>
810
+ `;
811
+ document.body.appendChild(modal);
812
+ modal.querySelector('.modal-close').addEventListener('click', () => modal.remove());
813
+ modal.addEventListener('click', (e) => {
814
+ if (e.target === modal) modal.remove();
815
+ });
816
+ } catch (err) {
817
+ alert(`Failed to load turn: ${err.message}`);
818
+ }
819
+ }
820
+
821
+ function renderDecisionsView() {
822
+ return `
823
+ <section class="view view-active" data-view="decisions">
824
+ <div class="page-header">
825
+ <h1 class="page-title">Recent Decisions</h1>
826
+ <input type="search" class="search-input" id="decisionsFilter" placeholder="Filter decisions…" value="${escapeHtml(
827
+ state.decisionsFilter,
828
+ )}" />
829
+ </div>
830
+ <div class="decisions-list" id="decisionsList"></div>
831
+ </section>
832
+ `;
833
+ }
834
+
835
+ function bindDecisionsView() {
836
+ $('#decisionsFilter').addEventListener('input', (e) => {
837
+ state.decisionsFilter = e.target.value.toLowerCase();
838
+ renderDecisions();
839
+ });
840
+ renderDecisions();
841
+ }
842
+
843
+ function renderDecisions() {
844
+ const list = $('#decisionsList');
845
+ if (!list) return;
846
+ const filtered = state.data.decisions.filter((d) => {
847
+ if (!state.decisionsFilter) return true;
848
+ const hay = `${d.summary} ${d.decisions.join(' ')} ${d.files.join(' ')} ${d.tags.join(' ')}`.toLowerCase();
849
+ return hay.includes(state.decisionsFilter);
850
+ });
851
+
852
+ if (filtered.length === 0) {
853
+ list.innerHTML = '<div class="empty-state">No matching decisions.</div>';
854
+ return;
855
+ }
856
+
857
+ list.innerHTML = filtered
858
+ .map(
859
+ (d) => `
860
+ <div class="decision-row">
861
+ <div class="decision-row-main">
862
+ <div class="decision-row-title">Turn ${d.turnId} · ${escapeHtml(d.sessionId)}</div>
863
+ <div class="decision-row-summary">${escapeHtml(d.summary)}</div>
237
864
  <div class="tag-list">
865
+ ${d.decisions.map((dec) => `<span class="tag">${escapeHtml(dec)}</span>`).join('')}
238
866
  ${d.files.map((f) => `<span class="tag file">${escapeHtml(f)}</span>`).join('')}
239
867
  ${d.tags.map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join('')}
240
868
  </div>
241
869
  </div>
242
- `,
243
- )
244
- .join('');
870
+ <div class="decision-row-meta">
871
+ <div class="decision-time">${formatDate(d.timestamp)}</div>
872
+ </div>
873
+ </div>
874
+ `,
875
+ )
876
+ .join('');
877
+ }
878
+
879
+ async function loadDecisions() {
880
+ state.data.decisions = await api('/api/decisions?limit=50');
881
+ if (state.currentView === 'decisions') renderDecisions();
882
+ }
883
+
884
+ // ---- Memories view ----
885
+
886
+ function renderMemoriesView() {
887
+ return `
888
+ <section class="view view-active" data-view="memories">
889
+ <div class="page-header">
890
+ <h1 class="page-title">Memories</h1>
891
+ <button class="btn btn-primary btn-sm" id="newFolderTopBtn" type="button">+ New folder</button>
892
+ </div>
893
+ <div class="memories-layout">
894
+ <div class="folder-tree" id="folderTree">
895
+ <div class="empty-state">Loading folders…</div>
896
+ </div>
897
+ <div class="memory-editor" id="memoryEditor">
898
+ <div class="empty-state">Select a folder or file to get started.</div>
899
+ </div>
900
+ </div>
901
+ </section>
902
+ `;
903
+ }
904
+
905
+ function bindMemoriesView() {
906
+ renderFolderTree();
907
+ renderMemoryEditor();
908
+ $('#newFolderTopBtn')?.addEventListener('click', () => createFolderPrompt(state.selectedMemoryFolder));
909
+ }
910
+
911
+ function renderFolderRow(node, path, depth = 0) {
912
+ const isRoot = path === node.name;
913
+ const isSelected = state.selectedMemoryFolder === path;
914
+ const childrenHtml = (node.children || [])
915
+ .map((child) => renderFolderRow(child, `${path}/${child.name}`, depth + 1))
916
+ .join('');
917
+
918
+ const files = Array.isArray(node.files) ? node.files : [];
919
+ const filesHtml = files.length
920
+ ? `<div class="tree-file-list">
921
+ ${files
922
+ .map(
923
+ (file) => `
924
+ <div class="tree-file-row ${
925
+ state.selectedMemoryFile?.folder === path && state.selectedMemoryFile?.key === file.key
926
+ ? 'active'
927
+ : ''
928
+ }" data-folder="${escapeHtml(path)}" data-key="${escapeHtml(file.key)}" style="padding-left:${
929
+ 12 + (depth + 1) * 14
930
+ }px">
931
+ <span class="file-icon">📄</span>
932
+ <span class="file-name">${escapeHtml(file.title || file.key)}</span>
933
+ </div>
934
+ `,
935
+ )
936
+ .join('')}
937
+ </div>`
938
+ : '';
939
+
940
+ return `
941
+ <div class="folder-branch" data-folder="${escapeHtml(path)}">
942
+ <div class="folder-row ${isSelected ? 'active' : ''}" style="padding-left:${12 + depth * 14}px">
943
+ <span class="folder-row-main" data-folder="${escapeHtml(path)}">
944
+ <span class="folder-icon">${isRoot ? '📁' : '📂'}</span>
945
+ <span class="folder-name">${escapeHtml(node.name || 'root')}</span>
946
+ ${files.length > 0 ? `<span class="folder-count">${files.length}</span>` : ''}
947
+ </span>
948
+ <span class="folder-actions">
949
+ <button class="icon-btn" data-action="new-file" data-folder="${escapeHtml(path)}" title="New file">✚</button>
950
+ <button class="icon-btn" data-action="rename" data-folder="${escapeHtml(path)}" title="Rename">✎</button>
951
+ ${!isRoot ? `<button class="icon-btn" data-action="delete" data-folder="${escapeHtml(path)}" title="Delete">🗑</button>` : ''}
952
+ </span>
953
+ </div>
954
+ ${filesHtml}
955
+ ${childrenHtml}
956
+ </div>
957
+ `;
958
+ }
959
+
960
+ function renderFolderTree() {
961
+ const tree = $('#folderTree');
962
+ if (!tree) return;
963
+ if (state.memoriesError) {
964
+ tree.innerHTML = `<div class="empty-state" style="color:var(--err)">Failed to load memories.<br><small>${escapeHtml(state.memoriesError)}</small></div>`;
965
+ return;
966
+ }
967
+ if (!state.data.memories) {
968
+ tree.innerHTML = '<div class="empty-state">Loading folders…</div>';
969
+ return;
245
970
  }
246
971
 
247
- async function loadDecisions() {
248
- state.decisions = await api('/api/decisions?limit=50');
249
- renderDecisions();
972
+ const virtualRoot = state.data.memories;
973
+ const rootsHtml = (virtualRoot.children || [])
974
+ .map((root) => renderFolderRow(root, root.name))
975
+ .join('');
976
+ tree.innerHTML = rootsHtml || '<div class="empty-state">No folders found.</div>';
977
+
978
+ tree.querySelectorAll('.folder-row-main').forEach((el) => {
979
+ el.addEventListener('click', (e) => {
980
+ e.stopPropagation();
981
+ selectFolder(el.dataset.folder);
982
+ });
983
+ });
984
+
985
+ tree.querySelectorAll('.tree-file-row').forEach((el) => {
986
+ el.addEventListener('click', (e) => {
987
+ e.stopPropagation();
988
+ selectFile(el.dataset.folder, el.dataset.key);
989
+ });
990
+ });
991
+
992
+ tree.querySelectorAll('[data-action]').forEach((btn) => {
993
+ btn.addEventListener('click', (e) => {
994
+ e.stopPropagation();
995
+ const folder = btn.dataset.folder;
996
+ const action = btn.dataset.action;
997
+ if (action === 'new-file') createFilePrompt(folder);
998
+ else if (action === 'rename') renameFolderPrompt(folder);
999
+ else if (action === 'delete') deleteFolderPrompt(folder);
1000
+ });
1001
+ });
1002
+ }
1003
+
1004
+ function getFolderNode(root, folderPath) {
1005
+ const parts = folderPath.split('/');
1006
+ let current = root;
1007
+ if (!current) return null;
1008
+ for (const part of parts) {
1009
+ current = (current.children || []).find((c) => c.name === part);
1010
+ if (!current) return null;
250
1011
  }
1012
+ return current;
1013
+ }
251
1014
 
252
- function renderMemoryNode(node, basePath = '') {
253
- const currentPath = basePath ? `${basePath}/${node.name}` : node.name;
254
- const filesHtml =
255
- node.files && node.files.length
256
- ? node.files
257
- .map(
258
- (file) => `
259
- <div class="tree-file" data-path="${escapeHtml(currentPath)}/${escapeHtml(file.key)}" data-key="${escapeHtml(
260
- file.key,
261
- )}">
262
- <span>📄</span>
263
- <span>${escapeHtml(file.title || file.key)}</span>
264
- </div>
265
- `,
266
- )
267
- .join('')
268
- : '';
269
-
270
- const childrenHtml =
271
- node.children && node.children.length
272
- ? node.children.map((child) => renderMemoryNode(child, currentPath)).join('')
273
- : '';
274
-
275
- const commentHtml = node.comment
276
- ? `<div class="tree-comment">${escapeHtml(node.comment)}</div>`
277
- : '';
278
-
279
- if (!node.children?.length && !node.files?.length) {
280
- return '';
281
- }
1015
+ function renderFileList(folderPath) {
1016
+ const node = getFolderNode(state.data.memories, folderPath);
1017
+ if (!node || !node.files.length) {
1018
+ return '<div class="empty-state" style="min-height:120px">No files in this folder.</div>';
1019
+ }
1020
+ const filesHtml = node.files
1021
+ .map(
1022
+ (file) => `
1023
+ <div class="file-row ${state.selectedMemoryFile?.folder === folderPath && state.selectedMemoryFile?.key === file.key ? 'active' : ''}"
1024
+ data-folder="${escapeHtml(folderPath)}" data-key="${escapeHtml(file.key)}">
1025
+ <span class="file-icon">📄</span>
1026
+ <span class="file-name">${escapeHtml(file.title || file.key)}</span>
1027
+ </div>
1028
+ `,
1029
+ )
1030
+ .join('');
1031
+ return `<div class="file-list">${filesHtml}</div>`;
1032
+ }
282
1033
 
283
- return `
284
- <div class="tree-node">
285
- <div class="tree-folder">
286
- <span class="tree-folder-icon">▸</span>
287
- <span>${escapeHtml(node.name)}</span>
1034
+ function renderMemoryEditor() {
1035
+ const editor = $('#memoryEditor');
1036
+ if (!editor) return;
1037
+
1038
+ const file = state.selectedMemoryFile;
1039
+ let composerHtml = '';
1040
+ if (file) {
1041
+ const editing = state.editingMemory || file.isNew;
1042
+ const tagsHtml = (Array.isArray(file.tags) ? file.tags : [])
1043
+ .map((t) => `<span class="tag">${escapeHtml(t)}</span>`)
1044
+ .join('');
1045
+ const tagsEditValue = Array.isArray(file.tags) ? file.tags.join(', ') : '';
1046
+ const bodyHtml = editing
1047
+ ? `
1048
+ <label class="field-label">Title</label>
1049
+ <input type="text" class="composer-input" id="memoryTitle" value="${escapeHtml(
1050
+ file.title || '',
1051
+ )}" placeholder="Memory title" />
1052
+ <label class="field-label">Tags</label>
1053
+ <input type="text" class="composer-input" id="memoryTags" value="${escapeHtml(
1054
+ tagsEditValue,
1055
+ )}" placeholder="tag1, tag2" />
1056
+ <label class="field-label">Content</label>
1057
+ <textarea class="composer-textarea" id="memoryContent" placeholder="Write markdown content…">${escapeHtml(
1058
+ file.content || '',
1059
+ )}</textarea>
1060
+ `
1061
+ : `
1062
+ <div class="memory-meta">
1063
+ <div class="memory-title">${escapeHtml(file.title || file.key)}</div>
1064
+ <div class="tag-list">${tagsHtml}</div>
1065
+ </div>
1066
+ <div class="memory-content md-content">${
1067
+ file.content ? renderMarkdown(file.content) : '<span class="muted">Empty file.</span>'
1068
+ }</div>
1069
+ `;
1070
+ const actionsHtml = editing
1071
+ ? `
1072
+ <button class="btn btn-primary btn-sm" id="saveMemoryBtn" type="button">Save</button>
1073
+ <button class="btn btn-secondary btn-sm" id="cancelEditMemoryBtn" type="button">Cancel</button>
1074
+ ${!file.isNew ? '<button class="btn btn-danger btn-sm" id="deleteMemoryBtn" type="button">Delete</button>' : ''}
1075
+ `
1076
+ : `
1077
+ <button class="btn btn-secondary btn-sm" id="editMemoryBtn" type="button">Edit</button>
1078
+ <button class="btn btn-danger btn-sm" id="deleteMemoryBtn" type="button">Delete</button>
1079
+ `;
1080
+ composerHtml = `
1081
+ <div class="composer-card memory-composer">
1082
+ <div class="composer-header">
1083
+ <h2 class="composer-title">${escapeHtml(file.key)}</h2>
1084
+ <div class="page-header-actions">
1085
+ ${actionsHtml}
1086
+ </div>
1087
+ </div>
1088
+ <div class="composer-status" id="composerStatus"></div>
1089
+ <div class="composer-body">
1090
+ ${bodyHtml}
1091
+ </div>
1092
+ </div>
1093
+ `;
1094
+ } else {
1095
+ composerHtml = `
1096
+ <div class="empty-state" style="min-height:180px">
1097
+ <div>
1098
+ <div style="font-size:18px;margin-bottom:8px">📝</div>
1099
+ <div>Select a file to view, or choose a folder and create a new file.</div>
288
1100
  </div>
289
- ${commentHtml}
290
- ${filesHtml}
291
- ${childrenHtml}
292
1101
  </div>
293
1102
  `;
294
1103
  }
295
1104
 
296
- async function loadMemoryContent(filePath, key) {
297
- const folder = filePath.replace(new RegExp(`/${key}$`), '');
298
- const encodedFolder = encodeURIComponent(folder);
299
- const encodedKey = encodeURIComponent(key);
300
- const preview = $('#memory-preview');
1105
+ editor.innerHTML = composerHtml;
301
1106
 
1107
+ if (file) {
1108
+ $('#editMemoryBtn')?.addEventListener('click', startEditMemory);
1109
+ $('#saveMemoryBtn')?.addEventListener('click', saveSelectedMemory);
1110
+ $('#cancelEditMemoryBtn')?.addEventListener('click', cancelEditMemory);
1111
+ $('#deleteMemoryBtn')?.addEventListener('click', deleteSelectedMemory);
1112
+ }
1113
+ }
1114
+
1115
+ function startEditMemory() {
1116
+ state.editingMemory = true;
1117
+ renderMemoryEditor();
1118
+ }
1119
+
1120
+ function cancelEditMemory() {
1121
+ state.editingMemory = false;
1122
+ if (state.selectedMemoryFile?.isNew) {
1123
+ state.selectedMemoryFile = null;
1124
+ }
1125
+ renderMemoryEditor();
1126
+ }
1127
+
1128
+ function selectFolder(folderPath) {
1129
+ state.selectedMemoryFolder = folderPath;
1130
+ state.selectedMemoryFile = null;
1131
+ renderFolderTree();
1132
+ renderMemoryEditor();
1133
+ }
1134
+
1135
+ async function selectFile(folder, key) {
1136
+ try {
1137
+ const memory = await api(`/api/memory/${encodeURIComponent(folder)}/${encodeURIComponent(key)}`);
1138
+ state.selectedMemoryFile = { folder, key, ...memory };
1139
+ state.selectedMemoryFolder = folder;
1140
+ state.editingMemory = false;
1141
+ renderFolderTree();
1142
+ renderMemoryEditor();
1143
+ } catch (err) {
1144
+ alert(`Failed to load memory: ${err.message}`);
1145
+ }
1146
+ }
1147
+
1148
+ async function createFolderPrompt(parentFolder) {
1149
+ const parent = parentFolder && state.memoryFolders.includes(parentFolder) ? parentFolder : 'memory';
1150
+ const name = prompt('New folder name:', '');
1151
+ if (!name) return;
1152
+ const folderPath = `${parent}/${name.trim()}`.replace(/\/+/g, '/');
1153
+ try {
1154
+ const result = await createMemoryFolder(folderPath);
1155
+ if (!result.ok) throw new Error(result.error || 'Failed to create folder');
1156
+ state.selectedMemoryFolder = folderPath;
1157
+ await loadMemories();
1158
+ } catch (err) {
1159
+ alert(`Create folder failed: ${err.message}`);
1160
+ }
1161
+ }
1162
+
1163
+ async function renameFolderPrompt(folderPath) {
1164
+ const newPath = prompt('Rename folder to:', folderPath);
1165
+ if (!newPath || newPath === folderPath) return;
1166
+ try {
1167
+ const result = await renameMemoryFolder(folderPath, newPath);
1168
+ if (!result.ok) throw new Error(result.error || 'Failed to rename folder');
1169
+ if (state.selectedMemoryFolder === folderPath) state.selectedMemoryFolder = newPath;
1170
+ await loadMemories();
1171
+ } catch (err) {
1172
+ alert(`Rename folder failed: ${err.message}`);
1173
+ }
1174
+ }
1175
+
1176
+ async function deleteFolderPrompt(folderPath) {
1177
+ if (!confirm(`Delete folder "${folderPath}" and all its contents? This cannot be undone.`)) return;
1178
+ try {
1179
+ const result = await deleteMemoryFolder(folderPath, true);
1180
+ if (!result.ok) throw new Error(result.error || 'Failed to delete folder');
1181
+ if (state.selectedMemoryFolder === folderPath) {
1182
+ state.selectedMemoryFolder = null;
1183
+ state.selectedMemoryFile = null;
1184
+ }
1185
+ await loadMemories();
1186
+ } catch (err) {
1187
+ alert(`Delete folder failed: ${err.message}`);
1188
+ }
1189
+ }
1190
+
1191
+ async function createFilePrompt(folderPath) {
1192
+ const key = prompt('New memory key:', '');
1193
+ if (!key) return;
1194
+ state.selectedMemoryFile = { folder: folderPath, key, title: '', tags: [], content: '', isNew: true };
1195
+ state.selectedMemoryFolder = folderPath;
1196
+ state.editingMemory = true;
1197
+ renderFolderTree();
1198
+ renderMemoryEditor();
1199
+ }
1200
+
1201
+ function parseTagsInput(value) {
1202
+ return String(value || '')
1203
+ .split(',')
1204
+ .map((t) => t.trim())
1205
+ .filter(Boolean);
1206
+ }
1207
+
1208
+ async function saveSelectedMemory() {
1209
+ const file = state.selectedMemoryFile;
1210
+ if (!file) return;
1211
+ const title = $('#memoryTitle').value;
1212
+ const tags = parseTagsInput($('#memoryTags').value);
1213
+ const content = $('#memoryContent').value;
1214
+ const status = $('#composerStatus');
1215
+ try {
1216
+ const result = await writeMemory(file.folder, file.key, { content, title, tags });
1217
+ if (!result.ok) throw new Error(result.error || 'Failed to save memory');
1218
+ setStatus(status, 'Memory saved.', 'success');
1219
+ state.editingMemory = false;
1220
+ state.selectedMemoryFile = { ...file, title, tags, content, isNew: false };
1221
+ await loadMemories();
1222
+ } catch (err) {
1223
+ setStatus(status, `Save failed: ${err.message}`, 'error');
1224
+ }
1225
+ }
1226
+
1227
+ async function deleteSelectedMemory() {
1228
+ const file = state.selectedMemoryFile;
1229
+ if (!file) return;
1230
+ if (!confirm(`Delete "${file.folder}/${file.key}"? This cannot be undone.`)) return;
1231
+ try {
1232
+ const result = await deleteMemoryFile(file.folder, file.key);
1233
+ if (!result.ok) throw new Error(result.error || 'Failed to delete memory');
1234
+ state.selectedMemoryFile = null;
1235
+ await loadMemories();
1236
+ } catch (err) {
1237
+ alert(`Delete memory failed: ${err.message}`);
1238
+ }
1239
+ }
1240
+
1241
+ function deriveFoldersFromTree(tree) {
1242
+ const folders = [];
1243
+ function walk(node, prefix) {
1244
+ if (!node) return;
1245
+ const path = prefix ? `${prefix}/${node.name}` : node.name;
1246
+ if (prefix || node.name) folders.push(path);
1247
+ for (const child of node.children || []) walk(child, path);
1248
+ }
1249
+ if (tree && Array.isArray(tree.children)) {
1250
+ for (const root of tree.children) walk(root, '');
1251
+ }
1252
+ return folders.sort();
1253
+ }
1254
+
1255
+ function findFirstFolderWithFiles(node) {
1256
+ if (!node) return null;
1257
+ if (Array.isArray(node.files) && node.files.length > 0 && node.name) {
1258
+ return node.name;
1259
+ }
1260
+ for (const child of node.children || []) {
1261
+ const found = findFirstFolderWithFiles(child);
1262
+ if (found) return node.name ? `${node.name}/${found}` : found;
1263
+ }
1264
+ return null;
1265
+ }
1266
+
1267
+ async function loadMemories() {
1268
+ state.memoriesError = null;
1269
+ try {
1270
+ const tree = await api('/api/memories');
1271
+ state.data.memories = tree;
1272
+ let folders = [];
302
1273
  try {
303
- const memory = await api(`/api/memory/${encodedFolder}/${encodedKey}`);
304
- preview.innerHTML = `
305
- <div class="preview-header">
306
- <div>
307
- <h2 class="preview-title">${escapeHtml(memory.title || key)}</h2>
308
- <div class="preview-path">${escapeHtml(filePath)}</div>
309
- <div class="tag-list" style="margin-top:8px">
310
- ${memory.tags.map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join('')}
1274
+ folders = await listMemoryFolders();
1275
+ } catch (err) {
1276
+ console.warn('Failed to fetch /api/folders, deriving from tree:', err);
1277
+ folders = deriveFoldersFromTree(tree);
1278
+ }
1279
+ state.memoryFolders = folders;
1280
+ if (state.selectedMemoryFolder && !folders.includes(state.selectedMemoryFolder)) {
1281
+ state.selectedMemoryFolder = null;
1282
+ state.selectedMemoryFile = null;
1283
+ }
1284
+ if (!state.selectedMemoryFolder && tree) {
1285
+ const autoFolder = findFirstFolderWithFiles(tree);
1286
+ if (autoFolder) state.selectedMemoryFolder = autoFolder;
1287
+ }
1288
+ if (state.currentView === 'memories') {
1289
+ renderFolderTree();
1290
+ renderMemoryEditor();
1291
+ }
1292
+ } catch (err) {
1293
+ state.memoriesError = String(err.message || err);
1294
+ console.error('loadMemories failed:', err);
1295
+ if (state.currentView === 'memories') {
1296
+ renderFolderTree();
1297
+ renderMemoryEditor();
1298
+ }
1299
+ }
1300
+ }
1301
+
1302
+ // ---- Settings view ----
1303
+
1304
+ function renderSettingsView() {
1305
+ const ws = state.data.workspace || {};
1306
+ const autoVis = localStorage.getItem(LS_KEY_AUTO_VIS) === 'true';
1307
+ return `
1308
+ <section class="view view-active" data-view="settings">
1309
+ <div class="page-header">
1310
+ <h1 class="page-title">Settings</h1>
1311
+ </div>
1312
+ <div class="settings-grid">
1313
+ <div class="card">
1314
+ <div class="card-header">
1315
+ <h2 class="card-title">Paths</h2>
1316
+ </div>
1317
+ <div class="card-body">
1318
+ <div class="info-row">
1319
+ <span class="info-label">Workspace ID</span>
1320
+ <code class="info-value" id="settingsWorkspaceId">${escapeHtml(ws.id || '–')}</code>
1321
+ </div>
1322
+ <div class="info-row">
1323
+ <span class="info-label">Workspace path</span>
1324
+ <code class="info-value" id="settingsCwd">${escapeHtml(ws.cwd || '–')}</code>
1325
+ </div>
1326
+ <div class="info-row">
1327
+ <span class="info-label">Store root</span>
1328
+ <code class="info-value" id="settingsStoreRoot">${escapeHtml(ws.storePath || '–')}</code>
1329
+ </div>
1330
+ <div class="info-row">
1331
+ <span class="info-label">MCP config hint</span>
1332
+ <code class="info-value">~/.kimi-code/mcp.json</code>
311
1333
  </div>
312
1334
  </div>
313
1335
  </div>
314
- <div class="preview-content">${escapeHtml(memory.content)}</div>
315
- `;
316
- } catch (err) {
317
- preview.innerHTML = `
318
- <div class="preview-header">
319
- <h2 class="preview-title">${escapeHtml(key)}</h2>
1336
+ <div class="card">
1337
+ <div class="card-header">
1338
+ <h2 class="card-title">Environment</h2>
1339
+ </div>
1340
+ <div class="card-body">
1341
+ <label class="toggle-row">
1342
+ <span>Auto-open dashboard (KIMI_MEMORY_AUTO_VIS)</span>
1343
+ <input type="checkbox" id="autoVisToggle" ${autoVis ? 'checked' : ''} />
1344
+ </label>
1345
+ <p class="help-text">When enabled, the dashboard will open automatically on startup. This toggle stores a local preference; the actual environment variable must be set in your Kimi Code/MCP configuration.</p>
1346
+ </div>
320
1347
  </div>
321
- <div class="preview-content error">Failed to load memory: ${escapeHtml(err.message)}</div>
322
- `;
323
- }
1348
+ <div class="card">
1349
+ <div class="card-header">
1350
+ <h2 class="card-title">Links</h2>
1351
+ </div>
1352
+ <div class="card-body">
1353
+ <div class="link-list">
1354
+ <a class="btn btn-secondary" href="https://github.com/Zehee/kimi-code-memory-mcp-server" target="_blank" rel="noopener">GitHub</a>
1355
+ <a class="btn btn-secondary" href="https://www.npmjs.com/package/kimi-code-memory-mcp-server" target="_blank" rel="noopener">npm</a>
1356
+ </div>
1357
+ </div>
1358
+ </div>
1359
+ </div>
1360
+ </section>
1361
+ `;
1362
+ }
1363
+
1364
+ function updateSettingsView() {
1365
+ if (state.currentView !== 'settings') return;
1366
+ const ws = state.data.workspace || {};
1367
+ $('#settingsWorkspaceId').textContent = ws.id || '–';
1368
+ $('#settingsCwd').textContent = ws.cwd || '–';
1369
+ $('#settingsStoreRoot').textContent = ws.storePath || '–';
1370
+ }
1371
+
1372
+ function bindSettingsView() {
1373
+ $('#autoVisToggle').addEventListener('change', (e) => {
1374
+ localStorage.setItem(LS_KEY_AUTO_VIS, String(e.target.checked));
1375
+ });
1376
+ }
1377
+
1378
+ // ---- Routing / init ----
1379
+
1380
+ function loadDataForView(view, theme, key) {
1381
+ switch (view) {
1382
+ case 'workspace':
1383
+ case 'settings':
1384
+ if (!state.data.workspace) loadWorkspace();
1385
+ break;
1386
+ case 'themes':
1387
+ if (state.data.themes.length === 0) loadThemes();
1388
+ break;
1389
+ case 'theme-detail':
1390
+ loadThemeDetail(theme);
1391
+ break;
1392
+ case 'decisions':
1393
+ if (state.data.decisions.length === 0) loadDecisions();
1394
+ break;
1395
+ case 'memories':
1396
+ if (!state.data.memories) loadMemories();
1397
+ break;
1398
+ case 'searches':
1399
+ if (!state.data.searches) loadSearches();
1400
+ break;
1401
+ case 'search-detail':
1402
+ loadSearchDetail();
1403
+ break;
324
1404
  }
1405
+ }
325
1406
 
326
- function renderMemories() {
327
- const tree = $('#memory-tree');
328
- if (!state.memories) {
329
- tree.innerHTML = '<div class="empty-state">Loading memories…</div>';
330
- return;
331
- }
332
- tree.innerHTML = renderMemoryNode(state.memories);
333
-
334
- tree.querySelectorAll('.tree-file').forEach((fileEl) => {
335
- fileEl.addEventListener('click', () => {
336
- tree.querySelectorAll('.tree-file').forEach((f) => f.classList.remove('active'));
337
- fileEl.classList.add('active');
338
- state.activeMemoryPath = fileEl.dataset.path;
339
- loadMemoryContent(fileEl.dataset.path, fileEl.dataset.key);
340
- });
341
- });
1407
+ function handleContentClick(e) {
1408
+ const deleteThemeBtn = e.target.closest('[data-action="delete-theme"]');
1409
+ if (deleteThemeBtn) {
1410
+ e.stopPropagation();
1411
+ deleteTheme(deleteThemeBtn.dataset.theme);
1412
+ return;
342
1413
  }
343
1414
 
344
- async function loadMemories() {
345
- state.memories = await api('/api/memories');
346
- renderMemories();
1415
+ const deleteSearchBtn = e.target.closest('[data-action="delete-search"]');
1416
+ if (deleteSearchBtn) {
1417
+ e.stopPropagation();
1418
+ deleteSearchView(deleteSearchBtn.dataset.key);
1419
+ return;
347
1420
  }
348
1421
 
349
- function openModal() {
350
- $('#modal').classList.add('open');
351
- $('#modal').setAttribute('aria-hidden', 'false');
1422
+ const searchRow = e.target.closest('#searchesTable .search-row');
1423
+ if (searchRow) {
1424
+ const key = searchRow.dataset.key;
1425
+ setHash(`searches/${key}`);
1426
+ applyHash();
1427
+ return;
352
1428
  }
353
1429
 
354
- function closeModal() {
355
- $('#modal').classList.remove('open');
356
- $('#modal').setAttribute('aria-hidden', 'true');
1430
+ const themeRow = e.target.closest('#themesTable .theme-row');
1431
+ if (themeRow) {
1432
+ const theme = themeRow.dataset.theme;
1433
+ setHash(`themes/${theme}`);
1434
+ applyHash();
1435
+ return;
357
1436
  }
358
1437
 
359
- function init() {
360
- $$('.nav-item').forEach((link) => {
361
- link.addEventListener('click', (e) => {
362
- e.preventDefault();
363
- const view = link.dataset.view;
364
- showView(view);
365
- history.pushState(null, '', `#${view}`);
366
- if (view === 'themes' && state.themes.length === 0) loadThemes();
367
- if (view === 'decisions' && state.decisions.length === 0) loadDecisions();
368
- if (view === 'memories' && !state.memories) loadMemories();
369
- });
370
- });
1438
+ const searchTimelineItem = e.target.closest('#searchTimeline .timeline-item');
1439
+ if (searchTimelineItem) {
1440
+ const sessionId = searchTimelineItem.dataset.session;
1441
+ const turnId = parseInt(searchTimelineItem.dataset.turn, 10);
1442
+ showRefinedTurnModal(sessionId, turnId);
1443
+ return;
1444
+ }
371
1445
 
372
- $('#menu-toggle').addEventListener('click', () => {
373
- $('.sidebar').classList.toggle('open');
374
- });
1446
+ const themeTimelineItem = e.target.closest('#themeTimeline .timeline-item[data-session]');
1447
+ if (themeTimelineItem) {
1448
+ const sessionId = themeTimelineItem.dataset.session;
1449
+ const turnId = parseInt(themeTimelineItem.dataset.turn, 10);
1450
+ showRefinedTurnModal(sessionId, turnId);
1451
+ return;
1452
+ }
1453
+ }
375
1454
 
376
- $('#save-essence').addEventListener('click', saveEssence);
377
- $('#sync-btn').addEventListener('click', syncIndex);
378
- $('#modal-close').addEventListener('click', closeModal);
379
- $('.modal-backdrop').addEventListener('click', closeModal);
1455
+ function init() {
1456
+ state.sidebarCollapsed = getInitialCollapsed();
1457
+ updateCollapsedClass();
380
1458
 
381
- document.addEventListener('keydown', (e) => {
382
- if (e.key === 'Escape') closeModal();
383
- });
1459
+ $('#content').addEventListener('click', handleContentClick);
384
1460
 
385
- const initialView = location.hash.replace('#', '') || 'workspace';
386
- showView(initialView);
1461
+ window.addEventListener('hashchange', applyHash);
1462
+ window.addEventListener('popstate', applyHash);
387
1463
 
388
- loadWorkspace();
389
- if (initialView === 'themes') loadThemes();
390
- if (initialView === 'decisions') loadDecisions();
391
- if (initialView === 'memories') loadMemories();
392
- }
1464
+ applyHash();
1465
+ loadWorkspace();
1466
+ }
393
1467
 
394
- init();
395
- })();
1468
+ init();