claude-memory-admin 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { el, node } from '/dom.mjs';
3
+ import { renderMarkdown } from '/markdown.mjs';
4
+ import { state } from '/state.mjs';
5
+ import { selectMemory, loadSessionsForProvenance } from '/store.mjs';
6
+ import { goToTab, sessionDate } from '/parts.mjs';
7
+ import { openDeleteDialog } from '/dialogs/delete.mjs';
8
+
9
+ export function memoryLookup(project) {
10
+ const byName = new Map();
11
+ for (const memory of project.memories) {
12
+ byName.set(memory.name, memory.file);
13
+ byName.set(memory.stem, memory.file);
14
+ }
15
+ return byName;
16
+ }
17
+
18
+ export function openTarget(file) {
19
+ return file ? { open: () => selectMemory(file) } : null;
20
+ }
21
+
22
+ export function renderBody(container, memory, project) {
23
+ const byName = memoryLookup(project);
24
+ renderMarkdown(container, memory.body, {
25
+ resolveWikilink: (target) => openTarget(byName.get(target)),
26
+ });
27
+ }
28
+
29
+ function memoryButton(memory) {
30
+ const active = state.selected === memory.file;
31
+ return node('button', {
32
+ class: ui.memoryItem(active),
33
+ 'data-memory': memory.file,
34
+ 'data-active': active ? '' : null,
35
+ onclick: () => selectMemory(memory.file),
36
+ }, [
37
+ node('span', { class: ui.memoryTop }, [
38
+ node('span', { class: ui.memoryName, text: memory.name }),
39
+ node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
40
+ memory.status !== 'indexed'
41
+ ? node('span', { class: ui.badge('warn'), text: memory.status })
42
+ : null,
43
+ ]),
44
+ memory.description ? node('span', { class: ui.memoryDesc, text: memory.description }) : null,
45
+ ]);
46
+ }
47
+
48
+ export function renderMemories(container) {
49
+ const project = state.store;
50
+ if (!project.memories.length) {
51
+ container.append(node('p', { class: ui.note, text: 'This project has no memory files yet.' }));
52
+ return;
53
+ }
54
+
55
+ const left = node('div');
56
+ const groups = new Map();
57
+ for (const memory of project.memories) {
58
+ const key = memory.section || (memory.status === 'indexed' ? 'Index' : 'Not in the index');
59
+ if (!groups.has(key)) groups.set(key, []);
60
+ groups.get(key).push(memory);
61
+ }
62
+ for (const [section, memories] of groups) {
63
+ left.append(node('div', { class: ui.sectionLabel, text: `${section} · ${memories.length}` }));
64
+ for (const memory of memories) left.append(memoryButton(memory));
65
+ }
66
+
67
+ const right = node('div', { id: 'detail', class: ui.detailPane });
68
+ container.append(node('div', { class: ui.split }, [left, right]));
69
+ renderDetail();
70
+ }
71
+
72
+ function renderDetail() {
73
+ const host = el('detail');
74
+ if (!host) return;
75
+ host.textContent = '';
76
+
77
+ const memory = state.store.memories.find((m) => m.file === state.selected);
78
+ if (!memory) {
79
+ host.append(node('p', { class: ui.note, text: 'Select a memory to read it.' }));
80
+ return;
81
+ }
82
+
83
+ const card = node('div', { class: ui.card });
84
+ card.append(node('div', { class: ui.detailHead }, [
85
+ node('h3', { class: ui.detailTitle, text: memory.name }),
86
+ node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
87
+ node('button', {
88
+ class: ui.buttonDangerSmall,
89
+ text: 'Delete',
90
+ onclick: () => openDeleteDialog(memory.file),
91
+ }),
92
+ ]));
93
+
94
+ if (memory.description) card.append(node('p', { class: ui.detailDesc, text: memory.description }));
95
+
96
+ const meta = node('dl', { class: ui.metaList });
97
+ const rows = [
98
+ ['file', memory.file],
99
+ ['status', memory.status],
100
+ ['modified', memory.modified
101
+ ? `${memory.modified.replace('T', ' ').replace(/\..*$/, '')}${memory.modifiedFrom === 'mtime' ? ' (file mtime)' : ''}`
102
+ : '-'],
103
+ ['size', `${(memory.bytes / 1024).toFixed(1)} KB`],
104
+ ];
105
+ if (!memory.nameMatchesFile) rows.push(['name', `${memory.name} (differs from filename)`]);
106
+ for (const [key, value] of Object.entries(memory.metadata)) {
107
+ if (key !== 'type' && key !== 'modified' && key !== 'originSessionId') rows.push([key, value]);
108
+ }
109
+ for (const [key, value] of rows) {
110
+ meta.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: String(value) }));
111
+ }
112
+ card.append(meta);
113
+
114
+ const origin = provenanceLine(memory);
115
+ if (origin) card.append(origin);
116
+
117
+ const body = node('div', { class: ui.prose });
118
+ card.append(body);
119
+ renderBody(body, memory, state.store);
120
+
121
+ const outbound = memory.outboundResolved || [];
122
+ if (outbound.length || memory.inbound.length) {
123
+ const links = node('div', { class: ui.linkSection });
124
+ if (outbound.length) {
125
+ links.append(node('div', { class: ui.sectionLabel, text: 'Links out' }));
126
+ const chips = node('div', { class: ui.linkRow });
127
+ for (const link of outbound) {
128
+ chips.append(node('button', {
129
+ class: ui.chip(Boolean(link.file)),
130
+ text: link.file ? link.target : `${link.target} (missing)`,
131
+ onclick: link.file ? () => selectMemory(link.file) : null,
132
+ }));
133
+ }
134
+ links.append(chips);
135
+ }
136
+ if (memory.inbound.length) {
137
+ links.append(node('div', { class: ui.sectionLabel, text: 'Linked from' }));
138
+ const chips = node('div', { class: ui.linkRow });
139
+ for (const link of memory.inbound) {
140
+ const source = state.store.memories.find((m) => m.file === link.from);
141
+ chips.append(node('button', {
142
+ class: ui.chip(true),
143
+ text: source ? source.name : link.from,
144
+ onclick: () => selectMemory(link.from),
145
+ }));
146
+ }
147
+ links.append(chips);
148
+ }
149
+ card.append(links);
150
+ }
151
+
152
+ host.append(card);
153
+ }
154
+
155
+ function provenanceLine(memory) {
156
+ const origin = memory.origin;
157
+ if (!origin) return null;
158
+
159
+ const known = state.aux.sessions?.sessions.find((session) => session.id === origin.sessionId);
160
+ const label = origin.present ? (known?.title || origin.sessionId) : origin.sessionId;
161
+
162
+ const row = node('div', { class: ui.provenanceRow }, [node('span', { text: 'written in' })]);
163
+
164
+ if (origin.present) {
165
+ row.append(node('button', {
166
+ class: ui.provenanceLink(true),
167
+ text: label,
168
+ title: 'Open the Sessions tab',
169
+ onclick: () => goToTab('sessions'),
170
+ }));
171
+ if (known?.gitBranch) row.append(node('span', { text: `on ${known.gitBranch}` }));
172
+ if (origin.modified) row.append(node('span', { text: sessionDate(origin.modified).slice(0, 10) }));
173
+ if (!known) loadSessionsForProvenance();
174
+ } else {
175
+ row.append(node('span', {
176
+ class: ui.provenanceLink(false),
177
+ text: label,
178
+ title: 'This transcript is no longer on disk',
179
+ }));
180
+ row.append(node('span', { text: '- transcript swept, so why this memory exists can no longer be traced' }));
181
+ }
182
+ return row;
183
+ }
@@ -0,0 +1,102 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { api } from '/api.mjs';
4
+ import { state } from '/state.mjs';
5
+ import { paint } from '/bus.mjs';
6
+ import { renderIssue } from '/views/issue.mjs';
7
+
8
+ function storeHasProjectDir() {
9
+ return state.store.kind === 'auto'
10
+ ? state.store.resolvedBy !== 'unresolved'
11
+ : Boolean(state.store.projectPath);
12
+ }
13
+
14
+ const pathCheckKey = () => `pathCheck:${state.storeId}`;
15
+
16
+ async function fetchPathCheck() {
17
+ const id = state.storeId;
18
+ let next;
19
+ try {
20
+ next = { running: false, data: await api(`/api/stores/${encodeURIComponent(id)}/path-check`) };
21
+ } catch (err) {
22
+ next = { running: false, error: err.message };
23
+ }
24
+ if (state.storeId !== id) return;
25
+ state.pathCheck = next;
26
+ if (state.tab === 'health') paint('tab');
27
+ }
28
+
29
+ function startPathCheck({ remember = true } = {}) {
30
+ if (remember) localStorage.setItem(pathCheckKey(), '1');
31
+ state.pathCheck = { running: true };
32
+ fetchPathCheck();
33
+ }
34
+
35
+ function runPathCheck() {
36
+ startPathCheck();
37
+ paint('tab');
38
+ }
39
+
40
+ function stopPathCheck() {
41
+ localStorage.removeItem(pathCheckKey());
42
+ state.pathCheck = null;
43
+ paint('tab');
44
+ }
45
+
46
+ export function renderPathCheck(container) {
47
+ if (!storeHasProjectDir()) return;
48
+
49
+ if (!state.pathCheck && localStorage.getItem(pathCheckKey()) === '1') {
50
+ startPathCheck({ remember: false });
51
+ }
52
+ const check = state.pathCheck;
53
+
54
+ const head = node('div', { class: ui.issue(false) });
55
+ const body = node('div', { class: ui.issueBody });
56
+
57
+ if (!check) {
58
+ body.append(
59
+ node('div', { class: ui.issueTitle, text: 'Paths named in memories are not being checked' }),
60
+ node('div', { class: ui.issueDetail, text: 'Every other check on this tab reads only memory/. This one reads the project itself, to find memories naming files that no longer exist. It stays off until you ask, and is remembered per store.' }),
61
+ );
62
+ head.append(body, node('button', { class: ui.buttonSmall, text: 'Check against the repo', onclick: () => runPathCheck() }));
63
+ return container.append(head);
64
+ }
65
+
66
+ if (check.running) {
67
+ body.append(node('div', { class: ui.issueTitle, text: 'Checking paths against the repo…' }));
68
+ head.append(body);
69
+ return container.append(head);
70
+ }
71
+
72
+ if (check.error) {
73
+ body.append(
74
+ node('div', { class: ui.issueTitle, text: 'Path check could not run' }),
75
+ node('div', { class: ui.issueDetail, text: check.error }),
76
+ );
77
+ head.append(body, node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: () => stopPathCheck() }));
78
+ return container.append(head);
79
+ }
80
+
81
+ const { checked, capped, missing, indexed, truncated } = check.data;
82
+ const scanned = `${indexed.toLocaleString()} files and folders scanned${truncated ? ', stopped at the index limit' : ''}`;
83
+ body.append(
84
+ node('div', { class: ui.issueTitle, text: missing.length
85
+ ? `${missing.length} of ${checked} paths named in memories match no file in the project`
86
+ : `All ${checked} paths named in memories still match a file` }),
87
+ node('div', { class: ui.issueDetail, text: capped
88
+ ? `${scanned}; stopped after the first ${checked} paths, so there may be more`
89
+ : scanned }),
90
+ );
91
+ if (missing.length) {
92
+ body.append(node('div', { class: ui.issueDetail, text: 'A pointer rather than a verdict: a file that moved reads the same as one the memory never got right.' }));
93
+ }
94
+ head.append(
95
+ body,
96
+ node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: () => stopPathCheck() }),
97
+ node('button', { class: ui.buttonSmall, text: 'Re-check', onclick: () => runPathCheck() }),
98
+ );
99
+ container.append(head);
100
+
101
+ for (const item of missing) container.append(renderIssue(item, state.store.memories));
102
+ }
@@ -0,0 +1,191 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { state } from '/state.mjs';
4
+ import { paint } from '/bus.mjs';
5
+ import { formatAge } from '/parts.mjs';
6
+ import { selectMemory } from '/store.mjs';
7
+ import { openBulkDeleteDialog } from '/dialogs/bulk-delete.mjs';
8
+ import { openMergeDialog } from '/dialogs/merge.mjs';
9
+ import { openMoveDialog } from '/dialogs/move.mjs';
10
+ import { openHookEditor } from '/dialogs/hook-editor.mjs';
11
+
12
+ const PRUNE_SORTS = {
13
+ oldest: { label: 'oldest first', compare: (a, b) => (b.ageDays ?? -1) - (a.ageDays ?? -1) },
14
+ largest: { label: 'largest first', compare: (a, b) => b.bytes - a.bytes },
15
+ unlinked: { label: 'least linked', compare: (a, b) => a.inbound.length - b.inbound.length || (b.ageDays ?? 0) - (a.ageDays ?? 0) },
16
+ name: { label: 'by name', compare: (a, b) => a.name.localeCompare(b.name) },
17
+ };
18
+
19
+ function renderBudget(container) {
20
+ const { index } = state.store.stats;
21
+ const percent = Math.min(100, index.worstPercent);
22
+ const level = index.level;
23
+
24
+ const note = index.level === 'over'
25
+ ? 'Over the limit. Everything past the cutoff is dropped when the session loads it, so those entries are effectively invisible to Claude right now.'
26
+ : index.level === 'near'
27
+ ? 'Approaching the limit. Once MEMORY.md passes it, everything after the cutoff stops being loaded at all.'
28
+ : 'Comfortably inside the limit. MEMORY.md is loaded at the start of every session.';
29
+
30
+ if (index.cutoff) {
31
+ const dropped = index.cutoff.droppedEntries;
32
+ const box = node('div', { class: ui.cutSummary });
33
+ box.append(node('div', { class: ui.cutSummaryHead, text: dropped.length
34
+ ? `${dropped.length} ${dropped.length === 1 ? 'memory is' : 'memories are'} past the cutoff and not loaded`
35
+ : `${index.cutoff.droppedLines} lines are past the cutoff and not loaded` }));
36
+ box.append(node('p', { class: ui.note, text: `MEMORY.md stops loading at line ${index.cutoff.rawLine + 1}, bounded by ${index.cutoff.by}. Claude cannot see anything below it.` }));
37
+ for (const entry of dropped.slice(0, 20)) {
38
+ box.append(node('div', { class: ui.issue(true) }, [
39
+ node('div', { class: ui.issueBody }, [
40
+ node('div', { class: ui.issueTitle, text: entry.title || entry.file }),
41
+ node('div', { class: ui.issueDetail, text: `MEMORY.md line ${entry.index + 1} · ${entry.file}` }),
42
+ ]),
43
+ node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(entry.file) }),
44
+ node('button', { class: ui.buttonPrimarySmall, text: 'Move up', onclick: () => openMoveDialog(entry) }),
45
+ ]));
46
+ }
47
+ if (dropped.length > 20) {
48
+ box.append(node('p', { class: ui.note, text: `${dropped.length - 20} more not listed.` }));
49
+ }
50
+ container.append(box);
51
+ }
52
+
53
+ container.append(node('div', { class: ui.meter }, [
54
+ node('div', { class: ui.meterTop }, [
55
+ node('span', { class: ui.meterValue, text: `${Math.round(index.worstPercent)}%` }),
56
+ node('span', { class: ui.meterUnit, text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
57
+ ]),
58
+ node('div', { class: ui.meterBar }, [
59
+ node('div', { class: ui.meterFill(level), style: `width:${percent.toFixed(1)}%` }),
60
+ ]),
61
+ node('p', { class: ui.meterNote, text: note }),
62
+ node('div', { class: ui.meterFacts }, [
63
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
64
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
65
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: String(index.entryCount) }), document.createTextNode(' index entries')]),
66
+ node('span', {}, [node('b', { class: ui.meterFactValue, text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
67
+ ]),
68
+ node('p', { class: ui.meterNote, text: 'Claude Code loads the first 200 lines or 25KB of MEMORY.md, whichever comes first, and drops the rest. Topic files are only read when Claude follows a link, so detail belongs in them and MEMORY.md should stay one line per entry.' }),
69
+ ]));
70
+ }
71
+
72
+ export function renderPrune(container) {
73
+ const project = state.store;
74
+ renderBudget(container);
75
+
76
+ if (!project.memories.length) {
77
+ container.append(node('p', { class: ui.note, text: 'Nothing to prune - this project has no memories.' }));
78
+ return;
79
+ }
80
+
81
+ const selected = state.pruneSelection;
82
+ const bar = node('div', { class: ui.pruneBar });
83
+ const sort = node('select', { class: ui.select });
84
+ for (const [key, config] of Object.entries(PRUNE_SORTS)) {
85
+ sort.append(node('option', { value: key, text: config.label, selected: key === state.pruneSort ? '' : null }));
86
+ }
87
+ sort.value = state.pruneSort;
88
+ sort.onchange = () => { state.pruneSort = sort.value; paint('tab'); };
89
+
90
+ const count = node('span', { class: ui.note });
91
+ const deleteButton = node('button', { class: ui.buttonDangerSmall });
92
+ const clearButton = node('button', { class: ui.buttonSmall, text: 'Clear selection' });
93
+
94
+ bar.append(node('span', { class: ui.sectionLabel, text: 'Prune' }), sort, node('span', { class: ui.pruneSpacer }), count, clearButton, deleteButton);
95
+ container.append(bar);
96
+
97
+ const list = node('div');
98
+ const ordered = [...project.memories].sort(PRUNE_SORTS[state.pruneSort].compare);
99
+ const rows = [];
100
+ for (const memory of ordered) {
101
+ const box = node('input', { type: 'checkbox', class: ui.checkbox });
102
+ box.checked = selected.has(memory.file);
103
+ const row = node('label', { class: ui.pruneRow(box.checked) }, [
104
+ box,
105
+ node('span', { class: ui.pruneMain }, [
106
+ node('span', { class: ui.pruneName, text: memory.name }),
107
+ node('span', { class: ui.pruneDesc, text: memory.description || memory.file }),
108
+ ]),
109
+ node('span', { class: ui.pruneFacts }, [
110
+ node('span', { class: ui.pruneFact((memory.ageDays ?? 0) > 180), text: formatAge(memory.ageDays) }),
111
+ node('span', { text: `${(memory.bytes / 1024).toFixed(1)}K` }),
112
+ node('span', { class: ui.pruneFact(!memory.inbound.length), text: `${memory.inbound.length} in` }),
113
+ node('span', { class: ui.pruneFact(memory.status !== 'indexed'), text: memory.status }),
114
+ ]),
115
+ ]);
116
+ box.addEventListener('change', () => {
117
+ if (box.checked) selected.add(memory.file); else selected.delete(memory.file);
118
+ row.className = ui.pruneRow(box.checked);
119
+ updateBar();
120
+ });
121
+ rows.push({ box, memory });
122
+ list.append(row);
123
+ }
124
+ container.append(list);
125
+
126
+ function updateBar() {
127
+ count.textContent = selected.size ? `${selected.size} selected` : '';
128
+ deleteButton.textContent = `Delete ${selected.size || ''}`.trim();
129
+ deleteButton.disabled = selected.size === 0;
130
+ }
131
+ clearButton.onclick = () => {
132
+ selected.clear();
133
+ paint('tab');
134
+ };
135
+ deleteButton.onclick = () => openBulkDeleteDialog([...selected]);
136
+ updateBar();
137
+
138
+ const { longHooks } = project.stats.index;
139
+ if (longHooks.length) {
140
+ container.append(node('div', { class: ui.sectionLabel, text: `Long index hooks · ${longHooks.length}` }));
141
+ container.append(node('p', { class: ui.noteTight, text: 'The hook is the text after the dash in MEMORY.md. It is loaded every session, so a 400-character hook costs more than the memory it points at. Shortening these is the cheapest win.' }));
142
+ for (const hook of longHooks.slice(0, 12)) {
143
+ const row = node('div', { class: ui.issue(false) });
144
+ const editButton = node('button', { class: ui.buttonPrimarySmall, text: 'Edit hook' });
145
+ row.append(
146
+ node('div', { class: ui.issueBody }, [
147
+ node('div', { class: ui.issueTitle, text: `${hook.hookLength} chars: ${hook.title}` }),
148
+ node('div', { class: ui.issueDetail, text: `MEMORY.md line ${hook.index + 1} · ${hook.file}` }),
149
+ ]),
150
+ node('button', { class: ui.buttonSmall, text: 'Open', onclick: () => selectMemory(hook.file) }),
151
+ editButton,
152
+ );
153
+ editButton.onclick = () => openHookEditor(row, hook.index);
154
+ container.append(row);
155
+ }
156
+ }
157
+
158
+ if (project.duplicates.length) {
159
+ container.append(node('div', { class: ui.sectionLabel, text: `Possible overlap · ${project.duplicates.length}` }));
160
+ container.append(node('p', { class: ui.noteTight, text: 'Ranked by how much rare vocabulary these pairs share. This is a hint, not a verdict - open both and decide whether one covers the other.' }));
161
+ for (const pair of project.duplicates) {
162
+ container.append(node('div', { class: ui.dupe }, [
163
+ node('div', { class: ui.dupeHead }, [
164
+ node('span', { class: ui.dupeScore, text: `${pair.score}%` }),
165
+ node('span', { text: `shared: ${pair.shared.join(', ')}` }),
166
+ ]),
167
+ node('div', { class: ui.dupePair }, [
168
+ ...[pair.a, pair.b].map((side) => node('div', {
169
+ class: ui.dupeSide,
170
+ onclick: () => selectMemory(side.file),
171
+ }, [
172
+ node('div', { class: ui.dupeSideName, text: side.name }),
173
+ node('div', { class: ui.dupeSideDesc, text: side.description || side.file }),
174
+ ])),
175
+ ]),
176
+ node('div', { class: ui.dupeActions }, [
177
+ node('button', {
178
+ class: ui.buttonSmall,
179
+ text: `Merge into ${pair.a.name}`,
180
+ onclick: () => openMergeDialog(pair.a.file, pair.b.file),
181
+ }),
182
+ node('button', {
183
+ class: ui.buttonSmall,
184
+ text: `Merge into ${pair.b.name}`,
185
+ onclick: () => openMergeDialog(pair.b.file, pair.a.file),
186
+ }),
187
+ ]),
188
+ ]));
189
+ }
190
+ }
191
+ }
@@ -0,0 +1,101 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { el, node } from '/dom.mjs';
3
+ import { api, toast } from '/api.mjs';
4
+ import { state } from '/state.mjs';
5
+ import { paint } from '/bus.mjs';
6
+ import { highlight } from '/parts.mjs';
7
+ import { openStore } from '/store.mjs';
8
+
9
+ let searchTimer = null;
10
+
11
+ export function scheduleSearch(value) {
12
+ state.query = value;
13
+ clearTimeout(searchTimer);
14
+ if (!value.trim()) {
15
+ state.search = null;
16
+ paint('view');
17
+ return;
18
+ }
19
+ searchTimer = setTimeout(runSearch, 180);
20
+ }
21
+
22
+ async function runSearch() {
23
+ const query = state.query.trim();
24
+ if (!query) return;
25
+ try {
26
+ state.search = await api(`/api/search?q=${encodeURIComponent(query)}`);
27
+ } catch (err) {
28
+ return toast(err.message, { error: true });
29
+ }
30
+ paint('view');
31
+ }
32
+
33
+ export function renderSearch() {
34
+ const data = state.search;
35
+ const host = el('search-results');
36
+ host.textContent = '';
37
+ el('search-title').textContent = data.total
38
+ ? `${data.total} result${data.total === 1 ? '' : 's'} for "${state.query.trim()}"`
39
+ : `No results for "${state.query.trim()}"`;
40
+
41
+ if (!data.total && !data.stores.length) {
42
+ host.append(node('p', { class: ui.note, text: 'Nothing matched. Every word has to appear somewhere in a memory, so try fewer words.' }));
43
+ return;
44
+ }
45
+
46
+ for (const group of data.stores) {
47
+ const block = node('div', { class: ui.resultGroup });
48
+ block.append(node('div', { class: ui.sectionLabel, text: group.sublabel
49
+ ? `${group.label} · ${group.sublabel} · ${group.results.length}`
50
+ : `${group.label} · ${group.results.length}` }));
51
+
52
+ for (const result of group.results) {
53
+ const button = node('button', {
54
+ class: ui.result,
55
+ onclick: () => openFromSearch(group.id, result.file),
56
+ });
57
+ const top = node('div', { class: ui.resultTop });
58
+ const name = node('span', { class: ui.resultName });
59
+ name.append(highlight(result.name, data.terms));
60
+ top.append(name, node('span', { class: ui.typeBadge(result.type), text: result.type }));
61
+ if (result.status !== 'indexed') top.append(node('span', { class: ui.badge('warn'), text: result.status }));
62
+ button.append(top);
63
+
64
+ if (result.snippet) {
65
+ const snippet = node('div', { class: ui.resultSnippet });
66
+ snippet.append(highlight(result.snippet.text, data.terms));
67
+ button.append(snippet);
68
+ }
69
+ button.append(node('div', { class: ui.resultWhere, text: `${result.file} · matched in ${result.fields.join(', ')}` }));
70
+ block.append(button);
71
+ }
72
+
73
+ for (const hit of group.indexHits) {
74
+ const button = node('button', {
75
+ class: ui.result,
76
+ onclick: () => openFromSearch(group.id, hit.file, 'index'),
77
+ });
78
+ button.append(node('div', { class: ui.resultWhere, text: `MEMORY.md line ${hit.index + 1}` }));
79
+ const line = node('div', { class: ui.resultSnippet });
80
+ line.append(highlight(hit.text.trim(), data.terms));
81
+ button.append(line);
82
+ block.append(button);
83
+ }
84
+ host.append(block);
85
+ }
86
+ }
87
+
88
+ async function openFromSearch(id, file, tab = 'memories') {
89
+ clearSearch();
90
+ if (id !== state.storeId) await openStore(id);
91
+ state.tab = tab;
92
+ if (file) state.selected = file;
93
+ paint('tabs', 'tab');
94
+ }
95
+
96
+ export function clearSearch() {
97
+ state.query = '';
98
+ state.search = null;
99
+ el('search').value = '';
100
+ paint('view');
101
+ }