claude-memory-admin 1.5.2 → 1.6.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/README.md +68 -128
- package/package.json +1 -1
- package/public/app.mjs +13 -34
- package/public/dialog.mjs +7 -0
- package/public/dialogs/bulk-delete.mjs +2 -1
- package/public/dialogs/trash.mjs +53 -0
- package/public/index.html +1 -0
- package/public/parts.mjs +10 -3
- package/public/state.mjs +29 -3
- package/public/store.mjs +11 -6
- package/public/ui.mjs +8 -16
- package/public/views/cleanup.mjs +63 -0
- package/public/views/context.mjs +2 -1
- package/public/views/cost-issue.mjs +70 -0
- package/public/views/environment.mjs +12 -0
- package/public/views/header.mjs +103 -46
- package/public/views/issue.mjs +12 -20
- package/public/views/memories.mjs +5 -32
- package/public/views/memory-list.mjs +121 -0
- package/public/views/memory.mjs +32 -0
- package/public/views/path-check.mjs +2 -1
- package/public/views/search.mjs +4 -5
- package/public/views/segments.mjs +37 -0
- package/public/views/sessions.mjs +2 -2
- package/public/views/settings.mjs +2 -1
- package/public/views/stores.mjs +3 -3
- package/public/views/worklist.mjs +33 -0
- package/public/views/health.mjs +0 -21
- package/public/views/prune.mjs +0 -191
- package/public/views/trash.mjs +0 -37
package/public/ui.mjs
CHANGED
|
@@ -100,7 +100,7 @@ export const sectionLabel = 'mt-5 mb-2 font-mono text-2xs font-semibold uppercas
|
|
|
100
100
|
export const note = 'text-xs text-fg-muted';
|
|
101
101
|
export const noteTight = 'mt-0 mb-2.5 text-xs text-fg-muted';
|
|
102
102
|
|
|
103
|
-
const memoryItemBase = '
|
|
103
|
+
const memoryItemBase = 'block min-w-0 flex-1 cursor-pointer rounded-md border bg-surface px-3 py-2.5 text-left transition-colors';
|
|
104
104
|
const memoryItemState = {
|
|
105
105
|
active: 'border-accent bg-accent-surface',
|
|
106
106
|
idle: 'border-line hover:border-accent-line',
|
|
@@ -109,6 +109,9 @@ export const memoryItem = (active) => cx(memoryItemBase, active ? memoryItemStat
|
|
|
109
109
|
export const memoryTop = 'mb-1 flex items-center gap-2';
|
|
110
110
|
export const memoryName = 'flex-1 truncate text-sm font-medium text-fg';
|
|
111
111
|
export const memoryDesc = 'line-clamp-2 text-xs text-fg-muted';
|
|
112
|
+
export const memoryFacts = 'mt-1.5 flex flex-wrap gap-3 font-mono text-2xs text-fg-muted';
|
|
113
|
+
export const memoryFact = (warn) => (warn ? 'text-warn' : 'text-fg-muted');
|
|
114
|
+
export const listItemRow = 'mb-1.5 flex items-start gap-2';
|
|
112
115
|
|
|
113
116
|
export const card = 'rounded-lg border border-line bg-surface p-5 shadow-card';
|
|
114
117
|
export const detailHead = 'mb-1 flex items-start gap-3';
|
|
@@ -243,9 +246,6 @@ export const issueBody = 'min-w-0 flex-1';
|
|
|
243
246
|
export const issueTitle = 'text-sm font-medium text-fg';
|
|
244
247
|
export const issueDetail = 'font-mono text-2xs break-words text-fg-muted';
|
|
245
248
|
|
|
246
|
-
export const cutSummary = 'mb-4 rounded-lg border border-danger-line bg-danger-surface px-4 py-3.5';
|
|
247
|
-
export const cutSummaryHead = 'mb-1 text-sm font-semibold text-danger';
|
|
248
|
-
|
|
249
249
|
export const meter = 'mb-5 rounded-lg border border-line bg-surface px-5 py-4';
|
|
250
250
|
export const meterTop = 'mb-2.5 flex items-baseline gap-2.5';
|
|
251
251
|
export const meterValue = 'font-mono text-2xl font-semibold tracking-tightest text-fg';
|
|
@@ -257,19 +257,11 @@ export const meterNote = 'mt-2.5 text-xs text-fg-muted';
|
|
|
257
257
|
export const meterFacts = 'mt-3 flex flex-wrap gap-4 border-t border-line pt-3 font-mono text-2xs text-fg-muted';
|
|
258
258
|
export const meterFactValue = 'font-semibold text-fg';
|
|
259
259
|
|
|
260
|
-
export const
|
|
261
|
-
export const
|
|
260
|
+
export const listBar = 'mb-2.5 flex flex-wrap items-center gap-2.5';
|
|
261
|
+
export const listSpacer = 'flex-1';
|
|
262
|
+
export const segmentBar = 'mb-5 flex flex-wrap items-center gap-3';
|
|
262
263
|
export const select = cx('cursor-pointer rounded-md border border-line bg-surface font-mono text-xs text-fg hover:border-line-strong focus:outline-none', controlPad.sm);
|
|
263
264
|
|
|
264
|
-
const pruneRowBase = 'mb-1 flex cursor-pointer items-center gap-2.5 rounded-md border px-3 py-2 transition-colors';
|
|
265
|
-
const pruneRowState = { on: 'border-danger-line bg-danger-surface', off: 'border-line bg-surface hover:border-accent-line' };
|
|
266
|
-
export const pruneRow = (on) => cx(pruneRowBase, on ? pruneRowState.on : pruneRowState.off);
|
|
267
|
-
export const pruneMain = 'min-w-0 flex-1';
|
|
268
|
-
export const pruneName = 'block text-sm font-medium text-fg';
|
|
269
|
-
export const pruneDesc = 'block truncate text-xs text-fg-muted';
|
|
270
|
-
export const pruneFacts = 'flex flex-none gap-3 font-mono text-2xs text-fg-muted';
|
|
271
|
-
export const pruneFact = (warn) => (warn ? 'text-warn' : 'text-fg-muted');
|
|
272
|
-
|
|
273
265
|
export const dupe = 'mb-2 rounded-lg border border-line bg-surface px-3.5 py-3';
|
|
274
266
|
export const dupeHead = 'mb-2 flex items-baseline gap-2 font-mono text-2xs text-fg-muted';
|
|
275
267
|
export const dupeScore = 'font-semibold text-warn';
|
|
@@ -319,7 +311,7 @@ export const cascadeDetail = 'block font-mono text-2xs break-words text-fg-muted
|
|
|
319
311
|
|
|
320
312
|
export const pathInput = cx('my-1 mb-3 w-full rounded-md border border-line bg-surface-sunken font-mono text-xs text-fg focus:border-accent focus:outline-none', controlPad.lg);
|
|
321
313
|
export const textArea = cx('my-1 mb-1.5 block min-h-20 w-full resize-y rounded-md border border-line bg-surface-sunken font-mono text-xs leading-relaxed text-fg focus:border-accent focus:outline-none', controlPad.lg);
|
|
322
|
-
export const hookEditor = 'mb-1.5 rounded-md border border-accent-line bg-surface-sunken px-3 py-2.5';
|
|
314
|
+
export const hookEditor = 'mb-1.5 w-full rounded-md border border-accent-line bg-surface-sunken px-3 py-2.5';
|
|
323
315
|
export const hookEditorFoot = 'flex justify-end gap-2';
|
|
324
316
|
export const charCount = 'mb-3 flex items-baseline justify-between gap-3 font-mono text-2xs text-fg-subtle';
|
|
325
317
|
export const charCountOver = 'mb-3 flex items-baseline justify-between gap-3 font-mono text-2xs text-warn';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as ui from '/ui.mjs';
|
|
2
|
+
import { node } from '/dom.mjs';
|
|
3
|
+
import { state } from '/state.mjs';
|
|
4
|
+
import { buildWorklist } from '/views/worklist.mjs';
|
|
5
|
+
import { renderCostIssue, COST_KINDS } from '/views/cost-issue.mjs';
|
|
6
|
+
import { renderIssue } from '/views/issue.mjs';
|
|
7
|
+
import { renderPathCheck } from '/views/path-check.mjs';
|
|
8
|
+
|
|
9
|
+
const SHOWN = 40;
|
|
10
|
+
|
|
11
|
+
function renderMeter(container) {
|
|
12
|
+
const { index } = state.store.stats;
|
|
13
|
+
const percent = Math.min(100, index.worstPercent);
|
|
14
|
+
|
|
15
|
+
const note = index.level === 'over'
|
|
16
|
+
? 'Over the limit. Everything past the cutoff is dropped when the session loads it, so those entries are effectively invisible to Claude right now.'
|
|
17
|
+
: index.level === 'near'
|
|
18
|
+
? 'Approaching the limit. Once MEMORY.md passes it, everything after the cutoff stops being loaded at all.'
|
|
19
|
+
: 'Comfortably inside the limit. MEMORY.md is loaded at the start of every session.';
|
|
20
|
+
|
|
21
|
+
container.append(node('div', { class: ui.meter }, [
|
|
22
|
+
node('div', { class: ui.meterTop }, [
|
|
23
|
+
node('span', { class: ui.meterValue, text: `${Math.round(index.worstPercent)}%` }),
|
|
24
|
+
node('span', { class: ui.meterUnit, text: `of the MEMORY.md load limit, currently bounded by ${index.limitedBy}` }),
|
|
25
|
+
]),
|
|
26
|
+
node('div', { class: ui.meterBar }, [
|
|
27
|
+
node('div', { class: ui.meterFill(index.level), style: `width:${percent.toFixed(1)}%` }),
|
|
28
|
+
]),
|
|
29
|
+
node('p', { class: ui.meterNote, text: note }),
|
|
30
|
+
node('div', { class: ui.meterFacts }, [
|
|
31
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${index.lines} / ${index.lineLimit}` }), document.createTextNode(' lines')]),
|
|
32
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(index.bytes / 1024).toFixed(1)} / ${index.byteLimit / 1024} KB` }), document.createTextNode(' loaded size')]),
|
|
33
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(index.entryCount) }), document.createTextNode(' index entries')]),
|
|
34
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `~${index.tokens.toLocaleString()}` }), document.createTextNode(' tokens, every session')]),
|
|
35
|
+
]),
|
|
36
|
+
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.' }),
|
|
37
|
+
]));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function renderCleanup(container) {
|
|
41
|
+
const { memories } = state.store;
|
|
42
|
+
renderMeter(container);
|
|
43
|
+
|
|
44
|
+
const items = buildWorklist(state.store);
|
|
45
|
+
if (!items.length) {
|
|
46
|
+
container.append(node('div', { class: ui.card }, [
|
|
47
|
+
node('p', { class: ui.okLine, text: '✓ Nothing to fix.' }),
|
|
48
|
+
node('p', { class: ui.noteTight, text: 'MEMORY.md is inside its load limit, every memory file is referenced by it, every pointer resolves, and every [[wikilink]] finds its target.' }),
|
|
49
|
+
]));
|
|
50
|
+
} else {
|
|
51
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Worst first · ${items.length}` }));
|
|
52
|
+
for (const item of items.slice(0, SHOWN)) {
|
|
53
|
+
const row = COST_KINDS.has(item.kind) ? renderCostIssue(item) : renderIssue(item, memories);
|
|
54
|
+
if (row) container.append(row);
|
|
55
|
+
}
|
|
56
|
+
if (items.length > SHOWN) {
|
|
57
|
+
container.append(node('p', { class: ui.note, text: `${items.length - SHOWN} more not listed. Fix some of the above and they move up.` }));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
container.append(node('div', { class: ui.sectionLabel, text: 'Beyond the memory directory' }));
|
|
62
|
+
renderPathCheck(container);
|
|
63
|
+
}
|
package/public/views/context.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { node } from '/dom.mjs';
|
|
|
3
3
|
import { renderMarkdown } from '/markdown.mjs';
|
|
4
4
|
import { api } from '/api.mjs';
|
|
5
5
|
import { state } from '/state.mjs';
|
|
6
|
+
import { isAt } from '/parts.mjs';
|
|
6
7
|
|
|
7
8
|
const SCOPE_ORDER = ['managed', 'user', 'project', 'local'];
|
|
8
9
|
|
|
@@ -30,7 +31,7 @@ export async function renderContext(container) {
|
|
|
30
31
|
container.textContent = '';
|
|
31
32
|
return container.append(node('p', { class: ui.note, text: err.message }));
|
|
32
33
|
}
|
|
33
|
-
if (
|
|
34
|
+
if (!isAt('environment', 'instructions')) return;
|
|
34
35
|
}
|
|
35
36
|
container.textContent = '';
|
|
36
37
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as ui from '/ui.mjs';
|
|
2
|
+
import { node } from '/dom.mjs';
|
|
3
|
+
import { issue } from '/parts.mjs';
|
|
4
|
+
import { selectMemory } from '/store.mjs';
|
|
5
|
+
import { openMergeDialog } from '/dialogs/merge.mjs';
|
|
6
|
+
import { openMoveDialog } from '/dialogs/move.mjs';
|
|
7
|
+
import { openHookEditor } from '/dialogs/hook-editor.mjs';
|
|
8
|
+
|
|
9
|
+
export function renderCostIssue(item) {
|
|
10
|
+
if (item.kind === 'past-cutoff') {
|
|
11
|
+
const { entry } = item;
|
|
12
|
+
return issue(
|
|
13
|
+
`Past the cutoff, not loaded: ${entry.title || entry.file}`,
|
|
14
|
+
`MEMORY.md line ${entry.index + 1} · ${entry.file} - the file is on disk, but Claude never sees this entry.`,
|
|
15
|
+
{
|
|
16
|
+
bad: true,
|
|
17
|
+
secondary: { label: 'Open', run: () => selectMemory(entry.file) },
|
|
18
|
+
action: { label: 'Move up', run: () => openMoveDialog(entry) },
|
|
19
|
+
},
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (item.kind === 'long-hook') {
|
|
24
|
+
const { hook } = item;
|
|
25
|
+
const row = issue(
|
|
26
|
+
`${hook.hookLength}-character hook: ${hook.title}`,
|
|
27
|
+
`MEMORY.md line ${hook.index + 1} · ${hook.file} - the hook loads every session, so shortening it is the cheapest win available.`,
|
|
28
|
+
{ secondary: { label: 'Open', run: () => selectMemory(hook.file) } },
|
|
29
|
+
);
|
|
30
|
+
row.append(node('button', {
|
|
31
|
+
class: ui.buttonPrimarySmall,
|
|
32
|
+
text: 'Edit hook',
|
|
33
|
+
onclick: () => openHookEditor(row, hook.index),
|
|
34
|
+
}));
|
|
35
|
+
return row;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (item.kind === 'overlap') {
|
|
39
|
+
const { pair } = item;
|
|
40
|
+
return node('div', { class: ui.dupe }, [
|
|
41
|
+
node('div', { class: ui.dupeHead }, [
|
|
42
|
+
node('span', { class: ui.dupeScore, text: `${pair.score}%` }),
|
|
43
|
+
node('span', { text: `shared: ${pair.shared.join(', ')}` }),
|
|
44
|
+
]),
|
|
45
|
+
node('div', { class: ui.dupePair }, [pair.a, pair.b].map((side) => node('div', {
|
|
46
|
+
class: ui.dupeSide,
|
|
47
|
+
onclick: () => selectMemory(side.file),
|
|
48
|
+
}, [
|
|
49
|
+
node('div', { class: ui.dupeSideName, text: side.name }),
|
|
50
|
+
node('div', { class: ui.dupeSideDesc, text: side.description || side.file }),
|
|
51
|
+
]))),
|
|
52
|
+
node('div', { class: ui.dupeActions }, [
|
|
53
|
+
node('button', {
|
|
54
|
+
class: ui.buttonSmall,
|
|
55
|
+
text: `Merge into ${pair.a.name}`,
|
|
56
|
+
onclick: () => openMergeDialog(pair.a.file, pair.b.file),
|
|
57
|
+
}),
|
|
58
|
+
node('button', {
|
|
59
|
+
class: ui.buttonSmall,
|
|
60
|
+
text: `Merge into ${pair.b.name}`,
|
|
61
|
+
onclick: () => openMergeDialog(pair.b.file, pair.a.file),
|
|
62
|
+
}),
|
|
63
|
+
]),
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const COST_KINDS = new Set(['past-cutoff', 'long-hook', 'overlap']);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { renderSegmented } from '/views/segments.mjs';
|
|
2
|
+
import { renderContext } from '/views/context.mjs';
|
|
3
|
+
import { renderSettings } from '/views/settings.mjs';
|
|
4
|
+
import { renderSessions } from '/views/sessions.mjs';
|
|
5
|
+
|
|
6
|
+
export function renderEnvironment(container) {
|
|
7
|
+
return renderSegmented(container, 'environment', {
|
|
8
|
+
instructions: renderContext,
|
|
9
|
+
settings: renderSettings,
|
|
10
|
+
sessions: renderSessions,
|
|
11
|
+
});
|
|
12
|
+
}
|
package/public/views/header.mjs
CHANGED
|
@@ -2,62 +2,119 @@ import * as ui from '/ui.mjs';
|
|
|
2
2
|
import { el, node } from '/dom.mjs';
|
|
3
3
|
import { state, worstSeverity } from '/state.mjs';
|
|
4
4
|
import { paint } from '/bus.mjs';
|
|
5
|
+
import { buildWorklist } from '/views/worklist.mjs';
|
|
5
6
|
import { openRememberPathDialog, forgetProjectPath } from '/dialogs/remember-path.mjs';
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
-
{
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
const NAVIGATION = [
|
|
9
|
+
{
|
|
10
|
+
id: 'memory',
|
|
11
|
+
label: 'Memory',
|
|
12
|
+
segments: [
|
|
13
|
+
{ id: 'list', label: 'List' },
|
|
14
|
+
{ id: 'index', label: 'MEMORY.md' },
|
|
15
|
+
{ id: 'graph', label: 'Graph' },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
{ id: 'cleanup', label: 'Cleanup', segments: [] },
|
|
19
|
+
{
|
|
20
|
+
id: 'environment',
|
|
21
|
+
label: 'Environment',
|
|
22
|
+
segments: [
|
|
23
|
+
{ id: 'instructions', label: 'Instructions' },
|
|
24
|
+
{ id: 'settings', label: 'Settings' },
|
|
25
|
+
{ id: 'sessions', label: 'Sessions' },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
17
28
|
];
|
|
18
29
|
|
|
30
|
+
const hasProjectDir = (store) => (store.kind === 'auto'
|
|
31
|
+
? store.resolvedBy !== 'unresolved'
|
|
32
|
+
: Boolean(store.projectPath));
|
|
33
|
+
|
|
34
|
+
function segmentVisible(tab, segment, store) {
|
|
35
|
+
const global = store.kind === 'global';
|
|
36
|
+
if (tab === 'memory') {
|
|
37
|
+
if (global) return false;
|
|
38
|
+
return segment === 'index' ? Boolean(store.hasIndex) : true;
|
|
39
|
+
}
|
|
40
|
+
if (segment === 'instructions') return global || hasProjectDir(store);
|
|
41
|
+
if (global) return false;
|
|
42
|
+
if (segment === 'sessions') return Boolean(store.sessions?.count);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function segmentBadge(id) {
|
|
47
|
+
if (id === 'instructions') {
|
|
48
|
+
const problems = state.aux.instructions?.problems || [];
|
|
49
|
+
return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
|
|
50
|
+
}
|
|
51
|
+
if (id === 'settings') {
|
|
52
|
+
const problems = state.aux.settings?.problems || [];
|
|
53
|
+
return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
|
|
54
|
+
}
|
|
55
|
+
if (id === 'sessions') {
|
|
56
|
+
const sessions = state.store.sessions;
|
|
57
|
+
return sessions ? { badge: String(sessions.count), tone: sessions.expiringCount ? 'warn' : 'neutral' } : {};
|
|
58
|
+
}
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function segmentsFor(tab) {
|
|
63
|
+
const entry = NAVIGATION.find((item) => item.id === tab);
|
|
64
|
+
if (!entry || !state.store) return [];
|
|
65
|
+
return entry.segments
|
|
66
|
+
.filter((segment) => segmentVisible(tab, segment.id, state.store))
|
|
67
|
+
.map((segment) => ({ ...segment, ...segmentBadge(segment.id) }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function tabVisible(tab, store) {
|
|
71
|
+
if (tab === 'cleanup') return store.kind !== 'global';
|
|
72
|
+
return segmentsFor(tab).length > 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function tabBadge(tab, store) {
|
|
76
|
+
if (tab === 'memory') return { badge: String(store.memories.length), tone: 'neutral' };
|
|
77
|
+
|
|
78
|
+
if (tab === 'cleanup') {
|
|
79
|
+
const count = buildWorklist(store).length;
|
|
80
|
+
if (!count) return {};
|
|
81
|
+
const level = store.stats ? store.stats.index.level : 'ok';
|
|
82
|
+
const bad = level === 'over' || store.health.severity === 'bad';
|
|
83
|
+
return { badge: String(count), tone: bad ? 'bad' : 'warn' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const problems = [
|
|
87
|
+
...(state.aux.instructions?.problems || []),
|
|
88
|
+
...(state.aux.settings?.problems || []),
|
|
89
|
+
];
|
|
90
|
+
return problems.length ? { badge: String(problems.length), tone: worstSeverity(problems) } : {};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function undoControl() {
|
|
94
|
+
const count = state.store.trash?.length || 0;
|
|
95
|
+
const button = el('undo');
|
|
96
|
+
button.hidden = state.store.kind === 'global' || !count;
|
|
97
|
+
button.textContent = '';
|
|
98
|
+
button.append(
|
|
99
|
+
document.createTextNode('\u21ba Undo'),
|
|
100
|
+
node('span', { class: ui.tabBadge('neutral'), text: String(count) }),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
19
104
|
export function renderTabs() {
|
|
20
105
|
const container = el('tabs');
|
|
21
106
|
container.textContent = '';
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
for (const tab of TABS) {
|
|
28
|
-
if (global && tab.id !== 'context') continue;
|
|
29
|
-
if (!global && tab.id === 'context' && !hasProjectDir) continue;
|
|
30
|
-
if (tab.id === 'sessions' && !state.store.sessions?.count) continue;
|
|
31
|
-
let badge = null;
|
|
32
|
-
let tone = 'neutral';
|
|
33
|
-
if (tab.id === 'memories') badge = String(state.store.memories.length);
|
|
34
|
-
if (tab.id === 'trash' && state.store.trash.length) badge = String(state.store.trash.length);
|
|
35
|
-
if (tab.id === 'health' && state.store.health.issueCount) {
|
|
36
|
-
badge = String(state.store.health.issueCount);
|
|
37
|
-
tone = state.store.health.severity;
|
|
38
|
-
}
|
|
39
|
-
if (tab.id === 'prune' && state.store.stats.index.level !== 'ok') {
|
|
40
|
-
badge = '!';
|
|
41
|
-
tone = state.store.stats.index.level === 'over' ? 'bad' : 'warn';
|
|
42
|
-
}
|
|
43
|
-
if (tab.id === 'sessions' && state.store.sessions) {
|
|
44
|
-
badge = String(state.store.sessions.count);
|
|
45
|
-
if (state.store.sessions.expiringCount) tone = 'warn';
|
|
46
|
-
}
|
|
47
|
-
if (tab.id === 'context' && state.aux.instructions?.problems.length) {
|
|
48
|
-
badge = String(state.aux.instructions.problems.length);
|
|
49
|
-
tone = worstSeverity(state.aux.instructions.problems);
|
|
50
|
-
}
|
|
51
|
-
if (tab.id === 'settings' && state.aux.settings?.problems.length) {
|
|
52
|
-
badge = String(state.aux.settings.problems.length);
|
|
53
|
-
tone = worstSeverity(state.aux.settings.problems);
|
|
54
|
-
}
|
|
107
|
+
undoControl();
|
|
108
|
+
|
|
109
|
+
for (const entry of NAVIGATION) {
|
|
110
|
+
if (!tabVisible(entry.id, state.store)) continue;
|
|
111
|
+
const { badge, tone } = tabBadge(entry.id, state.store);
|
|
55
112
|
|
|
56
113
|
container.append(node('button', {
|
|
57
|
-
class: ui.tab(state.tab ===
|
|
58
|
-
onclick: () => { state.tab =
|
|
114
|
+
class: ui.tab(state.tab === entry.id),
|
|
115
|
+
onclick: () => { state.tab = entry.id; paint('tabs', 'tab'); },
|
|
59
116
|
}, [
|
|
60
|
-
document.createTextNode(
|
|
117
|
+
document.createTextNode(entry.label),
|
|
61
118
|
badge ? node('span', { class: ui.tabBadge(tone), text: badge }) : null,
|
|
62
119
|
]));
|
|
63
120
|
}
|
package/public/views/issue.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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: () =>
|
|
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: () =>
|
|
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 {
|
|
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
|
-
|
|
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
|
|
169
|
-
onclick: () =>
|
|
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
|
+
}
|