kimi-code-memory-mcp-server 0.1.1 → 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.
- package/CHANGELOG.md +28 -1
- package/README.en.md +349 -0
- package/README.md +223 -137
- package/assets/contextFlow.svg +144 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/context/wire-context.d.ts +3 -0
- package/dist/context/wire-context.js +20 -50
- package/dist/context/wire-context.js.map +1 -1
- package/dist/dao/constants.d.ts +33 -0
- package/dist/dao/constants.js +17 -0
- package/dist/dao/constants.js.map +1 -0
- package/dist/dao/index-catalog.d.ts +19 -0
- package/dist/dao/index-catalog.js +94 -0
- package/dist/dao/index-catalog.js.map +1 -0
- package/dist/dao/index-reconciler.d.ts +13 -0
- package/dist/dao/index-reconciler.js +162 -0
- package/dist/dao/index-reconciler.js.map +1 -0
- package/dist/dao/index-store.d.ts +31 -0
- package/dist/dao/index-store.js +128 -0
- package/dist/dao/index-store.js.map +1 -0
- package/dist/dao/index.d.ts +12 -31
- package/dist/dao/index.js +50 -404
- package/dist/dao/index.js.map +1 -1
- package/dist/dao/memory-store.js +2 -10
- package/dist/dao/memory-store.js.map +1 -1
- package/dist/dao/memory-tree-renderer.d.ts +22 -0
- package/dist/dao/memory-tree-renderer.js +75 -0
- package/dist/dao/memory-tree-renderer.js.map +1 -0
- package/dist/prompts/index.d.ts +26 -0
- package/dist/prompts/index.js +103 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/refine/adapter.d.ts +6 -0
- package/dist/refine/adapter.js +28 -0
- package/dist/refine/adapter.js.map +1 -0
- package/dist/refine/constants.d.ts +35 -0
- package/dist/refine/constants.js +107 -0
- package/dist/refine/constants.js.map +1 -0
- package/dist/refine/extractor.d.ts +12 -0
- package/dist/refine/extractor.js +122 -0
- package/dist/refine/extractor.js.map +1 -0
- package/dist/refine/store.d.ts +23 -0
- package/dist/refine/store.js +153 -0
- package/dist/refine/store.js.map +1 -0
- package/dist/refine/types.d.ts +56 -0
- package/dist/refine/types.js +5 -0
- package/dist/refine/types.js.map +1 -0
- package/dist/refined-manager.d.ts +14 -56
- package/dist/refined-manager.js +27 -341
- package/dist/refined-manager.js.map +1 -1
- package/dist/resources/index.d.ts +15 -0
- package/dist/resources/index.js +134 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/server.js +46 -2
- package/dist/server.js.map +1 -1
- package/dist/theme-manager.d.ts +1 -0
- package/dist/theme-manager.js +7 -0
- package/dist/theme-manager.js.map +1 -1
- package/dist/tools/context-tools.d.ts +16 -51
- package/dist/tools/context-tools.js +303 -55
- package/dist/tools/context-tools.js.map +1 -1
- package/dist/tools/index.d.ts +5 -827
- package/dist/tools/index.js +23 -354
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/memory-tools.d.ts +4 -60
- package/dist/tools/memory-tools.js +129 -79
- package/dist/tools/memory-tools.js.map +1 -1
- package/dist/tools/system-tools.d.ts +3 -34
- package/dist/tools/system-tools.js +110 -33
- package/dist/tools/system-tools.js.map +1 -1
- package/dist/tools/theme-tools.d.ts +3 -31
- package/dist/tools/theme-tools.js +101 -22
- package/dist/tools/theme-tools.js.map +1 -1
- package/dist/tools/types.d.ts +21 -0
- package/dist/tools/types.js +13 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/types.d.ts +11 -2
- package/dist/utils/action-entities.d.ts +16 -0
- package/dist/utils/action-entities.js +35 -0
- package/dist/utils/action-entities.js.map +1 -0
- package/dist/utils/date.d.ts +11 -0
- package/dist/utils/date.js +13 -0
- package/dist/utils/date.js.map +1 -0
- package/dist/utils/file-helpers.d.ts +10 -0
- package/dist/utils/file-helpers.js +28 -0
- package/dist/utils/file-helpers.js.map +1 -0
- package/dist/utils/headings.d.ts +5 -0
- package/dist/utils/headings.js +21 -0
- package/dist/utils/headings.js.map +1 -0
- package/dist/utils/search.d.ts +17 -0
- package/dist/utils/search.js +60 -0
- package/dist/utils/search.js.map +1 -0
- package/dist/utils/tools.d.ts +5 -0
- package/dist/utils/tools.js +10 -0
- package/dist/utils/tools.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/vis/api.d.ts +117 -0
- package/dist/vis/api.js +426 -0
- package/dist/vis/api.js.map +1 -0
- package/dist/vis/auto-start.d.ts +12 -0
- package/dist/vis/auto-start.js +87 -0
- package/dist/vis/auto-start.js.map +1 -0
- package/dist/vis/server.d.ts +14 -0
- package/dist/vis/server.js +337 -0
- package/dist/vis/server.js.map +1 -0
- package/dist/vis/static/api.js +57 -0
- package/dist/vis/static/app.js +1468 -0
- package/dist/vis/static/index.html +25 -0
- package/dist/vis/static/state.js +26 -0
- package/dist/vis/static/style.css +1553 -0
- package/dist/vis/static/utils/helpers.js +94 -0
- package/dist/vis/static/utils/markdown.js +81 -0
- package/dist/vis-cli.d.ts +7 -0
- package/dist/vis-cli.js +140 -0
- package/dist/vis-cli.js.map +1 -0
- package/docs/0.agent-memory-market-research.md +354 -0
- package/docs/1.memory-system-proposal-for-kimi-code.md +688 -0
- package/docs/2.memory-architecture-overview.md +417 -0
- package/docs/3.memory-skill-prompt-design.md +430 -0
- package/docs/4.kimi-code-native-evolution-roadmap.md +559 -0
- package/docs/5.decision-guard-design-notes.md +500 -0
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/design-story.md +350 -0
- package/docs/three-layer-memory-model.md +153 -0
- package/docs/three-layer-memory-model.zh-CN.md +153 -0
- package/package.json +12 -6
- package/README.zh-CN.md +0 -292
|
@@ -0,0 +1,1468 @@
|
|
|
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);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
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)) };
|
|
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
|
+
}
|
|
87
|
+
|
|
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;
|
|
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;
|
|
146
|
+
|
|
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}
|
|
166
|
+
</div>
|
|
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' });
|
|
188
|
+
}
|
|
189
|
+
|
|
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
|
|
201
|
+
}
|
|
202
|
+
|
|
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();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
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;
|
|
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
|
+
}
|
|
358
|
+
|
|
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>';
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (state.currentView === 'settings') {
|
|
372
|
+
updateSettingsView();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
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 }),
|
|
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');
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
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}`;
|
|
405
|
+
}
|
|
406
|
+
setTimeout(() => {
|
|
407
|
+
btn.textContent = original;
|
|
408
|
+
btn.disabled = false;
|
|
409
|
+
}, 1500);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---- Themes view ----
|
|
413
|
+
|
|
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>
|
|
435
|
+
</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>
|
|
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
|
+
}
|
|
514
|
+
|
|
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('')}
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
`;
|
|
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 }),
|
|
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
|
+
}
|
|
603
|
+
|
|
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}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
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();
|
|
631
|
+
}
|
|
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>
|
|
864
|
+
<div class="tag-list">
|
|
865
|
+
${d.decisions.map((dec) => `<span class="tag">${escapeHtml(dec)}</span>`).join('')}
|
|
866
|
+
${d.files.map((f) => `<span class="tag file">${escapeHtml(f)}</span>`).join('')}
|
|
867
|
+
${d.tags.map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join('')}
|
|
868
|
+
</div>
|
|
869
|
+
</div>
|
|
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;
|
|
970
|
+
}
|
|
971
|
+
|
|
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;
|
|
1011
|
+
}
|
|
1012
|
+
return current;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
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
|
+
}
|
|
1033
|
+
|
|
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>
|
|
1100
|
+
</div>
|
|
1101
|
+
</div>
|
|
1102
|
+
`;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
editor.innerHTML = composerHtml;
|
|
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 = [];
|
|
1273
|
+
try {
|
|
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>
|
|
1333
|
+
</div>
|
|
1334
|
+
</div>
|
|
1335
|
+
</div>
|
|
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>
|
|
1347
|
+
</div>
|
|
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;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
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;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const deleteSearchBtn = e.target.closest('[data-action="delete-search"]');
|
|
1416
|
+
if (deleteSearchBtn) {
|
|
1417
|
+
e.stopPropagation();
|
|
1418
|
+
deleteSearchView(deleteSearchBtn.dataset.key);
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
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;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
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;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
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
|
+
}
|
|
1445
|
+
|
|
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
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function init() {
|
|
1456
|
+
state.sidebarCollapsed = getInitialCollapsed();
|
|
1457
|
+
updateCollapsedClass();
|
|
1458
|
+
|
|
1459
|
+
$('#content').addEventListener('click', handleContentClick);
|
|
1460
|
+
|
|
1461
|
+
window.addEventListener('hashchange', applyHash);
|
|
1462
|
+
window.addEventListener('popstate', applyHash);
|
|
1463
|
+
|
|
1464
|
+
applyHash();
|
|
1465
|
+
loadWorkspace();
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
init();
|