claude-memory-admin 1.5.2 → 1.7.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.
@@ -3,10 +3,11 @@ import { node } from '/dom.mjs';
3
3
  import { confirmDialog } from '/dialog.mjs';
4
4
  import { api, toast } from '/api.mjs';
5
5
  import { state } from '/state.mjs';
6
- import { issue, goToTab } from '/parts.mjs';
6
+ import { issue, goTo } from '/parts.mjs';
7
7
  import { openStore, selectMemory, restoreFromTrash } from '/store.mjs';
8
8
  import { openDeleteDialog } from '/dialogs/delete.mjs';
9
9
  import { openAddEntryDialog } from '/dialogs/add-entry.mjs';
10
+ import { openHookEditor } from '/dialogs/hook-editor.mjs';
10
11
 
11
12
  async function removeIndexLine(lineIndex, expectedText, done) {
12
13
  const go = await confirmDialog({
@@ -138,17 +139,6 @@ export function renderIssue(item, memories) {
138
139
  );
139
140
  }
140
141
 
141
- if (item.kind === 'long-hooks') {
142
- return issue(
143
- `${item.count} index hook${item.count === 1 ? '' : 's'} over 200 characters`,
144
- `longest is ${item.longest.hookLength} chars on "${item.longest.title}" - hooks load every session, so these are the cheapest thing to trim.`,
145
- {
146
- bad,
147
- action: { label: 'Open Prune', run: () => goToTab('prune') },
148
- },
149
- );
150
- }
151
-
152
142
  if (item.kind === 'duplicate-name') {
153
143
  return issue(
154
144
  `Two memories claim the name "${item.name}"`,
@@ -205,22 +195,24 @@ export function renderIssue(item, memories) {
205
195
  }
206
196
 
207
197
  if (item.kind === 'hook-repeats-description') {
208
- return issue(
198
+ const row = issue(
209
199
  `Hook repeats the description: ${item.file}`,
210
200
  `MEMORY.md line ${item.index + 1} - ${item.hookLength} characters restating what the file's own description already says. Only the hook loads every session.`,
211
- {
212
- bad,
213
- secondary: { label: 'Open', run: () => selectMemory(item.file) },
214
- action: { label: 'Open Prune', run: () => goToTab('prune') },
215
- },
201
+ { bad, secondary: { label: 'Open', run: () => selectMemory(item.file) } },
216
202
  );
203
+ row.append(node('button', {
204
+ class: ui.buttonPrimarySmall,
205
+ text: 'Edit hook',
206
+ onclick: () => openHookEditor(row, item.index),
207
+ }));
208
+ return row;
217
209
  }
218
210
 
219
211
  if (item.kind === 'empty-section') {
220
212
  return issue(
221
213
  `Empty section in MEMORY.md: "${item.section}"`,
222
214
  `line ${item.index + 1} - the heading has no entries under it, and headings count against the 200-line budget too.`,
223
- { bad, action: { label: 'Open MEMORY.md', run: () => goToTab('index') } },
215
+ { bad, action: { label: 'Open MEMORY.md', run: () => goTo('memory', 'index') } },
224
216
  );
225
217
  }
226
218
 
@@ -236,7 +228,7 @@ export function renderIssue(item, memories) {
236
228
  return issue(
237
229
  `${item.count} index ${item.count === 1 ? 'entry spills' : 'entries spill'} onto extra lines`,
238
230
  `${item.extraLines} extra line${item.extraLines === 1 ? '' : 's'} of the 200-line budget - the guidance is one line per entry, with the detail in the topic file.`,
239
- { bad, action: { label: 'Open MEMORY.md', run: () => goToTab('index') } },
231
+ { bad, action: { label: 'Open MEMORY.md', run: () => goTo('memory', 'index') } },
240
232
  );
241
233
  }
242
234
 
@@ -3,8 +3,9 @@ import { el, node } from '/dom.mjs';
3
3
  import { renderMarkdown } from '/markdown.mjs';
4
4
  import { state } from '/state.mjs';
5
5
  import { selectMemory, loadSessionsForProvenance } from '/store.mjs';
6
- import { goToTab, sessionDate } from '/parts.mjs';
6
+ import { goTo, sessionDate } from '/parts.mjs';
7
7
  import { openDeleteDialog } from '/dialogs/delete.mjs';
8
+ import { renderMemoryList } from '/views/memory-list.mjs';
8
9
 
9
10
  export function memoryLookup(project) {
10
11
  const byName = new Map();
@@ -26,25 +27,6 @@ export function renderBody(container, memory, project) {
26
27
  });
27
28
  }
28
29
 
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
30
  export function renderMemories(container) {
49
31
  const project = state.store;
50
32
  if (!project.memories.length) {
@@ -53,16 +35,7 @@ export function renderMemories(container) {
53
35
  }
54
36
 
55
37
  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
- }
38
+ renderMemoryList(left);
66
39
 
67
40
  const right = node('div', { id: 'detail', class: ui.detailPane });
68
41
  container.append(node('div', { class: ui.split }, [left, right]));
@@ -165,8 +138,8 @@ function provenanceLine(memory) {
165
138
  row.append(node('button', {
166
139
  class: ui.provenanceLink(true),
167
140
  text: label,
168
- title: 'Open the Sessions tab',
169
- onclick: () => goToTab('sessions'),
141
+ title: 'Open Environment \u203a Sessions',
142
+ onclick: () => goTo('environment', 'sessions'),
170
143
  }));
171
144
  if (known?.gitBranch) row.append(node('span', { text: `on ${known.gitBranch}` }));
172
145
  if (origin.modified) row.append(node('span', { text: sessionDate(origin.modified).slice(0, 10) }));
@@ -0,0 +1,121 @@
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
+
9
+ const SORTS = {
10
+ section: { label: 'by section', compare: null },
11
+ oldest: { label: 'oldest first', compare: (a, b) => (b.ageDays ?? -1) - (a.ageDays ?? -1) },
12
+ largest: { label: 'largest first', compare: (a, b) => b.bytes - a.bytes },
13
+ unlinked: { label: 'least linked', compare: (a, b) => a.inbound.length - b.inbound.length || (b.ageDays ?? 0) - (a.ageDays ?? 0) },
14
+ name: { label: 'by name', compare: (a, b) => a.name.localeCompare(b.name) },
15
+ };
16
+
17
+ function memoryButton(memory) {
18
+ const active = state.selected === memory.file;
19
+ return node('button', {
20
+ class: ui.memoryItem(active),
21
+ 'data-memory': memory.file,
22
+ 'data-active': active ? '' : null,
23
+ onclick: () => selectMemory(memory.file),
24
+ }, [
25
+ node('span', { class: ui.memoryTop }, [
26
+ node('span', { class: ui.memoryName, text: memory.name }),
27
+ node('span', { class: ui.typeBadge(memory.type), text: memory.type }),
28
+ memory.status !== 'indexed'
29
+ ? node('span', { class: ui.badge('warn'), text: memory.status })
30
+ : null,
31
+ ]),
32
+ memory.description ? node('span', { class: ui.memoryDesc, text: memory.description }) : null,
33
+ node('span', { class: ui.memoryFacts }, [
34
+ node('span', { class: ui.memoryFact((memory.ageDays ?? 0) > 180), text: formatAge(memory.ageDays) }),
35
+ node('span', { text: `${(memory.bytes / 1024).toFixed(1)}K` }),
36
+ node('span', { class: ui.memoryFact(!memory.inbound.length), text: `${memory.inbound.length} in` }),
37
+ ]),
38
+ ]);
39
+ }
40
+
41
+ function memoryRow(memory, onToggle) {
42
+ const row = node('div', { class: ui.listItemRow });
43
+ if (state.selecting) {
44
+ const box = node('input', { type: 'checkbox', class: ui.checkbox, 'aria-label': `Select ${memory.name}` });
45
+ box.checked = state.listSelection.has(memory.file);
46
+ box.addEventListener('change', () => {
47
+ if (box.checked) state.listSelection.add(memory.file);
48
+ else state.listSelection.delete(memory.file);
49
+ onToggle();
50
+ });
51
+ row.append(box);
52
+ }
53
+ row.append(memoryButton(memory));
54
+ return row;
55
+ }
56
+
57
+ function setSort(value) {
58
+ state.listSort = value;
59
+ localStorage.setItem('memoryListSort', value);
60
+ paint('tab');
61
+ }
62
+
63
+ function toolbar() {
64
+ const bar = node('div', { class: ui.listBar });
65
+ const sort = node('select', { class: ui.select, 'aria-label': 'Sort memories' });
66
+ for (const [key, config] of Object.entries(SORTS)) {
67
+ sort.append(node('option', { value: key, text: config.label }));
68
+ }
69
+ sort.value = state.listSort;
70
+ sort.onchange = () => setSort(sort.value);
71
+
72
+ const count = node('span', { class: ui.note });
73
+ const deleteButton = node('button', { class: ui.buttonDangerSmall });
74
+ const selectButton = node('button', {
75
+ class: ui.buttonSmall,
76
+ text: state.selecting ? 'Done' : 'Select',
77
+ onclick: () => {
78
+ state.selecting = !state.selecting;
79
+ if (!state.selecting) state.listSelection.clear();
80
+ paint('tab');
81
+ },
82
+ });
83
+
84
+ bar.append(sort, node('span', { class: ui.listSpacer }), count, selectButton);
85
+ if (state.selecting) bar.append(deleteButton);
86
+ deleteButton.onclick = () => openBulkDeleteDialog([...state.listSelection]);
87
+
88
+ const update = () => {
89
+ const size = state.listSelection.size;
90
+ count.textContent = size ? `${size} selected` : '';
91
+ deleteButton.textContent = `Delete ${size || ''}`.trim();
92
+ deleteButton.disabled = size === 0;
93
+ };
94
+ update();
95
+ return { bar, update };
96
+ }
97
+
98
+ export function renderMemoryList(container) {
99
+ const project = state.store;
100
+ const { bar, update } = toolbar();
101
+ container.append(bar);
102
+
103
+ const compare = SORTS[state.listSort].compare;
104
+ if (!compare) {
105
+ const groups = new Map();
106
+ for (const memory of project.memories) {
107
+ const key = memory.section || (memory.status === 'indexed' ? 'Index' : 'Not in the index');
108
+ if (!groups.has(key)) groups.set(key, []);
109
+ groups.get(key).push(memory);
110
+ }
111
+ for (const [section, memories] of groups) {
112
+ container.append(node('div', { class: ui.sectionLabel, text: `${section} · ${memories.length}` }));
113
+ for (const memory of memories) container.append(memoryRow(memory, update));
114
+ }
115
+ return;
116
+ }
117
+
118
+ for (const memory of [...project.memories].sort(compare)) {
119
+ container.append(memoryRow(memory, update));
120
+ }
121
+ }
@@ -0,0 +1,32 @@
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 { renderSegmented } from '/views/segments.mjs';
6
+ import { renderMemories } from '/views/memories.mjs';
7
+ import { renderIndex } from '/views/index.mjs';
8
+ import { renderGraph } from '/graph.mjs';
9
+ import { selectMemory } from '/store.mjs';
10
+
11
+ function renderMemoryGraph(container) {
12
+ const wrap = node('div', { id: 'graph-wrap', class: ui.graphWrap });
13
+ container.append(wrap);
14
+ renderGraph(wrap, state.store.graph, {
15
+ selected: state.selected,
16
+ spread: state.spread,
17
+ onSelect: (file) => selectMemory(file),
18
+ onSpreadChange: (value) => {
19
+ state.spread = value;
20
+ localStorage.setItem('graphSpread', String(value));
21
+ paint('tab');
22
+ },
23
+ });
24
+ }
25
+
26
+ export function renderMemory(container) {
27
+ return renderSegmented(container, 'memory', {
28
+ list: renderMemories,
29
+ index: renderIndex,
30
+ graph: renderMemoryGraph,
31
+ });
32
+ }
@@ -2,6 +2,7 @@ import * as ui from '/ui.mjs';
2
2
  import { node } from '/dom.mjs';
3
3
  import { api } from '/api.mjs';
4
4
  import { state } from '/state.mjs';
5
+ import { isAt } from '/parts.mjs';
5
6
  import { paint } from '/bus.mjs';
6
7
  import { renderIssue } from '/views/issue.mjs';
7
8
 
@@ -23,7 +24,7 @@ async function fetchPathCheck() {
23
24
  }
24
25
  if (state.storeId !== id) return;
25
26
  state.pathCheck = next;
26
- if (state.tab === 'health') paint('tab');
27
+ if (isAt('cleanup')) paint('tab');
27
28
  }
28
29
 
29
30
  function startPathCheck({ remember = true } = {}) {
@@ -0,0 +1,148 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+
4
+ const compact = (value) => {
5
+ const rounded = Math.round(value);
6
+ if (Math.abs(rounded) >= 1000000) return `${(rounded / 1000000).toFixed(1)}M`;
7
+ if (Math.abs(rounded) >= 1000) return `${(rounded / 1000).toFixed(1)}K`;
8
+ return String(rounded);
9
+ };
10
+
11
+ const clamp = (percent) => Math.min(100, Math.max(0, percent));
12
+
13
+ const fact = (value, label) => node('span', {}, [
14
+ node('b', { class: ui.meterFactValue, text: value }),
15
+ document.createTextNode(` ${label}`),
16
+ ]);
17
+
18
+ function meter(percent, unit, note, facts) {
19
+ return node('div', { class: ui.meter }, [
20
+ node('div', { class: ui.meterTop }, [
21
+ node('span', { class: ui.meterValue, text: `${percent.toFixed(1)}%` }),
22
+ node('span', { class: ui.meterUnit, text: unit }),
23
+ ]),
24
+ node('div', { class: ui.meterBar }, [
25
+ node('div', { class: ui.meterFill('accent'), style: `width:${clamp(percent).toFixed(1)}%` }),
26
+ ]),
27
+ note ? node('p', { class: ui.meterNote, text: note }) : null,
28
+ facts.length ? node('div', { class: ui.meterFacts }, facts) : null,
29
+ ]);
30
+ }
31
+
32
+ function head(report, actions) {
33
+ return node('div', { class: ui.sectionLabelRow }, [
34
+ node('span', { class: ui.sectionLabelInline, text: report.version ? `rtk ${report.version}` : 'rtk' }),
35
+ node('span', { class: ui.note, text: report.path || '' }),
36
+ node('span', { class: ui.listSpacer }),
37
+ node('button', { class: ui.buttonSmall, text: 'Re-read', onclick: actions.reread }),
38
+ node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: actions.turnOff }),
39
+ ]);
40
+ }
41
+
42
+ function projectSection(report) {
43
+ const project = report.project;
44
+ if (!project) return [];
45
+ if (!project.commands) {
46
+ return [
47
+ node('div', { class: ui.sectionLabel, text: 'This project' }),
48
+ node('p', { class: ui.noteTight, text: `rtk has filtered nothing in ${report.projectDir} yet.` }),
49
+ ];
50
+ }
51
+ return [node('div', { class: ui.sectionLabel, text: 'This project' }), meter(
52
+ project.savedPct,
53
+ 'of what rtk read in this project never reached the model',
54
+ 'rtk scopes its ledger by working directory, so this covers every command run from this project path.',
55
+ [
56
+ fact(compact(project.saved), 'tokens saved'),
57
+ fact(compact(project.commands), 'commands filtered'),
58
+ fact(`${compact(project.input)} → ${compact(project.output)}`, 'in / out'),
59
+ fact(`${compact(project.avgMs)}ms`, 'average'),
60
+ ],
61
+ )];
62
+ }
63
+
64
+ function machineMeter(report) {
65
+ const machine = report.machine;
66
+ if (!machine) return null;
67
+ const window = machine.window;
68
+ const facts = [
69
+ fact(compact(machine.saved), 'tokens saved'),
70
+ fact(compact(machine.commands), 'commands filtered'),
71
+ fact(`${compact(machine.avgMs)}ms`, 'average'),
72
+ ];
73
+ if (window && window.commands) {
74
+ facts.push(fact(`${compact(window.saved)} (${window.savedPct.toFixed(1)}%)`, `saved in the last ${window.days} days`));
75
+ }
76
+ return meter(
77
+ machine.savedPct,
78
+ 'of everything rtk has ever filtered on this machine',
79
+ null,
80
+ facts,
81
+ );
82
+ }
83
+
84
+ function missedRow(item) {
85
+ const saving = `${compact(item.savedTokens)} tokens (${item.savedPct.toFixed(0)}%)`;
86
+ return node('div', { class: ui.issue(false) }, [
87
+ node('div', { class: ui.issueBody }, [
88
+ node('div', { class: ui.issueTitle, text: `${item.command} ran ${item.count.toLocaleString()} times unfiltered` }),
89
+ node('div', { class: ui.issueDetail, text: `${item.equivalent} would have saved about ${saving}` }),
90
+ ]),
91
+ node('span', { class: ui.badge('neutral'), text: item.category || 'Other' }),
92
+ ]);
93
+ }
94
+
95
+ function discoverSection(report) {
96
+ const discover = report.discover;
97
+ if (!discover || !discover.commands) return [];
98
+
99
+ const parts = [
100
+ node('div', { class: ui.sectionLabel, text: 'Left on the table' }),
101
+ meter(
102
+ discover.adoptionPct,
103
+ `of the commands in the last ${discover.sinceDays} days already went through rtk`,
104
+ `Counted across ${discover.sessionsScanned.toLocaleString()} session transcripts for this project.`,
105
+ [
106
+ fact(discover.alreadyRtk.toLocaleString(), 'already filtered'),
107
+ fact(discover.commands.toLocaleString(), 'commands seen'),
108
+ fact(discover.sessionsScanned.toLocaleString(), 'sessions scanned'),
109
+ ],
110
+ ),
111
+ ];
112
+
113
+ if (!discover.missed.length) {
114
+ parts.push(node('p', { class: ui.okLine, text: 'Nothing rtk knows how to filter was run raw.' }));
115
+ return parts;
116
+ }
117
+
118
+ for (const item of discover.missed) parts.push(missedRow(item));
119
+ const hidden = discover.missedTotal - discover.missed.length;
120
+ const left = hidden === 1 ? '1 smaller one is not shown' : `${hidden} smaller ones are not shown`;
121
+ parts.push(node('p', { class: ui.note, text: hidden > 0
122
+ ? `Estimates from rtk itself, worst first; ${left}. They count what the filter would have stripped, not what the model would have ignored.`
123
+ : 'Estimates from rtk itself, worst first. They count what the filter would have stripped, not what the model would have ignored.' }));
124
+ return parts;
125
+ }
126
+
127
+ function errorNotes(report) {
128
+ if (!report.errors.length) return [];
129
+ return [
130
+ node('div', { class: ui.sectionLabel, text: 'Not read' }),
131
+ ...report.errors.map((error) => node('p', { class: ui.noteTight, text: `${error.step}: ${error.message}` })),
132
+ ];
133
+ }
134
+
135
+ export function renderRtk(container, report, actions) {
136
+ container.append(head(report, actions));
137
+ for (const part of projectSection(report)) container.append(part);
138
+
139
+ container.append(node('div', { class: ui.sectionLabel, text: report.project ? 'Every project' : 'This machine' }));
140
+ const machine = machineMeter(report);
141
+ container.append(machine || node('p', { class: ui.noteTight, text: 'The machine-wide ledger could not be read.' }));
142
+ if (!report.project && !report.projectDir) {
143
+ container.append(node('p', { class: ui.note, text: 'This entry is the user scope rather than a project, so rtk has no working directory to slice its ledger by.' }));
144
+ }
145
+
146
+ for (const part of discoverSection(report)) container.append(part);
147
+ for (const part of errorNotes(report)) container.append(part);
148
+ }
@@ -3,7 +3,7 @@ import { el, node } from '/dom.mjs';
3
3
  import { api, toast } from '/api.mjs';
4
4
  import { state } from '/state.mjs';
5
5
  import { paint } from '/bus.mjs';
6
- import { highlight } from '/parts.mjs';
6
+ import { highlight, goTo } from '/parts.mjs';
7
7
  import { openStore } from '/store.mjs';
8
8
 
9
9
  let searchTimer = null;
@@ -52,7 +52,7 @@ export function renderSearch() {
52
52
  for (const result of group.results) {
53
53
  const button = node('button', {
54
54
  class: ui.result,
55
- onclick: () => openFromSearch(group.id, result.file),
55
+ onclick: () => openFromSearch(group.id, result.file, 'list'),
56
56
  });
57
57
  const top = node('div', { class: ui.resultTop });
58
58
  const name = node('span', { class: ui.resultName });
@@ -85,12 +85,11 @@ export function renderSearch() {
85
85
  }
86
86
  }
87
87
 
88
- async function openFromSearch(id, file, tab = 'memories') {
88
+ async function openFromSearch(id, file, segment = 'list') {
89
89
  clearSearch();
90
90
  if (id !== state.storeId) await openStore(id);
91
- state.tab = tab;
92
91
  if (file) state.selected = file;
93
- paint('tabs', 'tab');
92
+ goTo('memory', segment);
94
93
  }
95
94
 
96
95
  export function clearSearch() {
@@ -0,0 +1,37 @@
1
+ import * as ui from '/ui.mjs';
2
+ import { node } from '/dom.mjs';
3
+ import { state } from '/state.mjs';
4
+ import { goTo } from '/parts.mjs';
5
+ import { segmentsFor } from '/views/header.mjs';
6
+
7
+ export function segmentBar(tab) {
8
+ const segments = segmentsFor(tab);
9
+ if (segments.length < 2) return null;
10
+ return node('div', { class: ui.segmentBar }, [
11
+ node('div', { class: ui.segmentGroup, role: 'tablist' }, segments.map((seg) => node('button', {
12
+ class: ui.segment(state.segment[tab] === seg.id),
13
+ role: 'tab',
14
+ 'aria-selected': String(state.segment[tab] === seg.id),
15
+ onclick: () => goTo(tab, seg.id),
16
+ }, [
17
+ document.createTextNode(seg.label),
18
+ seg.badge ? node('span', { class: ui.tabBadge(seg.tone) }, [document.createTextNode(seg.badge)]) : null,
19
+ ]))),
20
+ ]);
21
+ }
22
+
23
+ export function activeSegment(tab) {
24
+ const segments = segmentsFor(tab);
25
+ if (!segments.length) return null;
26
+ const current = state.segment[tab];
27
+ return segments.some((seg) => seg.id === current) ? current : segments[0].id;
28
+ }
29
+
30
+ export function renderSegmented(container, tab, views) {
31
+ const bar = segmentBar(tab);
32
+ if (bar) container.append(bar);
33
+ const host = node('div');
34
+ container.append(host);
35
+ const segment = activeSegment(tab);
36
+ return views[segment] ? views[segment](host) : null;
37
+ }
@@ -2,7 +2,7 @@ import * as ui from '/ui.mjs';
2
2
  import { node } from '/dom.mjs';
3
3
  import { api } from '/api.mjs';
4
4
  import { state } from '/state.mjs';
5
- import { sizeLabel, sessionDate } from '/parts.mjs';
5
+ import { sizeLabel, sessionDate, isAt } from '/parts.mjs';
6
6
  import { selectMemory } from '/store.mjs';
7
7
  import { paint } from '/bus.mjs';
8
8
 
@@ -170,7 +170,7 @@ export async function renderSessions(container) {
170
170
  container.textContent = '';
171
171
  return container.append(node('p', { class: ui.note, text: err.message }));
172
172
  }
173
- if (state.tab !== 'sessions') return;
173
+ if (!isAt('environment', 'sessions')) return;
174
174
  state.aux.sessions = data;
175
175
  }
176
176
  container.textContent = '';
@@ -2,6 +2,7 @@ import * as ui from '/ui.mjs';
2
2
  import { node } from '/dom.mjs';
3
3
  import { api } from '/api.mjs';
4
4
  import { state } from '/state.mjs';
5
+ import { isAt } from '/parts.mjs';
5
6
 
6
7
  const SETTINGS_PROBLEMS = {
7
8
  unparseable: (p) => [
@@ -34,7 +35,7 @@ export async function renderSettings(container) {
34
35
  container.textContent = '';
35
36
  return container.append(node('p', { class: ui.note, text: err.message }));
36
37
  }
37
- if (state.tab !== 'settings') return;
38
+ if (!isAt('environment', 'settings')) return;
38
39
  }
39
40
  container.textContent = '';
40
41
 
@@ -13,9 +13,9 @@ function storeSubtitle(store) {
13
13
 
14
14
  function issueTitle(store) {
15
15
  const parts = [];
16
- if (store.issueCount) parts.push(`${store.issueCount} in Health`);
17
- if (store.context) parts.push(`${store.context} in Context`);
18
- if (store.settings) parts.push(`${store.settings} in Settings`);
16
+ if (store.issueCount) parts.push(`${store.issueCount} to fix in Cleanup`);
17
+ const environment = (store.context || 0) + (store.settings || 0);
18
+ if (environment) parts.push(`${environment} in Environment`);
19
19
  if (store.kind === 'auto' && store.resolvedBy === 'unresolved') {
20
20
  parts.push('Real path could not be resolved - showing the raw folder name');
21
21
  }
@@ -0,0 +1,76 @@
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 { isAt } from '/parts.mjs';
6
+ import { paint } from '/bus.mjs';
7
+ import { renderRtk } from '/views/rtk.mjs';
8
+
9
+ const OPT_IN = 'toolsOptIn';
10
+
11
+ const installedRtk = () => state.tools.find((tool) => tool.id === 'rtk' && tool.found) || null;
12
+
13
+ async function fetchRtk() {
14
+ const id = state.storeId;
15
+ let next;
16
+ try {
17
+ next = { data: await api(`/api/stores/${encodeURIComponent(id)}/tools/rtk`) };
18
+ } catch (err) {
19
+ next = { error: err.message };
20
+ }
21
+ if (state.storeId !== id) return;
22
+ state.aux.rtk = next;
23
+ if (isAt('environment', 'tools')) paint('tab');
24
+ }
25
+
26
+ function startRtk({ remember = true } = {}) {
27
+ if (remember) localStorage.setItem(OPT_IN, '1');
28
+ state.aux.rtk = { running: true };
29
+ fetchRtk();
30
+ }
31
+
32
+ function readRtk() {
33
+ startRtk();
34
+ paint('tab');
35
+ }
36
+
37
+ function turnOff() {
38
+ localStorage.removeItem(OPT_IN);
39
+ state.aux.rtk = null;
40
+ paint('tab');
41
+ }
42
+
43
+ function gate(container, tool) {
44
+ container.append(node('div', { class: ui.issue(false) }, [
45
+ node('div', { class: ui.issueBody }, [
46
+ node('div', { class: ui.issueTitle, text: 'rtk is installed, and is not being read' }),
47
+ node('div', { class: ui.issueDetail, text: `${tool.path} keeps a ledger of what its filters stripped before it reached the model. Reading it runs rtk on this machine, so it stays off until you ask.` }),
48
+ ]),
49
+ node('button', { class: ui.buttonSmall, text: 'Read rtk stats', onclick: readRtk }),
50
+ ]));
51
+ }
52
+
53
+ function failure(container, message) {
54
+ container.append(node('div', { class: ui.issue(true) }, [
55
+ node('div', { class: ui.issueBody }, [
56
+ node('div', { class: ui.issueTitle, text: 'rtk could not be read' }),
57
+ node('div', { class: ui.issueDetail, text: message }),
58
+ ]),
59
+ node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: turnOff }),
60
+ node('button', { class: ui.buttonSmall, text: 'Try again', onclick: readRtk }),
61
+ ]));
62
+ }
63
+
64
+ export function renderTools(container) {
65
+ const tool = installedRtk();
66
+ if (!tool) return;
67
+
68
+ if (!state.aux.rtk && localStorage.getItem(OPT_IN) === '1') startRtk({ remember: false });
69
+
70
+ const report = state.aux.rtk;
71
+ if (!report) return gate(container, tool);
72
+ if (report.running) return container.append(node('p', { class: ui.note, text: 'Reading the rtk ledger…' }));
73
+ if (report.error) return failure(container, report.error);
74
+
75
+ renderRtk(container, report.data, { reread: readRtk, turnOff });
76
+ }