claude-memory-admin 1.5.1 → 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 +79 -124
- package/package.json +1 -1
- package/public/api.mjs +26 -0
- package/public/app.mjs +35 -2270
- package/public/bus.mjs +7 -0
- package/public/dialog.mjs +7 -0
- package/public/dialogs/add-entry.mjs +69 -0
- package/public/dialogs/bulk-delete.mjs +59 -0
- package/public/dialogs/delete.mjs +114 -0
- package/public/dialogs/hook-editor.mjs +54 -0
- package/public/dialogs/merge.mjs +81 -0
- package/public/dialogs/move.mjs +43 -0
- package/public/dialogs/remember-path.mjs +65 -0
- package/public/dialogs/store-delete.mjs +64 -0
- package/public/dialogs/trash.mjs +53 -0
- package/public/index.html +1 -0
- package/public/parts.mjs +65 -0
- package/public/state.mjs +48 -0
- package/public/store.mjs +121 -0
- package/public/styles.css +1 -1
- package/public/ui.mjs +44 -16
- package/public/views/cleanup.mjs +63 -0
- package/public/views/context.mjs +121 -0
- package/public/views/cost-issue.mjs +70 -0
- package/public/views/environment.mjs +12 -0
- package/public/views/header.mjs +183 -0
- package/public/views/index.mjs +122 -0
- package/public/views/issue.mjs +262 -0
- package/public/views/memories.mjs +156 -0
- package/public/views/memory-list.mjs +121 -0
- package/public/views/memory.mjs +32 -0
- package/public/views/path-check.mjs +103 -0
- package/public/views/search.mjs +100 -0
- package/public/views/segments.mjs +37 -0
- package/public/views/sessions.mjs +235 -0
- package/public/views/settings.mjs +121 -0
- package/public/views/stores.mjs +65 -0
- package/public/views/worklist.mjs +33 -0
- package/src/sessions.mjs +69 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import * as ui from '/ui.mjs';
|
|
2
|
+
import { node } from '/dom.mjs';
|
|
3
|
+
import { renderMarkdown } from '/markdown.mjs';
|
|
4
|
+
import { api } from '/api.mjs';
|
|
5
|
+
import { state } from '/state.mjs';
|
|
6
|
+
import { isAt } from '/parts.mjs';
|
|
7
|
+
|
|
8
|
+
const SCOPE_ORDER = ['managed', 'user', 'project', 'local'];
|
|
9
|
+
|
|
10
|
+
const CONTEXT_PROBLEMS = {
|
|
11
|
+
missing: (p) => ['Import does not resolve', `${p.spec} from ${p.from} - nothing at ${p.file}`],
|
|
12
|
+
'too-deep': (p) => ['Import chain is too deep', `${p.file} is a fifth hop; Claude Code follows four, so this never loads`],
|
|
13
|
+
cycle: (p) => ['Circular import', `${p.from} imports ${p.file}, which was already loaded`],
|
|
14
|
+
external: (p) => ['Import resolves outside the project', `${p.file} - Claude Code asks you to approve these once, and a declined one stays disabled silently`],
|
|
15
|
+
'invalid-glob': (p) => [`Rule matches nothing: ${p.pattern}`, `${p.file} - ${p.reason}`],
|
|
16
|
+
'glob-budget': (p) => ['Rule has too many brace expansions', `${p.file} expands to ${p.expansions} patterns, past the 1,000 budget, so it is used unexpanded and its literal braces match no file`],
|
|
17
|
+
'long-claude-md': (p) => [`${p.lines} lines, over the 200-line guidance`, `${p.file} - long files cost context every session and reduce adherence`],
|
|
18
|
+
'agents-md-not-imported': (p) => ['AGENTS.md is not loaded', `${p.file} exists, but Claude Code reads CLAUDE.md. Import it with @AGENTS.md, or symlink it.`],
|
|
19
|
+
'unreferenced-user-file': (p) => ['Nothing in the chain reaches this file', `${p.file} sits next to CLAUDE.md and loads nothing. Import it with @${p.file.split('/').pop()}, or delete it.`],
|
|
20
|
+
'duplicate-load': (p) => [`Loaded ${p.count} times: ${p.file}`, `reached from ${[...new Set(p.via)].join(' and ')} - every copy is read into context, so you pay ~${p.wastedTokens.toLocaleString()} extra tokens a session`],
|
|
21
|
+
'empty-instruction-file': (p) => ['Loads, but says nothing', `${p.file} is empty apart from its frontmatter, so it costs a read and contributes no instruction`],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function renderContext(container) {
|
|
25
|
+
let data = state.aux.instructions;
|
|
26
|
+
if (!data) {
|
|
27
|
+
container.append(node('p', { class: ui.note, text: 'Reading instruction files…' }));
|
|
28
|
+
try {
|
|
29
|
+
data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
container.textContent = '';
|
|
32
|
+
return container.append(node('p', { class: ui.note, text: err.message }));
|
|
33
|
+
}
|
|
34
|
+
if (!isAt('environment', 'instructions')) return;
|
|
35
|
+
}
|
|
36
|
+
container.textContent = '';
|
|
37
|
+
|
|
38
|
+
const global = state.store.kind === 'global';
|
|
39
|
+
if (!data.projectDir && !global) {
|
|
40
|
+
return container.append(node('p', { class: ui.note, text: 'This store is not tied to a project directory, so there are no instruction files to resolve.' }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { totals } = data;
|
|
44
|
+
container.append(node('div', { class: ui.meter }, [
|
|
45
|
+
node('div', { class: ui.meterTop }, [
|
|
46
|
+
node('span', { class: ui.meterValue, text: `~${totals.alwaysTokens.toLocaleString()}` }),
|
|
47
|
+
node('span', { class: ui.meterUnit, text: 'estimated tokens of instructions, every session' }),
|
|
48
|
+
]),
|
|
49
|
+
node('div', { class: ui.meterFacts }, [
|
|
50
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.files) }), document.createTextNode(' files resolved')]),
|
|
51
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.alwaysLines) }), document.createTextNode(' lines always loaded')]),
|
|
52
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${(totals.alwaysBytes / 1024).toFixed(1)} KB` }), document.createTextNode(' always loaded')]),
|
|
53
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: String(totals.conditionalFiles) }), document.createTextNode(' path-scoped rules, loaded only on a match')]),
|
|
54
|
+
]),
|
|
55
|
+
node('p', { class: ui.meterNote, text: 'Unlike MEMORY.md, none of this is truncated: CLAUDE.md files load in full however long they are. The 200-line figure is Claude Code’s guidance, not a cutoff, because long files cost context every session and are followed less reliably.' }),
|
|
56
|
+
]));
|
|
57
|
+
|
|
58
|
+
if (data.problems.length) {
|
|
59
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Problems · ${data.problems.length}` }));
|
|
60
|
+
for (const problem of data.problems) {
|
|
61
|
+
const describe = CONTEXT_PROBLEMS[problem.kind];
|
|
62
|
+
const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
|
|
63
|
+
container.append(node('div', { class: ui.issue(problem.severity === 'bad') }, [
|
|
64
|
+
node('div', { class: ui.issueBody }, [
|
|
65
|
+
node('div', { class: ui.issueTitle, text: title }),
|
|
66
|
+
node('div', { class: ui.issueDetail, text: detail }),
|
|
67
|
+
]),
|
|
68
|
+
]));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Loaded in this order · ${data.files.length}` }));
|
|
73
|
+
const card = node('div', { class: ui.card });
|
|
74
|
+
for (const file of data.files) {
|
|
75
|
+
const tags = [node('span', { class: ui.scopeBadge(file.scope), text: file.scope })];
|
|
76
|
+
if (file.kind === 'import') tags.push(node('span', { class: ui.badge(), text: `import · depth ${file.depth}` }));
|
|
77
|
+
if (file.kind === 'rule') tags.push(node('span', { class: ui.badge(), text: 'rule' }));
|
|
78
|
+
if (file.kind === 'managed-settings') tags.push(node('span', { class: ui.badge(), text: 'claudeMd setting' }));
|
|
79
|
+
if (file.conditional) tags.push(node('span', { class: ui.badge('warn'), text: 'only on a path match' }));
|
|
80
|
+
|
|
81
|
+
const body = node('div', { class: ui.contextBody, hidden: true });
|
|
82
|
+
const bodyProse = node('div', { class: ui.prose });
|
|
83
|
+
body.append(bodyProse);
|
|
84
|
+
const caret = node('span', { class: ui.contextCaret, text: '\u25b8' });
|
|
85
|
+
let rendered = false;
|
|
86
|
+
|
|
87
|
+
card.append(node('button', {
|
|
88
|
+
class: ui.contextRowButton,
|
|
89
|
+
'aria-expanded': 'false',
|
|
90
|
+
onclick: (event) => {
|
|
91
|
+
const open = body.hidden;
|
|
92
|
+
body.hidden = !open;
|
|
93
|
+
caret.textContent = open ? '\u25be' : '\u25b8';
|
|
94
|
+
event.currentTarget.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
95
|
+
if (open && !rendered) {
|
|
96
|
+
rendered = true;
|
|
97
|
+
renderMarkdown(bodyProse, file.text);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
}, [
|
|
101
|
+
node('div', { class: ui.contextMain }, [
|
|
102
|
+
node('div', { class: ui.contextTags }, tags),
|
|
103
|
+
node('div', { class: ui.contextFile }, [caret, document.createTextNode(file.file)]),
|
|
104
|
+
]),
|
|
105
|
+
node('div', { class: ui.contextSize, text: `${file.lines} L · ~${file.tokens.toLocaleString()} tok` }),
|
|
106
|
+
]));
|
|
107
|
+
card.append(body);
|
|
108
|
+
}
|
|
109
|
+
container.append(card);
|
|
110
|
+
|
|
111
|
+
if (data.excluded.length) {
|
|
112
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Excluded by claudeMdExcludes · ${data.excluded.length}` }));
|
|
113
|
+
for (const file of data.excluded) {
|
|
114
|
+
container.append(node('div', { class: ui.issue(false) }, [
|
|
115
|
+
node('div', { class: ui.issueBody }, [node('div', { class: ui.issueDetail, text: file })]),
|
|
116
|
+
]));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
container.append(node('p', { class: ui.meterNote, text: 'This is re-derived from the documented resolution rules, not a report from Claude Code. Run /context in a session to see what it actually loaded.' }));
|
|
121
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import * as ui from '/ui.mjs';
|
|
2
|
+
import { el, node } from '/dom.mjs';
|
|
3
|
+
import { state, worstSeverity } from '/state.mjs';
|
|
4
|
+
import { paint } from '/bus.mjs';
|
|
5
|
+
import { buildWorklist } from '/views/worklist.mjs';
|
|
6
|
+
import { openRememberPathDialog, forgetProjectPath } from '/dialogs/remember-path.mjs';
|
|
7
|
+
|
|
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
|
+
},
|
|
28
|
+
];
|
|
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
|
+
|
|
104
|
+
export function renderTabs() {
|
|
105
|
+
const container = el('tabs');
|
|
106
|
+
container.textContent = '';
|
|
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);
|
|
112
|
+
|
|
113
|
+
container.append(node('button', {
|
|
114
|
+
class: ui.tab(state.tab === entry.id),
|
|
115
|
+
onclick: () => { state.tab = entry.id; paint('tabs', 'tab'); },
|
|
116
|
+
}, [
|
|
117
|
+
document.createTextNode(entry.label),
|
|
118
|
+
badge ? node('span', { class: ui.tabBadge(tone), text: badge }) : null,
|
|
119
|
+
]));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function renderStoreHeader() {
|
|
124
|
+
const store = state.store;
|
|
125
|
+
el('project-title').textContent = store.label;
|
|
126
|
+
el('delete-project').hidden = store.kind === 'global';
|
|
127
|
+
|
|
128
|
+
const sub = el('project-sub');
|
|
129
|
+
sub.textContent = '';
|
|
130
|
+
|
|
131
|
+
if (store.kind === 'global') {
|
|
132
|
+
sub.append(node('span', { text: store.dir }));
|
|
133
|
+
sub.append(node('span', { class: ui.subNote, text: 'instructions every session loads, before any project is chosen' }));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (store.kind !== 'auto') {
|
|
138
|
+
const scope = store.kind === 'agent-user' ? 'user'
|
|
139
|
+
: store.kind === 'agent-project' ? 'project' : 'local';
|
|
140
|
+
sub.append(node('span', { text: store.dir }));
|
|
141
|
+
sub.append(node('span', { class: ui.subNote, text: `subagent memory, ${scope} scope` }));
|
|
142
|
+
if (store.kind === 'agent-project') {
|
|
143
|
+
sub.append(node('span', { class: ui.subWarn, text: 'checked into the repository - changes here show up in git status' }));
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
sub.append(node('span', { text: store.resolvedBy === 'unresolved'
|
|
149
|
+
? `${store.path} (real path unresolved)`
|
|
150
|
+
: store.path }));
|
|
151
|
+
|
|
152
|
+
const others = (store.workingDirs || []).filter((d) => d !== store.path);
|
|
153
|
+
if (others.length) {
|
|
154
|
+
sub.append(node('span', { class: ui.subNote, text: `also used from ${others.join(', ')}` }));
|
|
155
|
+
}
|
|
156
|
+
if (store.resolvedBy === 'unresolved') {
|
|
157
|
+
sub.append(node('button', { class: ui.linkButton, text: 'Remember path\u2026', onclick: openRememberPathDialog }));
|
|
158
|
+
} else if (store.resolvedBy === 'remembered') {
|
|
159
|
+
sub.append(node('span', { class: ui.subNote }, [
|
|
160
|
+
document.createTextNode('path remembered, not recovered from a transcript '),
|
|
161
|
+
node('button', { class: ui.linkButton, text: 'Forget', onclick: forgetProjectPath }),
|
|
162
|
+
]));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const evidence = store.sessions?.evidenceExpiresInDays;
|
|
166
|
+
if (!store.sessions?.remembered
|
|
167
|
+
&& (store.resolvedBy === 'transcript' || store.resolvedBy === 'repo-root')
|
|
168
|
+
&& typeof evidence === 'number' && evidence <= 14) {
|
|
169
|
+
sub.append(node('span', { class: ui.subWarn }, [
|
|
170
|
+
document.createTextNode(evidence === 0
|
|
171
|
+
? 'the last transcript proving this path is due to be swept - the store keeps its memories and loses its name '
|
|
172
|
+
: `the last transcript proving this path is swept in ${evidence} days, and nothing else records it `),
|
|
173
|
+
node('button', { class: ui.linkButton, text: 'Remember path\u2026', onclick: openRememberPathDialog }),
|
|
174
|
+
]));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const auto = store.autoMemory;
|
|
178
|
+
if (auto && auto.known && !auto.enabled) {
|
|
179
|
+
sub.append(node('span', { class: ui.subWarn, text: auto.scope === 'env'
|
|
180
|
+
? 'auto memory is off (CLAUDE_CODE_DISABLE_AUTO_MEMORY) - this store will not grow'
|
|
181
|
+
: `auto memory is off (${auto.setBy}) - this store will not grow` }));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as ui from '/ui.mjs';
|
|
2
|
+
import { node } from '/dom.mjs';
|
|
3
|
+
import { renderMarkdown } from '/markdown.mjs';
|
|
4
|
+
import { api, toast } from '/api.mjs';
|
|
5
|
+
import { state } from '/state.mjs';
|
|
6
|
+
import { paint } from '/bus.mjs';
|
|
7
|
+
import { openStore, selectMemory, restoreFromTrash } from '/store.mjs';
|
|
8
|
+
import { cutMarker } from '/parts.mjs';
|
|
9
|
+
import { memoryLookup, openTarget } from '/views/memories.mjs';
|
|
10
|
+
|
|
11
|
+
const INDEX_VIEWS = [
|
|
12
|
+
{ id: 'rendered', label: 'Rendered' },
|
|
13
|
+
{ id: 'source', label: 'Source' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function setIndexView(view) {
|
|
17
|
+
state.indexView = view;
|
|
18
|
+
localStorage.setItem('memoryIndexView', view);
|
|
19
|
+
paint('tab');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function indexViewPicker() {
|
|
23
|
+
return node('div', { class: ui.segmentGroup, role: 'tablist' }, INDEX_VIEWS.map((view) => node('button', {
|
|
24
|
+
class: ui.segment(state.indexView === view.id),
|
|
25
|
+
text: view.label,
|
|
26
|
+
role: 'tab',
|
|
27
|
+
'aria-selected': String(state.indexView === view.id),
|
|
28
|
+
onclick: () => setIndexView(view.id),
|
|
29
|
+
})));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function renderIndex(container) {
|
|
33
|
+
const project = state.store;
|
|
34
|
+
if (!project.hasIndex) {
|
|
35
|
+
container.append(node('p', { class: ui.note, text: 'This project has no MEMORY.md.' }));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const card = node('div', { class: ui.card });
|
|
40
|
+
card.append(node('div', { class: ui.cardHeadRow }, [
|
|
41
|
+
node('p', { class: ui.note, text: `${project.index.entries.length} index entries · ${project.index.lines.length} lines` }),
|
|
42
|
+
indexViewPicker(),
|
|
43
|
+
]));
|
|
44
|
+
|
|
45
|
+
if (state.indexView === 'source') renderIndexSource(card, project);
|
|
46
|
+
else renderIndexRendered(card, project);
|
|
47
|
+
|
|
48
|
+
container.append(card);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderIndexRendered(card, project) {
|
|
52
|
+
const { cutoff } = project.stats.index;
|
|
53
|
+
const files = new Set(project.memories.map((memory) => memory.file));
|
|
54
|
+
const byName = memoryLookup(project);
|
|
55
|
+
const options = {
|
|
56
|
+
resolveWikilink: (target) => openTarget(byName.get(target)),
|
|
57
|
+
resolveHref: (href) => openTarget(files.has(href) ? href : null),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const lines = project.index.raw.split('\n');
|
|
61
|
+
const loaded = node('div', { class: ui.prose });
|
|
62
|
+
renderMarkdown(loaded, cutoff ? lines.slice(0, cutoff.rawLine).join('\n') : project.index.raw, options);
|
|
63
|
+
card.append(loaded);
|
|
64
|
+
|
|
65
|
+
if (!cutoff) return;
|
|
66
|
+
card.append(cutMarker(cutoff));
|
|
67
|
+
const dropped = node('div', { class: ui.proseDim });
|
|
68
|
+
renderMarkdown(dropped, lines.slice(cutoff.rawLine).join('\n'), options);
|
|
69
|
+
card.append(dropped);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderIndexSource(card, project) {
|
|
73
|
+
const missing = new Set(project.health.danglingIndex.map((d) => d.index));
|
|
74
|
+
const { cutoff } = project.stats.index;
|
|
75
|
+
|
|
76
|
+
for (const line of project.index.lines) {
|
|
77
|
+
if (cutoff && line.index === cutoff.rawLine) card.append(cutMarker(cutoff));
|
|
78
|
+
|
|
79
|
+
const dangling = missing.has(line.index);
|
|
80
|
+
const dropped = Boolean(cutoff) && line.index >= cutoff.rawLine;
|
|
81
|
+
const clickable = line.kind === 'index' && !dangling;
|
|
82
|
+
const content = node('span', {
|
|
83
|
+
class: ui.indexLineText({ kind: line.kind, clickable, dropped }),
|
|
84
|
+
text: line.text || ' ',
|
|
85
|
+
});
|
|
86
|
+
if (clickable) content.onclick = () => selectMemory(line.file);
|
|
87
|
+
|
|
88
|
+
card.append(node('div', { class: ui.indexLine({ dangling, dropped }) }, [
|
|
89
|
+
node('span', { class: ui.indexLineNumber, text: String(line.index + 1) }),
|
|
90
|
+
content,
|
|
91
|
+
]));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function indexEntryAt(lineIndex) {
|
|
96
|
+
return state.store.index?.entries.find((entry) => entry.index === lineIndex) || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function sectionsAbove(limit) {
|
|
100
|
+
const seen = [];
|
|
101
|
+
for (const line of state.store.index?.lines || []) {
|
|
102
|
+
if (line.kind !== 'heading') continue;
|
|
103
|
+
if (limit !== null && line.index >= limit) continue;
|
|
104
|
+
if (line.section && !seen.includes(line.section)) seen.push(line.section);
|
|
105
|
+
}
|
|
106
|
+
return seen;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function runIndexEdit(action, body, message) {
|
|
110
|
+
try {
|
|
111
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/${action}`, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
body: JSON.stringify(body),
|
|
114
|
+
});
|
|
115
|
+
await openStore(state.storeId, { keepTab: true });
|
|
116
|
+
toast(message(result), { action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) } });
|
|
117
|
+
return result;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
toast(err.message, { error: true });
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|